壓縮十進制數據的一次實踐


簡介

在ERP應用中,經常要將大批量的數據下載到客戶端本地,例如查詢、報表或導出等功能,這將消耗大量的網絡資源來傳輸數據,特別是在移動辦公時,較大的數據量將造成等待時間太長。本文以壓縮十進制數據為例描述了壓縮這些數據的實踐。

分析案例

在傳輸的數據中,有各種類型的數據,有字符串、時間、十進制或bool等,本文將分析十進制這種類型。

在.net中,decimal是一個可以描述較大數據和小數,且保證計算准確的數據類型,比較適合ERP應用,但是他占用的空間比較大,反編譯其申明可以看到其占用4個int32,即高達16個字節。

1     private int flags;
2     private int hi;
3     private int lo;
4     private int mid;

直接調用GetBits(Decimal) : Int32[]存儲顯然不合算。

參考實現

微軟內部是如何存儲的呢?System.IO.BinaryWriter的默認實現還真的是這么干的,請看:

1 public virtual void Write(decimal value)
2 {
3     decimal.GetBytes(value, this._buffer);
4     this.OutStream.Write(this._buffer, 0, 0x10);
5 }

注:不過他有點耍賴,調用了internal的GetBytes減少了byte[]數組的不斷創建。

在二進制序列化的實現System.Runtime.Serialization.Formatters.Binary.__BinaryWriter中,使用了較為巧妙的方法,他將其轉換為字符串存儲:

1 internal void WriteDecimal(decimal value)
2 {
3     this.WriteString(value.ToString(CultureInfo.InvariantCulture));
4 }

通常情況下,一列的數據都是很小的數字,例如數量或者單價,可能0,也可能正整數,也可能是0.17這樣的小數.根據WriteString的實現可知,需要至少一個字節描述字符串的長度,然后一個字符占用一個字節(默認utf8),也就是說,數字0占用2個字節,0.17占用5個字節。

還不錯啊,至少比16個字節好多了。

有沒有更好的壓縮

觀察

我對微軟的實現還不夠滿意,仔細觀察常見的數據,可以總結道:

1、  通常一個小數由整數部分、小數部分和小數位數組成,例如0.17整數是0,小數部分是17,小數位數是2,另外一個例子1.007,整數部分是1,小數部分是7,小數位數是3;

2、  如果小數位數是0,即整數,那么就不必描述小數部分了;

3、  如果數字就是0,這種情況非常多,是不是用1個字節而不是2個字節描述呢?

4、  像17這樣的數字,用字符串描述就需要2個字節,而如果用二進制,1個字節就夠了。

基本方法

所以我的方法是:第一個字節描述小數位數,后面的字節描述整數部分,如果小數位數大於0,則還包含小數部分的整數描述。

例如0.12,我會存儲2,0,12三個正整數,十六進制描述就是

02

00

0C

 

 

 

 

 

 

 

我在上面多處提到正整數,我們可以利用7BitEncodedInt方式填寫整數部分和小數部分,他是一種可變長的描述方法。

如果數字是整數,我們還可以省略小數部分,例如12

00

0C

 

 

 

 

 

 

 

 

到這里,我們發現一個問題,如果是數字0,第一個字節和上面是一樣的(為0),這樣即使0也還需要再存儲一個字節0,用來確定整數部分是0,不然讀取時,就無法區分0和12了,但這就達不到我之前提到的數字0使用一個字節來描述的要求。

而且,這里還有負數的問題,還有如果decimal的值大於int32的范圍怎么辦?所以我在第一個字節上做了手腳,把他再拆分成4個部分。

A

A

A

A

A

B

C

D

第一個字節的8個位中,前5位存儲小數位數(A區),最大值是31,一般小數位數不會超過這個數字(事實上,下面的算法最多僅9位)。

B區一個位,如果是0,表示當前數字是0,其他任何位都無需考慮了,也沒有整數部分和小數部分,如果是1,表示此值非0,通過這種方法,我們就實現了數字0僅用一個字節描述的目的。

C區一個位,如果是0表示正數,如果是1表示此值為負數;

D區一個位,如果是0表示此值使用壓縮的存儲方法,1表示此值太大或太小,使用了16個字節的原樣輸出。

現在還是0.12為例,其第一個字節其二進制實際上是:

0

0

0

1

0

1

0

0

第一個字節換算成十六進制為:0x14,所以0.12其存儲的內容是:

14

00

0C

 

 

 

 

 

 

 

參考實現

下面是寫入時的代碼,我使用了比較笨拙的辦法獲取小數部分的整數描述值。

 1 // Fast access for 10^n where n is 0-9
 2 private static UInt32[] Powers10 = new UInt32[] {
 3     10,
 4     100, 
 5     1000, 
 6     10000,
 7     100000, 
 8     1000000,
 9     10000000,
10     100000000,
11     1000000000 
12 };
13 
14 private static void WriteToFullByte(SerializeContext context, decimal[] values) {
15     //整個邏輯放在一個方法中,目的是防止foreach循環中不斷跳入其他方法,增加消耗,在這個性能要求較高的程序是允許的。
16     var stream = context.Stream;
17     int flag;
18     int a, b, c;
19     decimal d, e;
20 
21     foreach (var value in values) {
22         if (value == 0m) {
23             //當數字是0時,第一個字節的所有位都是0,直接寫0
24             stream.WriteByte(byte.MinValue);
25         }
26         else {
27             //分析出整數部分,小數位和小數部分;
28             //ex: v = 1.070m;
29 
30             //整數部分太大,超過了int32的描述范圍;
31             //當value == Int32.MinValue時,其變為正數后,比Int32.MaxValue大了1,超過范圍,所以后面一個判斷使用<=
32             if (value > Int32.MaxValue || value <= Int32.MinValue) {
33                 goto FullWrite;
34             }
35 
36             if (value < 0) {
37                 flag = 0x4 | 0x2;
38                 e = value * -1;
39                 a = decimal.ToInt32(e);
40                 d = e - a;
41             }
42             else {
43                 flag = 0x4;
44                 //取出整數部分 a = 1
45                 a = decimal.ToInt32(value);
46                 //得到小數部分 d = 0.070m;
47                 d = value - a;
48             }
49 
50             c = 0;
51             if (d == 0m) {
52                 //沒有小數部分
53                 stream.WriteByte((byte)flag);
54                 context.Write7BitEncodedInt(a);
55                 goto NextFor;
56             }
57 
58             do {
59                 e = d * Powers10[c]; //ex: 0.7 , 7
60                 b = decimal.ToInt32(e);
61                 c++;
62 
63                 if (b == e) {//整數部分和小數部分相等,則得到小數部分的整數描述
64                     flag = (c << 3 | flag);
65                     stream.WriteByte((byte)flag);
66                     context.Write7BitEncodedInt(a);
67                     context.Write7BitEncodedInt(b);
68                     goto NextFor;
69                 }
70             } while (c < 9);
71 
72             //小數部分太多,也使用原始方法寫入。
73         FullWrite:
74             stream.WriteByte((byte)1);
75             //這里使用了BinaryWriter來填充到流,此實現bw實例級緩存了byte[]並調用decimal內部的
76             //的 GetBytes 方法,這比調用GetBits不斷創建數組減少了開銷。
77             context.Writer.Write(value);
78 
79         NextFor:
80             flag = 0;
81         }
82     }
83 }

 

下面是讀取時的方法:

 

 1 private static decimal[] PowersDecimal = new decimal[] {
 2     0.1m,
 3     0.01m, 
 4     0.001m, 
 5     0.0001m,
 6     0.00001m, 
 7     0.000001m,
 8     0.0000001m,
 9     0.00000001m,
10     0.000000001m 
11 };
12 
13 private static void ReadFromFullByte(SerializeContext context, decimal[] values, int length) {
14     var stream = context.Stream;
15     int flag;
16     int a, b, c;
17     decimal d;
18 
19     for (int i = 0; i < length; i++) {
20         flag = stream.ReadByte();
21         if (flag != 0) {
22             if (flag == 1) {
23                 //較大的數,系統內部完整數據
24                 values[i] = context.Reader.ReadDecimal();
25             }
26             else {
27                 c = flag >> 3;
28                 a = context.Read7BitEncodedInt();
29 
30                 if (c > 0) {
31                     b = context.Read7BitEncodedInt();
32                     //乘法比除法快,在測試用例中,除法時,總時間是1分04秒,而乘法時為50秒
33                     d = a + ((decimal)b * PowersDecimal[c - 1]);
34                 }
35                 else {
36                     d = a;
37                 }
38 
39                 if ((flag & 0x2) == 0x2) {
40                     values[i] = d * -1; //負數
41                 }
42                 else {
43                     values[i] = d;
44                 }
45             }
46         }
47     }
48 }

 

 

 

更進一步的優化

我在以上的基礎上,進一步設想,

1、  如果這一列所有的數字都是0呢?這在ERP中非常常見,因為為了滿足各種需求,設計人員總是設計一堆的字段,而這些字段一般用戶根本不填寫,全是0;

2、  如果這一列數字全是正整數,那么我是不是可以全部不要那個標志位字節,就節省一半的大小了,這種情況其實也常見,例如數量這一列,零售單明細中都是1~5之間的正整數,除非是稱重的商品。

基於上面的想法,我在整列的存儲中,使用一個字節描述存儲方式,注意是整列的開頭而不是每個數字的開頭。這第一個字節:

1、  如果是0,表示所有的數字是0;

2、  如果是1,表示所有的數字都是小於0xFF的正整數,后面都使用一個字節描述其整數部分;

3、  如果是2,表示所有的數字都是小於0xFF FF的正整數,后面都使用2個字節描述其整數部分;

4、  如果是3,表示所有的數字都是小於0xFF FF FF的正整數,后面都使用3個字節描述其整數部分;

5、  如果是大於3的數字,表示此列使用本文描述的動態壓縮方法存儲數據。

下面是一段簡單分析數據的方法,其返回此標志位:

 1 private static byte GetFirstFlag(decimal[] values) {
 2     decimal maxValue = 0;
 3     foreach (var item in values) {
 4         if (decimal.Floor(item) == item) {
 5             if (item > maxValue) {
 6                 maxValue = item;
 7                 if (maxValue > 0xFFFFFF) {
 8                     return 4;//大於3個byte可描述范圍 使用full的算法
 9                 }
10             }
11             else if (item < 0) {
12                 return 4;//負數 使用string的算法
13             }
14         }
15         else {
16             return 4;//存在有效小數 使用full的算法
17         }
18     }
19 
20     if (maxValue > 0xFFFF) {
21         return 3;// 65535 < maxValue <= 16777215 3個byte
22     }
23     else if (maxValue > 0xFF) {
24         return 2;//255 < maxValue <= 65535 2個byte
25     }
26     else if (maxValue > 0) {//0 < maxValue <= 255 1個byte
27         return 1;
28     }
29     return 0;
30 }

 

總結

通過不斷分析ERP中的常見數據,理順其規律,我們就能設計出更加優化的特定壓縮算法。

完整的測試代碼請點擊這里下載。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM