diff --git a/sa-token-plugin/sa-token-redis-template/src/main/java/cn/dev33/satoken/dao/SaTokenDaoForRedisTemplate.java b/sa-token-plugin/sa-token-redis-template/src/main/java/cn/dev33/satoken/dao/SaTokenDaoForRedisTemplate.java index 06ef87972..63252e2ba 100644 --- a/sa-token-plugin/sa-token-redis-template/src/main/java/cn/dev33/satoken/dao/SaTokenDaoForRedisTemplate.java +++ b/sa-token-plugin/sa-token-redis-template/src/main/java/cn/dev33/satoken/dao/SaTokenDaoForRedisTemplate.java @@ -19,9 +19,13 @@ import cn.dev33.satoken.util.SaFoxUtil; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.Cursor; +import org.springframework.data.redis.core.ScanOptions; import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Set; import java.util.concurrent.TimeUnit; @@ -36,6 +40,27 @@ public class SaTokenDaoForRedisTemplate implements SaTokenDaoByObjectFollowStrin public StringRedisTemplate stringRedisTemplate; + /** + * 更新 key-value 并保持过期时间不变的 Lua 脚本(原子操作,减少一次网络请求) + * */ + private static final DefaultRedisScript UPDATE_KEEP_TTL_SCRIPT; + + static { + UPDATE_KEEP_TTL_SCRIPT = new DefaultRedisScript<>(); + UPDATE_KEEP_TTL_SCRIPT.setScriptText( + "local ttl = redis.call('pttl', KEYS[1]) " + + "if ttl == -2 then " + + " return 0 " + // key 不存在,直接返回 + "elseif ttl == -1 then " + + " redis.call('set', KEYS[1], ARGV[1]) " + // 永不过期,直接 set + "else " + + " redis.call('set', KEYS[1], ARGV[1], 'PX', ttl) " + // 有过期时间,set 并保持原 ttl + "end " + + "return 1" + ); + UPDATE_KEEP_TTL_SCRIPT.setResultType(Long.class); + } + /** * 标记:当前 redis 连接信息是否已初始化成功 */ @@ -90,24 +115,23 @@ public void set(String key, String value, long timeout) { } /** - * 修改指定key-value键值对 (过期时间不变) + * 修改指定key-value键值对 (过期时间不变) (优化版:使用 Lua 脚本原子化操作,减少一次网络请求) + * + * 优化背景: + * 原有实现先调用 getExpire 再调用 set,需要两次网络请求,高并发场景下开销大; + * 且非原子操作,在两次请求之间 key 的过期时间可能被修改,存在数据不一致风险。 + * + * 优化方案: + * 使用 Lua 脚本将两个操作合并为一次原子操作,减少网络请求,保证数据一致性。 */ @Override public void update(String key, String value) { - @SuppressWarnings("all") - long expireMs = stringRedisTemplate.getExpire(key, TimeUnit.MILLISECONDS); - // -2 = 无此键 - if (expireMs == SaTokenDao.NOT_VALUE_EXPIRE) { - return; - } - // -1 = 永不过期 - if(expireMs == SaTokenDao.NEVER_EXPIRE) { - stringRedisTemplate.opsForValue().set(key, value); - } else { - stringRedisTemplate.opsForValue().set(key, value, expireMs, TimeUnit.MILLISECONDS); - } + stringRedisTemplate.execute( + UPDATE_KEEP_TTL_SCRIPT, + Collections.singletonList(key), + value + ); } - /** * 删除Value */ @@ -144,14 +168,34 @@ public void updateTimeout(String key, long timeout) { } - + /** - * 搜索数据 + * 搜索数据(优化版:使用 scan 替代 keys,避免 Redis 主线程阻塞,降低内存溢出风险) + * + * 优化背景: + * 原有实现使用 keys 命令,会遍历 Redis 所有 key 并阻塞主线程,高并发场景下会导致服务雪崩; + * 且一次性加载所有匹配 key 到内存,存在 OOM 风险。 + * + * 优化方案: + * 使用 scan 命令迭代式遍历,无阻塞、分批加载、内存安全,同时保持原有 API 完全兼容。 */ @Override public List searchData(String prefix, String keyword, int start, int size, boolean sortType) { - Set keys = stringRedisTemplate.keys(prefix + "*" + keyword + "*"); - List list = new ArrayList<>(keys); + String pattern = prefix + "*" + keyword + "*"; + List list = new ArrayList<>(); + + // 使用 scan 命令迭代遍历,避免阻塞 Redis 主线程 + ScanOptions options = ScanOptions.scanOptions() + .match(pattern) + .count(1000) // 每次迭代返回的数量建议值 + .build(); + + try (Cursor cursor = stringRedisTemplate.scan(options)) { + while (cursor.hasNext()) { + list.add(cursor.next()); + } + } + return SaFoxUtil.searchList(list, start, size, sortType); }