Redis隐藏的三大杀手锏:Bitmap、HyperLogLog、GEO实战解析

1 阅读6分钟

用极低的内存消耗,实现签到统计、UV统计、附近的人功能,这才是Redis的真正实力!

在Redis生态中,String、Hash、List、Set、Sorted Set这五种基础类型占据了90%以上的使用场景。但当遇到以下需求时,它们可能会显得笨重或低效:

  • 记录用户每天签到状态,需要存储上亿条记录,且查询某天签到人数
  • 统计网站UV(独立访客),要求极低的内存占用,允许一定误差
  • 实现"附近的人"功能,需要地理位置索引和范围查询

此时,Redis提供的三种特殊数据类型——Bitmap、HyperLogLog、GEO便闪亮登场。它们专为这类场景设计,在保证性能的同时,将内存消耗降到最低。


🔥 一、Bitmap:位图——签到统计的终极方案

📌 原理简介

Bitmap本质上是对String类型的扩展,它把字符串的每个bit位当作一个独立的单位进行读写。一个字节(byte)包含8个bit,因此一个10万位的Bitmap仅占用约12.5KB内存。

核心命令

SETBIT key offset value    # 设置指定偏移位的值(0/1)
GETBIT key offset          # 获取指定偏移位的值
BITCOUNT key [start end]   # 统计值为1的bit数量
BITOP operation destkey key [key...]  # 对多个Bitmap进行与、或、非、异或操作

🛠️ 业务场景:用户签到统计

需求:记录用户每日签到,统计月度签到次数,展示签到日历,以及统计某天总签到人数。

设计思路

  • 每个用户一个Bitmap,key为sign:userId:yearMonth(如sign:1001:202403
  • 偏移量(offset)使用当月的第几天(1~31),bit值为1表示已签到

Spring Boot实现

@Service
public class SignService {
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    /** 
     * 用户签到 
     * @param userId 用户ID 
     * @param date 签到日期(格式 yyyy-MM-dd) 
     */
    public void sign(Long userId, LocalDate date) {
        String key = buildSignKey(userId, date);
        int offset = date.getDayOfMonth() - 1;
        redisTemplate.opsForValue().setBit(key, offset, true);
    }
    
    /** 
     * 获取用户某月的签到次数 
     */
    public long getSignCount(Long userId, YearMonth yearMonth) {
        String key = buildSignKey(userId, yearMonth);
        return redisTemplate.opsForValue().bitCount(key);
    }
    
    /** 
     * 获取用户某月的签到记录(用于前端日历展示) 
     */
    public String getSignRecord(Long userId, YearMonth yearMonth) {
        String key = buildSignKey(userId, yearMonth);
        byte[] bytes = redisTemplate.opsForValue().get(key).getBytes();
        if (bytes == null) return " ";
        
        StringBuilder sb = new StringBuilder();
        for (byte b : bytes) {
            sb.append(String.format("%8s", Integer.toBinaryString(b & 0xFF)).replace(' ', '0'));
        }
        
        int days = yearMonth.lengthOfMonth();
        return sb.substring(0, days);
    }
    
    private String buildSignKey(Long userId, YearMonth yearMonth) {
        return "sign:" + userId + ":" + yearMonth.toString().replace("-", "");
    }
}

⚠️ 注意事项

  • 签到记录存储在Bitmap中,无需额外索引,查询效率极高
  • 适合千万级用户签到数据,内存占用仅几十MB
  • 统计某天总签到人数时,建议使用另一个Bitmap存储每日签到汇总,避免对所有用户Bitmap做BITOP操作

🔥 二、HyperLogLog:基数统计——UV统计的轻量级王者

📌 原理简介

HyperLogLog是一种概率性数据结构,用于统计集合中不重复元素的数量(即基数),误差约为0.81%。每个HyperLogLog键只占用12KB内存,无论存储多少元素,内存固定。

核心命令

PFADD key element [element...]  # 添加元素
PFCOUNT key [key...]           # 返回基数的估算值
PFMERGE destkey sourcekey [sourcekey...]  # 合并多个HyperLogLog

🛠️ 业务场景:网站UV统计

需求:统计某个页面每天的独立访客数(UV),允许一定误差。

设计思路

  • 每天一个HyperLogLog键,如uv:20240330
  • 用户访问时,将用户ID(或IP)添加到对应的HyperLogLog中
  • 使用PFCOUNT获取当天UV

Spring Boot实现

@Service
public class UVService {
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    /** 
     * 记录用户访问 
     * @param date 访问日期 
     * @param userId 用户ID 
     */
    public void visit(LocalDate date, Long userId) {
        String key = "uv:" + date.toString().replace("-", "");
        redisTemplate.opsForHyperLogLog().add(key, userId.toString());
    }
    
    /** 
     * 获取某天的UV 
     */
    public long getUV(LocalDate date) {
        String key = "uv:" + date.toString().replace("-", "");
        return redisTemplate.opsForHyperLogLog().size(key);
    }
    
    /** 
     * 获取多天累计UV(去重) 
     */
    public long getMultiDayUV(LocalDate start, LocalDate end) {
        List<String> keys = new ArrayList<>();
        for (LocalDate date = start; !date.isAfter(end); date = date.plusDays(1)) {
            keys.add("uv:" + date.toString().replace("-", ""));
        }
        
        String tempKey = "uv:temp:" + System.currentTimeMillis();
        redisTemplate.opsForHyperLogLog().union(tempKey, keys.toArray(new String[0]));
        long count = redisTemplate.opsForHyperLogLog().size(tempKey);
        redisTemplate.delete(tempKey);
        return count;
    }
}

⚠️ 注意事项

  • HyperLogLog适合海量数据、允许误差的场景,不适合精确计数
  • 合并操作(PFMERGE)会消耗CPU,建议在离线任务中执行
  • 实际使用中,可以设置PFADD的元素为userId:ip,避免同一用户多次访问导致重复计数

🔥 三、GEO:地理位置——附近的人、商户查询

📌 原理简介

GEO是Redis 3.2引入的数据类型,基于Sorted Set实现,支持存储经纬度坐标,并提供距离计算、半径查询等功能。

核心命令

GEOADD key longitude latitude member [longitude latitude member ...]  # 添加位置
GEOPOS key member [member ...]  # 获取指定成员的坐标
GEODIST key member1 member2 [unit]  # 计算两个成员之间的距离
GEORADIUS key longitude latitude radius unit [WITHCOORD] [WITHDIST] [COUNT count]  # 查询指定半径内的成员
GEORADIUSBYMEMBER key member radius unit ...  # 以某个成员为中心进行半径查询

🛠️ 业务场景:附近的人

需求:基于用户当前经纬度,查询附近一定范围内的其他用户,并按距离排序。

设计思路

  • 存储用户位置:GEOADD location:city 经度 纬度 用户ID
  • 查询附近用户:GEORADIUS location:city 经度 纬度 半径 km/km WITHDIST COUNT 20

Spring Boot实现

@Service
public class GeoService {
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    private static final String GEO_KEY = "location:beijing";
    
    /** 
     * 更新用户位置 
     */
    public void updateLocation(Long userId, double lng, double lat) {
        redisTemplate.opsForGeo().add(
            GEO_KEY, 
            new Point(lng, lat), 
            userId.toString()
        );
    }
    
    /** 
     * 获取附近的人(按距离排序) 
     * @param userId 当前用户ID 
     * @param radius 半径(米) 
     * @param limit 最多返回数量 
     * @return 列表,元素为 (userId, distance) 
     */
    public List<NearbyUser> getNearbyUsers(Long userId, double radius, int limit) {
        List<Point> points = redisTemplate.opsForGeo().position(GEO_KEY, userId.toString());
        if (points == null || points.isEmpty()) {
            return Collections.emptyList();
        }
        
        Point center = points.get(0);
        RedisGeoCommands.GeoRadiusCommandArgs args = RedisGeoCommands.GeoRadiusCommandArgs
            .newGeoRadiusArgs()
            .includeDistance()
            .sortAscending()
            .limit(limit);
        
        GeoResults<RedisGeoCommands.GeoLocation<String>> results = redisTemplate.opsForGeo()
            .radius(GEO_KEY, new Circle(center, new Distance(radius, Metrics.METERS)), args);
        
        if (results == null) return Collections.emptyList();
        
        List<NearbyUser> list = new ArrayList<>();
        for (GeoResult<RedisGeoCommands.GeoLocation<String>> result : results) {
            RedisGeoCommands.GeoLocation<String> location = result.getContent();
            if (location.getName().equals(userId.toString())) continue;
            
            double distance = result.getDistance().getValue();
            list.add(new NearbyUser(Long.parseLong(location.getName()), distance));
        }
        
        return list;
    }
    
    // 内部类
    static class NearbyUser {
        private Long userId;
        private double distance; // 单位:米
        
        public NearbyUser(Long userId, double distance) {
            this.userId = userId;
            this.distance = distance;
        }
        
        // getter, setter
    }
}

⚠️ 注意事项

  • GEO的底层是Sorted Set,无法直接修改某个成员的坐标,需要先删除再添加
  • 半径查询的精度受geohash编码影响,但Redis内部已做了优化,误差很小
  • 对于城市级数据,建议按城市拆分key,避免单key过大导致查询缓慢

📊 四、三种特殊数据类型对比总结

数据类型内存消耗精确性典型场景主要命令
Bitmap极小(按位存储)精确签到统计、布隆过滤器SETBIT, GETBIT, BITCOUNT
HyperLogLog固定12KB近似(0.81%)UV统计、独立访客PFADD, PFCOUNT, PFMERGE
GEO与成员数成正比精确附近的人、地理位置查询GEOADD, GEORADIUS, GEODIST

💡 五、实战小结

Bitmap、HyperLogLog、GEO三种特殊数据类型,虽然不是Redis的日常主力,但在特定场景下,它们往往能以极低的成本和极高的性能解决问题:

✅ 用Bitmap做签到,轻松支撑千万用户每日签到,内存占用仅几十MB
✅ 用HyperLogLog统计UV,告别大数据量的去重开销
✅ 用GEO实现LBS功能,无需引入额外组件

在Spring Boot中,借助Spring Data Redis提供的opsForGeo()opsForHyperLogLog()opsForValue().setBit()等API,我们可以非常方便地集成这些能力。

"Redis不是缓存,而是内存中的数据库。"

掌握这些"隐藏技能",让你的Redis使用从"会用"到"精通",代码更优雅,性能更极致。


关注我的微信公众号【卷毛的技术笔记】 我会持续输出后端技术干货,与你一起进阶!
点赞、收藏、转发,让更多开发者受益!