目录
问题描述
异常定位
排查路线
解决及思考
解决方案
思考
附-问题重现
分享一个时间格式处理的坑。
昨天晚上的时候,同事遇到了一个问题,客户投诉说我们系统同步给他们的订单创建时间和他们系统保存的创建时间出现了大范围的不一致现象,影响到了别的相关业务系统业务,要求我们立刻核查处理(这里解释一下,订购方,我方,落地方系统需要定时对比订单信息,三方比对保证订单信息一致)
因为大晚上,比较无聊,我刚好和同事在聊天,所以一块看了看这个问题
首先,出现这种问题,我们考虑了最有可能出现的问题
1、我们在接收订单的时候,对方传输的订单数据存在异常
2、我们接受订单,对于时间的转换存在问题
3、DAO框架不当的使用,加上数据库默认值配置的问题
4、其他业务场景对订单的更新造成的问题
开始挨个排查可能的原因
1、查找当时的订购日志,确认订购方发给我们的订单数据是否存在异常
经排查,订购方发送的订购数据正常。
2、查看程序是否抛出异常或错误
过滤全链路监控日志,无相关异常或错误
3、查看代码与数据库,是否设置了统一时间或默认值
经查看,无特殊设置赋值,DBA告知无默认值设置
4、询问相关同事及负责人,其他业务对此订单库表的使用情况
确认完毕,无其他业务或系统使用此订单库表,此订单库表为当前服务独享
喂喂喂,怎么肥四,怎么都没问题,干脆说不找了吧这个问题,让他们自己看着办修复吧
开个玩笑,当然要解决
经过上面的排查,几乎可以定位,问题点不在数据库,不在外部系统,在于内部对时间的处理
重新研究关于订单数据时间上的处理
发现代码如下:
String recordTime = sf.format(orderDate);
sf为:
private SimpleDateFormat sf = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");
对方格式为 yyyyMMddhhmmss
考虑问题出在这儿的转换上
此刻我已经猜测到问题所在,因为SimpleDateFormat 线程不安全,并发场景下会存在数据问题
但出于严谨,和同事进行了测试
1、造出一部分数据
2、在测试环境进行并发访问
3、查看数据信息
测试完成,确认此处转换存在问题
1、使用JDK8提供的线程安全的类 DateTimeFormatter 进行时间格式转换
2、进行格式转换前,对于 SimpleDateFormat ,不再公用,单独new,保证线程独享
虽然问题已经解决,但是为什么出现这个问题,还是可以探索一下的
SimpleDateFormat.format() 不安全,为什么不安全?只能查看源码,看能否找到答案
注意,format点进来,默认的是dateformat这个抽象类
我们要找到下面的真正的实现方法, SimpleDateFormat.format() 方法
可以看到,SimpleDateFormat.format() 调用了内部的 format 方法
这就是 SimpleDateFormat.format() 对时间格式的处理,⚠️ 注意,整个处理,是没有加锁的,也就是说,多线程情况下,存在竞争问题
看我圈的这一行
假设A线程设置了一个时间,B线程同时设置了另一个时间,这个时候,日历时间就已经不再正确
下边不管干啥,也没什么意义了
所以说,考虑线程安全问题很重要
附上问题重新代码及情况
我们只要模拟多个线程共用一个 SimpleDateFormat 就可以复现线程安全问题,为此,我准备了一份代码
public static void main(String[] args) throws InterruptedException, ClassNotFoundException, SQLException {
Class.forName("com.mysql.jdbc.Driver");
Connection connection = DriverManager.getConnection("jdbc:mysql://127.0.0.1:3306/user?useUnicode=true&characterEncoding=UTF-8&useSSL=false&rewriteBatchedStatements=true", "root", "root");
Statement statement = connection.createStatement();
ResultSet resultSet = statement.executeQuery("select create_time from users");
List list = new ArrayList<>();
ResultSetMetaData metaData = resultSet.getMetaData();
int columnCount = metaData.getColumnCount();
while (resultSet.next()) {
list.add(resultSet.getDate("create_time"));
}
SimpleDateFormat sf = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");
DateTimeFormatter df = DateTimeFormatter.ofPattern("yyyy-MM-dd hh:mm:ss");
ConcurrentHashMap map = new ConcurrentHashMap<>();
CountDownLatch countDownLatch = new CountDownLatch(1);
for (Date date : list) {
new Thread(() -> {
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
map.put(new SimpleDateFormat("yyyy-MM-dd hh:mm:ss").format(date), sf.format(date));
}).start();
}
for (int i = 0; i < 100; i++) {
}
countDownLatch.countDown();
Thread.sleep(10000L);
for (String key : map.keySet()) {
System.out.println(key + " " + map.get(key));
}
}
1、为了模拟场景真实与可靠,我创建了一张表,放入完全不同的创建时间,读取出这些时间
2、为了更加真实的模拟线程竞争的情况,一个时间创建一个线程,并阻塞这些线程
3、同步唤醒这些线程,尽管计数器底层也是挨个唤醒,但这点时间几乎可以忽略不计
3、为了更好的看出时间的区别,采用线程安全的map显示转换前后
4、key使用单独的独享的转换,value使用公共的转换
运行,打印,结果如下:
ok,本次的填坑记录到此为止,闪人,睡觉~