事故回放
周五晚高峰,在线用户从50万瞬间冲到120万,CPU 在70%上下徘徊,但内存像被“吸入黑洞”——RSS 30GB→80GB→OOM Killer 介入。重启后 10 分钟又回到60GB+。监控显示:对象数、连接数、堆分配速率都在涨,但“泄漏工具”报不出明显问题。值班同学的第一反应是“缓存开太大”,第二反应是“第三方 SDK 没释放”,直到我们拉出对象生命周期图,才发现:所谓的“智能指针”正把对象一个个“抱死”,高并发只是把隐患放大成了雪崩。
高并发预期 vs 实际雪崩
预期:线程池+异步 I/O+连接池,按百万并发设计,CPU/内存曲线平稳。实际:慢查询/下游抖动→请求堆积→线程池满→队列积压→更多对象被持有→内存飙升→GC/释放来不及→雪崩。本质:不是“内存泄漏”四个字能概括的,而是“广义泄漏”(对象被逻辑持有、迟迟不释放)+“资源堆积”(缓冲区、对象池、连接)+“级联放大”(重试/超时/熔断缺失)。这类问题在分布式系统中被称为服务雪崩:一个环节变慢,引发上游排队、线程耗尽、级联失败,最终全线崩溃。智能指针不是万能的现场还原 1:双向引用闭环两个对象互相持有对方的shared_ptr,强引用计数永远≥1,离开作用域也不会析构。典型如双向链表、父子节点互相“强引用”。修复:将一端改为weak_ptr,打破闭环。现场还原 2:enable_shared_from_this + lambda 自持class Session : enable_shared_from_this { void start(){ auto self=shared_from_this(); timer.setCallback(self{...}); } };回调里捕获的 self 让 Session 的引用计数“只增不减”,所有 Session 永不释放。修复:回调改为按weak_ptr捕获,使用前 lock() 判断有效性。现场还原 3:误把“托管权”当“访问权”shared_ptr 只管理“谁拥有”,不保证“线程安全”。多线程同时写同一对象,依然要加锁或使用无锁结构。小结:shared_ptr 不是银弹,循环引用会让对象“看似活着”,weak_ptr/合理所有权设计才是正解。
内存堆积的隐形杀手
发送缓冲区过大/零拷贝策略不当:把protobuf直接塞进 libevent 的 evbuffer 时,默认按块分配,常见128k 请求却拿到 256k chain,高并发拥塞时 chain 数暴涨(实测可达15000 个),虚拟内存飙到数 10GB,而有效载荷仅几十 MB。断开连接后,RES 也不见明显下降,因为 glibc 的 ptmalloc 为性能不会立刻把内存归还给 OS。应急可在低峰时调用 malloc_trim(0) 观察回落;根治要按数据真实大小分配、限流/背压、减少中间拷贝。广义泄漏:对象“逻辑未释放”比“狭义泄漏”更致命——比如缓存了已失效会话、订阅关系未清理、定时器未取消、I/O 写失败却没丢弃缓冲。这类问题Valgrind 往往无能为力,需要按数据路径二分排查、打点计数、观察对象生命周期。工具与方法:动态:Valgrind/ASan/LeakSanitizer、heaptrack、自定义分配钩子;静态:Clang-Tidy、Cppcheck;运行时:top/pmap、统计对象分配/释放、观察队列与线程池水位。结论:别把“内存上涨”简单归咎于泄漏,先看“谁在持有”
五步止血与长期治理
第一步:熔断/限流/降级/隔离给 RPC、DB、缓存加超时、限流与熔断,上游超时后立刻失败回退,避免线程池被慢请求“占满”;定时任务/突发流量单独隔离。引入Sentinel等组件做系统保护,必要时“优雅降级”。第二步:背压与反压限制发送窗口、队列长度与对象池上限;协议层支持流控帧;I/O 失败要能快速丢弃/重试,不要让错误数据长期占坑。第三步:修复智能指针闭环双向关系一端用weak_ptr;回调按weak_ptr捕获并在使用前lock();需要“把自己传出去”时继承 enable_shared_from_this,禁止在构造期调用 shared_from_this()。第四步:按“真实大小”分配与池化避免“一刀切”的大块分配;为热点路径设计对象池/内存池;对外部库(如网络/序列化)使用零拷贝时,严格评估 chunk 大小与回收策略。第五步:可观测性与演练暴露对象数、引用链、队列长度、超时率、P99/P999;定期做压测+故障注入,验证限流/熔断/背压是否生效。架构箴言:在高并发世界,稳定性优先于极致性能;与其事后“捞内存”,不如在入口处“卡住洪水”。