打开自己的博客,发现已经1个多月没有更新博客了,不忍直视!-_-|||

决定下班后来一发!

通常们会用mongo的findAndModify来生成类似mysql的自增ID,例如

/**
 * Generate a increase id.
 * @author Hisune <hi@hisune.com>
 * @param string $name Key name.
 * @return int Increase id.
 */
public static function generateId($name)
{
    /* @var $variable \MongoCollection */
    // !!! 以下所有Collection::get君代表返回对应mongo的collection对象
    $variable = Collection::get('variable');
    $id = $variable->findAndModify(
            [
                'key' => $name
            ],
            [
                '$inc' => [
                    'value' => 1
                ]
            ]
        );

    return $id['value'];
}

之所以可以用findAndModify来生成mysql的自增ID,是因为findAndModify操作具有原子性。基于这个特性,其实findAndModify还有其他的玩法,今天要介绍的是如何使用findAndModify来实现原子锁。

为什么要实现锁?从最简单的角度来说,就是为了防止用户并发请求时,导致数据被多次重复处理,例如利用并发操作刷奖励BUG等。

问题来了

一个很常见的应用场景

$collection = Collection::get('test');
$one = $collection->findOne(['test' => 1]);
if(!$one){
    // 模拟10毫秒的业务处理时间
    usleep(10000);
    echo 'none';
    $collection->save(['test' => 1]);
}else{
    echo 'have';
}

先去数据库中查询用户是否有过这个请求,如果有则不处理,没有则处理用户逻辑,然后插入一条处理完成的数据。

这种请求在用户访问量低的情况下是完全没问题的,但是,如果用户并发的发起这个请求呢?

模拟客户端(nodejs):

'use strict';
let http = require('http');
http.get('http://www.hisune.com/xxx', (res) => {
    res.setEncoding('utf8');
    res.on('data', (chunk) => console.log(chunk));
});
http.get('http://www.hisune.com/xxx', (res) => {
    res.setEncoding('utf8');
    res.on('data', (chunk) => console.log(chunk));
});
http.get('http://www.hisune.com/xxx', (res) => {
    res.setEncoding('utf8');
    res.on('data', (chunk) => console.log(chunk));
});

文件保存为testxx.js

测试输出:

$ node testxx
none
none
none

mongo数据:

{ 
    "_id" : ObjectId("5846a76abe818e5430000043"), 
    "test" : NumberInt(1)
}
{ 
    "_id" : ObjectId("5846a76abe818e483300004b"), 
    "test" : NumberInt(1)
}
{ 
    "_id" : ObjectId("5846a76abe818ec821000039"), 
    "test" : NumberInt(1)
}

不好意思,你的判断被绕过了。

多讲一句,关于文件锁

小明:报告,知道如何预防这种情况,使用文件锁!

小明贴上了他的代码:

$fp = fopen("lockfile", "a");
flock($fp, LOCK_EX) or die("blocked");
$collection = Collection::get('test');
$one = $collection->findOne(['test' => 1]);
if(!$one){
    // 模拟10毫秒的业务处理时间
    usleep(10000);
    echo 'none';
    $collection->save(['test' => 1]);
}else{
    echo 'have';
}

测试输出:

$ node testxx
none
have
have
```bash
mongo数据:
```json
{ 
    "_id" : ObjectId("5846a94cbe818e5430000044"), 
    "test" : NumberInt(1)
}

It worked!

这是一种实现方式,但是,将usleep(10000);这一行,也就是模拟10毫秒的业务处理时间的代码改为sleep(2);也就是2秒试一下(如果某个时间某个原因的某个同学的业务处理响应慢)。

执行node testxx,你会发现2秒后才同时输出响应结果:

$ node testxx
none
have
have

也就是说,第一个请求阻塞了后面的请求,试想一下,如果某个熟睡的夜晚你的业务正跑在你的线上,这个时候某个人的请求处理时间过长导致了其他用户的请求一直没有响应,这个时候你该怎么办?只有一个选择,爬起来,改代码!

显然文件锁是一种不那么友好的解决方案。

findAndModify原子锁的实现

/**
 * mongo实现的并发锁
 * @author Hisune <hi@hisune.com>
 * @param string $key 锁的key,此key不允许重复
 * @return boolean 是否允许进行下一步操作
 */
public static function lock($key)
{
    $collection = Collection::get('lock');

    $modify = $collection->findAndModify(
        [
            'key' => $key
        ],
        [
            '$set' => [
                'locked_at' => new \MongoDate(),
            ],
            '$inc' => [
                'lock' => 1,
            ]
        ],
        null,
        [
            'upsert' => true
        ]
    );

    return !$modify || $modify['lock'] < 1;
}

/**
 * 移除一个mongo并发锁
 * 如果移除失败,会根据mongo过期索引自动使锁失效,默认时间为5秒
 * @author Hisune <hi@hisune.com>
 * @param string $key 锁的key
 * @return boolean 是否移除成功
 */
public static function unlock($key)
{
    $collection = Collection::get('lock');
    $collection->findAndModify(
        [
            'key' => $key
        ],
        [
            '$set' => [
                'lock' => 0,
            ],
        ]
    );
    // 有可能记录已过期,执行的结果会是null,所以无论如何都返回true
    return true;
}

为了防止lock后业务处理异常,导致这个锁永远锁上了,需要设置一个锁的过期时间,具体时间可以根据你的实际业务场景来定。

们可以利用mongo的过期索引来自动实现:

$collection = \Helper\Collection::get('lock');
$collection->createIndex(['locked_at' => -1], ['expireAfterSeconds' => 5]);

你还可以动态设置你每一个锁的失效时间,直接给lock函数加一个时间参数,然后在写锁的时候将locked_at进行动态设置即可。

需要注意的是,mong的过期索引并不是按你设置的过期时间严格过期的,他是内部有一个定时器,定期扫描哪些记录已经过期,所以在你设置5秒过期,可能几十秒后才会过期!

应用findAndModify原子锁

if(Collection::lock('test')){
    $collection = Collection::get('test');
    $one = $collection->findOne(['test' => 1]);
    if(!$one){
        // 模拟10毫秒的业务处理时间
        usleep(10000);
        echo 'none';
        $collection->save(['test' => 1]);
    }else{
        echo 'have';
    }
    Collection::unlock('test');
}else{
    echo 'blocked';
}

结果:

$ node testxx
blocked
blocked
none

mongo数据:

{ 
    "_id" : ObjectId("58469841be818e4833000049"), 
    "test" : NumberInt(1)
}

3个请求,有2个请求被blocked了,It worked!

这种方式的锁仅会对单个请求阻塞,不会影响到其他用户的请求。所以就算将模拟的业务处理时间调整为2秒,也仅会阻塞当前处理的请求,而不会影响到其他用户请求。

再回过头来看的标题,没错,还有下一篇,下一篇保证肯定不会是1个月后!

如果您觉得您在我这里学到了新姿势,博主支持转载,姿势本身就是用来相互学习的。同时,本站文章如未注明均为 hisune 原创 请尊重劳动成果 转载请注明 转自: mongo的findAndModify应用之实现原子锁 - hisune.com