On Date/Timestamp Value in A Distributed System

本文探讨了在TDH上使用Inceptor查询Date或Timestamp类型字段时遇到的问题,包括日期多出时分秒的情况和存储过程输出结果错误等问题。分析了DST(夏时制)的影响及各节点时区配置不一致带来的影响。

关于分布式系统中日期或时间戳类型

最近,在TDH上使用Inceptor查询Date或Timestamp类型的字段,多次遇到返回值不稳定或者错误的问题。主要有两种问题表象:

  • 某些特殊Date类型的日期,会多出时分秒,且都是“01:00:00”
  • Date/Timestamp类型字段经存储过程,输出结果错误并且不稳定
    下面将逐一讲解。

开启DST,JVM会给DST开始日零点往前拨一小时

经过多次范围缩小,最终锁定整个数据集中只有某几个日期会多出时分秒,而且都是多了一小时。如:

...
1988-04-10 --> 1988-04-10 01:00:00
1991-04-14 --> 1991-04-14 01:00:00
...

查找了相关资料后,发现了DST(Daylight Saving Time)。

1986年至1991年,中华人民共和国在全国范围实行了六年夏时制。

经过zdump检查,确实操作系统开启了DST:

hadooptest4: # zdump -v /etc/localtime | grep 1988
/etc/localtime Sat Apr 9 15:59:59 1988 UT = Sat Apr 9 23:59:59 1988 CST isdst=0 gmtoff=28800
/etc/localtime Sat Apr 9 16:00:00 1988 UT = Sun Apr 10 01:00:00 1988 CDT isdst=1 gmtoff=32400
/etc/localtime Sat Sep 10 14:59:59 1988 UT = Sat Sep 10 23:59:59 1988 CDT isdst=1 gmtoff=32400
/etc/localtime Sat Sep 10 15:00:00 1988 UT = Sat Sep 10 23:00:00 1988 CST isdst=0 gmtoff=28800

这表明,1988年4月10日零点来临之时,由于实施夏时制,即DST,时钟会自动往前拨一小时,这才导致了Date类型值多出时分秒的错误。

但是,设置关闭DST时,遇到了另一个打破常识的说法:Etc/GMT-8等价于平时说的UTC+8或GMT+8。参考tz database

In the “Etc” area, zones west of GMT have a positive sign and those east have a negative sign in their name (e.g “Etc/GMT-14” is 14 hours ahead/east of GMT.)

所以最终的解法就是关闭所有JVM程序,设置不带DST的时区,再重启程序:

# cp /etc/localtime /etc/localtime.bk
# ln /usr/share/zoneinfo/Etc/GMT-8 -sf /etc/localtime

各节点时区设置不一致,Date/Timestamp值不稳定

在分布式系统中,需要尽量保证各节点的配置相同,不然容易引发一些匪夷所思的问题。时区设置就是一点,纵使设置成中国不同的城市,也会有细微的不同。这个问题来自于一个很平常的日期:

unstable1
以20170918为过滤条件,出现大量20170917

unstable2
并且同一句SQL,返回值是不一样的,20170917也有多有少。

首先排除了DST的原因:

  • 2017年已经不实行夏时制
  • 即使有夏时制,9月18日也不属于开始日(零时往前拨一个小时),结束日(一时往回拨到零时

经过代码检查,发现各节点的时区大不相同:

import java.util.TimeZone;
System.out.println("TimeZone: " + TimeZone.getDefault());

Different Timezone

从上图可以看出,节点间存在相差16个小时的问题,这也是之前不了解Etc的特点,用Etc/GMT+8关闭DST留下的问题。

值得注意的是,统一修改成Etc/GMT-8和ntp同步时间后发现时间不对,又修改回PRC(UTC+8/中国标准时区CST),但此后通过上述代码检查,仍旧出现时区不一致的问题。暂时无法充分解释此现象


2018.01.29 更新:JVM读到不同时区的原因
起初并不清楚该问题的原因,最近又再次遇到类似集群,便和同事深入排查了一番,发现以下3个过程:

  • Linux中的三个时区文件 Shanghai、Chongqing,Harbin 文件内容一模一样,只有名字不同;
  • Linux在读取/etc/localtime 的时,会按照一个不确定的顺序获取 Shanghai/Harbin/Chongqing 中的一个,但不管取到哪个,对操作系统而言都是对的;
  • 集群中各节点取到了Shanghai/Harbin/Chongqing中不同的城市,JVM的就会按照不同的城市进行不同的处理。

解决方法:重新修改系统的/etc/localtime,并保证/etc/sysconfig/clock内容一致能解决这个问题。
最后,附上修改过程:

echo 'show current system time'
date

echo 'rm /etc/localtime'
rm -rf /etc/localtime

echo 'show current UTC time'
date

echo 'link localtime to Etc/GMT-8'
ln /usr/share/zoneinfo/Etc/GMT-8 -sf /etc/localtime

echo 'sync time from NTP server'
sntp -r 88.3.224.1

echo 'show current system time'
date

echo 'check timezone'
java -cp /root/test-date-1.0.0.jar io.transwarp.test.TestDate


echo 'adjust and unify /etc/sysconfig/clock'
echo 'set HWCLOCK to "--localtime"'
sed -i 's/HWCLOCK=.*/HWCLOCK="--localtime"/g' /etc/sysconfig/clock

echo 'set SYSTOHC to "yes"'
sed -i 's/SYSTOHC=.*/SYSTOHC="yes"/g' /etc/sysconfig/clock

echo 'set TIMEZONE and DEFAULT_TIMEZONE to "Asia/Beijing"'
sed -i 's/TIMEZONE=.*/TIMEZONE="Asia\/Beijing"/g' /etc/sysconfig/clock

echo 'set system time back to hardware clock'
hwclock -w

echo 'show hwclock and hwclock --localtime'
hwclock && hwclock --localtime

echo 'check timezone'
java -cp /root/test-date-1.0.0.jar io.transwarp.test.TestDate

参考文章:

生成这个PO的建表语句 // DeviceInfo.java (已有部分保持不变,新增关联关系) /* Copyright 2019-2025 Zheng Jie Licensed under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an “AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package me.zhengjie.gen.domain; import lombok.Data; import cn.hutool.core.bean.BeanUtil; import io.swagger.annotations.ApiModelProperty; import cn.hutool.core.bean.copier.CopyOptions; import javax.persistence.*; import javax.validation.constraints.NotBlank; import java.sql.Timestamp; import java.io.Serializable; /** @website https://eladmin.vip @description / @author chen jiayuan @date 2025-09-16 **/ @Entity @Data @Table(name=“device_info”) public class DeviceInfo implements Serializable { // 设备状态常量 public static final Integer STATUS_OFFLINE = 0; // 下线 public static final Integer STATUS_ONLINE = 1; // 上线 @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @ApiModelProperty(value = “ID”) private Integer id; @Column(name = “country”) @NotBlank @ApiModelProperty(value = “国家码,例如:JP”) private String country; @Column(name = “model”, unique = true) @NotBlank @ApiModelProperty(value = “设备型号,例如:ER7206”) private String model; @Column(name = “model_version”) @ApiModelProperty(value = “设备版本,例如:1.0”) private String modelVersion; @Column(name = “model_template_id”) @ApiModelProperty(value = “设备模板ID”) private Integer modelTemplateId; @Column(name = “image_info_id”) @ApiModelProperty(value = “镜像信息ID”) private Integer imageInfoId; @Column(name = “status”) @ApiModelProperty(value = “状态:0-下线,1-上线”) private Integer status = 1; // 默认上线 @Column(name = “created_at”) @ApiModelProperty(value = “创建时间”) private Timestamp createdAt; @Column(name = “updated_at”) @ApiModelProperty(value = “更新时间”) private Timestamp updatedAt; public void copy(DeviceInfo source){ BeanUtil.copyProperties(source,this, CopyOptions.create().setIgnoreNullValue(true)); } }
最新发布
10-10
我目前后端的service是这样写的, @Override @Transactional(rollbackFor = Exception.class) public void submitApplication(DeviceApplicationForm resources) { Timestamp now = new Timestamp(System.currentTimeMillis()); // 保存或更新申请单 if (resources.getId() == null) { // 首次提交 resources.setStatus(0); // 待审批状态 resources.setCreatedAt(now); resources.setUpdatedAt(now); deviceApplicationFormRepository.save(resources); } else { // 重新提交 DeviceApplicationForm existing = deviceApplicationFormRepository.findById(resources.getId()) .orElseThrow(() -> new RuntimeException("申请单不存在")); existing.setApplicationTitle(resources.getApplicationTitle()); existing.setApplicationReason(resources.getApplicationReason()); existing.setTestContact(resources.getTestContact()); existing.setTestLeader(resources.getTestLeader()); existing.setDevContact(resources.getDevContact()); existing.setDevLeader(resources.getDevLeader()); existing.setStatus(0); // 重置为待审批状态 existing.setUpdatedAt(now); deviceApplicationFormRepository.save(existing); } // 获取申请单ID Integer applicationFormId = resources.getId() != null ? resources.getId() : resources.getId(); // 确定审批轮次 Integer round = approvalRecordRepository.findMaxRoundByApplicationFormId(applicationFormId) + 1; // 清除之前的审批状态 currentApprovalStatusRepository.deleteByApplicationFormIdAndRound(applicationFormId, round - 1); // 初始化第一阶段审批状态(并行审批) List<CurrentApprovalStatus> initialApprovals = new ArrayList<>(); // 添加测试接口人 CurrentApprovalStatus testContactStatus = new CurrentApprovalStatus(); testContactStatus.setApplicationFormId(applicationFormId); testContactStatus.setRound(round); testContactStatus.setStepOrder(1); testContactStatus.setApproverRole("test_contact"); testContactStatus.setApproverName(resources.getTestContact()); testContactStatus.setStatus(0); // 待审批 initialApprovals.add(testContactStatus); // 添加研发接口人 CurrentApprovalStatus devContactStatus = new CurrentApprovalStatus(); devContactStatus.setApplicationFormId(applicationFormId); devContactStatus.setRound(round); devContactStatus.setStepOrder(1); devContactStatus.setApproverRole("dev_contact"); devContactStatus.setApproverName(resources.getDevContact()); devContactStatus.setStatus(0); // 待审批 initialApprovals.add(devContactStatus); // 保存初始审批状态 currentApprovalStatusRepository.saveAll(initialApprovals); } /* * Copyright 2019-2025 Zheng Jie * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package me.zhengjie.gen.domain; import lombok.Data; import cn.hutool.core.bean.BeanUtil; import io.swagger.annotations.ApiModelProperty; import cn.hutool.core.bean.copier.CopyOptions; import javax.persistence.*; import javax.validation.constraints.*; import java.sql.Timestamp; import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotNull; import java.io.Serializable; /** * @website https://eladmin.vip * @description / * @author Chen Jiayuan * @date 2025-09-18 **/ @Entity @Data @Table(name="device_application_form") public class DeviceApplicationForm implements Serializable { // 状态常量定义 public static final Integer STATUS_DRAFT = -1; // 草稿 public static final Integer STATUS_PENDING = 0; // 待审批 public static final Integer STATUS_APPROVED = 1; // 审批通过 public static final Integer STATUS_FIRMWARE_VERIFY = 2; // 固件校验中 public static final Integer STATUS_FIRMWARE_FAILED = 3; // 固件校验失败 public static final Integer STATUS_SYNCING = 4; // 同步中 public static final Integer STATUS_SYNC_FAILED = 5; // 同步失败 public static final Integer STATUS_COMPLETED = 6; // 已完成 public static final Integer STATUS_REJECTED = 7; // 已驳回 public static final Integer STATUS_AUTO_PROCESSING = 8; // 自动处理中 public static final Integer STATUS_AUTO_FAILED = 9; // 自动处理失败 public static final Integer STATUS_MANUAL_TRIGGERED = 10; // 手动触发 @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "`id`") @ApiModelProperty(value = "id") private Integer id; @Column(name = "`applicant_id`",unique = true,nullable = false) @NotBlank @ApiModelProperty(value = "申请单UUID") private String applicantId; @Column(name = "`applicant_name`",nullable = false) @NotBlank @ApiModelProperty(value = "申请人姓名") private String applicantName; @Column(name = "`department`") @ApiModelProperty(value = "所属部门") private String department; @Column(name = "`application_date`",nullable = false) @NotNull @ApiModelProperty(value = "申请日期") private Timestamp applicationDate; @Column(name = "`application_data_id`",unique = true) @ApiModelProperty(value = "申请数据id") private Integer applicationDataId; @Column(name = "`application_type`",nullable = false) @NotNull @ApiModelProperty(value = "申请单类型:新增,修改,上线,下线") private Integer applicationType; @Column(name = "`application_data_type`",nullable = false) @NotNull @ApiModelProperty(value = "申请单数据类型:omada,vigi,adblocking") private Integer applicationDataType; @Column(name = "`application_title`",nullable = false) @NotBlank @ApiModelProperty(value = "申请单标题") private String applicationTitle; @Column(name = "`application_reason`") @ApiModelProperty(value = "申请理由") private String applicationReason; @Column(name = "`status`",nullable = false) @NotNull @ApiModelProperty(value = "申请状态") private Integer status; @Column(name = "`test_contact`") @ApiModelProperty(value = "测试接口人") private String testContact; @Column(name = "`test_leader`") @ApiModelProperty(value = "测试组长") private String testLeader; @Column(name = "`dev_contact`") @ApiModelProperty(value = "研发接口人") private String devContact; @Column(name = "`dev_leader`") @ApiModelProperty(value = "研发组长") private String devLeader; @Column(name = "`test_contact_approval`") @ApiModelProperty(value = "测试接口人审批状态") private Integer testContactApproval; @Column(name = "`test_leader_approval`") @ApiModelProperty(value = "测试组长审批状态") private Integer testLeaderApproval; @Column(name = "`dev_contact_approval`") @ApiModelProperty(value = "研发接口人审批状态") private Integer devContactApproval; @Column(name = "`dev_leader_approval`") @ApiModelProperty(value = "研发组长审批状态") private Integer devLeaderApproval; @Column(name = "`test_contact_comment`") @ApiModelProperty(value = "测试接口人审批意见") private String testContactComment; @Column(name = "`test_leader_comment`") @ApiModelProperty(value = "测试组长审批意见") private String testLeaderComment; @Column(name = "`dev_contact_comment`") @ApiModelProperty(value = "研发接口人审批意见") private String devContactComment; @Column(name = "`dev_leader_comment`") @ApiModelProperty(value = "研发组长意见审批") private String devLeaderComment; @Column(name = "`current_approvers`") @ApiModelProperty(value = "当前审核人列表(JSON格式存储)") private String currentApprovers; @Column(name = "`approval_history`") @ApiModelProperty(value = "审核历史表(JSON格式存储,记录每次提交的审批人,审批状态和审批意见)") private String approvalHistory; @Column(name = "`device_info_details`") @ApiModelProperty(value = "设备信息详情(JSON格式存储)") private String deviceInfoDetails; @Column(name = "`created_at`") @ApiModelProperty(value = "createdAt") private Timestamp createdAt; @Column(name = "`updated_at`") @ApiModelProperty(value = "updatedAt") private Timestamp updatedAt; public void copy(DeviceApplicationForm source){ BeanUtil.copyProperties(source,this, CopyOptions.create().setIgnoreNullValue(true)); } } 我前端本来也上传(DeviceApplicationForm但是我现在要改成 DeviceApplicationFormVo {,请帮我修改service代码
09-20
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值