日历系统的数据库设计是整个项目的根基。设计过程中可以参考 RFC 5545(iCalendar 规范)和 Google Calendar API 的数据模型,在规范兼容性和工程实用性之间做取舍。
核心表设计
经过几轮讨论和调整,最终确定了5张核心表。
calendar 表
日历是日程的容器,类似于文件夹的概念。每个用户可以有多个日历(工作日历、个人日历等)。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | BIGINT PK | 主键 |
| user_id | BIGINT NOT NULL | 所属用户 |
| name | VARCHAR(100) | 日历名称 |
| color | VARCHAR(7) | 颜色代码(如#FF5733) |
| description | TEXT | 描述 |
| timezone | VARCHAR(50) | 默认时区(如Asia/Shanghai) |
| is_default | BOOLEAN | 是否为默认日历 |
| is_visible | BOOLEAN | 前端是否显示 |
| created_at | TIMESTAMPTZ | 创建时间 |
| updated_at | TIMESTAMPTZ | 更新时间 |
每个用户注册时自动创建一个默认日历。timezone字段存储IANA时区标识符,这个很重要——后面所有时间计算都依赖它。
event 表
这是最核心的表,存储日程事件。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | BIGINT PK | 主键 |
| calendar_id | BIGINT FK | 所属日历 |
| title | VARCHAR(200) | 标题 |
| description | TEXT | 描述(支持富文本) |
| location | VARCHAR(500) | 地点 |
| start_time | TIMESTAMPTZ | 开始时间 |
| end_time | TIMESTAMPTZ | 结束时间 |
| all_day | BOOLEAN | 是否全天事件 |
| status | VARCHAR(20) | confirmed/tentative/cancelled |
| visibility | VARCHAR(20) | public/private/confidential |
| recurrence_id | BIGINT FK | 关联的重复规则(可空) |
| recurring_event_id | BIGINT FK | 原始重复事件ID(修改单次时用) |
| original_start_time | TIMESTAMPTZ | 原始开始时间(修改单次时用) |
| organizer_id | BIGINT | 组织者用户ID |
| created_at | TIMESTAMPTZ | 创建时间 |
| updated_at | TIMESTAMPTZ | 更新时间 |
几个设计决策的考虑:
start_time和end_time用TIMESTAMPTZ:PostgreSQL的TIMESTAMPTZ存储的是UTC时间戳,查询时自动按session时区转换。这样无论用户在哪个时区创建的事件,存储的都是同一个绝对时间点。
全天事件的处理:当all_day=true时,start_time存储的是当天00:00:00(日历时区),end_time存储的是结束日的00:00:00。比如"5月25日全天事件",start_time是2022-05-25T00:00:00+08:00,end_time是2022-05-26T00:00:00+08:00。
recurring_event_id和original_start_time:用于处理重复事件的单次修改(exception)。比如"每周一开会"改了某一周的时间,修改后的那一次会作为新记录插入,recurring_event_id指向原始事件,original_start_time记录它原本应该出现的时间。
event_recurrence 表
存储重复规则,遵循RFC 5545的RRULE格式。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | BIGINT PK | 主键 |
| event_id | BIGINT FK | 关联事件 |
| rrule | TEXT | RFC 5545 RRULE字符串 |
| freq | VARCHAR(20) | DAILY/WEEKLY/MONTHLY/YEARLY |
| interval_val | INT | 间隔(每N天/周/月/年) |
| by_day | VARCHAR(100) | 按星期(如MO,WE,FR) |
| by_month_day | VARCHAR(100) | 按月中日期(如1,15) |
| by_month | VARCHAR(50) | 按月份(如1,6) |
| until_date | TIMESTAMPTZ | 结束日期(和count二选一) |
| count | INT | 重复次数(和until二选一) |
| created_at | TIMESTAMPTZ | 创建时间 |
我同时存了rrule原始字符串和解析后的各字段。rrule用于和外部系统(CalDAV)交互时的兼容性,解析后的字段用于数据库层面的查询优化。
例如"每周一、三、五重复,到2022年底":
rrule:FREQ=WEEKLY;BYDAY=MO,WE,FR;UNTIL=20221231T235959Zfreq: WEEKLYby_day: MO,WE,FRuntil_date: 2022-12-31T23:59:59Z
event_attendee 表
参与者表。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | BIGINT PK | 主键 |
| event_id | BIGINT FK | 关联事件 |
| user_id | BIGINT | 参与者用户ID |
| VARCHAR(200) | 参与者邮箱(外部参与者) | |
| display_name | VARCHAR(100) | 显示名 |
| role | VARCHAR(20) | organizer/nequired/optional |
| status | VARCHAR(20) | accepted/declined/tentative/needsAction |
| responded_at | TIMESTAMPTZ | 最近一次响应时间 |
| created_at | TIMESTAMPTZ | 创建时间 |
user_id和email至少有一个有值。内部用户填user_id,外部参与者填email。
reminder 表
提醒表。每个事件可以有多个提醒。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | BIGINT PK | 主键 |
| event_id | BIGINT FK | 关联事件 |
| method | VARCHAR(20) | notification/email/webhook |
| minutes_before | INT | 事件前N分钟提醒 |
| is_sent | BOOLEAN | 是否已发送 |
| sent_at | TIMESTAMPTZ | 发送时间 |
| created_at | TIMESTAMPTZ | 创建时间 |
ER关系
user 1──N calendar 1──N event
│
├── 1──0..1 event_recurrence
├── 1──N event_attendee
└── 1──N reminder
event.recurring_event_id ──> event.id (自关联,单次修改指向原始重复事件)
核心关系很清晰:用户拥有多个日历,日历包含多个事件,事件可以有重复规则、参与者和提醒。
时间存储策略
这是我在设计过程中花时间最多的部分。日历系统的时间处理非常容易踩坑。
原则:存储UTC,展示转换
所有时间字段使用TIMESTAMPTZ类型,PostgreSQL会把输入的时间转为UTC存储,查询时按当前session的timezone设置转换输出。
-- 设置session时区
SET timezone = 'Asia/Shanghai';
-- 插入事件(输入的是上海时间,存储为UTC)
INSERT INTO event (title, start_time, end_time)
VALUES ('团队会议', '2022-05-25 14:00:00', '2022-05-25 15:00:00');
-- 查询(自动转换为session时区)
SELECT title, start_time FROM event;
-- 结果: 团队会议 | 2022-05-25 14:00:00+08
-- 切换时区查看同一条记录
SET timezone = 'America/New_York';
SELECT title, start_time FROM event;
-- 结果: 团队会议 | 2022-05-25 02:00:00-04
全天事件的特殊处理
全天事件在概念上是"没有具体时间的",但数据库需要一个具体的时间戳。我的做法是用日历所属时区的当天00:00:00。
API层做了封装,前端只传日期字符串(如"2022-05-25"),后端根据日历时区补全时间部分。
跨时区会议
用户A在北京创建了"上午10点开会",用户B在纽约看到的应该是"晚上10点"。因为我们存的是UTC时间戳,每个用户按自己的时区查询就自动转换了。这也是选择TIMESTAMPTZ的核心原因。
索引设计
日历系统最频繁的查询是"某个时间段内有哪些事件",索引设计围绕这个场景。
event表核心索引:
-
日历+时间范围:最高频查询
(calendar_id, start_time, end_time)复合索引
-
用户事件查询:查看某用户所有日历的事件
(calendar_id)外键索引配合calendar表的user_id
-
重复事件关联:查找某个重复事件的所有exception
(recurring_event_id)索引
-
时间范围查询优化:利用PostgreSQL的BRIN索引
start_time上建BRIN索引,因为日程数据天然有时间顺序
attendee表索引:
(event_id)查询事件的参与者(user_id, status)查询用户待处理的邀请
reminder表索引:
(event_id)常规关联查询(is_sent, minutes_before)提醒调度服务扫描用
PostgreSQL的tstzrange类型也值得考虑——可以用GiST索引做更高效的时间范围重叠查询(会议室冲突检测场景),不过这个留到后面优化阶段再看。
小结
数据库设计阶段最关键的几个决策:
- 所有时间用
TIMESTAMPTZ,存储UTC - 重复规则独立成表,同时保留RRULE原始字符串和解析字段
- 重复事件的单次修改通过
recurring_event_id自关联实现 - 全天事件用日历时区的00:00:00表示
下一篇会进入编码阶段,实现日程的CRUD API。