高并发下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 相关文章推荐
详解Redis实现限流的三种方式
Apr 27 Redis
深入浅析Redis 集群伸缩原理
May 15 Redis
Windows中Redis安装配置流程并实现远程访问功能
Jun 07 Redis
解析高可用Redis服务架构分析与搭建方案
Jun 20 Redis
Redis缓存-序列化对象存储乱码问题的解决
Jun 21 Redis
厉害!这是Redis可视化工具最全的横向评测
Jul 15 Redis
redis 存储对象的方法对比分析
Aug 02 Redis
Redis读写分离搭建的完整步骤
Sep 14 Redis
redis数据结构之压缩列表
Mar 21 Redis
redis 解决库存并发问题实现数量控制
Apr 08 Redis
浅谈Redis变慢的原因及排查方法
Jun 21 Redis
Redis实战之Lettuce的使用技巧详解
Dec 24 Redis
redis击穿 雪崩 穿透超详细解决方案梳理
Redis调用Lua脚本及使用场景快速掌握
Redis 的查询很快的原因解析及Redis 如何保证查询的高效
Redis 中使用 list,streams,pub/sub 几种方式实现消息队列的问题
Redis中有序集合的内部实现方式的详细介绍
Mar 16 #Redis
面试分析分布式架构Redis热点key大Value解决方案
分布式架构Redis中有哪些数据结构及底层实现原理
You might like
PHP 加密/解密函数 dencrypt(动态密文,带压缩功能,支持中文)
2009/01/30 PHP
PHP中使用memcache存储session的三种配置方法
2014/04/05 PHP
php遍历CSV类实例
2015/04/14 PHP
PHP中JSON的应用技巧
2015/10/10 PHP
PHP高并发和大流量解决方案整理
2019/12/24 PHP
Javascript学习笔记9 prototype封装继承
2010/01/11 Javascript
JS等比例缩小图片尺寸的实例
2013/02/27 Javascript
javascript Array 数组常用方法
2015/04/05 Javascript
javascript时间排序算法实现活动秒杀倒计时效果
2021/01/28 Javascript
使用jQuery实现WordPress中的Ctrl+Enter和@评论回复
2016/05/21 Javascript
使用Ajax生成的Excel文件并下载的实例
2016/11/21 Javascript
基于jQuery实现滚动切换效果
2016/12/02 Javascript
AngularJS实现进度条功能示例
2017/07/05 Javascript
基于 Immutable.js 实现撤销重做功能的实例代码
2018/03/01 Javascript
vue多层嵌套路由实例分析
2019/03/19 Javascript
vue2 拖动排序 vuedraggable组件的实现
2019/08/08 Javascript
50行代码实现贪吃蛇(具体思路及代码)
2013/04/27 Python
Python编程中运用闭包时所需要注意的一些地方
2015/05/02 Python
Django URL传递参数的方法总结
2016/08/28 Python
git进行版本控制心得详谈
2017/12/10 Python
详解Python中的四种队列
2018/05/21 Python
对Django的restful用法详解(自带的增删改查)
2019/08/28 Python
Python底层封装实现方法详解
2020/01/22 Python
python中的错误如何查看
2020/07/08 Python
Space NK美国站:英国高端美妆护肤商城
2017/05/22 全球购物
SneakerStudio英国:最佳运动鞋商店
2019/05/22 全球购物
美国和加拿大计算机和电子产品购物网站:TigerDirect.com
2019/09/13 全球购物
两则小学生的自我评价分享
2013/11/14 职场文书
人事部专员岗位职责
2014/03/04 职场文书
党风廉政承诺书
2014/03/27 职场文书
代领报检证委托书范本
2014/10/11 职场文书
2014年营销工作总结
2014/11/22 职场文书
关于拾金不昧的感谢信
2015/01/21 职场文书
清明节寄语2015
2015/03/23 职场文书
Python实现生活常识解答机器人
2021/06/28 Python
【海涛七七解说】DCG第二周:DK VS 天禄
2022/04/01 DOTA