当业务量越来越大的时候,为了能保证服务的运行,限流是必不可少的!OpenResty是一个高性能网关
OpenResty® is a dynamic web platform based on NGINX and LuaJIT.
OpenResty = Nginx + Lua,Lua是高性能脚本语言,有着C语言的执行效率但是又比C简单,能很方便的扩展OpenResty 的功能。
Lua 是由巴西里约热内卢天主教大学(Pontifical Catholic University of Rio de Janeiro)里的一个研究小组于1993年开发的一种轻量、小巧的脚本语言,用标准 C 语言编写,其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。
官网:http://www.lua.org/
docker + CentOS8 + Openresty 1.17.8.2
https://github.com/openresty/lua-resty-limit-traffic
Lua的库一般都是小巧轻便且功能都具备,这个限流库核心文件一共就四个,几百行代码就能实现限流功能,Lua的其他库也是这样,比如redis的库还是Http的库,麻雀虽小五脏俱全!
docker run -dit --name gw --privileged centos /usr/sbin/init
docker exec -it gw bash
在gw中
# 安装openresty
yum install -y yum-utils
yum-config-manager --add-repo https://openresty.org/package/centos/openresty.repo
yum install -y openresty
# 安装工具等
yum install -y net-tools vim telnet git httpd
# Openresty自带了lua-resty-limit-traffic组件,如果没有带,下载到/usr/local/openresty/lualib/resty/limit/文件夹即可
# 下载lua-resty-limit-traffic组件
[ `ls /usr/local/openresty/lualib/resty/limit/ | wc -l` = 0 ] && echo '请安装限速组件' || echo '已经安装限速组件'
# 安装了请忽略
cd ~ && git clone https://github.com/openresty/lua-resty-limit-traffic.git
mkdir -p /usr/local/openresty/lualib/resty/limit/
cp lua-resty-limit-traffic/lib/resty/limit/*.lua /usr/local/openresty/lualib/resty/limit/
# 启动openresy
openresty
场景:按照 ip 限制其并发连
参考: https://moonbingbing.gitbooks.io/openresty-best-practices/content/ngx_lua/lua-limit.html https://github.com/openresty/lua-resty-limit-traffic/blob/master/lib/resty/limit/conn.md https://developer.aliyun.com/article/759299
原理:lua_share_dict是nginx所有woker和lua runtime共享的,当一个请求来,往lua_share_dict记录键值对ip地址:1,当请求完成时再-1,再来一个在+1,设置一个上限5,当超过5时则拒绝请求,一定要注意内部重定向的问题!
mkdir -p /usr/local/openresty/lualib/utils
cat > /usr/local/openresty/lualib/utils/limit_conn.lua <<EOF
-- utils/limit_conn.lua
local limit_conn = require "resty.limit.conn"
-- new 的第四个参数用于估算每个请求会维持多长时间,以便于应用漏桶算法
local limit, limit_err = limit_conn.new("limit_conn_store", 8, 2, 0.05)
if not limit then
error("failed to instantiate a resty.limit.conn object: ", limit_err)
end
local _M = {}
function _M.incoming()
local key = ngx.var.binary_remote_addr
local delay, err = limit:incoming(key, true)
if not delay then
if err == "rejected" then
return ngx.exit(503) -- 超过的请求直接返回503
end
ngx.log(ngx.ERR, "failed to limit req: ", err)
return ngx.exit(500)
end
if limit:is_committed() then
local ctx = ngx.ctx
ctx.limit_conn_key = key
ctx.limit_conn_delay = delay
end
if delay >= 0.001 then
ngx.log(ngx.WARN, "delaying conn, excess ", delay,
"s per binary_remote_addr by limit_conn_store")
ngx.sleep(delay)
end
end
function _M.leaving()
local ctx = ngx.ctx
local key = ctx.limit_conn_key
if key then
local latency = tonumber(ngx.var.request_time) - ctx.limit_conn_delay
local conn, err = limit:leaving(key, latency)
if not conn then
ngx.log(ngx.ERR,
"failed to record the connection leaving ",
"request: ", err)
end
end
end
return _M
EOF
重点在于这句话local limit, limit_err = limit_conn.new("limit_conn_store", 8, 2, 0.05),允许的最大并发为常规的8个,突发的2个,一共8+2=10个并发,详情参考https://github.com/openresty/lua-resty-limit-traffic/blob/master/lib/resty/limit/conn.md#new
被拒绝的请求直接返回503
if err == "rejected" then
return ngx.exit(503) -- 超过的请求直接返回503
end
# 备份一下配置文件
cd /usr/local/openresty/nginx/conf/ && \cp nginx.conf nginx.conf.bak
# 添加配置
echo '' > /usr/local/openresty/nginx/conf/nginx.conf
vim /usr/local/openresty/nginx/conf/nginx.conf
添加如下内容
worker_processes 1;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
lua_code_cache on;
# 注意 limit_conn_store 的大小需要足够放置限流所需的键值。
# 每个 $binary_remote_addr 大小不会超过 16 字节(IPv6 情况下),算上 lua_shared_dict 的节点大小,总共不到 64 字节。
# 100M 可以放 1.6M 个键值对
lua_shared_dict limit_conn_store 100M;
server {
listen 80;
location / {
access_by_lua_block {
local limit_conn = require "utils.limit_conn"
-- 对于内部重定向或子请求,不进行限制。因为这些并不是真正对外的请求。
if ngx.req.is_internal() then
ngx.log(ngx.INFO,">> 内部重定向")
return
end
limit_conn.incoming()
ngx.log(ngx.INFO,">>> 请求进来了!")
}
content_by_lua_block {
-- 模拟请求处理时间,很重要,不加可能测试不出效果
-- 生产中没有请求是只返回一个静态的index.html的!
ngx.sleep(0.5)
}
log_by_lua_block {
local limit_conn = require "utils.limit_conn"
limit_conn.leaving()
ngx.log(ngx.INFO,">>> 请求离开了!")
}
}
}
}
重点在于这句话,模拟每个请求0.5秒处理完成
content_by_lua_block {
ngx.sleep(0.5)
}
注意在限制连接的代码里面,我们用 ngx.ctx 来存储 limit_conn_key。这里有一个坑。内部重定向(比如调用了 ngx.exec)会销毁 ngx.ctx,导致 limit_conn:leaving() 无法正确调用。 如果需要限连业务里有用到 ngx.exec,可以考虑改用 ngx.var 而不是 ngx.ctx,或者另外设计一套存储方式。只要能保证请求结束时能及时调用 limit:leaving() 即可。
openresty -s reload
上面的配置是每个请求处理0.5秒,并发是10
ab -n 10 -c 1 127.0.0.1/
# 请求全部成功,用时5s左右
Concurrency Level: 1
Time taken for tests: 5.012 seconds
Complete requests: 10
Failed requests: 0
ab -n 10 -c 10 127.0.0.1/
# 请求全部成功,用时1.5s左右
Concurrency Level: 10
Time taken for tests: 1.505 seconds
Complete requests: 10
Failed requests: 0
ab -n 20 -c 10 127.0.0.1/
# 请求全部成功,用时2s左右
Concurrency Level: 10
Time taken for tests: 2.005 seconds
Complete requests: 20
Failed requests: 0
ab -n 22 -c 11 127.0.0.1/
# 11个成功,11个失败
Concurrency Level: 11
Time taken for tests: 1.506 seconds
Complete requests: 22
Failed requests: 11
Non-2xx responses: 11 # HTTP状态非2xx的有11个,说明限并发成功(只有有非2xx的返回才会显示这句话)
上面测试的是content_by_lua,也就是内容直接在lua中生成,但是实际中内容有可能是后端服务器生成的,所以可以设置反向代理或者负载均衡,如下为反向代理配置
location / {
access_by_lua_block {
local limit_conn = require "utils.limit_conn"
-- 对于内部重定向或子请求,不进行限制。因为这些并不是真正对外的请求。
if ngx.req.is_internal() then
return
end
limit_conn.incoming()
}
log_by_lua_block {
local limit_conn = require "utils.limit_conn"
limit_conn.leaving()
}
# 反向代理
proxy_pass http://172.17.0.3:8080;
proxy_set_header Host $host;
proxy_redirect off;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 60;
proxy_read_timeout 600;
proxy_send_timeout 600;
}
location / {
access_by_lua_block {...}
content_by_lua_block {...}
log_by_lua_block {...}
}
nginx是按照阶段来执行指令的,和配置文件顺序没有关系,nginx是先执行access_by_lua_block,再执行content_by_lua_block,最后执行log_by_lua_block的,当在访问curl 127.0.0.1/时,如果没有content_by_lua_block,这里有一个内部重定向,会将127.0.0.1/的请求重定向到127.0.0.1/index.html,所以会按顺序再次执行access_by_lua_block,所以access_by_lua_block执行了两次,log_by_lua_block却执行了一次,当时的我十分懵逼,而加上content_by_lua或者proxy_pass则不会导致重定向,总之有内容来源时不会重定向,没有则会去找index.html导致重定向!
测试
vim /usr/local/openresty/nginx/conf/nginx.conf
# 修改成如下内容
server {
listen 80;
location / {
access_by_lua_block {
ngx.log(ngx.ERR,">>> access")
}
log_by_lua_block {
ngx.log(ngx.ERR,">>> log")
}
}
}
# 查看日志
tail -f /usr/local/openresty/nginx/logs/error.log
...[lua] access_by_lua(nginx.conf:24):2: >>> access, client: 127.0.0.1, server: , request: "GET / HTTP/1.1", host: "127.0.0.1"
...[lua] access_by_lua(nginx.conf:24):2: >>> access, client: 127.0.0.1, server: , request: "GET / HTTP/1.1", host: "127.0.0.1"
...[lua] log_by_lua(nginx.conf:27):2: >>> log while logging request, client: 127.0.0.1, server: , request: "GET / HTTP/1.1", host: "127.0.0.1"
这句话local limit_conn = require "utils.limit_conn",limit_conn中的local limit, limit_err = limit_conn.new("limit_conn_store", 8, 2, 0.05)只会初始化一次,之后都是用的都一个实例,不会每个请求进来都要new一个limit_conn有点浪费性能而且还把参数都重置了,是不可取的,所以封装到了utils.limit_conn中!
场景:限制 ip 每1s只能调用 10 次(允许在时间段开始的时候一次性放过10个请求)也就是说,速率不是固定的
也可以设置成别的,比如120/min,只需要修改个数和时间窗口(resty.limit.count和resty.limit.req区别在于:前者传入的是个数,后者传入的是速率)
mkdir -p /usr/local/openresty/lualib/utils
cat > /usr/local/openresty/lualib/utils/limit_count.lua <<EOF
-- utils/limit_count.lua
local limit_count = require "resty.limit.count"
-- rate: 10/s
local lim, err = limit_count.new("my_limit_count_store", 10, 1) -- 第二个参数次数,第三个参数时间窗口,单位s
if not lim then
ngx.log(ngx.ERR, "failed to instantiate a resty.limit.count object: ", err)
return ngx.exit(500)
end
local _M = {}
function _M.incoming()
local key = ngx.var.binary_remote_addr
local delay, err = lim:incoming(key, true)
if not delay then
if err == "rejected" then
ngx.header["X-RateLimit-Limit"] = "10"
ngx.header["X-RateLimit-Remaining"] = 0
return ngx.exit(503) -- 超过的请求直接返回503
end
ngx.log(ngx.ERR, "failed to limit req: ", err)
return ngx.exit(500)
end
-- 第二个参数是指定key的剩余调用量
local remaining = err
ngx.header["X-RateLimit-Limit"] = "10"
ngx.header["X-RateLimit-Remaining"] = remaining
end
return _M
EOF
echo '' > /usr/local/openresty/nginx/conf/nginx.conf
vim /usr/local/openresty/nginx/conf/nginx.conf
添加如下内容
worker_processes 1;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
lua_code_cache on;
lua_shared_dict my_limit_count_store 100M;
# resty.limit.count 需要resty.core
init_by_lua_block {
require "resty.core"
}
server {
listen 80;
location / {
access_by_lua_block {
local limit_count = require "utils.limit_count"
-- 对于内部重定向或子请求,不进行限制。因为这些并不是真正对外的请求。
if ngx.req.is_internal() then
return
end
limit_count.incoming()
}
content_by_lua_block {
ngx.sleep(0.1)
ngx.say('Hello')
}
# 如果内容源是反向代理
#proxy_pass http://172.17.0.3:8080;
#proxy_set_header Host $host;
#proxy_redirect off;
#proxy_set_header X-Real-IP $remote_addr;
#proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
#proxy_connect_timeout 60;
#proxy_read_timeout 600;
#proxy_send_timeout 600;
}
}
}
openresty -s reload
上面的配置是10/s,不叠加
ab -n 10 -c 10 127.0.0.1/
# 请求全部成功
Concurrency Level: 10
Time taken for tests: 0.202 seconds
Complete requests: 10
Failed requests: 0
ab -n 20 -c 20 127.0.0.1/
# 请求成功10个,其余全部失败
Concurrency Level: 20
Time taken for tests: 0.202 seconds
Complete requests: 20
Failed requests: 10
(Connect: 0, Receive: 0, Length: 10, Exceptions: 0)
Non-2xx responses: 10
HTTP/1.1 200 OK
Server: openresty/1.17.8.2
Date: Sat, 12 Sep 2020 09:46:06 GMT
Content-Type: application/octet-stream
Connection: keep-alive
X-RateLimit-Limit: 10 # 当前限制10个
X-RateLimit-Remaining: 9 # 剩余9个
场景:限制 ip 每1min只能调用 120次(平滑处理请求,即每秒放过2个请求),速率是固定的,并且桶没有容量(容量为0)
mkdir -p /usr/local/openresty/lualib/utils
cat > /usr/local/openresty/lualib/utils/limit_req_bucket.lua <<EOF
-- utils/limit_req_bucket.lua
local limit_req = require "resty.limit.req"
-- rate: 2/s即为120/min,burst设置为0,也就是没有桶容量,超过的都拒绝(rejected)
local lim, err = limit_req.new("my_limit_req_store", 2, 0)
if not lim then
ngx.log(ngx.ERR, "failed to instantiate a resty.limit.req object: ", err)
return ngx.exit(500)
end
local _M = {}
function _M.incoming()
local key = ngx.var.binary_remote_addr
local delay, err = lim:incoming(key, true)
if not delay then
if err == "rejected" then
return ngx.exit(503) -- 超过的请求直接返回503
end
ngx.log(ngx.ERR, "failed to limit req: ", err)
return ngx.exit(500)
end
end
return _M
EOF
echo '' > /usr/local/openresty/nginx/conf/nginx.conf
vim /usr/local/openresty/nginx/conf/nginx.conf
添加如下内容
worker_processes 1;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
lua_code_cache on;
lua_shared_dict my_limit_req_store 100M;
server {
listen 80;
location / {
access_by_lua_block {
local limit_count = require "utils.limit_req_bucket"
-- 对于内部重定向或子请求,不进行限制。因为这些并不是真正对外的请求。
if ngx.req.is_internal() then
return
end
limit_count.incoming()
}
content_by_lua_block {
ngx.sleep(0.1)
ngx.say('Hello')
}
# 如果内容源是反向代理
#proxy_pass http://172.17.0.3:8080;
#proxy_set_header Host $host;
#proxy_redirect off;
#proxy_set_header X-Real-IP $remote_addr;
#proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
#proxy_connect_timeout 60;
#proxy_read_timeout 600;
#proxy_send_timeout 600;
}
}
}
openresty -s reload
上面的配置是2/s即为120/min
ab -t 1 127.0.0.1/
# 实际请求1.1s,成功3个请求,符合预期
Time taken for tests: 1.100 seconds
Complete requests: 8656
Failed requests: 8653
(Connect: 0, Receive: 0, Length: 8653, Exceptions: 0)
Non-2xx responses: 8653
ab -t 5 127.0.0.1/
# 实际请求5.1s,成功11个请求,符合预期
Concurrency Level: 1
Time taken for tests: 5.100 seconds
Complete requests: 40054
Failed requests: 40043
(Connect: 0, Receive: 0, Length: 40043, Exceptions: 0)
Non-2xx responses: 40043
场景:限制 ip 每1min只能调用 120次(平滑处理请求,即每秒放过2个请求),速率是固定的,并且桶的容量有容量(设置burst)
只需要在桶(无容量)的基础之上增加burst的值即可,并且增加delay的处理
mkdir -p /usr/local/openresty/lualib/utils
cat > /usr/local/openresty/lualib/utils/limit_req_leaky_bucket.lua <<EOF
-- utils/limit_req_leaky_bucket.lua
local limit_req = require "resty.limit.req"
-- rate: 2/s即为120/min,增加桶容量为1/s,超过2/s不到(2+1)/s的delay,排队等候,这就是标准的漏桶
local lim, err = limit_req.new("my_limit_req_store", 2, 1)
if not lim then
ngx.log(ngx.ERR, "failed to instantiate a resty.limit.req object: ", err)
return ngx.exit(500)
end
local _M = {}
function _M.incoming()
local key = ngx.var.binary_remote_addr
local delay, err = lim:incoming(key, true)
if not delay then
if err == "rejected" then
return ngx.exit(503) -- 超过的请求直接返回503
end
ngx.log(ngx.ERR, "failed to limit req: ", err)
return ngx.exit(500)
end
-- 此方法返回,当前请求需要delay秒后才会被处理,和他前面对请求数
-- 所以此处对桶中请求进行延时处理,让其排队等待,就是应用了漏桶算法
-- 此处也是与令牌桶的主要区别
if delay >= 0.001 then
ngx.sleep(delay)
end
end
return _M
EOF
echo '' > /usr/local/openresty/nginx/conf/nginx.conf
vim /usr/local/openresty/nginx/conf/nginx.conf
添加如下内容
worker_processes 1;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
lua_code_cache on;
lua_shared_dict my_limit_req_store 100M;
server {
listen 80;
location / {
access_by_lua_block {
local limit_count = require "utils.limit_req_leaky_bucket"
-- 对于内部重定向或子请求,不进行限制。因为这些并不是真正对外的请求。
if ngx.req.is_internal() then
return
end
limit_count.incoming()
}
content_by_lua_block {
-- 模拟每个请求的耗时
ngx.sleep(0.1)
ngx.say('Hello')
}
# 如果内容源是反向代理
#proxy_pass http://172.17.0.3:8080;
#proxy_set_header Host $host;
#proxy_redirect off;
#proxy_set_header X-Real-IP $remote_addr;
#proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
#proxy_connect_timeout 60;
#proxy_read_timeout 600;
#proxy_send_timeout 600;
}
}
}
openresty -s reload
上面的配置是2/s,漏桶容量为1/s,即总共3/s,模拟的每个请求耗时为0.1s,那么1s内能处理至少10个请求
ab -t 1 127.0.0.1/
# 实际请求1.102s,成功3个请求,1s两个请求,一个是delay,符合预期
Time taken for tests: 1.103 seconds
Complete requests: 3
Failed requests: 0
场景:限制 ip 每1min只能调用 120次(平滑处理请求,即每秒放过2个请求),但是允许一定的突发流量(突发的流量,就是桶的容量(桶容量为60),超过桶容量直接拒绝
令牌桶其实可以看着是漏桶的逆操作,看我们对把超过请求速率而进入桶中的请求如何处理,如果是我们把这部分请求放入到等待队列中去,那么其实就是用了漏桶算法,但是如果我们允许直接处理这部分的突发请求,其实就是使用了令牌桶算法。
这边只要将上面漏桶算法关于桶中请求的延时处理的代码修改成直接送到后端服务就可以了,这样便是使用了令牌桶
mkdir -p /usr/local/openresty/lualib/utils
cat > /usr/local/openresty/lualib/utils/limit_req_token_bucket.lua <<EOF
-- utils/limit_req_token_bucket.lua
local limit_req = require "resty.limit.req"
-- rate: 2/s即为120/min,增加桶容量为60/s,超过2/s不到(2+60)/s的突发流量直接放行
local lim, err = limit_req.new("my_limit_req_store", 2, 60)
if not lim then
ngx.log(ngx.ERR, "failed to instantiate a resty.limit.req object: ", err)
return ngx.exit(500)
end
local _M = {}
function _M.incoming()
local key = ngx.var.binary_remote_addr
local delay, err = lim:incoming(key, true)
if not delay then
if err == "rejected" then
return ngx.exit(503) -- 超过的请求直接返回503
end
ngx.log(ngx.ERR, "failed to limit req: ", err)
return ngx.exit(500)
end
if delay >= 0.001 then
-- 不做任何操作,直接放行突发流量
-- ngx.sleep(delay)
end
end
return _M
EOF
echo '' > /usr/local/openresty/nginx/conf/nginx.conf
vim /usr/local/openresty/nginx/conf/nginx.conf
添加如下内容
worker_processes 1;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
lua_code_cache on;
lua_shared_dict my_limit_req_store 100M;
server {
listen 80;
location / {
access_by_lua_block {
local limit_count = require "utils.limit_req_token_bucket"
-- 对于内部重定向或子请求,不进行限制。因为这些并不是真正对外的请求。
if ngx.req.is_internal() then
return
end
limit_count.incoming()
}
content_by_lua_block {
-- 模拟每个请求的耗时
ngx.sleep(0.1)
ngx.say('Hello')
}
# 如果内容源是反向代理
#proxy_pass http://172.17.0.3:8080;
#proxy_set_header Host $host;
#proxy_redirect off;
#proxy_set_header X-Real-IP $remote_addr;
#proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
#proxy_connect_timeout 60;
#proxy_read_timeout 600;
#proxy_send_timeout 600;
}
}
}
openresty -s reload
上面模拟的每个请求耗时为0.1s,那么1s内能处理至少10个请求
ab -n 10 -c 10 -t 1 127.0.0.1/
# 实际请求1s,成功13个请求,可以看到是远远超过2个请求的,多余就是在处理突发请求
Concurrency Level: 10
Time taken for tests: 1.000 seconds
Complete requests: 12756
Failed requests: 12743
(Connect: 0, Receive: 0, Length: 12743, Exceptions: 0)
Non-2xx responses: 12743
上面的三种限速器conn、count、req可以进行各种组合,比如一个限速器是限制主机名的,一个是限制ip的,可以组合起来使用
参考:https://github.com/openresty/lua-resty-limit-traffic/blob/master/lib/resty/limit/traffic.md
因为工作中经常与 nginx 打交道,而 nginx 又有大量的模块是由 Lua 写的,所以有必要学习下 Lua 基础的语法知识。Lua 作为一门动态脚本语言,解释执行,和 JavaScript 有点相似。
语言特点
注释
内置数据类型
总共有6种内置数据类型, 其中包括nil, boolean, number, string, table, function
name = "dev4mobile" name = 'dev4mobile' nameWithAge = 'dev4mobile \n 25'
welcome = [[ hello world ]]
arr = { 1, "dev4mobile", 'cn.dev4mobile@gamil.com', 12.3, function()endv} person = { name = 'dev4mobile' }
-- 一般定义 function add(a, b) return a + b end -- 传递多个参数 funcation print(...) print(...) end -- 返回多个参数 function() return "abc", 12, function() end end
控制流语句
-- for 循环 arr = { 1, 2, 3, 4, 5 } for i=1, #arr do -- 索引从1开始 print(arr[i]) end -- while 循环 arr = { 1, 2, 3, 4, 5 } i = 1 while i <= #arr do print(arr[i]) i = i + 1 end -- repeate until 循环 arr = { 1, 2, 3, 4, 5 } i = 1 repeat print(arr[i]) i = i + 1 until i >= #arr
name = "dev4mobile" if #name > 10 then print("name length = ".. #name) elseif #name >5 then print("name length > 5, real length = "..#name) -- 两个点..代表字符串 else print("name length < "..#name) end
面向对象
实现原理:有点类似 JavaScript 的实现使用原型方式,使用函数 + table 实现。
众所周知,内存的高低是评判一款app的性能优劣的重要的指标之一。如何更简单的帮助开发者分析、暴露且解决内存泄漏问题,几乎是每一个平台或框架、开发者亟需的一个的"标配"的feature。但是对于flutter社区,缺少一款用得顺手的内存泄漏工具。
对于使用flutter而言,因使用dart语言,通过形成渲染树提交到c++的skia进行渲染,从dart层到c++层拥有很长的渲染链路,使用者必须对整个渲染链路有通盘深刻的理解,才能深刻此时此刻的内存使用情况。本文提出一种基于渲染树个数的方式寻找内存泄漏的解决方案。
当我们谈论内存时,通常说的是物理内存(Physical memory),同一个应用程序运行在不同机器或者操作系统上时,会因不同操作系统和机器的硬件条件的不同,分配的到物理内存大小会有所不同,但大致而言,一款应用程序所使用到的虚拟内存(Virtual Memory)而言便会大致一样,本文讨论的都指的是虚拟内存。
我们可以直观的理解,代码中操作的所有对象都是能用虚拟内存衡量,而不太关心对象是否存在于物理内存与否,只要能减少对象的应用,尽量少的持有对象,不管白猫黑猫,能减少对象的,都是“好猫”。
flutter从使用的语言上,可以分成3大部分,
Framework层 由Dart编写,开发者接触到顶层,用于应用层开发
Engine 层,由C/C++编写,主要进行图形渲染
Embedder层,由植入层语言编写,如iOS使用Objective-C/swift,Android使用java
当我们从进程角度谈论flutter应用的内存时,指的是这个三者所有的内存的总和。
为简化,这里可以简单的以使用者能直接接触的代码为边界,将其分成DartVM和native内存, DartVM指Dart虚拟机占用内存,而native内存包含Engine和平台相关的代码运行的内存。
既然说Flutter的使用者能接触到的最直接的对象都是使用Dart语言生成的对象,那么对于Engine层的对象的创建与销毁,使用者似乎鞭长莫及了?这就不得不说Dart虚拟机绑定层的设计了。
出于性能或者跨平台或其他原因,脚本语言或者基于虚拟机的语言都会提供c/c++或函数对象绑定到具体语言对象的接口,以便在语言中接着操控c/c++对象或函数,这层API称为绑定层。例如: 最易嵌入应用程序中的Lua binding ,Javascript V8 引擎的binding 等等。
Dart虚拟机在初始化时,会将C++声明的某个类或者函数和某个函数和Dart中的某个类或者绑定起来,依次注入Dart运行时的全局遍历中,当Dart代码执行某一个函数时,便是指向具体的C++对象或者函数。
下面是几个常见的绑定的几个c++类和对应的Dart类
flutter::EngineLayer --> ui.EngineLayer
flutter::FrameInfo --> ui.FrameInfo
flutter::CanvasImage --> ui.Image
flutter::SceneBuilder --> ui.SceneBuilder
flutter::Scene --> ui.Scene
以 ui.SceneBuilder
一个例子了解下Dart是如何绑定c++对象实例,并且控制这个c++实例的析构工作。
Dart层渲染过程是配置的layer渲染树,并且提交到c++层进行渲染的过程。
ui.SceneBuilder
便是这颗渲染树的容器
Dart代码调用构造函数 ui.SceneBuilder
时,调用c++方法SceneBuilder_constructor
调用 flutter::SceneBuilder
的构造方法并生成c++实例sceneBuilder
因 flutter::SceneBuilder
继承自内存计数对象RefCountedDartWrappable
,对象生成后会内存计数加1
将生成c++实例sceneBuilder使用Dart的API生成一个 WeakPersitentHandle
,注入到Dart上下中。在这里之后,Dart便可使用这个builder
对象,便可操作这个c++的flutter::SceneBuilder
实例。
程序运行许久后,当Dart虚拟机判断Dart 对象builder没有被任何其他对象引用时(例如简单的情况是被置空builder=,也称为无可达性),对象就会被垃圾回收器(Garbage Collection)回收释放,内存计数将会减一
当内存计数为0时,会触发c++的析构函数,最终c++实例指向的内存块被回收
可以看到,Dart是通过将C/C++实例封装成WeakPersitentHandle且注入到Dart上下文的方式,从而利用Dart虚拟机的GC(Garbage Collection)来控制C/C++实例的创建和释放工作
更直白而言,只要C/C++实例对应的Dart对象能正常被GC回收,C/C++所指向的内存空间便会正常释放。
因为Dart对象在VM中会因为GC整理碎片化中经常移动,所以使用对象时不会直接指向对象,而是使用句柄(handle)的方式间接指向对象,再者c/c++对象或者实例是介乎于Dart虚拟机之外,生命周期不受作用域约束,且一直长时间存在于整个Dart虚拟机中,所以称为常驻(Persistent),所以WeakPersistentHandle专门指向生命周期与常在的句柄,在Dart中专门用来封装C/C++实例。
在flutter官方提供的Observatory工具中,可以查看所有的WeakPersistentHandle对象
其中Peer这栏也就是封装c/c++对象的指针
Dart对象释放会被垃圾回收器(Garbage Collection)进行释放,是通过判定对象是否还有可达性(availability)来达到的。可达性是指通过某些根节点出发,通过对象与对象间的引用链去访问对象,如可通过引用链去访问对象,则说明对象有可达性,否则无可达性。
黄色有可达性,蓝色无可达性
看到这里我们会发现一个问题,其实我们很难从Dart侧感知C/C++对象的消亡,因为Dart对象无统一的如同C++类一样的析构函数,一旦对象因为循环引用等的原因被长期其他对象长期引用,GC将无法将其释放,最终导致内存泄漏。
将问题放大一点,我们知道flutter是一个渲染引擎,我们通过编写Dart语言构建出一颗Widget树,进而经过绘制等过程简化成Element树,RenderObject树,Layer树,并将这颗Layer树提交至C++层,进而使用Skia进行渲染。
如果某个Wigdet树或Element树的某个节点长期无法得到释放,将可能造成他的子节点也牵连着无法释放,将泄漏的内存空间迅速扩大。
例如,存在两个A,B界面,A界面通过Navigator.push的方式添加B界面,B界面通过Navigator.pop回退到A。如果B界面因为某些写法的缘故导致B的渲染树虽然被从主渲染树解开后依然无法被释放,这会导致整个原来B的子树都无法释放。
基于上面的这一个情况,我们其实可以通过对比当前帧使用到的渲染节点个数,对比当前内存中渲染节点的个数来判断前一个界面释放存在内存泄漏的情况。
Dart代码中都是通过往 ui.SceneBuilder
添加EngineLayer的方式去构建渲染树,那么我们只要检测c++中内存中EngineLayer的个数,对比当前帧使用的EngineLayer个数,如果内存中的EngineLayer个数长时间大于使用的个数,那么我们可以判断存在有内存泄漏
依然以上次A页面pushB界面,B界面pop回退A界面为例子。正常无内存泄漏的情况下,正在使用的layer个数(蓝色),内存中的layer个数(橙色)两条曲线的虽然有波动,但是最终都会比较贴合。
但是在B页面存在内存泄漏的时候,退到A界面后,B树完全无法释放,内存中的layer个数(橙色)无法最终贴合蓝色曲线(正在使用的layer个数)
也就是说,对于渲染而言,如果代码导致Widget树或Element树长时间无法被GC回收,很可能会导致严重的内存泄漏情况。
目前发现异步执行的代码的场景(Feature, async/await,methodChan)长期持有传入的BuildContext,导致 element 被移除后,依然长期存在,最终导致以及关联的 widget, state 发生泄漏。
再继续看B页面泄漏的例子
正确与错误的写法的区别在于,错误的仅是在调用Navigator.pop之前,使用异步方法Future引用了BuildContext,便会导致B界面内存泄漏。
目前flutter内存泄漏检测工具的设计思路是,对比界面进入前后的对象,寻找出未被释放的对象,进而查看未释放的引用关系(Retaining path或Inbound references),再结合源码进行分析,最后找到错误代码。
使用Flutter自带的Observatory纵然可以一个一个查看每个泄漏对象的引用关系,但是对于一个稍微复杂一点的界面而言,最终生成的layer个数是非常庞杂的,想要在Observatory所有的泄漏对象中找到有问题的代码是一项非常庞杂的任务。
为此我们将这些繁杂的定位工作都进行了可视化。
我们这里将每一帧提交到engine的所有EngineLayer进行了一个记录,并且以折线图的形式记录下来,如果上文说的内存中的layer个数异常的大于使用中的layer个数,那么就可判断前一个页面存在有内存泄漏。
进而,还可以抓取当前页面的layer树的结构,用以辅助定位具体由哪个RenderObject树生成的Layer树,进而继续分析由哪个Element节点生成的RenderObject节点
或者也可以打印出WeakPersitentHandle的引用链辅助分析
但如今的痛点依然存在,依然需要通过查看Handle的引用链,结合源码的分析才能最终比较快捷的定位问题。这也是接下来亟需解决的问题。
我们这种从渲染树的角度去探寻flutter内存泄漏的方法,可以推广到所以其他Dart不同类型的对象。
开发者在编写代码时,需要时刻注意异步调用,以及时刻注意操纵的Element会否被引用而导致无法释放
闲鱼作为长期深耕flutter的团队,也在持续在flutter工具链中持续发力,当然也少不了这一重要的内存检测工具的深入开发,欢迎大家持续关注!
*请认真填写需求信息,我们会在24小时内与您取得联系。