日历系统开发(七):时区处理的坑与方案

时区处理是日历系统中最复杂的部分,没有之一。实现过程中需要仔细处理各种时区转换和边界情况。这篇文章记录解决方案和思路。

时区为什么这么复杂

表面上看,时区就是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不要用缩写

ESTCST这类缩写是有歧义的。CST可能是中国标准时间(UTC+8)、美国中部标准时间(UTC-6)、或古巴标准时间(UTC-5)。永远使用IANA格式:Asia/ShanghaiAmerica/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协议的同步实现。