深入理解 IM 系统

第 7 章

本地数据库

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

这一章是初稿,会在写完前八章后再修订。

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

前面几章已经欠下了两笔:第 3 章说发件箱要存在磁盘上,App 被杀掉以后没发出去的消息还得在;第 6 章的信箱游标也得在重启后还在,不然每次打开 App 都要从头拉。这一章把它们,连同消息本身,放进手机上的一个本地数据库本地数据库local database手机上自己存的一份消息、会话和同步游标,通常是 SQLite。界面只读它,网络收到的东西先写进它;游标和它代表的消息在同一个事务里提交。在术语表里查看。

要回答的是三件事:手机为什么要自己存一份;存什么;以及怎么写,App 在任何一刻被杀掉,都不会少消息。第三件是这一章真正的决定。

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

不存是可以的。手机只在内存里留着当前看的那一屏,每打开一个会话,就问服务器要最近的 50 条。网页端常常就这样做,第 26 章再讲。在手机上,它的代价是这样的:

  • 按全书的数字,Ben 每天打开 App 20 次;假设每次看 3 个会话,一天就是 20 × 3 = 60 次打开会话。
  • 每次都要最近的 50 条:50 × 200 字节 = 10 KB。一天 60 × 10 KB = 600 KB。而 Ben 一天真正收到的消息只有 267 × 200 字节 ≈ 53 KB(第 6 章)。同样的消息被反复下载,是收到的 11 倍。
  • 每次打开会话至少等一个来回:在手机网络上 0.2 秒,一天 60 × 0.2 = 12 秒,而且这是网络好的时候。在地铁里,什么也看不到。

还有两样东西本来就必须存在磁盘上:发件箱(第 3 章)和信箱游标(第 6 章)。既然已经要一个能扛住重启的地方,消息也放进去。

于是手机上的分工变成这样:

  • 界面只读本地数据库。 打开会话,是在本地查最近的 50 条,不碰网络。
  • 网络收到的东西都先写进数据库:同步拉回的每一页、推送来的每一条、服务器回的每一个确认。写完了,界面再从数据库里读出来。
  • 发出的消息也先写进数据库,状态是“发送中”,界面马上显示出来;收到确认再改成“已发送”。

手机上最常用的本地数据库是 SQLite。它不是一台要连接的服务器,而是 App 里的一个库:整个数据库就是 App 目录里的一个文件,App 直接调用它读写,iOS 和 Android 都自带。后面会看到,Telegram 的客户端库 TDLib、Signal 的 App 也都是在它上面做的。

2. 存什么

四张表就够这一章用:

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 conversations (id INTEGER PRIMARY KEY, last_seq INTEGER, preview TEXT);  -- 手上最大的 seq;最后一条的预览
CREATE TABLE gaps (conversation_id INTEGER, from_seq INTEGER, to_seq INTEGER);   -- 还没下载的一段
CREATE TABLE sync_state (key TEXT PRIMARY KEY, value INTEGER);                   -- 信箱游标

几点说明:

  • 发件箱不是另一张表。 它就是 messages 里 state = 'sending' 的那几行。一条消息只有一行:显示、重发、确认,改的都是它。App 重启时,把还在“发送中”的找出来,带着原来的消息 ID 重发(第 4 章)。
  • (conversation_id, seq) 唯一,也就是聊天界面要用的索引。打开会话时查的是“这个会话里 seq 最大的 50 条”:有这个索引,数据库直接走到这个会话的末尾读 50 行,手机上存了一百万条也一样快;没有它,就要把整张表翻一遍。两个唯一键还有一个用处,第 4 节会用到:同一条消息再写一次,会被认出来。
  • gaps 记着洞。 第 6 章说,离线太久时只拿每个会话最新的一条,其余的打开会话时再拉。于是本地的会话里,最新的一条和更早的消息之间隔着一段没下载的。这张表记住它在哪,比如一行 (徒步群, 120, 140):徒步群的 seq 120 到 140 还在服务器上。Ben 往上翻到那里,就去服务器补。
  • conversations.last_seq 是手机上这个会话最大的 seq,第 5 章靠它发现跳号;preview 只是列表上显示的最后一条,完整的会话列表是第 17 章的事。
  • 游标放在同一个数据库里,而不是 App 的设置里。为什么,是下一节的事。

会有多大。 Ben 一天收 267 条、发 40 条,一共 307 条。每条消息按 300 字节算(200 字节的消息本身,加上两个索引和页里的空余,这是一个假设),一天 307 × 300 字节 ≈ 92 KB,一年 33.6 MB,三年约 100 MB。文字不是手机存储的难题;图片和视频才是,第 8 章讲。

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

手机上有两样东西必须对得上:消息,和游标。游标的意思是“到这里为止的,我都有了”。重启以后,手机只问游标之后的;服务器也只能相信它。

一个很自然的写法是把它们放在两个地方。游标只是一个数,正好放在 App 的设置里:iOS 的 UserDefaults、Android 的 SharedPreferences,都是和数据库分开的一个小设置文件,什么时候写到磁盘上由它自己决定。网络那边收到回复,就顺手把游标存进去。消息多,交给一个在后台线程上跑的写入队列,一条条写进数据库,不卡界面。于是游标存下的时候,队列里的消息往往还没写完。而且两边都是在后台慢慢落盘的(比如 SharedPreferences 的 apply()),谁先落到磁盘上,并不固定:这才是真正的问题。

下面是第 6 章那个热闹的晚上:Ben 回来时有 230 条新消息,在三个群里(120、80、30 条),每页 50 条,一页接一页地拉,在手机网络上每 0.2 秒到一页。写一条消息假设要 0.1 毫秒。每次提交要等数据真正写到闪存上(fsync),这里假设 5 毫秒。拖动“被杀的时刻”,看 App 在那一刻被杀,重启以后会怎样。

App 在第 1.0 秒被杀:最后一页刚到,游标已经存成了 230,可还有一些消息排在写入的队列里,没落到磁盘上。重启以后,这些消息会怎样?

磁盘上的消息存下的游标在这里被杀会缺消息

被杀时磁盘上
–
重启后从游标之后拉,缺
–
同步中会缺消息的时刻
–
提交次数
–

默认是“游标先存,消息逐条提交”。没有 BEGIN … COMMIT 时,SQLite 把每一条 INSERT 当作一个单独的事务,每条都要等一次提交:一页 50 条要 50 × 5.1 ≈ 255 毫秒,可下一页 200 毫秒后就到了。写入队列越排越长,网络在第 1.0 秒拉完了,磁盘要到第 1.37 秒才写完。游标在回复到达时就存了,不等队列:所以游标早已是 230,磁盘上的消息却还落在后面,图里的阴影就是这段差。

App 在第 1.0 秒被杀:磁盘上有 156 条,游标是 230。重启以后,手机从 230 之后拉,中间的 74 条(徒步群 38、同学群 26、公司群 10)服务器以为它已经有了,不会再给。

它们也不是永远找不回来:第 5 章的 seq 还在。徒步群下一条消息来时,手机看到 seq 从它手上最大的那个跳了几十号,就会去补。这三个群一晚上都在说话,几分钟内就补上了。可如果缺的是 Ben 妈妈的那一条“我明天上午到”,就要等她下一次发消息,也许是明天,也许是下周。在这之前,Ben 的会话列表显示的是旧的最后一条,那条消息他看不到,也没有任何提示。

切到“游标先存,消息整页提交”:一页 50 条放进一个事务,50 × 0.1 + 5 = 10 毫秒写完,赶在下一页之前。窗口小了很多,但还在:每一页的回复到达、游标已存、消息还没提交的那 10 毫秒里被杀,这一页就缺了。整个同步里,有 4.7% 的时刻在这样的窗口里;在第 1.0 秒那一刻被杀,缺的是最后一页的 30 条。

切到“同一个事务”:一页消息和游标一起提交。被杀在提交之前,两样都没写进去,重启后从旧的游标再拉这一页;被杀在提交之后,两样都在。没有哪一刻会缺消息。

前两种做法的 85.3% 和 4.7% 都靠着“提交要等 5 毫秒”的假设。后面会说到 WAL 模式加 synchronous = NORMAL,提交不必等闪存,逐条提交的队列也跟得上网络:假设一次提交只要 0.5 毫秒,85.3% 会掉到 13.5%,整页提交是 2.7%。可窗口不会消失:只要游标先存、消息后写,写一页要花多少时间,就有多长的窗口。不依赖任何假设的,只有“同一个事务”的 0。

被杀是常事吗? 是。App 退到后台以后,iOS 只给它很短的时间把手上的事做完,苹果的文档写得很直接:没有及时结束后台任务,系统会终止 App。Android 在内存紧张时会杀掉后台的进程。用户也会把 App 划掉,App 自己也会崩溃。离线同步恰好常常发生在 App 刚打开、或者刚被推送唤醒的那几秒里。

这个窗口能撞上多少次,没有公开的数可以用。按第 6 章,v1 一天有 200 万次同步;就算只有万分之一在两次写入之间被打断,一天也是 200 台手机少了消息,十万分之一也是 20 台。它们不报错,不会被用户说清楚,也很难在测试里复现:用户只会说“我没收到那条消息”。

4. 修好它:一页消息和游标,一个事务

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

BEGIN;
INSERT INTO messages (conversation_id, seq, sender_id, msg_id, text, state)
  VALUES (...), (...), ...      -- 这一页的 50 条;Ben 自己发的 state 是 'sent',别人的是 'received'
  ON CONFLICT (sender_id, msg_id) DO UPDATE SET seq = excluded.seq, state = excluded.state;
INSERT INTO conversations (id, last_seq, preview) VALUES (:conv, :seq, :text)
  ON CONFLICT (id) DO UPDATE SET                             -- 每个涉及的会话
    last_seq = max(coalesce(last_seq, 0), excluded.last_seq),
    preview  = CASE WHEN excluded.last_seq >= coalesce(last_seq, 0) THEN excluded.preview ELSE preview END;
INSERT INTO sync_state (key, value) VALUES ('inbox_cursor', :next_cursor)
  ON CONFLICT (key) DO UPDATE SET value = excluded.value;
COMMIT;

SQLite 的文档保证:一个事务里的改动,要么全部生效,要么一条都不生效,哪怕写到一半时程序崩溃、系统崩溃或者断电。所以游标和消息永远一起出现在磁盘上。这也是游标要放在同一个数据库里的原因:设置和数据库是两个存储,跨不了一个事务。

ON CONFLICT … DO UPDATE 的意思是:要插入的这条,如果 (发送者, 消息 ID) 已经有一行了,就不再插,而是用新来的 seq 和状态更新那一行。重复的写入因此无害:被杀在提交之前,重启后同一页会再来一次;推送和同步也可能送来同一条;它们都落到原来那一行上,和第 4 章服务器去重是同一个道理,只是放在了手机上。Ben 自己发的、还在“发送中”的那条从信箱里同步回来时,也是这样被改成“已发送”、填上 seq 的。会话那一句也是这样:第一次见到的会话直接插一行,见过的只把 last_seq 往大里改,预览只在来的是更新的消息时才换;coalesce 把空的 last_seq 当作 0。sync_state 那一句在第一次运行、还没有游标时也能写。ON CONFLICT … DO UPDATE 要 SQLite 3.24 以上;Android 自带的 SQLite 到 Android 10 都还是 3.22(Android 的文档列了每个版本),所以要支持老手机的 App 要么自带一份 SQLite,要么先 INSERT OR IGNORE、再 UPDATE。

同一条规则用在手机上每一个写入的地方:

  • 推送来一条消息:如果它带的信箱序号正好是游标 + 1(第 6 章),消息和新的游标一起提交;跳了号,就只存消息,游标不动,去拉缺的那段。
  • 发一条消息:先在一个事务里写入 state = 'sending' 的那一行,界面显示“发送中”,再交给网络。确认回来,一条 UPDATE 填上 seq、改成“已发送”。确认丢了也不要紧:这条消息会从信箱里同步回来,按上面的冲突规则落到同一行上。
  • 补一个洞:Ben 往上翻,拉回一段旧消息;这些消息和缩短后的 gaps 那一行,一起提交。会话那一句照样执行,可旧消息的 seq 比 last_seq 小,预览和 last_seq 都不会被它改回去。

还有一点细节。SQLite 有一种 WAL 模式(改动先追加到一个日志文件里),配合 synchronous = NORMAL(不在每次提交时都等闪存),写得更快。文档说,这样设置后,提交过的事务在 App 崩溃时一定还在,但遇到断电或系统崩溃,最后几个事务可能被回滚。被回滚也没关系:游标和它的消息在同一个事务里,一起回到之前的样子,还是对得上的。游标要是放在别处,就没有这个保证了。

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

一个事务一页,还顺手解决了另一件事:写得太慢。

第 6 章离线一周的 Ben:全部下载是 1,869 条、38 页,在手机网络上 38 × 0.2 = 7.6 秒,平均每秒要写约 246 条。

  • 每条消息一个事务:1,869 次提交,按上面假设的每条 0.1 + 5 毫秒,一共 9.5 秒,比下载还慢。写入队列越来越长,界面上的列表也跟着慢慢才更新。
  • 每页一个事务:38 次提交,1,869 × 0.1 + 38 × 5 毫秒 ≈ 0.38 秒。

离线一个月(8,010 条、161 页):每条一个事务要 40.9 秒,每页一个事务 1.6 秒。

5 毫秒是这一章的假设,不同的手机、不同的设置差得很远;不随假设变的是提交次数:1,869 次对 38 次,少了 49 倍。每次提交都是一次闪存写入。SQLite 的 FAQ 回答“为什么 INSERT 这么慢”,说的就是这件事:在普通电脑上它一秒能执行 5 万条以上的 INSERT,事务却只能提交“几十个”,因为默认每次提交都要等数据真正写到盘上;那是机械硬盘的数,闪存快得多,可 FAQ 后来补充说,结论不变:把多条 INSERT 包进一个 BEGIN … COMMIT,提交的开销就分摊掉了。

这些写入都放在后台的线程上,界面只读。读和写分开以后,用户在翻聊天记录时,大同步不会让屏幕卡住。

6. 重装和换手机

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

7. 代价

  • 两份真相。 服务器有一份,手机有一份,手机上的可能是错的:一条消息发出去被服务器拒了,要把那一行标成失败;同步出了洞,要记在 gaps 里、翻到时补;会话列表的未读数和最后一条,也会和服务器对不上(第 17 章要在登录时校一次)。客户端得会自己修。
  • 占手机的存储。 文字一年约 34 MB;v2 里那个 500 人的徒步群一天 1,000 条,一个群一年就多 110 MB。要给用户清理的办法,要决定旧消息留多久。
  • 升级要迁移。 表的结构会随着版本变,每次升级,用户手机上的旧数据库都要原地改过来,不能出错,也不能让 App 打开时卡好几秒(第 29 章)。
  • 写法上的纪律。 每一个会改游标、状态的地方,都要和它代表的数据在一个事务里。这条规则不难,难的是代码多了以后每个人都守住。
资源 不存,每次问服务器 本地数据库,一页一个事务
手机请求 每打开一个会话一次,一天 60 次 打开会话不发请求;只有同步和补洞
手机数据 打开会话一天下载约 600 KB 只下载新消息,一天约 53 KB
手机存储 几乎不用 一天约 92 KB,一年约 34 MB
手机磁盘写入 几乎没有 离线一周回来,提交 38 次(逐条提交是 1,869 次)

8. 其他答案

  • 不存,或者只存最近的一点:没有迁移、没有两份真相,换设备也没有区别。网络总是好、会话不多的场景(比如浏览器里的客服窗口)可以这样。第 26 章讲网页端怎么取舍。
  • 先写消息、再写游标,分开存:游标归一个你改不了的库管、进不了同一个事务时,这样也不会丢消息,前提是写入是幂等的(有唯一键,重复的跳过)。代价是被杀后会重复下载一页。
  • TDLib:Telegram 开源的客户端库。它的 README 说,网络、加密和本地数据存储都由它负责;代码里带着一份 SQLite。初始化的参数里,use_message_database 的说明是“在重启之间保留聊天和消息的缓存”,另有 database_encryption_key 给数据库加密。也就是这一章的做法:本地数据库是客户端库的一部分,App 不用自己写。
  • Signal:本地数据库是加了密的 SQLite,用的是 SQLCipher。Android 和 桌面端的依赖里都能看到它。服务器不留历史,手机上被偷走的就是全部历史,所以加密很值得;代价是每次读写多一点 CPU,密钥也要另找地方安全地放。

9. 这一章的决定

第 7 章加的一块:两台手机各有一个本地数据库。界面只读它,网络收到的都先写进它;发件箱是里面“发送中”的那几行,信箱游标和它代表的消息在同一个事务里提交。
Ana发件箱(本地库)Ben游标(本地库)消息 → ← ACK推送带 seq、信箱序号;重连从游标拉一个程序连接层持有长连接;记着 用户 → 连接消息层(收)取 seq;一个事务写消息和信箱;回 ACK分发层(发)推送带信箱序号;按信箱游标补齐业务层(旁路)Ana 在这个会话里吗存储层唯一键与信箱表发送者+消息ID会话+seq会话.last_seq信箱:用户+序号

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