高并发下Redis如何保持数据一致性(避免读后写)


Posted in Redis onMarch 18, 2022

“读后写”

通常意义上我们说读后写是指针对同一个数据的先读后写,且写入的值依赖于读取的值。

关于这个定义要拆成两部分来看,一:同一个数据;二:写依赖于读。(记住这个拆分,后续会用到,记为定义一、定义二)只有当这两部分都成立时,读后写的问题才会出现。

在项目中,当面对较多的并发时,使用redis进行读后写操作,是非常容易出问题的,常常使得程序不具备鲁棒性,bug很难稳定复现(得到的值往往跟并发数有关)。

举个栗子:

存在A、B两个进程,同时操作下面这段代码:

$objRedis = new Redis();
//获取key
$intNum   = $objRedis->get('key');
if ($intNum == 1) {
    //如果key的值为1,则给key加1
    $bolRet   = $objRedis->incr('key');

    //do something...
}
  • 如果A进程先get到了key,而此时key的值为1;
  • 同时,B进程此时也get到了key,同样key值为1;
  • B进程运行的快,先进行了if判断,发现满足条件,于是对key进行了累加操作,此时key变成了2;
  • A进程对B进程修改了key这个操作茫然无知,所以当它继续运行走到if判断条件时,由于它get的key是1,因此也满足条件,于是A进程也会对key进行累加操作,但是由于key已经被B进行累加过一次(key的值已经是2),因此当A再累加,key最终就变成了3。

实际上,代码的本意是希望key为1时执行一些操作,但当出现并发的时候,这段代码很难满足期望!
如果这样的代码出现在抽奖、秒杀等活动中,那就只能期望公司不会让个人承担损失了(汗)。
以上就是一个比较简单的读后写的问题。

对于这段代码其实很好解决,尤其是如果key的值本身没有意义的时候:

$objRedis = new Redis();
//获取key
$intNum   = $objRedis->incr('key');
if ($intNum == 1) {
    //do something...
}

以上代码使用了incr原子型操作,限制了并发(相当于加锁),就不会出现上述问题了。

但是,如果这个key如果是有意义的呢,那就不能随意改变,这种情况我们该怎么办?

详细说明

下面我举一个更具体的例子,然后从这个例子出发来抛几块砖(个人想的解决办法),希望引出更多的玉。

例子如下:
有一个活动,需要根据用户连续参与天数进行发奖,规则如下:

  • 连续参与1-3天,每天额外奖励10金币;
  • 连续参加4-7天,每天额外奖励50金币;
  • 连续参加8-15天,每天额外奖励100金币;
  • 连续参加15天以上,每天额外奖励200金币;

简单思路(使用读后写):

对每个用户使用一个hash存储,其中一个字段表示连续天数(‘sequence’),另一个字段存储最近参与日期(‘lastdate’)。
精简版代码如下:

$objRedis = new Redis();
//根据用户ID,生成redis的key
$strRedisKey = 'activity_' . $intUid;
//从Hash中获取最近参与时间
$mixDate     = $objRedis->HGET($strRedisKey, 'lastdate');

$intLastDate  = intval($mixDate);
$intYesterDay = intval(date("Ymd", strtotime("-1 day")));
$intCurrDate  = intval(date('Ymd'));
$intNum       = 0;//连续天数
if ($intCurrDate == $intLastDate) {
    //今天已经参与过,直接跳过
    return;
} elseif ($intLastDate == $intYesterDay) {
    //日期连续,增加连续天数
    $intNum = $objRedis->HINCRBY($strRedisKey, 'sequence', 1);
    if ($intNum > 0) {
        //将最近参与时间设置为当天
        $objRedis->HSET($strRedisKey, 'lastdate', $intCurrDate);
    }
} else {
    //日期不连续,设置连续天数为1,最近参与时间为当天
    $intNum = 1;
    $objRedis->HMSET($strRedisKey, 'sequence', $intNum, 'lastdate', $intCurrDate);
}

//do something(根据$intNum发放金币等操作)...

 很明显,这也是一个读后写的方法——先获取最近参与日期,再根据条件修改最近参与日期(定义一二都被满足了),这个方法在高并发的时候很有可能会导致连续天数的错误累加。

那么,这个例子如何避免读后写呢?
方法其实有很多,这里先举两个:

方法1:

通过使定义一或二不成立,从而使得读后写的问题不存在。

按日期进行存储——将redis的key按日期进行划分,比如用户ID为123的key从redis_123变为redis_123_20171225。这样的话,其实相当于避免了读写同一份数据。
代码如下:

$objRedis = new Redis();
//根据用户ID,生成redis的key
$strCurrRedisKey = 'activity_' . $intUid . '_' . date('Ymd');
//从Hash中获取最近参与时间
$mixNum          = $objRedis->GET($strCurrRedisKey);

$intNum = 0;//连续天数
if (is_null($mixNum)) {
    //当天还没被处理过,查找前一天的记录
    $strLastRedisKey = 'activity_' . $intUid . '_' . intval(date("Ymd", strtotime("-1 day")));
    $mixLastNum      = $objRedis->GET($strLastRedisKey);
    //计算连续天数
    $intNum = intval($mixLastNum) + 1;
    //设置当天的连续天数,并给这个key一周的过期时间
    $objRedis->SETEX($strCurrRedisKey, 604800, $intNum);
} else {
    //今天已经操作了,直接返回
    return;
}

//do something(根据$intNum发放金币等操作)...

这个思路是通过读昨天的数据后修改今天的数据,来达到避免对同一份数据读后写的目的的(使得定义一不成立,从而消除读后写的问题)。
这里虽然在最开始的时候也读取了今天的数据,但由于最后对今天的数据的修改只依赖于昨天的数据(今天的数据=昨天数据+1),而不依赖于读到的今天的数据,所以也就没有读后写的问题了(所以也可以看作是使定义二不成立)。

方法2:

限制并发。

方法一是使定义一或二不成立,从而解决读后写的问题。这里就不再在定义一或二上做文章了,下面换一个思路。
读后写归根结底其实还是并发下才会出现问题。因此这里介绍一个釜底抽薪的方法,限制并发!
一说到限制并发,可能第一反应就是加锁,自己在代码中加锁当然是一种办法,但是相对来说成本还是高一些(如何加锁可以参考我之前的一篇博文《用redis实现悲观锁》),这里就不再赘述。
其实读后写,最基本也是最简单的拆分方式是——读和写,那么釜底抽薪的办法就是能不能不读,只写!
实现思路就是只用一个key来存储连续天数+当前日期,然后使用原子型操作来写。一说到原子型操作,在redis中第一反应就是incr。那么顺着这个思路,我们怎么利用incr来操作呢?
其实关键是设计一个存储方式,满足既能存放连续天数,又能存放当前日期,还能使得这个值多次incr而不影响本身数据。这里说下我的设计方法:将一个12位的整数值看作是一个分段有意义的值,连续天数用最高的2位表示(因业务自定义),中间8位代表日期(如20171225),最后2位用于计数(无实际意义),比如:

将012017122523拆分成:
01|20171225|23
分别代表:连续天数|最近参与日期|计数

其中计数,这个字段是为了在利用incr时限制并发的。
示意代码如下:

$objRedis    = new Redis();
//根据用户ID,生成redis的key
$strRedisKey = 'activity_' . $intUid;
//从Hash中获取最近参与时间
$intVal       = intval($objRedis->INCR($strRedisKey));
$intCnt       = $intVal % 100;//获取计数
$intLastDate  = ($intVal - $intCnt) % 100000000;//获取最近参与日期
$intNum       = intval($intVal / 10000000000);//连续天数
$intYesterDay = intval(date("Ymd", strtotime("-1 day")));//昨天的日期
$intCurrDate  = intval(date('Ymd'));//今天的日期

if ($intCurrDate == $intLastDate) {
    //今天已经操作了
    if ($intCnt > 90) {
        //重置计数,防止超过给定范围(最大99)
        $objRedis->SET($strRedisKey, $intNum * 10000000000 + $intCurrDate * 100 + 1);
    }
    return;
} elseif ($intYesterDay == $intLastDate) {
    //日期连续,计算连续天数
    $intNum += 1;
} else {
    //日期不连续,重置连续天数
    $intNum = 1;
}
//更新连续天数及当前日期
$objRedis->SET($strRedisKey, $intNum * 10000000000 + $intCurrDate * 100 + 1);

//do something(根据$intNum发放金币等操作)...

只要涉及到数据读、写,就会有数据一致性问题,mysql中可以通过事务、锁(FOR UPDATE)等来保证一致性,而redis也可以根据业务需求设计不同的读写方式来实现(redis的事务真心不太好用)。这里抛出两种redis克服读后写问题的思路,希望能起到引玉的作用!

到此这篇关于高并发下Redis如何保持数据一致性(避免读后写)的文章就介绍到这了,更多相关Redis 数据一致性内容请搜索三水点靠木以前的文章或继续浏览下面的相关文章希望大家以后多多支持三水点靠木!

Redis 相关文章推荐
Java Socket实现Redis客户端的详细说明
May 26 Redis
Django使用redis配置缓存的方法
Jun 01 Redis
Redis 彻底禁用RDB持久化操作
Jul 09 Redis
Redis入门教程详解
Aug 30 Redis
使用redis实现延迟通知功能(Redis过期键通知)
Sep 04 Redis
Springboot/Springcloud项目集成redis进行存取的过程解析
Dec 04 Redis
Redis命令处理过程源码解析
Feb 12 Redis
Redis调用Lua脚本及使用场景快速掌握
Mar 16 Redis
Redis数据同步之redis shake的实现方法
Apr 21 Redis
Redis入门基础常用操作命令整理
Jun 01 Redis
Redis批量生成数据的实现
Jun 05 Redis
Redis实现分布式锁的五种方法详解
Jun 14 Redis
redis击穿 雪崩 穿透超详细解决方案梳理
Redis调用Lua脚本及使用场景快速掌握
Redis 的查询很快的原因解析及Redis 如何保证查询的高效
Redis 中使用 list,streams,pub/sub 几种方式实现消息队列的问题
Redis中有序集合的内部实现方式的详细介绍
Mar 16 #Redis
面试分析分布式架构Redis热点key大Value解决方案
分布式架构Redis中有哪些数据结构及底层实现原理
You might like
用PHP制作静态网站的模板框架(三)
2006/10/09 PHP
php+mysql开源XNA 聚合程序发布 下载
2007/07/13 PHP
cmd下运行php脚本
2008/11/25 PHP
PHP数组排序之sort、asort与ksort用法实例
2014/09/08 PHP
php第一次无法获取cookie问题处理
2014/12/15 PHP
PHP数组的定义、初始化和数组元素的显示实现代码
2016/11/05 PHP
jquery实现奇偶行赋值不同css值
2012/02/17 Javascript
JQuery文字列表向上滚动的代码
2013/11/13 Javascript
JavaScript获取function所有参数名的方法
2015/10/30 Javascript
使用jQuery5分钟快速搞定双色表格的简单实例
2016/08/08 Javascript
JS简单获取客户端IP地址的方法【调用搜狐接口】
2016/09/05 Javascript
JavaScript实现各种排序的代码详解
2017/08/28 Javascript
vue中echarts3.0自适应的方法
2018/02/26 Javascript
JavaScript简单实现关键字文本搜索高亮显示功能示例
2018/07/25 Javascript
JS通过位运算实现权限加解密
2018/08/14 Javascript
[49:27]2018DOTA2亚洲邀请赛 4.4 淘汰赛 TNC vs VG 第一场
2018/04/05 DOTA
python比较两个列表是否相等的方法
2015/07/28 Python
Python中几种导入模块的方式总结
2017/04/27 Python
Python使用pymysql从MySQL数据库中读出数据的方法
2018/07/25 Python
Python读取txt内容写入xls格式excel中的方法
2018/10/11 Python
将python运行结果保存至本地文件中的示例讲解
2019/07/11 Python
Django框架静态文件使用/中间件/禁用ip功能实例详解
2019/07/22 Python
节日快乐! Python画一棵圣诞树送给你
2019/12/24 Python
详解向scrapy中的spider传递参数的几种方法(2种)
2020/09/28 Python
python复合条件下的字典排序
2020/12/18 Python
IE8下CSS3选择器nth-child() 不兼容问题的解决方法
2016/11/16 HTML / CSS
Vita Fede官网:在意大利手工制作,在纽约市设计
2019/10/25 全球购物
医学专业五年以上个人求职信
2013/12/03 职场文书
求职自荐信格式
2013/12/04 职场文书
庆八一活动方案
2014/01/25 职场文书
公司领导班子召开党的群众路线教育实践活动总结大会新闻稿
2014/10/21 职场文书
助学感谢信范文
2015/01/21 职场文书
银行求职自荐信范文
2015/03/04 职场文书
行政前台岗位职责
2015/04/16 职场文书
物资采购管理制度
2015/08/06 职场文书
CocosCreator ScrollView优化系列之分帧加载
2021/04/14 Python