當業務量越來越大的時候,為了能保證服務的運行,限流是必不可少的!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小時內與您取得聯系。