こんにちは。新田です。
今回はRedisのデータのバックアップ/リストアを2コマンドで行う方法について説明します。
ただバックアップ/リストアするだけであれば、 redis-cli bgsave
して、 redis-cli lastdump
でrdbファイルのパスを特定し、 そのファイルをなんとか取り出して 正しいパスに配置し、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の挙動についてはこちらの記事の図解が大変にわかりやすいのでご参照ください。
(対話的でなく)一方的に沢山コマンドを送りつけるようなユースケースでは、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で充分ということに記事を書き始めてから気づいて萎えてしまったので雑な記事になってしまいましたが以上です。
システム開発はロジックを考えるパートやコードを書くパートよりも、周辺環境やドメインについてよく調べよく知ることが重要です。