28

记一次Redis scard读取数据结果不对的问题 - DaemonCoder

 4 years ago
source link: https://www.daemoncoder.com/a/%E8%AE%B0%E4%B8%80%E6%AC%A1Redis%20scard%E8%AF%BB%E5%8F%96%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%9C%E4%B8%8D%E5%AF%B9%E7%9A%84%E9%97%AE%E9%A2%98/4d54673d?
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
今天在用Redis时遇到一个奇怪的问题,虽然很快就定位到了问题,但是在开发时确实忽略了,还是意识不到位,这里记录一下问题的来龙去脉让自己铭记。
首先介绍一下背景,在一个类似抢票的项目中,其中有一步是需要把奖品数据存放在Redis的一个集合(Set)中,中奖时通过SPOP命名从集合中取出一个奖品。这个奖品集合的数据在开始抢票之前生成,放在了一个脚本中去做,问题就出现在了这个脚本。

奖品数据生成脚本

上面我们说的脚本要做的工作无非就是:
1. 读出全部的奖品
2. 分批把奖品存入Redis的一个Set中(考虑奖品过多,没有一次把全部存入,做了分批处理)
这么看脚本很简单,没有什么复杂的逻辑,但是需要确保全部奖品都写入Redis成功,不能只写入部分奖品。如果有失败,需要进行重试或者报警。
下面直接看下代码,代码用世界上最好的PHP语言,Redis客户端用的phpredis
function setPrize($arrPrizes) {
    try {
        // 真实的奖品是由上面的参数$arrPrizes传入,数据格式如下:
        // $arrPrizes = array(
        //     array('code' => 'A0001', 'name' => 'iPhone'),
        //     array('code' => 'A0002', 'name' => 'iPhone'),
        // );
        foreach ($arrPrizes as $intIndex => $arrPrize) {
            $arrPrizes[$intIndex] = json_encode($arrPrize);
        }

        // 获取Redis实例
        $objRedis       = RedisUtil::getInstance();
        $strRedisKey    = 'www.daemoncoder.com';
        $arrPrizePages  = array_chunk($arrPrizes, 100);
        foreach ($arrPrizePages as $arrPrizePage) {
            // 分批写入redis中
            $intAddResult = $objRedis->sAddArray($strRedisKey, $arrPrizePage);
            echo sprintf("AddResult: %s\n", var_export($intAddResult, true));
        }

        // 判断写入的集合最终大小是否和传入的数组大小一致
        // 不一致则中间有数据没有成功写入,需要返回给上层重试或者报警
        $intScardResult = $objRedis->sCard($strRedisKey);
        echo sprintf("ScardResult: %s\n", var_export($intScardResult, true));
        if ($intScardResult == count($arrPrizes)) {
            return true;
        }
    } catch (Exception $e) {
        var_dump($e);
    }
    return false;
}
代码中 RedisUtil::getInstance() 是一个封装过的Redis工具类,里面封装了获取redis连接、自动选择主从库等操作。$objRedis->sAddArray() 把奖品数据分批写入,最后通过$objRedis->sCard()来判断是否全部成功写入,有失败的话需要返回给上层调用的地方,进行重试或者报警处理。

问题的表现

上面的代码实在太简单,一顿操作就把代码写完了,然后开心地去测试,没问题。不过为了稳妥,还是设置了重试一定次数,依然执行失败就邮件报警,万事俱备,不可能再有问题了,上线!然后,就翻车了!喜获报警邮件一份!
经过多次在测试发现不稳定,大多数情况可能成功执行,但是有很小的概率会失败。问题是失败的情况下,并不是因为写Redis失败。从输出的数据看,失败的时候也成功执行了写入操作,而且sCard()操作也成功执行,就是得到的数据不对。

问题的定位

手动清除原有数据,重新执行脚本,问题复现之后,查看输出,一切正常,只有sCard()返回的数据不对,还手动查了下当时集合中写入的数据,确实成功写入了,所以问题就锁定在了sCard()方法。
查了下Redis SCARD命令的官方文档:https://redis.io/commands/scard,先确认自己的没有用错。命令功能很简单,没有什么特别的地方,就是返回集合中的元素数量,如果key不存在,返回0。
难不成redis的scard命令有bug不成?兴奋之际,再单独执行了一下sCard(),这次结果竟然对了,这么看,问题不在sCard(),大概可以猜到问题的原因是出在了数据延时的问题。就是说之前通过sAddArray()写入集合的数据,有部分还没有生效。Redis本身用单线程处理请求,理论不应该存在出现这种延时,但是线上环境的Redis往往都是主从结构的,主库到从库同步数据是会有延时的,这也是出现这个问题的真实的原因。
上述代码中用RedisUtil::getInstance()来获取redis实例,前面也有介绍,这个是我们自己封装的Redis工具类,会根据不同的redis命令做读写分离。sAddArray()是一个写请求,会自动选择主库连接执行,而sCard()是一个读请求,默认会选择从库去执行。所以会出现用sCard()读取不到集合真实的大小,因为从库此时可能还没有同步到最新的数据。
调整代码,强制让sCard()方法选择主库(每个人连接的Redis工具类不同,这里不再贴代码,大概的方式就是连接时指定主库的IP)。这样经过多次反复测试,没有再出现这个问题。
为什么上线前测试的时候没有发现这个问题?
部分原因是问题出现的概率比较小,还有更重要的一个原因,是我们线下测试环境的Redis就只有一个库!没有那么多资源去给测试环境做个主从,最根本的原因可能还是因为穷(囧)。我想应该有不少公司和我们一样的,所以希望这个问题对你也有帮助。
不仅仅是主从延时的问题不易发现,如果线上Redis有多台机器,选择机器连接出错的问题也不易发现。
用一个比较常见的场景为例,存储用户的数据时,往往根据用户的ID做哈希,分布存储在多台机器上,如果代码有bug计算哈希值时用错了值,就有可能选择错误的机器。如果恰好你和我们一样,测试环境只有可怜的一台机器,那么测试阶段可能发现不了这个问题,细思极恐有木有。
为什么开发时没有考虑到会有主从延时的问题?
这个确实要从自己找原因了,还要把提高自己的主从意识。不仅仅是这种场景要考虑主从,从Redis中读任何数据时,都要第一时间想到读到可能不是最新数据。也不仅仅是Redis,MySQL等其他主从结构数据库,也都要第一时间想到主从延时。

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK