3022 字
约 10 分钟
18
S13-A18 · 缓存与缓冲的混淆:加速和排队用的是两套判据

为了扛住每小时一次的写入高峰,在应用和数据库之间加了一层 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. 自测

  1. 用一句话给出区分缓存与缓冲的判据。为什么这个判据不看数据量、也不看组件类型?
  2. 给缓冲配 allkeys-lru 会发生什么?noeviction 又带来什么新问题?
  3. 确认时机如何决定"数据是否真的被接收"?把确认点前移的动机通常是什么?
  4. 为什么 TTL 对缓存是特性、对缓冲是缺陷?缓冲如果必须有过期时间,它应该扮演什么角色?
  5. 缓冲满了应该背压还是降级?缓存失效应该降级还是背压?两者互换会出什么问题?
  6. 上游能否重放,为什么是这条分界的最终判据?
S13-A18 · 缓存与缓冲的混淆:加速和排队用的是两套判据
https://www.clxhxhhr.top/posts/6381/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。