惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

AI
AI
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
Google DeepMind News
Google DeepMind News
T
Tenable Blog
博客园_首页
S
Securelist
Spread Privacy
Spread Privacy
Google Online Security Blog
Google Online Security Blog
Forbes - Security
Forbes - Security
Engineering at Meta
Engineering at Meta
U
Unit 42
L
LINUX DO - 热门话题
量子位
T
Threat Research - Cisco Blogs
博客园 - 【当耐特】
C
Cyber Attacks, Cyber Crime and Cyber Security
K
Kaspersky official blog
MyScale Blog
MyScale Blog
P
Proofpoint News Feed
The Last Watchdog
The Last Watchdog
Google DeepMind News
Google DeepMind News
GbyAI
GbyAI
Martin Fowler
Martin Fowler
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
Security Latest
Security Latest
Scott Helme
Scott Helme
V
Vulnerabilities – Threatpost
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
I
InfoQ
Know Your Adversary
Know Your Adversary
Cisco Talos Blog
Cisco Talos Blog
The Register - Security
The Register - Security
T
The Blog of Author Tim Ferriss
aimingoo的专栏
aimingoo的专栏
V2EX - 技术
V2EX - 技术
T
Tailwind CSS Blog
月光博客
月光博客
Recent Announcements
Recent Announcements
G
Google Developers Blog
F
Full Disclosure
W
WeLiveSecurity
宝玉的分享
宝玉的分享
腾讯CDC
G
GRAHAM CLULEY
Vercel News
Vercel News
Simon Willison's Weblog
Simon Willison's Weblog
美团技术团队
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
Help Net Security
Help Net Security

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Как я сделал трёхуровневый кэш сообщений в мессенджере на React Native — и что узнал по дороге
niktomimo · 2026-05-10 · via Все публикации подряд на Хабре

Уровень: 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-канал про инженерные решения по ходу разработки — там короче, чаще и без редактуры. Если интересен такой формат — заходите.

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