Lua 的定位

  • 轻量级脚本语言

  • 解释执行

  • 极其简单

  • 嵌入式友好(Redis / Nginx / 游戏引擎)

最常见的 3 个地方

场景 用途
Redis 原子操作、事务逻辑
Nginx (OpenResty) 鉴权、限流、灰度
游戏服务器 配置、热更新

其操作保证原子性

基础语法

变量&类型

1
2
3
4
local a = 10    --number
local b = "hello" --string
local c = true -- bool
local d = nil -- nil

Lua只有4种基本类型,
string -> int
用tonumber

Lua没有struct,map之类,全靠table实现:

  • 当数组用

    1
    2
    local arr = {1, 2, 3}
    print(arr[1]) -- 1(下标从1开始)
  • 当map用

    1
    2
    local m = {}
    m["xx"] = "yy"
  • 混用

    1
    2
    3
    4
    5
    local t = {
    a = 1,
    b = 2,
    [3] = "c"
    }

声明时候不加local默认全部是全局变量,官方建议所有变量加local

条件if else

1
2
3
4
5
6
7
if count > limit then
return 0
elseif a = 1 then
return 2
else
return 1
end

循环

1
2
3
4
5
for i = 1, 10 do
end

while condition do
end

函数

1
2
3
function A(a, b) 
return a + b
end

Go + redis + Lua 实现限流

Go → Redis → Lua(单线程)→ 返回
调用方法:

1. 滑动窗口限流

新建一个脚本,实现滑动窗口限流

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var rateLimitLua = redis.NewScript(`
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])

redis.call('ZREMRANGEBYSCORE', key, 0, now - window)

local count = redis.call('ZCARD', key)

if count >= limit then
return 0
else
-- member 必须唯一!
redis.call('ZADD', key, now, now .. '-' .. math.random())
redis.call('PEXPIRE', key, window)
return 1
end
`)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 限流判断  
func allowRequest(key string, limit int, window time.Duration) (bool, error) {
now := time.Now().UnixMilli()

res, err := rateLimitLua.Run(
ctx,
rds.Get(),
[]string{key},
now,
window.Milliseconds(),
limit,
).Int()

if err != nil {
return false, err
}

return res == 1, nil
}

在Go对应接口中调用这个方法,对单一接口实现限流,实现了一个简单的限流接口,使用滑动窗口对单一ip进行次数检验,如果某段时间内调用接口次数过多,会被限流

2. 令牌桶

思想: 系统以特定的速率往桶里放置令牌,请求来了拿到令牌就放行,拿不到就拒绝

需要的数学模型

1
2
3
4
capacity      桶容量(最大令牌数)
rate 令牌生成速率(token / second)
tokens 当前令牌数
last_time 上次补充令牌的时间

计算思路:

  • 计算距离上次的时间差 Δt
  • 新增令牌 = Δt × rate
  • tokens = min(capacity, tokens + 新增)
  • 如果 tokens ≥ 1 → tokens -= 1,放行
  • 否则 → 拒绝
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
-- KEYS[1]  限流 key
-- ARGV[1] 当前时间戳(毫秒)
-- ARGV[2] 令牌生成速率(token / 秒)
-- ARGV[3] 桶容量
-- ARGV[4] 本次请求消耗的令牌数

local key = KEYS[1]
local now = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local capacity = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])

local res = redis.call('HMGET', key, 'tokens', 'timestamp')
local tokens = res[1]
local last_time = res[2]

if tokens == false or last_time == false then
tokens = capacity
last_time = now
else
tokens = tonumber(tokens)
last_time = tonumber(last_time)
end

-- 毫秒 → 秒
local delta = math.max(0, now - last_time) / 1000
local new_tokens = math.min(capacity, tokens + delta * rate)

if new_tokens < cost then
redis.call('HMSET', key,
'tokens', new_tokens,
'timestamp', now
)
return 0
else
redis.call('HMSET', key,
'tokens', new_tokens - cost,
'timestamp', now
)
return 1
end

手写踩坑:HMGET为空的时候返回的是false,不是nil,nil会炸
Go封装:

tokenBuctet.Go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
package auth

import (
"context"
"fmt"
redis2 "github.com/redis/go-redis/v9"
constance "novel-launch/novel/Biu/const"
"novel-launch/novel/resource"
"time"
)

var tokenBucketLuaScript = `
-- KEYS[1] 限流 key
-- ARGV[1] 当前时间戳(毫秒)
-- ARGV[2] 令牌生成速率(token / 秒)
-- ARGV[3] 桶容量
-- ARGV[4] 本次请求消耗的令牌数

local key = KEYS[1]
local now = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local capacity = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])

local res = redis.call('HMGET', key, 'tokens', 'timestamp')
local tokens = res[1]
local last_time = res[2]

if tokens == false or last_time == false then
tokens = capacity
last_time = now
else
tokens = tonumber(tokens)
last_time = tonumber(last_time)
end

-- 毫秒 → 秒
local delta = math.max(0, now - last_time) / 1000
local new_tokens = math.min(capacity, tokens + delta * rate)

if new_tokens < cost then
redis.call('HMSET', key,
'tokens', new_tokens,
'timestamp', now
)
return 0
else
redis.call('HMSET', key,
'tokens', new_tokens - cost,
'timestamp', now
)
return 1
end
`

// AllowRequest 令牌桶限流
func AllowRequest(ctx context.Context, key string, rate float64, capacity int) (bool, error) {
if key == "" {
return false, nil
}
if rate < 0 {
fmt.Println("warning: token rate < 0")
rate = constance.TokenRate
}
if capacity < 0 || capacity > 1000000 {
fmt.Println("warning: token capacity must be reset")
capacity = constance.TokenCapacity
}

tokenBucketLua := redis2.NewScript(tokenBucketLuaScript)
res, err := tokenBucketLua.Run(
ctx,
resource.Rdscli,
[]string{key},
time.Now().UnixMilli(),
rate, // token / 秒
capacity,
1,
).Int64()

if err != nil {
return false, err
}
return res == 1, nil
}

使用时候在接口运行入口处加上Allow验证,1放行,0拦截


cpp脚本模拟接口调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <iostream>
#include <thread>
#include <vector>
#include <cstdio>
#include <cstdlib>

int curlRequest() {
FILE* fp = popen(
"curl -s -o /dev/null -w \"%{http_code}\" http://localhost:8080/getDetail",
"r"
);
if (!fp) return -1;

char buf[16] = {0};
fgets(buf, sizeof(buf), fp);
pclose(fp);

return std::atoi(buf);
}

int main() {
const int N = 20;

std::vector<int> httpCodes(N);
std::vector<std::thread> threads;

for (int i = 0; i < N; i++) {
threads.emplace_back([i, &httpCodes]() {
httpCodes[i] = curlRequest();
});
}

for (auto& t : threads) {
t.join();
}

// 按顺序输出
std::cout << "==== HTTP Codes (ordered) ====" << std::endl;
for (int i = 0; i < N; i++) {
std::cout << "req " << i << " -> " << httpCodes[i] << std::endl;
}

return 0;
}

可看到后面的输出429,被限流了


注意

Redis Lua 硬限制

限制 原因
不能 sleep 会阻塞 Redis
不能访问网络 保证确定性
不能有无限循环 防止卡死
不能调用非确定性函数 一致性问题

redisLua会把redis卡死,所以要有lua-time-limit


Go + redis + Lua 实现分布式锁

为什么要有这个东西?

单机实例容器加锁时候不必考虑多实例问题,只需要直接加锁,比如平时咱自己写的项目,相对某个东西限制并发,就直接对他枷锁,但是当有多个集群都需要拿到或者修改同一个实例的东西时候,就不能直接在某个单实例集群里面加锁了,需要有一个能管理所有集群的或者是不在这些集群运行里面的单独中间件来操作,这里用redis实现分布式锁最合理,因为redis满足上面所有条件,是独立于运行集群的单独实例

使用redis + lua实现分布式锁,了解了redis + lua使用方法后,这件事情就变成了在redis上存一个变量,用这个变量来标识锁是否存在,实现资源的控制

一般来说,可以直接用redis setnx来设置一个变量实现锁的功能,但是这样只能上锁,无法控制锁的释放和安全性,因为不是原子操作,例如:

1
2
3
setnx lock 1

expire lock 10

setnx后进程刚刚好挂了
expire没操作

lock没有释放
出现死锁

lua的原子性恰好补足了这一缺点
加锁

1
redis.call("SET", KEYS[1], ARGV[1], "NX", "EX", ARGV[2])

解锁:

1
2
3
4
5
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end

  • 锁的 value 是 唯一标识(UUID)
  • 只有 owner 才能删除
  • GET + DEL 原子执行

实际使用场景

场景 1:定时任务防止重复执行

真实背景

  • 服务部署 5 个实例
  • 用 XXL-JOB / Cron / Kubernetes CronJob
  • 到点后 5 个实例同时触发

    如果没有分布式锁

实例 A:执行任务 实例 B:执行任务 实例 C:执行任务

同一任务被执行 5 次

  • 重复发消息
  • 重复跑脚本
  • 重复生成数据

分布式锁在保护什么?

“这个时间点,只允许一个实例执行”


场景 2:订单 / 支付回调

真实背景

  • 支付平台 回调可能重复
  • 用户也可能手动重试

    没锁会发生什么?

    第一次回调:扣库存、记账 第二次回调:再扣一次
    直接资损

    锁在保护什么?

“一次业务逻辑,只能成功执行一次”

场景 3:缓存重建(经典缓存击穿)

真实背景

  • 热 key 过期
  • 瞬间 1000 个请求进来

没锁的后果

1000 个请求 → 1000 次 DB 查询

加锁

1 个请求重建缓存 999 个等待 / 返回旧值

锁在保护什么?

“同一时刻,只允许一个人查 DB 回源”


场景 4:库存扣减 / 名额占用

真实背景

  • 抢课
  • 抢优惠券
  • 抢名额

    没锁

  • 超卖
  • 状态错乱

    场景 5:分布式 ID / 序列生成

    背景

  • 多节点生成唯一号
  • 严格递增
    全局唯一性 + 顺序性