# Table of Contents - [MySql实战45讲 | MySql实战45讲](#mysql-45-mysql-45-) - [01 基础架构:一条SQL查询语句是如何执行的? | MySql实战45讲](#01-sql-mysql-45-) - [03 事务隔离:为什么你改了我还看不见? | MySql实战45讲](#03-mysql-45-) - [04 深入浅出索引(上) | MySql实战45讲](#04-mysql-45-) - [38 都说InnoDB好,那还要不要使用Memory引擎? | MySql实战45讲](#38-innodb-memory-mysql-45-) - [15 答疑文章(一):日志和索引相关问题 | MySql实战45讲](#15-mysql-45-) - [02 日志系统:一条SQL更新语句是如何执行的? | MySql实战45讲](#02-sql-mysql-45-) - [05 深入浅出索引(下) | MySql实战45讲](#05-mysql-45-) - [07 行锁功过:怎么减少行锁对性能的影响? | MySql实战45讲](#07-mysql-45-) - [31 误删数据后除了跑路,还能怎么办? | MySql实战45讲](#31-mysql-45-) - [林晓斌:我的 MySQL 心路历程 | MySql实战45讲](#-mysql-mysql-45-) - [06 全局锁和表锁 :给表加个字段怎么有这么多阻碍? | MySql实战45讲](#06-mysql-45-) - [23 MySQL是怎么保证数据不丢的? | MySql实战45讲](#23-mysql-mysql-45-) - [11 怎么给字符串字段加索引? | MySql实战45讲](#11-mysql-45-) - [12 为什么我的MySQL会“抖”一下? | MySql实战45讲](#12-mysql-mysql-45-) - [13 为什么表数据删掉一半,表文件大小不变? | MySql实战45讲](#13-mysql-45-) - [10 MySQL为什么有时候会选错索引? | MySql实战45讲](#10-mysql-mysql-45-) - [09 普通索引和唯一索引,应该怎么选择? | MySql实战45讲](#09-mysql-45-) - [14 count(*)这么慢,我该怎么办? | MySql实战45讲](#14-count-mysql-45-) - [08 事务到底是隔离的还是不隔离的? | MySql实战45讲](#08-mysql-45-) - [go语言核心36讲 | Go语言核心36讲](#go-36-go-36-) - [16 “order by”是怎么工作的? | MySql实战45讲](#16-order-by-mysql-45-) - [18 为什么这些SQL语句逻辑相同,性能却差异巨大? | MySql实战45讲](#18-sql-mysql-45-) - [19 为什么我只查一行的语句,也执行这么慢? | MySql实战45讲](#19-mysql-45-) - [17 如何正确地显示随机消息? | MySql实战45讲](#17-mysql-45-) - [20 幻读是什么,幻读有什么问题? | MySql实战45讲](#20-mysql-45-) - [21 为什么我只改一行的语句,锁这么多? | MySql实战45讲](#21-mysql-45-) - [22 MySQL有哪些“饮鸩止渴”提高性能的方法? | MySql实战45讲](#22-mysql-mysql-45-) - [24 MySQL是怎么保证主备一致的? | MySql实战45讲](#24-mysql-mysql-45-) - [25 MySQL是怎么保证高可用的? | MySql实战45讲](#25-mysql-mysql-45-) - [29 如何判断一个数据库是不是出问题了? | MySql实战45讲](#29-mysql-45-) - [27 主库出问题了,从库怎么办? | MySql实战45讲](#27-mysql-45-) - [33 我查这么多数据,会不会把数据库内存打爆? | MySql实战45讲](#33-mysql-45-) - [26 备库为什么会延迟好几个小时? | MySql实战45讲](#26-mysql-45-) - [32 为什么还有kill不掉的语句? | MySql实战45讲](#32-kill-mysql-45-) - [34 到底可不可以使用join? | MySql实战45讲](#34-join-mysql-45-) - [40 insert语句的锁为什么这么多? | MySql实战45讲](#40-insert-mysql-45-) - [41 怎么最快地复制一张表? | MySql实战45讲](#41-mysql-45-) - [45 自增id用完怎么办? | MySql实战45讲](#45-id-mysql-45-) - [37 什么时候会使用内部临时表? | MySql实战45讲](#37-mysql-45-) - [01 | 工作区和GOPATH | Go语言核心36讲](#01-gopath-go-36-) - [30 答疑文章(二):用动态的观点看加锁 | MySql实战45讲](#30-mysql-45-) - [28 读写分离有哪些坑? | MySql实战45讲](#28-mysql-45-) - [35 join语句怎么优化? | MySql实战45讲](#35-join-mysql-45-) - [21 | panic函数、recover函数以及defer语句 (上) | Go语言核心36讲](#21-panic-recover-defer-go-36-) - [23 | 测试的基本规则和流程 (上) | Go语言核心36讲](#23-go-36-) - [24 | 测试的基本规则和流程(下) | Go语言核心36讲](#24-go-36-) - [点线网面,一起构建MySQL知识网络 | MySql实战45讲](#-mysql-mysql-45-) - [42 grant之后要跟着flush privileges吗? | MySql实战45讲](#42-grant-flush-privileges-mysql-45-) - [02 | 命令源码文件 | Go语言核心36讲](#02-go-36-) - [05 | 程序实体的那些事儿(中) | Go语言核心36讲](#05-go-36-) - [43 要不要使用分区表? | MySql实战45讲](#43-mysql-45-) - [03 | 库源码文件 | Go语言核心36讲](#03-go-36-) - [36 为什么临时表可以重名? | MySql实战45讲](#36-mysql-45-) - [09 | 字典的操作和约束 | Go语言核心36讲](#09-go-36-) - [07 | 数组和切片 | Go语言核心36讲](#07-go-36-) - [29 | 原子操作(上) | Go语言核心36讲](#29-go-36-) - [39 自增主键为什么不是连续的? | MySql实战45讲](#39-mysql-45-) - [26 | sync.Mutex与sync.RWMutex | Go语言核心36讲](#26-sync-mutex-sync-rwmutex-go-36-) - [25 | 更多的测试手法 | Go语言核心36讲](#25-go-36-) - [08 | container包中的那些容器 | Go语言核心36讲](#08-container-go-36-) - [10 | 通道的基本操作 | Go语言核心36讲](#10-go-36-) - [12 | 使用函数的正确姿势 | Go语言核心36讲](#12-go-36-) - [04 | 程序实体的那些事儿(上) | Go语言核心36讲](#04-go-36-) - [28 | 条件变量sync.Cond (下) | Go语言核心36讲](#28-sync-cond-go-36-) - [11 | 通道的高级玩法 | Go语言核心36讲](#11-go-36-) - [06 | 程序实体的那些事儿 (下) | Go语言核心36讲](#06-go-36-) - [22 | panic函数、recover函数以及defer语句(下) | Go语言核心36讲](#22-panic-recover-defer-go-36-) - [27 | 条件变量sync.Cond (上) | Go语言核心36讲](#27-sync-cond-go-36-) - [44 答疑文章(三):说一说这些好问题 | MySql实战45讲](#44-mysql-45-) - [33 | 临时对象池sync.Pool | Go语言核心36讲](#33-sync-pool-go-36-) - [42 | bufio包中的数据类型 (上) | Go语言核心36讲](#42-bufio-go-36-) - [17 | go语句及其执行规则(下) | Go语言核心36讲](#17-go-go-36-) - [30 | 原子操作(下) | Go语言核心36讲](#30-go-36-) - [15 | 关于指针的有限操作 | Go语言核心36讲](#15-go-36-) - [41 | io包中的接口和工具 (下) | Go语言核心36讲](#41-io-go-36-) - [13 | 结构体及其方法的使用法门 | Go语言核心36讲](#13-go-36-) - [31 | sync.WaitGroup和sync.Once | Go语言核心36讲](#31-sync-waitgroup-sync-once-go-36-) - [34 | 并发安全字典sync.Map (上) | Go语言核心36讲](#34-sync-map-go-36-) - [43 | bufio包中的数据类型(下) | Go语言核心36讲](#43-bufio-go-36-) - [19 | 错误处理(上) | Go语言核心36讲](#19-go-36-) - [50 | 学习专栏的正确姿势 | Go语言核心36讲](#50-go-36-) - [16 | go语句及其执行规则(上) | Go语言核心36讲](#16-go-go-36-) - [18 | if语句、for语句和switch语句 | Go语言核心36讲](#18-if-for-switch-go-36-) - [39 | bytes包与字节串操作(下) | Go语言核心36讲](#39-bytes-go-36-) - [46 | 访问网络服务 | Go语言核心36讲](#46-go-36-) - [44 | 使用os包中的API (上) | Go语言核心36讲](#44-os-api-go-36-) - [20 | 错误处理 (下) | Go语言核心36讲](#20-go-36-) - [35 | 并发安全字典sync.Map (下) | Go语言核心36讲](#35-sync-map-go-36-) - [45 | 使用os包中的API (下) | Go语言核心36讲](#45-os-api-go-36-) - [38 | bytes包与字节串操作(上) | Go语言核心36讲](#38-bytes-go-36-) - [14 | 接口类型的合理运用 | Go语言核心36讲](#14-go-36-) - [36 | unicode与字符编码 | Go语言核心36讲](#36-unicode-go-36-) - [40 | io包中的接口和工具 (上) | Go语言核心36讲](#40-io-go-36-) - [47 | 基于HTTP协议的网络服务 | Go语言核心36讲](#47-http-go-36-) - [49 | 程序性能分析基础(下) | Go语言核心36讲](#49-go-36-) - [48 | 程序性能分析基础(上) | Go语言核心36讲](#48-go-36-) - [37 | strings包与字符串操作 | Go语言核心36讲](#37-strings-go-36-) - [32 | context.Context类型 | Go语言核心36讲](#32-context-context-go-36-) --- # MySql实战45讲 | MySql实战45讲 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/#ke-cheng-jie-shao) 课程介绍 MySQL 使用和面试中遇到的问题,很多人会通过搜索别人的经验来解决 ,零散不成体系。实际上只要理解了 MySQL 的底层工作原理,就能很快地直戳问题的本质。 本专栏一共 **45** 讲,**全集**更新完毕,通过探讨 MySQL 实战中最常见的 **36 个** 痛点问题,串起各个零散的知识点,配合 **100+** 手绘详解图,由线到面带你构建 MySQL 系统的学习路径。 在专栏中,丁奇结合自己十多年来,通过 MySQL 源码找答案的经历,梳理了 **MySQL 的主线知识**,比如**事务、索引、锁**等;并基于这条主线,带你缕清**概念、机制、原理、案例分析**以及本质,让你真正能掌握 **MySQL 核心技术与底层原理。** 值得一提的是,每篇文章中都附有**实践案例**,给你从理论到实战的系统性指导,让你少走弯路,彻底搞懂 MySQL。 本课程共包括两大模块。 **模块一,基础篇**。为你深入浅出地讲述 MySQL 核心知识,涵盖 MySQL 基础架构、日志系统、事务隔离、锁等内容。 **模块二,实践篇**。将从一个个关键的数据库问题出发,分析数据库原理,并给出实践指导。每个问题,都不只是简单地给出答案,而是从为什么要这么想、到底该怎样做出发,让你能够知其所以然,都将能够解决你平时工作中的一个疑惑点。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/#ke-cheng-mu-lu) 课程目录 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252FgXcEUVhmNOFyPku5fQYK%252Fimage.png%3Falt%3Dmedia%26token%3Dd0452077-4062-43a4-9917-2e2e0cf127ab&width=768&dpr=4&quality=100&sign=2f3f9e79&sv=2) > 本文档仅供学习使用,如您有条件,可支持作者:[课程链接](https://time.geekbang.org/column/intro/100020801) ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252F1digzcq7Z7a19GHVMVd1%252Fimage.png%3Falt%3Dmedia%26token%3D2a1a993a-2f00-49b0-9493-aa6eb59dd96e&width=768&dpr=4&quality=100&sign=cda6ad96&sv=2) [Next01 基础架构:一条SQL查询语句是如何执行的?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/01-ji-chu-jia-gou-yi-tiao-sql-cha-xun-yu-ju-shi-ru-he-zhi-hang-de) Last updated 2 years ago Was this helpful? --- # 01 基础架构:一条SQL查询语句是如何执行的? | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252FCReHPkB9HgVILCU1069M%252Fimage.png%3Falt%3Dmedia%26token%3D330f0d96-3ab6-446a-b644-a65e9a96e3ff&width=768&dpr=4&quality=100&sign=ddd4f0f4&sv=2) 你好,我是林晓斌。 这是专栏的第一篇文章,我想来跟你聊聊MySQL的基础架构。我们经常说,看一个事儿千万不要直接陷入细节里,你应该先鸟瞰其全貌,这样能够帮助你从高维度理解问题。同样,对于MySQL的学习也是这样。平时我们使用数据库,看到的通常都是一个整体。比如,你有个最简单的表,表里只有一个ID字段,在执行下面这个查询语句时: Copy mysql> select * from T where ID=10; 我们看到的只是输入一条语句,返回一个结果,却不知道这条语句在MySQL内部的执行过程。 所以今天我想和你一起把MySQL拆解一下,看看里面都有哪些“零件”,希望借由这个拆解过程,让你对MySQL有更深入的理解。这样当我们碰到MySQL的一些异常或者问题时,就能够直戳本质,更为快速地定位并解决问题。 下面我给出的是MySQL的基本架构示意图,从中你可以清楚地看到SQL语句在MySQL的各个功能模块中的执行过程。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F0d%2Fd9%2F0d2070e8f84c4801adbfa03bda1f98d9.png&width=768&dpr=4&quality=100&sign=9e1f0f54&sv=2) MySQL的逻辑架构图 大体来说,MySQL可以分为Server层和存储引擎层两部分。 Server层包括连接器、查询缓存、分析器、优化器、执行器等,涵盖MySQL的大多数核心服务功能,以及所有的内置函数(如日期、时间、数学和加密函数等),所有跨存储引擎的功能都在这一层实现,比如存储过程、触发器、视图等。 而存储引擎层负责数据的存储和提取。其架构模式是插件式的,支持InnoDB、MyISAM、Memory等多个存储引擎。现在最常用的存储引擎是InnoDB,它从MySQL 5.5.5版本开始成为了默认存储引擎。 也就是说,你执行create table建表的时候,如果不指定引擎类型,默认使用的就是InnoDB。不过,你也可以通过指定存储引擎的类型来选择别的引擎,比如在create table语句中使用engine=memory, 来指定使用内存引擎创建表。不同存储引擎的表数据存取方式不同,支持的功能也不同,在后面的文章中,我们会讨论到引擎的选择。 从图中不难看出,不同的存储引擎共用一个**Server层**,也就是从连接器到执行器的部分。你可以先对每个组件的名字有个印象,接下来我会结合开头提到的那条SQL语句,带你走一遍整个执行流程,依次看下每个组件的作用。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/01-ji-chu-jia-gou-yi-tiao-sql-cha-xun-yu-ju-shi-ru-he-zhi-hang-de#lian-jie-qi) 连接器 第一步,你会先连接到这个数据库上,这时候接待你的就是连接器。连接器负责跟客户端建立连接、获取权限、维持和管理连接。连接命令一般是这么写的: Copy mysql -h$ip -P$port -u$user -p 输完命令之后,你就需要在交互对话里面输入密码。虽然密码也可以直接跟在-p后面写在命令行中,但这样可能会导致你的密码泄露。如果你连的是生产服务器,强烈建议你不要这么做。 连接命令中的mysql是客户端工具,用来跟服务端建立连接。在完成经典的TCP握手后,连接器就要开始认证你的身份,这个时候用的就是你输入的用户名和密码。 * 如果用户名或密码不对,你就会收到一个"Access denied for user"的错误,然后客户端程序结束执行。 * 如果用户名密码认证通过,连接器会到权限表里面查出你拥有的权限。之后,这个连接里面的权限判断逻辑,都将依赖于此时读到的权限。 这就意味着,一个用户成功建立连接后,即使你用管理员账号对这个用户的权限做了修改,也不会影响已经存在连接的权限。修改完成后,只有再新建的连接才会使用新的权限设置。 连接完成后,如果你没有后续的动作,这个连接就处于空闲状态,你可以在show processlist命令中看到它。文本中这个图是show processlist的结果,其中的Command列显示为“Sleep”的这一行,就表示现在系统里面有一个空闲连接。 客户端如果太长时间没动静,连接器就会自动将它断开。这个时间是由参数wait\_timeout控制的,默认值是8小时。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Ff2%2Fed%2Ff2da4aa3a672d48ec05df97b9f992fed.png&width=768&dpr=4&quality=100&sign=a6ca9386&sv=2) 如果在连接被断开之后,客户端再次发送请求的话,就会收到一个错误提醒: Lost connection to MySQL server during query。这时候如果你要继续,就需要重连,然后再执行请求了。 数据库里面,长连接是指连接成功后,如果客户端持续有请求,则一直使用同一个连接。短连接则是指每次执行完很少的几次查询就断开连接,下次查询再重新建立一个。 建立连接的过程通常是比较复杂的,所以我建议你在使用中要尽量减少建立连接的动作,也就是尽量使用长连接。 但是全部使用长连接后,你可能会发现,有些时候MySQL占用内存涨得特别快,这是因为MySQL在执行过程中临时使用的内存是管理在连接对象里面的。这些资源会在连接断开的时候才释放。所以如果长连接累积下来,可能导致内存占用太大,被系统强行杀掉(OOM),从现象看就是MySQL异常重启了。 怎么解决这个问题呢?你可以考虑以下两种方案。 1. 定期断开长连接。使用一段时间,或者程序里面判断执行过一个占用内存的大查询后,断开连接,之后要查询再重连。 2. 如果你用的是MySQL 5.7或更新版本,可以在每次执行一个比较大的操作后,通过执行 mysql\_reset\_connection来重新初始化连接资源。这个过程不需要重连和重新做权限验证,但是会将连接恢复到刚刚创建完时的状态。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/01-ji-chu-jia-gou-yi-tiao-sql-cha-xun-yu-ju-shi-ru-he-zhi-hang-de#cha-xun-huan-cun) 查询缓存 连接建立完成后,你就可以执行select语句了。执行逻辑就会来到第二步:查询缓存。 MySQL拿到一个查询请求后,会先到查询缓存看看,之前是不是执行过这条语句。之前执行过的语句及其结果可能会以key-value对的形式,被直接缓存在内存中。key是查询的语句,value是查询的结果。如果你的查询能够直接在这个缓存中找到key,那么这个value就会被直接返回给客户端。 如果语句不在查询缓存中,就会继续后面的执行阶段。执行完成后,执行结果会被存入查询缓存中。你可以看到,如果查询命中缓存,MySQL不需要执行后面的复杂操作,就可以直接返回结果,这个效率会很高。 **但是大多数情况下我会建议你不要使用查询缓存,为什么呢?因为查询缓存往往弊大于利。** 查询缓存的失效非常频繁,只要有对一个表的更新,这个表上所有的查询缓存都会被清空。因此很可能你费劲地把结果存起来,还没使用呢,就被一个更新全清空了。对于更新压力大的数据库来说,查询缓存的命中率会非常低。除非你的业务就是有一张静态表,很长时间才会更新一次。比如,一个系统配置表,那这张表上的查询才适合使用查询缓存。 好在MySQL也提供了这种“按需使用”的方式。你可以将参数query\_cache\_type设置成DEMAND,这样对于默认的SQL语句都不使用查询缓存。而对于你确定要使用查询缓存的语句,可以用SQL\_CACHE显式指定,像下面这个语句一样: Copy mysql> select SQL_CACHE * from T where ID=10; 需要注意的是,MySQL 8.0版本直接将查询缓存的整块功能删掉了,也就是说8.0开始彻底没有这个功能了。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/01-ji-chu-jia-gou-yi-tiao-sql-cha-xun-yu-ju-shi-ru-he-zhi-hang-de#fen-xi-qi) 分析器 如果没有命中查询缓存,就要开始真正执行语句了。首先,MySQL需要知道你要做什么,因此需要对SQL语句做解析。 分析器先会做“词法分析”。你输入的是由多个字符串和空格组成的一条SQL语句,MySQL需要识别出里面的字符串分别是什么,代表什么。 MySQL从你输入的"select"这个关键字识别出来,这是一个查询语句。它也要把字符串“T”识别成“表名T”,把字符串“ID”识别成“列ID”。 做完了这些识别以后,就要做“语法分析”。根据词法分析的结果,语法分析器会根据语法规则,判断你输入的这个SQL语句是否满足MySQL语法。 如果你的语句不对,就会收到“You have an error in your SQL syntax”的错误提醒,比如下面这个语句select少打了开头的字母“s”。 Copy mysql> elect * from t where ID=1; ERROR 1064 (42000): You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'elect * from t where ID=1' at line 1 一般语法错误会提示第一个出现错误的位置,所以你要关注的是紧接“use near”的内容。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/01-ji-chu-jia-gou-yi-tiao-sql-cha-xun-yu-ju-shi-ru-he-zhi-hang-de#you-hua-qi) 优化器 经过了分析器,MySQL就知道你要做什么了。在开始执行之前,还要先经过优化器的处理。 优化器是在表里面有多个索引的时候,决定使用哪个索引;或者在一个语句有多表关联(join)的时候,决定各个表的连接顺序。比如你执行下面这样的语句,这个语句是执行两个表的join: Copy mysql> select * from t1 join t2 using(ID) where t1.c=10 and t2.d=20; * 既可以先从表t1里面取出c=10的记录的ID值,再根据ID值关联到表t2,再判断t2里面d的值是否等于20。 * 也可以先从表t2里面取出d=20的记录的ID值,再根据ID值关联到t1,再判断t1里面c的值是否等于10。 这两种执行方法的逻辑结果是一样的,但是执行的效率会有不同,而优化器的作用就是决定选择使用哪一个方案。 优化器阶段完成后,这个语句的执行方案就确定下来了,然后进入执行器阶段。如果你还有一些疑问,比如优化器是怎么选择索引的,有没有可能选择错等等,没关系,我会在后面的文章中单独展开说明优化器的内容。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/01-ji-chu-jia-gou-yi-tiao-sql-cha-xun-yu-ju-shi-ru-he-zhi-hang-de#zhi-xing-qi) 执行器 MySQL通过分析器知道了你要做什么,通过优化器知道了该怎么做,于是就进入了执行器阶段,开始执行语句。 开始执行的时候,要先判断一下你对这个表T有没有执行查询的权限,如果没有,就会返回没有权限的错误,如下所示。 Copy mysql> select * from T where ID=10; ERROR 1142 (42000): SELECT command denied to user 'b'@'localhost' for table 'T' 如果有权限,就打开表继续执行。打开表的时候,执行器就会根据表的引擎定义,去使用这个引擎提供的接口。 比如我们这个例子中的表T中,ID字段没有索引,那么执行器的执行流程是这样的: 1. 调用InnoDB引擎接口取这个表的第一行,判断ID值是不是10,如果不是则跳过,如果是则将这行存在结果集中; 2. 调用引擎接口取“下一行”,重复相同的判断逻辑,直到取到这个表的最后一行。 3. 执行器将上述遍历过程中所有满足条件的行组成的记录集作为结果集返回给客户端。 至此,这个语句就执行完成了。 对于有索引的表,执行的逻辑也差不多。第一次调用的是“取满足条件的第一行”这个接口,之后循环取“满足条件的下一行”这个接口,这些接口都是引擎中已经定义好的。 你会在数据库的慢查询日志中看到一个rows\_examined的字段,表示这个语句执行过程中扫描了多少行。这个值就是在执行器每次调用引擎获取数据行的时候累加的。 在有些场景下,执行器调用一次,在引擎内部则扫描了多行,因此\*\*引擎扫描行数跟rows\_examined并不是完全相同的。\*\*我们后面会专门有一篇文章来讲存储引擎的内部机制,里面会有详细的说明。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/01-ji-chu-jia-gou-yi-tiao-sql-cha-xun-yu-ju-shi-ru-he-zhi-hang-de#xiao-jie) 小结 今天我给你介绍了MySQL的逻辑架构,希望你对一个SQL语句完整执行流程的各个阶段有了一个初步的印象。由于篇幅的限制,我只是用一个查询的例子将各个环节过了一遍。如果你还对每个环节的展开细节存有疑问,也不用担心,后续在实战章节中我还会再提到它们。 我给你留一个问题吧,如果表T中没有字段k,而你执行了这个语句 select \* from T where k=1, 那肯定是会报“不存在这个列”的错误: “Unknown column ‘k’ in ‘where clause’”。你觉得这个错误是在我们上面提到的哪个阶段报出来的呢? > 在执行 SQL 查询的过程中,当出现类似于 "Unknown column 'k' in 'where clause'" 的错误信息时,这是在语义分析阶段报出的错误。 > > 具体的解析和执行过程如下: > > 1. 词法分析和语法分析阶段:在这个阶段,MySQL 解析器将输入的 SQL 查询语句进行词法分析和语法分析,生成语法树(Parse Tree)或语法分析树(Parse Tree)。这个阶段主要是验证查询语句的语法正确性,检查语句中的关键字、标识符、运算符等是否符合语法规则。 > > 2. 语义分析阶段:在语义分析阶段,MySQL 对语法树进行进一步的分析和验证。这个阶段主要是检查语句的语义正确性,包括验证列和表的存在性、数据类型的匹配、约束条件的合法性等。在执行 SQL 查询时,MySQL 需要确保查询中使用的列和表是存在的。 > > > 当执行语句 "SELECT \* FROM T WHERE k=1" 时,MySQL 会在语义分析阶段尝试解析和验证其中的列名。如果表 T 中不存在名为 'k' 的列,MySQL 将报告 "Unknown column 'k' in 'where clause'" 错误,表示查询中引用了不存在的列。 > > 因此,该错误是在语义分析阶段报出的,即在验证查询语句的语义正确性时发现的。在这个阶段,MySQL 检查查询语句是否符合数据库模式的定义和约束条件。 感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fce%2Fd9%2Fce7f4e35916ed1aa49206a53a0547bd9.jpg&width=768&dpr=4&quality=100&sign=49295b8f&sv=2) [PreviousMySql实战45讲](https://jums.gitbook.io/mysql-shi-zhan-45-jiang) [Next02 日志系统:一条SQL更新语句是如何执行的?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/02-ri-zhi-xi-tong-yi-tiao-sql-geng-xin-yu-ju-shi-ru-he-zhi-hang-de) Last updated 2 years ago Was this helpful? --- # 03 事务隔离:为什么你改了我还看不见? | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252FT8rPDHPkxk6efiDNqESl%252Fimage.png%3Falt%3Dmedia%26token%3D961d15c7-6208-4b1a-aeb7-91324c175685&width=768&dpr=4&quality=100&sign=fb880291&sv=2) 提到事务,你肯定不陌生,和数据库打交道的时候,我们总是会用到事务。最经典的例子就是转账,你要给朋友小王转100块钱,而此时你的银行卡只有100块钱。 转账过程具体到程序里会有一系列的操作,比如查询余额、做加减法、更新余额等,这些操作必须保证是一体的,不然等程序查完之后,还没做减法之前,你这100块钱,完全可以借着这个时间差再查一次,然后再给另外一个朋友转账,如果银行这么整,不就乱了么?这时就要用到“事务”这个概念了。 简单来说,事务就是要保证一组数据库操作,要么全部成功,要么全部失败。在MySQL中,事务支持是在引擎层实现的。你现在知道,MySQL是一个支持多引擎的系统,但并不是所有的引擎都支持事务。比如MySQL原生的MyISAM引擎就不支持事务,这也是MyISAM被InnoDB取代的重要原因之一。 今天的文章里,我将会以InnoDB为例,剖析MySQL在事务支持方面的特定实现,并基于原理给出相应的实践建议,希望这些案例能加深你对MySQL事务原理的理解。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/03-shi-wu-ge-li-wei-shi-mo-ni-gai-le-wo-huan-kan-bu-jian#ge-li-xing-yu-ge-li-ji-bie) 隔离性与隔离级别 提到事务,你肯定会想到ACID(Atomicity、Consistency、Isolation、Durability,即原子性、一致性、隔离性、持久性),今天我们就来说说其中I,也就是“隔离性”。 当数据库上有多个事务同时执行的时候,就可能出现脏读(dirty read)、不可重复读(non-repeatable read)、幻读(phantom read)的问题,为了解决这些问题,就有了“隔离级别”的概念。 在谈隔离级别之前,你首先要知道,你隔离得越严实,效率就会越低。因此很多时候,我们都要在二者之间寻找一个平衡点。SQL标准的事务隔离级别包括:读未提交(read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(serializable )。下面我逐一为你解释: * 读未提交是指,一个事务还没提交时,它做的变更就能被别的事务看到。 * 读提交是指,一个事务提交之后,它做的变更才会被其他事务看到。 * 可重复读是指,一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。 * 串行化,顾名思义是对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。 其中“读提交”和“可重复读”比较难理解,所以我用一个例子说明这几种隔离级别。假设数据表T中只有一列,其中一行的值为1,下面是按照时间顺序执行两个事务的行为。 Copy mysql> create table T(c int) engine=InnoDB; insert into T(c) values(1); 我们来看看在不同的隔离级别下,事务A会有哪些不同的返回结果,也就是图里面V1、V2、V3的返回值分别是什么。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F7d%2Ff8%2F7dea45932a6b722eb069d2264d0066f8.png&width=768&dpr=4&quality=100&sign=cf75a651&sv=2) * 若隔离级别是“读未提交”, 则V1的值就是2。这时候事务B虽然还没有提交,但是结果已经被A看到了。因此,V2、V3也都是2。 * 若隔离级别是“读提交”,则V1是1,V2的值是2。事务B的更新在提交后才能被A看到。所以, V3的值也是2。 * 若隔离级别是“可重复读”,则V1、V2是1,V3是2。之所以V2还是1,遵循的就是这个要求:事务在执行期间看到的数据前后必须是一致的。 * 若隔离级别是“串行化”,则在事务B执行“将1改成2”的时候,会被锁住。直到事务A提交后,事务B才可以继续执行。所以从A的角度看, V1、V2值是1,V3的值是2。 在实现上,数据库里面会创建一个视图,访问的时候以视图的逻辑结果为准。在“可重复读”隔离级别下,这个视图是在事务启动时创建的,整个事务存在期间都用这个视图。在“读提交”隔离级别下,这个视图是在每个SQL语句开始执行的时候创建的。这里需要注意的是,“读未提交”隔离级别下直接返回记录上的最新值,没有视图概念;而“串行化”隔离级别下直接用加锁的方式来避免并行访问。 我们可以看到在不同的隔离级别下,数据库行为是有所不同的。Oracle数据库的默认隔离级别其实就是“读提交”,因此对于一些从Oracle迁移到MySQL的应用,为保证数据库隔离级别的一致,你一定要记得将MySQL的隔离级别设置为“读提交”。 配置的方式是,将启动参数transaction-isolation的值设置成READ-COMMITTED。你可以用show variables来查看当前的值。 Copy mysql> show variables like 'transaction_isolation'; +-----------------------+----------------+ | Variable_name | Value | +-----------------------+----------------+ | transaction_isolation | READ-COMMITTED | +-----------------------+----------------+ 总结来说,存在即合理,哪个隔离级别都有它自己的使用场景,你要根据自己的业务情况来定。我想**你可能会问那什么时候需要“可重复读”的场景呢**?我们来看一个数据校对逻辑的案例。 假设你在管理一个个人银行账户表。一个表存了每个月月底的余额,一个表存了账单明细。这时候你要做数据校对,也就是判断上个月的余额和当前余额的差额,是否与本月的账单明细一致。你一定希望在校对过程中,即使有用户发生了一笔新的交易,也不影响你的校对结果。 这时候使用“可重复读”隔离级别就很方便。事务启动时的视图可以认为是静态的,不受其他事务更新的影响。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/03-shi-wu-ge-li-wei-shi-mo-ni-gai-le-wo-huan-kan-bu-jian#shi-wu-ge-li-de-shi-xian) 事务隔离的实现 理解了事务的隔离级别,我们再来看看事务隔离具体是怎么实现的。这里我们展开说明“可重复读”。 在MySQL中,实际上每条记录在更新的时候都会同时记录一条回滚操作。记录上的最新值,通过回滚操作,都可以得到前一个状态的值。 假设一个值从1被按顺序改成了2、3、4,在回滚日志里面就会有类似下面的记录。 当前值是4,但是在查询这条记录的时候,不同时刻启动的事务会有不同的read-view。如图中看到的,在视图A、B、C里面,这一个记录的值分别是1、2、4,同一条记录在系统中可以存在多个版本,就是数据库的多版本并发控制(MVCC)。对于read-view A,要得到1,就必须将当前值依次执行图中所有的回滚操作得到。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fd9%2Fee%2Fd9c313809e5ac148fc39feff532f0fee.png&width=768&dpr=4&quality=100&sign=7a252b84&sv=2) 同时你会发现,即使现在有另外一个事务正在将4改成5,这个事务跟read-view A、B、C对应的事务是不会冲突的。 你一定会问,回滚日志总不能一直保留吧,什么时候删除呢?答案是,在不需要的时候才删除。也就是说,系统会判断,当没有事务再需要用到这些回滚日志时,回滚日志会被删除。 什么时候才不需要了呢?就是当系统里没有比这个回滚日志更早的read-view的时候。 基于上面的说明,我们来讨论一下为什么建议你尽量不要使用长事务。 长事务意味着系统里面会存在很老的事务视图。由于这些事务随时可能访问数据库里面的任何数据,所以这个事务提交之前,数据库里面它可能用到的回滚记录都必须保留,这就会导致大量占用存储空间。 在MySQL 5.5及以前的版本,回滚日志是跟数据字典一起放在ibdata文件里的,即使长事务最终提交,回滚段被清理,文件也不会变小。我见过数据只有20GB,而回滚段有200GB的库。最终只好为了清理回滚段,重建整个库。 除了对回滚段的影响,长事务还占用锁资源,也可能拖垮整个库,这个我们会在后面讲锁的时候展开。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/03-shi-wu-ge-li-wei-shi-mo-ni-gai-le-wo-huan-kan-bu-jian#shi-wu-de-qi-dong-fang-shi) 事务的启动方式 如前面所述,长事务有这些潜在风险,我当然是建议你尽量避免。其实很多时候业务开发同学并不是有意使用长事务,通常是由于误用所致。MySQL的事务启动方式有以下几种: 1. 显式启动事务语句, begin 或 start transaction。配套的提交语句是commit,回滚语句是rollback。 2. set autocommit=0,这个命令会将这个线程的自动提交关掉。意味着如果你只执行一个select语句,这个事务就启动了,而且并不会自动提交。这个事务持续存在直到你主动执行commit 或 rollback 语句,或者断开连接。 有些客户端连接框架会默认连接成功后先执行一个set autocommit=0的命令。这就导致接下来的查询都在事务中,如果是长连接,就导致了意外的长事务。 因此,我会建议你总是使用set autocommit=1, 通过显式语句的方式来启动事务。 但是有的开发同学会纠结“多一次交互”的问题。对于一个需要频繁使用事务的业务,第二种方式每个事务在开始时都不需要主动执行一次 “begin”,减少了语句的交互次数。如果你也有这个顾虑,我建议你使用commit work and chain语法。 在autocommit为1的情况下,用begin显式启动的事务,如果执行commit则提交事务。如果执行 commit work and chain,则是提交事务并自动启动下一个事务,这样也省去了再次执行begin语句的开销。同时带来的好处是从程序开发的角度明确地知道每个语句是否处于事务中。 你可以在information\_schema库的innodb\_trx这个表中查询长事务,比如下面这个语句,用于查找持续时间超过60s的事务。 Copy select * from information_schema.innodb_trx where TIME_TO_SEC(timediff(now(),trx_started))>60 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/03-shi-wu-ge-li-wei-shi-mo-ni-gai-le-wo-huan-kan-bu-jian#xiao-jie) 小结 这篇文章里面,我介绍了MySQL的事务隔离级别的现象和实现,根据实现原理分析了长事务存在的风险,以及如何用正确的方式避免长事务。希望我举的例子能够帮助你理解事务,并更好地使用MySQL的事务特性。 我给你留一个问题吧。你现在知道了系统里面应该避免长事务,如果你是业务开发负责人同时也是数据库负责人,你会有什么方案来避免出现或者处理这种情况呢? 你可以把你的思考和观点写在留言区里,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/03-shi-wu-ge-li-wei-shi-mo-ni-gai-le-wo-huan-kan-bu-jian#shang-qi-wen-ti-shi-jian) 上期问题时间 在上期文章的最后,我给你留下的问题是一天一备跟一周一备的对比。 好处是“最长恢复时间”更短。 在一天一备的模式里,最坏情况下需要应用一天的binlog。比如,你每天0点做一次全量备份,而要恢复出一个到昨天晚上23点的备份。 一周一备最坏情况就要应用一周的binlog了。 系统的对应指标就是 @尼古拉斯·赵四 @慕塔 提到的RTO(恢复目标时间)。 当然这个是有成本的,因为更频繁全量备份需要消耗更多存储空间,所以这个RTO是成本换来的,就需要你根据业务重要性来评估了。 同时也感谢 @super blue cat、@高枕、@Jason 留下了高质量的评论。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fce%2Fd9%2Fce7f4e35916ed1aa49206a53a0547bd9.jpg&width=768&dpr=4&quality=100&sign=49295b8f&sv=2) [Previous02 日志系统:一条SQL更新语句是如何执行的?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/02-ri-zhi-xi-tong-yi-tiao-sql-geng-xin-yu-ju-shi-ru-he-zhi-hang-de) [Next04 深入浅出索引(上)](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/04-shen-ru-qian-chu-suo-yin-shang) Last updated 2 years ago Was this helpful? --- # 04 深入浅出索引(上) | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252F4HJDcrn0IH0dtwnUUDfJ%252Fimage.png%3Falt%3Dmedia%26token%3D437bef8d-0337-42d7-a4a6-cce8eb05f500&width=768&dpr=4&quality=100&sign=16f8f67f&sv=2) 提到数据库索引,我想你并不陌生,在日常工作中会经常接触到。比如某一个SQL查询比较慢,分析完原因之后,你可能就会说“给某个字段加个索引吧”之类的解决方案。但到底什么是索引,索引又是如何工作的呢?今天就让我们一起来聊聊这个话题吧。 数据库索引的内容比较多,我分成了上下两篇文章。索引是数据库系统里面最重要的概念之一,所以我希望你能够耐心看完。在后面的实战文章中,我也会经常引用这两篇文章中提到的知识点,加深你对数据库索引的理解。 一句话简单来说,索引的出现其实就是为了提高数据查询的效率,就像书的目录一样。一本500页的书,如果你想快速找到其中的某一个知识点,在不借助目录的情况下,那我估计你可得找一会儿。同样,对于数据库的表而言,索引其实就是它的“目录”。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/04-shen-ru-qian-chu-suo-yin-shang#suo-yin-de-chang-jian-mo-xing) 索引的常见模型 索引的出现是为了提高查询效率,但是实现索引的方式却有很多种,所以这里也就引入了索引模型的概念。可以用于提高读写效率的数据结构很多,这里我先给你介绍三种常见、也比较简单的数据结构,它们分别是哈希表、有序数组和搜索树。 下面我主要从使用的角度,为你简单分析一下这三种模型的区别。 哈希表是一种以键-值(key-value)存储数据的结构,我们只要输入待查找的值即key,就可以找到其对应的值即Value。哈希的思路很简单,把值放在数组里,用一个哈希函数把key换算成一个确定的位置,然后把value放在数组的这个位置。 不可避免地,多个key值经过哈希函数的换算,会出现同一个值的情况。处理这种情况的一种方法是,拉出一个链表。 假设,你现在维护着一个身份证信息和姓名的表,需要根据身份证号查找对应的名字,这时对应的哈希索引的示意图如下所示: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F0c%2F57%2F0c62b601afda86fe5d0fe57346ace957.png&width=768&dpr=4&quality=100&sign=698cd2cb&sv=2) 图1 哈希表示意图 图中,User2和User4根据身份证号算出来的值都是N,但没关系,后面还跟了一个链表。假设,这时候你要查ID\_card\_n2对应的名字是什么,处理步骤就是:首先,将ID\_card\_n2通过哈希函数算出N;然后,按顺序遍历,找到User2。 需要注意的是,图中四个ID\_card\_n的值并不是递增的,这样做的好处是增加新的User时速度会很快,只需要往后追加。但缺点是,因为不是有序的,所以哈希索引做区间查询的速度是很慢的。 你可以设想下,如果你现在要找身份证号在\[ID\_card\_X, ID\_card\_Y\]这个区间的所有用户,就必须全部扫描一遍了。 所以,**哈希表这种结构适用于只有等值查询的场景**,比如Memcached及其他一些NoSQL引擎。 而**有序数组在等值查询和范围查询场景中的性能就都非常优秀**。还是上面这个根据身份证号查名字的例子,如果我们使用有序数组来实现的话,示意图如下所示: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fbf%2F49%2Fbfc907a92f99cadf5493cf0afac9ca49.png&width=768&dpr=4&quality=100&sign=32a129f&sv=2) 图2 有序数组示意图 这里我们假设身份证号没有重复,这个数组就是按照身份证号递增的顺序保存的。这时候如果你要查ID\_card\_n2对应的名字,用二分法就可以快速得到,这个时间复杂度是O(log(N))。 同时很显然,这个索引结构支持范围查询。你要查身份证号在\[ID\_card\_X, ID\_card\_Y\]区间的User,可以先用二分法找到ID\_card\_X(如果不存在ID\_card\_X,就找到大于ID\_card\_X的第一个User),然后向右遍历,直到查到第一个大于ID\_card\_Y的身份证号,退出循环。 如果仅仅看查询效率,有序数组就是最好的数据结构了。但是,在需要更新数据的时候就麻烦了,你往中间插入一个记录就必须得挪动后面所有的记录,成本太高。 所以,**有序数组索引只适用于静态存储引擎**,比如你要保存的是2017年某个城市的所有人口信息,这类不会再修改的数据。 二叉搜索树也是课本里的经典数据结构了。还是上面根据身份证号查名字的例子,如果我们用二叉搜索树来实现的话,示意图如下所示: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F04%2F68%2F04fb9d24065635a6a637c25ba9ddde68.png&width=768&dpr=4&quality=100&sign=32d3184b&sv=2) 图3 二叉搜索树示意图 二叉搜索树的特点是:每个节点的左儿子小于父节点,父节点又小于右儿子。这样如果你要查ID\_card\_n2的话,按照图中的搜索顺序就是按照UserA -> UserC -> UserF -> User2这个路径得到。这个时间复杂度是O(log(N))。 当然为了维持O(log(N))的查询复杂度,你就需要保持这棵树是平衡二叉树。为了做这个保证,更新的时间复杂度也是O(log(N))。 树可以有二叉,也可以有多叉。多叉树就是每个节点有多个儿子,儿子之间的大小保证从左到右递增。二叉树是搜索效率最高的,但是实际上大多数的数据库存储却并不使用二叉树。其原因是,索引不止存在内存中,还要写到磁盘上。 你可以想象一下一棵100万节点的平衡二叉树,树高20。一次查询可能需要访问20个数据块。在机械硬盘时代,从磁盘随机读一个数据块需要10 ms左右的寻址时间。也就是说,对于一个100万行的表,如果使用二叉树来存储,单独访问一个行可能需要20个10 ms的时间,这个查询可真够慢的。 为了让一个查询尽量少地读磁盘,就必须让查询过程访问尽量少的数据块。那么,我们就不应该使用二叉树,而是要使用“N叉”树。这里,“N叉”树中的“N”取决于数据块的大小。 以InnoDB的一个整数字段索引为例,这个N差不多是1200。这棵树高是4的时候,就可以存1200的3次方个值,这已经17亿了。考虑到树根的数据块总是在内存中的,一个10亿行的表上一个整数字段的索引,查找一个值最多只需要访问3次磁盘。其实,树的第二层也有很大概率在内存中,那么访问磁盘的平均次数就更少了。 N叉树由于在读写上的性能优点,以及适配磁盘的访问模式,已经被广泛应用在数据库引擎中了。 不管是哈希还是有序数组,或者N叉树,它们都是不断迭代、不断优化的产物或者解决方案。数据库技术发展到今天,跳表、LSM树等数据结构也被用于引擎设计中,这里我就不再一一展开了。 你心里要有个概念,数据库底层存储的核心就是基于这些数据模型的。每碰到一个新数据库,我们需要先关注它的数据模型,这样才能从理论上分析出这个数据库的适用场景。 截止到这里,我用了半篇文章的篇幅和你介绍了不同的数据结构,以及它们的适用场景,你可能会觉得有些枯燥。但是,我建议你还是要多花一些时间来理解这部分内容,毕竟这是数据库处理数据的核心概念之一,在分析问题的时候会经常用到。当你理解了索引的模型后,就会发现在分析问题的时候会有一个更清晰的视角,体会到引擎设计的精妙之处。 现在,我们一起进入相对偏实战的内容吧。 在MySQL中,索引是在存储引擎层实现的,所以并没有统一的索引标准,即不同存储引擎的索引的工作方式并不一样。而即使多个存储引擎支持同一种类型的索引,其底层的实现也可能不同。由于InnoDB存储引擎在MySQL数据库中使用最为广泛,所以下面我就以InnoDB为例,和你分析一下其中的索引模型。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/04-shen-ru-qian-chu-suo-yin-shang#innodb-de-suo-yin-mo-xing) InnoDB 的索引模型 在InnoDB中,表都是根据主键顺序以索引的形式存放的,这种存储方式的表称为索引组织表。又因为前面我们提到的,InnoDB使用了B+树索引模型,所以数据都是存储在B+树中的。 每一个索引在InnoDB里面对应一棵B+树。 假设,我们有一个主键列为ID的表,表中有字段k,并且在k上有索引。 这个表的建表语句是: Copy mysql> create table T( id int primary key, k int not null, name varchar(16), index (k))engine=InnoDB; 表中R1~R5的(ID,k)值分别为(100,1)、(200,2)、(300,3)、(500,5)和(600,6),两棵树的示例示意图如下。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fdc%2F8d%2Fdcda101051f28502bd5c4402b292e38d.png&width=768&dpr=4&quality=100&sign=48cb738b&sv=2) 图4 InnoDB的索引组织结构 从图中不难看出,根据叶子节点的内容,索引类型分为主键索引和非主键索引。 主键索引的叶子节点存的是整行数据。在InnoDB里,主键索引也被称为聚簇索引(clustered index)。 非主键索引的叶子节点内容是主键的值。在InnoDB里,非主键索引也被称为二级索引(secondary index)。 根据上面的索引结构说明,我们来讨论一个问题:**基于主键索引和普通索引的查询有什么区别?** * 如果语句是select \* from T where ID=500,即主键查询方式,则只需要搜索ID这棵B+树; * 如果语句是select \* from T where k=5,即普通索引查询方式,则需要先搜索k索引树,得到ID的值为500,再到ID索引树搜索一次。这个过程称为回表。 也就是说,基于非主键索引的查询需要多扫描一棵索引树。因此,我们在应用中应该尽量使用主键查询。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/04-shen-ru-qian-chu-suo-yin-shang#suo-yin-wei-hu) 索引维护 B+树为了维护索引有序性,在插入新值的时候需要做必要的维护。以上面这个图为例,如果插入新的行ID值为700,则只需要在R5的记录后面插入一个新记录。如果新插入的ID值为400,就相对麻烦了,需要逻辑上挪动后面的数据,空出位置。 而更糟的情况是,如果R5所在的数据页已经满了,根据B+树的算法,这时候需要申请一个新的数据页,然后挪动部分数据过去。这个过程称为页分裂。在这种情况下,性能自然会受影响。 除了性能外,页分裂操作还影响数据页的利用率。原本放在一个页的数据,现在分到两个页中,整体空间利用率降低大约50%。 当然有分裂就有合并。当相邻两个页由于删除了数据,利用率很低之后,会将数据页做合并。合并的过程,可以认为是分裂过程的逆过程。 基于上面的索引维护过程说明,我们来讨论一个案例: > 你可能在一些建表规范里面见到过类似的描述,要求建表语句里一定要有自增主键。当然事无绝对,我们来分析一下哪些场景下应该使用自增主键,而哪些场景下不应该。 自增主键是指自增列上定义的主键,在建表语句中一般是这么定义的: NOT NULL PRIMARY KEY AUTO\_INCREMENT。 插入新记录的时候可以不指定ID的值,系统会获取当前ID最大值加1作为下一条记录的ID值。 也就是说,自增主键的插入数据模式,正符合了我们前面提到的递增插入的场景。每次插入一条新记录,都是追加操作,都不涉及到挪动其他记录,也不会触发叶子节点的分裂。 而有业务逻辑的字段做主键,则往往不容易保证有序插入,这样写数据成本相对较高。 除了考虑性能外,我们还可以从存储空间的角度来看。假设你的表中确实有一个唯一字段,比如字符串类型的身份证号,那应该用身份证号做主键,还是用自增字段做主键呢? 由于每个非主键索引的叶子节点上都是主键的值。如果用身份证号做主键,那么每个二级索引的叶子节点占用约20个字节,而如果用整型做主键,则只要4个字节,如果是长整型(bigint)则是8个字节。 **显然,主键长度越小,普通索引的叶子节点就越小,普通索引占用的空间也就越小。** 所以,从性能和存储空间方面考量,自增主键往往是更合理的选择。 有没有什么场景适合用业务字段直接做主键的呢?还是有的。比如,有些业务的场景需求是这样的: 1. 只有一个索引; 2. 该索引必须是唯一索引。 你一定看出来了,这就是典型的KV场景。 由于没有其他索引,所以也就不用考虑其他索引的叶子节点大小的问题。 这时候我们就要优先考虑上一段提到的“尽量使用主键查询”原则,直接将这个索引设置为主键,可以避免每次查询需要搜索两棵树。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/04-shen-ru-qian-chu-suo-yin-shang#xiao-jie) 小结 今天,我跟你分析了数据库引擎可用的数据结构,介绍了InnoDB采用的B+树结构,以及为什么InnoDB要这么选择。B+树能够很好地配合磁盘的读写特性,减少单次查询的磁盘访问次数。 由于InnoDB是索引组织表,一般情况下我会建议你创建一个自增主键,这样非主键索引占用的空间最小。但事无绝对,我也跟你讨论了使用业务逻辑字段做主键的应用场景。 最后,我给你留下一个问题吧。对于上面例子中的InnoDB表T,如果你要重建索引 k,你的两个SQL语句可以这么写: Copy alter table T drop index k; alter table T add index(k); 如果你要重建主键索引,也可以这么写: Copy alter table T drop primary key; alter table T add primary key(id); 我的问题是,对于上面这两个重建索引的作法,说出你的理解。如果有不合适的,为什么,更好的方法是什么? 你可以把你的思考和观点写在留言区里,我会在下一篇文章的末尾给出我的参考答案。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/04-shen-ru-qian-chu-suo-yin-shang#shang-qi-wen-ti-shi-jian) 上期问题时间 我在上一篇文章末尾给你留下的问题是:如何避免长事务对业务的影响? 这个问题,我们可以从应用开发端和数据库端来看。 **首先,从应用开发端来看:** 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(或更大的值)。如果真的出现大事务导致回滚段过大,这样设置后清理起来更方便。 感谢 @壹笙☞漂泊 @王凯 @易翔 留下的高质量评论。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fce%2Fd9%2Fce7f4e35916ed1aa49206a53a0547bd9.jpg&width=768&dpr=4&quality=100&sign=49295b8f&sv=2) [Previous03 事务隔离:为什么你改了我还看不见?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/03-shi-wu-ge-li-wei-shi-mo-ni-gai-le-wo-huan-kan-bu-jian) [Next05 深入浅出索引(下)](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/05-shen-ru-qian-chu-suo-yin-xia) Last updated 2 years ago Was this helpful? --- # 38 都说InnoDB好,那还要不要使用Memory引擎? | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252FThn9be2vacBiowXb3a5H%252Fimage.png%3Falt%3Dmedia%26token%3D4985c44f-634a-4191-9f4d-d9c70fd843ac&width=768&dpr=4&quality=100&sign=62748a38&sv=2) 我在上一篇文章末尾留给你的问题是:两个group by 语句都用了order by null,为什么使用内存临时表得到的语句结果里,0这个值在最后一行;而使用磁盘临时表得到的结果里,0这个值在第一行? 今天我们就来看看,出现这个问题的原因吧。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/38-du-shuo-innodb-hao-na-huan-yao-bu-yao-shi-yong-memory-yin-qing#nei-cun-biao-de-shu-ju-zu-zhi-jie-gou) 内存表的数据组织结构 为了便于分析,我来把这个问题简化一下,假设有以下的两张表t1 和 t2,其中表t1使用Memory 引擎, 表t2使用InnoDB引擎。 Copy create table t1(id int primary key, c int) engine=Memory; create table t2(id int primary key, c int) engine=innodb; insert into t1 values(1,1),(2,2),(3,3),(4,4),(5,5),(6,6),(7,7),(8,8),(9,9),(0,0); insert into t2 values(1,1),(2,2),(3,3),(4,4),(5,5),(6,6),(7,7),(8,8),(9,9),(0,0); 然后,我分别执行select \* from t1和select \* from t2。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F3f%2Fe6%2F3fb1100b6e3390357d4efff0ba4765e6.png&width=768&dpr=4&quality=100&sign=e699f1fd&sv=2) 图1 两个查询结果-0的位置 可以看到,内存表t1的返回结果里面0在最后一行,而InnoDB表t2的返回结果里0在第一行。 出现这个区别的原因,要从这两个引擎的主键索引的组织方式说起。 表t2用的是InnoDB引擎,它的主键索引id的组织方式,你已经很熟悉了:InnoDB表的数据就放在主键索引树上,主键索引是B+树。所以表t2的数据组织方式如下图所示: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F4e%2F8d%2F4e29e4f9db55ace6ab09161c68ad8c8d.jpg&width=768&dpr=4&quality=100&sign=ec8262b3&sv=2) 图2 表t2的数据组织 主键索引上的值是有序存储的。在执行select \*的时候,就会按照叶子节点从左到右扫描,所以得到的结果里,0就出现在第一行。 与InnoDB引擎不同,Memory引擎的数据和索引是分开的。我们来看一下表t1中的数据内容。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fdd%2F84%2Fdde03e92074cecba4154d30cd16a9684.jpg&width=768&dpr=4&quality=100&sign=fb5925ba&sv=2) 图3 表t1 的数据组织 可以看到,内存表的数据部分以数组的方式单独存放,而主键id索引里,存的是每个数据的位置。主键id是hash索引,可以看到索引上的key并不是有序的。 在内存表t1中,当我执行select \*的时候,走的是全表扫描,也就是顺序扫描这个数组。因此,0就是最后一个被读到,并放入结果集的数据。 可见,InnoDB和Memory引擎的数据组织方式是不同的: * InnoDB引擎把数据放在主键索引上,其他索引上保存的是主键id。这种方式,我们称之为**索引组织表**(Index Organizied Table)。 * 而Memory引擎采用的是把数据单独存放,索引上保存数据位置的数据组织形式,我们称之为**堆组织表**(Heap Organizied Table)。 从中我们可以看出,这两个引擎的一些典型不同: 1. InnoDB表的数据总是有序存放的,而内存表的数据就是按照写入顺序存放的; 2. 当数据文件有空洞的时候,InnoDB表在插入新数据的时候,为了保证数据有序性,只能在固定的位置写入新值,而内存表找到空位就可以插入新值; 3. 数据位置发生变化的时候,InnoDB表只需要修改主键索引,而内存表需要修改所有索引; 4. InnoDB表用主键索引查询时需要走一次索引查找,用普通索引查询的时候,需要走两次索引查找。而内存表没有这个区别,所有索引的“地位”都是相同的。 5. InnoDB支持变长数据类型,不同记录的长度可能不同;内存表不支持Blob 和 Text字段,并且即使定义了varchar(N),实际也当作char(N),也就是固定长度字符串来存储,因此内存表的每行数据长度相同。 由于内存表的这些特性,每个数据行被删除以后,空出的这个位置都可以被接下来要插入的数据复用。比如,如果要在表t1中执行: Copy delete from t1 where id=5; insert into t1 values(10,10); select * from t1; 就会看到返回结果里,id=10这一行出现在id=4之后,也就是原来id=5这行数据的位置。 需要指出的是,表t1的这个主键索引是哈希索引,因此如果执行范围查询,比如 Copy select * from t1 where id<5; 是用不上主键索引的,需要走全表扫描。你可以借此再回顾下[第4篇文章](https://time.geekbang.org/column/article/69236) 的内容。那如果要让内存表支持范围扫描,应该怎么办呢 ? ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/38-du-shuo-innodb-hao-na-huan-yao-bu-yao-shi-yong-memory-yin-qing#hash-suo-yin-he-btree-suo-yin) hash索引和B-Tree索引 实际上,内存表也是支B-Tree索引的。在id列上创建一个B-Tree索引,SQL语句可以这么写: Copy alter table t1 add index a_btree_index using btree (id); 这时,表t1的数据组织形式就变成了这样: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F17%2Fe3%2F1788deca56cb83c114d8353c92e3bde3.jpg&width=768&dpr=4&quality=100&sign=f94f1ed8&sv=2) 图4 表t1的数据组织--增加B-Tree索引 新增的这个B-Tree索引你看着就眼熟了,这跟InnoDB的b+树索引组织形式类似。 作为对比,你可以看一下这下面这两个语句的输出: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fa8%2F8a%2Fa85808fcccab24911d257d720550328a.png&width=768&dpr=4&quality=100&sign=4b5c0736&sv=2) 图5 使用B-Tree和hash索引查询返回结果对比 可以看到,执行select \* from t1 where id<5的时候,优化器会选择B-Tree索引,所以返回结果是0到4。 使用force index强行使用主键id这个索引,id=0这一行就在结果集的最末尾了。 其实,一般在我们的印象中,内存表的优势是速度快,其中的一个原因就是Memory引擎支持hash索引。当然,更重要的原因是,内存表的所有数据都保存在内存,而内存的读写速度总是比磁盘快。 但是,接下来我要跟你说明,为什么我不建议你在生产环境上使用内存表。这里的原因主要包括两个方面: 1. 锁粒度问题; 2. 数据持久化问题。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/38-du-shuo-innodb-hao-na-huan-yao-bu-yao-shi-yong-memory-yin-qing#nei-cun-biao-de-suo) 内存表的锁 我们先来说说内存表的锁粒度问题。 内存表不支持行锁,只支持表锁。因此,一张表只要有更新,就会堵住其他所有在这个表上的读写操作。 需要注意的是,这里的表锁跟之前我们介绍过的MDL锁不同,但都是表级的锁。接下来,我通过下面这个场景,跟你模拟一下内存表的表级锁。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Ff2%2F29%2Ff216e2d707559ed2ca98fbe21e509f29.png&width=768&dpr=4&quality=100&sign=af5bb64c&sv=2) 图6 内存表的表锁--复现步骤 在这个执行序列里,session A的update语句要执行50秒,在这个语句执行期间session B的查询会进入锁等待状态。session C的show processlist 结果输出如下: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F14%2F16%2F14d88076dad6db573f0b66f2c17df916.png&width=768&dpr=4&quality=100&sign=f77e5f81&sv=2) 图7 内存表的表锁--结果 跟行锁比起来,表锁对并发访问的支持不够好。所以,内存表的锁粒度问题,决定了它在处理并发事务的时候,性能也不会太好。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/38-du-shuo-innodb-hao-na-huan-yao-bu-yao-shi-yong-memory-yin-qing#shu-ju-chi-jiu-xing-wen-ti) 数据持久性问题 接下来,我们再看看数据持久性的问题。 数据放在内存中,是内存表的优势,但也是一个劣势。因为,数据库重启的时候,所有的内存表都会被清空。 你可能会说,如果数据库异常重启,内存表被清空也就清空了,不会有什么问题啊。但是,在高可用架构下,内存表的这个特点简直可以当做bug来看待了。为什么这么说呢? **我们先看看M-S架构下,使用内存表存在的问题。** ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F5b%2Fe9%2F5b910e4c0f1afa219aeecd1f291c95e9.jpg&width=768&dpr=4&quality=100&sign=7cfc406f&sv=2) 图8 M-S基本架构 我们来看一下下面这个时序: 1. 业务正常访问主库; 2. 备库硬件升级,备库重启,内存表t1内容被清空; 3. 备库重启后,客户端发送一条update语句,修改表t1的数据行,这时备库应用线程就会报错“找不到要更新的行”。 这样就会导致主备同步停止。当然,如果这时候发生主备切换的话,客户端会看到,表t1的数据“丢失”了。 在图8中这种有proxy的架构里,大家默认主备切换的逻辑是由数据库系统自己维护的。这样对客户端来说,就是“网络断开,重连之后,发现内存表数据丢失了”。 你可能说这还好啊,毕竟主备发生切换,连接会断开,业务端能够感知到异常。 但是,接下来内存表的这个特性就会让使用现象显得更“诡异”了。由于MySQL知道重启之后,内存表的数据会丢失。所以,担心主库重启之后,出现主备不一致,MySQL在实现上做了这样一件事儿:在数据库重启之后,往binlog里面写入一行DELETE FROM t1。 **如果你使用是如图9所示的双M结构的话:** ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F40%2F57%2F4089c9c1f92ce61d2ed779fd0932ba57.jpg&width=768&dpr=4&quality=100&sign=bc64f748&sv=2) 图9 双M结构 在备库重启的时候,备库binlog里的delete语句就会传到主库,然后把主库内存表的内容删除。这样你在使用的时候就会发现,主库的内存表数据突然被清空了。 基于上面的分析,你可以看到,内存表并不适合在生产环境上作为普通数据表使用。 有同学会说,但是内存表执行速度快呀。这个问题,其实你可以这么分析: 1. 如果你的表更新量大,那么并发度是一个很重要的参考指标,InnoDB支持行锁,并发度比内存表好; 2. 能放到内存表的数据量都不大。如果你考虑的是读的性能,一个读QPS很高并且数据量不大的表,即使是使用InnoDB,数据也是都会缓存在InnoDB Buffer Pool里的。因此,使用InnoDB表的读性能也不会差。 所以,**我建议你把普通内存表都用InnoDB表来代替。**但是,有一个场景却是例外的。 这个场景就是,我们在第35和36篇说到的用户临时表。在数据量可控,不会耗费过多内存的情况下,你可以考虑使用内存表。 内存临时表刚好可以无视内存表的两个不足,主要是下面的三个原因: 1. 临时表不会被其他线程访问,没有并发性的问题; 2. 临时表重启后也是需要删除的,清空数据这个问题不存在; 3. 备库的临时表也不会影响主库的用户线程。 现在,我们回过头再看一下第35篇join语句优化的例子,当时我建议的是创建一个InnoDB临时表,使用的语句序列是: Copy create temporary table temp_t(id int primary key, a int, b int, index(b))engine=innodb; insert into temp_t select * from t2 where b>=1 and b<=2000; select * from t1 join temp_t on (t1.b=temp_t.b); 了解了内存表的特性,你就知道了, 其实这里使用内存临时表的效果更好,原因有三个: 1. 相比于InnoDB表,使用内存表不需要写磁盘,往表temp\_t的写数据的速度更快; 2. 索引b使用hash索引,查找的速度比B-Tree索引快; 3. 临时表数据只有2000行,占用的内存有限。 因此,你可以对[第35篇文章](https://time.geekbang.org/column/article/80147) 的语句序列做一个改写,将临时表t1改成内存临时表,并且在字段b上创建一个hash索引。 Copy create temporary table temp_t(id int primary key, a int, b int, index (b))engine=memory; insert into temp_t select * from t2 where b>=1 and b<=2000; select * from t1 join temp_t on (t1.b=temp_t.b); ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fa4%2F92%2Fa468ba6d14ea225623074b6255b99f92.png&width=768&dpr=4&quality=100&sign=f62428af&sv=2) 图10 使用内存临时表的执行效果 可以看到,不论是导入数据的时间,还是执行join的时间,使用内存临时表的速度都比使用InnoDB临时表要更快一些。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/38-du-shuo-innodb-hao-na-huan-yao-bu-yao-shi-yong-memory-yin-qing#xiao-jie) 小结 今天这篇文章,我从“要不要使用内存表”这个问题展开,和你介绍了Memory引擎的几个特性。 可以看到,由于重启会丢数据,如果一个备库重启,会导致主备同步线程停止;如果主库跟这个备库是双M架构,还可能导致主库的内存表数据被删掉。 因此,在生产上,我不建议你使用普通内存表。 如果你是DBA,可以在建表的审核系统中增加这类规则,要求业务改用InnoDB表。我们在文中也分析了,其实InnoDB表性能还不错,而且数据安全也有保障。而内存表由于不支持行锁,更新语句会阻塞查询,性能也未必就如想象中那么好。 基于内存表的特性,我们还分析了它的一个适用场景,就是内存临时表。内存表支持hash索引,这个特性利用起来,对复杂查询的加速效果还是很不错的。 最后,我给你留一个问题吧。 假设你刚刚接手的一个数据库上,真的发现了一个内存表。备库重启之后肯定是会导致备库的内存表数据被清空,进而导致主备同步停止。这时,最好的做法是将它修改成InnoDB引擎表。 假设当时的业务场景暂时不允许你修改引擎,你可以加上什么自动化逻辑,来避免主备同步停止呢? 你可以把你的思考和分析写在评论区,我会在下一篇文章的末尾跟你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/38-du-shuo-innodb-hao-na-huan-yao-bu-yao-shi-yong-memory-yin-qing#shang-qi-wen-ti-shi-jian) 上期问题时间 今天文章的正文内容,已经回答了我们上期的问题,这里就不再赘述了。 评论区留言点赞板: > @老杨同志、@poppy、@长杰 这三位同学给出了正确答案,春节期间还持续保持跟进学习,给你们点赞。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F09%2F77%2F09c1073f99cf71d2fb162a716b5fa577.jpg&width=768&dpr=4&quality=100&sign=e5471b04&sv=2) [Previous37 什么时候会使用内部临时表?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/37-shi-mo-shi-hou-hui-shi-yong-nei-bu-lin-shi-biao) [Next39 自增主键为什么不是连续的?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/39-zi-zeng-zhu-jian-wei-shi-mo-bu-shi-lian-xu-de) Last updated 2 years ago Was this helpful? --- # 15 答疑文章(一):日志和索引相关问题 | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252Fsd1x0HjLY5p5PSdg7cPg%252Fimage.png%3Falt%3Dmedia%26token%3D5971b0a0-3631-4ea4-8df3-d90fe446d8d5&width=768&dpr=4&quality=100&sign=6d722a53&sv=2) 在今天这篇答疑文章更新前,MySQL实战这个专栏已经更新了14篇。在这些文章中,大家在评论区留下了很多高质量的留言。现在,每篇文章的评论区都有热心的同学帮忙总结文章知识点,也有不少同学提出了很多高质量的问题,更有一些同学帮忙解答其他同学提出的问题。 在浏览这些留言并回复的过程中,我倍受鼓舞,也尽我所知地帮助你解决问题、和你讨论。可以说,你们的留言活跃了整个专栏的氛围、提升了整个专栏的质量,谢谢你们。 评论区的大多数留言我都直接回复了,对于需要展开说明的问题,我都拿出小本子记了下来。这些被记下来的问题,就是我们今天这篇答疑文章的素材了。 到目前为止,我已经收集了47个问题,很难通过今天这一篇文章全部展开。所以,我就先从中找了几个联系非常紧密的问题,串了起来,希望可以帮你解决关于日志和索引的一些疑惑。而其他问题,我们就留着后面慢慢展开吧。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/15-da-yi-wen-zhang-yi-ri-zhi-he-suo-yin-xiang-guan-wen-ti#ri-zhi-xiang-guan-wen-ti) 日志相关问题 我在第2篇文章[《日志系统:一条SQL更新语句是如何执行的?》](https://time.geekbang.org/column/article/68633) 中,和你讲到binlog(归档日志)和redo log(重做日志)配合崩溃恢复的时候,用的是反证法,说明了如果没有两阶段提交,会导致MySQL出现主备数据不一致等问题。 在这篇文章下面,很多同学在问,在两阶段提交的不同瞬间,MySQL如果发生异常重启,是怎么保证数据完整性的? 现在,我们就从这个问题开始吧。 我再放一次两阶段提交的图,方便你学习下面的内容。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fee%2F2a%2Fee9af616e05e4b853eba27048351f62a.jpg&width=768&dpr=4&quality=100&sign=c080f6c2&sv=2) 图1 两阶段提交示意图 这里,我要先和你解释一个误会式的问题。有同学在评论区问到,这个图不是一个update语句的执行流程吗,怎么还会调用commit语句? 他产生这个疑问的原因,是把**两个“commit”的概念**混淆了: * 他说的“commit语句”,是指MySQL语法中,用于提交一个事务的命令。一般跟begin/start transaction 配对使用。 * 而我们图中用到的这个“commit步骤”,指的是事务提交过程中的一个小步骤,也是最后一步。当这个步骤执行完成后,这个事务就提交完成了。 * “commit语句”执行的时候,会包含“commit 步骤”。 而我们这个例子里面,没有显式地开启事务,因此这个update语句自己就是一个事务,在执行完成后提交事务时,就会用到这个“commit步骤“。 接下来,我们就一起分析一下**在两阶段提交的不同时刻,MySQL异常重启会出现什么现象。** 如果在图中时刻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)的情况,崩溃恢复过程中事务会被提交。 现在,我们继续延展一下这个问题。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/15-da-yi-wen-zhang-yi-ri-zhi-he-suo-yin-xiang-guan-wen-ti#zhui-wen-1mysql-zen-me-zhi-dao-binlog-shi-wan-zheng-de) 追问1:MySQL怎么知道binlog是完整的? 回答:一个事务的binlog是有完整格式的: * statement格式的binlog,最后会有COMMIT; * row格式的binlog,最后会有一个XID event。 另外,在MySQL 5.6.2版本以后,还引入了binlog-checksum参数,用来验证binlog内容的正确性。对于binlog日志由于磁盘原因,可能会在日志中间出错的情况,MySQL可以通过校验checksum的结果来发现。所以,MySQL还是有办法验证事务binlog的完整性的。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/15-da-yi-wen-zhang-yi-ri-zhi-he-suo-yin-xiang-guan-wen-ti#zhui-wen-2redo-log-he-binlog-shi-zen-me-guan-lian-qi-lai-de) 追问2:redo log 和 binlog是怎么关联起来的? 回答:它们有一个共同的数据字段,叫XID。崩溃恢复的时候,会按顺序扫描redo log: * 如果碰到既有prepare、又有commit的redo log,就直接提交; * 如果碰到只有parepare、而没有commit的redo log,就拿着XID去binlog找对应的事务。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/15-da-yi-wen-zhang-yi-ri-zhi-he-suo-yin-xiang-guan-wen-ti#zhui-wen-3-chu-yu-prepare-jie-duan-de-redo-log-jia-shang-wan-zheng-binlog-chong-qi-jiu-neng-hui-fu-m) 追问3:处于prepare阶段的redo log加上完整binlog,重启就能恢复,MySQL为什么要这么设计? 回答:其实,这个问题还是跟我们在反证法中说到的数据与备份的一致性有关。在时刻B,也就是binlog写完以后MySQL发生崩溃,这时候binlog已经写入了,之后就会被从库(或者用这个binlog恢复出来的库)使用。 所以,在主库上也要提交这个事务。采用这个策略,主库和备库的数据就保证了一致性。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/15-da-yi-wen-zhang-yi-ri-zhi-he-suo-yin-xiang-guan-wen-ti#zhui-wen-4-ru-guo-zhe-yang-de-hua-wei-shen-me-hai-yao-liang-jie-duan-ti-jiao-ne-gan-cui-xian-redo-lo) 追问4:如果这样的话,为什么还要两阶段提交呢?干脆先redo log写完,再写binlog。崩溃恢复的时候,必须得两个日志都完整才可以。是不是一样的逻辑? 回答:其实,两阶段提交是经典的分布式系统问题,并不是MySQL独有的。 如果必须要举一个场景,来说明这么做的必要性的话,那就是事务的持久性问题。 对于InnoDB引擎来说,如果redo log提交完成了,事务就不能回滚(如果这还允许回滚,就可能覆盖掉别的事务的更新)。而如果redo log直接提交,然后binlog写入的时候失败,InnoDB又回滚不了,数据和binlog日志又不一致了。 两阶段提交就是为了给所有人一个机会,当每个人都说“我ok”的时候,再一起提交。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/15-da-yi-wen-zhang-yi-ri-zhi-he-suo-yin-xiang-guan-wen-ti#zhui-wen-5-bu-yin-ru-liang-ge-ri-zhi-ye-jiu-mei-you-liang-jie-duan-ti-jiao-de-bi-yao-le-zhi-yong-bin) 追问5:不引入两个日志,也就没有两阶段提交的必要了。只用binlog来支持崩溃恢复,又能支持归档,不就可以了? 回答:这位同学的意思是,只保留binlog,然后可以把提交流程改成这样:… -> “数据更新到内存” -> “写 binlog” -> “提交事务”,是不是也可以提供崩溃恢复的能力? 答案是不可以。 如果说**历史原因**的话,那就是InnoDB并不是MySQL的原生存储引擎。MySQL的原生引擎是MyISAM,设计之初就有没有支持崩溃恢复。 InnoDB在作为MySQL的插件加入MySQL引擎家族之前,就已经是一个提供了崩溃恢复和事务支持的引擎了。 InnoDB接入了MySQL后,发现既然binlog没有崩溃恢复的能力,那就用InnoDB原有的redo log好了。 而如果说**实现上的原因**的话,就有很多了。就按照问题中说的,只用binlog来实现崩溃恢复的流程,我画了一张示意图,这里就没有redo log了。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Feb%2F63%2Feb838b87e9c20fa00aca50ef154f2a63.jpg&width=768&dpr=4&quality=100&sign=857048d6&sv=2) 图2 只用binlog支持崩溃恢复 这样的流程下,binlog还是不能支持崩溃恢复的。我说一个不支持的点吧:binlog没有能力恢复“数据页”。 如果在图中标的位置,也就是binlog2写完了,但是整个事务还没有commit的时候,MySQL发生了crash。 重启后,引擎内部事务2会回滚,然后应用binlog2可以补回来;但是对于事务1来说,系统已经认为提交完成了,不会再应用一次binlog1。 但是,InnoDB引擎使用的是WAL技术,执行事务的时候,写完内存和日志,事务就算完成了。如果之后崩溃,要依赖于日志来恢复数据页。 也就是说在图中这个位置发生崩溃的话,事务1也是可能丢失了的,而且是数据页级的丢失。此时,binlog里面并没有记录数据页的更新细节,是补不回来的。 你如果要说,那我优化一下binlog的内容,让它来记录数据页的更改可以吗?但,这其实就是又做了一个redo log出来。 所以,至少现在的binlog能力,还不能支持崩溃恢复。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/15-da-yi-wen-zhang-yi-ri-zhi-he-suo-yin-xiang-guan-wen-ti#zhui-wen-6-na-neng-bu-neng-fan-guo-lai-zhi-yong-redo-log-bu-yao-binlog) 追问6:那能不能反过来,只用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还做不到。你看,发展生态是多么重要。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/15-da-yi-wen-zhang-yi-ri-zhi-he-suo-yin-xiang-guan-wen-ti#zhui-wen-7redo-log-yi-ban-she-zhi-duo-da) 追问7:redo log一般设置多大? 回答:redo log太小的话,会导致很快就被写满,然后不得不强行刷redo log,这样WAL机制的能力就发挥不出来了。 所以,如果是现在常见的几个TB的磁盘的话,就不要太小气了,直接将redo log设置为4个文件、每个文件1GB吧。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/15-da-yi-wen-zhang-yi-ri-zhi-he-suo-yin-xiang-guan-wen-ti#zhui-wen-8-zheng-chang-yun-xing-zhong-de-shi-li-shu-ju-xie-ru-hou-de-zui-zhong-luo-pan-shi-cong-redo) 追问8:正常运行中的实例,数据写入后的最终落盘,是从redo log更新过来的还是从buffer pool更新过来的呢? 回答:这个问题其实问得非常好。这里涉及到了,“redo log里面到底是什么”的问题。 实际上,redo log并没有记录数据页的完整数据,所以它并没有能力自己去更新磁盘数据页,也就不存在“数据最终落盘,是由redo log更新过去”的情况。 1. 如果是正常运行的实例的话,数据页被修改以后,跟磁盘的数据页不一致,称为脏页。最终数据落盘,就是把内存中的数据页写盘。这个过程,甚至与redo log毫无关系。 2. 在崩溃恢复场景中,InnoDB如果判断到一个数据页可能在崩溃恢复的时候丢失了更新,就会将它读到内存,然后让redo log更新内存内容。更新完成后,内存页变成脏页,就回到了第一种情况的状态。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/15-da-yi-wen-zhang-yi-ri-zhi-he-suo-yin-xiang-guan-wen-ti#zhui-wen-9redo-log-buffer-shi-shen-me-shi-xian-xiu-gai-nei-cun-hai-shi-xian-xie-redo-log-wen-jian) 追问9:redo log buffer是什么?是先修改内存,还是先写redo log文件? 回答:这两个问题可以一起回答。 在一个事务的更新过程中,日志是要写多次的。比如下面这个事务: Copy 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会自己启动一个事务,在语句执行完成的时候提交。过程跟上面是一样的,只不过是“压缩”到了一个语句里面完成。 以上这些问题,就是把大家提过的关于redo log和binlog的问题串起来,做的一次集中回答。如果你还有问题,可以在评论区继续留言补充。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/15-da-yi-wen-zhang-yi-ri-zhi-he-suo-yin-xiang-guan-wen-ti#ye-wu-she-ji-wen-ti) 业务设计问题 接下来,我再和你分享@ithunter 同学在第8篇文章[《](https://time.geekbang.org/column/article/70562) [事务到底是隔离的还是不隔离的?](https://time.geekbang.org/column/article/70562) [》](https://time.geekbang.org/column/article/70562) 的评论区提到的跟索引相关的一个问题。我觉得这个问题挺有趣、也挺实用的,其他同学也可能会碰上这样的场景,在这里解答和分享一下。 问题是这样的(我文字上稍微做了点修改,方便大家理解): > 业务上有这样的需求,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锁层面有没有办法处理? 首先,我要先赞一下这样的提问方式。虽然极客时间现在的评论区还不能追加评论,但如果大家能够一次留言就把问题讲清楚的话,其实影响也不大。所以,我希望你在留言提问的时候,也能借鉴这种方式。 接下来,我把@ithunter 同学说的表模拟出来,方便我们讨论。 Copy 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”是关键字,我一般不建议使用关键字作为库名、表名、字段名或索引名。 我把他的疑问翻译一下,在并发场景下,同时有两个人,设置为关注对方,就可能导致无法成功加为朋友关系。 现在,我用你已经熟悉的时刻顺序表的形式,把这两个事务的执行语句列出来: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fc4%2Fed%2Fc45063baf1ae521bf5d98b6d7c0e0ced.png&width=300&dpr=4&quality=100&sign=2a784cee&sv=2) 图3 并发“喜欢”逻辑操作顺序 由于一开始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 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,则执行下面的逻辑 Copy 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的这条记录。 不知道你会不会吐槽:之前明明还说尽量不要使用唯一索引,结果这个例子一上来我就创建了两个。这里我要再和你说明一下,之前文章我们讨论的,是在“业务开发保证不会插入重复记录”的情况下,着重要解决性能问题的时候,才建议尽量使用普通索引。 而像这个例子里,按照这个设计,业务根本就是保证“我一定会插入重复数据,数据库一定要要有唯一性约束”,这时就没啥好说的了,唯一索引建起来吧。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/15-da-yi-wen-zhang-yi-ri-zhi-he-suo-yin-xiang-guan-wen-ti#xiao-jie) 小结 这是专栏的第一篇答疑文章。 我针对前14篇文章,大家在评论区中的留言,从中摘取了关于日志和索引的相关问题,串成了今天这篇文章。这里我也要再和你说一声,有些我答应在答疑文章中进行扩展的话题,今天这篇文章没来得及扩展,后续我会再找机会为你解答。所以,篇幅所限,评论区见吧。 最后,虽然这篇是答疑文章,但课后问题还是要有的。 我们创建了一个简单的表t,并插入一行,然后对这一行做修改。 Copy 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)。假设,我现在要执行: Copy mysql> update t set a=2 where id=1; 你会看到这样的结果: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F36%2F70%2F367b3f299b94353f32f75ea825391170.png&width=300&dpr=4&quality=100&sign=d3fe3c94&sv=2) 结果显示,匹配(rows matched)了一行,修改(Changed)了0行。 仅从现象上看,MySQL内部在处理这个命令的时候,可以有以下三种选择: 1. 更新都是先读后写的,MySQL读出数据,发现a的值本来就是2,不更新,直接返回,执行结束; 2. MySQL调用了InnoDB引擎提供的“修改为(1,2)”这个接口,但是引擎发现值与原来相同,不更新,直接返回; 3. InnoDB认真执行了“把这个值修改成(1,2)"这个操作,该加锁的加锁,该更新的更新。 你觉得实际情况会是以上哪种呢?你可否用构造实验的方式,来证明你的结论?进一步地,可以思考一下,MySQL为什么要选择这种策略呢? 你可以把你的验证方法和思考写在留言区里,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/15-da-yi-wen-zhang-yi-ri-zhi-he-suo-yin-xiang-guan-wen-ti#shang-qi-wen-ti-shi-jian) 上期问题时间 上期的问题是,用一个计数表记录一个业务表的总行数,在往业务表插入数据的时候,需要给计数值加1。 逻辑实现上是启动一个事务,执行两个语句: 1. insert into 数据表; 2. update 计数表,计数值加1。 从系统并发能力的角度考虑,怎么安排这两个语句的顺序。 这里,我直接复制 @阿建 的回答过来供你参考: > 并发系统性能的角度考虑,应该先插入操作记录,再更新计数表。 知识点在[《行锁功过:怎么减少行锁对性能的影响?》](https://time.geekbang.org/column/article/70215) > 因为更新计数表涉及到行锁的竞争,先插入再更新能最大程度地减少事务之间的锁等待,提升并发度。 评论区有同学说,应该把update计数表放后面,因为这个计数表可能保存了多个业务表的计数值。如果把update计数表放到事务的第一个语句,多个业务表同时插入数据的话,等待时间会更长。 这个答案的结论是对的,但是理解不太正确。即使我们用一个计数表记录多个业务表的行数,也肯定会给表名字段加唯一索引。类似于下面这样的表结构: Copy CREATE TABLE `rows_stat` ( `table_name` varchar(64) NOT NULL, `row_count` int(10) unsigned NOT NULL, PRIMARY KEY (`table_name`) ) ENGINE=InnoDB; 在更新计数表的时候,一定会传入where table\_name=$table\_name,使用主键索引,更新加行锁只会锁在一行上。 而在不同业务表插入数据,是更新不同的行,不会有行锁。 评论区留言点赞板: > @北天魔狼、@斜面镜子 Bil 和@Bin 等同学,都给出了正确答案; @果然如此 同学提了一个好问题,虽然引入事务,避免看到”业务上还没提交的更新”,但是Redis的计数被提前看到了。核心原因还是两个系统,不支持一致性视图; @ 帆帆帆帆帆帆帆帆 同学的问题提醒了大家,count(id)也是可以走普通索引得到的。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F09%2F77%2F09c1073f99cf71d2fb162a716b5fa577.jpg&width=768&dpr=4&quality=100&sign=e5471b04&sv=2) [Previous14 count(\*)这么慢,我该怎么办?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/14-count-zhe-mo-man-wo-gai-zen-mo-ban) [Next16 “order by”是怎么工作的?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/16-order-by-shi-zen-mo-gong-zuo-de) Last updated 2 years ago Was this helpful? --- # 02 日志系统:一条SQL更新语句是如何执行的? | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252F2uzTEJZie651VzXIWFVs%252Fimage.png%3Falt%3Dmedia%26token%3De60d8551-397a-43c3-a914-1553b55dd654&width=768&dpr=4&quality=100&sign=c88f2327&sv=2) 前面我们系统了解了一个查询语句的执行流程,并介绍了执行过程中涉及的处理模块。相信你还记得,一条查询语句的执行过程一般是经过连接器、分析器、优化器、执行器等功能模块,最后到达存储引擎。 那么,一条更新语句的执行流程又是怎样的呢? 之前你可能经常听DBA同事说,MySQL可以恢复到半个月内任意一秒的状态,惊叹的同时,你是不是心中也会不免会好奇,这是怎样做到的呢? 我们还是从一个表的一条更新语句说起,下面是这个表的创建语句,这个表有一个主键ID和一个整型字段c: Copy mysql> create table T(ID int primary key, c int); 如果要将ID=2这一行的值加1,SQL语句就会这么写: Copy mysql> update T set c=c+1 where ID=2; 前面我有跟你介绍过SQL语句基本的执行链路,这里我再把那张图拿过来,你也可以先简单看看这个图回顾下。首先,可以确定的说,查询语句的那一套流程,更新语句也是同样会走一遍。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F0d%2Fd9%2F0d2070e8f84c4801adbfa03bda1f98d9.png&width=768&dpr=4&quality=100&sign=9e1f0f54&sv=2) MySQL的逻辑架构图 你执行语句前要先连接数据库,这是连接器的工作。 前面我们说过,在一个表上有更新的时候,跟这个表有关的查询缓存会失效,所以这条语句就会把表T上所有缓存结果都清空。这也就是我们一般不建议使用查询缓存的原因。 接下来,分析器会通过词法和语法解析知道这是一条更新语句。优化器决定要使用ID这个索引。然后,执行器负责具体执行,找到这一行,然后更新。 与查询流程不一样的是,更新流程还涉及两个重要的日志模块,它们正是我们今天要讨论的主角:redo log(重做日志)和 binlog(归档日志)。如果接触MySQL,那这两个词肯定是绕不过的,我后面的内容里也会不断地和你强调。不过话说回来,redo log和binlog在设计上有很多有意思的地方,这些设计思路也可以用到你自己的程序里。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/02-ri-zhi-xi-tong-yi-tiao-sql-geng-xin-yu-ju-shi-ru-he-zhi-hang-de#zhong-yao-de-ri-zhi-mo-kuai-redo-log) 重要的日志模块:redo log 不知道你还记不记得《孔乙己》这篇文章,酒店掌柜有一个粉板,专门用来记录客人的赊账记录。如果赊账的人不多,那么他可以把顾客名和账目写在板上。但如果赊账的人多了,粉板总会有记不下的时候,这个时候掌柜一定还有一个专门记录赊账的账本。 如果有人要赊账或者还账的话,掌柜一般有两种做法: * 一种做法是直接把账本翻出来,把这次赊的账加上去或者扣除掉; * 另一种做法是先在粉板上记下这次的账,等打烊以后再把账本翻出来核算。 在生意红火柜台很忙时,掌柜一定会选择后者,因为前者操作实在是太麻烦了。首先,你得找到这个人的赊账总额那条记录。你想想,密密麻麻几十页,掌柜要找到那个名字,可能还得带上老花镜慢慢找,找到之后再拿出算盘计算,最后再将结果写回到账本上。 这整个过程想想都麻烦。相比之下,还是先在粉板上记一下方便。你想想,如果掌柜没有粉板的帮助,每次记账都得翻账本,效率是不是低得让人难以忍受? 同样,在MySQL里也有这个问题,如果每一次的更新操作都需要写进磁盘,然后磁盘也要找到对应的那条记录,然后再更新,整个过程IO成本、查找成本都很高。为了解决这个问题,MySQL的设计者就用了类似酒店掌柜粉板的思路来提升更新效率。 而粉板和账本配合的整个过程,其实就是MySQL里经常说到的WAL技术,WAL的全称是Write-Ahead Logging,它的关键点就是先写日志,再写磁盘,也就是先写粉板,等不忙的时候再写账本。 具体来说,当有一条记录需要更新的时候,InnoDB引擎就会先把记录写到redo log(粉板)里面,并更新内存,这个时候更新就算完成了。同时,InnoDB引擎会在适当的时候,将这个操作记录更新到磁盘里面,而这个更新往往是在系统比较空闲的时候做,这就像打烊以后掌柜做的事。 如果今天赊账的不多,掌柜可以等打烊后再整理。但如果某天赊账的特别多,粉板写满了,又怎么办呢?这个时候掌柜只好放下手中的活儿,把粉板中的一部分赊账记录更新到账本中,然后把这些记录从粉板上擦掉,为记新账腾出空间。 与此类似,InnoDB的redo log是固定大小的,比如可以配置为一组4个文件,每个文件的大小是1GB,那么这块“粉板”总共就可以记录4GB的操作。从头开始写,写到末尾就又回到开头循环写,如下面这个图所示。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fb0%2F9c%2Fb075250cad8d9f6c791a52b6a600f69c.jpg&width=768&dpr=4&quality=100&sign=453f599b&sv=2) write pos是当前记录的位置,一边写一边后移,写到第3号文件末尾后就回到0号文件开头。checkpoint是当前要擦除的位置,也是往后推移并且循环的,擦除记录前要把记录更新到数据文件。 write pos和checkpoint之间的是“粉板”上还空着的部分,可以用来记录新的操作。如果write pos追上checkpoint,表示“粉板”满了,这时候不能再执行新的更新,得停下来先擦掉一些记录,把checkpoint推进一下。 有了redo log,InnoDB就可以保证即使数据库发生异常重启,之前提交的记录都不会丢失,这个能力称为**crash-safe**。 要理解crash-safe这个概念,可以想想我们前面赊账记录的例子。只要赊账记录记在了粉板上或写在了账本上,之后即使掌柜忘记了,比如突然停业几天,恢复生意后依然可以通过账本和粉板上的数据明确赊账账目。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/02-ri-zhi-xi-tong-yi-tiao-sql-geng-xin-yu-ju-shi-ru-he-zhi-hang-de#zhong-yao-de-ri-zhi-mo-kuai-binlog) 重要的日志模块:binlog 前面我们讲过,MySQL整体来看,其实就有两块:一块是Server层,它主要做的是MySQL功能层面的事情;还有一块是引擎层,负责存储相关的具体事宜。上面我们聊到的粉板redo log是InnoDB引擎特有的日志,而Server层也有自己的日志,称为binlog(归档日志)。 我想你肯定会问,为什么会有两份日志呢? 因为最开始MySQL里并没有InnoDB引擎。MySQL自带的引擎是MyISAM,但是MyISAM没有crash-safe的能力,binlog日志只能用于归档。而InnoDB是另一个公司以插件形式引入MySQL的,既然只依靠binlog是没有crash-safe能力的,所以InnoDB使用另外一套日志系统——也就是redo log来实现crash-safe能力。 这两种日志有以下三点不同。 1. redo log是InnoDB引擎特有的;binlog是MySQL的Server层实现的,所有引擎都可以使用。 2. redo log是物理日志,记录的是“在某个数据页上做了什么修改”;binlog是逻辑日志,记录的是这个语句的原始逻辑,比如“给ID=2这一行的c字段加1 ”。 3. redo log是循环写的,空间固定会用完;binlog是可以追加写入的。“追加写”是指binlog文件写到一定大小后会切换到下一个,并不会覆盖以前的日志。 有了对这两个日志的概念性理解,我们再来看执行器和InnoDB引擎在执行这个简单的update语句时的内部流程。 1. 执行器先找引擎取ID=2这一行。ID是主键,引擎直接用树搜索找到这一行。如果ID=2这一行所在的数据页本来就在内存中,就直接返回给执行器;否则,需要先从磁盘读入内存,然后再返回。 2. 执行器拿到引擎给的行数据,把这个值加上1,比如原来是N,现在就是N+1,得到新的一行数据,再调用引擎接口写入这行新数据。 3. 引擎将这行新数据更新到内存中,同时将这个更新操作记录到redo log里面,此时redo log处于prepare状态。然后告知执行器执行完成了,随时可以提交事务。 4. 执行器生成这个操作的binlog,并把binlog写入磁盘。 5. 执行器调用引擎的提交事务接口,引擎把刚刚写入的redo log改成提交(commit)状态,更新完成。 这里我给出这个update语句的执行流程图,图中浅色框表示是在InnoDB内部执行的,深色框表示是在执行器中执行的。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F2e%2Fbe%2F2e5bff4910ec189fe1ee6e2ecc7b4bbe.png&width=768&dpr=4&quality=100&sign=59e78ea&sv=2) update语句执行流程 你可能注意到了,最后三步看上去有点“绕”,将redo log的写入拆成了两个步骤:prepare和commit,这就是"两阶段提交"。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/02-ri-zhi-xi-tong-yi-tiao-sql-geng-xin-yu-ju-shi-ru-he-zhi-hang-de#liang-jie-duan-ti-jiao) 两阶段提交 为什么必须有“两阶段提交”呢?这是为了让两份日志之间的逻辑一致。要说明这个问题,我们得从文章开头的那个问题说起:**怎样让数据库恢复到半个月内任意一秒的状态?** 前面我们说过了,binlog会记录所有的逻辑操作,并且是采用“追加写”的形式。如果你的DBA承诺说半个月内可以恢复,那么备份系统中一定会保存最近半个月的所有binlog,同时系统会定期做整库备份。这里的“定期”取决于系统的重要性,可以是一天一备,也可以是一周一备。 当需要恢复到指定的某一秒时,比如某天下午两点发现中午十二点有一次误删表,需要找回数据,那你可以这么做: * 首先,找到最近的一次全量备份,如果你运气好,可能就是昨天晚上的一个备份,从这个备份恢复到临时库; * 然后,从备份的时间点开始,将备份的binlog依次取出来,重放到中午误删表之前的那个时刻。 这样你的临时库就跟误删之前的线上库一样了,然后你可以把表数据从临时库取出来,按需要恢复到线上库去。 好了,说完了数据恢复过程,我们回来说说,为什么日志需要“两阶段提交”。这里不妨用反证法来进行解释。 由于redo log和binlog是两个独立的逻辑,如果不用两阶段提交,要么就是先写完redo log再写binlog,或者采用反过来的顺序。我们看看这两种方式会有什么问题。 仍然用前面的update语句来做例子。假设当前ID=2的行,字段c的值是0,再假设执行update语句过程中在写完第一个日志后,第二个日志还没有写完期间发生了crash,会出现什么情况呢? 1. **先写redo log后写binlog**。假设在redo log写完,binlog还没有写完的时候,MySQL进程异常重启。由于我们前面说过的,redo log写完之后,系统即使崩溃,仍然能够把数据恢复回来,所以恢复后这一行c的值是1。 但是由于binlog没写完就crash了,这时候binlog里面就没有记录这个语句。因此,之后备份日志的时候,存起来的binlog里面就没有这条语句。 然后你会发现,如果需要用这个binlog来恢复临时库的话,由于这个语句的binlog丢失,这个临时库就会少了这一次更新,恢复出来的这一行c的值就是0,与原库的值不同。 2. **先写binlog后写redo log**。如果在binlog写完之后crash,由于redo log还没写,崩溃恢复以后这个事务无效,所以这一行c的值是0。但是binlog里面已经记录了“把c从0改成1”这个日志。所以,在之后用binlog来恢复的时候就多了一个事务出来,恢复出来的这一行c的值就是1,与原库的值不同。 可以看到,如果不使用“两阶段提交”,那么数据库的状态就有可能和用它的日志恢复出来的库的状态不一致。 你可能会说,这个概率是不是很低,平时也没有什么动不动就需要恢复临时库的场景呀? 其实不是的,不只是误操作后需要用这个过程来恢复数据。当你需要扩容的时候,也就是需要再多搭建一些备库来增加系统的读能力的时候,现在常见的做法也是用全量备份加上应用binlog来实现的,这个“不一致”就会导致你的线上出现主从数据库不一致的情况。 简单说,redo log和binlog都可以用于表示事务的提交状态,而两阶段提交就是让这两个状态保持逻辑上的一致。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/02-ri-zhi-xi-tong-yi-tiao-sql-geng-xin-yu-ju-shi-ru-he-zhi-hang-de#xiao-jie) 小结 今天,我介绍了MySQL里面最重要的两个日志,即物理日志redo log和逻辑日志binlog。 redo log用于保证crash-safe能力。innodb\_flush\_log\_at\_trx\_commit这个参数设置成1的时候,表示每次事务的redo log都直接持久化到磁盘。这个参数我建议你设置成1,这样可以保证MySQL异常重启之后数据不丢失。 sync\_binlog这个参数设置成1的时候,表示每次事务的binlog都持久化到磁盘。这个参数我也建议你设置成1,这样可以保证MySQL异常重启之后binlog不丢失。 我还跟你介绍了与MySQL日志系统密切相关的“两阶段提交”。两阶段提交是跨系统维持数据逻辑一致性时常用的一个方案,即使你不做数据库内核开发,日常开发中也有可能会用到。 文章的最后,我给你留一个思考题吧。前面我说到定期全量备份的周期“取决于系统重要性,有的是一天一备,有的是一周一备”。那么在什么场景下,一天一备会比一周一备更有优势呢?或者说,它影响了这个数据库系统的哪个指标? 你可以把你的思考和观点写在留言区里,我会在下一篇文章的末尾给出我的答案。 感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fce%2Fd9%2Fce7f4e35916ed1aa49206a53a0547bd9.jpg&width=768&dpr=4&quality=100&sign=49295b8f&sv=2) [Previous01 基础架构:一条SQL查询语句是如何执行的?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/01-ji-chu-jia-gou-yi-tiao-sql-cha-xun-yu-ju-shi-ru-he-zhi-hang-de) [Next03 事务隔离:为什么你改了我还看不见?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/03-shi-wu-ge-li-wei-shi-mo-ni-gai-le-wo-huan-kan-bu-jian) Last updated 2 years ago Was this helpful? --- # 05 深入浅出索引(下) | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252FQMlSjVe3Y8JNJj2UTwhk%252Fimage.png%3Falt%3Dmedia%26token%3D55107562-f762-46c3-b0cf-5aa47bb9d9ea&width=768&dpr=4&quality=100&sign=cb785b05&sv=2) 在上一篇文章中,我和你介绍了InnoDB索引的数据结构模型,今天我们再继续聊聊跟MySQL索引有关的概念。 在开始这篇文章之前,我们先来看一下这个问题: 在下面这个表T中,如果我执行 select \* from T where k between 3 and 5,需要执行几次树的搜索操作,会扫描多少行? 下面是这个表的初始化语句。 Copy mysql> create table T ( ID int primary key, k int NOT NULL DEFAULT 0, s varchar(16) NOT NULL DEFAULT '', index k(k)) engine=InnoDB; insert into T values(100,1, 'aa'),(200,2,'bb'),(300,3,'cc'),(500,5,'ee'),(600,6,'ff'),(700,7,'gg'); ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fdc%2F8d%2Fdcda101051f28502bd5c4402b292e38d.png&width=768&dpr=4&quality=100&sign=48cb738b&sv=2) 图1 InnoDB的索引组织结构 现在,我们一起来看看这条SQL查询语句的执行流程: 1. 在k索引树上找到k=3的记录,取得 ID = 300; 2. 再到ID索引树查到ID=300对应的R3; 3. 在k索引树取下一个值k=5,取得ID=500; 4. 再回到ID索引树查到ID=500对应的R4; 5. 在k索引树取下一个值k=6,不满足条件,循环结束。 在这个过程中,**回到主键索引树搜索的过程,我们称为回表**。可以看到,这个查询过程读了k索引树的3条记录(步骤1、3和5),回表了两次(步骤2和4)。 在这个例子中,由于查询结果所需要的数据只在主键索引上有,所以不得不回表。那么,有没有可能经过索引优化,避免回表过程呢? ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/05-shen-ru-qian-chu-suo-yin-xia#fu-gai-suo-yin) 覆盖索引 如果执行的语句是select ID from T where k between 3 and 5,这时只需要查ID的值,而ID的值已经在k索引树上了,因此可以直接提供查询结果,不需要回表。也就是说,在这个查询里面,索引k已经“覆盖了”我们的查询需求,我们称为覆盖索引。 **由于覆盖索引可以减少树的搜索次数,显著提升查询性能,所以使用覆盖索引是一个常用的性能优化手段。** 需要注意的是,在引擎内部使用覆盖索引在索引k上其实读了三个记录,R3~R5(对应的索引k上的记录项),但是对于MySQL的Server层来说,它就是找引擎拿到了两条记录,因此MySQL认为扫描行数是2。 > 备注:关于如何查看扫描行数的问题,我将会在第16文章《如何正确地显示随机消息?》中,和你详细讨论。 基于上面覆盖索引的说明,我们来讨论一个问题:**在一个市民信息表上,是否有必要将身份证号和名字建立联合索引?** 假设这个市民表的定义是这样的: Copy CREATE TABLE `tuser` ( `id` int(11) NOT NULL, `id_card` varchar(32) DEFAULT NULL, `name` varchar(32) DEFAULT NULL, `age` int(11) DEFAULT NULL, `ismale` tinyint(1) DEFAULT NULL, PRIMARY KEY (`id`), KEY `id_card` (`id_card`), KEY `name_age` (`name`,`age`) ) ENGINE=InnoDB 我们知道,身份证号是市民的唯一标识。也就是说,如果有根据身份证号查询市民信息的需求,我们只要在身份证号字段上建立索引就够了。而再建立一个(身份证号、姓名)的联合索引,是不是浪费空间? 如果现在有一个高频请求,要根据市民的身份证号查询他的姓名,这个联合索引就有意义了。它可以在这个高频请求上用到覆盖索引,不再需要回表查整行记录,减少语句的执行时间。 当然,索引字段的维护总是有代价的。因此,在建立冗余索引来支持覆盖索引时就需要权衡考虑了。这正是业务DBA,或者称为业务数据架构师的工作。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/05-shen-ru-qian-chu-suo-yin-xia#zui-zuo-qian-zhui-yuan-ze) 最左前缀原则 看到这里你一定有一个疑问,如果为每一种查询都设计一个索引,索引是不是太多了。如果我现在要按照市民的身份证号去查他的家庭地址呢?虽然这个查询需求在业务中出现的概率不高,但总不能让它走全表扫描吧?反过来说,单独为一个不频繁的请求创建一个(身份证号,地址)的索引又感觉有点浪费。应该怎么做呢? 这里,我先和你说结论吧。**B+树这种索引结构,可以利用索引的“最左前缀”,来定位记录。** 为了直观地说明这个概念,我们用(name,age)这个联合索引来分析。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F89%2F70%2F89f74c631110cfbc83298ef27dcd6370.jpg&width=768&dpr=4&quality=100&sign=15443fb8&sv=2) 图2 (name,age)索引示意图 可以看到,索引项是按照索引定义里面出现的字段顺序排序的。 当你的逻辑需求是查到所有名字是“张三”的人时,可以快速定位到ID4,然后向后遍历得到所有需要的结果。 如果你要查的是所有名字第一个字是“张”的人,你的SQL语句的条件是"where name like ‘张%’"。这时,你也能够用上这个索引,查找到第一个符合条件的记录是ID3,然后向后遍历,直到不满足条件为止。 可以看到,不只是索引的全部定义,只要满足最左前缀,就可以利用索引来加速检索。这个最左前缀可以是联合索引的最左N个字段,也可以是字符串索引的最左M个字符。 基于上面对最左前缀索引的说明,我们来讨论一个问题:**在建立联合索引的时候,如何安排索引内的字段顺序。** 这里我们的评估标准是,索引的复用能力。因为可以支持最左前缀,所以当已经有了(a,b)这个联合索引后,一般就不需要单独在a上建立索引了。因此,**第一原则是,如果通过调整顺序,可以少维护一个索引,那么这个顺序往往就是需要优先考虑采用的。** 所以现在你知道了,这段开头的问题里,我们要为高频请求创建(身份证号,姓名)这个联合索引,并用这个索引支持“根据身份证号查询地址”的需求。 那么,如果既有联合查询,又有基于a、b各自的查询呢?查询条件里面只有b的语句,是无法使用(a,b)这个联合索引的,这时候你不得不维护另外一个索引,也就是说你需要同时维护(a,b)、(b) 这两个索引。 这时候,我们要**考虑的原则就是空间**了。比如上面这个市民表的情况,name字段是比age字段大的 ,那我就建议你创建一个(name,age)的联合索引和一个(age)的单字段索引。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/05-shen-ru-qian-chu-suo-yin-xia#suo-yin-xia-tui) 索引下推 上一段我们说到满足最左前缀原则的时候,最左前缀可以用于在索引中定位记录。这时,你可能要问,那些不符合最左前缀的部分,会怎么样呢? 我们还是以市民表的联合索引(name, age)为例。如果现在有一个需求:检索出表中“名字第一个字是张,而且年龄是10岁的所有男孩”。那么,SQL语句是这么写的: Copy mysql> select * from tuser where name like '张%' and age=10 and ismale=1; 你已经知道了前缀索引规则,所以这个语句在搜索索引树的时候,只能用 “张”,找到第一个满足条件的记录ID3。当然,这还不错,总比全表扫描要好。 然后呢? 当然是判断其他条件是否满足。 在MySQL 5.6之前,只能从ID3开始一个个回表。到主键索引上找出数据行,再对比字段值。 而MySQL 5.6 引入的索引下推优化(index condition pushdown), 可以在索引遍历过程中,对索引中包含的字段先做判断,直接过滤掉不满足条件的记录,减少回表次数。 图3和图4,是这两个过程的执行流程图。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fb3%2Fac%2Fb32aa8b1f75611e0759e52f5915539ac.jpg&width=768&dpr=4&quality=100&sign=457d1230&sv=2) 图3 无索引下推执行流程 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F76%2F1b%2F76e385f3df5a694cc4238c7b65acfe1b.jpg&width=768&dpr=4&quality=100&sign=67d706f0&sv=2) 图4 索引下推执行流程 在图3和4这两个图里面,每一个虚线箭头表示回表一次。 图3中,在(name,age)索引里面我特意去掉了age的值,这个过程InnoDB并不会去看age的值,只是按顺序把“name第一个字是’张’”的记录一条条取出来回表。因此,需要回表4次。 图4跟图3的区别是,InnoDB在(name,age)索引内部就判断了age是否等于10,对于不等于10的记录,直接判断并跳过。在我们的这个例子中,只需要对ID4、ID5这两条记录回表取数据判断,就只需要回表2次。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/05-shen-ru-qian-chu-suo-yin-xia#xiao-jie) 小结 今天这篇文章,我和你继续讨论了数据库索引的概念,包括了覆盖索引、前缀索引、索引下推。你可以看到,在满足语句需求的情况下, 尽量少地访问资源是数据库设计的重要原则之一。我们在使用数据库的时候,尤其是在设计表结构时,也要以减少资源消耗作为目标。 接下来我给你留下一个问题吧。 实际上主键索引也是可以使用多个字段的。DBA小吕在入职新公司的时候,就发现自己接手维护的库里面,有这么一个表,表结构定义类似这样的: Copy CREATE TABLE `geek` ( `a` int(11) NOT NULL, `b` int(11) NOT NULL, `c` int(11) NOT NULL, `d` int(11) NOT NULL, PRIMARY KEY (`a`,`b`), KEY `c` (`c`), KEY `ca` (`c`,`a`), KEY `cb` (`c`,`b`) ) ENGINE=InnoDB; 公司的同事告诉他说,由于历史原因,这个表需要a、b做联合主键,这个小吕理解了。 但是,学过本章内容的小吕又纳闷了,既然主键包含了a、b这两个字段,那意味着单独在字段c上创建一个索引,就已经包含了三个字段了呀,为什么要创建“ca”“cb”这两个索引? 同事告诉他,是因为他们的业务里面有这样的两种语句: Copy select * from geek where c=N order by a limit 1; select * from geek where c=N order by b limit 1; 我给你的问题是,这位同事的解释对吗,为了这两个查询模式,这两个索引是否都是必须的?为什么呢? 你可以把你的思考和观点写在留言区里,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/05-shen-ru-qian-chu-suo-yin-xia#shang-qi-wen-ti-shi-jian) 上期问题时间 上期的问题是,通过两个alter 语句重建索引k,以及通过两个alter语句重建主键索引是否合理。 在评论区,有同学问到为什么要重建索引。我们文章里面有提到,索引可能因为删除,或者页分裂等原因,导致数据页有空洞,重建索引的过程会创建一个新的索引,把数据按顺序插入,这样页面的利用率最高,也就是索引更紧凑、更省空间。 这道题目,我给你的“参考答案”是: 重建索引k的做法是合理的,可以达到省空间的目的。但是,重建主键的过程不合理。不论是删除主键还是创建主键,都会将整个表重建。所以连着执行这两个语句的话,第一个语句就白做了。这两个语句,你可以用这个语句代替 : alter table T engine=InnoDB。在专栏的第12篇文章《为什么表数据删掉一半,表文件大小不变?》中,我会和你分析这条语句的执行流程。 评论区留言中, @壹笙☞漂泊 做了很详细的笔记,@高枕 帮同学解答了问题,@约书亚 提了一个很不错的面试问题。在这里,我要和你们道一声感谢。 PS:如果你在面试中,曾有过被MySQL相关问题难住的经历,也可以把这个问题发到评论区,我们一起来讨论。如果解答这个问题,需要的篇幅会很长的话,我可以放到答疑文章展开。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fce%2Fd9%2Fce7f4e35916ed1aa49206a53a0547bd9.jpg&width=768&dpr=4&quality=100&sign=49295b8f&sv=2) [Previous04 深入浅出索引(上)](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/04-shen-ru-qian-chu-suo-yin-shang) [Next06 全局锁和表锁 :给表加个字段怎么有这么多阻碍?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/06-quan-ju-suo-he-biao-suo-gei-biao-jia-ge-zi-duan-zen-mo-you-zhe-mo-duo-zu-ai) Last updated 2 years ago Was this helpful? --- # 07 行锁功过:怎么减少行锁对性能的影响? | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252FGCxZkzJ6F43ffJQ7P165%252Fimage.png%3Falt%3Dmedia%26token%3D144ba06e-1147-46ce-adc8-7f3af93d116e&width=768&dpr=4&quality=100&sign=bc5f7ccf&sv=2) 在上一篇文章中,我跟你介绍了MySQL的全局锁和表级锁,今天我们就来讲讲MySQL的行锁。 MySQL的行锁是在引擎层由各个引擎自己实现的。但并不是所有的引擎都支持行锁,比如MyISAM引擎就不支持行锁。不支持行锁意味着并发控制只能使用表锁,对于这种引擎的表,同一张表上任何时刻只能有一个更新在执行,这就会影响到业务并发度。InnoDB是支持行锁的,这也是MyISAM被InnoDB替代的重要原因之一。 我们今天就主要来聊聊InnoDB的行锁,以及如何通过减少锁冲突来提升业务并发度。 顾名思义,行锁就是针对数据表中行记录的锁。这很好理解,比如事务A更新了一行,而这时候事务B也要更新同一行,则必须等事务A的操作完成后才能进行更新。 当然,数据库中还有一些没那么一目了然的概念和设计,这些概念如果理解和使用不当,容易导致程序出现非预期行为,比如两阶段锁。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/07-hang-suo-gong-guo-zen-mo-jian-shao-hang-suo-dui-xing-neng-de-ying-xiang#cong-liang-jie-duan-suo-shuo-qi) 从两阶段锁说起 我先给你举个例子。在下面的操作序列中,事务B的update语句执行时会是什么现象呢?假设字段id是表t的主键。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F51%2F10%2F51f501f718e420244b0a2ec2ce858710.jpg&width=768&dpr=4&quality=100&sign=e2a3e4e6&sv=2) 这个问题的结论取决于事务A在执行完两条update语句后,持有哪些锁,以及在什么时候释放。你可以验证一下:实际上事务B的update语句会被阻塞,直到事务A执行commit之后,事务B才能继续执行。 知道了这个答案,你一定知道了事务A持有的两个记录的行锁,都是在commit的时候才释放的。 也就是说,**在InnoDB事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。这个就是两阶段锁协议。** 知道了这个设定,对我们使用事务有什么帮助呢?那就是,如果你的事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放。我给你举个例子。 假设你负责实现一个电影票在线交易业务,顾客A要在影院B购买电影票。我们简化一点,这个业务需要涉及到以下操作: 1. 从顾客A账户余额中扣除电影票价; 2. 给影院B的账户余额增加这张电影票价; 3. 记录一条交易日志。 也就是说,要完成这个交易,我们需要update两条记录,并insert一条记录。当然,为了保证交易的原子性,我们要把这三个操作放在一个事务中。那么,你会怎样安排这三个语句在事务中的顺序呢? 试想如果同时有另外一个顾客C要在影院B买票,那么这两个事务冲突的部分就是语句2了。因为它们要更新同一个影院账户的余额,需要修改同一行数据。 根据两阶段锁协议,不论你怎样安排语句顺序,所有的操作需要的行锁都是在事务提交的时候才释放的。所以,如果你把语句2安排在最后,比如按照3、1、2这样的顺序,那么影院账户余额这一行的锁时间就最少。这就最大程度地减少了事务之间的锁等待,提升了并发度。 好了,现在由于你的正确设计,影院余额这一行的行锁在一个事务中不会停留很长时间。但是,这并没有完全解决你的困扰。 如果这个影院做活动,可以低价预售一年内所有的电影票,而且这个活动只做一天。于是在活动时间开始的时候,你的MySQL就挂了。你登上服务器一看,CPU消耗接近100%,但整个数据库每秒就执行不到100个事务。这是什么原因呢? 这里,我就要说到死锁和死锁检测了。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/07-hang-suo-gong-guo-zen-mo-jian-shao-hang-suo-dui-xing-neng-de-ying-xiang#si-suo-he-si-suo-jian-ce) 死锁和死锁检测 当并发系统中不同线程出现循环资源依赖,涉及的线程都在等待别的线程释放资源时,就会导致这几个线程都进入无限等待的状态,称为死锁。这里我用数据库中的行锁举个例子。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F4d%2F52%2F4d0eeec7b136371b79248a0aed005a52.jpg&width=768&dpr=4&quality=100&sign=87d7ef4d&sv=2) 这时候,事务A在等待事务B释放id=2的行锁,而事务B在等待事务A释放id=1的行锁。 事务A和事务B在互相等待对方的资源释放,就是进入了死锁状态。当出现死锁以后,有两种策略: * 一种策略是,直接进入等待,直到超时。这个超时时间可以通过参数innodb\_lock\_wait\_timeout来设置。 * 另一种策略是,发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。将参数innodb\_deadlock\_detect设置为on,表示开启这个逻辑。 在InnoDB中,innodb\_lock\_wait\_timeout的默认值是50s,意味着如果采用第一个策略,当出现死锁以后,第一个被锁住的线程要过50s才会超时退出,然后其他线程才有可能继续执行。对于在线服务来说,这个等待时间往往是无法接受的。 但是,我们又不可能直接把这个时间设置成一个很小的值,比如1s。这样当出现死锁的时候,确实很快就可以解开,但如果不是死锁,而是简单的锁等待呢?所以,超时时间设置太短的话,会出现很多误伤。 所以,正常情况下我们还是要采用第二种策略,即:主动死锁检测,而且innodb\_deadlock\_detect的默认值本身就是on。主动死锁检测在发生死锁的时候,是能够快速发现并进行处理的,但是它也是有额外负担的。 你可以想象一下这个过程:每当一个事务被锁的时候,就要看看它所依赖的线程有没有被别人锁住,如此循环,最后判断是否出现了循环等待,也就是死锁。 那如果是我们上面说到的所有事务都要更新同一行的场景呢? 每个新来的被堵住的线程,都要判断会不会由于自己的加入导致了死锁,这是一个时间复杂度是O(n)的操作。假设有1000个并发线程要同时更新同一行,那么死锁检测操作就是100万这个量级的。虽然最终检测的结果是没有死锁,但是这期间要消耗大量的CPU资源。因此,你就会看到CPU利用率很高,但是每秒却执行不了几个事务。 根据上面的分析,我们来讨论一下,怎么解决由这种热点行更新导致的性能问题呢?问题的症结在于,死锁检测要耗费大量的CPU资源。 **一种头痛医头的方法,就是如果你能确保这个业务一定不会出现死锁,可以临时把死锁检测关掉。**但是这种操作本身带有一定的风险,因为业务设计的时候一般不会把死锁当做一个严重错误,毕竟出现死锁了,就回滚,然后通过业务重试一般就没问题了,这是业务无损的。而关掉死锁检测意味着可能会出现大量的超时,这是业务有损的。 **另一个思路是控制并发度。**根据上面的分析,你会发现如果并发能够控制住,比如同一行同时最多只有10个线程在更新,那么死锁检测的成本很低,就不会出现这个问题。一个直接的想法就是,在客户端做并发控制。但是,你会很快发现这个方法不太可行,因为客户端很多。我见过一个应用,有600个客户端,这样即使每个客户端控制到只有5个并发线程,汇总到数据库服务端以后,峰值并发数也可能要达到3000。 因此,这个并发控制要做在数据库服务端。如果你有中间件,可以考虑在中间件实现;如果你的团队有能修改MySQL源码的人,也可以做在MySQL里面。基本思路就是,对于相同行的更新,在进入引擎之前排队。这样在InnoDB内部就不会有大量的死锁检测工作了。 可能你会问,**如果团队里暂时没有数据库方面的专家,不能实现这样的方案,能不能从设计上优化这个问题呢?** 你可以考虑通过将一行改成逻辑上的多行来减少锁冲突。还是以影院账户为例,可以考虑放在多条记录上,比如10个记录,影院的账户总额等于这10个记录的值的总和。这样每次要给影院账户加金额的时候,随机选其中一条记录来加。这样每次冲突概率变成原来的1/10,可以减少锁等待个数,也就减少了死锁检测的CPU消耗。 这个方案看上去是无损的,但其实这类方案需要根据业务逻辑做详细设计。如果账户余额可能会减少,比如退票逻辑,那么这时候就需要考虑当一部分行记录变成0的时候,代码要有特殊处理。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/07-hang-suo-gong-guo-zen-mo-jian-shao-hang-suo-dui-xing-neng-de-ying-xiang#xiao-jie) 小结 今天,我和你介绍了MySQL的行锁,涉及了两阶段锁协议、死锁和死锁检测这两大部分内容。 其中,我以两阶段协议为起点,和你一起讨论了在开发的时候如何安排正确的事务语句。这里的原则/我给你的建议是:如果你的事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁的申请时机尽量往后放。 但是,调整语句顺序并不能完全避免死锁。所以我们引入了死锁和死锁检测的概念,以及提供了三个方案,来减少死锁对数据库的影响。减少死锁的主要方向,就是控制访问相同资源的并发事务量。 最后,我给你留下一个问题吧。如果你要删除一个表里面的前10000行数据,有以下三种方法可以做到: * 第一种,直接执行delete from T limit 10000; * 第二种,在一个连接中循环执行20次 delete from T limit 500; * 第三种,在20个连接中同时执行delete from T limit 500。 你会选择哪一种方法呢?为什么呢? 你可以把你的思考和观点写在留言区里,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/07-hang-suo-gong-guo-zen-mo-jian-shao-hang-suo-dui-xing-neng-de-ying-xiang#shang-qi-wen-ti-shi-jian) 上期问题时间 上期我给你留的问题是:当备库用–single-transaction做逻辑备份的时候,如果从主库的binlog传来一个DDL语句会怎么样? 假设这个DDL是针对表t1的, 这里我把备份过程中几个关键的语句列出来: Copy 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前的表结构。 评论区留言点赞板: > @Aurora 给了最接近的答案; @echo_陈 问了一个好问题; @壹笙☞漂泊 做了很好的总结。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fce%2Fd9%2Fce7f4e35916ed1aa49206a53a0547bd9.jpg&width=768&dpr=4&quality=100&sign=49295b8f&sv=2) [Previous06 全局锁和表锁 :给表加个字段怎么有这么多阻碍?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/06-quan-ju-suo-he-biao-suo-gei-biao-jia-ge-zi-duan-zen-mo-you-zhe-mo-duo-zu-ai) [Next08 事务到底是隔离的还是不隔离的?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/08-shi-wu-dao-di-shi-ge-li-de-huan-shi-bu-ge-li-de) Last updated 2 years ago Was this helpful? --- # 31 误删数据后除了跑路,还能怎么办? | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252FRHZM5PrdesX0hSkI8Esn%252Fimage.png%3Falt%3Dmedia%26token%3D6291eaf0-4cac-43a5-bc0e-803e62004cac&width=768&dpr=4&quality=100&sign=50289cd7&sv=2) 今天我要和你讨论的是一个沉重的话题:误删数据。 在前面几篇文章中,我们介绍了MySQL的高可用架构。当然,传统的高可用架构是不能预防误删数据的,因为主库的一个drop table命令,会通过binlog传给所有从库和级联从库,进而导致整个集群的实例都会执行这个命令。 虽然我们之前遇到的大多数的数据被删,都是运维同学或者DBA背锅的。但实际上,只要有数据操作权限的同学,都有可能踩到误删数据这条线。 今天我们就来聊聊误删数据前后,我们可以做些什么,减少误删数据的风险,和由误删数据带来的损失。 为了找到解决误删数据的更高效的方法,我们需要先对和MySQL相关的误删数据,做下分类: 1. 使用delete语句误删数据行; 2. 使用drop table或者truncate table语句误删数据表; 3. 使用drop database语句误删数据库; 4. 使用rm命令误删整个MySQL实例。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/31-wu-shan-shu-ju-hou-chu-le-pao-lu-huan-neng-zen-mo-ban#wu-shan-xing) 误删行 在[第24篇文章](https://time.geekbang.org/column/article/76446) 中,我们提到如果是使用delete语句误删了数据行,可以用Flashback工具通过闪回把数据恢复回来。 Flashback恢复数据的原理,是修改binlog的内容,拿回原库重放。而能够使用这个方案的前提是,需要确保binlog\_format=row 和 binlog\_row\_image=FULL。 具体恢复数据时,对单个事务做如下处理: 1. 对于insert语句,对应的binlog event类型是Write\_rows event,把它改成Delete\_rows event即可; 2. 同理,对于delete语句,也是将Delete\_rows event改为Write\_rows event; 3. 而如果是Update\_rows的话,binlog里面记录了数据行修改前和修改后的值,对调这两行的位置即可。 如果误操作不是一个,而是多个,会怎么样呢?比如下面三个事务: Copy (A)delete ... (B)insert ... (C)update ... 现在要把数据库恢复回这三个事务操作之前的状态,用Flashback工具解析binlog后,写回主库的命令是: Copy (reverse C)update ... (reverse B)delete ... (reverse A)insert ... 也就是说,如果误删数据涉及到了多个事务的话,需要将事务的顺序调过来再执行。 **需要说明的是,我不建议你直接在主库上执行这些操作。** 恢复数据比较安全的做法,是恢复出一个备份,或者找一个从库作为临时库,在这个临时库上执行这些操作,然后再将确认过的临时库的数据,恢复回主库。 为什么要这么做呢? 这是因为,一个在执行线上逻辑的主库,数据状态的变更往往是有关联的。可能由于发现数据问题的时间晚了一点儿,就导致已经在之前误操作的基础上,业务代码逻辑又继续修改了其他数据。所以,如果这时候单独恢复这几行数据,而又未经确认的话,就可能会出现对数据的二次破坏。 当然,**我们不止要说误删数据的事后处理办法,更重要是要做到事前预防**。我有以下两个建议: 1. 把sql\_safe\_updates参数设置为on。这样一来,如果我们忘记在delete或者update语句中写where条件,或者where条件里面没有包含索引字段的话,这条语句的执行就会报错。 2. 代码上线前,必须经过SQL审计。 你可能会说,设置了sql\_safe\_updates=on,如果我真的要把一个小表的数据全部删掉,应该怎么办呢? 如果你确定这个删除操作没问题的话,可以在delete语句中加上where条件,比如where id>=0。 但是,delete全表是很慢的,需要生成回滚日志、写redo、写binlog。所以,从性能角度考虑,你应该优先考虑使用truncate table或者drop table命令。 使用delete命令删除的数据,你还可以用Flashback来恢复。而使用truncate /drop table和drop database命令删除的数据,就没办法通过Flashback来恢复了。为什么呢? 这是因为,即使我们配置了binlog\_format=row,执行这三个命令时,记录的binlog还是statement格式。binlog里面就只有一个truncate/drop 语句,这些信息是恢复不出数据的。 那么,如果我们真的是使用这几条命令误删数据了,又该怎么办呢? ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/31-wu-shan-shu-ju-hou-chu-le-pao-lu-huan-neng-zen-mo-ban#wu-shan-ku-biao) 误删库/表 这种情况下,要想恢复数据,就需要使用全量备份,加增量日志的方式了。这个方案要求线上有定期的全量备份,并且实时备份binlog。 在这两个条件都具备的情况下,假如有人中午12点误删了一个库,恢复数据的流程如下: 1. 取最近一次全量备份,假设这个库是一天一备,上次备份是当天0点; 2. 用备份恢复出一个临时库; 3. 从日志备份里面,取出凌晨0点之后的日志; 4. 把这些日志,除了误删除数据的语句外,全部应用到临时库。 这个流程的示意图如下所示: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F2f%2Fdb%2F2fafd0b75286e0163f432f85428ff8db.png&width=768&dpr=4&quality=100&sign=9ca2e070&sv=2) 图1 数据恢复流程-mysqlbinlog方法 关于这个过程,我需要和你说明如下几点: 1. 为了加速数据恢复,如果这个临时库上有多个数据库,你可以在使用mysqlbinlog命令时,加上一个–database参数,用来指定误删表所在的库。这样,就避免了在恢复数据时还要应用其他库日志的情况。 2. 在应用日志的时候,需要跳过12点误操作的那个语句的binlog: * 如果原实例没有使用GTID模式,只能在应用到包含12点的binlog文件的时候,先用–stop-position参数执行到误操作之前的日志,然后再用–start-position从误操作之后的日志继续执行; * 如果实例使用了GTID模式,就方便多了。假设误操作命令的GTID是gtid1,那么只需要执行set gtid\_next=gtid1;begin;commit; 先把这个GTID加到临时实例的GTID集合,之后按顺序执行binlog的时候,就会自动跳过误操作的语句。 不过,即使这样,使用mysqlbinlog方法恢复数据还是不够快,主要原因有两个: 1. 如果是误删表,最好就是只恢复出这张表,也就是只重放这张表的操作,但是mysqlbinlog工具并不能指定只解析一个表的日志; 2. 用mysqlbinlog解析出日志应用,应用日志的过程就只能是单线程。我们在[第26篇文章](https://time.geekbang.org/column/article/77083) 中介绍的那些并行复制的方法,在这里都用不上。 **一种加速的方法是,**在用备份恢复出临时实例之后,将这个临时实例设置成线上备库的从库,这样: 1. 在start slave之前,先通过执行 change replication filter replicate\_do\_table = (tbl\_name) 命令,就可以让临时库只同步误操作的表; 2. 这样做也可以用上并行复制技术,来加速整个数据恢复过程。 这个过程的示意图如下所示。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F65%2Ff1%2F65bb04929b8235fb677c7a78b5bd67f1.png&width=768&dpr=4&quality=100&sign=b489c094&sv=2) 图2 数据恢复流程-master-slave方法 可以看到,图中binlog备份系统到线上备库有一条虚线,是指如果由于时间太久,备库上已经删除了临时实例需要的binlog的话,我们可以从binlog备份系统中找到需要的binlog,再放回备库中。 假设,我们发现当前临时实例需要的binlog是从master.000005开始的,但是在备库上执行show binlogs 显示的最小的binlog文件是master.000007,意味着少了两个binlog文件。这时,我们就需要去binlog备份系统中找到这两个文件。 把之前删掉的binlog放回备库的操作步骤,是这样的: 1. 从备份系统下载master.000005和master.000006这两个文件,放到备库的日志目录下; 2. 打开日志目录下的master.index文件,在文件开头加入两行,内容分别是 “./master.000005”和“./master.000006”; 3. 重启备库,目的是要让备库重新识别这两个日志文件; 4. 现在这个备库上就有了临时库需要的所有binlog了,建立主备关系,就可以正常同步了。 不论是把mysqlbinlog工具解析出的binlog文件应用到临时库,还是把临时库接到备库上,这两个方案的共同点是:误删库或者表后,恢复数据的思路主要就是通过备份,再加上应用binlog的方式。 也就是说,这两个方案都要求备份系统定期备份全量日志,而且需要确保binlog在被从本地删除之前已经做了备份。 但是,一个系统不可能备份无限的日志,你还需要根据成本和磁盘空间资源,设定一个日志保留的天数。如果你的DBA团队告诉你,可以保证把某个实例恢复到半个月内的任意时间点,这就表示备份系统保留的日志时间就至少是半个月。 另外,我建议你不论使用上述哪种方式,都要把这个数据恢复功能做成自动化工具,并且经常拿出来演练。为什么这么说呢? 这里的原因,主要包括两个方面: 1. 虽然“发生这种事,大家都不想的”,但是万一出现了误删事件,能够快速恢复数据,将损失降到最小,也应该不用跑路了。 2. 而如果临时再手忙脚乱地手动操作,最后又误操作了,对业务造成了二次伤害,那就说不过去了。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/31-wu-shan-shu-ju-hou-chu-le-pao-lu-huan-neng-zen-mo-ban#yan-chi-fu-zhi-bei-ku) 延迟复制备库 虽然我们可以通过利用并行复制来加速恢复数据的过程,但是这个方案仍然存在“恢复时间不可控”的问题。 如果一个库的备份特别大,或者误操作的时间距离上一个全量备份的时间较长,比如一周一备的实例,在备份之后的第6天发生误操作,那就需要恢复6天的日志,这个恢复时间可能是要按天来计算的。 那么,我们有什么方法可以缩短恢复数据需要的时间呢? 如果有非常核心的业务,不允许太长的恢复时间,我们可以考虑**搭建延迟复制的备库。**这个功能是MySQL 5.6版本引入的。 一般的主备复制结构存在的问题是,如果主库上有个表被误删了,这个命令很快也会被发给所有从库,进而导致所有从库的数据表也都一起被误删了。 延迟复制的备库是一种特殊的备库,通过 CHANGE MASTER TO MASTER\_DELAY = N命令,可以指定这个备库持续保持跟主库有N秒的延迟。 比如你把N设置为3600,这就代表了如果主库上有数据被误删了,并且在1小时内发现了这个误操作命令,这个命令就还没有在这个延迟复制的备库执行。这时候到这个备库上执行stop slave,再通过之前介绍的方法,跳过误操作命令,就可以恢复出需要的数据。 这样的话,你就随时可以得到一个,只需要最多再追1小时,就可以恢复出数据的临时实例,也就缩短了整个数据恢复需要的时间。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/31-wu-shan-shu-ju-hou-chu-le-pao-lu-huan-neng-zen-mo-ban#yu-fang-wu-shan-ku-biao-de-fang-fa) 预防误删库/表的方法 虽然常在河边走,很难不湿鞋,但终究还是可以找到一些方法来避免的。所以这里,我也会给你一些减少误删操作风险的建议。 第一条建议是,账号分离。这样做的目的是,避免写错命令。比如: * 我们只给业务开发同学DML权限,而不给truncate/drop权限。而如果业务开发人员有DDL需求的话,也可以通过开发管理系统得到支持。 * 即使是DBA团队成员,日常也都规定只使用只读账号,必要的时候才使用有更新权限的账号。 第二条建议是,制定操作规范。这样做的目的,是避免写错要删除的表名。比如: * 在删除数据表之前,必须先对表做改名操作。然后,观察一段时间,确保对业务无影响以后再删除这张表。 * 改表名的时候,要求给表名加固定的后缀(比如加\_to\_be\_deleted),然后删除表的动作必须通过管理系统执行。并且,管理系删除表的时候,只能删除固定后缀的表。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/31-wu-shan-shu-ju-hou-chu-le-pao-lu-huan-neng-zen-mo-ban#rm-shan-chu-shu-ju) rm删除数据 其实,对于一个有高可用机制的MySQL集群来说,最不怕的就是rm删除数据了。只要不是恶意地把整个集群删除,而只是删掉了其中某一个节点的数据的话,HA系统就会开始工作,选出一个新的主库,从而保证整个集群的正常工作。 这时,你要做的就是在这个节点上把数据恢复回来,再接入整个集群。 当然了,现在不止是DBA有自动化系统,SA(系统管理员)也有自动化系统,所以也许一个批量下线机器的操作,会让你整个MySQL集群的所有节点都全军覆没。 应对这种情况,我的建议只能是说尽量把你的备份跨机房,或者最好是跨城市保存。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/31-wu-shan-shu-ju-hou-chu-le-pao-lu-huan-neng-zen-mo-ban#xiao-jie) 小结 今天,我和你讨论了误删数据的几种可能,以及误删后的处理方法。 但,我要强调的是,预防远比处理的意义来得大。 另外,在MySQL的集群方案中,会时不时地用到备份来恢复实例,因此定期检查备份的有效性也很有必要。 如果你是业务开发同学,你可以用show grants命令查看账户的权限,如果权限过大,可以建议DBA同学给你分配权限低一些的账号;你也可以评估业务的重要性,和DBA商量备份的周期、是否有必要创建延迟复制的备库等等。 数据和服务的可靠性不止是运维团队的工作,最终是各个环节一起保障的结果。 今天的课后话题是,回忆下你亲身经历过的误删数据事件吧,你用了什么方法来恢复数据呢?你在这个过程中得到的经验又是什么呢? 你可以把你的经历和经验写在留言区,我会在下一篇文章的末尾选取有趣的评论和你一起讨论。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/31-wu-shan-shu-ju-hou-chu-le-pao-lu-huan-neng-zen-mo-ban#shang-qi-wen-ti-shi-jian) 上期问题时间 我在上一篇文章给你留的问题,是关于空表的间隙的定义。 一个空表就只有一个间隙。比如,在空表上执行: Copy begin; select * from t where id>1 for update; 这个查询语句加锁的范围就是next-key lock (-∞, supremum\]。 验证方法的话,你可以使用下面的操作序列。你可以在图4中看到显示的结果。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F12%2F65%2F12eb6a38c347203f60df72ecaea95565.png&width=768&dpr=4&quality=100&sign=f990fc9a&sv=2) 图3 复现空表的next-key lock ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F53%2F9f%2F531b6556ffc82c6b02f9a010a3ceb09f.png&width=768&dpr=4&quality=100&sign=bc351038&sv=2) 图4 show engine innodb status 部分结果 评论区留言点赞板: > @老杨同志 给出了正确的分析和SQL语句验证方法; @库淘淘 指出了show engine innodb status验证结论。 赞这些思考和反馈。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F09%2F77%2F09c1073f99cf71d2fb162a716b5fa577.jpg&width=768&dpr=4&quality=100&sign=e5471b04&sv=2) [Previous30 答疑文章(二):用动态的观点看加锁](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/30-da-yi-wen-zhang-er-yong-dong-tai-de-guan-dian-kan-jia-suo) [Next32 为什么还有kill不掉的语句?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/32-wei-shi-mo-huan-you-kill-bu-diao-de-yu-ju) Last updated 2 years ago Was this helpful? --- # 林晓斌:我的 MySQL 心路历程 | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252FCW1GcFDvByJBFdFklEGB%252Fimage.png%3Falt%3Dmedia%26token%3Dc4dfce72-f4b0-4ae1-9779-7ae271de0835&width=768&dpr=4&quality=100&sign=e5f3597d&sv=2) 在专栏上线后的11月21日,我来到极客时间做了一场直播,主题就是“我的MySQL心路历程”。今天,我特意将这个直播的回顾文章,放在了专栏下面,希望你可以从我这些年和MySQL打交道的经历中,找到对你有所帮助的点。 这里,我先和你说一下,在这个直播中,我主要分享的内容: 1. 我和MySQL打交道的经历; 2. 你为什么要了解数据库原理; 3. 我建议的MySQL学习路径; 4. DBA的修炼之道。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/lin-xiao-bin-wo-de-mysql-xin-lu-li-cheng#wo-de-jing-li) 我的经历 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/lin-xiao-bin-wo-de-mysql-xin-lu-li-cheng#yi-feng-fu-de-jing-li-jin-ru-bai-du) 以丰富的经历进入百度 我是福州大学毕业的,据我了解,那时候我们学校的应届生很难直接进入百度,都要考到浙江大学读个研究生才行。没想到的是,我投递了简历后居然进了面试。 入职以后,我跑去问当时的面试官,为什么我的简历可以通过筛选?他们说:“因为你的简历厚啊”。我在读书的时候,确实做了很多项目,也实习过不少公司,所以简历里面的经历就显得很丰富了。 在面试的时候,有个让我印象很深刻的事儿。面试官问我说,你有这么多实习经历,有没有什么比较好玩儿的事?我想了想答道,跟你说个数据量很大的事儿 ,在跟移动做日志分析的时候我碰到了几千万行的数据。他听完以后就笑了。 后来,我进了百度才知道,几千万行那都是小数据。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/lin-xiao-bin-wo-de-mysql-xin-lu-li-cheng#kai-shi-chang-shi-kan-yuan-ma-jie-jue-wen-ti) 开始尝试看源码解决问题 加入百度后,我是在贴吧做后端程序,比如权限系统等等。其实很简单,就是写一个C语言程序,响应客户端请求,然后返回结果。 那个时候,我还仅仅是个MySQL的普通用户,使用了一段时间后就出现问题了:一个跑得很快的请求,偶尔会又跑得非常慢。老板问这是什么原因,而我又不好意思说不知道,于是就自己上网查资料。 但是,2008年那会儿,网上资料很少,花了挺长时间也没查出个所以然。最终,我只好去看源码。翻到源码,我当时就觉得它还蛮有意思的。而且,源码真的可以帮我解决一些问题。 于是一发不可收拾,我从那时候就入了源码的“坑”。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/lin-xiao-bin-wo-de-mysql-xin-lu-li-cheng#hun-she-qu-fen-xiang-jing-yan) 混社区分享经验 2010年的时候,阿里正好在招数据库的开发人员。虽然那时我还只是看得懂源码,没有什么开发经验,但还是抱着试试看的态度投了简历。然后顺利通过了面试,成功进入了阿里。之后,我就跟着褚霸(霸爷)干了7年多才离开了阿里。 在百度的时候,我基本上没有参加过社区活动。因为那时候百度可能更提倡内部分享,解决问题的经验基本上都是在内网分享。所以,去了阿里以后,我才建了博客、开了微博。我在阿里的花名叫丁奇,博客、微博、社区也因此都是用的这个名字。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/lin-xiao-bin-wo-de-mysql-xin-lu-li-cheng#wei-shen-me-yao-le-xie-shu-ju-ku-yuan-li) 为什么要了解数据库原理? 这里,我讲几个亲身经历的事情,和你聊聊为什么要了解数据库原理。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/lin-xiao-bin-wo-de-mysql-xin-lu-li-cheng#liao-jie-yuan-li-neng-bang-ni-geng-hao-di-ding-wei-wen-ti) 了解原理能帮你更好地定位问题 一次同学聚会,大家谈起了技术问题。一个在政府里的同学说,他们的系统很奇怪,每天早上都得重启一下应用程序,否则就提示连接数据库失败,他们都不知道该怎么办。 我分析说,按照这个错误提示,应该就是连接时间过长了,断开了连接。数据库默认的超时时间是8小时,而你们平时六点下班,下班之后系统就没有人用了,等到第二天早上九点甚至十点才上班,这中间的时间已经超过10个小时了,数据库的连接肯定就会断开了。 我当时说,估计这个系统程序写得比较差,连接失败也不会重连,仍然用原来断掉的连接,所以就报错了。然后,我让他回去把超时时间改得长一点。后来他跟我说,按照这个方法,问题已经解决了。 由此,我也更深刻地体会到,作为开发人员,即使我们只知道每个参数的意思,可能就可以给出一些问题的正确应对方法。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/lin-xiao-bin-wo-de-mysql-xin-lu-li-cheng#liao-jie-yuan-li-neng-rang-ni-geng-qiao-miao-di-jie-jue-wen-ti) 了解原理能让你更巧妙地解决问题 我在做贴吧系统的时候,每次访问页面都要请求一次权限。所以,这个请求权限的请求,访问概率会非常高,不可能每次都去数据库里查,怎么办呢? 我想了个简单的方案:在应用程序里面开了个很大的内存,启动的时候就把整张表全部load到内存里去。这样再有权限请求的时候,直接从内存里取就行了。 数据库重启时,我的进程也会跟着重启,接下来就会到数据表里面做全表扫描,把整个用户相关信息全部塞到内存里面去。 但是,后来我遇到了一个很郁闷的情况。有时候MySQL 崩溃了,我的程序重新加载权限到内存里,结果这个select语句要执行30分钟左右。本来MySQL正常重启一下是很快的,进程重启也很快,正常加载权限的过程只需要两分钟就跑完了。但是,为什么异常重启的时候就要30分钟呢? 我没辙了,只好去看源码。然后,我发现MySQL有个机制,当它觉得系统空闲时会尽量去刷脏页。 具体到我们的例子里,MySQL重启以后,会执行我的进程做全表扫描,但是因为这个时候权限数据还没有初始化完成,我的Server层不可能提供服务,于是MySQL里面就只有我那一个select全表扫描的请求,MySQL就认为现在很闲,开始拼命地刷脏页,结果就吃掉了大量的磁盘资源,导致我的全表扫描也跑得很慢。 知道了这个机制以后,我就写了个脚本,每隔0.5秒发一个请求,执行一个简单的SQL查询,告诉数据库其实我现在很忙,脏页刷得慢一点。 脚本一发布使用,脏页果然刷得慢了,加载权限的扫描也跑得很快了。据说我离职两年多以后,这个脚本还在用。 你看,如果我们懂得一些参数,并可以理解这些参数,就可以做正确的设置了。而如果我们进一步地懂得一些原理,就可以更巧妙地解决问题了。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/lin-xiao-bin-wo-de-mysql-xin-lu-li-cheng#kan-de-dong-yuan-ma-rang-ni-you-geng-duo-de-fang-fa) 看得懂源码让你有更多的方法 2012年的时候,阿里双十一业务的压力比较大。当时还没有这么多的SSD,是机械硬盘的时代。 为了应对压力我们开始引入SSD,但是不敢把SSD直接当存储用,而是作为二级缓存。当时,我们用了一个叫作Flashcache的开源系统(现在已经是老古董级别了,不知道你有没有听过这个系统)。 Flashcache实现,把SSD当作物理盘的二级缓存,可以提升性能。但是,我们自己部署后发现性能提升的效果没有预想的那么好,甚至还不如纯机械盘。 于是,我跟霸爷就开始研究。霸爷负责分析Flashcache的源码,我负责分析MySQL源码。后来我们发现Flashcache是有脏页比例的,当脏页比例到了80%就会停下来强行刷盘。 一开始我们以为这个脏页比例是全部的20%,看了源码才知道,原来它分了很多个桶,比如说一个桶20M,这个桶如果用完80%,它就认为脏页满了,就开始刷脏页。这也就意味着,如果你是顺序写的话,很容易就会把一个桶写满。 知道了这个原理以后,我就把日志之类顺序写的数据全都放到了机械硬盘,把随机写的数据放到了Flashcache上。这样修改以后,效果就好了。 你看,如果能看得懂源码,你的操作行为就会不一样。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/lin-xiao-bin-wo-de-mysql-xin-lu-li-cheng#mysql-xue-xi-lu-jing) MySQL学习路径 说到MySQL的学习路径,其实我上面分享的这些内容,都可以归结为学习路径。 首先你要会用,要去了解每个参数的意义,这样你的运维行为(使用行为)就会不一样。千万不要从网上拿了一些使用建议,别人怎么用,你就怎么用,而不去想为什么。再往后,就要去了解每个参数的实现原理。一旦你了解了这些原理,你的操作行为就会不一样。 再进一步,如果看得懂源码,那么你对数据库的理解也会不一样。 再来讲讲我是怎么带应届生的。实践是很好的学习方式,所以我会让新人来了以后先搭主备,然后你就会发现每个人的自学能力都不一样。比如遇到有延迟,或者我们故意构造一个主备数据不一致的场景,让新人了解怎么分析问题,解决问题。 如果一定要总结出一条学习路径的话,那首先要会**用**,然后可以**发现问题**。 在专栏里面,我在每篇文章末尾,都会提出一个常见问题,作为思考题。这些问题都不会很难,是跟专栏文章挂钩、又是会经常遇到的,但又无法直接从文章里拿到答案。 我的建议是,你可以尝试先不看答案自己去思考,或者去数据库里面翻一翻,这将会是一个不错的过程。 再下一步就是**实践**。之后当你觉得开始有一些“线”的概念了,再去**看MySQL的官方手册**。在我的专栏里,有人曾问我要不要直接去看手册? 我的建议是,一开始千万不要着急看手册,这里面有100多万个英文单词,你就算再厉害,也是看了后面忘了前面。所以,你一定要自己先有脉络,然后有一个知识网络,再看手册去查漏补缺。 我自己就是这么一路走过来的。 另外,在专栏的留言区,很多用户都希望我能推荐一本书搭配专栏学习。如果只推荐一本的话,我建议你读一下《高性能MySQL》这本书,它是MySQL这个领域的经典图书,已经出到第三版了,你可以想象一下它的流行度。 这本书的其中两位译者(彭立勋、翟卫祥)是我原团队的小伙伴,有着非常丰富的MySQL源码开发经验,他们对MySQL的深刻理解,让这本书保持了跟原作英文版同样高的质量。 极客时间的编辑说,他们已经和出版社沟通,为我们专栏的用户争取到了全网最低价,仅限3天,你可以直接[点击链接](time://mall?url=https%3A%2F%2Fh5.youzan.com%2Fv2%2Fgoods%2F27413vk03dzk8) 购买。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fce%2Fc8%2Fcebd662dab97995a7718c4a38009cfc8.png&width=300&dpr=4&quality=100&sign=27f69d77&sv=2) ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/lin-xiao-bin-wo-de-mysql-xin-lu-li-cheng#dba-de-xiu-lian) DBA的修炼 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/lin-xiao-bin-wo-de-mysql-xin-lu-li-cheng#dba-he-kai-fa-gong-cheng-shi-you-shen-me-xiang-tong-dian) DBA和开发工程师有什么相同点? 我带过开发团队,也带过DBA团队,所以可以分享一下这两个岗位的交集。 其实,DBA本身要有些开发底子,比如说做运维系统的开发。另外,自动化程度越高,DBA的日常运维工作量就越少,DBA得去了解开发业务逻辑,往业务架构师这个方向去做。 开发工程师也是一样,不能所有的问题都指望DBA来解决。因为,DBA在每个公司都是很少的几个人。所以,开发也需要对数据库原理有一定的了解,这样向DBA请教问题时才能更专业,更高效地解决问题。 所以说,这两个岗位应该有一定程度的融合,即:开发要了解数据库原理,DBA要了解业务和开发。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/lin-xiao-bin-wo-de-mysql-xin-lu-li-cheng#dba-you-qian-tu-ma) DBA有前途吗? 这里我要强调的是,每个岗位都有前途,只需要根据时代变迁稍微调整一下方向。 像原来开玩笑说DBA要体力好,因为得搬服务器。后来DBA的核心技能成了会搭库、会主备切换,但是现在这些也不够用了,因为已经有了自动化系统。 所以,DBA接下来一方面是要了解业务,做业务的架构师;另一方面,是要有前瞻性,做主动诊断系统,把每个业务的问题挑出来做成月报,让业务开发去优化,有不清楚的地方,开发同学会来找你咨询。你帮助他们做好了优化之后,可以把优化的指标呈现出来。这将很好地体现出你对于公司的价值。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/lin-xiao-bin-wo-de-mysql-xin-lu-li-cheng#you-na-xie-bi-jiao-hao-de-xi-guan-he-ti-gao-sql-xiaolde-fang-fa) 有哪些比较好的习惯和提高SQL效率的方法? 这个方法,总结起来就是:要多写SQL,培养自己对SQL语句执行效率的感觉。以后再写或者建索引的时候,知道这个语句执行下去大概的时间复杂度,是全表扫描还是索引扫描、是不是需要回表,在心里都有一个大概的概念。 这样每次写出来的SQL都会快一点,而且不容易犯低级错误。这也正式我开设这个专栏的目标。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/lin-xiao-bin-wo-de-mysql-xin-lu-li-cheng#kan-yuan-ma-xu-yao-shen-me-ji-shu) 看源码需要什么技术? 看源码的话,一是要掌握C和C++;另外还要熟悉一些调试工具。因为代码是静态的,运行起来是动态的,看代码是单线程的,运行起来是多线程的,所以要会调试。 另外,我不建议你用可视化的工具。虽然可视化工具很方便,但你不知道这个操作点下去以后,实际上做了什么,所以我建议你自己手写代码和SQL语句,这样对一些底层原理你会更明白。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/lin-xiao-bin-wo-de-mysql-xin-lu-li-cheng#zen-me-xue-xi-cc) 怎么学习C、C++? 我在读研究生的时候,在C和C++语言的学习上进步最大。 那时,我去给专科上C和C++的课。我觉得自己已经会了,完全可以教得了。但去了之后,我才知道,自己会跟能够教别人完全是两码事儿。备课的时候,你不能只讲会用的部分,还得把原理讲清楚。这样,就会倒逼自己进行更深入更全面的学习。 有的人看完技术博客和专栏,会把这篇文章的提纲列一下,写写自己的问题和对这篇文章的理解。这个过程,是非常利于学习的。因为你听进来是一回事儿,讲出去则是另一回事儿。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/lin-xiao-bin-wo-de-mysql-xin-lu-li-cheng#xue-shu-ju-ku-yao-bao-chi-shen-me-xin-tai) 学数据库要保持什么心态? 不只是数据库,所有多线程的服务,调试和追查问题的过程都是很枯燥的,遇到问题都会很麻烦。但是,你找出问题时的那一下会很爽。 我觉得你得找到这种感觉,它可以支持你度过接下来要枯燥很久的那段时光,这样你才能继续坚持下去。 当然,如果有更快乐的学习过程还是更好的,希望这个专栏能让你学习得轻松些。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F09%2F77%2F09c1073f99cf71d2fb162a716b5fa577.jpg&width=768&dpr=4&quality=100&sign=e5471b04&sv=2) [Previous点线网面,一起构建MySQL知识网络](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/dian-xian-wang-mian-yi-qi-gou-jian-mysql-zhi-shi-wang-luo) Last updated 2 years ago Was this helpful? --- # 06 全局锁和表锁 :给表加个字段怎么有这么多阻碍? | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252F2fQDLFP1VeBCjwt9fm8m%252Fimage.png%3Falt%3Dmedia%26token%3D4fc004af-f570-4393-ac54-d5c2b28e39df&width=300&dpr=4&quality=100&sign=36a38716&sv=2) 今天我要跟你聊聊MySQL的锁。数据库锁设计的初衷是处理并发问题。作为多用户共享的资源,当出现并发访问的时候,数据库需要合理地控制资源的访问规则。而锁就是用来实现这些访问规则的重要数据结构。 **根据加锁的范围,MySQL里面的锁大致可以分成全局锁、表级锁和行锁三类**。今天这篇文章,我会和你分享全局锁和表级锁。而关于行锁的内容,我会留着在下一篇文章中再和你详细介绍。 这里需要说明的是,锁的设计比较复杂,这两篇文章不会涉及锁的具体实现细节,主要介绍的是碰到锁时的现象和其背后的原理。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/06-quan-ju-suo-he-biao-suo-gei-biao-jia-ge-zi-duan-zen-mo-you-zhe-mo-duo-zu-ai#quan-ju-suo) 全局锁 顾名思义,全局锁就是对整个数据库实例加锁。MySQL提供了一个加全局读锁的方法,命令是 Flush tables with read lock (FTWRL)。当你需要让整个库处于只读状态的时候,可以使用这个命令,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。 **全局锁的典型使用场景是,做全库逻辑备份。**也就是把整库每个表都select出来存成文本。 以前有一种做法,是通过FTWRL确保不会有其他线程对数据库做更新,然后对整个库做备份。注意,在备份过程中整个库完全处于只读状态。 但是让整库都只读,听上去就很危险: * 如果你在主库上备份,那么在备份期间都不能执行更新,业务基本上就得停摆; * 如果你在从库上备份,那么备份期间从库不能执行主库同步过来的binlog,会导致主从延迟。 看来加全局锁不太好。但是细想一下,备份为什么要加锁呢?我们来看一下不加锁会有什么问题。 假设你现在要维护“极客时间”的购买系统,关注的是用户账户余额表和用户课程表。 现在发起一个逻辑备份。假设备份期间,有一个用户,他购买了一门课程,业务逻辑里就要扣掉他的余额,然后往已购课程里面加上一门课。 如果时间顺序上是先备份账户余额表(u\_account),然后用户购买,然后备份用户课程表(u\_course),会怎么样呢?你可以看一下这个图: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fcb%2Fcd%2Fcbfd4a0bbb1210792064bcea4e49b0cd.png&width=768&dpr=4&quality=100&sign=51204682&sv=2) 图1 业务和备份状态图 可以看到,这个备份结果里,用户A的数据状态是“账户余额没扣,但是用户课程表里面已经多了一门课”。如果后面用这个备份来恢复数据的话,用户A就发现,自己赚了。 作为用户可别觉得这样可真好啊,你可以试想一下:如果备份表的顺序反过来,先备份用户课程表再备份账户余额表,又可能会出现什么结果? 也就是说,不加锁的话,备份系统备份的得到的库不是一个逻辑时间点,这个视图是逻辑不一致的。 说到视图你肯定想起来了,我们在前面讲事务隔离的时候,其实是有一个方法能够拿到一致性视图的,对吧? 是的,就是在可重复读隔离级别下开启一个事务。 > 备注:如果你对事务隔离级别的概念不是很清晰的话,可以再回顾一下第3篇文章[《事务隔离:为什么你改了我还看不见?》](https://time.geekbang.org/column/article/68963) > 中的相关内容。 官方自带的逻辑备份工具是mysqldump。当mysqldump使用参数–single-transaction的时候,导数据之前就会启动一个事务,来确保拿到一致性视图。而由于MVCC的支持,这个过程中数据是可以正常更新的。 你一定在疑惑,有了这个功能,为什么还需要FTWRL呢?**一致性读是好,但前提是引擎要支持这个隔离级别。**比如,对于MyISAM这种不支持事务的引擎,如果备份过程中有更新,总是只能取到最新的数据,那么就破坏了备份的一致性。这时,我们就需要使用FTWRL命令了。 所以,**single-transaction方法只适用于所有的表使用事务引擎的库。**如果有的表使用了不支持事务的引擎,那么备份就只能通过FTWRL方法。这往往是DBA要求业务开发人员使用InnoDB替代MyISAM的原因之一。 你也许会问,**既然要全库只读,为什么不使用set global readonly=true的方式呢**?确实readonly方式也可以让全库进入只读状态,但我还是会建议你用FTWRL方式,主要有两个原因: * 一是,在有些系统中,readonly的值会被用来做其他逻辑,比如用来判断一个库是主库还是备库。因此,修改global变量的方式影响面更大,我不建议你使用。 * 二是,在异常处理机制上有差异。如果执行FTWRL命令之后由于客户端发生异常断开,那么MySQL会自动释放这个全局锁,整个库回到可以正常更新的状态。而将整个库设置为readonly之后,如果客户端发生异常,则数据库就会一直保持readonly状态,这样会导致整个库长时间处于不可写状态,风险较高。 业务的更新不只是增删改数据(DML),还有可能是加字段等修改表结构的操作(DDL)。不论是哪种方法,一个库被全局锁上以后,你要对里面任何一个表做加字段操作,都是会被锁住的。 但是,即使没有被全局锁住,加字段也不是就能一帆风顺的,因为你还会碰到接下来我们要介绍的表级锁。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/06-quan-ju-suo-he-biao-suo-gei-biao-jia-ge-zi-duan-zen-mo-you-zhe-mo-duo-zu-ai#biao-ji-suo) 表级锁 MySQL里面表级别的锁有两种:一种是表锁,一种是元数据锁(meta data lock,MDL)。 **表锁的语法是 lock tables … read/write。**与FTWRL类似,可以用unlock tables主动释放锁,也可以在客户端断开的时候自动释放。需要注意,lock tables语法除了会限制别的线程的读写外,也限定了本线程接下来的操作对象。 举个例子, 如果在某个线程A中执行lock tables t1 read, t2 write; 这个语句,则其他线程写t1、读写t2的语句都会被阻塞。同时,线程A在执行unlock tables之前,也只能执行读t1、读写t2的操作。连写t1都不允许,自然也不能访问其他表。 在还没有出现更细粒度的锁的时候,表锁是最常用的处理并发的方式。而对于InnoDB这种支持行锁的引擎,一般不使用lock tables命令来控制并发,毕竟锁住整个表的影响面还是太大。 **另一类表级的锁是MDL(metadata lock)。**MDL不需要显式使用,在访问一个表的时候会被自动加上。MDL的作用是,保证读写的正确性。你可以想象一下,如果一个查询正在遍历一个表中的数据,而执行期间另一个线程对这个表结构做变更,删了一列,那么查询线程拿到的结果跟表结构对不上,肯定是不行的。 因此,在MySQL 5.5版本中引入了MDL,当对一个表做增删改查操作的时候,加MDL读锁;当要对表做结构变更操作的时候,加MDL写锁。 * 读锁之间不互斥,因此你可以有多个线程同时对一张表增删改查。 * 读写锁之间、写锁之间是互斥的,用来保证变更表结构操作的安全性。因此,如果有两个线程要同时给一个表加字段,其中一个要等另一个执行完才能开始执行。 虽然MDL锁是系统默认会加的,但却是你不能忽略的一个机制。比如下面这个例子,我经常看到有人掉到这个坑里:给一个小表加个字段,导致整个库挂了。 你肯定知道,给一个表加字段,或者修改字段,或者加索引,需要扫描全表的数据。在对大表操作的时候,你肯定会特别小心,以免对线上服务造成影响。而实际上,即使是小表,操作不慎也会出问题。我们来看一下下面的操作序列,假设表t是一个小表。 > 备注:这里的实验环境是MySQL 5.6。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F7c%2Fce%2F7cf6a3bf90d72d1f0fc156ececdfb0ce.jpg&width=768&dpr=4&quality=100&sign=66f7bfce&sv=2) 我们可以看到session A先启动,这时候会对表t加一个MDL读锁。由于session B需要的也是MDL读锁,因此可以正常执行。 之后session C会被blocked,是因为session A的MDL读锁还没有释放,而session C需要MDL写锁,因此只能被阻塞。 如果只有session C自己被阻塞还没什么关系,但是之后所有要在表t上新申请MDL读锁的请求也会被session C阻塞。前面我们说了,所有对表的增删改查操作都需要先申请MDL读锁,就都被锁住,等于这个表现在完全不可读写了。 如果某个表上的查询语句频繁,而且客户端有重试机制,也就是说超时后会再起一个新session再请求的话,这个库的线程很快就会爆满。 你现在应该知道了,事务中的MDL锁,在语句执行开始时申请,但是语句结束后并不会马上释放,而会等到整个事务提交后再释放。 基于上面的分析,我们来讨论一个问题,**如何安全地给小表加字段?** 首先我们要解决长事务,事务不提交,就会一直占着MDL锁。在MySQL的information\_schema 库的 innodb\_trx 表中,你可以查到当前执行中的事务。如果你要做DDL变更的表刚好有长事务在执行,要考虑先暂停DDL,或者kill掉这个长事务。 但考虑一下这个场景。如果你要变更的表是一个热点表,虽然数据量不大,但是上面的请求很频繁,而你不得不加个字段,你该怎么做呢? 这时候kill可能未必管用,因为新的请求马上就来了。比较理想的机制是,在alter table语句里面设定等待时间,如果在这个指定的等待时间里面能够拿到MDL写锁最好,拿不到也不要阻塞后面的业务语句,先放弃。之后开发人员或者DBA再通过重试命令重复这个过程。 MariaDB已经合并了AliSQL的这个功能,所以这两个开源分支目前都支持DDL NOWAIT/WAIT n这个语法。 Copy ALTER TABLE tbl_name NOWAIT add column ... ALTER TABLE tbl_name WAIT N add column ... ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/06-quan-ju-suo-he-biao-suo-gei-biao-jia-ge-zi-duan-zen-mo-you-zhe-mo-duo-zu-ai#xiao-jie) 小结 今天,我跟你介绍了MySQL的全局锁和表级锁。 全局锁主要用在逻辑备份过程中。对于全部是InnoDB引擎的库,我建议你选择使用–single-transaction参数,对应用会更友好。 表锁一般是在数据库引擎不支持行锁的时候才会被用到的。如果你发现你的应用程序里有lock tables这样的语句,你需要追查一下,比较可能的情况是: * 要么是你的系统现在还在用MyISAM这类不支持事务的引擎,那要安排升级换引擎; * 要么是你的引擎升级了,但是代码还没升级。我见过这样的情况,最后业务开发就是把lock tables 和 unlock tables 改成 begin 和 commit,问题就解决了。 MDL会直到事务提交才释放,在做表结构变更的时候,你一定要小心不要导致锁住线上查询和更新。 最后,我给你留一个问题吧。备份一般都会在备库上执行,你在用–single-transaction方法做逻辑备份的过程中,如果主库上的一个小表做了一个DDL,比如给一个表上加了一列。这时候,从备库上会看到什么现象呢? 你可以把你的思考和观点写在留言区里,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 说明:这篇文章没有介绍到物理备份,物理备份会有一篇单独的文章。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/06-quan-ju-suo-he-biao-suo-gei-biao-jia-ge-zi-duan-zen-mo-you-zhe-mo-duo-zu-ai#shang-qi-wen-ti-shi-jian) 上期问题时间 上期的问题是关于对联合主键索引和InnoDB索引组织表的理解。 我直接贴@老杨同志 的回复略作修改如下(我修改的部分用橙色标出): 表记录 –a--|–b--|–c--|–d-- 1 2 3 d 1 3 2 d 1 4 3 d 2 1 3 d 2 2 2 d 2 3 4 d 主键 a,b的聚簇索引组织顺序相当于 order by a,b ,也就是先按a排序,再按b排序,c无序。 索引 ca 的组织是先按c排序,再按a排序,同时记录主键 –c--|–a--|–主键部分b-- (注意,这里不是ab,而是只有b) 2 1 3 2 2 2 3 1 2 3 1 4 3 2 1 4 2 3 这个跟索引c的数据是一模一样的。 索引 cb 的组织是先按c排序,在按b排序,同时记录主键 –c--|–b--|–主键部分a-- (同上) 2 2 2 2 3 1 3 1 2 3 2 1 3 4 1 4 3 2 所以,结论是ca可以去掉,cb需要保留。 评论区留言点赞: > @浪里白条 帮大家总结了复习要点; @约书亚 的问题里提到了MRR优化; @HwangZHen 留言言简意赅。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fce%2Fd9%2Fce7f4e35916ed1aa49206a53a0547bd9.jpg&width=768&dpr=4&quality=100&sign=49295b8f&sv=2) [Previous05 深入浅出索引(下)](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/05-shen-ru-qian-chu-suo-yin-xia) [Next07 行锁功过:怎么减少行锁对性能的影响?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/07-hang-suo-gong-guo-zen-mo-jian-shao-hang-suo-dui-xing-neng-de-ying-xiang) Last updated 2 years ago Was this helpful? --- # 23 MySQL是怎么保证数据不丢的? | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252FuMPAMQ2Y4a8w53mK34I2%252Fimage.png%3Falt%3Dmedia%26token%3D35fd7be3-edce-4104-89f4-914e397f497b&width=768&dpr=4&quality=100&sign=f328ac97&sv=2) 今天这篇文章,我会继续和你介绍在业务高峰期临时提升性能的方法。从文章标题“MySQL是怎么保证数据不丢的?”,你就可以看出来,今天我和你介绍的方法,跟数据的可靠性有关。 在专栏前面文章和答疑篇中,我都着重介绍了WAL机制(你可以再回顾下[第2篇](https://time.geekbang.org/column/article/68633) 、[第9篇](https://time.geekbang.org/column/article/70848) 、[第12篇](https://time.geekbang.org/column/article/71806) 和[第15篇](https://time.geekbang.org/column/article/73161) 文章中的相关内容),得到的结论是:只要redo log和binlog保证持久化到磁盘,就能确保MySQL异常重启后,数据可以恢复。 评论区有同学又继续追问,redo log的写入流程是怎么样的,如何保证redo log真实地写入了磁盘。那么今天,我们就再一起看看MySQL写入binlog和redo log的流程。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/23-mysql-shi-zen-mo-bao-zheng-shu-ju-bu-diu-de#binlog-de-xie-ru-ji-zhi) binlog的写入机制 其实,binlog的写入逻辑比较简单:事务执行过程中,先把日志写到binlog cache,事务提交的时候,再把binlog cache写到binlog文件中。 一个事务的binlog是不能被拆开的,因此不论这个事务多大,也要确保一次性写入。这就涉及到了binlog cache的保存问题。 系统给binlog cache分配了一片内存,每个线程一个,参数 binlog\_cache\_size用于控制单个线程内binlog cache所占内存的大小。如果超过了这个参数规定的大小,就要暂存到磁盘。 事务提交的时候,执行器把binlog cache里的完整事务写入到binlog中,并清空binlog cache。状态如图1所示。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F9e%2F3e%2F9ed86644d5f39efb0efec595abb92e3e.png&width=768&dpr=4&quality=100&sign=a090a569&sv=2) 图1 binlog写盘状态 可以看到,每个线程有自己binlog cache,但是共用同一份binlog文件。 * 图中的write,指的就是指把日志写入到文件系统的page cache,并没有把数据持久化到磁盘,所以速度比较快。 * 图中的fsync,才是将数据持久化到磁盘的操作。一般情况下,我们认为fsync才占磁盘的IOPS。 write 和fsync的时机,是由参数sync\_binlog控制的: 1. sync\_binlog=0的时候,表示每次提交事务都只write,不fsync; 2. sync\_binlog=1的时候,表示每次提交事务都会执行fsync; 3. sync\_binlog=N(N>1)的时候,表示每次提交事务都write,但累积N个事务后才fsync。 因此,在出现IO瓶颈的场景里,将sync\_binlog设置成一个比较大的值,可以提升性能。在实际的业务场景中,考虑到丢失日志量的可控性,一般不建议将这个参数设成0,比较常见的是将其设置为100~1000中的某个数值。 但是,将sync\_binlog设置为N,对应的风险是:如果主机发生异常重启,会丢失最近N个事务的binlog日志。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/23-mysql-shi-zen-mo-bao-zheng-shu-ju-bu-diu-de#redo-log-de-xie-ru-ji-zhi) redo log的写入机制 接下来,我们再说说redo log的写入机制。 在专栏的[第15篇答疑文章](https://time.geekbang.org/column/article/73161) 中,我给你介绍了redo log buffer。事务在执行过程中,生成的redo log是要先写到redo log buffer的。 然后就有同学问了,redo log buffer里面的内容,是不是每次生成后都要直接持久化到磁盘呢? 答案是,不需要。 如果事务执行期间MySQL发生异常重启,那这部分日志就丢了。由于事务并没有提交,所以这时日志丢了也不会有损失。 那么,另外一个问题是,事务还没提交的时候,redo log buffer中的部分日志有没有可能被持久化到磁盘呢? 答案是,确实会有。 这个问题,要从redo log可能存在的三种状态说起。这三种状态,对应的就是图2 中的三个颜色块。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F9d%2Fd4%2F9d057f61d3962407f413deebc80526d4.png&width=768&dpr=4&quality=100&sign=f5cca8a2&sv=2) 图2 MySQL redo log存储状态 这三种状态分别是: 1. 存在redo log buffer中,物理上是在MySQL进程内存中,就是图中的红色部分; 2. 写到磁盘(write),但是没有持久化(fsync),物理上是在文件系统的page cache里面,也就是图中的黄色部分; 3. 持久化到磁盘,对应的是hard disk,也就是图中的绿色部分。 日志写到redo log buffer是很快的,wirte到page cache也差不多,但是持久化到磁盘的速度就慢多了。 为了控制redo log的写入策略,InnoDB提供了innodb\_flush\_log\_at\_trx\_commit参数,它有三种可能取值: 1. 设置为0的时候,表示每次事务提交时都只是把redo log留在redo log buffer中; 2. 设置为1的时候,表示每次事务提交时都将redo log直接持久化到磁盘; 3. 设置为2的时候,表示每次事务提交时都只是把redo log写到page cache。 InnoDB有一个后台线程,每隔1秒,就会把redo log buffer中的日志,调用write写到文件系统的page cache,然后调用fsync持久化到磁盘。 注意,事务执行中间过程的redo log也是直接写在redo log buffer中的,这些redo log也会被后台线程一起持久化到磁盘。也就是说,一个没有提交的事务的redo log,也是可能已经持久化到磁盘的。 实际上,除了后台线程每秒一次的轮询操作外,还有两种场景会让一个没有提交的事务的redo log写入到磁盘中。 1. **一种是,redo log buffer占用的空间即将达到 innodb\_log\_buffer\_size一半的时候,后台线程会主动写盘。**注意,由于这个事务并没有提交,所以这个写盘动作只是write,而没有调用fsync,也就是只留在了文件系统的page cache。 2. **另一种是,并行的事务提交的时候,顺带将这个事务的redo log buffer持久化到磁盘。**假设一个事务A执行到一半,已经写了一些redo log到buffer中,这时候有另外一个线程的事务B提交,如果innodb\_flush\_log\_at\_trx\_commit设置的是1,那么按照这个参数的逻辑,事务B要把redo log buffer里的日志全部持久化到磁盘。这时候,就会带上事务A在redo log buffer里的日志一起持久化到磁盘。 这里需要说明的是,我们介绍两阶段提交的时候说过,时序上redo log先prepare, 再写binlog,最后再把redo log commit。 如果把innodb\_flush\_log\_at\_trx\_commit设置成1,那么redo log在prepare阶段就要持久化一次,因为有一个崩溃恢复逻辑是要依赖于prepare 的redo log,再加上binlog来恢复的。(如果你印象有点儿模糊了,可以再回顾下[第15篇文章](https://time.geekbang.org/column/article/73161) 中的相关内容)。 每秒一次后台轮询刷盘,再加上崩溃恢复这个逻辑,InnoDB就认为redo log在commit的时候就不需要fsync了,只会write到文件系统的page cache中就够了。 通常我们说MySQL的“双1”配置,指的就是sync\_binlog和innodb\_flush\_log\_at\_trx\_commit都设置成 1。也就是说,一个事务完整提交前,需要等待两次刷盘,一次是redo log(prepare 阶段),一次是binlog。 这时候,你可能有一个疑问,这意味着我从MySQL看到的TPS是每秒两万的话,每秒就会写四万次磁盘。但是,我用工具测试出来,磁盘能力也就两万左右,怎么能实现两万的TPS? 解释这个问题,就要用到组提交(group commit)机制了。 这里,我需要先和你介绍日志逻辑序列号(log sequence number,LSN)的概念。LSN是单调递增的,用来对应redo log的一个个写入点。每次写入长度为length的redo log, LSN的值就会加上length。 LSN也会写到InnoDB的数据页中,来确保数据页不会被多次执行重复的redo log。关于LSN和redo log、checkpoint的关系,我会在后面的文章中详细展开。 如图3所示,是三个并发事务(trx1, trx2, trx3)在prepare 阶段,都写完redo log buffer,持久化到磁盘的过程,对应的LSN分别是50、120 和160。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F93%2Fcc%2F933fdc052c6339de2aa3bf3f65b188cc.png&width=768&dpr=4&quality=100&sign=12b89d0e&sv=2) 图3 redo log 组提交 从图中可以看到, 1. trx1是第一个到达的,会被选为这组的 leader; 2. 等trx1要开始写盘的时候,这个组里面已经有了三个事务,这时候LSN也变成了160; 3. trx1去写盘的时候,带的就是LSN=160,因此等trx1返回时,所有LSN小于等于160的redo log,都已经被持久化到磁盘; 4. 这时候trx2和trx3就可以直接返回了。 所以,一次组提交里面,组员越多,节约磁盘IOPS的效果越好。但如果只有单线程压测,那就只能老老实实地一个事务对应一次持久化操作了。 在并发更新场景下,第一个事务写完redo log buffer以后,接下来这个fsync越晚调用,组员可能越多,节约IOPS的效果就越好。 为了让一次fsync带的组员更多,MySQL有一个很有趣的优化:拖时间。在介绍两阶段提交的时候,我曾经给你画了一个图,现在我把它截过来。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F98%2F51%2F98b3b4ff7b36d6d72e38029b86870551.png&width=768&dpr=4&quality=100&sign=1077dbb9&sv=2) 图4 两阶段提交 图中,我把“写binlog”当成一个动作。但实际上,写binlog是分成两步的: 1. 先把binlog从binlog cache中写到磁盘上的binlog文件; 2. 调用fsync持久化。 MySQL为了让组提交的效果更好,把redo log做fsync的时间拖到了步骤1之后。也就是说,上面的图变成了这样: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F5a%2F28%2F5ae7d074c34bc5bd55c82781de670c28.png&width=768&dpr=4&quality=100&sign=5b9e7a35&sv=2) 图5 两阶段提交细化 这么一来,binlog也可以组提交了。在执行图5中第4步把binlog fsync到磁盘时,如果有多个事务的binlog已经写完了,也是一起持久化的,这样也可以减少IOPS的消耗。 不过通常情况下第3步执行得会很快,所以binlog的write和fsync间的间隔时间短,导致能集合到一起持久化的binlog比较少,因此binlog的组提交的效果通常不如redo log的效果那么好。 如果你想提升binlog组提交的效果,可以通过设置 binlog\_group\_commit\_sync\_delay 和 binlog\_group\_commit\_sync\_no\_delay\_count来实现。 1. binlog\_group\_commit\_sync\_delay参数,表示延迟多少微秒后才调用fsync; 2. binlog\_group\_commit\_sync\_no\_delay\_count参数,表示累积多少次以后才调用fsync。 这两个条件是或的关系,也就是说只要有一个满足条件就会调用fsync。 所以,当binlog\_group\_commit\_sync\_delay设置为0的时候,binlog\_group\_commit\_sync\_no\_delay\_count也无效了。 之前有同学在评论区问到,WAL机制是减少磁盘写,可是每次提交事务都要写redo log和binlog,这磁盘读写次数也没变少呀? 现在你就能理解了,WAL机制主要得益于两个方面: 1. redo log 和 binlog都是顺序写,磁盘的顺序写比随机写速度要快; 2. 组提交机制,可以大幅度降低磁盘的IOPS消耗。 分析到这里,我们再来回答这个问题:**如果你的MySQL现在出现了性能瓶颈,而且瓶颈在IO上,可以通过哪些方法来提升性能呢?** 针对这个问题,可以考虑以下三种方法: 1. 设置 binlog\_group\_commit\_sync\_delay 和 binlog\_group\_commit\_sync\_no\_delay\_count参数,减少binlog的写盘次数。这个方法是基于“额外的故意等待”来实现的,因此可能会增加语句的响应时间,但没有丢失数据的风险。 2. 将sync\_binlog 设置为大于1的值(比较常见是100~1000)。这样做的风险是,主机掉电时会丢binlog日志。 3. 将innodb\_flush\_log\_at\_trx\_commit设置为2。这样做的风险是,主机掉电的时候会丢数据。 我不建议你把innodb\_flush\_log\_at\_trx\_commit 设置成0。因为把这个参数设置成0,表示redo log只保存在内存中,这样的话MySQL本身异常重启也会丢数据,风险太大。而redo log写到文件系统的page cache的速度也是很快的,所以将这个参数设置成2跟设置成0其实性能差不多,但这样做MySQL异常重启时就不会丢数据了,相比之下风险会更小。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/23-mysql-shi-zen-mo-bao-zheng-shu-ju-bu-diu-de#xiao-jie) 小结 在专栏的[第2篇](https://time.geekbang.org/column/article/68633) 和[第15篇](https://time.geekbang.org/column/article/73161) 文章中,我和你分析了,如果redo log和binlog是完整的,MySQL是如何保证crash-safe的。今天这篇文章,我着重和你介绍的是MySQL是“怎么保证redo log和binlog是完整的”。 希望这三篇文章串起来的内容,能够让你对crash-safe这个概念有更清晰的理解。 之前的第15篇答疑文章发布之后,有同学继续留言问到了一些跟日志相关的问题,这里为了方便你回顾、学习,我再集中回答一次这些问题。 **问题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. 如果客户端收到“执行异常”的消息,应用需要重连后通过查询当前状态来继续后续的逻辑。此时数据库只需要保证内部(数据和日志之间,主库和备库之间)一致就可以了。 最后,又到了课后问题时间。 今天我留给你的思考题是:你的生产库设置的是“双1”吗? 如果平时是的话,你有在什么场景下改成过“非双1”吗?你的这个操作又是基于什么决定的? 另外,我们都知道这些设置可能有损,如果发生了异常,你的止损方案是什么? 你可以把你的理解或者经验写在留言区,我会在下一篇文章的末尾选取有趣的评论和你一起分享和分析。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/23-mysql-shi-zen-mo-bao-zheng-shu-ju-bu-diu-de#shang-qi-wen-ti-shi-jian) 上期问题时间 我在上篇文章最后,想要你分享的是线上“救火”的经验。 @Long 同学,在留言中提到了几个很好的场景。 * 其中第3个问题,“如果一个数据库是被客户端的压力打满导致无法响应的,重启数据库是没用的。”,说明他很好地思考了。 这个问题是因为重启之后,业务请求还会再发。而且由于是重启,buffer pool被清空,可能会导致语句执行得更慢。 * 他提到的第4个问题也很典型。有时候一个表上会出现多个单字段索引(而且往往这是因为运维工程师对索引原理不够清晰做的设计),这样就可能出现优化器选择索引合并算法的现象。但实际上,索引合并算法的效率并不好。而通过将其中的一个索引改成联合索引的方法,是一个很好的应对方案。 还有其他几个同学提到的问题场景,也很好,很值得你一看。 > @Max 同学提到一个很好的例子:客户端程序的连接器,连接完成后会做一些诸如show columns的操作,在短连接模式下这个影响就非常大了。 这个提醒我们,在review项目的时候,不止要review我们自己业务的代码,也要review连接器的行为。一般做法就是在测试环境,把general\_log打开,用业务行为触发连接,然后通过general log分析连接器的行为。 > @Manjusaka 同学的留言中,第二点提得非常好:如果你的数据库请求模式直接对应于客户请求,这往往是一个危险的设计。因为客户行为不可控,可能突然因为你们公司的一个运营推广,压力暴增,这样很容易把数据库打挂。 在设计模型里面设计一层,专门负责管理请求和数据库服务资源,对于比较重要和大流量的业务,是一个好的设计方向。 > @Vincent 同学提了一个好问题,用文中提到的DDL方案,会导致binlog里面少了这个DDL语句,后续影响备份恢复的功能。由于需要另一个知识点(主备同步协议),我放在后面的文章中说明。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F09%2F77%2F09c1073f99cf71d2fb162a716b5fa577.jpg&width=768&dpr=4&quality=100&sign=e5471b04&sv=2) [Previous22 MySQL有哪些“饮鸩止渴”提高性能的方法?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/22-mysql-you-na-xie-yin-zhen-zhi-ke-ti-gao-xing-neng-de-fang-fa) [Next24 MySQL是怎么保证主备一致的?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/24-mysql-shi-zen-mo-bao-zheng-zhu-bei-yi-zhi-de) Last updated 2 years ago Was this helpful? --- # 11 怎么给字符串字段加索引? | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252FFR89yllYhdu5zHeCOBXr%252Fimage.png%3Falt%3Dmedia%26token%3D5b058654-90cc-4046-aba4-31db0151b0e7&width=768&dpr=4&quality=100&sign=605e6ed2&sv=2) 现在,几乎所有的系统都支持邮箱登录,如何在邮箱这样的字段上建立合理的索引,是我们今天要讨论的问题。 假设,你现在维护一个支持邮箱登录的系统,用户表是这么定义的: Copy mysql> create table SUser( ID bigint unsigned primary key, email varchar(64), ... )engine=innodb; 由于要使用邮箱登录,所以业务代码中一定会出现类似于这样的语句: Copy mysql> select f1, f2 from SUser where email='xxx'; 从第4和第5篇讲解索引的文章中,我们可以知道,如果email这个字段上没有索引,那么这个语句就只能做全表扫描。 同时,MySQL是支持前缀索引的,也就是说,你可以定义字符串的一部分作为索引。默认地,如果你创建索引的语句不指定前缀长度,那么索引就会包含整个字符串。 比如,这两个在email字段上创建索引的语句: Copy mysql> alter table SUser add index index1(email); 或 mysql> alter table SUser add index index2(email(6)); 第一个语句创建的index1索引里面,包含了每个记录的整个字符串;而第二个语句创建的index2索引里面,对于每个记录都是只取前6个字节。 那么,这两种不同的定义在数据结构和存储上有什么区别呢?如图2和3所示,就是这两个索引的示意图。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fd3%2Fb7%2Fd31da662bee595991862c439a5567eb7.jpg&width=768&dpr=4&quality=100&sign=a8a0e386&sv=2) 图1 email 索引结构 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F13%2F42%2F134583875561de914991fc2e192cf842.jpg&width=768&dpr=4&quality=100&sign=c20a760d&sv=2) 图2 email(6) 索引结构 从图中你可以看到,由于email(6)这个索引结构中每个邮箱字段都只取前6个字节(即:zhangs),所以占用的空间会更小,这就是使用前缀索引的优势。 但,这同时带来的损失是,可能会增加额外的记录扫描次数。 接下来,我们再看看下面这个语句,在这两个索引定义下分别是怎么执行的。 Copy select id,name,email from SUser where email='zhangssxyz@xxx.com'; **如果使用的是index1**(即email整个字符串的索引结构),执行顺序是这样的: 1. 从index1索引树找到满足索引值是’zhangssxyz@xxx.com’的这条记录,取得ID2的值; 2. 到主键上查到主键值是ID2的行,判断email的值是正确的,将这行记录加入结果集; 3. 取index1索引树上刚刚查到的位置的下一条记录,发现已经不满足email='zhangssxyz@xxx.com’的条件了,循环结束。 这个过程中,只需要回主键索引取一次数据,所以系统认为只扫描了一行。 **如果使用的是index2**(即email(6)索引结构),执行顺序是这样的: 1. 从index2索引树找到满足索引值是’zhangs’的记录,找到的第一个是ID1; 2. 到主键上查到主键值是ID1的行,判断出email的值不是’zhangssxyz@xxx.com’,这行记录丢弃; 3. 取index2上刚刚查到的位置的下一条记录,发现仍然是’zhangs’,取出ID2,再到ID索引上取整行然后判断,这次值对了,将这行记录加入结果集; 4. 重复上一步,直到在idxe2上取到的值不是’zhangs’时,循环结束。 在这个过程中,要回主键索引取4次数据,也就是扫描了4行。 通过这个对比,你很容易就可以发现,使用前缀索引后,可能会导致查询语句读数据的次数变多。 但是,对于这个查询语句来说,如果你定义的index2不是email(6)而是email(7),也就是说取email字段的前7个字节来构建索引的话,即满足前缀’zhangss’的记录只有一个,也能够直接查到ID2,只扫描一行就结束了。 也就是说**使用前缀索引,定义好长度,就可以做到既节省空间,又不用额外增加太多的查询成本。** 于是,你就有个问题:当要给字符串创建前缀索引时,有什么方法能够确定我应该使用多长的前缀呢? 实际上,我们在建立索引时关注的是区分度,区分度越高越好。因为区分度越高,意味着重复的键值越少。因此,我们可以通过统计索引上有多少个不同的值来判断要使用多长的前缀。 首先,你可以使用下面这个语句,算出这个列上有多少个不同的值: Copy mysql> select count(distinct email) as L from SUser; 然后,依次选取不同长度的前缀来看这个值,比如我们要看一下4~7个字节的前缀索引,可以用这个语句: Copy mysql> select count(distinct left(email,4))as L4, count(distinct left(email,5))as L5, count(distinct left(email,6))as L6, count(distinct left(email,7))as L7, from SUser; 当然,使用前缀索引很可能会损失区分度,所以你需要预先设定一个可以接受的损失比例,比如5%。然后,在返回的L4~L7中,找出不小于 L \* 95%的值,假设这里L6、L7都满足,你就可以选择前缀长度为6。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/11-zen-mo-gei-zi-fu-chuan-zi-duan-jia-suo-yin#qian-zhui-suo-yin-dui-fu-gai-suo-yin-de-ying-xiang) 前缀索引对覆盖索引的影响 前面我们说了使用前缀索引可能会增加扫描行数,这会影响到性能。其实,前缀索引的影响不止如此,我们再看一下另外一个场景。 你先来看看这个SQL语句: Copy select id,email from SUser where email='zhangssxyz@xxx.com'; 与前面例子中的SQL语句 Copy select id,name,email from SUser where email='zhangssxyz@xxx.com'; 相比,这个语句只要求返回id和email字段。 所以,如果使用index1(即email整个字符串的索引结构)的话,可以利用覆盖索引,从index1查到结果后直接就返回了,不需要回到ID索引再去查一次。而如果使用index2(即email(6)索引结构)的话,就不得不回到ID索引再去判断email字段的值。 即使你将index2的定义修改为email(18)的前缀索引,这时候虽然index2已经包含了所有的信息,但InnoDB还是要回到id索引再查一下,因为系统并不确定前缀索引的定义是否截断了完整信息。 也就是说,使用前缀索引就用不上覆盖索引对查询性能的优化了,这也是你在选择是否使用前缀索引时需要考虑的一个因素。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/11-zen-mo-gei-zi-fu-chuan-zi-duan-jia-suo-yin#qi-ta-fang-shi) 其他方式 对于类似于邮箱这样的字段来说,使用前缀索引的效果可能还不错。但是,遇到前缀的区分度不够好的情况时,我们要怎么办呢? 比如,我们国家的身份证号,一共18位,其中前6位是地址码,所以同一个县的人的身份证号前6位一般会是相同的。 假设你维护的数据库是一个市的公民信息系统,这时候如果对身份证号做长度为6的前缀索引的话,这个索引的区分度就非常低了。 按照我们前面说的方法,可能你需要创建长度为12以上的前缀索引,才能够满足区分度要求。 但是,索引选取的越长,占用的磁盘空间就越大,相同的数据页能放下的索引值就越少,搜索的效率也就会越低。 那么,如果我们能够确定业务需求里面只有按照身份证进行等值查询的需求,还有没有别的处理方法呢?这种方法,既可以占用更小的空间,也能达到相同的查询效率。 答案是,有的。 **第一种方式是使用倒序存储。**如果你存储身份证号的时候把它倒过来存,每次查询的时候,你可以这么写: Copy mysql> select field_list from t where id_card = reverse('input_id_card_string'); 由于身份证号的最后6位没有地址码这样的重复逻辑,所以最后这6位很可能就提供了足够的区分度。当然了,实践中你不要忘记使用count(distinct)方法去做个验证。 **第二种方式是使用hash字段。**你可以在表上再创建一个整数字段,来保存身份证的校验码,同时在这个字段上创建索引。 Copy mysql> alter table t add id_card_crc int unsigned, add index(id_card_crc); 然后每次插入新记录的时候,都同时用crc32()这个函数得到校验码填到这个新字段。由于校验码可能存在冲突,也就是说两个不同的身份证号通过crc32()函数得到的结果可能是相同的,所以你的查询语句where部分要判断id\_card的值是否精确相同。 Copy mysql> select field_list from t where id_card_crc=crc32('input_id_card_string') and id_card='input_id_card_string' 这样,索引的长度变成了4个字节,比原来小了很多。 接下来,我们再一起看看**使用倒序存储和使用hash字段这两种方法的异同点。** 首先,它们的相同点是,都不支持范围查询。倒序存储的字段上创建的索引是按照倒序字符串的方式排序的,已经没有办法利用索引方式查出身份证号码在\[ID\_X, ID\_Y\]的所有市民了。同样地,hash字段的方式也只能支持等值查询。 它们的区别,主要体现在以下三个方面: 1. 从占用的额外空间来看,倒序存储方式在主键索引上,不会消耗额外的存储空间,而hash字段方法需要增加一个字段。当然,倒序存储方式使用4个字节的前缀长度应该是不够的,如果再长一点,这个消耗跟额外这个hash字段也差不多抵消了。 2. 在CPU消耗方面,倒序方式每次写和读的时候,都需要额外调用一次reverse函数,而hash字段的方式需要额外调用一次crc32()函数。如果只从这两个函数的计算复杂度来看的话,reverse函数额外消耗的CPU资源会更小些。 3. 从查询效率上看,使用hash字段方式的查询性能相对更稳定一些。因为crc32算出来的值虽然有冲突的概率,但是概率非常小,可以认为每次查询的平均扫描行数接近1。而倒序存储方式毕竟还是用的前缀索引的方式,也就是说还是会增加扫描行数。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/11-zen-mo-gei-zi-fu-chuan-zi-duan-jia-suo-yin#xiao-jie) 小结 在今天这篇文章中,我跟你聊了聊字符串字段创建索引的场景。我们来回顾一下,你可以使用的方式有: 1. 直接创建完整索引,这样可能比较占用空间; 2. 创建前缀索引,节省空间,但会增加查询扫描次数,并且不能使用覆盖索引; 3. 倒序存储,再创建前缀索引,用于绕过字符串本身前缀的区分度不够的问题; 4. 创建hash字段索引,查询性能稳定,有额外的存储和计算消耗,跟第三种方式一样,都不支持范围扫描。 在实际应用中,你要根据业务字段的特点选择使用哪种方式。 好了,又到了最后的问题时间。 如果你在维护一个学校的学生信息数据库,学生登录名的统一格式是”学号@gmail.com", 而学号的规则是:十五位的数字,其中前三位是所在城市编号、第四到第六位是学校编号、第七位到第十位是入学年份、最后五位是顺序编号。 系统登录的时候都需要学生输入登录名和密码,验证正确后才能继续使用系统。就只考虑登录验证这个行为的话,你会怎么设计这个登录名的索引呢? 你可以把你的分析思路和设计结果写在留言区里,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/11-zen-mo-gei-zi-fu-chuan-zi-duan-jia-suo-yin#shang-qi-wen-ti-shi-jian) 上期问题时间 上篇文章中的第一个例子,评论区有几位同学说没有复现,大家要检查一下隔离级别是不是RR(Repeatable Read,可重复读),创建的表t是不是InnoDB引擎。我把复现过程做成了一个视频,供你参考。 在上一篇文章最后,我给你留的问题是,为什么经过这个操作序列,explain的结果就不对了?这里,我来为你分析一下原因。 delete 语句删掉了所有的数据,然后再通过call idata()插入了10万行数据,看上去是覆盖了原来的10万行。 但是,session A开启了事务并没有提交,所以之前插入的10万行数据是不能删除的。这样,之前的数据每一行数据都有两个版本,旧版本是delete之前的数据,新版本是标记为deleted的数据。 这样,索引a上的数据其实就有两份。 然后你会说,不对啊,主键上的数据也不能删,那没有使用force index的语句,使用explain命令看到的扫描行数为什么还是100000左右?(潜台词,如果这个也翻倍,也许优化器还会认为选字段a作为索引更合适) 是的,不过这个是主键,主键是直接按照表的行数来估计的。而表的行数,优化器直接用的是show table status的值。 这个值的计算方法,我会在后面有文章为你详细讲解。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fe0%2F67%2Fe0e4c8381f3feae4d87958470760d367.png&width=300&dpr=4&quality=100&sign=acce3966&sv=2) 评论区留言点赞板: > @斜面镜子 Bill 的评论最接近答案; @某、人 做了两个很不错的对照试验; @ye7zi 等几位同学很认真的验证,赞态度。大家的机器如果IO能力比较差的话,做这个验证的时候,可以把innodb\_flush\_log\_at\_trx\_commit 和 sync\_binlog 都设置成0。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fce%2Fd9%2Fce7f4e35916ed1aa49206a53a0547bd9.jpg&width=768&dpr=4&quality=100&sign=49295b8f&sv=2) [Previous10 MySQL为什么有时候会选错索引?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/10-mysql-wei-shi-mo-you-shi-hou-hui-xuan-cuo-suo-yin) [Next12 为什么我的MySQL会“抖”一下?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/12-wei-shi-mo-wo-de-mysql-hui-dou-yi-xia) Last updated 2 years ago Was this helpful? --- # 12 为什么我的MySQL会“抖”一下? | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252Felo2IJonSx33w1D9SDde%252Fimage.png%3Falt%3Dmedia%26token%3Dd4fdd442-3311-4a22-b108-3e92314e923d&width=768&dpr=4&quality=100&sign=923242c5&sv=2) 平时的工作中,不知道你有没有遇到过这样的场景,一条SQL语句,正常执行的时候特别快,但是有时也不知道怎么回事,它就会变得特别慢,并且这样的场景很难复现,它不只随机,而且持续时间还很短。 看上去,这就像是数据库“抖”了一下。今天,我们就一起来看一看这是什么原因。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/12-wei-shi-mo-wo-de-mysql-hui-dou-yi-xia#ni-de-sql-yu-ju-wei-shen-me-bian-man-liao) 你的SQL语句为什么变“慢”了 在前面第2篇文章[《日志系统:一条SQL更新语句是如何执行的?》](https://time.geekbang.org/column/article/68633) 中,我为你介绍了WAL机制。现在你知道了,InnoDB在处理更新语句的时候,只做了写日志这一个磁盘操作。这个日志叫作redo log(重做日志),也就是《孔乙己》里咸亨酒店掌柜用来记账的粉板,在更新内存写完redo log后,就返回给客户端,本次更新成功。 做下类比的话,掌柜记账的账本是数据文件,记账用的粉板是日志文件(redo log),掌柜的记忆就是内存。 掌柜总要找时间把账本更新一下,这对应的就是把内存里的数据写入磁盘的过程,术语就是flush。在这个flush操作执行之前,孔乙己的赊账总额,其实跟掌柜手中账本里面的记录是不一致的。因为孔乙己今天的赊账金额还只在粉板上,而账本里的记录是老的,还没把今天的赊账算进去。 **当内存数据页跟磁盘数据页内容不一致的时候,我们称这个内存页为“脏页”。内存数据写入到磁盘后,内存和磁盘上的数据页的内容就一致了,称为“干净页”**。 不论是脏页还是干净页,都在内存中。在这个例子里,内存对应的就是掌柜的记忆。 接下来,我们用一个示意图来展示一下“孔乙己赊账”的整个操作过程。假设原来孔乙己欠账10文,这次又要赊9文。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F34%2Fda%2F349cfab9e4f5d2a75e07b2132a301fda.jpeg&width=768&dpr=4&quality=100&sign=5dcf4649&sv=2) 图1 “孔乙己赊账”更新和flush过程 回到文章开头的问题,你不难想象,平时执行很快的更新操作,其实就是在写内存和日志,而MySQL偶尔“抖”一下的那个瞬间,可能就是在刷脏页(flush)。 那么,什么情况会引发数据库的flush过程呢? 我们还是继续用咸亨酒店掌柜的这个例子,想一想:掌柜在什么情况下会把粉板上的赊账记录改到账本上? * 第一种场景是,粉板满了,记不下了。这时候如果再有人来赊账,掌柜就只得放下手里的活儿,将粉板上的记录擦掉一些,留出空位以便继续记账。当然在擦掉之前,他必须先将正确的账目记录到账本中才行。 这个场景,对应的就是InnoDB的redo log写满了。这时候系统会停止所有更新操作,把checkpoint往前推进,redo log留出空间可以继续写。我在第二讲画了一个redo log的示意图,这里我改成环形,便于大家理解。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fa2%2Fe5%2Fa25bdbbfc2cfc5d5e20690547fe7f2e5.jpg&width=768&dpr=4&quality=100&sign=2a380564&sv=2) 图2 redo log状态图 checkpoint可不是随便往前修改一下位置就可以的。比如图2中,把checkpoint位置从CP推进到CP’,就需要将两个点之间的日志(浅绿色部分),对应的所有脏页都flush到磁盘上。之后,图中从write pos到CP’之间就是可以再写入的redo log的区域。 * 第二种场景是,这一天生意太好,要记住的事情太多,掌柜发现自己快记不住了,赶紧找出账本把孔乙己这笔账先加进去。 这种场景,对应的就是系统内存不足。当需要新的内存页,而内存不够用的时候,就要淘汰一些数据页,空出内存给别的数据页使用。如果淘汰的是“脏页”,就要先将脏页写到磁盘。 你一定会说,这时候难道不能直接把内存淘汰掉,下次需要请求的时候,从磁盘读入数据页,然后拿redo log出来应用不就行了?这里其实是从性能考虑的。如果刷脏页一定会写盘,就保证了每个数据页有两种状态: * 一种是内存里存在,内存里就肯定是正确的结果,直接返回; * 另一种是内存里没有数据,就可以肯定数据文件上是正确的结果,读入内存后返回。 这样的效率最高。 * 第三种场景是,生意不忙的时候,或者打烊之后。这时候柜台没事,掌柜闲着也是闲着,不如更新账本。 这种场景,对应的就是MySQL认为系统“空闲”的时候。当然,MySQL“这家酒店”的生意好起来可是会很快就能把粉板记满的,所以“掌柜”要合理地安排时间,即使是“生意好”的时候,也要见缝插针地找时间,只要有机会就刷一点“脏页”。 * 第四种场景是,年底了咸亨酒店要关门几天,需要把账结清一下。这时候掌柜要把所有账都记到账本上,这样过完年重新开张的时候,就能就着账本明确账目情况了。 这种场景,对应的就是MySQL正常关闭的情况。这时候,MySQL会把内存的脏页都flush到磁盘上,这样下次MySQL启动的时候,就可以直接从磁盘上读数据,启动速度会很快。 接下来,**你可以分析一下上面四种场景对性能的影响。** 其中,第三种情况是属于MySQL空闲时的操作,这时系统没什么压力,而第四种场景是数据库本来就要关闭了。这两种情况下,你不会太关注“性能”问题。所以这里,我们主要来分析一下前两种场景下的性能问题。 第一种是“redo log写满了,要flush脏页”,这种情况是InnoDB要尽量避免的。因为出现这种情况的时候,整个系统就不能再接受更新了,所有的更新都必须堵住。如果你从监控上看,这时候更新数会跌为0。 第二种是“内存不够用了,要先将脏页写到磁盘”,这种情况其实是常态。**InnoDB用缓冲池(buffer pool)管理内存,缓冲池中的内存页有三种状态:** * 第一种是,还没有使用的; * 第二种是,使用了并且是干净页; * 第三种是,使用了并且是脏页。 InnoDB的策略是尽量使用内存,因此对于一个长时间运行的库来说,未被使用的页面很少。 而当要读入的数据页没有在内存的时候,就必须到缓冲池中申请一个数据页。这时候只能把最久不使用的数据页从内存中淘汰掉:如果要淘汰的是一个干净页,就直接释放出来复用;但如果是脏页呢,就必须将脏页先刷到磁盘,变成干净页后才能复用。 所以,刷脏页虽然是常态,但是出现以下这两种情况,都是会明显影响性能的: 1. 一个查询要淘汰的脏页个数太多,会导致查询的响应时间明显变长; 2. 日志写满,更新全部堵住,写性能跌为0,这种情况对敏感业务来说,是不能接受的。 所以,InnoDB需要有控制脏页比例的机制,来尽量避免上面的这两种情况。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/12-wei-shi-mo-wo-de-mysql-hui-dou-yi-xia#innodb-shua-zang-ye-de-kong-zhi-ce-le) InnoDB刷脏页的控制策略 接下来,我就来和你说说InnoDB脏页的控制策略,以及和这些策略相关的参数。 首先,你要正确地告诉InnoDB所在主机的IO能力,这样InnoDB才能知道需要全力刷脏页的时候,可以刷多快。 这就要用到innodb\_io\_capacity这个参数了,它会告诉InnoDB你的磁盘能力。这个值我建议你设置成磁盘的IOPS。磁盘的IOPS可以通过fio这个工具来测试,下面的语句是我用来测试磁盘随机读写的命令: Copy 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\_io\_capacity参数,而导致的性能问题也比比皆是。之前,就曾有其他公司的开发负责人找我看一个库的性能问题,说MySQL的写入速度很慢,TPS很低,但是数据库主机的IO压力并不大。经过一番排查,发现罪魁祸首就是这个参数的设置出了问题。 他的主机磁盘用的是SSD,但是innodb\_io\_capacity的值设置的是300。于是,InnoDB认为这个系统的能力就这么差,所以刷脏页刷得特别慢,甚至比脏页生成的速度还慢,这样就造成了脏页累积,影响了查询和更新性能。 虽然我们现在已经定义了“全力刷脏页”的行为,但平时总不能一直是全力刷吧?毕竟磁盘能力不能只用来刷脏页,还需要服务用户请求。所以接下来,我们就一起看看InnoDB怎么控制引擎按照“全力”的百分比来刷脏页。 根据我前面提到的知识点,试想一下,**如果你来设计策略控制刷脏页的速度,会参考哪些因素呢?** 这个问题可以这么想,如果刷太慢,会出现什么情况?首先是内存脏页太多,其次是redo log写满。 所以,InnoDB的刷盘速度就是要参考这两个因素:一个是脏页比例,一个是redo log写盘速度。 InnoDB会根据这两个因素先单独算出两个数字。 参数innodb\_max\_dirty\_pages\_pct是脏页比例上限,默认值是75%。InnoDB会根据当前的脏页比例(假设为M),算出一个范围在0到100之间的数字,计算这个数字的伪代码类似这样: Copy 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%来控制刷脏页的速度。** 上述的计算流程比较抽象,不容易理解,所以我画了一个简单的流程图。图中的F1、F2就是上面我们通过脏页比例和redo log写入速度算出来的两个值。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fcc%2F74%2Fcc44c1d080141aa50df6a91067475374.png&width=768&dpr=4&quality=100&sign=33cbe9b8&sv=2) 图3 InnoDB刷脏页速度策略 现在你知道了,InnoDB会在后台刷脏页,而刷脏页的过程是要将内存页写入磁盘。所以,无论是你的查询语句在需要内存的时候可能要求淘汰一个脏页,还是由于刷脏页的逻辑会占用IO资源并可能影响到了你的更新语句,都可能是造成你从业务端感知到MySQL“抖”了一下的原因。 要尽量避免这种情况,你就要合理地设置innodb\_io\_capacity的值,并且**平时要多关注脏页比例,不要让它经常接近75%**。 其中,脏页比例是通过Innodb\_buffer\_pool\_pages\_dirty/Innodb\_buffer\_pool\_pages\_total得到的,具体的命令参考下面的代码: Copy 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。因为这时候IOPS往往不是瓶颈,而“只刷自己”,就能更快地执行完必要的刷脏页操作,减少SQL语句响应时间。 在MySQL 8.0中,innodb\_flush\_neighbors参数的默认值已经是0了。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/12-wei-shi-mo-wo-de-mysql-hui-dou-yi-xia#xiao-jie) 小结 今天这篇文章,我延续第2篇中介绍的WAL的概念,和你解释了这个机制后续需要的刷脏页操作和执行时机。利用WAL技术,数据库将随机写转换成了顺序写,大大提升了数据库的性能。 但是,由此也带来了内存脏页的问题。脏页会被后台线程自动flush,也会由于数据页淘汰而触发flush,而刷脏页的过程由于会占用资源,可能会让你的更新和查询语句的响应时间长一些。在文章里,我也给你介绍了控制刷脏页的方法和对应的监控方式。 文章最后,我给你留下一个思考题吧。 一个内存配置为128GB、innodb\_io\_capacity设置为20000的大规格实例,正常会建议你将redo log设置成4个1GB的文件。 但如果你在配置的时候不慎将redo log设置成了1个100M的文件,会发生什么情况呢?又为什么会出现这样的情况呢? 你可以把你的分析结论写在留言区里,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/12-wei-shi-mo-wo-de-mysql-hui-dou-yi-xia#shang-qi-wen-ti-shi-jian) 上期问题时间 上期我留给你的问题是,给一个学号字段创建索引,有哪些方法。 由于这个学号的规则,无论是正向还是反向的前缀索引,重复度都比较高。因为维护的只是一个学校的,因此前面6位(其中,前三位是所在城市编号、第四到第六位是学校编号)其实是固定的,邮箱后缀都是@gamil.com,因此可以只存入学年份加顺序编号,它们的长度是9位。 而其实在此基础上,可以用数字类型来存这9位数字。比如201100001,这样只需要占4个字节。其实这个就是一种hash,只是它用了最简单的转换规则:字符串转数字的规则,而刚好我们设定的这个背景,可以保证这个转换后结果的唯一性。 评论区中,也有其他一些很不错的见解。 评论用户@封建的风 说,一个学校的总人数这种数据量,50年才100万学生,这个表肯定是小表。为了业务简单,直接存原来的字符串。这个答复里面包含了“优化成本和收益”的思想,我觉得值得at出来。 @小潘 同学提了另外一个极致的方向。如果碰到表数据量特别大的场景,通过这种方式的收益是很不错的。 **评论区留言点赞板:** > @lttzzlll ,提到了用整型存“四位年份+五位编号”的方法; 由于整个学号的值超过了int上限,@老杨同志 也提到了用8个字节的bigint来存的方法。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fce%2Fd9%2Fce7f4e35916ed1aa49206a53a0547bd9.jpg&width=768&dpr=4&quality=100&sign=49295b8f&sv=2) [Previous11 怎么给字符串字段加索引?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/11-zen-mo-gei-zi-fu-chuan-zi-duan-jia-suo-yin) [Next13 为什么表数据删掉一半,表文件大小不变?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/13-wei-shi-mo-biao-shu-ju-shan-diao-yi-ban-biao-wen-jian-da-xiao-bu-bian) Last updated 2 years ago Was this helpful? --- # 13 为什么表数据删掉一半,表文件大小不变? | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252F0rgzE8S6K5QGGq0A1H2q%252Fimage.png%3Falt%3Dmedia%26token%3D3e30ae83-c150-4f3b-8337-0cfc235d9193&width=768&dpr=4&quality=100&sign=d53a2a78&sv=2) 经常会有同学来问我,我的数据库占用空间太大,我把一个最大的表删掉了一半的数据,怎么表文件的大小还是没变? 那么今天,我就和你聊聊数据库表的空间回收,看看如何解决这个问题。 这里,我们还是针对MySQL中应用最广泛的InnoDB引擎展开讨论。一个InnoDB表包含两部分,即:表结构定义和数据。在MySQL 8.0版本以前,表结构是存在以.frm为后缀的文件里。而MySQL 8.0版本,则已经允许把表结构定义放在系统数据表中了。因为表结构定义占用的空间很小,所以我们今天主要讨论的是表数据。 接下来,我会先和你说明为什么简单地删除表数据达不到表空间回收的效果,然后再和你介绍正确回收空间的方法。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/13-wei-shi-mo-biao-shu-ju-shan-diao-yi-ban-biao-wen-jian-da-xiao-bu-bian#can-shu-innodbfilepertable) 参数innodb\_file\_per\_table 表数据既可以存在共享表空间里,也可以是单独的文件。这个行为是由参数innodb\_file\_per\_table控制的: 1. 这个参数设置为OFF表示的是,表的数据放在系统共享表空间,也就是跟数据字典放在一起; 2. 这个参数设置为ON表示的是,每个InnoDB表数据存储在一个以 .ibd为后缀的文件中。 从MySQL 5.6.6版本开始,它的默认值就是ON了。 我建议你不论使用MySQL的哪个版本,都将这个值设置为ON。因为,一个表单独存储为一个文件更容易管理,而且在你不需要这个表的时候,通过drop table命令,系统就会直接删除这个文件。而如果是放在共享表空间中,即使表删掉了,空间也是不会回收的。 所以,**将innodb\_file\_per\_table设置为ON,是推荐做法,我们接下来的讨论都是基于这个设置展开的。** 我们在删除整个表的时候,可以使用drop table命令回收表空间。但是,我们遇到的更多的删除数据的场景是删除某些行,这时就遇到了我们文章开头的问题:表中的数据被删除了,但是表空间却没有被回收。 我们要彻底搞明白这个问题的话,就要从数据删除流程说起了。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/13-wei-shi-mo-biao-shu-ju-shan-diao-yi-ban-biao-wen-jian-da-xiao-bu-bian#shu-ju-shan-chu-liu-cheng) 数据删除流程 我们先再来看一下InnoDB中一个索引的示意图。在前面[第4](https://time.geekbang.org/column/article/69236) 和[第5](https://time.geekbang.org/column/article/69636) 篇文章中,我和你介绍索引时曾经提到过,InnoDB里的数据都是用B+树的结构组织的。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Ff0%2Fc8%2Ff0b1e4ac610bcb5c5922d0b18563f3c8.png&width=768&dpr=4&quality=100&sign=2c27156a&sv=2) 图1 B+树索引示意图 假设,我们要删掉R4这个记录,InnoDB引擎只会把R4这个记录标记为删除。如果之后要再插入一个ID在300和600之间的记录时,可能会复用这个位置。但是,磁盘文件的大小并不会缩小。 现在,你已经知道了InnoDB的数据是按页存储的,那么如果我们删掉了一个数据页上的所有记录,会怎么样? 答案是,整个数据页就可以被复用了。 但是,**数据页的复用跟记录的复用是不同的。** 记录的复用,只限于符合范围条件的数据。比如上面的这个例子,R4这条记录被删除后,如果插入一个ID是400的行,可以直接复用这个空间。但如果插入的是一个ID是800的行,就不能复用这个位置了。 而当整个页从B+树里面摘掉以后,可以复用到任何位置。以图1为例,如果将数据页page A上的所有记录删除以后,page A会被标记为可复用。这时候如果要插入一条ID=50的记录需要使用新页的时候,page A是可以被复用的。 如果相邻的两个数据页利用率都很小,系统就会把这两个页上的数据合到其中一个页上,另外一个数据页就被标记为可复用。 进一步地,如果我们用delete命令把整个表的数据删除呢?结果就是,所有的数据页都会被标记为可复用。但是磁盘上,文件不会变小。 你现在知道了,delete命令其实只是把记录的位置,或者数据页标记为了“可复用”,但磁盘文件的大小是不会变的。也就是说,通过delete命令是不能回收表空间的。这些可以复用,而没有被使用的空间,看起来就像是“空洞”。 实际上,**不止是删除数据会造成空洞,插入数据也会。** 如果数据是按照索引递增顺序插入的,那么索引是紧凑的。但如果数据是随机插入的,就可能造成索引的数据页分裂。 假设图1中page A已经满了,这时我要再插入一行数据,会怎样呢? ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F80%2Fea%2F8083f05a4a4c0372833a6e01d5a8e6ea.png&width=768&dpr=4&quality=100&sign=e7db8069&sv=2) 图2 插入数据导致页分裂 可以看到,由于page A满了,再插入一个ID是550的数据时,就不得不再申请一个新的页面page B来保存数据了。页分裂完成后,page A的末尾就留下了空洞(注意:实际上,可能不止1个记录的位置是空洞)。 另外,更新索引上的值,可以理解为删除一个旧的值,再插入一个新值。不难理解,这也是会造成空洞的。 也就是说,经过大量增删改的表,都是可能是存在空洞的。所以,如果能够把这些空洞去掉,就能达到收缩表空间的目的。 而重建表,就可以达到这样的目的。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/13-wei-shi-mo-biao-shu-ju-shan-diao-yi-ban-biao-wen-jian-da-xiao-bu-bian#chong-jian-biao) 重建表 试想一下,如果你现在有一个表A,需要做空间收缩,为了把表中存在的空洞去掉,你可以怎么做呢? 你可以新建一个与表A结构相同的表B,然后按照主键ID递增的顺序,把数据一行一行地从表A里读出来再插入到表B中。 由于表B是新建的表,所以表A主键索引上的空洞,在表B中就都不存在了。显然地,表B的主键索引更紧凑,数据页的利用率也更高。如果我们把表B作为临时表,数据从表A导入表B的操作完成后,用表B替换A,从效果上看,就起到了收缩表A空间的作用。 这里,你可以使用alter table A engine=InnoDB命令来重建表。在MySQL 5.5版本之前,这个命令的执行流程跟我们前面描述的差不多,区别只是这个临时表B不需要你自己创建,MySQL会自动完成转存数据、交换表名、删除旧表的操作。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F02%2Fcd%2F02e083adaec6e1191f54992f7bc13dcd.png&width=768&dpr=4&quality=100&sign=c8f80374&sv=2) 图3 改锁表DDL 显然,花时间最多的步骤是往临时表插入数据的过程,如果在这个过程中,有新的数据要写入到表A的话,就会造成数据丢失。因此,在整个DDL过程中,表A中不能有更新。也就是说,这个DDL不是Online的。 而在**MySQL 5.6版本开始引入的Online DDL,对这个操作流程做了优化。** 我给你简单描述一下引入了Online DDL之后,重建表的流程: 1. 建立一个临时文件,扫描表A主键的所有数据页; 2. 用数据页中表A的记录生成B+树,存储到临时文件中; 3. 生成临时文件的过程中,将所有对A的操作记录在一个日志文件(row log)中,对应的是图中state2的状态; 4. 临时文件生成后,将日志文件中的操作应用到临时文件,得到一个逻辑数据上与表A相同的数据文件,对应的就是图中state3的状态; 5. 用临时文件替换表A的数据文件。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F2d%2Ff0%2F2d1cfbbeb013b851a56390d38b5321f0.png&width=768&dpr=4&quality=100&sign=25247599&sv=2) 图4 Online DDL 可以看到,与图3过程的不同之处在于,由于日志文件记录和重放操作这个功能的存在,这个方案在重建表的过程中,允许对表A做增删改操作。这也就是Online DDL名字的来源。 我记得有同学在第6篇讲表锁的文章[《全局锁和表锁 :给表加个字段怎么索这么多阻碍?》](https://time.geekbang.org/column/article/69862) 的评论区留言说,DDL之前是要拿MDL写锁的,这样还能叫Online DDL吗? 确实,图4的流程中,alter语句在启动的时候需要获取MDL写锁,但是这个写锁在真正拷贝数据之前就退化成读锁了。 为什么要退化呢?为了实现Online,MDL读锁不会阻塞增删改操作。 那为什么不干脆直接解锁呢?为了保护自己,禁止其他线程对这个表同时做DDL。 而对于一个大表来说,Online DDL最耗时的过程就是拷贝数据到临时表的过程,这个步骤的执行期间可以接受增删改操作。所以,相对于整个DDL过程来说,锁的时间非常短。对业务来说,就可以认为是Online的。 需要补充说明的是,上述的这些重建方法都会扫描原表数据和构建临时文件。对于很大的表来说,这个操作是很消耗IO和CPU资源的。因此,如果是线上服务,你要很小心地控制操作时间。如果想要比较安全的操作的话,我推荐你使用GitHub开源的gh-ost来做。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/13-wei-shi-mo-biao-shu-ju-shan-diao-yi-ban-biao-wen-jian-da-xiao-bu-bian#online-he-inplace) Online 和 inplace 说到Online,我还要再和你澄清一下它和另一个跟DDL有关的、容易混淆的概念inplace的区别。 你可能注意到了,在图3中,我们把表A中的数据导出来的存放位置叫作tmp\_table。这是一个临时表,是在server层创建的。 在图4中,根据表A重建出来的数据是放在“tmp\_file”里的,这个临时文件是InnoDB在内部创建出来的。整个DDL过程都在InnoDB内部完成。对于server层来说,没有把数据挪动到临时表,是一个“原地”操作,这就是“inplace”名称的来源。 所以,我现在问你,如果你有一个1TB的表,现在磁盘间是1.2TB,能不能做一个inplace的DDL呢? 答案是不能。因为,tmp\_file也是要占用临时空间的。 我们重建表的这个语句alter table t engine=InnoDB,其实隐含的意思是: Copy alter table t engine=innodb,ALGORITHM=inplace; 跟inplace对应的就是拷贝表的方式了,用法是: Copy alter table t engine=innodb,ALGORITHM=copy; 当你使用ALGORITHM=copy的时候,表示的是强制拷贝表,对应的流程就是图3的操作过程。 但我这样说你可能会觉得,inplace跟Online是不是就是一个意思? 其实不是的,只是在重建表这个逻辑中刚好是这样而已。 比如,如果我要给InnoDB表的一个字段加全文索引,写法是: Copy alter table t add FULLTEXT(field_name); 这个过程是inplace的,但会阻塞增删改操作,是非Online的。 如果说这两个逻辑之间的关系是什么的话,可以概括为: 1. DDL过程如果是Online的,就一定是inplace的; 2. 反过来未必,也就是说inplace的DDL,有可能不是Online的。截止到MySQL 8.0,添加全文索引(FULLTEXT index)和空间索引(SPATIAL index)就属于这种情况。 最后,我们再延伸一下。 在第10篇文章[《MySQL为什么有时候会选错索引》](https://time.geekbang.org/column/article/71173) 的评论区中,有同学问到使用optimize table、analyze table和alter table这三种方式重建表的区别。这里,我顺便再简单和你解释一下。 * 从MySQL 5.6版本开始,alter table t engine = InnoDB(也就是recreate)默认的就是上面图4的流程了; * analyze table t 其实不是重建表,只是对表的索引信息做重新统计,没有修改数据,这个过程中加了MDL读锁; * optimize table t 等于recreate+analyze。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/13-wei-shi-mo-biao-shu-ju-shan-diao-yi-ban-biao-wen-jian-da-xiao-bu-bian#xiao-jie) 小结 今天这篇文章,我和你讨论了数据库中收缩表空间的方法。 现在你已经知道了,如果要收缩一个表,只是delete掉表里面不用的数据的话,表文件的大小是不会变的,你还要通过alter table命令重建表,才能达到表文件变小的目的。我跟你介绍了重建表的两种实现方式,Online DDL的方式是可以考虑在业务低峰期使用的,而MySQL 5.5及之前的版本,这个命令是会阻塞DML的,这个你需要特别小心。 最后,又到了我们的课后问题时间。 假设现在有人碰到了一个“想要收缩表空间,结果适得其反”的情况,看上去是这样的: 1. 一个表t文件大小为1TB; 2. 对这个表执行 alter table t engine=InnoDB; 3. 发现执行完成后,空间不仅没变小,还稍微大了一点儿,比如变成了1.01TB。 你觉得可能是什么原因呢 ? 你可以把你觉得可能的原因写在留言区里,我会在下一篇文章的末尾把大家描述的合理的原因都列出来,以后其他同学就不用掉到这样的坑里了。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/13-wei-shi-mo-biao-shu-ju-shan-diao-yi-ban-biao-wen-jian-da-xiao-bu-bian#shang-qi-wen-ti-shi-jian) 上期问题时间 在上期文章最后,我留给你的问题是,如果一个高配的机器,redo log设置太小,会发生什么情况。 每次事务提交都要写redo log,如果设置太小,很快就会被写满,也就是下面这个图的状态,这个“环”将很快被写满,write pos一直追着CP。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fa2%2Fe5%2Fa25bdbbfc2cfc5d5e20690547fe7f2e5.jpg&width=768&dpr=4&quality=100&sign=2a380564&sv=2) 这时候系统不得不停止所有更新,去推进checkpoint。 这时,你看到的现象就是**磁盘压力很小,但是数据库出现间歇性的性能下跌。** 评论区留言点赞板: > @某、人 给了一个形象的描述,而且提到了,在这种情况下,连change buffer的优化也失效了。因为checkpoint一直要往前推,这个操作就会触发merge操作,然后又进一步地触发刷脏页操作; 有几个同学提到了内存淘汰脏页,对应的redo log的操作,这个我们会在后面的文章中展开,大家可以先看一下 @melon 同学的描述了解一下; @算不出流源 提到了“动态平衡”,其实只要出现了这种“平衡”,意味着本应该后台的操作,就已经影响了业务应用,属于有损失的平衡。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fce%2Fd9%2Fce7f4e35916ed1aa49206a53a0547bd9.jpg&width=768&dpr=4&quality=100&sign=49295b8f&sv=2) [Previous12 为什么我的MySQL会“抖”一下?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/12-wei-shi-mo-wo-de-mysql-hui-dou-yi-xia) [Next14 count(\*)这么慢,我该怎么办?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/14-count-zhe-mo-man-wo-gai-zen-mo-ban) Last updated 2 years ago Was this helpful? --- # 10 MySQL为什么有时候会选错索引? | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252F2pbHmwL8pjWwNqIs8APy%252Fimage.png%3Falt%3Dmedia%26token%3D954f4cc9-39a9-4034-abae-a182c462a25f&width=768&dpr=4&quality=100&sign=debefb28&sv=2) 前面我们介绍过索引,你已经知道了在MySQL中一张表其实是可以支持多个索引的。但是,你写SQL语句的时候,并没有主动指定使用哪个索引。也就是说,使用哪个索引是由MySQL来确定的。 不知道你有没有碰到过这种情况,一条本来可以执行得很快的语句,却由于MySQL选错了索引,而导致执行速度变得很慢? 我们一起来看一个例子吧。 我们先建一个简单的表,表里有a、b两个字段,并分别建上索引: Copy CREATE TABLE `t` ( `id` int(11) NOT NULL, `a` int(11) DEFAULT NULL, `b` int(11) DEFAULT NULL, PRIMARY KEY (`id`), KEY `a` (`a`), KEY `b` (`b`) ) ENGINE=InnoDB; 然后,我们往表t中插入10万行记录,取值按整数递增,即:(1,1,1),(2,2,2),(3,3,3) 直到(100000,100000,100000)。 我是用存储过程来插入数据的,这里我贴出来方便你复现: Copy delimiter ;; create procedure idata() begin declare i int; set i=1; while(i<=100000)do insert into t values(i, i, i); set i=i+1; end while; end;; delimiter ; call idata(); 接下来,我们分析一条SQL语句: Copy mysql> select * from t where a between 10000 and 20000; 你一定会说,这个语句还用分析吗,很简单呀,a上有索引,肯定是要使用索引a的。 你说得没错,图1显示的就是使用explain命令看到的这条语句的执行情况。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F2c%2Fe3%2F2cfce769551c6eac9bfbee0563d48fe3.png&width=768&dpr=4&quality=100&sign=e28ddcfc&sv=2) 图1 使用explain命令查看语句执行情况 从图1看上去,这条查询语句的执行也确实符合预期,key这个字段值是’a’,表示优化器选择了索引a。 不过别急,这个案例不会这么简单。在我们已经准备好的包含了10万行数据的表上,我们再做如下操作。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F1e%2F1e%2F1e5ba1c2934d3b2c0d96b210a27e1a1e.png&width=768&dpr=4&quality=100&sign=a61ed409&sv=2) 图2 session A和session B的执行流程 这里,session A的操作你已经很熟悉了,它就是开启了一个事务。随后,session B把数据都删除后,又调用了 idata这个存储过程,插入了10万行数据。 这时候,session B的查询语句select \* from t where a between 10000 and 20000就不会再选择索引a了。我们可以通过慢查询日志(slow log)来查看一下具体的执行情况。 为了说明优化器选择的结果是否正确,我增加了一个对照,即:使用force index(a)来让优化器强制使用索引a(这部分内容,我还会在这篇文章的后半部分中提到)。 下面的三条SQL语句,就是这个实验过程。 Copy set long_query_time=0; select * from t where a between 10000 and 20000; /*Q1*/ select * from t force index(a) where a between 10000 and 20000;/*Q2*/ * 第一句,是将慢查询日志的阈值设置为0,表示这个线程接下来的语句都会被记录入慢查询日志中; * 第二句,Q1是session B原来的查询; * 第三句,Q2是加了force index(a)来和session B原来的查询语句执行情况对比。 如图3所示是这三条SQL语句执行完成后的慢查询日志。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F7c%2Ff6%2F7c58b9c71853b8bba1a8ad5e926de1f6.png&width=768&dpr=4&quality=100&sign=6d7fdd30&sv=2) 图3 slow log结果 可以看到,Q1扫描了10万行,显然是走了全表扫描,执行时间是40毫秒。Q2扫描了10001行,执行了21毫秒。也就是说,我们在没有使用force index的时候,MySQL用错了索引,导致了更长的执行时间。 这个例子对应的是我们平常不断地删除历史数据和新增数据的场景。这时,MySQL竟然会选错索引,是不是有点奇怪呢?今天,我们就从这个奇怪的结果说起吧。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/10-mysql-wei-shi-mo-you-shi-hou-hui-xuan-cuo-suo-yin#you-hua-qi-de-luo-ji) 优化器的逻辑 在第一篇文章中,我们就提到过,选择索引是优化器的工作。 而优化器选择索引的目的,是找到一个最优的执行方案,并用最小的代价去执行语句。在数据库里面,扫描行数是影响执行代价的因素之一。扫描的行数越少,意味着访问磁盘数据的次数越少,消耗的CPU资源越少。 当然,扫描行数并不是唯一的判断标准,优化器还会结合是否使用临时表、是否排序等因素进行综合判断。 我们这个简单的查询语句并没有涉及到临时表和排序,所以MySQL选错索引肯定是在判断扫描行数的时候出问题了。 那么,问题就是:**扫描行数是怎么判断的?** MySQL在真正开始执行语句之前,并不能精确地知道满足这个条件的记录有多少条,而只能根据统计信息来估算记录数。 这个统计信息就是索引的“区分度”。显然,一个索引上不同的值越多,这个索引的区分度就越好。而一个索引上不同的值的个数,我们称之为“基数”(cardinality)。也就是说,这个基数越大,索引的区分度越好。 我们可以使用show index方法,看到一个索引的基数。如图4所示,就是表t的show index 的结果 。虽然这个表的每一行的三个字段值都是一样的,但是在统计信息中,这三个索引的基数值并不同,而且其实都不准确。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F16%2Fd4%2F16dbf8124ad529fec0066950446079d4.png&width=768&dpr=4&quality=100&sign=120f7026&sv=2) 图4 表t的show index 结果 那么,**MySQL是怎样得到索引的基数的呢?**这里,我给你简单介绍一下MySQL采样统计的方法。 为什么要采样统计呢?因为把整张表取出来一行行统计,虽然可以得到精确的结果,但是代价太高了,所以只能选择“采样统计”。 采样统计的时候,InnoDB默认会选择N个数据页,统计这些页面上的不同值,得到一个平均值,然后乘以这个索引的页面数,就得到了这个索引的基数。 而数据表是会持续更新的,索引统计信息也不会固定不变。所以,当变更的数据行数超过1/M的时候,会自动触发重新做一次索引统计。 在MySQL中,有两种存储索引统计的方式,可以通过设置参数innodb\_stats\_persistent的值来选择: * 设置为on的时候,表示统计信息会持久化存储。这时,默认的N是20,M是10。 * 设置为off的时候,表示统计信息只存储在内存中。这时,默认的N是8,M是16。 由于是采样统计,所以不管N是20还是8,这个基数都是很容易不准的。 但,这还不是全部。 你可以从图4中看到,这次的索引统计值(cardinality列)虽然不够精确,但大体上还是差不多的,选错索引一定还有别的原因。 其实索引统计只是一个输入,对于一个具体的语句来说,优化器还要判断,执行这个语句本身要扫描多少行。 接下来,我们再一起看看优化器预估的,这两个语句的扫描行数是多少。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fe2%2F89%2Fe2bc5f120858391d4accff05573e1289.png&width=768&dpr=4&quality=100&sign=e0b95efa&sv=2) 图5 意外的explain结果 rows这个字段表示的是预计扫描行数。 其中,Q1的结果还是符合预期的,rows的值是104620;但是Q2的rows值是37116,偏差就大了。而图1中我们用explain命令看到的rows是只有10001行,是这个偏差误导了优化器的判断。 到这里,可能你的第一个疑问不是为什么不准,而是优化器为什么放着扫描37000行的执行计划不用,却选择了扫描行数是100000的执行计划呢? 这是因为,如果使用索引a,每次从索引a上拿到一个值,都要回到主键索引上查出整行数据,这个代价优化器也要算进去的。 而如果选择扫描10万行,是直接在主键索引上扫描的,没有额外的代价。 优化器会估算这两个选择的代价,从结果看来,优化器认为直接扫描主键索引更快。当然,从执行时间看来,这个选择并不是最优的。 使用普通索引需要把回表的代价算进去,在图1执行explain的时候,也考虑了这个策略的代价 ,但图1的选择是对的。也就是说,这个策略并没有问题。 所以冤有头债有主,MySQL选错索引,这件事儿还得归咎到没能准确地判断出扫描行数。至于为什么会得到错误的扫描行数,这个原因就作为课后问题,留给你去分析了。 既然是统计信息不对,那就修正。analyze table t 命令,可以用来重新统计索引信息。我们来看一下执行效果。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F20%2F9c%2F209e9d3514688a3bcabbb75e54e1e49c.png&width=768&dpr=4&quality=100&sign=77823b9&sv=2) 图6 执行analyze table t 命令恢复的explain结果 这回对了。 所以在实践中,如果你发现explain的结果预估的rows值跟实际情况差距比较大,可以采用这个方法来处理。 其实,如果只是索引统计不准确,通过analyze命令可以解决很多问题,但是前面我们说了,优化器可不止是看扫描行数。 依然是基于这个表t,我们看看另外一个语句: Copy mysql> select * from t where (a between 1 and 1000) and (b between 50000 and 100000) order by b limit 1; 从条件上看,这个查询没有符合条件的记录,因此会返回空集合。 在开始执行这条语句之前,你可以先设想一下,如果你来选择索引,会选择哪一个呢? 为了便于分析,我们先来看一下a、b这两个索引的结构图。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F1d%2Fb9%2F1d037f92063e800c3bfff3f4dbf1a2b9.png&width=768&dpr=4&quality=100&sign=8a2ddad8&sv=2) 图7 a、b索引的结构图 如果使用索引a进行查询,那么就是扫描索引a的前1000个值,然后取到对应的id,再到主键索引上去查出每一行,然后根据字段b来过滤。显然这样需要扫描1000行。 如果使用索引b进行查询,那么就是扫描索引b的最后50001个值,与上面的执行过程相同,也是需要回到主键索引上取值再判断,所以需要扫描50001行。 所以你一定会想,如果使用索引a的话,执行速度明显会快很多。那么,下面我们就来看看到底是不是这么一回事儿。 图8是执行explain的结果。 Copy mysql> explain select * from t where (a between 1 and 1000) and (b between 50000 and 100000) order by b limit 1; ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F48%2Fb8%2F483bcb1ef3bb902844e80d9cbdd73ab8.png&width=768&dpr=4&quality=100&sign=3c69103a&sv=2) 图8 使用explain方法查看执行计划 2 可以看到,返回结果中key字段显示,这次优化器选择了索引b,而rows字段显示需要扫描的行数是50198。 从这个结果中,你可以得到两个结论: 1. 扫描行数的估计值依然不准确; 2. 这个例子里MySQL又选错了索引。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/10-mysql-wei-shi-mo-you-shi-hou-hui-xuan-cuo-suo-yin#suo-yin-xuan-ze-yi-chang-he-chu-li) 索引选择异常和处理 其实大多数时候优化器都能找到正确的索引,但偶尔你还是会碰到我们上面举例的这两种情况:原本可以执行得很快的SQL语句,执行速度却比你预期的慢很多,你应该怎么办呢? **一种方法是,像我们第一个例子一样,采用force index强行选择一个索引。**MySQL会根据词法解析的结果分析出可能可以使用的索引作为候选项,然后在候选列表中依次判断每个索引需要扫描多少行。如果force index指定的索引在候选索引列表中,就直接选择这个索引,不再评估其他索引的执行代价。 我们来看看第二个例子。刚开始分析时,我们认为选择索引a会更好。现在,我们就来看看执行效果: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F95%2F54%2F9582401a6bed6cb8fd803c9555750b54.png&width=768&dpr=4&quality=100&sign=236d786a&sv=2) 图9 使用不同索引的语句执行耗时 可以看到,原本语句需要执行2.23秒,而当你使用force index(a)的时候,只用了0.05秒,比优化器的选择快了40多倍。 也就是说,优化器没有选择正确的索引,force index起到了“矫正”的作用。 不过很多程序员不喜欢使用force index,一来这么写不优美,二来如果索引改了名字,这个语句也得改,显得很麻烦。而且如果以后迁移到别的数据库的话,这个语法还可能会不兼容。 但其实使用force index最主要的问题还是变更的及时性。因为选错索引的情况还是比较少出现的,所以开发的时候通常不会先写上force index。而是等到线上出现问题的时候,你才会再去修改SQL语句、加上force index。但是修改之后还要测试和发布,对于生产系统来说,这个过程不够敏捷。 所以,数据库的问题最好还是在数据库内部来解决。那么,在数据库里面该怎样解决呢? 既然优化器放弃了使用索引a,说明a还不够合适,所以**第二种方法就是,我们可以考虑修改语句,引导MySQL使用我们期望的索引。**比如,在这个例子里,显然把“order by b limit 1” 改成 “order by b,a limit 1” ,语义的逻辑是相同的。 我们来看看改之后的效果: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F14%2F94%2F14cd598e52a2b72dd334a42603e5b894.png&width=768&dpr=4&quality=100&sign=4fcf36ab&sv=2) 图10 order by b,a limit 1 执行结果 之前优化器选择使用索引b,是因为它认为使用索引b可以避免排序(b本身是索引,已经是有序的了,如果选择索引b的话,不需要再做排序,只需要遍历),所以即使扫描行数多,也判定为代价更小。 现在order by b,a 这种写法,要求按照b,a排序,就意味着使用这两个索引都需要排序。因此,扫描行数成了影响决策的主要条件,于是此时优化器选了只需要扫描1000行的索引a。 当然,这种修改并不是通用的优化手段,只是刚好在这个语句里面有limit 1,因此如果有满足条件的记录, order by b limit 1和order by b,a limit 1 都会返回b是最小的那一行,逻辑上一致,才可以这么做。 如果你觉得修改语义这件事儿不太好,这里还有一种改法,图11是执行效果。 Copy mysql> select * from (select * from t where (a between 1 and 1000) and (b between 50000 and 100000) order by b limit 100)alias limit 1; ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fb1%2Fd7%2Fb1a2ad43c78477d7f93dbc692cbaa0d7.png&width=768&dpr=4&quality=100&sign=85ba49af&sv=2) 图11 改写SQL的explain 在这个例子里,我们用limit 100让优化器意识到,使用b索引代价是很高的。其实是我们根据数据特征诱导了一下优化器,也不具备通用性。 **第三种方法是,在有些场景下,我们可以新建一个更合适的索引,来提供给优化器做选择,或删掉误用的索引。** 不过,在这个例子中,我没有找到通过新增索引来改变优化器行为的方法。这种情况其实比较少,尤其是经过DBA索引优化过的库,再碰到这个bug,找到一个更合适的索引一般比较难。 如果我说还有一个方法是删掉索引b,你可能会觉得好笑。但实际上我碰到过两次这样的例子,最终是DBA跟业务开发沟通后,发现这个优化器错误选择的索引其实根本没有必要存在,于是就删掉了这个索引,优化器也就重新选择到了正确的索引。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/10-mysql-wei-shi-mo-you-shi-hou-hui-xuan-cuo-suo-yin#xiao-jie) 小结 今天我们一起聊了聊索引统计的更新机制,并提到了优化器存在选错索引的可能性。 对于由于索引统计信息不准确导致的问题,你可以用analyze table来解决。 而对于其他优化器误判的情况,你可以在应用端用force index来强行指定索引,也可以通过修改语句来引导优化器,还可以通过增加或者删除索引来绕过这个问题。 你可能会说,今天这篇文章后面的几个例子,怎么都没有展开说明其原理。我要告诉你的是,今天的话题,我们面对的是MySQL的bug,每一个展开都必须深入到一行行代码去量化,实在不是我们在这里应该做的事情。 所以,我把我用过的解决方法跟你分享,希望你在碰到类似情况的时候,能够有一些思路。 你平时在处理MySQL优化器bug的时候有什么别的方法,也发到评论区分享一下吧。 最后,我给你留下一个思考题。前面我们在构造第一个例子的过程中,通过session A的配合,让session B删除数据后又重新插入了一遍数据,然后就发现explain结果中,rows字段从10001变成37000多。 而如果没有session A的配合,只是单独执行delete from t 、call idata()、explain这三句话,会看到rows字段其实还是10000左右。你可以自己验证一下这个结果。 这是什么原因呢?也请你分析一下吧。 你可以把你的分析结论写在留言区里,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/10-mysql-wei-shi-mo-you-shi-hou-hui-xuan-cuo-suo-yin#shang-qi-wen-ti-shi-jian) 上期问题时间 我在上一篇文章最后留给你的问题是,如果某次写入使用了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对应的磁盘位置都还没有修改,属于脏页,之后各自刷回自己的物理数据,就是另外一个过程了。 评论区留言点赞板: > @某、人 把02篇的redo log更新细节和change buffer的更新串了起来; @Ivan 回复了其他同学的问题,并联系到Checkpoint机制; @约书亚 问到了merge和redolog的关系。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fce%2Fd9%2Fce7f4e35916ed1aa49206a53a0547bd9.jpg&width=768&dpr=4&quality=100&sign=49295b8f&sv=2) [Previous09 普通索引和唯一索引,应该怎么选择?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/09-pu-tong-suo-yin-he-wei-yi-suo-yin-ying-gai-zen-mo-xuan-ze) [Next11 怎么给字符串字段加索引?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/11-zen-mo-gei-zi-fu-chuan-zi-duan-jia-suo-yin) Last updated 2 years ago Was this helpful? --- # 09 普通索引和唯一索引,应该怎么选择? | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252FQOg449e90g1fE2aII0OS%252Fimage.png%3Falt%3Dmedia%26token%3D4a46eeb7-464b-4f9f-a982-b4a8d4b68527&width=768&dpr=4&quality=100&sign=a89a7c31&sv=2) 今天的正文开始前,我要特意感谢一下评论区几位留下高质量留言的同学。 用户名是 @某、人 的同学,对文章的知识点做了梳理,然后提了关于事务可见性的问题,就是先启动但是后提交的事务,对数据可见性的影响。@夏日雨同学也提到了这个问题,我在置顶评论中回复了,今天的文章末尾也会再展开说明。@Justin和@倪大人两位同学提了两个好问题。 对于能够引发更深一步思考的问题,我会在回复的内容中写上“好问题”三个字,方便你搜索,你也可以去看看他们的留言。 非常感谢大家很细致地看文章,并且留下了那么多和很高质量的留言。知道文章有给大家带来一些新理解,对我来说是一个很好的鼓励。同时,也让其他认真看评论区的同学,有机会发现一些自己还没有意识到的、但可能还不清晰的知识点,这也在总体上提高了整个专栏的质量。再次谢谢你们。 好了,现在就回到我们今天的正文内容。 在前面的基础篇文章中,我给你介绍过索引的基本概念,相信你已经了解了唯一索引和普通索引的区别。今天我们就继续来谈谈,在不同的业务场景下,应该选择普通索引,还是唯一索引? 假设你在维护一个市民系统,每个人都有一个唯一的身份证号,而且业务代码已经保证了不会写入两个重复的身份证号。如果市民系统需要按照身份证号查姓名,就会执行类似这样的SQL语句: Copy select name from CUser where id_card = 'xxxxxxxyyyyyyzzzzz'; 所以,你一定会考虑在id\_card字段上建索引。 由于身份证号字段比较大,我不建议你把身份证号当做主键,那么现在你有两个选择,要么给id\_card字段创建唯一索引,要么创建一个普通索引。如果业务代码已经保证了不会写入重复的身份证号,那么这两个选择逻辑上都是正确的。 现在我要问你的是,从性能的角度考虑,你选择唯一索引还是普通索引呢?选择的依据是什么呢? 简单起见,我们还是用第4篇文章[《深入浅出索引(上)》](https://time.geekbang.org/column/article/69236) 中的例子来说明,假设字段 k 上的值都不重复。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F1e%2F46%2F1ed9536031d6698570ea175a7b7f9a46.png&width=768&dpr=4&quality=100&sign=26cfae1a&sv=2) 图1 InnoDB的索引组织结构 接下来,我们就从这两种索引对查询语句和更新语句的性能影响来进行分析。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/09-pu-tong-suo-yin-he-wei-yi-suo-yin-ying-gai-zen-mo-xuan-ze#cha-xun-guo-cheng) 查询过程 假设,执行查询的语句是 select id from T where k=5。这个查询语句在索引树上查找的过程,先是通过B+树从树根开始,按层搜索到叶子节点,也就是图中右下角的这个数据页,然后可以认为数据页内部通过二分法来定位记录。 * 对于普通索引来说,查找到满足条件的第一个记录(5,500)后,需要查找下一个记录,直到碰到第一个不满足k=5条件的记录。 * 对于唯一索引来说,由于索引定义了唯一性,查找到第一个满足条件的记录后,就会停止继续检索。 那么,这个不同带来的性能差距会有多少呢?答案是,微乎其微。 你知道的,InnoDB的数据是按数据页为单位来读写的。也就是说,当需要读一条记录的时候,并不是将这个记录本身从磁盘读出来,而是以页为单位,将其整体读入内存。在InnoDB中,每个数据页的大小默认是16KB。 因为引擎是按页读写的,所以说,当找到k=5的记录的时候,它所在的数据页就都在内存里了。那么,对于普通索引来说,要多做的那一次“查找和判断下一条记录”的操作,就只需要一次指针寻找和一次计算。 当然,如果k=5这个记录刚好是这个数据页的最后一个记录,那么要取下一个记录,必须读取下一个数据页,这个操作会稍微复杂一些。 但是,我们之前计算过,对于整型字段,一个数据页可以放近千个key,因此出现这种情况的概率会很低。所以,我们计算平均性能差异时,仍可以认为这个操作成本对于现在的CPU来说可以忽略不计。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/09-pu-tong-suo-yin-he-wei-yi-suo-yin-ying-gai-zen-mo-xuan-ze#geng-xin-guo-cheng) 更新过程 为了说明普通索引和唯一索引对更新语句性能的影响这个问题,我需要先跟你介绍一下change buffer。 当需要更新一个数据页时,如果数据页在内存中就直接更新,而如果这个数据页还没有在内存中的话,在不影响数据一致性的前提下,InooDB会将这些更新操作缓存在change buffer中,这样就不需要从磁盘中读入这个数据页了。在下次查询需要访问这个数据页的时候,将数据页读入内存,然后执行change buffer中与这个页有关的操作。通过这种方式就能保证这个数据逻辑的正确性。 需要说明的是,虽然名字叫作change buffer,实际上它是可以持久化的数据。也就是说,change buffer在内存中有拷贝,也会被写入到磁盘上。 将change buffer中的操作应用到原数据页,得到最新结果的过程称为merge。除了访问这个数据页会触发merge外,系统有后台线程会定期merge。在数据库正常关闭(shutdown)的过程中,也会执行merge操作。 显然,如果能够将更新操作先记录在change buffer,减少读磁盘,语句的执行速度会得到明显的提升。而且,数据读入内存是需要占用buffer pool的,所以这种方式还能够避免占用内存,提高内存利用率。 那么,**什么条件下可以使用change buffer呢?** 对于唯一索引来说,所有的更新操作都要先判断这个操作是否违反唯一性约束。比如,要插入(4,400)这个记录,就要先判断现在表中是否已经存在k=4的记录,而这必须要将数据页读入内存才能判断。如果都已经读入到内存了,那直接更新内存会更快,就没必要使用change buffer了。 因此,唯一索引的更新就不能使用change buffer,实际上也只有普通索引可以使用。 change buffer用的是buffer pool里的内存,因此不能无限增大。change buffer的大小,可以通过参数innodb\_change\_buffer\_max\_size来动态设置。这个参数设置为50的时候,表示change buffer的大小最多只能占用buffer pool的50%。 现在,你已经理解了change buffer的机制,那么我们再一起来看看**如果要在这张表中插入一个新记录(4,400)的话,InnoDB的处理流程是怎样的。** 第一种情况是,**这个记录要更新的目标页在内存中**。这时,InnoDB的处理流程如下: * 对于唯一索引来说,找到3和5之间的位置,判断到没有冲突,插入这个值,语句执行结束; * 对于普通索引来说,找到3和5之间的位置,插入这个值,语句执行结束。 这样看来,普通索引和唯一索引对更新语句性能影响的差别,只是一个判断,只会耗费微小的CPU时间。 但,这不是我们关注的重点。 第二种情况是,**这个记录要更新的目标页不在内存中**。这时,InnoDB的处理流程如下: * 对于唯一索引来说,需要将数据页读入内存,判断到没有冲突,插入这个值,语句执行结束; * 对于普通索引来说,则是将更新记录在change buffer,语句执行就结束了。 将数据从磁盘读入内存涉及随机IO的访问,是数据库里面成本最高的操作之一。change buffer因为减少了随机磁盘访问,所以对更新性能的提升是会很明显的。 之前我就碰到过一件事儿,有个DBA的同学跟我反馈说,他负责的某个业务的库内存命中率突然从99%降低到了75%,整个系统处于阻塞状态,更新语句全部堵住。而探究其原因后,我发现这个业务有大量插入数据的操作,而他在前一天把其中的某个普通索引改成了唯一索引。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/09-pu-tong-suo-yin-he-wei-yi-suo-yin-ying-gai-zen-mo-xuan-ze#change-buffer-de-shi-yong-chang-jing) change buffer的使用场景 通过上面的分析,你已经清楚了使用change buffer对更新过程的加速作用,也清楚了change buffer只限于用在普通索引的场景下,而不适用于唯一索引。那么,现在有一个问题就是:普通索引的所有场景,使用change buffer都可以起到加速作用吗? 因为merge的时候是真正进行数据更新的时刻,而change buffer的主要目的就是将记录的变更动作缓存下来,所以在一个数据页做merge之前,change buffer记录的变更越多(也就是这个页面上要更新的次数越多),收益就越大。 因此,对于写多读少的业务来说,页面在写完以后马上被访问到的概率比较小,此时change buffer的使用效果最好。这种业务模型常见的就是账单类、日志类的系统。 反过来,假设一个业务的更新模式是写入之后马上会做查询,那么即使满足了条件,将更新先记录在change buffer,但之后由于马上要访问这个数据页,会立即触发merge过程。这样随机访问IO的次数不会减少,反而增加了change buffer的维护代价。所以,对于这种业务模式来说,change buffer反而起到了副作用。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/09-pu-tong-suo-yin-he-wei-yi-suo-yin-ying-gai-zen-mo-xuan-ze#suo-yin-xuan-ze-he-shi-jian) 索引选择和实践 回到我们文章开头的问题,普通索引和唯一索引应该怎么选择。其实,这两类索引在查询能力上是没差别的,主要考虑的是对更新性能的影响。所以,我建议你尽量选择普通索引。 如果所有的更新后面,都马上伴随着对这个记录的查询,那么你应该关闭change buffer。而在其他情况下,change buffer都能提升更新性能。 在实际使用中,你会发现,普通索引和change buffer的配合使用,对于数据量大的表的更新优化还是很明显的。 特别地,在使用机械硬盘时,change buffer这个机制的收效是非常显著的。所以,当你有一个类似“历史数据”的库,并且出于成本考虑用的是机械硬盘时,那你应该特别关注这些表里的索引,尽量使用普通索引,然后把change buffer 尽量开大,以确保这个“历史数据”表的数据写入速度。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/09-pu-tong-suo-yin-he-wei-yi-suo-yin-ying-gai-zen-mo-xuan-ze#change-buffer-he-redo-log) change buffer 和 redo log 理解了change buffer的原理,你可能会联想到我在前面文章中和你介绍过的redo log和WAL。 在前面文章的评论中,我发现有同学混淆了redo log和change buffer。WAL 提升性能的核心机制,也的确是尽量减少随机读写,这两个概念确实容易混淆。所以,这里我把它们放到了同一个流程里来说明,便于你区分这两个概念。 > 备注:这里,你可以再回顾下第2篇文章[《日志系统:一条SQL更新语句是如何执行的?》](https://time.geekbang.org/column/article/68633) > 中的相关内容。 现在,我们要在表上执行这个插入语句: Copy mysql> insert into t(id,k) values(id1,k1),(id2,k2); 这里,我们假设当前k索引树的状态,查找到位置后,k1所在的数据页在内存(InnoDB buffer pool)中,k2所在的数据页不在内存中。如图2所示是带change buffer的更新状态图。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F98%2Fa3%2F980a2b786f0ea7adabef2e64fb4c4ca3.png&width=768&dpr=4&quality=100&sign=640b469&sv=2) 图2 带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)无关了。所以,我在图中就没画出这两部分。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F6d%2F8e%2F6dc743577af1dbcbb8550bddbfc5f98e.png&width=768&dpr=4&quality=100&sign=ae949ce&sv=2) 图3 带change buffer的读过程 从图中可以看到: 1. 读Page 1的时候,直接从内存返回。有几位同学在前面文章的评论中问到,WAL之后如果读数据,是不是一定要读盘,是不是一定要从redo log里面把数据更新以后才可以返回?其实是不用的。你可以看一下图3的这个状态,虽然磁盘上还是之前的数据,但是这里直接从内存返回结果,结果是正确的。 2. 要读Page 2的时候,需要把Page 2从磁盘读入内存中,然后应用change buffer里面的操作日志,生成一个正确的版本并返回结果。 可以看到,直到需要读Page 2的时候,这个数据页才会被读入内存。 所以,如果要简单地对比这两个机制在提升更新性能上的收益的话,**redo log 主要节省的是随机写磁盘的IO消耗(转成顺序写),而change buffer主要节省的则是随机读磁盘的IO消耗。** ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/09-pu-tong-suo-yin-he-wei-yi-suo-yin-ying-gai-zen-mo-xuan-ze#xiao-jie) 小结 今天,我从普通索引和唯一索引的选择开始,和你分享了数据的查询和更新过程,然后说明了change buffer的机制以及应用场景,最后讲到了索引选择的实践。 由于唯一索引用不上change buffer的优化机制,因此如果业务可以接受,从性能角度出发我建议你优先考虑非唯一索引。 最后,又到了思考题时间。 通过图2你可以看到,change buffer一开始是写内存的,那么如果这个时候机器掉电重启,会不会导致change buffer丢失呢?change buffer丢失可不是小事儿,再从磁盘读入数据可就没有了merge过程,就等于是数据丢失了。会不会出现这种情况呢? 你可以把你的思考和观点写在留言区里,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 **补充:** 评论区大家对“是否使用唯一索引”有比较多的讨论,主要是纠结在“业务可能无法确保”的情况。这里,我再说明一下: * 首先,业务正确性优先。咱们这篇文章的前提是“业务代码已经保证不会写入重复数据”的情况下,讨论性能问题。如果业务不能保证,或者业务就是要求数据库来做约束,那么没得选,必须创建唯一索引。这种情况下,本篇文章的意义在于,如果碰上了大量插入数据慢、内存命中率低的时候,可以给你多提供一个排查思路。 * 然后,在一些“归档库”的场景,你是可以考虑使用唯一索引的。比如,线上数据只需要保留半年,然后历史数据保存在归档库。这时候,归档数据已经是确保没有唯一键冲突了。要提高归档效率,可以考虑把表里面的唯一索引改成普通索引。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/09-pu-tong-suo-yin-he-wei-yi-suo-yin-ying-gai-zen-mo-xuan-ze#shang-qi-wen-ti-shi-jian) 上期问题时间 上期的问题是:如何构造一个“数据无法修改”的场景。评论区里已经有不少同学给出了正确答案,这里我再描述一下。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fbe%2Fae%2Fbe7a4d8af04cdf93aaa11108933559ae.png&width=300&dpr=4&quality=100&sign=b96e9d0&sv=2) 这样,session A看到的就是我截图的效果了。 其实,还有另外一种场景,同学们在留言区都还没有提到。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fe2%2Ffa%2Fe24a0689571337959138d787c408defa.png&width=768&dpr=4&quality=100&sign=9bf465&sv=2) 这个操作序列跑出来,session A看的内容也是能够复现我截图的效果的。这个session B’启动的事务比A要早,其实是上期我们描述事务版本的可见性规则时留的彩蛋,因为规则里还有一个“活跃事务的判断”,我是准备留到这里再补充的。 当我试图在这里讲述完整规则的时候,发现第8篇文章[《事务到底是隔离的还是不隔离的?》](https://time.geekbang.org/column/article/70562) 中的解释引入了太多的概念,以致于分析起来非常复杂。 因此,我重写了第8篇,这样我们人工去判断可见性的时候,才会更方便。【看到这里,我建议你能够再重新打开第8篇文章并认真学习一次。如果学习的过程中,有任何问题,也欢迎你给我留言】 用新的方式来分析session B’的更新为什么对session A不可见就是:在session A视图数组创建的瞬间,session B’是活跃的,属于“版本未提交,不可见”这种情况。 业务中如果要绕过这类问题,@约书亚提供了一个“乐观锁”的解法,大家可以去上一篇的留言区看一下。 评论区留言点赞板: > @某、人、@夏日雨、@周巘、@李金刚 等同学提了一个很好的问题,就是我们今天答案的session B’ 的情况; @justin 提到了提交和未提交版本的区别对待,@倪大人 提到了读提交和当前读的区别,都是经过了思考后提出的好问题,大家可以去留言区看看。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fce%2Fd9%2Fce7f4e35916ed1aa49206a53a0547bd9.jpg&width=768&dpr=4&quality=100&sign=49295b8f&sv=2) [Previous08 事务到底是隔离的还是不隔离的?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/08-shi-wu-dao-di-shi-ge-li-de-huan-shi-bu-ge-li-de) [Next10 MySQL为什么有时候会选错索引?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/10-mysql-wei-shi-mo-you-shi-hou-hui-xuan-cuo-suo-yin) Last updated 2 years ago Was this helpful? --- # 14 count(*)这么慢,我该怎么办? | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252F1MOtxfcsrR6cbQbYyMI9%252Fimage.png%3Falt%3Dmedia%26token%3D1fdabaca-c718-430b-8ccb-f2ab00b670a0&width=768&dpr=4&quality=100&sign=bca4cf39&sv=2) 在开发系统的时候,你可能经常需要计算一个表的行数,比如一个交易系统的所有变更记录总数。这时候你可能会想,一条select count(\*) from t 语句不就解决了吗? 但是,你会发现随着系统中记录数越来越多,这条语句执行得也会越来越慢。然后你可能就想了,MySQL怎么这么笨啊,记个总数,每次要查的时候直接读出来,不就好了吗。 那么今天,我们就来聊聊count(\*)语句到底是怎样实现的,以及MySQL为什么会这么实现。然后,我会再和你说说,如果应用中有这种频繁变更并需要统计表行数的需求,业务设计上可以怎么做。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/14-count-zhe-mo-man-wo-gai-zen-mo-ban#count-de-shi-xian-fang-shi) count(\*)的实现方式 你首先要明确的是,在不同的MySQL引擎中,count(\*)有不同的实现方式。 * MyISAM引擎把一个表的总行数存在了磁盘上,因此执行count(\*)的时候会直接返回这个数,效率很高; * 而InnoDB引擎就麻烦了,它执行count(\*)的时候,需要把数据一行一行地从引擎里面读出来,然后累积计数。 这里需要注意的是,我们在这篇文章里讨论的是没有过滤条件的count(\*),如果加了where 条件的话,MyISAM表也是不能返回得这么快的。 在前面的文章中,我们一起分析了为什么要使用InnoDB,因为不论是在事务支持、并发能力还是在数据安全方面,InnoDB都优于MyISAM。我猜你的表也一定是用了InnoDB引擎。这就是当你的记录数越来越多的时候,计算一个表的总行数会越来越慢的原因。 那**为什么InnoDB不跟MyISAM一样,也把数字存起来呢?** 这是因为即使是在同一个时刻的多个查询,由于多版本并发控制(MVCC)的原因,InnoDB表“应该返回多少行”也是不确定的。这里,我用一个算count(\*)的例子来为你解释一下。 假设表t中现在有10000条记录,我们设计了三个用户并行的会话。 * 会话A先启动事务并查询一次表的总行数; * 会话B启动事务,插入一行后记录后,查询表的总行数; * 会话C先启动一个单独的语句,插入一行记录后,查询表的总行数。 我们假设从上到下是按照时间顺序执行的,同一行语句是在同一时刻执行的。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F5e%2F97%2F5e716ba1d464c8224c1c1f36135d0e97.png&width=768&dpr=4&quality=100&sign=2c3caa58&sv=2) 图1 会话A、B、C的执行流程 你会看到,在最后一个时刻,三个会话A、B、C会同时查询表t的总行数,但拿到的结果却不同。 这和InnoDB的事务设计有关系,可重复读是它默认的隔离级别,在代码上就是通过多版本并发控制,也就是MVCC来实现的。每一行记录都要判断自己是否对这个会话可见,因此对于count(\*)请求来说,InnoDB只好把数据一行一行地读出依次判断,可见的行才能够用于计算“基于这个查询”的表的总行数。 > 备注:如果你对MVCC记忆模糊了,可以再回顾下第3篇文章[《事务隔离:为什么你改了我还看不见?》](https://time.geekbang.org/column/article/68963) > 和第8篇文章[《事务到底是隔离的还是不隔离的?》](https://time.geekbang.org/column/article/70562) > 中的相关内容。 当然,现在这个看上去笨笨的MySQL,在执行count(\*)操作的时候还是做了优化的。 你知道的,InnoDB是索引组织表,主键索引树的叶子节点是数据,而普通索引树的叶子节点是主键值。所以,普通索引树比主键索引树小很多。对于count(\*)这样的操作,遍历哪个索引树得到的结果逻辑上都是一样的。因此,MySQL优化器会找到最小的那棵树来遍历。**在保证逻辑正确的前提下,尽量减少扫描的数据量,是数据库系统设计的通用法则之一。** 如果你用过show table status 命令的话,就会发现这个命令的输出结果里面也有一个TABLE\_ROWS用于显示这个表当前有多少行,这个命令执行挺快的,那这个TABLE\_ROWS能代替count(\*)吗? 你可能还记得在第10篇文章[《 MySQL为什么有时候会选错索引?》](https://time.geekbang.org/column/article/71173) 中我提到过,索引统计的值是通过采样来估算的。实际上,TABLE\_ROWS就是从这个采样估算得来的,因此它也很不准。有多不准呢,官方文档说误差可能达到40%到50%。**所以,show table status命令显示的行数也不能直接使用。** 到这里我们小结一下: * MyISAM表虽然count(\*)很快,但是不支持事务; * show table status命令虽然返回很快,但是不准确; * InnoDB表直接count(\*)会遍历全表,虽然结果准确,但会导致性能问题。 那么,回到文章开头的问题,如果你现在有一个页面经常要显示交易系统的操作记录总数,到底应该怎么办呢?答案是,我们只能自己计数。 接下来,我们讨论一下,看看自己计数有哪些方法,以及每种方法的优缺点有哪些。 这里,我先和你说一下这些方法的基本思路:你需要自己找一个地方,把操作记录表的行数存起来。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/14-count-zhe-mo-man-wo-gai-zen-mo-ban#yong-huan-cun-xi-tong-bao-cun-ji-shu) 用缓存系统保存计数 对于更新很频繁的库来说,你可能会第一时间想到,用缓存系统来支持。 你可以用一个Redis服务来保存这个表的总行数。这个表每被插入一行Redis计数就加1,每被删除一行Redis计数就减1。这种方式下,读和更新操作都很快,但你再想一下这种方式存在什么问题吗? 没错,缓存系统可能会丢失更新。 Redis的数据不能永久地留在内存里,所以你会找一个地方把这个值定期地持久化存储起来。但即使这样,仍然可能丢失更新。试想如果刚刚在数据表中插入了一行,Redis中保存的值也加了1,然后Redis异常重启了,重启后你要从存储redis数据的地方把这个值读回来,而刚刚加1的这个计数操作却丢失了。 当然了,这还是有解的。比如,Redis异常重启以后,到数据库里面单独执行一次count(\*)获取真实的行数,再把这个值写回到Redis里就可以了。异常重启毕竟不是经常出现的情况,这一次全表扫描的成本,还是可以接受的。 但实际上,**将计数保存在缓存系统中的方式,还不只是丢失更新的问题。即使Redis正常工作,这个值还是逻辑上不精确的。** 你可以设想一下有这么一个页面,要显示操作记录的总数,同时还要显示最近操作的100条记录。那么,这个页面的逻辑就需要先到Redis里面取出计数,再到数据表里面取数据记录。 我们是这么定义不精确的: 1. 一种是,查到的100行结果里面有最新插入记录,而Redis的计数里还没加1; 2. 另一种是,查到的100行结果里没有最新插入的记录,而Redis的计数里已经加了1。 这两种情况,都是逻辑不一致的。 我们一起来看看这个时序图。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F39%2F33%2F39898af053695dad37227d71ae288e33.png&width=768&dpr=4&quality=100&sign=b0aa79dd&sv=2) 图2 会话A、B执行时序图 图2中,会话A是一个插入交易记录的逻辑,往数据表里插入一行R,然后Redis计数加1;会话B就是查询页面显示时需要的数据。 在图2的这个时序里,在T3时刻会话B来查询的时候,会显示出新插入的R这个记录,但是Redis的计数还没加1。这时候,就会出现我们说的数据不一致。 你一定会说,这是因为我们执行新增记录逻辑时候,是先写数据表,再改Redis计数。而读的时候是先读Redis,再读数据表,这个顺序是相反的。那么,如果保持顺序一样的话,是不是就没问题了?我们现在把会话A的更新顺序换一下,再看看执行结果。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F5c%2Fdb%2F5c2f786beae1d8917cdc5033b7bf0bdb.png&width=768&dpr=4&quality=100&sign=ccdef24a&sv=2) 图3 调整顺序后,会话A、B的执行时序图 你会发现,这时候反过来了,会话B在T3时刻查询的时候,Redis计数加了1了,但还查不到新插入的R这一行,也是数据不一致的情况。 在并发系统里面,我们是无法精确控制不同线程的执行时刻的,因为存在图中的这种操作序列,所以,我们说即使Redis正常工作,这个计数值还是逻辑上不精确的。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/14-count-zhe-mo-man-wo-gai-zen-mo-ban#zai-shu-ju-ku-bao-cun-ji-shu) 在数据库保存计数 根据上面的分析,用缓存系统保存计数有丢失数据和计数不精确的问题。那么,**如果我们把这个计数直接放到数据库里单独的一张计数表C中,又会怎么样呢?** 首先,这解决了崩溃丢失的问题,InnoDB是支持崩溃恢复不丢数据的。 > 备注:关于InnoDB的崩溃恢复,你可以再回顾一下第2篇文章[《日志系统:一条SQL更新语句是如何执行的?》](https://time.geekbang.org/column/article/68633) > 中的相关内容。 然后,我们再看看能不能解决计数不精确的问题。 你会说,这不一样吗?无非就是把图3中对Redis的操作,改成了对计数表C的操作。只要出现图3的这种执行序列,这个问题还是无解的吧? 这个问题还真不是无解的。 我们这篇文章要解决的问题,都是由于InnoDB要支持事务,从而导致InnoDB表不能把count(\*)直接存起来,然后查询的时候直接返回形成的。 所谓以子之矛攻子之盾,现在我们就利用“事务”这个特性,把问题解决掉。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F9e%2Fe3%2F9e4170e2dfca3524eb5e92adb8647de3.png&width=768&dpr=4&quality=100&sign=93215353&sv=2) 图4 会话A、B的执行时序图 我们来看下现在的执行结果。虽然会话B的读操作仍然是在T3执行的,但是因为这时候更新事务还没有提交,所以计数值加1这个操作对会话B还不可见。 因此,会话B看到的结果里, 查计数值和“最近100条记录”看到的结果,逻辑上就是一致的。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/14-count-zhe-mo-man-wo-gai-zen-mo-ban#bu-tong-de-count-yong-fa) 不同的count用法 在前面文章的评论区,有同学留言问到:在select count(?) from t这样的查询语句里面,count(\*)、count(主键id)、count(字段)和count(1)等不同用法的性能,有哪些差别。今天谈到了count(\*)的性能问题,我就借此机会和你详细说明一下这几种用法的性能差别。 需要注意的是,下面的讨论还是基于InnoDB引擎的。 这里,首先你要弄清楚count()的语义。count()是一个聚合函数,对于返回的结果集,一行行地判断,如果count函数的参数不是NULL,累计值就加1,否则不加。最后返回累计值。 所以,count(\*)、count(主键id)和count(1) 都表示返回满足条件的结果集的总行数;而count(字段),则表示返回满足条件的数据行里面,参数“字段”不为NULL的总个数。 至于分析性能差别的时候,你可以记住这么几个原则: 1. server层要什么就给什么; 2. InnoDB只给必要的值; 3. 现在的优化器只优化了count(\*)的语义为“取行数”,其他“显而易见”的优化并没有做。 这是什么意思呢?接下来,我们就一个个地来看看。 **对于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才累加。 也就是前面的第一条原则,server层要什么字段,InnoDB就返回什么字段。 **但是count(\*)是例外**,并不会把全部字段取出来,而是专门做了优化,不取值。count(\*)肯定不是null,按行累加。 看到这里,你一定会说,优化器就不能自己判断一下吗,主键id肯定非空啊,为什么不能按照count(\*)来处理,多么简单的优化啊。 当然,MySQL专门针对这个语句进行优化,也不是不可以。但是这种需要专门优化的情况太多了,而且MySQL已经优化过count(\*)了,你直接使用这种用法就可以了。 所以结论是:按照效率排序的话,count(字段) @W\_T 等同学提到了数据表本身紧凑的情况; @undifined 提了一个好问题, @帆帆帆帆帆帆帆帆 同学回答了这个问题; @陈飞 @郜 @wang chen wen 都提了很不错的问题,大家可以去看看。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F09%2F77%2F09c1073f99cf71d2fb162a716b5fa577.jpg&width=768&dpr=4&quality=100&sign=e5471b04&sv=2) [Previous13 为什么表数据删掉一半,表文件大小不变?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/13-wei-shi-mo-biao-shu-ju-shan-diao-yi-ban-biao-wen-jian-da-xiao-bu-bian) [Next15 答疑文章(一):日志和索引相关问题](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/15-da-yi-wen-zhang-yi-ri-zhi-he-suo-yin-xiang-guan-wen-ti) Last updated 2 years ago Was this helpful? --- # 08 事务到底是隔离的还是不隔离的? | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252F6gcTTnRHF4Op6dj6fpRv%252Fimage.png%3Falt%3Dmedia%26token%3D4c492776-6564-4c5f-a93e-66446f6dc684&width=300&dpr=4&quality=100&sign=c25753fa&sv=2) 我在第3篇文章和你讲事务隔离级别的时候提到过,如果是可重复读隔离级别,事务T启动的时候会创建一个视图read-view,之后事务T执行期间,即使有其他事务修改了数据,事务T看到的仍然跟在启动时看到的一样。也就是说,一个在可重复读隔离级别下执行的事务,好像与世无争,不受外界影响。 但是,我在上一篇文章中,和你分享行锁的时候又提到,一个事务要更新一行,如果刚好有另外一个事务拥有这一行的行锁,它又不能这么超然了,会被锁住,进入等待状态。问题是,既然进入了等待状态,那么等到这个事务自己获取到行锁要更新数据的时候,它读到的值又是什么呢? 我给你举一个例子吧。下面是一个只有两行的表的初始化语句。 Copy mysql> CREATE TABLE `t` ( `id` int(11) NOT NULL, `k` int(11) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB; insert into t(id, k) values(1,1),(2,2); ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F82%2Fd6%2F823acf76e53c0bdba7beab45e72e90d6.png&width=768&dpr=4&quality=100&sign=adb6fe05&sv=2) 图1 事务A、B、C的执行流程 这里,我们需要注意的是事务的启动时机。 begin/start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个操作InnoDB表的语句,事务才真正启动。如果你想要马上启动一个事务,可以使用start transaction with consistent snapshot 这个命令。 还需要注意的是,在整个专栏里面,我们的例子中如果没有特别说明,都是默认autocommit=1。 在这个例子中,事务C没有显式地使用begin/commit,表示这个update语句本身就是一个事务,语句完成的时候会自动提交。事务B在更新了行之后查询; 事务A在一个只读事务中查询,并且时间顺序上是在事务B的查询之后。 这时,如果我告诉你事务B查到的k的值是3,而事务A查到的k的值是1,你是不是感觉有点晕呢? 所以,今天这篇文章,我其实就是想和你说明白这个问题,希望借由把这个疑惑解开的过程,能够帮助你对InnoDB的事务和锁有更进一步的理解。 在MySQL里,有两个“视图”的概念: * 一个是view。它是一个用查询语句定义的虚拟表,在调用的时候执行查询语句并生成结果。创建视图的语法是create view … ,而它的查询方法与表一样。 * 另一个是InnoDB在实现MVCC时用到的一致性读视图,即consistent read view,用于支持RC(Read Committed,读提交)和RR(Repeatable Read,可重复读)隔离级别的实现。 它没有物理结构,作用是事务执行期间用来定义“我能看到什么数据”。 在第3篇文章[《事务隔离:为什么你改了我还看不见?》](https://time.geekbang.org/column/article/68963) 中,我跟你解释过一遍MVCC的实现逻辑。今天为了说明查询和更新的区别,我换一个方式来说明,把read view拆开。你可以结合这两篇文章的说明来更深一步地理解MVCC。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/08-shi-wu-dao-di-shi-ge-li-de-huan-shi-bu-ge-li-de#kuai-zhao-zai-mvcc-li-shi-zen-me-gong-zuo-de) “快照”在MVCC里是怎么工作的? 在可重复读隔离级别下,事务在启动的时候就“拍了个快照”。注意,这个快照是基于整库的。 这时,你会说这看上去不太现实啊。如果一个库有100G,那么我启动一个事务,MySQL就要拷贝100G的数据出来,这个过程得多慢啊。可是,我平时的事务执行起来很快啊。 实际上,我们并不需要拷贝出这100G的数据。我们先来看看这个快照是怎么实现的。 InnoDB里面每个事务有一个唯一的事务ID,叫作transaction id。它是在事务开始的时候向InnoDB的事务系统申请的,是按申请顺序严格递增的。 而每行数据也都是有多个版本的。每次事务更新数据的时候,都会生成一个新的数据版本,并且把transaction id赋值给这个数据版本的事务ID,记为row trx\_id。同时,旧的数据版本要保留,并且在新的数据版本中,能够有信息可以直接拿到它。 也就是说,数据表中的一行记录,其实可能有多个版本(row),每个版本有自己的row trx\_id。 如图2所示,就是一个记录被多个事务连续更新后的状态。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F68%2Fed%2F68d08d277a6f7926a41cc5541d3dfced.png&width=768&dpr=4&quality=100&sign=31b4c002&sv=2) 图2 行状态变更图 图中虚线框里是同一行数据的4个版本,当前最新版本是V4,k的值是22,它是被transaction id 为25的事务更新的,因此它的row trx\_id也是25。 你可能会问,前面的文章不是说,语句更新会生成undo log(回滚日志)吗?那么,**undo log在哪呢?** 实际上,图2中的三个虚线箭头,就是undo log;而V1、V2、V3并不是物理上真实存在的,而是每次需要的时候根据当前版本和undo log计算出来的。比如,需要V2的时候,就是通过V4依次执行U3、U2算出来。 明白了多版本和row trx\_id的概念后,我们再来想一下,InnoDB是怎么定义那个“100G”的快照的。 按照可重复读的定义,一个事务启动的时候,能够看到所有已经提交的事务结果。但是之后,这个事务执行期间,其他事务的更新对它不可见。 因此,一个事务只需要在启动的时候声明说,“以我启动的时刻为准,如果一个数据版本是在我启动之前生成的,就认;如果是我启动以后才生成的,我就不认,我必须要找到它的上一个版本”。 当然,如果“上一个版本”也不可见,那就得继续往前找。还有,如果是这个事务自己更新的数据,它自己还是要认的。 在实现上, InnoDB为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务ID。“活跃”指的就是,启动了但还没提交。 数组里面事务ID的最小值记为低水位,当前系统里面已经创建过的事务ID的最大值加1记为高水位。 这个视图数组和高水位,就组成了当前事务的一致性视图(read-view)。 而数据版本的可见性规则,就是基于数据的row trx\_id和这个一致性视图的对比结果得到的。 这个视图数组把所有的row trx\_id 分成了几种不同的情况。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F88%2F5e%2F882114aaf55861832b4270d44507695e.png&width=768&dpr=4&quality=100&sign=e5db98de&sv=2) 图3 数据版本可见性规则 这样,对于当前事务的启动瞬间来说,一个数据版本的row trx\_id,有以下几种可能: 1. 如果落在绿色部分,表示这个版本是已提交的事务或者是当前事务自己生成的,这个数据是可见的; 2. 如果落在红色部分,表示这个版本是由将来启动的事务生成的,是肯定不可见的; 3. 如果落在黄色部分,那就包括两种情况 a. 若 row trx\_id在数组中,表示这个版本是由还没提交的事务生成的,不可见; b. 若 row trx\_id不在数组中,表示这个版本是已经提交了的事务生成的,可见。 比如,对于图2中的数据来说,如果有一个事务,它的低水位是18,那么当它访问这一行数据时,就会从V4通过U3计算出V3,所以在它看来,这一行的值是11。 你看,有了这个声明后,系统里面随后发生的更新,是不是就跟这个事务看到的内容无关了呢?因为之后的更新,生成的版本一定属于上面的2或者3(a)的情况,而对它来说,这些新的数据版本是不存在的,所以这个事务的快照,就是“静态”的了。 所以你现在知道了,**InnoDB利用了“所有数据都有多个版本”的这个特性,实现了“秒级创建快照”的能力。** 接下来,我们继续看一下图1中的三个事务,分析下事务A的语句返回的结果,为什么是k=1。 这里,我们不妨做如下假设: 1. 事务A开始前,系统里面只有一个活跃事务ID是99; 2. 事务A、B、C的版本号分别是100、101、102,且当前系统里只有这四个事务; 3. 三个事务开始前,(1,1)这一行数据的row trx\_id是90。 这样,事务A的视图数组就是\[99,100\], 事务B的视图数组是\[99,100,101\], 事务C的视图数组是\[99,100,101,102\]。 为了简化分析,我先把其他干扰语句去掉,只画出跟事务A查询逻辑有关的操作: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F94%2F49%2F9416c310e406519b7460437cb0c5c149.png&width=768&dpr=4&quality=100&sign=507c6b9e&sv=2) 图4 事务A查询数据逻辑图 从图中可以看到,第一个有效更新是事务C,把数据从(1,1)改成了(1,2)。这时候,这个数据的最新版本的row trx\_id是102,而90这个版本已经成为了历史版本。 第二个有效更新是事务B,把数据从(1,2)改成了(1,3)。这时候,这个数据的最新版本(即row trx\_id)是101,而102又成为了历史版本。 你可能注意到了,在事务A查询的时候,其实事务B还没有提交,但是它生成的(1,3)这个版本已经变成当前版本了。但这个版本对事务A必须是不可见的,否则就变成脏读了。 好,现在事务A要来读数据了,它的视图数组是\[99,100\]。当然了,读数据都是从当前版本读起的。所以,事务A查询语句的读数据流程是这样的: * 找到(1,3)的时候,判断出row trx\_id=101,比高水位大,处于红色区域,不可见; * 接着,找到上一个历史版本,一看row trx\_id=102,比高水位大,处于红色区域,不可见; * 再往前找,终于找到了(1,1),它的row trx\_id=90,比低水位小,处于绿色区域,可见。 这样执行下来,虽然期间这一行数据被修改过,但是事务A不论在什么时候查询,看到这行数据的结果都是一致的,所以我们称之为一致性读。 这个判断规则是从代码逻辑直接转译过来的,但是正如你所见,用于人肉分析可见性很麻烦。 所以,我来给你翻译一下。一个数据版本,对于一个事务视图来说,除了自己的更新总是可见以外,有三种情况: 1. 版本未提交,不可见; 2. 版本已提交,但是是在视图创建后提交的,不可见; 3. 版本已提交,而且是在视图创建前提交的,可见。 现在,我们用这个规则来判断图4中的查询结果,事务A的查询语句的视图数组是在事务A启动的时候生成的,这时候: * (1,3)还没提交,属于情况1,不可见; * (1,2)虽然提交了,但是是在视图数组创建之后提交的,属于情况2,不可见; * (1,1)是在视图数组创建之前提交的,可见。 你看,去掉数字对比后,只用时间先后顺序来判断,分析起来是不是轻松多了。所以,后面我们就都用这个规则来分析。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/08-shi-wu-dao-di-shi-ge-li-de-huan-shi-bu-ge-li-de#geng-xin-luo-ji) 更新逻辑 细心的同学可能有疑问了:**事务B的update语句,如果按照一致性读,好像结果不对哦?** 你看图5中,事务B的视图数组是先生成的,之后事务C才提交,不是应该看不见(1,2)吗,怎么能算出(1,3)来? ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F86%2F9f%2F86ad7e8abe7bf16505b97718d8ac149f.png&width=768&dpr=4&quality=100&sign=c7b0130a&sv=2) 图5 事务B更新逻辑图 是的,如果事务B在更新之前查询一次数据,这个查询返回的k的值确实是1。 但是,当它要去更新数据的时候,就不能再在历史版本上更新了,否则事务C的更新就丢失了。因此,事务B此时的set k=k+1是在(1,2)的基础上进行的操作。 所以,这里就用到了这样一条规则:**更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(current read)。** 因此,在更新的时候,当前读拿到的数据是(1,2),更新后生成了新版本的数据(1,3),这个新版本的row trx\_id是101。 所以,在执行事务B查询语句的时候,一看自己的版本号是101,最新数据的版本号也是101,是自己的更新,可以直接使用,所以查询得到的k的值是3。 这里我们提到了一个概念,叫作当前读。其实,除了update语句外,select语句如果加锁,也是当前读。 所以,如果把事务A的查询语句select \* from t where id=1修改一下,加上lock in share mode 或 for update,也都可以读到版本号是101的数据,返回的k的值是3。下面这两个select语句,就是分别加了读锁(S锁,共享锁)和写锁(X锁,排他锁)。 Copy mysql> select k from t where id=1 lock in share mode; mysql> select k from t where id=1 for update; 再往前一步,假设事务C不是马上提交的,而是变成了下面的事务C’,会怎么样呢? ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fcd%2F6e%2Fcda2a0d7decb61e59dddc83ac51efb6e.png&width=768&dpr=4&quality=100&sign=386e090a&sv=2) 图6 事务A、B、C'的执行流程 事务C’的不同是,更新后并没有马上提交,在它提交前,事务B的更新语句先发起了。前面说过了,虽然事务C’还没提交,但是(1,2)这个版本也已经生成了,并且是当前的最新版本。那么,事务B的更新语句会怎么处理呢? 这时候,我们在上一篇文章中提到的“两阶段锁协议”就要上场了。事务C’没提交,也就是说(1,2)这个版本上的写锁还没释放。而事务B是当前读,必须要读最新版本,而且必须加锁,因此就被锁住了,必须等到事务C’释放这个锁,才能继续它的当前读。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F54%2F92%2F540967ea905e8b63630e496786d84c92.png&width=768&dpr=4&quality=100&sign=8da04401&sv=2) 图7 事务B更新逻辑图(配合事务C') 到这里,我们把一致性读、当前读和行锁就串起来了。 现在,我们再回到文章开头的问题:**事务的可重复读的能力是怎么实现的?** 可重复读的核心就是一致性读(consistent read);而事务更新数据的时候,只能用当前读。如果当前的记录的行锁被其他事务占用的话,就需要进入锁等待。 而读提交的逻辑和可重复读的逻辑类似,它们最主要的区别是: * 在可重复读隔离级别下,只需要在事务开始的时候创建一致性视图,之后事务里的其他查询都共用这个一致性视图; * 在读提交隔离级别下,每一个语句执行前都会重新算出一个新的视图。 那么,我们再看一下,在读提交隔离级别下,事务A和事务B的查询语句查到的k,分别应该是多少呢? 这里需要说明一下,“start transaction with consistent snapshot; ”的意思是从这个语句开始,创建一个持续整个事务的一致性快照。所以,在读提交隔离级别下,这个用法就没意义了,等效于普通的start transaction。 下面是读提交时的状态图,可以看到这两个查询语句的创建视图数组的时机发生了变化,就是图中的read view框。(注意:这里,我们用的还是事务C的逻辑直接提交,而不是事务C’) ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fed%2F6e%2Fed4b8d03287df67ecca53b5b4830ee6e.png&width=768&dpr=4&quality=100&sign=6abaa0f7&sv=2) 图8 读提交隔离级别下的事务状态图 这时,事务A的查询语句的视图数组是在执行这个语句的时候创建的,时序上(1,2)、(1,3)的生成时间都在创建这个视图数组的时刻之前。但是,在这个时刻: * (1,3)还没提交,属于情况1,不可见; * (1,2)提交了,属于情况3,可见。 所以,这时候事务A查询语句返回的是k=2。 显然地,事务B查询结果k=3。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/08-shi-wu-dao-di-shi-ge-li-de-huan-shi-bu-ge-li-de#xiao-jie) 小结 InnoDB的行数据有多个版本,每个数据版本有自己的row trx\_id,每个事务或者语句有自己的一致性视图。普通查询语句是一致性读,一致性读会根据row trx\_id和一致性视图确定数据版本的可见性。 * 对于可重复读,查询只承认在事务启动前就已经提交完成的数据; * 对于读提交,查询只承认在语句启动前就已经提交完成的数据; 而当前读,总是读取已经提交完成的最新版本。 你也可以想一下,为什么表结构不支持“可重复读”?这是因为表结构没有对应的行数据,也没有row trx\_id,因此只能遵循当前读的逻辑。 当然,MySQL 8.0已经可以把表结构放在InnoDB字典里了,也许以后会支持表结构的可重复读。 又到思考题时间了。我用下面的表结构和初始化语句作为试验环境,事务隔离级别是可重复读。现在,我要把所有“字段c和id值相等的行”的c值清零,但是却发现了一个“诡异”的、改不掉的情况。请你构造出这种情况,并说明其原理。 Copy mysql> CREATE TABLE `t` ( `id` int(11) NOT NULL, `c` int(11) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB; insert into t(id, c) values(1,1),(2,2),(3,3),(4,4); ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F9b%2F0b%2F9b8fe7cf88c9ba40dc12e93e36c3060b.png&width=300&dpr=4&quality=100&sign=3f8d97eb&sv=2) 复现出来以后,请你再思考一下,在实际的业务开发中有没有可能碰到这种情况?你的应用代码会不会掉进这个“坑”里,你又是怎么解决的呢? 你可以把你的思考和观点写在留言区里,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/08-shi-wu-dao-di-shi-ge-li-de-huan-shi-bu-ge-li-de#shang-qi-wen-ti-shi-jian) 上期问题时间 我在上一篇文章最后,留给你的问题是:怎么删除表的前10000行。比较多的留言都选择了第二种方式,即:在一个连接中循环执行20次 delete from T limit 500。 确实是这样的,第二种方式是相对较好的。 第一种方式(即:直接执行delete from T limit 10000)里面,单个语句占用时间长,锁的时间也比较长;而且大事务还会导致主从延迟。 第三种方式(即:在20个连接中同时执行delete from T limit 500),会人为造成锁冲突。 评论区留言点赞板: > @Tony Du的评论,详细而且准确。 @Knight²º¹⁸ 提到了如果可以加上特定条件,将这10000行天然分开,可以考虑第三种。是的,实际上在操作的时候我也建议你尽量拿到ID再删除。 @荒漠甘泉 提了一个不错的问题,大家需要区分行锁、MDL锁和表锁的区别。对InnoDB表更新一行,可能过了MDL关,却被挡在行锁阶段。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fce%2Fd9%2Fce7f4e35916ed1aa49206a53a0547bd9.jpg&width=768&dpr=4&quality=100&sign=49295b8f&sv=2) [Previous07 行锁功过:怎么减少行锁对性能的影响?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/07-hang-suo-gong-guo-zen-mo-jian-shao-hang-suo-dui-xing-neng-de-ying-xiang) [Next09 普通索引和唯一索引,应该怎么选择?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/09-pu-tong-suo-yin-he-wei-yi-suo-yin-ying-gai-zen-mo-xuan-ze) Last updated 2 years ago Was this helpful? --- # go语言核心36讲 | Go语言核心36讲 专栏作者郝林承诺:**“我可以保证的是,如果你认真地跟我一起走完这个专栏,那么基本掌握 Go 语言是肯定的。至少,本专栏一定会成为你学习 Go 语言最重要的敲门砖和垫脚石。”** 这样的信心除了源自作者的实力之外,也是由于 Go 语言本身就是一种容易入门的语言,它语法简洁、即便没有计算机的学科背景,也可以在短时间内迅速掌握。除此之外,它稳定性强、利于上手、程序库多、运行迅速的诸多优点,让它在近年快速崛起,被誉为“新世纪的 C 语言”。 2018 年 7 月,Go 语言一举超过 Java,进入了编程语言招聘榜的前三名。许多大厂都已经拥抱 Go 语言。这其中包括以 Java 打天下的阿里巴巴,更别提那些深爱着 Go 语言的滴滴、今日头条、小米、奇虎 360、京东等明星公司。高人才需求也就意味着高额的回报,如今掌握 Go 语言已经成为了简历上的一项亮点,不但优先录取,薪资也随之一路攀升。 本专栏从大厂面试考察的 Go 语言核心知识点和能力出发, 但不囿于面试题的知识范畴。专栏每期针对一组 Go 语言经典例题,给出典型回答和考点分析,并会详细剖析其中所涉及的核心知识点,让你熟识题目背后所考察的知识与能力,助你架构起完整的 Go 语言知识体系。 本专栏一共分成 3 大模块,5 个章节。 模块一:Go 语言基础知识 1. 基础概念:讲述 Go 语言基础中的基础,包括一些基本概念和运作机制。它们都应该是你初识 Go 语言时必须知道的,同时也有助于你理解后面的知识。 模块二:Go 语言进阶技术 1. 数据类型和语句:Go 语言中的数据类型大都是很有特色的,你只有理解了它们才能真正玩转 Go 语言。另外,也有怎样使用各种语法和语句的相关知识 模块三:Go 语言实战与应用 1. Go 程序的测试:很多程序员总以为测试是另一个团队的事情,其实不然。单元测试甚至接口测试其实都应该是程序员去做的,并且应该受到重视。本部分主要解答在 Go 语言中究竟应该怎样做好测试这件事。 2. 标准库的用法:虽然 Go 语言提供了自己的高效并发编程方式,但是同步方法依然不容忽视。这些都是我们在日常工作中很可能会用到的。 3. Go 语言拾遗:这部分将会讲述一些我们使用 Go 语言做软件项目的过程中很可能会遇到的问题,至少会包含两篇文章,是附赠给广大 Go 语言爱好者的。 > 本文档仅供学习使用,如您有条件,可支持作者:[课程链接](https://time.geekbang.org/column/intro/112) > > 本课程对应的源代码:[https://github.com/crazyjums/go\_haolingeek](https://github.com/crazyjums/go_haolingeek) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252Fu8kBMM3ZtOPBcexzmQ46%252Fimage.png%3Falt%3Dmedia%26token%3Db32e66c4-ceaf-4857-9550-09ca7f48930e&width=768&dpr=4&quality=100&sign=ea100927&sv=2) [下一页01 | 工作区和GOPATH](https://jums.gitbook.io/36-lectures-on-golang/01-gong-zuo-qu-he-gopath) 最后更新于2年前 这有帮助吗? --- # 16 “order by”是怎么工作的? | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252F2bnc69noXr4AzudRxMo6%252Fimage.png%3Falt%3Dmedia%26token%3D1892f828-b1bf-47f1-a28c-4459b8907568&width=768&dpr=4&quality=100&sign=fe775638&sv=2) 在你开发应用的时候,一定会经常碰到需要根据指定的字段排序来显示结果的需求。还是以我们前面举例用过的市民表为例,假设你要查询城市是“杭州”的所有人名字,并且按照姓名排序返回前1000个人的姓名、年龄。 假设这个表的部分定义是这样的: Copy CREATE TABLE `t` ( `id` int(11) NOT NULL, `city` varchar(16) NOT NULL, `name` varchar(16) NOT NULL, `age` int(11) NOT NULL, `addr` varchar(128) DEFAULT NULL, PRIMARY KEY (`id`), KEY `city` (`city`) ) ENGINE=InnoDB; 这时,你的SQL语句可以这么写: Copy select city,name,age from t where city='杭州' order by name limit 1000 ; 这个语句看上去逻辑很清晰,但是你了解它的执行流程吗?今天,我就和你聊聊这个语句是怎么执行的,以及有什么参数会影响执行的行为。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/16-order-by-shi-zen-mo-gong-zuo-de#quan-zi-duan-pai-xu) 全字段排序 前面我们介绍过索引,所以你现在就很清楚了,为避免全表扫描,我们需要在city字段加上索引。 在city字段上创建索引之后,我们用explain命令来看看这个语句的执行情况。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F82%2F03%2F826579b63225def812330ef6c344a303.png&width=768&dpr=4&quality=100&sign=917e67cc&sv=2) 图1 使用explain命令查看语句的执行情况 Extra这个字段中的“Using filesort”表示的就是需要排序,MySQL会给每个线程分配一块内存用于排序,称为sort\_buffer。 为了说明这个SQL查询语句的执行过程,我们先来看一下city这个索引的示意图。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fff%2F1e%2Fffb5e4153d6e02977cb2d164e6b6bc1e.jpg&width=768&dpr=4&quality=100&sign=c24dac2d&sv=2) 图2 city字段的索引示意图 从图中可以看到,满足city='杭州’条件的行,是从ID\_X到ID\_(X+N)的这些记录。 通常情况下,这个语句执行流程如下所示 : 1. 初始化sort\_buffer,确定放入name、city、age这三个字段; 2. 从索引city找到第一个满足city='杭州’条件的主键id,也就是图中的ID\_X; 3. 到主键id索引取出整行,取name、city、age三个字段的值,存入sort\_buffer中; 4. 从索引city取下一个记录的主键id; 5. 重复步骤3、4直到city的值不满足查询条件为止,对应的主键id也就是图中的ID\_Y; 6. 对sort\_buffer中的数据按照字段name做快速排序; 7. 按照排序结果取前1000行返回给客户端。 我们暂且把这个排序过程,称为全字段排序,执行流程的示意图如下所示,下一篇文章中我们还会用到这个排序。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F6c%2F72%2F6c821828cddf46670f9d56e126e3e772.jpg&width=768&dpr=4&quality=100&sign=68f88ebd&sv=2) 图3 全字段排序 图中“按name排序”这个动作,可能在内存中完成,也可能需要使用外部排序,这取决于排序所需的内存和参数sort\_buffer\_size。 sort\_buffer\_size,就是MySQL为排序开辟的内存(sort\_buffer)的大小。如果要排序的数据量小于sort\_buffer\_size,排序就在内存中完成。但如果排序数据量太大,内存放不下,则不得不利用磁盘临时文件辅助排序。 你可以用下面介绍的方法,来确定一个排序语句是否使用了临时文件。 Copy /* 打开optimizer_trace,只对本线程有效 */ SET optimizer_trace='enabled=on'; /* @a保存Innodb_rows_read的初始值 */ select VARIABLE_VALUE into @a from performance_schema.session_status where variable_name = 'Innodb_rows_read'; /* 执行语句 */ select city, name,age from t where city='杭州' order by name limit 1000; /* 查看 OPTIMIZER_TRACE 输出 */ SELECT * FROM `information_schema`.`OPTIMIZER_TRACE`\G /* @b保存Innodb_rows_read的当前值 */ select VARIABLE_VALUE into @b from performance_schema.session_status where variable_name = 'Innodb_rows_read'; /* 计算Innodb_rows_read差值 */ select @b-@a; 这个方法是通过查看 OPTIMIZER\_TRACE 的结果来确认的,你可以从 number\_of\_tmp\_files中看到是否使用了临时文件。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F89%2F95%2F89baf99cdeefe90a22370e1d6f5e6495.png&width=768&dpr=4&quality=100&sign=41965621&sv=2) 图4 全排序的OPTIMIZER\_TRACE部分结果 number\_of\_tmp\_files表示的是,排序过程中使用的临时文件数。你一定奇怪,为什么需要12个文件?内存放不下时,就需要使用外部排序,外部排序一般使用归并排序算法。可以这么简单理解,**MySQL将需要排序的数据分成12份,每一份单独排序后存在这些临时文件中。然后把这12个有序文件再合并成一个有序的大文件。** 如果sort\_buffer\_size超过了需要排序的数据量的大小,number\_of\_tmp\_files就是0,表示排序可以直接在内存中完成。 否则就需要放在临时文件中排序。sort\_buffer\_size越小,需要分成的份数越多,number\_of\_tmp\_files的值就越大。 接下来,我再和你解释一下图4中其他两个值的意思。 我们的示例表中有4000条满足city='杭州’的记录,所以你可以看到 examined\_rows=4000,表示参与排序的行数是4000行。 sort\_mode 里面的packed\_additional\_fields的意思是,排序过程对字符串做了“紧凑”处理。即使name字段的定义是varchar(16),在排序过程中还是要按照实际长度来分配空间的。 同时,最后一个查询语句select @b-@a 的返回结果是4000,表示整个执行过程只扫描了4000行。 这里需要注意的是,为了避免对结论造成干扰,我把internal\_tmp\_disk\_storage\_engine设置成MyISAM。否则,select @b-@a的结果会显示为4001。 这是因为查询OPTIMIZER\_TRACE这个表时,需要用到临时表,而internal\_tmp\_disk\_storage\_engine的默认值是InnoDB。如果使用的是InnoDB引擎的话,把数据从临时表取出来的时候,会让Innodb\_rows\_read的值加1。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/16-order-by-shi-zen-mo-gong-zuo-de#rowid-pai-xu) rowid排序 在上面这个算法过程里面,只对原表的数据读了一遍,剩下的操作都是在sort\_buffer和临时文件中执行的。但这个算法有一个问题,就是如果查询要返回的字段很多的话,那么sort\_buffer里面要放的字段数太多,这样内存里能够同时放下的行数很少,要分成很多个临时文件,排序的性能会很差。 所以如果单行很大,这个方法效率不够好。 那么,**如果MySQL认为排序的单行长度太大会怎么做呢?** 接下来,我来修改一个参数,让MySQL采用另外一种算法。 Copy SET max_length_for_sort_data = 16; max\_length\_for\_sort\_data,是MySQL中专门控制用于排序的行数据的长度的一个参数。它的意思是,如果单行的长度超过这个值,MySQL就认为单行太大,要换一个算法。 city、name、age 这三个字段的定义总长度是36,我把max\_length\_for\_sort\_data设置为16,我们再来看看计算过程有什么改变。 新的算法放入sort\_buffer的字段,只有要排序的列(即name字段)和主键id。 但这时,排序的结果就因为少了city和age字段的值,不能直接返回了,整个执行流程就变成如下所示的样子: 1. 初始化sort\_buffer,确定放入两个字段,即name和id; 2. 从索引city找到第一个满足city='杭州’条件的主键id,也就是图中的ID\_X; 3. 到主键id索引取出整行,取name、id这两个字段,存入sort\_buffer中; 4. 从索引city取下一个记录的主键id; 5. 重复步骤3、4直到不满足city='杭州’条件为止,也就是图中的ID\_Y; 6. 对sort\_buffer中的数据按照字段name进行排序; 7. 遍历排序结果,取前1000行,并按照id的值回到原表中取出city、name和age三个字段返回给客户端。 这个执行流程的示意图如下,我把它称为rowid排序。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fdc%2F6d%2Fdc92b67721171206a302eb679c83e86d.jpg&width=768&dpr=4&quality=100&sign=3a8635cf&sv=2) 图5 rowid排序 对比图3的全字段排序流程图你会发现,rowid排序多访问了一次表t的主键索引,就是步骤7。 需要说明的是,最后的“结果集”是一个逻辑概念,实际上MySQL服务端从排序后的sort\_buffer中依次取出id,然后到原表查到city、name和age这三个字段的结果,不需要在服务端再耗费内存存储结果,是直接返回给客户端的。 根据这个说明过程和图示,你可以想一下,这个时候执行select @b-@a,结果会是多少呢? 现在,我们就来看看结果有什么不同。 首先,图中的examined\_rows的值还是4000,表示用于排序的数据是4000行。但是select @b-@a这个语句的值变成5000了。 因为这时候除了排序过程外,在排序完成后,还要根据id去原表取值。由于语句是limit 1000,因此会多读1000行。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F27%2F9b%2F27f164804d1a4689718291be5d10f89b.png&width=768&dpr=4&quality=100&sign=ea06b1cd&sv=2) 图6 rowid排序的OPTIMIZER\_TRACE部分输出 从OPTIMIZER\_TRACE的结果中,你还能看到另外两个信息也变了。 * sort\_mode变成了,表示参与排序的只有name和id这两个字段。 * number\_of\_tmp\_files变成10了,是因为这时候参与排序的行数虽然仍然是4000行,但是每一行都变小了,因此需要排序的总数据量就变小了,需要的临时文件也相应地变少了。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/16-order-by-shi-zen-mo-gong-zuo-de#quan-zi-duan-pai-xu-vs-rowid-pai-xu) 全字段排序 VS rowid排序 我们来分析一下,从这两个执行流程里,还能得出什么结论。 如果MySQL实在是担心排序内存太小,会影响排序效率,才会采用rowid排序算法,这样排序过程中一次可以排序更多行,但是需要再回到原表去取数据。 如果MySQL认为内存足够大,会优先选择全字段排序,把需要的字段都放到sort\_buffer中,这样排序后就会直接从内存里面返回查询结果了,不用再回到原表去取数据。 这也就体现了MySQL的一个设计思想:**如果内存够,就要多利用内存,尽量减少磁盘访问。** 对于InnoDB表来说,rowid排序会要求回表多造成磁盘读,因此不会被优先选择。 这个结论看上去有点废话的感觉,但是你要记住它,下一篇文章我们就会用到。 看到这里,你就了解了,MySQL做排序是一个成本比较高的操作。那么你会问,是不是所有的order by都需要排序操作呢?如果不排序就能得到正确的结果,那对系统的消耗会小很多,语句的执行时间也会变得更短。 其实,并不是所有的order by语句,都需要排序操作的。从上面分析的执行过程,我们可以看到,MySQL之所以需要生成临时表,并且在临时表上做排序操作,**其原因是原来的数据都是无序的。** 你可以设想下,如果能够保证从city这个索引上取出来的行,天然就是按照name递增排序的话,是不是就可以不用再排序了呢? 确实是这样的。 所以,我们可以在这个市民表上创建一个city和name的联合索引,对应的SQL语句是: Copy alter table t add index city_user(city, name); 作为与city索引的对比,我们来看看这个索引的示意图。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F28%2Fca%2F2881ed13d2fbc4fc96d02368229b9cca.jpg&width=768&dpr=4&quality=100&sign=8cc44551&sv=2) 图7 city和name联合索引示意图 在这个索引里面,我们依然可以用树搜索的方式定位到第一个满足city='杭州’的记录,并且额外确保了,接下来按顺序取“下一条记录”的遍历过程中,只要city的值是杭州,name的值就一定是有序的。 这样整个查询过程的流程就变成了: 1. 从索引(city,name)找到第一个满足city='杭州’条件的主键id; 2. 到主键id索引取出整行,取name、city、age三个字段的值,作为结果集的一部分直接返回; 3. 从索引(city,name)取下一个记录主键id; 4. 重复步骤2、3,直到查到第1000条记录,或者是不满足city='杭州’条件时循环结束。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F3f%2F92%2F3f590c3a14f9236f2d8e1e2cb9686692.jpg&width=768&dpr=4&quality=100&sign=fa6b0591&sv=2) 图8 引入(city,name)联合索引后,查询语句的执行计划 可以看到,这个查询过程不需要临时表,也不需要排序。接下来,我们用explain的结果来印证一下。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Ffc%2F8a%2Ffc53de303811ba3c46d344595743358a.png&width=768&dpr=4&quality=100&sign=dd4006e&sv=2) 图9 引入(city,name)联合索引后,查询语句的执行计划 从图中可以看到,Extra字段中没有Using filesort了,也就是不需要排序了。而且由于(city,name)这个联合索引本身有序,所以这个查询也不用把4000行全都读一遍,只要找到满足条件的前1000条记录就可以退出了。也就是说,在我们这个例子里,只需要扫描1000次。 既然说到这里了,我们再往前讨论,**这个语句的执行流程有没有可能进一步简化呢?**不知道你还记不记得,我在第5篇文章[《 深入浅出索引(下)》](https://time.geekbang.org/column/article/69636) 中,和你介绍的覆盖索引。 这里我们可以再稍微复习一下。**覆盖索引是指,索引上的信息足够满足查询请求,不需要再回到主键索引上去取数据。** 按照覆盖索引的概念,我们可以再优化一下这个查询语句的执行流程。 针对这个查询,我们可以创建一个city、name和age的联合索引,对应的SQL语句就是: Copy alter table t add index city_user_age(city, name, age); 这时,对于city字段的值相同的行来说,还是按照name字段的值递增排序的,此时的查询语句也就不再需要排序了。这样整个查询语句的执行流程就变成了: 1. 从索引(city,name,age)找到第一个满足city='杭州’条件的记录,取出其中的city、name和age这三个字段的值,作为结果集的一部分直接返回; 2. 从索引(city,name,age)取下一个记录,同样取出这三个字段的值,作为结果集的一部分直接返回; 3. 重复执行步骤2,直到查到第1000条记录,或者是不满足city='杭州’条件时循环结束。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fdf%2Fd6%2Fdf4b8e445a59c53df1f2e0f115f02cd6.jpg&width=768&dpr=4&quality=100&sign=b50d9cca&sv=2) 图10 引入(city,name,age)联合索引后,查询语句的执行流程 然后,我们再来看看explain的结果。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F9e%2F23%2F9e40b7b8f0e3f81126a9171cc22e3423.png&width=768&dpr=4&quality=100&sign=7cdb57ba&sv=2) 图11 引入(city,name,age)联合索引后,查询语句的执行计划 可以看到,Extra字段里面多了“Using index”,表示的就是使用了覆盖索引,性能上会快很多。 当然,这里并不是说要为了每个查询能用上覆盖索引,就要把语句中涉及的字段都建上联合索引,毕竟索引还是有维护代价的。这是一个需要权衡的决定。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/16-order-by-shi-zen-mo-gong-zuo-de#xiao-jie) 小结 今天这篇文章,我和你介绍了MySQL里面order by语句的几种算法流程。 在开发系统的时候,你总是不可避免地会使用到order by语句。你心里要清楚每个语句的排序逻辑是怎么实现的,还要能够分析出在最坏情况下,每个语句的执行对系统资源的消耗,这样才能做到下笔如有神,不犯低级错误。 最后,我给你留下一个思考题吧。 假设你的表里面已经有了city\_name(city, name)这个联合索引,然后你要查杭州和苏州两个城市中所有的市民的姓名,并且按名字排序,显示前100条记录。如果SQL查询语句是这么写的 : Copy mysql> select * from t where city in ('杭州',"苏州") order by name limit 100; 那么,这个语句执行的时候会有排序过程吗,为什么? 如果业务端代码由你来开发,需要实现一个在数据库端不需要排序的方案,你会怎么实现呢? 进一步地,如果有分页需求,要显示第101页,也就是说语句最后要改成 “limit 10000,100”, 你的实现方法又会是什么呢? 你可以把你的思考和观点写在留言区里,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/16-order-by-shi-zen-mo-gong-zuo-de#shang-qi-wen-ti-shi-jian) 上期问题时间 上期的问题是,当MySQL去更新一行,但是要修改的值跟原来的值是相同的,这时候MySQL会真的去执行一次修改吗?还是看到值相同就直接返回呢? 这是第一次我们课后问题的三个选项都有同学选的,所以我要和你需要详细说明一下。 第一个选项是,MySQL读出数据,发现值与原来相同,不更新,直接返回,执行结束。这里我们可以用一个锁实验来确认。 假设,当前表t里的值是(1,2)。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F6d%2F90%2F6d9d8837560d01b57d252c470157ea90.png&width=768&dpr=4&quality=100&sign=591a46a9&sv=2) 图12 锁验证方式 session B的update 语句被blocked了,加锁这个动作是InnoDB才能做的,所以排除选项1。 第二个选项是,MySQL调用了InnoDB引擎提供的接口,但是引擎发现值与原来相同,不更新,直接返回。有没有这种可能呢?这里我用一个可见性实验来确认。 假设当前表里的值是(1,2)。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F44%2F96%2F441682b64a3f5dd50f35b12ca4b87c96.png&width=768&dpr=4&quality=100&sign=f4c80510&sv=2) 图13 可见性验证方式 session A的第二个select 语句是一致性读(快照读),它是不能看见session B的更新的。 现在它返回的是(1,3),表示它看见了某个新的版本,这个版本只能是session A自己的update语句做更新的时候生成。(如果你对这个逻辑有疑惑的话,可以回顾下第8篇文章[《事务到底是隔离的还是不隔离的?》](https://time.geekbang.org/column/article/70562) 中的相关内容) 所以,我们上期思考题的答案应该是选项3,即:InnoDB认真执行了“把这个值修改成(1,2)"这个操作,该加锁的加锁,该更新的更新。 然后你会说,MySQL怎么这么笨,就不会更新前判断一下值是不是相同吗?如果判断一下,不就不用浪费InnoDB操作,多去更新一次了? 其实MySQL是确认了的。只是在这个语句里面,MySQL认为读出来的值,只有一个确定的 (id=1), 而要写的是(a=3),只从这两个信息是看不出来“不需要修改”的。 作为验证,你可以看一下下面这个例子。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F63%2Fc1%2F63dd6df32dacdb827d256e5acb9837c1.png&width=768&dpr=4&quality=100&sign=c6b431ae&sv=2) 图14 可见性验证方式--对照 **补充说明:** 上面我们的验证结果都是在binlog\_format=statement格式下进行的。 @didiren 补充了一个case, 如果是binlog\_format=row 并且binlog\_row\_image=FULL的时候,由于MySQL需要在binlog里面记录所有的字段,所以在读数据的时候就会把所有数据都读出来了。 根据上面说的规则,“既然读了数据,就会判断”, 因此在这时候,select \* from t where id=1,结果就是“返回 (1,2)”。 同理,如果是binlog\_row\_image=NOBLOB, 会读出除blob 外的所有字段,在我们这个例子里,结果还是“返回 (1,2)”。 对应的代码如图15所示。这是MySQL 5.6版本引入的,在此之前我没有看过。所以,特此说明。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fd4%2F89%2Fd413b9235d56c62f9829750a68b06b89.png&width=768&dpr=4&quality=100&sign=6924dbc2&sv=2) 图15 binlog\_row\_image=FULL读字段逻辑 类似的,@mahonebags 同学提到了timestamp字段的问题。结论是:如果表中有timestamp字段而且设置了自动更新的话,那么更新“别的字段”的时候,MySQL会读入所有涉及的字段,这样通过判断,就会发现不需要修改。 这两个点我会在后面讲更新性能的文章中再展开。 评论区留言点赞板: > @Gavin 、@melon、@阿建 等同学提到了锁验证法; @郭江伟 同学提到了两个点,都非常好,有去实际验证。结论是这样的: 第一,hexdump看出来没改应该是WAL机制生效了,要过一会儿,或者把库shutdown看看。 第二,binlog没写是MySQL Server层知道行的值没变,所以故意不写的,这个是在row格式下的策略。你可以把binlog\_format 改成statement再验证下。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F09%2F77%2F09c1073f99cf71d2fb162a716b5fa577.jpg&width=768&dpr=4&quality=100&sign=e5471b04&sv=2) [Previous15 答疑文章(一):日志和索引相关问题](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/15-da-yi-wen-zhang-yi-ri-zhi-he-suo-yin-xiang-guan-wen-ti) [Next17 如何正确地显示随机消息?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/17-ru-he-zheng-que-di-xian-shi-sui-ji-xiao-xi) Last updated 2 years ago Was this helpful? --- # 18 为什么这些SQL语句逻辑相同,性能却差异巨大? | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252FxuoQKELMFT9PIHDE5p2V%252Fimage.png%3Falt%3Dmedia%26token%3D83d6a8d5-800a-4b2a-84db-d73e6918ad8c&width=768&dpr=4&quality=100&sign=932e2c1d&sv=2) 在MySQL中,有很多看上去逻辑相同,但性能却差异巨大的SQL语句。对这些语句使用不当的话,就会不经意间导致整个数据库的压力变大。 我今天挑选了三个这样的案例和你分享。希望再遇到相似的问题时,你可以做到举一反三、快速解决问题。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/18-wei-shi-mo-zhe-xie-sql-yu-ju-luo-ji-xiang-tong-xing-neng-que-cha-yi-ju-da#an-li-yi-tiao-jian-zi-duan-han-shu-cao-zuo) 案例一:条件字段函数操作 假设你现在维护了一个交易系统,其中交易记录表tradelog包含交易流水号(tradeid)、交易员id(operator)、交易时间(t\_modified)等字段。为了便于描述,我们先忽略其他字段。这个表的建表语句如下: Copy mysql> CREATE TABLE `tradelog` ( `id` int(11) NOT NULL, `tradeid` varchar(32) DEFAULT NULL, `operator` int(11) DEFAULT NULL, `t_modified` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `tradeid` (`tradeid`), KEY `t_modified` (`t_modified`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 假设,现在已经记录了从2016年初到2018年底的所有数据,运营部门有一个需求是,要统计发生在所有年份中7月份的交易记录总数。这个逻辑看上去并不复杂,你的SQL语句可能会这么写: Copy mysql> select count(*) from tradelog where month(t_modified)=7; 由于t\_modified字段上有索引,于是你就很放心地在生产库中执行了这条语句,但却发现执行了特别久,才返回了结果。 如果你问DBA同事为什么会出现这样的情况,他大概会告诉你:如果对字段做了函数计算,就用不上索引了,这是MySQL的规定。 现在你已经学过了InnoDB的索引结构了,可以再追问一句为什么?为什么条件是where t\_modified='2018-7-1’的时候可以用上索引,而改成where month(t\_modified)=7的时候就不行了? 下面是这个t\_modified索引的示意图。方框上面的数字就是month()函数对应的值。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F3e%2F86%2F3e30d9a5e67f711f5af2e2599e800286.png&width=768&dpr=4&quality=100&sign=f9d62b8e&sv=2) 图1 t\_modified索引示意图 如果你的SQL语句条件用的是where t\_modified='2018-7-1’的话,引擎就会按照上面绿色箭头的路线,快速定位到 t\_modified='2018-7-1’需要的结果。 实际上,B+树提供的这个快速定位能力,来源于同一层兄弟节点的有序性。 但是,如果计算month()函数的话,你会看到传入7的时候,在树的第一层就不知道该怎么办了。 也就是说,**对索引字段做函数操作,可能会破坏索引值的有序性,因此优化器就决定放弃走树搜索功能。** 需要注意的是,优化器并不是要放弃使用这个索引。 在这个例子里,放弃了树搜索功能,优化器可以选择遍历主键索引,也可以选择遍历索引t\_modified,优化器对比索引大小后发现,索引t\_modified更小,遍历这个索引比遍历主键索引来得更快。因此最终还是会选择索引t\_modified。 接下来,我们使用explain命令,查看一下这条SQL语句的执行结果。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F27%2F55%2F27c2f5ff3549b18ba37a28f4919f3655.png&width=768&dpr=4&quality=100&sign=12081e4a&sv=2) 图2 explain 结果 key="t\_modified"表示的是,使用了t\_modified这个索引;我在测试表数据中插入了10万行数据,rows=100335,说明这条语句扫描了整个索引的所有值;Extra字段的Using index,表示的是使用了覆盖索引。 也就是说,由于在t\_modified字段加了month()函数操作,导致了全索引扫描。为了能够用上索引的快速定位能力,我们就要把SQL语句改成基于字段本身的范围查询。按照下面这个写法,优化器就能按照我们预期的,用上t\_modified索引的快速定位能力了。 Copy mysql> select count(*) from tradelog where -> (t_modified >= '2016-7-1' and t_modified<'2016-8-1') or -> (t_modified >= '2017-7-1' and t_modified<'2017-8-1') or -> (t_modified >= '2018-7-1' and t_modified<'2018-8-1'); 当然,如果你的系统上线时间更早,或者后面又插入了之后年份的数据的话,你就需要再把其他年份补齐。 到这里我给你说明了,由于加了month()函数操作,MySQL无法再使用索引快速定位功能,而只能使用全索引扫描。 不过优化器在个问题上确实有“偷懒”行为,即使是对于不改变有序性的函数,也不会考虑使用索引。比如,对于select \* from tradelog where id + 1 = 10000这个SQL语句,这个加1操作并不会改变有序性,但是MySQL优化器还是不能用id索引快速定位到9999这一行。所以,需要你在写SQL语句的时候,手动改写成 where id = 10000 -1才可以。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/18-wei-shi-mo-zhe-xie-sql-yu-ju-luo-ji-xiang-tong-xing-neng-que-cha-yi-ju-da#an-li-er-yin-shi-lei-xing-zhuan-huan) 案例二:隐式类型转换 接下来我再跟你说一说,另一个经常让程序员掉坑里的例子。 我们一起看一下这条SQL语句: Copy mysql> select * from tradelog where tradeid=110717; 交易编号tradeid这个字段上,本来就有索引,但是explain的结果却显示,这条语句需要走全表扫描。你可能也发现了,tradeid的字段类型是varchar(32),而输入的参数却是整型,所以需要做类型转换。 那么,现在这里就有两个问题: 1. 数据类型转换的规则是什么? 2. 为什么有数据类型转换,就需要走全索引扫描? 先来看第一个问题,你可能会说,数据库里面类型这么多,这种数据类型转换规则更多,我记不住,应该怎么办呢? 这里有一个简单的方法,看 select “10” > 9的结果: 1. 如果规则是“将字符串转成数字”,那么就是做数字比较,结果应该是1; 2. 如果规则是“将数字转成字符串”,那么就是做字符串比较,结果应该是0。 验证结果如图3所示。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F2b%2F14%2F2b67fc38f1651e2622fe21d49950b214.png&width=768&dpr=4&quality=100&sign=5d4293b2&sv=2) 图3 MySQL中字符串和数字转换的效果示意图 从图中可知,select “10” > 9返回的是1,所以你就能确认MySQL里的转换规则了:在MySQL中,字符串和数字做比较的话,是将字符串转换成数字。 这时,你再看这个全表扫描的语句: Copy mysql> select * from tradelog where tradeid=110717; 就知道对于优化器来说,这个语句相当于: Copy mysql> select * from tradelog where CAST(tradid AS signed int) = 110717; 也就是说,这条语句触发了我们上面说到的规则:对索引字段做函数操作,优化器会放弃走树搜索功能。 现在,我留给你一个小问题,id的类型是int,如果执行下面这个语句,是否会导致全表扫描呢? Copy select * from tradelog where id="83126"; 你可以先自己分析一下,再到数据库里面去验证确认。 接下来,我们再来看一个稍微复杂点的例子。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/18-wei-shi-mo-zhe-xie-sql-yu-ju-luo-ji-xiang-tong-xing-neng-que-cha-yi-ju-da#an-li-san-yin-shi-zi-fu-bian-ma-zhuan-huan) 案例三:隐式字符编码转换 假设系统里还有另外一个表trade\_detail,用于记录交易的操作细节。为了便于量化分析和复现,我往交易日志表tradelog和交易详情表trade\_detail这两个表里插入一些数据。 Copy mysql> CREATE TABLE `trade_detail` ( `id` int(11) NOT NULL, `tradeid` varchar(32) DEFAULT NULL, `trade_step` int(11) DEFAULT NULL, /*操作步骤*/ `step_info` varchar(32) DEFAULT NULL, /*步骤信息*/ PRIMARY KEY (`id`), KEY `tradeid` (`tradeid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; insert into tradelog values(1, 'aaaaaaaa', 1000, now()); insert into tradelog values(2, 'aaaaaaab', 1000, now()); insert into tradelog values(3, 'aaaaaaac', 1000, now()); insert into trade_detail values(1, 'aaaaaaaa', 1, 'add'); insert into trade_detail values(2, 'aaaaaaaa', 2, 'update'); insert into trade_detail values(3, 'aaaaaaaa', 3, 'commit'); insert into trade_detail values(4, 'aaaaaaab', 1, 'add'); insert into trade_detail values(5, 'aaaaaaab', 2, 'update'); insert into trade_detail values(6, 'aaaaaaab', 3, 'update again'); insert into trade_detail values(7, 'aaaaaaab', 4, 'commit'); insert into trade_detail values(8, 'aaaaaaac', 1, 'add'); insert into trade_detail values(9, 'aaaaaaac', 2, 'update'); insert into trade_detail values(10, 'aaaaaaac', 3, 'update again'); insert into trade_detail values(11, 'aaaaaaac', 4, 'commit'); 这时候,如果要查询id=2的交易的所有操作步骤信息,SQL语句可以这么写: Copy mysql> select d.* from tradelog l, trade_detail d where d.tradeid=l.tradeid and l.id=2; /*语句Q1*/ ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fad%2F22%2Fadfe464af1d15f3261b710a806c0fa22.png&width=768&dpr=4&quality=100&sign=fe197750&sv=2) 图4 语句Q1的explain 结果 我们一起来看下这个结果: 1. 第一行显示优化器会先在交易记录表tradelog上查到id=2的行,这个步骤用上了主键索引,rows=1表示只扫描一行; 2. 第二行key=NULL,表示没有用上交易详情表trade\_detail上的tradeid索引,进行了全表扫描。 在这个执行计划里,是从tradelog表中取tradeid字段,再去trade\_detail表里查询匹配字段。因此,我们把tradelog称为驱动表,把trade\_detail称为被驱动表,把tradeid称为关联字段。 接下来,我们看下这个explain结果表示的执行流程: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F82%2Fa9%2F8289c184c8529acea0269a7460dc62a9.png&width=768&dpr=4&quality=100&sign=89d3f3a0&sv=2) 图5 语句Q1的执行过程 图中: * 第1步,是根据id在tradelog表里找到L2这一行; * 第2步,是从L2中取出tradeid字段的值; * 第3步,是根据tradeid值到trade\_detail表中查找条件匹配的行。explain的结果里面第二行的key=NULL表示的就是,这个过程是通过遍历主键索引的方式,一个一个地判断tradeid的值是否匹配。 进行到这里,你会发现第3步不符合我们的预期。因为表trade\_detail里tradeid字段上是有索引的,我们本来是希望通过使用tradeid索引能够快速定位到等值的行。但,这里并没有。 如果你去问DBA同学,他们可能会告诉你,因为这两个表的字符集不同,一个是utf8,一个是utf8mb4,所以做表连接查询的时候用不上关联字段的索引。这个回答,也是通常你搜索这个问题时会得到的答案。 但是你应该再追问一下,为什么字符集不同就用不上索引呢? 我们说问题是出在执行步骤的第3步,如果单独把这一步改成SQL语句的话,那就是: Copy mysql> select * from trade_detail where tradeid=$L2.tradeid.value; 其中,$L2.tradeid.value的字符集是utf8mb4。 参照前面的两个例子,你肯定就想到了,字符集utf8mb4是utf8的超集,所以当这两个类型的字符串在做比较的时候,MySQL内部的操作是,先把utf8字符串转成utf8mb4字符集,再做比较。 > 这个设定很好理解,utf8mb4是utf8的超集。类似地,在程序设计语言里面,做自动类型转换的时候,为了避免数据在转换过程中由于截断导致数据错误,也都是“按数据长度增加的方向”进行转换的。 因此, 在执行上面这个语句的时候,需要将被驱动数据表里的字段一个个地转换成utf8mb4,再跟L2做比较。 也就是说,实际上这个语句等同于下面这个写法: Copy select * from trade_detail where CONVERT(traideid USING utf8mb4)=$L2.tradeid.value; CONVERT()函数,在这里的意思是把输入的字符串转成utf8mb4字符集。 这就再次触发了我们上面说到的原则:对索引字段做函数操作,优化器会放弃走树搜索功能。 到这里,你终于明确了,字符集不同只是条件之一,**连接过程中要求在被驱动表的索引字段上加函数操作**,是直接导致对被驱动表做全表扫描的原因。 作为对比验证,我给你提另外一个需求,“查找trade\_detail表里id=4的操作,对应的操作者是谁”,再来看下这个语句和它的执行计划。 Copy mysql>select l.operator from tradelog l , trade_detail d where d.tradeid=l.tradeid and d.id=4; ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F92%2F11%2F92cb498ceb3557e41700fae53ce9bd11.png&width=768&dpr=4&quality=100&sign=d33b90ca&sv=2) 图6 explain 结果 这个语句里trade\_detail 表成了驱动表,但是explain结果的第二行显示,这次的查询操作用上了被驱动表tradelog里的索引(tradeid),扫描行数是1。 这也是两个tradeid字段的join操作,为什么这次能用上被驱动表的tradeid索引呢?我们来分析一下。 假设驱动表trade\_detail里id=4的行记为R4,那么在连接的时候(图5的第3步),被驱动表tradelog上执行的就是类似这样的SQL 语句: Copy select operator from tradelog where traideid =$R4.tradeid.value; 这时候$R4.tradeid.value的字符集是utf8, 按照字符集转换规则,要转成utf8mb4,所以这个过程就被改写成: Copy select operator from tradelog where traideid =CONVERT($R4.tradeid.value USING utf8mb4); 你看,这里的CONVERT函数是加在输入参数上的,这样就可以用上被驱动表的traideid索引。 理解了原理以后,就可以用来指导操作了。如果要优化语句 Copy select d.* from tradelog l, trade_detail d where d.tradeid=l.tradeid and l.id=2; 的执行过程,有两种做法: * 比较常见的优化方法是,把trade\_detail表上的tradeid字段的字符集也改成utf8mb4,这样就没有字符集转换的问题了。 Copy alter table trade_detail modify tradeid varchar(32) CHARACTER SET utf8mb4 default null; * 如果能够修改字段的字符集的话,是最好不过了。但如果数据量比较大, 或者业务上暂时不能做这个DDL的话,那就只能采用修改SQL语句的方法了。 Copy mysql> select d.* from tradelog l , trade_detail d where d.tradeid=CONVERT(l.tradeid USING utf8) and l.id=2; ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Faa%2Fd6%2Faa844a7bf35d330b9ec96fc159331bd6.png&width=768&dpr=4&quality=100&sign=31922fbd&sv=2) 图7 SQL语句优化后的explain结果 这里,我主动把 l.tradeid转成utf8,就避免了被驱动表上的字符编码转换,从explain结果可以看到,这次索引走对了。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/18-wei-shi-mo-zhe-xie-sql-yu-ju-luo-ji-xiang-tong-xing-neng-que-cha-yi-ju-da#xiao-jie) 小结 今天我给你举了三个例子,其实是在说同一件事儿,即:**对索引字段做函数操作,可能会破坏索引值的有序性,因此优化器就决定放弃走树搜索功能。** 第二个例子是隐式类型转换,第三个例子是隐式字符编码转换,它们都跟第一个例子一样,因为要求在索引字段上做函数操作而导致了全索引扫描。 MySQL的优化器确实有“偷懒”的嫌疑,即使简单地把where id+1=1000改写成where id=1000-1就能够用上索引快速查找,也不会主动做这个语句重写。 因此,每次你的业务代码升级时,把可能出现的、新的SQL语句explain一下,是一个很好的习惯。 最后,又到了思考题时间。 今天我留给你的课后问题是,你遇到过别的、类似今天我们提到的性能问题吗?你认为原因是什么,又是怎么解决的呢? 你可以把你经历和分析写在留言区里,我会在下一篇文章的末尾选取有趣的评论跟大家一起分享和分析。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/18-wei-shi-mo-zhe-xie-sql-yu-ju-luo-ji-xiang-tong-xing-neng-que-cha-yi-ju-da#shang-qi-wen-ti-shi-jian) 上期问题时间 我在上篇文章的最后,留给你的问题是:我们文章中最后的一个方案是,通过三次limit Y,1 来得到需要的数据,你觉得有没有进一步的优化方法。 这里我给出一种方法,取Y1、Y2和Y3里面最大的一个数,记为M,最小的一个数记为N,然后执行下面这条SQL语句: Copy mysql> select * from t limit N, M-N+1; 再加上取整个表总行数的C行,这个方案的扫描行数总共只需要C+M+1行。 当然也可以先取回id值,在应用中确定了三个id值以后,再执行三次where id=X的语句也是可以的。@倪大人 同学在评论区就提到了这个方法。 这次评论区出现了很多很棒的留言: > @老杨同志 提出了重新整理的方法、@雪中鼠\[悠闲\] 提到了用rowid的方法,是类似的思路,就是让表里面保存一个无空洞的自增值,这样就可以用我们的随机算法1来实现; @吴宇晨 提到了拿到第一个值以后,用id迭代往下找的方案,利用了主键索引的有序性。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F09%2F77%2F09c1073f99cf71d2fb162a716b5fa577.jpg&width=768&dpr=4&quality=100&sign=e5471b04&sv=2) [Previous17 如何正确地显示随机消息?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/17-ru-he-zheng-que-di-xian-shi-sui-ji-xiao-xi) [Next19 为什么我只查一行的语句,也执行这么慢?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/19-wei-shi-mo-wo-zhi-cha-yi-hang-de-yu-ju-ye-zhi-hang-zhe-mo-man) Last updated 2 years ago Was this helpful? --- # 19 为什么我只查一行的语句,也执行这么慢? | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252FKHHpgKA1kxF9AwRjuCb9%252Fimage.png%3Falt%3Dmedia%26token%3D4903c604-155b-4581-9a6e-e1dcd8227564&width=768&dpr=4&quality=100&sign=465ba89d&sv=2) 一般情况下,如果我跟你说查询性能优化,你首先会想到一些复杂的语句,想到查询需要返回大量的数据。但有些情况下,“查一行”,也会执行得特别慢。今天,我就跟你聊聊这个有趣的话题,看看什么情况下,会出现这个现象。 需要说明的是,如果MySQL数据库本身就有很大的压力,导致数据库服务器CPU占用率很高或ioutil(IO利用率)很高,这种情况下所有语句的执行都有可能变慢,不属于我们今天的讨论范围。 为了便于描述,我还是构造一个表,基于这个表来说明今天的问题。这个表有两个字段id和c,并且我在里面插入了10万行记录。 Copy mysql> CREATE TABLE `t` ( `id` int(11) NOT NULL, `c` int(11) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB; delimiter ;; create procedure idata() begin declare i int; set i=1; while(i<=100000)do insert into t values(i,i); set i=i+1; end while; end;; delimiter ; call idata(); 接下来,我会用几个不同的场景来举例,有些是前面的文章中我们已经介绍过的知识点,你看看能不能一眼看穿,来检验一下吧。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/19-wei-shi-mo-wo-zhi-cha-yi-hang-de-yu-ju-ye-zhi-hang-zhe-mo-man#di-yi-lei-cha-xun-chang-shi-jian-bu-fan-hui) 第一类:查询长时间不返回 如图1所示,在表t执行下面的SQL语句: Copy mysql> select * from t where id=1; 查询结果长时间不返回。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F87%2F2a%2F8707b79d5ed906950749f5266014f22a.png&width=768&dpr=4&quality=100&sign=dc690fba&sv=2) 图1 查询长时间不返回 一般碰到这种情况的话,大概率是表t被锁住了。接下来分析原因的时候,一般都是首先执行一下show processlist命令,看看当前语句处于什么状态。 然后我们再针对每种状态,去分析它们产生的原因、如何复现,以及如何处理。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/19-wei-shi-mo-wo-zhi-cha-yi-hang-de-yu-ju-ye-zhi-hang-zhe-mo-man#deng-mdl-suo) 等MDL锁 如图2所示,就是使用show processlist命令查看Waiting for table metadata lock的示意图。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F50%2F28%2F5008d7e9e22be88a9c80916df4f4b328.png&width=768&dpr=4&quality=100&sign=22b776ed&sv=2) 图2 Waiting for table metadata lock状态示意图 出现**这个状态表示的是,现在有一个线程正在表t上请求或者持有MDL写锁,把select语句堵住了。** 在第6篇文章[《全局锁和表锁 :给表加个字段怎么有这么多阻碍?》](https://time.geekbang.org/column/article/69862) 中,我给你介绍过一种复现方法。但需要说明的是,那个复现过程是基于MySQL 5.6版本的。而MySQL 5.7版本修改了MDL的加锁策略,所以就不能复现这个场景了。 不过,在MySQL 5.7版本下复现这个场景,也很容易。如图3所示,我给出了简单的复现步骤。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F74%2Fca%2F742249a31b83f4858c51bfe106a5daca.png&width=300&dpr=4&quality=100&sign=ed174f4c&sv=2) 图3 MySQL 5.7中Waiting for table metadata lock的复现步骤 session A 通过lock table命令持有表t的MDL写锁,而session B的查询需要获取MDL读锁。所以,session B进入等待状态。 这类问题的处理方式,就是找到谁持有MDL写锁,然后把它kill掉。 但是,由于在show processlist的结果里面,session A的Command列是“Sleep”,导致查找起来很不方便。不过有了performance\_schema和sys系统库以后,就方便多了。(MySQL启动时需要设置performance\_schema=on,相比于设置为off会有10%左右的性能损失) 通过查询sys.schema\_table\_lock\_waits这张表,我们就可以直接找出造成阻塞的process id,把这个连接用kill 命令断开即可。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F74%2F01%2F74fb24ba3826e3831eeeff1670990c01.png&width=768&dpr=4&quality=100&sign=89e5d6ce&sv=2) 图4 查获加表锁的线程id ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/19-wei-shi-mo-wo-zhi-cha-yi-hang-de-yu-ju-ye-zhi-hang-zhe-mo-man#deng-flush) 等flush 接下来,我给你举另外一种查询被堵住的情况。 我在表t上,执行下面的SQL语句: Copy mysql> select * from information_schema.processlist where id=1; 这里,我先卖个关子。 你可以看一下图5。我查出来这个线程的状态是Waiting for table flush,你可以设想一下这是什么原因。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F2d%2F24%2F2d8250398bc7f8f7dce8b6b1923c3724.png&width=300&dpr=4&quality=100&sign=bb220f82&sv=2) 图5 Waiting for table flush状态示意图 这个状态表示的是,现在有一个线程正要对表t做flush操作。MySQL里面对表做flush操作的用法,一般有以下两个: Copy flush tables t with read lock; flush tables with read lock; 这两个flush语句,如果指定表t的话,代表的是只关闭表t;如果没有指定具体的表名,则表示关闭MySQL里所有打开的表。 但是正常这两个语句执行起来都很快,除非它们也被别的线程堵住了。 所以,出现Waiting for table flush状态的可能情况是:有一个flush tables命令被别的语句堵住了,然后它又堵住了我们的select语句。 现在,我们一起来复现一下这种情况,**复现步骤**如图6所示: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F2b%2F9c%2F2bbc77cfdb118b0d9ef3fdd679d0a69c.png&width=768&dpr=4&quality=100&sign=a4107bde&sv=2) 图6 Waiting for table flush的复现步骤 在session A中,我故意每行都调用一次sleep(1),这样这个语句默认要执行10万秒,在这期间表t一直是被session A“打开”着。然后,session B的flush tables t命令再要去关闭表t,就需要等session A的查询结束。这样,session C要再次查询的话,就会被flush 命令堵住了。 图7是这个复现步骤的show processlist结果。这个例子的排查也很简单,你看到这个show processlist的结果,肯定就知道应该怎么做了。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F39%2F7e%2F398407014180be4146c2d088fc07357e.png&width=768&dpr=4&quality=100&sign=3d6e2934&sv=2) 图 7 Waiting for table flush的show processlist 结果 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/19-wei-shi-mo-wo-zhi-cha-yi-hang-de-yu-ju-ye-zhi-hang-zhe-mo-man#deng-xing-suo) 等行锁 现在,经过了表级锁的考验,我们的select 语句终于来到引擎里了。 Copy mysql> select * from t where id=1 lock in share mode; 上面这条语句的用法你也很熟悉了,我们在第8篇[《事务到底是隔离的还是不隔离的?》](https://time.geekbang.org/column/article/70562) 文章介绍当前读时提到过。 由于访问id=1这个记录时要加读锁,如果这时候已经有一个事务在这行记录上持有一个写锁,我们的select语句就会被堵住。 复现步骤和现场如下: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F3e%2F75%2F3e68326b967701c59770612183277475.png&width=768&dpr=4&quality=100&sign=51bd56e7&sv=2) 图 8 行锁复现 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F3c%2F8f%2F3c266e23fc307283aa94923ecbbc738f.png&width=768&dpr=4&quality=100&sign=d2d84e5c&sv=2) 图 9 行锁show processlist 现场 显然,session A启动了事务,占有写锁,还不提交,是导致session B被堵住的原因。 这个问题并不难分析,但问题是怎么查出是谁占着这个写锁。如果你用的是MySQL 5.7版本,可以通过sys.innodb\_lock\_waits 表查到。 查询方法是: Copy mysql> select * from t sys.innodb_lock_waits where locked_table=`'test'.'t'`\G ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fd8%2F18%2Fd8603aeb4eaad3326699c13c46379118.png&width=768&dpr=4&quality=100&sign=df9bc26&sv=2) 图10 通过sys.innodb\_lock\_waits 查行锁 可以看到,这个信息很全,4号线程是造成堵塞的罪魁祸首。而干掉这个罪魁祸首的方式,就是KILL QUERY 4或KILL 4。 不过,这里不应该显示“KILL QUERY 4”。这个命令表示停止4号线程当前正在执行的语句,而这个方法其实是没有用的。因为占有行锁的是update语句,这个语句已经是之前执行完成了的,现在执行KILL QUERY,无法让这个事务去掉id=1上的行锁。 实际上,KILL 4才有效,也就是说直接断开这个连接。这里隐含的一个逻辑就是,连接被断开的时候,会自动回滚这个连接里面正在执行的线程,也就释放了id=1上的行锁。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/19-wei-shi-mo-wo-zhi-cha-yi-hang-de-yu-ju-ye-zhi-hang-zhe-mo-man#di-er-lei-cha-xun-man) 第二类:查询慢 经过了重重封“锁”,我们再来看看一些查询慢的例子。 先来看一条你一定知道原因的SQL语句: Copy mysql> select * from t where c=50000 limit 1; 由于字段c上没有索引,这个语句只能走id主键顺序扫描,因此需要扫描5万行。 作为确认,你可以看一下慢查询日志。注意,这里为了把所有语句记录到slow log里,我在连接后先执行了 set long\_query\_time=0,将慢查询日志的时间阈值设置为0。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fd8%2F3c%2Fd8b2b5f97c60ae4fc4a03c616847503c.png&width=768&dpr=4&quality=100&sign=5875b81b&sv=2) 图11 全表扫描5万行的slow log Rows\_examined显示扫描了50000行。你可能会说,不是很慢呀,11.5毫秒就返回了,我们线上一般都配置超过1秒才算慢查询。但你要记住:**坏查询不一定是慢查询**。我们这个例子里面只有10万行记录,数据量大起来的话,执行时间就线性涨上去了。 扫描行数多,所以执行慢,这个很好理解。 但是接下来,我们再看一个只扫描一行,但是执行很慢的语句。 如图12所示,是这个例子的slow log。可以看到,执行的语句是 Copy mysql> select * from t where id=1; 虽然扫描行数是1,但执行时间却长达800毫秒。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F66%2F46%2F66f26bb885401e8e460451ff6b0c0746.png&width=768&dpr=4&quality=100&sign=d17aa28e&sv=2) 图12 扫描一行却执行得很慢 是不是有点奇怪呢,这些时间都花在哪里了? 如果我把这个slow log的截图再往下拉一点,你可以看到下一个语句,select \* from t where id=1 lock in share mode,执行时扫描行数也是1行,执行时间是0.2毫秒。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fbd%2Fd2%2Fbde83e269d9fa185b27900c8aa8137d2.png&width=768&dpr=4&quality=100&sign=4791cecc&sv=2) 图 13 加上lock in share mode的slow log 看上去是不是更奇怪了?按理说lock in share mode还要加锁,时间应该更长才对啊。 可能有的同学已经有答案了。如果你还没有答案的话,我再给你一个提示信息,图14是这两个语句的执行输出结果。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F1f%2F1c%2F1fbb84bb392b6bfa93786fe032690b1c.png&width=768&dpr=4&quality=100&sign=cf6c31b5&sv=2) 图14 两个语句的输出结果 第一个语句的查询结果里c=1,带lock in share mode的语句返回的是c=1000001。看到这里应该有更多的同学知道原因了。如果你还是没有头绪的话,也别着急。我先跟你说明一下复现步骤,再分析原因。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F84%2Fff%2F84667a3449dc846e393142600ee7a2ff.png&width=768&dpr=4&quality=100&sign=c6bfbf76&sv=2) 图15 复现步骤 你看到了,session A先用start transaction with consistent snapshot命令启动了一个事务,之后session B才开始执行update 语句。 session B执行完100万次update语句后,id=1这一行处于什么状态呢?你可以从图16中找到答案。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F46%2F8c%2F46bb9f5e27854678bfcaeaf0c3b8a98c.png&width=768&dpr=4&quality=100&sign=ec21d402&sv=2) 图16 id=1的数据状态 session B更新完100万次,生成了100万个回滚日志(undo log)。 带lock in share mode的SQL语句,是当前读,因此会直接读到1000001这个结果,所以速度很快;而select \* from t where id=1这个语句,是一致性读,因此需要从1000001开始,依次执行undo log,执行了100万次以后,才将1这个结果返回。 注意,undo log里记录的其实是“把2改成1”,“把3改成2”这样的操作逻辑,画成减1的目的是方便你看图。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/19-wei-shi-mo-wo-zhi-cha-yi-hang-de-yu-ju-ye-zhi-hang-zhe-mo-man#xiao-jie) 小结 今天我给你举了在一个简单的表上,执行“查一行”,可能会出现的被锁住和执行慢的例子。这其中涉及到了表锁、行锁和一致性读的概念。 在实际使用中,碰到的场景会更复杂。但大同小异,你可以按照我在文章中介绍的定位方法,来定位并解决问题。 最后,我给你留一个问题吧。 我们在举例加锁读的时候,用的是这个语句,select \* from t where id=1 lock in share mode。由于id上有索引,所以可以直接定位到id=1这一行,因此读锁也是只加在了这一行上。 但如果是下面的SQL语句, Copy begin; select * from t where c=5 for update; commit; 这个语句序列是怎么加锁的呢?加的锁又是什么时候释放呢? 你可以把你的观点和验证方法写在留言区里,我会在下一篇文章的末尾给出我的参考答案。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/19-wei-shi-mo-wo-zhi-cha-yi-hang-de-yu-ju-ye-zhi-hang-zhe-mo-man#shang-qi-wen-ti-shi-jian) 上期问题时间 在上一篇文章最后,我留给你的问题是,希望你可以分享一下之前碰到过的、与文章中类似的场景。 @封建的风 提到一个有趣的场景,值得一说。我把他的问题重写一下,表结构如下: Copy mysql> CREATE TABLE `table_a` ( `id` int(11) NOT NULL, `b` varchar(10) DEFAULT NULL, PRIMARY KEY (`id`), KEY `b` (`b`) ) ENGINE=InnoDB; 假设现在表里面,有100万行数据,其中有10万行数据的b的值是’1234567890’, 假设现在执行语句是这么写的: Copy mysql> select * from table_a where b='1234567890abcd'; 这时候,MySQL会怎么执行呢? 最理想的情况是,MySQL看到字段b定义的是varchar(10),那肯定返回空呀。可惜,MySQL并没有这么做。 那要不,就是把’1234567890abcd’拿到索引里面去做匹配,肯定也没能够快速判断出索引树b上并没有这个值,也很快就能返回空结果。 但实际上,MySQL也不是这么做的。 这条SQL语句的执行很慢,流程是这样的: 1. 在传给引擎执行的时候,做了字符截断。因为引擎里面这个行只定义了长度是10,所以只截了前10个字节,就是’1234567890’进去做匹配; 2. 这样满足条件的数据有10万行; 3. 因为是select \*, 所以要做10万次回表; 4. 但是每次回表以后查出整行,到server层一判断,b的值都不是’1234567890abcd’; 5. 返回结果是空。 这个例子,是我们文章内容的一个很好的补充。虽然执行过程中可能经过函数操作,但是最终在拿到结果后,server层还是要做一轮判断的。 评论区留言点赞板: > @赖阿甘 提到了等号顺序问题,时间上MySQL优化器执行过程中,where 条件部分, a=b和 b=a的写法是一样的。 @沙漠里的骆驼 提到了一个常见的问题。相同的模板语句,但是匹配行数不同,语句执行时间相差很大。这种情况,在语句里面有order by这样的操作时会更明显。 @Justin 回答了我们正文中的问题,如果id 的类型是整数,传入的参数类型是字符串的时候,可以用上索引。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F09%2F77%2F09c1073f99cf71d2fb162a716b5fa577.jpg&width=768&dpr=4&quality=100&sign=e5471b04&sv=2) [Previous18 为什么这些SQL语句逻辑相同,性能却差异巨大?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/18-wei-shi-mo-zhe-xie-sql-yu-ju-luo-ji-xiang-tong-xing-neng-que-cha-yi-ju-da) [Next20 幻读是什么,幻读有什么问题?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/20-huan-du-shi-shi-mo-huan-du-you-shi-mo-wen-ti) Last updated 2 years ago Was this helpful? --- # 17 如何正确地显示随机消息? | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252FYWgUNGd3EZV2cJ8DeeKj%252Fimage.png%3Falt%3Dmedia%26token%3D8ce49bb7-eabd-4d42-a3be-ffe0eadc4aa0&width=768&dpr=4&quality=100&sign=c935aec9&sv=2) 我在上一篇文章,为你讲解完order by语句的几种执行模式后,就想到了之前一个做英语学习App的朋友碰到过的一个性能问题。今天这篇文章,我就从这个性能问题说起,和你说说MySQL中的另外一种排序需求,希望能够加深你对MySQL排序逻辑的理解。 这个英语学习App首页有一个随机显示单词的功能,也就是根据每个用户的级别有一个单词表,然后这个用户每次访问首页的时候,都会随机滚动显示三个单词。他们发现随着单词表变大,选单词这个逻辑变得越来越慢,甚至影响到了首页的打开速度。 现在,如果让你来设计这个SQL语句,你会怎么写呢? 为了便于理解,我对这个例子进行了简化:去掉每个级别的用户都有一个对应的单词表这个逻辑,直接就是从一个单词表中随机选出三个单词。这个表的建表语句和初始数据的命令如下: Copy mysql> CREATE TABLE `words` ( `id` int(11) NOT NULL AUTO_INCREMENT, `word` varchar(64) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB; delimiter ;; create procedure idata() begin declare i int; set i=0; while i<10000 do insert into words(word) values(concat(char(97+(i div 1000)), char(97+(i % 1000 div 100)), char(97+(i % 100 div 10)), char(97+(i % 10)))); set i=i+1; end while; end;; delimiter ; call idata(); 为了便于量化说明,我在这个表里面插入了10000行记录。接下来,我们就一起看看要随机选择3个单词,有什么方法实现,存在什么问题以及如何改进。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/17-ru-he-zheng-que-di-xian-shi-sui-ji-xiao-xi#nei-cun-lin-shi-biao) 内存临时表 首先,你会想到用order by rand()来实现这个逻辑。 Copy mysql> select word from words order by rand() limit 3; 这个语句的意思很直白,随机排序取前3个。虽然这个SQL语句写法很简单,但执行流程却有点复杂的。 我们先用explain命令来看看这个语句的执行情况。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F59%2F50%2F59a4fb0165b7ce1184e41f2d061ce350.png&width=768&dpr=4&quality=100&sign=2cae484c&sv=2) 图1 使用explain命令查看语句的执行情况 Extra字段显示Using temporary,表示的是需要使用临时表;Using filesort,表示的是需要执行排序操作。 因此这个Extra的意思就是,需要临时表,并且需要在临时表上排序。 这里,你可以先回顾一下[上一篇文章](https://time.geekbang.org/column/article/73479) 中全字段排序和rowid排序的内容。我把上一篇文章的两个流程图贴过来,方便你复习。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F6c%2F72%2F6c821828cddf46670f9d56e126e3e772.jpg&width=768&dpr=4&quality=100&sign=68f88ebd&sv=2) 图2 全字段排序 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fdc%2F6d%2Fdc92b67721171206a302eb679c83e86d.jpg&width=768&dpr=4&quality=100&sign=3a8635cf&sv=2) 图3 rowid排序 然后,我再问你一个问题,你觉得对于临时内存表的排序来说,它会选择哪一种算法呢?回顾一下上一篇文章的一个结论:**对于InnoDB表来说**,执行全字段排序会减少磁盘访问,因此会被优先选择。 我强调了“InnoDB表”,你肯定想到了,**对于内存表,回表过程只是简单地根据数据行的位置,直接访问内存得到数据,根本不会导致多访问磁盘**。优化器没有了这一层顾虑,那么它会优先考虑的,就是用于排序的行越少越好了,所以,MySQL这时就会选择rowid排序。 理解了这个算法选择的逻辑,我们再来看看语句的执行流程。同时,通过今天的这个例子,我们来尝试分析一下语句的扫描行数。 这条语句的执行流程是这样的: 1. 创建一个临时表。这个临时表使用的是memory引擎,表里有两个字段,第一个字段是double类型,为了后面描述方便,记为字段R,第二个字段是varchar(64)类型,记为字段W。并且,这个表没有建索引。 2. 从words表中,按主键顺序取出所有的word值。对于每一个word值,调用rand()函数生成一个大于0小于1的随机小数,并把这个随机小数和word分别存入临时表的R和W字段中,到此,扫描行数是10000。 3. 现在临时表有10000行数据了,接下来你要在这个没有索引的内存临时表上,按照字段R排序。 4. 初始化 sort\_buffer。sort\_buffer中有两个字段,一个是double类型,另一个是整型。 5. 从内存临时表中一行一行地取出R值和位置信息(我后面会和你解释这里为什么是“位置信息”),分别存入sort\_buffer中的两个字段里。这个过程要对内存临时表做全表扫描,此时扫描行数增加10000,变成了20000。 6. 在sort\_buffer中根据R的值进行排序。注意,这个过程没有涉及到表操作,所以不会增加扫描行数。 7. 排序完成后,取出前三个结果的位置信息,依次到内存临时表中取出word值,返回给客户端。这个过程中,访问了表的三行数据,总扫描行数变成了20003。 接下来,我们通过慢查询日志(slow log)来验证一下我们分析得到的扫描行数是否正确。 Copy # Query_time: 0.900376 Lock_time: 0.000347 Rows_sent: 3 Rows_examined: 20003 SET timestamp=1541402277; select word from words order by rand() limit 3; 其中,Rows\_examined:20003就表示这个语句执行过程中扫描了20003行,也就验证了我们分析得出的结论。 这里插一句题外话,在平时学习概念的过程中,你可以经常这样做,先通过原理分析算出扫描行数,然后再通过查看慢查询日志,来验证自己的结论。我自己就是经常这么做,这个过程很有趣,分析对了开心,分析错了但是弄清楚了也很开心。 现在,我来把完整的排序执行流程图画出来。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F2a%2Ffc%2F2abe849faa7dcad0189b61238b849ffc.png&width=768&dpr=4&quality=100&sign=eecb35de&sv=2) 图4 随机排序完整流程图1 图中的pos就是位置信息,你可能会觉得奇怪,这里的“位置信息”是个什么概念?在上一篇文章中,我们对InnoDB表排序的时候,明明用的还是ID字段。 这时候,我们就要回到一个基本概念:**MySQL的表是用什么方法来定位“一行数据”的。** 在前面[第4](https://time.geekbang.org/column/article/69236) 和[第5](https://time.geekbang.org/column/article/69636) 篇介绍索引的文章中,有几位同学问到,如果把一个InnoDB表的主键删掉,是不是就没有主键,就没办法回表了? 其实不是的。如果你创建的表没有主键,或者把一个表的主键删掉了,那么InnoDB会自己生成一个长度为6字节的rowid来作为主键。 这也就是排序模式里面,rowid名字的来历。实际上它表示的是:每个引擎用来唯一标识数据行的信息。 * 对于有主键的InnoDB表来说,这个rowid就是主键ID; * 对于没有主键的InnoDB表来说,这个rowid就是由系统生成的; * MEMORY引擎不是索引组织表。在这个例子里面,你可以认为它就是一个数组。因此,这个rowid其实就是数组的下标。 到这里,我来稍微小结一下:**order by rand()使用了内存临时表,内存临时表排序的时候使用了rowid排序方法。** ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/17-ru-he-zheng-que-di-xian-shi-sui-ji-xiao-xi#ci-pan-lin-shi-biao) 磁盘临时表 那么,是不是所有的临时表都是内存表呢? 其实不是的。tmp\_table\_size这个配置限制了内存临时表的大小,默认值是16M。如果临时表大小超过了tmp\_table\_size,那么内存临时表就会转成磁盘临时表。 磁盘临时表使用的引擎默认是InnoDB,是由参数internal\_tmp\_disk\_storage\_engine控制的。 当使用磁盘临时表的时候,对应的就是一个没有显式索引的InnoDB表的排序过程。 为了复现这个过程,我把tmp\_table\_size设置成1024,把sort\_buffer\_size设置成 32768, 把 max\_length\_for\_sort\_data 设置成16。 Copy set tmp_table_size=1024; set sort_buffer_size=32768; set max_length_for_sort_data=16; /* 打开 optimizer_trace,只对本线程有效 */ SET optimizer_trace='enabled=on'; /* 执行语句 */ select word from words order by rand() limit 3; /* 查看 OPTIMIZER_TRACE 输出 */ SELECT * FROM `information_schema`.`OPTIMIZER_TRACE`\G ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F78%2Fab%2F78d2db9a4fdba81feadccf6e878b4aab.png&width=768&dpr=4&quality=100&sign=7fd1ad5d&sv=2) 图5 OPTIMIZER\_TRACE部分结果 然后,我们来看一下这次OPTIMIZER\_TRACE的结果。 因为将max\_length\_for\_sort\_data设置成16,小于word字段的长度定义,所以我们看到sort\_mode里面显示的是rowid排序,这个是符合预期的,参与排序的是随机值R字段和rowid字段组成的行。 这时候你可能心算了一下,发现不对。R字段存放的随机值就8个字节,rowid是6个字节(至于为什么是6字节,就留给你课后思考吧),数据总行数是10000,这样算出来就有140000字节,超过了sort\_buffer\_size 定义的 32768字节了。但是,number\_of\_tmp\_files的值居然是0,难道不需要用临时文件吗? 这个SQL语句的排序确实没有用到临时文件,采用是MySQL 5.6版本引入的一个新的排序算法,即:优先队列排序算法。接下来,我们就看看为什么没有使用临时文件的算法,也就是归并排序算法,而是采用了优先队列排序算法。 其实,我们现在的SQL语句,只需要取R值最小的3个rowid。但是,如果使用归并排序算法的话,虽然最终也能得到前3个值,但是这个算法结束后,已经将10000行数据都排好序了。 也就是说,后面的9997行也是有序的了。但,我们的查询并不需要这些数据是有序的。所以,想一下就明白了,这浪费了非常多的计算量。 而优先队列算法,就可以精确地只得到三个最小值,执行流程如下: 1. 对于这10000个准备排序的(R,rowid),先取前三行,构造成一个堆; (对数据结构印象模糊的同学,可以先设想成这是一个由三个元素组成的数组) 1. 取下一个行(R’,rowid’),跟当前堆里面最大的R比较,如果R’小于R,把这个(R,rowid)从堆中去掉,换成(R’,rowid’); 2. 重复第2步,直到第10000个(R’,rowid’)完成比较。 这里我简单画了一个优先队列排序过程的示意图。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fe9%2F97%2Fe9c29cb20bf9668deba8981e444f6897.png&width=768&dpr=4&quality=100&sign=4de392af&sv=2) 图6 优先队列排序算法示例 图6是模拟6个(R,rowid)行,通过优先队列排序找到最小的三个R值的行的过程。整个排序过程中,为了最快地拿到当前堆的最大值,总是保持最大值在堆顶,因此这是一个最大堆。 图5的OPTIMIZER\_TRACE结果中,filesort\_priority\_queue\_optimization这个部分的chosen=true,就表示使用了优先队列排序算法,这个过程不需要临时文件,因此对应的number\_of\_tmp\_files是0。 这个流程结束后,我们构造的堆里面,就是这个10000行里面R值最小的三行。然后,依次把它们的rowid取出来,去临时表里面拿到word字段,这个过程就跟上一篇文章的rowid排序的过程一样了。 我们再看一下上面一篇文章的SQL查询语句: Copy select city,name,age from t where city='杭州' order by name limit 1000 ; 你可能会问,这里也用到了limit,为什么没用优先队列排序算法呢?原因是,这条SQL语句是limit 1000,如果使用优先队列算法的话,需要维护的堆的大小就是1000行的(name,rowid),超过了我设置的sort\_buffer\_size大小,所以只能使用归并排序算法。 总之,不论是使用哪种类型的临时表,order by rand()这种写法都会让计算过程非常复杂,需要大量的扫描行数,因此排序过程的资源消耗也会很大。 再回到我们文章开头的问题,怎么正确地随机排序呢? ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/17-ru-he-zheng-que-di-xian-shi-sui-ji-xiao-xi#sui-ji-pai-xu-fang-fa) 随机排序方法 我们先把问题简化一下,如果只随机选择1个word值,可以怎么做呢?思路上是这样的: 1. 取得这个表的主键id的最大值M和最小值N; 2. 用随机函数生成一个最大值到最小值之间的数 X = (M-N)\*rand() + N; 3. 取不小于X的第一个ID的行。 我们把这个算法,暂时称作随机算法1。这里,我直接给你贴一下执行语句的序列: Copy mysql> select max(id),min(id) into @M,@N from t ; set @X= floor((@M-@N+1)*rand() + @N); select * from t where id >= @X limit 1; 这个方法效率很高,因为取max(id)和min(id)都是不需要扫描索引的,而第三步的select也可以用索引快速定位,可以认为就只扫描了3行。但实际上,这个算法本身并不严格满足题目的随机要求,因为ID中间可能有空洞,因此选择不同行的概率不一样,不是真正的随机。 比如你有4个id,分别是1、2、4、5,如果按照上面的方法,那么取到 id=4的这一行的概率是取得其他行概率的两倍。 如果这四行的id分别是1、2、40000、40001呢?这个算法基本就能当bug来看待了。 所以,为了得到严格随机的结果,你可以用下面这个流程: 1. 取得整个表的行数,并记为C。 2. 取得 Y = floor(C \* rand())。 floor函数在这里的作用,就是取整数部分。 3. 再用limit Y,1 取得一行。 我们把这个算法,称为随机算法2。下面这段代码,就是上面流程的执行语句的序列。 Copy mysql> select count(*) into @C from t; set @Y = floor(@C * rand()); set @sql = concat("select * from t limit ", @Y, ",1"); prepare stmt from @sql; execute stmt; DEALLOCATE prepare stmt; 由于limit 后面的参数不能直接跟变量,所以我在上面的代码中使用了prepare+execute的方法。你也可以把拼接SQL语句的方法写在应用程序中,会更简单些。 这个随机算法2,解决了算法1里面明显的概率不均匀问题。 MySQL处理limit Y,1 的做法就是按顺序一个一个地读出来,丢掉前Y个,然后把下一个记录作为返回结果,因此这一步需要扫描Y+1行。再加上,第一步扫描的C行,总共需要扫描C+Y+1行,执行代价比随机算法1的代价要高。 当然,随机算法2跟直接order by rand()比起来,执行代价还是小很多的。 你可能问了,如果按照这个表有10000行来计算的话,C=10000,要是随机到比较大的Y值,那扫描行数也跟20000差不多了,接近order by rand()的扫描行数,为什么说随机算法2的代价要小很多呢?我就把这个问题留给你去课后思考吧。 现在,我们再看看,如果我们按照随机算法2的思路,要随机取3个word值呢?你可以这么做: 1. 取得整个表的行数,记为C; 2. 根据相同的随机方法得到Y1、Y2、Y3; 3. 再执行三个limit Y, 1语句得到三行数据。 我们把这个算法,称作随机算法3。下面这段代码,就是上面流程的执行语句的序列。 Copy mysql> select count(*) into @C from t; set @Y1 = floor(@C * rand()); set @Y2 = floor(@C * rand()); set @Y3 = floor(@C * rand()); select * from t limit @Y1,1; //在应用代码里面取Y1、Y2、Y3值,拼出SQL后执行 select * from t limit @Y2,1; select * from t limit @Y3,1; ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/17-ru-he-zheng-que-di-xian-shi-sui-ji-xiao-xi#xiao-jie) 小结 今天这篇文章,我是借着随机排序的需求,跟你介绍了MySQL对临时表排序的执行过程。 如果你直接使用order by rand(),这个语句需要Using temporary 和 Using filesort,查询的执行代价往往是比较大的。所以,在设计的时候你要量避开这种写法。 今天的例子里面,我们不是仅仅在数据库内部解决问题,还会让应用代码配合拼接SQL语句。在实际应用的过程中,比较规范的用法就是:尽量将业务逻辑写在业务代码中,让数据库只做“读写数据”的事情。因此,这类方法的应用还是比较广泛的。 最后,我给你留下一个思考题吧。 上面的随机算法3的总扫描行数是 C+(Y1+1)+(Y2+1)+(Y3+1),实际上它还是可以继续优化,来进一步减少扫描行数的。 我的问题是,如果你是这个需求的开发人员,你会怎么做,来减少扫描行数呢?说说你的方案,并说明你的方案需要的扫描行数。 你可以把你的设计和结论写在留言区里,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/17-ru-he-zheng-que-di-xian-shi-sui-ji-xiao-xi#shang-qi-wen-ti-shi-jian) 上期问题时间 我在上一篇文章最后留给你的问题是,select \* from t where city in (“杭州”," 苏州 ") order by name limit 100;这个SQL语句是否需要排序?有什么方案可以避免排序? 虽然有(city,name)联合索引,对于单个city内部,name是递增的。但是由于这条SQL语句不是要单独地查一个city的值,而是同时查了"杭州"和" 苏州 "两个城市,因此所有满足条件的name就不是递增的了。也就是说,这条SQL语句需要排序。 那怎么避免排序呢? 这里,我们要用到(city,name)联合索引的特性,把这一条语句拆成两条语句,执行流程如下: 1. 执行select \* from t where city=“杭州” order by name limit 100; 这个语句是不需要排序的,客户端用一个长度为100的内存数组A保存结果。 2. 执行select \* from t where city=“苏州” order by name limit 100; 用相同的方法,假设结果被存进了内存数组B。 3. 现在A和B是两个有序数组,然后你可以用归并排序的思想,得到name最小的前100值,就是我们需要的结果了。 如果把这条SQL语句里“limit 100”改成“limit 10000,100”的话,处理方式其实也差不多,即:要把上面的两条语句改成写: Copy select * from t where city="杭州" order by name limit 10100; 和 Copy select * from t where city="苏州" order by name limit 10100。 这时候数据量较大,可以同时起两个连接一行行读结果,用归并排序算法拿到这两个结果集里,按顺序取第10001~10100的name值,就是需要的结果了。 当然这个方案有一个明显的损失,就是从数据库返回给客户端的数据量变大了。 所以,如果数据的单行比较大的话,可以考虑把这两条SQL语句改成下面这种写法: Copy select id,name from t where city="杭州" order by name limit 10100; 和 Copy select id,name from t where city="苏州" order by name limit 10100。 然后,再用归并排序的方法取得按name顺序第10001~10100的name、id的值,然后拿着这100个id到数据库中去查出所有记录。 上面这些方法,需要你根据性能需求和开发的复杂度做出权衡。 评论区留言点赞板: > 评论区很多同学都提到不能排序,说明各位对索引的存储都理解对了。 @峰 同学提到了归并排序,是我们这个问题解法的核心思想; @老杨同志 的回答中提到了“从业务上砍掉功能”,这个也确实是在业务设计中可以考虑的一个方向; @某、人 帮忙回答了@发条橙子同学的问题,尤其是对问题一的回答,非常精彩。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F09%2F77%2F09c1073f99cf71d2fb162a716b5fa577.jpg&width=768&dpr=4&quality=100&sign=e5471b04&sv=2) [Previous16 “order by”是怎么工作的?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/16-order-by-shi-zen-mo-gong-zuo-de) [Next18 为什么这些SQL语句逻辑相同,性能却差异巨大?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/18-wei-shi-mo-zhe-xie-sql-yu-ju-luo-ji-xiang-tong-xing-neng-que-cha-yi-ju-da) Last updated 2 years ago Was this helpful? --- # 20 幻读是什么,幻读有什么问题? | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252FoEJTbjn4pjSWkuQjn7wQ%252Fimage.png%3Falt%3Dmedia%26token%3Dab6d9b2f-1e72-4db8-ac88-b2fa8ba91e83&width=768&dpr=4&quality=100&sign=4eb22a0&sv=2) 在上一篇文章最后,我给你留了一个关于加锁规则的问题。今天,我们就从这个问题说起吧。 为了便于说明问题,这一篇文章,我们就先使用一个小一点儿的表。建表和初始化语句如下(为了便于本期的例子说明,我把上篇文章中用到的表结构做了点儿修改): Copy CREATE TABLE `t` ( `id` int(11) NOT NULL, `c` int(11) DEFAULT NULL, `d` int(11) DEFAULT NULL, PRIMARY KEY (`id`), KEY `c` (`c`) ) ENGINE=InnoDB; insert into t values(0,0,0),(5,5,5), (10,10,10),(15,15,15),(20,20,20),(25,25,25); 这个表除了主键id外,还有一个索引c,初始化语句在表中插入了6行数据。 上期我留给你的问题是,下面的语句序列,是怎么加锁的,加的锁又是什么时候释放的呢? Copy begin; select * from t where d=5 for update; commit; 比较好理解的是,这个语句会命中d=5的这一行,对应的主键id=5,因此在select 语句执行完成后,id=5这一行会加一个写锁,而且由于两阶段锁协议,这个写锁会在执行commit语句的时候释放。 由于字段d上没有索引,因此这条查询语句会做全表扫描。那么,其他被扫描到的,但是不满足条件的5行记录上,会不会被加锁呢? 我们知道,InnoDB的默认事务隔离级别是可重复读,所以本文接下来没有特殊说明的部分,都是设定在可重复读隔离级别下。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/20-huan-du-shi-shi-mo-huan-du-you-shi-mo-wen-ti#huan-du-shi-shen-me) 幻读是什么? 现在,我们就来分析一下,如果只在id=5这一行加锁,而其他行的不加锁的话,会怎么样。 下面先来看一下这个场景(注意:这是我假设的一个场景): ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F5b%2F8b%2F5bc506e5884d21844126d26bbe6fa68b.png&width=768&dpr=4&quality=100&sign=5179564c&sv=2) 图 1 假设只在id=5这一行加行锁 可以看到,session A里执行了三次查询,分别是Q1、Q2和Q3。它们的SQL语句相同,都是select \* from t where d=5 for update。这个语句的意思你应该很清楚了,查所有d=5的行,而且使用的是当前读,并且加上写锁。现在,我们来看一下这三条SQL语句,分别会返回什么结果。 1. Q1只返回id=5这一行; 2. 在T2时刻,session B把id=0这一行的d值改成了5,因此T3时刻Q2查出来的是id=0和id=5这两行; 3. 在T4时刻,session C又插入一行(1,1,5),因此T5时刻Q3查出来的是id=0、id=1和id=5的这三行。 其中,Q3读到id=1这一行的现象,被称为“幻读”。也就是说,幻读指的是一个事务在前后两次查询同一个范围的时候,后一次查询看到了前一次查询没有看到的行。 这里,我需要对“幻读”做一个说明: 1. 在可重复读隔离级别下,普通的查询是快照读,是不会看到别的事务插入的数据的。因此,幻读在“当前读”下才会出现。 2. 上面session B的修改结果,被session A之后的select语句用“当前读”看到,不能称为幻读。幻读仅专指“新插入的行”。 如果只从第8篇文章[《事务到底是隔离的还是不隔离的?》](https://time.geekbang.org/column/article/70562) 我们学到的事务可见性规则来分析的话,上面这三条SQL语句的返回结果都没有问题。 因为这三个查询都是加了for update,都是当前读。而当前读的规则,就是要能读到所有已经提交的记录的最新值。并且,session B和sessionC的两条语句,执行后就会提交,所以Q2和Q3就是应该看到这两个事务的操作效果,而且也看到了,这跟事务的可见性规则并不矛盾。 但是,这是不是真的没问题呢? 不,这里还真就有问题。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/20-huan-du-shi-shi-mo-huan-du-you-shi-mo-wen-ti#huan-du-you-shen-me-wen-ti) 幻读有什么问题? **首先是语义上的。**session A在T1时刻就声明了,“我要把所有d=5的行锁住,不准别的事务进行读写操作”。而实际上,这个语义被破坏了。 如果现在这样看感觉还不明显的话,我再往session B和session C里面分别加一条SQL语句,你再看看会出现什么现象。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F7a%2F07%2F7a9ffa90ac3cc78db6a51ff9b9075607.png&width=768&dpr=4&quality=100&sign=137b9e44&sv=2) 图 2 假设只在id=5这一行加行锁--语义被破坏 session B的第二条语句update t set c=5 where id=0,语义是“我把id=0、d=5这一行的c值,改成了5”。 由于在T1时刻,session A 还只是给id=5这一行加了行锁, 并没有给id=0这行加上锁。因此,session B在T2时刻,是可以执行这两条update语句的。这样,就破坏了 session A 里Q1语句要锁住所有d=5的行的加锁声明。 session C也是一样的道理,对id=1这一行的修改,也是破坏了Q1的加锁声明。 **其次,是数据一致性的问题。** 我们知道,锁的设计是为了保证数据的一致性。而这个一致性,不止是数据库内部数据状态在此刻的一致性,还包含了数据和日志在逻辑上的一致性。 为了说明这个问题,我给session A在T1时刻再加一个更新语句,即:update t set d=100 where d=5。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fdc%2F92%2Fdcea7845ff0bdbee2622bf3c67d31d92.png&width=768&dpr=4&quality=100&sign=566d813b&sv=2) 图 3 假设只在id=5这一行加行锁--数据一致性问题 update的加锁语义和select …for update 是一致的,所以这时候加上这条update语句也很合理。session A声明说“要给d=5的语句加上锁”,就是为了要更新数据,新加的这条update语句就是把它认为加上了锁的这一行的d值修改成了100。 现在,我们来分析一下图3执行完成后,数据库里会是什么结果。 1. 经过T1时刻,id=5这一行变成 (5,5,100),当然这个结果最终是在T6时刻正式提交的; 2. 经过T2时刻,id=0这一行变成(0,5,5); 3. 经过T4时刻,表里面多了一行(1,5,5); 4. 其他行跟这个执行序列无关,保持不变。 这样看,这些数据也没啥问题,但是我们再来看看这时候binlog里面的内容。 1. T2时刻,session B事务提交,写入了两条语句; 2. T4时刻,session C事务提交,写入了两条语句; 3. T6时刻,session A事务提交,写入了update t set d=100 where d=5 这条语句。 我统一放到一起的话,就是这样的: Copy update t set d=5 where id=0; /*(0,0,5)*/ update t set c=5 where id=0; /*(0,5,5)*/ insert into t values(1,1,5); /*(1,1,5)*/ update t set c=5 where id=1; /*(1,5,5)*/ update t set d=100 where d=5;/*所有d=5的行,d改成100*/ 好,你应该看出问题了。这个语句序列,不论是拿到备库去执行,还是以后用binlog来克隆一个库,这三行的结果,都变成了 (0,5,100)、(1,5,100)和(5,5,100)。 也就是说,id=0和id=1这两行,发生了数据不一致。这个问题很严重,是不行的。 到这里,我们再回顾一下,**这个数据不一致到底是怎么引入的?** 我们分析一下可以知道,这是我们假设“select \* from t where d=5 for update这条语句只给d=5这一行,也就是id=5的这一行加锁”导致的。 所以我们认为,上面的设定不合理,要改。 那怎么改呢?我们把扫描过程中碰到的行,也都加上写锁,再来看看执行效果。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F34%2F47%2F34ad6478281709da833856084a1e3447.png&width=768&dpr=4&quality=100&sign=a72e132b&sv=2) 图 4 假设扫描到的行都被加上了行锁 由于session A把所有的行都加了写锁,所以session B在执行第一个update语句的时候就被锁住了。需要等到T6时刻session A提交以后,session B才能继续执行。 这样对于id=0这一行,在数据库里的最终结果还是 (0,5,5)。在binlog里面,执行序列是这样的: Copy insert into t values(1,1,5); /*(1,1,5)*/ update t set c=5 where id=1; /*(1,5,5)*/ update t set d=100 where d=5;/*所有d=5的行,d改成100*/ update t set d=5 where id=0; /*(0,0,5)*/ update t set c=5 where id=0; /*(0,5,5)*/ 可以看到,按照日志顺序执行,id=0这一行的最终结果也是(0,5,5)。所以,id=0这一行的问题解决了。 但同时你也可以看到,id=1这一行,在数据库里面的结果是(1,5,5),而根据binlog的执行结果是(1,5,100),也就是说幻读的问题还是没有解决。为什么我们已经这么“凶残”地,把所有的记录都上了锁,还是阻止不了id=1这一行的插入和更新呢? 原因很简单。在T3时刻,我们给所有行加锁的时候,id=1这一行还不存在,不存在也就加不上锁。 **也就是说,即使把所有的记录都加上锁,还是阻止不了新插入的记录,**这也是为什么“幻读”会被单独拿出来解决的原因。 到这里,其实我们刚说明完文章的标题 :幻读的定义和幻读有什么问题。 接下来,我们再看看InnoDB怎么解决幻读的问题。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/20-huan-du-shi-shi-mo-huan-du-you-shi-mo-wen-ti#ru-he-jie-jue-huan-du) 如何解决幻读? 现在你知道了,产生幻读的原因是,行锁只能锁住行,但是新插入记录这个动作,要更新的是记录之间的“间隙”。因此,为了解决幻读问题,InnoDB只好引入新的锁,也就是间隙锁(Gap Lock)。 顾名思义,间隙锁,锁的就是两个值之间的空隙。比如文章开头的表t,初始化插入了6个记录,这就产生了7个间隙。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fe7%2F61%2Fe7f7ca0d3dab2f48c588d714ee3ac861.png&width=768&dpr=4&quality=100&sign=deddbfbe&sv=2) 图 5 表t主键索引上的行锁和间隙锁 这样,当你执行 select \* from t where d=5 for update的时候,就不止是给数据库中已有的6个记录加上了行锁,还同时加了7个间隙锁。这样就确保了无法再插入新的记录。 也就是说这时候,在一行行扫描的过程中,不仅将给行加上了行锁,还给行两边的空隙,也加上了间隙锁。 现在你知道了,数据行是可以加上锁的实体,数据行之间的间隙,也是可以加上锁的实体。但是间隙锁跟我们之前碰到过的锁都不太一样。 比如行锁,分成读锁和写锁。下图就是这两种类型行锁的冲突关系。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fc4%2F51%2Fc435c765556c0f3735a6eda0779ff151.png&width=768&dpr=4&quality=100&sign=7d9e4131&sv=2) 图6 两种行锁间的冲突关系 也就是说,跟行锁有冲突关系的是“另外一个行锁”。 但是间隙锁不一样,**跟间隙锁存在冲突关系的,是“往这个间隙中插入一个记录”这个操作。**间隙锁之间都不存在冲突关系。 这句话不太好理解,我给你举个例子: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F7c%2F98%2F7c37732d936650f1cda7dbf27daf7498.png&width=768&dpr=4&quality=100&sign=701b3b4&sv=2) 图7 间隙锁之间不互锁 这里session B并不会被堵住。因为表t里并没有c=7这个记录,因此session A加的是间隙锁(5,10)。而session B也是在这个间隙加的间隙锁。它们有共同的目标,即:保护这个间隙,不允许插入值。但,它们之间是不冲突的。 间隙锁和行锁合称next-key lock,每个next-key lock是前开后闭区间。也就是说,我们的表t初始化以后,如果用select \* from t for update要把整个表所有记录锁起来,就形成了7个next-key lock,分别是 (-∞,0\]、(0,5\]、(5,10\]、(10,15\]、(15,20\]、(20, 25\]、(25, +supremum\]。 > 备注:这篇文章中,如果没有特别说明,我们把间隙锁记为开区间,把next-key lock记为前开后闭区间。 你可能会问说,这个supremum从哪儿来的呢? 这是因为+∞是开区间。实现上,InnoDB给每个索引加了一个不存在的最大值supremum,这样才符合我们前面说的“都是前开后闭区间”。 **间隙锁和next-key lock的引入,帮我们解决了幻读的问题,但同时也带来了一些“困扰”。** 在前面的文章中,就有同学提到了这个问题。我把他的问题转述一下,对应到我们这个例子的表来说,业务逻辑这样的:任意锁住一行,如果这一行不存在的话就插入,如果存在这一行就更新它的数据,代码如下: Copy begin; select * from t where id=N for update; /*如果行不存在*/ insert into t values(N,N,N); /*如果行存在*/ update t set d=N set id=N; commit; 可能你会说,这个不是insert … on duplicate key update 就能解决吗?但其实在有多个唯一键的时候,这个方法是不能满足这位提问同学的需求的。至于为什么,我会在后面的文章中再展开说明。 现在,我们就只讨论这个逻辑。 这个同学碰到的现象是,这个逻辑一旦有并发,就会碰到死锁。你一定也觉得奇怪,这个逻辑每次操作前用for update锁起来,已经是最严格的模式了,怎么还会有死锁呢? 这里,我用两个session来模拟并发,并假设N=9。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fdf%2Fbe%2Fdf37bf0bb9f85ea59f0540e24eb6bcbe.png&width=768&dpr=4&quality=100&sign=baa56dfc&sv=2) 图8 间隙锁导致的死锁 你看到了,其实都不需要用到后面的update语句,就已经形成死锁了。我们按语句执行顺序来分析一下: 1. session A 执行select … for update语句,由于id=9这一行并不存在,因此会加上间隙锁(5,10); 2. session B 执行select … for update语句,同样会加上间隙锁(5,10),间隙锁之间不会冲突,因此这个语句可以执行成功; 3. session B 试图插入一行(9,9,9),被session A的间隙锁挡住了,只好进入等待; 4. session A试图插入一行(9,9,9),被session B的间隙锁挡住了。 至此,两个session进入互相等待状态,形成死锁。当然,InnoDB的死锁检测马上就发现了这对死锁关系,让session A的insert语句报错返回了。 你现在知道了,**间隙锁的引入,可能会导致同样的语句锁住更大的范围,这其实是影响了并发度的**。其实,这还只是一个简单的例子,在下一篇文章中我们还会碰到更多、更复杂的例子。 你可能会说,为了解决幻读的问题,我们引入了这么一大串内容,有没有更简单一点的处理方法呢。 我在文章一开始就说过,如果没有特别说明,今天和你分析的问题都是在可重复读隔离级别下的,间隙锁是在可重复读隔离级别下才会生效的。所以,你如果把隔离级别设置为读提交的话,就没有间隙锁了。但同时,你要解决可能出现的数据和日志不一致问题,需要把binlog格式设置为row。这,也是现在不少公司使用的配置组合。 前面文章的评论区有同学留言说,他们公司就使用的是读提交隔离级别加binlog\_format=row的组合。他曾问他们公司的DBA说,你为什么要这么配置。DBA直接答复说,因为大家都这么用呀。 所以,这个同学在评论区就问说,这个配置到底合不合理。 关于这个问题本身的答案是,如果读提交隔离级别够用,也就是说,业务不需要可重复读的保证,这样考虑到读提交下操作数据的锁范围更小(没有间隙锁),这个选择是合理的。 但其实我想说的是,配置是否合理,跟业务场景有关,需要具体问题具体分析。 但是,如果DBA认为之所以这么用的原因是“大家都这么用”,那就有问题了,或者说,迟早会出问题。 比如说,大家都用读提交,可是逻辑备份的时候,mysqldump为什么要把备份线程设置成可重复读呢?(这个我在前面的文章中已经解释过了,你可以再回顾下第6篇文章[《全局锁和表锁 :给表加个字段怎么有这么多阻碍?》](https://time.geekbang.org/column/article/69862) 的内容) 然后,在备份期间,备份线程用的是可重复读,而业务线程用的是读提交。同时存在两种事务隔离级别,会不会有问题? 进一步地,这两个不同的隔离级别现象有什么不一样的,关于我们的业务,“用读提交就够了”这个结论是怎么得到的? 如果业务开发和运维团队这些问题都没有弄清楚,那么“没问题”这个结论,本身就是有问题的。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/20-huan-du-shi-shi-mo-huan-du-you-shi-mo-wen-ti#xiao-jie) 小结 今天我们从上一篇文章的课后问题说起,提到了全表扫描的加锁方式。我们发现即使给所有的行都加上行锁,仍然无法解决幻读问题,因此引入了间隙锁的概念。 我碰到过很多对数据库有一定了解的业务开发人员,他们在设计数据表结构和业务SQL语句的时候,对行锁有很准确的认识,但却很少考虑到间隙锁。最后的结果,就是生产库上会经常出现由于间隙锁导致的死锁现象。 行锁确实比较直观,判断规则也相对简单,间隙锁的引入会影响系统的并发度,也增加了锁分析的复杂度,但也有章可循。下一篇文章,我就会为你讲解InnoDB的加锁规则,帮你理顺这其中的“章法”。 作为对下一篇文章的预习,我给你留下一个思考题。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F0d%2F3d%2F0d796060073668ca169166a8903fbf3d.png&width=768&dpr=4&quality=100&sign=9919bc96&sv=2) 图9 事务进入锁等待状态 如果你之前没有了解过本篇文章的相关内容,一定觉得这三个语句简直是风马牛不相及。但实际上,这里session B和session C的insert 语句都会进入锁等待状态。 你可以试着分析一下,出现这种情况的原因是什么? 这里需要说明的是,这其实是我在下一篇文章介绍加锁规则后才能回答的问题,是留给你作为预习的,其中session C被锁住这个分析是有点难度的。如果你没有分析出来,也不要气馁,我会在下一篇文章和你详细说明。 你也可以说说,你的线上MySQL配置的是什么隔离级别,为什么会这么配置?你有没有碰到什么场景,是必须使用可重复读隔离级别的呢? 你可以把你的碰到的场景和分析写在留言区里,我会在下一篇文章选取有趣的评论跟大家一起分享和分析。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/20-huan-du-shi-shi-mo-huan-du-you-shi-mo-wen-ti#shang-qi-wen-ti-shi-jian) 上期问题时间 我们在本文的开头回答了上期问题。有同学的回答中还说明了读提交隔离级别下,在语句执行完成后,是只有行锁的。而且语句执行完成后,InnoDB就会把不满足条件的行行锁去掉。 当然了,c=5这一行的行锁,还是会等到commit的时候才释放的。 评论区留言点赞板: > @薛畅 、@张永志同学给出了正确答案。而且提到了在读提交隔离级别下,是只有行锁的。 @帆帆帆帆帆帆帆帆、@欧阳成 对上期的例子做了验证,需要说明一下,需要在启动配置里面增加performance\_schema=on,才能用上这个功能,performance\_schema库里的表才有数据。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F09%2F77%2F09c1073f99cf71d2fb162a716b5fa577.jpg&width=768&dpr=4&quality=100&sign=e5471b04&sv=2) [Previous19 为什么我只查一行的语句,也执行这么慢?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/19-wei-shi-mo-wo-zhi-cha-yi-hang-de-yu-ju-ye-zhi-hang-zhe-mo-man) [Next21 为什么我只改一行的语句,锁这么多?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/21-wei-shi-mo-wo-zhi-gai-yi-hang-de-yu-ju-suo-zhe-mo-duo) Last updated 2 years ago Was this helpful? --- # 21 为什么我只改一行的语句,锁这么多? | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252FWIbwXzg7AuAxvNIV9mXB%252Fimage.png%3Falt%3Dmedia%26token%3Dd308b269-2ad2-41e4-b06e-6b759b75d625&width=768&dpr=4&quality=100&sign=e51b11d4&sv=2) 在上一篇文章中,我和你介绍了间隙锁和next-key lock的概念,但是并没有说明加锁规则。间隙锁的概念理解起来确实有点儿难,尤其在配合上行锁以后,很容易在判断是否会出现锁等待的问题上犯错。 所以今天,我们就先从这个加锁规则开始吧。 首先说明一下,这些加锁规则我没在别的地方看到过有类似的总结,以前我自己判断的时候都是想着代码里面的实现来脑补的。这次为了总结成不看代码的同学也能理解的规则,是我又重新刷了代码临时总结出来的。所以,**这个规则有以下两条前提说明:** 1. MySQL后面的版本可能会改变加锁策略,所以这个规则只限于截止到现在的最新版本,即5.x系列<=5.7.24,8.0系列 <=8.0.13。 2. 如果大家在验证中有发现bad case的话,请提出来,我会再补充进这篇文章,使得一起学习本专栏的所有同学都能受益。 因为间隙锁在可重复读隔离级别下才有效,所以本篇文章接下来的描述,若没有特殊说明,默认是可重复读隔离级别。 **我总结的加锁规则里面,包含了两个“原则”、两个“优化”和一个“bug”。** 1. 原则1:加锁的基本单位是next-key lock。希望你还记得,next-key lock是前开后闭区间。 2. 原则2:查找过程中访问到的对象才会加锁。 3. 优化1:索引上的等值查询,给唯一索引加锁的时候,next-key lock退化为行锁。 4. 优化2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock退化为间隙锁。 5. 一个bug:唯一索引上的范围查询会访问到不满足条件的第一个值为止。 我还是以上篇文章的表t为例,和你解释一下这些规则。表t的建表语句和初始化语句如下。 Copy CREATE TABLE `t` ( `id` int(11) NOT NULL, `c` int(11) DEFAULT NULL, `d` int(11) DEFAULT NULL, PRIMARY KEY (`id`), KEY `c` (`c`) ) ENGINE=InnoDB; insert into t values(0,0,0),(5,5,5), (10,10,10),(15,15,15),(20,20,20),(25,25,25); 接下来的例子基本都是配合着图片说明的,所以我建议你可以对照着文稿看,有些例子可能会“毁三观”,也建议你读完文章后亲手实践一下。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/21-wei-shi-mo-wo-zhi-gai-yi-hang-de-yu-ju-suo-zhe-mo-duo#an-li-yi-deng-zhi-cha-xun-jian-xi-suo) 案例一:等值查询间隙锁 第一个例子是关于等值条件操作间隙: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F58%2F6c%2F585dfa8d0dd71171a6fa16bed4ba816c.png&width=768&dpr=4&quality=100&sign=5ee204c7&sv=2) 图1 等值查询的间隙锁 由于表t中没有id=7的记录,所以用我们上面提到的加锁规则判断一下的话: 1. 根据原则1,加锁单位是next-key lock,session A加锁范围就是(5,10\]; 2. 同时根据优化2,这是一个等值查询(id=7),而id=10不满足查询条件,next-key lock退化成间隙锁,因此最终加锁的范围是(5,10)。 所以,session B要往这个间隙里面插入id=8的记录会被锁住,但是session C修改id=10这行是可以的。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/21-wei-shi-mo-wo-zhi-gai-yi-hang-de-yu-ju-suo-zhe-mo-duo#an-li-er-fei-wei-yi-suo-yin-deng-zhi-suo) 案例二:非唯一索引等值锁 第二个例子是关于覆盖索引上的锁: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F46%2F65%2F465990fe8f6b418ca3f9992bd1bb5465.png&width=768&dpr=4&quality=100&sign=72a003d1&sv=2) 图2 只加在非唯一索引上的锁 看到这个例子,你是不是有一种“该锁的不锁,不该锁的乱锁”的感觉?我们来分析一下吧。 这里session A要给索引c上c=5的这一行加上读锁。 1. 根据原则1,加锁单位是next-key lock,因此会给(0,5\]加上next-key lock。 2. 要注意c是普通索引,因此仅访问c=5这一条记录是不能马上停下来的,需要向右遍历,查到c=10才放弃。根据原则2,访问到的都要加锁,因此要给(5,10\]加next-key lock。 3. 但是同时这个符合优化2:等值判断,向右遍历,最后一个值不满足c=5这个等值条件,因此退化成间隙锁(5,10)。 4. 根据原则2 ,**只有访问到的对象才会加锁**,这个查询使用覆盖索引,并不需要访问主键索引,所以主键索引上没有加任何锁,这就是为什么session B的update语句可以执行完成。 但session C要插入一个(7,7,7)的记录,就会被session A的间隙锁(5,10)锁住。 需要注意,在这个例子中,lock in share mode只锁覆盖索引,但是如果是for update就不一样了。 执行 for update时,系统会认为你接下来要更新数据,因此会顺便给主键索引上满足条件的行加上行锁。 这个例子说明,锁是加在索引上的;同时,它给我们的指导是,如果你要用lock in share mode来给行加读锁避免数据被更新的话,就必须得绕过覆盖索引的优化,在查询字段中加入索引中不存在的字段。比如,将session A的查询语句改成select d from t where c=5 lock in share mode。你可以自己验证一下效果。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/21-wei-shi-mo-wo-zhi-gai-yi-hang-de-yu-ju-suo-zhe-mo-duo#an-li-san-zhu-jian-suo-yin-fan-wei-suo) 案例三:主键索引范围锁 第三个例子是关于范围查询的。 举例之前,你可以先思考一下这个问题:对于我们这个表t,下面这两条查询语句,加锁范围相同吗? Copy mysql> select * from t where id=10 for update; mysql> select * from t where id>=10 and id<11 for update; 你可能会想,id定义为int类型,这两个语句就是等价的吧?其实,它们并不完全等价。 在逻辑上,这两条查语句肯定是等价的,但是它们的加锁规则不太一样。现在,我们就让session A执行第二个查询语句,来看看加锁效果。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F30%2F80%2F30b839bf941f109b04f1a36c302aea80.png&width=768&dpr=4&quality=100&sign=2d7e3d72&sv=2) 图3 主键索引上范围查询的锁 现在我们就用前面提到的加锁规则,来分析一下session A 会加什么锁呢? 1. 开始执行的时候,要找到第一个id=10的行,因此本该是next-key lock(5,10\]。 根据优化1, 主键id上的等值条件,退化成行锁,只加了id=10这一行的行锁。 2. 范围查找就往后继续找,找到id=15这一行停下来,因此需要加next-key lock(10,15\]。 所以,session A这时候锁的范围就是主键索引上,行锁id=10和next-key lock(10,15\]。这样,session B和session C的结果你就能理解了。 这里你需要注意一点,首次session A定位查找id=10的行的时候,是当做等值查询来判断的,而向右扫描到id=15的时候,用的是范围查询判断。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/21-wei-shi-mo-wo-zhi-gai-yi-hang-de-yu-ju-suo-zhe-mo-duo#an-li-si-fei-wei-yi-suo-yin-fan-wei-suo) 案例四:非唯一索引范围锁 接下来,我们再看两个范围查询加锁的例子,你可以对照着案例三来看。 需要注意的是,与案例三不同的是,案例四中查询语句的where部分用的是字段c。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F73%2F7a%2F7381475e9e951628c9fc907f5a57697a.png&width=768&dpr=4&quality=100&sign=7d160395&sv=2) 图4 非唯一索引范围锁 这次session A用字段c来判断,加锁规则跟案例三唯一的不同是:在第一次用c=10定位记录的时候,索引c上加了(5,10\]这个next-key lock后,由于索引c是非唯一索引,没有优化规则,也就是说不会蜕变为行锁,因此最终sesion A加的锁是,索引c上的(5,10\] 和(10,15\] 这两个next-key lock。 所以从结果上来看,sesson B要插入(8,8,8)的这个insert语句时就被堵住了。 这里需要扫描到c=15才停止扫描,是合理的,因为InnoDB要扫到c=15,才知道不需要继续往后找了。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/21-wei-shi-mo-wo-zhi-gai-yi-hang-de-yu-ju-suo-zhe-mo-duo#an-li-wu-wei-yi-suo-yin-fan-wei-suo-bug) 案例五:唯一索引范围锁bug 前面的四个案例,我们已经用到了加锁规则中的两个原则和两个优化,接下来再看一个关于加锁规则中bug的案例。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fb1%2F6d%2Fb105f8c4633e8d3a84e6422b1b1a316d.png&width=768&dpr=4&quality=100&sign=aa6f1311&sv=2) 图5 唯一索引范围锁的bug session A是一个范围查询,按照原则1的话,应该是索引id上只加(10,15\]这个next-key lock,并且因为id是唯一键,所以循环判断到id=15这一行就应该停止了。 但是实现上,InnoDB会往前扫描到第一个不满足条件的行为止,也就是id=20。而且由于这是个范围扫描,因此索引id上的(15,20\]这个next-key lock也会被锁上。 所以你看到了,session B要更新id=20这一行,是会被锁住的。同样地,session C要插入id=16的一行,也会被锁住。 照理说,这里锁住id=20这一行的行为,其实是没有必要的。因为扫描到id=15,就可以确定不用往后再找了。但实现上还是这么做了,因此我认为这是个bug。 我也曾找社区的专家讨论过,官方bug系统上也有提到,但是并未被verified。所以,认为这是bug这个事儿,也只能算我的一家之言,如果你有其他见解的话,也欢迎你提出来。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/21-wei-shi-mo-wo-zhi-gai-yi-hang-de-yu-ju-suo-zhe-mo-duo#an-li-liu-fei-wei-yi-suo-yin-shang-cun-zai-deng-zhi-de-li-zi) 案例六:非唯一索引上存在"等值"的例子 接下来的例子,是为了更好地说明“间隙”这个概念。这里,我给表t插入一条新记录。 Copy mysql> insert into t values(30,10,30); 新插入的这一行c=10,也就是说现在表里有两个c=10的行。那么,这时候索引c上的间隙是什么状态了呢?你要知道,由于非唯一索引上包含主键的值,所以是不可能存在“相同”的两行的。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fc1%2F59%2Fc1fda36c1502606eb5be3908011ba159.png&width=768&dpr=4&quality=100&sign=3dd8e17e&sv=2) 图6 非唯一索引等值的例子 可以看到,虽然有两个c=10,但是它们的主键值id是不同的(分别是10和30),因此这两个c=10的记录之间,也是有间隙的。 图中我画出了索引c上的主键id。为了跟间隙锁的开区间形式进行区别,我用(c=10,id=30)这样的形式,来表示索引上的一行。 现在,我们来看一下案例六。 这次我们用delete语句来验证。注意,delete语句加锁的逻辑,其实跟select ... for update 是类似的,也就是我在文章开始总结的两个“原则”、两个“优化”和一个“bug”。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fb5%2F78%2Fb55fb0a1cac3500b60e1cf9779d2da78.png&width=768&dpr=4&quality=100&sign=57834852&sv=2) 图7 delete 示例 这时,session A在遍历的时候,先访问第一个c=10的记录。同样地,根据原则1,这里加的是(c=5,id=5)到(c=10,id=10)这个next-key lock。 然后,session A向右查找,直到碰到(c=15,id=15)这一行,循环才结束。根据优化2,这是一个等值查询,向右查找到了不满足条件的行,所以会退化成(c=10,id=10) 到 (c=15,id=15)的间隙锁。 也就是说,这个delete语句在索引c上的加锁范围,就是下图中蓝色区域覆盖的部分。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fbb%2F24%2Fbb0ad92483d71f0dcaeeef278f89cb24.png&width=300&dpr=4&quality=100&sign=d236820f&sv=2) 图8 delete加锁效果示例 这个蓝色区域左右两边都是虚线,表示开区间,即(c=5,id=5)和(c=15,id=15)这两行上都没有锁。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/21-wei-shi-mo-wo-zhi-gai-yi-hang-de-yu-ju-suo-zhe-mo-duo#an-li-qi-limit-yu-ju-jia-suo) 案例七:limit 语句加锁 例子6也有一个对照案例,场景如下所示: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Faf%2F2e%2Fafc3a08ae7a254b3251e41b2a6dae02e.png&width=768&dpr=4&quality=100&sign=a409e080&sv=2) 图9 limit 语句加锁 这个例子里,session A的delete语句加了 limit 2。你知道表t里c=10的记录其实只有两条,因此加不加limit 2,删除的效果都是一样的,但是加锁的效果却不同。可以看到,session B的insert语句执行通过了,跟案例六的结果不同。 这是因为,案例七里的delete语句明确加了limit 2的限制,因此在遍历到(c=10, id=30)这一行之后,满足条件的语句已经有两条,循环就结束了。 因此,索引c上的加锁范围就变成了从(c=5,id=5)到(c=10,id=30)这个前开后闭区间,如下图所示: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fe5%2Fd5%2Fe5408ed94b3d44985073255db63bd0d5.png&width=300&dpr=4&quality=100&sign=4b4f04ce&sv=2) 图10 带limit 2的加锁效果 可以看到,(c=10,id=30)之后的这个间隙并没有在加锁范围里,因此insert语句插入c=12是可以执行成功的。 这个例子对我们实践的指导意义就是,**在删除数据的时候尽量加limit**。这样不仅可以控制删除数据的条数,让操作更安全,还可以减小加锁的范围。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/21-wei-shi-mo-wo-zhi-gai-yi-hang-de-yu-ju-suo-zhe-mo-duo#an-li-ba-yi-ge-si-suo-de-li-zi) 案例八:一个死锁的例子 前面的例子中,我们在分析的时候,是按照next-key lock的逻辑来分析的,因为这样分析比较方便。最后我们再看一个案例,目的是说明:next-key lock实际上是间隙锁和行锁加起来的结果。 你一定会疑惑,这个概念不是一开始就说了吗?不要着急,我们先来看下面这个例子: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F7b%2F06%2F7b911a4c995706e8aa2dd96ff0f36506.png&width=768&dpr=4&quality=100&sign=bcce2e63&sv=2) 图11 案例八的操作序列 现在,我们按时间顺序来分析一下为什么是这样的结果。 1. session A 启动事务后执行查询语句加lock in share mode,在索引c上加了next-key lock(5,10\] 和间隙锁(10,15); 2. session B 的update语句也要在索引c上加next-key lock(5,10\] ,进入锁等待; 3. 然后session A要再插入(8,8,8)这一行,被session B的间隙锁锁住。由于出现了死锁,InnoDB让session B回滚。 你可能会问,session B的next-key lock不是还没申请成功吗? 其实是这样的,session B的“加next-key lock(5,10\] ”操作,实际上分成了两步,先是加(5,10)的间隙锁,加锁成功;然后加c=10的行锁,这时候才被锁住的。 也就是说,我们在分析加锁规则的时候可以用next-key lock来分析。但是要知道,具体执行的时候,是要分成间隙锁和行锁两段来执行的。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/21-wei-shi-mo-wo-zhi-gai-yi-hang-de-yu-ju-suo-zhe-mo-duo#xiao-jie) 小结 这里我再次说明一下,我们上面的所有案例都是在可重复读隔离级别(repeatable-read)下验证的。同时,可重复读隔离级别遵守两阶段锁协议,所有加锁的资源,都是在事务提交或者回滚的时候才释放的。 在最后的案例中,你可以清楚地知道next-key lock实际上是由间隙锁加行锁实现的。如果切换到读提交隔离级别(read-committed)的话,就好理解了,过程中去掉间隙锁的部分,也就是只剩下行锁的部分。 其实读提交隔离级别在外键场景下还是有间隙锁,相对比较复杂,我们今天先不展开。 另外,在读提交隔离级别下还有一个优化,即:语句执行过程中加上的行锁,在语句执行完成后,就要把“不满足条件的行”上的行锁直接释放了,不需要等到事务提交。 也就是说,读提交隔离级别下,锁的范围更小,锁的时间更短,这也是不少业务都默认使用读提交隔离级别的原因。 不过,我希望你学过今天的课程以后,可以对next-key lock的概念有更清晰的认识,并且会用加锁规则去判断语句的加锁范围。 在业务需要使用可重复读隔离级别的时候,能够更细致地设计操作数据库的语句,解决幻读问题的同时,最大限度地提升系统并行处理事务的能力。 经过这篇文章的介绍,你再看一下上一篇文章最后的思考题,再来尝试分析一次。 我把题目重新描述和简化一下:还是我们在文章开头初始化的表t,里面有6条记录,图12的语句序列中,为什么session B的insert操作,会被锁住呢? ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F3a%2F1e%2F3a7578e104612a188a2d574eaa3bd81e.png&width=300&dpr=4&quality=100&sign=be42e5f9&sv=2) 图12 锁分析思考题 另外,如果你有兴趣多做一些实验的话,可以设计好语句序列,在执行之前先自己分析一下,然后实际地验证结果是否跟你的分析一致。 对于那些你自己无法解释的结果,可以发到评论区里,后面我争取挑一些有趣的案例在文章中分析。 你可以把你关于思考题的分析写在留言区,也可以分享你自己设计的锁验证方案,我会在下一篇文章的末尾选取有趣的评论跟大家分享。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/21-wei-shi-mo-wo-zhi-gai-yi-hang-de-yu-ju-suo-zhe-mo-duo#shang-qi-wen-ti-shi-jian) 上期问题时间 上期的问题,我在本期继续作为了课后思考题,所以会在下篇文章再一起公布“答案”。 这里,我展开回答一下评论区几位同学的问题。 * @令狐少侠 说,以前一直认为间隙锁只在二级索引上有。现在你知道了,有间隙的地方就可能有间隙锁。 * @浪里白条 同学问,如果是varchar类型,加锁规则是什么样的。 回答:实际上在判断间隙的时候,varchar和int是一样的,排好序以后,相邻两个值之间就有间隙。 * 有几位同学提到说,上一篇文章自己验证的结果跟案例一不同,就是在session A执行完这两个语句: Copy begin; select * from t where d=5 for update; /*Q1*/ 以后,session B 的update 和session C的insert 都会被堵住。这是不是跟文章的结论矛盾? 其实不是的,这个例子用的是反证假设,就是假设不堵住,会出现问题;然后,推导出session A需要锁整个表所有的行和所有间隙。 评论区留言点赞板: > @ 某、人 、@郭江伟 两位同学尝试分析了上期问题,并给了有启发性的解答。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F09%2F77%2F09c1073f99cf71d2fb162a716b5fa577.jpg&width=768&dpr=4&quality=100&sign=e5471b04&sv=2) [Previous20 幻读是什么,幻读有什么问题?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/20-huan-du-shi-shi-mo-huan-du-you-shi-mo-wen-ti) [Next22 MySQL有哪些“饮鸩止渴”提高性能的方法?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/22-mysql-you-na-xie-yin-zhen-zhi-ke-ti-gao-xing-neng-de-fang-fa) Last updated 2 years ago Was this helpful? --- # 22 MySQL有哪些“饮鸩止渴”提高性能的方法? | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252FH9WgmfrLxiMWB9m4Um4P%252Fimage.png%3Falt%3Dmedia%26token%3D84a69476-a6d2-4216-bbe8-a34124334367&width=768&dpr=4&quality=100&sign=ebacedc5&sv=2) 不知道你在实际运维过程中有没有碰到这样的情景:业务高峰期,生产环境的MySQL压力太大,没法正常响应,需要短期内、临时性地提升一些性能。 我以前做业务护航的时候,就偶尔会碰上这种场景。用户的开发负责人说,不管你用什么方案,让业务先跑起来再说。 但,如果是无损方案的话,肯定不需要等到这个时候才上场。今天我们就来聊聊这些临时方案,并着重说一说它们可能存在的风险。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/22-mysql-you-na-xie-yin-zhen-zhi-ke-ti-gao-xing-neng-de-fang-fa#duan-lian-jie-feng-bao) 短连接风暴 正常的短连接模式就是连接到数据库后,执行很少的SQL语句就断开,下次需要的时候再重连。如果使用的是短连接,在业务高峰期的时候,就可能出现连接数突然暴涨的情况。 我在第1篇文章[《基础架构:一条SQL查询语句是如何执行的?》](https://time.geekbang.org/column/article/68319) 中说过,MySQL建立连接的过程,成本是很高的。除了正常的网络连接三次握手外,还需要做登录权限判断和获得这个连接的数据读写权限。 在数据库压力比较小的时候,这些额外的成本并不明显。 但是,短连接模型存在一个风险,就是一旦数据库处理得慢一些,连接数就会暴涨。max\_connections参数,用来控制一个MySQL实例同时存在的连接数的上限,超过这个值,系统就会拒绝接下来的连接请求,并报错提示“Too many connections”。对于被拒绝连接的请求来说,从业务角度看就是数据库不可用。 在机器负载比较高的时候,处理现有请求的时间变长,每个连接保持的时间也更长。这时,再有新建连接的话,就可能会超过max\_connections的限制。 碰到这种情况时,一个比较自然的想法,就是调高max\_connections的值。但这样做是有风险的。因为设计max\_connections这个参数的目的是想保护MySQL,如果我们把它改得太大,让更多的连接都可以进来,那么系统的负载可能会进一步加大,大量的资源耗费在权限验证等逻辑上,结果可能是适得其反,已经连接的线程拿不到CPU资源去执行业务的SQL请求。 那么这种情况下,你还有没有别的建议呢?我这里还有两种方法,但要注意,这些方法都是有损的。 **第一种方法:先处理掉那些占着连接但是不工作的线程。** max\_connections的计算,不是看谁在running,是只要连着就占用一个计数位置。对于那些不需要保持的连接,我们可以通过kill connection主动踢掉。这个行为跟事先设置wait\_timeout的效果是一样的。设置wait\_timeout参数表示的是,一个线程空闲wait\_timeout这么多秒之后,就会被MySQL直接断开连接。 但是需要注意,在show processlist的结果里,踢掉显示为sleep的线程,可能是有损的。我们来看下面这个例子。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F90%2F2a%2F9091ff280592c8c68665771b1516c62a.png&width=768&dpr=4&quality=100&sign=b19db090&sv=2) 图1 sleep线程的两种状态 在上面这个例子里,如果断开session A的连接,因为这时候session A还没有提交,所以MySQL只能按照回滚事务来处理;而断开session B的连接,就没什么大影响。所以,如果按照优先级来说,你应该优先断开像session B这样的事务外空闲的连接。 但是,怎么判断哪些是事务外空闲的呢?session C在T时刻之后的30秒执行show processlist,看到的结果是这样的。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fae%2F25%2Fae6a9ceecf8517e47f9ebfc565f0f925.png&width=768&dpr=4&quality=100&sign=c993570f&sv=2) 图2 sleep线程的两种状态,show processlist结果 图中id=4和id=5的两个会话都是Sleep 状态。而要看事务具体状态的话,你可以查information\_schema库的innodb\_trx表。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fca%2Fe8%2Fca4b455c8eacbf32b98d1fe9ed9876e8.png&width=768&dpr=4&quality=100&sign=2cc4482a&sv=2) 图3 从information\_schema.innodb\_trx查询事务状态 这个结果里,trx\_mysql\_thread\_id=4,表示id=4的线程还处在事务中。 因此,如果是连接数过多,你可以优先断开事务外空闲太久的连接;如果这样还不够,再考虑断开事务内空闲太久的连接。 从服务端断开连接使用的是kill connection + id的命令, 一个客户端处于sleep状态时,它的连接被服务端主动断开后,这个客户端并不会马上知道。直到客户端在发起下一个请求的时候,才会收到这样的报错“ERROR 2013 (HY000): Lost connection to MySQL server during query”。 从数据库端主动断开连接可能是有损的,尤其是有的应用端收到这个错误后,不重新连接,而是直接用这个已经不能用的句柄重试查询。这会导致从应用端看上去,“MySQL一直没恢复”。 你可能觉得这是一个冷笑话,但实际上我碰到过不下10次。 所以,如果你是一个支持业务的DBA,不要假设所有的应用代码都会被正确地处理。即使只是一个断开连接的操作,也要确保通知到业务开发团队。 **第二种方法:减少连接过程的消耗。** 有的业务代码会在短时间内先大量申请数据库连接做备用,如果现在数据库确认是被连接行为打挂了,那么一种可能的做法,是让数据库跳过权限验证阶段。 跳过权限验证的方法是:重启数据库,并使用–skip-grant-tables参数启动。这样,整个MySQL会跳过所有的权限验证阶段,包括连接过程和语句执行过程在内。 但是,这种方法特别符合我们标题里说的“饮鸩止渴”,风险极高,是我特别不建议使用的方案。尤其你的库外网可访问的话,就更不能这么做了。 在MySQL 8.0版本里,如果你启用–skip-grant-tables参数,MySQL会默认把 --skip-networking参数打开,表示这时候数据库只能被本地的客户端连接。可见,MySQL官方对skip-grant-tables这个参数的安全问题也很重视。 除了短连接数暴增可能会带来性能问题外,实际上,我们在线上碰到更多的是查询或者更新语句导致的性能问题。其中,查询问题比较典型的有两类,一类是由新出现的慢查询导致的,一类是由QPS(每秒查询数)突增导致的。而关于更新语句导致的性能问题,我会在下一篇文章和你展开说明。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/22-mysql-you-na-xie-yin-zhen-zhi-ke-ti-gao-xing-neng-de-fang-fa#man-cha-xun-xing-neng-wen-ti) 慢查询性能问题 在MySQL中,会引发性能问题的慢查询,大体有以下三种可能: 1. 索引没有设计好; 2. SQL语句没写好; 3. MySQL选错了索引。 接下来,我们就具体分析一下这三种可能,以及对应的解决方案。 **导致慢查询的第一种可能是,索引没有设计好。** 这种场景一般就是通过紧急创建索引来解决。MySQL 5.6版本以后,创建索引都支持Online DDL了,对于那种高峰期数据库已经被这个语句打挂了的情况,最高效的做法就是直接执行alter table 语句。 比较理想的是能够在备库先执行。假设你现在的服务是一主一备,主库A、备库B,这个方案的大致流程是这样的: 1. 在备库B上执行 set sql\_log\_bin=off,也就是不写binlog,然后执行alter table 语句加上索引; 2. 执行主备切换; 3. 这时候主库是B,备库是A。在A上执行 set sql\_log\_bin=off,然后执行alter table 语句加上索引。 这是一个“古老”的DDL方案。平时在做变更的时候,你应该考虑类似gh-ost这样的方案,更加稳妥。但是在需要紧急处理时,上面这个方案的效率是最高的。 **导致慢查询的第二种可能是,语句没写好。** 比如,我们犯了在第18篇文章[《为什么这些SQL语句逻辑相同,性能却差异巨大?》](https://time.geekbang.org/column/article/74059) 中提到的那些错误,导致语句没有使用上索引。 这时,我们可以通过改写SQL语句来处理。MySQL 5.7提供了query\_rewrite功能,可以把输入的一种语句改写成另外一种模式。 比如,语句被错误地写成了 select \* from t where id + 1 = 10000,你可以通过下面的方式,增加一个语句改写规则。 Copy mysql> insert into query_rewrite.rewrite_rules(pattern, replacement, pattern_database) values ("select * from t where id + 1 = ?", "select * from t where id = ? - 1", "db1"); call query_rewrite.flush_rewrite_rules(); 这里,call query\_rewrite.flush\_rewrite\_rules()这个存储过程,是让插入的新规则生效,也就是我们说的“查询重写”。你可以用图4中的方法来确认改写规则是否生效。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F47%2F8a%2F47a1002cbc4c05c74841591d20f7388a.png&width=768&dpr=4&quality=100&sign=9b81ad28&sv=2) 图4 查询重写效果 **导致慢查询的第三种可能,就是碰上了我们在第10篇文章**[**《MySQL为什么有时候会选错索引?》**](https://time.geekbang.org/column/article/71173) **中提到的情况,MySQL选错了索引。** 这时候,应急方案就是给这个语句加上force index。 同样地,使用查询重写功能,给原来的语句加上force index,也可以解决这个问题。 上面我和你讨论的由慢查询导致性能问题的三种可能情况,实际上出现最多的是前两种,即:索引没设计好和语句没写好。而这两种情况,恰恰是完全可以避免的。比如,通过下面这个过程,我们就可以预先发现问题。 1. 上线前,在测试环境,把慢查询日志(slow log)打开,并且把long\_query\_time设置成0,确保每个语句都会被记录入慢查询日志; 2. 在测试表里插入模拟线上的数据,做一遍回归测试; 3. 观察慢查询日志里每类语句的输出,特别留意Rows\_examined字段是否与预期一致。(我们在前面文章中已经多次用到过Rows\_examined方法了,相信你已经动手尝试过了。如果还有不明白的,欢迎给我留言,我们一起讨论)。 不要吝啬这段花在上线前的“额外”时间,因为这会帮你省下很多故障复盘的时间。 如果新增的SQL语句不多,手动跑一下就可以。而如果是新项目的话,或者是修改了原有项目的 表结构设计,全量回归测试都是必要的。这时候,你需要工具帮你检查所有的SQL语句的返回结果。比如,你可以使用开源工具pt-query-digest([https://www.percona.com/doc/percona-toolkit/3.0/pt-query-digest.html](https://www.percona.com/doc/percona-toolkit/3.0/pt-query-digest.html) )。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/22-mysql-you-na-xie-yin-zhen-zhi-ke-ti-gao-xing-neng-de-fang-fa#qps-tu-zeng-wen-ti) QPS突增问题 有时候由于业务突然出现高峰,或者应用程序bug,导致某个语句的QPS突然暴涨,也可能导致MySQL压力过大,影响服务。 我之前碰到过一类情况,是由一个新功能的bug导致的。当然,最理想的情况是让业务把这个功能下掉,服务自然就会恢复。 而下掉一个功能,如果从数据库端处理的话,对应于不同的背景,有不同的方法可用。我这里再和你展开说明一下。 1. 一种是由全新业务的bug导致的。假设你的DB运维是比较规范的,也就是说白名单是一个个加的。这种情况下,如果你能够确定业务方会下掉这个功能,只是时间上没那么快,那么就可以从数据库端直接把白名单去掉。 2. 如果这个新功能使用的是单独的数据库用户,可以用管理员账号把这个用户删掉,然后断开现有连接。这样,这个新功能的连接不成功,由它引发的QPS就会变成0。 3. 如果这个新增的功能跟主体功能是部署在一起的,那么我们只能通过处理语句来限制。这时,我们可以使用上面提到的查询重写功能,把压力最大的SQL语句直接重写成"select 1"返回。 当然,这个操作的风险很高,需要你特别细致。它可能存在两个副作用: 1. 如果别的功能里面也用到了这个SQL语句模板,会有误伤; 2. 很多业务并不是靠这一个语句就能完成逻辑的,所以如果单独把这一个语句以select 1的结果返回的话,可能会导致后面的业务逻辑一起失败。 所以,方案3是用于止血的,跟前面提到的去掉权限验证一样,应该是你所有选项里优先级最低的一个方案。 同时你会发现,其实方案1和2都要依赖于规范的运维体系:虚拟化、白名单机制、业务账号分离。由此可见,更多的准备,往往意味着更稳定的系统。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/22-mysql-you-na-xie-yin-zhen-zhi-ke-ti-gao-xing-neng-de-fang-fa#xiao-jie) 小结 今天这篇文章,我以业务高峰期的性能问题为背景,和你介绍了一些紧急处理的手段。 这些处理手段中,既包括了粗暴地拒绝连接和断开连接,也有通过重写语句来绕过一些坑的方法;既有临时的高危方案,也有未雨绸缪的、相对安全的预案。 在实际开发中,我们也要尽量避免一些低效的方法,比如避免大量地使用短连接。同时,如果你做业务开发的话,要知道,连接异常断开是常有的事,你的代码里要有正确地重连并重试的机制。 DBA虽然可以通过语句重写来暂时处理问题,但是这本身是一个风险高的操作,做好SQL审计可以减少需要这类操作的机会。 其实,你可以看得出来,在这篇文章中我提到的解决方法主要集中在server层。在下一篇文章中,我会继续和你讨论一些跟InnoDB有关的处理方法。 最后,又到了我们的思考题时间了。 今天,我留给你的课后问题是,你是否碰到过,在业务高峰期需要临时救火的场景?你又是怎么处理的呢? 你可以把你的经历和经验写在留言区,我会在下一篇文章的末尾选取有趣的评论跟大家一起分享和分析。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/22-mysql-you-na-xie-yin-zhen-zhi-ke-ti-gao-xing-neng-de-fang-fa#shang-qi-wen-ti-shi-jian) 上期问题时间 前两期我给你留的问题是,下面这个图的执行序列中,为什么session B的insert语句会被堵住。 我们用上一篇的加锁规则来分析一下,看看session A的select语句加了哪些锁: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F3a%2F1e%2F3a7578e104612a188a2d574eaa3bd81e.png&width=768&dpr=4&quality=100&sign=be42e5f9&sv=2) 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两个行锁。 这里,我再啰嗦下,你会发现我在文章中,每次加锁都会说明是加在“哪个索引上”的。因为,锁就是加在索引上的,这是InnoDB的一个基础设定,需要你在分析问题的时候要一直记得。 评论区留言点赞板: > @HuaMax 给出了正确的解释。 > @Justin 同学提了个好问题,<=到底是间隙锁还是行锁?其实,这个问题,你要跟“执行过程”配合起来分析。在InnoDB要去找“第一个值”的时候,是按照等值去找的,用的是等值判断的规则;找到第一个值以后,要在索引内找“下一个值”,对应于我们规则中说的范围查找。 > @信信 提了一个不错的问题,要知道最终的加锁是根据实际执行情况来的。所以,如果一个select \* from … for update 语句,优化器决定使用全表扫描,那么就会把主键索引上next-key lock全加上。 > @nero 同学的问题,提示我需要提醒大家注意,“有行”才会加行锁。如果查询条件没有命中行,那就加next-key lock。当然,等值判断的时候,需要加上优化2(即:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock退化为间隙锁。)。 > @小李子、@发条橙子同学,都提了很好的问题,这期高质量评论很多,你也都可以去看看。 最后,我要为元旦期间还坚持学习的同学们,点个赞 ^\_^ ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F09%2F77%2F09c1073f99cf71d2fb162a716b5fa577.jpg&width=768&dpr=4&quality=100&sign=e5471b04&sv=2) [Previous21 为什么我只改一行的语句,锁这么多?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/21-wei-shi-mo-wo-zhi-gai-yi-hang-de-yu-ju-suo-zhe-mo-duo) [Next23 MySQL是怎么保证数据不丢的?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/23-mysql-shi-zen-mo-bao-zheng-shu-ju-bu-diu-de) Last updated 2 years ago Was this helpful? --- # 24 MySQL是怎么保证主备一致的? | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252FO2eiYEom65XqIv3tjhjN%252Fimage.png%3Falt%3Dmedia%26token%3Deb765945-a235-488e-b63e-192a3c82c471&width=768&dpr=4&quality=100&sign=ca0a10d7&sv=2) 在前面的文章中,我不止一次地和你提到了binlog,大家知道binlog可以用来归档,也可以用来做主备同步,但它的内容是什么样的呢?为什么备库执行了binlog就可以跟主库保持一致了呢?今天我就正式地和你介绍一下它。 毫不夸张地说,MySQL能够成为现下最流行的开源数据库,binlog功不可没。 在最开始,MySQL是以容易学习和方便的高可用架构,被开发人员青睐的。而它的几乎所有的高可用架构,都直接依赖于binlog。虽然这些高可用架构已经呈现出越来越复杂的趋势,但都是从最基本的一主一备演化过来的。 今天这篇文章我主要为你介绍主备的基本原理。理解了背后的设计原理,你也可以从业务开发的角度,来借鉴这些设计思想。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/24-mysql-shi-zen-mo-bao-zheng-zhu-bei-yi-zhi-de#mysql-zhu-bei-de-ji-ben-yuan-li) MySQL主备的基本原理 如图1所示就是基本的主备切换流程。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Ffd%2F10%2Ffd75a2b37ae6ca709b7f16fe060c2c10.png&width=768&dpr=4&quality=100&sign=7edf94dc&sv=2) 图 1 MySQL主备切换流程 在状态1中,客户端的读写都直接访问节点A,而节点B是A的备库,只是将A的更新都同步过来,到本地执行。这样可以保持节点B和A的数据是相同的。 当需要切换的时候,就切成状态2。这时候客户端读写访问的都是节点B,而节点A是B的备库。 在状态1中,虽然节点B没有被直接访问,但是我依然建议你把节点B(也就是备库)设置成只读(readonly)模式。这样做,有以下几个考虑: 1. 有时候一些运营类的查询语句会被放到备库上去查,设置为只读可以防止误操作; 2. 防止切换逻辑有bug,比如切换过程中出现双写,造成主备不一致; 3. 可以用readonly状态,来判断节点的角色。 你可能会问,我把备库设置成只读了,还怎么跟主库保持同步更新呢? 这个问题,你不用担心。因为readonly设置对超级(super)权限用户是无效的,而用于同步更新的线程,就拥有超级权限。 接下来,我们再看看**节点A到B这条线的内部流程是什么样的**。图2中画出的就是一个update语句在节点A执行,然后同步到节点B的完整流程图。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fa6%2Fa3%2Fa66c154c1bc51e071dd2cc8c1d6ca6a3.png&width=768&dpr=4&quality=100&sign=c185aef9&sv=2) 图2 主备流程图 图2中,包含了我在上一篇文章中讲到的binlog和redo log的写入机制相关的内容,可以看到:主库接收到客户端的更新请求后,执行内部事务的更新逻辑,同时写binlog。 备库B跟主库A之间维持了一个长连接。主库A内部有一个线程,专门用于服务备库B的这个长连接。一个事务日志同步的完整过程是这样的: 1. 在备库B上通过change master命令,设置主库A的IP、端口、用户名、密码,以及要从哪个位置开始请求binlog,这个位置包含文件名和日志偏移量。 2. 在备库B上执行start slave命令,这时候备库会启动两个线程,就是图中的io\_thread和sql\_thread。其中io\_thread负责与主库建立连接。 3. 主库A校验完用户名、密码后,开始按照备库B传过来的位置,从本地读取binlog,发给B。 4. 备库B拿到binlog后,写到本地文件,称为中转日志(relay log)。 5. sql\_thread读取中转日志,解析出日志里的命令,并执行。 这里需要说明,后来由于多线程复制方案的引入,sql\_thread演化成为了多个线程,跟我们今天要介绍的原理没有直接关系,暂且不展开。 分析完了这个长连接的逻辑,我们再来看一个问题:binlog里面到底是什么内容,为什么备库拿过去可以直接执行。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/24-mysql-shi-zen-mo-bao-zheng-zhu-bei-yi-zhi-de#binlog-de-san-zhong-ge-shi-dui-bi) binlog的三种格式对比 我在[第15篇答疑文章](https://time.geekbang.org/column/article/73161) 中,和你提到过binlog有两种格式,一种是statement,一种是row。可能你在其他资料上还会看到有第三种格式,叫作mixed,其实它就是前两种格式的混合。 为了便于描述binlog的这三种格式间的区别,我创建了一个表,并初始化几行数据。 Copy mysql> CREATE TABLE `t` ( `id` int(11) NOT NULL, `a` int(11) DEFAULT NULL, `t_modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `a` (`a`), KEY `t_modified`(`t_modified`) ) ENGINE=InnoDB; insert into t values(1,1,'2018-11-13'); insert into t values(2,2,'2018-11-12'); insert into t values(3,3,'2018-11-11'); insert into t values(4,4,'2018-11-10'); insert into t values(5,5,'2018-11-09'); 如果要在表中删除一行数据的话,我们来看看这个delete语句的binlog是怎么记录的。 注意,下面这个语句包含注释,如果你用MySQL客户端来做这个实验的话,要记得加-c参数,否则客户端会自动去掉注释。 Copy mysql> delete from t /*comment*/ where a>=4 and t_modified<='2018-11-10' limit 1; 当binlog\_format=statement时,binlog里面记录的就是SQL语句的原文。你可以用 Copy mysql> show binlog events in 'master.000001'; 命令看binlog中的内容。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fb9%2F31%2Fb9818f73cd7d38a96ddcb75350b52931.png&width=768&dpr=4&quality=100&sign=ed3d3e6f&sv=2) 图3 statement格式binlog 示例 现在,我们来看一下图3的输出结果。 * 第一行SET @@SESSION.GTID\_NEXT='ANONYMOUS’你可以先忽略,后面文章我们会在介绍主备切换的时候再提到; * 第二行是一个BEGIN,跟第四行的commit对应,表示中间是一个事务; * 第三行就是真实执行的语句了。可以看到,在真实执行的delete命令之前,还有一个“use ‘test’”命令。这条命令不是我们主动执行的,而是MySQL根据当前要操作的表所在的数据库,自行添加的。这样做可以保证日志传到备库去执行的时候,不论当前的工作线程在哪个库里,都能够正确地更新到test库的表t。 use 'test’命令之后的delete 语句,就是我们输入的SQL原文了。可以看到,binlog“忠实”地记录了SQL命令,甚至连注释也一并记录了。 * 最后一行是一个COMMIT。你可以看到里面写着xid=61。你还记得这个XID是做什么用的吗?如果记忆模糊了,可以再回顾一下[第15篇文章](https://time.geekbang.org/column/article/73161) 中的相关内容。 为了说明statement 和 row格式的区别,我们来看一下这条delete命令的执行效果图: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F96%2F2b%2F96c2be9c0fcbff66883118526b26652b.png&width=768&dpr=4&quality=100&sign=95e55032&sv=2) 图4 delete执行warnings 可以看到,运行这条delete命令产生了一个warning,原因是当前binlog设置的是statement格式,并且语句中有limit,所以这个命令可能是unsafe的。 为什么这么说呢?这是因为delete 带limit,很可能会出现主备数据不一致的情况。比如上面这个例子: 1. 如果delete语句使用的是索引a,那么会根据索引a找到第一个满足条件的行,也就是说删除的是a=4这一行; 2. 但如果使用的是索引t\_modified,那么删除的就是 t\_modified='2018-11-09’也就是a=5这一行。 由于statement格式下,记录到binlog里的是语句原文,因此可能会出现这样一种情况:在主库执行这条SQL语句的时候,用的是索引a;而在备库执行这条SQL语句的时候,却使用了索引t\_modified。因此,MySQL认为这样写是有风险的。 那么,如果我把binlog的格式改为binlog\_format=‘row’, 是不是就没有这个问题了呢?我们先来看看这时候binog中的内容吧。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fd6%2F26%2Fd67a38db154afff610ae3bb64e266826.png&width=768&dpr=4&quality=100&sign=57cf9b1b&sv=2) 图5 row格式binlog 示例 可以看到,与statement格式的binlog相比,前后的BEGIN和COMMIT是一样的。但是,row格式的binlog里没有了SQL语句的原文,而是替换成了两个event:Table\_map和Delete\_rows。 1. Table\_map event,用于说明接下来要操作的表是test库的表t; 2. Delete\_rows event,用于定义删除的行为。 其实,我们通过图5是看不到详细信息的,还需要借助mysqlbinlog工具,用下面这个命令解析和查看binlog中的内容。因为图5中的信息显示,这个事务的binlog是从8900这个位置开始的,所以可以用start-position参数来指定从这个位置的日志开始解析。 Copy mysqlbinlog -vv data/master.000001 --start-position=8900; ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fc3%2Fc2%2Fc342cf480d23b05d30a294b114cebfc2.png&width=768&dpr=4&quality=100&sign=a49e1dfe&sv=2) 图6 row格式binlog 示例的详细信息 从这个图中,我们可以看到以下几个信息: * server id 1,表示这个事务是在server\_id=1的这个库上执行的。 * 每个event都有CRC32的值,这是因为我把参数binlog\_checksum设置成了CRC32。 * Table\_map event跟在图5中看到的相同,显示了接下来要打开的表,map到数字226。现在我们这条SQL语句只操作了一张表,如果要操作多张表呢?每个表都有一个对应的Table\_map event、都会map到一个单独的数字,用于区分对不同表的操作。 * 我们在mysqlbinlog的命令中,使用了-vv参数是为了把内容都解析出来,所以从结果里面可以看到各个字段的值(比如,@1=4、 @2=4这些值)。 * binlog\_row\_image的默认配置是FULL,因此Delete\_event里面,包含了删掉的行的所有字段的值。如果把binlog\_row\_image设置为MINIMAL,则只会记录必要的信息,在这个例子里,就是只会记录id=4这个信息。 * 最后的Xid event,用于表示事务被正确地提交了。 你可以看到,当binlog\_format使用row格式的时候,binlog里面记录了真实删除行的主键id,这样binlog传到备库去的时候,就肯定会删除id=4的行,不会有主备删除不同行的问题。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/24-mysql-shi-zen-mo-bao-zheng-zhu-bei-yi-zhi-de#wei-shen-me-hui-you-mixed-ge-shi-de-binlog) 为什么会有mixed格式的binlog? 基于上面的信息,我们来讨论一个问题:**为什么会有mixed这种binlog格式的存在场景?**推论过程是这样的: * 因为有些statement格式的binlog可能会导致主备不一致,所以要使用row格式。 * 但row格式的缺点是,很占空间。比如你用一个delete语句删掉10万行数据,用statement的话就是一个SQL语句被记录到binlog中,占用几十个字节的空间。但如果用row格式的binlog,就要把这10万条记录都写到binlog中。这样做,不仅会占用更大的空间,同时写binlog也要耗费IO资源,影响执行速度。 * 所以,MySQL就取了个折中方案,也就是有了mixed格式的binlog。mixed格式的意思是,MySQL自己会判断这条SQL语句是否可能引起主备不一致,如果有可能,就用row格式,否则就用statement格式。 也就是说,mixed格式可以利用statment格式的优点,同时又避免了数据不一致的风险。 因此,如果你的线上MySQL设置的binlog格式是statement的话,那基本上就可以认为这是一个不合理的设置。你至少应该把binlog的格式设置为mixed。 比如我们这个例子,设置为mixed后,就会记录为row格式;而如果执行的语句去掉limit 1,就会记录为statement格式。 当然我要说的是,现在越来越多的场景要求把MySQL的binlog格式设置成row。这么做的理由有很多,我来给你举一个可以直接看出来的好处:**恢复数据**。 接下来,我们就分别从delete、insert和update这三种SQL语句的角度,来看看数据恢复的问题。 通过图6你可以看出来,即使我执行的是delete语句,row格式的binlog也会把被删掉的行的整行信息保存起来。所以,如果你在执行完一条delete语句以后,发现删错数据了,可以直接把binlog中记录的delete语句转成insert,把被错删的数据插入回去就可以恢复了。 如果你是执行错了insert语句呢?那就更直接了。row格式下,insert语句的binlog里会记录所有的字段信息,这些信息可以用来精确定位刚刚被插入的那一行。这时,你直接把insert语句转成delete语句,删除掉这被误插入的一行数据就可以了。 如果执行的是update语句的话,binlog里面会记录修改前整行的数据和修改后的整行数据。所以,如果你误执行了update语句的话,只需要把这个event前后的两行信息对调一下,再去数据库里面执行,就能恢复这个更新操作了。 其实,由delete、insert或者update语句导致的数据操作错误,需要恢复到操作之前状态的情况,也时有发生。MariaDB的[Flashback](https://mariadb.com/kb/en/library/flashback/) 工具就是基于上面介绍的原理来回滚数据的。 虽然mixed格式的binlog现在已经用得不多了,但这里我还是要再借用一下mixed格式来说明一个问题,来看一下这条SQL语句: Copy mysql> insert into t values(10,10, now()); 如果我们把binlog格式设置为mixed,你觉得MySQL会把它记录为row格式还是statement格式呢? 先不要着急说结果,我们一起来看一下这条语句执行的效果。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F01%2Fef%2F0150301698979255a6f27711c35e9eef.png&width=768&dpr=4&quality=100&sign=78353898&sv=2) 图7 mixed格式和now() 可以看到,MySQL用的居然是statement格式。你一定会奇怪,如果这个binlog过了1分钟才传给备库的话,那主备的数据不就不一致了吗? 接下来,我们再用mysqlbinlog工具来看看: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F1a%2F41%2F1ad3a4c4b9a71955edba5195757dd041.png&width=768&dpr=4&quality=100&sign=b8a150d3&sv=2) 图8 TIMESTAMP 命令 从图中的结果可以看到,原来binlog在记录event的时候,多记了一条命令:SET TIMESTAMP=1546103491。它用 SET TIMESTAMP命令约定了接下来的now()函数的返回时间。 因此,不论这个binlog是1分钟之后被备库执行,还是3天后用来恢复这个库的备份,这个insert语句插入的行,值都是固定的。也就是说,通过这条SET TIMESTAMP命令,MySQL就确保了主备数据的一致性。 我之前看过有人在重放binlog数据的时候,是这么做的:用mysqlbinlog解析出日志,然后把里面的statement语句直接拷贝出来执行。 你现在知道了,这个方法是有风险的。因为有些语句的执行结果是依赖于上下文命令的,直接执行的结果很可能是错误的。 所以,用binlog来恢复数据的标准做法是,用 mysqlbinlog工具解析出来,然后把解析结果整个发给MySQL执行。类似下面的命令: Copy mysqlbinlog master.000001 --start-position=2738 --stop-position=2973 | mysql -h127.0.0.1 -P13000 -u$user -p$pwd; 这个命令的意思是,将 master.000001 文件里面从第2738字节到第2973字节中间这段内容解析出来,放到MySQL去执行。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/24-mysql-shi-zen-mo-bao-zheng-zhu-bei-yi-zhi-de#xun-huan-fu-zhi-wen-ti) 循环复制问题 通过上面对MySQL中binlog基本内容的理解,你现在可以知道,binlog的特性确保了在备库执行相同的binlog,可以得到与主库相同的状态。 因此,我们可以认为正常情况下主备的数据是一致的。也就是说,图1中A、B两个节点的内容是一致的。其实,图1中我画的是M-S结构,但实际生产上使用比较多的是双M结构,也就是图9所示的主备切换流程。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F20%2F56%2F20ad4e163115198dc6cf372d5116c956.png&width=768&dpr=4&quality=100&sign=c8754b90&sv=2) 图 9 MySQL主备切换流程--双M结构 对比图9和图1,你可以发现,双M结构和M-S结构,其实区别只是多了一条线,即:节点A和B之间总是互为主备关系。这样在切换的时候就不用再修改主备关系。 但是,双M结构还有一个问题需要解决。 业务逻辑在节点A上更新了一条语句,然后再把生成的binlog 发给节点B,节点B执行完这条更新语句后也会生成binlog。(我建议你把参数log\_slave\_updates设置为on,表示备库执行relay log后生成binlog)。 那么,如果节点A同时是节点B的备库,相当于又把节点B新生成的binlog拿过来执行了一次,然后节点A和B间,会不断地循环执行这个更新语句,也就是循环复制了。这个要怎么解决呢? 从上面的图6中可以看到,MySQL在binlog中记录了这个命令第一次执行时所在实例的server id。因此,我们可以用下面的逻辑,来解决两个节点间的循环复制的问题: 1. 规定两个库的server id必须不同,如果相同,则它们之间不能设定为主备关系; 2. 一个备库接到binlog并在重放的过程中,生成与原binlog的server id相同的新的binlog; 3. 每个库在收到从自己的主库发过来的日志后,先判断server id,如果跟自己的相同,表示这个日志是自己生成的,就直接丢弃这个日志。 按照这个逻辑,如果我们设置了双M结构,日志的执行流就会变成这样: 1. 从节点A更新的事务,binlog里面记的都是A的server id; 2. 传到节点B执行一次以后,节点B生成的binlog 的server id也是A的server id; 3. 再传回给节点A,A判断到这个server id与自己的相同,就不会再处理这个日志。所以,死循环在这里就断掉了。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/24-mysql-shi-zen-mo-bao-zheng-zhu-bei-yi-zhi-de#xiao-jie) 小结 今天这篇文章,我给你介绍了MySQL binlog的格式和一些基本机制,是后面我要介绍的读写分离等系列文章的背景知识,希望你可以认真消化理解。 binlog在MySQL的各种高可用方案上扮演了重要角色。今天介绍的可以说是所有MySQL高可用方案的基础。在这之上演化出了诸如多节点、半同步、MySQL group replication等相对复杂的方案。 我也跟你介绍了MySQL不同格式binlog的优缺点,和设计者的思考。希望你在做系统开发时候,也能借鉴这些设计思想。 最后,我给你留下一个思考题吧。 说到循环复制问题的时候,我们说MySQL通过判断server id的方式,断掉死循环。但是,这个机制其实并不完备,在某些场景下,还是有可能出现死循环。 你能构造出一个这样的场景吗?又应该怎么解决呢? 你可以把你的设计和分析写在评论区,我会在下一篇文章跟你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/24-mysql-shi-zen-mo-bao-zheng-zhu-bei-yi-zhi-de#shang-qi-wen-ti-shi-jian) 上期问题时间 上期我留给你的问题是,你在什么时候会把线上生产库设置成“非双1”。我目前知道的场景,有以下这些: 1. 业务高峰期。一般如果有预知的高峰期,DBA会有预案,把主库设置成“非双1”。 2. 备库延迟,为了让备库尽快赶上主库。@永恒记忆和@Second Sight提到了这个场景。 3. 用备份恢复主库的副本,应用binlog的过程,这个跟上一种场景类似。 4. 批量导入数据的时候。 一般情况下,把生产库改成“非双1”配置,是设置innodb\_flush\_logs\_at\_trx\_commit=2、sync\_binlog=1000。 评论区留言点赞板: > @way 同学提到了一个有趣的现象,由于从库设置了 binlog\_group\_commit\_sync\_delay和binlog\_group\_commit\_sync\_no\_delay\_count导致一直延迟的情况。我们在主库设置这两个参数,是为了减少binlog的写盘压力。备库这么设置,尤其在“快要追上”的时候,就反而会受这两个参数的拖累。一般追主备就用“非双1”(追上记得改回来)。 > @一大只 同学验证了在sync\_binlog=0的情况下,设置sync\_delay和sync\_no\_delay\_count的现象,点赞这种发现边界的意识和手动验证的好习惯。是这样的:sync\_delay和sync\_no\_delay\_count的逻辑先走,因此该等还是会等。等到满足了这两个条件之一,就进入sync\_binlog阶段。这时候如果判断sync\_binlog=0,就直接跳过,还是不调fsync。 > @锅子 同学提到,设置sync\_binlog=0的时候,还是可以看到binlog文件马上做了修改。这个是对的,我们说“写到了page cache”,就是文件系统的page cache。而你用ls命令看到的就是文件系统返回的结果。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F09%2F77%2F09c1073f99cf71d2fb162a716b5fa577.jpg&width=768&dpr=4&quality=100&sign=e5471b04&sv=2) [Previous23 MySQL是怎么保证数据不丢的?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/23-mysql-shi-zen-mo-bao-zheng-shu-ju-bu-diu-de) [Next25 MySQL是怎么保证高可用的?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/25-mysql-shi-zen-mo-bao-zheng-gao-ke-yong-de) Last updated 2 years ago Was this helpful? --- # 25 MySQL是怎么保证高可用的? | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252FFXbPkWTULKCItkOBx09k%252Fimage.png%3Falt%3Dmedia%26token%3Daf94d57b-2ee8-4e0d-b000-11c95733c54c&width=768&dpr=4&quality=100&sign=90768e9d&sv=2) 在上一篇文章中,我和你介绍了binlog的基本内容,在一个主备关系中,每个备库接收主库的binlog并执行。 正常情况下,只要主库执行更新生成的所有binlog,都可以传到备库并被正确地执行,备库就能达到跟主库一致的状态,这就是最终一致性。 但是,MySQL要提供高可用能力,只有最终一致性是不够的。为什么这么说呢?今天我就着重和你分析一下。 这里,我再放一次上一篇文章中讲到的双M结构的主备切换流程图。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F89%2Fcc%2F89290bbcf454ff9a3dc5de42a85a69cc.png&width=768&dpr=4&quality=100&sign=f732cb41&sv=2) 图 1 MySQL主备切换流程--双M结构 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/25-mysql-shi-zen-mo-bao-zheng-gao-ke-yong-de#zhu-bei-yan-chi) 主备延迟 主备切换可能是一个主动运维动作,比如软件升级、主库所在机器按计划下线等,也可能是被动操作,比如主库所在机器掉电。 接下来,我们先一起看看主动切换的场景。 在介绍主动切换流程的详细步骤之前,我要先跟你说明一个概念,即“同步延迟”。与数据同步有关的时间点主要包括以下三个: 1. 主库A执行完成一个事务,写入binlog,我们把这个时刻记为T1; 2. 之后传给备库B,我们把备库B接收完这个binlog的时刻记为T2; 3. 备库B执行完成这个事务,我们把这个时刻记为T3。 所谓主备延迟,就是同一个事务,在备库执行完成的时间和主库执行完成的时间之间的差值,也就是T3-T1。 你可以在备库上执行show slave status命令,它的返回结果里面会显示seconds\_behind\_master,用于表示当前备库延迟了多少秒。 seconds\_behind\_master的计算方法是这样的: 1. 每个事务的binlog 里面都有一个时间字段,用于记录主库上写入的时间; 2. 备库取出当前正在执行的事务的时间字段的值,计算它与当前系统时间的差值,得到seconds\_behind\_master。 可以看到,其实seconds\_behind\_master这个参数计算的就是T3-T1。所以,我们可以用seconds\_behind\_master来作为主备延迟的值,这个值的时间精度是秒。 你可能会问,如果主备库机器的系统时间设置不一致,会不会导致主备延迟的值不准? 其实不会的。因为,备库连接到主库的时候,会通过执行SELECT UNIX\_TIMESTAMP()函数来获得当前主库的系统时间。如果这时候发现主库的系统时间与自己不一致,备库在执行seconds\_behind\_master计算的时候会自动扣掉这个差值。 需要说明的是,在网络正常的时候,日志从主库传给备库所需的时间是很短的,即T2-T1的值是非常小的。也就是说,网络正常情况下,主备延迟的主要来源是备库接收完binlog和执行完这个事务之间的时间差。 所以说,主备延迟最直接的表现是,备库消费中转日志(relay log)的速度,比主库生产binlog的速度要慢。接下来,我就和你一起分析下,这可能是由哪些原因导致的。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/25-mysql-shi-zen-mo-bao-zheng-gao-ke-yong-de#zhu-bei-yan-chi-de-lai-yuan) 主备延迟的来源 **首先,有些部署条件下,备库所在机器的性能要比主库所在的机器性能差。** 一般情况下,有人这么部署时的想法是,反正备库没有请求,所以可以用差一点儿的机器。或者,他们会把20个主库放在4台机器上,而把备库集中在一台机器上。 其实我们都知道,更新请求对IOPS的压力,在主库和备库上是无差别的。所以,做这种部署时,一般都会将备库设置为“非双1”的模式。 但实际上,更新过程中也会触发大量的读操作。所以,当备库主机上的多个备库都在争抢资源的时候,就可能会导致主备延迟了。 当然,这种部署现在比较少了。因为主备可能发生切换,备库随时可能变成主库,所以主备库选用相同规格的机器,并且做对称部署,是现在比较常见的情况。 追问1:但是,做了对称部署以后,还可能会有延迟。这是为什么呢? 这就是**第二种常见的可能了,即备库的压力大**。一般的想法是,主库既然提供了写能力,那么备库可以提供一些读能力。或者一些运营后台需要的分析语句,不能影响正常业务,所以只能在备库上跑。 我真就见过不少这样的情况。由于主库直接影响业务,大家使用起来会比较克制,反而忽视了备库的压力控制。结果就是,备库上的查询耗费了大量的CPU资源,影响了同步速度,造成主备延迟。 这种情况,我们一般可以这么处理: 1. 一主多从。除了备库外,可以多接几个从库,让这些从库来分担读的压力。 2. 通过binlog输出到外部系统,比如Hadoop这类系统,让外部系统提供统计类查询的能力。 其中,一主多从的方式大都会被采用。因为作为数据库系统,还必须保证有定期全量备份的能力。而从库,就很适合用来做备份。 > 备注:这里需要说明一下,从库和备库在概念上其实差不多。在我们这个专栏里,为了方便描述,我把会在HA过程中被选成新主库的,称为备库,其他的称为从库。 追问2:采用了一主多从,保证备库的压力不会超过主库,还有什么情况可能导致主备延迟吗? **这就是第三种可能了,即大事务。** 大事务这种情况很好理解。因为主库上必须等事务执行完成才会写入binlog,再传给备库。所以,如果一个主库上的语句执行10分钟,那这个事务很可能就会导致从库延迟10分钟。 不知道你所在公司的DBA有没有跟你这么说过:不要**一次性地用delete语句删除太多数据**。其实,这就是一个典型的大事务场景。 比如,一些归档类的数据,平时没有注意删除历史数据,等到空间快满了,业务开发人员要一次性地删掉大量历史数据。同时,又因为要避免在高峰期操作会影响业务(至少有这个意识还是很不错的),所以会在晚上执行这些大量数据的删除操作。 结果,负责的DBA同学半夜就会收到延迟报警。然后,DBA团队就要求你后续再删除数据的时候,要控制每个事务删除的数据量,分成多次删除。 **另一种典型的大事务场景,就是大表DDL。**这个场景,我在前面的文章中介绍过。处理方案就是,计划内的DDL,建议使用gh-ost方案(这里,你可以再回顾下第13篇文章[《为什么表数据删掉一半,表文件大小不变?》](https://time.geekbang.org/column/article/72388) 中的相关内容)。 追问3:如果主库上也不做大事务了,还有什么原因会导致主备延迟吗? 造成主备延迟还有一个大方向的原因,就是**备库的并行复制能力**。这个话题,我会留在下一篇文章再和你详细介绍。 其实还是有不少其他情况会导致主备延迟,如果你还碰到过其他场景,欢迎你在评论区给我留言,我来和你一起分析、讨论。 由于主备延迟的存在,所以在主备切换的时候,就相应的有不同的策略。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/25-mysql-shi-zen-mo-bao-zheng-gao-ke-yong-de#ke-kao-xing-you-xian-ce-le) 可靠性优先策略 在图1的双M结构下,从状态1到状态2切换的详细过程是这样的: 1. 判断备库B现在的seconds\_behind\_master,如果小于某个值(比如5秒)继续下一步,否则持续重试这一步; 2. 把主库A改成只读状态,即把readonly设置为true; 3. 判断备库B的seconds\_behind\_master的值,直到这个值变成0为止; 4. 把备库B改成可读写状态,也就是把readonly 设置为false; 5. 把业务请求切到备库B。 这个切换流程,一般是由专门的HA系统来完成的,我们暂时称之为可靠性优先流程。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F54%2F4a%2F54f4c7c31e6f0f807c2ab77f78c8844a.png&width=768&dpr=4&quality=100&sign=2805ef95&sv=2) 图2 MySQL可靠性优先主备切换流程 备注:图中的SBM,是seconds\_behind\_master参数的简写。 可以看到,这个切换流程中是有不可用时间的。因为在步骤2之后,主库A和备库B都处于readonly状态,也就是说这时系统处于不可写状态,直到步骤5完成后才能恢复。 在这个不可用状态中,比较耗费时间的是步骤3,可能需要耗费好几秒的时间。这也是为什么需要在步骤1先做判断,确保seconds\_behind\_master的值足够小。 试想如果一开始主备延迟就长达30分钟,而不先做判断直接切换的话,系统的不可用时间就会长达30分钟,这种情况一般业务都是不可接受的。 当然,系统的不可用时间,是由这个数据可靠性优先的策略决定的。你也可以选择可用性优先的策略,来把这个不可用时间几乎降为0。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/25-mysql-shi-zen-mo-bao-zheng-gao-ke-yong-de#ke-yong-xing-you-xian-ce-le) 可用性优先策略 如果我强行把步骤4、5调整到最开始执行,也就是说不等主备数据同步,直接把连接切到备库B,并且让备库B可以读写,那么系统几乎就没有不可用时间了。 我们把这个切换流程,暂时称作可用性优先流程。这个切换流程的代价,就是可能出现数据不一致的情况。 接下来,我就和你分享一个可用性优先流程产生数据不一致的例子。假设有一个表 t: Copy mysql> CREATE TABLE `t` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `c` int(11) unsigned DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB; insert into t(c) values(1),(2),(3); 这个表定义了一个自增主键id,初始化数据后,主库和备库上都是3行数据。接下来,业务人员要继续在表t上执行两条插入语句的命令,依次是: Copy insert into t(c) values(4); insert into t(c) values(5); 假设,现在主库上其他的数据表有大量的更新,导致主备延迟达到5秒。在插入一条c=4的语句后,发起了主备切换。 图3是**可用性优先策略,且binlog\_format=mixed**时的切换流程和数据结果。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F37%2F3a%2F3786bd6ad37faa34aca25bf1a1d8af3a.png&width=768&dpr=4&quality=100&sign=1559f16&sv=2) 图3 可用性优先策略,且binlog\_format=mixed 现在,我们一起分析下这个切换流程: 1. 步骤2中,主库A执行完insert语句,插入了一行数据(4,4),之后开始进行主备切换。 2. 步骤3中,由于主备之间有5秒的延迟,所以备库B还没来得及应用“插入c=4”这个中转日志,就开始接收客户端“插入 c=5”的命令。 3. 步骤4中,备库B插入了一行数据(4,5),并且把这个binlog发给主库A。 4. 步骤5中,备库B执行“插入c=4”这个中转日志,插入了一行数据(5,4)。而直接在备库B执行的“插入c=5”这个语句,传到主库A,就插入了一行新数据(5,5)。 最后的结果就是,主库A和备库B上出现了两行不一致的数据。可以看到,这个数据不一致,是由可用性优先流程导致的。 那么,如果我还是用**可用性优先策略,但设置binlog\_format=row**,情况又会怎样呢? 因为row格式在记录binlog的时候,会记录新插入的行的所有字段值,所以最后只会有一行不一致。而且,两边的主备同步的应用线程会报错duplicate key error并停止。也就是说,这种情况下,备库B的(5,4)和主库A的(5,5)这两行数据,都不会被对方执行。 图4中我画出了详细过程,你可以自己再分析一下。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fb8%2F43%2Fb8d2229b2b40dd087fd3b111d1bdda43.png&width=768&dpr=4&quality=100&sign=96dccf8c&sv=2) 图4 可用性优先策略,且binlog\_format=row 从上面的分析中,你可以看到一些结论: 1. 使用row格式的binlog时,数据不一致的问题更容易被发现。而使用mixed或者statement格式的binlog时,数据很可能悄悄地就不一致了。如果你过了很久才发现数据不一致的问题,很可能这时的数据不一致已经不可查,或者连带造成了更多的数据逻辑不一致。 2. 主备切换的可用性优先策略会导致数据不一致。因此,大多数情况下,我都建议你使用可靠性优先策略。毕竟对数据服务来说的话,数据的可靠性一般还是要优于可用性的。 但事无绝对,**有没有哪种情况数据的可用性优先级更高呢?** 答案是,有的。 我曾经碰到过这样的一个场景: * 有一个库的作用是记录操作日志。这时候,如果数据不一致可以通过binlog来修补,而这个短暂的不一致也不会引发业务问题。 * 同时,业务系统依赖于这个日志写入逻辑,如果这个库不可写,会导致线上的业务操作无法执行。 这时候,你可能就需要选择先强行切换,事后再补数据的策略。 当然,事后复盘的时候,我们想到了一个改进措施就是,让业务逻辑不要依赖于这类日志的写入。也就是说,日志写入这个逻辑模块应该可以降级,比如写到本地文件,或者写到另外一个临时库里面。 这样的话,这种场景就又可以使用可靠性优先策略了。 接下来我们再看看,**按照可靠性优先的思路,异常切换会是什么效果?** 假设,主库A和备库B间的主备延迟是30分钟,这时候主库A掉电了,HA系统要切换B作为主库。我们在主动切换的时候,可以等到主备延迟小于5秒的时候再启动切换,但这时候已经别无选择了。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F55%2F8b%2F553b7fc2d0dce3ec78bb595e1806eb8b.png&width=768&dpr=4&quality=100&sign=17bad0cf&sv=2) 图5 可靠性优先策略,主库不可用 采用可靠性优先策略的话,你就必须得等到备库B的seconds\_behind\_master=0之后,才能切换。但现在的情况比刚刚更严重,并不是系统只读、不可写的问题了,而是系统处于完全不可用的状态。因为,主库A掉电后,我们的连接还没有切到备库B。 你可能会问,那能不能直接切换到备库B,但是保持B只读呢? 这样也不行。 因为,这段时间内,中转日志还没有应用完成,如果直接发起主备切换,客户端查询看不到之前执行完成的事务,会认为有“数据丢失”。 虽然随着中转日志的继续应用,这些数据会恢复回来,但是对于一些业务来说,查询到“暂时丢失数据的状态”也是不能被接受的。 聊到这里你就知道了,在满足数据可靠性的前提下,MySQL高可用系统的可用性,是依赖于主备延迟的。延迟的时间越小,在主库故障的时候,服务恢复需要的时间就越短,可用性就越高。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/25-mysql-shi-zen-mo-bao-zheng-gao-ke-yong-de#xiao-jie) 小结 今天这篇文章,我先和你介绍了MySQL高可用系统的基础,就是主备切换逻辑。紧接着,我又和你讨论了几种会导致主备延迟的情况,以及相应的改进方向。 然后,由于主备延迟的存在,切换策略就有不同的选择。所以,我又和你一起分析了可靠性优先和可用性优先策略的区别。 在实际的应用中,我更建议使用可靠性优先的策略。毕竟保证数据准确,应该是数据库服务的底线。在这个基础上,通过减少主备延迟,提升系统的可用性。 最后,我给你留下一个思考题吧。 一般现在的数据库运维系统都有备库延迟监控,其实就是在备库上执行 show slave status,采集seconds\_behind\_master的值。 假设,现在你看到你维护的一个备库,它的延迟监控的图像类似图6,是一个45°斜向上的线段,你觉得可能是什么原因导致呢?你又会怎么去确认这个原因呢? ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fcf%2F71%2Fcf5ea52aa3b26ef56c567125197fa171.png&width=768&dpr=4&quality=100&sign=223fedd5&sv=2) 图6 备库延迟 你可以把你的分析写在评论区,我会在下一篇文章的末尾跟你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/25-mysql-shi-zen-mo-bao-zheng-gao-ke-yong-de#shang-qi-wen-ti-shi-jian) 上期问题时间 上期我留给你的问题是,什么情况下双M结构会出现循环复制。 一种场景是,在一个主库更新事务后,用命令set global server\_id=x修改了server\_id。等日志再传回来的时候,发现server\_id跟自己的server\_id不同,就只能执行了。 另一种场景是,有三个节点的时候,如图7所示,trx1是在节点 B执行的,因此binlog上的server\_id就是B,binlog传给节点 A,然后A和A’搭建了双M结构,就会出现循环复制。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Ff9%2F71%2Ff968192ce2f436c939dd702b8f409771.png&width=768&dpr=4&quality=100&sign=c035bae0&sv=2) 图7 三节点循环复制 这种三节点复制的场景,做数据库迁移的时候会出现。 如果出现了循环复制,可以在A或者A’上,执行如下命令: Copy stop slave; CHANGE MASTER TO IGNORE_SERVER_IDS=(server_id_of_B); start slave; 这样这个节点收到日志后就不会再执行。过一段时间后,再执行下面的命令把这个值改回来。 Copy stop slave; CHANGE MASTER TO IGNORE_SERVER_IDS=(); start slave; 评论区留言点赞板: > @一大只、@HuaMax 同学提到了第一个复现方法; > @Jonh同学提到了IGNORE\_SERVER\_IDS这个解决方法; > @React 提到,如果主备设置不同的步长,备库是不是可以设置为可读写。我的建议是,只要这个节点设计内就不会有业务直接在上面执行更新,就建议设置为readonly。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F09%2F77%2F09c1073f99cf71d2fb162a716b5fa577.jpg&width=768&dpr=4&quality=100&sign=e5471b04&sv=2) [Previous24 MySQL是怎么保证主备一致的?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/24-mysql-shi-zen-mo-bao-zheng-zhu-bei-yi-zhi-de) [Next26 备库为什么会延迟好几个小时?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/26-bei-ku-wei-shi-mo-hui-yan-chi-hao-ji-ge-xiao-shi) Last updated 2 years ago Was this helpful? --- # 29 如何判断一个数据库是不是出问题了? | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252F3sLT3zDcYg3sHfRrGiPv%252Fimage.png%3Falt%3Dmedia%26token%3D4aed3d5f-d2ba-45df-9fd2-9adabfd410c7&width=768&dpr=4&quality=100&sign=13a210b8&sv=2) 我在第[25](https://time.geekbang.org/column/article/76795) 和[27](https://time.geekbang.org/column/article/77427) 篇文章中,和你介绍了主备切换流程。通过这些内容的讲解,你应该已经很清楚了:在一主一备的双M架构里,主备切换只需要把客户端流量切到备库;而在一主多从架构里,主备切换除了要把客户端流量切到备库外,还需要把从库接到新主库上。 主备切换有两种场景,一种是主动切换,一种是被动切换。而其中被动切换,往往是因为主库出问题了,由HA系统发起的。 这也就引出了我们今天要讨论的问题:怎么判断一个主库出问题了? 你一定会说,这很简单啊,连上MySQL,执行个select 1就好了。但是select 1成功返回了,就表示主库没问题吗? ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/29-ru-he-pan-duan-yi-ge-shu-ju-ku-shi-bu-shi-chu-wen-ti-le#select-1-pan-duan) select 1判断 实际上,select 1成功返回,只能说明这个库的进程还在,并不能说明主库没问题。现在,我们来看一下这个场景。 Copy set global innodb_thread_concurrency=3; CREATE TABLE `t` ( `id` int(11) NOT NULL, `c` int(11) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB; insert into t values(1,1) ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F55%2F35076dd3d0a0d44d22b76d2a29885255.png&width=768&dpr=4&quality=100&sign=5cd606e1&sv=2) 图1 查询blocked 我们设置innodb\_thread\_concurrency参数的目的是,控制InnoDB的并发线程上限。也就是说,一旦并发线程数达到这个值,InnoDB在接收到新请求的时候,就会进入等待状态,直到有线程退出。 这里,我把innodb\_thread\_concurrency设置成3,表示InnoDB只允许3个线程并行执行。而在我们的例子中,前三个session 中的sleep(100),使得这三个语句都处于“执行”状态,以此来模拟大查询。 你看到了, session D里面,select 1是能执行成功的,但是查询表t的语句会被堵住。也就是说,如果这时候我们用select 1来检测实例是否正常的话,是检测不出问题的。 在InnoDB中,innodb\_thread\_concurrency这个参数的默认值是0,表示不限制并发线程数量。但是,不限制并发线程数肯定是不行的。因为,一个机器的CPU核数有限,线程全冲进来,上下文切换的成本就会太高。 所以,通常情况下,我们建议把innodb\_thread\_concurrency设置为64~128之间的值。这时,你一定会有疑问,并发线程上限数设置为128够干啥,线上的并发连接数动不动就上千了。 产生这个疑问的原因,是搞混了**并发连接和并发查询。** 并发连接和并发查询,并不是同一个概念。你在show processlist的结果里,看到的几千个连接,指的就是并发连接。而“当前正在执行”的语句,才是我们所说的并发查询。 并发连接数达到几千个影响并不大,就是多占一些内存而已。我们应该关注的是并发查询,因为并发查询太高才是CPU杀手。这也是为什么我们需要设置innodb\_thread\_concurrency参数的原因。 然后,你可能还会想起我们在[第7篇文章](https://time.geekbang.org/column/article/70215) 中讲到的热点更新和死锁检测的时候,如果把innodb\_thread\_concurrency设置为128的话,那么出现同一行热点更新的问题时,是不是很快就把128消耗完了,这样整个系统是不是就挂了呢? 实际上,**在线程进入锁等待以后,并发线程的计数会减一**,也就是说等行锁(也包括间隙锁)的线程是不算在128里面的。 MySQL这样设计是非常有意义的。因为,进入锁等待的线程已经不吃CPU了;更重要的是,必须这么设计,才能避免整个系统锁死。 为什么呢?假设处于锁等待的线程也占并发线程的计数,你可以设想一下这个场景: 1. 线程1执行begin; update t set c=c+1 where id=1, 启动了事务trx1, 然后保持这个状态。这时候,线程处于空闲状态,不算在并发线程里面。 2. 线程2到线程129都执行 update t set c=c+1 where id=1; 由于等行锁,进入等待状态。这样就有128个线程处于等待状态; 3. 如果处于锁等待状态的线程计数不减一,InnoDB就会认为线程数用满了,会阻止其他语句进入引擎执行,这样线程1不能提交事务。而另外的128个线程又处于锁等待状态,整个系统就堵住了。 下图2显示的就是这个状态。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F32%2F1d%2F3206ea18b8a24b546515b1b95dc4a11d.png&width=768&dpr=4&quality=100&sign=9779386c&sv=2) 图2 系统锁死状态(假设等行锁的语句占用并发计数) 这时候InnoDB不能响应任何请求,整个系统被锁死。而且,由于所有线程都处于等待状态,此时占用的CPU却是0,而这明显不合理。所以,我们说InnoDB在设计时,遇到进程进入锁等待的情况时,将并发线程的计数减1的设计,是合理而且是必要的。 虽然说等锁的线程不算在并发线程计数里,但如果它在真正地执行查询,就比如我们上面例子中前三个事务中的select sleep(100) from t,还是要算进并发线程的计数的。 在这个例子中,同时在执行的语句超过了设置的innodb\_thread\_concurrency的值,这时候系统其实已经不行了,但是通过select 1来检测系统,会认为系统还是正常的。 因此,我们使用select 1的判断逻辑要修改一下。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/29-ru-he-pan-duan-yi-ge-shu-ju-ku-shi-bu-shi-chu-wen-ti-le#cha-biao-pan-duan) 查表判断 为了能够检测InnoDB并发线程数过多导致的系统不可用情况,我们需要找一个访问InnoDB的场景。一般的做法是,在系统库(mysql库)里创建一个表,比如命名为health\_check,里面只放一行数据,然后定期执行: Copy mysql> select * from mysql.health_check; 使用这个方法,我们可以检测出由于并发线程过多导致的数据库不可用的情况。 但是,我们马上还会碰到下一个问题,即:空间满了以后,这种方法又会变得不好使。 我们知道,更新事务要写binlog,而一旦binlog所在磁盘的空间占用率达到100%,那么所有的更新语句和事务提交的commit语句就都会被堵住。但是,系统这时候还是可以正常读数据的。 因此,我们还是把这条监控语句再改进一下。接下来,我们就看看把查询语句改成更新语句后的效果。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/29-ru-he-pan-duan-yi-ge-shu-ju-ku-shi-bu-shi-chu-wen-ti-le#geng-xin-pan-duan) 更新判断 既然要更新,就要放个有意义的字段,常见做法是放一个timestamp字段,用来表示最后一次执行检测的时间。这条更新语句类似于: Copy mysql> update mysql.health_check set t_modified=now(); 节点可用性的检测都应该包含主库和备库。如果用更新来检测主库的话,那么备库也要进行更新检测。 但,备库的检测也是要写binlog的。由于我们一般会把数据库A和B的主备关系设计为双M结构,所以在备库B上执行的检测命令,也要发回给主库A。 但是,如果主库A和备库B都用相同的更新命令,就可能出现行冲突,也就是可能会导致主备同步停止。所以,现在看来mysql.health\_check 这个表就不能只有一行数据了。 为了让主备之间的更新不产生冲突,我们可以在mysql.health\_check表上存入多行数据,并用A、B的server\_id做主键。 Copy mysql> CREATE TABLE `health_check` ( `id` int(11) NOT NULL, `t_modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB; /* 检测命令 */ insert into mysql.health_check(id, t_modified) values (@@server_id, now()) on duplicate key update t_modified=now(); 由于MySQL规定了主库和备库的server\_id必须不同(否则创建主备关系的时候就会报错),这样就可以保证主、备库各自的检测命令不会发生冲突。 更新判断是一个相对比较常用的方案了,不过依然存在一些问题。其中,“判定慢”一直是让DBA头疼的问题。 你一定会疑惑,**更新语句,如果失败或者超时,就可以发起主备切换了,为什么还会有判定慢的问题呢?** 其实,这里涉及到的是服务器IO资源分配的问题。 首先,所有的检测逻辑都需要一个超时时间N。执行一条update语句,超过N秒后还不返回,就认为系统不可用。 你可以设想一个日志盘的IO利用率已经是100%的场景。这时候,整个系统响应非常慢,已经需要做主备切换了。 但是你要知道,IO利用率100%表示系统的IO是在工作的,每个请求都有机会获得IO资源,执行自己的任务。而我们的检测使用的update命令,需要的资源很少,所以可能在拿到IO资源的时候就可以提交成功,并且在超时时间N秒未到达之前就返回给了检测系统。 检测系统一看,update命令没有超时,于是就得到了“系统正常”的结论。 也就是说,这时候在业务系统上正常的SQL语句已经执行得很慢了,但是DBA上去一看,HA系统还在正常工作,并且认为主库现在处于可用状态。 之所以会出现这个现象,根本原因是我们上面说的所有方法,都是基于外部检测的。外部检测天然有一个问题,就是随机性。 因为,外部检测都需要定时轮询,所以系统可能已经出问题了,但是却需要等到下一个检测发起执行语句的时候,我们才有可能发现问题。而且,如果你的运气不够好的话,可能第一次轮询还不能发现,这就会导致切换慢的问题。 所以,接下来我要再和你介绍一种在MySQL内部发现数据库问题的方法。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/29-ru-he-pan-duan-yi-ge-shu-ju-ku-shi-bu-shi-chu-wen-ti-le#nei-bu-tong-ji) 内部统计 针对磁盘利用率这个问题,如果MySQL可以告诉我们,内部每一次IO请求的时间,那我们判断数据库是否出问题的方法就可靠得多了。 其实,MySQL 5.6版本以后提供的performance\_schema库,就在file\_summary\_by\_event\_name表里统计了每次IO请求的时间。 file\_summary\_by\_event\_name表里有很多行数据,我们先来看看event\_name='wait/io/file/innodb/innodb\_log\_file’这一行。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F75%2Fdd%2F752ccfe43b4eab155be17401838c62dd.png&width=768&dpr=4&quality=100&sign=19818435&sv=2) 图3 performance\_schema.file\_summary\_by\_event\_name的一行 图中这一行表示统计的是redo log的写入时间,第一列EVENT\_NAME 表示统计的类型。 接下来的三组数据,显示的是redo log操作的时间统计。 第一组五列,是所有IO类型的统计。其中,COUNT\_STAR是所有IO的总次数,接下来四列是具体的统计项, 单位是皮秒;前缀SUM、MIN、AVG、MAX,顾名思义指的就是总和、最小值、平均值和最大值。 第二组六列,是读操作的统计。最后一列SUM\_NUMBER\_OF\_BYTES\_READ统计的是,总共从redo log里读了多少个字节。 第三组六列,统计的是写操作。 最后的第四组数据,是对其他类型数据的统计。在redo log里,你可以认为它们就是对fsync的统计。 在performance\_schema库的file\_summary\_by\_event\_name表里,binlog对应的是event\_name = "wait/io/file/sql/binlog"这一行。各个字段的统计逻辑,与redo log的各个字段完全相同。这里,我就不再赘述了。 因为我们每一次操作数据库,performance\_schema都需要额外地统计这些信息,所以我们打开这个统计功能是有性能损耗的。 我的测试结果是,如果打开所有的performance\_schema项,性能大概会下降10%左右。所以,我建议你只打开自己需要的项进行统计。你可以通过下面的方法打开或者关闭某个具体项的统计。 如果要打开redo log的时间监控,你可以执行这个语句: Copy mysql> update setup_instruments set ENABLED='YES', Timed='YES' where name like '%wait/io/file/innodb/innodb_log_file%'; 假设,现在你已经开启了redo log和binlog这两个统计信息,那要怎么把这个信息用在实例状态诊断上呢? 很简单,你可以通过MAX\_TIMER的值来判断数据库是否出问题了。比如,你可以设定阈值,单次IO请求时间超过200毫秒属于异常,然后使用类似下面这条语句作为检测逻辑。 Copy mysql> select event_name,MAX_TIMER_WAIT FROM performance_schema.file_summary_by_event_name where event_name in ('wait/io/file/innodb/innodb_log_file','wait/io/file/sql/binlog') and MAX_TIMER_WAIT>200*1000000000; 发现异常后,取到你需要的信息,再通过下面这条语句: Copy mysql> truncate table performance_schema.file_summary_by_event_name; 把之前的统计信息清空。这样如果后面的监控中,再次出现这个异常,就可以加入监控累积值了。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/29-ru-he-pan-duan-yi-ge-shu-ju-ku-shi-bu-shi-chu-wen-ti-le#xiao-jie) 小结 今天,我和你介绍了检测一个MySQL实例健康状态的几种方法,以及各种方法存在的问题和演进的逻辑。 你看完后可能会觉得,select 1这样的方法是不是已经被淘汰了呢,但实际上使用非常广泛的MHA(Master High Availability),默认使用的就是这个方法。 MHA中的另一个可选方法是只做连接,就是 “如果连接成功就认为主库没问题”。不过据我所知,选择这个方法的很少。 其实,每个改进的方案,都会增加额外损耗,并不能用“对错”做直接判断,需要你根据业务实际情况去做权衡。 我个人比较倾向的方案,是优先考虑update系统表,然后再配合增加检测performance\_schema的信息。 最后,又到了我们的思考题时间。 今天,我想问你的是:业务系统一般也有高可用的需求,在你开发和维护过的服务中,你是怎么判断服务有没有出问题的呢? 你可以把你用到的方法和分析写在留言区,我会在下一篇文章中选取有趣的方案一起来分享和分析。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/29-ru-he-pan-duan-yi-ge-shu-ju-ku-shi-bu-shi-chu-wen-ti-le#shang-qi-wen-ti-shi-jian) 上期问题时间 上期的问题是,如果使用GTID等位点的方案做读写分离,在对大表做DDL的时候会怎么样。 假设,这条语句在主库上要执行10分钟,提交后传到备库就要10分钟(典型的大事务)。那么,在主库DDL之后再提交的事务的GTID,去备库查的时候,就会等10分钟才出现。 这样,这个读写分离机制在这10分钟之内都会超时,然后走主库。 这种预期内的操作,应该在业务低峰期的时候,确保主库能够支持所有业务查询,然后把读请求都切到主库,再在主库上做DDL。等备库延迟追上以后,再把读请求切回备库。 通过这个思考题,我主要想让关注的是,大事务对等位点方案的影响。 当然了,使用gh-ost方案来解决这个问题也是不错的选择。 评论区留言点赞板: > @曾剑、@max 同学提到的备库先做,再切主库的方法也是可以的。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F09%2F77%2F09c1073f99cf71d2fb162a716b5fa577.jpg&width=768&dpr=4&quality=100&sign=e5471b04&sv=2) [Previous28 读写分离有哪些坑?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/28-du-xie-fen-li-you-na-xie-keng) [Next30 答疑文章(二):用动态的观点看加锁](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/30-da-yi-wen-zhang-er-yong-dong-tai-de-guan-dian-kan-jia-suo) Last updated 2 years ago Was this helpful? --- # 27 主库出问题了,从库怎么办? | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252FgoCCPSkI56rLSsV5XWbQ%252Fimage.png%3Falt%3Dmedia%26token%3D75047ff7-c397-45ff-8aa7-4968952ab33b&width=768&dpr=4&quality=100&sign=1db9d580&sv=2) 在前面的第[24](https://time.geekbang.org/column/article/76446) 、[25](https://time.geekbang.org/column/article/76795) 和[26](https://time.geekbang.org/column/article/77083) 篇文章中,我和你介绍了MySQL主备复制的基础结构,但这些都是一主一备的结构。 大多数的互联网应用场景都是读多写少,因此你负责的业务,在发展过程中很可能先会遇到读性能的问题。而在数据库层解决读性能问题,就要涉及到接下来两篇文章要讨论的架构:一主多从。 今天这篇文章,我们就先聊聊一主多从的切换正确性。然后,我们在下一篇文章中再聊聊解决一主多从的查询逻辑正确性的方法。 如图1所示,就是一个基本的一主多从结构。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Faa%2F79%2Faadb3b956d1ffc13ac46515a7d619e79.png&width=768&dpr=4&quality=100&sign=e3a85e35&sv=2) 图1 一主多从基本结构 图中,虚线箭头表示的是主备关系,也就是A和A’互为主备, 从库B、C、D指向的是主库A。一主多从的设置,一般用于读写分离,主库负责所有的写入和一部分读,其他的读请求则由从库分担。 今天我们要讨论的就是,在一主多从架构下,主库故障后的主备切换问题。 如图2所示,就是主库发生故障,主备切换后的结果。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F00%2F53%2F0014f97423bd75235a9187f492fb2453.png&width=768&dpr=4&quality=100&sign=220e8057&sv=2) 图2 一主多从基本结构--主备切换 相比于一主一备的切换流程,一主多从结构在切换完成后,A’会成为新的主库,从库B、C、D也要改接到A’。正是由于多了从库B、C、D重新指向的这个过程,所以主备切换的复杂性也相应增加了。 接下来,我们再一起看看一个切换系统会怎么完成一主多从的主备切换过程。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/27-zhu-ku-chu-wen-ti-le-cong-ku-zen-mo-ban#ji-yu-wei-dian-de-zhu-bei-qie-huan) 基于位点的主备切换 这里,我们需要先来回顾一个知识点。 当我们把节点B设置成节点A’的从库的时候,需要执行一条change master命令: Copy CHANGE MASTER TO MASTER_HOST=$host_name MASTER_PORT=$port MASTER_USER=$user_name MASTER_PASSWORD=$password MASTER_LOG_FILE=$master_log_name MASTER_LOG_POS=$master_log_pos 这条命令有这么6个参数: * MASTER\_HOST、MASTER\_PORT、MASTER\_USER和MASTER\_PASSWORD四个参数,分别代表了主库A’的IP、端口、用户名和密码。 * 最后两个参数MASTER\_LOG\_FILE和MASTER\_LOG\_POS表示,要从主库的master\_log\_name文件的master\_log\_pos这个位置的日志继续同步。而这个位置就是我们所说的同步位点,也就是主库对应的文件名和日志偏移量。 那么,这里就有一个问题了,节点B要设置成A’的从库,就要执行change master命令,就不可避免地要设置位点的这两个参数,但是这两个参数到底应该怎么设置呢? 原来节点B是A的从库,本地记录的也是A的位点。但是相同的日志,A的位点和A’的位点是不同的。因此,从库B要切换的时候,就需要先经过“找同步位点”这个逻辑。 这个位点很难精确取到,只能取一个大概位置。为什么这么说呢? 我来和你分析一下看看这个位点一般是怎么获取到的,你就清楚其中不精确的原因了。 考虑到切换过程中不能丢数据,所以我们找位点的时候,总是要找一个“稍微往前”的,然后再通过判断跳过那些在从库B上已经执行过的事务。 一种取同步位点的方法是这样的: 1. 等待新主库A’把中转日志(relay log)全部同步完成; 2. 在A’上执行show master status命令,得到当前A’上最新的File 和 Position; 3. 取原主库A故障的时刻T; 4. 用mysqlbinlog工具解析A’的File,得到T时刻的位点。 Copy mysqlbinlog File --stop-datetime=T --start-datetime=T ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F34%2Fdd%2F3471dfe4aebcccfaec0523a08cdd0ddd.png&width=768&dpr=4&quality=100&sign=849ece49&sv=2) 图3 mysqlbinlog 部分输出结果 图中,end\_log\_pos后面的值“123”,表示的就是A’这个实例,在T时刻写入新的binlog的位置。然后,我们就可以把123这个值作为$master\_log\_pos ,用在节点B的change master命令里。 当然这个值并不精确。为什么呢? 你可以设想有这么一种情况,假设在T这个时刻,主库A已经执行完成了一个insert 语句插入了一行数据R,并且已经将binlog传给了A’和B,然后在传完的瞬间主库A的主机就掉电了。 那么,这时候系统的状态是这样的: 1. 在从库B上,由于同步了binlog, R这一行已经存在; 2. 在新主库A’上, R这一行也已经存在,日志是写在123这个位置之后的; 3. 我们在从库B上执行change master命令,指向A’的File文件的123位置,就会把插入R这一行数据的binlog又同步到从库B去执行。 这时候,从库B的同步线程就会报告 Duplicate entry ‘id\_of\_R’ for key ‘PRIMARY’ 错误,提示出现了主键冲突,然后停止同步。 所以,**通常情况下,我们在切换任务的时候,要先主动跳过这些错误,有两种常用的方法。** **一种做法是**,主动跳过一个事务。跳过命令的写法是: Copy set global sql_slave_skip_counter=1; start slave; 因为切换过程中,可能会不止重复执行一个事务,所以我们需要在从库B刚开始接到新主库A’时,持续观察,每次碰到这些错误就停下来,执行一次跳过命令,直到不再出现停下来的情况,以此来跳过可能涉及的所有事务。 **另外一种方式是,**通过设置slave\_skip\_errors参数,直接设置跳过指定的错误。 在执行主备切换时,有这么两类错误,是经常会遇到的: * 1062错误是插入数据时唯一键冲突; * 1032错误是删除数据时找不到行。 因此,我们可以把slave\_skip\_errors 设置为 “1032,1062”,这样中间碰到这两个错误时就直接跳过。 这里需要注意的是,这种直接跳过指定错误的方法,针对的是主备切换时,由于找不到精确的同步位点,所以只能采用这种方法来创建从库和新主库的主备关系。 这个背景是,我们很清楚在主备切换过程中,直接跳过1032和1062这两类错误是无损的,所以才可以这么设置slave\_skip\_errors参数。等到主备间的同步关系建立完成,并稳定执行一段时间之后,我们还需要把这个参数设置为空,以免之后真的出现了主从数据不一致,也跳过了。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/27-zhu-ku-chu-wen-ti-le-cong-ku-zen-mo-ban#gtid) GTID 通过sql\_slave\_skip\_counter跳过事务和通过slave\_skip\_errors忽略错误的方法,虽然都最终可以建立从库B和新主库A’的主备关系,但这两种操作都很复杂,而且容易出错。所以,MySQL 5.6版本引入了GTID,彻底解决了这个困难。 那么,GTID到底是什么意思,又是如何解决找同步位点这个问题呢?现在,我就和你简单介绍一下。 GTID的全称是Global Transaction Identifier,也就是全局事务ID,是一个事务在提交的时候生成的,是这个事务的唯一标识。它由两部分组成,格式是: Copy GTID=server_uuid:gno 其中: * server\_uuid是一个实例第一次启动时自动生成的,是一个全局唯一的值; * gno是一个整数,初始值是1,每次提交事务的时候分配给这个事务,并加1。 这里我需要和你说明一下,在MySQL的官方文档里,GTID格式是这么定义的: Copy GTID=source_id:transaction_id 这里的source\_id就是server\_uuid;而后面的这个transaction\_id,我觉得容易造成误导,所以我改成了gno。为什么说使用transaction\_id容易造成误解呢? 因为,在MySQL里面我们说transaction\_id就是指事务id,事务id是在事务执行过程中分配的,如果这个事务回滚了,事务id也会递增,而gno是在事务提交的时候才会分配。 从效果上看,GTID往往是连续的,因此我们用gno来表示更容易理解。 GTID模式的启动也很简单,我们只需要在启动一个MySQL实例的时候,加上参数gtid\_mode=on和enforce\_gtid\_consistency=on就可以了。 在GTID模式下,每个事务都会跟一个GTID一一对应。这个GTID有两种生成方式,而使用哪种方式取决于session变量gtid\_next的值。 1. 如果gtid\_next=automatic,代表使用默认值。这时,MySQL就会把server\_uuid:gno分配给这个事务。 a. 记录binlog的时候,先记录一行 SET @@SESSION.GTID\_NEXT=‘server\_uuid:gno’; b. 把这个GTID加入本实例的GTID集合。 2. 如果gtid\_next是一个指定的GTID的值,比如通过set gtid\_next='current\_gtid’指定为current\_gtid,那么就有两种可能: a. 如果current\_gtid已经存在于实例的GTID集合中,接下来执行的这个事务会直接被系统忽略; b. 如果current\_gtid没有存在于实例的GTID集合中,就将这个current\_gtid分配给接下来要执行的事务,也就是说系统不需要给这个事务生成新的GTID,因此gno也不用加1。 注意,一个current\_gtid只能给一个事务使用。这个事务提交后,如果要执行下一个事务,就要执行set 命令,把gtid\_next设置成另外一个gtid或者automatic。 这样,每个MySQL实例都维护了一个GTID集合,用来对应“这个实例执行过的所有事务”。 这样看上去不太容易理解,接下来我就用一个简单的例子,来和你说明GTID的基本用法。 我们在实例X中创建一个表t。 Copy CREATE TABLE `t` ( `id` int(11) NOT NULL, `c` int(11) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB; insert into t values(1,1); ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F28%2Fc2%2F28a5cab0079fb12fd5abecd92b3324c2.png&width=768&dpr=4&quality=100&sign=18bfca84&sv=2) 图4 初始化数据的binlog 可以看到,事务的BEGIN之前有一条SET @@SESSION.GTID\_NEXT命令。这时,如果实例X有从库,那么将CREATE TABLE和insert语句的binlog同步过去执行的话,执行事务之前就会先执行这两个SET命令, 这样被加入从库的GTID集合的,就是图中的这两个GTID。 假设,现在这个实例X是另外一个实例Y的从库,并且此时在实例Y上执行了下面这条插入语句: Copy insert into t values(1,1); 并且,这条语句在实例Y上的GTID是 “aaaaaaaa-cccc-dddd-eeee-ffffffffffff:10”。 那么,实例X作为Y的从库,就要同步这个事务过来执行,显然会出现主键冲突,导致实例X的同步线程停止。这时,我们应该怎么处理呢? 处理方法就是,你可以执行下面的这个语句序列: Copy set gtid_next='aaaaaaaa-cccc-dddd-eeee-ffffffffffff:10'; begin; commit; set gtid_next=automatic; start slave; 其中,前三条语句的作用,是通过提交一个空事务,把这个GTID加到实例X的GTID集合中。如图5所示,就是执行完这个空事务之后的show master status的结果。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fc8%2F57%2Fc8d3299ece7d583a3ecd1557851ed157.png&width=768&dpr=4&quality=100&sign=7795f6a3&sv=2) 图5 show master status结果 可以看到实例X的Executed\_Gtid\_set里面,已经加入了这个GTID。 这样,我再执行start slave命令让同步线程执行起来的时候,虽然实例X上还是会继续执行实例Y传过来的事务,但是由于“aaaaaaaa-cccc-dddd-eeee-ffffffffffff:10”已经存在于实例X的GTID集合中了,所以实例X就会直接跳过这个事务,也就不会再出现主键冲突的错误。 在上面的这个语句序列中,start slave命令之前还有一句set gtid\_next=automatic。这句话的作用是“恢复GTID的默认分配行为”,也就是说如果之后有新的事务再执行,就还是按照原来的分配方式,继续分配gno=3。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/27-zhu-ku-chu-wen-ti-le-cong-ku-zen-mo-ban#ji-yu-gtid-de-zhu-bei-qie-huan) 基于GTID的主备切换 现在,我们已经理解GTID的概念,再一起来看看基于GTID的主备复制的用法。 在GTID模式下,备库B要设置为新主库A’的从库的语法如下: Copy CHANGE MASTER TO MASTER_HOST=$host_name MASTER_PORT=$port MASTER_USER=$user_name MASTER_PASSWORD=$password master_auto_position=1 其中,master\_auto\_position=1就表示这个主备关系使用的是GTID协议。可以看到,前面让我们头疼不已的MASTER\_LOG\_FILE和MASTER\_LOG\_POS参数,已经不需要指定了。 我们把现在这个时刻,实例A’的GTID集合记为set\_a,实例B的GTID集合记为set\_b。接下来,我们就看看现在的主备切换逻辑。 我们在实例B上执行start slave命令,取binlog的逻辑是这样的: 1. 实例B指定主库A’,基于主备协议建立连接。 2. 实例B把set\_b发给主库A’。 3. 实例A’算出set\_a与set\_b的差集,也就是所有存在于set\_a,但是不存在于set\_b的GITD的集合,判断A’本地是否包含了这个差集需要的所有binlog事务。 a. 如果不包含,表示A’已经把实例B需要的binlog给删掉了,直接返回错误; b. 如果确认全部包含,A’从自己的binlog文件里面,找出第一个不在set\_b的事务,发给B; 4. 之后就从这个事务开始,往后读文件,按顺序取binlog发给B去执行。 其实,这个逻辑里面包含了一个设计思想:在基于GTID的主备关系里,系统认为只要建立主备关系,就必须保证主库发给备库的日志是完整的。因此,如果实例B需要的日志已经不存在,A’就拒绝把日志发给B。 这跟基于位点的主备协议不同。基于位点的协议,是由备库决定的,备库指定哪个位点,主库就发哪个位点,不做日志的完整性判断。 基于上面的介绍,我们再来看看引入GTID后,一主多从的切换场景下,主备切换是如何实现的。 由于不需要找位点了,所以从库B、C、D只需要分别执行change master命令指向实例A’即可。 其实,严谨地说,主备切换不是不需要找位点了,而是找位点这个工作,在实例A’内部就已经自动完成了。但由于这个工作是自动的,所以对HA系统的开发人员来说,非常友好。 之后这个系统就由新主库A’写入,主库A’的自己生成的binlog中的GTID集合格式是:server\_uuid\_of\_A’:1-M。 如果之前从库B的GTID集合格式是 server\_uuid\_of\_A:1-N, 那么切换之后GTID集合的格式就变成了server\_uuid\_of\_A:1-N, server\_uuid\_of\_A’:1-M。 当然,主库A’之前也是A的备库,因此主库A’和从库B的GTID集合是一样的。这就达到了我们预期。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/27-zhu-ku-chu-wen-ti-le-cong-ku-zen-mo-ban#gtid-he-zai-xian-ddl) GTID和在线DDL 接下来,我再举个例子帮你理解GTID。 之前在第22篇文章[《MySQL有哪些“饮鸩止渴”提高性能的方法?》](https://time.geekbang.org/column/article/75746) 中,我和你提到业务高峰期的慢查询性能问题时,分析到如果是由于索引缺失引起的性能问题,我们可以通过在线加索引来解决。但是,考虑到要避免新增索引对主库性能造成的影响,我们可以先在备库加索引,然后再切换。 当时我说,在双M结构下,备库执行的DDL语句也会传给主库,为了避免传回后对主库造成影响,要通过set sql\_log\_bin=off关掉binlog。 评论区有位同学提出了一个问题:这样操作的话,数据库里面是加了索引,但是binlog并没有记录下这一个更新,是不是会导致数据和日志不一致? 这个问题提得非常好。当时,我在留言的回复中就引用了GTID来说明。今天,我再和你展开说明一下。 假设,这两个互为主备关系的库还是实例X和实例Y,且当前主库是X,并且都打开了GTID模式。这时的主备切换流程可以变成下面这样: * 在实例X上执行stop slave。 * 在实例Y上执行DDL语句。注意,这里并不需要关闭binlog。 * 执行完成后,查出这个DDL语句对应的GTID,并记为 server\_uuid\_of\_Y:gno。 * 到实例X上执行以下语句序列: Copy set GTID_NEXT="server_uuid_of_Y:gno"; begin; commit; set gtid_next=automatic; start slave; 这样做的目的在于,既可以让实例Y的更新有binlog记录,同时也可以确保不会在实例X上执行这条更新。 * 接下来,执行完主备切换,然后照着上述流程再执行一遍即可。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/27-zhu-ku-chu-wen-ti-le-cong-ku-zen-mo-ban#xiao-jie) 小结 在今天这篇文章中,我先和你介绍了一主多从的主备切换流程。在这个过程中,从库找新主库的位点是一个痛点。由此,我们引出了MySQL 5.6版本引入的GTID模式,介绍了GTID的基本概念和用法。 可以看到,在GTID模式下,一主多从切换就非常方便了。 因此,如果你使用的MySQL版本支持GTID的话,我都建议你尽量使用GTID模式来做一主多从的切换。 在下一篇文章中,我们还能看到GTID模式在读写分离场景的应用。 最后,又到了我们的思考题时间。 你在GTID模式下设置主从关系的时候,从库执行start slave命令后,主库发现需要的binlog已经被删除掉了,导致主备创建不成功。这种情况下,你觉得可以怎么处理呢? 你可以把你的方法写在留言区,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/27-zhu-ku-chu-wen-ti-le-cong-ku-zen-mo-ban#shang-qi-wen-ti-shi-jian) 上期问题时间 上一篇文章最后,我给你留的问题是,如果主库都是单线程压力模式,在从库追主库的过程中,binlog-transaction-dependency-tracking 应该选用什么参数? 这个问题的答案是,应该将这个参数设置为WRITESET。 由于主库是单线程压力模式,所以每个事务的commit\_id都不同,那么设置为COMMIT\_ORDER模式的话,从库也只能单线程执行。 同样地,由于WRITESET\_SESSION模式要求在备库应用日志的时候,同一个线程的日志必须与主库上执行的先后顺序相同,也会导致主库单线程压力模式下退化成单线程复制。 所以,应该将binlog-transaction-dependency-tracking 设置为WRITESET。 **评论区留言点赞板:** > @慧鑫coming 问了一个好问题,对同一行作更新的几个事务,如果commit\_id相同,是不是在备库并行执行的时候会导致数据不一致?这个问题的答案是更新同一行的事务是不可能同时进入commit状态的。 @老杨同志 对这个问题给出了更详细的回答,大家可以去看一下。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F09%2F77%2F09c1073f99cf71d2fb162a716b5fa577.jpg&width=768&dpr=4&quality=100&sign=e5471b04&sv=2) [Previous26 备库为什么会延迟好几个小时?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/26-bei-ku-wei-shi-mo-hui-yan-chi-hao-ji-ge-xiao-shi) [Next28 读写分离有哪些坑?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/28-du-xie-fen-li-you-na-xie-keng) Last updated 2 years ago Was this helpful? --- # 33 我查这么多数据,会不会把数据库内存打爆? | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252FuGEPGIWxKp9SW7l9y5BJ%252Fimage.png%3Falt%3Dmedia%26token%3D38c07e0e-76d5-48c5-a073-9746d56d3b0d&width=768&dpr=4&quality=100&sign=a9708e95&sv=2) 我经常会被问到这样一个问题:我的主机内存只有100G,现在要对一个200G的大表做全表扫描,会不会把数据库主机的内存用光了? 这个问题确实值得担心,被系统OOM(out of memory)可不是闹着玩的。但是,反过来想想,逻辑备份的时候,可不就是做整库扫描吗?如果这样就会把内存吃光,逻辑备份不是早就挂了? 所以说,对大表做全表扫描,看来应该是没问题的。但是,这个流程到底是怎么样的呢? ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/33-wo-cha-zhe-mo-duo-shu-ju-hui-bu-hui-ba-shu-ju-ku-nei-cun-da-bao#quan-biao-sao-miao-dui-server-ceng-de-ying-xiang) 全表扫描对server层的影响 假设,我们现在要对一个200G的InnoDB表db1. t,执行一个全表扫描。当然,你要把扫描结果保存在客户端,会使用类似这样的命令: Copy mysql -h$host -P$port -u$user -p$pwd -e "select * from db1.t" > $target_file 你已经知道了,InnoDB的数据是保存在主键索引上的,所以全表扫描实际上是直接扫描表t的主键索引。这条查询语句由于没有其他的判断条件,所以查到的每一行都可以直接放到结果集里面,然后返回给客户端。 那么,这个“结果集”存在哪里呢? 实际上,服务端并不需要保存一个完整的结果集。取数据和发数据的流程是这样的: 1. 获取一行,写到net\_buffer中。这块内存的大小是由参数net\_buffer\_length定义的,默认是16k。 2. 重复获取行,直到net\_buffer写满,调用网络接口发出去。 3. 如果发送成功,就清空net\_buffer,然后继续取下一行,并写入net\_buffer。 4. 如果发送函数返回EAGAIN或WSAEWOULDBLOCK,就表示本地网络栈(socket send buffer)写满了,进入等待。直到网络栈重新可写,再继续发送。 这个过程对应的流程图如下所示。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fa0%2Fbd%2Fa027c300d7dde8cea4fad8f34b670ebd.jpg&width=768&dpr=4&quality=100&sign=b105a3c2&sv=2) 图1 查询结果发送流程 从这个流程中,你可以看到: 1. 一个查询在发送过程中,占用的MySQL内部的内存最大就是net\_buffer\_length这么大,并不会达到200G; 2. socket send buffer 也不可能达到200G(默认定义/proc/sys/net/core/wmem\_default),如果socket send buffer被写满,就会暂停读数据的流程。 也就是说,**MySQL是“边读边发的”**,这个概念很重要。这就意味着,如果客户端接收得慢,会导致MySQL服务端由于结果发不出去,这个事务的执行时间变长。 比如下面这个状态,就是我故意让客户端不去读socket receive buffer中的内容,然后在服务端show processlist看到的结果。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F18%2Fc3%2F183a704d4495bebbc13c524695b5b6c3.png&width=768&dpr=4&quality=100&sign=1ee56852&sv=2) 图2 服务端发送阻塞 如果你看到State的值一直处于**“Sending to client”**,就表示服务器端的网络栈写满了。 我在上一篇文章中曾提到,如果客户端使用–quick参数,会使用mysql\_use\_result方法。这个方法是读一行处理一行。你可以想象一下,假设有一个业务的逻辑比较复杂,每读一行数据以后要处理的逻辑如果很慢,就会导致客户端要过很久才会去取下一行数据,可能就会出现如图2所示的这种情况。 因此,**对于正常的线上业务来说,如果一个查询的返回结果不会很多的话,我都建议你使用mysql\_store\_result这个接口,直接把查询结果保存到本地内存。** 当然前提是查询返回结果不多。在[第30篇文章](https://time.geekbang.org/column/article/78427) 评论区,有同学说到自己因为执行了一个大查询导致客户端占用内存近20G,这种情况下就需要改用mysql\_use\_result接口了。 另一方面,如果你在自己负责维护的MySQL里看到很多个线程都处于“Sending to client”这个状态,就意味着你要让业务开发同学优化查询结果,并评估这么多的返回结果是否合理。 而如果要快速减少处于这个状态的线程的话,将net\_buffer\_length参数设置为一个更大的值是一个可选方案。 与“Sending to client”长相很类似的一个状态是**“Sending data”**,这是一个经常被误会的问题。有同学问我说,在自己维护的实例上看到很多查询语句的状态是“Sending data”,但查看网络也没什么问题啊,为什么Sending data要这么久? 实际上,一个查询语句的状态变化是这样的(注意:这里,我略去了其他无关的状态): * MySQL查询语句进入执行阶段后,首先把状态设置成“Sending data”; * 然后,发送执行结果的列相关的信息(meta data) 给客户端; * 再继续执行语句的流程; * 执行完成后,把状态设置成空字符串。 也就是说,“Sending data”并不一定是指“正在发送数据”,而可能是处于执行器过程中的任意阶段。比如,你可以构造一个锁等待的场景,就能看到Sending data状态。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F76%2F4b%2F7640b0d82965bf8b305514f30425424b.png&width=768&dpr=4&quality=100&sign=938a3586&sv=2) 图3 读全表被锁 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F84%2Fc0%2F84533515cf36be65582309fbb85e13c0.png&width=768&dpr=4&quality=100&sign=e65b1f51&sv=2) 图 4 Sending data状态 可以看到,session B明显是在等锁,状态显示为Sending data。 也就是说,仅当一个线程处于“等待客户端接收结果”的状态,才会显示"Sending to client";而如果显示成“Sending data”,它的意思只是“正在执行”。 现在你知道了,查询的结果是分段发给客户端的,因此扫描全表,查询返回大量的数据,并不会把内存打爆。 在server层的处理逻辑我们都清楚了,在InnoDB引擎里面又是怎么处理的呢? 扫描全表会不会对引擎系统造成影响呢? ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/33-wo-cha-zhe-mo-duo-shu-ju-hui-bu-hui-ba-shu-ju-ku-nei-cun-da-bao#quan-biao-sao-miao-dui-innodb-de-ying-xiang) 全表扫描对InnoDB的影响 在[第2](https://time.geekbang.org/column/article/68633) 和[第15篇](https://time.geekbang.org/column/article/73161) 文章中,我介绍WAL机制的时候,和你分析了InnoDB内存的一个作用,是保存更新的结果,再配合redo log,就避免了随机写盘。 内存的数据页是在Buffer Pool (BP)中管理的,在WAL里Buffer Pool 起到了加速更新的作用。而实际上,Buffer Pool 还有一个更重要的作用,就是加速查询。 在第2篇文章的评论区有同学问道,由于有WAL机制,当事务提交的时候,磁盘上的数据页是旧的,那如果这时候马上有一个查询要来读这个数据页,是不是要马上把redo log应用到数据页呢? 答案是不需要。因为这时候内存数据页的结果是最新的,直接读内存页就可以了。你看,这时候查询根本不需要读磁盘,直接从内存拿结果,速度是很快的。所以说,Buffer Pool还有加速查询的作用。 而Buffer Pool对查询的加速效果,依赖于一个重要的指标,即:**内存命中率**。 你可以在show engine innodb status结果中,查看一个系统当前的BP命中率。一般情况下,一个稳定服务的线上系统,要保证响应时间符合要求的话,内存命中率要在99%以上。 执行show engine innodb status ,可以看到“Buffer pool hit rate”字样,显示的就是当前的命中率。比如图5这个命中率,就是99.0%。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fc7%2F2e%2Fc70a95ee99826812c292c46de508982e.png&width=768&dpr=4&quality=100&sign=ba665dc2&sv=2) 图5 show engine innodb status显示内存命中率 如果所有查询需要的数据页都能够直接从内存得到,那是最好的,对应的命中率就是100%。但,这在实际生产上是很难做到的。 InnoDB Buffer Pool的大小是由参数 innodb\_buffer\_pool\_size确定的,一般建议设置成可用物理内存的60%~80%。 在大约十年前,单机的数据量是上百个G,而物理内存是几个G;现在虽然很多服务器都能有128G甚至更高的内存,但是单机的数据量却达到了T级别。 所以,innodb\_buffer\_pool\_size小于磁盘的数据量是很常见的。如果一个 Buffer Pool满了,而又要从磁盘读入一个数据页,那肯定是要淘汰一个旧数据页的。 InnoDB内存管理用的是最近最少使用 (Least Recently Used, LRU)算法,这个算法的核心就是淘汰最久未使用的数据。 下图是一个LRU算法的基本模型。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fe0%2F65%2Fe0ac92febac50a5d881f1188ea5bfd65.jpg&width=768&dpr=4&quality=100&sign=2bc3278d&sv=2) 图6 基本LRU算法 InnoDB管理Buffer Pool的LRU算法,是用链表来实现的。 1. 在图6的状态1里,链表头部是P1,表示P1是最近刚刚被访问过的数据页;假设内存里只能放下这么多数据页; 2. 这时候有一个读请求访问P3,因此变成状态2,P3被移到最前面; 3. 状态3表示,这次访问的数据页是不存在于链表中的,所以需要在Buffer Pool中新申请一个数据页Px,加到链表头部。但是由于内存已经满了,不能申请新的内存。于是,会清空链表末尾Pm这个数据页的内存,存入Px的内容,然后放到链表头部。 4. 从效果上看,就是最久没有被访问的数据页Pm,被淘汰了。 这个算法乍一看上去没什么问题,但是如果考虑到要做一个全表扫描,会不会有问题呢? 假设按照这个算法,我们要扫描一个200G的表,而这个表是一个历史数据表,平时没有业务访问它。 那么,按照这个算法扫描的话,就会把当前的Buffer Pool里的数据全部淘汰掉,存入扫描过程中访问到的数据页的内容。也就是说Buffer Pool里面主要放的是这个历史数据表的数据。 对于一个正在做业务服务的库,这可不妙。你会看到,Buffer Pool的内存命中率急剧下降,磁盘压力增加,SQL语句响应变慢。 所以,InnoDB不能直接使用这个LRU算法。实际上,InnoDB对LRU算法做了改进。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F25%2F9e%2F25e18920dd204cf99eec2d62755fe99e.png&width=768&dpr=4&quality=100&sign=9e686486&sv=2) 图 7 改进的LRU算法 在InnoDB实现上,按照5:3的比例把整个LRU链表分成了young区域和old区域。图中LRU\_old指向的就是old区域的第一个位置,是整个链表的5/8处。也就是说,靠近链表头部的5/8是young区域,靠近链表尾部的3/8是old区域。 改进后的LRU算法执行流程变成了下面这样。 1. 图7中状态1,要访问数据页P3,由于P3在young区域,因此和优化前的LRU算法一样,将其移到链表头部,变成状态2。 2. 之后要访问一个新的不存在于当前链表的数据页,这时候依然是淘汰掉数据页Pm,但是新插入的数据页Px,是放在LRU\_old处。 3. 处于old区域的数据页,每次被访问的时候都要做下面这个判断: * 若这个数据页在LRU链表中存在的时间超过了1秒,就把它移动到链表头部; * 如果这个数据页在LRU链表中存在的时间短于1秒,位置保持不变。1秒这个时间,是由参数innodb\_old\_blocks\_time控制的。其默认值是1000,单位毫秒。 这个策略,就是为了处理类似全表扫描的操作量身定制的。还是以刚刚的扫描200G的历史数据表为例,我们看看改进后的LRU算法的操作逻辑: 1. 扫描过程中,需要新插入的数据页,都被放到old区域; 2. 一个数据页里面有多条记录,这个数据页会被多次访问到,但由于是顺序扫描,这个数据页第一次被访问和最后一次被访问的时间间隔不会超过1秒,因此还是会被保留在old区域; 3. 再继续扫描后续的数据,之前的这个数据页之后也不会再被访问到,于是始终没有机会移到链表头部(也就是young区域),很快就会被淘汰出去。 可以看到,这个策略最大的收益,就是在扫描这个大表的过程中,虽然也用到了Buffer Pool,但是对young区域完全没有影响,从而保证了Buffer Pool响应正常业务的查询命中率。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/33-wo-cha-zhe-mo-duo-shu-ju-hui-bu-hui-ba-shu-ju-ku-nei-cun-da-bao#xiao-jie) 小结 今天,我用“大查询会不会把内存用光”这个问题,和你介绍了MySQL的查询结果,发送给客户端的过程。 由于MySQL采用的是边算边发的逻辑,因此对于数据量很大的查询结果来说,不会在server端保存完整的结果集。所以,如果客户端读结果不及时,会堵住MySQL的查询过程,但是不会把内存打爆。 而对于InnoDB引擎内部,由于有淘汰策略,大查询也不会导致内存暴涨。并且,由于InnoDB对LRU算法做了改进,冷数据的全表扫描,对Buffer Pool的影响也能做到可控。 当然,我们前面文章有说过,全表扫描还是比较耗费IO资源的,所以业务高峰期还是不能直接在线上主库执行全表扫描的。 最后,我给你留一个思考题吧。 我在文章中说到,如果由于客户端压力太大,迟迟不能接收结果,会导致MySQL无法发送结果而影响语句执行。但,这还不是最糟糕的情况。 你可以设想出由于客户端的性能问题,对数据库影响更严重的例子吗?或者你是否经历过这样的场景?你又是怎么优化的? 你可以把你的经验和分析写在留言区,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/33-wo-cha-zhe-mo-duo-shu-ju-hui-bu-hui-ba-shu-ju-ku-nei-cun-da-bao#shang-qi-wen-ti-shi-jian) 上期问题时间 上期的问题是,如果一个事务被kill之后,持续处于回滚状态,从恢复速度的角度看,你是应该重启等它执行结束,还是应该强行重启整个MySQL进程。 因为重启之后该做的回滚动作还是不能少的,所以从恢复速度的角度来说,应该让它自己结束。 当然,如果这个语句可能会占用别的锁,或者由于占用IO资源过多,从而影响到了别的语句执行的话,就需要先做主备切换,切到新主库提供服务。 切换之后别的线程都断开了连接,自动停止执行。接下来还是等它自己执行完成。这个操作属于我们在文章中说到的,减少系统压力,加速终止逻辑。 评论区留言点赞板: > @HuaMax 的回答中提到了对其他线程的影响; @夹心面包 @Ryoma @曾剑 同学提到了重启后依然继续做回滚操作的逻辑。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F09%2F77%2F09c1073f99cf71d2fb162a716b5fa577.jpg&width=768&dpr=4&quality=100&sign=e5471b04&sv=2) [Previous32 为什么还有kill不掉的语句?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/32-wei-shi-mo-huan-you-kill-bu-diao-de-yu-ju) [Next34 到底可不可以使用join?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/34-dao-di-ke-bu-ke-yi-shi-yong-join) Last updated 2 years ago Was this helpful? --- # 26 备库为什么会延迟好几个小时? | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252FG6oq006zwSxc4fDmvADR%252Fimage.png%3Falt%3Dmedia%26token%3Db745d2c8-8943-4140-a93c-6cea4f89285e&width=768&dpr=4&quality=100&sign=470db445&sv=2) 在上一篇文章中,我和你介绍了几种可能导致备库延迟的原因。你会发现,这些场景里,不论是偶发性的查询压力,还是备份,对备库延迟的影响一般是分钟级的,而且在备库恢复正常以后都能够追上来。 但是,如果备库执行日志的速度持续低于主库生成日志的速度,那这个延迟就有可能成了小时级别。而且对于一个压力持续比较高的主库来说,备库很可能永远都追不上主库的节奏。 这就涉及到今天我要给你介绍的话题:备库并行复制能力。 为了便于你理解,我们再一起看一下第24篇文章[《MySQL是怎么保证主备一致的?》](https://time.geekbang.org/column/article/76446) 的主备流程图。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F1a%2Fef%2F1a85a3bac30a32438bfd8862e5a34eef.png&width=768&dpr=4&quality=100&sign=4b43bd1e&sv=2) 图1 主备流程图 谈到主备的并行复制能力,我们要关注的是图中黑色的两个箭头。一个箭头代表了客户端写入主库,另一箭头代表的是备库上sql\_thread执行中转日志(relay log)。如果用箭头的粗细来代表并行度的话,那么真实情况就如图1所示,第一个箭头要明显粗于第二个箭头。 在主库上,影响并发度的原因就是各种锁了。由于InnoDB引擎支持行锁,除了所有并发事务都在更新同一行(热点行)这种极端场景外,它对业务并发度的支持还是很友好的。所以,你在性能测试的时候会发现,并发压测线程32就比单线程时,总体吞吐量高。 而日志在备库上的执行,就是图中备库上sql\_thread更新数据(DATA)的逻辑。如果是用单线程的话,就会导致备库应用日志不够快,造成主备延迟。 在官方的5.6版本之前,MySQL只支持单线程复制,由此在主库并发高、TPS高时就会出现严重的主备延迟问题。 从单线程复制到最新版本的多线程复制,中间的演化经历了好几个版本。接下来,我就跟你说说MySQL多线程复制的演进过程。 其实说到底,所有的多线程复制机制,都是要把图1中只有一个线程的sql\_thread,拆成多个线程,也就是都符合下面的这个模型: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fbc%2F45%2Fbcf75aa3b0f496699fd7885426bc6245.png&width=768&dpr=4&quality=100&sign=459a8fa1&sv=2) 图2 多线程模型 图2中,coordinator就是原来的sql\_thread, 不过现在它不再直接更新数据了,只负责读取中转日志和分发事务。真正更新日志的,变成了worker线程。而work线程的个数,就是由参数slave\_parallel\_workers决定的。根据我的经验,把这个值设置为8~16之间最好(32核物理机的情况),毕竟备库还有可能要提供读查询,不能把CPU都吃光了。 接下来,你需要先思考一个问题:事务能不能按照轮询的方式分发给各个worker,也就是第一个事务分给worker\_1,第二个事务发给worker\_2呢? 其实是不行的。因为,事务被分发给worker以后,不同的worker就独立执行了。但是,由于CPU的调度策略,很可能第二个事务最终比第一个事务先执行。而如果这时候刚好这两个事务更新的是同一行,也就意味着,同一行上的两个事务,在主库和备库上的执行顺序相反,会导致主备不一致的问题。 接下来,请你再设想一下另外一个问题:同一个事务的多个更新语句,能不能分给不同的worker来执行呢? 答案是,也不行。举个例子,一个事务更新了表t1和表t2中的各一行,如果这两条更新语句被分到不同worker的话,虽然最终的结果是主备一致的,但如果表t1执行完成的瞬间,备库上有一个查询,就会看到这个事务“更新了一半的结果”,破坏了事务逻辑的隔离性。 所以,coordinator在分发的时候,需要满足以下这两个基本要求: 1. 不能造成更新覆盖。这就要求更新同一行的两个事务,必须被分发到同一个worker中。 2. 同一个事务不能被拆开,必须放到同一个worker中。 各个版本的多线程复制,都遵循了这两条基本原则。接下来,我们就看看各个版本的并行复制策略。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/26-bei-ku-wei-shi-mo-hui-yan-chi-hao-ji-ge-xiao-shi#mysql-5.5-ban-ben-de-bing-xing-fu-zhi-ce-le) MySQL 5.5版本的并行复制策略 官方MySQL 5.5版本是不支持并行复制的。但是,在2012年的时候,我自己服务的业务出现了严重的主备延迟,原因就是备库只有单线程复制。然后,我就先后写了两个版本的并行策略。 这里,我给你介绍一下这两个版本的并行策略,即按表分发策略和按行分发策略,以帮助你理解MySQL官方版本并行复制策略的迭代。 #### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/26-bei-ku-wei-shi-mo-hui-yan-chi-hao-ji-ge-xiao-shi#an-biao-fen-fa-ce-le) 按表分发策略 按表分发事务的基本思路是,如果两个事务更新不同的表,它们就可以并行。因为数据是存储在表里的,所以按表分发,可以保证两个worker不会更新同一行。 当然,如果有跨表的事务,还是要把两张表放在一起考虑的。如图3所示,就是按表分发的规则。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F8b%2F76%2F8b6976fedd6e644022d4026581fb8d76.png&width=768&dpr=4&quality=100&sign=3d809bb2&sv=2) 图3 按表并行复制程模型 可以看到,每个worker线程对应一个hash表,用于保存当前正在这个worker的“执行队列”里的事务所涉及的表。hash表的key是“库名.表名”,value是一个数字,表示队列中有多少个事务修改这个表。 在有事务分配给worker时,事务里面涉及的表会被加到对应的hash表中。worker执行完成后,这个表会被从hash表中去掉。 图3中,hash\_table\_1表示,现在worker\_1的“待执行事务队列”里,有4个事务涉及到db1.t1表,有1个事务涉及到db2.t2表;hash\_table\_2表示,现在worker\_2中有一个事务会更新到表t3的数据。 假设在图中的情况下,coordinator从中转日志中读入一个新事务T,这个事务修改的行涉及到表t1和t3。 现在我们用事务T的分配流程,来看一下分配规则。 1. 由于事务T中涉及修改表t1,而worker\_1队列中有事务在修改表t1,事务T和队列中的某个事务要修改同一个表的数据,这种情况我们说事务T和worker\_1是冲突的。 2. 按照这个逻辑,顺序判断事务T和每个worker队列的冲突关系,会发现事务T跟worker\_2也冲突。 3. 事务T跟多于一个worker冲突,coordinator线程就进入等待。 4. 每个worker继续执行,同时修改hash\_table。假设hash\_table\_2里面涉及到修改表t3的事务先执行完成,就会从hash\_table\_2中把db1.t3这一项去掉。 5. 这样coordinator会发现跟事务T冲突的worker只有worker\_1了,因此就把它分配给worker\_1。 6. coordinator继续读下一个中转日志,继续分配事务。 也就是说,每个事务在分发的时候,跟所有worker的冲突关系包括以下三种情况: 1. 如果跟所有worker都不冲突,coordinator线程就会把这个事务分配给最空闲的woker; 2. 如果跟多于一个worker冲突,coordinator线程就进入等待状态,直到和这个事务存在冲突关系的worker只剩下1个; 3. 如果只跟一个worker冲突,coordinator线程就会把这个事务分配给这个存在冲突关系的worker。 这个按表分发的方案,在多个表负载均匀的场景里应用效果很好。但是,如果碰到热点表,比如所有的更新事务都会涉及到某一个表的时候,所有事务都会被分配到同一个worker中,就变成单线程复制了。 #### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/26-bei-ku-wei-shi-mo-hui-yan-chi-hao-ji-ge-xiao-shi#an-xing-fen-fa-ce-le) 按行分发策略 要解决热点表的并行复制问题,就需要一个按行并行复制的方案。按行复制的核心思路是:如果两个事务没有更新相同的行,它们在备库上可以并行执行。显然,这个模式要求binlog格式必须是row。 这时候,我们判断一个事务T和worker是否冲突,用的就规则就不是“修改同一个表”,而是“修改同一行”。 按行复制和按表复制的数据结构差不多,也是为每个worker,分配一个hash表。只是要实现按行分发,这时候的key,就必须是“库名+表名+唯一键的值”。 但是,这个“唯一键”只有主键id还是不够的,我们还需要考虑下面这种场景,表t1中除了主键,还有唯一索引a: Copy CREATE TABLE `t1` ( `id` int(11) NOT NULL, `a` int(11) DEFAULT NULL, `b` int(11) DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `a` (`a`) ) ENGINE=InnoDB; insert into t1 values(1,1,1),(2,2,2),(3,3,3),(4,4,4),(5,5,5); 假设,接下来我们要在主库执行这两个事务: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Ff1%2F78%2Ff19916e27b8ff28e87ed3ad9f5473378.png&width=768&dpr=4&quality=100&sign=1dbb0a25&sv=2) 图4 唯一键冲突示例 可以看到,这两个事务要更新的行的主键值不同,但是如果它们被分到不同的worker,就有可能session B的语句先执行。这时候id=1的行的a的值还是1,就会报唯一键冲突。 因此,基于行的策略,事务hash表中还需要考虑唯一键,即key应该是“库名+表名+索引a的名字+a的值”。 比如,在上面这个例子中,我要在表t1上执行update t1 set a=1 where id=2语句,在binlog里面记录了整行的数据修改前各个字段的值,和修改后各个字段的值。 因此,coordinator在解析这个语句的binlog的时候,这个事务的hash表就有三个项: 1. key=hash\_func(db1+t1+“PRIMARY”+2), value=2; 这里value=2是因为修改前后的行id值不变,出现了两次。 2. key=hash\_func(db1+t1+“a”+2), value=1,表示会影响到这个表a=2的行。 3. key=hash\_func(db1+t1+“a”+1), value=1,表示会影响到这个表a=1的行。 可见,**相比于按表并行分发策略,按行并行策略在决定线程分发的时候,需要消耗更多的计算资源。**你可能也发现了,这两个方案其实都有一些约束条件: 1. 要能够从binlog里面解析出表名、主键值和唯一索引的值。也就是说,主库的binlog格式必须是row; 2. 表必须有主键; 3. 不能有外键。表上如果有外键,级联更新的行不会记录在binlog中,这样冲突检测就不准确。 但,好在这三条约束规则,本来就是DBA之前要求业务开发人员必须遵守的线上使用规范,所以这两个并行复制策略在应用上也没有碰到什么麻烦。 对比按表分发和按行分发这两个方案的话,按行分发策略的并行度更高。不过,如果是要操作很多行的大事务的话,按行分发的策略有两个问题: 1. 耗费内存。比如一个语句要删除100万行数据,这时候hash表就要记录100万个项。 2. 耗费CPU。解析binlog,然后计算hash值,对于大事务,这个成本还是很高的。 所以,我在实现这个策略的时候会设置一个阈值,单个事务如果超过设置的行数阈值(比如,如果单个事务更新的行数超过10万行),就暂时退化为单线程模式,退化过程的逻辑大概是这样的: 1. coordinator暂时先hold住这个事务; 2. 等待所有worker都执行完成,变成空队列; 3. coordinator直接执行这个事务; 4. 恢复并行模式。 读到这里,你可能会感到奇怪,这两个策略又没有被合到官方,我为什么要介绍这么详细呢?其实,介绍这两个策略的目的是抛砖引玉,方便你理解后面要介绍的社区版本策略。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/26-bei-ku-wei-shi-mo-hui-yan-chi-hao-ji-ge-xiao-shi#mysql-5.6-ban-ben-de-bing-xing-fu-zhi-ce-le) MySQL 5.6版本的并行复制策略 官方MySQL5.6版本,支持了并行复制,只是支持的粒度是按库并行。理解了上面介绍的按表分发策略和按行分发策略,你就理解了,用于决定分发策略的hash表里,key就是数据库名。 这个策略的并行效果,取决于压力模型。如果在主库上有多个DB,并且各个DB的压力均衡,使用这个策略的效果会很好。 相比于按表和按行分发,这个策略有两个优势: 1. 构造hash值的时候很快,只需要库名;而且一个实例上DB数也不会很多,不会出现需要构造100万个项这种情况。 2. 不要求binlog的格式。因为statement格式的binlog也可以很容易拿到库名。 但是,如果你的主库上的表都放在同一个DB里面,这个策略就没有效果了;或者如果不同DB的热点不同,比如一个是业务逻辑库,一个是系统配置库,那也起不到并行的效果。 理论上你可以创建不同的DB,把相同热度的表均匀分到这些不同的DB中,强行使用这个策略。不过据我所知,由于需要特地移动数据,这个策略用得并不多。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/26-bei-ku-wei-shi-mo-hui-yan-chi-hao-ji-ge-xiao-shi#mariadb-de-bing-xing-fu-zhi-ce-le) MariaDB的并行复制策略 在[第23篇文章](https://time.geekbang.org/column/article/76161) 中,我给你介绍了redo log组提交(group commit)优化, 而MariaDB的并行复制策略利用的就是这个特性: 1. 能够在同一组里提交的事务,一定不会修改同一行; 2. 主库上可以并行执行的事务,备库上也一定是可以并行执行的。 在实现上,MariaDB是这么做的: 1. 在一组里面一起提交的事务,有一个相同的commit\_id,下一组就是commit\_id+1; 2. commit\_id直接写到binlog里面; 3. 传到备库应用的时候,相同commit\_id的事务分发到多个worker执行; 4. 这一组全部执行完成后,coordinator再去取下一批。 当时,这个策略出来的时候是相当惊艳的。因为,之前业界的思路都是在“分析binlog,并拆分到worker”上。而MariaDB的这个策略,目标是“模拟主库的并行模式”。 但是,这个策略有一个问题,它并没有实现“真正的模拟主库并发度”这个目标。在主库上,一组事务在commit的时候,下一组事务是同时处于“执行中”状态的。 如图5所示,假设了三组事务在主库的执行情况,你可以看到在trx1、trx2和trx3提交的时候,trx4、trx5和trx6是在执行的。这样,在第一组事务提交完成的时候,下一组事务很快就会进入commit状态。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F8f%2Fc3%2F8fec5fb48d6095aecc80016826efbfc3.png&width=768&dpr=4&quality=100&sign=228b3a8f&sv=2) 图5 主库并行事务 而按照MariaDB的并行复制策略,备库上的执行效果如图6所示。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F8a%2F22%2F8ac3799c1ff2f9833619a1624ca3e622.png&width=768&dpr=4&quality=100&sign=854122c0&sv=2) 图6 MariaDB 并行复制,备库并行效果 可以看到,在备库上执行的时候,要等第一组事务完全执行完成后,第二组事务才能开始执行,这样系统的吞吐量就不够。 另外,这个方案很容易被大事务拖后腿。假设trx2是一个超大事务,那么在备库应用的时候,trx1和trx3执行完成后,就只能等trx2完全执行完成,下一组才能开始执行。这段时间,只有一个worker线程在工作,是对资源的浪费。 不过即使如此,这个策略仍然是一个很漂亮的创新。因为,它对原系统的改造非常少,实现也很优雅。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/26-bei-ku-wei-shi-mo-hui-yan-chi-hao-ji-ge-xiao-shi#mysql-5.7-de-bing-xing-fu-zhi-ce-le) MySQL 5.7的并行复制策略 在MariaDB并行复制实现之后,官方的MySQL5.7版本也提供了类似的功能,由参数slave-parallel-type来控制并行复制策略: 1. 配置为DATABASE,表示使用MySQL 5.6版本的按库并行策略; 2. 配置为 LOGICAL\_CLOCK,表示的就是类似MariaDB的策略。不过,MySQL 5.7这个策略,针对并行度做了优化。这个优化的思路也很有趣儿。 你可以先考虑这样一个问题:同时处于“执行状态”的所有事务,是不是可以并行? 答案是,不能。 因为,这里面可能有由于锁冲突而处于锁等待状态的事务。如果这些事务在备库上被分配到不同的worker,就会出现备库跟主库不一致的情况。 而上面提到的MariaDB这个策略的核心,是“所有处于commit”状态的事务可以并行。事务处于commit状态,表示已经通过了锁冲突的检验了。 这时候,你可以再回顾一下两阶段提交,我把前面[第23篇文章](https://time.geekbang.org/column/article/76161) 中介绍过的两阶段提交过程图贴过来。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F5a%2F28%2F5ae7d074c34bc5bd55c82781de670c28.png&width=768&dpr=4&quality=100&sign=5b9e7a35&sv=2) 图7 两阶段提交细化过程图 其实,不用等到commit阶段,只要能够到达redo log prepare阶段,就表示事务已经通过锁冲突的检验了。 因此,MySQL 5.7并行复制策略的思想是: 1. 同时处于prepare状态的事务,在备库执行时是可以并行的; 2. 处于prepare状态的事务,与处于commit状态的事务之间,在备库执行时也是可以并行的。 我在第23篇文章,讲binlog的组提交的时候,介绍过两个参数: 1. binlog\_group\_commit\_sync\_delay参数,表示延迟多少微秒后才调用fsync; 2. binlog\_group\_commit\_sync\_no\_delay\_count参数,表示累积多少次以后才调用fsync。 这两个参数是用于故意拉长binlog从write到fsync的时间,以此减少binlog的写盘次数。在MySQL 5.7的并行复制策略里,它们可以用来制造更多的“同时处于prepare阶段的事务”。这样就增加了备库复制的并行度。 也就是说,这两个参数,既可以“故意”让主库提交得慢些,又可以让备库执行得快些。在MySQL 5.7处理备库延迟的时候,可以考虑调整这两个参数值,来达到提升备库复制并发度的目的。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/26-bei-ku-wei-shi-mo-hui-yan-chi-hao-ji-ge-xiao-shi#mysql-5.7.22-de-bing-xing-fu-zhi-ce-le) MySQL 5.7.22的并行复制策略 在2018年4月份发布的MySQL 5.7.22版本里,MySQL增加了一个新的并行复制策略,基于WRITESET的并行复制。 相应地,新增了一个参数binlog-transaction-dependency-tracking,用来控制是否启用这个新策略。这个参数的可选值有以下三种。 1. COMMIT\_ORDER,表示的就是前面介绍的,根据同时进入prepare和commit来判断是否可以并行的策略。 2. WRITESET,表示的是对于事务涉及更新的每一行,计算出这一行的hash值,组成集合writeset。如果两个事务没有操作相同的行,也就是说它们的writeset没有交集,就可以并行。 3. WRITESET\_SESSION,是在WRITESET的基础上多了一个约束,即在主库上同一个线程先后执行的两个事务,在备库执行的时候,要保证相同的先后顺序。 当然为了唯一标识,这个hash值是通过“库名+表名+索引名+值”计算出来的。如果一个表上除了有主键索引外,还有其他唯一索引,那么对于每个唯一索引,insert语句对应的writeset就要多增加一个hash值。 你可能看出来了,这跟我们前面介绍的基于MySQL 5.5版本的按行分发的策略是差不多的。不过,MySQL官方的这个实现还是有很大的优势: 1. writeset是在主库生成后直接写入到binlog里面的,这样在备库执行的时候,不需要解析binlog内容(event里的行数据),节省了很多计算量; 2. 不需要把整个事务的binlog都扫一遍才能决定分发到哪个worker,更省内存; 3. 由于备库的分发策略不依赖于binlog内容,所以binlog是statement格式也是可以的。 因此,MySQL 5.7.22的并行复制策略在通用性上还是有保证的。 当然,对于“表上没主键”和“外键约束”的场景,WRITESET策略也是没法并行的,也会暂时退化为单线程模型。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/26-bei-ku-wei-shi-mo-hui-yan-chi-hao-ji-ge-xiao-shi#xiao-jie) 小结 在今天这篇文章中,我和你介绍了MySQL的各种多线程复制策略。 为什么要有多线程复制呢?这是因为单线程复制的能力全面低于多线程复制,对于更新压力较大的主库,备库是可能一直追不上主库的。从现象上看就是,备库上seconds\_behind\_master的值越来越大。 在介绍完每个并行复制策略后,我还和你分享了不同策略的优缺点: * 如果你是DBA,就需要根据不同的业务场景,选择不同的策略; * 如果是你业务开发人员,也希望你能从中获取灵感用到平时的开发工作中。 从这些分析中,你也会发现大事务不仅会影响到主库,也是造成备库复制延迟的主要原因之一。因此,在平时的开发工作中,我建议你尽量减少大事务操作,把大事务拆成小事务。 官方MySQL5.7版本新增的备库并行策略,修改了binlog的内容,也就是说binlog协议并不是向上兼容的,在主备切换、版本升级的时候需要把这个因素也考虑进去。 最后,我给你留下一个思考题吧。 假设一个MySQL 5.7.22版本的主库,单线程插入了很多数据,过了3个小时后,我们要给这个主库搭建一个相同版本的备库。 这时候,你为了更快地让备库追上主库,要开并行复制。在binlog-transaction-dependency-tracking参数的COMMIT\_ORDER、WRITESET和WRITE\_SESSION这三个取值中,你会选择哪一个呢? 你选择的原因是什么?如果设置另外两个参数,你认为会出现什么现象呢? 你可以把你的答案和分析写在评论区,我会在下一篇文章跟你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/26-bei-ku-wei-shi-mo-hui-yan-chi-hao-ji-ge-xiao-shi#shang-qi-wen-ti-shi-jian) 上期问题时间 上期的问题是,什么情况下,备库的主备延迟会表现为一个45度的线段?评论区有不少同学的回复都说到了重点:备库的同步在这段时间完全被堵住了。 产生这种现象典型的场景主要包括两种: * 一种是大事务(包括大表DDL、一个事务操作很多行); * 还有一种情况比较隐蔽,就是备库起了一个长事务,比如 Copy begin; select * from t limit 1; 然后就不动了。 这时候主库对表t做了一个加字段操作,即使这个表很小,这个DDL在备库应用的时候也会被堵住,也不能看到这个现象。 评论区还有同学说是不是主库多线程、从库单线程,备库跟不上主库的更新节奏导致的?今天这篇文章,我们刚好讲的是并行复制。所以,你知道了,这种情况会导致主备延迟,但不会表现为这种标准的呈45度的直线。 评论区留言点赞板: > @易翔 、 @万勇、@老杨同志 等同学的回复都提到了我们上面说的场景; > @Max 同学提了一个很不错的问题。主备关系里面,备库主动连接,之后的binlog发送是主库主动推送的。之所以这么设计也是为了效率和实时性考虑,毕竟靠备库轮询,会有时间差。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F09%2F77%2F09c1073f99cf71d2fb162a716b5fa577.jpg&width=768&dpr=4&quality=100&sign=e5471b04&sv=2) [Previous25 MySQL是怎么保证高可用的?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/25-mysql-shi-zen-mo-bao-zheng-gao-ke-yong-de) [Next27 主库出问题了,从库怎么办?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/27-zhu-ku-chu-wen-ti-le-cong-ku-zen-mo-ban) Last updated 2 years ago Was this helpful? --- # 32 为什么还有kill不掉的语句? | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252F0Ldm1rdtTMdNA6V9xVqS%252Fimage.png%3Falt%3Dmedia%26token%3D86bcf247-4bc1-4690-a51b-d60915612390&width=768&dpr=4&quality=100&sign=7a4016ca&sv=2) 在MySQL中有两个kill命令:一个是kill query +线程id,表示终止这个线程中正在执行的语句;一个是kill connection +线程id,这里connection可缺省,表示断开这个线程的连接,当然如果这个线程有语句正在执行,也是要先停止正在执行的语句的。 不知道你在使用MySQL的时候,有没有遇到过这样的现象:使用了kill命令,却没能断开这个连接。再执行show processlist命令,看到这条语句的Command列显示的是Killed。 你一定会奇怪,显示为Killed是什么意思,不是应该直接在show processlist的结果里看不到这个线程了吗? 今天,我们就来讨论一下这个问题。 其实大多数情况下,kill query/connection命令是有效的。比如,执行一个查询的过程中,发现执行时间太久,要放弃继续查询,这时我们就可以用kill query命令,终止这条查询语句。 还有一种情况是,语句处于锁等待的时候,直接使用kill命令也是有效的。我们一起来看下这个例子: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F17%2Fd0%2F17f88dc70c3fbe06a7738a0ac01db4d0.png&width=768&dpr=4&quality=100&sign=ba5314b9&sv=2) 图1 kill query 成功的例子 可以看到,session C 执行kill query以后,session B几乎同时就提示了语句被中断。这,就是我们预期的结果。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/32-wei-shi-mo-huan-you-kill-bu-diao-de-yu-ju#shou-dao-kill-yi-hou-xian-cheng-zuo-shen-me) 收到kill以后,线程做什么? 但是,这里你要停下来想一下:session B是直接终止掉线程,什么都不管就直接退出吗?显然,这是不行的。 我在[第6篇文章](https://time.geekbang.org/column/article/69862) 中讲过,当对一个表做增删改查操作时,会在表上加MDL读锁。所以,session B虽然处于blocked状态,但还是拿着一个MDL读锁的。如果线程被kill的时候,就直接终止,那之后这个MDL读锁就没机会被释放了。 这样看来,kill并不是马上停止的意思,而是告诉执行线程说,这条语句已经不需要继续执行了,可以开始“执行停止的逻辑了”。 > 其实,这跟Linux的kill命令类似,kill -N pid并不是让进程直接停止,而是给进程发一个信号,然后进程处理这个信号,进入终止逻辑。只是对于MySQL的kill命令来说,不需要传信号量参数,就只有“停止”这个命令。 **实现上,当用户执行kill query thread\_id\_B时,MySQL里处理kill命令的线程做了两件事:** 1. 把session B的运行状态改成THD::KILL\_QUERY(将变量killed赋值为THD::KILL\_QUERY); 2. 给session B的执行线程发一个信号。 为什么要发信号呢? 因为像图1的我们例子里面,session B处于锁等待状态,如果只是把session B的线程状态设置THD::KILL\_QUERY,线程B并不知道这个状态变化,还是会继续等待。发一个信号的目的,就是让session B退出等待,来处理这个THD::KILL\_QUERY状态。 上面的分析中,隐含了这么三层意思: 1. 一个语句执行过程中有多处“埋点”,在这些“埋点”的地方判断线程状态,如果发现线程状态是THD::KILL\_QUERY,才开始进入语句终止逻辑; 2. 如果处于等待状态,必须是一个可以被唤醒的等待,否则根本不会执行到“埋点”处; 3. 语句从开始进入终止逻辑,到终止逻辑完全完成,是有一个过程的。 到这里你就知道了,原来不是“说停就停的”。 接下来,我们**再看一个kill不掉的例子**,也就是我们在前面[第29篇文章](https://time.geekbang.org/column/article/78134) 中提到的 innodb\_thread\_concurrency 不够用的例子。 首先,执行set global innodb\_thread\_concurrency=2,将InnoDB的并发线程上限数设置为2;然后,执行下面的序列: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F32%2F6e%2F32e4341409fabfe271db3dd4c4df696e.png&width=768&dpr=4&quality=100&sign=c2168995&sv=2) 图2 kill query 无效的例子 可以看到: 1. sesssion C执行的时候被堵住了; 2. 但是session D执行的kill query C命令却没什么效果, 3. 直到session E执行了kill connection命令,才断开了session C的连接,提示“Lost connection to MySQL server during query”, 4. 但是这时候,如果在session E中执行show processlist,你就能看到下面这个图。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F91%2F53%2F915c20e4c11b104d7bcf9d3457304c53.png&width=768&dpr=4&quality=100&sign=d5046955&sv=2) 图3 kill connection之后的效果 这时候,id=12这个线程的Commnad列显示的是Killed。也就是说,客户端虽然断开了连接,但实际上服务端上这条语句还在执行过程中。 **为什么在执行kill query命令时,这条语句不像第一个例子的update语句一样退出呢?** 在实现上,等行锁时,使用的是pthread\_cond\_timedwait函数,这个等待状态可以被唤醒。但是,在这个例子里,12号线程的等待逻辑是这样的:每10毫秒判断一下是否可以进入InnoDB执行,如果不行,就调用nanosleep函数进入sleep状态。 也就是说,虽然12号线程的状态已经被设置成了KILL\_QUERY,但是在这个等待进入InnoDB的循环过程中,并没有去判断线程的状态,因此根本不会进入终止逻辑阶段。 而当session E执行kill connection 命令时,是这么做的, 1. 把12号线程状态设置为KILL\_CONNECTION; 2. 关掉12号线程的网络连接。因为有这个操作,所以你会看到,这时候session C收到了断开连接的提示。 那为什么执行show processlist的时候,会看到Command列显示为killed呢?其实,这就是因为在执行show processlist的时候,有一个特别的逻辑: Copy 如果一个线程的状态是KILL_CONNECTION,就把Command列显示成Killed。 所以其实,即使是客户端退出了,这个线程的状态仍然是在等待中。那这个线程什么时候会退出呢? 答案是,只有等到满足进入InnoDB的条件后,session C的查询语句继续执行,然后才有可能判断到线程状态已经变成了KILL\_QUERY或者KILL\_CONNECTION,再进入终止逻辑阶段。 到这里,我们来小结一下。 **这个例子是kill无效的第一类情况,即:线程没有执行到判断线程状态的逻辑。**跟这种情况相同的,还有由于IO压力过大,读写IO的函数一直无法返回,导致不能及时判断线程的状态。 **另一类情况是,终止逻辑耗时较长。**这时候,从show processlist结果上看也是Command=Killed,需要等到终止逻辑完成,语句才算真正完成。这类情况,比较常见的场景有以下几种: 1. 超大事务执行期间被kill。这时候,回滚操作需要对事务执行期间生成的所有新数据版本做回收操作,耗时很长。 2. 大查询回滚。如果查询过程中生成了比较大的临时文件,加上此时文件系统压力大,删除临时文件可能需要等待IO资源,导致耗时较长。 3. DDL命令执行到最后阶段,如果被kill,需要删除中间过程的临时文件,也可能受IO资源影响耗时较久。 之前有人问过我,如果直接在客户端通过Ctrl+C命令,是不是就可以直接终止线程呢? 答案是,不可以。 这里有一个误解,其实在客户端的操作只能操作到客户端的线程,客户端和服务端只能通过网络交互,是不可能直接操作服务端线程的。 而由于MySQL是停等协议,所以这个线程执行的语句还没有返回的时候,再往这个连接里面继续发命令也是没有用的。实际上,执行Ctrl+C的时候,是MySQL客户端另外启动一个连接,然后发送一个kill query 命令。 所以,你可别以为在客户端执行完Ctrl+C就万事大吉了。因为,要kill掉一个线程,还涉及到后端的很多操作。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/32-wei-shi-mo-huan-you-kill-bu-diao-de-yu-ju#ling-wai-liang-ge-guan-yu-ke-hu-duan-de-wu-jie) 另外两个关于客户端的误解 在实际使用中,我也经常会碰到一些同学对客户端的使用有误解。接下来,我们就来看看两个最常见的误解。 **第一个误解是:如果库里面的表特别多,连接就会很慢。** 有些线上的库,会包含很多表(我见过最多的一个库里有6万个表)。这时候,你就会发现,每次用客户端连接都会卡在下面这个界面上。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F7e%2F83%2F7e4666bfd580505180c77447d1f44c83.png&width=768&dpr=4&quality=100&sign=fa90244a&sv=2) 图4 连接等待 而如果db1这个库里表很少的话,连接起来就会很快,可以很快进入输入命令的状态。因此,有同学会认为是表的数目影响了连接性能。 从[第一篇文章](https://time.geekbang.org/column/article/68319) 你就知道,每个客户端在和服务端建立连接的时候,需要做的事情就是TCP握手、用户校验、获取权限。但这几个操作,显然跟库里面表的个数无关。 但实际上,正如图中的文字提示所说的,当使用默认参数连接的时候,MySQL客户端会提供一个本地库名和表名补全的功能。为了实现这个功能,客户端在连接成功后,需要多做一些操作: 1. 执行show databases; 2. 切到db1库,执行show tables; 3. 把这两个命令的结果用于构建一个本地的哈希表。 在这些操作中,最花时间的就是第三步在本地构建哈希表的操作。所以,当一个库中的表个数非常多的时候,这一步就会花比较长的时间。 也就是说,**我们感知到的连接过程慢,其实并不是连接慢,也不是服务端慢,而是客户端慢。** 图中的提示也说了,如果在连接命令中加上-A,就可以关掉这个自动补全的功能,然后客户端就可以快速返回了。 这里自动补全的效果就是,你在输入库名或者表名的时候,输入前缀,可以使用Tab键自动补全表名或者显示提示。 实际使用中,如果你自动补全功能用得并不多,我建议你每次使用的时候都默认加-A。 其实提示里面没有说,除了加-A以外,加–quick(或者简写为-q)参数,也可以跳过这个阶段。但是,这个**–quick是一个更容易引起误会的参数,也是关于客户端常见的一个误解。** 你看到这个参数,是不是觉得这应该是一个让服务端加速的参数?但实际上恰恰相反,设置了这个参数可能会降低服务端的性能。为什么这么说呢? MySQL客户端发送请求后,接收服务端返回结果的方式有两种: 1. 一种是本地缓存,也就是在本地开一片内存,先把结果存起来。如果你用API开发,对应的就是mysql\_store\_result 方法。 2. 另一种是不缓存,读一个处理一个。如果你用API开发,对应的就是mysql\_use\_result方法。 MySQL客户端默认采用第一种方式,而如果加上–quick参数,就会使用第二种不缓存的方式。 采用不缓存的方式时,如果本地处理得慢,就会导致服务端发送结果被阻塞,因此会让服务端变慢。关于服务端的具体行为,我会在下一篇文章再和你展开说明。 那你会说,既然这样,为什么要给这个参数取名叫作quick呢?这是因为使用这个参数可以达到以下三点效果: * 第一点,就是前面提到的,跳过表名自动补全功能。 * 第二点,mysql\_store\_result需要申请本地内存来缓存查询结果,如果查询结果太大,会耗费较多的本地内存,可能会影响客户端本地机器的性能; * 第三点,是不会把执行命令记录到本地的命令历史文件。 所以你看到了,–quick参数的意思,是让客户端变得更快。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/32-wei-shi-mo-huan-you-kill-bu-diao-de-yu-ju#xiao-jie) 小结 在今天这篇文章中,我首先和你介绍了MySQL中,有些语句和连接“kill不掉”的情况。 这些“kill不掉”的情况,其实是因为发送kill命令的客户端,并没有强行停止目标线程的执行,而只是设置了个状态,并唤醒对应的线程。而被kill的线程,需要执行到判断状态的“埋点”,才会开始进入终止逻辑阶段。并且,终止逻辑本身也是需要耗费时间的。 所以,如果你发现一个线程处于Killed状态,你可以做的事情就是,通过影响系统环境,让这个Killed状态尽快结束。 比如,如果是第一个例子里InnoDB并发度的问题,你就可以临时调大innodb\_thread\_concurrency的值,或者停掉别的线程,让出位子给这个线程执行。 而如果是回滚逻辑由于受到IO资源限制执行得比较慢,就通过减少系统压力让它加速。 做完这些操作后,其实你已经没有办法再对它做什么了,只能等待流程自己完成。 最后,我给你留下一个思考题吧。 如果你碰到一个被killed的事务一直处于回滚状态,你认为是应该直接把MySQL进程强行重启,还是应该让它自己执行完成呢?为什么呢? 你可以把你的结论和分析写在留言区,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/32-wei-shi-mo-huan-you-kill-bu-diao-de-yu-ju#shang-qi-wen-ti-shi-jian) 上期问题时间 我在上一篇文章末尾,给你留下的问题是,希望你分享一下误删数据的处理经验。 **@苍茫 同学提到了一个例子**,我觉得值得跟大家分享一下。运维的同学直接拷贝文本去执行,SQL语句截断,导致数据库执行出错。 从浏览器拷贝文本执行,是一个非常不规范的操作。除了这个例子里面说的SQL语句截断问题,还可能存在乱码问题。 一般这种操作,如果脚本的开发和执行不是同一个人,需要开发同学把脚本放到git上,然后把git地址,以及文件的md5发给运维同学。 这样就要求运维同学在执行命令之前,确认要执行的文件的md5,跟之前开发同学提供的md5相同才能继续执行。 另外,我要特别点赞一下@苍茫 同学复现问题的思路和追查问题的态度。 **@linhui0705 同学提到的“四个脚本”的方法,我非常推崇**。这四个脚本分别是:备份脚本、执行脚本、验证脚本和回滚脚本。如果能够坚持做到,即使出现问题,也是可以很快恢复的,一定能降低出现故障的概率。 不过,这个方案最大的敌人是这样的思想:这是个小操作,不需要这么严格。 **@Knight²º¹⁸ 给了一个保护文件的方法**,我之前没有用过这种方法,不过这确实是一个不错的思路。 为了数据安全和服务稳定,多做点预防方案的设计讨论,总好过故障处理和事后复盘。方案设计讨论会和故障复盘会,这两种会议的会议室气氛完全不一样。经历过的同学一定懂的。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F09%2F77%2F09c1073f99cf71d2fb162a716b5fa577.jpg&width=768&dpr=4&quality=100&sign=e5471b04&sv=2) [Previous31 误删数据后除了跑路,还能怎么办?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/31-wu-shan-shu-ju-hou-chu-le-pao-lu-huan-neng-zen-mo-ban) [Next33 我查这么多数据,会不会把数据库内存打爆?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/33-wo-cha-zhe-mo-duo-shu-ju-hui-bu-hui-ba-shu-ju-ku-nei-cun-da-bao) Last updated 2 years ago Was this helpful? --- # 34 到底可不可以使用join? | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252FttxGrwNigrs7EtKBDJhB%252Fimage.png%3Falt%3Dmedia%26token%3D8c706a13-c9ca-4f9c-bf2a-4e378adbfaea&width=768&dpr=4&quality=100&sign=2868a792&sv=2) 在实际生产中,关于join语句使用的问题,一般会集中在以下两类: 1. 我们DBA不让使用join,使用join有什么问题呢? 2. 如果有两个大小不同的表做join,应该用哪个表做驱动表呢? 今天这篇文章,我就先跟你说说join语句到底是怎么执行的,然后再来回答这两个问题。 为了便于量化分析,我还是创建两个表t1和t2来和你说明。 Copy CREATE TABLE `t2` ( `id` int(11) NOT NULL, `a` int(11) DEFAULT NULL, `b` int(11) DEFAULT NULL, PRIMARY KEY (`id`), KEY `a` (`a`) ) ENGINE=InnoDB; drop procedure idata; delimiter ;; create procedure idata() begin declare i int; set i=1; while(i<=1000)do insert into t2 values(i, i, i); set i=i+1; end while; end;; delimiter ; call idata(); create table t1 like t2; insert into t1 (select * from t2 where id<=100) 可以看到,这两个表都有一个主键索引id和一个索引a,字段b上无索引。存储过程idata()往表t2里插入了1000行数据,在表t1里插入的是100行数据。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/34-dao-di-ke-bu-ke-yi-shi-yong-join#index-nested-loop-join) Index Nested-Loop Join 我们来看一下这个语句: Copy select * from t1 straight_join t2 on (t1.a=t2.a); 如果直接使用join语句,MySQL优化器可能会选择表t1或t2作为驱动表,这样会影响我们分析SQL语句的执行过程。所以,为了便于分析执行过程中的性能问题,我改用straight\_join让MySQL使用固定的连接方式执行查询,这样优化器只会按照我们指定的方式去join。在这个语句里,t1 是驱动表,t2是被驱动表。 现在,我们来看一下这条语句的explain结果。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F4b%2F90%2F4b9cb0e0b83618e01c9bfde44a0ea990.png&width=768&dpr=4&quality=100&sign=d8500716&sv=2) 图1 使用索引字段join的 explain结果 可以看到,在这条语句里,被驱动表t2的字段a上有索引,join过程用上了这个索引,因此这个语句的执行流程是这样的: 1. 从表t1中读入一行数据 R; 2. 从数据行R中,取出a字段到表t2里去查找; 3. 取出表t2中满足条件的行,跟R组成一行,作为结果集的一部分; 4. 重复执行步骤1到3,直到表t1的末尾循环结束。 这个过程是先遍历表t1,然后根据从表t1中取出的每行数据中的a值,去表t2中查找满足条件的记录。在形式上,这个过程就跟我们写程序时的嵌套查询类似,并且可以用上被驱动表的索引,所以我们称之为“Index Nested-Loop Join”,简称NLJ。 它对应的流程图如下所示: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fd8%2Ff6%2Fd83ad1cbd6118603be795b26d38f8df6.jpg&width=768&dpr=4&quality=100&sign=1eecf902&sv=2) 图2 Index Nested-Loop Join算法的执行流程 在这个流程里: 1. 对驱动表t1做了全表扫描,这个过程需要扫描100行; 2. 而对于每一行R,根据a字段去表t2查找,走的是树搜索过程。由于我们构造的数据都是一一对应的,因此每次的搜索过程都只扫描一行,也是总共扫描100行; 3. 所以,整个执行流程,总扫描行数是200。 现在我们知道了这个过程,再试着回答一下文章开头的两个问题。 先看第一个问题:**能不能使用join?** 假设不使用join,那我们就只能用单表查询。我们看看上面这条语句的需求,用单表查询怎么实现。 1. 执行`select * from t1`,查出表t1的所有数据,这里有100行; 2. 循环遍历这100行数据: * 从每一行R取出字段a的值$R.a; * 执行`select * from t2 where a=$R.a`; * 把返回的结果和R构成结果集的一行。 可以看到,在这个查询过程,也是扫描了200行,但是总共执行了101条语句,比直接join多了100次交互。除此之外,客户端还要自己拼接SQL语句和结果。 显然,这么做还不如直接join好。 我们再来看看第二个问题:**怎么选择驱动表?** 在这个join语句执行过程中,驱动表是走全表扫描,而被驱动表是走树搜索。 假设被驱动表的行数是M。每次在被驱动表查一行数据,要先搜索索引a,再搜索主键索引。每次搜索一棵树近似复杂度是以2为底的M的对数,记为log2M,所以在被驱动表上查一行的时间复杂度是 2\*log2M。 假设驱动表的行数是N,执行过程就要扫描驱动表N行,然后对于每一行,到被驱动表上匹配一次。 因此整个执行过程,近似复杂度是 N + N\*2\*log2M。 显然,N对扫描行数的影响更大,因此应该让小表来做驱动表。 > 如果你没觉得这个影响有那么“显然”, 可以这么理解:N扩大1000倍的话,扫描行数就会扩大1000倍;而M扩大1000倍,扫描行数扩大不到10倍。 到这里小结一下,通过上面的分析我们得到了两个结论: 1. 使用join语句,性能比强行拆成多个单表执行SQL语句的性能要好; 2. 如果使用join语句的话,需要让小表做驱动表。 但是,你需要注意,这个结论的前提是“可以使用被驱动表的索引”。 接下来,我们再看看被驱动表用不上索引的情况。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/34-dao-di-ke-bu-ke-yi-shi-yong-join#simple-nested-loop-join) Simple Nested-Loop Join 现在,我们把SQL语句改成这样: Copy select * from t1 straight_join t2 on (t1.a=t2.b); 由于表t2的字段b上没有索引,因此再用图2的执行流程时,每次到t2去匹配的时候,就要做一次全表扫描。 你可以先设想一下这个问题,继续使用图2的算法,是不是可以得到正确的结果呢?如果只看结果的话,这个算法是正确的,而且这个算法也有一个名字,叫做“Simple Nested-Loop Join”。 但是,这样算来,这个SQL请求就要扫描表t2多达100次,总共扫描100\*1000=10万行。 这还只是两个小表,如果t1和t2都是10万行的表(当然了,这也还是属于小表的范围),就要扫描100亿行,这个算法看上去太“笨重”了。 当然,MySQL也没有使用这个Simple Nested-Loop Join算法,而是使用了另一个叫作“Block Nested-Loop Join”的算法,简称BNL。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/34-dao-di-ke-bu-ke-yi-shi-yong-join#block-nested-loop-join) Block Nested-Loop Join 这时候,被驱动表上没有可用的索引,算法的流程是这样的: 1. 把表t1的数据读入线程内存join\_buffer中,由于我们这个语句中写的是select \*,因此是把整个表t1放入了内存; 2. 扫描表t2,把表t2中的每一行取出来,跟join\_buffer中的数据做对比,满足join条件的,作为结果集的一部分返回。 这个过程的流程图如下: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F15%2F73%2F15ae4f17c46bf71e8349a8f2ef70d573.jpg&width=768&dpr=4&quality=100&sign=ea70c28&sv=2) 图3 Block Nested-Loop Join 算法的执行流程 对应地,这条SQL语句的explain结果如下所示: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F67%2Fe1%2F676921fa0883e9463dd34fb2bc5e87e1.png&width=768&dpr=4&quality=100&sign=9819fb1a&sv=2) 图4 不使用索引字段join的 explain结果 可以看到,在这个过程中,对表t1和t2都做了一次全表扫描,因此总的扫描行数是1100。由于join\_buffer是以无序数组的方式组织的,因此对表t2中的每一行,都要做100次判断,总共需要在内存中做的判断次数是:100\*1000=10万次。 前面我们说过,如果使用Simple Nested-Loop Join算法进行查询,扫描行数也是10万行。因此,从时间复杂度上来说,这两个算法是一样的。但是,Block Nested-Loop Join算法的这10万次判断是内存操作,速度上会快很多,性能也更好。 接下来,我们来看一下,在这种情况下,应该选择哪个表做驱动表。 假设小表的行数是N,大表的行数是M,那么在这个算法里: 1. 两个表都做一次全表扫描,所以总的扫描行数是M+N; 2. 内存中的判断次数是M\*N。 可以看到,调换这两个算式中的M和N没差别,因此这时候选择大表还是小表做驱动表,执行耗时是一样的。 然后,你可能马上就会问了,这个例子里表t1才100行,要是表t1是一个大表,join\_buffer放不下怎么办呢? join\_buffer的大小是由参数join\_buffer\_size设定的,默认值是256k。**如果放不下表t1的所有数据话,策略很简单,就是分段放。**我把join\_buffer\_size改成1200,再执行: Copy select * from t1 straight_join t2 on (t1.a=t2.b); 执行过程就变成了: 1. 扫描表t1,顺序读取数据行放入join\_buffer中,放完第88行join\_buffer满了,继续第2步; 2. 扫描表t2,把t2中的每一行取出来,跟join\_buffer中的数据做对比,满足join条件的,作为结果集的一部分返回; 3. 清空join\_buffer; 4. 继续扫描表t1,顺序读取最后的12行数据放入join\_buffer中,继续执行第2步。 执行流程图也就变成这样: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F69%2Fc4%2F695adf810fcdb07e393467bcfd2f6ac4.jpg&width=768&dpr=4&quality=100&sign=569dfe0c&sv=2) 图5 Block Nested-Loop Join -- 两段 图中的步骤4和5,表示清空join\_buffer再复用。 这个流程才体现出了这个算法名字中“Block”的由来,表示“分块去join”。 可以看到,这时候由于表t1被分成了两次放入join\_buffer中,导致表t2会被扫描两次。虽然分成两次放入join\_buffer,但是判断等值条件的次数还是不变的,依然是(88+12)\*1000=10万次。 我们再来看下,在这种情况下驱动表的选择问题。 假设,驱动表的数据行数是N,需要分K段才能完成算法流程,被驱动表的数据行数是M。 注意,这里的K不是常数,N越大K就会越大,因此把K表示为λ\*N,显然λ的取值范围是(0,1)。 所以,在这个算法的执行过程中: 1. 扫描行数是 N+λ\*N\*M; 2. 内存判断 N\*M次。 显然,内存判断次数是不受选择哪个表作为驱动表影响的。而考虑到扫描行数,在M和N大小确定的情况下,N小一些,整个算式的结果会更小。 所以结论是,应该让小表当驱动表。 当然,你会发现,在N+λ\*N\*M这个式子里,λ才是影响扫描行数的关键因素,这个值越小越好。 刚刚我们说了N越大,分段数K越大。那么,N固定的时候,什么参数会影响K的大小呢?(也就是λ的大小)答案是join\_buffer\_size。join\_buffer\_size越大,一次可以放入的行越多,分成的段数也就越少,对被驱动表的全表扫描次数就越少。 这就是为什么,你可能会看到一些建议告诉你,如果你的join语句很慢,就把join\_buffer\_size改大。 理解了MySQL执行join的两种算法,现在我们再来试着**回答文章开头的两个问题**。 第一个问题:能不能使用join语句? 1. 如果可以使用Index Nested-Loop Join算法,也就是说可以用上被驱动表上的索引,其实是没问题的; 2. 如果使用Block Nested-Loop Join算法,扫描行数就会过多。尤其是在大表上的join操作,这样可能要扫描被驱动表很多次,会占用大量的系统资源。所以这种join尽量不要用。 所以你在判断要不要使用join语句时,就是看explain结果里面,Extra字段里面有没有出现“Block Nested Loop”字样。 第二个问题是:如果要使用join,应该选择大表做驱动表还是选择小表做驱动表? 1. 如果是Index Nested-Loop Join算法,应该选择小表做驱动表; 2. 如果是Block Nested-Loop Join算法: * 在join\_buffer\_size足够大的时候,是一样的; * 在join\_buffer\_size不够大的时候(这种情况更常见),应该选择小表做驱动表。 所以,这个问题的结论就是,总是应该使用小表做驱动表。 当然了,这里我需要说明下,**什么叫作“小表”**。 我们前面的例子是没有加条件的。如果我在语句的where条件加上 t2.id<=50这个限定条件,再来看下这两条语句: Copy select * from t1 straight_join t2 on (t1.b=t2.b) where t2.id<=50; select * from t2 straight_join t1 on (t1.b=t2.b) where t2.id<=50; 注意,为了让两条语句的被驱动表都用不上索引,所以join字段都使用了没有索引的字段b。 但如果是用第二个语句的话,join\_buffer只需要放入t2的前50行,显然是更好的。所以这里,“t2的前50行”是那个相对小的表,也就是“小表”。 我们再来看另外一组例子: Copy select t1.b,t2.* from t1 straight_join t2 on (t1.b=t2.b) where t2.id<=100; select t1.b,t2.* from t2 straight_join t1 on (t1.b=t2.b) where t2.id<=100; 这个例子里,表t1 和 t2都是只有100行参加join。但是,这两条语句每次查询放入join\_buffer中的数据是不一样的: * 表t1只查字段b,因此如果把t1放到join\_buffer中,则join\_buffer中只需要放入b的值; * 表t2需要查所有的字段,因此如果把表t2放到join\_buffer中的话,就需要放入三个字段id、a和b。 这里,我们应该选择表t1作为驱动表。也就是说在这个例子里,“只需要一列参与join的表t1”是那个相对小的表。 所以,更准确地说,**在决定哪个表做驱动表的时候,应该是两个表按照各自的条件过滤,过滤完成之后,计算参与join的各个字段的总数据量,数据量小的那个表,就是“小表”,应该作为驱动表。** ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/34-dao-di-ke-bu-ke-yi-shi-yong-join#xiao-jie) 小结 今天,我和你介绍了MySQL执行join语句的两种可能算法,这两种算法是由能否使用被驱动表的索引决定的。而能否用上被驱动表的索引,对join语句的性能影响很大。 通过对Index Nested-Loop Join和Block Nested-Loop Join两个算法执行过程的分析,我们也得到了文章开头两个问题的答案: 1. 如果可以使用被驱动表的索引,join语句还是有其优势的; 2. 不能使用被驱动表的索引,只能使用Block Nested-Loop Join算法,这样的语句就尽量不要使用; 3. 在使用join的时候,应该让小表做驱动表。 最后,又到了今天的问题时间。 我们在上文说到,使用Block Nested-Loop Join算法,可能会因为join\_buffer不够大,需要对被驱动表做多次全表扫描。 我的问题是,如果被驱动表是一个大表,并且是一个冷数据表,除了查询过程中可能会导致IO压力大以外,你觉得对这个MySQL服务还有什么更严重的影响吗?(这个问题需要结合上一篇文章的知识点) 你可以把你的结论和分析写在留言区,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/34-dao-di-ke-bu-ke-yi-shi-yong-join#shang-qi-wen-ti-shi-jian) 上期问题时间 我在上一篇文章最后留下的问题是,如果客户端由于压力过大,迟迟不能接收数据,会对服务端造成什么严重的影响。 这个问题的核心是,造成了“长事务”。 至于长事务的影响,就要结合我们前面文章中提到的锁、MVCC的知识点了。 * 如果前面的语句有更新,意味着它们在占用着行锁,会导致别的语句更新被锁住; * 当然读的事务也有问题,就是会导致undo log不能被回收,导致回滚段空间膨胀。 评论区留言点赞板: > @老杨同志 提到了更新之间会互相等锁的问题。同一个事务,更新之后要尽快提交,不要做没必要的查询,尤其是不要执行需要返回大量数据的查询; @长杰 同学提到了undo表空间变大,db服务堵塞,服务端磁盘空间不足的例子。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F09%2F77%2F09c1073f99cf71d2fb162a716b5fa577.jpg&width=768&dpr=4&quality=100&sign=e5471b04&sv=2) [Previous33 我查这么多数据,会不会把数据库内存打爆?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/33-wo-cha-zhe-mo-duo-shu-ju-hui-bu-hui-ba-shu-ju-ku-nei-cun-da-bao) [Next35 join语句怎么优化?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/35-join-yu-ju-zen-mo-you-hua) Last updated 2 years ago Was this helpful? --- # 40 insert语句的锁为什么这么多? | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252Fl9CE9xbWQLUnr8DtRmE1%252Fimage.png%3Falt%3Dmedia%26token%3Dcf13e287-7b38-4a5b-97b5-8316d8f08c1d&width=768&dpr=4&quality=100&sign=2549980d&sv=2) 在上一篇文章中,我提到MySQL对自增主键锁做了优化,尽量在申请到自增id以后,就释放自增锁。 因此,insert语句是一个很轻量的操作。不过,这个结论对于“普通的insert语句”才有效。也就是说,还有些insert语句是属于“特殊情况”的,在执行过程中需要给其他资源加锁,或者无法在申请到自增id以后就立马释放自增锁。 那么,今天这篇文章,我们就一起来聊聊这个话题。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/40-insert-yu-ju-de-suo-wei-shi-mo-zhe-mo-duo#insert-select-yu-ju) insert … select 语句 我们先从昨天的问题说起吧。表t和t2的表结构、初始化数据语句如下,今天的例子我们还是针对这两个表展开。 Copy CREATE TABLE `t` ( `id` int(11) NOT NULL AUTO_INCREMENT, `c` int(11) DEFAULT NULL, `d` int(11) DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `c` (`c`) ) ENGINE=InnoDB; insert into t values(null, 1,1); insert into t values(null, 2,2); insert into t values(null, 3,3); insert into t values(null, 4,4); create table t2 like t 现在,我们一起来看看为什么在可重复读隔离级别下,binlog\_format=statement时执行: Copy insert into t2(c,d) select c,d from t; 这个语句时,需要对表t的所有行和间隙加锁呢? 其实,这个问题我们需要考虑的还是日志和数据的一致性。我们看下这个执行序列: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F33%2F86%2F33e513ee55d5700dc67f32bcdafb9386.png&width=768&dpr=4&quality=100&sign=74f7eec3&sv=2) 图1 并发insert场景 实际的执行效果是,如果session B先执行,由于这个语句对表t主键索引加了(-∞,1\]这个next-key lock,会在语句执行完成后,才允许session A的insert语句执行。 但如果没有锁的话,就可能出现session B的insert语句先执行,但是后写入binlog的情况。于是,在binlog\_format=statement的情况下,binlog里面就记录了这样的语句序列: Copy insert into t values(-1,-1,-1); insert into t2(c,d) select c,d from t; 这个语句到了备库执行,就会把id=-1这一行也写到表t2中,出现主备不一致。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/40-insert-yu-ju-de-suo-wei-shi-mo-zhe-mo-duo#insert-xun-huan-xie-ru) insert 循环写入 当然了,执行insert … select 的时候,对目标表也不是锁全表,而是只锁住需要访问的资源。 如果现在有这么一个需求:要往表t2中插入一行数据,这一行的c值是表t中c值的最大值加1。 此时,我们可以这么写这条SQL语句 : Copy insert into t2(c,d) (select c+1, d from t force index(c) order by c desc limit 1); 这个语句的加锁范围,就是表t索引c上的(3,4\]和(4,supremum\]这两个next-key lock,以及主键索引上id=4这一行。 它的执行流程也比较简单,从表t中按照索引c倒序,扫描第一行,拿到结果写入到表t2中。 因此整条语句的扫描行数是1。 这个语句执行的慢查询日志(slow log),如下图所示: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F3e%2F74%2F3efdf8256309a44e23d93089459eda74.png&width=768&dpr=4&quality=100&sign=ea988867&sv=2) 图2 慢查询日志--将数据插入表t2 通过这个慢查询日志,我们看到Rows\_examined=1,正好验证了执行这条语句的扫描行数为1。 那么,如果我们是要把这样的一行数据插入到表t中的话: Copy insert into t(c,d) (select c+1, d from t force index(c) order by c desc limit 1); 语句的执行流程是怎样的?扫描行数又是多少呢? 这时候,我们再看慢查询日志就会发现不对了。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F6f%2F18%2F6f90b04c09188bff11dae6e788abb918.png&width=768&dpr=4&quality=100&sign=40a03daa&sv=2) 图3 慢查询日志--将数据插入表t 可以看到,这时候的Rows\_examined的值是5。 我在前面的文章中提到过,希望你都能够学会用explain的结果来“脑补”整条语句的执行过程。今天,我们就来一起试试。 如图4所示就是这条语句的explain结果。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fd7%2F2a%2Fd7270781ee3f216325b73bd53999b82a.png&width=768&dpr=4&quality=100&sign=835f3754&sv=2) 图4 explain结果 从Extra字段可以看到“Using temporary”字样,表示这个语句用到了临时表。也就是说,执行过程中,需要把表t的内容读出来,写入临时表。 图中rows显示的是1,我们不妨先对这个语句的执行流程做一个猜测:如果说是把子查询的结果读出来(扫描1行),写入临时表,然后再从临时表读出来(扫描1行),写回表t中。那么,这个语句的扫描行数就应该是2,而不是5。 所以,这个猜测不对。实际上,Explain结果里的rows=1是因为受到了limit 1 的影响。 从另一个角度考虑的话,我们可以看看InnoDB扫描了多少行。如图5所示,是在执行这个语句前后查看Innodb\_rows\_read的结果。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F48%2Fd7%2F489281d8029e8f60979cb7c4494010d7.png&width=768&dpr=4&quality=100&sign=1263afc6&sv=2) 图5 查看 Innodb\_rows\_read变化 可以看到,这个语句执行前后,Innodb\_rows\_read的值增加了4。因为默认临时表是使用Memory引擎的,所以这4行查的都是表t,也就是说对表t做了全表扫描。 这样,我们就把整个执行过程理清楚了: 1. 创建临时表,表里有两个字段c和d。 2. 按照索引c扫描表t,依次取c=4、3、2、1,然后回表,读到c和d的值写入临时表。这时,Rows\_examined=4。 3. 由于语义里面有limit 1,所以只取了临时表的第一行,再插入到表t中。这时,Rows\_examined的值加1,变成了5。 也就是说,这个语句会导致在表t上做全表扫描,并且会给索引c上的所有间隙都加上共享的next-key lock。所以,这个语句执行期间,其他事务不能在这个表上插入数据。 至于这个语句的执行为什么需要临时表,原因是这类一边遍历数据,一边更新数据的情况,如果读出来的数据直接写回原表,就可能在遍历过程中,读到刚刚插入的记录,新插入的记录如果参与计算逻辑,就跟语义不符。 由于实现上这个语句没有在子查询中就直接使用limit 1,从而导致了这个语句的执行需要遍历整个表t。它的优化方法也比较简单,就是用前面介绍的方法,先insert into到临时表temp\_t,这样就只需要扫描一行;然后再从表temp\_t里面取出这行数据插入表t1。 当然,由于这个语句涉及的数据量很小,你可以考虑使用内存临时表来做这个优化。使用内存临时表优化时,语句序列的写法如下: Copy create temporary table temp_t(c int,d int) engine=memory; insert into temp_t (select c+1, d from t force index(c) order by c desc limit 1); insert into t select * from temp_t; drop table temp_t; ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/40-insert-yu-ju-de-suo-wei-shi-mo-zhe-mo-duo#insert-wei-yi-jian-chong-tu) insert 唯一键冲突 前面的两个例子是使用insert … select的情况,接下来我要介绍的这个例子就是最常见的insert语句出现唯一键冲突的情况。 对于有唯一键的表,插入数据时出现唯一键冲突也是常见的情况了。我先给你举一个简单的唯一键冲突的例子。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F83%2Fca%2F83fb2d877932941b230d6b5be8cca6ca.png&width=768&dpr=4&quality=100&sign=9229ce62&sv=2) 图6 唯一键冲突加锁 这个例子也是在可重复读(repeatable read)隔离级别下执行的。可以看到,session B要执行的insert语句进入了锁等待状态。 也就是说,session A执行的insert语句,发生唯一键冲突的时候,并不只是简单地报错返回,还在冲突的索引上加了锁。我们前面说过,一个next-key lock就是由它右边界的值定义的。这时候,session A持有索引c上的(5,10\]共享next-key lock(读锁)。 至于为什么要加这个读锁,其实我也没有找到合理的解释。从作用上来看,这样做可以避免这一行被别的事务删掉。 这里[官方文档](https://dev.mysql.com/doc/refman/8.0/en/innodb-locks-set.html) 有一个描述错误,认为如果冲突的是主键索引,就加记录锁,唯一索引才加next-key lock。但实际上,这两类索引冲突加的都是next-key lock。 > 备注:这个bug,是我在写这篇文章查阅文档时发现的,已经[发给官方](https://bugs.mysql.com/bug.php?id=93806) > 并被verified了。 有同学在前面文章的评论区问到,在有多个唯一索引的表中并发插入数据时,会出现死锁。但是,由于他没有提供复现方法或者现场,我也无法做分析。所以,我建议你在评论区发问题的时候,尽量同时附上复现方法,或者现场信息,这样我才好和你一起分析问题。 这里,我就先和你分享一个经典的死锁场景,如果你还遇到过其他唯一键冲突导致的死锁场景,也欢迎给我留言。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F63%2F2d%2F63658eb26e7a03b49f123fceed94cd2d.png&width=768&dpr=4&quality=100&sign=312dc6b0&sv=2) 图7 唯一键冲突--死锁 在session A执行rollback语句回滚的时候,session C几乎同时发现死锁并返回。 这个死锁产生的逻辑是这样的: 1. 在T1时刻,启动session A,并执行insert语句,此时在索引c的c=5上加了记录锁。注意,这个索引是唯一索引,因此退化为记录锁(如果你的印象模糊了,可以回顾下[第21篇文章](https://time.geekbang.org/column/article/75659) 介绍的加锁规则)。 2. 在T2时刻,session B要执行相同的insert语句,发现了唯一键冲突,加上读锁;同样地,session C也在索引c上,c=5这一个记录上,加了读锁。 3. T3时刻,session A回滚。这时候,session B和session C都试图继续执行插入操作,都要加上写锁。两个session都要等待对方的行锁,所以就出现了死锁。 这个流程的状态变化图如下所示。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F3e%2Fb8%2F3e0bf1a1241931c14360e73fd10032b8.jpg&width=768&dpr=4&quality=100&sign=98ce8fa5&sv=2) 图8 状态变化图--死锁 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/40-insert-yu-ju-de-suo-wei-shi-mo-zhe-mo-duo#insert-into-...-on-duplicate-key-update) insert into … on duplicate key update 上面这个例子是主键冲突后直接报错,如果是改写成 Copy insert into t values(11,10,10) on duplicate key update d=100; 的话,就会给索引c上(5,10\] 加一个排他的next-key lock(写锁)。 **insert into … on duplicate key update 这个语义的逻辑是,插入一行数据,如果碰到唯一键约束,就执行后面的更新语句。** 注意,如果有多个列违反了唯一性约束,就会按照索引的顺序,修改跟第一个索引冲突的行。 现在表t里面已经有了(1,1,1)和(2,2,2)这两行,我们再来看看下面这个语句执行的效果: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F5f%2F02%2F5f384d6671c87a60e1ec7e490447d702.png&width=768&dpr=4&quality=100&sign=9ea4e26f&sv=2) 图9 两个唯一键同时冲突 可以看到,主键id是先判断的,MySQL认为这个语句跟id=2这一行冲突,所以修改的是id=2的行。 需要注意的是,执行这条语句的affected rows返回的是2,很容易造成误解。实际上,真正更新的只有一行,只是在代码实现上,insert和update都认为自己成功了,update计数加了1, insert计数也加了1。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/40-insert-yu-ju-de-suo-wei-shi-mo-zhe-mo-duo#xiao-jie) 小结 今天这篇文章,我和你介绍了几种特殊情况下的insert语句。 insert … select 是很常见的在两个表之间拷贝数据的方法。你需要注意,在可重复读隔离级别下,这个语句会给select的表里扫描到的记录和间隙加读锁。 而如果insert和select的对象是同一个表,则有可能会造成循环写入。这种情况下,我们需要引入用户临时表来做优化。 insert 语句如果出现唯一键冲突,会在冲突的唯一值上加共享的next-key lock(S锁)。因此,碰到由于唯一键约束导致报错后,要尽快提交或回滚事务,避免加锁时间过长。 最后,我给你留一个问题吧。 你平时在两个表之间拷贝数据用的是什么方法,有什么注意事项吗?在你的应用场景里,这个方法,相较于其他方法的优势是什么呢? 你可以把你的经验和分析写在评论区,我会在下一篇文章的末尾选取有趣的评论来和你一起分析。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/40-insert-yu-ju-de-suo-wei-shi-mo-zhe-mo-duo#shang-qi-wen-ti-shi-jian) 上期问题时间 我们已经在文章中回答了上期问题。 有同学提到,如果在insert … select 执行期间有其他线程操作原表,会导致逻辑错误。其实,这是不会的,如果不加锁,就是快照读。 一条语句执行期间,它的一致性视图是不会修改的,所以即使有其他事务修改了原表的数据,也不会影响这条语句看到的数据。 评论区留言点赞板: > @长杰 同学回答得非常准确。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F09%2F77%2F09c1073f99cf71d2fb162a716b5fa577.jpg&width=768&dpr=4&quality=100&sign=e5471b04&sv=2) [Previous39 自增主键为什么不是连续的?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/39-zi-zeng-zhu-jian-wei-shi-mo-bu-shi-lian-xu-de) [Next41 怎么最快地复制一张表?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/41-zen-mo-zui-kuai-di-fu-zhi-yi-zhang-biao) Last updated 2 years ago Was this helpful? --- # 41 怎么最快地复制一张表? | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252FRI3voOBNHbNeG8EFEB2W%252Fimage.png%3Falt%3Dmedia%26token%3D4b333368-9fad-458e-92bc-581871bcea54&width=768&dpr=4&quality=100&sign=fa19520e&sv=2) 我在上一篇文章最后,给你留下的问题是怎么在两张表中拷贝数据。如果可以控制对源表的扫描行数和加锁范围很小的话,我们简单地使用insert … select 语句即可实现。 当然,为了避免对源表加读锁,更稳妥的方案是先将数据写到外部文本文件,然后再写回目标表。这时,有两种常用的方法。接下来的内容,我会和你详细展开一下这两种方法。 为了便于说明,我还是先创建一个表db1.t,并插入1000行数据,同时创建一个相同结构的表db2.t。 Copy create database db1; use db1; create table t(id int primary key, a int, b int, index(a))engine=innodb; delimiter ;; create procedure idata() begin declare i int; set i=1; while(i<=1000)do insert into t values(i,i,i); set i=i+1; end while; end;; delimiter ; call idata(); create database db2; create table db2.t like db1.t 假设,我们要把db1.t里面a>900的数据行导出来,插入到db2.t中。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/41-zen-mo-zui-kuai-di-fu-zhi-yi-zhang-biao#mysqldump-fang-fa) mysqldump方法 一种方法是,使用mysqldump命令将数据导出成一组INSERT语句。你可以使用下面的命令: Copy 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 把结果输出到临时文件。 这条命令中,主要参数含义如下: 1. –single-transaction的作用是,在导出数据的时候不需要对表db1.t加表锁,而是使用START TRANSACTION WITH CONSISTENT SNAPSHOT的方法; 2. –add-locks设置为0,表示在输出的文件结果里,不增加" LOCK TABLES `t` WRITE;" ; 3. –no-create-info的意思是,不需要导出表结构; 4. –set-gtid-purged=off表示的是,不输出跟GTID相关的信息; 5. –result-file指定了输出文件的路径,其中client表示生成的文件是在客户端机器上的。 通过这条mysqldump命令生成的t.sql文件中就包含了如图1所示的INSERT语句。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F8a%2Fde%2F8acdcefcaf5c9940570bf7e8f73dbdde.png&width=768&dpr=4&quality=100&sign=8723c985&sv=2) 图1 mysqldump输出文件的部分结果 可以看到,一条INSERT语句里面会包含多个value对,这是为了后续用这个文件来写入数据的时候,执行速度可以更快。 如果你希望生成的文件中一条INSERT语句只插入一行数据的话,可以在执行mysqldump命令时,加上参数–skip-extended-insert。 然后,你可以通过下面这条命令,将这些INSERT语句放到db2库里去执行。 Copy mysql -h127.0.0.1 -P13000 -uroot db2 -e "source /client_tmp/t.sql" 需要说明的是,source并不是一条SQL语句,而是一个客户端命令。mysql客户端执行这个命令的流程是这样的: 1. 打开文件,默认以分号为结尾读取一条条的SQL语句; 2. 将SQL语句发送到服务端执行。 也就是说,服务端执行的并不是这个“source t.sql"语句,而是INSERT语句。所以,不论是在慢查询日志(slow log),还是在binlog,记录的都是这些要被真正执行的INSERT语句。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/41-zen-mo-zui-kuai-di-fu-zhi-yi-zhang-biao#dao-chu-csv-wen-jian) 导出CSV文件 另一种方法是直接将结果导出成.csv文件。MySQL提供了下面的语法,用来将查询结果导出到服务端本地目录: Copy select * from db1.t where a>900 into outfile '/server_tmp/t.csv'; 我们在使用这条语句时,需要注意如下几点。 1. 这条语句会将结果保存在服务端。如果你执行命令的客户端和MySQL服务端不在同一个机器上,客户端机器的临时目录下是不会生成t.csv文件的。 2. into outfile指定了文件的生成位置(/server\_tmp/),这个位置必须受参数secure\_file\_priv的限制。参数secure\_file\_priv的可选值和作用分别是: * 如果设置为empty,表示不限制文件生成的位置,这是不安全的设置; * 如果设置为一个表示路径的字符串,就要求生成的文件只能放在这个指定的目录,或者它的子目录; * 如果设置为NULL,就表示禁止在这个MySQL实例上执行select … into outfile 操作。 3. 这条命令不会帮你覆盖文件,因此你需要确保/server\_tmp/t.csv这个文件不存在,否则执行语句时就会因为有同名文件的存在而报错。 4. 这条命令生成的文本文件中,原则上一个数据行对应文本文件的一行。但是,如果字段中包含换行符,在生成的文本中也会有换行符。不过类似换行符、制表符这类符号,前面都会跟上“\\”这个转义符,这样就可以跟字段之间、数据行之间的分隔符区分开。 得到.csv导出文件后,你就可以用下面的load data命令将数据导入到目标表db2.t中。 Copy load data infile '/server_tmp/t.csv' into table db2.t; 这条语句的执行流程如下所示。 1. 打开文件/server\_tmp/t.csv,以制表符(\\t)作为字段间的分隔符,以换行符(\\n)作为记录之间的分隔符,进行数据读取; 2. 启动事务。 3. 判断每一行的字段数与表db2.t是否相同: * 若不相同,则直接报错,事务回滚; * 若相同,则构造成一行,调用InnoDB引擎接口,写入到表中。 4. 重复步骤3,直到/server\_tmp/t.csv整个文件读入完成,提交事务。 你可能有一个疑问,**如果binlog\_format=statement,这个load语句记录到binlog里以后,怎么在备库重放呢?** 由于/server\_tmp/t.csv文件只保存在主库所在的主机上,如果只是把这条语句原文写到binlog中,在备库执行的时候,备库的本地机器上没有这个文件,就会导致主备同步停止。 所以,这条语句执行的完整流程,其实是下面这样的。 1. 主库执行完成后,将/server\_tmp/t.csv文件的内容直接写到binlog文件中。 2. 往binlog文件中写入语句load data local infile ‘/tmp/SQL\_LOAD\_MB-1-0’ INTO TABLE \`db2\`.\`t\`。 3. 把这个binlog日志传到备库。 4. 备库的apply线程在执行这个事务日志时: a. 先将binlog中t.csv文件的内容读出来,写入到本地临时目录/tmp/SQL\_LOAD\_MB-1-0 中; b. 再执行load data语句,往备库的db2.t表中插入跟主库相同的数据。 执行流程如图2所示: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F3a%2Ffd%2F3a6790bc933af5ac45a75deba0f52cfd.jpg&width=768&dpr=4&quality=100&sign=a56fb99c&sv=2) 图2 load data的同步流程 注意,这里备库执行的load data语句里面,多了一个“local”。它的意思是“将执行这条命令的客户端所在机器的本地文件/tmp/SQL\_LOAD\_MB-1-0的内容,加载到目标表db2.t中”。 也就是说,**load data命令有两种用法**: 1. 不加“local”,是读取服务端的文件,这个文件必须在secure\_file\_priv指定的目录或子目录下; 2. 加上“local”,读取的是客户端的文件,只要mysql客户端有访问这个文件的权限即可。这时候,MySQL客户端会先把本地文件传给服务端,然后执行上述的load data流程。 另外需要注意的是,**select …into outfile方法不会生成表结构文件**, 所以我们导数据时还需要单独的命令得到表结构定义。mysqldump提供了一个–tab参数,可以同时导出表结构定义文件和csv数据文件。这条命令的使用方法如下: Copy mysqldump -h$host -P$port -u$user ---single-transaction --set-gtid-purged=OFF db1 t --where="a>900" --tab=$secure_file_priv 这条命令会在$secure\_file\_priv定义的目录下,创建一个t.sql文件保存建表语句,同时创建一个t.txt文件保存CSV数据。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/41-zen-mo-zui-kuai-di-fu-zhi-yi-zhang-biao#wu-li-kao-bei-fang-fa) 物理拷贝方法 前面我们提到的mysqldump方法和导出CSV文件的方法,都是逻辑导数据的方法,也就是将数据从表db1.t中读出来,生成文本,然后再写入目标表db2.t中。 你可能会问,有物理导数据的方法吗?比如,直接把db1.t表的.frm文件和.ibd文件拷贝到db2目录下,是否可行呢? 答案是不行的。 因为,一个InnoDB表,除了包含这两个物理文件外,还需要在数据字典中注册。直接拷贝这两个文件的话,因为数据字典中没有db2.t这个表,系统是不会识别和接受它们的。 不过,在MySQL 5.6版本引入了**可传输表空间**(transportable tablespace)的方法,可以通过导出+导入表空间的方式,实现物理拷贝表的功能。 假设我们现在的目标是在db1库下,复制一个跟表t相同的表r,具体的执行步骤如下: 1. 执行 create table r like t,创建一个相同表结构的空表; 2. 执行alter table r discard tablespace,这时候r.ibd文件会被删除; 3. 执行flush table t for export,这时候db1目录下会生成一个t.cfg文件; 4. 在db1目录下执行cp t.cfg r.cfg; cp t.ibd r.ibd;这两个命令(这里需要注意的是,拷贝得到的两个文件,MySQL进程要有读写权限); 5. 执行unlock tables,这时候t.cfg文件会被删除; 6. 执行alter table r import tablespace,将这个r.ibd文件作为表r的新的表空间,由于这个文件的数据内容和t.ibd是相同的,所以表r中就有了和表t相同的数据。 至此,拷贝表数据的操作就完成了。这个流程的执行过程图如下: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fba%2Fa7%2Fba1ced43eed4a55d49435c062fee21a7.jpg&width=768&dpr=4&quality=100&sign=1296ef54&sv=2) 图3 物理拷贝表 关于拷贝表的这个流程,有以下几个注意点: 1. 在第3步执行完flsuh table命令之后,db1.t整个表处于只读状态,直到执行unlock tables命令后才释放读锁; 2. 在执行import tablespace的时候,为了让文件里的表空间id和数据字典中的一致,会修改r.ibd的表空间id。而这个表空间id存在于每一个数据页中。因此,如果是一个很大的文件(比如TB级别),每个数据页都需要修改,所以你会看到这个import语句的执行是需要一些时间的。当然,如果是相比于逻辑导入的方法,import语句的耗时是非常短的。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/41-zen-mo-zui-kuai-di-fu-zhi-yi-zhang-biao#xiao-jie) 小结 今天这篇文章,我和你介绍了三种将一个表的数据导入到另外一个表中的方法。 我们来对比一下这三种方法的优缺点。 1. 物理拷贝的方式速度最快,尤其对于大表拷贝来说是最快的方法。如果出现误删表的情况,用备份恢复出误删之前的临时库,然后再把临时库中的表拷贝到生产库上,是恢复数据最快的方法。但是,这种方法的使用也有一定的局限性: * 必须是全表拷贝,不能只拷贝部分数据; * 需要到服务器上拷贝数据,在用户无法登录数据库主机的场景下无法使用; * 由于是通过拷贝物理文件实现的,源表和目标表都是使用InnoDB引擎时才能使用。 2. 用mysqldump生成包含INSERT语句文件的方法,可以在where参数增加过滤条件,来实现只导出部分数据。这个方式的不足之一是,不能使用join这种比较复杂的where条件写法。 3. 用select … into outfile的方法是最灵活的,支持所有的SQL写法。但,这个方法的缺点之一就是,每次只能导出一张表的数据,而且表结构也需要另外的语句单独备份。 后两种方式都是逻辑备份方式,是可以跨引擎使用的。 最后,我给你留下一个思考题吧。 我们前面介绍binlog\_format=statement的时候,binlog记录的load data命令是带local的。既然这条命令是发送到备库去执行的,那么备库执行的时候也是本地执行,为什么需要这个local呢?如果写到binlog中的命令不带local,又会出现什么问题呢? 你可以把你的分析写在评论区,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/41-zen-mo-zui-kuai-di-fu-zhi-yi-zhang-biao#shang-qi-wen-ti-shi-jian) 上期问题时间 我在上篇文章最后给你留下的思考题,已经在今天这篇文章的正文部分做了回答。 上篇文章的评论区有几个非常好的留言,我在这里和你分享一下。 @huolang 同学提了一个问题:如果sessionA拿到c=5的记录锁是写锁,那为什么sessionB和sessionC还能加c=5的读锁呢? 这是因为next-key lock是先加间隙锁,再加记录锁的。加间隙锁成功了,加记录锁就会被堵住。如果你对这个过程有疑问的话,可以再复习一下[第30篇文章](https://time.geekbang.org/column/article/78427) 中的相关内容。 @一大只 同学做了一个实验,验证了主键冲突以后,insert语句加间隙锁的效果。比我在上篇文章正文中提的那个回滚导致死锁的例子更直观,体现了他对这个知识点非常好的理解和思考,很赞。 @roaming 同学验证了在MySQL 8.0版本中,已经能够用临时表处理insert … select写入原表的语句了。 @老杨同志 的回答提到了我们本文中说到的几个方法。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F09%2F77%2F09c1073f99cf71d2fb162a716b5fa577.jpg&width=768&dpr=4&quality=100&sign=e5471b04&sv=2) [Previous40 insert语句的锁为什么这么多?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/40-insert-yu-ju-de-suo-wei-shi-mo-zhe-mo-duo) [Next42 grant之后要跟着flush privileges吗?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/42-grant-zhi-hou-yao-gen-zhuo-flush-privileges-ma) Last updated 2 years ago Was this helpful? --- # 45 自增id用完怎么办? | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252F3YeMtM4oVdypToxdshCw%252Fimage.png%3Falt%3Dmedia%26token%3D3d5e8835-d49d-47ec-945e-863d6c9899bd&width=768&dpr=4&quality=100&sign=a3dfbaa9&sv=2) MySQL里有很多自增的id,每个自增id都是定义了初始值,然后不停地往上加步长。虽然自然数是没有上限的,但是在计算机里,只要定义了表示这个数的字节长度,那它就有上限。比如,无符号整型(unsigned int)是4个字节,上限就是232-1。 既然自增id有上限,就有可能被用完。但是,自增id用完了会怎么样呢? 今天这篇文章,我们就来看看MySQL里面的几种自增id,一起分析一下它们的值达到上限以后,会出现什么情况。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/45-zi-zeng-id-yong-wan-zen-mo-ban#biao-ding-yi-zi-zeng-zhi-id) 表定义自增值id 说到自增id,你第一个想到的应该就是表结构定义里的自增字段,也就是我在第39篇文章[《自增主键为什么不是连续的?》](https://time.geekbang.org/column/article/80531) 中和你介绍过的自增主键id。 表定义的自增值达到上限后的逻辑是:再申请下一个id时,得到的值保持不变。 我们可以通过下面这个语句序列验证一下: Copy create table t(id int unsigned auto_increment primary key) auto_increment=4294967295; insert into t values(null); //成功插入一行 4294967295 show create table t; /* CREATE TABLE `t` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=4294967295; */ insert into t values(null); //Duplicate entry '4294967295' for key 'PRIMARY' 可以看到,第一个insert语句插入数据成功后,这个表的AUTO\_INCREMENT没有改变(还是4294967295),就导致了第二个insert语句又拿到相同的自增id值,再试图执行插入语句,报主键冲突错误。 232-1(4294967295)不是一个特别大的数,对于一个频繁插入删除数据的表来说,是可能会被用完的。因此在建表的时候你需要考察你的表是否有可能达到这个上限,如果有可能,就应该创建成8个字节的bigint unsigned。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/45-zi-zeng-id-yong-wan-zen-mo-ban#innodb-xi-tong-zi-zeng-rowid) InnoDB系统自增row\_id 如果你创建的InnoDB表没有指定主键,那么InnoDB会给你创建一个不可见的,长度为6个字节的row\_id。InnoDB维护了一个全局的dict\_sys.row\_id值,所有无主键的InnoDB表,每插入一行数据,都将当前的dict\_sys.row\_id值作为要插入数据的row\_id,然后把dict\_sys.row\_id的值加1。 实际上,在代码实现时row\_id是一个长度为8字节的无符号长整型(bigint unsigned)。但是,InnoDB在设计时,给row\_id留的只是6个字节的长度,这样写到数据表中时只放了最后6个字节,所以row\_id能写到数据表中的值,就有两个特征: 1. row\_id写入表中的值范围,是从0到248-1; 2. 当dict\_sys.row\_id=248时,如果再有插入数据的行为要来申请row\_id,拿到以后再取最后6个字节的话就是0。 也就是说,写入表的row\_id是从0开始到248-1。达到上限后,下一个值就是0,然后继续循环。 当然,248-1这个值本身已经很大了,但是如果一个MySQL实例跑得足够久的话,还是可能达到这个上限的。在InnoDB逻辑里,申请到row\_id=N后,就将这行数据写入表中;如果表中已经存在row\_id=N的行,新写入的行就会覆盖原有的行。 要验证这个结论的话,你可以通过gdb修改系统的自增row\_id来实现。注意,用gdb改变量这个操作是为了便于我们复现问题,只能在测试环境使用。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F6a%2F9a%2F6a7bfd460f9e75afcfcfc4a963339a9a.png&width=768&dpr=4&quality=100&sign=cfbeda7a&sv=2) 图1 row\_id用完的验证序列 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F5a%2F5c%2F5ad1fff81bda3a6b00ec84e84753fa5c.png&width=768&dpr=4&quality=100&sign=b5204dce&sv=2) 图2 row\_id用完的效果验证 可以看到,在我用gdb将dict\_sys.row\_id设置为248之后,再插入的a=2的行会出现在表t的第一行,因为这个值的row\_id=0。之后再插入的a=3的行,由于row\_id=1,就覆盖了之前a=1的行,因为a=1这一行的row\_id也是1。 从这个角度看,我们还是应该在InnoDB表中主动创建自增主键。因为,表自增id到达上限后,再插入数据时报主键冲突错误,是更能被接受的。 毕竟覆盖数据,就意味着数据丢失,影响的是数据可靠性;报主键冲突,是插入失败,影响的是可用性。而一般情况下,可靠性优先于可用性。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/45-zi-zeng-id-yong-wan-zen-mo-ban#xid) Xid 在第15篇文章[《答疑文章(一):日志和索引相关问题》](https://time.geekbang.org/column/article/73161) 中,我和你介绍redo log和binlog相配合的时候,提到了它们有一个共同的字段叫作Xid。它在MySQL中是用来对应事务的。 那么,Xid在MySQL内部是怎么生成的呢? MySQL内部维护了一个全局变量global\_query\_id,每次执行语句的时候将它赋值给Query\_id,然后给这个变量加1。如果当前语句是这个事务执行的第一条语句,那么MySQL还会同时把Query\_id赋值给这个事务的Xid。 而global\_query\_id是一个纯内存变量,重启之后就清零了。所以你就知道了,在同一个数据库实例中,不同事务的Xid也是有可能相同的。 但是MySQL重启之后会重新生成新的binlog文件,这就保证了,同一个binlog文件里,Xid一定是惟一的。 虽然MySQL重启不会导致同一个binlog里面出现两个相同的Xid,但是如果global\_query\_id达到上限后,就会继续从0开始计数。从理论上讲,还是就会出现同一个binlog里面出现相同Xid的场景。 因为global\_query\_id定义的长度是8个字节,这个自增值的上限是264-1。要出现这种情况,必须是下面这样的过程: 1. 执行一个事务,假设Xid是A; 2. 接下来执行264次查询语句,让global\_query\_id回到A; 3. 再启动一个事务,这个事务的Xid也是A。 不过,264这个值太大了,大到你可以认为这个可能性只会存在于理论上。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/45-zi-zeng-id-yong-wan-zen-mo-ban#innodb-trx_id) Innodb trx\_id Xid和InnoDB的trx\_id是两个容易混淆的概念。 Xid是由server层维护的。InnoDB内部使用Xid,就是为了能够在InnoDB事务和server之间做关联。但是,InnoDB自己的trx\_id,是另外维护的。 其实,你应该非常熟悉这个trx\_id。它就是在我们在第8篇文章[《事务到底是隔离的还是不隔离的?》](https://time.geekbang.org/column/article/70562) 中讲事务可见性时,用到的事务id(transaction id)。 InnoDB内部维护了一个max\_trx\_id全局变量,每次需要申请一个新的trx\_id时,就获得max\_trx\_id的当前值,然后并将max\_trx\_id加1。 InnoDB数据可见性的核心思想是:每一行数据都记录了更新它的trx\_id,当一个事务读到一行数据的时候,判断这个数据是否可见的方法,就是通过事务的一致性视图与这行数据的trx\_id做对比。 对于正在执行的事务,你可以从information\_schema.innodb\_trx表中看到事务的trx\_id。 我在上一篇文章的末尾留给你的思考题,就是关于从innodb\_trx表里面查到的trx\_id的。现在,我们一起来看一个事务现场: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F94%2F7c%2F94c704190f7609b3e6443688368cd97c.png&width=768&dpr=4&quality=100&sign=c8dcbf60&sv=2) 图3 事务的trx\_id session B里,我从innodb\_trx表里查出的这两个字段,第二个字段trx\_mysql\_thread\_id就是线程id。显示线程id,是为了说明这两次查询看到的事务对应的线程id都是5,也就是session A所在的线程。 可以看到,T2时刻显示的trx\_id是一个很大的数;T4时刻显示的trx\_id是1289,看上去是一个比较正常的数字。这是什么原因呢? 实际上,在T1时刻,session A还没有涉及到更新,是一个只读事务。而对于只读事务,InnoDB并不会分配trx\_id。也就是说: 1. 在T1时刻,trx\_id的值其实就是0。而这个很大的数,只是显示用的。一会儿我会再和你说说这个数据的生成逻辑。 2. 直到session A 在T3时刻执行insert语句的时候,InnoDB才真正分配了trx\_id。所以,T4时刻,session B查到的这个trx\_id的值就是1289。 需要注意的是,除了显而易见的修改类语句外,如果在select 语句后面加上for update,这个事务也不是只读事务。 在上一篇文章的评论区,有同学提出,实验的时候发现不止加1。这是因为: 1. update 和 delete语句除了事务本身,还涉及到标记删除旧数据,也就是要把数据放到purge队列里等待后续物理删除,这个操作也会把max\_trx\_id+1, 因此在一个事务中至少加2; 2. InnoDB的后台操作,比如表的索引信息统计这类操作,也是会启动内部事务的,因此你可能看到,trx\_id值并不是按照加1递增的。 那么,**T2时刻查到的这个很大的数字是怎么来的呢?** 其实,这个数字是每次查询的时候由系统临时计算出来的。它的算法是:把当前事务的trx变量的指针地址转成整数,再加上248。使用这个算法,就可以保证以下两点: 1. 因为同一个只读事务在执行期间,它的指针地址是不会变的,所以不论是在 innodb\_trx还是在innodb\_locks表里,同一个只读事务查出来的trx\_id就会是一样的。 2. 如果有并行的多个只读事务,每个事务的trx变量的指针地址肯定不同。这样,不同的并发只读事务,查出来的trx\_id就是不同的。 那么,**为什么还要再加上248呢?** 在显示值里面加上248,目的是要保证只读事务显示的trx\_id值比较大,正常情况下就会区别于读写事务的id。但是,trx\_id跟row\_id的逻辑类似,定义长度也是8个字节。因此,在理论上还是可能出现一个读写事务与一个只读事务显示的trx\_id相同的情况。不过这个概率很低,并且也没有什么实质危害,可以不管它。 另一个问题是,**只读事务不分配trx\_id,有什么好处呢?** * 一个好处是,这样做可以减小事务视图里面活跃事务数组的大小。因为当前正在运行的只读事务,是不影响数据的可见性判断的。所以,在创建事务的一致性视图时,InnoDB就只需要拷贝读写事务的trx\_id。 * 另一个好处是,可以减少trx\_id的申请次数。在InnoDB里,即使你只是执行一个普通的select语句,在执行过程中,也是要对应一个只读事务的。所以只读事务优化后,普通的查询语句不需要申请trx\_id,就大大减少了并发事务申请trx\_id的锁冲突。 由于只读事务不分配trx\_id,一个自然而然的结果就是trx\_id的增加速度变慢了。 但是,max\_trx\_id会持久化存储,重启也不会重置为0,那么从理论上讲,只要一个MySQL服务跑得足够久,就可能出现max\_trx\_id达到248-1的上限,然后从0开始的情况。 当达到这个状态后,MySQL就会持续出现一个脏读的bug,我们来复现一下这个bug。 首先我们需要把当前的max\_trx\_id先修改成248-1。注意:这个case里使用的是可重复读隔离级别。具体的操作流程如下: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F13%2Fc0%2F13735f955a437a848895787bf9c723c0.png&width=768&dpr=4&quality=100&sign=12b6f480&sv=2) 图 4 复现脏读 由于我们已经把系统的max\_trx\_id设置成了248-1,所以在session A启动的事务TA的低水位就是248-1。 在T2时刻,session B执行第一条update语句的事务id就是248-1,而第二条update语句的事务id就是0了,这条update语句执行后生成的数据版本上的trx\_id就是0。 在T3时刻,session A执行select语句的时候,判断可见性发现,c=3这个数据版本的trx\_id,小于事务TA的低水位,因此认为这个数据可见。 但,这个是脏读。 由于低水位值会持续增加,而事务id从0开始计数,就导致了系统在这个时刻之后,所有的查询都会出现脏读的。 并且,MySQL重启时max\_trx\_id也不会清0,也就是说重启MySQL,这个bug仍然存在。 那么,**这个bug也是只存在于理论上吗?** 假设一个MySQL实例的TPS是每秒50万,持续这个压力的话,在17.8年后,就会出现这个情况。如果TPS更高,这个年限自然也就更短了。但是,从MySQL的真正开始流行到现在,恐怕都还没有实例跑到过这个上限。不过,这个bug是只要MySQL实例服务时间够长,就会必然出现的。 当然,这个例子更现实的意义是,可以加深我们对低水位和数据可见性的理解。你也可以借此机会再回顾下第8篇文章[《事务到底是隔离的还是不隔离的?》](https://time.geekbang.org/column/article/70562) 中的相关内容。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/45-zi-zeng-id-yong-wan-zen-mo-ban#thread_id) thread\_id 接下来,我们再看看线程id(thread\_id)。其实,线程id才是MySQL中最常见的一种自增id。平时我们在查各种现场的时候,show processlist里面的第一列,就是thread\_id。 thread\_id的逻辑很好理解:系统保存了一个全局变量thread\_id\_counter,每新建一个连接,就将thread\_id\_counter赋值给这个新连接的线程变量。 thread\_id\_counter定义的大小是4个字节,因此达到232-1后,它就会重置为0,然后继续增加。但是,你不会在show processlist里看到两个相同的thread\_id。 这,是因为MySQL设计了一个唯一数组的逻辑,给新线程分配thread\_id的时候,逻辑代码是这样的: Copy do { new_id= thread_id_counter++; } while (!thread_ids.insert_unique(new_id).second); 这个代码逻辑简单而且实现优雅,相信你一看就能明白。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/45-zi-zeng-id-yong-wan-zen-mo-ban#xiao-jie) 小结 今天这篇文章,我给你介绍了MySQL不同的自增id达到上限以后的行为。数据库系统作为一个可能需要7\*24小时全年无休的服务,考虑这些边界是非常有必要的。 每种自增id有各自的应用场景,在达到上限后的表现也不同: 1. 表的自增id达到上限后,再申请时它的值就不会改变,进而导致继续插入数据时报主键冲突的错误。 2. row\_id达到上限后,则会归0再重新递增,如果出现相同的row\_id,后写的数据会覆盖之前的数据。 3. Xid只需要不在同一个binlog文件中出现重复值即可。虽然理论上会出现重复值,但是概率极小,可以忽略不计。 4. InnoDB的max\_trx\_id 递增值每次MySQL重启都会被保存起来,所以我们文章中提到的脏读的例子就是一个必现的bug,好在留给我们的时间还很充裕。 5. thread\_id是我们使用中最常见的,而且也是处理得最好的一个自增id逻辑了。 当然,在MySQL里还有别的自增id,比如table\_id、binlog文件序号等,就留给你去验证和探索了。 不同的自增id有不同的上限值,上限值的大小取决于声明的类型长度。而我们专栏声明的上限id就是45,所以今天这篇文章也是我们的最后一篇技术文章了。 既然没有下一个id了,课后也就没有思考题了。今天,我们换一个轻松的话题,请你来说说,读完专栏以后有什么感想吧。 这个“感想”,既可以是你读完专栏前后对某一些知识点的理解发生的变化,也可以是你积累的学习专栏文章的好方法,当然也可以是吐槽或者对未来的期望。 欢迎你给我留言,我们在评论区见,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F09%2F77%2F09c1073f99cf71d2fb162a716b5fa577.jpg&width=768&dpr=4&quality=100&sign=e5471b04&sv=2) [Previous44 答疑文章(三):说一说这些好问题](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/44-da-yi-wen-zhang-san-shuo-yi-shuo-zhe-xie-hao-wen-ti) [Next点线网面,一起构建MySQL知识网络](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/dian-xian-wang-mian-yi-qi-gou-jian-mysql-zhi-shi-wang-luo) Last updated 2 years ago Was this helpful? --- # 37 什么时候会使用内部临时表? | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252F8CrqMiPTYalwtfqNLVMP%252Fimage.png%3Falt%3Dmedia%26token%3D73cfe3e9-a063-4069-a057-c643cea93c3f&width=768&dpr=4&quality=100&sign=89044bb2&sv=2) 今天是大年初二,在开始我们今天的学习之前,我要先和你道一声春节快乐! 在[第16](https://time.geekbang.org/column/article/73479) 和[第34](https://time.geekbang.org/column/article/79700) 篇文章中,我分别和你介绍了sort buffer、内存临时表和join buffer。这三个数据结构都是用来存放语句执行过程中的中间数据,以辅助SQL语句的执行的。其中,我们在排序的时候用到了sort buffer,在使用join语句的时候用到了join buffer。 然后,你可能会有这样的疑问,MySQL什么时候会使用内部临时表呢? 今天这篇文章,我就先给你举两个需要用到内部临时表的例子,来看看内部临时表是怎么工作的。然后,我们再来分析,什么情况下会使用内部临时表。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/37-shi-mo-shi-hou-hui-shi-yong-nei-bu-lin-shi-biao#union-zhi-xing-liu-cheng) union 执行流程 为了便于量化分析,我用下面的表t1来举例。 Copy create table t1(id int primary key, a int, b int, index(a)); delimiter ;; create procedure idata() begin declare i int; set i=1; while(i<=1000)do insert into t1 values(i, i, i); set i=i+1; end while; end;; delimiter ; call idata(); 然后,我们执行下面这条语句: Copy (select 1000 as f) union (select id from t1 order by id desc limit 2); 这条语句用到了union,它的语义是,取这两个子查询结果的并集。并集的意思就是这两个集合加起来,重复的行只保留一行。 下图是这个语句的explain结果。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F40%2F4e%2F402cbdef84eef8f1b42201c6ec4bad4e.png&width=768&dpr=4&quality=100&sign=79974ca4&sv=2) 图1 union语句explain 结果 可以看到: * 第二行的key=PRIMARY,说明第二个子句用到了索引id。 * 第三行的Extra字段,表示在对子查询的结果集做union的时候,使用了临时表(Using temporary)。 这个语句的执行流程是这样的: 1. 创建一个内存临时表,这个临时表只有一个整型字段f,并且f是主键字段。 2. 执行第一个子查询,得到1000这个值,并存入临时表中。 3. 执行第二个子查询: * 拿到第一行id=1000,试图插入临时表中。但由于1000这个值已经存在于临时表了,违反了唯一性约束,所以插入失败,然后继续执行; * 取到第二行id=999,插入临时表成功。 4. 从临时表中按行取出数据,返回结果,并删除临时表,结果中包含两行数据分别是1000和999。 这个过程的流程图如下所示: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F5d%2F0e%2F5d038c1366d375cc997005a5d65c600e.jpg&width=768&dpr=4&quality=100&sign=fb638b21&sv=2) 图 2 union 执行流程 可以看到,这里的内存临时表起到了暂存数据的作用,而且计算过程还用上了临时表主键id的唯一性约束,实现了union的语义。 顺便提一下,如果把上面这个语句中的union改成union all的话,就没有了“去重”的语义。这样执行的时候,就依次执行子查询,得到的结果直接作为结果集的一部分,发给客户端。因此也就不需要临时表了。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fc1%2F6d%2Fc1e90d1d7417b484d566b95720fe3f6d.png&width=768&dpr=4&quality=100&sign=1200d180&sv=2) 图3 union all的explain结果 可以看到,第二行的Extra字段显示的是Using index,表示只使用了覆盖索引,没有用临时表了。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/37-shi-mo-shi-hou-hui-shi-yong-nei-bu-lin-shi-biao#group-by-zhi-xing-liu-cheng) group by 执行流程 另外一个常见的使用临时表的例子是group by,我们来看一下这个语句: Copy select id%10 as m, count(*) as c from t1 group by m; 这个语句的逻辑是把表t1里的数据,按照 id%10 进行分组统计,并按照m的结果排序后输出。它的explain结果如下: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F3d%2F98%2F3d1cb94589b6b3c4bb57b0bdfa385d98.png&width=768&dpr=4&quality=100&sign=eca42082&sv=2) 图4 group by 的explain结果 在Extra字段里面,我们可以看到三个信息: * Using index,表示这个语句使用了覆盖索引,选择了索引a,不需要回表; * Using temporary,表示使用了临时表; * Using filesort,表示需要排序。 这个语句的执行流程是这样的: 1. 创建内存临时表,表里有两个字段m和c,主键是m; 2. 扫描表t1的索引a,依次取出叶子节点上的id值,计算id%10的结果,记为x; * 如果临时表中没有主键为x的行,就插入一个记录(x,1); * 如果表中有主键为x的行,就将x这一行的c值加1; 3. 遍历完成后,再根据字段m做排序,得到结果集返回给客户端。 这个流程的执行图如下: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F03%2F54%2F0399382169faf50fc1b354099af71954.jpg&width=768&dpr=4&quality=100&sign=e6825bd3&sv=2) 图5 group by执行流程 图中最后一步,对内存临时表的排序,在[第17篇文章](https://time.geekbang.org/column/article/73795) 中已经有过介绍,我把图贴过来,方便你回顾。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fb5%2F68%2Fb5168d201f5a89de3b424ede2ebf3d68.jpg&width=768&dpr=4&quality=100&sign=d1a68759&sv=2) 图6 内存临时表排序流程 其中,临时表的排序过程就是图6中虚线框内的过程。 接下来,我们再看一下这条语句的执行结果: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fae%2F55%2Fae6a28d890efc35ee4d07f694068f455.png&width=768&dpr=4&quality=100&sign=4599dba2&sv=2) 图 7 group by执行结果 如果你的需求并不需要对结果进行排序,那你可以在SQL语句末尾增加order by null,也就是改成: Copy select id%10 as m, count(*) as c from t1 group by m order by null; 这样就跳过了最后排序的阶段,直接从临时表中取数据返回。返回的结果如图8所示。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F03%2Feb%2F036634e53276eaf8535c3442805dfaeb.png&width=768&dpr=4&quality=100&sign=3b87eeb8&sv=2) 图8 group + order by null 的结果(内存临时表) 由于表t1中的id值是从1开始的,因此返回的结果集中第一行是id=1;扫描到id=10的时候才插入m=0这一行,因此结果集里最后一行才是m=0。 这个例子里由于临时表只有10行,内存可以放得下,因此全程只使用了内存临时表。但是,内存临时表的大小是有限制的,参数tmp\_table\_size就是控制这个内存大小的,默认是16M。 如果我执行下面这个语句序列: Copy set tmp_table_size=1024; select id%100 as m, count(*) as c from t1 group by m order by null limit 10; 把内存临时表的大小限制为最大1024字节,并把语句改成id % 100,这样返回结果里有100行数据。但是,这时的内存临时表大小不够存下这100行数据,也就是说,执行过程中会发现内存临时表大小到达了上限(1024字节)。 那么,这时候就会把内存临时表转成磁盘临时表,磁盘临时表默认使用的引擎是InnoDB。 这时,返回的结果如图9所示。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fa7%2F6e%2Fa76381d0f3c947292cc28198901f9e6e.png&width=768&dpr=4&quality=100&sign=8d4dce19&sv=2) 图9 group + order by null 的结果(磁盘临时表) 如果这个表t1的数据量很大,很可能这个查询需要的磁盘临时表就会占用大量的磁盘空间。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/37-shi-mo-shi-hou-hui-shi-yong-nei-bu-lin-shi-biao#group-by-you-hua-fang-fa-suo-yin) group by 优化方法 --索引 可以看到,不论是使用内存临时表还是磁盘临时表,group by逻辑都需要构造一个带唯一索引的表,执行代价都是比较高的。如果表的数据量比较大,上面这个group by语句执行起来就会很慢,我们有什么优化的方法呢? 要解决group by语句的优化问题,你可以先想一下这个问题:执行group by语句为什么需要临时表? group by的语义逻辑,是统计不同的值出现的个数。但是,由于每一行的id%100的结果是无序的,所以我们就需要有一个临时表,来记录并统计结果。 那么,如果扫描过程中可以保证出现的数据是有序的,是不是就简单了呢? 假设,现在有一个类似图10的这么一个数据结构,我们来看看group by可以怎么做。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F5c%2F19%2F5c4a581c324c1f6702f9a2c70acddd19.jpg&width=768&dpr=4&quality=100&sign=1cfc908a&sv=2) 图10 group by算法优化-有序输入 可以看到,如果可以确保输入的数据是有序的,那么计算group by的时候,就只需要从左到右,顺序扫描,依次累加。也就是下面这个过程: * 当碰到第一个1的时候,已经知道累积了X个0,结果集里的第一行就是(0,X); * 当碰到第一个2的时候,已经知道累积了Y个1,结果集里的第一行就是(1,Y); 按照这个逻辑执行的话,扫描到整个输入的数据结束,就可以拿到group by的结果,不需要临时表,也不需要再额外排序。 你一定想到了,InnoDB的索引,就可以满足这个输入有序的条件。 在MySQL 5.7版本支持了generated column机制,用来实现列数据的关联更新。你可以用下面的方法创建一个列z,然后在z列上创建一个索引(如果是MySQL 5.6及之前的版本,你也可以创建普通列和索引,来解决这个问题)。 Copy alter table t1 add column z int generated always as(id % 100), add index(z); 这样,索引z上的数据就是类似图10这样有序的了。上面的group by语句就可以改成: Copy select z, count(*) as c from t1 group by z; 优化后的group by语句的explain结果,如下图所示: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fc9%2Fb9%2Fc9f88fa42d92cf7dde78fca26c4798b9.png&width=768&dpr=4&quality=100&sign=9276fbf&sv=2) 图11 group by 优化的explain结果 从Extra字段可以看到,这个语句的执行不再需要临时表,也不需要排序了。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/37-shi-mo-shi-hou-hui-shi-yong-nei-bu-lin-shi-biao#group-by-you-hua-fang-fa-zhi-jie-pai-xu) group by优化方法 --直接排序 所以,如果可以通过加索引来完成group by逻辑就再好不过了。但是,如果碰上不适合创建索引的场景,我们还是要老老实实做排序的。那么,这时候的group by要怎么优化呢? 如果我们明明知道,一个group by语句中需要放到临时表上的数据量特别大,却还是要按照“先放到内存临时表,插入一部分数据后,发现内存临时表不够用了再转成磁盘临时表”,看上去就有点儿傻。 那么,我们就会想了,MySQL有没有让我们直接走磁盘临时表的方法呢? 答案是,有的。 在group by语句中加入SQL\_BIG\_RESULT这个提示(hint),就可以告诉优化器:这个语句涉及的数据量很大,请直接用磁盘临时表。 MySQL的优化器一看,磁盘临时表是B+树存储,存储效率不如数组来得高。所以,既然你告诉我数据量很大,那从磁盘空间考虑,还是直接用数组来存吧。 因此,下面这个语句 Copy select SQL_BIG_RESULT id%100 as m, count(*) as c from t1 group by m; 的执行流程就是这样的: 1. 初始化sort\_buffer,确定放入一个整型字段,记为m; 2. 扫描表t1的索引a,依次取出里面的id值, 将 id%100的值存入sort\_buffer中; 3. 扫描完成后,对sort\_buffer的字段m做排序(如果sort\_buffer内存不够用,就会利用磁盘临时文件辅助排序); 4. 排序完成后,就得到了一个有序数组。 根据有序数组,得到数组里面的不同值,以及每个值的出现次数。这一步的逻辑,你已经从前面的图10中了解过了。 下面两张图分别是执行流程图和执行explain命令得到的结果。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F82%2F6a%2F8269dc6206a7ef20cb515c23df0b846a.jpg&width=768&dpr=4&quality=100&sign=9b24ede5&sv=2) 图12 使用 SQL\_BIG\_RESULT的执行流程图 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F83%2Fec%2F83b6cd6b3e37dfbf9699cf0ccc0f1bec.png&width=768&dpr=4&quality=100&sign=9c4a7495&sv=2) 图13 使用 SQL\_BIG\_RESULT的explain 结果 从Extra字段可以看到,这个语句的执行没有再使用临时表,而是直接用了排序算法。 基于上面的union、union all和group by语句的执行过程的分析,我们来回答文章开头的问题:MySQL什么时候会使用内部临时表? 1. 如果语句执行过程可以一边读数据,一边直接得到结果,是不需要额外内存的,否则就需要额外的内存,来保存中间结果; 2. join\_buffer是无序数组,sort\_buffer是有序数组,临时表是二维表结构; 3. 如果执行逻辑需要用到二维表特性,就会优先考虑使用临时表。比如我们的例子中,union需要用到唯一索引约束, group by还需要用到另外一个字段来存累积计数。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/37-shi-mo-shi-hou-hui-shi-yong-nei-bu-lin-shi-biao#xiao-jie) 小结 通过今天这篇文章,我重点和你讲了group by的几种实现算法,从中可以总结一些使用的指导原则: 1. 如果对group by语句的结果没有排序要求,要在语句后面加 order by null; 2. 尽量让group by过程用上表的索引,确认方法是explain结果里没有Using temporary 和 Using filesort; 3. 如果group by需要统计的数据量不大,尽量只使用内存临时表;也可以通过适当调大tmp\_table\_size参数,来避免用到磁盘临时表; 4. 如果数据量实在太大,使用SQL\_BIG\_RESULT这个提示,来告诉优化器直接使用排序算法得到group by的结果。 最后,我给你留下一个思考题吧。 文章中图8和图9都是order by null,为什么图8的返回结果里面,0是在结果集的最后一行,而图9的结果里面,0是在结果集的第一行? 你可以把你的分析写在留言区里,我会在下一篇文章和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/37-shi-mo-shi-hou-hui-shi-yong-nei-bu-lin-shi-biao#shang-qi-wen-ti-shi-jian) 上期问题时间 上期的问题是:为什么不能用rename修改临时表的改名。 在实现上,执行rename table语句的时候,要求按照“库名/表名.frm”的规则去磁盘找文件,但是临时表在磁盘上的frm文件是放在tmpdir目录下的,并且文件名的规则是“#sql{进程id}\_{线程id}\_序列号.frm”,因此会报“找不到文件名”的错误。 评论区留言点赞板: > @poppy 同学,通过执行语句的报错现象推测了这个实现过程。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F09%2F77%2F09c1073f99cf71d2fb162a716b5fa577.jpg&width=768&dpr=4&quality=100&sign=e5471b04&sv=2) [Previous36 为什么临时表可以重名?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/36-wei-shi-mo-lin-shi-biao-ke-yi-zhong-ming) [Next38 都说InnoDB好,那还要不要使用Memory引擎?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/38-du-shuo-innodb-hao-na-huan-yao-bu-yao-shi-yong-memory-yin-qing) Last updated 2 years ago Was this helpful? --- # 01 | 工作区和GOPATH | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252F2fcSFibBXWR2GokXOoWU%252Fimage.png%3Falt%3Dmedia%26token%3Da6ba07c2-2189-4248-a0b9-b979cdeaac37&width=768&dpr=4&quality=100&sign=d90b5b24&sv=2) #### [](https://jums.gitbook.io/36-lectures-on-golang/01-gong-zuo-qu-he-gopath#go-yu-yan-dai-ma-jiao-duo-jian-yi-pei-he-wen-zhang-shou-ting-yin-pin) 【Go 语言代码较多,建议配合文章收听音频。】 [6MB\ \ 1.工作区和GOPATH.mp3](https://2852705636-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FkmtyDvkosJZE1Rr33XGZ%2Fuploads%2FNPbEN12pP5bV2hlDVHGw%2F1.%E5%B7%A5%E4%BD%9C%E5%8C%BA%E5%92%8CGOPATH.mp3?alt=media&token=2089f8d5-8c7f-48b2-a7fb-4ba0e0a4b849) 你好,我是郝林。从今天开始,我将和你一起梳理 Go 语言的整个知识体系。 在过去的几年里,我与广大爱好者一起见证了 Go 语言的崛起。 从 Go 1.5 版本的自举(即用 Go 语言编写程序来实现 Go 语言自身),到 Go 1.7 版本的极速 GC(也称垃圾回收器),再到 2018 年 2 月发布的 Go 1.10 版本对其自带工具的全面升级,以及可预见的后续版本关键特性(比如用来做程序依赖管理的`go mod`命令),这一切都令我们欢欣鼓舞。Go 语言在一步步走向辉煌的同时,显然已经成为软件工程师们最喜爱的编程语言之一。 我开办这个专栏的主要目的,是要与你一起探索 Go 语言的奥秘,并帮助你在学习和实践的过程中获取更多。 我假设本专栏的读者已经具备了一定的计算机基础,比如,你要知道操作系统是什么、环境变量怎么设置、怎样正确使用命令行,等等。 当然了,如果你已经有了编程经验,尤其是一点点 Go 语言编程经验,那就更好了,毕竟我想教给你的,都是 Go 语言中非常核心的技术。 如果你对 Go 语言中最基本的概念和语法还不够了解,那么可能需要在学习本专栏的过程中去查阅[Go 语言规范文档](https://golang.google.cn/ref/spec) ,也可以把预习篇的基础知识图拿出来好好研究一下。 最后,我来说一下专栏的讲述模式。我总会以一道 Go 语言的面试题开始,针对它进行解答,我会告诉你为什么我要关注这道题,这道题的背后隐藏着哪些知识,并且,我会对这部分的内容,进行相关的知识扩展。 好了,准备就绪,我们一起开始。 * * * 我们学习 Go 语言时,要做的第一件事,都是根据自己电脑的计算架构(比如,是 32 位的计算机还是 64 位的计算机)以及操作系统(比如,是 Windows 还是 Linux),从[Go 语言官网](https://golang.google.cn/) 下载对应的二进制包,也就是可以拿来即用的安装包。 随后,我们会解压缩安装包、放置到某个目录、配置环境变量,并通过在命令行中输入`go version`来验证是否安装成功。 在这个过程中,我们还需要配置 3 个环境变量,也就是 GOROOT、GOPATH 和 GOBIN。这里我可以简单介绍一下。 * GOROOT:Go 语言安装根目录的路径,也就是 GO 语言的安装路径。 * GOPATH:若干工作区目录的路径。是我们自己定义的工作空间。 * GOBIN:GO 程序生成的可执行文件(executable file)的路径。 其中,GOPATH 背后的概念是最多的,也是最重要的。那么,**今天我们的面试问题是:你知道设置 GOPATH 有什么意义吗?** 关于这个问题,它的**典型回答**是这样的: 你可以把 GOPATH 简单理解成 Go 语言的工作目录,它的值是一个目录的路径,也可以是多个目录路径,每个目录都代表 Go 语言的一个工作区(workspace)。 我们需要利于这些工作区,去放置 Go 语言的源码文件(source file),以及安装(install)后的归档文件(archive file,也就是以“.a”为扩展名的文件)和可执行文件(executable file)。 事实上,由于 Go 语言项目在其生命周期内的所有操作(编码、依赖管理、构建、测试、安装等)基本上都是围绕着 GOPATH 和工作区进行的。所以,它的背后至少有 3 个知识点,分别是: **1\. Go 语言源码的组织方式是怎样的;** **2\. 你是否了解源码安装后的结果(只有在安装后,Go 语言源码才能被我们或其他代码使用);** **3\. 你是否理解构建和安装 Go 程序的过程(这在开发程序以及查找程序问题的时候都很有用,否则你很可能会走弯路)。** 下面我就重点来聊一聊这些内容。 ### [](https://jums.gitbook.io/36-lectures-on-golang/01-gong-zuo-qu-he-gopath#zhi-shi-kuo-zhan) 知识扩展 ### [](https://jums.gitbook.io/36-lectures-on-golang/01-gong-zuo-qu-he-gopath#id-1.-go-yu-yan-yuan-ma-de-zu-zhi-fang-shi) 1\. Go 语言源码的组织方式 与许多编程语言一样,Go 语言的源码也是以代码包为基本组织单位的。在文件系统中,这些代码包其实是与目录一一对应的。由于目录可以有子目录,所以代码包也可以有子包。 一个代码包中可以包含任意个以.go 为扩展名的源码文件,这些源码文件都需要被声明属于同一个代码包。 代码包的名称一般会与源码文件所在的目录同名。如果不同名,那么在构建、安装的过程中会以代码包名称为准。 每个代码包都会有导入路径。代码包的导入路径是其他代码在使用该包中的程序实体时,需要引入的路径。在实际使用程序实体之前,我们必须先导入其所在的代码包。具体的方式就是`import`该代码包的导入路径。就像这样: 复制 import "github.com/labstack/echo" 在工作区中,一个代码包的导入路径实际上就是从 src 子目录,到该包的实际存储位置的相对路径。 所以说,Go 语言源码的组织方式就是以环境变量 GOPATH、工作区、src 目录和代码包为主线的。一般情况下,Go 语言的源码文件都需要被存放在环境变量 GOPATH 包含的某个工作区(目录)中的 src 目录下的某个代码包(目录)中。 ### [](https://jums.gitbook.io/36-lectures-on-golang/01-gong-zuo-qu-he-gopath#id-2.-liao-jie-yuan-ma-an-zhuang-hou-de-jie-guo) 2\. 了解源码安装后的结果 了解了 Go 语言源码的组织方式后,我们很有必要知道 Go 语言源码在安装后会产生怎样的结果。 源码文件以及安装后的结果文件都会放到哪里呢?我们都知道,源码文件通常会被放在某个工作区的 src 子目录下。 那么在安装后如果产生了归档文件(以“.a”为扩展名的文件),就会放进该工作区的 pkg 子目录;如果产生了可执行文件,就可能会放进该工作区的 bin 子目录。 我再讲一下归档文件存放的具体位置和规则。 源码文件会以代码包的形式组织起来,一个代码包其实就对应一个目录。安装某个代码包而产生的归档文件是与这个代码包同名的。 放置它的相对目录就是该代码包的导入路径的直接父级。比如,一个已存在的代码包的导入路径是 复制 github.com/labstack/echo, 那么执行命令 复制 go install github.com/labstack/echo 生成的归档文件的相对目录就是 [github.com/labstack,](http://github.com/labstack%EF%BC%8C) 文件名为 echo.a。 顺便说一下,上面这个代码包导入路径还有另外一层含义,那就是:该代码包的源码文件存在于 GitHub 网站的 labstack 组的代码仓库 echo 中。 再说回来,归档文件的相对目录与 pkg 目录之间还有一级目录,叫做平台相关目录。平台相关目录的名称是由 build(也称“构建”)的目标操作系统、下划线和目标计算架构的代号组成的。 比如,构建某个代码包时的目标操作系统是 Linux,目标计算架构是 64 位的,那么对应的平台相关目录就是 linux\_amd64。 因此,上述代码包的归档文件就会被放置在当前工作区的子目录 pkg/linux\_amd64/github.com/labstack 中。 (GOPATH 与工作区) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F2f%2F3c%2F2fdfb5620e072d864907870e61ae5f3c.png&width=768&dpr=4&quality=100&sign=b935aa15&sv=2) 总之,你需要记住的是,某个工作区的 src 子目录下的源码文件在安装后一般会被放置到当前工作区的 pkg 子目录下对应的目录中,或者被直接放置到该工作区的 bin 子目录中。 ### [](https://jums.gitbook.io/36-lectures-on-golang/01-gong-zuo-qu-he-gopath#id-3.-li-jie-gou-jian-he-an-zhuang-go-cheng-xu-de-guo-cheng) 3\. 理解构建和安装 Go 程序的过程 我们再来说说构建和安装 Go 程序的过程都是怎样的,以及它们的异同点。 构建使用命令`go build`,安装使用命令`go install`。构建和安装代码包的时候都会执行编译、打包等操作,并且,这些操作生成的任何文件都会先被保存到某个临时的目录中。 如果构建的是库源码文件,那么操作的结果文件只会存在于临时目录中。这里的构建的主要意义在于检查和验证。 如果构建的是命令源码文件,那么操作的结果文件会被搬运到源码文件所在的目录中。 安装操作会先执行构建,然后还会进行链接操作,并且把结果文件搬运到指定目录。进一步说,如果安装的是库源码文件,那么结果文件会被搬运到它所在工作区的 pkg 目录下的某个子目录中。 如果安装的是命令源码文件,那么结果文件会被搬运到它所在工作区的 bin 目录中,或者环境变量`GOBIN`指向的目录中。 这里你需要记住的是,构建和安装的不同之处,以及执行相应命令后得到的结果文件都会出现在哪里。 ### [](https://jums.gitbook.io/36-lectures-on-golang/01-gong-zuo-qu-he-gopath#zong-jie) 总结 工作区和 GOPATH 的概念和含义是每个 Go 工程师都需要了解的。虽然它们都比较简单,但是说它们是 Go 程序开发的核心知识并不为过。 然而,我在招聘面试的过程中仍然发现有人忽略掉了它们。Go 语言提供的很多工具都是在 GOPATH 和工作区的基础上运行的,比如上面提到的`go build`、`go install`和`go get`,这三个命令也是我们最常用到的。 ### [](https://jums.gitbook.io/36-lectures-on-golang/01-gong-zuo-qu-he-gopath#si-kao-ti) 思考题 说到 Go 程序中的依赖管理,其实还有很多问题值得我们探索。我在这里留下两个问题供你进一步思考。 1. Go 语言在多个工作区中查找依赖包的时候是以怎样的顺序进行的? 2. 如果在多个工作区中都存在导入路径相同的代码包会产生冲突吗? 这两个问题之间其实是有一些关联的。答案并不复杂,你做几个试验几乎就可以找到它了。你也可以看一下 Go 语言标准库中`go build`包及其子包的源码。那里面的宝藏也很多,可以助你深刻理解 Go 程序的构建过程。 * * * ### [](https://jums.gitbook.io/36-lectures-on-golang/01-gong-zuo-qu-he-gopath#bu-chong-yue-du) 补充阅读 ### [](https://jums.gitbook.io/36-lectures-on-golang/01-gong-zuo-qu-he-gopath#go-build-ming-ling-yi-xie-ke-xuan-xiang-de-yong-tu-he-yong-fa) go build 命令一些可选项的用途和用法 在运行`go build`命令的时候,默认不会编译目标代码包所依赖的那些代码包。当然,如果被依赖的代码包的归档文件不存在,或者源码文件有了变化,那它还是会被编译。 如果要强制编译它们,可以在执行命令的时候加入标记`-a`。此时,不但目标代码包总是会被编译,它依赖的代码包也总会被编译,即使依赖的是标准库中的代码包也是如此。 另外,如果不但要编译依赖的代码包,还要安装它们的归档文件,那么可以加入标记`-i`。 那么我们怎么确定哪些代码包被编译了呢?有两种方法。 1. 运行`go build`命令时加入标记`-x`,这样可以看到`go build`命令具体都执行了哪些操作。另外也可以加入标记`-n`,这样可以只查看具体操作而不执行它们。 2. 运行`go build`命令时加入标记`-v`,这样可以看到`go build`命令编译的代码包的名称。它在与`-a`标记搭配使用时很有用。 下面再说一说与 Go 源码的安装联系很紧密的一个命令:`go get`。 命令`go get`会自动从一些主流公用代码仓库(比如 GitHub)下载目标代码包,并把它们安装到环境变量`GOPATH`包含的第 1 工作区的相应目录中。如果存在环境变量`GOBIN`,那么仅包含命令源码文件的代码包会被安装到`GOBIN`指向的那个目录。 最常用的几个标记有下面几种。 * `-u`:下载并安装代码包,不论工作区中是否已存在它们。 * `-d`:只下载代码包,不安装代码包。 * `-fix`:在下载代码包后先运行一个用于根据当前 Go 语言版本修正代码的工具,然后再安装代码包。 * `-t`:同时下载测试所需的代码包。 * `-insecure`:允许通过非安全的网络协议下载和安装代码包。HTTP 就是这样的协议。 Go 语言官方提供的`go get`命令是比较基础的,其中并没有提供依赖管理的功能。目前 GitHub 上有很多提供这类功能的第三方工具,比如`glide`、`gb`以及官方出品的`dep`、`vgo`等等,它们在内部大都会直接使用`go get`。 有时候,我们可能会出于某种目的变更存储源码的代码仓库或者代码包的相对路径。这时,为了让代码包的远程导入路径不受此类变更的影响,我们会使用自定义的代码包导入路径。 对代码包的远程导入路径进行自定义的方法是:在该代码包中的库源码文件的包声明语句的右边加入导入注释,像这样: 复制 package semaphore // import "golang.org/x/sync/semaphore" 这个代码包原本的完整导入路径是`github.com/golang/sync/semaphore`。这与实际存储它的网络地址对应的。该代码包的源码实际存在 GitHub 网站的 golang 组的 sync 代码仓库的 semaphore 目录下。而加入导入注释之后,用以下命令即可下载并安装该代码包了: 复制 go get golang.org/x/sync/semaphore 而 Go 语言官网 golang.org 下的路径 /x/sync/semaphore 并不是存放`semaphore`包的真实地址。我们称之为代码包的自定义导入路径。 不过,这还需要在 golang.org 这个域名背后的服务端程序上,添加一些支持才能使这条命令成功。 关于自定义代码包导入路径的完整说明可以参看[这里](https://github.com/hyper0x/go_command_tutorial/blob/master/0.3.md) 。 好了,对于`go build`命令和`go get`命令的简短介绍就到这里。如果你想查阅更详细的文档,那么可以访问 Go 语言官方的[命令文档页面](https://golang.google.cn/cmd/go) ,或者在命令行下输入诸如`go help build`这类的命令。 [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页go语言核心36讲](https://jums.gitbook.io/36-lectures-on-golang) [下一页02 | 命令源码文件](https://jums.gitbook.io/36-lectures-on-golang/02-ming-ling-yuan-ma-wen-jian) 最后更新于2年前 这有帮助吗? --- # 30 答疑文章(二):用动态的观点看加锁 | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252F12cGU8aVhpSsxjm5liT6%252Fimage.png%3Falt%3Dmedia%26token%3Dc7c20e79-662a-4fb2-9bd2-84ebb08e537f&width=768&dpr=4&quality=100&sign=1c03c586&sv=2) 在第[20](https://time.geekbang.org/column/article/75173) 和[21](https://time.geekbang.org/column/article/75659) 篇文章中,我和你介绍了InnoDB的间隙锁、next-key lock,以及加锁规则。在这两篇文章的评论区,出现了很多高质量的留言。我觉得通过分析这些问题,可以帮助你加深对加锁规则的理解。 所以,我就从中挑选了几个有代表性的问题,构成了今天这篇答疑文章的主题,即:用动态的观点看加锁。 **为了方便你理解,我们再一起复习一下加锁规则。这个规则中,包含了两个“原则”、两个“优化”和一个“bug”:** * 原则1:加锁的基本单位是next-key lock。希望你还记得,next-key lock是前开后闭区间。 * 原则2:查找过程中访问到的对象才会加锁。 * 优化1:索引上的等值查询,给唯一索引加锁的时候,next-key lock退化为行锁。 * 优化2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock退化为间隙锁。 * 一个bug:唯一索引上的范围查询会访问到不满足条件的第一个值为止。 接下来,我们的讨论还是基于下面这个表t: Copy CREATE TABLE `t` ( `id` int(11) NOT NULL, `c` int(11) DEFAULT NULL, `d` int(11) DEFAULT NULL, PRIMARY KEY (`id`), KEY `c` (`c`) ) ENGINE=InnoDB; insert into t values(0,0,0),(5,5,5), (10,10,10),(15,15,15),(20,20,20),(25,25,25); ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/30-da-yi-wen-zhang-er-yong-dong-tai-de-guan-dian-kan-jia-suo#bu-deng-hao-tiao-jian-li-de-deng-zhi-cha-xun) 不等号条件里的等值查询 有同学对“等值查询”提出了疑问:等值查询和“遍历”有什么区别?为什么我们文章的例子里面,where条件是不等号,这个过程里也有等值查询? 我们一起来看下这个例子,分析一下这条查询语句的加锁范围: Copy begin; select * from t where id>9 and id<12 order by id desc for update; 利用上面的加锁规则,我们知道这个语句的加锁范围是主键索引上的 (0,5\]、(5,10\]和(10, 15)。也就是说,id=15这一行,并没有被加上行锁。为什么呢? 我们说加锁单位是next-key lock,都是前开后闭区间,但是这里用到了优化2,即索引上的等值查询,向右遍历的时候id=15不满足条件,所以next-key lock退化为了间隙锁 (10, 15)。 但是,我们的查询语句中where条件是大于号和小于号,这里的“等值查询”又是从哪里来的呢? 要知道,加锁动作是发生在语句执行过程中的,所以你在分析加锁行为的时候,要从索引上的数据结构开始。这里,我再把这个过程拆解一下。 如图1所示,是这个表的索引id的示意图。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fac%2Fbb%2Fac1aa07860c565b907b32c5f75c4f2bb.png&width=768&dpr=4&quality=100&sign=9ea1cc83&sv=2) 图1 索引id示意图 1. 首先这个查询语句的语义是order by id desc,要拿到满足条件的所有行,优化器必须先找到“第一个id<12的值”。 2. 这个过程是通过索引树的搜索过程得到的,在引擎内部,其实是要找到id=12的这个值,只是最终没找到,但找到了(10,15)这个间隙。 3. 然后向左遍历,在遍历过程中,就不是等值查询了,会扫描到id=5这一行,所以会加一个next-key lock (0,5\]。 也就是说,在执行过程中,通过树搜索的方式定位记录的时候,用的是“等值查询”的方法。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/30-da-yi-wen-zhang-er-yong-dong-tai-de-guan-dian-kan-jia-suo#deng-zhi-cha-xun-de-guo-cheng) 等值查询的过程 与上面这个例子对应的,是@发条橙子同学提出的问题:下面这个语句的加锁范围是什么? Copy begin; select id from t where c in(5,20,10) lock in share mode; 这条查询语句里用的是in,我们先来看这条语句的explain结果。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F8a%2Fb3%2F8a089159c82c1458b26e2756583347b3.png&width=300&dpr=4&quality=100&sign=1cfed3e1&sv=2) 图2 in语句的explain结果 可以看到,这条in语句使用了索引c并且rows=3,说明这三个值都是通过B+树搜索定位的。 在查找c=5的时候,先锁住了(0,5\]。但是因为c不是唯一索引,为了确认还有没有别的记录c=5,就要向右遍历,找到c=10才确认没有了,这个过程满足优化2,所以加了间隙锁(5,10)。 同样的,执行c=10这个逻辑的时候,加锁的范围是(5,10\] 和 (10,15);执行c=20这个逻辑的时候,加锁的范围是(15,20\] 和 (20,25)。 通过这个分析,我们可以知道,这条语句在索引c上加的三个记录锁的顺序是:先加c=5的记录锁,再加c=10的记录锁,最后加c=20的记录锁。 你可能会说,这个加锁范围,不就是从(5,25)中去掉c=15的行锁吗?为什么这么麻烦地分段说呢? 因为我要跟你强调这个过程:这些锁是“在执行过程中一个一个加的”,而不是一次性加上去的。 理解了这个加锁过程之后,我们就可以来分析下面例子中的死锁问题了。 如果同时有另外一个语句,是这么写的: Copy select id from t where c in(5,20,10) order by c desc for update; 此时的加锁范围,又是什么呢? 我们现在都知道间隙锁是不互锁的,但是这两条语句都会在索引c上的c=5、10、20这三行记录上加记录锁。 这里你需要注意一下,由于语句里面是order by c desc, 这三个记录锁的加锁顺序,是先锁c=20,然后c=10,最后是c=5。 也就是说,这两条语句要加锁相同的资源,但是加锁顺序相反。当这两条语句并发执行的时候,就可能出现死锁。 关于死锁的信息,MySQL只保留了最后一个死锁的现场,但这个现场还是不完备的。 有同学在评论区留言到,希望我能展开一下怎么看死锁。现在,我就来简单分析一下上面这个例子的死锁现场。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/30-da-yi-wen-zhang-er-yong-dong-tai-de-guan-dian-kan-jia-suo#zen-me-kan-si-suo) 怎么看死锁? 图3是在出现死锁后,执行show engine innodb status命令得到的部分输出。这个命令会输出很多信息,有一节LATESTDETECTED DEADLOCK,就是记录的最后一次死锁信息。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fa7%2Ff6%2Fa7dccb91bc17d12746703eb194775cf6.png&width=300&dpr=4&quality=100&sign=2f0deac8&sv=2) 图3 死锁现场 我们来看看这图中的几个关键信息。 1. 这个结果分成三部分: * (1) TRANSACTION,是第一个事务的信息; * (2) TRANSACTION,是第二个事务的信息; * WE ROLL BACK TRANSACTION (1),是最终的处理结果,表示回滚了第一个事务。 2. 第一个事务的信息中: * WAITING FOR THIS LOCK TO BE GRANTED,表示的是这个事务在等待的锁信息; * index c of table \`test\`.\`t\`,说明在等的是表t的索引c上面的锁; * lock mode S waiting 表示这个语句要自己加一个读锁,当前的状态是等待中; * Record lock说明这是一个记录锁; * n\_fields 2表示这个记录是两列,也就是字段c和主键字段id; * 0: len 4; hex 0000000a; asc ;;是第一个字段,也就是c。值是十六进制a,也就是10; * 1: len 4; hex 0000000a; asc ;;是第二个字段,也就是主键id,值也是10; * 这两行里面的asc表示的是,接下来要打印出值里面的“可打印字符”,但10不是可打印字符,因此就显示空格。 * 第一个事务信息就只显示出了等锁的状态,在等待(c=10,id=10)这一行的锁。 * 当然你是知道的,既然出现死锁了,就表示这个事务也占有别的锁,但是没有显示出来。别着急,我们从第二个事务的信息中推导出来。 3. 第二个事务显示的信息要多一些: * “ HOLDS THE LOCK(S)”用来显示这个事务持有哪些锁; * index c of table \`test\`.\`t\` 表示锁是在表t的索引c上; * hex 0000000a和hex 00000014表示这个事务持有c=10和c=20这两个记录锁; * WAITING FOR THIS LOCK TO BE GRANTED,表示在等(c=5,id=5)这个记录锁。 从上面这些信息中,我们就知道: 1. “lock in share mode”的这条语句,持有c=5的记录锁,在等c=10的锁; 2. “for update”这个语句,持有c=20和c=10的记录锁,在等c=5的记录锁。 因此导致了死锁。这里,我们可以得到两个结论: 1. 由于锁是一个个加的,要避免死锁,对同一组资源,要按照尽量相同的顺序访问; 2. 在发生死锁的时刻,for update 这条语句占有的资源更多,回滚成本更大,所以InnoDB选择了回滚成本更小的lock in share mode语句,来回滚。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/30-da-yi-wen-zhang-er-yong-dong-tai-de-guan-dian-kan-jia-suo#zen-me-kan-suo-deng-dai) 怎么看锁等待? 看完死锁,我们再来看一个锁等待的例子。 在第21篇文章的评论区,@Geek\_9ca34e 同学做了一个有趣验证,我把复现步骤列出来: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Faf%2F75%2Faf3602b81aeb49e33577ba372d220a75.png&width=768&dpr=4&quality=100&sign=a508cfa3&sv=2) 图4 delete导致间隙变化 可以看到,由于session A并没有锁住c=10这个记录,所以session B删除id=10这一行是可以的。但是之后,session B再想insert id=10这一行回去就不行了。 现在我们一起看一下此时show engine innodb status的结果,看看能不能给我们一些提示。锁信息是在这个命令输出结果的TRANSACTIONS这一节。你可以在文稿中看到这张图片 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fc3%2Fa6%2Fc3744fb7b61df2a5b45b8eb1f2a853a6.png&width=300&dpr=4&quality=100&sign=e3aa2bf9&sv=2) 图 5 锁等待信息 我们来看几个关键信息。 1. index PRIMARY of table \`test\`.\`t\` ,表示这个语句被锁住是因为表t主键上的某个锁。 2. lock\_mode X locks gap before rec insert intention waiting 这里有几个信息: * insert intention表示当前线程准备插入一个记录,这是一个插入意向锁。为了便于理解,你可以认为它就是这个插入动作本身。 * gap before rec 表示这是一个间隙锁,而不是记录锁。 3. 那么这个gap是在哪个记录之前的呢?接下来的0~4这5行的内容就是这个记录的信息。 4. n\_fields 5也表示了,这一个记录有5列: * 0: len 4; hex 0000000f; asc ;;第一列是主键id字段,十六进制f就是id=15。所以,这时我们就知道了,这个间隙就是id=15之前的,因为id=10已经不存在了,它表示的就是(5,15)。 * 1: len 6; hex 000000000513; asc ;;第二列是长度为6字节的事务id,表示最后修改这一行的是trx id为1299的事务。 * 2: len 7; hex b0000001250134; asc % 4;; 第三列长度为7字节的回滚段信息。可以看到,这里的acs后面有显示内容(%和4),这是因为刚好这个字节是可打印字符。 * 后面两列是c和d的值,都是15。 因此,我们就知道了,由于delete操作把id=10这一行删掉了,原来的两个间隙(5,10)、(10,15)变成了一个(5,15)。 说到这里,你可以联合起来再思考一下这两个现象之间的关联: 1. session A执行完select语句后,什么都没做,但它加锁的范围突然“变大”了; 2. 第21篇文章的课后思考题,当我们执行select \* from t where c>=15 and c<=20 order by c desc lock in share mode; 向左扫描到c=10的时候,要把(5, 10\]锁起来。 也就是说,所谓“间隙”,其实根本就是由“这个间隙右边的那个记录”定义的。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/30-da-yi-wen-zhang-er-yong-dong-tai-de-guan-dian-kan-jia-suo#update-de-li-zi) update的例子 看过了insert和delete的加锁例子,我们再来看一个update语句的案例。在留言区中@信信 同学做了这个试验: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F61%2Fa7%2F61c1ceea7b59201649c2514c9db864a7.png&width=768&dpr=4&quality=100&sign=a5625d86&sv=2) 图 6 update 的例子 你可以自己分析一下,session A的加锁范围是索引c上的 (5,10\]、(10,15\]、(15,20\]、(20,25\]和(25,supremum\]。 > 注意:根据c>5查到的第一个记录是c=10,因此不会加(0,5\]这个next-key lock。 之后session B的第一个update语句,要把c=5改成c=1,你可以理解为两步: 1. 插入(c=1, id=5)这个记录; 2. 删除(c=5, id=5)这个记录。 按照我们上一节说的,索引c上(5,10)间隙是由这个间隙右边的记录,也就是c=10定义的。所以通过这个操作,session A的加锁范围变成了图7所示的样子: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fd2%2Fe9%2Fd2f6a0c46dd8d12f6a90dacc466d53e9.png&width=300&dpr=4&quality=100&sign=8ce78454&sv=2) 图 7 session B修改后, session A的加锁范围 好,接下来session B要执行 update t set c = 5 where c = 1这个语句了,一样地可以拆成两步: 1. 插入(c=5, id=5)这个记录; 2. 删除(c=1, id=5)这个记录。 第一步试图在已经加了间隙锁的(1,10)中插入数据,所以就被堵住了。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/30-da-yi-wen-zhang-er-yong-dong-tai-de-guan-dian-kan-jia-suo#xiao-jie) 小结 今天这篇文章,我用前面[第20](https://time.geekbang.org/column/article/75173) 和[第21篇](https://time.geekbang.org/column/article/75659) 文章评论区的几个问题,再次跟你复习了加锁规则。并且,我和你重点说明了,分析加锁范围时,一定要配合语句执行逻辑来进行。 在我看来,每个想认真了解MySQL原理的同学,应该都要能够做到:通过explain的结果,就能够脑补出一个SQL语句的执行流程。达到这样的程度,才算是对索引组织表、索引、锁的概念有了比较清晰的认识。你同样也可以用这个方法,来验证自己对这些知识点的掌握程度。 在分析这些加锁规则的过程中,我也顺便跟你介绍了怎么看show engine innodb status输出结果中的事务信息和死锁信息,希望这些内容对你以后分析现场能有所帮助。 老规矩,即便是答疑文章,我也还是要留一个课后问题给你的。 上面我们提到一个很重要的点:所谓“间隙”,其实根本就是由“这个间隙右边的那个记录”定义的。 那么,一个空表有间隙吗?这个间隙是由谁定义的?你怎么验证这个结论呢? 你可以把你关于分析和验证方法写在留言区,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/30-da-yi-wen-zhang-er-yong-dong-tai-de-guan-dian-kan-jia-suo#shang-qi-wen-ti-shi-jian) 上期问题时间 我在上一篇文章最后留给的问题,是分享一下你关于业务监控的处理经验。 在这篇文章的评论区,很多同学都分享了不错的经验。这里,我就选择几个比较典型的留言,和你分享吧: * @老杨同志 回答得很详细。他的主要思路就是关于服务状态和服务质量的监控。其中,服务状态的监控,一般都可以用外部系统来实现;而服务的质量的监控,就要通过接口的响应时间来统计。 * @Ryoma 同学,提到服务中使用了healthCheck来检测,其实跟我们文中提到的select 1的模式类似。 * @强哥 同学,按照监控的对象,将监控分成了基础监控、服务监控和业务监控,并分享了每种监控需要关注的对象。 这些都是很好的经验,你也可以根据具体的业务场景借鉴适合自己的方案。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F09%2F77%2F09c1073f99cf71d2fb162a716b5fa577.jpg&width=768&dpr=4&quality=100&sign=e5471b04&sv=2) [Previous29 如何判断一个数据库是不是出问题了?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/29-ru-he-pan-duan-yi-ge-shu-ju-ku-shi-bu-shi-chu-wen-ti-le) [Next31 误删数据后除了跑路,还能怎么办?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/31-wu-shan-shu-ju-hou-chu-le-pao-lu-huan-neng-zen-mo-ban) Last updated 2 years ago Was this helpful? --- # 28 读写分离有哪些坑? | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252FqdTZCtPxi9iFxrrvyemL%252Fimage.png%3Falt%3Dmedia%26token%3D5dc1d02f-0100-493b-be23-d0538eff2f65&width=768&dpr=4&quality=100&sign=e9a632fb&sv=2) 在上一篇文章中,我和你介绍了一主多从的结构以及切换流程。今天我们就继续聊聊一主多从架构的应用场景:读写分离,以及怎么处理主备延迟导致的读写分离问题。 我们在上一篇文章中提到的一主多从的结构,其实就是读写分离的基本结构了。这里,我再把这张图贴过来,方便你理解。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F13%2Faa%2F1334b9c08b8fd837832fdb2d82e6b0aa.png&width=768&dpr=4&quality=100&sign=9a534f5d&sv=2) 图1 读写分离基本结构 读写分离的主要目标就是分摊主库的压力。图1中的结构是客户端(client)主动做负载均衡,这种模式下一般会把数据库的连接信息放在客户端的连接层。也就是说,由客户端来选择后端数据库进行查询。 还有一种架构是,在MySQL和客户端之间有一个中间代理层proxy,客户端只连接proxy, 由proxy根据请求类型和上下文决定请求的分发路由。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F06%2F18%2F065ef246c59019effc8384967d774318.png&width=768&dpr=4&quality=100&sign=e21309f&sv=2) 图2 带proxy的读写分离架构 接下来,我们就看一下客户端直连和带proxy的读写分离架构,各有哪些特点。 1. 客户端直连方案,因为少了一层proxy转发,所以查询性能稍微好一点儿,并且整体架构简单,排查问题更方便。但是这种方案,由于要了解后端部署细节,所以在出现主备切换、库迁移等操作的时候,客户端都会感知到,并且需要调整数据库连接信息。 你可能会觉得这样客户端也太麻烦了,信息大量冗余,架构很丑。其实也未必,一般采用这样的架构,一定会伴随一个负责管理后端的组件,比如Zookeeper,尽量让业务端只专注于业务逻辑开发。 2. 带proxy的架构,对客户端比较友好。客户端不需要关注后端细节,连接维护、后端信息维护等工作,都是由proxy完成的。但这样的话,对后端维护团队的要求会更高。而且,proxy也需要有高可用架构。因此,带proxy架构的整体就相对比较复杂。 理解了这两种方案的优劣,具体选择哪个方案就取决于数据库团队提供的能力了。但目前看,趋势是往带proxy的架构方向发展的。 但是,不论使用哪种架构,你都会碰到我们今天要讨论的问题:由于主从可能存在延迟,客户端执行完一个更新事务后马上发起查询,如果查询选择的是从库的话,就有可能读到刚刚的事务更新之前的状态。 **这种“在从库上会读到系统的一个过期状态”的现象,在这篇文章里,我们暂且称之为“过期读”。** 前面我们说过了几种可能导致主备延迟的原因,以及对应的优化策略,但是主从延迟还是不能100%避免的。 不论哪种结构,客户端都希望查询从库的数据结果,跟查主库的数据结果是一样的。 接下来,我们就来讨论怎么处理过期读问题。 这里,我先把文章中涉及到的处理过期读的方案汇总在这里,以帮助你更好地理解和掌握全文的知识脉络。这些方案包括: * 强制走主库方案; * sleep方案; * 判断主备无延迟方案; * 配合semi-sync方案; * 等主库位点方案; * 等GTID方案。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/28-du-xie-fen-li-you-na-xie-keng#qiang-zhi-zou-zhu-ku-fang-an) 强制走主库方案 强制走主库方案其实就是,将查询请求做分类。通常情况下,我们可以将查询请求分为这么两类: 1. 对于必须要拿到最新结果的请求,强制将其发到主库上。比如,在一个交易平台上,卖家发布商品以后,马上要返回主页面,看商品是否发布成功。那么,这个请求需要拿到最新的结果,就必须走主库。 2. 对于可以读到旧数据的请求,才将其发到从库上。在这个交易平台上,买家来逛商铺页面,就算晚几秒看到最新发布的商品,也是可以接受的。那么,这类请求就可以走从库。 你可能会说,这个方案是不是有点畏难和取巧的意思,但其实这个方案是用得最多的。 当然,这个方案最大的问题在于,有时候你会碰到“所有查询都不能是过期读”的需求,比如一些金融类的业务。这样的话,你就要放弃读写分离,所有读写压力都在主库,等同于放弃了扩展性。 因此接下来,我们来讨论的话题是:可以支持读写分离的场景下,有哪些解决过期读的方案,并分析各个方案的优缺点。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/28-du-xie-fen-li-you-na-xie-keng#sleep-fang-an) Sleep 方案 主库更新后,读从库之前先sleep一下。具体的方案就是,类似于执行一条select sleep(1)命令。 这个方案的假设是,大多数情况下主备延迟在1秒之内,做一个sleep可以有很大概率拿到最新的数据。 这个方案给你的第一感觉,很可能是不靠谱儿,应该不会有人用吧?并且,你还可能会说,直接在发起查询时先执行一条sleep语句,用户体验很不友好啊。 但,这个思路确实可以在一定程度上解决问题。为了看起来更靠谱儿,我们可以换一种方式。 以卖家发布商品为例,商品发布后,用Ajax(Asynchronous JavaScript + XML,异步JavaScript和XML)直接把客户端输入的内容作为“新的商品”显示在页面上,而不是真正地去数据库做查询。 这样,卖家就可以通过这个显示,来确认产品已经发布成功了。等到卖家再刷新页面,去查看商品的时候,其实已经过了一段时间,也就达到了sleep的目的,进而也就解决了过期读的问题。 也就是说,这个sleep方案确实解决了类似场景下的过期读问题。但,从严格意义上来说,这个方案存在的问题就是不精确。这个不精确包含了两层意思: 1. 如果这个查询请求本来0.5秒就可以在从库上拿到正确结果,也会等1秒; 2. 如果延迟超过1秒,还是会出现过期读。 看到这里,你是不是有一种“你是不是在逗我”的感觉,这个改进方案虽然可以解决类似Ajax场景下的过期读问题,但还是怎么看都不靠谱儿。别着急,接下来我就和你介绍一些更准确的方案。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/28-du-xie-fen-li-you-na-xie-keng#pan-duan-zhu-bei-wu-yan-chi-fang-an) 判断主备无延迟方案 要确保备库无延迟,通常有三种做法。 通过前面的[第25篇](https://time.geekbang.org/column/article/76795) 文章,我们知道show slave status结果里的seconds\_behind\_master参数的值,可以用来衡量主备延迟时间的长短。 所以**第一种确保主备无延迟的方法是,**每次从库执行查询请求前,先判断seconds\_behind\_master是否已经等于0。如果还不等于0 ,那就必须等到这个参数变为0才能执行查询请求。 seconds\_behind\_master的单位是秒,如果你觉得精度不够的话,还可以采用对比位点和GTID的方法来确保主备无延迟,也就是我们接下来要说的第二和第三种方法。 如图3所示,是一个show slave status结果的部分截图。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F00%2Fc1%2F00110923007513e865d7f43a124887c1.png&width=768&dpr=4&quality=100&sign=893f8ff7&sv=2) 图3 show slave status结果 现在,我们就通过这个结果,来看看具体如何通过对比位点和GTID来确保主备无延迟。 **第二种方法,**对比位点确保主备无延迟: * Master\_Log\_File和Read\_Master\_Log\_Pos,表示的是读到的主库的最新位点; * Relay\_Master\_Log\_File和Exec\_Master\_Log\_Pos,表示的是备库执行的最新位点。 如果Master\_Log\_File和Relay\_Master\_Log\_File、Read\_Master\_Log\_Pos和Exec\_Master\_Log\_Pos这两组值完全相同,就表示接收到的日志已经同步完成。 **第三种方法,**对比GTID集合确保主备无延迟: * Auto\_Position=1 ,表示这对主备关系使用了GTID协议。 * Retrieved\_Gtid\_Set,是备库收到的所有日志的GTID集合; * Executed\_Gtid\_Set,是备库所有已经执行完成的GTID集合。 如果这两个集合相同,也表示备库接收到的日志都已经同步完成。 可见,对比位点和对比GTID这两种方法,都要比判断seconds\_behind\_master是否为0更准确。 在执行查询请求之前,先判断从库是否同步完成的方法,相比于sleep方案,准确度确实提升了不少,但还是没有达到“精确”的程度。为什么这么说呢? 我们现在一起来回顾下,一个事务的binlog在主备库之间的状态: 1. 主库执行完成,写入binlog,并反馈给客户端; 2. binlog被从主库发送给备库,备库收到; 3. 在备库执行binlog完成。 我们上面判断主备无延迟的逻辑,是“备库收到的日志都执行完成了”。但是,从binlog在主备之间状态的分析中,不难看出还有一部分日志,处于客户端已经收到提交确认,而备库还没收到日志的状态。 如图4所示就是这样的一个状态。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F55%2F9e%2F557445207b57d6c0f2747509d7d6619e.png&width=768&dpr=4&quality=100&sign=34be853a&sv=2) 图4 备库还没收到trx3 这时,主库上执行完成了三个事务trx1、trx2和trx3,其中: 1. trx1和trx2已经传到从库,并且已经执行完成了; 2. trx3在主库执行完成,并且已经回复给客户端,但是还没有传到从库中。 如果这时候你在从库B上执行查询请求,按照我们上面的逻辑,从库认为已经没有同步延迟,但还是查不到trx3的。严格地说,就是出现了过期读。 那么,这个问题有没有办法解决呢? ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/28-du-xie-fen-li-you-na-xie-keng#pei-he-semisync) 配合semi-sync 要解决这个问题,就要引入半同步复制,也就是semi-sync replication。 semi-sync做了这样的设计: 1. 事务提交的时候,主库把binlog发给从库; 2. 从库收到binlog以后,发回给主库一个ack,表示收到了; 3. 主库收到这个ack以后,才能给客户端返回“事务完成”的确认。 也就是说,如果启用了semi-sync,就表示所有给客户端发送过确认的事务,都确保了备库已经收到了这个日志。 在[第25篇文章](https://time.geekbang.org/column/article/76795) 的评论区,有同学问到:如果主库掉电的时候,有些binlog还来不及发给从库,会不会导致系统数据丢失? 答案是,如果使用的是普通的异步复制模式,就可能会丢失,但semi-sync就可以解决这个问题。 这样,semi-sync配合前面关于位点的判断,就能够确定在从库上执行的查询请求,可以避免过期读。 但是,semi-sync+位点判断的方案,只对一主一备的场景是成立的。在一主多从场景中,主库只要等到一个从库的ack,就开始给客户端返回确认。这时,在从库上执行查询请求,就有两种情况: 1. 如果查询是落在这个响应了ack的从库上,是能够确保读到最新数据; 2. 但如果是查询落到其他从库上,它们可能还没有收到最新的日志,就会产生过期读的问题。 其实,判断同步位点的方案还有另外一个潜在的问题,即:如果在业务更新的高峰期,主库的位点或者GTID集合更新很快,那么上面的两个位点等值判断就会一直不成立,很可能出现从库上迟迟无法响应查询请求的情况。 实际上,回到我们最初的业务逻辑里,当发起一个查询请求以后,我们要得到准确的结果,其实并不需要等到“主备完全同步”。 为什么这么说呢?我们来看一下这个时序图。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F9c%2F09%2F9cf54f3e91dc8f7b8947d7d8e384aa09.png&width=768&dpr=4&quality=100&sign=a0dc3772&sv=2) 图5 主备持续延迟一个事务 图5所示,就是等待位点方案的一个bad case。图中备库B下的虚线框,分别表示relaylog和binlog中的事务。可以看到,图5中从状态1 到状态4,一直处于延迟一个事务的状态。 备库B一直到状态4都和主库A存在延迟,如果用上面必须等到无延迟才能查询的方案,select语句直到状态4都不能被执行。 但是,其实客户端是在发完trx1更新后发起的select语句,我们只需要确保trx1已经执行完成就可以执行select语句了。也就是说,如果在状态3执行查询请求,得到的就是预期结果了。 到这里,我们小结一下,semi-sync配合判断主备无延迟的方案,存在两个问题: 1. 一主多从的时候,在某些从库执行查询请求会存在过期读的现象; 2. 在持续延迟的情况下,可能出现过度等待的问题。 接下来,我要和你介绍的等主库位点方案,就可以解决这两个问题。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/28-du-xie-fen-li-you-na-xie-keng#deng-zhu-ku-wei-dian-fang-an) 等主库位点方案 要理解等主库位点方案,我需要先和你介绍一条命令: Copy select master_pos_wait(file, pos[, timeout]); 这条命令的逻辑如下: 1. 它是在从库执行的; 2. 参数file和pos指的是主库上的文件名和位置; 3. timeout可选,设置为正整数N表示这个函数最多等待N秒。 这个命令正常返回的结果是一个正整数M,表示从命令开始执行,到应用完file和pos表示的binlog位置,执行了多少事务。 当然,除了正常返回一个正整数M外,这条命令还会返回一些其他结果,包括: 1. 如果执行期间,备库同步线程发生异常,则返回NULL; 2. 如果等待超过N秒,就返回-1; 3. 如果刚开始执行的时候,就发现已经执行过这个位置了,则返回0。 对于图5中先执行trx1,再执行一个查询请求的逻辑,要保证能够查到正确的数据,我们可以使用这个逻辑: 1. trx1事务更新完成后,马上执行show master status得到当前主库执行到的File和Position; 2. 选定一个从库执行查询语句; 3. 在从库上执行select master\_pos\_wait(File, Position, 1); 4. 如果返回值是>=0的正整数,则在这个从库执行查询语句; 5. 否则,到主库执行查询语句。 我把上面这个流程画出来。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fb2%2F57%2Fb20ae91ea46803df1b63ed683e1de357.png&width=768&dpr=4&quality=100&sign=a2f4fa4d&sv=2) 图6 master\_pos\_wait方案 这里我们假设,这条select查询最多在从库上等待1秒。那么,如果1秒内master\_pos\_wait返回一个大于等于0的整数,就确保了从库上执行的这个查询结果一定包含了trx1的数据。 步骤5到主库执行查询语句,是这类方案常用的退化机制。因为从库的延迟时间不可控,不能无限等待,所以如果等待超时,就应该放弃,然后到主库去查。 你可能会说,如果所有的从库都延迟超过1秒了,那查询压力不就都跑到主库上了吗?确实是这样。 但是,按照我们设定不允许过期读的要求,就只有两种选择,一种是超时放弃,一种是转到主库查询。具体怎么选择,就需要业务开发同学做好限流策略了。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/28-du-xie-fen-li-you-na-xie-keng#gtid-fang-an) GTID方案 如果你的数据库开启了GTID模式,对应的也有等待GTID的方案。 MySQL中同样提供了一个类似的命令: Copy select wait_for_executed_gtid_set(gtid_set, 1); 这条命令的逻辑是: 1. 等待,直到这个库执行的事务中包含传入的gtid\_set,返回0; 2. 超时返回1。 在前面等位点的方案中,我们执行完事务后,还要主动去主库执行show master status。而MySQL 5.7.6版本开始,允许在执行完更新类事务后,把这个事务的GTID返回给客户端,这样等GTID的方案就可以减少一次查询。 这时,等GTID的执行流程就变成了: 1. trx1事务更新完成后,从返回包直接获取这个事务的GTID,记为gtid1; 2. 选定一个从库执行查询语句; 3. 在从库上执行 select wait\_for\_executed\_gtid\_set(gtid1, 1); 4. 如果返回值是0,则在这个从库执行查询语句; 5. 否则,到主库执行查询语句。 跟等主库位点的方案一样,等待超时后是否直接到主库查询,需要业务开发同学来做限流考虑。 我把这个流程图画出来。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fd5%2F39%2Fd521de8017297aff59db2f68170ee739.png&width=768&dpr=4&quality=100&sign=3269588c&sv=2) 图7 wait\_for\_executed\_gtid\_set方案 在上面的第一步中,trx1事务更新完成后,从返回包直接获取这个事务的GTID。问题是,怎么能够让MySQL在执行事务后,返回包中带上GTID呢? 你只需要将参数session\_track\_gtids设置为OWN\_GTID,然后通过API接口mysql\_session\_track\_get\_first从返回包解析出GTID的值即可。 在专栏的[第一篇文章](https://time.geekbang.org/column/article/68319) 中,我介绍mysql\_reset\_connection的时候,评论区有同学留言问这类接口应该怎么使用。 这里我再回答一下。其实,MySQL并没有提供这类接口的SQL用法,是提供给程序的API([https://dev.mysql.com/doc/refman/5.7/en/c-api-functions.html](https://dev.mysql.com/doc/refman/5.7/en/c-api-functions.html) )。 比如,为了让客户端在事务提交后,返回的GITD能够在客户端显示出来,我对MySQL客户端代码做了点修改,如下所示: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F97%2F63%2F973bdd8741f830acebe005cbf37a7663.png&width=768&dpr=4&quality=100&sign=ce11a530&sv=2) 图8 显示更新事务的GTID--代码 这样,就可以看到语句执行完成,显示出GITD的值。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F25%2Ffe%2F253106d31d9d97aaa2846b2015f593fe.png&width=768&dpr=4&quality=100&sign=e6e2e039&sv=2) 图9 显示更新事务的GTID--效果 当然了,这只是一个例子。你要使用这个方案的时候,还是应该在你的客户端代码中调用mysql\_session\_track\_get\_first这个函数。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/28-du-xie-fen-li-you-na-xie-keng#xiao-jie) 小结 在今天这篇文章中,我跟你介绍了一主多从做读写分离时,可能碰到过期读的原因,以及几种应对的方案。 这几种方案中,有的方案看上去是做了妥协,有的方案看上去不那么靠谱儿,但都是有实际应用场景的,你需要根据业务需求选择。 即使是最后等待位点和等待GTID这两个方案,虽然看上去比较靠谱儿,但仍然存在需要权衡的情况。如果所有的从库都延迟,那么请求就会全部落到主库上,这时候会不会由于压力突然增大,把主库打挂了呢? 其实,在实际应用中,这几个方案是可以混合使用的。 比如,先在客户端对请求做分类,区分哪些请求可以接受过期读,而哪些请求完全不能接受过期读;然后,对于不能接受过期读的语句,再使用等GTID或等位点的方案。 但话说回来,过期读在本质上是由一写多读导致的。在实际应用中,可能会有别的不需要等待就可以水平扩展的数据库方案,但这往往是用牺牲写性能换来的,也就是需要在读性能和写性能中取权衡。 最后 ,我给你留下一个问题吧。 假设你的系统采用了我们文中介绍的最后一个方案,也就是等GTID的方案,现在你要对主库的一张大表做DDL,可能会出现什么情况呢?为了避免这种情况,你会怎么做呢? 你可以把你的分析和方案设计写在评论区,我会在下一篇文章跟你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/28-du-xie-fen-li-you-na-xie-keng#shang-qi-wen-ti-shi-jian) 上期问题时间 上期给你留的问题是,在GTID模式下,如果一个新的从库接上主库,但是需要的binlog已经没了,要怎么做? @某、人同学给了很详细的分析,我把他的回答略做修改贴过来。 1. 如果业务允许主从不一致的情况,那么可以在主库上先执行show global variables like ‘gtid\_purged’,得到主库已经删除的GTID集合,假设是gtid\_purged1;然后先在从库上执行reset master,再执行set global gtid\_purged =‘gtid\_purged1’;最后执行start slave,就会从主库现存的binlog开始同步。binlog缺失的那一部分,数据在从库上就可能会有丢失,造成主从不一致。 2. 如果需要主从数据一致的话,最好还是通过重新搭建从库来做。 3. 如果有其他的从库保留有全量的binlog的话,可以把新的从库先接到这个保留了全量binlog的从库,追上日志以后,如果有需要,再接回主库。 4. 如果binlog有备份的情况,可以先在从库上应用缺失的binlog,然后再执行start slave。 评论区留言点赞板: > @悟空 同学级联实验,验证了seconds\_behind\_master的计算逻辑。 > @\_CountingStars 问了一个好问题:MySQL是怎么快速定位binlog里面的某一个GTID位置的?答案是,在binlog文件头部的Previous\_gtids可以解决这个问题。 > @王朋飞 同学问了一个好问题,sql\_slave\_skip\_counter跳过的是一个event,由于MySQL总不能执行一半的事务,所以既然跳过了一个event,就会跳到这个事务的末尾,因此set global sql\_slave\_skip\_counter=1;start slave是可以跳过整个事务的。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F09%2F77%2F09c1073f99cf71d2fb162a716b5fa577.jpg&width=768&dpr=4&quality=100&sign=e5471b04&sv=2) [Previous27 主库出问题了,从库怎么办?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/27-zhu-ku-chu-wen-ti-le-cong-ku-zen-mo-ban) [Next29 如何判断一个数据库是不是出问题了?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/29-ru-he-pan-duan-yi-ge-shu-ju-ku-shi-bu-shi-chu-wen-ti-le) Last updated 2 years ago Was this helpful? --- # 35 join语句怎么优化? | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252FGGU2q9NfO12rHIgC6VwA%252Fimage.png%3Falt%3Dmedia%26token%3De83c65c3-4e62-4e37-807e-420658b6ece6&width=768&dpr=4&quality=100&sign=d2cc7f5d&sv=2) 在上一篇文章中,我和你介绍了join语句的两种算法,分别是Index Nested-Loop Join(NLJ)和Block Nested-Loop Join(BNL)。 我们发现在使用NLJ算法的时候,其实效果还是不错的,比通过应用层拆分成多个语句然后再拼接查询结果更方便,而且性能也不会差。 但是,BNL算法在大表join的时候性能就差多了,比较次数等于两个表参与join的行数的乘积,很消耗CPU资源。 当然了,这两个算法都还有继续优化的空间,我们今天就来聊聊这个话题。 为了便于分析,我还是创建两个表t1、t2来和你展开今天的问题。 Copy create table t1(id int primary key, a int, b int, index(a)); create table t2 like t1; drop procedure idata; delimiter ;; create procedure idata() begin declare i int; set i=1; while(i<=1000)do insert into t1 values(i, 1001-i, i); set i=i+1; end while; set i=1; while(i<=1000000)do insert into t2 values(i, i, i); set i=i+1; end while; end;; delimiter ; call idata(); 为了便于后面量化说明,我在表t1里,插入了1000行数据,每一行的a=1001-id的值。也就是说,表t1中字段a是逆序的。同时,我在表t2中插入了100万行数据。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/35-join-yu-ju-zen-mo-you-hua#multirange-read-you-hua) Multi-Range Read优化 在介绍join语句的优化方案之前,我需要先和你介绍一个知识点,即:Multi-Range Read优化(MRR)。这个优化的主要目的是尽量使用顺序读盘。 在[第4篇文章](https://time.geekbang.org/column/article/69236) 中,我和你介绍InnoDB的索引结构时,提到了“回表”的概念。我们先来回顾一下这个概念。回表是指,InnoDB在普通索引a上查到主键id的值后,再根据一个个主键id的值到主键索引上去查整行数据的过程。 然后,有同学在留言区问到,回表过程是一行行地查数据,还是批量地查数据? 我们先来看看这个问题。假设,我执行这个语句: Copy select * from t1 where a>=1 and a<=100; 主键索引是一棵B+树,在这棵树上,每次只能根据一个主键id查到一行数据。因此,回表肯定是一行行搜索主键索引的,基本流程如图1所示。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F17%2F11%2F1761edbd7734276ae0a213af3cdd3311.jpg&width=768&dpr=4&quality=100&sign=d306277a&sv=2) 图1 基本回表流程 如果随着a的值递增顺序查询的话,id的值就变成随机的,那么就会出现随机访问,性能相对较差。虽然“按行查”这个机制不能改,但是调整查询的顺序,还是能够加速的。 **因为大多数的数据都是按照主键递增顺序插入得到的,所以我们可以认为,如果按照主键的递增顺序查询的话,对磁盘的读比较接近顺序读,能够提升读性能。** 这,就是MRR优化的设计思路。此时,语句的执行流程变成了这样: 1. 根据索引a,定位到满足条件的记录,将id值放入read\_rnd\_buffer中; 2. 将read\_rnd\_buffer中的id进行递增排序; 3. 排序后的id数组,依次到主键id索引中查记录,并作为结果返回。 这里,read\_rnd\_buffer的大小是由read\_rnd\_buffer\_size参数控制的。如果步骤1中,read\_rnd\_buffer放满了,就会先执行完步骤2和3,然后清空read\_rnd\_buffer。之后继续找索引a的下个记录,并继续循环。 另外需要说明的是,如果你想要稳定地使用MRR优化的话,需要设置`set optimizer_switch="mrr_cost_based=off"`。(官方文档的说法,是现在的优化器策略,判断消耗的时候,会更倾向于不使用MRR,把mrr\_cost\_based设置为off,就是固定使用MRR了。) 下面两幅图就是使用了MRR优化后的执行流程和explain结果。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fd5%2Fc7%2Fd502fbaea7cac6f815c626b078da86c7.jpg&width=768&dpr=4&quality=100&sign=d06c8936&sv=2) 图2 MRR执行流程 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fa5%2F32%2Fa513d07ebaf1ae044d44391c89bc6432.png&width=768&dpr=4&quality=100&sign=8272d400&sv=2) 图3 MRR执行流程的explain结果 从图3的explain结果中,我们可以看到Extra字段多了Using MRR,表示的是用上了MRR优化。而且,由于我们在read\_rnd\_buffer中按照id做了排序,所以最后得到的结果集也是按照主键id递增顺序的,也就是与图1结果集中行的顺序相反。 到这里,我们小结一下。 **MRR能够提升性能的核心**在于,这条查询语句在索引a上做的是一个范围查询(也就是说,这是一个多值查询),可以得到足够多的主键id。这样通过排序以后,再去主键索引查数据,才能体现出“顺序性”的优势。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/35-join-yu-ju-zen-mo-you-hua#batched-key-access) Batched Key Access 理解了MRR性能提升的原理,我们就能理解MySQL在5.6版本后开始引入的Batched Key Acess(BKA)算法了。这个BKA算法,其实就是对NLJ算法的优化。 我们再来看看上一篇文章中用到的NLJ算法的流程图: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F10%2F3d%2F10e14e8b9691ac6337d457172b641a3d.jpg&width=768&dpr=4&quality=100&sign=a5d7cf2d&sv=2) 图4 Index Nested-Loop Join流程图 NLJ算法执行的逻辑是:从驱动表t1,一行行地取出a的值,再到被驱动表t2去做join。也就是说,对于表t2来说,每次都是匹配一个值。这时,MRR的优势就用不上了。 那怎么才能一次性地多传些值给表t2呢?方法就是,从表t1里一次性地多拿些行出来,一起传给表t2。 既然如此,我们就把表t1的数据取出来一部分,先放到一个临时内存。这个临时内存不是别人,就是join\_buffer。 通过上一篇文章,我们知道join\_buffer 在BNL算法里的作用,是暂存驱动表的数据。但是在NLJ算法里并没有用。那么,我们刚好就可以复用join\_buffer到BKA算法中。 如图5所示,是上面的NLJ算法优化后的BKA算法的流程。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F31%2F7e%2F31d85666542b9cb0b47a447a8593a47e.jpg&width=768&dpr=4&quality=100&sign=47edaf2a&sv=2) 图5 Batched Key Acess流程 图中,我在join\_buffer中放入的数据是P1~P100,表示的是只会取查询需要的字段。当然,如果join buffer放不下P1~P100的所有数据,就会把这100行数据分成多段执行上图的流程。 那么,这个BKA算法到底要怎么启用呢? 如果要使用BKA优化算法的话,你需要在执行SQL语句之前,先设置 Copy set optimizer_switch='mrr=on,mrr_cost_based=off,batched_key_access=on'; 其中,前两个参数的作用是要启用MRR。这么做的原因是,BKA算法的优化要依赖于MRR。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/35-join-yu-ju-zen-mo-you-hua#bnl-suan-fa-de-xing-neng-wen-ti) BNL算法的性能问题 说完了NLJ算法的优化,我们再来看BNL算法的优化。 我在上一篇文章末尾,给你留下的思考题是,使用Block Nested-Loop Join(BNL)算法时,可能会对被驱动表做多次扫描。如果这个被驱动表是一个大的冷数据表,除了会导致IO压力大以外,还会对系统有什么影响呢? 在[第33篇文章](https://time.geekbang.org/column/article/79407) 中,我们说到InnoDB的LRU算法的时候提到,由于InnoDB对Bufffer Pool的LRU算法做了优化,即:第一次从磁盘读入内存的数据页,会先放在old区域。如果1秒之后这个数据页不再被访问了,就不会被移动到LRU链表头部,这样对Buffer Pool的命中率影响就不大。 但是,如果一个使用BNL算法的join语句,多次扫描一个冷表,而且这个语句执行时间超过1秒,就会在再次扫描冷表的时候,把冷表的数据页移到LRU链表头部。 这种情况对应的,是冷表的数据量小于整个Buffer Pool的3/8,能够完全放入old区域的情况。 如果这个冷表很大,就会出现另外一种情况:业务正常访问的数据页,没有机会进入young区域。 由于优化机制的存在,一个正常访问的数据页,要进入young区域,需要隔1秒后再次被访问到。但是,由于我们的join语句在循环读磁盘和淘汰内存页,进入old区域的数据页,很可能在1秒之内就被淘汰了。这样,就会导致这个MySQL实例的Buffer Pool在这段时间内,young区域的数据页没有被合理地淘汰。 也就是说,这两种情况都会影响Buffer Pool的正常运作。 **大表join操作虽然对IO有影响,但是在语句执行结束后,对IO的影响也就结束了。但是,对Buffer Pool的影响就是持续性的,需要依靠后续的查询请求慢慢恢复内存命中率。** 为了减少这种影响,你可以考虑增大join\_buffer\_size的值,减少对被驱动表的扫描次数。 也就是说,BNL算法对系统的影响主要包括三个方面: 1. 可能会多次扫描被驱动表,占用磁盘IO资源; 2. 判断join条件需要执行M\*N次对比(M、N分别是两张表的行数),如果是大表就会占用非常多的CPU资源; 3. 可能会导致Buffer Pool的热数据被淘汰,影响内存命中率。 我们执行语句之前,需要通过理论分析和查看explain结果的方式,确认是否要使用BNL算法。如果确认优化器会使用BNL算法,就需要做优化。优化的常见做法是,给被驱动表的join字段加上索引,把BNL算法转成BKA算法。 接下来,我们就具体看看,这个优化怎么做? ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/35-join-yu-ju-zen-mo-you-hua#bnl-zhuan-bka) BNL转BKA 一些情况下,我们可以直接在被驱动表上建索引,这时就可以直接转成BKA算法了。 但是,有时候你确实会碰到一些不适合在被驱动表上建索引的情况。比如下面这个语句: Copy select * from t1 join t2 on (t1.b=t2.b) where t2.b>=1 and t2.b<=2000; 我们在文章开始的时候,在表t2中插入了100万行数据,但是经过where条件过滤后,需要参与join的只有2000行数据。如果这条语句同时是一个低频的SQL语句,那么再为这个语句在表t2的字段b上创建一个索引就很浪费了。 但是,如果使用BNL算法来join的话,这个语句的执行流程是这样的: 1. 把表t1的所有字段取出来,存入join\_buffer中。这个表只有1000行,join\_buffer\_size默认值是256k,可以完全存入。 2. 扫描表t2,取出每一行数据跟join\_buffer中的数据进行对比, * 如果不满足t1.b=t2.b,则跳过; * 如果满足t1.b=t2.b, 再判断其他条件,也就是是否满足t2.b处于\[1,2000\]的条件,如果是,就作为结果集的一部分返回,否则跳过。 我在上一篇文章中说过,对于表t2的每一行,判断join是否满足的时候,都需要遍历join\_buffer中的所有行。因此判断等值条件的次数是1000\*100万=10亿次,这个判断的工作量很大。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F92%2F60%2F92fbdbfc35da3040396401250cb33f60.png&width=768&dpr=4&quality=100&sign=522471bc&sv=2) 图6 explain结果 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fd8%2F9c%2Fd862bc3e88305688df2c354a4b26809c.png&width=768&dpr=4&quality=100&sign=fd16131f&sv=2) 图7 语句执行时间 可以看到,explain结果里Extra字段显示使用了BNL算法。在我的测试环境里,这条语句需要执行1分11秒。 在表t2的字段b上创建索引会浪费资源,但是不创建索引的话这个语句的等值条件要判断10亿次,想想也是浪费。那么,有没有两全其美的办法呢? 这时候,我们可以考虑使用临时表。使用临时表的大致思路是: 1. 把表t2中满足条件的数据放在临时表tmp\_t中; 2. 为了让join使用BKA算法,给临时表tmp\_t的字段b加上索引; 3. 让表t1和tmp\_t做join操作。 此时,对应的SQL语句的写法如下: Copy create temporary table temp_t(id int primary key, a int, b int, index(b))engine=innodb; insert into temp_t select * from t2 where b>=1 and b<=2000; select * from t1 join temp_t on (t1.b=temp_t.b); 图8就是这个语句序列的执行效果。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fa8%2Fc7%2Fa80cdffe8173fa0fd8969ed976ac6ac7.png&width=768&dpr=4&quality=100&sign=8b204b04&sv=2) 图8 使用临时表的执行效果 可以看到,整个过程3个语句执行时间的总和还不到1秒,相比于前面的1分11秒,性能得到了大幅提升。接下来,我们一起看一下这个过程的消耗: 1. 执行insert语句构造temp\_t表并插入数据的过程中,对表t2做了全表扫描,这里扫描行数是100万。 2. 之后的join语句,扫描表t1,这里的扫描行数是1000;join比较过程中,做了1000次带索引的查询。相比于优化前的join语句需要做10亿次条件判断来说,这个优化效果还是很明显的。 总体来看,不论是在原表上加索引,还是用有索引的临时表,我们的思路都是让join语句能够用上被驱动表上的索引,来触发BKA算法,提升查询性能。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/35-join-yu-ju-zen-mo-you-hua#kuo-zhan-hash-join) 扩展-hash join 看到这里你可能发现了,其实上面计算10亿次那个操作,看上去有点儿傻。如果join\_buffer里面维护的不是一个无序数组,而是一个哈希表的话,那么就不是10亿次判断,而是100万次hash查找。这样的话,整条语句的执行速度就快多了吧? 确实如此。 这,也正是MySQL的优化器和执行器一直被诟病的一个原因:不支持哈希join。并且,MySQL官方的roadmap,也是迟迟没有把这个优化排上议程。 实际上,这个优化思路,我们可以自己实现在业务端。实现流程大致如下: 1. `select * from t1;`取得表t1的全部1000行数据,在业务端存入一个hash结构,比如C++里的set、PHP的dict这样的数据结构。 2. `select * from t2 where b>=1 and b<=2000;` 获取表t2中满足条件的2000行数据。 3. 把这2000行数据,一行一行地取到业务端,到hash结构的数据表中寻找匹配的数据。满足匹配的条件的这行数据,就作为结果集的一行。 理论上,这个过程会比临时表方案的执行速度还要快一些。如果你感兴趣的话,可以自己验证一下。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/35-join-yu-ju-zen-mo-you-hua#xiao-jie) 小结 今天,我和你分享了Index Nested-Loop Join(NLJ)和Block Nested-Loop Join(BNL)的优化方法。 在这些优化方法中: 1. BKA优化是MySQL已经内置支持的,建议你默认使用; 2. BNL算法效率低,建议你都尽量转成BKA算法。优化的方向就是给被驱动表的关联字段加上索引; 3. 基于临时表的改进方案,对于能够提前过滤出小数据的join语句来说,效果还是很好的; 4. MySQL目前的版本还不支持hash join,但你可以配合应用端自己模拟出来,理论上效果要好于临时表的方案。 最后,我给你留下一道思考题吧。 我们在讲join语句的这两篇文章中,都只涉及到了两个表的join。那么,现在有一个三个表join的需求,假设这三个表的表结构如下: Copy CREATE TABLE `t1` ( `id` int(11) NOT NULL, `a` int(11) DEFAULT NULL, `b` int(11) DEFAULT NULL, `c` int(11) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB; create table t2 like t1; create table t3 like t2; insert into ... //初始化三张表的数据 语句的需求实现如下的join逻辑: Copy select * from t1 join t2 on(t1.a=t2.a) join t3 on (t2.b=t3.b) where t1.c>=X and t2.c>=Y and t3.c>=Z; 现在为了得到最快的执行速度,如果让你来设计表t1、t2、t3上的索引,来支持这个join语句,你会加哪些索引呢? 同时,如果我希望你用straight\_join来重写这个语句,配合你创建的索引,你就需要安排连接顺序,你主要考虑的因素是什么呢? 你可以把你的方案和分析写在留言区,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/35-join-yu-ju-zen-mo-you-hua#shang-qi-wen-ti-shi-jian) 上期问题时间 我在上篇文章最后留给你的问题,已经在本篇文章中解答了。 这里我再根据评论区留言的情况,简单总结下。根据数据量的大小,有这么两种情况: * @长杰 和 @老杨同志 提到了数据量小于old区域内存的情况; * @Zzz 同学,很认真地看了其他同学的评论,并且提了一个很深的问题。对被驱动表数据量大于Buffer Pool的场景,做了很细致的推演和分析。 给这些同学点赞,非常好的思考和讨论。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F09%2F77%2F09c1073f99cf71d2fb162a716b5fa577.jpg&width=768&dpr=4&quality=100&sign=e5471b04&sv=2) [Previous34 到底可不可以使用join?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/34-dao-di-ke-bu-ke-yi-shi-yong-join) [Next36 为什么临时表可以重名?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/36-wei-shi-mo-lin-shi-biao-ke-yi-zhong-ming) Last updated 2 years ago Was this helpful? --- # 21 | panic函数、recover函数以及defer语句 (上) | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252FmvPMFXEBg4Eopnbo6MM9%252Fimage.png%3Falt%3Dmedia%26token%3D8af4f778-c1b9-4485-a7a2-79de3b540221&width=768&dpr=4&quality=100&sign=a6242f71&sv=2) #### [](https://jums.gitbook.io/36-lectures-on-golang/21-panic-han-shu-recover-han-shu-yi-ji-defer-yu-ju-shang#go-yu-yan-dai-ma-jiao-duo-jian-yi-pei-he-wen-zhang-shou-ting-yin-pin) 【Go 语言代码较多,建议配合文章收听音频。】 [4MB\ \ 21.panic函数、recover函数以及defer语句 (上).mp3](https://2852705636-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FkmtyDvkosJZE1Rr33XGZ%2Fuploads%2FminC7HctHqhbWoxT9jsU%2F21.panic%E5%87%BD%E6%95%B0%E3%80%81recover%E5%87%BD%E6%95%B0%E4%BB%A5%E5%8F%8Adefer%E8%AF%AD%E5%8F%A5%20%EF%BC%88%E4%B8%8A%EF%BC%89.mp3?alt=media&token=ce83e859-a471-4f7c-a136-03d5ff4b4cd0) 我在上两篇文章中,详细地讲述了 Go 语言中的错误处理,并从两个视角为你总结了错误类型、错误值的处理技巧和设计方式。 在本篇,我要给你展示 Go 语言的另外一种错误处理方式。不过,严格来说,它处理的不是错误,而是异常,并且是一种在我们意料之外的程序异常。 这种程序异常被叫做 panic,我把它翻译为运行时恐慌。其中的“恐慌”二字是由 panic 直译过来的,而之所以前面又加上了“运行时”三个字,是因为这种异常只会在程序运行的时候被抛出来。 比如说,一个 Go 程序里有一个切片,它的长度是 5,也就是说该切片中的元素值的索引分别为`0`、`1`、`2`、`3`、`4`,但是,我在程序里却想通过索引`5`访问其中的元素值,显而易见,这样的访问是不正确的。 Go 程序,确切地说是程序内嵌的 Go 语言运行时系统,会在执行到这行代码的时候抛出一个“index out of range”的 panic,用以提示你索引越界了。 当然了,这不仅仅是个提示。当 panic 被抛出之后,如果我们没有在程序里添加任何保护措施的话,程序(或者说代表它的那个进程)就会在打印出 panic 的详细情况(以下简称 panic 详情)之后,终止运行。 现在,就让我们来看一下这样的 panic 详情中都有什么。 复制 panic: runtime error: index out of range goroutine 1 [running]: main.main() /Users/haolin/GeekTime/Golang_Puzzlers/src/puzzlers/article19/q0/demo47.go:5 +0x3d exit status 2 这份详情的第一行是“panic: runtime error: index out of range”。其中的“runtime error”的含义是,这是一个`runtime`代码包中抛出的 panic。在这个 panic 中,包含了一个`runtime.Error`接口类型的值。`runtime.Error`接口内嵌了`error`接口并做了一点点扩展,`runtime`包中有不少它的实现类型。 实际上,此详情中的“panic:”右边的内容,正是这个 panic 包含的`runtime.Error`类型值的字符串表示形式。 此外,panic 详情中一般还会包含与它的引发原因有关的 goroutine 的代码执行信息。正如前述详情中的“goroutine 1 \[running\]”,它表示有一个 ID 为`1`的 goroutine 在此 panic 被引发的时候正在运行。 注意,这里的 ID 其实并不重要,因为它只是 Go 语言运行时系统内部给予的一个 goroutine 编号,我们在程序中是无法获取和更改的。 我们再看下一行,“main.main()”表明了这个 goroutine 包装的`go`函数就是命令源码文件中的那个`main`函数,也就是说这里的 goroutine 正是主 goroutine。再下面的一行,指出的就是这个 goroutine 中的哪一行代码在此 panic 被引发时正在执行。 这包含了此行代码在其所属的源码文件中的行数,以及这个源码文件的绝对路径。这一行最后的`+0x3d`代表的是:此行代码相对于其所属函数的入口程序计数偏移量。不过,一般情况下它的用处并不大。 最后,“exit status 2”表明我的这个程序是以退出状态码`2`结束运行的。在大多数操作系统中,只要退出状态码不是`0`,都意味着程序运行的非正常结束。在 Go 语言中,因 panic 导致程序结束运行的退出状态码一般都会是`2`。 综上所述,我们从上边的这个 panic 详情可以看出,作为此 panic 的引发根源的代码处于 demo47.go 文件中的第 5 行,同时被包含在`main`包(也就是命令源码文件所在的代码包)的`main`函数中。 那么,我的第一个问题也随之而来了。我今天的问题是:**从 panic 被引发到程序终止运行的大致过程是什么?** **这道题的典型回答是这样的。** 我们先说一个大致的过程:某个函数中的某行代码有意或无意地引发了一个 panic。这时,初始的 panic 详情会被建立起来,并且该程序的控制权会立即从此行代码转移至调用其所属函数的那行代码上,也就是调用栈中的上一级。 这也意味着,此行代码所属函数的执行随即终止。紧接着,控制权并不会在此有片刻停留,它又会立即转移至再上一级的调用代码处。控制权如此一级一级地沿着调用栈的反方向传播至顶端,也就是我们编写的最外层函数那里。 这里的最外层函数指的是`go`函数,对于主 goroutine 来说就是`main`函数。但是控制权也不会停留在那里,而是被 Go 语言运行时系统收回。 随后,程序崩溃并终止运行,承载程序这次运行的进程也会随之死亡并消失。与此同时,在这个控制权传播的过程中,panic 详情会被逐渐地积累和完善,并会在程序终止之前被打印出来。 ### [](https://jums.gitbook.io/36-lectures-on-golang/21-panic-han-shu-recover-han-shu-yi-ji-defer-yu-ju-shang#wen-ti-jie-xi) 问题解析 panic 可能是我们在无意间(或者说一不小心)引发的,如前文所述的索引越界。这类 panic 是真正的、在我们意料之外的程序异常。不过,除此之外,我们还是可以有意地引发 panic。 Go 语言的内建函数`panic`是专门用于引发 panic 的。`panic`函数使程序开发者可以在程序运行期间报告异常。 注意,这与从函数返回错误值的意义是完全不同的。当我们的函数返回一个非`nil`的错误值时,函数的调用方有权选择不处理,并且不处理的后果往往是不致命的。 这里的“不致命”的意思是,不至于使程序无法提供任何功能(也可以说僵死)或者直接崩溃并终止运行(也就是真死)。 但是,当一个 panic 发生时,如果我们不施加任何保护措施,那么导致的直接后果就是程序崩溃,就像前面描述的那样,这显然是致命的。 为了更清楚地展示答案中描述的过程,我编写了 demo48.go 文件。你可以先查看一下其中的代码,再试着运行它,并体会它打印的内容所代表的含义。 我在这里再提示一点。panic 详情会在控制权传播的过程中,被逐渐地积累和完善,并且,控制权会一级一级地沿着调用栈的反方向传播至顶端。 因此,在针对某个 goroutine 的代码执行信息中,调用栈底端的信息会先出现,然后是上一级调用的信息,以此类推,最后才是此调用栈顶端的信息。 比如,`main`函数调用了`caller1`函数,而`caller1`函数又调用了`caller2`函数,那么`caller2`函数中代码的执行信息会先出现,然后是`caller1`函数中代码的执行信息,最后才是`main`函数的信息。 复制 goroutine 1 [running]: main.caller2() /Users/haolin/GeekTime/Golang_Puzzlers/src/puzzlers/article19/q1/demo48.go:22 +0x91 main.caller1() /Users/haolin/GeekTime/Golang_Puzzlers/src/puzzlers/article19/q1/demo48.go:15 +0x66 main.main() /Users/haolin/GeekTime/Golang_Puzzlers/src/puzzlers/article19/q1/demo48.go:9 +0x66 exit status 2 好了,到这里,我相信你已经对 panic 被引发后的程序终止过程有一定的了解了。深入地了解此过程,以及正确地解读 panic 详情应该是我们的必备技能,这在调试 Go 程序或者为 Go 程序排查错误的时候非常重要。 ### [](https://jums.gitbook.io/36-lectures-on-golang/21-panic-han-shu-recover-han-shu-yi-ji-defer-yu-ju-shang#zong-jie) 总结 最近的两篇文章,我们是围绕着 panic 函数、recover 函数以及 defer 语句进行的。今天我主要讲了 panic 函数。这个函数是专门被用来引发 panic 的。panic 也可以被称为运行时恐慌,它是一种只能在程序运行期间抛出的程序异常。 Go 语言的运行时系统可能会在程序出现严重错误时自动地抛出 panic,我们在需要时也可以通过调用`panic`函数引发 panic。但不论怎样,如果不加以处理,panic 就会导致程序崩溃并终止运行。 ### [](https://jums.gitbook.io/36-lectures-on-golang/21-panic-han-shu-recover-han-shu-yi-ji-defer-yu-ju-shang#si-kao-ti) 思考题 一个函数怎样才能把 panic 转化为`error`类型值,并将其作为函数的结果值返回给调用方? [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页20 | 错误处理 (下)](https://jums.gitbook.io/36-lectures-on-golang/20-cuo-wu-chu-li-xia) [下一页22 | panic函数、recover函数以及defer语句(下)](https://jums.gitbook.io/36-lectures-on-golang/22-panic-han-shu-recover-han-shu-yi-ji-defer-yu-ju-xia) 最后更新于2年前 这有帮助吗? --- # 23 | 测试的基本规则和流程 (上) | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252FeLvltoSApkG2vwXGMkml%252Fimage.png%3Falt%3Dmedia%26token%3Df3baa37b-bcd6-43bd-8d54-30c6dd238b1f&width=768&dpr=4&quality=100&sign=14012798&sv=2) #### [](https://jums.gitbook.io/36-lectures-on-golang/23-ce-shi-de-ji-ben-gui-ze-he-liu-cheng-shang#go-yu-yan-dai-ma-jiao-duo-jian-yi-pei-he-wen-zhang-shou-ting-yin-pin) 【Go 语言代码较多,建议配合文章收听音频。】 [4MB\ \ 23.测试的基本规则和流程 (上).mp3](https://2852705636-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FkmtyDvkosJZE1Rr33XGZ%2Fuploads%2FA4GShpXzpuApVpU0Yrij%2F23.%E6%B5%8B%E8%AF%95%E7%9A%84%E5%9F%BA%E6%9C%AC%E8%A7%84%E5%88%99%E5%92%8C%E6%B5%81%E7%A8%8B%20%EF%BC%88%E4%B8%8A%EF%BC%89.mp3?alt=media&token=649056fc-152f-4829-80b7-564644b2efb9) 你好,我是郝林,今天我分享的主题是:测试的基本规则和流程(上)。 你很棒,已经学完了本专栏最大的一个模块!这涉及了 Go 语言的所有内建数据类型,以及非常有特色的那些流程和语句。 你已经完全可以去独立编写各种各样的 Go 程序了。如果忘了什么,回到之前的文章再复习一下就好了。 在接下来的日子里,我将带你去学习在 Go 语言编程进阶的道路上必须掌握的附加知识,比如:Go 程序测试、程序监测,以及 Go 语言标准库中各种常用代码包的正确用法。 从上个世纪到今日今时,程序员们,尤其是国内的程序员们,都对编写程序乐此不疲,甚至废寝忘食(比如我自己就是一个例子)。 因为这是我们普通人训练自我、改变生活、甚至改变世界的一种特有的途径。不过,同样是程序,我们却往往对编写用于测试的程序敬而远之。这是为什么呢? 我个人感觉,从人的本性来讲,我们都或多或少会否定“对自我的否定”。我们不愿意看到我们编写的程序有 Bug(即程序错误或缺陷),尤其是刚刚倾注心血编写的,并且信心满满交付的程序。 不过,我想说的是,人是否会进步以及进步得有多快,依赖的恰恰就是对自我的否定,这包括否定的深刻与否,以及否定自我的频率如何。这其实就是“不破不立”这个词表达的含义。 对于程序和软件来讲,尽早发现问题、修正问题其实非常重要。在这个网络互联的大背景下,我们所做的程序、工具或者软件产品往往可以被散布得更快、更远。但是,与此同时,它们的错误和缺陷也会是这样,并且可能在短时间内就会影响到成千上万甚至更多的用户。 你可能会说:“在开源模式下这就是优势啊,我就是要让更多的人帮我发现错误甚至修正错误,我们还可以一起协作、共同维护程序。”但这其实是两码事,协作者往往是由早期或核心的用户转换过来的,但绝对不能说程序的用户就肯定会成为协作者。 当有很多用户开始对程序抱怨的时候,很可能就预示着你对此的人设要崩塌了。你会发现,或者总有一天会发现,越是人们关注和喜爱的程序,它的测试(尤其是自动化的测试)做得就越充分,测试流程就越规范。 即使你想众人拾柴火焰高,那也得先让别人喜欢上你的程序。况且,对于优良的程序和软件来说,测试必然是非常受重视的一个环节。所以,尽快用测试为你的程序建起堡垒吧! 对于程序或软件的测试也分很多种,比如:单元测试、API 测试、集成测试、灰度测试,等等。我在本模块会主要针对单元测试进行讲解。 我们来说一下单元测试,它又称程序员测试。顾名思义,这就是程序员们本该做的自我检查工作之一。 Go 语言的缔造者们从一开始就非常重视程序测试,并且为 Go 程序的开发者们提供了丰富的 API 和工具。利用这些 API 和工具,我们可以创建测试源码文件,并为命令源码文件和库源码文件中的程序实体,编写测试用例。 在 Go 语言中,一个测试用例往往会由一个或多个测试函数来代表,不过在大多数情况下,每个测试用例仅用一个测试函数就足够了。测试函数往往用于描述和保障某个程序实体的某方面功能,比如,该功能在正常情况下会因什么样的输入,产生什么样的输出,又比如,该功能会在什么情况下报错或表现异常,等等。 我们可以为 Go 程序编写三类测试,即:功能测试(test)、基准测试(benchmark,也称性能测试),以及示例测试(example)。 对于前两类测试,从名称上你就应该可以猜到它们的用途。而示例测试严格来讲也是一种功能测试,只不过它更关注程序打印出来的内容。 一般情况下,一个测试源码文件只会针对于某个命令源码文件,或库源码文件(以下简称被测源码文件)做测试,所以我们总会(并且应该)把它们放在同一个代码包内。 测试源码文件的主名称应该以被测源码文件的主名称为前导,并且必须以“\_test”为后缀。例如,如果被测源码文件的名称为 demo52.go,那么针对它的测试源码文件的名称就应该是 demo52\_test.go。 每个测试源码文件都必须至少包含一个测试函数。并且,从语法上讲,每个测试源码文件中,都可以包含用来做任何一类测试的测试函数,即使把这三类测试函数都塞进去也没有问题。我通常就是这么做的,只要把控好测试函数的分组和数量就可以了。 我们可以依据这些测试函数针对的不同程序实体,把它们分成不同的逻辑组,并且,利用注释以及帮助类的变量或函数来做分割。同时,我们还可以依据被测源码文件中程序实体的先后顺序,来安排测试源码文件中测试函数的顺序。 此外,不仅仅对测试源码文件的名称,对于测试函数的名称和签名,Go 语言也是有明文规定的。你知道这个规定的内容吗? **所以,我们今天的问题就是:Go 语言对测试函数的名称和签名都有哪些规定?** **这里我给出的典型回答是下面三个内容。** 对于功能测试函数来说,其名称必须以`Test`为前缀,并且参数列表中只应有一个`*testing.T`类型的参数声明。 对于性能测试函数来说,其名称必须以`Benchmark`为前缀,并且唯一参数的类型必须是`*testing.B`类型的。 对于示例测试函数来说,其名称必须以`Example`为前缀,但对函数的参数列表没有强制规定。 ### [](https://jums.gitbook.io/36-lectures-on-golang/23-ce-shi-de-ji-ben-gui-ze-he-liu-cheng-shang#wen-ti-jie-xi) 问题解析 我问这个问题的目的一般有两个。第一个目的当然是考察 Go 程序测试的基本规则。如果你经常编写测试源码文件,那么这道题应该是很容易回答的。 第二个目的是作为一个引子,引出第二个问题,即:`go test`命令执行的主要测试流程是什么?不过在这里我就不问你了,我直接说一下答案。 我们首先需要记住一点,只有测试源码文件的名称对了,测试函数的名称和签名也对了,当我们运行`go test`命令的时候,其中的测试代码才有可能被运行。 `go test`命令在开始运行时,会先做一些准备工作,比如,确定内部需要用到的命令,检查我们指定的代码包或源码文件的有效性,以及判断我们给予的标记是否合法,等等。 在准备工作顺利完成之后,`go test`命令就会针对每个被测代码包,依次地进行构建、执行包中符合要求的测试函数,清理临时文件,打印测试结果。这就是通常情况下的主要测试流程。 请注意上述的“依次”二字。对于每个被测代码包,`go test`命令会串行地执行测试流程中的每个步骤。 但是,为了加快测试速度,它通常会并发地对多个被测代码包进行功能测试,只不过,在最后打印测试结果的时候,它会依照我们给定的顺序逐个进行,这会让我们感觉到它是在完全串行地执行测试流程。 另一方面,由于并发的测试会让性能测试的结果存在偏差,所以性能测试一般都是串行进行的。更具体地说,只有在所有构建步骤都做完之后,`go test`命令才会真正地开始进行性能测试。 并且,下一个代码包性能测试的进行,总会等到上一个代码包性能测试的结果打印完成才会开始,而且性能测试函数的执行也都会是串行的。 一旦清楚了 Go 程序测试的具体过程,我们的一些疑惑就自然有了答案。比如,那个名叫`TestIntroduce`的测试函数为什么没执行,又比如,为什么即使是简单的性能测试,执行起来也会比功能测试慢,等等。 ### [](https://jums.gitbook.io/36-lectures-on-golang/23-ce-shi-de-ji-ben-gui-ze-he-liu-cheng-shang#zong-jie) 总结 在本篇文章的一开始,我就试图向你阐释程序测试的重要性。在我经历的公司中起码有一半都不重视程序测试,或者说没有精力去做程序测试。 尤其是中小型的公司,他们往往完全依靠软件质量保障团队,甚至真正的用户去帮他们测试。在这些情况下,软件错误或缺陷的发现、反馈和修复的周期通常会很长,成本也会很大,也许还会造成很不好的影响。 Go 语言是一门很重视程序测试的编程语言,它不但自带了`testing`包,还有专用于程序测试的命令`go test`。我们要想真正用好一个工具,就需要先了解它的核心逻辑。所以,我今天问你的第一个问题就是关于`go test`命令的基本规则和主要流程的。在知道这些之后,也许你对 Go 程序测试就会进入更深层次的了解。 ### [](https://jums.gitbook.io/36-lectures-on-golang/23-ce-shi-de-ji-ben-gui-ze-he-liu-cheng-shang#si-kao-ti) 思考题 除了本文中提到的,你还知道或用过`testing.T`类型和`testing.B`类型的哪些方法?它们都是做什么用的?你可以给我留言,我们一起讨论。 感谢你的收听,我们下次再见。 [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页22 | panic函数、recover函数以及defer语句(下)](https://jums.gitbook.io/36-lectures-on-golang/22-panic-han-shu-recover-han-shu-yi-ji-defer-yu-ju-xia) [下一页24 | 测试的基本规则和流程(下)](https://jums.gitbook.io/36-lectures-on-golang/24-ce-shi-de-ji-ben-gui-ze-he-liu-cheng-xia) 最后更新于2年前 这有帮助吗? --- # 24 | 测试的基本规则和流程(下) | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252F9ytidoEv8A5Qx1ouIPKG%252Fimage.png%3Falt%3Dmedia%26token%3Df6d4d604-28c9-44d7-8640-08a2853e9c9b&width=768&dpr=4&quality=100&sign=abbe38e7&sv=2) #### [](https://jums.gitbook.io/36-lectures-on-golang/24-ce-shi-de-ji-ben-gui-ze-he-liu-cheng-xia#go-yu-yan-dai-ma-jiao-duo-jian-yi-pei-he-wen-zhang-shou-ting-yin-pin) 【Go 语言代码较多,建议配合文章收听音频。】 [6MB\ \ 24.测试的基本规则和流程(下).mp3](https://2852705636-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FkmtyDvkosJZE1Rr33XGZ%2Fuploads%2F0jb9m5RAzTjlF8rIiNkW%2F24.%E6%B5%8B%E8%AF%95%E7%9A%84%E5%9F%BA%E6%9C%AC%E8%A7%84%E5%88%99%E5%92%8C%E6%B5%81%E7%A8%8B%EF%BC%88%E4%B8%8B%EF%BC%89.mp3?alt=media&token=ec2e3ac1-5678-4ddd-b96c-de4075b92514) 你好,我是郝林。今天我分享的主题是测试的基本规则和流程的(下)篇。 Go 语言是一门很重视程序测试的编程语言,所以在上一篇中,我与你再三强调了程序测试的重要性,同时,也介绍了关于`go test`命令的基本规则和主要流程的内容。今天我们继续分享测试的基本规则和流程。本篇代码和指令较多,你可以点击文章查看原文。 ### [](https://jums.gitbook.io/36-lectures-on-golang/24-ce-shi-de-ji-ben-gui-ze-he-liu-cheng-xia#zhi-shi-kuo-zhan) 知识扩展 #### [](https://jums.gitbook.io/36-lectures-on-golang/24-ce-shi-de-ji-ben-gui-ze-he-liu-cheng-xia#wen-ti-1-zen-yang-jie-shi-gong-neng-ce-shi-de-ce-shi-jie-guo) 问题 1:怎样解释功能测试的测试结果? 我们先来看下面的测试命令和结果: 复制 $ go test puzzlers/article20/q2 ok puzzlers/article20/q2 0.008s 以`$`符号开头表明此行展现的是我输入的命令。在这里,我输入了`go test puzzlers/article20/q2`,这表示我想对导入路径为`puzzlers/article20/q2`的代码包进行测试。代码下面一行就是此次测试的简要结果。 这个简要结果有三块内容。最左边的`ok`表示此次测试成功,也就是说没有发现测试结果不如预期的情况。 当然了,这里全由我们编写的测试代码决定,我们总是认定测试代码本身没有 Bug,并且忠诚地落实了我们的测试意图。在测试结果的中间,显示的是被测代码包的导入路径。 而在最右边,展现的是此次对该代码包的测试所耗费的时间,这里显示的`0.008s`,即 8 毫秒。不过,当我们紧接着第二次运行这个命令的时候,输出的测试结果会略有不同,如下所示: 复制 $ go test puzzlers/article20/q2 ok puzzlers/article20/q2 (cached) 可以看到,结果最右边的不再是测试耗时,而是`(cached)`。这表明,由于测试代码与被测代码都没有任何变动,所以`go test`命令直接把之前缓存测试成功的结果打印出来了。 go 命令通常会缓存程序构建的结果,以便在将来的构建中重用。我们可以通过运行`go env GOCACHE`命令来查看缓存目录的路径。缓存的数据总是能够正确地反映出当时的各种源码文件、构建环境、编译器选项等等的真实情况。 一旦有任何变动,缓存数据就会失效,go 命令就会再次真正地执行操作。所以我们并不用担心打印出的缓存数据不是实时的结果。go 命令会定期地删除最近未使用的缓存数据,但是,如果你想手动删除所有的缓存数据,运行一下`go clean -cache`命令就好了。 对于测试成功的结果,go 命令也是会缓存的。运行`go clean -testcache`将会删除所有的测试结果缓存。不过,这样做肯定不会删除任何构建结果缓存。 > 此外,设置环境变量`GODEBUG`的值也可以稍稍地改变 go 命令的缓存行为。比如,设置值为`gocacheverify=1`将会导致 go 命令绕过任何的缓存数据,而真正地执行操作并重新生成所有结果,然后再去检查新的结果与现有的缓存数据是否一致。 总之,我们并不用在意缓存数据的存在,因为它们肯定不会妨碍`go test`命令打印正确的测试结果。 你可能会问,如果测试失败,命令打印的结果将会是怎样的?如果功能测试函数的那个唯一参数被命名为`t`,那么当我们在其中调用`t.Fail`方法时,虽然当前的测试函数会继续执行下去,但是结果会显示该测试失败。如下所示: 复制 $ go test puzzlers/article20/q2 --- FAIL: TestFail (0.00s) demo53_test.go:49: Failed. FAIL FAIL puzzlers/article20/q2 0.007s 我们运行的命令与之前是相同的,但是我新增了一个功能测试函数`TestFail`,并在其中调用了`t.Fail`方法。测试结果显示,对被测代码包的测试,由于`TestFail`函数的测试失败而宣告失败。 注意,对于失败测试的结果,`go test`命令并不会进行缓存,所以,这种情况下的每次测试都会产生全新的结果。另外,如果测试失败了,那么`go test`命令将会导致:失败的测试函数中的常规测试日志一并被打印出来。 在这里的测试结果中,之所以显示了“demo53\_test.go:49: Failed.”这一行,是因为我在`TestFail`函数中的调用表达式`t.Fail()`的下边编写了代码`t.Log("Failed.")`。 `t.Log`方法以及`t.Logf`方法的作用,就是打印常规的测试日志,只不过当测试成功的时候,`go test`命令就不会打印这类日志了。如果你想在测试结果中看到所有的常规测试日志,那么可以在运行`go test`命令的时候加入标记`-v`。 > 若我们想让某个测试函数在执行的过程中立即失败,则可以在该函数中调用`t.FailNow`方法。 > > 我在下面把`TestFail`函数中的`t.Fail()`改为`t.FailNow()`。 > > 与`t.Fail()`不同,在`t.FailNow()`执行之后,当前函数会立即终止执行。换句话说,该行代码之后的所有代码都会失去执行机会。在这样修改之后,我再次运行上面的命令,得到的结果如下: 复制 --- FAIL: TestFail (0.00s) FAIL FAIL puzzlers/article20/q2 0.008s > 显然,之前显示在结果中的常规测试日志并没有出现在这里。 顺便说一下,如果你想在测试失败的同时打印失败测试日志,那么可以直接调用`t.Error`方法或者`t.Errorf`方法。 前者相当于`t.Log`方法和`t.Fail`方法的连续调用,而后者也与之类似,只不过它相当于先调用了`t.Logf`方法。 除此之外,还有`t.Fatal`方法和`t.Fatalf`方法,它们的作用是在打印失败错误日志之后立即终止当前测试函数的执行并宣告测试失败。更具体地说,这相当于它们在最后都调用了`t.FailNow`方法。 好了,到此为止,你是不是已经会解读功能测试的测试结果了呢? #### [](https://jums.gitbook.io/36-lectures-on-golang/24-ce-shi-de-ji-ben-gui-ze-he-liu-cheng-xia#wen-ti-2-zen-yang-jie-shi-xing-neng-ce-shi-de-ce-shi-jie-guo) 问题 2:怎样解释性能测试的测试结果? 性能测试与功能测试的结果格式有很多相似的地方。我们在这里仅关注前者的特殊之处。请看下面的打印结果。 复制 $ go test -bench=. -run=^$ puzzlers/article20/q3 goos: darwin goarch: amd64 pkg: puzzlers/article20/q3 BenchmarkGetPrimes-8 500000 2314 ns/op PASS ok puzzlers/article20/q3 1.192s 我在运行`go test`命令的时候加了两个标记。第一个标记及其值为`-bench=.`,只有有了这个标记,命令才会进行性能测试。该标记的值`.`表明需要执行任意名称的性能测试函数,当然了,函数名称还是要符合 Go 程序测试的基本规则的。 第二个标记及其值是`-run=^$`,这个标记用于表明需要执行哪些功能测试函数,这同样也是以函数名称为依据的。该标记的值`^$`意味着:只执行名称为空的功能测试函数,换句话说,不执行任何功能测试函数。 你可能已经看出来了,这两个标记的值都是正则表达式。实际上,它们只能以正则表达式为值。此外,如果运行`go test`命令的时候不加`-run`标记,那么就会使它执行被测代码包中的所有功能测试函数。 再来看测试结果,重点说一下倒数第三行的内容。`BenchmarkGetPrimes-8`被称为单个性能测试的名称,它表示命令执行了性能测试函数`BenchmarkGetPrimes`,并且当时所用的最大 P 数量为`8`。 最大 P 数量相当于可以同时运行 goroutine 的逻辑 CPU 的最大个数。这里的逻辑 CPU,也可以被称为 CPU 核心,但它并不等同于计算机中真正的 CPU 核心,只是 Go 语言运行时系统内部的一个概念,代表着它同时运行 goroutine 的能力。 顺便说一句,一台计算机的 CPU 核心的个数,意味着它能在同一时刻执行多少条程序指令,代表着它并行处理程序指令的能力。 我们可以通过调用 `runtime.GOMAXPROCS`函数改变最大 P 数量,也可以在运行`go test`命令时,加入标记`-cpu`来设置一个最大 P 数量的列表,以供命令在多次测试时使用。 至于怎样使用这个标记,以及`go test`命令执行的测试流程,会因此做出怎样的改变,我们在下一篇文章中再讨论。 在性能测试名称右边的是,`go test`命令最后一次执行性能测试函数(即`BenchmarkGetPrimes`函数)的时候,被测函数(即`GetPrimes`函数)被执行的实际次数。这是什么意思呢? `go test`命令在执行性能测试函数的时候会给它一个正整数,若该测试函数的唯一参数的名称为`b`,则该正整数就由`b.N`代表。我们应该在测试函数中配合着编写代码,比如: 复制 for i := 0; i < b.N; i++ { GetPrimes(1000) } 我在一个会迭代`b.N`次的循环中调用了`GetPrimes`函数,并给予它参数值`1000`。`go test`命令会先尝试把`b.N`设置为`1`,然后执行测试函数。 如果测试函数的执行时间没有超过上限,此上限默认为 1 秒,那么命令就会改大`b.N`的值,然后再次执行测试函数,如此往复,直到这个时间大于或等于上限为止。 当某次执行的时间大于或等于上限时,我们就说这是命令此次对该测试函数的最后一次执行。这时的`b.N`的值就会被包含在测试结果中,也就是上述测试结果中的`500000`。 我们可以简称该值为执行次数,但要注意,它指的是被测函数的执行次数,而不是性能测试函数的执行次数。 最后再看这个执行次数的右边,`2314 ns/op`表明单次执行`GetPrimes`函数的平均耗时为`2314`纳秒。这其实就是通过将最后一次执行测试函数时的执行时间,除以(被测函数的)执行次数而得出的。 以上这些,就是对默认情况下的性能测试结果的基本解读。你看明白了吗? ### [](https://jums.gitbook.io/36-lectures-on-golang/24-ce-shi-de-ji-ben-gui-ze-he-liu-cheng-xia#zong-jie) 总结 注意,对于功能测试和性能测试,命令执行测试流程的方式会有些不同。另外一个重要的问题是,我们在与`go test`命令交互时,怎样解读它提供给我们的信息。只有解读正确,你才能知道测试的成功与否,失败的具体原因以及严重程度等等。 除此之外,对于性能测试,你还需要关注命令输出的计算资源使用提示,以及各种性能度量。 这两篇的文章中,我们一起学习了不少东西,但是其实还不够。我们只是探讨了`go test`命令以及`testing`包的基本使用方式。 在下一篇,我们还会讨论更高级的内容。这将涉及`go test`命令的各种标记、`testing`包的更多 API,以及更复杂的测试结果。 ### [](https://jums.gitbook.io/36-lectures-on-golang/24-ce-shi-de-ji-ben-gui-ze-he-liu-cheng-xia#si-kao-ti) 思考题 在编写示例测试函数的时候,我们怎样指定预期的打印内容? [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页23 | 测试的基本规则和流程 (上)](https://jums.gitbook.io/36-lectures-on-golang/23-ce-shi-de-ji-ben-gui-ze-he-liu-cheng-shang) [下一页25 | 更多的测试手法](https://jums.gitbook.io/36-lectures-on-golang/25-geng-duo-de-ce-shi-shou-fa) 最后更新于2年前 这有帮助吗? --- # 点线网面,一起构建MySQL知识网络 | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252Fpd6sJe3vVnvTDGe0GRTm%252Fimage.png%3Falt%3Dmedia%26token%3D7b04e77c-7484-4f27-b40d-fab2e42e2cb6&width=768&dpr=4&quality=100&sign=ee2bae73&sv=2) 时光流逝,这是专栏的最后一篇文章。回顾整个过程,**如果用一个词来描述,就是“没料到”**: 我没料到文章这么难写,似乎每一篇文章都要用尽所学; 我没料到评论这么精彩,以致于我花在评论区的时间并不比正文少; 我没料到收获这么大,每一次被评论区的提问问到盲点,都会带着久违的兴奋去分析代码。 **如果让我自己评价这个专栏:** 我最满意的部分,是每一篇文章都带上了实践案例,也尽量讲清楚了原理; 我最得意的段落,是在讲事务隔离级别的时候,把文章重写到第三遍,终于能够写上“到这里,我们把一致性读、当前读和行锁就串起来了”; 我最开心的时候,是看到评论区有同学在回答课后思考题时,准确地用上了之前文章介绍的知识点。因为我理解的构建知识网络,就是这么从点到线,从线到网,从网到面的过程,很欣喜能跟大家一起走过这个过程。 当然,我更看重的还是你的评价。所以,当我看到你们在评论区和知乎说“好”的时候,就只会更细致地设计文章内容和课后思考题。 同时,我知道专栏的订阅用户中,有刚刚接触MySQL的新人,也有使用MySQL多年的同学。所以,我始终都在告诫自己,要尽量让大家都能有所收获。 在我的理解里,介绍数据库的文章需要有操作性,每一个操作有相应的原理,每一个原理背后又有它的原理,这是一个链条。能够讲清楚链条中的一个环节,就可能是一篇好文章。但是,每一层都有不同的受众。所以,我给这45篇文章定的目标就是:讲清楚操作和第一层的原理,并适当触及第二层原理。希望这样的设计不会让你觉得太浅。 有同学在问MySQL的学习路径,我在这里就和你谈谈我的理解。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/dian-xian-wang-mian-yi-qi-gou-jian-mysql-zhi-shi-wang-luo#id-1.-lu-jing-qian-wan-tiao-shi-jian-di-yi-tiao) 1\. 路径千万条,实践第一条 如果你问一个DBA“理解得最深刻的知识点”,他很可能告诉你是他踩得最深的那个坑。由此,“实践”的重要性可见一斑。 以前我带新人的时候,第一步就是要求他们手动搭建一套主备复制结构。并且,平时碰到问题的时候,我要求要动手复现。 从专栏评论区的留言可以看出来,有不少同学在跟着专栏中的案例做实验,我觉得这是个非常好的习惯,希望你能继续坚持下去。在阅读其他技术文章、图书的时候,也是同样的道理。如果你觉得自己理解了一个知识点,也一定要尝试设计一个例子来验证它。 同时,在设计案例的时候,我建议你也设计一个对照的反例,从而达到知识融汇贯通的目的。就像我在写这个专栏的过程中,就感觉自己也涨了不少知识,主要就得益于给文章设计案例的过程。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/dian-xian-wang-mian-yi-qi-gou-jian-mysql-zhi-shi-wang-luo#id-2.-yuan-li-shuo-bu-qing-shuang-shou-bai-fei-jin) 2\. 原理说不清,双手白费劲 不论是先实践再搞清楚原理去解释,还是先明白原理再通过实践去验证,都不失为一种好的学习方法,因人而异。但是,怎么证明自己是不是真的把原理弄清楚了呢?答案是说出来、写出来。 如果有人请教你某个知识点,那真是太好了,一定要跟他讲明白。不要觉得这是在浪费时间。因为这样做,一来可以帮你验证自己确实搞懂了这个知识点;二来可以提升自己的技术表达能力,毕竟你终究要面临和这样的三类人讲清楚原理的情况,即:老板、晋升答辩的评委、新工作的面试官。 我在带新人的时候,如果这一届的新人不止一个,就会让他们组成学习小组,并定期给他们出一个已经有确定答案的问题。大家分头去研究,之后在小组内进行讨论。如果你能碰到愿意跟你结成学习小组的同学,一定要好好珍惜。 而“写出来”又是一个更高的境界。因为,你在写的过程中,就会发现这个“明白”很可能只是一个假象。所以,在专栏下面写下自己对本章知识点的理解,也是一个不错的夯实学习成果的方法。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/dian-xian-wang-mian-yi-qi-gou-jian-mysql-zhi-shi-wang-luo#id-3.-zhi-shi-mei-ti-xi-zhuan-shen-jiu-wang-ji) 3\. 知识没体系,转身就忘记 把知识点“写下来”,还有一个好处,就是你会发现这个知识点的关联知识点。深究下去,点就连成线,然后再跟别的线找交叉。 比如,我们专栏里面讲到对临时表的操作不记录日志,然后你就可以给自己一个问题,这会不会导致备库同步出错?再比如,了解了临时表在不同的binlog格式下的行为,再追问一句,如果创建表的时候是statement格式,之后再修改为row格式(或者反之),会怎么样呢? 把这些都搞明白以后,你就能够把临时表、日志格式、同步机制,甚至于事务机制都连起来了。 相信你和我一样,在学习过程中最喜欢的就是这种交叉的瞬间。交叉多了,就形成了网络。而有了网络以后,吸收新知识的速度就很快了。 比如,如果你对事务隔离级别弄得很清楚了,在看到第45篇文章讲的max\_trx\_id超限会导致持续脏读的时候,相信你理解起来就很容易了。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/dian-xian-wang-mian-yi-qi-gou-jian-mysql-zhi-shi-wang-luo#id-4.-shou-ce-bu-quan-mian-an-li-sao-mang-dian) 4\. 手册补全面,案例扫盲点 有同学还问我,要不要一开始就看手册?我的建议是不要。看手册的时机,应该是你的知识网络构建得差不多的时候。 那你可能会问,什么时候算是差不多呢?其实,这没有一个固定的标准。但是,有一些基本实践可以帮你去做一个检验。 * 能否解释清楚错误日志(error log)、慢查询日志(slow log)中每一行的意思? * 能否快速评估出一个表结构或者一条SQL语句,设计得是否合理? * 能否通过explain的结果,来“脑补”整个执行过程(我们已经在专栏中练习几次了)? * 到网络上找MySQL的实践建议,对于每一条做一次分析: * 如果觉得不合理,能否给出自己的意见? * 如果觉得合理,能否给出自己的解释? 那,怎么判断自己的意见或者解释对不对呢?最快速、有效的途径,就是找有经验的人讨论。比如说,留言到我们专栏的相关文章的评论区,就是一个可行的方法。 这些实践做完后,你就应该对自己比较有信心了。这时候,你可以再去看手册,把知识网络中的盲点补全,进而形成面。而补全的方法就是前两点了,理论加实践。 我希望这45篇文章,可以在你构建MySQL知识体系的过程中,起到一个加速器的作用。 我特意安排在最后一篇文章,和你介绍MySQL里各种自增id达到定义的上限以后的不同行为。“45”就是我们这个专栏的id上限,而这一篇结束语,便是超过上限后的第一个值。这是一个未定义的值,由你来定义: * 有的同学可能会像表定义的自增id一样,就让它定格在这里; * 有的同学可能会像row\_id一样,二刷,然后用新的、更全面的理解去替代之前的理解; * 也许最多的情况是会像thread\_id一样,将已经彻底掌握的文章标记起来,专门刷那些之前看过、但是已经印象模糊的文章。 不论是哪一种策略,只要这45篇文章中,有那么几个知识点,像Xid或者InnoDB trx\_id一样,持久化到了你的知识网络里,你和我在这里花费的时间,就是“极客”的时间,就值了。 这是专栏的最后一篇文章的最后一句话,江湖再见。 [![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fa7%2F33%2Fa7455b773a63af5588e398ee792e8033.jpg&width=300&dpr=4&quality=100&sign=bdb94440&sv=2)](http://lixbr66veiw63rtj.mikecrm.com/7IfMxSe) [Previous45 自增id用完怎么办?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/45-zi-zeng-id-yong-wan-zen-mo-ban) [Next林晓斌:我的 MySQL 心路历程](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/lin-xiao-bin-wo-de-mysql-xin-lu-li-cheng) Last updated 2 years ago Was this helpful? --- # 42 grant之后要跟着flush privileges吗? | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252FVMZetbjfu9j0mlOBTZaf%252Fimage.png%3Falt%3Dmedia%26token%3Dd639bcfb-b85a-4750-a57a-75eb15487870&width=768&dpr=4&quality=100&sign=212dc524&sv=2) 在MySQL里面,grant语句是用来给用户赋权的。不知道你有没有见过一些操作文档里面提到,grant之后要马上跟着执行一个flush privileges命令,才能使赋权语句生效。我最开始使用MySQL的时候,就是照着一个操作文档的说明按照这个顺序操作的。 那么,grant之后真的需要执行flush privileges吗?如果没有执行这个flush命令的话,赋权语句真的不能生效吗? 接下来,我就先和你介绍一下grant语句和flush privileges语句分别做了什么事情,然后再一起来分析这个问题。 为了便于说明,我先创建一个用户: Copy create user 'ua'@'%' identified by 'pa'; 这条语句的逻辑是创建一个用户’ua’@’%’,密码是pa。注意,在MySQL里面,用户名(user)+地址(host)才表示一个用户,因此 ua@ip1 和 ua@ip2代表的是两个不同的用户。 这条命令做了两个动作: 1. 磁盘上,往mysql.user表里插入一行,由于没有指定权限,所以这行数据上所有表示权限的字段的值都是N; 2. 内存里,往数组acl\_users里插入一个acl\_user对象,这个对象的access字段值为0。 图1就是这个时刻用户ua在user表中的状态。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F7e%2F35%2F7e75bbfbca0cb932e1256941c99d5f35.png&width=768&dpr=4&quality=100&sign=d7dad948&sv=2) 图1 mysql.user 数据行 在MySQL中,用户权限是有不同的范围的。接下来,我就按照用户权限范围从大到小的顺序依次和你说明。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/42-grant-zhi-hou-yao-gen-zhuo-flush-privileges-ma#quan-ju-quan-xian) 全局权限 全局权限,作用于整个MySQL实例,这些权限信息保存在mysql库的user表里。如果我要给用户ua赋一个最高权限的话,语句是这么写的: Copy grant all privileges on *.* to 'ua'@'%' with grant option; 这个grant命令做了两个动作: 1. 磁盘上,将mysql.user表里,用户’ua’@’%'这一行的所有表示权限的字段的值都修改为‘Y’; 2. 内存里,从数组acl\_users中找到这个用户对应的对象,将access值(权限位)修改为二进制的“全1”。 在这个grant命令执行完成后,如果有新的客户端使用用户名ua登录成功,MySQL会为新连接维护一个线程对象,然后从acl\_users数组里查到这个用户的权限,并将权限值拷贝到这个线程对象中。之后在这个连接中执行的语句,所有关于全局权限的判断,都直接使用线程对象内部保存的权限位。 基于上面的分析我们可以知道: 1. grant 命令对于全局权限,同时更新了磁盘和内存。命令完成后即时生效,接下来新创建的连接会使用新的权限。 2. 对于一个已经存在的连接,它的全局权限不受grant命令的影响。 需要说明的是,**一般在生产环境上要合理控制用户权限的范围**。我们上面用到的这个grant语句就是一个典型的错误示范。如果一个用户有所有权限,一般就不应该设置为所有IP地址都可以访问。 如果要回收上面的grant语句赋予的权限,你可以使用下面这条命令: Copy revoke all privileges on *.* from 'ua'@'%'; 这条revoke命令的用法与grant类似,做了如下两个动作: 1. 磁盘上,将mysql.user表里,用户’ua’@’%'这一行的所有表示权限的字段的值都修改为“N”; 2. 内存里,从数组acl\_users中找到这个用户对应的对象,将access的值修改为0。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/42-grant-zhi-hou-yao-gen-zhuo-flush-privileges-ma#db-quan-xian) db权限 除了全局权限,MySQL也支持库级别的权限定义。如果要让用户ua拥有库db1的所有权限,可以执行下面这条命令: Copy grant all privileges on db1.* to 'ua'@'%' with grant option; 基于库的权限记录保存在mysql.db表中,在内存里则保存在数组acl\_dbs中。这条grant命令做了如下两个动作: 1. 磁盘上,往mysql.db表中插入了一行记录,所有权限位字段设置为“Y”; 2. 内存里,增加一个对象到数组acl\_dbs中,这个对象的权限位为“全1”。 图2就是这个时刻用户ua在db表中的状态。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F32%2F2e%2F32cd61ee14ad2f370e1de0fb4e39bb2e.png&width=768&dpr=4&quality=100&sign=855a3976&sv=2) 图2 mysql.db 数据行 每次需要判断一个用户对一个数据库读写权限的时候,都需要遍历一次acl\_dbs数组,根据user、host和db找到匹配的对象,然后根据对象的权限位来判断。 也就是说,grant修改db权限的时候,是同时对磁盘和内存生效的。 grant操作对于已经存在的连接的影响,在全局权限和基于db的权限效果是不同的。接下来,我们做一个对照试验来分别看一下。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fae%2Fc7%2Faea26807c8895961b666a5d96b081ac7.png&width=768&dpr=4&quality=100&sign=cd648f66&sv=2) 图3 权限操作效果 需要说明的是,图中set global sync\_binlog这个操作是需要super权限的。 可以看到,虽然用户ua的super权限在T3时刻已经通过revoke语句回收了,但是在T4时刻执行set global的时候,权限验证还是通过了。这是因为super是全局权限,这个权限信息在线程对象中,而revoke操作影响不到这个线程对象。 而在T5时刻去掉ua对db1库的所有权限后,在T6时刻session B再操作db1库的表,就会报错“权限不足”。这是因为acl\_dbs是一个全局数组,所有线程判断db权限都用这个数组,这样revoke操作马上就会影响到session B。 这里在代码实现上有一个特别的逻辑,如果当前会话已经处于某一个db里面,之前use这个库的时候拿到的库权限会保存在会话变量中。 你可以看到在T6时刻,session C和session B对表t的操作逻辑是一样的。但是session B报错,而session C可以执行成功。这是因为session C在T2 时刻执行的use db1,拿到了这个库的权限,在切换出db1库之前,session C对这个库就一直有权限。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/42-grant-zhi-hou-yao-gen-zhuo-flush-privileges-ma#biao-quan-xian-he-lie-quan-xian) 表权限和列权限 除了db级别的权限外,MySQL支持更细粒度的表权限和列权限。其中,表权限定义存放在表mysql.tables\_priv中,列权限定义存放在表mysql.columns\_priv中。这两类权限,组合起来存放在内存的hash结构column\_priv\_hash中。 这两类权限的赋权命令如下: Copy create table db1.t1(id int, a int); grant all privileges on db1.t1 to 'ua'@'%' with grant option; GRANT SELECT(id), INSERT (id,a) ON mydb.mytbl TO 'ua'@'%' with grant option; 跟db权限类似,这两个权限每次grant的时候都会修改数据表,也会同步修改内存中的hash结构。因此,对这两类权限的操作,也会马上影响到已经存在的连接。 看到这里,你一定会问,看来grant语句都是即时生效的,那这么看应该就不需要执行flush privileges语句了呀。 答案也确实是这样的。 flush privileges命令会清空acl\_users数组,然后从mysql.user表中读取数据重新加载,重新构造一个acl\_users数组。也就是说,以数据表中的数据为准,会将全局权限内存数组重新加载一遍。 同样地,对于db权限、表权限和列权限,MySQL也做了这样的处理。 也就是说,如果内存的权限数据和磁盘数据表相同的话,不需要执行flush privileges。而如果我们都是用grant/revoke语句来执行的话,内存和数据表本来就是保持同步更新的。 **因此,正常情况下,grant命令之后,没有必要跟着执行flush privileges命令。** ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/42-grant-zhi-hou-yao-gen-zhuo-flush-privileges-ma#flush-privileges-shi-yong-chang-jing) flush privileges使用场景 那么,flush privileges是在什么时候使用呢?显然,当数据表中的权限数据跟内存中的权限数据不一致的时候,flush privileges语句可以用来重建内存数据,达到一致状态。 这种不一致往往是由不规范的操作导致的,比如直接用DML语句操作系统权限表。我们来看一下下面这个场景: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F90%2Fec%2F9031814361be42b7bc084ad2ab2aa3ec.png&width=768&dpr=4&quality=100&sign=d0b66e0b&sv=2) 图4 使用flush privileges 可以看到,T3时刻虽然已经用delete语句删除了用户ua,但是在T4时刻,仍然可以用ua连接成功。原因就是,这时候内存中acl\_users数组中还有这个用户,因此系统判断时认为用户还正常存在。 在T5时刻执行过flush命令后,内存更新,T6时刻再要用ua来登录的话,就会报错“无法访问”了。 直接操作系统表是不规范的操作,这个不一致状态也会导致一些更“诡异”的现象发生。比如,前面这个通过delete语句删除用户的例子,就会出现下面的情况: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fdd%2Ff1%2Fdd625b6b4eb2dcbdaac73648a1af50f1.png&width=768&dpr=4&quality=100&sign=5b5c092a&sv=2) 图5 不规范权限操作导致的异常 可以看到,由于在T3时刻直接删除了数据表的记录,而内存的数据还存在。这就导致了: 1. T4时刻给用户ua赋权限失败,因为mysql.user表中找不到这行记录; 2. 而T5时刻要重新创建这个用户也不行,因为在做内存判断的时候,会认为这个用户还存在。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/42-grant-zhi-hou-yao-gen-zhuo-flush-privileges-ma#xiao-jie) 小结 今天这篇文章,我和你介绍了MySQL用户权限在数据表和内存中的存在形式,以及grant和revoke命令的执行逻辑。 grant语句会同时修改数据表和内存,判断权限的时候使用的是内存数据。因此,规范地使用grant和revoke语句,是不需要随后加上flush privileges语句的。 flush privileges语句本身会用数据表的数据重建一份内存权限数据,所以在权限数据可能存在不一致的情况下再使用。而这种不一致往往是由于直接用DML语句操作系统权限表导致的,所以我们尽量不要使用这类语句。 另外,在使用grant语句赋权时,你可能还会看到这样的写法: Copy grant super on *.* to 'ua'@'%' identified by 'pa'; 这条命令加了identified by ‘密码’, 语句的逻辑里面除了赋权外,还包含了: 1. 如果用户’ua’@’%'不存在,就创建这个用户,密码是pa; 2. 如果用户ua已经存在,就将密码修改成pa。 这也是一种不建议的写法,因为这种写法很容易就会不慎把密码给改了。 “grant之后随手加flush privileges”,我自己是这么使用了两三年之后,在看代码的时候才发现其实并不需要这样做,那已经是2011年的事情了。 去年我看到一位小伙伴这么操作的时候,指出这个问题时,他也觉得很神奇。因为,他和我一样看的第一份文档就是这么写的,自己也一直是这么用的。 所以,今天的课后问题是,请你也来说一说,在使用数据库或者写代码的过程中,有没有遇到过类似的场景:误用了很长时间以后,由于一个契机发现“啊,原来我错了这么久”? 你可以把你的经历写在留言区,我会在下一篇文章的末尾选取有趣的评论和你分享。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/42-grant-zhi-hou-yao-gen-zhuo-flush-privileges-ma#shang-qi-wen-ti-shi-jian) 上期问题时间 上期的问题是,MySQL解析statement格式的binlog的时候,对于load data命令,解析出来为什么用的是load data local。 这样做的一个原因是,为了确保备库应用binlog正常。因为备库可能配置了secure\_file\_priv=null,所以如果不用local的话,可能会导入失败,造成主备同步延迟。 另一种应用场景是使用mysqlbinlog工具解析binlog文件,并应用到目标库的情况。你可以使用下面这条命令 : Copy mysqlbinlog $binlog_file | mysql -h$host -P$port -u$user -p$pwd 把日志直接解析出来发给目标库执行。增加local,就能让这个方法支持非本地的$host。 评论区留言点赞板: > @poppy 、@库淘淘 两位同学提到了第一个场景; @王显伟 @lionetes 两位同学帮忙回答了 @undifined 同学的疑问,拷贝出来的文件要确保MySQL进程可以读。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F09%2F77%2F09c1073f99cf71d2fb162a716b5fa577.jpg&width=768&dpr=4&quality=100&sign=e5471b04&sv=2) [Previous41 怎么最快地复制一张表?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/41-zen-mo-zui-kuai-di-fu-zhi-yi-zhang-biao) [Next43 要不要使用分区表?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/43-yao-bu-yao-shi-yong-fen-qu-biao) Last updated 2 years ago Was this helpful? --- # 02 | 命令源码文件 | Go语言核心36讲 #### [](https://jums.gitbook.io/36-lectures-on-golang/02-ming-ling-yuan-ma-wen-jian#go-yu-yan-dai-ma-jiao-duo-jian-yi-pei-he-wen-zhang-shou-ting-yin-pin) 【Go 语言代码较多,建议配合文章收听音频。】 [8MB\ \ 2.命令源码文件.mp3](https://2852705636-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FkmtyDvkosJZE1Rr33XGZ%2Fuploads%2F812Osowcq0iPnBx0f7bW%2F2.%E5%91%BD%E4%BB%A4%E6%BA%90%E7%A0%81%E6%96%87%E4%BB%B6.mp3?alt=media&token=f88c2ab7-788b-4622-afe6-3acd58c1d06a) 音频 我们已经知道,环境变量 GOPATH 指向的是一个或多个工作区,每个工作区中都会有以代码包为基本组织形式的源码文件。 **这里的源码文件又分为三种,即:命令源码文件、库源码文件和测试源码文件,它们都有着不同的用途和编写规则。** 我在[“预习篇”的基础知识图](https://time.geekbang.org/column/article/13540?utm_source=weibo&utm_medium=xuxiaoping&utm_campaign=promotion&utm_content=columns) 介绍过这三种文件的基本情况。今天,我们就沿着**命令源码文件**的知识点,展开更深层级的学习。 * * * 一旦开始学习用编程语言编写程序,我们就一定希望在编码的过程中及时地得到反馈,只有这样才能清楚对错。实际上,我们的有效学习和进步,都是通过不断地接受反馈和执行修正实现的。 对于 Go 语言学习者来说,你在学习阶段中,也一定会经常编写可以直接运行的程序。这样的程序肯定会涉及命令源码文件的编写,而且,命令源码文件也可以很方便地用`go run`命令启动。 那么,**我今天的问题就是:命令源码文件的用途是什么,怎样编写它?** 这里,我给出你一个**参考的回答**:命令源码文件是程序的运行入口,是每个可独立运行的程序必须拥有的。我们可以通过构建或安装,生成与其对应的可执行文件,后者一般会与该命令源码文件的直接父目录同名。 **如果一个源码文件声明属于**`**main**`**包,并且包含一个无参数声明且无结果声明的**`**main**`**函数,那么它就是命令源码文件。** 就像下面这段代码: 复制 package main import "fmt" func main() { fmt.Println("Hello, world!") } 如果你把这段代码存成 demo1.go 文件,那么运行`go run demo1.go`命令后就会在屏幕(标准输出)中看到`Hello, world!` > 当需要模块化编程时,我们往往会将代码拆分到多个文件,甚至拆分到不同的代码包中。但无论怎样,对于一个独立的程序来说,命令源码文件永远只会也只能有一个。如果有与命令源码文件同包的源码文件,那么它们也应该声明属于`main`包。 ### [](https://jums.gitbook.io/36-lectures-on-golang/02-ming-ling-yuan-ma-wen-jian#wen-ti-jie-xi) 问题解析 命令源码文件如此重要,以至于它毫无疑问地成为了我们学习 Go 语言的第一助手。不过,只会打印`Hello, world`是远远不够的,咱们千万不要成为“Hello, world”党。既然决定学习 Go 语言,你就应该从每一个知识点深入下去。 无论是 Linux 还是 Windows,如果你用过命令行(command line)的话,肯定就会知道几乎所有命令(command)都是可以接收参数(argument)的。通过构建或安装命令源码文件,生成的可执行文件就可以被视为“命令”,既然是命令,那么就应该具备接收参数的能力。 下面,我就带你深入了解一下与命令参数的接收和解析有关的一系列问题。 ### [](https://jums.gitbook.io/36-lectures-on-golang/02-ming-ling-yuan-ma-wen-jian#zhi-shi-jing-jiang) 知识精讲 #### [](https://jums.gitbook.io/36-lectures-on-golang/02-ming-ling-yuan-ma-wen-jian#id-1.-ming-ling-yuan-ma-wen-jian-zen-yang-jie-shou-can-shu) 1\. 命令源码文件怎样接收参数 我们先看一段不完整的代码: 复制 package main import ( // 需在此处添加代码。[1] "fmt" ) var name string func init() { // 需在此处添加代码。[2] } func main() { // 需在此处添加代码。[3] fmt.Printf("Hello, %s!\n", name) } 如果邀请你帮助我,在注释处添加相应的代码,并让程序实现”根据运行程序时给定的参数问候某人”的功能,你会打算怎样做? 如果你知道做法,请现在就动手实现它。如果不知道也不要着急,咱们一起来搞定。 首先,Go 语言标准库中有一个代码包专门用于接收和解析命令参数。这个代码包的名字叫`flag`。 我之前说过,如果想要在代码中使用某个包中的程序实体,那么应该先导入这个包。因此,我们需要在`[1]`处添加代码`"flag"`。注意,这里应该在代码包导入路径的前后加上英文半角的引号。如此一来,上述代码导入了`flag`和`fmt`这两个包。 其次,人名肯定是由字符串代表的。所以我们要在`[2]`处添加调用`flag`包的`StringVar`函数的代码。就像这样: 复制 flag.StringVar(&name, "name", "everyone", "The greeting object.") 函数`flag.StringVar`接受 4 个参数。第 1 个参数是用于存储该命令参数的值的地址,具体到这里就是在前面声明的变量`name`的地址了,由表达式`&name`表示。 第 2 个参数是为了指定该命令参数的名称,这里是`name`。第 3 个参数是为了指定在未追加该命令参数时的默认值,这里是`everyone`。 至于第 4 个函数参数,即是该命令参数的简短说明了,这在打印命令说明时会用到。 顺便说一下,还有一个与`flag.StringVar`函数类似的函数,叫`flag.String`。这两个函数的区别是,后者会直接返回一个已经分配好的用于存储命令参数值的地址。如果使用它的话,我们就需要把 复制 var name string 改为 复制 var name = flag.String("name", "everyone", "The greeting object.") 所以,如果我们使用`flag.String`函数就需要改动原有的代码。这样并不符合上述问题的要求。 再说最后一个填空。我们需要在`[3]`处添加代码`flag.Parse()`。函数`flag.Parse`用于真正解析命令参数,并把它们的值赋给相应的变量。 对该函数的调用必须在所有命令参数存储载体的声明(这里是对变量`name`的声明)和设置(这里是在`[2]`处对`flag.StringVar`函数的调用)之后,并且在读取任何命令参数值之前进行。 正因为如此,我们最好把`flag.Parse()`放在`main`函数的函数体的第一行。 #### [](https://jums.gitbook.io/36-lectures-on-golang/02-ming-ling-yuan-ma-wen-jian#id-2.-zen-yang-zai-yun-xing-ming-ling-yuan-ma-wen-jian-de-shi-hou-chuan-ru-can-shu-you-zen-yang-cha-kan) 2\. 怎样在运行命令源码文件的时候传入参数,又怎样查看参数的使用说明 如果我们把上述代码存成名为 demo2.go 的文件,那么运行如下命令就可以为参数`name`传值: 复制 go run demo2.go -name="Robert" 运行后,打印到标准输出(stdout)的内容会是: 复制 Hello, Robert! 另外,如果想查看该命令源码文件的参数说明,可以这样做: 复制 $ go run demo2.go --help 其中的 `MY_ZUES_CHAR` `go run` 复制 Usage of /var/folders/ts/7lg_tl_x2gd_k1lm5g_48c7w0000gn/T/go-build155438482/b001/exe/demo2:  -name string     The greeting object. (default "everyone") exit status 2 你可能不明白下面这段输出代码的意思。 复制 /var/folders/ts/7lg_tl_x2gd_k1lm5g_48c7w0000gn/T/go-build155438482/b001/exe/demo2 这其实是`go run`命令构建上述命令源码文件时临时生成的可执行文件的完整路径。 如果我们先构建这个命令源码文件再运行生成的可执行文件,像这样: 复制 $ go build demo2.go $ ./demo2 --help 那么输出就会是 复制 Usage of ./demo2:  -name string     The greeting object. (default "everyone") #### [](https://jums.gitbook.io/36-lectures-on-golang/02-ming-ling-yuan-ma-wen-jian#id-3.-zen-yang-zi-ding-yi-ming-ling-yuan-ma-wen-jian-de-can-shu-shi-yong-shuo-ming) 3\. 怎样自定义命令源码文件的参数使用说明 这有很多种方式,最简单的一种方式就是对变量`flag.Usage`重新赋值。`flag.Usage`的类型是`func()`,即一种无参数声明且无结果声明的函数类型。 `flag.Usage`变量在声明时就已经被赋值了,所以我们才能够在运行命令`go run demo2.go --help`时看到正确的结果。 注意,对`flag.Usage`的赋值必须在调用`flag.Parse`函数之前。 现在,我们把 demo2.go 另存为 demo3.go,然后在`main`函数体的开始处加入如下代码。 复制 flag.Usage = func() {  fmt.Fprintf(os.Stderr, "Usage of %s:\n", "question")  flag.PrintDefaults() } 那么当运行 复制 $ go run demo3.go --help 后,就会看到 复制 Usage of question:  -name string     The greeting object. (default "everyone") exit status 2 现在再深入一层,我们在调用`flag`包中的一些函数(比如`StringVar`、`Parse`等等)的时候,实际上是在调用`flag.CommandLine`变量的对应方法。 `flag.CommandLine`相当于默认情况下的命令参数容器。所以,通过对`flag.CommandLine`重新赋值,我们可以更深层次地定制当前命令源码文件的参数使用说明。 现在我们把`main`函数体中的那条对`flag.Usage`变量的赋值语句注销掉,然后在`init`函数体的开始处添加如下代码: 复制 flag.CommandLine = flag.NewFlagSet("", flag.ExitOnError) flag.CommandLine.Usage = func() { fmt.Fprintf(os.Stderr, "Usage of %s:\n", "question") flag.PrintDefaults() } 再运行命令`go run demo3.go --help`后,其输出会与上一次的输出的一致。不过后面这种定制的方法更加灵活。比如,当我们把为`flag.CommandLine`赋值的那条语句改为 复制 flag.CommandLine = flag.NewFlagSet("", flag.PanicOnError) 后,再运行`go run demo3.go --help`命令就会产生另一种输出效果。这是由于我们在这里传给`flag.NewFlagSet`函数的第二个参数值是`flag.PanicOnError`。`flag.PanicOnError`和`flag.ExitOnError`都是预定义在`flag`包中的常量。 `flag.ExitOnError`的含义是,告诉命令参数容器,当命令后跟`--help`或者参数设置的不正确的时候,在打印命令参数使用说明后以状态码`2`结束当前程序。 状态码`2`代表用户错误地使用了命令,而`flag.PanicOnError`与之的区别是在最后抛出“运行时恐慌(panic)”。 上述两种情况都会在我们调用`flag.Parse`函数时被触发。顺便提一句,“运行时恐慌”是 Go 程序错误处理方面的概念。关于它的抛出和恢复方法,我在本专栏的后续部分中会讲到。 下面再进一步,我们索性不用全局的`flag.CommandLine`变量,转而自己创建一个私有的命令参数容器。我们在函数外再添加一个变量声明: 复制 var cmdLine = flag.NewFlagSet("question", flag.ExitOnError) 然后,我们把对`flag.StringVar`的调用替换为对`cmdLine.StringVar`调用,再把`flag.Parse()`替换为`cmdLine.Parse(os.Args[1:])`。 其中的`os.Args[1:]`指的就是我们给定的那些命令参数。这样做就完全脱离了`flag.CommandLine`。`*flag.FlagSet`类型的变量`cmdLine`拥有很多有意思的方法。你可以去探索一下。我就不在这里一一讲述了。 这样做的好处依然是更灵活地定制命令参数容器。但更重要的是,你的定制完全不会影响到那个全局变量`flag.CommandLine`。 **总结** 恭喜你!你现在已经走出了 Go 语言编程的第一步。你可以用 Go 编写命令,并可以让它们像众多操作系统命令那样被使用,甚至可以把它们嵌入到各种脚本中。 虽然我为你讲解了命令源码文件的基本编写方法,并且也谈到了为了让它接受参数而需要做的各种准备工作,但这并不是全部。 别担心,我在后面会经常提到它的。另外,如果你想详细了解`flag`包的用法,可以到[这个网址](https://golang.google.cn/pkg/flag/) 查看文档。或者直接使用`godoc`命令在本地启动一个 Go 语言文档服务器。怎样使用`godoc`命令?你可以参看[这里](https://github.com/hyper0x/go_command_tutorial/blob/master/0.5.md) 。 ### [](https://jums.gitbook.io/36-lectures-on-golang/02-ming-ling-yuan-ma-wen-jian#si-kao-ti) 思考题 我们已经见识过为命令源码文件传入字符串类型的参数值的方法,那还可以传入别的吗?这就是今天我留下的思考题。 1. 默认情况下,我们可以让命令源码文件接受哪些类型的参数值? 2. 我们可以把自定义的数据类型作为参数值的类型吗?如果可以,怎样做? 你可以通过查阅文档获得第一个问题的答案。记住,快速查看和理解文档是一项必备的技能。 至于第二个问题,你回答起来可能会有些困难,因为这涉及了另一个问题:“怎样声明自己的数据类型?”这个问题我在专栏的后续部分中也会讲到。如果是这样,我希望你记下它和这里说的另一问题,并在能解决后者之后再来回答前者。 [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页01 | 工作区和GOPATH](https://jums.gitbook.io/36-lectures-on-golang/01-gong-zuo-qu-he-gopath) [下一页03 | 库源码文件](https://jums.gitbook.io/36-lectures-on-golang/03-ku-yuan-ma-wen-jian) 最后更新于2年前 这有帮助吗? --- # 05 | 程序实体的那些事儿(中) | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252FITWntt8kxMZUVmXXQwjS%252Fimage.png%3Falt%3Dmedia%26token%3Dfa8207d1-94d8-40d0-9d96-700b9467ff7c&width=768&dpr=4&quality=100&sign=9cce6adb&sv=2) #### [](https://jums.gitbook.io/36-lectures-on-golang/05-cheng-xu-shi-ti-de-na-xie-shi-er-zhong#go-yu-yan-dai-ma-jiao-duo-jian-yi-pei-he-wen-zhang-shou-ting-yin-pin) 【Go 语言代码较多,建议配合文章收听音频。】 [8MB\ \ 5.程序实体的那些事儿(中).mp3](https://2852705636-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FkmtyDvkosJZE1Rr33XGZ%2Fuploads%2F0bMhrOL2fcnVj85cqmIH%2F5.%E7%A8%8B%E5%BA%8F%E5%AE%9E%E4%BD%93%E7%9A%84%E9%82%A3%E4%BA%9B%E4%BA%8B%E5%84%BF%EF%BC%88%E4%B8%AD%EF%BC%89.mp3?alt=media&token=a9aa8bc7-8dc6-4427-a21a-11ea4bcef784) 在前文中,我解释过代码块的含义。Go 语言的代码块是一层套一层的,就像大圆套小圆。一个代码块可以有若干个子代码块;但对于每个代码块,最多只会有一个直接包含它的代码块(后者可以简称为前者的外层代码块)。 这种代码块的划分,也间接地决定了程序实体的作用域。我们今天就来看看它们之间的关系。 先说说作用域是什么呢?我们都知道,一个程序实体被创造出来大都是为了让别的代码引用的。那么,哪里的代码可以引用它?这就是在讨论它的作用域。 我在前面说过,程序实体的访问权限有三种:包级私有的、模块级私有的和公开的。这其实就是 Go 语言在语言层面,依据代码块对程序实体的作用域进行的定义。 包级私有和模块级私有访问权限对应的都是代码包代码块,公开的访问权限对应的是全域代码块。然而,这个颗粒度是比较粗的,我们往往需要利用代码块再细化程序实体的作用域。 比如,我在一个函数中声明了一个变量,那么在通常情况下,这个变量是无法被这个函数以外的代码引用的。这个函数就是一个代码块,而这个变量的作用域被限制在了该代码块中。当然了,还有例外的情况,这部分内容,我留到讲函数的时候再说。 总之,请记住,**一个程序实体的作用域总是会被限制在某个代码块中,而这个作用域最大的用处,就是对程序实体的访问权限的控制。**对“高内聚,低耦合”这种程序设计思想的实践恰恰可以从这里开始。 你应该可以通过下面的问题进一步感受到代码块和作用域的魅力。 **今天的问题是:如果一个变量与其外层代码块中的变量重名会出现什么状况?** 我把此题的代码存到了 demo10.go 文件中了。你可以在“Golang\_Puzzlers”项目的`puzzlers/article5/q1`包中找到它。 复制 package main import "fmt" var block = "package" func main() { block := "function" { block := "inner" fmt.Printf("The block is %s.\n", block) } fmt.Printf("The block is %s.\n", block) } 这个命令源码文件中有四个代码块,它们是:全域代码块、`main`包代表的代码块、`main`函数代表的代码块,以及在`main`函数中的一个用花括号包起来的代码块。 我在后三个代码块中分别声明了一个名为`block`的变量,并分别把字符串值`"package"`、`"function"`和`"inner"`赋给了它们。此外,我在后两个代码块的最后分别尝试用`fmt.Printf`函数打印出“The block is %s.”。这里的“%s”只是为了占位,程序会用`block`变量的实际值替换掉。 具体的问题是:该源码文件中的代码能通过编译吗?如果不能,原因是什么?如果能,运行它后会打印出什么内容? ### [](https://jums.gitbook.io/36-lectures-on-golang/05-cheng-xu-shi-ti-de-na-xie-shi-er-zhong#dian-xing-hui-da) 典型回答 能通过编译。运行后打印出的内容是: 复制 The block is inner. The block is function. ### [](https://jums.gitbook.io/36-lectures-on-golang/05-cheng-xu-shi-ti-de-na-xie-shi-er-zhong#wen-ti-jie-xi) 问题解析 初看这道题,你可能会认为它无法通过编译,因为三处代码都声明了相同名称的变量。的确,声明重名的变量是无法通过编译的,用短变量声明对已有变量进行重声明除外,但这只是对于同一个代码块而言的。 对于不同的代码块来说,其中的变量重名没什么大不了,照样可以通过编译。即使这些代码块有直接的嵌套关系也是如此,就像 demo10.go 中的`main`包代码块、`main`函数代码块和那个最内层的代码块那样。 这样规定显然很方便也很合理,否则我们会每天为了选择变量名而烦恼。但是这会导致另外一个问题,我引用变量时到底用的是哪一个?这也是这道题的第二个考点。 这其实有一个很有画面感的查找过程。这个查找过程不只针对于变量,还适用于任何程序实体。如下面所示。 * 首先,代码引用变量的时候总会最优先查找当前代码块中的那个变量。注意,这里的“当前代码块”仅仅是引用变量的代码所在的那个代码块,并不包含任何子代码块。 * 其次,如果当前代码块中没有声明以此为名的变量,那么程序会沿着代码块的嵌套关系,从直接包含当前代码块的那个代码块开始,一层一层地查找。 * 一般情况下,程序会一直查到当前代码包代表的代码块。如果仍然找不到,那么 Go 语言的编译器就会报错了。 还记得吗?如果我们在当前源码文件中导入了其他代码包,那么引用其中的程序实体时,是需要以限定符为前缀的。所以程序在找代表变量未加限定符的名字(即标识符)的时候,是不会去被导入的代码包中查找的。 > 但有个特殊情况,如果我们把代码包导入语句写成`import . XXX`的形式(注意中间的那个“.”),那么就会让这个“XXX”包中公开的程序实体,被当前源码文件中的代码,视为当前代码包中的程序实体。 > > 比如,如果有代码包导入语句`import . fmt`,那么我们在当前源码文件中引用`fmt.Printf`函数的时候直接用`Printf`就可以了。在这个特殊情况下,程序在查找当前源码文件后会先去查用这种方式导入的那些代码包。 好了,当你明白了上述过程之后,再去看 demo10.go 中的代码。是不是感觉清晰了很多? 从作用域的角度也可以说,虽然通过`var block = "package"`声明的变量作用域是整个`main`代码包,但是在`main`函数中,它却被那两个同名的变量“屏蔽”了。 相似的,虽然`main`函数首先声明的`block`的作用域,是整个`main`函数,但是在最内层的那个代码块中,它却是不可能被引用到的。反过来讲,最内层代码块中的`block`也不可能被该块之外的代码引用到,这也是打印内容的第二行是“The block is function.”的另一半原因。 你现在应该知道了,这道题看似简单,但是它考察以及可延展的范围并不窄。 ### [](https://jums.gitbook.io/36-lectures-on-golang/05-cheng-xu-shi-ti-de-na-xie-shi-er-zhong#zhi-shi-kuo-zhan) 知识扩展 **不同代码块中的重名变量与变量重声明中的变量区别到底在哪儿?** 为了方便描述,我就把前者叫做“可重名变量”吧。注意,在同一个代码块中不允许出现重名的变量,这违背了 Go 语言的语法。关于这两者的表象和机理,我们已经讨论得足够充分了。你现在可以说出几条区别?请想一想,然后再看下面的列表。 1. 变量重声明中的变量一定是在某一个代码块内的。注意,这里的“某一个代码块内”并不包含它的任何子代码块,否则就变成了“多个代码块之间”。而可重名变量指的正是在多个代码块之间由相同的标识符代表的变量。 2. 变量重声明是对同一个变量的多次声明,这里的变量只有一个。而可重名变量中涉及的变量肯定是有多个的。 3. 不论对变量重声明多少次,其类型必须始终一致,具体遵从它第一次被声明时给定的类型。而可重名变量之间不存在类似的限制,它们的类型可以是任意的。 4. 如果可重名变量所在的代码块之间存在直接或间接的嵌套关系,那么它们之间一定会存在“屏蔽”的现象。但是这种现象绝对不会在变量重声明的场景下出现。 当然了,我们之前谈论过,对变量进行重声明还有一些前提条件,不过在这里并不是重点。我就不再赘述了。 以上 4 大区别中的第 3 条需要你再注意一下。既然可重名变量的类型可以是任意的,那么当它们之间存在“屏蔽”时你就更需要注意了。 不同类型的值大都有着不同的特性和用法。当你在某一种类型的值上施加只有在其他类型值上才能做的操作时,Go 语言编译器一定会告诉你:“这不可以”。 这种情况很好,甚至值得庆幸,因为你的程序存在的问题被提前发现了。如若不然,程序没准儿会在运行过程中由此引发很隐晦的问题,让你摸不着头脑。相比之下,那时候排查问题的成本可就太高了。所以,我们应该尽量利用 Go 语言的语法、规范和命令来约束我们的程序。 具体到不同类型的可重名变量的问题上,让我们先来看一下`puzzlers/article5/q2`包中的源码文件 demo11.go。它是一个很典型的例子。 复制 package main gogo import "fmt" var container = []string{"zero", "one", "two"} func main() { container := map[int]string{0: "zero", 1: "one", 2: "two"} fmt.Printf("The element is %q.\n", container[1]) } 在 demo11.go 中,有两个都叫做`container`的变量,分别位于`main`包代码块和`main`函数代码块。`main`包代码块中的变量是切片(slice)类型的,另一个是字典(map)类型的。在`main`函数的最后,我试图打印出`container`变量的值中索引为`1`的那个元素。 如果你熟悉这两个类型肯定会知道,在它们的值上我们都可以施加索引表达式,比如`container[0]`。只要中括号里的整数在有效范围之内(这里是 \[0, 2\]),它就可以把值中的某一个元素取出来。 如果`container`的类型不是数组、切片或字典类型,那么索引表达式就会引发编译错误。这正是利用 Go 语言语法,帮我们约束程序的一个例子;但是当我们想知道 container 确切类型的时候,利用索引表达式的方式就不够了。 当可重名变量的值被转换成某个接口类型值,或者它们的类型本身就是接口类型的时候,严格的类型检查就很有必要了。至于怎么检查,我们在下篇文章中再讨论。 ### [](https://jums.gitbook.io/36-lectures-on-golang/05-cheng-xu-shi-ti-de-na-xie-shi-er-zhong#zong-jie) 总结 我们先讨论了代码块,并且也谈到了它与程序实体的作用域,以及访问权限控制之间的巧妙关系。Go 语言本身对程序实体提供了相对粗粒度的访问控制。但我们自己可以利用代码块和作用域精细化控制它们。 如果在具有嵌套关系的不同代码块中存在重名的变量,那么我们应该特别小心,它们之间可能会发生“屏蔽”的现象。这样你在不同代码块中引用到变量很可能是不同的。具体的鉴别方式需要参考 Go 语言查找(代表了程序实体的)标识符的过程。 另外,请记住变量重声明与可重名变量之间的区别以及它们的重要特征。其中最容易产生隐晦问题的一点是,可重名变量可以各有各的类型。这时候我们往往应该在真正使用它们之前先对其类型进行检查。利用 Go 语言的语法、规范和命令做辅助的检查是很好的办法,但有些时候并不充分。 ### [](https://jums.gitbook.io/36-lectures-on-golang/05-cheng-xu-shi-ti-de-na-xie-shi-er-zhong#si-kao-ti) 思考题 我们在讨论 Go 语言查找标识符时的范围的时候,提到过`import . XXX`这种导入代码包的方式。这里有个思考题: 如果通过这种方式导入的代码包中的变量与当前代码包中的变量重名了,那么 Go 语言是会把它们当做“可重名变量”看待还是会报错呢? 其实我们写个例子一试便知,但重点是为什么?请你尝试从代码块和作用域的角度解释试验得到的答案。 [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页04 | 程序实体的那些事儿(上)](https://jums.gitbook.io/36-lectures-on-golang/04-cheng-xu-shi-ti-de-na-xie-shi-er-shang) [下一页06 | 程序实体的那些事儿 (下)](https://jums.gitbook.io/36-lectures-on-golang/06-cheng-xu-shi-ti-de-na-xie-shi-er-xia) 最后更新于2年前 这有帮助吗? --- # 43 要不要使用分区表? | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252FFeRwvf2VU29J2orSJOXU%252Fimage.png%3Falt%3Dmedia%26token%3D99e1c7e0-613c-4180-ad3b-3965dc79daad&width=768&dpr=4&quality=100&sign=a86c0be&sv=2) 我经常被问到这样一个问题:分区表有什么问题,为什么公司规范不让使用分区表呢?今天,我们就来聊聊分区表的使用行为,然后再一起回答这个问题。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/43-yao-bu-yao-shi-yong-fen-qu-biao#fen-qu-biao-shi-shen-me) 分区表是什么? 为了说明分区表的组织形式,我先创建一个表t: Copy CREATE TABLE `t` ( `ftime` datetime NOT NULL, `c` int(11) DEFAULT NULL, KEY (`ftime`) ) ENGINE=InnoDB DEFAULT CHARSET=latin1 PARTITION BY RANGE (YEAR(ftime)) (PARTITION p_2017 VALUES LESS THAN (2017) ENGINE = InnoDB, PARTITION p_2018 VALUES LESS THAN (2018) ENGINE = InnoDB, PARTITION p_2019 VALUES LESS THAN (2019) ENGINE = InnoDB, PARTITION p_others VALUES LESS THAN MAXVALUE ENGINE = InnoDB); insert into t values('2017-4-1',1),('2018-4-1',1); ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F06%2Ff5%2F06f041129783533de9c75580f9decdf5.png&width=768&dpr=4&quality=100&sign=4c3161ed&sv=2) 图1 表t的磁盘文件 我在表t中初始化插入了两行记录,按照定义的分区规则,这两行记录分别落在p\_2018和p\_2019这两个分区上。 可以看到,这个表包含了一个.frm文件和4个.ibd文件,每个分区对应一个.ibd文件。也就是说: * 对于引擎层来说,这是4个表; * 对于Server层来说,这是1个表。 你可能会觉得这两句都是废话。其实不然,这两句话非常重要,可以帮我们理解分区表的执行逻辑。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/43-yao-bu-yao-shi-yong-fen-qu-biao#fen-qu-biao-de-yin-qing-ceng-xing-wei) 分区表的引擎层行为 我先给你举个在分区表加间隙锁的例子,目的是说明对于InnoDB来说,这是4个表。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fd2%2Fc7%2Fd28d6ab873bd8337d88812d45b9266c7.png&width=768&dpr=4&quality=100&sign=770e9977&sv=2) 图2 分区表间隙锁示例 这里顺便复习一下,我在[第21篇文章](https://time.geekbang.org/column/article/75659) 和你介绍的间隙锁加锁规则。 我们初始化表t的时候,只插入了两行数据, ftime的值分别是,‘2017-4-1’ 和’2018-4-1’ 。session A的select语句对索引ftime上这两个记录之间的间隙加了锁。如果是一个普通表的话,那么T1时刻,在表t的ftime索引上,间隙和加锁状态应该是图3这样的。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F27%2Fd2%2F273c9ca869f5b52621641d73eb6f72d2.jpg&width=768&dpr=4&quality=100&sign=b8d6ea3e&sv=2) 图3 普通表的加锁范围 也就是说,‘2017-4-1’ 和’2018-4-1’ 这两个记录之间的间隙是会被锁住的。那么,sesion B的两条插入语句应该都要进入锁等待状态。 但是,从上面的实验效果可以看出,session B的第一个insert语句是可以执行成功的。这是因为,对于引擎来说,p\_2018和p\_2019是两个不同的表,也就是说2017-4-1的下一个记录并不是2018-4-1,而是p\_2018分区的supremum。所以T1时刻,在表t的ftime索引上,间隙和加锁的状态其实是图4这样的: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F92%2F5c%2F92f63aba0b24adefac7316c75463b95c.jpg&width=768&dpr=4&quality=100&sign=966c044b&sv=2) 图4 分区表t的加锁范围 由于分区表的规则,session A的select语句其实只操作了分区p\_2018,因此加锁范围就是图4中深绿色的部分。 所以,session B要写入一行ftime是2018-2-1的时候是可以成功的,而要写入2017-12-1这个记录,就要等session A的间隙锁。 图5就是这时候的show engine innodb status的部分结果。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fe3%2F0f%2Fe3d83d9ba89de9a6f541c9a2f24a3b0f.png&width=768&dpr=4&quality=100&sign=7a92e610&sv=2) 图5 session B被锁住信息 看完InnoDB引擎的例子,我们再来一个MyISAM分区表的例子。 我首先用alter table t engine=myisam,把表t改成MyISAM表;然后,我再用下面这个例子说明,对于MyISAM引擎来说,这是4个表。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F94%2F76%2F941306d4a7193455dcf1cfebf7678876.png&width=768&dpr=4&quality=100&sign=76ae4481&sv=2) 图6 用MyISAM表锁验证 在session A里面,我用sleep(100)将这条语句的执行时间设置为100秒。由于MyISAM引擎只支持表锁,所以这条update语句会锁住整个表t上的读。 但我们看到的结果是,session B的第一条查询语句是可以正常执行的,第二条语句才进入锁等待状态。 这正是因为MyISAM的表锁是在引擎层实现的,session A加的表锁,其实是锁在分区p\_2018上。因此,只会堵住在这个分区上执行的查询,落到其他分区的查询是不受影响的。 看到这里,你可能会说,分区表看来还不错嘛,为什么不让用呢?我们使用分区表的一个重要原因就是单表过大。那么,如果不使用分区表的话,我们就是要使用手动分表的方式。 接下来,我们一起看看手动分表和分区表有什么区别。 比如,按照年份来划分,我们就分别创建普通表t\_2017、t\_2018、t\_2019等等。手工分表的逻辑,也是找到需要更新的所有分表,然后依次执行更新。在性能上,这和分区表并没有实质的差别。 分区表和手工分表,一个是由server层来决定使用哪个分区,一个是由应用层代码来决定使用哪个分表。因此,从引擎层看,这两种方式也是没有差别的。 其实这两个方案的区别,主要是在server层上。从server层看,我们就不得不提到分区表一个被广为诟病的问题:打开表的行为。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/43-yao-bu-yao-shi-yong-fen-qu-biao#fen-qu-ce-le) 分区策略 每当第一次访问一个分区表的时候,MySQL需要把所有的分区都访问一遍。**一个典型的报错情况**是这样的:如果一个分区表的分区很多,比如超过了1000个,而MySQL启动的时候,open\_files\_limit参数使用的是默认值1024,那么就会在访问这个表的时候,由于需要打开所有的文件,导致打开表文件的个数超过了上限而报错。 下图就是我创建的一个包含了很多分区的表t\_myisam,执行一条插入语句后报错的情况。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fab%2Fe7%2Fabfa0054ec43d97fb18ba3c1c8829ae7.png&width=768&dpr=4&quality=100&sign=6e09f8f7&sv=2) 图 7 insert 语句报错 可以看到,这条insert语句,明显只需要访问一个分区,但语句却无法执行。 这时,你一定从表名猜到了,这个表我用的是MyISAM引擎。是的,因为使用InnoDB引擎的话,并不会出现这个问题。 MyISAM分区表使用的分区策略,我们称为**通用分区策略**(generic partitioning),每次访问分区都由server层控制。通用分区策略,是MySQL一开始支持分区表的时候就存在的代码,在文件管理、表管理的实现上很粗糙,因此有比较严重的性能问题。 从MySQL 5.7.9开始,InnoDB引擎引入了**本地分区策略**(native partitioning)。这个策略是在InnoDB内部自己管理打开分区的行为。 MySQL从5.7.17开始,将MyISAM分区表标记为即将弃用(deprecated),意思是“从这个版本开始不建议这么使用,请使用替代方案。在将来的版本中会废弃这个功能”。 从MySQL 8.0版本开始,就不允许创建MyISAM分区表了,只允许创建已经实现了本地分区策略的引擎。目前来看,只有InnoDB和NDB这两个引擎支持了本地分区策略。 接下来,我们再看一下分区表在server层的行为。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/43-yao-bu-yao-shi-yong-fen-qu-biao#fen-qu-biao-de-server-ceng-xing-wei) 分区表的server层行为 如果从server层看的话,一个分区表就只是一个表。 这句话是什么意思呢?接下来,我就用下面这个例子来和你说明。如图8和图9所示,分别是这个例子的操作序列和执行结果图。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F0e%2F81%2F0eca5a3190161e59ea58493915bd5e81.png&width=768&dpr=4&quality=100&sign=371b4c9a&sv=2) 图8 分区表的MDL锁 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Faf%2Fa8%2Fafe662f5e051a2ceb96a87624a589aa8.png&width=768&dpr=4&quality=100&sign=64a160f9&sv=2) 图9 show processlist结果 可以看到,虽然session B只需要操作p\_2107这个分区,但是由于session A持有整个表t的MDL锁,就导致了session B的alter语句被堵住。 这也是DBA同学经常说的,分区表,在做DDL的时候,影响会更大。如果你使用的是普通分表,那么当你在truncate一个分表的时候,肯定不会跟另外一个分表上的查询语句,出现MDL锁冲突。 到这里我们小结一下: 1. MySQL在第一次打开分区表的时候,需要访问所有的分区; 2. 在server层,认为这是同一张表,因此所有分区共用同一个MDL锁; 3. 在引擎层,认为这是不同的表,因此MDL锁之后的执行过程,会根据分区表规则,只访问必要的分区。 而关于“必要的分区”的判断,就是根据SQL语句中的where条件,结合分区规则来实现的。比如我们上面的例子中,where ftime=‘2018-4-1’,根据分区规则year函数算出来的值是2018,那么就会落在p\_2019这个分区。 但是,如果这个where 条件改成 where ftime>=‘2018-4-1’,虽然查询结果相同,但是这时候根据where条件,就要访问p\_2019和p\_others这两个分区。 如果查询语句的where条件中没有分区key,那就只能访问所有分区了。当然,这并不是分区表的问题。即使是使用业务分表的方式,where条件中没有使用分表的key,也必须访问所有的分表。 我们已经理解了分区表的概念,那么什么场景下适合使用分区表呢? ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/43-yao-bu-yao-shi-yong-fen-qu-biao#fen-qu-biao-de-ying-yong-chang-jing) 分区表的应用场景 分区表的一个显而易见的优势是对业务透明,相对于用户分表来说,使用分区表的业务代码更简洁。还有,分区表可以很方便的清理历史数据。 如果一项业务跑的时间足够长,往往就会有根据时间删除历史数据的需求。这时候,按照时间分区的分区表,就可以直接通过alter table t drop partition …这个语法删掉分区,从而删掉过期的历史数据。 这个alter table t drop partition …操作是直接删除分区文件,效果跟drop普通表类似。与使用delete语句删除数据相比,优势是速度快、对系统影响小。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/43-yao-bu-yao-shi-yong-fen-qu-biao#xiao-jie) 小结 这篇文章,我主要和你介绍的是server层和引擎层对分区表的处理方式。我希望通过这些介绍,你能够对是否选择使用分区表,有更清晰的想法。 需要注意的是,我是以范围分区(range)为例和你介绍的。实际上,MySQL还支持hash分区、list分区等分区方法。你可以在需要用到的时候,再翻翻[手册](https://dev.mysql.com/doc/refman/8.0/en/partitioning-types.html) 。 实际使用时,分区表跟用户分表比起来,有两个绕不开的问题:一个是第一次访问的时候需要访问所有分区,另一个是共用MDL锁。 因此,如果要使用分区表,就不要创建太多的分区。我见过一个用户做了按天分区策略,然后预先创建了10年的分区。这种情况下,访问分区表的性能自然是不好的。这里有两个问题需要注意: 1. 分区并不是越细越好。实际上,单表或者单分区的数据一千万行,只要没有特别大的索引,对于现在的硬件能力来说都已经是小表了。 2. 分区也不要提前预留太多,在使用之前预先创建即可。比如,如果是按月分区,每年年底时再把下一年度的12个新分区创建上即可。对于没有数据的历史分区,要及时的drop掉。 至于分区表的其他问题,比如查询需要跨多个分区取数据,查询性能就会比较慢,基本上就不是分区表本身的问题,而是数据量的问题或者说是使用方式的问题了。 当然,如果你的团队已经维护了成熟的分库分表中间件,用业务分表,对业务开发同学没有额外的复杂性,对DBA也更直观,自然是更好的。 最后,我给你留下一个思考题吧。 我们举例的表中没有用到自增主键,假设现在要创建一个自增字段id。MySQL要求分区表中的主键必须包含分区字段。如果要在表t的基础上做修改,你会怎么定义这个表的主键呢?为什么这么定义呢? 你可以把你的结论和分析写在留言区,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/43-yao-bu-yao-shi-yong-fen-qu-biao#shang-qi-wen-ti-shi-jian) 上期问题时间 上篇文章后面还不够多,可能很多同学还没来记得看吧,我们就等后续有更多留言的时候,再补充本期的“上期问题时间”吧。 @夹心面包 提到了在grant的时候是支持通配符的:"\_"表示一个任意字符,“%”表示任意字符串。这个技巧在一个分库分表方案里面,同一个分库上有多个db的时候,是挺方便的。不过我个人认为,权限赋值的时候,控制的精确性还是要优先考虑的。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F09%2F77%2F09c1073f99cf71d2fb162a716b5fa577.jpg&width=768&dpr=4&quality=100&sign=e5471b04&sv=2) [Previous42 grant之后要跟着flush privileges吗?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/42-grant-zhi-hou-yao-gen-zhuo-flush-privileges-ma) [Next44 答疑文章(三):说一说这些好问题](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/44-da-yi-wen-zhang-san-shuo-yi-shuo-zhe-xie-hao-wen-ti) Last updated 2 years ago Was this helpful? --- # 03 | 库源码文件 | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252FLoUWZOtqhS70yqARFDPG%252Fimage.png%3Falt%3Dmedia%26token%3Db64a852d-cc97-437b-beb1-b74089be6fe9&width=768&dpr=4&quality=100&sign=38f8b251&sv=2) #### [](https://jums.gitbook.io/36-lectures-on-golang/03-ku-yuan-ma-wen-jian#go-yu-yan-dai-ma-jiao-duo-jian-yi-pei-he-wen-zhang-shou-ting-yin-pin) 【Go 语言代码较多,建议配合文章收听音频。】 你已经使用过 Go 语言编写了小命令(或者说微型程序)吗? 当你在编写“Hello, world”的时候,一个源码文件就足够了,虽然这种小玩意儿没什么用,最多能给你一点点莫名的成就感。如果你对这一点点并不满足,别着急,跟着学,我肯定你也可以写出很厉害的程序。 * * * 我们在上一篇的文章中学到了命令源码文件的相关知识,那么除了命令源码文件,你还能用 Go 语言编写库源码文件。那么什么是库源码文件呢? 在我的定义中,**库源码文件是不能被直接运行的源码文件,它仅用于存放程序实体,这些程序实体可以被其他代码使用(只要遵从 Go 语言规范的话)。** 这里的“其他代码”可以与被使用的程序实体在同一个源码文件内,也可以在其他源码文件,甚至其他代码包中。 > 那么程序实体是什么呢?在 Go 语言中,程序实体是变量、常量、函数、结构体和接口的统称。 > > 我们总是会先声明(或者说定义)程序实体,然后再去使用。比如在上一篇的例子中,我们先定义了变量`name`,然后在`main`函数中调用`fmt.Printf`函数的时候用到了它。 > > 再多说一点,程序实体的名字被统称为标识符。标识符可以是任何 Unicode 编码可以表示的字母字符、数字以及下划线“\_”,但是其首字母不能是数字。 > > 从规则上说,我们可以用中文作为变量的名字。但是,我觉得这种命名方式非常不好,自己也会在开发团队中明令禁止这种做法。作为一名合格的程序员,我们应该向着编写国际水准的程序无限逼近。 回到正题。 我们今天的**问题是:怎样把命令源码文件中的代码拆分到其他库源码文件?** 我们用代码演示,把这个问题说得更具体一些。 如果在某个目录下有一个命令源码文件 demo4.go,如下: 复制 package main import ( "flag" ) var name string func init() { flag.StringVar(&name, "name", "everyone", "The greeting object.") } func main() { flag.Parse() hello(name) } 其中的代码你应该比较眼熟了。我在讲命令源码文件的时候贴过很相似的代码,那个源码文件名为 demo2.go。 这两个文件的不同之处在于,demo2.go 直接通过调用`fmt.Printf`函数打印问候语,而当前的 demo4.go 在同样位置调用了一个叫作`hello`的函数。 函数`hello`被声明在了另外一个源码文件中,我把它命名为 demo4\_lib.go,并且放在与 demo4.go 相同的目录下。如下: 复制 // 需在此处添加代码。[1] import "fmt" func hello(name string) { fmt.Printf("Hello, %s!\n", name) } 那么问题来了:注释 1 处应该填入什么代码? ### [](https://jums.gitbook.io/36-lectures-on-golang/03-ku-yuan-ma-wen-jian#dian-xing-hui-da) **典型回答** 答案很简单,填入代码包声明语句`package main`。为什么?我之前说过,在同一个目录下的源码文件都需要被声明为属于同一个代码包。 如果该目录下有一个命令源码文件,那么为了让同在一个目录下的文件都通过编译,其他源码文件应该也声明属于`main`包。 如此一来,我们就可以运行它们了。比如,我们可以在这些文件所在的目录下运行如下命令并得到相应的结果。 复制 $ go run demo4.go demo4_lib.go  Hello, everyone! 或者,像下面这样先构建当前的代码包再运行。 复制 $ go build puzzlers/article3/q1 $ ./q1             Hello, everyone! 在这里,我把 demo4.go 和 demo4\_lib.go 都放在了一个相对路径为`puzzlers/article3/q1`的目录中。 在默认情况下,相应的代码包的导入路径会与此一致。我们可以通过代码包的导入路径引用其中声明的程序实体。但是,这里的情况是不同的。 注意,demo4.go 和 demo4\_lib.go 都声明自己属于`main`包。我在前面讲 Go 语言源码的组织方式的时候提到过这种用法,即:源码文件声明的包名可以与其所在目录的名称不同,只要这些文件声明的包名一致就可以。 顺便说一下,我为本专栏创建了一个名为“Golang\_Puzzlers”的项目。该项目的 src 子目录下会存有我们涉及的所有代码和相关文件。 也就是说,正确的用法是,你需要把该项目的打包文件下载到本地的任意目录下,然后经解压缩后把“Golang\_Puzzlers”目录加入到环境变量`GOPATH`中。还记得吗?这会使“Golang\_Puzzlers”目录成为工作区之一。 ### [](https://jums.gitbook.io/36-lectures-on-golang/03-ku-yuan-ma-wen-jian#wen-ti-jie-xi) **问题解析** 这个问题考察的是代码包声明的基本规则。这里再总结一下。 第一条规则,同目录下的源码文件的代码包声明语句要一致。也就是说,它们要同属于一个代码包。这对于所有源码文件都是适用的。 如果目录中有命令源码文件,那么其他种类的源码文件也应该声明属于`main`包。这也是我们能够成功构建和运行它们的前提。 第二条规则,源码文件声明的代码包的名称可以与其所在的目录的名称不同。在针对代码包进行构建时,生成的结果文件的主名称与其父目录的名称一致。 对于命令源码文件而言,构建生成的可执行文件的主名称会与其父目录的名称相同,这在我前面的回答中也验证过了。 好了,经过我的反复强调,相信你已经记住这些规则了。下面的内容也将会与它们相关。 在编写真正的程序时,我们仅仅把代码拆分到几个源码文件中是不够的。我们往往会用模块化编程的方式,根据代码的功能和用途把它们放置到不同的代码包中。不过,这又会牵扯进一些 Go 语言的代码组织规则。我们一起来往下看。 ### [](https://jums.gitbook.io/36-lectures-on-golang/03-ku-yuan-ma-wen-jian#zhi-shi-jing-jiang) **知识精讲** #### [](https://jums.gitbook.io/36-lectures-on-golang/03-ku-yuan-ma-wen-jian#id-1.-zen-yang-ba-ming-ling-yuan-ma-wen-jian-zhong-de-dai-ma-chai-fen-dao-qi-ta-dai-ma-bao) 1\. 怎样把命令源码文件中的代码拆分到其他代码包? 我们先不用关注拆分代码的技巧。我在这里仍然依从前面的拆分方法。我把 demo4.go 另存为 demo5.go,并放到一个相对路径为`puzzlers/article3/q2`的目录中。 然后我再创建一个相对路径为`puzzlers/article3/q2/lib`的目录,再把 demo4\_lib.go 复制一份并改名为 demo5\_lib.go 放到该目录中。 现在,为了让它们通过编译,我们应该怎样修改代码?你可以先思考一下。我在这里给出一部分答案,我们一起来看看已经过修改的 demo5\_lib.go 文件。 复制 package lib5 import "fmt" func Hello(name string) { fmt.Printf("Hello, %s!\n", name) } 可以看到,我在这里修改了两个地方。第一个改动是,我把代码包声明语句由`package main`改为了`package lib5`。注意,我故意让声明的包名与其所在的目录的名称不同。第二个改动是,我把全小写的函数名`hello`改为首字母大写的`Hello`。 基于以上改动,我们再来看下面的几个问题。 #### [](https://jums.gitbook.io/36-lectures-on-golang/03-ku-yuan-ma-wen-jian#id-2.-dai-ma-bao-de-dao-ru-lu-jing-zong-hui-yu-qi-suo-zai-mu-lu-de-xiang-dui-lu-jing-yi-zhi-ma) **2\. 代码包的导入路径总会与其所在目录的相对路径一致吗?** 库源码文件 demo5\_lib.go 所在目录的相对路径是`puzzlers/article3/q2/lib`,而它却声明自己属于`lib5`包。在这种情况下,该包的导入路径是`puzzlers/article3/q2/lib`,还是`puzzlers/article3/q2/lib5`? 这个问题往往会让 Go 语言的初学者们困惑,就算是用 Go 开发过程序的人也不一定清楚。我们一起来看看。 首先,我们在构建或者安装这个代码包的时候,提供给`go`命令的路径应该是目录的相对路径,就像这样: 复制 go install puzzlers/article3/q2/lib  该命令会成功完成。之后,当前工作区的 pkg 子目录下会产生相应的归档文件,具体的相对路径是: 复制 pkg/darwin_amd64/puzzlers/article3/q2/lib.a 其中的`darwin_amd64`就是我在讲工作区时提到的平台相关目录。可以看到,这里与源码文件所在目录的相对路径是对应的。 为了进一步说明问题,我需要先对 demo5.go 做两个改动。第一个改动是,在以`import`为前导的代码包导入语句中加入`puzzlers/article3/q2/lib`,也就是试图导入这个代码包。 第二个改动是,把对`hello`函数的调用改为对`lib.Hello`函数的调用。其中的`lib.`叫做限定符,旨在指明右边的程序实体所在的代码包。不过这里与代码包导入路径的完整写法不同,只包含了路径中的最后一级`lib`,这与代码包声明语句中的规则一致。 现在,我们可以通过运行`go run demo5.go`命令试一试。错误提示会类似于下面这种。 复制 ./demo5.go:5:2: imported and not used: "puzzlers/article3/q2/lib" as lib5 ./demo5.go:16:2: undefined: lib 第一个错误提示的意思是,我们导入了`puzzlers/article3/q2/lib`包,但没有实际使用其中的任何程序实体。这在 Go 语言中是不被允许的,在编译时就会导致失败。 注意,这里还有另外一个线索,那就是“as lib5”。这说明虽然导入了代码包`puzzlers/article3/q2/lib`,但是使用其中的程序实体的时候应该以`lib5.`为限定符。这也就是第二个错误提示的原因了。Go 命令找不到`lib.`这个限定符对应的代码包。 为什么会是这样?根本原因就是,我们在源码文件中声明所属的代码包与其所在目录的名称不同。请记住,源码文件所在的目录相对于 src 目录的相对路径就是它的代码包导入路径,而实际使用其程序实体时给定的限定符要与它声明所属的代码包名称对应。 有两个方式可以使上述构建成功完成。我在这里选择把 demo5\_lib.go 文件中的代码包声明语句改为`package lib`。理由是,为了不让该代码包的使用者产生困惑,我们总是应该让声明的包名与其父目录的名称一致。 #### [](https://jums.gitbook.io/36-lectures-on-golang/03-ku-yuan-ma-wen-jian#id-3.-shen-me-yang-de-cheng-xu-shi-ti-cai-ke-yi-bei-dang-qian-bao-wai-de-dai-ma-yin-yong) **3\. 什么样的程序实体才可以被当前包外的代码引用?** 你可能会有疑问,我为什么要把 demo5\_lib.go 文件中的那个函数名称`hello`的首字母大写?实际上这涉及了 Go 语言中对于程序实体访问权限的规则。 超级简单,名称的首字母为大写的程序实体才可以被当前包外的代码引用,否则它就只能被当前包内的其他代码引用。 通过名称,Go 语言自然地把程序实体的访问权限划分为了包级私有的和公开的。对于包级私有的程序实体,即使你导入了它所在的代码包也无法引用到它。 #### [](https://jums.gitbook.io/36-lectures-on-golang/03-ku-yuan-ma-wen-jian#id-4.-dui-yu-cheng-xu-shi-ti-hai-you-qi-ta-de-fang-wen-quan-xian-gui-ze-ma) **4\. 对于程序实体,还有其他的访问权限规则吗?** 答案是肯定的。在 Go 1.5 及后续版本中,我们可以通过创建`internal`代码包让一些程序实体仅仅能被当前模块中的其他代码引用。这被称为 Go 程序实体的第三种访问权限:模块级私有。 具体规则是,`internal`代码包中声明的公开程序实体仅能被该代码包的直接父包及其子包中的代码引用。当然,引用前需要先导入这个`internal`包。对于其他代码包,导入该`internal`包都是非法的,无法通过编译。 “Golang\_Puzzlers”项目的`puzzlers/article3/q4`包中有一个简单的示例,可供你查看。你可以改动其中的代码并体会`internal`包的作用。 ### [](https://jums.gitbook.io/36-lectures-on-golang/03-ku-yuan-ma-wen-jian#zong-jie) **总结** 我们在本篇文章中详细讨论了把代码从命令源码文件中拆分出来的方法,这包括拆分到其他库源码文件,以及拆分到其他代码包。 这里涉及了几条重要的 Go 语言基本编码规则,即:代码包声明规则、代码包导入规则以及程序实体的访问权限规则。在进行模块化编程时,你必须记住这些规则,否则你的代码很可能无法通过编译。 ### [](https://jums.gitbook.io/36-lectures-on-golang/03-ku-yuan-ma-wen-jian#si-kao-ti) **思考题** 这次的思考题都是关于代码包导入的,如下。 1. 如果你需要导入两个代码包,而这两个代码包的导入路径的最后一级是相同的,比如:`dep/lib/flag`和`flag`,那么会产生冲突吗? 2. 如果会产生冲突,那么怎样解决这种冲突,有几种方式? 第一个问题比较简单,你一试便知。强烈建议你编写个例子,然后运行`go`命令构建它,并看看会有什么样的提示。 而第二个问题涉及了代码包导入语句的高级写法,你可能需要去查阅一下 Go 语言规范。不过也不难。你最多能想出几种解决办法呢?你可以给我留言,我们一起讨论。 [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页02 | 命令源码文件](https://jums.gitbook.io/36-lectures-on-golang/02-ming-ling-yuan-ma-wen-jian) [下一页04 | 程序实体的那些事儿(上)](https://jums.gitbook.io/36-lectures-on-golang/04-cheng-xu-shi-ti-de-na-xie-shi-er-shang) 最后更新于2年前 这有帮助吗? --- # 36 为什么临时表可以重名? | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252FACyUcL1hfiIwZ1Xgz7bY%252Fimage.png%3Falt%3Dmedia%26token%3D3d31c50e-1cb2-46f1-9868-ec79cc10f611&width=768&dpr=4&quality=100&sign=6a733e0f&sv=2) 今天是大年三十,在开始我们今天的学习之前,我要先和你道一声春节快乐! 在上一篇文章中,我们在优化join查询的时候使用到了临时表。当时,我们是这么用的: Copy create temporary table temp_t like t1; alter table temp_t add index(b); insert into temp_t select * from t2 where b>=1 and b<=2000; select * from t1 join temp_t on (t1.b=temp_t.b); 你可能会有疑问,为什么要用临时表呢?直接用普通表是不是也可以呢? 今天我们就从这个问题说起:临时表有哪些特征,为什么它适合这个场景? 这里,我需要先帮你厘清一个容易误解的问题:有的人可能会认为,临时表就是内存表。但是,这两个概念可是完全不同的。 * 内存表,指的是使用Memory引擎的表,建表语法是create table … engine=memory。这种表的数据都保存在内存里,系统重启的时候会被清空,但是表结构还在。除了这两个特性看上去比较“奇怪”外,从其他的特征上看,它就是一个正常的表。 * 而临时表,可以使用各种引擎类型 。如果是使用InnoDB引擎或者MyISAM引擎的临时表,写数据的时候是写到磁盘上的。当然,临时表也可以使用Memory引擎。 弄清楚了内存表和临时表的区别以后,我们再来看看临时表有哪些特征。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/36-wei-shi-mo-lin-shi-biao-ke-yi-zhong-ming#lin-shi-biao-de-te-xing) 临时表的特性 为了便于理解,我们来看下下面这个操作序列: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F3c%2Fe3%2F3cbb2843ef9a84ee582330fb1bd0d6e3.png&width=768&dpr=4&quality=100&sign=139234a2&sv=2) 图1 临时表特性示例 可以看到,临时表在使用上有以下几个特点: 1. 建表语法是create temporary table …。 2. 一个临时表只能被创建它的session访问,对其他线程不可见。所以,图中session A创建的临时表t,对于session B就是不可见的。 3. 临时表可以与普通表同名。 4. session A内有同名的临时表和普通表的时候,show create语句,以及增删改查语句访问的是临时表。 5. show tables命令不显示临时表。 由于临时表只能被创建它的session访问,所以在这个session结束的时候,会自动删除临时表。也正是由于这个特性,**临时表就特别适合我们文章开头的join优化这种场景**。为什么呢? 原因主要包括以下两个方面: 1. 不同session的临时表是可以重名的,如果有多个session同时执行join优化,不需要担心表名重复导致建表失败的问题。 2. 不需要担心数据删除问题。如果使用普通表,在流程执行过程中客户端发生了异常断开,或者数据库发生异常重启,还需要专门来清理中间过程中生成的数据表。而临时表由于会自动回收,所以不需要这个额外的操作。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/36-wei-shi-mo-lin-shi-biao-ke-yi-zhong-ming#lin-shi-biao-de-ying-yong) 临时表的应用 由于不用担心线程之间的重名冲突,临时表经常会被用在复杂查询的优化过程中。其中,分库分表系统的跨库查询就是一个典型的使用场景。 一般分库分表的场景,就是要把一个逻辑上的大表分散到不同的数据库实例上。比如。将一个大表ht,按照字段f,拆分成1024个分表,然后分布到32个数据库实例上。如下图所示: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fdd%2F81%2Fddb9c43526dfd9b9a3e6f8c153478181.jpg&width=768&dpr=4&quality=100&sign=64eddeeb&sv=2) 图2 分库分表简图 一般情况下,这种分库分表系统都有一个中间层proxy。不过,也有一些方案会让客户端直接连接数据库,也就是没有proxy这一层。 在这个架构中,分区key的选择是以“减少跨库和跨表查询”为依据的。如果大部分的语句都会包含f的等值条件,那么就要用f做分区键。这样,在proxy这一层解析完SQL语句以后,就能确定将这条语句路由到哪个分表做查询。 比如下面这条语句: Copy select v from ht where f=N; 这时,我们就可以通过分表规则(比如,N%1024)来确认需要的数据被放在了哪个分表上。这种语句只需要访问一个分表,是分库分表方案最欢迎的语句形式了。 但是,如果这个表上还有另外一个索引k,并且查询语句是这样的: Copy select v from ht where k >= M order by t_modified desc limit 100; 这时候,由于查询条件里面没有用到分区字段f,只能到所有的分区中去查找满足条件的所有行,然后统一做order by 的操作。这种情况下,有两种比较常用的思路。 **第一种思路是,**在proxy层的进程代码中实现排序。 这种方式的优势是处理速度快,拿到分库的数据以后,直接在内存中参与计算。不过,这个方案的缺点也比较明显: 1. 需要的开发工作量比较大。我们举例的这条语句还算是比较简单的,如果涉及到复杂的操作,比如group by,甚至join这样的操作,对中间层的开发能力要求比较高; 2. 对proxy端的压力比较大,尤其是很容易出现内存不够用和CPU瓶颈的问题。 **另一种思路就是,**把各个分库拿到的数据,汇总到一个MySQL实例的一个表中,然后在这个汇总实例上做逻辑操作。 比如上面这条语句,执行流程可以类似这样: * 在汇总库上创建一个临时表temp\_ht,表里包含三个字段v、k、t\_modified; * 在各个分库上执行 Copy select v,k,t_modified from ht_x where k >= M order by t_modified desc limit 100; * 把分库执行的结果插入到temp\_ht表中; * 执行 Copy select v from temp_ht order by t_modified desc limit 100; 得到结果。 这个过程对应的流程图如下所示: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Ff5%2F0d%2Ff5ebe0f5af37deeb4d0b63d6fb11fc0d.jpg&width=768&dpr=4&quality=100&sign=e38c625e&sv=2) 图3 跨库查询流程示意图 **在实践中,我们往往会发现每个分库的计算量都不饱和,所以会直接把临时表temp\_ht放到32个分库中的某一个上。**这时的查询逻辑与图3类似,你可以自己再思考一下具体的流程。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/36-wei-shi-mo-lin-shi-biao-ke-yi-zhong-ming#wei-shen-me-lin-shi-biao-ke-yi-zhong-ming) 为什么临时表可以重名? 你可能会问,不同线程可以创建同名的临时表,这是怎么做到的呢? 接下来,我们就看一下这个问题。 我们在执行 Copy create temporary table temp_t(id int primary key)engine=innodb; 这个语句的时候,MySQL要给这个InnoDB表创建一个frm文件保存表结构定义,还要有地方保存表数据。 **这个frm文件放在临时文件目录下,文件名的后缀是.frm,前缀是“#sql{进程id}\_{线程id}\_序列号”**。你可以使用select @@tmpdir命令,来显示实例的临时文件目录。 而关于表中数据的存放方式,在不同的MySQL版本中有着不同的处理方式: * 在5.6以及之前的版本里,MySQL会在临时文件目录下创建一个相同前缀、以.ibd为后缀的文件,用来存放数据文件; * 而从 5.7版本开始,MySQL引入了一个临时文件表空间,专门用来存放临时文件的数据。因此,我们就不需要再创建ibd文件了。 从文件名的前缀规则,我们可以看到,其实创建一个叫作t1的InnoDB临时表,MySQL在存储上认为我们创建的表名跟普通表t1是不同的,因此同一个库下面已经有普通表t1的情况下,还是可以再创建一个临时表t1的。 为了便于后面讨论,我先来举一个例子。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F22%2F1b%2F22078eab5c7688c9fbfd6185555bd91b.png&width=768&dpr=4&quality=100&sign=b25be4ea&sv=2) 图4 临时表的表名 这个进程的进程号是1234,session A的线程id是4,session B的线程id是5。所以你看到了,session A和session B创建的临时表,在磁盘上的文件不会重名。 MySQL维护数据表,除了物理上要有文件外,内存里面也有一套机制区别不同的表,每个表都对应一个table\_def\_key。 * 一个普通表的table\_def\_key的值是由“库名+表名”得到的,所以如果你要在同一个库下创建两个同名的普通表,创建第二个表的过程中就会发现table\_def\_key已经存在了。 * 而对于临时表,table\_def\_key在“库名+表名”基础上,又加入了“server\_id+thread\_id”。 也就是说,session A和sessionB创建的两个临时表t1,它们的table\_def\_key不同,磁盘文件名也不同,因此可以并存。 在实现上,每个线程都维护了自己的临时表链表。这样每次session内操作表的时候,先遍历链表,检查是否有这个名字的临时表,如果有就优先操作临时表,如果没有再操作普通表;在session结束的时候,对链表里的每个临时表,执行 “DROP TEMPORARY TABLE +表名”操作。 这时候你会发现,binlog中也记录了DROP TEMPORARY TABLE这条命令。你一定会觉得奇怪,临时表只在线程内自己可以访问,为什么需要写到binlog里面? 这,就需要说到主备复制了。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/36-wei-shi-mo-lin-shi-biao-ke-yi-zhong-ming#lin-shi-biao-he-zhu-bei-fu-zhi) 临时表和主备复制 既然写binlog,就意味着备库需要。 你可以设想一下,在主库上执行下面这个语句序列: Copy create table t_normal(id int primary key, c int)engine=innodb;/*Q1*/ create temporary table temp_t like t_normal;/*Q2*/ insert into temp_t values(1,1);/*Q3*/ insert into t_normal select * from temp_t;/*Q4*/ 如果关于临时表的操作都不记录,那么在备库就只有create table t\_normal表和insert into t\_normal select \* from temp\_t这两个语句的binlog日志,备库在执行到insert into t\_normal的时候,就会报错“表temp\_t不存在”。 你可能会说,如果把binlog设置为row格式就好了吧?因为binlog是row格式时,在记录insert into t\_normal的binlog时,记录的是这个操作的数据,即:write\_row event里面记录的逻辑是“插入一行数据(1,1)”。 确实是这样。如果当前的binlog\_format=row,那么跟临时表有关的语句,就不会记录到binlog里。也就是说,只在binlog\_format=statment/mixed 的时候,binlog中才会记录临时表的操作。 这种情况下,创建临时表的语句会传到备库执行,因此备库的同步线程就会创建这个临时表。主库在线程退出的时候,会自动删除临时表,但是备库同步线程是持续在运行的。所以,这时候我们就需要在主库上再写一个DROP TEMPORARY TABLE传给备库执行。 **之前有人问过我一个有趣的问题:**MySQL在记录binlog的时候,不论是create table还是alter table语句,都是原样记录,甚至于连空格都不变。但是如果执行drop table t\_normal,系统记录binlog就会写成: Copy DROP TABLE `t_normal` /* generated by server */ 也就是改成了标准的格式。为什么要这么做呢 ? 现在你知道原因了,那就是:drop table命令是可以一次删除多个表的。比如,在上面的例子中,设置binlog\_format=row,如果主库上执行 "drop table t\_normal, temp\_t"这个命令,那么binlog中就只能记录: Copy DROP TABLE `t_normal` /* generated by server */ 因为备库上并没有表temp\_t,将这个命令重写后再传到备库执行,才不会导致备库同步线程停止。 所以,drop table命令记录binlog的时候,就必须对语句做改写。“/\* generated by server \*/”说明了这是一个被服务端改写过的命令。 说到主备复制,**还有另外一个问题需要解决**:主库上不同的线程创建同名的临时表是没关系的,但是传到备库执行是怎么处理的呢? 现在,我给你举个例子,下面的序列中实例S是M的备库。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F74%2Fba%2F74e789024f10bcde515f21c0368847ba.png&width=768&dpr=4&quality=100&sign=3ed16027&sv=2) 图5 主备关系中的临时表操作 主库M上的两个session创建了同名的临时表t1,这两个create temporary table t1 语句都会被传到备库S上。 但是,备库的应用日志线程是共用的,也就是说要在应用线程里面先后执行这个create 语句两次。(即使开了多线程复制,也可能被分配到从库的同一个worker中执行)。那么,这会不会导致同步线程报错 ? 显然是不会的,否则临时表就是一个bug了。也就是说,备库线程在执行的时候,要把这两个t1表当做两个不同的临时表来处理。这,又是怎么实现的呢? MySQL在记录binlog的时候,会把主库执行这个语句的线程id写到binlog中。这样,在备库的应用线程就能够知道执行每个语句的主库线程id,并利用这个线程id来构造临时表的table\_def\_key: 1. session A的临时表t1,在备库的table\_def\_key就是:库名+t1+“M的serverid”+“session A的thread\_id”; 2. session B的临时表t1,在备库的table\_def\_key就是 :库名+t1+“M的serverid”+“session B的thread\_id”。 由于table\_def\_key不同,所以这两个表在备库的应用线程里面是不会冲突的。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/36-wei-shi-mo-lin-shi-biao-ke-yi-zhong-ming#xiao-jie) 小结 今天这篇文章,我和你介绍了临时表的用法和特性。 在实际应用中,临时表一般用于处理比较复杂的计算逻辑。由于临时表是每个线程自己可见的,所以不需要考虑多个线程执行同一个处理逻辑时,临时表的重名问题。在线程退出的时候,临时表也能自动删除,省去了收尾和异常处理的工作。 在binlog\_format='row’的时候,临时表的操作不记录到binlog中,也省去了不少麻烦,这也可以成为你选择binlog\_format时的一个考虑因素。 需要注意的是,我们上面说到的这种临时表,是用户自己创建的 ,也可以称为用户临时表。与它相对应的,就是内部临时表,在[第17篇文章](https://time.geekbang.org/column/article/73795) 中我已经和你介绍过。 最后,我给你留下一个思考题吧。 下面的语句序列是创建一个临时表,并将其改名: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F33%2Ff9%2F333ad95b2ce16de1931fe347128caff9.png&width=768&dpr=4&quality=100&sign=4689e946&sv=2) 图6 关于临时表改名的思考题 可以看到,我们可以使用alter table语法修改临时表的表名,而不能使用rename语法。你知道这是什么原因吗? 你可以把你的分析写在留言区,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/36-wei-shi-mo-lin-shi-biao-ke-yi-zhong-ming#shang-qi-wen-ti-shi-jian) 上期问题时间 上期的问题是,对于下面这个三个表的join语句, Copy select * from t1 join t2 on(t1.a=t2.a) join t3 on (t2.b=t3.b) where t1.c>=X and t2.c>=Y and t3.c>=Z; 如果改写成straight\_join,要怎么指定连接顺序,以及怎么给三个表创建索引。 第一原则是要尽量使用BKA算法。需要注意的是,使用BKA算法的时候,并不是“先计算两个表join的结果,再跟第三个表join”,而是直接嵌套查询的。 具体实现是:在t1.c>=X、t2.c>=Y、t3.c>=Z这三个条件里,选择一个经过过滤以后,数据最少的那个表,作为第一个驱动表。此时,可能会出现如下两种情况。 第一种情况,如果选出来是表t1或者t3,那剩下的部分就固定了。 1. 如果驱动表是t1,则连接顺序是t1->t2->t3,要在被驱动表字段创建上索引,也就是t2.a 和 t3.b上创建索引; 2. 如果驱动表是t3,则连接顺序是t3->t2->t1,需要在t2.b 和 t1.a上创建索引。 同时,我们还需要在第一个驱动表的字段c上创建索引。 第二种情况是,如果选出来的第一个驱动表是表t2的话,则需要评估另外两个条件的过滤效果。 总之,整体的思路就是,尽量让每一次参与join的驱动表的数据集,越小越好,因为这样我们的驱动表就会越小。 评论区留言点赞板: > @库淘淘 做了实验验证; @poppy同学做了很不错的分析; @dzkk 同学在评论中介绍了MariaDB支持的hash join,大家可以了解一下; @老杨同志提了一个好问题,如果语句使用了索引a,结果还要对a排序,就不用MRR优化了,否则回表完还要增加额外的排序过程,得不偿失。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F09%2F77%2F09c1073f99cf71d2fb162a716b5fa577.jpg&width=768&dpr=4&quality=100&sign=e5471b04&sv=2) [Previous35 join语句怎么优化?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/35-join-yu-ju-zen-mo-you-hua) [Next37 什么时候会使用内部临时表?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/37-shi-mo-shi-hou-hui-shi-yong-nei-bu-lin-shi-biao) Last updated 2 years ago Was this helpful? --- # 09 | 字典的操作和约束 | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252FP5wtxECGQAlHEBCPzKYQ%252Fimage.png%3Falt%3Dmedia%26token%3D716a163a-2573-47c6-9816-b0051879e629&width=768&dpr=4&quality=100&sign=8ef88c82&sv=2) 【Go 语言代码较多,建议配合文章收听音频。】 [7MB\ \ 9.字典的操作和约束.mp3](https://2852705636-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FkmtyDvkosJZE1Rr33XGZ%2Fuploads%2FC3hTI9zkJmkP05NPzTqF%2F9.%E5%AD%97%E5%85%B8%E7%9A%84%E6%93%8D%E4%BD%9C%E5%92%8C%E7%BA%A6%E6%9D%9F.mp3?alt=media&token=2d3b6269-152e-4f96-8ba9-7c28afb5fc73) 至今为止,我们讲过的集合类的高级数据类型都属于针对单一元素的容器。它们或用连续存储或用互存指针的方式收纳元素,每个元素都代表了一个从属于某一类型的独立值。 我们今天要讲的字典(map)却不同,它能存储的不是单一值的集合,而是键值对的集合。 什么是键值对?它是从英文 key-value pair 直译过来的一个词。顾名思义,一个键值对就代表了一对键和值。 注意,一个“键”和一个“值”分别代表了一个从属于某一类型的独立值,把它们两个捆绑在一起就是一个键值对了。在 Go 语言规范中,应该是为了避免歧义,他们将键值对换了一种称呼,叫做:“键 - 元素对”。我们也沿用这个看起来更加清晰的词来讲解。 Go 语言的字典类型其实是一个哈希表(hash table)的特定实现。在这个实现中,键和元素的最大不同在于,前者的类型是受限的,而后者却可以是任意类型的。 若要探究此限制的原因,我们就先要了解哈希表中最重要的一个过程:映射。你可以把键理解为元素的一个索引,我们可以在哈希表中通过键查找与它成对的那个元素。 键和元素的这种对应关系在数学里就被称为“映射”,这也是“map”这个词的本意,哈希表的映射过程就存在于对键 - 元素对的增、删、改、查的操作之中。 复制 aMap := map[string]int{ "one": 1, "two":    2, "three": 3, } k := "two" v, ok := aMap[k] if ok { fmt.Printf("The element of key %q: %d\n", k, v) } else { fmt.Println("Not found!") } 比如我们要在哈希表中查找与某个键值对应的那个元素值,那么我们需要先把键值作为参数传给这个哈希表。哈希表会先用哈希函数(hash function)把键值转换为哈希值。 哈希值通常是一个无符号的整数。一个哈希表会持有一定数量的桶(bucket),也可称之为哈希桶,这些哈希桶会均匀地储存其所属哈希表收纳的那些键 - 元素对。 因此,哈希表会先用这个键的哈希值的低几位去定位到一个哈希桶,然后再去这个哈希桶中,查找这个键。由于键 - 元素对总是被捆绑在一起存储的,所以一旦找到了键,就一定能找到对应的元素值。 随后,哈希表就会把相应的元素值作为结果返回。只要这个键 - 元素对存在于哈希表中就一定会被查找到,因为哈希表增、改、删键 - 元素对时侯的映射过程,与前文所述如出一辙。 现在我们知道了,映射过程的第一步就是把键值转换为哈希值。在 Go 语言的字典中,每一个键值都是由它的哈希值代表的。也就是说,字典不会独立存储任何键的值,但会独立存储它们的哈希值。 是不是隐约感觉到了什么?我们接着往下看。 **我们今天的问题是:字典的键类型不能是哪些类型?** 这个问题你可以在 Go 语言规范中找到答案,但却没那么简单。它的典型回答是:Go 语言字典的键类型不可以是函数类型、字典类型和切片类型。 ### [](https://jums.gitbook.io/36-lectures-on-golang/09-zi-dian-de-cao-zuo-he-yue-shu#wen-ti-jie-xi) 问题解析 我们来解析一下这个问题。 Go 语言规范规定,在键类型的值之间必须可以施加操作符`==`和`!=`。换句话说,键类型的值必须要支持判等操作。由于函数类型、字典类型和切片类型的值并不支持判等操作,所以字典的键类型不能是这些类型。 另外,如果键的类型是接口类型的,那么键值的实际类型也不能是上述三种类型,否则在程序运行过程中会引发 panic(即运行时恐慌)。举个例子: 复制 var badMap2 = map[interface{}]int{ "1":   1, []int{2}: 2, // 这里会引发 panic。 3:    3, } 这里的变量`badMap2`的类型是键类型为`interface{}`、值类型为`int`的字典类型。这样声明并不会引起什么错误。或者说,我通过这样的声明躲过了 Go 语言编译器的检查。 注意,我用字面量在声明该字典的同时对它进行了初始化,使它包含了三个键 - 元素对。其中第二个键 - 元素对的键值是`[]int{2}`,元素值是`2`。这样的键值也不会让 Go 语言编译器报错,因为从语法上说,这样做是可以的。 但是,当我们运行这段代码的时候,Go 语言的运行时(runtime)系统就会发现这里的问题,它会抛出一个 panic,并把根源指向字面量中定义第二个键 - 元素对的那一行。我们越晚发现问题,修正问题的成本就会越高,所以最好不要把字典的键类型设定为任何接口类型。如果非要这么做,请一定确保代码在可控的范围之内。 还要注意,如果键的类型是数组类型,那么还要确保该类型的元素类型不是函数类型、字典类型或切片类型。 比如,由于类型`[1][]string`的元素类型是`[]string`,所以它就不能作为字典类型的键类型。另外,如果键的类型是结构体类型,那么还要保证其中字段的类型的合法性。无论不合法的类型被埋藏得有多深,比如`map[[1][2][3][]string]int`,Go 语言编译器都会把它揪出来。 你可能会有疑问,为什么键类型的值必须支持判等操作?我在前面说过,Go 语言一旦定位到了某一个哈希桶,那么就会试图在这个桶中查找键值。具体是怎么找的呢? 首先,每个哈希桶都会把自己包含的所有键的哈希值存起来。Go 语言会用被查找键的哈希值与这些哈希值逐个对比,看看是否有相等的。如果一个相等的都没有,那么就说明这个桶中没有要查找的键值,这时 Go 语言就会立刻返回结果了。 如果有相等的,那就再用键值本身去对比一次。为什么还要对比?原因是,不同值的哈希值是可能相同的。这有个术语,叫做“哈希碰撞”。 所以,即使哈希值一样,键值也不一定一样。如果键类型的值之间无法判断相等,那么此时这个映射的过程就没办法继续下去了。最后,只有键的哈希值和键值都相等,才能说明查找到了匹配的键 - 元素对。 以上内容涉及的示例都在 demo18.go 中。 ### [](https://jums.gitbook.io/36-lectures-on-golang/09-zi-dian-de-cao-zuo-he-yue-shu#zhi-shi-kuo-zhan) 知识扩展 **问题 1:应该优先考虑哪些类型作为字典的键类型?** 你现在已经清楚了,在 Go 语言中,有些类型的值是支持判等的,有些是不支持的。那么在这些值支持判等的类型当中,哪些更适合作为字典的键类型呢? 这里先抛开我们使用字典时的上下文,只从性能的角度看。在前文所述的映射过程中,“把键值转换为哈希值”以及“把要查找的键值与哈希桶中的键值做对比”, 明显是两个重要且比较耗时的操作。 因此,可以说,**求哈希和判等操作的速度越快,对应的类型就越适合作为键类型。** 对于所有的基本类型、指针类型,以及数组类型、结构体类型和接口类型,Go 语言都有一套算法与之对应。这套算法中就包含了哈希和判等。以求哈希的操作为例,宽度越小的类型速度通常越快。对于布尔类型、整数类型、浮点数类型、复数类型和指针类型来说都是如此。对于字符串类型,由于它的宽度是不定的,所以要看它的值的具体长度,长度越短求哈希越快。 类型的宽度是指它的单个值需要占用的字节数。比如,`bool`、`int8`和`uint8`类型的一个值需要占用的字节数都是`1`,因此这些类型的宽度就都是`1`。 以上说的都是基本类型,再来看高级类型。对数组类型的值求哈希实际上是依次求得它的每个元素的哈希值并进行合并,所以速度就取决于它的元素类型以及它的长度。细则同上。 与之类似,对结构体类型的值求哈希实际上就是对它的所有字段值求哈希并进行合并,所以关键在于它的各个字段的类型以及字段的数量。而对于接口类型,具体的哈希算法,则由值的实际类型决定。 我不建议你使用这些高级数据类型作为字典的键类型,不仅仅是因为对它们的值求哈希,以及判等的速度较慢,更是因为在它们的值中存在变数。 比如,对一个数组来说,我可以任意改变其中的元素值,但在变化前后,它却代表了两个不同的键值。 对于结构体类型的值情况可能会好一些,因为如果我可以控制其中各字段的访问权限的话,就可以阻止外界修改它了。把接口类型作为字典的键类型最危险。 还记得吗?如果在这种情况下 Go 运行时系统发现某个键值不支持判等操作,那么就会立即抛出一个 panic。在最坏的情况下,这足以使程序崩溃。 那么,在那些基本类型中应该优先选择哪一个?答案是,优先选用数值类型和指针类型,通常情况下类型的宽度越小越好。如果非要选择字符串类型的话,最好对键值的长度进行额外的约束。 那什么是不通常的情况?笼统地说,Go 语言有时会对字典的增、删、改、查操作做一些优化。 比如,在字典的键类型为字符串类型的情况下;又比如,在字典的键类型为宽度为`4`或`8`的整数类型的情况下。 **问题 2:在值为**`**nil**`**的字典上执行读操作会成功吗,那写操作呢?** 好了,为了避免烧脑太久,我们再来说一个简单些的问题。由于字典是引用类型,所以当我们仅声明而不初始化一个字典类型的变量的时候,它的值会是`nil`。 在这样一个变量上试图通过键值获取对应的元素值,或者添加键 - 元素对,会成功吗?这个问题虽然简单,但却是我们必须铭记于心的,因为这涉及程序运行时的稳定性。 我来说一下答案。除了添加键 - 元素对,我们在一个值为`nil`的字典上做任何操作都不会引起错误。当我们试图在一个值为`nil`的字典中添加键 - 元素对的时候,Go 语言的运行时系统就会立即抛出一个 panic。你可以运行一下 demo19.go 文件试试看。 **总结** 我们这次主要讨论了与字典类型有关的,一些容易让人困惑的问题。比如,为什么字典的键类型会受到约束?又比如,我们通常应该选取什么样的类型作为字典的键类型。 我以 Go 语言规范为起始,并以 Go 语言源码为依据回答了这些问题。认真看了这篇文章之后,你应该对字典中的映射过程有了一定的理解。 另外,对于 Go 语言在那些合法的键类型上所做的求哈希和判等的操作,你也应该有所了解了。 再次强调,永远要注意那些可能引发 panic 的操作,比如像一个值为`nil`的字典添加键 - 元素对。 **思考题** 今天的思考题是关于并发安全性的。更具体地说,在同一时间段内但在不同的 goroutine(或者说 go 程)中对同一个值进行操作是否是安全的。这里的安全是指,该值不会因这些操作而产生混乱,或其它不可预知的问题。 具体的思考题是:字典类型的值是并发安全的吗?如果不是,那么在我们只在字典上添加或删除键 - 元素对的情况下,依然不安全吗?感谢你的收听,我们下期再见。 [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页08 | container包中的那些容器](https://jums.gitbook.io/36-lectures-on-golang/08-container-bao-zhong-de-na-xie-rong-qi) [下一页10 | 通道的基本操作](https://jums.gitbook.io/36-lectures-on-golang/10-tong-dao-de-ji-ben-cao-zuo) 最后更新于2年前 这有帮助吗? --- # 07 | 数组和切片 | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252Fp4GErajHzNFrK73GRIBE%252Fimage.png%3Falt%3Dmedia%26token%3D1939b4b5-b5b1-431f-9139-bce2c211127b&width=768&dpr=4&quality=100&sign=23b9bae2&sv=2) #### [](https://jums.gitbook.io/36-lectures-on-golang/07-shu-zu-he-qie-pian#go-yu-yan-dai-ma-jiao-duo-jian-yi-pei-he-wen-zhang-shou-ting-yin-pin) 【Go 语言代码较多,建议配合文章收听音频。】 [6MB\ \ 7.数组和切片.mp3](https://2852705636-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FkmtyDvkosJZE1Rr33XGZ%2Fuploads%2FNweTitCo9bOCb30c2VGx%2F7.%E6%95%B0%E7%BB%84%E5%92%8C%E5%88%87%E7%89%87.mp3?alt=media&token=100d35a6-4b2d-4497-9e07-cedb0fbcf1a3) 从本篇文章开始,我们正式进入了模块 2 的学习。在这之前,我们已经聊了很多的 Go 语言和编程方面的基础知识,相信你已经对 Go 语言的开发环境配置、常用源码文件写法,以及程序实体(尤其是变量)及其相关的各种概念和编程技巧(比如类型推断、变量重声明、可重名变量、类型断言、类型转换、别名类型和潜在类型等)都有了一定的理解。 它们都是我认为的 Go 语言编程基础中比较重要的部分,同时也是后续文章的基石。如果你在后面的学习过程中感觉有些吃力,那可能是基础仍未牢固,可以再回去复习一下。 * * * 我们这次主要讨论 Go 语言的数组(array)类型和切片(slice)类型。数组和切片有时候会让初学者感到困惑。它们的共同点是都属于集合类的类型,它们的值也都可以用来存储某一种类型的值(或者说元素)。 不过,它们最重要的不同是:**数组类型的值(以下简称数组)的长度是固定的,而切片类型的值(以下简称切片)是可变长的。** 数组的长度在声明它的时候就必须给定,并且之后不会再改变。可以说,数组的长度是其类型的一部分。比如,`[1]string`和`[2]string`就是两个不同的数组类型。 而切片的类型字面量中只有元素的类型,而没有长度。切片的长度可以自动地随着其中元素数量的增长而增长,但不会随着元素数量的减少而减小。 > 我们其实可以把切片看做是对数组的一层简单的封装,因为在每个切片的底层数据结构中,一定会包含一个数组。数组可以被叫做切片的底层数组,而切片也可以被看作是对数组的某个连续片段的引用。 > > 也正因为如此,Go 语言的切片类型属于引用类型,同属引用类型的还有字典类型、通道类型、函数类型等;而 Go 语言的数组类型则属于值类型,同属值类型的有基础数据类型以及结构体类型。 > > 注意,Go 语言里不存在像 Java 等编程语言中令人困惑的“传值或传引用”问题。在 Go 语言中,我们判断所谓的“传值”或者“传引用”只要看被传递的值的类型就好了。 > > 如果传递的值是引用类型的,那么就是“传引用”。如果传递的值是值类型的,那么就是“传值”。从传递成本的角度讲,引用类型的值往往要比值类型的值低很多。 > > 我们在数组和切片之上都可以应用索引表达式,得到的都会是某个元素。我们在它们之上也都可以应用切片表达式,也都会得到一个新的切片。 我们通过调用内建函数`len`,得到数组和切片的长度。通过调用内建函数`cap`,我们可以得到它们的容量。 但要注意,数组的容量永远等于其长度,都是不可变的。切片的容量却不是这样,并且它的变化是有规律可寻的。 下面我们就通过一道题来了解一下。**我们今天的问题就是:怎样正确估算切片的长度和容量?** 为此,我编写了一个简单的命令源码文件 [demo15.go](https://github.com/crazyjums/go_haolingeek/blob/master/article7/q1/demo15.go) 。 复制 package main import "fmt" func main() { // 示例 1。 s1 := make([]int, 5) fmt.Printf("The length of s1: %d\n", len(s1)) fmt.Printf("The capacity of s1: %d\n", cap(s1)) fmt.Printf("The value of s1: %d\n", s1) s2 := make([]int, 5, 8) fmt.Printf("The length of s2: %d\n", len(s2)) fmt.Printf("The capacity of s2: %d\n", cap(s2)) fmt.Printf("The value of s2: %d\n", s2) } 我描述一下它所做的事情。首先,我用内建函数`make`声明了一个`[]int`类型的变量`s1`。我传给`make`函数的第二个参数是`5`,从而指明了该切片的长度。我用几乎同样的方式声明了切片`s2`,只不过多传入了一个参数`8`以指明该切片的容量。 现在,具体的问题是:切片`s1`和`s2`的容量都是多少? 这道题的典型回答:切片`s1`和`s2`的容量分别是`5`和`8`。 ### [](https://jums.gitbook.io/36-lectures-on-golang/07-shu-zu-he-qie-pian#wen-ti-jie-xi) 问题解析 解析一下这道题。`s1`的容量为什么是`5`呢?因为我在声明`s1`的时候把它的长度设置成了`5`。当我们用`make`函数初始化切片时,如果不指明其容量,那么它就会和长度一致。如果在初始化时指明了容量,那么切片的实际容量也就是它了。这也正是`s2`的容量是`8`的原因。 我们顺便通过`s2`再来明确下长度、容量以及它们的关系。我在初始化`s2`代表的切片时同时指定了它的长度和容量。 我们说过,可以把切片看做是对数组的一层简单的封装,因为在每个切片的底层数据结构中,一定会包含一个数组。数组可以被叫做切片的底层数组,而切片也可以被看作是对数组的某个连续片段的引用。 在这种情况下,切片的容量实际上代表了它的底层数组的长度,这里是`8`。注意,切片的底层数组等同于我们前面讲到的数组,其长度不可变。 现在你需要跟着我一起想象:有一个窗口,你可以通过这个窗口看到一个数组,但是不一定能看到该数组中的所有元素,有时候只能看到连续的一部分元素。 现在,这个数组就是切片`s2`的底层数组,而这个窗口就是切片`s2`本身。`s2`的长度实际上指明的就是这个窗口的宽度,决定了你透过`s2`,可以看到其底层数组中的哪几个连续的元素。由于`s2`的长度是`5`,所以你可以看到底层数组中的第 1 个元素到第 5 个元素,对应的底层数组的索引范围是 \[0, 4\]。 切片代表的窗口也会被划分成一个一个的小格子,就像我们家里的窗户那样。每个小格子都对应着其底层数组中的某一个元素。 我们继续拿`s2`为例,这个窗口最左边的那个小格子对应的正好是其底层数组中的第一个元素,即索引为`0`的那个元素。因此可以说,`s2`中的索引从`0`到`4`所指向的元素恰恰就是其底层数组中索引从`0`到`4`代表的那 5 个元素。 请记住,当我们用`make`函数或切片值字面量(比如`[]int{1, 2, 3}`)初始化一个切片时,该窗口最左边的那个小格子总是会对应其底层数组中的第 1 个元素。 但是当我们通过切片表达式基于某个数组或切片生成新切片的时候,情况就变得复杂起来了。我们再来看一个例子: 复制 s3 := []int{1, 2, 3, 4, 5, 6, 7, 8} s4 := s3[3:6] fmt.Printf("The length of s4: %d\n", len(s4)) fmt.Printf("The capacity of s4: %d\n", cap(s4)) fmt.Printf("The value of s4: %d\n", s4) 切片`s3`中有 8 个元素,分别是从`1`到`8`的整数。`s3`的长度和容量都是`8`。然后,我用切片表达式`s3[3:6]`初始化了切片`s4`。问题是,这个`s4`的长度和容量分别是多少? 这并不难,用减法就可以搞定。首先你要知道,切片表达式中的方括号里的那两个整数都代表什么。我换一种表达方式你也许就清楚了,即:\[3, 6)。\ \ 这是数学中的区间表示法,常用于表示取值范围,我其实已经在本专栏用过好几次了。由此可知,`[3:6]`要表达的就是透过新窗口能看到的`s3`中元素的索引范围是从`3`到`5`(注意,不包括`6`)。\ \ 这里的`3`可被称为起始索引,`6`可被称为结束索引。那么`s4`的长度就是`6`减去`3`,即`3`。因此可以说,`s4`中的索引从`0`到`2`指向的元素对应的是`s3`及其底层数组中索引从`3`到`5`的那 3 个元素。\ \ 再来看容量。我在前面说过,切片的容量代表了它的底层数组的长度,但这仅限于使用`make`函数或者切片值字面量初始化切片的情况。更通用的规则是:一个切片的容量可以被看作是透过这个窗口最多可以看到的底层数组中元素的个数。\ \ 由于`s4`是通过在`s3`上施加切片操作得来的,所以`s3`的底层数组就是`s4`的底层数组。又因为,在底层数组不变的情况下,切片代表的窗口可以向右扩展,直至其底层数组的末尾。所以,`s4`的容量就是其底层数组的长度`8`, 减去上述切片表达式中的那个起始索引`3`,即`5`。\ \ 注意,切片代表的窗口是无法向左扩展的。也就是说,我们永远无法透过`s4`看到`s3`中最左边的那 3 个元素。\ \ 最后,顺便提一下把切片的窗口向右扩展到最大的方法。对于`s4`来说,切片表达式`s4[0:cap(s4)]`就可以做到。我想你应该能看懂。该表达式的结果值(即一个新的切片)会是`[]int{4, 5, 6, 7, 8}`,其长度和容量都是`5`。\ \ ### \ \ [](https://jums.gitbook.io/36-lectures-on-golang/07-shu-zu-he-qie-pian#zhi-shi-kuo-zhan)\ \ 知识扩展\ \ **1\. 问题:怎样估算切片容量的增长?**\ \ 一旦一个切片无法容纳更多的元素,Go 语言就会想办法扩容。但它并不会改变原来的切片,而是会生成一个容量更大的切片,然后将把原有的元素和新元素一并拷贝到新切片中。在一般的情况下,你可以简单地认为新切片的容量(以下简称新容量)将会是原切片容量(以下简称原容量)的 2 倍。\ \ 但是,当原切片的长度(以下简称原长度)大于或等于`1024`时,Go 语言将会以原容量的`1.25`倍作为新容量的基准(以下新容量基准)。新容量基准会被调整(不断地与`1.25`相乘),直到结果不小于原长度与要追加的元素数量之和(以下简称新长度)。最终,新容量往往会比新长度大一些,当然,相等也是可能的。\ \ 另外,如果我们一次追加的元素过多,以至于使新长度比原容量的 2 倍还要大,那么新容量就会以新长度为基准。注意,与前面那种情况一样,最终的新容量在很多时候都要比新容量基准更大一些。更多细节可参见`runtime`包中 slice.go 文件里的`growslice`及相关函数的具体实现。\ \ 我把展示上述扩容策略的一些例子都放到了 [demo16.go](https://github.com/crazyjums/go_haolingeek/blob/master/article7/q2/demo16.go)\ 文件中。你可以去试运行看看。\ \ **2\. 问题:切片的底层数组什么时候会被替换?**\ \ 确切地说,一个切片的底层数组永远不会被替换。为什么?虽然在扩容的时候 Go 语言一定会生成新的底层数组,但是它也同时生成了新的切片。它是把新的切片作为了新底层数组的窗口,而没有对原切片及其底层数组做任何改动。\ \ 请记住,在无需扩容时,`append`函数返回的是指向原底层数组的新切片,而在需要扩容时,`append`函数返回的是指向新底层数组的新切片。所以,严格来讲,“扩容”这个词用在这里虽然形象但并不合适。不过鉴于这种称呼已经用得很广泛了,我们也没必要另找新词了。\ \ 顺便说一下,只要新长度不会超过切片的原容量,那么使用`append`函数对其追加元素的时候就不会引起扩容。这只会使紧邻切片窗口右边的(底层数组中的)元素被新的元素替换掉。你可以运行 demo17.go 文件以增强对这些知识的理解。\ \ **总结**\ \ 总结一下,我们今天一起探讨了数组和切片以及它们之间的关系。切片是基于数组的,可变长的,并且非常轻快。一个切片的容量总是固定的,而且一个切片也只会与某一个底层数组绑定在一起。\ \ 此外,切片的容量总会是在切片长度和底层数组长度之间的某一个值,并且还与切片窗口最左边对应的元素在底层数组中的位置有关系。那两个分别用减法计算切片长度和容量的方法你一定要记住。\ \ 另外,`append`函数总会返回新的切片,而且如果新切片的容量比原切片的容量更大那么就意味着底层数组也是新的了。还有,你其实不必太在意切片“扩容”策略中的一些细节,只要能够理解它的基本规律并可以进行近似的估算就可以了。\ \ **思考题**\ \ 这里仍然是聚焦于切片的问题。\ \ 1. 如果有多个切片指向了同一个底层数组,那么你认为应该注意些什么?\ \ 2. 怎样沿用“扩容”的思想对切片进行“缩容”?请写出代码。\ \ \ 这两个问题都是开放性的,你需要认真思考一下。最好在动脑的同时动动手。\ \ [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)\ \ ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2)\ \ [上一页06 | 程序实体的那些事儿 (下)](https://jums.gitbook.io/36-lectures-on-golang/06-cheng-xu-shi-ti-de-na-xie-shi-er-xia)\ [下一页08 | container包中的那些容器](https://jums.gitbook.io/36-lectures-on-golang/08-container-bao-zhong-de-na-xie-rong-qi)\ \ 最后更新于2年前\ \ 这有帮助吗? --- # 29 | 原子操作(上) | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252FdvS5TBVoAKeza9KaTljL%252Fimage.png%3Falt%3Dmedia%26token%3Dc5025faa-ebd3-4770-8cb0-3b2a12587202&width=768&dpr=4&quality=100&sign=a99a028&sv=2) [4MB\ \ 29.原子操作(上).mp3](https://2852705636-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FkmtyDvkosJZE1Rr33XGZ%2Fuploads%2FdOfrSbWyXsYB5ZAzdXII%2F29.%E5%8E%9F%E5%AD%90%E6%93%8D%E4%BD%9C%EF%BC%88%E4%B8%8A%EF%BC%89.mp3?alt=media&token=deec10e0-3653-45aa-97b2-461649ea4cc6) 我们在前两篇文章中讨论了互斥锁、读写锁以及基于它们的条件变量。互斥锁是一个很有用的同步工具,它可以保证每一时刻进入临界区的 goroutine 只有一个。读写锁对共享资源的写操作和读操作则区别看待,并消除了读操作之间的互斥。 条件变量主要是用于协调想要访问共享资源的那些线程。当共享资源的状态发生变化时,它可以被用来通知被互斥锁阻塞的线程,它既可以基于互斥锁,也可以基于读写锁。当然了,读写锁也是一种互斥锁,前者是对后者的扩展。 通过对互斥锁的合理使用,我们可以使一个 goroutine 在执行临界区中的代码时,不被其他的 goroutine 打扰。不过,虽然不会被打扰,但是它仍然可能会被中断(interruption)。 我们已经知道,对于一个 Go 程序来说,Go 语言运行时系统中的调度器,会恰当地安排其中所有的 goroutine 的运行。不过,在同一时刻,只可能有少数的 goroutine 真正地处于运行状态,并且这个数量是固定的。 所以,为了公平起见,调度器总是会频繁地换上或换下这些 goroutine。换上的意思是,让一个 goroutine 由非运行状态转为运行状态,并促使其中的代码在某个 CPU 核心上执行。 换下的意思正好相反,即:使一个 goroutine 中的代码中断执行,并让它由运行状态转为非运行状态。 这个中断的时机有很多,任何两条语句执行的间隙,甚至在某条语句执行的过程中都是可以的。即使这些语句在临界区之内也是如此。所以,我们说,互斥锁虽然可以保证临界区中代码的串行执行,但却不能保证这些代码执行的原子性(atomicity)。 在众多的同步工具中,真正能够保证原子性执行的只有[原子操作](https://baike.baidu.com/item/%E5%8E%9F%E5%AD%90%E6%93%8D%E4%BD%9C/1880992?fr=aladdin) (atomic operation)。原子操作在进行的过程中是不允许中断的。在底层,这会由 CPU 提供芯片级别的支持,所以绝对有效。即使在拥有多 CPU 核心,或者多 CPU 的计算机系统中,原子操作的保证也是不可撼动的。 这使得原子操作可以完全地消除竞态条件,并能够绝对地保证并发安全性。并且,它的执行速度要比其他的同步工具快得多,通常会高出好几个数量级。不过,它的缺点也很明显。 **更具体地说,正是因为原子操作不能被中断,所以它需要足够简单,并且要求快速。** 你可以想象一下,如果原子操作迟迟不能完成,而它又不会被中断,那么将会给计算机执行指令的效率带来多么大的影响。因此,操作系统层面只对针对二进制位或整数的原子操作提供了支持。 Go 语言的原子操作当然是基于 CPU 和操作系统的,所以它也只针对少数数据类型的值提供了原子操作函数。这些函数都存在于标准库代码包`sync/atomic`中。 我一般会通过下面这道题初探一下应聘者对`sync/atomic`包的熟悉程度。**我们今天的问题是:**`**sync/atomic**`**包中提供了几种原子操作?可操作的数据类型又有哪些?** **这里的典型回答是:** `sync/atomic`包中的函数可以做的原子操作有:加法(add)、比较并交换(compare and swap,简称 CAS)、加载(load)、存储(store)和交换(swap)。 这些函数针对的数据类型并不多。但是,对这些类型中的每一个,`sync/atomic`包都会有一套函数给予支持。这些数据类型有:`int32`、`int64`、`uint32`、`uint64`、`uintptr`,以及`unsafe`包中的`Pointer`。不过,针对`unsafe.Pointer`类型,该包并未提供进行原子加法操作的函数。 此外,`sync/atomic`包还提供了一个名为`Value`的类型,它可以被用来存储任意类型的值。 ### [](https://jums.gitbook.io/36-lectures-on-golang/29-yuan-zi-cao-zuo-shang#wen-ti-jie-xi) 问题解析 这个问题很简单,因为答案是明摆在代码包文档里的。不过如果你连文档都没看过,那也可能回答不上来,至少是无法做出全面的回答。 我一般会通过此问题再衍生出来几道题。下面我就来逐个说明一下。 **第一个衍生问题** :我们都知道,传入这些原子操作函数的第一个参数值对应的都应该是那个被操作的值。比如,`atomic.AddInt32`函数的第一个参数,对应的一定是那个要被增大的整数。可是,这个参数的类型为什么不是`int32`而是`*int32`呢? 回答是:因为原子操作函数需要的是被操作值的指针,而不是这个值本身;被传入函数的参数值都会被复制,像这种基本类型的值一旦被传入函数,就已经与函数外的那个值毫无关系了。 所以,传入值本身没有任何意义。`unsafe.Pointer`类型虽然是指针类型,但是那些原子操作函数要操作的是这个指针值,而不是它指向的那个值,所以需要的仍然是指向这个指针值的指针。 只要原子操作函数拿到了被操作值的指针,就可以定位到存储该值的内存地址。只有这样,它们才能够通过底层的指令,准确地操作这个内存地址上的数据。 **第二个衍生问题:** 用于原子加法操作的函数可以做原子减法吗?比如,`atomic.AddInt32`函数可以用于减小那个被操作的整数值吗? 回答是:当然是可以的。`atomic.AddInt32`函数的第二个参数代表差量,它的类型是`int32`,是有符号的。如果我们想做原子减法,那么把这个差量设置为负整数就可以了。 对于`atomic.AddInt64`函数来说也是类似的。不过,要想用`atomic.AddUint32`和`atomic.AddUint64`函数做原子减法,就不能这么直接了,因为它们的第二个参数的类型分别是`uint32`和`uint64`,都是无符号的,不过,这也是可以做到的,就是稍微麻烦一些。 例如,如果想对`uint32`类型的被操作值`18`做原子减法,比如说差量是`-3`,那么我们可以先把这个差量转换为有符号的`int32`类型的值,然后再把该值的类型转换为`uint32`,用表达式来描述就是`uint32(int32(-3))`。 不过要注意,直接这样写会使 Go 语言的编译器报错,它会告诉你:“常量`-3`不在`uint32`类型可表示的范围内”,换句话说,这样做会让表达式的结果值溢出。 不过,如果我们先把`int32(-3)`的结果值赋给变量`delta`,再把`delta`的值转换为`uint32`类型的值,就可以绕过编译器的检查并得到正确的结果了。 最后,我们把这个结果作为`atomic.AddUint32`函数的第二个参数值,就可以达到对`uint32`类型的值做原子减法的目的了。 还有一种更加直接的方式。我们可以依据下面这个表达式来给定`atomic.AddUint32`函数的第二个参数值: 复制 ^uint32(-N-1)) 其中的`N`代表由负整数表示的差量。也就是说,我们先要把差量的绝对值减去`1`,然后再把得到的这个无类型的整数常量,转换为`uint32`类型的值,最后,在这个值之上做按位异或操作,就可以获得最终的参数值了。 这么做的原理也并不复杂。简单来说,此表达式的结果值的补码,与使用前一种方法得到的值的补码相同,所以这两种方式是等价的。我们都知道,整数在计算机中是以补码的形式存在的,所以在这里,结果值的补码相同就意味着表达式的等价。 ### [](https://jums.gitbook.io/36-lectures-on-golang/29-yuan-zi-cao-zuo-shang#zong-jie) 总结 今天,我们一起学习了`sync/atomic`代码包中提供的原子操作函数和原子值类型。原子操作函数使用起来都非常简单,但也有一些细节需要我们注意。我在主问题的衍生问题中对它们进行了逐一说明。 在下一篇文章中,我们会继续分享原子操作的衍生内容。如果你对原子操作有什么样的问题,都可以给我留言,我们一起讨论,感谢你的收听,我们下期再见。 [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页28 | 条件变量sync.Cond (下)](https://jums.gitbook.io/36-lectures-on-golang/28-tiao-jian-bian-liang-sync.cond-xia) [下一页30 | 原子操作(下)](https://jums.gitbook.io/36-lectures-on-golang/30-yuan-zi-cao-zuo-xia) 最后更新于2年前 这有帮助吗? --- # 39 自增主键为什么不是连续的? | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252FVT5K8xISa9OaKPdTBCCB%252Fimage.png%3Falt%3Dmedia%26token%3Dec900c0a-4234-4b7e-ac1d-5e199d222329&width=768&dpr=4&quality=100&sign=2ff3f19a&sv=2) 在[第4篇文章](https://time.geekbang.org/column/article/69236) 中,我们提到过自增主键,由于自增主键可以让主键索引尽量地保持递增顺序插入,避免了页分裂,因此索引更紧凑。 之前我见过有的业务设计依赖于自增主键的连续性,也就是说,这个设计假设自增主键是连续的。但实际上,这样的假设是错的,因为自增主键不能保证连续递增。 今天这篇文章,我们就来说说这个问题,看看什么情况下自增主键会出现 “空洞”? 为了便于说明,我们创建一个表t,其中id是自增主键字段、c是唯一索引。 Copy CREATE TABLE `t` ( `id` int(11) NOT NULL AUTO_INCREMENT, `c` int(11) DEFAULT NULL, `d` int(11) DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `c` (`c`) ) ENGINE=InnoDB; ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/39-zi-zeng-zhu-jian-wei-shi-mo-bu-shi-lian-xu-de#zi-zeng-zhi-bao-cun-zai-na-er) 自增值保存在哪儿? 在这个空表t里面执行insert into t values(null, 1, 1);插入一行数据,再执行show create table命令,就可以看到如下图所示的结果: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fcb%2Fff%2Fcb2637cada0201b18650f56875e94fff.png&width=768&dpr=4&quality=100&sign=56166479&sv=2) 图1 自动生成的AUTO\_INCREMENT值 可以看到,表定义里面出现了一个AUTO\_INCREMENT=2,表示下一次插入数据时,如果需要自动生成自增值,会生成id=2。 其实,这个输出结果容易引起这样的误解:自增值是保存在表结构定义里的。实际上,**表的结构定义存放在后缀名为.frm的文件中,但是并不会保存自增值。** 不同的引擎对于自增值的保存策略不同。 * MyISAM引擎的自增值保存在数据文件中。 * InnoDB引擎的自增值,其实是保存在了内存里,并且到了MySQL 8.0版本后,才有了“自增值持久化”的能力,也就是才实现了“如果发生重启,表的自增值可以恢复为MySQL重启前的值”,具体情况是: * 在MySQL 5.7及之前的版本,自增值保存在内存里,并没有持久化。每次重启后,第一次打开表的时候,都会去找自增值的最大值max(id),然后将max(id)+1作为这个表当前的自增值。 举例来说,如果一个表当前数据行里最大的id是10,AUTO\_INCREMENT=11。这时候,我们删除id=10的行,AUTO\_INCREMENT还是11。但如果马上重启实例,重启后这个表的AUTO\_INCREMENT就会变成10。 也就是说,MySQL重启可能会修改一个表的AUTO\_INCREMENT的值。 * 在MySQL 8.0版本,将自增值的变更记录在了redo log中,重启的时候依靠redo log恢复重启之前的值。 理解了MySQL对自增值的保存策略以后,我们再看看自增值修改机制。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/39-zi-zeng-zhu-jian-wei-shi-mo-bu-shi-lian-xu-de#zi-zeng-zhi-xiu-gai-ji-zhi) 自增值修改机制 在MySQL里面,如果字段id被定义为AUTO\_INCREMENT,在插入一行数据的时候,自增值的行为如下: 1. 如果插入数据时id字段指定为0、null 或未指定值,那么就把这个表当前的 AUTO\_INCREMENT值填到自增字段; 2. 如果插入数据时id字段指定了具体的值,就直接使用语句里指定的值。 根据要插入的值和当前自增值的大小关系,自增值的变更结果也会有所不同。假设,某次要插入的值是X,当前的自增值是Y。 1. 如果X 备注:在一些场景下,使用的就不全是默认值。比如,双M的主备结构里要求双写的时候,我们就可能会设置成auto\_increment\_increment=2,让一个库的自增id都是奇数,另一个库的自增id都是偶数,避免两个库生成的主键发生冲突。 当auto\_increment\_offset和auto\_increment\_increment都是1的时候,新的自增值生成逻辑很简单,就是: 1. 如果准备插入的值>=当前自增值,新的自增值就是“准备插入的值+1”; 2. 否则,自增值不变。 这就引入了我们文章开头提到的问题,在这两个参数都设置为1的时候,自增主键id却不能保证是连续的,这是什么原因呢? ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/39-zi-zeng-zhu-jian-wei-shi-mo-bu-shi-lian-xu-de#zi-zeng-zhi-de-xiu-gai-shi-ji) 自增值的修改时机 要回答这个问题,我们就要看一下自增值的修改时机。 假设,表t里面已经有了(1,1,1)这条记录,这时我再执行一条插入数据命令: Copy insert into t values(null, 1, 1); 这个语句的执行流程就是: 1. 执行器调用InnoDB引擎接口写入一行,传入的这一行的值是(0,1,1); 2. InnoDB发现用户没有指定自增id的值,获取表t当前的自增值2; 3. 将传入的行的值改成(2,1,1); 4. 将表的自增值改成3; 5. 继续执行插入数据操作,由于已经存在c=1的记录,所以报Duplicate key error,语句返回。 对应的执行流程图如下: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Ff1%2Fd3%2Ff16d89a6e7ad6e2cde13b32bb2292dd3.jpg&width=768&dpr=4&quality=100&sign=6a9a992e&sv=2) 图2 insert(null, 1,1)唯一键冲突 可以看到,这个表的自增值改成3,是在真正执行插入数据的操作之前。这个语句真正执行的时候,因为碰到唯一键c冲突,所以id=2这一行并没有插入成功,但也没有将自增值再改回去。 所以,在这之后,再插入新的数据行时,拿到的自增id就是3。也就是说,出现了自增主键不连续的情况。 如图3所示就是完整的演示结果。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F77%2F26%2F77b87820b649692a555f19b562d5d926.png&width=768&dpr=4&quality=100&sign=e50ed38&sv=2) 图3 一个自增主键id不连续的复现步骤 可以看到,这个操作序列复现了一个自增主键id不连续的现场(没有id=2的行)。可见,**唯一键冲突是导致自增主键id不连续的第一种原因。** 同样地,事务**回滚也会产生类似的现象,这就是第二种原因。** 下面这个语句序列就可以构造不连续的自增id,你可以自己验证一下。 Copy insert into t values(null,1,1); begin; insert into t values(null,2,2); rollback; insert into t values(null,2,2); //插入的行是(3,2,2) 你可能会问,为什么在出现唯一键冲突或者回滚的时候,MySQL没有把表t的自增值改回去呢?如果把表t的当前自增值从3改回2,再插入新数据的时候,不就可以生成id=2的一行数据了吗? 其实,MySQL这么设计是为了提升性能。接下来,我就跟你分析一下这个设计思路,看看**自增值为什么不能回退。** 假设有两个并行执行的事务,在申请自增值的时候,为了避免两个事务申请到相同的自增id,肯定要加锁,然后顺序申请。 1. 假设事务A申请到了id=2, 事务B申请到id=3,那么这时候表t的自增值是4,之后继续执行。 2. 事务B正确提交了,但事务A出现了唯一键冲突。 3. 如果允许事务A把自增id回退,也就是把表t的当前自增值改回2,那么就会出现这样的情况:表里面已经有id=3的行,而当前的自增id值是2。 4. 接下来,继续执行的其他事务就会申请到id=2,然后再申请到id=3。这时,就会出现插入语句报错“主键冲突”。 而为了解决这个主键冲突,有两种方法: 1. 每次申请id之前,先判断表里面是否已经存在这个id。如果存在,就跳过这个id。但是,这个方法的成本很高。因为,本来申请id是一个很快的操作,现在还要再去主键索引树上判断id是否存在。 2. 把自增id的锁范围扩大,必须等到一个事务执行完成并提交,下一个事务才能再申请自增id。这个方法的问题,就是锁的粒度太大,系统并发能力大大下降。 可见,这两个方法都会导致性能问题。造成这些麻烦的罪魁祸首,就是我们假设的这个“允许自增id回退”的前提导致的。 因此,InnoDB放弃了这个设计,语句执行失败也不回退自增id。也正是因为这样,所以才只保证了自增id是递增的,但不保证是连续的。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/39-zi-zeng-zhu-jian-wei-shi-mo-bu-shi-lian-xu-de#zi-zeng-suo-de-you-hua) 自增锁的优化 可以看到,自增id锁并不是一个事务锁,而是每次申请完就马上释放,以便允许别的事务再申请。其实,在MySQL 5.1版本之前,并不是这样的。 接下来,我会先给你介绍下自增锁设计的历史,这样有助于你分析接下来的一个问题。 在MySQL 5.0版本的时候,自增锁的范围是语句级别。也就是说,如果一个语句申请了一个表自增锁,这个锁会等语句执行结束以后才释放。显然,这样设计会影响并发度。 MySQL 5.1.22版本引入了一个新策略,新增参数innodb\_autoinc\_lock\_mode,默认值是1。 1. 这个参数的值被设置为0时,表示采用之前MySQL 5.0版本的策略,即语句执行结束后才释放锁; 2. 这个参数的值被设置为1时: * 普通insert语句,自增锁在申请之后就马上释放; * 类似insert … select这样的批量插入数据的语句,自增锁还是要等语句结束后才被释放; 3. 这个参数的值被设置为2时,所有的申请自增主键的动作都是申请后就释放锁。 你一定有两个疑问:**为什么默认设置下,insert … select 要使用语句级的锁?为什么这个参数的默认值不是2?** 答案是,这么设计还是为了数据的一致性。 我们一起来看一下这个场景: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fe0%2Fdf%2Fe0a69e151277de54a8262657e4ec89df.png&width=768&dpr=4&quality=100&sign=8858b47b&sv=2) 图4 批量插入数据的自增锁 在这个例子里,我往表t1中插入了4行数据,然后创建了一个相同结构的表t2,然后两个session同时执行向表t2中插入数据的操作。 你可以设想一下,如果session B是申请了自增值以后马上就释放自增锁,那么就可能出现这样的情况: * session B先插入了两个记录,(1,1,1)、(2,2,2); * 然后,session A来申请自增id得到id=3,插入了(3,5,5); * 之后,session B继续执行,插入两条记录(4,3,3)、 (5,4,4)。 你可能会说,这也没关系吧,毕竟session B的语义本身就没有要求表t2的所有行的数据都跟session A相同。 是的,从数据逻辑上看是对的。但是,如果我们现在的binlog\_format=statement,你可以设想下,binlog会怎么记录呢? 由于两个session是同时执行插入数据命令的,所以binlog里面对表t2的更新日志只有两种情况:要么先记session A的,要么先记session B的。 但不论是哪一种,这个binlog拿去从库执行,或者用来恢复临时实例,备库和临时实例里面,session B这个语句执行出来,生成的结果里面,id都是连续的。这时,这个库就发生了数据不一致。 你可以分析一下,出现这个问题的原因是什么? 其实,这是因为原库session B的insert语句,生成的id不连续。这个不连续的id,用statement格式的binlog来串行执行,是执行不出来的。 而要解决这个问题,有两种思路: 1. 一种思路是,让原库的批量插入数据语句,固定生成连续的id值。所以,自增锁直到语句执行结束才释放,就是为了达到这个目的。 2. 另一种思路是,在binlog里面把插入数据的操作都如实记录进来,到备库执行的时候,不再依赖于自增主键去生成。这种情况,其实就是innodb\_autoinc\_lock\_mode设置为2,同时binlog\_format设置为row。 因此,**在生产上,尤其是有insert … select这种批量插入数据的场景时,从并发插入数据性能的角度考虑,我建议你这样设置:innodb\_autoinc\_lock\_mode=2 ,并且 binlog\_format=row**.这样做,既能提升并发性,又不会出现数据一致性问题。 需要注意的是,我这里说的**批量插入数据,包含的语句类型是insert … select、replace … select和load data语句。** 但是,在普通的insert语句里面包含多个value值的情况下,即使innodb\_autoinc\_lock\_mode设置为1,也不会等语句执行完成才释放锁。因为这类语句在申请自增id的时候,是可以精确计算出需要多少个id的,然后一次性申请,申请完成后锁就可以释放了。 也就是说,批量插入数据的语句,之所以需要这么设置,是因为“不知道要预先申请多少个id”。 既然预先不知道要申请多少个自增id,那么一种直接的想法就是需要一个时申请一个。但如果一个select … insert语句要插入10万行数据,按照这个逻辑的话就要申请10万次。显然,这种申请自增id的策略,在大批量插入数据的情况下,不但速度慢,还会影响并发插入的性能。 因此,对于批量插入数据的语句,MySQL有一个批量申请自增id的策略: 1. 语句执行过程中,第一次申请自增id,会分配1个; 2. 1个用完以后,这个语句第二次申请自增id,会分配2个; 3. 2个用完以后,还是这个语句,第三次申请自增id,会分配4个; 4. 依此类推,同一个语句去申请自增id,每次申请到的自增id个数都是上一次的两倍。 举个例子,我们一起看看下面的这个语句序列: Copy insert into t values(null, 1,1); insert into t values(null, 2,2); insert into t values(null, 3,3); insert into t values(null, 4,4); create table t2 like t; insert into t2(c,d) select c,d from t; insert into t2 values(null, 5,5); insert…select,实际上往表t2中插入了4行数据。但是,这四行数据是分三次申请的自增id,第一次申请到了id=1,第二次被分配了id=2和id=3, 第三次被分配到id=4到id=7。 由于这条语句实际只用上了4个id,所以id=5到id=7就被浪费掉了。之后,再执行insert into t2 values(null, 5,5),实际上插入的数据就是(8,5,5)。 **这是主键id出现自增id不连续的第三种原因。** ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/39-zi-zeng-zhu-jian-wei-shi-mo-bu-shi-lian-xu-de#xiao-jie) 小结 今天,我们从“自增主键为什么会出现不连续的值”这个问题开始,首先讨论了自增值的存储。 在MyISAM引擎里面,自增值是被写在数据文件上的。而在InnoDB中,自增值是被记录在内存的。MySQL直到8.0版本,才给InnoDB表的自增值加上了持久化的能力,确保重启前后一个表的自增值不变。 然后,我和你分享了在一个语句执行过程中,自增值改变的时机,分析了为什么MySQL在事务回滚的时候不能回收自增id。 MySQL 5.1.22版本开始引入的参数innodb\_autoinc\_lock\_mode,控制了自增值申请时的锁范围。从并发性能的角度考虑,我建议你将其设置为2,同时将binlog\_format设置为row。我在前面的文章中其实多次提到,binlog\_format设置为row,是很有必要的。今天的例子给这个结论多了一个理由。 最后,我给你留一个思考题吧。 在最后一个例子中,执行insert into t2(c,d) select c,d from t;这个语句的时候,如果隔离级别是可重复读(repeatable read),binlog\_format=statement。这个语句会对表t的所有记录和间隙加锁。 你觉得为什么需要这么做呢? 你可以把你的思考和分析写在评论区,我会在下一篇文章和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/39-zi-zeng-zhu-jian-wei-shi-mo-bu-shi-lian-xu-de#shang-qi-wen-ti-shi-jian) 上期问题时间 上期的问题是,如果你维护的MySQL系统里有内存表,怎么避免内存表突然丢数据,然后导致主备同步停止的情况。 我们假设的是主库暂时不能修改引擎,那么就把备库的内存表引擎先都改成InnoDB。对于每个内存表,执行 Copy set sql_log_bin=off; alter table tbl_name engine=innodb; 这样就能避免备库重启的时候,数据丢失的问题。 由于主库重启后,会往binlog里面写“delete from tbl\_name”,这个命令传到备库,备库的同名的表数据也会被清空。 因此,就不会出现主备同步停止的问题。 如果由于主库异常重启,触发了HA,这时候我们之前修改过引擎的备库变成了主库。而原来的主库变成了新备库,在新备库上把所有的内存表(这时候表里没数据)都改成InnoDB表。 所以,如果我们不能直接修改主库上的表引擎,可以配置一个自动巡检的工具,在备库上发现内存表就把引擎改了。 同时,跟业务开发同学约定好建表规则,避免创建新的内存表。 评论区留言点赞板: > 大家在春节期间还坚持看专栏,并且深入地思考和回复,给大家点赞。 @长杰 同学提到的将数据保存到InnoDB表用来持久化,也是一个方法。不过,我还是建议釜底抽薪,直接修改备库的内存表的引擎。 @老杨同志 提到的是主库异常重启的场景,这时候是不会报主备不一致的,因为主库重启的时候写了delete from tbl\_name,主备的内存表都清空了。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F09%2F77%2F09c1073f99cf71d2fb162a716b5fa577.jpg&width=768&dpr=4&quality=100&sign=e5471b04&sv=2) [Previous38 都说InnoDB好,那还要不要使用Memory引擎?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/38-du-shuo-innodb-hao-na-huan-yao-bu-yao-shi-yong-memory-yin-qing) [Next40 insert语句的锁为什么这么多?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/40-insert-yu-ju-de-suo-wei-shi-mo-zhe-mo-duo) Last updated 2 years ago Was this helpful? --- # 26 | sync.Mutex与sync.RWMutex | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252F5szylnS8DeyLTdFI0hG6%252Fimage.png%3Falt%3Dmedia%26token%3Db47c449d-0e00-4c5c-a65d-25cdb88bba68&width=768&dpr=4&quality=100&sign=5d4f27bd&sv=2) #### [](https://jums.gitbook.io/36-lectures-on-golang/26-sync.mutex-yu-sync.rwmutex#go-yu-yan-dai-ma-jiao-duo-jian-yi-pei-he-wen-zhang-shou-ting-yin-pin) 【Go 语言代码较多,建议配合文章收听音频。】 [8MB\ \ 26.sync.Mutex与sync.RWMutex.mp3](https://2852705636-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FkmtyDvkosJZE1Rr33XGZ%2Fuploads%2FRiqSDMhmD3upt4fKwsR2%2F26.sync.Mutex%E4%B8%8Esync.RWMutex.mp3?alt=media&token=980b1c52-13db-4b6c-b43e-a0aa88c88a94) 我在前面用 20 多篇文章,为你详细地剖析了 Go 语言本身的一些东西,这包括了基础概念、重要语法、高级数据类型、特色语句、测试方案等等。 这些都是 Go 语言为我们提供的最核心的技术。我想,这已经足够让你对 Go 语言有一个比较深刻的理解了。 从本篇文章开始,我们将一起探讨 Go 语言自带标准库中一些比较核心的代码包。这会涉及这些代码包的标准用法、使用禁忌、背后原理以及周边的知识。 既然 Go 语言是以独特的并发编程模型傲视群雄的语言,那么我们就先来学习与并发编程关系最紧密的代码包。我们首先要看的就是`sync`包。这里的“sync”的中文意思是“同步”。我们下面就从同步讲起。 相比于 Go 语言宣扬的“用通讯的方式共享数据”,通过共享数据的方式来传递信息和协调线程运行的做法其实更加主流,毕竟大多数的现代编程语言,都是用后一种方式作为并发编程的解决方案的。这种方案的历史非常悠久,恐怕可以追溯到上个世纪多进程编程时代伊始了。 一旦数据被多个线程共享,那么就很可能会产生争用和冲突的情况。这种情况也被称为竞态条件(race condition),这往往会破坏共享数据的一致性。 共享数据的一致性代表着某种约定,即:多个线程对共享数据的操作总是可以达到它们各自预期的效果。 如果这个一致性得不到保证,那么将会影响到一些线程中代码和流程的正确执行,甚至会造成某种不可预知的错误。这种错误一般都很难发现和定位,排查起来的成本也是非常高的,所以一定要尽量避免。 举个例子,同时有多个线程连续向同一个缓冲区写入数据块,如果没有一个机制去协调这些线程的写入操作的话,那么被写入的数据块就很可能会出现错乱。比如,在线程 A 还没有写完一个数据块的时候,线程 B 就开始写入另外一个数据块了。 显然,这两个数据块中的数据会被混在一起,并且已经很难分清了。因此,在这种情况下,我们就需要采取一些措施来协调它们对缓冲区的修改。这通常就会涉及同步。 概括来讲,同步的用途有两个,一个是避免多个线程在同一时刻操作同一个数据块,另一个是协调多个线程,以避免它们在同一时刻执行同一个代码块。 由于这样的数据块和代码块的背后都隐含着一种或多种资源(比如存储资源、计算资源、I/O 资源、网络资源等等),所以我们可以把它们看做是共享资源,或者说共享资源的代表。我们所说的同步其实就是在控制多个线程对共享资源的访问。 一个线程在想要访问某一个共享资源的时候,需要先申请对该资源的访问权限,并且只有在申请成功之后,访问才能真正开始。 而当线程对共享资源的访问结束时,它还必须归还对该资源的访问权限,若要再次访问仍需申请。 你可以把这里所说的访问权限想象成一块令牌,线程一旦拿到了令牌,就可以进入指定的区域,从而访问到资源,而一旦线程要离开这个区域了,就需要把令牌还回去,绝不能把令牌带走。 如果针对某个共享资源的访问令牌只有一块,那么在同一时刻,就最多只能有一个线程进入到那个区域,并访问到该资源。 这时,我们可以说,多个并发运行的线程对这个共享资源的访问是完全串行的。只要一个代码片段需要实现对共享资源的串行化访问,就可以被视为一个临界区(critical section),也就是我刚刚说的,由于要访问到资源而必须进入的那个区域。 比如,在我前面举的那个例子中,实现了数据块写入操作的代码就共同组成了一个临界区。如果针对同一个共享资源,这样的代码片段有多个,那么它们就可以被称为相关临界区。 它们可以是一个内含了共享数据的结构体及其方法,也可以是操作同一块共享数据的多个函数。临界区总是需要受到保护的,否则就会产生竞态条件。施加保护的重要手段之一,就是使用实现了某种同步机制的工具,也称为同步工具。 **在 Go 语言中,可供我们选择的同步工具并不少。其中,最重要且最常用的同步工具当属互斥量(mutual exclusion,简称 mutex)。**`sync`包中的`Mutex`就是与其对应的类型,该类型的值可以被称为互斥量或者互斥锁。 一个互斥锁可以被用来保护一个临界区或者一组相关临界区。我们可以通过它来保证,在同一时刻只有一个 goroutine 处于该临界区之内。 为了兑现这个保证,每当有 goroutine 想进入临界区时,都需要先对它进行锁定,并且,每个 goroutine 离开临界区时,都要及时地对它进行解锁。 锁定操作可以通过调用互斥锁的`Lock`方法实现,而解锁操作可以调用互斥锁的`Unlock`方法。以下是 demo58.go 文件中重点代码经过简化之后的片段: 复制 mu.Lock() _, err := writer.Write([]byte(data)) if err != nil { log.Printf("error: %s [%d]", err, id) } mu.Unlock() 你可能已经看出来了,这里的互斥锁就相当于我们前面说的那块访问令牌。那么,我们怎样才能用好这块访问令牌呢?请看下面的问题。 **我们今天的问题是:我们使用互斥锁时有哪些注意事项?** 这里有一个典型回答。 使用互斥锁的注意事项如下: 1. 不要重复锁定互斥锁; 2. 不要忘记解锁互斥锁,必要时使用`defer`语句; 3. 不要对尚未锁定或者已解锁的互斥锁解锁; 4. 不要在多个函数之间直接传递互斥锁。 ### [](https://jums.gitbook.io/36-lectures-on-golang/26-sync.mutex-yu-sync.rwmutex#wen-ti-jie-xi) 问题解析 首先,你还是要把互斥锁看作是针对某一个临界区或某一组相关临界区的唯一访问令牌。 虽然没有任何强制规定来限制,你用同一个互斥锁保护多个无关的临界区,但是这样做,一定会让你的程序变得很复杂,并且也会明显地增加你的心智负担。 你要知道,对一个已经被锁定的互斥锁进行锁定,是会立即阻塞当前的 goroutine 的。这个 goroutine 所执行的流程,会一直停滞在调用该互斥锁的`Lock`方法的那行代码上。 直到该互斥锁的`Unlock`方法被调用,并且这里的锁定操作成功完成,后续的代码(也就是临界区中的代码)才会开始执行。这也正是互斥锁能够保护临界区的原因所在。 一旦,你把一个互斥锁同时用在了多个地方,就必然会有更多的 goroutine 争用这把锁。这不但会让你的程序变慢,还会大大增加死锁(deadlock)的可能性。 所谓的死锁,指的就是当前程序中的主 goroutine,以及我们启用的那些 goroutine 都已经被阻塞。这些 goroutine 可以被统称为用户级的 goroutine。这就相当于整个程序都已经停滞不前了。 Go 语言运行时系统是不允许这种情况出现的,只要它发现所有的用户级 goroutine 都处于等待状态,就会自行抛出一个带有如下信息的 panic: 复制 fatal error: all goroutines are asleep - deadlock! **注意,这种由 Go 语言运行时系统自行抛出的 panic 都属于致命错误,都是无法被恢复的,调用**`**recover**`**函数对它们起不到任何作用。也就是说,一旦产生死锁,程序必然崩溃。** 因此,我们一定要尽量避免这种情况的发生。而最简单、有效的方式就是让每一个互斥锁都只保护一个临界区或一组相关临界区。 在这个前提之下,我们还需要注意,对于同一个 goroutine 而言,既不要重复锁定一个互斥锁,也不要忘记对它进行解锁。 一个 goroutine 对某一个互斥锁的重复锁定,就意味着它自己锁死了自己。先不说这种做法本身就是错误的,在这种情况下,想让其他的 goroutine 来帮它解锁是非常难以保证其正确性的。 我以前就在团队代码库中见到过这样的代码。那个作者的本意是先让一个 goroutine 自己锁死自己,然后再让一个负责调度的 goroutine 定时地解锁那个互斥锁,从而让前一个 goroutine 周期性地去做一些事情,比如每分钟检查一次服务器状态,或者每天清理一次日志。 这个想法本身是没有什么问题的,但却选错了实现的工具。对于互斥锁这种需要精细化控制的同步工具而言,这样的任务并不适合它。 在这种情况下,即使选用通道或者`time.Ticker`类型,然后自行实现功能都是可以的,程序的复杂度和我们的心智负担也会小很多,更何况还有不少已经很完备的解决方案可供选择。 话说回来,其实我们说“不要忘记解锁互斥锁”的一个很重要的原因就是:避免重复锁定。因为在一个 goroutine 执行的流程中,可能会出现诸如“锁定、解锁、再锁定、再解锁”的操作,所以如果我们忘记了中间的解锁操作,那就一定会造成重复锁定。 除此之外,忘记解锁还会使其他的 goroutine 无法进入到该互斥锁保护的临界区,这轻则会导致一些程序功能的失效,重则会造成死锁和程序崩溃。 在很多时候,一个函数执行的流程并不是单一的,流程中间可能会有分叉,也可能会被中断。 如果一个流程在锁定了某个互斥锁之后分叉了,或者有被中断的可能,那么就应该使用`defer`语句来对它进行解锁,而且这样的`defer`语句应该紧跟在锁定操作之后。这是最保险的一种做法。 忘记解锁导致的问题有时候是比较隐秘的,并不会那么快就暴露出来。这也是我们需要特别关注它的原因。相比之下,解锁未锁定的互斥锁会立即引发 panic。 并且,与死锁导致的 panic 一样,它们是无法被恢复的。**因此,我们总是应该保证,对于每一个锁定操作,都要有且只有一个对应的解锁操作。** 换句话说,我们应该让它们成对出现。这也算是互斥锁的一个很重要的使用原则了。在很多时候,利用`defer`语句进行解锁可以更容易做到这一点。 最后,可能你已经知道,Go 语言中的互斥锁是开箱即用的。换句话说,一旦我们声明了一个`sync.Mutex`类型的变量,就可以直接使用它了。 不过要注意,该类型是一个结构体类型,属于值类型中的一种。把它传给一个函数、将它从函数中返回、把它赋给其他变量、让它进入某个通道都会导致它的副本的产生。 并且,原值和它的副本,以及多个副本之间都是完全独立的,它们都是不同的互斥锁。 如果你把一个互斥锁作为参数值传给了一个函数,那么在这个函数中对传入的锁的所有操作,都不会对存在于该函数之外的那个原锁产生任何的影响。 所以,你在这样做之前,一定要考虑清楚,这种结果是你想要的吗?我想,在大多数情况下应该都不是。即使你真的希望,在这个函数中使用另外一个互斥锁也不要这样做,这主要是为了避免歧义。 以上这些,就是我想要告诉你的关于互斥锁的锁定、解锁,以及传递方面的知识。这其中还包括了我的一些理解。希望能够对你有用。相关的例子我已经写在 demo59.go 文件中了,你可以去阅读一番,并运行起来看看。 ### [](https://jums.gitbook.io/36-lectures-on-golang/26-sync.mutex-yu-sync.rwmutex#zhi-shi-kuo-zhan) 知识扩展 问题 1:读写锁与互斥锁有哪些异同? 读写锁是读 / 写互斥锁的简称。在 Go 语言中,读写锁由`sync.RWMutex`类型的值代表。与`sync.Mutex`类型一样,这个类型也是开箱即用的。 顾名思义,读写锁是把对共享资源的“读操作”和“写操作”区别对待了。它可以对这两种操作施加不同程度的保护。换句话说,相比于互斥锁,读写锁可以实现更加细腻的访问控制。 一个读写锁中实际上包含了两个锁,即:读锁和写锁。`sync.RWMutex`类型中的`Lock`方法和`Unlock`方法分别用于对写锁进行锁定和解锁,而它的`RLock`方法和`RUnlock`方法则分别用于对读锁进行锁定和解锁。 另外,对于同一个读写锁来说有如下规则。 1. 在写锁已被锁定的情况下再试图锁定写锁,会阻塞当前的 goroutine。 2. 在写锁已被锁定的情况下试图锁定读锁,也会阻塞当前的 goroutine。 3. 在读锁已被锁定的情况下试图锁定写锁,同样会阻塞当前的 goroutine。 4. 在读锁已被锁定的情况下再试图锁定读锁,并不会阻塞当前的 goroutine。 换一个角度来说,对于某个受到读写锁保护的共享资源,多个写操作不能同时进行,写操作和读操作也不能同时进行,但多个读操作却可以同时进行。 当然了,只有在我们正确使用读写锁的情况下,才能达到这种效果。还是那句话,我们需要让每一个锁都只保护一个临界区,或者一组相关临界区,并以此尽量减少误用的可能性。顺便说一句,我们通常把这种不能同时进行的操作称为互斥操作。 再来看另一个方面。对写锁进行解锁,会唤醒“所有因试图锁定读锁,而被阻塞的 goroutine”,并且,这通常会使它们都成功完成对读锁的锁定。 然而,对读锁进行解锁,只会在没有其他读锁锁定的前提下,唤醒“因试图锁定写锁,而被阻塞的 goroutine”;并且,最终只会有一个被唤醒的 goroutine 能够成功完成对写锁的锁定,其他的 goroutine 还要在原处继续等待。至于是哪一个 goroutine,那就要看谁的等待时间最长了。 除此之外,读写锁对写操作之间的互斥,其实是通过它内含的一个互斥锁实现的。因此,也可以说,Go 语言的读写锁是互斥锁的一种扩展。 最后,需要强调的是,与互斥锁类似,解锁“读写锁中未被锁定的写锁”,会立即引发 panic,对于其中的读锁也是如此,并且同样是不可恢复的。 总之,读写锁与互斥锁的不同,都源于它把对共享资源的写操作和读操作区别对待了。这也使得它实现的互斥规则要更复杂一些。 不过,正因为如此,我们可以使用它对共享资源的操作,实行更加细腻的控制。另外,由于这里的读写锁是互斥锁的一种扩展,所以在有些方面它还是沿用了互斥锁的行为模式。比如,在解锁未锁定的写锁或读锁时的表现,又比如,对写操作之间互斥的实现方式。 ### [](https://jums.gitbook.io/36-lectures-on-golang/26-sync.mutex-yu-sync.rwmutex#zong-jie) 总结 我们今天讨论了很多与多线程、共享资源以及同步有关的知识。其中涉及了不少重要的并发编程概念,比如,竞态条件、临界区、互斥量、死锁等。 虽然 Go 语言是以“用通讯的方式共享数据”为亮点的,但是它依然提供了一些易用的同步工具。其中,互斥锁是我们最常用到的一个。 互斥锁常常被用来:保证多个 goroutine 并发地访问同一个共享资源时的完全串行,这是通过保护针对此共享资源的一个临界区,或一组相关临界区实现的。因此,我们可以把它看做是 goroutine 进入相关临界区时,必须拿到的访问令牌。 为了用对并且用好互斥锁,我们需要了解它实现的互斥规则,更要理解一些关于它的注意事项。 比如,不要重复锁定或忘记解锁,因为这会造成 goroutine 不必要的阻塞,甚至导致程序的死锁。 又比如,不要传递互斥锁,因为这会产生它的副本,从而引起歧义并可能导致互斥操作的失效。 再次强调,我们总是应该让每一个互斥锁都只保护一个临界区,或一组相关临界区。 至于读写锁,它是互斥锁的一种扩展。我们需要知道它与互斥锁的异同,尤其是互斥规则和行为模式方面的异同。一个读写锁中同时包含了读锁和写锁,由此也可以看出它对于针对共享资源的读操作和写操作是区别对待的。我们可以基于这件事,对共享资源实施更加细致的访问控制。 最后,需要特别注意的是,无论是互斥锁还是读写锁,我们都不要试图去解锁未锁定的锁,因为这样会引发不可恢复的 panic。 ### [](https://jums.gitbook.io/36-lectures-on-golang/26-sync.mutex-yu-sync.rwmutex#si-kao-ti) 思考题 1. 你知道互斥锁和读写锁的指针类型都实现了哪一个接口吗? 2. 怎样获取读写锁中的读锁? [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页25 | 更多的测试手法](https://jums.gitbook.io/36-lectures-on-golang/25-geng-duo-de-ce-shi-shou-fa) [下一页27 | 条件变量sync.Cond (上)](https://jums.gitbook.io/36-lectures-on-golang/27-tiao-jian-bian-liang-sync.cond-shang) 最后更新于2年前 这有帮助吗? --- # 25 | 更多的测试手法 | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252FZ725P1Sfh5YfxYGZ6kPN%252Fimage.png%3Falt%3Dmedia%26token%3D491a9250-a2d5-421c-947e-1a1004cdaa50&width=768&dpr=4&quality=100&sign=6d1a5329&sv=2) #### [](https://jums.gitbook.io/36-lectures-on-golang/25-geng-duo-de-ce-shi-shou-fa#go-yu-yan-dai-ma-jiao-duo-jian-yi-pei-he-wen-zhang-shou-ting-yin-pin) 【Go 语言代码较多,建议配合文章收听音频。】 [7MB\ \ 25.更多的测试手法.mp3](https://2852705636-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FkmtyDvkosJZE1Rr33XGZ%2Fuploads%2F75bWPCf6G5ZoM7O1li1m%2F25.%E6%9B%B4%E5%A4%9A%E7%9A%84%E6%B5%8B%E8%AF%95%E6%89%8B%E6%B3%95.mp3?alt=media&token=d12094c5-f5c1-472e-b9dd-5562900d9a4d) 在前面的文章中,我们一起学习了 Go 程序测试的基础知识和基本测试手法。这主要包括了 Go 程序测试的基本规则和主要流程、`testing.T`类型和`testing.B`类型的常用方法、`go test`命令的基本使用方式、常规测试结果的解读等等。 在本篇文章,我会继续为你讲解更多更高级的测试方法。这会涉及`testing`包中更多的 API、`go test`命令支持的,更多标记更加复杂的测试结果,以及测试覆盖度分析等等。 续接前文。我在前面提到了`go test`命令的标记`-cpu`,它是用来设置测试执行最大 P 数量的列表的。 复习一下,我在讲 go 语句的时候说过,这里的 P 是 processor 的缩写,每个 processor 都是一个可以承载若干个 G,且能够使这些 G 适时地与 M 进行对接并得到真正运行的中介。 正是由于 P 的存在,G 和 M 才可以呈现出多对多的关系,并能够及时、灵活地进行组合和分离。 这里的 G 就是 goroutine 的缩写,可以被理解为 Go 语言自己实现的用户级线程。M 即为 machine 的缩写,代表着系统级线程,或者说操作系统内核级别的线程。 Go 语言并发编程模型中的 P,正是 goroutine 的数量能够数十万计的关键所在。P 的数量意味着 Go 程序背后的运行时系统中,会有多少个用于承载可运行的 G 的队列存在。每一个队列都相当于一条流水线,它会源源不断地把可运行的 G 输送给空闲的 M,并使这两者对接。 一旦对接完成,被对接的 G 就真正地运行在操作系统的内核级线程之上了。每条流水线之间虽然会有联系,但都是独立运作的。 因此,最大 P 数量就代表着 Go 语言运行时系统同时运行 goroutine 的能力,也可以被视为其中逻辑 CPU 的最大个数。 而`go test`命令的`-cpu`标记正是用于设置这个最大个数的。也许你已经知道,在默认情况下,最大 P 数量就等于当前计算机 CPU 核心的实际数量。 当然了,前者也可以大于或者小于后者,如此可以在一定程度上模拟拥有不同的 CPU 核心数的计算机。 所以,也可以说,使用`-cpu`标记可以模拟被测程序在计算能力不同计算机中的表现。 现在,你已经知道了`-cpu`标记的用途及其背后的含义。那么它的具体用法,以及对`go test`命令的影响你是否也清楚呢? **我们今天的问题是:怎样设置**`**-cpu**`**标记的值,以及它会对测试流程产生什么样的影响?** **这里的典型回答是:** 标记`-cpu`的值应该是一个正整数的列表,该列表的表现形式为:以英文半角逗号分隔的多个整数字面量,比如`1,2,4`。 针对于此值中的每一个正整数,`go test`命令都会先设置最大 P 数量为该数,然后再执行测试函数。 如果测试函数有多个,那么`go test`命令会依照此方式逐个执行。 > 以`1,2,4`为例,`go test`命令会先以`1`,`2`,`4`为最大 P 数量分别去执行第一个测试函数,之后再用同样的方式执行第二个测试函数,以此类推。 ### [](https://jums.gitbook.io/36-lectures-on-golang/25-geng-duo-de-ce-shi-shou-fa#wen-ti-jie-xi) 问题解析 实际上,不论我们是否追加了`-cpu`标记,`go test`命令执行测试函数时流程都是相同的,只不过具体执行步骤会略有不同。 `go test`命令在进行准备工作的时候会读取`-cpu`标记的值,并把它转换为一个以`int`为元素类型的切片,我们也可以称它为逻辑 CPU 切片。 如果该命令发现我们并没有追加这个标记,那么就会让逻辑 CPU 切片只包含一个元素值,即最大 P 数量的默认值,也就是当前计算机 CPU 核心的实际数量。 在准备执行某个测试函数的时候,无论该函数是功能测试函数,还是性能测试函数,`go test`命令都会迭代逻辑 CPU 切片,并且在每次迭代时,先依据当前的元素值设置最大 P 数量,然后再去执行测试函数。 注意,对于性能测试函数来说,这里可能不只执行了一次。你还记得测试函数的执行时间上限,以及那个由`b.N`代表的被测程序的执行次数吗? 如果你忘了,那么可以再复习一下上篇文章中的第二个扩展问题。概括来讲,`go test`命令每一次对性能测试函数的执行,都是一个探索的过程。它会在测试函数的执行时间上限不变的前提下,尝试找到被测程序的最大执行次数。 在这个过程中,性能测试函数可能会被执行多次。为了以后描述方便,我们把这样一个探索的过程称为:对性能测试函数的一次探索式执行,这其中包含了对该函数的若干次执行,当然,肯定也包括了对被测程序更多次的执行。 说到多次执行测试函数,我们就不得不提及另外一个标记,即`-count`。`-count`标记是专门用于重复执行测试函数的。它的值必须大于或等于`0`,并且默认值为`1`。 如果我们在运行`go test`命令的时候追加了`-count 5`,那么对于每一个测试函数,命令都会在预设的不同条件下(比如不同的最大 P 数量下)分别重复执行五次。 如果我们把前文所述的`-cpu`标记、`-count`标记,以及探索式执行联合起来看,就可以用一个公式来描述单个性能测试函数,在`go test`命令的一次运行过程中的执行次数,即: 复制 性能测试函数的执行次数 = `-cpu`标记的值中正整数的个数 x `-count`标记的值 x 探索式执行中测试函数的实际执行次数 对于功能测试函数来说,这个公式会更加简单一些,即: 复制 功能测试函数的执行次数 = `-cpu`标记的值中正整数的个数 x `-count`标记的值 看完了这两个公式,我想,你也许遇到过这种情况,在对 Go 程序执行某种自动化测试的过程中,测试日志会显得特别多,而且好多都是重复的。 这时,我们首先就应该想到,上面这些导致测试函数多次执行的标记和流程。我们往往需要检查这些标记的使用是否合理、日志记录是否有必要等等,从而对测试日志进行精简。 比如,对于功能测试函数来说,我们通常没有必要重复执行它,即使是在不同的最大 P 数量下也是如此。注意,这里所说的重复执行指的是,在被测程序的输入(比如说被测函数的参数值)相同情况下的多次执行。 有些时候,在输入完全相同的情况下,被测程序会因其他外部环境的不同,而表现出不同的行为。这时我们需要考虑的往往应该是:这个程序在设计上是否合理,而不是通过重复执行测试来检测风险。 还有些时候,我们的程序会无法避免地依赖一些外部环境,比如数据库或者其他服务。这时,我们依然不应该让测试的反复执行成为检测手段,而应该在测试中通过仿造(mock)外部环境,来规避掉它们的不确定性。 其实,单元测试的意思就是:对单一的功能模块进行边界清晰的测试,并且不掺杂任何对外部环境的检测。这也是“单元”二字要表达的主要含义。 正好相反,对于性能测试函数来说,我们常常需要反复地执行,并以此试图抹平当时的计算资源调度的细微差别对被测程序性能的影响。通过`-cpu`标记,我们还能够模拟被测程序在计算能力不同计算机中的性能表现。 不过要注意,这里设置的最大 P 数量,最好不要超过当前计算机 CPU 核心的实际数量。因为一旦超出计算机实际的并行处理能力,Go 程序在性能上就无法再得到显著地提升了。 这就像一个漏斗,不论我们怎样灌水,水的漏出速度总是有限的。更何况,为了管理过多的 P,Go 语言运行时系统还会耗费额外的计算资源。 显然,上述模拟得出的程序性能一定是不准确的。不过,这或多或少可以作为一个参考,因为,这样模拟出的性能一般都会低于程序在计算环境中的实际性能。 好了,关于`-cpu`标记,以及由此引出的`-count`标记和测试函数多次执行的问题,我们就先聊到这里。不过,为了让你再巩固一下前面的知识,我现在给出一段测试结果: 复制 pkg: puzzlers/article21/q1 BenchmarkGetPrimesWith100-2 10000000 218 ns/op BenchmarkGetPrimesWith100-2 10000000 215 ns/op BenchmarkGetPrimesWith100-4 10000000 215 ns/op BenchmarkGetPrimesWith100-4 10000000 216 ns/op BenchmarkGetPrimesWith10000-2 50000 31523 ns/op BenchmarkGetPrimesWith10000-2 50000 32372 ns/op BenchmarkGetPrimesWith10000-4 50000 32065 ns/op BenchmarkGetPrimesWith10000-4 50000 31936 ns/op BenchmarkGetPrimesWith1000000-2 300 4085799 ns/op BenchmarkGetPrimesWith1000000-2 300 4121975 ns/op BenchmarkGetPrimesWith1000000-4 300 4112283 ns/op BenchmarkGetPrimesWith1000000-4 300 4086174 ns/op 现在,我希望让你反推一下,我在运行`go test`命令时追加的`-cpu`标记和`-count`标记的值都是什么。反推之后,你可以用实验的方式进行验证。 ### [](https://jums.gitbook.io/36-lectures-on-golang/25-geng-duo-de-ce-shi-shou-fa#zhi-shi-kuo-zhan) 知识扩展 #### [](https://jums.gitbook.io/36-lectures-on-golang/25-geng-duo-de-ce-shi-shou-fa#wen-ti-1parallel-biao-ji-de-zuo-yong-shi-shen-me) 问题 1:`-parallel`标记的作用是什么? 我们在运行`go test`命令的时候,可以追加标记`-parallel`,该标记的作用是:设置同一个被测代码包中的功能测试函数的最大并发执行数。该标记的默认值是测试运行时的最大 P 数量(这可以通过调用表达式`runtime.GOMAXPROCS(0)`获得)。 我在上篇文章中已经说过,对于功能测试,为了加快测试速度,命令通常会并发地测试多个被测代码包。 但是,在默认情况下,对于同一个被测代码包中的多个功能测试函数,命令会串行地执行它们。除非我们在一些功能测试函数中显式地调用`t.Parallel`方法。 这个时候,这些包含了`t.Parallel`方法调用的功能测试函数就会被`go test`命令并发地执行,而并发执行的最大数量正是由`-parallel`标记值决定的。不过要注意,同一个功能测试函数的多次执行之间一定是串行的。 你可以运行命令`go test -v puzzlers/article21/q2`或者`go test -count=2 -v puzzlers/article21/q2`,查看测试结果,然后仔细地体会一下。 最后,强调一下,`-parallel`标记对性能测试是无效的。当然了,对于性能测试来说,也是可以并发进行的,不过机制上会有所不同。 概括地讲,这涉及了`b.RunParallel`方法、`b.SetParallelism`方法和`-cpu`标记的联合运用。如果想进一步了解,你可以查看`testing`代码包的文档。([https://golang.google.cn/pkg/testing)](https://golang.google.cn/pkg/testing%EF%BC%89) #### [](https://jums.gitbook.io/36-lectures-on-golang/25-geng-duo-de-ce-shi-shou-fa#wen-ti-2-xing-neng-ce-shi-han-shu-zhong-de-ji-shi-qi-shi-zuo-shen-me-yong-de) 问题 2:性能测试函数中的计时器是做什么用的? 如果你看过`testing`包的文档,那么很可能会发现其中的`testing.B`类型有这么几个指针方法:`StartTimer`、`StopTimer`和`ResetTimer`。这些方法都是用于操作当前的性能测试函数专属的计时器的。 所谓的计时器,是一个逻辑上的概念,它其实是`testing.B`类型中一些字段的统称。这些字段用于记录:当前测试函数在当次执行过程中耗费的时间、分配的堆内存的字节数以及分配次数。 我在下面会以测试函数的执行时间为例,来说明此计时器的用法。不过,你需要知道的是,这三个方法在开始记录、停止记录或重新记录执行时间的同时,也会对堆内存分配字节数和分配次数的记录起到相同的作用。 实际上,`go test`命令本身就会用到这样的计时器。当准备执行某个性能测试函数的时候,命令会重置并启动该函数专属的计时器。一旦这个函数执行完毕,命令又会立即停止这个计时器。 如此一来,命令就能够准确地记录下(我们在前面多次提到的)测试函数执行时间了。然后,命令就会将这个时间与执行时间上限进行比较,并决定是否在改大`b.N`的值之后,再次执行测试函数。 还记得吗?这就是我在前面讲过的,对性能测试函数的探索式执行。显然,如果我们在测试函数中自行操作这个计时器,就一定会影响到这个探索式执行的结果。也就是说,这会让命令找到被测程序的最大执行次数有所不同。 请看在 demo57\_test.go 文件中的那个性能测试函数,如下所示: 复制 func BenchmarkGetPrimes(b *testing.B) { b.StopTimer() time.Sleep(time.Millisecond * 500) // 模拟某个耗时但与被测程序关系不大的操作。 max := 10000 b.StartTimer() for i := 0; i < b.N; i++ { GetPrimes(max) } } 需要注意的是该函数体中的前四行代码。我先停止了当前测试函数的计时器,然后通过调用`time.Sleep`函数,模拟了一个比较耗时的额外操作,并且在给变量`max`赋值之后又启动了该计时器。 你可以想象一下,我们需要耗费额外的时间去确定`max`变量的值,虽然在后面它会被传入`GetPrimes`函数,但是,针对`GetPrimes`函数本身的性能测试并不应该包含确定参数值的过程。 因此,我们需要把这个过程所耗费的时间,从当前测试函数的执行时间中去除掉。这样就能够避免这一过程对测试结果的不良影响了。 每当这个测试函数执行完毕后,`go test`命令拿到的执行时间都只应该包含调用`GetPrimes`函数所耗费的那些时间。只有依据这个时间做出的后续判断,以及找到被测程序的最大执行次数才是准确的。 在性能测试函数中,我们可以通过对`b.StartTimer`和`b.StopTimer`方法的联合运用,再去除掉任何一段代码的执行时间。 相比之下,`b.ResetTimer`方法的灵活性就要差一些了,它只能用于:去除在调用它之前那些代码的执行时间。不过,无论在调用它的时候,计时器是不是正在运行,它都可以起作用。 ### [](https://jums.gitbook.io/36-lectures-on-golang/25-geng-duo-de-ce-shi-shou-fa#zong-jie) 总结 在本篇文章中,我假设你已经理解了上一篇文章涉及的内容。因此,我在这里围绕着几个可以被`go test`命令接受的重要标记,进一步地阐释了功能测试和性能测试在不同条件下的测试流程。 其中,比较重要的有最大 P 数量的含义,`-cpu`标记的作用及其对测试流程的影响,针对性能测试函数的探索式执行的意义,测试函数执行时间的计算方法,以及`-count`标记的用途和适用场景。 当然了,学会怎样并发地执行多个功能测试函数也是很有必要的。这需要联合运用`-parallel`标记和功能测试函数中的`t.Parallel`方法。 另外,你还需要知道性能测试函数专属计时器的内涵,以及那三个方法对计时器起到的作用。通过对计时器的操作,我们可以达到精确化性能测试函数的执行时间的目的,从而帮助`go test`命令找到被测程序真实的最大执行次数。 到这里,我们对 Go 程序测试的讨论就要告一段落了。我们需要搞清楚的是,`go test`命令所执行的基本测试流程是什么,以及我们可以通过什么样的手段让测试流程产生变化,从而满足我们的测试需求并为我们提供更加充分的测试结果。 希望你已经从中学到了一些东西,并能够学以致用。 思考题 `-benchmem`标记和`-benchtime`标记的作用分别是什么? 怎样在测试的时候开启测试覆盖度分析?如果开启,会有什么副作用吗? 关于这两个问题,你都可以参考官方的[go 命令文档中的测试标记部分进行](https://golang.google.cn/cmd/go/#hdr-Testing_flags) 回答。 [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页24 | 测试的基本规则和流程(下)](https://jums.gitbook.io/36-lectures-on-golang/24-ce-shi-de-ji-ben-gui-ze-he-liu-cheng-xia) [下一页26 | sync.Mutex与sync.RWMutex](https://jums.gitbook.io/36-lectures-on-golang/26-sync.mutex-yu-sync.rwmutex) 最后更新于2年前 这有帮助吗? --- # 08 | container包中的那些容器 | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252Fq3s69lSQM78YgzrD0tcM%252Fimage.png%3Falt%3Dmedia%26token%3D2eae766a-d1b9-42d9-b616-132cebd33034&width=768&dpr=4&quality=100&sign=729d9681&sv=2) #### [](https://jums.gitbook.io/36-lectures-on-golang/08-container-bao-zhong-de-na-xie-rong-qi#go-yu-yan-dai-ma-jiao-duo-jian-yi-pei-he-wen-zhang-shou-ting-yin-pin) 【Go 语言代码较多,建议配合文章收听音频。】 [6MB\ \ 8.container包中的那些容器.mp3](https://2852705636-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FkmtyDvkosJZE1Rr33XGZ%2Fuploads%2FUsVrq4JpYeXBEc8fq4Qg%2F8.container%E5%8C%85%E4%B8%AD%E7%9A%84%E9%82%A3%E4%BA%9B%E5%AE%B9%E5%99%A8.mp3?alt=media&token=9e55cf85-a562-4872-abb2-1460d0610497) 我们在上次讨论了数组和切片,当我们提到数组的时候,往往会想起链表。Go 语言的链表实现在标准库的`container/list`代码包中。 这个代码包中有两个公开的程序实体——`List`和`Element`,List 实现了一个双向链表(以下简称链表),而 Element 则代表了链表中元素的结构。 **那么,我今天的问题是:可以把自己生成的**`**Element**`**类型值传给链表吗?** 我们在这里用到了`List`的四种方法。 `MoveBefore`方法和`MoveAfter`方法,它们分别用于把给定的元素移动到另一个元素的前面和后面。 `MoveToFront`方法和`MoveToBack`方法,分别用于把给定的元素移动到链表的最前端和最后端。 在这些方法中,“给定的元素”都是`*Element`类型的,`*Element`类型是`Element`类型的指针类型,`*Element`的值就是元素的指针。 复制 func (l *List) MoveBefore(e, mark *Element) func (l *List) MoveAfter(e, mark *Element) func (l *List) MoveToFront(e *Element) func (l *List) MoveToBack(e *Element) 具体问题是,如果我们自己生成这样的值,然后把它作为“给定的元素”传给链表的方法,那么会发生什么?链表会接受它吗? 这里,给出一个**典型回答**:不会接受,这些方法将不会对链表做出任何改动。因为我们自己生成的`Element`值并不在链表中,所以也就谈不上“在链表中移动元素”。更何况链表不允许我们把自己生成的`Element`值插入其中。 ### [](https://jums.gitbook.io/36-lectures-on-golang/08-container-bao-zhong-de-na-xie-rong-qi#wen-ti-jie-xi) 问题解析 在`List`包含的方法中,用于插入新元素的那些方法都只接受`interface{}`类型的值。这些方法在内部会使用`Element`值,包装接收到的新元素。 这样做正是为了避免直接使用我们自己生成的元素,主要原因是避免链表的内部关联,遭到外界破坏,这对于链表本身以及我们这些使用者来说都是有益的。 `List`的方法还有下面这几种: `Front`和`Back`方法分别用于获取链表中最前端和最后端的元素, `InsertBefore`和`InsertAfter`方法分别用于在指定的元素之前和之后插入新元素,`PushFront`和`PushBack`方法则分别用于在链表的最前端和最后端插入新元素。 复制 func (l *List) Front() *Element func (l *List) Back() *Element func (l *List) InsertBefore(v interface{}, mark *Element) *Element func (l *List) InsertAfter(v interface{}, mark *Element) *Element func (l *List) PushFront(v interface{}) *Element func (l *List) PushBack(v interface{}) *Element 这些方法都会把一个`Element`值的指针作为结果返回,它们就是链表留给我们的安全“接口”。拿到这些内部元素的指针,我们就可以去调用前面提到的用于移动元素的方法了。 **知识扩展** **1\. 问题:为什么链表可以做到开箱即用?** `List`和`Element`都是结构体类型。结构体类型有一个特点,那就是它们的零值都会是拥有特定结构,但是没有任何定制化内容的值,相当于一个空壳。值中的字段也都会被分别赋予各自类型的零值。 > 广义来讲,所谓的零值就是只做了声明,但还未做初始化的变量被给予的缺省值。每个类型的零值都会依据该类型的特性而被设定。 > > 比如,经过语句`var a [2]int`声明的变量`a`的值,将会是一个包含了两个`0`的整数数组。又比如,经过语句`var s []int`声明的变量`s`的值将会是一个`[]int`类型的、值为`nil`的切片。 那么经过语句`var l list.List`声明的变量`l`的值将会是什么呢?\[1\] 这个零值将会是一个长度为`0`的链表。这个链表持有的根元素也将会是一个空壳,其中只会包含缺省的内容。那这样的链表我们可以直接拿来使用吗? 答案是,可以的。这被称为“开箱即用”。Go 语言标准库中很多结构体类型的程序实体都做到了开箱即用。这也是在编写可供别人使用的代码包(或者说程序库)时,我们推荐遵循的最佳实践之一。那么,语句`var l list.List`声明的链表`l`可以直接使用,这是怎么做到的呢? 关键在于它的“延迟初始化”机制。 所谓的**延迟初始化**,你可以理解为把初始化操作延后,仅在实际需要的时候才进行。延迟初始化的优点在于“延后”,它可以分散初始化操作带来的计算量和存储空间消耗。 例如,如果我们需要集中声明非常多的大容量切片的话,那么那时的 CPU 和内存空间的使用量肯定都会一个激增,并且只有设法让其中的切片及其底层数组被回收,内存使用量才会有所降低。 如果数组是可以被延迟初始化的,那么计算量和存储空间的压力就可以被分散到实际使用它们的时候。这些数组被实际使用的时间越分散,延迟初始化带来的优势就会越明显。 > 实际上,Go 语言的切片就起到了延迟初始化其底层数组的作用,你可以想一想为什么会这么说的理由。 > > 延迟初始化的缺点恰恰也在于“延后”。你可以想象一下,如果我在调用链表的每个方法的时候,它们都需要先去判断链表是否已经被初始化,那这也会是一个计算量上的浪费。在这些方法被非常频繁地调用的情况下,这种浪费的影响就开始显现了,程序的性能将会降低。 在这里的链表实现中,一些方法是无需对是否初始化做判断的。比如`Front`方法和`Back`方法,一旦发现链表的长度为`0`, 直接返回`nil`就好了。 又比如,在用于删除元素、移动元素,以及一些用于插入元素的方法中,只要判断一下传入的元素中指向所属链表的指针,是否与当前链表的指针相等就可以了。 如果不相等,就一定说明传入的元素不是这个链表中的,后续的操作就不用做了。反之,就一定说明这个链表已经被初始化了。 原因在于,链表的`PushFront`方法、`PushBack`方法、`PushBackList`方法以及`PushFrontList`方法总会先判断链表的状态,并在必要时进行初始化,这就是延迟初始化。 而且,我们在向一个空的链表中添加新元素的时候,肯定会调用这四个方法中的一个,这时新元素中指向所属链表的指针,一定会被设定为当前链表的指针。所以,指针相等是链表已经初始化的充分必要条件。 明白了吗?`List`利用了自身以及`Element`在结构上的特点,巧妙地平衡了延迟初始化的优缺点,使得链表可以开箱即用,并且在性能上可以达到最优。 **问题 2:**`**Ring**`**与**`**List**`**的区别在哪儿?** `container/ring`包中的`Ring`类型实现的是一个循环链表,也就是我们俗称的环。其实`List`在内部就是一个循环链表。它的根元素永远不会持有任何实际的元素值,而该元素的存在就是为了连接这个循环链表的首尾两端。 所以也可以说,`List`的零值是一个只包含了根元素,但不包含任何实际元素值的空链表。那么,既然`Ring`和`List`在本质上都是循环链表,那它们到底有什么不同呢? 最主要的不同有下面几种。 1. `Ring`类型的数据结构仅由它自身即可代表,而`List`类型则需要由它以及`Element`类型联合表示。这是表示方式上的不同,也是结构复杂度上的不同。 2. 一个`Ring`类型的值严格来讲,只代表了其所属的循环链表中的一个元素,而一个`List`类型的值则代表了一个完整的链表。这是表示维度上的不同。 3. 在创建并初始化一个`Ring`值的时候,我们可以指定它包含的元素的数量,但是对于一个`List`值来说却不能这样做(也没有必要这样做)。循环链表一旦被创建,其长度是不可变的。这是两个代码包中的`New`函数在功能上的不同,也是两个类型在初始化值方面的第一个不同。 4. 仅通过`var r ring.Ring`语句声明的`r`将会是一个长度为`1`的循环链表,而`List`类型的零值则是一个长度为`0`的链表。别忘了`List`中的根元素不会持有实际元素值,因此计算长度时不会包含它。这是两个类型在初始化值方面的第二个不同。 5. `Ring`值的`Len`方法的算法复杂度是 O(N) 的,而`List`值的`Len`方法的算法复杂度则是 O(1) 的。这是两者在性能方面最显而易见的差别。 其他的不同基本上都是方法方面的了。比如,循环链表也有用于插入、移动或删除元素的方法,不过用起来都显得更抽象一些,等等。 **总结** 我们今天主要讨论了`container/list`包中的链表实现。我们详细讲解了链表的一些主要的使用技巧和实现特点。由于此链表实现在内部就是一个循环链表,所以我们还把它与`container/ring`包中的循环链表实现做了一番比较,包括结构、初始化以及性能方面。 **思考题** 1. `container/ring`包中的循环链表的适用场景都有哪些? 2. 你使用过`container/heap`包中的堆吗?它的适用场景又有哪些呢? 在这里,我们先不求对它们的实现了如指掌,能用对、用好才是我们进阶之前的第一步。好了,感谢你的收听,我们下次再见。 * * * \[1\]:`List`这个结构体类型有两个字段,一个是`Element`类型的字段`root`,另一个是`int`类型的字段`len`。顾名思义,前者代表的就是那个根元素,而后者用于存储链表的长度。注意,它们都是包级私有的,也就是说使用者无法查看和修改它们。 像前面那样声明的`l`,其字段`root`和`len`都会被赋予相应的零值。`len`的零值是`0`,正好可以表明该链表还未包含任何元素。由于`root`是`Element`类型的,所以它的零值就是该类型的空壳,用字面量表示的话就是`Element{}`。 `Element`类型包含了几个包级私有的字段,分别用于存储前一个元素、后一个元素以及所属链表的指针值。另外还有一个名叫`Value`的公开的字段,该字段的作用就是持有元素的实际值,它是`interface{}`类型的。在`Element`类型的零值中,这些字段的值都会是`nil`。 ### [](https://jums.gitbook.io/36-lectures-on-golang/08-container-bao-zhong-de-na-xie-rong-qi#can-kao-yue-du) 参考阅读 #### [](https://jums.gitbook.io/36-lectures-on-golang/08-container-bao-zhong-de-na-xie-rong-qi#qie-pian-yu-shu-zu-de-bi-jiao) 切片与数组的比较 切片本身有着占用内存少和创建便捷等特点,但它的本质上还是数组。切片的一大好处是可以让我们通过窗口快速地定位并获取,或者修改底层数组中的元素。 不过,当我们想删除切片中的元素的时候就没那么简单了。元素复制一般是免不了的,就算只删除一个元素,有时也会造成大量元素的移动。这时还要注意空出的元素槽位的“清空”,否则很可能会造成内存泄漏。 另一方面,在切片被频繁“扩容”的情况下,新的底层数组会不断产生,这时内存分配的量以及元素复制的次数可能就很可观了,这肯定会对程序的性能产生负面的影响。 尤其是当我们没有一个合理、有效的”缩容“策略的时候,旧的底层数组无法被回收,新的底层数组中也会有大量无用的元素槽位。过度的内存浪费不但会降低程序的性能,还可能会使内存溢出并导致程序崩溃。 由此可见,正确地使用切片是多么的重要。不过,一个更重要的事实是,任何数据结构都不是银弹。不是吗?数组的自身特点和适用场景都非常鲜明,切片也是一样。它们都是 Go 语言原生的数据结构,使用起来也都很方便. 不过,你的集合类工具箱中不应该只有它们。这就是我们使用链表的原因。 不过,对比来看,一个链表所占用的内存空间,往往要比包含相同元素的数组所占内存大得多。这是由于链表的元素并不是连续存储的,所以相邻的元素之间需要互相保存对方的指针。不但如此,每个元素还要存有它所属链表的指针。 有了这些关联,链表的结构反倒更简单了。它只持有头部元素(或称为根元素)基本上就可以了。当然了,为了防止不必要的遍历和计算,链表的长度记录在内也是必须的。 [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页07 | 数组和切片](https://jums.gitbook.io/36-lectures-on-golang/07-shu-zu-he-qie-pian) [下一页09 | 字典的操作和约束](https://jums.gitbook.io/36-lectures-on-golang/09-zi-dian-de-cao-zuo-he-yue-shu) 最后更新于2年前 这有帮助吗? --- # 10 | 通道的基本操作 | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252FoxptpSov92PGUL51Tx08%252Fimage.png%3Falt%3Dmedia%26token%3Db798e4c3-5a60-40c7-98b9-68043fc8ed63&width=768&dpr=4&quality=100&sign=58f95954&sv=2) 【Go 语言代码较多,建议配合文章收听音频。】 [9MB\ \ 10.通道的基本操作.mp3](https://2852705636-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FkmtyDvkosJZE1Rr33XGZ%2Fuploads%2FexYX1ex74VHfJNKmFcTO%2F10.%E9%80%9A%E9%81%93%E7%9A%84%E5%9F%BA%E6%9C%AC%E6%93%8D%E4%BD%9C.mp3?alt=media&token=7fd4a77e-d95a-4926-b2fa-51c9b6565d3f) 作为 Go 语言最有特色的数据类型,通道(channel)完全可以与 goroutine(也可称为 go 程)并驾齐驱,共同代表 Go 语言独有的并发编程模式和编程哲学。 > Don’t communicate by sharing memory; share memory by communicating. (不要通过共享内存来通信,而应该通过通信来共享内存。) 这是作为 Go 语言的主要创造者之一的 Rob Pike 的至理名言,这也充分体现了 Go 语言最重要的编程理念。而通道类型恰恰是后半句话的完美实现,我们可以利用通道在多个 goroutine 之间传递数据。 通道类型的值本身就是并发安全的,这也是 Go 语言自带的、唯一一个可以满足并发安全性的类型。它使用起来十分简单,并不会徒增我们的心智负担。 在声明并初始化一个通道的时候,我们需要用到 Go 语言的内建函数`make`。就像用`make`初始化切片那样,我们传给这个函数的第一个参数应该是代表了通道的具体类型的类型字面量。 在声明一个通道类型变量的时候,我们首先要确定该通道类型的元素类型,这决定了我们可以通过这个通道传递什么类型的数据。 比如,类型字面量`chan int`,其中的`chan`是表示通道类型的关键字,而`int`则说明了该通道类型的元素类型。又比如,`chan string`代表了一个元素类型为`string`的通道类型。 在初始化通道的时候,`make`函数除了必须接收这样的类型字面量作为参数,还可以接收一个`int`类型的参数。 后者是可选的,用于表示该通道的容量。所谓通道的容量,就是指通道最多可以缓存多少个元素值。由此,虽然这个参数是`int`类型的,但是它是不能小于`0`的。 当容量为`0`时,我们可以称通道为非缓冲通道,也就是不带缓冲的通道。而当容量大于`0`时,我们可以称为缓冲通道,也就是带有缓冲的通道。非缓冲通道和缓冲通道有着不同的数据传递方式,这个我在后面会讲到。 一个通道相当于一个先进先出(FIFO)的队列。也就是说,通道中的各个元素值都是严格地按照发送的顺序排列的,先被发送通道的元素值一定会先被接收。元素值的发送和接收都需要用到操作符`<-`。我们也可以叫它接送操作符。一个左尖括号紧接着一个减号形象地代表了元素值的传输方向。 复制 package main import "fmt" func main() { ch1 := make(chan int, 3) ch1 <- 2 ch1 <- 1 ch1 <- 3 elem1 := <-ch1 fmt.Printf("The first element received from channel ch1: %v\n", elem1) } 在 demo20.go 文件中,我声明并初始化了一个元素类型为`int`、容量为`3`的通道`ch1`,并用三条语句,向该通道先后发送了三个元素值`2`、`1`和`3`。这里的语句需要这样写:依次敲入通道变量的名称(比如`ch1`)、接送操作符`<-`以及想要发送的元素值(比如`2`),并且这三者之间最好用空格进行分割。 这显然表达了“这个元素值将被发送该通道”这个语义。由于该通道的容量为 3,所以,我可以在通道不包含任何元素值的时候,连续地向该通道发送三个值,此时这三个值都会被缓存在通道之中。 当我们需要从通道接收元素值的时候,同样要用接送操作符`<-`,只不过,这时需要把它写在变量名的左边,用于表达“要从该通道接收一个元素值”的语义。 比如:`<-ch1`,这也可以被叫做接收表达式。在一般情况下,接收表达式的结果将会是通道中的一个元素值。 如果我们需要把如此得来的元素值存起来,那么在接收表达式的左边就需要依次添加赋值符号(`=`或`:=`)和用于存值的变量的名字。因此,语句`elem1 := <-ch1`会将最先进入`ch1`的元素`2`接收来并存入变量`elem1`。 现在我们来看一道与此有关的题目。**今天的问题是:对通道的发送和接收操作都有哪些基本的特性?** 这个问题的背后隐藏着很多的知识点,**我们来看一下典型回答**。 它们的基本特性如下。 1. 对于同一个通道,发送操作之间是互斥的,接收操作之间也是互斥的。 2. 发送操作和接收操作中对元素值的处理都是不可分割的。 3. 发送操作在完全完成之前会被阻塞。接收操作也是如此。 **问题解析** **我们先来看第一个基本特性。** 在同一时刻,Go 语言的运行时系统(以下简称运行时系统)只会执行对同一个通道的任意个发送操作中的某一个。 直到这个元素值被完全复制进该通道之后,其他针对该通道的发送操作才可能被执行。 类似的,在同一时刻,运行时系统也只会执行,对同一个通道的任意个接收操作中的某一个。 直到这个元素值完全被移出该通道之后,其他针对该通道的接收操作才可能被执行。即使这些操作是并发执行的也是如此。 这里所谓的并发执行,你可以这样认为,多个代码块分别在不同的 goroutine 之中,并有机会在同一个时间段内被执行。 另外,对于通道中的同一个元素值来说,发送操作和接收操作之间也是互斥的。例如,虽然会出现,正在被复制进通道但还未复制完成的元素值,但是这时它绝不会被想接收它的一方看到和取走。 **这里要注意的一个细节是,元素值从外界进入通道时会被复制。更具体地说,进入通道的并不是在接收操作符右边的那个元素值,而是它的副本。** 另一方面,元素值从通道进入外界时会被移动。这个移动操作实际上包含了两步,第一步是生成正在通道中的这个元素值的副本,并准备给到接收方,第二步是删除在通道中的这个元素值。 **顺着这个细节再来看第二个基本特性。** 这里的“不可分割”的意思是,它们处理元素值时都是一气呵成的,绝不会被打断。 例如,发送操作要么还没复制元素值,要么已经复制完毕,绝不会出现只复制了一部分的情况。 又例如,接收操作在准备好元素值的副本之后,一定会删除掉通道中的原值,绝不会出现通道中仍有残留的情况。 这既是为了保证通道中元素值的完整性,也是为了保证通道操作的唯一性。对于通道中的同一个元素值来说,它只可能是某一个发送操作放入的,同时也只可能被某一个接收操作取出。 **再来说第三个基本特性。** 一般情况下,发送操作包括了“复制元素值”和“放置副本到通道内部”这两个步骤。 在这两个步骤完全完成之前,发起这个发送操作的那句代码会一直阻塞在那里。也就是说,在它之后的代码不会有执行的机会,直到这句代码的阻塞解除。 更细致地说,在通道完成发送操作之后,运行时系统会通知这句代码所在的 goroutine,以使它去争取继续运行代码的机会。 另外,接收操作通常包含了“复制通道内的元素值”“放置副本到接收方”“删掉原值”三个步骤。 在所有这些步骤完全完成之前,发起该操作的代码也会一直阻塞,直到该代码所在的 goroutine 收到了运行时系统的通知并重新获得运行机会为止。 说到这里,你可能已经感觉到,**如此阻塞代码其实就是为了实现操作的互斥和元素值的完整。** 下面我来说一个关于通道操作阻塞的问题。 ### [](https://jums.gitbook.io/36-lectures-on-golang/10-tong-dao-de-ji-ben-cao-zuo#zhi-shi-kuo-zhan) 知识扩展 **问题 1:发送操作和接收操作在什么时候可能被长时间的阻塞?** 先说针对**缓冲通道**的情况。如果通道已满,那么对它的所有发送操作都会被阻塞,直到通道中有元素值被接收走。 这时,通道会优先通知最早因此而等待的、那个发送操作所在的 goroutine,后者会再次执行发送操作。 由于发送操作在这种情况下被阻塞后,它们所在的 goroutine 会顺序地进入通道内部的发送等待队列,所以通知的顺序总是公平的。 相对的,如果通道已空,那么对它的所有接收操作都会被阻塞,直到通道中有新的元素值出现。这时,通道会通知最早等待的那个接收操作所在的 goroutine,并使它再次执行接收操作。 因此而等待的、所有接收操作所在的 goroutine,都会按照先后顺序被放入通道内部的接收等待队列。 对于**非缓冲通道**,情况要简单一些。无论是发送操作还是接收操作,一开始执行就会被阻塞,直到配对的操作也开始执行,才会继续传递。由此可见,非缓冲通道是在用同步的方式传递数据。也就是说,只有收发双方对接上了,数据才会被传递。 并且,数据是直接从发送方复制到接收方的,中间并不会用非缓冲通道做中转。相比之下,缓冲通道则在用异步的方式传递数据。 在大多数情况下,缓冲通道会作为收发双方的中间件。正如前文所述,元素值会先从发送方复制到缓冲通道,之后再由缓冲通道复制给接收方。 但是,当发送操作在执行的时候发现空的通道中,正好有等待的接收操作,那么它会直接把元素值复制给接收方。 以上说的都是在正确使用通道的前提下会发生的事情。下面我特别说明一下,由于错误使用通道而造成的阻塞。 对于值为`nil`的通道,不论它的具体类型是什么,对它的发送操作和接收操作都会永久地处于阻塞状态。它们所属的 goroutine 中的任何代码,都不再会被执行。 注意,由于通道类型是引用类型,所以它的零值就是`nil`。换句话说,当我们只声明该类型的变量但没有用`make`函数对它进行初始化时,该变量的值就会是`nil`。我们一定不要忘记初始化通道! 你可以去看一下 demo21.go,我在里面用代码罗列了一下会造成阻塞的几种情况。 **问题 2:发送操作和接收操作在什么时候会引发 panic?** 对于一个已初始化,但并未关闭的通道来说,收发操作一定不会引发 panic。但是通道一旦关闭,再对它进行发送操作,就会引发 panic。 另外,如果我们试图关闭一个已经关闭了的通道,也会引发 panic。注意,接收操作是可以感知到通道的关闭的,并能够安全退出。 更具体地说,当我们把接收表达式的结果同时赋给两个变量时,第二个变量的类型就是一定`bool`类型。它的值如果为`false`就说明通道已经关闭,并且再没有元素值可取了。 注意,如果通道关闭时,里面还有元素值未被取出,那么接收表达式的第一个结果,仍会是通道中的某一个元素值,而第二个结果值一定会是`true`。 因此,通过接收表达式的第二个结果值,来判断通道是否关闭是可能有延时的。 由于通道的收发操作有上述特性,所以除非有特殊的保障措施,我们千万不要让接收方关闭通道,而应当让发送方做这件事。这在 demo22.go 中有一个简单的模式可供参考。 **总结** 今天我们讲到了通道的一些常规操作,包括初始化、发送、接收和关闭。通道类型是 Go 语言特有的,所以你一开始肯定会感到陌生,其中的一些规则和奥妙还需要你铭记于心,并细心体会。 首先是在初始化通道时设定其容量的意义,这有时会让通道拥有不同的行为模式。对通道的发送操作和接收操作都有哪些基本特性,也是我们必须清楚的。 这涉及了它们什么时候会互斥,什么时候会造成阻塞,什么时候会引起 panic,以及它们收发元素值的顺序是怎样的,它们是怎样保证元素值的完整性的,元素值通常会被复制几次,等等。 最后别忘了,通道也是 Go 语言的并发编程模式中重要的一员。 **思考题** 我希望你能通过试验获得下述问题的答案。 1. 通道的长度代表着什么?它在什么时候会通道的容量相同? 2. 元素值在经过通道传递时会被复制,那么这个复制是浅表复制还是深层复制呢? [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页09 | 字典的操作和约束](https://jums.gitbook.io/36-lectures-on-golang/09-zi-dian-de-cao-zuo-he-yue-shu) [下一页11 | 通道的高级玩法](https://jums.gitbook.io/36-lectures-on-golang/11-tong-dao-de-gao-ji-wan-fa) 最后更新于2年前 这有帮助吗? --- # 12 | 使用函数的正确姿势 | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252Fw80MT7FoHt3DdLclZZuM%252Fimage.png%3Falt%3Dmedia%26token%3Db5fc8ec0-0105-4a51-8d66-c244df38f721&width=768&dpr=4&quality=100&sign=c4cfe42&sv=2) [6MB\ \ 12.使用函数的正确姿势.mp3](https://2852705636-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FkmtyDvkosJZE1Rr33XGZ%2Fuploads%2F4WIiKm3YbrnOuCDvsCZu%2F12.%E4%BD%BF%E7%94%A8%E5%87%BD%E6%95%B0%E7%9A%84%E6%AD%A3%E7%A1%AE%E5%A7%BF%E5%8A%BF.mp3?alt=media&token=de8ae4c1-45b4-4ebc-9841-1edf0bd73729) 在前几期文章中,我们分了几次,把 Go 语言自身提供的,所有集合类的数据类型都讲了一遍,额外还讲了标准库的`container`包中的几个类型。 在几乎所有主流的编程语言中,集合类的数据类型都是最常用和最重要的。我希望通过这些天的讨论,能让你对它们的运用更上一层楼。 从今天开始,我会开始向你介绍使用 Go 语言进行模块化编程时,必须了解的知识,这包括几个重要的数据类型以及一些模块化编程的技巧。首先我们需要了解的是 Go 语言的函数以及函数类型。 在 Go 语言中,函数可是一等的(first-class)公民,函数类型也是一等的数据类型。这是什么意思呢? 简单来说,这意味着函数不但可以用于封装代码、分割功能、解耦逻辑,还可以化身为普通的值,在其他函数间传递、赋予变量、做类型判断和转换等等,就像切片和字典的值那样。 而更深层次的含义就是:函数值可以由此成为能够被随意传播的独立逻辑组件(或者说功能模块)。 对于函数类型来说,它是一种对一组输入、输出进行模板化的重要工具,它比接口类型更加轻巧、灵活,它的值也借此变成了可被热替换的逻辑组件。比如,我在 demo26.go 文件中是这样写的: 复制 package main import "fmt" type Printer func(contents string) (n int, err error) func printToStd(contents string) (bytesNum int, err error) { return fmt.Println(contents) } func main() { var p Printer p = printToStd p("something") } 这里,我先声明了一个函数类型,名叫`Printer`。注意这里的写法,在类型声明的名称右边的是`func`关键字,我们由此就可知这是一个函数类型的声明。在`func`右边的就是这个函数类型的参数列表和结果列表。 其中,参数列表必须由圆括号包裹,而只要结果列表中只有一个结果声明,并且没有为它命名,我们就可以省略掉外围的圆括号。 这里书写函数签名的方式与函数声明的是一致的。只是紧挨在参数列表左边的不是函数名称,而是关键字`func`。这里函数名称和`func`互换了一下位置而已。 函数的签名其实就是函数的参数列表和结果列表的统称,它定义了可用来鉴别不同函数的那些特征,同时也定义了我们与函数交互的方式。 注意,各个参数和结果的名称不能算作函数签名的一部分,甚至对于结果声明来说,没有名称都可以。 只要两个函数的参数列表和结果列表中的元素顺序及其类型是一致的,我们就可以说它们是一样的函数,或者说是实现了同一个函数类型的函数。 严格来说,函数的名称也不能算作函数签名的一部分,它只是我们在调用函数时,需要给定的标识符而已。 我在下面声明的函数`printToStd`的签名与`Printer`的是一致的,因此前者是后者的一个实现,即使它们的名称以及有的结果名称是不同的。 通过`main`函数中的代码,我们就可以证实这两者的关系了,我顺利地把`printToStd`函数赋给了`Printer`类型的变量`p`,并且成功地调用了它。 总之,“函数是一等的公民”是函数式编程(functional programming)的重要特征。Go 语言在语言层面支持了函数式编程。我们下面的问题就与此有关。**今天的问题是:怎样编写高阶函数?** 什么是高阶函数?简单地说,高阶函数可以满足下面的两个条件: 1. 接受其他的函数作为参数传入; 2. 把其他的函数作为结果返回。 只要满足了其中任意一个特点,我们就可以说这个函数是一个高阶函数。高阶函数也是函数式编程中的重要概念和特征。 具体的问题是,我想通过编写`calculate`函数来实现两个整数间的加减乘除运算,但是希望两个整数和具体的操作都由该函数的调用方给出,那么,这样一个函数应该怎样编写呢。 **典型回答** 首先,声明一个名叫`operate`的函数类型,它有两个参数和一个结果,都是`int`类型的。 复制 type operate func(x, y int) int 然后,编写`calculate`函数的签名部分。这个函数除了需要两个`int`类型的参数之外,还应该有一个`operate`类型的参数。 该函数的结果应该有两个,一个是`int`类型的,代表真正的操作结果,另一个应该是`error`类型的,因为如果那个`operate`类型的参数值为`nil`,那么就应该直接返回一个错误。 顺便说一下,函数类型属于引用类型,它的值可以为`nil`,而这种类型的零值恰恰就是`nil`。 复制 func calculate(x int, y int, op operate) (int, error) { if op == nil { return 0, errors.New("invalid operation") } return op(x, y), nil } `calculate`函数实现起来就很简单了。我们需要先用卫述语句检查一下参数,如果`operate`类型的参数`op`为`nil`,那么就直接返回`0`和一个代表了具体错误的`error`类型值。 如果检查无误,那么就调用`op`并把那两个操作数传给它,最后返回`op`返回的结果和代表没有错误发生的`nil`。 **问题解析** 首先提一句,答案中提到的卫述语句是指被用来检查关键的先决条件的合法性,并在检查未通过的情况下立即终止当前代码块的执行的语句。 其实只要你搞懂了“函数是一等的公民”这句话背后的含义,这道题就会很简单。我在上面已经讲过了,希望你已经清楚了。我在上一个例子中展示了其中一点,即:把函数作为一个普通的值赋给一个变量。 在这道题中,我问的其实是怎样实现另一点,即:让函数在其他函数间传递。 在答案中,`calculate`函数的其中一个参数是`operate`类型的,而且后者就是一个函数类型。在调用`calculate`函数的时候,我们需要传入一个`operate`类型的函数值。这个函数值应该怎么写? 只要它的签名与`operate`类型的签名一致,并且实现得当就可以了。我们可以像上一个例子那样先声明好一个函数,再把它赋给一个变量,也可以直接编写一个实现了`operate`类型的匿名函数。 复制 op := func(x, y int) int { return x + y } `calculate`函数就是一个高阶函数。但是我们说高阶函数的特点有两个,而该函数只展示了其中一个,即:接受其他的函数作为参数传入。 那另一个特点,把其他的函数作为结果返回,又是怎么玩的呢?你可以看看我在 demo27.go 文件中声明的函数类型`calculateFunc`和函数`genCalculator`。其中,`genCalculator`函数的唯一结果的类型就是`calculateFunc`。 这里先给出使用它们的代码。 复制 x, y = 56, 78 add := genCalculator(op) result, err = add(x, y) fmt.Printf("The result: %d (error: %v)\n", result, err) 你可以自己写出`calculateFunc`类型和`genCalculator`函数的实现吗?你可以动手试一试 **知识扩展** **问题 1:如何实现闭包?** 闭包又是什么?你可以想象一下,在一个函数中存在对外来标识符的引用。所谓的外来标识符,既不代表当前函数的任何参数或结果,也不是函数内部声明的,它是直接从外边拿过来的。 还有个专门的术语称呼它,叫自由变量,可见它代表的肯定是个变量。实际上,如果它是个常量,那也就形成不了闭包了,因为常量是不可变的程序实体,而闭包体现的却是由“不确定”变为“确定”的一个过程。 我们说的这个函数(以下简称闭包函数)就是因为引用了自由变量,而呈现出了一种“不确定”的状态,也叫“开放”状态。也就是说,它的内部逻辑并不是完整的,有一部分逻辑需要这个自由变量参与完成,而后者到底代表了什么在闭包函数被定义的时候却是未知的。 即使对于像 Go 语言这种静态类型的编程语言而言,我们在定义闭包函数的时候最多也只能知道自由变量的类型。 在我们刚刚提到的`genCalculator`函数内部,实际上就实现了一个闭包,而`genCalculator`函数也是一个高阶函数。 复制 func genCalculator(op operate) calculateFunc { return func(x int, y int) (int, error) { if op == nil { return 0, errors.New("invalid operation") } return op(x, y), nil } } `genCalculator`函数只做了一件事,那就是定义一个匿名的、`calculateFunc`类型的函数并把它作为结果值返回。 而这个匿名的函数就是一个闭包函数。它里面使用的变量`op`既不代表它的任何参数或结果也不是它自己声明的,而是定义它的`genCalculator`函数的参数,所以是一个自由变量。 这个自由变量究竟代表了什么,这一点并不是在定义这个闭包函数的时候确定的,而是在`genCalculator`函数被调用的时候确定的。 只有给定了该函数的参数`op`,我们才能知道它返回给我们的闭包函数可以用于什么运算。 看到`if op == nil {`那一行了吗?Go 语言编译器读到这里时会试图去寻找`op`所代表的东西,它会发现`op`代表的是`genCalculator`函数的参数,然后,它会把这两者联系起来。这时可以说,自由变量`op`被“捕获”了。 当程序运行到这里的时候,`op`就是那个参数值了。如此一来,这个闭包函数的状态就由“不确定”变为了“确定”,或者说转到了“闭合”状态,至此也就真正地形成了一个闭包。 看出来了吗?我们在用高阶函数实现闭包。这也是高阶函数的一大功用。 那么,实现闭包的意义又在哪里呢?表面上看,我们只是延迟实现了一部分程序逻辑或功能而已,但实际上,我们是在动态地生成那部分程序逻辑。我们可以借此在程序运行的过程中,根据需要生成功能不同的函数,继而影响后续的程序行为。这与 GoF 设计模式中的模板方法模式有着异曲同工之妙,不是吗? **问题 2:传入函数的那些参数值后来怎么样了?** 让我们把目光再次聚焦到函数本身。我们先看一个示例。 复制 package main import "fmt" func main() { array1 := [3]string{"a", "b", "c"} fmt.Printf("The array: %v\n", array1) array2 := modifyArray(array1) fmt.Printf("The modified array: %v\n", array2) fmt.Printf("The original array: %v\n", array1) } func modifyArray(a [3]string) [3]string { a[1] = "x" return a } 这个命令源码文件(也就是 demo28.go)在运行之后会输出什么?这是我常出的一道考题。 我在`main`函数中声明了一个数组`array1`,然后把它传给了函数`modify`,`modify`对参数值稍作修改后将其作为结果值返回。`main`函数中的代码拿到这个结果之后打印了它(即`array2`),以及原来的数组`array1`。关键问题是,原数组会因`modify`函数对参数值的修改而改变吗? 答案是:原数组不会改变。为什么呢?原因是,所有传给函数的参数值都会被复制,函数在其内部使用的并不是参数值的原值,而是它的副本。 由于数组是值类型,所以每一次复制都会拷贝它,以及它的所有元素值。我在`modify`函数中修改的只是原数组的副本而已,并不会对原数组造成任何影响。 注意,对于引用类型,比如:切片、字典、通道,像上面那样复制它们的值,只会拷贝它们本身而已,并不会拷贝它们引用的底层数据。也就是说,这时只是浅表复制,而不是深层复制。 以切片值为例,如此复制的时候,只是拷贝了它指向底层数组中某一个元素的指针,以及它的长度值和容量值,而它的底层数组并不会被拷贝。 另外还要注意,就算我们传入函数的是一个值类型的参数值,但如果这个参数值中的某个元素是引用类型的,那么我们仍然要小心。 比如: 复制 complexArray1 := [3][]string{ []string{"d", "e", "f"}, []string{"g", "h", "i"}, []string{"j", "k", "l"}, } 变量`complexArray1`是`[3][]string`类型的,也就是说,虽然它是一个数组,但是其中的每个元素又都是一个切片。这样一个值被传入函数的话,函数中对该参数值的修改会影响到`complexArray1`本身吗?我想,这可以留作今天的思考题。 **总结** 我们今天主要聚焦于函数的使用手法。在 Go 语言中,函数可是一等的(first-class)公民。它既可以被独立声明,也可以被作为普通的值来传递或赋予变量。除此之外,我们还可以在其他函数的内部声明匿名函数并把它直接赋给变量。 你需要记住 Go 语言是怎样鉴别一个函数的,函数的签名在这里起到了至关重要的作用。 函数是 Go 语言支持函数式编程的主要体现。我们可以通过“把函数传给函数”以及“让函数返回函数”来编写高阶函数,也可以用高阶函数来实现闭包,并以此做到部分程序逻辑的动态生成。 我们在最后还说了一下关于函数传参的一个注意事项,这很重要,可能会关系到程序的稳定和安全。一个相关的原则是:既不要把你程序的细节暴露给外界,也尽量不要让外界的变动影响到你的程序。你可以想想这个原则在这里可以起到怎样的指导作用。 **思考题** 今天我给你留下两道思考题。 1. `complexArray1`被传入函数的话,这个函数中对该参数值的修改会影响到它的原值吗? 2. 函数真正拿到的参数值其实只是它们的副本,那么函数返回给调用方的结果值也会被复制吗? [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页11 | 通道的高级玩法](https://jums.gitbook.io/36-lectures-on-golang/11-tong-dao-de-gao-ji-wan-fa) [下一页13 | 结构体及其方法的使用法门](https://jums.gitbook.io/36-lectures-on-golang/13-jie-gou-ti-ji-qi-fang-fa-de-shi-yong-fa-men) 最后更新于2年前 这有帮助吗? --- # 04 | 程序实体的那些事儿(上) | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252FPB6nR6XagEJyjxYq2qLM%252Fimage.png%3Falt%3Dmedia%26token%3D88a58998-3765-4c5a-9f18-0446458c1920&width=768&dpr=4&quality=100&sign=7345f87f&sv=2) #### [](https://jums.gitbook.io/36-lectures-on-golang/04-cheng-xu-shi-ti-de-na-xie-shi-er-shang#go-yu-yan-dai-ma-jiao-duo-jian-yi-pei-he-wen-zhang-shou-ting-yin-pin) 【Go 语言代码较多,建议配合文章收听音频。】 [8MB\ \ 4.程序实体的那些事儿(上).mp3](https://2852705636-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FkmtyDvkosJZE1Rr33XGZ%2Fuploads%2FIoWQq4y3qa9z9oMh22oH%2F4.%E7%A8%8B%E5%BA%8F%E5%AE%9E%E4%BD%93%E7%9A%84%E9%82%A3%E4%BA%9B%E4%BA%8B%E5%84%BF%EF%BC%88%E4%B8%8A%EF%BC%89.mp3?alt=media&token=db7a0a76-7f5d-4853-ab1b-14892292ec99) 我已经为你打开了 Go 语言编程之门,并向你展示了“程序从初建到拆分,再到模块化”的基本演化路径。 一个编程老手让程序完成基本演化,可能也就需要几十分钟甚至十几分钟,因为他们一开始就会把车开到模块化编程的道路上。我相信,等你真正理解了这个过程之后,也会驾轻就熟的。 上述套路是通用的,不是只适用于 Go 语言。但从本篇开始,我会开始向你介绍 Go 语言中的各种特性以及相应的编程方法和思想。 * * * 我在讲解那两种源码文件基本编写方法的时候,声明和使用了一些程序实体。你也许已经若有所觉,也许还在云里雾里。没关系,我现在就与你一起梳理这方面的重点。 还记得吗?**Go 语言中的程序实体包括变量、常量、函数、结构体和接口。** Go 语言是静态类型的编程语言,所以我们在声明变量或常量的时候,都需要指定它们的类型,或者给予足够的信息,这样才可以让 Go 语言能够推导出它们的类型。 > 在 Go 语言中,变量的类型可以是其预定义的那些类型,也可以是程序自定义的函数、结构体或接口。常量的合法类型不多,只能是那些 Go 语言预定义的基本类型。它的声明方式也更简单一些。 好了,下面这个简单的问题你需要了解一下。 ### [](https://jums.gitbook.io/36-lectures-on-golang/04-cheng-xu-shi-ti-de-na-xie-shi-er-shang#wen-ti-sheng-ming-bian-liang-you-ji-zhong-fang-shi) **问题:声明变量有几种方式?** 先看段代码。 复制 package main import ( "flag" "fmt" ) func main() { var name string // [1] flag.StringVar(&name, "name", "everyone", "The greeting object.") // [2] flag.Parse() fmt.Printf("Hello, %v!\n", name) } 这是一个很简单的命令源码文件,我把它命名为 demo7.go。它是 demo2.go 的微调版。我只是把变量`name`的声明和对`flag.StringVar`函数的调用,都移动到了`main`函数中,这分别对应代码中的注释`[1]`和`[2]`。 具体的问题是,除了`var name string`这种声明变量`name`的方式,还有其他方式吗?你可以选择性地改动注释`[1]`和`[2]`处的代码。 ### [](https://jums.gitbook.io/36-lectures-on-golang/04-cheng-xu-shi-ti-de-na-xie-shi-er-shang#dian-xing-hui-da) **典型回答** 这有几种做法,我在这里只说最典型的两种。 **第一种方式**需要先对注释`[2]`处的代码稍作改动,把被调用的函数由`flag.StringVar`改为`flag.String`,传参的列表也需要随之修改,这是为了`[1]`和`[2]`处代码合并的准备工作。 复制 var name = *flag.String("name", "everyone", "The greeting object.") 合并后的代码看起来更简洁一些。我把注释`[1]`处的代码中的`string`去掉了,右边添加了一个`=`,然后再拼接上经过修改的`[2]`处代码。 我在调用`flag.String`函数的代码左边加了一个`*`,这是因为该函数返回的结果值类型是`*string`而不是`string`。类型`*string`代表的是字符串的指针类型,而不是字符串类型。 关于 Go 语言中的指针,我在后面会有专门的介绍。你在这里只需要知道,我通过一个“`*`”把这个字符串指针值指向的字符串值取出来了,然后通过赋值符号“`=`”把后者赋给了`name`变量。 好了,我想你已经基本理解了这行代码中的每一个部分。 **下面我接着说第二种方式。**第二种方式与第一种方式非常类似,它基于第一种方式的代码,赋值符号`=`右边的代码不动,左边只留下`name`,再把`=`变成`:=`。 复制 name := *flag.String("name", "everyone", "The greeting object.") ### [](https://jums.gitbook.io/36-lectures-on-golang/04-cheng-xu-shi-ti-de-na-xie-shi-er-shang#wen-ti-jie-xi) **问题解析** 这个问题的基本考点有两个。**一个是你要知道 Go 语言中的类型推断,以及它在代码中的基本体现,另一个是短变量声明的用法。** 第一种方式中的代码在声明变量`name`的同时,还为它赋了值,而这时声明中并没有显式指定`name`的类型。 还记得吗?之前的变量声明语句是`var name string`。这里利用了 Go 语言自身的类型推断而省去了`string`。 > 简单地说,类型推断是一种编程语言在编译期自动解释表达式类型的能力。什么是表达式?详细的解释你可以参看 Go 语言规范中的[表达式](https://golang.google.cn/ref/spec#Expressions) > 和[表达式语句](https://golang.google.cn/ref/spec#Expression_statements) > 章节。我在这里就不赘述了。 你可以认为,表达式类型就是对表达式进行求值后得到结果的类型。Go 语言中的类型推断是很简约的,这也是 Go 语言整体的风格。 它只能用于对变量或常量的初始化,就像上述回答中描述的那样。对`flag.String`函数的调用其实就是一个调用表达式,而这个表达式的类型是`*string`,即字符串的指针类型。 这也是调用`flag.String`函数后得到结果的类型。随后,Go 语言把这个调用了`flag.String`函数的表达式类型,直接作为了变量`name`的类型,这就是“推断”一词所指代的操作了。 至于第二种方式所用的短变量声明,实际上就是 Go 语言的类型推断再加上一点点语法糖。 我们只能在函数体内部使用短变量声明。在编写`if`、`for`或`switch`语句的时候,我们经常把它安插在初始化子句中,并用来声明一些临时的变量。而相比之下,第一种方式更加通用,它可以被用在任何地方。 短变量声明还有其他的玩法,我稍后就会讲到。 ### [](https://jums.gitbook.io/36-lectures-on-golang/04-cheng-xu-shi-ti-de-na-xie-shi-er-shang#zhi-shi-kuo-zhan) **知识扩展** #### [](https://jums.gitbook.io/36-lectures-on-golang/04-cheng-xu-shi-ti-de-na-xie-shi-er-shang#id-1.-go-yu-yan-de-lei-xing-tui-duan-ke-yi-dai-lai-na-xie-hao-chu) **1\. Go 语言的类型推断可以带来哪些好处?** 如果面试官问你这个问题,你应该怎样回答? 当然,在写代码时,我们通过使用 Go 语言的类型推断,而节省下来的键盘敲击次数几乎可以忽略不计。但它真正的好处,往往会体现在我们写代码之后的那些事情上,比如代码重构。 为了更好的演示,我们先要做一点准备工作。我们依然通过调用一个函数在声明`name`变量的同时为它赋值,但是这个函数不是`flag.String`,而是由我们自己定义的某个函数,比如叫`getTheFlag`。 复制 package main import ( "flag" "fmt" ) func main() { var name = getTheFlag() flag.Parse() fmt.Printf("Hello, %v!\n", *name) } func getTheFlag() *string { return flag.String("name", "everyone", "The greeting object.") } 我们可以用`getTheFlag`函数包裹(或者说包装)那个对`flag.String`函数的调用,并把其结果直接作为`getTheFlag`函数的结果,结果的类型是`*string`。 这样一来,`var name =`右边的表达式,可以变为针对`getTheFlag`函数的调用表达式了。这实际上是对“声明并赋值`name`变量的那行代码”的重构。 > 我们通常把不改变某个程序与外界的任何交互方式和规则,而只改变其内部实现”的代码修改方式,叫做对该程序的重构。重构的对象可以是一行代码、一个函数、一个功能模块,甚至一个软件系统。 好了,在准备工作做完之后,你会发现,你可以随意改变`getTheFlag`函数的内部实现,及其返回结果的类型,而不用修改`main`函数中的任何代码。 这个命令源码文件依然可以通过编译,并且构建和运行也都不会有问题。也许你能感觉得到,这是一个关于程序灵活性的质变。 我们不显式地指定变量`name`的类型,使得它可以被赋予任何类型的值。也就是说,变量`name`的类型可以在其初始化时,由其他程序动态地确定。 在你改变`getTheFlag`函数的结果类型之后,Go 语言的编译器会在你再次构建该程序的时候,自动地更新变量`name`的类型。如果你使用过`Python`或`Ruby`这种动态类型的编程语言的话,一定会觉得这情景似曾相识。 没错,通过这种类型推断,你可以体验到动态类型编程语言所带来的一部分优势,即程序灵活性的明显提升。但在那些编程语言中,这种提升可以说是用程序的可维护性和运行效率换来的。 Go 语言是静态类型的,所以一旦在初始化变量时确定了它的类型,之后就不可能再改变。这就避免了在后面维护程序时的一些问题。另外,请记住,这种类型的确定是在编译期完成的,因此不会对程序的运行效率产生任何影响。 现在,你应该已经对这个问题有一个比较深刻的理解了。 如果只用一两句话回答这个问题的话,我想可以是这样的:Go 语言的类型推断可以明显提升程序的灵活性,使得代码重构变得更加容易,同时又不会给代码的维护带来额外负担(实际上,它恰恰可以避免散弹式的代码修改),更不会损失程序的运行效率。 #### [](https://jums.gitbook.io/36-lectures-on-golang/04-cheng-xu-shi-ti-de-na-xie-shi-er-shang#id-2.-bian-liang-de-zhong-sheng-ming-shi-shen-me-yi-si) **2\. 变量的重声明是什么意思?** 这涉及了短变量声明。通过使用它,我们可以对同一个代码块中的变量进行重声明。 既然说到了代码块,我先来解释一下它。在 Go 语言中,代码块一般就是一个由花括号括起来的区域,里面可以包含表达式和语句。Go 语言本身以及我们编写的代码共同形成了一个非常大的代码块,也叫全域代码块。 这主要体现在,只要是公开的全局变量,都可以被任何代码所使用。相对小一些的代码块是代码包,一个代码包可以包含许多子代码包,所以这样的代码块也可以很大。 接下来,每个源码文件也都是一个代码块,每个函数也是一个代码块,每个`if`语句、`for`语句、`switch`语句和`select`语句都是一个代码块。甚至,`switch`或`select`语句中的`case`子句也都是独立的代码块。 走个极端,我就在`main`函数中写一对紧挨着的花括号算不算一个代码块?当然也算,这甚至还有个名词,叫“空代码块”。 回到变量重声明的问题上。其含义是对已经声明过的变量再次声明。变量重声明的前提条件如下。 1. 由于变量的类型在其初始化时就已经确定了,所以对它再次声明时赋予的类型必须与其原本的类型相同,否则会产生编译错误。 2. 变量的重声明只可能发生在某一个代码块中。如果与当前的变量重名的是外层代码块中的变量,那么就是另外一种含义了,我在下一篇文章中会讲到。 3. 变量的重声明只有在使用短变量声明时才会发生,否则也无法通过编译。如果要在此处声明全新的变量,那么就应该使用包含关键字`var`的声明语句,但是这时就不能与同一个代码块中的任何变量有重名了。 4. 被“声明并赋值”的变量必须是多个,并且其中至少有一个是新的变量。这时我们才可以说对其中的旧变量进行了重声明。 这样来看,变量重声明其实算是一个语法糖(或者叫便利措施)。它允许我们在使用短变量声明时不用理会被赋值的多个变量中是否包含旧变量。可以想象,如果不这样会多写不少代码。 我把一个简单的例子写在了“Golang\_Puzzlers”项目的`puzzlers/article4/q3`包中的 demo9.go 文件中,你可以去看一下。 这其中最重要的两行代码如下: 复制 var err error n, err := io.WriteString(os.Stdout, "Hello, everyone!\n") 我使用短变量声明对新变量`n`和旧变量`err`进行了“声明并赋值”,这时也是对后者的重声明。 ### [](https://jums.gitbook.io/36-lectures-on-golang/04-cheng-xu-shi-ti-de-na-xie-shi-er-shang#zong-jie) **总结** 在本篇中,我们聚焦于最基本的 Go 语言程序实体:变量。并详细解说了变量声明和赋值的基本方法,及其背后的重要概念和知识。我们使用关键字`var`和短变量声明,都可以实现对变量的“声明并赋值”。 这两种方式各有千秋,有着各自的特点和适用场景。前者可以被用在任何地方,而后者只能被用在函数或者其他更小的代码块中。 不过,通过前者我们无法对已有的变量进行重声明,也就是说它无法处理新旧变量混在一起的情况。不过它们也有一个很重要的共同点,即:基于类型推断,Go 语言的类型推断只应用在了对变量或常量的初始化方面。 ### [](https://jums.gitbook.io/36-lectures-on-golang/04-cheng-xu-shi-ti-de-na-xie-shi-er-shang#si-kao-ti) **思考题** 本次的思考题只有一个:如果与当前的变量重名的是外层代码块中的变量,那么这意味着什么? 这道题对于你来说可能有些难,不过我鼓励你多做几次试验试试,你可以在代码中多写一些打印语句,然后运行它,并记录下每次试验的结果。如果有疑问也一定要写下来,答案将在下篇文章中揭晓。 [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页03 | 库源码文件](https://jums.gitbook.io/36-lectures-on-golang/03-ku-yuan-ma-wen-jian) [下一页05 | 程序实体的那些事儿(中)](https://jums.gitbook.io/36-lectures-on-golang/05-cheng-xu-shi-ti-de-na-xie-shi-er-zhong) 最后更新于2年前 这有帮助吗? --- # 28 | 条件变量sync.Cond (下) | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252FKqLmrBqBFXU5uuyZxExf%252Fimage.png%3Falt%3Dmedia%26token%3D33b5e58c-8d13-4144-a1c9-694473c8e3a5&width=768&dpr=4&quality=100&sign=81b13274&sv=2) #### [](https://jums.gitbook.io/36-lectures-on-golang/28-tiao-jian-bian-liang-sync.cond-xia#go-yu-yan-dai-ma-jiao-duo-jian-yi-pei-he-wen-zhang-shou-ting-yin-pin) 【Go 语言代码较多,建议配合文章收听音频。】 [4MB\ \ 28.条件变量sync.Cond (下).mp3](https://2852705636-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FkmtyDvkosJZE1Rr33XGZ%2Fuploads%2F337Ez0JMOhfgmvp5CXC6%2F28.%E6%9D%A1%E4%BB%B6%E5%8F%98%E9%87%8Fsync.Cond%20%EF%BC%88%E4%B8%8B%EF%BC%89.mp3?alt=media&token=ff368793-ce15-4498-8832-82962c4e186e) 你好,我是郝林,今天我继续分享条件变量 sync.Cond 的内容。我们紧接着上一篇的内容进行知识扩展。 ### [](https://jums.gitbook.io/36-lectures-on-golang/28-tiao-jian-bian-liang-sync.cond-xia#wen-ti-1-tiao-jian-bian-liang-de-wait-fang-fa-zuo-le-shen-me) 问题 1:条件变量的`Wait`方法做了什么? 在了解了条件变量的使用方式之后,你可能会有这么几个疑问。 1. 为什么先要锁定条件变量基于的互斥锁,才能调用它的`Wait`方法? 2. 为什么要用`for`语句来包裹调用其`Wait`方法的表达式,用`if`语句不行吗? 这些问题我在面试的时候也经常问。你需要对这个`Wait`方法的内部机制有所了解才能回答上来。 条件变量的`Wait`方法主要做了四件事。 1. 把调用它的 goroutine(也就是当前的 goroutine)加入到当前条件变量的通知队列中。 2. 解锁当前的条件变量基于的那个互斥锁。 3. 让当前的 goroutine 处于等待状态,等到通知到来时再决定是否唤醒它。此时,这个 goroutine 就会阻塞在调用这个`Wait`方法的那行代码上。 4. 如果通知到来并且决定唤醒这个 goroutine,那么就在唤醒它之后重新锁定当前条件变量基于的互斥锁。自此之后,当前的 goroutine 就会继续执行后面的代码了。 你现在知道我刚刚说的第一个疑问的答案了吗?因为条件变量的`Wait`方法在阻塞当前的 goroutine 之前会解锁它基于的互斥锁,所以在调用该`Wait`方法之前我们必须先锁定那个互斥锁,否则在调用这个`Wait`方法时,就会引发一个不可恢复的 panic。 为什么条件变量的`Wait`方法要这么做呢?你可以想象一下,如果`Wait`方法在互斥锁已经锁定的情况下,阻塞了当前的 goroutine,那么又由谁来解锁呢?别的 goroutine 吗? 先不说这违背了互斥锁的重要使用原则,即:成对的锁定和解锁,就算别的 goroutine 可以来解锁,那万一解锁重复了怎么办?由此引发的 panic 可是无法恢复的。 如果当前的 goroutine 无法解锁,别的 goroutine 也都不来解锁,那么又由谁来进入临界区,并改变共享资源的状态呢?只要共享资源的状态不变,即使当前的 goroutine 因收到通知而被唤醒,也依然会再次执行这个`Wait`方法,并再次被阻塞。 所以说,如果条件变量的`Wait`方法不先解锁互斥锁的话,那么就只会造成两种后果:不是当前的程序因 panic 而崩溃,就是相关的 goroutine 全面阻塞。 再解释第二个疑问。很显然,`if`语句只会对共享资源的状态检查一次,而`for`语句却可以做多次检查,直到这个状态改变为止。那为什么要做多次检查呢? 这主要是为了保险起见。如果一个 goroutine 因收到通知而被唤醒,但却发现共享资源的状态,依然不符合它的要求,那么就应该再次调用条件变量的`Wait`方法,并继续等待下次通知的到来。这种情况是很有可能发生的,具体如下面所示。 1. 有多个 goroutine 在等待共享资源的同一种状态。比如,它们都在等`mailbox`变量的值不为`0`的时候再把它的值变为`0`,这就相当于有多个人在等着我向信箱里放置情报。虽然等待的 goroutine 有多个,但每次成功的 goroutine 却只可能有一个。别忘了,条件变量的`Wait`方法会在当前的 goroutine 醒来后先重新锁定那个互斥锁。在成功的 goroutine 最终解锁互斥锁之后,其他的 goroutine 会先后进入临界区,但它们会发现共享资源的状态依然不是它们想要的。这个时候,`for`循环就很有必要了。 2. 共享资源可能有的状态不是两个,而是更多。比如,`mailbox`变量的可能值不只有`0`和`1`,还有`2`、`3`、`4`。这种情况下,由于状态在每次改变后的结果只可能有一个,所以,在设计合理的前提下,单一的结果一定不可能满足所有 goroutine 的条件。那些未被满足的 goroutine 显然还需要继续等待和检查。 3. 有一种可能,共享资源的状态只有两个,并且每种状态都只有一个 goroutine 在关注,就像我们在主问题当中实现的那个例子那样。不过,即使是这样,使用`for`语句仍然是有必要的。原因是,在一些多 CPU 核心的计算机系统中,即使没有收到条件变量的通知,调用其`Wait`方法的 goroutine 也是有可能被唤醒的。这是由计算机硬件层面决定的,即使是操作系统(比如 Linux)本身提供的条件变量也会如此。 综上所述,在包裹条件变量的`Wait`方法的时候,我们总是应该使用`for`语句。 好了,到这里,关于条件变量的`Wait`方法,我想你知道的应该已经足够多了。 ### [](https://jums.gitbook.io/36-lectures-on-golang/28-tiao-jian-bian-liang-sync.cond-xia#wen-ti-2-tiao-jian-bian-liang-de-signal-fang-fa-he-broadcast-fang-fa-you-na-xie-yi-tong) 问题 2:条件变量的`Signal`方法和`Broadcast`方法有哪些异同? 条件变量的`Signal`方法和`Broadcast`方法都是被用来发送通知的,不同的是,前者的通知只会唤醒一个因此而等待的 goroutine,而后者的通知却会唤醒所有为此等待的 goroutine。条件变量的`Wait`方法总会把当前的 goroutine 添加到通知队列的队尾,而它的`Signal`方法总会从通知队列的队首开始查找可被唤醒的 goroutine。所以,因`Signal`方法的通知而被唤醒的 goroutine 一般都是最早等待的那一个。 这两个方法的行为决定了它们的适用场景。如果你确定只有一个 goroutine 在等待通知,或者只需唤醒任意一个 goroutine 就可以满足要求,那么使用条件变量的`Signal`方法就好了。否则,使用`Broadcast`方法总没错,只要你设置好各个 goroutine 所期望的共享资源状态就可以。 此外,再次强调一下,与`Wait`方法不同,条件变量的`Signal`方法和`Broadcast`方法并不需要在互斥锁的保护下执行。恰恰相反,我们最好在解锁条件变量基于的那个互斥锁之后,再去调用它的这两个方法。这更有利于程序的运行效率。 最后,请注意,条件变量的通知具有即时性。也就是说,如果发送通知的时候没有 goroutine 为此等待,那么该通知就会被直接丢弃。在这之后才开始等待的 goroutine 只可能被后面的通知唤醒。 你可以打开 demo62.go 文件,并仔细观察它与 demo61.go 的不同。尤其是`lock`变量的类型,以及发送通知的方式。 ### [](https://jums.gitbook.io/36-lectures-on-golang/28-tiao-jian-bian-liang-sync.cond-xia#zong-jie) 总结 我们今天主要讲了条件变量,它是基于互斥锁的一种同步工具。在 Go 语言中,我们需要用`sync.NewCond`函数来初始化一个`sync.Cond`类型的条件变量。 `sync.NewCond`函数需要一个`sync.Locker`类型的参数值。 `*sync.Mutex`类型的值以及`*sync.RWMutex`类型的值都可以满足这个要求。都可以满足这个要求。另外,后者的`RLocker`方法可以返回这个值中的读锁,也同样可以作为`sync.NewCond`函数的参数值,如此就可以生成与读写锁中的读锁对应的条件变量了。 条件变量的`Wait`方法需要在它基于的互斥锁保护下执行,否则就会引发不可恢复的 panic。此外,我们最好使用`for`语句来检查共享资源的状态,并包裹对条件变量的`Wait`方法的调用。 不要用`if`语句,因为它不能重复地执行”检查状态 - 等待通知 - 被唤醒“的这个流程。重复执行这个流程的原因是,一个因等待通知,而被阻塞的 goroutine,可能会在共享资源的状态不满足其要求的情况下被唤醒。 条件变量的`Signal`方法只会唤醒一个因等待通知而被阻塞的 goroutine,而它的`Broadcast`方法却可以唤醒所有为此而等待的 goroutine。后者比前者的适应场景要多得多。 这两个方法并不需要受到互斥锁的保护,我们也最好不要在解锁互斥锁之前调用它们。还有,条件变量的通知具有即时性。当通知被发送的时候,如果没有任何 goroutine 需要被唤醒,那么该通知就会立即失效。 ### [](https://jums.gitbook.io/36-lectures-on-golang/28-tiao-jian-bian-liang-sync.cond-xia#si-kao-ti) 思考题 `sync.Cond`类型中的公开字段`L`是做什么用的?我们可以在使用条件变量的过程中改变这个字段的值吗? [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页27 | 条件变量sync.Cond (上)](https://jums.gitbook.io/36-lectures-on-golang/27-tiao-jian-bian-liang-sync.cond-shang) [下一页29 | 原子操作(上)](https://jums.gitbook.io/36-lectures-on-golang/29-yuan-zi-cao-zuo-shang) 最后更新于2年前 这有帮助吗? --- # 11 | 通道的高级玩法 | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252FXmhYkOrbdDN4Bt7NHUaV%252Fimage.png%3Falt%3Dmedia%26token%3D6d1aa9a8-c913-4292-a202-571f3635bddc&width=768&dpr=4&quality=100&sign=f37ebee2&sv=2) [6MB\ \ 11.通道的高级玩法.mp3](https://2852705636-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FkmtyDvkosJZE1Rr33XGZ%2Fuploads%2FpgjOp6T0DjSKtIJtPn0l%2F11.%E9%80%9A%E9%81%93%E7%9A%84%E9%AB%98%E7%BA%A7%E7%8E%A9%E6%B3%95.mp3?alt=media&token=141b314a-03be-43af-845d-2957e3a2820e) 我们已经讨论过了通道的基本操作以及背后的规则。今天,我再来讲讲通道的高级玩法。 首先来说说单向通道。我们在说“通道”的时候指的都是双向通道,即:既可以发也可以收的通道。 所谓单向通道就是,只能发不能收,或者只能收不能发的通道。一个通道是双向的,还是单向的是由它的类型字面量体现的。 还记得我们在上篇文章中说过的接收操作符`<-`吗?如果我们把它用在通道的类型字面量中,那么它代表的就不是“发送”或“接收”的动作了,而是表示通道的方向。 比如: 复制 var uselessChan = make(chan<- int, 1) 我声明并初始化了一个名叫`uselessChan`的变量。这个变量的类型是`chan<- int`,容量是`1`。 请注意紧挨在关键字`chan`右边的那个`<-`,这表示了这个通道是单向的,并且只能发而不能收。 类似的,如果这个操作符紧挨在`chan`的左边,那么就说明该通道只能收不能发。所以,前者可以被简称为发送通道,后者可以被简称为接收通道。 注意,与发送操作和接收操作对应,这里的“发”和“收”都是站在操作通道的代码的角度上说的。 从上述变量的名字上你也能猜到,这样的通道是没用的。通道就是为了传递数据而存在的,声明一个只有一端(发送端或者接收端)能用的通道没有任何意义。那么,单向通道的用途究竟在哪儿呢? **问题:单向通道有什么应用价值?** 你可以先自己想想,然后再接着往下看。 **典型回答** 概括地说,单向通道最主要的用途就是约束其他代码的行为。 **问题解析** 这需要从两个方面讲,都跟函数的声明有些关系。先来看下面的代码: 复制 func SendInt(ch chan<- int) { ch <- rand.Intn(1000) } 我用`func`关键字声明了一个叫做`SendInt`的函数。这个函数只接受一个`chan<- int`类型的参数。在这个函数中的代码只能向参数`ch`发送元素值,而不能从它那里接收元素值。这就起到了约束函数行为的作用。 你可能会问,我自己写的函数自己肯定能确定操作通道的方式,为什么还要再约束?好吧,这个例子可能过于简单了。在实际场景中,这种约束一般会出现在接口类型声明中的某个方法定义上。请看这个叫`Notifier`的接口类型声明: 复制 type Notifier interface { SendInt(ch chan<- int) } 在接口类型声明的花括号中,每一行都代表着一个方法的定义。接口中的方法定义与函数声明很类似,但是只包含了方法名称、参数列表和结果列表。 一个类型如果想成为一个接口类型的实现类型,那么就必须实现这个接口中定义的所有方法。因此,如果我们在某个方法的定义中使用了单向通道类型,那么就相当于在对它的所有实现做出约束。 在这里,`Notifier`接口中的`SendInt`方法只会接受一个发送通道作为参数,所以,在该接口的所有实现类型中的`SendInt`方法都会受到限制。这种约束方式还是很有用的,尤其是在我们编写模板代码或者可扩展的程序库的时候。 顺便说一下,我们在调用`SendInt`函数的时候,只需要把一个元素类型匹配的双向通道传给它就行了,没必要用发送通道,因为 Go 语言在这种情况下会自动地把双向通道转换为函数所需的单向通道。 复制 intChan1 := make(chan int, 3) SendInt(intChan1) 在另一个方面,我们还可以在函数声明的结果列表中使用单向通道。如下所示: 复制 func getIntChan() <-chan int { num := 5 ch := make(chan int, num) for i := 0; i < num; i++ { ch <- i } close(ch) return ch } 函数`getIntChan`会返回一个`<-chan int`类型的通道,这就意味着得到该通道的程序,只能从通道中接收元素值。这实际上就是对函数调用方的一种约束了。 另外,我们在 Go 语言中还可以声明函数类型,如果我们在函数类型中使用了单向通道,那么就相等于在约束所有实现了这个函数类型的函数。 我们再顺便看一下调用`getIntChan`的代码: 复制 intChan2 := getIntChan() for elem := range intChan2 { fmt.Printf("The element in intChan2: %v\n", elem) } 我把调用`getIntChan`得到的结果值赋给了变量`intChan2`,然后用`for`语句循环地取出了该通道中的所有元素值,并打印出来。 这里的`for`语句也可以被称为带有`range`子句的`for`语句。它的用法我在后面讲`for`语句的时候专门说明。现在你只需要知道关于它的三件事。 * 一、这样一条`for`语句会不断地尝试从`intChan2`种取出元素值,即使`intChan2`被关闭,它也会在取出所有剩余的元素值之后再结束执行。 * 二、当`intChan2`中没有元素值时,它会被阻塞在有`for`关键字的那一行,直到有新的元素值可取。 * 三、假设`intChan2`的值为`nil`,那么它会被永远阻塞在有`for`关键字的那一行。 这就是带`range`子句的`for`语句与通道的联用方式。不过,它是一种用途比较广泛的语句,还可以被用来从其他一些类型的值中获取元素。除此之外,Go 语言还有一种专门为了操作通道而存在的语句:`select`语句。 **知识扩展** **问题 1:**`**select**`**语句与通道怎样联用,应该注意些什么?** `select`语句只能与通道联用,它一般由若干个分支组成。每次执行这种语句的时候,一般只有一个分支中的代码会被运行。 `select`语句的分支分为两种,一种叫做候选分支,另一种叫做默认分支。候选分支总是以关键字`case`开头,后跟一个`case`表达式和一个冒号,然后我们可以从下一行开始写入当分支被选中时需要执行的语句。 默认分支其实就是 default case,因为,当且仅当没有候选分支被选中时它才会被执行,所以它以关键字`default`开头并直接后跟一个冒号。同样的,我们可以在`default:`的下一行写入要执行的语句。 由于`select`语句是专为通道而设计的,所以每个`case`表达式中都只能包含操作通道的表达式,比如接收表达式。 当然,如果我们需要把接收表达式的结果赋给变量的话,还可以把这里写成赋值语句或者短变量声明。下面展示一个简单的例子。 复制 // 准备好几个通道。 intChannels := [3]chan int{ make(chan int, 1), make(chan int, 1), make(chan int, 1), } // 随机选择一个通道,并向它发送元素值。 index := rand.Intn(3) fmt.Printf("The index: %d\n", index) intChannels[index] <- index // 哪一个通道中有可取的元素值,哪个对应的分支就会被执行。 select { case <-intChannels[0]: fmt.Println("The first candidate case is selected.") case <-intChannels[1]: fmt.Println("The second candidate case is selected.") case elem := <-intChannels[2]: fmt.Printf("The third candidate case is selected, the element is %d.\n", elem) default: fmt.Println("No candidate case is selected!") } 我先准备好了三个类型为`chan int`、容量为`1`的通道,并把它们存入了一个叫做`intChannels`的数组。 然后,我随机选择一个范围在 \[0, 2\] 的整数,把它作为索引在上述数组中选择一个通道,并向其中发送一个元素值。 最后,我用一个包含了三个候选分支的`select`语句,分别尝试从上述三个通道中接收元素值,哪一个通道中有值,哪一个对应的候选分支就会被执行。后面还有一个默认分支,不过在这里它是不可能被选中的。 在使用`select`语句的时候,我们首先需要注意下面几个事情。 1. 如果像上述示例那样加入了默认分支,那么无论涉及通道操作的表达式是否有阻塞,`select`语句都不会被阻塞。如果那几个表达式都阻塞了,或者说都没有满足求值的条件,那么默认分支就会被选中并执行。 2. 如果没有加入默认分支,那么一旦所有的`case`表达式都没有满足求值条件,那么`select`语句就会被阻塞。直到至少有一个`case`表达式满足条件为止。 3. 还记得吗?我们可能会因为通道关闭了,而直接从通道接收到一个其元素类型的零值。所以,在很多时候,我们需要通过接收表达式的第二个结果值来判断通道是否已经关闭。一旦发现某个通道关闭了,我们就应该及时地屏蔽掉对应的分支或者采取其他措施。这对于程序逻辑和程序性能都是有好处的。 4. `select`语句只能对其中的每一个`case`表达式各求值一次。所以,如果我们想连续或定时地操作其中的通道的话,就往往需要通过在`for`语句中嵌入`select`语句的方式实现。但这时要注意,简单地在`select`语句的分支中使用`break`语句,只能结束当前的`select`语句的执行,而并不会对外层的`for`语句产生作用。这种错误的用法可能会让这个`for`语句无休止地运行下去。 下面是一个简单的示例。 复制 intChan := make(chan int, 1) // 一秒后关闭通道。 time.AfterFunc(time.Second, func() { close(intChan) }) select { case _, ok := <-intChan: if !ok { fmt.Println("The candidate case is closed.") break } fmt.Println("The candidate case is selected.") } 我先声明并初始化了一个叫做`intChan`的通道,然后通过`time`包中的`AfterFunc`函数约定在一秒钟之后关闭该通道。 后面的`select`语句只有一个候选分支,我在其中利用接收表达式的第二个结果值对`intChan`通道是否已关闭做了判断,并在得到肯定结果后,通过`break`语句立即结束当前`select`语句的执行。 这个例子以及前面那个例子都可以在 demo24.go 文件中被找到。你应该运行下,看看结果如何。 上面这些注意事项中的一部分涉及到了`select`语句的分支选择规则。我觉得很有必要再专门整理和总结一下这些规则。 **问题 2:**`**select**`**语句的分支选择规则都有哪些?** 规则如下面所示。 1. 对于每一个`case`表达式,都至少会包含一个代表发送操作的发送表达式或者一个代表接收操作的接收表达式,同时也可能会包含其他的表达式。比如,如果`case`表达式是包含了接收表达式的短变量声明时,那么在赋值符号左边的就可以是一个或两个表达式,不过此处的表达式的结果必须是可以被赋值的。当这样的`case`表达式被求值时,它包含的多个表达式总会以从左到右的顺序被求值。 2. `select`语句包含的候选分支中的`case`表达式都会在该语句执行开始时先被求值,并且求值的顺序是依从代码编写的顺序从上到下的。结合上一条规则,在`select`语句开始执行时,排在最上边的候选分支中最左边的表达式会最先被求值,然后是它右边的表达式。仅当最上边的候选分支中的所有表达式都被求值完毕后,从上边数第二个候选分支中的表达式才会被求值,顺序同样是从左到右,然后是第三个候选分支、第四个候选分支,以此类推。 3. 对于每一个`case`表达式,如果其中的发送表达式或者接收表达式在被求值时,相应的操作正处于阻塞状态,那么对该`case`表达式的求值就是不成功的。在这种情况下,我们可以说,这个`case`表达式所在的候选分支是不满足选择条件的。 4. 仅当`select`语句中的所有`case`表达式都被求值完毕后,它才会开始选择候选分支。这时候,它只会挑选满足选择条件的候选分支执行。如果所有的候选分支都不满足选择条件,那么默认分支就会被执行。如果这时没有默认分支,那么`select`语句就会立即进入阻塞状态,直到至少有一个候选分支满足选择条件为止。一旦有一个候选分支满足选择条件,`select`语句(或者说它所在的 goroutine)就会被唤醒,这个候选分支就会被执行。 5. 如果`select`语句发现同时有多个候选分支满足选择条件,那么它就会用一种伪随机的算法在这些分支中选择一个并执行。注意,即使`select`语句是在被唤醒时发现的这种情况,也会这样做。 6. 一条`select`语句中只能够有一个默认分支。并且,默认分支只在无候选分支可选时才会被执行,这与它的编写位置无关。 7. `select`语句的每次执行,包括`case`表达式求值和分支选择,都是独立的。不过,至于它的执行是否是并发安全的,就要看其中的`case`表达式以及分支中,是否包含并发不安全的代码了。 我把与以上规则相关的示例放在 demo25.go 文件中了。你一定要去试运行一下,然后尝试用上面的规则去解释它的输出内容。 **总结** 今天,我们先讲了单向通道的表示方法,操作符“`<-`”仍然是关键。如果只用一个词来概括单向通道存在的意义的话,那就是“约束”,也就是对代码的约束。 我们可以使用带`range`子句的`for`语句从通道中获取数据,也可以通过`select`语句操纵通道。 `select`语句是专门为通道而设计的,它可以包含若干个候选分支,每个分支中的`case`表达式都会包含针对某个通道的发送或接收操作。 当`select`语句被执行时,它会根据一套**分支选择规则**选中某一个分支并执行其中的代码。如果所有的候选分支都没有被选中,那么默认分支(如果有的话)就会被执行。注意,发送和接收操作的阻塞是分支选择规则的一个很重要的依据。 **思考题** 今天的思考题都由上述内容中的线索延伸而来。 1. 如果在`select`语句中发现某个通道已关闭,那么应该怎样屏蔽掉它所在的分支? 2. 在`select`语句与`for`语句联用时,怎样直接退出外层的`for`语句? [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页10 | 通道的基本操作](https://jums.gitbook.io/36-lectures-on-golang/10-tong-dao-de-ji-ben-cao-zuo) [下一页12 | 使用函数的正确姿势](https://jums.gitbook.io/36-lectures-on-golang/12-shi-yong-han-shu-de-zheng-que-zi-shi) 最后更新于2年前 这有帮助吗? --- # 06 | 程序实体的那些事儿 (下) | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252FZZZaiij1bE69Zuu0sLfg%252Fimage.png%3Falt%3Dmedia%26token%3D1bfdf296-ed11-4d89-b8bd-553133f3b1f6&width=768&dpr=4&quality=100&sign=7259d87d&sv=2) #### [](https://jums.gitbook.io/36-lectures-on-golang/06-cheng-xu-shi-ti-de-na-xie-shi-er-xia#go-yu-yan-dai-ma-jiao-duo-jian-yi-pei-he-wen-zhang-shou-ting-yin-pin) 【Go 语言代码较多,建议配合文章收听音频。】 [8MB\ \ 6.程序实体的那些事儿 (下).mp3](https://2852705636-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FkmtyDvkosJZE1Rr33XGZ%2Fuploads%2FMz8qE8z6s18VszWZJWdl%2F6.%E7%A8%8B%E5%BA%8F%E5%AE%9E%E4%BD%93%E7%9A%84%E9%82%A3%E4%BA%9B%E4%BA%8B%E5%84%BF%20%EF%BC%88%E4%B8%8B%EF%BC%89.mp3?alt=media&token=40269b70-fec9-4cc0-b3fb-9e623ade9679) 在上一篇文章,我们一直都在围绕着可重名变量进行讨论。 还记得吗?最后我强调,如果可重名变量的类型不同,那么就需要引起我们的特别关注了,它们之间可能会存在“屏蔽”的现象。 必要时,我们需要严格地检查它们的类型。但怎样检查呢?咱们现在就说。**我今天的问题是:怎样判断一个变量的类型?** 我们依然以在上一篇文章中展示过的 demo11.go 为基础。 复制 package main import "fmt" var container = []string{"zero", "one", "two"} func main() { container := map[int]string{0: "zero", 1: "one", 2: "two"} fmt.Printf("The element is %q.\n", container[1]) } 那么,怎样在打印其中元素之前正确判断变量`container`的类型? ### [](https://jums.gitbook.io/36-lectures-on-golang/06-cheng-xu-shi-ti-de-na-xie-shi-er-xia#dian-xing-hui-da) 典型回答 答案是使用“类型断言”表达式。具体怎么写呢? 复制 value, ok := interface{}(container).([]string) 这里有一条赋值语句。在赋值符号的右边,是一个类型断言表达式。 它包括了用来把`container`变量的值转换为空接口值的`interface{}(container)`。 以及一个用于判断前者的类型是否为切片类型 `[]string` 的 `.([]string)`。 这个表达式的结果可以被赋给两个变量,在这里由`value`和`ok`代表。变量`ok`是布尔(bool)类型的,它将代表类型判断的结果,`true`或`false`。 如果是`true`,那么被判断的值将会被自动转换为`[]string`类型的值,并赋给变量`value`,否则`value`将被赋予`nil`(即“空”)。 顺便提一下,这里的`ok`也可以没有。也就是说,类型断言表达式的结果,可以只被赋给一个变量,在这里是`value`。 但是这样的话,当判断为否时就会引发异常。 这种异常在 Go 语言中被叫做`panic`,我把它翻译为运行时恐慌。因为它是一种在 Go 程序运行期间才会被抛出的异常,而“恐慌”二字是英文 Panic 的中文直译。 除非显式地“恢复”这种“恐慌”,否则它会使 Go 程序崩溃并停止。所以,在一般情况下,我们还是应该使用带`ok`变量的写法。 ### [](https://jums.gitbook.io/36-lectures-on-golang/06-cheng-xu-shi-ti-de-na-xie-shi-er-xia#wen-ti-jie-xi) 问题解析 正式说明一下,类型断言表达式的语法形式是`x.(T)`。其中的`x`代表要被判断类型的那个值。这个值当下的类型必须是接口类型的,不过具体是哪个接口类型其实是无所谓的。 所以,当这里的`container`变量类型不是任何的接口类型时,我们就需要先把它转成某个接口类型的值。 如果`container`是某个接口类型的,那么这个类型断言表达式就可以是`container.([]string)`。这样看是不是清晰一些了? 在 Go 语言中,`interface{}`代表空接口,任何类型都是它的实现类型。我在下个模块,会再讲接口及其实现类型的问题。现在你只要知道,任何类型的值都可以很方便地被转换成空接口的值就行了。 这里的具体语法是`interface{}(x)`,例如前面展示的`interface{}(container)`。 你可能会对这里的`{}`产生疑惑,为什么在关键字`interface`的右边还要加上这个东西? 请记住,一对不包裹任何东西的花括号,除了可以代表空的代码块之外,还可以用于表示不包含任何内容的数据结构(或者说数据类型)。 比如你今后肯定会遇到的`struct{}`,它就代表了不包含任何字段和方法的、空的结构体类型。 而空接口`interface{}`则代表了不包含任何方法定义的、空的接口类型。 当然了,对于一些集合类的数据类型来说,`{}`还可以用来表示其值不包含任何元素,比如空的切片值`[]string{}`,以及空的字典值`map[int]string{}`。 再往答案的最右边看。圆括号中`[]string`是一个类型字面量。所谓类型字面量,就是用来表示数据类型本身的若干个字符。 比如,`string`是表示字符串类型的字面量,`uint8`是表示 8 位无符号整数类型的字面量。 再复杂一些的就是我们刚才提到的`[]string`,用来表示元素类型为`string`的切片类型,以及`map[int]string`,用来表示键类型为`int`、值类型为`string`的字典类型。 还有更复杂的结构体类型字面量、接口类型字面量,等等。这些描述起来占用篇幅较多,我在后面再说吧。 针对当前的这个问题,我写了 demo12.go。它是 demo11.go 的修改版。我在其中分别使用了两种方式来实施类型断言,一种用的是我上面讲到的方式,另一种用的是我们还没讨论过的`switch`语句,先供你参考。 可以看到,当前问题的答案可以只有一行代码。你可能会想,这一行代码解释起来也太复杂了吧? 千万不要为此烦恼,这其中很大一部分都是一些基本语法和概念,你只要记住它们就好了。但这也正是我要告诉你的,一小段代码可以隐藏很多细节。面试官可以由此延伸到几个方向继续提问。这有点儿像泼墨,可以迅速由点及面。 ### [](https://jums.gitbook.io/36-lectures-on-golang/06-cheng-xu-shi-ti-de-na-xie-shi-er-xia#zhi-shi-kuo-zhan) 知识扩展 **1\. 你认为类型转换规则中有哪些值得注意的地方?** 类型转换表达式的基本写法我已经在前面展示过了。它的语法形式是`T(x)`。 其中的`x`可以是一个变量,也可以是一个代表值的字面量(比如`1.23`和`struct{}`),还可以是一个表达式。 注意,如果是表达式,那么该表达式的结果只能是一个值,而不能是多个值。在这个上下文中,`x`可以被叫做源值,它的类型就是源类型,而那个`T`代表的类型就是目标类型。 如果从源类型到目标类型的转换是不合法的,那么就会引发一个编译错误。那怎样才算合法?具体的规则可参见 Go 语言规范中的[转换](https://golang.google.cn/ref/spec#Conversions) 部分。 我们在这里要关心的,并不是那些 Go 语言编译器可以检测出的问题。恰恰相反,那些在语言规范或其他官方文档中已经说明在编程语言层面很难检测的东西才是我们应该关注的。 **很多初学者所说的陷阱(或者说坑),大都源于他们需要了解但却不了解的那些知识和技巧。因此,在这些规则中,我想抛出三个我认为很常用并且非常值得注意的知识点,提前帮你标出一些“陷阱”。** 首先,对于整数类型值、整数常量之间的类型转换,原则上只要源值在目标类型的可表示范围内就是合法的。 比如,之所以`uint8(255)`可以把无类型的常量`255`转换为`uint8`类型的值,是因为`255`在 \[0, 255\] 的范围内。 但需要特别注意的是,源整数类型的可表示范围较大,而目标类型的可表示范围较小的情况,比如把值的类型从`int16`转换为`int8`。请看下面这段代码: 复制 var srcInt = int16(-255) dstInt := int8(srcInt) 变量`srcInt`的值是`int16`类型的`-255`,而变量`dstInt`的值是由前者转换而来的,类型是`int8`。`int16`类型的可表示范围可比`int8`类型大了不少。问题是,`dstInt`的值是多少? 首先你要知道,整数在 Go 语言以及计算机中都是以补码的形式存储的。这主要是为了简化计算机对整数的运算过程。补码其实就是原码各位求反再加 1。 比如,`int16`类型的值`-255`的补码是`1111111100000001`。如果我们把该值转换为`int8`类型的值,那么 Go 语言会把在较高位置(或者说最左边位置)上的 8 位二进制数直接截掉,从而得到`00000001`。 又由于其最左边一位是`0`,表示它是个正整数,以及正整数的补码就等于其原码,所以`dstInt`的值就是`1`。 一定要记住,当整数值的类型的有效范围由宽变窄时,只需在补码形式下截掉一定数量的高位二进制数即可。 类似的快刀斩乱麻规则还有:当把一个浮点数类型的值转换为整数类型值时,前者的小数部分会被全部截掉。 第二,虽然直接把一个整数值转换为一个`string`类型的值是可行的,但值得关注的是,被转换的整数值应该可以代表一个有效的 Unicode 代码点,否则转换的结果将会是`"�"`(仅由高亮的问号组成的字符串值)。 字符`'�'`的 Unicode 代码点是`U+FFFD`。它是 Unicode 标准中定义的 Replacement Character,专用于替换那些未知的、不被认可的以及无法展示的字符。 我肯定不会去问“哪个整数值转换后会得到哪个字符串”,这太变态了!但是我会写下: 复制 string(-1) 并询问会得到什么?这可是完全不同的问题啊。由于`-1`肯定无法代表一个有效的 Unicode 代码点,所以得到的总会是`"�"`。在实际工作中,我们在排查问题时可能会遇到`�`,你需要知道这可能是由于什么引起的。 第三个知识点是关于`string`类型与各种切片类型之间的互转的。 你先要理解的是,一个值在从`string`类型向`[]byte`类型转换时代表着以 UTF-8 编码的字符串会被拆分成零散、独立的字节。 除了与 ASCII 编码兼容的那部分字符集,以 UTF-8 编码的某个单一字节是无法代表一个字符的。 复制 string([]byte{'\xe4', '\xbd', '\xa0', '\xe5', '\xa5', '\xbd'}) // 你好 比如,UTF-8 编码的三个字节`\xe4`、`\xbd`和`\xa0`合在一起才能代表字符`'你'`,而`\xe5`、`\xa5`和`\xbd`合在一起才能代表字符`'好'`。 其次,一个值在从`string`类型向`[]rune`类型转换时代表着字符串会被拆分成一个个 Unicode 字符。 复制 string([]rune{'\u4F60', '\u597D'}) // 你好 当你真正理解了 Unicode 标准及其字符集和编码方案之后,上面这些内容就会显得很容易了。什么是 Unicode 标准?我会首先推荐你去它的[官方网站](http://www.unicode.org/) 一探究竟。 **2\. 什么是别名类型?什么是潜在类型?** 我们可以用关键字`type`声明自定义的各种类型。当然了,这些类型必须在 Go 语言基本类型和高级类型的范畴之内。在它们当中,有一种被叫做“别名类型”的类型。我们可以像下面这样声明它: 复制 type MyString = string 这条声明语句表示,`MyString`是`string`类型的别名类型。顾名思义,别名类型与其源类型的区别恐怕只是在名称上,它们是完全相同的。 源类型与别名类型是一对概念,是两个对立的称呼。别名类型主要是为了代码重构而存在的。更详细的信息可参见 Go 语言官方的文档[Proposal: Type Aliases](https://golang.org/design/18130-type-alias) 。 Go 语言内建的基本类型中就存在两个别名类型。`byte`是`uint8`的别名类型,而`rune`是`int32`的别名类型。 一定要注意,如果我这样声明: 复制 type MyString2 string // 注意,这里没有等号。 `MyString2`和`string`就是两个不同的类型了。这里的`MyString2`是一个新的类型,不同于其他任何类型。 这种方式也可以被叫做对类型的再定义。我们刚刚把`string`类型再定义成了另外一个类型`MyString2`。 对于这里的类型再定义来说,`string`可以被称为`MyString2`的潜在类型。潜在类型的含义是某个类型在本质上是哪个类型或者是哪个类型的集合。 潜在类型相同的不同类型的值之间是可以进行类型转换的。因此,`MyString2`类型的值与`string`类型的值可以使用类型转换表达式进行互转。 但对于集合类的类型`[]MyString2`与`[]string`来说这样做却是不合法的,因为`[]MyString2`与`[]string`的潜在类型不同,分别是`MyString2`和`string`。 另外,即使两个类型的潜在类型相同,它们的值之间也不能进行判等或比较,它们的变量之间也不能赋值。 ### [](https://jums.gitbook.io/36-lectures-on-golang/06-cheng-xu-shi-ti-de-na-xie-shi-er-xia#zong-jie) 总结 在本篇文章中,我们聚焦于类型。Go 语言中的每个变量都是有类型的,我们可以使用类型断言表达式判断变量是哪个类型的。 正确使用该表达式需要一些小技巧,比如总是应该把结果赋给两个变量。另外还要保证被判断的变量是接口类型的,这可能会用到类型转换表达式。 我们在使用类型转换表达式对变量的类型进行转换的时候,会受到一套规则的严格约束。 我们必须关注这套规则中的一些细节,尤其是那些 Go 语言命令不会帮你检查的细节,否则就会踩进所谓的“陷阱”中。 此外,你还应该搞清楚别名类型声明与类型再定义之间的区别,以及由此带来的它们的值在类型转换、判等、比较和赋值操作方面的不同。 ### [](https://jums.gitbook.io/36-lectures-on-golang/06-cheng-xu-shi-ti-de-na-xie-shi-er-xia#si-kao-ti) 思考题 本篇文章的思考题有两个。 1. 除了上述提及的那些,你还认为类型转换规则中有哪些值得注意的地方? 2. 你能具体说说别名类型在代码重构过程中可以起到哪些作用吗? 这些问题的答案都在文中提到的官方文档之中。 [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页05 | 程序实体的那些事儿(中)](https://jums.gitbook.io/36-lectures-on-golang/05-cheng-xu-shi-ti-de-na-xie-shi-er-zhong) [下一页07 | 数组和切片](https://jums.gitbook.io/36-lectures-on-golang/07-shu-zu-he-qie-pian) 最后更新于2年前 这有帮助吗? --- # 22 | panic函数、recover函数以及defer语句(下) | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252FxLFwJg7tC8oozV176jL5%252Fimage.png%3Falt%3Dmedia%26token%3De144acb0-9ecd-498e-ae5d-0770c179998d&width=768&dpr=4&quality=100&sign=887a017a&sv=2) #### [](https://jums.gitbook.io/36-lectures-on-golang/22-panic-han-shu-recover-han-shu-yi-ji-defer-yu-ju-xia#go-yu-yan-dai-ma-jiao-duo-jian-yi-pei-he-wen-zhang-shou-ting-yin-pin) 【Go 语言代码较多,建议配合文章收听音频。】 [6MB\ \ 22.panic函数、recover函数以及defer语句(下).mp3](https://2852705636-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FkmtyDvkosJZE1Rr33XGZ%2Fuploads%2FvjQohoQ2RURZwIMXhNj4%2F22.panic%E5%87%BD%E6%95%B0%E3%80%81recover%E5%87%BD%E6%95%B0%E4%BB%A5%E5%8F%8Adefer%E8%AF%AD%E5%8F%A5%EF%BC%88%E4%B8%8B%EF%BC%89.mp3?alt=media&token=e22aca2e-382e-453c-9a49-95168494ef48) 你好,我是郝林,今天我们继续来聊聊 panic 函数、recover 函数以及 defer 语句的内容。 我在前一篇文章提到过这样一个说法,panic 之中可以包含一个值,用于简要解释引发此 panic 的原因。 如果一个 panic 是我们在无意间引发的,那么其中的值只能由 Go 语言运行时系统给定。但是,当我们使用`panic`函数有意地引发一个 panic 的时候,却可以自行指定其包含的值。我们今天的第一个问题就是针对后一种情况提出的。 ### [](https://jums.gitbook.io/36-lectures-on-golang/22-panic-han-shu-recover-han-shu-yi-ji-defer-yu-ju-xia#zhi-shi-kuo-zhan) 知识扩展 #### [](https://jums.gitbook.io/36-lectures-on-golang/22-panic-han-shu-recover-han-shu-yi-ji-defer-yu-ju-xia#wen-ti-1-zen-yang-rang-panic-bao-han-yi-ge-zhi-yi-ji-ying-gai-rang-ta-bao-han-shen-me-yang-de-zhi) 问题 1:怎样让 panic 包含一个值,以及应该让它包含什么样的值? 这其实很简单,在调用`panic`函数时,把某个值作为参数传给该函数就可以了。由于`panic`函数的唯一一个参数是空接口(也就是`interface{}`)类型的,所以从语法上讲,它可以接受任何类型的值。 但是,我们最好传入`error`类型的错误值,或者其他的可以被有效序列化的值。这里的“有效序列化”指的是,可以更易读地去表示形式转换。 还记得吗?对于`fmt`包下的各种打印函数来说,`error`类型值的`Error`方法与其他类型值的`String`方法是等价的,它们的唯一结果都是`string`类型的。 我们在通过占位符`%s`打印这些值的时候,它们的字符串表示形式分别都是这两种方法产出的。 一旦程序异常了,我们就一定要把异常的相关信息记录下来,这通常都是记到程序日志里。 我们在为程序排查错误的时候,首先要做的就是查看和解读程序日志;而最常用也是最方便的日志记录方式,就是记下相关值的字符串表示形式。 所以,如果你觉得某个值有可能会被记到日志里,那么就应该为它关联`String`方法。如果这个值是`error`类型的,那么让它的`Error`方法返回你为它定制的字符串表示形式就可以了。 对于此,你可能会想到`fmt.Sprintf`,以及`fmt.Fprintf`这类可以格式化并输出参数的函数。 是的,它们本身就可以被用来输出值的某种表示形式。不过,它们在功能上,肯定远不如我们自己定义的`Error`方法或者`String`方法。因此,为不同的数据类型分别编写这两种方法总是首选。 可是,这与传给`panic`函数的参数值又有什么关系呢?其实道理是相同的。至少在程序崩溃的时候,panic 包含的那个值字符串表示形式会被打印出来。 另外,我们还可以施加某种保护措施,避免程序的崩溃。这个时候,panic 包含的值会被取出,而在取出之后,它一般都会被打印出来或者记录到日志里。 既然说到了应对 panic 的保护措施,我们再来看下面一个问题。 #### [](https://jums.gitbook.io/36-lectures-on-golang/22-panic-han-shu-recover-han-shu-yi-ji-defer-yu-ju-xia#wen-ti-2-zen-yang-shi-jia-ying-dui-panic-de-bao-hu-cuo-shi-cong-er-bi-mian-cheng-xu-beng-kui) 问题 2:怎样施加应对 panic 的保护措施,从而避免程序崩溃? Go 语言的内建函数`recover`专用于恢复 panic,或者说平息运行时恐慌。`recover`函数无需任何参数,并且会返回一个空接口类型的值。 如果用法正确,这个值实际上就是即将恢复的 panic 包含的值。并且,如果这个 panic 是因我们调用`panic`函数而引发的,那么该值同时也会是我们此次调用`panic`函数时,传入的参数值副本。请注意,这里强调用法的正确。我们先来看看什么是不正确的用法。 复制 package main import ( "fmt" "errors" ) func main() { fmt.Println("Enter function main.") // 引发 panic。 panic(errors.New("something wrong")) p := recover() fmt.Printf("panic: %s\n", p) fmt.Println("Exit function main.") } 在上面这个`main`函数中,我先通过调用`panic`函数引发了一个 panic,紧接着想通过调用`recover`函数恢复这个 panic。可结果呢?你一试便知,程序依然会崩溃,这个`recover`函数调用并不会起到任何作用,甚至都没有机会执行。 还记得吗?我提到过 panic 一旦发生,控制权就会讯速地沿着调用栈的反方向传播。所以,在`panic`函数调用之后的代码,根本就没有执行的机会。 那如果我把调用`recover`函数的代码提前呢?也就是说,先调用`recover`函数,再调用`panic`函数会怎么样呢? 这显然也是不行的,因为,如果在我们调用`recover`函数时未发生 panic,那么该函数就不会做任何事情,并且只会返回一个`nil`。 换句话说,这样做毫无意义。那么,到底什么才是正确的`recover`函数用法呢?这就不得不提到`defer`语句了。 顾名思义,`defer`语句就是被用来延迟执行代码的。延迟到什么时候呢?这要延迟到该语句所在的函数即将执行结束的那一刻,无论结束执行的原因是什么。 这与`go`语句有些类似,一个`defer`语句总是由一个`defer`关键字和一个调用表达式组成。 这里存在一些限制,有一些调用表达式是不能出现在这里的,包括:针对 Go 语言内建函数的调用表达式,以及针对`unsafe`包中的函数的调用表达式。 顺便说一下,对于`go`语句中的调用表达式,限制也是一样的。另外,在这里被调用的函数可以是有名称的,也可以是匿名的。我们可以把这里的函数叫做`defer`函数或者延迟函数。注意,被延迟执行的是`defer`函数,而不是`defer`语句。 我刚才说了,无论函数结束执行的原因是什么,其中的`defer`函数调用都会在它即将结束执行的那一刻执行。即使导致它执行结束的原因是一个 panic 也会是这样。正因为如此,我们需要联用`defer`语句和`recover`函数调用,才能够恢复一个已经发生的 panic。 我们来看一下经过修正的代码。 复制 package main import ( "fmt" "errors" ) func main() { fmt.Println("Enter function main.") defer func(){ fmt.Println("Enter defer function.") if p := recover(); p != nil { fmt.Printf("panic: %s\n", p) } fmt.Println("Exit defer function.") }() // 引发 panic。 panic(errors.New("something wrong")) fmt.Println("Exit function main.") } 在这个`main`函数中,我先编写了一条`defer`语句,并在`defer`函数中调用了`recover`函数。仅当调用的结果值不为`nil`时,也就是说只有 panic 确实已发生时,我才会打印一行以“panic:”为前缀的内容。 紧接着,我调用了`panic`函数,并传入了一个`error`类型值。这里一定要注意,我们要尽量把`defer`语句写在函数体的开始处,因为在引发 panic 的语句之后的所有语句,都不会有任何执行机会。 也只有这样,`defer`函数中的`recover`函数调用才会拦截,并恢复`defer`语句所属的函数,及其调用的代码中发生的所有 panic。 至此,我向你展示了两个很典型的`recover`函数的错误用法,以及一个基本的正确用法。 我希望你能够记住错误用法背后的缘由,同时也希望你能真正地理解联用`defer`语句和`recover`函数调用的真谛。 在命令源码文件 demo50.go 中,我把上述三种用法合并在了一段代码中。你可以运行该文件,并体会各种用法所产生的不同效果。 下面我再来多说一点关于`defer`语句的事情。 #### [](https://jums.gitbook.io/36-lectures-on-golang/22-panic-han-shu-recover-han-shu-yi-ji-defer-yu-ju-xia#wen-ti-3-ru-guo-yi-ge-han-shu-zhong-you-duo-tiao-defer-yu-ju-na-me-na-ji-ge-defer-han-shu-diao-yong) 问题 3:如果一个函数中有多条`defer`语句,那么那几个`defer`函数调用的执行顺序是怎样的? 如果只用一句话回答的话,那就是:在同一个函数中,`defer`函数调用的执行顺序与它们分别所属的`defer`语句的出现顺序(更严谨地说,是执行顺序)完全相反。 当一个函数即将结束执行时,其中的写在最下边的`defer`函数调用会最先执行,其次是写在它上边、与它的距离最近的那个`defer`函数调用,以此类推,最上边的`defer`函数调用会最后一个执行。 如果函数中有一条`for`语句,并且这条`for`语句中包含了一条`defer`语句,那么,显然这条`defer`语句的执行次数,就取决于`for`语句的迭代次数。 并且,同一条`defer`语句每被执行一次,其中的`defer`函数调用就会产生一次,而且,这些函数调用同样不会被立即执行。 那么问题来了,这条`for`语句中产生的多个`defer`函数调用,会以怎样的顺序执行呢? 为了彻底搞清楚,我们需要弄明白`defer`语句执行时发生的事情。 其实也并不复杂,在`defer`语句每次执行的时候,Go 语言会把它携带的`defer`函数及其参数值另行存储到一个队列中。 这个队列与该`defer`语句所属的函数是对应的,并且,它是先进后出(FILO)的,相当于一个栈。 在需要执行某个函数中的`defer`函数调用的时候,Go 语言会先拿到对应的队列,然后从该队列中一个一个地取出`defer`函数及其参数值,并逐个执行调用。 这正是我说“`defer`函数调用与其所属的`defer`语句的执行顺序完全相反”的原因了。 下面该你出场了,我在 demo51.go 文件中编写了一个与本问题有关的示例,其中的核心代码很简单,只有几行而已。 我希望你先查看代码,然后思考并写下该示例被运行时,会打印出哪些内容。 如果你实在想不出来,那么也可以先运行示例,再试着解释打印出的内容。总之,你需要完全搞明白那几行内容为什么会以那样的顺序出现的确切原因。 ### [](https://jums.gitbook.io/36-lectures-on-golang/22-panic-han-shu-recover-han-shu-yi-ji-defer-yu-ju-xia#zong-jie) 总结 我们这两期的内容主要讲了两个函数和一条语句。`recover`函数专用于恢复 panic,并且调用即恢复。 它在被调用时会返回一个空接口类型的结果值。如果在调用它时并没有 panic 发生,那么这个结果值就会是`nil`。 而如果被恢复的 panic 是我们通过调用`panic`函数引发的,那么它返回的结果值就会是我们传给`panic`函数参数值的副本。 对`recover`函数的调用只有在`defer`语句中才能真正起作用。`defer`语句是被用来延迟执行代码的。 更确切地说,它会让其携带的`defer`函数的调用延迟执行,并且会延迟到该`defer`语句所属的函数即将结束执行的那一刻。 在同一个函数中,延迟执行的`defer`函数调用,会与它们分别所属的`defer`语句的执行顺序完全相反。还要注意,同一条`defer`语句每被执行一次,就会产生一个延迟执行的`defer`函数调用。 这种情况在`defer`语句与`for`语句联用时经常出现。这时更要关注`for`语句中,同一条`defer`语句产生的多个`defer`函数调用的实际执行顺序。 以上这些,就是关于 Go 语言中特殊的程序异常,及其处理方式的核心知识。这里边可以衍生出很多面试题目。 ### [](https://jums.gitbook.io/36-lectures-on-golang/22-panic-han-shu-recover-han-shu-yi-ji-defer-yu-ju-xia#si-kao-ti) 思考题 我们可以在`defer`函数中恢复 panic,那么可以在其中引发 panic 吗? [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252F7hsfmP8QKrHeIqc5lzkM%252Fimage.png%3Falt%3Dmedia%26token%3D289d845a-5732-4eac-8c8a-d1edebb03564&width=768&dpr=4&quality=100&sign=ccb93749&sv=2) [上一页21 | panic函数、recover函数以及defer语句 (上)](https://jums.gitbook.io/36-lectures-on-golang/21-panic-han-shu-recover-han-shu-yi-ji-defer-yu-ju-shang) [下一页23 | 测试的基本规则和流程 (上)](https://jums.gitbook.io/36-lectures-on-golang/23-ce-shi-de-ji-ben-gui-ze-he-liu-cheng-shang) 最后更新于2年前 这有帮助吗? --- # 27 | 条件变量sync.Cond (上) | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252FdSI8J1fEPdlpOCqP6Zes%252Fimage.png%3Falt%3Dmedia%26token%3D4b141c9f-9e70-46d4-a593-7dd7b8a84e8b&width=768&dpr=4&quality=100&sign=8f3a6757&sv=2) #### [](https://jums.gitbook.io/36-lectures-on-golang/27-tiao-jian-bian-liang-sync.cond-shang#go-yu-yan-dai-ma-jiao-duo-jian-yi-pei-he-wen-zhang-shou-ting-yin-pin) 【Go 语言代码较多,建议配合文章收听音频。】 [5MB\ \ 27.条件变量sync.Cond (上).mp3](https://2852705636-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FkmtyDvkosJZE1Rr33XGZ%2Fuploads%2FE1QH69Pyd19GkPoolzXc%2F27.%E6%9D%A1%E4%BB%B6%E5%8F%98%E9%87%8Fsync.Cond%20%EF%BC%88%E4%B8%8A%EF%BC%89.mp3?alt=media&token=00dd3b6d-03eb-40ab-8962-eb811022094a) 在上篇文章中,我们主要说的是互斥锁,今天我和你来聊一聊条件变量(conditional variable)。我们常常会把条件变量这个同步工具拿来与互斥锁一起讨论。实际上,条件变量是基于互斥锁的,它必须有互斥锁的支撑才能发挥作用。 条件变量并不是被用来保护临界区和共享资源的,它是用于协调想要访问共享资源的那些线程的。当共享资源的状态发生变化时,它可以被用来通知被互斥锁阻塞的线程。 比如说,我们两个人在共同执行一项秘密任务,这需要在不直接联系和见面的前提下进行。我需要向一个信箱里放置情报,你需要从这个信箱中获取情报。这个信箱就相当于一个共享资源,而我们就分别是进行写操作的线程和进行读操作的线程。 如果我在放置的时候发现信箱里还有未被取走的情报,那就不再放置,而先返回。另一方面,如果你在获取的时候发现信箱里没有情报,那也只能先回去了。这就相当于写的线程或读的线程阻塞的情况。 虽然我们俩都有信箱的钥匙,但是同一时刻只能有一个人插入钥匙并打开信箱,这就是锁的作用了。更何况咱们俩是不能直接见面的,所以这个信箱本身就可以被视为一个临界区。 尽管没有协调好,咱们俩仍然要想方设法的完成任务啊。所以,如果信箱里有情报,而你却迟迟未取走,那我就需要每过一段时间带着新情报去检查一次,若发现信箱空了,我就需要及时地把新情报放到里面。 另一方面,如果信箱里一直没有情报,那你也要每过一段时间去打开看看,一旦有了情报就及时地取走。这么做是可以的,但就是太危险了,很容易被敌人发现。 后来,我们又想了一个计策,各自雇佣了一个不起眼的小孩儿。如果早上七点有一个戴红色帽子的小孩儿从你家楼下路过,那么就意味着信箱里有了新情报。另一边,如果上午九点有一个戴蓝色帽子的小孩儿从我家楼下路过,那就说明你已经从信箱中取走了情报。 这样一来,咱们执行任务的隐蔽性高多了,并且效率的提升非常显著。这两个戴不同颜色帽子的小孩儿就相当于条件变量,在共享资源的状态产生变化的时候,起到了通知的作用。 当然了,我们是在用 Go 语言编写程序,而不是在执行什么秘密任务。因此,条件变量在这里的最大优势就是在效率方面的提升。当共享资源的状态不满足条件的时候,想操作它的线程再也不用循环往复地做检查了,只要等待通知就好了。 说到这里,想考考你知道怎么使用条件变量吗?所以,**我们今天的问题就是:条件变量怎样与互斥锁配合使用?** **这道题的典型回答是:条件变量的初始化离不开互斥锁,并且它的方法有的也是基于互斥锁的。** 条件变量提供的方法有三个:等待通知(wait)、单发通知(signal)和广播通知(broadcast)。我们在利用条件变量等待通知的时候,需要在它基于的那个互斥锁 保护下进行。而在进行单发通知或广播通知的时候,却是恰恰相反的,也就是说,需要在对应的互斥锁解锁之后再做这两种操作。 ### [](https://jums.gitbook.io/36-lectures-on-golang/27-tiao-jian-bian-liang-sync.cond-shang#wen-ti-jie-xi) 问题解析 这个问题看起来很简单,但其实可以基于它, 延伸出很多其他的问题。比如,每个方法的使用时机是什么?又比如,每个方法执行的内部流程是怎样的? 下面,我们一边用代码实现前面那个例子,一边讨论条件变量的使用。 首先,我们先来创建如下几个变量。 复制 var mailbox uint8 var lock sync.RWMutex sendCond := sync.NewCond(&lock) recvCond := sync.NewCond(lock.RLocker()) 变量`mailbox`代表信箱,是`uint8`类型的。若它的值为`0`则表示信箱中没有情报,而当它的值为`1`时则说明信箱中有情报。`lock`是一个类型为`sync.RWMutex`的变量,是一个读写锁,也可以被视为信箱上的那把锁。 另外,基于这把锁,我还创建了两个代表条件变量的变量,名字分别叫`sendCond`和`recvCond`。它们都是`*sync.Cond`类型的,同时也都是由`sync.NewCond`函数来初始化的。 与`sync.Mutex`类型和`sync.RWMutex`类型不同,`sync.Cond`类型并不是开箱即用的。我们只能利用`sync.NewCond`函数创建它的指针值。这个函数需要一个`sync.Locker`类型的参数值。 还记得吗?我在前面说过,条件变量是基于互斥锁的,它必须有互斥锁的支撑才能够起作用。因此,这里的参数值是不可或缺的,它会参与到条件变量的方法实现当中。 `sync.Locker`其实是一个接口,在它的声明中只包含了两个方法定义,即:`Lock()`和`Unlock()`。`sync.Mutex`类型和`sync.RWMutex`类型都拥有`Lock`方法和`Unlock`方法,只不过它们都是指针方法。因此,这两个类型的指针类型才是`sync.Locker`接口的实现类型。 我在为`sendCond`变量做初始化的时候,把基于`lock`变量的指针值传给了`sync.NewCond`函数。 原因是,`lock`变量的`Lock`方法和`Unlock`方法分别用于对其中写锁的锁定和解锁,它们与`sendCond`变量的含义是对应的。`sendCond`是专门为放置情报而准备的条件变量,向信箱里放置情报,可以被视为对共享资源的写操作。 相应的,`recvCond`变量代表的是专门为获取情报而准备的条件变量。虽然获取情报也会涉及对信箱状态的改变,但是好在做这件事的人只会有你一个,而且我们也需要借此了解一下,条件变量与读写锁中的读锁的联用方式。所以,在这里,我们暂且把获取情报看做是对共享资源的读操作。 因此,为了初始化`recvCond`这个条件变量,我们需要的是`lock`变量中的读锁,并且还需要是`sync.Locker`类型的。 可是,`lock`变量中用于对读锁进行锁定和解锁的方法却是`RLock`和`RUnlock`,它们与`sync.Locker`接口中定义的方法并不匹配。 好在`sync.RWMutex`类型的`RLocker`方法可以实现这一需求。我们只要在调用`sync.NewCond`函数时,传入调用表达式`lock.RLocker()`的结果值,就可以使该函数返回符合要求的条件变量了。 为什么说通过`lock.RLocker()`得来的值就是`lock`变量中的读锁呢?实际上,这个值所拥有的`Lock`方法和`Unlock`方法,在其内部会分别调用`lock`变量的`RLock`方法和`RUnlock`方法。也就是说,前两个方法仅仅是后两个方法的代理而已。 好了,我们现在有四个变量。一个是代表信箱的`mailbox`,一个是代表信箱上的锁的`lock`。还有两个是,代表了蓝帽子小孩儿的`sendCond`,以及代表了红帽子小孩儿的`recvCond`。 我,现在是一个 goroutine(携带的`go`函数),想要适时地向信箱里放置情报并通知你,应该怎么做呢? 复制 lock.Lock() for mailbox == 1 { sendCond.Wait() } mailbox = 1 lock.Unlock() recvCond.Signal() 我肯定需要先调用`lock`变量的`Lock`方法。注意,这个`Lock`方法在这里意味的是:持有信箱上的锁,并且有打开信箱的权利,而不是锁上这个锁。 然后,我要检查`mailbox`变量的值是否等于`1`,也就是说,要看看信箱里是不是还存有情报。如果还有情报,那么我就回家去等蓝帽子小孩儿了。 这就是那条`for`语句以及其中的调用表达式`sendCond.Wait()`所表示的含义了。你可能会问,为什么这里是`for`语句而不是`if`语句呢?我在后面会对此进行解释的。 我们再往后看,如果信箱里没有情报,那么我就把新情报放进去,关上信箱、锁上锁,然后离开。用代码表达出来就是`mailbox = 1`和`lock.Unlock()`。 离开之后我还要做一件事,那就是让红帽子小孩儿准时去你家楼下路过。也就是说,我会及时地通知你“信箱里已经有新情报了”,我们调用`recvCond`的`Signal`方法就可以实现这一步骤。 另一方面,你现在是另一个 goroutine,想要适时地从信箱中获取情报,然后通知我。 复制 lock.RLock() for mailbox == 0 { recvCond.Wait() } mailbox = 0 lock.RUnlock() sendCond.Signal() 你跟我做的事情在流程上其实基本一致,只不过每一步操作的对象是不同的。你需要调用的是`lock`变量的`RLock`方法。因为你要进行的是读操作,并且会使用`recvCond`变量作为辅助。`recvCond`与`lock`变量的读锁是对应的。 在打开信箱后,你要关注的是信箱里是不是没有情报,也就是检查`mailbox`变量的值是否等于`0`。如果它确实等于`0`,那么你就需要回家去等红帽子小孩儿,也就是调用`recvCond`的`Wait`方法。这里使用的依然是`for`语句。 如果信箱里有情报,那么你就应该取走情报,关上信箱、锁上锁,然后离开。对应的代码是`mailbox = 0`和`lock.RUnlock()`。之后,你还需要让蓝帽子小孩儿准时去我家楼下路过。这样我就知道信箱中的情报已经被你获取了。 以上这些,就是对咱们俩要执行秘密任务的代码实现。其中的条件变量的用法需要你特别注意。 再强调一下,只要条件不满足,我就会通过调用`sendCond`变量的`Wait`方法,去等待你的通知,只有在收到通知之后我才会再次检查信箱。 另外,当我需要通知你的时候,我会调用`recvCond`变量的`Signal`方法。你使用这两个条件变量的方式正好与我相反。你可能也看出来了,利用条件变量可以实现单向的通知,而双向的通知则需要两个条件变量。这也是条件变量的基本使用规则。 你可以打开 demo61.go 文件,看到上述例子的全部实现代码。 ### [](https://jums.gitbook.io/36-lectures-on-golang/27-tiao-jian-bian-liang-sync.cond-shang#zong-jie) 总结 我们这两期的文章会围绕条件变量的内容展开,条件变量是基于互斥锁的一种同步工具,它必须有互斥锁的支撑才能发挥作用。 条件变量可以协调那些想要访问共享资源的线程。当共享资源的状态发生变化时,它可以被用来通知被互斥锁阻塞的线程。我在文章举了一个两人访问信箱的例子,并用代码实现了这个过程。 ### [](https://jums.gitbook.io/36-lectures-on-golang/27-tiao-jian-bian-liang-sync.cond-shang#si-kao-ti) 思考题 `*sync.Cond`类型的值可以被传递吗?那`sync.Cond`类型的值呢? 感谢你的收听,我们下期再见。 [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页26 | sync.Mutex与sync.RWMutex](https://jums.gitbook.io/36-lectures-on-golang/26-sync.mutex-yu-sync.rwmutex) [下一页28 | 条件变量sync.Cond (下)](https://jums.gitbook.io/36-lectures-on-golang/28-tiao-jian-bian-liang-sync.cond-xia) 最后更新于2年前 这有帮助吗? --- # 44 答疑文章(三):说一说这些好问题 | MySql实战45讲 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2F4021568157-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FZd3BioERPxnjd2lahSFX%252Fuploads%252FCV8yF5TDl2odVE1pQewx%252Fimage.png%3Falt%3Dmedia%26token%3D54d4ceca-6f0a-4e0a-81ee-2b62caaefda0&width=768&dpr=4&quality=100&sign=3a5604b4&sv=2) 这是我们专栏的最后一篇答疑文章,今天我们来说说一些好问题。 在我看来,能够帮我们扩展一个逻辑的边界的问题,就是好问题。因为通过解决这样的问题,能够加深我们对这个逻辑的理解,或者帮我们关联到另外一个知识点,进而可以帮助我们建立起自己的知识网络。 在工作中会问好问题,是一个很重要的能力。 经过这段时间的学习,从评论区的问题我可以感觉出来,紧跟课程学习的同学,对SQL语句执行性能的感觉越来越好了,提出的问题也越来越细致和精准了。 接下来,我们就一起看看同学们在评论区提到的这些好问题。在和你一起分析这些问题的时候,我会指出它们具体是在哪篇文章出现的。同时,在回答这些问题的过程中,我会假设你已经掌握了这篇文章涉及的知识。当然,如果你印象模糊了,也可以跳回文章再复习一次。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/44-da-yi-wen-zhang-san-shuo-yi-shuo-zhe-xie-hao-wen-ti#join-de-xie-fa) join的写法 在第35篇文章[《join语句怎么优化?》](https://time.geekbang.org/column/article/80147) 中,我在介绍join执行顺序的时候,用的都是straight\_join。@郭健 同学在文后提出了两个问题: 1. 如果用left join的话,左边的表一定是驱动表吗? 2. 如果两个表的join包含多个条件的等值匹配,是都要写到on里面呢,还是只把一个条件写到on里面,其他条件写到where部分? 为了同时回答这两个问题,我来构造两个表a和b: Copy create table a(f1 int, f2 int, index(f1))engine=innodb; create table b(f1 int, f2 int)engine=innodb; insert into a values(1,1),(2,2),(3,3),(4,4),(5,5),(6,6); insert into b values(3,3),(4,4),(5,5),(6,6),(7,7),(8,8); 表a和b都有两个字段f1和f2,不同的是表a的字段f1上有索引。然后,我往两个表中都插入了6条记录,其中在表a和b中同时存在的数据有4行。 @郭健 同学提到的第二个问题,其实就是下面这两种写法的区别: Copy select * from a left join b on(a.f1=b.f1) and (a.f2=b.f2); /*Q1*/ select * from a left join b on(a.f1=b.f1) where (a.f2=b.f2);/*Q2*/ 我把这两条语句分别记为Q1和Q2。 首先,需要说明的是,这两个left join语句的语义逻辑并不相同。我们先来看一下它们的执行结果。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F87%2Fbd%2F871f890532349781fdc4a4287e9f91bd.png&width=768&dpr=4&quality=100&sign=c7c85d95&sv=2) 图1 两个join的查询结果 可以看到: * 语句Q1返回的数据集是6行,表a中即使没有满足匹配条件的记录,查询结果中也会返回一行,并将表b的各个字段值填成NULL。 * 语句Q2返回的是4行。从逻辑上可以这么理解,最后的两行,由于表b中没有匹配的字段,结果集里面b.f2的值是空,不满足where 部分的条件判断,因此不能作为结果集的一部分。 接下来,我们看看实际执行这两条语句时,MySQL是怎么做的。 我们先一起看看语句Q1的explain结果: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fb7%2F17%2Fb7f27917ceb0be90ef7b201f2794c817.png&width=768&dpr=4&quality=100&sign=91c9398a&sv=2) 图2 Q1的explain结果 可以看到,这个结果符合我们的预期: * 驱动表是表a,被驱动表是表b; * 由于表b的f1字段上没有索引,所以使用的是Block Nexted Loop Join(简称BNL) 算法。 看到BNL算法,你就应该知道这条语句的执行流程其实是这样的: 1. 把表a的内容读入join\_buffer 中。因为是select \* ,所以字段f1和f2都被放入join\_buffer了。 2. 顺序扫描表b,对于每一行数据,判断join条件(也就是a.f1=b.f1 and a.f2=b.f2)是否满足,满足条件的记录, 作为结果集的一行返回。如果语句中有where子句,需要先判断where部分满足条件后,再返回。 3. 表b扫描完成后,对于没有被匹配的表a的行(在这个例子中就是(1,1)、(2,2)这两行),把剩余字段补上NULL,再放入结果集中。 对应的流程图如下: ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F8f%2Fd7%2F8fd4b4b179fb84caaecece84b6406ad7.jpg&width=768&dpr=4&quality=100&sign=5b70e050&sv=2) 图3 left join -BNL算法 可以看到,这条语句确实是以表a为驱动表,而且从执行效果看,也和使用straight\_join是一样的。 你可能会想,语句Q2的查询结果里面少了最后两行数据,是不是就是把上面流程中的步骤3去掉呢?我们还是先看一下语句Q2的expain结果吧。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Ff5%2F9c%2Ff5712c56dc84d331990409a5c313ea9c.png&width=768&dpr=4&quality=100&sign=be3842af&sv=2) 图4 Q2的explain结果 这里先和你说一句题外话,专栏马上就结束了,我也和你一起根据explain结果“脑补”了很多次一条语句的执行流程了,所以我希望你已经具备了这个能力。今天,我们再一起分析一次SQL语句的explain结果。 可以看到,这条语句是以表b为驱动表的。而如果一条join语句的Extra字段什么都没写的话,就表示使用的是Index Nested-Loop Join(简称NLJ)算法。 因此,语句Q2的执行流程是这样的:顺序扫描表b,每一行用b.f1到表a中去查,匹配到记录后判断a.f2=b.f2是否满足,满足条件的话就作为结果集的一部分返回。 那么,**为什么语句Q1和Q2这两个查询的执行流程会差距这么大呢?**其实,这是因为优化器基于Q2这个查询的语义做了优化。 为了理解这个问题,我需要再和你交代一个背景知识点:在MySQL里,NULL跟任何值执行等值判断和不等值判断的结果,都是NULL。这里包括, select NULL = NULL 的结果,也是返回NULL。 因此,语句Q2里面where a.f2=b.f2就表示,查询结果里面不会包含b.f2是NULL的行,这样这个left join的语义就是“找到这两个表里面,f1、f2对应相同的行。对于表a中存在,而表b中匹配不到的行,就放弃”。 这样,这条语句虽然用的是left join,但是语义跟join是一致的。 因此,优化器就把这条语句的left join改写成了join,然后因为表a的f1上有索引,就把表b作为驱动表,这样就可以用上NLJ 算法。在执行explain之后,你再执行show warnings,就能看到这个改写的结果,如图5所示。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fd7%2Fab%2Fd74878e7469edb8b713a18c6158530ab.png&width=768&dpr=4&quality=100&sign=6997774f&sv=2) 图5 Q2的改写结果 这个例子说明,即使我们在SQL语句中写成left join,执行过程还是有可能不是从左到右连接的。也就是说,**使用left join时,左边的表不一定是驱动表。** 这样看来,**如果需要left join的语义,就不能把被驱动表的字段放在where条件里面做等值判断或不等值判断,必须都写在on里面。**那如果是join语句呢? 这时候,我们再看看这两条语句: Copy select * from a join b on(a.f1=b.f1) and (a.f2=b.f2); /*Q3*/ select * from a join b on(a.f1=b.f1) where (a.f2=b.f2);/*Q4*/ 我们再使用一次看explain 和 show warnings的方法,看看优化器是怎么做的。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fd9%2Ff5%2Fd9952e4c2150bc649c7f2977e6ea80f5.png&width=768&dpr=4&quality=100&sign=8501688&sv=2) 图6 join语句改写 可以看到,这两条语句都被改写成: Copy select * from a join b where (a.f1=b.f1) and (a.f2=b.f2); 执行计划自然也是一模一样的。 也就是说,在这种情况下,join将判断条件是否全部放在on部分就没有区别了。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/44-da-yi-wen-zhang-san-shuo-yi-shuo-zhe-xie-hao-wen-ti#simple-nested-loop-join-de-xing-neng-wen-ti) Simple Nested Loop Join 的性能问题 我们知道,join语句使用不同的算法,对语句的性能影响会很大。在第34篇文章[《到底可不可以使用join?》](https://time.geekbang.org/column/article/79700) 的评论区中,@书策稠浊 和 @朝夕心 两位同学提了一个很不错的问题。 我们在文中说到,虽然BNL算法和Simple Nested Loop Join 算法都是要判断M\*N次(M和N分别是join的两个表的行数),但是Simple Nested Loop Join 算法的每轮判断都要走全表扫描,因此性能上BNL算法执行起来会快很多。 为了便于说明,我还是先为你简单描述一下这两个算法。 BNL算法的执行逻辑是: 1. 首先,将驱动表的数据全部读入内存join\_buffer中,这里join\_buffer是无序数组; 2. 然后,顺序遍历被驱动表的所有行,每一行数据都跟join\_buffer中的数据进行匹配,匹配成功则作为结果集的一部分返回。 Simple Nested Loop Join算法的执行逻辑是:顺序取出驱动表中的每一行数据,到被驱动表去做全表扫描匹配,匹配成功则作为结果集的一部分返回。 这两位同学的疑问是,Simple Nested Loop Join算法,其实也是把数据读到内存里,然后按照匹配条件进行判断,为什么性能差距会这么大呢? 解释这个问题,需要用到MySQL中索引结构和Buffer Pool的相关知识点: 1. 在对被驱动表做全表扫描的时候,如果数据没有在Buffer Pool中,就需要等待这部分数据从磁盘读入; 从磁盘读入数据到内存中,会影响正常业务的Buffer Pool命中率,而且这个算法天然会对被驱动表的数据做多次访问,更容易将这些数据页放到Buffer Pool的头部(请参考[第35篇文章](https://time.geekbang.org/column/article/80147) 中的相关内容); 2. 即使被驱动表数据都在内存中,每次查找“下一个记录的操作”,都是类似指针操作。而join\_buffer中是数组,遍历的成本更低。 所以说,BNL算法的性能会更好。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/44-da-yi-wen-zhang-san-shuo-yi-shuo-zhe-xie-hao-wen-ti#distinct-he-group-by-de-xing-neng) distinct 和 group by的性能 在第37篇文章[《什么时候会使用内部临时表?》](https://time.geekbang.org/column/article/80477) 中,@老杨同志 提了一个好问题:如果只需要去重,不需要执行聚合函数,distinct 和group by哪种效率高一些呢? 我来展开一下他的问题:如果表t的字段a上没有索引,那么下面这两条语句: Copy select a from t group by a order by null; select distinct a from t; 的性能是不是相同的? 首先需要说明的是,这种group by的写法,并不是SQL标准的写法。标准的group by语句,是需要在select部分加一个聚合函数,比如: Copy select a,count(*) from t group by a order by null; 这条语句的逻辑是:按照字段a分组,计算每组的a出现的次数。在这个结果里,由于做的是聚合计算,相同的a只出现一次。 > 备注:这里你可以顺便复习一下[第37篇文章](https://time.geekbang.org/column/article/80477) > 中关于group by的相关内容。 没有了count(\*)以后,也就是不再需要执行“计算总数”的逻辑时,第一条语句的逻辑就变成是:按照字段a做分组,相同的a的值只返回一行。而这就是distinct的语义,所以不需要执行聚合函数时,distinct 和group by这两条语句的语义和执行流程是相同的,因此执行性能也相同。 这两条语句的执行流程是下面这样的。 1. 创建一个临时表,临时表有一个字段a,并且在这个字段a上创建一个唯一索引; 2. 遍历表t,依次取数据插入临时表中: * 如果发现唯一键冲突,就跳过; * 否则插入成功; 3. 遍历完成后,将临时表作为结果集返回给客户端。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/44-da-yi-wen-zhang-san-shuo-yi-shuo-zhe-xie-hao-wen-ti#bei-ku-zi-zeng-zhu-jian-wen-ti) 备库自增主键问题 除了性能问题,大家对细节的追问也很到位。在第39篇文章[《自增主键为什么不是连续的?》](https://time.geekbang.org/column/article/80531) 评论区,@帽子掉了 同学问到:在binlog\_format=statement时,语句A先获取id=1,然后语句B获取id=2;接着语句B提交,写binlog,然后语句A再写binlog。这时候,如果binlog重放,是不是会发生语句B的id为1,而语句A的id为2的不一致情况呢? 首先,这个问题默认了“自增id的生成顺序,和binlog的写入顺序可能是不同的”,这个理解是正确的。 其次,这个问题限定在statement格式下,也是对的。因为row格式的binlog就没有这个问题了,Write row event里面直接写了每一行的所有字段的值。 而至于为什么不会发生不一致的情况,我们来看一下下面的这个例子。 Copy create table t(id int auto_increment primary key); insert into t values(null); ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2Fb5%2F25%2Fb55b2167aa301d899ccc86a00b496b25.png&width=768&dpr=4&quality=100&sign=f9012b5e&sv=2) 图7 insert 语句的binlog 可以看到,在insert语句之前,还有一句SET INSERT\_ID=1。这条命令的意思是,这个线程里下一次需要用到自增值的时候,不论当前表的自增值是多少,固定用1这个值。 这个SET INSERT\_ID语句是固定跟在insert语句之前的,比如@帽子掉了同学提到的场景,主库上语句A的id是1,语句B的id是2,但是写入binlog的顺序先B后A,那么binlog就变成: Copy SET INSERT_ID=2; 语句B; SET INSERT_ID=1; 语句A; 你看,在备库上语句B用到的INSERT\_ID依然是2,跟主库相同。 因此,即使两个INSERT语句在主备库的执行顺序不同,自增主键字段的值也不会不一致。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/44-da-yi-wen-zhang-san-shuo-yi-shuo-zhe-xie-hao-wen-ti#xiao-jie) 小结 今天这篇答疑文章,我选了4个好问题和你分享,并做了分析。在我看来,能够提出好问题,首先表示这些同学理解了我们文章的内容,进而又做了深入思考。有你们在认真的阅读和思考,对我来说是鼓励,也是动力。 说实话,短短的三篇答疑文章无法全部展开同学们在评论区留下的高质量问题,之后有的同学还会二刷,也会有新的同学加入,大家想到新的问题就请给我留言吧,我会继续关注评论区,和你在评论区交流。 老规矩,答疑文章也是要有课后思考题的。 在[第8篇文章](https://time.geekbang.org/column/article/70562) 的评论区, @XD同学提到一个问题:他查看了一下innodb\_trx,发现这个事务的trx\_id是一个很大的数(281479535353408),而且似乎在同一个session中启动的会话得到的trx\_id是保持不变的。当执行任何加写锁的语句后,trx\_id都会变成一个很小的数字(118378)。 你可以通过实验验证一下,然后分析看看,事务id的分配规则是什么,以及MySQL为什么要这么设计呢? 你可以把你的结论和分析写在留言区,我会在下一篇文章和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 ### [](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/44-da-yi-wen-zhang-san-shuo-yi-shuo-zhe-xie-hao-wen-ti#shang-qi-wen-ti-shi-jian) 上期问题时间 上期的问题是,怎么给分区表t创建自增主键。由于MySQL要求主键包含所有的分区字段,所以肯定是要创建联合主键的。 这时候就有两种可选:一种是(ftime, id),另一种是(id, ftime)。 如果从利用率上来看,应该使用(ftime, id)这种模式。因为用ftime做分区key,说明大多数语句都是包含ftime的,使用这种模式,可以利用前缀索引的规则,减少一个索引。 这时的建表语句是: Copy CREATE TABLE `t` ( `id` int(11) NOT NULL AUTO_INCREMENT, `ftime` datetime NOT NULL, `c` int(11) DEFAULT NULL, PRIMARY KEY (`ftime`,`id`) ) ENGINE=MyISAM DEFAULT CHARSET=latin1 PARTITION BY RANGE (YEAR(ftime)) (PARTITION p_2017 VALUES LESS THAN (2017) ENGINE = MyISAM, PARTITION p_2018 VALUES LESS THAN (2018) ENGINE = MyISAM, PARTITION p_2019 VALUES LESS THAN (2019) ENGINE = MyISAM, PARTITION p_others VALUES LESS THAN MAXVALUE ENGINE = MyISAM); 当然,我的建议是你要尽量使用InnoDB引擎。InnoDB表要求至少有一个索引,以自增字段作为第一个字段,所以需要加一个id的单独索引。 Copy CREATE TABLE `t` ( `id` int(11) NOT NULL AUTO_INCREMENT, `ftime` datetime NOT NULL, `c` int(11) DEFAULT NULL, PRIMARY KEY (`ftime`,`id`), KEY `id` (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=latin1 PARTITION BY RANGE (YEAR(ftime)) (PARTITION p_2017 VALUES LESS THAN (2017) ENGINE = InnoDB, PARTITION p_2018 VALUES LESS THAN (2018) ENGINE = InnoDB, PARTITION p_2019 VALUES LESS THAN (2019) ENGINE = InnoDB, PARTITION p_others VALUES LESS THAN MAXVALUE ENGINE = InnoDB); 当然把字段反过来,创建成: Copy PRIMARY KEY (`id`,`ftime`), KEY `id` (`ftime`) 也是可以的。 评论区留言点赞板: > @夹心面包 、@郭江伟 同学提到了最后一种方案。 > @aliang 同学提了一个好问题,关于open\_files\_limit和innodb\_open\_files的关系,我在回复中做了说明,大家可以看一下。 > @万勇 提了一个好问题,实际上对于现在官方的版本,将字段加在中间还是最后,在性能上是没差别的。但是,我建议大家养成习惯(如果你是DBA就帮业务开发同学养成习惯),将字段加在最后面,因为这样还是比较方便操作的。这个问题,我也在评论的答复中做了说明,你可以看一下。 ![](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F09%2F77%2F09c1073f99cf71d2fb162a716b5fa577.jpg&width=768&dpr=4&quality=100&sign=e5471b04&sv=2) [Previous43 要不要使用分区表?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/43-yao-bu-yao-shi-yong-fen-qu-biao) [Next45 自增id用完怎么办?](https://jums.gitbook.io/mysql-shi-zhan-45-jiang/45-zi-zeng-id-yong-wan-zen-mo-ban) Last updated 2 years ago Was this helpful? --- # 33 | 临时对象池sync.Pool | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252FwvMTVnFtOWA5alwEDKYo%252Fimage.png%3Falt%3Dmedia%26token%3D2f75d51d-e9f8-487a-963e-b5d263953906&width=768&dpr=4&quality=100&sign=18ba1006&sv=2) 到目前为止,我们已经一起学习了 Go 语言标准库中最重要的那几个同步工具,这包括非常经典的互斥锁、读写锁、条件变量和原子操作,以及 Go 语言特有的几个同步工具: 1. `sync/atomic.Value`; 2. `sync.Once`; 3. `sync.WaitGroup` 4. `context.Context`。 今天,我们来讲 Go 语言标准库中的另一个同步工具:`sync.Pool`。 `sync.Pool`类型可以被称为临时对象池,它的值可以被用来存储临时的对象。与 Go 语言的很多同步工具一样,`sync.Pool`类型也属于结构体类型,它的值在被真正使用之后,就不应该再被复制了。 这里的“临时对象”的意思是:不需要持久使用的某一类值。这类值对于程序来说可有可无,但如果有的话会明显更好。它们的创建和销毁可以在任何时候发生,并且完全不会影响到程序的功能。 同时,它们也应该是无需被区分的,其中的任何一个值都可以代替另一个。如果你的某类值完全满足上述条件,那么你就可以把它们存储到临时对象池中。 你可能已经想到了,我们可以把临时对象池当作针对某种数据的缓存来用。实际上,在我看来,临时对象池最主要的用途就在于此。 `sync.Pool`类型只有两个方法——`Put`和`Get`。前者用于在当前的池中存放临时对象,它接受一个`interface{}`类型的参数;而后者则被用于从当前的池中获取临时对象,它会返回一个`interface{}`类型的值。 更具体地说,这个类型的`Get`方法可能会从当前的池中删除掉任何一个值,然后把这个值作为结果返回。如果此时当前的池中没有任何值,那么这个方法就会使用当前池的`New`字段创建一个新值,并直接将其返回。 `sync.Pool`类型的`New`字段代表着创建临时对象的函数。它的类型是没有参数但有唯一结果的函数类型,即:`func() interface{}`。 这个函数是`Get`方法最后的临时对象获取手段。`Get`方法如果到了最后,仍然无法获取到一个值,那么就会调用该函数。该函数的结果值并不会被存入当前的临时对象池中,而是直接返回给`Get`方法的调用方。 这里的`New`字段的实际值需要我们在初始化临时对象池的时候就给定。否则,在我们调用它的`Get`方法的时候就有可能会得到`nil`。所以,`sync.Pool`类型并不是开箱即用的。不过,这个类型也就只有这么一个公开的字段,因此初始化起来也并不麻烦。 举个例子。标准库代码包`fmt`就使用到了`sync.Pool`类型。这个包会创建一个用于缓存某类临时对象的`sync.Pool`类型值,并将这个值赋给一个名为`ppFree`的变量。这类临时对象可以识别、格式化和暂存需要打印的内容。 复制 var ppFree = sync.Pool{ New: func() interface{} { return new(pp) }, } 临时对象池`ppFree`的`New`字段在被调用的时候,总是会返回一个全新的`pp`类型值的指针(即临时对象)。这就保证了`ppFree`的`Get`方法总能返回一个可以包含需要打印内容的值。 `pp`类型是`fmt`包中的私有类型,它有很多实现了不同功能的方法。不过,这里的重点是,它的每一个值都是独立的、平等的和可重用的。 更具体地说,这些对象既互不干扰,又不会受到外部状态的影响。它们几乎只针对某个需要打印内容的缓冲区而已。由于`fmt`包中的代码在真正使用这些临时对象之前,总是会先对其进行重置,所以它们并不在意取到的是哪一个临时对象。 这就是临时对象的平等性的具体体现。另外,这些代码在使用完临时对象之后,都会先抹掉其中已缓冲的内容,然后再把它存放到`ppFree`中。这样就为重用这类临时对象做好了准备。 众所周知的`fmt.Println`、`fmt.Printf`等打印函数都是如此使用`ppFree`,以及其中的临时对象的。因此,在程序同时执行很多的打印函数调用的时候,`ppFree`可以及时地把它缓存的临时对象提供给它们,以加快执行的速度。 而当程序在一段时间内不再执行打印函数调用时,`ppFree`中的临时对象又能够被及时地清理掉,以节省内存空间。显然,在这个维度上,临时对象池可以帮助程序实现可伸缩性。这就是它的最大价值。 我想,到了这里你已经清楚了临时对象池的基本功能、使用方式、适用场景和存在意义。我们下面来讨论一下它的一些内部机制,这样,我们就可以更好地利用它做更多的事。 首先,我来问你一个问题。这个问题很可能也是你想问的。今天的问题是:为什么说临时对象池中的值会被及时地清理掉? 这里的典型回答是:因为,Go 语言运行时系统中的垃圾回收器,所以在每次开始执行之前,都会对所有已创建的临时对象池中的值进行全面地清除。 ### [](https://jums.gitbook.io/36-lectures-on-golang/33-lin-shi-dui-xiang-chi-sync.pool#wen-ti-jie-xi) 问题解析 我在前面已经向你讲述了临时对象会在什么时候被创建,下面我再来详细说说它会在什么时候被销毁。 `sync`包在被初始化的时候,会向 Go 语言运行时系统注册一个函数,这个函数的功能就是清除所有已创建的临时对象池中的值。我们可以把它称为池清理函数。一旦池清理函数被注册到了 Go 语言运行时系统,后者在每次即将执行垃圾回收时就都会执行前者。 另外,在`sync`包中还有一个包级私有的全局变量。这个变量代表了当前的程序中使用的所有临时对象池的汇总,它是元素类型为`*sync.Pool`的切片。我们可以称之为池汇总列表。 通常,在一个临时对象池的`Put`方法或`Get`方法第一次被调用的时候,这个池就会被添加到池汇总列表中。正因为如此,池清理函数总是能访问到所有正在被真正使用的临时对象池。 更具体地说,池清理函数会遍历池汇总列表。对于其中的每一个临时对象池,它都会先将池中所有的私有临时对象和共享临时对象列表都置为`nil`,然后再把这个池中的所有本地池列表都销毁掉。 最后,池清理函数会把池汇总列表重置为空的切片。如此一来,这些池中存储的临时对象就全部被清除干净了。如果临时对象池以外的代码再无对它们的引用,那么在稍后的垃圾回收过程中,这些临时对象就会被当作垃圾销毁掉,它们占用的内存空间也会被回收以备他用。 以上,就是我对临时对象清理的进一步说明。首先需要记住的是,池清理函数和池汇总列表的含义,以及它们起到的关键作用。一旦理解了这些,那么在有人问到你这个问题的时候,你应该就可以从容地应对了。 不过,我们在这里还碰到了几个新的词,比如:私有临时对象、共享临时对象列表和本地池。这些都代表着什么呢?这就涉及了下面的问题。 ### [](https://jums.gitbook.io/36-lectures-on-golang/33-lin-shi-dui-xiang-chi-sync.pool#zhi-shi-kuo-zhan) 知识扩展 #### [](https://jums.gitbook.io/36-lectures-on-golang/33-lin-shi-dui-xiang-chi-sync.pool#wen-ti-1-lin-shi-dui-xiang-chi-cun-chu-zhi-suo-yong-de-shu-ju-jie-gou-shi-zen-yang-de) 问题 1:临时对象池存储值所用的数据结构是怎样的? 在临时对象池中,有一个多层的数据结构。正因为有了它的存在,临时对象池才能够非常高效地存储大量的值。 这个数据结构的顶层,我们可以称之为本地池列表,不过更确切地说,它是一个数组。这个列表的长度,总是与 Go 语言调度器中的 P 的数量相同。 还记得吗?Go 语言调度器中的 P 是 processor 的缩写,它指的是一种可以承载若干个 G、且能够使这些 G 适时地与 M 进行对接,并得到真正运行的中介。 这里的 G 正是 goroutine 的缩写,而 M 则是 machine 的缩写,后者指代的是系统级的线程。正因为有了 P 的存在,G 和 M 才能够进行灵活、高效的配对,从而实现强大的并发编程模型。 P 存在的一个很重要的原因是为了分散并发程序的执行压力,而让临时对象池中的本地池列表的长度与 P 的数量相同的主要原因也是分散压力。这里所说的压力包括了存储和性能两个方面。在说明它们之前,我们先来探索一下临时对象池中的那个数据结构。 在本地池列表中的每个本地池都包含了三个字段(或者说组件),它们是:存储私有临时对象的字段`private`、代表了共享临时对象列表的字段`shared`,以及一个`sync.Mutex`类型的嵌入字段。 实际上,每个本地池都对应着一个 P。我们都知道,一个 goroutine 要想真正运行就必须先与某个 P 产生关联。也就是说,一个正在运行的 goroutine 必然会关联着某个 P。 在程序调用临时对象池的`Put`方法或`Get`方法的时候,总会先试图从该临时对象池的本地池列表中,获取与之对应的本地池,依据的就是与当前的 goroutine 关联的那个 P 的 ID。 换句话说,一个临时对象池的`Put`方法或`Get`方法会获取到哪一个本地池,完全取决于调用它的代码所在的 goroutine 关联的那个 P。 既然说到了这里,那么紧接着就会有下面这个问题。 #### [](https://jums.gitbook.io/36-lectures-on-golang/33-lin-shi-dui-xiang-chi-sync.pool#wen-ti-2-lin-shi-dui-xiang-chi-shi-zen-yang-li-yong-nei-bu-shu-ju-jie-gou-lai-cun-qu-zhi-de) 问题 2:临时对象池是怎样利用内部数据结构来存取值的? 临时对象池的`Put`方法总会先试图把新的临时对象,存储到对应的本地池的`private`字段中,以便在后面获取临时对象的时候,可以快速地拿到一个可用的值。只有当这个`private`字段已经存有某个值时,该方法才会去访问本地池的`shared`字段。 相应的,临时对象池的`Get`方法,总会先试图从对应的本地池的`private`字段处获取一个临时对象。只有当这个`private`字段的值为`nil`时,它才会去访问本地池的`shared`字段。 一个本地池的`shared`字段原则上可以被任何 goroutine 中的代码访问到,不论这个 goroutine 关联的是哪一个 P。这也是我把它叫做共享临时对象列表的原因。相比之下,一个本地池的`private`字段,只可能被与之对应的那个 P 所关联的 goroutine 中的代码访问到,所以可以说,它是 P 级私有的。 以临时对象池的`Put`方法为例,它一旦发现对应的本地池的`private`字段已存有值,就会去访问这个本地池的`shared`字段。当然,由于`shared`字段是共享的,所以此时必须受到互斥锁的保护。 还记得本地池嵌入的那个`sync.Mutex`类型的字段吗?它就是这里用到的互斥锁,也就是说,本地池本身就拥有互斥锁的功能。`Put`方法会在互斥锁的保护下,把新的临时对象追加到共享临时对象列表的末尾。 相应的,临时对象池的`Get`方法在发现对应本地池的`private`字段未存有值时,也会去访问后者的`shared`字段。它会在互斥锁的保护下,试图把该共享临时对象列表中的最后一个元素值取出并作为结果。 不过,这里的共享临时对象列表也可能是空的,这可能是由于这个本地池中的所有临时对象都已经被取走了,也可能是当前的临时对象池刚被清理过。 无论原因是什么,`Get`方法都会去访问当前的临时对象池中的所有本地池,它会去逐个搜索它们的共享临时对象列表。 只要发现某个共享临时对象列表中包含元素值,它就会把该列表的最后一个元素值取出并作为结果返回。 当然了,即使这样也可能无法拿到一个可用的临时对象,比如,在所有的临时对象池都刚被大清洗的情况下就会是如此。 这时,`Get`方法就会使出最后的手段——调用可创建临时对象的那个函数。还记得吗?这个函数是由临时对象池的`New`字段代表的,并且需要我们在初始化临时对象池的时候给定。如果这个字段的值是`nil`,那么`Get`方法此时也只能返回`nil`了。 以上,就是我对这个问题的较完整回答。 ### [](https://jums.gitbook.io/36-lectures-on-golang/33-lin-shi-dui-xiang-chi-sync.pool#zong-jie) 总结 今天,我们一起讨论了另一个比较有用的同步工具——`sync.Pool`类型,它的值被我称为临时对象池。 临时对象池有一个`New`字段,我们在初始化这个池的时候最好给定它。临时对象池还拥有两个方法,即:`Put`和`Get`,它们分别被用于向池中存放临时对象,和从池中获取临时对象。 临时对象池中存储的每一个值都应该是独立的、平等的和可重用的。我们应该既不用关心从池中拿到的是哪一个值,也不用在意这个值是否已经被使用过。 要完全做到这两点,可能会需要我们额外地写一些代码。不过,这个代码量应该是微乎其微的,就像`fmt`包对临时对象池的用法那样。所以,在选用临时对象池的时候,我们必须要把它将要存储的值的特性考虑在内。 在临时对象池的内部,有一个多层的数据结构支撑着对临时对象的存储。它的顶层是本地池列表,其中包含了与某个 P 对应的那些本地池,并且其长度与 P 的数量总是相同的。 在每个本地池中,都包含一个私有的临时对象和一个共享的临时对象列表。前者只能被其对应的 P 所关联的那个 goroutine 中的代码访问到,而后者却没有这个约束。从另一个角度讲,前者用于临时对象的快速存取,而后者则用于临时对象的池内共享。 正因为有了这样的数据结构,临时对象池才能够有效地分散存储压力和性能压力。同时,又因为临时对象池的`Get`方法对这个数据结构的妙用,才使得其中的临时对象能够被高效地利用。比如,该方法有时候会从其他的本地池的共享临时对象列表中,“偷取”一个临时对象。 这样的内部结构和存取方式,让临时对象池成为了一个特点鲜明的同步工具。它存储的临时对象都应该是拥有较长生命周期的值,并且,这些值不应该被某个 goroutine 中的代码长期的持有和使用。 因此,临时对象池非常适合用作针对某种数据的缓存。从某种角度讲,临时对象池可以帮助程序实现可伸缩性,这也正是它的最大价值。 思考题 今天的思考题是:怎样保证一个临时对象池中总有比较充足的临时对象? 请从临时对象池的初始化和方法调用两个方面作答。必要时可以参考`fmt`包以及 demo70.go 文件中使用临时对象池的方式。 感谢你的收听,我们下次再见。 [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页32 | context.Context类型](https://jums.gitbook.io/36-lectures-on-golang/32-context.context-lei-xing) [下一页34 | 并发安全字典sync.Map (上)](https://jums.gitbook.io/36-lectures-on-golang/34-bing-fa-an-quan-zi-dian-sync.map-shang) 最后更新于2年前 这有帮助吗? --- # 42 | bufio包中的数据类型 (上) | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252FtXDV08RbEzSpNiUFacxZ%252Fimage.png%3Falt%3Dmedia%26token%3D2bc329c4-f1c9-41fe-8e8e-c1220cc3e881&width=768&dpr=4&quality=100&sign=46ee56d7&sv=2) 今天,我们来讲另一个与 I/O 操作强相关的代码包`bufio`。`bufio`是“buffered I/O”的缩写。顾名思义,这个代码包中的程序实体实现的 I/O 操作都内置了缓冲区。 `bufio`包中的数据类型主要有: 1. `Reader`; 2. `Scanner`; 3. `Writer`和`ReadWriter`。 与`io`包中的数据类型类似,这些类型的值也都需要在初始化的时候,包装一个或多个简单 I/O 接口类型的值。(这里的简单 I/O 接口类型指的就是`io`包中的那些简单接口。) 下面,我们将通过一系列问题对`bufio.Reader`类型和`bufio.Writer`类型进行讨论(以前者为主)。**今天我的问题是:**`**bufio.Reader**`**类型值中的缓冲区起着怎样的作用?** **这道题的典型回答是这样的。** `bufio.Reader`类型的值(以下简称`Reader`值)内的缓冲区其实就是一个数据存储中介,它介于底层读取器与读取方法及其调用方之间。所谓的底层读取器,就是在初始化此类值的时候传入的`io.Reader`类型的参数值。 `Reader`值的读取方法一般都会先从其所属值的缓冲区中读取数据。同时,在必要的时候,它们还会预先从底层读取器那里读出一部分数据,并暂存于缓冲区之中以备后用。 有这样一个缓冲区的好处是,可以在大多数的时候降低读取方法的执行时间。虽然读取方法有时还要负责填充缓冲区,但从总体来看,读取方法的平均执行时间一般都会因此有大幅度的缩短。 ### [](https://jums.gitbook.io/36-lectures-on-golang/42-bufio-bao-zhong-de-shu-ju-lei-xing-shang#wen-ti-jie-xi) 问题解析 `bufio.Reader`类型并不是开箱即用的,因为它包含了一些需要显式初始化的字段。为了让你能在后面更好地理解它的读取方法的内部流程,我先在这里简要地解释一下这些字段,如下所示。 1. `buf`:`[]byte`类型的字段,即字节切片,代表缓冲区。虽然它是切片类型的,但是其长度却会在初始化的时候指定,并在之后保持不变。 2. `rd`:`io.Reader`类型的字段,代表底层读取器。缓冲区中的数据就是从这里拷贝来的。 3. `r`:`int`类型的字段,代表对缓冲区进行下一次读取时的开始索引。我们可以称它为已读计数。 4. `w`:`int`类型的字段,代表对缓冲区进行下一次写入时的开始索引。我们可以称之为已写计数。 5. `err`:`error`类型的字段。它的值用于表示在从底层读取器获得数据时发生的错误。这里的值在被读取或忽略之后,该字段会被置为`nil`。 6. `lastByte`:`int`类型的字段,用于记录缓冲区中最后一个被读取的字节。读回退时会用到它的值。 7. `lastRuneSize`:`int`类型的字段,用于记录缓冲区中最后一个被读取的 Unicode 字符所占用的字节数。读回退的时候会用到它的值。这个字段只会在其所属值的`ReadRune`方法中才会被赋予有意义的值。在其他情况下,它都会被置为`-1`。 `bufio`包为我们提供了两个用于初始化`Reader`值的函数,分别叫`NewReader`和`NewReaderSize`,它们都会返回一个`*bufio.Reader`类型的值。 `NewReader`函数初始化的`Reader`值会拥有一个默认尺寸的缓冲区。这个默认尺寸是 4096 个字节,即:4 KB。而`NewReaderSize`函数则将缓冲区尺寸的决定权抛给了使用方。 由于这里的缓冲区在一个`Reader`值的生命周期内其尺寸不可变,所以在有些时候是需要做一些权衡的。`NewReaderSize`函数就提供了这样一个途径。 在`bufio.Reader`类型拥有的读取方法中,`Peek`方法和`ReadSlice`方法都会调用该类型一个名为`fill`的包级私有方法。`fill`方法的作用是填充内部缓冲区。我们在这里就先重点说说它。 `fill`方法会先检查其所属值的已读计数。如果这个计数不大于`0`,那么有两种可能。一种可能是其缓冲区中的字节都是全新的,也就是说它们都没有被读取过,另一种可能是缓冲区刚被压缩过。 对缓冲区的压缩包括两个步骤。第一步,把缓冲区中在`[已读计数, 已写计数)`范围之内的所有元素值(或者说字节)都依次拷贝到缓冲区的头部。\ \ 比如,把缓冲区中与已读计数代表的索引对应字节拷贝到索引`0`的位置,并把紧挨在它后边的字节拷贝到索引`1`的位置,以此类推。\ \ 这一步之所以不会有任何副作用,是因为它基于两个事实:第一事实,已读计数之前的字节都已经被读取过,并且肯定不会再被读取了,因此把它们覆盖掉是安全的。\ \ 第二个事实,在压缩缓冲区之后,已写计数之后的字节只可能是已被读取过的字节,或者是已被拷贝到缓冲区头部的未读字节,又或者是代表未曾被填入数据的零值`0x00`。所以,后续的新字节是可以被写到这些位置上的。\ \ 在压缩缓冲区的第二步中,`fill`方法会把已写计数的新值设定为原已写计数与原已读计数的差。这个差所代表的索引,就是压缩后第一次写入字节时的开始索引。\ \ 另外,该方法还会把已读计数的值置为`0`。显而易见,在压缩之后,再读取字节就肯定要从缓冲区的头部开始读了。\ \ 实际上,`fill`方法只要在开始时发现其所属值的已读计数大于`0`,就会对缓冲区进行一次压缩。之后,如果缓冲区中还有可写的位置,那么该方法就会对其进行填充。\ \ 在填充缓冲区的时候,`fill`方法会试图从底层读取器那里,读取足够多的字节,并尽量把从已写计数代表的索引位置到缓冲区末尾之间的空间都填满。\ \ 在这个过程中,`fill`方法会及时地更新已写计数,以保证填充的正确性和顺序性。另外,它还会判断从底层读取器读取数据的时候,是否有错误发生。如果有,那么它就会把错误值赋给其所属值的`err`字段,并终止填充流程。\ \ 好了,到这里,我们暂告一个段落。在本题中,我对`bufio.Reader`类型的基本结构,以及相关的一些函数和方法进行了概括介绍,并且重点阐述了该类型的`fill`方法。\ \ 后者是我们在后面要说明的一些读取流程的重要组成部分。你起码要记住的是:这个`fill`方法大致都做了些什么。\ \ ### \ \ [](https://jums.gitbook.io/36-lectures-on-golang/42-bufio-bao-zhong-de-shu-ju-lei-xing-shang#zhi-shi-kuo-zhan)\ \ 知识扩展\ \ 问题 1:`bufio.Writer`类型值中缓冲的数据什么时候会被写到它的底层写入器?\ \ 我们先来看一下`bufio.Writer`类型都有哪些字段:\ \ 1. `err`:`error`类型的字段。它的值用于表示在向底层写入器写数据时发生的错误。\ \ 2. `buf`:`[]byte`类型的字段,代表缓冲区。在初始化之后,它的长度会保持不变。\ \ 3. `n`:`int`类型的字段,代表对缓冲区进行下一次写入时的开始索引。我们可以称之为已写计数。\ \ 4. `wr`:`io.Writer`类型的字段,代表底层写入器。\ \ \ `bufio.Writer`类型有一个名为`Flush`的方法,它的主要功能是把相应缓冲区中暂存的所有数据,都写到底层写入器中。数据一旦被写进底层写入器,该方法就会把它们从缓冲区中删除掉。\ \ 不过,这里的删除有时候只是逻辑上的删除而已。不论是否成功地写入了所有的暂存数据,`Flush`方法都会妥当处置,并保证不会出现重写和漏写的情况。该类型的字段`n`在此会起到很重要的作用。\ \ `bufio.Writer`类型值(以下简称`Writer`值)拥有的所有数据写入方法都会在必要的时候调用它的`Flush`方法。\ \ 比如,`Write`方法有时候会在把数据写进缓冲区之后,调用`Flush`方法,以便为后续的新数据腾出空间。`WriteString`方法的行为与之类似。\ \ 又比如,`WriteByte`方法和`WriteRune`方法,都会在发现缓冲区中的可写空间不足以容纳新的字节,或 Unicode 字符的时候,调用`Flush`方法。\ \ 此外,如果`Write`方法发现需要写入的字节太多,同时缓冲区已空,那么它就会跨过缓冲区,并直接把这些数据写到底层写入器中。\ \ 而`ReadFrom`方法,则会在发现底层写入器的类型是`io.ReaderFrom`接口的实现之后,直接调用其`ReadFrom`方法把参数值持有的数据写进去。\ \ 总之,在通常情况下,只要缓冲区中的可写空间无法容纳需要写入的新数据,`Flush`方法就一定会被调用。并且,`bufio.Writer`类型的一些方法有时候还会试图走捷径,跨过缓冲区而直接对接数据供需的双方。\ \ 你可以在理解了这些内部机制之后,有的放矢地编写你的代码。不过,在你把所有的数据都写入`Writer`值之后,再调用一下它的`Flush`方法,显然是最稳妥的。\ \ ### \ \ [](https://jums.gitbook.io/36-lectures-on-golang/42-bufio-bao-zhong-de-shu-ju-lei-xing-shang#zong-jie)\ \ 总结\ \ 今天我们从“`bufio.Reader`类型值中的缓冲区起着怎样的作用”这道问题入手,介绍了一部分 bufio 包中的数据类型,在下一次的分享中,我会沿着这个问题继续展开。\ \ 你对今天的内容有什么样的思考,可以给我留言,我们一起讨论。感谢你的收听,我们下期再见。\ \ [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)\ \ ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2)\ \ [上一页41 | io包中的接口和工具 (下)](https://jums.gitbook.io/36-lectures-on-golang/41-io-bao-zhong-de-jie-kou-he-gong-ju-xia)\ [下一页43 | bufio包中的数据类型(下)](https://jums.gitbook.io/36-lectures-on-golang/43-bufio-bao-zhong-de-shu-ju-lei-xing-xia)\ \ 最后更新于2年前\ \ 这有帮助吗? --- # 17 | go语句及其执行规则(下) | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252FTDa8btbk3b60AERmI0J2%252Fimage.png%3Falt%3Dmedia%26token%3D858bf04d-9c63-4cee-9173-6eeec63754e8&width=768&dpr=4&quality=100&sign=a79e2ee7&sv=2) [4MB\ \ 17.go语句及其执行规则(下).mp3](https://2852705636-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FkmtyDvkosJZE1Rr33XGZ%2Fuploads%2FmALZuZvXN5jciw6HKBkV%2F17.go%E8%AF%AD%E5%8F%A5%E5%8F%8A%E5%85%B6%E6%89%A7%E8%A1%8C%E8%A7%84%E5%88%99%EF%BC%88%E4%B8%8B%EF%BC%89.mp3?alt=media&token=3e0a6e15-c8bd-4e86-a671-6885b8985642) 你好,我是郝林,今天我们继续分享 go 语句执行规则的内容。 在上一篇文章中,我们讲到了 goroutine 在操作系统的并发编程体系,以及在 Go 语言并发编程模型中的地位和作用等一系列内容,今天我们继续来聊一聊这个话题。 ### [](https://jums.gitbook.io/36-lectures-on-golang/17-go-yu-ju-ji-qi-zhi-hang-gui-ze-xia#zhi-shi-kuo-zhan) 知识扩展 **问题 1:怎样才能让主 goroutine 等待其他 goroutine?** 我刚才说过,一旦主 goroutine 中的代码执行完毕,当前的 Go 程序就会结束运行,无论其他的 goroutine 是否已经在运行了。那么,怎样才能做到等其他的 goroutine 运行完毕之后,再让主 goroutine 结束运行呢? 其实有很多办法可以做到这一点。其中,最简单粗暴的办法就是让主 goroutine“小睡”一会儿。 复制 for i := 0; i < 10; i++ { go func() { fmt.Println(i) }() } time.Sleep(time.Millisecond * 500) 在`for`语句的后边,我调用了`time`包的`Sleep`函数,并把`time.Millisecond * 500`的结果作为参数值传给了它。`time.Sleep`函数的功能就是让当前的 goroutine(在这里就是主 goroutine)暂停运行一段时间,直到到达指定的恢复运行时间。 我们可以把一个相对的时间传给该函数,就像我在这里传入的“500 毫秒”那样。`time.Sleep`函数会在被调用时用当前的绝对时间,再加上相对时间计算出在未来的恢复运行时间。显然,一旦到达恢复运行时间,当前的 goroutine 就会从“睡眠”中醒来,并开始继续执行后边的代码。 这个办法是可行的,只要“睡眠”的时间不要太短就好。不过,问题恰恰就在这里,我们让主 goroutine“睡眠”多长时间才是合适的呢?如果“睡眠”太短,则很可能不足以让其他的 goroutine 运行完毕,而若“睡眠”太长则纯属浪费时间,这个时间就太难把握了。 你可能会想到,既然不容易预估时间,那我们就让其他的 goroutine 在运行完毕的时候告诉我们好了。这个思路很好,但怎么做呢? 你是否想到了通道呢?我们先创建一个通道,它的长度应该与我们手动启用的 goroutine 的数量一致。在每个手动启用的 goroutine 即将运行完毕的时候,我们都要向该通道发送一个值。 注意,这些发送表达式应该被放在它们的`go`函数体的最后面。对应的,我们还需要在`main`函数的最后从通道接收元素值,接收的次数也应该与手动启用的 goroutine 的数量保持一致。关于这些你可以到 [demo39.go](https://github.com/crazyjums/go_haolingeek/blob/master/article16/q2/demo39.go) 文件中,去查看具体的写法。 其中有一个细节你需要注意。我在声明通道`sign`的时候是以`chan struct{}`作为其类型的。其中的类型字面量`struct{}`有些类似于空接口类型`interface{}`,它代表了既不包含任何字段也不拥有任何方法的空结构体类型。 注意,`struct{}`类型值的表示法只有一个,即:`struct{}{}`。并且,它占用的内存空间是`0`字节。确切地说,这个值在整个 Go 程序中永远都只会存在一份。虽然我们可以无数次地使用这个值字面量,但是用到的却都是同一个值。 当我们仅仅把通道当作传递某种简单信号的介质的时候,用`struct{}`作为其元素类型是再好不过的了。顺便说一句,我在讲“结构体及其方法的使用法门”的时候留过一道与此相关的思考题,你可以返回去看一看。 再说回当下的问题,有没有比使用通道更好的方法?如果你知道标准库中的代码包`sync`的话,那么可能会想到`sync.WaitGroup`类型。没错,这是一个更好的答案。不过具体的使用方式我在后边讲`sync`包的时候再说。 **问题 2:怎样让我们启用的多个 goroutine 按照既定的顺序运行?** 在很多时候,当我沿着上面的主问题以及第一个扩展问题一路问下来的时候,应聘者往往会被这第二个扩展问题难住。 所以基于上一篇主问题中的代码,怎样做到让从`0`到`9`这几个整数按照自然数的顺序打印出来?你可能会说,我不用 goroutine 不就可以了嘛。没错,这样是可以,但是如果我不考虑这样做呢。你应该怎么解决这个问题? 当然了,众多应聘者回答的其他答案也是五花八门的,有的可行,有的不可行,还有的把原来的代码改得面目全非。我下面就来说说我的思路,以及心目中的答案吧。这个答案并不一定是最佳的,也许你在看完之后还可以想到更优的答案。 首先,我们需要稍微改造一下`for`语句中的那个`go`函数,要让它接受一个`int`类型的参数,并在调用它的时候把变量`i`的值传进去。为了不改动这个`go`函数中的其他代码,我们可以把它的这个参数也命名为`i`。 复制 for i := 0; i < 10; i++ { go func(i int) { fmt.Println(i) }(i) } 只有这样,Go 语言才能保证每个 goroutine 都可以拿到一个唯一的整数。其原因与`go`函数的执行时机有关。 我在前面已经讲过了。在`go`语句被执行时,我们传给`go`函数的参数`i`会先被求值,如此就得到了当次迭代的序号。之后,无论`go`函数会在什么时候执行,这个参数值都不会变。也就是说,`go`函数中调用的`fmt.Println`函数打印的一定会是那个当次迭代的序号。 然后,我们在着手改造`for`语句中的`go`函数。 复制 for i := uint32(0); i < 10; i++ { go func(i uint32) { fn := func() { fmt.Println(i) } trigger(i, fn) }(i) } 我在`go`函数中先声明了一个匿名的函数,并把它赋给了变量`fn`。这个匿名函数做的事情很简单,只是调用`fmt.Println`函数以打印`go`函数的参数`i`的值。 在这之后,我调用了一个名叫`trigger`的函数,并把`go`函数的参数`i`和刚刚声明的变量`fn`作为参数传给了它。注意,`for`语句声明的局部变量`i`和`go`函数的参数`i`的类型都变了,都由`int`变为了`uint32`。至于为什么,我一会儿再说。 再来说`trigger`函数。该函数接受两个参数,一个是`uint32`类型的参数`i`, 另一个是`func()`类型的参数`fn`。你应该记得,`func()`代表的是既无参数声明也无结果声明的函数类型。 复制 trigger := func(i uint32, fn func()) { for { if n := atomic.LoadUint32(&count); n == i { fn() atomic.AddUint32(&count, 1) break } time.Sleep(time.Nanosecond) } } `trigger`函数会不断地获取一个名叫`count`的变量的值,并判断该值是否与参数`i`的值相同。如果相同,那么就立即调用`fn`代表的函数,然后把`count`变量的值加`1`,最后显式地退出当前的循环。否则,我们就先让当前的 goroutine“睡眠”一个纳秒再进入下一个迭代。 注意,我操作变量`count`的时候使用的都是原子操作。这是由于`trigger`函数会被多个 goroutine 并发地调用,所以它用到的非本地变量`count`,就被多个用户级线程共用了。因此,对它的操作就产生了竞态条件(race condition),破坏了程序的并发安全性。 所以,我们总是应该对这样的操作加以保护,在`sync/atomic`包中声明了很多用于原子操作的函数。 另外,由于我选用的原子操作函数对被操作的数值的类型有约束,所以我才对`count`以及相关的变量和参数的类型进行了统一的变更(由`int`变为了`uint32`)。 纵观`count`变量、`trigger`函数以及改造后的`for`语句和`go`函数,我要做的是,让`count`变量成为一个信号,它的值总是下一个可以调用打印函数的`go`函数的序号。 这个序号其实就是启用 goroutine 时,那个当次迭代的序号。也正因为如此,`go`函数实际的执行顺序才会与`go`语句的执行顺序完全一致。此外,这里的`trigger`函数实现了一种自旋(spinning)。除非发现条件已满足,否则它会不断地进行检查。 最后要说的是,因为我依然想让主 goroutine 最后一个运行完毕,所以还需要加一行代码。不过既然有了`trigger`函数,我就没有再使用通道。 复制 trigger(10, func(){}) 调用`trigger`函数完全可以达到相同的效果。由于当所有我手动启用的 goroutine 都运行完毕之后,`count`的值一定会是`10`,所以我就把`10`作为了第一个参数值。又由于我并不想打印这个`10`,所以我把一个什么都不做的函数作为了第二个参数值。 总之,通过上述的改造,我使得异步发起的`go`函数得到了同步地(或者说按照既定顺序地)执行,你也可以动手自己试一试,感受一下。 **总结** 在本篇文章中,我们接着上一篇文章的主问题,讨论了当我们想让运行结果更加可控的时候,应该怎样去做。 主 goroutine 的运行若过早结束,那么我们的并发程序的功能就很可能无法全部完成。所以我们往往需要通过一些手段去进行干涉,比如调用`time.Sleep`函数或者使用通道。我们在后面的文章中还会讨论更高级的手段。 另外,`go`函数的实际执行顺序往往与其所属的`go`语句的执行顺序(或者说 goroutine 的启用顺序)不同,而且默认情况下的执行顺序是不可预知的。那怎样才能让这两个顺序一致呢?其实复杂的实现方式有不少,但是可能会把原来的代码改得面目全非。我在这里提供了一种比较简单、清晰的改造方案,供你参考。 总之,我希望通过上述基础知识以及三个连贯的问题帮你串起一条主线。这应该会让你更快地深入理解 goroutine 及其背后的并发编程模型,从而更加游刃有余地使用`go`语句。 **思考题** 1.`runtime`包中提供了哪些与模型三要素 G、P 和 M 相关的函数?(模型三要素内容在上一篇) [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页16 | go语句及其执行规则(上)](https://jums.gitbook.io/36-lectures-on-golang/16-go-yu-ju-ji-qi-zhi-hang-gui-ze-shang) [下一页18 | if语句、for语句和switch语句](https://jums.gitbook.io/36-lectures-on-golang/18-if-yu-ju-for-yu-ju-he-switch-yu-ju) 最后更新于2年前 这有帮助吗? --- # 30 | 原子操作(下) | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252FLPTVs5J6HmJtjKGNs16x%252Fimage.png%3Falt%3Dmedia%26token%3D8387a4d0-2bb1-46a8-8152-a347f105c507&width=768&dpr=4&quality=100&sign=1ad853e7&sv=2) [9MB\ \ 30.原子操作(下).mp3](https://2852705636-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FkmtyDvkosJZE1Rr33XGZ%2Fuploads%2FGcJQF2H4b6ixQlEsi10P%2F30.%E5%8E%9F%E5%AD%90%E6%93%8D%E4%BD%9C%EF%BC%88%E4%B8%8B%EF%BC%89.mp3?alt=media&token=ee6e2698-377b-4e2e-851a-483eea9a92b2) 你好,我是郝林,今天我们继续分享原子操作的内容。 我们接着上一篇文章的内容继续聊,上一篇我们提到了,`sync/atomic`包中的函数可以做的原子操作有:加法(add)、比较并交换(compare and swap,简称 CAS)、加载(load)、存储(store)和交换(swap)。并且以此衍生出了两个问题。 今天我们继续来看**第三个衍生问题:** 比较并交换操作与交换操作相比有什么不同?优势在哪里? 回答是:比较并交换操作即 CAS 操作,是有条件的交换操作,只有在条件满足的情况下才会进行值的交换。 所谓的交换指的是,把新值赋给变量,并返回变量的旧值。在进行 CAS 操作的时候,函数会先判断被操作变量的当前值,是否与我们预期的旧值相等。如果相等,它就把新值赋给该变量,并返回`true`以表明交换操作已进行;否则就忽略交换操作,并返回`false`。 可以看到,CAS 操作并不是单一的操作,而是一种操作组合。这与其他的原子操作都不同。正因为如此,它的用途要更广泛一些。例如,我们将它与`for`语句联用就可以实现一种简易的自旋锁(spinlock)。 复制 for { if atomic.CompareAndSwapInt32(&num2, 10, 0) { fmt.Println("The second number has gone to zero.") break } time.Sleep(time.Millisecond * 500) } 在`for`语句中的 CAS 操作可以不停地检查某个需要满足的条件,一旦条件满足就退出`for`循环。这就相当于,只要条件未被满足,当前的流程就会被一直“阻塞”在这里。 这在效果上与互斥锁有些类似。不过,它们的适用场景是不同的。我们在使用互斥锁的时候,总是假设共享资源的状态会被其他的 goroutine 频繁地改变。 而`for`语句加 CAS 操作的假设往往是:共享资源状态的改变并不频繁,或者,它的状态总会变成期望的那样。这是一种更加乐观,或者说更加宽松的做法。 **第四个衍生问题:** 假设我已经保证了对一个变量的写操作都是原子操作,比如:加或减、存储、交换等等,那我对它进行读操作的时候,还有必要使用原子操作吗? 回答:很有必要。其中的道理你可以对照一下读写锁。为什么在读写锁保护下的写操作和读操作之间是互斥的?这是为了防止读操作读到没有被修改完的值,对吗? 如果写操作还没有进行完,读操作就来读了,那么就只能读到仅修改了一部分的值。这显然破坏了值的完整性,读出来的值也是完全错误的。 所以,一旦你决定了要对一个共享资源进行保护,那就要做到完全的保护。不完全的保护基本上与不保护没有什么区别。 好了,上面的主问题以及相关的衍生问题涉及了原子操作函数的用法、原理、对比和一些最佳实践,希望你已经理解了。 由于这里的原子操作函数只支持非常有限的数据类型,所以在很多应用场景下,互斥锁往往是更加适合的。 不过,一旦我们确定了在某个场景下可以使用原子操作函数,比如:只涉及并发地读写单一的整数类型值,或者多个互不相关的整数类型值,那就不要再考虑互斥锁了。 这主要是因为原子操作函数的执行速度要比互斥锁快得多。而且,它们使用起来更加简单,不会涉及临界区的选择,以及死锁等问题。当然了,在使用 CAS 操作的时候,我们还是要多加注意的,因为它可以被用来模仿锁,并有可能“阻塞”流程。 ### [](https://jums.gitbook.io/36-lectures-on-golang/30-yuan-zi-cao-zuo-xia#zhi-shi-kuo-zhan) 知识扩展 问题:怎样用好`sync/atomic.Value`? 为了扩大原子操作的适用范围,Go 语言在 1.4 版本发布的时候向`sync/atomic`包中添加了一个新的类型`Value`。此类型的值相当于一个容器,可以被用来“原子地”存储和加载任意的值。 `atomic.Value`类型是开箱即用的,我们声明一个该类型的变量(以下简称原子变量)之后就可以直接使用了。这个类型使用起来很简单,它只有两个指针方法——`Store`和`Load`。不过,虽然简单,但还是有一些值得注意的地方的。 首先一点,一旦`atomic.Value`类型的值(以下简称原子值)被真正使用,它就不应该再被复制了。什么叫做“真正使用”呢? 我们只要用它来存储值了,就相当于开始真正使用了。`atomic.Value`类型属于结构体类型,而结构体类型属于值类型。 所以,复制该类型的值会产生一个完全分离的新值。这个新值相当于被复制的那个值的一个快照。之后,不论后者存储的值怎样改变,都不会影响到前者,反之亦然。 另外,关于用原子值来存储值,有两条强制性的使用规则。第一条规则,不能用原子值存储`nil`。也就是说,我们不能把`nil`作为参数值传入原子值的`Store`方法,否则就会引发一个 panic。 这里要注意,如果有一个接口类型的变量,它的动态值是`nil`,但动态类型却不是`nil`,那么它的值就不等于`nil`。我在前面讲接口的时候和你说明过这个问题。正因为如此,这样一个变量的值是可以被存入原子值的。 第二条规则,我们向原子值存储的第一个值,决定了它今后能且只能存储哪一个类型的值。 例如,我第一次向一个原子值存储了一个`string`类型的值,那我在后面就只能用该原子值来存储字符串了。如果我又想用它存储结构体,那么在调用它的`Store`方法的时候就会引发一个 panic。这个 panic 会告诉我,这次存储的值的类型与之前的不一致。 你可能会想:我先存储一个接口类型的值,然后再存储这个接口的某个实现类型的值,这样是不是可以呢? 很可惜,这样是不可以的,同样会引发一个 panic。因为原子值内部是依据被存储值的实际类型来做判断的。所以,即使是实现了同一个接口的不同类型,它们的值也不能被先后存储到同一个原子值中。 遗憾的是,我们无法通过某个方法获知一个原子值是否已经被真正使用,并且,也没有办法通过常规的途径得到一个原子值可以存储值的实际类型。这使得我们误用原子值的可能性大大增加,尤其是在多个地方使用同一个原子值的时候。 **下面,我给你几条具体的使用建议。** 1. 不要把内部使用的原子值暴露给外界。比如,声明一个全局的原子变量并不是一个正确的做法。这个变量的访问权限最起码也应该是包级私有的。 2. 如果不得不让包外,或模块外的代码使用你的原子值,那么可以声明一个包级私有的原子变量,然后再通过一个或多个公开的函数,让外界间接地使用到它。注意,这种情况下不要把原子值传递到外界,不论是传递原子值本身还是它的指针值。 3. 如果通过某个函数可以向内部的原子值存储值的话,那么就应该在这个函数中先判断被存储值类型的合法性。若不合法,则应该直接返回对应的错误值,从而避免 panic 的发生。 4. 如果可能的话,我们可以把原子值封装到一个数据类型中,比如一个结构体类型。这样,我们既可以通过该类型的方法更加安全地存储值,又可以在该类型中包含可存储值的合法类型信息。 除了上述使用建议之外,我还要再特别强调一点:尽量不要向原子值中存储引用类型的值。因为这很容易造成安全漏洞。请看下面的代码: 复制 var box6 atomic.Value v6 := []int{1, 2, 3} box6.Store(v6) v6[1] = 4 // 注意,此处的操作不是并发安全的! 我把一个`[]int`类型的切片值`v6`, 存入了原子值`box6`。注意,切片类型属于引用类型。所以,我在外面改动这个切片值,就等于修改了`box6`中存储的那个值。这相当于绕过了原子值而进行了非并发安全的操作。那么,应该怎样修补这个漏洞呢?可以这样做: 复制 store := func(v []int) { replica := make([]int, len(v)) copy(replica, v) box6.Store(replica) } store(v6) v6[2] = 5 // 此处的操作是安全的。 我先为切片值`v6`创建了一个完全的副本。这个副本涉及的数据已经与原值毫不相干了。然后,我再把这个副本存入`box6`。如此一来,无论我再对`v6`的值做怎样的修改,都不会破坏`box6`提供的安全保护。 以上,就是我要告诉你的关于`atomic.Value`的注意事项和使用建议。你可以在 demo64.go 文件中看到相应的示例。 ### [](https://jums.gitbook.io/36-lectures-on-golang/30-yuan-zi-cao-zuo-xia#zong-jie) 总结 我们把这两篇文章一起总结一下。相对于原子操作函数,原子值类型的优势很明显,但它的使用规则也更多一些。首先,在首次真正使用后,原子值就不应该再被复制了。 其次,原子值的`Store`方法对其参数值(也就是被存储值)有两个强制的约束。一个约束是,参数值不能为`nil`。另一个约束是,参数值的类型不能与首个被存储值的类型不同。也就是说,一旦一个原子值存储了某个类型的值,那它以后就只能存储这个类型的值了。 基于上面这几个注意事项,我提出了几条使用建议,包括:不要对外暴露原子变量、不要传递原子值及其指针值、尽量不要在原子值中存储引用类型的值,等等。与之相关的一些解决方案我也一并提出了。希望你能够受用。 原子操作明显比互斥锁要更加轻便,但是限制也同样明显。所以,我们在进行二选一的时候通常不会太困难。但是原子值与互斥锁之间的选择有时候就需要仔细的考量了。不过,如果你能牢记我今天讲的这些内容的话,应该会有很大的助力。 ### [](https://jums.gitbook.io/36-lectures-on-golang/30-yuan-zi-cao-zuo-xia#si-kao-ti) 思考题 今天的思考题只有一个,那就是:如果要对原子值和互斥锁进行二选一,你认为最重要的三个决策条件应该是什么? [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页29 | 原子操作(上)](https://jums.gitbook.io/36-lectures-on-golang/29-yuan-zi-cao-zuo-shang) [下一页31 | sync.WaitGroup和sync.Once](https://jums.gitbook.io/36-lectures-on-golang/31-sync.waitgroup-he-sync.once) 最后更新于2年前 这有帮助吗? --- # 15 | 关于指针的有限操作 | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252FagyFlYIhDzKTTJSvKwWP%252Fimage.png%3Falt%3Dmedia%26token%3Dd6b3e527-b68f-449d-b14f-a2638437f24b&width=768&dpr=4&quality=100&sign=7d7b5bdd&sv=2) [8MB\ \ 15.关于指针的有限操作.mp3](https://2852705636-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FkmtyDvkosJZE1Rr33XGZ%2Fuploads%2Ff3NEoV9QBr9BNRhCojo4%2F15.%E5%85%B3%E4%BA%8E%E6%8C%87%E9%92%88%E7%9A%84%E6%9C%89%E9%99%90%E6%93%8D%E4%BD%9C.mp3?alt=media&token=bbf701c2-5ea0-49f9-8da7-47390fef2443) 在前面的文章中,我们已经提到过很多次“指针”了,你应该已经比较熟悉了。不过,我们那时大多指的是指针类型及其对应的指针值,今天我们讲的则是更为深入的内容。 让我们先来复习一下。 复制 type Dog struct { name string } func (dog *Dog) SetName(name string) { dog.name = name } 对于基本类型`Dog`来说,`*Dog`就是它的指针类型。而对于一个`Dog`类型,值不为`nil`的变量`dog`,取址表达式`&dog`的结果就是该变量的值(也就是基本值)的指针值。 如果一个方法的接收者是`*Dog`类型的,那么该方法就是基本类型`Dog`的一个指针方法。 在这种情况下,这个方法的接收者实际上就是当前的基本值的指针值。我们可以通过指针值无缝地访问到基本值包含的任何字段,以及调用与之关联的任何方法。这应该就是我们在编写 Go 程序的过程中,用得最频繁的“指针”了。 从传统意义上说,指针是一个指向某个确切的内存地址的值。这个内存地址可以是任何数据或代码的起始地址,比如,某个变量、某个字段或某个函数。 我们刚刚只提到了其中的一种情况,在 Go 语言中还有其他几样东西可以代表“指针”。其中最贴近传统意义的当属`uintptr`类型了。该类型实际上是一个数值类型,也是 Go 语言内建的数据类型之一。 根据当前计算机的计算架构的不同,它可以存储 32 位或 64 位的无符号整数,可以代表任何指针的位(bit)模式,也就是原始的内存地址。 再来看 Go 语言标准库中的`unsafe`包。`unsafe`包中有一个类型叫做`Pointer`,也代表了“指针”。 `unsafe.Pointer`可以表示任何指向可寻址的值的指针,同时它也是前面提到的指针值和`uintptr`值之间的桥梁。也就是说,通过它,我们可以在这两种值之上进行双向的转换。这里有一个很关键的词——可寻址的(addressable)。在我们继续说`unsafe.Pointer`之前,需要先要搞清楚这个词的确切含义。 **今天的问题是:你能列举出 Go 语言中的哪些值是不可寻址的吗?** **这道题的典型回答是**以下列表中的值都是不可寻址的。 * 常量的值。 * 基本类型值的字面量。 * 算术操作的结果值。 * 对各种字面量的索引表达式和切片表达式的结果值。不过有一个例外,对切片字面量的索引结果值却是可寻址的。 * 对字符串变量的索引表达式和切片表达式的结果值。 * 对字典变量的索引表达式的结果值。 * 函数字面量和方法字面量,以及对它们的调用表达式的结果值。 * 结构体字面量的字段值,也就是对结构体字面量的选择表达式的结果值。 * 类型转换表达式的结果值。 * 类型断言表达式的结果值。 * 接收表达式的结果值。 ### [](https://jums.gitbook.io/36-lectures-on-golang/15-guan-yu-zhi-zhen-de-you-xian-cao-zuo#wen-ti-jie-xi) 问题解析 初看答案中的这些不可寻址的值好像并没有什么规律。不过别急,我们一起来梳理一下。你可以对照着 [demo35.go](https://github.com/crazyjums/go_haolingeek/blob/master/article15/q1/demo35.go) 文件中的代码来看,这样应该会让你理解起来更容易一些。 常量的值总是会被存储到一个确切的内存区域中,并且这种值肯定是**不可变的**。基本类型值的字面量也是一样,其实它们本就可以被视为常量,只不过没有任何标识符可以代表它们罢了。 第一个关键词:不可变的。由于 Go 语言中的字符串值也是不可变的,所以对于一个字符串类型的变量来说,基于它的索引或切片的结果值也都是不可寻址的,因为即使拿到了这种值的内存地址也改变不了什么。 算术操作的结果值属于一种**临时结果**。在我们把这种结果值赋给任何变量或常量之前,即使能拿到它的内存地址也是没有任何意义的。 第二个关键词:临时结果。这个关键词能被用来解释很多现象。我们可以把各种对值字面量施加的表达式的求值结果都看做是临时结果。 我们都知道,Go 语言中的表达式有很多种,其中常用的包括以下几种。 * 用于获得某个元素的索引表达式。 * 用于获得某个切片(片段)的切片表达式。 * 用于访问某个字段的选择表达式。 * 用于调用某个函数或方法的调用表达式。 * 用于转换值的类型的类型转换表达式。 * 用于判断值的类型的类型断言表达式。 * 向通道发送元素值或从通道那里接收元素值的接收表达式。 我们把以上这些表达式施加在某个值字面量上一般都会得到一个临时结果。比如,对数组字面量和字典字面量的索引结果值,又比如,对数组字面量和切片字面量的切片结果值。它们都属于临时结果,都是不可寻址的。 一个需要特别注意的例外是,对切片字面量的索引结果值是可寻址的。因为不论怎样,每个切片值都会持有一个底层数组,而这个底层数组中的每个元素值都是有一个确切的内存地址的。 你可能会问,那么对切片字面量的切片结果值为什么却是不可寻址的?这是因为切片表达式总会返回一个新的切片值,而这个新的切片值在被赋给变量之前属于临时结果。 你可能已经注意到了,我一直在说针对数组值、切片值或字典值的**字面量**的表达式会产生临时结果。如果针对的是数组类型或切片类型的**变量**,那么索引或切片的结果值就都不属于临时结果了,是可寻址的。 这主要因为变量的值本身就不是“临时的”。对比而言,值字面量在还没有与任何变量(或者说任何标识符)绑定之前是没有落脚点的,我们无法以任何方式引用到它们。这样的值就是“临时的”。 再说一个例外。我们通过对字典类型的变量施加索引表达式,得到的结果值不属于临时结果,可是,这样的值却是不可寻址的。原因是,字典中的每个键 - 元素对的存储位置都可能会变化,而且这种变化外界是无法感知的。 我们都知道,字典中总会有若干个哈希桶用于均匀地储存键 - 元素对。当满足一定条件时,字典可能会改变哈希桶的数量,并适时地把其中的键 - 元素对搬运到对应的新的哈希桶中。 在这种情况下,获取字典中任何元素值的指针都是无意义的,也是**不安全的**。我们不知道什么时候那个元素值会被搬运到何处,也不知道原先的那个内存地址上还会被存放什么别的东西。所以,这样的值就应该是不可寻址的。 第三个关键词:不安全的。“不安全的”操作很可能会破坏程序的一致性,引发不可预知的错误,从而严重影响程序的功能和稳定性。 再来看函数。函数在 Go 语言中是一等公民,所以我们可以把代表函数或方法的字面量或标识符赋给某个变量、传给某个函数或者从某个函数传出。但是,这样的函数和方法都是不可寻址的。一个原因是函数就是代码,是不可变的。 另一个原因是,拿到指向一段代码的指针是不安全的。此外,对函数或方法的调用结果值也是不可寻址的,这是因为它们都属于临时结果。 至于典型回答中最后列出的那几种值,由于都是针对值字面量的某种表达式的结果值,所以都属于临时结果,都不可寻址。 好了,说了这么多,希望你已经有所领悟了。我来总结一下。 1. **不可变的**值不可寻址。常量、基本类型的值字面量、字符串变量的值、函数以及方法的字面量都是如此。其实这样规定也有安全性方面的考虑。 2. 绝大多数被视为**临时结果**的值都是不可寻址的。算术操作的结果值属于临时结果,针对值字面量的表达式结果值也属于临时结果。但有一个例外,对切片字面量的索引结果值虽然也属于临时结果,但却是可寻址的。 3. 若拿到某值的指针可能会破坏程序的一致性,那么就是**不安全的**,该值就不可寻址。由于字典的内部机制,对字典的索引结果值的取址操作都是不安全的。另外,获取由字面量或标识符代表的函数或方法的地址显然也是不安全的。 最后说一句,如果我们把临时结果赋给一个变量,那么它就是可寻址的了。如此一来,取得的指针指向的就是这个变量持有的那个值了。 ### [](https://jums.gitbook.io/36-lectures-on-golang/15-guan-yu-zhi-zhen-de-you-xian-cao-zuo#zhi-shi-kuo-zhan) 知识扩展 **问题 1:不可寻址的值在使用上有哪些限制?** 首当其冲的当然是无法使用取址操作符`&`获取它们的指针了。不过,对不可寻址的值施加取址操作都会使编译器报错,所以倒是不用太担心,你只要记住我在前面讲述的那几条规律,并在编码的时候提前注意一下就好了。 我们来看下面这个小问题。我们依然以那个结构体类型`Dog`为例。 复制 func New(name string) Dog { return Dog{name} } 我们再为它编写一个函数`New`。这个函数会接受一个名为`name`的`string`类型的参数,并会用这个参数初始化一个`Dog`类型的值,最后返回该值。我现在要问的是:如果我调用该函数,并直接以链式的手法调用其结果值的指针方法`SetName`,那么可以达到预期的效果吗? 复制 New("little pig").SetName("monster") 如果你还记得我在前面讲述的内容,那么肯定会知道调用`New`函数所得到的结果值属于临时结果,是不可寻址的。 可是,那又怎样呢?别忘了,我在讲结构体类型及其方法的时候还说过,我们可以在一个基本类型的值上调用它的指针方法,这是因为 Go 语言会自动地帮我们转译。 更具体地说,对于一个`Dog`类型的变量`dog`来说,调用表达式`dog.SetName("monster")`会被自动地转译为`(&dog).SetName("monster")`,即:先取`dog`的指针值,再在该指针值上调用`SetName`方法。 发现问题了吗?由于`New`函数的调用结果值是不可寻址的,所以无法对它进行取址操作。因此,上边这行链式调用会让编译器报告两个错误,一个是果,即:不能在`New("little pig")`的结果值上调用指针方法。一个是因,即:不能取得`New("little pig")`的地址。 除此之外,我们都知道,Go 语言中的`++`和`--`并不属于操作符,而分别是自增语句和自减语句的重要组成部分。 虽然 Go 语言规范中的语法定义是,只要在`++`或`--`的左边添加一个表达式,就可以组成一个自增语句或自减语句,但是,它还明确了一个很重要的限制,那就是这个表达式的结果值必须是可寻址的。这就使得针对值字面量的表达式几乎都无法被用在这里。 不过这有一个例外,虽然对字典字面量和字典变量索引表达式的结果值都是不可寻址的,但是这样的表达式却可以被用在自增语句和自减语句中。 与之类似的规则还有两个。一个是,在赋值语句中,赋值操作符左边的表达式的结果值必须可寻址的,但是对字典的索引结果值也是可以的。 另一个是,在带有`range`子句的`for`语句中,在`range`关键字左边的表达式的结果值也都必须是可寻址的,不过对字典的索引结果值同样可以被用在这里。以上这三条规则我们合并起来记忆就可以了。 与这些定死的规则相比,我刚刚讲到的那个与指针方法有关的问题,你需要好好理解一下,它涉及了两个知识点的联合运用。起码在我面试的时候,它是一个可选择的考点。 **问题 2:怎样通过**`**unsafe.Pointer**`**操纵可寻址的值?** 前边的基础知识很重要。不过现在让我们再次关注指针的用法。我说过,`unsafe.Pointer`是像`*Dog`类型的值这样的指针值和`uintptr`值之间的桥梁,那么我们怎样利用`unsafe.Pointer`的中转和`uintptr`的底层操作来操纵像`dog`这样的值呢? 首先说明,这是一项黑科技。它可以绕过 Go 语言的编译器和其他工具的重重检查,并达到潜入内存修改数据的目的。这并不是一种正常的编程手段,使用它会很危险,很有可能造成安全隐患。 我们总是应该优先使用常规代码包中提供的 API 去编写程序,当然也可以把像`reflect`以及`go/ast`这样的代码包作为备选项。作为上层应用的开发者,请谨慎地使用`unsafe`包中的任何程序实体。 不过既然说到这里了,我们还是要来一探究竟的。请看下面的代码: 复制 dog := Dog{"little pig"} dogP := &dog dogPtr := uintptr(unsafe.Pointer(dogP)) 我先声明了一个`Dog`类型的变量`dog`,然后用取址操作符`&`,取出了它的指针值,并把它赋给了变量`dogP`。 最后,我使用了两个类型转换,先把`dogP`转换成了一个`unsafe.Pointer`类型的值,然后紧接着又把后者转换成了一个`uintptr`的值,并把它赋给了变量`dogPtr`。这背后隐藏着一些转换规则,如下: 1. 一个指针值(比如`*Dog`类型的值)可以被转换为一个`unsafe.Pointer`类型的值,反之亦然。 2. 一个`uintptr`类型的值也可以被转换为一个`unsafe.Pointer`类型的值,反之亦然。 3. 一个指针值无法被直接转换成一个`uintptr`类型的值,反过来也是如此。 所以,对于指针值和`uintptr`类型值之间的转换,必须使用`unsafe.Pointer`类型的值作为中转。那么,我们把指针值转换成`uintptr`类型的值有什么意义吗? 复制 namePtr := dogPtr + unsafe.Offsetof(dogP.name) nameP := (*string)(unsafe.Pointer(namePtr)) 这里需要与`unsafe.Offsetof`函数搭配使用才能看出端倪。`unsafe.Offsetof`函数用于获取两个值在内存中的起始存储地址之间的偏移量,以字节为单位。 这两个值一个是某个字段的值,另一个是该字段值所属的那个结构体值。我们在调用这个函数的时候,需要把针对字段的选择表达式传给它,比如`dogP.name`。 有了这个偏移量,又有了结构体值在内存中的起始存储地址(这里由`dogPtr`变量代表),把它们相加我们就可以得到`dogP`的`name`字段值的起始存储地址了。这个地址由变量`namePtr`代表。 此后,我们可以再通过两次类型转换把`namePtr`的值转换成一个`*string`类型的值,这样就得到了指向`dogP`的`name`字段值的指针值。 你可能会问,我直接用取址表达式`&(dogP.name)`不就能拿到这个指针值了吗?干嘛绕这么大一圈呢?你可以想象一下,如果我们根本就不知道这个结构体类型是什么,也拿不到`dogP`这个变量,那么还能去访问它的`name`字段吗? 答案是,只要有`namePtr`就可以。它就是一个无符号整数,但同时也是一个指向了程序内部数据的内存地址。它可能会给我们带来一些好处,比如可以直接修改埋藏得很深的内部数据。 但是,一旦我们有意或无意地把这个内存地址泄露出去,那么其他人就能够肆意地改动`dogP.name`的值,以及周围的内存地址上存储的任何数据了。 即使他们不知道这些数据的结构也无所谓啊,改不好还改不坏吗?不正确地改动一定会给程序带来不可预知的问题,甚至造成程序崩溃。这可能还是最好的灾难性后果;所以我才说,使用这种非正常的编程手段会很危险。 好了,现在你知道了这种手段,也知道了它的危险性,那就谨慎对待,防患于未然吧。 ### [](https://jums.gitbook.io/36-lectures-on-golang/15-guan-yu-zhi-zhen-de-you-xian-cao-zuo#zong-jie) 总结 我们今天集中说了说与指针有关的问题。基于基本类型的指针值应该是我们最常用到的,也是我们最需要关注的,比如`*Dog`类型的值。怎样得到一个这样的指针值呢?这需要用到取址操作和操作符`&`。 不过这里还有个前提,那就是取址操作的操作对象必须是可寻址的。关于这方面你需要记住三个关键词:不可变的、临时结果和不安全的。只要一个值符合了这三个关键词中的任何一个,它就是不可寻址的。 但有一个例外,对切片字面量的索引结果值是可寻址的。那么不可寻址的值在使用上有哪些限制呢?一个最重要的限制是关于指针方法的,即:无法调用一个不可寻址值的指针方法。这涉及了两个知识点的联合运用。 相比于刚说到的这些,`unsafe.Pointer`类型和`uintptr`类型的重要性好像就没那么高了。它们的值同样可以代表指针,并且比前面说的指针值更贴近于底层和内存。 虽然我们可以利用它们去访问或修改一些内部数据,而且就灵活性而言,这种要比通用的方式高很多,但是这往往也会带来不容小觑的安全隐患。 因此,在很多时候,使用它们操纵数据是弊大于利的。不过,对于硬币的背面,我们也总是有必要去了解的。 ### [](https://jums.gitbook.io/36-lectures-on-golang/15-guan-yu-zhi-zhen-de-you-xian-cao-zuo#si-kao-ti) 思考题 今天的思考题是:引用类型的值的指针值是有意义的吗?如果没有意义,为什么?如果有意义,意义在哪里? [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页14 | 接口类型的合理运用](https://jums.gitbook.io/36-lectures-on-golang/14-jie-kou-lei-xing-de-he-li-yun-yong) [下一页16 | go语句及其执行规则(上)](https://jums.gitbook.io/36-lectures-on-golang/16-go-yu-ju-ji-qi-zhi-hang-gui-ze-shang) 最后更新于2年前 这有帮助吗? --- # 41 | io包中的接口和工具 (下) | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252FckTOZn4ne7hgL4Ebgr4U%252Fimage.png%3Falt%3Dmedia%26token%3Dab19334d-3043-4875-8353-eeedd07abfc7&width=768&dpr=4&quality=100&sign=f67179d&sv=2) 上一篇文章中,我主要讲到了`io.Reader`的扩展接口和实现类型。当然,`io`代码包中的核心接口不止`io.Reader`一个。 我们基于它引出的一条主线只是`io`包类型体系中的一部分。我们很有必要再从另一个角度去探索一下,以求对`io`包有更加全面的了解。下面的一个问题就与此有关。 ### [](https://jums.gitbook.io/36-lectures-on-golang/41-io-bao-zhong-de-jie-kou-he-gong-ju-xia#zhi-shi-kuo-zhan) 知识扩展 #### [](https://jums.gitbook.io/36-lectures-on-golang/41-io-bao-zhong-de-jie-kou-he-gong-ju-xia#wen-ti-io-bao-zhong-de-jie-kou-dou-you-na-xie-ta-men-zhi-jian-dou-you-zhe-zen-yang-de-guan-xi) 问题:`io`包中的接口都有哪些?它们之间都有着怎样的关系? 我们可以把没有嵌入其他接口并且只定义了一个方法的接口叫做简单接口。在`io`包中,这样的接口一共有 11 个。 在它们之中,有的接口有着众多的扩展接口和实现类型,我们可以称之为核心接口。`**io**`**包中的核心接口只有 3 个,它们是:**`**io.Reader**`**、**`**io.Writer**`**和**`**io.Closer**`**。** 我们还可以把`io`包中的简单接口分为四大类。这四大类接口分别针对于四种操作,即:读取、写入、关闭和读写位置设定。前三种操作属于基本的 I/O 操作。 关于读取操作,我们在前面已经重点讨论过核心接口`io.Reader`。它在`io`包中有 5 个扩展接口,并有 6 个实现类型。除了它,这个包中针对读取操作的接口还有不少。我们下面就来梳理一下。 首先来看`io.ByteReader`和`io.RuneReader`这两个简单接口。它们分别定义了一个读取方法,即:`ReadByte`和`ReadRune`。 但与`io.Reader`接口中`Read`方法不同的是,这两个读取方法分别只能够读取下一个单一的字节和 Unicode 字符。 我们之前讲过的数据类型`strings.Reader`和`bytes.Buffer`都是`io.ByteReader`和`io.RuneReader`的实现类型。 不仅如此,这两个类型还都实现了`io.ByteScanner`接口和`io.RuneScanner`接口。 `io.ByteScanner`接口内嵌了简单接口`io.ByteReader`,并定义了额外的`UnreadByte`方法。如此一来,它就抽象出了一个能够读取和读回退单个字节的功能集。 与之类似,`io.RuneScanner`内嵌了简单接口`io.RuneReader`,并定义了额外的`UnreadRune`方法。它抽象的是可以读取和读回退单个 Unicode 字符的功能集。 再来看`io.ReaderAt`接口。它也是一个简单接口,其中只定义了一个方法`ReadAt`。与我们在前面说过的读取方法都不同,`ReadAt`是一个纯粹的只读方法。 它只去读取其所属值中包含的字节,而不对这个值进行任何的改动,比如,它绝对不能去修改已读计数的值。这也是`io.ReaderAt`接口与其实现类型之间最重要的一个约定。 因此,如果仅仅并发地调用某一个值的`ReadAt`方法,那么安全性应该是可以得到保障的。 另外,还有一个读取操作相关的接口我们没有介绍过,它就是`io.WriterTo`。这个接口定义了一个名为`WriteTo`的方法。 千万不要被它的名字迷惑,这个`WriteTo`方法其实是一个读取方法。它会接受一个`io.Writer`类型的参数值,并会把其所属值中的数据读出并写入到这个参数值中。 与之相对应的是`io.ReaderFrom`接口。它定义了一个名叫`ReadFrom`的写入方法。该方法会接受一个`io.Reader`类型的参数值,并会从该参数值中读出数据, 并写入到其所属值中。 值得一提的是,我们在前面用到过的`io.CopyN`函数,在复制数据的时候会先检测其参数`src`的值是否实现了`io.WriterTo`接口。如果是,那么它就直接利用该值的`WriteTo`方法,把其中的数据拷贝给参数`dst`代表的值。 类似的,这个函数还会检测`dst`的值是否实现了`io.ReaderFrom`接口。如果是,那么它就会利用这个值的`ReadFrom`方法,直接从`src`那里把数据拷贝进该值。 实际上,对于`io.Copy`函数和`io.CopyBuffer`函数来说也是如此,因为它们在内部做数据复制的时候用的都是同一套代码。 你也看到了,`io.ReaderFrom`接口与`io.WriterTo`接口对应得很规整。实际上,在`io`包中,与写入操作有关的接口都与读取操作的相关接口有着一定的对应关系。下面,我们就来说说前者。 首先当然是核心接口`io.Writer`。基于它的扩展接口除了有我们已知的`io.ReadWriter`、`io.ReadWriteCloser`和`io.ReadWriteSeeker`之外,还有`io.WriteCloser`和`io.WriteSeeker`。 我们之前提及的`*io.pipe`就是`io.ReadWriter`接口的实现类型。然而,在`io`包中并没有`io.ReadWriteCloser`接口的实现,它的实现类型主要集中在`net`包中。 除此之外,写入操作相关的简单接口还有`io.ByteWriter`和`io.WriterAt`。可惜,`io`包中也没有它们的实现类型。不过,有一个数据类型值得在这里提一句,那就是`*os.File`。 这个类型不但是`io.WriterAt`接口的实现类型,还同时实现了`io.ReadWriteCloser`接口和`io.ReadWriteSeeker`接口。也就是说,该类型支持的 I/O 操作非常的丰富。 `io.Seeker`接口作为一个读写位置设定相关的简单接口,也仅仅定义了一个方法,名叫`Seek`。 我在讲`strings.Reader`类型的时候还专门说过这个`Seek`方法,当时还给出了一个与已读计数估算有关的例子。该方法主要用于寻找并设定下一次读取或写入时的起始索引位置。 `io`包中有几个基于`io.Seeker`的扩展接口,包括前面讲过的`io.ReadSeeker`和`io.ReadWriteSeeker`,以及还未曾提过的`io.WriteSeeker`。后者是基于`io.Writer`和`io.Seeker`的扩展接口。 我们之前多次提到的两个指针类型`strings.Reader`和`io.SectionReader`都实现了`io.Seeker`接口。顺便说一句,这两个类型也都是`io.ReaderAt`接口的实现类型。 最后,关闭操作相关的接口`io.Closer`非常通用,它的扩展接口和实现类型都不少。我们单从名称上就能够一眼看出`io`包中的哪些接口是它的扩展接口。至于它的实现类型,`io`包中只有`io.PipeReader`和`io.PipeWriter`。 ### [](https://jums.gitbook.io/36-lectures-on-golang/41-io-bao-zhong-de-jie-kou-he-gong-ju-xia#zong-jie) 总结 我们来总结一下这两篇的内容。在 Go 语言中,对接口的扩展是通过接口类型之间的嵌入来实现的,这也常被叫做接口的组合。而`io`代码包恰恰就可以作为接口扩展的一个标杆,它可以成为我们运用这种技巧时的一个参考标准。 在本文中,我根据接口定义的方法的数量以及是否有接口嵌入,把`io`包中的接口分为了简单接口和扩展接口。 同时,我又根据这些简单接口的扩展接口和实现类型的数量级,把它们分为了核心接口和非核心接口。 在`io`包中,称得上核心接口的简单接口只有 3 个,即:`io.Reader`、`io.Writer`和`io.Closer`。这些核心接口在 Go 语言标准库中的实现类型都在 200 个以上。 另外,根据针对的 I/O 操作的不同,我还把简单接口分为了四大类。这四大类接口针对的操作分别是:读取、写入、关闭和读写位置设定。 其中,前三种操作属于基本的 I/O 操作。基于此,我带你梳理了每个类别的简单接口,并讲解了它们在`io`包中的扩展接口,以及具有代表性的实现类型。 除此之外,我还从多个维度为你描述了一些重要程序实体的功用和机理,比如:数据段读取器`io.SectionReader`、作为同步内存管道核心实现的`io.pipe`类型,以及用于数据拷贝的`io.CopyN`函数,等等。 我如此详尽且多角度的阐释,正是为了让你能够记牢`io`代码包中有着网状关系的接口和数据类型。我希望这个目的已经达到了,最起码,本文可以作为你深刻记忆它们的开始。 最后再强调一下,`io`包中的简单接口共有 11 个。其中,读取操作相关的接口有 5 个,写入操作相关的接口有 4 个,而与关闭操作有关的接口只有 1 个,另外还有一个读写位置设定相关的接口。 此外,`io`包还包含了 9 个基于这些简单接口的扩展接口。你需要在今后思考和实践的是,你在什么时候应该编写哪些数据类型实现`io`包中的哪些接口,并以此得到最大的好处。 ### [](https://jums.gitbook.io/36-lectures-on-golang/41-io-bao-zhong-de-jie-kou-he-gong-ju-xia#si-kao-ti) 思考题 今天的思考题是:`io`包中的同步内存管道的运作机制是什么? [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页40 | io包中的接口和工具 (上)](https://jums.gitbook.io/36-lectures-on-golang/40-io-bao-zhong-de-jie-kou-he-gong-ju-shang) [下一页42 | bufio包中的数据类型 (上)](https://jums.gitbook.io/36-lectures-on-golang/42-bufio-bao-zhong-de-shu-ju-lei-xing-shang) 最后更新于2年前 这有帮助吗? --- # 13 | 结构体及其方法的使用法门 | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252FscGHdrBtAAnfwadhNQcb%252Fimage.png%3Falt%3Dmedia%26token%3Dfe32faff-2811-42d9-b609-772d83fc8da2&width=768&dpr=4&quality=100&sign=3c439f2e&sv=2) [6MB\ \ 13.结构体及其方法的使用法门.mp3](https://2852705636-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FkmtyDvkosJZE1Rr33XGZ%2Fuploads%2FlKssJxYcsLiccoJm1Lqs%2F13.%E7%BB%93%E6%9E%84%E4%BD%93%E5%8F%8A%E5%85%B6%E6%96%B9%E6%B3%95%E7%9A%84%E4%BD%BF%E7%94%A8%E6%B3%95%E9%97%A8.mp3?alt=media&token=8dea7f80-940a-4a3b-a83a-50ae5d3b3f78) 我们都知道,结构体类型表示的是实实在在的数据结构。一个结构体类型可以包含若干个字段,每个字段通常都需要有确切的名字和类型。 结构体类型也可以不包含任何字段,这样并不是没有意义的,因为我们还可以为这些类型关联上一些方法,这里你可以把方法看做是函数的特殊版本。 函数则是独立的程序实体。我们可以声明有名字的函数,也可以声明没名字的函数,还可以把它们当做普通的值传来传去。我们能把具有相同签名的函数抽象成独立的函数类型,以作为一组输入、输出(或者说一类逻辑组件)的代表。 方法却不同,它需要有名字,不能被当作值来看待,最重要的是,它必须隶属于某一个类型。方法所属的类型会通过其声明中的接收者(receiver)声明体现出来。 接收者声明就是在关键字`func`和方法名称之间的那个圆括号包裹起来的内容,其中必须包含确切的名称和类型字面量。这个接收者的类型其实就是当前方法所属的那个类型,而接收者的名称,则用于在当前方法中引用它所属的类型的当前值。 我们举个例子来看一下。 复制 // AnimalCategory 代表动物分类学中的基本分类法。 type AnimalCategory struct { kingdom string // 界。 phylum string // 门。 class  string // 纲。 order  string // 目。 family string // 科。 genus  string // 属。 species string // 种。 } func (ac AnimalCategory) String() string { return fmt.Sprintf("%s%s%s%s%s%s%s", ac.kingdom, ac.phylum, ac.class, ac.order, ac.family, ac.genus, ac.species) } 结构体类型`AnimalCategory`代表了动物的基本分类法,其中有 7 个`string`类型的字段,分别表示各个等级的分类。 下边有个名叫`String`的方法,从它的接收者声明可以看出它隶属于`AnimalCategory`类型。通过该方法的接收者名称`ac`,我们可以在其中引用到当前值的任何一个字段,或者调用到当前值的任何一个方法(也包括`String`方法自己)。 这个`String`方法的功能是提供当前值的字符串表示形式,其中的各个等级分类会按照从大到小的顺序排列。使用时,我们可以这样表示: 复制 category := AnimalCategory{species: "cat"} fmt.Printf("The animal category: %s\n", category) 这里,我用字面量初始化了一个`AnimalCategory`类型的值,并把它赋给了变量`category`。为了不喧宾夺主,我只为其中的`species`字段指定了字符串值`"cat"`,该字段代表最末级分类“种”。 在 Go 语言中,我们可以通过为一个类型编写名为`String`的方法,来自定义该类型的字符串表示形式。这个`String`方法不需要任何参数声明,但需要有一个`string`类型的结果声明。 正因为如此,我在调用`fmt.Printf`函数时,使用占位符`%s`和`category`值本身就可以打印出后者的字符串表示形式,而无需显式地调用它的`String`方法。 `fmt.Printf`函数会自己去寻找它。此时的打印内容会是`The animal category: cat`。显而易见,`category`的`String`方法成功地引用了当前值的所有字段。 方法隶属的类型其实并不局限于结构体类型,但必须是某个自定义的数据类型,并且不能是任何接口类型。 一个数据类型关联的所有方法,共同组成了该类型的方法集合。同一个方法集合中的方法不能出现重名。并且,如果它们所属的是一个结构体类型,那么它们的名称与该类型中任何字段的名称也不能重复。 我们可以把结构体类型中的一个字段看作是它的一个属性或者一项数据,再把隶属于它的一个方法看作是附加在其中数据之上的一个能力或者一项操作。 将属性及其能力(或者说数据及其操作)封装在一起,是面向对象编程(object-oriented programming)的一个主要原则。 Go 语言摄取了面向对象编程中的很多优秀特性,同时也推荐这种封装的做法。从这方面看,Go 语言其实是支持面向对象编程的,但它选择摒弃了一些在实际运用过程中容易引起程序开发者困惑的特性和规则。 现在,让我们再把目光放到结构体类型的字段声明上。我们来看下面的代码: 复制 type Animal struct { scientificName string // 学名。 AnimalCategory    // 动物基本分类。 } 我声明了一个结构体类型,名叫`Animal`。它有两个字段。一个是`string`类型的字段`scientificName`,代表了动物的学名。而另一个字段声明中只有`AnimalCategory`,它正是我在前面编写的那个结构体类型的名字。这是什么意思呢? **那么,我们今天的问题是:**`**Animal**`**类型中的字段声明**`**AnimalCategory**`**代表了什么?** 更宽泛地讲,如果结构体类型的某个字段声明中只有一个类型名,那么该字段代表了什么? **这个问题的典型回答是**:字段声明`AnimalCategory`代表了`Animal`类型的一个嵌入字段。Go 语言规范规定,如果一个字段的声明中只有字段的类型名而没有字段的名称,那么它就是一个嵌入字段,也可以被称为匿名字段。我们可以通过此类型变量的名称后跟“.”,再后跟嵌入字段类型的方式引用到该字段。也就是说,嵌入字段的类型既是类型也是名称。 ### [](https://jums.gitbook.io/36-lectures-on-golang/13-jie-gou-ti-ji-qi-fang-fa-de-shi-yong-fa-men#wen-ti-jie-xi) 问题解析 说到引用结构体的嵌入字段,`Animal`类型有个方法叫`Category`,它是这么写的: 复制 func (a Animal) Category() string { return a.AnimalCategory.String() } `Category`方法的接收者类型是`Animal`,接收者名称是`a`。在该方法中,我通过表达式`a.AnimalCategory`选择到了`a`的这个嵌入字段,然后又选择了该字段的`String`方法并调用了它。 顺便提一下,在某个代表变量的标识符的右边加“.”,再加上字段名或方法名的表达式被称为选择表达式,它用来表示选择了该变量的某个字段或者方法。 这是 Go 语言规范中的说法,与“引用结构体的某某字段”或“调用结构体的某某方法”的说法是相通的。我在以后会混用这两种说法。 实际上,把一个结构体类型嵌入到另一个结构体类型中的意义不止如此。嵌入字段的方法集合会被无条件地合并进被嵌入类型的方法集合中。例如下面这种: 复制 animal := Animal{ scientificName: "American Shorthair", AnimalCategory: category, } fmt.Printf("The animal: %s\n", animal) 我声明了一个`Animal`类型的变量`animal`并对它进行初始化。我把字符串值`"American Shorthair"`赋给它的字段`scientificName`,并把前面声明过的变量`category`赋给它的嵌入字段`AnimalCategory`。 我在后面使用`fmt.Printf`函数和`%s`占位符试图打印`animal`的字符串表示形式,相当于调用`animal`的`String`方法。虽然我们还没有为`Animal`类型编写`String`方法,但这样做是没问题的。因为在这里,嵌入字段`AnimalCategory`的`String`方法会被当做`animal`的方法调用。 那如果我也为`Animal`类型编写一个`String`方法呢?这里会调用哪一个呢? 答案是,`animal`的`String`方法会被调用。这时,我们说,嵌入字段`AnimalCategory`的`String`方法被“屏蔽”了。注意,只要名称相同,无论这两个方法的签名是否一致,被嵌入类型的方法都会“屏蔽”掉嵌入字段的同名方法。 类似的,由于我们同样可以像访问被嵌入类型的字段那样,直接访问嵌入字段的字段,所以如果这两个结构体类型里存在同名的字段,那么嵌入字段中的那个字段一定会被“屏蔽”。这与我们在前面讲过的,可重名变量之间可能存在的“屏蔽”现象很相似。 正因为嵌入字段的字段和方法都可以“嫁接”到被嵌入类型上,所以即使在两个同名的成员一个是字段,另一个是方法的情况下,这种“屏蔽”现象依然会存在。 不过,即使被屏蔽了,我们仍然可以通过链式的选择表达式,选择到嵌入字段的字段或方法,就像我在`Category`方法中所做的那样。这种“屏蔽”其实还带来了一些好处。我们看看下面这个`Animal`类型的`String`方法的实现: 复制 func (a Animal) String() string { return fmt.Sprintf("%s (category: %s)", a.scientificName, a.AnimalCategory) } 在这里,我们把对嵌入字段的`String`方法的调用结果融入到了`Animal`类型的同名方法的结果中。这种将同名方法的结果逐层“包装”的手法是很常见和有用的,也算是一种惯用法了。 最后,我还要提一下多层嵌入的问题。也就是说,嵌入字段本身也有嵌入字段的情况。请看我声明的`Cat`类型: 复制 type Cat struct { name string Animal } func (cat Cat) String() string { return fmt.Sprintf("%s (category: %s, name: %q)", cat.scientificName, cat.Animal.AnimalCategory, cat.name) } 结构体类型`Cat`中有一个嵌入字段`Animal`,而`Animal`类型还有一个嵌入字段`AnimalCategory`。 在这种情况下,“屏蔽”现象会以嵌入的层级为依据,嵌入层级越深的字段或方法越可能被“屏蔽”。 例如,当我们调用`Cat`类型值的`String`方法时,如果该类型确有`String`方法,那么嵌入字段`Animal`和`AnimalCategory`的`String`方法都会被“屏蔽”。 如果该类型没有`String`方法,那么嵌入字段`Animal`的`String`方法会被调用,而它的嵌入字段`AnimalCategory`的`String`方法仍然会被屏蔽。 只有当`Cat`类型和`Animal`类型都没有`String`方法的时候,`AnimalCategory`的`String`方法菜会被调用。 最后的最后,如果处于同一个层级的多个嵌入字段拥有同名的字段或方法,那么从被嵌入类型的值那里,选择此名称的时候就会引发一个编译错误,因为编译器无法确定被选择的成员到底是哪一个。 以上关于嵌入字段的所有示例都在 demo29.go 中,希望能对你有所帮助。 ### [](https://jums.gitbook.io/36-lectures-on-golang/13-jie-gou-ti-ji-qi-fang-fa-de-shi-yong-fa-men#zhi-shi-kuo-zhan) 知识扩展 **问题 1:Go 语言是用嵌入字段实现了继承吗?** 这里强调一下,Go 语言中根本没有继承的概念,它所做的是通过嵌入字段的方式实现了类型之间的组合。这样做的具体原因和理念请见 Go 语言官网的 FAQ 中的[Why is there no type inheritance?](https://golang.org/doc/faq#inheritance) 。 简单来说,面向对象编程中的继承,其实是通过牺牲一定的代码简洁性来换取可扩展性,而且这种可扩展性是通过侵入的方式来实现的。类型之间的组合采用的是非声明的方式,我们不需要显式地声明某个类型实现了某个接口,或者一个类型继承了另一个类型。 同时,类型组合也是非侵入式的,它不会破坏类型的封装或加重类型之间的耦合。我们要做的只是把类型当做字段嵌入进来,然后坐享其成地使用嵌入字段所拥有的一切。如果嵌入字段有哪里不合心意,我们还可以用“包装”或“屏蔽”的方式去调整和优化。 另外,类型间的组合也是灵活的,我们总是可以通过嵌入字段的方式把一个类型的属性和能力“嫁接”给另一个类型。 这时候,被嵌入类型也就自然而然地实现了嵌入字段所实现的接口。再者,组合要比继承更加简洁和清晰,Go 语言可以轻而易举地通过嵌入多个字段来实现功能强大的类型,却不会有多重继承那样复杂的层次结构和可观的管理成本。 接口类型之间也可以组合。在 Go 语言中,接口类型之间的组合甚至更加常见,我们常常以此来扩展接口定义的行为或者标记接口的特征。与此有关的内容我在下一篇文章中再讲。 在我面试过的众多 Go 工程师中,有很多人都在说“Go 语言用嵌入字段实现了继承”,而且深信不疑。 要么是他们还在用其他编程语言的视角和理念来看待 Go 语言,要么就是受到了某些所谓的“Go 语言教程”的误导。每当这时,我都忍不住当场纠正他们,并建议他们去看看官网上的解答。 **问题 2:值方法和指针方法都是什么意思?有什么区别?** 我们都知道,方法的接收者类型必须是某个自定义的数据类型,而且不能是接口类型或接口的指针类型。所谓的值方法,就是接收者类型是非指针的自定义数据类型的方法。 比如,我们在前面为`AnimalCategory`、`Animal`以及`Cat`类型声明的那些方法都是值方法。就拿`Cat`来说,它的`String`方法的接收者类型就是`Cat`,一个非指针类型。那什么叫指针类型呢?请看这个方法: 复制 func (cat *Cat) SetName(name string) { cat.name = name } 方法`SetName`的接收者类型是`*Cat`。`Cat`左边再加个`*`代表的就是`Cat`类型的指针类型。这时,`Cat`可以被叫做`*Cat`的基本类型。你可以认为这种指针类型的值表示的是指向某个基本类型值的指针。 我们可以通过把取值操作符`*`放在这样一个指针值的左边来组成一个取值表达式,以获取该指针值指向的基本类型值,也可以通过把取址操作符`&`放在一个可寻址的基本类型值的左边来组成一个取址表达式,以获取该基本类型值的指针值。所谓的指针方法,就是接收者类型是上述指针类型的方法。 那么值方法和指针方法之间有什么不同点呢?它们的不同如下所示。 1. 值方法的接收者是该方法所属的那个类型值的一个副本。我们在该方法内对该副本的修改一般都不会体现在原值上,除非这个类型本身是某个引用类型(比如切片或字典)的别名类型。 而指针方法的接收者,是该方法所属的那个基本类型值的指针值的一个副本。我们在这样的方法内对该副本指向的值进行修改,却一定会体现在原值上。 2. 一个自定义数据类型的方法集合中仅会包含它的所有值方法,而该类型的指针类型的方法集合却囊括了前者的所有方法,包括所有值方法和所有指针方法。 严格来讲,我们在这样的基本类型的值上只能调用到它的值方法。但是,Go 语言会适时地为我们进行自动地转译,使得我们在这样的值上也能调用到它的指针方法。 比如,在`Cat`类型的变量`cat`之上,之所以我们可以通过`cat.SetName("monster")`修改猫的名字,是因为 Go 语言把它自动转译为了`(&cat).SetName("monster")`,即:先取`cat`的指针值,然后在该指针值上调用`SetName`方法。 3. 在后边你会了解到,一个类型的方法集合中有哪些方法与它能实现哪些接口类型是息息相关的。如果一个基本类型和它的指针类型的方法集合是不同的,那么它们具体实现的接口类型的数量就也会有差异,除非这两个数量都是零。 比如,一个指针类型实现了某某接口类型,但它的基本类型却不一定能够作为该接口的实现类型。 能够体现值方法和指针方法之间差异的小例子我放在 demo30.go 文件里了,你可以参照一下。 **总结** 结构体类型的嵌入字段比较容易让 Go 语言新手们迷惑,所以我在本篇文章着重解释了它的编写方法、基本的特性和规则以及更深层次的含义。在理解了结构体类型及其方法的组成方式和构造套路之后,这些知识应该是你重点掌握的。 嵌入字段是其声明中只有类型而没有名称的字段,它可以以一种很自然的方式为被嵌入的类型带来新的属性和能力。在一般情况下,我们用简单的选择表达式就可以直接引用到它们的字段和方法。 不过,我们需要小心可能产生“屏蔽”现象的地方,尤其是当存在多个嵌入字段或者多层嵌入的时候。“屏蔽”现象可能会让你的实际引用与你的预期不符。 另外,你一定要梳理清楚值方法和指针方法的不同之处,包括这两种方法各自能做什么、不能做什么以及会影响到其所属类型的哪些方面。这涉及值的修改、方法集合和接口实现。 最后,再次强调,嵌入字段是实现类型间组合的一种方式,这与继承没有半点儿关系。Go 语言虽然支持面向对象编程,但是根本就没有“继承”这个概念。 **思考题** 1. 我们可以在结构体类型中嵌入某个类型的指针类型吗?如果可以,有哪些注意事项? 2. 字面量`struct{}`代表了什么?又有什么用处? [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页12 | 使用函数的正确姿势](https://jums.gitbook.io/36-lectures-on-golang/12-shi-yong-han-shu-de-zheng-que-zi-shi) [下一页14 | 接口类型的合理运用](https://jums.gitbook.io/36-lectures-on-golang/14-jie-kou-lei-xing-de-he-li-yun-yong) 最后更新于2年前 这有帮助吗? --- # 31 | sync.WaitGroup和sync.Once | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252FGXEgaVkpfa5aFewAKRyY%252Fimage.png%3Falt%3Dmedia%26token%3D7c95911f-41e6-430b-bfbe-007c52f73708&width=768&dpr=4&quality=100&sign=d32547c8&sv=2) 我们在前几次讲的互斥锁、条件变量和原子操作都是最基本重要的同步工具。在 Go 语言中,除了通道之外,它们也算是最为常用的并发安全工具了。 说到通道,不知道你想过没有,之前在一些场合下里,我们使用通道的方式看起来都似乎有些蹩脚。比如:声明一个通道,使它的容量与我们手动启用的 goroutine 的数量相同。之后利用这个通道,让主 goroutine 等待其他 goroutine 的运行结束。 这一步更具体地说就是:让其他的 goroutine 在运行结束之前,都向这个通道发送一个元素值,并且,让主 goroutine 在最后从这个通道中接收元素值,接收的次数需要与其他的 goroutine 的数量相同。 这就是下面的`coordinateWithChan`函数展示的多 goroutine 协作流程。 复制 func coordinateWithChan() { sign := make(chan struct{}, 2) num := int32(0) fmt.Printf("The number: %d [with chan struct{}]\n", num) max := int32(10) go addNum(&num, 1, max, func() { sign <- struct{}{} }) go addNum(&num, 2, max, func() { sign <- struct{}{} }) <-sign <-sign } 其中的`addNum`函数的声明在 demo65.go 文件中。`addNum`函数会把它接受的最后一个参数值作为其中的`defer`函数。 我手动启用的两个 goroutine 都会调用`addNum`函数,而它们传给该函数的最后一个参数值(也就是那个既无参数声明,也无结果声明的函数)都只会做一件事情,那就是向通道`sign`发送一个元素值。 看到`coordinateWithChan`函数中最后的那两行代码了吗?重复的两个接收表达式`<-sign`,是不是看起来很丑陋? 其实,在这种应用场景下,我们可以选用另外一个同步工具,即:`sync`包的`WaitGroup`类型。它比通道更加适合实现这种一对多的 goroutine 协作流程。 `sync.WaitGroup`类型(以下简称`WaitGroup`类型)是开箱即用的,也是并发安全的。同时,与我们前面讨论的几个同步工具一样,它一旦被真正使用就不能被复制了。 `WaitGroup`类型拥有三个指针方法:`Add`、`Done`和`Wait`。你可以想象该类型中有一个计数器,它的默认值是`0`。我们可以通过调用该类型值的`Add`方法来增加,或者减少这个计数器的值。 一般情况下,我会用这个方法来记录需要等待的 goroutine 的数量。相对应的,这个类型的`Done`方法,用于对其所属值中计数器的值进行减一操作。我们可以在需要等待的 goroutine 中,通过`defer`语句调用它。 而此类型的`Wait`方法的功能是,阻塞当前的 goroutine,直到其所属值中的计数器归零。如果在该方法被调用的时候,那个计数器的值就是`0`,那么它将不会做任何事情。 你可能已经看出来了,`WaitGroup`类型的值(以下简称`WaitGroup`值)完全可以被用来替换`coordinateWithChan`函数中的通道`sign`。下面的`coordinateWithWaitGroup`函数就是它的改造版本。 复制 func coordinateWithWaitGroup() { var wg sync.WaitGroup wg.Add(2) num := int32(0) fmt.Printf("The number: %d [with sync.WaitGroup]\n", num) max := int32(10) go addNum(&num, 3, max, wg.Done) go addNum(&num, 4, max, wg.Done) wg.Wait() } 很明显,整体代码少了好几行,而且看起来也更加简洁了。这里我先声明了一个`WaitGroup`类型的变量`wg`。然后,我调用了它的`Add`方法并传入了`2`,因为我会在后面启用两个需要等待的 goroutine。 由于`wg`变量的`Done`方法本身就是一个既无参数声明,也无结果声明的函数,所以我在`go`语句中调用`addNum`函数的时候,可以直接把该方法作为最后一个参数值传进去。 在`coordinateWithWaitGroup`函数的最后,我调用了`wg`的`Wait`方法。如此一来,该函数就可以等到那两个 goroutine 都运行结束之后,再结束执行了。 以上就是`WaitGroup`类型最典型的应用场景了。不过不能止步于此,对于这个类型,我们还是有必要再深入了解一下的。我们一起看下面的问题。 **问题:**`**sync.WaitGroup**`**类型值中计数器的值可以小于**`**0**`**吗?** 这里的典型回答是:不可以。 ### [](https://jums.gitbook.io/36-lectures-on-golang/31-sync.waitgroup-he-sync.once#wen-ti-jie-xi) 问题解析 为什么不可以呢,我们解析一下。**之所以说**`**WaitGroup**`**值中计数器的值不能小于**`**0**`**,是因为这样会引发一个 panic。** 不适当地调用这类值的`Done`方法和`Add`方法都会如此。别忘了,我们在调用`Add`方法的时候是可以传入一个负数的。 实际上,导致`WaitGroup`值的方法抛出 panic 的原因不只这一种。 你需要知道,在我们声明了这样一个变量之后,应该首先根据需要等待的 goroutine,或者其他事件的数量,调用它的`Add`方法,以使计数器的值大于`0`。这是确保我们能在后面正常地使用这类值的前提。 如果我们对它的`Add`方法的首次调用,与对它的`Wait`方法的调用是同时发起的,比如,在同时启用的两个 goroutine 中,分别调用这两个方法,**那么就有可能会让这里的**`**Add**`**方法抛出一个 panic。** 这种情况不太容易复现,也正因为如此,我们更应该予以重视。所以,虽然`WaitGroup`值本身并不需要初始化,但是尽早地增加其计数器的值,还是非常有必要的。 另外,你可能已经知道,`WaitGroup`值是可以被复用的,但需要保证其计数周期的完整性。这里的计数周期指的是这样一个过程:该值中的计数器值由`0`变为了某个正整数,而后又经过一系列的变化,最终由某个正整数又变回了`0`。 也就是说,只要计数器的值始于`0`又归为`0`,就可以被视为一个计数周期。在一个此类值的生命周期中,它可以经历任意多个计数周期。但是,只有在它走完当前的计数周期之后,才能够开始下一个计数周期。 因此,也可以说,如果一个此类值的`Wait`方法在它的某个计数周期中被调用,那么就会立即阻塞当前的 goroutine,直至这个计数周期完成。在这种情况下,该值的下一个计数周期,必须要等到这个`Wait`方法执行结束之后,才能够开始。 如果在一个此类值的`Wait`方法被执行期间,跨越了两个计数周期,**那么就会引发一个 panic。** 例如,在当前的 goroutine 因调用此类值的`Wait`方法,而被阻塞的时候,另一个 goroutine 调用了该值的`Done`方法,并使其计数器的值变为了`0`。 这会唤醒当前的 goroutine,并使它试图继续执行`Wait`方法中其余的代码。但在这时,又有一个 goroutine 调用了它的`Add`方法,并让其计数器的值又从`0`变为了某个正整数。**此时,这里的**`**Wait**`**方法就会立即抛出一个 panic。** 纵观上述会引发 panic 的后两种情况,我们可以总结出这样一条关于`WaitGroup`值的使用禁忌,即:不要把增加其计数器值的操作和调用其`Wait`方法的代码,放在不同的 goroutine 中执行。换句话说,要杜绝对同一个`WaitGroup`值的两种操作的并发执行。 除了第一种情况外,我们通常需要反复地实验,才能够让`WaitGroup`值的方法抛出 panic。再次强调,虽然这不是每次都发生,但是在长期运行的程序中,这种情况发生的概率还是不小的,我们必须要重视它们。 如果你对复现这些异常情况感兴趣,那么可以参看`sync`代码包中的 waitgroup\_test.go 文件。其中的名称以`TestWaitGroupMisuse`为前缀的测试函数,很好地展示了这些异常情况的发生条件。你可以模仿这些测试函数自己写一些测试代码,执行一下试试看。 ### [](https://jums.gitbook.io/36-lectures-on-golang/31-sync.waitgroup-he-sync.once#zhi-shi-kuo-zhan) 知识扩展 #### [](https://jums.gitbook.io/36-lectures-on-golang/31-sync.waitgroup-he-sync.once#wen-ti-sync.once-lei-xing-zhi-de-do-fang-fa-shi-zen-me-bao-zheng-zhi-zhi-xing-can-shu-han-shu-yi-ci) 问题:`sync.Once`类型值的`Do`方法是怎么保证只执行参数函数一次的? 与`sync.WaitGroup`类型一样,`sync.Once`类型(以下简称`Once`类型)也属于结构体类型,同样也是开箱即用和并发安全的。由于这个类型中包含了一个`sync.Mutex`类型的字段,所以,复制该类型的值也会导致功能的失效。 `Once`类型的`Do`方法只接受一个参数,这个参数的类型必须是`func()`,即:无参数声明和结果声明的函数。该方法的功能并不是对每一种参数函数都只执行一次,而是只执行“首次被调用时传入的”那个函数,并且之后不会再执行任何参数函数。 所以,如果你有多个只需要执行一次的函数,那么就应该为它们中的每一个都分配一个`sync.Once`类型的值(以下简称`Once`值)。 `Once`类型中还有一个名叫`done`的`uint32`类型的字段。它的作用是记录其所属值的`Do`方法被调用的次数。不过,该字段的值只可能是`0`或者`1`。一旦`Do`方法的首次调用完成,它的值就会从`0`变为`1`。 你可能会问,既然`done`字段的值不是`0`就是`1`,那为什么还要使用需要四个字节的`uint32`类型呢? 原因很简单,因为对它的操作必须是“原子”的。`Do`方法在一开始就会通过调用`atomic.LoadUint32`函数来获取该字段的值,并且一旦发现该值为`1`就会直接返回。这也初步保证了“`Do`方法,只会执行首次被调用时传入的函数”。 不过,单凭这样一个判断的保证是不够的。因为,如果有两个 goroutine 都调用了同一个新的`Once`值的`Do`方法,并且几乎同时执行到了其中的这个条件判断代码,那么它们就都会因判断结果为`false`而继续执行`Do`方法中剩余的代码。 在这个条件判断之后,`Do`方法会立即锁定其所属值中的那个`sync.Mutex`类型的字段`m`。然后,它会在临界区中再次检查`done`字段的值,并且仅在条件满足时,才会去调用参数函数,以及用原子操作把`done`的值变为`1`。 如果你熟悉 GoF 设计模式中的单例模式的话,那么肯定能看出来,这个`Do`方法的实现方式,与那个单例模式有很多相似之处。它们都会先在临界区之外判断一次关键条件,若条件不满足则立即返回。这通常被称为“快路径”,或者叫做“快速失败路径”。 如果条件满足,那么到了临界区中还要再对关键条件进行一次判断,这主要是为了更加严谨。这两次条件判断常被统称为(跨临界区的)“双重检查”。由于进入临界区之前,肯定要锁定保护它的互斥锁`m`,显然会降低代码的执行速度,所以其中的第二次条件判断,以及后续的操作就被称为“慢路径”或者“常规路径”。 别看`Do`方法中的代码不多,但它却应用了一个很经典的编程范式。我们在 Go 语言及其标准库中,还能看到不少这个经典范式及它衍生版本的应用案例。 **下面我再来说说这个**`**Do**`**方法在功能方面的两个特点。** **第一个特点**,由于`Do`方法只会在参数函数执行结束之后把`done`字段的值变为`1`,因此,如果参数函数的执行需要很长时间或者根本就不会结束(比如执行一些守护任务),那么就有可能会导致相关 goroutine 的同时阻塞。 例如,有多个 goroutine 并发地调用了同一个`Once`值的`Do`方法,并且传入的函数都会一直执行而不结束。那么,这些 goroutine 就都会因调用了这个`Do`方法而阻塞。因为,除了那个抢先执行了参数函数的 goroutine 之外,其他的 goroutine 都会被阻塞在锁定该`Once`值的互斥锁`m`的那行代码上。 **第二个特点**,`Do`方法在参数函数执行结束后,对`done`字段的赋值用的是原子操作,并且,这一操作是被挂在`defer`语句中的。因此,不论参数函数的执行会以怎样的方式结束,`done`字段的值都会变为`1`。 也就是说,即使这个参数函数没有执行成功(比如引发了一个 panic),我们也无法使用同一个`Once`值重新执行它了。所以,如果你需要为参数函数的执行设定重试机制,那么就要考虑`Once`值的适时替换问题。 在很多时候,我们需要依据`Do`方法的这两个特点来设计与之相关的流程,以避免不必要的程序阻塞和功能缺失。 ### [](https://jums.gitbook.io/36-lectures-on-golang/31-sync.waitgroup-he-sync.once#zong-jie) 总结 `sync`代码包的`WaitGroup`类型和`Once`类型都是非常易用的同步工具。它们都是开箱即用和并发安全的。 利用`WaitGroup`值,我们可以很方便地实现一对多的 goroutine 协作流程,即:一个分发子任务的 goroutine,和多个执行子任务的 goroutine,共同来完成一个较大的任务。 在使用`WaitGroup`值的时候,我们一定要注意,千万不要让其中的计数器的值小于`0`,否则就会引发 panic。 另外,我们最好用“先统一`Add`,再并发`Done`,最后`Wait`”这种标准方式,来使用`WaitGroup`值。尤其不要在调用`Wait`方法的同时,并发地通过调用`Add`方法去增加其计数器的值,因为这也有可能引发 panic。 `Once`值的使用方式比`WaitGroup`值更加简单,它只有一个`Do`方法。同一个`Once`值的`Do`方法,永远只会执行第一次被调用时传入的参数函数,不论这个函数的执行会以怎样的方式结束。 只要传入某个`Do`方法的参数函数没有结束执行,任何之后调用该方法的 goroutine 就都会被阻塞。只有在这个参数函数执行结束以后,那些 goroutine 才会逐一被唤醒。 `Once`类型使用互斥锁和原子操作实现了功能,而`WaitGroup`类型中只用到了原子操作。 所以可以说,它们都是更高层次的同步工具。它们都基于基本的通用工具,实现了某一种特定的功能。`sync`包中的其他高级同步工具,其实也都是这样的。 ### [](https://jums.gitbook.io/36-lectures-on-golang/31-sync.waitgroup-he-sync.once#si-kao-ti) 思考题 今天的思考题是:在使用`WaitGroup`值实现一对多的 goroutine 协作流程时,怎样才能让分发子任务的 goroutine 获得各个子任务的具体执行结果? [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页30 | 原子操作(下)](https://jums.gitbook.io/36-lectures-on-golang/30-yuan-zi-cao-zuo-xia) [下一页32 | context.Context类型](https://jums.gitbook.io/36-lectures-on-golang/32-context.context-lei-xing) 最后更新于2年前 这有帮助吗? --- # 34 | 并发安全字典sync.Map (上) | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252FluRbxklLSbloauFSz5dp%252Fimage.png%3Falt%3Dmedia%26token%3D521d3f69-2514-4d10-91e2-77735cf77647&width=768&dpr=4&quality=100&sign=21acf96a&sv=2) 在前面,我几乎已经把 Go 语言自带的同步工具全盘托出了。你是否已经听懂了会用了呢?无论怎样,我都希望你能够多多练习、多多使用。它们和 Go 语言独有的并发编程方式并不冲突,相反,配合起来使用,绝对能达到“一加一大于二”的效果。 当然了,至于怎样配合就是一门学问了。我在前面已经讲了不少的方法和技巧,不过,更多的东西可能就需要你在实践中逐渐领悟和总结了。 我们今天再来讲一个并发安全的高级数据结构:`sync.Map`。众所周知,Go 语言自带的字典类型`map`并不是并发安全的。 换句话说,在同一时间段内,让不同 goroutine 中的代码,对同一个字典进行读写操作是不安全的。字典值本身可能会因这些操作而产生混乱,相关的程序也可能会因此发生不可预知的问题。 在`sync.Map`出现之前,我们如果要实现并发安全的字典,就只能自行构建。不过,这其实也不是什么麻烦事,使用 `sync.Mutex`或`sync.RWMutex`,再加上原生的`map`就可以轻松地做到。 GitHub 网站上已经有很多库提供了类似的数据结构。我在《Go 并发编程实战》的第 2 版中也提供了一个比较完整的并发安全字典的实现。它的性能比同类的数据结构还要好一些,因为它在很大程度上有效地避免了对锁的依赖。 尽管已经有了不少的参考实现,Go 语言爱好者们还是希望 Go 语言官方能够发布一个标准的并发安全字典。经过大家多年的建议和吐槽,Go 语言官方终于在 2017 年发布的 Go 1.9 中正式加入了并发安全的字典类型`sync.Map`。 这个字典类型提供了一些常用的键值存取操作方法,并保证了这些操作的并发安全。同时,它的存、取、删等操作都可以基本保证在常数时间内执行完毕。换句话说,它们的算法复杂度与`map`类型一样都是`O(1)`的。 在有些时候,与单纯使用原生`map`和互斥锁的方案相比,使用`sync.Map`可以显著地减少锁的争用。`sync.Map`本身虽然也用到了锁,但是,它其实在尽可能地避免使用锁。 我们都知道,使用锁就意味着要把一些并发的操作强制串行化。这往往会降低程序的性能,尤其是在计算机拥有多个 CPU 核心的情况下。因此,我们常说,能用原子操作就不要用锁,不过这很有局限性,毕竟原子只能对一些基本的数据类型提供支持。 无论在何种场景下使用`sync.Map`,我们都需要注意,与原生`map`明显不同,它只是 Go 语言标准库中的一员,而不是语言层面的东西。也正因为这一点,Go 语言的编译器并不会对它的键和值进行特殊的类型检查。 如果你看过`sync.Map`的文档或者实际使用过它,那么就一定会知道,它所有的方法涉及的键和值的类型都是`interface{}`,也就是空接口,这意味着可以包罗万象。所以,我们必须在程序中自行保证它的键类型和值类型的正确性。 好了,现在第一个问题来了。**今天的问题是:并发安全字典对键的类型有要求吗?** 这道题的典型回答是:有要求。键的实际类型不能是函数类型、字典类型和切片类型。 **解析一下这个问题。** 我们都知道,Go 语言的原生字典的键类型不能是函数类型、字典类型和切片类型。 由于并发安全字典内部使用的存储介质正是原生字典,又因为它使用的原生字典键类型也是可以包罗万象的`interface{}`,所以,我们绝对不能带着任何实际类型为函数类型、字典类型或切片类型的键值去操作并发安全字典。 由于这些键值的实际类型只有在程序运行期间才能够确定,所以 Go 语言编译器是无法在编译期对它们进行检查的,不正确的键值实际类型肯定会引发 panic。 因此,我们在这里首先要做的一件事就是:一定不要违反上述规则。我们应该在每次操作并发安全字典的时候,都去显式地检查键值的实际类型。无论是存、取还是删,都应该如此。 当然,更好的做法是,把针对同一个并发安全字典的这几种操作都集中起来,然后统一地编写检查代码。除此之外,把并发安全字典封装在一个结构体类型中,往往是一个很好的选择。 总之,我们必须保证键的类型是可比较的(或者说可判等的)。如果你实在拿不准,那么可以先通过调用`reflect.TypeOf`函数得到一个键值对应的反射类型值(即:`reflect.Type`类型的值),然后再调用这个值的`Comparable`方法,得到确切的判断结果。 ### [](https://jums.gitbook.io/36-lectures-on-golang/34-bing-fa-an-quan-zi-dian-sync.map-shang#zhi-shi-kuo-zhan) 知识扩展 ### [](https://jums.gitbook.io/36-lectures-on-golang/34-bing-fa-an-quan-zi-dian-sync.map-shang#wen-ti-1-zen-yang-bao-zheng-bing-fa-an-quan-zi-dian-zhong-de-jian-he-zhi-de-lei-xing-zheng-que-xing) 问题 1:怎样保证并发安全字典中的键和值的类型正确性?(方案一) 简单地说,可以使用类型断言表达式或者反射操作来保证它们的类型正确性。 为了进一步明确并发安全字典中键值的实际类型,这里大致有两种方案可选。 **第一种方案是,让并发安全字典只能存储某个特定类型的键。** 比如,指定这里的键只能是`int`类型的,或者只能是字符串,又或是某类结构体。一旦完全确定了键的类型,你就可以在进行存、取、删操作的时候,使用类型断言表达式去对键的类型做检查了。 一般情况下,这种检查并不繁琐。而且,你要是把并发安全字典封装在一个结构体类型里面,那就更加方便了。你这时完全可以让 Go 语言编译器帮助你做类型检查。请看下面的代码: 复制 type IntStrMap struct { m sync.Map } func (iMap *IntStrMap) Delete(key int) { iMap.m.Delete(key) } func (iMap *IntStrMap) Load(key int) (value string, ok bool) { v, ok := iMap.m.Load(key) if v != nil { value = v.(string) } return } func (iMap *IntStrMap) LoadOrStore(key int, value string) (actual string, loaded bool) { a, loaded := iMap.m.LoadOrStore(key, value) actual = a.(string) return } func (iMap *IntStrMap) Range(f func(key int, value string) bool) { f1 := func(key, value interface{}) bool { return f(key.(int), value.(string)) } iMap.m.Range(f1) } func (iMap *IntStrMap) Store(key int, value string) { iMap.m.Store(key, value) } 如上所示,我编写了一个名为`IntStrMap`的结构体类型,它代表了键类型为`int`、值类型为`string`的并发安全字典。在这个结构体类型中,只有一个`sync.Map`类型的字段`m`。并且,这个类型拥有的所有方法,都与`sync.Map`类型的方法非常类似。 两者对应的方法名称完全一致,方法签名也非常相似,只不过,与键和值相关的那些参数和结果的类型不同而已。在`IntStrMap`类型的方法签名中,明确了键的类型为`int`,且值的类型为`string`。 显然,这些方法在接受键和值的时候就不用再做类型检查了。另外,这些方法在从`m`中取出键和值的时候,完全不用担心它们的类型会不正确,因为它的正确性在当初存入的时候,就已经由 Go 语言编译器保证了。 稍微总结一下。第一种方案适用于我们可以完全确定键和值的具体类型的情况。在这种情况下,我们可以利用 Go 语言编译器去做类型检查,并用类型断言表达式作为辅助,就像`IntStrMap`那样。 ### [](https://jums.gitbook.io/36-lectures-on-golang/34-bing-fa-an-quan-zi-dian-sync.map-shang#zong-jie) 总结 我们今天讨论的是`sync.Map`类型,它是一种并发安全的字典。它提供了一些常用的键、值存取操作方法,并保证了这些操作的并发安全。同时,它还保证了存、取、删等操作的常数级执行时间。 与原生的字典相同,并发安全字典对键的类型也是有要求的。它们同样不能是函数类型、字典类型和切片类型。另外,由于并发安全字典提供的方法涉及的键和值的类型都是`interface{}`,所以我们在调用这些方法的时候,往往还需要对键和值的实际类型进行检查。 这里大致有两个方案。我们今天主要提到了第一种方案,这是在编码时就完全确定键和值的类型,然后利用 Go 语言的编译器帮我们做检查。在下一次的文章中,我们会提到另外一种方案,并对比这两种方案的优劣。除此之外,我会继续探讨并发安全字典的相关问题。 感谢你的收听,我们下期再见。 [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页33 | 临时对象池sync.Pool](https://jums.gitbook.io/36-lectures-on-golang/33-lin-shi-dui-xiang-chi-sync.pool) [下一页35 | 并发安全字典sync.Map (下)](https://jums.gitbook.io/36-lectures-on-golang/35-bing-fa-an-quan-zi-dian-sync.map-xia) 最后更新于2年前 这有帮助吗? --- # 43 | bufio包中的数据类型(下) | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252FVoeJ3AXfNjZDUEtVXZKf%252Fimage.png%3Falt%3Dmedia%26token%3D10e880a6-3d84-4bc5-9599-b01fd93f9add&width=768&dpr=4&quality=100&sign=d860204d&sv=2) 你好,我是郝林,我今天继续分享 bufio 包中的数据类型。 在上一篇文章中,我提到了`bufio`包中的数据类型主要有`Reader`、`Scanner`、`Writer`和`ReadWriter`。并着重讲到了`bufio.Reader`类型与`bufio.Writer`类型,今天,我们继续专注`bufio.Reader`的内容来进行学习。 ### [](https://jums.gitbook.io/36-lectures-on-golang/43-bufio-bao-zhong-de-shu-ju-lei-xing-xia#zhi-shi-kuo-zhan) 知识扩展 #### [](https://jums.gitbook.io/36-lectures-on-golang/43-bufio-bao-zhong-de-shu-ju-lei-xing-xia#wen-ti-bufio.reader-lei-xing-du-qu-fang-fa-you-na-xie-bu-tong) 问题 :`bufio.Reader`类型读取方法有哪些不同? `bufio.Reader`类型拥有很多用于读取数据的指针方法,**这里面有 4 个方法可以作为不同读取流程的代表,它们是:**`**Peek**`**、**`**Read**`**、**`**ReadSlice**`**和**`**ReadBytes**`**。** `Reader`值的`Peek`方法的功能是:读取并返回其缓冲区中的`n`个未读字节,并且它会从已读计数代表的索引位置开始读。 在缓冲区未被填满,并且其中的未读字节的数量小于`n`的时候,该方法就会调用`fill`方法,以启动缓冲区填充流程。但是,如果它发现上次填充缓冲区的时候有错误,那就不会再次填充。 如果调用方给定的`n`比缓冲区的长度还要大,或者缓冲区中未读字节的数量小于`n`,那么`Peek`方法就会把“所有未读字节组成的序列”作为第一个结果值返回。 同时,它通常还会把 复制 “`bufio.ErrBufferFull`变量的值(以下简称缓冲区已满的错误)” 作为第二个结果值返回,用来表示:虽然缓冲区被压缩和填满了,但是仍然满足不了要求。 只有在上述的情况都没有出现时,`Peek`方法才能返回:“以已读计数为起始的`n`个字节”和“表示未发生任何错误的`nil`”。 `bufio.Reader`类型的 Peek 方法有一个鲜明的特点,那就是:即使它读取了缓冲区中的数据,也不会更改已读计数的值。 这个类型的其他读取方法并不是这样。就拿该类型的`Read`方法来说,它有时会把缓冲区中的未读字节,依次拷贝到其参数`p`代表的字节切片中,并立即根据实际拷贝的字节数增加已读计数的值。 在缓冲区中还有未读字节的情况下,该方法的做法就是如此。不过,在另一些时候,其所属值的已读计数会等于已写计数,这表明:此时的缓冲区中已经没有任何未读的字节了。 当缓冲区中已无未读字节时,`Read`方法会先检查参数`p`的长度是否大于或等于缓冲区的长度。如果是,那么该方法会索性放弃向缓冲区中填充数据,转而直接从其底层读取器中读出数据并拷贝到`p`中。 这意味着它完全跨过了缓冲区,并直连了数据供需的双方。需要注意的是,`Peek`方法在遇到类似情况时的做法与这里的区别。 这两种做法孰优孰劣还要看具体的使用场景。`Peek`方法会在条件满足时填充缓冲区,并在发现参数`n`的值比缓冲区的长度更大时,直接返回缓冲区中的所有未读字节。 如果我们当初设定的缓冲区长度很大,那么在这种情况下的方法执行耗时,就有可能会比较长。最主要的原因是填充缓冲区需要花费较长的时间。 由`fill`方法执行的流程可知,它会尽量填满缓冲区中的可写空间。然而,`Read`方法在大多数的情况下,是不会向缓冲区中写入数据的,尤其是在前面描述的那种情况下,即:缓冲区中已无未读字节,且参数`p`的长度大于或等于缓冲区的长度。 此时,该方法会直接从底层读取器那里读出数据,所以数据的读出速度就成为了这种情况下方法执行耗时的决定性因素。 当然了,我在这里说的只是耗时操作在某些情况下更可能出现在哪里,一切的结论还是要以性能测试的客观结果为准。 说回`Read`方法的内部流程。如果缓冲区中已无未读字节,但其长度比参数`p`的长度更大,那么该方法会先把已读计数和已写计数的值都重置为`0`,然后再尝试着使用从底层读取器那里获取的数据,对缓冲区进行一次从头至尾的填充。 不过要注意,这里的尝试只会进行一次。无论在这一时刻是否能够获取到数据,也无论获取时是否有错误发生,都会是如此。而`fill`方法的做法与此不同,只要没有发生错误,它就会进行多次尝试,因此它真正获取到一些数据的可能性更大。 不过,这两个方法有一点是相同,那就是:只要它们把获取到的数据写入缓冲区,就会及时地更新已写计数的值。 再来说`ReadSlice`方法和`ReadBytes`方法。这两个方法的功能总体上来说都是持续地读取数据,直至遇到调用方给定的分隔符为止。 `ReadSlice`方法会先在其缓冲区的未读部分中寻找分隔符。如果未能找到,并且缓冲区未满,那么该方法会先通过调用`fill`方法对缓冲区进行填充,然后再次寻找,如此往复。 如果在填充的过程中发生了错误,那么它会把缓冲区中的未读部分作为结果返回,同时返回相应的错误值。 注意,在这个过程中有可能会出现虽然缓冲区已被填满,但仍然没能找到分隔符的情况。这时,`ReadSlice`方法会把整个缓冲区(也就是`buf`字段代表的字节切片)作为第一个结果值,并把缓冲区已满的错误(即`bufio.ErrBufferFull`变量的值)作为第二个结果值。 经过`fill`方法填满的缓冲区肯定从头至尾都只包含了未读的字节,所以这样做是合理的。 当然了,一旦`ReadSlice`方法找到了分隔符,它就会在缓冲区上切出相应的、包含分隔符的字节切片,并把该切片作为结果值返回。无论分隔符找到与否,该方法都会正确地设置已读计数的值。 比如,在返回缓冲区中的所有未读字节,或者代表全部缓冲区的字节切片之前,它会把已写计数的值赋给已读计数,以表明缓冲区中已无未读字节。 如果说`ReadSlice`是一个容易半途而废的方法的话,那么可以说`ReadBytes`方法算得上是相当的执着。`ReadBytes`方法会通过调用`ReadSlice`方法一次又一次地从缓冲区中读取数据,直至找到分隔符为止。 在这个过程中,`ReadSlice`方法可能会因缓冲区已满而返回所有已读到的字节和相应的错误值,但`ReadBytes`方法总是会忽略掉这样的错误,并再次调用`ReadSlice`方法,这使得后者会继续填充缓冲区并在其中寻找分隔符。 除非`ReadSlice`方法返回的错误值并不代表缓冲区已满的错误,或者它找到了分隔符,否则这一过程永远不会结束。 如果寻找的过程结束了,不管是不是因为找到了分隔符,`ReadBytes`方法都会把在这个过程中读到的所有字节,按照读取的先后顺序组装成一个字节切片,并把它作为第一个结果值。如果过程结束是因为出现错误,那么它还会把拿到的错误值作为第二个结果值。 在`bufio.Reader`类型的众多读取方法中,依赖`ReadSlice`方法的除了`ReadBytes`方法,还有`ReadLine`方法。不过后者在读取流程上并没有什么特别之处,我就不在这里赘述了。另外,该类型的`ReadString`方法完全依赖于`ReadBytes`方法,前者只是在后者返回的结果值之上做了一个简单的类型转换而已。 最后,我还要提醒你一下,有个安全性方面的问题需要你注意。`bufio.Reader`类型的`Peek`方法、`ReadSlice`方法和`ReadLine`方法都有可能会造成内容泄露。这主要是因为它们在正常的情况下都会返回直接基于缓冲区的字节切片。我在讲`bytes.Buffer`类型的时候解释过什么叫内容泄露。你可以返回查看。 调用方可以通过这些方法返回的结果值访问到缓冲区的其他部分,甚至修改缓冲区中的内容。这通常都是很危险的。 ### [](https://jums.gitbook.io/36-lectures-on-golang/43-bufio-bao-zhong-de-shu-ju-lei-xing-xia#zong-jie) 总结 我们用比较长的篇幅介绍了`bufio`包中的数据类型,其中的重点是`bufio.Reader`类型。 `bufio.Reader`类型代表的是携带缓冲区的读取器。它的值在被初始化的时候需要接受一个底层的读取器,后者的类型必须是`io.Reader`接口的实现。 `Reader`值中的缓冲区其实就是一个数据存储中介,它介于底层读取器与读取方法及其调用方之间。此类值的读取方法一般都会先从该值的缓冲区中读取数据,同时在必要的时候预先从其底层读取器那里读出一部分数据,并填充到缓冲区中以备后用。填充缓冲区的操作通常会由该值的`fill`方法执行。在填充的过程中,`fill`方法有时还会对缓冲区进行压缩。 在`Reader`值拥有的众多读取方法中,有 4 个方法可以作为不同读取流程的代表,它们是:`Peek`、`Read`、`ReadSlice`和`ReadBytes`。 `Peek`方法的特点是即使读取了缓冲区中的数据,也不会更改已读计数的值。而`Read`方法会在参数值的长度过大,且缓冲区中已无未读字节时,跨过缓冲区并直接向底层读取器索要数据。 `ReadSlice`方法会在缓冲区的未读部分中寻找给定的分隔符,并在必要时对缓冲区进行填充。 如果在填满缓冲区之后仍然未能找到分隔符,那么该方法就会把整个缓冲区作为第一个结果值返回,同时返回缓冲区已满的错误。 `ReadBytes`方法会通过调用`ReadSlice`方法,一次又一次地填充缓冲区,并在其中寻找分隔符。除非发生了未预料到的错误或者找到了分隔符,否则这一过程将会一直进行下去。 `Reader`值的`ReadLine`方法会依赖于它的`ReadSlice`方法,而其`ReadString`方法则完全依赖于`ReadBytes`方法。 另外,值得我们特别注意的是,`Reader`值的`Peek`方法、`ReadSlice`方法和`ReadLine`方法都可能会造成其缓冲区中的内容的泄露。 最后再说一下`bufio.Writer`类型。把该类值的缓冲区中暂存的数据写进其底层写入器的功能,主要是由它的`Flush`方法实现的。 此类值的所有数据写入方法都会在必要的时候调用它的`Flush`方法。一般情况下,这些写入方法都会先把数据写进其所属值的缓冲区,然后再增加该值中的已写计数。但是,在有些时候,`Write`方法和`ReadFrom`方法也会跨过缓冲区,并直接把数据写进其底层写入器。 请记住,虽然这些写入方法都会不时地调用`Flush`方法,但是在写入所有的数据之后再显式地调用一下这个方法总是最稳妥的。 ### [](https://jums.gitbook.io/36-lectures-on-golang/43-bufio-bao-zhong-de-shu-ju-lei-xing-xia#si-kao-ti) 思考题 今天的思考题是:`bufio.Scanner`类型的主要功用是什么?它有哪些特点? 感谢你的收听,我们下期再见。 [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页42 | bufio包中的数据类型 (上)](https://jums.gitbook.io/36-lectures-on-golang/42-bufio-bao-zhong-de-shu-ju-lei-xing-shang) [下一页44 | 使用os包中的API (上)](https://jums.gitbook.io/36-lectures-on-golang/44-shi-yong-os-bao-zhong-de-api-shang) 最后更新于2年前 这有帮助吗? --- # 19 | 错误处理(上) | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fimages.unsplash.com%2Fphoto-1682685797277-f2bf89b24017%3Fcrop%3Dentropy%26cs%3Dsrgb%26fm%3Djpg%26ixid%3DM3wxOTcwMjR8MHwxfHJhbmRvbXx8fHx8fHx8fDE2ODU2ODY5MDZ8%26ixlib%3Drb-4.0.3%26q%3D85&width=768&dpr=4&quality=100&sign=ef008955&sv=2) [3MB\ \ 19.错误处理(上).mp3](https://2852705636-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FkmtyDvkosJZE1Rr33XGZ%2Fuploads%2FZcTwdXUIGu6869inuEXw%2F19.%E9%94%99%E8%AF%AF%E5%A4%84%E7%90%86%EF%BC%88%E4%B8%8A%EF%BC%89.mp3?alt=media&token=b30a6c00-7b17-4c2d-bf86-61d4bf4c5f31) 提到 Go 语言中的错误处理,我们其实已经在前面接触过几次了。比如,我们声明过`error`类型的变量`err`,也调用过`errors`包中的`New`函数。今天,我会用这篇文章为你梳理 Go 语言错误处理的相关知识,同时提出一些关键问题并与你一起探讨。 我们说过`error`类型其实是一个接口类型,也是一个 Go 语言的内建类型。在这个接口类型的声明中只包含了一个方法`Error`。这个方法不接受任何参数,但是会返回一个`string`类型的结果。 它的作用是返回错误信息的字符串表示形式。我们使用`error`类型的方式通常是,在函数声明的结果列表的最后,声明一个该类型的结果,同时在调用这个函数之后,先判断它返回的最后一个结果值是否“不为`nil`”。 如果这个值“不为`nil`”,那么就进入错误处理流程,否则就继续进行正常的流程。下面是一个例子,代码在 demo44.go 文件中。 复制 package main import ( "errors" "fmt" ) func echo(request string) (response string, err error) { if request == "" { err = errors.New("empty request") return } response = fmt.Sprintf("echo: %s", request) return } func main() { for _, req := range []string{"", "hello!"} { fmt.Printf("request: %s\n", req) resp, err := echo(req) if err != nil { fmt.Printf("error: %s\n", err) continue } fmt.Printf("response: %s\n", resp) } } 我们先看`echo`函数的声明。`echo`函数接受一个`string`类型的参数`request`,并会返回两个结果。 这两个结果都是有名称的,第一个结果`response`也是`string`类型的,它代表了这个函数正常执行后的结果值。第二个结果`err`就是`error`类型的,它代表了函数执行出错时的结果值,同时也包含了具体的错误信息。 当`echo`函数被调用时,它会先检查参数`request`的值。如果该值为空字符串,那么它就会通过调用`errors.New`函数,为结果`err`赋值,然后忽略掉后边的操作并直接返回。 此时,结果`response`的值也会是一个空字符串。如果`request`的值并不是空字符串,那么它就为结果`response`赋一个适当的值,然后返回,此时的结果`err`的值会是`nil`。 再来看`main`函数中的代码。我在每次调用`echo`函数之后都会把它返回的结果值赋给变量`resp`和`err`,并且总是先检查`err`的值是否“不为`nil`”,如果是,就打印错误信息,否则就打印常规的响应信息。 这里值得注意的地方有两个。第一,在`echo`函数和`main`函数中,我都使用到了卫述语句。我在前面讲函数用法的时候也提到过卫述语句。简单地讲,它就是被用来检查后续操作的前置条件并进行相应处理的语句。 对于`echo`函数来说,它进行常规操作的前提是:传入的参数值一定要符合要求。而对于调用`echo`函数的程序来说,进行后续操作的前提就是`echo`函数的执行不能出错。 我们在进行错误处理的时候经常会用到卫述语句,以至于有些人会吐槽说:“我的程序满屏都是卫述语句,简直是太难看了!”不过,我倒认为这有可能是程序设计上的问题。每个编程语言的理念和风格几乎都会有明显的不同,我们常常需要顺应它们的纹理去做设计,而不是用其他语言的编程思想来编写当下语言的程序。 再来说第二个值得注意的地方。我在生成`error`类型值的时候用到了`errors.New`函数。这是一种最基本的生成错误值的方式。我们调用它的时候传入一个由字符串代表的错误信息,它会给返回给我们一个包含了这个错误信息的`error`类型值。该值的静态类型当然是`error`,而动态类型则是一个在`errors`包中的,包级私有的类型`*errorString`。 显然,`errorString`类型拥有的一个指针方法实现了`error`接口中的`Error`方法。这个方法在被调用后,会原封不动地返回我们之前传入的错误信息。实际上,`error`类型值的`Error`方法就相当于其他类型值的`String`方法。 我们已经知道,通过调用`fmt.Printf`函数,并给定占位符`%s`就可以打印出某个值的字符串表示形式。对于其他类型的值来说,只要我们能为这个类型编写一个`String`方法,就可以自定义它的字符串表示形式。而对于`error`类型值,它的字符串表示形式则取决于它的`Error`方法。 在上述情况下,`fmt.Printf`函数如果发现被打印的值是一个`error`类型的值,那么就会去调用它的`Error`方法。`fmt`包中的这类打印函数其实都是这么做的。 顺便提一句,当我们想通过模板化的方式生成错误信息,并得到错误值时,可以使用`fmt.Errorf`函数。该函数所做的其实就是先调用`fmt.Sprintf`函数,得到确切的错误信息;再调用`errors.New`函数,得到包含该错误信息的`error`类型值,最后返回该值。 好了,我现在问一个关于对错误值做判断的问题。我们今天的**问题是:对于具体错误的判断,Go 语言中都有哪些惯用法?** 由于`error`是一个接口类型,所以即使同为`error`类型的错误值,它们的实际类型也可能不同。这个问题还可以换一种问法,即:怎样判断一个错误值具体代表的是哪一类错误? 这道题的**典型回答**是这样的: 1. 对于类型在已知范围内的一系列错误值,一般使用类型断言表达式或类型`switch`语句来判断; 2. 对于已有相应变量且类型相同的一系列错误值,一般直接使用判等操作来判断; 3. 对于没有相应变量且类型未知的一系列错误值,只能使用其错误信息的字符串表示形式来做判断。 ### [](https://jums.gitbook.io/36-lectures-on-golang/19-cuo-wu-chu-li-shang#wen-ti-jie-xi) 问题解析 如果你看过一些 Go 语言标准库的源代码,那么对这几种情况应该都不陌生。我下面分别对它们做个说明。 类型在已知范围内的错误值其实是最容易分辨的。就拿`os`包中的几个代表错误的类型`os.PathError`、`os.LinkError`、`os.SyscallError`和`os/exec.Error`来说,它们的指针类型都是`error`接口的实现类型,同时它们也都包含了一个名叫`Err`,类型为`error`接口类型的代表潜在错误的字段。 如果我们得到一个`error`类型值,并且知道该值的实际类型肯定是它们中的某一个,那么就可以用类型`switch`语句去做判断。例如: 复制 func underlyingError(err error) error { switch err := err.(type) { case *os.PathError: return err.Err case *os.LinkError: return err.Err case *os.SyscallError: return err.Err case *exec.Error: return err.Err } return err } 函数`underlyingError`的作用是:获取和返回已知的操作系统相关错误的潜在错误值。其中的类型`switch`语句中有若干个`case`子句,分别对应了上述几个错误类型。当它们被选中时,都会把函数参数`err`的`Err`字段作为结果值返回。如果它们都未被选中,那么该函数就会直接把参数值作为结果返回,即放弃获取潜在错误值。 只要类型不同,我们就可以如此分辨。但是在错误值类型相同的情况下,这些手段就无能为力了。在 Go 语言的标准库中也有不少以相同方式创建的同类型的错误值。 我们还拿`os`包来说,其中不少的错误值都是通过调用`errors.New`函数来初始化的,比如:`os.ErrClosed`、`os.ErrInvalid`以及`os.ErrPermission`,等等。 注意,与前面讲到的那些错误类型不同,这几个都是已经定义好的、确切的错误值。`os`包中的代码有时候会把它们当做潜在错误值,封装进前面那些错误类型的值中。 如果我们在操作文件系统的时候得到了一个错误值,并且知道该值的潜在错误值肯定是上述值中的某一个,那么就可以用普通的`switch`语句去做判断,当然了,用`if`语句和判等操作符也是可以的。例如: 复制 printError := func(i int, err error) { if err == nil { fmt.Println("nil error") return } err = underlyingError(err) switch err { case os.ErrClosed: fmt.Printf("error(closed)[%d]: %s\n", i, err) case os.ErrInvalid: fmt.Printf("error(invalid)[%d]: %s\n", i, err) case os.ErrPermission: fmt.Printf("error(permission)[%d]: %s\n", i, err) } } 这个由`printError`变量代表的函数会接受一个`error`类型的参数值。该值总会代表某个文件操作相关的错误,这是我故意地以不正确的方式操作文件后得到的。 虽然我不知道这些错误值的类型的范围,但却知道它们或它们的潜在错误值一定是某个已经在`os`包中定义的值。 所以,我先用`underlyingError`函数得到它们的潜在错误值,当然也可能只得到原错误值而已。然后,我用`switch`语句对错误值进行判等操作,三个`case`子句分别对应我刚刚提到的那三个已存在于`os`包中的错误值。如此一来,我就能分辨出具体错误了。 对于上面这两种情况,我们都有明确的方式去解决。但是,如果我们对一个错误值可能代表的含义知之甚少,那么就只能通过它拥有的错误信息去做判断了。 好在我们总是能通过错误值的`Error`方法,拿到它的错误信息。其实`os`包中就有做这种判断的函数,比如:`os.IsExist`、`os.IsNotExist`和`os.IsPermission`。命令源码文件 demo45.go 中包含了对它们的应用,这大致跟前面展示的代码差不太多,我就不在这里赘述了。 ### [](https://jums.gitbook.io/36-lectures-on-golang/19-cuo-wu-chu-li-shang#zong-jie) 总结 今天我们一起初步学习了错误处理的内容。我们总结了错误类型、错误值的处理技巧和设计方式,并一起分享了 Go 语言中处理错误的最基本方式。由于错误处理的内容分为上下两篇,在下一次的文章中,我们会站在建造者的角度,一起来探索一下:怎样根据实际情况给予恰当的错误值。 ### [](https://jums.gitbook.io/36-lectures-on-golang/19-cuo-wu-chu-li-shang#si-kao-ti) 思考题 请列举出你经常用到或者看到的 3 个错误类型,它们所在的错误类型体系都是怎样的?你能画出一棵树来描述它们吗? 感谢你的收听,我们下期再见。 [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页18 | if语句、for语句和switch语句](https://jums.gitbook.io/36-lectures-on-golang/18-if-yu-ju-for-yu-ju-he-switch-yu-ju) [下一页20 | 错误处理 (下)](https://jums.gitbook.io/36-lectures-on-golang/20-cuo-wu-chu-li-xia) 最后更新于2年前 这有帮助吗? --- # 50 | 学习专栏的正确姿势 | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252FEquy2iPzn6wf57cAcYqt%252Fimage.png%3Falt%3Dmedia%26token%3D26428341-1e1c-459e-8801-58852fe7f90a&width=768&dpr=4&quality=100&sign=de707330&sv=2) 你好,我是郝林,今天我分享的主题是,学习专栏的正确姿势。 到了这里,专栏的全部内容已经都推送到你的面前了。如果你已经同步学习完了,那么我要给你点一个大大的赞! 还没有看完的同学也不要着急,因为推送的速度肯定要比你们的学习速度快上不少。如果是我的话,我肯定无法用很快的速度,去认真地学习和理解专栏内容的。不过,粗读一遍的话,这个时间倒是绰绰有余的。我今天就想跟你聊聊学习专栏的正确姿势。 ### [](https://jums.gitbook.io/36-lectures-on-golang/50-xue-xi-zhuan-lan-de-zheng-que-zi-shi#zhuan-lan-ying-gai-zen-yang-xue) 专栏应该怎样学 我们做互联网技术的人,应该对这种索引 + 摘要 + 详情的数据存取方案并不陌生。我希望我的专栏文章也可以达成这样的一种状态:它是你需要时,即能查阅的知识手册。 在第一次听音频或浏览文章的时候,你可以走马观花,并不用去细扣每一个概念和每一句话。让自己对每一个主题、每一个问题和每一个要点都有一个大概的印象就可以了。 如此一来,当想到或遇到某方面的疑惑的时候,你就可以有一个大致的方向,并且知道怎样从专栏里找出相应的内容。 这就是所谓的粗读,相当于在你的脑袋里面存了一份索引,甚至是一份摘要。利用这种快速的学习方式,你往往可以在有限的精力和无限的知识之间做出适合你的权衡。 极客时间可以让我们无限期地查阅专栏的全部内容。所以你完全不用心急,可以按照自己的节奏先粗读、再细读,然后再拿这个专栏当做知识手册来用。重要的是真正的理解和积极的实践,而不是阅读的速度。 ### [](https://jums.gitbook.io/36-lectures-on-golang/50-xue-xi-zhuan-lan-de-zheng-que-zi-shi#shi-jian-de-zheng-que-zi-shi) 实践的正确姿势 最近一段时间,有不少同学问我说:“老师,我快要学完这个专栏了,也买了你的书,那我后边怎么去实践呢?” 问我此类问题的同学,大多数都是很少有机会在工作中使用 Go 语言的程序员,或者是对 Go 语言感兴趣的互联网领域的从业者,还有一些是在校的大学生。 我给大家的第一个建议一般都是“去写网络爬虫吧”。 互联网络的世界很庞杂,但又有一定的规律可循,是非常好的技术学习环境。你编写一个网络服务程序,即使放到了公共的网络上,也还需要考虑清楚一系列的问题,才能让你有足够多的技术磨炼机会,比如,服务的种类、功能、规则、安全、界面、受众、宣传和访问途径,以及日常的非技术性维护。 我认为,这已经不是纯粹的技术实践了,对于初期的技术技能增长是不利的。当然了,如果你有信心和精力去搞定这一系列问题,并乐于从中学习到各种各样的技能,那就放手去做吧。 我在我的书和专栏中一直都在释放这样几个信号:“并发程序”“互联网络”“客户端”“网络爬虫”。这其实就是我们实践的最佳切入点。它成本低,收效明显,既有深度又有广度。 有的同学还问我:“我的程序爬取了某某网站,可是只爬了两三下就好像被人家封掉了”。原因很明显,你暴力获取人家的网站内容,肯定会封你的啊。 我们要让程序去模拟人的行为,模拟人使用网络浏览器访问网站内容的过程,而不是用尽计算力去疯狂地霸占人家的带宽和服务,否则那不就成了网络攻击了。这是一个非常重要的自我实践的技巧,请大家记住,“利己,但不要损人”。 注意,正常爬取网站内容并不意味着失去了高并发的应用场景。把内容下载下来只是一个开始,后边还有不少的工作要做呢。 单单“模拟人”这一点就需要花一些心思。而且,你可以同时爬取成千上万的同类甚至不同类的网站。这已经足够你研究和实践很长一段时间了。我在这里还要郑重地提示一下,做这类技术研究一定不要跨越道德的底线,更不能违反法律。 再进一步,我们最好以结构化的形式把爬取到的网络内容存储下来。当得到足够多的数据之后,你的选择就很多了。比如,对某类数据进行整理、提取和分析,从而挖掘出更有价值的东西。这就属于数据挖掘的范畴了。 在如今这个数据过剩的时代,这也是一项很重要的技能。又比如,基于这些数据提供统一的访问接口,制作成搜索引擎,甚至对外提供服务。这也是一个很有深度的选择。 当然,技术实践的方式远不止这些。不过鉴于篇幅,我就先说这么多。 最后,我再给大家推荐一些优秀的 Go 项目。别忘了,阅读优秀的项目源码也是一个很重要的学习途径。请看下图。 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F29%2Ff8%2F29c794731d9fb43f9fd37cc4d91994f8.png&width=300&dpr=4&quality=100&sign=8616439b&sv=2) (长按保存大图查看) 这幅图包含了我之前私藏的所有高`Star`,且近期依然活跃的 Go 项目。不得不说,在 Github 这个全球最大的程序员交友社区中,好东西真的是不少。 在这幅图的左上角,有我对图中各种符号的说明,大家在进一步读图之前需要先看一下。参看这些项目的顺序完全由你自己决定,不过我建议从“贴近你实际工作的那个方面”入手,然后可以是“你感兴趣的方面”,最后有机会再看其他的项目。千万不要贪多,要循序渐进着来。 我个人还为你们专门在[BearyChat](https://bearychat.com/) 上创建了一个名叫“GoHackers”的团队空间。创建这个空间的初衷是我想增进与专栏读者们的交流,包括文章答疑、思考题解读以及在技术和职业方面的互通有无。 当然了,即使不是本专栏的读者也是可以加入的,只要你对 Go 语言编程感兴趣就可以。通过这个[邀请链接](https://gohackers.bearychat.com/signup/2d533429591347db9810f3f106596270) ,你可以直接加入并参与讨论。不过,你可能需要先简单地注册一下。 [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页49 | 程序性能分析基础(下)](https://jums.gitbook.io/36-lectures-on-golang/49-cheng-xu-xing-neng-fen-xi-ji-chu-xia) 最后更新于2年前 这有帮助吗? --- # 16 | go语句及其执行规则(上) | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252Fe3A0UsY6tdkInUkecCbk%252Fimage.png%3Falt%3Dmedia%26token%3D72436371-9fa1-4968-ae88-1e093ea90cee&width=768&dpr=4&quality=100&sign=e5f4f3b6&sv=2) [7MB\ \ 16.go语句及其执行规则(上).mp3](https://2852705636-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FkmtyDvkosJZE1Rr33XGZ%2Fuploads%2FdVaTI8Y1t7xwlhv0mtsn%2F16.go%E8%AF%AD%E5%8F%A5%E5%8F%8A%E5%85%B6%E6%89%A7%E8%A1%8C%E8%A7%84%E5%88%99%EF%BC%88%E4%B8%8A%EF%BC%89.mp3?alt=media&token=4284c32b-0be6-43a5-8ddd-9ed000a9fc44) 你很棒,已经学完了关于 Go 语言数据类型的全部内容。我相信你不但已经知晓了怎样高效地使用 Go 语言内建的那些数据类型,还明白了怎样正确地创造自己的数据类型。 对于 Go 语言的编程知识,你确实已经知道了不少了。不过,如果你真想玩转 Go 语言还需要知道它的一些特色流程和语法。 尤其是我们将会在本篇文章中讨论的`go`语句,这也是 Go 语言的最大特色了。它足可以代表 Go 语言最重要的编程哲学和并发编程模式。 让我们再重温一下下面这句话: > Don’t communicate by sharing memory; share memory by communicating. 从 Go 语言编程的角度解释,这句话的意思就是:不要通过共享数据来通讯,恰恰相反,要以通讯的方式共享数据。 我们已经知道,通道(也就是 channel)类型的值可以被用来以通讯的方式共享数据。更具体地说,它一般被用来在不同的 goroutine 之间传递数据。那么 goroutine 到底代表着什么呢? 简单来说,goroutine 代表着并发编程模型中的用户级线程。你可能已经知道,操作系统本身提供了进程和线程这两种并发执行程序的工具。进程,描述的就是程序的执行过程,是运行着的程序的代表。 换句话说,一个进程其实就是某个程序运行时的一个产物。如果说静静地躺在那里的代码就是程序的话,那么奔跑着的、正在发挥着既有功能的代码就可以被称为进程。 我们的电脑为什么可以同时运行那么多应用程序?我们的手机为什么可以有那么多 App 同时在后台刷新?这都是因为在它们的操作系统之上有多个代表着不同应用程序或 App 的进程在同时运行。 再来说说线程。首先,线程总是在进程之内的,它可以被视为进程中运行着的控制流(或者说代码执行的流程)。 一个进程至少会包含一个线程。如果一个进程只包含了一个线程,那么它里面的所有代码都只会被串行地执行。每个进程的第一个线程都会随着该进程的启动而被创建,它们可以被称为其所属进程的主线程。 相对应的,如果一个进程中包含了多个线程,那么其中的代码就可以被并发地执行。除了进程的第一个线程之外,其他的线程都是由进程中已存在的线程创建出来的。 也就是说,主线程之外的其他线程都只能由代码显式地创建和销毁。这需要我们在编写程序的时候进行手动控制,操作系统以及进程本身并不会帮我们下达这样的指令,它们只会忠实地执行我们的指令。 不过,在 Go 程序当中,Go 语言的运行时(runtime)系统会帮助我们自动地创建和销毁系统级的线程。这里的系统级线程指的就是我们刚刚说过的操作系统提供的线程。 而对应的用户级线程指的是架设在系统级线程之上的,由用户(或者说我们编写的程序)完全控制的代码执行流程。用户级线程的创建、销毁、调度、状态变更以及其中的代码和数据都完全需要我们的程序自己去实现和处理。 这带来了很多优势,比如,因为它们的创建和销毁并不用通过操作系统去做,所以速度会很快,又比如,由于不用等着操作系统去调度它们的运行,所以往往会很容易控制并且可以很灵活。 但是,劣势也是有的,最明显也最重要的一个劣势就是复杂。如果我们只使用了系统级线程,那么我们只要指明需要新线程执行的代码片段,并且下达创建或销毁线程的指令就好了,其他的一切具体实现都会由操作系统代劳。 但是,如果使用用户级线程,我们就不得不既是指令下达者,又是指令执行者。我们必须全权负责与用户级线程有关的所有具体实现。 操作系统不但不会帮忙,还会要求我们的具体实现必须与它正确地对接,否则用户级线程就无法被并发地,甚至正确地运行。毕竟我们编写的所有代码最终都需要通过操作系统才能在计算机上执行。这听起来就很麻烦,不是吗? 不过别担心,Go 语言不但有着独特的并发编程模型,以及用户级线程 goroutine,还拥有强大的用于调度 goroutine、对接系统级线程的调度器。 这个调度器是 Go 语言运行时系统的重要组成部分,它主要负责统筹调配 Go 并发编程模型中的三个主要元素,即:G(goroutine 的缩写)、P(processor 的缩写)和 M(machine 的缩写)。 其中的 M 指代的就是系统级线程。而 P 指的是一种可以承载若干个 G,且能够使这些 G 适时地与 M 进行对接,并得到真正运行的中介。 从宏观上说,G 和 M 由于 P 的存在可以呈现出多对多的关系。当一个正在与某个 M 对接并运行着的 G,需要因某个事件(比如等待 I/O 或锁的解除)而暂停运行的时候,调度器总会及时地发现,并把这个 G 与那个 M 分离开,以释放计算资源供那些等待运行的 G 使用。 而当一个 G 需要恢复运行的时候,调度器又会尽快地为它寻找空闲的计算资源(包括 M)并安排运行。另外,当 M 不够用时,调度器会帮我们向操作系统申请新的系统级线程,而当某个 M 已无用时,调度器又会负责把它及时地销毁掉。 正因为调度器帮助我们做了很多事,所以我们的 Go 程序才总是能高效地利用操作系统和计算机资源。程序中的所有 goroutine 也都会被充分地调度,其中的代码也都会被并发地运行,即使这样的 goroutine 有数以十万计,也仍然可以如此。 由于篇幅原因,关于 Go 语言内部的调度器和运行时系统的更多细节,我在这里就不再深入讲述了。你需要知道,Go 语言实现了一套非常完善的运行时系统,保证了我们的程序在高并发的情况下依旧能够稳定、高效地运行。 如果你对这些具体的细节感兴趣,并还想进一步探索,那么我推荐你去看看我写的那本《Go 并发编程实战》。我在这本书中用了相当大的篇幅阐释了 Go 语言并发编程模型的原理、运作机制,以及所有与之紧密相关的知识。 下面,我会从编程实践的角度出发,以`go`语句的用法为主线,向你介绍`go`语句的执行规则、最佳实践和使用禁忌。 我们来看一下今天的**问题:什么是主 goroutine,它与我们启用的其他 goroutine 有什么不同?** 我们具体来看一道我在面试中经常提问的编程题。 复制 package main import ( "fmt" "time" ) func main() { for i := 0; i < 10; i++ { go func() { fmt.Println(i) }() } //time.Sleep(1 * time.Second) time.Sleep(time.Millisecond * 500) } 在 [demo38.go](https://github.com/crazyjums/go_haolingeek/blob/master/article16/q1/demo38.go) 中,我只在`main`函数中写了一条`for`语句。这条`for`语句中的代码会迭代运行 10 次,并有一个局部变量`i`代表着当次迭代的序号,该序号是从`0`开始的。 在这条`for`语句中仅有一条`go`语句,这条`go`语句中也仅有一条语句。这条最里面的语句调用了`fmt.Println`函数并想要打印出变量`i`的值。 这个程序很简单,三条语句逐条嵌套。我的具体问题是:这个命令源码文件被执行后会打印出什么内容? 这道题的**典型回答**是:不会有任何内容被打印出来。 ### [](https://jums.gitbook.io/36-lectures-on-golang/16-go-yu-ju-ji-qi-zhi-hang-gui-ze-shang#wen-ti-jie-xi) 问题解析 与一个进程总会有一个主线程类似,每一个独立的 Go 程序在运行时也总会有一个主 goroutine。这个主 goroutine 会在 Go 程序的运行准备工作完成后被自动地启用,并不需要我们做任何手动的操作。 想必你已经知道,每条`go`语句一般都会携带一个函数调用,这个被调用的函数常常被称为`go`函数。而主 goroutine 的`go`函数就是那个作为程序入口的`main`函数。 一定要注意,`go`函数真正被执行的时间总会与其所属的`go`语句被执行的时间不同。当程序执行到一条`go`语句的时候,Go 语言的运行时系统,会先试图从某个存放空闲的 G 的队列中获取一个 G(也就是 goroutine),它只有在找不到空闲 G 的情况下才会去创建一个新的 G。 这也是为什么我总会说“启用”一个 goroutine 而不说“创建”一个 goroutine 的原因。已存在的 goroutine 总是会被优先复用。 然而,创建 G 的成本也是非常低的。创建一个 G 并不会像新建一个进程或者一个系统级线程那样,必须通过操作系统的系统调用来完成,在 Go 语言的运行时系统内部就可以完全做到了,更何况一个 G 仅相当于为需要并发执行代码片段服务的上下文环境而已。 在拿到了一个空闲的 G 之后,Go 语言运行时系统会用这个 G 去包装当前的那个`go`函数(或者说该函数中的那些代码),然后再把这个 G 追加到某个存放可运行的 G 的队列中。 这类队列中的 G 总是会按照先入先出的顺序,很快地由运行时系统内部的调度器安排运行。虽然这会很快,但是由于上面所说的那些准备工作还是不可避免的,所以耗时还是存在的。 因此,`go`函数的执行时间总是会明显滞后于它所属的`go`语句的执行时间。当然了,这里所说的“明显滞后”是对于计算机的 CPU 时钟和 Go 程序来说的。我们在大多数时候都不会有明显的感觉。 在说明了原理之后,我们再来看这种原理下的表象。请记住,只要`go`语句本身执行完毕,Go 程序完全不会等待`go`函数的执行,它会立刻去执行后边的语句。这就是所谓的异步并发地执行。 这里“后边的语句”指的一般是`for`语句中的下一个迭代。然而,当最后一个迭代运行的时候,这个“后边的语句”是不存在的。 在 [demo38.go](https://github.com/crazyjums/go_haolingeek/blob/master/article16/q1/demo38.go) 中的那条`for`语句会以很快的速度执行完毕。当它执行完毕时,那 10 个包装了`go`函数的 goroutine 往往还没有获得运行的机会。 请注意,`go`函数中的那个对`fmt.Println`函数的调用是以`for`语句中的变量`i`作为参数的。你可以想象一下,如果当`for`语句执行完毕的时候,这些`go`函数都还没有执行,那么它们引用的变量`i`的值将会是什么? 它们都会是`10`,对吗?那么这道题的答案会是“打印出 10 个`10`”,是这样吗? 在确定最终的答案之前,你还需要知道一个与主 goroutine 有关的重要特性,即:一旦主 goroutine 中的代码(也就是`main`函数中的那些代码)执行完毕,当前的 Go 程序就会结束运行。 如此一来,如果在 Go 程序结束的那一刻,还有 goroutine 未得到运行机会,那么它们就真的没有运行机会了,它们中的代码也就不会被执行了。 我们刚才谈论过,当`for`语句的最后一个迭代运行的时候,其中的那条`go`语句即是最后一条语句。所以,在执行完这条`go`语句之后,主 goroutine 中的代码也就执行完了,Go 程序会立即结束运行。那么,如果这样的话,还会有任何内容被打印出来吗? 严谨地讲,Go 语言并不会去保证这些 goroutine 会以怎样的顺序运行。由于主 goroutine 会与我们手动启用的其他 goroutine 一起接受调度,又因为调度器很可能会在 goroutine 中的代码只执行了一部分的时候暂停,以期所有的 goroutine 有更公平的运行机会。 所以哪个 goroutine 先执行完、哪个 goroutine 后执行完往往是不可预知的,除非我们使用了某种 Go 语言提供的方式进行了人为干预。然而,在这段代码中,我们并没有进行任何人为干预。 那答案到底是什么呢?就 [demo38.go](https://github.com/crazyjums/go_haolingeek/blob/master/article16/q1/demo38.go) 中如此简单的代码而言,绝大多数情况都会是“不会有任何内容被打印出来”。 但是为了严谨起见,无论应聘者的回答是“打印出 10 个`10`”还是“不会有任何内容被打印出来”,又或是“打印出乱序的`0`到`9`”,我都会紧接着去追问“为什么?”因为只有你知道了这背后的原理,你做出的回答才会被认为是正确的。 这个原理是如此的重要,以至于如果你不知道它,那么就几乎无法编写出正确的可并发执行的程序。如果你不知道此原理,那么即使你写的并发程序看起来可以正确地运行,那也肯定是运气好而已。 ### [](https://jums.gitbook.io/36-lectures-on-golang/16-go-yu-ju-ji-qi-zhi-hang-gui-ze-shang#zong-jie) 总结 今天,我描述了 goroutine 在操作系统的并发编程体系,以及在 Go 语言并发编程模型中的地位和作用。这些知识点会为你打下一个坚实的基础。 我还提到了 Go 语言内部的运行时系统和调度器,以及它们围绕着 goroutine 做的那些统筹调配和维护工作。这些内容中的每句话应该都会对你正确理解 goroutine 起到实质性的作用。你可以用这些知识去解释主问题中的那个程序在运行后为什么会产出那样的结果。 下一篇内容,我们还会继续围绕 go 语句以及执行规则谈一些扩展知识,今天留给你的思考题就是:用什么手段可以对 goroutine 的启用数量加以限制? 感谢你的收听,我们下次再见。 [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页15 | 关于指针的有限操作](https://jums.gitbook.io/36-lectures-on-golang/15-guan-yu-zhi-zhen-de-you-xian-cao-zuo) [下一页17 | go语句及其执行规则(下)](https://jums.gitbook.io/36-lectures-on-golang/17-go-yu-ju-ji-qi-zhi-hang-gui-ze-xia) 最后更新于2年前 这有帮助吗? --- # 18 | if语句、for语句和switch语句 | Go语言核心36讲 1 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252Ftxz9mcqAwQGj62tFhUHX%252Fimage.png%3Falt%3Dmedia%26token%3D6197c2c1-914d-4ad8-a7a1-4d0bf3cd6503&width=768&dpr=4&quality=100&sign=d8688b25&sv=2) [7MB\ \ 18.if语句、for语句和switch语句.mp3](https://2852705636-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FkmtyDvkosJZE1Rr33XGZ%2Fuploads%2FVhGyCvzTNsoviTjBEuB7%2F18.if%E8%AF%AD%E5%8F%A5%E3%80%81for%E8%AF%AD%E5%8F%A5%E5%92%8Cswitch%E8%AF%AD%E5%8F%A5.mp3?alt=media&token=8dcf5ebd-8582-40ce-8b3b-28e15955b4a0) 在上两篇文章中,我主要为你讲解了与`go`语句、goroutine 和 Go 语言调度器有关的知识和技法。 内容很多,你不用急于完全消化,可以在编程实践过程中逐步理解和感悟,争取夯实它们。 现在,让我们暂时走下神坛,回归民间。我今天要讲的`if`语句、`for`语句和`switch`语句都属于 Go 语言的基本流程控制语句。它们的语法看起来很朴素,但实际上也会有一些使用技巧和注意事项。我在本篇文章中会以一系列面试题为线索,为你讲述它们的用法。 那么,**今天的问题是:使用携带**`**range**`**子句的**`**for**`**语句时需要注意哪些细节?** 这是一个比较笼统的问题。我还是通过编程题来讲解吧。 本问题中的代码都被放在了命令源码文件 [demo41.go](https://github.com/crazyjums/go_haolingeek/blob/master/article17/q1/demo41.go) 的`main`函数中的。为了专注问题本身,本篇文章中展示的编程题会省略掉一部分代码包声明语句、代码包导入语句和`main`函数本身的声明部分。 复制 numbers1 := []int{1, 2, 3, 4, 5, 6} for i := range numbers1 { if i == 3 { numbers1[i] |= i } } fmt.Println(numbers1) 我先声明了一个元素类型为`int`的切片类型的变量`numbers1`,在该切片中有 6 个元素值,分别是从`1`到`6`的整数。我用一条携带`range`子句的`for`语句去迭代`numbers1`变量中的所有元素值。 在这条`for`语句中,只有一个迭代变量`i`。我在每次迭代时,都会先去判断`i`的值是否等于`3`,如果结果为`true`,那么就让`numbers1`的第`i`个元素值与`i`本身做按位或的操作,再把操作结果作为`numbers1`的新的第`i`个元素值。最后我会打印出`numbers1`的值。 所以具体的问题就是,这段代码执行后会打印出什么内容? 这里的**典型回答**是:打印的内容会是`[1 2 3 7 5 6]`。 ### [](https://jums.gitbook.io/36-lectures-on-golang/18-if-yu-ju-for-yu-ju-he-switch-yu-ju#wen-ti-jie-xi) 问题解析 你心算得到的答案是这样吗?让我们一起来复现一下这个计算过程。 当`for`语句被执行的时候,在`range`关键字右边的`numbers1`会先被求值。这个位置上的代码被称为`range`表达式。`range`表达式的结果值可以是数组、数组的指针、切片、字符串、字典或者允许接收操作的通道中的某一个,并且结果值只能有一个。 对于不同种类的`range`表达式结果值,`for`语句的迭代变量的数量可以有所不同。就拿我们这里的`numbers1`来说,它是一个切片,那么迭代变量就可以有两个,右边的迭代变量代表当次迭代对应的某一个元素值,而左边的迭代变量则代表该元素值在切片中的索引值。 那么,如果像本题代码中的`for`语句那样,只有一个迭代变量的情况意味着什么呢?这意味着,该迭代变量只会代表当次迭代对应的元素值的索引值。 更宽泛地讲,当只有一个迭代变量的时候,数组、数组的指针、切片和字符串的元素值都是无处安放的,我们只能拿到按照从小到大顺序给出的一个个索引值。 因此,这里的迭代变量`i`的值会依次是从`0`到`5`的整数。当`i`的值等于`3`的时候,与之对应的是切片中的第 4 个元素值`4`。对`4`和`3`进行按位或操作得到的结果是`7`。这就是答案中的第 4 个整数是`7`的原因了。 现在,我稍稍修改一下上面的代码。我们再来估算一下打印内容。 复制 numbers2 := [...]int{1, 2, 3, 4, 5, 6} maxIndex2 := len(numbers2) - 1 for i, e := range numbers2 { if i == maxIndex2 { numbers2[0] += e } else { numbers2[i+1] += e } } fmt.Println(numbers2) 注意,我把迭代的对象换成了`numbers2`。`numbers2`中的元素值同样是从`1`到`6`的 6 个整数,并且元素类型同样是`int`,但它是一个数组而不是一个切片。 在`for`语句中,我总是会对紧挨在当次迭代对应的元素后边的那个元素,进行重新赋值,新的值会是这两个元素的值之和。当迭代到最后一个元素时,我会把此`range`表达式结果值中的第一个元素值,替换为它的原值与最后一个元素值的和,最后,我会打印出`numbers2`的值。 对于这段代码,我的问题依旧是:打印的内容会是什么?你可以先思考一下。 好了,我要公布答案了。打印的内容会是`[7 3 5 7 9 11]`。我先来重现一下计算过程。当`for`语句被执行的时候,在`range`关键字右边的`numbers2`会先被求值。 这里需要注意两点: 1. `range`表达式只会在`for`语句开始执行时被求值一次,无论后边会有多少次迭代; 2. `range`表达式的求值结果会被复制,也就是说,被迭代的对象是`range`表达式结果值的副本而不是原值。 基于这两个规则,我们接着往下看。在第一次迭代时,我改变的是`numbers2`的第二个元素的值,新值为`3`,也就是`1`和`2`之和。 但是,被迭代的对象的第二个元素却没有任何改变,毕竟它与`numbers2`已经是毫不相关的两个数组了。因此,在第二次迭代时,我会把`numbers2`的第三个元素的值修改为`5`,即被迭代对象的第二个元素值`2`和第三个元素值`3`的和。 以此类推,之后的`numbers2`的元素值依次会是`7`、`9`和`11`。当迭代到最后一个元素时,我会把`numbers2`的第一个元素的值修改为`1`和`6`之和。 好了,现在该你操刀了。你需要把`numbers2`的值由一个数组改成一个切片,其中的元素值都不要变。为了避免混淆,你还要把这个切片值赋给变量`numbers3`,并且把后边代码中所有的`numbers2`都改为`numbers3`。 问题是不变的,执行这段修改版的代码后打印的内容会是什么呢?如果你实在估算不出来,可以先实际执行一下,然后再尝试解释看到的答案。提示一下,切片与数组是不同的,前者是引用类型的,而后者是值类型的。 我们可以先接着讨论后边的内容,但是我强烈建议你一定要回来,再看看我留给你的这个问题,认真地思考和计算一下。 **知识扩展** **问题 1:**`**switch**`**语句中的**`**switch**`**表达式和**`**case**`**表达式之间有着怎样的联系?** 先来看一段代码。 复制 value1 := [...]int8{0, 1, 2, 3, 4, 5, 6} switch 1 + 3 { case value1[0], value1[1]: fmt.Println("0 or 1") case value1[2], value1[3]: fmt.Println("2 or 3") case value1[4], value1[5], value1[6]: fmt.Println("4 or 5 or 6") } 我先声明了一个数组类型的变量`value1`,该变量的元素类型是`int8`。在后边的`switch`语句中,被夹在`switch`关键字和左花括号`{`之间的是`1 + 3`,这个位置上的代码被称为`switch`表达式。这个`switch`语句还包含了三个`case`子句,而每个`case`子句又各包含了一个`case`表达式和一条打印语句。 所谓的`case`表达式一般由`case`关键字和一个表达式列表组成,表达式列表中的多个表达式之间需要有英文逗号`,`分割,比如,上面代码中的`case value1[0], value1[1]`就是一个`case`表达式,其中的两个子表达式都是由索引表达式表示的。 另外的两个`case`表达式分别是`case value1[2], value1[3]`和`case value1[4], value1[5], value1[6]`。 此外,在这里的每个`case`子句中的那些打印语句,会分别打印出不同的内容,这些内容用于表示`case`子句被选中的原因,比如,打印内容`0 or 1`表示当前`case`子句被选中是因为`switch`表达式的结果值等于`0`或`1`中的某一个。另外两条打印语句会分别打印出`2 or 3`和`4 or 5 or 6`。 现在问题来了,拥有这样三个`case`表达式的`switch`语句可以成功通过编译吗?如果不可以,原因是什么?如果可以,那么该`switch`语句被执行后会打印出什么内容。 我刚才说过,只要`switch`表达式的结果值与某个`case`表达式中的任意一个子表达式的结果值相等,该`case`表达式所属的`case`子句就会被选中。 并且,一旦某个`case`子句被选中,其中的附带在`case`表达式后边的那些语句就会被执行。与此同时,其他的所有`case`子句都会被忽略。 当然了,如果被选中的`case`子句附带的语句列表中包含了`fallthrough`语句,那么紧挨在它下边的那个`case`子句附带的语句也会被执行。 正因为存在上述判断相等的操作(以下简称判等操作),`switch`语句对`switch`表达式的结果类型,以及各个`case`表达式中子表达式的结果类型都是有要求的。毕竟,在 Go 语言中,只有类型相同的值之间才有可能被允许进行判等操作。 如果`switch`表达式的结果值是无类型的常量,比如`1 + 3`的求值结果就是无类型的常量`4`,那么这个常量会被自动地转换为此种常量的默认类型的值,比如整数`4`的默认类型是`int`,又比如浮点数`3.14`的默认类型是`float64`。 因此,由于上述代码中的`switch`表达式的结果类型是`int`,而那些`case`表达式中子表达式的结果类型却是`int8`,它们的类型并不相同,所以这条`switch`语句是无法通过编译的。 再来看一段很类似的代码: 复制 value2 := [...]int8{0, 1, 2, 3, 4, 5, 6} switch value2[4] { case 0, 1: fmt.Println("0 or 1") case 2, 3: fmt.Println("2 or 3") case 4, 5, 6: fmt.Println("4 or 5 or 6") } 其中的变量`value2`与`value1`的值是完全相同的。但不同的是,我把`switch`表达式换成了`value2[4]`,并把下边那三个`case`表达式分别换为了`case 0, 1`、`case 2, 3`和`case 4, 5, 6`。 如此一来,`switch`表达式的结果值是`int8`类型的,而那些`case`表达式中子表达式的结果值却是无类型的常量了。这与之前的情况恰恰相反。那么,这样的`switch`语句可以通过编译吗? 答案是肯定的。因为,如果`case`表达式中子表达式的结果值是无类型的常量,那么它的类型会被自动地转换为`switch`表达式的结果类型,又由于上述那几个整数都可以被转换为`int8`类型的值,所以对这些表达式的结果值进行判等操作是没有问题的。 当然了,如果这里说的自动转换没能成功,那么`switch`语句照样通不过编译。 通过上面这两道题,你应该可以搞清楚`switch`表达式和`case`表达式之间的联系了。由于需要进行判等操作,所以前者和后者中的子表达式的结果类型需要相同。 `switch`语句会进行有限的类型转换,但肯定不能保证这种转换可以统一它们的类型。还要注意,如果这些表达式的结果类型有某个接口类型,那么一定要小心检查它们的动态值是否都具有可比性(或者说是否允许判等操作)。因为,如果答案是否定的,虽然不会造成编译错误,但是后果会更加严重:引发 panic(也就是运行时恐慌)。 **问题 2:**`**switch**`**语句对它的**`**case**`**表达式有哪些约束?** 我在上一个问题的阐述中还重点表达了一点,不知你注意到了没有,那就是:`switch`语句在`case`子句的选择上是具有唯一性的。正因为如此,`switch`语句不允许`case`表达式中的子表达式结果值存在相等的情况,不论这些结果值相等的子表达式,是否存在于不同的`case`表达式中,都会是这样的结果。具体请看这段代码: 复制 value3 := [...]int8{0, 1, 2, 3, 4, 5, 6} switch value3[4] { case 0, 1, 2: fmt.Println("0 or 1 or 2") case 2, 3, 4: fmt.Println("2 or 3 or 4") case 4, 5, 6: fmt.Println("4 or 5 or 6") } 变量`value3`的值同`value1`,依然是由从`0`到`6`的 7 个整数组成的数组,元素类型是`int8`。`switch`表达式是`value3[4]`,三个`case`表达式分别是`case 0, 1, 2`、`case 2, 3, 4`和`case 4, 5, 6`。 由于在这三个`case`表达式中存在结果值相等的子表达式,所以这个`switch`语句无法通过编译。不过,好在这个约束本身还有个约束,那就是只针对结果值为常量的子表达式。 比如,子表达式`1+1`和`2`不能同时出现,`1+3`和`4`也不能同时出现。有了这个约束的约束,我们就可以想办法绕过这个对子表达式的限制了。再看一段代码: 复制 value5 := [...]int8{0, 1, 2, 3, 4, 5, 6} switch value5[4] { case value5[0], value5[1], value5[2]: fmt.Println("0 or 1 or 2") case value5[2], value5[3], value5[4]: fmt.Println("2 or 3 or 4") case value5[4], value5[5], value5[6]: fmt.Println("4 or 5 or26") } 变量名换成了`value5`,但这不是重点。重点是,我把`case`表达式中的常量都换成了诸如`value5[0]`这样的索引表达式。 虽然第一个`case`表达式和第二个`case`表达式都包含了`value5[2]`,并且第二个`case`表达式和第三个`case`表达式都包含了`value5[4]`,但这已经不是问题了。这条`switch`语句可以成功通过编译。 不过,这种绕过方式对用于类型判断的`switch`语句(以下简称为类型`switch`语句)就无效了。因为类型`switch`语句中的`case`表达式的子表达式,都必须直接由类型字面量表示,而无法通过间接的方式表示。代码如下: 复制 value6 := interface{}(byte(127)) switch t := value6.(type) { case uint8, uint16: fmt.Println("uint8 or uint16") case byte: fmt.Printf("byte") default: fmt.Printf("unsupported type: %T", t) } 变量`value6`的值是空接口类型的。该值包装了一个`byte`类型的值`127`。我在后面使用类型`switch`语句来判断`value6`的实际类型,并打印相应的内容。 这里有两个普通的`case`子句,还有一个`default case`子句。前者的`case`表达式分别是`case uint8, uint16`和`case byte`。你还记得吗?`byte`类型是`uint8`类型的别名类型。 因此,它们两个本质上是同一个类型,只是类型名称不同罢了。在这种情况下,这个类型`switch`语句是无法通过编译的,因为子表达式`byte`和`uint8`重复了。好了,以上说的就是`case`表达式的约束以及绕过方式,你学会了吗。 **总结** 我们今天主要讨论了`for`语句和`switch`语句,不过我并没有说明那些语法规则,因为它们太简单了。我们需要多加注意的往往是那些隐藏在 Go 语言规范和最佳实践里的细节。 这些细节其实就是我们很多技术初学者所谓的“坑”。比如,我在讲`for`语句的时候交代了携带`range`子句时只有一个迭代变量意味着什么。你必须知道在迭代数组或切片时只有一个迭代变量的话是无法迭代出其中的元素值的,否则你的程序可能就不会像你预期的那样运行了。 还有,`range`表达式的结果值是会被复制的,实际迭代时并不会使用原值。至于会影响到什么,那就要看这个结果值的类型是值类型还是引用类型了。 说到`switch`语句,你要明白其中的`case`表达式的所有子表达式的结果值都是要与`switch`表达式的结果值判等的,因此它们的类型必须相同或者能够都统一到`switch`表达式的结果类型。如果无法做到,那么这条`switch`语句就不能通过编译。 最后,同一条`switch`语句中的所有`case`表达式的子表达式的结果值不能重复,不过好在这只是对于由字面量直接表示的子表达式而言的。 请记住,普通`case`子句的编写顺序很重要,最上边的`case`子句中的子表达式总是会被最先求值,在判等的时候顺序也是这样。因此,如果某些子表达式的结果值有重复并且它们与`switch`表达式的结果值相等,那么位置靠上的`case`子句总会被选中。 **思考题** 1. 在类型`switch`语句中,我们怎样对被判断类型的那个值做相应的类型转换? 2. 在`if`语句中,初始化子句声明的变量的作用域是什么? [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页17 | go语句及其执行规则(下)](https://jums.gitbook.io/36-lectures-on-golang/17-go-yu-ju-ji-qi-zhi-hang-gui-ze-xia) [下一页19 | 错误处理(上)](https://jums.gitbook.io/36-lectures-on-golang/19-cuo-wu-chu-li-shang) 最后更新于2年前 这有帮助吗? --- # 39 | bytes包与字节串操作(下) | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252FnsTyw8eFrl4MAAHfrXWk%252Fimage.png%3Falt%3Dmedia%26token%3D7541ba1e-a7cb-450d-b233-e2cec8e1ff8d&width=768&dpr=4&quality=100&sign=a9608f4c&sv=2) 你好,我是郝林,今天我们继续分享 bytes 包与字节串操作的相关内容。 在上一篇文章中,我们分享了`bytes.Buffer`中已读计数的大致功用,并围绕着这个问题做了解析,下面我们来进行相关的知识扩展。 ### [](https://jums.gitbook.io/36-lectures-on-golang/39-bytes-bao-yu-zi-jie-chuan-cao-zuo-xia#zhi-shi-kuo-zhan) 知识扩展 #### [](https://jums.gitbook.io/36-lectures-on-golang/39-bytes-bao-yu-zi-jie-chuan-cao-zuo-xia#wen-ti-1bytes.buffer-de-kuo-rong-ce-le-shi-zen-yang-de) 问题 1:`bytes.Buffer`的扩容策略是怎样的? `Buffer`值既可以被手动扩容,也可以进行自动扩容。并且,这两种扩容方式的策略是基本一致的。所以,除非我们完全确定后续内容所需的字节数,否则让`Buffer`值自动去扩容就好了。 在扩容的时候,`Buffer`值中相应的代码(以下简称扩容代码)会先判断内容容器的剩余容量,是否可以满足调用方的要求,或者是否足够容纳新的内容。 如果可以,那么扩容代码会在当前的内容容器之上,进行长度扩充。更具体地说,如果内容容器的容量与其长度的差,大于或等于另需的字节数,那么扩容代码就会通过切片操作对原有的内容容器的长度进行扩充,就像下面这样: 复制 b.buf = b.buf[:length+need] 反之,如果内容容器的剩余容量不够了,那么扩容代码可能就会用新的内容容器去替代原有的内容容器,从而实现扩容。不过,这里还一步优化。 如果当前内容容器的容量的一半仍然大于或等于其现有长度再加上另需的字节数的和,即: 复制 cap(b.buf)/2 >= len(b.buf)+need 那么,扩容代码就会复用现有的内容容器,并把容器中的未读内容拷贝到它的头部位置。这也意味着其中的已读内容,将会全部被未读内容和之后的新内容覆盖掉。 这样的复用预计可以至少节省掉一次后续的扩容所带来的内存分配,以及若干字节的拷贝。 若这一步优化未能达成,也就是说,当前内容容器的容量小于新长度的二倍,那么扩容代码就只能再创建一个新的内容容器,并把原有容器中的未读内容拷贝进去,最后再用新的容器替换掉原有的容器。这个新容器的容量将会等于原有容量的二倍再加上另需字节数的和。 > 新容器的容量 =2\* 原有容量 + 所需字节数 通过上面这些步骤,对内容容器的扩充基本上就完成了。不过,为了内部数据的一致性,以及避免原有的已读内容可能造成的数据混乱,扩容代码还会把已读计数置为`0`,并再对内容容器做一下切片操作,以掩盖掉原有的已读内容。 顺便说一下,对于处在零值状态的`Buffer`值来说,如果第一次扩容时的另需字节数不大于`64`,那么该值就会基于一个预先定义好的、长度为`64`的字节数组来创建内容容器。 在这种情况下,这个内容容器的容量就是`64`。这样做的目的是为了让`Buffer`值在刚被真正使用的时候就可以快速地做好准备。 #### [](https://jums.gitbook.io/36-lectures-on-golang/39-bytes-bao-yu-zi-jie-chuan-cao-zuo-xia#wen-ti-2bytes.buffer-zhong-de-na-xie-fang-fa-ke-neng-hui-zao-cheng-nei-rong-de-xie-lou) 问题 2:`bytes.Buffer`中的哪些方法可能会造成内容的泄露? 首先明确一点,什么叫内容泄露?这里所说的内容泄露是指,使用`Buffer`值的一方通过某种非标准的(或者说不正式的)方式得到了本不该得到的内容。 比如说,我通过调用`Buffer`值的某个用于读取内容的方法,得到了一部分未读内容。我应该,也只应该通过这个方法的结果值,拿到在那一时刻`Buffer`值中的未读内容。 但是,在这个`Buffer`值又有了一些新内容之后,我却可以通过当时得到的结果值,直接获得新的内容,而不需要再次调用相应的方法。 这就是典型的非标准读取方式。这种读取方式是不应该存在的,即使存在,我们也不应该使用。因为它是在无意中(或者说一不小心)暴露出来的,其行为很可能是不稳定的。 在`bytes.Buffer`中,`Bytes`方法和`Next`方法都可能会造成内容的泄露。原因在于,它们都把基于内容容器的切片直接返回给了方法的调用方。 我们都知道,通过切片,我们可以直接访问和操纵它的底层数组。不论这个切片是基于某个数组得来的,还是通过对另一个切片做切片操作获得的,都是如此。 在这里,`Bytes`方法和`Next`方法返回的字节切片,都是通过对内容容器做切片操作得到的。也就是说,它们与内容容器共用了同一个底层数组,起码在一段时期之内是这样的。 以`Bytes`方法为例。它会返回在调用那一刻其所属值中的所有未读内容。示例代码如下: 复制 contents := "ab" buffer1 := bytes.NewBufferString(contents) fmt.Printf("The capacity of new buffer with contents %q: %d\n", contents, buffer1.Cap()) // 内容容器的容量为:8。 unreadBytes := buffer1.Bytes() fmt.Printf("The unread bytes of the buffer: %v\n", unreadBytes) // 未读内容为:[97 98]。 我用字符串值`"ab"`初始化了一个`Buffer`值,由变量`buffer1`代表,并打印了当时该值的一些状态。你可能会有疑惑,我只在这个`Buffer`值中放入了一个长度为`2`的字符串值,但为什么该值的容量却变为了`8`。 虽然这与我们当前的主题无关,但是我可以提示你一下:你可以去阅读`runtime`包中一个名叫`stringtoslicebyte`的函数,答案就在其中。 接着说`buffer1`。我又向该值写入了字符串值`"cdefg"`,此时,其容量仍然是`8`。我在前面通过调用`buffer1`的`Bytes`方法得到的结果值`unreadBytes`,包含了在那时其中的所有未读内容。 但是,由于这个结果值与`buffer1`的内容容器在此时还共用着同一个底层数组,所以,我只需通过简单的再切片操作,就可以利用这个结果值拿到`buffer1`在此时的所有未读内容。如此一来,`buffer1`的新内容就被泄露出来了。 复制 buffer1.WriteString("cdefg") fmt.Printf("The capacity of buffer: %d\n", buffer1.Cap()) // 内容容器的容量仍为:8。 unreadBytes = unreadBytes[:cap(unreadBytes)] fmt.Printf("The unread bytes of the buffer: %v\n", unreadBytes) // 基于前面获取到的结果值可得,未读内容为:[97 98 99 100 101 102 103 0]。 如果我当时把`unreadBytes`的值传到了外界,那么外界就可以通过该值操纵`buffer1`的内容了,就像下面这样: 复制 unreadBytes[len(unreadBytes)-2] = byte('X') // 'X'的 ASCII 编码为 88。 fmt.Printf("The unread bytes of the buffer: %v\n", buffer1.Bytes()) // 未读内容变为了:[97 98 99 100 101 102 88]。 现在,你应该能够体会到,这里的内容泄露可能造成的严重后果了吧?对于`Buffer`值的`Next`方法,也存在相同的问题。 不过,如果经过扩容,`Buffer`值的内容容器或者它的底层数组被重新设定了,那么之前的内容泄露问题就无法再进一步发展了。我在 demo80.go 文件中写了一个比较完整的示例,你可以去看一看,并揣摩一下。 ### [](https://jums.gitbook.io/36-lectures-on-golang/39-bytes-bao-yu-zi-jie-chuan-cao-zuo-xia#zong-jie) 总结 我们结合两篇内容总结一下。与`strings.Builder`类型不同,`bytes.Buffer`不但可以拼接、截断其中的字节序列,以各种形式导出其中的内容,还可以顺序地读取其中的子序列。`bytes.Buffer`类型使用字节切片作为其内容容器,并且会用一个字段实时地记录已读字节的计数。 虽然我们无法直接计算出这个已读计数,但是由于它在`Buffer`值中起到的作用非常关键,所以我们很有必要去理解它。无论是读取、写入、截断、导出还是重置,已读计数都是功能实现中的重要一环。 与`strings.Builder`类型的值一样,`Buffer`值既可以被手动扩容,也可以进行自动的扩容。除非我们完全确定后续内容所需的字节数,否则让`Buffer`值自动去扩容就好了。 `Buffer`值的扩容方法并不一定会为了获得更大的容量,替换掉现有的内容容器,而是先会本着尽量减少内存分配和内容拷贝的原则,对当前的内容容器进行重用。并且,只有在容量实在无法满足要求的时候,它才会去创建新的内容容器。 此外,你可能并没有想到,`Buffer`值的某些方法可能会造成内容的泄露。这主要是由于这些方法返回的结果值,在一段时期内会与其所属值的内容容器共用同一个底层数组。 **如果我们有意或无意地把这些结果值传到了外界,那么外界就有可能通过它们操纵相关联**`**Buffer**`**值的内容。** 这属于很严重的数据安全问题。我们一定要避免这种情况的发生。最彻底的做法是,在传出切片这类值之前要做好隔离。比如,先对它们进行深度拷贝,然后再把副本传出去。 ### [](https://jums.gitbook.io/36-lectures-on-golang/39-bytes-bao-yu-zi-jie-chuan-cao-zuo-xia#si-kao-ti) 思考题 今天的思考题是:对比`strings.Builder`和`bytes.Buffer`的`String`方法,并判断哪一个更高效?原因是什么? [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页38 | bytes包与字节串操作(上)](https://jums.gitbook.io/36-lectures-on-golang/38-bytes-bao-yu-zi-jie-chuan-cao-zuo-shang) [下一页40 | io包中的接口和工具 (上)](https://jums.gitbook.io/36-lectures-on-golang/40-io-bao-zhong-de-jie-kou-he-gong-ju-shang) 最后更新于2年前 这有帮助吗? --- # 46 | 访问网络服务 | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252FEHX02lHEhk5jdJ9Mc5WD%252Fimage.png%3Falt%3Dmedia%26token%3D565692d9-7187-4b7d-a59e-71a8e2557b75&width=768&dpr=4&quality=100&sign=96612d3d&sv=2) 你真的很棒,已经跟着我一起从最开始初识 Go 语言,一步一步地走到了这里。 在这之前的几十篇文章中,我向你一点一点地介绍了很多 Go 语言的核心知识,以及一些最最基础的标准库代码包。我想,你已经完全有能力独立去做一些事情了。 为了激发你更多的兴趣,我还打算用几篇文章来说说 Go 语言的网络编程。不过,关于网络编程这个事情,恐怕早已庞大到用一两本专著都无法对它进行完整论述的地步了。 所以,我在这里说的东西只能算是个引子。只要这样能让你产生想去尝试的冲动,我就很开心了。 人们常常会使用 Go 语言去编写网络程序(当然了,这方面也是 Go 语言最为擅长的事情)。说到网络编程,我们就不得不提及 socket。 socket,常被翻译为套接字,它应该算是网络编程世界中最为核心的知识之一了。关于 socket,我们可以讨论的东西太多了,因此,我在这里只围绕着 Go 语言向你介绍一些关于它的基础知识。 所谓 socket,是一种 IPC 方法。IPC 是 Inter-Process Communication 的缩写,可以被翻译为进程间通信。顾名思义,IPC 这个概念(或者说规范)主要定义的是多个进程之间,相互通信的方法。 这些方法主要包括:系统信号(signal)、管道(pipe)、套接字 (socket)、文件锁(file lock)、消息队列(message queue)、信号灯(semaphore,有的地方也称之为信号量)等。现存的主流操作系统大都对 IPC 提供了强有力的支持,尤其是 socket。 你可能已经知道,Go 语言对 IPC 也提供了一定的支持。比如,在`os`代码包和`os/signal`代码包中就有针对系统信号的 API。 又比如,`os.Pipe`函数可以创建命名管道,而`os/exec`代码包则对另一类管道(匿名管道)提供了支持。对于 socket,Go 语言与之相应的程序实体都在其标准库的`net`代码包中。 毫不夸张地说,在众多的 IPC 方法中,socket 是最为通用和灵活的一种。与其他的 IPC 方法不同,利用 socket 进行通信的进程,可以不局限在同一台计算机当中。实际上,通信的双方无论存在于世界上的哪个角落,只要能够通过计算机的网卡端口以及网络进行互联,就可以使用 socket。 支持 socket 的操作系统一般都会对外提供一套 API。跑在它们之上的应用程序,利用这套 API 就可以与互联网上的另一台计算机中的程序、同一台计算机中的其他程序,甚至同一个程序中的其他线程进行通信。 例如,在 Linux 操作系统中,用于创建 socket 实例的 API,就是由一个名为`socket`的系统调用代表的。这个系统调用是 Linux 内核的一部分。 所谓的系统调用,你可以理解为特殊的 C 语言函数。它们是连接应用程序和操作系统内核的桥梁,也是应用程序使用操作系统功能的唯一渠道。 在 Go 语言标准库的`syscall`代码包中,有一个与这个`socket`系统调用相对应的函数。这两者的函数签名是基本一致的,它们都会接受三个`int`类型的参数,并会返回一个可以代表文件描述符的结果。 但不同的是,`syscall`包中的`Socket`函数本身是平台不相关的。在其底层,Go 语言为它支持的每个操作系统都做了适配,这才使得这个函数无论在哪个平台上,总是有效的。 Go 语言的`net`代码包中的很多程序实体,都会直接或间接地使用到`syscall.Socket`函数。比如,我们在调用`net.Dial`函数的时候,会为它的两个参数设定值。其中的第一个参数名为`network`,它决定着 Go 程序在底层会创建什么样的 socket 实例,并使用什么样的协议与其他程序通信。 下面,我们就通过一个简单的问题来看看怎样正确地调用`net.Dial`函数。 **今天的问题是:**`**net.Dial**`**函数的第一个参数**`**network**`**有哪些可选值?** 这道题的**典型回答**是这样的。 `net.Dial`函数会接受两个参数,分别名为`network`和`address`,都是`string`类型的。 参数`network`常用的可选值一共有 9 个。这些值分别代表了程序底层创建的 socket 实例可使用的不同通信协议,罗列如下。 * `"tcp"`:代表 TCP 协议,其基于的 IP 协议的版本根据参数`address`的值自适应。 * `"tcp4"`:代表基于 IP 协议第四版的 TCP 协议。 * `"tcp6"`:代表基于 IP 协议第六版的 TCP 协议。 * `"udp"`:代表 UDP 协议,其基于的 IP 协议的版本根据参数`address`的值自适应。 * `"udp4"`:代表基于 IP 协议第四版的 UDP 协议。 * `"udp6"`:代表基于 IP 协议第六版的 UDP 协议。 * `"unix"`:代表 Unix 通信域下的一种内部 socket 协议,以 SOCK\_STREAM 为 socket 类型。 * `"unixgram"`:代表 Unix 通信域下的一种内部 socket 协议,以 SOCK\_DGRAM 为 socket 类型。 * `"unixpacket"`:代表 Unix 通信域下的一种内部 socket 协议,以 SOCK\_SEQPACKET 为 socket 类型。 ### [](https://jums.gitbook.io/36-lectures-on-golang/46-fang-wen-wang-luo-fu-wu#wen-ti-jie-xi) 问题解析 为了更好地理解这些可选值的深层含义,我们需要了解一下`syscall.Socket`函数接受的那三个参数。 我在前面说了,这个函数接受的三个参数都是`int`类型的。这些参数所代表的分别是想要创建的 socket 实例通信域、类型以及使用的协议。 Socket 的通信域主要有这样几个可选项:IPv4 域、IPv6 域和 Unix 域。我想你应该能够猜出前两者的含义,它们对应的分别是基于 IP 协议第四版的网络,和基于 IP 协议第六版的网络。 现在的计算机网络大都是基于 IP 协议第四版的,但是由于现有 IP 地址的逐渐枯竭,网络世界也在逐步地支持 IP 协议第六版。 Unix 域,指的是一种类 Unix 操作系统中特有的通信域。在装有此类操作系统的同一台计算机中,应用程序可以基于此域建立 socket 连接。 以上三种通信域分别可以由`syscall`代码包中的常量`AF_INET`、`AF_INET6`和`AF_UNIX`表示。 Socket 的类型一共有 4 种,分别是:`SOCK_DGRAM`、`SOCK_STREAM`、`SOCK_SEQPACKET`以及`SOCK_RAW`。`syscall`代码包中也都有同名的常量与之对应。前两者更加常用一些。 `SOCK_DGRAM`中的“DGRAM”代表的是 datagram,即数据报文。它是一种有数消息边界但没有逻辑连接的非可靠 socket 类型,我们熟知的基于 UDP 协议的网络通信就属于此类。 有消息边界的意思是,与 socket 相关的操作系统内核中的程序(以下简称内核程序)在发送或接收数据的时候是以消息为单位的。你可以把消息理解为带有固定边界的一段数据。内核程序可以自动地识别和维护这种边界,并在必要的时候,把数据切割成一个一个的消息,或者把多个消息串接成连续的数据。如此一来,应用程序只需要面向消息进行处理就可以了。 所谓的有逻辑连接是指,通信双方在收发数据之前必须先建立网络连接。待连接建立好之后,双方就可以一对一地进行数据传输了。显然,基于 UDP 协议的网络通信并不需要这样,它是没有逻辑连接的。 只要应用程序指定好对方的网络地址,内核程序就可以立即把数据报文发送出去。这有优势,也有劣势。 优势是发送速度快,不长期占用网络资源,并且每次发送都可以指定不同的网络地址。当然了,最后一个优势有时候也是劣势,因为这会使数据报文更长一些。其他的劣势有,无法保证传输的可靠性,不能实现数据的有序性,以及数据只能单向进行传输。 而`SOCK_STREAM`这个 socket 类型,恰恰与`SOCK_DGRAM`相反。它没有消息边界,但有逻辑连接,能够保证传输的可靠性和数据的有序性,同时还可以实现数据的双向传输。众所周知的基于 TCP 协议的网络通信就属于此类。 这样的网络通信传输数据的形式是字节流,而不是数据报文。字节流是以字节为单位的。内核程序无法感知一段字节流中包含了多少个消息,以及这些消息是否完整,这完全需要应用程序自己去把控。 不过,此类网络通信中的一端,总是会忠实地按照另一端发送数据时的字节排列顺序,接收和缓存它们。所以,应用程序需要根据双方的约定去数据中查找消息边界,并按照边界切割数据,仅此而已。 `syscall.Socket`函数的第三个参数用于表示 socket 实例所使用的协议。通常,只要明确指定了前两个参数的值,我们就无需再去确定第三个参数值了,一般把它置为`0`就可以了。这时,内核程序会自行选择最合适的协议。 比如,当前两个参数值分别为`syscall.AF_INET`和`syscall.SOCK_DGRAM`的时候,内核程序会选择 UDP 作为协议。又比如,在前两个参数值分别为`syscall.AF_INET6`和`syscall.SOCK_STREAM`时,内核程序可能会选择 TCP 作为协议。 不过,你也看到了,在使用`net`包中的高层次 API 的时候,我们连那前两个参数值都无需给定,只需要把前面罗列的那些字符串字面量的其中一个,作为`network`参数的值就好了。当然,如果你在使用这些 API 的时候,能够想到我在上面说的这些基础知识的话,那么一定会对你做出正确的判断和选择有所帮助。 ### [](https://jums.gitbook.io/36-lectures-on-golang/46-fang-wen-wang-luo-fu-wu#zhi-shi-kuo-zhan) 知识扩展 #### [](https://jums.gitbook.io/36-lectures-on-golang/46-fang-wen-wang-luo-fu-wu#wen-ti-1-diao-yong-net.dialtimeout-han-shu-shi-gei-ding-de-chao-shi-shi-jian-yi-wei-zhe-shen-me) 问题 1:调用`net.DialTimeout`函数时给定的超时时间意味着什么? 简单来说,这里的超时时间,代表着函数为网络连接建立完成而等待的最长时间。这是一个相对的时间。它会由这个函数的参数`timeout`的值表示。 开始的时间点几乎是我们调用`net.DialTimeout`函数的那一刻。在这之后,时间会主要花费在“解析参数`network`和`address`的值”,以及“创建 socket 实例并建立网络连接”这两件事情上。 不论执行到哪一步,只要在绝对的超时时间达到的那一刻,网络连接还没有建立完成,该函数就会返回一个代表了 I/O 操作超时的错误值。 值得注意的是,在解析`address`的值的时候,函数会确定网络服务的 IP 地址、端口号等必要信息,并在需要时访问 DNS 服务。另外,如果解析出的 IP 地址有多个,那么函数会串行或并发地尝试建立连接。但无论用什么样的方式尝试,函数总会以最先建立成功的那个连接为准。同时,它还会根据超时前的剩余时间去设定针对每次连接尝试的超时时间,以便让它们都有适当的时间执行。 再多说一点。在`net`包中还有一个名为`Dialer`的结构体类型。该类型有一个名叫`Timeout`的字段,它与上述的`timeout`参数的含义是完全一致的。实际上,`net.DialTimeout`函数正是利用了这个类型的值才得以实现功能的。 `net.Dialer`类型值得你好好学习一下,尤其是它的每个字段的功用以及它的`DialContext`方法。 ### [](https://jums.gitbook.io/36-lectures-on-golang/46-fang-wen-wang-luo-fu-wu#zong-jie) 总结 我们今天提及了使用 Go 语言进行网络编程这个主题。作为引子,我先向你介绍了关于 socket 的一些基础知识。socket 常被翻译为套接字,它是一种 IPC 方法。IPC 可以被翻译为进程间通信,它主要定义了多个进程之间相互通信的方法。 Socket 是 IPC 方法中最为通用和灵活的一种。与其他的方法不同,利用 socket 进行通信的进程可以不局限在同一台计算机当中。只要通信的双方能够通过计算机的网卡端口,以及网络进行互联就可以使用 socket,无论它们存在于世界上的哪个角落。 支持 socket 的操作系统一般都会对外提供一套 API。Go 语言的`syscall`代码包中也有与之对应的程序实体。其中最重要的一个就是`syscall.Socket`函数。不过,`syscall`包中的这些程序实体,对于普通的 Go 程序来说都属于底层的东西了,我们通常很少会用到。一般情况下,我们都会使用`net`代码包及其子包中的 API 去编写网络程序。 `net`包中一个很常用的函数,名为`Dial`。这个函数主要用于连接网络服务。它会接受两个参数,你需要搞明白这两个参数的值都应该怎么去设定。 尤其是`network`参数,它有很多的可选值,其中最常用的有 9 个。这些可选值的背后都代表着相应的 socket 属性,包括通信域、类型以及使用的协议。一旦你理解了这些 socket 属性,就一定会帮助你做出正确的判断和选择。 与此相关的一个函数是`net.DialTimeout`。我们在调用它的时候需要设定一个超时时间。这个超时时间的含义你是需要搞清楚的。 通过它,我们可以牵扯出这个函数的一大堆实现细节。另外,还有一个叫做`net.Dialer`的结构体类型。这个类型其实是前述两个函数的底层实现,值得你好好地学习一番。 以上,就是我今天讲的主要内容,它们都是关于怎样访问网络服务的。你可以从这里入手,进入 Go 语言的网络编程世界。 ### [](https://jums.gitbook.io/36-lectures-on-golang/46-fang-wen-wang-luo-fu-wu#si-kao-ti) 思考题 今天的思考题也与超时时间有关。在你调用了`net.Dial`等函数之后,如果成功就会得到一个代表了网络连接的`net.Conn`接口类型的值。我的问题是:怎样在`net.Conn`类型的值上正确地设定针对读操作和写操作的超时时间? [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页45 | 使用os包中的API (下)](https://jums.gitbook.io/36-lectures-on-golang/45-shi-yong-os-bao-zhong-de-api-xia) [下一页47 | 基于HTTP协议的网络服务](https://jums.gitbook.io/36-lectures-on-golang/47-ji-yu-http-xie-yi-de-wang-luo-fu-wu) 最后更新于2年前 这有帮助吗? --- # 44 | 使用os包中的API (上) | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252Fw1KZbMLPp3OdaeRaUd40%252Fimage.png%3Falt%3Dmedia%26token%3Dac10de47-136e-4625-9559-ebe5139a79c0&width=768&dpr=4&quality=100&sign=79d60fe8&sv=2) 我们今天要讲的是`os`代码包中的 API。这个代码包可以让我们拥有操控计算机操作系统的能力。 这个代码包提供的都是平台不相关的 API。那么说,什么叫平台不相关的 API 呢?它的意思是,这些 API 基于(或者说抽象自)操作系统,为我们使用操作系统的功能提供高层次的支持,但是,它们并不依赖于具体的操作系统。 不论是 Linux、macOS、Windows,还是 FreeBSD、OpenBSD、Plan9,`os`代码包都可以为之提供统一的使用接口。这使得我们可以用同样的方式,来操纵不同的操作系统,并得到相似的结果。 `os`包中的 API 主要可以帮助我们使用操作系统中的文件系统、权限系统、环境变量、系统进程以及系统信号。其中,操纵文件系统的 API 最为丰富。我们不但可以利用这些 API 创建和删除文件以及目录,还可以获取到它们的各种信息、修改它们的内容、改变它们的访问权限,等等。 说到这里,就不得不提及一个非常常用的数据类型:`os.File`。 从字面上来看,`os.File`类型代表了操作系统中的文件。但实际上,它可以代表的远不止于此。或许你已经知道,对于类 Unix 的操作系统(包括 Linux、macOS、FreeBSD 等),其中的一切都可以被看做是文件。 除了文本文件、二进制文件、压缩文件、目录这些常见的形式之外,还有符号链接、各种物理设备(包括内置或外接的面向块或者字符的设备)、命名管道,以及套接字(也就是 socket),等等。 因此,可以说,我们能够利用`os.File`类型操纵的东西太多了。不过,为了聚焦于`os.File`本身,同时也为了让本文讲述的内容更加通用,我们在这里主要把`os.File`类型应用于常规的文件。 下面这个问题,就是以`os.File`类型代表的最基本内容入手。**我们今天的问题是:**`**os.File**`**类型都实现了哪些**`**io**`**包中的接口?** 这道题的**典型回答**是这样的。 `os.File`类型拥有的都是指针方法,所以除了空接口之外,它本身没有实现任何接口。而它的指针类型则实现了很多`io`代码包中的接口。 首先,对于`io`包中最核心的 3 个简单接口`io.Reader`、`io.Writer`和`io.Closer`,`*os.File`类型都实现了它们。 其次,该类型还实现了另外的 3 个简单接口,即:`io.ReaderAt`、`io.Seeker`和`io.WriterAt`。 正是因为`*os.File`类型实现了这些简单接口,所以它也顺便实现了`io`包的 9 个扩展接口中的 7 个。然而,由于它并没有实现简单接口`io.ByteReader`和`io.RuneReader`,所以它没有实现分别作为这两者的扩展接口的`io.ByteScanner`和`io.RuneScanner`。 总之,`os.File`类型及其指针类型的值,不但可以通过各种方式读取和写入某个文件中的内容,还可以寻找并设定下一次读取或写入时的起始索引位置,另外还可以随时对文件进行关闭。 但是,它们并不能专门地读取文件中的下一个字节或者下一个 Unicode 字符,也不能进行任何的读回退操作。不过,单独读取下一个字节或字符的功能也可以通过其他方式来实现,比如,调用它的`Read`方法并传入适当的参数值就可以做到这一点。 ### [](https://jums.gitbook.io/36-lectures-on-golang/44-shi-yong-os-bao-zhong-de-api-shang#wen-ti-jie-xi) 问题解析 这个问题其实在间接地问“`os.File`类型能够以何种方式操作文件?”我在前面的典型回答中也给出了简要的答案。在我进一步地说明一些细节之前,我们先来看看怎样才能获得一个`os.File`类型的指针值(以下简称`File`值)。 在`os`包中,有这样几个函数,即:`Create`、`NewFile`、`Open`和`OpenFile`。 `os.Create`函数用于根据给定的路径创建一个新的文件。它会返回一个`File`值和一个错误值。我们可以在该函数返回的`File`值之上,对相应的文件进行读操作和写操作。 不但如此,我们使用这个函数创建的文件,对于操作系统中的所有用户来说,都是可以读和写的。换句话说,一旦这样的文件被创建出来,任何能够登录其所属的操作系统的用户,都可以在任意时刻读取该文件中的内容,或者向该文件写入内容。 注意,如果在我们给予`os.Create`函数的路径之上已经存在了一个文件,那么该函数会先清空现有文件中的全部内容,然后再把它作为第一个结果值返回。 另外,`os.Create`函数是有可能返回非`nil`的错误值的。比如,如果我们给定的路径上的某一级父目录并不存在,那么该函数就会返回一个`*os.PathError`类型的错误值,以表示“不存在的文件或目录”。 再来看`os.NewFile`函数。该函数在被调用的时候需要接受一个代表文件描述符的、`uintptr`类型的值,以及一个用于表示文件名的字符串值。 如果我们给定的文件描述符并不是有效的,那么这个函数将会返回`nil`,否则,它将会返回一个代表了相应文件的`File`值。 注意,不要被这个函数的名称误导了,它的功能并不是创建一个新的文件,而是依据一个已经存在的文件的描述符,来新建一个包装了该文件的`File`值。 例如,我们可以像这样拿到一个包装了标准错误输出的`File`值: 复制 file3 := os.NewFile(uintptr(syscall.Stderr), "/dev/stderr") 然后,通过这个`File`值向标准错误输出上写入一些内容: 复制 if file3 != nil { defer file3.Close() file3.WriteString( "The Go language program writes the contents into stderr.\n") } `os.Open`函数会打开一个文件并返回包装了该文件的`File`值。然而,该函数只能以只读模式打开文件。换句话说,我们只能从该函数返回的`File`值中读取内容,而不能向它写入任何内容。 如果我们调用了这个`File`值的任何一个写入方法,那么都将会得到一个表示了“坏的文件描述符”的错误值。实际上,我们刚刚说的只读模式,正是应用在`File`值所持有的文件描述符之上的。 所谓的文件描述符,是由通常很小的非负整数代表的。它一般会由 I/O 相关的系统调用返回,并作为某个文件的一个标识存在。 从操作系统的层面看,针对任何文件的 I/O 操作都需要用到这个文件描述符。只不过,Go 语言中的一些数据类型,为我们隐匿掉了这个描述符,如此一来我们就无需时刻关注和辨别它了(就像`os.File`类型这样)。 实际上,我们在调用前文所述的`os.Create`函数、`os.Open`函数以及将会提到的`os.OpenFile`函数的时候,它们都会执行同一个系统调用,并且在成功之后得到这样一个文件描述符。这个文件描述符将会被储存在它们返回的`File`值中。 `os.File`类型有一个指针方法,名叫`Fd`。它在被调用之后将会返回一个`uintptr`类型的值。这个值就代表了当前的`File`值所持有的那个文件描述符。 不过,在`os`包中,除了`NewFile`函数需要用到它,它也没有什么别的用武之地了。所以,如果你操作的只是常规的文件或者目录,那么就无需特别地在意它了。 最后,再说一下`os.OpenFile`函数。这个函数其实是`os.Create`函数和`os.Open`函数的底层支持,它最为灵活。 这个函数有 3 个参数,分别名为`name`、`flag`和`perm`。其中的`name`指代的就是文件的路径。而`flag`参数指的则是需要施加在文件描述符之上的模式,我在前面提到的只读模式就是这里的一个可选项。 在 Go 语言中,这个只读模式由常量`os.O_RDONLY`代表,它是`int`类型的。当然了,这里除了只读模式之外,还有几个别的模式可选,我们稍后再细说。 `os.OpenFile`函数的参数`perm`代表的也是模式,它的类型是`os.FileMode`,此类型是一个基于`uint32`类型的再定义类型。 为了加以区别,我们把参数`flag`指代的模式叫做操作模式,而把参数`perm`指代的模式叫做权限模式。可以这么说,前者限定了操作文件的方式,而后者则可以控制文件的访问权限。关于权限模式的更多细节我们将在后面讨论。 到这里,你需要记住的是,通过`os.File`类型的值,我们不但可以对文件进行读取、写入、关闭等操作,还可以设定下一次读取或写入时的起始索引位置。 此外,`os`包中还有用于创建全新文件的`Create`函数,用于包装现存文件的`NewFile`函数,以及可被用来打开已存在的文件的`Open`函数和`OpenFile`函数。 ### [](https://jums.gitbook.io/36-lectures-on-golang/44-shi-yong-os-bao-zhong-de-api-shang#zong-jie) 总结 我们今天讲的是`os`代码包以及其中的程序实体。我们首先讨论了`os`包存在的意义,和它的主要用途。代码包中所包含的 API,都是对操作系统的某方面功能的高层次抽象,这使得我们可以通过它以统一的方式,操纵不同的操作系统,并得到相似的结果。 在这个代码包中,操纵文件系统的 API 最为丰富,最有代表性的就是数据类型`os.File`。`os.File`类型不但可以代表操作系统中的文件,还可以代表很多其他的东西。尤其是在类 Unix 的操作系统中,它几乎可以代表一切可以操纵的软件和硬件。 在下一期的文章中,我会继续讲解 os 包中的 API 的内容。如果你对这部分的知识有什么问题,可以给我留言,感谢你的收听,我们下期再见。 [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页43 | bufio包中的数据类型(下)](https://jums.gitbook.io/36-lectures-on-golang/43-bufio-bao-zhong-de-shu-ju-lei-xing-xia) [下一页45 | 使用os包中的API (下)](https://jums.gitbook.io/36-lectures-on-golang/45-shi-yong-os-bao-zhong-de-api-xia) 最后更新于2年前 这有帮助吗? --- # 20 | 错误处理 (下) | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252FYqMap0VCo15x0VK5W2or%252Fimage.png%3Falt%3Dmedia%26token%3D9d1dab0e-067e-4454-b15f-898684bfd48b&width=768&dpr=4&quality=100&sign=56703753&sv=2) [5MB\ \ 20.错误处理 (下).mp3](https://2852705636-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FkmtyDvkosJZE1Rr33XGZ%2Fuploads%2FQwGQQddtvcGgOQH5UIf5%2F20.%E9%94%99%E8%AF%AF%E5%A4%84%E7%90%86%20%EF%BC%88%E4%B8%8B%EF%BC%89.mp3?alt=media&token=861c9430-86f6-467d-9d68-054d5485fcc6) 你好,我是郝林,今天我们继续来分享错误处理。 在上一篇文章中,我们主要讨论的是从使用者的角度看“怎样处理好错误值”。那么,接下来我们需要关注的,就是站在建造者的角度,去关心“怎样才能给予使用者恰当的错误值”的问题了。 ### [](https://jums.gitbook.io/36-lectures-on-golang/20-cuo-wu-chu-li-xia#zhi-shi-kuo-zhan) 知识扩展 **问题:怎样根据实际情况给予恰当的错误值?** 我们已经知道,构建错误值体系的基本方式有两种,即:创建立体的错误类型体系和创建扁平的错误值列表。 先说错误类型体系。由于在 Go 语言中实现接口是非侵入式的,所以我们可以做得很灵活。比如,在标准库的`net`代码包中,有一个名为`Error`的接口类型。它算是内建接口类型`error`的一个扩展接口,因为`error`是`net.Error`的嵌入接口。 `net.Error`接口除了拥有`error`接口的`Error`方法之外,还有两个自己声明的方法:`Timeout`和`Temporary`。 `net`包中有很多错误类型都实现了`net.Error`接口,比如: 1. `*net.OpError`; 2. `*net.AddrError`; 3. `net.UnknownNetworkError`等等。 你可以把这些错误类型想象成一棵树,内建接口`error`就是树的根,而`net.Error`接口就是一个在根上延伸的第一级非叶子节点。 同时,你也可以把这看做是一种多层分类的手段。当`net`包的使用者拿到一个错误值的时候,可以先判断它是否是`net.Error`类型的,也就是说该值是否代表了一个网络相关的错误。 如果是,那么我们还可以再进一步判断它的类型是哪一个更具体的错误类型,这样就能知道这个网络相关的错误具体是由于操作不当引起的,还是因为网络地址问题引起的,又或是由于网络协议不正确引起的。 当我们细看`net`包中的这些具体错误类型的实现时,还会发现,与`os`包中的一些错误类型类似,它们也都有一个名为`Err`、类型为`error`接口类型的字段,代表的也是当前错误的潜在错误。 所以说,这些错误类型的值之间还可以有另外一种关系,即:链式关系。比如说,使用者调用`net.DialTCP`之类的函数时,`net`包中的代码可能会返回给他一个`*net.OpError`类型的错误值,以表示由于他的操作不当造成了一个错误。 同时,这些代码还可能会把一个`*net.AddrError`或`net.UnknownNetworkError`类型的值赋给该错误值的`Err`字段,以表明导致这个错误的潜在原因。如果,此处的潜在错误值的`Err`字段也有非`nil`的值,那么将会指明更深层次的错误原因。如此一级又一级就像链条一样最终会指向问题的根源。 把以上这些内容总结成一句话就是,用类型建立起树形结构的错误体系,用统一字段建立起可追根溯源的链式错误关联。这是 Go 语言标准库给予我们的优秀范本,非常有借鉴意义。 不过要注意,如果你不想让包外代码改动你返回的错误值的话,一定要小写其中字段的名称首字母。你可以通过暴露某些方法让包外代码有进一步获取错误信息的权限,比如编写一个可以返回包级私有的`err`字段值的公开方法`Err`。 相比于立体的错误类型体系,扁平的错误值列表就要简单得多了。当我们只是想预先创建一些代表已知错误的错误值时候,用这种扁平化的方式就很恰当了。 不过,由于`error`是接口类型,所以通过`errors.New`函数生成的错误值只能被赋给变量,而不能赋给常量,又由于这些代表错误的变量需要给包外代码使用,所以其访问权限只能是公开的。 这就带来了一个问题,如果有恶意代码改变了这些公开变量的值,那么程序的功能就必然会受到影响。因为在这种情况下我们往往会通过判等操作来判断拿到的错误值具体是哪一个错误,如果这些公开变量的值被改变了,那么相应的判等操作的结果也会随之改变。 这里有两个解决方案。第一个方案是,先私有化此类变量,也就是说,让它们的名称首字母变成小写,然后编写公开的用于获取错误值以及用于判等错误值的函数。 比如,对于错误值`os.ErrClosed`,先改写它的名称,让其变成`os.errClosed`,然后再编写`ErrClosed`函数和`IsErrClosed`函数。 当然了,这不是说让你去改动标准库中已有的代码,这样做的危害会很大,甚至是致命的。我只能说,对于你可控的代码,最好还是要尽量收紧访问权限。 再来说第二个方案,此方案存在于`syscall`包中。该包中有一个类型叫做`Errno`,该类型代表了系统调用时可能发生的底层错误。这个错误类型是`error`接口的实现类型,同时也是对内建类型`uintptr`的再定义类型。 由于`uintptr`可以作为常量的类型,所以`syscall.Errno`自然也可以。`syscall`包中声明有大量的`Errno`类型的常量,每个常量都对应一种系统调用错误。`syscall`包外的代码可以拿到这些代表错误的常量,但却无法改变它们。 我们可以仿照这种声明方式来构建我们自己的错误值列表,这样就可以保证错误值的只读特性了。 好了,总之,扁平的错误值列表虽然相对简单,但是你一定要知道其中的隐患以及有效的解决方案是什么。 **总结** 今天,我从两个视角为你总结了错误类型、错误值的处理技巧和设计方式。我们先一起看了一下 Go 语言中处理错误的最基本方式,这涉及了函数结果列表设计、`errors.New`函数、卫述语句以及使用打印函数输出错误值。 接下来,我提出的第一个问题是关于错误判断的。对于一个错误值来说,我们可以获取到它的类型、值以及它携带的错误信息。 如果我们可以确定其类型范围或者值的范围,那么就可以使用一些明确的手段获知具体的错误种类。否则,我们就只能通过匹配其携带的错误信息来大致区分它们的种类。 由于底层系统给予我们的错误信息还是很有规律可循的,所以用这种方式去判断效果还比较显著。但是第三方程序给出的错误信息很可能就没那么规整了,这种情况下靠错误信息去辨识种类就会比较困难。 有了以上阐释,当把视角从使用者换位到建造者,我们往往就会去自觉地仔细思考程序错误体系的设计了。我在这里提出了两个在 Go 语言标准库中使用很广泛的方案,即:立体的错误类型体系和扁平的错误值列表。 之所以说错误类型体系是立体的,是因为从整体上看它往往呈现出树形的结构。通过接口间的嵌套以及接口的实现,我们就可以构建出一棵错误类型树。 通过这棵树,使用者就可以一步步地确定错误值的种类了。另外,为了追根溯源的需要,我们还可以在错误类型中,统一安放一个可以代表潜在错误的字段。这叫做链式的错误关联,可以帮助使用者找到错误的根源。 相比之下,错误值列表就比较简单了。它其实就是若干个名称不同但类型相同的错误值集合。 不过需要注意的是,如果它们是公开的,那就应该尽量让它们成为常量而不是变量,或者编写私有的错误值以及公开的获取和判等函数,否则就很难避免恶意的篡改。 这其实是“最小化访问权限”这个程序设计原则的一个具体体现。无论怎样设计程序错误体系,我们都应该把这一点考虑在内。 **思考题** 请列举出你经常用到或者看到的 3 个错误值,它们分别在哪个错误值列表里?这些错误值列表分别包含的是哪个种类的错误? [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页19 | 错误处理(上)](https://jums.gitbook.io/36-lectures-on-golang/19-cuo-wu-chu-li-shang) [下一页21 | panic函数、recover函数以及defer语句 (上)](https://jums.gitbook.io/36-lectures-on-golang/21-panic-han-shu-recover-han-shu-yi-ji-defer-yu-ju-shang) 最后更新于2年前 这有帮助吗? --- # 35 | 并发安全字典sync.Map (下) | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252FyiUqnJJEmT4VBJ8KubZJ%252Fimage.png%3Falt%3Dmedia%26token%3De5128406-4f9a-4420-8470-c386b2325d3b&width=768&dpr=4&quality=100&sign=66cdda5e&sv=2) 你好,我是郝林,今天我们继续来分享并发安全字典 sync.Map 的内容。 我们在上一篇文章中谈到了,由于并发安全字典提供的方法涉及的键和值的类型都是`interface{}`,所以我们在调用这些方法的时候,往往还需要对键和值的实际类型进行检查。 这里大致有两个方案。我们上一篇文章中提到了第一种方案,在编码时就完全确定键和值的类型,然后利用 Go 语言的编译器帮我们做检查。 这样做很方便,不是吗?不过,虽然方便,但是却让这样的字典类型缺少了一些灵活性。如果我们还需要一个键类型为`uint32`并发安全字典的话,那就不得不再如法炮制地写一遍代码了。因此,在需求多样化之后,工作量反而更大,甚至会产生很多雷同的代码。 ### [](https://jums.gitbook.io/36-lectures-on-golang/35-bing-fa-an-quan-zi-dian-sync.map-xia#zhi-shi-kuo-zhan) 知识扩展 ### [](https://jums.gitbook.io/36-lectures-on-golang/35-bing-fa-an-quan-zi-dian-sync.map-xia#wen-ti-1-zen-yang-bao-zheng-bing-fa-an-quan-zi-dian-zhong-de-jian-he-zhi-de-lei-xing-zheng-que-xing) 问题 1:怎样保证并发安全字典中的键和值的类型正确性?(方案二) 那么,如果我们既想保持`sync.Map`类型原有的灵活性,又想约束键和值的类型,那么应该怎样做呢?这就涉及了第二个方案。 **在第二种方案中,我们封装的结构体类型的所有方法,都可以与**`**sync.Map**`**类型的方法完全一致(包括方法名称和方法签名)。** 不过,在这些方法中,我们就需要添加一些做类型检查的代码了。另外,这样并发安全字典的键类型和值类型,必须在初始化的时候就完全确定。并且,这种情况下必须先要保证键的类型是可比较的。 所以在设计这样的结构体类型的时候,只包含`sync.Map`类型的字段就不够了。 比如: 复制 type ConcurrentMap struct { m sync.Map keyType reflect.Type valueType reflect.Type } 这里`ConcurrentMap`类型代表的是可自定义键类型和值类型的并发安全字典。这个类型同样有一个`sync.Map`类型的字段`m`,代表着其内部使用的并发安全字典。 另外,它的字段`keyType`和`valueType`分别用于保存键类型和值类型。这两个字段的类型都是`reflect.Type`,我们可称之为反射类型。 这个类型可以代表 Go 语言的任何数据类型。并且,这个类型的值也非常容易获得:通过调用`reflect.TypeOf`函数并把某个样本值传入即可。比如:调用表达式`reflect.TypeOf(int(123))`的结果值,就代表了`int`类型的反射类型值。 我们现在来看一看`ConcurrentMap`类型方法应该怎么写。先说`Load`方法,这个方法接受一个`interface{}`类型的参数`key`,参数`key`代表了某个键的值。 因此,当我们根据 ConcurrentMap 在`m`字段的值中查找键值对的时候,就必须保证 ConcurrentMap 的类型是正确的。由于反射类型值之间可以直接使用操作符`==`或`!=`进行判等,所以这里的类型检查代码非常简单。 复制 func (cMap *ConcurrentMap) Load(key interface{}) (value interface{}, ok bool) { if reflect.TypeOf(key) != cMap.keyType { return } return cMap.m.Load(key) } 我们把一个接口类型值传入`reflect.TypeOf`函数,就可以得到与这个值的实际类型对应的反射类型值。因此,如果参数值的反射类型与`keyType`字段代表的反射类型不相等,那么我们就忽略后续操作,并直接返回。 这时,`Load`方法的第一个结果`value`的值为`nil`,而第二个结果`ok`的值为`false`。这完全符合`Load`方法原本的含义。 再来说`Store`方法。`Store`方法接受两个参数`key`和`value`,它们的类型也都是`interface{}`。因此,我们的类型检查应该针对它们来做。 复制 func (cMap *ConcurrentMap) Store(key, value interface{}) { if reflect.TypeOf(key) != cMap.keyType { panic(fmt.Errorf("wrong key type: %v", reflect.TypeOf(key))) } if reflect.TypeOf(value) != cMap.valueType { panic(fmt.Errorf("wrong value type: %v", reflect.TypeOf(value))) } cMap.m.Store(key, value) } 这里的类型检查代码与`Load`方法中的代码很类似,不同的是对检查结果的处理措施。当参数`key`或`value`的实际类型不符合要求时,`Store`方法会立即引发 panic。 这主要是由于`Store`方法没有结果声明,所以在参数值有问题的时候,它无法通过比较平和的方式告知调用方。不过,这也是符合`Store`方法的原本含义的。 如果你不想这么做,也是可以的,那么就需要为`Store`方法添加一个`error`类型的结果。并且,在发现参数值类型不正确的时候,让它直接返回相应的`error`类型值,而不是引发 panic。要知道,这里展示的只一个参考实现,你可以根据实际的应用场景去做优化和改进。 至于与`ConcurrentMap`类型相关的其他方法和函数,我在这里就不展示了。它们在类型检查方式和处理流程上并没有特别之处。你可以在 demo72.go 文件中看到这些代码。 稍微总结一下。第一种方案适用于我们可以完全确定键和值具体类型的情况。在这种情况下,我们可以利用 Go 语言编译器去做类型检查,并用类型断言表达式作为辅助,就像`IntStrMap`那样。 在第二种方案中,我们无需在程序运行之前就明确键和值的类型,只要在初始化并发安全字典的时候,动态地给定它们就可以了。这里主要需要用到`reflect`包中的函数和数据类型,外加一些简单的判等操作。 第一种方案存在一个很明显的缺陷,那就是无法灵活地改变字典的键和值的类型。一旦需求出现多样化,编码的工作量就会随之而来。 第二种方案很好地弥补了这一缺陷,但是,那些反射操作或多或少都会降低程序的性能。我们往往需要根据实际的应用场景,通过严谨且一致的测试,来获得和比较程序的各项指标,并以此作为方案选择的重要依据之一。 ### [](https://jums.gitbook.io/36-lectures-on-golang/35-bing-fa-an-quan-zi-dian-sync.map-xia#wen-ti-2-bing-fa-an-quan-zi-dian-ru-he-zuo-dao-jin-liang-bi-mian-shi-yong-suo) 问题 2:并发安全字典如何做到尽量避免使用锁? `sync.Map`类型在内部使用了大量的原子操作来存取键和值,并使用了两个原生的`map`作为存储介质。 其中一个原生`map`被存在了`sync.Map`的`read`字段中,该字段是`sync/atomic.Value`类型的。这个原生字典可以被看作一个快照,它总会在条件满足时,去重新保存所属的`sync.Map`值中包含的所有键值对。 为了描述方便,我们在后面简称它为只读字典。不过,只读字典虽然不会增减其中的键,但却允许变更其中的键所对应的值。所以,它并不是传统意义上的快照,它的只读特性只是对于其中键的集合而言的。 由`read`字段的类型可知,`sync.Map`在替换只读字典的时候根本用不着锁。另外,这个只读字典在存储键值对的时候,还在值之上封装了一层。 它先把值转换为了`unsafe.Pointer`类型的值,然后再把后者封装,并储存在其中的原生字典中。如此一来,在变更某个键所对应的值的时候,就也可以使用原子操作了。 `sync.Map`中的另一个原生字典由它的`dirty`字段代表。它存储键值对的方式与`read`字段中的原生字典一致,它的键类型也是`interface{}`,并且同样是把值先做转换和封装后再进行储存的。我们暂且把它称为脏字典。 注意,脏字典和只读字典如果都存有同一个键值对,那么这里的两个键指的肯定是同一个基本值,对于两个值来说也是如此。正如前文所述,这两个字典在存储键和值的时候都只会存入它们的某个指针,而不是基本值。 `sync.Map`在查找指定的键所对应的值的时候,总会先去只读字典中寻找,并不需要锁定互斥锁。只有当确定“只读字典中没有,但脏字典中可能会有这个键”的时候,它才会在锁的保护下去访问脏字典。 相对应的,`sync.Map`在存储键值对的时候,只要只读字典中已存有这个键,并且该键值对未被标记为“已删除”,就会把新值存到里面并直接返回,这种情况下也不需要用到锁。 否则,它才会在锁的保护下把键值对存储到脏字典中。这个时候,该键值对的“已删除”标记会被抹去。 顺便说一句,只有当一个键值对应该被删除,但却仍然存在于只读字典中的时候,才会被用标记为“已删除”的方式进行逻辑删除,而不会直接被物理删除。 这种情况会在重建脏字典以后的一段时间内出现。不过,过不了多久,它们就会被真正删除掉。在查找和遍历键值对的时候,已被逻辑删除的键值对永远会被无视。 对于删除键值对,`sync.Map`会先去检查只读字典中是否有对应的键。如果没有,脏字典中可能有,那么它就会在锁的保护下,试图从脏字典中删掉该键值对。 最后,`sync.Map`会把该键值对中指向值的那个指针置为`nil`,这是另一种逻辑删除的方式。 除此之外,还有一个细节需要注意,只读字典和脏字典之间是会互相转换的。在脏字典中查找键值对次数足够多的时候,`sync.Map`会把脏字典直接作为只读字典,保存在它的`read`字段中,然后把代表脏字典的`dirty`字段的值置为`nil`。 在这之后,一旦再有新的键值对存入,它就会依据只读字典去重建脏字典。这个时候,它会把只读字典中已被逻辑删除的键值对过滤掉。理所当然,这些转换操作肯定都需要在锁的保护下进行。 综上所述,`sync.Map`的只读字典和脏字典中的键值对集合并不是实时同步的,它们在某些时间段内可能会有不同。 由于只读字典中键的集合不能被改变,所以其中的键值对有时候可能是不全的。相反,脏字典中的键值对集合总是完全的,并且其中不会包含已被逻辑删除的键值对。 因此,可以看出,在读操作有很多但写操作却很少的情况下,并发安全字典的性能往往会更好。在几个写操作当中,新增键值对的操作对并发安全字典的性能影响是最大的,其次是删除操作,最后才是修改操作。 如果被操作的键值对已经存在于`sync.Map`的只读字典中,并且没有被逻辑删除,那么修改它并不会使用到锁,对其性能的影响就会很小。 ### [](https://jums.gitbook.io/36-lectures-on-golang/35-bing-fa-an-quan-zi-dian-sync.map-xia#zong-jie) 总结 这两篇文章中,我们讨论了`sync.Map`类型,并谈到了怎样保证并发安全字典中的键和值的类型正确性。 为了进一步明确并发安全字典中键值的实际类型,这里大致有两种方案可选。其中一种方案是,在编码时就完全确定键和值的类型,然后利用 Go 语言的编译器帮我们做检查。另一种方案是,接受动态的类型设置,并在程序运行的时候通过反射操作进行检查。 这两种方案各有利弊,前一种方案在扩展性方面有所欠缺,而后一种方案通常会影响到程序的性能。在实际使用的时候,我们一般都需要通过客观的测试来帮助决策。 另外,在有些时候,与单纯使用原生字典和互斥锁的方案相比,使用`sync.Map`可以显著地减少锁的争用。`sync.Map`本身确实也用到了锁,但是,它会尽可能地避免使用锁。 这就要说到`sync.Map`对其持有两个原生字典的巧妙使用了。这两个原生字典一个被称为只读字典,另一个被称为脏字典。通过对它们的分析,我们知道了并发安全字典的适用场景,以及每种操作对其性能的影响程度。 ### [](https://jums.gitbook.io/36-lectures-on-golang/35-bing-fa-an-quan-zi-dian-sync.map-xia#si-kao-ti) 思考题 今天的思考题是:关于保证并发安全字典中的键和值的类型正确性,你还能想到其他的方案吗? [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页34 | 并发安全字典sync.Map (上)](https://jums.gitbook.io/36-lectures-on-golang/34-bing-fa-an-quan-zi-dian-sync.map-shang) [下一页36 | unicode与字符编码](https://jums.gitbook.io/36-lectures-on-golang/36-unicode-yu-zi-fu-bian-ma) 最后更新于2年前 这有帮助吗? --- # 45 | 使用os包中的API (下) | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252FEpp4OySoXD3KaSO0Qp38%252Fimage.png%3Falt%3Dmedia%26token%3D7cade9b1-70dc-4035-8073-d4d3e8ee05b8&width=768&dpr=4&quality=100&sign=ed471ac8&sv=2) 你好,我是郝林,今天我们继续分享使用 os 包中的 API。 我们在上一篇文章中。从“`os.File`类型都实现了哪些`io`包中的接口”这一问题出发,介绍了一系列的相关内容。今天我们继续围绕这一知识点进行扩展。 ### [](https://jums.gitbook.io/36-lectures-on-golang/45-shi-yong-os-bao-zhong-de-api-xia#zhi-shi-kuo-zhan) 知识扩展 #### [](https://jums.gitbook.io/36-lectures-on-golang/45-shi-yong-os-bao-zhong-de-api-xia#wen-ti-1-ke-ying-yong-yu-file-zhi-de-cao-zuo-mo-shi-dou-you-na-xie) 问题 1:可应用于`File`值的操作模式都有哪些? 针对`File`值的操作模式主要有只读模式、只写模式和读写模式。这些模式分别由常量`os.O_RDONLY`、`os.O_WRONLY`和`os.O_RDWR`代表。在我们新建或打开一个文件的时候,必须把这三个模式中的一个设定为此文件的操作模式。 除此之外,我们还可以为这里的文件设置额外的操作模式,可选项如下所示。 * `os.O_APPEND`:当向文件中写入内容时,把新内容追加到现有内容的后边。 * `os.O_CREATE`:当给定路径上的文件不存在时,创建一个新文件。 * `os.O_EXCL`:需要与`os.O_CREATE`一同使用,表示在给定的路径上不能有已存在的文件。 * `os.O_SYNC`:在打开的文件之上实施同步 I/O。它会保证读写的内容总会与硬盘上的数据保持同步。 * `os.O_TRUNC`:如果文件已存在,并且是常规的文件,那么就先清空其中已经存在的任何内容。 对于以上操作模式的使用,`os.Create`函数和`os.Open`函数都是现成的例子。 复制 func Create(name string) (*File, error) { return OpenFile(name, O_RDWR|O_CREATE|O_TRUNC, 0666) } `os.Create`函数在调用`os.OpenFile`函数的时候,给予的操作模式是`os.O_RDWR`、`os.O_CREATE`和`os.O_TRUNC`的组合。 这就基本上决定了前者的行为,即:如果参数`name`代表路径之上的文件不存在,那么就新建一个,否则,先清空现存文件中的全部内容。并且,它返回的`File`值的读取方法和写入方法都是可用的。这里需要注意,多个操作模式是通过按位或操作符`|`组合起来的。 func Open(name string) (\*File, error) { return OpenFile(name, O\_RDONLY, 0) } 我在前面说过,`os.Open`函数的功能是:以只读模式打开已经存在的文件。其根源就是它在调用`os.OpenFile`函数的时候,只提供了一个单一的操作模式`os.O_RDONLY`。 以上,就是我对可应用于`File`值的操作模式的简单解释。在 demo88.go 文件中还有少许示例,可供你参考。 #### [](https://jums.gitbook.io/36-lectures-on-golang/45-shi-yong-os-bao-zhong-de-api-xia#wen-ti-2-zen-yang-she-ding-chang-gui-wen-jian-de-fang-wen-quan-xian) 问题 2:怎样设定常规文件的访问权限? 我们已经知道,`os.OpenFile`函数的第三个参数`perm`代表的是权限模式,其类型是`os.FileMode`。但实际上,`os.FileMode`类型能够代表的,可远不只权限模式,它还可以代表文件模式(也可以称之为文件种类)。 由于`os.FileMode`是基于`uint32`类型的再定义类型,所以它的每个值都包含了 32 个比特位。在这 32 个比特位当中,每个比特位都有其特定的含义。 比如,如果在其最高比特位上的二进制数是`1`,那么该值表示的文件模式就等同于`os.ModeDir`,也就是说,相应的文件代表的是一个目录。 又比如,如果其中的第 26 个比特位上的是`1`,那么相应的值表示的文件模式就等同于`os.ModeNamedPipe`,也就是说,那个文件代表的是一个命名管道。 实际上,在一个`os.FileMode`类型的值(以下简称`FileMode`值)中,只有最低的 9 个比特位才用于表示文件的权限。当我们拿到一个此类型的值时,可以把它和`os.ModePerm`常量的值做按位与操作。 这个常量的值是`0777`,是一个八进制的无符号整数,其最低的 9 个比特位上都是`1`,而更高的 23 个比特位上都是`0`。 所以,经过这样的按位与操作之后,我们即可得到这个`FileMode`值中所有用于表示文件权限的比特位,也就是该值所表示的权限模式。这将会与我们调用`FileMode`值的`Perm`方法所得到的结果值是一致。 在这 9 个用于表示文件权限的比特位中,每 3 个比特位为一组,共可分为 3 组。从高到低,这 3 组分别表示的是文件所有者(也就是创建这个文件的那个用户)、文件所有者所属的用户组,以及其他用户对该文件的访问权限。而对于每个组,其中的 3 个比特位从高到低分别表示读权限、写权限和执行权限。 如果在其中的某个比特位上的是`1`,那么就意味着相应的权限开启,否则,就表示相应的权限关闭。 因此,八进制整数`0777`就表示:操作系统中的所有用户都对当前的文件有读、写和执行的权限,而八进制整数`0666`则表示:所有用户都对当前文件有读和写的权限,但都没有执行的权限。 我们在调用`os.OpenFile`函数的时候,可以根据以上说明设置它的第三个参数。但要注意,只有在新建文件的时候,这里的第三个参数值才是有效的。在其他情况下,即使我们设置了此参数,也不会对目标文件产生任何的影响。 ### [](https://jums.gitbook.io/36-lectures-on-golang/45-shi-yong-os-bao-zhong-de-api-xia#zong-jie) 总结 为了聚焦于`os.File`类型本身,我在这两篇文章中主要讲述了怎样把 os.File 类型应用于常规的文件。该类型的指针类型实现了很多`io`包中的接口,因此它的具体功用也就可以不言自明了。 通过该类型的值,我们不但可以对文件进行各种读取、写入、关闭等操作,还可以设定下一次读取或写入时的起始索引位置。 在使用这个类型的值之前,我们必须先要创建它。所以,我为你重点介绍了几个可以创建,并获得此类型值的函数,包括:`os.Create`、`os.NewFile`、`os.Open`和`os.OpenFile`。我们用什么样的方式创建`File`值,就决定了我们可以使用它来做什么。 利用`os.Create`函数,我们可以在操作系统中创建一个全新的文件,或者清空一个现存文件中的全部内容并重用它。 在相应的`File`值之上,我们可以对该文件进行任何的读写操作。虽然`os.NewFile`函数并不是被用来创建新文件的,但是它能够基于一个有效的文件描述符包装出一个可用的`File`值。 `os.Open`函数的功能是打开一个已经存在的文件。但是,我们只能通过它返回的`File`值对相应的文件进行读操作。 `os.OpenFile`是这些函数中最为灵活的一个,通过它,我们可以设定被打开文件的操作模式和权限模式。实际上,`os.Create`函数和`os.Open`函数都只是对它的简单封装而已。 在使用`os.OpenFile`函数的时候,我们必须要搞清楚操作模式和权限模式所代表的真正含义,以及设定它们的正确方式。 我在本文的扩展问题中分别对它们进行了较为详细的解释。同时,我在对应的示例文件中也编写了一些代码。你需要认真地阅读和理解这些代码,并在运行它们的过程当中悟出这两种模式的真谛。 我在本文中讲述的东西对于`os`包来说,只是海面上的那部分冰山而已。这个代码包囊括的知识众多,而且延展性都很强。 如果你想完全理解它们,可能还需要去参看操作系统等方面的文档和教程。由于篇幅原因,我在这里只是做了一个引导,帮助你初识该包中的一些重要的程序实体,并给予你一个可以深入下去的切入点,希望你已经在路上了。 **思考题** 今天的思考题是:怎样通过`os`包中的 API 创建和操纵一个系统进程? [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页44 | 使用os包中的API (上)](https://jums.gitbook.io/36-lectures-on-golang/44-shi-yong-os-bao-zhong-de-api-shang) [下一页46 | 访问网络服务](https://jums.gitbook.io/36-lectures-on-golang/46-fang-wen-wang-luo-fu-wu) 最后更新于2年前 这有帮助吗? --- # 38 | bytes包与字节串操作(上) | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252FKfWcPP1IMJQCLFJJeNRg%252Fimage.png%3Falt%3Dmedia%26token%3D2c3c62c4-20a6-4cc5-9fcf-d340bc90519f&width=768&dpr=4&quality=100&sign=2a502805&sv=2) 我相信,经过上一次的学习,你已经对`strings.Builder`和`strings.Reader`这两个类型足够熟悉了。 我上次还建议你去自行查阅`strings`代码包中的其他程序实体。如果你认真去看了,那么肯定会对我们今天要讨论的`bytes`代码包,有种似曾相识的感觉。 `strings`包和`bytes`包可以说是一对孪生兄弟,它们在 API 方面非常的相似。单从它们提供的函数的数量和功能上讲,差别微乎其微。 只不过,`strings`包主要面向的是 Unicode 字符和经过 UTF-8 编码的字符串,而`bytes`包面对的则主要是字节和字节切片。 我今天会主要讲`bytes`包中最有特色的类型`Buffer`。顾名思义,`bytes.Buffer`类型的用途主要是作为字节序列的缓冲区。 与`strings.Builder`类型一样,`bytes.Buffer`也是开箱即用的。但不同的是,`strings.Builder`只能拼接和导出字符串,而`bytes.Buffer`不但可以拼接、截断其中的字节序列,以各种形式导出其中的内容,还可以顺序地读取其中的子序列。 可以说,`bytes.Buffer`是集读、写功能于一身的数据类型。当然了,这些也基本上都是作为一个缓冲区应该拥有的功能。 在内部,`bytes.Buffer`类型同样是使用字节切片作为内容容器的。并且,与`strings.Reader`类型类似,`bytes.Buffer`有一个`int`类型的字段,用于代表已读字节的计数,可以简称为已读计数。 不过,这里的已读计数就无法通过`bytes.Buffer`提供的方法计算出来了。 我们先来看下面的代码: 复制 var buffer1 bytes.Buffer contents := "Simple byte buffer for marshaling data." fmt.Printf("Writing contents %q ...\n", contents) buffer1.WriteString(contents) fmt.Printf("The length of buffer: %d\n", buffer1.Len()) fmt.Printf("The capacity of buffer: %d\n", buffer1.Cap()) 我先声明了一个`bytes.Buffer`类型的变量`buffer1`,并写入了一个字符串。然后,我想打印出这个`bytes.Buffer`类型的值(以下简称`Buffer`值)的长度和容量。在运行这段代码之后,我们将会看到如下的输出: 复制 Writing contents "Simple byte buffer for marshaling data." ... The length of buffer: 39 The capacity of buffer: 64 乍一看这没什么问题。长度`39`和容量`64`的含义看起来与我们已知的概念是一致的。我向缓冲区中写入了一个长度为`39`的字符串,所以`buffer1`的长度就是`39`。 根据切片的自动扩容策略,`64`这个数字也是合理的。另外,可以想象,这时的已读计数的值应该是`0`,这是因为我还没有调用任何用于读取其中内容的方法。 可实际上,与`strings.Reader`类型的`Len`方法一样,`buffer1`的`Len`方法返回的也是内容容器中未被读取部分的长度,而不是其中已存内容的总长度(以下简称内容长度)。示例如下: 复制 p1 := make([]byte, 7) n, _ := buffer1.Read(p1) fmt.Printf("%d bytes were read. (call Read)\n", n) fmt.Printf("The length of buffer: %d\n", buffer1.Len()) fmt.Printf("The capacity of buffer: %d\n", buffer1.Cap()) 当我从`buffer1`中读取一部分内容,并用它们填满长度为`7`的字节切片`p1`之后,`buffer1`的`Len`方法返回的结果值也会随即发生变化。如果运行这段代码,我们会发现,这个缓冲区的长度已经变为了`32`。 另外,因为我们并没有再向该缓冲区中写入任何内容,所以它的容量会保持不变,仍是`64`。 总之,在这里,你需要记住的是,`Buffer`值的长度是未读内容的长度,而不是已存内容的总长度。它与在当前值之上的读操作和写操作都有关系,并会随着这两种操作的进行而改变,它可能会变得更小,也可能会变得更大。 而`Buffer`值的容量指的是它的内容容器(也就是那个字节切片)的容量,它只与在当前值之上的写操作有关,并会随着内容的写入而不断增长。 再说已读计数。由于`strings.Reader`还有一个`Size`方法可以给出内容长度的值,所以我们用内容长度减去未读部分的长度,就可以很方便地得到它的已读计数。 然而,`bytes.Buffer`类型却没有这样一个方法,它只有`Cap`方法。可是`Cap`方法提供的是内容容器的容量,也不是内容长度。 并且,这里的内容容器容量在很多时候都与内容长度不相同。因此,没有了现成的计算公式,只要遇到稍微复杂些的情况,我们就很难估算出`Buffer`值的已读计数。 一旦理解了已读计数这个概念,并且能够在读写的过程中,实时地获得已读计数和内容长度的值,我们就可以很直观地了解到当前`Buffer`值各种方法的行为了。不过,很可惜,这两个数字我们都无法直接拿到。 虽然,我们无法直接得到一个`Buffer`值的已读计数,并且有时候也很难估算它,但是我们绝对不能就此作罢,而应该通过研读`bytes.Buffer`和文档和源码,去探究已读计数在其中起到的关键作用。 否则,我们想用好`bytes.Buffer`的意愿,恐怕就不会那么容易实现了。 下面的这个问题,如果你认真地阅读了`bytes.Buffer`的源码之后,就可以很好地回答出来。 **我们今天的问题是:**`**bytes.Buffer**`**类型的值记录的已读计数,在其中起到了怎样的作用?** 这道题的典型回答是这样的。 `bytes.Buffer`中的已读计数的大致功用如下所示。 1. 读取内容时,相应方法会依据已读计数找到未读部分,并在读取后更新计数。 2. 写入内容时,如需扩容,相应方法会根据已读计数实现扩容策略。 3. 截断内容时,相应方法截掉的是已读计数代表索引之后的未读部分。 4. 读回退时,相应方法需要用已读计数记录回退点。 5. 重置内容时,相应方法会把已读计数置为`0`。 6. 导出内容时,相应方法只会导出已读计数代表的索引之后的未读部分。 7. 获取长度时,相应方法会依据已读计数和内容容器的长度,计算未读部分的长度并返回。 ### [](https://jums.gitbook.io/36-lectures-on-golang/38-bytes-bao-yu-zi-jie-chuan-cao-zuo-shang#wen-ti-jie-xi) 问题解析 通过上面的典型回答,我们已经能够体会到已读计数在`bytes.Buffer`类型,及其方法中的重要性了。没错,`bytes.Buffer`的绝大多数方法都用到了已读计数,而且都是非用不可。 在读取内容的时候,相应方法会先根据已读计数,判断一下内容容器中是否还有未读的内容。如果有,那么它就会从已读计数代表的索引处开始读取。 在读取完成后,它还会及时地更新已读计数。也就是说,它会记录一下又有多少个字节被读取了。**这里所说的相应方法包括了所有名称以**`**Read**`**开头的方法,以及**`**Next**`**方法和**`**WriteTo**`**方法。** 在写入内容的时候,绝大多数的相应方法都会先检查当前的内容容器,是否有足够的容量容纳新的内容。如果没有,那么它们就会对内容容器进行扩容。 在扩容的时候,方法会在必要时,依据已读计数找到未读部分,并把其中的内容拷贝到扩容后内容容器的头部位置。 然后,方法将会把已读计数的值置为`0`,以表示下一次读取需要从内容容器的第一个字节开始。**用于写入内容的相应方法,包括了所有名称以**`**Write**`**开头的方法,以及**`**ReadFrom**`**方法。** **用于截断内容的方法**`**Truncate**`**,会让很多对**`**bytes.Buffer**`**不太了解的程序开发者迷惑。** 它会接受一个`int`类型的参数,这个参数的值代表了:在截断时需要保留头部的多少个字节。 不过,需要注意的是,这里说的头部指的并不是内容容器的头部,而是其中的未读部分的头部。头部的起始索引正是由已读计数的值表示的。因此,在这种情况下,已读计数的值再加上参数值后得到的和,就是内容容器新的总长度。 **在**`**bytes.Buffer**`**中,用于读回退的方法有**`**UnreadByte**`**和**`**UnreadRune**`**。** 这两个方法分别用于回退一个字节和回退一个 Unicode 字符。调用它们一般都是为了退回在上一次被读取内容末尾的那个分隔符,或者为重新读取前一个字节或字符做准备。 不过,退回的前提是,在调用它们之前的那一个操作必须是“读取”,并且是成功的读取,否则这些方法就只能忽略后续操作并返回一个非`nil`的错误值。 `UnreadByte`方法的做法比较简单,把已读计数的值减`1`就好了。而`UnreadRune`方法需要从已读计数中减去的,是上一次被读取的 Unicode 字符所占用的字节数。 这个字节数由`bytes.Buffer`的另一个字段负责存储,它在这里的有效取值范围是 \[1, 4\]。只有`ReadRune`方法才会把这个字段的值设定在此范围之内。 由此可见,只有紧接在调用`ReadRune`方法之后,对`UnreadRune`方法的调用才能够成功完成。该方法明显比`UnreadByte`方法的适用面更窄。 我在前面说过,`bytes.Buffer`的`Len`方法返回的是内容容器中未读部分的长度,而不是其中已存内容的总长度(即:内容长度)。 而该类型的`Bytes`方法和`String`方法的行为,与`Len`方法是保持一致的。前两个方法只会去访问未读部分中的内容,并返回相应的结果值。 在我们剖析了所有的相关方法之后,可以这样来总结:在已读计数代表的索引之前的那些内容,永远都是已经被读过的,它们几乎没有机会再次被读取。 不过,这些已读内容所在的内存空间可能会被存入新的内容。这一般都是由于重置或者扩充内容容器导致的。这时,已读计数一定会被置为`0`,从而再次指向内容容器中的第一个字节。这有时候也是为了避免内存分配和重用内存空间。 ### [](https://jums.gitbook.io/36-lectures-on-golang/38-bytes-bao-yu-zi-jie-chuan-cao-zuo-shang#zong-jie) 总结 总结一下,`bytes.Buffer`是一个集读、写功能于一身的数据类型。它非常适合作为字节序列的缓冲区。我们会在下一篇文章中继续对 bytes.Buffer 的知识进行延展。如果你对于这部分内容有什么样问题,欢迎给我留言,我们一起讨论。 感谢你的收听,我们下次再见。 [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页37 | strings包与字符串操作](https://jums.gitbook.io/36-lectures-on-golang/37-strings-bao-yu-zi-fu-chuan-cao-zuo) [下一页39 | bytes包与字节串操作(下)](https://jums.gitbook.io/36-lectures-on-golang/39-bytes-bao-yu-zi-jie-chuan-cao-zuo-xia) 最后更新于2年前 这有帮助吗? --- # 14 | 接口类型的合理运用 | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252FrjPlzAx69P50z2kyMLyO%252Fimage.png%3Falt%3Dmedia%26token%3Dcfa3e7a6-c649-484c-8165-3606172d7a10&width=768&dpr=4&quality=100&sign=c6b4ea76&sv=2) [5MB\ \ 14.接口类型的合理运用.mp3](https://2852705636-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FkmtyDvkosJZE1Rr33XGZ%2Fuploads%2FDKsWkoTfsuXkHNbd11un%2F14.%E6%8E%A5%E5%8F%A3%E7%B1%BB%E5%9E%8B%E7%9A%84%E5%90%88%E7%90%86%E8%BF%90%E7%94%A8.mp3?alt=media&token=634daf42-683e-4947-b239-d8ffb032833e) 在 Go 语言的语境中,当我们在谈论“接口”的时候,一定指的是接口类型。因为接口类型与其他数据类型不同,它是没法被值化的,或者说是没法被实例化的。 更具体地说,我们既不能通过调用`new`函数或`make`函数创建出一个接口类型的值,也无法用字面量来表示一个接口类型的值。 对于某一个接口类型来说,如果没有任何数据类型可以作为它的实现,那么该接口的值就不可能存在。 我已经在前面展示过,通过关键字`type`和`interface`,我们可以声明出接口类型。接口类型的类型字面量与结构体类型的看起来有些相似,它们都用花括号包裹一些核心信息。只不过,结构体类型包裹的是它的字段声明,而接口类型包裹的是它的方法定义。 请注意,接口类型声明中的这些方法所代表的就是该接口的方法集合。一个接口的方法集合就是它的全部特征。 对于任何数据类型,只要它的方法集合中完全包含了一个接口的全部特征(即全部的方法),那么它就一定是这个接口的实现类型。比如下面这样: 复制 type Pet interface { SetName(name string) Name() string Category() string } 我声明了一个接口类型`Pet`,它包含了 3 个方法定义,方法名称分别为`SetName`、`Name`和`Category`。这 3 个方法共同组成了接口类型`Pet`的方法集合。 只要一个数据类型的方法集合中有这 3 个方法,那么它就一定是`Pet`接口的实现类型。这是一种无侵入式的接口实现方式。这种方式还有一个专有名词,叫“Duck typing”,中文常译作“鸭子类型”。你可以到百度的[百科页面](https://baike.baidu.com/item/%E9%B8%AD%E5%AD%90%E7%B1%BB%E5%9E%8B) 上去了解一下详情。 顺便说一句,怎样判定一个数据类型的某一个方法实现的就是某个接口类型中的某个方法呢? 这有两个充分必要条件,一个是“两个方法的签名需要完全一致”,另一个是“两个方法的名称要一模一样”。显然,这比判断一个函数是否实现了某个函数类型要更加严格一些。 如果你查阅了上篇文章附带的最后一个示例的话,那么就一定会知道,虽然结构体类型`Cat`不是`Pet`接口的实现类型,但它的指针类型`*Cat`却是这个的实现类型。 如果你还不知道原因,那么请跟着我一起来看。我已经把`Cat`类型的声明搬到了 demo31.go 文件中,并进行了一些简化,以便你看得更清楚。对了,由于`Cat`和`Pet`的发音过于相似,我还把`Cat`重命名为了`Dog`。 我声明的类型`Dog`附带了 3 个方法。其中有 2 个值方法,分别是`Name`和`Category`,另外还有一个指针方法`SetName`。 这就意味着,`Dog`类型本身的方法集合中只包含了 2 个方法,也就是所有的值方法。而它的指针类型`*Dog`方法集合却包含了 3 个方法, 也就是说,它拥有`Dog`类型附带的所有值方法和指针方法。又由于这 3 个方法恰恰分别是`Pet`接口中某个方法的实现,所以`*Dog`类型就成为了`Pet`接口的实现类型。 复制 dog := Dog{"little pig"}o var pet Pet = &dog 正因为如此,我可以声明并初始化一个`Dog`类型的变量`dog`,然后把它的指针值赋给类型为`Pet`的变量`pet`。 这里有几个名词需要你先记住。对于一个接口类型的变量来说,例如上面的变量`pet`,我们赋给它的值可以被叫做它的实际值(也称动态值),而该值的类型可以被叫做这个变量的实际类型(也称动态类型)。 比如,我们把取址表达式`&dog`的结果值赋给了变量`pet`,这时这个结果值就是变量`pet`的动态值,而此结果值的类型`*Dog`就是该变量的动态类型。 动态类型这个叫法是相对于静态类型而言的。对于变量`pet`来讲,它的静态类型就是`Pet`,并且永远是`Pet`,但是它的动态类型却会随着我们赋给它的动态值而变化。 比如,只有我把一个`*Dog`类型的值赋给变量`pet`之后,该变量的动态类型才会是`*Dog`。如果还有一个`Pet`接口的实现类型`*Fish`,并且我又把一个此类型的值赋给了`pet`,那么它的动态类型就会变为`*Fish`。 还有,在我们给一个接口类型的变量赋予实际的值之前,它的动态类型是不存在的。 你需要想办法搞清楚接口类型的变量(以下简称接口变量)的动态值、动态类型和静态类型都是什么意思。因为我会在后面基于这些概念讲解更深层次的知识。 好了,我下面会就“怎样用好 Go 语言的接口”这个话题提出一系列问题,也请你跟着我一起思考这些问题。 **那么今天的问题是:当我们为一个接口变量赋值时会发生什么?** 为了突出问题,我把`Pet`接口的声明简化了一下。 复制 type Pet interface { Name() string Category() string } 我从中去掉了`Pet`接口的那个名为`SetName`的方法。这样一来,`Dog`类型也就变成`Pet`接口的实现类型了。你可以在 demo32.go 文件中找到本问题的代码。 现在,我先声明并初始化了一个`Dog`类型的变量`dog`,这时它的`name`字段的值是`"little pig"`。然后,我把该变量赋给了一个`Pet`类型的变量`pet`。最后我通过调用`dog`的方法`SetName`把它的`name`字段的值改成了`"monster"`。 复制 dog := Dog{"little pig"} var pet Pet = dog dog.SetName("monster") 所以,我要问的具体问题是:在以上代码执行后,`pet`变量的字段`name`的值会是什么? **这个题目的典型回答是**:`pet`变量的字段`name`的值依然是`"little pig"`。 ### [](https://jums.gitbook.io/36-lectures-on-golang/14-jie-kou-lei-xing-de-he-li-yun-yong#wen-ti-jie-xi) 问题解析 首先,由于`dog`的`SetName`方法是指针方法,所以该方法持有的接收者就是指向`dog`的指针值的副本,因而其中对接收者的`name`字段的设置就是对变量`dog`的改动。那么当`dog.SetName("monster")`执行之后,`dog`的`name`字段的值就一定是`"monster"`。如果你理解到了这一层,那么请小心前方的陷阱。 为什么`dog`的`name`字段值变了,而`pet`的却没有呢?这里有一条通用的规则需要你知晓:如果我们使用一个变量给另外一个变量赋值,那么真正赋给后者的,并不是前者持有的那个值,而是该值的一个副本。 例如,我声明并初始化了一个`Dog`类型的变量`dog1`,这时它的`name`是`"little pig"`。然后,我在把`dog1`赋给变量`dog2`之后,修改了`dog1`的`name`字段的值。这时,`dog2`的`name`字段的值是什么? 复制 dog1 := Dog{"little pig"} dog2 := dog1 dog1.name = "monster" 这个问题与前面那道题几乎一样,只不过这里没有涉及接口类型。这时的`dog2`的`name`仍然会是`"little pig"`。这就是我刚刚告诉你的那条通用规则的又一个体现。 当你知道了这条通用规则之后,确实可以把前面那道题做对。不过,如果当我问你为什么的时候你只说出了这一个原因,那么,我只能说你仅仅答对了一半。 那么另一半是什么?这就需要从接口类型值的存储方式和结构说起了。我在前面说过,接口类型本身是无法被值化的。在我们赋予它实际的值之前,它的值一定会是`nil`,这也是它的零值。 反过来讲,一旦它被赋予了某个实现类型的值,它的值就不再是`nil`了。不过要注意,即使我们像前面那样把`dog`的值赋给了`pet`,`pet`的值与`dog`的值也是不同的。这不仅仅是副本与原值的那种不同。 当我们给一个接口变量赋值的时候,该变量的动态类型会与它的动态值一起被存储在一个专用的数据结构中。 严格来讲,这样一个变量的值其实是这个专用数据结构的一个实例,而不是我们赋给该变量的那个实际的值。所以我才说,`pet`的值与`dog`的值肯定是不同的,无论是从它们存储的内容,还是存储的结构上来看都是如此。不过,我们可以认为,这时`pet`的值中包含了`dog`值的副本。 我们就把这个专用的数据结构叫做`iface`吧,在 Go 语言的`runtime`包中它其实就叫这个名字。 `iface`的实例会包含两个指针,一个是指向类型信息的指针,另一个是指向动态值的指针。这里的类型信息是由另一个专用数据结构的实例承载的,其中包含了动态值的类型,以及使它实现了接口的方法和调用它们的途径,等等。 总之,接口变量被赋予动态值的时候,存储的是包含了这个动态值的副本的一个结构更加复杂的值。你明白了吗? ### [](https://jums.gitbook.io/36-lectures-on-golang/14-jie-kou-lei-xing-de-he-li-yun-yong#zhi-shi-kuo-zhan) 知识扩展 **问题 1:接口变量的值在什么情况下才真正为**`**nil**`**?** 这个问题初看起来就不是个问题。对于一个引用类型的变量,它的值是否为`nil`完全取决于我们赋给它了什么,是这样吗?我们先来看一段代码: 复制 var dog1 *Dog fmt.Println("The first dog is nil. [wrap1]") dog2 := dog1 fmt.Println("The second dog is nil. [wrap1]") var pet Pet = dog2 if pet == nil { fmt.Println("The pet is nil. [wrap1]") } else { fmt.Println("The pet is not nil. [wrap1]") } 在 demo33.go 文件的这段代码中,我先声明了一个`*Dog`类型的变量`dog1`,并且没有对它进行初始化。这时该变量的值是什么?显然是`nil`。然后我把该变量赋给了`dog2`,后者的值此时也必定是`nil`,对吗? 现在问题来了:当我把`dog2`赋给`Pet`类型的变量`pet`之后,变量`pet`的值会是什么?答案是`nil`吗? 如果你真正理解了我在上一个问题的解析中讲到的知识,尤其是接口变量赋值及其值的数据结构那部分,那么这道题就不难回答。你可以先思考一下,然后再接着往下看。 当我们把`dog2`的值赋给变量`pet`的时候,`dog2`的值会先被复制,不过由于在这里它的值是`nil`,所以就没必要复制了。 然后,Go 语言会用我上面提到的那个专用数据结构`iface`的实例包装这个`dog2`的值的副本,这里是`nil`。 虽然被包装的动态值是`nil`,但是`pet`的值却不会是`nil`,因为这个动态值只是`pet`值的一部分而已。 顺便说一句,这时的`pet`的动态类型就存在了,是`*Dog`。我们可以通过`fmt.Printf`函数和占位符`%T`来验证这一点,另外`reflect`包的`TypeOf`函数也可以起到类似的作用。 换个角度来看。我们把`nil`赋给了`pet`,但是`pet`的值却不是`nil`。 这很奇怪对吗?其实不然。在 Go 语言中,我们把由字面量`nil`表示的值叫做无类型的`nil`。这是真正的`nil`,因为它的类型也是`nil`的。虽然`dog2`的值是真正的`nil`,但是当我们把这个变量赋给`pet`的时候,Go 语言会把它的类型和值放在一起考虑。 也就是说,这时 Go 语言会识别出赋予`pet`的值是一个`*Dog`类型的`nil`。然后,Go 语言就会用一个`iface`的实例包装它,包装后的产物肯定就不是`nil`了。 只要我们把一个有类型的`nil`赋给接口变量,那么这个变量的值就一定不会是那个真正的`nil`。因此,当我们使用判等符号`==`判断`pet`是否与字面量`nil`相等的时候,答案一定会是`false`。 那么,怎样才能让一个接口变量的值真正为`nil`呢?要么只声明它但不做初始化,要么直接把字面量`nil`赋给它。 **问题 2:怎样实现接口之间的组合?** 接口类型间的嵌入也被称为接口的组合。我在前面讲过结构体类型的嵌入字段,这其实就是在说结构体类型间的嵌入。 接口类型间的嵌入要更简单一些,因为它不会涉及方法间的“屏蔽”。只要组合的接口之间有同名的方法就会产生冲突,从而无法通过编译,即使同名方法的签名彼此不同也会是如此。因此,接口的组合根本不可能导致“屏蔽”现象的出现。 与结构体类型间的嵌入很相似,我们只要把一个接口类型的名称直接写到另一个接口类型的成员列表中就可以了。比如: 复制 type Animal interface { ScientificName() string Category() string } type Pet interface { Animal Name() string } 接口类型`Pet`包含了两个成员,一个是代表了另一个接口类型的`Animal`,一个是方法`Name`的定义。它们都被包含在`Pet`的类型声明的花括号中,并且都各自独占一行。此时,`Animal`接口包含的所有方法也就成为了`Pet`接口的方法。 Go 语言团队鼓励我们声明体量较小的接口,并建议我们通过这种接口间的组合来扩展程序、增加程序的灵活性。 这是因为相比于包含很多方法的大接口而言,小接口可以更加专注地表达某一种能力或某一类特征,同时也更容易被组合在一起。 Go 语言标准库代码包`io`中的`ReadWriteCloser`接口和`ReadWriter`接口就是这样的例子,它们都是由若干个小接口组合而成的。以`io.ReadWriteCloser`接口为例,它是由`io.Reader`、`io.Writer`和`io.Closer`这三个接口组成的。 这三个接口都只包含了一个方法,是典型的小接口。它们中的每一个都只代表了一种能力,分别是读出、写入和关闭。我们编写这几个小接口的实现类型通常都会很容易。并且,一旦我们同时实现了它们,就等于实现了它们的组合接口`io.ReadWriteCloser`。 即使我们只实现了`io.Reader`和`io.Writer`,那么也等同于实现了`io.ReadWriter`接口,因为后者就是前两个接口组成的。可以看到,这几个`io`包中的接口共同组成了一个接口矩阵。它们既相互关联又独立存在。 我在 demo34.go 文件中写了一个能够体现接口组合优势的小例子,你可以去参看一下。总之,善用接口组合和小接口可以让你的程序框架更加稳定和灵活。 **总结** 好了,我们来简要总结一下。 Go 语言的接口常用于代表某种能力或某类特征。首先,我们要弄清楚的是,接口变量的动态值、动态类型和静态类型都代表了什么。这些都是正确使用接口变量的基础。当我们给接口变量赋值时,接口变量会持有被赋予值的副本,而不是它本身。 更重要的是,接口变量的值并不等同于这个可被称为动态值的副本。它会包含两个指针,一个指针指向动态值,一个指针指向类型信息。 基于此,即使我们把一个值为`nil`的某个实现类型的变量赋给了接口变量,后者的值也不可能是真正的`nil`。虽然这时它的动态值会为`nil`,但它的动态类型确是存在的。 请记住,除非我们只声明而不初始化,或者显式地赋给它`nil`,否则接口变量的值就不会为`nil`。 后面的一个问题相对轻松一些,它是关于程序设计方面的。用好小接口和接口组合总是有益的,我们可以以此形成接口矩阵,进而搭起灵活的程序框架。如果在实现接口时再配合运用结构体类型间的嵌入手法,那么接口组合就可以发挥更大的效用。 **思考题** 如果我们把一个值为`nil`的某个实现类型的变量赋给了接口变量,那么在这个接口变量上仍然可以调用该接口的方法吗?如果可以,有哪些注意事项?如果不可以,原因是什么? [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页13 | 结构体及其方法的使用法门](https://jums.gitbook.io/36-lectures-on-golang/13-jie-gou-ti-ji-qi-fang-fa-de-shi-yong-fa-men) [下一页15 | 关于指针的有限操作](https://jums.gitbook.io/36-lectures-on-golang/15-guan-yu-zhi-zhen-de-you-xian-cao-zuo) 最后更新于2年前 这有帮助吗? --- # 36 | unicode与字符编码 | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252Fcl9CXAnEjsSx5QpYXXw1%252Fimage.png%3Falt%3Dmedia%26token%3D7358c4fe-b60e-4c7e-86e6-f1c582512e24&width=768&dpr=4&quality=100&sign=b5c249&sv=2) 到目前为止,我们已经一起陆陆续续地学完了 Go 语言中那些最重要也最有特色的概念、语法和编程方式。我对于它们非常喜爱,简直可以用如数家珍来形容了。在开始今天的内容之前,我先来做一个简单的总结。 基于混合线程的并发编程模型自然不必多说。在数据类型方面,有基于底层数组的切片、用来传递数据的通道、作为一等类型的函数、可实现面向对象的结构体、能无侵入实现的接口等。 在语法方面有,异步编程神器`go`语句、函数的最后关卡`defer`语句、可做类型判断的`switch`语句、多通道操作利器`select`语句,以及非常有特色的异常处理函数`panic`和`recover`。 除了这些,我们还一起讨论了测试 Go 程序的主要方式。这涉及了 Go 语言自带的程序测试套件,相关的概念和工具包括:独立的测试源码文件、三种功用不同的测试函数、专用的`testing`代码包,以及功能强大的`go test`命令。 另外,就在前不久,我还为你深入讲解了 Go 语言提供的那些同步工具。它们也是 Go 语言并发编程工具箱中不可或缺的一部分。 这包括了经典的互斥锁、读写锁、条件变量和原子操作,以及 Go 语言特有的一些数据类型,即:单次执行小助手`sync.Once`、临时对象池`sync.Pool`、可帮助我们实现多 goroutine 协作流程的`sync.WaitGroup`和`context.Context`。另外,还有一种高效的并发安全字典`sync.Map`。 毫不夸张地说,如果你真正地掌握了上述这些知识,那么就已经获得了 Go 语言编程的精髓。在这之后,你再去研读 Go 语言标准库和那些优秀第三方库中的代码的时候,就一定会事半功倍。同时,在使用 Go 语言编写软件的时候,你肯定也会如鱼得水、游刃有余的。 * * * 我用了大量的篇幅讲解了 Go 语言中最核心的知识点,真心希望你已经搞懂了这些内容。在后面的日子里,我会与你一起去探究 Go 语言标准库中最常用的那些代码包,弄清它们的用法、了解它们的机理。当然了,我还会顺便讲一讲那些必备的周边知识。 首先,让我们来关注字符编码方面的问题。这应该是在计算机软件领域中非常基础的一个问题了。 我在前面说过,Go 语言中的标识符可以包含任何 Unicode 编码可以表示的字母字符。我还说过,虽然我们可以直接把一个整数值转换为一个`string`类型的值,但是被转换的整数值应该可以代表一个有效的 Unicode 代码点,否则转换的结果就将会是`"�"`,即:一个仅由高亮的问号组成的字符串值。 另外,当一个`string`类型的值被转换为`[]rune`类型值的时候,其中的字符串会被拆分成一个一个的 Unicode 字符。 显然,Go 语言采用的字符编码方案从属于 Unicode 编码规范。更确切地说,Go 语言的代码正是由 Unicode 字符组成的。Go 语言的所有源代码,都必须按照 Unicode 编码规范中的 UTF-8 编码格式进行编码。 换句话说,Go 语言的源码文件必须使用 UTF-8 编码格式进行存储。如果源码文件中出现了非 UTF-8 编码的字符,那么在构建、安装以及运行的时候,go 命令就会报告错误“illegal UTF-8 encoding”。 在这里,我们首先要对 Unicode 编码规范有所了解。不过,在讲述它之前,我先来简要地介绍一下 ASCII 编码。 ### [](https://jums.gitbook.io/36-lectures-on-golang/36-unicode-yu-zi-fu-bian-ma#ascii-bian-ma) ASCII 编码 ASCII 是英文“American Standard Code for Information Interchange”的缩写,中文译为美国信息交换标准代码。它是由美国国家标准学会(ANSI)制定的单字节字符编码方案,可用于基于文本的数据交换。 它最初是美国的国家标准,后又被国际标准化组织(ISO)定为国际标准,称为 ISO 646 标准,并适用于所有的拉丁文字字母。 ASCII 编码方案使用单个字节(byte)的二进制数来编码一个字符。标准的 ASCII 编码用一个字节的最高比特(bit)位作为奇偶校验位,而扩展的 ASCII 编码则将此位也用于表示字符。ASCII 编码支持的可打印字符和控制字符的集合也被叫做 ASCII 编码集。 我们所说的 Unicode 编码规范,实际上是另一个更加通用的、针对书面字符和文本的字符编码标准。它为世界上现存的所有自然语言中的每一个字符,都设定了一个唯一的二进制编码。它定义了不同自然语言的文本数据在国际间交换的统一方式,并为全球化软件创建了一个重要的基础。 Unicode 编码规范以 ASCII 编码集为出发点,并突破了 ASCII 只能对拉丁字母进行编码的限制。它不但提供了可以对世界上超过百万的字符进行编码的能力,还支持所有已知的转义序列和控制代码。 我们都知道,在计算机系统的内部,抽象的字符会被编码为整数。这些整数的范围被称为代码空间。在代码空间之内,每一个特定的整数都被称为一个代码点。一个受支持的抽象字符会被映射并分配给某个特定的代码点,反过来讲,一个代码点总是可以被看成一个被编码的字符。 Unicode 编码规范通常使用十六进制表示法来表示 Unicode 代码点的整数值,并使用“U+”作为前缀。比如,英文字母字符“a”的 Unicode 代码点是 U+0061。在 Unicode 编码规范中,一个字符能且只能由与它对应的那个代码点表示。 Unicode 编码规范现在的最新版本是 11.0,并会于 2019 年 3 月发布 12.0 版本。而 Go 语言从 1.10 版本开始,已经对 Unicode 的 10.0 版本提供了全面的支持。对于绝大多数的应用场景来说,这已经完全够用了。 Unicode 编码规范提供了三种不同的编码格式,即:UTF-8、UTF-16 和 UTF-32。其中的 UTF 是 UCS Transformation Format 的缩写。而 UCS 又是 Universal Character Set 的缩写,但也可以代表 Unicode Character Set。所以,UTF 也可以被翻译为 Unicode 转换格式。它代表的是字符与字节序列之间的转换方式。 在这几种编码格式的名称中,“-”右边的整数的含义是,以多少个比特位作为一个编码单元。以 UTF-8 为例,它会以 8 个比特,也就是一个字节,作为一个编码单元。并且,它与标准的 ASCII 编码是完全兼容的。也就是说,在 \[0x00, 0x7F\] 的范围内,这两种编码表示的字符都是相同的。这也是 UTF-8 编码格式的一个巨大优势。 UTF-8 是一种可变宽的编码方案。换句话说,它会用一个或多个字节的二进制数来表示某个字符,最多使用四个字节。比如,对于一个英文字符,它仅用一个字节的二进制数就可以表示,而对于一个中文字符,它需要使用三个字节才能够表示。不论怎样,一个受支持的字符总是可以由 UTF-8 编码为一个字节序列。以下会简称后者为 UTF-8 编码值。 现在,在你初步地了解了这些知识之后,请认真地思考并回答下面的问题。别担心,我会在后面进一步阐述 Unicode、UTF-8 以及 Go 语言对它们的运用。 **问题:一个**`**string**`**类型的值在底层是怎样被表达的?** **典型回答** 是在底层,一个`string`类型的值是由一系列相对应的 Unicode 代码点的 UTF-8 编码值来表达的。 ### [](https://jums.gitbook.io/36-lectures-on-golang/36-unicode-yu-zi-fu-bian-ma#wen-ti-jie-xi) 问题解析 在 Go 语言中,一个`string`类型的值既可以被拆分为一个包含多个字符的序列,也可以被拆分为一个包含多个字节的序列。前者可以由一个以`rune`为元素类型的切片来表示,而后者则可以由一个以`byte`为元素类型的切片代表。 `rune`是 Go 语言特有的一个基本数据类型,它的一个值就代表一个字符,即:一个 Unicode 字符。比如,`'G'`、`'o'`、`'爱'`、`'好'`、`'者'`代表的就都是一个 Unicode 字符。 我们已经知道,UTF-8 编码方案会把一个 Unicode 字符编码为一个长度在 \[1, 4\] 范围内的字节序列。所以,一个`rune`类型的值也可以由一个或多个字节来代表。 复制 type rune = int32 根据`rune`类型的声明可知,它实际上就是`int32`类型的一个别名类型。也就是说,一个`rune`类型的值会由四个字节宽度的空间来存储。它的存储空间总是能够存下一个 UTF-8 编码值。 一个`rune`类型的值在底层其实就是一个 UTF-8 编码值。前者是(便于我们人类理解的)外部展现,后者是(便于计算机系统理解的)内在表达。 请看下面的代码: 复制 str := "Go 爱好者 " fmt.Printf("The string: %q\n", str) fmt.Printf(" => runes(char): %q\n", []rune(str)) fmt.Printf(" => runes(hex): %x\n", []rune(str)) fmt.Printf(" => bytes(hex): [% x]\n", []byte(str)) 字符串值`"Go 爱好者"`如果被转换为`[]rune`类型的值的话,其中的每一个字符(不论是英文字符还是中文字符)就都会独立成为一个`rune`类型的元素值。因此,这段代码打印出的第二行内容就会如下所示: 复制 => runes(char): ['G' 'o' '爱' '好' '者'] 又由于,每个`rune`类型的值在底层都是由一个 UTF-8 编码值来表达的,所以我们可以换一种方式来展现这个字符序列: 复制 => runes(hex): [47 6f 7231 597d 8005] 可以看到,五个十六进制数与五个字符相对应。很明显,前两个十六进制数`47`和`6f`代表的整数都比较小,它们分别表示字符`'G'`和`'o'`。因为它们都是英文字符,所以对应的 UTF-8 编码值用一个字节表达就足够了。一个字节的编码值被转换为整数之后,不会大到哪里去。 而后三个十六进制数`7231`、`597d`和`8005`都相对较大,它们分别表示中文字符`'爱'`、`'好'`和`'者'`。这些中文字符对应的 UTF-8 编码值,都需要使用三个字节来表达。所以,这三个数就是把对应的三个字节的编码值,转换为整数后得到的结果。 我们还可以进一步地拆分,把每个字符的 UTF-8 编码值都拆成相应的字节序列。上述代码中的第五行就是这么做的。它会得到如下的输出: 复制 => bytes(hex): [47 6f e7 88 b1 e5 a5 bd e8 80 85] 这里得到的字节切片比前面的字符切片明显长了很多。这正是因为一个中文字符的 UTF-8 编码值需要用三个字节来表达。这个字节切片的前两个元素值与字符切片的前两个元素值是一致的,而在这之后,前者的每三个元素值才对应字符切片中的一个元素值。 注意,对于一个多字节的 UTF-8 编码值来说,我们可以把它当做一个整体转换为单一的整数,也可以先把它拆成字节序列,再把每个字节分别转换为一个整数,从而得到多个整数。 这两种表示法展现出来的内容往往会很不一样。比如,对于中文字符`'爱'`来说,它的 UTF-8 编码值可以展现为单一的整数`7231`,也可以展现为三个整数,即:`e7`、`88`和`b1`。 总之,一个`string`类型的值会由若干个 Unicode 字符组成,每个 Unicode 字符都可以由一个`rune`类型的值来承载。 这些字符在底层都会被转换为 UTF-8 编码值,而这些 UTF-8 编码值又会以字节序列的形式表达和存储。因此,一个`string`类型的值在底层就是一个能够表达若干个 UTF-8 编码值的字节序列。 ### [](https://jums.gitbook.io/36-lectures-on-golang/36-unicode-yu-zi-fu-bian-ma#zhi-shi-kuo-zhan) 知识扩展 **问题 1:使用带有**`**range**`**子句的**`**for**`**语句遍历字符串值的时候应该注意什么?** 带有`range`子句的`for`语句会先把被遍历的字符串值拆成一个字节序列,然后再试图找出这个字节序列中包含的每一个 UTF-8 编码值,或者说每一个 Unicode 字符。 这样的`for`语句可以为两个迭代变量赋值。如果存在两个迭代变量,那么赋给第一个变量的值就将会是当前字节序列中的某个 UTF-8 编码值的第一个字节所对应的那个索引值。而赋给第二个变量的值则是这个 UTF-8 编码值代表的那个 Unicode 字符,其类型会是`rune`。 例如,有这么几行代码: 复制 str := "Go 爱好者 " for i, c := range str { fmt.Printf("%d: %q [% x]\n", i, c, []byte(string(c))) } 这里被遍历的字符串值是`"Go 爱好者"`。在每次迭代的时候,这段代码都会打印出两个迭代变量的值,以及第二个值的字节序列形式。完整的打印内容如下: 复制 0: 'G' [47] 1: 'o' [6f] 2: '爱' [e7 88 b1] 5: '好' [e5 a5 bd] 8: '者' [e8 80 85] 第一行内容中的关键信息有`0`、`'G'`和`[47]`。这是由于这个字符串值中的第一个 Unicode 字符是`'G'`。该字符是一个单字节字符,并且由相应的字节序列中的第一个字节表达。这个字节的十六进制表示为`47`。 第二行展示的内容与之类似,即:第二个 Unicode 字符是`'o'`,由字节序列中的第二个字节表达,其十六进制表示为`6f`。再往下看,第三行展示的是`'爱'`,也是第三个 Unicode 字符。因为它是一个中文字符,所以由字节序列中的第三、四、五个字节共同表达,其十六进制表示也不再是单一的整数,而是`e7`、`88`和`b1`组成的序列。 下面要注意了,正是因为`'爱'`是由三个字节共同表达的,所以第四个 Unicode 字符`'好'`对应的索引值并不是`3`,而是`2`加`3`后得到的`5`。 这里的`2`代表的是`'爱'`对应的索引值,而`3`代表的则是`'爱'`对应的 UTF-8 编码值的宽度。对于这个字符串值中的最后一个字符`'者'`来说也是类似的,因此,它对应的索引值是`8`。 由此可以看出,这样的`for`语句可以逐一地迭代出字符串值里的每个 Unicode 字符。但是,相邻的 Unicode 字符的索引值并不一定是连续的。这取决于前一个 Unicode 字符是否为单字节字符。 正因为如此,如果我们想得到其中某个 Unicode 字符对应的 UTF-8 编码值的宽度,就可以用下一个字符的索引值减去当前字符的索引值。 初学者可能会对`for`语句的这种行为感到困惑,因为它给予两个迭代变量的值看起来并不总是对应的。不过,一旦我们了解了它的内在机制就会拨云见日、豁然开朗。 ### [](https://jums.gitbook.io/36-lectures-on-golang/36-unicode-yu-zi-fu-bian-ma#zong-jie) 总结 我们今天把目光聚焦在了 Unicode 编码规范、UTF-8 编码格式,以及 Go 语言对字符串和字符的相关处理方式上。 Go 语言的代码是由 Unicode 字符组成的,它们都必须由 Unicode 编码规范中的 UTF-8 编码格式进行编码并存储,否则就会导致 go 命令的报错。 Unicode 编码规范中的编码格式定义的是字符与字节序列之间的转换方式。其中的 UTF-8 是一种可变宽的编码方案。它会用一个或多个字节的二进制数来表示某个字符,最多使用四个字节。一个受支持的字符总是可以由 UTF-8 编码为一个字节序列,后者也可以被称为 UTF-8 编码值。 Go 语言中的一个`string`类型值会由若干个 Unicode 字符组成,每个 Unicode 字符都可以由一个`rune`类型的值来承载。 这些字符在底层都会被转换为 UTF-8 编码值,而这些 UTF-8 编码值又会以字节序列的形式表达和存储。因此,一个`string`类型的值在底层就是一个能够表达若干个 UTF-8 编码值的字节序列。 初学者可能会对带有`range`子句的`for`语句遍历字符串值的行为感到困惑,因为它给予两个迭代变量的值看起来并不总是对应的。但事实并非如此。 这样的`for`语句会先把被遍历的字符串值拆成一个字节序列,然后再试图找出这个字节序列中包含的每一个 UTF-8 编码值,或者说每一个 Unicode 字符。相邻的 Unicode 字符的索引值并不一定是连续的。这取决于前一个 Unicode 字符是否为单字节字符。一旦我们清楚了这些内在机制就不会再困惑了。 对于 Go 语言来说,Unicode 编码规范和 UTF-8 编码格式算是基础之一了。我们应该了解到它们对 Go 语言的重要性。这对于正确理解 Go 语言中的相关数据类型以及日后的相关程序编写都会很有好处。 ### [](https://jums.gitbook.io/36-lectures-on-golang/36-unicode-yu-zi-fu-bian-ma#si-kao-ti) 思考题 今天的思考题是:判断一个 Unicode 字符是否为单字节字符通常有几种方式? [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页35 | 并发安全字典sync.Map (下)](https://jums.gitbook.io/36-lectures-on-golang/35-bing-fa-an-quan-zi-dian-sync.map-xia) [下一页37 | strings包与字符串操作](https://jums.gitbook.io/36-lectures-on-golang/37-strings-bao-yu-zi-fu-chuan-cao-zuo) 最后更新于2年前 这有帮助吗? --- # 40 | io包中的接口和工具 (上) | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F86%2F37%2F86d40335594d8bfc37e80d0fcbfb9e37.jpg&width=768&dpr=4&quality=100&sign=122d784e&sv=2) 我们在前几篇文章中,主要讨论了`strings.Builder`、`strings.Reader`和`bytes.Buffer`这三个数据类型。 还记得吗?当时我还问过你“它们都实现了哪些接口”。在我们继续讲解`io`包中的接口和工具之前,我先来解答一下这个问题。 `strings.Builder`类型主要用于构建字符串,它的指针类型实现的接口有`io.Writer`、`io.ByteWriter`和`fmt.Stringer`。另外,它其实还实现了一个`io`包的包级私有接口`io.stringWriter`。 `strings.Reader`类型主要用于读取字符串,它的指针类型实现的接口比较多,包括: 1. `io.Reader`; 2. `io.ReaderAt`; 3. `io.ByteReader`; 4. `io.RuneReader`; 5. `io.Seeker`; 6. `io.ByteScanner`; 7. `io.RuneScanner`; 8. `io.WriterTo`; 共有 8 个,它们都是`io`包中的接口。 其中,`io.ByteScanner`是`io.ByteReader`的扩展接口,而`io.RuneScanner`又是`io.RuneReader`的扩展接口。 `bytes.Buffer`是集读、写功能于一身的数据类型,它非常适合作为字节序列的缓冲区。它的指针类型实现的接口就更多了。更具体地说,该指针类型实现的读取相关的接口有下面几个。 1. `io.Reader`; 2. `io.ByteReader`; 3. `io.RuneReader`; 4. `io.ByteScanner`; 5. `io.RuneScanner`; 6. `io.WriterTo`; 共有 6 个。而其实现的写入相关的接口则有这些。 1. `io.Writer`; 2. `io.ByteWriter`; 3. `io.stringWriter`; 4. `io.ReaderFrom`; 共 4 个。此外,它还实现了导出相关的接口`fmt.Stringer`。 那么,这些类型实现了这么多的接口,其动机(或者说目的)究竟是什么呢?简单地说,这是为了提高不同程序实体之间的互操作性。远的不说,我们就以`io`包中的一些函数为例。 在`io`包中,有这样几个用于拷贝数据的函数,它们是: `io.Copy`、`io.CopyBuffer`和`io.CopyN`。 虽然这几个函数在功能上都略有差别,但是它们都首先会接受两个参数,即:用于代表数据目的地、`io.Writer`类型的参数`dst`,以及用于代表数据来源的、`io.Reader`类型的参数`src`。这些函数的功能大致上都是把数据从`src`拷贝到`dst`。 不论我们给予它们的第一个参数值是什么类型的,只要这个类型实现了`io.Writer`接口即可。同样的,无论我们传给它们的第二个参数值的实际类型是什么,只要该类型实现了`io.Reader`接口就行。 一旦我们满足了这两个条件,这些函数几乎就可以正常地执行了。当然了,函数中还会对必要的参数值进行有效性的检查,如果检查不通过,它的执行也是不能够成功结束的。 下面来看一段示例代码: 复制 src := strings.NewReader( "CopyN copies n bytes (or until an error) from src to dst. " + "It returns the number of bytes copied and " + "the earliest error encountered while copying.") dst := new(strings.Builder) written, err := io.CopyN(dst, src, 58) if err != nil { fmt.Printf("error: %v\n", err) } else { fmt.Printf("Written(%d): %q\n", written, dst.String()) } 我先使用`strings.NewReader`创建了一个字符串读取器,并把它赋给了变量`src`,然后我又`new`了一个字符串构建器,并将其赋予了变量`dst`。 之后,我在调用`io.CopyN`函数的时候,把这两个变量的值都传了进去,同时把给这个函数的第三个参数值设定为了`58`。也就是说,我想从`src`中拷贝前`58`个字节到`dst`那里。 虽然,变量`src`和`dst`的类型分别是`strings.Reader`和`strings.Builder`,但是当它们被传到`io.CopyN`函数的时候,就已经分别被包装成了`io.Reader`类型和`io.Writer`类型的值。`io.CopyN`函数也根本不会去在意它们的实际类型到底是什么。 为了优化的目的,`io.CopyN`函数中的代码会对参数值进行再包装,也会检测这些参数值是否还实现了别的接口,甚至还会去探求某个参数值被包装后的实际类型,是否为某个特殊的类型。但是,从总体上来看,这些代码都是面向参数声明中的接口来做的。 `io.CopyN`函数的作者通过面向接口编程,极大地拓展了它的适用范围和应用场景。换个角度看,正因为`strings.Reader`类型和`strings.Builder`类型都实现了不少接口,所以它们的值才能够被使用在更广阔的场景中。 换句话说,如此一来,Go 语言的各种库中,能够操作它们的函数和数据类型明显多了很多。 这就是我想要告诉你的,`strings`包和`bytes`包中的数据类型在实现了若干接口之后得到的最大好处。也可以说,这就是面向接口编程带来的最大优势。这些数据类型和函数的做法,也是非常值得我们在编程的过程中去效仿的。 可以看到,前文所述的几个类型实现的大都是`io`代码包中的接口。实际上,`io`包中的接口对于 Go 语言的标准库和很多第三方库而言,都起着举足轻重的作用。它们非常基础也非常重要。 就拿`io.Reader`和`io.Writer`这两个最核心的接口来说,它们是很多接口的扩展对象和设计源泉。同时,单从 Go 语言的标准库中统计,实现了它们的数据类型都(各自)有上百个,而引用它们的代码更是都(各自)有 400 多处。 很多数据类型实现了`io.Reader`接口,是因为它们提供了从某处读取数据的功能。类似的,许多能够把数据写入某处的数据类型,也都会去实现`io.Writer`接口。 其实,有不少类型的设计初衷都是实现这两个核心接口的某个或某些扩展接口,以提供比单纯的字节序列读取或写入更加丰富的功能,就像前面讲到的那几个`strings`包和`bytes`包中的数据类型那样。 在 Go 语言中,对接口的扩展是通过接口类型之间的嵌入来实现的,这也常被叫做接口的组合。我在讲接口的时候也提到过,Go 语言提倡使用小接口加接口组合的方式,来扩展程序的行为以及增加程序的灵活性。`io`代码包恰恰就可以作为这样的一个标杆,它可以成为我们运用这种技巧时的一个参考标准。 下面,我就以`io.Reader`接口为对象提出一个与接口扩展和实现有关的问题。如果你研究过这个核心接口以及相关的数据类型的话,这个问题回答起来就并不困难。 **我们今天的问题是:在**`**io**`**包中,**`**io.Reader**`**的扩展接口和实现类型都有哪些?它们分别都有什么功用?** 这道题的**典型回答**是这样的。在`io`包中,`io.Reader`的扩展接口有下面几种。 1. `io.ReadWriter`:此接口既是`io.Reader`的扩展接口,也是`io.Writer`的扩展接口。换句话说,该接口定义了一组行为,包含且仅包含了基本的字节序列读取方法`Read`,和字节序列写入方法`Write`。 2. `io.ReadCloser`:此接口除了包含基本的字节序列读取方法之外,还拥有一个基本的关闭方法`Close`。后者一般用于关闭数据读写的通路。这个接口其实是`io.Reader`接口和`io.Closer`接口的组合。 3. `io.ReadWriteCloser`:很明显,此接口是`io.Reader`、`io.Writer`和`io.Closer`这三个接口的组合。 4. `io.ReadSeeker`:此接口的特点是拥有一个用于寻找读写位置的基本方法`Seek`。更具体地说,该方法可以根据给定的偏移量基于数据的起始位置、末尾位置,或者当前读写位置去寻找新的读写位置。这个新的读写位置用于表明下一次读或写时的起始索引。`Seek`是`io.Seeker`接口唯一拥有的方法。 5. `io.ReadWriteSeeker`:显然,此接口是另一个三合一的扩展接口,它是`io.Reader`、`io.Writer`和`io.Seeker`的组合。 再来说说`io`包中的`io.Reader`接口的实现类型,它们包括下面几项内容。 1. `*io.LimitedReader`:此类型的基本类型会包装`io.Reader`类型的值,并提供一个额外的受限读取的功能。所谓的受限读取指的是,此类型的读取方法`Read`返回的总数据量会受到限制,无论该方法被调用多少次。这个限制由该类型的字段`N`指明,单位是字节。 2. `*io.SectionReader`:此类型的基本类型可以包装`io.ReaderAt`类型的值,并且会限制它的`Read`方法,只能够读取原始数据中的某一个部分(或者说某一段)。 这个数据段的起始位置和末尾位置,需要在它被初始化的时候就指明,并且之后无法变更。该类型值的行为与切片有些类似,它只会对外暴露在其窗口之中的那些数据。 3. `*io.teeReader`:此类型是一个包级私有的数据类型,也是`io.TeeReader`函数结果值的实际类型。这个函数接受两个参数`r`和`w`,类型分别是`io.Reader`和`io.Writer`。 其结果值的`Read`方法会把`r`中的数据经过作为方法参数的字节切片`p`写入到`w`。可以说,这个值就是`r`和`w`之间的数据桥梁,而那个参数`p`就是这座桥上的数据搬运者。 4. `io.multiReader`:此类型也是一个包级私有的数据类型。类似的,`io`包中有一个名为`MultiReader`的函数,它可以接受若干个`io.Reader`类型的参数值,并返回一个实际类型为`io.multiReader`的结果值。 当这个结果值的`Read`方法被调用时,它会顺序地从前面那些`io.Reader`类型的参数值中读取数据。因此,我们也可以称之为多对象读取器。 5. `io.pipe`:此类型为一个包级私有的数据类型,它比上述类型都要复杂得多。它不但实现了`io.Reader`接口,而且还实现了`io.Writer`接口。 实际上,`io.PipeReader`类型和`io.PipeWriter`类型拥有的所有指针方法都是以它为基础的。这些方法都只是代理了`io.pipe`类型值所拥有的某一个方法而已。 又因为`io.Pipe`函数会返回这两个类型的指针值并分别把它们作为其生成的同步内存管道的两端,所以可以说,`*io.pipe`类型就是`io`包提供的同步内存管道的核心实现。 6. `io.PipeReader`:此类型可以被视为`io.pipe`类型的代理类型。它代理了后者的一部分功能,并基于后者实现了`io.ReadCloser`接口。同时,它还定义了同步内存管道的读取端。 注意,我在这里忽略掉了测试源码文件中的实现类型,以及不会以任何形式直接对外暴露的那些实现类型。 ### [](https://jums.gitbook.io/36-lectures-on-golang/40-io-bao-zhong-de-jie-kou-he-gong-ju-shang#wen-ti-jie-xi) 问题解析 我问这个问题的目的主要是评估你对`io`包的熟悉程度。这个代码包是 Go 语言标准库中所有 I/O 相关 API 的根基,所以,我们必须对其中的每一个程序实体都有所了解。 然而,由于该包包含的内容众多,因此这里的问题是以`io.Reader`接口作为切入点的。通过`io.Reader`接口,我们应该能够梳理出基于它的类型树,并知晓其中每一个类型的功用。 `io.Reader`可谓是`io`包乃至是整个 Go 语言标准库中的核心接口,所以我们可以从它那里牵扯出很多扩展接口和实现类型。 我在本问题的典型回答中,为你罗列和介绍了`io`包范围内的相关数据类型。这些类型中的每一个都值得你认真去理解,尤其是那几个实现了`io.Reader`接口的类型。它们实现的功能在细节上都各有不同。 在很多时候,我们可以根据实际需求将它们搭配起来使用。例如,对施加在原始数据之上的(由`Read`方法提供的)读取功能进行多层次的包装(比如受限读取和多对象读取等),以满足较为复杂的读取需求。 在实际的面试中,只要应聘者能够从某一个方面出发,说出`io.Reader`的扩展接口及其存在意义,或者说清楚该接口的三五个实现类型,那么就可以算是基本回答正确了。 比如,从读取、写入、关闭这一些列的基本功能出发,描述清楚 复制 `io.ReadWriter`、`io.ReadCloser`、`io.ReadWriteCloser` 这几个接口。 又比如,说明白`io.LimitedReader`和`io.SectionReader`这两个类型之间的异同点。再比如,阐述`*io.SectionReader`类型实现`io.ReadSeeker`接口的具体方式,等等。不过,这只是合格的门槛,应聘者回答得越全面越好。 我在示例文件 demo82.go 中写了一些代码,以展示上述类型的一些基本用法,供你参考。 ### [](https://jums.gitbook.io/36-lectures-on-golang/40-io-bao-zhong-de-jie-kou-he-gong-ju-shang#zong-jie) 总结 我们今天一直在讨论和梳理`io`代码包中的程序实体,尤其是那些重要的接口及其实现类型。 `io`包中的接口对于 Go 语言的标准库和很多第三方库而言,都起着举足轻重的作用。其中最核心的`io.Reader`接口和`io.Writer`接口,是很多接口的扩展对象或设计源泉。我们下一节会继续讲解`io`包中的接口内容。 你用过哪些`io`包中的接口和工具呢,又有哪些收获和感受呢,你可以给我留言,我们一起讨论。感谢你的收听,我们下次再见。 [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页39 | bytes包与字节串操作(下)](https://jums.gitbook.io/36-lectures-on-golang/39-bytes-bao-yu-zi-jie-chuan-cao-zuo-xia) [下一页41 | io包中的接口和工具 (下)](https://jums.gitbook.io/36-lectures-on-golang/41-io-bao-zhong-de-jie-kou-he-gong-ju-xia) 最后更新于2年前 这有帮助吗? --- # 47 | 基于HTTP协议的网络服务 | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252FLAFUX37AZklYVqRlmmF8%252Fimage.png%3Falt%3Dmedia%26token%3Da5809fe1-3da2-4591-a8fc-bb1ef40e52e1&width=768&dpr=4&quality=100&sign=e613f153&sv=2) 我们在上一篇文章中简单地讨论了网络编程和 socket,并由此提及了 Go 语言标准库中的`syscall`代码包和`net`代码包。 我还重点讲述了`net.Dial`函数和`syscall.Socket`函数的参数含义。前者间接地调用了后者,所以正确理解后者,会对用好前者有很大裨益。 之后,我们把视线转移到了`net.DialTimeout`函数以及它对操作超时的处理上,这又涉及了`net.Dialer`类型。实际上,这个类型正是`net`包中这两个“拨号”函数的底层实现。 我们像上一篇文章的示例代码那样用`net.Dial`或`net.DialTimeout`函数来访问基于 HTTP 协议的网络服务是完全没有问题的。HTTP 协议是基于 TCP/IP 协议栈的,并且它也是一个面向普通文本的协议。 原则上,我们使用任何一个文本编辑器,都可以轻易地写出一个完整的 HTTP 请求报文。只要你搞清楚了请求报文的头部(header)和主体(body)应该包含的内容,这样做就会很容易。所以,在这种情况下,即便直接使用`net.Dial`函数,你应该也不会感觉到困难。 不过,不困难并不意味着很方便。如果我们只是访问基于 HTTP 协议的网络服务的话,那么使用`net/http`代码包中的程序实体来做,显然会更加便捷。 其中,最便捷的是使用`http.Get`函数。我们在调用它的时候只需要传给它一个 URL 就可以了,比如像下面这样: 复制 复制代码 `http.Get`函数会返回两个结果值。第一个结果值的类型是`*http.Response`,它是网络服务给我们传回来的响应内容的结构化表示。 第二个结果值是`error`类型的,它代表了在创建和发送 HTTP 请求,以及接收和解析 HTTP 响应的过程中可能发生的错误。 `http.Get`函数会在内部使用缺省的 HTTP 客户端,并且调用它的`Get`方法以完成功能。这个缺省的 HTTP 客户端是由`net/http`包中的公开变量`DefaultClient`代表的,其类型是`*http.Client`。它的基本类型也是可以被拿来使用的,甚至它还是开箱即用的。下面的这两行代码: 复制 复制代码 与前面的这一行代码 复制 复制代码 是等价的。 `http.Client`是一个结构体类型,并且它包含的字段都是公开的。之所以该类型的零值仍然可用,是因为它的这些字段要么存在着相应的缺省值,要么其零值直接就可以使用,且代表着特定的含义。 现在,我问你一个问题,是关于这个类型中的最重要的一个字段的。 **今天的问题是:**`**http.Client**`**类型中的**`**Transport**`**字段代表着什么?** 这道题的**典型回答**是这样的。 `http.Client`类型中的`Transport`字段代表着:向网络服务发送 HTTP 请求,并从网络服务接收 HTTP 响应的操作过程。也就是说,该字段的方法`RoundTrip`应该实现单次 HTTP 事务(或者说基于 HTTP 协议的单次交互)需要的所有步骤。 这个字段是`http.RoundTripper`接口类型的,它有一个由`http.DefaultTransport`变量代表的缺省值(以下简称`DefaultTransport`)。当我们在初始化一个`http.Client`类型的值(以下简称`Client`值)的时候,如果没有显式地为该字段赋值,那么这个`Client`值就会直接使用`DefaultTransport`。 顺便说一下,`http.Client`类型的`Timeout`字段,代表的正是前面所说的单次 HTTP 事务的超时时间,它是`time.Duration`类型的。它的零值是可用的,用于表示没有设置超时时间。 ### [](https://jums.gitbook.io/36-lectures-on-golang/47-ji-yu-http-xie-yi-de-wang-luo-fu-wu#wen-ti-jie-xi) 问题解析 下面,我们再通过该字段的缺省值`DefaultTransport`,来深入地了解一下这个`Transport`字段。 `DefaultTransport`的实际类型是`*http.Transport`,后者即为`http.RoundTripper`接口的默认实现。这个类型是可以被复用的,也推荐被复用,同时,它也是并发安全的。正因为如此,`http.Client`类型也拥有着同样的特质。 `http.Transport`类型,会在内部使用一个`net.Dialer`类型的值(以下简称`Dialer`值),并且,它会把该值的`Timeout`字段的值,设定为`30`秒。 也就是说,这个`Dialer`值如果在 30 秒内还没有建立好网络连接,那么就会被判定为操作超时。在`DefaultTransport`的值被初始化的时候,这样的`Dialer`值的`DialContext`方法会被赋给前者的`DialContext`字段。 `http.Transport`类型还包含了很多其他的字段,其中有一些字段是关于操作超时的。 * `IdleConnTimeout`:含义是空闲的连接在多久之后就应该被关闭。 * `DefaultTransport`会把该字段的值设定为`90`秒。如果该值为`0`,那么就表示不关闭空闲的连接。注意,这样很可能会造成资源的泄露。 * `ResponseHeaderTimeout`:含义是,从客户端把请求完全递交给操作系统到从操作系统那里接收到响应报文头的最大时长。`DefaultTransport`并没有设定该字段的值。 * `ExpectContinueTimeout`:含义是,在客户端递交了请求报文头之后,等待接收第一个响应报文头的最长时间。在客户端想要使用 HTTP 的“POST”方法把一个很大的报文体发送给服务端的时候,它可以先通过发送一个包含了“Expect: 100-continue”的请求报文头,来询问服务端是否愿意接收这个大报文体。这个字段就是用于设定在这种情况下的超时时间的。注意,如果该字段的值不大于`0`,那么无论多大的请求报文体都将会被立即发送出去。这样可能会造成网络资源的浪费。`DefaultTransport`把该字段的值设定为了`1`秒。 * `TLSHandshakeTimeout`:TLS 是 Transport Layer Security 的缩写,可以被翻译为传输层安全。这个字段代表了基于 TLS 协议的连接在被建立时的握手阶段的超时时间。若该值为`0`,则表示对这个时间不设限。`DefaultTransport`把该字段的值设定为了`10`秒。 此外,还有一些与`IdleConnTimeout`相关的字段值得我们关注,即:`MaxIdleConns`、`MaxIdleConnsPerHost`以及`MaxConnsPerHost`。 无论当前的`http.Transport`类型的值(以下简称`Transport`值)访问了多少个网络服务,`MaxIdleConns`字段都只会对空闲连接的总数做出限定。而`MaxIdleConnsPerHost`字段限定的则是,该`Transport`值访问的每一个网络服务的最大空闲连接数。 每一个网络服务都会有自己的网络地址,可能会使用不同的网络协议,对于一些 HTTP 请求也可能会用到代理。`Transport`值正是通过这三个方面的具体情况,来鉴别不同的网络服务的。 `MaxIdleConnsPerHost`字段的缺省值,由`http.DefaultMaxIdleConnsPerHost`变量代表,值为`2`。也就是说,在默认情况下,对于某一个`Transport`值访问的每一个网络服务,它的空闲连接数都最多只能有两个。 与`MaxIdleConnsPerHost`字段的含义相似的,是`MaxConnsPerHost`字段。不过,后者限制的是,针对某一个`Transport`值访问的每一个网络服务的最大连接数,不论这些连接是否是空闲的。并且,该字段没有相应的缺省值,它的零值表示不对此设限。 `DefaultTransport`并没有显式地为`MaxIdleConnsPerHost`和`MaxConnsPerHost`这两个字段赋值,但是它却把`MaxIdleConns`字段的值设定为了`100`。 换句话说,在默认情况下,空闲连接的总数最大为`100`,而针对每个网络服务的最大空闲连接数为`2`。注意,上述两个与空闲连接数有关的字段的值应该是联动的,所以,你有时候需要根据实际情况来定制它们。 当然了,这首先需要我们在初始化`Client`值的时候,定制它的`Transport`字段的值。定制这个值的方式,可以参看`DefaultTransport`变量的声明。 最后,我简单说一下为什么会出现空闲的连接。我们都知道,HTTP 协议有一个请求报文头叫做“Connection”。在 HTTP 协议的 1.1 版本中,这个报文头的值默认是“keep-alive”。 在这种情况下的网络连接都是持久连接,它们会在当前的 HTTP 事务完成后仍然保持着连通性,因此是可以被复用的。 既然连接可以被复用,那么就会有两种可能。一种可能是,针对于同一个网络服务,有新的 HTTP 请求被递交,该连接被再次使用。另一种可能是,不再有对该网络服务的 HTTP 请求,该连接被闲置。 显然,后一种可能就产生了空闲的连接。另外,如果分配给某一个网络服务的连接过多的话,也可能会导致空闲连接的产生,因为每一个新递交的 HTTP 请求,都只会征用一个空闲的连接。所以,为空闲连接设定限制,在大多数情况下都是很有必要的,也是需要斟酌的。 如果我们想彻底地杜绝空闲连接的产生,那么可以在初始化`Transport`值的时候把它的`DisableKeepAlives`字段的值设定为`true`。这时,HTTP 请求的“Connection”报文头的值就会被设置为“close”。这会告诉网络服务,这个网络连接不必保持,当前的 HTTP 事务完成后就可以断开它了。 如此一来,每当一个 HTTP 请求被递交时,就都会产生一个新的网络连接。这样做会明显地加重网络服务以及客户端的负载,并会让每个 HTTP 事务都耗费更多的时间。所以,在一般情况下,我们都不要去设置这个`DisableKeepAlives`字段。 顺便说一句,在`net.Dialer`类型中,也有一个看起来很相似的字段`KeepAlive`。不过,它与前面所说的 HTTP 持久连接并不是一个概念,`KeepAlive`是直接作用在底层的 socket 上的。 它的背后是一种针对网络连接(更确切地说,是 TCP 连接)的存活探测机制。它的值用于表示每间隔多长时间发送一次探测包。当该值不大于`0`时,则表示不开启这种机制。`DefaultTransport`会把这个字段的值设定为`30`秒。 好了,以上这些内容阐述的就是,`http.Client`类型中的`Transport`字段的含义,以及它的值的定制方式。这涉及了`http.RoundTripper`接口、`http.DefaultTransport`变量、`http.Transport`类型,以及`net.Dialer`类型。 ### [](https://jums.gitbook.io/36-lectures-on-golang/47-ji-yu-http-xie-yi-de-wang-luo-fu-wu#zhi-shi-kuo-zhan) 知识扩展 #### [](https://jums.gitbook.io/36-lectures-on-golang/47-ji-yu-http-xie-yi-de-wang-luo-fu-wu#wen-ti-http.server-lei-xing-de-listenandserve-fang-fa-dou-zuo-le-na-xie-shi-qing) 问题:`http.Server`类型的`ListenAndServe`方法都做了哪些事情? `http.Server`类型与`http.Client`是相对应的。`http.Server`代表的是基于 HTTP 协议的服务端,或者说网络服务。 `http.Server`类型的`ListenAndServe`方法的功能是:监听一个基于 TCP 协议的网络地址,并对接收到的 HTTP 请求进行处理。这个方法会默认开启针对网络连接的存活探测机制,以保证连接是持久的。同时,该方法会一直执行,直到有严重的错误发生或者被外界关掉。当被外界关掉时,它会返回一个由`http.ErrServerClosed`变量代表的错误值。 对于本问题,典型回答可以像下面这样。 这个`ListenAndServe`方法主要会做下面这几件事情。 1. 检查当前的`http.Server`类型的值(以下简称当前值)的`Addr`字段。该字段的值代表了当前的网络服务需要使用的网络地址,即:IP 地址和端口号. 如果这个字段的值为空字符串,那么就用`":http"`代替。也就是说,使用任何可以代表本机的域名和 IP 地址,并且端口号为`80`。 2. 通过调用`net.Listen`函数在已确定的网络地址上启动基于 TCP 协议的监听。 3. 检查`net.Listen`函数返回的错误值。如果该错误值不为`nil`,那么就直接返回该值。否则,通过调用当前值的`Serve`方法准备接受和处理将要到来的 HTTP 请求。 可以从当前问题直接衍生出的问题一般有两个,一个是“`net.Listen`函数都做了哪些事情”,另一个是“`http.Server`类型的`Serve`方法是怎样接受和处理 HTTP 请求的”。 **对于第一个直接的衍生问题,如果概括地说,回答可以是:** 1. 解析参数值中包含的网络地址隐含的 IP 地址和端口号; 2. 根据给定的网络协议,确定监听的方法,并开始进行监听。 从这里的第二个步骤出发,我们还可以继续提出一些间接的衍生问题。这往往会涉及`net.socket`函数以及相关的 socket 知识。 **对于第二个直接的衍生问题,我们可以这样回答:** 在一个`for`循环中,网络监听器的`Accept`方法会被不断地调用,该方法会返回两个结果值;第一个结果值是`net.Conn`类型的,它会代表包含了新到来的 HTTP 请求的网络连接;第二个结果值是代表了可能发生的错误的`error`类型值。 如果这个错误值不为`nil`,除非它代表了一个暂时性的错误,否则循环都会被终止。如果是暂时性的错误,那么循环的下一次迭代将会在一段时间之后开始执行。 如果这里的`Accept`方法没有返回非`nil`的错误值,那么这里的程序将会先把它的第一个结果值包装成一个`*http.conn`类型的值(以下简称`conn`值),然后通过在新的 goroutine 中调用这个`conn`值的`serve`方法,来对当前的 HTTP 请求进行处理。 这个处理的细节还是很多的,所以我们依然可以找出不少的间接的衍生问题。比如,这个`conn`值的状态有几种,分别代表着处理的哪个阶段?又比如,处理过程中会用到哪些读取器和写入器,它们的作用分别是什么?再比如,这里的程序是怎样调用我们自定义的处理函数的,等等。 诸如此类的问题很多,我就不在这里一一列举和说明了。你只需要记住一句话:“源码之前了无秘密”。上面这些问题的答案都可以在 Go 语言标准库的源码中找到。如果你想对本问题进行深入的探索,那么一定要去看`net/http`代码包的源码。 ### [](https://jums.gitbook.io/36-lectures-on-golang/47-ji-yu-http-xie-yi-de-wang-luo-fu-wu#zong-jie) 总结 今天,我们主要讲的是基于 HTTP 协议的网络服务,侧重点仍然在客户端。 我们在讨论了`http.Get`函数和`http.Client`类型的简单使用方式之后,把目光聚焦在了后者的`Transport`字段。 这个字段代表着单次 HTTP 事务的操作过程。它是`http.RoundTripper`接口类型的。它的缺省值由`http.DefaultTransport`变量代表,其实际类型是`*http.Transport`。 `http.Transport`包含的字段非常多。我们先讲了`DefaultTransport`中的`DialContext`字段会被赋予什么样的值,又详细说明了一些关于操作超时的字段。 比如`IdleConnTimeout`和`ExpectContinueTimeout`,以及相关的`MaxIdleConns`和`MaxIdleConnsPerHost`等等。之后,我又简单地解释了出现空闲连接的原因,以及相关的定制方式。 最后,作为扩展,我还为你简要地梳理了`http.Server`类型的`ListenAndServe`方法,执行的主要流程。不过,由于篇幅原因,我没有做深入讲述。但是,这并不意味着没有必要深入下去。相反,这个方法很重要,值得我们认真地去探索一番。 在你需要或者有兴趣的时候,我希望你能去好好地看一看`net/http`包中的相关源码。一切秘密都在其中。 ### [](https://jums.gitbook.io/36-lectures-on-golang/47-ji-yu-http-xie-yi-de-wang-luo-fu-wu#si-kao-ti) 思考题 我今天留给你的思考题比较简单,即:怎样优雅地停止基于 HTTP 协议的网络服务程序? [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页46 | 访问网络服务](https://jums.gitbook.io/36-lectures-on-golang/46-fang-wen-wang-luo-fu-wu) [下一页48 | 程序性能分析基础(上)](https://jums.gitbook.io/36-lectures-on-golang/48-cheng-xu-xing-neng-fen-xi-ji-chu-shang) 最后更新于2年前 这有帮助吗? --- # 49 | 程序性能分析基础(下) | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252FHGYyFiwqTKBriiOhS1Rc%252Fimage.png%3Falt%3Dmedia%26token%3D43baf0a6-0a78-418d-80fa-f5c7a9e12cc6&width=768&dpr=4&quality=100&sign=c380a8b0&sv=2) 你好,我是郝林,今天我们继续分享程序性能分析基础的内容。 在上一篇文章中,我们围绕着“怎样让程序对 CPU 概要信息进行采样”这一问题进行了探讨,今天,我们再来一起看看它的拓展问题。 ### [](https://jums.gitbook.io/36-lectures-on-golang/49-cheng-xu-xing-neng-fen-xi-ji-chu-xia#zhi-shi-kuo-zhan) 知识扩展 #### [](https://jums.gitbook.io/36-lectures-on-golang/49-cheng-xu-xing-neng-fen-xi-ji-chu-xia#wen-ti-1-zen-yang-she-ding-nei-cun-gai-yao-xin-xi-de-cai-yang-pinl) 问题 1:怎样设定内存概要信息的采样频率? 针对内存概要信息的采样会按照一定比例收集 Go 程序在运行期间的堆内存使用情况。设定内存概要信息采样频率的方法很简单,只要为`runtime.MemProfileRate`变量赋值即可。 这个变量的含义是,平均每分配多少个字节,就对堆内存的使用情况进行一次采样。如果把该变量的值设为`0`,那么,Go 语言运行时系统就会完全停止对内存概要信息的采样。该变量的缺省值是`512 KB`,也就是`512`千字节。 注意,如果你要设定这个采样频率,那么越早设定越好,并且只应该设定一次,否则就可能会对 Go 语言运行时系统的采样工作,造成不良影响。比如,只在`main`函数的开始处设定一次。 在这之后,当我们想获取内存概要信息的时候,还需要调用`runtime/pprof`包中的`WriteHeapProfile`函数。该函数会把收集好的内存概要信息,写到我们指定的写入器中。 注意,我们通过`WriteHeapProfile`函数得到的内存概要信息并不是实时的,它是一个快照,是在最近一次的内存垃圾收集工作完成时产生的。如果你想要实时的信息,那么可以调用`runtime.ReadMemStats`函数。不过要特别注意,该函数会引起 Go 语言调度器的短暂停顿。 以上,就是关于内存概要信息的采样频率设定问题的简要回答。 #### [](https://jums.gitbook.io/36-lectures-on-golang/49-cheng-xu-xing-neng-fen-xi-ji-chu-xia#wen-ti-2-zen-yang-huo-qu-dao-zu-se-gai-yao-xin-xi) 问题 2:怎样获取到阻塞概要信息? 我们调用`runtime`包中的`SetBlockProfileRate`函数,即可对阻塞概要信息的采样频率进行设定。该函数有一个名叫`rate`的参数,它是`int`类型的。 这个参数的含义是,只要发现一个阻塞事件的持续时间达到了多少个纳秒,就可以对其进行采样。如果这个参数的值小于或等于`0`,那么就意味着 Go 语言运行时系统将会完全停止对阻塞概要信息的采样。 在`runtime`包中,还有一个名叫`blockprofilerate`的包级私有变量,它是`uint64`类型的。这个变量的含义是,只要发现一个阻塞事件的持续时间跨越了多少个 CPU 时钟周期,就可以对其进行采样。它的含义与我们刚刚提到的`rate`参数的含义非常相似,不是吗? 实际上,这两者的区别仅仅在于单位不同。`runtime.SetBlockProfileRate`函数会先对参数`rate`的值进行单位换算和必要的类型转换,然后,它会把换算结果用原子操作赋给`blockprofilerate`变量。由于此变量的缺省值是`0`,所以 Go 语言运行时系统在默认情况下并不会记录任何在程序中发生的阻塞事件。 另一方面,当我们需要获取阻塞概要信息的时候,需要先调用`runtime/pprof`包中的`Lookup`函数并传入参数值`"block"`,从而得到一个`*runtime/pprof.Profile`类型的值(以下简称`Profile`值)。在这之后,我们还需要调用这个`Profile`值的`WriteTo`方法,以驱使它把概要信息写进我们指定的写入器中。 这个`WriteTo`方法有两个参数,一个参数就是我们刚刚提到的写入器,它是`io.Writer`类型的。而另一个参数则是代表了概要信息详细程度的`int`类型参数`debug`。 `debug`参数主要的可选值有两个,即:`0`和`1`。当`debug`的值为`0`时,通过`WriteTo`方法写进写入器的概要信息仅会包含`go tool pprof`工具所需的内存地址,这些内存地址会以十六进制的形式展现出来。 当该值为`1`时,相应的包名、函数名、源码文件路径、代码行号等信息就都会作为注释被加入进去。另外,`debug`为`0`时的概要信息,会经由 protocol buffers 转换为字节流。而在`debug`为`1`的时候,`WriteTo`方法输出的这些概要信息就是我们可以读懂的普通文本了。 除此之外,`debug`的值也可以是`2`。这时,被输出的概要信息也会是普通的文本,并且通常会包含更多的细节。至于这些细节都包含了哪些内容,那就要看我们调用`runtime/pprof.Lookup`函数的时候传入的是什么样的参数值了。下面,我们就来一起看一下这个函数。 #### [](https://jums.gitbook.io/36-lectures-on-golang/49-cheng-xu-xing-neng-fen-xi-ji-chu-xia#wen-ti-3runtimepprof.lookup-han-shu-de-zheng-que-diao-yong-fang-shi-shi-shen-me) 问题 3:`runtime/pprof.Lookup`函数的正确调用方式是什么? `runtime/pprof.Lookup`函数(以下简称`Lookup`函数)的功能是,提供与给定的名称相对应的概要信息。这个概要信息会由一个`Profile`值代表。如果该函数返回了一个`nil`,那么就说明不存在与给定名称对应的概要信息。 `runtime/pprof`包已经为我们预先定义了 6 个概要名称。它们对应的概要信息收集方法和输出方法也都已经准备好了。我们直接拿来使用就可以了。它们是:`goroutine`、`heap`、`allocs`、`threadcreate`、`block`和`mutex`。 当我们把`"goroutine"`传入`Lookup`函数的时候,该函数会利用相应的方法,收集到当前正在使用的所有 goroutine 的堆栈跟踪信息。注意,这样的收集会引起 Go 语言调度器的短暂停顿。 当调用该函数返回的`Profile`值的`WriteTo`方法时,如果参数`debug`的值大于或等于`2`,那么该方法就会输出所有 goroutine 的堆栈跟踪信息。这些信息可能会非常多。如果它们占用的空间超过了`64 MB`(也就是`64`兆字节),那么相应的方法就会将超出的部分截掉。 如果`Lookup`函数接到的参数值是`"heap"`,那么它就会收集与堆内存的分配和释放有关的采样信息。这实际上就是我们在前面讨论过的内存概要信息。在我们传入`"allocs"`的时候,后续的操作会与之非常的相似。 在这两种情况下,`Lookup`函数返回的`Profile`值也会极其相像。只不过,在这两种`Profile`值的`WriteTo`方法被调用时,它们输出的概要信息会有细微的差别,而且这仅仅体现在参数`debug`等于`0`的时候。 `"heap"`会使得被输出的内存概要信息默认以“在用空间”(inuse\_space)的视角呈现,而`"allocs"`对应的默认视角则是“已分配空间”(alloc\_space)。 “在用空间”是指,已经被分配但还未被释放的内存空间。在这个视角下,`go tool pprof`工具并不会去理会与已释放空间有关的那部分信息。而在“已分配空间”的视角下,所有的内存分配信息都会被展现出来,无论这些内存空间在采样时是否已被释放。 此外,无论是`"heap"`还是`"allocs"`,在我们调用`Profile`值的`WriteTo`方法的时候,只要赋予`debug`参数的值大于`0`,那么该方法输出内容的规格就会是相同的。 参数值`"threadcreate"`会使`Lookup`函数去收集一些堆栈跟踪信息。这些堆栈跟踪信息中的每一个都会描绘出一个代码调用链,这些调用链上的代码都导致新的操作系统线程产生。这样的`Profile`值的输出规格也只有两种,取决于我们传给其`WriteTo`方法的参数值是否大于`0`。 再说`"block"`和`"mutex"`。`"block"`代表的是,因争用同步原语而被阻塞的那些代码的堆栈跟踪信息。还记得吗?这就是我们在前面讲过的阻塞概要信息。 与之相对应,`"mutex"`代表的是,曾经作为同步原语持有者的那些代码,它们的堆栈跟踪信息。它们的输出规格也都只有两种,取决于`debug`是否大于`0`。 这里所说的同步原语,指的是存在于 Go 语言运行时系统内部的一种底层的同步工具,或者说一种同步机制。 它是直接面向内存地址的,并以异步信号量和原子操作作为实现手段。我们已经熟知的通道、互斥锁、条件变量、”WaitGroup“,以及 Go 语言运行时系统本身,都会利用它来实现自己的功能。 好了,关于这个问题,我们已经谈了不少了。我相信,你已经对`Lookup`函数的调用方式及其背后的含义有了比较深刻的理解了。demo99.go 文件中包含了一些示例代码,可供你参考。 #### [](https://jums.gitbook.io/36-lectures-on-golang/49-cheng-xu-xing-neng-fen-xi-ji-chu-xia#wen-ti-4-ru-he-wei-ji-yu-http-xie-yi-de-wang-luo-fu-wu-tian-jia-xing-neng-fen-xi-jie-kou) 问题 4:如何为基于 HTTP 协议的网络服务添加性能分析接口? 这个问题说起来还是很简单的。这是因为我们在一般情况下只要在程序中导入`net/http/pprof`代码包就可以了,就像这样: 复制 import _ "net/http/pprof" 然后,启动网络服务并开始监听,比如: 复制 log.Println(http.ListenAndServe("localhost:8082", nil)) 在运行这个程序之后,我们就可以通过在网络浏览器中访问`http://localhost:8082/debug/pprof`这个地址看到一个简约的网页。如果你认真地看了上一个问题的话,那么肯定可以快速搞明白这个网页中各个部分的含义。 在`/debug/pprof/`这个 URL 路径下还有很多可用的子路径,这一点你通过点选网页中的链接就可以了解到。像`allocs`、`block`、`goroutine`、`heap`、`mutex`、`threadcreate`这 6 个子路径,在底层其实都是通过`Lookup`函数来处理的。关于这个函数,你应该已经很熟悉了。 这些子路径都可以接受查询参数`debug`。它用于控制概要信息的格式和详细程度。至于它的可选值,我就不再赘述了。它的缺省值是`0`。另外,还有一个名叫`gc`的查询参数。它用于控制是否在获取概要信息之前强制地执行一次垃圾回收。只要它的值大于`0`,程序就会这样做。不过,这个参数仅在`/debug/pprof/heap`路径下有效。 一旦`/debug/pprof/profile`路径被访问,程序就会去执行对 CPU 概要信息的采样。它接受一个名为`seconds`的查询参数。该参数的含义是,采样工作需要持续多少秒。如果这个参数未被显式地指定,那么采样工作会持续`30`秒。注意,在这个路径下,程序只会响应经 protocol buffers 转换的字节流。我们可以通过`go tool pprof`工具直接读取这样的 HTTP 响应,例如: 复制 go tool pprof http://localhost:6060/debug/pprof/profile?seconds=60 除此之外,还有一个值得我们关注的路径,即:`/debug/pprof/trace`。在这个路径下,程序主要会利用`runtime/trace`代码包中的 API 来处理我们的请求。 更具体地说,程序会先调用`trace.Start`函数,然后在查询参数`seconds`指定的持续时间之后再调用`trace.Stop`函数。这里的`seconds`的缺省值是`1`秒。至于`runtime/trace`代码包的功用,我就留给你自己去查阅和探索吧。 前面说的这些 URL 路径都是固定不变的。这是默认情况下的访问规则。我们还可以对它们进行定制,就像这样: 复制 mux := http.NewServeMux() pathPrefix := "/d/pprof/" mux.HandleFunc(pathPrefix, func(w http.ResponseWriter, r *http.Request) { name := strings.TrimPrefix(r.URL.Path, pathPrefix) if name != "" { pprof.Handler(name).ServeHTTP(w, r) return } pprof.Index(w, r) }) mux.HandleFunc(pathPrefix+"cmdline", pprof.Cmdline) mux.HandleFunc(pathPrefix+"profile", pprof.Profile) mux.HandleFunc(pathPrefix+"symbol", pprof.Symbol) mux.HandleFunc(pathPrefix+"trace", pprof.Trace) server := http.Server{ Addr: "localhost:8083", Handler: mux, } 可以看到,我们几乎只使用了`net/http/pprof`代码包中的几个程序实体,就完成了这样的定制。这在我们使用第三方的网络服务开发框架时尤其有用。 我们自定义的 HTTP 请求多路复用器`mux`所包含的访问规则与默认的规则很相似,只不过 URL 路径的前缀更短了一些而已。 我们定制`mux`的过程与`net/http/pprof`包中的`init`函数所做的事情也是类似的。这个`init`函数的存在,其实就是我们在前面仅仅导入"net/http/pprof"代码包就能够访问相关路径的原因。 在我们编写网络服务程序的时候,使用`net/http/pprof`包要比直接使用`runtime/pprof`包方便和实用很多。通过合理运用,这个代码包可以为网络服务的监测提供有力的支撑。关于这个包的知识,我就先介绍到这里。 ### [](https://jums.gitbook.io/36-lectures-on-golang/49-cheng-xu-xing-neng-fen-xi-ji-chu-xia#zong-jie) 总结 这两篇文章中,我们主要讲了 Go 程序的性能分析,提到的很多内容都是你必备的知识和技巧。这些有助于你真正地理解以采样、收集、输出为代表的一系列操作步骤。 我提到的几种概要信息有关的问题。你需要记住的是,每一种概要信息都代表了什么,它们分别都包含了什么样的内容。 你还需要知道获取它们的正确方式,包括怎样启动和停止采样、怎样设定采样频率,以及怎样控制输出内容的格式和详细程度。 此外,`runtime/pprof`包中的`Lookup`函数的正确调用方式也很重要。对于除了 CPU 概要信息之外的其他概要信息,我们都可以通过调用这个函数获取到。 除此之外,我还提及了一个上层的应用,即:为基于 HTTP 协议的网络服务,添加性能分析接口。这也是很实用的一个部分。 虽然`net/http/pprof`包提供的程序实体并不多,但是它却能够让我们用不同的方式,实现性能分析接口的嵌入。这些方式有的是极简的、开箱即用的,而有的则用于满足各种定制需求。 以上这些,就是我今天为你讲述的 Go 语言知识,它们是程序性能分析的基础。如果你把 Go 语言程序运用于生产环境,那么肯定会涉及它们。对于这里提到的所有内容和问题,我都希望你能够认真地去思考和领会。这样才能够让你在真正使用它们的时候信手拈来。 ### [](https://jums.gitbook.io/36-lectures-on-golang/49-cheng-xu-xing-neng-fen-xi-ji-chu-xia#si-kao-ti) 思考题 我今天留给你的思考题其实在前面已经透露了,那就是:`runtime/trace`代码包的功用是什么? 感谢你的收听,我们下期再见。 [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页48 | 程序性能分析基础(上)](https://jums.gitbook.io/36-lectures-on-golang/48-cheng-xu-xing-neng-fen-xi-ji-chu-shang) [下一页50 | 学习专栏的正确姿势](https://jums.gitbook.io/36-lectures-on-golang/50-xue-xi-zhuan-lan-de-zheng-que-zi-shi) 最后更新于2年前 这有帮助吗? --- # 48 | 程序性能分析基础(上) | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252FRIBGnRNT88CpwBbf0J4Z%252Fimage.png%3Falt%3Dmedia%26token%3D9f425461-096c-4c5b-9ef4-6bab2b3a7e0c&width=768&dpr=4&quality=100&sign=e5c304d4&sv=2) 作为拾遗的部分,今天我们来讲讲与 Go 程序性能分析有关的基础知识。 Go 语言为程序开发者们提供了丰富的性能分析 API,和非常好用的标准工具。这些 API 主要存在于: 1. `runtime/pprof`; 2. `net/http/pprof`; 3. `runtime/trace`; 这三个代码包中。 另外,`runtime`代码包中还包含了一些更底层的 API。它们可以被用来收集或输出 Go 程序运行过程中的一些关键指标,并帮助我们生成相应的概要文件以供后续分析时使用。 至于标准工具,主要有`go tool pprof`和`go tool trace`这两个。它们可以解析概要文件中的信息,并以人类易读的方式把这些信息展示出来。 此外,`go test`命令也可以在程序测试完成后生成概要文件。如此一来,我们就可以很方便地使用前面那两个工具读取概要文件,并对被测程序的性能加以分析。这无疑会让程序性能测试的一手资料更加丰富,结果更加精确和可信。 在 Go 语言中,用于分析程序性能的概要文件有三种,分别是:CPU 概要文件(CPU Profile)、内存概要文件(Mem Profile)和阻塞概要文件(Block Profile)。 这些概要文件中包含的都是:在某一段时间内,对 Go 程序的相关指标进行多次采样后得到的概要信息。 对于 CPU 概要文件来说,其中的每一段独立的概要信息都记录着,在进行某一次采样的那个时刻,CPU 上正在执行的 Go 代码。 而对于内存概要文件,其中的每一段概要信息都记载着,在某个采样时刻,正在执行的 Go 代码以及堆内存的使用情况,这里包含已分配和已释放的字节数量和对象数量。至于阻塞概要文件,其中的每一段概要信息,都代表着 Go 程序中的一个 goroutine 阻塞事件。 注意,在默认情况下,这些概要文件中的信息并不是普通的文本,它们都是以二进制的形式展现的。如果你使用一个常规的文本编辑器查看它们的话,那么肯定会看到一堆“乱码”。 这时就可以显现出`go tool pprof`这个工具的作用了。我们可以通过它进入一个基于命令行的交互式界面,并对指定的概要文件进行查阅。就像下面这样: 复制 $ go tool pprof cpuprofile.out Type: cpu Time: Nov 9, 2018 at 4:31pm (CST) Duration: 7.96s, Total samples = 6.88s (86.38%) Entering interactive mode (type "help" for commands, "o" for options) (pprof) 关于这个工具的具体用法,我就不在这里赘述了。在进入这个工具的交互式界面之后,我们只要输入指令`help`并按下回车键,就可以看到很详细的帮助文档。 我们现在来说说怎样生成概要文件。 你可能会问,既然在概要文件中的信息不是普通的文本,那么它们到底是什么格式的呢?一个对广大的程序开发者而言,并不那么重要的事实是,它们是通过 protocol buffers 生成的二进制数据流,或者说字节流。 概括来讲,protocol buffers 是一种数据序列化协议,同时也是一个序列化工具。它可以把一个值,比如一个结构体或者一个字典,转换成一段字节流。 也可以反过来,把经过它生成的字节流反向转换为程序中的一个值。前者就被叫做序列化,而后者则被称为反序列化。 换句话说,protocol buffers 定义和实现了一种“可以让数据在结构形态和扁平形态之间互相转换”的方式。 Protocol buffers 的优势有不少。比如,它可以在序列化数据的同时对数据进行压缩,所以它生成的字节流,通常都要比相同数据的其他格式(例如 XML 和 JSON)占用的空间明显小很多。 又比如,它既能让我们自己去定义数据序列化和结构化的格式,也允许我们在保证向后兼容的前提下去更新这种格式。 正因为这些优势,Go 语言从 1.8 版本开始,把所有 profile 相关的信息生成工作都交给 protocol buffers 来做了。这也是我们在上述概要文件中,看不到普通文本的根本原因了。 Protocol buffers 的用途非常广泛,并且在诸如数据存储、数据传输等任务中有着很高的使用率。不过,关于它,我暂时就介绍到这里。你目前知道这些也就足够了。你并不用关心`runtime/pprof`包以及`runtime`包中的程序是如何序列化这些概要信息的。 继续回到怎样生成概要文件的话题,我们依然通过具体的问题来讲述。 **我们今天的问题是:怎样让程序对 CPU 概要信息进行采样?** **这道题的典型回答是这样的。** 这需要用到`runtime/pprof`包中的 API。更具体地说,在我们想让程序开始对 CPU 概要信息进行采样的时候,需要调用这个代码包中的`StartCPUProfile`函数,而在停止采样的时候则需要调用该包中的`StopCPUProfile`函数。 ### [](https://jums.gitbook.io/36-lectures-on-golang/48-cheng-xu-xing-neng-fen-xi-ji-chu-shang#wen-ti-jie-xi) 问题解析 `runtime/pprof.StartCPUProfile`函数(以下简称`StartCPUProfile`函数)在被调用的时候,先会去设定 CPU 概要信息的采样频率,并会在单独的 goroutine 中进行 CPU 概要信息的收集和输出。 注意,`StartCPUProfile`函数设定的采样频率总是固定的,即:`100`赫兹。也就是说,每秒采样`100`次,或者说每`10`毫秒采样一次。 赫兹,也称 Hz,是从英文单词“Hertz”(一个英文姓氏)音译过来的一个中文词。它是 CPU 主频的基本单位。 CPU 的主频指的是,CPU 内核工作的时钟频率,也常被称为 CPU clock speed。这个时钟频率的倒数即为时钟周期(clock cycle),也就是一个 CPU 内核执行一条运算指令所需的时间,单位是秒。 例如,主频为`1000`Hz 的 CPU,它的单个内核执行一条运算指令所需的时间为`0.001`秒,即`1`毫秒。又例如,我们现在常用的`3.2`GHz 的多核 CPU,其单个内核在`1`个纳秒的时间里就可以至少执行三条运算指令。 `StartCPUProfile`函数设定的 CPU 概要信息采样频率,相对于现代的 CPU 主频来说是非常低的。这主要有两个方面的原因。 一方面,过高的采样频率会对 Go 程序的运行效率造成很明显的负面影响。因此,`runtime`包中`SetCPUProfileRate`函数在被调用的时候,会保证采样频率不超过`1`MHz(兆赫),也就是说,它只允许每`1`微秒最多采样一次。`StartCPUProfile`函数正是通过调用这个函数来设定 CPU 概要信息的采样频率的。 另一方面,经过大量的实验,Go 语言团队发现`100`Hz 是一个比较合适的设定。因为这样做既可以得到足够多、足够有用的概要信息,又不至于让程序的运行出现停滞。另外,操作系统对高频采样的处理能力也是有限的,一般情况下,超过`500`Hz 就很可能得不到及时的响应了。 在`StartCPUProfile`函数执行之后,一个新启用的 goroutine 将会负责执行 CPU 概要信息的收集和输出,直到`runtime/pprof`包中的`StopCPUProfile`函数被成功调用。 `StopCPUProfile`函数也会调用`runtime.SetCPUProfileRate`函数,并把参数值(也就是采样频率)设为`0`。这会让针对 CPU 概要信息的采样工作停止。 同时,它也会给负责收集 CPU 概要信息的代码一个“信号”,以告知收集工作也需要停止了。 在接到这样的“信号”之后,那部分程序将会把这段时间内收集到的所有 CPU 概要信息,全部写入到我们在调用`StartCPUProfile`函数的时候指定的写入器中。只有在上述操作全部完成之后,`StopCPUProfile`函数才会返回。 好了,经过这一番解释,你应该已经对 CPU 概要信息的采样工作有一定的认识了。你可以去看看 demo96.go 文件中的代码,并运行几次试试。这样会有助于你加深对这个问题的理解。 ### [](https://jums.gitbook.io/36-lectures-on-golang/48-cheng-xu-xing-neng-fen-xi-ji-chu-shang#zong-jie) 总结 我们这两篇内容讲的是 Go 程序的性能分析,这其中的内容都是你从事这项任务必备的一些知识和技巧。 首先,我们需要知道,与程序性能分析有关的 API 主要存在于`runtime`、`runtime/pprof`和`net/http/pprof`这几个代码包中。它们可以帮助我们收集相应的性能概要信息,并把这些信息输出到我们指定的地方。 Go 语言的运行时系统会根据要求对程序的相关指标进行多次采样,并对采样的结果进行组织和整理,最后形成一份完整的性能分析报告。这份报告就是我们一直在说的概要信息的汇总。 一般情况下,我们会把概要信息输出到文件。根据概要信息的不同,概要文件的种类主要有三个,分别是:CPU 概要文件(CPU Profile)、内存概要文件(Mem Profile)和阻塞概要文件(Block Profile)。 在本文中,我提出了一道与上述几种概要信息有关的问题。在下一篇文章中,我们会继续对这部分问题的探究。 你对今天的内容有什么样的思考与疑惑,可以给我留言,感谢你的收听,我们下次再见。 [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页47 | 基于HTTP协议的网络服务](https://jums.gitbook.io/36-lectures-on-golang/47-ji-yu-http-xie-yi-de-wang-luo-fu-wu) [下一页49 | 程序性能分析基础(下)](https://jums.gitbook.io/36-lectures-on-golang/49-cheng-xu-xing-neng-fen-xi-ji-chu-xia) 最后更新于2年前 这有帮助吗? --- # 37 | strings包与字符串操作 | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252FJT3rj1f7O31dJ43yN4Tv%252Fimage.png%3Falt%3Dmedia%26token%3D34a4c988-f9a8-4a43-a275-5391549fb659&width=768&dpr=4&quality=100&sign=7c40a640&sv=2) 在上一篇文章中,我介绍了 Go 语言与 Unicode 编码规范、UTF-8 编码格式的渊源及运用。Go 语言不但拥有可以独立代表 Unicode 字符的类型`rune`,而且还有可以对字符串值进行 Unicode 字符拆分的`for`语句。 除此之外,标准库中的`unicode`包及其子包还提供了很多的函数和数据类型,可以帮助我们解析各种内容中的 Unicode 字符。这些程序实体都很好用,也都很简单明了,而且有效地隐藏了 Unicode 编码规范中的一些复杂的细节。我就不在这里对它们进行专门的讲解了。 我们今天主要来说一说标准库中的`strings`代码包。这个代码包也用到了不少`unicode`包和`unicode/utf8`包中的程序实体。比如,`strings.Builder`类型的`WriteRune`方法,又比如,`strings.Reader`类型的`ReadRune`方法,等等。 下面这个问题就是针对`strings.Builder`类型的。**我们今天的问题是:与**`**string**`**值相比,**`**strings.Builder**`**类型的值有哪些优势?** 这里的**典型回答**是这样的。 `strings.Builder`类型的值(以下简称`Builder`值)的优势有下面的三种: 已存在的内容不可变,但可以拼接更多的内容; 减少了内存分配和内容拷贝的次数; 可将内容重置,可重用值。 ### [](https://jums.gitbook.io/36-lectures-on-golang/37-strings-bao-yu-zi-fu-chuan-cao-zuo#wen-ti-jie-xi) 问题解析 我们都知道,在 Go 语言中,`string`类型的值是不可变的。如果我们想获得一个不一样的字符串,那么就只能基于原字符串进行裁剪、拼接等操作,从而生成一个新的字符串。裁剪操作可以使用切片表达式,而拼接操作可以用操作符`+`实现。 在底层,一个`string`值的内容会被存储到一块连续的内存空间中。同时,这块内存容纳的字节数量也会被记录下来,并用于表示该`string`值的长度。 你可以把这块内存的内容看成一个字节数组,而相应的`string`值则包含了指向字节数组头部的指针值。如此一来,我们在一个`string`值上应用切片表达式,就相当于在对其底层的字节数组做切片。 另一方面,我们在进行字符串拼接的时候,Go 语言会把所有被拼接的字符串依次拷贝到一个崭新且足够大的连续内存空间中,并把持有相应指针值的`string`值作为结果返回。 显然,当程序中存在过多的字符串拼接操作的时候,会对内存的分配产生非常大的压力。 注意,虽然`string`值在内部持有一个指针值,但其类型仍然属于值类型。不过,由于`string`值的不可变,其中的指针值也为内存空间的节省做出了贡献。 更具体地说,一个`string`值会在底层与它的所有副本共用同一个字节数组。由于这里的字节数组永远不会被改变,所以这样做是绝对安全的。 与`string`值相比,`Builder`值的优势其实主要体现在字符串拼接方面。 `Builder`值中有一个用于承载内容的容器(以下简称内容容器)。它是一个以`byte`为元素类型的切片(以下简称字节切片)。 由于这样的字节切片的底层数组就是一个字节数组,所以我们可以说它与`string`值存储内容的方式是一样的。实际上,它们都是通过一个`unsafe.Pointer`类型的字段来持有那个指向了底层字节数组的指针值的。 正是因为这样的内部构造,`Builder`值同样拥有高效利用内存的前提条件。虽然,对于字节切片本身来说,它包含的任何元素值都可以被修改,但是`Builder`值并不允许这样做,其中的内容只能够被拼接或者完全重置。 这就意味着,已存在于`Builder`值中的内容是不可变的。因此,我们可以利用`Builder`值提供的方法拼接更多的内容,而丝毫不用担心这些方法会影响到已存在的内容。 这里所说的方法指的是,`Builder`值拥有的一系列指针方法,包括:`Write`、`WriteByte`、`WriteRune`和`WriteString`。我们可以把它们统称为拼接方法。 我们可以通过调用上述方法把新的内容拼接到已存在的内容的尾部(也就是右边)。这时,如有必要,`Builder`值会自动地对自身的内容容器进行扩容。这里的自动扩容策略与切片的扩容策略一致。 换句话说,我们在向`Builder`值拼接内容的时候并不一定会引起扩容。只要内容容器的容量够用,扩容就不会进行,针对于此的内存分配也不会发生。同时,只要没有扩容,`Builder`值中已存在的内容就不会再被拷贝。 除了`Builder`值的自动扩容,我们还可以选择手动扩容,这通过调用`Builder`值的`Grow`方法就可以做到。`Grow`方法也可以被称为扩容方法,它接受一个`int`类型的参数`n`,该参数用于代表将要扩充的字节数量。 如有必要,`Grow`方法会把其所属值中内容容器的容量增加`n`个字节。更具体地讲,它会生成一个字节切片作为新的内容容器,该切片的容量会是原容器容量的二倍再加上`n`。之后,它会把原容器中的所有字节全部拷贝到新容器中。 复制 var builder1 strings.Builder // 省略若干代码。 fmt.Println("Grow the builder ...") builder1.Grow(10) fmt.Printf("The length of contents in the builder is %d.\n", builder1.Len()) 当然,`Grow`方法还可能什么都不做。这种情况的前提条件是:当前的内容容器中的未用容量已经够用了,即:未用容量大于或等于`n`。这里的前提条件与前面提到的自动扩容策略中的前提条件是类似的。 复制 fmt.Println("Reset the builder ...") builder1.Reset() fmt.Printf("The third output(%d):\n%q\n", builder1.Len(), builder1.String()) 最后,`Builder`值是可以被重用的。通过调用它的`Reset`方法,我们可以让`Builder`值重新回到零值状态,就像它从未被使用过那样。一旦被重用,`Builder`值中原有的内容容器会被直接丢弃。之后,它和其中的所有内容,将会被 Go 语言的垃圾回收器标记并回收掉。 ### [](https://jums.gitbook.io/36-lectures-on-golang/37-strings-bao-yu-zi-fu-chuan-cao-zuo#zhi-shi-kuo-zhan) 知识扩展 #### [](https://jums.gitbook.io/36-lectures-on-golang/37-strings-bao-yu-zi-fu-chuan-cao-zuo#wen-ti-1strings.builder-lei-xing-zai-shi-yong-shang-you-yue-shu-ma) 问题 1:`strings.Builder`类型在使用上有约束吗? 答案是:有约束,概括如下: 在已被真正使用后就不可再被复制; 由于其内容不是完全不可变的,所以需要使用方自行解决操作冲突和并发安全问题。 我们只要调用了`Builder`值的拼接方法或扩容方法,就意味着开始真正使用它了。显而易见,这些方法都会改变其所属值中的内容容器的状态。 一旦调用了它们,我们就不能再以任何的方式对其所属值进行复制了。否则,只要在任何副本上调用上述方法就都会引发 panic。 这种 panic 会告诉我们,这样的使用方式是并不合法的,因为这里的`Builder`值是副本而不是原值。顺便说一句,这里所说的复制方式,包括但不限于在函数间传递值、通过通道传递值、把值赋予变量等等。 复制 var builder1 strings.Builder builder1.Grow(1) builder3 := builder1 //builder3.Grow(1) // 这里会引发 panic。 _ = builder3 虽然这个约束非常严格,但是如果我们仔细思考一下的话,就会发现它还是有好处的。 正是由于已使用的`Builder`值不能再被复制,所以肯定不会出现多个`Builder`值中的内容容器(也就是那个字节切片)共用一个底层字节数组的情况。这样也就避免了多个同源的`Builder`值在拼接内容时可能产生的冲突问题。 不过,虽然已使用的`Builder`值不能再被复制,但是它的指针值却可以。无论什么时候,我们都可以通过任何方式复制这样的指针值。注意,这样的指针值指向的都会是同一个`Builder`值。 复制 f2 := func(bp *strings.Builder) { (*bp).Grow(1) // 这里虽然不会引发 panic,但不是并发安全的。 builder4 := *bp //builder4.Grow(1) // 这里会引发 panic。 _ = builder4 } f2(&builder1) 正因为如此,这里就产生了一个问题,即:如果`Builder`值被多方同时操作,那么其中的内容就很可能会产生混乱。这就是我们所说的操作冲突和并发安全问题。 `Builder`值自己是无法解决这些问题的。所以,我们在通过传递其指针值共享`Builder`值的时候,一定要确保各方对它的使用是正确、有序的,并且是并发安全的;而最彻底的解决方案是,绝不共享`Builder`值以及它的指针值。 我们可以在各处分别声明一个`Builder`值来使用,也可以先声明一个`Builder`值,然后在真正使用它之前,便将它的副本传到各处。另外,我们还可以先使用再传递,只要在传递之前调用它的`Reset`方法即可。 复制 builder1.Reset() builder5 := builder1 builder5.Grow(1) // 这里不会引发 panic。 总之,关于复制`Builder`值的约束是有意义的,也是很有必要的。虽然我们仍然可以通过某些方式共享`Builder`值,但最好还是不要以身犯险,“各自为政”是最好的解决方案。不过,对于处在零值状态的`Builder`值,复制不会有任何问题。 #### [](https://jums.gitbook.io/36-lectures-on-golang/37-strings-bao-yu-zi-fu-chuan-cao-zuo#wen-ti-2-wei-shen-me-shuo-strings.reader-lei-xing-de-zhi-ke-yi-gao-xiao-di-du-qu-zi-fu-chuan) 问题 2:为什么说`strings.Reader`类型的值可以高效地读取字符串? 与`strings.Builder`类型恰恰相反,`strings.Reader`类型是为了高效读取字符串而存在的。后者的高效主要体现在它对字符串的读取机制上,它封装了很多用于在`string`值上读取内容的最佳实践。 `strings.Reader`类型的值(以下简称`Reader`值)可以让我们很方便地读取一个字符串中的内容。在读取的过程中,`Reader`值会保存已读取的字节的计数(以下简称已读计数)。 已读计数也代表着下一次读取的起始索引位置。`Reader`值正是依靠这样一个计数,以及针对字符串值的切片表达式,从而实现快速读取。 此外,这个已读计数也是读取回退和位置设定时的重要依据。虽然它属于`Reader`值的内部结构,但我们还是可以通过该值的`Len`方法和`Size`把它计算出来的。代码如下: 复制 var reader1 strings.Reader // 省略若干代码。 readingIndex := reader1.Size() - int64(reader1.Len()) // 计算出的已读计数。 `Reader`值拥有的大部分用于读取的方法都会及时地更新已读计数。比如,`ReadByte`方法会在读取成功后将这个计数的值加`1`,又比如,`ReadRune`方法在读取成功之后,会把被读取的字符所占用的字节数作为计数的增量。 不过,`ReadAt`方法算是一个例外。它既不会依据已读计数进行读取,也不会在读取后更新它。正因为如此,这个方法可以自由地读取其所属的`Reader`值中的任何内容。 除此之外,`Reader`值的`Seek`方法也会更新该值的已读计数。实际上,这个`Seek`方法的主要作用正是设定下一次读取的起始索引位置。 另外,如果我们把常量`io.SeekCurrent`的值作为第二个参数值传给该方法,那么它还会依据当前的已读计数,以及第一个参数`offset`的值来计算新的计数值。 由于`Seek`方法会返回新的计数值,所以我们可以很容易地验证这一点。比如像下面这样: 复制 offset2 := int64(17) expectedIndex := reader1.Size() - int64(reader1.Len()) + offset2 fmt.Printf("Seek with offset %d and whence %d ...\n", offset2, io.SeekCurrent) readingIndex, _ := reader1.Seek(offset2, io.SeekCurrent) fmt.Printf("The reading index in reader: %d (returned by Seek)\n", readingIndex) fmt.Printf("The reading index in reader: %d (computed by me)\n", expectedIndex) 综上所述,`Reader`值实现高效读取的关键就在于它内部的已读计数。计数的值就代表着下一次读取的起始索引位置。它可以很容易地被计算出来。`Reader`值的`Seek`方法可以直接设定该值中的已读计数值。 ### [](https://jums.gitbook.io/36-lectures-on-golang/37-strings-bao-yu-zi-fu-chuan-cao-zuo#zong-jie) 总结 今天,我们主要讨论了`strings`代码包中的两个重要类型,即:`Builder`和`Reader`。前者用于构建字符串,而后者则用于读取字符串。 与`string`值相比,`Builder`值的优势主要体现在字符串拼接方面。它可以在保证已存在的内容不变的前提下,拼接更多的内容,并且会在拼接的过程中,尽量减少内存分配和内容拷贝的次数。 不过,这类值在使用上也是有约束的。它在被真正使用之后就不能再被复制了,否则就会引发 panic。虽然这个约束很严格,但是也可以带来一定的好处。它可以有效地避免一些操作冲突。虽然我们可以通过一些手段(比如传递它的指针值)绕过这个约束,但这是弊大于利的。最好的解决方案就是分别声明、分开使用、互不干涉。 `Reader`值可以让我们很方便地读取一个字符串中的内容。它的高效主要体现在它对字符串的读取机制上。在读取的过程中,`Reader`值会保存已读取的字节的计数,也称已读计数。 这个计数代表着下一次读取的起始索引位置,同时也是高效读取的关键所在。我们可以利用这类值的`Len`方法和`Size`方法,计算出其中的已读计数的值。有了它,我们就可以更加灵活地进行字符串读取了。 我只在本文介绍了上述两个数据类型,但并不意味着`strings`包中有用的程序实体只有这两个。实际上,`strings`包还提供了大量的函数。比如: 复制 `Count`、`IndexRune`、`Map`、`Replace`、`SplitN`、`Trim`,等等。 它们都是非常易用和高效的。你可以去看看它们的源码,也许会因此有所感悟。 ### [](https://jums.gitbook.io/36-lectures-on-golang/37-strings-bao-yu-zi-fu-chuan-cao-zuo#si-kao-ti) 思考题 今天的思考题是:`*strings.Builder`和`*strings.Reader`都分别实现了哪些接口?这样做有什么好处吗? [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页36 | unicode与字符编码](https://jums.gitbook.io/36-lectures-on-golang/36-unicode-yu-zi-fu-bian-ma) [下一页38 | bytes包与字节串操作(上)](https://jums.gitbook.io/36-lectures-on-golang/38-bytes-bao-yu-zi-jie-chuan-cao-zuo-shang) 最后更新于2年前 这有帮助吗? --- # 32 | context.Context类型 | Go语言核心36讲 ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2F2852705636-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252FkmtyDvkosJZE1Rr33XGZ%252Fuploads%252FP1mjXXM7PqcWOsOVTIzE%252Fimage.png%3Falt%3Dmedia%26token%3D62cd841d-9aa8-40c6-8014-edb45892fb60&width=768&dpr=4&quality=100&sign=aabbfd2f&sv=2) 我们在上篇文章中讲到了`sync.WaitGroup`类型:一个可以帮我们实现一对多 goroutine 协作流程的同步工具。 在使用`WaitGroup`值的时候,我们最好用“先统一`Add`,再并发`Done`,最后`Wait`”的标准模式来构建协作流程。如果在调用该值的`Wait`方法的同时,为了增大其计数器的值,而并发地调用该值的`Add`方法,那么就很可能会引发 panic。 这就带来了一个问题,如果,我们不能在一开始就确定执行子任务的 goroutine 的数量,那么使用`WaitGroup`值来协调它们和分发子任务的 goroutine,就是有一定风险的。一个解决方案是:分批地启用执行子任务的 goroutine。 我们都知道,`WaitGroup`值是可以被复用的,但需要保证其计数周期的完整性。尤其是涉及对其`Wait`方法调用的时候,它的下一个计数周期必须要等到,与当前计数周期对应的那个`Wait`方法调用完成之后,才能够开始。 我在前面提到的可能会引发 panic 的情况,就是由于没有遵循这条规则而导致的。 只要我们在严格遵循上述规则的前提下,分批地启用执行子任务的 goroutine,就肯定不会有问题。具体的实现方式有不少,其中最简单的方式就是使用`for`循环来作为辅助。这里的代码如下: 复制 func coordinateWithWaitGroup() { total := 12 stride := 3 var num int32 fmt.Printf("The number: %d [with sync.WaitGroup]\n", num) var wg sync.WaitGroup for i := 1; i <= total; i = i + stride { wg.Add(stride) for j := 0; j < stride; j++ { go addNum(&num, i+j, wg.Done) } wg.Wait() } fmt.Println("End.") } 这里展示的`coordinateWithWaitGroup`函数,就是上一篇文章中同名函数的改造版本。而其中调用的`addNum`函数,则是上一篇文章中同名函数的简化版本。这两个函数都已被放置在了 demo67.go 文件中。 我们可以看到,经过改造后的`coordinateWithWaitGroup`函数,循环地使用了由变量`wg`代表的`WaitGroup`值。它运用的依然是“先统一`Add`,再并发`Done`,最后`Wait`”的这种模式,只不过它利用`for`语句,对此进行了复用。 好了,至此你应该已经对`WaitGroup`值的运用有所了解了。不过,我现在想让你使用另一种工具来实现上面的协作流程。 **我们今天的问题就是:怎样使用**`**context**`**包中的程序实体,实现一对多的 goroutine 协作流程?** 更具体地说,我需要你编写一个名为`coordinateWithContext`的函数。这个函数应该具有上面`coordinateWithWaitGroup`函数相同的功能。 显然,你不能再使用`sync.WaitGroup`了,而要用`context`包中的函数和`Context`类型作为实现工具。这里注意一点,是否分批启用执行子任务的 goroutine 其实并不重要。 我在这里给你一个参考答案。 复制 func coordinateWithContext() { total := 12 var num int32 fmt.Printf("The number: %d [with context.Context]\n", num) cxt, cancelFunc := context.WithCancel(context.Background()) for i := 1; i <= total; i++ { go addNum(&num, i, func() { if atomic.LoadInt32(&num) == int32(total) { cancelFunc() } }) } <-cxt.Done() fmt.Println("End.") } 在这个函数体中,我先后调用了`context.Background`函数和`context.WithCancel`函数,并得到了一个可撤销的`context.Context`类型的值(由变量`cxt`代表),以及一个`context.CancelFunc`类型的撤销函数(由变量`cancelFunc`代表)。 在后面那条唯一的`for`语句中,我在每次迭代中都通过一条`go`语句,异步地调用`addNum`函数,调用的总次数只依据了`total`变量的值。 请注意我给予`addNum`函数的最后一个参数值。它是一个匿名函数,其中只包含了一条`if`语句。这条`if`语句会“原子地”加载`num`变量的值,并判断它是否等于`total`变量的值。 如果两个值相等,那么就调用`cancelFunc`函数。其含义是,如果所有的`addNum`函数都执行完毕,那么就立即通知分发子任务的 goroutine。 这里分发子任务的 goroutine,即为执行`coordinateWithContext`函数的 goroutine。它在执行完`for`语句后,会立即调用`cxt`变量的`Done`函数,并试图针对该函数返回的通道,进行接收操作。 由于一旦`cancelFunc`函数被调用,针对该通道的接收操作就会马上结束,所以,这样做就可以实现“等待所有的`addNum`函数都执行完毕”的功能。 ### [](https://jums.gitbook.io/36-lectures-on-golang/32-context.context-lei-xing#wen-ti-jie-xi) 问题解析 `context.Context`类型(以下简称`Context`类型)是在 Go 1.7 发布时才被加入到标准库的。而后,标准库中的很多其他代码包都为了支持它而进行了扩展,包括:`os/exec`包、`net`包、`database/sql`包,以及`runtime/pprof`包和`runtime/trace`包,等等。 `Context`类型之所以受到了标准库中众多代码包的积极支持,主要是因为它是一种非常通用的同步工具。它的值不但可以被任意地扩散,而且还可以被用来传递额外的信息和信号。 更具体地说,`Context`类型可以提供一类代表上下文的值。此类值是并发安全的,也就是说它可以被传播给多个 goroutine。 由于`Context`类型实际上是一个接口类型,而`context`包中实现该接口的所有私有类型,都是基于某个数据类型的指针类型,所以,如此传播并不会影响该类型值的功能和安全。 `Context`类型的值(以下简称`Context`值)是可以繁衍的,这意味着我们可以通过一个`Context`值产生出任意个子值。这些子值可以携带其父值的属性和数据,也可以响应我们通过其父值传达的信号。 正因为如此,所有的`Context`值共同构成了一颗代表了上下文全貌的树形结构。这棵树的树根(或者称上下文根节点)是一个已经在`context`包中预定义好的`Context`值,它是全局唯一的。通过调用`context.Background`函数,我们就可以获取到它(我在`coordinateWithContext`函数中就是这么做的)。 这里注意一下,这个上下文根节点仅仅是一个最基本的支点,它不提供任何额外的功能。也就是说,它既不可以被撤销(cancel),也不能携带任何数据。 除此之外,`context`包中还包含了四个用于繁衍`Context`值的函数,即:`WithCancel`、`WithDeadline`、`WithTimeout`和`WithValue`。 这些函数的第一个参数的类型都是`context.Context`,而名称都为`parent`。顾名思义,这个位置上的参数对应的都是它们将会产生的`Context`值的父值。 `WithCancel`函数用于产生一个可撤销的`parent`的子值。在`coordinateWithContext`函数中,我通过调用该函数,获得了一个衍生自上下文根节点的`Context`值,和一个用于触发撤销信号的函数。 而`WithDeadline`函数和`WithTimeout`函数则都可以被用来产生一个会定时撤销的`parent`的子值。至于`WithValue`函数,我们可以通过调用它,产生一个会携带额外数据的`parent`的子值。 到这里,我们已经对`context`包中的函数和`Context`类型有了一个基本的认识了。不过这还不够,我们再来扩展一下。 ### [](https://jums.gitbook.io/36-lectures-on-golang/32-context.context-lei-xing#zhi-shi-kuo-zhan) 知识扩展 #### [](https://jums.gitbook.io/36-lectures-on-golang/32-context.context-lei-xing#wen-ti-1-ke-che-xiao-de-zai-context-bao-zhong-dai-biao-zhe-shen-me-che-xiao-yi-ge-context-zhi-you-yi) 问题 1:“可撤销的”在`context`包中代表着什么?“撤销”一个`Context`值又意味着什么? 我相信很多初识`context`包的 Go 程序开发者,都会有这样的疑问。确实,“可撤销的”(cancelable)这个词在这里是比较抽象的,很容易让人迷惑。我这里再来解释一下。 这需要从`Context`类型的声明讲起。这个接口中有两个方法与“撤销”息息相关。`Done`方法会返回一个元素类型为`struct{}`的接收通道。不过,这个接收通道的用途并不是传递元素值,而是让调用方去感知“撤销”当前`Context`值的那个信号。 一旦当前的`Context`值被撤销,这里的接收通道就会被立即关闭。我们都知道,对于一个未包含任何元素值的通道来说,它的关闭会使任何针对它的接收操作立即结束。 正因为如此,在`coordinateWithContext`函数中,基于调用表达式`cxt.Done()`的接收操作,才能够起到感知撤销信号的作用。 除了让`Context`值的使用方感知到撤销信号,让它们得到“撤销”的具体原因,有时也是很有必要的。后者即是`Context`类型的`Err`方法的作用。该方法的结果是`error`类型的,并且其值只可能等于`context.Canceled`变量的值,或者`context.DeadlineExceeded`变量的值。 前者用于表示手动撤销,而后者则代表:由于我们给定的过期时间已到,而导致的撤销。 你可能已经感觉到了,对于`Context`值来说,“撤销”这个词如果当名词讲,指的其实就是被用来表达“撤销”状态的信号;如果当动词讲,指的就是对撤销信号的传达;而“可撤销的”指的则是具有传达这种撤销信号的能力。 我在前面讲过,当我们通过调用`context.WithCancel`函数产生一个可撤销的`Context`值时,还会获得一个用于触发撤销信号的函数。 通过调用这个函数,我们就可以触发针对这个`Context`值的撤销信号。一旦触发,撤销信号就会立即被传达给这个`Context`值,并由它的`Done`方法的结果值(一个接收通道)表达出来。 撤销函数只负责触发信号,而对应的可撤销的`Context`值也只负责传达信号,它们都不会去管后边具体的“撤销”操作。实际上,我们的代码可以在感知到撤销信号之后,进行任意的操作,`Context`值对此并没有任何的约束。 最后,若再深究的话,这里的“撤销”最原始的含义其实就是,终止程序针对某种请求(比如 HTTP 请求)的响应,或者取消对某种指令(比如 SQL 指令)的处理。这也是 Go 语言团队在创建`context`代码包,和`Context`类型时的初衷。 如果我们去查看`net`包和`database/sql`包的 API 和源码的话,就可以了解它们在这方面的典型应用。 #### [](https://jums.gitbook.io/36-lectures-on-golang/32-context.context-lei-xing#wen-ti-2-che-xiao-xin-hao-shi-ru-he-zai-shang-xia-wen-shu-zhong-chuan-bo-de) 问题 2:撤销信号是如何在上下文树中传播的? 我在前面讲了,`context`包中包含了四个用于繁衍`Context`值的函数。其中的`WithCancel`、`WithDeadline`和`WithTimeout`都是被用来基于给定的`Context`值产生可撤销的子值的。 `context`包的`WithCancel`函数在被调用后会产生两个结果值。第一个结果值就是那个可撤销的`Context`值,而第二个结果值则是用于触发撤销信号的函数。 在撤销函数被调用之后,对应的`Context`值会先关闭它内部的接收通道,也就是它的`Done`方法会返回的那个通道。 然后,它会向它的所有子值(或者说子节点)传达撤销信号。这些子值会如法炮制,把撤销信号继续传播下去。最后,这个`Context`值会断开它与其父值之间的关联。 我们通过调用`context`包的`WithDeadline`函数或者`WithTimeout`函数生成的`Context`值也是可撤销的。它们不但可以被手动撤销,还会依据在生成时被给定的过期时间,自动地进行定时撤销。这里定时撤销的功能是借助它们内部的计时器来实现的。 当过期时间到达时,这两种`Context`值的行为与`Context`值被手动撤销时的行为是几乎一致的,只不过前者会在最后停止并释放掉其内部的计时器。 最后要注意,通过调用`context.WithValue`函数得到的`Context`值是不可撤销的。撤销信号在被传播时,若遇到它们则会直接跨过,并试图将信号直接传给它们的子值。 #### [](https://jums.gitbook.io/36-lectures-on-golang/32-context.context-lei-xing#wen-ti-3-zen-yang-tong-guo-context-zhi-xie-dai-shu-ju-zen-yang-cong-zhong-huo-qu-shu-ju) 问题 3:怎样通过`Context`值携带数据?怎样从中获取数据? 既然谈到了`context`包的`WithValue`函数,我们就来说说`Context`值携带数据的方式。 `WithValue`函数在产生新的`Context`值(以下简称含数据的`Context`值)的时候需要三个参数,即:父值、键和值。与“字典对于键的约束”类似,这里键的类型必须是可判等的。 原因很简单,当我们从中获取数据的时候,它需要根据给定的键来查找对应的值。不过,这种`Context`值并不是用字典来存储键和值的,后两者只是被简单地存储在前者的相应字段中而已。 `Context`类型的`Value`方法就是被用来获取数据的。在我们调用含数据的`Context`值的`Value`方法时,它会先判断给定的键,是否与当前值中存储的键相等,如果相等就把该值中存储的值直接返回,否则就到其父值中继续查找。 如果其父值中仍然未存储相等的键,那么该方法就会沿着上下文根节点的方向一路查找下去。 注意,除了含数据的`Context`值以外,其他几种`Context`值都是无法携带数据的。因此,`Context`值的`Value`方法在沿路查找的时候,会直接跨过那几种值。 如果我们调用的`Value`方法的所属值本身就是不含数据的,那么实际调用的就将会是其父辈或祖辈的`Value`方法。这是由于这几种`Context`值的实际类型,都属于结构体类型,并且它们都是通过“将其父值嵌入到自身”,来表达父子关系的。 最后,提醒一下,`Context`接口并没有提供改变数据的方法。因此,在通常情况下,我们只能通过在上下文树中添加含数据的`Context`值来存储新的数据,或者通过撤销此种值的父值丢弃掉相应的数据。如果你存储在这里的数据可以从外部改变,那么必须自行保证安全。 ### [](https://jums.gitbook.io/36-lectures-on-golang/32-context.context-lei-xing#zong-jie) 总结 我们今天主要讨论的是`context`包中的函数和`Context`类型。该包中的函数都是用于产生新的`Context`类型值的。`Context`类型是一个可以帮助我们实现多 goroutine 协作流程的同步工具。不但如此,我们还可以通过此类型的值传达撤销信号或传递数据。 `Context`类型的实际值大体上分为三种,即:根`Context`值、可撤销的`Context`值和含数据的`Context`值。所有的`Context`值共同构成了一颗上下文树。这棵树的作用域是全局的,而根`Context`值就是这棵树的根。它是全局唯一的,并且不提供任何额外的功能。 可撤销的`Context`值又分为:只可手动撤销的`Context`值,和可以定时撤销的`Context`值。 我们可以通过生成它们时得到的撤销函数来对其进行手动的撤销。对于后者,定时撤销的时间必须在生成时就完全确定,并且不能更改。不过,我们可以在过期时间达到之前,对其进行手动的撤销。 一旦撤销函数被调用,撤销信号就会立即被传达给对应的`Context`值,并由该值的`Done`方法返回的接收通道表达出来。 “撤销”这个操作是`Context`值能够协调多个 goroutine 的关键所在。撤销信号总是会沿着上下文树叶子节点的方向传播开来。 含数据的`Context`值可以携带数据。每个值都可以存储一对键和值。在我们调用它的`Value`方法的时候,它会沿着上下文树的根节点的方向逐个值的进行查找。如果发现相等的键,它就会立即返回对应的值,否则将在最后返回`nil`。 含数据的`Context`值不能被撤销,而可撤销的`Context`值又无法携带数据。但是,由于它们共同组成了一个有机的整体(即上下文树),所以在功能上要比`sync.WaitGroup`强大得多。 ### [](https://jums.gitbook.io/36-lectures-on-golang/32-context.context-lei-xing#si-kao-ti) 思考题 今天的思考题是:`Context`值在传达撤销信号的时候是广度优先的,还是深度优先的?其优势和劣势都是什么? [戳此查看 Go 语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers) ![](https://jums.gitbook.io/36-lectures-on-golang/~gitbook/image?url=https%3A%2F%2Fstatic001.geekbang.org%2Fresource%2Fimage%2F35%2F48%2F358e4e8578a706598e18a7dfed3ed648.jpg&width=768&dpr=4&quality=100&sign=78a96e87&sv=2) [上一页31 | sync.WaitGroup和sync.Once](https://jums.gitbook.io/36-lectures-on-golang/31-sync.waitgroup-he-sync.once) [下一页33 | 临时对象池sync.Pool](https://jums.gitbook.io/36-lectures-on-golang/33-lin-shi-dui-xiang-chi-sync.pool) 最后更新于2年前 这有帮助吗? ---