【MySql】4- 实践篇(二)

文章目录

    • [1. SQL 语句为什么变"慢"了](#1. SQL 语句为什么变“慢”了)
      • [1.1 什么情况会引发数据库的 flush 过程呢?](#1.1 什么情况会引发数据库的 flush 过程呢?)
      • [1.2 四种情况性能分析](#1.2 四种情况性能分析)
      • [1.3 InnoDB 刷脏页的控制策略](#1.3 InnoDB 刷脏页的控制策略)
    • [2. 数据库表的空间回收](#2. 数据库表的空间回收)
      • [2.1 innodb_file_per_table参数](#2.1 innodb_file_per_table参数)
      • [2.2 数据删除流程](#2.2 数据删除流程)
      • [2.3 重建表](#2.3 重建表)
      • [2.4 Online 和 inplace](#2.4 Online 和 inplace)
    • [3. count(*) 语句怎样实现](#3. count(*) 语句怎样实现)
      • [3.1 为何 InnoDB 不把数字存起来呢](#3.1 为何 InnoDB 不把数字存起来呢)
      • [3.2 如何保存操作记录总数](#3.2 如何保存操作记录总数)
        • [3.2.1 用缓存系统保存计数](#3.2.1 用缓存系统保存计数)
        • [3.2.2 在数据库保存计数](#3.2.2 在数据库保存计数)
      • [3.3 不同的 count 用法(基于 InnoDB 引擎)](#3.3 不同的 count 用法(基于 InnoDB 引擎))

1. SQL 语句为什么变"慢"了

InnoDB 在处理更新语句的时候,只做了写日志这一个磁盘操作。这个日志叫作 redo log(重做日志),在更新内存写完 redo log 后,就返回给客户端,本次更新成功。

当内存数据页跟磁盘数据页内容不一致的时候,我们称这个内存页为"脏页"。内存数据写入到磁盘后,内存和磁盘上的数据页的内容就一致了,称为"干净页"。

这里可以通过一个"孔乙己赊账"的案例来分析一下:

假设原来孔乙己欠账 10 文,这次又要赊 9 文。

平时执行很快的更新操作,其实就是在写内存和日志,而 MySQL 偶尔"抖"一下的那个瞬间,可能就是在刷脏页(flush)

1.1 什么情况会引发数据库的 flush 过程呢?

继续用咸亨酒店掌柜的这个例子,想一想:掌柜在什么情况下会把粉板上的赊账记录改到账本上?

  • 第一种场景是,粉板满了,记不下了。这时候如果再有人来赊账,掌柜就只得放下手里的活儿,将粉板上的记录擦掉一些,留出空位以便继续记账。当然在擦掉之前,他必须先将正确的账目记录到账本中才行。

这个场景,对应的就是 InnoDB 的 redo log 写满了。这时候系统会停止所有更新操作,把 checkpoint 往前推进,redo log 留出空间可以继续写

checkpoint 可不是随便往前修改一下位置就可以的。比如图 2 中,把 checkpoint 位置从 CP 推进到 CP',就需要将两个点之间的日志(浅绿色部分),对应的所有脏页都 flush 到磁盘上。之后,图中从 write pos 到 CP'之间就是可以再写入的 redo log 的区域。

  • 第二种场景是,要记住的事情太多,掌柜发现自己快记不住了,赶紧找出账本把孔乙己这笔账先加进去。

这种场景,对应的就是系统内存不足。当需要新的内存页,而内存不够用的时候,就要淘汰一些数据页,空出内存给别的数据页使用。如果淘汰的是"脏页",就要先将脏页写到磁盘。

  • 第三种场景是,生意不忙的时候,或者打烊之后。这时候柜台没事,掌柜闲着也是闲着,不如更新账本。

这种场景,对应的就是 MySQL 认为系统"空闲"的时候。当然,MySQL"这家酒店"的生意好起来可是会很快就能把粉板记满的,所以"掌柜"要合理地安排时间,即使是"生意好"的时候,也要见缝插针地找时间,只要有机会就刷一点"脏页"。

  • 第四种场景是,年底了咸亨酒店要关门几天,需要把账结清一下。这时候掌柜要把所有账都记到账本上,这样过完年重新开张的时候,就能就着账本明确账目情况了。

这种场景,对应的就是 MySQL 正常关闭的情况。这时候,MySQL 会把内存的脏页都 flush 到磁盘上,这样下次 MySQL 启动的时候,就可以直接从磁盘上读数据,启动速度会很快。

1.2 四种情况性能分析

第三种情况是属于 MySQL 空闲时的操作,这时系统没什么压力,而第四种场景是数据库本来就要关闭了。这两种情况下,不会太关注"性能"问题。所以这里,主要来分析一下前两种场景下的性能问题。

  • 第一种是"redo log 写满了,要 flush 脏页",这种情况是 InnoDB 要尽量避免的。因为出现这种情况的时候,整个系统就不能再接受更新了,所有的更新都必须堵住。如果你从监控上看,这时候更新数会跌为 0。
  • 第二种是"内存不够用了,要先将脏页写到磁盘",这种情况其实是常态。
    InnoDB 用缓冲池(buffer pool)管理内存,缓冲池中的内存页有三种状态:
    1. 还没有使用的;
    2. 使用了并且是干净页;
    3. 使用了并且是脏页。

InnoDB 的策略是尽量使用内存,因此对于一个长时间运行的库来说,未被使用的页面很少。

而当要读入的数据页没有在内存的时候,就必须到缓冲池中申请一个数据页。这时候只能把最久不使用的数据页从内存中淘汰掉:如果要淘汰的是一个干净页,就直接释放出来复用;但如果是脏页呢,就必须将脏页先刷到磁盘,变成干净页后才能复用。

刷脏页虽然是常态,但是出现以下这两种情况,都是会明显影响性能的:

  1. 一个查询要淘汰的脏页个数太多,会导致查询的响应时间明显变长;
  2. 日志写满,更新全部堵住,写性能跌为 0,这种情况对敏感业务来说,是不能接受的。

所以,InnoDB 需要有控制脏页比例的机制,来尽量避免上面的这两种情况。

1.3 InnoDB 刷脏页的控制策略

首先,要正确地告诉 InnoDB 所在主机的 IO 能力,这样 InnoDB 才能知道需要全力刷脏页的时候,可以刷多快。

用到 innodb_io_capacity 这个参数了,它会告诉 InnoDB 你的磁盘能力。建议设置成磁盘的 IOPS。

磁盘的 IOPS 可以通过 fio 这个工具来测试

测试磁盘随机读写的命令:

sql 复制代码
 fio -filename=$filename -direct=1 -iodepth 1 -thread -rw=randrw -ioengine=psync -bs=16k -size=500M -numjobs=10 -runtime=10 -group_reporting -name=mytest 

InnoDB 怎么控制引擎按照"全力"的百分比来刷脏页

刷盘速度就是要参考这两个因素:

  • 一个是脏页比例,
  • 一个是 redo log 写盘速度。

参数 innodb_max_dirty_pages_pct 是脏页比例上限,默认值是 75%。

InnoDB 会根据当前的脏页比例(假设为 M),算出一个范围在 0 到 100 之间的数字,伪代码如下:

F1(M)

{

if M>=innodb_max_dirty_pages_pct then

return 100;

return 100*M/innodb_max_dirty_pages_pct;

}

InnoDB 每次写入的日志都有一个序号,当前写入的序号跟 checkpoint 对应的序号之间的差值,我们假设为 N。InnoDB 会根据这个 N 算出一个范围在 0 到 100 之间的数字,这个计算公式可以记为 F2(N)。

F2(N) 算法比较复杂,N 越大,算出来的值越大

根据上述算得的 F1(M) 和 F2(N) 两个值,取其中较大的值记为 R,之后引擎就可以按照 innodb_io_capacity 定义的能力乘以 R% 来控制刷脏页的速度。

流程图如下:

要尽量避免这种查询慢的情况,就要合理地设置 innodb_io_capacity 的值,并且平时要多关注脏页比例,不要让它经常接近 75%。

脏页比例是通过 Innodb_buffer_pool_pages_dirty/Innodb_buffer_pool_pages_total 得到的,具体的命令参考下面的代码:

sql 复制代码
mysql> select VARIABLE_VALUE into @a from global_status where VARIABLE_NAME = 'Innodb_buffer_pool_pages_dirty';
select VARIABLE_VALUE into @b from global_status where VARIABLE_NAME = 'Innodb_buffer_pool_pages_total';
select @a/@b;

一旦一个查询请求需要在执行过程中先 flush 掉一个脏页时,这个查询就可能要比平时慢了。而 MySQL 中的一个机制,可能让你的查询会更慢:在准备刷一个脏页的时候,如果这个数据页旁边的数据页刚好是脏页,就会把这个"邻居"也带着一起刷掉;而且这个把"邻居"拖下水的逻辑还可以继续蔓延,也就是对于每个邻居数据页,如果跟它相邻的数据页也还是脏页的话,也会被放到一起刷。

在 InnoDB 中,innodb_flush_neighbors 参数就是用来控制这个行为的,值为 1 的时会有"连坐"机制,值为 0 时表示不找邻居,仅刷自己。

找"邻居"这个优化在机械硬盘时代是很有意义的,可以减少很多随机 IO。机械硬盘的随机 IOPS 一般只有几百,相同的逻辑操作减少随机 IO 就意味着系统性能的大幅度提升。
如果使用的是 SSD 这类 IOPS 比较高的设备的话,我就建议你把 innodb_flush_neighbors 的值设置成 0

在 MySQL 8.0 中,innodb_flush_neighbors 参数的默认值已经是 0 了。


思考

一个内存配置为 128GB、innodb_io_capacity 设置为 20000 的大规格实例,正常会建议你将 redo log 设置成 4 个 1GB 的文件。

但如果你在配置的时候不慎将 redo log 设置成了 1 个 100M 的文件,会发生什么情况呢?又为什么会出现这样的情况呢?

每次事务提交都要写 redo log,如果设置太小,很快就会被写满,也就是下面这个图的状态,这个"环"将很快被写满,write pos 一直追着 CP。这时候系统不得不停止所有更新,去推进 checkpoint。这时,看到的现象就是磁盘压力很小,但是数据库出现间歇性的性能下跌。


2. 数据库表的空间回收

问题

数据库占用空间太大,我把一个最大的表删掉了一半的数据,怎么表文件的大小还是没变?

一个 InnoDB 表包含两部分,

  • 表结构定义
  • 数据。

在 MySQL 8.0 版本以前,表结构是存在以.frm 为后缀的文件里。

而 MySQL 8.0 版本,则已经允许把表结构定义放在系统数据表中了。因为表结构定义占用的空间很小

2.1 innodb_file_per_table参数

参数 innodb_file_per_table控制表数据是存在共享表空间里,还是单独的文件中。

  1. 这个参数设置为 OFF 表示的是,表的数据放在系统共享表空间,也就是跟数据字典放在一起;
  2. 这个参数设置为 ON 表示的是,每个 InnoDB 表数据存储在一个以 .ibd 为后缀的文件中。

从 MySQL 5.6.6 版本开始,它的默认值就是 ON 了。

建议不论使用 MySQL 的哪个版本,都将这个值设置为 ON。

因为,一个表单独存储为一个文件更容易管理,而且在你不需要这个表的时候,通过 drop table 命令,系统就会直接删除这个文件。而如果是放在共享表空间中,即使表删掉了,空间也是不会回收的

删除整个表的时候,可以使用 drop table 命令回收表空间,但是,遇到的更多的删除数据的场景是删除某些行,这就遇到了开头的问题:表中的数据被删除了,但是表空间却没有被回收。

2.2 数据删除流程

先来看看一个B+ 树索引示意图:

假设,要删掉 R4 这个记录,InnoDB 引擎只会把 R4 这个记录标记为删除。如果之后要再插入一个 ID 在 300 和 600 之间的记录时,可能会复用这个位置。但是,磁盘文件的大小并不会缩小。

现在,已经知道了 InnoDB 的数据是按页存储 的,如果删掉了一个数据页上的所有记录,整个数据页就可以被复用了。但是,数据页的复用跟记录的复用是不同的。

  • 记录的复用,只限于符合范围条件的数据。如上面的例子,R4 这条记录被删除后,如果插入一个 ID 是 400 的行,可以直接复用这个空间。但如果插入的是一个 ID 是 800 的行,就不能复用这个位置了。
  • 而当整个页从 B+ 树里面摘掉以后,可以复用到任何位置。

如果相邻的两个数据页利用率都很小,系统就会把这两个页上的数据合到其中一个页上,另外一个数据页就被标记为可复用。

如果用 delete 命令把整个表的数据删除,所有的数据页都会被标记为可复用。但是磁盘上,文件不会变小。

delete 命令只是把记录的位置,或者数据页标记为了"可复用",但磁盘文件的大小是不会变,通过 delete 命令是不能回收表空间的

不止是删除数据会造成空洞,插入数据也会。

如果数据是按照索引递增顺序插入的,那么索引是紧凑的。但如果数据是随机插入的,就可能造成索引的数据页分裂。

可以看到,由于 page A 满了,再插入一个 ID 是 550 的数据时,就不得不再申请一个新的页面 page B 来保存数据了。

页分裂完成后,page A 的末尾就留下了空洞(注意:实际上,可能不止 1 个记录的位置是空洞)。

更新索引上的值,可以理解为删除一个旧的值,再插入一个新值。这也是会造成空洞的。

大量增删改的表,都是可能是存在空洞的。所以,如果能够把这些空洞去掉,就能达到收缩表空间的目的。而重建表,就可以达到这样的目的。

2.3 重建表

如果有一个表 A,需要做空间收缩,为了把表中存在的空洞去掉,可以新建一个与表 A 结构相同的表 B,然后按照主键 ID 递增的顺序,把数据一行一行地从表 A 里读出来再插入到表 B 中。

由于表 B 是新建的表,所以表 A 主键索引上的空洞,在表 B 中就都不存在了。显然地,表 B 的主键索引更紧凑,数据页的利用率也更高。如果把表 B 作为临时表,数据从表 A 导入表 B 的操作完成后,用表 B 替换 A,从效果上看,就起到了收缩表 A 空间的作用。

可以使用 alter table A engine=InnoDB 命令来重建表,临时表 B (server层创建)不需要自己创建,MySQL 会自动完成转存数据、交换表名、删除旧表的操作。

改锁表 DDL

花时间最多的步骤是往临时表插入数据的过程,如果在这个过程中,有新的数据要写入到表 A 的话,就会造成数据丢失。因此,在整个 DDL 过程中,表 A 中不能有更新。也就是说,这个 DDL 不是 Online 的,MySQL 5.6 版本开始引入的 Online DDL,对这个操作流程做了优化。

Online DDL 之后,重建表的流程:

  1. 建立一个临时文件(存在tmp_file中),扫描表 A 主键的所有数据页;
  2. 用数据页中表 A 的记录生成 B+ 树,存储到临时文件中;
  3. 生成临时文件的过程中,将所有对 A 的操作记录在一个日志文件(row log)中,对应的是图中 state2 的状态;
  4. 临时文件生成后,将日志文件中的操作应用到临时文件,得到一个逻辑数据上与表 A 相同的数据文件,对应的就是图中 state3 的状态;
  5. 用临时文件替换表 A 的数据文件。

Online DDL

上图流程中,alter 语句在启动的时候需要获取 MDL 写锁,但是这个写锁在真正拷贝数据之前就退化成读锁了。

为什么要退化呢?为了实现 Online,MDL 读锁不会阻塞增删改操作。

那为什么不干脆直接解锁呢?为了保护自己,禁止其他线程对这个表同时做 DDL。

对于一个大表来说,Online DDL 最耗时的过程就是拷贝数据到临时表的过程,这个步骤的执行期间可以接受增删改操作。所以,相对于整个 DDL 过程来说,锁的时间非常短。对业务来说,就可以认为是 Online 的。

**重建方法都会扫描原表数据和构建临时文件。对于很大的表来说,这个操作是很消耗 IO 和 CPU 资源的。**因此,如果是线上服务,要很小心地控制操作时间。如果想要比较安全的操作的话,推荐使用 GitHub 开源的 gh-ost 来做。

2.4 Online 和 inplace

表 A 使用Online DDL重建出来的数据是放在"tmp_file"里的,这个临时文件是 InnoDB 在内部创建出来的。

整个 DDL 过程都在 InnoDB 内部完成。对于 server 层来说,没有把数据挪动到临时表,是一个"原地"操作,这就是"inplace"名称的来源。

如果有一个 1TB 的表,现在磁盘间是 1.2TB,能不能做一个 inplace 的 DDL 呢?答案是不能。因为,tmp_file 也是要占用临时空间的。

alter table t engine=InnoDB,其实隐含的意思是:

sql 复制代码
alter table t engine=innodb,ALGORITHM=inplace;

inplace 对应的就是拷贝表的方式了,用法是:

sql 复制代码
alter table t engine=innodb,ALGORITHM=copy;

当使用 ALGORITHM=copy 的时候,表示的是强制拷贝表,对应的流程就是图 《改锁表 DDL》 的操作过程。

Online 和 inplace这两个逻辑之间的关系:

  1. DDL 过程如果是 Online 的,就一定是 inplace 的;
  2. 反过来未必,也就是说 inplace 的 DDL,有可能不是 Online 的。截止到 MySQL 8.0,添加全文索引(FULLTEXT index)和空间索引 (SPATIAL index) 就属于这种情况。
  • 从 MySQL 5.6 版本开始,alter table t engine = InnoDB(也就是 recreate)默认的就是上面图 4 的流程了
  • analyze table t 其实不是重建表,只是对表的索引信息做重新统计,没有修改数据,这个过程中加了 MDL 读锁;
  • optimize table t 等于 recreate+analyze。

思考

假设现在有人碰到了一个"想要收缩表空间,结果适得其反"的情况,看上去是这样的:

  1. 一个表 t 文件大小为 1TB;
  2. 对这个表执行 alter table t engine=InnoDB;
  3. 发现执行完成后,空间不仅没变小,还稍微大了一点儿,比如变成了 1.01TB。

可能是什么原因呢 ?

这个表,本身就已经没有空洞的了,比如说刚刚做过一次重建表操作。在 DDL 期间,如果刚好有外部的 DML 在执行,这期间可能会引入一些新的空洞。

在重建表的时候,InnoDB 不会把整张表占满,每个页留了 1/16 给后续的更新用。也就是说,其实重建表之后不是"最"紧凑的。


3. count(*) 语句怎样实现

在不同的 MySQL 引擎中,count(*) 有不同的实现方式。

  • MyISAM 引擎把一个表的总行数存在了磁盘上,因此执行 count(*) 的时候会直接返回这个数,效率很高;
  • InnoDB 引擎就麻烦了,它执行 count(*) 的时候,需要把数据一行一行地从引擎里面读出来,然后累积计数。

需要注意的是,在这里讨论的是没有过滤条件的 count(*),如果加了 where 条件的话,MyISAM 表也是不能返回得这么快的。

3.1 为何 InnoDB 不把数字存起来呢

因为即使是在同一个时刻的多个查询,由于多版本并发控制(MVCC)的原因,InnoDB 表"应该返回多少行"也是不确定的

举例

假设表 t 中现在有 10000 条记录,我们设计了三个用户并行的会话。

  • 会话 A 先启动事务并查询一次表的总行数;
  • 会话 B 启动事务,插入一行后记录后,查询表的总行数;
  • 会话 C 先启动一个单独的语句,插入一行记录后,查询表的总行数。

假设从上到下是按照时间顺序执行的,同一行语句是在同一时刻执行的。

图 1 会话 A、B、C 的执行流程

在最后一个时刻,三个会话 A、B、C 会同时查询表 t 的总行数,但拿到的结果却不同。

这与InnoDB 的事务设计有关系,可重复读是它默认的隔离级别,在代码上就是通过多版本并发控制,也就是 MVCC 来实现的。每一行记录都要判断自己是否对这个会话可见,因此对于 count(*) 请求来说,InnoDB 只好把数据一行一行地读出依次判断,可见的行才能够用于计算"基于这个查询"的表的总行数。

count(*) 操作的优化
InnoDB 是索引组织表 ,主键索引树的叶子节点是数据,而普通索引树的叶子节点是主键值。所以,普通索引树比主键索引树小很多。对于 count(*) 这样的操作,遍历哪个索引树得到的结果逻辑上都是一样的。因此,MySQL 优化器会找到最小的那棵树来遍历。在保证逻辑正确的前提下,尽量减少扫描的数据量,是数据库系统设计的通用法则之一。

show table status 命令的输出结果里面也有一个 TABLE_ROWS,索引统计的值是通过采样来估算的。实际上,TABLE_ROWS 就是从这个采样估算得来的,因此它也很不准。官方文档说误差可能达到 40% 到 50%。所以,show table status 命令显示的行数也不能直接使用。

小结一下:

  • MyISAM 表虽然 count(*) 很快,但是不支持事务;
  • show table status 命令虽然返回很快,但是不准确;
  • InnoDB 表直接 count(*) 会遍历全表,虽然结果准确,但会导致性能问题。

3.2 如何保存操作记录总数

3.2.1 用缓存系统保存计数

可以用一个 Redis 服务来保存这个表的总行数。这个表每被插入一行 Redis 计数就加 1,每被删除一行 Redis 计数就减 1。这种方式下,读和更新操作都很快,但缓存系统可能会丢失更新。

将计数保存在缓存系统中的方式,还不只是丢失更新的问题。即使 Redis 正常工作,这个值还是逻辑上不精确的。

举例

设想一下有这么一个页面,要显示操作记录的总数,同时还要显示最近操作的 100 条记录。那么,这个页面的逻辑就需要先到 Redis 里面取出计数,再到数据表里面取数据记录。

会出现一下两种情况:

  1. 查到的 100 行结果里面有最新插入记录,而 Redis 的计数里还没加 1;
  2. 查到的 100 行结果里没有最新插入的记录,而 Redis 的计数里已经加了 1。

时序图如下:


在并发系统里面,我们是无法精确控制不同线程的执行时刻的,因为存在图中的这种操作序列,所以说即使 Redis 正常工作,这个计数值还是逻辑上不精确的。

3.2.2 在数据库保存计数

会话 B 的读操作仍然是在 T3 执行的,但是因为这时候更新事务还没有提交,所以计数值加 1 这个操作对会话 B 还不可见。因此,会话 B 看到的结果里, 查计数值和"最近 100 条记录"看到的结果,逻辑上就是一致的。

3.3 不同的 count 用法(基于 InnoDB 引擎)

  • count() 的语义

count() 是一个聚合函数,对于返回的结果集,一行行地判断,如果 count 函数的参数不是 NULL,累计值就加 1,否则不加。最后返回累计值。

count(*)、count(主键 id) 和 count(1) 都表示返回满足条件的结果集的总行数;而 count(字段),则表示返回满足条件的数据行里面,参数"字段"不为 NULL 的总个数。

对于 count(主键 id) 来说,InnoDB 引擎会遍历整张表,把每一行的 id 值都取出来,返回给 server 层。server 层拿到 id 后,判断是不可能为空的,就按行累加。

对于 count(1) 来说,InnoDB 引擎遍历整张表,但不取值。server 层对于返回的每一行,放一个数字"1"进去,判断是不可能为空的,按行累加。

对比出来,count(1) 执行得要比 count(主键 id) 快。因为从引擎返回 id 会涉及到解析数据行,以及拷贝字段值的操作。

对于 count(字段) 来说:

  1. 如果这个"字段"是定义为 not null 的话,一行行地从记录里面读出这个字段,判断不能为 null,按行累加;
  2. 如果这个"字段"定义允许为 null,那么执行的时候,判断到有可能是 null,还要把值取出来再判断一下,不是 null 才累加。

count(*) 是例外,并不会把全部字段取出来,而是专门做了优化,不取值。count(*) 肯定不是 null,按行累加。

结论

按照效率排序的话,count(字段)<count(主键 id)<count(1)≈count( * ),所以建议,尽量使用 count(*)。


思考

由于事务可以保证中间结果不被别的事务读到,因此修改计数值和插入新记录的顺序是不影响逻辑结果的。

但是,从并发系统性能的角度考虑,在这个事务序列里,应该先插入操作记录,还是应该先更新计数表呢?

并发系统性能的角度考虑,应该先插入操作记录,再更新计数表。因为更新计数表涉及到行锁的竞争,先插入再更新能最大程度地减少事务之间的锁等待,提升并发度


来自《MySQL实战45讲》林晓斌

相关推荐
SilentSamsara5 分钟前
RAG 系统入门:LangChain/LlamaIndex + Chroma 向量数据库的检索增强实战
数据库·人工智能·python·青少年编程·langchain
RoboWizard8 分钟前
一块硬盘上架前要闯多少关?
java·服务器·数据库
oyyanghh9 分钟前
从Cursor到TRAE的三周vibe coding体验对比
数据库·oracle
Bert.Cai14 分钟前
Oracle CONCAT函数详解
数据库·oracle
豆豆21 分钟前
2026年如何选择适合自己的网站管理系统?
数据库·cms·wordpress·建站系统·网站管理系统·建站软件·织梦
xuefuhe26 分钟前
MySQL8.4 tar.xz安装
mysql
吴声子夜歌1 小时前
SQL经典实例——检索记录
数据库·sql
黄焖鸡能干四碗1 小时前
软件系统概要设计说明书模版(Word)
大数据·运维·数据库·架构·需求分析
dust_and_stars1 小时前
为什么ubuntu24 snap install code-server 不需要--classic?
网络·数据库
BomanGe21 小时前
NSK W1406FA系列长行程高速精密丝杠技术指南
运维·服务器·数据库·经验分享·规格说明书