My logo
Published on

Redis 实战 Lua 脚本应用

频率控制

10秒内只能访问3次。 后续该脚本可以在nginx或者程序运行脚本中直接使用,判断返回是否为0,就0就不让其继续访问。

-- redis-cli --eval ratelimiting.lua rate.limitingl:127.0.0.1 , 10 3

-- rate.limitingl + 1
local times = redis.call('incr',KEYS[1])

-- 第一次访问的时候加上过期时间10秒(10秒过后从新计数)
if times == 1 then
    redis.call('expire',KEYS[1], ARGV[1])
end

-- 注意,从redis进来的默认为字符串,lua同种数据类型只能和同种数据类型比较
if times > tonumber(ARGV[2]) then
    return 0
end
return 1

以上,如果不使用redis+lua,那高并发下incr和expire就会出现原子性破坏,造成expire执行多次浪费

延时队列

Zset 里面存储的是 Value/Score 键值对,我们将 Value 存储为序列化的任务消息,Score 存储为下一次任务消息运行的时间(Deadline),然后轮询 ZsetScore 值大于 Now 的任务消息进行处理。

# 生产延时消息
zadd(queue-key, now_ts+5, task_json)
# 消费延时消息
while True:
  task_json = zrevrangebyscore(queue-key, now_ts, 0, 0, 1)
  if task_json:
    grabbed_ok = zrem(queue-key, task_json)
    if grabbed_ok:
      process_task(task_json)
  else:
    sleep(1000)  // 歇 1s

当消费者是多线程或者多进程的时候,这里会存在竞争浪费问题。当前线程明明将 task_json 从 Zset 中轮询出来了,但是通过 Zrem 来争抢时却抢不到手。 这时就可以使用 LUA 脚本来解决这个问题,将轮询和争抢操作原子化,这样就可以避免竞争浪费。

local res = nil
local tasks = redis.pcall("zrevrangebyscore", KEYS[1], ARGV[1], 0, "LIMIT", 0, 1)
if #tasks > 0 then
  local ok = redis.pcall("zrem", KEYS[1], tasks[1])
  if ok > 0 then
    res = tasks[1] 
  end
end
return res

自增ID

local key = KEYS[1]
local id = redis.call('get',key)
if(id == false)
then
    redis.call('set',key,1)
    return key.."0001"
else
    redis.call('set',key,id+1)
    return key..string.format('%04d',id + 1)
end

通过lua使getset命令原子化,杜绝高并发下的浪费。

秒杀或者抢红包

  • 业务需求: 每次只允许领取10个红包
  • 操作流程:判断是否能抢->抢到红包->记录抢到红包的人->异步发红包
  • 解决问题:高并发下的红包超发(或者商品超卖),判断能否抢和抢一定要原子性的捆绑在一起,否则就会出现超发
-- 抢红包脚本
--[[
--red:list 为 List 结构,存放预先生成的红包金额
red:draw_count:u:openid 为 k-v 结构,用户领取红包计数器
red:draw为 Hash 结构,存放红包领取记录
red:task 也为 List 结构,红包异步发放队列
openid 为用户的openid
]]--
local openid = KEYS[1]
local isDraw = redis.call("HEXISTS","red:draw",openid)

-- 已经领取
if isDraw ~= 0 then
    return true
end
-- 领取太多次了
local times = redis.call("INCR","red:draw_count:u:"..openid)
if times and tonumber(times) > 9 then
    return 0
end

local number = redis.call("RPOP","red:list")
-- 没有红包
if not number then
    return {}
end
-- 领取人昵称为Fhb,头像为 https:// xxxxxx
local red = {money=number,name=KEYS[2] , pic = KEYS[3] }
-- 领取记录
redis.call("HSET","red:draw",openid,cjson.encode(red))

-- 处理队列
red["openid"] = openid
redis.call("RPUSH","red:task",cjson.encode(red))

return true

分布式锁

Redis在 2.6以前的版本用setnx做分布式锁的时候,会出现setnxexpire遭到原子性破坏的可能,必须要配合lua脚本来实现原子性。但在2.6.12 版本开始,为 SET 命令增加了一系列选项: SET key value[EX seconds][PX milliseconds][NX|XX]

  • EX seconds:设置指定的过期时间,单位秒。
  • PX milliseconds:设置指定的过期时间,单位毫秒。
  • NX:仅当key不存在时设置值。
  • XX:仅当key存在时设置值。

可以看出来,SET 命令的天然原子性完全可以取代 SETNXEXPIRE 命令。

/**
 * redis排重锁
 * @param $key
 * @param $expires
 * @param int $value
 * @return mixed
 */
public function redisLock($key, $expires, $value = 1)
{
    //在key不存在时,添加key并$expires秒过期
    return $this->redis->set($key, $value, ['nx', 'ex' => $expires]);
}

总结:凡是需要多条Redis命令需要捆绑在一起原子性操作的,都要使用lua来实现。