WebアプリをDockerやKubernetes上にデプロイすると、Podをスケールアウトして同時に複数ユーザーを処理できるようになります。
しかしここでよくある疑問が、
「もしPod AとPod Bが同じデータベースの同じレコードを同時に更新したらどうなるの?」
という問題です。今回はこの仕組みについて解説します。
1. 誰が競合を解決するのか?
- Kubernetes:解決しない(Podのスケジューリングやネットワークを管理するだけ)
- Pod(アプリケーション):競合検出はできない(ただしエラーを受けてリトライはできる)
- データベース:解決の主体。ロックやトランザクションで整合性を守る
結論から言うと、最終的に競合を処理するのはデータベースです。
2. データベースの仕組み
データベースには、複数のクライアント(Podなど)が同時に書き込みをしても矛盾が生じないようにするための仕組みがあります。
トランザクション分離レベル
代表的なものは次の通りです(PostgreSQL/MySQL)。
-
READ COMMITTED(デフォルト)
コミット済みのデータだけを読む。二重更新はエラーになる。 -
REPEATABLE READ
トランザクション中の読み取り結果を固定する。 -
SERIALIZABLE
すべてを直列実行されたかのように扱い、競合があるとエラーを返す。
ロック
-
行ロック(Row Lock)
更新対象の行だけをロック。他のPodはその行を更新できない。 -
テーブルロック(Table Lock)
テーブル全体をロックする(大規模アプリでは滅多に使わない)。
3. Railsアプリ側での対応方法
Rails(ActiveRecord)には、DBが返すエラーをうまく処理する仕組みがあります。
悲観的ロック(Pessimistic Lock)
「どうせ競合するなら最初からロックしてしまえ」という考え方。
user = User.lock.find(1) # SELECT ... FOR UPDATE が発行される
user.update!(balance: user.balance - 100)
この場合、他のPodが同じユーザーを更新しようとすると、処理が終わるまで待たされます。
楽観的ロック(Optimistic Lock)
「そんなに頻繁に競合しないはず。競合したらその時考えよう」という考え方。
ActiveRecordに lock_version カラムを追加すると、自動的に楽観的ロックが有効になります。
user = User.find(1)
user.update!(balance: user.balance - 100)
もし別のPodが先に同じ行を更新していた場合、Railsは ActiveRecord::StaleObjectError を発生させます。
これを捕まえてリトライ処理を入れるのが一般的です。
リトライ処理
競合エラーが発生した場合に「もう一度やり直す」仕組みを入れておくと安定します。
begin
user = User.find(1)
user.update!(balance: user.balance - 100)
rescue ActiveRecord::StaleObjectError
retry
end
4. 実際の運用ではどうする?
-
データベースが 整合性を守る最後の砦 になる
-
アプリ(Rails)は エラーをキャッチしてどう再処理するか を設計する
-
高トラフィック環境では
- トランザクション + ロックの適切な使い分け
- 楽観的ロックで競合検出
- リトライ戦略の実装
が基本となります
まとめ
KubernetesでPodを増やすとアプリはスケーラブルになりますが、データの整合性はDBに任せるのが大原則です。
その上で、Railsアプリは 「競合が起きたときにどう振る舞うか」 を設計する必要があります。
つまり、
- Pod同士はDBで調停される
- RailsはDBエラーをどう処理するかを実装する
この役割分担を理解しておくと、Kubernetesでのアプリ運用がぐっと安心になります。