Administrator
发布于 2018-05-15 / 1493 阅读
26

SimpleDateFormat 非线程安全导致线上日期错乱复盘

客服说:订单创建时间显示成了 1970 年

5 月 14 号早上,客服群里转过来一张用户截图,订单详情里的创建时间是 1970-01-01 08:00:00。我第一反应是数据库存了 0,查了一下,数据库里的时间戳完全正常。

那就是显示的时候格式化错了。去看那段代码:

@Service
public class OrderService {

    // 为了"复用",把 SimpleDateFormat 提成了静态常量
    private static final SimpleDateFormat SDF =
            new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    public OrderVO convert(Order order) {
        OrderVO vo = new OrderVO();
        vo.setCreateTime(SDF.format(order.getCreateTime()));
        return vo;
    }
}

我还记得写这行代码时的心理活动:老师说过"不要重复创建对象,浪费性能",所以把它提成 static final。看起来很规范,对吧。

复现:一模一样的异常

在本地用多线程跑了一遍:

public class SdfTest {
    private static final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    public static void main(String[] args) throws InterruptedException {
        ExecutorService pool = Executors.newFixedThreadPool(20);
        CountDownLatch latch = new CountDownLatch(200);

        Set<String> results = Collections.synchronizedSet(new HashSet<>());
        for (int i = 0; i < 200; i++) {
            pool.execute(() -> {
                try {
                    Date d = new Date(1526236800000L);   // 2018-05-14 00:00:00
                    results.add(SDF.format(d));
                } catch (Exception e) {
                    results.add("EXCEPTION: " + e.getClass().getSimpleName() + " " + e.getMessage());
                } finally {
                    latch.countDown();
                }
            });
        }
        latch.await();
        pool.shutdown();

        System.out.println("出现了 " + results.size() + " 种不同的结果:");
        results.forEach(System.out::println);
    }
}

同一个 Date,理论上只能有一种结果。实际输出:

出现了 7 种不同的结果:
2018-05-14 00:00:00
2018-01-05 00:00:00
0018-05-14 00:00:00
2018-05-14 00:00:14
2018-12-05 00:00:00
1970-01-01 00:00:00
EXCEPTION: NumberFormatException multiple points

月份和日期乱窜,年份被截成 0018,还有直接抛 NumberFormatException 的。用户看到的 1970 就是这么来的。

根因:共享的 Calendar 字段

翻开 SimpleDateFormat 的源码,它的父类 DateFormat 里有个 protected 字段:

public abstract class DateFormat extends Format {
    protected Calendar calendar;
    protected NumberFormat numberFormat;
}

SimpleDateFormat 的 format 方法会先把待格式化的时间"设置"到这个共享的 Calendar 上,再逐字段读取:

// SimpleDateFormat.format 内部(JDK 8 节选)
private StringBuffer format(Date date, StringBuffer toAppendTo, FieldDelegate delegate) {
    calendar.setTime(date);        // 第一步:把 calendar 设置成这次要格式化的时间

    boolean useDateFormatSymbols = useDateFormatSymbols();
    for (int i = 0; i < compiledPattern.length; ) {
        int tag = compiledPattern[i] >>> 8;
        int count = compiledPattern[i++] & 0xff;
        switch (tag) {
        case TAG_QUOTE_ASCII_CHAR:
            toAppendTo.append((char)count);
            break;
        case TAG_QUOTE_CHARS:
            toAppendTo.append(compiledPattern, i, count);
            i += count;
            break;
        default:
            subFormat(tag, count, delegate, toAppendTo, useDateFormatSymbols);
            break;
        }
    }
    return toAppendTo;
}

问题就在这:calendar.setTime(date) 和后面 subFormat 里读 calendar 字段的这一整段,没有任何同步措施

多线程下会发生什么?线程 A 执行完 calendar.setTime(2018-05-14),还没来得及读取,线程 B 插进来执行了 calendar.setTime(1970-01-01)。等线程 A 继续往下读字段时,读到的就是 B 设置的值了。两个线程互相踩,于是月份、日期、年份各读各的,拼出一个不存在的日期。

NumberFormatException: multiple points 那个异常也是同理:SimpleDateFormat 内部用 DecimalFormat 格式化数字字段,而 numberFormat 同样是共享字段,两个线程同时往同一个 StringBuffer 里写数字,就写出了 2018.05.14 这种含两个小数点的串,解析时炸掉。

顺带一提,parse 方法也不安全,源码里能看到同样的 calendar.setTime() + 读字段模式,而且它还会先调 calendar.clear(),多线程下有概率把别人的中间状态清掉。

四种修法

方案一:每次 new(最简单,性能可接受)

public OrderVO convert(Order order) {
    SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    vo.setCreateTime(sdf.format(order.getCreateTime()));
}

老实说这是最省心的办法。我实测了一下,单次创建 SimpleDateFormat 大约 3.2 微秒,格式化一次大约 0.8 微秒。也就是说每次 new 会让耗时变成 4 倍——听起来很吓人,但绝对值只有 3 微秒,对于 QPS 几百的接口完全可以忽略。只有每秒几十万次的场景才需要考虑优化。

方案二:加锁或用 ThreadLocal(我们线上临时用的)

出事当天要先止血,我用 ThreadLocal 改的——每个线程一份实例,互不影响:

private static final ThreadLocal<SimpleDateFormat> SDF_HOLDER =
    ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));

public OrderVO convert(Order order) {
    vo.setCreateTime(SDF_HOLDER.get().format(order.getCreateTime()));
}

注意用 ThreadLocal 要留意两个点:一是 Web 容器(Tomcat)的线程是复用的,ThreadLocal 的生命周期跟着线程走,如果放的是大对象会有内存泄漏风险,SimpleDateFormat 不大,无所谓;二是如果线程池里的任务会被传递,ThreadLocal 的值不会跟着走。

方案三:DateTimeFormatter(最终方案)

JDK 8 引入的 java.time 包里,DateTimeFormatter不可变且线程安全的,可以直接定义成 static final 常量放心用:

private static final DateTimeFormatter FORMATTER =
        DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

public OrderVO convert(Order order) {
    LocalDateTime ldt = LocalDateTime.ofInstant(
            order.getCreateTime().toInstant(), ZoneId.systemDefault());
    vo.setCreateTime(ldt.format(FORMATTER));
}

为什么它线程安全?因为 DateTimeFormatter 内部没有任何可变的共享状态——解析和格式化时,它把上下文信息作为参数传递(DateTimePrintContext),而不是存在自己的字段里。这种"无状态"的设计从根上消除了并发问题。

用 200 线程跑一遍同样的测试,200 次结果全部一致,没有任何异常。

方案四:Apache Commons Lang 的 FastDateFormat

如果因为历史原因必须用 Date(比如公司的公共库接口写死了),可以用 commons-lang3 的 FastDateFormat,它是线程安全的:

private static final FastDateFormat FDF =
        FastDateFormat.getInstance("yyyy-MM-dd HH:mm:ss");

内部也是靠不可变对象实现的,API 和 SimpleDateFormat 基本兼容,迁移成本低。

顺带修了数据库字段

改完代码我又回头查了一遍数据库,发现另一个隐患:t_order.create_time 用的是 timestamp 类型,MySQL 5.7 里 timestamp 的范围是 1970-01-01 到 2038-01-19,也就是所谓的 2038 年问题。虽然离得远,但我顺手把新表都改成了 datetime(范围 1000-9999 年)。老表不敢动,怕影响线上。

小结

这次的教训有两条。

第一,"提取成 static final 复用"这个优化动作本身是有前提的——被复用的对象必须线程安全。JDK 里明确标注了线程安全的类(如 String、Integer、DateTimeFormatter、ConcurrentHashMap)可以放心共享,没标注的(SimpleDateFormat、HashMap、ArrayList、Random)一律不要跨线程共享。想偷懒的时候去翻一下 javadoc,看有没有 "This class is thread-safe" 或 "not synchronized" 的字样。

第二,我后来给全项目做了一次扫描,把所有 static 的 SimpleDateFormat 找出来:

grep -rn "static.*SimpleDateFormat" --include=*.java src/

一共 11 处,全都改成了 DateTimeFormatter。这种坑不会在你测试的时候暴露,只会在并发上来之后随机出现,属于典型的"埋雷"型 bug。

参考