浅谈php文件锁机制

以前的自己没有弄懂文件锁的概念,导致自己犯过一个相关的错误。有一个微信公众号的项目,做过微信公众号开发的应该都知道,调用微信公众号的接口基本都需要access_token。微信有专门的接口来获取access_token,这个access_token每条调用次数是有限制的,另外它的有效时间为2个小时。所以,我们获取access_token后需要自行保存。

我当时的做法是,将它保存在文件里,使用json格式保存。类似这样{"access_token":"a2b2af43b2s4ib", "expire":1587114849},实现的伪代码如下:

/** 
* 获取access_token
* $file 存放access_token的文件
*/
function getAccessToken ($file)
{
   $jsonData = file_get_contents($file);
   
   if (!$jsonData) {
       $token = setToken($file);
  } else if (json_decode($jsonData, true)['expire'] <= time()){
       $token = setToken($file);
  } else {
       $token = json_decode($jsonData, true)['access_token'];
  }
   
   return $token;
}

/**
* 获取access_token,并将其保存在文件中
*/
function setToken ($file)
{
   $fp = fopen($file, 'r+');
   
   $tokenJson = ...; // 调用微信接口获取到token
   fwrite($fp, json_encode($tokenJson));
   
   return $tokenJson['access_token'];
}

然后,项目运行了一段时间后,出现了问题,但是过了1到2秒再刷新就又正常了。第一次没在意,以为是网络或其他什么问题。但之后又再现时,我就知道肯定是哪里出了问题。然后自己一步步去排查,终于发现的问题所在。

问题原因:当access_token到期了,这个时候,如果有多个请求差不多时间来了,就可能出现两个现象:

  • 请求A发现access_token过期了,那么它就需要去获取数据然后保存到文件中,在他将要保存前,B来了,这个时候,他看access_token过期了,他也会去获取数据然后保存。他们两同时向文件写入数据,就很可能导致文件内容被破坏。
  • 请求A发现access_token过期了,那么它就需要去获取数据然后保存到文件中,在它保存的过程里,还没保存成功时,这个时候B来了,可能就会读取到不完整的内容。

Table of Contents

php文件锁

在PHP中提供了 flock()函数,可以对文件使用锁定机制(锁定或释放文件)。当一个进程在访问文件时加上锁,其他进程要想对该文件进行访问,则必须等到锁定被释放以后。这样就可以避免在并发访问同一个文件时破坏数据。

函数原型如下:

flock ( resource $handle , int $operation [, int &$wouldblock ] ) : bool
  • handle:文件系统指针,是典型地由 fopen() 创建的 resource(资源)。
  • operation

operation 可以是以下值之一:

LOCK_SH取得共享锁定(读取的程序)。

LOCK_EX 取得独占锁定(写入的程序)。

LOCK_UN 释放锁定(无论共享或独占)。

LOCK_NB附加锁定(Windows 上还不支持)。

  • wouldblock

如果锁定会堵塞的话(EWOULDBLOCK 错误码情况下),可选的第三个参数会被设置为 TRUE。(Windows 上不支持)

demo

demo1.php

<?php

$file = 'data.txt';
$handler = fopen($file, 'a+') or die('文件资源打开失败');
// 取得独占锁
if (flock($handler, LOCK_EX)) {
   sleep(5);
   flock($handler, LOCK_UN);
} else {
   echo '锁定失败';
}

fclose($handler);

demo2.php

<?php
$file = 'data.txt';
$handler = fopen($file, 'a+') or die('文件资源打开失败');

// 取得独占锁
if (flock($handler, LOCK_EX)) {
   fwrite($handler, 'sometest string');
   flock($handler, LOCK_UN);
} else {
   echo '锁定失败';
}

fclose($handler);

先运行demo1.php然后立即运行demo2.php,会发现,因为被demo1.php锁定了文件,demo2.php写入不了新内容,只有等demo1.php释放了锁定,demo2.php才能拿到独占锁,然后才能写入文件。

问题解决

学完这些知识后,就能解决我之前的问题了。改进的伪代码如下:

function getToken ($file)
{
   $tokenJson = file_get_contents($file);
   
   if (!$tokenJson) {
       $token = loadToken($file);
  } else if (json_decode($tokenJson, true)['expire'] <= time()){
       $token = loadToken($file);
  } else {
       $token = json_decode($tokenJson, true)['access_token'];
  }
   
   return $token;
}

function loadToken ($file)
{
   $fp = fopen($file, 'w');
   // 取得独占锁
   if (flock($fp, LOCK_EX)) {
       $tokenJson = ...; // 调用微信接口获取到token
  fwrite($fp, json_encode($tokenJson));
       flock($fp, LOCK_UN);
  } else {
       return false;
  }
 
   return $tokenJson['access_token'];
}