冯老师的困惑 —— 测试和正式环境掐架篇(二)

冯老师的困惑

又一个阳光明媚的下午,下班时间已过,我刚收拾好东西准备回家,远远看见冯老师又踱步向我走来。

「一块加个班吧,请你吃猪头肉。」

「咋了,又遇到问题了么?冯老师」

「知子莫若父啊……懂我,来帮我看看」

眼瞅着来业务了,我自然不能放过。于是,我看了一眼手机:「六点过五分了,冯老师。记好时间~」

「你来看,又是一个线上环境和测试环境表现不一致的问题……」

「额,咋这些奇葩问题都让你遇见了……」

「少废话,赶紧帮我看看,代码已经发布到线上去了,现在线上一访问就报一个数据库错误呢,测试环境都是正常的。」

「报什么错误?」

冯老师把错误日志调出来给我看,看着像是数据库底层代码报出来的:

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 协议》,转载必须注明作者和本文链接
你应该了解真相,真相会让你自由。
本帖由系统于 5个月前 自动加精
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
《L01 基础入门》
我们将带你从零开发一个项目并部署到线上,本课程教授 Web 开发中专业、实用的技能,如 Git 工作流、Laravel Mix 前端工作流等。
讨论数量: 12

沙发......

7个月前 评论
快乐的皮拉夫 (楼主) 7个月前

下次一起吃猪头肉

7个月前 评论
快乐的皮拉夫 (楼主) 7个月前

有微信群吗

7个月前 评论
快乐的皮拉夫 (楼主) 7个月前
green_hand (作者) 7个月前

你到底是小说家还是程序员啊?

7个月前 评论
快乐的皮拉夫 (楼主) 7个月前

最后这张图就是皮拉夫侦探吗?

6个月前 评论
快乐的皮拉夫 (楼主) 6个月前

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!
文章
38
粉丝
110
喜欢
653
收藏
719
排名:273
访问:3.5 万
私信
所有博文
社区赞助商