随机取n个不相同的元素(Lua实现)

本文介绍了一种无需删除操作即可从数组中随机选择不同元素的方法。通过随机交换元素位置,最终获得所需数量的不同随机元素。

一些逻辑逻辑需求要在一个长度为m数组中取出n个不同的元素(m > n),一般第一想法是随机出一个下标,然后删除对应的元素,以此来取出不同的元素。本篇文章介绍一个不需要删除操作的方法。代码如下:

-- 假设长度m为10,随机取出个数为n=3
local table = {1, 2, 3, 4, 5, 6, 7, 8, 9, 0}
local count = 3
local length = #table
for i = 1, count do
    local ri = math.random(i, length)
    local tmp = table[i]
    table[i] = table[ri]
    table[ri] = tmp
end
-- table中前3个元素就是要取出的三个元素

实现思路:第一次循环在1-m中随机一个下标ri,然后将下标1和ri交换,第二次循环在2-m中随机一个下标ri,将ri和2进行交换,最后table中前三个元素就是随机出来的元素。

<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 ####
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值