Как я сделал трёхуровневый кэш сообщений в мессенджере на React Native — и что узнал по дороге

от автора

Уровень: middle/senior мобильная разработка, React Native, SQLite Стек: Expo SDK 54, React Native, expo-sqlite, drizzle-orm, AsyncStorage, TypeScript Что внутри: архитектура, код из продакшна, грабли, цифры

Вступление

Я делаю мессенджер ONEMIX на React Native. К моменту, когда я начал писать этот пост, в нём уже больше десятка экранов, групповые WebRTC-звонки через LiveKit, E2E на Double Ratchet + Sealed Sender, push-нотификации с cold-start навигацией и десктоп-версия на Electron. Но самым важным куском, который определяет ощущение от приложения, оказался не звук и не видео. А то, насколько быстро открывается чат.

Если вы хоть раз делали список сообщений на React Native, вы знаете эту боль: открыл чат — пустой экран на 200–800 мс, потом подгрузка, потом скачок при докрутке наверх. В Telegram такого не бывает: открыл — мгновенно увидел последние сообщения, прокрутил наверх — никаких пустот, история идёт сплошной лентой.

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

Почему один уровень не работает

Самый частый подход, который я встречаю в туториалах и open-source мессенджерах на RN: всё ходит в сеть, на устройстве кэшируется в AsyncStorage. Этот подход разваливается по трём причинам сразу.

Сеть медленная. Даже на хорошем 4G round‑trip до сервера — это 100–300 мс. На метро или в подвале — 1–3 секунды. Открывать чат с такой задержкой нельзя, пользователь уйдёт.

AsyncStorage — это key‑value хранилище без индексов. Когда у тебя в чате 5000 сообщений, ты вынужден хранить всю историю одной строкой JSON. Чтобы добавить новое сообщение, ты читаешь всю строку, парсишь, добавляешь, сериализуешь обратно, пишешь. Это десятки миллисекунд на каждое сообщение и ужасный износ устройства. А если хранить по сообщениям отдельно — получишь линейный поиск по тысячам ключей.

Нет уровня «мгновенно». Любая работа с диском в RN асинхронная. Это значит между открытием чата и появлением первого сообщения всегда есть промежуток, в котором экран либо пустой, либо со спиннером. Пользователь это видит.

Решение, к которому пришёл Telegram и которое я реализовал в ONEMIX — три уровня, каждый отвечает за свою скорость:

  • Level 1: In-memory LRU. ~0 мс, синхронный доступ, держит самые горячие данные.

  • Level 2: SQLite с индексами. 1–5 мс, асинхронный, переживает перезапуск приложения.

  • Level 3: Сервер. 200–2000 мс, источник истины, забираем delta через pts.

Дальше — как это устроено внутри.

Level 1: In-memory LRU

Цель этого уровня — отдавать данные мгновенно и синхронно. Без await, без промисов. Когда React-компонент рендерится в первый раз и спрашивает «дай мне сообщения чата X», ответ должен быть готов в той же microtask.

Реализация на голом Map:

class LRUCache<K, V> {  private map = new Map<K, V>();  constructor(private maxSize: number) {}  get(key: K): V | undefined {    const v = this.map.get(key);    if (v !== undefined) {      // Move to end (most recently used)      this.map.delete(key);      this.map.set(key, v);    }    return v;  }  set(key: K, value: V): void {    if (this.map.has(key)) this.map.delete(key);    this.map.set(key, value);    if (this.map.size > this.maxSize) {      // Evict least recently used (first entry)      this.map.delete(this.map.keys().next().value!);    }  }}

Здесь работает важная особенность ES2015+: Map сохраняет порядок вставки. То есть первый ключ от map.keys().next() — это самый старый по обращению. На каждом get мы удаляем и снова вставляем — ключ переезжает в конец. На каждом set при переполнении — удаляем самый старый. Это полноценный LRU без отдельной двусвязной структуры.

В ONEMIX два уровня L1: один для самих сообщений, второй — для pts (точка синхронизации, к ней вернёмся):

interface L1Entry {  messages: any[];  pts: number;  loadedAt: number;}const l1Messages = new LRUCache<string, L1Entry>(150);const l1Pts = new Map<string, number>();

150 чатов × до 500 сообщений в каждом — это потолок памяти примерно 30–60 МБ при обычном использовании, и это терпимо. Чаты, к которым давно не обращались, выпадают, но остаются в SQLite.

Самое важное в L1 — синхронный доступ:

export function getMessagesSync(chatId: string): any[] | null {  const l1 = l1Messages.get(chatId);  if (l1 && l1.messages.length > 0) return l1.messages;  return null;}

В компоненте экрана чата это используется так:

const initialMessages = getMessagesSync(chatId) ?? [];const [messages, setMessages] = useState(initialMessages);useEffect(() => {  // Параллельно — догрузка из L2 и L3, обновление состояния  hydrateFromL2AndL3(chatId).then(setMessages);}, [chatId]);

То есть при открытии чата мы немедленно отрисовываем то, что есть в памяти. Если чат недавно открывали — это все последние сообщения, экран показывается без единого спиннера. Параллельно стартует асинхронный поход в SQLite + сеть, и состояние тихо обновится, если что-то изменилось.

Level 2: SQLite с правильными индексами

L1 — память. Память кончается, когда приложение убито системой. Поэтому нужен персистентный уровень, который переживает перезапуск.

Я использую expo-sqlite напрямую и drizzle-orm для схемы. Drizzle — это не ORM в смысле гидрации объектов с ленивыми связями, это просто типобезопасный билдер DDL и запросов. Я в основном использую его для миграций, а сами запросы пишу на сыром SQL — для производительности и контроля.

Схема и индексы

export const messages = sqliteTable("messages", {  id:               text("id").primaryKey(),  chatId:           text("chat_id").notNull(),  senderId:         text("sender_id"),  content:          text("content"),  attachmentType:   text("attachment_type"),  attachmentUrl:    text("attachment_url"),  replyToId:        text("reply_to_id"),  reactions:        text("reactions"),       // JSON blob  pts:              integer("pts"),  readAt:           text("read_at"),  editedAt:         text("edited_at"),  createdAt:        text("created_at").notNull(),  deletedForAll:    integer("deleted_for_all").default(0),  // … прочие поля}, (t) => ({  // Главный паттерн: последние N сообщений чата  idxChatDate: index("idx_msg_chat_date").on(t.chatId, t.createdAt),  // Для offset_id / around-id  idxChatId:   index("idx_msg_chat_id").on(t.chatId, t.id),  // Для diff-sync по pts  idxPts:      index("idx_msg_pts").on(t.chatId, t.pts),}));

Три индекса — три паттерна доступа. Каждый запрос дальше будет точно попадать в один из них.

JSON в полях reactions, forwardedFrom, mediaGroup — сознательное решение. Эти данные читаются всегда вместе с сообщением, никогда не фильтруются по содержимому, и нормализовать их в отдельные таблицы означало бы JOIN на каждый запрос. SQLite — не PostgreSQL, JOIN’ы здесь дороже, чем кажется.

Чтение последних N сообщений

const rows = await db.all(  `SELECT * FROM messages   WHERE chat_id = ? AND deleted_for_all = 0   ORDER BY created_at DESC LIMIT ?`,  [chatId, limit]);

С индексом (chat_id, created_at) это range-scan — десятки микросекунд при размере таблицы в сотни тысяч строк. Условие deleted_for_all = 0 отрезается на этапе чтения индекса, сортировка не делается отдельно (она уже в порядке индекса).

Cursor-based пагинация — главное архитектурное решение

Здесь начинается интересное. Когда пользователь крутит ленту наверх, нужно подгружать ещё. Самый очевидный способ — OFFSET. Например LIMIT 50 OFFSET 200. Это работает на маленьких таблицах и катастрофически тормозит на больших: SQLite честно сканирует первые 200 строк, чтобы их пропустить.

Telegram использует cursor-based пагинацию через offset_id: «дай мне 50 сообщений старше сообщения с id X». Я делаю так же:

export async function getMessagesBeforeId(  chatId: string,  offsetId: string,  limit = 50): Promise<any[]> {  const db = await getSqlite();  if (!db) return [];  try {    const rows = (await db.all(      `SELECT * FROM messages       WHERE chat_id = ? AND id < ? AND deleted_for_all = 0       ORDER BY id DESC LIMIT ?`,      [chatId, offsetId, limit]    ) as any[]).reverse();    return rows.map(rowToMessage);  } catch (e) {    console.warn("[CacheV2] getMessagesBeforeId error:", e);    return [];  }}

Тут важная деталь, на которой я споткнулся не сразу: id сообщений я генерирую как time-sortable UUID (формат, в котором первые байты — миллисекундный timestamp). То есть лексикографическое сравнение id < ? совпадает с хронологическим. Это позволяет отказаться от сравнения created_at в WHERE и работать только с индексом (chat_id, id) — одним range-scan, без подзапросов вида «select created_at where id = ? then compare».

Если бы id был случайным (как UUIDv4), пришлось бы делать подзапрос, либо хранить отдельное поле — это лишний JOIN или лишняя колонка в индексе. Time-sortable id — это бесплатная производительность.

Around-id — прыжок к конкретному сообщению

Когда юзер тапает по replied-сообщению, нам нужно показать его в контексте: половина сообщений до, половина после.

export async function getMessagesAroundId(  chatId: string,  messageId: string,  limit = 80): Promise<{ messages: any[]; targetIndex: number } | null> {  const db = await getSqlite();  if (!db) return null;  try {    const half = Math.floor(limit / 2);    const afterRows = await db.all(      `SELECT * FROM messages       WHERE chat_id = ? AND id >= ? AND deleted_for_all = 0       ORDER BY id ASC LIMIT ?`,      [chatId, messageId, half + 1]    ) as any[];    const beforeRows = (await db.all(      `SELECT * FROM messages       WHERE chat_id = ? AND id < ? AND deleted_for_all = 0       ORDER BY id DESC LIMIT ?`,      [chatId, messageId, half]    ) as any[]).reverse();    const messages = [...beforeRows, ...afterRows].map(rowToMessage);    const targetIndex = messages.findIndex(m => m.id === messageId);    return { messages, targetIndex };  } catch (e) {    console.warn("[CacheV2] getMessagesAroundId error:", e);    return null;  }}

Два index range scan, никаких JOIN, никаких subquery. Возвращаем не только сообщения, но и targetIndex — чтобы UI понял, на какое сообщение нужно проскроллить и подсветить.

Batch insert внутри транзакции

Когда сервер прислал нам 50 новых сообщений, наивный код напишет их по одному. На моём тестовом устройстве это занимало 150–200 мс — каждый INSERT в отдельной транзакции, каждый — fsync. Решение:

await db.withTransactionAsync(async () => {  for (const row of rows) {    await db.runAsync(      `INSERT OR REPLACE INTO messages (        id, chat_id, sender_id, sender_name, sender_avatar,        content, attachment_type, attachment_url, file_name, file_size,        reply_to_id, forwarded_from_id, forwarded_from, media_group, reactions,        pts, read_at, edited_at, created_at, deleted_for_all, is_system, is_neuro      ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,      [        row.id, row.chat_id, row.sender_id, row.sender_name, row.sender_avatar,        row.content, row.attachment_type, row.attachment_url, row.file_name, row.file_size,        row.reply_to_id, row.forwarded_from_id, row.forwarded_from, row.media_group, row.reactions,        row.pts, row.read_at, row.edited_at, row.created_at, row.deleted_for_all, row.is_system, row.is_neuro,      ]    );  }});

INSERT OR REPLACE нужен потому, что у сообщения мог поменяться статус (прочитано, отредактировано). Транзакция вокруг батча даёт ускорение в 10–20 раз — все 50 INSERT летят в один fsync.

WAL mode SQLite (включается через PRAGMA journal_mode=WAL при инициализации) добавляет ещё параллельность чтения во время записи, что критично для UX — никто не зависает, пока идёт sync.

Level 3: pts-based delta-sync с сервером

L1 и L2 — это локальные кэши. Истина — на сервере. Главный вопрос: когда приложение запускается и видит, что в L2 есть 1000 сообщений последнего месяца, как понять, нужно ли что-то докачивать?

Самый плохой ответ — «запросить всё и сравнить». Это убивает батарею и трафик.

Telegram изобрёл механику pts (positive timestamp / persistent timestamp — у разных людей разная расшифровка). Идея простая: каждое изменение в чате — новое сообщение, удаление, редактирование, прочтение — увеличивает счётчик pts на сервере. Клиент хранит у себя последний известный pts. При запуске спрашивает: «у меня pts=12345, что нового?» Сервер отвечает: «вот все изменения с pts=12346».

Это лёгкий запрос: если в чате с момента последнего захода ничего не было, ответ пустой. Если было 3 новых сообщения — приходят только эти 3.

Хранение pts в моей схеме реализовано через отдельную таблицу плюс расширенное состояние синхронизации:

interface SyncState {  pts: number;  qts: number;       // отдельный счётчик для зашифрованных сообщений  seq: number;       // глобальный seq для гарантий порядка  maxReadId: string | null;  lastSync: string | null;  syncOk: boolean;}export async function updateSyncState(chatId: string, updates: Partial<SyncState>): Promise<void> {  const current = await getSyncState(chatId);  const next: SyncState = { ...current, ...updates };  l1SyncState.set(chatId, next);  const db = await getSqlite();  if (!db) return;  await db.runAsync(    `INSERT INTO chat_sync_state (chat_id, pts, qts, seq, max_read_id, last_sync, sync_ok)     VALUES (?, ?, ?, ?, ?, ?, ?)     ON CONFLICT(chat_id) DO UPDATE SET       pts = excluded.pts,       qts = excluded.qts,       seq = excluded.seq,       max_read_id = excluded.max_read_id,       last_sync = excluded.last_sync,       sync_ok = excluded.sync_ok`,    [chatId, next.pts, next.qts, next.seq,     next.maxReadId, next.lastSync ?? new Date().toISOString(), next.syncOk ? 1 : 0]  );}

ON CONFLICT DO UPDATE (UPSERT) — стандартный SQLite-приём, чтобы одним запросом и вставить, и обновить. Кэширование pts в l1SyncState (Map в памяти) убирает походы в SQLite на горячем пути.

Если sync прерван (например, потеря сети в середине), syncOk = false помечает чат как требующий полной ресинхронизации — следующий заход сделает не delta, а full fetch. Это спасает от состояний, в которых клиент думает, что всё хорошо, а на самом деле пропустил пачку обновлений.

Координация трёх уровней

Самое важное — не пропустить уровень и не сделать лишнюю работу. Полный путь чтения сообщений в getMessages:

export async function getMessages(chatId: string, limit = 80): Promise<any[] | null> {  // L1: instant  const l1 = l1Messages.get(chatId);  if (l1 && l1.messages.length > 0) {    const ago = Date.now() - l1.loadedAt;    if (ago < 5 * 60 * 1000) {      return l1.messages.slice(-limit);    }  }  // L2: SQLite  const db = await getSqlite();  if (db) {    try {      const rows = await db.all(        `SELECT * FROM messages WHERE chat_id = ? AND deleted_for_all = 0         ORDER BY created_at DESC LIMIT ?`,        [chatId, limit]      );      if (rows && rows.length > 0) {        const msgs = rows.reverse().map(rowToMessage);        l1Messages.set(chatId, {          messages: msgs,          pts: l1Pts.get(chatId) ?? 0,          loadedAt: Date.now(),        });        return msgs;      }    } catch (e) {      console.warn("[CacheV2] SQLite getMessages error:", e);    }  }  // L2 fallback: AsyncStorage (legacy для старых версий приложения)  // ... опускаю, см. репозиторий  // null означает: вызывающий код должен забрать из сети (L3)  return null;}

Логика сверху вниз: сначала память, потом диск, потом сообщить наверх «ничего нет, иди в сеть». L1 сам себя греет от L2 после первого попадания на L2 — следующий вызов уже мгновенный. Свежесть L1 — 5 минут; после этого считаем данные потенциально устаревшими и идём в L2 за последней версией.

Запись зеркальная — пишем сразу в L1 и L2:

export async function saveMessages(chatId: string, msgs: any[]): Promise<void> {  if (!msgs || msgs.length === 0) return;  // L1 — синхронно, мерджим с существующим  const existing = l1Messages.get(chatId);  if (existing) {    const existingIds = new Set(existing.messages.map((m: any) => m.id));    const fresh = msgs.filter((m: any) => !existingIds.has(m.id));    const merged = [...existing.messages, ...fresh]      .sort((a, b) => a.created_at < b.created_at ? -1 : 1)      .slice(-500);    l1Messages.set(chatId, { messages: merged, pts: existing.pts, loadedAt: Date.now() });  } else {    const sorted = [...msgs].sort((a, b) => a.created_at < b.created_at ? -1 : 1);    l1Messages.set(chatId, { messages: sorted, pts: 0, loadedAt: Date.now() });  }  // L2 — асинхронно, batch-транзакцией  const db = await getSqlite();  if (db) {    // ... batch insert внутри транзакции (см. выше)  }}

Обратите внимание: L1 апдейтится синхронно. Это значит, что после saveMessages сразу же getMessagesSync увидит новые данные — даже если SQLite ещё пишет в фоне. Для UI это критично: сообщение, которое ты отправил, должно появиться в ленте мгновенно, а не после fsync.

Грабли, которые я собрал

Грабля 1: холодный старт

Когда приложение запускается, L1 пустой. Если просто открыть чат и пойти в L2, мы получим первый кадр через 5–20 мс — это ощущается как лёгкий лаг. В Telegram такого нет.

Решение, которое я подсмотрел и реализовал: при старте приложения, до первого рендера, прогреваем L1 из AsyncStorage (в моей схеме часть данных дублируется туда для legacy-совместимости и быстрого preload):

const _instant: {  chats: any[] | null;  channels: any[] | null;  bots: any[] | null;  folders: any[] | null;  chatById: Map<string, any>;  chatInfo: Map<string, any>;  imageSizes: Map<string, any>;  loaded: boolean;} = {  chats: null, channels: null, bots: null, folders: null,  chatById: new Map(), chatInfo: new Map(), imageSizes: new Map(),  loaded: false,};export async function preloadCacheInstant(): Promise<void> {  if (_instant.loaded) return;  // Читаем все ключевые AsyncStorage-записи параллельно  const keys = [EXT_KEYS.CHATS_LIST, EXT_KEYS.CHANNELS_LIST, EXT_KEYS.BOTS_LIST, EXT_KEYS.FOLDERS_LIST];  const entries = await AsyncStorage.multiGet(keys);  for (const [key, raw] of entries) {    if (!raw) continue;    try {      const { data } = JSON.parse(raw);      if (key === EXT_KEYS.CHATS_LIST)    _instant.chats    = data;      if (key === EXT_KEYS.CHANNELS_LIST) _instant.channels = data;      if (key === EXT_KEYS.BOTS_LIST)     _instant.bots     = data;      if (key === EXT_KEYS.FOLDERS_LIST)  _instant.folders  = data;    } catch {}  }  if (_instant.chats) {    for (const c of _instant.chats) {      if (c && c.id) _instant.chatById.set(c.id, c);    }  }  _instant.loaded = true;}

Это вызывается из _layout.tsx ДО первого рендера экрана чатов. После этого getChatsSync() и getChatByIdSync() работают синхронно, и список чатов появляется в первый кадр.

Грабля 2: AsyncStorage умеет лгать о размере

Я долго не мог понять, почему на некоторых устройствах после месяца активного использования приложение начинает тормозить на ровном месте. Оказалось: AsyncStorage на Android — это SQLite под капотом (одна таблица key/value), но без индексов и без vacuum. После тысяч записей-удалений он начинает читать данные через десятки фрагментированных страниц.

Решения два, я применил оба:

  • Вынести всё, что хранится тысячами (сообщения), в нормальную SQLite-таблицу с индексами. Это и есть L2.

  • В AsyncStorage держать только списки и состояния (несколько килобайт каждое), а не множество мелких ключей.

После миграции с AsyncStorage-как-основное-хранилище на SQLite-как-основное холодный старт ускорился примерно в 4 раза. Это как раз то, что произошло у меня в рефакторинге message-cache.ts (1130 строк монолита) на cache-v2.ts + media-cache.ts.

Грабля 3: реакции и редактирования ломают наивный мердж

Если код мерджит сообщения по id и оставляет «существующие неизменными», ты потеряешь обновления реакций, статус прочтения и редактирования. Я долго ловил баг «у пользователя стоит лайк на сообщении, но в твоём интерфейсе его нет». Решение — отдельный путь updateMessage, который применяет частичные апдейты и в L1, и в L2 точечным UPDATE без перезаписи всей строки:

export async function updateMessage(chatId: string, messageId: string, updates: Partial<any>): Promise<void> {  // L1  const l1 = l1Messages.get(chatId);  if (l1) {    l1.messages = l1.messages.map((m: any) =>      m.id === messageId ? { ...m, ...updates } : m    );  }  // L2 — частичный UPDATE  const db = await getSqlite();  if (db) {    const setClauses: string[] = [];    const values: any[] = [];    if (updates.content !== undefined)   { setClauses.push("content = ?");   values.push(updates.content); }    if (updates.read_at !== undefined)   { setClauses.push("read_at = ?");   values.push(updates.read_at); }    if (updates.edited_at !== undefined) { setClauses.push("edited_at = ?"); values.push(updates.edited_at); }    if (updates.reactions !== undefined) { setClauses.push("reactions = ?"); values.push(JSON.stringify(updates.reactions)); }    if (setClauses.length > 0) {      await db.runAsync(        `UPDATE messages SET ${setClauses.join(", ")} WHERE id = ?`,        [...values, messageId]      );    }  }}

saveMessages (полная вставка) и updateMessage (частичный апдейт) — это разные операции на разных событиях, и смешивать их нельзя.

Грабля 4: lazy-init SQLite

Сначала я инициализировал SQLite на старте приложения, синхронно в _layout.tsx. Это добавило 80–150 мс к холодному старту, что мне не нравилось. Я перенёс init в lazy-pattern:

let _sqliteReady = false;let _sqliteError = false;let _db: any = null;async function getSqlite() {  if (_sqliteReady) return _db;  if (_sqliteError) return null;  try {    const dbMod = await import("./db/index");    await dbMod.initDb();    _db = dbMod.getDb();    _sqliteReady = true;    return _db;  } catch (e) {    console.warn("[CacheV2] SQLite init failed, falling back to AsyncStorage:", e);    _sqliteError = true;    return null;  }}

Первый поход в L2 теперь стоит 80–150 мс, но он происходит после того, как L1 уже отдал данные в первый кадр. К моменту, когда пользователь начнёт что-то делать, SQLite уже готов. И если init упал (например, на старом Android device без места на диске), флаг _sqliteError не даст системе пытаться снова и снова — она работает на AsyncStorage-fallback.

Что в итоге

После всех этих изменений приложение ведёт себя так:

  • Открытие чата — мгновенно, если он недавно открывался (L1 hit), и за 5–10 мс в холодном случае (L2 hit).

  • Прокрутка истории — без пустот, страница в 50 сообщений из L2 приходит за ~5 мс.

  • Холодный старт — список чатов в первом кадре, без спиннера.

  • Объём трафика на синхронизацию маленький — приходят только дельты по pts, а не вся история.

Главный урок, который я для себя сделал: в мобильном приложении уровень «0 мс синхронно» — это отдельный архитектурный слой, не оптимизация. Без него все остальные слои выглядят медленными, потому что между ними и юзером всегда есть пропущенный кадр. С ним всё остальное может быть как угодно медленным — пользователь этого не увидит.

Если делаете что-то похожее — возьмите эти три уровня как отправную точку. Они сильно проще, чем кажутся, и сильно лучше, чем любой одноуровневый подход.

Спасибо, что дочитали. Я делаю ONEMIX как соло-разработчик, и пишу в Telegram-канал про инженерные решения по ходу разработки — там короче, чаще и без редактуры. Если интересен такой формат — заходите.

Если есть вопросы по конкретным кускам кода или хотите разобрать смежную задачу — пишите в комментарии, на следующую статью пойдёт самая интересная.

ссылка на оригинал статьи https://habr.com/ru/articles/1033502/