针对 OpenAI 日历题目的完整解答:事件模型、时区与夏令时、带例外的重复事件、读取时展开与物化实例、各视图的快速范围查询、邀请、跨设备的同步令牌、冲突以及提醒。

由 The Forward Deployed 编辑团队 撰写并审校

本文属于 OpenAI 系统设计题库。该题目能代表该轮面试的水平。分析与解答为本站原创。

问题描述

用户拥有一个或多个日历。他们可以创建单次和重复事件、邀请他人,并回复邀请。应用在网页端和手机端展示日、周、月、年视图,手机在后台同步。会议室预订、跨公司的忙闲查询以及任务列表不在范围内,除非额外提出。

题目点出的两个难点:每个视图的快速检索,以及变更在多设备间的近实时传播。两者都取决于数据模型。

澄清问题

  • 规模? 练习用数字:5 亿用户,每用户每周新增 5 个事件。
  • 重复事件? 有:每日、每周、每月规则,可带结束日期或无结束。
  • 邀请? 有,且带回复:接受、拒绝、暂定。
  • 时区? 用户会出差旅行;事件在某个时区创建,必须在任何地方都正确显示。
  • 手机离线编辑? 有,稍后同步。
  • 提醒? 事件前的通知,按用户设置。

日历为什么难

日历看起来就是一张带开始和结束时间的事件表。但有三点会打破这个印象。

重复事件是无限的。一个没有结束时间的每周会议,永远每周都有一个实例。你无法存储无限行,但视图必须把这些实例当作行来展示,而且用户会编辑单个实例:“只移动本周的会议。”

时间不是一个数字。纽约每周 9:00 的会议,因夏令时的缘故,在夏季和冬季对应的 UTC 时间不同。如果只用 UTC 存储,它每年会漂移两次、每次一小时。

而且每次变更都必须触达许多设备。组织者的一次编辑会改变每位参会者日历上的事件,而他们所有的手机和笔记本电脑都必须在几秒内得知这一变更,且不能重新下载整个日历。

因此,核心矛盾在于紧凑、正确的模型与快速读取之间的权衡。存储规则能让模型保持小巧且正确;而视图则想要某个范围内的扁平行。

flowchart LR E[(Events:<br/>rules + exceptions)]:::store --> X[Expand into instances]:::svc X --> V[View: range query<br/>week of Sep 22]:::svc CH[(Change log per calendar)]:::store --> SY[Sync to devices]:::svc E --> CH classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717; classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717;
关键思路。 把规则作为事实来源存储,用实例服务视图,并通过变更日志同步设备。

核心概念

重复规则

iCalendar 标准将重复描述为一条规则,例如

FREQ=WEEKLY;BYDAY=MO;UNTIL=20271231

。一个事件行保存该规则和第一次发生;后续实例由它生成。

例外

与规则不同的单个实例:被取消、移动或改名的实例。例外以该实例的原始开始时间为键,从而能与它所替代的生成实例匹配。

墙上时间与时区

将事件的开始时间存储为本地墙上时间加上时区名称,例如 09:00 America/New_York,并在需要时计算 UTC。时区名称使系统能为每个日期正确应用夏令时规则。

同步令牌

标记日历变更历史中某个位置的令牌。设备发送其令牌,只接收自那之后的变更。

区间重叠

当事件在视图范围结束之前开始、并在视图范围开始之后结束时,事件与该视图范围重叠。这一个条件驱动所有视图查询。

核心思想。 用规则加例外处理重复,用墙上时间加时区保证正确性,用同步令牌服务设备,用一个重叠条件支撑视图查询。

需求

在继续阅读之前。 列出需求。用户绝不会原谅你破坏的特性是什么?

1.1 功能需求

  • 创建、更新和删除单个事件与重复事件。
  • 编辑单个实例、“此实例及后续实例”或整个系列。
  • 邀请参与者;参与者回复;组织者查看回复。
  • 快速查看日、周、月或年视图。
  • 在几秒内将变更同步到所有设备;支持离线编辑。
  • 在事件开始前发送提醒。

1.2 非功能需求

  • 跨时区和夏令时变更的时间正确性。
  • 视图延迟 p95 低于 200 ms。
  • 同步延迟 对在线设备低于几秒。
  • 不丢失编辑,包括离线编辑。
  • 读取的高可用性。

1.3 约束与特性

在正确的时间显示正确的事件是那个特性。 一个时间显示偏差一小时的会议,比一个加载缓慢的页面更糟糕。读取量是约束。 手机和浏览器读取日历的频率远高于任何人写入的频率,因此视图查询必须廉价。

粗略估算

  • 事件:5 亿用户 × 每周 5 个 = 每周新增 25 亿个事件,平均每秒约 4,100 次写入,峰值可能达到 12,000 次。
  • 读取:用户每天多次打开日历,且每台设备都在后台同步。假设每次写入对应 20 次读取:平均每秒约 80,000 次读取。
  • 存储:25 亿 × 1 KB = 每周新增事件 2.5 TB,复制前每年约 130 TB。旧事件很少被读取;应进行分层存储。
  • 实例:如果 30% 的事件每周重复,并且你提前物化一年,那么每个重复事件会增加 52 行实例记录。这使实例存储量比事件表放大许多倍,这就是物化的代价。
  • 邀请:平均 3 位参会者,每次事件变更会扩散到 4 个日历。
关键思路。 读操作比写操作高一个数量级;重复事件和参会者扩散会成倍放大每次写操作所触及的数据量。

API 设计

继续阅读之前。 当用户编辑一个重复会议时,API 必须支持哪三种选择?

仅此实例、此实例及之后所有实例,或整个系列。每种选择对应不同的数据变更。

GET  /v1/calendars/:id/events?from=2026-09-21T00:00&to=2026-09-28T00:00&tz=Europe/London
  -> instances in range (recurring ones expanded, exceptions applied)

POST   /v1/calendars/:id/events
  {title, start: {local: "2026-09-22T09:00", tz: "America/New_York"}, duration_min: 30,
   rrule?: "FREQ=WEEKLY;BYDAY=MO", attendees: [...]}
PATCH  /v1/events/:id?scope=instance|following|series&instance_start=...
  {fields..., if_version: 7}
DELETE /v1/events/:id?scope=...&instance_start=...
POST   /v1/events/:id/responses   {response: accepted|declined|tentative}

GET  /v1/calendars/:id/changes?sync_token=abc   -> {changes[], next_sync_token}
                                                 -> 410 if the token is too old

数据模型

calendars   (id, owner_id, name, default_tz)
events      (id, calendar_id, organizer_id, title, location, description,
             start_local, start_tz, duration_min,
             rrule NULL, series_until_utc NULL,
             version, updated_at, deleted_at)
exceptions  (event_id, original_start_local, status: cancelled|modified,
             new_start_local?, new_tz?, new_duration_min?, overrides_json)
attendees   (event_id, user_id, response, updated_at)
instances   (calendar_id, start_utc, end_utc, event_id, original_start_local)
            primary key (calendar_id, start_utc, event_id)     -- materialized window
changes     (calendar_id, seq, event_id, op, ts)                -- sync log
reminders   (user_id, event_id, minutes_before)

该

instances

表是派生的。它始终可以从

events

和

exceptions

.

高层设计

5.1 每个事件一行,时间用 UTC

存储每个事件的开始和结束时间用 UTC,并按范围查询。单次事件没问题。每周会议要么是一行,但范围查询在之后的周无法看到它;要么是无限多行。而且夏令时变更后,9:00 的会议会变成 8:00 或 10:00。

5.2 修复 1:墙上时间加时区

将开始时间存储为本地时间加时区名称,并使用该日期的时区规则将其转换为 UTC。这样会议全年都保持在纽约时间 9:00。

5.3 修复 2:规则与例外

重复事件存储为一行并带有一条规则。对单个实例的修改成为以该实例原始开始时间为键的例外行。

5.4 修复 3:物化的实例窗口

后台任务将每个重复事件展开为滚动窗口内的实例行,例如提前一年加一些历史记录。视图查询变成对

(calendar_id, start_utc)

的一次索引范围扫描。超出窗口的查询,例如 2029 年的年度视图,则在读取时展开。

flowchart LR EV[(events + exceptions)]:::store --> EXP[Expander job]:::new --> INS[(instances: rolling 1-year window)]:::new Q[Week view]:::svc -->|range scan| INS Q2[View in 2029]:::svc -->|expand on read| EV classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717; classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717; classDef new fill:#ffffff,stroke:#c4492d,stroke-width:2px,stroke-dasharray:5 3,color:#171717;

5.5 修复 4:变更日志与同步令牌

日历的每次变更都会以递增的序列号追加到其变更日志中。设备使用令牌进行同步;推送通知会告诉它们立即同步。

5.6 组合后的设计

sequenceDiagram autonumber actor O as 组织者 participant API as 日历 API participant DB as 事件数据库 participant X as 展开器 participant CL as 变更日志 participant P as 推送服务 participant D as 参会者设备 O->>API: PATCH 事件(scope=series,if_version 7) API->>DB: 更新事件,版本 8 API->>X: 为组织者和参会者日历重新展开实例 X->>DB: 重写窗口内的实例 API->>CL: 向每个受影响的日历追加变更 API->>P: 通知受影响用户的设备 P-->>D: “日历已变更” D->>API: GET changes?sync_token=... API-->>D: 变更 + 下一个 token
核心思路。 带时区的墙上时间、带例外的规则、用于快速视图的物化窗口,以及带 token 用于同步的变更日志。

深入探讨

6.1 重复事件及其编辑

阅读前思考。 一个每周一的会议持续一年。用户仅将下周一的会议移到周二,之后又把整个系列改为 10:00。下周的实例会发生什么?

移动单个实例会写入一条例外:原开始时间“该日期的周一 09:00”,新开始时间“周二 09:00”。系列记录不变。

将整个系列改为 10:00 会更新系列记录。展开器重新生成实例。对于下周,它根据规则生成周一 10:00 的实例,然后查找以原开始时间“周一 09:00”为键的例外……但键不再匹配,因为规则现在生成的是 10:00。

这是一个经典的陷阱。有两个都说得通的答案:

  • 按原始日期而非时间对例外建立键,适用于每日和每周规则,并将系列时间变更应用到未覆盖时间的例外上。被移动的实例保持在周二,现在是 10:00。
  • 询问用户。 日历应用通常会警告修改系列会重置已修改的实例,并丢弃这些例外。

指出这个陷阱并选择一种方案。面试官考察的是你能否发现它。

“此实例及后续”会拆分系列:将旧系列的结束时间设为所选实例之前,并以该实例为起点、用新值创建一个新系列。拆分点之后的例外移到新系列。

区分答案的关键:重复规则

弱:永久存储每个实例,或一个都不存

要么物化无限行,要么无法在范围查询中显示重复事件。

好:规则加例外

存储规则,按原始开始时间对例外建立键,并支持实例级、后续级和系列级编辑。

强:能处理编辑之间的交互

能解释系列变更时例外会发生什么,为“此实例及后续”拆分系列,并在事件自身的时区内进行展开。

6.2 时区与夏令时

存储

start_local

和

start_tz

,绝不仅存 UTC。通过从本地墙上时间和该日期的时区规则计算 UTC 时间来展开每个实例。一个纽约时间 9:00 的会议在夏季是 13:00 UTC,冬季是 14:00 UTC;两者都是正确的。

时长以分钟存储,而不是以结束时间存储,因此跨越夏令时切换的会议仍保持预期的时长。

时区规则会变化:政府会调整夏令时的日期。当时区数据库更新时,需要重新展开受影响时区的未来实例。仅以 UTC 存储的事件无法被修正,这是采用墙上时间加时区的又一个理由。

全天事件没有时间也没有时区:将其存储为日期。9 月 22 日的生日在任何地方都是 9 月 22 日。

6.3 快速视图

在继续阅读之前。 编写周视图的查询。为什么一个长时间的事件会带来麻烦?

对物化窗口进行重叠查询:

SELECT * FROM instances WHERE calendar_id = ANY(:calendars) AND start_utc < :range_end AND end_utc > :range_start;

上的索引

(calendar_id, start_utc)

可以按

start_utc < :range_end

缩小范围,但

end_utc > :range_start

一侧不受索引约束。一个上周开始、为期两周的会议的

start_utc

远早于该范围,但仍然必须显示。如果不加注意,查询会向后扫描每一个更早的事件。

修复方法:限制回溯范围,

start_utc > :range_start - max_event_length

,即把超过(比如)7 天的长事件保存在一个所有视图都会读取的小型独立表中。这样就能限定扫描范围。

月视图和年视图会读取更多行。按日历缓存已渲染的月视图,并在变更时使其失效。年视图通常只显示忙碌的日子,因此一个按天计数的表就足够了。

6.4 邀请

组织者的事件是唯一数据源。与会者获得一条

attendees

行,每个与会者的日历通过该行显示事件;他们自己的实例会被展开到其日历的实例表中。组织者的更新会修改一条事件行,并触发为每个与会者日历重新展开以及写入变更日志条目。

将事件复制到每个与会者的日历中会使读取变得简单,但每次更新都会变成可能产生偏差的写入扇出。参考模型可以避免偏差;扇出仍然会发生,但只针对派生行和变更日志,而这些都可以重建。

回复是按与会者存储的,保存在与会者行上,并出现在组织者的变更日志中,以便他们的设备更新回复计数。

使用其他日历系统的外部与会者会收到带有 iCalendar 附件的标准邀请邮件。

6.5 跨设备同步

在继续阅读之前。 一部手机已经离线三天。它如何赶上进度?如果它离线了六个月呢?

每个日历都有一个带递增序列号的只追加变更日志。设备将其最后应用的序列号存储为同步令牌。重新连接时,它请求其令牌之后的变更。服务器返回已变更的事件和新的令牌。设备按顺序应用它们。

如需即时更新,服务器会通过推送通知或已建立的连接发送一条“日历 X 已变更”的消息。设备随后进行同步。推送本身不携带数据,变更日志才携带数据,这让协议保持简单可靠。

变更日志会在保留期(例如 30 天)后被裁剪。持有更旧令牌的设备会收到 410 Gone,并对今天前后一个时间窗口做全量重新同步,然后继续增量同步。

6.6 冲突与离线编辑

手机在离线状态下编辑了一个活动,而组织者同时在网页端编辑了同一活动。每次编辑都携带其基于的版本号。服务器接受第一个;第二个未通过

if_version

检查。可选方案:字段级合并(每个字段取自修改了该字段的那次编辑,并标记真正的冲突),或按字段最后写入者胜出。对日历而言,如果明确说明,字段级最后写入者胜出通常是可以接受的;当设备覆盖了某些内容时,会显示一条提示。

6.7 提醒

提醒的触发时间是实例开始时间减去所选的偏移量。维护一个次日提醒队列,由实例表构建,并在变更时刷新。调度服务每秒弹出到期提醒并发送通知。由于只物化次日的提醒,每次变更时重新计算的开销很小。

6.8 用代码展开规则

在继续阅读之前。 将每周一 09:00 纽约的会议展开为某个日期范围内的 UTC 实例,并应用例外规则。
from datetime import datetime, timedelta, time
from zoneinfo import ZoneInfo

def expand_weekly(first_local_date, local_time, tz_name, weekday,
                  range_start_utc, range_end_utc, exceptions, until_local_date=None):
    tz = ZoneInfo(tz_name)
    utc = ZoneInfo("UTC")
    day = first_local_date
    while day.weekday() != weekday:
        day += timedelta(days=1)
    out = []
    while True:
        if until_local_date and day > until_local_date:
            break
        local = datetime.combine(day, local_time, tzinfo=tz)   # wall time in the event's zone
        start_utc = local.astimezone(utc)                       # correct offset for this date
        if start_utc >= range_end_utc:
            break
        ex = exceptions.get(day)                                # keyed by original local date
        if ex is None:
            out.append(start_utc)
        elif ex["status"] == "modified":
            out.append(ex["new_start_utc"])
        # cancelled: skip
        day += timedelta(weeks=1)
    return [s for s in out if s >= range_start_utc]

关键的一行是

datetime.combine(day, local_time, tzinfo=tz)

:每个实例都基于其自身日期的墙上时间构建,因此 UTC 偏移量会跟随夏令时变化。例外规则通过原始本地日期查找。真实的实现还需要处理 iCalendar 标准的各种规则形式,以及转换日上不存在或出现两次的墙上时间。

6.9 夏令时的边界情况

每年有两个时刻会让朴素的代码出错。

  • 春季前拨。 在纽约,转换日的 02:00 到 02:59 不存在。每天 02:30 的活动没有有效时间。标准做法是将其向前移动一个间隙,改为 03:30。
  • 秋季回拨。 01:00 到 01:59 出现两次。每天 01:30 的活动存在歧义。应始终如一地选择第一次出现的时间。

请同时说出这两种情况,说明你采用的规则,并把它们加入测试套件。面试官会用它们来检验你存储的是墙上时间还是 UTC。

拉开答案差距的关键:时间处理

弱:在 UTC 中加七天

通过加上固定的 UTC 时间间隔来展开每周活动,每次夏令时切换都会漂移一小时。

良好:在活动所在时区展开

在活动所在时区,基于本地墙上时间构建每个实例。

强:处理时区转换

还为转换日上不存在或重复的时间定义了行为,并在时区规则变化时重新展开。

变体

7.1 空闲/忙碌查询与日程助手

为多人寻找时间时,需要从实例表中读取每个人在该时间段的忙碌区间,并求空闲时间的交集。隐私很重要:返回忙碌时段,而不是事件标题。

7.2 会议室预订

会议室是带有约束条件的日历:不允许重复预订。使用条件插入或对房间时间范围加锁,这需要比个人日历更强的一致性。

7.3 共享团队日历

许多用户读取同一个日历。要积极缓存其视图,并将变更通知扇出到所有订阅者的设备。

7.4 用户规模扩大十倍

在 50 亿用户规模的日历下,按日历 ID 对事件和实例进行分片。此时邀请会跨分片:一次更新先写入组织者的分片,然后发布一个变更事件,每个参与者的分片将该事件应用到其派生实例和变更日志上。接受参与者视图比组织者滞后一两秒,并明确说明这一点。

可迁移的模式

日历的本质是一个紧凑的基于规则的模型,读取时使用物化视图,同步时使用变更日志。同样的结构出现在订阅计费(规则生成账单)、排班系统,以及任何用户编辑规则但读取实例的产品中:把规则作为唯一事实来源,派生出数据行,并用序列号进行同步。

回顾:30 秒答案

  • 墙上时间加时区。 绝不只用 UTC;按日期逐一展开。
  • 规则加例外。 例外以原始开始时间为键;用“此事件及后续”方式拆分系列。
  • 物化实例窗口。 范围扫描与有界的回溯期取交集;超出回溯期的部分在读取时展开。
  • 一个组织者事件,参与者持有引用。 更新时为每个参与者重新展开并记录日志。
  • 变更日志加同步令牌。 推送只说“有变更”;设备拉取增量;令牌太旧时进行全量重新同步。

测验

+为什么把事件的开始时间存储为本地时间加时区,而不是 UTC?

循环事件在夏令时切换后仍保持本地时间不变,因此其 UTC 时间会偏移。本地时间加时区名称让系统能为每个日期计算出正确的 UTC 时间,即使时区规则发生变化。

+为什么例外要以实例的原始开始时间作为键?

生成的实例由规则放置的位置来标识。以该原始位置作为例外的键,展开器就能匹配并替换正确的实例,即使该实例已被移动。

当一个长事件与视图范围的起点重叠时,会出什么问题?

它在范围开始之前就已开始,因此只查询范围内开始时间的查询会漏掉它。查询必须包含开始时间更早、但结束时间落在范围内或范围之后的事件,并使用有界的回溯期来保持扫描规模较小。

+设备离线后如何追上进度?

它会发送同步令牌(即它已应用的最后一个变更序列号),并且只接收之后的变更。如果令牌早于保留的日志,它会先对某个时间窗口进行完整重新同步,然后再继续增量同步。

+为什么推送通知不携带事件数据?

变更日志是唯一事实来源。推送只是告诉设备去同步,这样可以让排序、重试和丢失的推送都变得简单:下一次同步会获取所有内容。

+为什么要根据各自日期的墙上时间来构建每个实例?

时区的 UTC 偏移量会随夏令时而变化。根据每个实例的本地日期和时间来构建,可以得到该日期的正确偏移量;而添加固定的 UTC 间隔会产生一小时的偏差。

+在春季时钟前拨的那一天,每天 02:30 的事件应该怎么处理?

该时间在切换日并不存在。一种常见的规则是将其向前移动一个间隔,改为 03:30。无论采用哪种规则,都要一致地应用并加以测试。

参考资料与延伸阅读

NextURL Shortener