Redis 与缓存一致性
本文性质声明:本机没有 Redis 实例,全部内容未实测,是文档级知识整理,聚焦”什么时候用什么、一致性代价是什么”的决策框架。Redis 的具体性能数字(ops、延迟)以你手头版本的官方 benchmark 为准;要看本机实测,看同专栏 db-transaction / db-sqlite 等 SQLite 篇。
一、先问要不要缓存:三个判据
缓存是用一致性换性能的交易,动手前确认三个条件:
- 读远多于写。读写比低于 10:1 的场景,缓存的命中率撑不起它的复杂度。
- 数据有局部性。同一份数据被反复读(热点商品、配置、用户会话);纯流水型数据(日志、事件)没有局部性,缓存了也没用。
- 能接受最终一致。缓存和数据库之间的窗口期内读到旧值,业务能否容忍?不能容忍的字段(余额、库存)要么不缓存,要么走带版本号的强一致协议。
三个条件缺一个,先把数据库侧的优化做完(索引、执行计划、连接池)再谈缓存——缓存经常被用来掩盖没建的索引。
二、五大数据结构:按场景选,不要按熟悉程度选
| 结构 | 底层 | 典型场景 | 选它的理由 | 替代判断 |
|---|---|---|---|---|
| String | SDS | 计数器、分布式锁、简单 KV | 最省事,INCR 原子 | 需要多字段别用 JSON 字符串硬塞 |
| Hash | ziplist/hashtable | 对象属性(用户信息、商品) | 单键读写部分字段,HGET 不动其他字段 | 字段少而固定时比 String+JSON 省流量 |
| List | ziplist/quicklist | 最新 N 条、简单队列 | LPUSH+LTRIM 维护定长 | 要消费确认/重试时 List 不够 |
| Set | intset/hashtable | 标签、共同关注、去重 | SINTER/SDIFF 一行算完 | 要带权重的相似度用 ZSet |
| ZSet | ziplist/skiplist | 排行榜、延迟队列、范围查询 | 按分数范围取是 O(logN) | 只存成员不要分数时 Set 更省 |
两个跨结构的常识:
EXPIRE是键级的,SET默认清掉 TTL。SET key value之后原来有的过期时间没了——想保留得用KEEPTTL参数,这是”缓存突然永不过期”的高频原因。- 大键是运维事故源:一个装了几百万元素的 Hash/List,
DEL它同步阻塞、主从同步它撑大带宽。用SCAN分批删、HRANDFIELD/SRANDMEMBER抽样删,或者重新设计键粒度。
三、三种故障形态:成因不同,解法不同
3.1 缓存穿透:查不存在的数据
请求带着随机 ID 打过来,缓存永远 miss,每次直穿数据库。恶意场景下这是免费的 CC 攻击。解法按严格度:
- 缓存空值:miss 后把
null也写缓存,给短 TTL(几分钟)。最简单,缺点是key 量大时浪费内存。 - 布隆过滤器:前置判断”key 一定不存在”,挡掉绝大多数。有误判率(说不存在就一定不存在,说存在可能不存在),误判的那部分回落到底层。
- 入口校验:ID 格式、权限、频率限制在网关层做掉——成本最低,最该先做。
3.2 缓存击穿:热点 key 过期瞬间
一个超热 key 过期,并发请求同时 miss,同时打库——数据库瞬时被同一个 key 的流量踩穿。解法:
- 互斥重建:第一个 miss 的请求拿锁(
SET NX EX)回源,其他等待后重读缓存。用分布式锁要处理持锁者崩了的情况(锁过期、看门狗续期)。 - 逻辑过期:key 永不物理过期,值里带”逻辑过期时间”,读到过期的异步重建。可用性优先,牺牲实时性。
- 热点预热:大促前把热点 key 提前灌好、TTL 加随机抖动错开。
3.3 缓存雪崩:大量 key 同时过期或缓存整体挂掉
整批 key 同一时刻 TTL 到期,或 Redis 重启后冷启动——数据库面对全部流量。解法:
- TTL 随机化:
base + random(0, 300),把过期时刻打散。这是性价比最高的一招,应该作为默认实践。 - 多级缓存:本地缓存(Caffeine/Guava)挡一层,Redis 挂了还有本地顶着(忍受短时间不一致)。
- 熔断降级:缓存不可用时返回兜底数据(默认值、静态页),别让流量全部穿到底层。
- 高可用部署:哨兵/Cluster 保证节点故障自动切换,切换期间仍有秒级不可用,熔断是必需品不是可选项。
四、四种读写模式:一致性代价递增
| 模式 | 写路径 | 读路径 | 一致性 | 复杂度 |
|---|---|---|---|---|
| Cache-Aside(旁路) | 先更新 DB,再删缓存 | miss 则读库回填 | 最终一致(有窗口) | 低,最常用 |
| Read-Through | 缓存自己回源(对应用透明) | 同上 | 最终一致 | 中 |
| Write-Through | 同步写缓存 + DB | 同上 | 强(写时双写) | 中,写延迟翻倍 |
| Write-Behind(回写) | 只写缓存,异步批量落 DB | 同上 | 弱,可能丢数据 | 高 |
Cache-Aside 的两个经典坑及对策:
- 先更新 DB 再删缓存 vs 先删缓存再更新 DB:选前者。先删缓存的话,更新 DB 期间进来的读请求会把旧值重新填回缓存——旧值住到 TTL 到期。先更新 DB 再删,窗口只有”删之前那一瞬”,且下次读自然回填新值。
- 删缓存失败怎么办:删缓存这一步不能失败——失败就重试、重试不行进消息队列异步删、再不行告警人工。写路径的可靠性排序:DB 更新 > 缓存删除 > 一切其他,因为 DB 是真相源。
删除优于更新:并发写时,“更新缓存”会让两个写请求以不同顺序到达缓存和 DB,产生持久分歧;“删缓存”让下一次读从 DB 重建,天然收敛。
五、分布式锁:Redis 最被滥用的能力
SET key random_value NX EX 30 是正确姿势,四个必须回答的问题:
- value 为什么是随机值:释放时要校验”锁是我的”(Lua 脚本比对再删),否则会把别人的锁删掉——A 持锁超时被系统收回,B 拿到锁,A 执行完去删,删的是 B 的锁。
- 持锁者崩了怎么办:靠 EX 过期自动释放,过期时间就是最长故障时间——设太长,崩了后锁住等待;设太短,业务没跑完锁没了。看门狗(后台线程续期)是常见补丁,但它自己也是分布式难题。
- 锁的粒度:锁全局 key 等于把并发关掉。锁的粒度应该覆盖”要互斥的那段资源”——单个库存、单个用户的扣减,不是整个下单流程。
- Redlock 之争:多节点多数派锁在分区下的正确性在社区有长期争论(Kleppmann 的反驳与 antirez 的回应都值得读)。单点 Redis 的锁就别谈 CAP 了,把它当”多数场景够用的优化”,强互斥需求用 etcd/ZooKeeper 这类共识系统。
六、持久化与部署形态(概念层)
- RDB:定时快照,文件紧凑、恢复快,丢两个快照之间的数据。适合备份、灾备副本。
- AOF:追加命令日志,
appendfsync everysec默认每秒落盘,最多丢 1 秒。文件大、恢复慢,可用 AOF 重写压缩。 - 混合持久化(4.0+):RDB 做全量基线 + AOF 记增量,取两者之长。
- 部署形态:主从(读写分离、故障转移的基础)、哨兵(自动故障转移,客户端要跟着切)、Cluster(数据分片 + 高可用,16384 槽)。集群模式改变了客户端的 key 路由和事务/pipeline 的语义(跨槽事务不支持),接入前确认客户端支持。
七、未实测清单
- 各结构的实际 ops/延迟、RDB/AOF 的恢复耗时、Cluster 的迁移行为——无实例,未测。
- 与 MySQL 组合的完整链路压测——无实例,未测。
- 内存淘汰策略(
maxmemory-policy八种)的实际命中影响——无实例,未测;文档级建议:纯缓存场景allkeys-lru,有持久 key 混存用volatile-lru,别用noeviction(写不进去时报错比悄悄丢数据好排查)。