混沌工程不是搞破坏:一次给 Redis 注入 500ms 延迟,验证了“本地缓存兜底“是个假象

📅 2026/8/18 6:27:52
混沌工程不是搞破坏:一次给 Redis 注入 500ms 延迟,验证了“本地缓存兜底“是个假象
title: 混沌工程不是搞破坏一次给 Redis 注入 500ms 延迟验证了本地缓存兜底是个假象tags: [混沌工程, chaos-mesh, 故障注入, resilience4j, 高可用]categories: [后端, 稳定性]我们团队一直自信地说购物车服务挂了 Redis 也不怕因为本地有 Caffeine 缓存兜底。直到有次混沌演练我给购物车服务的 Redis 实例注入 500ms 网络延迟监控里购物车接口的错误率没炸但下游订单服务的 DB 连接池先满了——本地缓存根本没兜住流量全穿透到回源。这篇想说清楚混沌工程的价值不是制造故障吓人而是用一次受控的实验证伪你以为成立、其实没成立的假设。那个本地缓存兜底就是个被混沌实验证伪的假象。事故现场以为有兜底结果兜底漏了我们的购物车读路径是先读 Caffeine 本地缓存miss 再读 RedisRedis 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 又穿透到 DBDB 连接池瞬间打满订单服务跟着挂。正确的兜底本地缓存作为 fallbackmiss 不回源混沌实验后我们把逻辑改成本地缓存是 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 TryCart 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 注入购物车错误率 0DB 回源量几乎不变。用代码编排一次受控的混沌实验混沌工程讲究假设—实验—观察—结论闭环。我们用 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 约 30ms8 分钟内 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 个服务调用的反例。