2. Redis核心数据结构与高并发选型
在缓存体系中,Redis 的价值并不只在于“快”,更在于它用一组语义明确的数据结构,把不同业务形态的读写模式映射成了不同的内存操作。选错结构,往往不是慢一点,而是内存放大数倍、命令复杂度从 O(1) 退化为 O(N),甚至在高并发下把单线程主线程拖垮。因此,本节的核心不是背命令,而是建立“业务访问模式 → 数据结构 → 复杂度与内存代价”的对应关系。
一、先看复杂度与内存:选型的两个硬约束
Redis 命令执行在主线程上,单条命令的耗时直接决定吞吐上限。判断一个结构是否适合缓存场景,第一看单次操作的时间复杂度,第二看单位数据的编码开销。Redis 为小对象设计了紧凑编码:String 用 int/embstr/raw,Hash 在字段少且值小时用 listpack(旧版本为 ziplist),元素变大或变多后转为 hashtable;List 用 quicklist(listpack 节点组成的双向链表);Set 在整数且量小时用 intset,否则 hashtable;ZSet 用 listpack 或 skiplist+dict。编码转换是“一次性成本”,但转换后内存和 CPU 开销都会上升,这是容量规划时必须预估的。
| 结构 | 典型命令 | 关键复杂度 | 缓存场景定位 |
|---|---|---|---|
| String | GET/SET/INCR/EXPIRE | O(1) | 对象整体缓存、计数器、分布式锁 |
| Hash | HGET/HSET/HMGET | O(1) 单字段 | 对象字段级读写、部分更新 |
| List | LPUSH/RPOP/LRANGE | 两端 O(1),索引 O(N) | 时间线、队列、最新 N 条 |
| Set | SADD/SISMEMBER/SINTER | 增删查 O(1),集合运算 O(N) | 去重、标签、共同好友 |
| ZSet | ZADD/ZRANGEBYSCORE | 增删 O(log N),范围 O(log N+M) | 排行榜、延时队列、滑动窗口 |
| Bitmap | SETBIT/BITCOUNT/BITOP | SETBIT O(1),BITCOUNT O(N) | 签到、活跃标记、布尔统计 |
| HyperLogLog | PFADD/PFCOUNT/PFMERGE | O(1),固定约 12KB | 海量 UV 基数估算 |
二、String 与 Hash:整体缓存还是字段缓存
String 是最通用的选择。缓存一个用户对象时,把 JSON 序列化后整体 SET,读取时一次 GET 反序列化,网络往返最少,适合“读多写少、每次都要完整对象”的场景。它的代价是更新粒度粗:只改一个字段也要重写整个 value,value 越大,写入放大越明显。计数器场景则体现 String 的另一面:INCR 是原子操作,天然适合点赞数、库存扣减的预扣,配合 EXPIRE 可做限流窗口。
Hash 解决的是字段级读写。用户对象用 HSET user:1001 name "Tom" age 28 存储,改年龄只需 HSET user:1001 age 29,不触碰其他字段。当对象字段多、更新频繁且每次只关心少数字段时,Hash 的内存和带宽都更优。但要注意:Hash 不能对单个字段设过期时间,整个 key 共享 TTL;如果字段数量持续增长且无法收敛,hashtable 编码会带来较大指针开销。经验上,字段数在几十到几百、单值不大时 Hash 很划算;若字段数上千,应评估是否拆分为多个 key。
三、List、Set、ZSet:有序、去重与排序
List 是双向链表语义,LPUSH + LTRIM 是经典的“保留最新 N 条”模式:每次写入后裁剪,内存恒定,适合消息流、操作日志、最新动态。它也能做简单队列(LPUSH + BRPOP),但要注意 BRPOP 阻塞会占用连接,且没有 ACK 机制,消息可靠性不如 Stream。List 的随机索引是 O(N),不要用它做分页随机访问。
Set 提供 O(1) 的成员判断和去重,适合“是否已存在”类缓存,例如已读集合、黑名单、标签集合。SINTER/SUNION 可做交集并集,但集合运算复杂度与元素数相关,大集合上执行会阻塞主线程,应避免在请求路径上对大 Set 做全量运算,必要时用 SINTERCARD 限制或离线计算。
ZSet 是排行榜和延时队列的首选。ZADD 以 score 排序,ZRANGE 取 Top N,ZRANGEBYSCORE 按分数区间取,ZREMRANGEBYSCORE 清理过期。延时队列的典型做法是把“到期时间戳”作为 score,消费者用 ZRANGEBYSCORE key 0 now LIMIT 0 10 拉取到期任务,再用 ZREM 原子删除,避免重复消费。ZSet 的增删是 O(log N),N 很大时仍可接受,但内存开销高于 Set,因为每个元素要额外存 score 和跳表指针。
四、Bitmap 与 HyperLogLog:极致省内存的概率与位运算
Bitmap 本质是 String 上的位操作。用户签到用 SETBIT sign:202405 1001 1 标记,BITCOUNT 统计当月签到天数,BITOP AND 可算连续活跃。它的优势是内存极省:1 亿用户按天标记只需约 12.5MB。但前提是 ID 必须能映射为连续偏移量,稀疏 ID 会浪费大量空间;BITCOUNT 是 O(N),对大 Bitmap 全量统计仍有成本,可配合分片或按周/月拆分 key。
HyperLogLog 用于基数估算,标准误差约 0.81%,固定占用约 12KB,与元素数量无关。统计 UV 时 PFADD uv:20240501 user_id,PFCOUNT 得到近似值,PFMERGE 可合并多天。它不存储原始元素,无法判断某个用户是否已存在,也不能取回明细,因此只适合“只要数量、不要明细”的统计场景。若业务需要精确值或成员查询,应改用 Set 或 Bitmap。
五、高并发选型的落地判断
把上面的结构放回真实流量中,可以形成几条可执行的判断规则。第一,优先选择单次操作 O(1) 或 O(log N) 的结构,任何 O(N) 命令都要评估 N 的上界;第二,控制单个 key 的元素规模,List 用 LTRIM 限长,ZSet 定期 ZREMRANGEBYRANK 清理尾部,Set 和 Hash 在字段数超过阈值时拆分;第三,区分“精确”和“近似”,UV、活跃度这类统计用 HyperLogLog 或 Bitmap,成员判断和明细用 Set;第四,注意 TTL 的作用范围,Hash、List、ZSet 只能整 key 过期,若需要字段级过期,应把字段拆成独立 String key 或借助 ZSet 的 score 做逻辑过期。
最后给出一个可复用的选型检查顺序:先明确访问模式是整体读写还是字段读写,再判断是否需要排序、去重或范围查询,然后确认结果必须精确还是可以近似,最后核对元素规模与内存预算,决定是否分片、限长或改用其他结构。按这个顺序走一遍,绝大多数缓存结构选型都能在几分钟内收敛到两三个候选,再用压测验证即可。