Spring Boot Redis Geo: Nearby Stores & Geofencing – Pitfalls, Optimizations & Production Patterns

A hands-on guide to replacing MySQL spatial queries with Redis Geo for nearby-store search and geofencing, covering Spring Boot integration, GeoHash internals, coordinate-system pitfalls, pagination strategies, cluster key design, and real-world performance numbers from a 5,000-store convenience-chain deployment.

Xiaolin Talks Programming
Xiaolin Talks Programming
Xiaolin Talks Programming
Spring Boot Redis Geo: Nearby Stores & Geofencing – Pitfalls, Optimizations & Production Patterns

Why Redis Geo Beats MySQL for Location Queries

The author migrated a nationwide convenience-store chain (thousands of stores) from MySQL spatial indexes to Redis Geo. MySQL P99 latency was 200 ms; after switching, P99 dropped below 2 ms and two servers were decommissioned.

Redis Geo Internals: ZSet + GeoHash

Redis Geo is not a new data structure—it uses a sorted set (ZSet) where each member's score is a 52-bit integer encoding longitude (26 bits) and latitude (26 bits) via GeoHash. GeoHash repeatedly bisects the longitude range [-180,180] and latitude range [-90,90], producing a bit string. Spatially adjacent points have similar scores, so ZSet range queries ( GEOSEARCH) effectively become score-range lookups instead of computing distances for every point. The 52-bit encoding yields centimeter-level precision; keeping 6 decimal places in practice is sufficient.

Spring Boot Integration

Add spring-boot-starter-data-redis and commons-pool2 (required for Lettuce connection pooling). Example application.yml:

spring:
  data:
    redis:
      host: 127.0.0.1
      port: 6379
      password: yourpassword
      database: 0
      timeout: 3s
      lettuce:
        pool:
          max-active: 16
          max-idle: 8
          min-idle: 2
          max-wait: 5s

Without explicit pool settings, Lettuce's pool stays disabled and high concurrency causes connection leaks.

The author wraps RedisTemplate.opsForGeo() in a GeoCommand component to simplify GeoRadiusCommandArgs usage:

@Component
public class GeoCommand {
    private final RedisTemplate<String, String> redisTemplate;
    public GeoCommand(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }
    public void add(String key, String member, double lon, double lat) {
        redisTemplate.opsForGeo().add(key, new Point(lon, lat), member);
    }
    public void addAll(String key, Map<String, Point> points) {
        points.forEach((member, point) -> redisTemplate.opsForGeo().add(key, point, member));
    }
    public List<NearbyResult> searchNearby(String key, double lon, double lat, double radiusKm) {
        Circle circle = new Circle(new Point(lon, lat), new Distance(radiusKm, Metrics.KILOMETERS));
        RedisGeoCommands.GeoRadiusCommandArgs args = RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs()
                .includeDistance()
                .sortAscending();
        GeoResults<RedisGeoCommands.GeoLocation<String>> results = redisTemplate.opsForGeo().radius(key, circle, args);
        if (results == null) return Collections.emptyList();
        return results.getContent().stream()
                .map(r -> new NearbyResult(r.getContent().getName(), r.getDistance().getValue()))
                .collect(Collectors.toList());
    }
}

Nearby-Store Query: End-to-End Practice

Store master data lives in MySQL ( id, name, longitude, latitude). On startup, all stores are bulk-loaded into Redis ( geo:stores key) via geoCommand.addAll. Subsequent changes are synced asynchronously via message queue.

Query flow:

Redis Geo does the spatial filter: search 3 km radius, return store IDs + distances.

Application enriches from local cache or MySQL (name, open status, rating).

Application applies final sorting (e.g., rating first, then distance) and filters closed stores.

Pagination: GEOSEARCH supports LIMIT count but not OFFSET. Two options:

Fetch offset+limit and skip in application (fine for a few thousand points).

Cursor-based: use last member as reference for next page (more memory-efficient but complex). The author chose the first approach because users rarely paginate beyond the first page.

Geofencing Implementation

Geofencing reduces to "is a point inside a circle?" For a single fence, Haversine formula works. For thousands of fences (e.g., delivery rider check-in), store all fence centers in a Geo key ( geo:fences). On each location update, search centers within the fence radius (e.g., 500 m). Any hit means the user is inside that fence. Track enter/exit events by comparing current result with previous state stored in Redis ( SETNX or a simple key with TTL).

public List<String> findFencesContainingUser(double lon, double lat) {
    List<NearbyResult> results = geoCommand.searchNearby("geo:fences", lon, lat, 0.5);
    return results.stream().map(NearbyResult::getMember).collect(Collectors.toList());
}

Distance Calculation Pitfalls

1. Coordinate-system mismatch. Chinese map SDKs use different datums: Gaode/Tencent = GCJ-02, Baidu = BD-09, GPS = WGS-84. Storing GPS coordinates but querying with Gaode coordinates yields ~hundreds-of-meters errors. Fix: convert all coordinates to a single system (e.g., GCJ-02) before writing to Redis. The author once had stores in GCJ-02 and app using BD-09; adding a BD-09→GCJ-02 conversion at write time solved it.

2. Longitude/latitude order confusion. Some APIs output lng,lat, others lat,lng. Swapping them can place points in the Gulf of Guinea (0,0). Always verify order.

Real-World Performance & Scaling Limits

5,000 stores, 3,000 QPS peak → 1–2 ms per query, P99 ≤ 5 ms.

Beyond ~1 million points, Redis Geo struggles: memory pressure and single-ZSet latency grow. Consider PostGIS or Elasticsearch geo_shape.

Redis is single-threaded; avoid running slow commands ( KEYS, large SMEMBERS) on the same instance. Dedicate a Redis instance (or at least a separate node) for Geo workloads.

Boundary Issues & Operational Gotchas

GeoHash edge boundaries: Adjacent points on opposite sides of a GeoHash cell boundary have very different scores. Redis GEOSEARCH internally checks the target cell plus its 8 neighbors, so misses are rare except near poles (lat > 85°).

Over-large radius: A 500 km radius without LIMIT can return hundreds of thousands of entries, OOM-ing the client. Always add LIMIT (e.g., 50). For truly large radii, switch to PostGIS.

Cluster key design: In Redis Cluster, a single key lives on one shard. Putting all stores in geo:stores creates a hotspot. Solution: shard by city ( geo:stores:beijing, geo:stores:shanghai). Resolve user's city via GeoIP or reverse-geocoding (one extra network hop, but spreads load).

Temporary keys: Writing user location as a temporary Geo member ( user:temp:xxx) under high concurrency churns keys and triggers GC. Better: keep user location in a separate key or compute Haversine in the application layer (30 lines of code).

Data Synchronization Strategy

Avoid cron-based full refreshes. Use MQ: after MySQL write, publish an event; consumer updates Redis Geo. For frequent updates, version keys ( geo:stores:v2) and switch atomically to avoid intermediate "store exists but not queryable" states.

Production-Ready Geofence Service (Simplified)

@Service
public class FenceService {
    private static final String FENCE_GEO_KEY = "geo:fences";
    private static final String USER_STATUS_PREFIX = "fence:user:";
    private final RedisTemplate<String, String> redisTemplate;
    private final GeoCommand geoCommand;
    public FenceService(RedisTemplate<String, String> redisTemplate, GeoCommand geoCommand) {
        this.redisTemplate = redisTemplate;
        this.geoCommand = geoCommand;
    }
    public String reportUserLocation(String userId, double lon, double lat) {
        double radiusMeters = 500;
        List<NearbyResult> fences = geoCommand.searchNearby(FENCE_GEO_KEY, lon, lat, radiusMeters / 1000.0);
        boolean inside = !fences.isEmpty();
        String statusKey = USER_STATUS_PREFIX + userId;
        Boolean wasInside = redisTemplate.opsForValue().get(statusKey) != null;
        if (inside && !Boolean.TRUE.equals(wasInside)) {
            redisTemplate.opsForValue().set(statusKey, "1", Duration.ofHours(12));
            return "ENTER";
        } else if (!inside && Boolean.TRUE.equals(wasInside)) {
            redisTemplate.delete(statusKey);
            return "EXIT";
        }
        return "NONE";
    }
}

Note: This assumes a user is near at most one fence at a time. Overlapping fences require per-fence state tracking.

Decision Checklist

Data < 1M points: Use Redis Geo directly.

Data > 1M, only circular queries: Shard by city or use Redis Cluster.

Complex polygons, path offsets, etc.: Switch to PostGIS or Elasticsearch.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Performance OptimizationSpring BootGeoHashCoordinate SystemsGeofencingRedis GeoHaversineNearby Search
Xiaolin Talks Programming
Written by

Xiaolin Talks Programming

Focuses on sharing original technical insights. Senior architect at a top tech company with years of experience in technical architecture and management, and extensive interview experience. Offers one-on-one technical coaching, guiding you from beginner to architecture design to technical management. Follow for free learning resources. Free one-on-one interview coaching to help you land offers quickly.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.