那天(2021-05-14)刚把一张TBASE核心表的shard key从uid换成order_id,我坐在工位上长舒了一口气。这个需求从提出来到落地,前后折腾了将近两周,真正动手换分片键只花了半天,剩下的时间全在调研、造数据、演练和踩坑上。TBASE数据表更换shard key这件事,表面看就是一个DDL,但分布式数据库里换分片键,本质上是在动整个数据分布的地基。如果你们团队也在考虑类似改造,我可以把这次全过程拆开来讲:包括为什么换、怎么选新键、在线迁移怎么做、哪些坑必须绕开。先说结论:TBASE里没有办法直接改一张大表的shard key,只能通过建新表、迁数据、切流量的方式完成,整个过程最怕的不是速度慢,而是数据不一致和回滚不了。
1. 先搞清楚分片键为什么非换不可
1.1 分片键就是分布式表的路由器
TBASE这类分布式数据库和单机数据库最大的区别,就是一张表的数据不会都放在一个节点上。建表时指定的shard key,决定了每一行该发到哪个分片节点、落在哪张物理分表里。你可以把分片键想象成快递单上的目的地城市:每个网点只负责指定城市包裹的收发。只要目的地写得对,包裹能直达;如果写得模糊或者填错,要么快递员全城乱跑,要么某个网点堆成山。
所以,shard key不是一个可以随便拍脑袋选的属性,它同时决定了三种东西:数据分布是否均衡、单点查询能不能直接路由、点查和join的代价是高是低。服务器加得再多,只要分片键选得不好,大部分请求照样压在其中几个节点上,扩容也白扩容。这次要换的这张表,就是被这三个问题同时卡住了。
1.2 旧分片键的实际病状:倾斜与跨节点
旧表的分片键是uid,业务上是一个面向用户订单的流水表。最初上线时用户基数不大,按uid分片很自然——一个用户的数据尽量落在同一个分片,查询某个用户的订单时可以只访问一个节点。但随着业务跑起来,两个问题越来越突出。
第一,大客户出现了。个别企业客户通过接口批量下单,一个uid下有几千万条订单,这些数据全部落在同一个分片上,这个分片的磁盘、CPU、IO持续飙高,其他分片却很空闲。这种数据倾斜在运维监控里看得特别明显:16个分片里有两个分片的磁盘使用率接近85%,其余只有30%。第二,新的经营分析场景经常要按order_id查详情,或者按时间范围拉全量订单。按order_id查询时,数据库不知道order_id属于哪个分片,只能广播到所有节点,再做汇总。我们统计过,这类跨节点查询占到高峰期总查询量的四成,P99从原来的20毫秒涨到了200毫秒。这已经不是优化一两条SQL能解决的问题,必须换键。
1.3 为什么加索引解决不了这个问题
当时有同事提过:既然order_id查询多,那就在order_id上建一个全局二级索引不就行了嘛。这个说法对单机数据库完全正确,但分布式数据库不太一样。TBASE里二级索引分为全局和本地两种,如果全局索引用order_id作为索引键,虽然能加速按order_id的等值查询,但每次写入都要额外维护一份全局索引数据,写入放大的问题会非常严重。而且数据倾斜依然存在,大uid所在分片照样是热点。二级索引解决的是“查询路径”,解决不了“数据分布”。分片键是物理分布,索引是逻辑路径,换分片键是动地基,加索引只是换墙纸。
2. 更换前的调研与选型:先把路探好
2.1 盘点这张表的“朋友圈”
换分片键影响的不只是这一张表,还有所有跟它关联查询、写入同一事务的对象。我们第一步就是把表的结构、索引、约束、触发器、外键依赖全部导出来,逐个过一遍。这张表有13个字段,包括自增主键id、uid、order_id、merchant_id、amount、create_time等。主键是自增id,没有其他唯一索引,也没有外键依赖,这给后期切换减少了很多麻烦。但表上有3个二级索引,直接建新表的话索引都得重建。
数据量估算也很重要。我们通过管理端的统计接口看到,单表数据量已经到1.8亿行,原始数据约260GB,加上索引约310GB,日增数据量在300万行左右。增长速率直接影响增量同步方案的选择:如果日增量太大,追平时间会拉得很长,在线切换窗口就会特别紧张。另外还要看这张表被哪些视图、存储过程或下游同步任务引用,我们梳理出一个依赖清单,后面切换时才能逐个通知。
2.2 候选分片键的取舍:用数据说话
我们收集了所有高频查询的where条件,整理成一张表。候选有order_id、merchant_id、buyer_id、order_time。团队里当时有两种声音:一个建议用merchant_id,因为大客户报表经常按商户聚合;另一个建议用order_id,因为所有订单详情都带order_id。
我当时的判断标准有三条:一是基数要足够高,键值去重数至少要远大于分片节点数的几十倍,否则某些分片根本分不到数据;二是取值分布要均匀,不能有某个键值独占大比例数据;三是高频查询尽可能带上这个键的等值条件,让请求能精准路由到单个分片。另外键的类型尽量简单,整数比字符串好,哈希计算快,存储也省。
用这三条去筛:order_time肯定不行,时间范围查询天然有热点,而且随时间推移,新数据全压在尾部;buyer_id和uid类似,也会有头部买家倾斜;merchant_id基数较小,头部商户会集中大量订单;order_id是类自增的流水号,基数高、新值持续产生,天然比较均匀。加上订单详情、状态更新、订单列表查询几乎都带order_id,路由效率提升立竿见影。最终定了order_id。
为了验证,我们在测试环境抽了全量数据,用下面两条SQL统计候选键的分布。第一条看去重数和最大键值行数,第二条模拟分片后各分片的数据量,虽然真实分片策略不一定就是MOD,但思路是通的。
-- 查看候选键的总去重数和最大键值行数 SELECT COUNT(DISTINCT order_id) AS cnt, MAX(cn) AS max_cnt FROM ( SELECT order_id, COUNT(*) AS cn FROM t_order GROUP BY order_id ) t; -- 以16个分片为例,估算按order_id分片后的分布 SELECT MOD(order_id, 16) AS shard_no, COUNT(*) FROM t_order GROUP BY MOD(order_id, 16) ORDER BY shard_no;最终结果:按order_id分片后,16个分片的最大/最小数据量比约为1.1,而按uid分片时这个比值是十几倍。看到这个数,大家才统一了意见。这里我想多说一句:分片键的评估不能靠感觉,一定要用线上数据做统计,而且要模拟真实分片函数,不然很容易踩到哈希边界上的“看似均匀,实际局部热点”的坑。
2.3 离线迁移还是在线迁移:时间窗口说了算
方案选型时,我们列了三种做法。第一种是全量停机迁移:低峰期停业务,导出旧表数据,导入新表,再切换。优点是链路短、好回滚;缺点是至少要2小时停机,这对线上订单系统不可接受。第二种是应用双写加增量同步:不停机,但应用要改代码,复杂度高。第三种是用数据库自带的在线迁移工具做全量日志同步,我们当时没有现成工具,自己搭CDC成本太高。
最终选了第二种方案的简化版:应用双写 + 分批回填 + 切换前短暂停写。这个方案允许的不可用时间大概只有1到2分钟,业务方可以接受。这里想强调的是,没有万能的方案,只有匹配窗口的方案。如果你们的业务能接受2小时停机,离线迁移其实更安全,尤其是在合规审查严格、不允许应用层改代码的团队里。不要为了追求“高级”而选在线迁移,复杂度是要用真实工作量买单的。
3. 影子表在线迁移实操:一步步来
3.1 备份与预检查
虽然是重建表迁数据,不是物理损坏,但我们还是做了一次全量逻辑备份。我习惯把“备份”和“逃生通道”当成一回事:只要备份在手,后面任何步骤出了问题都可以从零重来,心里不慌。
备份要在低峰期执行,并记录备份完成时刻的位点,方便增量阶段对账。备份文件放到和数据库集群隔离的存储上——我见过因为机器故障导致备份和原数据一起丢的例子,所以这个成本不能省。预检查还包含三件事:确认当前集群所有节点状态正常、确认磁盘有至少1.5倍表空间的余量、确认应用侧连接串和账号权限清晰。权限这里容易被忽略,新表建好后,原来的应用账号必须立刻授权,不然切换时会遇到权限错误。
3.2 创建新表并同步元数据
我管新表叫“影子表”,因为它在切换前不承接线上流量,只负责把数据迁过去。建表时最容易犯的错误是只想着换DISTRIBUTE BY SHARD(order_id),却忘了同步索引和约束。
字段类型要和旧表完全一致,否则导入时可能发生隐式转换,损坏原有数据;自增序列要重新创建并设置好起始值,避免后续主键冲突;如果旧表有分区,新表也要保持一致。分片键和分区键是两套逻辑:分片键决定数据在节点间怎么散,分区键决定数据在节点内怎么切,很多人会搞混。
我们实际使用的建表语句简化后如下:
CREATE TABLE t_order_new ( id BIGSERIAL, uid BIGINT NOT NULL, order_id BIGINT NOT NULL, merchant_id BIGINT, amount NUMERIC(12,2), create_time TIMESTAMP, PRIMARY KEY (id) ) DISTRIBUTE BY SHARD(order_id); CREATE INDEX idx_order_new_uid ON t_order_new(uid); CREATE INDEX idx_order_new_merchant ON t_order_new(merchant_id);建表完成后,立刻查询元数据确认分布键写的是order_id而不是uid。这一步看起来多余,但有同事确实把两张表建在同一个分布键上,切换完发现根本没用。
3.3 分批回填历史数据
新表建好后,开始把旧表数据复制过去。最粗暴的写法是INSERT INTO t_order_new SELECT * FROM t_order。小表没问题,但1.8亿行一个大事务会拖垮整个集群。分布式数据库最怕大事务,大事务会导致持有锁时间过长、日志膨胀、节点间通信阻塞。
所以我把导入拆成按create_time日期分片的一批批小事务。每批控制行数在5000到10000行,数据量控制在20MB以内。早期的测试脚本大概是这样:
import psycopg2 conn = psycopg2.connect(dsn) cur = conn.cursor() cur.execute("SELECT MIN(create_time), MAX(create_time) FROM t_order") min_dt, max_dt = cur.fetchone() # 按小时切片,每小时一般不超过30万行,再在代码里分批提交 # 这里只示例批量插入思路,实际脚本要加游标分批、断点续跑和异常重试 while current <= max_dt: cur.execute(""" INSERT INTO t_order_new SELECT * FROM t_order WHERE create_time >= %s AND create_time < %s """, (start, end)) conn.commit()注意,用INSERT...SELECT时尽量在业务低峰期执行,减少对源表的锁竞争。如果导入进程挂了,不要从头再来,做一个断点记录表,记录每次导入的起始和结束时间。我们第一次跑了三小时,因为一个网络抖动中断,幸好有断点,续跑只花了二十分钟。
3.4 增量同步:应用双写方案
历史数据回填期间,线上还在持续写入,如果只做历史回填,切到新表就会缺数据。最终我们启用了应用双写:应用层在创建订单、更新订单状态时,同时写旧表和新表;旧表继续承接线上查询,新表同时接收增量。这个动作需要应用发版,我们提前一周完成了双写代码的灰度,灰度比例是10% -> 50% -> 100%。
双写最怕不一致。我们约定双写必须放在同一个事务里,要么新旧表都成功,要么都失败。对于更新操作,先更新旧表再更新新表,并比对影响行数;对于删除操作,两边都删,并记录日志。双写不是简单的复制SQL,新表的分片键是order_id,而旧表是uid,所以双写的时候应用必须已经知道order_id,不能事务里只有uid。这一点在应用改造时特别容易漏。
如果你的系统没有能力改应用,也可以走CDC日志尾随方案,但要注意增量任务必须从“历史数据导出开始的位点”启动,否则中间会漏数据。我们最终因为运维成本没有上CDC,双写方案对团队来说更好把控。
3.5 切换流量:只有2分钟的窗口
当历史回填完成,双写延迟为零,我们申请了一个低峰期窗口:凌晨2点到2点30分。切换动作分两步:第一步通知上游业务短暂停写,通常1到2分钟;第二步把应用层的读写路由从旧表切到新表,同时停掉双写。
因为应用通过数据库代理访问,我们直接改了代理上的逻辑库映射,将原来指向t_order的流量重新指向t_order_new。这里不建议用RENAME表名切换,虽然看起来爽,但RENAME不改分布键,也容易让后续维护的人误以为表还是同一张。改路由逻辑上更清晰:旧表保留,新表承接,出了问题马上改回路由就能回滚。
切换瞬间我们要盯三个指标:数据库QPS是否掉零、新表写入错误数是否为0、旧表读取是否还在持续。我们当时切换后新表写入正常,但旧表流量没有完全断掉,几秒后才全部切完,原因是连接池残留连接,这个坑后面专门讲。
3.6 一致性校验与灰度观察
切换完,真正紧张的时刻才到。一致性校验至少三关:
第一关,行数一致。旧表和新表在同一个时间点的快照对比,行数误差要在可接受范围内,最好为零。第二关,抽样字段一致。随机抽几万行,比较order_id、amount、status、create_time等关键字段是否完全一样。第三关,聚合值一致。用SUM(amount)、COUNT(DISTINCT uid)做粗粒度对比,能快速发现整体偏差。
校验SQL模板:
-- 旧表快照 SELECT COUNT(*) AS cnt, SUM(amount) AS sum_amount FROM t_order_old; -- 新表快照 SELECT COUNT(*) AS cnt, SUM(amount) AS sum_amount FROM t_order_new;如果差异不为零,先查增量延迟或者双写日志,不要马上断定迁移失败。我们第一次切换后,行数一致,但SUM(amount)差了0.01,查了半天发现是历史上一笔手工退款只写在备注里导致的,与迁移无关。校验只能证明迁移没做错,不代表业务数据本身干净。
3.7 清理旧表与后续优化
新表稳定运行三天后,各项监控指标回落,我们才决定清理旧表。清理前检查磁盘空间是否足够,因为DROP TABLE后如果表空间没回收,还会占用目录空间。旧表约310GB,我们预留了400GB空闲。
DROP TABLE t_order_old之后,更新统计信息,让优化器知道新表的数据量,接着对核心查询做EXPLAIN,确认没有全分片扫描。我们当时还做了一轮分片数量评估,发现16个分片在某些时段仍有局部热点,后来把分片数调整到32,情况明显好转。分片数量不是越大越好,但配合热点监控,适当调整能让分布更平。
4. 实操中踩过的五个坑
4.1 应用查询模型没有同步改造
最早我们以为换完分片键数据库自动就好了。结果新表切换后,客服后台按uid查订单的接口还是很慢。虽然在uid上建了二级索引,但分布式库里二级索引扫描仍然需要跨分片,性能提升有限。后来团队把这类查询改成先查“uid与order_id映射表”的方式,先拿到订单号列表,再按order_id精准路由,问题才缓解。换分片键不等于一劳永逸,它本质上是一次查询模型的重构。
4.2 大事务回滚引发的连锁反应
第一次回填测试时,图省事用一个INSERT导了3000万行,跑了20多分钟,结果某个节点磁盘空间不足,事务回滚,随后其他节点复制延迟飙升。原因是这个大事务在多个分片上都持有了大量锁,回滚期间锁不释放,业务查询全部被堵。从那以后我就定了一条规则:分布式数据库里的单事务影响行数不要超过1万行,迁移场景尤其如此。
4.3 双写逻辑下的主键冲突
双写代码上线后,新表里出现几万条重复id。排查发现,订单创建接口在极端情况会走“先查询再插入”的路径,如果旧表存在而新表不存在,应用会把同一批数据插入两次。修复方式是双写逻辑里遇到唯一键冲突时改走更新,而不是继续插入。这类问题靠人工看日志很难发现,一定要在双写代码里加打点:每次双写后两边影响行数不一样就实时告警。
4.4 连接池残留连接打乱切换节奏
我们计划凌晨2点切换,业务暂停写1分钟。结果应用侧连接池里的长连接不会立刻感知到路由变更,仍然往旧表写入。切换后新表数据入口是断的,旧表还在涨,吓得我们差点回滚。后来解决办法是:切换前先由运维把应用节点从负载均衡摘掉,等待连接池空闲连接全部超时释放,通常等2到5分钟,再切数据库路由;切换后再挂回流量。这个步骤必须写进切换脚本,不能靠人脑临时记住。
4.5 新键也不一定完全均匀
order_id虽然是递增的,但业务侧有批量导入任务,会在短时间内连续插入千万级连续ID。这些ID经过哈希后,会因为分片桶限制集中到某几个分片,形成瞬时热点。我们最终调整了导入任务,让批量任务错峰执行,并把分片数从16加到32,缓解了热点。这说明“均匀”永远是相对的,要持续监控,建完表不代表可以放手。
5. 沉淀下来的经验
5.1 分片键是业务查询模式的显式声明
单机数据库靠索引服务查询,分布式数据库的分片键就是第一层、也是最重要的一层“物理索引”。它一旦选错,后面所有优化都像在沙地上盖楼。换键的成本是一次性几周的工作量,但如果能换来后续半年的稳定,这笔账非常划算。每次建新表之前,我都会拉着业务方把未来三个月的高频查询场景过一遍,而不是只问“这张表用什么分片键”。
5.2 在线迁移必须演练和准备回滚
我们在测试集群完整演练了三遍,每遍都发现新问题:第一遍路由切换脚本有误,第二遍双写数据校验跑不起来,第三遍才顺利。正因为演练过,生产切换时才敢按回车。回滚预案不是简单一句“备份还在”,而是“备份文件放在哪、恢复命令是什么、切换回旧表的步骤有哪些”,最好把这些都写成脚本,防止切换现场大脑空白。
5.3 把过程固化成检查清单
这次迁移最后真正沉淀下来的不是一段代码,而是一张十五项的CheckList:数据量核对、备份位点确认、双写代码灰度比例、连接池释放等待、新旧表行数对比、高峰监控、回滚触发条件……下次如果还要换另一张表,照着清单走,能省大量时间。分布式数据库很多操作都是高危的,人可以临时聪明,但流程不能临时拍脑袋。
那次切完所有流量已经凌晨四点,虽然累,但看着监控曲线一点点平下去,心里踏实了。如果你们也正在准备TBASE数据表更换shard key,希望这些记录能帮你们少走几个弯路。尤其是最后那条建议:先演练,再上线;先备份,再动手。数据库不会给你第二次“手滑”的机会。