Redis Geo: encontre o local mais próximo com Spring Boot
1. Visão geral
O recurso “encontre a loja perto de você” aparece na maioria dos aplicativos de entrega de comida, chamada de táxi ou mapa de cadeias de cafés. Você pode escrever a fórmula haversine e varrer a tabela inteira de locais, mas isso é lento com grandes volumes de dados.
Redis possui um conjunto de comandos Geo para resolver esse problema com latência de apenas alguns milissegundos. Este artigo o orienta a armazenar coordenadas no Redis, encontrar o local mais próximo com GEOSEARCH, calcular a distância entre dois pontos e escrever testes para essas operações no Spring Boot 3.x.
2. Como o Redis Geo funciona
O Redis não possui um tipo de dados Geo separado. Cada coordenada é codificada como um geohash de 52 bits, que é armazenado como um score em um conjunto ordenado. Pontos próximos geralmente têm geohashes próximos. Graças a isso, o Redis precisa verificar apenas alguns intervalos de scores em vez de todo o conjunto de dados.
Você pode testar rapidamente com 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
O comando GEOADD aceita longitude primeiro, latitude depois. GEOSEARCH encontra os membros dentro de um raio de 2 km ao redor do Lago Hoàn Kiếm, ordena do mais próximo para o mais distante (ASC) e retorna a distância (WITHDIST):
1) 1) "cong-trang-tien"
2) "0.4465"
2) 1) "cafe-giang"
2) "0.5845"
highlands-lotte fica a cerca de 4 km de distância, então não aparece. Os números dos resultados em sua máquina podem diferir nos últimos dígitos decimais, porque o Redis usa geohash e um modelo esférico da Terra.
Como é essencialmente um conjunto ordenado, você ainda pode usar ZRANGE, ZCARD, ZREM na mesma chave.
Nota:
GEORADIUSeGEORADIUSBYMEMBERforam marcados como deprecated a partir do Redis 6.2. Para novo código, você deve usarGEOSEARCH.
3. Instalando Spring Data Redis
Adicione o starter do Redis ao pom.xml:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
Configure a conexão em application.properties:
spring.data.redis.host=localhost
spring.data.redis.port=6379
O Spring Boot cria automaticamente o bean StringRedisTemplate. Essa é a escolha certa para Geo, porque os membros costumam ser IDs em formato de string. Você acessa o conjunto de comandos Geo por meio de opsForGeo(), que retorna GeoOperations<String, String>.
4. Armazenando locais
Crie um serviço que agrupe todas as operações 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);
}
}
Explicação:
Pointpertence ao pacoteorg.springframework.data.geo. O construtor aceita(x, y), ou seja, (longitude, latitude).geoOps.add(...)é equivalente aGEOADD. SestoreIdjá existe, as coordenadas antigas são sobrescritas. Você não terá registros duplicados.geoOps.remove(...)na verdade chamaZREM, porque Geo é um conjunto ordenado.
Quando você precisa carregar vários pontos de uma vez, passe um Map<String, Point> para enviá-los tudo em um único comando:
public void addStores(Map<String, Point> stores) {
geoOps.add(KEY, stores);
}
Dessa forma, você economiza round-trips de rede em comparação com chamadas addStore em um loop.
5. Encontrando o local mais próximo
5.1. Buscando pela coordenada do usuário
O caso mais comum é quando o aplicativo envia a localização GPS do usuário. Você precisa retornar as N lojas mais próximas dentro de um raio especificado. Primeiro, declare um registro para o resultado:
public record NearbyStore(String storeId, double distanceKm, double longitude, double latitude) {
}
Em seguida, adicione o método de busca ao 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();
}
O que cada parte faz:
GeoReference.fromCoordinate(...)corresponde aFROMLONLAT, ou seja, o ponto de referência para medir a distância.new Distance(radiusKm, Metrics.KILOMETERS)corresponde aBYRADIUS 2 km. A unidade que você escolhe aqui também é a unidade da distância retornada.includeDistance()eincludeCoordinates()adicionamWITHDISTeWITHCOORD. SeincludeCoordinates()estiver faltando,getPoint()retornaránull.sortAscending()ordena do mais próximo para o mais distante.limit(limit)adicionaCOUNT.
Tente chamar com a localização do 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. Buscando ao redor de um local existente
Às vezes, você quer sugerir “outras lojas perto desta loja”. Você não precisa de coordenadas, apenas use o próprio membro 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. O resultado sempre contém o próprio membro de origem com distância 0, então você precisa filtrá-lo como na linha filter.
5.3. Buscando dentro de um retângulo delimitador
Quando o usuário arrasta o mapa, você geralmente precisa de todos os pontos dentro do viewport, não dentro de um círculo. Neste caso, você usa BoundingBox (correspondente 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 o retângulo pela largura e altura, com o centro sendo o ponto de referência.
6. Calculando distância e obtendo coordenadas
Além de buscar, GeoOperations também suporta duas outras operações úteis:
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. Se um dos dois membros não existe, o método retornanull.positioncorresponde aGEOPOS. A lista retornada pode conter elementosnullpara membros que não existem.
As coordenadas lidas novamente serão um pouco diferentes do valor que você escreveu, por exemplo 105.85470169782638 em vez de 105.8547. Esse é o erro do geohash de 52 bits, cerca de um metro. Não compare com equals o valor original.
7. Escrevendo testes com Testcontainers
Para executar testes em um Redis real sem depender da máquina de desenvolvimento, use o Testcontainers. Adicione a dependência:
<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>
Escreva testes para o comportamento 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") informa ao Spring Boot para configurar automaticamente o host e a porta do contêiner. Você não precisa de @DynamicPropertySource. Para testar distâncias, use isCloseTo(..., within(...)) em vez de comparação absoluta, porque o resultado tem um pequeno erro como mencionado na seção 6.
8. Erros comuns e quando não usar
Invertendo longitude e latitude. Este é o erro número um. Google Maps exibe “21.0287, 105.8522” (lat, lon), mas Redis e Point precisam de (lon, lat). Se inverter, o Redis pode relatar um erro invalid longitude,latitude pair, ou pior, armazenar com sucesso um ponto em uma localização errada.
Nota: O Redis aceita apenas latitudes no intervalo ±85.05112878 graus, de acordo com os limites da projeção Web Mercator. Você não pode armazenar pontos perto dos dois polos.
Armazenando dados detalhados no membro. O membro deve ser um ID curto como store:1024. Não coloque JSON com nome, endereço, horário de funcionamento no membro. Armazene informações detalhadas em um Redis Hash ou banco de dados. Depois de ter a lista de IDs de GEOSEARCH, busque os detalhes com uma única chamada em lote.
Chave muito grande. Um conjunto ordenado contendo dezenas de milhões de pontos é uma chave grande. Excluir ou migrar essa chave bloqueará o Redis. No Redis Cluster, todos os pontos de uma chave ficam no mesmo shard. Você deve dividir a chave por cidade ou região, como coffee:hanoi, coffee:hcm.
Sem TTL por membro. Com posições de motorista atualizadas continuamente, os membros “mortos” permanecerão para sempre. Você precisa de um job de limpeza separado, como um conjunto ordenado auxiliar que armazena o timestamp da última atualização, e depois exclua periodicamente os membros vencidos.
Raio muito grande sem limit. Buscar 50 km sem limites em uma cidade densamente povoada pode retornar dezenas de milhares de elementos. Você sempre deve definir um limit razoável.
Quando NÃO usar Redis Geo:
- Você precisa consultar por polígono (limites do distrito, zona de entrega), interseção geométrica ou roteamento. Use PostGIS ou Elasticsearch geo.
- Você precisa filtrar por múltiplas condições, como “mais próximo, aberto agora, com estacionamento”. Redis Geo filtra apenas por distância. Você deve fazer o resto da filtragem na camada de aplicação ou usar Redis Query Engine.
- O Redis é o único local de armazenamento sem persistência. Geo deve ser um índice acelerador, enquanto a fonte de dados original reside no banco de dados.
9. Conclusão
Redis Geo transforma o problema “encontre o local mais próximo” em alguns comandos simples em um conjunto ordenado. Você aprendeu a armazenar coordenadas com GEOADD, pesquisar por raio ou retângulo delimitador com GEOSEARCH e calcular distância com GEODIST. Você também envolveu tudo isso em um serviço Spring Boot com testes usando Testcontainers.
Ao aplicar na prática, lembre-se de três pontos: sempre passe a longitude primeiro, mantenha o membro como um ID curto e divida a chave por região quando os dados são grandes. Para consultas espaciais mais complexas do que apenas distância, você deve combinar Redis com um sistema especializado em dados geográficos.