作者:京東科技 董健
快取Redis,是我們最常用的服務,其適用場景廣泛,被大量應用到各業務場景中。也正因如此,快取成為了重要的硬體成本來源,我們有必要從空間上做一些優化,降低成本的同時也會提高效能。
下面以我們的案例說明,將快取空間減少70%的做法。
1、我們需要將POJO儲存到快取中,該類定義如下
public class TestPOJO implements Serializable {
private String testStatus;
private String userPin;
private String investor;
private Date testQueryTime;
private Date createTime;
private String bizInfo;
private Date otherTime;
private BigDecimal userAmount;
private BigDecimal userRate;
private BigDecimal applyAmount;
private String type;
private String checkTime;
private String preTestStatus;
public Object[] toValueArray(){
Object[] array = {testStatus, userPin, investor, testQueryTime,
createTime, bizInfo, otherTime, userAmount,
userRate, applyAmount, type, checkTime, preTestStatus};
return array;
}
public CreditRecord fromValueArray(Object[] valueArray){
//具體的資料型別會丟失,需要做處理
}
}
2、用下面的範例作為測試資料
TestPOJO pojo = new TestPOJO();
pojo.setApplyAmount(new BigDecimal("200.11"));
pojo.setBizInfo("XX");
pojo.setUserAmount(new BigDecimal("1000.00"));
pojo.setTestStatus("SUCCESS");
pojo.setCheckTime("2023-02-02");
pojo.setInvestor("ABCD");
pojo.setUserRate(new BigDecimal("0.002"));
pojo.setTestQueryTime(new Date());
pojo.setOtherTime(new Date());
pojo.setPreTestStatus("PROCESSING");
pojo.setUserPin("ABCDEFGHIJ");
pojo.setType("Y");
System.out.println(JSON.toJSONString(pojo).length());
使用JSON直接序列化、列印 length=284****,這種方式是最簡單的方式,也是最常用的方式,具體資料如下:
{"applyAmount":200.11,"bizInfo":"XX","checkTime":"2023-02-02","investor":"ABCD","otherTime":"2023-04-10 17:45:17.717","preCheckStatus":"PROCESSING","testQueryTime":"2023-04-10 17:45:17.717","testStatus":"SUCCESS","type":"Y","userAmount":1000.00,"userPin":"ABCDEFGHIJ","userRate":0.002}
我們發現,以上包含了大量無用的資料,其中屬性名是沒有必要儲存的。
System.out.println(JSON.toJSONString(pojo.toValueArray()).length());
通過選擇陣列結構代替物件結構,去掉了屬性名,列印 length=144,將資料大小降低了50%,具體資料如下:
["SUCCESS","ABCDEFGHIJ","ABCD","2023-04-10 17:45:17.717",null,"XX","2023-04-10 17:45:17.717",1000.00,0.002,200.11,"Y","2023-02-02","PROCESSING"]
我們發現,null是沒有必要儲存的,時間的格式被序列化為字串,不合理的序列化結果,導致了資料的膨脹,所以我們應該選用更好的序列化工具。
//我們仍然選取JSON格式,但使用了第三方序列化工具
System.out.println(new ObjectMapper(new MessagePackFactory()).writeValueAsBytes(pojo.toValueArray()).length);
選取更好的序列化工具,實現欄位的壓縮和合理的資料格式,列印 length=92,空間比上一步又降低了40%。
這是一份二進位制資料,需要以二進位制操作Redis,將二進位制轉為字串後,列印如下:
��SUCCESS�ABCDEFGHIJ�ABCD��j�6���XX��j�6����?`bM����@i��Q�Y�2023-02-02�PROCESSING
順著這個思路再深挖,我們發現,可以通過手動選擇資料型別,實現更極致的優化效果,選擇使用更小的資料型別,會獲得進一步的提升。
在以上用例中,testStatus、preCheckStatus、investor這3個欄位,實際上是列舉字串型別,如果能夠使用更簡單資料型別(比如byte或者int等)替代string,還可以進一步節省空間。其中checkTime可以用Long型別替代字串,會被序列化工具輸出更少的位元組。
public Object[] toValueArray(){
Object[] array = {toInt(testStatus), userPin, toInt(investor), testQueryTime,
createTime, bizInfo, otherTime, userAmount,
userRate, applyAmount, type, toLong(checkTime), toInt(preTestStatus)};
return array;
}
在手動調整後,使用了更小的資料型別替代了String型別,列印 length=69
除了以上的幾點之外,還可以考慮使用ZIP壓縮方式獲取更小的體積,在內容較大或重複性較多的情況下,ZIP壓縮的效果明顯,如果儲存的內容是TestPOJO的陣列,可能適合使用ZIP壓縮。
但ZIP壓縮並不一定會減少體積,在小於30個位元組的情況下,也許還會增加體積。在重複性內容較少的情況下,無法獲得明顯提升。並且存在CPU開銷。
在經過以上優化之後,ZIP壓縮不再是必選項,需要根據實際資料做測試才能分辨到ZIP的壓縮效果。
上面的幾個改進步驟體現了優化的思路,但是反序列化的過程會導致型別的丟失,處理起來比較繁瑣,所以我們還需要考慮反序列化的問題。
在快取物件被預定義的情況下,我們完全可以手動處理每個欄位,所以在實戰中,推薦使用手動序列化達到上述目的,實現精細化的控制,達到最好的壓縮效果和最小的效能開銷。
可以參考以下msgpack的實現程式碼,以下為測試程式碼,請自行封裝更好的Packer和UnPacker等工具:
<dependency>
<groupId>org.msgpack</groupId>
<artifactId>msgpack-core</artifactId>
<version>0.9.3</version>
</dependency>
public byte[] toByteArray() throws Exception {
MessageBufferPacker packer = MessagePack.newDefaultBufferPacker();
toByteArray(packer);
packer.close();
return packer.toByteArray();
}
public void toByteArray(MessageBufferPacker packer) throws Exception {
if (testStatus == null) {
packer.packNil();
}else{
packer.packString(testStatus);
}
if (userPin == null) {
packer.packNil();
}else{
packer.packString(userPin);
}
if (investor == null) {
packer.packNil();
}else{
packer.packString(investor);
}
if (testQueryTime == null) {
packer.packNil();
}else{
packer.packLong(testQueryTime.getTime());
}
if (createTime == null) {
packer.packNil();
}else{
packer.packLong(createTime.getTime());
}
if (bizInfo == null) {
packer.packNil();
}else{
packer.packString(bizInfo);
}
if (otherTime == null) {
packer.packNil();
}else{
packer.packLong(otherTime.getTime());
}
if (userAmount == null) {
packer.packNil();
}else{
packer.packString(userAmount.toString());
}
if (userRate == null) {
packer.packNil();
}else{
packer.packString(userRate.toString());
}
if (applyAmount == null) {
packer.packNil();
}else{
packer.packString(applyAmount.toString());
}
if (type == null) {
packer.packNil();
}else{
packer.packString(type);
}
if (checkTime == null) {
packer.packNil();
}else{
packer.packString(checkTime);
}
if (preTestStatus == null) {
packer.packNil();
}else{
packer.packString(preTestStatus);
}
}
public void fromByteArray(byte[] byteArray) throws Exception {
MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(byteArray);
fromByteArray(unpacker);
unpacker.close();
}
public void fromByteArray(MessageUnpacker unpacker) throws Exception {
if (!unpacker.tryUnpackNil()){
this.setTestStatus(unpacker.unpackString());
}
if (!unpacker.tryUnpackNil()){
this.setUserPin(unpacker.unpackString());
}
if (!unpacker.tryUnpackNil()){
this.setInvestor(unpacker.unpackString());
}
if (!unpacker.tryUnpackNil()){
this.setTestQueryTime(new Date(unpacker.unpackLong()));
}
if (!unpacker.tryUnpackNil()){
this.setCreateTime(new Date(unpacker.unpackLong()));
}
if (!unpacker.tryUnpackNil()){
this.setBizInfo(unpacker.unpackString());
}
if (!unpacker.tryUnpackNil()){
this.setOtherTime(new Date(unpacker.unpackLong()));
}
if (!unpacker.tryUnpackNil()){
this.setUserAmount(new BigDecimal(unpacker.unpackString()));
}
if (!unpacker.tryUnpackNil()){
this.setUserRate(new BigDecimal(unpacker.unpackString()));
}
if (!unpacker.tryUnpackNil()){
this.setApplyAmount(new BigDecimal(unpacker.unpackString()));
}
if (!unpacker.tryUnpackNil()){
this.setType(unpacker.unpackString());
}
if (!unpacker.tryUnpackNil()){
this.setCheckTime(unpacker.unpackString());
}
if (!unpacker.tryUnpackNil()){
this.setPreTestStatus(unpacker.unpackString());
}
}
假設,我們為2億使用者儲存資料,每個使用者包含40個欄位,欄位key的長度是6個位元組,欄位是分別管理的。
正常情況下,我們會想到hash結構,而hash結構儲存了key的資訊,會佔用額外資源,欄位key屬於不必要資料,按照上述思路,可以使用list替代hash結構。
通過Redis官方工具測試,使用list結構需要144G的空間,而使用hash結構需要245G的空間(當50%以上的屬性為空時,需要進行測試,是否仍然適用)
在以上案例中,我們採取了幾個非常簡單的措施,僅僅有幾行簡單的程式碼,可降低空間70%以上,在資料量較大以及效能要求較高的場景中,是非常值得推薦的。:
• 使用陣列替代物件(如果大量欄位為空,需配合序列化工具對null進行壓縮)
• 使用更好的序列化工具
• 使用更小的資料型別
• 考慮使用ZIP壓縮
• 使用list替代hash結構(如果大量欄位為空,需要進行測試對比)