一、问题现场诡异跳号的S2026031700051.1 异常现象复现某日生产环境突然爆出Duplicate entry S202603170005 for key...的异常。查看数据库发现一个有趣的现象sql-- 数据库中的记录 S202603170001 -- 第一批次 S202603170002 -- 第一批次 S202603170003 -- 第二批次第一次循环 S202603170005 -- 第二批次第一次循环跳过了004 -- 然后第二次循环试图插入S202603170005冲突了关键疑点004去哪里了为什么005生成了两次1.2 代码还原现场java// 问题核心代码 private String generateNumberAdd(traceReportDTO traceReportDTO, Integer index) { String currentDate LocalDate.now().format(DateTimeFormatter.ofPattern(yyyyMMdd)); // 查询当日已生成的追溯码数量 LambdaQueryWrappertraceReportDo queryWrapper new LambdaQueryWrapper(); queryWrapper.like(traceReportDo::getTraceNo, S currentDate); Long count traceReportMapper.selectCount(queryWrapper); // ← 问题根源 String sequenceNumber String.format(%04d, count 1); // ← 基于count计算 return S currentDate sequenceNumber; }二、深入解剖为什么相同的count生成了相同的编码2.1 并发时序分析当多个线程/多次循环同时执行时发生了经典的检查-获取-执行并发问题java// 时间线T1和T2几乎同时执行 T1时刻线程A查询count 2数据库有S001、S002 T2时刻线程B查询count 2数据库仍有S001、S002 ← 脏读 T3时刻线程A计算count13生成S003插入数据库 T4时刻线程B计算count13生成S003插入数据库 ← Boom主键冲突本质原因查询和插入不是原子操作多个线程基于相同的count值计算编码数据库的隔离级别无法阻止这种逻辑层面的并发2.2 跳号现象解析再看那个神秘的004跳号java第一次循环批量插入3条 count2 → 13 (S003) count3 → 14 (S004) // 本该生成但... count4 → 15 (S005) 第二次循环又来一批 count5 → 16 (S006) // 实际生成了S005不对 // 实际情况是 第一次循环T1(count2)→S003, T2(count2)→S003(冲突)❌ 第二次循环基于新的count生成导致了跳号和重复三、解决方案Synchronized登场3.1 Synchronized锁的本质javasynchronized(锁对象) { // 临界区代码 - 同一时刻只有一个线程能执行 }锁的三大特性原子性代码块不可分割要么全执行要么全不执行可见性线程修改后其他线程立即可见最新值有序性禁止指令重排序保证执行顺序3.2 实战改造方案javaService public class TraceReportService { // 使用静态常量作为锁对象确保所有实例共享同一把锁 private static final Object TRACE_REPORT_LOCK new Object(); public void addParent(traceReportDTO traceReportDTO, Integer index) { synchronized (TRACE_REPORT_LOCK) { // ← 加锁保护 // 1. 业务校验 LambdaQueryWrappertraceReportDo queryWrapper new LambdaQueryWrapper(); // ... 查询条件 Long count traceReportMapper.selectCount(queryWrapper); if (count 0) { log.info(记录已存在{}, traceReportDTO); return; } // 2. 生成编码基于最新的count traceReportDo traceReportDo new traceReportDo(); BeanUtil.copyProperties(traceReportDTO, traceReportDo); traceReportDo.setTraceNo(generateNumberAdd(traceReportDTO, index)); // 3. 插入数据库 traceReportMapper.insert(traceReportDo); } // ← 自动释放锁 } private String generateNumberAdd(traceReportDTO traceReportDTO, Integer index) { // 注意这个方法也被锁保护了因为是在synchronized块内调用 String currentDate LocalDate.now().format(DateTimeFormatter.ofPattern(yyyyMMdd)); LambdaQueryWrappertraceReportDo queryWrapper new LambdaQueryWrapper(); queryWrapper.like(traceReportDo::getTraceNo, S currentDate); Long count traceReportMapper.selectCount(queryWrapper); // 这里有个潜在bugindex参数没用到应该是count index String sequenceNumber String.format(%04d, count 1); return S currentDate sequenceNumber; } }3.3 改造后的执行流程java// 加了synchronized后的并发执行时序 线程A: [获取锁] → 查询count2 → 生成S003 → 插入 → [释放锁] 线程B: [等待] ...等待锁... 线程B: ...获得锁... → 查询count3 → 生成S004 → 插入 → [释放锁] 线程C: [获得锁] → 查询count4 → 生成S005 → 插入完美解决✅ 无重复编码✅ 连续递增不跳号✅ 线程安全四、进阶优化更优的解决方案4.1 数据库唯一约束兜底sqlALTER TABLE trace_report ADD UNIQUE INDEX uk_trace_no (trace_no);即使代码层面有疏漏数据库也能拦住重复数据。4.2 基于数据库的乐观锁javaUpdate(UPDATE sequence_table SET current_value current_value #{increment} WHERE biz_date #{bizDate} AND current_value #{increment} max_value) int incrementSequence(Param(bizDate) String bizDate, Param(increment) int increment);4.3 Redis分布式锁适用于分布式系统javapublic String generateTraceNoWithRedisLock(traceReportDTO dto) { String lockKey lock:traceNo: dto.getBatchNo(); String requestId UUID.randomUUID().toString(); try { // 尝试获取锁等待3秒锁自动释放时间10秒 boolean locked redisLock.tryLock(lockKey, requestId, 3, 10, TimeUnit.SECONDS); if (!locked) { throw new RuntimeException(系统繁忙请稍后重试); } // 业务逻辑查询生成插入 return doGenerateTraceNo(dto); } finally { redisLock.releaseLock(lockKey, requestId); } }4.4 性能对比方案并发性能实现复杂度适用场景Synchronized低串行低单机应用并发量100 QPS数据库唯一约束中低兜底方案必须配合其他方案数据库乐观锁中高中冲突概率不高的场景Redis分布式锁高高分布式系统高并发场景五、最佳实践建议5.1 最终优化版本javaService public class TraceReportService { private static final Object LOCK new Object(); Transactional(rollbackFor Exception.class) public void addParent(traceReportDTO traceReportDTO, Integer index) { synchronized (LOCK) { // 1. 校验是否存在 if (checkExists(traceReportDTO)) { return; } // 2. 生成编码基于当前最大序号index String traceNo generateTraceNo(traceReportDTO, index); // 3. 保存数据 saveTraceReport(traceReportDTO, traceNo); // 4. 可选的记录已使用序号防止重复 markSequenceUsed(traceNo); } } private String generateTraceNo(traceReportDTO dto, Integer index) { String date LocalDate.now().format(DateTimeFormatter.ofPattern(yyyyMMdd)); // 获取当日最大序号带行锁防止并发 Integer maxSeq traceReportMapper.getMaxSequenceForDate(date); // 计算新序号最大序号 index int newSeq (maxSeq null ? 0 : maxSeq) index; return String.format(S%s%04d, date, newSeq); } }5.2 使用建议锁粒度要细只锁必要的最小代码块锁对象要合适使用static final常量避免this锁事务与锁的顺序先获取锁再开启事务监控告警对锁等待时间、冲突次数进行监控兜底策略永远保留数据库唯一约束作为最后防线六、总结Synchronized虽老但在单机应用中仍然是解决并发问题的利器。它通过串行化访问共享资源从根本上杜绝了查询-计算-插入这个经典并发陷阱。核心要点回顾❌ 问题根源查询和插入不是原子操作✅ 解决方案synchronized保证原子性 进阶思路根据并发量选择合适方案️ 最佳实践锁 唯一约束双重保障记住在并发编程中看见查询后计算再更新的模式就要立刻警觉——这可能就是一个潜在的并发bug最后送大家一句并发编程没有银弹但理解原理能让你少踩坑。Synchronized虽然简单用对地方一样能解决大问题