#DevOps

数据库分库分表与水平扩展

单库扛不住时怎么办?本文讲清数据库扩展的路径:先垂直优化(索引/查询/读写分离),再水平(分库分表),分片键怎么选、跨片查询怎么办、分布式事务与一致性、以及"别急着分库"——大多数项目用不上,先榨干单机。

本文要点

  • 主题聚焦:DevOps
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年6月17日·更新于 2026年7月23日

每次数据库慢一点,就有人跳出来喊"我们分库吧"。这是后端最经典的"大锤思维"——手里拿锤子,看什么都像钉子。但现实是:我见过的分库项目,十个有七个是过度设计,拆完不仅没解决瓶颈,反而把运维和一致性拖进深渊;真正"非分不可"的可能只有一个。

这篇文章不讲八股,讲的是:单库扛不住时,正确的扩展路径是什么;万一真走到分库分表,分片键怎么选、跨片查询怎么治、一致性怎么妥协;以及最重要的——为什么你应该尽可能晚地走到那一步。

一、先把单机榨干:扩展的正确顺序

数据库扩展不是"性能不行了就拆",而是一条有严格先后顺序的链路。跳级是最贵的错误。

text
索引/查询优化 → 连接池/参数调优 → 读写分离 → 缓存层 → 垂直拆分 → 水平分片
   (几乎免费)        (免费)          (便宜)      (便宜)      (中等)        (极贵)

第一步永远是优化,不是扩容。90% 的慢查询根因就三个:缺索引、SELECT *、N+1。EXPLAIN 跑一遍,把全表扫描、filesort、临时表干掉,QPS 经常立刻翻几倍。这一步几乎零成本,收益却最大。

第二步是连接池与参数。innodb_buffer_pool_size 给到物理内存的 70%、连接池(HikariCP / PgBouncer)调到合理上限、max_connections 别用默认值——改完吞吐经常再涨一截。

第三步是读写分离。读多写少的业务,主库写、从库读,读流量分摊出去主库压力立刻下来。代价是主从延迟,写完立刻读要走主库——这件事下面还会反复出现。

第四步才是缓存。热数据进 Redis,cache-aside + 合理 TTL + 空值缓存防穿透,读 QPS 能降一个数量级。代价是引入一致性问题:缓存不是银弹,是另一份需要同步的状态。

前四步都做完了,单机真的扛不住了,才考虑拆库。 跳过前面直接分库,等于把"加一台 Redis 就能解决的事"变成"运维三套分布式系统"。

二、垂直拆分:按业务切,先把混乱治了

水平分片之前,先做垂直拆分。它的核心不是扛量,是治架构的乱。

按业务拆库:一个臃肿的"大库"里塞着用户、订单、商品、评论、消息……所有表共享一个 buffer pool,某张表(比如订单)做大批量统计把 IO 吃光,用户登录就跟着卡。按业务边界切成独立库:

text
        [单体数据库]
   用户 / 订单 / 商品 / 评论 / 消息
            │
   ┌────┬───┴───┬──────┬──────┐
   ▼    ▼       ▼      ▼      ▼
 [用户库] [订单库] [商品库] [评论库] [消息库]
   各自独立实例、独立故障域

拆完的好处是物理隔离——订单库被慢查询拖死,不影响用户登录。代价是跨库 JOIN 没了,得改成应用层组装或冗余字段。

大字段拆表(冷热分离):一张表塞着 content、extra_json 这种几 KB 字段,buffer pool 命中率被严重拉低。把大字段拆到扩展表,主表只留高频访问的小字段:

text
[user]          id, name, email, created_at     ← 热表,小行,常驻缓存
[user_profile]  user_id, avatar, bio, extra     ← 冷表,按需 join

这两步几乎零风险,先做透,很多"分库压力"会自动消失。

三、水平分片:把一张大表拆成 N 份

垂直拆完了,某个核心表(典型如订单、消息流水)单表还是上亿行、写入打满——这时才轮到水平分片(sharding):按某个维度把一张表的数据均匀分散到多个库多个表。

常见分片策略三种:

text
┌─────────────────────────────────────────────────────────┐
│  1. Hash 分片: shard = hash(key) % N                    │
│     数据分布均匀,但扩容(加片)时要 rehash,痛苦        │
│                                                         │
│  2. Range 分片: 按 id/时间区间,如 0-1000万 → 片0       │
│     扩容友好,但容易热点(最新数据集中写在某一片)       │
│                                                         │
│  3. 地域/业务分片: 北美用户 → 美东库,欧洲 → 欧西库      │
│     数据就近、合规友好,但分布可能严重不均               │
└─────────────────────────────────────────────────────────┘

落地时通常两层路由:库 = hash(key) / 表数,表 = hash(key) % 表数。比如 4 库 8 表共 32 个物理表,用户 u42 的订单 → hash(42) % 32 = 10 → DB_1 的 order_2。

具体路由业界一般不手撸,用中间件:ShardingSphere、Vitess、MyCAT、PostgreSQL 的 Citus。能不写路由层就别写,这块坑比你想象的多得多。

四、分片键:决定生死的那个字段

分片能不能成功,90% 取决于分片键选得对不对。选错了,数据倾斜和跨片查询灾难会同时炸。

原则:按读路径上的高频查询条件选,让大多数查询只命中一个分片。

电商订单,几乎一切查询都从"某个用户的订单"出发——查我的订单、查我的退款、查我的物流。所以分片键就应该是 user_id。这样 90% 以上的查询天然落在单片,无需跨片。

text
订单查询: WHERE user_id = ?     ← 命中单片,快
商家查询: WHERE merchant_id = ? ← 全片扫描,慢!
运营后台: WHERE created_at > ?  ← 全片扫描,慢!

商家和后台这两条路径就难办了。常见解法是双写一份按 merchant_id 分片的异构索引表专门给商家维度用,代价是写入翻倍、一致性更复杂——这是水平扩展绕不开的税。

反面教材:按 id 自增分片(读请求不带 id,每次先反查路由,白多一跳);按 created_at 分片(最新数据集中在一个热点片,写放大严重);随机分片(直接退化成"全片扫描器")。

还要警惕倾斜:按 user_id hash 看起来均匀,但若某几个大 B 端客户单条记录几百万行,照样会堆到同一片。这种长尾用户要走专门的热点表单独处理。

五、跨片查询:分库之后真正的痛

分片一旦上线,以下能力全退化:

JOIN 没了。原来一句 SELECT ... FROM order JOIN user 搞定的事,现在两张表可能不在一个库,只能在应用层先查一边、再拿 ID 批量查另一边、再在内存里拼。

聚合要合并。COUNT、SUM 不能直接下推,要发到所有分片各自算再合并。中间件大多能自动做,但性能是 O(N 分片)。

全局分页极难。这是最坑的。LIMIT 10000, 20 在分片场景下意思是:每个分片都要查前 10020 条,合并后再排序取 20。页数越深放大越狠。常见妥协:只允许"下一页"游标分页(WHERE id > last_id ORDER BY id LIMIT 20);或者把需要复杂排序/分页的字段同步到 Elasticsearch,查询走 ES、拿到 ID 再回库取详情——这是业界主流。

全局唯一 ID。自增主键失效——N 个库各自 auto_increment 会撞车。要么用独立发号器,要么用雪花算法(Snowflake):

text
┌── 1 bit ──┬── 41 bit ─────┬── 10 bit ──┬── 12 bit ───┐
│   符号位   │   毫秒时间戳   │ 机器/机房ID │  单机序列号  │
└──────────┴───────────────┴───────────┴────────────┘
              ↑ 趋势递增,利于 B+Tree 顺序写

雪花算法的好处是 ID 趋势递增(对 InnoDB 主键友好,避免页分裂)、本地生成无网络开销、全局唯一。坑是依赖时钟,时钟回拨会生成重复 ID——生产里要么禁用 NTP 步进回拨,要么用 Leaf 这类带 ZK 协调的发号器兜底。

六、分布式事务:别强求强一致

分库之后,原来一个 BEGIN ... COMMIT 搞定的事,现在要跨多个库——分布式事务的噩梦开始了。先记住:业务能容忍最终一致,就绝对不要上强一致。

四种主流方案,按代价从高到低:

text
┌──────────┬──────────────────────┬───────────────────────────┐
│  方案     │  一致性 / 性能        │  适用场景                  │
├──────────┼──────────────────────┼───────────────────────────┤
│  2PC      │  强一致 / 极差        │  几乎别用,阻塞所有参与者   │
│  TCC      │  强一致 / 中          │  资金、库存等核心交易       │
│  Saga     │  最终一致 / 好        │  长流程业务,可补偿         │
│  本地消息  │  最终一致 / 最好      │  异步解耦,90% 场景够用     │
└──────────┴──────────────────────┴───────────────────────────┘

2PC(两阶段提交):协调者让所有参与者先锁资源、再统一提交。理论完美,实战灾难——任何参与者卡住,所有库都被锁住;协调者挂了全场悬空。除非跨库强一致是命门(银行核心账务),否则别碰。

TCC(Try-Confirm-Cancel):业务层把每个操作拆成"预留-确认-回滚"三步。性能比 2PC 好很多,但每个服务都要写三套逻辑,开发成本极高,只留给资金、库存这种不能错的场景。

Saga:把长事务拆成一串本地事务,每步配一个补偿操作,失败时反向补偿。适合订单流程这种跨多服务的长链路,代价是中间状态对外可见。

本地消息表 / 可靠消息(实战首选):思路是"把分布式事务降级为本地事务 + 异步消息"——业务数据和消息表放在同一个库,一个本地事务里写完;后台轮询把消息投递到 MQ,消费者幂等消费。配合 RocketMQ 事务消息、Kafka 事务 producer 或 Outbox 模式,90% 的互联网业务用它就够。

幂等是底线:分布式系统里消息一定会有"至少一次"重投递。消费者不做幂等迟早出数据问题——用唯一键、状态机、去重表都行,但不能不做。

七、别急着分库:写给大多数团队

说了这么多分片的技术细节,最后泼一盆冷水:绝大多数项目,根本走不到分库那一步。

我见过太多团队,日 QPS 几千就张罗着"上分库分表"。算笔账:单机 MySQL/PostgreSQL 配 SSD + 64GB 内存,稳定扛几万 QPS 不夸张;加一主两从 + Redis 缓存,读 QPS 还能再翻一个数量级;真有"单表上亿"瓶颈之前,索引和分区表(PARTITION)能拖很久。

也就是说,大多数业务在到达分库阈值前,产品早就迭代或死掉了。提前分库,等于为可能永远不会到来的未来,提前支付巨额运维税。

分库的真实代价:

  1. 运维爆炸:N 个实例 × 主从 × 备份 × 监控,DBA 工作量翻几倍。
  2. 一致性噩梦:跨库事务、消息补偿、幂等设计,每一项都能让团队加班一个月。
  3. 迁移困难:数据怎么平滑切?双写灰度?停机?没有一次分库迁移是轻松的。
  4. 查询能力倒退:复杂报表、JOIN、统计得搬到数仓或 OLAP 引擎,链路被生生拉长。

所以正确的心态是:把分库当成"最后一颗子弹"。索引、读写分离、缓存、分区表、垂直拆分、升级硬件都用尽了,业务还在加速增长——这时才认真评估分片。评估时先问问能不能换个更猛的数据库(分析查询迁 ClickHouse、时序迁 TimescaleDB),很多时候换工具比拆库便宜得多。

小结

  1. 扩展有严格的顺序:索引 → 参数 → 读写分离 → 缓存 → 垂直拆分 → 水平分片。跳级是后端最贵的错误,前四步几乎免费、收益最大。
  2. 分片键决定生死:永远按"读路径上的高频条件"选,让 90% 以上查询命中单片。电商订单选 user_id,别选 id 或 created_at。
  3. 跨片查询是真正的痛:JOIN 退化、聚合要合并、全局分页几乎无解,深度分页禁用或走搜索引擎兜底。
  4. 分布式事务别强求强一致:90% 的场景用"本地消息表 + 幂等"达到最终一致就够了,TCC 留给资金、库存这种命门。
  5. 绝大多数项目用不上分库:单机 + 读写分离 + 缓存能扛到几万 QPS。分库是最后一颗子弹,提前开火只会让你多养几套分布式系统。

一句话收尾:数据库的水平扩展,从来不是"性能不行了就分",而是"所有便宜的办法都用完了,实在没办法了才分"。能不分,就别分;要分,就先想清楚分片键、跨片查询和一致性这三道关怎么过。

Related

相关推荐