どうも、すなです
業務でMySQLに特定の名前でロックをかけるという作業を行った
多分だけど排他制御の一種だと思ってる。
ただ通常のトランザクションで使うような特定のレコードを対象にした排他制御ではなくて、DB全体に対する特定のキーワードでの排他制御。
そんなことできるのねーと中々便利に思ったのでメモしておく
本題とズレるけどGolangでのdeferの使い方とかも一緒に勉強できてよかったのでメモしておく
背景
どういう背景でこの機能を使用することになったかというと、特定の日時で予約を取る機能があって、数m秒単位しか違わないタイミングで同時に同じ日時で予約を取ると2重に予約が取れてしまうという状況が発生してしまっていた。
詳細な原因は、API中の特定の関数の中で「レコードがないことを確認 → テーブルにレコードを挿入する」という処理があるんだけど、数m秒しか違わないとレコードが挿入された情報の同期を取る前にレコードない判定になってしまって両方レコード挿入してしまっていた。
// 数m秒しか違わないタイミングで同時にこの関数が呼ばれるとどちらも予約がない判定になってしまっていた
func 予約をとる関数() {
予約がないことを確認する処理
↓
予約をとる処理
}
この数m秒の違いでも先に予約処理がきた方を優先させるためにMySQLでロックをかける処理を行った
MySQLのロックとは?
どう対処したかというと、処理を以下のような感じにする
func 予約をとる関数() {
DBに"20210414"みたいな特定の日時でDBロックをかける
↓
予約がないことを確認する処理
↓
予約を取る処理
↓
DBロックを解放する
}
このロックとはどういうものかというと、キーワードみたいなものでDBにロックをかける
とは言ってもその間DBは普通にアクセスできる。
ポイントはロックがかかった状態で別のDBコネクションから同じキーワードでロックをかけようとするとそこで処理が止まるということ
なので今からこの日時の予約を取るぞ!と宣言した場合に、他のコネクションでその日時で予約が取りたかったらその宣言が解放されるまで待ち続けるということになる
したがって予約がないことを確認する処理の前にこのロックをかける処理を入れておけば、以下のようにダブルブッキングを防ぐことができる。
APIコール1で20210414で予約を取ろうとしてロックをかける
→ APIコール1で予約がないことを確認する処理
→ APIコール2で20210414で予約を取ろうとしてロックをかけようとするが、既に20210414でロックがかかっているため待機
→ APIコール1で予約処理
→ APIコール1でロック解放
→ ロックが解放されたのでAPIコール2で20210414でロックをかける
→ APIコール2で予約がないことを確認するが予約があるので処理終了(ダブルブッキングしない)
(文字だけで書くとわかりにくいが図を用意するのが面倒かった。。)
ちなみにロックがかかっていた場合に待機しつづける時間は指定できる。
こうすることで数m秒しか違わないタイミングでもダブルブッキングを防いで、先勝ちで処理を優先させることができるようになった
実際のコード
SQL
ロック処理
lock_nameという名前でロックするようにしている。既にロックがかかっていた場合は5秒間待ち続ける
SELECT GET_LOCK("lock_name",5)
ロック解除
SELECT COALESCE(RELEASE_LOCK("lock_name"), 2)
ロック解除は名前付きのロックが存在しない場合はnilで返ってくるような仕様になってるんだけど、それだとGoで実装した時に返り値の型がintに統一できないのでCOALESCEを使ってnilだった場合に2で置き換えるような処理にしている
ちなみに特定の名前でロックがされているかどうかを調べるには以下の方法
SELECT IS_FREE_LOCK("lock_name")
Goでの実装
実際のDB操作部分はrepository層で別関数として実装している
func (interactor *Reservation) SaveReservation() (res Responseの型, err error) {
...
lockName := "test"
timeOut := 5 //既に上記のlockNameでロックがかかっていた場合、5秒待つ
// トランザクション開始
err = interactor.transactionRepository.Begin()
if err != nil {
return nil, err
}
// 途中で万が一ヌルポなどでpanicになった時にDBロックを解放してからロールバックする
defer func() {
if r := recover(); r != nil {
// ロック解除処理
setNewError(interactor.reservationRepository.ReleaseLock(lockName), &err)
if err != nil {
fmt.Printf("ReleaseLock err, lockName ==> %v", lockName)
}
interactor.transactionRepository.Rollback()
}
}()
// これ以降の処理では最後に必ずDBロックを解放してからCommit or Rollbackする
defer func() {
// ロック解除処理
setNewError(interactor.reservationRepository.ReleaseLock(lockName), &err)
if err != nil {
fmt.Println(err.Error())
interactor.transactionRepository.Rollback()
} else {
err = interactor.transactionRepository.Commit()
if err != nil {
interactor.transactionRepository.Rollback()
}
}
}()
// DBロック処理
ok, err := interactor.reservationRepository.GetLock(lockName, timeOut)
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("DBのロックができなかった")
}
...
}
ポイントはトランザクションを開始した後にすぐにdeferでロックを解除する処理を書いておくこと
deferは関数が終わる直前のタイミングで呼ばれるのでここで必ずDBロックを解除するようにしている
その後Commit or Rollbackする
このDBロックはDBコネクションが切れたタイミングで外れるらしいけど、ちゃんと明示的にいかなる場合もロック解除するようにしておく
setNewErrorという関数を挟んでいるのは、defer内では返り値が設定できないためそれまでのerrオブジェクトに上書きするようにしている
ロック解除時にエラーが起こった場合に、それをそれまでのerrオブジェクトにエラー内容を追加することでログに残すようにするという意図
実際にはsetNewError関数は以下のようにしてある
// deferで返り値が設定できないため、ポインタを使ってcurrentErrを上書きするための関数
func setNewError(newErr error, currentErr *error) {
// それまでにエラーが発生していなければnewErrで上書きしてレスポンス
if *currentErr == nil {
*currentErr = newErr
return
}
// それまでにエラーが発生していて、deferでもエラーが発生した場合はエラーメッセージを追加する
if newErr != nil {
*currentErr = fmt.Errorf((*currentErr).Error() + ” ” + newErr.Error())
}
}
これで排他制御ができるようになったし、万が一エラーが起きてもちゃんとロックが解除されてログも残せるようになった
ちょっと運用してみたけど変なエラーとか起こってなかったし、多分大丈夫だと思う
まとめ
特定のレコードに対する排他制御は悲観ロックとかを使えば良さそう。使ったことないけどMySQLならSELECT文の最後に FOR UPDATE
とかを付ければいいらしい
特定のレコードではなくそもそもレコードを挿入するかどうかという場合はDB全体にロックをかける仕組みが必要。
でもやたらめったらロックかけるとまずいので特定のキーワードでロックをかけるようにする。
ロックがかかってたら待機する
この仕組みうまいなーと思った
調べたら行レベルのロックじゃなくてテーブルレベルのロックとかもあるらしい
DBって普段使わないけど重要な機能たくさんありそう
参考
https://dev.mysql.com/doc/refman/5.7/en/locking-functions.html
コメント