在批发档口记账 app 中,由于用户数量越来越多,有几个老客户了反馈单据丢失的问题;
打个比方:
我们开单会去创建开单表 order_create
,开单表会生成详情 order_create_goods_sku
,然后生成欠货表 order_owe_goods_sku
;
然后接着是发货请求,会生成发货主表 order_delivery
和 发货详情 order_delivery_goods_sku
;发货会生成库存变动记录;
这时候已经有了开单和开单详情,证明已经开单请求成功;
但是库存变动记录有,发货单却找不到;这时候我去查找了日志,发现也有发货请求;
然而通过库存变动记录找不到相应的发货单了,这是怎么回事,难道是被人撤销了吗?
全局查找了整个项目,没有强删除的接口,难道是代码有问题?
那么我开始定位代码,因为事务包裹着整个”发货的创建流程” 和 “库存变动记录的生成”;
所以要么就全部回滚,那么不太可能是回滚问题?
跟踪数据库操作:
那是不是有人从数据库删除了?
我写了个触发器,只要有人强删记录就会记录到一张跟踪表:
1.首先创建跟踪表:
1
2
3
4
5
6
7
8
9
CREATE TABLE ` juniu_track_delivery ` (
` track_delivery_id ` int ( 11 ) NOT NULL AUTO_INCREMENT COMMENT '订单ID' ,
` order_delivery_id ` int ( 11 ) NOT NULL DEFAULT 0 COMMENT '发货单ID' ,
` order_id ` int ( 11 ) NOT NULL DEFAULT 0 COMMENT '订单ID' ,
` deliver_timestamp ` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '发货时间' ,
` deleted_at ` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '被删除时间' ,
` deleted_by ` varchar ( 50 ) DEFAULT '' COMMENT '删除的用户ip' ,
PRIMARY KEY ( ` track_delivery_id ` )
) ENGINE = InnoDB AUTO_INCREMENT = 0 DEFAULT CHARSET = utf8 ;
2.建立触发器 track_delete_delivery
跟踪:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
DELIMITER //
CREATE TRIGGER track_delete_delivery
AFTER DELETE
ON juniu_order_delivery FOR EACH ROW
BEGIN
DECLARE vUser varchar ( 50 );
-- Find username of person performing the DELETE into table
SELECT USER () INTO vUser ;
-- Insert record into audit table
INSERT INTO juniu_track_delivery
( order_delivery_id ,
order_id ,
deliver_timestamp ,
deleted_at ,
deleted_by )
VALUES
( OLD . order_delivery_id ,
OLD . order_id ,
OLD . deliver_timestamp ,
SYSDATE (),
vUser );
END ; //
DELIMITER ;
观察一段时间,仍有数据丢失,但是并没有跟踪到,初步判断不是人为删除;
Mysql 事务特性:
Mysql 事务具有四大特性:A(原子性)C(一致性)I(隔离性)D(持久性);
事务有五个级别:
TRANSACTION_NONE 不使用事务。
TRANSACTION_READ_UNCOMMITTED 未提交读,允许脏读。
TRANSACTION_READ_COMMITTED 提交读,防止脏读,最常用的隔离级别,并且是大多数数据库的默认隔离级别
TRANSACTION_REPEATABLE_READ 重复读,可以防止脏读和不可重复读,
TRANSACTION_SERIALIZABLE 序列化,可以防止脏读,不可重复读取和幻读,(事务串行化)会降低数据库的效率
1、脏读: 事务A读取了事务B更新的数据,然后B回滚操作,那么A读取到的数据是脏数据
2、不可重复读: 事务 A 多次读取同一数据,事务 B 在事务A多次读取的过程中,对数据作了更新并提交,导致事务A多次读取同一数据时,结果不一致。
3、幻读: 事务 A 修改了表中所有数据,但是事务 B 插入了一条数据,当事务 A 查询数据发现还有一条记录没有改过来,就好像发生了幻觉一样,这就叫幻读。
小结: 不可重复读的和幻读很容易混淆,不可重复读侧重于修改,幻读侧重于新增或删除。解决不可重复读的问题只需锁住满足条件的行,解决幻读需要锁表
最后我们来查看一下 Mysql 数据库事务配置,看是不是事务配置错误:
1
2
3
4
5
select @@ tx_isolation ;
select @@ session . tx_isolation ;
select @@ global . tx_isolation ;
显示结果都是 READ-COMMITTED
查看事务源码:
重新回到回滚代码,既然都被包裹起来,但是又回滚不成功,我开始查阅 Laravel API,因为我怕会是框架有问题,
找到相应的 laravel 版本 5.1: https://laravel.com/api/5.1/
寻找 transaction
,显示有
Illuminate\Database\ConnectionInterface::transaction
Illuminate\Database\Connection::transaction
接口定义的是规则,所以我看数据库的连接类 Illuminate\Database\Connection::transaction
这里有几个我们要看的方法:
1
2
3
4
5
6
7
mixed transaction ( Closure $callback )
void beginTransaction ()
void commit ()
void rollBack ()
点击右边行号,可以跳转到 5.1 源代码文件:
先查看 transaction
方法,此处参数是一个闭包,我们项目中使用的不是这种写法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public function transaction ( Closure $callback )
{
$this -> beginTransaction ();
// We'll simply execute the given callback within a try / catch block
// and if we catch any exception we can rollback the transaction
// so that none of the changes are persisted to the database.
try {
$result = $callback ( $this );
$this -> commit ();
}
// If we catch an exception, we will roll back so nothing gets messed
// up in the database. Then we'll re-throw the exception so it can
// be handled how the developer sees fit for their applications.
catch ( Exception $e ) {
$this -> rollBack ();
throw $e ;
} catch ( Throwable $e ) {
$this -> rollBack ();
throw $e ;
}
return $result ;
}
整个事务用 try catch
包裹住,如果失败,直接抛出异常,并且回滚,transactions
自然减为 0;
然后我看看我们项目中所使用的方法,通过DB::beginTransaction();
开始,然后中途出错,直接写 DB::rollback()
,
然后 return
返回,最后成功提交 DB::commit()
。
先看看 beginTransaction()
的源码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public function beginTransaction ()
{
if ( $this -> transactions == 0 ) {
try {
$this -> pdo -> beginTransaction ();
} catch ( Exception $e ) {
if ( $this -> causedByLostConnection ( $e )) {
$this -> reconnect ();
$this -> pdo -> beginTransaction ();
} else {
throw $e ;
}
}
} elseif ( $this -> transactions >= 1 && $this -> queryGrammar -> supportsSavepoints ()) {
$this -> pdo -> exec (
$this -> queryGrammar -> compileSavepoint ( 'trans' . ( $this -> transactions + 1 ))
);
}
$this -> transactions ++ ;
$this -> fireConnectionEvent ( 'beganTransaction' );
}
可以看到,这里的 transactions
属性记录多少层事务,通过 try catch
包裹一个事务开始,如果失败,重新尝试连接,并将transaction + 1
;
再看 rollback()
1
2
3
4
5
6
7
8
9
10
11
12
public function rollBack ()
{
if ( $this -> transactions == 1 ) {
$this -> pdo -> rollBack ();
} elseif ( $this -> transactions > 1 && $this -> queryGrammar -> supportsSavepoints ()) {
$this -> pdo -> exec (
$this -> queryGrammar -> compileSavepointRollBack ( 'trans' . $this -> transactions )
);
}
$this -> transactions = max ( 0 , $this -> transactions - 1 );
$this -> fireConnectionEvent ( 'rollingBack' );
}
这里 只有在 transactions 为 1 的时候,才去 rollback 整个事务 ;
再来看看 commit()
1
2
3
4
5
6
7
8
public function commit ()
{
if ( $this -> transactions == 1 ) {
$this -> pdo -> commit ();
}
$this -> transactions -- ;
$this -> fireConnectionEvent ( 'committed' );
}
也是只有当 transactions == 1
的时候才会 commit
整个事务;
不像 transaction
闭包那样,有整个 try catch
包裹,这里每一个步骤都要自己控制,
项目中事务的写法经常是:
1
2
3
4
5
6
7
8
9
10
11
12
DB :: beginTransaction ();
$res = OrderDelivery :: insert ([ ... ]);
if ( ! $res ) {
DB :: rollback ();
return $this -> fail ( - 1 , '创建失败' );
}
$delivery_skus = json_decode ( $color_size_matrix , true );
OrderDeliveryGoodsSku :: insert ( $delivery_skus );
...
...
DB :: commit ();
那么就有问题了,如果某个步骤出错,但是因为没有抛出异常,因为查看 API 像 insert
失败会返回 false
;
需要人工判断返回值去 rollback
,如果没有判断,也没有 rollback
,这样 transactions
就没有减到 1;
这就有可能跑到 commit
那里去给 transactions - 1
了;
结论:
所以较好的方法还是,
transaction
带闭包参数;
beginTransaction + try catch
用 beginTransaction + try catch
,只要有问题,直接到 catch
那做一次回滚即可,不用担心哪里忘了 rollback
;
示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
DB :: beginTrasaction ();
try {
OrderModificationGoodsSku :: insertGetId ();
OrderOweGoodsSku :: where () -> update ();
DB :: commit ();
} catch ( \Throwable $e ) {
// For php 7
DB :: rollback ();
//throw $e;
Log :: error ( $e -> getMessage ());
return $this -> fail ( - 1 , "创建失败" );
} catch ( \Exception $e ) {
// For php5
DB :: rollback ();
//throw $e;
Log :: error ( $e -> getMessage ());
return $this -> fail ( - 1 , "创建失败" );
}