高并发下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...
}
1
2
3
4
5
6
7
8
9
如果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...
}
1
2
3
4
5
6
以上代码使用了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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
很明显,这也是一个读后写的方法——先获取最近参与日期,再根据条件修改最近参与日期(定义一二都被满足了),这个方法在高并发的时候很有可能会导致连续天数的错误累加。
那么,这个例子如何避免读后写呢?
方法其实有很多,这里先举两个:
方法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
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
这个思路是通过读昨天的数据后修改今天的数据,来达到避免对同一份数据读后写的目的的(使得定义一不成立,从而消除读后写的问题)。
这里虽然在最开始的时候也读取了今天的数据,但由于最后对今天的数据的修改只依赖于昨天的数据(今天的数据=昨天数据+1),而不依赖于读到的今天的数据,所以也就没有读后写的问题了(所以也可以看作是使定义二不成立)。
方法二:
限制并发。
方法一是使定义一或二不成立,从而解决读后写的问题。这里就不再在定义一或二上做文章了,下面换一个思路。
读后写归根结底其实还是并发下才会出现问题。因此这里介绍一个釜底抽薪的方法,限制并发!
一说到限制并发,可能第一反应就是加锁,自己在代码中加锁当然是一种办法,但是相对来说成本还是高一些(如何加锁可以参考我之前的一篇博文《用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发放金币等操作)...
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
只要涉及到数据读、写,就会有数据一致性问题,mysql中可以通过事务、锁(FOR UPDATE)等来保证一致性,而redis也可以根据业务需求设计不同的读写方式来实现(redis的事务真心不太好用)。这里抛出两种redis克服读后写问题的思路,希望能起到引玉的作用!
水平有限,欢迎指正~
如需转发,请注明出处,thx~
---------------------
作者:grey256
来源:CSDN
原文:https://blog.csdn.net/u011832039/article/details/78924418
版权声明:本文为博主原创文章,转载请附上博文链接!
高并发下Redis如何保持数据一致性(避免读后写)的更多相关文章
- [Redis] - 高并发下Redis缓存穿透解决
高并发情况下,可能都要访问数据库,因为同时访问的方法,这时需要加入同步锁,当其中一个缓存获取后,其它的就要通过缓存获取数据. 方法一: 在方法上加上同步锁 synchronized //加同步锁,解决 ...
- 高并发下redis
1.================================================================================================== ...
- 高并发下redis缓存穿透问题解决方案
一.使用场景 我们在日常的开发中,经常会遇到查询数据列表的问题,有些数据是不经常变化的,如果想做一下优化,在提高查询的速度的同时减轻数据库的压力,那么redis缓存绝对是一个好的解决方案. 二.需求 ...
- php结合redis实现高并发下的抢购、秒杀功能
抢购.秒杀是如今很常见的一个应用场景,主要需要解决的问题有两个:1 高并发对数据库产生的压力2 竞争状态下如何解决库存的正确减少("超卖"问题)对于第一个问题,已经很容易想到用缓存 ...
- (高级篇)php结合redis实现高并发下的抢购、秒杀功能
抢购.秒杀是如今很常见的一个应用场景,主要需要解决的问题有两个:1 高并发对数据库产生的压力2 竞争状态下如何解决库存的正确减少("超卖"问题)对于第一个问题,已经很容易想到用缓存 ...
- php结合redis实现高并发下的抢购、秒杀功能 (转载)
抢购.秒杀是如今很常见的一个应用场景,主要需要解决的问题有两个: 1 高并发对数据库产生的压力 2 竞争状态下如何解决库存的正确减少("超卖"问题) 对于第一个问题,已经很容易想到 ...
- redis实现高并发下的抢购/秒杀功能
之前写过一篇文章,高并发的解决思路(点此进入查看),今天再次抽空整理下实际场景中的具体代码逻辑实现吧:抢购/秒杀是如今很常见的一个应用场景,那么高并发竞争下如何解决超抢(或超卖库存不足为负数的问题)呢 ...
- PHP和Redis实现在高并发下的抢购及秒杀功能示例详解
抢购.秒杀是平常很常见的场景,面试的时候面试官也经常会问到,比如问你淘宝中的抢购秒杀是怎么实现的等等. 抢购.秒杀实现很简单,但是有些问题需要解决,主要针对两个问题: 一.高并发对数据库产生的压力二. ...
- 【转】php结合redis实现高并发下的抢购、秒杀功能
抢购.秒杀是如今很常见的一个应用场景,主要需要解决的问题有两个:1 高并发对数据库产生的压力2 竞争状态下如何解决库存的正确减少("超卖"问题)对于第一个问题,已经很容易想到用缓存 ...
随机推荐
- Java底层代码实现单文件读取和写入(解决中文乱码问题)
需求: 将"E:/data/车站一次/阿坝藏族羌族自治州.csv"文件中的内容读取,写入到"E:/data//车站一次.csv". 代码: public cla ...
- HASH、HASH函数、HASH算法的通俗理解
之前经常遇到hash函数或者经常用到hash函数,但是hash到底是什么?或者hash函数到底是什么?却很少去考虑.最近同学去面试被问到这个问题,自己看文章也看到hash的问题.遂较为细致的追究了一番 ...
- JDK源码 - ArrayList (基于1.7)
前言 推荐一位大牛的博客: https://blog.csdn.net/eson_15/article/details/51121833 我基本都是看的他的源码分析,刚开始如果直接看jdk源码可能 ...
- Docker容器技术-基础与架构
一.什么是容器 容器是对应用程序及其依赖关系的封装. 1.容器的优点 容器与主机的操作系统共享资源,提高了效率,性能损耗低 容器具有可移植性 容器是轻量的,可同时运行数十个容器,模拟分布式系统 不必花 ...
- python里两种遍历目录的方法
os.walk 函数声明:os.walk(top,topdown=True,onerror=None) (1)参数top表示需要遍历的顶级目录的路径. (2)参数topdown的默认值是“True”表 ...
- 一个简单的Javascript闭包示例
//=====用闭包实现函数的Curry化===== //数字求和函数的函数生成器 function addGenerator( num ){ //返回一个简单的匿名函数,求两个数的和,其中第一个数字 ...
- linux平台及windows平台mysql重启方法
各个平台mysql 重启: inux平台及windows平台mysql重启方法 Linux下重启MySQL的正确方法: 1.通过rpm包安装的MySQL service mysqld restart ...
- Qt 安装事件过滤器installEventFilter
Qt 安装事件过滤器installEventFilter (2013-01-28 14:29:18) 转载▼ 分类: 工作笔记 Qt的事件模型一个强大的功能是一个QObject对象能够监视发送其他 ...
- sql中的group by 和 having 用法
sql中的group by 用法:-- Group By语句从英文的字面意义上理解就是“根据(by)一定的规则进行分组(Group)”.--它的作用是通过一定的规则将一个数据集划分成若干个小的区域,然 ...
- 使用struts2的iterator标签出现的错误
错误如下所示: 代码如下所示: <body> <s:debug></s:debug> 获取list的值第一种方式 <!-- 3 获取值栈list集合数据 -- ...