Redis GEO, BitMap, and HyperLogLog for Location-Based Services

Nearby Shops

Redis GEO data structures enable efficient nearby shop queries. The key pattern uses a prefix combined with shop type ID, with each GEO entry storing a shop identifier and its geographic coordinates.

Data Import

A unit test populates Redis with shop location data from MySQL:

@Test
void loadShopData() {
    // Fetch all shops from database
    List<Shop> shopList = shopService.list();
    
    // Group shops by type for batch processing
    Map<Long, List<Shop>> groupedShops = shopList.stream()
        .collect(Collectors.groupingBy(Shop::getTypeId));
    
    // Process each group
    for (Map.Entry<Long, List<Shop>> group : groupedShops.entrySet()) {
        Long typeId = group.getKey();
        String geoKey = "shop:geo:" + typeId;
        List<Shop> shopsByType = group.getValue();

        // Prepare geo locations for batch insert
        List<RedisGeoCommands.GeoLocation<String>> locations = 
            new ArrayList<>(shopsByType.size());
        for (Shop shop : shopsByType) {
            locations.add(new RedisGeoCommands.GeoLocation<>(
                shop.getId().toString(),
                new Point(shop.getLongitude(), shop.getLatitude())
            ));
        }
        stringRedisTemplate.opsForGeo().add(geoKey, locations);
    }
}

Feature Implemantation

SpringDataRedis 2.3.9 lacks GEOSEARCH support from Redis 6.2, requiring version upgrades:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    <exclusions>
        <exclusion>
            <artifactId>spring-data-redis</artifactId>
            <groupId>org.springframework.data</groupId>
        </exclusion>
        <exclusion>
            <artifactId>lettuce-core</artifactId>
            <groupId>io.lettuce</groupId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>org.springframework.data</groupId>
    <artifactId>spring-data-redis</artifactId>
    <version>2.6.2</version>
</dependency>
<dependency>
    <groupId>io.lettuce</groupId>
    <artifactId>lettuce-core</artifactId>
    <version>6.1.6.RELEASE</version>
</dependency>

Controller endpoint:

@GetMapping("/of/type")
public Result queryShopByType(
    @RequestParam("typeId") Integer typeId,
    @RequestParam(value = "current", defaultValue = "1") Integer current,
    @RequestParam(value = "x", required = false) Double x,
    @RequestParam(value = "y", required = false) Double y
) {
    return shopService.queryShopByType(typeId, current, x, y);
}

Service implementation:

@Override
public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) {
    // No coordinates provided - query database directly
    if (x == null || y == null) {
        Page<Shop> page = query()
            .eq("type_id", typeId)
            .page(new Page<>(current, SystemConstants.DEFAULT_PAGE_SIZE));
        return Result.ok(page.getRecords());
    }

    // Calculate pagination boundaries
    int fromIndex = (current - 1) * SystemConstants.DEFAULT_PAGE_SIZE;
    int toIndex = current * SystemConstants.DEFAULT_PAGE_SIZE;

    // Query Redis for shops within range, sorted by distance
    String geoKey = "shop:geo:" + typeId;
    GeoResults<RedisGeoCommands.GeoLocation<String>> results = 
        stringRedisTemplate.opsForGeo().search(
            geoKey,
            GeoReference.fromCoordinate(x, y),
            new Distance(5000),
            RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs()
                .includeDistance().limit(toIndex)
        );

    // Handle empty results
    if (results == null)
        return Result.ok(Collections.emptyList());
    List<GeoResult<RedisGeoCommands.GeoLocation<String>>> geoResults = results.getContent();

    if (geoResults.size() < fromIndex)
        return Result.ok(Collections.emptyList());

    // Extract shop IDs and distances
    ArrayList<Object> shopIds = new ArrayList<>(geoResults.size());
    Map<String, Distance> distanceMap = new HashMap<>(geoResults.size());
    geoResults.stream().skip(fromIndex).forEach(result -> {
        String shopIdStr = result.getContent().getName();
        shopIds.add(Long.valueOf(shopIdStr));
        distanceMap.put(shopIdStr, result.getDistance());
    });

    // Fetch shop details and attach distance info
    String idString = StrUtil.join(",", shopIds);
    List<Shop> shops = query()
        .in("id", shopIds)
        .last("ORDER BY FIELD(id," + idString + ")")
        .list();
    
    for (Shop shop : shops) {
        shop.setDistance(distanceMap.get(shop.getId().toString()).getValue());
    }
    
    return Result.ok(shops);
}

User Check-in

Check-in Operation

Controller:

@PostMapping("/sign")
public Result sign() {
    return userService.sign();
}

Service:

@Override
public Result sign() {
    Long userId = UserHolder.getUser().getId();
    LocalDateTime now = LocalDateTime.now();
    
    // Key format: sign:userId:yyyyMM
    String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
    String signKey = "sign:" + userId + keySuffix;
    
    // Day offset (0-indexed for SETBIT)
    int dayOffset = now.getDayOfMonth() - 1;
    
    stringRedisTemplate.opsForValue().setBit(signKey, dayOffset, true);
    return Result.ok();
}

Conseuctive Check-in Statistics

Controller:

@GetMapping("/sign/count")
public Result signCount() {
    return userService.signCount();
}

Service:

@Override
public Result signCount() {
    Long userId = UserHolder.getUser().getId();
    LocalDateTime now = LocalDateTime.now();
    
    String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
    String signKey = "sign:" + userId + keySuffix;
    
    int dayOfMonth = now.getDayOfMonth();
    
    // Retrieve bitfield for the current month
    List<Long> bitfieldResult = stringRedisTemplate.opsForValue().bitField(
        signKey,
        BitFieldSubCommands.create()
            .get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth))
            .valueAt(0)
    );
    
    if (bitfieldResult == null || bitfieldResult.isEmpty())
        return Result.ok(0);
    
    Long bitmap = bitfieldResult.get(0);
    if (bitmap == null || bitmap == 0)
        return Result.ok(0);
    
    // Count consecutive 1s from the right (today's position)
    int consecutiveDays = 0;
    while ((bitmap & 1) != 0) {
        bitmap >>= 1;
        consecutiveDays++;
    }
    
    return Result.ok(consecutiveDays);
}

UV Statistics

HyperLogLog provides approximate unique visitor counting with minimal memory usage. The following test inserts one million entries:

@Test
void testHyperLogLog() {
    String[] userBatch = new String[1000];
    int position = 0;
    
    for (int i = 1; i <= 1000000; i++) {
        userBatch[position++] = "user_" + i;
        
        // Flush batch every 1000 entries
        if (i % 1000 == 0) {
            position = 0;
            stringRedisTemplate.opsForHyperLogLog().add("hll1", userBatch);
        }
    }
    
    Long estimatedCount = stringRedisTemplate.opsForHyperLogLog().size("hll1");
    System.out.println("estimatedCount =" + estimatedCount);
}

Result: With one million entries, the estimated count was 997,593, yielding an error rate of approximately 0.24% — negligible for most analytical purposes.

Tags: Redis GEO Bitmap HyperLogLog LBS

Posted on Sat, 03 Oct 2026 16:53:13 +0000 by shoz