<think>
我们将在Spring Boot项目中通过Lua脚本实现多个ZSet的并集操作(ZUNION)并进行分页处理。由于Redis的ZUNION命令返回整个结果集,我们在Lua脚本中完成并集计算后,再在Lua中实现分页(通过ZRANGE获取分页数据),以避免在Java中处理大量数据。
步骤:
1. 编写Lua脚本,执行以下操作:
a. 使用`ZUNIONSTORE`创建一个临时有序集合,存储并集结果。
b. 使用`ZRANGE`命令(带WITHSCORES)获取临时集合中指定分页的数据。
c. 删除临时集合(避免内存泄漏)。
d. 返回分页数据(包括元素和分数)。
2. 在Spring Boot中通过`RedisTemplate`执行该Lua脚本。
3. 处理返回结果。
注意:临时集合的键需要唯一,可以使用随机字符串或者UUID生成,但Lua中生成随机字符串较为复杂,我们可以使用Redis的`TIME`命令和计数器生成唯一键,或者由Spring Boot生成传入。这里选择由Spring Boot生成临时键前缀,然后结合Lua脚本中的逻辑生成临时键。
但是,为了减少Lua脚本的复杂度,我们将临时键的名称通过参数传入。在Spring Boot中生成一个随机的临时键名(例如:temp:zunion:<UUID>),然后在Lua脚本中使用这个键。
脚本逻辑:
- 输入参数:
KEYS[1..n]: 待合并的有序集合的键
ARGV[1]: 临时集合的键名
ARGV[2]: 分页的起始索引(在有序集合中的排名,从0开始)
ARGV[3]: 分页的结束索引(包含)
ARGV[4..]: 权重和聚合方式等参数(可选,需要结构化处理)
由于权重和聚合方式参数个数可变,我们设计将权重和聚合方式通过其他方式传递(比如作为额外的KEYS?但不好)。另一种方式是:将权重和聚合方式放在ARGV中固定位置,然后剩余部分为分页参数。
但是,Redis的ZUNIONSTORE命令本身参数复杂,我们可以在Lua脚本中写死聚合方式为SUM(或者通过参数指定),权重同样通过参数传入。
我们设计ARGV如下:
ARGV[1]: 临时键名
ARGV[2]: 权重列表(用逗号分隔的字符串,如"1,2,1")
ARGV[3]: 聚合方式(字符串,如"SUM")
ARGV[4]: 分页起始索引(0-based)
ARGV[5]: 分页结束索引(包含)
注意:权重列表的个数必须与KEYS个数相同。
Lua脚本内容:
```lua
-- 获取参数
local tempKey = ARGV[1]
local weights = ARGV[2]
local aggregate = ARGV[3]
local startIndex = tonumber(ARGV[4])
local endIndex = tonumber(ARGV[5])
-- 构建ZUNIONSTORE命令参数
local numKeys = #KEYS
local zunionArgs = {numKeys}
for i=1, numKeys do
zunionArgs[#zunionArgs+1] = KEYS[i]
end
-- 添加权重参数(如果权重存在且不为空)
if weights ~= nil and weights ~= '' then
local weightList = {}
for weight in string.gmatch(weights, '([^,]+)') do
table.insert(weightList, weight)
end
if #weightList == numKeys then
table.insert(zunionArgs, 'WEIGHTS')
for _, weight in ipairs(weightList) do
table.insert(zunionArgs, weight)
end
end
end
-- 添加聚合方式
if aggregate ~= nil and aggregate ~= '' then
table.insert(zunionArgs, 'AGGREGATE')
table.insert(zunionArgs, aggregate)
end
-- 执行ZUNIONSTORE
redis.call('ZUNIONSTORE', tempKey, unpack(zunionArgs))
-- 使用ZRANGE获取分页数据(WITHSCORES)
local result = redis.call('ZRANGE', tempKey, startIndex, endIndex, 'WITHSCORES')
-- 删除临时集合
redis.call('DEL', tempKey)
return result
```
但是,这个脚本存在一个问题:如果并集操作产生大量数据,创建临时集合可能会消耗较多内存和时间。但分页只取一小部分,我们可以考虑使用ZRANGE命令直接在临时集合上取分页,然后删除临时集合。由于临时集合在同一个脚本中创建和删除,所以是原子操作,不会残留。
在Spring Boot中执行Lua脚本的步骤:
1. 初始化Lua脚本,将其加载到RedisTemplate中(或使用evalsha)。
2. 生成一个临时键(确保唯一性),例如:使用UUID。
3. 构建参数:
- KEYS:多个ZSet的键(List<String>)
- ARGV:临时键名、权重字符串(逗号分隔)、聚合方式字符串、起始索引、结束索引
4. 执行脚本,并处理返回结果。脚本返回一个列表,格式为:[元素1, 分数1, 元素2, 分数2, ...]
5. 将返回的结果列表转换为Java对象(如List<Map.Entry<String, Double>>)。
注意:分页的起始索引和结束索引是基于有序集合的排名(从0开始)。在分页参数计算时,前端传入的页码page(从1开始)和每页大小size,我们需要转换为:
起始索引 = (page - 1) * size
结束索引 = 起始索引 + size - 1
但是需要注意:如果使用ZRANGE,结束索引包含在内,并且支持负索引(表示从尾部开始),但我们这里用正索引。
实现代码:
步骤1:定义Lua脚本
步骤2:编写Spring Boot服务方法
由于Lua脚本较长,我们以字符串形式定义在Java类中。
考虑到脚本可能较长,且需要多次使用,我们可以将其放在单独的文件中,然后通过Resource加载。但为了示例清晰,这里直接以字符串形式编写。
完整代码示例:
```java
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.connection.RedisZSetCommands;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class ZSetLuaUnionService {
private final RedisTemplate<String, Object> redisTemplate;
private final DefaultRedisScript<List> redisScript;
// Lua脚本
private static final String LUA_SCRIPT =
"local tempKey = ARGV[1]\n" +
"local weights = ARGV[2]\n" +
"local aggregate = ARGV[3]\n" +
"local startIndex = tonumber(ARGV[4])\n" +
"local endIndex = tonumber(ARGV[5])\n" +
"\n" +
"local numKeys = #KEYS\n" +
"local zunionArgs = {numKeys}\n" +
"for i=1, numKeys do\n" +
" zunionArgs[#zunionArgs+1] = KEYS[i]\n" +
"end\n" +
"\n" +
"if weights ~= nil and weights ~= '' then\n" +
" local weightList = {}\n" +
" for weight in string.gmatch(weights, '([^,]+)') do\n" +
" table.insert(weightList, weight)\n" +
" end\n" +
" if #weightList == numKeys then\n" +
" table.insert(zunionArgs, 'WEIGHTS')\n" +
" for _, weight in ipairs(weightList) do\n" +
" table.insert(zunionArgs, weight)\n" +
" end\n" +
" end\n" +
"end\n" +
"\n" +
"if aggregate ~= nil and aggregate ~= '' then\n" +
" table.insert(zunionArgs, 'AGGREGATE')\n" +
" table.insert(zunionArgs, aggregate)\n" +
"end\n" +
"\n" +
"redis.call('ZUNIONSTORE', tempKey, unpack(zunionArgs))\n" +
"\n" +
"local result = redis.call('ZRANGE', tempKey, startIndex, endIndex, 'WITHSCORES')\n" +
"\n" +
"redis.call('DEL', tempKey)\n" +
"\n" +
"return result";
@Autowired
public ZSetLuaUnionService(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
// 初始化脚本
this.redisScript = new DefaultRedisScript<>();
this.redisScript.setScriptText(LUA_SCRIPT);
this.redisScript.setResultType(List.class);
}
/**
* 使用Lua脚本执行ZUNION并分页
* @param keys 多个ZSet的键
* @param weights 权重列表(可空)
* @param aggregate 聚合方式(SUM, MIN, MAX),可空,默认SUM
* @param page 页码(从1开始)
* @param size 每页大小
* @return 分页结果(元素和分数)
*/
public List<Map.Entry<String, Double>> zunionWithPaginationByLua(List<String> keys, List<Double> weights, String aggregate, int page, int size) {
// 计算分页索引(基于0)
long startIndex = (long) (page - 1) * size;
long endIndex = startIndex + size - 1;
// 生成临时键(使用UUID确保唯一)
String tempKey = "temp:zunion:" + UUID.randomUUID();
// 权重转换为逗号分隔字符串
String weightsArg = weights != null ? weights.stream().map(String::valueOf).collect(Collectors.joining(",")) : "";
// 聚合方式,如果为空则使用默认值SUM
String aggregateArg = aggregate != null ? aggregate : "SUM";
// 准备参数
List<String> keysArgs = keys;
String[] argvArgs = new String[]{tempKey, weightsArg, aggregateArg, String.valueOf(startIndex), String.valueOf(endIndex)};
// 执行脚本(注意:序列化器,键和参数都使用String序列化)
List<Object> result = (List<Object>) redisTemplate.execute(
redisScript,
new StringRedisSerializer(), // 键序列化器
new StringRedisSerializer(), // 值序列化器(用于结果,但这里我们手动转换)
keysArgs,
argvArgs
);
// 处理返回结果:结果是一个列表,每两个元素为一组(元素, 分数)
if (result == null || result.isEmpty()) {
return Collections.emptyList();
}
List<Map.Entry<String, Double>> entries = new ArrayList<>();
for (int i = 0; i < result.size(); i += 2) {
String value = (String) result.get(i);
Double score = Double.valueOf((String) result.get(i + 1));
entries.add(new AbstractMap.SimpleEntry<>(value, score));
}
return entries;
}
}
```
测试用例:
```java
@SpringBootTest
public class ZSetLuaUnionServiceTest {
@Autowired
private ZSetLuaUnionService zSetLuaUnionService;
@Test
public void testZunionWithPaginationByLua() {
// 准备数据
// 假设有两个有序集合
// ... 这里省略,确保redis中有两个有序集合,比如zset1, zset2
List<String> keys = Arrays.asList("zset1", "zset2");
List<Double> weights = Arrays.asList(1.0, 2.0);
int page = 1;
int size = 10;
List<Map.Entry<String, Double>> result = zSetLuaUnionService.zunionWithPaginationByLua(keys, weights, "SUM", page, size);
// 输出结果
result.forEach(entry -> System.out.println(entry.getKey() + " : " + entry.getValue()));
}
}
```
注意事项:
1. 临时键的生成使用了UUID,确保唯一性,避免并发问题。
2. 脚本中使用了ZUNIONSTORE,它会在Redis服务器上创建一个临时集合,然后我们立即删除。由于整个脚本是原子操作,所以即使在高并发下也不会出现临时键残留。
3. 权重和聚合方式通过字符串参数传递,在Lua脚本中解析。
4. 分页索引是从0开始的,注意计算。
性能考虑:
- 当有序集合非常大时,尽管只取一页数据,但ZUNIONSTORE需要对整个并集进行计算,可能会影响性能。因此,这种方法适合数据量不是特别大的场景。
- 对于非常大的集合,可能需要考虑其他方案,如预先计算并存储并集(定时更新),然后直接分页查询。
相关问题:
1. 如何避免在Lua脚本中使用ZUNIONSTORE操作大型ZSet导致的性能问题?
2. 除了使用临时集合,还有哪些方法可以实现ZSet并集的分页查询?
3. 在分布式环境下,如何确保Lua脚本中临时键的唯一性以避免冲突?
</think>
### 在 Spring Boot 中使用 Lua 脚本实现 ZUNION 并集操作及分页处理
我将通过 Lua 脚本实现多个有序集合(ZSet)的并集操作(ZUNION)并结合分页处理。这种方案相比纯 Java 实现有以下优势:
- **原子性**:所有操作在 Redis 服务器端原子执行
- **高性能**:减少网络传输次数,避免大结果集传输
- **内存优化**:通过分页避免一次性加载全部数据
#### 实现方案设计
```mermaid
graph TD
A[客户端请求] --> B[生成临时键名]
B --> C[构造Lua脚本参数]
C --> D[执行Lua脚本]
D --> E[ZUNIONSTORE创建临时集合]
E --> F[ZRANGE分页查询]
F --> G[删除临时集合]
G --> H[返回分页结果]
```
#### 核心 Lua 脚本
```lua
-- KEYS: 有序集合键列表
-- ARGV[1]: 临时集合键名
-- ARGV[2]: 起始索引 (0-based)
-- ARGV[3]: 结束索引
-- ARGV[4]: 权重列表 (JSON数组)
-- ARGV[5]: 聚合方式 (SUM/MIN/MAX)
-- 解析权重参数
local weights = cjson.decode(ARGV[4])
local weightArgs = {}
if #weights > 0 then
table.insert(weightArgs, 'WEIGHTS')
for _, w in ipairs(weights) do
table.insert(weightArgs, w)
end
end
-- 执行并集存储
redis.call('ZUNIONSTORE', ARGV[1], #KEYS, unpack(KEYS), unpack(weightArgs), 'AGGREGATE', ARGV[5])
-- 分页查询
local result = redis.call('ZRANGE', ARGV[1], ARGV[2], ARGV[3], 'WITHSCORES')
-- 清理临时数据
redis.call('DEL', ARGV[1])
return result
```
#### Spring Boot 服务层实现
```java
@Service
public class ZSetLuaService {
private final RedisTemplate<String, Object> redisTemplate;
private final DefaultRedisScript<List> redisScript;
// Lua脚本初始化
public ZSetLuaService(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
this.redisScript = new DefaultRedisScript<>();
this.redisScript.setScriptSource(new ResourceScriptSource(
new ClassPathResource("lua/zunion-paginate.lua")));
this.redisScript.setResultType(List.class);
}
/**
* 执行并集分页操作
* @param keys ZSet键列表
* @param weights 权重列表(需与keys长度一致)
* @param aggregate 聚合方式
* @param page 页码(从1开始)
* @param size 每页大小
* @return 分页结果(元素+分数)
*/
public List<Map.Entry<String, Double>> zunionPaginate(
List<String> keys,
List<Double> weights,
Aggregate aggregate,
int page,
int size) {
// 1. 计算分页参数
long start = (page - 1) * size;
long end = start + size - 1;
// 2. 生成唯一临时键名
String tempKey = "temp:zunion:" + UUID.randomUUID();
// 3. 准备脚本参数
List<String> keyList = new ArrayList<>(keys);
String weightsJson = new Gson().toJson(weights);
// 4. 执行Lua脚本
List<Object> results = redisTemplate.execute(
redisScript,
keyList,
tempKey, start, end, weightsJson, aggregate.name()
);
// 5. 处理结果 [value1, score1, value2, score2, ...]
List<Map.Entry<String, Double>> entries = new ArrayList<>();
for (int i = 0; i < results.size(); i += 2) {
String value = (String) results.get(i);
Double score = Double.parseDouble((String) results.get(i + 1));
entries.add(new AbstractMap.SimpleEntry<>(value, score));
}
return entries;
}
}
```
#### 测试用例
```java
@SpringBootTest
class ZSetLuaServiceTest {
@Autowired
private ZSetLuaService zsetService;
@Test
void testZunionPaginate() {
// 准备测试数据
redisTemplate.opsForZSet().add("zset1", "A", 10);
redisTemplate.opsForZSet().add("zset1", "B", 20);
redisTemplate.opsForZSet().add("zset2", "B", 30);
redisTemplate.opsForZSet().add("zset2", "C", 40);
// 执行并集分页查询
List<Map.Entry<String, Double>> page1 = zsetService.zunionPaginate(
Arrays.asList("zset1", "zset2"),
Arrays.asList(1.0, 1.0),
Aggregate.SUM,
1, // 第一页
2 // 每页2条
);
// 验证结果
assertEquals(2, page1.size());
assertEquals("A", page1.get(0).getKey());
assertEquals(10.0, page1.get(0).getValue(), 0.01);
assertEquals("B", page1.get(1).getKey());
assertEquals(50.0, page1.get(1).getValue(), 0.01); // 20+30
}
}
```
#### 关键优化点说明
1. **临时键管理**
- 使用 `UUID` 确保键名唯一性
- 脚本内自动清理临时集合(`DEL`命令)
- 添加 `temp:` 前缀便于识别临时数据
2. **参数传递优化**
- 权重列表转换为 JSON 格式传输
- 分页参数转为 0-based 索引
- 使用 `unpack()` 处理动态参数
3. **性能保障**
- 单次网络往返完成全部操作
- 结果集大小限制为页尺寸
- 原子性避免并发冲突
4. **错误处理扩展**
```lua
-- 添加参数校验
if #KEYS < 2 then
return redis.error_reply("至少需要2个集合")
end
if #weights > 0 and #weights ~= #KEYS then
return redis.error_reply("权重数与集合数不匹配")
end
```
#### 适用场景建议
1. **中小规模数据集**(元素数 < 100,000)
2. **实时性要求高的排行榜合并**
3. **多维度权重聚合场景**
对于超大规模数据集(>100万元素),推荐:
1. 使用 `ZUNIONSTORE` 持久化合并结果
2. 通过 `ZSCAN` 分页遍历大集合
3. 设置 TTL 自动清理临时数据[^1]
[^1]: Redis 有序集合 ZSet 详解
[^2]: Spring boot 整合 redis
####