Redis Geo: Nächste Orte mit Spring Boot finden
1. Übersicht
Die Funktion „Shop in deiner Nähe finden” erscheint in fast jeder Food-Delivery-, Ride-Sharing- oder Kaffeehaus-Karten-App. Du könntest zwar selbst die Haversine-Formel schreiben und alle Standorte durchsuchen, aber diese Methode ist langsam bei großen Datenmengen.
Redis verfügt über eine Reihe von Geo-Befehlen, um dieses Problem mit einer Latenz von nur wenigen Millisekunden zu lösen. Dieser Artikel zeigt dir, wie du Koordinaten in Redis speicherst, Standorte mit GEOSEARCH findest, Entfernungen zwischen zwei Punkten berechnest und Tests für diese Operationen in Spring Boot 3.x schreibst.
2. Wie Redis Geo funktioniert
Redis hat keinen separaten Datentyp für Geo. Jede Koordinate wird in einen 52-Bit-Geohash kodiert und dann als score in einem sorted set gespeichert. Punkte, die räumlich nah beieinander liegen, haben normalerweise ähnliche Geohashes. Deshalb muss Redis nur ein paar Score-Bereiche durchsuchen, anstatt den ganzen Datensatz zu scannen.
Probier es schnell mit redis-cli aus:
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
Der GEOADD-Befehl erwartet Längengrad zuerst, Breitengrad danach. GEOSEARCH findet alle Member im 2-km-Radius um den Hoan Kiem See, sortiert sie von nah zu fern (ASC) und gibt die Entfernung mit aus (WITHDIST):
1) 1) "cong-trang-tien"
2) "0.4465"
2) 1) "cafe-giang"
2) "0.5845"
highlands-lotte ist etwa 4 km entfernt und wird daher nicht angezeigt. Die genauen Ergebnisse auf deinem System können in den letzten Dezimalstellen abweichen, da Redis Geohash und ein sphärisches Erdmodell verwendet.
Da die Implementierung auf sorted sets basiert, kannst du immer noch ZRANGE, ZCARD und ZREM auf demselben Key verwenden.
Hinweis:
GEORADIUSundGEORADIUSBYMEMBERsind seit Redis 6.2 veraltet. Bei neuem Code solltest duGEOSEARCHverwenden.
3. Spring Data Redis einrichten
Füge den Redis-Starter zu pom.xml hinzu:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
Konfiguriere die Verbindung in application.properties:
spring.data.redis.host=localhost
spring.data.redis.port=6379
Spring Boot erstellt automatisch ein StringRedisTemplate-Bean. Das ist die richtige Wahl für Geo, da Member normalerweise String-IDs sind. Du greifst auf die Geo-Befehle über opsForGeo() zu, was GeoOperations<String, String> zurückgibt.
4. Standorte speichern
Erstelle einen Service, der alle Geo-Operationen bündelt:
@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);
}
}
Erklärung:
Pointstammt aus dem Paketorg.springframework.data.geo. Der Konstruktor erwartet(x, y), also (Längengrad, Breitengrad).geoOps.add(...)entsprichtGEOADD. WennstoreIdbereits existiert, werden die alten Koordinaten überschrieben. Es gibt keine doppelten Einträge.geoOps.remove(...)ist im Wesentlichen ein Aufruf zuZREM, da Geo auf sorted sets basiert.
Wenn du mehrere Punkte auf einmal laden musst, kannst du eine Map<String, Point> übergeben, um alle in einem Befehl zu senden:
public void addStores(Map<String, Point> stores) {
geoOps.add(KEY, stores);
}
Das spart Netzwerk-Round-Trips ein, wenn man es mit dem Aufruf in einer Schleife vergleicht.
5. Nächste Standorte finden
5.1. Suche nach Benutzerkoordinaten
Der häufigste Fall ist, dass die App die GPS-Position des Benutzers hochlädt. Du musst die N nächsten Shops innerhalb eines vorgegebenen Radius zurückgeben. Deklariere zunächst ein record für das Ergebnis:
public record NearbyStore(String storeId, double distanceKm, double longitude, double latitude) {
}
Füge dann die Suchmethode zu StoreLocatorService hinzu:
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();
}
Was macht jeder Teil:
GeoReference.fromCoordinate(...)entsprichtFROMLONLAT, dem Referenzpunkt zum Messen der Entfernung.new Distance(radiusKm, Metrics.KILOMETERS)entsprichtBYRADIUS 2 km. Die Einheit, die du hier wählst, ist auch die Einheit der zurückgegebenen Entfernung.includeDistance()undincludeCoordinates()fügenWITHDISTundWITHCOORDhinzu. Wenn duincludeCoordinates()weglässt, gibtgetPoint()null zurück.sortAscending()sortiert von nah zu fern.limit(limit)fügtCOUNThinzu.
Probier es mit Koordinaten des Hoan Kiem Sees:
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. Suche um einen bereits vorhandenen Standort
Manchmal möchtest du „andere Shops in der Nähe dieses Shops” vorschlagen. Du brauchst keine Koordinaten, sondern verwendest den Member selbst als Zentrum:
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 entspricht FROMMEMBER. Die Ergebnisse enthalten immer den Member selbst mit einer Entfernung von 0, daher musst du ihn mit filter ausfiltern.
5.3. Suche in einem Begrenzungsrahmen
Wenn der Benutzer die Karte zieht, brauchst du normalerweise alle Punkte im angezeigten Rahmen, nicht in einem Kreis. Dann verwendest du BoundingBox (entspricht 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 definiert den Rahmen durch Breite und Höhe, mit dem Referenzpunkt als Mittelpunkt.
6. Entfernung berechnen und Koordinaten abrufen
Neben der Suche bietet GeoOperations zwei weitere nützliche Operationen:
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);
}
distanceentsprichtGEODIST. Wenn einer der beiden Member nicht existiert, gibt die Methode null zurück.positionentsprichtGEOPOS. Die zurückgegebene Liste enthält null-Elemente für nicht existierende Member.
Die Koordinaten, die du ausliest, weichen leicht vom Wert ab, den du geschrieben hast, zum Beispiel 105.85470169782638 statt 105.8547. Das ist der Fehler des 52-Bit-Geohash, etwa weniger als ein Meter. Du solltest nicht mit equals() mit dem Originalwert vergleichen.
7. Tests mit Testcontainers schreiben
Damit Tests auf einer echten Redis-Instanz laufen, ohne dass du von deinem System abhängig bist, verwendest du Testcontainers. Füge Abhängigkeiten hinzu:
<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>
Schreib Tests für das Hauptverhalten:
@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") teilt Spring Boot mit, dass es Host und Port aus dem Container konfigurieren soll. Du brauchst nicht @DynamicPropertySource. Tests für Entfernungen verwenden isCloseTo(..., within(...)) anstelle von absoluten Vergleichen, da die Ergebnisse einen kleinen Fehler haben, wie in Abschnitt 6 erwähnt.
8. Häufige Fehler und wann du Redis Geo nicht verwenden solltest
Längengrad und Breitengrad vertauschen. Das ist der häufigste Fehler. Google Maps zeigt „21.0287, 105.8522” (lat, lon) an, aber Redis und Point erwarten (lon, lat). Wenn du sie vertauschst, kann Redis einen Fehler invalid longitude,latitude pair melden, oder noch schlimmer, es speichert erfolgreich einen Punkt an falscher Position.
Hinweis: Redis akzeptiert nur Breitengrade im Bereich von ±85.05112878 Grad gemäß der Web-Mercator-Projektion. Du kannst keine Punkte dicht an den beiden Polen speichern.
Speichere detaillierte Daten nicht im Member. Member sollten kurze IDs wie store:1024 sein. Verpack nicht JSON-Name, Adresse, Öffnungszeiten im Member. Speichere detaillierte Informationen in einem Redis Hash oder einer Datenbank. Nachdem du die ID-Liste aus GEOSEARCH hast, holst du die Details mit einem Batch-Aufruf.
Keys sind zu groß. Ein sorted set mit Dutzenden Millionen Punkten ist ein big key. Das Löschen oder Migrieren dieses Keys blockiert Redis. In Redis Cluster liegen alle Punkte eines Keys auf demselben Shard. Du solltest Keys nach Stadt oder Region aufteilen, zum Beispiel coffee:hanoi, coffee:hcm.
Es gibt kein TTL pro Member. Bei sich ständig aktualisierenden Fahrerstandorten werden „tote” Member für immer dort bleiben. Du brauchst einen separaten Bereinigungsjob, zum Beispiel ein zusätzliches sorted set, das den letzten Aktualisierungszeitstempel speichert, und das periodisch abgelaufene Member löscht.
Zu großer Radius ohne limit. 50-km-Suche ohne Limit in einer Stadt mit vielen Punkten kann Zehntausende von Elementen zurückgeben. Du solltest immer ein angemessenes Limit setzen.
Wann du Redis Geo NICHT verwenden solltest:
- Du musst nach Polygonen abfragen, geometrische Schnitte machen oder Routenplanung durchführen. Verwende PostGIS oder Elasticsearch geo.
- Du musst mit mehreren Bedingungen filtern, wie „nächstgelegene, offen, Parkplatz vorhanden”. Redis Geo filtert nur nach Entfernung. Die restliche Filterung musst du auf der Anwendungsebene tun oder eine Query Engine verwenden.
- Redis ist das einzige Speichermedium ohne Persistierung. Geo sollte ein Beschleunigungsindex sein, während die ursprüngliche Datenquelle in der Datenbank bleibt.
9. Fazit
Redis Geo verwandelt das Problem „nächsten Ort finden” in ein paar einfache Befehle auf einem sorted set. Du hast gelernt, wie du Koordinaten mit GEOADD speicherst, nach Radius oder Begrenzungsrahmen mit GEOSEARCH suchst und Entfernungen mit GEODIST berechnest. Du hast alles auch in einen Spring-Boot-Service gepackt und ihn mit Tests getestet, die Testcontainers verwenden.
Wenn du es praktisch anwendest, musst du drei Dinge beachten: Immer Längengrad zuerst übergeben, halte Member als kurze IDs, und teile Keys nach Region auf, wenn die Daten groß sind. Bei komplexeren Raumabfragen als nur Entfernung solltest du Redis mit einem spezialisierten Geospatial-System kombinieren.