在实际开发中,常常会使用NoSQL缓存数据来减少MySQL的读取压力,同样,也可以利用Ngx_Lua的缓存来减少MySQL的压力,本节将介绍缓存和数据库的交互方案。
10.5.1 从数据库获取数据
从MySQL中获取数据后存放到Ngx_Lua缓存中,有多种实现方案。下面是比较常见的3种方案。
A方案,适合在缓存的key较多时使用,流程大致如图10-2所示。
图10-2 当key较多时的缓存流程
B方案,适合在缓存的key较少时使用,流程大致如图10-3所示。
图10-3 当key较少时的缓存流程
C方案,适合在缓存的key非常少时使用,会定期请求Nginx缓存来刷新接口,缓存刷新接口时会同步所有的数据,所以不会存在miss缓存的情况。客户端的请求只和Nginx缓存打交道,不直接访问MySQL。当key非常少时的缓存流程如图10-4所示。
图10-4 当key非常少时的缓存流程
A方案和B方案的主要区别在于,B方案有定时任务,可以批量更新缓存的数据,这样客户端的请求一般就不会进入缓存未命中(缓存miss)的流程。C方案和B方案的区别在于,在C方案中客户端不和MySQL数据库直接打交道。
这3种方案都用到了指令lua_shared_dict,其实,使用lua-resty-lrucache也可以。下面就以lua-resty-lrucache为例来实现缓存与数据交互的方案。
首先,创建db_op模块,用来读取MySQL数据。方法是将下面的代码写入db_op.lua文件中,并存放到lua_package_path路径下:
local _DB = {}
--下面函数的主要任务是执行SQL语句,将数据提取出来
function _DB.getMySQL(sql)
local MySQL = require "resty.MySQL";
local db, err = MySQL:new();
if not db then
ngx.say("failed to instantiate MySQL: ", err);
return
end
--设置超时时间为5s
db:set_timeout(5000) ;
--连接MySQL
local ok, err, errcode, sqlstate = db:connect{
host = "10.19.10.113",
port = 3306,
database = "clairvoyant",
user = "ngx_test",
password = "ngx_test",
charset = "utf8",
max_packet_size = 2048 * 2048
}
--如果连接失败,则输出异常信息
if not ok then
ngx.say("failed to connect: ", err, ": ", errcode, " ", sqlstate);
return
end
--执行SQL语句
local sql = sql
local res, err, errcode, sqlstate =
db:query(sql)
if not res then
ngx.say("bad result: ", err, ": ", errcode, ": ", sqlstate, ".")
return
end
ngx.log(ngx.ERR,db:get_reused_times(),err)
local ok, err = db:set_keepalive(10000, 10)
if not ok then
ngx.say("failed to set keepalive: ", err);
return
end
return res
end
return _DB
然后,创建host_deny模块,其主要作用是实现对Ngx_Lua中的数据的缓存,将下面的代码写入host_deny.lua文件中,并存放到lua_package_path的路径下:
local _M = {}
local lrucache = require "resty.lrucache"
--载入db_op模块,用来传递SQL的参数
local db_op = require("db_op")
local cache, err = lrucache.new(1000) --声明1个可以缓存1000个key的列表
if not cache then
return error("failed to create the cache: " .. (err or "unknown"))
end
local function mem_set(host)
--利用Lua的格式化功能,将参数host的值合并到SQL语句中
local sql = string.format([[select sleep(3),host from nginx_ resource where host = '%s' limit 1]] , host)
--执行SQL语句
local res = db_op.getMySQL(sql)
if type(res) == 'table' then
for i, data in ipairs(res) do
--将读取到的数据插入共享内存中。'find'只是1个标识,也可以使用其他任意字符,重点是key是host要找的值
cache:set(data["host"],'find',5)
end
end
return
end
local function mem_get(host)
local res_host,stale_data = cache:get(host)
if res_host then
return res_host
elseif stale_data then
--如果数据过期,仍然会读取数据,这在某些场景下是很有用的,例如,当MySQL宕机时,它可以先提供过期数据来使用
mem_set(host)
res_host = cache:get(host)
return res_host
else
--没有数据, 执行SQL语句后,再返回数据
mem_set(host)
res_host = cache:get(host)
return res_host
end
end
function _M.fromcache(host)
--在缓存中查找URL的host头信息的值
local res_host = mem_get(host)
return res_host
end
return _M
添加Nginx配置文件,根据请求访问的host头信息设置白名单,作用是禁止某些域名的访问:
server {
listen 80;
location / {
access_by_lua_block {
--加载host_deny模块
local host_deny = require "host_deny"
local ngx = require "ngx"
--使用host_deny模块的fromcache函数查询host是否在白名单中
local white_host = host_deny.fromcache(host)
--如果白名单中没有,就返回403错误
if not white_host then
ngx.exit(ngx.HTTP_FORBIDDEN)
else
ngx.exit(ngx.OK)
end
}
content_by_lua_block {
ngx.say("hello world!!!")
}
}
}
先使用1个不在白名单中的域名进行访问,返回403错误;再使用1个在白名单中的域名进行访问,返回200,如下所示:
# curl -i 'http://testnginx.com/' -H 'Host: a.test.com'
HTTP/1.1 403 Forbidden
Server: nginx/1.12.2
Date: Mon, 18 Jun 2018 09:24:04 GMT
Content-Type: text/html
Content-Length: 169
Connection: keep-alive
HTTP/1.1 200 OK
Server: nginx/1.12.2
Date: Mon, 18 Jun 2018 09:23:31 GMT
Content-Type: application/octet-stream
Transfer-Encoding: chunked
Connection: keep-alive
hello world!!!
此服务存在一个隐患,即如果缓存miss过多,且有很多重复的请求时,会造成MySQL负担过大,从而产生不必要的资源消耗。下一节将会介绍使用锁机制来减少重复请求的方法。
10.5.2 避免缓存失效引起的“风暴”
为了减少重复请求访问数据库的次数,可以使用lua-resty-lock模块,它提供加锁的方式去访问数据库,类似于之前讲到的ngx_http_proxy_module模块中的proxy_cache_lock。
下面是在Nginx下安装lua-resty-lock的方法(OpenResty不需要安装,默认已经支持):
# wget -S https://codeload.github.com/openresty/lua-resty-lock/tar.gz/ v0.07 -O lua-resty-lock_0.07.tar.gz
# tar -zxvf lua-resty-lock_0.07.tar.gz
# cp lua-resty-lock-0.07/lib/resty/lock.lua \
/usr/local/nginx_1.12.2/conf/lua_modules/resty
注意:如果使用Nginx进行开发,但又不打算用resty.core模块,需使用lua-resty-lock 0.07版本。因为大于这个版本的lua-resty-lock需要加载resty.core模块才可以使用。
模块的Wiki已经给出了很直观的例子,供读者参考,地址为https://github.com/ openresty/lua-resty-lock。
以10.5.1节中的代码为例来实现锁机制,为了使锁操作看上去更明显,给SQL查询的请求加上了sleep(3),这样MySQL会等待3s后再返回数据,当缓存失效时,就可以看到锁的作用了。具体示例如下。
首先,需要有1个db_op模块来读取MySQL中的数据,db_op模块的内容与10.5.1节的代码一样,这里不再赘述。然后,创建host_deny.lua模块,其内容如下:
local _M = {}
--载入db_op模块,用来传递SQL语句的参数
local db_op = require("db_op")
-- db_locks的作用是存放锁的key(每个锁都需要1个名字,key就是锁的名字)的共享内存,cache存放的是业务数据,也就是要读取的key/value的缓存数据
local function get_MySQL(host)
--获取MySQL数据的配置文件没有太大的变化,因为锁操作并不在MySQL上
--SQL语句会在sleep 3s后才输出,这样当缓存过期时,很多请求就会等待MySQL的返回数据,从而形成锁的测试环境
local sql = string.format([[select sleep(3),host from nginx_ resource where host = '%s' limit 1]] , host)
local res = db_op.getMySQL(sql)
if res[1] then
local value = res[1]["host"] or nil
return value
end
return nil
end
local function lock_db(key)
--导入锁的模块
local resty_lock = require "resty.lock"
--创建锁的实例,db_locks就是之前声明存放锁key的共享内存
local lock, err = resty_lock:new("db_locks")
if not lock then
ngx.log(ngx.ERR,err)
return nil,"failed to create lock: " .. err
end
--对要查询的key加锁
local elapsed, err = lock:lock(key)
if not elapsed then
ngx.log(ngx.ERR,err)
return nil,"failed to acquire the lock: " .. err
end
--记录当前请求等待锁时花费的时间
ngx.log(ngx.ERR,elapsed)
--再次查询缓存,因为在锁的过程中,可能前面某个请求已经获得了数据并存放到了缓存中。如果没有,则继续执行查询
local val, err = cache:get(key)
if val then
--如果获取到值,就释放锁
local ok, err = lock:unlock()
if not ok then
ngx.log(ngx.ERR,err)
return nil,"failed to unlock: " .. err
end
return val
end
--从MySQL中获取数据
local val = get_MySQL(key)
if not val then
--即使没有查询到数据,也要释放锁
local ok, err = lock:unlock()
if not ok then
ngx.log(ngx.ERR,err)
return nil,"failed to unlock: " .. err
end
--如果某个key一直被高并发访问,但在MySQL中却没有数据,请求就会一直穿透缓存到MySQL中进行查询,特别是当服务被攻击时,并发会很高。这时,可以设置1个不存在的值如null来缓存一段时间,以减少这种穿透现象的发生
local ok,err = cache:set(key,'null',1) -- 1表示缓存时间是1s
return 'null' --将字符串null返回,退出此函数
end
--如果查询到val,就对缓存进行存储
local ok, err = cache:set(key, val,3)
if not ok then
--即使set失败,也要释放锁
local ok, err = lock:unlock()
if not ok then
return nil,"failed to unlock: " .. err
end
return nil,"failed to update shm cache: " .. err
end
--释放锁
local ok, err = lock:unlock()
if not ok then
return nil,"failed to unlock: " .. err
end
return val
end
local function mem_get(host)
local res_host = cache:get(host)
if res_host then
return res_host
else
--当缓存中没有数据时,执行锁操作的查询函数
local res_host = lock_db(host)
return res_host
end
end
function _M.fromcache(host)
--在缓存中查找host参数
local res_host = mem_get(host)
return res_host
end
return _M
上述代码的主要目的是从缓存中获取host头信息,如果没有获取到host头信息的数据,就去MySQL中读取,读取前会先给相同的key添加1个锁,这样可以确保同一个key的操作在同一时间内只会执行1次,剩下的请求需等锁返回后再执行。
注意:本次代码使用lua_shared_dict的共享内存做示例,各位读者也可以看到lua_shared_dict在使用上和lua-resty-lrucache有细微区别。
配置nginx.conf文件,内容如下:
--创建锁操作的共享内存区域
lua_shared_dict db_locks 1m;
--创建缓存数据的共享内存区域
lua_shared_dict db_cache 5m;
server {
listen 80;
location / {
access_by_lua_block {
local host_deny = require "host_deny"
local ngx = require "ngx"
local white_host = host_deny.fromcache(host) or nil
if not white_host then
ngx.exit(ngx.HTTP_FORBIDDEN)
else
ngx.exit(ngx.OK)
end
}
content_by_lua_block {
ngx.say("hello world!!!")
}
}
}
执行压测,并发5个请求进行访问:
error.log会输出如下的日志:
2018/06/19 19:17:56 [error] 8318#8318: *18671259 [lua] host_deny.lua:38: lock_db(): 0, client: 10.19.48.161, server: , request: "GET / HTTP/1.0", host: "www.zhe800.com"
2018/06/19 19:18:00 [error] 8318#8318: *18671262 [lua] host_deny.lua:38: lock_db(): 3.511, client: 10.19.48.161, server: , request: "GET / HTTP/1.0", host: "www.zhe800.com"
2018/06/19 19:18:00 [error] 8318#8318: *18671261 [lua] host_deny.lua:38: lock_db(): 3.511, client: 10.19.48.161, server: , request: "GET / HTTP/1.0", host: "www.zhe800.com"
2018/06/19 19:18:00 [error] 8318#8318: *18671263 [lua] host_deny.lua:38: lock_db(): 3.511, client: 10.19.48.161, server: , request: "GET / HTTP/1.0", host: "www.zhe800.com"
2018/06/19 19:18:00 [error] 8318#8318: *18671264 [lua] host_deny.lua:38: lock_db(): 3.511, client: 10.19.48.161, server: , request: "GET / HTTP/1.0", host: "www.zhe800.com"
2018/06/19 19:18:02 [error] 8318#8318: *18694720 [lua] host_deny.lua:38: lock_db(): 0, client: 10.19.48.161, server: , request: "GET / HTTP/1.0", host: "www.zhe800.com"
2018/06/19 19:18:05 [error] 8318#8318: *18694721 [lua] host_deny.lua:38: lock_db(): 3.011, client: 10.19.48.161, server: , request: "GET / HTTP/1.0", host: "www.zhe800.com"
2018/06/19 19:18:05 [error] 8318#8318: *18694722 [lua] host_deny.lua:38: lock_db(): 3.011, client: 10.19.48.161, server: , request: "GET / HTTP/1.0", host: "www.zhe800.com"
2018/06/19 19:18:05 [error] 8318#8318: *18694723 [lua] host_deny.lua:38: lock_db(): 3.011, client: 10.19.48.161, server: , request: "GET / HTTP/1.0", host: "www.zhe800.com"
2018/06/19 19:18:05 [error] 8318#8318: *18694724 [lua] host_deny.lua:38: lock_db(): 3.011, client: 10.19.48.161, server: , request: "GET / HTTP/1.0", host: "www.zhe800.com"
从日志中可以观察到如下情况。
lock_db() 打印出的超过3s的请求占比很高,这是因为加了sleep(3)。
最初缓存里没有数据,当第1条请求获取数据时加了锁。
如果在3s内多次请求相同的key,会产生锁,ngx.log(ngx.ERR,elapsed)输出的值就是锁等待的时间。
注意:当查询的数据在MySQL中不存在时,会发现打印日志要快很多,这是因为当MySQL查询为空时,sleep是不起作用的,但锁仍然在正常工作。
锁操作也可以做一些微调,避免出现因死锁或忘记释放锁而引发的性能问题,这些微调主要设置在new的指令中。
new
语法:obj, err = lock:new(dict_name, opts?)
含义:创建锁的新实例,dict_name是在Nginx配置中声明的共享内存。
opts是可选参数,它是table类型的,包含如下参数。
exptime:持有锁的有效时间(单位为秒),默认是30s,支持最小设置为0.001s。它可以用来避免产生死锁。
timeout:等待锁的最长时间,可以用来避免出现一直等待锁的情况。timeout的值不能超过exptime的值,并且支持设置为0立即返回。
step:等待锁的休眠时间(单位为秒),默认是0.001s,如果发现已经有锁,在等待锁时会休眠0.001s后再去尝试获取锁,如果锁仍然很忙(如被其他请求占用),就继续等待,但每次等待的时间会受到ratio控制。
ratio:控制等待锁的每次步长的比率,默认是2,这意味着下一次等待的时间会翻倍,但总的等待时间不能超过max_step的值。
max_step:设置最大的等待锁的睡眠时间(单位为秒),默认是0.5s。
小结
本章讲解了Ngx_Lua中常见的缓存功能,它们各有利弊,在使用中通过合理的设计可以将其“利”发挥到最大,将其“弊”控制到最小。
领取专属 10元无门槛券
私享最新 技术干货