LZ4压缩算法实现思路详解

最近遇到一个需求,需要使用LZ4的流式压缩来减少流量开销。笔者也是第一次接触LZ4算法,但速度之王的名声早已如雷贯耳。需求本身要求我使用go语言来编写一套LZ4压缩工具库,因为github上的go语言版本是残废的(它不支持流式压缩),而CGO又会带来很多额外的开销(C栈和go栈的内存分布差距很大,有着很大的上下文切换开销),于是我受命编写纯go版本的LZ4压缩工具库。

压缩的通用原理就是,假如当前位置的一个字符串序列,在以前的历史数据中也出现过,那么现在用一种特殊的格式或者特殊的小序列来表示它,就可以起到压缩的效果,因为特殊格式或者特殊小序列通常都是比原本的字符串序列更小的。

先把LZ4的压缩实现抛在一边,LZ4是一种压缩算法,它的实现可以变幻莫测,只要可以生成合乎规范的压缩数据即可。LZ4算法最重要,也是最容易出错的地方,也就是它的格式了。下面,先来看一下LZ4的格式:

 

Sequence:它是LZ4最小的数据单元,格式如下:

Token(1字节)

 

字面序列长度(0~n字节)

 

字面序列序列

 

Offset

(2字节)

 

匹配长度(0~n字节)

匹配长度(低4位)

字面序列长度(高4位)

  1. 字面序列,这些序列(及其子序列)被认为没有在以前的历史数据中出现过,所以在压缩数据中,它们将以原本的形式保存下来,当解压的时候,则会被完整的拷贝过去。
  2. 匹配序列,匹配序列是以前出现过的序列,它使用一个offset和匹配长度来表示。Offset是两个匹配序列的起始位置相差的字符个数。
  3. 字面序列长度,即是字面序列的字符个数,它将分为两部分存储,如果它小于等于15,则只需要将值存入Token的高4位即可。如果它比15更大,则把15存入Token的高4位,高于15的部分,将存入Token后面的字节。例如,字面序列长度为375,则Token的高4位为全1(即15),Token的后一个字节为255,再后一个字节为105。
  4. 匹配长度,即是匹配序列的字符个数,与字面序列长度类似,它也分为两部分存储,如果它小于等于15,则只需要将值存入Token的低4位即可。如果它比15更大,则把15存入Token的低4位,高于15的部分,将存入Offset后面的字节。例如,字面序列长度为375,则Token的低4位为全1(即15),Offset的后一个字节为255,再后一个字节为105。
  5. 特殊情况:当Token足以存下字面序列长度时,Token后面就是字面序列。当Token足以存下匹配长度时,Offset后面的字节将是EOF或者另外一个sequence。最后一个sequence是没有Offset的。因此,最后一个字节的最小长度为6。

 

Block:它由多个sequence组成,在通常的实现中,它的格式如下:

Totalsize

Sequence1

Sequence2

......

SequenceN

 

Totalsize指的是所有sequence的总长度。

在压缩和解压Block的时候,有一些兼容性需求的约定需要遵守:

  1. 最后5个字节必须是字面序列或者字面序列的一部分。
  2. 寻找匹配的起始位置不能在最后12个字节,这也意味着,小于13个字节的源数据将无法压缩,只能成为字面序列。
  3. 解压时,字面序列的起始位置到数据的截止位置至少要有8个字节(2个字节的Offset,最后一个sequence的Token和长度为5的字面序列)。
  4. 解压时,可以认为在输入缓存中,匹配位置后面至少有12个可读的数据。

可以说,如果能构造正常的sequence,并且能解决前三条问题,压缩出来的数据就基本没问题了。

 

解压算法:LZ4的解压速度是其他解压算法望尘莫及的,同时,由于解压算法比压缩算法简单,先实现解压算法可以更好的理解LZ4压缩算法。相关概念如下:

 

  1. RingBuffer,为了方便数据存取,通常使用压缩和解压算法时,都会分别使用了一个RingBuffer来存储源数据,每次压缩和解压缩时读取一个Block进行处理。当源数据超过一定上限的时候,再对其进行重置。
  2. Dictionary,即字典,字典里面存储的是可以引用到的历史数据,在解压的过程中,需要从字典里面找到对应的匹配序列,然后将其拷贝到输出缓存里。在压缩的过程中,同样会从字典里寻找匹配序列。

 

下面来讨论一下解压的实现,可以约定字典是RingBuffer的一个子集,RingBuffer的起始位置就是字典的起始位置,用一个游标dictEndIndex来指定字典的边界,从起始位置到边界的内容就是可以寻找匹配序列的历史数据。不难想象,当RingBuffer重置之后,字典也要进行重置。

算法步骤:

  1. 从RingBuffer中取出一个Block。将Block第一个sequence的第一个字节作为当前操作位置。
  2. 视当前操作位置为Token,取出其高四位的数据作为字面序列长度。如果得到的长度是15,则读取Token后面的字节,加到字面序列长度中,直到读取到的字节不是255。
  3. 接下来读到的内容就是字面序列了,根据字面序列长度,读取所有字面序列,并拷贝到输出缓存中。
  4. 字面序列后是2个字节的Offset,读取之后,从输出缓存的当前位置(刚字面序列结尾后的一个字节)往前追溯Offset个字节,即可得到匹配位置。匹配位置可能在字典中,也可能在本block中。
  5. 再以类似2的方法读取匹配序列长度,于是,匹配序列、字典和本block之间可能会存在5种情况:
    1. 整个匹配序列完全在字典中。
    2. 匹配位置在字典中,序列的截止位置在当前位置之后。这意味着匹配序列有一部分在字典中,有一部分在本block已解压的部分,有一部分还没解压。
    3. 匹配位置在字典之后,当前位置之前,序列的截止位置在当前位置之后。这意味着有一部分在本block已解压的部分,有一部分还没解压。
    4. 整个匹配序列完全在字典之后,当前位置之前。
    5. 匹配位置在字典里,匹配序列的截止位置在当前位置之前。这意味着匹配序列有一部分在字典中,有一部分在本block已解压的部分。
  6. 拷贝匹配序列到输出缓存。继续进行2操作,除非本Block已解压完成。
  7. 将字典的范围扩充到本block解压出来的,如果RingBuffer发生了重置,则将本block的解压出来的数据作为字典。继续执行1操作,直到所有Block模块都解压完成。

 

 

压缩算法:当了解完格式,实现了解压算法,那么对LZ4压缩数据就应该有足够的认识了,这时候来实现LZ4压缩算法是最稳的。先来了解几个注意事项:

  1. RingBuffer:在解压算法中,RingBuffer是用来提供源数据,也就是已压缩数据的。在压缩算法中,它也是用来存储已压缩数据的,但是此时它自然将变成目的数据输出的地方。而字典具有和解压算法相同的含义和使用方法。
  2. Hash表:LZ4使用一张Hash表来快速找到匹配位置。在逐个遍历原始待压缩数据时,每遍历到一个位置,就读取当前位置以后的4个字节(包括当前位置)作为一个int,并计算出它的Hash值,然后将Hash值作为Key,当前位置作为Value,存入Hash表。可以使用指针或者字典起始位置到当前位置的偏移量表示当前位置(表里面存指针或者存int,只能选一种)。以后要是在某个位置再得到这样一个hash值,并且两个位置读取出来的4字节相等,就认为是找到匹配了。
  3. 锚点:上一次经过拷贝字面序列,或者匹配序列处理完成后的位置。例如,一开始,锚点是源数据的起始位置,当完成一次13个字节字面序列拷贝之后,锚点变成了第14个字节的位置。再完成一个7字节的匹配序列处理之后,锚点变成了第21个字节的位置。

 

算法步骤:

  1. 从数据源中取出打算压缩成一个block的数据。
  2. 遍历每个字节,从每个字节的位置读取4个字节,计算出hash值,并从hash表中寻找匹配,如果匹配到的value和当前位置的4字节相同,则认为匹配,稍后将执行3操作。如果不匹配,则将hash值和当前位置存入hash表。继续遍历字节。
  3. 找到匹配后,将锚点到当前位置的距离作为字面序列长度,在输出缓存中构造一个字节的Token,并且尝试将字面长度存入Token的高4位。如果字面长度大于等于15,就在Token后面继续存储字面序列长度超过15的部分,直到存下所有字面序列长度为止。
  4. 从锚点开始,拷贝字面序列到输出缓存中。然后把锚点更新到源数据中的字面序列后。
  5. 将当前位置与匹配位置作差,得到Offset,并且将Offset存入字面序列后的两个字节里。
  6. 从匹配位置和当前位置继续往后比对,看看最多还能有多少字节相同,以得到最长的匹配序列。将匹配序列长度以类似3操作的方式存入Token的低4位和Offset后的字节。
  7. 此时源数据的当前位置应该已经到了匹配序列末尾的位置,更新锚点到匹配序列后。重复进行2操作,直到所有源数据都被压缩。

 

值得注意的几个点:

  1. 寻找匹配序列的时候,当前的匹配序列不应该触及最后12个字节,这是出于兼容性的约定。
  2. 历史数据里的匹配序列和本block的匹配序列不应该重合,重合的数据必定不是历史数据。

你可能感兴趣的:(辅助手段)