1. 一次支付重复事故的全过程复盘
1.1 当天晚上的现象:对账报表先拉响警报
那天是周四晚上十一点多,我正在改一个定时任务,群里突然被对账组拉了个语音会议。财务说,当天有四十多笔订单在支付渠道侧被扣了两次款,金额从几十到几千都有,用户投诉已经进来了。第一反应是支付网关侧出问题,但渠道方给的响应流水显示,每一笔重复扣款的请求都是真实到达、真实成功的,而且用的都是同一个商户订单号,只是两次请求间隔了大概三到八秒。
这个信息很关键。如果只是网关回调重复投递,正常情况下我们的消费端是有幂等处理的,不应该再往外发扣款请求。可事实是,我们的支付服务确实对这些订单发起了两次扣款。也就是说,问题不在渠道,在我们的消费逻辑。于是我从日志系统拉出其中一笔订单的完整调用链,发现同一个订单的支付回调事件,被两个消费入口各处理了一次,而且这两条路径都认为自己拿到的是第一手事件,都执行了完整的入账和向网关确认的逻辑。
那晚我还不知道根因是什么,但已经隐约感觉到,这大概率不是一两个bug那么简单,而是一类“平时不炸、并发一上来就炸”的结构性问题。
1.2 三个小时的排查链路:从网关到代码库
排查的第一步是确认消息队列里到底投递了几次。当时用的是RabbitMQ,消费者声明的是手动ack,正常情况下消息消费成功后会确认掉,网络抖动或者服务重启会触发重新投递,这就是所谓的at-least-once语义。我看了队列监控,那几笔订单对应的消息确实存在重复投递记录,但之前一直有幂等保护撑着,重复投递并不可怕。
可怕的是,这次幂等保护没有拦住。继续往下翻代码,找到了处理支付回调的核心类PaymentHandler。这一看就愣住了:类里面同时存在两个方法,一个叫Process(orderId),一个叫ProcessAsync(orderId)。两个方法都做了完整的入账处理,都查订单状态,都调用支付网关确认接口,都更新下游系统状态。但它们的幂等检查逻辑并不一样,Process用的是老一套“先查状态再更新”,ProcessAsync用的是后来重构的新逻辑。
于是整个事故链浮出水面:
- 网关回调到达,消息队列因为某种原因做了重复投递;
- 两次投递间隔了几秒,分别被两个消费入口接收;
- 一个入口走了老的同步方法
Process,另一个入口走了新的异步方法ProcessAsync; - 老方法内部因为历史遗留问题,幂等查询读到的是过期数据,判断“订单未支付”;
- 新方法这时候也正在处理同一个订单,两个分支同时向渠道发起了扣款请求。
整个排查过程花了三个小时,真正定位到代码那行只花了不到三分钟。看到这两个方法名并排躺在一起的时候,我在工位上骂了句脏话——这种破事,编译器不报错,单测全绿,压测只要不把并发窗口压到极限也很难暴露。
1.3 事故根因:同步版与异步版方法并存引发的调用分叉
把那次事故的所有因素拆开看,真正最致命的是这几点,缺一个可能都炸不了:
- 同一个类里同时存在语义相近的同步版和异步版方法,而且两者行为并不等价;
- 两个方法都没有标注
[Obsolete],编译阶段没有任何提示; - 调用方分散在不同模块,A服务引用了
Process,B服务引用了ProcessAsync,代码评审时没人会觉得这两个方法“是同一个业务的两种实现”; - 老方法内部存在
.Result阻塞异步代码的僵尸逻辑,导致幂等检查结果在实际并发下不可靠。
很多人会把这类事故归因为“消息重复投递”,但我们复盘的时候明确写了结论:消息重复投递只是导火索,真正的定时炸弹是异步方法命名和调用规范长期缺失,导致同一份业务逻辑被人为分裂成了两条不可控的执行路径。这也解释了为什么事故这么难查:它不是单点问题,而是代码组织方式导致的系统性风险。
2. 为什么async/await命名冲突能藏这么久:语言与协作的双重陷阱
2.1 C#的签名规则:编译器放过了最危险的部分
先说一个很多新人都不知道的点。在C#里,方法重载的判定依据是方法名、参数个数和参数类型,返回类型不参与重载决议。这意味着,如果你想在一个类里写一个返回void的Process()和一个返回Task的Process(),编译器会直接报错,这层保护是存在的。
但问题在于,异步方法约定加后缀Async之后,情况就变了。Process()和ProcessAsync()是两个完全不同的方法名,编译器会认为它们是两个合法且互不干扰的方法。它对这两者的关系一无所知,不知道它们其实在处理同一个业务,不知道它们应该语义等价,更不知道它们不应该同时存在。
这就相当于编译器把“同步版和异步版不能混用”的检查责任整个移交给了人。而人的可靠性,在长期维护的代码库里是要打个问号的。老员工知道Process已经废弃,新来的同事看到Process没有警告标记,自然而然地认为它可以正常使用。IDE的智能提示甚至还会同时推荐这两个方法,谁排前面谁排后面完全取决于字母顺序和命名空间引用。
我在复盘文档里写过一句话:方法重载是编译器能拦住的事故,方法命名冲突是编译器根本不管的事故。后者比前者危险得多,因为它隐蔽、分散、而且常常被归因为“代码风格问题”,连代码评审都不一定关注。
2.2 异步僵尸方法:.Result/Wait埋下的定时炸弹
这次事故里最阴险的角色,是那个内部调用了.Result的同步方法。简单说,老的Process方法本来是个同步方法,后来重构的时候有人发现里面有异步数据库调用,图省事直接写了_orderRepo.CountAsync().Result。这在语法上能过编译,在单元测试环境里也基本能跑通,但一旦到了真实并发场景,它就是一个定时炸弹。
具体来说,.Result和.Wait()会阻塞当前线程去等待异步操作完成。在ASP.NET Core里,因为默认没有同步上下文(不像老的Framework版本会死锁),它确实不会死锁,但代价是线程池被浪费、线程切换变频繁、状态检查的时机也被扭曲。最坑的是,这类僵尸代码往往掩盖了真正的异步调用链,让原本应该顺序执行的幂等检查变得不可靠。
我一直跟团队强调一个类比:.Result就像安全气囊故障灯亮了你直接拿胶带贴上。灯是不亮了,车也能开,但真撞车的时候气囊不会弹出来。我们的僵尸方法就是这样,平时单测跑着全绿,订单量也扛得住,但它内部的幂等判断在并发窗口下已经不可信了。一旦有重复消息进来,它就成了第二个放行的入口。
2.3 团队协作中的“命名自由”是事故温床
这一类事故多发的团队,通常有一个共同特征:方法起名基本凭个人习惯。有人习惯动词原形,有人习惯动词加Async,还有人会用HandleTask、RunProcess、ExecutePayment这类毫无规律的名字。每个名字单独看都没毛病,但放在同一个类、同一个模块里,就会形成“语义黑洞”——你看到一个方法名,根本没法判断它到底是同步还是异步、是废弃还是保留、有没有副作用。
更要命的是,异步方法在代码库里其实是有“传染性”的。一个方法变成异步,调用它的方法基本也得变异步,否则就要用.Result去堵。如果团队里没有统一的强制约定,这个传染过程就会产生大量的僵尸方法和重复实现。到后面,连写代码的人自己都记不清哪个方法是当前的正确入口。
我们那套支付处理逻辑,之所以会出现Process和ProcessAsync同时保留一年半的情况,正是因为两个方法在不同时间被不同人添加,各自有各自的调用方,代码评审的时候大家都只管自己那部分,没有人有权限说“这两个方法只能留一个”。这种治理层面的缺失,不写进规范里,早晚还会再炸一次。
3. 通宵焊出的五条防冲突规范,逐条拆解
3.1 规范一:Async后缀是承诺,不是装饰
第一条规范,也是最核心的一条:代码里所有真正执行异步I/O操作的方法,必须以动词原形+Async结尾;不做异步I/O、内部没有await的普通方法,禁止以Async结尾;任何拼写变体(Aysnc、Asynchronous、Asyc等)一律视为违规。
为什么要卡死后缀?因为Async后缀在团队协作里应该是一个严肃的“异步承诺”。看到这个方法,调用方就知道:第一,它可以被await;第二,它不会主动阻塞线程;第三,它在调用链里会向上传递异步状态。如果这个约定被破坏——比如一个方法叫ProcessAsync但内部没有真正的异步操作,或者反过来,内部有异步操作但名字不带Async——那么调用方对方法行为的判断就不可靠了。
另外还需要两个配套动作:
- 同类业务行为只允许保留异步版本。也就是说,不可以在同一个类里同时存在
Process和ProcessAsync两个方法,处理同一业务的时候二者只能留其一。 - 确实需要对旧调用方做兼容的,必须把旧方法标上
[Obsolete(..., error: true)],让编译直接失败,而不是靠人肉眼去辨别。
下表是我们贴在规范文档里的正反例:
| 方法签名 | 判定 | 说明 |
|---|---|---|
Task<PaymentResult> ProcessAsync(string orderId) | 合规 | 真正的异步方法 |
void Process(string orderId) | 合规,但如果同类有ProcessAsync则违规 | 不允许新旧并存 |
Task<PaymentResult> Process(string orderId) | 违规 | 异步方法没加Async后缀 |
void ProcessAsync(string orderId) | 违规 | 非异步方法不能带Async后缀 |
PaymentResult ProcessAsyn(string orderId) | 违规 | 拼写变体同样禁止 |
3.2 规范二:全库封杀async void、.Result和.Wait
第二条规范,属于底线级的封禁:业务代码中禁止出现async void(仅UI事件处理器例外)、禁止使用.Result、禁止使用.Wait()、禁止使用.GetAwaiter().GetResult();禁止用Task.Run把一个异步方法包装成同步调用来逃避await。
这些写法的共同问题是:它们把一个异步方法“伪装”成同步行为,让调用方误以为方法已经执行完毕、状态已经落定,从而绕过异步流程的控制。在我们的支付事故里,僵尸方法正是因为.Result的存在,才在并发窗口下做出了错误的幂等判断。
实操上,这一条是最容易自动化拦截的。我们直接在CI里加了一步扫描,用ripgrep就能扫出来:
rg -n "\.Result\b|\.Wait\(\)|\.GetAwaiter\(\)\.GetResult\(\)|async\s+void" \ --glob "*.cs" \ --glob "!**/obj/**" \ --glob "!**/bin/**"扫描结果只要不为空,PR合入直接失败。这条封禁刚推的时候,团队的阻力其实不小,因为存量代码里这种写法太多了,平均每个模块能扫出十几处。但从长期看,这笔账非常划算。我后来算过,被封禁的每一种写法,几乎都能对应到网上真实发生过的一次线上事故。与其赌它不出问题,不如直接从编译和CI层面封死。
3.3 规范三:同步与异步API必须物理隔离
第三条规范解决的是“同名业务分裂”的问题:一个业务模块内部,不允许同时暴露同步版和异步版API,甚至不允许一个类里同时出现核心业务相关的同步和异步方法。
这里说的“物理隔离”,是指在代码组织层面就要做到:
- 如果模块业务明确走异步(比如涉及I/O、消息、数据库),所有对外方法一律返回
Task或Task<T>,服务层不再提供同步包装; - 历史遗留的同步方法如果暂时无法删除,必须单独放到
Legacy目录或专门的兼容类中,不允许和新方法写在同一个类文件里; - 对外暴露的接口契约里,方法名、参数、返回值必须能够直接表达异步语义,不允许出现“看内部实现才知道是同步还是异步”的情况。
为什么要做到物理隔离?因为光靠命名约定,靠人自觉,在长期维护中一定会失效。物理隔离的目的,是让代码结构本身成为约束:新方法想调用旧的同步逻辑,得跨越目录、跨越类边界、还可能需要处理一堆标记为废弃的类型,这样开发者就会下意识地去找异步入口,而不是图省事直接复用旧的同步方法。
这一条执行的时候,我们做了不少存量改造。最典型的一个例子,是把一个到处被引用的OrderHelper类拆成了OrderQueryService和OrderCommandService,同步逻辑全部挪到LegacyOrderSdk里并打上废弃标记。改造过程很痛苦,但它让代码库的结构终于能“看得懂”了。
3.4 规范四:关键路径幂等锁兜底
第四条规范,是给临界路径上的所有处理逻辑加一道独立的保险:支付、转账、订单状态变更等关键入口,必须有幂等键和分布式锁的双重保护,且锁的持有时间必须覆盖“查-判-写”的全过程。
先说明白一个原则:消息队列的at-least-once语义决定了重复投递是正常的、必然的。我们不能指望“消息不会重复”,只能保证“重复消息产生的副作用等于零”。这就意味着,幂等不能是锦上添花,而必须是强制兜底。
我们在支付回调入口实现的逻辑大概是这样的:
public async Task<PaymentResult> HandleNotifyAsync(string orderId, string channel) { var lockKey = $"pay:idempotent:{channel}:{orderId}"; await using var lockHandle = await _distributedLock.AcquireAsync( lockKey, TimeSpan.FromSeconds(10)); if (lockHandle == null) { return PaymentResult.Duplicate; } var exists = await _paymentRecordRepo.ExistsAsync( x => x.OrderId == orderId && x.Channel == channel && x.NotifyType == NotifyType.Charge); if (exists) { return PaymentResult.Duplicate; } // 入账、更新订单状态、扣减库存、发送确认,全部在一个事务内提交 await _unitOfWork.ExecuteAsync(async () => { await _paymentRecordRepo.AddAsync(...); await _orderRepo.UpdatePaidAsync(orderId, ...); await _outboxRepo.AddAsync(...); }); return PaymentResult.Success; }这个实现里的关键点,也是踩坑最多的地方,我列一下:
- 幂等键的粒度要尽量细。我们用的是“渠道+订单号+事件类型”,不能只到订单号,否则同一订单不同事件会互相误伤;
- 锁必须用分布式锁,进程内锁在多个实例部署时等于没有锁;
- 锁的持有时间要覆盖查询、判断、写入全过程。锁如果只覆盖“判断是否有记录”,释放之后另一个消费者依然可以在写入完成前闯进来,造成双写;
- 事务提交完成后,分布式锁再释放。如果先释放锁再提交事务,中间会有时间窗口让第二个消费者拿到锁并读到旧状态。
这条规范不是在和命名冲突直接对抗,而是在命名规范失效的时候兜底。哪怕某天命名又乱了,消息又重复了,幂等锁依然能保证结果正确。这也是我坚持把它写进“防冲突规范”而不是单独列为“稳定性措施”的原因。
3.5 规范五:CI里自动扫描,把规范焊死
第五条规范是前四条的执行保障:所有代码规范性检查必须自动化,禁止依赖人工评审来检查异步命名和僵尸代码。
人做不到的事,机器做得到。具体的落地我有三个层次:
第一层,是引入微软官方的异步分析器Microsoft.VisualStudio.Threading.Analyzers,它内置了VSTHRD002、VSTHRD104、VSTHRD110等规则,能自动诊断.Result、async void、在非异步方法里等待异步任务等常见问题。我们在Directory.Build.props里统一开启,把关键规则提升为error:
<PropertyGroup> <AnalysisLevel>latest</AnalysisLevel> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> </PropertyGroup> <ItemGroup> <PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.*" PrivateAssets="all" /> </ItemGroup>第二层,是自定义一个简单的Roslyn Analyzer,检查类里是否存在“同一业务动词同时有裸动词方法和动词+Async方法”的情况。这个分析器逻辑不复杂,核心就是遍历一个类的所有方法,把方法名归一化(去掉Async、AsTask等后缀),然后统计是否有重复。发现重复就输出Diagnostic,级别设为Error。
第三层,是CI流水线里的正则扫描。因为自定义Analyzer的开发和NuGet包发布需要时间,我们第一天就先在GitLab CI里跑了一条最朴素的ripgrep命令,把.Result、.Wait()、async void、可疑方法名全部扫出来。扫描脚本的输出作为MR的检查项,硬阻断。这条命令虽然粗糙,但当天晚上就生效了,比Analyzer的落地速度快得多。
3.6 五条规范汇总表
写到这里,把五条规范放到一张表里,方便直接抄作业:
| 编号 | 规范名称 | 核心要求 | 拦截方式 |
|---|---|---|---|
| 1 | Async后缀是承诺 | 异步方法必须动词+Async;禁止拼写变体;同类业务禁止新旧并存 | Code Review + 自定义Analyzer |
| 2 | 异步监狱封禁 | 禁止async void、.Result、.Wait()、GetAwaiter().GetResult() | CI正则扫描 + 官方Analyzer |
| 3 | 同步异步物理隔离 | 模块内不允许同时暴露同步与异步API;废弃方法独立目录并标记 | Code Review + 架构约束 |
| 4 | 幂等锁兜底 | 关键路径必须有幂等键+分布式锁,锁覆盖查判写全过程 | 代码模板 + 架构评审 |
| 5 | CI自动化审查 | 所有规范检查自动化,不靠人工 | 流水线硬阻断 |
4. 规范落地:评审、工具与存量迁移
4.1 异步专项评审清单
规范文档写了一堆,落到代码评审里必须能直接执行。我们把异步相关的检查做成了一份专项清单,评审人的职责不是“看一眼”,而是逐项打勾。
这份清单大概长这样:
- [ ] 方法名是否遵循“动词+Async”命名,是否存在拼写变体;
- [ ] 是否出现了
async void,如果出现,有没有经过专项豁免; - [ ] 是否存在
.Result、.Wait()、.GetAwaiter().GetResult(); - [ ] 类内是否存在语义相同的同步版和异步版方法并存;
- [ ] 异步方法内部有没有不必要的
Task.Run包装,或把CPU密集型操作当成异步I/O; - [ ] 调用链路上是否全部使用await传递,有没有异步方法被fire-and-forget;
- [ ] 支付、订单、库存等关键模块,是否强制要求幂等键+分布式锁模板;
- [ ] 如果方法被标记为
[Obsolete],使用者是否已经清理。
我一般在评审时看完业务逻辑之后,会专门再扫一眼方法签名区域。凡是看到一个类里出现Xxx()和XxxAsync()并存的,不管业务是否健康,先给个“这块需要重构”的评论。因为这种并存本身就是一种技术债信号,它说明我们还没有把历史包袱真正卸掉。
4.2 工具扫描与MR阻断的细节
说点工具落地时的实际教训。很多团队其实装了Analyzer,但由于没有把警告提升为错误,它们在CI里根本不生效。开发者本地能看到波浪线,但合代码的时候没人管,等于白装。
我们调整后的做法是:在Directory.Build.props里统一设置TreatWarningsAsErrors,但这又带来一个问题——存量代码的警告会被当成错误,导致整个解决方案编译不过。所以第二步是允许通过NoWarn或WarningsNotAsErrors列表临时压掉一批存量警告,同时要求新代码的警告数量必须为零。
这个“存量压掉、新增为零”的策略很关键。它避免了“全量整改”的大工程,又保证了新代码质量。我们当时给团队的指标很简单:新提交的PR里,异步相关的Analyzer警告数必须为0,本地带警告提交,CI直接判红。
对于自定义Analyzer,我也提醒一句:不要一开始就想着写一个很复杂的分析器去检查所有规则,那会拖很久。先把“同名动词+N多实现”这一个最痛的规则做出来,配合使用官方Analyzer和正则扫描,覆盖率已经足够高。后续有必要再逐步迭代。
4.3 存量代码迁移的步骤与顺序
规范落地最难的不是写文档,而是处理存量代码。我们采取的顺序是:
第一步,全量扫描并登记僵尸清单。用rg把全仓库的.Result、async void、语义重复的方法名全部扫出来,建一个async-refactoring.md的事项清单,按模块归类。别急着改,先知道敌人有多少。
第二步,按风险等级排序。先改支付、订单、账户这类涉及资金和状态的模块,再改报表、日志这类低风险模块。支付模块全部优先完成幂等锁模板化改造,哪怕方法命名还没统一,至少保证重复消息不再造成资金损失。
第三步,按模块做异步化收敛。每个模块指定一个负责人,把所有语义重复的同步/异步方法合并成一个标准异步方法,并同步更新所有调用方。老方法一律标记为[Obsolete(..., error: true)],确保旧调用点编译不过。
第四步,删除。等调用方全部切走之后,清理掉僵尸方法。这一步我们延期了大概两个月才彻底做完,但过程中已经通过编译错误保证了不会再有新增调用方去使用废弃逻辑。
整个过程最大的感受是:不要试图在同一个PR里既做功能开发又做异步清理。异步重构必须独立排期、独立测试、独立上线,否则出了问题你根本不知道是哪一步引起的,排查复杂度会翻好几倍。
4.4 团队协作习惯的最后一块拼图
规范是死的,团队是活的。最后这一点,是那次事故之后我更深的理解:一套规范能不能活下来,取决于它在日常协作中被执行的痛苦程度。
如果规范的执行靠人工记忆,靠评审人心情,靠老员工传帮带,那它在第三个月就开始腐化。只有把规范变成编译错误、变成CI红灯、变成模板代码、变成架构约束,它才能在团队人员流动、项目工期紧张的情况下,依然稳定运转。
我们这个团队现在的状态是:新代码想写出.Result会直接被CI拦掉,想新增一个裸动词方法和已有的XxxAsync并存会触发自定义Analyzer的error,支付回调入口只有一套统一模板可以套用。看起来好像规矩很多,但实际上开发效率反而更高了——因为每个人都不用再去猜这个方法到底是什么语义,看名字就知道该怎么用。
那次通宵换来的五条规范,名字虽然起得中二,但每一条背后都是真金白银的用户投诉和一夜没合眼的教训。如果你也在维护一个长期演进的.NET服务,强烈建议先把你代码库里的异步方法扫一遍,用不上五条规范,哪怕先做掉第一条和第四条,就已经能规避掉一大半重复消息导致的事故了。