缓存故障、降级与恢复:控制回源流量和旧值期限
缓存平时承担了大量读取,故障时这些请求可能同时转向数据库。原先只接收少量 miss 的数据库,会突然面对接近全部读流量;连接池排队、查询超时和客户端重试又会继续增加负担。
降级方案需要回答两个问题:哪些请求还能得到有意义的结果,以及系统最多允许多少额外工作。恢复时同样需要控制节奏,避免缓存刚上线就被预热、重连和积压请求再次压满。
缓存读取有哪些可选择的结果
命中、缺失、错误分别进入不同分支
读取商品展示信息
├── 缓存返回有效值
│ └── 返回缓存内容
├── 缓存明确不存在
│ └── 获取回源许可 → 查询数据库 → 按规则填充
└── 缓存操作失败
├── 允许旧值且仍在期限内 → 返回带版本信息的最后可用值
├── 获得回源许可 → 查询数据库
└── 无可用旧值或许可 → 明确返回暂时不可用缺失是一个数据结果,超时、认证失败和反序列化错误则需要分别处理。把所有异常都转为空值,会隐藏配置问题,还可能把错误结果写成负缓存。
例如商品介绍允许短时返回旧内容;库存扣减、余额判断和权限校验需要使用相应的业务规则。即使缓存中的旧余额“看起来合理”,也不能据此授权一次新的扣款。
给最后可用值明确的时间限制
普通 TTL 到期后,缓存条目可能直接消失。如果降级时需要使用旧值,应显式保留内容版本、生成或确认时间,以及允许使用的最晚时刻。
| 字段 | 用途 |
|---|---|
| value / revision | 内容与对应版本 |
| freshUntil | 正常使用或需要刷新时的判断 |
| staleUntil | 故障降级也不得超过的期限 |
| 来源和适用范围 | 确定它能回答哪个主体、哪个业务问题 |
staleUntil 到达后应停止使用,不能在每次命中时不断向后推。若副本原本就来自存在延迟的读取路径,还需计入这部分旧值来源,而不是只从写入本地缓存时开始计算数据新鲜度。
旧值策略应当与接口语义一致。报价展示可以标识报价版本,下单仍需重新校验实际价格;权限撤销则可能要求立即停止使用旧结果。
返回失败也需要清楚的接口语义
限流拒绝、依赖不可用和请求参数错误应有不同的错误类型。HTTP API 可以根据具体含义使用 429 或 503,并在能够给出合理等待建议时提供 Retry-After;客户端仍需有重试预算,不能收到任何错误就立即重发。429 见 Additional HTTP Status Codes,503 与 Retry-After 的语义见 HTTP Semantics。
若业务数据库已经提交,仅在缓存写入或删除时失败,应保留已经发生的业务结果,再安排缓存修复。将整个请求伪装成“完全没有执行”会诱发重复操作。实际提交与删除失败的对照见 缓存一致性。
限制故障扩散到数据库和应用线程
从命中率估算新增回源量
假设读请求每秒 10,000 次,平时命中率为 99%,那么正常回源约为每秒 100 次。缓存全部不可用且所有请求都回源时,数据库读取可能接近每秒 10,000 次。
这个计算只表示流量变化的量级;真实负载还取决于查询成本、同键合并、批量请求和写入竞争。若回源需要多条 SQL,应按实际 SQL 数与连接占用时间计算,而非只看接口 QPS。
缓存故障时要优先维持关键路径的可用容量。可以对不同业务配置回源并发上限、等待期限与优先级,必要时拒绝非关键请求。
超时需要放进一次请求的总预算
客户端或上游给定的请求期限
├── 缓存等待
├── 获取回源许可或连接的等待
├── 数据库查询
├── 序列化和响应写出
└── 必要的余量若整个接口需要在 500 毫秒内返回,缓存已经等待了 450 毫秒,再开始一个一秒数据库查询没有实际意义。应将剩余时间传给后续调用,并在预算不足时结束请求。
命令超时限制客户端等待时间。已经发送的写命令仍可能在服务端执行;读取超时后的回源一般可以重新读取事实,扣减、计数或发布消息的超时则需要幂等与结果查询。真实“服务端已执行但客户端缺少回复”的实验见 Redis 客户端与协议。
Lettuce 中连接超时、同步命令等待、断线期间的命令接收和请求队列是不同配置项。队列过大时,故障期间可能保存大量待执行命令。设置有界 requestQueueSize,并明确 autoReconnect 与 disconnectedBehavior,能让过载或断线更早暴露给调用方。选项定义见 Lettuce Client Options。
用受限许可控制同时回源
if (!permits.tryAcquire()) {
throw new IllegalStateException("temporarily unavailable");
}
try {
return loadFromDatabase();
} finally {
permits.release();
}这段结构中的 permits 可以是 Semaphore;它限制同时进入受保护代码的任务数。许可必须在 finally 中归还,查询失败、请求取消和序列化异常都不能泄漏许可。获取与释放规则见 Java Semaphore。
如果选择等待许可,也要给等待设置期限并限制排队数量。把 10,000 个请求全部挂在线程或 Future 上等待,仍会占用内存、连接和上游资源。
每个实例各设三个许可,十个实例合计就可能有三十个回源任务。单实例上限应由数据库的整体余量分配,并为正常业务读写保留空间。
同键合并、限流和熔断各处理一部分问题
| 措施 | 主要减少什么 | 仍需处理什么 |
|---|---|---|
| 同键加载合并 | 同一条目上的重复加载 | 不同键同时失效 |
| 回源并发许可 | 同时占用下游的任务 | 每秒总次数、查询超时 |
| 速率限制 | 一段时间内进入的请求数量 | 单次请求执行很久 |
| 熔断 | 已知故障期间反复访问依赖 | 降级路径容量与恢复探测 |
| 有界队列 | 积压对象和等待数量 | 超期请求取消与拒绝响应 |
熔断器暂时停止访问故障依赖后,应通过少量探测判断是否恢复。不要把每个业务请求都当成探测流量,也不要在每个调用层各自重试三次,放大为多层相乘的访问次数。
重试适合有限的暂时性失败,并需要总截止时间、次数上限和间隔抖动。认证错误、错误键类型或不兼容序列化通常需要修正配置或数据;重复发送同一命令不会修复它们。
恢复时先建立小范围可用性
PING 恢复只能说明服务器可以处理这类命令。接下来还需要用应用实际身份验证读写权限、键格式、序列化、TTL,以及所连接的实例和数据是否正确。
可采用以下顺序:
连接恢复
→ 少量实际读写探测
→ 检查数据代次和本地旧副本
→ 有界预热少量高价值条目
→ 逐步恢复普通请求与后台任务
→ 持续观察数据库余量和缓存尾延迟预热应从可确认的源数据读取,限制批量大小、并发与速率。多个实例同时全量预热,会把恢复操作变成另一轮集中回源。
本地 L1 在 Redis 故障期间可能错过失效通知。恢复连接后,需要清理无法确认版本的本地副本,或用可靠的代次/重放机制重新同步。Pub/Sub 断线丢失与多级副本问题见 BigKey、内存与多级缓存。
用真实暂停验证受限回源
准备专用环境
下载 缓存实验工程。普通 Linux 用户按 工程准备取得 PROJECT_DIR、LAB_DIR,再按 Redis 网络准备启动 ca11-redis。
环境为 Bash、Docker、Maven 3.9.12、Java 25 或 17、Redis 8.10.1。Redis 位于 ca11-lab internal 网络,没有宿主端口发布。测试创建自己的 H2 内存数据库和随机 Redis 键。
此实验会让整个专用 Redis 暂停处理普通客户端命令五秒。因此只在这一实验实例上执行,不与其他实验并行,也不连接共享开发 Redis 或生产 Redis。命令作用范围见 CLIENT PAUSE。
test "$(id -u)" -ne 0 || exit 1
docker exec ca11-redis redis-cli PING
docker run --rm --name ca11-degradation-build \
--network ca11-lab --user "$(id -u):$(id -g)" \
-e MAVEN_CONFIG=/tmp/maven \
-e REDIS_URI=redis://ca11-redis:6379 \
-v "$PROJECT_DIR:/work" -v "$LAB_DIR/m2:/cache" -w /work \
maven:3.9.12-eclipse-temurin-25 \
mvn -B -ntp -o -Duser.home=/tmp -Dmaven.repo.local=/cache \
-Dtest=CacheDegradationTest test预期 2 个测试通过,BUILD SUCCESS。Java 17 使用 maven:3.9.12-eclipse-temurin-17 重跑。测试开始连接前会检查固定实验地址,拒绝其他 REDIS_URI。
20 次超时后,只有三次查询进入数据库
实验先写入一个有效缓存值,随后向真实 Redis 发送五秒 CLIENT PAUSE。读取连接的命令等待期限为 100 毫秒,20 个线程同时发送 GET。
每个读取实际超时后,尝试获取三个许可之一。取得许可的线程执行真实 JDBC 查询,再在受控位置等待;其余线程立即返回 busy。等待点用于稳定保留三个正在处理的回源任务,使拒绝分支能够被观察。
realRedisTimeouts=20 actualDatabaseQueries=3 peakFallback=3 rejected=17结果由实际异常次数、SQL 调用次数、活跃任务峰值和返回值共同计算。若三个许可未被正确释放,后续请求就会持续拒绝;若把许可放在查询之后,数据库已经承受了全部查询,上限设置便失去作用。
测试中的等待点延长了回源任务占用时间,不能把这组耗时作为数据库性能数据。它验证并发控制的行为,不用于选择生产中的“最佳三个许可”。
等待服务器恢复,再读取原值
afterPauseDeadline=PONG existingCachedValue=cached控制连接使用足够覆盖五秒暂停的等待期限,实际等待服务端恢复处理,随后检查 PING 和原缓存值。读取连接恢复为两秒等待期限,旧连接上的新 GET 可以成功完成。
这次故障是服务端命令暂停,TCP 连接没有被主动切断,也没有停止 Redis 进程。它没有覆盖 DNS 故障、连接重建、主从切换或数据丢失;这些情况应在具备对应部署的隔离环境中分别注入。
CLIENT PAUSE 有自动结束期限,测试结束时清理自身键和连接。若测试在暂停期间退出,先等五秒,再用实验容器的 redis-cli PING 确认恢复,不需要删除其他数据或反复重启服务。
检查旧值期限与有限预热
staleAllowedBeforeDeadline=true deadlineAndSensitiveReadRejected=true warmKeys=2 actualWarmQueries=2另一个测试用显式时间参数检查旧值规则:允许旧值且未到期限时返回;达到期限或该操作禁止旧值时抛出不可用异常。
预热部分使用真实 H2:先提交商品新内容,再逐条读取并写入两个 Redis 键,最后从 Redis 读取两条新值。这里采用串行两条作为有界预热示例,未执行自动扩容或生产流量爬坡。
需要更大批量时,应在同样的查询计数和延迟观测下逐步扩大,并在数据库等待增加时暂停预热。
从故障定位到长期维护
先分清客户端等待与服务端执行
| 观察 | 可能涉及的位置 | 下一步 |
|---|---|---|
| 应用超时增加,Redis 命令执行时间正常 | 网络、客户端线程、事件循环、排队或解码 | 关联客户端耗时、线程与连接队列 |
| Redis 慢命令增加 | 大结果、复杂命令、脚本执行 | 检查命令类型、输入规模与键分布 |
| 命中率下降且数据库等待增加 | 集中到期、驱逐、冷启动或键变更 | 限制回源,检查 TTL 和驱逐指标 |
| 缓存恢复后仍读取旧值 | 本地副本、失效遗漏或反序列化兼容 | 检查内容版本,清理不能确认的副本 |
| 连接成功但命令持续 NOPERM | ACL 或实际连接身份 | 修正权限,不通过扩大重试掩盖 |
| 新写入失败但 PING 正常 | 内存上限、只读节点或命令错误 | 检查完整错误与目标节点角色 |
SLOWLOG 记录的是服务端命令执行耗时,不包含客户端网络收发的全部时间。因此应用慢而 SLOWLOG 没有对应条目时,仍应检查连接、网络和客户端处理。具体定义见 SLOWLOG,更广的延迟来源见 Diagnosing latency issues。
排查应优先使用受限、聚合的指标。MONITOR 会输出命令流并可能暴露键和值,也会引入额外开销,不宜把它作为生产环境的常驻观测方式。
观察结果和新增负载
请求结果
├── 正常缓存命中
├── 数据库回源成功
├── 允许的旧值返回
└── 明确拒绝或失败
资源变化
├── 缓存命令尾延迟、超时、队列和重连
├── 回源许可占用、等待与拒绝
├── 数据库查询数、连接等待、查询尾延迟
└── 预热速率、失效积压和内存驱逐整体 HTTP 成功率可能因为大量旧值响应而保持稳定;需要同时观察旧值比例、版本年龄与业务错误。日志中保留请求关联 ID 和错误类别,不记录会话令牌、认证凭据或完整敏感缓存值。
Redis INFO 提供命中/缺失、驱逐、内存和连接等计数,但应用层还需要按用途区分调用结果。指标字段见 INFO。
为每类缓存保存可操作的配置说明
| 项目 | 应说明的内容 |
|---|---|
| 内容来源 | 哪张表、哪个服务或哪条事件流可以重新生成 |
| 键结构 | 环境、租户、对象、版本与编码规则 |
| 有效性 | TTL、最大允许旧值期限、主动失效方式 |
| 容量 | 大小上限、驱逐策略、预期访问量 |
| 故障处理 | 回源预算、拒绝条件、修复方式 |
| 变更与清理 | 新旧格式兼容、旧命名空间退出条件 |
这份说明应跟随配置和代码变化更新。缓存的维护者需要能够回答:删掉这类键会触发多少回源,恢复旧版本应用能否读懂新值,以及撤销一条权限后哪些副本必须失效。
批量删除前先确认目标前缀、规模和回源预算。SCAN 是游标遍历,应处理多轮结果;它不是数据快照。命令的迭代保证见 SCAN。具体的限定前缀、UNLINK 和保留非目标键实验在 容量与多级缓存篇。
修改键格式时安排新旧版本共存
序列化结构变化可以使用版本化命名空间,让新应用写入新格式,旧应用继续使用旧格式。这样仍可能增加内存和回源量,应先计算共存阶段的资源成本。
若需要双写,明确哪一份结果用于读取、哪条失败能够重试,以及何时停止旧写入。切换前检查旧版本实例是否已经退出;清理旧键时保留可回退条件,避免仍在运行的旧实例重新填充刚被清除的数据。
完成恢复后,再逐项撤销临时放行、放大的超时或增加的本地额度。应急设置若长期保留,会改变日常系统的容量与风险分配。
权威资料与规范地址
按客户端行为、故障注入、并发控制与观测命令查阅。部署方式不同的故障,需要在对应环境中验证。
