並行性の問題と解決策

並行性の問題は本当に厄介だ。

私は3年間、アプリテックサービスをMSA環境で開発・運用してきた。恥ずかしい話だが、「ポイントを使ったクーポン発行」のように整合性が重要な業務で、並行性の問題が発生したことがある。

この記事は、次の開発ではもっと良くやろうという自分へのリマインダーとして、そして読みに来てくれたあなたにも何か持ち帰ってもらえたらという気持ちで書いている。

問題の定義

並行性の問題とは、複数のスレッドやトランザクションが同時に同じデータへアクセスして値を変更するときに、予期しない結果が生じる現象を指す。

簡単な例として、ソン・シギョンのコンサートが飛ぶように売れ、最後の1席だけが残っているとしよう。 その1席をめぐって100人が本当に同時に予約ボタンを押したらどうなるだろうか。100件の予約リクエストが本当に同時にサーバーへ届いたらどうなるだろうか。

1席に100人を座らせたくなければ、開発者であるあなたがこの問題を賢く解決しなければならない。

解決方法の紹介

大きく分けると、解決方法は3つある。

1つ目: 事後処理する方法

翌日出社してデータを確認し、99人に謝罪のメッセージを送ればよい。
ただし、コンサートのチケット販売のように重要なサービスでは、多くの人が怒るだろうし、私はすぐに無職になってしまうだろう。したがって、業務の重要度によっては解決方法になる場合も、ならない場合もある。

2つ目: 完了前に競合を確認する方法

seatテーブルにseat_version intフィールドを置く。 すべてのリクエストはまず、予約しようとするseatのseat_versionを読む。
各リクエストは決済など必要な業務を進め、最後に座席を予約済みに変更するとき、seat_versionが最初に読んだ値と異なっていないかを確認する。

このときversionが変わっていれば、その間に誰かが座席を予約したということなので、自分のリクエストを失敗させる。

いったん処理を進め、完了前に競合を検出してロールバックするこの方式を楽観ロックと呼ぶ。
楽観ロックは「まず進めよう」という考え方なので、競合が起きた場合には必ずロールバックの処理が必要になる。
すでに決済まで完了した後に競合を検出した場合は、決済取消の処理を行わなければならないなど、時間がかかり面倒な状況になる。

一方、カン・ウィチャンの無料コンサート予約のように、問題が発生する可能性がほとんどなく、ロールバックのコストも低いサービスでは、まず処理を進める性質のおかげで、以下で紹介する「悲観ロック」より高いスループットを得られる。

3つ目: 1つずつ順番に処理する方法

複数のリクエストが同時に来ても、一度に1つずつだけ処理する。残りのリクエストは先行するリクエストが完了するまで待つ。
最初から順番に処理するため、2つ目のリクエストはseatを参照した時点で、すでに予約済みであることを確認できる。同時リクエストは安全に処理され、ロールバックを考える必要もなくなる。これが悲観ロックだ。

ただし悲観ロックには、逐次処理によってスループットが大きく落ちるという致命的な欠点がある。そのため、安易にロックを取ってはいけない。

悲観ロックはどう実装するか?

1. Java synchronized

サーバーが1台だけなら、スレッド間の競合だけを考えればよい。

Javaのsynchronizedは、同じJVM内で一度に1つのスレッドだけがクリティカルセクションに入れるようにする。座席IDごとに個別のロックを置けば、A席を予約している間にB席の予約まで止める必要はない。

@Service
public class SeatReservationService {
    private final Map<Long, Object> locks = new ConcurrentHashMap<>();
 
    public void reserve(Long seatId, Long memberId) {
        Object lock = locks.computeIfAbsent(seatId, id -> new Object());
 
        synchronized (lock) {
            Seat seat = seatRepository.findById(seatId).orElseThrow();
 
            if (seat.isReserved()) {
                throw new AlreadyReservedException();
            }
 
            seat.reserve(memberId);
        }
    }
}

synchronizedのスコープには、参照時点から含めなければならない。ロックを取得した最初のリクエストが座席を予約すると、次のリクエストはロックを取得した後、すでに予約済みであることを確認して失敗する。

2. SELECT ... FOR UPDATE

MSA環境では、複数のアプリケーションが参照する共有ストレージが必要になる。まず実装しやすい方法として、RDBを共有ストレージにする方法がある。

MySQLでは、SELECT ... FOR UPDATE;形式でテーブルの特定行にロックをかけられる。

@Transactional
public void reserve(Long seatId, Long memberId) {
    Seat seat = seatRepository.findByIdForUpdate(seatId)
        .orElseThrow();
 
    if (seat.isReserved()) {
        throw new AlreadyReservedException();
    }
 
    seat.reserve(memberId);
}
-- findByForUpdate(seatId)
SELECT *
FROM seat
WHERE id = :seatId
FOR UPDATE;

最初のリクエストが1行をロックすると、次のリクエストはロックが解除されるまで待つ。必ずトランザクションの中で使用しなければならない。また、ロックを保持している間はDBコネクションを1つ使い続けるため、DBコネクションを必要とする別の業務ロジックの応答時間が遅くなることがある。

そのため、FOR UPDATEでロックを取得する場合は、ロック取得後に外部API呼び出しなど、長時間ブロックするロジックが入らないようにコードを設計する必要がある。

3. Redis Redlock

共有ストレージとしてRedisがよく使われる。多くの利点があり、ロック情報を保存するには十分で、高速であり、RDBの負荷を減らせる。

最初のリクエストは、seat:reservation:Aというキーがあるかを確認し、なければキーを作成して業務を進める。後続のリクエストはseat:reservation:Aを参照したときにキーがあるため、waitTimeの間だけ待ち、ロックが残っていれば失敗として処理する。

概念的には、次のRedisコマンドでロックを先取りする。

SET seat:reservation:A 8f3b... NX PX 3000

SETはキーに値を保存する。NXはキーがない場合にのみ保存するという意味なので、複数のリクエストが同時に実行されても、ロックを取得できるのは1つだけだ。PX 3000はキーを3,000ms後に自動で期限切れにする。これがlease timeである。値にはロックを取得したリクエストを識別できるランダムなトークンを入れる。Redisコマンドを直接使って実装するなら、ロックを解放するときも、このトークンが一致する場合にだけキーを削除する必要がある。Redissonのようなライブラリを使えば、この処理を代わりに行ってくれる。

RLock lock = redissonClient.getLock("seat:reservation:1");
 
boolean locked = lock.tryLock(waitTime, leaseTime, TimeUnit.SECONDS);
if (!locked) {
    throw new ReservationInProgressException();
}
 
try {
    reservationTransaction.reserve(1L, memberId);
} finally {
    lock.unlock();
}

すべて良さそうに見えるが、タイミングの問題がある。

リクエストAが3秒のlease timeでロックを取得した直後、ネットワーク遅延によって決済APIが5秒間停止したとしよう。3秒が過ぎるとRedisはロックを期限切れにし、リクエストBは同じロックを取得して予約を進められる。その後、リクエストAの決済APIが正常に完了して再開すると、AとBが同時に業務を処理することになる。

lease timeを極端に長くすればこの問題をなくせるが、障害が起きたとき、次のリクエストは非常に長く待たなければならない。短くすれば復旧は速いが、先行する作業が終わる前にロックが解除される危険がある。

どの方法を選ぶべきか?

開発環境と業務要件に応じて選べばよい。

方法適した環境注意点
synchronizedサーバーが1台のとき複数インスタンスでは使えない。
SELECT ... FOR UPDATERDBに余裕があるときコネクションの過剰使用を考える必要がある。
Redis RedlockRDBの負荷を減らしたいときlease timeが短いと、並行性の問題が起きる可能性がある。

私たちのサービスではRedlockを積極的に活用している。

ShopifyがRedisを外し、RDBだけで並行性を処理した事例

ShopifyはRedisとMySQLで注文を実装していたが、RedisとMySQLを1つのトランザクションにまとめることはできないため、障害時に過剰販売や予期しない在庫切れが起きていたという。

そこでRedisを外すために、MySQLにinventory_poolテーブルを作り、在庫1個を1行として挿入し、FOR UPDATE + SKIP LOCKEDを活用して、実際の大量トラフィック環境で高いスループットを達成した事例がある。

Shopifyの人たちは本当に天才だと思う。ぜひ読んでほしい。

原文リンク

終わり