# Redis 事务与 Lua 脚本

# 概述

在 Redis 中,事务和 Lua 脚本是实现原子性操作的重要机制。它们可以确保一组命令要么全部执行,要么全部不执行,从而保证数据的一致性。

本文将详细介绍 Redis 事务的机制、Lua 脚本的编程方法,以及它们在实际应用中的使用场景。

# Redis 事务

# 什么是 Redis 事务

Redis 事务是一组命令的集合,这些命令会按照顺序执行,并且在执行过程中不会被其他客户端的命令打断。Redis 事务通过以下命令实现:

  • MULTI :标记事务开始
  • EXEC :执行事务中的所有命令
  • DISCARD :取消事务
  • WATCH :监视一个或多个 key

# Redis 事务基本使用

# 基本事务流程

1
2
3
4
5
6
7
8
9
10
# 开启事务
MULTI

# 添加命令到事务队列
SET key1 value1
SET key2 value2
GET key1

# 执行事务
EXEC

# 取消事务

1
2
3
4
5
6
7
8
9
# 开启事务
MULTI

# 添加命令
SET key1 value1
SET key2 value2

# 取消事务
DISCARD

# Redis 事务特性

# 1. 原子性

Redis 事务中的命令要么全部执行,要么全部不执行。

1
2
3
4
5
# 示例:银行转账
MULTI
DECR user1:balance 100
INCR user2:balance 100
EXEC

# 2. 隔离性

事务执行过程中,其他客户端的命令不会插入执行。

1
2
3
4
5
6
# 客户端1
MULTI
SET key1 value1
# 此时客户端2的命令不会执行
SET key2 value2
EXEC

# 3. 不支持回滚

Redis 事务不支持命令执行失败后的回滚操作。

1
2
3
4
5
MULTI
SET key1 value1 # 成功
INCR non_existent_key # 失败,但不会回滚前面的命令
SET key2 value2 # 仍然会执行
EXEC

# WATCH 命令

# WATCH 机制原理

WATCH 命令用于实现乐观锁,监视一个或多个 key,如果在事务执行前这些 key 被其他客户端修改,事务将不会执行。

1
2
3
4
5
6
7
# 监视key
WATCH balance

# 开启事务
MULTI
DECR balance 100
EXEC

# WATCH 使用示例

1
2
3
4
5
6
7
# 客户端1
WATCH balance
GET balance # 假设返回1000
MULTI
DECR balance 100
# 客户端2在此期间修改了balance
EXEC # 事务执行失败,返回nil

# UNWATCH 命令

1
2
# 取消监视
UNWATCH

# Redis 事务实战

# 1. 库存扣减

1
2
3
4
5
6
# 商品库存扣减
WATCH product:1001:stock
GET product:1001:stock # 检查库存
MULTI
DECR product:1001:stock # 扣减库存
EXEC

# 2. 积分兑换

1
2
3
4
5
6
7
# 用户积分兑换
WATCH user:1001:points
GET user:1001:points # 检查积分
MULTI
DECRBY user:1001:points 100
INCR user:1001:coupons 1
EXEC

# 3. 限流器实现

1
2
3
4
5
6
7
# 基于Redis的简单限流器
WATCH rate_limit:user:1001
GET rate_limit:user:1001
MULTI
INCR rate_limit:user:1001
EXPIRE rate_limit:user:1001 60
EXEC

# Lua 脚本

# 什么是 Lua 脚本

Lua 脚本是一种轻量级的脚本语言,Redis 从 2.6 版本开始支持 Lua 脚本。Lua 脚本在 Redis 中具有以下优势:

  • 原子性执行:脚本执行过程中不会被其他命令打断
  • 减少网络开销:多个命令可以在一次网络调用中完成
  • 复用性:脚本可以被多次调用
  • 性能优化:脚本在 Redis 服务器端执行

# Lua 脚本基本语法

# 1. 脚本执行

1
2
3
4
5
6
7
8
# 直接执行脚本
EVAL 'return "Hello, Redis!"' 0

# 执行带参数的脚本
EVAL 'return ARGV[1] .. " " .. ARGV[2]' 0 Hello Redis

# 操作Redis数据
EVAL 'return redis.call("SET", KEYS[1], ARGV[1])' 1 mykey myvalue

# 2. 脚本参数说明

  • KEYS[1], KEYS[2], ... :Redis key 参数
  • ARGV[1], ARGV[2], ... :普通参数
  • 第一个数字参数表示 KEYS 的数量

# 3. 基本语法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
-- 变量定义
local key = KEYS[1]
local value = ARGV[1]

-- 条件判断
if redis.call("EXISTS", key) == 1 then
return redis.call("GET", key)
else
return "not exists"
end

-- 循环
for i = 1, tonumber(ARGV[1]) do
redis.call("INCR", key)
end

-- 返回值
return result

# Lua 脚本 Redis API

# 1. redis.call()

1
2
3
-- 调用Redis命令
local result = redis.call("GET", "mykey")
local count = redis.call("INCR", "counter")

# 2. redis.pcall()

1
2
3
4
5
6
7
-- 安全调用,不会抛出异常
local result = redis.pcall("GET", "mykey")
if result then
-- 处理结果
else
-- 处理错误
end

# 3. 返回值处理

1
2
3
4
5
6
7
8
9
10
11
-- 返回字符串
return "success"

-- 返回数字
return 100

-- 返回表
return {1, 2, 3}

-- 返回状态
return redis.status_reply("OK")

# Lua 脚本实战

# 1. 原子性库存扣减

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- 原子性库存扣减脚本
local stock_key = KEYS[1]
local order_count = tonumber(ARGV[1])

-- 获取当前库存
local current_stock = tonumber(redis.call("GET", stock_key))

-- 检查库存是否充足
if current_stock >= order_count then
-- 扣减库存
redis.call("DECRBY", stock_key, order_count)
return 1 -- 成功
else
return 0 -- 失败
end

# 2. 限流器实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
-- 滑动窗口限流器
local key = KEYS[1]
local window = tonumber(ARGV[1]) -- 时间窗口(秒)
local limit = tonumber(ARGV[2]) -- 限制次数

local current_time = tonumber(redis.call("TIME")[1])
local window_start = current_time - window

-- 移除过期的记录
redis.call("ZREMRANGEBYSCORE", key, 0, window_start)

-- 获取当前窗口内的请求数
local current_count = redis.call("ZCARD", key)

if current_count < limit then
-- 添加当前请求记录
redis.call("ZADD", key, current_time, current_time)
redis.call("EXPIRE", key, window)
return 1 -- 允许请求
else
return 0 -- 拒绝请求
end

# 3. 分布式锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
-- 分布式锁实现
local lock_key = KEYS[1]
local lock_value = ARGV[1]
local expire_time = tonumber(ARGV[2])

-- 尝试获取锁
local result = redis.call("SET", lock_key, lock_value, "NX", "EX", expire_time)

if result then
return 1 -- 获取成功
else
-- 检查锁是否是自己持有的
local current_value = redis.call("GET", lock_key)
if current_value == lock_value then
-- 续期
redis.call("EXPIRE", lock_key, expire_time)
return 1
else
return 0 -- 获取失败
end
end

# 4. 有序集合排行榜

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
-- 更新排行榜
local leaderboard_key = KEYS[1]
local user_id = ARGV[1]
local score = tonumber(ARGV[2])

-- 更新用户分数
redis.call("ZADD", leaderboard_key, score, user_id)

-- 获取用户排名
local rank = redis.call("ZREVRANK", leaderboard_key, user_id)

-- 返回用户信息和排名
local user_info = {
rank = rank + 1,
score = score
}

return user_info

# 5. 批量操作优化

1
2
3
4
5
6
7
8
9
10
11
12
13
-- 批量设置过期时间
local pattern = ARGV[1]
local expire_time = tonumber(ARGV[2])

-- 获取匹配的key
local keys = redis.call("KEYS", pattern)

-- 批量设置过期时间
for i = 1, #keys do
redis.call("EXPIRE", keys[i], expire_time)
end

return #keys -- 返回处理的key数量

# 脚本管理

# 1. 脚本加载和执行

# SCRIPT LOAD

1
2
3
# 加载脚本并获取SHA1
SCRIPT LOAD 'return "Hello, Redis!"'
# 返回:5b203c6a3d4f8a4c9e8b0c1a2d3e4f5a6b7c8d9e

# EVALSHA

1
2
# 使用SHA1执行脚本
EVALSHA 5b203c6a3d4f8a4c9e8b0c1a2d3e4f5a6b7c8d9e 0

# 2. 脚本管理命令

1
2
3
4
5
6
7
8
# 查看脚本是否存在
SCRIPT EXISTS 5b203c6a3d4f8a4c9e8b0c1a2d3e4f5a6b7c8d9e

# 删除脚本
SCRIPT FLUSH

# 杀死正在执行的脚本
SCRIPT KILL

# 3. 脚本调试

1
2
3
4
5
6
7
8
# 启用调试模式
redis-cli --ldb --eval script.lua key1 key2 , arg1 arg2

# 调试命令
# help - 查看帮助
# step - 单步执行
# continue - 继续执行
# print var - 打印变量

# SpringDataRedis 中的 Lua 脚本

# 1. 使用 RedisTemplate 执行 Lua 脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Autowired
private RedisTemplate<String, Object> redisTemplate;

public boolean atomicDecrementStock(String key, int count) {
String script =
"local stock = tonumber(redis.call('GET', KEYS[1]))\n" +
"if stock >= tonumber(ARGV[1]) then\n" +
" redis.call('DECRBY', KEYS[1], ARGV[1])\n" +
" return 1\n" +
"else\n" +
" return 0\n" +
"end";

DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
Long result = redisTemplate.execute(redisScript, Collections.singletonList(key), count);
return result != null && result == 1;
}

# 2. 使用 RedisScript 接口

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
@Configuration
public class LuaScriptConfig {

@Bean
public RedisScript<Long> stockDecrementScript() {
String script =
"local stock = tonumber(redis.call('GET', KEYS[1]))\n" +
"if stock >= tonumber(ARGV[1]) then\n" +
" redis.call('DECRBY', KEYS[1], ARGV[1])\n" +
" return 1\n" +
"else\n" +
" return 0\n" +
"end";

return new DefaultRedisScript<>(script, Long.class);
}
}

@Service
public class StockService {

@Autowired
private RedisScript<Long> stockDecrementScript;

@Autowired
private RedisTemplate<String, Object> redisTemplate;

public boolean decrementStock(String key, int count) {
Long result = redisTemplate.execute(
stockDecrementScript,
Collections.singletonList(key),
count
);
return result != null && result == 1;
}
}

# 3. 从文件加载 Lua 脚本

1
2
3
4
5
6
7
8
9
10
11
@Component
public class LuaScriptLoader {

@Value("classpath:scripts/rate_limit.lua")
private Resource rateLimitScript;

public RedisScript<Long> loadRateLimitScript() throws IOException {
String scriptText = StreamUtils.copyToString(rateLimitScript.getInputStream(), StandardCharsets.UTF_8);
return new DefaultRedisScript<>(scriptText, Long.class);
}
}

# 性能优化

# 1. 脚本缓存

1
2
3
# 加载常用脚本到缓存
SCRIPT LOAD 'return redis.call("GET", KEYS[1])'
SCRIPT LOAD 'return redis.call("SET", KEYS[1], ARGV[1])'

# 2. 批量操作

1
2
3
4
5
6
-- 批量获取
local results = {}
for i = 1, #KEYS do
results[i] = redis.call("GET", KEYS[i])
end
return results

# 3. 管道化执行

1
2
3
4
5
6
7
8
9
10
// 使用管道执行多个脚本
List<Object> results = redisTemplate.executePipelined(new SessionCallback<Object>() {
@Override
public Object execute(RedisOperations operations) throws DataAccessException {
for (int i = 0; i < 100; i++) {
operations.execute(redisScript, Collections.singletonList("key:" + i), i);
}
return null;
}
});

# 最佳实践

# 1. 脚本设计原则

  • 保持简洁:避免复杂的逻辑和循环
  • 错误处理:使用 pcall 处理可能的错误
  • 参数验证:验证输入参数的有效性
  • 资源控制:避免死循环和资源泄漏

# 2. 错误处理

1
2
3
4
5
6
7
8
9
10
-- 安全的脚本执行
local ok, result = pcall(function()
return redis.call("GET", KEYS[1])
end)

if ok then
return result
else
return redis.error_reply("Script execution failed")
end

# 3. 调试技巧

1
2
3
4
5
-- 添加调试日志
local debug = true
if debug then
redis.call("SET", "debug:log", "Current value: " .. tostring(value))
end

# 总结

Redis 事务和 Lua 脚本是实现原子性操作的重要工具:

  • Redis 事务:适合简单的原子性操作,使用简单但功能有限
  • Lua 脚本:功能强大,支持复杂逻辑,性能更好
  • 选择建议:简单操作使用事务,复杂逻辑使用 Lua 脚本

合理使用这些特性,可以有效提高应用的性能和数据一致性。