分布式事务解决方案:最佳实践

数据清洗工
2025-12-15 22:19
阅读 209

大家好,我是老K,目前在百度干了两年算法工程师,主要搞搜索相关。听起来高大上吧?其实日常大部分时间都在和数据一致性、延迟、召回率这些“魔鬼细节”死磕。坐标杭州,平时除了写Go服务、调参、看日志,就是刷LeetCode准备跳槽(别问,问就是阿里网易机会多,但面试造火箭,入职拧螺丝)。

最近半年,因为业务要从单体架构往微服务拆,我们组被迫从“算法舒适区”一脚踩进了后端深水区——分布式事务。本来以为这玩意儿就是教科书里的概念,直到上周五晚上10点,测试群里突然@我:“线上订单状态不一致,用户付了钱没发货!”那一刻,我盯着VSCode里堆满插件的界面,手里的冰美式差点洒在机械键盘上——原来理论和生产环境之间,隔着一个太平洋

今天这篇,就结合我在百度这两年踩过的坑、熬过的夜、掉过的头发,聊聊分布式事务在Go语言下的实战经验。不讲八股文,只说人话,顺便自曝几个线上事故,希望能帮你少走点弯路。


为啥分布式事务成了“必修课”?

先说背景。我们组去年Q3接了个需求:把原来耦合在搜索主链路里的“用户行为打点 + 积分发放”模块拆成独立微服务。产品经理画的架构图贼清爽,三个服务:search-servicelog-servicepoint-service,通过gRPC互相调用。

结果上线第一天,运维就拉警报:积分漏发率高达5%。一查日志,发现log-service写库成功,但调用point-service时网络超时,事务没回滚。用户行为记录了,积分没到账——典型的数据不一致

这时候我才意识到:单机事务(ACID)在分布式世界里,就像诺基亚在5G时代——根本不够用


方案选型:不是所有轮子都值得造

面对分布式事务,业内主流方案就那几个:2PC、TCC、Saga、本地消息表、Seata。作为一个被deadline追着跑的打工人,我的第一反应是:“有没有现成的轮子能直接抄?”

于是翻遍GitHub,发现Go生态里比较成熟的有:

  • DTM(国产之光,作者是滴滴前员工)
  • Seata-Golang(阿里开源,但文档像天书)
  • 自研(别闹,除非你想连续加班一个月)

我们最终选了 DTM,原因很现实:

  1. Go原生支持,不用引入Java依赖
  2. 文档清晰,示例代码能直接跑起来
  3. 支持多种模式(TCC、Saga、XA),灵活切换

💡 安全意识提醒:别为了“技术先进”硬上2PC!2PC性能差、锁粒度大,在高并发场景下极易成为瓶颈。我们早期试过Seata的AT模式,结果在双11压测时数据库连接池直接被打爆,DBA差点提刀来办公室找我。


实战:用TCC模式搞定积分发放

回到我们的积分场景。核心诉求就两点:

  • 用户行为必须记录(不能丢)
  • 积分必须准确发放(不能多也不能少)

我们采用了 TCC(Try-Confirm-Cancel) 模式。简单说就是三步走:

  1. Try:预留资源(比如冻结积分额度)
  2. Confirm:真正执行(扣减冻结额度,加到用户账户)
  3. Cancel:回滚(释放冻结额度)

关键代码(Go + DTM)

首先定义服务接口:

// point-service/main.go
type PointService struct{}

// Try阶段:冻结积分
func (s *PointService) TryFreeze(ctx context.Context, req *pb.FreezeRequest) (*empty.Empty, error) {
    // 检查用户余额是否足够(伪代码)
    if !checkBalance(req.UserId, req.Amount) {
        return nil, status.Error(codes.FailedPrecondition, "余额不足")
    }
    // 冻结额度,写入临时表
    err := db.Exec("INSERT INTO frozen_points (user_id, amount) VALUES (?, ?)", req.UserId, reqAmount)
    return &empty.Empty{}, err
}

// Confirm阶段:真正扣款
func (s *PointService) ConfirmDeduct(ctx context.Context, req *pb.ConfirmRequest) (*empty.Empty, error) {
    // 从冻结表移除,并更新主账户
    tx := db.Begin()
    tx.Exec("DELETE FROM frozen_points WHERE user_id = ?", req.UserId)
    tx.Exec("UPDATE user_points SET balance = balance - ? WHERE user_id = ?", req.Amount, req.UserId)
    return &empty.Empty{}, tx.Commit()
}

// Cancel阶段:解冻
func (s *PointService) CancelFreeze(ctx context.Context, req *pb.CancelRequest) (*empty.Empty, error) {
    db.Exec("DELETE FROM frozen_points WHERE user_id = ?", req.UserId)
    return &empty.Empty{}, nil
}

然后在 search-service 中发起全局事务:

// search-service/handler.go
func handleUserAction(userId string, action string) error {
    gid := dtmcli.MustGenGid() // 全局事务ID
    req := &pb.ActionRequest{UserId: userId, Action: action}

    // 注册TCC事务
    err := dtmimp.DtmClient().CallTcc(gid, "http://point-service/tcc", 
        map[string]interface{}{
            "try":    "/point/try",
            "confirm": "/point/confirm",
            "cancel":  "/point/cancel",
        }, req)
    
    if err != nil {
        log.Errorf("TCC failed: %v", err)
        return err
    }
    return nil
}

⚠️ 血泪教训Try阶段必须幂等! 我们第一次上线时,没做幂等校验,结果网络抖动导致重复Try,用户积分被冻结了两次。用户投诉到客服,我被leader叫去“喝茶”半小时。


安全与可靠性:别让事务变成定时炸弹

分布式事务最大的风险不是功能不对,而是静默失败——系统看起来正常,数据却悄悄烂掉。为此我们加了几道保险:

1. 事务日志审计

所有DTM事务都会写入独立日志表,字段包括:

  • gid(全局事务ID)
  • status(succeed/failed/pending)
  • create_time, update_time

每天凌晨跑个脚本,扫描pending超过5分钟的事务,自动告警。宁可误报,不可漏报

2. 接口签名防篡改

微服务间调用全部加上HMAC-SHA256签名,防止中间人攻击或参数被恶意修改。虽然内网调用,但安全无小事——去年隔壁组就因为没签名校验,被实习生用Postman刷了10万积分。

3. 熔断降级

point-service不可用时,search-service会降级为“异步补偿”:先记录行为日志,稍后由定时任务重试。保证主链路不崩,这才是搜索系统的底线。


性能对比:TCC vs Saga vs 本地消息表

我们做了压测(4核8G,MySQL 5.7,1000 QPS),结果如下:

方案 平均延迟(ms) 最大吞吐(ops/s) 数据一致性 开发复杂度
2PC 120 800 强一致
TCC 45 2200 最终一致
Saga 38 2500 最终一致 中高
本地消息表 30 2800 最终一致

结论很清晰:

  • 强一致场景极少,除非金融交易
  • TCC在可控性和性能之间取得平衡
  • 本地消息表虽快,但需要额外维护消息表,且无法处理“空回滚”问题

运维经验:线上事故复盘

去年双11,我们遇到一次经典事故:

现象:大量订单状态卡在“支付成功但未发货”
根因order-service在Confirm阶段写DB超时,DTM标记事务失败,但没触发Cancel(因为Cancel接口本身也有Bug)
后果:库存被占用,用户无法重新下单

复盘后我们做了三件事

  1. 所有Cancel/Try接口加超时控制(默认3s)
  2. DTM开启事务自动重试(最多3次)
  3. 关键服务部署两个实例,避免单点故障

现在回想起来,那晚我和运维兄弟蹲在机房吃泡面debug的场景还历历在目。分布式系统里,没有银弹,只有不断打补丁的勇气


给Go开发者的建议

如果你也在用Go搞微服务,记住这几点:

  • 别迷信框架:DTM再好,也得理解底层原理。我花了一周读完DTM源码,才敢上生产。
  • 日志要详细:每个事务步骤都要打gid,方便追踪。我们用zap+dtmid字段,排查效率提升80%。
  • 测试要狠:用chaos-mesh模拟网络分区、服务宕机,提前暴露问题。
  • 监控要全:Prometheus + Grafana 监控事务成功率、延迟、重试次数。

最后:技术之外

写这篇文章时,已经是凌晨1点。窗外杭州的雨还没停,VSCode右下角显示“Go 1.22.3”,插件栏里Go Test ExplorerError Lens还在闪。

说实话,搞分布式事务挺痛苦的。它不像算法那样有优雅的数学解,更多是脏活累活:调超时、对账、写补偿。但每当看到用户反馈“积分终于到账了”,那种成就感,比调出一个完美的Recall@10还爽。

如果你也在折腾分布式事务,欢迎留言交流(或者一起吐槽产品经理)。毕竟,在这个不确定的世界里,至少我们可以努力让数据保持一致

共勉。

评论 0

最热最新
暂无评论
匿名用户Lv.1
0
影响力
0
文章
0
粉丝