IO与存储性能
# IO 与存储性能
本文核心命题:IO 是"看不见的杀手"——它不像 CPU/内存那样直观可见,但 90% 的卡顿、ANR、启动慢的背后都有 IO 在作祟。IO 性能问题的根因 90% 在"如何使用 API",不在"用什么 API"。
# 01.阅读说明
- 本文卷归属:卷二 · 资源篇 · 第 6 篇
- 本文目标层级:L2 进阶 → L3 专家
- 适用平台:Android(主) / iOS / Web / 跨端框架 / 嵌入式
- 前置阅读:
卷二·02 内存监控与治理(mmap 是内存与 IO 的桥梁)卷二·03 OOM 异常与低内存治理(IO 与 mmap 共享地址空间)
- 本文核心命题:
IO 是"看不见的杀手":CPU profiler 看不见(fsync 期间 CPU 空闲)、CPU 占用低(IO 等待)、内存正常(脏页未刷)。
IO 优化 = 主线程零 IO + IO 量级最小化 + 单次 IO 最快 + 持续防退化。
全文 21 章地图:
§01 阅读说明 §02 贯穿案例 §03 IO 物理本质 §04 落盘机制原理
§05 度量与采集 §06 归因决策树
§07 文件 IO 全链路 ⭐ §08 SQLite 全链路 ⭐ §09 KV 存储全链路 ⭐
§10 mmap 全链路 ⭐ §11 跨端存储对照 ⭐ §12 跨端对照
§13 治理一层主线程零 IO ⭐ §14 治理二层量级 ⭐ §15 治理三层批量 ⭐ §16 治理四层防退化 ⭐
§17 求证实验 ⭐ §18 实战案例 §19 防劣化体系 §20 跨平台速查
§21 总结与延伸
2
3
4
5
6
7
阅读建议:先读 §02 案例 → §03/§04 拿到原理 → §05/§06 学会度量归因 → §07-§11 五个全链路(文件/SQLite/KV/mmap/跨端)→ §13-§16 四层治理 → §17 求证 → §18-§20 工程闭环。
# 02.贯穿案例
本案例贯穿全文:§03 看懂介质本质、§05/§06 用三方案+决策树定位、§17 用实验复盘、§13-§16 给出分层策略闭环。
# 2.1 案例背景
某头部阅读 App V7.3 上线"全量日志埋点"功能(产品同学希望"用户每个动作都有数据"),灰度后立刻爆雷:
- ANR 率从 0.04% 飙升到 0.61%,被 Google Play 打"应用未响应"标签。
- 冷启动 P95 从 1.4s 升到 4.1s(产品要求"秒开"目标完全失败)。
- 低端机(Android 8,2GB RAM)SQLite 主线程写 P99 达 820ms,触发 ANR。
- 客服收到大量"读到一半页面卡 1 秒"投诉。
- 阅读时长(核心 KPI)下降 8%。
研发组初步反应:"日志写一下能慢成这样?SharedPreferences/SQLite 都是异步的吧?"——这是典型的"API 表面认知"。
# 2.2 经验派的 4 周折腾(典型反面教材)
| 周次 | 动作 | 结果 |
|---|---|---|
| 第 1 周 | 把日志改成 SharedPreferences.apply()(认为是异步) | ANR 率反而升到 0.78%:apply 累积后 onPause/onStop 同步等所有未落盘 |
| 第 2 周 | 把 SQLite 加大量索引(怀疑查询慢) | 写入更慢(每写一行更新所有索引);冷启动达 4.6s |
| 第 3 周 | 把日志写入加 try-catch(怀疑异常拖慢) | 无变化(IO 不是异常) |
| 第 4 周 | 把日志改成"批量每 50 条写一次"(方向对但实现错) | 仍未包事务,性能仅好 15% |
复盘:四周折腾错在"对 IO API 的表面认知"——以为 apply 真异步、以为加索引能加速所有操作、以为加 try-catch 能治 IO。IO 性能的真相在介质和落盘机制层,不在 API 名字层。
# 2.3 方法派的 6 天闭环
新接手的同学按本文方法论重做:
Day 1(§03/§04 物理本质 + §05 三方案):
- 方案 A(系统级 iostat):写 IOPS 在卡顿时段飙到 1200+(远超低端机能力)。
- 方案 B(/proc/[pid]/io):阅读 App 进程 write_bytes 每分钟 47MB(异常高)。
- 方案 C(应用层埋点):日志库每秒触发 80+ 次 SQLite INSERT + 每次单独 commit。
→ 每条日志 = 1 次 INSERT + 1 次 fsync + 1 次 SharedPreferences.apply——三件事都在主线程触发。
Day 2(§06 决策树):
- CPU 占用低(仅 8-15%)→ 大概率 IO 阻塞 ✓
- 主线程 ✓
- API:SharedPreferences 用 apply 但累积;SQLite 无事务、每条单独写
Day 3(§17 实验思路验证):直接用 §17.1 数据说服团队:"apply 累积 100 次会让 onPause 卡 80-200ms"。
Day 4-5(§13-§16 分层策略):
- 第 1 层(少):日志先在内存批量到 1000 条,再异步刷盘。
- 第 2 层(批):日志库内部 SQLite 改
BEGIN TRANSACTION ... COMMIT,1000 条一次事务。 - 第 3 层(异):日志写入完全异步线程;SharedPreferences 改用 MMKV(mmap)。
- 第 4 层(缩):日志体积用 protobuf 替代 JSON,每条 -50%。
Day 6(上线灰度对比):
# 2.4 上线效果
| 指标 | 经验派 4 周后 | 方法派 6 天后 |
|---|---|---|
| ANR 率 | 0.78% | 0.05% |
| 冷启动 P95 | 4.6s | 1.3s |
| 低端机 SQLite 主线程写 P99 | 820 ms | 8 ms(移离主线程) |
| 日志写入吞吐 | 80 条/s | 3500 条/s |
| 阅读时长 | -8% | +2%(回升) |
核心洞察:日志库的"每条 INSERT + fsync"是典型 IO 反模式——单看每次操作很轻量(< 5ms),高频累积直接 ANR。IO 性能问题的根因 90% 在"如何使用 API",不在"用什么 API"。MMKV、WAL、事务批量这些"老技术"在每个项目都值得被重新检查。
# 2.5 案例如何串起本文
- §03 物理本质 ▶▶ 介质延迟差 7 个数量级 + fsync 是杀手——案例所有问题的根。
- §04 落盘机制 ▶▶ apply 异步 ≠ 不阻塞——表面 API vs 物理本质。
- §07-§11 五大全链路 ▶▶ 文件/SQLite/KV/mmap/跨端 五条链路对应案例每一类问题。
- §17 求证实验 ▶▶ §17.1 SP 假异步、§17.2 批量、§17.3 小文件、§17.4 WAL、§17.5 mmap 都在案例中变现。
- §13-§16 四层治理 ▶▶ "少→批→异→缩"四字诀正是案例落地路径。
探索性思考:为什么"API 表面认知"是工程师最容易陷入的误区?因为 API 文档写"apply() 是异步的"——大多数工程师就此打住。但 SP 的源码里 apply() 异步只是"返回快",生命周期切换时仍同步等。API 是抽象的承诺,物理是冷酷的事实 —— 真正的工程师必须穿透 API 看到物理。
# 03.IO 物理本质
# 3.1 一句话定义
IO = 数据在"易失存储(内存)"和"持久存储(磁盘)"之间的传输。
这句话隐含三个不可商量的物理约束:
约束一:存储介质的延迟差距是 7 个数量级
L1 cache: 1 ns (类比:1 秒)
内存: 100 ns (类比:1.5 分钟)
SSD 随机读: 100 μs (类比:1 天)
SSD 随机写: 1 ms (类比:10 天)
HDD 寻道: 10 ms (类比:100 天)
机械硬盘随机: 100 ms (类比:3 年)
2
3
4
5
6
移动设备几乎都是 eMMC/UFS(类 SSD),但低端机的小颗粒寿命末期,写入延迟可达 50-200ms。
约束二:fsync 是同步阻塞
write() 系统调用 → 数据进入 page cache(μs 级)
↓
内核异步刷盘(kernel 决定何时)
↓
fsync() → 强制刷盘 + 等待硬件 ACK(ms 级)
线程完全阻塞
2
3
4
5
6
fsync 一次可能 = 1ms(高端 SSD)= 50-200ms(低端 eMMC 末期)。
约束三:小文件惩罚
每个文件的 IO 都涉及:
- inode 查找
- 目录遍历
- 元数据更新(atime/mtime)
- 分配/释放数据块
1000 个 1KB 文件的总开销远比 1 个 1MB 文件大(§17.3 实验27×)。
# 3.2 现象与代价
IO 慢的工程本质:
- 同步阻塞:fsync/fdatasync 会等数据真正落盘,期间线程完全阻塞
- 小文件惩罚:每个文件都要走 inode 查找、目录遍历
- 写放大:一次逻辑写可能触发多次物理写(日志、索引、元数据)
- 碎片惩罚:长时间使用后文件碎片化,连续读变成随机读
业务代价:
- 主线程 fsync 一次,60fps 直接掉到 6fps
- 启动期 IO > 5MB 时冷启时间 +500ms
- 日志高频写不优化时 ANR 率涨 10×
▶▶ 回扣 §02 案例:低端机 SQLite 主线程写 P99 达 820ms 直接对应"低端机小颗粒寿命末期写入 50-200ms"——日志库每秒触发 80+ 次写直接打爆设备 IO 能力。
# 3.3 度量准则与基准
资源视角(USE):
| 指标 | 含义 | 阈值参考 |
|---|---|---|
| IOPS | 每秒 IO 次数 | 视设备 |
| IO 带宽 | 读/写吞吐 | 视设备 |
| IO 等待时间 | iowait | < 10% |
请求视角(RED):
| 指标 | 含义 | 阈值参考 |
|---|---|---|
| 主线程单次 IO 耗时 | 单次 IO 调用 | P95 < 16ms |
| 主线程总 IO 时长占比 | IO 时长 / 总时长 | < 5% |
| 数据库主线程查询 | 单次 query | P99 < 50ms |
| 启动期 IO 总量 | 冷启读写字节 | < 5MB |
行业基准:
| 平台 | 主线程 IO 红线 | KV 选型 | DB 选型 |
|---|---|---|---|
| Android | 0 主线程 IO | MMKV | SQLite + WAL |
| iOS | 0 主线程 IO | UserDefaults(轻量)/ MMKV | Core Data / GRDB |
| Web | 0 主线程 IO | localStorage(小)/ IndexedDB | IndexedDB |
| 嵌入式 | 严格规划 | NVM | 嵌入式 KV |
# 3.4 反直觉问题清单
带着这些问题阅读:
- 为什么"日志写得多"会直接导致 ANR?
- 同样写 1MB,写 1 个文件 vs 写 1000 个小文件,差几个数量级?
- 为什么 SQLite 加了索引反而更慢?
- 主线程读 SharedPreferences 真的安全吗?
- 为什么 mmap 写入"没真正落盘"也能保证不丢?
- SSD 的"随机 IO 不比顺序 IO 慢"是真的吗?
- 为什么"批量写"比"逐条写"快 100 倍?
- 异步写一定比同步写好吗?
探索性思考:为什么 IO 是"看不见的杀手"?因为它的症状(卡顿)和现象(CPU 低)是相反的——CPU profiler 上看一切正常,但用户感受卡顿。正常的 profile 工具按"CPU 占用"找问题,但 IO 阻塞时 CPU 占用是 0 —— 这是工具的盲区。好的工程师要会"看缺席的数据" —— CPU 不忙但卡顿,那就是 IO。
# 04.落盘机制原理
# 4.1 文件 IO 全栈架构
应用代码(Java/Kotlin/Swift/JS)
↓ FileOutputStream.write()
语言运行时(JVM/ARC/V8)
↓ JNI / syscall
libc / Bionic
↓ write() syscall
内核 VFS(虚拟文件系统)
↓ ext4/F2FS/APFS/HFS+ 具体文件系统
Page Cache(内核内存)
↓ 异步刷盘(kernel 决定)
Block 层 + IO 调度器(CFQ/Deadline)
↓
设备驱动(eMMC/UFS/NVMe)
↓
存储介质(NAND Flash)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
关键观察:
- write() 默认只到 page cache(用户态返回)
- fsync() 才真正落到介质(硬同步点)
- 任何一层崩溃都会丢"page cache 中未落盘"的数据
# 4.2 落盘语义层级
① 用户态 buffer → ② page cache → ③ 介质
write() ─┐
│
fflush() ─┤ 仅刷到 page cache
│
fsync() ─┘ 强制刷到介质(含数据 + 元数据)
fdatasync() ── 仅刷数据,不刷元数据(更快)
2
3
4
5
6
7
8
关键认知:
- 崩溃丢失:write() 后未 fflush,进程崩溃时丢
- 断电丢失:fflush() 但未 fsync,断电时丢
- 介质损坏:fsync() 后仍可能丢(硬件故障)
性能 vs 可靠性权衡:
- 不 fsync:高性能,崩溃可丢最近数据
- fsync:可靠,每次 1-200ms 阻塞
- mmap:高性能 + 内核管理(详见 §10)
# 4.3 写放大与小文件惩罚
写放大:
逻辑写 1KB →
物理写 4KB(块对齐)+
数据库 WAL 8KB +
索引更新 4KB
= 物理 16KB(16× 放大)
2
3
4
5
小文件元数据开销:
写 1 个 100MB 文件:
= 1 次 inode 创建 + ~25600 个数据块
写 1 万个 10KB 文件:
= 10000 次 inode 创建 + ~30000 个数据块(每文件至少 1 块对齐)
+ 10000 次目录遍历
+ 10000 次 mtime 更新
2
3
4
5
6
7
# 4.4 跨平台同构原理
底层都是 POSIX 文件 IO(Windows 是 win32 API,但语义相近)。
跨平台术语对照
| 通用术语 | Android | iOS | Web | 嵌入式 |
|---|---|---|---|---|
| 文件读写 | FileInputStream | NSFileHandle | File API | fopen/fread |
| KV 存储 | MMKV / SP | NSUserDefaults | localStorage | NVS |
| 数据库 | SQLite / Room | Core Data / GRDB | IndexedDB | 嵌入式 SQL |
| 内存映射 | MemoryFile / mmap | mmap | SharedArrayBuffer | mmap |
| 强制落盘 | fsync | fsync | (无) | sync |
| 文件系统 | ext4 / F2FS | APFS / HFS+ | OPFS / IndexedDB | LittleFS / FATFS |
# 4.5 平台差异点矩阵
| 维度 | Android | iOS | Web | 嵌入式 |
|---|---|---|---|---|
| 主流文件系统 | ext4(旧)/ F2FS(新) | APFS | OPFS(实验) | LittleFS / FATFS |
| 默认 KV | SharedPreferences | NSUserDefaults | localStorage | EEPROM |
| 默认 DB | SQLite | Core Data | IndexedDB | 自定义 |
| 沙箱 | 应用专用 | 应用专用 | 域名专用 | 无 |
| 异步 API | Coroutines | DispatchQueue | Promise | 视设计 |
探索性思考:为什么"page cache"是 OS 设计的伟大发明?因为它把"应用写"和"介质写"解耦——应用可以高频小写,内核合并刷盘。但代价是"崩溃丢数据"的风险。所有抽象都是 trade-off —— 性能换可靠性,反之亦然。
# 05.度量与采集
# 5.1 三类采集方案
① 系统级 IO 监控(设备级)
② 进程级 IO 追踪(进程级)
③ 应用层埋点(业务级)
2
3
① 系统级 IO 监控
# Android
adb shell cat /proc/diskstats
adb shell iostat 1
# Linux
iostat -x 1
2
3
4
5
6
优势:开销极低,全设备视图。
局限:粒度到设备级,无法归因到具体进程。
② 进程级 IO 追踪
# Android
adb shell cat /proc/$(pidof com.example.app)/io
# 输出:
# rchar: 读字节总数
# wchar: 写字节总数
# read_bytes: 实际从介质读
# write_bytes: 实际写入介质
2
3
4
5
6
7
8
优势:进程级归因。
局限:strace 拖慢被测进程数倍;线上不可用。
③ 应用层埋点
class IOWrapper(private val target: FileOutputStream) {
override fun write(buf: ByteArray) {
val start = SystemClock.elapsedRealtimeNanos()
try { target.write(buf) }
finally {
val durMs = (SystemClock.elapsedRealtimeNanos() - start) / 1_000_000
if (durMs > 16) report(durMs, Throwable())
}
}
}
2
3
4
5
6
7
8
9
10
优势:业务级归因(哪个业务、哪行代码)。
局限:覆盖度有限(仅业务调用层);性能开销 5-10%。
# 5.2 各方案的可见盲区
| 方案 | 钩子位置 | 数据粒度 | 性能开销 | 跨端通用性 | 线上可用 | 主要局限 |
|---|---|---|---|---|---|---|
| ① 系统级 | iostat | 设备 | 极低 | 跨端有差异 | 服务端可 | 无归因 |
| ② 进程级 | /proc/io | 进程 | 高(strace) | Linux 系 | 不可 | 不知调用栈 |
| ③ 应用层 | API hook | 业务调用 | 中(5-10%) | 跨端通用 | 可 | 不覆盖系统 |
实战建议:服务端用 ①;端上排障用 ②;端上线上用 ③ + 抽样 ①。
# 5.3 跨平台采集对照表
| 平台 | 系统级 | 进程级 | 应用层 |
|---|---|---|---|
| Android | /proc/diskstats / iostat | /proc/[pid]/io | FileInputStream / SQLiteDatabase hook |
| iOS | task_info | task_info | NSFileHandle hook |
| Web | (沙盒) | (沙盒) | IndexedDB / localStorage timing |
| Linux | iostat / blktrace | /proc/[pid]/io | strace / 自定义 hook |
# 5.4 数据可信度评估
- 系统级:可信度高,但只看总量。
- 进程级:可信度高,但开销大。
- 应用层:可信度中(受 hook 覆盖度限制),但归因强。
探索性思考:为什么 IO 监控难做?因为它"看似简单实则复杂"——单一指标(IOPS / 字节数)只看总量,定位需要"调用栈 + 时间",但抓栈本身就拖慢 IO。IO 是 Heisenberg 原理的真实应用 —— 测量改变被测物。
# 06.归因决策树
# 6.1 IO 问题决策树
卡顿 / 慢
│
├─ CPU 高吗?
│ │
│ ├─ 高 → CPU 问题(不在本章)
│ └─ 低 → 大概率 IO 阻塞
│ │
│ ├─ 主线程吗?
│ │ │
│ │ ├─ 是 → 检查文件操作、数据库、SP/MMKV、日志
│ │ └─ 否 → 跨线程锁等待,或主线程在等异步 IO 结果
│ │
│ └─ 用什么 API?
│ ├─ SharedPreferences → apply 还是 commit?
│ ├─ SQLite → 事务/索引/WAL 检查
│ ├─ 文件 → fsync/小文件/序列化开销
│ └─ 网络缓存 → 磁盘缓存路径检查
│
└─ IO 量大吗?
│
├─ 量大 → 算法层优化(合并、分片、压缩)
└─ 量小但慢 → 物理层问题(碎片、磁盘满、低端机颗粒劣化)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
▶▶ 回扣 §02 案例:阅读 App 的诊断路径精准命中——CPU 低(8-15%)→ IO 阻塞 → 主线程 → SQLite 无事务 + SharedPreferences 累积。
# 6.2 主线程 IO 检测三板斧
- StrictMode(Android):开发期开启 detectDiskReads/detectDiskWrites,主线程 IO 直接红屏
- 采样栈追踪:定时采样主线程栈,统计落在 IO 系统调用上的比例
- AOP 拦截:编译期插桩 FileInputStream/SQLiteDatabase 的 open,主线程触发记录
# 6.3 数据库慢查询定位
SQLite 慢查询定位 4 步:
- 开启
EXPLAIN QUERY PLAN,看是否走索引 - 检查
PRAGMA journal_mode(WAL 比 DELETE 快 3-5 倍) - 看是否在事务里(无事务的逐条写每条都 fsync)
- 检查
page_size与cache_size(默认值往往不合理)
# 6.4 IO 量过大归因
典型 IO 量过大场景:
- 日志写入:每条日志一次 fsync(典型反模式)
- 数据库批量写未事务:每条 INSERT 一次 fsync
- 多个小文件写:每个文件多次 syscall
- 未压缩数据传输:网络 -> 磁盘 -> 内存全链路放大
- 缓存重复读:同一文件反复读
探索性思考:为什么"决策树"是 IO 归因最有效的工具?因为 IO 问题的症状-根因映射相对结构化 —— CPU 低就是 IO,主线程就是要异步,这些都是确定性的判断。IO 归因不需要 AI,决策树足够。
# 07.文件 IO 全链路
# 7.1 Android 文件 IO 全链路
① FileOutputStream(file).write(bytes)
↓ Java 层缓冲(默认无缓冲)
② libcore.io.IoBridge → Posix.write
↓ JNI 调用
③ Bionic libc → write() syscall
↓ 陷入内核
④ VFS → ext4/F2FS 文件系统
↓
⑤ Page Cache(写入内存)
↓ 用户态返回(μs 级)
⑥ Kernel 异步刷盘(30s 默认)
↓
⑦ Block IO 调度 → eMMC / UFS 驱动
↓
⑧ NAND Flash 物理写入
2
3
4
5
6
7
8
9
10
11
12
13
14
15
关键性能点:
- write() 默认只到 page cache(~10μs)
- 显式 fsync() 才真正落盘(1-200ms)
- BufferedOutputStream 减少 syscall 次数(推荐 8KB-64KB 缓冲)
优化代码:
// 反例:每次 write 都 syscall
val out = FileOutputStream(file)
data.forEach { out.write(it.toByteArray()) }
// 正例:用缓冲
BufferedOutputStream(FileOutputStream(file), 64 * 1024).use { out ->
data.forEach { out.write(it.toByteArray()) }
} // close 时 flush 一次
// 关键节点强制落盘(金融场景)
out.fd.sync() // 等价 fsync
2
3
4
5
6
7
8
9
10
11
# 7.2 iOS 文件 IO 全链路
① NSFileHandle / FileManager
↓
② Foundation 缓冲层
↓
③ libsystem_kernel.dylib → write() syscall
↓
④ XNU 内核 → APFS / HFS+
↓
⑤ Unified Buffer Cache
↓
⑥ AHCI / NVMe 驱动 → 介质
2
3
4
5
6
7
8
9
10
11
iOS 特殊性:
- APFS 写时复制(CoW),元数据开销大
- iCloud 同步会增加额外 IO
- NSFileProtection 加密层带来 ~5% 性能损耗
# 7.3 Web 文件 IO 全链路
① File API / OPFS(Origin Private File System)
↓
② 浏览器内部 IndexedDB 实现
↓
③ 转换为 SQLite 操作(部分浏览器)
↓
④ 走 OS 文件 IO
2
3
4
5
6
7
Web 特殊性:
- 沙盒限制:只能在浏览器分配的存储区
- 没有 fsync 等价 API
- IndexedDB 性能与浏览器实现强相关
# 7.4 嵌入式文件 IO 全链路
① 应用 API
↓
② RTOS 文件层(LittleFS / FATFS)
↓
③ Flash 驱动
↓
④ NOR / NAND Flash
2
3
4
5
6
7
嵌入式特殊性:
- 无 page cache(直接落盘)
- Flash 擦写次数限制(~10K-100K)
- 必须 wear-leveling 算法
# 7.5 跨平台文件 IO 性能对照
| 平台 | write() 延迟 | fsync 延迟 | 推荐缓冲 |
|---|---|---|---|
| Android(高端机) | ~10μs | 1-10ms | 64KB |
| Android(低端机) | ~20μs | 50-200ms | 32KB |
| iOS | ~10μs | 1-10ms | 64KB |
| Web IndexedDB | ~100μs | (无) | (transaction) |
| 嵌入式 | ~100μs | 直接 | 1-4KB |
▶▶ 回扣 §02 案例:低端机 SQLite 主线程写 P99 达 820ms 直接对应"低端机 fsync 50-200ms"。物理介质的延迟上限是优化的天花板,不能违反。
探索性思考:为什么"page cache"对应用透明却如此重要?因为它是性能与可靠性的"调节器" —— 没有它,每次 write 都要等介质(~ms);有它后,write 几乎免费(~μs)。好的系统设计往往是"看不见的优化" —— 用户感觉不到 page cache 的存在,但失去它整个 OS 都会慢 100×。
# 08.SQLite 全链路
# 8.1 Android SQLite 写入全链路
① db.execSQL("INSERT INTO t ...")
↓
② SQLiteDatabase 解析 SQL
↓
③ 准备 statement(编译)
↓
④ 在事务上下文中执行:
- 默认无事务 → 自动 BEGIN + INSERT + COMMIT(每条都触发)
- COMMIT 触发:
- 写 WAL 文件(或 rollback journal)
- fsync()
- 写主库(WAL)/ 应用 journal(DELETE)
- fsync()
↓
⑤ 释放锁
2
3
4
5
6
7
8
9
10
11
12
13
14
15
关键瓶颈:
- 每条 INSERT 都触发 2 次 fsync(1-200ms × 2)
- 没有事务时,1000 条 INSERT = 2000 次 fsync
事务优化:
db.beginTransaction()
try {
items.forEach {
db.execSQL("INSERT INTO t VALUES (?, ?)", arrayOf(it.id, it.data))
}
db.setTransactionSuccessful()
} finally {
db.endTransaction() // 仅 1 次 fsync
}
2
3
4
5
6
7
8
9
收益:1000 条从 1000 次 fsync → 1 次 fsync(§17.2 实验70× 加速)。
# 8.2 SQLite WAL 全链路
DELETE 模式(默认):
① BEGIN → 创建 rollback journal
↓
② INSERT → 主库写
↓
③ COMMIT → 删除 journal(fsync 主库)
读阻塞写,写阻塞读
2
3
4
5
6
7
8
WAL 模式:
① BEGIN → 写 WAL 文件
↓
② INSERT → 追加到 WAL
↓
③ COMMIT → fsync WAL(不动主库)
↓ 后台 checkpoint
④ 一次性把 WAL 合并到主库
读不阻塞写,写不阻塞读
2
3
4
5
6
7
8
9
10
配置:
PRAGMA journal_mode=WAL; -- 启用 WAL
PRAGMA synchronous=NORMAL; -- WAL 下用 NORMAL(默认 FULL 性能差)
PRAGMA wal_autocheckpoint=1000; -- 1000 页自动 checkpoint
2
3
§17.4 实验 数据:
| 模式 | 读吞吐 | 写吞吐 | 读 P99 | 写 P99 |
|---|---|---|---|---|
| DELETE | 1,200 op/s | 350 op/s | 28 ms | 180 ms |
| WAL | 8,500 op/s | 1,800 op/s | 5 ms | 15 ms |
# 8.3 SQLite 索引全链路
① CREATE INDEX → B-tree 创建
↓ 占额外空间
② SELECT WHERE indexed_col = ?
→ 走索引(O(log n))
③ INSERT / UPDATE / DELETE
→ 更新表 + 更新所有索引(每个索引都要写)
2
3
4
5
6
关键认知:
- 索引加速查询,但拖慢写入
- 每多一个索引,写入慢 3-5%
- 复合索引按"高频先后"排列
反模式:
-- ❌ 给低基数字段加索引(如 gender 仅 M/F)
CREATE INDEX idx_gender ON users(gender);
-- ❌ 给所有字段都加索引(写入慢 3-5×)
-- ✅ 仅为高频查询字段加索引
CREATE INDEX idx_user_email ON users(email);
-- ✅ 复合索引按高频先后
CREATE INDEX idx_log_time_level ON logs(create_time, level);
2
3
4
5
6
7
8
9
10
# 8.4 iOS Core Data 全链路
Core Data 是 iOS 的 ORM 层,底层仍是 SQLite。
① NSManagedObjectContext.save()
↓
② NSPersistentStoreCoordinator 转换为 SQL
↓
③ 走 SQLite 标准链路
2
3
4
5
Core Data 注意点:
- 比直接 SQLite 多一层 ORM(性能 -20%)
- 主线程 context 慎用,应用 background context
- 自动 fetch limit 防止全表扫
# 8.5 跨平台 DB 性能对照
| 数据库 | 平台 | 单条写延迟 | 批量优化 | 推荐场景 |
|---|---|---|---|---|
| SQLite | 全平台 | 1-10ms | 事务 | 通用 |
| Core Data | iOS | 2-15ms | NSBatchInsertRequest | iOS 业务对象 |
| Room | Android | 1-10ms | @Transaction | Android |
| IndexedDB | Web | 1-50ms | transaction | Web 离线 |
| Realm | 跨端 | < 1ms | write 块 | 高性能场景 |
▶▶ 回扣 §02 案例:日志库的"每条 INSERT 单独 commit"是典型反模式——每条 = 2 次 fsync = 2-400ms(低端机)。Day 4 改事务批量后,1000 条只需 2 次 fsync,吞吐 80→3500 条/s。
探索性思考:为什么"事务"是 SQLite 性能的"关键"?因为事务是"fsync 合并的契约" —— 应用告诉 SQLite "这一批要么全成要么全败",SQLite 就只在 COMMIT 时 fsync 一次。事务的本质是"原子性 + 性能"的统一 —— 看似为正确性服务,实为性能服务。
# 09.KV 存储全链路
# 9.1 Android SharedPreferences 全链路
① sp.edit().putString(k, v).apply()
↓ 仅修改内存中 HashMap
② QueuedWork 异步队列入队
↓
③ 子线程写入 XML 文件
↓
④ fsync()
关键:onPause / onStop 时会同步等所有 apply 完成
(QueuedWork.waitToFinish)
2
3
4
5
6
7
8
9
10
SP 的"假异步":
- apply() 返回快(μs 级)
- 但生命周期切换时,主线程会等所有未落盘的 apply
- 累积 100 次 apply,onPause 卡 80-200ms
§17.1 实验证明这是反直觉的陷阱。
SP 替代方案 MMKV:
MMKV.initialize(context)
val mmkv = MMKV.defaultMMKV()
mmkv.encode("key", value) // 真异步,无生命周期等待
2
3
# 9.2 MMKV 全链路
① mmkv.encode("key", value)
↓
② 序列化为 protobuf 字节
↓
③ 直接写入 mmap 区域
↓ (内核管理刷盘)
④ 用户态返回(< 1μs)
关键:mmap 数据由内核异步刷盘
进程崩溃可能丢失最近未刷数据
2
3
4
5
6
7
8
9
10
MMKV 优势:
- mmap 零 syscall
- protobuf 紧凑序列化
- 多进程支持
- 跨平台(Android/iOS/Win/Mac)
MMKV 局限:
- 进程崩溃可能丢数据(需 msync 或 sync mode)
- 单 key value 不超过 buffer size
# 9.3 iOS UserDefaults 全链路
① UserDefaults.standard.set(value, forKey: key)
↓
② 修改内存中 dictionary
↓
③ 异步写入 plist 文件(cfprefsd 进程)
↓
④ 跨进程通知更新
2
3
4
5
6
7
iOS UserDefaults 注意点:
- 适合小数据(< 1MB)
- 跨进程同步开销大
- 大数据用 Core Data 或文件
# 9.4 Web localStorage / IndexedDB
localStorage:
localStorage.setItem(key, value)
↓ 同步阻塞主线程
写入磁盘
2
3
limitations:
- 同步阻塞(每次写都阻塞主线程)
- 5-10MB 限制
- 仅字符串
IndexedDB:
indexedDB.open(...).onsuccess = (e) => {
const db = e.target.result;
const tx = db.transaction(["store"], "readwrite");
tx.objectStore("store").put({key, value});
}
2
3
4
5
IndexedDB 优势:
- 异步
- 支持复杂数据
- 大容量(视浏览器,通常 50MB+)
# 9.5 跨平台 KV 选型对照
| 场景 | Android | iOS | Web | 跨端 |
|---|---|---|---|---|
| 高频小数据 | MMKV | MMKV | localStorage | MMKV |
| 配置项 | MMKV / SP | UserDefaults | localStorage | MMKV |
| 大数据 | SQLite / Room | Core Data | IndexedDB | Realm |
| 跨进程 | MMKV (multi mode) | App Group | (无标准) | MMKV |
§17.5 实验 数据(高频小写):
| 场景 | 普通 IO + fsync | mmap (MMKV) | 增益 |
|---|---|---|---|
| 100×100B/s 持续 60s | 12,500 ms | 125 ms | 100× |
▶▶ 回扣 §02 案例:日志库改 MMKV 后吞吐从 80 升到 3500 条/s,正是 mmap 在"高频小写"场景下的真实威力。
探索性思考:为什么 MMKV 能"在原 SP 接口下"提供 100× 性能?因为它打破了"接口决定性能"的迷信——同样的 KV 接口,底层一个走 fsync 一个走 mmap,性能差 100×。接口是契约,实现是性能 —— 工程师永远要看实现,不要被接口骗了。
# 10.mmap 全链路
# 10.1 mmap 物理原理
① mmap(addr, len, PROT_RW, MAP_SHARED, fd, offset)
↓ 内核创建 VMA(虚拟内存区域)
② 应用首次访问 addr → page fault
↓
③ 内核从文件读 page 到 page cache
↓
④ 映射到应用虚拟地址
↓
⑤ 应用读写就是普通内存操作
⑥ 内核异步刷脏页
↓
⑦ msync(addr, len, MS_SYNC) 强制刷
2
3
4
5
6
7
8
9
10
11
12
13
关键认知:
- mmap 没有 read/write syscall
- 数据在 page cache 中,多进程共享
- 写后不主动 msync 可能丢数据(崩溃 / 断电)
# 10.2 mmap 适用场景
最适合:
- 高频小写(KV / 日志)
- 大文件随机访问(数据库 / 索引)
- 跨进程共享(IPC)
- 只读大文件(资源 / 模型)
不适合:
- 一次性大文件读(开销大于普通 IO)
- 严格落盘的金融场景(需配合 msync)
- 嵌入式(内存有限)
# 10.3 Android mmap API
// API 27+
val sm = SharedMemory.create("name", size)
val buf = sm.mapReadWrite()
buf.putInt(0, 42)
// 旧版本:MemoryFile(ashmem)
val mf = MemoryFile("name", size)
mf.writeBytes(data, 0, 0, data.size)
2
3
4
5
6
7
8
# 10.4 iOS mmap API
let fd = open(path, O_RDWR | O_CREAT, 0666)
ftruncate(fd, size)
let addr = mmap(nil, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0)
// 使用 addr 作为普通内存
msync(addr, size, MS_SYNC) // 强制刷盘
2
3
4
5
# 10.5 mmap vs fsync 对比
| 维度 | mmap | fsync IO |
|---|---|---|
| 高频小写 | 100× 快 | 慢 |
| 大数据顺序 | 略快 | 接近 |
| 数据安全(崩溃) | 可能丢 | 保证 |
| 复杂度 | 中(需理解 VMA) | 低 |
| 跨进程 | 天然支持 | 复杂 |
探索性思考:为什么 mmap 在工程上"被低估"?因为它的接口看起来不像 IO(更像内存)。工程师本能用 read/write,不会想到把"写文件"变成"写内存"。好的工具往往违反直觉 —— mmap 的"反直觉"是它优势的源头。
# 11.跨端存储对照
# 11.1 跨端存储栈对照
Android:FileSystem → SQLite / MMKV / SP
iOS: FileSystem → SQLite / Core Data / UserDefaults / MMKV
Web: OPFS → IndexedDB / localStorage / SessionStorage
嵌入式: Flash → 嵌入式 KV / 自定义
2
3
4
# 11.2 文件系统对照
| 平台 | 文件系统 | 写时复制 | 加密 | 性能特点 |
|---|---|---|---|---|
| Android(旧) | ext4 | 否 | 视设备 | 性能稳定 |
| Android(新) | F2FS | 是(部分) | 设备级 | 优化 SSD |
| iOS | APFS | 是 | 默认 | 元数据开销大 |
| macOS | APFS / HFS+ | 是 / 否 | 视配置 | 同上 |
| Linux | ext4 / btrfs | 否 / 是 | 视配置 | 通用 |
# 11.3 KV 存储对照
| 平台 | 默认方案 | 推荐方案 | 性能 |
|---|---|---|---|
| Android | SharedPreferences(慢) | MMKV | 100× |
| iOS | UserDefaults(中) | MMKV / 自定义 mmap | 5-10× |
| Web | localStorage(同步) | IndexedDB | varies |
| 跨端 | (无) | MMKV / Realm | 高 |
# 11.4 数据库对照
| 平台 | 默认方案 | 推荐配置 |
|---|---|---|
| Android | SQLite(Room) | WAL + 事务 + 合理索引 |
| iOS | Core Data | 后台 context + WAL |
| Web | IndexedDB | transaction 批量 |
| 跨端 | Realm / SQLite | 与平台一致 |
# 11.5 跨端通用最佳实践
- 所有平台:主线程零 IO
- 所有平台:高频小写用 mmap(MMKV)
- 所有平台:DB 必启用 WAL(或等价)
- 所有平台:批量必用事务
- 所有平台:小文件聚合存储
探索性思考:为什么"跨平台 IO 优化"思路高度统一?因为底层物理(NAND Flash)和 OS 设计(POSIX)是统一的。应用层 API 看起来千差万别,但物理本质是同一个。理解物理层的工程师,看任何平台都是"换 API 名字而已"。
# 12.跨端对照
# 12.1 五个全链路总览
| 链路 | Android | iOS | Web | 嵌入式 |
|---|---|---|---|---|
| 文件 IO | FileOutputStream → ext4/F2FS | NSFileHandle → APFS | IndexedDB → 浏览器实现 | LittleFS / FATFS |
| 数据库 | SQLite + Room | Core Data | IndexedDB | 嵌入式 SQL |
| KV 存储 | SP → MMKV | UserDefaults | localStorage | NVS |
| mmap | SharedMemory(API 27+) | mmap syscall | SharedArrayBuffer | mmap |
| 跨端方案 | MMKV / Realm | MMKV / Realm | IndexedDB | 自定义 |
# 12.2 各平台优化优先级
Android:
- StrictMode 全开
- SP 全面迁移 MMKV
- SQLite 启用 WAL + 事务
- 日志/统计走异步缓冲
- 小文件聚合存储
iOS:
- Core Data 必用 background context
- UserDefaults 仅小数据,大数据用 MMKV / Core Data
- SQLite/GRDB 配合 WAL
- 文件系统操作主线程禁
Web:
- localStorage 仅极小数据
- 大数据用 IndexedDB
- transaction 批量写入
- OPFS 用于高性能场景
# 12.3 反直觉问题答疑
| 问题 | 答案 |
|---|---|
| "日志写得多" 怎么 ANR? | 每条 fsync 50-200ms,累积阻塞主线程 |
| 1MB vs 1000×1KB 差几个数量级? | 27× |
| 加索引必加速 SQLite 吗? | 错。写入慢 3-5× |
| 主线程读 SP 安全吗? | 不。首次读会同步加载整个 XML |
| mmap 数据真"零拷贝"? | 是。但需 msync 才保证落盘 |
| SSD 随机不慢吗? | 读基本无差,写仍 2-5× 写放大 |
| 批量写比逐条快多少? | 70-100× |
| 异步写一定好吗? | 不一定,进程崩溃可能丢 |
▶▶ 回扣 §02 案例:经验派 100% 命中"反直觉问题"——把 SP apply 当真异步,把加索引当万能。真相是:物理介质的延迟、fsync 的阻塞、page cache 的存在 才是真正决定性能的因素。
# 13.治理一层主线程零 IO
核心命题:主线程 IO 是性能问题的最大单点。这一层"0 容忍"是基础。
# 13.1 StrictMode 全量拦截 + penaltyDeath
机理:把"约定"变成"强制约束"。
代码:
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder()
.detectDiskReads()
.detectDiskWrites()
.detectNetwork()
.penaltyDeath() // Debug 直接 crash 暴露
.build());
}
2
3
4
5
6
7
8
收益:开发期 100% 拦截;线上版用 penaltyLog 收集。
边界:Release 不能 penaltyDeath(用户场景);某些库(Glide 等)可能误报需白名单。
# 13.2 SharedPreferences 全面迁移到 MMKV
机理:§17.5 实验高频小写 100×;§17.1 实验SP apply 仍会在生命周期切换时同步等。
代码:
// 反例
val sp = context.getSharedPreferences("config", MODE_PRIVATE)
sp.edit().putString("key", value).apply()
// 正例
MMKV.initialize(context)
val mmkv = MMKV.defaultMMKV()
mmkv.encode("key", value) // 真异步,无生命周期等待
2
3
4
5
6
7
8
收益:§02 案例日志库切 MMKV 后吞吐从 80→3500 条/s。
边界:MMKV 数据兼容性需测试;多进程场景用 MMKV.mmkvWithID(..., MMKV.MULTI_PROCESS_MODE)。
# 13.3 所有 SQLite 操作走子线程
机理:即使是 SELECT id FROM users LIMIT 1 在 SD 卡有压力时也可能 100ms+。
代码:
viewModelScope.launch(Dispatchers.IO) {
val users = db.userDao().getAll()
withContext(Dispatchers.Main) {
adapter.submitList(users)
}
}
2
3
4
5
6
收益:主线程永不被 SQLite 阻塞。
边界:Room 默认在主线程会 throw(除非 allowMainThreadQueries() 显式开启);可用此特性强制约束。
# 13.4 日志/统计走异步缓冲
机理:§02 案例日志库就是反面教材;正例是 Logan/Xlog 设计。
代码:
class AsyncLogger {
private val buffer = ConcurrentLinkedQueue<LogEntry>()
private val executor = Executors.newSingleThreadExecutor()
fun log(entry: LogEntry) {
buffer.offer(entry) // 主线程仅入队(μs 级)
if (buffer.size >= 1000) flushAsync()
}
private fun flushAsync() {
executor.submit {
val batch = mutableListOf<LogEntry>()
while (batch.size < 1000 && buffer.isNotEmpty()) {
batch += buffer.poll()
}
writeBatchToFile(batch) // 子线程批量写
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
收益:§02 案例日志主线程耗时从 ms 级降到 μs 级。
边界:进程崩溃时缓冲区数据丢;关键日志用 mmap 兜底。
# 13.5 主线程零 IO 检查清单
- [ ] StrictMode 已开启(Debug)
- [ ] 所有 SP 已迁移 MMKV
- [ ] 所有 SQLite 操作在子线程
- [ ] 日志/统计走异步缓冲
- [ ] 网络缓存读取异步
- [ ] 资源加载异步
- [ ] 启动期 IO ≤ 5MB
探索性思考:为什么"主线程零 IO"是看似简单实则最难的优化?因为开发者本能写"看起来简单"的同步代码——
saveSettings()比lifecycleScope.launch { saveSettings() }短。简洁的代码 ≠ 正确的代码 —— 工程上要培养"异步优先"的思维。
▶▶ 回扣 §02 案例:方法派 Day 4 第 1 层"主线程零 IO"是治理的基础——没有这一层,后续所有优化都没意义。
# 14.治理二层量级
核心命题:能不写就不写;必须写就压缩。
# 14.1 缓存命中即返回
机理:内存缓存命中 = 0 IO;磁盘缓存命中 = 1 次读;网络 = 重新拉。
代码:
class TieredCache<K, V> {
private val memCache = LruCache<K, V>(100)
fun get(key: K, loader: () -> V): V {
memCache.get(key)?.let { return it } // 0 IO
val v = loadFromDisk(key) ?: loader().also { saveToDisk(key, it) }
memCache.put(key, v)
return v
}
}
2
3
4
5
6
7
8
9
10
收益:热点数据 IO 频次 -90%。
边界:缓存一致性需通过版本号 / push 失效。
# 14.2 数据压缩(CPU 换 IO 永远划算)
机理:CPU 解压 10MB 数据 ~10ms;IO 读 10MB ~100ms。压缩到 3MB 只需 ~30ms IO + ~10ms 解压 = -60%。
代码:
// 写入压缩
val compressed = gzip(jsonBytes)
file.writeBytes(compressed)
// 读取解压
val bytes = gunzip(file.readBytes())
2
3
4
5
6
收益:磁盘空间 -60%,IO 时间 -50%。
边界:小文件(< 4KB)压缩收益小;CPU 紧张设备需评估。
# 14.3 协议升级(PB / FlatBuffer 替代 JSON)
机理:PB 比 JSON 体积 -50%、解析 -70%;FlatBuffer 零拷贝直接访问。
对照:
| 格式 | 体积 | 解析速度 | 易用 |
|---|---|---|---|
| JSON | 100% | 1× | 高 |
| Protobuf | 50% | 3× | 中 |
| FlatBuffer | 50% | 10×(零拷贝) | 低 |
收益:见 卷四·02 §6.1 策略 1.4。
边界:PB 需双端协议同步;schema 变更需兼容设计。
# 14.4 小文件聚合存储
机理:§17.3 实验1 万个 10KB 比 1 个 100MB 慢 27×。
代码:用 LevelDB / SQLite 聚合存 KV,避免一 key 一文件:
// 反例:每个图片缩略图一个文件
File("$cacheDir/thumb_$id.png").writeBytes(bytes)
// 正例:聚合到 SQLite 或 LevelDB
db.execSQL("INSERT INTO thumbs (id, data) VALUES (?, ?)", arrayOf(id, bytes))
2
3
4
5
收益:千级以上小文件场景 IO 时间 -90%。
边界:聚合后单文件损坏影响范围大,需备份策略。
# 14.5 IO 量级最小化检查清单
- [ ] 热点数据有内存缓存
- [ ] 大文件压缩(gzip / brotli)
- [ ] 协议用 PB / FlatBuffer 替代 JSON
- [ ] 小文件聚合到 DB / LevelDB
- [ ] 启动期 IO 加载延迟到首屏后
探索性思考:为什么"CPU 换 IO 永远划算"?因为 CPU 是 ns 级、IO 是 ms 级,6 个数量级差距。即使解压用 10ms CPU,也只是 IO 节省 100ms 的 10%。性能优化是数量级的游戏 —— 用快的资源换慢的资源永远赚。
▶▶ 回扣 §02 案例:Day 5 日志体积用 protobuf 替代 JSON,每条 -50%——直接让磁盘空间和 IO 时间同步缩减。
# 15.治理三层批量
# 15.1 SQLite 事务批量化
机理:§17.2 实验事务比逐条快 70×。
代码:
db.beginTransaction()
try {
items.forEach {
db.execSQL("INSERT INTO t VALUES (?, ?)", arrayOf(it.id, it.data))
}
db.setTransactionSuccessful()
} finally {
db.endTransaction()
}
2
3
4
5
6
7
8
9
收益:§02 案例日志批量化是关键动作。
边界:事务内不能做长任务;事务大小过大(> 10MB)会触发 WAL 文件膨胀。
# 15.2 SQLite 启用 WAL 模式
机理:§17.4 实验吞吐 3-7×。
代码:
db.execSQL("PRAGMA journal_mode=WAL")
db.execSQL("PRAGMA synchronous=NORMAL") // 配合 WAL 用 NORMAL 而非 FULL
2
收益:读写并发;写吞吐 +400%。
边界:WAL 文件需定期 checkpoint(PRAGMA wal_checkpoint(TRUNCATE));隔离级别变 SNAPSHOT。
# 15.3 合理设置 SQLite 索引(不是越多越好)
机理:索引加速查询但拖慢写入(每写要更新所有索引)。
代码:
-- 仅为高频查询字段加索引
CREATE INDEX idx_user_email ON users(email);
-- 不要给低基数字段加索引(如 gender 仅 M/F)
-- 复合索引按"高频先后"排列
CREATE INDEX idx_log_time_level ON logs(create_time, level);
2
3
4
5
6
7
收益:查询 100× 加速;过度加索引则写入慢 3-5×。
边界:EXPLAIN QUERY PLAN 验证;线上监控写延迟。
# 15.4 异步刷盘(mmap 自动 / msync 手动)
机理:mmap 让内核自动刷脏页;关键节点 msync 兜底。
代码:
// mmap 写入后异步刷盘(内核自动)
char* addr = mmap(nullptr, len, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
memcpy(addr, data, len);
// 关键节点(如交易完成)显式 msync
msync(addr, len, MS_SYNC);
2
3
4
5
收益:吞吐 100×,关键节点保证一致性。
边界:mmap 数据可能丢(最近未 msync 的);金融类必须 msync。
# 15.5 文件 IO 缓冲
机理:BufferedOutputStream 减少 syscall 次数。
代码:
// 反例:每次 write 都 syscall
val out = FileOutputStream(file)
data.forEach { out.write(it.toByteArray()) }
// 正例:用缓冲
BufferedOutputStream(FileOutputStream(file), 64 * 1024).use { out ->
data.forEach { out.write(it.toByteArray()) }
}
2
3
4
5
6
7
8
收益:syscall 次数 -100×(10000 次 → 100 次)。
边界:缓冲大小要合理(推荐 64KB);崩溃时缓冲数据丢。
探索性思考:为什么"批量"是 IO 优化的"圣杯"?因为它把"频次开销"摊到"批量大小"——10 次 fsync 100ms vs 1 次 fsync 100ms。批量化的本质是"摊薄固定成本" —— 这是工程上的通用智慧(不仅 IO,网络/数据库/线程都适用)。
▶▶ 回扣 §02 案例:方法派 Day 4 第 3 层"批量 + 事务 + WAL"组合是 SQLite 性能的"三件套"——单步收益 60×,案例日志库吞吐 80→3500 条/s。
# 16.治理四层防退化
核心命题:IO 性能容易在迭代中退化(新功能加日志/缓存/查询),必须建立长效机制。
# 16.1 主线程 IO 时长 SLO 监控
机理:上报每次主线程 IO 调用时长,P95 超阈值告警。
代码:
class IOTimingAspect {
fun aroundIO(call: () -> Unit) {
val start = SystemClock.elapsedRealtimeNanos()
try { call() }
finally {
val durMs = (SystemClock.elapsedRealtimeNanos() - start) / 1_000_000
if (Looper.myLooper() == Looper.getMainLooper() && durMs > 16) {
report("main_io", durMs, Throwable().stackTraceToString())
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
收益:退化在 24h 内被发现。
边界:上报采样防数据爆炸。
# 16.2 CI 基线测试
机理:每次 PR 跑标准 IO 场景,超基线 20% 阻断合入。
代码:
// Macrobenchmark
@Test
fun ioBenchmark() = benchmarkRule.measureRepeated(
metrics = listOf(TraceSectionMetric("file_io")),
iterations = 5
) {
startActivityAndWait()
repeat(100) { writeLog("test entry") }
}
2
3
4
5
6
7
8
9
收益:退化在合入前被拦截。
边界:基线需周期性更新(设备/数据规模变化)。
# 16.3 定期 wal_checkpoint 和 VACUUM
机理:长期运行 SQLite 会产生空闲页 + WAL 膨胀。
代码:
// 定期(如启动空闲期)
db.execSQL("PRAGMA wal_checkpoint(TRUNCATE)")
// 每月或必要时
db.execSQL("VACUUM")
2
3
4
收益:磁盘空间稳定;查询性能不退化。
边界:VACUUM 全表重建,耗时较长,应在后台/空闲时段。
# 16.4 ROI 排序
| ROI | 优化项 | 收益 | 成本 | 风险 | 对应章节 |
|---|---|---|---|---|---|
| 极高 | StrictMode 全开 + 主线程拦截 | 杜绝主线程 IO 事故 | 1 天 | 零 | §13.1 |
| 极高 | SP 全面迁移 MMKV | 高频写 100× | 1-2 周 | 中 | §13.2 |
| 极高 | SQLite 事务批量化 | 写入 70× | 1 周 | 低 | §15.1 |
| 极高 | SQLite 启用 WAL | 吞吐 3-7× | 几天 | 中 | §15.2 |
| 极高 | 日志/统计异步缓冲 | 主线程零 IO | 1-2 周 | 低 | §13.4 |
| 高 | SQLite 操作全走子线程 | 主线程不被阻塞 | 1-2 周 | 中 | §13.3 |
| 高 | 缓存命中即返回 | IO 频次 -90% | 2-3 周 | 中 | §14.1 |
| 高 | 数据压缩 | IO 时间 -50% | 1 周 | 低 | §14.2 |
| 高 | 小文件聚合 | 千级以上 -90% | 2-3 周 | 中 | §14.4 |
| 中 | 主线程 IO SLO 监控 | 退化早发现 | 1 周 | 低 | §16.1 |
| 中 | PB/FlatBuffer 协议 | 体积/解析双优化 | 2-4 周 | 中 | §14.3 |
| 中 | 合理索引(去冗余) | 写入 -40%、查询不变 | 1 周 | 中 | §15.3 |
| 中 | mmap + msync | 高频小写 100× | 1-2 周 | 中 | §15.4 |
| 中 | CI 基线测试 | 退化拦截 | 1-2 周 | 低 | §16.2 |
| 中 | wal_checkpoint + VACUUM | 长期性能稳定 | 几天 | 低 | §16.3 |
| 低 | 自实现存储引擎 | 极少收益 | 极高 | 极高 | - |
# 16.5 避免反向收益
- 改 apply 以为治了主线程 IO:§02 案例第 1 周翻车。
- 加大量索引以为加速 SQLite:写入反而慢 3-5×。
- try-catch 包住 IO 异常:异常被吞但 IO 慢依旧。
- 批量但不包事务:仍然每条 fsync。
- mmap 后从不 msync:进程崩溃丢数据。
探索性思考:为什么"防退化"是工程文化问题?因为新功能开发者本能加日志、加缓存、加查询——每个单独看都"很轻量",但累积起来必然退化。长效机制 = 自动化拦截 + 文化共识 —— 缺一不可。
# 17.求证实验 ⭐
本节是"为什么这些优化生效"的实证基础。每个实验严格遵循"6 步求证法"。
# 17.1 实验一:主线程 SP 的真实代价
猜想:SharedPreferences.apply() 真的"异步",主线程不阻塞。
假设:apply() 调用本身 < 1ms;但 onResume/onPause 会等所有未落盘的 apply 完成(QueuedWork.waitToFinish);累积 100 次 apply 后 onPause 被阻塞 80-200ms。
执行:
| 设备 | 100 次 apply | onPause 阻塞时长 |
|---|---|---|
| 高端机(Pixel 6) | < 5ms | 30-50ms |
| 低端机(Android 8) | 8ms | 80-200ms |
验证:
- apply 异步只是"返回快",但生命周期切换时仍会同步等。
- 累积 100 次 apply 后 onPause 被阻塞 80-200ms。
思考:
- apply ≠ 异步,只是"返回快"——这是 API 表面认知 vs 物理本质认知的最经典反例。
- 用 MMKV 替代 SP;高频写场景永远不要用 SP。
- MMKV 用 mmap + 自定义 protobuf,写入性能提升 100x。
▶▶ 回扣 §02 案例:经验派第 1 周"改 apply 以为异步"完全踩中本实验"apply 累积后 onPause 卡 80-200ms"的陷阱。
# 17.2 实验二:批量 vs 逐条写入
猜想:批量比逐条快几倍。
假设:批量比逐条快 50 倍。
执行:
| 实现 | 1 万条耗时 | fsync 次数 |
|---|---|---|
| 逐条 INSERT | 28 秒 | 10000 |
| 事务包裹批量 INSERT | 0.4 秒(70×) | 1 |
| 多行 INSERT | 0.3 秒(93×) | 1 |
验证:
- fsync 是瓶颈,事务把 1 万次 fsync 合并成 1 次。
思考:
- 任何 SQLite 批量写必须包事务。
- 进一步合并成多行 INSERT 还能再省 25%。
- WAL 模式下事务的可见性边界变化,需评估业务一致性需求。
# 17.3 实验三:小文件惩罚
猜想:小文件慢一些但差距有限。
假设:小文件慢 10 倍以上。
执行:
| 实现 | 总耗时 |
|---|---|
| 1 个 100MB 文件 | 0.8s |
| 100 个 1MB 文件 | 1.5s |
| 1 万个 10KB 文件 | 22s(27×) |
验证:
- 每个文件都要 open/close/fsync 元数据,inode 锁竞争严重。
思考:
- 缓存设计避免"一 key 一文件",用单文件分段或 LevelDB/SQLite 聚合存。
- iOS 上小文件惩罚比 Android 更严重(APFS 的写时复制机制开销大)。
# 17.4 实验四:WAL vs DELETE
猜想:WAL 略快,但差异不大。
假设:WAL 模式读写并发,并发场景吞吐 3-5×。
执行:
| 模式 | 读吞吐 | 写吞吐 | 读 P99 | 写 P99 | 磁盘开销 |
|---|---|---|---|---|---|
| DELETE | 1,200 op/s | 350 op/s | 28 ms | 180 ms | 0 额外 |
| WAL | 8,500 op/s | 1,800 op/s | 5 ms | 15 ms | +5-50MB |
验证:
- WAL 写不阻塞读:吞吐 3-7× 提升。
- 写也大幅加速。
- 副作用:WAL 文件可能膨胀;读隔离从 SERIALIZABLE 变 SNAPSHOT。
思考:
- 90% 的 SQLite 场景都应启用 WAL。
- 除非业务严格需要 SERIALIZABLE 隔离(如金融转账)。
# 17.5 实验五:mmap vs fsync
猜想:mmap 比 fsync IO 快几倍。
假设:高频小写场景 mmap 快 50-100×;大数据场景增益缩小到 2-5×。
执行:
| 场景 | 普通 IO + fsync | mmap (MMKV) | 增益 |
|---|---|---|---|
| 100×100B/s 持续 60s | 12,500 ms | 125 ms | 100× |
| 10×10KB/s 持续 60s | 850 ms | 45 ms | 19× |
| 单次 1MB 顺序写 | 8 ms | 4 ms | 2× |
| 断电模拟 | 0 丢数据 | 最近 30s 可能丢 | - |
验证:
- 高频小写是 mmap 的杀手锏。
- 大数据场景增益小(IO 主导,不是 syscall 主导)。
- 副作用:mmap 数据需 msync 才保证落盘。
思考:
- 高频小数据(KV、日志、缓存)用 mmap;大数据不必。
- 需要严格落盘的场景调用 msync。
# 17.6 五大实验启示
SP apply 假异步 → onPause 累积阻塞,必须用 MMKV ─┐
批量 vs 逐条 → 事务让 fsync 合并,70× │
小文件惩罚 → 1 万个 10KB 比 1 个 100MB 慢 27× ├─▶ IO 优化 = 物理介质 + API 选型 + 使用方式
WAL vs DELETE → 读写并发,吞吐 3-7× │
mmap vs fsync → 高频小写 100×,大数据 2× ─┘
2
3
4
5
统一启示:
- 物理介质决定上限:低端机颗粒劣化时单次写可达 200ms,必须从源头减少 IO。
- API 选型决定基线:MMKV/WAL/mmap 是 IO 优化的"基础设施"。
- 使用方式决定 90% 性能:批量 + 事务 + 异步 = 三大铁律。
- fsync 是隐形杀手:所有"慢" 90% 是它造成的。
- 小文件比大文件慢得多:缓存设计要聚合存。
▶▶ 回扣 §02 案例:方法派 6 天闭环每一步都对应本节实验。实验是优化前的"必经之路"。
# 18.实战案例
# 18.1 跨端同构案例:日志写崩主线程
背景:某 App 接入新日志库,所有日志同步写文件 + fsync。线下没事,线上爆出大量 ANR。
根因:低端机 fsync 单次 200ms,日志一多累积阻塞。
治理:
- Android:日志走 mmap + 异步刷盘,单次写 < 1μs
- iOS:使用 os_log(系统级异步)或自定义 mmap
- Web:sendBeacon API 替代同步 console.log
效果:
- Android ANR 率 0.61% → 0.05%
- iOS Watchdog Kill -90%
- Web 性能基本无影响(浏览器自管)
洞察:任何"看起来很轻量"的写操作,到了低端机都可能爆炸。日志库设计必须考虑最差设备。
# 18.2 平台特异案例:iOS Core Data 主线程查询
背景:某 iOS App 启动慢,profile 看不到明显热点,但启动时间 P50 1.8s。
根因:AppDelegate 里有一个 Core Data 查询,主线程同步执行。低存储设备上 Core Data 文件碎片化导致查询 1.2s。
治理:
// 反例:主线程查询
let users = try context.fetch(request)
// 正例:background context
container.performBackgroundTask { ctx in
let users = try ctx.fetch(request)
DispatchQueue.main.async {
self.updateUI(with: users)
}
}
2
3
4
5
6
7
8
9
10
效果:启动 P50 1.8s → 0.6s。
洞察:iOS 的 Core Data 比直接 SQLite 多一层 ORM,性能问题更隐蔽。
# 18.3 反例案例:批量但不包事务
背景:某 App 工程师按"批量写"建议改造日志库,把"每条 commit"改成"每 50 条写一次"。
结果:
- 性能仅提升 15%
- ANR 率没明显下降
- 工程师疑惑"为什么没效果"
根因:50 条 INSERT 仍然是 50 次 fsync,因为每条 INSERT 默认自动 commit。
修复:
// 错误:仅"批量调用"
items.forEach { db.execSQL("INSERT...", it) } // 每条仍是独立事务
// 正确:包事务
db.beginTransaction()
try {
items.forEach { db.execSQL("INSERT...", it) }
db.setTransactionSuccessful()
} finally {
db.endTransaction() // 仅 1 次 fsync
}
2
3
4
5
6
7
8
9
10
11
效果:性能从 +15% 升到 +6900%(70×)。
洞察:"批量"必须配合"事务",否则等于零。这是 §02 案例第 4 周翻车的根因。
# 19.防劣化体系
# 19.1 三道防线总览
┌────────────┐ ┌────────────┐ ┌────────────┐
│ 编码期 Lint │ → │ CI 卡口 │ → │ 线上 SLO │
│ IDE 即时提示│ │ Macrobench │ │ 监控告警 │
└────────────┘ └────────────┘ └────────────┘
2
3
4
# 19.2 编码期 Lint
自定义规则:
- 主线程 IO(StrictMode + Lint 双重)
- SharedPreferences 直接使用 → 警告(建议 MMKV)
- SQLite 操作未在事务中(批量场景)→ 警告
- 每条循环中调用 contentResolver → 警告
- BufferedOutputStream 未使用 → 警告
- mmap 后未 msync → 警告
# 19.3 CI 卡口
性能基线测试:
@Test
fun ioBenchmark() = benchmarkRule.measureRepeated(
metrics = listOf(TraceSectionMetric("file_io")),
iterations = 5
) {
startActivityAndWait()
repeat(100) { writeLog("test entry") }
}
2
3
4
5
6
7
8
卡口规则:
- 启动期 IO 量超基线 20% → 阻断 PR
- 主线程 IO 时长 P99 退化 → 阻断
- 数据库写入吞吐退化 ≥ 10% → 警告
# 19.4 线上 SLO
| 指标 | 目标 | 告警阈值 |
|---|---|---|
| 主线程单次 IO 耗时 P95 | ≤ 16ms(一帧) | > 50ms |
| 主线程总 IO 时长占比 | ≤ 5% | > 15% |
| 数据库主线程查询 P99 | ≤ 50ms | > 200ms |
| 启动期 IO 总量 | ≤ 5MB | > 10MB |
| ANR 中 IO 类占比 | < 10% | > 30% |
# 19.5 文化建设
- IO 预算:新模块申报"启动期 IO 预算"
- IO Code Review:IO 相关 PR 必有 perf reviewer
- IO OKR:主线程 IO 时长进 OKR
探索性思考:为什么 IO 防退化特别需要"自动化"?因为 IO 退化是"渐进性的"——单次新增功能加一行
prefs.edit().apply()看不出问题,10 个功能累积就 ANR。自动化拦截 = 在源头拒绝累积 —— 这是工程上的"剪枝"哲学。
# 20.跨平台速查
# 20.1 工具速查
| 平台 | 系统级 | 进程级 | 应用层 |
|---|---|---|---|
| Android | iostat / /proc/diskstats | /proc/[pid]/io | StrictMode + AOP |
| iOS | task_info | task_info | Instruments File Activity |
| Web | (沙盒) | (沙盒) | DevTools Performance |
| Linux | iostat / blktrace | /proc/[pid]/io | strace |
# 20.2 关键 API 速查
| 目的 | Android | iOS | Web |
|---|---|---|---|
| 主线程 IO 拦截 | StrictMode | (自实现) | (无) |
| KV 高性能 | MMKV | MMKV / 自定义 mmap | IndexedDB |
| 数据库 | SQLite + WAL | Core Data + WAL | IndexedDB transaction |
| 大文件读 | mmap | mmap | OPFS |
| 异步 IO | Coroutines / Executor | DispatchQueue | async/await |
| 强制落盘 | fsync / FileChannel.force | fsync | (无) |
| 文件缓冲 | BufferedOutputStream | NSFileHandle buffered | (浏览器自管) |
# 20.3 各平台优化清单
Android:
- [ ] StrictMode 全开(Debug penaltyDeath,Release penaltyLog)
- [ ] SP 全面迁移 MMKV
- [ ] SQLite 启用 WAL + 事务批量
- [ ] 日志走异步缓冲
- [ ] 大文件 mmap
- [ ] 小文件聚合存(LevelDB / SQLite)
- [ ] 协议升级 PB / FlatBuffer
- [ ] 数据压缩(gzip / brotli)
- [ ] 主线程 IO SLO 监控
- [ ] CI 基线测试
iOS:
- [ ] Core Data 必用 background context
- [ ] UserDefaults 仅小数据
- [ ] 大文件 mmap
- [ ] os_log 替代 print
- [ ] APFS 注意写时复制开销
Web:
- [ ] localStorage 仅极小数据
- [ ] 大数据用 IndexedDB(transaction 批量)
- [ ] OPFS 用于高性能场景
- [ ] sendBeacon 异步上报
- [ ] Service Worker 离线缓存
# 21.总结与延伸
# 21.1 五条核心原则
- 主线程 0 IO:StrictMode 强制约束,§02 案例ANR 0.78%→0.05% 的根。
- MMKV 替代 SP:§17.5100× 性能,无需再讨论。
- SQLite 必启用 WAL + 事务:§17.2 + §17.4双重证据,3-70× 收益。
- 小文件必须聚合:§17.327× 差距是物理事实。
- fsync 是隐形杀手:90% 的"IO 慢"都是它造成的。
# 21.2 五个常见误区
| 误区 | 真相 |
|---|---|
| "apply 是异步的所以安全" | 错(onPause 累积阻塞) |
| "加索引必加速" | 错(写入慢 3-5×) |
| "批量就是好" | 错(不包事务等于没批量) |
| "mmap 数据安全" | 错(崩溃丢未 msync 数据) |
| "SSD 随机写不慢" | 错(仍有 2-5× 写放大) |
# 21.3 一句话总结
IO 是"看不见的杀手"——90% 的卡顿、ANR、启动慢都源于它,但 CPU profiler 看不到。主线程 0 IO、MMKV 替代 SP、SQLite 必 WAL+事务、小文件必聚合、fsync 必批量。这五条铁律落地,应用 IO 性能就达到行业前 10%。IO 性能问题的根因 90% 在"如何使用 API",不在"用什么 API"——MMKV、WAL、事务批量这些"老技术"在每个项目都值得被重新检查。
# 21.4 延伸阅读
卷二·02 内存监控与治理:mmap 是内存与 IO 的桥梁卷二·03 OOM 异常与低内存治理:mmap 共享地址空间卷二·04 线程模型调度优化:IO 必须异步线程卷三·04 ANR 监控与治理:IO 是 ANR 的高频根因卷四·01 App 冷启动优化:启动期 IO 是大头卷四·05 功耗与电量优化:IO 频次直接影响功耗
# 21.5 给团队的建议
- 第 1 周:StrictMode 全量打开,治理所有主线程 IO 红屏
- 第 2 周:用 MMKV 替换 SharedPreferences
- 第 3 周:SQLite 全部启用 WAL + 检查索引覆盖率
- 第 4 周:日志、缓存的写入路径统一异步化
下一篇预告:
卷三·06 动画交互响应优化—— 把"用户操作到画面响应"的最后一公里做好。