分布式事务太难?用Go写个实战项目就懂了

架构还没想好
2025-12-22 09:01
阅读 716

大家好,我是团队里的后端培训负责人,过去三年带过三十多位应届生。最近有位实习生问我:“老师,分布式事务听起来好高深,是不是只有大厂才用得上?”我笑着告诉他:“其实你每天刷的电商App、点的外卖系统,背后都离不开它。”

我当初学的时候,也被“两阶段提交”“TCC”“Saga”这些术语吓到过。但真正动手写一遍代码后才发现:只要理解业务场景,再复杂的概念也能拆解成简单的步骤。今天这篇教程,就是专门为零基础同学准备的——我们不用数学公式,也不堆理论,而是通过一个真实的订单-库存系统,手把手带你实现分布式事务的最佳实践。


为什么你需要关心分布式事务?

想象一下这个场景:你在某宝下单买手机,点击“支付”后,系统需要同时做两件事:

  1. 扣减库存(比如从100台变成99台)
  2. 创建订单记录

如果这两个操作分别在不同的数据库(比如订单库和商品库),就可能出现问题:

  • 情况A:库存扣了,但订单创建失败 → 用户付了钱却没拿到货
  • 情况B:订单创建了,但库存没扣 → 超卖!100台手机被101个人抢到

分布式事务的核心目标:保证跨多个系统的操作要么全部成功,要么全部失败,就像在一个数据库里执行一样。这在微服务架构中极其常见。

📌 新手误区提醒:很多初学者以为“加个数据库事务就能解决”。但注意!单机事务(如MySQL的BEGIN...COMMIT)只能控制同一个数据库内的操作。一旦涉及多个数据库或服务,就必须用分布式方案。


开发环境准备:5分钟搭好Go实验场

我们用 Go语言 实现,因为它简洁高效,且在云原生领域应用广泛。以下是所需工具:

工具 版本要求 安装方式
Go 1.19+ 官网下载
Docker 20.10+ sudo apt install docker.io (Linux) 或 Docker Desktop
MySQL 8.0 通过Docker运行

步骤1:启动两个独立的MySQL实例

# 启动订单数据库
docker run -d --name order-db -e MYSQL_ROOT_PASSWORD=123456 -p 3307:3306 mysql:8.0

# 启动库存数据库
docker run -d --name stock-db -e MYSQL_ROOT_PASSWORD=123456 -p 3308:3306 mysql:8.0

步骤2:初始化数据库表

-- 连接到 order-db (端口3307)
CREATE DATABASE order_db;
USE order_db;
CREATE TABLE orders (
    id INT AUTO_INCREMENT PRIMARY KEY,
    user_id INT NOT NULL,
    product_id INT NOT NULL,
    status VARCHAR(20) DEFAULT 'pending'
);

-- 连接到 stock-db (端口3308)
CREATE DATABASE stock_db;
USE stock_db;
CREATE TABLE inventory (
    product_id INT PRIMARY KEY,
    stock INT NOT NULL
);
INSERT INTO inventory VALUES (1001, 100); -- 初始库存100

步骤3:创建Go项目

mkdir distributed-tx-demo && cd distributed-tx-demo
go mod init distributed-tx-demo
go get github.com/go-sql-driver/mysql

💡 开发心得:我建议新手先用Docker隔离数据库,避免本地环境冲突。记得在/etc/hosts添加127.0.0.1 order-db stock-db方便连接。


核心概念拆解:三种主流方案怎么选?

分布式事务没有银弹,不同场景用不同方案。我们重点讲最适合入门的两种

方案1:基于消息队列的最终一致性(推荐新手!)

原理

  1. 主服务(如订单)先完成本地事务
  2. 发送消息到MQ(如RabbitMQ/Kafka)
  3. 库存服务消费消息并执行扣减
  4. 若失败则重试,直到成功

优点:简单、高性能、天然支持异步
缺点:有短暂不一致窗口(但业务可接受)

方案2:TCC(Try-Confirm-Cancel)

原理

  • Try阶段:预留资源(如冻结库存)
  • Confirm阶段:确认使用资源
  • Cancel阶段:释放预留资源

优点:强一致性
缺点:代码复杂度高,需设计反向操作

📌 避坑指南:不要一上来就学Seata或XA协议!先掌握消息队列方案,90%的业务场景够用。


实战项目:用Go实现订单-库存的最终一致性

我们将用 内存队列模拟MQ(简化环境),重点理解流程。

第一步:定义核心结构

// models.go
type Order struct {
    ID        int
    UserID    int
    ProductID int
    Status    string
}

type Inventory struct {
    ProductID int
    Stock     int
}

第二步:实现订单服务(主服务)

// order_service.go
import (
    "database/sql"
    _ "github.com/go-sql-driver/mysql"
)

var orderDB *sql.DB

func init() {
    var err error
    orderDB, err = sql.Open("mysql", "root:123456@tcp(localhost:3307)/order_db")
    if err != nil {
        panic(err)
    }
}

// 创建订单 + 发送消息
func CreateOrder(userID, productID int) error {
    tx, err := orderDB.Begin()
    if err != nil {
        return err
    }

    // 1. 插入订单记录(状态为pending)
    _, err = tx.Exec(
        "INSERT INTO orders (user_id, product_id, status) VALUES (?, ?, ?)",
        userID, productID, "pending",
    )
    if err != nil {
        tx.Rollback()
        return err
    }

    // 2. 提交本地事务
    if err = tx.Commit(); err != nil {
        return err
    }

    // 3. 发送消息到队列(关键!)
    SendMessage(OrderMessage{
        UserID:    userID,
        ProductID: productID,
        Action:    "DECREASE_STOCK",
    })
    return nil
}

第三步:实现库存服务(消费者)

// stock_service.go
var stockDB *sql.DB

func init() {
    var err error
    stockDB, err = sql.Open("mysql", "root:123456@tcp(localhost:3308)/stock_db")
    if err != nil {
        panic(err)
    }
}

// 处理扣库存消息
func HandleStockMessage(msg OrderMessage) error {
    // 检查库存是否足够
    var currentStock int
    err := stockDB.QueryRow(
        "SELECT stock FROM inventory WHERE product_id = ?",
        msg.ProductID,
    ).Scan(&currentStock)
    if err != nil || currentStock <= 0 {
        return fmt.Errorf("insufficient stock")
    }

    // 扣减库存
    _, err = stockDB.Exec(
        "UPDATE inventory SET stock = stock - 1 WHERE product_id = ?",
        msg.ProductID,
    )
    return err
}

第四步:模拟消息队列(简化版)

// message_queue.go
type OrderMessage struct {
    UserID    int
    ProductID int
    Action    string
}

var messageQueue = make(chan OrderMessage, 100)

func SendMessage(msg OrderMessage) {
    messageQueue <- msg // 生产消息
}

// 启动消费者协程
func StartConsumer() {
    go func() {
        for msg := range messageQueue {
            // 重试机制:最多3次
            for attempt := 0; attempt < 3; attempt++ {
                if err := HandleStockMessage(msg); err == nil {
                    break // 成功则跳出
                }
                time.Sleep(time.Second * time.Duration(attempt+1)) // 退避重试
            }
        }
    }()
}

第五步:主函数串联流程

// main.go
func main() {
    // 初始化数据库连接
    init()

    // 启动消息消费者
    StartConsumer()

    // 模拟用户下单
    if err := CreateOrder(123, 1001); err != nil {
        log.Fatal("Order failed:", err)
    }

    fmt.Println("Order created! Waiting for stock update...")
    time.Sleep(2 * time.Second) // 等待异步处理
}

💡 开发心得:实际项目中,你会用RabbitMQ/Kafka替代内存队列,并加入消息持久化死信队列。但核心逻辑不变——本地事务成功后再发消息


常见问题解答:新手踩坑实录

❓ 问题1:消息发送失败怎么办?

  • 场景:订单事务提交成功,但发消息时网络中断
  • 解决方案
    1. 将消息存入本地消息表(与订单同库)
    2. 启动后台任务扫描未发送的消息并重试
    CREATE TABLE outbox (
        id BIGINT AUTO_INCREMENT,
        message JSON,
        status ENUM('pending','sent'),
        PRIMARY KEY(id)
    );
    

❓ 问题2:库存服务重复消费消息导致超扣?

  • 原因:MQ可能重复投递(At-Least-Once语义)
  • 解决方案
    • 幂等性设计:在库存表加唯一索引或版本号
    ALTER TABLE inventory ADD COLUMN version INT DEFAULT 0;
    UPDATE inventory SET stock = stock - 1, version = version + 1 
    WHERE product_id = ? AND version = ?; -- 乐观锁
    

❓ 问题3:如何监控事务最终一致性?

  • 建议
    • 记录每条消息的trace_id
    • 用Prometheus监控“pending订单数”和“库存不一致率”
    • 设置告警阈值(如pending订单>100持续5分钟)

从爬虫到分布式:我的学习路径建议

有同学问:“老师,我只会写爬虫,能学分布式吗?” 当然能!分布式系统本质是解决“协作”问题,而爬虫教会你:

  • 如何处理网络请求(HTTP/HTTPS)
  • 如何解析结构化数据(JSON/XML)
  • 如何应对异常(重试、代理轮换)

这些能力在分布式事务中同样关键。下一步建议:

📚 学习路线图

  1. 巩固基础

    • 掌握Go的并发模型(goroutine/channel)
    • 理解数据库事务ACID特性
  2. 进阶实践

    • 用RabbitMQ替换内存队列
    • 尝试TCC模式(实现冻结/解冻库存接口)
  3. 生产级方案

    • 学习Seata框架的AT模式
    • 研究Saga模式在长流程中的应用(如机票预订)

⚠️ 重要避坑原则

  • 不要过度设计:90%场景用最终一致性足够
  • 日志即证据:每个关键步骤必须记录详细日志
  • 测试要覆盖异常:手动kill进程、断网模拟故障

结语:复杂问题,简单拆解

回想起我带的第一届应届生,有个同学死磕两阶段提交一周没进展。后来我们一起用消息队列做了个简易Demo,他恍然大悟:“原来分布式事务不是魔法,就是把大问题拆成小步骤!”

技术没有捷径,但有方法。今天这个教程故意避开了复杂的理论推导,因为我知道——对初学者而言,跑通第一行代码的成就感,胜过十页PPT

现在,打开你的IDE,复制文中的代码,亲手跑一遍。遇到报错别慌,那是系统在教你成长。当你看到库存从100变成99的那一刻,分布式事务对你来说就不再是黑盒了。

最后留个小作业:尝试在库存不足时,让订单状态自动变为“failed”。欢迎在评论区贴出你的解决方案!

评论 0

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