背景
最近项目中有个模块要改版,其中有一个需求,从Excel中导入数据,并且要求Excel的表头,都可以作为下拉筛选项,导入的数据,就是相应的选项值。 例如,假如有下面这个Excel表格:
| 姓名 | 性别 | 省份 | 年龄 | 数学 | 英语 |
|---|---|---|---|---|---|
| 张三 | 男 | 安徽 | 19 | 98 | 87 |
| 李四 | 男 | 安徽 | 18 | 99 | 80 |
| 王二麻 | 男 | 湖北 | 20 | 88 | 80 |
| 赵四 | 男 | 上海 | 18 | 78 | 77 |
那么,筛选项就有姓名、性别、省份、年龄、数学、英语,相应的,筛选项的值,就是其对应的去重后的数据集。
也就是说,英语这项筛选项,对应的筛选值[87, 80, 77]。
由于导入的数据,表头不固定,所以没办法在数据库里建立相应的字段。之前有个版本,是将数据拆分成key、val形式,保存在数据库里。
但是,这样的话,会导致数据量暴增!一个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字段固定的情况下,才能生效,而我们的字段都不是固定的!
所以,这个方案,可以作为一个知识储备,但不适用我们的业务。
其实,问题归纳起来就两点:
-
表头不固定的数据存储
-
数据检索
表头不固定的数据存储,最好的方式就是使用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万+。
最初,有客服反应,说某个客户有个年份的筛选项,导入的数据有2019、2020、2021,可是,2020不显示。
一开始,以为是数据导入的时候出了问题,导致的不显示。看了一下后台导入的数据,发现数据正常导入了啊。
再看Redis中的数据,使用RDM连接Redis,无意中看到RDM中显示好几个key重复的key,没在意这些,以为是RDM的bug,毕竟,电脑上的RDM版本已经很老了。继续看缓存数据,发现缓存中,数据也正常写入了啊。
接着,又有客服反应,一个客户那边,有个省份的筛选项,明明导入的省份有20多个,可却只显示十几个,而且每次显示的省份竟然还都不一样。
这是什么鬼?!!
数据都正常录入了啊,不管是MySQL还是Redis,数据都有啊!
然后,又有客服反应说,有个客户那里,通过筛选项,搜不到数据,但是相应条件下,实际是有数据的!
一下子来这么多问题,头疼!!
冷静,总结一下问题:
-
筛选项的数据不全
-
筛选项的数据,总是变更
-
搜不到数据,但是数据实际是有的
过一下代码,问题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,就返回多少。
关于scan的cursor参数,为什么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
可以,公司使用的是腾讯云的Redis,竟然将keys命令给屏蔽掉了!(/ω\)