首页
学习
活动
专区
工具
TVP
发布
精选内容/技术社群/优惠产品,尽在小程序
立即前往

缓存利器(五)、缓存和数据库的交互

在实际开发中,常常会使用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中常见的缓存功能,它们各有利弊,在使用中通过合理的设计可以将其“利”发挥到最大,将其“弊”控制到最小。

  • 发表于:
  • 原文链接https://kuaibao.qq.com/s/20181219G1311M00?refer=cp_1026
  • 腾讯「腾讯云开发者社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券