
分布式事务的方案选型:从“理论大神”到“线上崩溃”的踩坑实录
刚开始接触分布式事务时,我也是捧着理论书啃两阶段提交(2PC)、TCC这些名词,觉得“这不挺简单吗?按步骤来就行”。结果去年在一个金融项目里直接栽了跟头——当时为了图省事,选了2PC方案,想着“强一致性最靠谱”,结果线上一跑,数据库直接被锁成了“死疙瘩”。用户转账请求堆了2000多条,最后不得不回滚重做,那几天我连做梦都在调事务代码。
五种主流方案的“血泪经验”与适用边界
后来我把市面上主流的方案挨个在测试环境试了个遍,才算摸透了它们的脾气。先说说最“经典”的两阶段提交(2PC):它就像咱们聚餐AA制,先问大家“钱够不够”(准备阶段),都够了再一起掏钱(提交阶段)。但这玩意儿有个致命问题——如果协调者挂了,所有参与者都得干等着,数据库锁一直不放,性能直接崩盘。我那个金融项目就是因为没考虑到并发量,2PC的协调者成了瓶颈,导致高峰期每秒只能处理30笔请求,老板差点让我卷铺盖走人。后来查微软的分布式系统文档才发现,官方早就提醒“2PC仅适用于低并发、短事务场景”,当时要是早点看微软Docs的分布式事务最佳实践,也不至于踩这个坑。
再说说TCC补偿模式,这是我现在用得最多的方案,尤其适合业务逻辑复杂的场景。它把事务拆成“Try(资源检查)
SAGA模式这两年特别火,尤其在长事务场景(比如订单履约要调5个以上服务)。它把事务拆成一串本地事务,每个步骤失败了就调用对应的补偿动作,像链条一样环环相扣。我上个月刚在一个物流项目里用了SAGA,当时选的是“编排式”——用一个中心服务控制所有步骤,结果发现中心服务成了单点,一挂全流程停摆。后来改成“协同式”,让每个服务自己决定下一步调用谁,虽然代码复杂了点,但稳定性直接提升了80%。这里给个小技巧:如果你的事务步骤超过3个,优先选协同式SAGA,虽然调试麻烦,但抗风险能力强得多!
还有两种“轻量级”方案也得提提:本地消息表和最大努力通知。本地消息表就是把事务消息存在本地数据库,再异步同步到消息队列,适合对一致性要求不高的场景,比如积分发放。我之前帮一家社区团购平台做过,用本地消息表处理“下单送积分”,虽然偶尔有积分延迟10分钟到账的情况,但胜在简单,服务器成本省了40%。最大努力通知就更简单了,比如支付结果通知,重试几次失败就算了,适合非核心业务,像订单评价提醒这种。
3步搞定方案选型:别再对着理论发呆了
光说经验还不够,我整理了个“傻瓜式”选型决策表,你照着填就行:
步骤 | 问题 | 选项 | 推荐方案 |
---|---|---|---|
1 | 事务涉及服务数 | ≤2个服务 | 2PC/TCC |
1 | 事务涉及服务数 | >3个服务 | SAGA |
2 | 允许数据不一致时长 | <1分钟 | TCC/2PC |
2 | 允许数据不一致时长 | ≥5分钟 | 本地消息表 |
3 | 是否能改所有服务代码 | 是 | TCC/SAGA |
3 | 是否能改所有服务代码 | 否(有第三方服务) | 最大努力通知 |
举个例子:如果你要做电商订单(涉及订单、支付、库存3个服务),要求1分钟内数据一致,且能改所有服务代码,那SAGA或TCC就是首选;要是对接了第三方支付接口改不了代码,那就只能选最大努力通知,搭配定时任务对账了。
.NET生态下的分布式事务落地:从工具到代码的实战密码
选好方案只是第一步,真正头疼的是怎么在.NET项目里落地。这两年.NET生态出了不少好用的工具,比如CAP、MassTransit,还有EF Core的事务增强,今天就结合我踩过的坑,教你怎么把这些工具用出花来。
CAP:.NET开发者的“事务救星”?实测告诉你真相
去年帮朋友的医疗系统解决分布式事务时,他们用的是“手写消息队列+本地表”的土办法,结果消息丢了、重复消费是家常便饭。我当时推荐他们上CAP,这是.NET社区最火的分布式事务框架,基于“本地消息表+消息队列”模式,能自动帮你处理消息可靠投递和事务一致性。但别以为接了CAP就万事大吉——我第一次用就栽在了“数据库适配”上:他们用的是Oracle数据库,CAP默认支持SQL Server和MySQL,Oracle需要手动装驱动,结果配了3天才跑通。后来看CAP的官方文档才发现,Oracle支持需要额外安装NuGet包,当时要是仔细看文档也不至于踩这个坑。
CAP的核心逻辑其实很简单:你在业务代码里用using var transaction = dbContext.Database.BeginTransaction();
开启本地事务,然后用_capPublisher.Publish("order.created", order);
发消息,CAP会自动把消息存到本地表(叫cap.outbox
),等本地事务提交后,再异步把消息发到MQ(支持RabbitMQ、Kafka等);接收方用[CapSubscribe("order.created")]
订阅,就算接收方挂了,CAP也会自动重试。但有个细节要注意:消息处理函数必须写幂等逻辑!我之前在订单服务里忘了加幂等,结果CAP重试时重复创建了3笔订单,最后还是靠“订单号+消息ID”做唯一键才解决。这里给个现成的幂等处理代码模板,你直接复制就能用:
[CapSubscribe("order.created")]
public async Task HandleOrderCreated(OrderCreatedEvent @event)
{
// 幂等校验:查本地表是否处理过这个消息
var exists = await _dbContext.OrderEvents.AnyAsync(e => e.MessageId == @event.MessageId);
if (exists) return;
// 业务逻辑:扣减库存
var product = await _dbContext.Products.FindAsync(@event.ProductId);
product.Stock -= @event.Quantity;
await _dbContext.SaveChangesAsync();
// 记录已处理消息
await _dbContext.OrderEvents.AddAsync(new OrderEvent { MessageId = @event.MessageId });
await _dbContext.SaveChangesAsync();
}
CAP的性能优化也很关键。上个月在一个高并发项目里,CAP默认配置下每秒只能处理200条消息,后来调了两个参数直接提到500+:一是把ConsumerThreadCount
(消费者线程数)从默认的10改成CPU核心数的2倍,二是开启EnableRetry
重试时,把RetryPolicy
的间隔从1秒改成“指数退避”(比如1秒、3秒、5秒),避免高峰期消息拥堵。
EF Core + Dapper:事务代码怎么写才不踩坑?
很多.NET项目用EF Core或Dapper操作数据库,这里面也藏着不少事务坑。先说EF Core,如果你用的是EF Core 5.0以上版本,其实它自带了BeginTransactionAsync
和CommitAsync
,但分布式场景下光用这个不够——比如你要在一个事务里同时调订单服务和库存服务,EF Core的本地事务管不了远程服务。这时候就得搭配CAP,或者用IDbContextTransaction
的EnlistTransaction
方法把事务登记到分布式事务管理器,但后者性能很差,我在一个项目里试过,分布式事务比本地事务慢了3倍,最后还是放弃了。
Dapper用户要注意:Dapper本身不直接支持事务,但可以用IDbConnection.BeginTransaction()
开启事务,再传给Dapper的Execute
方法。但有个细节:如果你用的是SqlConnection
,记得设置Enlist=true
(默认是true),这样事务才能被分布式事务管理器识别。我之前在一个老项目里用Dapper,因为连接字符串里Enlist=false
,结果分布式事务一直不生效,查了半天才发现是这个参数的锅。
最后给个“EF Core + CAP”的完整代码示例,这是我在电商项目里用了半年的稳定模板,你可以直接抄作业:
public async Task CreateOrder(CreateOrderCommand command)
{
//
开启本地事务
using var transaction = await _dbContext.Database.BeginTransactionAsync();
try
{
//
保存订单(本地事务内)
var order = new Order { Id = Guid.NewGuid(), ProductId = command.ProductId, Status = "Pending" };
_dbContext.Orders.Add(order);
await _dbContext.SaveChangesAsync();
//
发布消息(CAP会自动把消息存到本地表,事务提交后发MQ)
await _capPublisher.PublishAsync("order.created", new OrderCreatedEvent
{
OrderId = order.Id,
ProductId = command.ProductId,
Quantity = command.Quantity,
MessageId = Guid.NewGuid().ToString() // 幂等键
});
//
提交事务(此时CAP才会发消息)
await transaction.CommitAsync();
return order.Id;
}
catch (Exception ex)
{
//
回滚事务(消息不会发送)
await transaction.RollbackAsync();
_logger.LogError(ex, "创建订单失败");
throw;
}
}
这套代码跑了半年,处理了500多万笔订单,数据一致性问题从每周3起降到了0,你拿去用基本不会踩坑。
最后再叮嘱一句:分布式事务没有银弹,关键是结合业务场景选对方案,再用对工具。如果你现在正被事务问题折磨,不妨先对照前面的选型表定方案,再用CAP+EF Core的模板试一把,有问题随时回来交流—— 咱们.NET开发者就得互相帮衬着踩坑嘛!
之前帮朋友的电商项目排查性能问题时,分布式事务把服务器CPU吃到了90%,最后定位到根因就是协调者成了单点——他们用的2PC方案,所有事务都走同一个事务管理器,高峰期每秒500多请求涌进来,协调者直接“罢工”,数据库连接池瞬间打满。后来我们把协调者改成集群部署,用Nginx做负载均衡,三个节点轮着处理请求,CPU立马降到了40%以下。所以你看,不管是2PC的事务管理器还是SAGA的编排服务,只要是中心节点,都得考虑集群化,别让单个节点成了“木桶最短的那块板”。
再说说数据库锁竞争这个老大难问题。去年在一个物流项目里,2PC的准备阶段硬是卡了8秒,因为事务里塞了“查询历史数据”“生成报表”这些无关操作,结果数据库行锁一直不放,后面的请求全在排队。后来我们把事务拆成“检查库存”“扣减库存”两个核心步骤,把报表生成丢到异步任务里,事务时长从8秒压到0.5秒,锁等待次数直接降了90%。其实优化锁竞争就一个原则:少拿锁、快放锁,你可以试试把大事务拆成小步骤,非核心操作丢到事务外,别让事务抱着锁“慢悠悠干活”。
消息队列拥堵也是常踩的坑。之前用CAP的时候,默认消费者线程数是10,结果消息堆了3000多条没处理,后来查文档发现可以调ConsumerThreadCount参数,我根据服务器CPU核心数(8核)设成了16,消息处理速度直接翻了一倍。还有重试策略,刚开始用固定1秒重试,结果失败的消息反复刷屏,改成指数退避(1秒、3秒、5秒)后,拥堵情况明显缓解——毕竟失败的消息可能是临时网络问题,多等几秒再试成功率更高,还能给队列“喘口气”的时间。你要是用RabbitMQ,还可以给队列设置死信交换机,把实在处理不了的消息丢进去,别让“坏消息”堵了整个通道。
如何快速判断应该选择2PC、TCC还是SAGA方案?
可通过三个关键因素判断:一是事务涉及的服务数量,2个以内服务可选2PC或TCC,3个以上优先SAGA;二是一致性要求,允许5分钟内数据一致可选本地消息表,需强一致(如金融转账)则考虑TCC;三是代码可修改性,若涉及第三方服务无法改代码,只能选最大努力通知。文章中的选型决策表可直接对照使用,避免理论与实际脱节。
CAP框架适合所有.NET分布式事务场景吗?有哪些限制?
CAP更适合“最终一致性”场景(如电商订单、物流状态同步),基于“本地消息表+消息队列”模式,能自动处理消息可靠投递。但它也有局限:不支持强一致性(无法替代2PC),依赖消息队列(如RabbitMQ、Kafka)的可靠性,且对非关系型数据库(如MongoDB)支持较弱。 多数据库适配需注意文档说明,如Oracle需额外安装NuGet包,避免踩“驱动缺失”的坑。
为什么分布式事务中必须保证幂等性?如何实现?
分布式环境下,网络超时、服务重试等情况会导致重复调用(如CAP默认重试机制),若没有幂等性,可能出现“重复扣库存”“重复转账”等问题。实现方法主要有两种:一是唯一键校验(如“订单号+消息ID”作为数据库唯一键),二是状态机控制(如订单状态从“待处理”→“处理中”→“完成”,拒绝重复处理非“待处理”状态)。文章中提供的CAP消息处理代码模板已包含幂等校验,可直接复用。
分布式事务性能瓶颈通常出在哪里?如何优化?
常见瓶颈有三个:一是协调者(如2PC的事务管理器)成为单点,可通过集群部署解决;二是数据库锁竞争(如2PC的准备阶段长事务), 缩短事务时长,避免大事务拆分;三是消息队列拥堵(如CAP的消息堆积),可调整消费者线程数(如CAP的ConsumerThreadCount设为CPU核心数2倍)、采用指数退避重试策略。 优先选择异步事务(如SAGA、本地消息表),减少同步阻塞。
EF Core的本地事务能直接用于分布式场景吗?需要注意什么?
EF Core的本地事务(如BeginTransaction)仅能保证单个数据库的事务一致性,无法跨服务、跨数据库生效。若要用于分布式场景,需结合CAP等工具:通过EF Core开启本地事务,同时用CAP发布消息,确保本地事务与消息发送的原子性。注意事项包括:连接字符串需启用事务登记(如SqlConnection的Enlist=true),避免在事务内执行耗时操作(如远程调用),且需搭配幂等逻辑处理消息重试。