分布式事务太难?文科生用Go带你一步步搞定
大家好,我是一个从历史系转码成功的后端工程师。当初第一次听到“分布式事务”这个词时,我差点以为是某种高深的金融操作——结果发现它比我想的更复杂,也更有意思。今天,我想用最接地气的方式,带完全零基础的朋友搞懂这个看似“硬核”的概念。
我写这篇教程,是因为市面上太多资料一上来就堆术语、画架构图,把新手直接劝退。而我希望你读完这篇文章后,不仅能理解分布式事务是什么,还能动手写出自己的第一个解决方案。全程使用 Go 语言,因为它的简洁和并发模型特别适合这类场景。
为什么我们需要分布式事务?
想象你在一个电商系统里下单:
- 扣减库存(调用库存服务)
- 创建订单(调用订单服务)
- 扣款(调用支付服务)
这三个操作分布在三个不同的服务中。如果第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. 本地消息表 / 最终一致性 —— 发短信通知
这是最常用、最适合初学者的方案!核心思想是:
“先干自己的事,再想办法通知别人”
比如:
- 订单服务创建订单,同时往本地消息表插入一条“待发送”的消息
- 后台任务不断扫描这张表,把消息发给库存服务
- 库存服务收到后扣库存,并回复“已处理”
- 订单服务收到回复后,把消息标记为“已处理”
即使中间断电、网络抖动,重启后也能继续处理,最终达到一致。
我们今天的实战就用这个方案!
实战:用 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")
}
第二步:运行测试
- 启动 Redis(前面已用 Docker 启动)
- 终端1:
cd order-service && go run main.go - 终端2:
cd inventory-service && go run main.go - 终端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、数据库支持非常好,代码简洁。
学习建议:下一步怎么走?
- 精读一本经典:强烈推荐《Designing Data-Intensive Applications》(中文名《数据密集型应用系统设计》)。这本书第8-9章讲透了分布式系统的各种一致性模型。
- 动手扩展项目:
- 加入消息重试机制(比如最多重试3次)
- 用 RabbitMQ 替代 Redis(更可靠)
- 增加“补偿”逻辑:如果库存扣减失败,自动取消订单
- 了解 Seata:这是阿里开源的分布式事务框架,支持 AT/TCC/Saga 模式。虽然 Go 版本还不成熟,但 Java 版值得研究。
- 不要追求 100% 一致:大多数业务场景,最终一致性 足够了。强一致性成本太高。
最后的话
我当初学分布式事务时,也被“CAP定理”、“BASE理论”绕得晕头转向。但当我动手写了一个简单的消息表方案后,突然就通了。技术不是用来膜拜的,是用来解决问题的。
希望这篇教程能帮你迈出第一步。记住:所有复杂的架构,都始于一行能跑起来的代码。
现在,打开你的编辑器,试着跑一遍上面的例子吧!遇到问题?欢迎留言讨论。

评论 0