LongLong's Blog

分享IT技术,分享生活感悟,热爱摄影,热爱航天。

使用Nginx和Redis实现消息推送

实现消息的实时推送,基本的思路就是客户端和服务器之间保持一个长连接,当有消息需要推送给客户端时,服务器发送消息的内容给客户端。而很多人会认为保持太多的长连接会严重影响系统的性能,但如今的网络模型都是基于事件驱动的非阻塞模式,对于总连接数N拥有O(1)的性能,所以并没有必要在这方面有所担心,空闲的连接只是会占用一定数量的内存而已。

1. 消息推送原理

客户端和服务端的Nginx保持一个长连接,同时Nginx和后端的Redis通过SUBSCRIBE指令保持一个长连接,CHANNEL的名称为经过加密的字符串,在建立长连接之前通过认证逻辑下发并作为建立长连接的参数(可以为多个),在需要对客户端进行推送时,只需要向对应的CHANNEL中通过PUBLISH指令发送相应的内容即可实现1对1或1对多的内容推送。

2. Nginx和Reids的配置

要实现Nginx与Redis的通信,可以使用Nginx的Lua模块来完成,其中需要用到resty-lua作为与Redis进行通信的API。

location /sub {
    --检测客户端是否断开
    lua_check_client_abort on;
    content_by_lua_block {
        local cjson = require "cjson";
        local redis = require "resty.redis";
        --建立连接
        local r = redis:new();
        r:connect("127.0.0.1", 6379);
        --启动消息订阅
        local res, err = r:subscribe(ngx.unescape_uri(ngx.var.arg_key));
        --如果客户端意外关闭,则断开与Redis的连接并退出运行
        ngx.on_abort(function()
            r:close();
            ngx.exit(499);
        end);
        --循环接收消息
        while not ngx.worker.exiting()
        do
            repeat
                local res, err = r:read_reply();
                if err then
                    break;
                end
                --接收完成后将消息发送给客户端
                local ok, err = ngx.say(cjson.encode(res));
                ngx.flush();
            until true
        end
    }
}

由于需要处理较高的连接数,所以在Nginx和Redis中需要将支持的最大连接数调到合适的值,由于Nginx对于每一个长连接都需要建立一个SUBSCRIBE的长连接到Redis,因此受到系统端口号的限制,一个IP地址只能支持65535个端口,因此最大连接数设置在60000左右即可。

#nginx
worker_processes 65535;
#redis
maxclients 60000

3. 测试效果

与Nginx建立长连接后,向Redis中依次发布两条消息

curl http://localhost:8080/sub?key=abc -v
< HTTP/1.1 200 OK
< Server: openresty/1.13.6.2
< Date: Sun, 02 Dec 2018 03:48:39 GMT
< Content-Type: application/octet-stream
< Transfer-Encoding: chunked
< Connection: keep-alive
<
["message","abc","123"]
["message","abc","666"]

长连接数测试,建立15000个长连接到Nginx

const http = require('http');
options = {
	hostname: '127.0.0.1',
	port: 8080,
	path: '/sub?key=abc'
};
list = [];
for (var i = 0; i < 15000; i++) {
	req = http.request(options);
	req.end();
	list.push(req);
}

15000个长连接建立后,Nginx主进程占用的内存为280M左右

# Clients
connected_clients:15001

  VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
312672 284664    784 S   0.0  28.2   0:59.09 nginx

4. 总结

使用Nginx和Redis实现了简单的消息推送系统,但存在的缺点是一个IP只能处理60000个连接,不过可以通过在一台服务器上启动多个Nginx实例并绑定不同的IP来充分利用系统的资源。

使用Nginx动态缩放图片

在使用图片时,为了加快加载速度一般会根据页面中实际使用的尺寸使用原图的缩略图,一般情况各种规格的缩略图会在上传之后进行生成。而实际产品设计中可能会用到没有生成过的规格的缩略图,而增加一种规格的缩略图会是一个很大的维护操作,比较容易想到的方法是在实际使用时动态生成缩略图。

1. Nginx的image filter模块

Nginx原生的image filter模块使用libgd对图片进行操作,支持缩放,旋转,裁剪三种操作。例如以下配置可将图片缩放为150X100,并旋转90度

location /img/ {
    proxy_pass   http://backend;
    image_filter resize 150 100;
    image_filter rotate 90;
}

配合Nginx的Lua脚本可以实现对图片的动态缩放要求,例如以下配置可提取URL中的缩放后图片宽高,进行缩放

location /resize/ {
    set $w 0;
    set $h 0;
    image_filter resize $w $h;
    rewrite_by_lua_block {
        --通过正则表达式提取图片缩放后的宽和高
        local m = ngx.re.match(ngx.var.uri, '^/resize(.*[^/]+)_(\\d+)x(\\d+)\\.jpg$');
        --匹配失败则返回404
        if not m then
            ngx.exit(ngx.HTTP_NOT_FOUND);
        end
        --设置缩放后的宽和高
        ngx.var.w = m[2];
        ngx.var.h = m[3];
        --执行Rewrite
        ngx.req.set_uri(m[1] .. '.jpg', false);
    }
}

注意image filter模块进行的缩放操作是保持宽高比的。另外由于使用的是libgd缩放的质量和性能都比较一般,同时如果需要根据图片的原始信息做更加复杂的处理,则image filter模块会较为难以实现。

2. Nginx配合OpenCV Lua模块

OpenCV作为一个比较强大的计算机视觉处理库,实现图片的缩放自然很简单,同时相比安装Lua的OpenCV模块,自己写一个wrapper反而来得更加容易一些

#include <opencv2/opencv.hpp>
#include <vector>
#include <cstring>
#include <iostream>
//这里必须声明为extern "C",否则在调用时会提示找不到方法
extern "C" {
    uchar* im_resize(const char* buf, int len, int h, int w, const char* ext, int& ret_len);
}
uchar* im_resize(const char* buf, int len, int h, int w, const char* ext, int& ret_len) {
    //破获抛出的异常,便于在Lua中进行调试
    try {
        //通过图片二进制数据流获得图片矩阵
        cv::Mat im = cv::imdecode(std::vector<char>(buf, buf + len), CV_LOAD_IMAGE_COLOR);
        //这里声明为UMat是便于在支持OpenCL的环境中进行加速
        cv::UMat src;
        cv::UMat dst;
        src = im.getUMat(cv::ACCESS_RW);
        //调用OpenCV的缩放方法
        cv::resize(src, dst, cv::Size(h, w));
        //重新生成压缩格式
        std::vector<uchar> ret;
        cv::imencode(ext, dst, ret);
        //返回结果
        ret_len = ret.size();
        return ret.data();
    } catch (cv::Exception& e) {
        std::cout << e.what() << std::endl;
        return NULL;
    }
}

编译的命令为

g++ -O2 -shared -fPIC `pkg-config opencv` resize.cc -o libresize.so

在nginx中使用Lua脚本进行调用,在Luajit中可以使用ffi模块直接调用动态库中的函数,需要使用ffi.cdef对函数的格式进行声明(类似include头文件)

location /resize/ {
    content_by_lua_block {
        --使用ffi直接调用动态库中的函数
        local ffi = require('ffi');
        --声明函数的格式,类似include头文件
        ffi.cdef[[
        char* im_resize(const char* buf, int len, int h, int w, const char* ext, int& ret_len);
        ]]
        ---加载动态库
        local resize = ffi.load('./libresize.so');
        --通过正则表达式提取图片缩放后的宽和高
        local m = ngx.re.match(ngx.var.uri, '^/resize(.*[^/]+)_(\\d+)x(\\d+).jpg$');
        --匹配失败则返回404
        if not m then
            ngx.exit(ngx.HTTP_NOT_FOUND);
        end
        --获取图像内容
        local res = ngx.location.capture(m[1] .. '.jpg');
        --如果没有返回200则直接返回对应的状态
        if res.status ~= ngx.HTTP_OK then
            ngx.exit(res.status);
        end
        --获得图像的内容和长度
        local body = res.body;
        local n = string.len(body);
        --初始化结果长度指针
        local ret_len = ffi.new('int[1]');
        --调用缩放函数
        local ret = resize.im_resize(body, n, tonumber(m[2]), tonumber(m[3]), '.jpg', ret_len);
        --返回结果
        ngx.header['Last-Modified'] = res.header['Last-Modified'];
        ngx.print(ffi.string(ret, ret_len[0]));
    }
}

如此实现的图片缩放功能经测试,处理能力在300~400rps(CPU为AMD Ryzen 2200G启用OpenCL加速),基本上还可以接受。