4.1 每服务一库:拆库的代价与收益

场景:一个 MySQL 拖垮三个服务

服务拆完的头两个月,我们监控系统里最刺眼的不是某个服务的错误率,而是数据库的连接数和慢查询数。原因是明摆着的:订单、商品、用户三个服务虽然进程独立了,但背后还是同一个 MySQL 实例,三四十张表挤在一起。这就像三个人合租一间厨房,一个人炖汤,另外两个连泡面都烧不开水。

最典型的一次事故发生在大促预热。运营批量导入 SKU,商品服务里一个更新库存的大事务跑了四十多秒,期间订单服务的创建订单接口全部超时。链路追踪的 trace 链路一片飘红,但根因不在任何一个服务的代码里,而是在数据库这一层。那天晚上团队凑在一起定了一件事:服务按业务域拆了,库也得跟着拆,否则拆服务只拆了一半。

拆库之前我们先把对照表拉出来,说服自己也说服老板:

维度 拆库前(单库共用) 拆库后(每服务一库)
故障隔离 一个慢 SQL 全站抖动 订单库抖动不影响商品读
DDL 影响 加字段要全库停机窗口 各库各自灰度执行
备份恢复 整库回滚,粒度粗 可按业务域单独恢复
扩容成本 整实例升配,为热点买单 热点库单独升配即可
跨表查询 天然支持 join 需代码组装或离线汇总

代价也写在表里了:join 没了,报表难做了,一致性要靠别的方式保证。这些代价在后面三节会逐一展开,这一节先解决怎么拆得稳。

方案:三个库,一张迁移时间表

按第一篇定下的业务域边界,我们拆出三个库:order_db、product_db、user_db。原则很简单:服务拥有自己的库,别的服务想拿数据,要么走接口,要么走消息,绝不跨库直连。

迁移我们选了最稳的土办法,分四个阶段:

  1. 双写:新库建好表结构,应用层同时写老库和新库,以老库为准。
  2. 同步:老库的增量变更通过 binlog 同步到新库,兜底双写漏掉的部分。
  3. 切读:按服务灰度把读流量切到新库,先切非核心接口,再切核心链路。
  4. 下线:观察一周无差异后,停止双写,老库只保留冷数据。

时间表别排得太紧,我们原计划两周搞定,实际跑了五周,主要时间都耗在数据比对上。每天晚上跑一次全量比对脚本,把老库和新库的记录数、关键字段 hash 逐表对一遍,任何不一致都要查到原因才能继续推进。四个阶段我们定了明确的验收标准:

阶段 时长 主要动作 验收标准
双写 5 天 应用层同时写老库新库 双写成功率连续三天 100%
同步 7 天 binlog 增量同步兜底 延迟稳定在 3 秒内
切读 10 天 按接口灰度切读 接口错误率与耗时无劣化
下线 7 天 停双写,老库转冷 比对脚本连续一周零差异

binlog 同步我们用的是 canal,部署在一台独立的 CVM 上,订阅老库的 binlog 把变更转发到新库。配置上有两个细节值得记住:第一,binlog 格式必须是 ROW,STATEMENT 格式在跨库同步时会丢上下文;第二,同步任务启动前用一个固定的位点做全量初始化,全量跑完再从固定位点接增量,这个位点要提前在老库上锁记录好,否则初始化期间的增量变更会漏掉。

灰度切读靠配置开关控制,做法是按用户 ID 取模灰度,上线时先放百分之一流量到新库:

func (s *OrderService) Query(ctx context.Context, no string) (*Order, error) {
    if config.ReadNewDB(no) { // 按 uid 取模灰度
        return s.newDB.Query(ctx, no)
    }
    return s.oldDB.Query(ctx, no)
}

配置:Go 里管理多个数据源

拆完库第一个要改的是数据访问层。我们没有上复杂的分库中间件,就是最朴素的用法:每个服务持有自己的连接,互不串门。以订单服务为例,用 GORM 管理连接:

package db

import (
    "gorm.io/driver/mysql"
    "gorm.io/gorm"
    "time"
)

func NewOrderDB(cfg Config) *gorm.DB {
    dsn := cfg.OrderDSN // order_db 专用账号
    db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
    if err != nil {
        panic(err)
    }
    sqlDB, _ := db.DB()
    sqlDB.SetMaxOpenConns(50)
    sqlDB.SetMaxIdleConns(10)
    sqlDB.SetConnMaxLifetime(30 * time.Minute)
    return db
}

连接池参数是踩过坑后才认真的。大促时订单服务每秒处理两千多笔订单,MaxOpenConns 一开始没设,默认无上限,MySQL 的连接数被三个服务一起打满,直接拒绝连接。后来定了个规矩:每个服务的连接数上限按实例规格算,MySQL 的 max_connections 减去预留再除以服务实例数。

还有一个强约束写进了代码规范:DAO 层禁止出现跨库 join 的残留。我们写了个简单的静态检查脚本,扫描 SQL 里的表名前缀,订单服务里出现 product_ 前缀的表直接报警,效果比口头约定好得多。

踩坑:join 消失之后

拆库真正痛的不是迁移,是迁移之后原来那些 join 查询怎么办。我们的商品详情页以前一个 join 搞定:商品基本信息、库存、卖家昵称一张表出。拆完之后要走多次调用:商品服务查基本信息和库存,用户服务查卖家信息。接口耗时从 30ms 涨到 90ms,还在gRPC 调用链上多了两跳。

解决办法分两步。一是把卖家昵称这类几乎不变的数据做冗余字段,直接存在商品表里,变更时通过消息异步更新;二是把商品详情页整体交给缓存兜住,读流量基本不打到库,这部分的方案在4.4 节展开。

报表系统是另一个受害者。运营每天看的销售报表以前就是几个 join 加聚合,拆完之后只能在凌晨把三个库的数据导到一个汇总库里再跑。初期用 mysqldump 加定时任务,土但能跑;后来量大了换成 binlog 实时同步到汇总库。有半天时间报表对不上,排查发现是同步任务漏了订单的退款状态字段,补上之后才对齐。这事提醒我们:汇总库的字段必须有明确的 schema 版本管理,否则数据对不上你都不知道从哪天开始错的。

自增主键也踩了一脚。订单表原来用自增 ID,拆库后多个库各自从 1 开始自增,前端拿到的订单号撞在一起。最后统一换成雪花算法生成分布式 ID,机器位用服务实例编号占位,避免不同实例生成相同 ID。

还有一个迁移期的坑差点酿成事故:切读第一天就发现有用户反映订单列表少了记录。排查发现是双写代码里漏掉了一处历史接口,这个接口只写老库没写新库。我们之前没做写路径全覆盖检查,光对账早晚高峰的数据量是发现不了的,因为那部分写入量很小。补救办法是暂停切读,用 binlog 把老库的差额数据补到新库,再对全表重新比对一次。教训:双写改造后必须做一次全量写路径扫描,所有写库的位置逐一确认,比对脚本也得包含冷门时段的数据。

给准备拆库的你

先说怎么定拆库顺序,比方案本身更重要。判断依据有三个:读写压力是不是已经互相拖累、边界是不是足够清晰、团队能不能腾出连续的人力。我们按这个标准把订单库排在第一个,因为它的写压力最大,且与商品、用户之间的耦合最容易被接口替代。用户库最后拆,改动面大但压力最小,风险可控。

  • 先拆读写压力最大、边界最清晰的那一个,我们选的是订单库,收益最大。
  • 迁移期间老库是唯一事实来源,切读之前任何比对的懒惰都会在后面加倍偿还。
  • join 的消失是必然代价,提前盘点所有跨表查询,想好替代方案再动刀。
  • 主键方案提前定,自增 ID 跨库必撞,建议直接上雪花算法。

拆库的机器成本并不高,迁移用的新型实例是新购 CVM 自建的 MySQL,规格按连接数预估,新用户活动价拿下来比原实例升配还便宜,具体可以看这个腾讯云 CVM 限时特惠页。

小结

每服务一库的本质是用复杂度换隔离性:故障隔离、发布隔离、扩容隔离,代价是跨库查询、分布式事务和运维成本。如果你的团队还在合租数据库,可以先问自己一句:是不是所有服务都真的需要独立数据库?小团队共享一个库按 schema 隔离也是个选项,等你真的被互相拖累到疼了再拆也不迟。一旦拆了,就请准备迎接接下来分布式事务的挑战。

延伸阅读