高并发下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 相关文章推荐
Redis5之后版本的高可用集群搭建的实现
Apr 27 Redis
Redis延迟队列和分布式延迟队列的简答实现
May 13 Redis
redis内存空间效率问题的深入探究
May 17 Redis
redis实现共同好友的思路详解
May 26 Redis
深入理解redis中multi与pipeline
Jun 02 Redis
Redis可视化客户端小结
Jun 10 Redis
你真的了解redis为什么要提供pipeline功能
Jun 22 Redis
了解Redis常见应用场景
Jun 23 Redis
浅析Redis Sentinel 与 Redis Cluster
Jun 24 Redis
Redis集群节点通信过程/原理流程分析
Mar 18 Redis
redis复制有可能碰到的问题汇总
Apr 03 Redis
解决 redis 无法远程连接
May 15 Redis
redis击穿 雪崩 穿透超详细解决方案梳理
Redis调用Lua脚本及使用场景快速掌握
Redis 的查询很快的原因解析及Redis 如何保证查询的高效
Redis 中使用 list,streams,pub/sub 几种方式实现消息队列的问题
Redis中有序集合的内部实现方式的详细介绍
Mar 16 #Redis
面试分析分布式架构Redis热点key大Value解决方案
分布式架构Redis中有哪些数据结构及底层实现原理
You might like
for循环连续求和、九九乘法表代码
2012/02/20 PHP
php中防止SQL注入的最佳解决方法
2013/04/25 PHP
php写app接口并返回json数据的实例(分享)
2017/05/20 PHP
Ubuntu上安装yaf扩展的方法
2018/01/29 PHP
PHP获取文件扩展名的常用方法小结【五种方式】
2018/04/27 PHP
浅谈Laravel模板实体转义带来的坑
2019/10/22 PHP
JavaScript 验证浏览器是否支持javascript的方法小结
2009/05/17 Javascript
JavaScript 继承详解 第一篇
2009/08/30 Javascript
JavaScript 学习历程和心得分享
2010/12/12 Javascript
js 得到文件后缀(通过正则实现)
2013/07/08 Javascript
JavaScript 实现简单的倒计时弹窗DEMO附图
2014/03/05 Javascript
简单封装js的dom查询实例代码
2016/07/08 Javascript
[原创]jQuery常用的4种加载方式分析
2016/07/25 Javascript
JS实现多张图片预览同步上传功能
2017/06/23 Javascript
Vue 用Vant实现时间选择器的示例代码
2019/10/25 Javascript
鸿蒙系统中的 JS 开发框架
2020/09/18 Javascript
[00:10]DOTA2全国高校联赛 以DOTA2会友
2018/05/30 DOTA
[01:09:50]VP vs Pain 2018国际邀请赛小组赛BO2 第二场
2018/08/20 DOTA
使用Python的Twisted框架构建非阻塞下载程序的实例教程
2016/05/25 Python
详解Python 模拟实现生产者消费者模式的实例
2017/08/10 Python
Python 调用 zabbix api的方法示例
2019/01/06 Python
python使用Plotly绘图工具绘制气泡图
2019/04/01 Python
Python实现二叉树前序、中序、后序及层次遍历示例代码
2019/05/18 Python
django的分页器Paginator 从django中导入类
2019/07/25 Python
Python 去除字符串中指定字符串
2020/03/05 Python
酒店管理毕业生自荐信
2013/10/24 职场文书
自考自我鉴定范文
2013/10/30 职场文书
集团公司人力资源部岗位职责
2014/01/03 职场文书
监督检查工作方案
2014/05/28 职场文书
医院领导班子四风问题对照检查材料
2014/10/26 职场文书
商务宴请邀请函范文
2015/02/02 职场文书
服务员岗位职责范本
2015/04/09 职场文书
企业财务人员岗位职责
2015/04/14 职场文书
用Python写一个简易版弹球游戏
2021/04/13 Python
用python自动生成日历
2021/04/24 Python
weblogic服务建立数据源连接测试更新mysql驱动包的问题及解决方法
2022/01/22 MySQL