分布式数据库在分库分表场景下,核心难题就两个:数据该往哪个库哪张表写(路由),以及怎么生成全局唯一且有序的ID(全局序列号)。中间件要解决的就是这两件事——路由决定数据落点,全局序列号保证数据标识不冲突。具体做法是:路由层通过分片键(sharding key)配合哈希或范围算法,将SQL精准转发到目标物理节点;全局序列号则通过雪花算法、号段模式或数据库自增步长等方式,在多节点环境下生成不重复、可排序的唯一ID。下面把这两块内容拆开来讲透。

一、分库分表路由的核心逻辑

路由是中间件的第一道关卡。当业务SQL打到中间件时,中间件必须在毫秒级时间内判断这条SQL该发到哪个数据库实例、哪张物理表。路由策略选错,轻则查询走全库扫描,重则数据写错位置导致业务错乱。

目前主流的路由算法有三种。第一种是哈希取模,比如对用户ID做MD5后对表数量取模,优点是数据分布均匀,缺点是扩容时需要数据迁移。第二种是范围分片,比如按订单ID的区间划分,1-1000万在表A,1000万-2000万在表B,优点是范围查询效率高,缺点是容易产生热点。第三种是一致性哈希,通过虚拟节点解决扩容时数据大规模迁移的问题,适合节点频繁变动的云环境。

路由不只是"写"的问题,"读"同样关键。中间件需要解析SQL类型,判断是单表路由、多表路由还是跨库聚合。比如一条SELECT带了WHERE条件且条件命中分片键,就走单表路由直接定位;如果没有命中分片键,就需要广播到所有分片再合并结果。这对SQL解析引擎的要求非常高,要能精准识别表名、字段名、WHERE条件中的分片键表达式。

// 伪代码:哈希路由核心逻辑
int shardCount = 16;
String shardingKey = "user_id";
Long keyValue = getValueFromSQL(sql, shardingKey);
int targetTable = Math.abs(keyValue.hashCode()) % shardCount;
String targetDB = "db_" + (targetTable / 4);
String targetTableName = "t_order_" + (targetTable % 4);
routeTo(targetDB, targetTableName);
二、全局序列号为什么不能用数据库自增

单库时代,用MySQL的AUTO_INCREMENT就够了。但分库分表后,每个库都有自己的自增序列,不同库生成的ID会重复。有人说设置不同的起始值和步长,比如库A从1开始步长4,库B从2开始步长4,这确实能解决重复问题,但带来三个硬伤:第一,扩容加库时要重新规划所有节点的起始值,运维成本高;第二,ID不连续,中间有空洞;第三,步长配置一旦出错就会产生冲突,风险不可控。

所以分布式场景下,全局序列号必须由中间件统一生成或者由独立的发号服务生成,不能依赖各库的自增机制。这是架构设计的基本原则。

三、三种主流全局序列号生成方案

第一种是雪花算法(Snowflake)。它生成一个64位的Long型ID,结构是:1位符号位 + 41位时间戳 + 10位机器ID + 12位序列号。优点是不依赖数据库、生成速度极快(单机每秒可达数百万)、ID有序递增。缺点是时钟回拨问题会导致ID重复,需要额外的时钟回拨处理机制。中间件通常会在启动时分配机器ID,或者用ZooKeeper协调分配。

// 雪花算法核心结构(64位)
// 1bit符号位 | 41bit时间戳 | 10bit机器ID | 12bit序列号
// 示例:0 00011001010101010101010101010101010101010 0000000001 000000000001
// 对应十进制ID:17592886548481

第二种是号段模式(Segment)。中间件从数据库批量获取一段ID范围,比如一次取1000个,缓存在本地内存中慢慢用。用完了再去取下一段。这种方式对数据库压力小,ID基本连续,适合对ID连续性有要求的业务。缺点是中间件重启会丢失未使用的号段,造成ID空洞;多个中间件实例同时取号段需要用数据库的乐观锁或分布式锁来保证不重复。

// 号段模式取号伪代码
function fetchSegment() {
    // 用UPDATE ... RETURNING 原子性地获取号段
    String sql = "UPDATE id_generator SET current_val = current_val + 1000 " +
                 "WHERE biz_type = 'order' RETURNING current_val";
    Long maxId = db.execute(sql);
    segment.setMaxId(maxId);
    segment.setCurrentId(maxId - 999);
}

Long nextId() {
    if (segment.getCurrentId() > segment.getMaxId()) {
        fetchSegment();
    }
    return segment.getCurrentId()++;
}

第三种是基于Redis的INCR实现。利用Redis的原子自增命令,配合业务前缀生成全局唯一ID。实现简单,性能好,但强依赖Redis的高可用。如果Redis宕机且没有持久化,可能丢号。生产环境通常会用Redis Cluster加持久化来保障。

四、路由与全局序列号如何协同工作

在实际业务中,这两个功能不是孤立的。比如一个订单创建请求,中间件先解析SQL,通过路由规则确定目标库和表,然后生成全局订单ID,再将带有这个ID的INSERT语句发到目标节点。整个过程要保证事务性——ID生成和路由定位必须在同一个请求链路中完成,不能出现ID生成了但路由失败导致ID浪费的情况。

更复杂的场景是跨库事务。比如下单涉及扣库存(库A)和创建订单(库B),中间件需要用分布式事务协调器(如XA、TCC、Saga)来保证数据一致性。这时候全局序列号的生成时机就很关键,通常建议在事务开始前就预生成ID,避免事务回滚后ID被浪费但又无法回收的问题。

五、选型建议和避坑指南

如果你的业务是高并发写入、对ID有序性有要求,优先选雪花算法,但一定要做好时钟回拨的兜底方案。如果业务对ID连续性敏感、写入并发没那么极端,号段模式更稳妥。如果团队技术栈已经重度依赖Redis,用Redis INCR也是务实选择。

路由方面,如果查询以点查为主(根据ID查单条),哈希路由最合适;如果范围查询多(按时间查订单列表),范围分片更优;如果节点经常扩缩容,一致性哈希是必选项。千万不要一套路由策略打天下,要根据业务SQL的实际分布来选。

还有一个容易踩的坑:中间件本身的高可用。如果中间件挂了,整个数据库访问就断了。所以中间件必须做集群部署,路由规则和序列号生成逻辑要能在多实例间同步。序列号服务尤其要做好容灾,比如号段模式可以配置双写、雪花算法可以用多机房时钟同步。

六、总结

分布式数据库分库分表中间件的路由和全局序列号,本质上是解决"数据去哪"和"数据叫什么"两个问题。路由靠分片键加算法精准定位,全局序列号靠雪花、号段或Redis等方案保证唯一有序。两者协同配合,才能让分库分表架构真正跑得稳、跑得快。选型时不要追求最先进的方案,要选最匹配业务特征的方案,同时把高可用和容灾放在第一位。