针对 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。无论采用哪种规则,都要一致地应用并加以测试。
参考资料与延伸阅读
- RFC 5545:互联网日历与日程安排(iCalendar)定义了重复规则、例外和时区。
- Google Calendar API:高效同步资源描述了同步令牌以及过期后的完整重新同步。
- IANA 时区数据库是时区和夏令时规则的来源。
- 同步令牌模式与 design Slack 中每设备游标是同一个思路。