记Date的一次踩坑
一. 背景
1. 线上问题
新功能的营销活动时间23:59:59才结束, 但是16点多发现页面显示《活动已结束》
2. 排查过程
- http抓数据包: activityEndTime为1654761599000, 转化为Date为
2022-06-09 15:59:59
问题显而易见, 少了8个小时. 且不是前端的问题
查询DB数据: 活动结束时间是分为两个字段储存的:
endDate (dateTime类型): 2022-06-09
endTime(time类型): 23:59:59
java类型都是用Date接收的. 暂时也没发现问题, 难道是time类型转Date类型导致的?
- 查看缓存: endDate=1654704000000, endDate=57599000
换算了下没问题, 说明数据从Db -> java中Date类型 -> Json序列化
, 链路是没问题的
那只有一种可能, endDate和endTime拼接成activityEndTime的时候出问题了
- 查看拼接逻辑: activityEndTime = endDate.getTime()+endTime.getTime();
这太简单了, 难道会有问题? debug跟了一下, 卧槽! 果然结果有问题!!
带着疑问点开了Date.getTime()方法:
/**
* Returns the number of milliseconds since January 1, 1970, 00:00:00 GMT
* represented by this <tt>Date</tt> object.
*
* @return the number of milliseconds since January 1, 1970, 00:00:00 GMT
* represented by this date.
*/
public long getTime() {
return getTimeImpl();
}
也就是说getTime()返回的时间戳是相对于0时区的 January 1, 1970, 00:00:00
而言
此时对于东8区而言, 是 January 1, 1970, 08:00:00
验证了下: 57599000/3600000 = 15:59:59, 而new Date(57599000L).toString()
结果是Thu Jan 01 23:59:59 CST 1970
总结一句话: Date是带时区的(跟着当前坐标的时区走), long类型的时间戳相对于0时区
3. 问题的原因
那这个地方为什么会少8小时呢?
因为案例里的2022-06-09和23:59:59, 都是当前时区的时间, 即东8区的时间
activityEndTime = endDate.getTime()+endTime.getTime()
调用getTime()会换算成0时区的时间戳时, 会向左偏移8小时(即减少), 前端拿到相加的activityEndTime
会向右偏移转化为东8区的Date类型.
但前面向左便宜了两次, 后面只向右偏移了一次. 所以不对等, 导致多偏移了一次减少了8小时.
可以运行以下demo:
Date date1 = new Date(0L);//0时区的0点,东8区的8点
Date date2 = new Date(3600*1000L);//0时区的1点,东8区的9点
Date date3 = new Date(7200L*1000);//0时区的3点,东8区的11点
Date date4 = new Date(date1.getTime() + date2.getTime());
Date date5 = new Date(date1.getTime() + date2.getTime() + date3.getTime());
System.out.println(date4);
System.out.println(date5);
会发现date4
少了8小时(多偏移了1次), date5
少了16小时(多偏移了两次)
解决方法: 不要用时间戳相+的方式, 可以借助于dateFormat, 或者转化为Calendar后偏移时间
4. Date的时区确定
那Date是怎么确定当前时区的呢? 带着疑问看了下toString方法:
public String toString() {
// "EEE MMM dd HH:mm:ss zzz yyyy";
BaseCalendar.Date date = normalize();//将时间戳换算成当前时区的时间
StringBuilder sb = new StringBuilder(28);
int index = date.getDayOfWeek();
if (index == BaseCalendar.SUNDAY) {
index = 8;
}
convertToAbbr(sb, wtb[index]).append(' '); // EEE
convertToAbbr(sb, wtb[date.getMonth() - 1 + 2 + 7]).append(' '); // MMM
CalendarUtils.sprintf0d(sb, date.getDayOfMonth(), 2).append(' '); // dd
CalendarUtils.sprintf0d(sb, date.getHours(), 2).append(':'); // HH
CalendarUtils.sprintf0d(sb, date.getMinutes(), 2).append(':'); // mm
CalendarUtils.sprintf0d(sb, date.getSeconds(), 2).append(' '); // ss
TimeZone zi = date.getZone();
if (zi != null) {
sb.append(zi.getDisplayName(date.isDaylightTime(), TimeZone.SHORT, Locale.US)); // zzz
} else {
sb.append("GMT");
}
sb.append(' ').append(date.getYear()); // yyyy
return sb.toString();
}
private final BaseCalendar.Date normalize() {
if (cdate == null) {
BaseCalendar cal = getCalendarSystem(fastTime);
cdate = (BaseCalendar.Date) cal.getCalendarDate(fastTime,
TimeZone.getDefaultRef());
return cdate;
}
// Normalize cdate with the TimeZone in cdate first. This is
// required for the compatible behavior.
if (!cdate.isNormalized()) {
cdate = normalize(cdate);
}
// If the default TimeZone has changed, then recalculate the
// fields with the new TimeZone.
TimeZone tz = TimeZone.getDefaultRef();
if (tz != cdate.getZone()) {
cdate.setZone(tz);
CalendarSystem cal = getCalendarSystem(cdate);
cal.getCalendarDate(fastTime, cdate);
}
return cdate;
}
static TimeZone getDefaultRef() {
TimeZone defaultZone = defaultTimeZone;
if (defaultZone == null) {
// Need to initialize the default time zone.
defaultZone = setDefaultZone();
assert defaultZone != null;
}
// Don't clone here.
return defaultZone;
}
private static synchronized TimeZone setDefaultZone() {
TimeZone tz;
// get the time zone ID from the system properties
String zoneID = AccessController.doPrivileged(
new GetPropertyAction("user.timezone"));
// if the time zone ID is not set (yet), perform the
// platform to Java time zone ID mapping.
if (zoneID == null || zoneID.isEmpty()) {
String javaHome = AccessController.doPrivileged(
new GetPropertyAction("java.home"));
try {
zoneID = getSystemTimeZoneID(javaHome);
if (zoneID == null) {
zoneID = GMT_ID;
}
} catch (NullPointerException e) {
zoneID = GMT_ID;
}
}
// Get the time zone for zoneID. But not fall back to
// "GMT" here.
tz = getTimeZone(zoneID, false);
if (tz == null) {
// If the given zone ID is unknown in Java, try to
// get the GMT-offset-based time zone ID,
// a.k.a. custom time zone ID (e.g., "GMT-08:00").
String gmtOffsetID = getSystemGMTOffsetID();
if (gmtOffsetID != null) {
zoneID = gmtOffsetID;
}
tz = getTimeZone(zoneID, true);
}
assert tz != null;
final String id = zoneID;
AccessController.doPrivileged(new PrivilegedAction<Void>() {
@Override
public Void run() {
System.setProperty("user.timezone", id);
return null;
}
});
defaultTimeZone = tz;
return tz;
}
private static native String getSystemTimeZoneID(String javaHome);
总结下: 就是先根据系统变量user.timezone
获取, 若未设置最后调用到本地方法根据javaHome获取
从网上查了下, 一般当前时区配置在/etc/localtime
里, 多有的地区对应的时区库在/var/db/timezone/zoneinfo
- 下面是所有时区的简写, toString方法时候对应
Thu Jan 01 23:59:59 CST
中的CST
标准时间代码 | 与GMT的偏移量 | 描述 |
---|---|---|
NZDT | +13:00 | 新西兰夏令时 |
IDLE | +12:00 | 国际日期变更线,东边 |
NZST | +12:00 | 新西兰标准时间 |
NZT | +12:00 | 新西兰时间 |
AESST | +11:00 | 澳大利亚东部夏时制 |
CST(ACSST) | +10:30 | 中澳大利亚标准时间 |
CADT | +10:30 | 中澳大利亚夏时制 |
SADT | +10:30 | 南澳大利亚夏时制 |
EST(EAST) | +10:00 | 东澳大利亚标准时间 |
GST | +10:00 | 关岛标准时间 |
LIGT | +10:00 | 澳大利亚墨尔本时间 |
CAST | +9:30 | 中澳大利亚标准时间 |
SAT(SAST) | +9:30 | 南澳大利亚标准时间 |
WDT(AWSST) | +9:00 | 澳大利亚西部标准夏令时 |
JST | +9:00 | 日本标准时间,(USSR Zone 8) |
KST | +9:00 | 韩国标准时间 |
MT | +8:30 | 毛里求斯时间 |
WST(AWST) | +8:00 | 澳大利亚西部标准时间 |
CCT | +8:00 | 中国沿海时间(北京时间) |
JT | +7:30 | 爪哇时间 |
IT | +3:30 | 伊朗时间 |
BT | +3:00 | 巴格达时间 |
EETDST | +3:00 | 东欧夏时制 |
CETDST | +2:00 | 中欧夏时制 |
EET | +2:00 | 东欧,(USSR Zone 1) |
FWT | +2:00 | 法国冬时制 |
IST | +2:00 | 以色列标准时间 |
MEST | +2:00 | 中欧夏时制 |
METDST | +2:00 | 中欧白昼时间 |
SST | +2:00 | 瑞典夏时制 |
BST | +1:00 | 英国夏时制 |
CET | +1:00 | 中欧时间 |
DNT | +1:00 | Dansk Normal Tid |
FST | +1:00 | 法国夏时制 |
MET | +1:00 | 中欧时间 |
MEWT | +1:00 | 中欧冬时制 |
MEZ | +1:00 | 中欧时区 |
NOR | +1:00 | 挪威标准时间 |
SET | +1:00 | Seychelles Time |
SWT | +1:00 | 瑞典冬时制 |
WETDST | +1:00 | 西欧光照利用时间(夏时制) |
GMT | 0:00 | 格林威治标准时间 |
WET | 0:00 | 西欧 |
WAT | -1:00 | 西非时间 |
NDT | -2:30 | 纽芬兰(新大陆)白昼时间 |
ADT | -03:00 | 大西洋白昼时间 |
NFT | -3:30 | 纽芬兰(新大陆)标准时间 |
NST | -3:30 | 纽芬兰(新大陆)标准时间 |
AST | -4:00 | 大西洋标准时间(加拿大) |
EDT | -4:00 | (美国)东部夏令时 |
CDT | -5:00 | (美国)中部夏令时 |
EST | -5:00 | (美国)东部标准时间 |
CST | -6:00 | (美国)中部标准时间 |
MDT | -6:00 | (美国)山地夏令时 |
MST | -7:00 | (美国)山地标准时间 |
PDT | -7:00 | (美国)太平洋夏令时 |
PST | -8:00 | (美国)太平洋标准时间 |
YDT | -8:00 | Yukon夏令时 |
HDT | -9:00 | 夏威仪/阿拉斯加白昼时间 |
YST | -9:00 | Yukon标准时 |
AHST | -10:00 | 夏威仪-阿拉斯加标准时间 |
CAT | -10:00 | 中阿拉斯加时间 |
NT | -11:00 | 州时间(Nome Time) |
IDLW | -12:00 | 国际日期变更线,西边 |