为了扛住每小时一次的写入高峰,在应用和数据库之间加了一层 Redis,把待写入的记录先堆进去,再由消费者慢慢落库。上线一个月后 Redis 内存打满,淘汰策略按 LRU 把最老的记录清掉了——那些记录再也没写进数据库。
复盘时有人说"缓存本来就会丢数据"。这句话对缓存是对的,对缓冲是灾难。同一个组件,当缓存用和当缓冲用,失效的后果完全不同:前者丢的是性能,后者丢的是业务数据。
1. 一条判据分清两者
区分的标准只有一个问题:
这份数据丢了,会不会有人需要它?
答案是"不会,重算一遍就有了"——它是缓存。答案是"会,这就是用户的订单"——它是缓冲。
| 维度 | 缓存 | 缓冲 |
|---|---|---|
| 数据来源 | 别处已有的事实 | 尚未落地的待办 |
| 丢失后果 | 性能下降、回源增加 | 业务数据缺失、对账不平 |
| 可重建性 | 可,从源重新加载 | 不可,除非上游能重放 |
| 淘汰策略 | LRU/LFU 合理 | 任何淘汰都是丢数据 |
| 容量满了怎么办 | 淘汰 + 增加回源 | 背压 + 扩容 + 报警 |
| 正确性要求 | 允许短暂不一致 | 必须最终一条不少 |
| 关键指标 | 命中率、回源率 | 积压量、确认延迟、丢失数 |
判断一份数据属于哪一类,实际可以做一个小实验:把它删掉,然后看谁的报警响了。缓存被删掉时响的是性能监控(延迟、回源率、数据库负载);缓冲被删掉时响的是业务监控(订单数不对、对账差异、客服工单)。报警响在哪个域,它就是哪一类。
技术上没有任何一个组件天生是缓存或缓冲。Kafka 常被当缓冲用,但把 retention.ms 设成十分钟、关掉副本,它就成了一个昂贵的缓存;Redis 常被当缓存用,但配上 AOF 与 noeviction,它也能承担小规模的缓冲。角色由语义决定,不由技术选型决定。
2. 四个具体的错配
淘汰策略
给缓冲配 allkeys-lru,等于告诉 Redis"内存紧张时丢数据没关系"。而这个策略恰恰是多数缓存的默认配置,从缓存场景复制过来的配置文件里通常就带着它。
// 缓存角色:可以淘汰,淘汰是正常行为
maxmemory-policy allkeys-lru
// 缓冲角色:绝不能淘汰;宁可写入失败让上游感知
maxmemory-policy noeviction
// 注意:noeviction 下写入会直接报错,上游必须有背压或重试,否则变成丢在应用层
持久化与确认时机
缓冲能否算"已接收",取决于它落盘到什么程度。
| 确认时机 | 对缓冲的含义 | 允许的故障 |
|---|---|---|
| 写入内存即确认 | 进程崩溃即丢 | 只适合缓存 |
| AOF everysec | 最多丢 1 秒 | 可接受于非关键数据 |
| AOF always / 主从同步确认 | 落盘才算收 | 关键业务数据 |
| 双写 + 对账 | 可发现缺失 | 有补偿机制时 |
确认得太早,等于把缓冲当缓存用。这个错误在高峰期特别容易发生:为了压低写入延迟,有人把确认点前移到内存写入完成,延迟数字立刻变好看,风险同时被抬高。
过期时间
TTL 用完就消失,这对缓存是特性(保证读到的东西不会太旧),对缓冲是缺陷(未处理的任务会自己蒸发)。
如果缓冲里必须有过期时间,那它应该是兜底清理而不是正常路径:正常路径上,数据应当在 TTL 到期前被处理完;到期未处理完说明消费能力不足,这应该触发报警,而不是静默删除。
指标口径
两类指标混在一起看会得出矛盾结论。
| 指标 | 说明什么 | 高了怎么办 |
|---|---|---|
| 缓存命中率 | 加速效果 | 高是好事 |
| 回源率 | 缓存失效程度 | 高要查是否容量不足或 TTL 太短 |
| 队列积压量 | 消费能力与生产速率的差 | 高要扩消费者或降生产速率 |
| 确认延迟 | 数据多久才真正安全 | 高要查持久化配置 |
积压的判据需要同时看三个数,缺一个就会误判:
// 缓冲健康度:只看积压量会漏掉"消费停了但生产也停了"的假健康
SELECT
MAX(queued_at) AS oldest_pending, -- 最老待处理的时间
COUNT(*) AS backlog, -- 积压量
COUNT(*) / NULLIF(TIMESTAMPDIFF(SECOND, MIN(queued_at), NOW()), 0) AS drain_rate
FROM order_write_queue
WHERE status = 'pending';
// oldest_pending 增长 = 消费停滞(最危险)
// backlog 高但 oldest_pending 稳定 = 生产快于消费,尚可承受
// 三者同时正常而业务说数据少 = 数据在更早的一环就丢了
把"队列积压"当成"缓存未命中"来处理是常见的误判:看到积压就加大生产侧的重试、或者干脆丢弃重投,于是问题扩大。反过来把"命中率下降"当成积压去扩容,只会白花钱。
3. 背压与降级是两种保护
| 保护手段 | 作用对象 | 触发时发生什么 | 用户感受 |
|---|---|---|---|
| 缓存降级 | 读路径 | 绕过缓存直连数据源 | 变慢 |
| 缓冲背压 | 写路径 | 拒绝接收新数据或减速 | 报错或排队 |
| 限流 | 入口 | 拒绝请求 | 明确错误码 |
| 熔断 | 依赖调用 | 快速失败 | 部分功能不可用 |
缓存降级的默认动作是"变慢",缓冲背压的默认动作是"拒绝"。这两个动作不能互换:如果缓冲满了选择降级(丢弃并继续接收),数据就没了;如果缓存失效选择了背压(拒绝读请求),一个本可以变慢的服务变成了不可用。
两者的关系还体现在资源竞争上:同一条链路上,缓存和缓冲往往共用同一批资源(同一台 Redis、同一组连接、同一段带宽)。高峰期缓存因为回源暴涨而占满内存,把缓冲挤掉——这是"缓存打满导致数据丢失"最常见的真实路径,而不是淘汰策略配置错误。所以两类角色如果必须共用资源,就要么物理隔离,要么给缓冲设资源下限。
// 共用实例时,按 key 前缀分别限流与限额,避免互相挤占
// Redis 本身不支持按键前缀分额度,需在应用侧用独立连接池与独立实例
// 推荐做法:缓冲单独一个实例,不与缓存共享 maxmemory
判断用哪一个,还是回到那条判据:丢了会不会有人需要。会被需要,只能背压;不会,可以降级。
// 保护手段的选择只有两步判断
数据丢了有人需要 ? ├─ 是 → 背压(拒绝接收 / 减速)
└─ 否 → 降级(绕过并继续服务)
缓冲满 → 背压
缓存失效 → 降级
两者互换 → 前者丢数据、后者把可降级变成不可用
4. 在接口与文档里固定这个分界
混淆之所以反复发生,是因为"这层 Redis 是干嘛的"从来没被写下来。有效的做法是把角色写进配置和命名。
// 角色显式声明,违反时由启动检查拦下
store:
name: order-write-queue
role: buffer // 允许值:cache | buffer
maxmemory_policy: noeviction
persistence: { mode: aof, appendfsync: everysec }
ttl: null // buffer 不允许设 TTL
on_full: backpressure // 缓冲满了必须背压
guard: // 启动时校验,配置与角色冲突即拒绝启动
- role==buffer -> ttl is null
- role==buffer -> maxmemory_policy == noeviction
- role==buffer -> persistence.mode != none
- role==cache -> on_full != block
store:
name: product-detail-cache
role: cache
maxmemory_policy: allkeys-lru
ttl: 300
on_full: evict
启动检查这一条比文档有效得多:文档会被绕过,配置校验不会。四个约束里任何一条被违反,服务起不来,问题在上线前就暴露。
5. 排错:五种"加了缓存结果更糟"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 高峰期数据缺失,日志无异常 | 缓冲被配了淘汰策略 | 改 noeviction,加上游背压 |
| 重启后待处理数据消失 | 缓冲未持久化或确认过早 | 开 AOF、后移确认点 |
| 未处理任务自行蒸发 | 给缓冲设了 TTL | 去掉 TTL,改为积压报警 |
| 扩了消费者积压不降 | 积压其实来自某分区倾斜或下游慢 | 看分区与下游耗时,不要盲扩 |
| 命中率上升但延迟没降 | 命中的是热键,慢请求集中在长尾 | 按请求分组看命中率,不只看整体 |
第四条值得展开:积压不降时最自然的反应是加消费者,但如果瓶颈在下游数据库或者某个分区特别热,加消费者只会增加竞争。正确的顺序是先看积压的分布,再看消费速率与下游耗时,最后才谈扩容。
排错时容易一上来就查连接数,因为报错在客户端最显眼;但缓冲角色的第一现场是内存与淘汰计数——evicted_keys > 0 就已经在丢数据了,此时查客户端池配置是在解决另一个问题。顺序要按"分布 → 消费速率 → 下游耗时"走,不要跳步。
6. 边界:什么情况这个区分可以模糊
| 场景 | 为什么可以模糊 | 做法 |
|---|---|---|
| 上游数据可完整重放 | 丢了能重新消费 | 允许淘汰,但要记录丢弃数 |
| 纯读路径,无写入 | 不存在"待处理"语义 | 全按缓存处理 |
| 单进程内的临时结构 | 进程退出即全部失效 | 不必区分 |
| 数据可重算且代价低 | 重建成本低于保障成本 | 按缓存处理并接受丢失 |
| 缓冲极短(毫秒级) | 丢失窗口极小 | 放宽持久化要求,但要监控 |
判断口径一句话:上游能不能重放。能重放,就允许把它当缓存;不能重放,它就必须按缓冲的全部要求来做——不淘汰、不设 TTL、确认点后移、有对账。
7. 自测
- 用一句话给出区分缓存与缓冲的判据。为什么这个判据不看数据量、也不看组件类型?
- 给缓冲配
allkeys-lru会发生什么?noeviction又带来什么新问题? - 确认时机如何决定"数据是否真的被接收"?把确认点前移的动机通常是什么?
- 为什么 TTL 对缓存是特性、对缓冲是缺陷?缓冲如果必须有过期时间,它应该扮演什么角色?
- 缓冲满了应该背压还是降级?缓存失效应该降级还是背压?两者互换会出什么问题?
- 上游能否重放,为什么是这条分界的最终判据?