時間に依存したコード
サーバーサイドのアプリケーションを開発する際、インスタンスの時間に依存したコードを書くことは多いと思います。例えばチャットアプリケーションのAPIサーバーをイメージしてみてください。メッセージ投稿APIでアプリケーションで現在時刻を投稿時間としてセットしてDBにインサートし、メッセージ取得APIではDBに入っているレコードを投稿時間順に並び替えて取得する、といった感じでしょうか。
メッセージ投稿APIで使用した投稿時間
をアプリケーションでセットしましたが(Goならtime.Now()
、RubyならTime.now
とか)、その値は信用できるものなのでしょうか。
インスタンスの時間は信用できない
そもそもインスタンスの時間はどうやって決まっているのかというところですが、サーバーの時刻は一般的にNTP(Network Time Protocol)という仕組みを使って同期が取られています。定期的にNTPサーバーと通信を行い、NTPサーバーが返す時間と大幅にずれていたら時間を進めるor戻すといったことを行っています。そのためこの精度はNTPサーバーと通信を行う間隔、ネットワークの遅延、ハードウェアの故障等様々な要因によって左右されてしまいます。一般論としてズレは数十ms以内に収まるようですが、必ずその範囲に収まるわけではないのでズレを許容するもしくはインスタンスの時間を使用しないなど、アプリケーション側で対応する必要があります。
ちなみに金融機関等、時刻の正確性がかなりの精度で求められるところではハードウェアに十分な投資を行い精度を高めているそうです。
問題が発生した例
では実際にインスタンスの時間に依存したコードを書いていたがために問題が発生した例を紹介します。
環境
- Go v1.15
- GKE
- Cloud Spanner
背景
データ(仮にStorages
テーブルとします)の変更履歴を保存し競合を防ぐためのRevisions
テーブルというものがあります。
Revisions
テーブル
name | type |
---|---|
ID | STRING |
Revision | STRING |
CreatedAt | TIMESTAMP |
storageを追加するリクエストには最新のRevisionも一緒に含め、DBに入っている最新のRevisionと比較することで競合を防ぐ仕組みになっています。
// クライアントからのリクエスト
// Storage: 新しく更新したいストレージの内容
// PrevRevision: 一つ前のRevision
// NextRevision: 次のRevision
req := getRequest()
// CreatedAtで並び替えて最新のrevisionをDBから取得する
rev := getLatestRevision()
// Revisionが競合しているためエラー
if req.PrevRevision != rev {
return errors.New("invalid revision")
}
// 最新のrevisionを作成してDBにINSERTする
new := &Revision{
ID: uuid,
Revision: req.NextRevision,
CreatedAt: time.Now(), // 現在時刻をセット
}
insertRevision(new)
// わかりやすくするため簡略化しています
// 上記のコードはspannerのtransaction内で実行しています
発生した問題
膨大なリクエストを送るとたまにinvalid revision
エラーが発生していました。リクエストのログによると問題なく直列に正しいRevisionを指定して送信していました。しかしDBに入っているレコードを見ると、CreatedAtで並び替えた時にリクエストの逆の順序でレコードが挿入されていました。
このCreatedAtは正確にはDBにコミットされたタイミングでのDB上の時間
ではなく、DBにコミットするタイミングでのpod上の時間
なため、リクエストを処理した実際の時間とpodの時間の順序が逆転してしまったためにこの問題が発生したと考えました。
リクエスト
- リクエストは直列(前のレスポンスが返ってきてから次のリクエストを送信する)に実行していた
- リクエストには正しいPrevRevisionが指定されていた(前回のリクエストで設定したNextRevision)
- リクエストはそれぞれ別のpodに送られている
- リクエストは1つあたり数十msで完了している
1. {prevRevision: a, nextRevision: b} -> ok
2. {prevRevision: b, nextRevision: c} -> ok
3. {prevRevision: c, nextRevision: d} -> invalid revision error
DB
- CreatedAtで並び替えた時、Revisionがa->bの順序で入っているのが正しいが、実際はb->aの順序で入っていた。
Revision | CreatedAt |
---|---|
a | 2020-01-01T00:00:00.001000Z |
b | 2020-01-01T00:00:00.000000Z |
解決策
RevisionsテーブルのCreatedAtカラムにはDBにコミットされたtimestampを入れるようにコードを変更することでinvalid revision
エラーは発生しなくなりました。(Cloud Spanner の commit_timestamp)。
まとめ
インスタンスの時間に依存したシステムを作るときは、その時間は必ずしも正確ではなくずれる可能性がある、ということを頭の片隅においておくと良いと思います。
参考リンク
この記事を読んで「面白かった」「学びがあった」と思っていただけた方、よろしければ Twitter や facebook、はてなブックマークにてコメントをして頂けると嬉しいです。
また DeNA 公式 Twitter アカウント @DeNAxTech では、 Blog記事だけでなく色々な勉強会での登壇資料も発信してます。こちらもぜひフォローして頂けると嬉しいです。
Follow @DeNAxTech