一、前言
需求:在CMS运营管理系统中,显示在线用户人数。
当时现状是 :用户登录信息以
user_token:
前缀存储在 Redis 服务器里。当时开发同学上手直接使用
KEYS user_token:*
来统计在线人数,一把梭上线了,结果刚上线 Redis 就假死不可用了、其他服务纷纷报超时。只能先降级,下线此功能,优化后再上线。
😳 那就有人问:为啥 KEYS 命令会造成阻塞?
- 对键值的增删改查操作,是 Redis 主线程执行的主要任务。所有复杂度高的增删改查操作都会阻塞 Redis。
KEYS
:是遍历查询,复杂度为 O(N)。
😏 又有人问:Redis 6.0 不是支持多线程处理网络请求嘛?
-
网络IO有时候波动,导致响应慢,但 Redis 使用 IO多路复用机制,避免主线程一直等待网络连接或请求到来的状态。
-
Redis
采用多个 IO 线程:是用来处理网络请求,提高网络请求处理的并行度。- 原因 :单个线程处理网络读写的速度跟不上底层网络硬件的速度,所以这块采用多线程。
- Redis 多 IO 线程模型只用来处理网络读写请求,对于 Redis 的读写命令依然是 单线程(主线程) 处理。
回到这个问题,那如何处理这个问题呢?
- 方案一:使用 SCAN 命令
- 方案二:重新设计方案,计数服务。

(1)Scan 使用说明
SCAN 命令是渐进式的,它每次只返回一小部分结果。这意味着它不会一次性锁定整个数据库,从而减少了对 Redis 服务器的影响。
SCAN 的时间复杂度是 O(1),但这是针对每次迭代而言的。完整扫描整个数据库的时间复杂度是 O(N),其中 N 是键的数量。
相关语法 :SCAN cursor [MATCH pattern] [COUNT count]
cursor
:指光标的位置:是整数值,从 0 开始,到 0 结束,查询结果是空,但游标值不为 0,表示遍历还没结束。match pattern
:正则匹配字段。count
:限定服务器单次遍历的字典槽位数量(约等于),只是对增量式迭代命令的一种提示(hint),并不是查询结果返回的最大数量,它的默认值是 10。
shell
# 从头开始查询 user_token: 前缀的 key,返回 100 个
SCAN 0 MATCH user_token:* COUNT 100
Scan 还有如下三个指令:
- HScan:检索哈希类型的数据
HSCAN key cursor [MATCH pattern] [COUNT count]
- SScan:检索集合类型中的数据
SSCAN key cursor [MATCH pattern] [COUNT count]
- ZScan:检索有序集合中的数据
ZSCAN key cursor [MATCH pattern] [COUNT count]
shell
> hscan myhash 0 match *va* count 10
> sscan myset 0 match *va* count 20
> zscan myzset 0 match *va* count 20
(2)有哪些坑?😭😭😭
主要围绕这句话 :SCAN cursor [MATCH pattern] [COUNT count]
cursor
:指光标的位置:是整数值,从 0 开始,到 0 结束,查询结果是空,但游标值不为 0,表示遍历还没结束。
举个栗子 🌰:Redis里有 100W key前缀为 name:
的key,其中 name:杭州*
开头的 KEY 有 2 个。
-
使用命令
SCAN 0 name:杭州* COUNT 100
,可能返回数据:光标位置- 这是因为这一次遍历结束了,返回下次遍历开始的位置。
shell
> scan 0 MATCH name:杭州* COUNT 100
110592
-
使用命令
SCAN 0 name:杭州* COUNT 10000000
,等待一会儿后返回数据:光标位置、对应key- 说明只要 COUNT 足够大,一次 SCAN 调用就足够,只不过等待时间过长。
shell
> scan 0 MATCH name:杭州* COUNT 10000000
0
name:杭州环宇生物
name:杭州宇生物
(3) 小结
Scan 及它的相关命令可以保证以下查询规则:
- 它可以完整返回开始到结束检索集合中出现的所有元素,也就是在整个查询过程中如果这些元素没有被删除,且符合检索条件,则一定会被查询出来;
- 它可以保证不会查询出,在开始检索之前删除的那些元素。
然后,Scan 命令包含以下缺点:
- 一个元素可能被返回多次,需要客户端来实现去重;
- 在迭代过程中如果有元素被修改,那么修改的元素能不能被遍历到不确定。
Scan 具备以下几个特点:
- Scan 可以实现 keys 的匹配功能;
- Scan 是通过游标进行查询的不会导致 Redis 假死;
- Scan 提供了 count 参数,可以规定遍历的数量;
- Scan 会把游标返回给客户端,用户客户端继续遍历查询;
- Scan 返回的结果可能会有重复数据,需要客户端去重;
- 单次返回空值且游标不为 0,说明遍历还没结束;
- Scan 可以保证在开始检索之前,被删除的元素一定不会被查询出来;
- 在迭代过程中如果有元素被修改, Scan 不保证能查询出相关的元素。
二、Scan模糊搜索实战
需求:用 Scan 进行模糊搜索。
- 特定场合:数据加密
这一趴主要分为 3 部分:
- 模拟数据:加载百万数据
- 编写代码:SCAN
- 改进一版:Lua + SCAN
1. 加载百万数据
模拟加载数据,可以随机生成,也可以数据库里读取:
js
const mysql = require('mysql2/promise');
const Redis = require('ioredis');
const { pipeline } = require('stream');
const { promisify } = require('util');
const pipelineAsync = promisify(pipeline);
// MySQL 连接配置
const mysqlConfig = {
host: '',
user: '',
password: '!',
database: '',
};
// Redis 连接配置
const redisConfig = {
host: '',
port: 6379,
password: ''
};
async function main() {
const mysqlConnection = await mysql.createConnection(mysqlConfig);
const redisClient = new Redis(redisConfig);
try {
// 创建一个可读流来读取 MySQL 数据
const [rows] = await mysqlConnection.query("SELECT id, companyName FROM lead WHERE companyName != '' LIMIT 1000000");
console.log(`Total records fetched: ${rows.length}`);
//console.log(`Total records fetched: %s`, rows[0].companyName);
// 使用 pipeline 批量写入 Redis
let pipeline = redisClient.pipeline();
let inserted = 0;
for (let i = 0; i < rows.length; i++) {
const key = "crm:tenant:" + rows[i].companyName;
const value = 200000000000000 + i;
pipeline.set(key, value);
// 每 10000 条数据执行一次 pipeline
if ((i + 1) % 10000 === 0) {
inserted += 10000;
await pipeline.exec();
console.log(`Processed ${inserted} records`);
pipeline = redisClient.pipeline();
}
}
// 执行剩余的 pipeline 操作
if (pipeline.length > 0) {
await pipeline.exec();
}
console.log('Data transfer completed');
} catch (error) {
console.error('Error:', error);
} finally {
await mysqlConnection.end();
await redisClient.quit();
}
}
main();
模拟数据如下,数据占比:平均每个 Key 140B,100W数据大概 100MB

2. 编写业务代码:SCAN
直接搜索:
java
private Set<String> executeScan(String pattern) {
Set<String> keys = new HashSet<>();
Set<String> result = new HashSet<>();
ScanOptions options = ScanOptions.scanOptions().match(pattern).count(DEFAULT_CNT).build();
redisTemplate.execute((RedisCallback<Void>) connection -> {
try (Cursor<byte[]> cursor = connection.scan(options)) {
while (cursor.hasNext()) {
String key = new String(cursor.next());
keys.add(key);
String value = redisTemplate.opsForValue().get(key);
if (StringUtils.isNotBlank(value)) {
result.add(value);
}
}
} catch (Exception e) {
log.error("scanKeys error: ", e);
}
return null;
});
return result;
}
3. 改进一版:Lua + SCAN
lua
local function scanKeys(pattern, max_count)
local cursor = "0"
local keys = {}
local cnt = 0
repeat
local result = redis.call("SCAN", cursor, "MATCH", pattern, "COUNT", 10000)
cursor = result[1]
local scanned_keys = result[2]
for i, key in ipairs(scanned_keys) do
if cnt >= max_count then
break
end
cnt = cnt + 1
table.insert(keys, key)
end
until cursor == "0"
return keys
end
local key_pattern = KEYS[1]
local max_count = tonumber(ARGV[1])
local keys = scanKeys(key_pattern, max_count)
local values = {}
for i, key in ipairs(keys) do
local value = redis.call("GET", key)
if value then
table.insert(values, value)
end
end
return values
业务代码执行:
java
private Set<String> executeScanScript(String pattern, int maxCount) {
try {
List<String> list = redisTemplate.execute((RedisCallback<List<String>>) connection ->
(List<String>) connection.evalSha(
scriptSha,
ReturnType.MULTI,
1,
pattern.getBytes(),
String.valueOf(maxCount).getBytes()
));
if (CollectionUtils.isEmpty(list)) {
return Collections.emptySet();
}
return new HashSet<>(list);
} catch (Exception e) {
log.error("executeScanScript error: ", e);
if (e.getMessage().contains("NOSCRIPT")) {
init();
return executeScanScript(pattern, maxCount);
}
}
return Collections.emptySet();
}
性能测试
性能测试的使用场景有如下:
- 技术选型:比如测试 Memcached 和 Redis;
- 单机VS集群:单机 Redis 和集群 Redis 的吞吐量;
- 不同类型的存储性能:集合和有序集合;
- 持久话:开启持久化和关闭持久化的吞吐量;
- Redis 版本:不同 Redis 版本的吞吐量
平时性能测试分为两种:
redis-benchmark
:对 Redis 基准进行性能测试- 编写代码模拟,并用 Jmeter 进行性能压测
(1)redis-benchmark 测试
redis-benchmark
对 Redis
进行基准的性能测试。
压测命令 :redis-benchmark -h 127.0.0.1 -p 6379 -c 50 -n 10000
-h
:指定服务器主机名-p
:指定服务器端口-c
:指定并发连接数-n
:指定请求数-d
:以字节的形式指定 SET/GET 值的数据大小-r
:SET/GET/INCR 使用随机 key,SADD 使用随机值
shell
> redis-benchmark -t set,get,incr -n 1000000 -q
SET: 81726.05 requests per second
GET: 81466.40 requests per second
INCR: 82481.03 requests per second
测试命令示例:
shell
# 100个并发、100000个请求
redis-benchmark -c 100 -n 100000
# 测试 `scan` 的 `lua`脚本:
redis-benchmark -n 100000 -q script load "redis.call('set', 'key', 'value')"
(2)Jmeter 使用压测
线上 Redis内存 1GB,线上数据量约 200w,平均搜索1.6s。