2021SC@SDUSC
基于内容长度可变分块
滚动哈希与拉宾指纹
滚动哈希
考虑解决字符串匹配问题。一种方法是利用字符串哈希值进行匹配。已知模式串长度为 n n n,那么我们可以依次截取匹配串中长为 n n n的子串计算哈希,然后与模式串的哈希进行比对,若相等则得到一次匹配。在极大概率下,哈希碰撞到的子串与模式串相同。
然后用算法实现。在已经计算过 s i … i + n \small s_{i\dots i+n} si…i+n哈希的前提下,如果要计算 s i + 1 … i + n + 1 \small s_{i+1\dots i+n+1} si+1…i+n+1的哈希,若采用暴力方法,则需要扫描整个子串,其复杂度与暴力匹配相同,显然需要继续优化。
考虑使用长为 n n n的滑动窗口解决此问题:每扫过一个字符,从原哈希中删去旧字符的影响,然后加上新字符的影响,得到新哈希:
若采用此方法,则需要设计一个哈希函数支持动态地增删首尾字符的影响。可以考虑使用素域 M \small M M上的多项式映射,其中 n \small n n为窗口长度:(注意必须是素数,这样值域上各个值的概率才几乎相等)
h a s h ( s i … i + n − 1 ) = ( s i a n − 1 + s i + 1 a n − 2 + ⋯ + s i + n − 1 a + s i + n − 1 ) ( m o d M ) hash(s_{i\dots i+n-1})=(s_{i}a^{n-1}+s_{i+1}a^{n-2}+\dots +s_{i+n-1}a+s_{i+n-1})_{\pmod{M}} hash(si…i+n−1)=(sian−1+si+1an−2+⋯+si+n−1a+si+n−1)(modM)
那么易推知:
h a s h ( s i + 1 … i + n ) = ( s i + 1 a n − 1 + s i + 2 a n − 2 + ⋯ + s i + n − 1 a + s i + n ) ( m o d M ) hash(s_{i+1\dots i+n})=(s_{i+1}a^{n-1}+s_{i+2}a^{n-2}+\dots +s_{i+n-1}a+s_{i+n})_{\pmod{M}} hash(si+1…i+n)=(si+1an−1+si+2an−2+⋯+si+n−1a+si+n)(modM)
两式的递推关系如下:
h a s h ( s i + 1 … i + n ) = ( a ⋅ h a s h ( s i … i + n − 1 ) ( m o d M ) − s i a n + s i + n ) ( m o d M ) hash(s_{i+1\dots i+n})=(a·hash(s_{i\dots i+n-1})_{\pmod{M}}-s_ia^{n}+s_{i+n})_{\pmod{M}} hash(si+1…i+n)=(a⋅hash(si…i+n−1)(modM)−sian+si+n)(modM)
因此就可以以 O ( 1 ) \small O(1) O(1)的复杂度得到下一个窗口的哈希值。
# https://www.infoarena.ro/blog/rolling-hash
# a is a constant
an = 1 # a^n
rolling_hash = 0
for i in range(0, n):
rolling_hash = (rolling_hash * a + S[i]) % MOD
an = (an * a) % MOD
if rolling_hash == hash_p:
# match
for i in range(1, m - n + 1):
rolling_hash = (rolling_hash * a + S[i + n - 1] - an * S[i - 1]) % MOD
if rolling_hash == hash_p:
# match
其中 a n \small a^n an是常数,可以进行预处理。
拉宾指纹
拉宾指纹也是一个多项式哈希映射,但它并非在素域 M \small M M上,且映射结果不是一个值。拉宾指纹使用的是有限域 G F ( 2 ) \small GF(2) GF(2)上的多项式,例如: f ( x ) = x 3 + x 2 + 1 f(x)=x^3+x^2+1 f(x)=x3+x2+1。这个多项式可以使用二进制表示为 1101 \small 1101 1101。
之所以使用这样的多项式表示,是因为相比传统的值运算, G F ( 2 ) \small GF(2) GF(2)多项式运算更简单:加减都是异或,这样就完全不需要考虑进位的问题。并且多项式的乘除性质与整数相似。不过就算不需要考虑进位,乘法和除法(求余)也只能以 k \small k k的复杂度完成( k \small k k为多项式的最高次幂)。
拉宾指纹的哈希函数如下:(和素域类似,模需要是个不可约多项式)
h a s h ( s i … i + n ) = ( s i a ( x ) n + s i + 1 a ( x ) n − 1 + ⋯ + s i + n − 1 a ( x ) + s i + n ) ( m o d M ( x ) ) hash(s_{i\dots i+n})=(s_{i}a(x)^n+s_{i+1}a(x)^{n-1}+\dots +s_{i+n-1}a(x)+s_{i+n})_{\pmod{M(x)}} hash(si…i+n)=(sia(x)n+si+1a(x)n−1+⋯+si+n−1a(x)+si+n)(modM(x))
递推式如下:
h a s h ( s i + 1 … i + n ) = ( ( a ( x ) ⋅ h a s h ( s i … i + n − 1 ) ) ( m o d M ( x ) ) − s i a n ( x ) + s i + n ) ( m o d M ( x ) ) hash(s_{i+1\dots i+n})=((a(x)·hash(s_{i\dots i+n-1}))_{\pmod{M(x)}}-s_ia^{n}(x)+s_{i+n})_{\pmod{M(x)}} hash(si+1…i+n)=((a(x)⋅hash(si…i+n−1))(modM(x))−sian(x)+si+n)(modM(x))
实现
首先选择有限域 G F ( 2 ) \small GF(2) GF(2)多项式的模 p ( x ) p(x) p(x),要求是个不可约多项式。这里选取了一个 k = 64 \small k=64 k=64的多项式。
static u_int64_t poly = 0xbfe6b8a5bf378d83LL;
考虑滑动窗口每次滑动一个字节,则可以令 a ( x ) = x 8 a(x)=x^8 a(x)=x8。
根据拉宾指纹递推式,若直接运算,则需要进行 2 \small 2 2次乘法和 1 \small 1 1次求余,其复杂度为 O ( k ) \small O(k) O(k)。虽说 k \small k k是个常数,但如果优化了 k k k,再结合 G F ( 2 ) \small GF(2) GF(2)多项式无需进位的性质,拉宾指纹的性能甚至会超过传统哈希。
将递推式分为三个部分:不可预处理的乘法部分 h a s h ( s i … i + n ) ⋅ a ( x ) \small hash(s_{i\dots i+n})·a(x) hash(si…i+n)⋅a(x)、可预处理的乘法部分 s i a n + 1 s_ia^{n+1} sian+1、加法部分 s i + n + 1 \small s_{i+n+1} si+n+1。第一个部分不可预处理,因为哈希值是不可预知的;第二个部分可预处理,因为 s i s_i si作为一个字节只有 2 8 \small 2^8 28种取值,且 a n ( x ) a^n(x) an(x)也是一个常量;最后一个部分只需一个异或运算,可忽略。
首先考虑第二部分。先实现多项式乘除法,然后易求得
a
n
(
x
)
a^n(x)
an(x),接着枚举
s
i
s_i
si的值并求得
s
i
a
n
(
x
)
s_ia^n(x)
sian(x),最后将结果缓存到
U
\small U
U表中。对幂运算部分继续优化:假如已知处理第一部分的算法为MUL(p, a)
,其复杂度为
O
(
1
)
\small O(1)
O(1),那么幂运算就等价于p=MUL(p, a)
进行
n
n
n次,这样就优化掉了常数
k
k
k。
然后考虑优化第一部分,我们需要计算的是 ( p ( x ) ⋅ a ( x ) ) ( m o d M ( x ) ) (\small p(x)·a(x))_{\pmod{M(x)}} (p(x)⋅a(x))(modM(x))。优化过程如下:
-
对乘法优化
发现每次都是乘以 a ( x ) = x 8 \small a(x)=x^8 a(x)=x8,若以二进制表示,就是左移八位。左移复杂度为 O ( 1 ) \small O(1) O(1)。
-
对求余优化
令 g ( x ) \small g(x) g(x)等于 p ( x ) \small p(x) p(x)的最高次项 x s h i f t x x^{shiftx} xshiftx,则必有 g ( x ) ≤ p ( x ) \small g(x)\le p(x) g(x)≤p(x)。原式可改写为:
( ( p − p ( m o d g / a ) + p ( m o d g / a ) ) ⋅ a ) ( m o d M ) ((p-p_{\pmod{g/a}}+p_{\pmod{g/a}})·a)_{\pmod{M}} ((p−p(modg/a)+p(modg/a))⋅a)(modM)
对其进行变换:
= ( p ( m o d g / a ) ⋅ a ) ( m o d g / a ) + ( ( p − p ( m o d g / a ) ) ⋅ a ) ( m o d M ) = ( ( p − ( p − p ( m o d g / a ) ) ) ⋅ a ) ( m o d g / a ) + ( ( p − p ( m o d g / a ) ) ⋅ a ) ( m o d M ) \begin{array}{ll}=(p_{\pmod{g/a}}·a)_{\pmod{g/a}}+((p-p_{\pmod{g/a}})·a)_{\pmod{M}}\\=((p-(p-p_{\pmod{g/a}}))·a)_{\pmod{g/a}}+((p-p_{\pmod{g/a}})·a)_{\pmod{M}}\end{array} =(p(modg/a)⋅a)(modg/a)+((p−p(modg/a))⋅a)(modM)=((p−(p−p(modg/a)))⋅a)(modg/a)+((p−p(modg/a))⋅a)(modM)
注意到第一个子式一定小于 g g g,因此一定小于 a a a。令 j ⋅ ( g a ) = p − p ( m o d g / a ) \small j·(\frac{g}{a})=p-p_{\pmod{g/a}} j⋅(ag)=p−p(modg/a),则上式可改写为:
( ( p − j ⋅ ( g a ) ) ⋅ a ) + ( g ⋅ j ) ( m o d M ) ((p-j·(\frac{g}{a}))·a)+(g·j)_{\pmod{M}} ((p−j⋅(ag))⋅a)+(g⋅j)(modM)
其中 g / a = x s h i f t x − 8 \small g/a=x^{shiftx-8} g/a=xshiftx−8,因此 p − p ( m o d g / a ) p-p_{\pmod{g/a}} p−p(modg/a)就是保留 p p p的高八位其余位填零;而 j j j就是 p p p的高八位。将各个式子改写为二进制形式,并使用位运算,则上式等价于:
(p^j)<<8+g*j%(xshift-8)
继续改写,将 p p p提出来:
(p<<8) ^ (g*j%(xshift-8)|j<<(xshift+8))
所以预处理g*j%(xshift-8)|j<<(xshift+8)
,就可以以
O
(
1
)
\small O(1)
O(1)计算第一部分。将前式缓存至
T
\small T
T,并将其与第三部分结合,得到最终代码:
// (p * a + m) % M
(p<<8|m)^T[p>>xshift-8]
-
相关源码
int xshift, shift static inline char fls64(u_int64_t v); // 获取最高位的1在哪一位 u_int64_t polymod(u_int64_t nh, u_int64_t nl, u_int64_t d); // (nh<<64|nl) % d void polymult(u_int64_t *php, u_int64_t *plp, u_int64_t x, u_int64_t y); // x * y = (php<<64|plp) static u_int64_t append8(u_int64_t p, u_char m) { return ((p << 8) | m) ^ T[p >> shift]; } static void calcT (u_int64_t poly) { // T int j = 0; xshift = fls64(poly) - 1; shift = xshift - 8; u_int64_t T1 = polymod(0, INT64 (1) << xshift, poly); for (j = 0; j < 256; j++) { T[j] = polymmult(j, T1, poly) | ((u_int64_t) j << xshift); } } static void calcU(int size) // U { int i; u_int64_t sizeshift = 1; for (i = 1; i < size; i++) sizeshift = append8(sizeshift, 0); for (i = 0; i < 256; i++) U[i] = polymmult(i, sizeshift, poly); } void rabin_init(int len) { // 初始化 calcT(poly); calcU(len); } unsigned int rabin_checksum(char *buf, int len) { // 首次计算,窗口长度为len int i; unsigned int sum = 0; for (i = 0; i < len; ++i) { sum = rabin_rolling_checksum (sum, len, 0, buf[i]); } return sum; } unsigned int rabin_rolling_checksum(unsigned int csum, int len, char c1, char c2) { // 滚动计算 return append8(csum ^ U[(unsigned char)c1], c2); // (csum*a+c2-a^len*c1)%poly }
在git中也用到了此技术,源码位于:git/diff-delta,可作参考学习
CDC
问题的引入
-
为什么要分块
对于本地文件系统,分块用于解决孔问题、方便操作系统管理空间、降低扫描次数等。而对于网络文件系统更是如此,分块后只需同步那些被修改的块,比重新上传整个文件更有效率。
-
定长分块
现在对文件进行定长分块,假设文件中的内容为
abcdefg
,每四个字节分为一块,则分块后为abcd|efg
。假如在头部加入了一个字符,内容变更为0abcdefg
,则分块后为0abc|defg
,发现两个块和之前完全不一样。这意味着如果要向网络文件系统同步此次修改,则需重新上传两个块。 -
基于内容可变长度的分块
假如我们基于内容进行分块,以
d
作为断点,在d
后产生断点,那么此时分块就变成了0abcd|efg
。发现这样分块与之前的分块仅一个块不一致,也就是说只需重新上传这个不一致的块,相比定长分块效率大大提高。 -
缺点
有极低的概率出现多个短块。如
dddd
以d
断开,则会得到d|d|d|d
。此情况会导致块数过多,因而变得难以维护。
滚动哈希与断点
显然不能总是以相同的内容作为断点,例如若文件内容为dd...d
,则分块后为d|d|...|d|
。每个块仅包含一个字符,非常浪费空间,更不方便管理,违背了分块的初衷。
我们希望以某种概率分布选择断点使得各个块有一个平均大小,同时又保证断点的某种属性相同。发现哈希恰好能满足这两个性质。
哈希是伪随机的,对于 b \small b b位的二进制哈希值,其出现的概率都是 2 − b 2^{-b} 2−b。这时候考虑此前的滚动哈希,假设滑动窗口每滑动一次产生的哈希都是随机的,那么上一次碰撞到下一次碰撞的长度即块长恰好呈几何分布:
p d f ( l ) = ( 1 − 2 − b ) l − 1 ⋅ 2 − b \mathit{pdf}(l)=(1-2^{-b})^{l-1}·2^{-b} pdf(l)=(1−2−b)l−1⋅2−b
那么期望的块长就是 2 b 2^b 2b。
上下限
虽然哈希使得块长有预期的平均,但这是基于随机的数据,仍然存在特殊的数据使得特殊的情况出现,而一旦这种情况出现,将大大降低分块的效率。因此采用了一个折中的方案,限制块长的最小值与最大值。当以最小块长或最大块长分块时(非变长分块),块的性质类似于定长分块。
若平均块长为 2 20 2^{20} 220字节(1MB),最小块长为 2 18 2^{18} 218字节(256KB),最大块长为 2 22 2^{22} 222字节(4MB),可以通过几何分布计算非变长分块出现的概率:
P ( l ≤ 2 18 ) + P ( l ≥ 2 22 ) = ∑ i = 1 2 18 p d f ( i ) + ∑ i = 2 22 ∞ p d f ( i ) = 1 − ∑ i = 2 18 2 22 p d f ( i ) ≈ 0.239515 P(l\le 2^{18})+P(l\ge 2^{22})=\sum_{i=1}^{2^{18}}\mathit{pdf}(i)+\sum_{i=2^{22}}^{\infty}\mathit{pdf}(i)=1-\sum_{i=2^{18}}^{2^{22}}\mathit{pdf}(i)\approx 0.239515 P(l≤218)+P(l≥222)=i=1∑218pdf(i)+i=222∑∞pdf(i)=1−i=218∑222pdf(i)≈0.239515
也就是说断点的命中率约为 p ′ = 76 % p'=\small 76\% p′=76%。假设数据中一个子序列 s s s在修改前后不变,其中包含了 k k k个断点,那么可以通过几何分布算出CDC在第 i i i个断点首次命中的概率为:
p d f ( i ) = p ′ ( 1 − p ′ ) i − 1 \mathit{pdf}(i)=p'(1-p')^{i-1} pdf(i)=p′(1−p′)i−1
因此期望在第 1 p ′ ≈ 1.314950 \frac{1}{p'}\approx 1.314950 p′1≈1.314950个断点命中。由于断点间隔的期望为 2 20 2^{20} 220,那么对应的,未命中的数据长度期望则是 2 b p ′ ≈ 1.3 \frac{2^b}{p'}\approx 1.3 p′2b≈1.3MB。一旦命中断点,后面的分块情况完全相同。也就是说在这个子序列中平均只有约1.3MB的数据被重新以最大长度或最小长度分块了,其余的块仍然不变。
(实际项目中定义平均块长为8MB,最小块长为6MB,最大块长为10MB。由此求得命中率约为 0.185862 \small 0.185862 0.185862;其中块长为6MB的概率约为 0.527633 \small 0.527633 0.527633,为10MB的概率约为 0.286505 \small 0.286505 0.286505。虽然命中率低,但重分块的期望也仅 2 b p ′ = 8 0.185862 ≈ 43 \frac{2^b}{p'}=\frac{8}{0.185862}\approx 43 p′2b=0.1858628≈43MB,这对于G甚至T级别的文件来讲微不足道)
实现
/*
最小块长略与文件缓冲区略,主要看以下分块部分
*/
if (cur < block_min_sz - 1) // 最小块长
cur = block_min_sz - 1;
while (cur < tail) { // 一直扫描直到达到tail
fingerprint = (cur == block_min_sz - 1) ? // 计算指纹
finger(buf + cur - BLOCK_WIN_SZ + 1, BLOCK_WIN_SZ) : // 首次计算
rolling_finger (fingerprint, BLOCK_WIN_SZ,
*(buf+cur-BLOCK_WIN_SZ), *(buf + cur)); // 滚动计算
if (((fingerprint & block_mask) == ((BREAK_VALUE & block_mask)))
|| cur + 1 >= file_descr->block_max_sz) // 碰撞,找到断点
{
if (file_descr->block_nr == file_descr->max_block_nr) {
seaf_warning ("Block id array is not large enough, bail out.\n");
free (buf);
return -1;
}
gint64 idx_size = cur + 1;
WRITE_CDC_BLOCK (cur + 1, write_data); // 将buf[0..cur]写入块
if (indexed) // 记录已处理的长度
*indexed += idx_size;
break;
} else { // 继续寻找
cur ++;
}
}
相关函数释义
具体注释详见:https://github.com/poi0qwe/seafile-server-learn/tree/main/common/cdc
-
将块写入文件 / WriteblockFunc
static int default_write_chunk (CDCDescriptor *chunk_descr) // 默认写块文件的方法 { char filename[NAME_MAX_SZ]; char chksum_str[CHECKSUM_LENGTH *2 + 1]; int fd_chunk, ret; memset(chksum_str, 0, sizeof(chksum_str)); rawdata_to_hex (chunk_descr->checksum, chksum_str, CHECKSUM_LENGTH); // 将checksum转为HEX串 snprintf (filename, NAME_MAX_SZ, "./%s", chksum_str); // 设置文件名为checksum的HEX串,并限定其最大长度为`NAME_MAX_SZ-1` fd_chunk = g_open (filename, O_RDWR | O_CREAT | O_BINARY, 0644); // 创建文件,并写文件 if (fd_chunk < 0) // 打开文件失败 return -1; ret = writen (fd_chunk, chunk_descr->block_buf, chunk_descr->len); // 将缓冲写入文件,写n个 close (fd_chunk); // 关闭文件 return ret; // 返回写的结果 }
-
初始化
// 给定文件,初始化它的文件分块信息(只用到了文件大小) static int init_cdc_file_descriptor (int fd, uint64_t file_size, CDCFileDescriptor *file_descr) { int max_block_nr = 0; int block_min_sz = 0; file_descr->block_nr = 0; // 实际块数 // 若为空,则设置默认值 if (file_descr->block_min_sz <= 0) file_descr->block_min_sz = BLOCK_MIN_SZ; if (file_descr->block_max_sz <= 0) file_descr->block_max_sz = BLOCK_MAX_SZ; if (file_descr->block_sz <= 0) file_descr->block_sz = BLOCK_SZ; if (file_descr->write_block == NULL) file_descr->write_block = (WriteblockFunc)default_write_chunk; // 默认写块文件的方法 block_min_sz = file_descr->block_min_sz; // 块的最小大小 max_block_nr = ((file_size + block_min_sz - 1) / block_min_sz); // 计算最大块数(极值) file_descr->blk_sha1s = (uint8_t *)calloc (sizeof(uint8_t), max_block_nr * CHECKSUM_LENGTH); // 按照最大块数申请空间 file_descr->max_block_nr = max_block_nr; return 0; }
-
写数据与校验和
// 写一个块(block_sz是块的大小,write_data表示是否写入硬盘) #define WRITE_CDC_BLOCK(block_sz, write_data) \ do { \ int _block_sz = (block_sz); \ chunk_descr.len = _block_sz; \ // 设置缓冲长度 chunk_descr.offset = offset; \ // 设置偏移 ret = file_descr->write_block (file_descr->repo_id, \ // 写文件方法 file_descr->version, \ &chunk_descr, \ crypt, \ chunk_descr.checksum, \ (write_data)); \ if (ret < 0) { \ // 写失败 free (buf); \ g_warning ("CDC: failed to write chunk.\n"); \ return -1; \ } \ memcpy (file_descr->blk_sha1s + \ file_descr->block_nr * CHECKSUM_LENGTH, \ chunk_descr.checksum, CHECKSUM_LENGTH); \ // 记录块的SHA1 SHA1_Update (&file_ctx, chunk_descr.checksum, 20); \ // 更新SHA1 file_descr->block_nr++; \ // 记录块数 offset += _block_sz; \ // 记录偏移 \ memmove (buf, buf + _block_sz, tail - _block_sz); \ // 更新buf tail = tail - _block_sz; \ cur = 0; \ // 移动指针 }while(0); // 表示执行一次
-
分块主函数
int file_chunk_cdc(int fd_src, // 文件标识符 CDCFileDescriptor *file_descr, // 文件分块信息,新建或更新 SeafileCrypt *crypt, // 加密信息 gboolean write_data, // 是否写入硬盘 gint64 *indexed) // 块索引 // 注释详见:https://github.com/poi0qwe/seafile-server-learn/blob/main/common/cdc/cdc.c