馮老師的困惑 —— 測試和正式環境掐架篇(二)

快樂的皮拉夫發表於2023-11-25

馮老師的困惑

又一個陽光明媚的下午,下班時間已過,我剛收拾好東西準備回家,遠遠看見馮老師又踱步向我走來。

「一塊加個班吧,請你吃豬頭肉。」

「咋了,又遇到問題了麼?馮老師」

「知子莫若父啊……懂我,來幫我看看」

眼瞅著來業務了,我自然不能放過。於是,我看了一眼手機:「六點過五分了,馮老師。記好時間~」

「你來看,又是一個線上環境和測試環境表現不一致的問題……」

「額,咋這些奇葩問題都讓你遇見了……」

「少廢話,趕緊幫我看看,程式碼已經發布到線上去了,現線上上一訪問就報一個資料庫錯誤呢,測試環境都是正常的。」

「報什麼錯誤?」

馮老師把錯誤日誌調出來給我看,看著像是資料庫底層程式碼報出來的:

Call to a member function num_rows() on a non-object

專案是老專案,基於CI 2.0框架開發的。從報錯資訊可以初步判斷在一個空物件上呼叫了一個成員方法num_rows()

我再次和馮老師對了一下表,確認無誤以後,就開始我的「破案之旅」了。

神探皮拉夫登場

「你能告訴我這個報錯是在哪處業務程式碼觸發的嗎,馮老師?」

「在這。」

$userId = $this->params['user_id'];
$supplierDb = $this->load->database('supplier', true);
$query = $supplierDb->from('ship_template')->where('user_id', $userId);
$total = $query->count_all_results(); //這裡報錯了

雖然已經很久沒接觸過 CI 程式碼了,不過從字面意思也大概能推測出來這段程式碼的功能:

  • 載入supplier這個標識的資料庫
  • 根據條件執行查詢
  • 計算查詢結果數量

在和馮老師確認了我的推斷以後,我便開始蒐集其他證據。

spplier這個資料庫連線標識有什麼特別之處麼?」

supplier是我新加的。」

「其他連線標識可以正常使用嗎?」

「其他的都正常,就我加的這個報錯。問題是我在測試環境能正常執行,正式的就不行。」

「還有其他報錯資訊嗎?」

「沒有了。」

「線上資料庫根據這個條件能查出資料來嗎?」

「可以的,我試過了。」

「給我個SQL,我試試。」

「哎……還不相信我,我給你SQL你試試。」

並非我不敢相信馮老師,在真相未知之前,我向來喜歡保持思維的獨立性,這樣更容易讓我抓住一些常被忽略的細節,畢竟,細節決定一切

我拿到SQL,線上上資料庫執行以後,確實能拿到資料。既然查詢的結果存在,那麼問題可能出現在資料庫連線上。我進而把注意力轉移到supplier這個識別符號的資料庫配置上:

$db['supplier']['hostname'] = 'XXX';
$db['supplier']['username'] = 'XXX';
$db['supplier']['password'] = 'XXX';
$db['supplier']['database'] = 'supplier';
$db['supplier']['dbdriver'] = 'mysql';
$db['supplier']['dbprefix'] = '';
$db['supplier']['pconnect'] = FALSE;

我在正式環境使用MySQL客戶端和supplier的配置進行連線測試,連線也是沒有問題的。

可能有些小夥伴會有疑惑,如果資料庫連線有問題的話,應該直接就丟擲MySQL連線異常的錯誤了吧,日誌中應該就能體現出來啊。我敢保證,能這麼問的小夥伴肯定沒接手過「飽經滄桑」的老專案。在一些老專案中,並非所有的事情都會朝著預期方向發展,同樣,也不是你想要什麼就能有什麼的。不然,我也就不會寫下這篇文章了。

「下面這句資料庫連線控制程式碼的程式碼,有資料嗎,馮老師?列印給我看看。」

$supplierDb = $this->load->database('supplier', true);

「這個也正常。你能想到的我都試過了。不然也就不會問你了,要看嗎?」

「列印給我看看吧。」

馮老師如是將連線控制程式碼列印給我看。確實,連線控制程式碼也是正常的,列印的都是supplier標識對應的資料庫連線物件資訊。

資料庫連線正常,資料庫也存在資料,查詢卻查不出來?真是見鬼了。似乎事情又陷入了僵局。

看我一籌莫展的樣子,馮老師嘴角揚起一絲邪魅的微笑:「行不行,架構?這頓豬頭肉還能吃上麼?」

「在哪查錯誤日誌資訊?我看看還有漏掉的線索麼。」

「都跟你說了,沒有了,還不信我,我發給你日誌路徑,你再確認一遍。」

馮老給我發了一個日誌路徑:/XXX/log/20231116.CRITICAL.log

我登上伺服器,進到了日誌所在的目錄下。除了馮老師發的日誌以外,我還看到一個20231116.ERROR.log

20231116.CRITICAL.log這個日誌有記錄錯誤資訊嗎?」

「這個……我咋沒注意到有這個日誌呢?」

「……險些又被你給蒙了。你觸發一下錯誤,我看下有沒有日誌。」

馮老師又觸發了一下,我tail了一下錯誤日誌,果然有了新發現:

Unable to select database: supplier
Query error: Table 'manage.ship_template' doesn't exist

這兩行報錯的字面意思是:

  • 無法選擇supplier資料庫
  • manage.ship_template 表不存在

我一眼就發現了端倪:ship_template表的資料庫應該是supplier,而日誌中提示的卻是manage,這裡明顯是有問題的。

我把報錯指出給馮老師看,馮老師也是一臉不解:為啥會使用manage這個資料庫呢?

「資料庫配置檔案中應該有manage庫的配置資訊吧?」我問馮老師。

「有的,老的業務都是走的這個資料庫。我在網上特意查了文件,如果使用額外的資料配置的話,需要先重置現有資料庫連線控制程式碼,然後再載入新的資料庫連線。我也試過這種方式了,也是沒效果。」

其實,到這裡我已經大概知道為什麼查不到資料了。只不過,我還是想不通,為什麼資料庫連線是正常的,而到了真正查詢的階段卻被「調了包」呢?

我打算從報錯的原始碼進行分析。

我決定從Unable to select database這個報錯資訊入手。透過檢索CI的原始碼,不難發現這句報錯的出處:

database/DB_driver.php

...
// Select the DB... assuming a database name is specified in the config file
if ($this->database != '')
{
    if (!$this->db_select())
    {
        log_message('error', 'Unable to select database: '.$this->database);

        if ($this->db_debug)
        {
            $this->display_error('db_unable_to_select', $this->database);
        }
        return FALSE;
    }
    ...
}

該判斷邏輯位於資料庫驅動檔案DB_driver.php的初始化方法initialize()中。

透過觀察不難發現,這個報錯是因為觸發了!$this->db_select()這個判斷。我們再來看看db_select()這個方法做了什麼:

mysql_driver.php

...
function db_select()
{
    return @mysql_select_db($this->database, $this->conn_id);
}
...

這裡可以看出,這個mysql_select_db()方法有兩個引數,一個資料庫名稱,一個連線 ID 。從報錯日誌Unable to select database: supplier可以知道,資料庫名稱沒有問題,那可能有問題的就只有連線 ID 這個欄位了。

我們需要看一下這個連線 ID 是怎麼定義的。

就在!$this->db_select()判斷邏輯之前幾行程式碼的位置,我發現了連線 ID 的定義邏輯,如下:

// Connect to the database and set the connection ID
$this->conn_id = ($this->pconnect == FALSE) ? $this->db_connect() : $this->db_pconnect();

這裡,會根據pconnect的值決定走db_connect()方法還是db_pconnect()方法。

pconnect是讀的資料庫配置。上文配置資訊可以看到,supplierpconnect值配置的是FALSE

$db['supplier']['pconnect'] = FALSE;

所以,這裡會走db_connect()方法的邏輯。讓我們來看看db_connect()長什麼樣吧:

function db_connect()
{
    if ($this->port != '')
    {
        $this->hostname .= ':'.$this->port;
    }

    if(empty(self::$connect)){
        self::$connect = @mysql_connect($this->hostname, $this->username, $this->password, TRUE);
    } 

    return self::$connect;
}

再順便看看db_pconnect()是怎麼實現的吧:

function db_pconnect()
{
    if ($this->port != '')
    {
        $this->hostname .= ':'.$this->port;
    }

    return @mysql_pconnect($this->hostname, $this->username, $this->password);
}

透過對比我們發現,除了 MySQL 的連線方式不同之外,db_connect()方法使用了熟悉的單例模式。所以在同一個請求週期中,靜態變數$connect僅在類載入的時候初始化一次。也就是說,在多資料庫連線的情況下,如果載入過一次資料庫以後,後續的再載入其他資料庫的時候,都會返回之前存在的連線控制程式碼 ID 。這也正好就能解釋通,為什麼supplier選擇資料庫的時候會提示無法選擇資料庫了。

這也正好能解釋,為什麼在資料庫連線沒有報錯,但是無法執行查詢了:

// No connection resource?  Throw an error
if ( ! $this->conn_id)
{
    log_message('error', 'Unable to connect to the database');

    if ($this->db_debug)
    {
        $this->display_error('db_unable_to_connect');
    }
    return FALSE;
}

因為連線控制程式碼$cond_id存的是之前的資料庫連線。所以,這裡驗證能正常透過。

事情到這裡,一切問題似乎已經朝著明朗的方向發展了。

還有個問題需要確認:為什麼測試環境是正常的呢?

我檢視測試環境的資料庫配置資訊,發現如下:

...
$db['manage']['hostname'] = 'XXX';
$db['manage']['username'] = 'XXX';
$db['manage']['password'] = 'XXX';
$db['manage']['database'] = 'manage';
$db['manage']['dbdriver'] = 'mysql';
$db['manage']['dbprefix'] = '';
$db['manage']['pconnect'] = FALSE;

$db['supplier']['hostname'] = 'XXX';
$db['supplier']['username'] = 'XXX';
$db['supplier']['password'] = 'XXX';
$db['supplier']['database'] = 'supplier';
$db['supplier']['dbdriver'] = 'mysql';
$db['supplier']['dbprefix'] = '';
$db['supplier']['pconnect'] = FALSE;
...

原來如此!!!

我知道為什麼了。測試環境用的都是同一套連線資訊,僅僅是資料庫不同而已。所以,當載入新的資料庫時,用之前的資料庫連線控制程式碼依舊能走的通。

為了驗證我的猜想,我在正式環境列印了連線控制程式碼 ID 資訊,結果和我猜想的一致。

事已至此,所有的謎團都已經解開了。雖然我已經胸有成竹,但我依舊保持了冷靜。

「馮老師,想知道怎麼回事嗎?」

馮老師瞪圓了雙眼:「別囉嗦,快說。」

「別急,先來選擇一下套餐。回答問題,三頓豬頭肉。處理問題,五頓豬頭肉。售後全包,十頓豬頭肉。」

「靠……坐地起價啊。」

「沒辦法啊,行情如此啊。」

「那就套餐二吧……」

揭秘時刻

於是,我開始了我的「裝啵兒」時刻。

  1. 首先,根據Unable to select database: supplier這條關鍵的報錯資訊,我們找到報錯位置。經過一層層溯源,我們發現是db_select()這個方法報的錯。

  2. 透過檢視db_select()方法的邏輯,得知是mysql_select_db($this->database, $this->conn_id);這行程式碼出的問題。由日誌可以知道,資料庫沒有問題,只能是連線控制程式碼 ID 有問題。

  3. 我們再來看連線控制程式碼 ID 的設定邏輯。透過$this->conn_id = ($this->pconnect == FALSE) ? $this->db_connect() : $this->db_pconnect();這行程式碼可以得知,連線控制程式碼 ID 是$this->db_connect()方法返回的。

  4. 分析$this->db_connect()實現邏輯,發現用了單例模式。在同一個請求週期內,連線控制程式碼 ID 僅在資料庫驅動類在初次載入時被設定一次,後續呼叫都是返回此連線控制程式碼 ID 。

  5. 所以,即使載入了其他資料庫,即使重新進行了初始化操作,因為單例模式的緣故,導致無法建立最新的連線。而你新建的資料庫連線和其他已有的資料庫連線資訊不一樣,所以用之前的連線控制程式碼 ID 自然也就無法運算元據庫查詢操作。

  6. 至於測試環境為什麼可以,那是因為測試環境都是共用的同一個連線資訊,只是資料庫不同而已,所以「碰巧」能走的通。

聽我一口氣從頭講到尾,馮老師只說了一個字:「靠……」

「那現在應該怎麼處理呢?」

我開始說出我的解決方案。

  1. 首先,根本原因是因為無法建立新的連線導致的。所以,你要想辦法用你新加的連線方式建立連線才可以。

  2. 因為現有的連線方法使用了單例模式,所以外部自然無法在同一個請求週期內改變單例初始化的資料。不過,你可以換成pconnect的連線方式,只需要把配置檔案中的$db['supplier']['pconnect'] = TRUE就可以了。

  3. 據我所知,mysql_connect()mysql_pconnect()的區別是:mysql_pconnect()開啟的是持久的連線,不能使用mysql_close()方法關閉,在cgi執行模式下,兩者並無大的區別,因為每次cgi執行結束後都會銷燬掉資源,所以,切換成pconnect連線方式應無大礙。

聽完我的解釋,馮老師皺了皺眉頭:「我看文件上說,人家說多資料庫連線的情況下,不讓用pconnect連線方式呢?」

「你在哪看的?」

「中文文件啊,我發給你……」

「你這是中文翻譯文件啊,原文件有這句話嗎?」

「肯定是一樣的吧,翻譯的還能不一樣啊?」

「瞅一眼。」

當我們開啟同版本的原文文件,發現還真沒有這句話:

這時,我們回頭去看那句話,發現是「譯註」,也就是說這是翻譯者加上去的一句話。

「靠……連文件也能有假,全世界都在騙我。」

「除了我,馮老師。別忘了,五頓豬頭肉,給你記賬上了。」

馮老師按我說的,調整成pconnect的連線方式,果然正常了。

劇情反轉

經此一役,我也思考了很多東西。

我們用著當下流行的Laravel框架,已經越來越高效,越優雅了。而對比多年前老版本的CI框架,感覺都不像是一個時代的產物,雖說不敢鄙視,但也像遇見多年未曾謀面的老朋友,對方還在用著翻蓋手機一樣驚訝的感覺。

我不想輕易否認前輩的成果,畢竟我也是從那個年代,用著人家的程式碼過來的。現在,我能做的,就是儘可能地沉浸在過去的程式碼中,乘著時光機,去找尋一些當年如此設計的初衷。

為什麼要在底層使用單例模式呢?這樣不就沒法擴充套件了麼?

當我帶著疑問再次審視這段程式碼的時候,我察覺到了異樣:

我本人雖然邋遢,卻有著與外表截然不同的程式碼潔癖。少一個換行,不一致的縮排,甚至多一個空格都會讓我感覺到不安。

當我仔細觀察了幾遍以後,發現第 2 處和第 1 處程式碼花括號的佈局方式,以及if語句小括號的前後空格方式,都存在差異。按道理說,作為當年大火的開源框架,不應該存在這種程式碼書寫規範的問題啊,難不成……?

為了驗證我的猜想,我從網上幾經周折才找到了同版本的CI程式碼。下載下來以後,我迫不及待找到了同樣位置的程式碼,結果令我大跌眼鏡:

function db_connect()
{
    if ($this->port != '')
    {
        $this->hostname .= ':'.$this->port;
    }

    return @mysql_connect($this->hostname, $this->username, $this->password, TRUE);
}

原始版本的程式碼中,並沒有使用單例模式,每次都是重新建立連線並返回。

也就是說,我們用的CI原始碼被動過了……

如此看來,中文文件上說的也就能解釋的通了。文件沒有錯,錯的是「原始碼」,準確的說,是「我們的原始碼」。

我找到馮老師,告訴了他這一切。

馮老師聽完以後,先是一臉不可思議,隨即釋懷一般笑開了花:「你們就玩兒吧,早晚把我玩兒死……哎」

想著這次的賬還沒給馮老師記上,我對馮老師說:「這次草率了,豬頭肉就免了吧,權當為馮老師服務了。」

「不用,記賬上就行,你還是有功勞的,算賞給你的。」

果然還是馮老師。

反思

我對老版本CI的鄙夷很快就隨著劇情的反轉轉移到更改框架原始碼的「公司前輩」身上了,但這種批判也沒有持續很久。

冷靜下來以後,我開始反思這種現狀背後的問題。

可以推測,當時能下定決心去動原始碼肯定也是遇到了什麼瓶頸,我猜是資料庫連線佔用資源的問題(後來從公司老員工那證實確實也是這個原因),所以才在沒有辦法的情況下做了這個調整。

不可否認,在一段時間內,這種做法也確實解決了一部分問題。直到幾年以後,這種平靜,才被馮老師用一板斧給打破了。

但是,現有的秩序已經建立在此規則上了。儘管不合理,我也不敢冒著風險去打破它,至少現在還不會。如果有一天我看清了這座大廈的全貌,我想我可能會挑戰一下。

現階段,我只能告訴馮老師:「世事難料,就先這麼著吧。」

就像福爾摩斯《波西米亞醜聞》一文中說的一樣:

這就是波西米亞王國怎樣受到一樁大丑聞的威脅,而福爾摩斯的傑出計劃又是怎樣為一個女人的聰明才智所挫敗的經過。他過去對女人的聰明機智常常加以嘲笑,近來我很少聽到他這樣的嘲笑了。當他說到艾琳·艾德勒或提到她那張照片時,他總是用那位女人這一尊敬的稱呼。

或許當我以後再提起CI的時候,我想我應該這麼稱呼它:

那個小巧但功能強大的 PHP 框架。

NuTGpfkzNq.jpg!large

本作品採用《CC 協議》,轉載必須註明作者和本文連結
你應該瞭解真相,真相會讓你自由。

相關文章