1. 项目背景
1.1 业务场景
秒杀活动上线前的最后一轮压测发现了一个严重问题:在 1 万并发的压力下,库存偶尔会被扣成负数。开发同学排查代码后愣住了——逻辑是这样的:
if (redis.get("stock:sku1001") > 0) { // 步骤1: 检查库存
redis.decr("stock:sku1001"); // 步骤2: 扣库存
redis.sadd("buyers:sku1001", userId); // 步骤3: 记录购买
}
在单线程测试时完全正常,但并发压测下出现了超卖。根源在于:步骤 1(GET)和步骤 2(DECR)是两个独立的网络请求,中间有时间窗口。请求 A 在步骤 1 读到库存 = 1,还没来得及执行步骤 2;请求 B 也读到库存 = 1——两个请求都认为有库存,都执行了扣减,最终库存变成 -1。
另一个场景同样棘手:分布式锁的释放。第 12 章的互斥锁需要先比较 value 是否匹配再删除——如果先 GET 比较再 DEL,这两步也不是原子的,可能导致删错锁。
Redis 的每条命令是原子的,但多条命令组成的业务逻辑不是。Lua 脚本把多条 Redis 命令打包成一个"服务端原子块"——脚本在 Redis 主线程内部连续执行,中间不会被其他命令插入。
1.2 为什么不是 MULTI/EXEC?
| 需求 | MULTI/EXEC | Lua |
|---|---|---|
| 判断后修改(if 逻辑) | 不支持——MULTI 不能根据之前命令的结果决定下一步 | 支持——Lua 是完整的编程语言 |
| 条件循环 | 不支持 | 支持(但注意不要死循环) |
| 事务原子性 | 命令排队但不支持回滚 | 脚本要么全部执行,要么全部不执行 |
| 性能 | 每次 EXEC 传输所有命令 | 脚本缓存(EVALSHA)后只传 SHA 值 |
1.3 本章目标
掌握 Redis Lua 脚本的四种实战模式:
- 库存扣减+限购:原子地检查库存、扣减、记录购买。
- 释放锁:比较 value 后安全删除,防止误删。
- 复合计数器:INCR + 首次 EXPIRE 的原子组合。
- SCRIPT LOAD/EVALSHA:应用层脚本缓存和 NOSCRIPT 处理。
2. 项目设计
2.1 第一幕:为什么客户端代码不原子?
小胖:"Redis 不是单线程吗?我先 GET 再 DECR,两条命令肯定按顺序排队执行,怎么还会超卖?"
小白:"排队是指每个命令自己——GET 是一个原子操作,DECR 是一个原子操作。但两个命令之间有空隙——在这段空隙里,另一个客户端的命令可能被插入。Redis 不保证'你的多条命令中间不插入别人的命令'。"
大师拿笔画了一个时间线:
请求 A 请求 B Redis
│ │ │
├── GET stock ──────────────────┼─────────────────────▶ 返回 1
│ │ │
│ ├── GET stock ─────────▶ 返回 1
│ │ │
├── DECR stock ─────────────────┼─────────────────────▶ stock = 0
│ │ │
│ ├── DECR stock ─────────▶ stock = -1 ← 超卖!
大师:"Redis 保证 GET 返回 1 这一步没有中间状态,DECR 扣减也没有中间状态。但你的业务逻辑'如果库存>0 则扣减'跨越了两个命令——两个请求都看到了库存=1,两个都扣了。"
技术映射:Redis 的单线程模型保证的是单条命令的原子性,不是客户端多步业务逻辑的原子性。要跨越命令实现原子性,需要 Lua。
小胖:"那我把 Lua 脚本发给 Redis,Redis 执行脚本期间不会处理其他命令?"
大师:"正确。Lua 脚本在 Redis 内部像一个'迷你事务'——整个脚本在事件循环中一次执行完,中间不会插入其他命令。脚本开始执行时其他请求都在队列里等着。"
2.2 第二幕:Lua 脚本的执行边界
小胖:"既然 Lua 这么强,那我是不是可以把整个下单流程都写进 Lua?查库存、算价格、验证优惠券、写订单——全塞进去,保证原子。"
小白:"不行。Lua 在 Redis 主线程执行,脚本太慢会阻塞所有其他请求。10 毫秒的脚本在 10 万 QPS 的场景下,意味着阻塞期间积压 1000 个请求。"
大师:"Lua 脚本的执行原则——短、小、确定。短:执行时间控制在 1ms 以内;小:只操作 Redis 本地数据,不访问外部服务;确定:同样的输入永远产生同样的输出和同样的 Redis 操作。"
Lua 适合的场景:
✓ 库存检查+扣减 (通常 < 0.1ms)
✓ 释放锁 (比较+删除, < 0.1ms)
✓ 复合计数器 (INCR+EXPIRE, < 0.1ms)
✓ 限购检查 (判断+扣库存+记录用户, < 0.2ms)
Lua 不适合的场景:
✗ 扫描大集合 (遍历 10000 个成员)
✗ 复杂计算 (价格计算、推荐算法)
✗ 访问外部服务 (HTTP 调用、数据库查询)
✗ 不确定操作 (随机数、时间相关——Lua 中的随机和时间在 Redis 中行为可预测)
技术映射:Lua 的价值在于"把数据和逻辑放在一起",让判断和修改之间没有时间窗口。它的代价是"脚本越长,阻塞风险越大"。
2.3 第三幕:KEYS 和 ARGV 不是随便传的
小胖:"传参不就是把变量名扔给 Lua 吗?直接拼字符串多方便。"
大师:"千万不要。第一,拼字符串容易注入——如果 userId 里面包含了 Lua 代码,你就中招了。第二,Redis Cluster 需要知道脚本访问哪些 key——通过 KEYS 参数传递,Redis 可以校验这些 key 是否在同一分片。第三,直接拼字符串的脚本无法复用——每次参数不同都要生成不同的脚本。"
# 错误做法:拼字符串(有注入风险!)
EVAL "return redis.call('GET', 'user:' .. '" .. userId .. "')" 0
# 正确做法:通过 KEYS 和 ARGV 传递
EVAL "return redis.call('GET', KEYS[1])" 1 user:1001
# KEYS 和 ARGV 的区别:
# KEYS[1], KEYS[2]... → 脚本需要访问的 key 名称
# ARGV[1], ARGV[2]... → 脚本需要的普通参数(用户 ID、数量等)
# 脚本开头 "1" → 表示 KEYS 数组的长度(ARGV 长度不需要声明)
技术映射:KEYS 和 ARGV 不仅是为了安全,也是为 Redis Cluster 的兼容性铺路。所有 key 必须通过 KEYS 数组传入。
3. 项目实战
3.1 环境准备
docker exec -it redis-lab redis-cli -a redis123
3.2 实战一:库存扣减+限购检查
步骤 1:准备测试数据
127.0.0.1:6379> SET mall:seckill:stock:sku1001 5
OK
127.0.0.1:6379> DEL mall:seckill:buyers:sku1001
(integer) 0
步骤 2:执行扣减脚本
-- seckill_deduct.lua
-- KEYS[1]: 库存 key
-- KEYS[2]: 已购买用户集合 key
-- ARGV[1]: 用户 ID
-- ARGV[2]: 购买数量
-- 返回: 1=成功, 0=库存不足, -1=重复购买
local stockKey = KEYS[1]
local buyerKey = KEYS[2]
local userId = ARGV[1]
local quantity = tonumber(ARGV[2])
-- 1. 检查是否重复购买
if redis.call('SISMEMBER', buyerKey, userId) == 1 then
return -1
end
-- 2. 检查库存是否充足
local stock = tonumber(redis.call('GET', stockKey) or '0')
if stock < quantity then
return 0
end
-- 3. 原子扣减 + 记录购买
redis.call('DECRBY', stockKey, quantity)
redis.call('SADD', buyerKey, userId)
return 1
# 通过 EVAL 执行(压成一行)
127.0.0.1:6379> EVAL "local sk=KEYS[1]; local bk=KEYS[2]; local uid=ARGV[1]; local qty=tonumber(ARGV[2]); if redis.call('SISMEMBER',bk,uid)==1 then return -1 end; local stock=tonumber(redis.call('GET',sk) or '0'); if stock<qty then return 0 end; redis.call('DECRBY',sk,qty); redis.call('SADD',bk,uid); return 1" 2 mall:seckill:stock:sku1001 mall:seckill:buyers:sku1001 user-1 1
(integer) 1
# 验证库存
127.0.0.1:6379> GET mall:seckill:stock:sku1001
"4"
# 重复购买 → 返回 -1
127.0.0.1:6379> EVAL "..." 2 mall:seckill:stock:sku1001 mall:seckill:buyers:sku1001 user-1 1
(integer) -1
步骤 3:Java 封装
public class SeckillService {
private static final String SECKILL_LUA =
"local sk=KEYS[1]; local bk=KEYS[2]; local uid=ARGV[1]; local qty=tonumber(ARGV[2]); " +
"if redis.call('SISMEMBER',bk,uid)==1 then return -1 end; " +
"local stock=tonumber(redis.call('GET',sk) or '0'); " +
"if stock<qty then return 0 end; " +
"redis.call('DECRBY',sk,qty); redis.call('SADD',bk,uid); return 1";
// 尝试秒杀
public SeckillResult trySeckill(String skuId, String userId, int quantity) {
List<String> keys = Arrays.asList(
"mall:seckill:stock:" + skuId,
"mall:seckill:buyers:" + skuId
);
List<String> args = Arrays.asList(userId, String.valueOf(quantity));
Long result = redis.eval(SECKILL_LUA, keys, args);
switch (result.intValue()) {
case 1: return SeckillResult.SUCCESS;
case 0: return SeckillResult.STOCK_NOT_ENOUGH;
case -1: return SeckillResult.ALREADY_BOUGHT;
default: return SeckillResult.UNKNOWN;
}
}
}
3.3 实战二:安全释放锁
# 先获取锁
127.0.0.1:6379> SET mall:lock:order:9001 "req-uuid-abc123" NX EX 10
OK
# 安全释放锁脚本
# KEYS[1]: 锁 key
# ARGV[1]: requestId(验证锁的持有者)
# 返回: 1=释放成功, 0=不是自己的锁(可能已过期被其他请求获取)
127.0.0.1:6379> EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end" 1 mall:lock:order:9001 req-uuid-abc123
(integer) 1
# 验证锁已释放
127.0.0.1:6379> GET mall:lock:order:9001
(nil)
# 如果是别人尝试释放(或用错误的 requestId)
127.0.0.1:6379> SET mall:lock:order:9001 "req-uuid-xyz" NX EX 10
OK
127.0.0.1:6379> EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end" 1 mall:lock:order:9001 req-uuid-wrong
(integer) 0 # 不是自己的锁,拒绝释放
Java 封装:
public class RedisLock {
private static final String UNLOCK_LUA =
"if redis.call('GET', KEYS[1]) == ARGV[1] then " +
"return redis.call('DEL', KEYS[1]) else return 0 end";
public boolean unlock(String lockKey, String requestId) {
Long result = redis.eval(UNLOCK_LUA,
Collections.singletonList(lockKey),
Collections.singletonList(requestId));
return result != null && result == 1;
}
}
3.4 实战三:复合计数器——INCR + 首次 EXPIRE
# 问题:INCR 和 EXPIRE 是两步,可能出现 INCR 后 key 无 TTL
# 解决:Lua 原子处理
127.0.0.1:6379> EVAL "local c=redis.call('INCR',KEYS[1]); if c==1 then redis.call('EXPIRE',KEYS[1],ARGV[1]); end; return c" 1 mall:limit:sms:13800000000:20260718 86400
(integer) 1 # 首次递增,设置了 TTL
127.0.0.1:6379> TTL mall:limit:sms:13800000000:20260718
(integer) 86396 # TTL 已设置
127.0.0.1:6379> EVAL "local c=redis.call('INCR',KEYS[1]); if c==1 then redis.call('EXPIRE',KEYS[1],ARGV[1]); end; return c" 1 mall:limit:sms:13800000000:20260718 86400
(integer) 2 # 非首次递增,不重复设置 TTL
3.5 实战四:SCRIPT LOAD 和 EVALSHA
# 步骤 1: 加载脚本到 Redis 缓存
127.0.0.1:6379> SCRIPT LOAD "local sk=KEYS[1]; local bk=KEYS[2]; local uid=ARGV[1]; local qty=tonumber(ARGV[2]); if redis.call('SISMEMBER',bk,uid)==1 then return -1 end; local stock=tonumber(redis.call('GET',sk) or '0'); if stock<qty then return 0 end; redis.call('DECRBY',sk,qty); redis.call('SADD',bk,uid); return 1"
"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"
# 步骤 2: 用 SHA 执行(不用传完整脚本文本)
127.0.0.1:6379> EVALSHA a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0 2 mall:seckill:stock:sku1001 mall:seckill:buyers:sku1001 user-3 1
(integer) 1
# 步骤 3: 检查脚本是否存在
127.0.0.1:6379> SCRIPT EXISTS a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
1) (integer) 1
# 步骤 4: 查看所有已加载的脚本
127.0.0.1:6379> SCRIPT DEBUG
# 步骤 5: 清空脚本缓存
127.0.0.1:6379> SCRIPT FLUSH
OK
Java SCRIPT LOAD 缓存模式:
public class LuaScriptCache {
private final ConcurrentHashMap<String, String> scriptShaCache = new ConcurrentHashMap<>();
// 执行脚本(自动处理 NOSCRIPT)
public <T> T execute(String scriptName, String luaScript,
List<String> keys, List<String> args, Class<T> returnType) {
String sha = scriptShaCache.computeIfAbsent(scriptName, name -> {
// 首次加载到 Redis(如果 Redis 重启,SHA 会失效)
return redis.scriptLoad(luaScript);
});
try {
return redis.evalSha(sha, keys, args, returnType);
} catch (RedisSystemException e) {
// NOSCRIPT → 脚本缓存丢失(Redis 重启/主从切换)
if (e.getMessage().contains("NOSCRIPT")) {
log.warn("脚本 SHA 失效,重新加载: {}", scriptName);
String newSha = redis.scriptLoad(luaScript);
scriptShaCache.put(scriptName, newSha);
return redis.evalSha(newSha, keys, args, returnType);
}
throw e;
}
}
}
3.6 实战五:Lua 脚本的性能考量
# 方法 1:测量脚本执行时间(用 redis-cli 的 --latency 模式)
# 终端 A: 执行 EVAL
# 终端 B: 监控慢查询
127.0.0.1:6379> CONFIG SET slowlog-log-slower-than 100
OK
127.0.0.1:6379> SLOWLOG RESET
# 执行一个"重"脚本(模拟遍历 1000 元素——不要在生产这样做!)
127.0.0.1:6379> EVAL "local sum=0; for i=1,1000 do sum=sum+i end; return sum" 0
(integer) 500500
# 查看慢查询
127.0.0.1:6379> SLOWLOG GET 1
# 可以看到执行耗时(微秒)
关键认知:即使是纯计算(不操作 Redis 数据),Lua 也在主线程中执行。1000 次循环还好(微秒级),10 万次循环就可能触发毫秒级的阻塞。
3.7 常见坑
坑1:脚本中写大循环
-- 危险!遍历一个 10 万成员的集合
local members = redis.call('SMEMBERS', 'large:set')
for i, member in ipairs(members) do
-- ... 处理每个成员
end
-- 阻塞时间 = SMEMBERS 的耗时 + 循环体的耗时
-- 可能达到秒级!
坑2:没有正确处理 nil 值
-- 危险:GET 可能返回 false (Lua中) 或 nil
local stock = redis.call('GET', KEYS[1]) -- 可能是 false
-- 正确:
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
坑3:KEYS 参数数量错误
# EVAL 的第二个参数是 KEYS 的数量,后面跟 KEYS 数组,再后面是 ARGV
EVAL "..." 2 key1 key2 arg1 arg2
# ↑ 2 表示 KEYS 有 2 个: key1, key2
# arg1, arg2 属于 ARGV
3.8 实战六:限流器 Lua 实现
-- rate_limiter.lua
-- KEYS[1]: 限流计数 key
-- ARGV[1]: 时间窗口(秒)
-- ARGV[2]: 最大请求数
-- 返回: 1=允许, 0=限流
local key = KEYS[1]
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local current = redis.call('INCR', key)
if current == 1 then
redis.call('EXPIRE', key, window)
end
if current > limit then
return 0 -- 超过限制
end
return 1 -- 允许通过
# 测试限流器: 10 秒窗口内最多 3 次请求
127.0.0.1:6379> EVAL "local k=KEYS[1]; local w=tonumber(ARGV[1]); local l=tonumber(ARGV[2]); local c=redis.call('INCR',k); if c==1 then redis.call('EXPIRE',k,w) end; if c>l then return 0 end; return 1" 1 mall:ratelimit:api:login:13800000000 10 3
(integer) 1 # 第 1 次: 允许
# 第 2 次: 允许
# 第 3 次: 允许
# 第 4 次: 返回 0 (限流)
# 查看当前计数
127.0.0.1:6379> GET mall:ratelimit:api:login:13800000000
"3"
3.9 实战七:Lua 与 Redis Functions 的演进关系
Redis 7.0 引入了 Redis Functions,它是 Lua 脚本的演进方案。相比 EVAL/EVALSHA,Functions 有几个关键改进:
| 维度 | Lua 脚本(EVAL) | Redis Functions(7.0+) |
|---|---|---|
| 持久化 | 脚本缓存在内存中,重启丢失 | 通过 RDB/AOF 持久化,重启后仍存在 |
| 版本管理 | 无版本概念 | 支持库和函数名,可以原子替换 |
| 加载方式 | 客户端主动 SCRIPT LOAD |
FUNCTION LOAD 后服务端持有 |
| 集群 | 需在每个节点分别加载 | 可通过 FUNCTION LOAD REPLACE 同步 |
| 调试 | 仅 SCRIPT DEBUG |
更完善的调试支持 |
# Redis Functions 示例(需要 Redis 7.0+):
# 注册一个函数到库 "mylib"
127.0.0.1:6379> FUNCTION LOAD "#!lua name=mylib\nredis.register_function('deduct_stock', function(keys, args) local sk=keys[1]; local bk=keys[2]; local uid=args[1]; local qty=tonumber(args[2]); if redis.call('SISMEMBER',bk,uid)==1 then return -1 end; local stock=tonumber(redis.call('GET',sk) or '0'); if stock<qty then return 0 end; redis.call('DECRBY',sk,qty); redis.call('SADD',bk,uid); return 1 end)"
# 调用函数(与 EVAL 不同,通过 FCALL)
127.0.0.1:6379> FCALL deduct_stock 2 mall:seckill:stock:sku1001 mall:seckill:buyers:sku1001 user-5 1
(integer) 1
# 列出所有函数
127.0.0.1:6379> FUNCTION LIST
技术映射:Functions 是 Lua 脚本的"服务端函数"版本——持久化、有版本、支持库组织。对于需要长期维护的复杂 Lua 逻辑,建议直接使用 Functions。
3.10 实战八:复杂 Lua 示例——优惠券秒杀
一个更复杂的 Lua 场景:用户抢优惠券,需要同时检查库存、限制每人最多抢一张、记录领取。
-- coupon_seckill.lua
-- KEYS[1]: 优惠券库存 key
-- KEYS[2]: 已领取用户集合 key
-- KEYS[3]: 用户已领取张数 key (每人上限)
-- ARGV[1]: 用户 ID
-- ARGV[2]: 每人最大领取张数
-- 返回: 1=成功, 0=库存不足, -1=重复领取, -2=超出个人上限
local stockKey = KEYS[1]
local buyerKey = KEYS[2]
local userLimitKey = KEYS[3]
local userId = ARGV[1]
local userLimit = tonumber(ARGV[2])
-- 检查是否已领过该券
if redis.call('SISMEMBER', buyerKey, userId) == 1 then
return -1 -- 重复领取
end
-- 检查个人总领取数是否超限
local userCount = tonumber(redis.call('GET', userLimitKey) or '0')
if userCount >= userLimit then
return -2 -- 超出个人上限
end
-- 检查库存
local stock = tonumber(redis.call('GET', stockKey) or '0')
if stock <= 0 then
return 0 -- 库存不足
end
-- 原子扣减
redis.call('DECR', stockKey)
redis.call('SADD', buyerKey, userId)
redis.call('INCR', userLimitKey)
return 1
# 测试优惠券秒杀
127.0.0.1:6379> SET mall:coupon:stock:coupon618 3
127.0.0.1:6379> EVAL "local sk=KEYS[1]; local bk=KEYS[2]; local ulk=KEYS[3]; local uid=ARGV[1]; local ul=tonumber(ARGV[2]); if redis.call('SISMEMBER',bk,uid)==1 then return -1 end; local uc=tonumber(redis.call('GET',ulk) or '0'); if uc>=ul then return -2 end; local s=tonumber(redis.call('GET',sk) or '0'); if s<=0 then return 0 end; redis.call('DECR',sk); redis.call('SADD',bk,uid); redis.call('INCR',ulk); return 1" 3 mall:coupon:stock:coupon618 mall:coupon:buyers:coupon618 mall:coupon:userlimit:user1:coupon618 user-1 2
(integer) 1 # 成功
3.11 实战九:Lua 脚本调试技巧
# 使用 SCRIPT DEBUG 进入调试模式
127.0.0.1:6379> SCRIPT DEBUG YES
# 之后执行的 EVAL 会进入逐步调试(生产禁用!)
# 更实用的调试方式——在脚本中加入日志返回
# 用 redis.log() 写日志:
127.0.0.1:6379> EVAL "redis.log(redis.LOG_NOTICE, 'debug: stock=' .. redis.call('GET', KEYS[1])); return redis.call('GET', KEYS[1])" 1 mall:seckill:stock:sku1001
# 查看 Redis 日志: docker logs redis-lab
# 用 redis-cli 测脚本耗时
127.0.0.1:6379> SLOWLOG RESET
127.0.0.1:6379> EVAL "return redis.call('TIME')[1]" 0
127.0.0.1:6379> SLOWLOG GET 1
### 3.12 实战十:Lua 脚本在生产中的最佳实践清单
1. **脚本必须短小**——执行时间控制在 1ms 以内,避免阻塞主线程。超过 1ms 的脚本应考虑用 `SLOWLOG` 监控。
2. **KEYS 必须显式声明**——不要通过字符串拼接动态生成 key 名,否则在 Redis Cluster 中无法正确路由。
3. **返回值必须契约化**——定义清晰的返回值含义(1=成功, 0=库存不足, -1=重复等),客户端不要靠匹配字符串判断结果。
4. **处理 nil/false**——`redis.call('GET')` 在 Lua 中返回 `false` 而不是 `nil`,用 `or '0'` 兜底。
5. **不要在 Lua 中写随机逻辑**——`math.random` 在 Redis 中的行为是可预测的,如果需要随机,传参时在客户端生成。
6. **脚本仓库存入 Git**——不要只在 Redis 中存一份。每个脚本对应一个 `.lua` 文件,纳入版本管理。
7. **NOSCRIPT 必须有兜底**——`EVALSHA` 前检查脚本是否存在,`NOSCRIPT` 时自动 `SCRIPT LOAD` 重试。
8. **主从切换后脚本会丢失**——如果主节点宕机,从节点提升为主后,之前的 `SCRIPT LOAD` 缓存不存在。应用必须有能力重新加载。
```java
// 生产级 Lua 执行器(含所有最佳实践)
public class ProductionLuaExecutor {
private final ConcurrentHashMap<String, String> shaCache = new ConcurrentHashMap<>();
private final StringRedisTemplate redis;
// 脚本元数据
private static record ScriptMeta(String name, String lua, int keyCount, String description) {}
private static final ScriptMeta SECKILL_DEDUCT = new ScriptMeta(
"seckill_deduct",
"local sk=KEYS[1]; local bk=KEYS[2]; ...",
2,
"秒杀库存扣减+限购检查"
);
public Long executeScript(ScriptMeta script, List<String> keys, List<String> args) {
String sha = shaCache.get(script.name());
// 执行(含 NOSCRIPT 自动恢复)
try {
return redis.execute((RedisCallback<Long>) conn ->
conn.evalSha(sha.getBytes(), script.keyCount(),
keys.toArray(new byte[0][]), args.toArray(new byte[0][])));
} catch (Exception e) {
if (e.getMessage().contains("NOSCRIPT")) {
log.warn("脚本 {} SHA 失效,重新加载", script.name());
sha = redis.scriptLoad(script.lua());
shaCache.put(script.name(), sha);
return executeScript(script, keys, args);
}
throw e;
}
}
}
4. 项目总结
4.1 核心命令速查
| 命令 | 用途 | 注意事项 |
|---|---|---|
EVAL script numkeys key... arg... |
执行 Lua 脚本 | numkeys 是 KEYS 的数量 |
EVALSHA sha1 numkeys key... arg... |
用 SHA 执行已缓存的脚本 | 比 EVAL 少传脚本文本,节省带宽 |
SCRIPT LOAD script |
加载脚本到缓存 | 返回 SHA1 值 |
SCRIPT EXISTS sha1... |
检查脚本是否在缓存中 | |
SCRIPT FLUSH |
清空脚本缓存 | 生产慎用——会导致所有 EVALSHA 失败 |
SCRIPT KILL |
终止正在运行的脚本 | 仅当脚本未执行写操作时可终止 |
4.2 Lua vs MULTI/EXEC vs Pipeline
| 维度 | Lua | MULTI/EXEC | Pipeline |
|---|---|---|---|
| 原子性 | 脚本内所有命令原子执行 | 命令排队但不隔离其他客户端 | 无原子性保证 |
| 条件判断 | 支持 | 不支持 | 不支持 |
| 回滚 | 不自动回滚 | 不自动回滚 | 无回滚概念 |
| 减少 RTT | 中等(首次 EVAL 传全文) | 不减少(仍需多次 RTT) | 大幅减少 |
| 适用场景 | 判断后修改、锁释放 | 批量无关联更新 | 批量读写 |
4.3 思考题
-
设计题:有一个秒杀场景,商品库存 100 件,同时有 10 万用户并发抢购。如果用本章的 Lua 脚本,热点 key(库存 key)会成为瓶颈。请提出至少两种"热点库存拆分"方案,让并发压力分散到多个 key 上。
-
安全题:如果 Lua 脚本里写了一个无限循环
while true do end,会发生什么?有什么方法可以终止它?这对生产环境有什么启示?
(答案提示将在下一章末尾给出。)
上一章思考题答案提示(第12章):
第1题(基本信息 vs 实时库存):建议拆成两个 key。
mall:cache:product:{id}:basic(TTL=600+random)存不常变的基本信息,mall:cache:product:{id}:stock(TTL=60)存实时库存。好处:库存变化时只删库存缓存,基本信息不受影响;坏处:前端需要两次 GET。折中:如果基本信息变化频率极低(几天改一次),可以在更新基本信息时主动刷新缓存而不用 TTL。第2题(Redis 短暂不可用后的 miss 骤增):这是典型的"缓存雪崩"——Redis 恢复后所有缓存都失效了。减少影响的方案:① 客户端实现熔断——Redis 不可用时直接降级(返回兜底数据或空),不穿透到数据库;② 缓存预热——Redis 恢复后主动拉取热点数据写入缓存;③ 本地缓存兜底——用 Caffeine 等本地缓存保存最近访问的数据副本。
延伸阅读与资源
Redis 8 实战精讲:从 CRUD 到源码,构建高可用缓存系统
Java 工程师进阶:从 JVM 生产排障到OpenJDK原理
MySQL Server 9从入门到进阶的实战之旅
Nacos 3.x 注册配置中心实战修炼:从入门到源码扩展
Elasticsearch从入门到进阶的实战之旅
RabbitMQ从入门到进阶的实战之旅:从单机到大促高可用架构
Celery 入门到进阶之路:从异步任务到自研调度平台
LangGraph 生产级实战进阶:从零到生产级Agent工作流开发
Dify 从入门到源码:LLM 应用平台实战修炼
从零到生产级:FastAPI 异步高并发、源码与 SRE 实战
实战SQLAlchemy 2.0: 从 CRUD 到生产级架构
从零打造企业级 AI 助手:LangChain RAG、Agent 与生产实战
后端工程师 AI 转型课:Ollama 私有化大模型从入门到生产
MongoDB 实战进阶与内核修炼
NumPy 从入门到生产落地:全链路实战指南(科学计算/向量化/性能调优)
Milvus向量数据库实战修炼:从 0 到 1 精通向量检索与生产落地
Python 3实战精进:从脚本到高并发订单引擎
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经

微信公众号: 架构师日常笔记 欢迎关注!
浙公网安备 33010602011771号