分表需应用层显式计算表名,ORM不支持自动路由;按ID取模或时间分表各有适用场景与限制,必须重构SQL编写、DAO设计及监控体系。

分表必须由应用层显式计算表名,ORM 不会自动帮你切
Go 的 database/sql、gorm、sqlx 全都不认识“分表”这个概念。你写 db.Create(&user),它只会往 struct 标签里写的 table: "user" 插;写 db.Where("id = ?", id).First(&u),也永远查 user 表。数据实际在 user_0 里?那查询就一定返回 sql.ErrNoRows——这是最常被忽略的错误源头。
必须把表名生成逻辑抽成纯函数,例如:
func GetTableNameByUserID(id int64) string {
return fmt.Sprintf("user_%d", abs(id)%4)
}
- 所有
INSERT/SELECT/UPDATE/DELETE操作前,先调这个函数算出真实表名 - 用
gorm时,必须显式调db.Table(tableName),不能依赖db.Model(&User{}) - 别把这逻辑藏在 DAO 方法内部——测试难、复用难、排查难
按 ID 取模分表:简单但扩容痛苦,慎用于主键增长快的场景
id % N 是最容易上手的分表方式,但它有两个硬伤:单表数据量不可控(ID 跳跃或不连续),扩容时必须迁移历史数据。
适用前提很窄:
立即学习“go语言免费学习笔记(深入)”;
- 用户量稳定、ID 分布均匀(比如用雪花算法生成)
- 能接受停机迁移,或有双写+校验能力
- 负数 ID 必须先取绝对值:
abs(id) % 4,否则结果不可预测 - 别用
tables[id%len(tables)]这种切片索引——表名顺序一变,路由全乱
性能影响小,但 MySQL 对 user_0、user_1 这类带数字后缀的表名不走预编译缓存,高并发下 prepare 解析开销略升。
按时间分表:适合日志、订单,关键在统一业务时间语义
用 created_at 或明确命名的 event_time 字段生成表名(如 order_202409),天然支持 TTL 删除和冷热分离。但真正难的是跨时间边界的行为处理。
比如下单在 9 月 30 日 23:59,支付回调在 10 月 1 日 00:02——该写哪张表?答案必须唯一且可追溯:
- 一律以业务发生时间为准,不是系统时间或回调时间
- 表名格式推荐
time.Format("200601")(年月),避免用"2006-01"——横杠在某些备份工具或权限系统里会被误判为分隔符 - 查跨月数据时,不能只查一张表;需动态生成多条 SQL +
UNION ALL,或 Go 层循环查后 merge;gorm不支持跨表UNION,得用db.Raw()
分表后 JOIN 和事务基本不可行,必须重构访问模式
这不是限制,是事实:MySQL 不支持跨物理表的外键约束,也不支持跨表事务原子性。你写的 JOIN user_0 ON u.id = o.user_id 会直接报错,或者查不到关联数据。
现实方案只有两个:
- 把 JOIN 拆成多次单表查询,在 Go 层做关联(适合数据量不大、关联字段少)
- 冗余关键字段(比如订单表里存用户昵称),用空间换查询简单性
- 彻底放弃跨表事务——转账这类强一致性操作,要么改用消息队列+对账,要么压到单分片内完成
最容易被忽略的一点:分表不是加个函数就能跑通的事,它是对整个数据访问链路的重设计。从 SQL 编写、DAO 接口定义、测试用例覆盖,到监控指标(比如各分表写入量是否倾斜),都得同步调整。


















