
简介面向Android平台需要高效安全数据读写的开发者这套源码示例演示了如何通过JNI技术调用C/C代码以Direct IO直接输入输出模式与加密TF卡通信适合具备JNI基础、从事底层存储或性能优化的中级以上开发者。传统输入输出依赖内核缓冲区而Direct IO使用open函数的O_DIRECT标志绕过缓存减少数据拷贝满足数据库、媒体处理、嵌入式存储等低延迟场景的严格要求。压缩包仅2个文件、约2KB其中C文件负责封装JNI接口并提供native方法C文件负责TF卡设备打开、读写与数据对齐等底层细节结构紧凑便于移植。目前已有555人学习。通过学习可掌握O_DIRECT启用前提、缓冲区对齐及缓存一致性管理理解加密TF卡须在用户空间自行集成加解密的原因并参考错误回退与CPU占用权衡思路加快Android底层存储开发进程。1. Android 设备上做 T 卡全盘备份读到的却是旧数据某次接到一个需求对一台嵌入式设备的加密 T 卡做整卡镜像备份要求备份文件和 T 卡上实际落盘的数据严格一致。设备跑的是 Android存储走的是 TF 卡量产时 T 卡做过分区加密。一开始用 FileInputStream 读块设备节点结果备份镜像在二次校验时总对不上排查了很久才定位到根因——Android 默认文件 IO 走了 page cache应用层读到的是几十毫秒前内核缓存里的旧内容而不是盘片上真实的数据。这个项目解的就是这个问题通过安卓 JNI 实现 direct IOO_DIRECT 方式的块设备读写绕过页缓存让每次读写都直接命中设备盘片或加密层。包里带一套完整的 JNI 工程Java 接口、native 层 C 实现、对齐缓冲区管理以及加密 T 卡场景的接入 Demo。适合做 Android 底层存储、安全设备日志采集、整机备份还原的开发者你要是只写应用层 UI大概率用不上但只要你的数据和盘片必须严格一致这套代码就是一个能直接落地的底子。2. 底层机制O_DIRECT 到底关掉了什么以及 JNI 怎么把 Java 世界送进内核2.1 open() 里的 O_DIRECT一份关于绕过缓存的直白解释先看 Linux 的 open 系统调用O_DIRECT 这个 flag 的作用。官方文档的说法叫“减少缓存影响”翻译成人话不带 O_DIRECT 时read() 会先查内核 page cache命中就直接从缓存拷贝给用户空间没命中则缺页从磁盘读进缓存再返回。带上 O_DIRECT 之后read() 绕开 page cache直接从块设备或文件系统底层把数据拷贝到调用方指定的缓冲区读写完成后页面也不留在缓存里。这套机制对 T 卡读写有一个很实际的收益普通回写缓存下write() 把数据放进内存页就返回了真正落盘要等后台回写线程或 fsync而 direct IO 的 write 要等数据交给设备层才返回。对加密 T 卡这种“断电后盘上数据必须可恢复”的场景这一点决定了生产可用性。// 典型的 O_DIRECT 打开方式 int fd open(/dev/block/mmcblk1p1, O_RDWR | O_DIRECT, 0644); if (fd 0) { // errno 最常见的是 EINVAL通常是对齐或文件系统不支持 perror(open with O_DIRECT failed); }逻辑说明O_DIRECT 标志要求 open() 打开的路径支持直接 IO。块设备节点一般都能支持但挂载在存储栈上的某些文件系统并不支持所以把 O_DIRECT 用在普通文件路径上时经常拿到 EINVAL。参数说明O_RDWR 表示可读可写O_DIRECT 在 Android bionic 的 fcntl.h 里已有定义不需要自己拼宏0644 是权限位块设备节点通常用不到它实际访问控制靠 SELinux 策略。在大文件吞吐层面direct IO 并不总能比缓存 IO “更快”。缓存 IO 命中后速度接近纯内存拷贝direct IO 少了这层羊毛可薅。但 direct IO 胜在可预期性——每次读的延迟都是稳定的设备 IO 延迟不受缓存命中率影响。对块设备镜像备份来说这个可预期性比吞吐峰值更值得关注也是我最后选它而不是继续调大 FileInputStream 缓冲区的原因。2.2 Android 存储栈对 direct IO 的隐藏限制Android 的存储栈比桌面 Linux 多了一层。用户访问外置 T 卡常规路径是 vold 进程挂载文件系统再通过 FUSEsdcardfs给 App 提供权限受限的访问。FUSE 对 direct IO 的支持参差不齐很多机型上对 FUSE 路径开 O_DIRECT 会直接报 EINVAL部分内核版本即使接受了 flag底层的 FUSE 请求也未必真正绕过缓存。所以要在 Android 上对 T 卡做 direct IO普遍做法是绕过文件系统层直接打开块设备路径。块设备路径怎么定位先看挂载信息再按主次设备号找 /dev/block 下的节点。adb shell cat /proc/mounts # 找到 T 卡的挂载行例如 /dev/block/mmcblk1p1 /storage/XXXX vfat rw,... adb shell ls -l /dev/block/mmcblk1p1 # brw------- 1 root root 179, 24 ...逻辑说明拿到设备路径后App 进程必须有对应节点的访问权限否则 open 返回 EACCES。在 userdebug 工程机上可以临时调整策略验证但正式产品里必须由有权限的守护进程代开这属于 Android 系统权限模型的硬约束绕不开。参数说明mmcblk1p1 里的 1 代表外置 SD/TF 卡控制器编号p1 是第一个分区不同设备编号不同要按实际挂载信息解析不能写死。2.3 JNI 是绕过 Java 层约束的通道Java 层的 FileOutputStream 走 libcore 静态绑定RandomAccessFile 虽然支持文件描述符操作但接口里没有暴露 O_DIRECT 标志位。唯一干净的通道就是 JNIJava 声明 native 方法C 实现在 native 层 open、read、write全程不经过 Java 的 IO 类库。JNI 同时解决了缓冲区的对齐问题。Java 的 byte[] 数组分配在 Art 堆上起始地址往往不是 512 或 4096 对齐的直接把这个数组的指针交给 O_DIRECT 的 read轻则 EINVAL重则读到错位数据。native 层用 posix_memalign 单独分一块对齐内存再 memcpy 回 Java 数组这是这个项目能够稳定跑起来的前提。JNI 层还要处理两个 Java 侧的别扭点。一是 Java 没有 unsigned 类型native 返回的指针被当成 long 时若最高位为 1Java 侧会得出负数导致误判二是 native 崩溃时 Java 层拿到的 SIGSEGV 堆栈信息有限所以工程里把关键错误路径的日志都打在 native 层出问题先看 logcat 里的 DirectIO-JNI 标签。这两点虽小但在真机调试时能省下大量时间。3. 核心代码拆解一个 direct IO 的读写流程在 JNI 里长什么样3.1 Java 侧接口用 long 指针而不是 byte[]工程里 Java 层有一个 DirectIO.java对外提供块级读写能力。设计上所有 native 方法都传递 long 类型的指针地址避免 JNI 层反复 GetByteArrayElements 的开销也让缓冲区的生命周期管理完全收口在 native 侧。方法声明结构如下public class DirectIO { static { System.loadLibrary(directio); } // 分配一块按 alignment 字节对齐的内存返回内存地址 public static native long allocBuffer(int size, int alignment); // 释放 allocBuffer 分配出来的缓冲区 public static native void freeBuffer(long ptr); // 打开设备或文件flags 透传 open() 的系统调用标志 public static native long open(String path, int flags, int mode); // 从指定偏移读 size 字节到 native 缓冲区返回实际读到的字节数 public static native int read(long fd, long buf, int size, long offset); // 从 native 缓冲区写 size 字节到指定偏移 public static native int write(long fd, long buf, int size, long offset); // 落盘等同 fsync(2) public static native void sync(long fd); // 关闭文件描述符 public static native void close(long fd); }逻辑说明allocBuffer 返回 long 地址Java 层不直接接触缓冲区内容read/write 都带 offset 参数因为这套接口主要被用于 T 卡分区的块级备份恢复块设备没有文件游标的概念用 pread/pwrite 按偏移访问更合理也不会改变文件描述符的当前位置。参数说明alignment 传 512 或 4096 即可。size 建议是 alignment 的整数倍。read 返回的不是期望字节数时要么设备没那么多数据要么对齐参数给错了。fd 透传 native 层 open() 的返回值Java 层不持有真实的文件描述符避免被垃圾回收器误处理关闭。3.2 native 侧实现从 posix_memalign 到 pread/pwriteNative 层用了动态注册函数名不遵循 Java_ 前缀的强制规则实现起来更干净。核心函数有三个值得看allocBuffer、open、read。先看内存分配#include jni.h #include fcntl.h #include unistd.h #include stdlib.h #include string.h #include android/log.h #define TAG DirectIO-JNI #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__) // 对应动态注册表里的 native_allocBuffer static jlong native_allocBuffer(JNIEnv *env, jclass clazz, jint size, jint alignment) { void *ptr NULL; // alignment 必须是 2 的幂且是 sizeof(void *) 的倍数 int ret posix_memalign(ptr, alignment, size); if (ret ! 0) { LOGE(posix_memalign failed, ret%d, ret); return 0L; } // 顺手清零避免 O_DIRECT 短读时把历史内容带回 Java 层 memset(ptr, 0, size); return (jlong)(uintptr_t)ptr; }逻辑说明posix_memalign 返回的是已对齐的内存指针把指针当作整型塞进 long 还给 Java。清零是防御性写法因为 O_DIRECT 读不满 size 时缓冲区剩余空间会带着旧内容后续拷贝或比对容易出玄学问题。参数说明alignment 有讲究常见块设备逻辑块大小是 512 字节但 eMMC 5.1、UFS 这类设备按 4K 对齐更稳。如果设备并发 IO 多我一般直接把 alignment 设成 4096通用性比 512 好。size 的取值看单次 IO 量备份 T 卡时单次 64KB 的 buffer 效果不错太大对内核内存压力大容易触发 block 层的合并且造成延迟抖动。再看打开设备的部分static jlong native_open(JNIEnv *env, jclass clazz, jstring path, jint flags, jint mode) { const char *path_cstr (*env)-GetStringUTFChars(env, path, NULL); if (path_cstr NULL) return -1L; // 默认带上 O_DIRECT调用方也可以通过 flags 自行控制 int fd open(path_cstr, flags | O_DIRECT, mode); (*env)-ReleaseStringUTFChars(env, path, path_cstr); if (fd 0) { LOGE(open failed, errno%d (%s), errno, strerror(errno)); return -1L; } return (jlong)fd; }逻辑说明GetStringUTFChars 把 Java 的 String 转成 C 的 char 数组用完必须 ReleaseStringUTFChars否则本地引用会泄漏。open 的 flags 由 Java 透传对 T 卡块设备节点常用的组合是 O_RDWR | O_DIRECT。打开失败时返回 -1LJava 层判断负数即可。参数说明mode 在打开块设备时通常传 0因为设备节点权限由 SELinux 管控open 函数的 mode 参数只对新建文件有意义。接着是 read 的实现static jint native_read(JNIEnv *env, jclass clazz, jlong fd, jlong buf, jint size, jlong offset) { ssize_t n pread((int)fd, (void *)(uintptr_t)buf, (size_t)size, (off_t)offset); if (n 0) { LOGE(pread failed, errno%d (%s), errno, strerror(errno)); return -1; } // 返回实际读到的字节数Java 层据此判断是否重试 return (jint)n; }逻辑说明pread 相比 read 的差异在于它接受 offset 参数且不改变文件位置指针。块设备上文件位置没有实际意义按 offset 访问才是块级操作的正确姿势。读回来的数据在 native buffer 里Java 层要读明文时需要另一个 JNI 方法 memcpy 回 byte[] 数组。参数说明size 单次不要超过 2GB-1否则 jint 返回值会溢出实际工程里单次 64KB 到 1MB 足够。offset 必须按对齐要求传入否则 pread 返回 EINVAL这是块设备 direct IO 最常见的错误。3.3 对齐问题为什么 JNI 层要自己管缓冲区对齐是 direct IO 能不能跑起来的分水岭。O_DIRECT 要求三对齐缓冲区地址、传输字节数、读写偏移三者都必须是设备逻辑块大小的整数倍。用 Java 数组做 buffer 翻车就是因为这个——Java 堆对象地址从 Art 堆来堆的基址和对象头的布局让对齐变成不可控的事。工程里做了个封装把“分配对齐内存 → pread → 拷贝到 Java 数组 → 释放”合成一个 native 方法省掉 Java 侧自己管 buffer 的负担static jbyteArray native_readBytes(JNIEnv *env, jclass clazz, jlong fd, jint size, jlong offset, jint alignment) { void *buf NULL; if (posix_memalign(buf, alignment, size) ! 0) return NULL; ssize_t n pread((int)fd, buf, size, offset); if (n 0) { free(buf); return NULL; } jbyteArray arr (*env)-NewByteArray(env, (jsize)n); (*env)-SetByteArrayRegion(env, arr, 0, (jsize)n, (jbyte *)buf); free(buf); return arr; }逻辑说明这个封装把对齐分配、pread、拷贝、释放四个步骤收拢在一个 native 方法里Java 侧拿到的就是干净的 byte[]。注意 SetByteArrayRegion 之后数据从 native 内存拷贝到了 Java 堆这一层拷贝省不掉但相比 Java IO 字节流经过的 BufferedInputStream 等多层缓冲整体路径短得多。参数说明alignment 按 4096 传size 小于对齐值时部分设备会直接报 EINVAL所以这个封装内部没有做大小纠偏要求在调用方保证 size 是对齐值的整数倍。3.4 加密 T 卡的接入方式直接 IO 和 T 卡加密怎么结合典型配置是 T 卡分区被 dm-crypt 或文件级加密保护挂载之后是一层加密文件系统但块设备节点本身存的是密文。这时候 direct IO 在块设备层读写读出来的是加密后的数据块要拿明文要么 native 层自己加解密要么打开解密映射之后的设备节点也就是 /dev/block/dm-X。这个包的 Demo 给的是后一条路先查设备的 dm 映射顺序拿到解密后的块设备节点再对该节点做 O_DIRECT 读写。整体链路就是“App JNI → dm-crypt 解密层 → 物理 T 卡”页缓存照常被 O_DIRECT 绕过。adb shell ls -l /dev/block/dm-* adb shell dmsetup table逻辑说明dmsetup table 能看到设备映射表的 target 类型和参数target 类型是 crypt 的就是加密层。需要读明文时打开对应的 /dev/block/dm-X 即可。注意线程安全多个线程同时 pread 同一个 fd 是安全的pread 不会改变文件位置但一个设备节点建议每个进程只 open 一次多次打开会重复引用计数卸载设备时可能出现 busy 状态。4. 编译与运行从 NDK 交叉编译到真机效果确认4.1 工程结构和 CMakeLists源码包目录结构很清晰顺着目录能找到所有关键代码directio/ ├── app/ │ ├── src/main/ │ │ ├── java/com/example/directio/DirectIO.java │ │ └── cpp/ # JNI C 代码 │ ├── CMakeLists.txt │ └── build.gradle ├── tools/ │ ├── bench.sh # 读写基准测试脚本 │ └── dump_block.sh # 块设备镜像导出工具 └── README.mdCMakeLists.txt 里三个关键配置cmake_minimum_required(VERSION 3.22.1) project(directio) add_library(directio SHARED src/main/cpp/directio_jni.c ) target_link_libraries(directio android log ) target_compile_options(directio PRIVATE -O2 -Wall)逻辑说明链接 android 和 log 是为了使用 __android_log_print 打点正式发布时可去掉 log 依赖再编一版。编译选项 -O2 是对 native 层代码的基础优化-Wall 用来在编译阶段提前暴露类型不匹配等问题。参数说明Android Studio 里 build.gradle 的 externalNativeBuild 配置指向这个 CMakeListsabiFilters 建议只保留 arm64-v8a因为 direct IO 本身对 32 位进程没有额外收益而且 64 位下 long 正好容纳指针地址。4.2 JNI 方法注册静态命名与 RegisterNatives上面核心代码段里函数名是 native_ 开头靠 JNI_OnLoad 动态注册。动态注册的好处是不用管 C 符号的 JNI 命名规则Java 侧改方法名时 C 函数名可以不动缺点是签名描述符必须和 Java 声明一字不差写错不会编译失败只在运行期报 NoSuchMethodError。注册表长这样static const JNINativeMethod methods[] { {allocBuffer, (II)J, (void *)native_allocBuffer}, {freeBuffer, (J)V, (void *)native_freeBuffer}, {open, (Ljava/lang/String;II)J, (void *)native_open}, {read, (JJIJ)I, (void *)native_read}, {write, (JJIJ)I, (void *)native_write}, {sync, (J)V, (void *)native_sync}, {close, (J)V, (void *)native_close}, }; JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void *reserved) { JNIEnv *env NULL; if ((*vm)-GetEnv(vm, (void **)env, JNI_VERSION_1_6) ! JNI_OK) { return JNI_ERR; } jclass clazz (*env)-FindClass(env, com/example/directio/DirectIO); if (clazz NULL) return JNI_ERR; (*env)-RegisterNatives(env, clazz, methods, sizeof(methods) / sizeof(methods[0])); (*env)-DeleteLocalRef(env, clazz); return JNI_VERSION_1_6; }逻辑说明动态注册的签名描述符是另一套语法J 表示 longI 表示 intV 表示 voidLjava/lang/String; 表示 String 类型后面的 [B 表示 byte[]。方法和签名的对应关系必须和 Java 侧完全一致。参数说明GetEnv 是 JNI 规范写法如果返回 JNI_OK 说明当前线程已绑定 JavaVMJNI_VERSION_1_6 是兼容性最好的版本要求Android 全版本都支持。静态注册和动态注册可以做个对比对比项静态命名动态 RegisterNatives符号命名必须按 Java_包名_类名_方法名格式随便起名签名维护方法名和参数由命名隐式约束签名描述符逐个手写错误时机链接期缺失符号直接编译失败运行期 FindClass 失败才暴露混淆兼容包名和方法名混淆后找不到符号RegisterNatives 在 load 时执行不受混淆影响实际工程建议用动态注册尤其当多个 native 库配合混淆构建时静态命名对混淆后的符号解析非常脆弱。4.3 用基准测试确认 direct IO 生效跑一个不依赖外部工具的基准思路是比较普通 IO 与 O_DIRECT 在重复读同一偏移时的耗时差异。普通 IO 第二次读会命中 page cache速度显著快于第一次O_DIRECT 则每次都是真实设备访问两次耗时基本持平。这种对比不需要专业工具一台 adb 能连的真机就够了。adb push app/build/outputs/apk/debug/app-debug.apk /data/local/tmp/ adb shell su -c mkdir -p /data/local/tmp/directiodemo adb shell su -c cp /data/local/tmp/app-debug.apk /data/local/tmp/directiodemo/更直接的验证方式是打开一个块设备节点故意用 O_DIRECT 读一个长度不对齐的数据块预期返回 EINVAL再换成对齐的块返回正常。能稳定复现这个现象说明 O_DIRECT 真的被设备层接收了而不是被内核静默忽略long buf DirectIO.allocBuffer(4096, 4096); int n DirectIO.read(fd, buf, 1024, 0); // 错误示范size 不是 4096 的整数倍 // 预期返回 -1errno22 EINVAL这段故意写反的调用是一个很实用的自检方法。如果跑完返回值是 1024说明设备或文件系统没有真正给 O_DIRECT 放行要回头查 open 是否正确带上了 O_DIRECT 标志以及中间层是否做了降级处理。提示自检时构造一个 size 为非对齐的读请求返回 EINVAL 说明 O_DIRECT 链路真实生效。这是排查“开了等于没开”问题的标准动作。5. 避坑记录direct IO 最常见的五个翻车现场这套 JNI 在 x86 模拟器、arm64 真机、不同内核版本上跑过一圈能明显感觉到 direct IO 属于“能做但绝不省心”的机制。下面五个坑都是项目验证期间实际趟过的按现象、原因、解决三行整理方便直接对照。5.1 open 直接报 EINVAL文件系统不支持 O_DIRECT现象在 /storage/emulated/0 路径下创建一个测试文件open(/storage/emulated/0/test.bin, O_RDWR | O_DIRECT) 在多数真机上直接返回 -1errno 是 EINVAL。原因FUSE 和 sdcardfs 对上层的 O_DIRECT 支持不完整open 阶段就会拒绝部分机型表面上接受了 flag但底层 FUSE 请求没有真正穿透到存储设备。解决不要对挂载点路径做 direct IO要访问就打开底层块设备节点如果业务上必须走文件系统先确认文件系统类型和内核配置再考虑用 O_DIRECT 的替代方案。5.2 对齐条件没满足open 成功但第一次 pread 就崩溃现象打开 /dev/block/mmcblk1p1 成功但第一次 pread 返回 -1errno22。原因设备逻辑块大小是 4096而分配缓冲区时只按 512 对齐size 或 offset 也不是 4096 的整数倍。open 阶段不会检查对齐错误延后到了第一次读写才暴露。解决先用命令确认设备逻辑块大小。adb shell cat /sys/block/mmcblk1/queue/logical_block_size拿到结果后把 alignment、size、offset 全部按该值对齐。我一般写一个断言宏在调用前校验指针地址和 size 取模结果一秒钟就能发现对齐问题不用等 pread 返回 EINVAL 再猜。5.3 Java 数组直接当 buffer地址不对齐加拷贝风暴现象用 GetByteArrayElements 拿到 byte[] 的指针后直接传给 pread结果时好时坏某些系统版本稳定 EINVALRelease 之后偶尔崩溃。原因Art 堆里的 byte 数组地址不受控制即使 GetByteArrayElements 暂时固定了数组也不能保证起始地址对齐GC 压缩后对象移动还会让指针失效。解决一律走 native 层 posix_memalign 加 memcpy 的两段式不要省那一次拷贝。分配完 native buffer 后对关键缓冲区做一次 mlock 锁定物理页避免 IO 中途被换出。Android 虽然一般没有 swap但这属于保底没坏处的防御习惯。5.4 读不满每次 pread 返回小于请求的字节数现象读加密 T 卡分区对应的块设备请求读 64KB返回经常只有 32KB 或更少没有报错。原因dm-crypt 映射层对单次 IO 有内部限制设备自身的 multi-read 在某些固件下也会出现 short transfer。解决把 read 包一层循环累计已读字节n 小于 0 时按 errno 决定重试或退出n 等于 0 才认为到达末尾。这个循环逻辑应该放在 native 层避免每次短读都跨 JNI 边界回到 Java 再发起下一次调用。5.5 拿到块设备节点但 open 返回 EACCES现象已经通过 su 拿到 root shell但 App 进程内 open(/dev/block/mmcblk1p1) 依然返回 EACCES。原因Android 的 SELinux 策略对 App 域访问块设备节点做了明确限制即使进程有 root 权限安全上下文不对也无法访问。解决先在工程机上 adb shell setenforce 0 临时验证是不是 SELinux 导致的确认后正式方案要么让 vold 这类有权限的系统进程打开 fd 通过 Binder 传给 App要么给指定 APK 配置 SELinux 策略放行而不是靠 root 在运行时改全局开关。6. 二次开发从直接读写到加密 IO 框架6.1 改造点一native 层加 AES-XTS 加解密如果加密 T 卡的明文由文件系统层负责而你想在文件系统之上再接一层自己的数据保护可以在 read/write 中间塞入 AES-XTS。XTS 模式专为块存储设计每个数据块用 tweak 区分适合按块偏移独立加解密读历史块时也能独立解出来。加解密的位置就放在 native 层 pread 之后、write 之前对 Java 完全透明// 在 native_read 返回前对读出来的数据做解密 ssize_t n pread(fd, buf, size, offset); if (n 0 cipher_enabled) { aes_xts_decrypt(buf, n, offset, key, tweak); // 按 offset 生成 tweak }逻辑说明改这个位置时注意密钥不要硬编码进 so优先从 Android Keystore 或 TEE 取出后通过 JNI 参数传入。参数说明tweak 来自块号 offsetXTS 模式下每个 16 字节块的 tweak 是线性递增的解密时直接拿偏移量计算即可。6.2 改造点二用 io_uring 替换 pread/pwrite高版本 Android 内核已经带 io_uring块设备 IO 用它做异步批量提交比 pread 加线程池省掉大量系统调用开销。如果做的是高吞吐 T 卡备份引擎这个替换值得投入struct io_uring_sqe *sqe io_uring_get_sqe(ring); io_uring_prep_read(sqe, fd, buf, size, offset); io_uring_submit(ring); // 等待 cqe 完成逻辑说明io_uring 的优势是批量提交和完成事件收割配合已有的对齐缓冲区可以做到零拷贝路径。改的时候保持对齐约束不变O_DIRECT 的规则不会因为换 IO 引擎而放宽。6.3 验证方法改完第一件事用包里 tools 下的 bench.sh 跑一组普通 IO、direct IO、加密 direct IO 的对比看吞吐和 CPU 占用。第二件事是断电测试——写入后直接断电重启再读盘验证数据完整。direct IO 的意义不在延迟低而在数据同步性可靠断电测试能把这个特性验证到位。我在每一次改完这套 JNI 之后都会强制走一遍冷启动加断电恢复加数据比对三件套不通过就不把手里的二进制交出去。希望上面这些踩坑细节能帮你在接入 direct IO 时少走几趟弯路。本文还有配套的精品资源点击获取