
1. 分片之后真正疼的地方在哪上周一个做交易系统的朋友甩过来一个具体问题订单表拆成32个分片之后用户端查“我的订单”很快客服后台按订单号查也很快偏偏运营一按订单号批量查就完蛋——订单号里没带用户信息路由不出去只能广播到32张表再合并。他问我有没有办法让订单号自己“知道”该去哪张表。这就是基因法要解决的问题。说白了基因法是一种把分片键的信息提前编织进业务主键的路由设计。它让订单ID本身携带足够的位信息任何人拿到这个ID不查用户表、不走映射关系、不维护内存里的哈希环直接做几次位运算就能定位到唯一分片。听起来不难但真正落地的时候位宽怎么切、分片数为啥必须是2的幂、扩容怎么办、哪些查询它压根治不了这些才是决定方案能不能上生产的关键。这篇我按自己带过的几个项目的实际做法来写从ID的二进制结构一路讲到扩容迁移参数和代码都是能直接抄的。这篇适合已经踩过分库分表第一轮坑、现在被跨分片查询折磨的中高级后端看。如果你还在选型阶段前两节的方案对比表也能帮你判断该不该上这套东西。1.1 两条查询路径的天然冲突分库分表的第一道选择题永远是分片键选谁。订单表最自然的答案是用户ID因为“查我的订单”是最高频的查询走用户ID分片能保证单分片命中性能最好。但这个选择会立刻带来第二个问题——按订单号查怎么办。订单号是全局唯一主键但它和用户ID之间没有任何数学关系你没法从一串随机生成的ID里反推出它属于哪个用户。于是所有按订单号的查询都变成了广播32个分片挨个发SQL结果在内存里归并。单次查询的RT从个位数毫秒变成几十毫秒QPS一上来数据库连接池先扛不住。反过来也一样。要是你为了订单号查询方便把分片键改成订单号本身那“查我的订单”就得广播——这个查询频次比按订单号查高一个数量级等于用更大的代价换更小的收益。运营侧一个低频操作不值得让C端主链路陪葬。这就是矛盾的本质分片键只能有一个但业务上有两个维度的查询都必须是单分片命中。传统解法有两条——要么维护一张映射表把订单号和分片位置记下来查之前先查映射要么把订单维度数据同步一份到以订单号分片的另一组表。两条路都是拿存储和一致性换查询效率。1.2 三种常见路由方案摆在一起比我把实际项目里见过的几种做法拉出来对比参数取的是一个典型的中型交易场景单表目标3000万行总数据量约10亿分片总数32。方案路由方式单次查询开销额外存储一致性风险扩容难度映射表订单号→分片先查映射表再查业务表2次查询映射表本身也要分片与订单表1:1中写订单和写映射需同事务或补偿中异构索引表订单维度双写按订单号分片的第二套表1次查询接近2倍高需处理双写失败高基因法从订单号位运算解析1次查询0次索引查找无无路由信息在ID里高需全量重排这张表最关键的一行是“一致性风险”。前两种方案都在系统里引入了第二份必须保持同步的数据而基因法把路由信息直接编码进主键主键写下去的那一刻路由就确定了不存在同步一说。这是它最大的结构性优势——用ID的位空间换掉了一个分布式一致性问题。代价也很清楚扩容那栏它是“高”。这个后面单独开一节讲是基因法最难看的地方。1.3 基因法到底“基因”在哪“基因”这个词是个比方但挺贴切。生物体的每个细胞里都带着完整的遗传信息随便取一个细胞就能还原这个个体。基因法追求的是同一个效果订单号是业务主键随便拿一个订单号就能还原出它的分片归属。实现上就是把用户ID的低若干位切下来嵌进订单号的固定位置。取几位取决于你的分片总数必须是2的幂。取5位就是32分片取6位是64分片取10位是1024分片。这个位段一旦嵌进订单号它就和订单号永久绑定了后续任何解析都是纯位运算无状态、O(1)、不依赖任何外部服务。有个容易混淆的点得说清楚基因法不是雪花算法的替代品它是雪花算法的一个变种用法。雪花算法本来就是“时间戳机器ID序列号”的拼接基因法只是把机器ID那一段或者额外挤出来的几位用来存用户基因。它俩不是二选一的关系。提示如果团队里有人把基因法理解成“在订单表里加一列存分片号”那是误解。存成一列意味着你还要在查询时先读出来等于退化成映射表。基因的核心价值就在于它藏在主键的二进制位里不需要额外字段不需要额外读取。2. 位运算拆解把用户ID塞进订单号2.1 64位长整型怎么切我用 long64位来装因为Java生态里几乎所有分库分表中间件、所有数据库的bigint都支持不用额外序列化。切法如下这是经过几个项目验证过比较均衡的一种位段位数用途取值范围bit 63 ~ 2341相对纪元的时间戳毫秒约69.7年bit 22 ~ 176工作节点ID0 ~ 63bit 16 ~ 125用户基因位0 ~ 31bit 11 ~ 012毫秒内序列号0 ~ 4095算一下总位数41 6 5 12 64刚好占满没有浪费。再算吞吐能力。每毫秒每个工作节点能出 2^12 4096 个ID64个工作节点合计就是 4096 × 64 × 1000 ≈ 2.6亿/秒。对绝大多数业务这个天花板根本摸不到真正的瓶颈永远在数据库不在ID生成器。时间戳部分为什么要用相对纪元而不是直接存 Unix 毫秒因为直接存的话41位只能覆盖约69.7年从1970年算起早就过了一半而且高位全是1看着别扭。我一般取项目立项那一年的1月1日做纪元把时间戳整体左移能多用几十年。这个参数一次定死之后不要再改改了历史订单号全部解析错误。基因位为什么取5位因为32分片对中型业务够用而且5位挤进64位结构里压力最小。如果你的预判是最终会到512分片那就一开始取9位序列号相应压缩到8位每毫秒256个多节点汇总也够。宁可序列号短一点也别让基因位不够用——基因位改了等于所有历史ID作废这个代价和扩分片是同一量级。2.2 分片总数必须是2的幂这是硬约束这一条是基因法的物理定律没有变通空间。因为取基因用的是按位与long gene userId (shardTotal - 1);只有当shardTotal - 1的二进制是全1的时候也就是 shardTotal 是2的幂这个与运算才等价于取模并且结果均匀分布。分片总数是24、30、36这种非2的幂掩码就不是全1位与的结果会缺失一部分值导致某些分片永远空着另一些分片吃双倍流量。如果业务上真的需要30个分片怎么办我的做法是向上取到32建32个分片但只启用其中30个把多出来的2个分片当作预留。这样ID结构不变路由代码不变只是有一小部分分片的连接池闲置。浪费的那点资源远小于改ID结构的成本。还有一个附带结论分库数、分表数也各自应该是2的幂至少要让它们的乘积是2的幂。我见过有人定4库×6表24分片然后强行用基因法结果永远有8个表是空的。这种错误排查起来极其痛苦因为日志上看所有SQL都成功只是数据分布表偏斜等发现的时候已经跑了几个月数据。2.3 库表索引从基因值怎么推出来基因值gene的取值范围是 0 ~ 31需要映射到具体是哪个库、哪张表。映射方式有多种核心原则只有一个让连续的用户ID均匀散到不同的库里避免热点。最直觉的写法是dbIndex gene / 8、tableIndex gene % 8假设4库8表。但这样有个毛病userId从0开始递增时前8个用户全落在库0第9到16个用户全落在库1热点会成块聚集。生产上更常用的是交叉映射int dbIndex gene 3; // 低2位定库4个库 int tableIndex gene 2; // 高位定表8张表这样 userId 为 0、1、2、3 的用户分别落到 4 个不同库的 0 号表5、6、7、8 落到 4 个不同库的 1 号表天然形成轮转写入压力均匀。这个细节不在任何官方文档里但凡是线上跑过的系统基本都是这么干的。验算一下。假设 userId 12345二进制是11000000111001。gene 12345 3131的二进制是11111取低5位得到11001也就是十进制 25。dbIndex 25 33的二进制11取低2位得01也就是 1落到1号库。tableIndex 25 2得110十进制 6落到6号表。从库表反推验证1号库6号表对应的 gene 应该满足gene 3 1且gene 2 6。符合条件的 gene 是110 01 25唯一解。路由双射成立不会出现两条数据落在同一位置却解析出不同结果的情况。3. 从建表到上线一套32分片的完整落地3.1 表结构与分片规则的约定物理表就用order_0到order_7这样的命名每个库一套共4套。所有的分片配置写在一个常量类里绝不允许散落在各处public final class ShardConfig { public static final int GENE_BITS 5; public static final int SHARD_TOTAL 1 GENE_BITS; // 32 public static final int DB_COUNT 4; public static final int TABLE_COUNT 8; // 4 * 8 32与 SHARD_TOTAL 必须相等 static { if (DB_COUNT * TABLE_COUNT ! SHARD_TOTAL) { throw new IllegalStateException(分片配置不自洽); } } }这个静态校验块看着多余但我真的见过因为中途调整配置导致库表数乘积和基因空间对不上的事故。加上这两行启动时直接崩掉比跑一个月之后发现数据偏斜好得多。订单表里必须冗余存一份 user_id。很多人觉得基因已经编进订单号了还存 user_id 干嘛。原因有两个一是运营后台按用户查订单时需要where user_id ?这个条件没有这列就没法建索引二是扩容迁移时你需要用它重算基因位否则历史数据没法搬到新分片。这条是硬要求不是可选项。3.2 订单号生成器的实现细节生成器用单例每个应用实例初始化时分配一个唯一的 workerId。分配方式可以是启动时向注册中心抢位也可以是配置文件里写死。写入量不大的业务写死更省事。public class OrderIdGenerator { private static final long EPOCH 1704067200000L; private static final int GENE_BITS 5; private static final int WORKER_BITS 6; private static final int SEQ_BITS 12; private static final long SEQ_MASK (1L SEQ_BITS) - 1; private static final long GENE_MASK (1L GENE_BITS) - 1; private static final long WORKER_MASK (1L WORKER_BITS) - 1; private final long workerId; private long lastTimestamp -1L; private long sequence 0L; public OrderIdGenerator(long workerId) { if (workerId 0 || workerId WORKER_MASK) { throw new IllegalArgumentException(workerId 越界); } this.workerId workerId; } public synchronized long nextId(long userId) { long now System.currentTimeMillis(); if (now lastTimestamp) { long offset lastTimestamp - now; if (offset 5) { // 小幅回拨自旋等待 now waitUntil(lastTimestamp); } else { throw new IllegalStateException(时钟回拨过大: offset ms); } } if (now lastTimestamp) { sequence (sequence 1) SEQ_MASK; if (sequence 0L) { now waitUntil(lastTimestamp 1); } } else { sequence 0L; } lastTimestamp now; long gene userId GENE_MASK; return ((now - EPOCH) (WORKER_BITS GENE_BITS SEQ_BITS)) | (workerId (GENE_BITS SEQ_BITS)) | (gene SEQ_BITS) | sequence; } private long waitUntil(long target) { long now System.currentTimeMillis(); while (now target) { now System.currentTimeMillis(); } return now; } }这里有两个实现选择值得展开。时钟回拨那一段我没有直接抛异常。小于5毫秒的回拨在虚拟机迁移、NTP微调的场景里很常见直接抛会让一批下单请求失败。自旋等待几毫秒用户体验上无感。超过5毫秒才认为是真异常抛出去让上层走重试。这个阈值是我根据实际日志里回拨幅度的分布调的你们可以根据自己的环境调整但不要设成0也别设成几百毫秒——自旋太久会拖垮线程池。序列号用完的处理也值得一提。同一毫秒内序列号加到4096会回绕到0这时候必须等下一毫秒不能直接复用。这段逻辑如果写错会在压测的时候表现为极小概率的主键冲突而且只在QPS刚好的时候触发非常难复现。压测阶段务必把QPS压到每秒40万以上跑一遍。3.3 查询侧的路由改写解析部分的代码比生成简单得多但同样要封装成工具类禁止业务代码里裸写位移public final class OrderShardRouter { private static final int SEQ_BITS 12; public static int geneOf(long orderId) { return (int) ((orderId SEQ_BITS) 31L); } public static int dbIndexOf(long orderId) { return geneOf(orderId) 3; } public static int tableIndexOf(long orderId) { return geneOf(orderId) 2; } public static String tableNameOf(long orderId) { return order_ tableIndexOf(orderId); } public static long geneOfUser(long userId) { return userId 31L; } }业务层拿到订单号直接调tableNameOf就能拼出物理表名中间件拿到订单号做分片路由也是同一套逻辑。生成侧和解析侧必须共用同一份位宽常量这一点比代码本身重要。我见过生成器用的5位基因、路由层因为历史原因还在用旧版的4位上线后一半订单查不到回滚花了两个小时。按用户ID查的场景更简单因为基因相同意味着分片相同-- 路由计算出唯一分片只发往 order_6 select * from order_6 where user_id ? order by create_time desc limit 20;这条SQL有个隐含前提一个用户的所有订单确实都在同一个分片里。因为基因只取 userId 的低5位同一个 userId 每次算出来的 gene 必然相同所以结论成立。但反过来不成立——userId 0 和 userId 32 的基因都是0落在同一个分片。这不是问题只是分片内数据量会比按用户数平均估算的略多一点容量规划时把系数乘个1.2就够。3.4 上线前必须补的两块第一块是对账。基因法本身的正确性靠位运算保证但业务代码里但凡有一处用了别的分片键生成订单就会出现“订单号解析出来的分片”和“实际写入的分片”不一致。这种脏数据平时不暴露等按订单号查的时候才发现查不到。上线前写一个巡检任务从每个分片抽样拉订单号重新解析一遍和实际所在分片比对不一致就告警。第二块是分片均衡度监控按天统计每个分片的行数和QPS画成柱状图。正常情况下32根柱子高度应该差不多如果某几根明显高出一截多半是基因位宽或者映射方式出了问题。注意均衡度检查一定要在灰度阶段做不要等全量数据灌进去再看。数据灌进去了再发现问题改结构意味着全量重刷代价差着好几个数量级。4. 扩容基因法最难看的那道坎4.1 为什么不能像一致性哈希那样平滑扩先说清楚这个问题一致性哈希解决的问题是“节点增减时尽量少搬数据”它牺牲的是路由的确定性——你得维护一个哈希环查询时在环上做二分查找节点数变了环也要重建。基因法反过来路由是确定性的、无状态的、O(1)的但它对分片数变化极其敏感。敏感在哪假设原来32分片基因取低5位。现在要扩到128分片基因得取低7位。同一个 userId12345原来12345 31 25新的12345 127 57。分片位置从25变成57历史订单还在25号分片里但按新规则解析出来的地址是57直接查不到。更麻烦的是历史订单号里存的基因位只有5位就算你知道 userId 也没法从旧订单号里读出7位基因——那两位压根没存过。所以扩容时必须靠订单表里冗余的 user_id 重新计算这也是我前面强调 user_id 必须存的原因。4.2 一次性按终局规模设计基因位宽有没有办法绕开这个问题有一个技巧我特别推荐叫基因位宽预留。做法是立项时直接按终局规模定基因位宽。比如预判这套系统5年内会到1024分片那基因位就直接切10位ID结构一次定死。初期只建32个分片时路由代码这么写long gene userId 1023L; // 完整的10位基因永久存进ID int shard (int) (gene % 32); // 当前启用32分片 int dbIdx shard 3; int tblIdx shard 2;扩容到128分片时只需要把路由代码里的% 32改成% 128ID生成逻辑一行不动。历史订单号里那10位基因一直是完整的任何时候都能正确解析。数据仍然要按新路由搬迁但不需要回溯重建任何历史订单号——这个差别在实际操作里是灾难级和可控级的区别。代价是序列号位宽被压缩。10位基因 6位工作节点 41位时间戳 57位序列号只剩7位每毫秒128个。单节点128个/毫秒听起来少但配合6位工作节点64个实例总吞吐还是819万/秒。真到瓶颈了把工作节点位压到4位、序列号加到9位重新评估。4.3 双写迁移的完整步骤如果没有做位宽预留那就只能老老实实迁移。完整流程我走过一次大概是这样第一步建新集群128个空分片新老两套表结构完全一致。老系统订单号生成器继续用旧规则新订单号结构不变。第二步开双写。写订单时同时往老分片和新分片各写一份用本地消息表保证最终一致——先写本地事务表记录待写入任务异步线程消费失败重试。这个阶段读还是走老分片新分片只是持续积累增量。第三步全量迁移历史数据。按 userId 分页扫描老分片的每一行用新规则算出目标分片批量写入新分片。这里要注意扫描必须按主键游标推进不能用 limit offset否则越到后面越慢。每批500到1000行批间 sleep 一点避免把线上库 IO 打满。第四步数据校验。这一步千万别省。校验方式是按 userId 抽样比对老分片和新分片里同一用户的订单条数和金额合计。抽样比例至少5%重点抽样大客户和最近30天的数据。发现不一致就停下来查不要抱着“小概率不影响”的心态往下走。第五步切读。先切1%的读流量到新分片观察一周没问题再放到10%、50%、100%。每一步都要盯住按订单号查和按用户查两条链路的错误率。第六步关闭双写老分片降级为只读保留三个月后下线。整个迁移周期以10亿行数据量估算我那次用了大约六周其中光数据校验就占了两周。这个时间不要在排期里压缩校验省下来的时间出问题时要翻倍还回去。提示双写期间如果出现新分片写入失败不要让订单主流程跟着失败。用 try-catch 包住新分片的写入记录失败日志交给补偿任务重试。主流程的成功率永远优先于数据完整性完整性靠后续补偿和时间换。5. 踩坑实录与问题速查5.1 常见问题排查表下面这些是我和身边同行真实踩过的按出现频次从高到低排现象大概率原因排查手段处理方式按订单号查不到但按用户查得到生成侧和路由侧基因位宽不一致打印订单号的二进制手工解析统一常量类加启动自检部分分片行数是其他分片的3倍以上分片总数不是2的幂掩码非全1检查 SHARD_TOTAL 的二进制向上取到2的幂重建同一毫秒并发下单出现主键冲突序列号回绕后未等待下一毫秒压测到序列号边界修正 waitUntil 逻辑扩容后历史订单查不到基因位宽变更旧ID解析错位对比新旧解析结果用冗余 user_id 重刷数据基因法订单号被财务系统拒绝订单号非连续递增被当成异常核对财务系统的校验规则加一层业务流水号对外跨分片 count 特别慢有报表在做全表聚合慢查询日志异构到分析型存储别在业务库聚合倒数第二条挺值得说的。基因法出来的订单号不是严格连续递增的同一毫秒内会因为基因位不同而乱序而且中间有空号不同基因占位。如果对接方比如财务、风控有“流水号必须连续”的隐含假设就会出问题。解决办法是维护一个独立的业务流水号序列对外展示用它订单号只在系统内部流转。5.2 几条不写在文档里的经验第一条订单号的位数不要为了好看去凑整。有人为了订单号是20位整数硬生生砍掉序列号位宽换来的是每毫秒只能出16个ID压测直接卡死。ID是内部主键长度不重要唯一和有序才重要。第二条基因值不要直接用 userId 的原始低位先做一次混淆。虽然用户ID种子通常也是自增的但万一某个业务线用了连续的用户ID段低位就会高度集中。加一步int mixed userId ^ (userId 16)再取低位代价极小分布更散。这个操作纯属防御性设计没有它也能跑但线上出问题的时候你会庆幸加了。第三条分片数的选择要给未来留一倍余量。现在算出来32分片够用那就按64分片来设计基因位初期只建32个。多出来的一位基因不占什么成本但省掉了一次位宽变更。第四条不要用基因法去解决它解决不了的问题。下面这节专门讲。6. 能力边界基因法治不了什么6.1 三条它明确无能为力的查询基因法只对“分片键本身”和“编码了分片键的主键”这两条路径有效。超出这个范围它一点忙都帮不上。按商家维度查订单就是一个典型。商家ID和用户ID之间没有数学关系一个商家会有一万个不同用户的订单散在所有分片上。你拿到商家ID无论怎么位运算都算不出分片位置。这个场景的正确解法是给商家维度单独建一套异构表或者把订单数据同步到搜索引擎按商家建索引。基因法在这里不是不好用是根本用不上。还有一类是范围查询比如“查昨天所有超过500元的订单”。这类查询的条件里既没有用户ID也没有订单号路由信息完全缺失只能全分片扫描。这种需求的正确姿势是走离线数仓或者分析型数据库别指望OLTP分片库能扛。第三类是跨分片聚合比如“统计今天各分片的订单总额”。基因法能让每个分片独立统计但合并这一步绕不开。数据量大的时候合并本身就成了瓶颈。这类需求同样建议分流到分析型存储。注意判断一个查询能不能走基因法只需问一句——查询条件里有没有 user_id 或者完整的 order_id。有就是单分片没有就是广播。这个判断标准简单到可以写进代码规范里让每个开发自己就能判断不用每次来问你。6.2 什么时候别用基因法分片总数固定不了、未来一定会大幅调整的场景慎用。比如一个还在快速试错的业务半年内从10万单涨到1亿单分片数从8改到256改了三回每次都要全量迁移团队会被拖垮。这种场景更适合先不拆或者拆得粗一点等业务形态稳定了再重构。订单表里不存 user_id 的场景绝对不能上。基因法的扩容完全依赖这个字段重算基因没有它历史数据就是一堆无法归位的孤儿。对主键有严格单调递增要求的场景也要掂量。基因法插在中间的基因位会打乱同毫秒内的顺序虽然整体趋势还是递增的但对某些强依赖严格顺序的下游比如某些变更数据捕获链路可能需要额外适配。最后一条是我个人的判断标准如果团队里没有人愿意为这套方案写文档、写巡检、写迁移脚本那就别上。基因法的核心逻辑只有几十行代码但围绕它的运维成本、迁移成本、排障成本远远超过那几十行。选型的时候把这些算进去才不会被方案本身的简洁性骗了。我自己的体会是分库分表这件事方案本身没有高下只有匹不匹配。基因法最适合的场景就三个条件同时满足有一条天然的、高频的分片键同时存在另一条必须单分片命中的查询路径团队有能力承担扩容迁移的工程成本。三条都占它就是我见过最干净的解法缺一条就该老老实实去看异构索引表。