【山大智云】SeafileServer源码分析之CDC(基于内容长度可变分块)

2021SC@SDUSC

基于内容长度可变分块

滚动哈希与拉宾指纹

滚动哈希

考虑解决字符串匹配问题。一种方法是利用字符串哈希值进行匹配。已知模式串长度为 n n n,那么我们可以依次截取匹配串中长为 n n n的子串计算哈希,然后与模式串的哈希进行比对,若相等则得到一次匹配。在极大概率下,哈希碰撞到的子串与模式串相同。

然后用算法实现。在已经计算过 s i … i + n \small s_{i\dots i+n} sii+n哈希的前提下,如果要计算 s i + 1 … i + n + 1 \small s_{i+1\dots i+n+1} si+1i+n+1的哈希,若采用暴力方法,则需要扫描整个子串,其复杂度与暴力匹配相同,显然需要继续优化。

拉宾-卡普算法:https://zh.wikipedia.org/wiki/拉宾-卡普算法

考虑使用长为 n n n的滑动窗口解决此问题:每扫过一个字符,从原哈希中删去旧字符的影响,然后加上新字符的影响,得到新哈希:

https://infoarena.ro/blog/rolling-hash?action=download&file=image10.png&safe_only=true

若采用此方法,则需要设计一个哈希函数支持动态地增删首尾字符的影响。可以考虑使用素域 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(sii+n1)=(sian1+si+1an2++si+n1a+si+n1)(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+1i+n)=(si+1an1+si+2an2++si+n1a+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+1i+n)=(ahash(sii+n1)(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是常数,可以进行预处理。

拉宾指纹

拉宾指纹:https://zh.wikipedia.org/wiki/拉宾指纹

拉宾指纹也是一个多项式哈希映射,但它并非在素域 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(sii+n)=(sia(x)n+si+1a(x)n1++si+n1a(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+1i+n)=((a(x)hash(sii+n1))(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(sii+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))。优化过程如下:

  1. 对乘法优化

    发现每次都是乘以 a ( x ) = x 8 \small a(x)=x^8 a(x)=x8,若以二进制表示,就是左移八位。左移复杂度为 O ( 1 ) \small O(1) O(1)

  2. 对求余优化

    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}} ((pp(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)+((pp(modg/a))a)(modM)=((p(pp(modg/a)))a)(modg/a)+((pp(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)=pp(modg/a),则上式可改写为:

    ( ( p − j ⋅ ( g a ) ) ⋅ a ) + ( g ⋅ j ) ( m o d M ) ((p-j·(\frac{g}{a}))·a)+(g·j)_{\pmod{M}} ((pj(ag))a)+(gj)(modM)

    其中 g / a = x s h i f t x − 8 \small g/a=x^{shiftx-8} g/a=xshiftx8,因此 p − p ( m o d g / a ) p-p_{\pmod{g/a}} pp(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

问题的引入

  1. 为什么要分块

    对于本地文件系统,分块用于解决孔问题、方便操作系统管理空间、降低扫描次数等。而对于网络文件系统更是如此,分块后只需同步那些被修改的块,比重新上传整个文件更有效率。

  2. 定长分块

    现在对文件进行定长分块,假设文件中的内容为abcdefg,每四个字节分为一块,则分块后为abcd|efg。假如在头部加入了一个字符,内容变更为0abcdefg,则分块后为0abc|defg,发现两个块和之前完全不一样。这意味着如果要向网络文件系统同步此次修改,则需重新上传两个块。

  3. 基于内容可变长度的分块

    假如我们基于内容进行分块,以d作为断点,在d后产生断点,那么此时分块就变成了0abcd|efg。发现这样分块与之前的分块仅一个块不一致,也就是说只需重新上传这个不一致的块,相比定长分块效率大大提高。

  4. 缺点

    有极低的概率出现多个短块。如ddddd断开,则会得到d|d|d|d。此情况会导致块数过多,因而变得难以维护。

滚动哈希与断点

显然不能总是以相同的内容作为断点,例如若文件内容为dd...d,则分块后为d|d|...|d|。每个块仅包含一个字符,非常浪费空间,更不方便管理,违背了分块的初衷。

我们希望以某种概率分布选择断点使得各个块有一个平均大小,同时又保证断点的某种属性相同。发现哈希恰好能满足这两个性质。

哈希是伪随机的,对于 b \small b b位的二进制哈希值,其出现的概率都是 2 − b 2^{-b} 2b。这时候考虑此前的滚动哈希,假设滑动窗口每滑动一次产生的哈希都是随机的,那么上一次碰撞到下一次碰撞的长度即块长恰好呈几何分布:

p d f ( l ) = ( 1 − 2 − b ) l − 1 ⋅ 2 − b \mathit{pdf}(l)=(1-2^{-b})^{l-1}·2^{-b} pdf(l)=(12b)l12b

那么期望的块长就是 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(l218)+P(l222)=i=1218pdf(i)+i=222pdf(i)=1i=218222pdf(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(1p)i1

因此期望在第 1 p ′ ≈ 1.314950 \frac{1}{p'}\approx 1.314950 p11.314950个断点命中。由于断点间隔的期望为 2 20 2^{20} 220,那么对应的,未命中的数据长度期望则是 2 b p ′ ≈ 1.3 \frac{2^b}{p'}\approx 1.3 p2b1.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 p2b=0.185862843MB,这对于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
    
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值