用TypeORM還需要手動釋放資料庫連線嗎?

語言: CN / TW / HK

1 請求pending場景引出資料庫連線池問題

當你的node專案中出現請求一直被pending住可能是什麼原因呢?當然是一直沒有返回。那為什麼沒有返回呢,肯定是哪裡最後沒有呼叫返回。 看一個最核心的例子, 理解一下

```ts const http = require('http')

http.createServer(function(req, res) { setTimeout(()=>{ res.end('End') },5000) }).listen(8040) ```

image.png 這個例子就是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但是沒有呼叫釋放方法。官網文件的提示如下:

image.png

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), ) 內部呼叫Driverconnect方法 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的引數。

image.png

所以在第一節中說給連線池設定引數是需要放到options.extra中。

createPool 中繼續呼叫的是createPool ```ts protected createPool(connectionOptions: any): Promise { // create a connection pool const pool = this.mysql.createPool(connectionOptions)

    // 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 { ... const usedQueryRunner = queryRunner || this.createQueryRunner()

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中關鍵的幾個物件,他們之間的關係,使用他們執行命令時是否需要手動釋放連線。

  • 如果覺得有用請幫忙點個贊🙏。
  • 我正在參與掘金技術社群創作者簽約計劃招募活動,點選連結報名投稿