Календарь выглядит обманчиво простым: "таблица events". Но как только появляются повторяющиеся события, часовые пояса, приглашения с ответами и напоминания на 10 минут до начала -- инженерная сложность резко растёт. Разберём как построить такой сервис.
Функциональные требования
- CRUD событий: название, время начала/конца, location, описание, color.
- Participants: приглашать других пользователей, ждать ответов (accept/decline/maybe).
- Recurrence: повторяющиеся события (RRULE по RFC 5545 -- "каждый вторник", "первый понедельник месяца").
- Исключения из серии: "каждый вторник, кроме 23 апреля" или "23 апреля -- другое время".
- Reminders: push / email за N минут до события.
- Timezone-aware: событие в Нью-Йорке видно корректно из Москвы.
- Shared calendars: "календарь команды", с подпиской.
- Free/busy query: "найди 30 минут свободных у троих в ближайшие 3 дня".
- CalDAV для interop с Apple Calendar, Outlook.
Нефункциональные требования
- Correctness: timezone/DST handling -- нет ошибок.
- Latency: <100 ms на день просмотра, <500 ms на месяц с сотнями событий.
- Scale: 1B users, 10 events/user/month -> 10B events/month, ~3800 writes/sec avg, 20K peak.
- Availability: 99.95%.
- Reliability: reminders не теряются. "At-least-once" delivery, идемпотентно на клиенте.
Оценки нагрузки
- Пользователей: 1B total, 300M DAU.
- Event insert: 10B/month = 4K/sec avg, 20K peak.
- Event read (view calendar): 300M * 5 views/day = 1.5B/day, ~17K/sec avg, 100K peak.
- Reminders: каждое событие в среднем 1.5 напоминания -> 15B reminders/month, 6K/sec avg, 50K peak (утренние события).
- Storage: event ~500 байт + participants ~100 байт * 5 = 1 KB. 10B/month * 1 KB * 24 months retention = 240 TB горячих данных.
- Free/busy index: ~200 байт на событие, 240 TB / 5 = 48 TB.
High-Level Architecture
┌─────────┐ ┌─────────────────┐ ┌───────────────────┐
│ Клиент │ ───► │ API Gateway │ ──────► │ Event Service │
│ (web, │ │ (auth, │ │ (CRUD, RRULE, │
│ mobile,│ │ rate limit, │ │ tz conversion) │
│ CalDAV)│ │ CalDAV impl.) │ └─────────┬─────────┘
└─────────┘ └─────────────────┘ │
│
▼
┌──────────────────┐
│ PostgreSQL │
│ (events, RRULE, │
│ partitioned by │
│ user+month) │
└──────┬───────────┘
│
┌──────────────────────┼──────────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Invitation │ │ Reminder │ │ Free/Busy │
│ Service │ │ Scheduler │ │ Index │
│ (ACL, RSVP, │ │ (time-bucket │ │ (per-user │
│ mail send) │ │ queue) │ │ materialized│
└──────────────┘ └──────┬───────┘ │ intervals) │
│ └──────────────┘
▼
┌──────────────┐
│ Notifier │
│ (push, email)│
└──────────────┘
API дизайн
POST /api/v1/events
GET /api/v1/events/{id}
PATCH /api/v1/events/{id}
DELETE /api/v1/events/{id}
GET /api/v1/calendars/{cid}/events?from=&to=
GET /api/v1/freebusy?users=a,b,c&from=&to=
POST /api/v1/events/{id}/rsvp body: {status: accepted|declined|maybe}
<?php
declare(strict_types=1);
final readonly class CreateEventRequest
{
/**
* @param list<string> $attendees // emails
* @param list<int> $remindersMinutes
*/
public function __construct(
public string $title,
public \DateTimeImmutable $startsAt, // in UTC
public \DateTimeImmutable $endsAt, // in UTC
public string $timezone, // IANA id, e.g. "Europe/Moscow"
public ?string $location = null,
public ?string $description = null,
public ?string $rrule = null, // e.g. "FREQ=WEEKLY;BYDAY=TU;UNTIL=20260901T000000Z"
public array $attendees = [],
public array $remindersMinutes = [10],
) {}
}
final readonly class EventDTO
{
public function __construct(
public string $id,
public string $title,
public \DateTimeImmutable $startsAt,
public \DateTimeImmutable $endsAt,
public string $timezone,
public ?string $rrule,
public ?string $recurrenceId, // если это override серии
public ?string $masterId, // ссылка на родителя (для overrides)
) {}
}
CREATE TABLE calendars (
id UUID PRIMARY KEY,
owner_id UUID NOT NULL,
name TEXT NOT NULL,
timezone TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Master events (single or recurring master).
CREATE TABLE events (
id UUID PRIMARY KEY,
calendar_id UUID NOT NULL,
owner_id UUID NOT NULL,
title TEXT NOT NULL,
starts_at TIMESTAMPTZ NOT NULL, -- UTC absolute
ends_at TIMESTAMPTZ NOT NULL,
timezone TEXT NOT NULL, -- IANA tz
rrule TEXT, -- e.g. FREQ=WEEKLY;...
exdates TIMESTAMPTZ[], -- removed occurrences
location TEXT,
description TEXT,
master_id UUID, -- for instance overrides
recurrence_id TIMESTAMPTZ, -- original occurrence timestamp
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
) PARTITION BY RANGE (starts_at);
CREATE INDEX idx_events_cal_time ON events (calendar_id, starts_at);
CREATE INDEX idx_events_master ON events (master_id) WHERE master_id IS NOT NULL;
CREATE TABLE attendees (
event_id UUID NOT NULL,
user_id UUID NOT NULL,
status SMALLINT NOT NULL, -- 1=pending, 2=accepted, 3=declined, 4=tentative
role SMALLINT NOT NULL, -- 1=organizer, 2=required, 3=optional
responded_at TIMESTAMPTZ,
PRIMARY KEY (event_id, user_id)
);
-- Reminder queue. Partitioned by fire_at (15-минутные бакеты).
CREATE TABLE reminders (
id UUID PRIMARY KEY,
event_id UUID NOT NULL,
user_id UUID NOT NULL,
fire_at TIMESTAMPTZ NOT NULL,
delivered BOOLEAN NOT NULL DEFAULT FALSE
) PARTITION BY RANGE (fire_at);
CREATE INDEX idx_reminders_fire ON reminders (fire_at) WHERE NOT delivered;
-- Free/busy materialized per user для быстрых запросов.
CREATE TABLE free_busy_intervals (
user_id UUID NOT NULL,
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ NOT NULL,
event_id UUID NOT NULL,
PRIMARY KEY (user_id, starts_at, event_id)
) PARTITION BY RANGE (starts_at);
CREATE INDEX idx_fb_user_time ON free_busy_intervals (user_id, starts_at);
Events партиционируются по starts_at (месяц на партицию). Старые партиции -- cold storage.
Ключевые компоненты
1. Timezones и DST
Событие "Совещание в 10:00 по Москве каждую пятницу". Хранить нужно два атрибута:
- wall-clock time в локальной tz (10:00) + tz ("Europe/Moscow");
- или UTC absolute time + tz.
Google/Apple рекомендуют local + tz для recurring (иначе DST ломает серию: после перехода часов "10:00" может стать "11:00" UTC, а должно остаться "10:00" local). Для single-occurrence -- UTC достаточно.
На практике хранят оба: starts_at TIMESTAMPTZ (absolute) + timezone TEXT (IANA id). При RRULE expansion применяем RRULE в local tz, потом конвертируем в UTC.
2. RRULE
RFC 5545. Примеры:
FREQ=DAILY;INTERVAL=2;COUNT=10 # каждые 2 дня, 10 раз
FREQ=WEEKLY;BYDAY=MO,WE,FR # пн/ср/пт
FREQ=MONTHLY;BYDAY=1MO # первый пн месяца
FREQ=YEARLY;BYMONTH=12;BYDAY=4TH # 4-й четверг декабря
Два варианта хранения occurrences:
А. Virtual (on-demand): храним только master + RRULE. При запросе календаря разворачиваем RRULE -> список occurrences в интервале.
- Плюсы: компактно, можно менять серию централизованно.
- Минусы: expansion каждый раз стоит CPU; overrides (exception instances) нужно хранить отдельно и "overlay" поверх.
Б. Materialized: при создании серии сразу генерируем все occurrences в events.
- Плюсы: просто выборка по диапазону.
- Минусы: серия "каждый день навсегда" -- бесконечно. Меняешь серию -- надо update всех.
Используют гибрид: virtual + materialized на ближайшие 2 года для быстрых запросов и free/busy. Редкий запрос "что у меня через 5 лет?" развернёт RRULE on-demand.
<?php
declare(strict_types=1);
use Recurr\Rule;
use Recurr\Transformer\ArrayTransformer;
use Recurr\Transformer\Constraint\BetweenConstraint;
final class RruleExpander
{
public function expand(
\DateTimeImmutable $masterStart,
string $rrule,
string $timezone,
\DateTimeImmutable $from,
\DateTimeImmutable $to,
): array {
$tz = new \DateTimeZone($timezone);
// Recurr operates on DateTime instances in a specific timezone.
$startInTz = $masterStart->setTimezone($tz);
$rule = new Rule($rrule, \DateTime::createFromImmutable($startInTz), null, $timezone);
$transformer = new ArrayTransformer();
$constraint = new BetweenConstraint(
\DateTime::createFromImmutable($from),
\DateTime::createFromImmutable($to),
true,
);
$occurrences = $transformer->transform($rule, $constraint);
$results = [];
foreach ($occurrences as $o) {
$results[] = \DateTimeImmutable::createFromMutable($o->getStart());
}
return $results;
}
}
Пользователь сдвинул один экземпляр серии: "23 апреля -- не 10:00, а 12:00". В базе появляется дополнительная запись:
master_id= ID серии;recurrence_id= original time (10:00 UTC 23 апреля);starts_at= new time (12:00 UTC 23 апреля);exdatesв мастере добавляет original time (чтобы не показывать исходный).
При expansion: разворачиваем RRULE минус exdates + оverrides с master_id=master.
4. Invitations и RSVP
Когда создаётся event с attendees:
- Event вставляется в календарь организатора.
- На каждого attendee создаётся запись в
attendees (event_id, user_id, status=pending). - Отправляется email/push "You're invited".
- Копия события автоматически появляется в календарях attendees.
- Когда attendee нажимает "accept" ->
UPDATE attendees ... SET status=2; событие остаётся. - "Decline" ->
status=3; UI может скрыть или показать как "declined".
5. Reminders
Масштабный background job. Механика:
- При создании события insert в
reminders (event_id, user_id, fire_at, delivered=false). - Таблица партиционирована по 15-минутным bucket'ам.
- Воркер раз в минуту берёт следующий бакет, делает
SELECT ... WHERE fire_at <= now() AND NOT delivered LIMIT 10000, отправляет push/email, отмечаетdelivered=true. - Идемпотентность: если воркер упал после отправки, но до update -- отправим дубль. Клиент дедуплицирует по
event_id + fire_at.
Для масштаба 50K fires/sec: шардируем воркеры по hash(user_id) % N, каждый обрабатывает свой shard.
<?php
declare(strict_types=1);
final class ReminderWorker
{
public function __construct(
private readonly \PDO $db,
private readonly Notifier $notifier,
private readonly LoggerInterface $logger,
) {}
public function runBatch(int $shardId, int $shardCount): int
{
$stmt = $this->db->prepare(<<<SQL
SELECT id, event_id, user_id, fire_at
FROM reminders
WHERE fire_at <= now()
AND NOT delivered
AND hashtext(user_id::text) % :shards = :shard
ORDER BY fire_at
LIMIT 5000
FOR UPDATE SKIP LOCKED
SQL);
$stmt->execute(['shards' => $shardCount, 'shard' => $shardId]);
$rows = $stmt->fetchAll(\PDO::FETCH_ASSOC);
$count = 0;
foreach ($rows as $r) {
try {
$this->notifier->send($r['user_id'], $r['event_id']);
$this->db->prepare('UPDATE reminders SET delivered=true WHERE id = :id')
->execute(['id' => $r['id']]);
$count++;
} catch (\Throwable $e) {
$this->logger->error('reminder failed', ['id' => $r['id'], 'err' => $e->getMessage()]);
}
}
return $count;
}
}
Запрос "когда все трое свободны в ближайшие 3 дня":
- Для каждого user_id вытащить
free_busy_intervalsв запрошенном диапазоне. - Собрать union busy.
- Найти "gaps" длиной >= requested duration.
- Вернуть первые N.
Материализованная free_busy_intervals позволяет не ходить в events + не разворачивать RRULE каждый раз. Поддерживается insert/update триггерами / async worker'ом.
package freebusy
import (
"context"
"database/sql"
"sort"
"time"
)
type Interval struct {
Start time.Time
End time.Time
}
// FindSlots returns available slots of given duration that are free for all users.
func FindSlots(ctx context.Context, db *sql.DB, users []string, from, to time.Time, duration time.Duration) ([]Interval, error) {
busy := make([]Interval, 0)
for _, u := range users {
rows, err := db.QueryContext(ctx,
`SELECT starts_at, ends_at FROM free_busy_intervals
WHERE user_id = $1 AND ends_at > $2 AND starts_at < $3`,
u, from, to)
if err != nil {
return nil, err
}
for rows.Next() {
var iv Interval
if err := rows.Scan(&iv.Start, &iv.End); err != nil {
rows.Close()
return nil, err
}
busy = append(busy, iv)
}
rows.Close()
}
// Merge overlapping busy intervals.
sort.Slice(busy, func(i, j int) bool { return busy[i].Start.Before(busy[j].Start) })
merged := make([]Interval, 0, len(busy))
for _, iv := range busy {
if n := len(merged); n > 0 && !iv.Start.After(merged[n-1].End) {
if iv.End.After(merged[n-1].End) {
merged[n-1].End = iv.End
}
continue
}
merged = append(merged, iv)
}
// Find gaps of >= duration in [from, to].
slots := make([]Interval, 0)
cursor := from
for _, iv := range merged {
if iv.Start.After(cursor) && iv.Start.Sub(cursor) >= duration {
slots = append(slots, Interval{Start: cursor, End: iv.Start})
}
if iv.End.After(cursor) {
cursor = iv.End
}
}
if to.Sub(cursor) >= duration {
slots = append(slots, Interval{Start: cursor, End: to})
}
return slots, nil
}
CalDAV -- протокол над WebDAV для синхронизации календарей между клиентами (Apple Calendar, Outlook, Thunderbird). API Gateway реализует набор методов (PROPFIND, REPORT, PUT, DELETE) и возвращает события как VCALENDAR/ICS. Внутри мы transform'им между VCALENDAR <-> наш EventDTO. Реализовать полный CalDAV -- большой проект, поэтому обычно берут готовую библиотеку (sabre/dav для PHP).
Масштабирование и bottlenecks
Bottleneck 1: month view query
Пользователь открывает календарь на месяц с серией "каждый день по 3 раза" -> разворачиваем 90 occurrences + миксуем overrides. На 10 пользователях в team view -- тысячи occurrences. Решения:
- материализация occurrences на ближайший год;
- кэширование месячного view в Redis на 60 секунд;
- incremental render на клиенте.
Bottleneck 2: reminder accuracy
Если воркеры отстают на 5 минут -- напоминание "за 10 минут" придёт только за 5. Решение: slot-based queue (bucket = 15s), воркеры берут свой shard + bucket. Утром (7-10 AM) пиковая нагрузка -> масштабируем worker pool в 5x.
Bottleneck 3: partition management
Events по месяцам -- старые партиции через 24 месяца переносим в cold storage (S3 + read-through). Новые автоматически создаются pg_partman.
Bottleneck 4: free/busy across many calendars
Team calendar с 1000 members -> 1000 * 100 events = 100K events. Аггрегация real-time дорого. Решение: per-user pre-computed free_busy, а team free/busy -- union с LIMIT по времени.
Trade-offs и альтернативы
| Решение | Плюс | Минус |
|---|---|---|
| Virtual RRULE | Компактно, гибко | CPU на каждый read |
| Materialized occurrences | Быстрые read | Storage, update-cascade |
| Гибрид (2y materialized) | Лучший баланс | Сложнее логика |
| Store UTC + tz | Правильный DST | Всегда нужна конверсия |
| Store local + tz | Recurring стабильны | Сложнее timezone queries |
| Reminder bucket table | Масштабируется | Много партиций |
| Reminder via RabbitMQ | Native delayed | Зависимость от брокера |
| Per-user free/busy | Быстрые групповые запросы | Дублирование данных |
| On-the-fly free/busy | Нет дублей | Медленнее |
Альтернативный storage -- Cassandra/ScyllaDB для events с partition key = user_id. Масштабируется проще Postgres, но транзакционность теряем (RSVP + invitation cascade сложнее).
Reminder delivery можно доверить external: AWS EventBridge Scheduler, GCP Cloud Tasks. Сокращает сопровождение, но привязывает к cloud.
Выводы
Календарь -- пример где timezone / DST сложнее, чем кажется: хранить именно local time для recurring, applying RRULE в той же tz. RRULE expansion -- дорогая операция, поэтому материализуем ближайший горизонт и компенсируем overrides отдельной таблицей. Reminders делаем через time-bucketed queue, что даёт и масштаб, и точность. Free/busy выносим в отдельный materialized index -- группа из 10 человек не должна порождать 10 разворачиваний серий. Всё остальное (invitations, RSVP, sharing) -- стандартные приёмы CRUD + push-уведомлений.