Skip to main content

lua 小事两则

00:00/00:00

找好听的歌越来越困难了, 求推荐


两个工作上遇到的小事情, 恰好都跟 lua 这个语言有关, 合并起来说一下

定时重置的计数器

项目中有这么一个需求, 对某些资源的访问做频率限制, 开发的解决思路是这样:

  1. 使用 redis 作为频率统计存储
  2. 使用 incr 作为存储手段
  3. 使用 expire 做超时控制

即如果我们要求控制的频率是 60次/分钟, 那只要使用一个 key, 设置1分钟的超时, 每次 incr, 只要 incr 拿到的值超过60, 就拒绝资源访问

代码上, 有几种实现:

版本1.0

第一次实现, 是直接在客户端实现以上的逻辑

local utils = require "utils"
local conf = require "conf"
local _M = {}

function _M.is_request_legal(key)
    local redis, err
    local res

    redis_cli, err = utils.get_redis()
    if err then
        return false
    end

    _, err = redis_cli:set(key, 0, "NX", "EX", conf.key_reset_interval)
    if err then
        redis_cli:close()
        return false
    end

    res, err = redis_cli:incr(key)
    if err then
        redis_cli:close()
        return false
    end

    if tonumber(res) > conf.qps_limit then
        redis_cli:close()
        return false
    end

    redis_cli:close()
    return true
end

return _M

这种设计很明显存在一个问题, 就是某些 key 可能永远被封禁, 因为 set 设置超时与 incr 不是原子性的, 有可能我在使用 sex 检查 key 是否存在时 key 还存在, 之后设置 incr 时 key 已经过期, 导致 incr 了一个不存在的 key, 一旦这种 key 被设置, 那这个 key 的 超时永远不会被设置, 访问次数只会增加不会重置

因此, 我们在下个版本里试图修复这个 bug

版本2.0

要修复版本1.0的问题, 需要使用”原子性”这个东西, 在 redis 里多操作的原子性是通过 lua 脚本实现的

local utils = require "utils"
local conf = require "conf"
local sha1 = require "sha1"
local _M = {}

function _M.is_request_legal(key)
    local redis, err
    local res
    local counter_script, counter_script_sha1

    redis_cli, err = utils.get_redis()
    if err then
        return false
    end

    counter_script = [[
        local res
        local key = KEYS[1]
        local expire_time = ARGV[1]
        local qps_limit = ARGV[2]
        local _, err = redis.pcall("set", key, 0, "NX", "EX", expire_time)
        if err then
            return -1
        end

        res, err = redis.pcall("incr", key)
        if err then
            return -1
        end

        if tonumber(res) > tonumber(qps_limit) then
            return 0
        end
        return -1
    ]]
    counter_script_sha1 = sha1(counter_script)
    res, err = redis_cli:evalsha(counter_script_sha1, key, conf.key_reset_interval, conf.qps_limit)
    if err == "NOSCRIPT No matching script. Please use EVAL." then
        res, err = redis_cli:eval(counter_script, key, conf.key_reset_interval, conf.qps_limit)
    end
    redis_cli:close()
    if err then
        return false
    end

    if tonumber(res) <= 0 then
        return false
    end

    return true
end

return _M

使用 lua 脚本后, “原子性”似乎能够保证, 那是否能够满足需求呢

实际上, 并不能, 即使使用 lua 脚本, 依然存在某些 key 不能被设置超时时间的 bug, 问题在于

redis 虽然是每次只处理一个请求, 但是对于过期key 的处理, 除了主动淘汰外, 还有被动淘汰的过程, 因此, 即使在”看似原子性的脚本里”, 依然会出现set 的时候 key 存在导致 set 失败, incr 的时候 key 已经过期导致的 key 没有超时时间, 复现的条件是key 在 set 与 incr 的时间间隔内过期了, 在访问频率比较大的场景下, 这种情况是必现的

版本3.0

问题在于, redis 没有incr key ex这种原子命令
在不存在原子命令的前提下, 实际上 没有任何精确的办法能够实现这种需求

我们来考虑一下, 使用 eval 脚本的问题在于不同命令之间的时间间隔导致的结果不一致, 如果我们事先考虑到这种不一致, 可以将即将到来的事情提前处理, 即不再通过检查 key 是否存在来设置超时, 而是在检查到 key 的过期时间小于某个阈值时直接删除key并重置 key 的超时时间

版本代码如下:

local utils = require "utils"
local conf = require "conf"
local sha1 = require "sha1"
local _M = {}

function _M.is_request_legal(key)
    local redis, err
    local res

    redis_cli, err = utils.get_redis()
    if err then
        return false
    end

    local counter_script = [[
        local res, err
        local key = KEYS[1]
        local expire_time = ARGV[1]
        local qps_limit = ARGV[2]
        local process_time = ARGV[3]

        res, err = redis.pcall("ttl", key)
        if err then
            return -1
        end
        if tonumber(res) < 0 or tonumber(res) < tonumber(process_time) then
            res, err = redis.pcall("set", key, 0, "EX", 3600)
            if err then
                return -1
            end
        end

        res, err = redis.pcall("incr", key)
        if err then
            return -1
        end

        if tonumber(res) > tonumber(qps_limit) then
            return 0
        end
        return -1
    ]]
    local counter_script_sha1 = sha1(counter_script)
    res, err = redis_cli:evalsha(counter_script_sha1, 1, key, conf.key_reset_interval, conf.qps_limit, conf.process_time)
    if err == "NOSCRIPT No matching script. Please use EVAL." then
        res, err = redis_cli:eval(counter_script, 1, key, conf.key_reset_interval, conf.qps_limit, conf.process_time)
    end
    redis_cli:close()

    if tonumber(res) <= 0 then
        return false
    end

    return true
end

return _M

最终, 我们在脚本的执行时间不会超时某个值的前提下完成了功能需求

可以看到, 因为引入了一个不精确的值, 实现并不美, 有类似需求的同学可以看下是否有更好看的解决思路

不能识别的 gzip 格式

最近产品上有一些升级, 体验上来讲就是用户下载文件更快了也更安全了, 但是有个问题是, 在小流量之后, 部分用户没法进行文档预览

我们检查了所有的接口, 返回的数据都是合法和符合预期的, 所以首先怀疑是端上的问题, 端上报有些数据无法进行解压, 说我们返回的 gzip 格式没有在 http 的 header 头里增加 Content-Encoding: gzip标记, 抓包检查请求后, 发现是有 header 的, 端上打日志无法打印 header

然后怀疑是不是运营商有什么奇怪的逻辑, 试过了所有接入的 ip, 都没发现问题

跟踪端的日志打印, 发现是端的 http 库自动去掉了 gzip 头, 并在去掉头之前尝试进行解压缩, 库的解压缩失败, 报错是在按照 gzip 格式读取完所有数据之后, 还有多余的字节

这时候开始怀疑是不是又出现数据最后多一个换行或者空格的情况, 在模块之间挨个尝试请求, 最后发现在某个流量调度模块里使用 lua 输出的时候使用了ngx.say()导致数据结尾增加了一个换行

改成 ngx.print()之后端报正常

所以, 自己不熟悉的 api 不能乱用

(查别人的代码的问题真是一件痛苦的事情)

打赏
微信扫一扫支付
微信logo微信扫一扫, 打赏作者吧~

One thought to “lua 小事两则”

  1. 第一个问题我觉得很简单啊,我就实现过,两个方案
    1) 自己管理超时
    2) 像操作其他数据一样,先锁住目标数据(保证一个可预测的状态)

    端的 http 库自动去掉了 gzip 头, 并在去掉头之前尝试进行解压缩
    这是说Android吧,Android我记得会干这种事,而且我觉得这样也很合理啊
    另外我们的代码太挫啦,数据前后出现乱七八糟空格换行这种已经出现好多次了
    而且更挫的是端上居然把这个BUG都当成feature了,上次把开头有空格的问题修复以后,端上直接解析失败了…

评论已关闭