SPDK的reduce块压缩方案基于使用ssd存储的压缩块,如果不是ssd磁盘也没必要使用压缩功能。压缩过程会产生元数据,元数据也需要持久化保存。该元数据用于记录逻辑空间到ssd盘上存储压缩数据的映射。数据压缩功能对外体现为一个压缩的块设备bdev,该bdev和一般的bdev用法并无二致,用户的io通过这个压缩bdev后数据会被压缩,然后写入后端存储设备中。
压缩bdev的后存储必须是支持精简配置的存储设备,如果不支持精简配置即使经过压缩效果也不会很明显
后端存储设备的大小必须适合最坏的情况,即没有数据可以压缩。在这种情况下,后备存储设备的大小将与压缩块设备相同。块设备后端一般是使用虚化的存储池,存储池内资源实现共享,资源池可以是传统的san形式或者分布式资源池,该数据压缩算法为了保证原子性不会覆盖写数据,会不停的向后追加写。这种技术已经普遍应用于存储中。另外在更新关联的元数据之前,需要一些额外的后端存储来临时存储要进行写入的数据
为了获得最佳的NVMe性能,从后端存储设备将以4KB为粒度进行分配、读取和写入数据。这些4KB的单元称为“后端IO单元”。它们的索引从0到N-1,索引称为“后端IO单元索引”。
压缩块设备bdev以chunk为单位压缩和解压数据,chunk是至少两个后端IO单元的倍数。每个chunk内后端IO单元数决定了块大小,这是在创建压缩块设备时指定。一个块消耗的后端IO单元数量介于1和块中总共的io单元数之间。例如,一个16KB的chunk可能消耗1、2、3或4个后端IO单元。消耗IO单元的数量取决于chunk能够被压缩的程度。chunk和其相关联的磁盘块的映射关系存储在元数据中。每个chunk映射由N个64位值组成,其中N是chunk中支持IO单元的最大数量。每个64位值对应于一个后端IO单元索引。特殊值(例如2^64-1)表明后端存储io单元没有压缩。分配的chunk映射数等于压缩块设备的大小除以它的chunk大小,再加上一些额外的chunk映射数。这些额外的chunk映射用于确保写操作的原子性,一开始,所有的chunk映射表示“空闲chunk映射列表”。
最后,压缩块设备的逻辑视图由“逻辑映射”表示。逻辑映射是将压缩块设备中的块偏移量映射到相应的chunk映射。逻辑映射中的每个条目都是一个64位值,表示关联的chunk映射。如果没有关联的chunk映射,则使用一个特殊值(UINT64_MAX)。通过将字节偏移量除以chunk大小来获得索引来确定映射,该索引用作chunk映射项数组的数组索引。一开始,逻辑映射中的所有条目都没有关联的块映射。注意,虽然对后端存储设备的访问以4KB单元为粒度,但是逻辑视图可能允许4KB或512字节的访问。
为了说明这个算法,我们将使用一个实际的例子在一个非常小的规模。
压缩块设备的大小为64KB,chunk大小为16KB。这将实现下列目标:
1、后端存储由一个80KB的精简配置逻辑卷组成。这相当于64KB的压缩块设备在最坏的压缩情况下,还需要额外的16KB来处理额外的写操作。
2、“空闲IO单元列表”将由索引0到19(包括)组成。这些代表了后端存储器中的20个4KB 的IO单元。
3、“chunk映射”的大小为32字节。每个块有4个IO单元(16KB / 4KB),每个IO单元索引有8B (64b)。
4、5个chunk映射占用160B的内存。这对应于压缩块设备中的4个chunk的4个chunk映射(64KB / 16KB),加上一个额外的块映射,用于覆盖现有块。
5、“空闲chunk映射链表”将由索引0到4(包括)组成。它们表示分配的5个chunk映射。
6、“逻辑映射”将占用32B的内存中。这对应于压缩块设备中chunk的4个条目,每个条目8B (64b)。
在这些示例中,值“X”表示上面描述的特殊值(2^64-1)。
初始状态:
Write 16KB at Offset 32KB
1、在逻辑映射中找到对应的索引。偏移量32KB除以chunk大小(16KB)等于2。
2、逻辑映射中的第2项是“X”。这意味着这16KB还没有被写入。
3、在内存中分配一个16KB的缓冲区
4、将传入的16KB数据压缩到这个分配的缓冲区中
5、假设该数据压缩到6KB。这需要2个4KB的后备IO单元。
6、从空闲的IO单元列表中分配2个io单元(0和1)。始终使用空闲的IO单元列表中编号最低的条目。
7、将6KB的数据写入备份IO单元0和1。
8、从空闲chunk映射列表中分配一个chunk映射(0)。
9、将(0,1,X, X)写入chunk映射。这表示只有2个后备IO单元用于存储16KB的数据。
10、将chunk映射索引写入逻辑映射中的条目2。
Write 4KB at Offset 8KB
1、在逻辑映射中找到对应的索引。偏移量8KB除以chunk大小为0。
2、逻辑映射中的第0项是“X”。这意味着这16KB还没有被写入。
3、写操作不是针对整个16KB块,我们一样也要为源数据分配一个16KB块大小的缓冲区。
4、将传入的4KB数据复制到这个16KB缓冲区的8KB偏移量。将剩余的缓冲区归零。
5、分配一个16KB的目标缓冲区。
6、将16KB的源数据缓冲区压缩到16KB的目标缓冲区
7、假设该数据压缩到3KB。这需要1个 4KB的IO单元。
8、从空闲的IO单元列表中分配1个(2)。
9、将3KB的数据写入IO单元。
10、从空闲chunk映射列表中分配一个块映射(1)。
11、写(X,X,2, X)到chunk映射。
12、将chunk映射索引写入逻辑映射中的条目0。
Read 16KB at Offset 16KB
1、偏移量16KB映射到逻辑映射中的索引1。
2、逻辑映射中的条目1是“X”。这意味着这16KB还没有被写入。
3、由于没有数据被写入这个chunk,所以返回所有的0来满足读取的I/O。
Write 4KB at Offset 4KB
1、偏移量4KB映射到逻辑映射中的索引0。
2、逻辑映射中的条目0是“1”。因为本次写没有覆盖整个chunk,所以执行读改写操作。
3、块映射1仅有一个IO单元(2)。分配一个16KB的缓冲区并将块2读入其中。请注意,分配的是16KB而不是4KB,这样我们就可以重用这个缓冲区来保存稍后将写入磁盘的压缩数据。
4、为此块的未压缩数据分配16KB缓冲区。将压缩数据缓冲区中的数据解压缩到此缓冲区中。
5、将传入的4KB数据复制到未压缩数据缓冲区的4KB偏移量。
6、将16KB的未压缩数据缓冲区压缩到压缩数据缓冲区中。
7、假设这个数据压缩到5KB。这需要2个4KB的IO单元。
8、从空闲IO单元列表中分配块3和4。
9、将5KB的数据写入块3和块4。
10、从空闲块映射列表中分配chunk映射2。
11、将(3、4、X、X)写入chunk映射2。注意,此时逻辑映射不引用chunk映射。如果此时出现电源故障,则此chunk的先前数据仍然完全有效。
12、将chunk映射2写入逻辑映射中的条目0。
13、chunk映射1返回到空闲chunk映射列表。
14、空闲IO单元2返回到空闲IO单元列表。
跨多个chunk的情况
跨越chunk边界的操作在逻辑上被分割为多个操作,每个操作都与单个chunk关联。
示例:20KB写入,偏移量为4KB
在这种情况下,写操作被分割为一个位于4KB偏移位置的12KB写操作(只影响逻辑映射中的chunk 0)和一个位于16KB偏移位置的8KB写操作(只影响逻辑映射中的chunk 1)。每个写操作都使用上述算法独立处理。直到两个操作都完成了,才会完成20KB的写操作。
Unmap
对整chunk的取消映射操作是通过从逻辑映射中删除chunk映射条目(如果有的话)来实现的。chunk映射返回到空闲chunk映射列表,与chunk映射关联的任何IO单元返回到空闲IO单元列表。
只影响chunk的一部分的取消映射操作可以被视为向chunk的那个区域写入0。如果通过多个操作取消映射整个chunk,则可以通过未压缩的全等于零进行检测。发生这种情况时,可能会从逻辑映射中删除chunk映射条目。
在未映射整个chunk之后,对chunk的后续读将返回0。这类似于上面的“以16KB偏移量读取16KB”示例。
Write Zeroes Operations
写零操作的处理方式与unmap操作类似。如果写零操作覆盖整个chunk,我们可以在逻辑映射中完全删除chunk的条目。然后对该chunk的后续读返回0。
Restart
使用libreduce的应用程序重新启动时,它将重新加载压缩卷。
当压缩卷被重新加载时,通过遍历逻辑映射重新构建空闲chunk映射列表和空闲IO单元列表。逻辑映射将只指向有效的chunk映射,并且有效chunk映射将只指向有效的IO单元。任何未引用的chunk映射和IO单元都进入各自的空闲列表。
这确保了如果系统在写操作的中间崩溃——即在chunk映射更新期间或之后,但在它被写入逻辑映射之前可以保证数据一致性。
Chunk上的并发处理
实现必须小心处理同一chunk上的重叠操作。例如,操作1向chunk A写入一些数据,同时操作2也向chunk A写入一些数据。在这种情况下,操作2应该在操作1完成后才开始。
精简卷
后端存储必须是精简卷以实现压缩。这个算法将总是使用在后端存储设备上距离偏移量0最近的IO单元。这确保了即使后备存储设备的大小可能与压缩卷的大小类似,后端存储设备的存储空间实际上不会分配,直到真正需要IO单元
压缩源码分析
首先是创建压缩块设备,压缩块设备是在bdev的设备上在套了一层壳,压缩bdev的后端可能是ssd或者其他bdev设备例如iscsidev等。
创建压缩bdev的大概流程
create_compress_bdev--à vbdev_init_reduce--à spdk_reduce_vol_init--àspdk_reduce_vol_init--à_comp_reduce_writev--à _init_write_path_cpl--à_init_write_super_cpl--àvbdev_reduce_init_cb--àvbdev_compress_claim--àspdk_bdev_register
这里只列出了主要的函数,需要重点说明下在vbdev_compress_claim中对封装的comp_bdev块设备设置执行函数fn_table等,如果对内核通用块层熟悉的话很容易就发现这个vbdev_compress_claim函数的功能非常类似内核通用块层如何注册块设备的操作。不熟悉也不要紧,这些都是套路,按照固定套路来就可以了。
数据压缩
先看下数据压缩的主要函数流程:
vbdev_compress_submit_request--à_comp_bdev_io_submit--àspdk_reduce_vol_writev--à_start_writev_request--à_reduce_vol_compress_chunk--à_write_compress_done-à_reduce_vol_write_chunk--à_issue_backing_ops--à_comp_reduce_writev
在_reduce_vol_compress_chunk函数里面真正开始压缩,该函数也是调用了之前后端块设备上注册的压缩函数。
static void
_reduce_vol_compress_chunk(struct spdk_reduce_vol_request *req, reduce_request_fn next_fn)
{
struct spdk_reduce_vol *vol = req->vol;
req->backing_cb_args.cb_fn = next_fn;
req->backing_cb_args.cb_arg = req;
req->comp_buf_iov[0].iov_base = req->comp_buf;
req->comp_buf_iov[0].iov_len = vol->params.chunk_size;
req->decomp_buf_iov[0].iov_base = req->decomp_buf;
req->decomp_buf_iov[0].iov_len = vol->params.chunk_size;
vol->backing_dev->compress(vol->backing_dev,
req->decomp_buf_iov, 1, req->comp_buf_iov, 1,
&req->backing_cb_args);
}
static void
_comp_reduce_compress(struct spdk_reduce_backing_dev *dev,
struct iovec *src_iovs, int src_iovcnt,
struct iovec *dst_iovs, int dst_iovcnt,
struct spdk_reduce_vol_cb_args *cb_arg)
{
int rc;
rc = _compress_operation(dev, src_iovs, src_iovcnt, dst_iovs, dst_iovcnt, true, cb_arg);
if (rc) {
SPDK_ERRLOG("with compress operation code %d (%s)\n", rc, spdk_strerror(-rc));
cb_arg->cb_fn(cb_arg->cb_arg, rc);
}
}
调用压缩接口,真正处理压缩、解压缩是DPDK的compressdev。总的来说spdk的压缩算法算是中规中矩,压缩后的数据按照4K对齐。现在市面上的很多存储产品中的压缩是非对齐存放,压缩后的数据长度是多长就占用多少物理空间。