title: "混沌工程不是搞破坏:一次给 Redis 注入 500ms 延迟,验证了'本地缓存兜底'是个假象"
tags: [混沌工程, chaos-mesh, 故障注入, resilience4j, 高可用]
categories: [后端, 稳定性]
我们团队一直"自信"地说:购物车服务挂了 Redis 也不怕,因为本地有 Caffeine 缓存兜底。直到有次混沌演练,我给购物车服务的 Redis 实例注入 500ms 网络延迟,监控里购物车接口的错误率没炸,但下游订单服务的 DB 连接池先满了——本地缓存根本没兜住,流量全穿透到回源。
这篇想说清楚:混沌工程的价值不是"制造故障吓人",而是用一次受控的实验,证伪你以为成立、其实没成立的假设。那个"本地缓存兜底"就是个被混沌实验证伪的假象。
事故现场:以为有兜底,结果兜底漏了
我们的购物车读路径是:先读 Caffeine 本地缓存,miss 再读 Redis,Redis miss 再读 DB。关键是本地缓存的写入口只有一个——在"从 Redis 拿到数据后回填"。混沌注入 Redis 延迟后,Redis 每次读都多 500ms,于是:
- 本地缓存里已经有的数据照常命中(这批没事);
- 本地缓存没有的数据(新用户、缓存刚过期)全部阻塞在等 Redis 的 500ms 上,线程被占住;
- 更糟的是,部分请求在 Redis 返回后回填了本地缓存、却又触发了"回填时顺手查 DB 补扩展字段"的逻辑,DB 回源量同比涨了 11 倍。
最小复现当时的问题代码:
// 错误示范:本地缓存只在"读到 Redis 成功"时回填,且回填顺手查了 DB public Cart loadCart(long userId) { Cart c = localCache.getIfPresent(userId); // ① 本地缓存 if (c != null) return c; c = redisTemplate.opsForValue().get("cart:" + userId); // ② Redis(被注入 500ms 延迟) if (c != null) { enrichFromDb(c); // ③ 回填时回源 DB(灾难放大器) localCache.put(userId, c); // ④ 才回填本地缓存 return c; } c = cartMapper.selectByUser(userId); // ⑤ Redis 也没有才查 DB localCache.put(userId, c); return c; }逐行看:第 3 行本地缓存命中就返回,没问题;第 5 行读 Redis,被注入延迟后卡 500ms;第 7 行enrichFromDb在回填前又查了一次 DB——这是当时为了"缓存里顺手把用户等级字段补上"加的,结果本地缓存 miss 的请求每个都要打一次 DB;第 8 行才把结果放回本地缓存。后果是:Redis 一慢,所有本地缓存 miss 的请求既卡在 Redis 又穿透到 DB,DB 连接池瞬间打满,订单服务跟着挂。
正确的兜底:本地缓存作为 fallback,miss 不回源
混沌实验后我们把逻辑改成:本地缓存是 Redis 的 fallback,而不是前置层;Redis 不可用时直接读本地缓存的"上次好值",绝不回源 DB。
// 正确示范:用 Resilience4j 包裹 Redis 读,超时即走本地缓存兜底,不回源 DB private final CircuitBreaker breaker = CircuitBreaker.ofDefaults("cartRedis"); public Cart loadCart(long userId) { Cart cached = localCache.getIfPresent(userId); // 本地缓存始终是"上次好值" // 用熔断包裹 Redis 读,500ms 超时就降级,不阻塞、不回源 DB Try<Cart> redisTry = Try.ofSupplier( CircuitBreaker.decorateSupplier(breaker, () -> redisTemplate.opsForValue().get("cart:" + userId))) .recover(throwable -> cached); // Redis 挂了?直接返回本地缓存 Cart c = redisTry.get(); if (c != null) return c; // 本地缓存和 Redis 都没有,才查 DB,且这次查 DB 也用信号量限流,避免打爆 return dbWithBulkhead(userId); }逐行看:第 4 行本地缓存保留"上次好值"(哪怕 Redis 挂了也还有旧数据可用);第 7 行CircuitBreaker.decorateSupplier把 Redis 读包进熔断器,配置 500ms 超时;第 11 行recover在 Redis 抛异常时返回本地缓存cached,不再查 DB;第 15 行只有本地缓存和 Redis 都没有时才走 DB,且dbWithBulkhead用信号量限制并发查 DB 的数量,避免穿透把 DB 打爆。改完后同样的 500ms 注入,购物车错误率 0,DB 回源量几乎不变。
用代码编排一次受控的混沌实验
混沌工程讲究"假设—实验—观察—结论"闭环。我们用 chaosblade 在预发环境做注入,可以用 Java 调它的 CLI 编排,保证实验可重复、可一键停止:
// 用 Java 编排一次混沌实验:给 Redis 容器注入 500ms 延迟,10 分钟后自动恢复 public class ChaosExperiment { public static void main(String[] args) throws Exception { // 1) 注入网络延迟:目标是购物车依赖的 Redis 实例 String inject = "chaosblade create docker network delay " + "--time 500 --jitter 50 " + "--container-id redis-cart-1 " + "--interface eth0"; int code = new ProcessBuilder("bash", "-c", inject) .inheritIO().start().waitFor(); System.out.println("注入结果 code=" + code); // 2) 持续采集购物车接口的 QPS / 错误率 / DB 连接池占用(这里用伪代码代表埋点) observe("cart.loadCart", Duration.ofMinutes(8)); // 3) 无论结果如何,必须销毁实验,恢复环境 String destroy = "chaosblade destroy <uid>"; new ProcessBuilder("bash", "-c", destroy).inheritIO().start().waitFor(); System.out.println("实验已销毁,环境恢复"); } }逐行看:第 5 行chaosblade create docker network delay给 Redis 容器redis-cart-1的eth0注入 500ms 延迟(带 50ms 抖动更接近真实网络);第 11 行observe在实验期间采集指标(真实环境里我们接了 Prometheus 看 8 分钟曲线);第 15 行destroy是最关键的一步——任何实验都要有确定的销毁动作,否则演练变生产事故。我们规定所有混沌实验必须配超时自动恢复(chaosblade 支持--timeout)。
手动演练 vs 平台化注入对比
| 维度 | 手动 kill / 改配置 | 混沌平台注入(Chaos Mesh/blade) |
|---|---|---|
| 可控性 | 低,容易忘记恢复 | 高,支持超时自动恢复 |
| 可重复 | 差,靠人记步骤 | 强,实验即代码(YAML/Java) |
| 爆炸半径 | 难精准,常误伤 | 可精确到 Pod / 容器 / 网络层 |
| 假设验证 | 弱,偏"救火式" | 强,先写假设再证伪 |
| 风险 | 高 | 中(仍有误判风险,需预发先行) |
我们现在的规矩是:所有混沌实验先在预发环境跑通,假设被证伪或证实后,再决定要不要上生产做"小流量真实验证"。生产环境的实验必须配abort按钮和自动恢复。
实验分级:预发证伪,生产只做小流量真实验证
一个容易犯的错是把混沌实验直接上生产全量跑。我们的分级是:预发环境可以"暴力"注入(kill 节点、断网、打满 CPU),目的是把假设证伪;生产环境只做"小流量、短时、可熔断"的真实验证——比如只对 1% 的购物车流量注入 200ms Redis 延迟,观察熔断是否真生效,且随时能一键中止。这样既拿到真实证据,又不会把演练演成事故。生产实验的核心纪律是:爆炸半径可控、恢复路径确定、有人盯盘。
复盘真实数字
- 注入 500ms Redis 延迟后,本地缓存 miss 的请求平均等待500ms + 回源 DB 约 30ms,8 分钟内 DB 连接池(最大 50)被打满37 次,订单服务出现约 2.1 万次慢查询告警。
- 改造成"本地缓存 fallback + 熔断 + DB 信号量限流"后,同样注入下购物车错误率0%,DB 连接池占用峰值12/50,无任何告警。
- 那次实验证伪了两个团队假设:①"本地缓存兜住了"——实际兜底只在命中时成立;②"Redis 慢不影响 DB"——实际穿透回源把 DB 打挂。
- 我们后来把购物车本地缓存的 TTL 从 5 分钟提到 30 分钟(容忍 Redis 短暂时长不可用),并把
enrichFromDb这种"顺手回源"逻辑全部删掉。
我的取舍判断
第一,混沌工程的起点是"假设",不是"故障"。别为了"显得我们做了稳定性"去随机 kill 节点。先写下"如果 Redis 延迟 500ms,购物车应该错误率 < 1%",再去注入验证。证伪假设比制造故障有价值得多——这次就是靠证伪"本地缓存兜底"救了一潜在的线上事故。
第二,本地缓存应该是 fallback,不是前置穿透层。这个设计错误很隐蔽:平时 Redis 正常,本地缓存命中率高,你根本看不出它"只在命中时兜底"的缺陷。只有注入延迟这种实验才会暴露。
第三,任何实验都要有"一键销毁 + 自动恢复"。我们吃过一次亏:早期手动演练忘了恢复网络规则,导致那个 Redis 实例延迟持续了一整晚。从那以后所有实验强制destroy+ 超时。
思考题
你团队里有没有一句"我们某某组件挂了也不怕,因为有 XXX 兜底"的断言?如果用一次混沌实验去证伪它,你打算注入什么故障、观察什么指标?欢迎评论区聊聊。
稳定性是练出来的,不是吹出来的。下一篇写软件架构模式,讲一次过度服务化把简单查询拆成 5 个服务调用的反例。