背景
最近团队小伙伴弗曼统计了线上用户数据写入对齐情况,通过统计数据发现了一个有趣的现象: 用户写入请求中近 70% 的数据块 4K 不对齐,这也就是说 NFSClient 对大多数的应用写入没有做对齐优化。
下面会从 NFSClient BufferWrite 实现流程的维度解释 IO 不对齐的原因,最后据此给出了若干实践建议。
场景分析
应用程序一般可以使用 DirectIO 或 BufferIO 两种方式向文件写入数据。在 DirectIO 模式时 NFSClient 直接将用户 IO 通过 RPC 发送给服务端,因此 DirectIO 方式写入 NFSClient 不会做对齐,但考虑到应用程序使用 DirectIO 时一般会在应用侧做对齐,因此非对齐 IO 的多数应该是 BufferIO 方式。
内核使用 struct nfs_req 对象记录某个缓存页更改情况,同时使用 struct page 对象的 private 字段保存,因此只需分析 BufferIO 时 nfs_req 的处理逻辑就能够知道对齐规则。NFSClient 调用 nfs_updatepage() 更新 nfs_req 对象,其核心代码如下:
从上图红色框中代码可以看出来,NFSClient 会尝试按照缓存页大小对 offset/count 对齐,继续查看 nfs_can_extent_write() 函数实现:
从上述代码可知:
-
若为同步写,则不尝试对齐,这是因为对同步写做对齐并没有明显收益还会放大 IO;
-
若缓存页内容不是最新,则不允许对齐,否则就要读惩罚,注意 nfs_write_begin() 函数已处理是否需要读惩罚;
-
若持有 Write Delegation,则允许对齐,因为 Write Delegation 保证本地缓存数据一定是最新,关于 Delegation 可参考文章《NFSClient Delegation 实现与陷阱》;
-
若不持有文件锁,则允许对齐,注意此处依据 NFSClient 设计哲学的一个假设:应用在不持有文件锁写入数据时,应用侧应该保证文件不会被多客户端修改;
-
若持有全文件的写锁,则允许对齐,因为写锁保证了本地缓存数据肯定是有效的且不存在多客户端更改,此外这里要求全文件锁的原因是为了简化代码逻辑。
合并规则
上面提到内核使用 struct nfs_req 对象表示每个缓存页的更改情况,考虑到 struct nfs_req 只能表示单区间,因此 BufferIO 需合并同一个缓存页的多次更改,合并规则较为简单:
两个写合并后必须是一个连续的区间。
相关代码实现可参考 nfs_try_to_update_request() 函数,代码较为简单,故不再详细描述。从合并规则还可以知道:同一个缓存页上的两个非连续 IO 需要两次 RPC 写入。
缓存页 UpdateToDate 设置
从场景分析中知道缓存页是否设置了 UpdateToDate 标记是同一缓存页上的两个 IO 能否合并的重要前提条件,那 NFSClient 是什么时候将缓存页设置为 UpdateToDate 状态的呢?总的来说,NFSClient 在两个地方尝试设置该标记:
- 将缓存页加入到 address space 时,实现代码是 nfs_write_begin() 函数,相关逻辑如下:
继续查看 nfs_want_read_modify_write() 函数实现:
上图中蓝色框中是 pNFS 相关处理暂时不做讨论,红色框则是一般情况下将缓存页加入到 address space 时是否需要读惩罚(读取数据后缓存页内容自然为最新),具体的需满足如下几个条件:
a) 若应用打开文件模式允许读,则允许读惩罚,这是因为根据数据的局部性原理,刚写入的数据很可能再次读;
b) 缓存页内容不是最新,若已经是最新很显然没必要再次读老数据;
c) 当前缓存页没有正在进行的 IO,也就是说没有向服务端有读写请求,注意 nfs_req 只能表示单个 IO,显然此时不允许读;
d) 本次修改只是缓存页的局部内容,显然如果全覆盖是没有必要读入老数据;
- 当内核将待修改的数据拷贝到缓存页后会调用 nfs_write_end() 函数通知 NFSClient 数据已经拷贝到缓存页,此时 NFSClient 需根据写入情况设置缓存页是否为最新的状态:
通过上面代码可知:
a) 蓝色框中表示缓存页是文件变大时新追加的缓存页,此时缓存页内容为全零,自然可设置为最新;
b) 红色框中表示缓存页中所有的有效数据均被覆盖,此时缓存页内容必然为最新;
总之,数据写入后若完全覆盖该缓存页的所有原有效数据,则设置为最新。
实践建议
至此基本搞清楚了 NFSClient BufferWrite 的对齐实现逻辑,感兴趣的同学可自行编写测试用例验证。结合对齐实现和测试验证,初步的可给出如下建议:
-
O_SYNC 方式不会做缓存页对齐;
-
当文件被大量小块 IO 重复覆盖写时,可考虑用 O_RDWR 方式打开(注意副作用是会有读惩罚),有利于聚合同一个缓存页的写 IO,减少 RPC 次数;
-
使用 O_WRONLY 方式打开时,同一缓存页的不连续更改不会做聚合,每个 IO 都会触发一次 RPC,降低访问性能;
-
使用类 MPI 方式多客户端并发修改同一文件时,条带大小应该做到缓存页对齐,否则可能会导致数据被错误覆盖;
-
不恰当的使用文件锁会导致不做缓存页对齐;
-
不使用文件锁时小块 IO 可能会缓存页对齐,导致 IO 放大。
总结
缓存页对齐是提高 BufferIO 访问性能地有效手段,NFSClient 在设计上尽量会尝试缓存页对齐,但受限于 NFS 共享特性的约束,也只能对较为有限的情况做缓存页对齐,这就潜在地要求应用侧配合才可以达到最优性能。
END