我有一个php系统,允许客户使用电子钱包(商店信用)从我们的系统中购买商品(下订单)。
这是数据库示例
**sales_order**
+--------+-------+----------+--------+--------------+-----------+
|order_id| price |product_id| status |already_refund|customer_id|
+--------+-------+----------+--------+--------------+-----------+
| 1 | 1000 | 1 |canceled| 1 | 2 |
| 2 | 2000 | 2 |pending | 0 | 2 |
| 3 | 3000 | 3 |complete| 0 | 1 |
+--------+-------+----------+--------+--------------+-----------+
**ewallet**
+-----------+-------+
|customer_id|balance|
+-----------+-------+
| 1 | 43200 |
| 2 | 22500 |
| 3 | 78400 |
+-----------+-------+
表sales_order包含客户所下的订单,“ reales_refund”列用于标记已取消订单的退款。
我每5分钟运行一次cron,以检查是否可以取消状态为待处理的订单,然后可以将其退还给客户电子钱包
function checkPendingOrders(){
$orders = $this->orderCollection->filter(['status'=>'pending']);
foreach($orders as $order){
//check if order is ready to be canceled
$isCanceled = $this->isCanceled($order->getId());
if($isCanceled === false) continue;
if($order->getAlreadyRefund() == '0'){ // check if already refund
$order->setAlredyRefund('1')->save();
$this->refund($order->getId()); //refund the money to customer ewallet
}
$order->setStatus('canceled')->save();
}
}
使用此功能,两个不同的cron计划可以同时处理相同数据的问题,这将导致退款过程可以被调用两次,因此客户将获得双倍的退款金额。当两个相同的函数同时运行以处理相同的数据时,如何处理此类问题?我制定的if
子句无法处理此类问题
更新
我曾尝试在会话中使用microtime作为验证并锁定MySQL中的表行,所以与存储在order_id
生成的唯一会话中相比,我一开始将变量设置为包含microtime。 ,然后在锁定表行并更新我的电子钱包表之前,先添加一个条件,以将微时间值与会话进行匹配
function checkPendingOrders(){
$orders = $this->orderCollection->filter(['status'=>'pending']);
foreach($orders as $order){
//assign unique microtime to session
$mt = round(microtime(true) * 1000);
if(!isset($_SESSION['cancel'.$order->getId()])) $_SESSION['cancel'.$order->getId()] = $mt;
//check if order is ready to be canceled
$isCanceled = $this->isCanceled($order->getId());
if($isCanceled === false) continue;
if($order->getAlreadyRefund() == '0'){ // check if already refund
$order->setAlreadyRefund('1')->save();
//check if microtime is the same as the first one that running
if($_SESSION['cancel'.$order->getId()] == $mt){
//update using lock row
$this->_dbConnection->beginTransaction();
$sqlRaws[] = "SELECT * FROM ewallet WHERE customer_id = ".$order->getCustomerId()." FOR UPDATE;";
$sqlRaws[] = "UPDATE ewallet SET balance =(balance+".$order->getPrice().") WHERE customer_id = ".$order->getCustomerId().";";
foreach ($sqlRaws as $sqlRaw) {
$this->_dbConnection->query($sqlRaw);
}
$this->_dbConnection->commit();
}
}
unset($_SESSION['cancel'.$order->getId()]);
$order->setStatus('canceled')->save();
}
}
但是当我进行strees测试时,问题仍然存在,因为在某些情况下,相同的函数在相同的微时间处理相同的数据,并在相同的准确时间启动mysql事务
答案 0 :(得分:8)
@Rick James Answer像往常一样很棒,他只是没有告诉您您需要锁定哪些数据。
首先,请允许我对您所说的话发表评论
但是当我进行strees测试时,问题仍然存在,
并发压力感知应用程序不会仅通过压力测试进行测试,这是因为您无法控制将要发生的情况,并且可能不幸,并且测试结果良好,而您仍然可以您的应用程序-中的偷偷摸摸的错误,请相信我,并发错误是最糟糕的:(--
您需要手动打开2个客户端(数据库会话)并模拟竞争状况,在MySQL工作台中打开2个连接就足够了。
让我们这样做,在客户端(MySQL Workbench或phpMyAdmin)中打开2个连接,并按此顺序执行这些语句,将它们视为同时运行的PHP脚本。
**sales_order**
+--------+-------+----------+--------+--------------+-----------+
|order_id| price |product_id| status |already_refund|customer_id|
+--------+-------+----------+--------+--------------+-----------+
| 1 | 1000 | 1 |canceled| 1 | 2 |
| 2 | 2000 | 2 |pending | 0 | 2 |
| 3 | 3000 | 3 |complete| 0 | 1 |
+--------+-------+----------+--------+--------------+-----------+
(SESSION 1) > select * from sales_order where status = 'pending';
-- result 1 row (order_id 2)
(SESSION 2) > select * from sales_order where status = 'pending';
-- result 1 row (order_id 2)
/*
>> BUG: Both sessions are reading that order 2 is pending and already_refund is 0
your session 1 script is going to see that this guy needs to cancel
and his already_refund column is 0 so it will increase his wallet with 2000
*/
(SESSION 1) > update sales_order set status = 'canceled' , already_refund = 1
where order_id = 2
(SESSION 1) > update ewallet set balance = balance + 2000 where customer_id = 2
/*
same with your session 2 script : it is going to see that this guy needs
to cancel and his already_refund column is 0 so it will increase his
wallet with 2000
*/
(SESSION 2) > update sales_order set status = 'canceled' , already_refund = 1
where order_id = 2
(SESSION 2) > update ewallet set balance = balance + 2000 where customer_id = 2
现在,客户2会因此而感到高兴,而这种情况就是您问的问题
((假设如果5个会话可以在其中一个将already_refund
更新为1之前读取订单,那么客户2在得到5 * 2000
时会非常高兴) < / p>
我:现在,花点时间思考一下这种情况,您认为如何保护自己免受这种情况的侵害? ..?
您:按照@Rick的说法锁定
我:完全是
您:好吧,现在我要锁定ewallet
表
我:否,您需要锁定sales_order
,以便SESSION 2在SESSION1完成工作之前无法读取数据,现在让我们通过应用锁定来更改方案。>
(SESSION 1) > START TRANSACTION;
-- MySQL > OK;
(SESSION 2) > START TRANSACTION;
-- MySQL > OK;
(SESSION 1) > select * from sales_order where status = 'pending' FOR UPDATE;
-- MySQL > OK result 1 row (order_id 2)
(SESSION 2) > select * from sales_order where status = 'pending' FOR UPDATE;
-- MySQL > WAAAAAAAAAAAAAAAIT ...... THE DATA IS LOCKED
/*
now session 2 is waiting for the result of the select query .....
and session 1 is going to see that this guy needs to cancel and his
already_refund column is 0 so it will increase his wallet with 2000
*/
(SESSION 1) > update sales_order set status = 'canceled' , already_refund = 1
where order_id = 2
(SESSION 1) > update ewallet set balance = balance + 2000 where customer_id = 2;
(SESSION 2) > :/ I am still waiting for the result of the select .....
(SESSION 1) > COMMIT;
-- MySQL > OK , now I will release the lock so any other session can read the data
-- MySQL > I will now execute the select statement of session 2
-- MySQL > the result of the select statement of session 2 is 0 rows
(SESSION 2) > /* 0 rows ! no pending orders !
Ok just end the transaction, there is nothing to do*/
现在您很高兴不是客户2!
SELECT * from sales_order where status = 'pending' FOR UPDATE
可能不会仅锁定pending
个订单,因为它使用status
列上的搜索条件而不使用唯一索引
MySQL manual表示
用于锁定读取(使用FOR UPDATE进行选择或 FOR SHARE),UPDATE和DELETE语句,所采取的锁 取决于语句是否使用具有唯一性的唯一索引 搜索条件或范围类型的搜索条件。
.......对于其他搜索条件和非唯一索引,InnoDB锁定索引 扫描范围...
(这是我最不喜欢MySQL的事情之一。我希望只锁定select语句返回的行:()
我不了解您的应用程序,但是如果该cron任务只是取消待处理的订单,则将其删除,并在用户取消其订单时开始取消过程。
如果already_refund
列始终被更新为1,而状态列也被更新为canceled
,则“已取消的订单意味着他也将被退款“ ,并且摆脱了already_refund
列,多余的数据=额外的工作和额外的问题
MySQL documentation examples of locking reads 向下滚动至“锁定阅读示例”
答案 1 :(得分:7)
微时间思想将增加代码的复杂性。 $order->getAlreadyRefund()
可能正在从内存中获取值,因此它不是可靠的事实来源。
但是,您可以依赖单个更新,条件是仅在状态仍为“ pending”且has_refund仍为0时才更新。您将具有如下SQL语句:
UPDATE
sales_order
SET
status = 'canceled',
already_refund = %d
where
order_id = 1
and status = 'pending'
and already_refund = 0;
您只需要为模型编写一个方法即可执行上述称为setCancelRefund()
的SQL,您可能会遇到以下这样的简单情况:
<?php
function checkPendingOrders() {
$orders = $this->orderCollection->filter(['status'=>'pending']);
foreach($orders as $order) {
//check if order is ready to be canceled
$isCanceled = $this->isCanceled($order->getId());
if ($isCanceled === false) {
continue;
}
if ($order->getAlreadyRefund() == '0') { // check if already refund
// Your new method should do the following
// UPDATE sales_order SET status = 'canceled', already_refund = 1 where order_id = %d and status = 'pending' and already_refund = 0;
$affected_rows = $order->setCancelRefund();
if ($affected_rows == 0) {
continue;
}
$this->refund($order->getId()); //refund the money to customer ewallet
}
}
}
答案 2 :(得分:7)
如果表尚未ENGINE=InnoDB
,请将表切换到InnoDB。参见http://mysql.rjweb.org/doc.php/myisam2innodb
在“交易”中包装需要“原子化”的任何操作序列:
START TRANSACTION;
...
COMMIT;
如果您在交易中支持SELECTs
,请添加FOR UPDATE
:
SELECT ... FOR UPDATE;
这会阻止其他连接。
在每个SQL语句之后检查错误。如果遇到“等待超时”的“僵局”,请重新开始事务。
删除所有“微时间”,LOCK TABLES
等。
一个典型的死锁示例是,一个连接抓住两行,而另一个连接抓住相同的行,但顺序相反。一笔交易将被InnoDB中止,并且它所做的任何事情(在交易内部)都将被撤消。
可能发生的另一件事是,两个连接都以相同顺序抓住相同的行。一个继续运行直到完成,而另一个被阻止直到完成。在给出错误之前,默认的超时时间是50秒。通常情况下,两者都会完成(一个接一个),而您都不是一个明智的选择。
答案 3 :(得分:3)
有一个解决此问题的简单方法。使用UPDATE sales_order SET already_refund = 1 WHERE already_refund = 0 AND id = ?
格式的查询。更新的结果应包括受影响的行数,该数将为零或一。如果是ewallet,最好这样做,否则它将由另一个进程更新。
答案 4 :(得分:2)
您可能要使用Pidfile。 Pidfile包含给定程序的进程ID。将进行两项检查:首先,文件本身是否存在,其次,文件中的进程ID是否为正在运行的进程的ID。
<?php
class Mutex {
function lock() {
/**
* $_SERVER['PHP_SELF'] returns the current script being executed.
* Ff your php file is located at http://www.yourserver.com/script.php,
* PHP_SELF will contain script.php
*
* /!\ Do note that depending on the distribution, /tmp/ content might be cleared
* periodically!
*/
$pidfile = '/tmp/' . basename($_SERVER['PHP_SELF']) . '.pid';
if (file_exists($pidfile)) {
$pid = file_get_contents($pidfile);
/**
* Signal 0 is used to check whether a process exists or not
*/
$running = posix_kill($pid, 0);
if ($running) {
/**
* Process already running
*/
exit("process running"); // terminates script
} else {
/**
* Pidfile contains a pid of a process that isn't running, remove the file
*/
unlink($pidfile);
}
}
$handle = fopen($pidfile, 'x'); // stream
if (!$handle) {
exit("File already exists or was not able to create it");
}
$pid = getmypid();
fwrite($handle, $pid); // write process id of current process
register_shutdown_function(array($this, 'unlock')); // runs on exit or when the script terminates
return true;
}
function unlock() {
$pidfile = '/tmp/' . basename($_SERVER['PHP_SELF']) . '.pid';
if (file_exists($pidfile)) {
unlink($pidfile);
}
}
}
您可以通过以下方式使用它:
$mutex = new Mutex();
$mutex->lock();
// do something
$mutex->unlock();
因此,如果有两个并发的cron进程(必须是同一个文件!),则如果一个获得了锁,则另一个将终止。
答案 5 :(得分:2)
答案 6 :(得分:2)
为此,必须使用mysql TRANSACTION并使用SELECT FOR UPDATE。
https://dev.mysql.com/doc/refman/8.0/en/innodb-locking-reads.html
如果您使用的是PDO,则您的Function setAlredyRefund()可能看起来像这样:
function setAlredyRefund($orderID){
try{
$pdo->beginTransaction();
$sql = "SELECT * FROM sales_order WHERE order_id = :order_id AND already_refund = 0 FOR UPDATE";
$stmt = $pdo->prepare($sql);
$stmt->bindParam(":orderID", $orderID, PDO::PARAM_INT);
$stmt->execute();
$sql = "UPDATE sales_order SET already_refund = 1";
$stmt = $pdo->prepare($sql);
$stmt->execute();
$pdo->commit();
}
catch(Exception $e){
echo $e->getMessage();
$pdo->rollBack();
}
}
答案 7 :(得分:2)
如果我是您,我将使其分为两个步骤:而不是使用“ already_refund”列,而使用“ refund_status”列,cron作业将首先将该列更改为“ to_refund”,然后,在下一个相同类型或不同cron作业中,当实际退款发生时,将其再次更改为“ refunded”。
我知道也许您可以同时完成此操作,但是很多时候最好具有更易理解的代码/过程,尽管这可能需要更多时间。尤其是在处理金钱时...
答案 8 :(得分:2)
这是带有一个锁定文件的简单解决方案:
<?php
// semaphore read lock status
$file_sem = fopen( "sem.txt", "r" );
$str = fgets( $file_sem );
fclose( $file_sem );
$secs_last_mod_file = time() - filemtime( "sem.txt" );
// if ( in file lock value ) and ( difference in time between current time and time of file modifcation less than 600 seconds ),
// then it means the same process running in another thread
if( ( $str == "2" ) && ( $secs_last_mod_file < 600 ) )
{
die( "\n" . "----die can't put lock in file" . "\n" );
}
// semaphore open lock
$file_sem = fopen( "sem.txt", "w" );
fputs( $file_sem, "2" );
fflush( $file_sem );
fclose( $file_sem );
// Put your code here
// semaphore close lock
$file_sem = fopen( "sem.txt", "w" );
fputs( $file_sem, "1" );
fclose( $file_sem );
?>
我在自己的网站上使用此解决方案。
答案 9 :(得分:-1)
如果我理解,当您说“ 2个不同的cron时间表可以同时处理相同的数据”时,您的说法是:如果第一个实例花费5分钟以上的时间,则脚本的2个实例可以同时运行完成任务?
我不知道您代码的哪一部分花费最多的时间,但是我想这本身就是退款过程。在这种情况下,我要做的是:
status = 'pending'
选择数量有限的订单status='refunding'
status='cancelled'
。这样,如果启动另一个cron作业,它将选择一组完全不同的待处理订单进行处理。
答案 10 :(得分:-1)
这是OS中的常见现象,为此Mutex引入了。通过使用互斥锁,您可以同时停止写操作。将Mutex与if条件一起使用,以避免重复退款。
要了解详细信息,请点击以下两个链接:
https://www.php.net/manual/en/mutex.lock.php
https://paulcourt.co.uk/article/cross-server-locking-with-mysql-php