Redis Geo: encontrar el lugar más cercano con Spring Boot
1. Descripción general
La característica de “encontrar una tienda cerca de ti” aparece en la mayoría de aplicaciones de entrega de comida, transporte compartido o mapas de cadenas de café. Podrías escribir tu propia fórmula de haversine y luego escanear toda la tabla de ubicaciones, pero este enfoque es lento cuando tienes datos grandes.
Redis tiene un conjunto de comandos Geo listos para resolver este problema con una latencia de solo unos pocos milisegundos. Este artículo te enseña cómo guardar coordenadas en Redis, encontrar el lugar más cercano usando GEOSEARCH, calcular la distancia entre dos puntos y escribir pruebas para estas operaciones en Spring Boot 3.x.
2. Cómo funciona Redis Geo
Redis no tiene un tipo de datos Geo separado. Cada coordenada se codifica en un geohash de 52 bits y se guarda como score en un sorted set. Los puntos cercanos suelen tener geohash cercano. Gracias a esto, Redis solo necesita escanear algunos rangos de score en lugar de todo el conjunto de datos.
Prueba rápidamente con 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
El comando GEOADD toma longitud primero, latitud después. GEOSEARCH busca miembros que se encuentren dentro de un radio de 2 km alrededor del Lago Hoàn Kiếm, ordenados de cercano a lejano (ASC) e incluye la distancia (WITHDIST):
1) 1) "cong-trang-tien"
2) "0.4465"
2) 1) "cafe-giang"
2) "0.5845"
highlands-lotte está a unos 4 km, por lo que no aparece. Los números en tu máquina pueden diferir en los últimos decimales, ya que Redis usa geohash y un modelo de Tierra esférica.
Como su naturaleza es un sorted set, aún puedes usar ZRANGE, ZCARD, ZREM en la misma clave.
Nota:
GEORADIUSyGEORADIUSBYMEMBERhan sido marcados como deprecated desde Redis 6.2. Para código nuevo, debes usarGEOSEARCH.
3. Configurar Spring Data Redis
Añade el starter Redis a pom.xml:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
Configura la conexión en application.properties:
spring.data.redis.host=localhost
spring.data.redis.port=6379
Spring Boot crea automáticamente el bean StringRedisTemplate. Esta es la opción adecuada para Geo, porque los miembros suelen ser IDs de tipo string. Accedes al conjunto de comandos Geo a través de opsForGeo(), que devuelve GeoOperations<String, String>.
4. Guardar ubicaciones
Crea un service que agrupe todas las operaciones 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);
}
}
Explicación:
Pointpertenece al paqueteorg.springframework.data.geo. El constructor toma(x, y), es decir, (longitude, latitude).geoOps.add(...)es equivalente aGEOADD. SistoreIdya existe, las coordenadas antiguas se sobrescriben. No tendrás registros duplicados.geoOps.remove(...)en realidad llama aZREM, porque Geo es un sorted set.
Cuando necesites cargar múltiples puntos de una vez, pasas un Map<String, Point> para enviar todo en un comando:
public void addStores(Map<String, Point> stores) {
geoOps.add(KEY, stores);
}
Este enfoque ahorra viajes de red en comparación con llamar a addStore en un bucle.
5. Encontrar ubicaciones cercanas
5.1. Buscar por coordenadas del usuario
El caso más común es que la aplicación envíe la ubicación GPS del usuario. Necesitas devolver las N tiendas más cercanas dentro de un radio especificado. Primero, declara un record para el resultado:
public record NearbyStore(String storeId, double distanceKm, double longitude, double latitude) {
}
Luego añade el método de búsqueda a 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();
}
Qué hace cada parte:
GeoReference.fromCoordinate(...)corresponde aFROMLONLAT, es decir, el punto de referencia desde el que medir la distancia.new Distance(radiusKm, Metrics.KILOMETERS)corresponde aBYRADIUS 2 km. La unidad que elijas aquí también será la unidad de la distancia devuelta.includeDistance()eincludeCoordinates()añadenWITHDISTyWITHCOORD. Si faltaincludeCoordinates(),getPoint()devolveránull.sortAscending()ordena de cercano a lejano.limit(limit)añadeCOUNT.
Intenta llamar con la ubicación del Lago Hoàn Kiếm:
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. Buscar alrededor de una ubicación existente
A veces quieres sugerir “otras tiendas cerca de esta tienda”. No necesitas las coordenadas, solo usa el miembro como centro:
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 corresponde a FROMMEMBER. El resultado siempre contiene el miembro de origen con distancia 0, por lo que necesitas filtrarlo como en la línea filter.
5.3. Buscar dentro de un rectángulo
Cuando el usuario arrastra el mapa, generalmente necesitas todos los puntos dentro del marco visible, no dentro de un círculo. Aquí usas BoundingBox (correspondiente a 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 define el marco por ancho y alto, con el centro siendo el punto de referencia.
6. Calcular distancia y obtener coordenadas
Además de buscar, GeoOperations admite dos operaciones útiles más:
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);
}
distancecorresponde aGEODIST. Si uno de los dos miembros no existe, el método devuelvenull.positioncorresponde aGEOPOS. La lista devuelta tiene elementosnullpara miembros que no existen.
Las coordenadas que leas de vuelta serán ligeramente diferentes de las que escribiste, por ejemplo 105.85470169782638 en lugar de 105.8547. Este es el error del geohash de 52 bits, de menos de un metro. No compares con equals contra el valor original.
7. Escribir pruebas con Testcontainers
Para que las pruebas se ejecuten en un Redis real sin depender de la máquina de desarrollo, usa Testcontainers. Añade la dependencia:
<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>
Escribe pruebas para el comportamiento 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") le indica a Spring Boot que configure automáticamente el host y puerto desde el contenedor. No necesitas @DynamicPropertySource. Las pruebas de distancia usan isCloseTo(..., within(...)) en lugar de comparación absoluta, porque el resultado tiene un pequeño error como se mencionó en la sección 6.
8. Errores comunes y cuándo no usar Redis Geo
Intercambiar longitud y latitud. Este es el error número uno. Google Maps muestra “21.0287, 105.8522” (lat, lon), pero Redis y Point necesitan (lon, lat). Si los intercambias, Redis puede reportar un error invalid longitude,latitude pair, o peor aún, guardar un punto con ubicación incorrecta.
Nota: Redis solo acepta latitudes en el rango ±85.05112878 grados, según los límites de la proyección Web Mercator. No puedes guardar puntos cerca de los dos polos.
Guardar datos detallados en el miembro. El miembro debe ser un ID corto como store:1024. No metas JSON con nombre, dirección, horario de apertura en el miembro. Guarda la información detallada en un Redis Hash o base de datos. Después de obtener una lista de IDs de GEOSEARCH, recupera los detalles con una sola llamada batch.
Claves demasiado grandes. Un sorted set que contiene decenas de millones de puntos es una big key. Eliminar o migrar esta clave bloqueará Redis. En Redis Cluster, todos los puntos de una clave residen en el mismo shard. Debes dividir las claves por ciudad o región, por ejemplo coffee:hanoi, coffee:hcm.
Sin TTL por miembro. Con ubicaciones de conductores que se actualizan constantemente, los miembros “muertos” permanecerán para siempre. Necesitas un trabajo de limpieza separado, por ejemplo, un sorted set auxiliar que guarde la última marca de tiempo de actualización, luego elimina periódicamente los miembros obsoletos.
Radio grande sin limit. Buscar sin límite en un radio de 50 km en una ciudad con muchos puntos puede devolver decenas de miles de elementos. Siempre debes establecer un limit razonable.
Cuándo NO usar Redis Geo:
- Necesitas consultas por polígono (límites de distrito, zonas de entrega), intersecciones geométricas o enrutamiento. Usa PostGIS o Elasticsearch geo.
- Necesitas filtrar combinando múltiples condiciones, como “más cercano, abierto ahora, tiene estacionamiento”. Redis Geo solo filtra por distancia. El resto del filtrado debe hacerlo en la capa de aplicación o usar Redis Query Engine.
- Redis es el único almacenamiento sin persistencia. Geo debe ser un índice de aceleración, mientras que la fuente de datos original reside en la base de datos.
9. Conclusión
Redis Geo transforma el problema de “encontrar el lugar más cercano” en unos pocos comandos simples sobre un sorted set. Ya sabes cómo guardar coordenadas con GEOADD, buscar por radio o por rectángulo con GEOSEARCH, y calcular distancia con GEODIST. También has empaquetado todo en un service Spring Boot con pruebas usando Testcontainers.
Al aplicar en la práctica, recuerda tres puntos: siempre pasa la longitud primero, mantén el miembro como un ID corto, y divide las claves por región cuando los datos son grandes. Para consultas espaciales más complejas que la distancia, debes combinar Redis con un sistema especializado en datos geográficos.