上周六遇到一个重复订单的问题: 用户支付成功后,订单列表里产生了两条订单号相同的记录,经过分析后确认是由于用户在等待支付结果页刷新页面,导致接口请求了两次(等待支付结果页面做了Loading效果,一般来说用户不会刷新,但是最近是销售旺季,支付网关的响应速度有所变慢,可能用户等待时间过长导致用户认为页面失去响应而刷新页面),系统在接到第一次请求后,开始执行提交订单流程(查询购物车数据 -> 提交用户支付token来获取支付结果 -> 获取“支付成功”结果后,创建订单记录,清空用户购物车 -> 给接口返回“支付成功”响应), 在提交订单流程还未结束时,用户刷新页面,系统收到第二次请求,第一次请求还未结束,此时用户购物车还未清空,如果不加以限制,第二次请求也会正常进行,最终额外创建一个订单记录
这就引出了一个问题: 对于不同请求(进程/线程),修改同一个资源时,如何让该资源只能被一个请求(进程/线程)持有,其他请求(进程/线程)只能等待直到该资源被释放?
这是一个典型的互斥问题,可以通过互斥锁来解决:在第一个请求中对该资源加锁,直到对该资源修改完成后,再对该资源进行解锁;其他请求访问该资源前,需要先获取锁,如果发现此时该资源已被加锁,需要等待直到锁被释放。
一种简单的互斥锁实现(PHP + Redis):
基于Redis的SETNX命令,以ResourceId为key, 以系统当前时间戳 + 锁过期时间为value, 尝试写入。如果SETNX成功,则表示加锁成功,如果SETNX失败,表示key已存在,说明此时ResourceId资源已经被锁,此时我们需要判断该锁是否已经过期:通过Redis GET命令获取以ResourceId为key的value,也就是锁的过期时间,对比当前系统时间,如果锁过期时间 < 当前系统时间,则说明锁已过期,此时可以重新为ResourceId设置新的过期时间: SET(ResourceId, 系统当前时间戳 + 锁过期时间);如果锁过期时间 >= 当前系统时间, 则需要等待锁释放。
1 | 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); } |