在分析Avro源碼時,發現Avro為了對int、long類型數據壓縮,采用Protocol Buffers的ZigZag編碼(Thrift也采用了ZigZag來壓縮整數)。
1. 補碼編碼
為了便於后面的分析,我們先回顧下幾個概念:
- 原碼:最高位為符號位,剩余位表示絕對值;
- 反碼:除符號位外,對原碼剩余位依次取反;
- 補碼:對於正數,補碼為其自身;對於負數,除符號位外對原碼剩余位依次取反然后+1。
補碼解決了原碼中\(0\)存在兩種編碼的問題:
補碼\([1000 \enspace 0001]_補\) 表示\(-127\);此外,原碼中還存在加法錯誤的問題:
若用補碼,則可得到正確結果:
因此,在計算機存儲整數時,采用的是補碼。此外,整數的補碼有一些有趣的性質:
- 左移1位(n << 1),無論正數還是負數,相當於乘以2;對於正數,若大於
Integer.MAX_VALUE/2
(1076741823),則會發生溢出,導致左移1位后為負數 - 右移31位(n >> 31),對於正數,則返回
0x00000000
;對於負數,則返回0xffffffff
這些性質正好在ZigZag編碼中用到了。
2. ZigZag
對於int值1,-1,20151103,均是用4 Bytes來表示:
在《Huffman編碼》中證明了壓縮編碼應滿足:
高概率的碼字字長應不長於低概率的碼字字長
一般情況下,使用較多的是小整數,那么較小的整數應使用更少的byte來編碼。基於此思想,ZigZag被提出來。
編碼
首先,ZigZag按絕對值升序排列,將整數hash成遞增的32位bit流,其hash函數為h(n) = (n << 1) ^ (n >> 31)
;對應地long類型(64位)的hash函數為(n << 1) ^ (n >> 63)
。整數的補碼(十六進制)與hash函數的對應關系如下:
n | hex | h(n) | ZigZag (hex) |
---|---|---|---|
0 | 00 00 00 00 | 00 00 00 00 | 00 |
-1 | ff ff ff ff | 00 00 00 01 | 01 |
1 | 00 00 00 01 | 00 00 00 02 | 02 |
-2 | ff ff ff fe | 00 00 00 03 | 03 |
2 | 00 00 00 02 | 00 00 00 04 | 04 |
... | ... | ... | ... |
-64 | ff ff ff c0 | 00 00 00 7f | 7f |
64 | 00 00 00 40 | 00 00 00 80 | 80 01 |
... | ... | ... | ... |
拿到hash值后,想當然的編碼策略:直接去掉hash值的前導0之后的byte作為壓縮編碼。但是,為什么ZigZag(64)=8001
呢?這涉及到編碼唯一可譯性的問題,只有當編碼為前綴碼才能保證可譯,即
任意一碼字均不為其他碼字的前綴
我們來看看,如果按上面的策略做壓縮編碼,則
h(0) = 0x0 = [00]
h(64) = 0x80 = [80]
h(16384) = 0x8000 = [80 00]
那么,當收到字節流[80 00]
時,是應解碼為兩個整數64, 00
,還是一個整數16384
?因此,為了保證編碼的唯一可譯性,需要對hash值進行前綴碼編碼,ZigZag采用了如下策略:
input: int n
output: byte[] buf
loop
if 第七位滿1或有進位:
n |= 0x80;
取低位的8位作為一個byte寫入buf;
n >>>=7(無符號右移7位,在高位插0);
else:
取低位的8位作為一個byte寫入buf
end
ZigZag編碼的Java實現(從org.apache.avro.io.BinaryData
摳出來的):
/** Encode an integer to the byte array at the given position. Will throw
* IndexOutOfBounds if it overflows. Users should ensure that there are at
* least 5 bytes left in the buffer before calling this method.
* @return The number of bytes written to the buffer, between 1 and 5.
*/
public static int encodeInt(int n, byte[] buf, int pos) {
// move sign to low-order bit, and flip others if negative
n = (n << 1) ^ (n >> 31);
int start = pos;
if ((n & ~0x7F) != 0) {
buf[pos++] = (byte)((n | 0x80) & 0xFF);
n >>>= 7;
if (n > 0x7F) {
buf[pos++] = (byte)((n | 0x80) & 0xFF);
n >>>= 7;
if (n > 0x7F) {
buf[pos++] = (byte)((n | 0x80) & 0xFF);
n >>>= 7;
if (n > 0x7F) {
buf[pos++] = (byte)((n | 0x80) & 0xFF);
n >>>= 7;
}
}
}
}
buf[pos++] = (byte) n;
return pos - start;
}
ZigZag是一種變長編碼,當整數值較大時,hash值的十六進制的有效位會較長,對應地ZigZag碼字會出現需要5 byte存儲;比如,
ZigZag(Integer.MAX_VALUE)=[fe ff ff ff 0f]
解碼
解碼為編碼的逆操作,首先,將ZigZag編碼還原成hash值,然后用hash函數\(h(n)\)的逆函數\(h^{-1}(n)\) = (n >>> 1) ^ -(n & 1)
得到原始的整數值。Java代碼實現(在avro源碼org.apache.avro.io.BinaryDecoder
中)如下:
public static int readInt(byte[] buf, int pos) throws IOException {
int len = 1;
int b = buf[pos] & 0xff;
int n = b & 0x7f;
if (b > 0x7f) {
b = buf[pos + len++] & 0xff;
n ^= (b & 0x7f) << 7;
if (b > 0x7f) {
b = buf[pos + len++] & 0xff;
n ^= (b & 0x7f) << 14;
if (b > 0x7f) {
b = buf[pos + len++] & 0xff;
n ^= (b & 0x7f) << 21;
if (b > 0x7f) {
b = buf[pos + len++] & 0xff;
n ^= (b & 0x7f) << 28;
if (b > 0x7f) {
throw new IOException("Invalid int encoding");
}
}
}
}
}
pos += len;
return (n >>> 1) ^ -(n & 1); // back to two's-complement
}
ZigZag總結如下:
- ZigZag僅從經驗出發,認為較小的整數會有較大的概率出現,故設計編碼策略:小整數對應的ZigZag碼字短,大整數對應的ZigZag碼字長。
- 但是,在特定的場景下,比如,要傳輸的整數為大整數居多,ZigZag編碼的壓縮效率就不理想了。