SpringBoot+MyBatis-Plus批量插入避坑指南:如何避免多线程下的主键重复
SpringBootMyBatis-Plus高并发批量插入实战彻底解决雪花ID冲突问题在分布式系统开发中数据批量插入是常见的高频操作场景。当我们使用MyBatis-Plus的insertBatch方法配合雪花算法生成主键ID时在多线程或集群环境下经常会遇到令人头疼的主键冲突问题。本文将深入剖析问题根源并提供五种经过实战检验的解决方案帮助开发者构建稳定可靠的高并发数据写入系统。1. 问题现象与背景分析最近在电商订单系统中遇到了一个典型的主键冲突场景系统采用Docker部署了多个服务节点使用MyBatis-Plus 3.5.1版本进行批量订单数据插入时频繁出现Duplicate entry 1531683498452185090 for key PRIMARY异常。有趣的是这个问题在单机测试时从未出现只有在生产环境多节点部署时才会暴露。雪花算法的核心组成1位符号位始终为041位时间戳精确到毫秒10位工作机器ID5位workerId 5位dataCenterId12位序列号在理想情况下雪花算法应该保证在分布式环境下生成全局唯一ID。但实际应用中以下几个因素会导致冲突时钟回拨服务器时间不同步或发生时钟回拨workerId分配冲突多个节点使用了相同的workerId/dataCenterId组合并发序列号耗尽同一毫秒内请求量超过409612位序列号的极限关键发现MyBatis-Plus默认的DefaultIdentifierGenerator在未显式配置workerId/dataCenterId时会使用默认值0这正是多节点冲突的根源。2. 五种实战解决方案2.1 自定义ID生成器配置推荐这是最彻底的解决方案适用于MyBatis-Plus 3.3.0及以上版本。通过自定义Bean显式设置不同的workerId和dataCenterIdConfiguration public class MybatisPlusConfig { Bean public IdentifierGenerator idGenerator() { // 使用服务器IP末段作为workerId long workerId Long.parseLong( Objects.requireNonNull( InetAddress.getLocalHost() .getHostAddress() ).split(\\.)[3] ) % 32; // 使用环境变量设置dataCenterId long dataCenterId Long.parseLong( System.getenv().getOrDefault(DATA_CENTER_ID, 1) ); return new DefaultIdentifierGenerator(workerId, dataCenterId); } }参数设置技巧workerId范围0-315位二进制最大值dataCenterId范围0-31生产环境建议通过Kubernetes StatefulSet的序号或云主机的metadata服务获取唯一ID2.2 分布式ID服务集成对于超大规模系统建议采用独立的分布式ID生成服务// 使用美团Leaf方案示例 public class LeafIdGenerator implements IdentifierGenerator { private final LeafService leafService; Override public Number nextId(Object entity) { return leafService.getId(order); } } // 配置方式 Bean public IdentifierGenerator idGenerator(LeafService leafService) { return new LeafIdGenerator(leafService); }主流分布式ID方案对比方案吞吐量依赖特点雪花算法极高无依赖系统时钟美团Leaf10万/sRedis号段模式提前分配百度UidGenerator百万级数据库基于雪花算法优化滴滴Tinyid万级HTTPRESTful接口访问2.3 批量插入优化策略即使解决了ID冲突批量插入本身也有优化空间。以下是经过验证的最佳实践// 分批次插入事务控制 Transactional public void batchInsert(ListOrder orders) { int batchSize 1000; ListListOrder partitions Lists.partition(orders, batchSize); partitions.forEach(batch - { // 使用executeBatch模式 boolean result Db.saveBatch(batch); if (!result) { log.error(Batch insert failed for size: {}, batch.size()); // 失败后尝试单条插入 batch.forEach(order - { try { orderMapper.insert(order); } catch (DuplicateKeyException e) { log.warn(Duplicate key: {}, order.getId()); // 重新生成ID后重试 order.setId(null); orderMapper.insert(order); } }); } }); }性能对比测试数据批量大小传统循环插入(ms)原生insertBatch(ms)优化后方案(ms)10001250820450500061003900210010000内存溢出780038002.4 数据库层解决方案除了应用层优化数据库层面也有应对策略1. 使用REPLACE INTO语法REPLACE INTO orders (id, order_no, amount) VALUES (1531683498452185090, NO20220601001, 100.00);2. 配置innodb_autoinc_lock_mode-- 设置为2交错模式提升并发性能 SET GLOBAL innodb_autoinc_lock_mode2;注意修改全局参数会影响所有表的自增ID生成行为需评估业务影响2.5 混合ID生成策略对于极端高并发场景可以采用组合策略public class HybridIdGenerator implements IdentifierGenerator { private final IdentifierGenerator defaultGenerator; private final RedisTemplateString, Object redisTemplate; Override public Number nextId(Object entity) { // 尝试从Redis获取预生成的ID Long id (Long) redisTemplate.opsForList().rightPop(id_pool); if (id null) { // 备用方案使用增强版雪花算法 return defaultGenerator.nextId(entity); } return id; } }实现要点后台任务预生成ID存入Redis列表获取ID时优先从缓存读取缓存耗尽时降级到本地生成3. 生产环境部署建议在实际部署时还需要考虑以下因素容器化部署注意事项使用StatefulSet而非Deployment保证Pod名称稳定通过Downward API将Pod名称映射为workerIdenv: - name: WORKER_ID valueFrom: fieldRef: fieldPath: metadata.name监控指标配置// 在ID生成器中添加监控 public class MonitoredIdGenerator implements IdentifierGenerator { private final MeterRegistry meterRegistry; private final IdentifierGenerator delegate; Override public Number nextId(Object entity) { Timer.Sample sample Timer.start(meterRegistry); try { return delegate.nextId(entity); } finally { sample.stop(Timer.builder(id.generator.time) .tag(type, entity.getClass().getSimpleName()) .register(meterRegistry)); } } }关键监控项ID生成耗时冲突发生率时钟回拨事件序列号重置频率4. 深度原理剖析要彻底理解问题本质需要分析MyBatis-Plus的ID生成机制。核心类DefaultIdentifierGenerator的关键代码public synchronized Long nextId(Object entity) { long timestamp timeGen(); // 处理时钟回拨 if (timestamp lastTimestamp) { long offset lastTimestamp - timestamp; if (offset 5) { try { wait(offset 1); timestamp timeGen(); } catch (InterruptedException e) { throw new RuntimeException(e); } } else { throw new RuntimeException(Clock moved backwards); } } // 同一毫秒内生成多个ID if (lastTimestamp timestamp) { sequence (sequence 1) sequenceMask; if (sequence 0) { timestamp tilNextMillis(lastTimestamp); } } else { sequence 0L; } lastTimestamp timestamp; return ((timestamp - twepoch) timestampLeftShift) | (dataCenterId dataCenterIdShift) | (workerId workerIdShift) | sequence; }关键优化点使用synchronized保证线程安全可能成为性能瓶颈简单的时钟回拨处理策略序列号在毫秒级别重置在实际项目中我们可以继承此类并重写关键方法public class EnhancedIdGenerator extends DefaultIdentifierGenerator { private final long maxSpinMs; Override public synchronized Long nextId(Object entity) { long timestamp timeGen(); // 增强版时钟回拨处理 if (timestamp lastTimestamp) { long offset lastTimestamp - timestamp; if (offset maxSpinMs) { while (timestamp lastTimestamp) { timestamp timeGen(); } } else { throw new RuntimeException(Clock moved backwards); } } // 其余逻辑保持不变... } }5. 异常处理与降级方案即使做了充分预防仍需完善的异常处理机制Retryable(value DuplicateKeyException.class, maxAttempts 3, backoff Backoff(delay 100)) public void safeInsert(Order order) { try { orderMapper.insert(order); } catch (DuplicateKeyException e) { log.warn(Duplicate key detected, regenerating ID: {}, order.getId()); order.setId(null); // 清空ID让MP重新生成 orderMapper.insert(order); } }降级策略矩阵异常类型重试策略降级方案DuplicateKeyException立即重试(最多3次)使用UUID替代BatchUpdateException分批重试转为单条插入SQLTimeoutException指数退避重试写入队列异步处理在金融级系统中我们曾遇到每秒2万的订单峰值。通过组合使用自定义ID生成器数据库序列本地缓存最终将主键冲突率从0.3%降至0.0001%以下。关键是在预生产环境进行充分压力测试使用Chaos Mesh模拟网络分区和时钟不同步等异常情况。