4、Redis数据结构(下):Hash、ZSet、HyperLogLog、Bitmap、Geospatial
好,咱们接着聊。上一节我们把String、List、Set这几个基础结构讲透了。这一节要聊的这几个,说实话,才是真正让Redis从「缓存工具」变成「数据结构服务器」的关键。我个人习惯把Hash、ZSet、HyperLogLog、Bitmap、Geospatial称为「Redis的五大金刚」,因为它们在实战中真的太能打了。
4.1 Hash:对象存储的利器
Hash在Redis里长什么样?说白了就是一个key对应一个field-value的映射表。你想想看,如果我要存一个用户信息:name、age、email。用String的话,我得拼一个JSON字符串存进去,更新某个字段还得整个取出来反序列化,改完再序列化存回去。累不累?
用Hash就优雅多了:
HSET user:1001 name "张三" age 28 email "zhangsan@example.com"
HGET user:1001 name
HGETALL user:1001
我在项目中遇到过最典型的场景——用户会话管理。每个用户的session信息可能有十几个字段,用Hash存,每个字段独立操作,内存占用还比String小。为什么?因为Hash的底层实现用了ziplist或hashtable,当字段少、值小的时候,ziplist的压缩效率非常高。
核心要点:Hash适合存储「对象」类型的数据。单个Hash最好控制在1000个字段以内,超过这个量,ziplist会退化成hashtable,性能会下降。
小技巧:用HMSET可以批量设置字段,用HGETALL可以一次取出所有字段。但注意,HGETALL是O(n)操作,字段太多时慎用。
4.2 ZSet:带权重的有序集合
ZSet,全称Sorted Set。它和Set的区别在哪?Set是无序的,ZSet是有序的。每个元素关联一个double类型的score,Redis根据score排序。这玩意儿太实用了。
我记得有一次做排行榜功能,产品经理说「要实时更新,要支持按分数排序,还要能分页」。用数据库?每次查询都order by,并发一高就扛不住。用ZSet?一行命令搞定:
ZADD leaderboard 100 "user1" 95 "user2" 88 "user3"
ZREVRANGE leaderboard 0 2 WITHSCORES -- 前三名
ZRANK leaderboard "user2" -- 查看排名
ZSet的底层实现是跳表(skiplist)加哈希表。跳表保证了O(log n)的插入、删除、查找效率,哈希表保证了快速定位元素。嗯,这里要注意,ZSet的score可以相同,相同score时按字典序排列。
避坑指南:我曾经在项目中用ZSet做延迟队列,score存时间戳。结果发现ZRANGEBYSCORE在score范围很大时,性能会下降。后来改用分桶策略,把时间戳按小时分段,每个段一个ZSet,问题就解决了。
ZSet的经典应用场景:
- 排行榜(游戏、直播、电商)
- 延迟队列(定时任务调度)
- 滑动窗口限流(score存时间戳)
- 自动补全(score存权重)
4.3 HyperLogLog:基数统计的轻骑兵
HyperLogLog,名字挺唬人,其实就干一件事——统计不重复元素的个数。而且它特别省内存,12KB就能统计2^64个元素。你想想看,如果用Set去重统计UV,一个用户ID占几十字节,100万UV就是几十MB。HyperLogLog只要12KB,误差率0.81%左右。
为什么会有误差?因为它用的是概率算法。它不存具体元素,只存元素的哈希值,然后通过「分桶+取前导零」的方式估算基数。说白了,就是用精度换内存。
PFADD uv:2024-01-01 "user1" "user2" "user3"
PFCOUNT uv:2024-01-01 -- 返回3
PFMERGE uv:week uv:2024-01-01 uv:2024-01-02 -- 合并多天的UV
适用场景:UV统计、搜索词去重统计、页面访问量统计。不适合需要精确计数的场景,比如订单数量、库存数量。
我在项目中用过HyperLogLog做全站的UV统计。每天一个key,12KB内存,一年才4.3MB。配合PFMERGE,还能做周UV、月UV的汇总。误差0.81%?对于UV统计来说,完全可以接受。
4.4 Bitmap:位图操作的艺术
Bitmap,本质上就是String,只不过按位操作。一个字节8个bit,每个bit可以表示一个状态。1亿个用户,用Bitmap存签到状态,只需要12.5MB。你想想看,如果用数据库存,1亿条记录得占多少空间?
BitMap的操作命令很直观:
SETBIT sign:2024-01-01 1001 1 -- 用户1001签到
GETBIT sign:2024-01-01 1001 -- 查询是否签到
BITCOUNT sign:2024-01-01 -- 统计当天签到人数
BITOP AND result sign:2024-01-01 sign:2024-01-02 -- 连续两天签到的人
实战经验:我曾经用Bitmap做用户画像标签。每个标签一个Bitmap,用户ID作为偏移量。要找出「男性且25-30岁且喜欢运动」的用户,直接用BITOP AND三个Bitmap,毫秒级返回结果。比数据库的INNER JOIN快了几个数量级。
Bitmap的经典应用:
- 用户签到统计
- 在线状态追踪
- 布隆过滤器的简易实现
- 权限控制(每个bit代表一个权限)
4.5 Geospatial:地理位置的专属管家
Geospatial,Redis 3.2引入的地理位置模块。底层实现是ZSet,score存的是经纬度的GeoHash编码。支持添加坐标、计算距离、范围查询。
GEOADD locations 116.397128 39.916527 "天安门"
GEODIST locations "天安门" "故宫" km -- 计算距离
GEORADIUS locations 116.4 39.9 5 km -- 查询5公里内的地点
我记得有一次做「附近的人」功能,产品要求响应时间在100ms以内。用MySQL的经纬度计算?一个查询就要几百毫秒,并发一高直接崩。换成Redis的Geospatial,单个查询不到1毫秒,配合GEOHASH还能做分片。
避坑指南:Geospatial的精度问题要注意。GEOADD的经纬度精度是小数点后6位,约0.1米。但GeoHash编码在边界附近会有「边界效应」,两个很近的点可能被分到不同的hash块。我曾经因为这个原因,导致附近的人漏掉了几个。解决方案是查询时扩大半径,然后客户端再做一次精确过滤。
Geospatial的适用场景:
- 附近的人/商家/门店
- 地理围栏(判断是否进入某个区域)
- 物流配送范围查询
- 旅游景点推荐
小结
这五种数据结构,各有各的绝活。Hash管对象,ZSet管排序,HyperLogLog管基数,Bitmap管位图,Geospatial管位置。选对数据结构,性能能提升一个数量级。选错了?嗯,那就等着被DBA约谈吧。
下一节我们聊聊Redis的持久化机制,RDB和AOF到底怎么选。到时候我会分享一个我踩过的坑——因为配置不当,导致Redis重启后丢了半小时的数据。敬请期待。