Skip to content

Commit be4df0c

Browse files
fix: 使用 SCAN 命令替代 KEYS 命令,避免大数据量时阻塞 Redis
问题: - KEYS 命令会阻塞 Redis,在大数据量场景下导致性能问题 - 多个线上故障与此相关 解决方案: - 使用 SCAN 命令替代 KEYS,非阻塞式遍历 影响文件: - sa-token-redis-template: 使用 RedisCallback + connection.scan() - sa-token-redis-template-jdk-serializer: 同上 - sa-token-redisx: 保持原有实现(redisx 库内部已使用 SCAN) - sa-token-jfinal-plugin: 使用 Jedis.scan() - sa-token-jboot-plugin: 使用 Jedis.scan() 新增测试: - SaTokenDaoForRedisTemplateTest: 6个测试用例覆盖 searchData 方法 - 测试通过:本机 Redis 环境,无密码 参考: - https://redis.io/commands/scan/ - https://redis.io/commands/keys/
1 parent 0d2f2d4 commit be4df0c

File tree

7 files changed

+227
-14
lines changed

7 files changed

+227
-14
lines changed

sa-token-plugin/sa-token-redis-template-jdk-serializer/src/main/java/cn/dev33/satoken/dao/SaTokenDaoForRedisTemplate.java

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,11 @@
2020
import org.springframework.beans.factory.annotation.Autowired;
2121
import org.springframework.data.redis.connection.RedisConnectionFactory;
2222
import org.springframework.data.redis.core.StringRedisTemplate;
23+
import org.springframework.data.redis.core.RedisCallback;
24+
import org.springframework.data.redis.core.ScanOptions;
2325

2426
import java.util.ArrayList;
2527
import java.util.List;
26-
import java.util.Set;
2728
import java.util.concurrent.TimeUnit;
2829

2930
/**
@@ -151,8 +152,23 @@ public void updateTimeout(String key, long timeout) {
151152
*/
152153
@Override
153154
public List<String> searchData(String prefix, String keyword, int start, int size, boolean sortType) {
154-
Set<String> keys = stringRedisTemplate.keys(prefix + "*" + keyword + "*");
155-
List<String> list = new ArrayList<>(keys);
155+
// 使用 SCAN 命令替代 KEYS,避免在大数据量时阻塞 Redis
156+
List<String> list = new ArrayList<>();
157+
String pattern = prefix + "*" + keyword + "*";
158+
159+
// 使用 RedisConnection 的 scan 方法进行非阻塞遍历
160+
stringRedisTemplate.execute((RedisCallback<Void>) connection -> {
161+
ScanOptions options = ScanOptions.scanOptions().match(pattern).count(1000).build();
162+
try (org.springframework.data.redis.core.Cursor<byte[]> cursor = connection.scan(options)) {
163+
while (cursor.hasNext()) {
164+
list.add(new String(cursor.next()));
165+
}
166+
} catch (Exception e) {
167+
throw new RuntimeException("Redis scan error", e);
168+
}
169+
return null;
170+
});
171+
156172
return SaFoxUtil.searchList(list, start, size, sortType);
157173
}
158174

sa-token-plugin/sa-token-redis-template/pom.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@
2727
<groupId>org.springframework.boot</groupId>
2828
<artifactId>spring-boot-starter-data-redis</artifactId>
2929
</dependency>
30+
<!-- Test dependencies -->
31+
<dependency>
32+
<groupId>org.springframework.boot</groupId>
33+
<artifactId>spring-boot-starter-test</artifactId>
34+
<scope>test</scope>
35+
</dependency>
3036
</dependencies>
3137

3238

sa-token-plugin/sa-token-redis-template/src/main/java/cn/dev33/satoken/dao/SaTokenDaoForRedisTemplate.java

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,11 @@
2020
import org.springframework.beans.factory.annotation.Autowired;
2121
import org.springframework.data.redis.connection.RedisConnectionFactory;
2222
import org.springframework.data.redis.core.StringRedisTemplate;
23+
import org.springframework.data.redis.core.RedisCallback;
24+
import org.springframework.data.redis.core.ScanOptions;
2325

2426
import java.util.ArrayList;
2527
import java.util.List;
26-
import java.util.Set;
2728
import java.util.concurrent.TimeUnit;
2829

2930
/**
@@ -150,8 +151,23 @@ public void updateTimeout(String key, long timeout) {
150151
*/
151152
@Override
152153
public List<String> searchData(String prefix, String keyword, int start, int size, boolean sortType) {
153-
Set<String> keys = stringRedisTemplate.keys(prefix + "*" + keyword + "*");
154-
List<String> list = new ArrayList<>(keys);
154+
// 使用 SCAN 命令替代 KEYS,避免在大数据量时阻塞 Redis
155+
List<String> list = new ArrayList<>();
156+
String pattern = prefix + "*" + keyword + "*";
157+
158+
// 使用 RedisConnection 的 scan 方法进行非阻塞遍历
159+
stringRedisTemplate.execute((RedisCallback<Void>) connection -> {
160+
ScanOptions options = ScanOptions.scanOptions().match(pattern).count(1000).build();
161+
try (org.springframework.data.redis.core.Cursor<byte[]> cursor = connection.scan(options)) {
162+
while (cursor.hasNext()) {
163+
list.add(new String(cursor.next()));
164+
}
165+
} catch (Exception e) {
166+
throw new RuntimeException("Redis scan error", e);
167+
}
168+
return null;
169+
});
170+
155171
return SaFoxUtil.searchList(list, start, size, sortType);
156172
}
157173

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/*
2+
* Copyright 2020-2099 sa-token.cc
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package cn.dev33.satoken.dao;
17+
18+
import org.junit.jupiter.api.AfterEach;
19+
import org.junit.jupiter.api.BeforeEach;
20+
import org.junit.jupiter.api.Test;
21+
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
22+
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
23+
import org.springframework.data.redis.core.StringRedisTemplate;
24+
25+
import java.util.List;
26+
27+
import static org.junit.jupiter.api.Assertions.*;
28+
29+
/**
30+
* SaTokenDaoForRedisTemplate 单元测试
31+
*
32+
* 需要 Redis 环境,使用 SCAN 命令测试 searchData 方法
33+
*
34+
* @author click33
35+
* @since 1.45.0
36+
*/
37+
class SaTokenDaoForRedisTemplateTest {
38+
39+
private SaTokenDaoForRedisTemplate dao;
40+
private StringRedisTemplate redisTemplate;
41+
42+
@BeforeEach
43+
void setUp() {
44+
// 连接本地 Redis(无密码)
45+
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration();
46+
config.setHostName("localhost");
47+
config.setPort(6379);
48+
49+
LettuceConnectionFactory factory = new LettuceConnectionFactory(config);
50+
factory.afterPropertiesSet();
51+
52+
redisTemplate = new StringRedisTemplate(factory);
53+
redisTemplate.afterPropertiesSet();
54+
55+
dao = new SaTokenDaoForRedisTemplate();
56+
dao.stringRedisTemplate = redisTemplate;
57+
}
58+
59+
@AfterEach
60+
void tearDown() {
61+
// 清理测试数据
62+
redisTemplate.delete(redisTemplate.keys("test_scan_*"));
63+
}
64+
65+
@Test
66+
void testSearchData_Empty() {
67+
// 测试空结果
68+
List<String> result = dao.searchData("test_scan_", "notexist", 0, 10, true);
69+
assertNotNull(result);
70+
assertTrue(result.isEmpty());
71+
}
72+
73+
@Test
74+
void testSearchData_SingleKey() {
75+
// 准备测试数据
76+
redisTemplate.opsForValue().set("test_scan_key1", "value1");
77+
78+
// 测试搜索
79+
List<String> result = dao.searchData("test_scan_", "key", 0, 10, true);
80+
81+
assertNotNull(result);
82+
assertEquals(1, result.size());
83+
assertTrue(result.contains("test_scan_key1"));
84+
}
85+
86+
@Test
87+
void testSearchData_MultipleKeys() {
88+
// 准备测试数据 - 创建多个 key
89+
for (int i = 1; i <= 5; i++) {
90+
redisTemplate.opsForValue().set("test_scan_key" + i, "value" + i);
91+
}
92+
93+
// 测试搜索
94+
List<String> result = dao.searchData("test_scan_", "key", 0, 10, true);
95+
96+
assertNotNull(result);
97+
assertEquals(5, result.size());
98+
}
99+
100+
@Test
101+
void testSearchData_Pagination() {
102+
// 准备测试数据 - 创建多个 key
103+
for (int i = 1; i <= 10; i++) {
104+
redisTemplate.opsForValue().set("test_scan_page" + i, "value" + i);
105+
}
106+
107+
// 测试分页 - 第一页
108+
List<String> page1 = dao.searchData("test_scan_", "page", 0, 5, true);
109+
assertEquals(5, page1.size());
110+
111+
// 测试分页 - 第二页
112+
List<String> page2 = dao.searchData("test_scan_", "page", 5, 5, true);
113+
assertEquals(5, page2.size());
114+
}
115+
116+
@Test
117+
void testSearchData_Pattern() {
118+
// 准备测试数据
119+
redisTemplate.opsForValue().set("test_scan_user_1001", "user1");
120+
redisTemplate.opsForValue().set("test_scan_user_1002", "user2");
121+
redisTemplate.opsForValue().set("test_scan_token_1001", "token1");
122+
123+
// 测试模式匹配 - 只匹配 user
124+
List<String> result = dao.searchData("test_scan_", "user", 0, 10, true);
125+
assertEquals(2, result.size());
126+
127+
// 测试模式匹配 - 只匹配 token
128+
List<String> result2 = dao.searchData("test_scan_", "token", 0, 10, true);
129+
assertEquals(1, result2.size());
130+
}
131+
132+
@Test
133+
void testBasicOperations() {
134+
// 测试基本的 get/set 操作
135+
dao.set("test_scan_basic", "hello", 60);
136+
assertEquals("hello", dao.get("test_scan_basic"));
137+
138+
// 测试 update
139+
dao.update("test_scan_basic", "world");
140+
assertEquals("world", dao.get("test_scan_basic"));
141+
142+
// 测试 delete
143+
dao.delete("test_scan_basic");
144+
assertNull(dao.get("test_scan_basic"));
145+
}
146+
}

sa-token-plugin/sa-token-redisx/src/main/java/cn/dev33/satoken/dao/SaTokenDaoForRedisx.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,11 +121,13 @@ public void updateTimeout(String key, long timeout) {
121121

122122
/**
123123
* 搜索数据
124+
*
125+
* 注意:redisx 库的 keys() 方法内部已使用 SCAN 实现,不会阻塞 Redis
124126
*/
125127
@Override
126128
public List<String> searchData(String prefix, String keyword, int start, int size, boolean sortType) {
127129
Set<String> keys = redisBucket.keys(prefix + "*" + keyword + "*");
128130
List<String> list = new ArrayList<>(keys);
129131
return SaFoxUtil.searchList(list, start, size, sortType);
130132
}
131-
}
133+
}

sa-token-starter/sa-token-jboot-plugin/src/main/java/cn/dev33/satoken/jboot/SaTokenCacheDao.java

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,12 @@
2525
import io.jboot.support.redis.JbootRedisConfig;
2626
import io.jboot.utils.ConfigUtil;
2727
import redis.clients.jedis.Jedis;
28+
import redis.clients.jedis.ScanParams;
29+
import redis.clients.jedis.ScanResult;
2830

2931
import java.util.ArrayList;
3032
import java.util.List;
3133
import java.util.Map;
32-
import java.util.Set;
3334
import java.util.concurrent.ConcurrentHashMap;
3435

3536
/**
@@ -272,14 +273,24 @@ public void updateSessionTimeout(String sessionId, long timeout) {
272273

273274
@Override
274275
public List<String> searchData(String prefix, String keyword, int start, int size, boolean sortType) {
276+
// 使用 SCAN 命令替代 KEYS,避免在大数据量时阻塞 Redis
277+
List<String> list = new ArrayList<>();
278+
String pattern = prefix + "*" + keyword + "*";
279+
String cursor = ScanParams.SCAN_POINTER_START;
280+
ScanParams params = new ScanParams().match(pattern).count(1000);
281+
275282
Jedis jedis = saRedisCache.getJedis();
276283
try {
277-
Set<String> keys = jedis.keys(prefix + "*" + keyword + "*");
278-
List<String> list = new ArrayList<>(keys);
279-
return SaFoxUtil.searchList(list, start, size, sortType);
284+
do {
285+
ScanResult<String> result = jedis.scan(cursor, params);
286+
list.addAll(result.getResult());
287+
cursor = result.getCursor();
288+
} while (!cursor.equals(ScanParams.SCAN_POINTER_START));
280289
} finally {
281290
saRedisCache.returnResource(jedis);
282291
}
292+
293+
return SaFoxUtil.searchList(list, start, size, sortType);
283294
}
284295

285296

sa-token-starter/sa-token-jfinal-plugin/src/main/java/cn/dev33/satoken/jfinal/SaTokenDaoRedis.java

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,11 @@
2222
import com.jfinal.plugin.redis.Redis;
2323
import com.jfinal.plugin.redis.serializer.ISerializer;
2424
import redis.clients.jedis.Jedis;
25+
import redis.clients.jedis.ScanParams;
26+
import redis.clients.jedis.ScanResult;
2527

2628
import java.util.ArrayList;
2729
import java.util.List;
28-
import java.util.Set;
2930

3031
public class SaTokenDaoRedis implements SaTokenDaoBySessionFollowObject {
3132

@@ -240,8 +241,23 @@ public void updateObjectTimeout(String key, long timeout) {
240241
*/
241242
@Override
242243
public List<String> searchData(String prefix, String keyword, int start, int size, boolean sortType) {
243-
Set<String> keys = redis.keys(prefix + "*" + keyword + "*");
244-
List<String> list = new ArrayList<>(keys);
244+
// 使用 SCAN 命令替代 KEYS,避免在大数据量时阻塞 Redis
245+
List<String> list = new ArrayList<>();
246+
String pattern = prefix + "*" + keyword + "*";
247+
String cursor = ScanParams.SCAN_POINTER_START;
248+
ScanParams params = new ScanParams().match(pattern).count(1000);
249+
250+
Jedis jedis = getJedis();
251+
try {
252+
do {
253+
ScanResult<String> result = jedis.scan(cursor, params);
254+
list.addAll(result.getResult());
255+
cursor = result.getCursor();
256+
} while (!cursor.equals(ScanParams.SCAN_POINTER_START));
257+
} finally {
258+
close(jedis);
259+
}
260+
245261
return SaFoxUtil.searchList(list, start, size, sortType);
246262
}
247263

0 commit comments

Comments
 (0)