[date: 2018-07-31 11:11] [visits: 30]

记一次内存泄漏处理

近期在检查服务器状态时,发现每个Node.js进程占用的内存大小在400-500M之间,根据平时的经验判断是代码中出现了内存泄漏导致,本文就记录这次内存泄漏问题的处理过程。

定位内存泄漏原因过程没有用到什么高难度技术,只是靠经验凭感觉找到的问题代码,简言之就是应用中有一个合成朋友圈分享海报功能,考虑前端canvas兼容性不够好以及该功能使用频次低,所以采用的是服务端合成,就是这个功能导致了应用出现内存泄漏。

合成方式

图片合成功能是借助sharp这个库实现的,原理是通过sharp提供的api往背景图上不断的叠加可变元素(头像、昵称、文案),原始代码比较长,定位问题的时候简化了处理代码,如下所示:

const fs = require('fs');
const _ = require('lodash');
const sharp = require('sharp');

let count = 0;
setInterval(() => {
    if (++count % 20 === 0) {
        console.log(count, _.mapValues(process.memoryUsage(), item => (item / 1024 / 1024).toFixed('4') + 'M'));
    }

    _makePost();
}, 300);

function _makePost() {
    let text = `
        <svg height="96" width="450">
            <text x="0" y="24" font-size="26" fill="white" font-family="Microsoft YaHei">
                极品装备,点击有,海量红包,点击送
            </text>
        </svg>
    `;

    return sharp(fs.readFileSync('./share.jpg'))
        .overlayWith(fs.readFileSync('./avatar.jpg'), {
            top: 100,
            left: 100
        })
        .overlayWith(Buffer.from(text), {
            top: 195,
            left: 212
        })
        .toBuffer();
}

以上代码中share.jpg是背景图,avatar.jpg是用户头像,代码逻辑是间隔300ms模拟一次合成,每20次合成打印一次进程的内存使用情况。

分析

通过node --trace_gc --trace_gc_verbose index.js运行上面的代码,可以发现应用内存在不断上升,海报合成运行360次后,应用内存会涨到500M左右,再往后内存还会继续涨下去。

360 { rss: '478.2734M',
  heapTotal: '10.9063M',
  heapUsed: '8.9829M',
  external: '12.2104M' }

针对process.memoryUsage()的输出,官方有相应解释:

process.memoryUsage获得的内存使用情况外,GC也打印了一些日志:

[26236:0x104800000] Fast promotion mode: false survival rate: 0%
[26236:0x104800000]   119240 ms: Scavenge 9.4 (12.4) -> 8.4 (12.4) MB, 10.6 / 5.4 ms  allocation failure 
[26236:0x104800000] Memory allocator,   used:  12704 KB, available: 1453664 KB
[26236:0x104800000] New space,          used:      7 KB, available:    999 KB, committed:   1024 KB
[26236:0x104800000] Old space,          used:   4824 KB, available:    498 KB, committed:   5436 KB
[26236:0x104800000] Code space,         used:   1216 KB, available:      0 KB, committed:   2048KB
[26236:0x104800000] Map space,          used:    419 KB, available:      0 KB, committed:    532 KB
[26236:0x104800000] Large object space, used:   2108 KB, available: 1453143 KB, committed:   2128 KB
[26236:0x104800000] All spaces,         used:   8576 KB, available: 1454641 KB, committed:  11168KB
[26236:0x104800000] External memory reported:    374 KB
[26236:0x104800000] External memory global 0 KB
[26236:0x104800000] Total time spent in GC  : 46.7 ms

两者结合,可以发现V8所使用的内存与process.memoryUsage()中heapTotal,heapUsed的基本一致,只占10M左右。由此判断内存泄漏并不是发生在V8当中,进而联想到代码频繁使用到Buffer(Buffer对象不经过V8的内存分配机制),内存泄漏的原因极大可能是由于这部分Buffer没有被释放导致。

Buffer内存不被释放,原因可能是指向Buffer的引用在V8中一直存在,但仔细分析代码后并没有发现可疑代码,只能逐步拆解代码细化问题原因,最终可重现问题的代码如下所示:

const sharp = require('sharp');

let count = 0;
setInterval(() => {
    if (++count % 100 === 0) {
        console.log(count, process.memoryUsage());
    }

    sharp(Buffer.from(`
        <svg>
            <text>example</text>
        </svg>
    `)).toBuffer();
}, 100);

在sharp中使用svg + text会导致内存泄漏,这个问题跟应用代码没什么关系,初步判断是sharp底层出了问题,有点难办。

给库的作者提了一个issue等待回复,搞不定的话只能再物色一下其他库完成这事,最终完成后再来更新这篇文章。

如果你的代码也用到了sharp以及svg+text,可以检查一下是否会导致内存泄漏。

2018-07-31更新

sharp库svg + text问题还没解决,但找到另外一种实现合成文字到图片的方式:用opentype.js将文字转换成path,在继续使用svg的方式合成,这样就不会存在内存泄漏,代码示例:

const sharp = require('sharp');
const font = require('opentype.js').loadSync('xxx.ttf'); // xxx.ttf: 字体文件路径

let text = '木有鱼丸';
let width = font.getAdvanceWidth(text, 24); // 动态计算文字宽度
let txtBuffer = Buffer.from(_svg4Text(text, {
    x: 0,
    y: 20,
    color: 'white',
    size: 24,
    height: 24,
    width: width
}));

sharp(input) // input: 图片路径或buffer
    .overlayWith(text, {
        top: 100,
        left: (750 - width) / 2 // 文字居中
    })
    .jpeg()
    .toBuffer()
    .then(ret => {
        // ret: 合成后的图片buffer
    });

function _svg4Text(text, options) {
    let {
        x,
        y,
        color,
        size,
        height,
        width
    } = options;

    let path = font.getPath(text, x, y, size);
    path.fill = color;

    return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
        height="${height}" width="${width}"> 

        ${path.toSVG()}
    </svg>`;
}

如果设置x = 0, y = 0,画出来的文字只能看到些许底部,我认为所看到的底部恰好好是“字粗”,自己通过实践得到y的取值采用与字体大小1:1.2的关系,比如字体大小是24,那么y = 24 / 1.2 = 20,这个潜规则并不严谨...

2018-08-07更新

使用svg+text的代码在OS X下运行的确看似有内存泄漏问题,在自己机器上运行8000次时RSS大小约4.8G,但这有可能是因为可用内存较大所以进程一直没有释放内存,水平不够,没去仔细研究这一块。

但svg+text的代码在linux下运行时,占用内存一直保持在30-40M,搭建完整的应用环境后进一步测试发这并不是内存泄漏,而是RSS占用过高。

应用进程因为这一个功能要多占用300M内存,考虑调用频率确实较低,计划使用子进程调用的方式实现该功能。