深入理解MySQL 5.7 GTID系列(六):MySQL启动初始化GTID模块

本文涉及的产品
云数据库 RDS MySQL Serverless,0.5-2RCU 50GB
简介:

本节也是一个重头戏,后面的故障案例也和本节有关。本节将详细介绍Gtid模块的初始化,以及什么时候读取了我们前文提及的两个GTID持久化介质:

BINLOG文件
mysql.gtid_executed

此外也会描述他们的读取方式。
同时分析这个步骤我也将在重点步骤分为两种情况来分别讨论:

  1. 主库开启GTID开启BINLOG
  2. 从库开启GTID开启BINLOG不开启log_slave_updates参数。

因为这两种使我们通常设置的方式,下面简称主库从库

一、初始化GTID模块全局变量内存空间

首先初始化GTID几个GLOBAL内存空间包括 gtid_state\sid_map\gtid_table_persistor这个调用由mysqld.cc调入gtid_server_init()

if (init_server_components())
 unireg_abort(MYSQLD_ABORT_EXIT);

其中init_server_components()会初始化很多模块GTID只是其中很小的一个,INNODB就在这里初始化。

gtid_server_init()函数片段如下:

(!(global_sid_lock= new Checkable_rwlock(
#ifdef HAVE_PSI_INTERFACE
 key_rwlock_global_sid_lock
#endif
 )) ||
 !(gtid_mode_lock= new Checkable_rwlock(
#ifdef HAVE_PSI_INTERFACE
 key_rwlock_gtid_mode_lock
#endif
 )) ||
 !(global_sid_map= new Sid_map(global_sid_lock)) || //new一个内存Sid_map内存空间出来
 !(gtid_state= new Gtid_state(global_sid_lock, global_sid_map))||//new一个内存Gtid_state内存空间出来
 !(gtid_table_persistor= new Gtid_table_persistor()));//new一个内存Gtid_table_persistor内存空间出来

二、初始化获得本数据库的server_uuid

这个初始化过程在前文提到了,无非就是通过auto.cnf获得server_uuid,如果没有则重新生成,具体可以参考一下前文这里不再过多描述。

if (init_server_auto_options())
 {
 sql_print_error("Initialization of the server's UUID failed because it could"
 " not be read from the auto.cnf file. If this is a new"
 " server, the initialization failed because it was not"
 " possible to generate a new UUID.");
 unireg_abort(MYSQLD_ABORT_EXIT);
 }

三、初始化GTID_STATE全局结构

global_sid_lock->rdlock();
 int gtid_ret= gtid_state->init();//将server_uuid对应的sid(Uuid)和sidno加入到
Sid_map中。
 global_sid_lock->unlock();

 if (gtid_ret)
 unireg_abort(MYSQLD_ABORT_EXIT);

其实本步骤也是完成了sidno的加入sid_map中,有兴趣的可以参考int gtid_state::init()函数逻辑非常简单。

四、读取mysql.gtid_executed

这一步开始读取我们的第一个GTID持久化介质mysql.gtid_executed表,其最终调用为gtid_table_persistor::fetch_gtids(gtid_set *gtid_set)其原理为一行一行的读取mysql.gtid_executed表的内容加入到gtid_state.executed_gtids中,我们来看源码:

// Initialize executed_gtids from mysql.gtid_executed table.
 if (gtid_state->read_gtid_executed_from_table() == -1)
 unireg_abort(1);

gtid_state::read_gtid_executed_from_table只是一层简单的封装如下:

int Gtid_state::read_gtid_executed_from_table()
{
 return gtid_table_persistor->fetch_gtids(&executed_gtids);
}

接下来看看gtid_table_persistor::fetch_gtids(gtid_set *gtid_set)函数逻辑片段:

if ((err= table->file->ha_rnd_init(true)))
 {
 ret= -1;
 goto end;
 }

 while(!(err= table->file->ha_rnd_next(table->record[0]))) //开始一行一行读取数据
 {
 /* Store the gtid into the gtid_set */

 /**
 @todo:
 - take only global_sid_lock->rdlock(), and take
 gtid_state->sid_lock for each iteration.
 - Add wrapper around Gtid_set::add_gno_interval and call that
 instead.
 */
 global_sid_lock->wrlock();
 if (gtid_set->add_gtid_text(encode_gtid_text(table).c_str()) != //此处将读取到的一行Gtid区间加入到Gtid_state.executed_gtids中。
 RETURN_STATUS_OK)
 {
 global_sid_lock->unlock();
 break;
 }
 global_sid_lock->unlock();
 }

完成本步骤过后gtid_state.executed_gtids将设置,主库和从库的设置不同

  • 主库因为mysql.gtid_executed只包含上一个BINLOG的全部GTID所以,它不包含最新BINLOGGTID,所以在第七步还需要修正这个值。
  • 从库因为mysql.gtid_executed会实时更新,因此它包含了全部的GTID

五、定义中间变量和获得指针

本步骤是一个非关键步骤但是定义了一些中间变量而且定义了4个指针来分别获得GTID_STATE四个内存变量的地址,方便操作。

if (opt_bin_log) //如果binlog开启
 {
 /*
 Initialize GLOBAL.GTID_EXECUTED and GLOBAL.GTID_PURGED from
 gtid_executed table and binlog files during server startup.
 */
 Gtid_set *executed_gtids=
 const_cast<Gtid_set *>(gtid_state->get_executed_gtids());//获得Gtid_state.executed_gtids的指针
 Gtid_set *lost_gtids=
 const_cast<Gtid_set *>(gtid_state->get_lost_gtids());//获得gtid_state.get_lost_gtids的指针
 Gtid_set *gtids_only_in_table=
 const_cast<Gtid_set *>(gtid_state->get_gtids_only_in_table());//获得gtid_state.get_lost_gtids的指针
 Gtid_set *previous_gtids_logged=
 const_cast<Gtid_set *>(gtid_state->get_previous_gtids_logged());//获得gtid_state.previous_gtids_logged的指针

 Gtid_set purged_gtids_from_binlog(global_sid_map, global_sid_lock);//定义临时变量用于存储从binlog中扫描到已经丢弃的Gtid事务。
 Gtid_set gtids_in_binlog(global_sid_map, global_sid_lock);//定义中间变量binlog中包含的所有Gtid事务包括丢弃的。
 Gtid_set gtids_in_binlog_not_in_table(global_sid_map, global_sid_lock);//定义中间变量没有存放在表中而在binlog中存在过的Gtid事务,
//显然主库包含这样一个集合,因为主库的gtids_in_binlog>gtids_only_in_table,而从库同样也不包含这样一个集合因为从库的全部Gtid事务都在表中。

六、读取BINLOG文件

本步骤将会读取我们提及的第二个GTID持久化介质BINLOG,其读取方式为先反向读取获得gtids_in_binlog然后正向读取获得 purged_gtids_from_binlog,并且这里正向读取purged_gtids_from_binlog将会受到binlog_gtid_simple_recovery参数的影响。同时我们前文所描述5.7 中previous gtid event会在没有开启GTIDBINLOG也包含这个EVENT,将在这部体现出它的价值。

if (mysql_bin_log.init_gtid_sets(>ids_in_binlog,
 &purged_gtids_from_binlog,
 opt_master_verify_checksum,
 true/*true=need lock*/,
 NULL/*trx_parser*/,
 NULL/*gtid_partial_trx*/,
 true/*is_server_starting*/))

我们发现他实际上就是调用bool mysql_bin_log::init_gtid_sets()函数我们继续看这个函数重要代码片段:

 list<string> filename_list; //定义一个string list来存储文件名
 LOG_INFO linfo;
 int error;

 list<string>::iterator it;//定义一个list的正向迭代器
 list<string>::reverse_iterator rit;//定义一个list的反向迭代器

for (error= find_log_pos(&linfo, NULL, false/*need_lock_index=false*/); !error; //这部分实际上就是将文件名全部加入到这个list中
 error= find_next_log(&linfo, false/*need_lock_index=false*/))
 {
 DBUG_PRINT("info", ("read log filename '%s'", linfo.log_file_name));
 filename_list.push_back(string(linfo.log_file_name));
 }
 if (error != LOG_INFO_EOF)
 {
 DBUG_PRINT("error", ("Error reading %s index",
 is_relay_log ? "relaylog" : "binlog"));
 goto end;
 }

 if (all_gtids != NULL) //数据库启动初始化的情况下all_gtids不会为NULL,但是如果是做purge binary logs命令等删除binlog log all_gtid会传入NULL
 {
 rit= filename_list.rbegin(); //反向迭代器指向list尾部
 bool can_stop_reading= false;
 reached_first_file= (rit == filename_list.rend());//如果只有一个binlog则为true
 while (!can_stop_reading && !reached_first_file) //开始反向循环扫描来获得gtids_in_binlog(all_gtids)集合
 {
 const char *filename= rit->c_str(); //获取文件名
 rit++;
 reached_first_file= (rit == filename_list.rend());//如果达到第一个文件则为true表示扫描完成
 switch (read_gtids_from_binlog(filename, all_gtids,
 reached_first_file ? lost_gtids : NULL,
 NULL/* first_gtid */,
 sid_map, verify_checksum, is_relay_log)) //通过函数read_gtids_from_binlog读取这个binlog文件
 {
 case ERROR:
 {
 error= 1;
 goto end;
 }
 case GOT_GTIDS: //如果扫描本binlog有PREVIOUS GTID EVENT和GTID EVENT 则break 跳出循环且设置can_stop_reading= true
 {
 can_stop_reading= true;
 break;
 }
 case GOT_PREVIOUS_GTIDS://如果扫描本binlog只有PREVIOUS GTID EVENT 则进入逻辑判断
 {
 if (!is_relay_log)//我们只考虑binlog 不会是relaylog 那么 break 跳出循环且设置can_stop_reading= true,
 //注意这里并不受到binlog_gtid_simple_recovery参数的影响,我们知道5.7.5过后每一个binlog都
 //包含了PREVIOUS GTID EVENT实际上即使没有开启GTID这里也会跳出循环,则只是扫描了最后一个binlog 文件
 can_stop_reading= true;
 break;
 }
 case NO_GTIDS: //如果没有找到PREVIOUS GTID EVENT和GTID EVENT 则做如下逻辑,实际上5.7过后不可能出现这种问题,因为必然包含了PREVIOUS GTID EVENT
 //即便是没有开启GTID,所以反向查找一定会在扫描最后一个文件后跳出循环
 {
 if (binlog_gtid_simple_recovery && is_server_starting &&
 !is_relay_log) //这里受到了binlog_gtid_simple_recovery参数的影响,但是我们知道这个分支是不会执行的。除非这个数据库是升级的并且没有开启Gtid
 {
 DBUG_ASSERT(all_gtids->is_empty());//断言all_gtids还是没有找到
 DBUG_ASSERT(lost_gtids->is_empty());//断言lost_gtids还是没有找到
 goto end;//结束扫描,从这里我们发现如果mysql是升级而来的一定要注意这个问题,设置binlog_gtid_simple_recovery可能拿不到正确的GTID,对于升级
 //最好使用master-slave 进行升级,可以规避这个风险。
 }
 /*FALLTHROUGH*/
 }
 case TRUNCATED:
 {
 break;
 }
 }
 }
//中间还有一部分处理relaylog的占时没有去研究接下来就是正向查找获得purged_gtids_from_binlog(lost_gtids)

 if (lost_gtids != NULL && !reached_first_file)//如果前面的扫描没有扫描完全部的binlog,这实际在5.7中是肯定的。
 {

 for (it= filename_list.begin(); it != filename_list.end(); it++)//进行正向查找
 {
 /*
 We should pass a first_gtid to read_gtids_from_binlog when
 binlog_gtid_simple_recovery is disabled, or else it will return
 right after reading the PREVIOUS_GTIDS event to avoid stall on
 reading the whole binary log.
 */
 Gtid first_gtid= {0, 0};
 const char *filename= it->c_str();//获得文件名指针
 switch (read_gtids_from_binlog(filename, NULL, lost_gtids,
 binlog_gtid_simple_recovery ? NULL :
 &first_gtid,
 sid_map, verify_checksum, is_relay_log))
 {
 case ERROR:
 {
 error= 1;
 /*FALLTHROUGH*/
 }
 case GOT_GTIDS: //如果扫描本binlog有PREVIOUS GTID EVENT和GTID EVENT 则跳出循环直达end
 {
 goto end;
 }
 case NO_GTIDS: //这里如果binlog不包含GTID EVENT和PREVIOUS GTID EVENT其处理逻辑一致
 case GOT_PREVIOUS_GTIDS:
 {
 if (binlog_gtid_simple_recovery) //这里受到了binlog_gtid_simple_recovery。如果设置为ON,实际上在5.7过后
 goto end; //PREVIOUS GTID EVENT是一定命中的,可以得到正确的结果,但是如果是5.6升级而来
 /*FALLTHROUGH*/ //则binlog不包含PREVIOUS GTID EVENT则purged_gtids_from_binlog(lost_gtids)获取为空
 //如果在5.7中关闭了GTID,这种情况这里虽然PREVIOUS GTID EVENT命中但是任然
 //不会跳出循环goto end,继续下一个文件扫描。
 } 
 case TRUNCATED:
 {
 break;
 }
 }
 }

到这里我们分析了反向查找和正向查找,我们代码注释上也说明了binlog_gtid_simple_recovery作用,因为有了PREVIOUS GTID EVENT的支持,5.7.6过后这个参数默认都是设置为TRUE,如果在GTID关闭的情况下设置binlog_gtid_simple_recoveryFLASE可能需要扫描大量的BINLOG才会确定purged_gtids_from_binlog这个集合,这可能出现在两个地方:

  • 如这里讨论的MySQL重启的时候。
  • 如前文所讨论在purge binary logs to或者操作参数expire_logs_days设置的时间删除BINLOG的时候。

这里也是我后文描述的第二个案例出现的原因。

正常情况下到这里我们的gtids_in_binlogpurged_gtids_from_binlog已经获取:

  • 主库gtids_in_binlog包含了所有最新的GTID事务(包含丢弃的)而purged_gtids_from_binlog包含了已经丢弃的GTID事务。
  • 从库压根没有BINLOG因此gtids_in_binlogpurged_gtids_from_binlog为空集合。

七、对gtid_state.executed_gtidsmysql.gtid_executed表的修正

如第四步描述主库通过读取mysql.gtid_executed表获得的gtid_state.executed_gtids并不是最新的,所以整理需要修正,代码如下:

if (!gtids_in_binlog.is_empty() && //如果gtids_in_binlog不为空,从库为空不走这个逻辑了,这里主要是主库对Gtid_state.executed_gtids的修正
 !gtids_in_binlog.is_subset(executed_gtids)) //并且executed_gtids是gtids_in_binlog的子集
 {
 gtids_in_binlog_not_in_table.add_gtid_set(>ids_in_binlog);
 if (!executed_gtids->is_empty())
 gtids_in_binlog_not_in_table.remove_gtid_set(executed_gtids); //将不在表中的GTID及gtids_in_binlog-executed_gtids 加入到gtids_in_binlog_not_in_table
 if (gtid_state->save(>ids_in_binlog_not_in_table) == -1)//这里将gtids_in_binlog_not_in_table这个Gtid集合存储到mysql.gtid_executed表中完成修正
 {
 global_sid_lock->unlock();
 unireg_abort(MYSQLD_ABORT_EXIT);
 }
 executed_gtids->add_gtid_set(>ids_in_binlog_not_in_table);//最后在executed_gtids中加入这个gtids_in_binlog_not_in_table,这个完成executed_gtids就是最新的Gtid_set了,完成了Gtid_state.executed_gtids的修正
 }

这一步完全是主库才会触发的逻辑:

  • 主库完成这一步gtid_state.executed_gtidsmysql.gtid_executed表都将修正到最新的GTID集合。
  • 从库不做这个逻辑,因为从库的gtid_state.executed_gtidsmysql.gtid_executed表本来就是最新的。

到这里gtid_state.executed_gtids也就是我们的gtid_executed变量初始化已经完成mysql.gtid_executed表已经修正。

八、初始化gtid_state.gtids_only_in_table

由于上一步已经获得了完整的的gtid_state.executed_gtids 集合,这里获得gtid_state.gtids_only_in_table只需要简单的gtids_only_in_table= executed_gtids - gtids_in_binlog相减即可。

/* gtids_only_in_table= executed_gtids - gtids_in_binlog */
 if (gtids_only_in_table->add_gtid_set(executed_gtids) != //这里将executed_gtids加入到gtids_only_in_table
 RETURN_STATUS_OK)
 {
 global_sid_lock->unlock();
 unireg_abort(MYSQLD_ABORT_EXIT);
 }
 gtids_only_in_table->remove_gtid_set(>ids_in_binlog); //这里将去掉gtids_in_binlog

这一步主库和从库如下:

  • 主库由于存在gtid_state.executed_gtids是最新的同时gtids_in_binlog也是最新的所以gtid_state. gtids_only_in_table是一个空集合。
  • 从库由于gtid_state.executed_gtids是最新的但是gtids_in_binlog是一个空集合,所以gtid_state. gtids_only_in_tablegtid_state.executed_gtids相等。

九、初始化gtid_state.lost_gtids

这一步开始获取gtid_state.lost_gtids也就是我们的gtid_purged变量,这里只需要简单的用gtid_state.gtids_only_in_table + purged_gtids_from_binlog;即可,他们都已经获取

/*
 lost_gtids = executed_gtids -
 (gtids_in_binlog - purged_gtids_from_binlog)
 = gtids_only_in_table + purged_gtids_from_binlog;
 */

 if (lost_gtids->add_gtid_set(gtids_only_in_table) != RETURN_STATUS_OK || //将gtids_only_in_table这个集合加入lost_gtids
 lost_gtids->add_gtid_set(&purged_gtids_from_binlog) != //将purged_gtids_from_binlog加入到这个集合
 RETURN_STATUS_OK)
 {
 global_sid_lock->unlock();
 unireg_abort(MYSQLD_ABORT_EXIT);
 }

这一步主库和从库如下:

  • 主库由于gtid_state.gtids_only_in_table为空集合,而purged_gtids_from_binlog则是获取的第一个binlog Previous gtid eventGTID。所以正常情况下gtid_state.lost_gtids就等于第一个BINLOGbinlog previous gtid event 的GTID
  • 从库由于gtid_state.gtids_only_in_tablegtid_state.executed_gtids相等而purged_gtids_from_binlog是空集合,所以正常情况下从库的gtid_state.lost_gtids就等于就等于gtid_state.executed_gtids。也就是gtid_purged变量和gtid_executed变量相等。

到这里gtid_purged变量和gtid_executed变量以及mysql.gtid_executed表都已经初始化完成。

十、初始化gtid_state.previous_gtids_logged

这个值没有变量能够看到,它代表是直到上一个BINLOG所包含的全部的BINLOG GTID

/* Prepare previous_gtids_logged for next binlog */
 if (previous_gtids_logged->add_gtid_set(>ids_in_binlog) !=//很明显将扫描到的gtids_in_binlog的这个集合加入即可。
 RETURN_STATUS_OK)
 {
 global_sid_lock->unlock();
 unireg_abort(MYSQLD_ABORT_EXIT);
 }

很明显因为启动的时候BINLOG会切换所以简单的将扫描到gtids_in_binlog加入到集合即可。
这一步主库和从库如下:

  • 主库gtids_in_binlog包含全部GTID事务。所以gtid_state.previous_gtids_logged就包含全部BINLOG中的GTID事务。但是之后会做BINLOG切换。
  • 从库gtids_in_binlog为空,显然gtid_state.previous_gtids_logged也为空。

十一、本节小结

通过读取mysql.gtid_executedBINLOG,然后经过一系列的运算后,我们的GTID模块初始化完成。4个内存变量和mysql.gtid_executed都得到了初始化,总结如下:

1 mysql.gtid_executed
  • 主库在第四步读取,在第七步的修正完成初始化,它包含了现有的全部的GTID事务。
  • 从库在第四步读取,因为从库mysql.gtid_executed本来就是最新的不需要更改。
2 gtid_state.executed_gtids和它对应了变量gtid_executed
  • 主库通过第四步和第七步将Gtid_state.executed_gtids初始化,它包含了全部的Gtid事务。
  • 从库通过第四步就已经全部初始化完成,它包含了现有的全部的Gtid事务。
3 gtid_state.gtids_only_in_table
  • 主库通过第八步进行初始化,主库gtid_state. gtids_only_in_table是一个空集合。
  • 从库通过第八步进行初始化,从库gtid_state. gtids_only_in_tablegtid_state.executed_gtids相等。
4 Gtid_state.lost_gtids 它对应了变量 gtid_purged。
  • 主库通过第九步进行初始化,gtid_state.lost_gtids就是第一个binlog previous gtid eventGTID
  • 从库通过第九步进行初始化,gtid_state.lost_gtids等于gtid_state.executed_gtids
5 gtid_state.previous_gtids_logged。
  • 主库通过第十步进行初始化,gtid_state.previous_gtids_logged就包含全部BINLOG中的GTID事务
  • 从库通过第十步进行初始化,gtid_state.previous_gtids_logged为空集合。

注意本节第五步包含了BINLOG文件的读取方法以及binlog_gtid_simple_recovery参数的作用

学习完本节至少能够学习到:

  • 1、mysql.gtid_executed是如何以及何时读取的。
  • 2、BINLOG文件中的GTID何时读取。
  • 3、整个GTID模块的初始化流程及细节。
  • 4、binlog_gtid_simple_recovery参数的作用。
原文发布时间为:2018-02-5
本文作者:高鹏(重庆八怪)
本文来自云栖社区合作伙伴“ 老叶茶馆”,了解相关信息可以关注“ 老叶茶馆”微信公众号
相关实践学习
基于CentOS快速搭建LAMP环境
本教程介绍如何搭建LAMP环境,其中LAMP分别代表Linux、Apache、MySQL和PHP。
全面了解阿里云能为你做什么
阿里云在全球各地部署高效节能的绿色数据中心,利用清洁计算为万物互联的新世界提供源源不断的能源动力,目前开服的区域包括中国(华北、华东、华南、香港)、新加坡、美国(美东、美西)、欧洲、中东、澳大利亚、日本。目前阿里云的产品涵盖弹性计算、数据库、存储与CDN、分析与搜索、云通信、网络、管理与监控、应用服务、互联网中间件、移动服务、视频服务等。通过本课程,来了解阿里云能够为你的业务带来哪些帮助 &nbsp; &nbsp; 相关的阿里云产品:云服务器ECS 云服务器 ECS(Elastic Compute Service)是一种弹性可伸缩的计算服务,助您降低 IT 成本,提升运维效率,使您更专注于核心业务创新。产品详情: https://www.aliyun.com/product/ecs
相关文章
|
14天前
|
关系型数据库 MySQL Apache
mysql5.7 本地计算机上的mysql 服务启动后停止 的问题解决
mysql5.7 本地计算机上的mysql 服务启动后停止 的问题解决
11 0
|
3月前
|
存储 关系型数据库 MySQL
升级宝典!阿里云RDS MySQL助力MySQL5.7升级到8.0
2023年10月,社区MySQL5.7停服。阿里云RDS MySQL对MySQL5.7的服务将进行到2024年10月21日,同时,并将通过有效的方案和大量的升级经验,鼓励和助力广大企业和开发者将MySQL5.7升级到MySQL8.0。
|
4月前
|
安全 关系型数据库 MySQL
Linux 实用小脚本系列(2)----mysql安全初始化脚本的免交互执行--mysql_secure_installation
Linux 实用小脚本系列(2)----mysql安全初始化脚本的免交互执行--mysql_secure_installation
49 0
|
5月前
|
NoSQL 关系型数据库 MySQL
Docker-compose封装mysql和redis并初始化数据
Docker-compose封装mysql和redis并初始化数据
128 0
|
1月前
|
关系型数据库 MySQL 数据库
初始化RDS实例
初始化RDS实例
15 3
|
1月前
|
存储 DataWorks 关系型数据库
购买和初始化阿里云RDS
购买和初始化阿里云RDS
26 3
|
2月前
|
消息中间件 关系型数据库 MySQL
使用Nginx的stream模块实现MySQL反向代理与RabbitMQ负载均衡
使用Nginx的stream模块实现MySQL反向代理与RabbitMQ负载均衡
61 0
|
2月前
|
资源调度 JavaScript 关系型数据库
Node.js【文件系统模块、路径模块 、连接 MySQL、nodemon、操作 MySQL】(三)-全面详解(学习总结---从入门到深化)
Node.js【文件系统模块、路径模块 、连接 MySQL、nodemon、操作 MySQL】(三)-全面详解(学习总结---从入门到深化)
33 0
|
3月前
|
关系型数据库 MySQL 数据库
MySQL安装配置初始化 Windows10安装8.0.23版本
MySQL是应用最广泛、普及度最高的开源关系型数据库。
38 0
|
3月前
|
资源调度 JavaScript 关系型数据库
Node.js【文件系统模块、路径模块 、连接 MySQL、nodemon、操作 MySQL】(三)-全面详解(学习总结---从入门到深化)(下)
Node.js【文件系统模块、路径模块 、连接 MySQL、nodemon、操作 MySQL】(三)-全面详解(学习总结---从入门到深化)
19 0