最近把很多以前零零散散的知识缕了一下,深刻意识到带着问题场景去学习的重要性。
ROUND1:关于数据库中最常见的读写和存储引发的一系列乱学
一开始我们使用read/write,后来有了pread/pwrite,再后来有了readv/writev/preadv/pwritev,现在还有了io-uring。同时还有在SSD上的顺序读写上下功夫,NVMe协议的SSD具有多队列加速读写,虚拟化qemu-kvm还有virtio/vhost的解决方案,配合SPDK协议用用户态设备驱动避免频繁的内核上下文切换以及轮询等优化将磁盘读写能够打到几千万/s的IOPS(Reactor+减少拷贝+减少上下文切换+批量读写+CPU指令优化+内核旁路+用户态设备驱动等)。还有LSM数据结构方便数据库可以批量写入磁盘,以前每一条单独写入容易引起磁盘随机寻址,但会带来读写放大的问题。
基础:一次read不考虑缓存,会有一次DMA拷贝涉及到从磁盘缓存区取数据到内核缓冲区,一次CPU拷贝从内核缓冲区到用户态缓冲区,同时还有用户态转为内核态的开销(MMU中TLB cache的刷新以及栈上下文现场的切换)。同理一次write逆操作也有用户态转内核态的开销一次CPU拷贝一次DMA拷贝。如果write需要写数据到磁盘,那么操作系统会有page cache,当用户态写回内核态的时候会把page cache中的该页置为PG_DIRTY,最终会由回收线程可能是kswapd周期性回收,也可能是pdflush根据脏页比率周期回收、内存水位阈值时紧急回收(sync则直接写回)。如果write需要写数据到网卡,也就是我们可以用sendfile和mmap和DMA gather/scatter优化的一个场景,到网卡的数据需要放在内核的发包队列,再由DMA将发包队列的数据放到网卡设备的ring上,考虑从磁盘取数据直接封装成网络包发送给网卡的情况,此时是不需要用户态参与对数据的修改操作的,只有一个转发操作,这个时候我们再调用read和write就很鸡肋了,所以也就需要刚才提到的sendfile等的优化降低CPU拷贝,减少读写中写的那次用户态内核态转换的开销,有硬件设备支持的DMA gather/scatter不仅可以降低CPU拷贝还减少了DMA拷贝。上述优化降低了拷贝也经常被称为零拷贝,但其实没有真正的把拷贝降为0。毕竟你在硬件设备上的拷贝并不能由我们应用层来控制。readv和writev其实就是用了iovec进行了批量读写,批量的总是更优解。
io-uring是linux5.x用的加速读写的方案,其中使用了生产者-消费者双队列,一个SQ和一个CQ,已完成的io读写挂回CQ已完成队列,等待下发至磁盘的读写放到SQ提交队列,用户态与内核态可共享这两个队列的内存区域,同时使用head和tail指针实现了无锁操作。对于read/write等系统调用会注册到io-uring上封装成一个SQE,io-uring用了轮询机制加速对请求的下发以及已读完请求的回调。两种轮询sqpoll(询的是提交队列)和iopoll(询块硬件设备)减少传统的中断模式。同时电梯算法对bio进行合并形成一个request,下发至硬件设备的队列加速读写(有的硬件设备是bio-based,有的是request-based,flash-cache实现了一层devicemapper统一管理两种设备从而实现混合盘的场景,比如用SSD和HDD混合或SSD做HDD的缓存加速数据库的读写。)合并的话也就是更好的处理批量,减少磁盘寻址或者写放大的问题。
对于像SPDK(前端配置vhost,后端配置NVMe SSD)和DPDK这种已经变态到要保证每秒千万个4kb的随机读,假设CPU每秒4GHz,处理每个IO得要400个时钟周期约100ns,需要保证数据可以在CPU L1 Cache中读到,同时不允许任何中断,甚至连mmap这种已经很快的mmio多了(每次的mmio写操作CPU会生成一个请求,将其放入一个硬件队列,并通过PCIe总线发往设备,如果CPU产生了太多的MMIO操作填满队列,CPU会被阻塞等待硬件队列资源的释放,对于AHCI等旧的规范每个IO需要多达7次的MMIO,NVMe通过批量操作使得只需要在发送和回收命令时各通过MMIO写一次队列从而减少MMIO开销)都会影响速度,所以像用户态驱动避免kvm到qemu到host kernel的模式切换的复杂度、轮询、无锁、CPU cacheline、数据依赖性提前载入内存、CPU亲和性run-to-completion等等设计都需要进行考量。
关于NVMe协议:
1.从硬件协议理解SSD读写:可以多队列并行的进行Host与SSD的交互,包含admin command和io command。NVMe Sets针对SSD上的资源进行了sets 的划分,每个set的读写是不冲突的,实现更高的Qos,其中Host与SSD之间会通过DoorBell进行通知,Host将数据写至SQ并通过SSD去SQ读指令,SSD完成后将结果写入CQ通过DoorBell通知SSD去获得返回结果。存在写命令时,会通知SSD对应的LBA,SSD需要通过PCIe去内存中读取相应的地址并将对应数据写入磁盘闪存,读操作同理,也是通过LBA。这种时候就需要一种内存和磁盘的地址映射关系,NVMe提供了PRP和SGL两种地址映射的命令,写操作可以根据admin command中的PRP Entry去查找对应的内存地址,对应映射的是固定大小的物理页,io command可以通过SGL映射到内存虚拟地址的任意大小的页面。
2.从读写放大来理解SSD读写:写放大指的是假设只需要写4kb的数据但由于SSD的磁盘设计导致一个块作为擦除单位,当该块中没有空闲空间存储新的4kb数据但该块中存在已失效的数据,此时需要将整个块512kb复制到新的块同时将可擦除数据去除,从而留下新的空间用于存储新的数据。此时为了4kb的写入,在SSD上却写入了512kb,带来了(128?)写入放大。由于SSD的这种特性,对于100%的随机写入,会产生更多的写放大,而对于顺序追加写入,可以很好的降低写放大。同时SSD自身还有对于擦除块的优化比如TRIM,会启动一个后台线程及时地统一擦除不需要的数据。SSD不像以前的机械硬盘需要磁头寻道,机械硬盘是可以直接擦除对应的数据的而SSD只能覆写一个扇区,还只能擦除块将旧数据复制出再带着新数据一起重新写入。
LSM数据结构常用于SSD硬盘,因为在最后一层的SSTable上写入SSD是有序的,对于LSM常用于kv数据库的索引,将原先的B+树索引拆分成几个小树,内存中的索引是一颗小树,最终写在磁盘上的会将小树合并成一个大的B+树,所以个人理解应该是很适合那些需要经常访问最近的数据以及写入大于检索的操作(检索需要在SSTable上查找出数据,没有找到自动进入下一层SSTable,会有读放大的问题)。对于传统的B+树(相比于LSM读数据比较有优势)如果表的数据过大每个索引之间的距离会很大,可能会在不同的页上,此时如果追加数据并且数据是随机写,此时需要不断的读出相应索引并对B+树不同地方进行频繁的分裂,而对于LSM树只需要最新的数据写入MemTable之后等待后台线程合并成有序的就好了。通过合并上层的随机读写使得最终写入磁盘的数据是批量有序的,数据写入进内存MemTable(使用红黑树、skiplist等结构保证MemTable有序以及方便查找),当MemTable数据到达一定量级或一定时间开始启动合并线程将数据合并至下一层的SSTable(使用BloomFilter进行高速查找),已写入的数据不会被再次更新,需要更新的数据会追加至新的文件,这样的冗余数据会有后台的合并线程进行合并更新并继续追加至新的SSTable,因此每层的SSTable是不变的。???????
|