在实际开发中,常常会使用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"
            local host = ngx.var.host
            --使用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 

[root@testnginx ~]# curl   -i 'http://testnginx.com/' -H 'Host: shop.zhe800.com'
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 db_locks=  ngx.shared.db_locks
local cache=  ngx.shared.db_cache

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 host = ngx.var.host
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个请求进行访问:
# webbench -c 5 -t 10 'http://www.zhe800.com/'
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中常见的缓存功能,它们各有利弊,在使用中通过合理的设计可以将其“利”发挥到最大,将其“弊”控制到最小。