精华内容
下载资源
问答
  • 高并发下的Nginx性能优化实战

    千人学习 2019-12-24 14:44:52
    本课程内容包含讲解解读Nginx的基础知识,解读Nginx的核心知识、带领学员进行高并发环境下的Nginx性能优化实战,让学生能够快速将所学融合到企业应用中。 【课程如何观看?】 PC端:...
  • PHP解决高并发问题

    万次阅读 2020-05-27 12:10:04
    我们通常衡量一个Web系统的吞吐率的指标是QPS(Query Per Second,每秒处理请求数),解决每秒数万次的高并发场景,这个指标非常关键。举个例子,我们假设处理一个业务请求平均响应时间为100ms,同时,系统内有20台...

    举个例子,高速路口,1秒钟来5部车,每秒通过5部车,高速路口运作正常。突然,这个路口1秒钟只能通过4部车,车流量仍然依旧,结果必定出现大塞车。(5条车道忽然变成4条车道的感觉)

    同理,某一个秒内,20*500个可用连接进程都在满负荷工作中,却仍然有1万个新来请求,没有连接进程可用,系统陷入到异常状态也是预期之内。

    14834077821.jpg

    其实在正常的非高并发的业务场景中,也有类似的情况出现,某个业务请求接口出现问题,响应时间极慢,将整个Web请求响应时间拉得很长,逐渐将Web服务器的可用连接数占满,其他正常的业务请求,无连接进程可用。

    更可怕的问题是,是用户的行为特点,系统越是不可用,用户的点击越频繁,恶性循环最终导致“雪崩”(其中一台Web机器挂了,导致流量分散到其他正常工作的机器上,再导致正常的机器也挂,然后恶性循环),将整个Web系统拖垮。

    重启与过载保护

    如果系统发生“雪崩”,贸然重启服务,是无法解决问题的。最常见的现象是,启动起来后,立刻挂掉。这个时候,最好在入口层将流量拒绝,然后再将重启。如果是redis/memcache这种服务也挂了,重启的时候需要注意“预热”,并且很可能需要比较长的时间。

    秒杀和抢购的场景,流量往往是超乎我们系统的准备和想象的。这个时候,过载保护是必要的。如果检测到系统满负载状态,拒绝请求也是一种保护措施。在前端设置过滤是最简单的方式,但是,这种做法是被用户“千夫所指”的行为。更合适一点的是,将过载保护设置在CGI入口层,快速将客户的直接请求返回

    高并发下的数据安全

    我们知道在多线程写入同一个文件的时候,会存现“线程安全”的问题(多个线程同时运行同一段代码,如果每次运行结果和单线程运行的结果是一样的,结果和预期相同,就是线程安全的)。如果是MySQL数据库,可以使用它自带的锁机制很好的解决问题,但是,在大规模并发的场景中,是不推荐使用MySQL的。秒杀和抢购的场景中,还有另外一个问题,就是“超发”,如果在这方面控制不慎,会产生发送过多的情况。我们也曾经听说过,某些电商搞抢购活动,买家成功拍下后,商家却不承认订单有效,拒绝发货。这里的问题,也许并不一定是商家奸诈,而是系统技术层面存在超发风险导致的。

    1. 超发的原因

    假设某个抢购场景中,我们一共只有100个商品,在最后一刻,我们已经消耗了99个商品,仅剩最后一个。这个时候,系统发来多个并发请求,这批请求读取到的商品余量都是99个,然后都通过了这一个余量判断,最终导致超发。(同文章前面说的场景)

    14834077822.jpg

    在上面的这个图中,就导致了并发用户B也“抢购成功”,多让一个人获得了商品。这种场景,在高并发的情况下非常容易出现。

    优化方案1:将库存字段number字段设为unsigned,当库存为0时,因为字段不能为负数,将会返回false

     <?php
     //优化方案1:将库存字段number字段设为unsigned,当库存为0时,因为字段不能为负数,将会返回false
     include('./mysql.php');
     $username = 'wang'.rand(0,1000);
     //生成唯一订单
     function build_order_no(){
       return date('ymd').substr(implode(NULL, array_map('ord', str_split(substr(uniqid(), 7, 13), 1))), 0, 8);
     }
     //记录日志
     function insertLog($event,$type=0,$username){
         global $conn;
         $sql="insert into ih_log(event,type,usernma)
         values('$event','$type','$username')";
         return mysqli_query($conn,$sql);
     }
     function insertOrder($order_sn,$user_id,$goods_id,$sku_id,$price,$username,$number)
     {
           global $conn;
           $sql="insert into ih_order(order_sn,user_id,goods_id,sku_id,price,username,number)
           values('$order_sn','$user_id','$goods_id','$sku_id','$price','$username','$number')";
          return  mysqli_query($conn,$sql);
     }
     //模拟下单操作
     //库存是否大于0
     $sql="select number from ih_store where goods_id='$goods_id' and sku_id='$sku_id' ";
     $rs=mysqli_query($conn,$sql);
     $row = $rs->fetch_assoc();
       if($row['number']>0){//高并发下会导致超卖
           if($row['number']<$number){
             return insertLog('库存不够',3,$username);
           }
           $order_sn=build_order_no();
           //库存减少
           $sql="update ih_store set number=number-{$number} where sku_id='$sku_id' and number>0";
           $store_rs=mysqli_query($conn,$sql);
           if($store_rs){
               //生成订单
               insertOrder($order_sn,$user_id,$goods_id,$sku_id,$price,$username,$number);
               insertLog('库存减少成功',1,$username);
           }else{
               insertLog('库存减少失败',2,$username);
           }
       }else{
           insertLog('库存不够',3,$username);
       }
     ?>
    
    1. 悲观锁思路

    解决线程安全的思路很多,可以从“悲观锁”的方向开始讨论。

    悲观锁,也就是在修改数据的时候,采用锁定状态,排斥外部请求的修改。遇到加锁的状态,就必须等待。

    14834077833.jpg

    虽然上述的方案的确解决了线程安全的问题,但是,别忘记,我们的场景是“高并发”。也就是说,会很多这样的修改请求,每个请求都需要等待“锁”,某些线程可能永远都没有机会抢到这个“锁”,这种请求就会死在那里。同时,这种请求会很多,瞬间增大系统的平均响应时间,结果是可用连接数被耗尽,系统陷入异常。

    优化方案2:使用MySQL的事务,锁住操作的行

     <?php
     //优化方案2:使用MySQL的事务,锁住操作的行
     include('./mysql.php');
     //生成唯一订单号
     function build_order_no(){
       return date('ymd').substr(implode(NULL, array_map('ord', str_split(substr(uniqid(), 7, 13), 1))), 0, 8);
     }
     //记录日志
     function insertLog($event,$type=0){
         global $conn;
         $sql="insert into ih_log(event,type)
         values('$event','$type')";
         mysqli_query($conn,$sql);
     }
     //模拟下单操作
     //库存是否大于0
     mysqli_query($conn,"BEGIN");  //开始事务
     $sql="select number from ih_store where goods_id='$goods_id' and sku_id='$sku_id' FOR UPDATE";//此时这条记录被锁住,其它事务必须等待此次事务提交后才能执行
     $rs=mysqli_query($conn,$sql);
     $row=$rs->fetch_assoc();
     if($row['number']>0){
         //生成订单
         $order_sn=build_order_no();
         $sql="insert into ih_order(order_sn,user_id,goods_id,sku_id,price)
         values('$order_sn','$user_id','$goods_id','$sku_id','$price')";
         $order_rs=mysqli_query($conn,$sql);
         //库存减少
         $sql="update ih_store set number=number-{$number} where sku_id='$sku_id'";
         $store_rs=mysqli_query($conn,$sql);
         if($store_rs){
           echo '库存减少成功';
             insertLog('库存减少成功');
             mysqli_query($conn,"COMMIT");//事务提交即解锁
         }else{
           echo '库存减少失败';
             insertLog('库存减少失败');
         }
     }else{
       echo '库存不够';
         insertLog('库存不够');
         mysqli_query($conn,"ROLLBACK");
     }
     ?>
    
    1. FIFO队列思路

    那好,那么我们稍微修改一下上面的场景,我们直接将请求放入队列中的,采用FIFO(First Input First Output,先进先出),这样的话,我们就不会导致某些请求永远获取不到锁。看到这里,是不是有点强行将多线程变成单线程的感觉哈。

    14834077834.jpg

    然后,我们现在解决了锁的问题,全部请求采用“先进先出”的队列方式来处理。那么新的问题来了,高并发的场景下,因为请求很多,很可能一瞬间将队列内存“撑爆”,然后系统又陷入到了异常状态。或者设计一个极大的内存队列,也是一种方案,但是,系统处理完一个队列内请求的速度根本无法和疯狂涌入队列中的数目相比。也就是说,队列内的请求会越积累越多,最终Web系统平均响应时候还是会大幅下降,系统还是陷入异常。

    1. 文件锁的思路
      对于日IP不高或者说并发数不是很大的应用,一般不用考虑这些!用一般的文件操作方法完全没有问题。但如果并发高,在我们对文件进行读写操作时,很有可能多个进程对进一文件进行操作,如果这时不对文件的访问进行相应的独占,就容易造成数据丢失

    优化方案4:使用非阻塞的文件排他锁

     <?php
     //优化方案4:使用非阻塞的文件排他锁
     include ('./mysql.php');
     //生成唯一订单号
     function build_order_no(){
       return date('ymd').substr(implode(NULL, array_map('ord', str_split(substr(uniqid(), 7, 13), 1))), 0, 8);
     }
     //记录日志
     function insertLog($event,$type=0){
         global $conn;
         $sql="insert into ih_log(event,type)
         values('$event','$type')";
         mysqli_query($conn,$sql);
     }
     $fp = fopen("lock.txt", "w+");
     if(!flock($fp,LOCK_EX | LOCK_NB)){
         echo "系统繁忙,请稍后再试";
         return;
     }
     //下单
     $sql="select number from ih_store where goods_id='$goods_id' and sku_id='$sku_id'";
     $rs =  mysqli_query($conn,$sql);
     $row = $rs->fetch_assoc();
     if($row['number']>0){//库存是否大于0
         //模拟下单操作
         $order_sn=build_order_no();
         $sql="insert into ih_order(order_sn,user_id,goods_id,sku_id,price)
         values('$order_sn','$user_id','$goods_id','$sku_id','$price')";
         $order_rs =  mysqli_query($conn,$sql);
         //库存减少
         $sql="update ih_store set number=number-{$number} where sku_id='$sku_id'";
         $store_rs =  mysqli_query($conn,$sql);
         if($store_rs){
           echo '库存减少成功';
             insertLog('库存减少成功');
             flock($fp,LOCK_UN);//释放锁
         }else{
           echo '库存减少失败';
             insertLog('库存减少失败');
         }
     }else{
       echo '库存不够';
         insertLog('库存不够');
     }
     fclose($fp);
      ?>
    
     <?php
     //优化方案4:使用非阻塞的文件排他锁
     include ('./mysql.php');
     //生成唯一订单号
     function build_order_no(){
       return date('ymd').substr(implode(NULL, array_map('ord', str_split(substr(uniqid(), 7, 13), 1))), 0, 8);
     }
     //记录日志
     function insertLog($event,$type=0){
         global $conn;
         $sql="insert into ih_log(event,type)
         values('$event','$type')";
         mysqli_query($conn,$sql);
     }
     $fp = fopen("lock.txt", "w+");
     if(!flock($fp,LOCK_EX | LOCK_NB)){
         echo "系统繁忙,请稍后再试";
         return;
     }
     //下单
     $sql="select number from ih_store where goods_id='$goods_id' and sku_id='$sku_id'";
     $rs =  mysqli_query($conn,$sql);
     $row = $rs->fetch_assoc();
     if($row['number']>0){//库存是否大于0
         //模拟下单操作
         $order_sn=build_order_no();
         $sql="insert into ih_order(order_sn,user_id,goods_id,sku_id,price)
         values('$order_sn','$user_id','$goods_id','$sku_id','$price')";
         $order_rs =  mysqli_query($conn,$sql);
         //库存减少
         $sql="update ih_store set number=number-{$number} where sku_id='$sku_id'";
         $store_rs =  mysqli_query($conn,$sql);
         if($store_rs){
           echo '库存减少成功';
             insertLog('库存减少成功');
             flock($fp,LOCK_UN);//释放锁
         }else{
           echo '库存减少失败';
             insertLog('库存减少失败');
         }
     }else{
       echo '库存不够';
         insertLog('库存不够');
     }
     fclose($fp);
      ?>
    
    1. 乐观锁思路

    这个时候,我们就可以讨论一下“乐观锁”的思路了。乐观锁,是相对于“悲观锁”采用更为宽松的加锁机制,大都是采用带版本号(Version)更新。实现就是,这个数据所有请求都有资格去修改,但会获得一个该数据的版本号,只有版本号符合的才能更新成功,其他的返回抢购失败。这样的话,我们就不需要考虑队列的问题,不过,它会增大CPU的计算开销。但是,综合来说,这是一个比较好的解决方案。

    在这里插入图片描述
    有很多软件和服务都“乐观锁”功能的支持,例如Redis中的watch就是其中之一。通过这个实现,我们保证了数据的安全。

    优化方案5:Redis中的watch

     <?php
     $redis = new redis();
      $result = $redis->connect('127.0.0.1', 6379);
      echo $mywatchkey = $redis->get("mywatchkey");
     /*
       //插入抢购数据
      if($mywatchkey>0)
      {
          $redis->watch("mywatchkey");
       //启动一个新的事务。
         $redis->multi();
        $redis->set("mywatchkey",$mywatchkey-1);
        $result = $redis->exec();
        if($result) {
           $redis->hSet("watchkeylist","user_".mt_rand(1,99999),time());
           $watchkeylist = $redis->hGetAll("watchkeylist");
             echo "抢购成功!<br/>";
             $re = $mywatchkey - 1;  
             echo "剩余数量:".$re."<br/>";
             echo "用户列表:<pre>";
             print_r($watchkeylist);
        }else{
           echo "手气不好,再抢购!";exit;
        } 
      }else{
          // $redis->hSet("watchkeylist","user_".mt_rand(1,99999),"12");
          //  $watchkeylist = $redis->hGetAll("watchkeylist");
             echo "fail!<br/>";   
             echo ".no result<br/>";
             echo "用户列表:<pre>";
           //  var_dump($watchkeylist); 
      }*/
     $rob_total = 100;   //抢购数量
     if($mywatchkey<=$rob_total){
         $redis->watch("mywatchkey");
         $redis->multi(); //在当前连接上启动一个新的事务。
         //插入抢购数据
         $redis->set("mywatchkey",$mywatchkey+1);
         $rob_result = $redis->exec();
         if($rob_result){
              $redis->hSet("watchkeylist","user_".mt_rand(1, 9999),$mywatchkey);
             $mywatchlist = $redis->hGetAll("watchkeylist");
             echo "抢购成功!<br/>";
           
             echo "剩余数量:".($rob_total-$mywatchkey-1)."<br/>";
             echo "用户列表:<pre>";
             var_dump($mywatchlist);
         }else{
               $redis->hSet("watchkeylist","user_".mt_rand(1, 9999),'meiqiangdao');
             echo "手气不好,再抢购!";exit;
         }
     }
     ?>
    
    展开全文
  • Java高并发系列第二讲 并发级别

    万次阅读 2020-09-01 20:02:17
    由于临界区的存在,多线程之间的并发必须受到控制。根据控制并发的策略,我们可以把并发的级别分为阻塞、无饥饿、无障碍、无锁、无等待几种。 阻塞 一个线程是阻塞的,那么在其他线程释放资源之前,当前线程无法继续...

    由于临界区的存在,多线程之间的并发必须受到控制。根据控制并发的策略,我们可以把并发的级别分为阻塞、无饥饿、无障碍、无锁、无等待几种。

    阻塞

    一个线程是阻塞的,那么在其他线程释放资源之前,当前线程无法继续执行。当我们使用synchronized关键字或者重入锁时,我们得到的就是阻塞的线程。

    synchronize关键字和重入锁都试图在执行后续代码前,得到临界区的锁,如果得不到,线程就会被挂起等待,直到占有了所需资源为止。

    无饥饿(Starvation-Free)

    如果线程之间是有优先级的,那么线程调度的时候总是会倾向于先满足高优先级的线程。也就是说,对于同一个资源的分配,是不公平的!图1.7中显示了非公平锁与公平锁两种情况(五角星表示高优先级线程)。对于非公平锁来说,系统允许高优先级的线程插队。这样有可能导致低优先级线程产生饥饿。但如果锁是公平的,按照先来后到的规则,那么饥饿就不会产生,不管新来的线程优先级多高,要想获得资源,就必须乖乖排队,这样所有的线程都有机会执行。
    在这里插入图片描述

    无障碍(Obstruction-Free)

    无障碍是一种最弱的非阻塞调度。两个线程如果无障碍地执行,那么不会因为临界区的问题导致一方被挂起。换言之,大家都可以大摇大摆地进入临界区了。那么大家一起修改共享数据,把数据改坏了怎么办呢?对于无障碍的线程来说,一旦检测到这种情况,它就会立即对自己所做的修改进行回滚,确保数据安全。但如果没有数据竞争发生,那么线程就可以顺利完成自己的工作,走出临界区。

    如果说阻塞的控制方式是悲观策略,也就是说,系统认为两个线程之间很有可能发生不幸的冲突,因此以保护共享数据为第一优先级,相对来说,非阻塞的调度就是一种乐观的策略。它认为多个线程之间很有可能不会发生冲突,或者说这种概率不大。因此大家都应该无障碍地执行,但是一旦检测到冲突,就应该进行回滚。

    从这个策略中也可以看到,无障碍的多线程程序并不一定能顺畅运行。因为当临界区中存在严重的冲突时,所有的线程可能都会不断地回滚自己的操作,而没有一个线程可以走出临界区。这种情况会影响系统的正常执行。所以,我们可能会非常希望在这一堆线程中,至少可以有一个线程能够在有限的时间内完成自己的操作,而退出临界区。至少这样可以保证系统不会在临界区中进行无限的等待。

    一种可行的无障碍实现可以依赖一个"一致性标记"来实现。线程在操作之前,先读取并保存这个标记,在操作完成后,再次读取,检查这个标记是否被更改过,如果两者是一致的,则说明资源访问没有冲突。如果不一致,则说明资源可能在操作过程中与其他线程冲突,需要重试操作。而任何对资源有修改操作的线程,在修改数据前,都需要更新这个一致性标记,表示数据不再安全。

    数据库中乐观锁,应该比较熟悉,表中需要一个字段version(版本号),每次更新数据version+1,更新的时候将版本号作为条件进行更新,根据更新影响的行数判断更新是否成功,伪代码如下:

    1.查询数据,此时版本号为w_v
    2.打开事务
    3.做一些业务操作
    4.update t set version = version + 1 where id = 记录id and version = w_v;
    //此行会返回影响的行数c  
    5.if(c>0){
    //提交事务
    }
    else{
    //回滚事务
    }
    

    多个线程更新同一条数据的时候,数据库会对当前数据加锁,同一时刻只有一个线程可以执行更新语句。

    无锁(Lock-Free)

    无锁的并行都是无障碍的。在无锁的情况下,所有的线程都能尝试对临界区进行访问,但不同的是,无锁的并发保证必然有一个线程能够在有限步内完成操作离开临界区。

    在无锁的调用中,一个典型的特点是可能会包含一个无穷循环。在这个循环中,线程会不断尝试修改共享变量。如果没有冲突,修改成功,那么程序退出,否则继续尝试修改。但无论如何,无锁的并行总能保证有一个线程是可以胜出的,不至于全军覆没。至于临界区中竞争失败的线程,他们必须不断重试,直到自己获胜。如果运气很不好,总是尝试不成功,则会出现类似饥饿的先写,线程会停止。

    下面就是一段无锁的示意代码,如果修改不成功,那么循环永远不会停止。

    while(!atomicVar.compareAndSet(localVar,localVar+1)){
    	localVal = atomicVar.get();
    }
    

    无等待

    无锁只要求有一个线程可以在有限步内完成操作,而无等待则在无锁的基础上更进一步扩展。它要求所有线程都必须在有限步内完成,这样不会引起饥饿问题。如果限制这个步骤的上限,还可以进一步分解为有界无等待和线程数无关的无等待等几种,他们之间的区别只是对循环次数的限制不同。

    一种典型的无等待结果就是RCU(Read Copy Update)。它的基本思想是,对数据的读可以不加控制。因此,所有的读线程都是无等待的,它们既不会被锁定等待也不会引起任何冲突。但在写数据的时候,先获取原始数据的副本,接着只修改副本数据(这就是为什么读可以不加控制),修改完成后,在合适的时机回写数据。

    展开全文
  • 解决:读写分离,对缓存,一般都是用来支撑读高并发的,写的请求是比较少的,大量的请求都是在读 一般有多个redis,其中有一个master-redis,专门接受写请求,然后把数据复制到其他的多个slave-redis中,然后所有的读请求都从...

    1. 如果redis要支撑超过10万+的并发,应该怎么做?

    a) 单机的redis几户不可能QPS超过10万,一般都是单机几万
    解决:读写分离,对缓存,一般都是用来支撑读高并发的,写的请求是比较少的,大量的请求都是在读
    一般有多个redis,其中有一个master-redis,专门接受写请求,然后把数据复制到其他的多个slave-redis中,然后所有的读请求都从多个slave-redis中去读
    这种架构就是主从架构,一主多从,主负责写,并且将数据同步复制到其他的slave节点,从节点负责读,所有的读请求全部走从节点
    好处:可以水平扩容,继续增加slave节点就行

    2. redis replication基本原理核心机制

    a) 主节点异步向从节点复制,从redis2.8开始,slave-node会周期性的确认自己每次复制的数据量
    b) 一个master-node可以配置多个slave-node
    c) slave-node也可以连接其他slave-node
    d) slave-node在复制的时候,master-node是正常工作的,也不影响slave-node的读操作,因为复制时候,slave-node用的是旧的数据集来提供服务,但是复制完成的时候,需要删除旧的数据集,加载新数据集,这个时候就会暂停对外服务了
    e) slave-node主要是用来做横向扩容的,做读写分离,扩容的slave-node可以提高读的吞吐量

    3. master持久化对于主从节点的安全保障的意义

    a) 如果开启了主从架构,必须开启master-node的持久化;万一master-node宕机了,还可以从持久化的备份文件中去恢复数据,master-node启动的时候才有数据,slave-node复制的时候才有数据
    b) 不建议用slave-node作为master-node的数据热备,因为那样的话,如果你关掉master-node的持久化,可能master-node节点在宕机重启的时候数据是空的,然后slave-node一经过复制,slave-node数据也丢了

    4. redis主从复制原理,断点续传,无磁盘化复制,过期key处理

    a) 主从复制的原理
    当启动一个slave-node的时候,它会发送一个PSYNC命令给master-node,如果这是slave-node第一次连接master-node,那么会触发一次full resynchronization ,开始full resynchronization的时候,master-node会启动一个后台线程,开始生成一份RDB快照文件,同时还会将客户端收到的所有写命令缓存到内存中,RDB文件生成完毕之后,master-node会将这个RDB发送给slave-node,slave-node会先写入本地磁盘,然后再从本地磁盘加载到内存中,然后master-node会将内存中缓存的写命令发送给slave-node,slave-node也会同步这些数据

    Slave-node如果跟master-node有网络故障,断开了连接,会自动重连,master-node如果发现有多个slave-node都来重新连接,仅仅会启动一个rdb save操作,用一份数据服务所有的slave-node
    b) 断点续传
    从redis2.8开始,就支持主从复制的断电续传,如果主从复制过程中,网络连接断掉了,那么可以接着上次复制的地方,继续复制下去,而不是从头开始复制一份

    Mater-node会在内存中创建一个backlog,mster和slave都会保存一个replica offset还有一个master id,offset就是保存在backlog中的,如果master和slave网络连接断掉了,slave会让master从上次的replice offset开始继续复制

    但是如果没有找到对应的offset,那么就会执行一次resynchronization
    c) 无磁盘化复制
    Master-node在内存中直接创建rdb,然后发送给slave-node,不会在自己本地落地磁盘了,其中有两个参数设置
    repl-diskless-sync
    repl-diskless-sync-delay ,等待一定时长再开始复制,因为要等更多的slave-node重新链接过来,因为一旦复制开始,就不能再去连接其他的slave-node了
    d) 过期key处理
    slave-node不会过期key,只会等待master过期key,如果master-node过期了一个key,或者通过LRU淘汰了一个key,那么会模拟一条del命令发送给slave

    展开全文
  • 面试题:高并发场景下,如何保证缓存与数据库一致性? 问题分析 我们日常开发中,对于缓存用的最多的场景就像下图一样,可能仅仅是对数据进行缓存,减轻数据库压力,缩短接口响应时间。 这种方案在不需要考虑...

    面试题:高并发场景下,如何保证缓存与数据库一致性?

    问题分析

    我们日常开发中,对于缓存用的最多的场景就像下图一样,可能仅仅是对数据进行缓存,减轻数据库压力,缩短接口响应时间。

    这种方案在不需要考虑高并发得去写缓存,高并发得读写缓存时,是不会有问题,但是如果是在高并发场景下,要保证缓存和数据库的一致性,至少需要解决以下问题:

    高并发写时的数据不一致问题

    高并发读写时,请求执行各步骤的顺序是不可控的。假设此时有一个请求A,B都在在执行写流程,请求A是需要将某个数据改成1,请求B是需要将某个数据改为2,执行操作如下时就会导致数据不一致的问题:

    1.请求A执行操作1.1删除缓存。

    2.请求A执行操作1.2更新数据库,将值改为1。

    3.请求B执行操作1.1删除缓存。

    4.请求B执行操作1.2更新数据库,将值改为2

    5.假设说请求B所在服务器网络延迟比较低,请求B先更新缓存,此时缓存中的key对应的value是2。

    6.请求A更新缓存,将缓存中B更新的数据进行覆盖,将key对应的值改为1。

    此时数据库中是B修改后的数据,值为2,而缓存中的数据是1,这样在缓存过期前,用户读到的都是脏数据,与数据库不一致。

    高并发读写时的数据不一致的问题

    高并发读写时,请求执行各步骤的顺序是不可控的。假设此时有一个请求A在执行写流程,将原值由1改成2,请求B执行读流程,执行操作如下时就会导致数据不一致的问题:

    1.写请求A执行1.1操作删除缓存key,value是原值1。

    2.读请求B执行2.1操作发现缓存中没有数据,就去执行2.2操作读数据库,读到旧数据,值为1。

    3.写请求A执行1.2操作更新数据库,将数据由1改为2。

    4.写请求A执行1.3操作更新缓存,此时缓存中的数据key对应的value是2。

    5.读请求B执行2.3操作更新缓存,将之前读到的旧数据1设置到缓存中,此时缓存中的数据key对应的value是1。

    所以如果说读请求B所在服务器网络延迟比较高,去执行2.3操作比写请求A晚,就会导致写请求A更新完缓存后,读请求B使用之前读到的旧数据去更新缓存,此时缓存中数据就与数据库中的不一致。

    解决方案

    保证数据一致性,网上有很多种方案,例如:

    1.先删除缓存,再更新数据库。

    2.先更新数据库,再删除缓存。

    3.先删除缓存,再更新数据库,然后异步延迟一段时间再去删一次缓存。

    但是这些方案都是存在各种各样的问题,这里篇幅有限,只给出目前相对正确的三套方案,目前的这些方案也有自己的局限性。

    方案1.写请求串行化

    写请求

    1.写请求更新之前先获取分布式锁,获得之后才能去数据库更新这个数据,获取不到就进行等待,超时后就返回更新失败。

    2.更新完之后去刷新缓存,如果刷新失败,放到内存队列中进行重试(重试时取数据库最新数据更新缓存)。

    读请求

    读请求发现缓存中没有数据时,直接去读取数据库,读完更新缓存。

    总结

    这种技术方案通过对写请求的实现串行化来保证数据一致性,但是会导致吞吐量变低。比较适合银行相关的业务,因为对于银行项目来说,保证数据一致性比可用性更加重要,就像是去存款机存钱,取钱时,为了保证账户安全,都是会让用户执行操作后,等待一段时间才能获得反馈,这段时间其实取款机是不可用的。

    方案2.先更新数据库,异步删除缓存,删除失败后重试

    1.先更新数据库

    2.异步删除缓存(如果数据库是读写分离的,那么删除缓存时需要延迟删除,否则可能会在删除缓存时,从库还没有收到更新后的数据,其他读请求就去从库读到旧数据然后设置到缓存中。)

    3.删除缓存失败时,将删除的key放到内存队列或者是消息队列中进行异步重试

    #### 发散思考

    在更新完数据库后,我们为什么不直接更新,而是采用删除缓存呢?

    这是因为直接更新缓存的话,在高并发场景下,有多个更新请求时,难以保证后更新数据库的请求会后更新缓存,也就是上面的高并发写问题。如果采用删除缓存,可以让下次读时读取数据库,更新缓存,保证一致性。

    方案3.业务项目更新数据库,其他项目订阅binlog更新

    1.业务项目直接更新数据库。

    2.cannal项目会读取数据库的binlog,然后解析后发消息到kafka。

    3.然后缓存更新项目订阅topic,从kafka接收到更新数据库操作的消息后,更新缓存,更新缓存失败时,新建异步线程去重试或者将操作发到消息队列,后续再进行处理。

    总结:

    但是这种方案在更新数据库后,缓存中还是旧值,必须等缓存更新项目消费消息后,更新缓存,缓存中才是最新值。所以更新操作完成与更新生效之间会有一定的延迟。

    最后

    大家有了解其他的技术方案,也欢迎一起讨论!

    参考链接:

    https://www.cnblogs.com/-wenli/p/11474164.html

    https://www.cnblogs.com/rjzheng/p/9041659.html

    展开全文

空空如也

空空如也

1 2 3 4 5 ... 20
收藏数 109,031
精华内容 43,612
关键字:

高并发