拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

分库分表实战:全局主键、分片键与分布式事务核心问题解析

分库分表实战:全局主键、分片键与分布式事务核心问题解析

1. 开篇:分库分表不只是拆分,更是整套体系的重构

这篇是分库分表系列的第二篇,接上一篇聊的“什么时候该分、分成几库几表”的选型逻辑。如果你还在纠结“我到底要不要分库分表”,建议先回去看第一篇,把分片维度和容量评估搞明白,再来读这篇。

这一篇我想聊的,是真正动手做分库分表之后,大家普遍会踩进去的深水区:全局主键怎么生成、分片键到底怎么选才不后悔、跨库查询和分页要怎么面对、分布式事务是不是必须上、存量数据怎么平滑迁移。这些事不是写几十行SQL就能糊弄过去的,每一项都关系到系统上线之后的稳定性和你的睡眠质量。

我的感受是,很多人对分库分表的理解停留在“把一张大表拆成N张小表”,真到了设计阶段才发现,拆表只是最表层的一步。表拆完了,应用层的路由规则、ID生成方案、事务边界、查询路径、数据迁移脚本,全都得跟着重构。这一篇就是把这些“拆完表之后怎么办”的问题一个一个拆开讲清楚,适合正在做分库分表方案设计、或者已经上了分库分表但被各种边界问题折磨的读者。

2. 全局主键生成:别让唯一ID成为第一个坑

表拆完,第一个迎面撞上的问题就是主键。原来用MySQL自增ID,单表里没问题,分表之后每张表都从1开始自增,业务上需要全局唯一的主键来标识一条记录,冲突就来了。这时候很多团队会想到用“ID + 分片键”的组合来做唯一,比如订单表用“用户ID + 订单ID”作为联合主键,但这只是数据库层面能查到,对外接口、缓存Key、消息队列里的消息去重、日志追踪链路,全都需要一个全局唯一的ID。

说白了,全局唯一ID是分布式系统的地基。地基没打好,后面所有依赖ID做幂等、做路由、做关联查询的功能都会跟着出问题。

2.1 常用方案横向对比

我身边团队实际用过的方案基本是下面这几种,各有取舍,我得先说结论再逐个分析:

方案实现方式优点缺点适用场景
UUID直接生成无序字符串实现简单、全球唯一长度太长、无序、索引性能差日志流水、非核心业务主键
数据库自增ID + 步长每台库设置不同步长实现简单、短小有序扩容要改配置、步长固定不灵活中小规模系统
数据库号段模式集中发号器批量取号段性能好、有顺序引入额外依赖、号段浪费多数业务均可
Redis自增用INCR命令发号性能高、实现简单依赖Redis高可用、有丢失风险允许少量ID重复兜底的场景
雪花算法时间戳 + 机器ID + 序列号趋势递增、无中心化、性能极高依赖时钟、机器ID分配绝大多数业务首选

UUID我第一个排除,不是因为不能用,而是因为作为主键它会导致索引页分裂严重。InnoDB的聚簇索引是按主键顺序组织的,UUID完全无序,插入时数据页频繁分裂,写入性能会明显劣化,更不用说它36个字符的长度在存储和传输上的开销。

数据库自增ID加步长的方案,原理是给每个节点配置不同的起始值和步长。比如节点A的ID是1、3、5、7……节点B是2、4、6、8……。这个方案在节点少(2到3个)的时候很清爽,但痛点在于扩容。从2个库扩到4个库,步长从2改成4,老数据里的ID步长是2生成出来的,新数据用步长4生成,两段ID从数学关系上就已经对不上了,必须做数据迁移或者重新映射。而且一旦某个节点写入量特别大,ID被快速消耗到步长上限,还会出现并发冲突的边界问题。这个方案只能作为临时过渡,不建议作为长期设计。

2.2 雪花算法为什么能成为默认选项

雪花算法在实际项目里的普及程度,已经高到有点“无脑选”的意思了。它生成的ID是64位长整型:1位符号位(永远是0)加41位毫秒时间戳加10位机器ID加12位毫秒内序列号。这样算下来,单台机器一毫秒可以生成4096个ID,理论上足够绝大多数业务的峰值流量。

它的核心优势在我看来有三个:第一是无中心化,不依赖数据库或Redis,每台应用服务器本地就能生成ID,没有网络开销;第二是趋势递增,同一毫秒内生成的ID是有序的,对索引友好;第三是ID里自带了时间戳信息,排错的时候可以直接从ID上看出这条数据的生成时间,这个特性在追线上问题的时候特别有用。

但雪花算法不是没有坑。最经典的是时钟回拨问题。如果服务器的NTP同步把系统时钟往回拨了哪怕几十毫秒,生成的ID就可能跟之前已经发出去的重叠。我见过有的团队在代码里只做了一次时钟校验就认为万事大吉,实际线上NTP回拨几十毫秒是偶发事件,处理方式一般是记录上一次生成ID的时间戳,如果当前时间小于上次时间,就进行短暂自旋等待,超过一定阈值再切换备用机器继续生成。

机器ID分配也是个隐藏麻烦。10位机器ID理论上支持1024台机器,但很多团队在配置里硬编码,一旦服务实例水平扩容,忘记调整配置就会生成重复ID。我个人的做法是用ZooKeeper或者数据库记录已分配的机器ID,新实例启动时自动获取一个未被占用的ID并注册,实例下线后延迟释放,确保不会两个实例拿着同一个ID发号。

对于大部分后端团队,我的建议是直接用成熟的开源版本,比如Leaf或者自带雪花算法的各个框架组件,不要自己从头写。自己实现的雪花算法,序列号用完了怎么处理、时钟回拨怎么办、机器ID怎么分配,这些边界问题每个人都会写错,成熟组件已经处理过这些极端情况了。

2.3 无状态业务到底需不需要有序ID

有些业务场景对ID顺序性完全无感,比如日志系统、消息流水。这时候有团队会问:我能不能就直接用UUID,省得引入雪花算法的复杂度?我的看法是,如果你的表数据量确实不大,而且确认没有用主键做范围查询的需求,UUID没问题。但你要是准备做分库分表,说明表数据量已经大到要考虑单表瓶颈了,那么索引写入性能就是实打实的约束。一个由字母和横线组成的36位字符串和纯数字的长整型,在B+树上的插入性能差距不是小数目。分库分表之后你的目标是让每张分表的写入都尽量快,主键设计绝不该在这里拖后腿。

3. 分片键选择:方向错了,后面全是返工

如果说全局主键是地基,那分片键就是承重墙,拆了再改等于动结构手术。我见过不止一个团队,上线半年之后发现分片键选得不好,被迫做全量数据迁移。那场面,几个通宵都补不回来。

3.1 分片键的本质是路由规则

分库分表之后,一条数据存到哪张表、查询的时候去哪张表找,全靠分片键计算。我们最常见的分片算法是取模。比如分成16张表,分片键的哈希值对16取模,结果0到15,正好映射到16张表。这个映射关系一旦确定,所有读写都得按这个规则走。分片键选得好,查询路由一路顺风,选得不好,你会发现系统里90%的查询都变成“全表扫描”——这里说的全表扫描不是单表上的全表扫描,而是要把所有分表都查一遍再合并结果,性能直接从毫秒级掉到秒级。

我总结了一个原则叫“三问定分片键”:

  • 这个字段是不是绝大多数查询的必带条件?
  • 这个字段的取值是不是足够分散,不会出现超级热点?
  • 这个字段是不是基本不变的?

第一问防的是“路由失效”,第二问防的是“数据倾斜”,第三问防的是“字段更新导致重新路由”。这三个问题里但凡有一个不满足,你都得认真想想这个分片键能不能用。

3.2 一个典型的订单系统案例

拿我实际做过的一个订单系统来说事。订单表的核心访问路径有两个:一是买家查“我买过什么”,二是卖家查“我卖过什么”。如果以买家ID为分片键,买家维度的查询非常舒服,一条SQL直接定位到某一张分表。但卖家维度的查询就要跨所有分表去扫,因为一个卖家的订单分布在所有分表里。反过来以卖家ID为分片键,卖家查询很爽,买家查询却成了问题。

这世界上没有完美的分片键,只有适合业务主路径的分片键。我当时的选择是:以买家ID为分片键,因为平台是C2C模式,买家单次访问频率远高于卖家,而且买家查询QPS占整体查询的八成以上。卖家端的跨分片查询,用离线数仓同步一套卖家维度的汇总数据来支撑,而不是在在线库里硬扛。

如果你遇到的是买家和卖家查询重要性五五开的情况,那就要考虑冗余双写——同一份订单数据按买家维度和卖家维度各存一份,各自用各自的分片键。这在业界叫“数据冗余双维度”,代价是写入放大一倍,而且两边的数据一致性要靠事务或者消息队列来保证。这是一个典型的用存储换查询性能的思路,前提是你对数据一致性有足够的手段兜底。

3.3 数据倾斜:分片键取值分布不均怎么办

分片键选好了,还要防另一个坑:数据倾斜。我们常说取模会均匀分布,但均匀分布的前提是分片键本身的取值足够离散。如果分片键是城市名,一共就几十个城市,那么头部城市对应的分表数据量可能是尾部城市的几十倍,这就等于分库分表白做了——最热的分片还是会先到瓶颈。

处理数据倾斜有几个思路。第一是做二次散列:对分片键先做一个哈希转换,再取模,这样能把原本取值集中的字段打散。比如用户ID本身就已经够分散,不用二次处理;但如果分片键是城市维度,可以先对这个城市名做一次哈希,让它的分布近似均匀。第二是拆分热点键:识别出超级热点,比如某个大卖家的订单量是别人的几百倍,那就给这个卖家的数据单独映射到多个分片,再按一定规则在这些分片里轮询写入。这个方案很有效,但实现复杂度不低,因为你写入时要识别热点键,查询时也要知道它的数据分布规则。

数据倾斜这件事,我的经验是最好在设计阶段就预测到,不要等到监控告警之后再优化。上线前把业务数据里分片键的取值分布拉出来画个表,看看前10个热点值的占比,如果超过20%,就得考虑二次散列或者热点拆分。这个功夫花在前期,比后期重建分表划算太多。

4. 跨分片查询:不以分片键为条件的SQL都是成本炸弹

分库分表之后,应用层最直接的痛感就来自跨分片查询。原来一张表上的简单SQL,现在要么被改造成带分片键条件的路由SQL,要么被迫变成“广播SQL”——把所有分表跑一遍再把结果聚合。

4.1 不带分片键的查询为什么这么贵

我举个例子。订单表按买家ID分了16个分片,你有一个需求是查“某个时间段内所有金额大于1000元的订单”,查询条件里只有时间范围跟金额,完全没有买家ID。这时候数据库不知道去哪个分片找,只能把请求发给16个分片,每个分片都执行一遍查询,然后在应用层把结果合并。一次查询变成16次查询,而且查询完成时间取决于最慢的那张分表。当分片数从16变成128的时候,这个代价还会线性放大。

更难受的是分页。单库单表的时候不用管LIMIT 10, 20这种写法,但跨分片查询下,如果你要拿第11到20条数据,每个分片都要先取前30条,然后应用层合并排序,再取第11到20条。翻到第100页的时候,每个分片要取前1010条,聚合之后排序,再把中间那段丢掉。越翻越慢,性能是线性恶化的。

4.2 怎么绕开跨分片查询的性能陷阱

这里没有银弹,只有工程学上的取舍。我列几个实际项目里验证过有效的做法:

第一,用宽表冗余解决高频查询。既然后台管理界面需要按时间、金额、状态这些条件组合查询,那就在库里专门维护一个查询用宽表,用另一个分片键(比如按时间维度分片),数据通过消息队列从订单主表异步同步过去。查询界面读宽表,核心交易链路读订单分表,两边互不干扰。

第二,把跨分片聚合下推到ES或者OLAP引擎。订单表可以只保留在线交易所需的最短字段,把所有需要复杂筛选的字段通过binlog或者MQ同步到ES里。后台运营的筛选查询直接查ES,不仅跨分片问题没了,连多条件组合查询的性能也提升了一个量级。这个方案在很多大厂订单系统里是标准做法。

第三,业务上做限制。比如客户端强制要求必须选择“我的订单”入口,这样查询必然携带买家ID,天然路由到单一分片。很多C端产品的历史订单查询就是这样设计的:要么按订单号精确查,要么按买家维度翻列表,直接禁止“全平台所有订单”这种无筛选条件的在线查询。这不仅是性能考虑,也是合规和安全考虑。

4.3 落地方案:分片键优先,其他查询靠辅助

在代码层面,我个人的做法是建立一个查询路由层。对外暴露的DAO接口,在实现里明确区分两类查询:支持分片键的查询直接路由;不支持分片键的查询走辅助查询引擎。这个路由逻辑必须写在所有查询入口的最前端,否则任何一次“忘了带分片键”的调用,都会变成一次面向全部分片的下意识扫描。

我还见过一种做法,是在SQL解析层自动做去分片键检查,没有分片键的SQL直接拦截返回错误,防止开发人员无意间写出一条跨全分片的查询。这个策略虽然有点狠,但确实能保证线上不会出现因为代码疏漏导致的慢查询风暴。不过它对团队开发效率有一定影响,实施前需要跟开发规范做好配合。

5. 落到实处的方案:跨分片事务,到底该不该上分布式事务

分库分表之后,原来单库里一个事务能搞定的事,现在跨库了。比如下单要扣库存、写订单、加积分,这三个操作如果落在不同的分片上,传统数据库事务就管不了了。这时候分布式事务要不要上、上哪种方案,是架构评审里必吵的话题。

5.1 先分清事务边界,再选方案

我的建议是先把事务边界理顺。不是所有跨分片操作都需要分布式事务,很多业务场景用“最终一致性”就能满足。你扣库存和写订单,如果落在不同分片,最保险的是用本地消息表配合消息队列做异步补偿:先在一个分片内写订单并把消息落地,再异步把扣库存的消息发送出去,库存服务消费消息完成扣减。这个过程不用强一致,允许短暂的不一致窗口,最终达到一致状态就行。

那什么时候必须上分布式事务?我理解是:一笔业务操作必须要么全部成功、要么全部失败,而且失败之后不能有任何一条数据处于中间状态。金融级的账务变动、库存预占这类场景,通常就属于这个范畴。这时候2PC(两阶段提交)或者基于TCC(Try-Confirm-Cancel)的方案才值得引入。

5.2 TCC的实践心得

TCC是我在工作中用得相对多的方案。它跟2PC的区别在于,2PC是数据库层面做的,事务资源由数据库锁住;TCC是业务层面做的,每个参与者都要自己实现Try、Confirm、Cancel三个方法。

以库存扣减为例,Try阶段先把可用库存扣减、冻结库存增加,订单状态写成“待确认”;Confirm阶段把冻结库存真正扣掉;Cancel阶段把冻结库存释放、恢复可用库存。这套逻辑的难点在于Cancel一定要能幂等执行,因为网络抖动会导致重复Cancel或者先Confirm后Cancel的乱序,每个方法都要做好幂等控制。

这里我必须提醒一点:TCC的实现成本不小,每个参与事务的接口都要额外写Try、Confirm、Cancel三套逻辑,而且三套逻辑全都要正确处理并发和异常。如果你的团队规模不大,不建议所有业务都套用TCC,只在真正需要强一致的少数核心链路上用就好。

5.3 协调者的高可用问题

不管用哪种分布式事务方案,协调者都是单点风险最大的组件。我见过有些团队的分布式事务协调器部署在单台服务器上,一旦宕机,所有跨库事务全部卡住。正确做法是协调器本身做多节点高可用,并且事务状态要持久化,宕机重启后能从上次的断点继续推进。

另外一个容易被忽略的点是事务的超时时间。分布式事务涉及多个网络调用,耗时通常比本地事务长得多。如果超时时间设置得太短,会出现很多“明明在继续执行却被判定为超时”的误报;如果太长,事务长时间锁定资源,后面堆积的事务又会把系统拖垮。这个参数需要结合线上实际响应时间反复调整,没有一劳永逸的值。

6. 从拆完到跑通:存量数据迁移与双写

分库分表最惊险的一步,不是设计,不是开发,而是把线上正在跑的存量数据从单库平滑迁移到分库分表的新架构上。这一步做不好,可能就是故障演练事故——直接在线上演习。

6.1 双写方案的整体链路

行业里比较成熟的方案是:新代码上线之后同时写老库和新分片,读流量逐步切换到新分片,等切流完成之后再做存量数据全量校验和搬迁。这个方案的关键在双写阶段,它保证了新旧两套存储的数据在增量上是一致的,避免直接切换带来的数据丢失。

双写的详细步骤我拆成下面几条:

  1. 在应用层增加一个双写开关,开关打开时,所有写请求同时写入老库和新分片,新分片的写入失败不影响老库的主流程,只记录日志告警。
  2. 读流量先保持走老库,观察新分片的写入延迟、数据量增长、写入失败率,确认稳定之后再逐步把读流量按比例切到新分片。
  3. 全部读流量切到新分片之后,开始做存量数据搬迁。这个过程一般用离线工具导出老库全量数据,按分片规则计算归属,灌入对应的分片表。
  4. 存量数据灌完之后不能直接结束,必须做数据比对——抽样比对新老库里的记录数、关键字段的MD5值,发现不一致的用补偿任务修正。
  5. 最后一步才是切写流量。把双写开关关掉,所有读写请求全部走新分片,老库只做只读备份保留一段时间。

这里我补充一下为什么读流量切换要按比例逐步来。一次性切完看起来效率高,但如果新分片存在索引设计缺陷或者慢查询,瞬间进来的大量读流量会把新库打挂。按5%、10%、25%、50%、100%的比例逐步切换,每个阶段观察一段时间,出问题时能迅速回滚,这是线上架构变更里最基本的风险控制逻辑。

6.2 数据一致性校验的实操细节

存量数据搬迁之后,新老数据不一致是很正常的事,关键是怎么快速发现差异并修复。我的做法是分两层校验。

第一层是粗粒度校验:统计老库每张表的记录总数、金额类字段的总和,跟新分片对应维度的汇总比对。这一层能发现明显的数据丢失或者重复。

第二层是细粒度校验:把每张表的主键和关键业务字段拼成一个字符串,用CRC32或者MD5映射成一个大整数,分别计算老库和新分片全量数据的哈希值,比较是否一致。如果主库数据量大到无法一次性读完,就按分片和时间范围切片,逐批做比对。

校验发现的差异,按类型不同分别处理。完全缺失的记录要重新补导;字段值不一致的要定位是哪一侧在双写阶段产生了覆盖,很多时候是双写顺序不一致导致的最终值不同,需要业务上明确以哪一侧为准做覆盖修正。这个流程耗时很长,但走完一遍,线上迁移的基本信心就有了。

6.3 迁移过程中最容易被忽略的清理工作

切流完成绝不是迁移结束。我见过不少团队切完流量就撤了,老库继续占着磁盘和备份空间,binlog还在持续产生,监控还在监听一些毫无意义的指标,运维成本被白花在已经不需要的资源上。

老库在只读保留期结束后,要安排下线。下线前先把老库的备份全量导出一份归档,确认新分片运行稳定达到一个业务周期(我一般建议至少两周,覆盖月结类任务),然后把老库实例停机、从云资源里释放,同时关闭和它关联的监控告警、定时备份任务、防火墙规则。这些事看起来琐碎,但每次做不完都会在后续的预算审计和运维排查里反复折磨人。

7. 分库分表之后,那些容易“阴沟翻船”的细节

前面聊的都是主线流程,最后我得把一些边角细节摆出来。这些细节单独拎出来都不大,但堆在一起,足够让一个看起来很稳的上线变得手忙脚乱。

7.1 自增主键依赖业务代码里的坑

分库分表之后,最典型的一个隐藏问题就是很多开发同学习惯在代码里先查一次SELECT MAX(id)再去插入新数据。在单表模式下这么做只是性能问题,在分表模式下这就是严重的并发埋雷——同一个分片内的两条并发事务可能读到相同的MAX值,然后插入冲突。正确的做法是全局ID生成器一次性分配好ID,写入业务代码不再关心主键是怎么来的。

7.2 分片键的更新问题

有些表的分片键不是完全不可变的。比如订单表以用户ID分片,用户注销后更换了用户ID,理论上这个订单就要从原分片搬到新分片。但分片键变更意味着数据路由变化,直接UPDATE会是跨分片操作,大部分分库分表中间件根本不支持这种跨分片的UPDATE。我的建议是:业务上就把分片键定为不可变字段,任何时候不允许修改;如果确实有修改需求,把它包装成“删除旧记录 + 插入新记录”两个动作,在代码层面完成迁移,而不是指望数据库能做这件事。

7.3 全局表(字典表)的处理

分库分表之后,有些数据量小但每个分片都要用到的表,比如地区字典、商品类目、系统配置,业内叫“全局表”。对这种表的标准做法是:在每个分片里都冗余一份同样的数据,写入时广播到所有分片,读取直接在本地分片完成。这样避免了一次跨分片的连接查询。全局表的数据量必须严格控制,因为每次写入都要广播到全部分片,写入成本和分片数量成正比。如果这类表的写入QPS比较高,就要考虑从在线链路里剥离出来,走缓存或者配置中心。

7.4 不要把分库分表当成所有问题的解药

最后我想很直接地说一句:分库分表解决的是单库容量和单库并发连接数的问题,它不会让慢SQL变快,不会让糟糕的索引设计变好,更不会替代缓存层的职责。如果一张表的SQL本来就走得慢,拆完之后它在每个分片里依然走得慢——只是慢的次数多了几倍而已。分库分表之前,先把单库单表这台发动机修好,再去考虑多缸联动的复杂度。不然你只是在用一个巨大的技术债,去掩盖一个同样巨大的技术债,最后两笔债一起到期,那才是真的灾难现场。

我这几年做分库分表改造最大的体会就是:技术方案本身不难,难的是在整个过程里始终保持“数据一致、请求不断、可以回滚”的底线。每上一个新机制,先想它失败时的表现是什么,再想它正常时的性能怎么样。顺序不能反,反了,线上就会用告警教你做人。

返回列表