越看到后面, 越觉得和现在的工作内容差距太大, 待后续真正接触 MySQL 运维时, 再仔细挑着看看吧.
基础知识
Server 层包括连接器、查询缓存、分析器、优化器、执行器等,涵盖 MySQL 的大多数核心服务功能,以及所有的内置函数(如日期、时间、数学和加密函数等),所有跨存储引擎的功能都在这一层实现,比如存储过程、触发器、视图等。
而存储引擎层负责数据的存储和提取。其架构模式是插件式的,支持 InnoDB、MyISAM、Memory 等多个存储引擎。
全部使用长连接后,有些时候 MySQL 占用内存涨得特别快,这是因为 MySQL 在执行过程中临时使用的内存是管理在连接对象里面的。这些资源会在连接断开的时候才释放。所以如果长连接累积下来,可能导致内存占用太大,被系统强行杀掉(OOM),从现象看就是 MySQL 异常重启了。
- 定期断开长连接
- mysql_reset_connection
日志系统
当有一条记录需要更新的时候,InnoDB 引擎就会先把记录写到 redo log,并更新内存,这个时候更新就算完成了。同时,InnoDB 引擎会在适当的时候,将这个操作记录更新到磁盘里面,而这个更新往往是在系统比较空闲的时候做
redo-log | bin-log
- redo log 是 InnoDB 引擎特有的; binlog 是 MySQL 的 Server 层实现的,所有引擎都可以使用。
- redo log 是物理日志,记录的是"在某个数据页上做了什么修改"; binlog 是逻辑日志,记录的是这个语句的原始逻辑,比如 “update T set c=c+1 where id=2”
- redo log 是循环写的,空间固定会用完; binlog 是可以追加写入的。“追加写”是指 binlog 文件写到一定大小后会切换到下一个,并不会覆盖以前的日志。
update 流程
- 执行器先找引擎取 ID=2 这一行。ID 是主键,引擎直接用树搜索找到这一行。如果 ID=2 这一行所在的数据页本来就在内存中,就直接返回给执行器;否则,需要先从磁盘读入内存,然后再返回。
- 执行器拿到引擎给的行数据,把这个值加上 1,比如原来是 N,现在就是 N+1,得到新的一行数据,再调用引擎接口写入这行新数据。
- 引擎将这行新数据更新到内存中,同时将这个更新操作记录到 redo log 里面,此时 redo log 处于 prepare 状态。然后告知执行器执行完成了,随时可以提交事务。
- 执行器生成这个操作的 binlog,并把 binlog 写入磁盘。
- 执行器调用引擎的提交事务接口,引擎把刚刚写入的 redo log 改成提交(commit)状态,更新完成。
binlog
事务执行过程中,先把日志写到 binlog cache,事务提交的时候,再把 binlog cache 写到 binlog 文件中。
- sync_binlog=0 的时候,表示每次提交事务都只 write,不 fsync;
- sync_binlog=1 的时候,表示每次提交事务都会执行 fsync;
- sync_binlog=N(N>1) 的时候,表示每次提交事务都 write,但累积 N 个事务后才 fsync。
redolog
为控制 redo log 的写入策略,InnoDB 提供了 innodb_flush_log_at_trx_commit
参数,它有三种可能取值:
- 设置为 0 的时候,表示每次事务提交时都只是把 redo log 留在 redo log buffer 中 ;
- 设置为 1 的时候,表示每次事务提交时都将 redo log 直接持久化到磁盘;
- 设置为 2 的时候,表示每次事务提交时都只是把 redo log 写到 page cache。
redo log 主要节省的是随机写磁盘的 IO 消耗(转成顺序写),而 change buffer 主要节省的则是随机读磁盘的 IO 消耗。
redo log buffer, flush at commit stage. (prepare -> binlog -> commit)
索引
当不使用索引查询数据时,会执行 full table scan (explain type ALL)
- hash index 不适合范围查询
- sorted array 适合固定数据
- B-Tree 综合能力强
主键索引的叶子节点存储的是整行数据,非主键索引的叶子节点存储的是主键的值。[查询两次:回表]
覆盖索引是指,索引上的信息足够满足查询请求,不需要再回到主键索引上去取数据。
覆盖索引、前缀索引、索引下推
|
|
[!question] MySQL 为什么有时候会选错索引? 对于每个索引,依据区分度,会计算出[基数],这是判断索引效率的基准
在 MySQL 中,基数是通过采样计算的,即有可能产生问题描述的情况
- 采用 force index 强行选择索引
- 修改 SQL 语句,引导 MySQL 使用期望的索引
- 新建更加合适的索引
锁
- 全局锁:对整个数据库实例加锁 全局锁的典型使用场景是,做全库逻辑备份。
Flush tables with read lock
-> 全库逻辑备份 - 表级锁
lock tables ... read/write
- MDL (metadata lock) 可能后阻塞后面的读操作,在
alter table
时最好加上超时时间
- 行级锁
- 在 InnoDB 事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。这个就是两阶段锁协议。
行锁死锁
- 一种策略是,直接进入等待,直到超时。这个超时时间可以通过参数
innodb_lock_wait_timeout
来设置。 - 另一种策略是,发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。将参数
innodb_deadlock_detect
设置为 on,表示开启这个逻辑。
事务隔离
- 一致性读 [row tx_id]
select k from t where id = 1
仅能读取可见最新版本数据
- 当前读:更新数据都是先读后写的,而这个读 (最新 row tx_id 版本),只能读当前的值,称为“当前读”(current read)。
update t set k = k + 1 where id = 1
select k from t where id = 1 lock for share mode
select k from t where id = 1 for update
可见性
一个数据版本,对于一个事务视图来说,除了自己的更新总是可见以外,有三种情况:
- 版本未提交:不可见
- 版本已提交,但在视图创建后提交:不可见
- 版本已提交,在视图创建前提交:可见
begin/start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个操作 InnoDB 表的语句,事务才真正启动。如果你想要马上启动一个事务,可以使用 start transaction with consistent snapshot 这个命令。
可重复读的核心就是一致性读(consistent read);而事务更新数据的时候,只能用当前读。如果当前的记录的行锁被其他事务占用的话,就需要进入锁等待。
- 对于可重复读,查询只承认在事务启动前就已经提交完成的数据;
- 对于读提交,查询只承认在语句启动前就已经提交完成的数据;
脏页 flush 时机
- redo log 写满: MySQL 会停止所有的更新操作,把 checkpoint 往前推进, redo log 才可以继续写入数据
- 系统内存不足:当需要新的内存页、而内存不够用的时候,需要淘汰一些数据页,空出内存; 如果淘汰的是脏页,需要先进行 flush
- 系统空闲时
- 系统正常关闭
InnoDB 刷脏页的控制策略
innodb_io_capacity
当前系统的 io 处理能力,直接影响 mysql 的脏页 flush 效率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_max_dirty_pages_pct
- redo log 写盘速度
如果一个高配的机器,redo log 设置太小,会发生什么情况
每次事务提交都要写 redo log,如果设置太小,很快就会被写满,也就是下面这个图的状态,这个“环”将很快被写满,write pos 一直追着 CP。
磁盘压力很小,但是数据库出现间歇性的性能下跌。
表空间
innodb_file_per_table
控制表数据存放在共享表空间,还是单独的文件。ON 表示每个 InnoDB 表数据存储在 *.idb
文件中 通过 drop table
命令可以直接删除该表文件。
[!question] 为什么表数据删掉一半,表文件大小不变? InnoDB 是按页存储的,如果删除一整个数据页的记录,整个数据页也就可以被复用了; 问题出在,数据页的复用与记录的复用是不同的。
delete 命令其实只是把记录的位置,或者数据页标记为了“可复用”,但磁盘文件的大小是不会变的。
重建表:
alter table t engine=innodb,ALGORITHM=inplace
如果要收缩一个表,只是 delete 掉表里面不用的数据的话,表文件的大小是不会变的,你还要通过 alter table 命令重建表,才能达到表文件变小的目的。
count (*)
- MyISAM 虽然
count(*)
很快,但不支持事务 [count 额外存储在指定位置] show table status
命令很快,但是不准确- InnoDB
count(*)
时会遍历全表,有性能损耗
count 行为
- count(id) innodb 遍历整张表, 把 id 取出来, 返回给 server 层
- count(1) innodb 遍历整张表, 但不取值
- count(字段) 按行取字段, 且要判断 null
- $count(*)$ 不取值
按照效率排序的话,$count(字段)<count(主键 id)<count(1)≈count(*)$
order by
MySQL 会给每个线程分配一块内存用于排序,称为 sort_buffer。
- 全字段排序
- rowid 排序 (max_length_for_sort_data), 相比全字段多一次回表操作
如何确定一个排序语句是否使用了临时文件 (超出 sort_buffer 容量)
|
|
tmp_table_size 这个配置限制了内存临时表的大小,默认值是 16M。如果临时表大小超过了 tmp_table_size,那么内存临时表就会转成磁盘临时表。
持久化
binlog 的写入逻辑比较简单:事务执行过程中,先把日志写到 binlog cache,事务提交的时候,再把 binlog cache 写到 binlog 文件中。
每个线程有自己 binlog cache,但是共用同一份 binlog 文件。
- 图中的 write,指的就是指把日志写入到文件系统的 page cache,并没有把数据持久化到磁盘,所以速度比较快。
- 图中的 fsync,才是将数据持久化到磁盘的操作。一般情况下,我们认为 fsync 才占磁盘的 IOPS。
write 和 fsync 的时机,是由参数 sync_binlog 控制的:
- sync_binlog=0 的时候,表示每次提交事务都只 write,不 fsync;
- sync_binlog=1 的时候,表示每次提交事务都会执行 fsync;
- sync_binlog=N(N>1) 的时候,表示每次提交事务都 write,但累积 N 个事务后才 fsync。
为了控制 redo log 的写入策略,InnoDB 提供了 innodb_flush_log_at_trx_commit 参数,它有三种可能取值:
- 设置为 0 的时候,表示每次事务提交时都只是把 redo log 留在 redo log buffer 中 ;
- 设置为 1 的时候,表示每次事务提交时都将 redo log 直接持久化到磁盘;
- 设置为 2 的时候,表示每次事务提交时都只是把 redo log 写到 page cache。
WAL 机制主要得益于两个方面:
- redo log 和 binlog 都是顺序写,磁盘的顺序写比随机写速度要快;
- 组提交机制,可以大幅度降低磁盘的 IOPS 消耗。
主备
binlog 的三种格式
- statement (sql 原本的内容)
- row (将 where 条件转换为 ID, 保证数据一致性) 占用很大空间 -> 便于恢复数据
- mixed (根据当前语句选用不同的格式)
有些语句的执行结果是依赖于上下文命令的,直接执行的结果很可能是错误的。
读写分离 - 延迟
- 强制走主库
- sleep
- 判断主备无延迟
- show slave status - seconds_behind_master
- Retrieved_Gtid_set / Executed_Gtid_set
- 配合 semi-sync replication
- 等主库位点
select master_pos_wait(file, pos[, timeout]);
- 等 GTID
select wait_for_executed_gtid_set(gtid_set, 1);
数据库是否正常
建议把 innodb_thread_concurrency 设置为 64~128 之间的值。(并发查询数量限制, 不影响并发连接数)
在线程进入锁等待以后,并发线程的计数会减一,也就是说等行锁(也包括间隙锁)的线程是不算数的。
select * from mysql.health_check;
MySQL 5.6 版本以后提供的 performance_schema 库,在 file_summary_by_event_name 表里统计了每次 IO 请求的时间。
误删数据对应
- 把 sql_safe_updates 参数设置为 on
- 上线前的 SQL 审计
即使配置了 binlog_format=row,执行 truncate/drop 时,记录的 binlog 还是 statement 格式。binlog 里面就只有一个 truncate/drop 语句,这些信息是恢复不出数据的。
全表查询
MySQL 是 “边读边发的”, net_buffer (16K); 当 net_buffer 写满了, 就向客户端发送数据. (sending_to_client 状态)
mysql query 线程的 sending_data 仅代表 query 开始执行, 不一定正在发送数据, 可能在等待锁. 仅当一个线程处于“等待客户端接收结果”的状态,才会显示"Sending to client";
- MySQL 查询语句进入执行阶段后,首先把状态设置成 “Sending data”;
- 然后,发送执行结果的列相关的信息 (meta data) 给客户端;
- 再继续执行语句的流程;
- 执行完成后,把状态设置成空字符串。
buffer_pool 的数据采用了魔改的 LRU 算法, 区分出 young/old 区域 (分代). 以防止大量查询旧数据, 导致整个 buffer_pool 的原始数据都失效.
何时 join
能不能使用 join 语句?
- 如果可以使用 Index Nested-Loop Join 算法,也就是说可以用上被驱动表上的索引,其实是没问题的;
- 如果使用 Block Nested-Loop Join 算法,扫描行数就会过多。尤其是在大表上的 join 操作,这样可能要扫描被驱动表很多次,会占用大量的系统资源。这种 join 尽量不要用。
如果要使用 join,应该选择大表做驱动表还是选择小表做驱动表?
- 如果是 Index Nested-Loop Join 算法,应该选择小表做驱动表;
- 如果是 Block Nested-Loop Join 算法:
- 在 join_buffer_size 足够大的时候,是一样的
- 在 join_buffer_size 不够大的时候(这种情况更常见),应该选择小表做驱动表
在决定哪个表做驱动表的时候,应该是两个表按照各自的条件过滤,过滤完成之后,计算参与 join 的各个字段的总数据量,数据量小的那个表,就是“小表”,应该作为驱动表。
临时表
- 一个临时表只能被创建它的 session 访问, 对其他线程不可见.
- 临时表经常会被用在复杂查询的优化过程中。
- 内部临时表
- union
- group by
自增主键
主键自增不连续的可能性原因
- 唯一键冲突是导致自增主键 id 不连续的第一种原因。
- 事务回滚也会产生类似的现象,这就是第二种原因。
- 批量申请自增 id 的策略
表数据复制
- mysqldump
mysqldump -h$host -P$port -u$user --add-locks=0 --no-create-info --single-transaction --set-gtid-purged=OFF db1 t --where="a>900" --result-file=/client_tmp/t.sql
- export csv
select * from db1.t where a>900 into outfile '/server_tmp/t.csv';
load data infile '/server_tmp/t.csv' into table db2.t;
- physical copy (.frm, .ibd)
自增 ID
- 表的自增 id 达到上限后,再申请时它的值就不会改变,进而导致继续插入数据时报主键冲突的错误。
- row_id 达到上限后,则会归 0 再重新递增,如果出现相同的 row_id,后写的数据会覆盖之前的数据。
- Xid 只需要不在同一个 binlog 文件中出现重复值即可。虽然理论上会出现重复值,但是概率极小,可以忽略不计。
- InnoDB 的 max_trx_id 递增值每次 MySQL 重启都会被保存起来,所以我们文章中提到的脏读的例子就是一个必现的 bug,好在留给我们的时间还很充裕。
- thread_id 是我们使用中最常见的,而且也是处理得最好的一个自增 id 逻辑了。($2**32-1$ - 0 循环使用)
实践建议
- 避免使用
order by rand()
,会使用 using temporary 和 using filesort,查询代价巨大。 - 如果对字段做了函数计算,就用不上索引了,这是 MySQL 的规定。
- 隐式类型转换会导致全表扫描。[在 MySQL 中,字符串和数字做比较的话,是将字符串转换成数字。]
- 隐式字符编码转换会导致全表扫描。(utf8 -> utf8bm4) 在 join 不同的表时可能发生
questions
普通索引, 唯一索引
- 普通索引会使用 change_buffer 来加速更新操作 (buffer_pool 中一种 buffer, 通过 innodb_change_buffer_max_size 来动态设置)
- 如果数据在内存, 操作内存
- 如果数据不在内存, 操作 change_buffer
- 唯一索引在更新时
- 如果数据已在内存中, 校验唯一性后, 执行更新操作
- 如果数据不在内存, 则会先进行一次 IO 操作, 再对内存进行操作
如果所有的更新后面,都马上伴随着对这个记录的查询,那么应该关闭 change buffer。而在其他情况下,change buffer 都能提升更新性能。
mysql 如何选择索引
优化器选择索引的目的,是找到一个最优的执行方案,并用最小的代价去执行语句。在数据库里面,扫描行数是影响执行代价的因素之一。扫描的行数越少,意味着访问磁盘数据的次数越少,消耗的 CPU 资源越少。优化器还会结合是否使用临时表、是否排序等因素进行综合判断。
一个索引上不同的值越多,这个索引的区分度就越好。而一个索引上不同的值的个数,我们称之为“基数”(cardinality).
- 对于由于索引统计信息不准确导致的问题,你可以用
analyze table
来解决。 - 对于其他优化器误判的情况,你可以在应用端用
force index
来强行指定索引,也可以通过修改语句来引导优化器,还可以通过增加或者删除索引来绕过这个问题。
字符串索引
- 直接创建完整索引,这样可能比较占用空间;
- 创建前缀索引,节省空间,但会增加查询扫描次数,并且不能使用覆盖索引;
- 倒序存储,再创建前缀索引,用于绕过字符串本身前缀的区分度不够的问题;
- 创建 hash 字段索引,查询性能稳定,有额外的存储和计算消耗,跟第三种方式一样,都不支持范围扫描。
查询常见问题
- 长时间不返回
- 大概率是表锁了, show processlist 查看语句执行情况
- 等待 flush
- 等待行级锁
- 慢查询
- 引入读锁, 当前读, 查询速度更快
- $select * from t where id = 1$ -> 800ms
- $select * from t where id = 1 lock in share mode$ -> 0.2ms
加锁规则
- 加锁的基本单位是 next-key lock
- 查找过程中访问到的对象才加锁
- 索引上的等值查询, 给唯一索引加锁时, next-key lock 退化成行锁
- 索引上的等值查询, 向右遍历时且最后一个值不满足等值条件时, next-key lock 退化成间隙锁
- 唯一索引上的范围查询会访问到不满足条件的第一个值为止
如果 MySQL 出现了性能瓶颈, 且在 IO 上, 如何提升性能
- 设置 binlog_group_commit_sync_delay 和 binlog_group_commit_sync_no_delay_count 参数, 减少 binlog 的写盘次数.
- 将 sync_binlog 设置为大于 1 的值 (主机重启会丢失数据)
- 将 innodb_flush_log_at_trx_commit 设置为 2 (主机重启会丢失数据)
References
- MySQL 实战 45 讲 - 极客时间