基于Redis SETNX实现互斥锁

上周六遇到一个重复订单的问题: 用户支付成功后,订单列表里产生了两条订单号相同的记录,经过分析后确认是由于用户在等待支付结果页刷新页面,导致接口请求了两次(等待支付结果页面做了Loading效果,一般来说用户不会刷新,但是最近是销售旺季,支付网关的响应速度有所变慢,可能用户等待时间过长导致用户认为页面失去响应而刷新页面),系统在接到第一次请求后,开始执行提交订单流程(查询购物车数据 -> 提交用户支付token来获取支付结果 -> 获取“支付成功”结果后,创建订单记录,清空用户购物车 -> 给接口返回“支付成功”响应), 在提交订单流程还未结束时,用户刷新页面,系统收到第二次请求,第一次请求还未结束,此时用户购物车还未清空,如果不加以限制,第二次请求也会正常进行,最终额外创建一个订单记录

这就引出了一个问题: 对于不同请求(进程/线程),修改同一个资源时,如何让该资源只能被一个请求(进程/线程)持有,其他请求(进程/线程)只能等待直到该资源被释放? 这是一个典型的互斥问题,可以通过互斥锁来解决:在第一个请求中对该资源加锁,直到对该资源修改完成后,再对该资源进行解锁;其他请求访问该资源前,需要先获取锁,如果发现此时该资源已被加锁,需要等待直到锁被释放。

一种简单的互斥锁实现(PHP + Redis):

基于Redis的SETNX命令,以ResourceId为key, 以系统当前时间戳 + 锁过期时间为value, 尝试写入。如果SETNX成功,则表示加锁成功,如果SETNX失败,表示key已存在,说明此时ResourceId资源已经被锁,此时我们需要判断该锁是否已经过期:通过Redis GET命令获取以ResourceId为key的value,也就是锁的过期时间,对比当前系统时间,如果锁过期时间 < 当前系统时间,则说明锁已过期,此时可以重新为ResourceId设置新的过期时间: SET(ResourceId, 系统当前时间戳 + 锁过期时间);如果锁过期时间 >= 当前系统时间, 则需要等待锁释放。

SETNX 是SET IF NOT EXISTS的简写.日常命令格式是SETNX key value,如果 key不存在,则SETNX成功返回1,如果这个key已经存在了,则返回0。 上面的锁过期,重新加锁的机制,有一点不足:如果有多个请求(进程/线程)在同时等待锁,当锁过期后,可能会有多个请求同时重新为ResourceId设置新的过期时间,这样会导致多个请求加锁成功。为了解决这一问题,我们可以给Redis SET命令添加GET选项,让其在设置锁的过期时间时,返回旧的过期时间,如果旧的过期时间 > 当前系统时间,说明锁已被其他请求抢占从而加锁失败

1
SET(ResourceId, 系统当前时间戳 + 锁过期时间, "GET")

PHP伪代码实现:

 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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public function execute($resourceId)
{
    $this->getLock($resourceId);
    try {
        //do your CURD
    } finally {
        $this->unlock($resourceId);
    }
}

private function getLock($resourceId)
{
    while (!$this->_getLock($resourceId)) {
        usleep(200000); // 等待获得锁
    }
}

/**
 *
 * @param $resourceId
 * @return bool
 */
private function _getLock($resourceId)
{
    $key = 'your_lock_prefix_' . $resourceId;
    $now = time();
    $value = $now + 30; // 设置锁过期时间为当前系统时间 + 30秒
    $success = (int)$this->redis->setnx($key, $value);
    if ($success > 0) { // 无锁状态
        return true;
    }
    // 有锁状态
    $oldValue = (int)$this->redis->get($key);
    if ($oldValue > $now) { // 锁未过期
        return false;
    } else {    // 锁已过期
        $oldValue = (int)$this->redis->set($key, $value, 'GET');
        return $oldValue < $now;
    }
}

private function unlock($resourceId)
{
    $key = 'your_lock_prefix_' . $resourceId;
    $this->redis->del($key);
}
Built with Hugo
主题 StackJimmy 设计