redis设置锁,(20)秒还未设置成功,请检查redis是否正常

问题

昨晚上线之后,出现了不少次邮件告警:

您好  
#redis设置锁,(20)秒还未设置成功,请检查redis是否正常  
key: per:sorce-do-task:task_id:task_scanuser_id:8268817, value: per:sorce-do-task:task_id:task_scanuser_id:8268817

分析

看告警内容,是 redis 设置锁没有成功,一直等待这个锁。看了一下告警处的代码:


/**
 * 设置锁,一定要返回成功,否则一直等待
 * @param $key
 * @param $value
 * @param int $ttl 过期时间
 * @param int $maxsleeptime 最长等待时间,超过该时间发邮件提醒
 * @return mixed
 */
function set_synchronized_lock($key, $value, $ttl = 0, $maxsleeptime = 20)
{
    $CI = &get_instance();
    $CI->load->driver('cache');
    $sleep = 0;
    while (!$CI->cache->redis->savenx($key, $value, $ttl)) {
        sleep(1);
        $sleep++;
        if ($sleep > $maxsleeptime) {
            $content = "#redis设置锁,($maxsleeptime)秒还未设置成功,请检查redis是否正常<br>key: $key, value: $value";
            send_email('REDIS_LOCK_NOTIFY', [], [$content]);
            break;
        }
    }
    return true;
}

这里循环间隔1s不断用 savenx 方法来设置锁(获取锁),然后它失败了,每次失败sleep+1,等到sleep超过最大$maxsleeptime时间(预设为20s)时就发邮件告警。

是哪些原因会导致它失败呢?

进入 savenx 源码看,实际上调用的是 redis 的 setnx 命令,这个命令的官方说明是:

Return Integer reply, specifically:

1 if the key was set
0 if the key was not set

也就是说在不断循环的尝试过程中,这个 key 表示的值已经存在,也就是说锁一直在被别人占用。

这个时候,我们需要去看是谁占用了这个 key,我们通过告警邮件中的 key-value 信息找到报错业务处的代码:

public function do_task_post()
{
    $lock_key = 'per:sorce-do-task:task_id:' . $task_ids . 'user_id:' . $this->userData['user_id'];  
    $this->set_lock($lock_key, '做积分任务加锁');
    try {
        ...    
        return $this->sendFail(null, '没有可完成的任务');
    } catch(Exception $e) {
        ...
    } finally() {
        $this->release_lock($lock_key, '做积分任务解锁');
    }
}

这个业务加解锁相关的业务抽象如上,第4行是加锁,第12行是解锁。

这个业务之前一直正常,理论上锁不应该一直占用。唯一释放锁的地方就是 finally 语句块中的 release_lock(),这个方法本身没有改动过,应该不会突然就一直释放失败了。还有一种可能就是它没有执行。

是不是因为有慢查询导致它没有执行?这次没有修改到这个业务,看了业务代码,没有慢查询。这个原因PASS。

我们看到 try 中有 return 语句,是不是这个 return 语句提前返回了?

本地调试了几次确实提前返回了,切换到上线前的分支版本又调试了几次,发现是先释放锁再返回的。仔细对比代码,发现这次修改了 $this->sendFail() 方法,把里面的 return 改为了 exit 了。原来是这个原因?是它影响了 finally 语句块的执行。

public function sendFail($data , $msg){  
    $response = [  
        'code' => -1,  
        'data' => $data,  
        'msg'  => $msg  
    ];  
    return; // 昨晚上线后是 exit;
}

那么 finally 语句跟 returnexit 有什么关系呢?我找了下官方文档看,果然有解释:官方文档关于 finally 解释如下:

finally 代码块可以放在 catch 之后,或者直接代替它。 无论是否抛出了异常,在 try 和 catch 之后、在执行后续代码之前, 放在 finally 里的代码总是会执行。

值得注意的是 finally 和 return 语句之间存在相互影响。 如果在 try 或 catch 里遇到 return,仍然会执行 finally 里的代码。 而且,遇到 return 语句时,会先执行 finally 再返回结果。 此外,如果 finally 里也包含了 return 语句,将返回 finally 里的值。

总结

  • try-catch-finally 的执行顺序是:try 语句 - (exit) - finally语句 -(finally return) - (try return)
  • 改基类代码时要慎重,测试覆盖全。
  • 禁止使用 exit,因为有的框架可能还会在控制器返回之后,会有后置默认处理。