Redis scan命令的一些坑

629 阅读5分钟

背景

最近项目中有个模块要改版,其中有一个需求,从Excel中导入数据,并且要求Excel的表头,都可以作为下拉筛选项,导入的数据,就是相应的选项值。 例如,假如有下面这个Excel表格:

姓名性别省份年龄数学英语
张三安徽199887
李四安徽189980
王二麻湖北208880
赵四上海187877

那么,筛选项就有姓名性别省份年龄数学英语,相应的,筛选项的值,就是其对应的去重后的数据集。 也就是说,英语这项筛选项,对应的筛选值[87, 80, 77]。

由于导入的数据,表头不固定,所以没办法在数据库里建立相应的字段。之前有个版本,是将数据拆分成keyval形式,保存在数据库里。

但是,这样的话,会导致数据量暴增!一个10x100的表格,导入数据库后,会变成1000条数据。只需要几天的时间,数据量就会突破几百万条。

所有的表头,都要作为筛选项,所以必须要加索引了,这么多的数据量,光索引就是很大的一笔开销了。

所以,这次改版的时候,决定练数据的存储方式,也一并修改。

看到这里,其实大家心里都有个方案了,那就是使用NoSQL数据库来保存数据,将Excel中,每行数据作为一条json数据,保存到NoSQL数据库中。但是,公司的技术栈,没有NoSQL数据库,只能使用MySQL存储数据。

MySQL保存json数据

MySQL从什么时候开始支持保存json格式数据的?不知道,没查过。但是这不影响我干活,因为我知道,单位使用的MySQL版本,支持保存json。

CREATE TABLE `a_test` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `details` json DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB;

创建数据库,组织Excel里的数据,保存到数据库中。

{
    "姓名": "张三",
    "性别": "男",
    "省份": "安徽",
    "年龄": "19",
    "数学": "98",
    "英语": "87"
}

数据是保存进去了,但是,业务的要求是,所有字段都可以搜索,虽然这样保存数据后,数据量是少了很多,但索引还是不能少的,毕竟是要作为搜索项。而MySQL不能直接对json格式的数据创建索引,要想对json字段创建索引,需要通过其他方式。

网上搜了一下,找到了一个方案,通过MySQL虚拟字段来实现。 如何索引JSON字段

P.S:这个网站是淘宝数据库内核月报,里面有很多干货,建议大家收藏起来。

但是,有一个问题,这个方案,只有在json字段固定的情况下,才能生效,而我们的字段都不是固定的!

所以,这个方案,可以作为一个知识储备,但不适用我们的业务。

其实,问题归纳起来就两点:

  1. 表头不固定的数据存储

  2. 数据检索

表头不固定的数据存储,最好的方式就是使用NoSQL,如果没有NoSQL的话,可以用MySQL的json格式存储。

但是,这种方式只能用来存储,检索的话,就不怎么方便了,我们需要借助其他工具来完成检索。

使用Redis实现检索功能

Redis不仅可以作为一个缓存系统,也可以作为一个NoSQL数据库。

我们使用Redis作为搜索引擎,来完成数据的下拉检索,检索出数据的id,然后通过id回查数据库。

众所周知,Redis通过key-val的形式来检索数据,要想将Redis作为搜索引擎,得要先组织好数据的key

下拉选项的检索,都是精确搜索。所以,我们可以将下拉选项的检索项和值,组成一个key,将数据的id,作为值,保存在Redis的set中,这样就可以通过筛选项检索出相应的id集合,然后通过取所有筛选项id集合的交集,来获取最终的数据。

之所以不用MySQL来冗余这些下拉选项数据,是因为需要对每个选项的值做去重处理。使用MySQL来处理的话,会很麻烦。如果使用Redis的话,就很简单了。

例如上面那条数据,

$record = '{
    "姓名": "张三",
    "性别": "男",
    "省份": "安徽",
    "年龄": "19",
    "数学": "98",
    "英语": "87"
}';
$id = $model->insert($record);
$keyFormula = "search_%s_%s";
foreach($record as $key=>$val) {
    $key = sprintf($keyFormula, $key, $val);
    $redis->sAdd($key, $id);
}

scan命令登场了

数据该保存到数据库的,已经保存到数据库了,该保存到Redis中的,也已经保存进去了。下面,该考虑怎么检索了。

由于所有的下拉选项和相应的值,都要服务端提供给前端,而所有选项都不是固定的。但是没关系,数据都录入Redis了,剩下的就是读取出来后,然后给客户端动态渲染就行了。

所有筛选项和值,我们都已经转换成Redis中的key了,所以,只需要通过scan命令,扫描所有key,将这些key读取出来,再稍微转换一下,就能获取到我们想要的数据了。

之所以使用scan命令,而不使用keys命令,大家随便网上搜一下,都会有一大堆的解释的,这里就不再赘述了。

以前没用过scan命令,可这个难不到我,网上随便搜一下,就有一堆示例。

$options = [];
$iterator = null;
while(false !== ($keys = $redis->scan($iterator, "search_*", 10000)) {
    foreach($keys as $key) {
        // 使用下划线分隔字符
        $tmpArray = explode('_', $key);
        // 取数组最后一个数据
        $optionVal = array_pop($tmpArray);
        $optionKey = array_pop($tmpArray);
        $options[$optionKey][] = $optionVal;
    }
}

这样,就能获取到所有的下拉选项和相应的值了。

测试了一下,没问题,上线!!

问题来了

由于我们是SaaS服务,就算将数据尽量分散在Redis的各个db中,单个db的key数量,还是达到了50万+。

最初,有客服反应,说某个客户有个年份的筛选项,导入的数据有201920202021,可是,2020不显示。

一开始,以为是数据导入的时候出了问题,导致的不显示。看了一下后台导入的数据,发现数据正常导入了啊。

再看Redis中的数据,使用RDM连接Redis,无意中看到RDM中显示好几个key重复的key,没在意这些,以为是RDM的bug,毕竟,电脑上的RDM版本已经很老了。继续看缓存数据,发现缓存中,数据也正常写入了啊。

接着,又有客服反应,一个客户那边,有个省份的筛选项,明明导入的省份有20多个,可却只显示十几个,而且每次显示的省份竟然还都不一样。

这是什么鬼?!!

数据都正常录入了啊,不管是MySQL还是Redis,数据都有啊!

然后,又有客服反应说,有个客户那里,通过筛选项,搜不到数据,但是相应条件下,实际是有数据的!

一下子来这么多问题,头疼!!

冷静,总结一下问题:

  1. 筛选项的数据不全

  2. 筛选项的数据,总是变更

  3. 搜不到数据,但是数据实际是有的

过一下代码,问题1、2,都是筛选项的问题,而筛选项的数据,都是通过scan命令从Redis中读取的。而问题3,搜不到数据的问题,仔细分析一下Redis中的数据,发现很多key中的数据,数据库里面已经被删除了!

可是,在删除数据库里数据的时候,明明也清理缓存了啊。

问了一下客服是怎么操作的,得到的答复是,之前数据录入的有问题,所以使用了一键清理后,重新导入数据了。后台是有一个一键清理的功能,一键清理所有数据。

数据库里是删除了,但是在清理Redis中的数据时,出问题了。

Redis不支持一个del命令删除多个key,没次del,都要明确指定一个key

所以在删除key之前,必须要知道这个key

在一键清理这里,在清理Redis数据的时候,也是用的scan命令,获取所有的key,然后再一个一个地del

客服在清理完数据后,理论上Redis中相应的数据应该都清理掉了,但实际情况是,还有很多的漏网之鱼,这些数据污染搜索结果,才导致的搜不到数据。

问题找到了,都是scan命令的问题。

面向搜索引擎编程,Google一下,Redisscan命令的一些坑,结果搜到一大堆鼓吹scan命令的,而且都是和keys命令做比较的,文章的内容有90%都是一样的!!

而且大部分都是介绍scan命令的count参数,这个参数的用法,我已经知道了啊。

简要说明一下就是,count参数并不是返回给你的数据量,而是Redis每次会指定的游标开始,取count个数量的key,然后再执行匹配,能匹配多少个key,就返回多少。

关于scancursor参数,为什么cursor忽大忽小,和scan命令的具体用法,大家自己去搜索吧。

好不容易找到几篇有点用的,介绍scan命令的原理,结果发现,内容相似度超过95%!!,随便选一遍大致浏览了一下,终于找到自己想要的答案了。

scan命令在扫描key的时候,是通过渐进式地扫描所有key,所以在扫描的时候,可能会出现rehash的情况,什么是rehash?简答地说就是发送hash重组了,更详细地,自己去搜索吧。

所以,如果在扫描的时候,发生了rehash,那就可能出现重扫,或者漏扫的情况!!

这就是为什么,我在RDM中,会看到重复的key,并不是因为RDM出现了bug,而是出现重扫了。

所以下拉筛选项那里,年份对应的2020不显示。因为出现漏扫了!

所以省份那里,明明有20多个省份,却只出现十几个,而且每次的结果都不一样。因为发生rehash,出现漏扫了!

所以,在清理缓存的时候,会有漏网之鱼,因为漏扫了!

在用scan命令获取到下拉选项的值的时候,我习惯性地做了去重处理,所以就算重扫了,我也不知道。

问题找到了,解决起来就简单了。

都是因为scan命令导致的!!!

既然scan命令会导致这些问题,那就不用它!

老老实实冗余数据吧,再定义一个

$record = '{
    "姓名": "张三",
    "性别": "男",
    "省份": "安徽",
    "年龄": "19",
    "数学": "98",
    "英语": "87"
}';
$id = $model->insert($record);
$keyFormula = "search_%s_%s";
// 所有筛选项对应的key
$optionFormula = "option_%s";
foreach($record as $key=>$val) {
    $key = sprintf($keyFormula, $key, $val);
    $optionKey = sprintf($optionFormula, $key);
    $redis->sAdd($key, $id);
    // 每个筛选项对应的值
    $redis->sAdd($optionKey, $val);
}

继续利用Redis的set自动去重机制,将筛选项的数据保存在Redis中,而不是通过scan命令计算出来。

在清理缓存的时候,先从数据库里读取到详情的数据,然后拼凑出相应的key,再去删除缓存吧。

总结

在使用scan命令扫描所有key的时候,如果出现rehash的情况,那么你扫描出来的数据,可能重复,可能漏扫,重扫的话,问题不大。可如果出现漏扫了,那你就倒霉了。

所以,scan命令还是慎用吧!!

以前虽然粗略地看过Redis的源码,但技术太菜了,没看懂多少。对Redis每个命令的实现原理,更是一个都没看!

看来等有时间了,得要好好看看了。

P.S:竟然scan命令有这些问题,在处理的时候,我当然想着偷懒,使用keys命令。在清理缓存的时候,使用keys命令获取所有要删除的key。在计算下拉选项的时候,先用keys命令获取想要的数据,然后再定义一个缓存,保存最后计算出来的下拉选项和相应的值。

这样,系统也只是偶尔出现卡顿,对我们的业务来说,只要在1秒内返回数据,问题都不大。/doge

image.png

可以,公司使用的是腾讯云的Redis,竟然将keys命令给屏蔽掉了!(/ω\)