第 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. 这一章的决定
消息有地方放了。下一章:一张 4 MB 的照片,不能和文字消息挤在同一条连接上。