高并发下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),而不依赖于读到的今天的数据,所以也就没有读后写的问题了(所以也可以看作是使定义二不成立)。


方法二:

限制并发。

方法一是使定义一或二不成立,从而解决读后写的问题。这里就不再在定义一或二上做文章了,下面换一个思路。
读后写归根结底其实还是并发下才会出现问题。因此这里介绍一个釜底抽薪的方法,限制并发!
一说到限制并发,可能第一反应就是加锁,自己在代码中加锁当然是一种办法,但是相对来说成本还是高一些(如何加锁可以参考我之前的一篇博文《用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克服读后写问题的思路,希望能起到引玉的作用!

水平有限,欢迎指正~
如需转发,请注明出处,thx~

<think>好的,我现在需要回答用户关于Redis缓存中保持数据一致性的方法的问题。用户已经提供了一些参考资料,我需要仔细理解这些内容,然后整理出一个清晰的回答结构。 首先,我应该回顾用户提供的引用内容。引用1提到,使用缓存时,像用户数据这类读多少的情况适合用Redis,而订单和支付数据这种高实时性的则不适合。引用2指出,双操作下缓存和数据库的数据不一致是一个挑战。引用3详细分析了读操作和操作的流程,指出在这些操作中可能出现不一致的情况,比如读取时缓存不存在或操作时更新顺序的问题。 接下来,我需要确定用户的问题核心:如何实现Redis缓存的数据读一致性。根据已有的知识,常见的策略有Cache-Aside、Read/Write Through、Write Behind等。结合引用中的内容,尤其是引用3提到的读操作和操作的流程,需要详细说明这些策略及其适用场景,同时提到可能的问题和解决方案。 在组织回答时,应该分步骤讲解,比如先讲读操作的处理,再讲操作的处理,然后讨论不同策略的优缺点。此外,还需要考虑并发情况下的一致性问题,比如延迟双删策略,或者使用分布式锁来避免竞态条件。 另外,用户提供的引用中提到了几种情况,比如读操作时缓存不存在导致后续读取旧数据,或者操作先更新数据库再删除缓存可能导致的不一致。需要将这些情况融入到回答中,说明不同的方法如何应对这些情况。 最后,生成相关问题,这些问题应该围绕用户可能进一步关心的点,比如具体策略的比较、异常处理、高并发下的解决方案,以及不同业务场景的选择建议等。确保问题具有引导性,帮助用户深入理解该主题。 需要确保回答符合系统级指令,使用正确的LaTeX格式,引用标记正确,并且结构清晰。同时,使用中文回答,避免技术术语使用不当,让内容易于理解。</think>### Redis缓存数据读一致性实现方式 为实现Redis缓存与数据库的数据一致性,需根据业务场景选择合适的策略。以下是常见方法及其原理: --- #### 一、读操作基本流程 1. **读操作** - 流程:先读缓存 → 缓存命中则返回数据;未命中则读数据库 → 入缓存 → 返回数据 - 风险:高并发下可能缓存旧数据(如数据库更新后未及时同步缓存)[^3]。 2. **操作** - 关键点:需同步更新数据库和缓存,但顺序影响一致性: - **先更新数据库,再删除缓存**:可能因删除失败导致缓存旧数据[^2]。 - **先删除缓存,再更新数据库**:可能因并发读请求将旧数据重新入缓存。 --- #### 二、一致性保障策略 1. **Cache-Aside(旁路缓存)** - **读操作**:优先读缓存,未命中则读数据库并回填。 - **操作**:直接更新数据库,成功后删除缓存。 - **适用场景**:读多少(如用户信息)[^1]。 - **缺点**:短时间可能存在缓存与数据库不一致(如删除缓存失败时)。 2. **Write-Through(直模式)** - **原理**:应用层将数据同时入数据库和缓存。 - **优点**:强一致性保障。 - **缺点**:入延迟高,适合少读多但对一致性要求高的场景。 3. **延迟双删** - **步骤**: 1. 删除缓存 → 更新数据库 → 延迟一定时间后再次删除缓存。 2. 第二次删除用于清除并发读可能产生的旧数据。 - **适用场景**:高并发操作,需结合业务估算合理延迟时间。 4. **分布式锁** - **原理**:操作前加锁,阻止其他线程读,确保原子性。 - **示例**: ```python lock = acquire_lock("data_key") if lock: update_database() delete_cache() release_lock("data_key") ``` - **缺点**:降低系统吞吐量。 --- #### 三、异常处理与优化 1. **重试机制** - 缓存删除失败时,通过消息队列或异步任务重试,确保最终一致性[^2]。 2. **订阅数据库日志(如MySQL Binlog)** - 使用工具(如Canal)监听数据库变更,自动更新/删除缓存,实现准实时同步。 3. **设置缓存过期时间** - 即使策略失败,缓存过期后自动重新加载,降低长期不一致风险。 --- #### 四、策略选择建议 | 场景特征 | 推荐策略 | |--------------------------|--------------------| | 读多少,容忍短暂不一致 | Cache-Aside | | 强一致性要求 | Write-Through + 锁 | | 高并发入 | 延迟双删 + 重试 | ---
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值