Уровень: 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/