Redis Geo : trouver les points les plus proches avec Spring Boot

1. Vue d’ensemble

La fonction « trouver un magasin près de toi » apparaît dans la plupart des applications de livraison de nourriture, d’appels de taxi ou de cartes de chaînes de cafés. Vous pourriez écrire vous-même la formule haversine puis balayer toute la table de localisation, mais cette approche est lente avec de grandes données.

Redis dispose d’un ensemble de commandes Geo pour résoudre ce problème avec une latence de seulement quelques millisecondes. Cet article vous montre comment stocker des coordonnées dans Redis, trouver les points les plus proches avec GEOSEARCH, calculer la distance entre deux points et écrire des tests pour ces opérations dans Spring Boot 3.x.

2. Comment fonctionne Redis Geo

Redis n’a pas de type de données Geo distinct. Chaque coordonnée est encodée en un geohash 52-bit, puis stockée comme un score dans un sorted set. Les points proches les uns des autres ont généralement un geohash similaire. Grâce à cela, Redis ne doit balayer que quelques plages de scores au lieu de l’ensemble des données.

Vous pouvez l’essayer rapidement avec 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

La commande GEOADD accepte la longitude en premier, puis la latitude. GEOSEARCH trouve les membres situés dans un rayon de 2 km autour du Lac Hoan Kiem, triés du plus proche au plus éloigné (ASC) et retourne la distance (WITHDIST) :

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

highlands-lotte est à environ 4 km de distance, il n’apparaît donc pas. Le résultat sur votre machine peut légèrement différer au niveau des décimales, car Redis utilise le geohash et un modèle de Terre sphérique.

Comme Redis utilise en réalité un sorted set, vous pouvez toujours utiliser ZRANGE, ZCARD, ZREM sur la même clé.

Remarque : GEORADIUS et GEORADIUSBYMEMBER sont marqués comme dépréciés depuis Redis 6.2. Pour le nouveau code, utilisez GEOSEARCH.

3. Installation de Spring Data Redis

Ajoutez le starter Redis à pom.xml :

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

Configurez la connexion dans application.properties :

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

Spring Boot crée automatiquement un bean StringRedisTemplate. C’est un bon choix pour Geo, car les membres sont généralement des ID sous forme de chaîne. Vous accédez aux commandes Geo via opsForGeo(), qui retourne GeoOperations<String, String>.

4. Stocker les points

Créez un service qui regroupe toutes les opérations Geo :

@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);
    }
}

Explications :

  • Point provient du package org.springframework.data.geo. Le constructeur accepte (x, y), c’est-à-dire (longitude, latitude).
  • geoOps.add(...) est équivalent à GEOADD. Si storeId existe déjà, les anciennes coordonnées sont remplacées. Vous n’avez pas à craindre les doublons.
  • geoOps.remove(...) appelle en réalité ZREM, car Geo est un sorted set.

Quand vous devez charger plusieurs points à la fois, vous pouvez passer un Map<String, Point> pour envoyer tout en une seule commande :

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

Cette approche économise les allers-retours réseau par rapport à appeler addStore dans une boucle.

5. Trouver les points les plus proches

5.1. Recherche basée sur les coordonnées de l’utilisateur

Le cas le plus courant est que l’application envoie la position GPS de l’utilisateur. Vous avez besoin de retourner les N magasins les plus proches dans un rayon donné. Commencez par déclarer un record pour le résultat :

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

Ensuite, ajoutez la méthode de recherche à 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();
}

Ce que fait chaque partie :

  • GeoReference.fromCoordinate(...) correspond à FROMLONLAT, c’est-à-dire le point de référence pour mesurer la distance.
  • new Distance(radiusKm, Metrics.KILOMETERS) correspond à BYRADIUS 2 km. L’unité que vous choisissez ici est aussi l’unité de la distance retournée.
  • includeDistance() et includeCoordinates() ajoutent WITHDIST et WITHCOORD. Si vous omettez includeCoordinates(), getPoint() retournera null.
  • sortAscending() trie du plus proche au plus éloigné. limit(limit) ajoute COUNT.

Essayez avec la localisation du Lac Hoan Kiem :

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. Recherche autour d’un point existant

Parfois, vous voulez suggérer « les autres magasins près de celui-ci ». Vous n’avez pas besoin de coordonnées, utilisez simplement le membre lui-même comme centre :

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 correspond à FROMMEMBER. Le résultat inclut toujours le membre d’origine avec une distance de 0, donc vous devez le filtrer comme dans la ligne filter.

5.3. Recherche dans un rectangle

Quand l’utilisateur déplace la carte, vous avez généralement besoin de tous les points dans le cadre affiché, pas dans un cercle. Utilisez alors BoundingBox (correspondant à 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 définit le cadre selon la largeur et la hauteur, avec le point de référence au centre.

6. Calculer les distances et récupérer les coordonnées

En plus de la recherche, GeoOperations prend en charge deux autres opérations utiles :

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 correspond à GEODIST. Si l’un des deux membres n’existe pas, la méthode retourne null.
  • position correspond à GEOPOS. La liste retournée peut contenir des éléments null pour les membres inexistants.

Les coordonnées relues seront légèrement différentes des valeurs que vous avez écrites, par exemple 105.85470169782638 au lieu de 105.8547. C’est l’erreur du geohash 52-bit, de l’ordre d’un mètre. Ne comparez pas avec l’égalité equals par rapport aux valeurs d’origine.

7. Tester avec Testcontainers

Pour tester sur un vrai Redis sans dépendre de votre machine de développement, utilisez Testcontainers. Ajoutez la dépendance :

<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>

Écrivez un test pour le comportement principal :

@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") indique à Spring Boot de configurer automatiquement le host et le port à partir du conteneur. Vous n’avez pas besoin de @DynamicPropertySource. Pour les tests de distance, utilisez isCloseTo(..., within(...)) au lieu d’une comparaison absolue, car le résultat a une petite marge d’erreur comme mentionné à la section 6.

8. Erreurs courantes et quand ne pas utiliser

Échanger la longitude et la latitude. C’est l’erreur numéro un. Google Maps affiche « 21.0287, 105.8522 » (lat, lon), tandis que Redis et Point ont besoin de (lon, lat). Si vous les échangez, Redis peut signaler une erreur invalid longitude,latitude pair, ou pire, stocker correctement un point à la mauvaise position.

Remarque : Redis accepte uniquement les latitudes dans la plage ±85.05112878 degrés, conformément à la limite de la projection Web Mercator. Vous ne pouvez pas stocker de points près des deux pôles.

Stocker des données détaillées dans le membre. Le membre doit être un ID court comme store:1024. N’insérez pas JSON avec le nom, l’adresse, les heures d’ouverture dans le membre. Stockez les informations détaillées dans un Hash Redis ou une base de données. Une fois que vous avez la liste des ID de GEOSEARCH, récupérez les détails avec un appel batch.

Clés trop grandes. Un sorted set contenant des dizaines de millions de points est une clé volumineuse. Supprimer ou migrer cette clé bloquera Redis. Dans Redis Cluster, tous les points d’une clé résident sur la même partition. Divisez la clé par ville ou région, par exemple coffee:hanoi, coffee:hcm.

Pas de TTL par membre. Avec les positions des conducteurs mises à jour régulièrement, les membres « morts » resteront éternellement. Vous avez besoin d’un job de nettoyage distinct, par exemple un sorted set supplémentaire stockant l’horodatage de la dernière mise à jour, puis supprimez périodiquement les membres expirés.

Rayon très large sans limit. Chercher dans un rayon de 50 km sans limite dans une ville avec beaucoup de points peut retourner des dizaines de milliers d’éléments. Vous devriez toujours définir un limit raisonnable.

Quand NE PAS utiliser Redis Geo :

  • Vous avez besoin de requêtes sur des polygones (limites de districts, zones de livraison), d’intersections géométriques ou de calculs d’itinéraires. Utilisez PostGIS ou Elasticsearch geo.
  • Vous avez besoin de filtrer les résultats par plusieurs conditions, comme « plus proche, ouvert maintenant, a une place de parking ». Redis Geo ne filtre que par distance. Le reste du filtrage doit être fait au niveau application, ou utilisez un moteur de requête Redis.
  • Redis est votre seul endroit de stockage sans persistance. Geo doit être un index d’accélération, tandis que la source de données d’origine reste dans la base de données.

9. Conclusion

Redis Geo transforme le problème « trouver le point le plus proche » en quelques commandes simples sur un sorted set. Vous savez maintenant comment stocker les coordonnées avec GEOADD, rechercher par rayon ou rectangle avec GEOSEARCH, et calculer les distances avec GEODIST. Vous avez aussi encapsulé tout cela dans un service Spring Boot avec un test utilisant Testcontainers.

Lors de la mise en œuvre pratique, souvenez-vous de trois points : passez toujours la longitude en premier, gardez le membre comme un ID court, et divisez la clé par région si les données sont volumineuses. Pour les requêtes spatiales plus complexes au-delà de la distance, combinez Redis avec un système spécialisé dans les données géographiques.

redisspring-bootspring-data-redisgeospatialjava