ライドシェアアプリの設計:最寄りドライバー検索とマッチング

1. 概要

ライドシェアアプリには2つの根本的な問題があります。どのドライバーが顧客の近くにいるのか?複数のドライバーが同時に「受け入れ」をクリックしても、乗車が確実に1人のドライバーにのみ割り当てられるようにするにはどうするか?

この記事ではシステム設計の観点から問題全体をカバーします:要件、スケール推定、データモデル、API、処理フロー。その後、難しい部分に焦点を当てます:レース条件、一貫性、ピーク時負荷。読み終わる頃には、Spring Boot 3、Redis、Kafka、PostgreSQLを使って「ドライバー検索とマッチング」の中核を構築できるようになっているでしょう。

2. 要件

2.1. 機能要件

  • ドライバーはオンライン中に定期的にGPS位置を送信する。
  • 顧客は乗車をリクエストし、乗車位置と目的地を指定する。
  • システムは最寄りの利用可能なドライバーを検索し、順番にオファーを送信する。
  • ドライバーはオファーを受け入れるか拒否する。15秒以内に応答がない場合、システムは次のドライバーにオファーを移す。
  • 顧客は乗車のステータスを追跡できる:REQUESTED → ACCEPTED → IN_PROGRESS → COMPLETED(またはCANCELLED)。

2.2. スケール推定と制約

中規模国の運用を想定します:

  • ピーク時に30万人のドライバーがオンラインで、各ドライバーは4秒ごとに位置を送信する。つまり、約毎秒7万5千件の位置更新があります。
  • 毎秒約1,500件の乗車リクエストがあります。雨の日や夕方の時間帯には、この数字が5倍になることもあります。
  • ドライバーの検索は200ミリ秒(p99)以内に完了する必要があります。
  • 1つの乗車は正確に1人のドライバーに割り当てられ、1人のドライバーは最大1つのアクティブな乗車を持つ。これは絶対に破られてはいけない制約です。

これらの数字から重要な観察が得られます。ドライバーの位置情報は書き込みが非常に多く、短命で、損失を許容できるデータです。一方、乗車データは書き込みが少ない代わりに、完全な一貫性が必要です。したがって、これら2種類のデータは異なるストレージに置くべきです。

3. データモデル

ドライバーの位置はRedisに、乗車はPostgreSQLに保存します:

CREATE TABLE rides (
    id           BIGSERIAL PRIMARY KEY,
    customer_id  VARCHAR(64)  NOT NULL,
    driver_id    VARCHAR(64),
    pickup_lat   DOUBLE PRECISION NOT NULL,
    pickup_lng   DOUBLE PRECISION NOT NULL,
    status       VARCHAR(20)  NOT NULL DEFAULT 'REQUESTED',
    created_at   TIMESTAMP    NOT NULL DEFAULT now(),
    accepted_at  TIMESTAMP
);

-- 各ドライバーはアクティブな乗車を最大1つ持つ
CREATE UNIQUE INDEX ux_rides_driver_active
    ON rides (driver_id)
    WHERE status IN ('ACCEPTED', 'IN_PROGRESS');

末尾のPartial Unique Indexは最後の「安全ネット」です。アプリケーション層にバグがあり、同じドライバーに2つの乗車を割り当てようとした場合、データベースは2番目の書き込みを拒否します。

Redisでは3種類のキーを使用します:

drivers:available            GEO set: driverId -> (lng, lat)、利用可能なドライバーのみを含む
driver:heartbeat:{driverId}  String、TTL 30秒、ドライバーがオンラインであることを示す
driver:lock:{driverId}       String、TTL 15秒、値 = 招待中のrideId

4. API

POST /api/drivers/{driverId}/location   { "lat": 10.7769, "lng": 106.7009 }
POST /api/rides                         { "customerId": "c-42", "pickupLat": ..., "pickupLng": ... }
POST /api/rides/{rideId}/accept         { "driverId": "d-7" }
GET  /api/rides/{rideId}

位置更新エンドポイントは非常に大きな負荷がかかります。実際には、この部分はWebSocketまたはgRPC streamを通常使用して、4秒ごとにHTTP接続を開く必要がなくします。この例をシンプルに保つため、この記事ではRESTを使用します。

5. Redis GEOでドライバー位置の保存と検索

依存関係を追加:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

5.1. 位置の更新

@Service
public class DriverLocationService {

    static final String AVAILABLE_KEY = "drivers:available";
    private final StringRedisTemplate redis;

    public DriverLocationService(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public void updateLocation(String driverId, double lng, double lat) {
        // ドライバーの位置をGEO setに追加
        redis.opsForGeo().add(AVAILABLE_KEY, new Point(lng, lat), driverId);
        // ドライバーのハートビートを更新
        redis.opsForValue().set("driver:heartbeat:" + driverId, "1", Duration.ofSeconds(30));
    }

    public void markBusy(String driverId) {
        // ドライバーを利用可能なリストから削除
        redis.opsForGeo().remove(AVAILABLE_KEY, driverId);
    }
}

GEOADDは座標をgeohashとしてソート済みセットに保存します。各コマンドは時間計算量O(log N)であるため、1つのRedisノードは毎秒数万のコマンドを楽に処理できます。

ハートビート部分が存在する理由は、GEO setのメンバーには個別のTTLがないからです。ドライバーが接続を失うか、アプリを閉じても、古い座標はセット内に残ります。ハートビートキーは30秒後に期限切れになり、「ゾンビ」ドライバーをフィルタリングするのに役立ちます。

注意: Redis GEOは**(経度、緯度)**の順序でパラメータを受け取ります。順序を逆にするのは非常に一般的なエラーです。逆にしてしまうと、ホーチミン市のドライバーが南極に「移動」してしまいます。

5.2. 最寄りドライバーの検索

public List<String> findNearby(double lng, double lat, double radiusKm, int limit) {
    var args = RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs()
            .includeDistance()
            .sortAscending()
            .limit(limit * 2L); // オフラインドライバーの除去をカバーするため余分に取得

    GeoResults<RedisGeoCommands.GeoLocation<String>> results = redis.opsForGeo().search(
            AVAILABLE_KEY,
            GeoReference.fromCoordinate(lng, lat),
            new Distance(radiusKm, Metrics.KILOMETERS),
            args);

    List<String> candidates = results.getContent().stream()
            .map(r -> r.getContent().getName())
            .toList();

    // 1回のMGETですべての候補のハートビートをチェック
    List<String> alive = redis.opsForValue().multiGet(
            candidates.stream().map(id -> "driver:heartbeat:" + id).toList());

    List<String> online = new ArrayList<>();
    for (int i = 0; i < candidates.size() && online.size() < limit; i++) {
        if (alive.get(i) != null) {
            online.add(candidates.get(i));
        }
    }
    return online;
}

GEOSEARCHは指定された半径内のドライバーを返し、近い順にソートします。その後、1回のMGETコマンドで全候補のハートビートをチェックするため、追加でラウンドトリップは1回だけです。第1区の顧客が3km半径内にいる場合、結果は次のようになります:

[d-7 (0.4 km), d-19 (0.9 km), d-3 (1.6 km)]

ここでの距離は直線距離です。本番環境では、地図サービスの実際のETAに基づいてトップN候補を再ランク付けする必要があります。理由は、500m離れているが川の向こう側にいるドライバーが、1km離れているドライバーより遅く到着する可能性があるからです。

6. マッチングとレース条件の処理

2種類のレース条件をブロックする必要があります:

  1. 2つの乗車が同じドライバーに招待する。 同じ場所に立つ2人の顧客が同時に乗車をリクエストし、両方のマッチング処理がd-7を選択します。
  2. 2人のドライバーが同じ乗車を受け入れる。 これは、待機時間を短縮するために複数のドライバーに同時にオファーを送信するときに発生します。

6.1. マッチング中のRedisロック

@Service
public class DriverLockService {

    private static final RedisScript<Long> RELEASE = RedisScript.of("""
            if redis.call('GET', KEYS[1]) == ARGV[1] then
                return redis.call('DEL', KEYS[1])
            end
            return 0
            """, Long.class);

    private final StringRedisTemplate redis;

    public DriverLockService(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public boolean tryLock(String driverId, long rideId) {
        return Boolean.TRUE.equals(redis.opsForValue()
                .setIfAbsent("driver:lock:" + driverId, String.valueOf(rideId), Duration.ofSeconds(15)));
    }

    public void release(String driverId, long rideId) {
        redis.execute(RELEASE, List.of("driver:lock:" + driverId), String.valueOf(rideId));
    }
}

SET NX EXはアトミック操作です。各ドライバーのロックを持つことができるマッチング処理は1つだけです。ロックは15秒後に自動的に期限切れになり、これはドライバーの応答を待つ時間と正確に一致します。このTTLのおかげで、システムは専用の自動クリーンアップ処理なしに自動的に「次のドライバーに移す」ことができます。

Luaスクリプトは、値がrideIdと一致する場合のみキーを削除するために使用されます。単純にDELを使用すると、期限切れの乗車Aが、乗車Bが取得したばかりのロックを誤って削除する可能性があります。

6.2. 条件付き更新でマッチングを確定

Redisロックは重複したドライバーオファーを避けるための最適化メカニズムにすぎません。真実のソースはまだデータベースです:

@Repository
public class RideRepository {

    private final JdbcTemplate jdbc;

    public RideRepository(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    public boolean assignDriver(long rideId, String driverId) {
        int updated = jdbc.update("""
                UPDATE rides
                SET status = 'ACCEPTED', driver_id = ?, accepted_at = CURRENT_TIMESTAMP
                WHERE id = ? AND status = 'REQUESTED'
                """, driverId, rideId);
        return updated == 1;
    }
}

status = 'REQUESTED'という条件により、UPDATEコマンドが比較交換操作になります。2人のドライバーが同時に受け入れをクリックすると、データベースは同じ行への2つの書き込みをシリアル化します。最初のコマンドは1を返します。2番目のコマンドはステータスが既にACCEPTEDであることを確認し、0を返します。この方法ではSELECT ... FOR UPDATEや専用のバージョンカラムは不要です。

サービスはマッチング受け入れロジックを組み合わせます:

@Transactional
public AcceptResult accept(long rideId, String driverId) {
    if (!rideRepository.assignDriver(rideId, driverId)) {
        return AcceptResult.ALREADY_TAKEN;
    }
    locationService.markBusy(driverId);
    lockService.release(driverId, rideId);
    return AcceptResult.ACCEPTED;
}

ここでの順序は意図的です:最初にデータベースに書き込み、その後Redisを更新します。markBusyステップが失敗した場合、ドライバーはさらに数秒間利用可能なリストに残ります。最悪の場合、ドライバーは追加の招待を受け取り、その場合partial unique indexが2番目の乗車割り当てをブロックします。逆の順序ではより危険です:Redisからドライバーを削除してからデータベースへの書き込みに失敗すると、ドライバーはシステムから「消えて」しまいます。

7. Kafkaを使った非同期処理フロー

ドライバー検索、招待送信、その後15秒間の待機は長い処理です。顧客のHTTPリクエストをこの間ずっと保持してはいけません。POST /api/rides APIは乗車を保存して202 Acceptedをすぐに返すだけです。マッチング部分は別のコンシューマーで実行されます。

<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>
public record RideRequested(long rideId, String cityId, double lng, double lat, int attempt) {}

@Component
public class MatchingConsumer {

    private final DriverLocationService locations;
    private final DriverLockService locks;
    private final OfferSender offerSender;
    private final KafkaTemplate<String, RideRequested> kafka;

    // コンストラクタは簡潔性のため省略

    @KafkaListener(topics = "ride-requested", groupId = "matching")
    public void onRideRequested(RideRequested event) {
        // 近いドライバーを順に試す
        for (String driverId : locations.findNearby(event.lng(), event.lat(), 3, 10)) {
            if (locks.tryLock(driverId, event.rideId())) {
                // ドライバーをロック、招待を送信
                offerSender.send(driverId, event.rideId()); // プッシュ通知 / WebSocket
                return;
            }
        }
        // 誰も利用可能ではない:後で再試行するため再度キューに入れる
        if (event.attempt() < 5) {
            kafka.send("ride-requested-retry", event.cityId(),
                    new RideRequested(event.rideId(), event.cityId(), event.lng(), event.lat(), event.attempt() + 1));
        }
    }
}

コンシューマーは近い順に候補を巡ります。最初に利用可能なドライバーをロックし、そのドライバーに招待を送信します。誰も利用可能でない場合、イベントはリトライトピックに移され、試行回数に制限があります。

メッセージキーはcityIdです。これにより、同じ都市のすべての乗車は同じパーティションに落ちるため、コンシューマーのローカルドライバーキャッシュ(がある場合)のキャッシュヒット率が高くなります。

小さな問題が1つあります:乗車をPostgreSQLに保存してからKafkaに送信するのは2つの分離された操作です。アプリケーションが途中で崩壊した場合、乗車はREQUESTEDステータスで永遠に「スタック」したままになります。標準的な修正はtransactional outboxです。イベントをridesテーブルと同じトランザクション内でoutboxテーブルに書き込み、別のプロセス(またはDebezium)がoutboxテーブルを読み取ってKafkaに送信するようにします。

注意: Kafkaはat-least-onceを保証するため、コンシューマーはイベントを2回受け取る可能性があります。マッチングロジックはべき等である必要があります。招待を送信する前に、乗車がREQUESTEDステータスのままであることを確認してください。6.2セクションの条件付き更新は既に重複に対して安全です。

8. ピーク時の負荷対応

雨の日の17時に、乗車リクエストとアクティブなドライバーの両方が急増します。負荷の急増に対処する主な4つの方法があります。

  • 位置データを地域でシャード化。 全国用に1つのdrivers:availableキーを使用する代わりに、都市またはgeohashグリッドでシャード化します。例:drivers:available:hcm。各キーはRedis Clusterのシャードに置かれるため、書き込み負荷が分散されます。顧客が2つのグリッドの境界線近くに立つ場合は、隣接するグリッドもクエリします。
  • 位置送信の頻度を減らす。 静止しているか、顧客を乗せているドライバーは4秒ごとに位置を送信する必要はありません。50mを超えて移動したとき、または15秒経過したときのみ送信します。この最適化だけで書き込みを40~60%削減できます。
  • バックプレッシャーの採用。 Kafkaはバッファーの役割を果たします。乗車リクエストが5倍に増加しても、マッチング遅延は数秒増加するだけで、損失は発生しません。コンシューマーラグに基づいてコンシューマー数をスケーリングし、上限をパーティション数に設定します。
  • ダイナミックプライシング。 これはテクニカルなソリューションではなく、プロダクトレイヤーのソリューションです。料金が高いほど需要が減り、より多くのドライバーがオンラインになり、システムのバランスが取れます。

さらに、customerIdによってAPI乗車リクエストをrate limitする必要があります。アプリが「乗車を探索中」を長く表示すると、顧客は連続して再度リクエストをクリックする傾向があり、負荷が2倍になる可能性があります。

9. マッチング受け入れ時のレース条件テスト

テストすべき最も重要な動作は:複数のドライバーが1つの乗車を受け入れた場合、1人だけが勝つということです。以下のテストはH2インメモリデータベースを@JdbcTestで使用します。ファイルsrc/test/resources/schema.sqlは3セクションのridesテーブルを含み、H2が対応していないpartial indexを省略しています。

@JdbcTest
@Import(RideRepository.class)
class RideRepositoryTest {

    @Autowired
    RideRepository rideRepository;

    @Autowired
    JdbcTemplate jdbc;

    @Test
    void givenConcurrentAccepts_whenAssignDriver_thenOnlyOneDriverWins() throws Exception {
        jdbc.update("INSERT INTO rides (id, customer_id, pickup_lat, pickup_lng, status) "
                + "VALUES (1, 'c-42', 10.77, 106.70, 'REQUESTED')");

        List<String> drivers = List.of("d-1", "d-2", "d-3", "d-4", "d-5");
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<Boolean>> results = drivers.stream()
                    .map(d -> executor.submit(() -> rideRepository.assignDriver(1L, d)))
                    .toList();

            long winners = 0;
            for (Future<Boolean> f : results) {
                if (f.get()) winners++;
            }
            assertThat(winners).isEqualTo(1);
        }

        String status = jdbc.queryForObject("SELECT status FROM rides WHERE id = 1", String.class);
        assertThat(status).isEqualTo("ACCEPTED");
    }
}

5つのvirtual threadが同じ乗車でassignDriverを呼び出します。実行順序がどうであれ、status = 'REQUESTED'という条件に一致するUPDATEコマンドは正確に1つだけです。

テストで@JdbcTestのデフォルト@Transactionalを削除したことに気づくかもしれません。理由は:新しいスレッドはテストメソッドと同じトランザクションで実行されず、INSERTはauto-commitの構成のため依然として表示されます。winners = 0の結果が出た場合、テストクラスに@Transactional(propagation = Propagation.NOT_SUPPORTED)を追加してください。

GEOロジックについては、mockの代わりにTestcontainersを使用して実際のRedisを実行する必要があります。距離計算と並び替え順序はmockでは検証できません。

10. 結論

この記事ではライドシェアアプリケーションのコア部分の概要を説明しました。要点はデータを特性に応じて分割することです:

  • ドライバーの位置は書き込みが多く、損失を許容できるため、Redis GEOに配置し、ハートビートで接続を失ったドライバーをフィルタリングします。
  • 乗車は一貫性が必要なため、PostgreSQLに配置します。条件付き更新は比較交換として機能し、partial unique indexが最後の安全ネットとなります。
  • TTL付きRedisロックは重複招待を防止し、タイムアウトを自動的に処理します。ただし、ロックはメカニズムの最適化であり、真実のソースではありません。
  • Kafkaは顧客のリクエストからマッチング処理を分離し、負荷の急増を吸収し、リトライを可能にします。Outboxパターンはイベント損失を防ぎます。

スケーリングを続ける場合は、さらに検討する方向があります:直線距離の代わりにETAでランク付けする、数秒ごとにバッチマッチングを実行してグローバルを最適化する、ライド共有(プーリング)です。これらはすべてここで説明した基盤上に構築できます。

system-designrediskafkaspring-bootcase-study