2594 字
约 8 分钟
15
S12-X13 · 缓存数据结构:hash、zset 与 bitmap 的用法

同一个 Redis,有人用它存 session,有人用它做排行榜,有人用它做去重——选错结构的表现是:内存比数据大十倍、排行榜越翻越慢、去重集合涨到几个 G 才开始限流。五种基本结构不是"都能存数据",而是五种不同的访问模型,选型的判据是"你打算怎么读它"。

先看判据,再看不适合的用法:


1. 五种结构的适用判据

结构 适用 判据(怎么读) 明显不该用的场景
string 计数、序列化对象、锁值 整读整写 需要改单个字段(要反序列化再写回)
hash 对象字段、配置、购物车 按字段读写 字段数上万、需要整体过期
list 轻量队列、时间线 两端进出 需要按位置随机访问、需要可靠投递
set 去重、标签、交并差 成员判断、集合运算 需要排序、需要范围查询
zset 排行榜、延迟队列、范围查询 按分数排序取区间 只做去重(白付排序成本)

选型只有一句判断:读的时候要"整个值"还是"其中一部分",要"顺序"还是"集合"。要字段级更新选 hash,要顺序与区间选 zset,要存在性判断选 set 或 bitmap。


2. 大 key 与热 key

类型 判定口径 危害
大 key(string) value 达到 MB 级(按业务核对,常见阈值 1MB 起) 网络传输阻塞、删除卡顿
大 key(hash/zset/list) 元素数上万,或整体内存占比高 HGETALL/ZRANGE 全量拉取阻塞
热 key 单 key QPS 占实例总 QPS 显著比例 单分片打满,集群倾斜
集群倾斜 单 slot 数据量或流量远超均值 扩容无效,热点仍在同一分片

大 key 最疼的不是读,是删:DEL 一个百万字段的 hash 会在主线程上同步释放,期间其他命令全部排队。用 UNLINK 把释放交给后台线程,或用 HSCAN 分批删字段。

# 找出实例里的大 key(采样,不代表全量;按 Redis 7.x 核对参数)
redis-cli --bigkeys
redis-cli --memkeys
# 异步删除,避免主线程卡顿
redis-cli UNLINK big:hash:1
// 分批删字段:每批 200,批间留间隙,避免长时间占用主线程
public void dropHashFields(String key) {
    ScanOptions opts = ScanOptions.scanOptions().count(200).build();
    try (Cursor<Map.Entry<Object, Object>> cur = redis.opsForHash().scan(key, opts)) {
        List<Object> fields = new ArrayList<>();
        while (cur.hasNext()) {
            fields.add(cur.next().getKey());
            if (fields.size() >= 200) {
                redis.opsForHash().delete(key, fields.toArray());
                fields.clear();
            }
        }
        if (!fields.isEmpty()) redis.opsForHash().delete(key, fields.toArray());
    }
}

拆分的方向有两个:按维度拆(user:{id}:profile 与 user:{id}:stat 分开),按字段哈希拆(cart:{uid % 16}:{uid})。前者降低单 key 体积,后者解决单分片倾斜。


3. zset 的三种典型用法

用法 score 设计 关键命令 注意
排行榜 分数本身(越大越前) ZADD / ZREVRANGE / ZREVRANK 同分排序不稳定,需拼业务 id
范围查询(时间轴) 时间戳 ZRANGEBYSCORE / ZREMRANGEBYSCORE score 精度到毫秒要确认
延迟队列 到期时间戳 ZADD / ZRANGEBYSCORE ... LIMIT 需原子"取出即删"

分数设计是 zset 唯一的难点:同分时 zset 按 member 字典序排,所以"分数相同但要求按创建先后"必须把时间揉进 score(例如 score = 时间戳 * 1000 + 序号,注意 double 精度只到 2^53)。排行榜常见的"并列名次"需求也不能靠 ZREVRANK 直接给,要自己按分数区间重算。

延迟队列的最小实现:

# 投递
ZADD delay:order 1730000000000 "order:1001"
# 轮询取到期任务(原子领取用 Lua,见下)
ZRANGEBYSCORE delay:order 0 1730000000000 LIMIT 0 20

它的可靠性缺口有三处:没有 ack(pop 出来处理失败就丢了)、没有重试语义(除非自己写回)、没有并发安全(多消费者会取到同一条)。要补,就得用 Lua 把"取 + 移入处理中队列"做成一步:

-- KEYS[1]=delay:order  KEYS[2]=delay:processing
local due = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1], 'LIMIT', 0, ARGV[2])
for i, v in ipairs(due) do
  redis.call('ZREM', KEYS[1], v)
  redis.call('ZADD', KEYS[2], ARGV[3], v)   -- 带租约,超时可回收
end
return due

即便这样,它也只是"带租约的轮询队列",不等于 MQ:没有消费确认、没有死信、没有顺序保证。


4. bitmap 与 HyperLogLog

用途 结构 命令 误差/边界
签到、在线状态 bitmap(本质是 string 的位操作) SETBIT / GETBIT / BITCOUNT 精确;offset 决定内存(按最大 id 分配)
活跃用户 UV 估算 HyperLogLog PFADD / PFCOUNT / PFMERGE 标准误差约 0.81%,内存固定约 12KB
用户标签位图 bitmap + 位运算 BITOP AND/OR 精确;但按用户 id 连续分配才省内存
# 签到:第 200 天签到,offset 用「用户 id 段 + 天数」或每用户一个 key
SETBIT sign:2026:uid:1001 200 1
BITCOUNT sign:2026:uid:1001
# UV:按天一个 HLL,跨天合并
PFADD uv:2026-11-09 uid1001 uid1002
PFCOUNT uv:2026-11-09
PFMERGE uv:week uv:2026-11-09 uv:2026-11-10

bitmap 的坑在 offset 决定内存:给 id 为 10 亿的用户做签到位图,会直接分配上百 MB。要么按用户维度分 key,要么把 id 映射到紧凑序号。HyperLogLog 的坑在不能取交集求"既 A 又 B",它只支持并集合并;需要精确交集时只能回到 set,代价是内存涨几个数量级。


5. SETNX 做分布式锁的四个必备条件

条件 做法 缺了会怎样
互斥 SET key val NX PX ttl 原子完成 分两步 SETNX + EXPIRE 中间崩溃即死锁
超时 PX 给租约,且租约大于业务最坏耗时 持锁进程挂了锁永不释放
唯一值 value 用 UUID/请求 id 误删别人的锁
原子释放 Lua 比对 value 再 DEL GET 后崩溃,删掉后继者的锁
String token = UUID.randomUUID().toString();
Boolean ok = redis.opsForValue().setIfAbsent("lock:order:" + id, token, Duration.ofSeconds(10));
if (Boolean.TRUE.equals(ok)) {
    try { doBusiness(); }
    finally {
        // 必须 Lua 比对后删除,防止超时后误删别人的锁
        redis.execute(new DefaultRedisScript<>(RELEASE_LUA, Long.class),
                List.of("lock:order:" + id), token);
    }
}
-- RELEASE_LUA
if redis.call('GET', KEYS[1]) == ARGV[1] then
  return redis.call('DEL', KEYS[1])
end
return 0

Redlock 的争议一句话:它把"多数派加锁"当成线性一致性保证,而依赖系统时钟与网络延迟的假设在真实故障模型下站不住——需要强互斥时,加锁对象本身应该支持 fencing token。


6. Lua 与 SCAN:两件替代"多命令拼装"的工具

多命令需要原子时,Lua 是首选(单线程执行,期间不插入其他命令):

-- 原子:库存扣减 + 记录结果
local left = tonumber(redis.call('GET', KEYS[1]))
if left == nil or left <= 0 then return -1 end
redis.call('DECR', KEYS[1])
return left - 1

注意 Lua 的三个限制:脚本执行阻塞主线程(不能写死循环)、KEYS/ARGV 必须显式传入(集群模式下要保证同 slot)、EVALSHA 依赖脚本已加载(需处理 NOSCRIPT 回退)。

SCAN 的语义常被误解:游标不是偏移量,是哈希桶的位置,所以遍历顺序不确定、可能重复返回、增删期间可能漏。但它保证"遍历期间一直存在的元素一定被返回",这让它适合做对账与清理,不适合做精确快照。

SCAN 0 MATCH "user:*:stat" COUNT 500
SSCAN / HSCAN / ZSCAN 同理

KEYS 在生产被禁的理由只有一个:它是 O(N) 且全程阻塞。10 万个 key 的实例上执行 KEYS *,这段时间所有其他命令排队,等于人为制造一次故障。统计 key 数量用 DBSIZE,找特定前缀用 SCAN。


7. 排错:七种现场

现象 根因 处理
实例偶发卡顿数百毫秒 大 key 的 DEL 或 HGETALL 阻塞主线程 改 UNLINK、分批 HSCAN、避免全量命令
集群扩容后仍有一个分片打满 单 key 过热或 slot 倾斜 key 分片、本地缓存、拆大 key
排行榜名次偶发跳变 同分按 member 字典序排 score 拼时间戳与序号
延迟队列任务执行两次 多消费者无原子领取 用 Lua 取 + 移入 processing
签到内存暴涨 bitmap offset 用了原始用户 id 映射成紧凑序号或按用户分 key
zset 内存只涨不跌 排行榜无上限、历史数据未清理 定期 ZREMRANGEBYRANK/ZREMRANGEBYSCORE,只留 TopN 与时间窗
KEYS 之后整个实例超时 O(N) 阻塞 立即停用,改 SCAN/DBSIZE

8. 边界:不该用 Redis 结构解决的问题

需求 不该用什么 应该用什么
可靠消息投递、重试、死信 list / zset 队列 Kafka / RocketMQ 等 MQ
精确的集合交并差且数据量大 HyperLogLog 离线数仓或 set(接受内存)
强互斥且要求正确性 单实例 SETNX 支持 fencing token 的锁服务
复杂查询、多条件过滤 hash 手写索引 搜索引擎 / 关系库
大文件、图片 string 对象存储,Redis 只存地址

9. 自测

  1. 同样是"存一个用户对象",什么时候用 hash、什么时候用序列化的 string?判据是什么?
  2. 大 key 的危害为什么"删"比"读"更严重?UNLINK 改变了什么?
  3. zset 同分时的排序规则是什么?排行榜要"按时间先后"该怎么设计 score?
  4. 用 zset 做延迟队列,三个可靠性缺口分别是什么?Lua 领取能补上哪一个?
  5. SETNX 分布式锁的四个条件里,哪两个必须靠 Lua 才能同时满足?
  6. SCAN 的游标为什么不能当偏移量用?它能保证的语义到底是什么?
S12-X13 · 缓存数据结构:hash、zset 与 bitmap 的用法
https://www.clxhxhhr.top/posts/5983/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。