如何使用Redis实现悲观锁解决高并发情况下读写带来的脏读问题 / ThinkPHP5.1 / Redis Cache / File Cache

在用户量/客户端数量比较少的时候,只要系统的业务逻辑是正确的,一般都不会发现有什么问题。但随着用户量/客户端数量逐渐增多,高并发带来的问题就会逐渐出现,而脏读是众多问题的其中之一。

一、无并发控制,会带来什么问题?

本文以ThinkPHP5.1.39的代码作为案例,下面是一个File Cache读写操作:

public function fileCacheCase(){
    $keyName  = "test";
    $keyValue = 996;

    //写入缓存
    Cache::set($keyName, $keyValue, 3600);

    //从缓存中获取值
    $data = Cache::get($keyName);

    //删除缓存
    Cache::rm($keyName);

    echo "OK! $data";
}

访问这个function,会输出

OK! 996

无论你访问几次,结果都是如此,但这是单线程的情况(只有你自己一个人在访问这个function),如果是多个人同时不停的访问这个function,还会是这样吗?想一想 😛

 

使用jmeter测试一下,120线程测试了十几秒,发现了3种不同的返回结果。

1、返回了 OK! 996

与单线程时的结果一致,是正常处理逻辑。

 

2、只返回了OK!而不是OK! 996

说明缓存不存在,原因是:在A线程将996写入缓存后,B线程将缓存删除了。此时A线程从缓存中读出来的数据为null,所以A线程输出了OK! ,而不是OK! 996

 

3、返回了一个500错误

报错的内容是: file_get_contents(…)No such file or directory。

显然是cache文件夹下的某个缓存文件不存在,所以引起了这个错误。原因是:A线程在删除缓存后,B线程也在执行删除缓存的操作。当缓存文件已被删除时,再执行删除缓存文件的操作,自然就报了文件不存在的错误。(实测120个线程并发,总计500个请求,异常率0.20%

 

尽管我修改了File Cache的133行,在删除前判断文件是否存在,虽然异常率降低了,但依然存在。可以看到的是,在高并发场景下,问题已经显现出来了。

 

下面我们用redis缓存试试看:

public function fileCacheCase(){
    $keyName  = "test";
    $keyValue = 996;

    //写入缓存
    Cache::store('redis')->set($keyName, $keyValue, 3600);

    //从缓存中获取值
    $data = Cache::store('redis')->get($keyName);

    //删除缓存
    Cache::store('redis')->rm($keyName);

    echo "OK! $data";
}

经过测试,与上面的3种情况一致。(根据thinkphp5.1的官方文档,我使用的是store来切换到redis,但不知道为何,仍然会报File Cache驱动的No such file or directory/unlink错误,十分诡异)

 

如何解决高并发场景下带来的脏读问题?

答案是:使用锁机制。

 

二、锁机制

根据锁的控制范围,可分为单机锁/分布式锁2种。根据锁的实现思想,可分为悲观锁/乐观锁2种。

2.1、单机锁

即为单机环境的锁,无分布式设计。

常用的实现工具:

  • Redis
  • Memcached

 

2.2、分布式锁

为了防止分布式系统中的多个进程之间相互干扰,我们需要一种分布式协调技术来对这些进程进行调度。而这个分布式协调技术的核心就是来实现这个分布式锁

  • 在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行
  • 高可用的获取锁与释放锁
  • 高性能的获取锁与释放锁
  • 具备锁失效机制,防止死锁
  • 具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败

常用的实现工具:

  • Zookeeper
  • Redis
  • Memcached
  • Chubby

 

2.3、悲观锁

总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。

2.4、乐观锁

总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。

 

2.5、如何选择悲观/乐观锁?

从上面对两种锁的介绍,我们知道两种锁各有优缺点,不可认为一种好于另一种,像乐观锁适用于写比较少的情况下(多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果是多写的情况,一般会经常产生冲突,这就会导致上层应用会不断的进行retry,这样反倒是降低了性能,所以一般多写的场景下用悲观锁就比较合适。

 

三、Redis实现悲观锁

在商品秒杀活动活动中,流量峰值相对平常时的流量是高出非常多的。使用Redis实现悲观锁机制,可以解决商品库存脏读的问题。

初始化库存:

public function stockInit()
{
    $key       = "stock";
    $stockInit = 699;

    //清空所有缓存
    Cache::clear();
    Cache::store('redis')->clear();

    //写入库存初始值
    Cache::store('redis')->set($key, $stockInit);

    echo 'stock Init';
}

 

3.1、悲观锁实现(一)

看似符合逻辑的商品秒杀:

public function flashSale()
{
    $key        = "stock";
    $lockSuffix = "_lock";

    //判断库存锁是否存在
    while (Cache::get($key . $lockSuffix) == true) {
        // 存在锁定则等待
        usleep(200000);
    }

    //库存上锁
    Cache::store('redis')->set($key . $lockSuffix, 1, 30);

    //获取库存值
    $stock = Cache::store('redis')->get($key);

    //减库存
    if ($stock > 0) {
        $temp  = $stock;
        $stock -= 1;
    } else {
        //打开库存锁
        Cache::store('redis')->set($key . $lockSuffix, false);
        return "已售罄";
    }
    Cache::store('redis')->set($key, $stock);

    //打开库存锁
    Cache::store('redis')->set($key . $lockSuffix, false);

    return "恭喜,您抢到了第 {$temp}个库存!";
}

 

实测150线程并发,异常率0%,虽然引用了锁机制,看似符合逻辑的锁机制,但仍会有极低的概率脏读,原因无他,有N个线程同时抢到了锁。虽然概率低,但线程一多仍然会脏读。所以需要改用redis原生支持的setnx来保证只有一个线程抢到了锁。

 

如下,两个线程同时抢到了第80个库存:

 

3.2、悲观锁实现(二)

setnx 是set if not exists的简写,在key不存在时等价于set,如果key存在,则不更新缓存内容,且返回false。使用这个特性,可以保证锁只有一个线程抢到了。

使用redis setnx实现悲观锁的商品秒杀:

public function flashSale()
{
    $redisConifg = config('cache.redis');                  //获取当前模块下的config文件夹中的cache文件的redis配置数组
    $redis       = Cache::connect($redisConifg);           //获取thinkPHP官方封装的Redis Cache对象
    $handler     = Cache::connect($redisConifg)->handler();//获取php redis扩展原生redis对象 https://github.com/phpredis/phpredis

    $key        = "stock";//商品库存缓存名
    $lockSuffix = "_lock";//商品库存锁后缀名
    $timeOut    = 10;     //库存锁过期时间

    //抢库存锁
    while ($handler->set($key . $lockSuffix, 1, ['nx', 'ex' => $timeOut]) == false) {
        // 没有抢到则等待
        usleep(20000);
    }

    //当前线程抢到库存锁了

    //获取库存值
    $stock = $redis->get($key);

    //减库存
    if ($stock > 0) {
        $temp  = $stock;
        $stock -= 1;
    } else {
        //删除库存锁
        $redis->rm($key . $lockSuffix);
        return "已售罄";
    }

    //更新库存值
    $redis->set($key, $stock);

    //删除库存锁
    $redis->rm($key . $lockSuffix);

    return "恭喜,您抢到了第 {$temp}个库存!";
}

150线程并发测试后,并没有发现有异常情况了。根据实际业务需求,可以增加等待超时机制。

 

参考资料

https://redis.io/commands/set

https://github.com/phpredis/phpredis#set

https://www.jianshu.com/p/a1ebab8ce78a

https://blog.csdn.net/qq_34337272/article/details/81072874

 

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇