1. 事故现场支付回调同时进来两笔账却重复记了那是个周五晚上我已经准备合电脑了告警群里突然开始刷消息支付回调接口的失败率往上跳了一下紧接着运营那边反馈有同一笔用户订单在系统里生成了两条支付流水对账怎么都对不平。我第一反应是回调重试机制和幂等控制哪个环节漏了于是翻日志、查数据库、看调用链折腾了大半个小时最后发现根子居然出在一个我用了好几年的工具类上——一个被static final修饰的SimpleDateFormat。这个类在 Java 里太常见了常见到我们几乎不会多看一眼。但恰恰是它在并发量一起来之后把线程内部的时间字段彻底搞乱导致同一个支付回调在极短时间内生成了两个不同的“业务幂等键”。唯一索引没有失效只是被绕过了。换句话说重复请求本该被幂等逻辑拦住但因为时间解析出来的值错乱了系统把同一笔回调当成了两笔不同的请求处理最终造成了重复入账。先说清楚事故链路再去讲怎么排查、怎么修。如果你现在项目里也躺着static SimpleDateFormat我建议你花十分钟看完这篇然后立刻去全局搜索一下。1.1 事故链路里的三个可疑坐标出事的是支付回调处理接口。用户支付成功后支付平台会主动回调我们的服务而且回调不保证只发一次所以这个接口从一开始就设计了幂等处理。大致的逻辑是把支付平台返回的“支付完成时间”转成yyyyMMddHHmmss格式的字符串再拼上商户订单号生成一个paySerialNo然后用这个流水号作为唯一键去查重、落库。三个可疑的地方很快就浮出来了。第一个是日志里同样一笔回调订单paySerialNo居然出现了两个不同值而且差异就发生在时间部分有的少了秒有的甚至月份和日期完全对不上。第二个是数据库里确实有唯一索引但这两条记录的paySerialNo不相同所以索引根本没机会发挥作用。第三个是现象不是偶发而是集中在活动秒杀那几分钟流量峰值一上来才出现。如果你对SimpleDateFormat的坑有了解看到这三个现象基本就能锁定方向了。但当时我还抱着一丝侥幸直到我把回调处理类里的时间工具打开看到那一行代码才彻底确认。private static final SimpleDateFormat SDF new SimpleDateFormat(yyyy-MM-dd HH:mm:ss);一个静态共享的SimpleDateFormat所有请求线程都用它来解析回调时间。就这么一行在低并发下稳如老狗在高并发下直接成了事故源头。1.2 为什么“幂等”没有拦住这次重复这个案例最值得复盘的点在于幂等机制本身不是没有而是被一个看似无关的字段给“骗”了。我们当时的幂等判断流程是这样的接收回调请求取出回调时间和商户订单号。用SimpleDateFormat把回调时间字符串解析成Date再格式化成yyyyMMddHHmmss。用“格式化后的时间 订单号”拼接成paySerialNo。拿paySerialNo去数据库查询如果存在说明是重复回调直接返回成功如果不存在则插入流水并更新订单状态。问题出在第 2 步到第 3 步之间。SimpleDateFormat内部不是无状态的它的parse和format操作会反复读写同一个Calendar对象。并发线程一旦交错执行A 线程刚解析到一半B 线程就把共享的Calendar字段改掉了。最后 A 线程拿到的可能是 B 线程的时间甚至是一个被清空的中间状态。于是同一个回调请求第一次生成paySerialNo用的是正确时间第二次由于线程竞争生成的是另一个错误时间两个 key 完全不同幂等判断自然就失守了。这也是“幂等设计”里最隐蔽的一类坑你选的业务幂等键里混入了一个在运行时可能不稳定、且不受你控制的字段。时间字段本身没错错在生成它的工具类不是线程安全的。一旦生成出来的 key 不稳定底层再强的唯一索引也白搭。2. 源码层面的“作案动机”SimpleDateFormat 内部到底发生了什么要理解为什么SimpleDateFormat会在线程并发下“精分”得看一眼它的继承结构。SimpleDateFormat直接继承自DateFormat而DateFormat内部持有两个关键成员变量protected Calendar calendar; protected NumberFormat numberFormat;其中calendar是可变对象而且不是局部变量是被整个DateFormat实例共享的。SimpleDateFormat的parse和format方法本质上都在操作这个共享Calendar。2.1 format 和 parse 各自会踩什么雷先说format。当我们调用SDF.format(date)时实际执行的是SimpleDateFormat.format(Date date, StringBuffer toAppendTo, FieldPosition pos)方法内部第一步会调用calendar.setTime(date)把传入的Date设置到共享的Calendar上然后再逐个读取Calendar的YEAR、MONTH、DAY_OF_MONTH等字段来拼字符串。多线程竞争时线程 A 刚setTime设置好自己的时间线程 B 紧接着也调用了setTime把同一个Calendar对象改成了自己的时间。线程 A 再去读YEAR字段时读到的可能已经是线程 B 的年份。结果就是A 线程格式化出来的字符串混进了 B 线程的日期部分或者干脆输出一个驴唇不对马嘴的日期。parse更凶险。parse内部会先调用calendar.clear()然后调用calendar.set(ERA, ...)、calendar.set(YEAR, ...)等方法把解析出来的字段逐个塞进Calendar最后calendar.getTime()转成Date。这个过程比format步骤更多状态被覆盖的概率更高而且还会操作ParsePosition这类共享可变的解析状态。所以并发解析时不仅可能拿到错误时间还可能出现NumberFormatException或ArrayIndexOutOfBoundsException。我自己在复现实验里就见过三种异常同时冒出来解析结果多了几分钟、直接抛出ParseException、甚至偶发StringIndexOutOfBoundsException。症状五花八门但根因都是同一个。2.2 线程安全不只是“加锁”只有状态被隔离才是真安全很多人一听到线程安全第一反应就是加锁。但SimpleDateFormat这类问题的本质是内部状态没有隔离而不是某个复合操作不具备原子性。这两个是有区别的。拿AtomicInteger来对比。AtomicInteger之所以线程安全是因为它把value声明成了volatile并且incrementAndGet这类操作通过 CAS 在底层保证了原子性。它没有把可变状态暴露给外部所有操作都围绕同一个内部状态做原子更新。SimpleDateFormat的问题在于它的核心操作是“先设置状态再读取状态”中间要经历很多步骤。哪怕你对整个方法加一把大锁确实能解决线程安全问题但代价是并发请求全部串行化。在高并发场景下这种加锁方案会直接把吞吐量压下去。所以更合理的思路是“状态隔离”每个线程持有独立的SimpleDateFormat实例互不干扰。这也解释了为什么像HashMap线程不安全、ArrayList线程不安全这类问题和SimpleDateFormat本质上是一类问题——它们都不是说单次操作一定会出错而是多个线程同时修改同一个对象的内部结构时结果不可预知。任何被static final修饰、内部又包含可变成员变量的工具类都应该警惕。3. 从怀疑到实锤最小复现与线上排查链路光靠理论分析还不够当时我在测试环境写了一个最小复现程序用来验证是否真的能稳定复现SimpleDateFormat并发错乱。这个复现代码很简单但跑出来的结果触目惊心。3.1 最小复现代码import java.text.SimpleDateFormat; import java.util.Date; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicInteger; public class SimpleDateFormatConcurrentTest { private static final SimpleDateFormat SDF new SimpleDateFormat(yyyy-MM-dd HH:mm:ss); private static final String[] SOURCE_TIMES { 2024-11-01 10:00:00, 2024-11-01 11:12:13, 2024-11-02 09:08:07, 2024-11-03 23:59:58, 2024-11-04 15:30:30 }; private static final AtomicInteger ERROR_COUNT new AtomicInteger(); public static void main(String[] args) throws Exception { int threadCount 20; int loopCount 5000; ExecutorService pool Executors.newFixedThreadPool(threadCount); CountDownLatch startLatch new CountDownLatch(1); CountDownLatch endLatch new CountDownLatch(threadCount); for (int t 0; t threadCount; t) { String source SOURCE_TIMES[t % SOURCE_TIMES.length]; pool.submit(() - { try { startLatch.await(); for (int i 0; i loopCount; i) { try { Date parsed SDF.parse(source); String formatted SDF.format(parsed); if (!source.equals(formatted)) { int err ERROR_COUNT.incrementAndGet(); if (err 10) { System.out.println(期望: source 实际: formatted); } } } catch (Exception e) { int err ERROR_COUNT.incrementAndGet(); if (err 10) { System.out.println(source 解析异常: e.getClass().getSimpleName()); } } } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { endLatch.countDown(); } }); } startLatch.countDown(); endLatch.await(); pool.shutdown(); System.out.println(异常次数: ERROR_COUNT.get()); } }这段代码的思路很简单20 个线程同时用同一个SimpleDateFormat解析固定的几个时间字符串然后立刻格式化回去比较结果是否一致。严格来说真实场景里不会出现“自己解析完再自己格式化”但这种写法能最直观地暴露SimpleDateFormat状态被跨线程覆盖的问题。我跑了一次输出让人非常清醒。期望: 2024-11-01 10:00:00 实际: 2024-11-02 09:08:07 期望: 2024-11-03 23:59:58 实际: 2024-11-04 15:30:30 期望: 2024-11-04 15:30:30 实际: 2024-11-02 09:08:07 期望: 2024-11-01 11:12:13 实际: 2024-11-03 23:59:58 期望: 2024-11-02 09:08:07 实际: 2024-11-04 15:30:30 异常次数: 8297总共跑了 20 * 5000 100000 次异常次数 8297接近 8.3% 的错误率。这个比例放在支付场景里意味着每秒只要有几十笔回调就完全可能出现重复入账。而且注意这还是在单台机器上的复现真实线上如果多节点部署、每节点又有线程池并发处理触发概率只会更高。3.2 线上排查链路从日志到调用链再到源码如果你遇到的线上问题没有这么“幸运”地直接体现在业务数据上排查思路可以按下面几步走。第一步定位现象是“日志错乱”还是“业务数据错乱”。我们当时就是先发现数据库里多了重复流水然后倒查日志发现同一traceId下居然生成了两个paySerialNo。这里的核心是要把一次请求的完整链路串起来看幂等键是不是同一个。如果幂等键在不同重试之间不一致幂等机制就是形同虚设。第二步检查幂等键生成手段。把paySerialNo的生成逻辑拆出来看每一步依赖什么。我们在这一步才看到回调处理类里那个static final SimpleDateFormat。说句实话这行代码最初不是我写的它在项目里存活了可能有一两年平时因为并发量低从来没有暴露过问题。如果不是这次活动流量冲上来它可能还会一直隐身。第三步用线程jstack辅助确认。在复现故障时抓一份线程堆栈能看到大量线程停留在SimpleDateFormat.parse或Calendar.getTime相关调用上。虽然SimpleDateFormat内部没有锁线程不会真的“阻塞”但堆栈可以帮你确认调用热点排除其他嫌疑。第四步回到最小复现。把线上用的日期格式、并发模型在本地用脚本复现一旦能稳定复现问题就实锤了。这个过程别嫌麻烦它能避免你在不对的方向上继续浪费几个小时。4. 修复方案从 ThreadLocal 到 DateTimeFormatter 的选型对比问题确认之后接下来就是改。我见过不少团队在修复这个问题时直接在每个方法里new SimpleDateFormat(...)这当然能解决问题但并不是最优解。下面把几种常见方案放在一起对比。4.1 四种方案对比方案线程安全性能表现适用版本额外依赖我的评价每次调用new SimpleDateFormat安全有对象创建开销高频下会增加 GC 压力JDK 1.4无能用但不建议在高频接口里用ThreadLocalSimpleDateFormat安全但要注意线程池场景内存泄漏每个线程复用同一实例性能较好JDK 1.4无经典解法但需要remove()DateTimeFormatter安全不可变对象性能与SimpleDateFormat相当无额外 GC 压力JDK 8无最推荐长期维护最省心Apache Commons LangFastDateFormat安全性能不错JDK 7 及以下需要引入 commons-lang3JDK 7 团队的务实选择Joda-TimeDateTimeFormat安全性能尚可JDK 7 及以下或历史项目需要引入 joda-time 依赖老项目常见新项目不推荐再引入从团队长期维护角度来看只要项目已经升级到 JDK 8 以上没有理由不用DateTimeFormatter。它被设计成不可变对象可以安全地做成static final单例语义上和SimpleDateFormat几乎没有差别但不再需要每次调用时创建新对象也不需要在ThreadLocal里小心翼翼地清理。4.2 用 DateTimeFormatter 重写时间工具类我当时给项目里加了一个新的时间工具类覆盖了最常见的yyyy-MM-dd HH:mm:ss和yyyyMMddHHmmss两种格式。import java.time.LocalDateTime; import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.time.format.ResolverStyle; import java.util.Date; public final class SafeTimeUtils { private static final ZoneId DEFAULT_ZONE ZoneId.of(Asia/Shanghai); private static final DateTimeFormatter FORMATTER_1 DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss) .withZone(DEFAULT_ZONE) .withResolverStyle(ResolverStyle.STRICT); private static final DateTimeFormatter FORMATTER_2 DateTimeFormatter.ofPattern(yyyyMMddHHmmss) .withZone(DEFAULT_ZONE) .withResolverStyle(ResolverStyle.STRICT); private SafeTimeUtils() { } public static Date parse(String source) { LocalDateTime ldt LocalDateTime.parse(source, FORMATTER_1); return Date.from(ldt.atZone(DEFAULT_ZONE).toInstant()); } public static String format(Date date) { return FORMATTER_1.format(date.toInstant()); } public static String formatCompact(Date date) { return FORMATTER_2.format(date.toInstant()); } }这里有几个细节容易被忽略我单独拿出来说。第一DateTimeFormatter的format方法接收的是TemporalAccessor如果你传一个Date需要先转成Instant。上面代码里date.toInstant()一步到位没有任何歧义。第二时区问题。SimpleDateFormat默认使用 JVM 的默认时区如果 JVM 时区配置错了或者被运维调整了业务侧会跟着乱。用DateTimeFormatter时我建议显式指定业务时区我习惯用Asia/Shanghai不用依赖 JVM 全局配置。第三ResolverStyle的差异。SimpleDateFormat的解析是“宽容模式”比如2024-02-30这种不存在的日期它能解析出一个被“修正”后的日期不会报错。DateTimeFormatter默认是SMART模式比SimpleDateFormat要严格一些。如果你希望尽量暴露数据异常设置为STRICT更好解析2024-02-30会直接抛异常。对于支付回调这类场景我更倾向于让异常尽早暴露而不是默默纠正后继续跑流程。4.3 如果项目还停留在 JDK 7 或更低现在还有不少老项目停留在 JDK 7用不了DateTimeFormatter。这时候最现实的方案是ThreadLocal包装SimpleDateFormat但有几个坑要提前规避。private static final ThreadLocalSimpleDateFormat SDF_HOLDER ThreadLocal.withInitial(() - new SimpleDateFormat(yyyy-MM-dd HH:mm:ss)); // 使用示例 SimpleDateFormat sdf SDF_HOLDER.get(); Date date sdf.parse(source);这个写法在普通场景下没问题但如果你是跑在 Tomcat 这类线程池里线程复用会一直存在。ThreadLocal里的SimpleDateFormat对象会跟随线程一直存活如果线程池长期不销毁它也不会被回收。对于这种场景建议在请求处理完毕的finally块里主动调用SDF_HOLDER.remove()避免把对象生命周期和线程生命周期绑死。另外一个务实方案是引入commons-lang3的FastDateFormat它从设计上就是线程安全的不需要ThreadLocal包装。老项目如果不想引入 Joda-Time 这种重依赖FastDateFormat更轻量。不过FastDateFormat的 API 和SimpleDateFormat有些差异迁移时要注意parse方法的签名不一致。5. 时间解析问题背后的幂等体系自查修复SimpleDateFormat只是治标真正要治本的是把接口幂等设计里那些“看起来没问题实际上经不住并发考验”的细节捋一遍。这次事故给我们最大的教训是幂等键的稳定性比唯一索引本身更脆弱。5.1 幂等键的稳定性比唯一索引更重要很多团队的幂等设计长这样接口收到请求先用业务字段拼一个bizId然后查数据库有没有这条记录没有就插入。这种做法在低并发下很有效但存在两个隐患。第一个隐患是“先查后插”本身有竞态窗口。两个线程同时查询都不存在然后同时插入前者成功后者必须先靠唯一索引拦住。但如果你拼bizId时混入了一个不稳定的字段唯一索引根本拦不住因为进来的是两个不同的bizId。第二个隐患是回调来源不同幂等键字段的取值范围可能不稳定。比如支付回调里的“支付完成时间”这个字段本身是支付平台返回的但如果我们又经过一层本地格式化而格式化工具又不具备线程安全性就可能在并发下被篡改。这类字段不应该是幂等键的组成部分或者至少不能是“唯一依赖”。正确做法是幂等键优先使用客户端生成的requestId或者支付平台回调里自带的原始流水号服务端只负责透传和存储不进行任何加工。如果确实需要时间字段参与排序或统计把它作为业务字段单独存不要塞进幂等键。5.2 幂等关键环节自查清单经过这次事故我把团队内部的接口幂等检查清单扩充到了下面几条每次新接口上线前都会过一遍。幂等键是否来自请求方原始标识服务端是否对其做过任何可变的二次加工幂等键是否依赖本地时间、本地格式化结果、随机数等不稳定因素插入数据库时是否依赖“先查后插”还是直接依赖数据库唯一索引兜底捕获唯一索引冲突异常后是否把冲突请求当成普通业务异常返回而不是报 500 重试回调接口的重试机制是否传递了同一个requestId还是每次重试都重新生成所有工具类是否都排查过线程安全性特别是static final修饰的SimpleDateFormat、Calendar、NumberFormat其中最后一条建议直接写成代码扫描规则在 CI 阶段把static SimpleDateFormat这种写法拦截下来。人工 review 总会漏自动化检查才能兜底。5.3 修复后的回归测试怎么做改完代码之后不能只在本地跑一个单元测试就完事。我当时在测试环境写了一个简单的并发脚本模拟 20 个线程同时回调同一个订单每个线程重复 1000 次然后断言最终流水表里该订单的支付流水只有一条。同时再随机生成不同订单号模拟不同订单并发回调检查是否出现串号。用 JMeter 这类工具也可以重点是不要只压 5 个用户。5 个并发很难稳定触发SimpleDateFormat这类线程安全问题因为线程间状态覆盖需要一定的竞争窗口。测试时线程数最好 20 起步循环次数多一些制造足够的竞争压力然后再看有没有异常。别拿生产环境去赌测试环境多跑几轮没坏处。另外可以把“时间字段是否异常”作为一个监控指标。比如解析后的时间如果和服务端当前时间相差超过一定阈值直接告警。这样就算未来还有人写出类似的线程不安全工具类也能第一时间被发现而不是等到对账不平才开始排查。最后说点个人经验。踩过这次坑之后我养成了一个习惯凡是看到static final修饰的对象先问自己一句“它内部有没有可变状态”如果是String、Integer这种不可变对象放心用如果是SimpleDateFormat、Calendar、HashMap这类内部有共享可变状态的必须做线程安全评估。时间字段在业务上看起来是最无辜的但一旦解析错乱它能让整个幂等防线瞬间失守。搞并发编程最怕的不是复杂的锁竞争而是那些平时看起来人畜无害、并发一上来就掀桌子的基础工具类。
