分布式事务太难?文科生用Go带你一步步搞定

Kafka信使
2025-12-28 10:59
阅读 266

大家好,我是一个从历史系转码成功的后端工程师。当初第一次听到“分布式事务”这个词时,我差点以为是某种高深的金融操作——结果发现它比我想的更复杂,也更有意思。今天,我想用最接地气的方式,带完全零基础的朋友搞懂这个看似“硬核”的概念。

我写这篇教程,是因为市面上太多资料一上来就堆术语、画架构图,把新手直接劝退。而我希望你读完这篇文章后,不仅能理解分布式事务是什么,还能动手写出自己的第一个解决方案。全程使用 Go 语言,因为它的简洁和并发模型特别适合这类场景。


为什么我们需要分布式事务?

想象你在一个电商系统里下单:

  1. 扣减库存(调用库存服务)
  2. 创建订单(调用订单服务)
  3. 扣款(调用支付服务)

这三个操作分布在三个不同的服务中。如果第2步成功了,但第3步失败了,会发生什么?——用户看到订单创建成功,但钱没扣,库存也没释放。这就是典型的 数据不一致 问题。

在单体应用里,我们可以用数据库事务(BEGIN...COMMIT)保证这三步要么全成功,要么全回滚。但在分布式系统中,每个服务都有自己的数据库,传统的事务机制失效了。

于是,分布式事务 就成了我们必须面对的课题。


环境准备:5分钟搭好开发环境

我们用 Go 来实现,所以先确保你的机器有以下工具:

工具 版本要求 安装方式
Go ≥1.20 官网下载
Docker 最新版 安装 Docker Desktop
Redis 7.x `docker run -d -p 6379:6
379 redis:7`

然后初始化一个 Go 项目:

mkdir distributed-tx-demo
cd distributed-tx-demo
go mod init distributed-tx-demo

安装依赖(我们会用到 Gin 做 Web 框架,Redis 做消息队列):

go get github.com/gin-gonic/gin
go get github.com/go-redis/redis/v8

💡 小贴士:如果你是纯新手,别被这些命令吓到。它们只是告诉电脑:“帮我建个文件夹,然后在里面准备写代码的环境”。


核心概念:三种主流方案通俗讲

分布式事务没有银弹,但有几种经典解法。我用“寄快递”来打个比方:

1. 两阶段提交(2PC)——像签收确认

  • 阶段一:协调者问所有参与者:“你们能发货吗?”(Prepare)
  • 阶段二:如果大家都说“能”,协调者说:“好,发吧!”(Commit);否则说:“算了,别发了。”(Rollback)

优点:强一致性
缺点:性能差、阻塞、单点故障

📚 书籍推荐:《Designing Data-Intensive Applications》第9章详细讲了2PC的利弊。

2. TCC(Try-Confirm-Cancel)——先冻结再操作

  • Try:预留资源(比如冻结库存)
  • Confirm:真正扣减(如果所有服务都 Try 成功)
  • Cancel:释放预留(如果有任一失败)

优点:灵活、高性能
缺点:业务侵入性强,要写三套逻辑

3. 本地消息表 / 最终一致性 —— 发短信通知

这是最常用、最适合初学者的方案!核心思想是:

“先干自己的事,再想办法通知别人”

比如:

  1. 订单服务创建订单,同时往本地消息表插入一条“待发送”的消息
  2. 后台任务不断扫描这张表,把消息发给库存服务
  3. 库存服务收到后扣库存,并回复“已处理”
  4. 订单服务收到回复后,把消息标记为“已处理”

即使中间断电、网络抖动,重启后也能继续处理,最终达到一致。

我们今天的实战就用这个方案!


实战:用 Go 实现一个简易分布式事务

我们要模拟一个场景:用户下单时,订单服务通知库存服务扣减库存

第一步:建两个服务

distributed-tx-demo/
├── order-service/
│   ├── main.go
│   └── db/
│       └── messages.sql
└── inventory-service/
    └── main.go

1. 订单服务(order-service)

先建数据库表(用 SQLite 演示,生产环境建议 MySQL):

-- messages.sql
CREATE TABLE IF NOT EXISTS outbox (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    event_type TEXT NOT NULL,      -- 'DECREASE_INVENTORY'
    payload TEXT NOT NULL,         -- JSON: {"order_id": "123", "sku": "IPHONE", "qty": 1}
    status TEXT DEFAULT 'pending', -- 'pending', 'sent', 'failed'
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

main.go 内容:

package main

import (
	"database/sql"
	"encoding/json"
	"log"
	"net/http"
	"time"

	_ "github.com/mattn/go-sqlite3"
	"github.com/gin-gonic/gin"
	"github.com/go-redis/redis/v8"
	"context"
)

var db *sql.DB
var rdb *redis.Client

func init() {
	var err error
	db, err = sql.Open("sqlite3", "./orders.db")
	if err != nil {
		log.Fatal(err)
	}
	// 初始化表(简单起见,每次启动都执行)
	db.Exec(`CREATE TABLE IF NOT EXISTS orders (id TEXT PRIMARY KEY, user_id TEXT, sku TEXT, qty INT);`)
	db.Exec(`CREATE TABLE IF NOT EXISTS outbox (id INTEGER PRIMARY KEY, event_type TEXT, payload TEXT, status TEXT DEFAULT 'pending');`)

	rdb = redis.NewClient(&redis.Options{
		Addr: "localhost:6379",
	})
}

// 下单接口
func createOrder(c *gin.Context) {
	type Req struct {
		UserID string `json:"user_id"`
		SKU    string `json:"sku"`
		Qty    int    `json:"qty"`
	}
	var req Req
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(400, gin.H{"error": err.Error()})
		return
	}

	orderID := "ORD" + time.Now().Format("20060102150405")

	// 1. 创建订单(本地事务)
	tx, _ := db.Begin()
	tx.Exec(`INSERT INTO orders (id, user_id, sku, qty) VALUES (?, ?, ?, ?)`,
		orderID, req.UserID, req.SKU, req.Qty)

	// 2. 插入消息到 outbox 表
	payload, _ := json.Marshal(map[string]interface{}{
		"order_id": orderID,
		"sku":      req.SKU,
		"qty":      req.Qty,
	})
	tx.Exec(`INSERT INTO outbox (event_type, payload) VALUES ('DECREASE_INVENTORY', ?)`, string(payload))
	tx.Commit()

	// 3. 异步发送消息(简化:直接发 Redis)
	rdb.Publish(context.Background(), "inventory_events", string(payload))

	c.JSON(200, gin.H{"order_id": orderID})
}

func main() {
	r := gin.Default()
	r.POST("/order", createOrder)
	r.Run(":8080")
}

2. 库存服务(inventory-service)

main.go

package main

import (
	"context"
	"database/sql"
	"encoding/json"
	"log"
	"net/http"

	_ "github.com/mattn/go-sqlite3"
	"github.com/gin-gonic/gin"
	"github.com/go-redis/redis/v8"
)

var invDB *sql.DB
var rdb *redis.Client

func init() {
	var err error
	invDB, err = sql.Open("sqlite3", "./inventory.db")
	if err != nil {
		log.Fatal(err)
	}
	invDB.Exec(`CREATE TABLE IF NOT EXISTS stock (sku TEXT PRIMARY KEY, qty INT);`)
	// 初始化库存
	invDB.Exec(`INSERT OR IGNORE INTO stock (sku, qty) VALUES ('IPHONE', 100);`)

	rdb = redis.NewClient(&redis.Options{Addr: "localhost:6379"})
}

type InventoryEvent struct {
	OrderID string `json:"order_id"`
	SKU     string `json:"sku"`
	Qty     int    `json:"qty"`
}

func listenToInventoryEvents() {
	pubsub := rdb.Subscribe(context.Background(), "inventory_events")
	defer pubsub.Close()

	for msg := range pubsub.Channel() {
		var event InventoryEvent
		json.Unmarshal([]byte(msg.Payload), &event)

		// 扣减库存
		_, err := invDB.Exec(`UPDATE stock SET qty = qty - ? WHERE sku = ? AND qty >= ?`,
			event.Qty, event.SKU, event.Qty)
		if err != nil || rowsAffected == 0 {
			log.Printf("库存不足或扣减失败: %+v", event)
			// 这里可以发失败消息回订单服务(简化略)
			continue
		}

		log.Printf("成功扣减库存: %+v", event)
	}
}

func main() {
	go listenToInventoryEvents() // 启动监听协程

	r := gin.Default()
	r.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) })
	r.Run(":8081")
}

第二步:运行测试

  1. 启动 Redis(前面已用 Docker 启动)
  2. 终端1:cd order-service && go run main.go
  3. 终端2:cd inventory-service && go run main.go
  4. 终端3:用 curl 测试下单
curl -X POST http://localhost:8080/order \
  -H "Content-Type: application/json" \
  -d '{"user_id":"U123","sku":"IPHONE","qty":1}'

你会看到库存服务日志输出“成功扣减库存”。如果此时库存服务宕机,消息会留在 Redis 中(实际生产建议用持久化队列如 Kafka/RabbitMQ),等它恢复后继续处理。


新手常见问题解答

Q1:为什么不用数据库事务直接跨服务?

A:因为每个服务有自己的数据库连接,事务不能跨数据库。MySQL 的 XA 事务理论上可以,但性能极差,且 Go 生态支持弱。

Q2:消息丢了怎么办?

A:关键是要做到 “本地事务 + 消息”原子性。我们的 outbox 表和订单在同一事务中写入,保证不会丢。即使 Redis 挂了,也可以加一个后台任务定期重试未发送的消息。

Q3:TCC 和 2PC 哪个更好?

A:对新手来说,最终一致性方案最友好。TCC 要求业务逻辑改造大,2PC 性能差。除非你做金融系统,否则优先考虑消息驱动。

Q4:Go 适合做分布式事务吗?

A:非常适合!Go 的 goroutine 天然适合处理异步任务,而且标准库对 HTTP、JSON、数据库支持非常好,代码简洁。


学习建议:下一步怎么走?

  1. 精读一本经典:强烈推荐《Designing Data-Intensive Applications》(中文名《数据密集型应用系统设计》)。这本书第8-9章讲透了分布式系统的各种一致性模型。
  2. 动手扩展项目
    • 加入消息重试机制(比如最多重试3次)
    • 用 RabbitMQ 替代 Redis(更可靠)
    • 增加“补偿”逻辑:如果库存扣减失败,自动取消订单
  3. 了解 Seata:这是阿里开源的分布式事务框架,支持 AT/TCC/Saga 模式。虽然 Go 版本还不成熟,但 Java 版值得研究。
  4. 不要追求 100% 一致:大多数业务场景,最终一致性 足够了。强一致性成本太高。

最后的话

我当初学分布式事务时,也被“CAP定理”、“BASE理论”绕得晕头转向。但当我动手写了一个简单的消息表方案后,突然就通了。技术不是用来膜拜的,是用来解决问题的。

希望这篇教程能帮你迈出第一步。记住:所有复杂的架构,都始于一行能跑起来的代码

现在,打开你的编辑器,试着跑一遍上面的例子吧!遇到问题?欢迎留言讨论。

评论 0

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