时区处理是日历系统中最复杂的部分,没有之一。实现过程中需要仔细处理各种时区转换和边界情况。这篇文章记录解决方案和思路。
时区为什么这么复杂
表面上看,时区就是UTC偏移量——北京是UTC+8,纽约是UTC-5。但实际工程中要面对的问题远比这复杂:
1. 夏令时(DST)
美国东部时区(America/New_York)在夏天是UTC-4(EDT),冬天是UTC-5(EST)。每年3月第二个周日凌晨2点跳到3点,11月第一个周日凌晨2点回到1点。这意味着:
- 3月那天只有23小时
- 11月那天有25小时
- 存在一个"不存在的时间"(2:00-3:00在春天)和一个"重复的时间"(1:00-2:00在秋天)
2. 历史时区变更
时区规则不是一成不变的。中国在1986-1991年实行过夏令时,土耳其在2016年取消了夏令时,萨摩亚在2011年直接跳过了12月30日从UTC-11切换到UTC+13。IANA时区数据库每年更新好几次。
3. 浮动时间 vs 固定时间
有些事件是"浮动"的——比如"每天早上9点晨会",不管你在哪个时区都是当地9点。而"北京时间下午3点的跨国会议"是固定的,对纽约的同事来说是凌晨3点。
Java时区API
Java 8的java.time包在时区处理上做得很好,核心类:
// ZonedDateTime:带时区的完整日期时间
ZonedDateTime meeting = ZonedDateTime.of(
2022, 11, 10, 15, 0, 0, 0,
ZoneId.of("Asia/Shanghai")
);
// 转换到其他时区
ZonedDateTime inNewYork = meeting.withZoneSameInstant(
ZoneId.of("America/New_York")
);
// 2022-11-10T02:00-05:00[America/New_York]
// Instant:时间线上的一个绝对点,不带时区
Instant instant = meeting.toInstant();
// LocalDateTime:没有时区信息的日期时间(浮动时间)
LocalDateTime floatingTime = LocalDateTime.of(2022, 11, 10, 9, 0);
关键区别:ZonedDateTime是一个绝对时刻,LocalDateTime是一个"概念上的时间"。
存储策略
经过反复考虑,我采用了这样的存储方案:
CREATE TABLE calendar_events (
id BIGINT PRIMARY KEY,
title VARCHAR(500),
-- 固定时间事件
start_instant TIMESTAMP, -- UTC时间戳
end_instant TIMESTAMP, -- UTC时间戳
timezone_id VARCHAR(50), -- 原始时区ID,如 'Asia/Shanghai'
-- 浮动时间事件
start_local TIMESTAMP, -- 本地时间(无时区)
end_local TIMESTAMP,
is_floating BOOLEAN DEFAULT FALSE,
-- ...
);
核心原则:同时存储UTC时间和原始时区ID。
为什么不只存UTC?因为时区规则可能会变。假设你创建了一个"明年3月15日下午3点北京时间"的事件,如果中国突然宣布恢复夏令时(虽然不太可能),UTC偏移就变了。存了原始时区ID,就能根据最新的时区规则重新计算UTC时间。
为什么不只存本地时间+时区ID?因为查询太复杂。要查某个时间范围内的所有事件,如果每条记录的时区都不同,SQL根本没法写。UTC时间戳可以直接做范围查询。
重复事件的时区处理
这是最头疼的部分。考虑这个场景:
纽约用户创建了一个"每周一上午9:00"的重复会议。
2022年3月14日(夏令时开始后的第一个周一),这个会议应该是几点?答案是当地时间9:00 AM EDT(UTC-4),而不是之前的9:00 AM EST(UTC-5)。也就是说,对应的UTC时间从14:00变成了13:00。
我的实现:
public List<ZonedDateTime> expandRecurrence(
RecurrenceRule rule,
ZonedDateTime dtStart,
ZoneId eventZone,
Instant rangeStart,
Instant rangeEnd) {
List<ZonedDateTime> occurrences = new ArrayList<>();
// 在本地时间域中展开重复规则
LocalDateTime localStart = dtStart.toLocalDateTime();
LocalDateTime current = localStart;
while (true) {
// 将本地时间转换回带时区的时间
// 这里用ofLocal处理DST间隙和重叠
ZonedDateTime zdt = current.atZone(eventZone);
// 转成Instant做范围判断
if (zdt.toInstant().isAfter(rangeEnd)) break;
if (!zdt.toInstant().isBefore(rangeStart)) {
occurrences.add(zdt);
}
// 按规则计算下一次
current = rule.nextOccurrence(current);
if (current == null) break;
}
return occurrences;
}
关键在于:在本地时间域(LocalDateTime)中做日期运算,然后转换回ZonedDateTime。这样DST切换时,本地时间保持不变,UTC偏移自动调整。
踩过的坑
坑1:ZonedDateTime.plusDays()的行为
// 2022-03-12 09:00 EST (UTC-5)
ZonedDateTime before = ZonedDateTime.of(
2022, 3, 12, 9, 0, 0, 0,
ZoneId.of("America/New_York"));
// 加1天 → 2022-03-13 09:00 EDT (UTC-4) ✓
// 实际经过了23小时(因为夏令时跳了1小时)
ZonedDateTime after = before.plusDays(1);
// 加24小时 → 2022-03-13 10:00 EDT (UTC-4) ✗
// 实际经过了24小时,但本地时间变成了10点
ZonedDateTime wrong = before.plusHours(24);
plusDays(1)和plusHours(24)在DST切换时结果不同!日历系统中应该用plusDays。
坑2:不存在的时间
// 2022-03-13 02:30 在纽约不存在(从2:00直接跳到3:00)
LocalDateTime gap = LocalDateTime.of(2022, 3, 13, 2, 30);
ZonedDateTime resolved = gap.atZone(ZoneId.of("America/New_York"));
// Java默认行为:向前调整到 03:30 EDT
如果用户的重复事件恰好落在DST间隙中,需要决定如何处理。我选择了Java默认的"向前调整"策略。
坑3:时区ID不要用缩写
EST、CST这类缩写是有歧义的。CST可能是中国标准时间(UTC+8)、美国中部标准时间(UTC-6)、或古巴标准时间(UTC-5)。永远使用IANA格式:Asia/Shanghai、America/Chicago。
坑4:数据库的时区陷阱
MySQL的TIMESTAMP类型会自动做时区转换(基于time_zone系统变量),而DATETIME不会。我最终选择了TIMESTAMP配合SET time_zone = '+00:00',确保所有存取都是UTC。
测试策略
时区相关的测试特别重要,我写了一组针对边界情况的测试用例:
@ParameterizedTest
@CsvSource({
// 正常情况
"America/New_York, 2022-06-15T09:00, 2022-06-15T13:00Z",
// EST → EDT 切换日
"America/New_York, 2022-03-13T09:00, 2022-03-13T13:00Z",
// EDT → EST 切换日
"America/New_York, 2022-11-06T09:00, 2022-11-06T14:00Z",
// 无DST的时区
"Asia/Shanghai, 2022-06-15T09:00, 2022-06-15T01:00Z",
})
void testLocalToUtc(String zoneId, String localTime, String expectedUtc) {
LocalDateTime local = LocalDateTime.parse(localTime);
ZonedDateTime zdt = local.atZone(ZoneId.of(zoneId));
assertEquals(Instant.parse(expectedUtc), zdt.toInstant());
}
时区处理没有捷径,只有理解底层机制并充分测试才能避免线上事故。下一篇准备聊CalDAV协议的同步实现。