smallnewer / bugs

18 stars 4 forks source link

利用二进制操作降低保存进度的空间占用 #126

Open smallnewer opened 7 years ago

smallnewer commented 7 years ago

有时候会有这种需求:需要你记录下玩家是否完成过某个任务/副本。而任务/副本的总量又非常大(比如有几万)。 这种需求很少出现也比较棘手,但也不是完全没有解决办法。 通常我们会用这种数据结构存储

var data = {
    id1: true, 
    id2: true,
    ....
}

一个10W key的上述对象,在V8里约占用3M内存,如果考虑编解码+传输压力,则不可能在线上服务器环境使用这种方案。 如果利用二进制存储,大约只需要3K内存(纯估算,但基本在K的量级)。而读写速度也远比10Wkey的大对象快得多(太大没法利用hidden class提高性能了)。

原理是: 二进制的每一位代表一个任务/副本是否已完成。 由于JS的位运算只支持32位整形(即只能存31个记录),所以会利用数组存储多个int32。虽然这会导致额外的内存占用和存储压力,但也有一定的优点:兼容Int32Array,前后端更通用。

因此也有几个限制:

  1. key必须是数字
  2. key的数字不建议过大。因为内存占用按最大值来算,如最大100W,最优写法也需要30K内存(即使前99W都不用也会占内存和存储空间)。
  3. 实现时最好用TypeArray。Array有过多的不稳定因素。

下面是一个DEMO代码

// 用二进制来存储进度

function setData (data, index, bool) {
    if (bool) {
        return data | (1 << (index - 1) );
    }else{
        return data & ~(1 << (index - 1) );
    }
}

function getData (data, index) {
    return data >> (index - 1) & 1;
}

// [0~31, 32~63, ...]
function get (arr, index) {
    var num = arr[Math.floor(index / 32)];
    var ind = index % 32 + 1;
    return getData(num, ind);
}

function set (arr, index, bool) {
    var num = Math.floor(index / 32);
    var ind = index % 32 + 1;   // 从1开始
    if (num >= arr.length - 1) {
        for (var i = arr.length; i <= num; i++) {
            arr.push(0)
        };
    };
    arr[num] = setData(arr[num], ind, bool);
}

// tester
;(function () {
    // 10W次随机存取
    function test (index) {
        var arr = [];
        var hash = {};
        for (var i = 0; i < 100000; i++) {
            var num = Math.round(Math.random() * 10000);
            if (hash[num]) {
                if (get(arr, num) !== 1) {
                    throw new Error('存取错误.')
                }
                set(arr, num, false);
                hash[num] = 0;
                if (get(arr, num) !== 0) {
                    throw new Error('存取错误..')
                };
            }else{
                if (get(arr, num) !== 0) {
                    console.error(arr, num)
                    throw new Error('存取错误...')
                }
                hash[num] = 1;
                set(arr, num, true);
                if (get(arr, num) !== 1) {
                    throw new Error('存取错误....')
                }
            }
        };
    }
    for (var i = 0; i < 100; i++) {
        test();
    };

    console.log('测试通过')
})();
smallnewer commented 7 years ago

基于上面的思路。 利用MongoDB持久化,把JS数据==>BSON优化为JS==>Binary==>BSON,好处多多。

传统的JS数据==>BSON方案缺点:

  1. 持久化耗时
  2. JS内存表现糟糕(大量对象导致GC频繁,且受到1.3G的HEAP限制)

如果改为新的方案:

  1. 数据存储到V8 HEAP外部,不会导致GC压力,也不会收到1.3G内存限制。性能更好。
  2. JS直接操作Binary二进制数据,序列化到BSON时压力更小
  3. 二进制数据更加节省内存占用

最主要的还是1~2点。这是Node.JS单进程的负载瓶颈之一。譬如传统的机器是8核16G,即一个Node用2G内存~~

需要自己撸一套二进制编码协议,需要支持:

  1. 类似BSON,对象数组可动态增长。性能要可接受
  2. 文档大小不可超过16M,方便对接MongoDB
  3. 能高效处理double与二进制的转换(至少不能太烂)
  4. 支持unsafe的旧语法迁移
  5. 支持扩展子数据结构,如ProcessSaver存储进度,提高复用度
  6. 支持操作同步