前言
本文 GitBook 地址: https://www.gitbook.com/book/leoyang90/lar...
當 connection
物件構建初始化完成後,我們就可以利用 DB
來進行資料庫的 CRUD
( Create
、Retrieve
、Update
、Delete
)操作。本篇文章,我們將會講述 laravel
如何與 pdo
互動,實現基本資料庫服務的原理。
run
laravel
中任何資料庫的操作都要經過 run
這個函式,這個函式作用在於重新連線資料庫、記錄資料庫日誌、資料庫異常處理:
protected function run($query, $bindings, Closure $callback)
{
$this->reconnectIfMissingConnection();
$start = microtime(true);
try {
$result = $this->runQueryCallback($query, $bindings, $callback);
} catch (QueryException $e) {
$result = $this->handleQueryException(
$e, $query, $bindings, $callback
);
}
$this->logQuery(
$query, $bindings, $this->getElapsedTime($start)
);
return $result;
}
重新連線資料庫 reconnect
如果當期的 pdo
是空,那麼就會呼叫 reconnector
重新與資料庫進行連線:
protected function reconnectIfMissingConnection()
{
if (is_null($this->pdo)) {
$this->reconnect();
}
}
public function reconnect()
{
if (is_callable($this->reconnector)) {
return call_user_func($this->reconnector, $this);
}
throw new LogicException('Lost connection and no reconnector available.');
}
執行資料庫操作
資料庫的 curd 操作會被包裝成為一個閉包函式,作為 runQueryCallback
的一個引數,當執行正常時,會返回結果,如果遇到異常的話,會將異常轉化為 QueryException
,並且丟擲。
protected function runQueryCallback($query, $bindings, Closure $callback)
{
try {
$result = $callback($query, $bindings);
}
catch (Exception $e) {
throw new QueryException(
$query, $this->prepareBindings($bindings), $e
);
}
return $result;
}
資料庫異常處理
當 pdo
查詢返回異常的時候,如果當前是事務進行時,那麼直接返回異常,讓上一層事務來處理。
如果是由於與資料庫事情連線導致的異常,那麼就要重新與資料庫進行連線:
protected function handleQueryException($e, $query, $bindings, Closure $callback)
{
if ($this->transactions >= 1) {
throw $e;
}
return $this->tryAgainIfCausedByLostConnection(
$e, $query, $bindings, $callback
);
}
與資料庫失去連線:
protected function tryAgainIfCausedByLostConnection(QueryException $e, $query, $bindings, Closure $callback)
{
if ($this->causedByLostConnection($e->getPrevious())) {
$this->reconnect();
return $this->runQueryCallback($query, $bindings, $callback);
}
throw $e;
}
protected function causedByLostConnection(Exception $e)
{
$message = $e->getMessage();
return Str::contains($message, [
'server has gone away',
'no connection to the server',
'Lost connection',
'is dead or not enabled',
'Error while sending',
'decryption failed or bad record mac',
'server closed the connection unexpectedly',
'SSL connection has been closed unexpectedly',
'Error writing data to the connection',
'Resource deadlock avoided',
'Transaction() on null',
'child connection forced to terminate due to client_idle_limit',
]);
}
資料庫日誌
public function logQuery($query, $bindings, $time = null)
{
$this->event(new QueryExecuted($query, $bindings, $time, $this));
if ($this->loggingQueries) {
$this->queryLog[] = compact('query', 'bindings', 'time');
}
}
想要開啟或關閉日誌功能:
public function enableQueryLog()
{
$this->loggingQueries = true;
}
public function disableQueryLog()
{
$this->loggingQueries = false;
}
Select 查詢
public function select($query, $bindings = [], $useReadPdo = true)
{
return $this->run($query, $bindings, function ($query, $bindings) use ($useReadPdo) {
if ($this->pretending()) {
return [];
}
$statement = $this->prepared($this->getPdoForSelect($useReadPdo)
->prepare($query));
$this->bindValues($statement, $this->prepareBindings($bindings));
$statement->execute();
return $statement->fetchAll();
});
}
資料庫的查詢主要有一下幾個步驟:
- 獲取
$this->pdo
成員變數,若當前未連線資料庫,則進行資料庫連線,獲取pdo
物件。 - 設定
pdo
資料fetch
模式 pdo
進行sql
語句預處理,pdo
繫結引數sql
語句執行,並獲取資料。
getPdoForSelect 獲取 pdo 物件
protected function getPdoForSelect($useReadPdo = true)
{
return $useReadPdo ? $this->getReadPdo() : $this->getPdo();
}
public function getPdo()
{
if ($this->pdo instanceof Closure) {
return $this->pdo = call_user_func($this->pdo);
}
return $this->pdo;
}
public function getReadPdo()
{
if ($this->transactions > 0) {
return $this->getPdo();
}
if ($this->getConfig('sticky') && $this->recordsModified) {
return $this->getPdo();
}
if ($this->readPdo instanceof Closure) {
return $this->readPdo = call_user_func($this->readPdo);
}
return $this->readPdo ?: $this->getPdo();
}
getPdo
這裡邏輯比較簡單,值得我們注意的是 getReadPdo
。為了減緩資料庫的壓力,我們常常對資料庫進行讀寫分離,也就是隻要當寫資料庫這種操作發生時,才會使用寫資料庫,否則都會用讀資料庫。這種措施減少了資料庫的壓力,但是也帶來了一些問題,那就是讀寫兩個資料庫在一定時間內會出現資料不一致的情況,原因就是寫庫的資料未能及時推送給讀庫,造成讀庫資料延遲的現象。為了在一定程度上解決這類問題,laravel
增添了 sticky
選項,
從程式中我們可以看出,當我們設定選項 sticky
為真,並且的確對資料庫進行了寫操作後,getReadPdo
會強制返回主庫的連線,這樣就避免了讀寫分離造成的延遲問題。
還有一種情況,當資料庫在執行事務期間,所有的讀取操作也會被強制連線主庫。
prepared 設定資料獲取方式
protected $fetchMode = PDO::FETCH_OBJ;
protected function prepared(PDOStatement $statement)
{
$statement->setFetchMode($this->fetchMode);
$this->event(new Events\StatementPrepared(
$this, $statement
));
return $statement;
}
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開始索引的引數位置。
public function prepareBindings(array $bindings)
{
$grammar = $this->getQueryGrammar();
foreach ($bindings as $key => $value) {
if ($value instanceof DateTimeInterface) {
$bindings[$key] = $value->format($grammar->getDateFormat());
} elseif ($value === false) {
$bindings[$key] = 0;
}
}
return $bindings;
}
public function bindValues($statement, $bindings)
{
foreach ($bindings as $key => $value) {
$statement->bindValue(
is_string($key) ? $key : $key + 1, $value,
is_int($value) ? PDO::PARAM_INT : PDO::PARAM_STR
);
}
}
insert
public function insert($query, $bindings = [])
{
return $this->statement($query, $bindings);
}
public function statement($query, $bindings = [])
{
return $this->run($query, $bindings, function ($query, $bindings) {
if ($this->pretending()) {
return true;
}
$statement = $this->getPdo()->prepare($query);
$this->bindValues($statement, $this->prepareBindings($bindings));
$this->recordsHaveBeenModified();
return $statement->execute();
});
}
這部分的程式碼與 select
非常相似,不同之處有一下幾個:
- 直接獲取寫庫的連線,不會考慮讀庫
- 由於不需要返回任何資料庫資料,因此也不必設定
fetchMode
。 recordsHaveBeenModified
函式標誌當前連線資料庫已被寫入。- 不需要呼叫函式
fetchAll
public function recordsHaveBeenModified($value = true)
{
if (! $this->recordsModified) {
$this->recordsModified = $value;
}
}
update、delete
affectingStatement
這個函式與上面的 statement
函式一致,只是最後會返回更新、刪除影響的行數。
public function update($query, $bindings = [])
{
return $this->affectingStatement($query, $bindings);
}
public function delete($query, $bindings = [])
{
return $this->affectingStatement($query, $bindings);
}
public function affectingStatement($query, $bindings = [])
{
return $this->run($query, $bindings, function ($query, $bindings) {
if ($this->pretending()) {
return 0;
}
$statement = $this->getPdo()->prepare($query);
$this->bindValues($statement, $this->prepareBindings($bindings));
$statement->execute();
$this->recordsHaveBeenModified(
($count = $statement->rowCount()) > 0
);
return $count;
});
}
transaction 資料庫事務
為保持資料的一致性,對於重要的資料我們經常使用資料庫事務,transaction
函式接受一個閉包函式,與一個重複嘗試的次數:
public function transaction(Closure $callback, $attempts = 1)
{
for ($currentAttempt = 1; $currentAttempt <= $attempts; $currentAttempt++) {
$this->beginTransaction();
try {
return tap($callback($this), function ($result) {
$this->commit();
});
}
catch (Exception $e) {
$this->handleTransactionException(
$e, $currentAttempt, $attempts
);
} catch (Throwable $e) {
$this->rollBack();
throw $e;
}
}
}
開始事務
資料庫事務中非常重要的成員變數是 $this->transactions
,它標誌著當前事務的程式:
public function beginTransaction()
{
$this->createTransaction();
++$this->transactions;
$this->fireConnectionEvent('beganTransaction');
}
可以看出,當建立事務成功後,就會累加 $this->transactions
,並且啟動 event
,建立事務:
protected function createTransaction()
{
if ($this->transactions == 0) {
try {
$this->getPdo()->beginTransaction();
} catch (Exception $e) {
$this->handleBeginTransactionException($e);
}
} elseif ($this->transactions >= 1 && $this->queryGrammar->supportsSavepoints()) {
$this->createSavepoint();
}
}
如果當前沒有任何事務,那麼就會呼叫 pdo
來開啟事務。
如果當前已經在事務保護的範圍內,那麼就會建立 SAVEPOINT
,實現資料庫巢狀事務:
protected function createSavepoint()
{
$this->getPdo()->exec(
$this->queryGrammar->compileSavepoint('trans'.($this->transactions + 1))
);
}
public function compileSavepoint($name)
{
return 'SAVEPOINT '.$name;
}
如果建立事務失敗,那麼就會呼叫 handleBeginTransactionException
:
protected function handleBeginTransactionException($e)
{
if ($this->causedByLostConnection($e)) {
$this->reconnect();
$this->pdo->beginTransaction();
} else {
throw $e;
}
}
如果建立事務失敗是由於與資料庫失去連線的話,那麼就會重新連線資料庫,否則就要丟擲異常。
事務異常
事務的異常處理比較複雜,可以先看一看程式碼:
protected function handleTransactionException($e, $currentAttempt, $maxAttempts)
{
if ($this->causedByDeadlock($e) &&
$this->transactions > 1) {
--$this->transactions;
throw $e;
}
$this->rollBack();
if ($this->causedByDeadlock($e) &&
$currentAttempt < $maxAttempts) {
return;
}
throw $e;
}
protected function causedByDeadlock(Exception $e)
{
$message = $e->getMessage();
return Str::contains($message, [
'Deadlock found when trying to get lock',
'deadlock detected',
'The database file is locked',
'database is locked',
'database table is locked',
'A table in the database is locked',
'has been chosen as the deadlock victim',
'Lock wait timeout exceeded; try restarting transaction',
]);
}
這裡可以分為四種情況:
- 單一事務,非死鎖導致的異常
單一事務就是說,此時的事務只有一層,沒有巢狀事務的存在。資料庫的異常也不是死鎖導致的,一般是由於 sql
語句不正確引起的。這個時候,handleTransactionException
會直接回滾事務,並且丟擲異常到外層:
try {
return tap($callback($this), function ($result) {
$this->commit();
});
}
catch (Exception $e) {
$this->handleTransactionException(
$e, $currentAttempt, $attempts
);
} catch (Throwable $e) {
$this->rollBack();
throw $e;
}
接到異常之後,程式會再次回滾,但是由於 $this->transactions
已經為 0,因此回滾直接返回,並未真正執行,之後就會丟擲異常。
- 單一事務,死鎖異常
有死鎖導致的單一事務異常,一般是由於其他程式同時更改了資料庫,這個時候,就要判斷當前重複嘗試的次數是否大於使用者設定的 maxAttempts
,如果小於就繼續嘗試,如果大於,那麼就會丟擲異常。
- 巢狀事務,非死鎖異常
如果出現巢狀事務,例如:
\DB::transaction(function(){
...
//directly or indirectly call another transaction:
\DB::transaction(function() {
...
...
}, 2);//attempt twice
}, 2);//attempt twice
如果是非死鎖導致的異常,那麼就要首先回滾內層的事務,丟擲異常到外層事務,再回滾外層事務,丟擲異常,讓使用者來處理。也就是說,對於巢狀事務來說,內部事務異常,一定要回滾整個事務,而不是僅僅回滾內部事務。
- 巢狀事務,死鎖異常
巢狀事務的死鎖異常,仍然和巢狀事務非死鎖異常一樣,內部事務異常,一定要回滾整個事務。
但是,不同的是,mysql
對於巢狀事務的回滾會導致外部事務一併回滾:InnoDB Error Handling,因此這時,我們僅僅將 $this->transactions
減一,並丟擲異常,使得外層事務回滾丟擲異常即可。
回滾事務
如果事務內的資料庫更新操作失敗,那麼就要進行回滾:
public function rollBack($toLevel = null)
{
$toLevel = is_null($toLevel)
? $this->transactions - 1
: $toLevel;
if ($toLevel < 0 || $toLevel >= $this->transactions) {
return;
}
$this->performRollBack($toLevel);
$this->transactions = $toLevel;
$this->fireConnectionEvent('rollingBack');
}
回滾的第一件事就是要減少 $this->transactions
的值,標誌當前事務失敗。
回滾的時候仍然要判斷當前事務的狀態,如果當前處於巢狀事務的話,就要進行回滾到 SAVEPOINT
,如果是單一事務的話,才會真正回滾退出事務:
protected function performRollBack($toLevel)
{
if ($toLevel == 0) {
$this->getPdo()->rollBack();
} elseif ($this->queryGrammar->supportsSavepoints()) {
$this->getPdo()->exec(
$this->queryGrammar->compileSavepointRollBack('trans'.($toLevel + 1))
);
}
}
public function compileSavepointRollBack($name)
{
return 'ROLLBACK TO SAVEPOINT '.$name;
}
提交事務
提交事務比較簡單,僅僅是呼叫 pdo
的 commit
即可。需要注意的是對於巢狀事務的事務提交,commit
函式僅僅更新了 $this->transactions
,而並沒有真正提交事務,原因是內層事務的提交對於 mysql
來說是無效的,只有外部事務的提交才能更新整個事務。
public function commit()
{
if ($this->transactions == 1) {
$this->getPdo()->commit();
}
$this->transactions = max(0, $this->transactions - 1);
$this->fireConnectionEvent('committed');
}
本作品採用《CC 協議》,轉載必須註明作者和本文連結