open atlas
↑ К треку
Разборы System Design SDC · 03 · 04

Спроектируй почту масштаба Gmail

Спроектируй распределённую почту: SMTP-приём с фильтром спама, ящики шардированы на пользователя, вложения дедуплицированы в объектном хранилище, инвертированный индекс поиска на пользователя, метки как many-to-many оверлей, IMAP-синк через журнал изменений ящика.

SDC Senior ◷ 35 min
Уровень
ОсновыJuniorMiddleSenior

«Почта решена, это просто SMTP» — пока тебе не надо построить её на миллиард пользователей и заметить, что каждое допущение ломается. Рассылка уходит 50 миллионам подписчиков: ты правда хранишь 50 миллионов одинаковых копий того же PDF-вложения на 2 МБ? Пользователь ищет «счёт от acme март» по 200 000 сообщений и хочет результаты за 50 мс — ты будешь сканировать весь его ящик? Его телефон, ноутбук и веб-почта все должны показывать то же состояние прочитано/непрочитано и те же метки мгновенно — как им оставаться в синке, не перекачивая каждый раз всё? И 90% входящей почты — спам, который надо отвергнуть до того, как он вообще будет стоить тебе хранилища. Почта выглядит как CRUD-приложение, а на деле это задача fan-out, задача поиска, задача дедупа и задача синка, сложенные вместе — и дизайн, что справляется с тысячей ящиков, рушится на миллиарде, если не шардировать на пользователя, не дедуплицировать байты, не индексировать ради поиска и не синкать журналом изменений вместо копирования.

Требования

Когда система обслуживает миллиард ящиков, каждое решение, которое ты принимаешь как само собой разумеющееся при тысяче, становится кризисом масштабирования. Прежде чем читать требования, спроси себя: что будет доминировать по стоимости — fan-out доставка, поиск, хранение вложений или синк? Функциональные — принимай почту по SMTP (Simple Mail Transfer Protocol — протокол отправки электронной почты) и доставляй в нужный ящик; отправляй почту; читай/листай сообщения; полнотекстовый поиск по ящику; организуй метками (у сообщения может быть много); обрабатывай вложения; держи несколько клиентов (веб, телефон, десктоп) в синке, включая состояние прочитано/непрочитано, помечено звездой, метки. Спам должен фильтроваться. IMAP/JMAP-подобные протоколы для клиентов.

Нефункциональныедолговечность (терять почту недопустимо), масштаб на пользователя (ящик может держать сотни тысяч сообщений; система держит миллиард ящиков), задержка поиска (sub-100 мс по огромному ящику), эффективность синка (клиенты не должны перекачивать всё) и устойчивость к злоупотреблениям (спам — большинство входящего объёма и должен отвергаться дёшево). Чтения (открытие, листание, поиск) намного превосходят отправки.

Формирующие факты: данные чисто партиционируются на пользователя (твоя почта независима от моей), вложения огромны и часто дублируются, поиску нужен индекс, не скан, и синк должен быть инкрементальным.

Оценка

Пользователи: ~1B ящиков
Входящее:   ~сотни миллиардов сообщений/день; ~90% спам (отвергается на приёме)
            легитимных ~1e10/день ÷ ~1e5 с ≈ ~1e5 сообщений/сек доставляется, пик выше
Ящик:       в среднем десятки тысяч сообщений; power-юзеры 100K+
Сообщение:  заголовки + тело ~ десятки КБ; вложения КБ–десятки МБ (основная масса байт)
Хранилище:  1B юзеров × ~15 ГБ ≈ ~1e19 байт (~десятки EB) — вложения доминируют
Дедуп:      одно вирусное вложение, отправленное миллионам → храни ОДИН физ. блоб, ref-count
Поиск:      инвертированный индекс на ящик; sub-100 мс требует индекс, никогда не скан
Синк:       монотонный счётчик изменений на ящик → клиенты тянут лишь изменения с X

Цифры форсируют четыре решения сразу: шардируй по пользователю (единственный естественный ключ партиции), клади вложения в дедуплицированное объектное хранилище (иначе хранишь тот же PDF рассылки миллионы раз), строй инвертированный индекс на ящик (скан 200K сообщений не уложится в 50 мс) и синкай журналом изменений (пере-листать ящик на 200K сообщений на каждом устройстве абсурдно).

Высокоуровневый дизайн

Входящая почта бьёт в SMTP-серверы за MX-записями; стадия спам/фильтр отвергает или карантинит большинство до хранения; принятые сообщения пишутся в шард ящика получателя (метаданные + тело), а вложения отделяются в дедуплицированное объектное хранилище, и сообщение хранит лишь ссылку. Индексатор добавляет сообщение в индекс поиска пользователя. Клиенты читают и синкаются через слой API/IMAP, что использует журнал изменений ящика, отдавая лишь дельты.

Ключ партиции — пользователь: каждый ящик независим, так что ты шардируешь хранилище сообщений, индекс и журнал изменений все по ID пользователя. Это наичистейший возможный шардинг, ведь в общем пути нет кросс-юзер-джойнов — твоему inbox никогда не нужен мой — потому почта так хорошо масштабируется горизонтально, раз ты решился на партиционирование на пользователя.

Глубокое погружение: вложения, дедуп и поиск

Сообщение — две очень разные вещи, склеенные вместе: малая структурированная часть (заголовки, получатели, текст тела, набор меток) и потенциально крупные вложения. Храни их раздельно — тело сообщения и метаданные в шарде ящика, байты вложения в объектном хранилище, адресуемые по содержимому и дедуплицированные (та же идея, что в уроке Drive). PDF-рассылка на 2 МБ, отправленная 50 миллионам — это один физический блоб с 50 миллионами ссылок, не 50 миллионов копий — без дедупа вложения доминировали бы и разорили хранилище. Сообщение просто держит указатель (контент-хеш) плюс ref-count, так что блоб удаляется лишь когда уходит последнее ссылающееся сообщение.

Поиск не может быть сканом. Ящик на 200 000 сообщений, запрошенный по «счёт acme март», должен ответить за десятки миллисекунд, так что ты строишь инвертированный индекс на пользователя: отображение каждого терма в список ID сообщений, его содержащих, чтобы запрос пересекал несколько коротких posting-списков вместо чтения каждого сообщения. Индекс шардирован по пользователю (как и всё прочее), обновляется асинхронно индексатором по мере прихода почты. Индексация на пользователя держит каждый индекс малым, а запрос локальным для шарда одного пользователя — ты никогда не ищешь по пользователям, так что индексу не нужно быть глобальным.

Почему это работает

Зачем моделировать метки как many-to-many оверлей вместо классических папок и почему это важно на масштабе? Папка — это локация: сообщение живёт ровно в одном месте, так что «переместить в папку» физически перемещает его, и сообщение не может быть в двух папках сразу. Инсайт Gmail в том, что организация — на деле тегирование: одно сообщение может быть Work, и Receipts, и Important одновременно, а Inbox/Archive — тоже просто метки (архивация снимает метку Inbox, она не двигает байты). Так что моделируй это как many-to-many отношение — у сообщения набор ID меток — а не указатель родительской папки. Это важно, потому что (1) это избегает перемещения байт сообщения при реорганизации (ты мутируешь малый набор меток), (2) листинг «всё с меткой Work» — поиск по индексу, не обход директории, и (3) это тривиально композируется с поиском (метка — просто ещё один индексированный терм). Модель папок форсирует семантику одной-локации, под которую реальная организация почты не подходит; метки — модель данных, что совпадает с доменом.

Глубокое погружение: инкрементальный синк и спам

Три устройства показывают один ящик; ни одному нельзя перекачивать 200 000 сообщений ради обновления. Механизм — журнал изменений на ящик с монотонным счётчиком (HISTORYID у Gmail; MODSEQ/CONDSTORE — расширение IMAP для отслеживания изменений по порядковому номеру; JMAP — современный JSON-протокол для почтовых клиентов с встроенным инкрементальным синком). Каждая мутация — новое сообщение, флип прочитано/непрочитано, смена метки, удаление — инкрементирует счётчик ящика и записывается. Клиент помнит последний счётчик, что видел, и спрашивает «дай всё с счётчика X»; сервер возвращает лишь эту дельту. Так открытие телефона после того, как ноутбук пометил десять писем прочитанными, тянет десять изменений, не весь ящик. Это та же форма push-затем-pull-дельты, что в уроке Drive: журнал изменений делает синк O(изменений), не O(размер ящика), что единственное и работает на сотнях тысяч сообщений на пользователя.

Спам фильтруется на приёме, до хранения, по грубой экономической причине: ~90% входящего — спам, и хранить-затем-фильтровать значило бы платить за долговечное хранение, индексацию и репликацию в десять раз большего объёма почты, чем держишь. Стадия SMTP применяет дешёвые отказы первыми (репутация/DNSBL — списки блокировки известных спам-IP — по отправляющему IP, провалы аутентификации SPF/DKIM/DMARC — DNS-записи, подтверждающие, что сервер уполномочен слать от имени домена — рейт-лимиты), затем классификацию контента (ML-модели по заголовкам и телу), отвергая напрочь или направляя в метку Spam. Отвергай рано и дёшево; цель — никогда не тратить долговечное хранилище на почту, что выбросишь.

Частая ошибка

Ошибка масштабирования, что кажется безвредной сначала: реализовать случай отправлено-многим как запись в ящик каждого получателя полной копии включая байты вложения. Для рассылки на 50M юзеров это 50M × 2 МБ = 100 ТБ записано на одну отправку — и ты сохранил тот же PDF 50 миллионов раз. Починка из двух частей: дедуплицируй вложение (один блоб, ref-counted, 50M указателей), чтобы байты хранились однажды, и осознай, что fan-out доставка всё же пишет 50M малых строк сообщений (каждый ящик действительно получает сообщение), но эти строки крошечны без встроенных байт. Ошибка — путать «доставить в 50M inbox-ов» (неизбежные малые записи, fan-out) с «хранить 50M копий вложения» (избегаемо, через дедуп). Отдели тяжёлые байты от лёгкой доставки — и отправка становится по карману.

Викторина

PDF-вложение на 2 МБ отправлено по почте 50 миллионам подписчиков. Как должно проектироваться хранение вложений?

Викторина

Телефон, ноутбук и веб-почта пользователя должны показывать то же состояние ящика без перекачки каждым 200 000 сообщений. Каков механизм?

Закончи аналогию

Поскольку твоему inbox никогда не нужно джойниться с моим, хранилище сообщений, индекс поиска и журнал изменений все партиционированы по одному ключу. Этот естественный ключ партиции — _______.

Узкие места и компромиссы

Когда будешь стресс-тестировать этот дизайн, задай себе: что случится, если дедуп сломается и ref-count разойдётся, и какова максимальная задержка индексации до того, как новое сообщение станет ищущимся? Стоимость байт доминируют вложения, контролируется дедупом (один ref-counted блоб, не N копий) — центральный компромисс хранения. Поиск разменивает хранилище индекса и задержку асинхронной индексации на sub-100 мс запросы; альтернатива (скан) не масштабируется, так что индекс неоспорим. Синк разменивает малый журнал изменений на ящик на O(изменений) обновление вместо O(ящик) пере-листинга. Спам разменивает некоторый риск ложных срабатываний на экономику отвержения 90% объёма до хранения — отвергай рано, отвергай дёшево. Модель метки-как-оверлей разменивает привычную ментальную модель папки-одной-локации на many-to-many набор тегов, что никогда не двигает байты и композируется с поиском. А фундаментальное решение — партиционировать всё по пользователю — и есть то, что делает всё это масштабируемым, ведь данные почты не имеют кросс-юзер связности на горячем пути. Как в каждом разборе этого юнита: тяжёлые байты (вложения) идут в дедуплицированное объектное хранилище, а структурированное состояние (сообщения, метки, индекс, журнал изменений) живёт в шардированных на пользователя хранилищах, где его согласованность дёшево поддерживать.

Вспомните перед уходом
  1. 01
    Зачем партиционировать почту по пользователю и как обрабатываются вложения и поиск?
  2. 02
    Почему метки — many-to-many оверлей вместо папок?
  3. 03
    Как работает инкрементальный синк и зачем фильтровать спам на приёме?
Итог

«Почта — это просто SMTP» прячет четыре сложенные задачи, что проявляются лишь на миллиарде пользователей. Фундаментальный ход — партиционировать всё по пользователю: хранилище сообщений, индекс поиска и журнал изменений все шардируются по ID пользователя, ведь ящики независимы без кросс-юзер-джойнов на горячем пути — чистейший шардинг в этом юните. Входящая почта проверяется спам-фильтром на SMTP-приёме, до хранения (~90% объёма отвергается дешёвыми IP/auth-проверками, затем классификацией контента), так что ты никогда не платишь долговечным хранилищем за почту, что выбросишь. Принятые сообщения распадаются на малую структурированную часть (заголовки, тело, набор меток) в шарде ящика и крупные вложения, хранимые адресуемо по содержимому и дедуплицированно в объектном хранилище — PDF рассылки 50M юзерам это один ref-counted блоб с 50M указателей, не 50M копий; fan-out пишет лишь крошечные строки сообщений. Поиск обслуживается инвертированным индексом на пользователя (терм → posting-списки), никогда не сканом, так что ящик на 200K сообщений отвечает за десятки миллисекунд. Метки моделируются как many-to-many оверлей, а не папки, так что реорганизация мутирует крошечный набор тегов вместо перемещения байт и композируется с поиском. А клиенты остаются согласованными через инкрементальный синк по монотонному журналу изменений ящика (HISTORYID/MODSEQ/JMAP state) — тяни лишь изменения с последнего счётчика, O(изменений), не O(ящик). Как и по всему юниту, тяжёлые байты идут в дедуплицированное объектное хранилище, а структурированное состояние на пользователя несёт согласованность. Теперь, когда услышишь «почта — это просто CRUD» на ревью дизайна, знаешь, что спросить: дедуплицированы ли вложения, есть ли у поиска индекс, движется ли синк журналом изменений и отвергается ли спам до касания хранилища?

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 7 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.