Redis Geo: tìm địa điểm gần nhất với Spring Boot
1. Tổng quan
Tính năng “tìm cửa hàng gần bạn” xuất hiện ở hầu hết ứng dụng giao đồ ăn, gọi xe hay bản đồ chuỗi cà phê. Bạn có thể tự viết công thức haversine rồi quét toàn bộ bảng địa điểm, nhưng cách này chậm khi dữ liệu lớn.
Redis có sẵn nhóm lệnh Geo để giải quyết bài toán này với độ trễ chỉ vài mili giây. Bài viết hướng dẫn bạn lưu tọa độ vào Redis, tìm địa điểm gần nhất bằng GEOSEARCH, tính khoảng cách giữa hai điểm và viết test cho các thao tác đó trong Spring Boot 3.x.
2. Redis Geo hoạt động thế nào
Redis không có kiểu dữ liệu Geo riêng. Mỗi tọa độ được mã hóa thành một geohash 52-bit, rồi lưu làm score trong một sorted set. Các điểm gần nhau thường có geohash gần nhau. Nhờ vậy Redis chỉ cần quét vài khoảng score thay vì toàn bộ tập dữ liệu.
Bạn thử nhanh bằng 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
Lệnh GEOADD nhận kinh độ trước, vĩ độ sau. GEOSEARCH tìm các member nằm trong bán kính 2 km quanh Hồ Hoàn Kiếm, sắp xếp từ gần tới xa (ASC) và trả kèm khoảng cách (WITHDIST):
1) 1) "cong-trang-tien"
2) "0.4465"
2) 1) "cafe-giang"
2) "0.5845"
highlands-lotte cách khoảng 4 km nên không xuất hiện. Kết quả số trên máy bạn có thể lệch ở chữ số thập phân cuối, vì Redis dùng geohash và mô hình Trái Đất hình cầu.
Vì bản chất là sorted set, bạn vẫn dùng được ZRANGE, ZCARD, ZREM trên cùng key.
Lưu ý:
GEORADIUSvàGEORADIUSBYMEMBERđã bị đánh dấu deprecated từ Redis 6.2. Với code mới, bạn nên dùngGEOSEARCH.
3. Cài đặt Spring Data Redis
Thêm starter Redis vào pom.xml:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
Cấu hình kết nối trong application.properties:
spring.data.redis.host=localhost
spring.data.redis.port=6379
Spring Boot tự tạo bean StringRedisTemplate. Đây là lựa chọn phù hợp cho Geo, vì member thường là ID dạng chuỗi. Bạn truy cập nhóm lệnh Geo qua opsForGeo(), trả về GeoOperations<String, String>.
4. Lưu địa điểm
Tạo một service gom mọi thao tác 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);
}
}
Giải thích:
Pointthuộc packageorg.springframework.data.geo. Constructor nhận(x, y), tức (longitude, latitude).geoOps.add(...)tương đươngGEOADD. NếustoreIdđã tồn tại, tọa độ cũ bị ghi đè. Bạn không bị trùng bản ghi.geoOps.remove(...)thực chất gọiZREM, vì Geo là sorted set.
Khi cần nạp nhiều điểm một lúc, bạn truyền một Map<String, Point> để gửi tất cả trong một lệnh:
public void addStores(Map<String, Point> stores) {
geoOps.add(KEY, stores);
}
Cách này tiết kiệm round-trip mạng so với gọi addStore trong vòng lặp.
5. Tìm địa điểm gần nhất
5.1. Tìm theo tọa độ người dùng
Trường hợp phổ biến nhất là ứng dụng gửi lên vị trí GPS của người dùng. Bạn cần trả về N cửa hàng gần nhất trong một bán kính cho trước. Trước tiên, khai báo một record cho kết quả:
public record NearbyStore(String storeId, double distanceKm, double longitude, double latitude) {
}
Sau đó thêm phương thức tìm kiếm vào 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();
}
Từng phần làm gì:
GeoReference.fromCoordinate(...)tương ứngFROMLONLAT, tức điểm gốc để đo khoảng cách.new Distance(radiusKm, Metrics.KILOMETERS)tương ứngBYRADIUS 2 km. Đơn vị bạn chọn ở đây cũng là đơn vị của khoảng cách trả về.includeDistance()vàincludeCoordinates()thêmWITHDISTvàWITHCOORD. Nếu thiếuincludeCoordinates(),getPoint()sẽ trả vềnull.sortAscending()sắp xếp từ gần tới xa.limit(limit)thêmCOUNT.
Gọi thử với vị trí Hồ 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. Tìm quanh một địa điểm đã có
Đôi khi bạn muốn gợi ý “các cửa hàng khác gần cửa hàng này”. Bạn không cần tọa độ, chỉ cần dùng chính member làm tâm:
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 tương ứng FROMMEMBER. Kết quả luôn chứa chính member gốc với khoảng cách 0, nên bạn cần lọc nó ra như dòng filter.
5.3. Tìm trong khung hình chữ nhật
Khi người dùng kéo bản đồ, bạn thường cần mọi điểm trong khung đang hiển thị, không phải trong một hình tròn. Lúc này bạn dùng BoundingBox (tương ứng 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 định nghĩa khung theo chiều rộng và chiều cao, với tâm là điểm tham chiếu.
6. Tính khoảng cách và lấy tọa độ
Ngoài tìm kiếm, GeoOperations còn hỗ trợ hai thao tác hữu ích khác:
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);
}
distancetương ứngGEODIST. Nếu một trong hai member không tồn tại, phương thức trả vềnull.positiontương ứngGEOPOS. Danh sách trả về có phần tửnullvới member không tồn tại.
Tọa độ đọc lại sẽ hơi khác giá trị bạn đã ghi, chẳng hạn 105.85470169782638 thay vì 105.8547. Đó là sai số của geohash 52-bit, cỡ dưới một mét. Bạn đừng so sánh bằng equals với giá trị gốc.
7. Viết test với Testcontainers
Để test chạy trên Redis thật mà không phụ thuộc máy dev, bạn dùng Testcontainers. Thêm dependency:
<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>
Viết test cho hành vi chính:
@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") báo cho Spring Boot tự cấu hình host và port từ container. Bạn không cần @DynamicPropertySource. Test khoảng cách dùng isCloseTo(..., within(...)) thay vì so sánh tuyệt đối, vì kết quả có sai số nhỏ như đã nói ở mục 6.
8. Lỗi hay gặp và khi nào không nên dùng
Đảo kinh độ và vĩ độ. Đây là lỗi số một. Google Maps hiển thị “21.0287, 105.8522” (lat, lon), còn Redis và Point cần (lon, lat). Nếu đảo, Redis có thể báo lỗi invalid longitude,latitude pair, hoặc tệ hơn là lưu thành công một điểm sai vị trí.
Lưu ý: Redis chỉ chấp nhận vĩ độ trong khoảng ±85.05112878 độ, theo giới hạn của phép chiếu Web Mercator. Bạn không lưu được điểm ở sát hai cực.
Lưu dữ liệu chi tiết vào member. Member nên là ID ngắn như store:1024. Đừng nhét JSON tên, địa chỉ, giờ mở cửa vào member. Hãy lưu thông tin chi tiết ở Redis Hash hoặc database. Sau khi có danh sách ID từ GEOSEARCH, bạn lấy chi tiết bằng một lần gọi batch.
Key quá lớn. Một sorted set chứa hàng chục triệu điểm là một big key. Xóa hay migrate key này sẽ chặn Redis. Trong Redis Cluster, mọi điểm của một key nằm trên cùng một shard. Bạn nên chia key theo thành phố hoặc vùng, ví dụ coffee:hanoi, coffee:hcm.
Không có TTL theo từng member. Với vị trí tài xế cập nhật liên tục, các member “chết” sẽ ở lại mãi. Bạn cần job dọn dẹp riêng, ví dụ một sorted set phụ lưu timestamp cập nhật cuối, rồi xóa định kỳ các member quá hạn.
Bán kính quá lớn không có limit. Tìm 50 km không giới hạn trong một thành phố đông điểm có thể trả về hàng chục nghìn phần tử. Bạn nên luôn đặt limit hợp lý.
Khi nào KHÔNG nên dùng Redis Geo:
- Bạn cần truy vấn theo đa giác (ranh giới quận, vùng giao hàng), giao cắt hình học, hoặc tìm đường. Hãy dùng PostGIS hoặc Elasticsearch geo.
- Bạn cần lọc kết hợp nhiều điều kiện, như “gần nhất, đang mở cửa, có chỗ đậu xe”. Redis Geo chỉ lọc theo khoảng cách. Phần lọc còn lại bạn phải làm ở tầng ứng dụng, hoặc dùng Redis Query Engine.
- Redis là nơi lưu trữ duy nhất mà không có persistence. Geo nên là chỉ mục tăng tốc, còn nguồn dữ liệu gốc vẫn nằm ở database.
9. Kết luận
Redis Geo biến bài toán “tìm địa điểm gần nhất” thành vài lệnh đơn giản trên sorted set. Bạn đã biết cách lưu tọa độ với GEOADD, tìm theo bán kính hoặc khung hình chữ nhật với GEOSEARCH, và tính khoảng cách với GEODIST. Bạn cũng đã gói tất cả vào một service Spring Boot có test bằng Testcontainers.
Khi áp dụng thực tế, bạn cần nhớ ba điểm: luôn truyền kinh độ trước, giữ member là ID ngắn, và chia key theo vùng khi dữ liệu lớn. Với các truy vấn không gian phức tạp hơn khoảng cách, bạn nên kết hợp Redis với một hệ thống chuyên về dữ liệu địa lý.