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: GEORADIUS y GEORADIUSBYMEMBER han sido marcados como deprecated desde Redis 6.2. Para código nuevo, debes usar GEOSEARCH.

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:

  • Point pertenece al paquete org.springframework.data.geo. El constructor toma (x, y), es decir, (longitude, latitude).
  • geoOps.add(...) es equivalente a GEOADD. Si storeId ya existe, las coordenadas antiguas se sobrescriben. No tendrás registros duplicados.
  • geoOps.remove(...) en realidad llama a ZREM, 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 a FROMLONLAT, es decir, el punto de referencia desde el que medir la distancia.
  • new Distance(radiusKm, Metrics.KILOMETERS) corresponde a BYRADIUS 2 km. La unidad que elijas aquí también será la unidad de la distancia devuelta.
  • includeDistance() e includeCoordinates() añaden WITHDIST y WITHCOORD. Si falta includeCoordinates(), getPoint() devolverá null.
  • sortAscending() ordena de cercano a lejano. limit(limit) añade COUNT.

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);
}
  • distance corresponde a GEODIST. Si uno de los dos miembros no existe, el método devuelve null.
  • position corresponde a GEOPOS. La lista devuelta tiene elementos null para 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.

redisspring-bootspring-data-redisgeospatialjava