小Cの已经记不起来的博客

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_0order_7,库是 db_0db_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。要么业务层做最终一致性(消息表 + 定时补偿),要么就老老实实把强一致的数据放同一个库。

上线迁移

新表建好、路由代码部署上去了,老数据怎么迁过去。我的做法是双写:

  1. 先上线代码,读走老表、写走双写(老表 + 新分片表)
  2. 跑一个离线脚本把历史数据灌进分片表(用上面的 Route 函数算每个数据该进哪个分片,批量 insert)
  3. 数据校验,diff 一下老表和新表的分片总和,行数对得上、抽查字段没问题
  4. 切读:把读流量灰度切到新表,按 user_id hash 放量
  5. 稳定一周后,关老表写入,干掉双写

整个过程中最担心的是第 4 步。读切换一定要按分片键来灰度,别随机切,不然同一个用户的请求一会儿读老表一会儿读新表,数据不一致的问题直接爆炸。

最后说两句

分库分表这事儿没有银弹,上面这套方案能 cover 绝大多数 CRUD 场景。如果你的查询模式特别复杂(全文检索、多维度筛选),那可能得上 Elasticsearch 或者 ClickHouse 了,别硬往 MySQL 上堆。

还有一点,监控一定要跟上。每个分片库的慢查询、连接数、磁盘 IO 单独看。别等到某个分片热点爆了才发现,那场面跟拆炸弹似的。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放