资讯动态

分库分表后的索引设计实战:主键、二级索引与全局索引的坑与解法

发布时间:2026/9/9 8:29:37 来源:尧图企业网站定制
分库分表一旦提上日程很多人第一反应是“怎么分”“按什么分”把路由键定好订单表哗啦啦拆成几十个心里踏实了。但真正到了上线问题才会一个个冒出来而其中最先让你夜不能寐的往往是索引设计。因为单库单表时代索引是“锦上添花”顶多慢查询调优。到了分库分表之后索引直接决定你是能正常跑业务还是每天被DBA追着骂。主键怎么生成、二级索引怎么建、全局索引怎么做每个都是独立的难题。这篇就按我这些年实际踩坑的顺序从18张分表到64张分表再到跨机房部署把索引这块掰开揉碎讲清楚。1. 分库分表之后的索引困境路由键消失查询直接崩1.1 路由键是索引的第一层约束分库分表的本质是把一张大表按某个维度打散到多张物理表里这个维度就是路由键。订单表按user_id分那user_id就是路由键每个用户的所有订单都固定落在某个分片上。单条查询如果带上了user_id中间件能直接定位物理表快得像是没分库分表。但问题在于业务查询不可能永远只走路由键。查订单你会按订单号查、按商家查、按时间范围查、按订单状态查。一旦查询条件和路由键无关中间件就只能在所有分片上同时执行然后把结果合并这就是俗称的“广播查询”。几十个分片同时跑一条SQL哪怕单个分片只要10毫秒合并之后就是几十甚至上百毫秒压力一大就直接拖垮数据库。所以在设计索引之前你得先接受一个现实分库分表之后很多单库时代的查询习惯必须改。筛选条件先看有没有路由键没有路由键索引设计要解决的第一件事就是怎么让这类查询不把系统拖死。1.2 单库索引设计经验为什么直接失效单库表里你可以很自然地建一个联合索引比如select * from order where merchant_id ? and status ?。在单表十几亿数据下建一个(merchant_id, status)的联合索引命中索引后速度尚可接受。但分表之后情况完全不同。假如按user_id拆成32张表你执行where merchant_id ?中间件拿到这条SQL会把它复制成32条扔到32个分片分别执行然后合并结果。此时你在每个分片内部建的(merchant_id, status)索引只是“局部有效”它能让单个分片快一点但无法避免32次扫描和合并。更麻烦的是合并时如果需要排序、分页中间件得把所有分片的结果全捞出来在内存里排序一旦数据量上来内存就爆。这就是我为什么说“分库分表后索引设计真正变了”你建的索引要从“单机B树能不能命中”变成“在分布式剪枝和远程取数成本下这个索引到底值不值”。那些在单库年代根本不用考虑的问题比如跨分片join、唯一性约束、分页有序性现在全成了索引设计的一部分。2. 主键索引设计先用对ID生成策略再谈B树2.1 主键选择决定了分布式环境下的写入与查询效率主键索引在MySQL里是聚簇索引数据行按主键的顺序物理排列。单库单表时自增主键最省心写入顺序和B树叶子节点顺序完全一致不存在页分裂写入性能稳定。但分库分表之后一个全局自增主键根本没法用因为多个分片各自生成ID会冲突。当时团队里就有个经典争论能不能保留自增给每个分片设不同起始值和步长比如分片1生成1、33、65分片2生成2、34、66这样ID虽然全局唯一还带自增属性。但如果后续要扩容从32片扩到64片步长得重新调已经生成的ID也可能和新的分布策略冲突维护成本极高。我们实践下来这种方法只适合分片数将来绝对不变且很小的场景稍微有点扩展预期都不建议入坑。现在工程上主流的做法是用分布式ID生成器来保证全局唯一比如雪花算法Snowflake、号段模式、Redis自增号段等。不同方案的取舍我在下面展开了说。2.2 几种分布式ID方案的实测对比方案原理优点缺点适用场景雪花算法64位Long由时间戳机器ID序列号组成趋势递增、不依赖中间件、生成快时钟回拨会导致ID重复需要处理大多数互联网业务分库分表后主键首选Redis自增号段Redis维护一段可用ID区间业务服务批量申请ID有序、可控性强、无时钟问题引入Redis依赖占网络开销Redis挂了需要降级方案对ID顺序性要求高的场景UUID字符串全网唯一生成简单不依赖任何组件随机无序作为主键会导致严重页分裂索引占用空间大基本不建议用做主键数据库号段表用一张独立的库表记录当前号段批量申请实现逻辑简单、可靠高并发下数据库表有压力单点风险中小规模业务雪花算法是我最常用的因为ID本身是Long型8个字节放在主键索引里非常节省空间而且趋势递增对B树友好。但有个必须处理的点时钟回拨。我曾经遇到一次NTP同步导致的时间跳变雪花算法在同一毫秒内生成了重复ID结果插入时主键冲突整个订单写入链路报警。后来在生成器里加了几行容错判断如果当前时间小于上一次生成时间就拒绝服务或等待时间追上再配合一个内存队列做补偿才算把风险兜住。2.3 主键自增特性在分库分表下还重要吗很多人纠结的点是“主键要不要保持自增”。从MySQL索引结构看聚簇索引是顺序写入性能最好如果是随机主键每插入一条都要在B树中间做节点分裂InnoDB会产生大量随机IO。但分库分表后带来的写入压力是分散到多个分片的单分片的写入量通常只是原来总写入量的几十分之一此时页分裂的影响被摊薄了反而分布式ID生成的全局唯一性、有序性才更重要。我建议的原则是主键索引必须全局唯一、尽量单调递增、尽可能短。雪花算法生成的ID具备这三个特性。至于那种“全是UUID字符串”的主键我可以很直接地说在生产分表环境里见过一次就再也不想见第二次索引空间膨胀得厉害一张亿级订单表光主键索引就能多占几十GB磁盘查询还因为随机IO频繁触发慢日志。2.4 主键要不要参与路由有一类分库分表实践会把“主键本身”当成路由键典型是消息表、流水表按主键哈希路由。这种情况主键的设计和路由键是耦合的好处是查询单条记录时直接定位分片避免广播。坏处是一旦你要按业务维度查比如查某个用户的所有消息还是要走二级索引或全局索引。如果你的主键是雪花ID而路由键是user_id那么通过主键查询本身就是一个“非路由查询”每次都得广播。这时可以考虑在中间件里配置主键映射表或者把“主键查询”改造成先查索引表再查数据。在我做过的业务里订单主键和路由键是分开的路由键是buyer_id主键用雪花ID。这样确实存在“按订单号精确查”要广播的问题但通过全局索引表解决之后整体可控。这个方案我放在第四部分专门讲。3. 二级索引设计本地索引不是万能钥匙3.1 分片内二级索引的局限二级索引在分库分表里的第一层含义是“分片本地的二级索引”。比如分片1里的订单子表有一个idx_merchant_status(merchant_id, status)在分片内查询时它肯定能加速。但如果SQL没走路由键中间件最终会把这个查询广播到所有分片每个分片都会用各自本地的二级索引查出结果然后统一返回上层做合并。这样的做法在数据量小、分片少的时候可行比如2个分片广播代价不高。但分片多了之后性能会呈线性恶化。更大的隐患是“limit m, n”这种分页MySQL的limit是分片内limit不是全局limit要取第100页的数据每片都得先把offsetlimit条数据全部查出来差的数据越多内存、带宽、耗时都受不住。因此在设计二级索引时不能只想着给SQL加上索引就跑得快还要估算“这条SQL会被广播多少次”。建议规则如果一条SQL必须广播而且业务上高频就应该考虑用全局索引方案而不是靠本地二级索引硬扛。3.2 什么是基因法让部分字段也带上路由信息一个很巧妙的优化思路是“基因法”。核心思想是在设计表结构时把路由键的一部分信息冗余到需要跨分片查询的字段上使得这些字段天然能推导出路由键。举个例子订单号可以设计为16位雪花ID 4位user_id哈希后缀。这样当你拿着订单号查询时可以直接从订单号末尾的4位推导出它属于哪个分片根本不需要广播。这就是把“全局唯一ID”和“路由基因”合并解决了“按订单号查询无法直接路由”的痛点。基因法说起来简单实现细节里全是坑。首先路由键哈希后要保证均匀分布不能让某个后缀占比过高其次订单号的长度要严格控制加太长会影响存储和索引大小最后分库分表中间件不一定支持从字段里抽取路由信息有的需要你在SQL里显式带一个路由键比如select * from order where user_id 基因函数(ord_no) and ord_no ?这是可行的。我实际用的就是对订单号做一次哈希后置入用户ID的低位查询时调用同一算法算出可用路由键效果非常稳查询耗时从广播的200ms降到10ms以内。3.3 冗余字段用空间换查询效率二级索引设计里还有一种常见做法把高频查询的字段直接冗余到原表里。比如订单表经常按supplier_id查那在拆分时直接把supplier_id作为冗余字段存到每个分片里再在分片内建索引。这样仍然没法避免广播但至少每个分片能利用二级索引加速从“全表扫描”降到“索引范围扫”。更进一步的玩法是“双路由键”把表同时设计成既能按user_id查又能按supplier_id查。实现方式一般是维护一张映射关系表或者在中间件里配置多个路由策略。但这涉及数据的一致性、去重、扩容复杂度直接上升一个档次。如果业务不是特别刚需我建议用全局索引表方案代替双路由键维护成本更低。4. 全局索引设计让非路由查询也能快起来4.1 索引表方案的完整链路所谓全局索引最简单的实现就是“用一张索引表记录业务字段和路由键的映射”。比如订单分片表按user_id路由但用户会经常根据order_no查订单那我们就建一张order_no到user_id的映射表物理上不参与路由或用order_no作为路由键。查询链路变成两步根据where条件在索引表中查出对应的路由键user_id。用user_id作为路由键到分片表中精确查询那一个分片。第一步访问的索引表本身也要分库分表的话通常按order_no做路由因为查询条件就是order_no。第二步就回到了路由键查询速度快、不广播。这张索引表本质上是“二级索引的分布式版本”只是它的存储和查询要单独设计。代价是写入时多写一张表多一次分布式事务但查询性能提升是质的飞跃。我在订单服务里就是这种结构索引表只存order_no、user_id、create_time三个字段单行很小也没啥更新场景写入量比订单表少几个量级所以很好维护。4.2 如何维护索引表与数据表的一致性有索引表就有数据一致性问题。分片表写入订单时必须同时写索引表如果其中一个成功一个失败数据就“漂移”了。这块工程上通常有几种做法一种是基于本地消息表和异步任务。在写订单的同一个数据库事务里也插入一条“待建索引”的任务记录然后异步把索引信息同步到索引表。利用本地事务保证订单写成功了任务就一定存在再用定时任务扫描未完成任务驱动索引同步。这种方式有秒级延迟但对业务侵入小且不容易阻塞主链路。另一种是基于分布式事务中间件比如Seata的AT模式让订单分片表和索引表之间的事务状态保持一致。优点是强一致缺点是要部署中间件对性能和系统复杂度有影响。我在索引表这类“冗余数据”场景中一般偏向第一种异步最终一致因为索引表允许短时间的缺失但绝不能长期不一致。需要特别注意索引表里的数据要尽量避免更新和删除如果业务确实需要那就要记录操作日志在异步任务里对相同order_no做版本校验或更新判断否则高并发下可能把旧值覆盖新值。4.3 全局索引的另外两种形态广播表与数据冗余除了索引表还有两种常用的全局索引形态。第一种是“广播表”。把一些不经常加字段、数据量很小的配置型数据直接在每个分片中存一份完整副本。比如商品类目表所有分片都有相同的数据查询时直接本地查不需要全局索引。这种方案适合“几乎只读、极少变化”的数据性能极好问题在于更新时要在所有分片同步如果同步失败各分片的数据就不一致。实践中通常用消息队列广播操作加上全表刷新兜底。第二种是“数据冗余”。订单分片表按user_id路由但如果按merchant维度查询也非常高频那干脆在建一张按merchant_id路由的“商家订单索引表”冗余订单号、用户ID、金额、状态等核心字段。查询商家订单直接查商家订单表拿到完整数据再按需回原表补详情。这个方案等于“用空间换查询”在报表、运营后台这类场景特别实用。缺点也很明显同一订单存了不止一份业务要接受一份冗余存储和一次额外的广播写入。这三种方案的选型我总结成下表方案实现成本一致性查询性能适用场景索引表映射式中最终一致即可精确查询极快范围查询一般按唯一键或高频维度反查路由键广播表低需保证全分片同步每个分片本地查无广播配置类、字典类小表数据冗余索引表高多写多存需分布式事务或异步按冗余维度查询极快运营后台、商家报表、多维查询4.4 如果分片中间件已经支持全局索引如果你的分库分表中间件是ShardingSphere这类的它提供了全局索引的理论模型实际落地时也要注意。ShardingSphere可以把一张逻辑表绑定多个分片路由策略允许同一张表同时按user_id和order_no路由这就需要你维护“分片键与关联键的映射”本质上也是索引表那一套。但从数据一致性维护看中间件不会替你解决多表写一致问题该做的异步同步、对账你还是跑不掉。所以我不建议一上来就上全局索引的中间件“魔法”最好先梳理业务到底有哪些“非路由查询”再判断值不值得额外引入复杂度。5. 实操案例一个订单表从0到1的索引设计5.1 场景假设与表结构设计假设现在要设计一个订单系统单表数据量预估到十亿路由键选buyer_id用32个分片。核心查询场景有几种买家查自己的订单、后台按订单号查详情、客服按商家查订单、财务按时间范围导出。我把表结构大致设计如下-- 订单分片表按 buyer_id 哈希分片 CREATE TABLE t_order_{0..31} ( id bigint NOT NULL COMMENT 雪花ID主键, order_no varchar(64) NOT NULL COMMENT 业务订单号含用户ID基因, buyer_id bigint NOT NULL COMMENT 买家ID路由键, seller_id bigint NOT NULL COMMENT 卖家ID, amount decimal(12,2) NOT NULL, status tinyint NOT NULL COMMENT 1待支付 2已支付 3已完成 4已取消, create_time datetime NOT NULL, update_time datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_buyer_status (buyer_id, status, create_time), KEY idx_seller_status (seller_id, status, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里出现了一个应该注意的地方既然按buyer_id路由那uk_order_no这种全局唯一索引在分片内是无法保证全局唯一性的。分片A里的order_no1001不影响分片B里也存一个order_no1001除非用基因法保证全局唯一。所以前面说order_no要设计成“自带路由基因”就是为了能让这个唯一键在全局成立。我用的是“雪花ID买家ID低4位”生成出的order_no虽然比纯订单号长一点但全局唯一插入分片时直接根据order_no后缀确定分片位置。这样按order_no查询时也可通过同一个函数反解出buyer_id来定位分片从而实现“基于订单号的精确查询”从广播变成单点查询。5.2 二级索引如何配合分片键使用对买家查询场景直接走buyer_id作为路由键再配合idx_buyer_status联合索引性能非常可控。查询语句长这样select * from t_order where buyer_id ? and status 2 order by create_time desc limit 20;因为有buyer_id路由中间件只查1个分片索引能覆盖status和create_time排序执行计划会走idx_buyer_status速度很理想。如果要做分页也是在这个分片内局部limit业务上买家通常只看前几页问题不大。但对商家查询场景seller_id不是路由键仍然要广播。我们的后台对实时性要求没那么高所以用了第二种策略维护一张商家订单索引表按seller_id路由只存必要字段CREATE TABLE t_seller_order_index_{0..15} ( id bigint NOT NULL, order_no varchar(64) NOT NULL, buyer_id bigint NOT NULL, seller_id bigint NOT NULL, amount decimal(12,2) NOT NULL, status tinyint NOT NULL, create_time datetime NOT NULL, PRIMARY KEY (id), KEY idx_seller_status_time (seller_id, status, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这张索引表由订单服务通过异步消息同步从订单分片表写入成功之后发布一条订单变更消息消费者解析后写入商家索引表。查询场景变成了按seller_id路由商家索引表然后select *相当于走idx_seller_status_time返回的字段已经可以支撑列表展示如果还要看商品明细再用order_no回原订单表查。实际测试下来在32分片订单表上原来广播查询商家订单平均要120ms左右并发一高就超时。改成商家索引表后P99耗时稳定在25ms上下效果非常明显。5.3 全局索引表与对账补偿机制索引表我们用的是异步最终一致那怎么保证最终一致我设计了一个补偿任务每分钟扫描一次“事件表”。事件表跟订单表在同一个库内订单事务提交前插入一条sync_event记录状态为pending。异步消费者处理成功后把状态置为done。补偿任务负责把pending超过2分钟的事件重新投递。同时每个月跑一次全量对账核对订单表与商家索引表的数据量、金额sum、状态分布是否一致不一致的用订单表数据修补索引表。这套机制不复杂但非常抗打。中途遇到过MQ积压、消费者被kill、索引表写入超时等问题都能靠补偿任务兜底半夜醒来看到告警再处理也不至于造成线上不可用。所以我经常跟团队说全局索引最大的难点不在查询设计而在数据一致性兜底。千万别指望一条消息就能保证100%同步必须有重试和对账。5.4 时间范围查询和分页怎么处理财务按时间导出这种诉求本质上没有均匀分布的路由键很难在索引层面优化。我们的做法是按create_time的月份建“归档表”每个月一张大宽表存的是上个月全量订单数据并在导出时并行拉取所有分片但限制每次最多并发8个分片避免瞬间打爆数据库。单分片内的create_time索引也能帮上忙但全局范围查询无论怎么做都会有性能天花板最靠谱的做法是分析场景落地到数仓或离线表别把实时库拖垮。分页这块如果是买家侧的“我的订单”我强烈建议“游标分页”代替offset深度分页。比如每次查询都传last_create_time和last_idSQL写成select * from t_order where buyer_id ? and status 2 and (create_time, id) (?, ?) order by create_time desc, id desc limit 20;这样每页查询都是稳定的索引前缀匹配即使翻到第100页也不会出现“扫描大量偏移行然后丢弃”的情况。如果产品上必须支持跳页那也别在实时库上做用搜索引擎或离线数仓支撑。6. 常见问题与排查经验分布式索引的坑一个个说6.1 明明建了索引走了却还是全分片广播排查这类问题第一步不是看SQL而是问这条SQL带路由键了吗如果没有中间件做广播是预期的不是索引的问题。第二步是看执行计划确认每个分片是不是真的走到了索引。我经常看到有人把联合索引建反了比如idx_status_seller (status, seller_id)但查询条件是seller_id status索引最左前缀原则直接失效每个分片都在全表扫。调优方法很简单把等值条件的字段放在联合索引最前面区分度高的放前面。6.2 唯一索引全局唯一性限制分库分表后数据库内的唯一索引只在单分片内有效。如果业务需要全局唯一又不能用基因法那常见方案有两个。一个是用Redis的setnx或分布式锁来检查唯一性缺点是依赖缓存且并发下性能一般适合低频操作。另一个是建全局唯一索引表主键就是业务唯一键插入前先insert这个索引表成功了再写分片表保证全局唯一。我在操作“订单号唯一”时靠基因法在应用层解决了但在另一套“用户手机号唯一”场景因为手机号和user_id没有固定数学关系就建了一张user_phone_index表手机号作为主键注册时先抢这个主键抢到了再写用户分片表有效避免并发下手机号重复注册。6.3 索引表写入与数据写入的分布式事务如果业务要求索引表和数据表强一致简单粗暴的“先写业务表再写索引表”一定有问题。建议用本地消息表异步任务或者用Seata这类分布式事务中间件。我个人的推荐是异步最终一致前提是业务能接受秒级延迟。对于那种“索引表缺失会导致资损”的场景比如支付流水反查那再上分布式事务也不迟。当时的经验是分布式事务中间件用起来很爽但出了问题难排查。有一次Seata全局事务回滚时业务分片表和索引表的undo_log对不上数据愣是卡了半天。从那之后我对“非核心数据同步”的诉求一律降级为异步只有资金类强一致才用分布式事务。6.4 表数量扩容后索引怎么调整分库分表最怕扩容。假设从32片扩到64片老的订单数据已经按旧路由键分布新的数据用新路由规则写入老数据要平滑迁移索引表也得跟着切。索引表的扩容相对简单因为它是从数据表派生出来的可以重新从分片表全量重建。如果担心全量重建耗时长可以对每个分片表按create_time分批导出再按新路由键导入新的索引表。整个过程建议先用影子表演练一遍别在生产直接切。6.5 缓存在分布式索引设计里的位置最后说一个心得别所有东西都堆到数据库索引上。很多高频的“非路由查询”与其设计复杂的全局索引不如直接在Redis里建立映射。比如“订单号到buyer_id”的映射可以用Redis Stringkey是order_novalue是buyer_id写入时和订单表同步刷新查询时先走Redis命中就直接定位分片不命中再走索引表。Redis的带宽和延迟都比数据库索引响应快而且天然具备全局视图。代价是缓存一致性和过期策略要设计好。我在高并发查单链路就是“Redis映射 索引表兜底 本地缓存兜底”三层结构实测下来性价比很高。落地的顺序建议如果你现在正在做分库分表的索引设计我建议按这个顺序推进先梳理所有高频SQL分类为“带路由键查询”“非路由键精确查询”“非路由键范围查询”“后台分析查询”。带路由键的确保每个分片的联合索引最左前缀正确非路由键精确查询引入基因法或索引表目标是消灭广播范围查询和后台分析尽量别在实时库做能走缓存走缓存能走数仓走数仓。整个过程量力而行别为了“全局索引”这个概念本身承担过高的复杂度。踩过这么多坑之后我最大的体会是分布式环境下的索引设计永远是在查询性能、存储成本、一致性和开发运维复杂度之间找平衡。没有一套方案适合所有场景真正靠谱的永远是把你自己的业务查询彻底梳理清楚之后再选择对应的设计模式。希望这篇能让你少走一些我走过的弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价