Go 语言 分库分表 实战:从原理到落地
最近公司那个用户订单表,单表已经干到 8000 多万行了,查询慢得像便秘。DBA 说"兄弟你得分库分表了",我说行吧。然后就开始调研,发现 Go 生态里面像样的分库分表库真没几个,要么就是 Java 那边 MyCat、ShardingSphere 那套东西翻译过来的阉割版,要么就是自己从零撸。为什么不能有个开箱即用的东西呢...可能是 Go 社区都觉得这事儿太脏不想干吧。
下面是一套我自己实测跑通、已经上了生产的方案,不整花里胡哨的,能活下来就行。
先想清楚你要分什么
别上来就写代码,先拿张纸画一下。分库分表不是银弹,你 5000 行数据分 8 个库纯属脑子有病。经验值:单表超过 2000 万行、或者单个数据库扛不住 QPS 的时候再考虑。
分片键选哪个是最要命的问题。订单表的话,一般选 user_id 而不是 order_id。因为你的查询场景 90% 是"查我的订单",用 user_id 分片,同一个用户的数据在同一个分片里,不用跨库扫。你要是用 order_id 分片,查"我的全部订单"就得遍历所有分片,那分表的意义就没了,纯粹给自己找麻烦。
路由怎么搞
原理很简单:拿分片键算个 hash,取模,决定去哪个库、哪张表。Go 里面不用引什么重型依赖,一个 md5 或者 fnv32 就够用了。
// pkg/shard/route.go
package shard
import (
"encoding/binary"
"hash/fnv"
)
// Route 根据分片键计算出库号和表号
// dbCount: 有几个库, tablePerDb: 每个库有几张表
func Route(key string, dbCount, tablePerDb int) (db int, table int) {
h := fnv.New32a()
h.Write([]byte(key))
idx := binary.BigEndian.Uint32(h.Sum(nil))
// 注意:这里用总表数取模,而不是分别取模
// 否则会出现某个库有表是空的、某个库挤爆的情况
totalTables := dbCount * tablePerDb
globalIdx := int(idx % uint32(totalTables))
db = globalIdx / tablePerDb
table = globalIdx % tablePerDb
return
}
调用起来就是 db, tbl := shard.Route(userID, 4, 8),得到库 03、表 07,最终表名就是 order_0 到 order_7,库是 db_0 到 db_3。
写连接池
分库之后你不可能为每个库手动建一个 *sql.DB。我一般用一个 map 管理,启动的时候一次性建好:
// pkg/shard/pool.go
package shard
import (
"database/sql"
"fmt"
"sync"
_ "github.com/go-sql-driver/mysql"
)
type Pool struct {
dbs []*sql.DB
mu sync.RWMutex
}
// Config 每个库的连接串
type Config struct {
DSNs []string // 按库序号排列: [0]="user:pass@tcp(db0:3306)/orders"
}
func NewPool(cfg Config) (*Pool, error) {
p := &Pool{dbs: make([]*sql.DB, len(cfg.DSNs))}
for i, dsn := range cfg.DSNs {
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, fmt.Errorf("open db %d: %w", i, err)
}
db.SetMaxOpenConns(50)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(0) // 别搞什么连接过期,MySQL 那边 wait_timeout 你自己调
p.dbs[i] = db
}
return p, nil
}
func (p *Pool) GetDB(index int) (*sql.DB, error) {
if index < 0 || index >= len(p.dbs) {
return nil, fmt.Errorf("db index %d out of range [0, %d)", index, len(p.dbs))
}
return p.dbs[index], nil
}
改写 SQL
这一步最恶心。你得把 order 这张逻辑表名替换成实际的 order_3 这种物理表名。我选择不用什么 AST 解析器(Go 那边没有 Java 那种 antlr 生态那么全),直接用字符串替换搞定大部分场景:
// pkg/shard/rewrite.go
package shard
import (
"fmt"
"strings"
)
func RewriteSQL(sql string, logicTable string, actualTable string) string {
// 粗暴但有效,生产跑了半年没出过问题
// 如果你的 SQL 特别复杂(子查询、JOIN 多表),建议引 vitess 的 parser
// 链接: github.com/vitessio/vitess/go/vt/sqlparser
return strings.ReplaceAll(sql, logicTable, actualTable)
}
// Execute 路由 + 改写 + 执行,一条龙
func (p *Pool) Execute(ctx context.Context, logicTable, shardKey string, sql string, args ...interface{}) (sql.Result, error) {
dbIdx, tblIdx := Route(shardKey, p.dbCount, p.tablePerDb)
db, err := p.GetDB(dbIdx)
if err != nil {
return nil, err
}
actualTable := fmt.Sprintf("%s_%d", logicTable, tblIdx)
rewritten := RewriteSQL(sql, logicTable, actualTable)
return db.ExecContext(ctx, rewritten, args...)
}
最头疼的问题:ID 怎么生成
分表之后 AUTO_INCREMENT 就废了,每个表各自从 1 开始,合起来全是重复 ID。Go 生态我试过 snowflake 的几种实现,最终选了 github.com/bwmarrin/snowflake,简单稳定,workerID 用机器标识区分就行:
// pkg/idgen/snowflake.go
package idgen
import "github.com/bwmarrin/snowflake"
var Node *snowflake.Node
func Init(workerID int64) error {
var err error
Node, err = snowflake.NewNode(workerID)
return err
}
func NextID() int64 {
return Node.Generate().Int64()
}
workerID 怎么分配:容器化部署的话,StatefulSet 的 pod ordinal 直接拿来用;裸机就写配置文件里手动分配。别用 IP 末段,重复概率不低,我踩过这个坑。
跨库事务和 JOIN
说实话,这两件事能不做就不做。分库分表之后,你的业务逻辑里出现跨库 JOIN 基本就是设计出问题了。查用户的订单列表,user 信息走缓存或者冗余一份到订单表(对,就是反范式,别跟我扯第三范式,性能面前那些都是虚的)。
跨库事务更没法搞,XA 那玩意儿性能拉胯还一堆边界 case。要么业务层做最终一致性(消息表 + 定时补偿),要么就老老实实把强一致的数据放同一个库。
上线迁移
新表建好、路由代码部署上去了,老数据怎么迁过去。我的做法是双写:
- 先上线代码,读走老表、写走双写(老表 + 新分片表)
- 跑一个离线脚本把历史数据灌进分片表(用上面的 Route 函数算每个数据该进哪个分片,批量 insert)
- 数据校验,diff 一下老表和新表的分片总和,行数对得上、抽查字段没问题
- 切读:把读流量灰度切到新表,按 user_id hash 放量
- 稳定一周后,关老表写入,干掉双写
整个过程中最担心的是第 4 步。读切换一定要按分片键来灰度,别随机切,不然同一个用户的请求一会儿读老表一会儿读新表,数据不一致的问题直接爆炸。
最后说两句
分库分表这事儿没有银弹,上面这套方案能 cover 绝大多数 CRUD 场景。如果你的查询模式特别复杂(全文检索、多维度筛选),那可能得上 Elasticsearch 或者 ClickHouse 了,别硬往 MySQL 上堆。
还有一点,监控一定要跟上。每个分片库的慢查询、连接数、磁盘 IO 单独看。别等到某个分片热点爆了才发现,那场面跟拆炸弹似的。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。