思考并回答以下问题:
- Exception::getPrevious—返回异常链中的前一个异常。什么意思?
前言
当Connection对象构建初始化完成后,我们就可以利用DB来进行数据库的CURD(Create、Update、Retrieve、Delete)操作。本篇文章,我们将会讲述Laravel如何与PDO交互,实现基本数据库服务的原理。
源码
run
Laravel中任何数据库的操作都要经过run这个函数,这个函数作用在于重新连接数据库、记录数据库日志、数据库异常处理:
Illuminate\Database\Connection.php
1 | /** |
重新连接数据库reconnect
如果当前的pdo是空,那么就会调用reconnector重新与数据库进行连接: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/**
* 如果PDO连接丢失,重新连接数据库。
*
* @return void
*/
protected function reconnectIfMissingConnection()
{
if (is_null($this->pdo))
{
$this->reconnect();
}
}
/**
* 重新连接数据库。
*
* @return void
*
* @throws \LogicException
*/
public function reconnect()
{
if (is_callable($this->reconnector))
{
$this->doctrineConnection = null;
return call_user_func($this->reconnector, $this);
}
throw new LogicException('Lost connection and no reconnector available.');
}
运行数据库操作
数据库的CURD操作会被包装成为一个闭包函数,作为runQueryCallback的一个参数,当运行正常时,会返回结果,如果遇到异常的话,会将异常转化为QueryException,并且抛出。
1 | /** |
数据库异常处理
当pdo查询返回异常的时候,如果当前是事务进行时,那么直接返回异常,让上一层事务来处理。
如果是由于与数据库事情连接导致的异常,那么就要重新与数据库进行连接:
1 | /** |
与数据库失去连接:
1 | /** |
Illuminate\Database\DetectsLostConnections.php
1 |
|
数据库日志
1 | /** |
想要开启或关闭日志功能: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/**
* 指明是否正在记录查询。
*
* @var bool
*/
protected $loggingQueries = false;
/**
* 在连接上启用查询日志。
*
* @return void
*/
public function enableQueryLog()
{
$this->loggingQueries = true;
}
/**
* 在连接上禁用查询日志。
*
* @return void
*/
public function disableQueryLog()
{
$this->loggingQueries = false;
}
Select查询
1 | /** |
数据库的查询主要有以下几个步骤:
- 获取$this->pdo成员变量,若当前未连接数据库,则进行数据库连接,获取pdo对象。
- 设置pdo数据fetch模式。
- pdo进行sql语句预处理。
- pdo绑定参数sql语句执行,并获取数据。
getPdoForSelect获取pdo对象
1 | /** |
getPdo这里逻辑比较简单,值得我们注意的是getReadPdo。为了减缓数据库的压力,我们常常对数据库进行读写分离,也就是只要当写数据库这种操作发生时,才会使用写数据库,否则都会用读数据库。这种措施减少了数据库的压力,但是也带来了一些问题,那就是读写两个数据库在一定时间内会出现数据不一致的情况,原因就是写库的数据未能及时推送给读库,造成读库数据延迟的现象。为了在一定程度上解决这类问题,Laravel增添了sticky选项,从程序中我们可以看出,当我们设置选项sticky为真,并且的确对数据库进行了写操作后,getReadPdo会强制返回主库的连接,这样就避免了读写分离造成的延迟问题。
还有一种情况,当数据库在执行事务期间,所有的读取操作也会被强制连接主库。
prepared设置数据获取方式
1 | /** |
pdo的setFetchMode函数用于为语句设置默认的获取模式,通常模式有-以下几种:
- PDO::FETCH_ASSOC 从结果集中获取以列名为索引的关联数组。
- PDO::FETCH_NUM 从结果集中获取一个以列在行中的数值偏移量为索引的值数组。
- PDO::FETCH_BOTH 这是默认值,包含上面两种数组。
- PDO::FETCH_OBJ 从结果集当前行的记录中获取其属性对应各个列名的一个对象。
- PDO::FETCH_BOUND 使用fetch()返回TRUE,并将获取的列值赋给在bindParm()方法中指定的相应变量。
- PDO::FETCH_LAZY 创建关联数组和索引数组,以及包含列属性的一个对象,从而可以在这三种接口中任选一种。
pdo的prepare函数
prepare函数会为PDOStatement::execute()方法准备要执行的SQL语句, SQL语句可以包含零个或多个命名(:name)或问号(?)参数标记,参数在SQL执行时会被替换。
不能在SQL语句中同时包含命名(:name)或问号(?)参数标记,只能选择其中一种风格。
预处理SQL语句中的参数在使用PDOStatement::execute()方法时会传递真实的参数。
之所以使用prepare函数,是因为这个函数可以防止SQL注入,并且可以加快同一查询语句的速度。关于预处理与参数绑定防止SQL漏洞注入的原理可以参考:Web安全之SQL注入攻击技巧与防范.
pdo的bindValues函数
在调用pdo的参数绑定函数之前,Laravel对参数值进一步进行了优化,把时间类型的对象利用grammer的设置重新格式化,false也改为0。
pdo的参数绑定函数bindValue,对于使用命名占位符的预处理语句,应是类似:name形式的参数名。对于使用问号占位符的预处理语句,应是以1开始索引的参数位置。
1 | /** |
insert
1 | /** |
这部分的代码与select非常相似,不同之处有以下几个:
- 直接获取写库的连接,不会考虑读库。
- 由于不需要返回任何数据库数据,因此也不必设置fetchMode。
- recordsHaveBeenModified函数标志当前连接数据库已被写入。
- 不需要调用函数fetchAll。
1 | /** |
update、delete
affectingStatement这个函数与上面的statement函数一致,只是最后会返回更新、删除影响的行数。
1 | /** |
事务
transaction数据库事务
为保持数据的一致性,对于重要的数据我们经常使用数据库事务,transaction函数接受一个闭包函数,与一个重复尝试的次数:
1 |
|
开始事务
数据库事务中非常重要的成员变量是$this->transactions,它标志着当前事务的进程:
1 | /** |
可以看出,当创建事务成功后,就会累加$this->transactions,并且启动event,创建事务:
1 | /** |
如果当前没有任何事务,那么就会调用pdo来开启事务。
如果当前已经在事务保护的范围内,那么就会创建SAVEPOINT,实现数据库嵌套事务:1
2
3
4
5
6
7
8
9
10
11
12
13/**
* 在数据库中创建一个保存点。
*
* @return void
*
* @throws \Throwable
*/
protected function createSavepoint()
{
$this->getPdo()->exec(
$this->queryGrammar->compileSavepoint('trans'.($this->transactions + 1))
);
}
1 |
|
如果创建事务失败,那么就会调用handleBeginTransactionException:
1 | /** |
如果创建事务失败是由于与数据库失去连接的话,那么就会重新连接数据库,否则就要抛出异常。
事务异常
事务的异常处理比较复杂,可以先看一看代码: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/**
* 处理运行事务语句时遇到的异常。
*
* @param \Throwable $e
* @param int $currentAttempt
* @param int $maxAttempts
* @return void
*
* @throws \Throwable
*/
protected function handleTransactionException(Throwable $e, $currentAttempt, $maxAttempts)
{
// 陷入僵局时,MySQL会回滚整个事务,因此我们不能只是重试查询。
// 我们必须彻底消除该异常,并让开发人员以另一种方式处理它。
// 我们也会减少transactions。
if ($this->causedByConcurrencyError($e) &&
$this->transactions > 1)
{
$this->transactions--;
throw $e;
}
// 如果有异常,我们将回滚该事务,然后我们可以检查是否超出了最大尝试次数,
// 如果没有,我们将返回并在循环中再次尝试此查询。
$this->rollBack();
if ($this->causedByConcurrencyError($e) &&
$currentAttempt < $maxAttempts)
{
return;
}
throw $e;
}
1 |
|
这里可以分为四种情况:
- 单一事务,非死锁导致的异常
单一事务就是说,此时的事务只有一层,没有嵌套事务的存在。数据库的异常也不是死锁导致的,一般是由于sql语句不正确引起的。这个时候,handleTransactionException会直接回滚事务,并且抛出异常到外层:
1 | try |
接到异常之后,程序会再次回滚,但是由于$this->transactions已经为0,因此回滚直接返回,并未真正执行,之后就会抛出异常。
- 单一事务,死锁异常
有死锁导致的单一事务异常,一般是由于其他程序同时更改了数据库,这个时候,就要判断当前重复尝试的次数是否大于用户设置的maxAttempts,如果小于就继续尝试,如果大于,那么就会抛出异常。
- 嵌套事务,非死锁异常
如果出现嵌套事务,例如:
1 | \DB::transaction(function(){ |
如果是非死锁导致的异常,那么就要首先回滚内层的事务,抛出异常到外层事务,再回滚外层事务,抛出异常,让用户来处理。也就是说,对于嵌套事务来说,内部事务异常,一定要回滚整个事务,而不是仅仅回滚内部事务。
- 嵌套事务,死锁异常
嵌套事务的死锁异常,仍然和嵌套事务非死锁异常一样,内部事务异常,一定要回滚整个事务。
但是,不同的是,mysql对于嵌套事务的回滚会导致外部事务一并回滚:InnoDB Error Handling,因此这时,我们仅仅将$this->transactions减一,并抛出异常,使得外层事务回滚抛出异常即可。
回滚事务
如果事务内的数据库更新操作失败,那么就要进行回滚:
1 | /** |
回滚的第一件事就是要减少$this->transactions的值,标志当前事务失败。
回滚的时候仍然要判断当前事务的状态,如果当前处于嵌套事务的话,就要进行回滚到SAVEPOINT,如果是单一事务的话,才会真正回滚退出事务:
1 | /** |
提交事务
提交事务比较简单,仅仅是调用pdo的commit即可。需要注意的是对于嵌套事务的事务提交,commit函数仅仅更新了$this->transactions,而并没有真正提交事务,原因是内层事务的提交对于mysql来说是无效的,只有外部事务的提交才能更新整个事务。
1 | /** |