1. 问题现场还原脚本说 PASS系统却读出一片零1.1 这个现象到底长什么样先把这个场景描述清楚因为很多人第一次遇到时会怀疑人生。你写了一个设备老化测试脚本或者一个批量校验脚本跑完以后脚本自己打印了一行PASS退出码是 0日志里干干净净。结果你换了一台机器或者重启之后用系统工具去读那块盘发现读出来的数据全是00也就是全零。脚本说没问题操作系统说这盘是空的两边各执一词。我第一次碰到这个问题是在做批量设备老化测试的时候。脚本逻辑很简单往指定 LBA 写一段特征数据读回来比对一致就 PASS。单机跑了几十轮都没事结果换到另一批机器上脚本依然 PASS但用系统自带的工具去看那块盘前几个扇区全是零。当时第一反应是脚本有 bug查了半天发现脚本没问题问题出在数据根本没落到盘上。这个现象的本质是写入路径上的某一层把数据吞了但读取路径上脚本读到的又是缓存里的旧数据所以脚本比对通过而真正落到介质上的数据是零。要搞清楚是谁在撒谎得把整条链路拆开看。1.2 为什么这个问题值得单独拿出来讲很多人会觉得脚本 PASS 就行了系统读出来是零可能是工具的问题。但在设备老化测试、产线校验、固件升级验证这些场景里这个判断是致命的。老化测试的目的就是验证存储介质在长时间、多轮次读写之后是否还可靠。如果脚本因为缓存的原因一直报 PASS那这批设备里真正有问题的就会被放行到了客户手里才暴露返修成本是产线拦截的几十倍。而且这个问题的迷惑性极强。脚本逻辑看起来无懈可击退出码正常日志正常单机复现还复现不出来。它往往只在特定硬件组合、特定驱动版本、特定写入模式下才出现。所以它值得单独拎出来从 AHCI、LBA、MBR 这几个层面把链路走一遍搞清楚每一层各自在干什么谁有可能把数据吃掉。1.3 涉及的核心概念先铺垫一下在往下拆之前先把几个关键词用大白话解释清楚不然后面看链路会卡。LBA是逻辑块地址你可以把它理解成硬盘上的门牌号。操作系统不直接跟盘说我要第 3 个扇区而是说我要 LBA 3由盘自己翻译成物理位置。脚本读写数据本质上就是往某个 LBA 写、从某个 LBA 读。AHCI是主板和 SATA 盘之间的一种通信协议它规定了数据怎么从内存搬到盘上。你可以把它想象成一条快递通道CPU 把包裹数据放到内存的某个位置然后告诉 AHCI 控制器把这个包裹送到盘的某个门牌号控制器负责实际搬运。MBR是主引导记录位于盘的第一个扇区LBA 0里面存着分区表和一小段引导代码。很多校验脚本会去读 LBA 0 来确认盘的状态所以 MBR 区域的数据对不对直接关系到脚本的判断。缓存是这里的关键角色。数据从脚本到最终落到盘上中间可能经过操作系统页缓存、文件系统缓存、块设备层缓存、盘自己的写缓存。任何一层缓存没刷下去数据就还在内存里盘上还是旧的。2. 数据链路全拆解从脚本到盘中间隔了几道门2.1 一条写入请求的完整旅程要找出谁在撒谎最直接的办法就是把一条写入请求从脚本发出到真正落到盘上的全过程走一遍。假设脚本执行的是往 LBA 100 写入 512 字节特征数据这个操作它的旅程大致是这样的脚本调用系统接口发起写请求数据先进入操作系统的页缓存Page Cache。如果走的是文件系统文件系统会把逻辑偏移映射到具体的 LBA然后交给块设备层。块设备层把请求放进 IO 调度队列等待合适的时机下发给驱动。驱动把请求翻译成 AHCI 命令写入命令列表然后通知控制器。AHCI 控制器通过 DMA 把数据从内存搬到盘的内部缓存。盘收到数据后先放进自己的写缓存然后择机写入实际介质。盘返回完成信号控制器通知驱动驱动通知块设备层块设备层通知文件系统最后脚本收到写成功。这条链路上第 1 步、第 3 步、第 6 步都是缓存点。任何一步的缓存没有正确刷下去脚本都会收到成功但盘上可能还是旧数据。2.2 脚本为什么读到了正确的数据脚本写完立刻读回来比对读的是哪里大概率读的还是页缓存。因为刚写进去的数据就在缓存里读请求会直接命中缓存根本不会下发到盘。所以脚本读到的就是它刚写的那份数据比对当然通过。这就是脚本说 PASS的真相它验证的是缓存不是介质。它以为自己验证了盘实际上验证的是内存。这个逻辑在单机、短时间、小数据量的场景下不会出问题因为缓存迟早会刷下去。但在老化测试这种高频、多轮、可能中途断电或重启的场景下缓存没刷下去的概率就上来了。2.3 系统为什么读到了全零系统工具读出来全零通常有两种情况。第一种是数据确实没写下去。比如写请求还在缓存里盘上那个 LBA 从来没被写过读出来自然是零。或者写请求下发了但盘内部缓存没落盘掉电后数据丢失重启再读就是零。第二种是读的路径和写的路径不一致。比如脚本写的是某个分区内的偏移系统工具读的是裸设备偏移两者算出来的 LBA 根本不是同一个位置。这种情况在 MBR 和分区表存在的时候特别容易发生因为分区有起始偏移脚本如果没把偏移算进去写的位置和读的位置就错开了。还有一种比较隐蔽的情况AHCI 模式切换导致地址映射变化。比如系统从 IDE 兼容模式切到 AHCI 模式或者反过来盘的访问方式变了之前写的数据在新模式下读出来的位置可能就不对了。这个在 Windows 改 AHCI 模式无法启动的案例里经常出现本质是驱动加载顺序和存储栈初始化的问题。2.4 三层缓存的职责与失效场景把缓存分三层来看会更清楚谁该负责什么。缓存层级位置职责失效场景页缓存操作系统内存加速文件读写合并 IO未调用 fsync/sync掉电丢失块设备层缓存内核块设备合并、排序 IO 请求未 flush请求还在队列盘写缓存盘内部加速写入响应未发 FLUSH CACHE掉电丢失脚本报 PASS 最常见的原因就是只验证了页缓存没有强制刷到盘。要验证介质必须在写完之后调用 flush 类操作把三层缓存都刷下去然后再读。而且读的时候要绕过缓存直接读裸设备才能读到介质上的真实数据。3. 实操排查怎么定位到底是哪一层在撒谎3.1 第一步确认写入是否真的下发到了盘排查的第一步是确认写请求有没有离开操作系统。最直接的办法是看块设备层的统计。在 Linux 下可以读/sys/block/sdX/stat里面有几个关键字段写完成次数、写合并次数、写扇区数。如果脚本写了很多次但这个统计里的写扇区数没怎么涨说明请求还堵在缓存里根本没下发。另一个办法是用iostat或者blktrace观察实际下发的 IO。blktrace能看到每个 IO 请求从进入到完成的完整生命周期包括它在队列里待了多久、什么时候下发给驱动、什么时候完成。如果脚本报 PASS 但blktrace里看不到对应的写请求那基本可以确定数据没下去。注意读块设备统计之前先确认你读的是正确的设备节点。多盘机器上很容易看错盘尤其是盘符会变的环境。建议用盘的序列号或者 WWN 来定位不要依赖 sdX 这种会变的命名。3.2 第二步确认盘上数据是否真的落盘确认请求下发之后下一步是确认数据有没有真正落到介质。这里的关键是绕过所有缓存去读。在 Linux 下可以用dd直接读裸设备配合iflagdirect绕过页缓存dd if/dev/sdX of/tmp/readback.bin bs512 skip100 count1 iflagdirect然后用hexdump或者xxd看读出来的内容是不是你写的那份特征数据。如果是全零说明数据没落盘或者落到了别的位置。如果条件允许最彻底的办法是把盘拆下来用另一台机器读或者用专门的读盘工具读。这样能完全排除操作系统缓存的影响看到介质的真实状态。老化测试场景下我一般会在测试前后各做一次裸盘读取对比数据是否一致。3.3 第三步确认 LBA 计算是否正确如果数据确实写下去了但读出来的位置不对那问题就在 LBA 计算上。这里最常见的坑是分区偏移没算进去。假设盘上有一个 MBR 分区表第一个分区从 LBA 2048 开始。脚本如果直接往 LBA 100 写那写的是 MBR 保留区域不是分区内的数据。而系统工具如果通过文件系统读读的是分区内的偏移算出来的 LBA 是 2048 100 2148。两边差了一个分区起始偏移读出来的当然不是同一份数据。排查这个问题的办法很简单把脚本用的 LBA 和系统工具用的 LBA 都打印出来对比一下差值。如果差值正好等于分区起始扇区那就是偏移没算。MBR 分区表里每个分区的起始 LBA 可以在分区表项里读到用fdisk -l或者直接解析 MBR 都能看到。3.4 第四步确认 AHCI 模式与驱动状态如果前面三步都排除了那就要看 AHCI 层面。AHCI 模式切换会导致存储栈重新初始化如果切换过程中有未刷下去的数据就可能丢失。Windows 改 AHCI 模式无法启动很多时候就是因为切换前没把存储驱动准备好系统起来之后找不到盘或者盘的状态不对。在 Linux 下可以看dmesg里 AHCI 相关的日志确认控制器是否正常初始化、盘是否被正确识别、有没有报错。如果看到failed to IDENTIFY或者link reset之类的信息说明链路层有问题数据可能根本没到盘。还有一个容易被忽略的点盘的写缓存策略。有些盘默认开启写缓存返回完成信号时数据其实还在盘的内存里。如果这时候掉电数据就丢了。要确保数据落盘需要在写完以后发FLUSH CACHE命令。在 Linux 下可以用hdparm -F /dev/sdX或者sg_raw发 flush 命令。4. 脚本层面的修复让 PASS 变得可信4.1 写完必须 flush读必须 direct脚本要可信核心就两条写完强制刷盘读的时候绕过缓存。写完之后根据你用的接口选择对应的 flush 方式。如果是文件操作调用fsync或者fdatasync如果是裸设备操作发BLKFLSBUFioctl 或者fsync到设备文件如果要对盘发 flush用hdparm -F或者直接发 SCSI/ATA 命令。读的时候用O_DIRECT打开设备文件或者在dd里加iflagdirect确保读请求不经过页缓存直接下发到盘。这样读到的才是介质上的真实数据。import os # 写O_DIRECT 绕过页缓存写完 fsync 刷盘 fd os.open(/dev/sdX, os.O_RDWR | os.O_DIRECT) os.lseek(fd, 100 * 512, os.SEEK_SET) os.write(fd, b\xAA * 512) os.fsync(fd) os.close(fd) # 读同样 O_DIRECT读回来比对 fd os.open(/dev/sdX, os.O_RDONLY | os.O_DIRECT) os.lseek(fd, 100 * 512, os.SEEK_SET) data os.read(fd, 512) os.close(fd) assert data b\xAA * 512, 数据不一致介质上不是写入的内容这段代码的关键在于O_DIRECT和fsync的配合。O_DIRECT让读写都不经过页缓存fsync确保数据从盘内部缓存落到介质。两个都做到脚本的 PASS 才是真的 PASS。4.2 加入掉电模拟与重启验证老化测试场景下光刷盘还不够还要模拟掉电和重启。因为有些盘在正常掉电时能保住数据但异常掉电时就不行。要验证这一点可以在写入并 flush 之后直接断电然后重新上电读盘看数据还在不在。如果条件不允许直接断电可以用软件方式模拟写完 flush 之后卸载设备、重新扫描、再读。或者用echo 1 /sys/block/sdX/device/delete把盘移除再重新扫描。这样能强制走一遍重新初始化的流程暴露缓存和初始化顺序的问题。实操心得掉电测试不要只做一次。我一般会做至少 10 轮每轮写不同的特征数据掉电后读回来比对。有些盘的问题是概率性的做一次两次看不出来做多了才能暴露。4.3 校验范围要覆盖关键区域脚本校验不要只盯着自己写的那几个 LBA。老化测试的目的是验证整块盘的可靠性所以校验范围要覆盖 MBR、分区表、文件系统元数据区、以及数据区。尤其是 MBR 区域很多盘的问题会先在这里暴露。一个比较稳妥的做法是测试前先把整块盘的关键区域读一遍存一份基准测试后再读一遍逐字节比对。关键区域包括 LBA 0MBR、分区表所在扇区、文件系统超级块、以及测试数据区。这样任何一处被改写或者变成零都能被发现。4.4 日志要记录足够的信息脚本的日志不能只打 PASS 和 FAIL要记录足够的信息用于事后分析。至少包括测试的 LBA 范围、写入的特征数据模式、flush 的返回状态、读回数据的校验和、以及每次操作的耗时。如果出现 FAIL还要记录读回的实际数据内容方便判断是全零、旧数据、还是部分损坏。我习惯在日志里加一个时间戳和盘序列号这样多盘并行测试的时候能快速定位是哪块盘、哪个时间点出的问题。日志格式用结构化格式比如 JSON Lines方便后续用脚本分析。5. 常见问题速查与避坑清单5.1 问题速查表现象可能原因排查方法修复方式脚本 PASS系统读全零数据还在页缓存看块设备写统计写后 fsync读用 O_DIRECT脚本 PASS重启后数据丢失盘写缓存未刷掉电重启后读盘写后发 FLUSH CACHE脚本读到的数据和系统不一致LBA 偏移算错对比两边 LBA加上分区起始偏移切换 AHCI 后盘读不到驱动未正确加载看 dmesg 日志切换前准备驱动按顺序初始化部分 LBA 读出来是旧数据写请求被合并或丢弃blktrace 看 IO 生命周期检查 IO 调度器和队列深度老化测试偶发 FAIL盘介质老化或缓存策略多轮测试统计失败率更换盘或调整测试参数5.2 避坑清单不要相信脚本自己的 PASS。脚本验证的是它能看到的东西如果它看的是缓存那 PASS 没有意义。任何涉及介质可靠性的测试都必须绕过缓存去读。不要在测试过程中切换存储模式。AHCI 模式切换会重新初始化存储栈过程中未刷盘的数据可能丢失。如果必须切换先 flush 所有缓存再切换切换后重新验证。不要忽略盘的写缓存策略。有些盘默认开启写缓存响应很快但掉电丢数据。老化测试要模拟真实使用场景如果真实场景会掉电测试就必须覆盖掉电情况。不要只测一个 LBA。单点测试覆盖不了整块盘的问题。老化测试要覆盖多个区域尤其是 MBR、分区表、文件系统元数据这些关键位置。不要用会变的设备名。/dev/sda、/dev/sdb这些名字在多盘环境下会变用盘的序列号或者/dev/disk/by-id/下的稳定链接来定位盘。不要忘记记录基准数据。测试前先读一遍关键区域存基准测试后比对。没有基准出了问题都不知道是原来就这样还是测试导致的。5.3 一个真实的排查案例之前遇到过一个案例脚本在 A 机器上跑PASS换到 B 机器上跑也 PASS但把 B 机器上测试过的盘拆下来装到 C 机器上读数据全零。查了很久最后发现是 B 机器的 AHCI 驱动版本有问题写请求下发了但控制器没有正确执行 DMA数据根本没到盘。而脚本读的时候命中了页缓存所以报 PASS。这个案例的教训是脚本 PASS 只能证明脚本自己逻辑自洽不能证明数据真的落盘了。要证明数据落盘必须用独立于写入路径的方式去读比如换机器读、裸盘读、掉电后读。只有读到的数据和写入的一致才能说这次写入是可靠的。6. 把测试做扎实的几个经验6.1 测试设计要覆盖真实场景老化测试不是跑个脚本就完事测试设计要贴近真实使用场景。真实场景里盘会经历什么频繁读写、随机掉电、温度变化、长时间运行。测试就要覆盖这些多轮次读写、随机掉电、高温老化、长时间连续跑。只做一轮写入读回比对覆盖不了这些场景。我一般会把测试分成几个阶段预热阶段做基础读写验证压力阶段做高频随机读写掉电阶段做随机掉电恢复验证最后做全盘数据一致性校验。每个阶段都有明确的通过标准任何一个阶段不过整块盘就判 FAIL。6.2 数据特征要有多样性写入的特征数据不要只用一种模式。全AA、全55、递增序列、随机数据这几种模式对盘的考验是不一样的。全AA和全55能暴露位翻转问题递增序列能暴露地址映射问题随机数据能暴露更复杂的介质缺陷。测试时轮换使用这几种模式覆盖更全面。6.3 失败要能复现和定位测试失败不可怕可怕的是失败了不知道怎么复现。所以测试脚本要记录足够的信息失败时的 LBA、写入的数据、读回的数据、操作序列、时间戳、盘的状态。有了这些信息才能复现问题、定位原因。如果条件允许失败时自动保存现场把失败 LBA 附近的数据 dump 出来把 dmesg 日志保存下来把盘的 SMART 信息读出来。这些信息对后续分析非常有价值。6.4 定期校准测试环境测试环境本身也要定期校准。比如测试用的机器AHCI 驱动版本、内核版本、BIOS 设置都要记录并保持一致。如果测试环境变了测试结果就没有可比性。我一般会给每台测试机建一个配置档案记录硬件、固件、驱动、系统版本每次测试前核对一遍。还有一点测试用的参考盘要定期校验。参考盘是用来做基准比对的如果参考盘本身有问题测试结果就不可信。定期用独立工具校验参考盘确保它本身是可靠的。6.5 脚本要能独立运行和自检测试脚本最好能独立运行不依赖太多外部环境。脚本启动时先做自检确认目标盘存在、确认权限足够、确认 flush 和 direct 读可用、确认日志路径可写。自检不过就直接退出不要带着问题往下跑。脚本还要能处理异常情况盘突然掉线、读写超时、flush 失败。这些情况要能捕获并记录而不是直接崩溃。老化测试跑的时间长中途出异常的概率不低脚本要能扛住。7. 关于这个问题的几点个人体会脚本说 PASS、系统读全零这个问题看起来是脚本和系统在互相甩锅实际上大多数时候是测试方法本身有漏洞。脚本验证的是它能看到的数据如果它看到的是缓存那 PASS 就是假的。系统读全零是因为它读的是介质介质上确实没有数据。我踩过几次坑之后现在做任何存储相关的测试都会坚持三条原则写后必 flush读必 direct验证必换路径。写后 flush 确保数据离开内存读用 direct 确保读到介质换路径验证确保不是自己骗自己。这三条做到了脚本的 PASS 才有含金量。还有一点AHCI、LBA、MBR 这些概念平时写业务代码可能用不到但一旦涉及存储可靠性测试就必须搞清楚。因为问题往往就出在这些底层细节上上层脚本写得再漂亮底层数据没落盘一切都是白搭。最后分享一个小技巧如果你不确定数据有没有落盘最笨但最有效的办法就是把盘拆下来换一台机器读。换机器读能排除所有操作系统缓存和驱动的影响看到介质的真实状态。这个办法虽然麻烦但在关键测试场景下值得做。