Redis 如何模糊搜索 Scan 😂😂😂

一、前言

需求:在CMS运营管理系统中,显示在线用户人数。

当时现状是 :用户登录信息以 user_token:前缀存储在 Redis 服务器里。

当时开发同学上手直接使用 KEYS user_token:*来统计在线人数,一把梭上线了,结果刚上线 Redis 就假死不可用了、其他服务纷纷报超时。只能先降级,下线此功能,优化后再上线。

😳 那就有人问:为啥 KEYS 命令会造成阻塞?

  1. 对键值的增删改查操作,是 Redis 主线程执行的主要任务。所有复杂度高的增删改查操作都会阻塞 Redis。
  2. KEYS:是遍历查询,复杂度为 O(N)。

😏 又有人问:Redis 6.0 不是支持多线程处理网络请求嘛?

  1. 网络IO有时候波动,导致响应慢,但 Redis 使用 IO多路复用机制,避免主线程一直等待网络连接或请求到来的状态。

  2. Redis 采用多个 IO 线程:是用来处理网络请求,提高网络请求处理的并行度。

    • 原因单个线程处理网络读写的速度跟不上底层网络硬件的速度,所以这块采用多线程。
  1. 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 还有如下三个指令

  1. HScan:检索哈希类型的数据 HSCAN key cursor [MATCH pattern] [COUNT count]
  2. SScan:检索集合类型中的数据 SSCAN key cursor [MATCH pattern] [COUNT count]
  3. 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 个。

  1. 使用命令 SCAN 0 name:杭州* COUNT 100,可能返回数据:光标位置

    • 这是因为这一次遍历结束了,返回下次遍历开始的位置。
shell 复制代码
> scan 0 MATCH name:杭州* COUNT 100
110592
  1. 使用命令 SCAN 0 name:杭州* COUNT 10000000,等待一会儿后返回数据:光标位置、对应key

    • 说明只要 COUNT 足够大,一次 SCAN 调用就足够,只不过等待时间过长。
shell 复制代码
> scan 0 MATCH name:杭州* COUNT 10000000
0
name:杭州环宇生物
name:杭州宇生物

(3) 小结

Scan 及它的相关命令可以保证以下查询规则

  • 它可以完整返回开始到结束检索集合中出现的所有元素,也就是在整个查询过程中如果这些元素没有被删除,且符合检索条件,则一定会被查询出来;
  • 它可以保证不会查询出,在开始检索之前删除的那些元素。

然后,Scan 命令包含以下缺点

  • 一个元素可能被返回多次,需要客户端来实现去重;
  • 在迭代过程中如果有元素被修改,那么修改的元素能不能被遍历到不确定。

Scan 具备以下几个特点:

  1. Scan 可以实现 keys 的匹配功能;
  2. Scan 是通过游标进行查询的不会导致 Redis 假死;
  3. Scan 提供了 count 参数,可以规定遍历的数量;
  4. Scan 会把游标返回给客户端,用户客户端继续遍历查询;
  5. Scan 返回的结果可能会有重复数据,需要客户端去重;
  6. 单次返回空值且游标不为 0,说明遍历还没结束;
  7. Scan 可以保证在开始检索之前,被删除的元素一定不会被查询出来;
  8. 在迭代过程中如果有元素被修改, Scan 不保证能查询出相关的元素。

二、Scan模糊搜索实战

需求:用 Scan 进行模糊搜索。

  • 特定场合:数据加密

这一趴主要分为 3 部分

  1. 模拟数据:加载百万数据
  2. 编写代码:SCAN
  3. 改进一版: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();
}

性能测试

性能测试的使用场景有如下

  1. 技术选型:比如测试 Memcached 和 Redis;
  2. 单机VS集群:单机 Redis 和集群 Redis 的吞吐量;
  3. 不同类型的存储性能:集合和有序集合;
  4. 持久话:开启持久化和关闭持久化的吞吐量;
  5. Redis 版本:不同 Redis 版本的吞吐量

平时性能测试分为两种

  1. redis-benchmark:对 Redis 基准进行性能测试
  2. 编写代码模拟,并用 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。

相关推荐
woai33647 分钟前
首页实现多级缓存
redis·缓存·caffeine
陈随易9 分钟前
一段时间没写文章了,花了10天放了个屁
前端·后端·程序员
星星电灯猴12 分钟前
抓包工具分析接口跳转异常:安全校验误判 Bug 全记录
后端
调试人生的显微镜14 分钟前
后台发热、掉电严重?iOS 应用性能问题实战分析全过程
后端
深栈解码22 分钟前
OpenIM 源码深度解析系列(十八):附录二数据库结构
后端
前端付豪28 分钟前
Google Ads 广告系统排序与实时竞价架构揭秘
前端·后端·架构
努力的小郑38 分钟前
MySQL DATETIME类型存储空间详解:从8字节到5字节的演变
后端
码上库利南1 小时前
详解Redis数据库和缓存不一致的情况及解决方案
数据库·redis·缓存
哪吒编程2 小时前
我的第一个AI编程助手,IDEA最新插件“飞算JavaAI”,太爽了
java·后端·ai编程
vvilkim2 小时前
Uniapp H5端SEO优化全攻略:提升搜索引擎排名与流量
搜索引擎·uni-app