Android稳定性-43-Android I/O 阻塞稳定性问题:存储满、fsync、blocked thread 与系统卡死

做 Android 稳定性测试时,I/O 阻塞是很容易被低估的一类问题。它不像 Java Crash 那样有清晰堆栈,也不像重启那样有明显边界。很多时候,测试同学看到的是“系统变慢”“相机打不开”“桌面无响应”“设置页卡住”“日志停止刷新”,而设备内部真正发生的是写盘队列堆积、文件系统等待、数据库事务卡住、日志服务反压、system_server 里的关键线程被同步 I/O 拖住。

I/O 问题麻烦的地方在于它经常穿透多层:业务写数据库,Framework 写 settings,system_server 落盘状态,vold 处理卷状态,logd 持续刷日志,底层文件系统还可能因为剩余空间、擦写放大、坏块管理、加密、fsync 策略而变慢。单看一段 logcat,很容易把它误判成 App 逻辑慢;单看 CPU,又会发现负载并不高;单看 ANR,则只能看到某个线程停在 fsyncopenrename 或 Binder 返回之前。

这篇文章只讨论稳定性测试视角下应该怎样识别、复现、记录和推动 I/O 阻塞问题。重点不放在文件系统理论,而放在整机测试里最常见的几条链路:存储空间不足、频繁同步写、数据库和日志争抢、blocked thread、system_server 卡死、长稳后期资源退化。文章会给出一个完整案例,也会列出命令、表格、误判、检查清单和输出物模板,方便直接放进专项方案或问题复盘里使用。

一、先把 I/O 阻塞当成整机风险看

很多团队第一次遇到 I/O 阻塞时,会把它归到某个应用的问题里:某个 App 启动慢,某个页面保存失败,某次 Monkey 跑出 ANR。这样做不是完全错误,但容易漏掉更大的风险。如果同一时间窗口里多个进程都出现写入慢、Binder 超时、Input dispatching timed outsystem_server 线程等待,那么问题就不再是单个模块的体验缺陷,而是整机稳定性风险。

判断 I/O 是否影响整机,至少要看三件事。第一,现象是不是跨应用出现,比如桌面、设置、相机、文件管理和被测业务都变慢。第二,异常前后是否有共同资源变化,比如 /data 剩余空间接近阈值、日志目录膨胀、数据库频繁 checkpoint、后台抓取脚本大量拉取文件。第三,系统关键线程是否出现等待,尤其是 system_serversurfaceflingersystemuilogdvold 这些进程。

I/O 阻塞的用户感知通常很分散。点击无响应可能来自主线程等待数据库;锁屏慢可能来自写入系统设置;拍照失败可能来自媒体库和相机文件落盘;安装应用失败可能来自包管理扫描和 dexopt;长稳中突然大面积 ANR 可能是日志写入、截图保存、bugreport 抓取和业务写盘叠加。测试报告里如果只写“操作卡顿”,研发很难知道该从哪里进入。

所以在问题单里要把现象写成链路:什么时候开始变慢,哪个动作触发,哪些进程同时受影响,磁盘空间和 I/O 等待如何变化,关键线程停在哪里。这样写的价值不只是让开发定位更快,也能让版本评审判断它是否需要阻断。

二、I/O 阻塞常见触发源

Android 设备上的 I/O 触发源比很多人想象得多。最直观的是业务写文件,比如录制视频、保存图片、下载升级包、导入大量联系人、即时通讯同步消息。更隐蔽的是系统自己的持久化行为:settings 写配置,PackageManager 写包状态,ActivityManager 写进程记录,DropBoxManager 写异常,statsd 写统计,logd 写日志,vold 处理存储卷状态。

长稳测试里还有一类人为放大的触发源。自动化脚本为了保留证据,可能高频截图、录屏、拉日志、压缩目录、执行 bugreport。单次看都合理,但多台设备并发跑几天后,证据采集本身可能成为 I/O 压力源。尤其是把大文件直接写到 /sdcard,再由 MTP、ADB 或同步程序读取,会和业务测试争抢同一存储。

低存储场景也不能只理解成“空间不够”。当剩余空间逼近系统阈值时,应用写入会失败,数据库事务会变慢,文件系统元数据操作会频繁失败重试,系统清理策略可能启动,媒体扫描可能反复处理临界状态。表面现象往往不是一个明确的 No space left on device,而是连续卡顿、随机 ANR、保存失败和系统服务超时混在一起。

另一类触发源是同步写策略。某些模块为了保证数据不丢,会在主路径里频繁调用 fsync 或等价同步接口。少量写入时看不出问题,遇到低端闪存、温升降频、后台日志暴增、数据库 checkpoint、OTA 后迁移时,延迟会被放大。稳定性测试要关心的不是“能不能写成功”,而是写入是否把用户路径和系统关键线程拖进不可接受的等待。

三、现象分层:从用户动作到内核等待

定位 I/O 阻塞时,可以把现象分成四层。第一层是用户感知:点击后几秒没有反馈、滑动停顿、拍照转圈、安装卡住、桌面无响应。第二层是进程表现:目标进程 ANR、systemui 卡住、system_server Watchdog、日志大量出现超时。第三层是资源状态:iowait 升高、读写吞吐异常、剩余空间不足、进程处于 D 状态。第四层是等待点:线程栈停在 fsyncsync_file_rangeopenatrenameSQLiteConnectionFileOutputStream 或 Binder 返回之前。

这四层不是顺序执行的定位步骤,而是记录问题时必须尽量补齐的证据面。只写用户感知,问题会变成体验争论;只贴线程栈,缺少影响范围;只给资源曲线,无法归因到业务动作;只给命令输出,评审不知道版本风险。

一个比较实用的写法是把时间线固定下来。例如 21:13:20 自动化脚本进入相机录像,21:14:02 /data 剩余空间低于 500 MB,21:14:37 相机保存视频失败,21:14:50 systemui 首次出现输入超时,21:15:10 system_server 某线程停在写 settings,21:16:30 多个 App ANR。这样的记录能把随机现象变成可讨论的工程事实。

如果设备支持 Perfetto 或 ftrace,I/O 线程、调度延迟和 Binder 等待可以放进同一条时间线;如果不具备条件,最少也要把 logcat、top、df、dumpsys、traces 和测试脚本日志按同一时钟对齐。稳定性问题常常不是缺少日志,而是缺少能互相解释的时间窗口。

四、基础命令:先把现场留住

I/O 阻塞类问题最怕事后只有一句“当时很卡”。现场采集要尽量轻量,避免采集动作继续压垮设备。下面这组命令适合在复现中分阶段执行,先拿状态,再决定是否抓更重的证据。

1
2
3
4
5
6
7
8
9
10
adb shell date
adb shell df -h
adb shell df -i
adb shell top -H -b -n 1
adb shell cat /proc/meminfo
adb shell cat /proc/pressure/io 2>/dev/null
adb shell cat /proc/diskstats
adb shell ps -A -o PID,PPID,USER,STAT,NAME
adb shell logcat -b all -v threadtime -d > logcat_io_window.txt
adb bugreport bugreport-io.zip

如果怀疑某个进程被写盘拖住,可以进一步看线程和栈。不同版本可用能力不同,命令要按设备权限调整。

1
2
3
4
5
6
7
adb shell pidof system_server
adb shell ps -T -p $(adb shell pidof system_server | tr -d '\r')
adb shell kill -3 $(adb shell pidof com.example.app | tr -d '\r')
adb shell cat /data/anr/traces.txt 2>/dev/null
adb shell ls -lt /data/anr /data/tombstones /data/system/dropbox 2>/dev/null
adb shell dumpsys activity anr
adb shell dumpsys dropbox --print

如果怀疑空间打满或日志膨胀,需要看目录级别的增长。量产 user 版本权限受限时,至少保留 df、应用私有目录大小、外部存储目录和测试脚本生成物。

1
2
3
4
5
6
adb shell du -h -d 1 /sdcard 2>/dev/null | sort -h
adb shell du -h -d 1 /data/local/tmp 2>/dev/null | sort -h
adb shell ls -lh /sdcard/Movies /sdcard/Pictures /sdcard/Download 2>/dev/null
adb shell dumpsys diskstats
adb shell dumpsys storaged
adb shell dumpsys package com.example.app | sed -n '/dataDir/,+3p'

命令不是越多越好。现场已经明显卡死时,优先执行不会生成大文件的状态命令;bugreport、Perfetto、目录压缩要放在设备还能承受的时候。如果每次复现都靠抓完整 bugreport 才能看到线索,测试方案本身就需要优化。

五、关键指标表:哪些数字值得进报告

I/O 问题的报告不应该堆满所有命令输出,而要把能支持判断的数字摘出来。下面这张表可以作为问题单或专项报告的核心数据区。

指标 推荐采集方式 观察重点 风险信号
/data 剩余空间 df -h /data 异常前后的剩余量和下降速度 接近系统保留阈值、短时间快速下降
inode 使用率 df -i 小文件是否耗尽 inode 空间还有但创建文件失败
I/O pressure /proc/pressure/io somefull 是否持续升高 长时间等待 I/O 导致调度受影响
线程状态 top -Hps -T 是否有大量 D 状态线程 关键进程不可中断等待
ANR 等待点 /data/anr/traces 主线程、Binder 线程、锁等待 栈停在文件、数据库、同步写
写入目录 du、脚本日志 哪些目录增长最快 日志、截图、录屏、缓存失控
文件系统错误 kernel log、bugreport 是否有 ext4/f2fs 错误 需要转内核或存储驱动排查

这张表的价值在于把“卡”变成可比较的版本数据。比如同一套长稳脚本,版本 A 运行 48 小时后 /data 还剩 18 GB,版本 B 只剩 2 GB;版本 A 没有 D 状态线程,版本 B 在异常窗口里 logd、相机进程、业务进程都有等待;版本 A 的 ANR 栈停在业务锁,版本 B 停在数据库同步写。这样的对比比单独贴一份 traces 更有说服力。

指标也要避免绝对化。iowait 不高不代表没有 I/O 阻塞,因为问题可能集中在单个同步写路径;剩余空间足够也不代表文件系统没有问题,因为目录扫描、元数据、加密和单文件写入都可能拖慢;没有 ANR 也不代表风险低,因为系统可能只是每次卡 2 秒,没有触发阈值,却已经严重影响用户体验。

六、完整案例:低存储叠加日志膨胀导致系统卡死

某项目在准入长稳阶段发现 20 台设备里有 4 台在第 3 天凌晨出现桌面无响应。表面现象是 Monkey 停在桌面,脚本日志显示 ADB 还能连上,但 input keyevent HOME 延迟 8 到 15 秒。最初问题被归到 “Launcher 偶发 ANR”,因为 /data/anr 里确实有桌面 ANR。后来复查时间线才发现,桌面只是第一个被用户路径打中的进程,真正的压力来自日志和录屏文件。

测试环境里,脚本每 5 分钟截一次屏,每小时保存一段 60 秒录屏,异常时抓一次 logcat -d。新版本为了排查另一个问题,把某个 Native 模块日志级别打开到了 debug,日志量比基线版本增加了约 6 倍。前两天设备表现正常,到第三天凌晨 /sdcard/TestEvidence 已经超过 28 GB,/data 剩余空间跌到 700 MB 左右。此时业务 App 仍在做本地数据库写入,相机模块也在保存压力测试图片。

异常窗口里,df -h 显示 /data 使用率 97%;top -H 里多个进程出现 D 状态;traces 里桌面主线程等待 Binder 返回,Binder 对端是 system_serversystem_server 的一个线程停在写入系统状态文件,另一个线程正在处理包状态更新;logd 有明显积压。内核日志没有文件系统损坏,但能看到多次写入失败和空间不足相关提示。

最后根因被拆成两部分:产品版本引入了过量 debug 日志,测试脚本没有对证据目录设置容量上限。修复也分两条走。研发关闭默认 debug 日志并限制单文件大小;测试平台改成按设备设置证据目录上限,超过阈值只保留最近 N 个窗口,并把 /data 剩余空间低于 2 GB 作为强制停止条件。复测 72 小时后,没有再出现桌面无响应,日志目录增长曲线回到基线范围。

这个案例的关键不是找到“谁写满了磁盘”这么简单,而是把版本改动、测试脚本、系统状态和用户现象放到一条线上。否则问题很容易在 Launcher、Monkey、日志模块、测试平台之间来回流转。

七、存储满测试不能只靠手工塞文件

低存储专项经常被做成一个很粗糙的动作:用 dd 塞满 /sdcard,然后跑几条用例。这样能发现一部分保存失败,但覆盖不了真实系统进入低存储状态的路径。真实用户的存储压力通常来自照片、视频、聊天缓存、下载文件、应用缓存和系统日志混合增长,目录分布、文件大小、写入频率都不一样。

更合理的做法是分三个阶段。第一阶段验证阈值:让剩余空间分别停在 10 GB、5 GB、2 GB、1 GB、500 MB,观察系统提示、应用保存、安装更新、相机录像、日志写入是否符合预期。第二阶段验证持续增长:让后台以固定速度写入文件,同时跑业务遍历,看系统有没有提前预警和清理。第三阶段验证恢复:删除大文件后,应用和系统服务能不能恢复,不要只验证异常出现。

示例命令可以这样组织,注意要给设备和脚本留退出通道,不要把 userdebug 设备写到完全不可操作。

1
2
3
4
5
6
7
adb shell mkdir -p /sdcard/io_fill
adb shell "df -h /data"
adb shell "dd if=/dev/zero of=/sdcard/io_fill/fill_1.bin bs=10M count=100"
adb shell "sync"
adb shell "df -h /data"
adb shell "rm -f /sdcard/io_fill/fill_1.bin"
adb shell "sync"

如果要做连续压力,可以让脚本每轮写一个固定大小文件,写完记录时间、剩余空间、业务动作结果和系统日志关键字。不要只记录最终是否失败,因为很多低存储问题的价值在于阈值附近的退化速度。

八、fsync 和数据库等待怎么判断

fsync 本身不是错误。系统和应用需要它保证关键数据落盘,比如数据库事务、配置更新、崩溃日志、媒体文件索引。问题出在调用频率、调用位置和等待时间。如果一个用户点击路径每次都同步写多次,或者在主线程上等待数据库落盘,那么闪存稍慢、空间稍低、温度稍高时就可能变成稳定性问题。

从 traces 看,Java 层可能出现 java.io.FileDescriptor.syncSQLiteConnection.nativeExecuteForChangedRowCountSharedPreferencesImpl.writeToFileFileOutputStream.write 等等待点。Native 层可能看到 fsyncfdatasyncpwriterenameopenat。如果栈里还有锁等待,要继续判断锁的持有者是不是正在做 I/O,否则容易把问题误写成单纯锁竞争。

数据库问题要特别看事务边界。一次业务动作里反复打开数据库、逐条写入、每条提交,和批量事务提交的表现完全不同。稳定性测试不需要替研发设计代码,但可以在报告里明确写出:卡顿窗口内主线程等待 SQLite 写入,写入发生在用户点击后 200 ms 内,单次等待超过 ANR 前 4 秒,复现概率 7/10,关闭某项日志后等待下降。这种证据足以推动模块 owner 深入代码。

如果设备允许,可以结合 Perfetto 看 scheddiskbinderamwm 等轨道。看不到底层块设备细节时,也可以用多次 traces、logcat 时间戳和业务埋点近似确认。不要因为缺少完美工具就停止定位,先把等待关系说清楚。

九、blocked thread 与 system_server 风险

system_server 里的 I/O 等待比普通 App 更危险,因为它承载 Activity、Window、Package、Power、Input、Clipboard、Alarm 等大量服务。单个服务写盘变慢,可能通过锁、Binder 或消息队列影响多个用户路径。很多整机卡死最终都能看到 system_server 线程等待,但要小心:它可能是根因,也可能是被别的模块拖住。

分析 system_server 时要关注三类线程。第一类是主线程或关键 Handler 线程,如果它们长时间处理 I/O,风险最高。第二类是 Binder 线程池,如果大量 Binder 线程被同步请求占满,外部 App 调系统能力会排队。第三类是服务内部工作线程,比如 Package、UsageStats、DropBox、BatteryStats、Settings 等,它们写入慢时可能反向影响调用方。

常用命令如下:

1
2
3
4
5
6
7
adb shell pidof system_server
adb shell ps -T -p $(adb shell pidof system_server | tr -d '\r')
adb shell kill -3 $(adb shell pidof system_server | tr -d '\r')
adb shell sleep 3
adb shell ls -lt /data/anr 2>/dev/null
adb shell dumpsys activity service com.android.server.DropBoxManagerService 2>/dev/null
adb shell dumpsys package --checkin 2>/dev/null | head

报告里不要只贴 system_server 栈,要写清楚它影响了什么。例如:Launcher 通过 Binder 请求启动 Activity,等待 ActivityTaskManager 返回;同一窗口内 Settings 写入卡住,PackageManager 正在扫描目录;多个 App 的 ANR 都指向系统服务响应慢。这样才能把问题从“某个 App 卡住”升级为“系统服务存在阻塞风险”。

十、常见误判

误判 为什么容易发生 更稳妥的判断方式
看到 Launcher ANR 就归桌面 用户停在桌面时最容易触发桌面 ANR 看同窗口其他进程和 system_server 栈
CPU 不高就排除系统问题 I/O 等待时 CPU 可能并不忙 看线程状态、pressure、等待栈
空间没满就排除低存储 阈值、inode、目录膨胀都可能造成失败 同时看 df -hdf -i、目录增长
fsync 就要求删除同步写 关键数据需要持久化 判断调用位置、频率和等待时间
bugreport 抓不到就认为不可定位 抓取动作可能改变现场 用轻量命令和脚本日志补时间线
只看被测 App I/O 压力经常来自日志、系统服务和脚本 把测试环境生成物也纳入审计

这些误判背后都有同一个问题:把单点证据当成完整结论。I/O 阻塞很少靠一条日志定案,它更依赖多份证据互相支撑。测试同学的价值就在于把这些证据按时间、进程和资源关系组织起来。

十一、检查清单

  • 是否记录异常前后 10 分钟的精确时间窗口。
  • 是否保存 /data/sdcard、关键证据目录的空间变化。
  • 是否检查 inode、目录数量和单目录大文件。
  • 是否采集目标进程、system_serversystemui 的线程状态。
  • 是否查看 ANR traces 里的等待点和 Binder 对端。
  • 是否排查测试脚本自己的截图、录屏、日志是否过量。
  • 是否确认异常是单机、批量、单版本还是跨版本。
  • 是否区分写入失败、写入慢、同步等待和文件系统错误。
  • 是否复测删除大文件或关闭高频日志后的恢复情况。
  • 是否给出版本风险结论,而不是只写“建议开发分析”。

这份清单适合放在问题单尾部,也适合做成测试平台的自动审计项。特别是证据目录大小、剩余空间阈值和日志增长速度,完全可以由平台每天巡检,不必等到设备卡死后再人工补救。

十二、输出物模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
问题标题:
[I/O][长稳] 版本 V2026.03.27 低存储叠加日志膨胀导致桌面和业务应用无响应

影响范围:
型号:X1 8+256G
版本:V2026.03.27 nightly
场景:72 小时长稳,Monkey + 相机录像 + 后台日志采集
复现:4/20 台,第 53 到 61 小时出现

用户现象:
点击 HOME、设置、相机均出现 8 秒以上无反馈,部分设备产生 Launcher 和业务 App ANR。

关键证据:
1. 21:14:02 /data 剩余 700 MB,/sdcard/TestEvidence 28 GB。
2. 21:14:50 多个进程出现 D 状态,logd 写入积压。
3. 21:15:10 system_server 线程等待系统状态写入,Launcher Binder 请求超时。
4. 关闭 debug 日志并限制证据目录后,72 小时复测未复现。

根因判断:
版本新增 Native debug 日志导致写盘量异常;测试平台未设置证据保留上限,低存储后放大系统写入等待。

处理建议:
研发关闭默认 debug 日志并限制日志轮转;测试平台增加目录上限、低存储停止线和日报告警。

版本结论:
当前构建不建议准入长稳通过,需完成修复并提供 72 小时复测数据。

模板里的每个字段都服务于评审:影响范围决定优先级,用户现象决定严重程度,关键证据决定可信度,根因判断决定归属,处理建议决定下一步,版本结论决定是否放行。没有这些字段,I/O 问题很容易在口头同步里失真。

十三、小结

十四、复测时要证明压力已经消失

I/O 问题修复后,不能只看原用例不再 ANR。更可靠的复测要证明压力源、等待点和用户现象都已经消失。比如日志膨胀类问题,复测时要同时看目录增长速度、剩余空间曲线、logd 状态和前台操作响应;数据库同步写问题,复测时要看主线程等待是否缩短、帧耗时是否恢复、事务数量是否下降;低存储问题,复测时要看阈值提示、写入失败处理和删除文件后的恢复。

复测报告里可以保留一张“修复前后对照表”。同样运行 72 小时,修复前 /data 从 18 GB 降到 700 MB,修复后仍保留 14 GB;修复前 system_server 线程多次停在写入等待,修复后未再出现同类栈;修复前桌面和相机都有无响应,修复后关键路径最长响应 1.2 秒。这样的对照比一句“复测通过”更适合进入准入评审。

还要关注副作用。限制日志大小以后,异常现场是否仍能定位?批量写数据库以后,异常断电是否会丢关键状态?低存储停止测试以后,是否影响长稳覆盖?稳定性修复经常在“减少压力”和“保留证据”之间取平衡,测试需要把新策略的边界也写进复测结论。

I/O 阻塞不是某个命令能单独解决的问题。它通常由业务写盘、系统持久化、日志策略、存储状态、测试脚本和底层文件系统共同构成。稳定性测试要做的,是把这些因素放进同一个时间窗口里,让现象、资源、线程和版本变更能够互相解释。

真正有价值的 I/O 问题报告,应该能回答四个问题:用户到底受到了什么影响,哪些进程和系统资源在同一时间异常,最可能的写入来源是什么,版本应该阻断、灰度观察还是普通遗留。只要这四个问题说清楚,后续无论交给应用、Framework、系统服务、内核还是测试平台,都不会变成无方向的日志转发。