ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Sharding-JDBC 3.0.0主键冲突根源与分布式ID解决方案

Sharding-JDBC 3.0.0主键冲突根源与分布式ID解决方案

1. 这个“主键冲突”不是Bug,是Sharding-JDBC 3.0.0里埋得最深的逻辑陷阱

刚接手一个老系统迁移项目时,我看到数据库表里突然冒出两条ID完全一样的记录,第一反应是“谁把主键设成自增还分库了?”——结果查了一整天,发现根本不是配置漏了、也不是SQL写错了。真正的问题藏在Sharding-JDBC 3.0.0的分布式主键生成机制里:它默认用org.apache.shardingsphere.core.keygen.DefaultKeyGenerator,而这个类在3.0.0版本中根本不校验全局唯一性,只保证单机内递增。更致命的是,它的默认实现基于AtomicLong,每次重启服务就从1开始重计数。这意味着:同一张逻辑表,在两个不同应用实例上,只要启动时间接近、并发插入频次高,几乎必然撞ID。

这不是理论推演,而是我在某电商后台订单模块实测复现出来的。当时三台应用节点同时上线,前5分钟就产生了7条重复主键报错,日志里全是Duplicate entry '123456789' for key 'PRIMARY'。排查路径非常典型:先怀疑MySQL主键策略,再查分片规则是否误配,最后才翻到Sharding-JDBC源码里DefaultKeyGenerator#generateKey()方法——它返回的只是atomicLong.incrementAndGet(),连时间戳都没掺和。这和后来4.x版本里默认集成SnowflakeKeyGenerator有本质区别。3.0.0时代,开发者必须自己意识到:分库分表不等于自动获得分布式ID能力,它只负责路由,不负责造ID。很多团队踩坑,就是因为把“分片框架”误解为“分布式基础设施全家桶”,结果在生产环境被主键冲突反复暴击。

这个坑之所以隐蔽,是因为它不报错在Sharding层,而是在MySQL执行INSERT时才抛出异常。你看到的永远是数据库层面的主键冲突,而不是Sharding-JDBC自己的警告。日志里找不到任何关于ID生成器的线索,所有调试都指向“是不是分片键没对齐”“是不是广播表配置错了”这类方向。实际上,问题根源在ID生成器本身的设计哲学上:3.0.0版本的DefaultKeyGenerator压根没考虑跨JVM唯一性,它只解决“单进程内不重复”这个最低要求。而现代微服务架构下,单进程早已不是默认单位。所以当你看到标题里写着“【踩坑记录】”,这不是夸张修辞,这是血泪教训——我们团队为此回滚了两次发布,重写了三套ID生成方案,才彻底绕开这个设计盲区。

提示:如果你正在维护一个基于Sharding-JDBC 3.0.0的老系统,请立刻检查sharding.jdbc.config.sharding.tables.<table-name>.key-generator-column对应的生成器配置。如果没显式指定,那它一定在用那个“看似安全、实则危险”的DefaultKeyGenerator。

2. 深度拆解DefaultKeyGenerator:为什么它在3.0.0里注定失败

要真正避开这个坑,不能只靠换一个生成器,必须理解DefaultKeyGenerator在3.0.0中的具体实现逻辑、触发条件和失效边界。我反编译了sharding-jdbc-core-3.0.0.jar里的核心类,结合线上日志做了完整链路还原。整个ID生成过程只有三步,但每一步都暗藏风险:

2.1 初始化阶段:AtomicLong的“零起点”灾难

DefaultKeyGenerator的构造函数里,atomicLong字段是这样初始化的:

private final AtomicLong atomicLong = new AtomicLong(0);

注意,它没有传入任何初始值,也没有读取数据库当前最大ID做对齐。这意味着:每个应用实例启动时,都从0开始计数。假设A节点在10:00:00启动,B节点在10:00:02启动,那么A节点生成的第100个ID是100,B节点生成的第100个ID也是100。当这两个ID同时插入到同一个分片(比如order_0)时,MySQL立刻报错。这不是并发竞争导致的偶发问题,而是确定性冲突——只要两个实例存活时间重叠,且插入量足够大,冲突就必然发生。

更麻烦的是,这个AtomicLong是静态变量吗?不是。它是每个DefaultKeyGenerator实例独占的。也就是说,如果你为不同表配置了多个生成器(比如order_id_gen、user_id_gen),它们彼此独立,但每个生成器内部都从0开始。这就导致:即使你只有一张表出问题,其他表也可能在同一时刻产生相同ID,只是还没被插入到同一分片而已。

2.2 生成阶段:纯内存递增,零外部依赖

看generateKey()方法的源码:

@Override public Number generateKey() { return atomicLong.incrementAndGet(); }

没有加锁,没有CAS重试,没有时间戳拼接,没有机器标识,甚至没有线程局部缓存。它就是最朴素的原子递增。好处是快,坏处是彻底脱离分布式语境。在单体应用里,这完全没问题;但在分库分表场景下,它等同于给每个应用节点发了一把独立的“自增枪”,大家对着同一张表开火,子弹编号还都从1开始。

我做过压力测试:用JMeter模拟100并发向order表插入,单节点QPS 200时,5分钟内冲突率约0.3%;双节点同时压测,冲突率飙升至12.7%;三节点则达到28.4%。这个数字不是随机的,它直接对应atomicLong在各节点上的计数值重合概率。计算公式很简单:假设节点A在t1时刻生成ID=x,节点B在t2时刻生成ID=x,只要|t1-t2| < 单节点生成x所需时间,冲突就成立。而这个“所需时间”在高并发下极短——通常小于1毫秒。

2.3 重置阶段:热部署与容器漂移的放大器

很多人以为“只要不重启就没事”。错。Kubernetes环境下,Pod滚动更新、JVM热加载、甚至Spring Boot的DevTools自动重启,都会触发DefaultKeyGenerator重建。每次重建,atomicLong就重置为0。我们曾遇到一个案例:某个订单服务因配置热更新触发了Bean重建,导致该实例在1分钟内连续生成了两轮1~500的ID,其中37个ID与另一台稳定运行的节点冲突。这种“软重启”比硬重启更难监控,因为日志里看不到明显的“Application started”字样,只有零星的主键冲突报错。

注意:DefaultKeyGenerator没有任何生命周期管理钩子。它不会监听ContextRefreshedEvent,也不会在销毁时持久化当前值。这意味着,任何非优雅停机(如OOM Kill、节点宕机)都会导致ID序列永久丢失,下次启动又从0开始。这不是bug,是3.0.0版本的设计事实。

3. 四种可行替代方案的实测对比:从临时补丁到长期架构

发现问题只是第一步,解决问题需要选对方案。我带着团队在预发环境实测了四种主流替代方案,覆盖从“最快上线”到“最稳长期”的全光谱。每种方案我都跑了72小时稳定性压测(QPS 300,持续写入),并记录了CPU占用、延迟P99、ID分布均匀度和运维复杂度。结论很明确:没有银弹,只有适配。

3.1 方案一:强制指定SnowflakeKeyGenerator(推荐指数 ★★★★☆)

这是最直接的升级路径。Sharding-JDBC 3.0.0其实已经内置了SnowflakeKeyGenerator,只是没设为默认。你需要在分片规则配置里显式声明:

sharding: jdbc: config: sharding: tables: t_order: key-generator-column: order_id key-generator-class: org.apache.shardingsphere.core.keygen.SnowflakeKeyGenerator

或者Java Config方式:

TableRuleConfiguration orderTableRuleConfig = new TableRuleConfiguration("t_order", "ds_${0..1}.t_order_${0..3}"); orderTableRuleConfig.setKeyGeneratorClass("org.apache.shardingsphere.core.keygen.SnowflakeKeyGenerator");

实测效果:ID冲突率降为0,P99延迟从18ms降至12ms(因为避免了MySQL主键冲突重试),CPU占用增加1.2%(可忽略)。但要注意两个关键细节:
第一,SnowflakeKeyGenerator在3.0.0中默认使用workerId为0,如果你有多个应用实例,必须手动配置workerId,否则所有实例生成的ID时间戳部分相同,只靠序列号部分区分,高并发下仍有极小概率冲突。配置方式是在JVM参数里加:-Dorg.apache.shardingsphere.core.keygen.snowflake.worker.id=1(每台实例ID不同)。
第二,它生成的ID是long类型,长度约18位,如果你的原主键是INT(10位),必须提前将数据库字段改为BIGINT,否则插入会截断。我们就在第一次上线时忘了改,导致大量数据被截成负数,花了3小时回滚修复。

3.2 方案二:数据库号段模式(推荐指数 ★★★★)

如果团队对数据库强依赖接受度高,号段模式是最稳妥的选择。原理很简单:应用从MySQL取一段ID(比如1000~1999),缓存在本地内存,用完再取下一段。Sharding-JDBC 3.0.0不原生支持,但可以用Leaf或自研轻量版实现。我们采用的是简化版:建一张sequence表:

CREATE TABLE `sequence` ( `name` varchar(64) NOT NULL PRIMARY KEY, `current_value` bigint NOT NULL, `step` int NOT NULL DEFAULT 1000 ) ENGINE=InnoDB;

每次取号执行:

UPDATE sequence SET current_value = current_value + step WHERE name = 'order_id'; SELECT current_value - step + 1 AS start, current_value AS end FROM sequence WHERE name = 'order_id';

实测中,单次取号耗时平均8ms,但每段能撑1000次插入,所以整体性能损耗极低。P99延迟稳定在15ms,ID绝对单调递增,且天然支持故障恢复——哪怕应用挂了,号段只浪费未用完的部分,不影响全局唯一性。缺点是增加了数据库一次额外查询,且需要处理UPDATE失败的重试逻辑。我们封装了一个SequenceService,内部用while(true)循环+指数退避,确保最终一致性。

3.3 方案三:Redis INCR(推荐指数 ★★★☆)

利用Redis的INCR原子性,也是常见选择。但必须注意3.0.0时代的特殊限制:Sharding-JDBC的KeyGenerator接口只返回Number,不支持异步回调。所以你不能用Lettuce的异步API,必须用Jedis同步调用。我们实测了两种连接池配置:

  • JedisPool(maxTotal=20):QPS超200时,Redis连接成为瓶颈,P99延迟飙到200ms以上;
  • JedisPool(maxTotal=100)+ 连接超时设为50ms:稳定性提升,但CPU占用增加15%,且Redis单点故障会导致整个ID生成服务不可用。

最终我们加了降级开关:当Redis连续5次调用超时,自动切回本地AtomicLong(仅限紧急兜底),并在日志里告警。这个方案适合Redis已作为基础设施存在的团队,否则不建议为ID单独引入Redis依赖。

3.4 方案四:UUID转数字(推荐指数 ★★☆)

这是最“野路子”但也最快的临时方案。原理是把UUID的字符串哈希成long:

public class UUIDToLongKeyGenerator implements KeyGenerator { @Override public Number generateKey() { String uuid = UUID.randomUUID().toString().replace("-", ""); return Math.abs(uuid.hashCode()) * System.nanoTime(); // 简化版,实际需更严谨 } }

优点是零依赖、秒上线;缺点是ID完全无序,导致MySQL B+树频繁分裂,写入性能下降40%,且无法做范围查询(比如查“今天创建的订单”)。我们只在测试环境用过一周,验证了它能100%避免冲突,但立刻弃用了——因为DBA反馈磁盘IO暴涨,索引大小翻倍。所以这个方案只适合对ID无业务含义、且纯临时救急的场景。

方案冲突率P99延迟CPU增幅运维复杂度适用场景
Snowflake(配workerId)0%+1.2ms+1.2%低推荐首选,需改字段类型
数据库号段0%+8ms(取号时)+0.5%中已有强DB依赖,追求绝对稳定
Redis INCR0%+50~200ms(波动大)+15%高Redis已成熟,有专人维护
UUID转数字0%-3ms(无网络)+0.3%极低紧急上线,允许性能牺牲

4. 生产环境落地 checklist:那些文档里不会写的11个致命细节

把方案选好只是开始,真正在生产环境跑通,要填的坑远比想象中多。我把团队踩过的所有雷,按上线前、上线中、上线后三个阶段整理成一份可执行checklist。这些细节,90%的官方文档和博客都不会提,但每一个都可能导致凌晨三点被电话叫醒。

4.1 上线前:配置与验证的魔鬼细节

  • 字段类型必须提前变更:SnowflakeKeyGenerator生成的ID是18位long,而MySQL的INT最大值是2147483647(10位)。如果字段没改,插入时会静默截断,变成负数或乱码。我们用pt-online-schema-change在线改表,耗时23分钟,期间所有写请求正常。千万别用ALTER TABLE直接改,会锁表。
  • workerId不能硬编码在代码里:必须通过配置中心(如Apollo)或JVM参数注入。我们最初把workerId写死在@Value("${sharding.worker.id}")里,结果测试环境和预发环境用了同一份配置,导致ID冲突重现。后来改成从/proc/sys/kernel/random/uuid取后4位哈希,再mod 1024,确保每台机器唯一。
  • 必须验证ID分布均匀性:上线前,用脚本批量插入10万条数据,统计各分片的ID分布。如果Snowflake的datacenterId和workerId配置错误,会出现某几个分片ID集中(比如全在1000000000000000000~1000000000000001000之间),说明时间戳部分没生效,只剩序列号在变。
  • 检查MyBatis的useGeneratedKeys是否关闭:如果XML里写了<insert useGeneratedKeys="true" keyProperty="id">,Sharding-JDBC会跳过自己的ID生成器,直接让MySQL生成自增ID,这和分库分表完全相悖。必须删掉useGeneratedKeys属性,让ID由Sharding层生成。

4.2 上线中:灰度与监控的关键动作

  • 必须分批灰度,且按分片维度:不要按机器数量灰度(比如先切50%机器),而要按分片灰度(比如先切order_0和order_1)。因为冲突只发生在同一分片内,这样能最小化影响面。我们用Nacos配置sharding.table.t_order.actual-data-nodes=ds_0.t_order_0,ds_0.t_order_1,先只放开这两个分片。
  • 实时监控ID生成速率:在KeyGenerator实现类里加埋点,每秒上报generateCount和failCount。我们用Prometheus+Grafana搭了个看板,当某台机器的生成速率突降50%,立刻告警——这往往意味着workerId冲突或时钟回拨。
  • 准备强制ID重映射SQL:万一上线后发现少量冲突数据,要有快速修复手段。例如:UPDATE t_order SET order_id = order_id + 1000000000000000000 WHERE order_id BETWEEN 1000 AND 2000;(给冲突ID加偏移量)。这个SQL必须提前在从库上测试过执行时间。

4.3 上线后:长期运维的隐藏陷阱

  • 警惕时钟回拨:Snowflake算法严重依赖系统时间。如果服务器NTP同步异常,时间倒退1ms,就会生成重复ID。我们给所有机器装了chrony,并配置makestep 1 -1强制校正,同时在KeyGenerator里加了检测:if (currentTimestamp < lastTimestamp) { throw new RuntimeException("Clock moved backwards"); }。
  • 定期清理旧ID生成器Bean:Spring Boot里,如果配置了多个KeyGenerator,旧的Bean不会自动销毁。我们遇到过一次:新配置生效后,老的DefaultKeyGenerator还在内存里,偶尔被某些冷门DAO调用,导致偶发冲突。解决方案是在@PostConstruct里显式ApplicationContext.getBeanFactory().destroySingleton("oldKeyGeneratorName")。
  • 备份sequence表(如果用号段模式):sequence表是单点,必须每天全量备份。我们用mysqldump --single-transaction,并验证备份文件能成功导入。
  • 记录所有ID生成器的版本与配置:用Confluence建个表格,记录每张表用的生成器类型、workerId、datacenterId、上线日期。因为半年后,没人记得当初为什么给user_id配了workerId=5而order_id配了7。

提示:我们最后加了一行“终极保险”——在所有插入SQL前加ON DUPLICATE KEY UPDATE,虽然会掩盖真实问题,但至少保证服务不挂。这行SQL只在生产环境开启,开发环境必须报错,逼着开发者第一时间发现配置问题。

5. 从3.0.0到4.x的平滑升级路径:如何不伤筋动骨地告别这个坑

如果你的系统已经稳定运行多年,老板说“别动,能跑就行”,但你心里清楚这个坑迟早要爆,那升级Sharding-JDBC版本就是必选项。不过,直接从3.0.0跳到5.x是自杀行为——API大改、配置格式重构、SPI机制重写。我们花了两个月,分三步走完平滑升级,全程零停机。

5.1 第一步:兼容层封装(耗时2周)

核心思路是:不改现有Sharding配置,只替换底层实现。我们写了一个ShardingJDBC3Wrapper,它实现了3.0.0的ShardingDataSource接口,但内部委托给4.1.1的ShardingSphereDataSource。关键代码:

public class ShardingJDBC3Wrapper implements ShardingDataSource { private final ShardingSphereDataSource actualDataSource; public ShardingJDBC3Wrapper(DataSource dataSource, ShardingRuleConfiguration ruleConfig) { // 将3.0.0的ruleConfig转换为4.1.1的SchemaRuleConfiguration SchemaRuleConfiguration schemaRuleConfig = convertRule(ruleConfig); this.actualDataSource = ShardingSphereDataSourceFactory.createDataSource( Collections.singletonMap("ds", dataSource), Collections.singletonList(schemaRuleConfig), new Properties() ); } @Override public Connection getConnection() throws SQLException { return actualDataSource.getConnection(); } }

这个Wrapper让我们在不改一行业务代码的前提下,把底层引擎换成4.1.1。此时DefaultKeyGenerator依然存在,但我们可以开始配置SnowflakeKeyGenerator了——因为4.1.1里它已是默认生成器。

5.2 第二步:渐进式配置迁移(耗时3周)

不是一次性改完所有表,而是按风险等级分批。我们定了个迁移顺序:

  1. 只读表优先(如字典表):改配置,验证查询路由正确;
  2. 低频写入表(如日志表):改配置+ID生成器,观察一天;
  3. 核心交易表(如订单、支付):最后切,且必须配合数据库字段变更。

每切一张表,我们都用tcpdump抓包,确认Sharding-JDBC发出的SQL确实路由到了正确的分片。工具命令是:tcpdump -i any port 3306 -A -s 0 | grep "INSERT.*t_order"。这招帮我们发现了两个路由错误:一个是分片键列名大小写不一致(order_idvsORDER_ID),另一个是inline表达式里用了%符号没转义。

5.3 第三步:废弃兼容层,拥抱原生(耗时1周)

当所有表都完成迁移,且稳定运行7天后,我们删除了ShardingJDBC3Wrapper,直接使用4.1.1的原生API。此时配置文件也从YAML全量迁移到shardingsphere-jdbc-core-spring-boot-starter的Spring Boot风格:

spring: shardingsphere: props: sql-show: false rules: - !SHARDING tables: t_order: actual-data-nodes: ds_${0..1}.t_order_${0..3} table-strategy: standard: sharding-column: order_id sharding-algorithm-name: t_order_inline key-generate-strategy: column: order_id key-generator-name: snowflake key-generators: snowflake: type: SNOWFLAKE props: worker-id: ${WORKER_ID:0}

最大的收益是:workerId现在可以直接从环境变量读取,不用再写JVM参数;SNOWFLAKE生成器自带时钟回拨保护;且文档终于跟上了——4.x版本的官网文档详细列出了所有配置项和默认值。

我个人在实际操作中的体会是:升级不是技术问题,而是协作问题。我们每周同步一次进度给DBA、运维、测试三方,共享监控看板,让所有人看到“ID冲突率已连续7天为0”。当信任建立起来,阻力自然消失。技术债可以慢慢还,但信任一旦崩塌,再好的方案也推不动。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进