さて、前回に続いてRedisのロックについて書いていきます。
前回、Redisでデータの整合性を担保するには2フェーズコミットが必要であるといいました。
で、Redisで素朴にそれを実現するためにwatch multi execコマンドも見ました。
ここではそのやり方を更に一歩進めようと思います。
Redisのロックは基本的に楽観的なロックですが、次の疑似コードを見てください。
while True:
try:
pipe.watch
pipe.hoge()
pipe.hpge()
pipe.exec()
catch:
path
filelly:
return
これは他の人が同じデータにアクセスした際、通知を受け取って、もう一回やり直す。ということを言っていますが、これだと、例えばたくさんの人が一気にデータにアクセスすると、自分は長い間このデータにアクセスできません。
つまり、whileでデータの一貫性を監視することは大事だが、何も毎回この処理を連発する必要はないということがいいたい訳です。
そこでロックです。
1ロックの実装方法
ロックの実装ですが、非常に単純な方法で十分に効果が出ます。
pythonっぽいコードで見てみましょう
ex)
import uuid
def get_lock(conn, name):
id = str(uuid.uuid4())
if conn.setnx("lock:" + name, id):
return True
else:
return False
def rel_lock(conn, name):
conn.delete('lock:' + name)
return True
これは何をしているかというと単純にsetnxでロックの名前に対してidを振っているだけです。
key: lock:yoshiya, value: 1j3i5f
的な。
もしロックが獲得できなければfalseを返します。
で、実際にこのロックを使う立場としては
def something(conn):
lock = get_lock(conn, "yoshiya")
if not lock:
return False
pipe = conn.pipeline()
pipe.hoge()
pipe.hoge()
pipe.exec()
rel_lock(conn, 'yoshiya')
return True
こんな感じにします。ここでwhile, watchが抜けていることにお気づきだろうか?
代わりにlockがあったら即座にreturnしている。
こうすることで、そもそもpipelineの処理が走る前にそのデータを使っていいものか何なのかを事前にチェックして、大丈夫であれば実行し、そう出なければクライアントに"無理でしたエラー"を通知することになります。
すると、watchすることでたくさんの人が同じデータをいじっているとき、whileで何回もやり直されている部分が消えます。故にパフォーマンスが伸びます。ただし、複数人が同時にロックの獲得をしにきた場合、やはりここでも結局同じような事が起きています。その場合、このロック獲得だけにwhile + watchをかけるべきです。こうする事でループで繰り返される処理が最小限になり、パフォーマンスは楽観ロックより確保できる事になります。
余談ですが、 josiah L. CarlsonとPRのやり取りの結果、
Redis in Actionの P161~ P162の リスト6-9のコードで、ロックをかけているけれどもwatchされているので、これだとロックのうまみがあまり無い。
故にロックをかけているときはなるべくwatchしたくないという結論になりました。
このように、ロックをかけて、watchの中でたくさんのコードが走る事を押さえる事でパフォーマンスをかなり向上させる事ができるようです。どの程度かは結局サービスの規模によりますのでベンチマークはとりません。
さらに、このロックに粒度を細かくすることで、更にパフォーマンスを向上させる事も出来ます。しかし、そこには罠もあります。
2 デッドロック
上のコードは至極単純で、素朴な書き方ですが、本番ではこんな書き方はまずいです。なぜなら、ロックしたんだけどロック解放する前に切断しちゃった。
ってなったら一生ロックが解放されませんので、誰もそのロックを獲得できなくなります。これは所謂デッドロックになっているわけです。粒度を細かくすればするほど、コードの書き方は難しくなっていき、何らかの不注意が起きる事でデッドロックが発生するかもしれません。
じゃあどういうふうに対策をするのか
3 タイムアウト
一定時間、そのロックを抱えたクライアントがいたとする、その時、制限時間を越えてロックを抱えているようなら、そのクライアントの処理を一切破棄して、ロックを解放するという方法がベストな解決策でしょう。その例がしたのpython的な疑似コード。
def lock_whith_timeout(conn, name, timeout=10):
id = str(uuid.uuid4())
lock = 'lock:' + name
if conn.setnx(lockname, id):
conn.expire(lockname, timeout)
return True
else not conn.ttl(lockname):
conn.expire(lockname, lock_timeout)
return False
何をしているのかというと、さっきのコードに追加して単にexpireで時限爆弾を仕込んでいるだけです。長い時間ロックが解放されず、放置されると expireが発動してロックが消えます。
なんかこのコード、elseの下でもconn.setnxすればよさそうな気がしない訳でもないですが、これできっちり動作します。(expireで時間切れするとsetされたキーが消されるためロックが一生獲得できない事はなくなる。)
さて、ロックとトランザクションの話はこれでおしまいにしますが、深いことやろうとするといろいろあります。ロックを複数人で取れるようにする計数セマフォとか、要求を全てキューにして、絶対にその順番で処理させる事で競合をさけるとか。タスク自体を遅延させる(Node.js的な)とかいろんな手法がありますが、今回、特に簡単にできる割には効果のある方法を紹介しました。
次回、redisの総集編としてコマンドの復習とかメモリをケチる方法、手で書くシャーディングとかやるかもしれません。。。。が、時間を使いすぎている感が....
他にはNginx vagrantあたり紹介したいな。