# 第 7 章：本地数据库

> 补回来的消息放在手机的哪里？App 在写到一半时被杀掉，重新打开后会不会少了几条？

深入理解 IM 系统 · https://im.liko.page/zh/local-database/

第 6 章最后，Ben 出了地铁，手机按信箱游标补回了错过的消息。那一章没有说的是：**补回来的消息放在哪？**

前面几章还欠着两笔：第 3 章的发件箱要在 App 被杀以后还在；第 6 章的信箱游标也要在重启后还在。这一章把它们，连同消息本身，放进手机上的一个本地数据库，并回答这一章真正的问题：怎么写，App 在任何一刻被杀，都不会少消息。

## 1. 为什么手机要自己存一份

不存是可以的：每打开一个会话，就问服务器要最近的 50 条。网页端常常这样做（第 26 章）。在手机上，它的代价是：

- Ben 每天打开 App 20 次，假设每次看 3 个会话，一天 **60 次**打开会话。
- 每次 50 × 200 字节 = **10 KB**，一天 **600 KB**；而他一天真正收到的只有 267 × 200 字节 ≈ **53 KB**（第 6 章）。同样的消息反复下载，是收到的 **11 倍**。
- 每次至少等一个来回，一天 60 × 0.2 = **12 秒**。在地铁里，什么也看不到。

发件箱和游标本来就得存在磁盘上，消息也放进去。于是分工变成：

- **界面只读本地数据库**，打开会话不碰网络；
- **网络收到的都先写进数据库**：同步的每一页、推送的每一条、每一个确认；
- **发出的消息也先写进去**，状态“发送中”，界面马上显示，收到确认再改成“已发送”。

手机上最常用的是 SQLite：它不是要连接的服务器，而是 App 里的一个库，整个数据库就是 App 目录里的一个文件。iOS 和 Android 都自带。

## 2. 存什么

```sql
CREATE TABLE messages (
  id              INTEGER PRIMARY KEY,
  conversation_id INTEGER NOT NULL,
  seq             INTEGER,               -- 服务器给的会话内序号（第 5 章）；发送中的还没有
  sender_id       INTEGER NOT NULL,
  msg_id          BLOB NOT NULL,         -- 手机生成的消息 ID（第 4 章）
  text            TEXT,
  state           TEXT NOT NULL,         -- 'sending' / 'sent' / 'failed' / 'received'
  UNIQUE (conversation_id, seq),
  UNIQUE (sender_id, msg_id)
);
CREATE TABLE gaps (conversation_id INTEGER, from_seq INTEGER, to_seq INTEGER);  -- 还没下载的一段
CREATE TABLE sync_state (key TEXT PRIMARY KEY, value INTEGER);                  -- 信箱游标
```

- **发件箱不是另一张表**，就是 `state = 'sending'` 的那几行。App 重启时把它们找出来，带着原来的消息 ID 重发（第 4 章）。
- **`(conversation_id, seq)`** 是聊天界面用的索引：打开会话查“seq 最大的 50 条”，数据库直接走到这个会话的末尾读 50 行，存了一百万条也一样快。
- **`gaps` 记着洞。** 第 6 章离线太久时只拿每个会话最新的一条，于是本地会有一段没下载的，比如一行 `(徒步群, 120, 140)`。Ben 往上翻到那里，再去服务器补。
- **游标和消息在同一个数据库里**，而不是 App 的设置里。为什么，是下一节的事。

手机还会记着每个会话最大的 seq，第 5 章靠它发现跳号。

**会有多大。** Ben 一天收 267 条、发 40 条。每条按 300 字节算（消息本身加索引，这是一个假设），一天约 **92 KB**，一年 **33.6 MB**。文字不是手机存储的难题，图片和视频才是（第 8 章）。

## 3. 看它坏掉：游标跑到了消息前面

游标的意思是“到这里为止的，我都有了”。重启以后，手机只问游标之后的，服务器也只能相信它。

一个很自然的写法是把两样分开放。游标只是一个数，放进 App 的设置（iOS 的 UserDefaults、Android 的 SharedPreferences，是和数据库分开的一个小文件），收到回复就存；消息多，交给后台线程上的写入队列，一条条写进数据库。于是游标存下时，队列里的消息往往还没写完；两个文件谁先落到磁盘上，也不固定。

下面是第 6 章那个热闹的晚上：230 条新消息，在三个群里，每页 50 条，每 0.2 秒到一页。写一条消息假设 0.1 毫秒；每次**提交**要等数据真正写到闪存上，假设 5 毫秒。拖动“被杀的时刻”看看。

*（互动图：请在网页上查看 https://im.liko.page/zh/local-database/）*

- **游标先存，消息逐条提交**：每条消息一个事务，一页要 255 毫秒，比下一页来得还慢，队列越排越长。App 在第 1.0 秒被杀：磁盘上 156 条，游标却是 230。重启后手机从 230 之后拉，中间的 **74 条**服务器以为它已经有了，不会再给。
- **游标先存，消息整页提交**：一页 10 毫秒写完，窗口小了，但还在：整个同步里仍有 **4.7%** 的时刻，被杀就缺一页。
- **同一个事务**：一页消息和游标一起提交，要么都在，要么都不在。**没有哪一刻会缺消息。**

前两种做法的数字（74 条、4.7%）都靠着“提交要 5 毫秒”的假设；只有最后的 0 不靠任何假设。

缺的消息也不是永远找不回来：那个群的下一条消息来时，seq 跳了号，手机会去补（第 5 章）。可如果缺的是 Ben 妈妈那条“我明天上午到”，就要等她下次发消息，也许是下周，在这之前他什么提示也看不到。

被杀是常事：iOS 只给后台的 App 很短的时间，[苹果的文档](https://developer.apple.com/documentation/uikit/extending-your-app-s-background-execution-time)说没及时结束后台任务，系统会终止 App；Android 内存紧张时会杀后台进程；用户会把 App 划掉，App 也会崩溃。v1 一天 200 万次同步，就算只有万分之一在两次写入之间被打断，一天也是 **200 台**手机少了消息，十万分之一也是 **20 台**，而且没有报错，也很难复现。

## 4. 修好它：一页消息和游标，一个事务

规则只有一句：**游标代表的东西没写进去之前，游标不能先写进去。** 最简单的做法是同一个事务：

```sql
BEGIN;
INSERT INTO messages (...) VALUES (...), (...), ...    -- 这一页的 50 条
  ON CONFLICT (sender_id, msg_id) DO UPDATE SET seq = excluded.seq, state = excluded.state;  -- 重复的落到原来那一行
INSERT OR REPLACE INTO sync_state (key, value) VALUES ('inbox_cursor', :next_cursor);
COMMIT;
```

SQLite 的[文档](https://www.sqlite.org/transactional.html)保证，一个事务里的改动要么全部生效、要么都不生效，哪怕中途程序崩溃或者断电。所以游标要放在同一个数据库里：设置文件和数据库是两个存储，跨不了一个事务。

被杀在提交之前，重启后同一页会再来一次；推送和同步也可能送来同一条。重复的那条按唯一键 `(sender_id, msg_id)` 落到原来那一行上，和第 4 章服务器去重是同一个道理，只是放在手机上；Ben 自己那条“发送中”的消息从信箱同步回来时，也是这样被改成“已发送”的。

同一条规则用在每一个写入的地方：

- **推送来一条**：信箱序号正好是游标 + 1，消息和游标一起提交；跳了号，只存消息，去拉缺的那段。
- **发一条**：先写入“发送中”的那一行，再发；确认回来，同一行填上 seq、改成“已发送”。
- **补一个洞**：拉回的旧消息和缩短后的 `gaps` 那一行一起提交。

## 5. 估算：大同步时手机要写多少

一页一个事务，还顺手解决了写得太慢。离线一周的 Ben（第 6 章）要写 **1,869 条、38 页**，下载 7.6 秒：每条一个事务是 1,869 次提交、约 **9.5 秒**，比下载还慢；每页一个事务是 38 次提交、约 **0.38 秒**。离线一个月：**40.9 秒**对 **1.6 秒**。5 毫秒是假设，不随它变的是提交次数，少了 49 倍。SQLite 的 [FAQ](https://www.sqlite.org/faq.html#q19) 说的就是这件事：它一秒能执行几万条 `INSERT`，事务却只能提交几十个（那是机械硬盘的数，闪存快得多），把多条 `INSERT` 包进一个事务，提交的开销就分摊掉了。

## 6. 重装和换手机

本地数据库在 App 的目录里，App 删掉或换了手机，它就没了；能找回多少，取决于服务器留了多少。这本书的服务器一直留着消息，所以新手机按第 6 章“先拿列表”：200 个会话各最新一条，约 **42 KB**、**4 个请求**，更早的往上翻时再拉。新设备第一次同步该拉多深，是第 14 章的事。也有产品的服务器送达后就删掉消息，比如 WhatsApp（[隐私政策](https://www.whatsapp.com/legal/privacy-policy)）和 Signal（[条款](https://signal.org/legal/)），历史只在手机上，丢了就要靠备份或旧手机：第 41 章讲。

## 7. 代价

- **两份真相。** 手机上的那份可能是错的：被服务器拒了的消息要标成失败，同步出的洞要记下、翻到时补，会话列表也会和服务器对不上（第 17 章）。客户端得会自己修。
- **占存储。** 文字一年约 34 MB；v2 里 500 人的徒步群一天 1,000 条，一个群一年就多 110 MB。要给用户清理的办法。
- **升级要迁移。** 表结构随版本变，每次升级都要把手机上的旧数据库原地改过来（第 29 章）。
- **写法上的纪律。** 每个会改游标或状态的地方，都要和它代表的数据在一个事务里。规则不难，难的是代码多了以后每个人都守住。

| 资源 | 不存，每次问服务器 | 本地数据库，一页一个事务 |
|---|---|---|
| 手机请求 | 每打开一个会话一次，一天 60 次 | 打开会话不发请求；只有同步和补洞 |
| 手机数据 | 打开会话一天下载约 600 KB | 只下载新消息，一天约 53 KB |
| 手机存储 | 几乎不用 | 一天约 92 KB，一年约 34 MB |
| 手机磁盘写入 | 几乎没有 | 离线一周回来，提交 38 次（逐条提交是 1,869 次） |

## 8. 其他答案

- **不存，或只存最近的一点**：没有迁移、没有两份真相。网络好、会话少的地方（比如浏览器里的客服窗口）可以这样，第 26 章讲网页端。
- **先写消息、再写游标，分开存**：游标进不了同一个事务时，这个顺序也不丢消息，代价是被杀后重复下载一页。
- **TDLib**：Telegram 开源的客户端库，[README](https://github.com/tdlib/td) 说网络、加密和本地数据存储都由它负责，代码里带着 SQLite；`use_message_database` [参数](https://core.telegram.org/tdlib/docs/classtd_1_1td__api_1_1set_tdlib_parameters.html)在重启之间保留聊天和消息。
- **Signal**：本地数据库是加了密的 SQLite（SQLCipher，见 [Android](https://github.com/signalapp/Signal-Android/blob/main/gradle/libs.versions.toml) 和[桌面端](https://github.com/signalapp/Signal-Desktop/blob/main/package.json)的依赖）。服务器不留历史，手机上就是全部历史，加密值得。

## 9. 这一章的决定

*（互动图：请在网页上查看 https://im.liko.page/zh/local-database/）*

**决策卡**

- 问题：补回来的消息、没发出去的消息、同步游标，都要在 App 被杀、重启以后还在；每次打开会话都问服务器，一天多下载约 600 KB，离线时什么也看不到。游标和消息分开存时，App 在两次写入之间被杀，游标跑到消息前面，那几条消息不会再被拉回来。
- 选择：手机上一个本地数据库（SQLite）：消息、洞、游标都在里面，发件箱就是“发送中”的那几行。界面只读数据库，网络收到的都先写进去。每一页同步、每一条推送、每一个确认，都和它推动的游标或状态在同一个事务里提交；重复的写入靠唯一键（发送者, 消息 ID）落到原来那一行上。
- 代价：两份真相，客户端要会自己修；一年约 34 MB 文字，媒体更多；每次升级要迁移；每个改游标的地方都要守住事务的纪律。
- 什么时候重新考虑：图片和文件存在哪（第 8 章）；新设备第一次同步拉多深（第 14 章）；会话列表放在本地数据库里（第 17 章）；网页端存多少（第 26 章）；一套本地数据库给所有平台、升级迁移（第 29 章）；服务器不留历史（第 41 章）。
- 其他答案：不存或只存最近的（网页、客服窗口）；先写消息再写游标（多下载一页）；TDLib 的消息数据库；Signal 加密的 SQLite（SQLCipher）。

消息有地方放了。下一章：一张 5 MB 的照片，不能和文字消息挤在同一条连接上。
