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

“读后写”

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

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

在项目中,当面对较多的并发时,使用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 数据一致性内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • 面试常问:如何保证Redis缓存和数据库的数据一致性

    目录 一.一致性 1.强一致性 2.弱一致性 3.最终一致性 二.redis缓存和mysql数据库数据一致性解决 1.方案一:采用延时双删策略 2.方案二:一步更新缓存(基于订阅Binlog的同步机制) 首先,我们先来看看有哪几种一致性的情况呢? 一.一致性 1.强一致性 如果你的项目对缓存的要求是强一致性的,那么请不要使用缓存.这种一致性级别是最符合用户直觉的,它要求系统写入什么,读出来的也会是什么,用户体验好,但实现起来往往对系统的性能影响大. 2.弱一致性 这种一致性级别约束了系统在写入成

  • MySQL与Redis如何保证数据一致性详解

    前言 由于缓存的高并发和高性能已经在各种项目中被广泛使用,在读取缓存这方面基本都是一致的,大概都是按照下图的流程进行操作: 但是在更新缓存方面,是更新完数据库再更新缓存还是直接删除缓存呢?又或者是先删除缓存再更新数据库?在这一点上就值得探讨了. 一致性方案 在实际项目开发中需要保证数据库和缓存中的数据一致,否则人家充值了100块,不断刷新却还是显示0.01元,岂不是尴尬?从理论上来说,为缓存设置过期时间是最终保证数据一致性的解决方案,采用这种方案的话,所有的写操作都是以数据库为准,如果数据库写入

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

    “读后写” 通常意义上我们说读后写是指针对同一个数据的先读后写,且写入的值依赖于读取的值. 关于这个定义要拆成两部分来看,一:同一个数据:二:写依赖于读.(记住这个拆分,后续会用到,记为定义一.定义二)只有当这两部分都成立时,读后写的问题才会出现. 在项目中,当面对较多的并发时,使用redis进行读后写操作,是非常容易出问题的,常常使得程序不具备鲁棒性,bug很难稳定复现(得到的值往往跟并发数有关). 举个栗子: 存在A.B两个进程,同时操作下面这段代码: $objRedis = new Red

  • php结合redis高并发下发帖、发微博的实现方法

    发帖.发微博.点赞.评论等这些操作很频繁的动作如果并发量小,直接入库是最简单的 但是并发量一大,数据库肯定扛不住,这时可采取延迟发布:先将发布动作保存在队列里,后台进程循环获取再入库 模拟发布微博先进入redis队列 weibo_redis.php <?php //此处需要安装phpredis扩展 $redis = new Redis(); $redis->connect('127.0.0.1', 6379); $redis->auth("php001"); //连接

  • php结合redis实现高并发下的抢购、秒杀功能的实例

    抢购.秒杀是如今很常见的一个应用场景,主要需要解决的问题有两个: 1 高并发对数据库产生的压力 2 竞争状态下如何解决库存的正确减少("超卖"问题) 对于第一个问题,已经很容易想到用缓存来处理抢购,避免直接操作数据库,例如使用Redis. 重点在于第二个问题 常规写法: 查询出对应商品的库存,看是否大于0,然后执行生成订单等操作,但是在判断库存是否大于0处,如果在高并发下就会有问题,导致库存量出现负数 <?php $conn=mysql_connect("localho

  • PHP+Redis 消息队列 实现高并发下注册人数统计的实例

    前言 现在越来越多的网站开始注重统计和用户行为分析,作为网站经常使用的功能,如何让统计性能更加高,这也是我们需要考虑的事情.本篇通过Redis来优化统计功能(以注册人数统计为例). 传统的统计功能都是直接操作数据库把数据插入表中.这样做,对数据库的性能消耗就会比较大. 思路: 这里我们用到了redis的队列,注册的时候先添加到队列,然后在处理的时候出队,并且把人数添加redis里. 代码: <?php //register.php $redis = new Redis(); $redis->c

  • PHP+Redis事务解决高并发下商品超卖问题(推荐)

    对于一些有一定用户量的电商网站,如果只是单纯的使用关系型数据库(如MySQL.Oracle)来做抢购,对数据库的压力是非常大的,而且如果不使用好数据库的锁机制,还会导致商品.优惠券超卖的问题.我所在的公司也遇到了同样的问题,问题发生在优惠券被超量抢购上,在问题发生后我们开始想办法解决问题,由于自己使用redis比较多,我准备使用redis来解决这个问题.利用redis的高性能和事务特性来解决线上优惠券被超库存抢购的问题,下面我给出我临时解决这个问题的第一版的伪代码,去掉了一些细节: /** *

  • PHP+Redis链表解决高并发下商品超卖问题(实现原理及步骤)

    上一篇文章聊了一下使用Redis事务来解决高并发商品超卖问题,今天我们来聊一下使用Redis链表来解决高并发商品超卖问题. 实现原理 使用redis链表来做,因为pop操作是原子的,即使有很多用户同时到达,也是依次执行,推荐使用. 实现步骤 第一步,先将商品库存入队列 /** * 添加商品数量到商品队列 * @param int $couponId 优惠券ID */ function addCoupons($couponId) { //1.初始化Redis连接 $redis = new Redi

  • 解决使用redisTemplate高并发下连接池满的问题

    用JMeter进行高并发测试的时候,发现报错: org.springframework.data.redis.RedisConnectionFailureException: Cannot get Jedis connection; nested exception is redis.clients.jedis.exceptions.JedisException: Could not get a resource from the pool 连不上redis,是因为连接池不够用了 我用的是red

  • 从架构思维角度分析高并发下幂等性解决方案

    目录 1 背景 2 幂等性概念 3 幂等性问题的常见解决方案 3.1 查询操作和删除操作 3.2 使用唯一索引 或者唯一组合索引 3.3 token机制 3.4 悲观锁 3.5 乐观锁 3.6 分布式锁 3.7  select + insert 3.8 状态机幂等 3.9 保证Api接口的幂等性 4 会议室的解决方案 5 总结 1 背景 我们的云办公系统有一个会议预定模块,每个月最后一个工作日的下午三点,会启动对下个月会议室的可用预定. 公司的 会议室大约200个,但是需求量远不止于此,所以会形

  • 高并发下如何避免重复数据产生技巧

    目录 前言 1. 需求 2. 性能优化 3. 出问题了 4. 多线程消费 5. 顺序消费 6. 唯一索引 5. 分布式锁 6. 统一mq异步处理 7. insert on duplicate key update 8. insert ignore 9. 防重表 前言 最近测试给我提了一个bug,说我之前提供的一个批量复制商品的接口,产生了重复的商品数据. 追查原因之后发现,这个事情没想象中简单,可以说一波多折. 1. 需求 产品有个需求:用户选择一些品牌,点击确定按钮之后,系统需要基于一份默认品

随机推荐