AGRISMILE DEV BLOG

株式会社AGRI SMILEの開発ブログです。

Redisのダンプ/リストアを2コマンドで行う

こんにちは。新田です。
今回はRedisのデータのバックアップ/リストアを2コマンドで行う方法について説明します。
ただバックアップ/リストアするだけであれば、 redis-cli bgsave して、 redis-cli lastdumprdbファイルのパスを特定し、 そのファイルをなんとか取り出して 正しいパスに配置し、Redisを再起動する方法が正攻法です。

しかし今回バックアップ対象のRedisサーバーがマネージドサービス下で、ダンプファイルを取り出したりファイルを配置することができません。つまり

そのファイルをなんとか取り出して

これができない場合の方法について書きます。

DUMP と PTTL と RESTOREコマンド

Redisのコマンドには

があります。

DUMPは、指定したkeyの(任意の型の)データをシリアライズして文字列として返してくれます。このとき、そのキーに設定されたTTL(有効期限)データは含まれません。
PTTLは、指定したkeyの有効期限(までの残り時間)をミリ秒単位で返してくれます。
RESTOREは、keyとTTLデータとDUMPデータから値を復元してくれます。

ということはすべてのキーについてDUMPとPTTLをコールして、返り値をRESTOREすると、バックアップ&リストアできます。
ただ、この方法を愚直にやると通信が沢山発生してしまいます。

# 疑似コード
client = RedisClient.new
keys = client.keys('*')
backup_data = []
keys.each do |key|
  dump = client.dump(key)
  ttlms = client.pttl(key)
  backup_data.push([key, ttlms, dump])
end
backup_data.each do |payload|
  redis.restore(payload[0], payload[1] <= 0 ? 0 : payload[1], payload[2])
end

MULTI

いちいち通信発生させたくない人向けに MULTI というコマンドが用意されています。

MULTIの挙動についてはこちらの記事の図解が大変にわかりやすいのでご参照ください。

christina04.hatenablog.com

(対話的でなく)一方的に沢山コマンドを送りつけるようなユースケースでは、MULTIが便利です。
本件においても MULTI でやってしまうのがよいでしょう。

# 疑似コード
client = RedisClient.new
keys = client.keys('*')
backup_data = []
redis.multi do
  keys.each do |key|
    dump = client.dump(key)
    ttlms = client.pttl(key)
    backup_data.push([key, ttlms, dump])
  end
end
redis.multi do
  backup_data.each do |payload|
    redis.restore(payload[0], payload[1] <= 0 ? 0 : payload[1], payload[2])
  end
end

が、当初私が勘違いで「MULTIはコマンドを打つたびに通信が発生する」と思い込んでいたため、もう1通りソリューションを考えていて、捨てるのももったいないので共有します。

EVAL

EVAL という、任意のluaスクリプトを実行できるコマンドがあります。

これを利用して、ダンプ用のコマンド1コール、リストア用のコマンド1コールで行ってしまうコードが以下です。

import Log from '../middlewares/Log';
const Redis = require('ioredis');

class RedisService {
  // https://stackoverflow.com/questions/34618946/lua-base64-encode から借用
  static Base64LuaScript: string = `
    local b='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
    local function enc(data)
        return ((data:gsub('.', function(x)
            local r,b='',x:byte();
            for i=8,1,-1 do r=r..(b%2^i-b%2^(i-1)>0 and '1' or '0') end
            return r;
        end)..'0000'):gsub('%d%d%d?%d?%d?%d?', function(x)
            if (#x < 6) then return '' end
            local c=0;
            for i=1,6 do c=c+(x:sub(i,i)=='1' and 2^(6-i) or 0) end
            return b:sub(c+1,c+1);
        end)..({ '', '==', '=' })[#data%3+1]);
    end

    local function dec(data)
        data = string.gsub(data, '[^'..b..'=]', '');
        return (data:gsub('.', function(x)
            if (x == '=') then return '' end
            local r,f='',(b:find(x)-1);
            for i=6,1,-1 do r=r..(f%2^i-f%2^(i-1)>0 and '1' or '0') end
            return r;
        end):gsub('%d%d%d?%d?%d?%d?%d?%d?', function(x)
            if (#x ~= 8) then return '' end;
            local c=0;
            for i=1,8 do c=c+(x:sub(i,i)=='1' and 2^(8-i) or 0) end
            return string.char(c);
        end));
    end;
  `;

  public static async getAllDump(redisUrl: string): Promise<string> {
    const redis = Redis(redisUrl);
    try {
      const backupLuaScript = `
      ${this.Base64LuaScript}
      local keys = redis.call('keys', '*');
      local ret = {};
      for i, key in ipairs(keys) do
        ret[i] = { enc(key), redis.call('PTTL', key), enc(redis.call('DUMP', key))};
      end;
      return ret;
    `.replace(/\n/g, ' ');
      const dumpData: [string, number, string][] = await redis.eval(backupLuaScript, 0);
      return dumpData.map((r) => r.map(String).join(',')).join('#');
    } catch (e) {
      Log.error(e.message);
      throw e;
    } finally {
      redis.disconnect();
    }
  }

  public static async restoreDump(redisUrl: string, dumpStr): Promise<string> {
    const redis = Redis(redisUrl);

    try {
      const restoreLuaScript = `
    ${this.Base64LuaScript}
      local input_str = "${dumpStr}";
      local function split_string(str, delimiter)
          local result = {};
          for match in (str..delimiter):gmatch("(.-)"..delimiter) do
              table.insert(result, match);
          end
          return result;
      end

      local lines = split_string(input_str, "#");

      for i, line in ipairs(lines) do
          local elements = split_string(line, ",")
          if #elements ~= 3 then
              return redis.error_reply("3要素以上ある行があります");
          end

          if tonumber(elements[2]) <= 0 then
              elements[2] = 0;
          end

          redis.call("RESTORE", dec(elements[1]), elements[2], dec(elements[3]))
      end

      return "OK"
    `.replace(/\n/g, ' ');
      return (await redis.eval(restoreLuaScript, 0)) as string;
    } catch (e) {
      Log.error(e.message);
      throw e;
    } finally {
      redis.disconnect();
    }
  }
}

const redisUrl = 'rediss://~~';
const targetRedisUrl = 'rediss://~~';
const dumpData = await RedisService.getAllDump(redisUrl);
await RedisService.restoreDump(targetRedisUrl, dumpData);

コードの内容を日本語で説明するなら、
luaでkeyをすべて取得しそれぞれについて [keyN, pttl, dump] を取得。 key1,pttl,dump#key2,pttl,dump#key3,pttl,dump... の独自形式で保管。それをリストア用luaスクリプトにそのまま投げて、luaでパースし各個RESTOREを行う。このとき、独自形式はコンマとシャープ記号で区切っているので、コンマとシャープ記号が入りうるkeyとdumpについてはエスケープのためBase64変換して扱う。
というものです。

MULTIを使う場合は3コマンド(keys + dump + restore)になりますが、この方法だと2コマンドで行けて便利と言うことができるかもしれません。
もう一つメリットを強いて挙げるなら、クライアントサイドでの処理は文字列のやり取りぐらいで済み、キーごとにループを回すような処理をRedisサーバー側に押し付けることができるというのがあります。

単純なGETのケースで調べてみると

# multi version
redis.multi do |pipe|
  100000.times do |i|
    pipe.get('somekey')
  end
end

# eval version
script = <<~LUA
  local ret = {}
  for i = 1, 100000 do
    redis.call('GET', 'somekey')
  end
  return ret
LUA

redis.eval(script)

通信回数はあまり変わりませんが、送信するデータ量を考えると、また実行速度を見てみると後者のほうが優れていますし、この差はキーが増えるごとに差が開くことになります。 とはいえコードの保守性の観点からは前者のほうが遥かに優れていると言えるはずです。

おわりに

MULTIで充分ということに記事を書き始めてから気づいて萎えてしまったので雑な記事になってしまいましたが以上です。
システム開発はロジックを考えるパートやコードを書くパートよりも、周辺環境やドメインについてよく調べよく知ることが重要です。