同一个 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. 自测
- 同样是"存一个用户对象",什么时候用
hash、什么时候用序列化的string?判据是什么? - 大 key 的危害为什么"删"比"读"更严重?
UNLINK改变了什么? zset同分时的排序规则是什么?排行榜要"按时间先后"该怎么设计 score?- 用
zset做延迟队列,三个可靠性缺口分别是什么?Lua 领取能补上哪一个? SETNX分布式锁的四个条件里,哪两个必须靠 Lua 才能同时满足?SCAN的游标为什么不能当偏移量用?它能保证的语义到底是什么?