摘要

本博文主要是介绍的在大厂面试过程中涉及到数据库中相关的问题和解答。帮助大家更好的学习与连接数据库相关的原理。

一、数据的索引方面问题

如果一个高配的机器,redo log 设置太小,会发生什么情况。

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

如果某次写入使用了 change buffer 机制,之后主机异常重启,是否会丢失 change buffer 和数据。

这个问题的答案是不会丢失,虽然是只更新内存,但是在事务提交的时候,我们把 change buffer 的操作也记录到 redo log 里了,所以崩溃恢复的时候,change buffer 也能找回来。

如果是merge 的过程是否会把数据直接写回磁盘。

merge 的执行流程是这样的:

  1. 从磁盘读入数据页到内存(老版本的数据页);
  2. 从 change buffer 里找出这个数据页的 change buffer 记录 (可能有多个),依次应用,得到新版数据页;
  3. 写 redo log。这个 redo log 包含了数据的变更和 change buffer 的变更。

到这里 merge 过程就结束了。这时候,数据页和内存中 change buffer 对应的磁盘位置都还没有修改,属于脏页,之后各自刷回自己的物理数据,就是另外一个过程了。

为什么经过这个操作序列,explain 的结果就不对了

delete 语句删掉了所有的数据,然后再通过 call idata() 插入了 10 万行数据,看上去是覆盖了原来的 10 万行。

但是,session A 开启了事务并没有提交,所以之前插入的 10 万行数据是不能删除的。这样,之前的数据每一行数据都有两个版本,旧版本是 delete 之前的数据,新版本是标记为 deleted 的数据。

这样,索引 a 上的数据其实就有两份。

然后你会说,不对啊,主键上的数据也不能删,那没有使用 force index 的语句,使用 explain 命令看到的扫描行数为什么还是 100000 左右?(潜台词,如果这个也翻倍,也许优化器还会认为选字段 a 作为索引更合适)

是的,不过这个是主键,主键是直接按照表的行数来估计的。而表的行数,优化器直接用的是 show table status 的值。

二、数据库事务方面问题

mysql的事务没有起作用的情况有哪些?

  1. @Trscational 的注解没有注释pulblic的方法。
  2. 方法没有抛出的RuntimeException异常
  3. 代码异常是时候使用了try-catch 进行的捕获处理
  4. @Tractional 没有纳入spring的容易注解
  5. Mysql的没有使用InnoDB的存储引擎,可能使用的是的MySAIM引擎。

如何避免长事务对业务的影响?

首先,从应用开发端来看:

  1. 确认是否使用了 set autocommit=0。这个确认工作可以在测试环境中开展,把 MySQL 的 general_log 开起来,然后随便跑一个业务逻辑,通过 general_log 的日志来确认。一般框架如果会设置这个值,也就会提供参数来控制行为,你的目标就是把它改成 1。
  2. 确认是否有不必要的只读事务。有些框架会习惯不管什么语句先用 begin/commit 框起来。我见过有些是业务并没有这个需要,但是也把好几个 select 语句放到了事务中。这种只读事务可以去掉。
  3. 业务连接数据库的时候,根据业务本身的预估,通过 SET MAX_EXECUTION_TIME 命令,来控制每个语句执行的最长时间,避免单个语句意外执行太长时间。(为什么会意外?在后续的文章中会提到这类案例)

其次,从数据库端来看:

  1. 监控 information_schema.Innodb_trx 表,设置长事务阈值,超过就报警 / 或者 kill;
  2. Percona 的 pt-kill 这个工具不错,推荐使用;
  3. 在业务功能测试阶段要求输出所有的 general_log,分析日志行为提前发现问题;
  4. 如果使用的是 MySQL 5.6 或者更新版本,把 innodb_undo_tablespaces 设置成 2(或更大的值)。如果真的出现大事务导致回滚段过大,这样设置后清理起来更方便。

三、数据库锁方面问题

看看 session A 的 select 语句加了哪些锁:

  1. 由于是 order by c desc,第一个要定位的是索引 c 上“最右边的”c=20 的行,所以会加上间隙锁 (20,25) 和 next-key lock (15,20]。
  2. 在索引 c 上向左遍历,要扫描到 c=10 才停下来,所以 next-key lock 会加到 (5,10],这正是阻塞 session B 的 insert 语句的原因。
  3. 在扫描过程中,c=20、c=15、c=10 这三行都存在值,由于是 select *,所以会在主键 id 上加三个行锁。

因此,session A 的 select 语句锁的范围就是:

  1. 索引 c 上 (5, 25);
  2. 主键索引上 id=15、20 两个行锁。

如果你之前没有了解过本篇文章的相关内容,一定觉得这三个语句简直是风马牛不相及。但实际上,这里 session B 和 session C 的 insert 语句都会进入锁等待状态。你可以试着分析一下,出现这种情况的原因是什么?

你也可以说说,你的线上 MySQL 配置的是什么隔离级别,为什么会这么配置?你有没有碰到什么场景,是必须使用可重复读隔离级别的呢?

备份一般都会在备库上执行,你在用–single-transaction 方法做逻辑备份的过程中,如果主库上的一个小表做了一个 DDL,比如给一个表上加了一列。这时候,从备库上会看到什么现象呢?

假设这个 DDL 是针对表 t1 的, 这里我把备份过程中几个关键的语句列出来:

Q1:SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
Q2:START TRANSACTION  WITH CONSISTENT SNAPSHOT;
/* other tables */
Q3:SAVEPOINT sp;
/* 时刻 1 */
Q4:show create table `t1`;
/* 时刻 2 */
Q5:SELECT * FROM `t1`;
/* 时刻 3 */
Q6:ROLLBACK TO SAVEPOINT sp;
/* 时刻 4 */
/* other tables */

在备份开始的时候,为了确保 RR(可重复读)隔离级别,再设置一次 RR 隔离级别 (Q1);

启动事务,这里用 WITH CONSISTENT SNAPSHOT 确保这个语句执行完就可以得到一个一致性视图(Q2);

设置一个保存点,这个很重要(Q3);

show create 是为了拿到表结构 (Q4),然后正式导数据 (Q5),回滚到 SAVEPOINT sp,在这里的作用是释放 t1 的 MDL 锁 (Q6)。当然这部分属于“超纲”,上文正文里面都没提到。

DDL 从主库传过来的时间按照效果不同,我打了四个时刻。题目设定为小表,我们假定到达后,如果开始执行,则很快能够执行完成。

参考答案如下:

  1. 如果在 Q4 语句执行之前到达,现象:没有影响,备份拿到的是 DDL 后的表结构。
  2. 如果在“时刻 2”到达,则表结构被改过,Q5 执行的时候,报 Table definition has changed, please retry transaction,现象:mysqldump 终止;
  3. 如果在“时刻 2”和“时刻 3”之间到达,mysqldump 占着 t1 的 MDL 读锁,binlog 被阻塞,现象:主从延迟,直到 Q6 执行完成。
  4. 从“时刻 4”开始,mysqldump 释放了 MDL 读锁,现象:没有影响,备份拿到的是 DDL 前的表结构。

如果你要删除一个表里面的前 10000 行数据,有以下三种方法可以做到:

  • 第一种,直接执行 delete from T limit 10000;
  • 第二种,在一个连接中循环执行 20 次 delete from T limit 500;
  • 第三种,在 20 个连接中同时执行 delete from T limit 500。

第二种方式是相对较好的。

第一种方式(即:直接执行 delete from T limit 10000)里面,单个语句占用时间长,锁的时间也比较长;而且大事务还会导致主从延迟。

第三种方式(即:在 20 个连接中同时执行 delete from T limit 500),会人为造成锁冲突。

四、数据库查询方面问题

主要是优化什么呢?

优化就比较简单了,因为我们建立表可能会建立很多索引,优化有一步就是要确认使用哪个索引,比如使用你的主键索引,联合索引还是什么索引更好。还有就是对执行顺序进行优化,条件那么多,先查哪个表,还是先关联,会出现很多方案,最后由优化器决定选用哪种方案。最后就是执行了,执行就交给执行器去做。第一步可能就是权限的判断,其实这里我不确定的一个点就是,我接触的公司很多都是自研的线上查询系统,我们是不能用Navicat直连线上库,只能去网页操作,那表的权限是在MySQL层做的,还是系统做的,我猜应该是系统层做的,MySQL可能默认就全开放了,只是我们 不知道ip。

怎么查看空闲连接列表?

show processlist,下图就是我在自己的数据库表执行命令的结果,其中的Command列显示为Sleep的这一行,就表示现在系统里面有一个空闲连接。

这里需要注意的是,我们数据库的客户端太久没响应,连接器就会自动断开了,这个时间参数是wait_timeout控制住的,默认时长为8小时。断开后重连的时候会报错,如果你想再继续操作,你就需要重连了。

你了解MySQL的查询缓存么?

MySQL拿到一个查询请求后,会先到查询缓存看看,之前是不是执行过这条语句。大家是不是好奇同一条语句在MySQL执行两次,第一次和后面的时间是不一样的,后者明显快一些,这就是因为缓存的存在。他跟Redis一样,只要是你之前执行过的语句,都会在内存里面用key-value形式存储着。查询的时候就会拿着语句先去缓存中查询,如果能够命中就返回缓存的value,如果不命中就执行后面的阶段。但是我还是不喜欢用缓存,因为缓存弊大于利。

因为缓存弊大于利?

缓存的失效很容易,只要对表有任何的更新,这个表的所有查询缓存就会全部被清空,就会出现缓存还没使用,就直接被清空了,或者积累了很多缓存准备用来着,但是一个更新打回原形。这就导致查询的命中率低的可怕,只有那种只查询不更新的表适用缓存,但是这样的表往往很少存在,一般都是什么配置表之类的。

那我们查询的时候不想用缓存一般都是怎么操作的,或者是用缓存又怎么操作?

可以显示调用,把query_cache_type设置成为DEMAND,这样SQL默认不适用缓存,想用缓存就用SQL_CACHE。有个小技巧就是,我们之前开发的时候,都会去库里看看sql执行时间,但是可能是有缓存的,一般我们就在sql前面使用SQL_NO_CACHE就可以知道真正的查询时间了。

 select SQL_NO_CACHE * from B

缓存在MySQL8.0之后就取消了,所以大家现在应该不需要太关注这个问题,主要是我之前用的版本都不高,所以缓存一直有,在《高性能MySQL》书中也看到了一些关于缓存的介绍,就想起来给大家也提一下了。

缓存查询完了应该做啥呢?

在缓存没有命中的情况下,就开始执行语句了,你写的语句有没有语法错误,这是接下来MySQL比较关心的点。那他会怎么做呢?会先做词法分析,你的语句有这么多单词、空格,MySQL就需要识别每个字符串所代表的是什么,是关键字,还是表名,还是列名等等。然后就开始语法分析,根据词法分析的结果,语法分析会判断你sql的对错,错了会提醒你的,并且会提示你哪里错了。分析没错之后就进入下一步,优化器。

mysql8查询缓存弃用_MySQL为什么取消了Query Cache?

MySQL查询缓存是查询结果缓存。它将以SEL开头的查询与哈希表进行比较,如果匹配,则返回上一次查询的结果。进行匹配时,查询必须逐字节匹配,例如 SELECT * FROM t1; 不等于select * from t1;,此外,一些不确定的查询结果无法被缓存,任何对表的修改都会导致这些表的所有缓存无效。因此,适用于查询缓存的最理想的方案是只读,特别是需要检查数百万行后仅返回数行的复杂查询。如果你的查询符合这样一个特点,开启查询缓存会提升你的查询性能。

随着技术的进步,经过时间的考验,MySQL的工程团队发现启用缓存的好处并不多。首先,查询缓存的效果取决于缓存的命中率,只有命中缓存的查询效果才能有改善,因此无法预测其性能。其次,查询缓存的另一个大问题是它受到单个互斥锁的保护。在具有多个内核的服务器上,大量查询会导致大量的互斥锁争用。

适合QueryCache的场景?

首先,查询缓存QC的大小只有几MB,不适合将缓存设置得太大,由于在更新过程中需要线程锁定QueryCache,因此对于非常大的缓存,可能会看到锁争用问题。那么,哪些情况有助于从查询缓存中获益呢?以下是理想条件:

  • 相同的查询是由相同或多个客户机重复发出的。
  • 被访问的底层数据本质上是静态或半静态的。
  • 查询有可能是资源密集型和/或构建简短但计算复杂的结果集,同时结果集比较小。
  • 并发性和查询QPS都不高

这4种情况只是理想情况下,实际的业务系统都是有CRUD操作的,数据更新比较频繁,查询接口的QPS比较高,所以能满足上面的理想情况下的业务场景实在很少,我能想到就是配置表,数据字典表这些基本都是静态或半静态的,可以时通过QC来提高查询效率。

不适合QueryCache的场景

如果表数据变化很快,则查询缓存将失效,并且由于不断从缓存中删除查询,从而使服务器负载升高,处理速度变得更慢,如果数据每隔几秒钟更新一次或更加频繁,则查询缓存不太可能合适。同时,查询缓存使用单个互斥体来控制对缓存的访问,实际上是给服务器SQL处理引擎强加了一个单线程网关,在查询QPS比较高的情况下,可能成为一个性能瓶颈,会严重降低查询的处理速度。因此,MySQL 5.6中默认禁用了查询缓存。

五、数据的结构相关的问题

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

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

你觉得可能是什么原因呢 ?

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

假如是这么一个过程:

  1. 将表 t 重建一次;
  2. 插入一部分数据,但是插入的这些数据,用掉了一部分的预留空间;
  3. 这种情况下,再重建一次表 t,就可能会出现问题中的现象。

六、项目中的数据库问题

你的生产环境中的mysql的配置是什么?能够抗住多少的并发量?

起步是8核 推荐使用的是16核 32G,采用的SSD固态硬盘,8核16核每一秒可以抗住1000-2000并发需求。16核32G每一秒抗住2000-4000并发需求。

那除了重新链接,还有别的方式么?因为建立链接还是比较麻烦的。

使用长连接。但是这里有个缺点,使用长连接之后,内存会飙得很快,我们知道MySQL在执行过程中临时使用的内存是管理在连接对象里面的。只有在链接断开的时候才能得到释放,那如果一直使用长连接,那就会导致OOM(Out Of Memory),会导致MySQL重启,在JVM里面就会导致频繁的Full GC。我一般会定期断开长连接,使用一段时间后,或者程序里面判断执行过一个占用内存比较大的查询后就断开连接,需要的时候重连就好了。执行比较大的一个查询后,执行mysql_reset_connection可以重新初始化连接资源。这个过程相比上面一种会好点,不需要重连,但是会初始化连接的状态。

在刚刚讨论的方案中,我们用了事务来确保计数准确。由于事务可以保证中间结果不被别的事务读到,因此修改计数值和插入新记录的顺序是不影响逻辑结果的。但是,从并发系统性能的角度考虑,你觉得在这个事务序列里,应该先插入操作记录,还是应该先更新计数表呢?

业务上有这样的需求,A、B 两个用户,如果互相关注,则成为好友。设计上是有两张表,一个是 like 表,一个是 friend 表,like 表有 user_id、liker_id 两个字段,我设置为复合唯一索引即 uk_user_id_liker_id。语句执行逻辑是这样的:

以 A 关注 B 为例: 第一步,先查询对方有没有关注自己(B 有没有关注 A) select * from like where user_id = B and liker_id = A;

如果有,则成为好友 insert into friend;

没有,则只是单向关注关系 insert into like;

但是如果 A、B 同时关注对方,会出现不会成为好友的情况。因为上面第 1 步,双方都没关注对方。第 1 步即使使用了排他锁也不行,因为记录不存在,行锁无法生效。请问这种情况,在 MySQL 锁层面有没有办法处理?

CREATE TABLE `like` (`id` int(11) NOT NULL AUTO_INCREMENT,`user_id` int(11) NOT NULL,`liker_id` int(11) NOT NULL,PRIMARY KEY (`id`),UNIQUE KEY `uk_user_id_liker_id` (`user_id`,`liker_id`)
) ENGINE=InnoDB;CREATE TABLE `friend` (id` int(11) NOT NULL AUTO_INCREMENT,`friend_1_id` int(11) NOT NULL,`firned_2_id` int(11) NOT NULL,UNIQUE KEY `uk_friend` (`friend_1_id`,`firned_2_id`)PRIMARY KEY (`id`)
) ENGINE=InnoDB;

虽然这个题干中,并没有说到 friend 表的索引结构。但我猜测 friend_1_id 和 friend_2_id 也有索引,为便于描述,我给加上唯一索引。

顺便说明一下,“like”是关键字,我一般不建议使用关键字作为库名、表名、字段名或索引名。

我把他的疑问翻译一下,在并发场景下,同时有两个人,设置为关注对方,就可能导致无法成功加为朋友关系。

现在,我用你已经熟悉的时刻顺序表的形式,把这两个事务的执行语句列出来:

由于一开始 A 和 B 之间没有关注关系,所以两个事务里面的 select 语句查出来的结果都是空。

因此,session 1 的逻辑就是“既然 B 没有关注 A,那就只插入一个单向关注关系”。session 2 也同样是这个逻辑。

这个结果对业务来说就是 bug 了。因为在业务设定里面,这两个逻辑都执行完成以后,是应该在 friend 表里面插入一行记录的。

如提问里面说的,“第 1 步即使使用了排他锁也不行,因为记录不存在,行锁无法生效”。不过,我想到了另外一个方法,来解决这个问题。

首先,要给“like”表增加一个字段,比如叫作 relation_ship,并设为整型,取值 1、2、3。

值是 1 的时候,表示 user_id 关注 liker_id; 值是 2 的时候,表示 liker_id 关注 user_id; 值是 3 的时候,表示互相关注。

然后,当 A 关注 B 的时候,逻辑改成如下所示的样子:

应用代码里面,比较 A 和 B 的大小,如果 A<B,就执行下面的逻辑

mysql> begin; /* 启动事务 */
insert into `like`(user_id, liker_id, relation_ship) values(A, B, 1) on duplicate key update relation_ship=relation_ship | 1;
select relation_ship from `like` where user_id=A and liker_id=B;
/* 代码中判断返回的 relation_ship,如果是 1,事务结束,执行 commit如果是 3,则执行下面这两个语句:*/
insert ignore into friend(friend_1_id, friend_2_id) values(A,B);
commit;

如果 A>B,则执行下面的逻辑

mysql> begin; /* 启动事务 */
insert into `like`(user_id, liker_id, relation_ship) values(B, A, 2) on duplicate key update relation_ship=relation_ship | 2;
select relation_ship from `like` where user_id=B and liker_id=A;
/* 代码中判断返回的 relation_ship,如果是 2,事务结束,执行 commit如果是 3,则执行下面这两个语句:
*/
insert ignore into friend(friend_1_id, friend_2_id) values(B,A);
commit;

这个设计里,让“like”表里的数据保证 user_id < liker_id,这样不论是 A 关注 B,还是 B 关注 A,在操作“like”表的时候,如果反向的关系已经存在,就会出现行锁冲突。

然后,insert … on duplicate 语句,确保了在事务内部,执行了这个 SQL 语句后,就强行占住了这个行锁,之后的 select 判断 relation_ship 这个逻辑时就确保了是在行锁保护下的读操作。

操作符 “|” 是按位或,连同最后一句 insert 语句里的 ignore,是为了保证重复调用时的幂等性。

这样,即使在双方“同时”执行关注操作,最终数据库里的结果,也是 like 表里面有一条关于 A 和 B 的记录,而且 relation_ship 的值是 3, 并且 friend 表里面也有了 A 和 B 的这条记录。

不知道你会不会吐槽:之前明明还说尽量不要使用唯一索引,结果这个例子一上来我就创建了两个。这里我要再和你说明一下,之前文章我们讨论的,是在“业务开发保证不会插入重复记录”的情况下,着重要解决性能问题的时候,才建议尽量使用普通索引。

而像这个例子里,按照这个设计,业务根本就是保证“我一定会插入重复数据,数据库一定要要有唯一性约束”,这时就没啥好说的了,唯一索引建起来吧。

我们创建了一个简单的表 t,并插入一行,然后对这一行做修改。

mysql> CREATE TABLE `t` (
`id` int(11) NOT NULL primary key auto_increment,
`a` int(11) DEFAULT NULL
) ENGINE=InnoDB;
insert into t values(1,2);

这时候,表 t 里有唯一的一行数据 (1,2)。假设,我现在要执行:

mysql> update t set a=2 where id=1;

你会看到这样的结果:

结果显示,匹配 (rows matched) 了一行,修改 (Changed) 了 0 行。

仅从现象上看,MySQL 内部在处理这个命令的时候,可以有以下三种选择:

  1. 更新都是先读后写的,MySQL 读出数据,发现 a 的值本来就是 2,不更新,直接返回,执行结束;
  2. MySQL 调用了 InnoDB 引擎提供的“修改为 (1,2)”这个接口,但是引擎发现值与原来相同,不更新,直接返回;
  3. InnoDB 认真执行了“把这个值修改成 (1,2)"这个操作,该加锁的加锁,该更新的更新。

你觉得实际情况会是以上哪种呢?你可否用构造实验的方式,来证明你的结论?进一步地,可以思考一下,MySQL 为什么要选择这种策略呢?

**问题 1:**执行一个 update 语句以后,我再去执行 hexdump 命令直接查看 ibd 文件内容,为什么没有看到数据有改变呢?

回答:这可能是因为 WAL 机制的原因。update 语句执行完成后,InnoDB 只保证写完了 redo log、内存,可能还没来得及将数据写到磁盘。

**问题 2:**为什么 binlog cache 是每个线程自己维护的,而 redo log buffer 是全局共用的?

回答:MySQL 这么设计的主要原因是,binlog 是不能“被打断的”。一个事务的 binlog 必须连续写,因此要整个事务完成后,再一起写到文件里。

而 redo log 并没有这个要求,中间有生成的日志可以写到 redo log buffer 中。redo log buffer 中的内容还能“搭便车”,其他事务提交的时候可以被一起写到磁盘中。

**问题 3:**事务执行期间,还没到提交阶段,如果发生 crash 的话,redo log 肯定丢了,这会不会导致主备不一致呢?

回答:不会。因为这时候 binlog 也还在 binlog cache 里,没发给备库。crash 以后 redo log 和 binlog 都没有了,从业务角度看这个事务也没有提交,所以数据是一致的。

**问题 4:**如果 binlog 写完盘以后发生 crash,这时候还没给客户端答复就重启了。等客户端再重连进来,发现事务已经提交成功了,这是不是 bug?

回答:不是。

你可以设想一下更极端的情况,整个事务都提交成功了,redo log commit 完成了,备库也收到 binlog 并执行了。但是主库和客户端网络断开了,导致事务成功的包返回不回去,这时候客户端也会收到“网络断开”的异常。这种也只能算是事务成功的,不能认为是 bug。

实际上数据库的 crash-safe 保证的是:

  1. 如果客户端收到事务成功的消息,事务就一定持久化了;
  2. 如果客户端收到事务失败(比如主键冲突、回滚等)的消息,事务就一定失败了;
  3. 如果客户端收到“执行异常”的消息,应用需要重连后通过查询当前状态来继续后续的逻辑。此时数据库只需要保证内部(数据和日志之间,主库和备库之间)一致就可以了。

如果一个数据库是被客户端的压力打满导致无法响应的,重启数据库是没用的。”,

这个问题是因为重启之后,业务请求还会再发。而且由于是重启,buffer pool 被清空,可能会导致语句执行得更慢。

有时候一个表上会出现多个单字段索引(而且往往这是因为运维工程师对索引原理不够清晰做的设计),这样就可能出现优化器选择索引合并算法的现象。但实际上,索引合并算法的效率并不好。而通过将其中的一个索引改成联合索引的方法,是一个很好的应对方案。

七、日志相关问题

在两阶段提交的不同瞬间,MySQL 如果发生异常重启,是怎么保证数据完整性的?

两阶段提交原理图:

没有显式地开启事务,因此这个 update 语句自己就是一个事务,在执行完成后提交事务时,就会用到这个“commit 步骤“。

如果在图中时刻 A 的地方,也就是写入 redo log 处于 prepare 阶段之后、写 binlog 之前,发生了崩溃(crash),由于此时 binlog 还没写,redo log 也还没提交,所以崩溃恢复的时候,这个事务会回滚。这时候,binlog 还没写,所以也不会传到备库。到这里,大家都可以理解。

大家出现问题的地方,主要集中在时刻 B,也就是 binlog 写完,redo log 还没 commit 前发生 crash,那崩溃恢复的时候 MySQL 会怎么处理?

我们先来看一下崩溃恢复时的判断规则。

  1. 如果 redo log 里面的事务是完整的,也就是已经有了 commit 标识,则直接提交;
  2. 如果 redo log 里面的事务只有完整的 prepare,则判断对应的事务 binlog 是否存在并完整: a. 如果是,则提交事务; b. 否则,回滚事务。

这里,时刻 B 发生 crash 对应的就是 2(a) 的情况,崩溃恢复过程中事务会被提交。

7.1 MySQL 怎么知道 binlog 是完整的?

:一个事务的 binlog 是有完整格式的:

  • statement 格式的 binlog,最后会有 COMMIT;
  • row 格式的 binlog,最后会有一个 XID event。

另外,在 MySQL 5.6.2 版本以后,还引入了 binlog-checksum 参数,用来验证 binlog 内容的正确性。对于 binlog 日志由于磁盘原因,可能会在日志中间出错的情况,MySQL 可以通过校验 checksum 的结果来发现。所以,MySQL 还是有办法验证事务 binlog 的完整性的。

7.3 redo log 和 binlog 是怎么关联起来的?

回答:它们有一个共同的数据字段,叫 XID。崩溃恢复的时候,会按顺序扫描 redo log:

  • 如果碰到既有 prepare、又有 commit 的 redo log,就直接提交;
  • 如果碰到只有 parepare、而没有 commit 的 redo log,就拿着 XID 去 binlog 找对应的事务。

7.4 处于 prepare 阶段的 redo log 加上完整 binlog,重启就能恢复,MySQL 为什么要这么设计?

其实,这个问题还是跟我们在反证法中说到的数据与备份的一致性有关。在时刻 B,也就是 binlog 写完以后 MySQL 发生崩溃,这时候 binlog 已经写入了,之后就会被从库(或者用这个 binlog 恢复出来的库)使用。

所以,在主库上也要提交这个事务。采用这个策略,主库和备库的数据就保证了一致性。

7.5 如果这样的话,为什么还要两阶段提交呢?干脆先 redo log 写完,再写 binlog。崩溃恢复的时候,必须得两个日志都完整才可以。是不是一样的逻辑?

其实,两阶段提交是经典的分布式系统问题,并不是 MySQL 独有的。

如果必须要举一个场景,来说明这么做的必要性的话,那就是事务的持久性问题。

对于 InnoDB 引擎来说,如果 redo log 提交完成了,事务就不能回滚(如果这还允许回滚,就可能覆盖掉别的事务的更新)。而如果 redo log 直接提交,然后 binlog 写入的时候失败,InnoDB 又回滚不了,数据和 binlog 日志又不一致了。

两阶段提交就是为了给所有人一个机会,当每个人都说“我 ok”的时候,再一起提交。

7.6 不引入两个日志,也就没有两阶段提交的必要了。只用 binlog 来支持崩溃恢复,又能支持归档,不就可以了?

这位同学的意思是,只保留 binlog,然后可以把提交流程改成这样:… -> “数据更新到内存” -> “写 binlog” -> “提交事务”,是不是也可以提供崩溃恢复的能力?

答案是不可以。

如果说历史原因的话,那就是 InnoDB 并不是 MySQL 的原生存储引擎。MySQL 的原生引擎是 MyISAM,设计之初就有没有支持崩溃恢复。

InnoDB 在作为 MySQL 的插件加入 MySQL 引擎家族之前,就已经是一个提供了崩溃恢复和事务支持的引擎了。

InnoDB 接入了 MySQL 后,发现既然 binlog 没有崩溃恢复的能力,那就用 InnoDB 原有的 redo log 好了。

而如果说实现上的原因的话,就有很多了。就按照问题中说的,只用 binlog 来实现崩溃恢复的流程,我画了一张示意图,这里就没有 redo log 了。

这样的流程下,binlog 还是不能支持崩溃恢复的。我说一个不支持的点吧:binlog 没有能力恢复“数据页”。

如果在图中标的位置,也就是 binlog2 写完了,但是整个事务还没有 commit 的时候,MySQL 发生了 crash。

重启后,引擎内部事务 2 会回滚,然后应用 binlog2 可以补回来;但是对于事务 1 来说,系统已经认为提交完成了,不会再应用一次 binlog1。

但是,InnoDB 引擎使用的是 WAL 技术,执行事务的时候,写完内存和日志,事务就算完成了。如果之后崩溃,要依赖于日志来恢复数据页。

也就是说在图中这个位置发生崩溃的话,事务 1 也是可能丢失了的,而且是数据页级的丢失。此时,binlog 里面并没有记录数据页的更新细节,是补不回来的。

你如果要说,那我优化一下 binlog 的内容,让它来记录数据页的更改可以吗?但,这其实就是又做了一个 redo log 出来。

所以,至少现在的 binlog 能力,还不能支持崩溃恢复。

7.7 那能不能反过来,只用 redo log,不要 binlog?

如果只从崩溃恢复的角度来讲是可以的。你可以把 binlog 关掉,这样就没有两阶段提交了,但系统依然是 crash-safe 的。

但是,如果你了解一下业界各个公司的使用场景的话,就会发现在正式的生产库上,binlog 都是开着的。因为 binlog 有着 redo log 无法替代的功能。

一个是归档。redo log 是循环写,写到末尾是要回到开头继续写的。这样历史日志没法保留,redo log 也就起不到归档的作用。

一个就是 MySQL 系统依赖于 binlog。binlog 作为 MySQL 一开始就有的功能,被用在了很多地方。其中,MySQL 系统高可用的基础,就是 binlog 复制。

还有很多公司有异构系统(比如一些数据分析系统),这些系统就靠消费 MySQL 的 binlog 来更新自己的数据。关掉 binlog 的话,这些下游系统就没法输入了。

总之,由于现在包括 MySQL 高可用在内的很多系统机制都依赖于 binlog,所以“鸠占鹊巢”redo log 还做不到。你看,发展生态是多么重要。

7.8 redo log 一般设置多大?

redo log 太小的话,会导致很快就被写满,然后不得不强行刷 redo log,这样 WAL 机制的能力就发挥不出来了。

所以,如果是现在常见的几个 TB 的磁盘的话,就不要太小气了,直接将 redo log 设置为 4 个文件、每个文件 1GB 吧。

7.9 正常运行中的实例,数据写入后的最终落盘,是从 redo log 更新过来的还是从 buffer pool 更新过来的呢?

这个问题其实问得非常好。这里涉及到了,“redo log 里面到底是什么”的问题。

实际上,redo log 并没有记录数据页的完整数据,所以它并没有能力自己去更新磁盘数据页,也就不存在“数据最终落盘,是由 redo log 更新过去”的情况。

  1. 如果是正常运行的实例的话,数据页被修改以后,跟磁盘的数据页不一致,称为脏页。最终数据落盘,就是把内存中的数据页写盘。这个过程,甚至与 redo log 毫无关系。
  2. 在崩溃恢复场景中,InnoDB 如果判断到一个数据页可能在崩溃恢复的时候丢失了更新,就会将它读到内存,然后让 redo log 更新内存内容。更新完成后,内存页变成脏页,就回到了第一种情况的状态。

7.10 redo log buffer 是什么?是先修改内存,还是先写 redo log 文件?

在一个事务的更新过程中,日志是要写多次的。比如下面这个事务:

begin;
insert into t1 ...
insert into t2 ...
commit;

这个事务要往两个表中插入记录,插入数据的过程中,生成的日志都得先保存起来,但又不能在还没 commit 的时候就直接写到 redo log 文件里。

所以,redo log buffer 就是一块内存,用来先存 redo 日志的。也就是说,在执行第一个 insert 的时候,数据的内存被修改了,redo log buffer 也写入了日志。

但是,真正把日志写到 redo log 文件(文件名是 ib_logfile+ 数字),是在执行 commit 语句的时候做的。

(这里说的是事务执行过程中不会“主动去刷盘”,以减少不必要的 IO 消耗。但是可能会出现“被动写入磁盘”,比如内存不够、其他事务提交等情况。这个问题我们会在后面第 22 篇文章《MySQL 有哪些“饮鸩止渴”的提高性能的方法?》中再详细展开)。

单独执行一个更新语句的时候,InnoDB 会自己启动一个事务,在语句执行完成的时候提交。过程跟上面是一样的,只不过是“压缩”到了一个语句里面完成。

八、数据库的主备相关问题

说到循环复制问题的时候,我们说 MySQL 通过判断 server id 的方式,断掉死循环。但是,这个机制其实并不完备,在某些场景下,还是有可能出现死循环。你能构造出一个这样的场景吗?又应该怎么解决呢?

今天我留给你的思考题是:你的生产库设置的是“双 1”吗? 如果平时是的话,你有在什么场景下改成过“非双 1”吗?你的这个操作又是基于什么决定的?另外,我们都知道这些设置可能有损,如果发生了异常,你的止损方案是什么?

你在什么时候会把线上生产库设置成“非双 1”。我目前知道的场景,有以下这些:

  1. 业务高峰期。一般如果有预知的高峰期,DBA 会有预案,把主库设置成“非双 1”。
  2. 备库延迟,为了让备库尽快赶上主库。@永恒记忆和 @Second Sight 提到了这个场景。
  3. 用备份恢复主库的副本,应用 binlog 的过程,这个跟上一种场景类似。
  4. 批量导入数据的时候。

一般情况下,把生产库改成“非双 1”配置,是设置 innodb_flush_logs_at_trx_commit=2、sync_binlog=1000。

什么情况下双 M 结构会出现循环复制。

一种场景是,在一个主库更新事务后,用命令 set global server_id=x 修改了 server_id。等日志再传回来的时候,发现 server_id 跟自己的 server_id 不同,就只能执行了。

另一种场景是,有三个节点的时候,如图 7 所示,trx1 是在节点 B 执行的,因此 binlog 上的 server_id 就是 B,binlog 传给节点 A,然后 A 和 A’搭建了双 M 结构,就会出现循环复制。

这种三节点复制的场景,做数据库迁移的时候会出现。

如果出现了循环复制,可以在 A 或者 A’上,执行如下命令:

stop slave;
CHANGE MASTER TO IGNORE_SERVER_IDS=(server_id_of_B);
start slave;

这样这个节点收到日志后就不会再执行。过一段时间后,再执行下面的命令把这个值改回来。

stop slave;
CHANGE MASTER TO IGNORE_SERVER_IDS=();
start slave;

一般现在的数据库运维系统都有备库延迟监控,其实就是在备库上执行 show slave status,采集 seconds_behind_master 的值。

假设,现在你看到你维护的一个备库,它的延迟监控的图像类似图 6,是一个 45°斜向上的线段,你觉得可能是什么原因导致呢?你又会怎么去确认这个原因呢?

缓存表数据与索引数据,把磁盘上的数据加载到缓冲池,避免每次访问都进行磁盘 IO,起到加速访问的作用。

速度快,那为啥不把所有数据都放到缓冲池里

凡事都具备两面性,抛开数据易失性不说,访问快速的反面是存储容量小:

(1)缓存访问快,但容量小,数据库存储了 200G 数据,缓存容量可能只有 64G;

(2)内存访问快,但容量小,买一台笔记本磁盘有 2T,内存可能只有 16G;

因此,只能把 “最热” 的数据放到 “最近” 的地方,以 “最大限度” 的降低磁盘访问。

如何管理与淘汰缓冲池,使得性能最大化呢?

什么是预读?

磁盘读写,并不是按需读取,而是按页读取,一次至少读一页数据(一般是 4K),如果未来要读取的数据就在页中,就能够省去后续的磁盘 IO,提高效率。

预读为什么有效?

数据访问,通常都遵循 “集中读写” 的原则,使用一些数据,大概率会使用附近的数据,这就是所谓的“局部性原理”,它表明提前加载是有效的,确实能够减少磁盘 IO。

按页 (4K) 读取,和 InnoDB 的缓冲池设计有啥关系?

(1)磁盘访问按页读取能够提高性能,所以缓冲池一般也是按页缓存数据;

(2)预读机制启示了我们,能把一些 “可能要访问” 的页提前加入缓冲池,避免未来的磁盘 IO 操作;

InnoDB 是以什么算法,来管理这些缓冲页呢?

最容易想到的,就是 LRU(Least recently used)。OS 都会用 LRU 来进行页置换管理,但 MySQL 的玩法并不一样。

传统的 LRU 是如何进行缓冲页管理?

常见的玩法是,把入缓冲池的页放到 LRU 的头部,作为最近访问的元素,从而最晚被淘汰。这里又分两种情况:

(1)页已经在缓冲池里,那就只做 “移至”LRU 头部的动作,而没有页被淘汰;

(2)页不在缓冲池里,除了做 “放入”LRU 头部的动作,还要做 “淘汰”LRU 尾部页的动作;

如上图,假如管理缓冲池的 LRU 长度为 10,缓冲了页号为 1,3,5…,40,7 的页。假如,接下来要访问的数据在页号为 4 的页中:

(1)页号为 4 的页,本来就在缓冲池里;

(2)把页号为 4 的页,放到 LRU 的头部即可,没有页被淘汰;

为了减少数据移动,LRU 一般用链表实现。假如,再接下来要访问的数据在页号为 50 的页中:

(1)页号为 50 的页,原来不在缓冲池里;

(2)把页号为 50 的页,放到 LRU 头部,同时淘汰尾部页号为 7 的页;

传统的 LRU 缓冲池算法十分直观,OS,memcache 等很多软件都在用,MySQL 为啥这么矫情,不能直接用呢?

这里有两个问题:

(1)预读失效;

(2)缓冲池污染;

什么是预读失效?:由于预读 (Read-Ahead),提前把页放入了缓冲池,但最终 MySQL 并没有从页中读取数据,称为预读失效。

如何对预读失效进行优化?

要优化预读失效,思路是:

(1)让预读失败的页,停留在缓冲池 LRU 里的时间尽可能短;

(2)让真正被读取的页,才挪到缓冲池 LRU 的头部;

以保证,真正被读取的热数据留在缓冲池里的时间尽可能长。

具体方法是:

(1)将 LRU 分为两个部分:

  • 新生代 (new sublist)

  • 老生代 (old sublist)

(2)新老生代收尾相连,即:新生代的尾 (tail) 连接着老生代的头 (head);

(3)新页(例如被预读的页)加入缓冲池时,只加入到老生代头部:

  • 如果数据真正被读取(预读成功),才会加入到新生代的头部

  • 如果数据没有被读取,则会比新生代里的 “热数据页” 更早被淘汰出缓冲池

举个例子,整个缓冲池 LRU 如上图:

(1)整个 LRU 长度是 10;

(2)前 70% 是新生代;

(3)后 30% 是老生代;

(4)新老生代首尾相连;

假如有一个页号为 50 的新页被预读加入缓冲池:

(1)50 只会从老生代头部插入,老生代尾部(也是整体尾部)的页会被淘汰掉;

(2)假设 50 这一页不会被真正读取,即预读失败,它将比新生代的数据更早淘汰出缓冲池;

假如 50 这一页立刻被读取到,例如 SQL 访问了页内的行 row 数据:

(1)它会被立刻加入到新生代的头部;

(2)新生代的页会被挤到老生代,此时并不会有页面被真正淘汰;

改进版缓冲池 LRU 能够很好的解决 “预读失败” 的问题。但也不要因噎废食,因为害怕预读失败而取消预读策略,大部分情况下,局部性原理是成立的,预读是有效的。新老生代改进版 LRU 仍然解决不了缓冲池污染的问题。

什么是 MySQL 缓冲池污染?

当某一个 SQL 语句,要批量扫描大量数据时,可能导致把缓冲池的所有页都替换出去,导致大量热数据被换出,MySQL 性能急剧下降,这种情况叫缓冲池污染。

例如,有一个数据量较大的用户表,当执行:

select * from user where name like "%shenjian%";

虽然结果集可能只有少量数据,但这类 like 不能命中索引,必须全表扫描,就需要访问大量的页:

(1)把页加到缓冲池(插入老生代头部);

(2)从页里读出相关的 row(插入新生代头部);

(3)row 里的 name 字段和字符串 shenjian 进行比较,如果符合条件,加入到结果集中;

(4)… 直到扫描完所有页中的所有 row…

如此一来,所有的数据页都会被加载到新生代的头部,但只会访问一次,真正的热数据被大量换出。

怎么这类扫码大量数据导致的缓冲池污染问题呢?

MySQL 缓冲池加入了一个 “老生代停留时间窗口” 的机制:

1)假设 T = 老生代停留时间窗口;

(2)插入老生代头部的页,即使立刻被访问,并不会立刻放入新生代头部;

(3)只有满足 “被访问” 并且 “在老生代停留时间” 大于 T,才会被放入新生代头部;

继续举例,假如批量数据扫描,有 51,52,53,54,55 等五个页面将要依次被访问。

如果没有 “老生代停留时间窗口” 的策略,这些批量被访问的页面,会换出大量热数据。

加入 “老生代停留时间窗口” 策略后,短时间内被大量加载的页,并不会立刻插入新生代头部,而是优先淘汰那些,短期内仅仅访问了一次的页。

而只有在老生代呆的时间足够久,停留时间大于 T,才会被插入新生代头部。

总结

(1)缓冲池 (buffer pool) 是一种常见的降低磁盘访问的机制;

(2)缓冲池通常以页 (page) 为单位缓存数据;

(3)缓冲池的常见管理算法是 LRU,memcache,OS,InnoDB 都使用了这种算法;

(4)InnoDB 对普通 LRU 进行了优化:

  • 将缓冲池分为老生代和新生代,入缓冲池的页,优先进入老生代,页被访问,才进入新生代,以解决预读失效的问题

  • 页被访问,且在老生代停留时间超过配置阈值的,才进入新生代,以解决批量数据访问,大量热数据淘汰的问题

change buffer 和 redo log与redo log 和 WAL的区别

WAL 提升性能的核心机制,也的确是尽量减少随机读写

现在,我们要在表上执行这个插入语句:

insert into t(id,k) values(id1,k1),(id2,k2);

这里,我们假设当前 k 索引树的状态,查找到位置后,k1 所在的数据页在内存 (InnoDB buffer pool) 中,k2 所在的数据页不在内存中。如图 2 所示是带 change buffer 的更新状态图。

change buffer 的更新过程

分析这条更新语句,你会发现它涉及了四个部分:内存、redo log(ib_log_fileX)、 数据表空间(t.ibd)、系统表空间(ibdata1)。

这条更新语句做了如下的操作(按照图中的数字顺序):

  1. Page 1 在内存中,直接更新内存;
  2. Page 2 没有在内存中,就在内存的 change buffer 区域,记录下“我要往 Page 2 插入一行”这个信息
  3. 将上述两个动作记入 redo log 中(图中 3 和 4)。

做完上面这些,事务就可以完成了。所以,你会看到,执行这条更新语句的成本很低,就是写了两处内存,然后写了一处磁盘(两次操作合在一起写了一次磁盘),而且还是顺序写的。

那在这之后的读请求,要怎么处理呢?

 select * from t where k in (k1, k2)

如果读语句发生在更新语句后不久,内存中的数据都还在,那么此时的这两个读操作就与系统表空间(ibdata1)和 redo log(ib_log_fileX)无关了。所以,我在图中就没画出这两部分。

从图中可以看到:

  1. 读 Page 1 的时候,直接从内存返回。有几位同学在前面文章的评论中问到,WAL 之后如果读数据,是不是一定要读盘,是不是一定要从 redo log 里面把数据更新以后才可以返回?其实是不用的。你可以看一下图 3 的这个状态,虽然磁盘上还是之前的数据,但是这里直接从内存返回结果,结果是正确的。
  2. 要读 Page 2 的时候,需要把 Page 2 从磁盘读入内存中,然后应用 change buffer 里面的操作日志,生成一个正确的版本并返回结果。

可以看到,直到需要读 Page 2 的时候,这个数据页才会被读入内存。

所以,如果要简单地对比这两个机制在提升更新性能上的收益的话,redo log 主要节省的是随机写磁盘的 IO 消耗(转成顺序写),而 change buffer 主要节省的则是随机读磁盘的 IO 消耗。

博文参考

《我们一起进大厂》系列-数据库基础 - 掘金

MYSQL——大厂面试问题集合相关推荐

  1. 计算机网络——大厂面试问题集合

    摘和 本博文主要收集面试过程中涉及到计算机网络的面试题目与解答.以下博文的问题都来源互联网中收集的面试题目.其中有些的问题的解答可能存在错误.还请大家帮忙指正. 计算机网络知识脑图 计算机网络--计算 ...

  2. 最新Mysql大厂面试必会的34问题

    目录 1.mysql的隔离级别 2.MYSQL性能优化 常用5种方式 3.索引详解 1.何为索引,有什么用? 2.索引的优缺点 3.什么情况下需要建索引? 4.什么情况下不建索引? 5.索引的底层数据 ...

  3. Etcd——大厂面试问题集合

    摘要 本博文主要是讲述etcd在大厂那个的面试问题 面试问题 Raft算法问题 Raft 中一个 Term(任期)是什么意思? Raft 状态机是怎样切换的? 如何保证最短时间内竞选出 Leader, ...

  4. 大厂面试突击——集合篇

    ⭐欢迎订阅<技术专家修炼>专栏,面试10多家大厂总结出的高频面试知识. ⭐更多java面试学习资料,点击文末卡片关注后回复[资料]获取 幸福,不是长生不老,不是大鱼大肉,不是权倾朝野.幸福 ...

  5. MySQL大厂优化方案轻松应对高并发!真牛!

    大家好,我是不才陈某~ 前两天看到字节一个老哥写的帖子,提到高阶开发者必须掌握的技能,其中他明确提出了"精通MySQL". 为啥MySQL对开发人员如此重要? 第一,不管你去面试哪 ...

  6. 阿里P7大牛整理!BAT大厂面试基础题集合,成功入职字节跳动

    都说大厂面试必问源码,可很多人看完MMKV 源码.Handler 源码.Binder 源码.OkHttp 源码等源码记不住,是脑子有问题吗?当然不是!是因为你没有掌握学习源码的技巧. 我的朋友子路,很 ...

  7. androID程序!BAT大厂面试基础题集合,附带学习经验

    没有稳定的工作,只有稳定的能力. 又到了万物复苏的季节,在程序猿这个行当里,作为 Android 开发出生的,在经历了八年的脱发生涯后,有了越来越多的想法和感触 趋势 随着各类移动跨平台的兴起,在 R ...

  8. redis list操作_大厂面试高频Redis,记不住的多操作几次吧

    redis 的基本操作方法 1.redis 的连接 : //实例化redis $redis = new Redis(); //连接$redis->connect('127.0.0.1', 637 ...

  9. 【重点】Java大厂面试10个知识点汇总

    本文收录在猪哥GitHub:https://github.com/pig6/Java 中,本项目收集一线大厂面试.实战.Java学习路线等. 本篇笔记是我五年来的工作学习面试经验&记录,希望对 ...

最新文章

  1. SQL Server 最佳实践分析器使用小结
  2. python语言的数据类型有哪些_Python语言有哪些数据类型
  3. 5GS 协议栈 — GTPv1-U 协议
  4. 应用层的AIDL用法(原)
  5. 本地计算机端口流量,计算机和防火墙上的端口及其用途-101问题
  6. 四川省中职计算机考试题,(四川省计算机等级考试题库.doc
  7. 开发经验分享_04_快速熟悉程序(思路)
  8. vm安装diagram
  9. Python进阶(八)Python中的关键字
  10. 暴风一号1kb病毒又称快捷方式病毒
  11. ug建模和草图切换_从草图到XD,为什么我要切换
  12. 信道估计之MMSE算法
  13. 中英文金额大写转换器
  14. 2017先给自己定一个小目标,比方说先挣一个亿
  15. 痛失阵地,又一家热门BT种子观影网站关停
  16. 手摇计算机发展年代,你知道“手摇式”的计算机吗?你肯定不知道
  17. linux物料管理,SAP-PM 工具管理篇之出入库
  18. 团队作业1——团队展示
  19. 亚马逊影响者红人,用关联视频给卖家带来哪些好处?
  20. React 基础----1

热门文章

  1. 用python语言,用类,对象写类似口袋妖怪战斗
  2. JAVA基础与JAVA基础语法
  3. 电商卖车或成趋势,拼多多卖“劳斯莱斯“就是正确的吗?
  4. VSCode环境下使用J-Link实现ESP32的JTAG 调试
  5. 基于arduino-due,jlink以及OpenOCD搭建zephyr调试平台
  6. DictionaryEntry来循环访问Hashtable对象的实例
  7. android 重置偏好设置密码,android – 按下“重置应用偏好设置”后,我的应用的所有权限都被撤消...
  8. 使用 Print-js 实现打印、预览功能 前端部分
  9. 新手入门:3个数升序排列
  10. 三年外包终上岸,这些公司能不去就不去,这回阿里P6+稳了啊