
那年我还在维护一个订单系统线上出过一个让我印象非常深的问题用户支付成功钱已经从账户扣掉了结果在发送通知那一步下游渠道抛了个异常。代码里有个统一的catch接住异常后打了条日志然后流程继续往下走。看起来程序没有崩溃用户却既收到了扣款又收到了支付失败请重试的提示。客服后台一夜之间多了一堆工单。这个案例让我彻底意识到一件事异常安全不是异常有没有被捕获的问题而是异常发生之后系统状态是否还能符合预期的问题。它不会写进功能清单却比大多数功能都更能决定一个系统能不能长期稳定地跑下去。异常安全编程要应对的正是这一类问题当异常或错误出现时资源不泄露、对象保持合法状态、关键不变量不被破坏。无论你用的是哪种语言只要代码里有用完后必须释放的资源或者有需要保持一致的状态就躲不开这个话题。这篇文章我会从异常安全的定义讲起搭配具体的反面案例和重构方法顺便把排查、测试和code review时该盯的地方都梳理一遍。1. 异常安全不等于捕获异常先搞清楚它解决的是什么问题1.1 扣款成功但通知失败一次让我对异常改观的线上事故上面那个订单事故细看其实有两层问题。第一层是异常路径处理得太粗糙。catch住异常后只做了一件事记录日志。但业务流程里用户被扣款和用户收到支付结果是两个独立的副作用后者失败时前者已经落库了用户视角就是扣了钱但说我没支付成功。第二层是流程顺序没有设计。通知下游本来是可以提前预检的或者退一步说就算通知失败也应该有对应的补偿动作比如让用户看到支付成功通知稍后补发而不是把整个支付结果标记成失败。第二层才是异常安全真正关心的东西。它关心的不是catch住之后程序还跑不跑而是异常发生后系统状态还对不对。catch只是把异常拦住拦住之后做没做对的事是另一回事。后来这个问题的修复不是多写几行catch而是调整了流程结构把可能失败的步骤前置把真正不可控的外部调用放在最后同时给通知环节加了补偿队列。到这一步我才开始把异常安全当成设计问题而不是单纯的错误处理问题。1.2 异常安全的定义资源、状态与不变量异常安全在工程上一般拆成三件事来看。资源内存、文件句柄、 socket、数据库连接、锁一切用完必须归还的东西。异常发生后不能因为提前退出而不还。状态对象成员变量、数据库记录、缓存、外部系统可见的表现。异常发生后状态不能变成自相矛盾的半新半旧。不变量任何时刻都必须为真的约束例如账户余额不能为负订单状态与支付状态一致。异常发生后不变量必须还能成立。举个例子一个账户类里有余额字段业务规则要求余额不能为负。如果一次扣款操作在余额减少完成后、在记录流水之前抛了异常用户的钱少了但流水丢了余额本身可能还是非负的但账户的审计不变量已经被破坏了。这正是异常安全要防的东西。所以判断一段代码异常安全不安全不能看它有没有try catch而要看上面这三件事在异常路径下是否都站得住。1.3 哪些代码最该关心异常安全哪些不用过度设计我见过两种极端一种是对异常完全不管裸指针满天飞另一种是每个函数都套try catch恨不得把return都包起来。两者都错。异常安全有明确的优先级不是所有代码都值得做同样强度的防护。真正需要认真对待异常安全的是这几类代码管理资源的代码。new/delete、lock/unlock、打开关闭文件、建立连接异常一进来资源就容易悬空。对外暴露状态的边界代码。数据库写入、缓存更新、消息推送、用户可见的字段变更这类代码出错会影响整条业务链。公共库和通用组件。你不知道调用方会在什么上下文中调用你异常会沿着调用链传播到哪里去所以公共代码的异常安全要求反而更高。构造函数、析构函数、swap、move这些特殊成员函数。它们在异常语义上有特殊规则踩坑成本特别高。反过来纯计算、无副作用、不持有资源、也不修改外部状态的代码比如一个字符串格式化函数、一段纯算法计算通常不太需要过度设计。把精力集中在上述四类代码上收益比全局铺开大得多。2. 从能活下来到根本不抛异常安全等级的四个台阶异常安全不是模糊的处理得好不好它有明确的等级划分。这组等级最早在C圈子里被系统讨论现在几乎所有语言的异常设计都能套用。理解这四个等级你就知道运行不崩溃和状态完全正确之间差了多少。2.1 基本保证底线是不泄露、不崩坏基本保证说的是抛异常时资源不泄露对象处于合法状态不变量仍然成立但对象的具体内容可能已经被改变且改变到哪一步调用方无法预知。标准库容器大多提供的是基本保证。比如向vector里push_back时抛了bad_alloc容器本身仍然是有效的可以继续使用但里面元素的状态符合哪个历史版本标准不保证。调用方只知道它没坏不知道它变成了什么。基本保证是任何代码的底线。如果你的代码连基本保证都做不到那异常发生时可能直接把进程搞死或者搞崩数据。大部分资源泄漏类问题根源就是连基本保证都没达到。2.2 强保证提交或回滚没有第三种结局强保证更进一步抛异常时程序状态与调用前完全一致就像这次操作从来就没发生过一样。用数据库的说法就是原子性——要么全部生效要么全部回滚。强保证是commit或rollback级别的承诺没有中间状态没有改了A没改B的半成品。日常开发中转账、配置热更新、订单状态流转这类操作都值得争取强保证。现实里很多线上bug本质都是某个操作只有基本保证但调用方默认它提供了强保证于是在异常路径上读到了诡异的状态。2.3 不抛异常保证危险动作交给绝对的确定性最强的保证不叫异常安全强保证而是不抛异常保证。它承诺函数在任何情况下都不会向外抛出异常失败要么被内部吞掉要么通过其他方式报告比如返回错误码。哪些函数必须做到不抛最典型的是析构函数、swap、移动构造函数、内存释放操作。原因很实在析构函数如果抛异常在正常的栈展开过程中再抛一次异常运行时会直接调用terminate终止进程。swap通常被用作强保证实现的最后一步如果它自己会抛异常前面的副本工作全白费。移动构造函数如果不抛容器在扩容时才能放心地移动元素而不是一个一个拷贝。2.4 错误码路径同样需要异常安全的纪律有些项目会明文禁止异常比如部分嵌入式环境、游戏引擎核心、或对性能极其敏感的服务。这种情况下错误码替代了异常但资源释放和状态一致性的问题不会消失只是换了个马甲。错误码的隐蔽风险是忘记检查返回值。函数返回错误码调用方忘了看接下来继续用半初始化的结果做后续操作状态照样崩。所以即使用错误码也一样需要RAII来兜底资源释放一样需要先prepare再commit的思想来保证状态原子性。2.5 一张表理清四个等级的场景与手段说了一大堆整理成一张表平时翻起来方便保证等级核心承诺典型场景常用实现手段基本保证资源不泄露对象合法但状态可能已改变多数容器操作、日志写入RAII兜底资源catch后恢复合法状态强保证失败时状态与调用前完全一致转账、配置更新、订单状态流转copy-and-swap、事务、先prepare再commit不抛异常函数绝不向外抛异常析构、swap、move、资源释放内部吞错、避免在关键路径分配资源无异常代码域错误码传递失败但仍需保证资源与状态嵌入式、性能敏感核心RAII加状态机错误码检查与恢复这张表写进团队的代码规范文档里比空喊注意异常安全有用得多。3. 最常见的异常安全破坏者裸资源、半初始化与中间状态失控很多异常安全问题不是难懂而是藏在最普通的写法里。这一章我挑几个最常出现的雷区每一个都在真实代码里见过不止一次。3.1 裸指针遇上异常delete永远等不到最典型的案例void process() { Widget* w new Widget(); doSomething(w); // 如果这里抛出异常 delete w; // 这行永远等不到执行 }只要doSomething抛出异常delete w就执行不到堆上的Widget变成内存泄漏。更麻烦的是这种泄漏不会立刻崩而是内存慢慢涨过几天才被监控发现。改成智能指针一行解决void process() { auto w std::make_uniqueWidget(); doSomething(w.get()); }局部智能指针会在栈展开时被自动析构无论doSomething是否抛异常内存都会归还。这不是什么高深技巧但能记住并真正落实到每一处裸指针上的人其实不多。更隐蔽的是多资源获取场景第一个new成功第二个new抛异常。如果两个都是裸指针成员第一个指针就悬空泄漏了。处理方法一样——每个资源都放进独立的RAII对象里一个成员一个智能指针。3.2 构造函数抛异常析构不跑资源悬空构造函数是异常安全里最容易踩坑的位置因为它的失败语义比较反直觉class Connection { Buffer* buffer_; Socket* socket_; public: Connection() { buffer_ new Buffer(); socket_ new Socket(); // 如果这里抛出异常 } ~Connection() { delete buffer_; delete socket_; } };如果new Socket()抛异常Connection的构造函数会失败而这个对象根本不存在所以析构函数不会被调用。结果是buffer_指向的Buffer泄漏了。C对这条规则没有例外构造函数失败时只有已经构造完成的成员那些本身是完整对象的成员会被自动析构裸指针自己不会释放指向的东西。修复方案也很直接成员改成智能指针或者直接用成员对象而不是指针成员。class Connection { std::unique_ptrBuffer buffer_; std::unique_ptrSocket socket_; };第二个成员构造失败时第一个成员的unique_ptr析构会被调用Buffer正常释放。这个坑在Java、C#里虽然不会内存泄漏但构造函数里手动获取了连接、文件句柄等资源时同样要小心构造函数中途挂掉那些外部资源也得靠finally或try-with-resources兜住。3.3 多步骤操作的中间状态改了AB失败了怎么办异常安全不只在对象内部。一个业务流程跨多个对象、甚至跨多个系统时中间状态失控是最难防的。举个真实场景开户流程第一步创建用户记录成功第二步初始化账户资金失败。如果代码是每步各写各的用户记录可能已经落库资金账户却没有建立用户拿到一个残缺账号。这种问题在内存里面表现为对象字段不一致在数据库里表现为表和表之间对不上在分布式系统里就是数据不一致的悬案。处理思路一般有三条整个流程包进同一个事务失败一起回滚。这是最省心的前提是你的存储支持事务。如果跨服务拆不开事务就设计补偿操作失败时把已经完成的一步反向恢复。在内存中的多对象操作先把所有新状态准备好最后一步统一替换引用也就是后面第五章要讲的先prepare再commit。这三条思路的共同点是承认中间状态存在然后用机制保证中间状态不可见。3.4 手动管理锁异常一来就是死锁锁资源同样经常被裸写mutex.lock(); updateConfig(config); mutex.unlock(); // 如果 updateConfig 抛异常updateConfig抛异常后unlock执行不到锁永远不会释放。其他线程全部卡死服务表现为假死。这种问题比内存泄漏更难排查因为进程还在但所有请求都进来了不处理。正确姿势是锁也要RAIIstd::lock_guardstd::mutex lock(mutex); updateConfig(config);lock_guard析构时必定释放锁异常也拦不住。文件句柄、数据库连接、socket也是一样的原则能用手动close的地方都要想一下如果这中间抛了异常close还跑不跑得掉跑不掉就封装起来。4. RAII与它的亲戚们把资源释放从自觉变成必然聊完雷区聊核心武器。RAIIResource Acquisition Is Initialization是我在异常安全上最依赖的手段没有之一。4.1 RAII的本质把资源生命周期绑定到对象生命周期RAII的思想一句话就能说清在对象构造时获取资源在对象析构时释放资源。资源活多久就看对象活多久。为什么它能对抗异常因为C有一条铁律栈展开时所有活着的局部对象一定会被析构不管你是正常return还是异常往上抛。也就是说只要资源被某个局部对象的成员管着异常过来时它跑不掉一定会被释放。这是编译器替你保证的不是靠程序员记得。一个生活化的类比进门换鞋后钥匙放门口挂钩上出门时看到钥匙就想起来拿。对象的生命周期就是那个挂钩资源就是钥匙——只要你守规矩放在挂钩上走的时候就不会忘。4.2 不只是CRust、Python、Java、Go里的同一思想不少人以为RAII是C专属其实这套思想早就被各个语言以不同形态吸收了。Rust把RAII当成立身之本Droptrait就是显式的析构钩子文件、锁、连接全都是被析构自动释放的对象。Python的with语句配合上下文管理器保证无论代码块里是否抛异常__exit__都会执行。Java从7开始提供的try-with-resources本质也是把资源释放变成块退出时的必然动作。Go的defer虽然机制不同但实现目标一致注册一个一定会在函数退出时执行的清理动作。所以无论你主力语言是什么资源释放必须与某种确定性生命周期绑定这条原则都适用。C里叫RAII别处可能叫别的名字但思路相通。4.3 把RAII真正落地内存之外还有连接、锁、句柄很多人一谈RAII就想到智能指针以为只负责内存。其实RAII的应用范围要大得多。内存资源unique_ptr、shared_ptr注意别在环状结构里硬套shared_ptr那是另一个坑。锁资源lock_guard、unique_lock以及读写锁的RAII封装。文件句柄把fopen/fclose包进一个句柄类析构时判空关闭。数据库连接连接从连接池借出时用一个Guard对象持有析构时归还连接池。临时信号处理、操作系统的handle、CUDA的显存分配凡是有成对操作的地方都值得做一层RAII。比如一个简单的连接归还封装class ConnectionGuard { Database* db_; public: explicit ConnectionGuard(Database* db) : db_(db) {} ~ConnectionGuard() { if (db_) db_-release(); } };连接用完了扔进Guard函数里无论走到哪条路径析构都会触发归还。这种十几行的封装能省下无数个异常路径忘了归还连接的bug。4.4 RAII解决了一半问题另一半靠状态设计注意RAII管的是资源不是业务状态。一个对象里有a_和b_两个字段先改了a_改b_时抛异常RAII一点忙都帮不上——它只负责释放不负责撤销已修改的字段值。所以把资源问题解决完之后真正的深水区是状态一致性。这正是下一章要解决的。先记住这个分工资源靠RAII状态靠强保证设计。5. 拿到强保证的实战手艺copy-and-swap、pimpl与事务思维强保证在代码层面怎么落地我按从单对象到多对象、从本地到跨服务的顺序讲三套可操作的套路。5.1 copy-and-swap所有危险操作都先发生在副本上copy-and-swap大概是实现强保证最经典也最不容易出错的手法。思路朴素你担心修改原对象时会抛异常那就先复制一份所有可能失败的操作全放在副本上做最后用一次不可能失败的swap把副本和原对象交换。class Order { std::vectorItem items_; Money total_; public: void addItem(const Item item) { Order copy(*this); // 可能抛异常的拷贝 copy.items_.push_back(item); // 可能抛异常 copy.total_ item.price(); // 可能抛异常 swap(copy); // 不抛异常临界点 } void swap(Order other) noexcept { items_.swap(other.items_); std::swap(total_, other.total_); } };为什么它天然具备强保证因为所有可能失败的修改都作用在copy上原对象从头到尾没被动过。一旦中途抛异常copy被析构原对象保持原样只有所有操作都成功才走到swap这一步而swap声明了noexcept不可能抛异常。整个函数对外表现就是要么一切照旧要么全部更新。代价是拷贝开销。它更适合低频、敏感的状态变更比如配置更新、小规模订单操作。如果每次请求都往一个几千万元素的大容器里做copy-and-swap性能肯定受不了。这种时候通常要退而求其次或者用下面的pimpl思路做局部化。5.2 pimpl与先prepare再commit的替换思维pimplPointer to Implementation原本是C里隐藏实现细节、保持二进制兼容的惯用法但它对异常安全也有奇效。原因在于它天然支持替换实现而不是修改实现。比如一个组件内部的状态全部放在Impl结构里组件本身只持有一个指向Impl的指针。更新时不是直接改现有Impl而是构造一个新的Impl把新状态填好然后用一次swap整体替换struct WidgetImpl { std::vectorRule rules; std::string version; }; void Widget::update(const Config c) { auto newImpl std::make_uniqueWidgetImpl(); newImpl-rules buildRules(c); // 可能抛异常 newImpl-version c.version; // 简单赋值 impl_.swap(newImpl); // noexcept提交点 }这个模式背后的思维可以概括成一句话能替换就不修改。与其在原状态上抠抠索索地改回去不如造一个完整的新状态候选者一切都准备好之后一次性切换。切换之前外部看到的一直是旧状态永远看不到半新半旧的东西。这种不可变中间体思维在函数式编程里很常见在异常安全里一样好用。写复杂状态变更时先想想能不能先把新状态全算出来再一次性提交往往比你小心翼翼地在原对象上做回滚要可靠得多。5.3 单对象之外多对象与跨服务的事务式提交单对象可以用copy-and-swap多对象呢思路可以升维。同进程内的多个对象更新可以先把所有新状态都prepare成临时副本等全部准备成功后再依次执行不会失败的swap提交。只要每个swap都是noexcept整个流程就有强保证的潜质。如果对象的数据本身存在数据库里那就直接用数据库事务。事务的原子性对应强保证隔离性保证中间状态别人看不到这比在应用层手工回滚靠谱得多。跨服务、跨数据库的时候本地事务往往覆盖不了这时候业界常用的姿势是SAGA/补偿模式把一个大操作拆成多个子操作每个子操作都有对应的反向补偿。如果第N步失败就把前面N-1步的补偿全部执行。这个方案不再有单点上的强保证但通过设计把最终一致性兜住。我在应用层最喜欢的一条原则是把校验和所有可能失败的操作尽量往前提让真正不可逆的提交点尽量靠后、尽量少。很多异常安全问题其实是通过调整步骤顺序就能消解的。6. 代码审查、故障排查与异常路径测试让隐患浮出水面掌握了原理和手法还得有让隐患现形的能力。这一章分享我在code review和排故障时常用的实战姿势。6.1 我在code review时扫异常安全问题的检查清单每次看别人提交的代码我会专门留一条异常安全视角的检查链路。大概长这样函数里有没有裸指针、裸句柄、手动lock/unlock、手动close有的话问一句中间抛异常谁会释放它有没有先修改状态再判断后续操作是否成功的路径失败时的状态回滚逻辑写了吗析构函数里有没有可能抛异常的操作析构函数默认应该noexcept需要在析构里做复杂清理的话先想想怎么吞掉或重新设计。swap和移动构造函数有没有标noexcept标了之后真的不会抛吗构造函数里多个资源获取的顺序失败时已获取的资源会不会悬空对外接口的异常契约写清楚了吗调用方知道你这个函数是基本保证、强保证还是不抛吗文件、数据库、下游RPC这些副作用操作是不是尽量放在了最后失败有没有补偿这套清单看着长其实顺下来只要一两分钟。它能挡住绝大多数低级的异常安全错误。6.2 偶发故障排查从现象反推异常路径异常安全的bug往往不是每次必现而是偶发最容易让人头疼。常见现象和可能的根因我归纳过几种内存持续缓慢增长大概率是异常路径上某个裸指针泄漏正常路径释放了异常路径跳过了。偶发死锁、服务假死先查锁看有没有手动lock/unlock中间有没有任何一步可能抛异常。数据状态偶发错乱、且只在重试或降级路径出现怀疑多步骤操作缺少强保证第二、三步骤之间失败导致的。极端情况下直接terminate或进程崩溃查析构函数是否抛异常或者noexcept函数里真的抛了异常。排查思路一般是从最后一个成功动作往回倒推。先看异常发生的位置再看这个位置之前已经完成了哪些副作用最后判断这些副作用是否具备可补偿性。我曾经排查过一个登录服务的偶发bugtoken更新成功后写操作日志失败导致每次登录都重复发token。代码层面异常是被处理了但token这个核心状态已经改了日志失败让重试逻辑误判操作失败于是无限重试。这类问题的复现手法最常见的是故障注入在目标函数内部临时抛一个异常观察资源释放和状态变化。不模拟异常光靠肉眼review很难还原现场。6.3 给异常路径写测试注入、受限分配器与状态快照异常安全的测试和普通功能测试不太一样关键不是验证正常功能而是模拟异常发生后验证状态不变。我有三种比较有效的姿势。第一种用单元测试框架显式断言状态不变。先保存对象快照再触发一个必然抛异常的操作最后比对快照TEST(OrderTest, AddItemThrowsWhenAllocFails) { Order o; auto before o.snapshot(); EXPECT_THROW(o.addItem(bad_item), std::bad_alloc); EXPECT_EQ(o.snapshot(), before); // 强保证的核心断言 }第二种用受限分配器模拟内存分配失败。把全局或局部的operator new换成受控版本在指定次数分配时抛出bad_alloc专门测试极低内存场景。第三种在关键交界处注入异常。比如模拟下游RPC返回失败、模拟数据库连接断开验证补偿逻辑是否真的执行了。很多团队不测补偿逻辑结果补偿代码上线半年从来没跑过一旦真出事直接连环翻车。异常安全测试不需要覆盖每个函数优先覆盖那些影响对外状态和管理稀缺资源的代码。6.4 别把noexcept当装饰贴错标签会直接terminate最后提醒一个最常见的误解把所有函数都标上noexcept并不代表更安全反而可能更危险。noexcept对编译器和运行时的语义是我承诺这个函数绝不抛异常。如果里面真抛了程序不会得到正常的异常处理流程而是直接调用terminate把进程干掉。也就是说标noexcept是拿进程生命在担保的不是装饰品。什么时候该标析构函数、swap、移动构造函数、简单的getter、纯内存操作这些确实应该尽量做到不抛并标上。什么时候别标任何可能分配内存、可能触发复杂逻辑、可能调用外部服务的地方只要不确信就别标。还有一点容易被忽略容器在扩容时如果元素的移动构造函数是noexcept容器会放心地移动元素如果不是容器会退回拷贝构造。同样的代码有没有标noexcept直接影响性能。这不是玄学是标准库实实在在的行为。7. 重构了一个老配置模块之后我对异常安全的三点体会文章最后讲一个具体经历。之前接手过一个配置加载模块旧的实现大概是这样的结构一堆裸指针成员、手动加锁、分三步初始化。平时运行没问题但只要加载的配置源里出现一个格式错误整个模块就会陷入部分配置更新、部分配置还是旧值的诡异状态。重启又好了过一段又犯。重构的时候我没做什么惊天动地的操作就是把资源全部换成RAII管理把配置更新改成copy-and-swap式的全量替换锁换成lock_guard。代码行数反而减少了。上线之后那个偶发配置错乱问题再没出现过。这次重构给我留下三个比较深的体会。第一个体会是异常安全的主战场不在catch里而在资源的放法和状态的提交方式上。资源放好了、提交方式原子了异常自己就会无害化。第二个体会是习惯的力量。后来我每写一个函数都会先在脑子里过一句如果这里抛异常哪些东西会被破坏这个答案一开始没写后来直接写在函数注释里。日积月累很多隐患在写代码的当下就被消掉了而不是等测试和线上来发现。第三个体会是别迷信某一种语言特性。C的RAII好用Java的try-with-resources好用Go的defer也好用但工具只是工具。真正的异常安全能力是你对异常发生后状态会变成什么样有没有把握。有了这个把握什么语言都能写出安全的代码没有这个把握换什么语言都会在某个深夜被线上故障叫醒。最后分享一个小技巧code review的时候先看资源再看状态最后看返回值处理。按照这个顺序扫下来的代码异常安全隐患基本藏不住。