Redis Geo:Spring Bootで最も近い場所を見つける

1. 概要

「あなたの近くの店舗を探す」機能は、ほぼすべての配食アプリケーション、ライドシェアアプリ、またはコーヒーチェーンマップに表示されます。Haversine式を自分で書いて場所テーブル全体をスキャンすることはできますが、この方法はデータが大きいと遅くなります。

Redisは、わずか数ミリ秒の遅延でこの問題を解決するためのGeoコマンドグループを備えています。この記事では、座標をRedisに保存し、GEOSEARCHを使用して最も近い場所を見つけ、2つのポイント間の距離を計算し、Spring Boot 3.xでこれらの操作のテストを書く方法を説明します。

2. Redis Geoの仕組み

Redisには独立したGeoデータ型がありません。各座標は52ビットgeohashにエンコードされ、sorted setのscoreとして保存されます。互いに近いポイントは通常、似たgeohashを持っています。このため、Redisはデータセット全体ではなく、わずかなスコア範囲のみをスキャンする必要があります。

redis-cliを使用して素早くテストしてみてください:

GEOADD coffee:hanoi 105.8547 21.0334 cafe-giang
GEOADD coffee:hanoi 105.8541 21.0251 cong-trang-tien
GEOADD coffee:hanoi 105.8128 21.0318 highlands-lotte

GEOSEARCH coffee:hanoi FROMLONLAT 105.8522 21.0287 BYRADIUS 2 km ASC WITHDIST

GEOADDコマンドは経度を先に、緯度を後に受け取ります。GEOSEARCHコマンドは、ホアンキエム湖の周り2 km以内にあるメンバーを見つけ、近い順から遠い順に並べ替え(ASC)、距離を含めて返します(WITHDIST):

1) 1) "cong-trang-tien"
   2) "0.4465"
2) 1) "cafe-giang"
   2) "0.5845"

highlands-lotteは約4 km離れているため表示されません。マシン上の数値結果は小数点以下の最後の桁で異なる可能性があります。これは、Redisがgeohashと球体地球モデルを使用するためです。

本質的にはsorted setであるため、同じキーでZRANGE、ZCARD、ZREMを引き続き使用できます。

注意: GEORADIUSとGEORADIUSBYMEMBERは、Redis 6.2からdeprecatedとしてマークされています。新しいコードでは、GEOSEARCHを使用する必要があります。

3. Spring Data Redisの設定

pom.xmlにRedis starterを追加します:

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

application.propertiesで接続を設定します:

spring.data.redis.host=localhost
spring.data.redis.port=6379

Spring BootはStringRedisTemplate beanを自動作成します。これはGeoに適切な選択です。なぜなら、メンバーは通常、文字列形式のIDだからです。opsForGeo()を通じてGeoコマンドグループにアクセスします。これはGeoOperations<String, String>を返します。

4. 場所を保存する

すべてのGeo操作をまとめたserviceを作成します:

@Service
public class StoreLocatorService {

    static final String KEY = "coffee:hanoi";

    private final GeoOperations<String, String> geoOps;

    public StoreLocatorService(StringRedisTemplate redisTemplate) {
        this.geoOps = redisTemplate.opsForGeo();
    }

    public void addStore(String storeId, double longitude, double latitude) {
        geoOps.add(KEY, new Point(longitude, latitude), storeId);
    }

    public void removeStore(String storeId) {
        geoOps.remove(KEY, storeId);
    }
}

説明:

  • Pointはorg.springframework.data.geoパッケージに属しています。コンストラクタは(x, y)、つまり**(longitude, latitude)**を受け入れます。
  • geoOps.add(...)はGEOADDと同等です。storeIdが既に存在する場合、古い座標は上書きされます。重複レコードは発生しません。
  • geoOps.remove(...)は実際にはZREMを呼び出します。GeoはsortedSetだからです。

一度に多くのポイントをロードする必要がある場合は、Map<String, Point>を渡して、すべてを1つのコマンドで送信します:

public void addStores(Map<String, Point> stores) {
    geoOps.add(KEY, stores);
}

この方法は、ループでaddStoreを呼び出すのと比較して、ネットワークのラウンドトリップを節約します。

5. 最も近い場所を見つける

5.1. ユーザーの座標に基づいて検索

最も一般的なケースは、アプリケーションがユーザーのGPS位置を送信することです。指定された半径内でN個の最も近い店舗を返す必要があります。まず、結果のためのレコードを宣言します:

public record NearbyStore(String storeId, double distanceKm, double longitude, double latitude) {
}

次に、StoreLocatorServiceに検索メソッドを追加します:

public List<NearbyStore> findNearest(double longitude, double latitude,
                                     double radiusKm, int limit) {
    var args = RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs()
            .includeDistance()
            .includeCoordinates()
            .sortAscending()
            .limit(limit);

    GeoResults<RedisGeoCommands.GeoLocation<String>> results = geoOps.search(
            KEY,
            GeoReference.fromCoordinate(longitude, latitude),
            new Distance(radiusKm, Metrics.KILOMETERS),
            args);

    if (results == null) {
        return List.of();
    }

    return results.getContent().stream()
            .map(r -> new NearbyStore(
                    r.getContent().getName(),
                    r.getDistance().getValue(),
                    r.getContent().getPoint().getX(),
                    r.getContent().getPoint().getY()))
            .toList();
}

各部分が何をするのか:

  • GeoReference.fromCoordinate(...)はFROMLONLATに対応しており、距離を測定するための基準点です。
  • new Distance(radiusKm, Metrics.KILOMETERS)はBYRADIUS 2 kmに対応しています。ここで選択した単位は、返される距離の単位でもあります。
  • includeDistance()とincludeCoordinates()はWITHDISTとWITHCOORDを追加します。includeCoordinates()がない場合、getPoint()はnullを返します。
  • sortAscending()は近い順から遠い順に並べ替えます。limit(limit)はCOUNTを追加します。

ホアンキエム湖の位置を使用して試してみてください:

storeLocatorService.findNearest(105.8522, 21.0287, 2, 5)
        .forEach(s -> System.out.printf("%s: %.2f km%n", s.storeId(), s.distanceKm()));
cong-trang-tien: 0.45 km
cafe-giang: 0.58 km

5.2. 既存の場所の周りで検索

「この店舗の近くにある他の店舗」を提案したいことがあります。座標は必要ありません。メンバー自体を中心として使用するだけです:

public List<String> findStoresNear(String storeId, double radiusKm) {
    var results = geoOps.search(
            KEY,
            GeoReference.fromMember(storeId),
            new Distance(radiusKm, Metrics.KILOMETERS),
            RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().sortAscending());

    if (results == null) {
        return List.of();
    }

    return results.getContent().stream()
            .map(r -> r.getContent().getName())
            .filter(name -> !name.equals(storeId))
            .toList();
}

GeoReference.fromMemberはFROMMEMBERに対応しています。結果には常に距離0の元のメンバー自体が含まれるため、filter行のように除外する必要があります。

5.3. 矩形枠内で検索

ユーザーが地図をドラッグするとき、通常は円形ではなく、表示されているフレーム内のすべてのポイントが必要です。この場合はBoundingBox(BYBOXに対応)を使用します:

public List<String> findInBox(double centerLon, double centerLat,
                              double widthKm, double heightKm) {
    var box = new BoundingBox(
            new Distance(widthKm, Metrics.KILOMETERS),
            new Distance(heightKm, Metrics.KILOMETERS));

    var results = geoOps.search(KEY,
            GeoReference.fromCoordinate(centerLon, centerLat),
            box,
            RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs());

    return results == null ? List.of()
            : results.getContent().stream().map(r -> r.getContent().getName()).toList();
}

BoundingBoxは幅と高さによってフレームを定義し、中心は基準点です。

6. 距離の計算と座標の取得

検索に加えて、GeoOperationsは他の2つの有用な操作をサポートしています:

public Distance distanceBetween(String storeA, String storeB) {
    return geoOps.distance(KEY, storeA, storeB, Metrics.KILOMETERS);
}

public Point locationOf(String storeId) {
    List<Point> points = geoOps.position(KEY, storeId);
    return (points == null || points.isEmpty()) ? null : points.get(0);
}
  • distanceはGEODISTに対応しています。メンバーのいずれかが存在しない場合、メソッドはnullを返します。
  • positionはGEOPOSに対応しています。返されるリストには、存在しないメンバーのnull要素があります。

読み直された座標は、書き込んだ値とわずかに異なります。例えば、105.8547の代わりに105.85470169782638になるでしょう。これはgeohash 52ビットの誤差で、約1メートル未満です。元の値とequalsで比較しないでください。

7. Testcontainersを使用したテスト

開発マシンに依存せずに実際のRedis上でテストを実行するには、Testcontainersを使用します。dependencyを追加します:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>

主な動作のテストを書きます:

@SpringBootTest
@Testcontainers
class StoreLocatorServiceTest {

    @Container
    @ServiceConnection(name = "redis")
    static GenericContainer<?> redis =
            new GenericContainer<>(DockerImageName.parse("redis:7-alpine"))
                    .withExposedPorts(6379);

    @Autowired
    StoreLocatorService service;

    @Autowired
    StringRedisTemplate redisTemplate;

    @BeforeEach
    void setUp() {
        redisTemplate.delete(StoreLocatorService.KEY);
        service.addStore("cafe-giang", 105.8547, 21.0334);
        service.addStore("cong-trang-tien", 105.8541, 21.0251);
        service.addStore("highlands-lotte", 105.8128, 21.0318);
    }

    @Test
    void shouldReturnStoresWithinRadiusSortedByDistance() {
        List<NearbyStore> result = service.findNearest(105.8522, 21.0287, 2, 10);

        assertThat(result)
                .extracting(NearbyStore::storeId)
                .containsExactly("cong-trang-tien", "cafe-giang");
        assertThat(result.get(0).distanceKm()).isCloseTo(0.45, within(0.02));
    }

    @Test
    void shouldRespectLimit() {
        assertThat(service.findNearest(105.8522, 21.0287, 10, 1))
                .extracting(NearbyStore::storeId)
                .containsExactly("cong-trang-tien");
    }

    @Test
    void shouldNotFindRemovedStore() {
        service.removeStore("cafe-giang");

        assertThat(service.findNearest(105.8522, 21.0287, 2, 10))
                .extracting(NearbyStore::storeId)
                .doesNotContain("cafe-giang");
    }
}

@ServiceConnection(name = "redis")はSpring Bootに、コンテナからホストとポートを自動的に設定するように指示します。@DynamicPropertySourceは必要ありません。距離テストでは、結果に小さな誤差があるため(セクション6で説明したように)、絶対比較の代わりにisCloseTo(..., within(...))を使用してください。

8. よくあるエラーと使用すべきではない場合

**経度と緯度を入れ替える。**これは番号1のエラーです。Googleマップは「21.0287、105.8522」(lat、lon)を表示しますが、RedisとPointは(lon、lat)が必要です。入れ替えた場合、Redisはinvalid longitude,latitude pairエラーを報告するか、より悪いことに、誤った場所を持つポイントが正常に保存される場合があります。

注意: Redisは、Web Mercator投影の制限に従って、±85.05112878度の範囲内の緯度のみを受け入れます。両極に近いポイントを保存することはできません。

**詳細データをメンバーに保存する。**メンバーはstore:1024のような短いIDである必要があります。名前、住所、営業時間のJSONをメンバーに詰め込まないでください。詳細情報はRedis HashまたはDatabaseに保存してください。GEOSEARCHからIDのリストを取得した後、1つのバッチ呼び出しで詳細を取得します。

**大きすぎるキー。**数千万のポイントを含むsorted setは、大きなキーです。このキーを削除または移行するとRedisがブロックされます。Redis Clusterでは、1つのキーのすべてのポイントが同じシャード上にあります。キーを都市または地域で分割する必要があります。例えば、coffee:hanoi、coffee:hcm。

**メンバーごとのTTLがない。**ドライバーの位置が継続的に更新されている場合、「デッド」メンバーは永遠に残ります。最後の更新タイムスタンプを保存する補助のsorted setなど、独立したクリーンアップジョブが必要です。その後、期限切れのメンバーを定期的に削除してください。

**limitなしで半径が大きすぎる。**ポイントが多い都市で50 kmの無制限検索は、数万の要素を返す可能性があります。常に合理的なlimitを設定する必要があります。

Redis Geoを使用すべきではない場合:

  • ポリゴン(区の境界、配信エリア)、幾何学的交差、またはルート検索でクエリを実行する必要があります。PostGISまたはElasticsearch geoを使用してください。
  • 「最も近い、営業中、駐車スペースがある」など、複数の条件で結果をフィルタリングする必要があります。Redis Geoは距離によってのみフィルタリングします。残りのフィルタリングはアプリケーション層で実行するか、Redis Query Engineを使用する必要があります。
  • Redisが永続化のない唯一のストレージです。Geoはアクセラレーション索引である必要があり、ソースデータはデータベースに残る必要があります。

9. 結論

Redis Geoは「最も近い場所を見つける」という問題を、sorted setに対するいくつかの単純なコマンドに変えます。GEOADDを使用して座標を保存し、GEOSEARCHを使用して半径または矩形フレームで検索し、GEODISTを使用して距離を計算する方法を学びました。すべてをTestcontainersでテストしたSpring Boot serviceにまとめました。

実装を適用する際は、3つのポイントを覚えておく必要があります:常に経度を先に渡す、メンバーを短いIDとして保つ、データが大きい場合はキーを領域で分割する。距離より複雑な空間クエリの場合は、Redisと地理的データに特化したシステムを組み合わせるべきです。

redisspring-bootspring-data-redisgeospatialjava