用TypeORM還需要手動釋放資料庫連線嗎?
1 請求pending場景引出資料庫連線池問題
當你的node專案中出現請求一直被pending住可能是什麼原因呢?當然是一直沒有返回。那為什麼沒有返回呢,肯定是哪裡最後沒有呼叫返回。 看一個最核心的例子, 理解一下
```ts const http = require('http')
http.createServer(function(req, res) { setTimeout(()=>{ res.end('End') },5000) }).listen(8040) ```
這個例子就是5秒後呼叫end 所以介面pending了5秒,如果一直不呼叫end則會一直pending直到超時失敗。
有人說縮短超時時間, 那樣只會讓請求一直失敗,沒有從根本上解決問題。當然我們出問題的時候不會這麼簡單,因為我們用了各種node框架,連線了各種其他的服務。如果是單一的接口出問題還比較容易定位,就怕的是所有的介面都出問題。
下面我就說一個case,當服務使用了一段時間之後所有介面都會pending的排查過程:
- 首先是重啟服務之後就會好,證明是服務本身的問題。
- 猜測是中介軟體沒有呼叫next導致pending,檢查程式碼,發現沒問題。
- 檢視下服務的監控,發現CPU,記憶體都沒問題。
- 檢視日誌都是請求進入之後就沒有返回了,也沒有報錯。
- 本地進行復現,然後就可以進行除錯。
- 本地除錯發現所有方法都是卡到了TypeORM的方法上就沒有再執行了。
- 那就繼續深入除錯TypeORM的方法,把它的日誌都加上,發現到最後就是不執行SQL。
- 研究
TypeORM
的原始碼,內部建立連線使用的還是mysql
, 繼續看mysql
連線池的引數, 關鍵的引數如下:
- `waitForConnections`:確定沒有可用連線且已達到限制時池的操作。如果`true`,池將連線請求排隊並在一個可用時呼叫它。如果`false`,池將立即回撥並返回錯誤。(預設: `true`)
- `connectionLimit`:連線池中最大的連線數量。(預設: `10`)
- `queueLimit`: 排隊等待連線的最大佇列數。如果設定為`0`,則對排隊的連線請求數沒有限制。(預設: `0`)
這三個預設引數非常重要,waitForConnections
預設值是true,如果沒有可用的連線 就會一直排隊等著,也不會丟擲錯誤。queueLimit
預設值是0,就代表隊列長度沒有限制。綜合這三個引數就可以說明 如果有連線被使用不釋放並且一直不放回連線池,那麼之後的所有請求都會排隊等待。
其次連線池還有一些事件:
獲取到連線
pool.on('acquire', function (connection) {
console.log('Connection %d acquired', connection.threadId);
});
建立新的連線
ts
pool.on('connection', function (connection) {
connection.query('SET SESSION auto_increment_increment=1')
});
有回撥進入獲取連線的佇列
ts
pool.on('enqueue', function () {
console.log('Waiting for available connection slot');
});
連線被釋放回連線池
ts
pool.on('release', function (connection) {
console.log('Connection %d released', connection.threadId);
});
我們把這些事件加上,然後調整引數, 只要有等待的情況就報錯,而且最大連線數設定為了2。
ts
{
waitForConnections: false,
connectionLimit: 2,
}
設定了之後,再進行測試,發現正常的TypeORM方法呼叫之後都進入release事件,只有在一個介面的時候沒有進入release事件,然後之後的呼叫都觸發了ERROR。
接下來就是看那個介面中的程式碼,定位到問題,手動建立了QueryRunner
但是沒有呼叫釋放方法。官網文件的提示如下:
2 用TypeORM還需要手動釋放資料庫連線嗎
通過上面排查到問題之後,我們可能就會有疑問,用TypeORM還需要手動釋放資料庫連線嗎? 這麼不智慧嗎? 我都什麼情況下需要手動釋放,什麼情況下不用? 回答以上問題 我們需要看TypeORM中的一些關鍵概念,連線建立的過程,連線池是如何工作的,TypeORM中是如何封裝的。
2.1 TypeORM中建立連線池的過程
DataSource
DataSource
資料來源,連線一個數據庫的物件,是最根本的一個物件, 建立它的選項是DataSourceOptions
,它包含兩部分的選項,第一是通用引數,因為TypeORM支援多種資料庫,這部分引數是所有資料庫都支援,另一部分只針對指定資料庫的引數。
ts
const options: DataSourceOptions = {
...
}
const dataSource = new DataSource(options)
dataSource.initialize().then(
async (dataSource) => {
// 這裡可以利用dataSource的API進行資料庫操作
},
(error) => console.log("Cannot connect: ", error),
)
Driver
是操作不同資料庫的驅動器,因為TypeORM支援多種資料庫,所以是通過DriverFactory
的方式建立。
ts
export class DataSource {
constructor(options: DataSourceOptions) {
this.driver = new DriverFactory().create(this)
}
}
DriverFactory
中根據型別建立相應的Driver
物件
ts
export class DriverFactory {
/**
* Creates a new driver depend on a given connection's driver type.
*/
create(connection: DataSource): Driver {
const { type } = connection.options
switch (type) {
case "mysql":
return new MysqlDriver(connection)
case "postgres":
return new PostgresDriver(connection)
...
}
}
把options引數繼續直接傳遞給了MysqlDriver
建立連線
initialize 方法
ts
dataSource.initialize().then(
async (dataSource) => {
// 這裡可以利用dataSource的API進行資料庫操作
},
(error) => console.log("Cannot connect: ", error),
)
內部呼叫Driver
的connect
方法
ts
async initialize(): Promise<this> {
...
await this.driver.connect()
...
}
connect
方法中建立的連線池
ts
async connect(): Promise<void> {
...
this.pool = await this.createPool(
this.createConnectionOptions(this.options, this.options),
)
...
}
構造createPool的引數, 最關鍵的是其中merge了extra的引數。
所以在第一節中說給連線池設定引數是需要放到options.extra
中。
createPool 中繼續呼叫的是createPool
```ts
protected createPool(connectionOptions: any): Promise
// make sure connection is working fine
return new Promise<void>((ok, fail) => {
// (issue #610) we make first connection to database to make sure if connection credentials are wrong
// we give error before calling any other method that creates actual query runner
pool.getConnection((err: any, connection: any) => {
if (err) return pool.end(() => fail(err))
connection.release()
ok(pool)
})
})
}
```
最後到了mysql的createPool方法,
```ts exports.createPool = function createPool(config) { var Pool = loadClass('Pool'); var PoolConfig = loadClass('PoolConfig');
return new Pool({config: new PoolConfig(config)}); }; ```
之後TypeORM中都是使用這個連線池中的連線
ts
this.pool.getConnection((err: any, dbConnection: any) => {
err ? fail(err) : ok(this.prepareDbConnection(dbConnection))
})
2.2 連線池的工作原理
連線池的工作原理就需要檢視mysql的原始碼了。
有四個佇列
ts
function Pool(options) {
...
this._acquiringConnections = []; // 正在獲取中的連線
this._allConnections = []; // 所有的連線
this._freeConnections = []; // 當前連線池中空閒的連線
this._connectionQueue = []; // 等待連線的回撥
}
獲取連線方法,已經添加了註釋。邏輯是 有可用的直接返回,超過限制則報錯或者加入等待佇列,否則就建立新的連線。 ```ts Pool.prototype.getConnection = function (cb) { ... var connection; var pool = this; //_freeConnections 可用的connect佇列 if (this._freeConnections.length > 0) { connection = this._freeConnections.shift(); // ping之後 算獲取成功 this.acquireConnection(connection, cb); return; } // 沒有限制 或者小於限制 則建立新的connection if (this.config.connectionLimit === 0 || this._allConnections.length < this.config.connectionLimit) { connection = new PoolConnection(this, { config: this.config.newConnectionConfig() }); // 獲取中 this._acquiringConnections.push(connection); // 加入所有連結佇列 this._allConnections.push(connection); // 發起連結 connection.connect({timeout: this.config.acquireTimeout}, function onConnect(err) { // 獲取中刪除 spliceConnection(pool._acquiringConnections, connection); if (err) { // 出錯的connect 竟然也放到了 free佇列中 可見只存的connect的物件 每次用的時候會重新連線 // 可能連上了就不用重新連 沒連上就需要重新建立 這個需要看 connection.connect的實現方式 pool._purgeConnection(connection); cb(err); return; } // 成功觸發 pool.emit('connection', connection); pool.emit('acquire', connection); cb(null, connection); }); return; }
// 如果不等 則直接拋錯 if (!this.config.waitForConnections) { process.nextTick(function(){ var err = new Error('No connections available.'); err.code = 'POOL_CONNLIMIT'; cb(err); }); return; } // 否則加入佇列中 this._enqueueCallback(cb); }; ```
釋放連線方法,主幹邏輯就是 放入_freeConnections
佇列,如果有等待則執行等待回撥。
```ts
Pool.prototype.releaseConnection = function releaseConnection(connection) {
...
// release方法會將其重新放入 free的佇列中
this._freeConnections.push(connection);
this.emit('release', connection);
if(this._connectionQueue.length) {
// get connection with next waiting callback
this.getConnection(this._connectionQueue.shift());
}
...
}; ```
2.3 TypeORM中執行語句的方法
TypeORM中有多少種執行SQL的方法,他們之間是什麼關係,哪些需要釋放連線,哪些不需要呢?
QueryRunner
每一個QueryRunner例項從一個連線池中分配一個獨立的連線,可以進行操作,常用的方法 - connect 建立連線 - release 釋放連線 - startTransaction 開始事物 - commitTransaction 提交事物 - rollbackTransaction 回滾事物 - query 執行查詢
它屬於TypeORM中最底層的操作物件,如果使用它的方法則需要自己手動的釋放連線,否則就會一直佔用連線池中的數量。
用DataSource物件可以建立它,可以看到還給QueryRunner添加了一個manager屬性,後邊會講到。
ts
createQueryRunner(mode: ReplicationMode = "master"): QueryRunner {
const queryRunner = this.driver.createQueryRunner(mode)
const manager = this.createEntityManager(queryRunner)
Object.assign(queryRunner, { manager: manager })
return queryRunner
}
DataSource
DataSource也提供了一個query
方法直接執行SQL,是不需要手動釋放連線,內部已經進行了釋放
```ts
async query(
query: string,
parameters?: any[],
queryRunner?: QueryRunner,
): Promise
try {
return await usedQueryRunner.query(query, parameters) // await is needed here because we are using finally
} finally {
// 不是使用者自己傳遞的則釋放
if (!queryRunner) await usedQueryRunner.release()
}
} ```
QueryBuilder
QueryBuilder
是 TypeORM 最強大的功能之一,它允許你使用優雅方便的語法構建 SQL 查詢,執行它們並獲得自動轉換的實體。 它的內部也是自動的建立QueryRunner 然後釋放。
ts
finally {
if (queryRunner !== this.queryRunner) {
// means we created our own query runner
// 是內部自己建立的 則進行釋放
await queryRunner.release()
}
}
DataSource 和EntityManager都提供了建立的方法 createQueryBuilder,但是最終都是呼叫到DataSource的方法上。
ts
createQueryBuilder<Entity>(
entityOrRunner?: EntityTarget<Entity> | QueryRunner,
alias?: string,
queryRunner?: QueryRunner,
)
EntityMangaer
封裝了所有Entity常用的CRUD方法,裡邊最後執行也是同樣有釋放的邏輯
ts
finally {
// release query runner only if its created by us
if (!this.queryRunner) await queryRunner.release()
}
DataSouce自身有一個預設的manger,也提供了createEntityManager
可以自己建立。
ts
createEntityManager(queryRunner?: QueryRunner): EntityManager {
return new EntityManagerFactory().create(this, queryRunner)
}
Reposotity
是針對某一個實體進行CRUD操作的物件,和EntityManager相比就是每一個方法可以少傳一個Entity型別。它裡邊的方法完全是呼叫EntityManager的方法,例如:
ts
save<T extends DeepPartial<Entity>>(
entityOrEntities: T | T[],
options?: SaveOptions,
): Promise<T | T[]> {
return this.manager.save<Entity, T>(
this.metadata.target as any,
entityOrEntities as any,
options,
)
}
獲取它的方法是EntityManager的getRepository
ts
getRepository<Entity extends ObjectLiteral>(
target: EntityTarget<Entity>,
): Repository<Entity> {
// 快取
const repository = this.repositories.find(
(repository) => repository.target === target,
)
if (repository) return repository
...
// 建立
const newRepository = new Repository<any>(
target,
this,
this.queryRunner,
)
this.repositories.push(newRepository)
return newRepository
}
DataSource也提供了方法。
ts
getRepository<Entity extends ObjectLiteral>(
target: EntityTarget<Entity>,
): Repository<Entity> {
return this.manager.getRepository(target)
}
有一個關鍵的點,上面沒有提,其實EntityManager、QueryBuilder在建立的時候是可以指定QueryRunner的,而且如果是指定QueryRunner,則不會進行釋放,需要使用者手動釋放。 所以需要手動釋放的場景都是自己建立了QueryRunner, 非必要不需要建立QueryRunner物件。
3 總結
本文從TypeORM中未釋放資料庫連線導致的問題為入口,講解了排查問題的思路,然後深入原始碼,分析了mysql連線池的原理,發生問題的原因。最後講解了TypeORM中關鍵的幾個物件,他們之間的關係,使用他們執行命令時是否需要手動釋放連線。
- 如果覺得有用請幫忙點個贊🙏。
- 我正在參與掘金技術社群創作者簽約計劃招募活動,點選連結報名投稿。