44-SQL必知必会

开篇词丨SQL可能是你掌握的最有用的技能

1946 年，世界上第一台电脑诞生，如今，借由这台电脑发展起来的互联网已经自成江湖。在这几十年里，无数的技术、产业在这片江湖里沉浮，有的方兴未艾，有的已经几幕兴衰。

但在这片浩荡的波动里，有一门技术从未消失，甚至“老当益壮”，那就是 SQL。

SQL 作为与数据直接打交道的语言，是与各种前端、后端语言进行交互的“中台”语言。

不论是前端工程师，还是后端算法工程师，都一定会和数据打交道，都需要了解如何又快又准确地提取自己想要的数据。更别提数据分析师了，他们的工作就是和数据打交道，整理不同的报告，以便指导业务决策。

尽管技术人员或多或少地会使用 SQL，但不同的人编写出来的 SQL 的效率是不同的，比如说一份好的 SQL 执行计划就会尽量减少 I/O 操作，因为 I/O 是 DBMS 最容易出现瓶颈的地方，可以说数据库操作中有大量的时间都花在了 I/O 上。

此外，你还需要考虑如何降低 CPU 的计算量，在 SQL 语句中使用 GROUP BY、ORDER BY 等这些语句会消耗大量的 CPU 计算资源，因此我们需要从全局出发，不仅需要考虑数据库的 I/O 性能，还需要考虑 CPU 计算、内存使用情况等。

比如 EXIST 查询和 IN 查询在某些情况下可以得到相同的结果，但是具体执行起来，哪个效率更高呢？

假设我把这个模式抽象为下面这样：

SELECT * FROM A WHERE cc IN (SELECT cc FROM B)

SELECT * FROM A WHERE EXIST (SELECT cc FROM B WHERE B.cc=A.cc)

在查询过程中，我们需要判断表 A 和表 B 的大小。如果表 A 比表 B 大，那么 IN 子查询的效率比 EXIST 子查询效率高。

当然，SQL 的实用和强大远远不止体现在 IT 技术领域，在产品和运营方面，SQL 更是发挥了它强大的威力。

举个例子吧，假设你是一款游戏的产品经理，你想查询各种条件下的英雄都有哪些，比如最大生命值大于 7000 的法师英雄都有谁，那么你会怎么做呢？是找研发帮忙？还是从大量的数据里慢慢找？

当然这两种方式都可以，但如果每次都找研发也怪不好意思的，自己动手找吧，效率又很低。

实际上用一条 SQL 语句，你就可以直接从数据表中得到答案：

SELECT * FROM heros WHERE hp_max >= 7000 AND role = '法师'

SQL 语句直观到你就算没有 SQL 基础，也能凭借英语基础猜出它的大致意思。这就是 SQL 最大的特点。

假如你是一名运营人员，想要看下 7 天内的新增用户数有多少，该怎么做呢？首先我们需要获取现在的时间，使用 NOW() 函数即可，然后把它转化成天数，与用户的注册时间进行对比，小于 7 天的时间即是我们的筛选条件，最后就可以得到想要的数据了：

SELECT COUNT(*) as num FROM new_user WHERE TO_DAYS(NOW())-TO_DAYS(regist_time)<=7

上面举的两个例子属于相对简单的 SQL 查询，SQL 还可以帮助你统计每日新增、每日活跃、次日留存的数据。

事实上除了业务之外，基于数据的各种技术中也会用到 SQL，比如 OLTP（联机事务处理过程）、OLAP（联机分析处理过程）、RDBMS（对象关系型数据库管理系统）。甚至是在 NoSQL 的阵营上，如今也在使用类似 SQL 的操作，要知道，提出 NoSQL 这个概念的初衷就是远离 SQL，但如今人们更愿意把 NoSQL 定义为 Not Only SQL（不只是 SQL）。此外在我们熟悉的 XML、JSON 等数据格式中，都存在着各种 SQL，比如用于 XML 的 SQL、用于 JSON 的 SQL 等。除此以外，还包括了用于记录地理位置信息的 SQL、用于搜索的 SQL、用于时间序列数据的 SQL、用于流的 SQL 等。

可以说不论是和业务打交道，还是和数据相关的技术打交道，都需要 SQL。

如果你从事编程或身处互联网行业，那么没有什么比学习 SQL 语言更有价值。SQL 可能是你掌握的最有用的技能。对数据了解的需求往往是高频的，因此自己掌握 SQL 在实际工作中是非常有必要的。

不论是产品经理、运营人员，还是开发人员、数据分析师，你都可以用到 SQL 语言。它就像一把利剑，除了可以提高你的工作效率之外，还能扩大你的工作视野。

如果你没有 SQL 基础，我希望这个专栏可以帮助你彻底理解 SQL，从而开启你的数据之旅，比如如何用 SQL 检索数据、过滤数据，如何使用 SQL 函数、子查询……考虑到在实际工作中，你还会接触到不同的关系型数据库管理系统，我还会讲解 SQL 在不同的数据库管理系统中是如何使用的。

即使对于有 SQL 基础的同学来说，面对同样的 SQL 语句，查询效率可能千差万别。在你实际工作的时候，如何快速定位 SQL 的性能问题，并找到正确的优化方案会让你的价值倍增。

数据是 SQL 的生命力，你不用担心面对干巴巴的 SQL 语言而感到无从下手，为了避免纸上谈兵，让你在工作中更容易、更熟练地使用 SQL，我特意为专栏建了一个王者荣耀数据库和 NBA 球员数据库。这些数据库既是我在讲解 SQL 时要用到的事例，也是你课后练习的素材。

我相信以理论为骨，以数据库为翼，才能让你在消化理论的同时也能上手练习，真正了解和驾驭 SQL 这门语言。

因此，我把专栏分成了四个部分，既可以让你掌握 SQL 这门语言，也可以让你了解最新的数据库管理技术。

1. 基础篇

SQL 的语法非常简单，就像英语一样，但它的功能却很强大，可以帮助我们对数据实现索引、排序、分组等功能。但是这些命令在不同的数据库管理系统中的使用却有所差异，因此在专栏中，我不仅会重点讲解 SQL 本身的语法，还会讲解这些语法在类似 MySQL、Oracle、SQL Server 等不同的数据库管理系统中是如何使用的。

2. 进阶篇

很多人在写 SQL 的时候都会遇到这样的问题：“同样都是用 SQL 查询数据，为什么我写的语句要比别人的慢？”

事实上，就是因为 SQL 语法简单，导致很多人写的时候不拘小节，比如搞混了关键词的顺序，这就在无意中降低了 SQL 的执行效率。

在这一部分，我会讲解在实际工作中，使用 SQL 经常会遇到的问题，以及如何使用工具进行分析，快速定位性能问题及解决方案。

3. 高级篇

在大数据的时代，诞生了很多适用于不同场景的数据库管理系统，既有基于 SQL 的数据库，如 Oracle、MySQL、SQL Server、Access、WebSQL、SQLite 等，也有基于 NoSQL 的数据库，比如 MongoDB、Redis 等。

在这一部分中，我会讲一讲各种主流数据库管理系统的使用。

4. 实战篇

以上几个部分是在帮你梳理 SQL 的知识体系，但只有学会用 SQL 系统地进行项目实战，你才能真正地学以致用，让 SQL 为你的工作助力。

在这一部分中，我会结合数据分析的项目，为你讲解 SQL 的实战案例，比如如何用 SQL 做数据清洗、数据集成等。

如果你是一名程序员，那么会用 SQL 无疑是你的基本技能；如果你是产品经理或者运营人员，那么 SQL 将会为你打开一个全新的世界，让你从不一样的视角看待数据，利用数据。

我希望这个专栏可以带你走进 SQL 的世界，让你掌握它的基础语法，学会分析性能问题，一步步精通 SQL。这时你会发现，原本毫无规则、抽象的数据仿佛一下子具象了，变得会说话了，它会告诉你数据之间彼此的关联、背后所代表的含义……

SQL 让我们看到了数据的奇妙，你想不想和我一起开启这场 SQL 学习之旅呢？

1丨了解SQL：一门半衰期很长的语言

在我们的日常工作中，使用的是类似 MySQL、Oracle 这种的数据库管理系统，实际上这些数据库管理系统都遵循 SQL 语言，这就意味着，我们在使用这些数据库的时候，都是通过 SQL 语言与它们打交道。所以对于从事编程或者互联网行业的人来说，最具有中台能力的语言便是 SQL 语言。自从 SQL 加入了 TIOBE 编程语言排行榜，就一直保持在 Top 10。

SQL 语言无处不在，它对于不同职位的人来说都有价值。SQL 已经不仅仅是技术人员需要掌握的技能，产品经理、运营人员也同样需要掌握 SQL。

SQL 语言从诞生到现在，很少变化。这就意味着一旦你掌握了它，就可以一劳永逸，至少在你的职业生涯中，它都可以发挥作用。

SQL 入门并不难。

半衰期很长的 SQL

可以说在整个数字化的世界中，最重要而且最通用的元基础就是数据，而直接与数据打交道的语言就是 SQL 语言。很多人忽视了 SQL 语言的重要性，认为它不就是 SELECT 语句吗，掌握它应该是数据分析师的事。事实上在实际工作中，你不应该低估 SQL 的作用。如今互联网的很多业务处理离不开 SQL，因为它们都需要与数据打交道。

SQL 在各种技术和业务中无处不在，它的情况又是怎样的呢？45 年前，也就是 1974 年，IBM 研究员发布了一篇揭开数据库技术的论文《SEQUEL：一门结构化的英语查询语言》，直到今天这门结构化的查询语言并没有太大的变化，相比于其他语言，SQL 的半衰期可以说是非常长了。

SQL 有两个重要的标准，分别是 SQL92 和 SQL99，它们分别代表了 92 年和 99 年颁布的 SQL 标准，我们今天使用的 SQL 语言依然遵循这些标准。要知道 92 年是 Windows3.1 发布的时间，如今还有多少人记得它，但如果你从事数据分析，或者和数据相关的工作，依然会用到 SQL 语言。

作为技术和互联网行业的从业人员，我们总是希望能找到一个通用性强，变化相对少，上手相对容易的语言，SQL 正是为数不多的，可以满足这三个条件的语言。

入门 SQL 并不难

SQL 功能这么强大，那么学起来会很难吗？一点也不。SQL 不需要像其他语言那样，学习起来需要大量的程序语言基础，SQL 更像是一门英语，有一些简单的英语单词，当你使用它的时候，就好像在用英语与数据库进行对话。

我们可以把 SQL 语言按照功能划分成以下的 4 个部分：

DDL，英文叫做 Data Definition Language，也就是数据定义语言，它用来定义我们的数据库对象，包括数据库、数据表和列。通过使用 DDL，我们可以创建，删除和修改数据库和表结构。

DML，英文叫做 Data Manipulation Language，数据操作语言，我们用它操作和数据库相关的记录，比如增加、删除、修改数据表中的记录。

DCL，英文叫做 Data Control Language，数据控制语言，我们用它来定义访问权限和安全级别。

DQL，英文叫做 Data Query Language，数据查询语言，我们用它查询想要的记录，它是 SQL 语言的重中之重。在实际的业务中，我们绝大多数情况下都是在和查询打交道，因此学会编写正确且高效的查询语句，是学习的重点。

学习 SQL 就像学习英文语法一样。SQL 是为数不多的声明性语言，这种语言的特点就是，你只需要告诉计算机，你想从原始数据中获取什么样的数据结果即可。比如我想找主要角色定位是战士的英雄，以及他们的英雄名和最大生命值，就可以输入下面的语言：

SELECT name, hp_max FROM heros WHERE role_main = '战士'

这里我定义了 heros 数据表，包括了 name、hp_max、role_main 等字段，具体的数据表我会在后面的课程中作为示例讲解，这里只是做个简单的说明。

你能从这段代码看出，我并没有告诉计算机该如何执行才能得到结果，这也是声明性语言最大的便捷性。我们不需要指定具体的执行步骤，比如先执行哪一步，再执行哪一步，在执行前是否要检查是否满足条件 A 等等这些传统的编程思维。

SQL 语言定义了我们的需求，而不同的 DBMS（数据库管理系统）则会按照指定的 SQL 帮我们提取想要的结果，这样是不是很棒！

开启 SQL 之旅

SQL 是我们与 DBMS 交流的语言，我们在创建 DBMS 之前，还需要对它进行设计，对于 RDBMS 来说采用的是 ER 图（Entity Relationship Diagram），即实体 - 关系图的方式进行设计。

ER 图评审通过后，我们再用 SQL 语句或者可视化管理工具（如 Navicat）创建数据表。

实体 - 关系图有什么用呢？它是我们用来描述现实世界的概念模型，在这个模型中有 3 个要素：实体、属性、关系。

实体就是我们要管理的对象，属性是标识每个实体的属性，关系则是对象之间的关系。比如我们创建了“英雄”这个实体，那么它下面的属性包括了姓名、职业、最大生命值、初始生命值、最大魔法值、初始魔法值和攻击范围等。同时，我们还有“用户”这个实体，它下面的属性包括用户 ID、登录名、密码、性别和头像等。

“英雄”和“用户”这两个实体之间就是多对多的关系，也就是说一个英雄可以从属多个用户，而一个用户也可以拥有多个英雄。

除了多对多之外，也有一对一和一对多的关系。

创建完数据表之后，我们就可以用 SQL 操作了。你能看到很多 SQL 语句的大小写不统一，虽然大小写不会影响 SQL 的执行，不过我还是推荐你采用统一的书写规范，因为好的代码规范是提高效率的关键。

关于 SQL 大小写的问题，我总结了下面两点：

表名、表别名、字段名、字段别名等都小写；

SQL 保留字、函数名、绑定变量等都大写。

比如下面这个 SQL 语句：

SELECT name, hp_max FROM heros WHERE role_main = '战士'

你能看到 SELECT、FROM、WHERE 这些常用的 SQL 保留字都采用了大写，而 name、hp_max、role_main 这些字段名，表名都采用了小写。此外在数据表的字段名推荐采用下划线命名，比如 role_main 这种。

总结

今天我带你初步了解了 SQL 语言，当然，SQL 再简单，也还是需要你一步一步，从点滴做起，先掌握基本的 DDL、DML、DCL 和 DQL 语法，再了解不同的 DBMS 中的 SQL 语法差异，然后再来看如何优化，提升 SQL 的效率。要想写出高性能的 SQL，首先要了解它的原理，其次就是做大量的练习。

SQL 的价值在于通用性强（市场需求普遍），半衰期长（一次学习终身受用），入门不难。实际上，很多事情的价值都可以按照这三点来进行判断，比如一个产品的市场价值。如果你是一名产品经理，你是喜欢通用性更强的产品，还是喜欢更个性的产品。今天的文章只是简单预热，你可能也会有一些感悟，不妨说说你对一个产品或者语言的市场价值的理解。

欢迎你在评论区写下你的心得，也欢迎把这篇文章分享给你的朋友或者同事，让更多人了解 SQL 这门语言。

02丨DBMS的前世今生

上一讲我们介绍过两个 IBM 研究员在 1974 年发表了一篇有关结构化英语查询语言的论文，并将这门语言命名为 SEQUEL。SEQUEL 的语言标准是开放的，但是围绕它的商业化竞争却从来没有停止过。首先因为商标之争，SEQUEL 改名为 SQL，后来有一个重量级的公司基于那篇论文研发出了商业化的数据库管理软件，这就是 Oracle。自此之后，又诞生了一堆大家熟知的 DBMS，比如 MySQL、SQL Server、PostgreSQL、DB2 和 MongoDB 等。

我们今天了解一下这些数据库管理软件，也就是 DBMS。为什么需要了解它们呢？因为在工作中，我们通常都是和这些数据库管理软件打交道，只不过 SQL 是它们的通用语言。

今天我们先从 SQL 语言中跳脱出来，来分析下这些 DBMS。关于今天的内容，你可以从以下几个方面进行思考：

主流的 DBMS 都有哪些，它们各自都有哪些特点；

既然 SQL 是通用的标准语言，为什么能存在这么多 DBMS；

从这些 DBMS 的发展史中，你有哪些感悟。

DB、DBS 和 DBMS 的区别是什么

说到 DBMS，有一些概念你需要了解。

DBMS 的英文全称是 DataBase Management System，数据库管理系统，实际上它可以对多个数据库进行管理，所以你可以理解为 DBMS = 多个数据库（DB） + 管理程序。

DB 的英文是 DataBase，也就是数据库。数据库是存储数据的集合，你可以把它理解为多个数据表。

DBS 的英文是 DataBase System，数据库系统。它是更大的概念，包括了数据库、数据库管理系统以及数据库管理人员 DBA。

这里需要注意的是，虽然我们有时候把 Oracle、MySQL 等称之为数据库，但确切讲，它们应该是数据库管理系统，即 DBMS。

排名前 20 的 DBMS 都是那些

了解了 DBMS 的概念之后，我们来看下当前主流的 DBMS 都有哪些。下面这张表是 2019 年 5 月 DB-Engines 公布的 DBMS 的排名（每年的排名会有更新，主要依据这些 DBMS 在搜索引擎上的热度）：

从排名中我们能看出来，关系型数据库绝对是 DBMS 的主流，其中使用最多的 DBMS 分别是 Oracle、MySQL 和 SQL Server。

关系型数据库（RDBMS）就是建立在关系模型基础上的数据库，SQL 就是关系型数据库的查询语言。

相比于 SQL，NoSQL 泛指非关系型数据库，包括了榜单上的键值型数据库、文档型数据库、搜索引擎和列存储等，除此以外还包括图形数据库。

键值型数据库通过 Key-Value 键值的方式来存储数据，其中 Key 和 Value 可以是简单的对象，也可以是复杂的对象。Key 作为唯一的标识符，优点是查找速度快，在这方面明显优于关系型数据库，同时缺点也很明显，它无法像关系型数据库一样自由使用条件过滤（比如 WHERE），如果你不知道去哪里找数据，就要遍历所有的键，这就会消耗大量的计算。键值型数据库典型的使用场景是作为内容缓存。Redis 是最流行的键值型数据库。

文档型数据库用来管理文档，在数据库中文档作为处理信息的基本单位，一个文档就相当于一条记录，MongoDB 是最流行的文档型数据库。

搜索引擎也是数据库检索中的重要应用，常见的全文搜索引擎有 Elasticsearch、Splunk 和 Solr。虽然关系型数据库采用了索引提升检索效率，但是针对全文索引效率却较低。搜索引擎的优势在于采用了全文搜索的技术，核心原理是“倒排索引”。

列式数据库是相对于行式存储的数据库，Oracle、MySQL、SQL Server 等数据库都是采用的行式存储（Row-based），而列式数据库是将数据按照列存储到数据库中，这样做的好处是可以大量降低系统的 I/O，适合于分布式文件系统，不足在于功能相对有限。

图形数据库，利用了图这种数据结构存储了实体（对象）之间的关系。最典型的例子就是社交网络中人与人的关系，数据模型主要是以节点和边（关系）来实现，特点在于能高效地解决复杂的关系问题。

SQL 阵营与 NoSQL 阵营

NoSQL 的分类很多，刚才提到的键值型、文档型、搜索引擎、列式存储和图形数据库等都属于 NoSQL 阵营。也只有用 NoSQL 一词才能将这些技术囊括进来。即便如此，在 DBMS 排名中，还是 SQL 阵营的比重更大，影响力前 5 的 DBMS 中有 4 个是关系型数据库，而排名前 20 的 DBMS 中也有 12 个是关系型数据库。所以说，掌握 SQL 是非常有必要的。

由于 SQL 一直称霸 DBMS，因此许多人在思考是否有一种数据库技术能远离 SQL，于是 NoSQL 诞生了，但是随着发展却发现越来越离不开 SQL。到目前为止 NoSQL 阵营中的 DBMS 都会有实现类似 SQL 的功能。下面是“NoSQL”这个名词在不同时期的诠释，从这些释义的变化中可以看出 NoSQL 功能的演变：

1970：NoSQL = We have no SQL

1980：NoSQL = Know SQL

2000：NoSQL = No SQL!

2005：NoSQL = Not only SQL

2013：NoSQL = No, SQL!

NoSQL 对 SQL 做出了很好的补充，它可以让我们在云计算时代，更好地使用数据库技术，比如快速读写，这样可以用低廉的成本，更方便进行扩展。整个专栏的学习也将围绕 SQL 展开，同时你还需要了解 SQL 阵营中不同的 DBMS 之间的使用差异。这些 DBMS 除了支持 SQL 标准以外，还会有自己的“方言”，也就是自己独有的语法。在专栏中，我也会对近些年热门的 NoSQL 进行讲解，方便你在后续使用中更快上手。

SQL 阵营中的 DBMS

如果我们把数据互通作为当今数字化社会发展的大中台能力，那么 DBMS 无疑是一个巨大的市场。在这个市场中，排名前 20 的 DBMS 有 12 个属于 SQL 阵营，其中排名前 3 名的 DBMS 均为 SQL 阵营，它们分别是 Oracle、MySQL 和 SQL Server。这三家的市场份额远超其他 DBMS 的市场份额。

下面，我们来简单介绍下这三个主流 DBMS 的发展。

1979 年，Oracle 2 诞生，它是第一个商用的 RDBMS（关系型数据库管理系统），随后被卖给了军方客户。随着 Oracle 软件的名气越来越大，公司也改叫 Oracle 公司。20 世纪 90 年代，Oracle 的创始人埃里森成为继比尔·盖茨之后第二富有的人，可以说 IBM 缔造了两个帝国，一个是软件业的霸主微软，另一个是企业软件市场的霸主 Oracle。如今 Oracle 的年收入达到了 400 亿美金，足以证明商用数据库软件的价值。从这点我们也能看出，如果选择了一个大的赛道，就要尽早商业化，占据大型企业客户完全可以创建巨大的商业价值，也足以证明一个软件企业不需要靠卖硬件也可以挣到很多钱。

MySQL 是 1995 年诞生的开源数据库管理系统，因为免费开源的特性，得到了开发者的喜爱，用户量迅速增长，成为开源数据库的 No.1。但在发展过程中，MySQL 先后两次被易手，先是在 2008 年被 SUN 收购，然后在 2010 年 SUN 被 Oracle 收购，于是 Oracle 同时拥有了 MySQL 的管理权，至此 Oracle 在数据库领域中成为绝对的领导者。从这里我们也能看到，虽然 MySQL 是免费的产品，但是使用人数多，就足以证明巨大的用户价值。一个有巨大用户价值的产品，即使没有直接的商业价值，但作为基础设施也会被商业巨头看上。

不过在 Oracle 收购 MySQL 的同时，MySQL 的创造者担心 MySQL 有闭源的风险，因此创建了 MySQL 的分支项目 MariaDB，MariaDB 在绝大部分情况下都是与 MySQL 兼容的，并且增加了许多新的特性，比如支持更多的存储引擎类型。许多企业也由原来的 MySQL 纷纷转向了 MariaDB。

SQL Server 是微软开发的商业数据库，诞生于 1989 年。实际上微软还推出了 Access 数据库，它是一种桌面数据库，同时具备后台存储和前台界面开发的功能，更加轻量级，适合小型的应用场景。因为后台的存储空间有限，一般只有 2G，Access 的优势在于可以在前台便捷地进行界面开发。而 SQL Server 是大型数据库，用于后台的存储和查询，不具备界面开发的功能。从这里我们也能看出，即使 SQL 语言是通用的，但是为了满足不同用户的使用场景，会存在多个 DBMS。比如 Oracle 更适合大型跨国企业的使用，因为他们对费用不敏感，但是对性能要求以及安全性有更高的要求，而 MySQL 更受到许多互联网公司，尤其是早期创业公司的青睐。

总结

今天我们简单梳理了 DBMS 的发展。1974 年，SEQUEL 论文发表，1979 年，第一个商用关系型数据库 Oracle 2 诞生，1995 年，MySQL 开源数据库诞生，如今，NoSQL 得到了发展，并且围绕 SQL 标准展开的 DBMS 竞赛从来没有停止过。在这段发展史中，既有 SQL 阵营，又有 NoSQL 阵营，既有商业数据库软件，又有开源产品，在不同的应用场景下，同一家公司也会有不同的 DBMS 布局。

如果说不同的 DBMS 代表了不同公司的利益，那么作为使用者的我们更应该注重的是这些 DBMS 的使用场景。比如 Oracle 作为市场占有率最高的商用数据库软件，适合大型的跨国企业，而针对轻量级的桌面数据库，我们采用 Access 就可以了。对于免费开源的产品来说，可以选用 MySQL 或者 MariaDB。同时在 NoSQL 阵营中，我们也需要了解键值型、文档型、搜索引擎、列式数据库和图形数据库的区别。

我在文章中列举了排名前 20 的 DBMS，你都使用过哪些呢？可以说说你的使用体会吗？另外你有没有想过，虽然 SQL 是通用的标准语言，但为什么能存在这么多 DBMS 呢？

欢迎你在评论区写下你的思考，也欢迎把这篇文章分享给你的朋友或者同事。

03丨学会用数据库的方式思考SQL是如何执行的

通过上一篇文章对不同的 DBMS 的介绍，你应该对它们有了一些基础的了解。虽然 SQL 是声明式语言，我们可以像使用英语一样使用它，不过在 RDBMS（关系型数据库管理系统）中，SQL 的实现方式还是有差别的。今天我们就从数据库的角度来思考一下 SQL 是如何被执行的。

关于今天的内容，你会从以下几个方面进行学习：

Oracle 中的 SQL 是如何执行的，什么是硬解析和软解析；

MySQL 中的 SQL 是如何执行的，MySQL 的体系结构又是怎样的；

什么是存储引擎，MySQL 的存储引擎都有哪些？

Oracle 中的 SQL 是如何执行的

我们先来看下 SQL 在 Oracle 中的执行过程：

从上面这张图中可以看出，SQL 语句在 Oracle 中经历了以下的几个步骤。

语法检查：检查 SQL 拼写是否正确，如果不正确，Oracle 会报语法错误。

语义检查：检查 SQL 中的访问对象是否存在。比如我们在写 SELECT 语句的时候，列名写错了，系统就会提示错误。语法检查和语义检查的作用是保证 SQL 语句没有错误。

权限检查：看用户是否具备访问该数据的权限。

共享池检查：共享池（Shared Pool）是一块内存池，最主要的作用是缓存 SQL 语句和该语句的执行计划。Oracle 通过检查共享池是否存在 SQL 语句的执行计划，来判断进行软解析，还是硬解析。那软解析和硬解析又该怎么理解呢？

在共享池中，Oracle 首先对 SQL 语句进行 Hash 运算，然后根据 Hash 值在库缓存（Library Cache）中查找，如果存在 SQL 语句的执行计划，就直接拿来执行，直接进入“执行器”的环节，这就是软解析。

如果没有找到 SQL 语句和执行计划，Oracle 就需要创建解析树进行解析，生成执行计划，进入“优化器”这个步骤，这就是硬解析。

优化器：优化器中就是要进行硬解析，也就是决定怎么做，比如创建解析树，生成执行计划。

执行器：当有了解析树和执行计划之后，就知道了 SQL 该怎么被执行，这样就可以在执行器中执行语句了。

共享池是 Oracle 中的术语，包括了库缓存，数据字典缓冲区等。我们上面已经讲到了库缓存区，它主要缓存 SQL 语句和执行计划。而数据字典缓冲区存储的是 Oracle 中的对象定义，比如表、视图、索引等对象。当对 SQL 语句进行解析的时候，如果需要相关的数据，会从数据字典缓冲区中提取。

库缓存这一个步骤，决定了 SQL 语句是否需要进行硬解析。为了提升 SQL 的执行效率，我们应该尽量避免硬解析，因为在 SQL 的执行过程中，创建解析树，生成执行计划是很消耗资源的。

你可能会问，如何避免硬解析，尽量使用软解析呢？在 Oracle 中，绑定变量是它的一大特色。绑定变量就是在 SQL 语句中使用变量，通过不同的变量取值来改变 SQL 的执行结果。这样做的好处是能提升软解析的可能性，不足之处在于可能会导致生成的执行计划不够优化，因此是否需要绑定变量还需要视情况而定。

举个例子，我们可以使用下面的查询语句：

SQL> select * from player where player_id = 10001;

你也可以使用绑定变量，如：

SQL> select * from player where player_id = :player_id;

这两个查询语句的效率在 Oracle 中是完全不同的。如果你在查询 player_id = 10001 之后，还会查询 10002、10003 之类的数据，那么每一次查询都会创建一个新的查询解析。而第二种方式使用了绑定变量，那么在第一次查询之后，在共享池中就会存在这类查询的执行计划，也就是软解析。

因此我们可以通过使用绑定变量来减少硬解析，减少 Oracle 的解析工作量。但是这种方式也有缺点，使用动态 SQL 的方式，因为参数不同，会导致 SQL 的执行效率不同，同时 SQL 优化也会比较困难。

MySQL 中的 SQL 是如何执行的

Oracle 中采用了共享池来判断 SQL 语句是否存在缓存和执行计划，通过这一步骤我们可以知道应该采用硬解析还是软解析。那么在 MySQL 中，SQL 是如何被执行的呢？

首先 MySQL 是典型的 C/S 架构，即 Client/Server 架构，服务器端程序使用的 mysqld。整体的 MySQL 流程如下图所示：

你能看到 MySQL 由三层组成：

连接层：客户端和服务器端建立连接，客户端发送 SQL 至服务器端；

SQL 层：对 SQL 语句进行查询处理；

存储引擎层：与数据库文件打交道，负责数据的存储和读取。

其中 SQL 层与数据库文件的存储方式无关，我们来看下 SQL 层的结构：

查询缓存：Server 如果在查询缓存中发现了这条 SQL 语句，就会直接将结果返回给客户端；如果没有，就进入到解析器阶段。需要说明的是，因为查询缓存往往效率不高，所以在 MySQL8.0 之后就抛弃了这个功能。

解析器：在解析器中对 SQL 语句进行语法分析、语义分析。

优化器：在优化器中会确定 SQL 语句的执行路径，比如是根据全表检索，还是根据索引来检索等。

执行器：在执行之前需要判断该用户是否具备权限，如果具备权限就执行 SQL 查询并返回结果。在 MySQL8.0 以下的版本，如果设置了查询缓存，这时会将查询结果进行缓存。

你能看到 SQL 语句在 MySQL 中的流程是：SQL 语句→缓存查询→解析器→优化器→执行器。在一部分中，MySQL 和 Oracle 执行 SQL 的原理是一样的。

与 Oracle 不同的是，MySQL 的存储引擎采用了插件的形式，每个存储引擎都面向一种特定的数据库应用环境。同时开源的 MySQL 还允许开发人员设置自己的存储引擎，下面是一些常见的存储引擎：

InnoDB 存储引擎：它是 MySQL 5.5 版本之后默认的存储引擎，最大的特点是支持事务、行级锁定、外键约束等。

MyISAM 存储引擎：在 MySQL 5.5 版本之前是默认的存储引擎，不支持事务，也不支持外键，最大的特点是速度快，占用资源少。

Memory 存储引擎：使用系统内存作为存储介质，以便得到更快的响应速度。不过如果 mysqld 进程崩溃，则会导致所有的数据丢失，因此我们只有当数据是临时的情况下才使用 Memory 存储引擎。

NDB 存储引擎：也叫做 NDB Cluster 存储引擎，主要用于 MySQL Cluster 分布式集群环境，类似于 Oracle 的 RAC 集群。

Archive 存储引擎：它有很好的压缩机制，用于文件归档，在请求写入时会进行压缩，所以也经常用来做仓库。

需要注意的是，数据库的设计在于表的设计，而在 MySQL 中每个表的设计都可以采用不同的存储引擎，我们可以根据实际的数据处理需要来选择存储引擎，这也是 MySQL 的强大之处。

数据库管理系统也是一种软件

我们刚才了解了 SQL 语句在 Oracle 和 MySQL 中的执行流程，实际上完整的 Oracle 和 MySQL 结构图要复杂得多：

如果你只是简单地把 MySQL 和 Oracle 看成数据库管理系统软件，从外部看难免会觉得“晦涩难懂”，毕竟组织结构太多了。我们在学习的时候，还需要具备抽象的能力，抓取最核心的部分：SQL 的执行原理。因为不同的 DBMS 的 SQL 的执行原理是相通的，只是在不同的软件中，各有各的实现路径。

既然一条 SQL 语句会经历不同的模块，那我们就来看下，在不同的模块中，SQL 执行所使用的资源（时间）是怎样的。下面我来教你如何在 MySQL 中对一条 SQL 语句的执行时间进行分析。

首先我们需要看下 profiling 是否开启，开启它可以让 MySQL 收集在 SQL 执行时所使用的资源情况，命令如下：

mysql> select @@profiling;

profiling=0 代表关闭，我们需要把 profiling 打开，即设置为 1：

mysql> set profiling=1;

然后我们执行一个 SQL 查询（你可以执行任何一个 SQL 查询）：

mysql> select * from wucai.heros;

查看当前会话所产生的所有 profiles：

你会发现我们刚才执行了两次查询，Query ID 分别为 1 和 2。如果我们想要获取上一次查询的执行时间，可以使用：

mysql> show profile;

当然你也可以查询指定的 Query ID，比如：

mysql> show profile for query 2;

查询 SQL 的执行时间结果和上面是一样的。

在 8.0 版本之后，MySQL 不再支持缓存的查询，原因我在上文已经说过。一旦数据表有更新，缓存都将清空，因此只有数据表是静态的时候，或者数据表很少发生变化时，使用缓存查询才有价值，否则如果数据表经常更新，反而增加了 SQL 的查询时间。

你可以使用 select version() 来查看 MySQL 的版本情况。

总结

我们在使用 SQL 的时候，往往只见树木，不见森林，不会注意到它在各种数据库软件中是如何执行的，今天我们从全貌的角度来理解这个问题。你能看到不同的 RDBMS 之间有相同的地方，也有不同的地方。

相同的地方在于 Oracle 和 MySQL 都是通过解析器→优化器→执行器这样的流程来执行 SQL 的。

但 Oracle 和 MySQL 在进行 SQL 的查询上面有软件实现层面的差异。Oracle 提出了共享池的概念，通过共享池来判断是进行软解析，还是硬解析。而在 MySQL 中，8.0 以后的版本不再支持查询缓存，而是直接执行解析器→优化器→执行器的流程，这一点从 MySQL 中的 show profile 里也能看到。同时 MySQL 的一大特色就是提供了各种存储引擎以供选择，不同的存储引擎有各自的使用场景，我们可以针对每张表选择适合的存储引擎。

今天的内容到这里就结束了，你能说一下 Oracle 中的绑定变量是什么吗？使用它有什么优缺点吗？MySQL 的存储引擎是一大特色，其中 MyISAM 和 InnoDB 都是常用的存储引擎，这两个搜索引擎的特性和使用场景分别是什么？

最后留一道选择题吧，解析后的 SQL 语句在 Oracle 的哪个区域中进行缓存？

A. 数据缓冲区
B. 日志缓冲区
C. 共享池
D. 大池

欢迎你在评论区写下你的思考，我会在评论区与你一起交流，如果这篇文章帮你理顺了 Oracle 和 MySQL 执行 SQL 的过程，欢迎你把它分享给你的朋友或者同事。

※注：本篇文章出现的图片请点击这里下载高清大图。

04丨使用DDL创建数据库&数据表时需要注意什么？

DDL 是 DBMS 的核心组件，也是 SQL 的重要组成部分，DDL 的正确性和稳定性是整个 SQL 运行的重要基础。面对同一个需求，不同的开发人员创建出来的数据库和数据表可能千差万别，那么在设计数据库的时候，究竟什么是好的原则？我们在创建数据表的时候需要注意什么？

今天的内容，你可以从以下几个角度来学习：

了解 DDL 的基础语法，它如何定义数据库和数据表；

使用 DDL 定义数据表时，都有哪些约束性；

使用 DDL 设计数据库时，都有哪些重要原则。

DDL 的基础语法及设计工具

DDL 的英文全称是 Data Definition Language，中文是数据定义语言。它定义了数据库的结构和数据表的结构。

在 DDL 中，我们常用的功能是增删改，分别对应的命令是 CREATE、DROP 和 ALTER。需要注意的是，在执行 DDL 的时候，不需要 COMMIT，就可以完成执行任务。

1.对数据库进行定义

CREATE DATABASE nba; // 创建一个名为 nba 的数据库

DROP DATABASE nba; // 删除一个名为 nba 的数据库

2.对数据表进行定义

创建表结构的语法是这样的：

CREATE TABLE table_name

创建表结构

比如我们想创建一个球员表，表名为 player，里面有两个字段，一个是 player_id，它是 int 类型，另一个 player_name 字段是varchar(255)类型。这两个字段都不为空，且 player_id 是递增的。

那么创建的时候就可以写为：

CREATE TABLE player  (

  player_id int(11) NOT NULL AUTO_INCREMENT,

  player_name varchar(255) NOT NULL

);

需要注意的是，语句最后以分号（;）作为结束符，最后一个字段的定义结束后没有逗号。数据类型中 int(11) 代表整数类型，显示长度为 11 位，括号中的参数 11 代表的是最大有效显示长度，与类型包含的数值范围大小无关。varchar(255)代表的是最大长度为 255 的可变字符串类型。NOT NULL表明整个字段不能是空值，是一种数据约束。AUTO_INCREMENT代表主键自动增长。

实际上，我们通常很少自己写 DDL 语句，可以使用一些可视化工具来创建和操作数据库和数据表。在这里我推荐使用 Navicat，它是一个数据库管理和设计工具，跨平台，支持很多种数据库管理软件，比如 MySQL、Oracle、MariaDB 等。基本上专栏讲到的数据库软件都可以使用 Navicat 来管理。

假如还是针对 player 这张表，我们想设计以下的字段：

其中 player_id 是数据表 player 的主键，且自动增长，也就是 player_id 会从 1 开始，然后每次加 1。player_id、team_id、player_name 这三个字段均不为空，height 字段可以为空。

按照上面的设计需求，我们可以使用 Navicat 软件进行设计，如下所示：

然后，我们还可以对 player_name 字段进行索引，索引类型为Unique。使用 Navicat 设置如下：

这样一张 player 表就通过可视化工具设计好了。我们可以把这张表导出来，可以看看这张表对应的 SQL 语句是怎样的。方法是在 Navicat 左侧用右键选中 player 这张表，然后选择“转储 SQL 文件”→“仅结构”，这样就可以看到导出的 SQL 文件了，代码如下：

DROP TABLE IF EXISTS `player`;

CREATE TABLE `player`  (

  `player_id` int(11) NOT NULL AUTO_INCREMENT,

  `team_id` int(11) NOT NULL,

  `player_name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,

  `height` float(3, 2) NULL DEFAULT 0.00,

  PRIMARY KEY (`player_id`) USING BTREE,

  UNIQUE INDEX `player_name`(`player_name`) USING BTREE

) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

你能看到整个 SQL 文件中的 DDL 处理，首先先删除 player 表（如果数据库中存在该表的话），然后再创建 player 表，里面的数据表和字段都使用了反引号，这是为了避免它们的名称与 MySQL 保留字段相同，对数据表和字段名称都加上了反引号。

其中 player_name 字段的字符集是 utf8，排序规则是utf8_general_ci，代表对大小写不敏感，如果设置为utf8_bin，代表对大小写敏感，还有许多其他排序规则这里不进行介绍。

因为 player_id 设置为了主键，因此在 DDL 中使用PRIMARY KEY进行规定，同时索引方法采用 BTREE。

因为我们对 player_name 字段进行索引，在设置字段索引时，我们可以设置为UNIQUE INDEX（唯一索引），也可以设置为其他索引方式，比如NORMAL INDEX（普通索引），这里我们采用UNIQUE INDEX。唯一索引和普通索引的区别在于它对字段进行了唯一性的约束。在索引方式上，你可以选择BTREE或者HASH，这里采用了BTREE方法进行索引。我会在后面介绍BTREE和HASH索引方式的区别。

整个数据表的存储规则采用 InnoDB。之前我们简单介绍过 InnoDB，它是 MySQL5.5 版本之后默认的存储引擎。同时，我们将字符集设置为 utf8，排序规则为utf8_general_ci，行格式为Dynamic，就可以定义数据表的最后约定了：

ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

你能看出可视化工具还是非常方便的，它能直接帮我们将数据库的结构定义转化成 SQL 语言，方便数据库和数据表结构的导出和导入。不过在使用可视化工具前，你首先需要了解对于 DDL 的基础语法，至少能清晰地看出来不同字段的定义规则、索引方法，以及主键和外键的定义。

修改表结构

在创建表结构之后，我们还可以对表结构进行修改，虽然直接使用 Navicat 可以保证重新导出的数据表就是最新的，但你也有必要了解，如何使用 DDL 命令来完成表结构的修改。

1. 添加字段，比如我在数据表中添加一个 age 字段，类型为int(11)

ALTER TABLE player ADD (age int(11));

2. 修改字段名，将 age 字段改成player_age

ALTER TABLE player RENAME COLUMN age to player_age

3. 修改字段的数据类型，将player_age的数据类型设置为float(3,1)

ALTER TABLE player MODIFY (player_age float(3,1));

4. 删除字段, 删除刚才添加的player_age字段

ALTER TABLE player DROP COLUMN player_age;

数据表的常见约束

当我们创建数据表的时候，还会对字段进行约束，约束的目的在于保证 RDBMS 里面数据的准确性和一致性。下面，我们来看下常见的约束有哪些。

首先是主键约束。

主键起的作用是唯一标识一条记录，不能重复，不能为空，即 UNIQUE+NOT NULL。一个数据表的主键只能有一个。主键可以是一个字段，也可以由多个字段复合组成。在上面的例子中，我们就把 player_id 设置为了主键。

其次还有外键约束。

外键确保了表与表之间引用的完整性。一个表中的外键对应另一张表的主键。外键可以是重复的，也可以为空。比如 player_id 在 player 表中是主键，如果你想设置一个球员比分表即 player_score，就可以在 player_score 中设置 player_id 为外键，关联到 player 表中。

除了对键进行约束外，还有字段约束。

唯一性约束。

唯一性约束表明了字段在表中的数值是唯一的，即使我们已经有了主键，还可以对其他字段进行唯一性约束。比如我们在 player 表中给 player_name 设置唯一性约束，就表明任何两个球员的姓名不能相同。需要注意的是，唯一性约束和普通索引（NORMAL INDEX）之间是有区别的。唯一性约束相当于创建了一个约束和普通索引，目的是保证字段的正确性，而普通索引只是提升数据检索的速度，并不对字段的唯一性进行约束。

NOT NULL 约束。对字段定义了 NOT NULL，即表明该字段不应为空，必须有取值。

DEFAULT，表明了字段的默认值。如果在插入数据的时候，这个字段没有取值，就设置为默认值。比如我们将身高 height 字段的取值默认设置为 0.00，即DEFAULT 0.00。

CHECK 约束，用来检查特定字段取值范围的有效性，CHECK 约束的结果不能为 FALSE，比如我们可以对身高 height 的数值进行 CHECK 约束，必须≥0，且＜3，即CHECK(height>=0 AND height<3)。

设计数据表的原则

我们在设计数据表的时候，经常会考虑到各种问题，比如：用户都需要什么数据？需要在数据表中保存哪些数据？哪些数据是经常访问的数据？如何提升检索效率？

如何保证数据表中数据的正确性，当插入、删除、更新的时候该进行怎样的约束检查？

如何降低数据表的数据冗余度，保证数据表不会因为用户量的增长而迅速扩张？

如何让负责数据库维护的人员更方便地使用数据库？

除此以外，我们使用数据库的应用场景也各不相同，可以说针对不同的情况，设计出来的数据表可能千差万别。那么有没有一种设计原则可以让我们来借鉴呢？这里我整理了一个“三少一多”原则：

1.数据表的个数越少越好

RDBMS 的核心在于对实体和联系的定义，也就是 E-R 图（Entity Relationship Diagram），数据表越少，证明实体和联系设计得越简洁，既方便理解又方便操作。

2.数据表中的字段个数越少越好

字段个数越多，数据冗余的可能性越大。设置字段个数少的前提是各个字段相互独立，而不是某个字段的取值可以由其他字段计算出来。当然字段个数少是相对的，我们通常会在数据冗余和检索效率中进行平衡。

3.数据表中联合主键的字段个数越少越好

设置主键是为了确定唯一性，当一个字段无法确定唯一性的时候，就需要采用联合主键的方式（也就是用多个字段来定义一个主键）。联合主键中的字段越多，占用的索引空间越大，不仅会加大理解难度，还会增加运行时间和索引空间，因此联合主键的字段个数越少越好。

4.使用主键和外键越多越好

数据库的设计实际上就是定义各种表，以及各种字段之间的关系。这些关系越多，证明这些实体之间的冗余度越低，利用度越高。这样做的好处在于不仅保证了数据表之间的独立性，还能提升相互之间的关联使用率。

你应该能看出来“三少一多”原则的核心就是简单可复用。简单指的是用更少的表、更少的字段、更少的联合主键字段来完成数据表的设计。可复用则是通过主键、外键的使用来增强数据表之间的复用率。因为一个主键可以理解是一张表的代表。键设计得越多，证明它们之间的利用率越高。

总结

今天我们学习了 DDL 的基础语法，比如如何对数据库和数据库表进行定义，也了解了使用 Navicat 可视化管理工具来辅助我们完成数据表的设计，省去了手写 SQL 的工作量。

在创建数据表的时候，除了对字段名及数据类型进行定义以外，我们考虑最多的就是关于字段的约束，我介绍了 7 种常见的约束，它们都是数据表设计中会用到的约束：主键、外键、唯一性、NOT NULL、DEFAULT、CHECK 约束等。

当然，了解了如何操作创建数据表之后，你还需要动脑思考，怎样才能设计出一个好的数据表？设计的原则都有哪些？针对这个，我整理出了“三少一多”原则，在实际使用过程中，你需要灵活掌握，因为这个原则并不是绝对的，有时候我们需要牺牲数据的冗余度来换取数据处理的效率。

我们在创建数据表的时候，会对数据表设置主键、外键和索引。你能说下这三者的作用和区别吗？

欢迎你在评论区写下你的答案，我会和你一起交流，也欢迎把这篇文章分享给你的朋友或者同事。

05丨检索数据：你还在SELECT * 么？

今天我们主要学习如何使用 SQL 检索数据。如果你已经有了一定的 SQL 基础，这节课可以跳过，也可以把它当做是个快速的复习。

SELECT 可以说是 SQL 中最常用的语句了。你可以把 SQL 语句看作是英语语句，SELECT 就是 SQL 中的关键字之一，除了 SELECT 之外，还有 INSERT、DELETE、UPDATE 等关键字，这些关键字是 SQL 的保留字，这样可以很方便地帮助我们分析理解 SQL 语句。我们在定义数据库表名、字段名和变量名时，要尽量避免使用这些保留字。

SELECT 的作用是从一个表或多个表中检索出想要的数据行。今天我主要讲解 SELECT 的基础查询，后面我会讲解如何通过多个表的连接操作进行复杂的查询。

在这篇文章中，你需要重点掌握以下几方面的内容：

SELECT 查询的基础语法；

如何排序检索数据；

什么情况下用SELECT*，如何提升 SELECT 查询效率？

SELECT 查询的基础语法

SELECT 可以帮助我们从一个表或多个表中进行数据查询。我们知道一个数据表是由列（字段名）和行（数据行）组成的，我们要返回满足条件的数据行，就需要在 SELECT 后面加上我们想要查询的列名，可以是一列，也可以是多个列。如果你不知道所有列名都有什么，也可以检索所有列。

我创建了一个王者荣耀英雄数据表，这张表里一共有 69 个英雄，23 个属性值（不包括英雄名 name）。SQL 文件见Github 地址。

数据表中这 24 个字段（除了 id 以外），分别代表的含义见下图。

查询列

如果我们想要对数据表中的某一列进行检索，在 SELECT 后面加上这个列的字段名即可。比如我们想要检索数据表中都有哪些英雄。

SQL：SELECT name FROM heros

运行结果（69 条记录）见下图，你可以看到这样就等于单独输出了 name 这一列。

我们也可以对多个列进行检索，在列名之间用逗号 (,) 分割即可。比如我们想要检索有哪些英雄，他们的最大生命、最大法力、最大物攻和最大物防分别是多少。

SQL：SELECT name, hp_max, mp_max, attack_max, defense_max FROM heros

运行结果（69 条记录）：

这个表中一共有 25 个字段，除了 id 和英雄名 name 以外，还存在 23 个属性值，如果我们记不住所有的字段名称，可以使用 SELECT * 帮我们检索出所有的列：

SQL：SELECT * FROM heros

运行结果（69 条记录）：

我们在做数据探索的时候，SELECT *还是很有用的，这样我们就不需要写很长的 SELECT 语句了。但是在生产环境时要尽量避免使用SELECT*，具体原因我会在后面讲。

起别名

我们在使用 SELECT 查询的时候，还有一些技巧可以使用，比如你可以给列名起别名。我们在进行检索的时候，可以给英雄名、最大生命、最大法力、最大物攻和最大物防等取别名：

SQL：SELECT name AS n, hp_max AS hm, mp_max AS mm, attack_max AS am, defense_max AS dm FROM heros

运行结果和上面多列检索的运行结果是一样的，只是将列名改成了 n、hm、mm、am 和 dm。当然这里的列别名只是举例，一般来说起别名的作用是对原有名称进行简化，从而让 SQL 语句看起来更精简。同样我们也可以对表名称起别名，这个在多表连接查询的时候会用到。

查询常数

SELECT 查询还可以对常数进行查询。对的，就是在 SELECT 查询结果中增加一列固定的常数列。这列的取值是我们指定的，而不是从数据表中动态取出的。你可能会问为什么我们还要对常数进行查询呢？SQL 中的 SELECT 语法的确提供了这个功能，一般来说我们只从一个表中查询数据，通常不需要增加一个固定的常数列，但如果我们想整合不同的数据源，用常数列作为这个表的标记，就需要查询常数。

比如说，我们想对 heros 数据表中的英雄名进行查询，同时增加一列字段platform，这个字段固定值为“王者荣耀”，可以这样写：

SQL：SELECT '王者荣耀' as platform, name FROM heros

运行结果：（69 条记录）

在这个 SQL 语句中，我们虚构了一个platform字段，并且把它设置为固定值“王者荣耀”。

需要说明的是，如果常数是个字符串，那么使用单引号（‘’）就非常重要了，比如‘王者荣耀’。单引号说明引号中的字符串是个常数，否则 SQL 会把王者荣耀当成列名进行查询，但实际上数据表里没有这个列名，就会引起错误。如果常数是英文字母，比如'WZRY'也需要加引号。如果常数是个数字，就可以直接写数字，不需要单引号，比如：

SQL：SELECT 123 as platform, name FROM heros

运行结果：（69 条记录）

去除重复行

关于单个表的 SELECT 查询，还有一个非常实用的操作，就是从结果中去掉重复的行。使用的关键字是 DISTINCT。比如我们想要看下 heros 表中关于攻击范围的取值都有哪些：

SQL：SELECT DISTINCT attack_range FROM heros

这是运行结果（2 条记录），这样我们就能直观地看到攻击范围其实只有两个值，那就是近战和远程。

如果我们带上英雄名称，会是怎样呢：

SQL：SELECT DISTINCT attack_range, name FROM heros

运行结果（69 条记录）：

这里有两点需要注意：

DISTINCT 需要放到所有列名的前面，如果写成SELECT name, DISTINCT attack_range FROM heros会报错。

DISTINCT 其实是对后面所有列名的组合进行去重，你能看到最后的结果是 69 条，因为这 69 个英雄名称不同，都有攻击范围（attack_range）这个属性值。如果你想要看都有哪些不同的攻击范围（attack_range），只需要写DISTINCT attack_range即可，后面不需要再加其他的列名了。

如何排序检索数据

当我们检索数据的时候，有时候需要按照某种顺序进行结果的返回，比如我们想要查询所有的英雄，按照最大生命从高到底的顺序进行排列，就需要使用 ORDER BY 子句。使用 ORDER BY 子句有以下几个点需要掌握：

排序的列名：ORDER BY 后面可以有一个或多个列名，如果是多个列名进行排序，会按照后面第一个列先进行排序，当第一列的值相同的时候，再按照第二列进行排序，以此类推。

排序的顺序：ORDER BY 后面可以注明排序规则，ASC 代表递增排序，DESC 代表递减排序。如果没有注明排序规则，默认情况下是按照 ASC 递增排序。我们很容易理解 ORDER BY 对数值类型字段的排序规则，但如果排序字段类型为文本数据，就需要参考数据库的设置方式了，这样才能判断 A 是在 B 之前，还是在 B 之后。比如使用 MySQL 在创建字段的时候设置为 BINARY 属性，就代表区分大小写。

非选择列排序：ORDER BY 可以使用非选择列进行排序，所以即使在 SELECT 后面没有这个列名，你同样可以放到 ORDER BY 后面进行排序。

ORDER BY 的位置：ORDER BY 通常位于 SELECT 语句的最后一条子句，否则会报错。

在了解了 ORDER BY 的使用语法之后，我们来看下如何对 heros 数据表进行排序。

假设我们想要显示英雄名称及最大生命值，按照最大生命值从高到低的方式进行排序：

SQL：SELECT name, hp_max FROM heros ORDER BY hp_max DESC

运行结果（69 条记录）：

如果想要显示英雄名称及最大生命值，按照第一排序最大法力从低到高，当最大法力值相等的时候则按照第二排序进行，即最大生命值从高到低的方式进行排序：

SQL：SELECT name, hp_max FROM heros ORDER BY mp_max, hp_max DESC

运行结果：（69 条记录）

约束返回结果的数量

另外在查询过程中，我们可以约束返回结果的数量，使用 LIMIT 关键字。比如我们想返回英雄名称及最大生命值，按照最大生命值从高到低排序，返回 5 条记录即可。

SQL：SELECT name, hp_max FROM heros ORDER BY hp_max DESC LIMIT 5

运行结果（5 条记录）：

有一点需要注意，约束返回结果的数量，在不同的 DBMS 中使用的关键字可能不同。在 MySQL、PostgreSQL、MariaDB 和 SQLite 中使用 LIMIT 关键字，而且需要放到 SELECT 语句的最后面。如果是 SQL Server 和 Access，需要使用 TOP 关键字，比如：

SQL：SELECT TOP 5 name, hp_max FROM heros ORDER BY hp_max DESC

如果是 DB2，使用FETCH FIRST 5 ROWS ONLY这样的关键字：

SQL：SELECT name, hp_max FROM heros ORDER BY hp_max DESC FETCH FIRST 5 ROWS ONLY

如果是 Oracle，你需要基于 ROWNUM 来统计行数：

SQL：SELECT name, hp_max FROM heros WHERE ROWNUM <=5 ORDER BY hp_max DESC

需要说明的是，这条语句是先取出来前 5 条数据行，然后再按照 hp_max 从高到低的顺序进行排序。但这样产生的结果和上述方法的并不一样。我会在后面讲到子查询，你可以使用SELECT name, hp_max FROM (SELECT name, hp_max FROM heros ORDER BY hp_max) WHERE ROWNUM <=5得到与上述方法一致的结果。

约束返回结果的数量可以减少数据表的网络传输量，也可以提升查询效率。如果我们知道返回结果只有 1 条，就可以使用LIMIT 1，告诉 SELECT 语句只需要返回一条记录即可。这样的好处就是 SELECT 不需要扫描完整的表，只需要检索到一条符合条件的记录即可返回。

SELECT 的执行顺序

查询是 RDBMS 中最频繁的操作。我们在理解 SELECT 语法的时候，还需要了解 SELECT 执行时的底层原理。只有这样，才能让我们对 SQL 有更深刻的认识。

其中你需要记住 SELECT 查询时的两个顺序：

1. 关键字的顺序是不能颠倒的：

SELECT ... FROM ... WHERE ... GROUP BY ... HAVING ... ORDER BY ...

2.SELECT 语句的执行顺序（在 MySQL 和 Oracle 中，SELECT 执行顺序基本相同）：

FROM > WHERE > GROUP BY > HAVING > SELECT 的字段 > DISTINCT > ORDER BY > LIMIT

比如你写了一个 SQL 语句，那么它的关键字顺序和执行顺序是下面这样的：

SELECT DISTINCT player_id, player_name, count(*) as num # 顺序 5

FROM player JOIN team ON player.team_id = team.team_id # 顺序 1

WHERE height > 1.80 # 顺序 2

GROUP BY player.team_id # 顺序 3

HAVING num > 2 # 顺序 4

ORDER BY num DESC # 顺序 6

LIMIT 2 # 顺序 7

在 SELECT 语句执行这些步骤的时候，每个步骤都会产生一个虚拟表，然后将这个虚拟表传入下一个步骤中作为输入。需要注意的是，这些步骤隐含在 SQL 的执行过程中，对于我们来说是不可见的。

我来详细解释一下 SQL 的执行原理。

首先，你可以注意到，SELECT 是先执行 FROM 这一步的。在这个阶段，如果是多张表联查，还会经历下面的几个步骤：

首先先通过 CROSS JOIN 求笛卡尔积，相当于得到虚拟表 vt（virtual table）1-1；

通过 ON 进行筛选，在虚拟表 vt1-1 的基础上进行筛选，得到虚拟表 vt1-2；

添加外部行。如果我们使用的是左连接、右链接或者全连接，就会涉及到外部行，也就是在虚拟表 vt1-2 的基础上增加外部行，得到虚拟表 vt1-3。

当然如果我们操作的是两张以上的表，还会重复上面的步骤，直到所有表都被处理完为止。这个过程得到是我们的原始数据。

当我们拿到了查询数据表的原始数据，也就是最终的虚拟表 vt1，就可以在此基础上再进行 WHERE 阶段。在这个阶段中，会根据 vt1 表的结果进行筛选过滤，得到虚拟表 vt2。

然后进入第三步和第四步，也就是 GROUP 和 HAVING 阶段。在这个阶段中，实际上是在虚拟表 vt2 的基础上进行分组和分组过滤，得到中间的虚拟表 vt3 和 vt4。

当我们完成了条件筛选部分之后，就可以筛选表中提取的字段，也就是进入到 SELECT 和 DISTINCT 阶段。

首先在 SELECT 阶段会提取想要的字段，然后在 DISTINCT 阶段过滤掉重复的行，分别得到中间的虚拟表 vt5-1 和 vt5-2。

当我们提取了想要的字段数据之后，就可以按照指定的字段进行排序，也就是 ORDER BY 阶段，得到虚拟表 vt6。

最后在 vt6 的基础上，取出指定行的记录，也就是 LIMIT 阶段，得到最终的结果，对应的是虚拟表 vt7。

当然我们在写 SELECT 语句的时候，不一定存在所有的关键字，相应的阶段就会省略。

同时因为 SQL 是一门类似英语的结构化查询语言，所以我们在写 SELECT 语句的时候，还要注意相应的关键字顺序，所谓底层运行的原理，就是我们刚才讲到的执行顺序。

什么情况下用 SELECT*，如何提升 SELECT 查询效率？

当我们初学 SELECT 语法的时候，经常会使用SELECT *，因为使用方便。实际上这样也增加了数据库的负担。所以如果我们不需要把所有列都检索出来，还是先指定出所需的列名，因为写清列名，可以减少数据表查询的网络传输量，而且考虑到在实际的工作中，我们往往不需要全部的列名，因此你需要养成良好的习惯，写出所需的列名。

如果我们只是练习，或者对数据表进行探索，那么是可以使用SELECT *的。它的查询效率和把所有列名都写出来再进行查询的效率相差并不大。这样可以方便你对数据表有个整体的认知。但是在生产环境下，不推荐你直接使用SELECT *进行查询。

总结

今天我对 SELECT 的基础语法进行了讲解，SELECT 是 SQL 的基础。但不同阶段看 SELECT 都会有新的体会。当你第一次学习的时候，关注的往往是如何使用它，或者语法是否正确。再看的时候，可能就会更关注 SELECT 的查询效率，以及不同 DBMS 之间的差别。

在我们的日常工作中，很多人都可以写出 SELECT 语句，但是执行的效率却相差很大。产生这种情况的原因主要有两个，一个是习惯的培养，比如大部分初学者会经常使用SELECT *，而好的习惯则是只查询所需要的列；另一个对 SQL 查询的执行顺序及查询效率的关注，比如当你知道只有 1 条记录的时候，就可以使用LIMIT 1来进行约束，从而提升查询效率。

最后留两道思考题吧，我今天对单表的 SELECT 查询进行了讲解，你之前可能也有学习使用的经验，可以说下你对 SELECT 使用的理解吗？另外，我今天使用 heros 数据表进行了举例，请你编写 SQL 语句，对英雄名称和最大法力进行查询，按照最大生命从高到低排序，只返回 5 条记录即可。你可以说明下使用的 DBMS 及相应的 SQL 语句。

欢迎你把这篇文章分享给你的朋友或者同事，与他们一起来分析一下王者荣耀的数据，互相切磋交流。

06丨数据过滤：SQL数据过滤都有哪些方法？

我在上篇文章中讲到过，提升查询效率的一个很重要的方式，就是约束返回结果的数量，还有一个很有效的方式，就是指定筛选条件，进行过滤。过滤可以筛选符合条件的结果，并进行返回，减少不必要的数据行。

那么在今天的内容里，我们来学习如何对 SQL 数据进行过滤，这里主要使用的就是 WHERE 子句。

你可能已经使用过 WHERE 子句，说起来 SQL 其实很简单，只要能把满足条件的内容筛选出来即可，但在实际使用过程中，不同人写出来的 WHERE 子句存在很大差别，比如执行效率的高低，有没有遇到莫名的报错等。

在今天的学习中，你重点需要掌握以下几方面的内容：

学会使用 WHERE 子句，如何使用比较运算符对字段的数值进行比较筛选；

如何使用逻辑运算符，进行多条件的过滤；

学会使用通配符对数据条件进行复杂过滤。

比较运算符

在 SQL 中，我们可以使用 WHERE 子句对条件进行筛选，在此之前，你需要了解 WHERE 子句中的比较运算符。这些比较运算符的含义你可以参见下面这张表格：

实际上你能看到，同样的含义可能会有多种表达方式，比如小于等于，可以是（<=），也可以是不大于（!>）。同样不等于，可以用（<>），也可以用（!=），它们的含义都是相同的，但这些符号的顺序都不能颠倒，比如你不能写（=<）。需要注意的是，你需要查看使用的 DBMS 是否支持，不同的 DBMS 支持的运算符可能是不同的，比如 Access 不支持（!=），不等于应该使用（<>）。在 MySQL 中，不支持（!>）（!<）等。

我在上一篇文章中使用了 heros 数据表，今天还是以这张表格做练习。下面我们通过比较运算符对王者荣耀的英雄属性进行条件筛选。

WHERE 子句的基本格式是：SELECT ……(列名) FROM ……(表名) WHERE ……(子句条件)

比如我们想要查询所有最大生命值大于 6000 的英雄：

SQL：SELECT name, hp_max FROM heros WHERE hp_max > 6000

运行结果（41 条记录）：

想要查询所有最大生命值在 5399 到 6811 之间的英雄：

SQL：SELECT name, hp_max FROM heros WHERE hp_max BETWEEN 5399 AND 6811

运行结果：（41 条记录）

需要注意的是hp_max可以取值到最小值和最大值，即 5399 和 6811。

我们也可以对 heros 表中的hp_max字段进行空值检查。

SQL：SELECT name, hp_max FROM heros WHERE hp_max IS NULL

运行结果为空，说明 heros 表中的hp_max字段没有存在空值的数据行。

逻辑运算符

我刚才介绍了比较运算符，如果我们存在多个 WHERE 条件子句，可以使用逻辑运算符：

我们还是通过例子来看下这些逻辑运算符的使用，同样采用 heros 这张表的数据查询。

假设想要筛选最大生命值大于 6000，最大法力大于 1700 的英雄，然后按照最大生命值和最大法力值之和从高到低进行排序。

SQL：SELECT name, hp_max, mp_max FROM heros WHERE hp_max > 6000 AND mp_max > 1700 ORDER BY (hp_max+mp_max) DESC

运行结果：（23 条记录）

如果 AND 和 OR 同时存在 WHERE 子句中会是怎样的呢？假设我们想要查询最大生命值加最大法力值大于 8000 的英雄，或者最大生命值大于 6000 并且最大法力值大于 1700 的英雄。

SQL：SELECT name, hp_max, mp_max FROM heros WHERE (hp_max+mp_max) > 8000 OR hp_max > 6000 AND mp_max > 1700 ORDER BY (hp_max+mp_max) DESC

运行结果：（33 条记录）

你能看出来相比于上一个条件查询，这次的条件查询多出来了 10 个英雄，这是因为我们放宽了条件，允许最大生命值 + 最大法力值大于 8000 的英雄显示出来。另外你需要注意到，当 WHERE 子句中同时存在 OR 和 AND 的时候，AND 执行的优先级会更高，也就是说 SQL 会优先处理 AND 操作符，然后再处理 OR 操作符。

如果我们对这条查询语句 OR 两边的条件增加一个括号，结果会是怎样的呢？

SQL：SELECT name, hp_max, mp_max FROM heros WHERE ((hp_max+mp_max) > 8000 OR hp_max > 6000) AND mp_max > 1700 ORDER BY (hp_max+mp_max) DESC

运行结果：

所以当 WHERE 子句中同时出现 AND 和 OR 操作符的时候，你需要考虑到执行的先后顺序，也就是两个操作符执行的优先级。一般来说 () 优先级最高，其次优先级是 AND，然后是 OR。

如果我想要查询主要定位或者次要定位是法师或是射手的英雄，同时英雄的上线时间不在 2016-01-01 到 2017-01-01 之间。

SQL：

SELECT name, role_main, role_assist, hp_max, mp_max, birthdate

FROM heros

WHERE (role_main IN ('法师', '射手') OR role_assist IN ('法师', '射手'))

AND DATE(birthdate) NOT BETWEEN '2016-01-01' AND '2017-01-01'

ORDER BY (hp_max + mp_max) DESC

你能看到我把 WHERE 子句分成了两个部分。第一部分是关于主要定位和次要定位的条件过滤，使用的是role_main in ('法师', '射手') OR role_assist in ('法师', '射手')。这里用到了 IN 逻辑运算符，同时role_main和role_assist是 OR（或）的关系。

第二部分是关于上线时间的条件过滤。NOT 代表否，因为我们要找到不在 2016-01-01 到 2017-01-01 之间的日期，因此用到了NOT BETWEEN '2016-01-01' AND '2017-01-01'。同时我们是在对日期类型数据进行检索，所以使用到了 DATE 函数，将字段 birthdate 转化为日期类型再进行比较。关于日期的操作，我会在下一篇文章中再作具体介绍。

这是运行结果（6 条记录）：

使用通配符进行过滤

刚才讲解的条件过滤都是对已知值进行的过滤，还有一种情况是我们要检索文本中包含某个词的所有数据，这里就需要使用通配符。通配符就是我们用来匹配值的一部分的特殊字符。这里我们需要使用到 LIKE 操作符。

如果我们想要匹配任意字符串出现的任意次数，需要使用（%）通配符。比如我们想要查找英雄名中包含“太”字的英雄都有哪些：

SQL：SELECT name FROM heros WHERE name LIKE '% 太 %'

运行结果：（2 条记录）

需要说明的是不同 DBMS 对通配符的定义不同，在 Access 中使用的是（*）而不是（%）。另外关于字符串的搜索可能是需要区分大小写的，比如'liu%'就不能匹配上'LIU BEI'。具体是否区分大小写还需要考虑不同的 DBMS 以及它们的配置。

如果我们想要匹配单个字符，就需要使用下划线 (_) 通配符。（%）和（_）的区别在于，（%）代表一个或多个字符，而（_）只代表一个字符。比如我们想要查找英雄名除了第一个字以外，包含“太”字的英雄有哪些。

SQL：SELECT name FROM heros WHERE name LIKE '_% 太 %'

运行结果（1 条记录）：

因为太乙真人的太是第一个字符，而_%太%中的太不是在第一个字符，所以匹配不到“太乙真人”，只可以匹配上“东皇太一”。

同样需要说明的是，在 Access 中使用（?）来代替（_），而且在 DB2 中是不支持通配符（_）的，因此你需要在使用的时候查阅相关的 DBMS 文档。

你能看出来通配符还是很有用的，尤其是在进行字符串匹配的时候。不过在实际操作过程中，我还是建议你尽量少用通配符，因为它需要消耗数据库更长的时间来进行匹配。即使你对 LIKE 检索的字段进行了索引，索引的价值也可能会失效。如果要让索引生效，那么 LIKE 后面就不能以（%）开头，比如使用LIKE '%太%'或LIKE '%太'的时候就会对全表进行扫描。如果使用LIKE '太%'，同时检索的字段进行了索引的时候，则不会进行全表扫描。

总结

今天我对 SQL 语句中的 WHERE 子句进行了讲解，你可以使用比较运算符、逻辑运算符和通配符这三种方式对检索条件进行过滤。

比较运算符是对数值进行比较，不同的 DBMS 支持的比较运算符可能不同，你需要事先查阅相应的 DBMS 文档。逻辑运算符可以让我们同时使用多个 WHERE 子句，你需要注意的是 AND 和 OR 运算符的执行顺序。通配符可以让我们对文本类型的字段进行模糊查询，不过检索的代价也是很高的，通常都需要用到全表扫描，所以效率很低。只有当 LIKE 语句后面不用通配符，并且对字段进行索引的时候才不会对全表进行扫描。

你可能认为学习 SQL 并不难，掌握这些语法就可以对数据进行筛选查询。但实际工作中不同人写的 SQL 语句的查询效率差别很大，保持高效率的一个很重要的原因，就是要避免全表扫描，所以我们会考虑在 WHERE 及 ORDER BY 涉及到的列上增加索引。

你能说一下 WHERE 子句中比较运算符、逻辑运算符和通配符这三者各自的作用吗？以 heros 数据表为例，请你编写 SQL 语句，对英雄名称、主要定位、次要定位、最大生命和最大法力进行查询，筛选条件为：主要定位是坦克或者战士，并且次要定位不为空，同时满足最大生命值大于 8000 或者最大法力小于 1500 的英雄，并且按照最大生命和最大法力之和从高到底的顺序进行排序。

欢迎你在评论区写下你的思考，也欢迎点击请朋友读，把这篇文章分享给你的朋友或者同事。

07丨什么是SQL函数？为什么使用SQL函数可能会带来问题？

函数在计算机语言的使用中贯穿始终，在 SQL 中我们也可以使用函数对检索出来的数据进行函数操作，比如求某列数据的平均值，或者求字符串的长度等。从函数定义的角度出发，我们可以将函数分成内置函数和自定义函数。在 SQL 语言中，同样也包括了内置函数和自定义函数。内置函数是系统内置的通用函数，而自定义函数是我们根据自己的需要编写的，下面讲解的是 SQL 的内置函数。

你需要从以下几个方面掌握 SQL 函数：

什么是 SQL 函数？

内置的 SQL 函数都包括哪些？

如何使用 SQL 函数对一个数据表进行操作，比如针对一个王者荣耀的英雄数据库，我们可以使用这些函数完成哪些操作？

什么情况下使用 SQL 函数？为什么使用 SQL 函数有时候会带来问题？

什么是 SQL 函数

当我们学习编程语言的时候，也会遇到函数。函数的作用是什么呢？它可以把我们经常使用的代码封装起来，需要的时候直接调用即可。这样既提高了代码效率，又提高了可维护性。

SQL 中的函数一般是在数据上执行的，可以很方便地转换和处理数据。一般来说，当我们从数据表中检索出数据之后，就可以进一步对这些数据进行操作，得到更有意义的结果，比如返回指定条件的函数，或者求某个字段的平均值等。

常用的 SQL 函数有哪些

SQL 提供了一些常用的内置函数，当然你也可以自己定义 SQL 函数。SQL 的内置函数对于不同的数据库软件来说具有一定的通用性，我们可以把内置函数分成四类：

算术函数

字符串函数

日期函数

转换函数

这 4 类函数分别代表了算术处理、字符串处理、日期处理、数据类型转换，它们是 SQL 函数常用的划分形式，你可以思考下，为什么是这 4 个维度？

函数是对提取出来的数据进行操作，那么数据表中字段类型的定义有哪几种呢？

我们经常会保存一些数值，不论是整数类型，还是浮点类型，实际上对应的就是数值类型。同样我们也会保存一些文本内容，可能是人名，也可能是某个说明，对应的就是字符串类型。此外我们还需要保存时间，也就是日期类型。那么针对数值、字符串和日期类型的数据，我们可以对它们分别进行算术函数、字符串函数以及日期函数的操作。如果想要完成不同类型数据之间的转换，就可以使用转换函数。

算术函数

算术函数，顾名思义就是对数值类型的字段进行算术运算。常用的算术函数及含义如下表所示：

这里我举一些简单的例子，你来体会下：

SELECT ABS(-2)，运行结果为 2。

SELECT MOD(101,3)，运行结果 2。

SELECT ROUND(37.25,1)，运行结果 37.3。

字符串函数

常用的字符串函数操作包括了字符串拼接，大小写转换，求长度以及字符串替换和截取等。具体的函数名称及含义如下表所示：

这里同样有一些简单的例子，你可以自己运行下：

SELECT CONCAT('abc', 123)，运行结果为 abc123。

SELECT LENGTH('你好')，运行结果为 6。

SELECT CHAR_LENGTH('你好')，运行结果为 2。

SELECT LOWER('ABC')，运行结果为 abc。

SELECT UPPER('abc')，运行结果 ABC。

SELECT REPLACE('fabcd', 'abc', 123)，运行结果为 f123d。

SELECT SUBSTRING('fabcd', 1,3)，运行结果为 fab。

日期函数

日期函数是对数据表中的日期进行处理，常用的函数包括：

下面是一些简单的例子，你可自己运行下：

SELECT CURRENT_DATE()，运行结果为 2019-04-03。

SELECT CURRENT_TIME()，运行结果为 21:26:34。

SELECT CURRENT_TIMESTAMP()，运行结果为 2019-04-03 21:26:34。

SELECT EXTRACT(YEAR FROM '2019-04-03')，运行结果为 2019。

SELECT DATE('2019-04-01 12:00:05')，运行结果为 2019-04-01。

这里需要注意的是，DATE 日期格式必须是 yyyy-mm-dd 的形式。如果要进行日期比较，就要使用 DATE 函数，不要直接使用日期与字符串进行比较，我会在后面的例子中讲具体的原因。

转换函数

转换函数可以转换数据之间的类型，常用的函数如下表所示：

这两个函数不像其他函数，看一眼函数名就知道代表什么、如何使用。下面举了这两个函数的例子，你需要自己运行下：

SELECT CAST(123.123 AS INT)，运行结果会报错。

SELECT CAST(123.123 AS DECIMAL(8,2))，运行结果为 123.12。

SELECT COALESCE(null,1,2)，运行结果为 1。

CAST 函数在转换数据类型的时候，不会四舍五入，如果原数值有小数，那么转换为整数类型的时候就会报错。不过你可以指定转化的小数类型，在 MySQL 和 SQL Server 中，你可以用DECIMAL(a,b)来指定，其中 a 代表整数部分和小数部分加起来最大的位数，b 代表小数位数，比如DECIMAL(8,2)代表的是精度为 8 位（整数加小数位数最多为 8 位），小数位数为 2 位的数据类型。所以SELECT CAST(123.123 AS DECIMAL(8,2))的转换结果为 123.12。

用 SQL 函数对王者荣耀英雄数据做处理

我创建了一个王者荣耀英雄数据库，一共有 69 个英雄，23 个属性值。SQL 文件见 Github 地址：https://github.com/cystanford/sql_heros_data。

我们现在把这个文件导入到 MySQL 中，你可以使用 Navicat 可视化数据库管理工具将.sql 文件导入到数据库中。数据表为 heros，然后使用今天学习的 SQL 函数，对这个英雄数据表进行处理。

首先显示英雄以及他的物攻成长，对应字段为attack_growth。我们让这个字段精确到小数点后一位，需要使用的是算术函数里的 ROUND 函数。

SQL：SELECT name, ROUND(attack_growth,1) FROM heros

代码中，ROUND(attack_growth,1)中的attack_growth代表想要处理的数据，“1”代表四舍五入的位数，也就是我们这里需要精确到的位数。

运行结果为：

假设我们想显示英雄最大生命值的最大值，就需要用到 MAX 函数。在数据中，“最大生命值”对应的列数为hp_max，在代码中的格式为MAX(hp_max)。

SQL：SELECT MAX(hp_max) FROM heros

运行结果为 9328。

假如我们想要知道最大生命值最大的是哪个英雄，以及对应的数值，就需要分成两个步骤来处理：首先找到英雄的最大生命值的最大值，即SELECT MAX(hp_max) FROM heros，然后再筛选最大生命值等于这个最大值的英雄，如下所示。

SQL：SELECT name, hp_max FROM heros WHERE hp_max = (SELECT MAX(hp_max) FROM heros)

运行结果：

假如我们想显示英雄的名字，以及他们的名字字数，需要用到CHAR_LENGTH函数。

SQL：SELECT CHAR_LENGTH(name), name FROM heros

运行结果为：

假如想要提取英雄上线日期（对应字段 birthdate）的年份，只显示有上线日期的英雄即可（有些英雄没有上线日期的数据，不需要显示），这里我们需要使用 EXTRACT 函数，提取某一个时间元素。所以我们需要筛选上线日期不为空的英雄，即WHERE birthdate is not null，然后再显示他们的名字和上线日期的年份，即：

SQL： SELECT name, EXTRACT(YEAR FROM birthdate) AS birthdate FROM heros WHERE birthdate is NOT NULL

或者使用如下形式：

SQL: SELECT name, YEAR(birthdate) AS birthdate FROM heros WHERE birthdate is NOT NULL

运行结果为：

假设我们需要找出在 2016 年 10 月 1 日之后上线的所有英雄。这里我们可以采用 DATE 函数来判断 birthdate 的日期是否大于 2016-10-01，即WHERE DATE(birthdate)>'2016-10-01'，然后再显示符合要求的全部字段信息，即：

SQL： SELECT * FROM heros WHERE DATE(birthdate)>'2016-10-01'

需要注意的是下面这种写法是不安全的：

SELECT * FROM heros WHERE birthdate>'2016-10-01'

因为很多时候你无法确认 birthdate 的数据类型是字符串，还是 datetime 类型，如果你想对日期部分进行比较，那么使用DATE(birthdate)来进行比较是更安全的。

运行结果为：

假设我们需要知道在 2016 年 10 月 1 日之后上线英雄的平均最大生命值、平均最大法力和最高物攻最大值。同样我们需要先筛选日期条件，即WHERE DATE(birthdate)>'2016-10-01'，然后再选择AVG(hp_max), AVG(mp_max), MAX(attack_max)字段进行显示。

SQL： SELECT AVG(hp_max), AVG(mp_max), MAX(attack_max) FROM heros WHERE DATE(birthdate)>'2016-10-01'

运行结果为：

为什么使用 SQL 函数会带来问题

尽管 SQL 函数使用起来会很方便，但我们使用的时候还是要谨慎，因为你使用的函数很可能在运行环境中无法工作，这是为什么呢？

如果你学习过编程语言，就会知道语言是有不同版本的，比如 Python 会有 2.7 版本和 3.x 版本，不过它们之间的函数差异不大，也就在 10% 左右。但我们在使用 SQL 语言的时候，不是直接和这门语言打交道，而是通过它使用不同的数据库软件，即 DBMS。DBMS 之间的差异性很大，远大于同一个语言不同版本之间的差异。实际上，只有很少的函数是被 DBMS 同时支持的。比如，大多数 DBMS 使用（||）或者（+）来做拼接符，而在 MySQL 中的字符串拼接函数为Concat()。大部分 DBMS 会有自己特定的函数，这就意味着采用 SQL 函数的代码可移植性是很差的，因此在使用函数的时候需要特别注意。

关于大小写的规范

细心的人可能会发现，我在写 SELECT 语句的时候用的是大写，而你在网上很多地方，包括你自己写的时候可能用的是小写。实际上在 SQL 中，关键字和函数名是不用区分字母大小写的，比如 SELECT、WHERE、ORDER、GROUP BY 等关键字，以及 ABS、MOD、ROUND、MAX 等函数名。

不过在 SQL 中，你还是要确定大小写的规范，因为在 Linux 和 Windows 环境下，你可能会遇到不同的大小写问题。

比如 MySQL 在 Linux 的环境下，数据库名、表名、变量名是严格区分大小写的，而字段名是忽略大小写的。

而 MySQL 在 Windows 的环境下全部不区分大小写。

这就意味着如果你的变量名命名规范没有统一，就可能产生错误。这里有一个有关命名规范的建议：

关键字和函数名称全部大写；

数据库名、表名、字段名称全部小写；

SQL 语句必须以分号结尾。

虽然关键字和函数名称在 SQL 中不区分大小写，也就是如果小写的话同样可以执行，但是数据库名、表名和字段名在 Linux MySQL 环境下是区分大小写的，因此建议你统一这些字段的命名规则，比如全部采用小写的方式。同时将关键词和函数名称全部大写，以便于区分数据库名、表名、字段名。

总结

函数对于一门语言的重要性毋庸置疑，我们在写 Python 代码的时候，会自己编写函数，也会使用 Python 内置的函数。在 SQL 中，使用函数的时候需要格外留意。不过如果工程量不大，使用的是同一个 DBMS 的话，还是可以使用函数简化操作的，这样也能提高代码效率。只是在系统集成，或者在多个 DBMS 同时存在的情况下，使用函数的时候就需要慎重一些。

比如CONCAT()是字符串拼接函数，在 MySQL 和 Oracle 中都有这个函数，但是在这两个 DBMS 中作用却不一样，CONCAT函数在 MySQL 中可以连接多个字符串，而在 Oracle 中CONCAT函数只能连接两个字符串，如果要连接多个字符串就需要用（||）连字符来解决。

讲完了 SQL 函数的使用，我们来做一道练习题。还是根据王者荣耀英雄数据表，请你使用 SQL 函数作如下的练习：计算英雄的最大生命平均值；显示出所有在 2017 年之前上线的英雄，如果英雄没有统计上线日期则不显示。

欢迎你在评论区与我分享你的答案，也欢迎点击”请朋友读“，把这篇文章分享给你的朋友或者同事。

08丨什么是SQL的聚集函数，如何利用它们汇总表的数据？

我们上节课讲到了 SQL 函数，包括算术函数、字符串函数、日期函数和转换函数。实际上 SQL 函数还有一种，叫做聚集函数，它是对一组数据进行汇总的函数，输入的是一组数据的集合，输出的是单个值。通常我们可以利用聚集函数汇总表的数据，如果稍微复杂一些，我们还需要先对数据做筛选，然后再进行聚集，比如先按照某个条件进行分组，对分组条件进行筛选，然后得到筛选后的分组的汇总信息。

有关今天的内容，你重点需要掌握以下几个方面：

聚集函数都有哪些，能否在一条 SELECT 语句中使用多个聚集函数；

如何对数据进行分组，并进行聚集统计；

如何使用 HAVING 过滤分组，HAVING 和 WHERE 的区别是什么。

聚集函数都有哪些

SQL 中的聚集函数一共包括 5 个，可以帮我们求某列的最大值、最小值和平均值等，它们分别是：

这些函数你可能已经接触过，我们再来简单复习一遍。我们继续使用 heros 数据表，对王者荣耀的英雄数据进行聚合。

如果我们想要查询最大生命值大于 6000 的英雄数量。

SQL：SELECT COUNT(*) FROM heros WHERE hp_max > 6000

运行结果为 41。

如果想要查询最大生命值大于 6000，且有次要定位的英雄数量，需要使用 COUNT 函数。

SQL：SELECT COUNT(role_assist) FROM heros WHERE hp_max > 6000

运行结果是 23。

需要说明的是，有些英雄没有次要定位，即 role_assist 为 NULL，这时COUNT(role_assist)会忽略值为 NULL 的数据行，而 COUNT(*) 只是统计数据行数，不管某个字段是否为 NULL。

如果我们想要查询射手（主要定位或者次要定位是射手）的最大生命值的最大值是多少，需要使用 MAX 函数。

SQL：SELECT MAX(hp_max) FROM heros WHERE role_main = '射手' or role_assist = '射手'

运行结果为 6014。

你能看到，上面的例子里，都是在一条 SELECT 语句中使用了一次聚集函数，实际上我们也可以在一条 SELECT 语句中进行多项聚集函数的查询，比如我们想知道射手（主要定位或者次要定位是射手）的英雄数、平均最大生命值、法力最大值的最大值、攻击最大值的最小值，以及这些英雄总的防御最大值等汇总数据。

如果想要知道英雄的数量，我们使用的是 COUNT(*) 函数，求平均值、最大值、最小值，以及总的防御最大值，我们分别使用的是 AVG、MAX、MIN 和 SUM 函数。另外我们还需要对英雄的主要定位和次要定位进行筛选，使用的是WHERE role_main = '射手' or role_assist = '射手'。

SQL: SELECT COUNT(*), AVG(hp_max), MAX(mp_max), MIN(attack_max), SUM(defense_max) FROM heros WHERE role_main = '射手' or role_assist = '射手'

运行结果：

需要说明的是 AVG、MAX、MIN 等聚集函数会自动忽略值为 NULL 的数据行，MAX 和 MIN 函数也可以用于字符串类型数据的统计，如果是英文字母，则按照 A—Z 的顺序排列，越往后，数值越大。如果是汉字则按照全拼拼音进行排列。比如：

SQL：SELECT MIN(CONVERT(name USING gbk)), MAX(CONVERT(name USING gbk)) FROM heros

运行结果：

需要说明的是，我们需要先把 name 字段统一转化为 gbk 类型，使用CONVERT(name USING gbk)，然后再使用 MIN 和 MAX 取最小值和最大值。

我们也可以对数据行中不同的取值进行聚集，先用 DISTINCT 函数取不同的数据，然后再使用聚集函数。比如我们想要查询不同的生命最大值的英雄数量是多少。

SQL: SELECT COUNT(DISTINCT hp_max) FROM heros

运行结果为 61。

实际上在 heros 这个数据表中，一共有 69 个英雄数量，生命最大值不一样的英雄数量是 61 个。

假如我们想要统计不同生命最大值英雄的平均生命最大值，保留小数点后两位。首先需要取不同生命最大值，即DISTINCT hp_max，然后针对它们取平均值，即AVG(DISTINCT hp_max)，最后再针对这个值保留小数点两位，也就是ROUND(AVG(DISTINCT hp_max), 2)。

SQL: SELECT ROUND(AVG(DISTINCT hp_max), 2) FROM heros

运行结果为 6653.84。

你能看到，如果我们不使用 DISTINCT 函数，就是对全部数据进行聚集统计。如果使用了 DISTINCT 函数，就可以对数值不同的数据进行聚集。一般我们使用 MAX 和 MIN 函数统计数据行的时候，不需要再额外使用 DISTINCT，因为使用 DISTINCT 和全部数据行进行最大值、最小值的统计结果是相等的。

如何对数据进行分组，并进行聚集统计

我们在做统计的时候，可能需要先对数据按照不同的数值进行分组，然后对这些分好的组进行聚集统计。对数据进行分组，需要使用 GROUP BY 子句。

比如我们想按照英雄的主要定位进行分组，并统计每组的英雄数量。

SQL: SELECT COUNT(*), role_main FROM heros GROUP BY role_main

运行结果（6 条记录）：

如果我们想要对英雄按照次要定位进行分组，并统计每组英雄的数量。

SELECT COUNT(*), role_assist FROM heros GROUP BY role_assist

运行结果：（6 条记录）

你能看出如果字段为 NULL，也会被列为一个分组。在这个查询统计中，次要定位为 NULL，即只有一个主要定位的英雄是 40 个。

我们也可以使用多个字段进行分组，这就相当于把这些字段可能出现的所有的取值情况都进行分组。比如，我们想要按照英雄的主要定位、次要定位进行分组，查看这些英雄的数量，并按照这些分组的英雄数量从高到低进行排序。

SELECT COUNT(*) as num, role_main, role_assist FROM heros GROUP BY role_main, role_assist ORDER BY num DESC

运行结果：（19 条记录）

如何使用 HAVING 过滤分组，它与 WHERE 的区别是什么？

当我们创建出很多分组的时候，有时候就需要对分组进行过滤。你可能首先会想到 WHERE 子句，实际上过滤分组我们使用的是 HAVING。HAVING 的作用和 WHERE 一样，都是起到过滤的作用，只不过 WHERE 是用于数据行，而 HAVING 则作用于分组。

比如我们想要按照英雄的主要定位、次要定位进行分组，并且筛选分组中英雄数量大于 5 的组，最后按照分组中的英雄数量从高到低进行排序。

首先我们需要获取的是英雄的数量、主要定位和次要定位，即SELECT COUNT(*) as num, role_main, role_assist。然后按照英雄的主要定位和次要定位进行分组，即GROUP BY role_main, role_assist，同时我们要对分组中的英雄数量进行筛选，选择大于 5 的分组，即HAVING num > 5，然后按照英雄数量从高到低进行排序，即ORDER BY num DESC。

SQL: SELECT COUNT(*) as num, role_main, role_assist FROM heros GROUP BY role_main, role_assist HAVING num > 5 ORDER BY num DESC

运行结果：（4 条记录）

你能看到还是上面这个分组，只不过我们按照数量进行了过滤，筛选了数量大于 5 的分组进行输出。如果把 HAVING 替换成了 WHERE，SQL 则会报错。对于分组的筛选，我们一定要用 HAVING，而不是 WHERE。另外你需要知道的是，HAVING 支持所有 WHERE 的操作，因此所有需要 WHERE 子句实现的功能，你都可以使用 HAVING 对分组进行筛选。

我们再来看个例子，通过这个例子查看一下 WHERE 和 HAVING 进行条件过滤的区别。筛选最大生命值大于 6000 的英雄，按照主要定位、次要定位进行分组，并且显示分组中英雄数量大于 5 的分组，按照数量从高到低进行排序。

SQL: SELECT COUNT(*) as num, role_main, role_assist FROM heros WHERE hp_max > 6000 GROUP BY role_main, role_assist HAVING num > 5 ORDER BY num DESC

运行结果：（2 条记录）

你能看到，还是针对上一个例子的查询，只是我们先增加了一个过滤条件，即筛选最大生命值大于 6000 的英雄。这里我们就需要先使用 WHERE 子句对最大生命值大于 6000 的英雄进行条件过滤，然后再使用 GROUP BY 进行分组，使用 HAVING 进行分组的条件判断，然后使用 ORDER BY 进行排序。

总结

今天我对 SQL 的聚集函数进行了讲解。通常我们还会对数据先进行分组，然后再使用聚集函数统计不同组的数据概况，比如数据行数、平均值、最大值、最小值以及求和等。我们也可以使用 HAVING 对分组进行过滤，然后通过 ORDER BY 按照某个字段的顺序进行排序输出。有时候你能看到在一条 SELECT 语句中，可能会包括多个子句，用 WHERE 进行数据量的过滤，用 GROUP BY 进行分组，用 HAVING 进行分组过滤，用 ORDER BY 进行排序……

你要记住，在 SELECT 查询中，关键字的顺序是不能颠倒的，它们的顺序是：

SELECT ... FROM ... WHERE ... GROUP BY ... HAVING ... ORDER BY ...

另外需要注意的是，使用 GROUP BY 进行分组，如果想让输出的结果有序，可以在 GROUP BY 后使用 ORDER BY。因为 GROUP BY 只起到了分组的作用，排序还是需要通过 ORDER BY 来完成。

我今天对 SQL 的聚集函数以及 SQL 查询中的关键字顺序进行了讲解，但你还是需要通过训练加深理解，基于 heros 数据表，请你写出下面 2 个 SQL 查询语句：

筛选最大生命值大于 6000 的英雄，按照主要定位进行分组，选择分组英雄数量大于 5 的分组，按照分组英雄数从高到低进行排序，并显示每个分组的英雄数量、主要定位和平均最大生命值。

筛选最大生命值与最大法力值之和大于 7000 的英雄，按照攻击范围来进行分组，显示分组的英雄数量，以及分组英雄的最大生命值与法力值之和的平均值、最大值和最小值，并按照分组英雄数从高到低进行排序，其中聚集函数的结果包括小数点后两位。

欢迎你在评论区与我分享你的答案，如果你觉得这篇文章有帮助，欢迎把它分享给你的朋友或者同事，一起切磋交流一下。

09丨子查询：子查询的种类都有哪些，如何提高子查询的性能？

上节课我讲到了聚集函数，以及如何对数据进行分组统计，可以说我们之前讲的内容都是围绕单个表的 SELECT 查询展开的，实际上 SQL 还允许我们进行子查询，也就是嵌套在查询中的查询。这样做的好处是可以让我们进行更复杂的查询，同时更加容易理解查询的过程。因为很多时候，我们无法直接从数据表中得到查询结果，需要从查询结果集中再次进行查询，才能得到想要的结果。这个“查询结果集”就是今天我们要讲的子查询。

通过今天的文章，我希望你可以掌握以下的内容：

子查询可以分为关联子查询和非关联子查询。我会举一个 NBA 数据库查询的例子，告诉你什么是关联子查询，什么是非关联子查询；

子查询中有一些关键词，可以方便我们对子查询的结果进行比较。比如存在性检测子查询，也就是 EXISTS 子查询，以及集合比较子查询，其中集合比较子查询关键词有 IN、SOME、 ANY 和 ALL，这些关键词在子查询中的作用是什么；

子查询也可以作为主查询的列，我们如何使用子查询作为计算字段出现在 SELECT 查询中呢？

什么是关联子查询，什么是非关联子查询

子查询虽然是一种嵌套查询的形式，不过我们依然可以依据子查询是否执行多次，从而将子查询划分为关联子查询和非关联子查询。

子查询从数据表中查询了数据结果，如果这个数据结果只执行一次，然后这个数据结果作为主查询的条件进行执行，那么这样的子查询叫做非关联子查询。

同样，如果子查询需要执行多次，即采用循环的方式，先从外部查询开始，每次都传入子查询进行查询，然后再将结果反馈给外部，这种嵌套的执行方式就称为关联子查询。

单说概念有点抽象，我们用数据表举例说明一下。这里我创建了 NBA 球员数据库，SQL 文件你可以从GitHub上下载。

文件中一共包括了 5 张表，player 表为球员表，team 为球队表，team_score 为球队比赛表，player_score 为球员比赛成绩表，height_grades 为球员身高对应的等级表。

其中 player 表，也就是球员表，一共有 37 个球员，如下所示：

team 表为球队表，一共有 3 支球队，如下所示：

team_score 表为球队比赛成绩表，一共记录了两场比赛的成绩，如下所示：

player_score 表为球员比赛成绩表，记录了一场比赛中球员的表现。这张表一共包括 19 个字段，代表的含义如下：

其中 shoot_attempts 代表总出手的次数，它等于二分球出手和三分球出手次数的总和。比如 2019 年 4 月 1 日，韦恩·艾灵顿在底特律活塞和印第安纳步行者的比赛中，总出手次数为 19，总命中 10，三分球 13 投 4 中，罚球 4 罚 2 中，因此总分 score=(10-4)×2+4×3+2=26，也就是二分球得分 12+ 三分球得分 12+ 罚球得分 2=26。

需要说明的是，通常在工作中，数据表的字段比较多，一开始创建的时候会知道每个字段的定义，过了一段时间再回过头来看，对当初的定义就不那么确定了，容易混淆字段，解决这一问题最好的方式就是做个说明文档，用实例举例。

比如 shoot_attempts 是总出手次数（这里的总出手次数 = 二分球出手次数 + 三分球出手次数，不包括罚球的次数），用上面提到的韦恩·艾灵顿的例子做补充说明，再回过头来看这张表的时候，就可以很容易理解每个字段的定义了。

我们以 NBA 球员数据表为例，假设我们想要知道哪个球员的身高最高，最高身高是多少，就可以采用子查询的方式：

SQL: SELECT player_name, height FROM player WHERE height = (SELECT max(height) FROM player)

运行结果：（1 条记录）

你能看到，通过SELECT max(height) FROM player可以得到最高身高这个数值，结果为 2.16，然后我们再通过 player 这个表，看谁具有这个身高，再进行输出，这样的子查询就是非关联子查询。

如果子查询的执行依赖于外部查询，通常情况下都是因为子查询中的表用到了外部的表，并进行了条件关联，因此每执行一次外部查询，子查询都要重新计算一次，这样的子查询就称之为关联子查询。比如我们想要查找每个球队中大于平均身高的球员有哪些，并显示他们的球员姓名、身高以及所在球队 ID。

首先我们需要统计球队的平均身高，即SELECT avg(height) FROM player AS b WHERE a.team_id = b.team_id，然后筛选身高大于这个数值的球员姓名、身高和球队 ID，即：

SELECT player_name, height, team_id FROM player AS a WHERE height > (SELECT avg(height) FROM player AS b WHERE a.team_id = b.team_id)

运行结果：（18 条记录）

EXISTS 子查询

关联子查询通常也会和 EXISTS 一起来使用，EXISTS 子查询用来判断条件是否满足，满足的话为 True，不满足为 False。

比如我们想要看出场过的球员都有哪些，并且显示他们的姓名、球员 ID 和球队 ID。在这个统计中，是否出场是通过 player_score 这张表中的球员出场表现来统计的，如果某个球员在 player_score 中有出场记录则代表他出场过，这里就使用到了 EXISTS 子查询，即EXISTS (SELECT player_id FROM player_score WHERE player.player_id = player_score.player_id)，然后将它作为筛选的条件，实际上也是关联子查询，即：

SQL：SELECT player_id, team_id, player_name FROM player WHERE EXISTS (SELECT player_id FROM player_score WHERE player.player_id = player_score.player_id)

运行结果：（19 条记录）

同样，NOT EXISTS 就是不存在的意思，我们也可以通过 NOT EXISTS 查询不存在于 player_score 表中的球员信息，比如主表中的 player_id 不在子表 player_score 中，判断语句为NOT EXISTS (SELECT player_id FROM player_score WHERE player.player_id = player_score.player_id)。整体的 SQL 语句为：

SQL: SELECT player_id, team_id, player_name FROM player WHERE NOT EXISTS (SELECT player_id FROM player_score WHERE player.player_id = player_score.player_id)

运行结果：（18 条记录）

集合比较子查询

集合比较子查询的作用是与另一个查询结果集进行比较，我们可以在子查询中使用 IN、ANY、ALL 和 SOME 操作符，它们的含义和英文意义一样：

还是通过上面那个例子，假设我们想要看出场过的球员都有哪些，可以采用 IN 子查询来进行操作：

SELECT player_id, team_id, player_name FROM player WHERE player_id in (SELECT player_id FROM player_score WHERE player.player_id = player_score.player_id)

你会发现运行结果和上面的是一样的，那么问题来了，既然 IN 和 EXISTS 都可以得到相同的结果，那么我们该使用 IN 还是 EXISTS 呢？

我们可以把这个模式抽象为：

SELECT * FROM A WHERE cc IN (SELECT cc FROM B)

SELECT * FROM A WHERE EXIST (SELECT cc FROM B WHERE B.cc=A.cc)

实际上在查询过程中，在我们对 cc 列建立索引的情况下，我们还需要判断表 A 和表 B 的大小。在这里例子当中，表 A 指的是 player 表，表 B 指的是 player_score 表。如果表 A 比表 B 大，那么 IN 子查询的效率要比 EXIST 子查询效率高，因为这时 B 表中如果对 cc 列进行了索引，那么 IN 子查询的效率就会比较高。

同样，如果表 A 比表 B 小，那么使用 EXISTS 子查询效率会更高，因为我们可以使用到 A 表中对 cc 列的索引，而不用从 B 中进行 cc 列的查询。

了解了 IN 查询后，我们来看下 ANY 和 ALL 子查询。刚才讲到了 ANY 和 ALL 都需要使用比较符，比较符包括了（>）（=）（<）（>=）（<=）和（<>）等。

如果我们想要查询球员表中，比印第安纳步行者（对应的 team_id 为 1002）中任何一个球员身高高的球员的信息，并且输出他们的球员 ID、球员姓名和球员身高，该怎么写呢？首先我们需要找出所有印第安纳步行者队中的球员身高，即SELECT height FROM player WHERE team_id = 1002，然后使用 ANY 子查询即：

SQL: SELECT player_id, player_name, height FROM player WHERE height > ANY (SELECT height FROM player WHERE team_id = 1002)

运行结果：（35 条记录）

运行结果为 35 条，你发现有 2 个人的身高是不如印第安纳步行者的所有球员的。

同样，如果我们想要知道比印第安纳步行者（对应的 team_id 为 1002）中所有球员身高都高的球员的信息，并且输出球员 ID、球员姓名和球员身高，该怎么写呢？

SQL: SELECT player_id, player_name, height FROM player WHERE height > ALL (SELECT height FROM player WHERE team_id = 1002)

运行结果：（1 条记录）

我们能看到比印第安纳步行者所有球员都高的球员，在 player 这张表（一共 37 个球员）中只有索恩·马克。

需要强调的是 ANY、ALL 关键字必须与一个比较操作符一起使用。因为如果你不使用比较操作符，就起不到集合比较的作用，那么使用 ANY 和 ALL 就没有任何意义。

将子查询作为计算字段

我刚才讲了子查询的几种用法，实际上子查询也可以作为主查询的计算字段。比如我想查询每个球队的球员数，也就是对应 team 这张表，我需要查询相同的 team_id 在 player 这张表中所有的球员数量是多少。

SQL: SELECT team_name, (SELECT count(*) FROM player WHERE player.team_id = team.team_id) AS player_num FROM team

运行结果：（3 条记录）

你能看到，在 player 表中只有底特律活塞和印第安纳步行者的球员数据，所以它们的 player_num 不为 0，而亚特兰大老鹰的 player_num 等于 0。在查询的时候，我将子查询SELECT count(*) FROM player WHERE player.team_id = team.team_id作为了计算字段，通常我们需要给这个计算字段起一个别名，这里我用的是 player_num，因为子查询的语句比较长，使用别名更容易理解。

总结

今天我讲解了子查询的使用，按照子查询执行的次数，我们可以将子查询分成关联子查询和非关联子查询，其中非关联子查询与主查询的执行无关，只需要执行一次即可，而关联子查询，则需要将主查询的字段值传入子查询中进行关联查询。

同时，在子查询中你可能会使用到 EXISTS、IN、ANY、ALL 和 SOME 等关键字。在某些情况下使用 EXISTS 和 IN 可以得到相同的效果，具体使用哪个执行效率更高，则需要看字段的索引情况以及表 A 和表 B 哪个表更大。同样，IN、ANY、ALL、SOME 这些关键字是用于集合比较的，SOME 是 ANY 的别名，当我们使用 ANY 或 ALL 的时候，一定要使用比较操作符。

最后，我讲解了如何使用子查询作为计算字段，把子查询的结果作为主查询的列。

SQL 中，子查询的使用大大增强了 SELECT 查询的能力，因为很多时候查询需要从结果集中获取数据，或者需要从同一个表中先计算得出一个数据结果，然后与这个数据结果（可能是某个标量，也可能是某个集合）进行比较。

我今天讲解了子查询的使用，其中讲到了 EXISTS 和 IN 子查询效率的比较，当查询字段进行了索引时，主表 A 大于从表 B，使用 IN 子查询效率更高，相反主表 A 小于从表 B 时，使用 EXISTS 子查询效率更高，同样，如果使用 NOT IN 子查询和 NOT EXISTS 子查询，在什么情况下，哪个效率更高呢？

最后请你使用子查询，编写 SQL 语句，得到场均得分大于 20 的球员。场均得分从 player_score 表中获取，同时你需要输出球员的 ID、球员姓名以及所在球队的 ID 信息。

欢迎在评论区写下你的思考，也欢迎点击请朋友读把这篇文章分享给你的朋友或者同事。

10丨常用的SQL标准有哪些，在SQL92中是如何使用连接的？

今天我主要讲解连接表的操作。在讲解之前，我想先给你介绍下连接（JOIN）在 SQL 中的重要性。

我们知道 SQL 的英文全称叫做 Structured Query Language，它有一个很强大的功能，就是能在各个数据表之间进行连接查询（Query）。这是因为 SQL 是建立在关系型数据库基础上的一种语言。关系型数据库的典型数据结构就是数据表，这些数据表的组成都是结构化的（Structured）。你可以把关系模型理解成一个二维表格模型，这个二维表格是由行（row）和列（column）组成的。每一个行（row）就是一条数据，每一列（column）就是数据在某一维度的属性。

正是因为在数据库中，表的组成是基于关系模型的，所以一个表就是一个关系。一个数据库中可以包括多个表，也就是存在多种数据之间的关系。而我们之所以能使用 SQL 语言对各个数据表进行复杂查询，核心就在于连接，它可以用一条 SELECT 语句在多张表之间进行查询。你也可以理解为，关系型数据库的核心之一就是连接。

既然连接在 SQL 中这么重要，那么针对今天的内容，需要你从以下几个方面进行掌握：

SQL 实际上存在不同的标准，不同标准下的连接定义也有不同。你首先需要了解常用的 SQL 标准有哪些；

了解了 SQL 的标准之后，我们从 SQL92 标准入门，来看下连接表的种类有哪些；

针对一个实际的数据库表，如果你想要做数据统计，需要学会使用跨表的连接进行操作。

常用的 SQL 标准有哪些

在正式开始讲连接表的种类时，我们首先需要知道 SQL 存在不同版本的标准规范，因为不同规范下的表连接操作是有区别的。

SQL 有两个主要的标准，分别是 SQL92 和 SQL99。92 和 99 代表了标准提出的时间，SQL92 就是 92 年提出的标准规范。当然除了 SQL92 和 SQL99 以外，还存在 SQL-86、SQL-89、SQL:2003、SQL:2008、SQL:2011 和 SQL:2016 等其他的标准。

这么多标准，到底该学习哪个呢？实际上最重要的 SQL 标准就是 SQL92 和 SQL99。一般来说 SQL92 的形式更简单，但是写的 SQL 语句会比较长，可读性较差。而 SQL99 相比于 SQL92 来说，语法更加复杂，但可读性更强。我们从这两个标准发布的页数也能看出，SQL92 的标准有 500 页，而 SQL99 标准超过了 1000 页。实际上你不用担心要学习这么多内容，基本上从 SQL99 之后，很少有人能掌握所有内容，因为确实太多了。就好比我们使用 Windows、Linux 和 Office 的时候，很少有人能掌握全部内容一样。我们只需要掌握一些核心的功能，满足日常工作的需求即可。

在 SQL92 中是如何使用连接的

相比于 SQL99，SQL92 规则更简单，更适合入门。在这篇文章中，我会先讲 SQL92 是如何对连接表进行操作的，下一篇文章再讲 SQL99，到时候你可以对比下这两者之间有什么区别。

在进行连接之前，我们需要用数据表做举例。这里我创建了 NBA 球员和球队两张表，SQL 文件你可以从GitHub上下载。

其中 player 表为球员表，一共有 37 个球员，如下所示：

team 表为球队表，一共有 3 支球队，如下所示：

有了这两个数据表之后，我们再来看下 SQL92 中的 5 种连接方式，它们分别是笛卡尔积、等值连接、非等值连接、外连接（左连接、右连接）和自连接。

笛卡尔积

笛卡尔乘积是一个数学运算。假设我有两个集合 X 和 Y，那么 X 和 Y 的笛卡尔积就是 X 和 Y 的所有可能组合，也就是第一个对象来自于 X，第二个对象来自于 Y 的所有可能。

我们假定 player 表的数据是集合 X，先进行 SQL 查询：

SELECT * FROM player

再假定 team 表的数据为集合 Y，同样需要进行 SQL 查询：

SELECT * FROM team

你会看到运行结果会显示出上面的两张表格。

接着我们再来看下两张表的笛卡尔积的结果，这是笛卡尔积的调用方式：

SQL: SELECT * FROM player, team

运行结果（一共 37*3=111 条记录）：

笛卡尔积也称为交叉连接，英文是 CROSS JOIN，它的作用就是可以把任意表进行连接，即使这两张表不相关。但我们通常进行连接还是需要筛选的，因此你需要在连接后面加上 WHERE 子句，也就是作为过滤条件对连接数据进行筛选。比如后面要讲到的等值连接。

等值连接

两张表的等值连接就是用两张表中都存在的列进行连接。我们也可以对多张表进行等值连接。

针对 player 表和 team 表都存在 team_id 这一列，我们可以用等值连接进行查询。

SQL: SELECT player_id, player.team_id, player_name, height, team_name FROM player, team WHERE player.team_id = team.team_id

运行结果（一共 37 条记录）：

我们在进行等值连接的时候，可以使用表的别名，这样会让 SQL 语句更简洁：

SELECT player_id, a.team_id, player_name, height, team_name FROM player AS a, team AS b WHERE a.team_id = b.team_id

需要注意的是，如果我们使用了表的别名，在查询字段中就只能使用别名进行代替，不能使用原有的表名，比如下面的 SQL 查询就会报错：

SELECT player_id, player.team_id, player_name, height, team_name FROM player AS a, team AS b WHERE a.team_id = b.team_id

非等值连接

当我们进行多表查询的时候，如果连接多个表的条件是等号时，就是等值连接，其他的运算符连接就是非等值查询。

这里我创建一个身高级别表 height_grades，如下所示：

我们知道 player 表中有身高 height 字段，如果想要知道每个球员的身高的级别，可以采用非等值连接查询。

SQL：SELECT p.player_name, p.height, h.height_level

FROM player AS p, height_grades AS h

WHERE p.height BETWEEN h.height_lowest AND h.height_highest

运行结果（37 条记录）：

外连接

除了查询满足条件的记录以外，外连接还可以查询某一方不满足条件的记录。两张表的外连接，会有一张是主表，另一张是从表。如果是多张表的外连接，那么第一张表是主表，即显示全部的行，而第剩下的表则显示对应连接的信息。在 SQL92 中采用（+）代表从表所在的位置，而且在 SQL92 中，只有左外连接和右外连接，没有全外连接。

什么是左外连接，什么是右外连接呢？

左外连接，就是指左边的表是主表，需要显示左边表的全部行，而右侧的表是从表，（+）表示哪个是从表。

SQL：SELECT * FROM player, team where player.team_id = team.team_id(+)

相当于 SQL99 中的：

SQL：SELECT * FROM player LEFT JOIN team on player.team_id = team.team_id

右外连接，指的就是右边的表是主表，需要显示右边表的全部行，而左侧的表是从表。

SQL：SELECT * FROM player, team where player.team_id(+) = team.team_id

相当于 SQL99 中的：

SQL：SELECT * FROM player RIGHT JOIN team on player.team_id = team.team_id

需要注意的是，LEFT JOIN 和 RIGHT JOIN 只存在于 SQL99 及以后的标准中，在 SQL92 中不存在，只能用（+）表示。

自连接

自连接可以对多个表进行操作，也可以对同一个表进行操作。也就是说查询条件使用了当前表的字段。

比如我们想要查看比布雷克·格里芬高的球员都有谁，以及他们的对应身高：

SQL：SELECT b.player_name, b.height FROM player as a , player as b WHERE a.player_name = '布雷克 - 格里芬' and a.height < b.height

运行结果（6 条记录）：

如果不用自连接的话，需要采用两次 SQL 查询。首先需要查询布雷克·格里芬的身高。

SQL：SELECT height FROM player WHERE player_name = '布雷克 - 格里芬'

运行结果为 2.08。

然后再查询比 2.08 高的球员都有谁，以及他们的对应身高：

SQL：SELECT player_name, height FROM player WHERE height > 2.08

运行结果和采用自连接的运行结果是一致的。

总结

今天我讲解了常用的 SQL 标准以及 SQL92 中的连接操作。SQL92 和 SQL99 是经典的 SQL 标准，也分别叫做 SQL-2 和 SQL-3 标准。也正是在这两个标准发布之后，SQL 影响力越来越大，甚至超越了数据库领域。现如今 SQL 已经不仅仅是数据库领域的主流语言，还是信息领域中信息处理的主流语言。在图形检索、图像检索以及语音检索中都能看到 SQL 语言的使用。

除此以外，我们使用的主流 RDBMS，比如 MySQL、Oracle、SQL Sever、DB2、PostgreSQL 等都支持 SQL 语言，也就是说它们的使用符合大部分 SQL 标准，但很难完全符合，因为这些数据库管理系统都在 SQL 语言的基础上，根据自身产品的特点进行了扩充。即使这样，SQL 语言也是目前所有语言中半衰期最长的，在 1992 年，Windows3.1 发布，SQL92 标准也同时发布，如今我们早已不使用 Windows3.1 操作系统，而 SQL92 标准却一直持续至今。

当然我们也要注意到 SQL 标准的变化，以及不同数据库管理系统使用时的差别，比如 Oracle 对 SQL92 支持较好，而 MySQL 则不支持 SQL92 的外连接。

我今天讲解了 SQL 的连接操作，你能说说内连接、外连接和自连接指的是什么吗？另外，你不妨拿案例中的 team 表做一道动手题，表格中一共有 3 支球队，现在这 3 支球队需要进行比赛，请用一条 SQL 语句显示出所有可能的比赛组合。

欢迎你在评论区写下你的答案，也欢迎把这篇文章分享给你的朋友或者同事，与他们一起交流一下。

11丨SQL99是如何使用连接的，与SQL92的区别是什么？

上节课我们讲解了 SQL92 标准，在它之后又提出了 SQL99 标准。现在各大 DBMS 中对 SQL99 标准的支持度更好。你一定听说过 LEFT JOIN、RIGHT JOIN 这样的操作符，这实际上就是 SQL99 的标准，在 SQL92 中它们是用（+）代替的。SQL92 和 SQL99 标准原理类似，只是 SQL99 标准的可读性更强。

今天我就来讲解一下 SQL99 标准中的连接查询，在今天的课程中你需要重点掌握以下几方面的内容：

SQL99 标准下的连接查询是如何操作的？

SQL99 与 SQL92 的区别是什么？

在不同的 DBMS 中，使用连接需要注意什么？

SQL99 标准中的连接查询

上一篇文章中，我用 NBA 球员的数据表进行了举例，包括了三张数据表 player、team 和 height_grades。

其中 player 表为球员表，一共有 37 个球员，如下所示：

team 表为球队表，一共有 3 支球队，如下所示：

height_grades 表为身高等级表，如下所示：

接下来我们看下在 SQL99 标准中，是如何进行连接查询的？

交叉连接

交叉连接实际上就是 SQL92 中的笛卡尔乘积，只是这里我们采用的是 CROSS JOIN。

我们可以通过下面这行代码得到 player 和 team 这两张表的笛卡尔积的结果：

SQL: SELECT * FROM player CROSS JOIN team

运行结果（一共 37*3=111 条记录）：

如果多张表进行交叉连接，比如表 t1，表 t2，表 t3 进行交叉连接，可以写成下面这样：

SQL: SELECT * FROM t1 CROSS JOIN t2 CROSS JOIN t3

自然连接

你可以把自然连接理解为 SQL92 中的等值连接。它会帮你自动查询两张连接表中所有相同的字段，然后进行等值连接。

如果我们想把 player 表和 team 表进行等值连接，相同的字段是 team_id。还记得在 SQL92 标准中，是如何编写的么？

SELECT player_id, a.team_id, player_name, height, team_name FROM player as a, team as b WHERE a.team_id = b.team_id

在 SQL99 中你可以写成：

SELECT player_id, team_id, player_name, height, team_name FROM player NATURAL JOIN team

实际上，在 SQL99 中用 NATURAL JOIN 替代了 WHERE player.team_id = team.team_id。

ON 连接

ON 连接用来指定我们想要的连接条件，针对上面的例子，它同样可以帮助我们实现自然连接的功能：

SELECT player_id, player.team_id, player_name, height, team_name FROM player JOIN team ON player.team_id = team.team_id

这里我们指定了连接条件是ON player.team_id = team.team_id，相当于是用 ON 进行了 team_id 字段的等值连接。

当然你也可以 ON 连接进行非等值连接，比如我们想要查询球员的身高等级，需要用 player 和 height_grades 两张表：

SQL99：SELECT p.player_name, p.height, h.height_level

FROM player as p JOIN height_grades as h

ON height BETWEEN h.height_lowest AND h.height_highest

这个语句的运行结果和我们之前采用 SQL92 标准的查询结果一样。

SQL92：SELECT p.player_name, p.height, h.height_level

FROM player AS p, height_grades AS h

WHERE p.height BETWEEN h.height_lowest AND h.height_highest

一般来说在 SQL99 中，我们需要连接的表会采用 JOIN 进行连接，ON 指定了连接条件，后面可以是等值连接，也可以采用非等值连接。

USING 连接

当我们进行连接的时候，可以用 USING 指定数据表里的同名字段进行等值连接。比如：

SELECT player_id, team_id, player_name, height, team_name FROM player JOIN team USING(team_id)

你能看出与自然连接 NATURAL JOIN 不同的是，USING 指定了具体的相同的字段名称，你需要在 USING 的括号 () 中填入要指定的同名字段。同时使用 JOIN USING 可以简化 JOIN ON 的等值连接，它与下面的 SQL 查询结果是相同的：

SELECT player_id, player.team_id, player_name, height, team_name FROM player JOIN team ON player.team_id = team.team_id

外连接

SQL99 的外连接包括了三种形式：

左外连接：LEFT JOIN 或 LEFT OUTER JOIN

右外连接：RIGHT JOIN 或 RIGHT OUTER JOIN

全外连接：FULL JOIN 或 FULL OUTER JOIN

我们在 SQL92 中讲解了左外连接、右外连接，在 SQL99 中还有全外连接。全外连接实际上就是左外连接和右外连接的结合。在这三种外连接中，我们一般省略 OUTER 不写。

1. 左外连接

SQL92

SELECT * FROM player, team where player.team_id = team.team_id(+)

SQL99

SELECT * FROM player LEFT JOIN team ON player.team_id = team.team_id

2. 右外连接

SQL92

SELECT * FROM player, team where player.team_id(+) = team.team_id

SQL99

SELECT * FROM player RIGHT JOIN team ON player.team_id = team.team_id

3. 全外连接

SQL99

SELECT * FROM player FULL JOIN team ON player.team_id = team.team_id

需要注意的是 MySQL 不支持全外连接，否则的话全外连接会返回左表和右表中的所有行。当表之间有匹配的行，会显示内连接的结果。当某行在另一个表中没有匹配时，那么会把另一个表中选择的列显示为空值。

也就是说，全外连接的结果 = 左右表匹配的数据 + 左表没有匹配到的数据 + 右表没有匹配到的数据。

自连接

自连接的原理在 SQL92 和 SQL99 中都是一样的，只是表述方式不同。

比如我们想要查看比布雷克·格里芬身高高的球员都有哪些，在两个 SQL 标准下的查询如下。

SQL92

SELECT b.player_name, b.height FROM player as a , player as b WHERE a.player_name = '布雷克 - 格里芬' and a.height < b.height

SQL99

SELECT b.player_name, b.height FROM player as a JOIN player as b ON a.player_name = '布雷克 - 格里芬' and a.height < b.height

运行结果（6 条记录）：

SQL99 和 SQL92 的区别

至此我们讲解完了 SQL92 和 SQL99 标准下的连接查询，它们都对连接进行了定义，只是操作的方式略有不同。我们再来回顾下，这些连接操作基本上可以分成三种情况：

内连接：将多个表之间满足连接条件的数据行查询出来。它包括了等值连接、非等值连接和自连接。

外连接：会返回一个表中的所有记录，以及另一个表中匹配的行。它包括了左外连接、右外连接和全连接。

交叉连接：也称为笛卡尔积，返回左表中每一行与右表中每一行的组合。在 SQL99 中使用的 CROSS JOIN。

不过 SQL92 在这三种连接操作中，和 SQL99 还存在着明显的区别。

首先我们看下 SQL92 中的 WHERE 和 SQL99 中的 JOIN。

你能看出在 SQL92 中进行查询时，会把所有需要连接的表都放到 FROM 之后，然后在 WHERE 中写明连接的条件。而 SQL99 在这方面更灵活，它不需要一次性把所有需要连接的表都放到 FROM 之后，而是采用 JOIN 的方式，每次连接一张表，可以多次使用 JOIN 进行连接。

另外，我建议多表连接使用 SQL99 标准，因为层次性更强，可读性更强，比如：

SELECT ...

FROM table1

    JOIN table2 ON table1 和 table2 的连接条件

        JOIN table3 ON table2 和 table3 的连接条件

它的嵌套逻辑类似我们使用的 FOR 循环：

for t1 in table1:

    for t2 in table2:

       if condition1:

           for t3 in table3:

              if condition2:

                  output t1 + t2 + t3

SQL99 采用的这种嵌套结构非常清爽，即使再多的表进行连接也都清晰可见。如果你采用 SQL92，可读性就会大打折扣。

最后一点就是，SQL99 在 SQL92 的基础上提供了一些特殊语法，比如 NATURAL JOIN 和 JOIN USING。它们在实际中是比较常用的，省略了 ON 后面的等值条件判断，让 SQL 语句更加简洁。

不同 DBMS 中使用连接需要注意的地方

SQL 连接具有通用性，但是不同的 DBMS 在使用规范上会存在差异，在标准支持上也存在不同。在实际工作中，你需要参考你正在使用的 DBMS 文档，这里我整理了一些需要注意的常见的问题。

1. 不是所有的 DBMS 都支持全外连接

虽然 SQL99 标准提供了全外连接，但不是所有的 DBMS 都支持。不仅 MySQL 不支持，Access、SQLite、MariaDB 等数据库软件也不支持。不过在 Oracle、DB2、SQL Server 中是支持的。

2.Oracle 没有表别名 AS

为了让 SQL 查询语句更简洁，我们经常会使用表别名 AS，不过在 Oracle 中是不存在 AS 的，使用表别名的时候，直接在表名后面写上表别名即可，比如 player p，而不是 player AS p。

3.SQLite 的外连接只有左连接

SQLite 是一款轻量级的数据库软件，在外连接上只支持左连接，不支持右连接，不过如果你想使用右连接的方式，比如table1 RIGHT JOIN table2，在 SQLite 你可以写成table2 LEFT JOIN table1，这样就可以得到相同的效果。

除了一些常见的语法问题，还有一些关于连接的性能问题需要你注意：

1. 控制连接表的数量

多表连接就相当于嵌套 for 循环一样，非常消耗资源，会让 SQL 查询性能下降得很严重，因此不要连接不必要的表。在许多 DBMS 中，也都会有最大连接表的限制。

2. 在连接时不要忘记 WHERE 语句

多表连接的目的不是为了做笛卡尔积，而是筛选符合条件的数据行，因此在多表连接的时候不要忘记了 WHERE 语句，这样可以过滤掉不必要的数据行返回。

3. 使用自连接而不是子查询

我们在查看比布雷克·格里芬高的球员都有谁的时候，可以使用子查询，也可以使用自连接。一般情况建议你使用自连接，因为在许多 DBMS 的处理过程中，对于自连接的处理速度要比子查询快得多。你可以这样理解：子查询实际上是通过未知表进行查询后的条件判断，而自连接是通过已知的自身数据表进行条件判断，因此在大部分 DBMS 中都对自连接处理进行了优化。

总结

连接可以说是 SQL 中的核心操作，通过两篇文章的学习，你已经从多个维度对连接进行了了解。同时，我们对 SQL 的两个重要标准 SQL92 和 SQL99 进行了学习，在我们需要进行外连接的时候，建议采用 SQL99 标准，这样更适合阅读。

此外我还想强调一下，我们在进行连接的时候，使用的关系型数据库管理系统，之所以存在关系是因为各种数据表之间存在关联，它们并不是孤立存在的。在实际工作中，尤其是做业务报表的时候，我们会用到 SQL 中的连接操作（JOIN），因此我们需要理解和熟练掌握 SQL 标准中连接的使用，以及不同 DBMS 中对连接的语法规范。剩下要做的，就是通过做练习和实战来增强你的经验了，做的练习多了，也就自然有感觉了。

我今天讲解了 SQL99 的连接操作，不妨请你做一个小练习。请你编写 SQL 查询语句，查询不同身高级别（对应 height_grades 表）对应的球员数量（对应 player 表）。

欢迎你在评论区写下你的答案，我会在评论区与你一起讨论。也欢迎把这篇文章分享给你的朋友或者同事。

12丨视图在SQL中的作用是什么，它是怎样工作的？

我们之前对 SQL 中的数据表查询进行了讲解，今天我们来看下如何对视图进行查询。视图，也就是我们今天要讲的虚拟表，本身是不具有数据的，它是 SQL 中的一个重要概念。从下面这张图中，你能看到，虚拟表的创建连接了一个或多个数据表，不同的查询应用都可以建立在虚拟表之上。

视图一方面可以帮我们使用表的一部分而不是所有的表，另一方面也可以针对不同的用户制定不同的查询视图。比如，针对一个公司的销售人员，我们只想给他看部分数据，而某些特殊的数据，比如采购的价格，则不会提供给他。

刚才讲的只是视图的一个使用场景，实际上视图还有很多作用，今天我们就一起学习下。今天的文章里，你将重点掌握以下的内容：

什么是视图？如何创建、更新和删除视图？

如何使用视图来简化我们的 SQL 操作？

视图和临时表的区别是什么，它们各自有什么优缺点？

如何创建，更新和删除视图

视图作为一张虚拟表，帮我们封装了底层与数据表的接口。它相当于是一张表或多张表的数据结果集。视图的这一特点，可以帮我们简化复杂的 SQL 查询，比如在编写视图后，我们就可以直接重用它，而不需要考虑视图中包含的基础查询的细节。同样，我们也可以根据需要更改数据格式，返回与底层数据表格式不同的数据。

通常情况下，小型项目的数据库可以不使用视图，但是在大型项目中，以及数据表比较复杂的情况下，视图的价值就凸显出来了，它可以帮助我们把经常查询的结果集放到虚拟表中，提升使用效率。理解和使用起来都非常方便。

创建视图：CREATE VIEW

那么该如何创建视图呢？创建视图的语法是：

CREATE VIEW view_name AS

SELECT column1, column2

FROM table

WHERE condition

实际上就是我们在 SQL 查询语句的基础上封装了视图 VIEW，这样就会基于 SQL 语句的结果集形成一张虚拟表。其中 view_name 为视图名称，column1、column2 代表列名，condition 代表查询过滤条件。

我们以 NBA 球员数据表为例。我们想要查询比 NBA 球员平均身高高的球员都有哪些，显示他们的球员 ID 和身高。假设我们给这个视图起个名字 player_above_avg_height，那么创建视图可以写成：

CREATE VIEW player_above_avg_height AS

SELECT player_id, height

FROM player

WHERE height > (SELECT AVG(height) from player)

视图查询结果（18 条记录）：

当视图创建之后，它就相当于一个虚拟表，可以直接使用：

SELECT * FROM player_above_avg_height

运行结果和上面一样。

嵌套视图

当我们创建好一张视图之后，还可以在它的基础上继续创建视图，比如我们想在虚拟表 player_above_avg_height 的基础上，找到比这个表中的球员平均身高高的球员，作为新的视图 player_above_above_avg_height，那么可以写成：

CREATE VIEW player_above_above_avg_height AS

SELECT player_id, height

FROM player

WHERE height > (SELECT AVG(height) from player_above_avg_height)

视图查询结果（11 条记录）：

你能看到这个视图的数据记录数为 11 个，比之前的记录少了 7 个。

修改视图：ALTER VIEW

修改视图的语法是：

ALTER VIEW view_name AS

SELECT column1, column2

FROM table

WHERE condition

你能看出来它的语法和创建视图一样，只是对原有视图的更新。比如我们想更新视图 player_above_avg_height，增加一个 player_name 字段，可以写成：

ALTER VIEW player_above_avg_height AS

SELECT player_id, player_name, height

FROM player

WHERE height > (SELECT AVG(height) from player)

这样的话，下次再对视图进行查询的时候，视图结果就进行了更新。

SELECT * FROM player_above_avg_height

运行结果（18 条记录）：

删除视图：DROP VIEW

删除视图的语法是：

DROP VIEW view_name

比如我们想把刚才创建的视图删除，可以使用：

DROP VIEW player_above_avg_height

需要说明的是，SQLite 不支持视图的修改，仅支持只读视图，也就是说你只能使用 CREATE VIEW 和 DROP VIEW，如果想要修改视图，就需要先 DROP 然后再 CREATE。

如何使用视图简化 SQL 操作

从上面这个例子中，你能看出视图就是对 SELECT 语句进行了封装，方便我们重用它们。下面我们再来看几个视图使用的例子。

利用视图完成复杂的连接

我在讲解 SQL99 标准连接操作的时候，举了一个 NBA 球员和身高等级连接的例子，有两张表，分别为 player 和 height_grades。其中 height_grades 记录了不同身高对应的身高等级。这里我们可以通过创建视图，来完成球员以及对应身高等级的查询。

首先我们对 player 表和 height_grades 表进行连接，关联条件是球员的身高 height（在身高等级表规定的最低身高和最高身高之间），这样就可以得到这个球员对应的身高等级，对应的字段为 height_level。然后我们通过 SELECT 得到我们想要查询的字段，分别为球员姓名 player_name、球员身高 height，还有对应的身高等级 height_level。然后把取得的查询结果集放到视图 player_height_grades 中，即：

CREATE VIEW player_height_grades AS

SELECT p.player_name, p.height, h.height_level

FROM player as p JOIN height_grades as h

ON height BETWEEN h.height_lowest AND h.height_highest

运行结果（37 条记录）：

以后我们进行查询的时候，可以直接通过视图查询，比如我想查询身高介于 1.90m 和 2.08m 之间的球员及他们对应的身高：

SELECT * FROM player_height_grades WHERE height >= 1.90 AND height <= 2.08

运行结果（26 条记录）：

这样就把一个相对复杂的连接查询转化成了视图查询。

利用视图对数据进行格式化

我们经常需要输出某个格式的内容，比如我们想输出球员姓名和对应的球队，对应格式为 player_name(team_name)，就可以使用视图来完成数据格式化的操作：

CREATE VIEW player_team AS

SELECT CONCAT(player_name, '(' , team.team_name , ')') AS player_team FROM player JOIN team WHERE player.team_id = team.team_id

首先我们将 player 表和 team 表进行连接，关联条件是相同的 team_id。我们想要的格式是player_name(team_name)，因此我们使用 CONCAT 函数，即CONCAT(player_name, '(' , team.team_name , ')')，将 player_name 字段和 team_name 字段进行拼接，得到了拼接值被命名为 player_team 的字段名，将它放到视图 player_team 中。

这样的话，我们直接查询视图，就可以得到格式化后的结果：

SELECT * FROM player_team

运行结果（37 条记录）：

使用视图与计算字段

我们在数据查询中，有很多统计的需求可以通过视图来完成。正确地使用视图可以帮我们简化复杂的数据处理。

我以球员比赛成绩表为例，对应的是 player_score 表。这张表中一共有 19 个字段，它们代表的含义如下：

如果我想要统计每位球员在每场比赛中的二分球、三分球和罚球的得分，可以通过创建视图完成：

CREATE VIEW game_player_score AS

SELECT game_id, player_id, (shoot_hits-shoot_3_hits)*2 AS shoot_2_points, shoot_3_hits*3 AS shoot_3_points, shoot_p_hits AS shoot_p_points, score  FROM player_score

然后通过查询视图就可以完成。

SELECT * FROM game_player_score

运行结果（19 条记录）：

你能看出正确使用视图可以简化复杂的 SQL 查询，让 SQL 更加清爽易用。不过有一点需要注意，视图是虚拟表，它只是封装了底层的数据表查询接口，因此有些 RDBMS 不支持对视图创建索引（有些 RDBMS 则支持，比如新版本的 SQL Server）。

总结

今天我讲解了视图的使用，包括创建，修改和删除视图。使用视图有很多好处，比如安全、简单清晰。

安全性：虚拟表是基于底层数据表的，我们在使用视图时，一般不会轻易通过视图对底层数据进行修改，即使是使用单表的视图，也会受到限制，比如计算字段，类型转换等是无法通过视图来对底层数据进行修改的，这也在一定程度上保证了数据表的数据安全性。同时，我们还可以针对不同用户开放不同的数据查询权限，比如人员薪酬是个敏感的字段，那么只给某个级别以上的人员开放，其他人的查询视图中则不提供这个字段。

简单清晰：视图是对 SQL 查询的封装，它可以将原本复杂的 SQL 查询简化，在编写好查询之后，我们就可以直接重用它而不必要知道基本的查询细节。同时我们还可以在视图之上再嵌套视图。这样就好比我们在进行模块化编程一样，不仅结构清晰，还提升了代码的复用率。

另外，我们也需要了解到视图是虚拟表，本身不存储数据，如果想要通过视图对底层数据表的数据进行修改也会受到很多限制，通常我们是把视图用于查询，也就是对 SQL 查询的一种封装。那么它和临时表又有什么区别呢？在实际工作中，我们可能会见到各种临时数据。比如你可能会问，如果我在做一个电商的系统，中间会有个购物车的功能，需要临时统计购物车中的商品和金额，那该怎么办呢？这里就需要用到临时表了，临时表是真实存在的数据表，不过它不用于长期存放数据，只为当前连接存在，关闭连接后，临时表就会自动释放。

今天我们对视图进行了讲解，你能用自己的语言来说下视图的优缺点么？另外视图在更新的时候会影响到数据表吗？

欢迎你在评论区写下你的思考，也欢迎把这篇文章分享给你的朋友或者同事，一起交流一下。

13丨什么是存储过程，在实际项目中用得多么？

上一节我介绍了视图，它是 SQL 中的一个重要应用，使用视图对 SQL 查询进行封装，可以让 SQL 的代码结构更清晰，让用户权限管理更安全。

今天我来讲一下 SQL 的存储过程，它是 SQL 中另一个重要应用，和视图一样，都是对 SQL 代码进行封装，可以反复利用。它和视图有着同样的优点，清晰、安全，还可以减少网络传输量。不过它和视图不同，视图是虚拟表，通常不对底层数据表直接操作，而存储过程是程序化的 SQL，可以直接操作底层数据表，相比于面向集合的操作方式，能够实现一些更复杂的数据处理。存储过程可以说是由 SQL 语句和流控制语句构成的语句集合，它和我们之前学到的函数一样，可以接收输入参数，也可以返回输出参数给调用者，返回计算结果。

今天有关存储过程的内容，你将重点掌握以下几个部分：

什么是存储过程，如何创建一个存储过程？

流控制语句都有哪些，如何使用它们？

各大公司是如何看待存储过程的？在实际工作中，我们该如何使用存储过程？

什么是存储过程，如何创建一个存储过程

存储过程的英文是 Stored Procedure。它的思想很简单，就是 SQL 语句的封装。一旦存储过程被创建出来，使用它就像使用函数一样简单，我们直接通过调用存储过程名即可。我在前面讲过，存储过程实际上由 SQL 语句和流控制语句共同组成。流控制语句都有哪些呢？这个我稍后讲解。

我们先来看下如何定义一个存储过程：

CREATE PROCEDURE 存储过程名称 ([参数列表])

BEGIN

    需要执行的语句

END

在这里，我们使用 CREATE PROCEDURE 创建一个存储过程，后面是存储过程的名称，以及过程所带的参数，可以包括输入参数和输出参数。最后由 BEGIN 和 END 来定义我们所要执行的语句块。

和视图一样，我们可以删除已经创建的存储过程，使用的是 DROP PROCEDURE。如果要更新存储过程，我们需要使用 ALTER PROCEDURE。

讲完了如何创建，更新和删除一个存储过程，下面我们来看下如何实现一个简单的存储过程。比如我想做一个累加运算，计算 1+2+…+n 等于多少，我们可以通过参数 n 来表示想要累加的个数，那么如何用存储过程实现这一目的呢？这里我做一个 add_num 的存储过程，具体的代码如下：

CREATE PROCEDURE `add_num`(IN n INT)

BEGIN

       DECLARE i INT;

       DECLARE sum INT;

       SET i = 1;

       SET sum = 0;

       WHILE i <= n DO

              SET sum = sum + i;

              SET i = i +1;

       END WHILE;

       SELECT sum;

END

当我们需要再次使用这个存储过程的时候，直接使用 CALL add_num(50);即可。这里我传入的参数为 50，也就是统计 1+2+…+50 的积累之和，查询结果为：

这就是一个简单的存储过程，除了理解 1+2+…+n 的实现过程，还有两点你需要理解，一个是 DELIMITER 定义语句的结束符，另一个是存储过程的三种参数类型。

我们先来看下 DELIMITER 的作用。如果你使用 Navicat 这个工具来管理 MySQL 执行存储过程，那么直接执行上面这段代码就可以了。如果用的是 MySQL，你还需要用 DELIMITER 来临时定义新的结束符。因为默认情况下 SQL 采用（；）作为结束符，这样当存储过程中的每一句 SQL 结束之后，采用（；）作为结束符，就相当于告诉 SQL 可以执行这一句了。但是存储过程是一个整体，我们不希望 SQL 逐条执行，而是采用存储过程整段执行的方式，因此我们就需要临时定义新的 DELIMITER，新的结束符可以用（//）或者（$$）。如果你用的是 MySQL，那么上面这段代码，应该写成下面这样：

DELIMITER //

CREATE PROCEDURE `add_num`(IN n INT)

BEGIN

       DECLARE i INT;

       DECLARE sum INT;

       SET i = 1;

       SET sum = 0;

       WHILE i <= n DO

              SET sum = sum + i;

              SET i = i +1;

       END WHILE;

       SELECT sum;

END //

DELIMITER ;

首先我用（//）作为结束符，又在整个存储过程结束后采用了（//）作为结束符号，告诉 SQL 可以执行了，然后再将结束符还原成默认的（;）。

需要注意的是，如果你用的是 Navicat 工具，那么在编写存储过程的时候，Navicat 会自动设置 DELIMITER 为其他符号，我们不需要再进行 DELIMITER 的操作。

我们再来看下存储过程的 3 种参数类型。在刚才的存储过程中，我们使用了 IN 类型的参数，另外还有 OUT 类型和 INOUT 类型，作用如下：

IN 和 OUT 的结合，既用于存储过程的传入参数，同时又可以把计算结果放到参数中，调用者可以得到返回值。

你能看到，IN 参数必须在调用存储过程时指定，而在存储过程中修改该参数的值不能被返回。而 OUT 参数和 INOUT 参数可以在存储过程中被改变，并可返回。

举个例子，这里会用到我们之前讲过的王者荣耀的英雄数据表 heros。假设我想创建一个存储类型 get_hero_scores，用来查询某一类型英雄中的最大的最大生命值，最小的最大魔法值，以及平均最大攻击值，那么该怎么写呢？

CREATE PROCEDURE `get_hero_scores`(

       OUT max_max_hp FLOAT,

       OUT min_max_mp FLOAT,

       OUT avg_max_attack FLOAT,

       s VARCHAR(255)

       )

BEGIN

       SELECT MAX(hp_max), MIN(mp_max), AVG(attack_max) FROM heros WHERE role_main = s INTO max_max_hp, min_max_mp, avg_max_attack;

END

你能看到我定义了 4 个参数类型，其中 3 个为 OUT 类型，分别为 max_max_hp、min_max_mp 和 avg_max_attack，另一个参数 s 为 IN 类型。

这里我们从 heros 数据表中筛选主要英雄定位为 s 的英雄数据，即筛选条件为 role_main=s，提取这些数据中的最大的最大生命值，最小的最大魔法值，以及平均最大攻击值，分别赋值给变量 max_max_hp、min_max_mp 和 avg_max_attack。

然后我们就可以调用存储过程，使用下面这段代码即可：

CALL get_hero_scores(@max_max_hp, @min_max_mp, @avg_max_attack, '战士');

SELECT @max_max_hp, @min_max_mp, @avg_max_attack;

运行结果：

流控制语句

流控制语句是用来做流程控制的，我刚才讲了两个简单的存储过程的例子，一个是 1+2+…+n 的结果计算，一个是王者荣耀的数据查询，你能看到这两个例子中，我用到了下面的流控制语句：

BEGIN…END：BEGIN…END 中间包含了多个语句，每个语句都以（;）号为结束符。

DECLARE：DECLARE 用来声明变量，使用的位置在于 BEGIN…END 语句中间，而且需要在其他语句使用之前进行变量的声明。

SET：赋值语句，用于对变量进行赋值。

SELECT…INTO：把从数据表中查询的结果存放到变量中，也就是为变量赋值。

除了上面这些用到的流控制语句以外，还有一些常用的流控制语句：

1.IF…THEN…ENDIF：条件判断语句，我们还可以在 IF…THEN…ENDIF 中使用 ELSE 和 ELSEIF 来进行条件判断。
2.CASE：CASE 语句用于多条件的分支判断，使用的语法是下面这样的。

CASE

	WHEN expression1 THEN ...

	WHEN expression2 THEN ...

	...

    ELSE

    --ELSE 语句可以加，也可以不加。加的话代表的所有条件都不满足时采用的方式。

END

3.LOOP、LEAVE 和 ITERATE：LOOP 是循环语句，使用 LEAVE 可以跳出循环，使用 ITERATE 则可以进入下一次循环。如果你有面向过程的编程语言的使用经验，你可以把 LEAVE 理解为 BREAK，把 ITERATE 理解为 CONTINUE。
4.REPEAT…UNTIL…END REPEAT：这是一个循环语句，首先会执行一次循环，然后在 UNTIL 中进行表达式的判断，如果满足条件就退出，即 END REPEAT；如果条件不满足，则会就继续执行循环，直到满足退出条件为止。
5.WHILE…DO…END WHILE：这也是循环语句，和 REPEAT 循环不同的是，这个语句需要先进行条件判断，如果满足条件就进行循环，如果不满足条件就退出循环。

我们之前说过 SQL 是声明型语言，使用 SQL 就像在使用英语，简单直接。今天讲的存储过程，尤其是在存储过程中使用到的流控制语句，属于过程性语言，类似于 C++ 语言中函数，这些语句可以帮我们解决复杂的业务逻辑。

关于存储过程使用的争议

尽管存储过程有诸多优点，但是对于存储过程的使用，一直都存在着很多争议，比如有些公司对于大型项目要求使用存储过程，而有些公司在手册中明确禁止使用存储过程，为什么这些公司对存储过程的使用需求差别这么大呢？

我们得从存储过程的特点来找答案。

你能看到存储过程有很多好处。

首先存储过程可以一次编译多次使用。存储过程只在创造时进行编译，之后的使用都不需要重新编译，这就提升了 SQL 的执行效率。其次它可以减少开发工作量。将代码封装成模块，实际上是编程的核心思想之一，这样可以把复杂的问题拆解成不同的模块，然后模块之间可以重复使用，在减少开发工作量的同时，还能保证代码的结构清晰。还有一点，存储过程的安全性强，我们在设定存储过程的时候可以设置对用户的使用权限，这样就和视图一样具有较强的安全性。最后它可以减少网络传输量，因为代码封装到存储过程中，每次使用只需要调用存储过程即可，这样就减少了网络传输量。同时在进行相对复杂的数据库操作时，原本需要使用一条一条的 SQL 语句，可能要连接多次数据库才能完成的操作，现在变成了一次存储过程，只需要连接一次即可。

基于上面这些优点，不少大公司都要求大型项目使用存储过程，比如微软、IBM 等公司。但是国内的阿里并不推荐开发人员使用存储过程，这是为什么呢？

存储过程虽然有诸如上面的好处，但缺点也是很明显的。

它的可移植性差，存储过程不能跨数据库移植，比如在 MySQL、Oracle 和 SQL Server 里编写的存储过程，在换成其他数据库时都需要重新编写。

其次调试困难，只有少数 DBMS 支持存储过程的调试。对于复杂的存储过程来说，开发和维护都不容易。

此外，存储过程的版本管理也很困难，比如数据表索引发生变化了，可能会导致存储过程失效。我们在开发软件的时候往往需要进行版本管理，但是存储过程本身没有版本控制，版本迭代更新的时候很麻烦。

最后它不适合高并发的场景，高并发的场景需要减少数据库的压力，有时数据库会采用分库分表的方式，而且对可扩展性要求很高，在这种情况下，存储过程会变得难以维护，增加数据库的压力，显然就不适用了。

了解了存储过程的优缺点之后，我想说的是，存储过程既方便，又有局限性。尽管不同的公司对存储过程的态度不一，但是对于我们开发人员来说，不论怎样，掌握存储过程都是必备的技能之一。

最后我们做一个小练习吧。针对王者荣耀的英雄数据表 heros 表，请编写存储过程 get_sum_score，用来得到某一类型英雄（主要定位为某一类型即可）的最大生命值的总和。

欢迎你在评论区写下你的答案，也欢迎把这篇文章分享给你的朋友或者同事，一起交流一下。

14丨什么是事务处理，如何使用COMMIT和ROLLBACK进行操作？

我们知道在 MySQL 5.5 版本之前，默认的存储引擎是 MyISAM，在 5.5 版本之后默认存储引擎是 InnoDB。InnoDB 和 MyISAM 区别之一就是 InnoDB 支持事务，也可以说这是 InnoDB 取代 MyISAM 的重要原因。那么什么是事务呢？事务的英文是 transaction，从英文中你也能看出来它是进行一次处理的基本单元，要么完全执行，要么都不执行。

这么讲，你可能觉得有些抽象，我换一种方式讲。

不知道你是否遇到过这样的情况，你去家门口的小卖铺买东西，已经交了钱，但是老板比较忙接了个电话，忘记你是否交过钱，然后让你重新付款，这时你还要找之前的付款记录证明你已经完成了付款。

实际上如果我们线下的交易也能支持事务（满足事务的特性），就不会出现交了钱却拿不到商品的烦恼了，同样，对于小卖铺的老板来说，也不存在给出了商品但没有收到款的风险。总之，事务保证了一次处理的完整性，也保证了数据库中的数据一致性。它是一种高级的数据处理方式，如果我们在增加、删除、修改的时候某一个环节出了错，它允许我们回滚还原。正是因为这个特点，事务非常适合应用在安全性高的场景里，比如金融行业等。

我们今天就来学习下 SQL 中的事务。今天的课程你将重点掌握以下的内容：

事务的特性是什么？如何理解它们？

如何对事务进行控制？控制的命令都有哪些？

为什么我们执行 COMMIT、ROLLBACK 这些命令的时候，有时会成功，有时会失败？

事务的特性：ACID

我刚才提到了事务的特性：要么完全执行，要么都不执行。不过要对事务进行更深一步的理解，还要从事务的 4 个特性说起，这 4 个特性用英文字母来表达就是 ACID。

A，也就是原子性（Atomicity）。原子的概念就是不可分割，你可以把它理解为组成物质的基本单位，也是我们进行数据处理操作的基本单位。

C，就是一致性（Consistency）。一致性指的就是数据库在进行事务操作后，会由原来的一致状态，变成另一种一致的状态。也就是说当事务提交后，或者当事务发生回滚后，数据库的完整性约束不能被破坏。

I，就是隔离性（Isolation）。它指的是每个事务都是彼此独立的，不会受到其他事务的执行影响。也就是说一个事务在提交之前，对其他事务都是不可见的。

最后一个 D，指的是持久性（Durability）。事务提交之后对数据的修改是持久性的，即使在系统出故障的情况下，比如系统崩溃或者存储介质发生故障，数据的修改依然是有效的。因为当事务完成，数据库的日志就会被更新，这时可以通过日志，让系统恢复到最后一次成功的更新状态。

ACID 可以说是事务的四大特性，在这四个特性中，原子性是基础，隔离性是手段，一致性是约束条件，而持久性是我们的目的。原子性和隔离性比较好理解，这里我讲下对一致性的理解（国内很多网站上对一致性的阐述有误，具体你可以参考 Wikipedia 对Consistency的阐述）。

我之前讲到过数据表的 7 种常见约束（对应 04 篇）。这里指的一致性本身是由具体的业务定义的，也就是说，任何写入数据库中的数据都需要满足我们事先定义的约束规则。

比如说，在数据表中我们将姓名字段设置为唯一性约束，这时当事务进行提交或者事务发生回滚的时候，如果数据表中的姓名非唯一，就破坏了事务的一致性要求。所以说，事务操作会让数据表的状态变成另一种一致的状态，如果事务中的某个操作失败了，系统就会自动撤销当前正在执行的事务，返回到事务操作之前的状态。

事务的另一个特点就是持久性，持久性是通过事务日志来保证的。日志包括了回滚日志和重做日志。当我们通过事务对数据进行修改的时候，首先会将数据库的变化信息记录到重做日志中，然后再对数据库中对应的行进行修改。这样做的好处是，即使数据库系统崩溃，数据库重启后也能找到没有更新到数据库系统中的重做日志，重新执行，从而使事务具有持久性。

事务的控制

当我们了解了事务的特性后，再来看下如何使用事务。我们知道 Oracle 是支持事务的，而在 MySQL 中，则需要选择适合的存储引擎才可以支持事务。如果你使用的是 MySQL，可以通过 SHOW ENGINES 命令来查看当前 MySQL 支持的存储引擎都有哪些，以及这些存储引擎是否支持事务。

你能看出在 MySQL 中，InnoDB 是支持事务的，而 MyISAM 存储引擎不支持事务。

看到这里，我们已经对事务有了一定的了解，现在我们再来看下事务的常用控制语句都有哪些。

START TRANSACTION 或者 BEGIN，作用是显式开启一个事务。

COMMIT：提交事务。当提交事务后，对数据库的修改是永久性的。

ROLLBACK 或者 ROLLBACK TO [SAVEPOINT]，意为回滚事务。意思是撤销正在进行的所有没有提交的修改，或者将事务回滚到某个保存点。

SAVEPOINT：在事务中创建保存点，方便后续针对保存点进行回滚。一个事务中可以存在多个保存点。

RELEASE SAVEPOINT：删除某个保存点。

SET TRANSACTION，设置事务的隔离级别。

需要说明的是，使用事务有两种方式，分别为隐式事务和显式事务。隐式事务实际上就是自动提交，Oracle 默认不自动提交，需要手写 COMMIT 命令，而 MySQL 默认自动提交，当然我们可以配置 MySQL 的参数：

mysql> set autocommit =0;  // 关闭自动提交

mysql> set autocommit =1;  // 开启自动提交

我们看下在 MySQL 的默认状态下，下面这个事务最后的处理结果是什么：

CREATE TABLE test(name varchar(255), PRIMARY KEY (name)) ENGINE=InnoDB;

BEGIN;

INSERT INTO test SELECT '关羽';

COMMIT;

BEGIN;

INSERT INTO test SELECT '张飞';

INSERT INTO test SELECT '张飞';

ROLLBACK;

SELECT * FROM test;

运行结果（1 行数据）：

在这个事务中，整个 SQL 一共执行了 2 个事务，第一个是插入“关羽”，提交后执行成功，第二个是插入两次“张飞”，这里需要注意的是，我们将 name 设置为了主键，也就是说主键的值是唯一的，那么第二次插入“张飞”时就会产生错误，然后执行 ROLLBACK 相当于对事务进行了回滚，所以我们看到最终结果只有一行数据，也就是第一个事务执行之后的结果，即“关羽”。

那么如果我们进行下面的操作又会怎样呢？

CREATE TABLE test(name varchar(255), PRIMARY KEY (name)) ENGINE=InnoDB;

BEGIN;

INSERT INTO test SELECT '关羽';

COMMIT;

INSERT INTO test SELECT '张飞';

INSERT INTO test SELECT '张飞';

ROLLBACK;

SELECT * FROM test;

运行结果（2 行数据）：

你能看到这次数据是 2 行，上一次操作我把两次插入“张飞”放到一个事务里，而这次操作它们不在同一个事务里，那么对于 MySQL 来说，默认情况下这实际上就是两个事务，因为在 autocommit=1 的情况下，MySQL 会进行隐式事务，也就是自动提交，因此在进行第一次插入“张飞”后，数据表里就存在了两行数据，而第二次插入“张飞”就会报错：1062 - Duplicate entry '张飞' for key 'PRIMARY'。

最后我们在执行 ROLLBACK 的时候，实际上事务已经自动提交了，就没法进行回滚了。

同样的我们再来看下这段代码，你又能发现什么不同呢？

CREATE TABLE test(name varchar(255), PRIMARY KEY (name)) ENGINE=InnoDB;

SET @@completion_type = 1;

BEGIN;

INSERT INTO test SELECT '关羽';

COMMIT;

INSERT INTO test SELECT '张飞';

INSERT INTO test SELECT '张飞';

ROLLBACK;

SELECT * FROM test;

运行结果（1 行数据）：

你能看到还是相同的 SQL 代码，只是我在事务开始之前设置了SET @@completion_type = 1;，结果就和我们第一次处理的一样，只有一个“关羽”。这是为什么呢？

这里我讲解下 MySQL 中 completion_type 参数的作用，实际上这个参数有 3 种可能：

completion=0，这是默认情况。也就是说当我们执行 COMMIT 的时候会提交事务，在执行下一个事务时，还需要我们使用 START TRANSACTION 或者 BEGIN 来开启。

completion=1，这种情况下，当我们提交事务后，相当于执行了 COMMIT AND CHAIN，也就是开启一个链式事务，即当我们提交事务之后会开启一个相同隔离级别的事务（隔离级别会在下一节中进行介绍）。

completion=2，这种情况下 COMMIT=COMMIT AND RELEASE，也就是当我们提交后，会自动与服务器断开连接。

在上面这段代码里，我使用了 completion=1，也就是说当我提交之后，相当于在下一行写了一个 START TRANSACTION 或 BEGIN。这时两次插入“张飞”会被认为是在同一个事务之内的操作，那么第二次插入“张飞”就会导致事务失败，而回滚也将这次事务进行了撤销，所以你能看到的结果就只有一个“关羽”。

通过这样简单的练习，你应该能体会到事务提交和回滚的操作。

当我们设置 autocommit=0 时，不论是否采用 START TRANSACTION 或者 BEGIN 的方式来开启事务，都需要用 COMMIT 进行提交，让事务生效，使用 ROLLBACK 对事务进行回滚。

当我们设置 autocommit=1 时，每条 SQL 语句都会自动进行提交。
不过这时，如果你采用 START TRANSACTION 或者 BEGIN 的方式来显式地开启事务，那么这个事务只有在 COMMIT 时才会生效，在 ROLLBACK 时才会回滚。

总结

关于 SQL 中的事务处理，内容相对比较多，因此我会采用两节来进行讲解。今天我们对事务的概念进行了理解，并进行了简单的事务操作。我们在做数据库操作的时候，可能会失败，但正是因为有事务的存在，即使在数据库操作失败的情况下，也能保证数据的一致性。同样，多个应用程序访问数据库的时候，事务可以提供隔离，保证事务之间不被干扰。最后，事务一旦提交，结果就会是永久性的，这就意味着，即使系统崩溃了，数据库也可以对数据进行恢复。

在使用事务的过程中，我们会采用控制流语句对事务进行操作，不过在实际操作中，不一定每次使用 COMMIT 或 ROLLBACK 都会成功，你还需要知道当前系统的事务执行方式，也就是一些常用的参数情况，比如 MySQL 中的 autocommit 和 completion_type 等。

事务是数据库区别于文件系统的重要特性之一，当我们有了事务就会让数据库始终保持一致性，同时我们还能通过事务的机制恢复到某个时间点，这样可以保证已提交到数据库的修改不会因为系统崩溃而丢失。

今天的内容到这里就结束了，你能说一下 MySQL 中都有哪些存储引擎支持事务，通过什么命令可以查看它们吗？另外，你是如何理解事务的特性的？

欢迎你在评论区写下你的答案，也欢迎把这篇文章分享给你的朋友或者同事，一起来交流。

15丨初识事务隔离：隔离的级别有哪些，它们都解决了哪些异常问题？

上一篇文章中，我们讲到了事务的四大特性 ACID，分别是原子性、一致性、隔离性和持久性，其中隔离性是事务的基本特性之一，它可以防止数据库在并发处理时出现数据不一致的情况。最严格的情况下，我们可以采用串行化的方式来执行每一个事务，这就意味着事务之间是相互独立的，不存在并发的情况。然而在实际生产环境下，考虑到随着用户量的增多，会存在大规模并发访问的情况，这就要求数据库有更高的吞吐能力，这个时候串行化的方式就无法满足数据库高并发访问的需求，我们还需要降低数据库的隔离标准，来换取事务之间的并发能力。

有时候我们需要牺牲一定的正确性来换取效率的提升，也就是说，我们需要通过设置不同的隔离等级，以便在正确性和效率之间进行平衡。同时，随着 RDBMS 种类和应用场景的增多，数据库的设计者需要统一对数据库隔离级别进行定义，说明这些隔离标准都解决了哪些问题。

我们今天主要讲解事务的异常以及隔离级别都有哪些，如果你已经对它们有所了解，可以跳过本次章节，当然你也可以通过今天的课程快速复习一遍：

事务并发处理可能存在的三种异常有哪些？什么是脏读、不可重复读和幻读？

针对可能存在的异常情况，四种事务隔离的级别分别是什么？

如何使用 MySQL 客户端来模拟脏读、不可重复读和幻读？

事务并发处理可能存在的异常都有哪些？

在了解数据库隔离级别之前，我们需要了解设定事务的隔离级别都要解决哪些可能存在的问题，也就是事务并发处理时会存在哪些异常情况。实际上，SQL-92 标准中已经对 3 种异常情况进行了定义，这些异常情况级别分别为脏读（Dirty Read）、不可重复读（Nnrepeatable Read）和幻读（Phantom Read）。

脏读、不可重复读和幻读都代表了什么，我用一个例子来给你讲解下。比如说我们有个英雄表 heros_temp，如下所示：

这张英雄表，我们会记录很多英雄的姓名，假设我们不对事务进行隔离操作，那么数据库在进行事务的并发处理时会出现怎样的情况？

第一天，小张访问数据库，正在进行事务操作，往里面写入一个新的英雄“吕布”：

SQL> BEGIN;

SQL> INSERT INTO heros_temp values(4, '吕布');

当小张还没有提交该事务的时候，小李又对数据表进行了访问，他想看下这张英雄表里都有哪些英雄：

SQL> SELECT * FROM heros_temp;

这时，小李看到的结果如下：

你有没有发现什么异常？这个时候小张还没有提交事务，但是小李却读到了小张还没有提交的数据，这种现象我们称之为“脏读”。

那么什么是不可重复读呢？

第二天，小张想查看 id=1 的英雄是谁，于是他进行了 SQL 查询：

SQL> SELECT name FROM heros_temp WHERE id = 1;

运行结果：

然而此时，小李开始了一个事务操作，他对 id=1 的英雄姓名进行了修改，把原来的“张飞”改成了“张翼德”：

SQL> BEGIN;

SQL> UPDATE heros_temp SET name = '张翼德' WHERE id = 1;

然后小张再一次进行查询，同样也是查看 id=1 的英雄是谁：

SQL> SELECT name FROM heros_temp WHERE id = 1;

运行结果：

这个时候你会发现，两次查询的结果并不一样。小张会想这是怎么回事呢？他明明刚执行了一次查询，马上又进行了一次查询，结果两次的查询结果不同。实际上小张遇到的情况我们称之为“不可重复读”，也就是同一条记录，两次读取的结果不同。

什么是幻读？

第三天，小张想要看下数据表里都有哪些英雄，他开始执行下面这条语句：

SQL> SELECT * FROM heros_temp;

这时当小张执行完之后，小李又开始了一个事务，往数据库里插入一个新的英雄“吕布”：

SQL> BEGIN;

SQL> INSERT INTO heros_temp values(4, '吕布');

不巧的是，小张这时忘记了英雄都有哪些，又重新执行了一遍查询：

SQL> SELECT * FROM heros_temp;

他发现这一次查询多了一个英雄，原来只有 3 个，现在变成了 4 个。这种异常情况我们称之为“幻读”。

我来总结下这三种异常情况的特点：

脏读：读到了其他事务还没有提交的数据。

不可重复读：对某数据进行读取，发现两次读取的结果不同，也就是说没有读到相同的内容。这是因为有其他事务对这个数据同时进行了修改或删除。

幻读：事务 A 根据条件查询得到了 N 条数据，但此时事务 B 更改或者增加了 M 条符合事务 A 查询条件的数据，这样当事务 A 再次进行查询的时候发现会有 N+M 条数据，产生了幻读。

事务隔离的级别有哪些？

脏读、不可重复读和幻读这三种异常情况，是在 SQL-92 标准中定义的，同时 SQL-92 标准还定义了 4 种隔离级别来解决这些异常情况。

解决异常数量从少到多的顺序（比如读未提交可能存在 3 种异常，可串行化则不会存在这些异常）决定了隔离级别的高低，这四种隔离级别从低到高分别是：读未提交（READ UNCOMMITTED ）、读已提交（READ COMMITTED）、可重复读（REPEATABLE READ）和可串行化（SERIALIZABLE）。这些隔离级别能解决的异常情况如下表所示：

你能看到可串行化能避免所有的异常情况，而读未提交则允许异常情况发生。

关于这四种级别，我来简单讲解下。

读未提交，也就是允许读到未提交的数据，这种情况下查询是不会使用锁的，可能会产生脏读、不可重复读、幻读等情况。

读已提交就是只能读到已经提交的内容，可以避免脏读的产生，属于 RDBMS 中常见的默认隔离级别（比如说 Oracle 和 SQL Server），但如果想要避免不可重复读或者幻读，就需要我们在 SQL 查询的时候编写带加锁的 SQL 语句（我会在进阶篇里讲加锁）。

可重复读，保证一个事务在相同查询条件下两次查询得到的数据结果是一致的，可以避免不可重复读和脏读，但无法避免幻读。MySQL 默认的隔离级别就是可重复读。

可串行化，将事务进行串行化，也就是在一个队列中按照顺序执行，可串行化是最高级别的隔离等级，可以解决事务读取中所有可能出现的异常情况，但是它牺牲了系统的并发性。

使用 MySQL 客户端来模拟三种异常

我在讲解这三种异常的时候举了一个英雄数据表查询的例子，你还可以自己写 SQL 来模拟一下这三种异常。

首先我们需要一个英雄数据表 heros_temp，具体表结构和数据，你可以从GitHub上下载 heros_temp.sql 文件。

你也可以执行下面的 SQL 文件，来完成 heros_temp 数据表的创建。

-- ----------------------------

-- Table structure for heros_temp

-- ----------------------------

DROP TABLE IF EXISTS `heros_temp`;

CREATE TABLE `heros_temp`  (

  `id` int(11) NOT NULL,

  `name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,

  PRIMARY KEY (`id`) USING BTREE

) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

-- ----------------------------

-- Records of heros_temp

-- ----------------------------

INSERT INTO `heros_temp` VALUES (1, '张飞');

INSERT INTO `heros_temp` VALUES (2, '关羽');

INSERT INTO `heros_temp` VALUES (3, '刘备');

模拟的时候我们需要开两个 MySQL 客户端，分别是客户端 1 和客户端 2。

在客户端 1 中，我们先来查看下当前会话的隔离级别，使用命令：

mysql> SHOW VARIABLES LIKE 'transaction_isolation';

然后你能看到当前的隔离级别是 REPEATABLE-READ，也就是可重复读。

现在我们把隔离级别降到最低，设置为 READ UNCOMMITTED（读未提交）。

mysql> SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;

然后再查看下当前会话（SESSION）下的隔离级别，结果如下：

因为 MySQL 默认是事务自动提交，这里我们还需要将 autocommit 参数设置为 0，命令如下：

mysql> SET autocommit = 0;

然后我们再来查看 SESSION 中的 autocommit 取值，结果如下：

接着我们以同样的操作启动客户端 2，也就是将隔离级别设置为 READ UNCOMMITTED（读未提交），autocommit 设置为 0。

模拟“脏读”

我们在客户端 2 中开启一个事务，在 heros_temp 表中写入一个新的英雄“吕布”，注意这个时候不要提交。

然后我们在客户端 1 中，查看当前的英雄表：

你能发现客户端 1 中读取了客户端 2 未提交的新英雄“吕布”，实际上客户端 2 可能马上回滚，从而造成了“脏读”。

模拟“不可重复读”

我们用客户端 1 来查看 id=1 的英雄：

然后用客户端 2 对 id=1 的英雄姓名进行修改：

这时用客户端 1 再次进行查询：

你能发现对于客户端 1 来说，同一条查询语句出现了“不可重复读”。

模拟“幻读”

我们先用客户端 1 查询数据表中的所有英雄：

然后用客户端 2，开始插入新的英雄“吕布”：

这时，我们再用客户端 1 重新进行查看：

你会发现数据表多出一条数据。

如果你是初学者，那么你可以采用 heros_temp 数据表简单模拟一下以上的过程，加深对脏读、不可重复读以及幻读的理解。对应的，你也会更了解不同的隔离级别解决的异常问题。

总结

我们今天只是简单讲解了 4 种隔离级别，以及对应的要解决的三种异常问题。我会在优化篇这一模块里继续讲解隔离级别以及锁的使用。

你能看到，标准的价值在于，即使是不同的 RDBMS 都需要达成对异常问题和隔离级别定义的共识。这就意味着一个隔离级别的实现满足了下面的两个条件：

正确性：只要能满足某一个隔离级别，一定能解决这个隔离级别对应的异常问题。

与实现无关：实际上 RDBMS 种类很多，这就意味着有多少种 RDBMS，就有多少种锁的实现方式，因此它们实现隔离级别的原理可能不同，然而一个好的标准不应该限制其实现的方式。

隔离级别越低，意味着系统吞吐量（并发程度）越大，但同时也意味着出现异常问题的可能性会更大。在实际使用过程中我们往往需要在性能和正确性上进行权衡和取舍，没有完美的解决方案，只有适合与否。

今天的内容到这里就结束了，你能思考一下为什么隔离级别越高，就越影响系统的并发性能吗？以及不可重复读和幻读的区别是什么？

欢迎你在评论区写下你的思考，也欢迎把这篇文章分享给你的朋友或者同事。

16丨游标：当我们需要逐条处理数据时，该怎么做？

我们在编写 SQL 语句的时候通常是面向集合进行思考，这种思考方式更让我们关注结果集的特征，而不是具体的实现过程。面向集合的思考方式与面向过程的思考方式各有特点，我们该如何理解它们呢？

我们用下面这张图开启今天的学习。这张图中一共有 9 个图形，每个图形有不同的特征，包括形状、纹理、颜色和个数等。

当我们看到这张图时，有时候会不由自主地按照某个属性进行分类，比如说按照红色分类，那么 1、4、9 就是一类。这实际上就是属于同一个条件下的查询结果集。或者我们也可以按照物体的个数来划分，比如都有 3 个物体的，那么对应的就是 2、5、6、8，这就是对应着“都包括 3 个物体”的查询结果集。

你能看出来集合思维更像是从整体的角度来考虑，然后把整个数据集按照不同的属性进行划分，形成不同的子集合。面向集合的思考方式，让我们关注“获取什么”，而不是“如何获取”，这也可以说是 SQL 与传统编程最大的区别之一，因为 SQL 本身是以关系模型和集合论为基础的。

然而也有一些情况，我们不需要对查询结果集中的所有数据行都采用相同的处理方式，需要每次处理一行或者一部分行，这时就需要面向过程的编程方法了。游标就是这种编程方式的体现。如果你之前已经有了一些面向过程的编程经验，那么对于游标的理解也会比较容易。

关于游标，你需要掌握以下几个方面的内容：

什么是游标？我们为什么要使用游标？

如何使用游标？使用游标的常用步骤都包括哪些？

如何使用游标来解决一些常见的问题？

什么是游标？

在数据库中，游标是个重要的概念，它提供了一种灵活的操作方式，可以让我们从数据结果集中每次提取一条数据记录进行操作。游标让 SQL 这种面向集合的语言有了面向过程开发的能力。可以说，游标是面向过程的编程方式，这与面向集合的编程方式有所不同。

在 SQL 中，游标是一种临时的数据库对象，可以指向存储在数据库表中的数据行指针。这里游标充当了指针的作用，我们可以通过操作游标来对数据行进行操作。

比如我们查询了 heros 数据表中最大生命值大于 8500 的英雄都有哪些：

SELECT id, name, hp_max FROM heros WHERE hp_max > 8500

查询结果（4 条数据）：

这里我们就可以通过游标来操作数据行，如图所示此时游标所在的行是“白起”的记录，我们也可以在结果集上滚动游标，指向结果集中的任意一行。

如何使用游标？

游标实际上是一种控制数据集的更加灵活的处理方式。

如果我们想要使用游标，一般需要经历五个步骤。不同 DBMS 中，使用游标的语法可能略有不同。

第一步，定义游标。

DECLARE cursor_name CURSOR FOR select_statement

这个语法适用于 MySQL，SQL Server，DB2 和 MariaDB。如果是用 Oracle 或者 PostgreSQL，需要写成：

DECLARE cursor_name CURSOR IS select_statement

要使用 SELECT 语句来获取数据结果集，而此时还没有开始遍历数据，这里 select_statement 代表的是 SELECT 语句。

下面我用 MySQL 举例讲解游标的使用，如果你使用的是其他的 RDBMS，具体的游标语法可能略有差异。我们定义一个能够存储 heros 数据表中的最大生命值的游标，可以写为：

DECLARE cur_hero CURSOR FOR

	SELECT hp_max FROM heros;

第二步，打开游标。

OPEN cursor_name

当我们定义好游标之后，如果想要使用游标，必须先打开游标。打开游标的时候 SELECT 语句的查询结果集就会送到游标工作区。

第三步，从游标中取得数据。

FETCH cursor_name INTO var_name ...

这句的作用是使用 cursor_name 这个游标来读取当前行，并且将数据保存到 var_name 这个变量中，游标指针指到下一行。如果游标读取的数据行有多个列名，则在 INTO 关键字后面赋值给多个变量名即可。

第四步，关闭游标。

CLOSE cursor_name

有 OPEN 就会有 CLOSE，也就是打开和关闭游标。当我们使用完游标后需要关闭掉该游标。关闭游标之后，我们就不能再检索查询结果中的数据行，如果需要检索只能再次打开游标。

最后一步，释放游标。

DEALLOCATE PREPARE

有 DECLARE 就需要有 DEALLOCATE，DEALLOCATE 的作用是释放游标。我们一定要养成释放游标的习惯，否则游标会一直存在于内存中，直到进程结束后才会自动释放。当你不需要使用游标的时候，释放游标可以减少资源浪费。

上面就是 5 个常用的游标步骤。我来举一个简单的例子，假设我想用游标来扫描 heros 数据表中的数据行，然后累计最大生命值，那么该怎么做呢？

我先创建一个存储过程 calc_hp_max，然后在存储过程中定义游标 cur_hero，使用 FETCH 获取每一行的具体数值，然后赋值给变量 hp，再用变量 hp_sum 做累加求和，最后再输出 hp_sum，代码如下：

CREATE PROCEDURE `calc_hp_max`()

BEGIN

       -- 创建接收游标的变量

       DECLARE hp INT;

       -- 创建总数变量

       DECLARE hp_sum INT DEFAULT 0;

       -- 创建结束标志变量

       DECLARE done INT DEFAULT false;

       -- 定义游标

       DECLARE cur_hero CURSOR FOR SELECT hp_max FROM heros;

       OPEN cur_hero;

       read_loop:LOOP

       FETCH cur_hero INTO hp;

       SET hp_sum = hp_sum + hp;

       END LOOP;

       CLOSE cur_hero;

       SELECT hp_sum;

       DEALLOCATE PREPARE cur_hero;

END

你会发现执行call calc_hp_max()这一句的时候系统会提示 1329 错误，也就是在 LOOP 中当游标没有取到数据时会报的错误。

当游标溢出时（也就是当游标指向到最后一行数据后继续执行会报的错误），我们可以定义一个 continue 的事件，指定这个事件发生时修改变量 done 的值，以此来判断游标是否已经溢出，即：

DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = true;

同时在循环中我们需要加上对 done 的判断，如果游标的循环已经结束，就需要跳出 read_loop 循环，完善的代码如下：

CREATE PROCEDURE `calc_hp_max`()

BEGIN

       -- 创建接收游标的变量

       DECLARE hp INT;

       -- 创建总数变量

       DECLARE hp_sum INT DEFAULT 0;

       -- 创建结束标志变量

     DECLARE done INT DEFAULT false;

       -- 定义游标

       DECLARE cur_hero CURSOR FOR SELECT hp_max FROM heros;

       -- 指定游标循环结束时的返回值

     DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = true;

       OPEN cur_hero;

       read_loop:LOOP

       FETCH cur_hero INTO hp;

       -- 判断游标的循环是否结束

       IF done THEN

                     LEAVE read_loop;

       END IF;

       SET hp_sum = hp_sum + hp;

       END LOOP;

       CLOSE cur_hero;

       SELECT hp_sum;

       DEALLOCATE PREPARE cur_hero;

END

运行结果（1 行数据）：

在游标中的循环中，除了使用 LOOP 循环以外，你还可以使用 REPEAT… UNTIL…以及 WHILE 循环。它们同样需要设置 CONTINUE 事件来处理游标溢出的情况。

所以你能看出，使用游标可以让我们对 SELECT 结果集中的每一行数据进行相同或者不同的操作，从而很精细化地管理结果集中的每一条数据。

使用游标来解决一些常见的问题

我刚才讲了一个简单的使用案例，实际上如果想要统计 hp_sum，完全可以通过 SQL 语句来完成，比如：

SELECT SUM(hp_max) FROM heros

运行结果（1 行数据）：

那么游标都有什么用呢？

当你需要处理一些复杂的数据行计算的时候，游标就会起到作用了。我举个例子，还是针对 heros 数据表，假设我们想要对英雄的物攻成长（对应 attack_growth）进行升级，在新版本中大范围提升英雄的物攻成长数值，但是针对不同的英雄情况，提升的幅度也不同，具体提升的方式如下。

如果这个英雄原有的物攻成长小于 5，那么将在原有基础上提升 7%-10%。如果物攻成长的提升空间（即最高物攻 attack_max- 初始物攻 attack_start）大于 200，那么在原有的基础上提升 10%；如果物攻成长的提升空间在 150 到 200 之间，则提升 8%；如果物攻成长的提升空间不足 150，则提升 7%。

如果原有英雄的物攻成长在 5—10 之间，那么将在原有基础上提升 5%。

如果原有英雄的物攻成长大于 10，则保持不变。

以上所有的更新后的物攻成长数值，都需要保留小数点后 3 位。

你能看到上面这个计算的情况相对复杂，实际工作中你可能会遇到比这个更加复杂的情况，这时你可以采用面向过程的思考方式来完成这种任务，也就是说先取出每行的数值，然后针对数值的不同情况采取不同的计算方式。

针对上面这个情况，你自己可以用游标来完成转换，具体的代码如下：

CREATE PROCEDURE `alter_attack_growth`()

BEGIN

       -- 创建接收游标的变量

       DECLARE temp_id INT;

       DECLARE temp_growth, temp_max, temp_start, temp_diff FLOAT;

       -- 创建结束标志变量

       DECLARE done INT DEFAULT false;

       -- 定义游标

       DECLARE cur_hero CURSOR FOR SELECT id, attack_growth, attack_max, attack_start FROM heros;

       -- 指定游标循环结束时的返回值

       DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = true;

       OPEN cur_hero;

       FETCH cur_hero INTO temp_id, temp_growth, temp_max, temp_start;

       REPEAT

                     IF NOT done THEN

                            SET temp_diff = temp_max - temp_start;

                            IF temp_growth < 5 THEN

                                   IF temp_diff > 200 THEN

                                          SET temp_growth = temp_growth * 1.1;

                                   ELSEIF temp_diff >= 150 AND temp_diff <=200 THEN

                                          SET temp_growth = temp_growth * 1.08;

                                   ELSEIF temp_diff < 150 THEN

                                          SET temp_growth = temp_growth * 1.07;

                                   END IF;

                            ELSEIF temp_growth >=5 AND temp_growth <=10 THEN

                                   SET temp_growth = temp_growth * 1.05;

                            END IF;

                            UPDATE heros SET attack_growth = ROUND(temp_growth,3) WHERE id = temp_id;

                     END IF;

       FETCH cur_hero INTO temp_id, temp_growth, temp_max, temp_start;

       UNTIL done = true END REPEAT;

       CLOSE cur_hero;

       DEALLOCATE PREPARE cur_hero;

END

这里我创建了 alter_attack_growth 这个存储过程，使用了 REPEAT…UNTIL…的循环方式，针对不同的情况计算了新的物攻成长 temp_growth，然后对原有的 attack_growth 进行了更新，最后调用 call alter_attack_growth(); 执行存储过程。

有一点需要注意的是，我们在对数据表进行更新前，需要备份之前的表，我们可以将备份后的表命名为 heros_copy1。更新完 heros 数据表之后，你可以看下两张表在 attack_growth 字段上的对比，我们使用 SQL 进行查询：

SELECT heros.id, heros.attack_growth, heros_copy1.attack_growth FROM heros JOIN heros_copy1 WHERE heros.id = heros_copy1.id

运行结果（69 条记录）：

通过前后两张表的 attack_growth 对比你也能看出来，存储过程通过游标对不同的数据行进行了更新。

需要说明的是，以上代码适用于 MySQL，如果在 SQL Server 或 Oracle 中，使用方式会有些差别。

总结

今天我们讲解了如何在 SQL 中使用游标，游标实际上是面向过程的思维方式，与面向集合的思维方式不同的地方在于，游标更加关注“如何执行”。我们可以通过游标更加精细、灵活地查询和管理想要的数据行。

有的时候，我们需要找特定数据，用 SQL 查询写起来会比较困难，比如两表或多表之间的嵌套循环查找，如果用 JOIN 会非常消耗资源，效率也可能不高，而用游标则会比较高效。

虽然在处理某些复杂的数据情况下，使用游标可以更灵活，但同时也会带来一些性能问题，比如在使用游标的过程中，会对数据行进行加锁，这样在业务并发量大的时候，不仅会影响业务之间的效率，还会消耗系统资源，造成内存不足，这是因为游标是在内存中进行的处理。如果有游标的替代方案，我们可以采用替代方案。

我们今天讲解了游标，你能用自己的语言介绍下游标的作用吗？另外，我们之前提到过，SQL 本身是一门结构化查询语言，但我们也可以在 SQL 的基础上进行面向过程的开发，完成较为复杂的功能，你能说一下面向过程和面向集合这两种编程方式的区别吗？

欢迎你在评论区写下你的思考，也欢迎把这篇文章分享给你的朋友或者同事，一起交流一下。

17丨如何使用Python操作MySQL？

我们之前都是直接在 DBMS 里面进行 SQL 的操作，实际上我们还可以通过后端语言对 DBMS 进行访问以及进行相应的操作，这样更具有灵活性，可以实现一些较为复杂的操作。作为一个后端开发人员，掌握一些 SQL 技术是必须的；作为一个数据库管理人员，了解后端语言如何开发和管理数据库也是很有必要的。

今天我以 Python 为例，讲解下如何对 MySQL 数据库进行操作。你需要掌握以下几个方面的内容：

Python 的 DB API 规范是什么，遵守这个规范有什么用？

基于 DB API，MySQL 官方提供了驱动器 mysql-connector，如何使用它来完成对数据库管理系统的操作？

CRUD 是最常见的数据库的操作，分别对应数据的增加、读取、修改和删除。在掌握了 mysql-connector 的使用方法之后，如何完成对数据表的 CRUD 操作？

Python DB API 规范

Python 可以支持非常多的数据库管理系统，比如 MySQL、Oracle、SQL Server 和 PostgreSQL 等。为了实现对这些 DBMS 的统一访问，Python 需要遵守一个规范，这就是 DB API 规范。我在下图中列出了 DB API 规范的作用，这个规范给我们提供了数据库对象连接、对象交互和异常处理的方式，为各种 DBMS 提供了统一的访问接口。这样做的好处就是如果项目需要切换数据库，Python 层的代码移植会比较简单。

我们在使用 Python 对 DBMS 进行操作的时候，需要经过下面的几个步骤：

引入 API 模块；

与数据库建立连接；

执行 SQL 语句；

关闭数据库连接。

如何使用 mysql-connector

使用 Python 对数据库进行访问需要基于 DB API 规范，这里有不少库供我们选择，比如 MySQLdb、mysqlclient、PyMySQL、peewee 和 SQLAIchemy 等。今天我讲解的是 mysql-connector，它是 MySQL 官方提供的驱动器，用来给后端语言，比如 Python 提供连接。

下面我们看下如何用 Python 使用 mysql-connector，以完成数据库的连接和使用。

首先安装 mysql-connector。在使用前，你需要先使用下面这句命令进行安装：

pip install mysql-connector

在安装之后，你可以创建数据库连接，然后查看下数据库的版本号，来验证下数据库是否连接成功。代码如下：

# -*- coding: UTF-8 -*-

import mysql.connector

# 打开数据库连接

db = mysql.connector.connect(

       host="localhost",

       user="root",

       passwd="XXX", # 写上你的数据库密码

       database='wucai',

       auth_plugin='mysql_native_password'

)

# 获取操作游标

cursor = db.cursor()

# 执行 SQL 语句

cursor.execute("SELECT VERSION()")

# 获取一条数据

data = cursor.fetchone()

print("MySQL 版本: %s " % data)

# 关闭游标 & 数据库连接

cursor.close()

db.close()

运行结果：

MySQL 版本: 8.0.13

上面这段代码中有两个重要的对象你需要了解下，分别是 Connection 和 Cursor。

Connection 就是对数据库的当前连接进行管理，我们可以通过它来进行以下操作：

通过指定 host、user、passwd 和 port 等参数来创建数据库连接，这些参数分别对应着数据库 IP 地址、用户名、密码和端口号；

使用 db.close() 关闭数据库连接；

使用 db.cursor() 创建游标，操作数据库中的数据；

使用 db.begin() 开启事务；

使用 db.commit() 和 db.rollback()，对事务进行提交以及回滚。

当我们通过cursor = db.cursor()创建游标后，就可以通过面向过程的编程方式对数据库中的数据进行操作：

使用cursor.execute(query_sql)，执行数据库查询；

使用cursor.fetchone()，读取数据集中的一条数据；

使用cursor.fetchall()，取出数据集中的所有行，返回一个元组 tuples 类型；

使用cursor.fetchmany(n)，取出数据集中的多条数据，同样返回一个元组 tuples；

使用cursor.rowcount，返回查询结果集中的行数。如果没有查询到数据或者还没有查询，则结果为 -1，否则会返回查询得到的数据行数；

使用cursor.close()，关闭游标。

对数据表进行增删改查

了解了 Connection 和 Cursor 的使用方式之后，我们来看下如何来对 heros 数据表进行 CRUD 的操作，即增加、读取、更新和删除。

首先是增加数据。

假设我们想在 player 表中增加一名新球员，姓名为“约翰·科林斯”，球队 ID 为 1003（即亚特兰大老鹰），身高为 2.08m。代码如下：

# 插入新球员

sql = "INSERT INTO player (team_id, player_name, height) VALUES (%s, %s, %s)"

val = (1003, " 约翰 - 科林斯 ", 2.08)

cursor.execute(sql, val)

db.commit()

print(cursor.rowcount, " 记录插入成功。")

我们使用 cursor.execute 来执行相应的 SQL 语句，val 为 SQL 语句中的参数，SQL 执行后使用 db.commit() 进行提交。需要说明的是，我们在使用 SQL 语句的时候，可以向 SQL 语句传递参数，这时 SQL 语句里要统一用（%s）进行占位，否则就会报错。不论插入的数值为整数类型，还是浮点类型，都需要统一用（%s）进行占位。

另外在用游标进行 SQL 操作之后，还需要使用 db.commit() 进行提交，否则数据不会被插入。

然后是读取数据。我们来看下数据是否被插入成功，这里我们查询下身高大于等于 2.08m 的球员都有哪些，代码如下：

# 查询身高大于等于 2.08 的球员

sql = 'SELECT player_id, player_name, height FROM player WHERE height>=2.08'

cursor.execute(sql)

data = cursor.fetchall()

for each_player in data:

  print(each_player)

运行结果：

(10003, '安德烈 - 德拉蒙德', 2.11)

(10004, '索恩 - 马克', 2.16)

(10009, '扎扎 - 帕楚里亚', 2.11)

(10010, '乔恩 - 洛伊尔', 2.08)

(10011, '布雷克 - 格里芬', 2.08)

(10015, '亨利 - 埃伦森', 2.11)

(10023, '多曼塔斯 - 萨博尼斯', 2.11)

(10024, '迈尔斯 - 特纳', 2.11)

(10032, 'TJ- 利夫', 2.08)

(10033, '凯尔 - 奥奎因', 2.08)

(10037, '伊凯·阿尼博古', 2.08)

(10038, '约翰 - 科林斯', 2.08)

你能看到球员约翰·科林斯被正确插入。

那么如何修改数据呢？

假如我想修改刚才插入的球员约翰·科林斯的身高，将身高修改成 2.09，代码如下：

# 修改球员约翰 - 科林斯

sql = 'UPDATE player SET height = %s WHERE player_name = %s'

val = (2.09, " 约翰 - 科林斯 ")

cursor.execute(sql, val)

db.commit()

print(cursor.rowcount, " 记录被修改。")

最后我们看下如何删除约翰·科林斯这个球员的数据，代码如下：

sql = 'DELETE FROM player WHERE player_name = %s'

val = (" 约翰 - 科林斯 ",)

cursor.execute(sql, val)

db.commit()

print(cursor.rowcount, " 记录删除成功。")

最后都执行完了，我们来关闭游标和数据库的连接，使用以下代码即可：

cursor.close()

db.close()

针对上面的操作过程，你可以模拟下数据的 CRUD 操作，但有几点你需要注意。

1. 打开数据库连接以后，如果不再使用，则需要关闭数据库连接，以免造成资源浪费。
2. 在对数据进行增加、删除和修改的时候，可能会出现异常，这时就需要用try...except捕获异常信息。比如针对插入球员约翰·科林斯这个操作，你可以写成下面这样：

import traceback

try:

  sql = "INSERT INTO player (team_id, player_name, height) VALUES (%s, %s, %s)"

  val = (1003, " 约翰 - 科林斯 ", 2.08)

  cursor.execute(sql, val)

  db.commit()

  print(cursor.rowcount, " 记录插入成功。")

except Exception as e:

  # 打印异常信息

  traceback.print_exc()

  # 回滚

  db.rollback()

finally:

  # 关闭数据库连接

  db.close()

运行结果告诉我们记录插入成功。

3. 如果你在使用 mysql-connector 连接的时候，系统报的错误为authentication plugin caching_sha2，这时你需要下载最新的版本更新来解决，点击这里进行更新。

总结

我今天讲解了如何使用 Python 来操作 MySQL，这里我们使用的是官方提供的 mysql-connector，当然除了它之外，还有很多库可以进行选择。

在使用基于 DB API 规范的协议时，重点需要掌握 Connection 和 Cursor 这两个对象，Connection 就是对数据库的连接进行管理，而 Cursor 是对数据库的游标进行管理，通过它们，我们可以执行具体的 SQL 语句，以及处理复杂的数据。

用 Python 操作 MySQL，还有很多种姿势，mysql-connector 只是其中一种，实际上还有另外一种方式，就是采用 ORM 框架。ORM 的英文是 Object Relational Mapping，也就是采用对象关系映射的模式，使用这种模式可以将数据库中各种数据表之间的关系映射到程序中的对象。这种模式可以屏蔽底层的数据库的细节，不需要我们与复杂的 SQL 语句打交道，直接采用操作对象的形式操作就可以。

不过如果应用数据实体少，其实没有必要使用 ORM 框架，针对少量对象的管理，自己实现起来也很简单，比如本篇文章中我讲到的采用官方提供的 mysql-connector 驱动的方式来实现 CRUD。引入一个框架的学习成本很高，代码膨胀也很厉害，所以如果是相对简单的操作，完全可以自己动手来实现。

使用 Python 对数据库进行操作，关键在于实战，所以这里我出一个练习题。请你使用 Python 对 heros 表中最大生命值大于 6000 的英雄进行查询，并且输出相应的属性值。

欢迎在评论区写下你的答案，我会与你一起交流。也欢迎把这篇文章分享给你的朋友或者同事，与它们一起交流一下。

18丨SQLAlchemy：如何使用Python ORM框架来操作MySQL？

上节课，我介绍了 Python DB API 规范的作用，以及如何使用 MySQL 官方的 mysql-connector 驱动来完成数据库的连接和使用。在项目比较小的时候，我们可以直接使用 SQL 语句，通过 mysql-connector 完成与 MySQL 的交互，但是任何事物都有两面性，随着项目规模的增加，代码会越来越复杂，维护的成本也越来越高，这时 mysql-connector 就不够用了，我们需要更好的设计模式。

Python 还有另一种方式可以与 MySQL 进行交互，这种方式采用的是 ORM 框架。我们今天就来讲解如何使用 ORM 框架操作 MySQL，那么今天的课程你需要掌握以下几个方面的内容：

什么是 ORM 框架，以及为什么要使用 ORM 框架？

Python 中的 ORM 框架都有哪些？

如何使用 SQLAlchemy 来完成与 MySQL 的交互？

我们为什么要使用 ORM 框架？

在讲解 ORM 框架之前，我们需要先了解什么是持久化。如下图所示，持久化层在业务逻辑层和数据库层起到了衔接的作用，它可以将内存中的数据模型转化为存储模型，或者将存储模型转化为内存中的数据模型。

你可能会想到，我们在讲事务的 4 大特性 ACID 时，提到过持久性。你可以简单地理解为，持久性就是将对象数据永久存储在数据库中。通常我们将数据库的作用理解为永久存储，将内存理解为暂时存储。我们在程序的层面操作数据，其实都是把数据放到内存中进行处理，如果需要数据就会通过持久化层，从数据库中取数据；如果需要保存数据，就是将对象数据通过持久化层存储到数据库中。

那么 ORM 解决的是什么问题呢？它提供了一种持久化模式，可以高效地对数据库进行访问。ORM 的英文是 Object Relation Mapping，中文叫对象关系映射。它是 RDBMS 和业务实体对象之间的一个映射，从图中你也能看到，它可以把底层的 RDBMS 封装成业务实体对象，提供给业务逻辑层使用。程序员往往关注业务逻辑层面，而不是底层数据库该如何访问，以及如何编写 SQL 语句获取数据等等。采用 ORM，就可以从数据库的设计层面转化成面向对象的思维。

我在开篇的时候提到过，随着项目规模的增大，在代码层编写 SQL 语句访问数据库会降低开发效率，也会提升维护成本，因此越来越多的开发人员会采用基于 ORM 的方式来操作数据库。这样做的好处就是一旦定义好了对象模型，就可以让它们简单可复用，从而不必关注底层的数据库访问细节，我们只要将注意力集中到业务逻辑层面就可以了。由此还可以带来另一点好处，那就是即便数据库本身进行了更换，在业务逻辑代码上也不会有大的调整。这是因为 ORM 抽象了数据的存取，同时也兼容多种 DBMS，我们不用关心底层采用的到底是哪种 DBMS，是 MySQL，SQL Server，PostgreSQL 还是 SQLite。

但没有一种模式是完美的，采用 ORM 当然也会付出一些代价，比如性能上的一些损失。面对一些复杂的数据查询，ORM 会显得力不从心。虽然可以实现功能，但相比于直接编写 SQL 查询语句来说，ORM 需要编写的代码量和花费的时间会比较多，这种情况下，直接编写 SQL 反而会更简单有效。

其实你也能看出来，没有一种方式是一劳永逸的，在实际工作中，我们需要根据需求选择适合的方式。

Python 中的 ORM 框架都有哪些

ORM 框架帮我们适配了各种 DBMS，同时我们也可以选择不同的 ORM 框架。如果你用 Python 的话，有三种主流的 ORM 框架。

第一个是 Django，它是 Python 的 WEB 应用开发框架，本身走大而全的方式。Django 采用了 MTV 的框架模式，包括了 Model（模型），View（视图）和 Template（模版）。Model 模型只是 Django 的一部分功能，我们可以通过它来实现数据库的增删改查操作。

一个 Model 映射到一个数据表，如下图所示：

从这张图上你能直观地看到，ORM 的作用就是建立了对象关系映射。模型的每个属性代表数据表中的一个字段，我们通过操作类实例对象，对数据表中的数据行进行增删改查等操作。

第二个是 SQLALchemy，它也是 Python 中常用的 ORM 框架之一。它提供了 SQL 工具包及 ORM 工具，如果你想用支持 ORM 和支持原生 SQL 两种方式的工具，那么 SQLALchemy 是很好的选择。另外 SQLALchemy 的社区更加活跃，这对项目实施会很有帮助。

第三个是 peewee，这是一个轻量级的 ORM 框架，简单易用。peewee 采用了 Model 类、Field 实例和 Model 实例来与数据库建立映射关系，从而完成面向对象的管理方式。使用起来方便，学习成本也低。

如何使用 SQLAlchemy 来操作 MySQL

下面我们来看下如何使用 SQLAlchemy 工具对 player 数据表进行增删改查，在使用前，你需要先安装相应的工具包：

pip install sqlalchemy

初始化数据库连接

from sqlalchemy import create_engine

# 初始化数据库连接，修改为你的数据库用户名和密码

engine = create_engine('mysql+mysqlconnector://root:password@localhost:3306/wucai')

create_engine 的使用方法类似我们在上篇文章中提到的 mysql.connector，都需要提供数据库 + 数据库连接框架，即对应的是mysql+mysqlconnector，后面的是用户名:密码@IP地址:端口号/数据库名称。

创建模型

我们已经创建了 player 数据表，这里需要创建相应的 player 模型。

# 定义 Player 对象:

class Player(Base):

    # 表的名字:

    __tablename__ = 'player'

    # 表的结构:

    player_id = Column(Integer, primary_key=True, autoincrement=True)

    team_id = Column(Integer)

    player_name = Column(String(255))

    height = Column(Float(3,2))

这里需要说明的是，__tablename__ 指明了模型对应的数据表名称，即 player 数据表。同时我们在 Player 模型中对采用的变量名进行定义，变量名需要和数据表中的字段名称保持一致，否则会找不到数据表中的字段。在 SQLAlchemy 中，我们采用 Column 对字段进行定义，常用的数据类型如下：

除了指定 Column 的数据类型以外，我们也可以指定 Column 的参数，这些参数可以帮我们对对象创建列约束：

这里需要说明的是，如果你使用相应的数据类型，那么需要提前在 SQLAlchemy 中进行引用，比如：

from sqlalchemy import Column, String, Integer, Float

对数据表进行增删改查

假设我们想给 player 表增加一名新球员，姓名为“约翰·科林斯”，球队 ID 为 1003（即亚特兰大老鹰），身高为 2.08。代码如下：

# 创建 DBSession 类型:

DBSession = sessionmaker(bind=engine)

# 创建 session 对象:

session = DBSession()

# 创建 Player 对象:

new_player = Player(team_id = 1003, player_name = " 约翰 - 科林斯 ", height = 2.08)

# 添加到 session:

session.add(new_player)

# 提交即保存到数据库:

session.commit()

# 关闭 session:

session.close()

这里，我们首先需要初始化 DBSession，相当于创建一个数据库的会话实例 session。通过 session 来完成新球员的添加。对于新球员的数据，我们可以通过 Player 类来完成创建，在参数中指定相应的team_id, player_name, height即可。

然后把创建好的对象 new_player 添加到 session 中，提交到数据库即可完成添加数据的操作。

接着，我们来看一下如何查询数据。

添加完插入的新球员之后，我们可以查询下身高 ≥ 2.08m 的球员都有哪些，代码如下：

# 增加 to_dict() 方法到 Base 类中

def to_dict(self):

    return {c.name: getattr(self, c.name, None)

            for c in self.__table__.columns}

# 将对象可以转化为 dict 类型

Base.to_dict = to_dict

# 查询身高 >=2.08 的球员有哪些

rows = session.query(Player).filter(Player.height >= 2.08).all()

print([row.to_dict() for row in rows])

运行结果：

[{'player_id': 10003, 'team_id': 1001, 'player_name': '安德烈 - 德拉蒙德', 'height': Decimal('2.1100000000')}, {'player_id': 10004, 'team_id': 1001, 'player_name': '索恩 - 马克', 'height': Decimal('2.1600000000')}, {'player_id': 10009, 'team_id': 1001, 'player_name': '扎扎 - 帕楚里亚', 'height': Decimal('2.1100000000')}, {'player_id': 10010, 'team_id': 1001, 'player_name': '乔恩 - 洛伊尔', 'height': Decimal('2.0800000000')}, {'player_id': 10011, 'team_id': 1001, 'player_name': '布雷克 - 格里芬', 'height': Decimal('2.0800000000')}, {'player_id': 10015, 'team_id': 1001, 'player_name': '亨利 - 埃伦森', 'height': Decimal('2.1100000000')}, {'player_id': 10023, 'team_id': 1002, 'player_name': '多曼塔斯 - 萨博尼斯', 'height': Decimal('2.1100000000')}, {'player_id': 10024, 'team_id': 1002, 'player_name': '迈尔斯 - 特纳', 'height': Decimal('2.1100000000')}, {'player_id': 10032, 'team_id': 1002, 'player_name': 'TJ- 利夫', 'height': Decimal('2.0800000000')}, {'player_id': 10033, 'team_id': 1002, 'player_name': '凯尔 - 奥奎因', 'height': Decimal('2.0800000000')}, {'player_id': 10037, 'team_id': 1002, 'player_name': '伊凯·阿尼博古', 'height': Decimal('2.0800000000')}, {'player_id': 10038, 'team_id': 1003, 'player_name': '约翰 - 科林斯', 'height': Decimal('2.0800000000')}]

如果我们对整个数据行进行查询，采用的是session.query(Player)，相当于使用的是 SELECT *。这时如果我们想要在 Python 中对 query 结果进行打印，可以对 Base 类增加to_dict()方法，相当于将对象转化成了 Python 的字典类型。

在进行查询的时候，我们使用的是 filter 方法，对应的是 SQL 中的 WHERE 条件查询。除此之外，filter 也支持多条件查询。

如果是 AND 的关系，比如我们想要查询身高 ≥ 2.08，同时身高 ≤ 2.10 的球员，可以写成下面这样：

rows = session.query(Player).filter(Player.height >=2.08, Player.height <=2.10).all()

如果是 OR 的关系，比如我们想要查询身高 ≥ 2.08，或者身高 ≤ 2.10 的球员，可以写成这样：

rows = session.query(Player).filter(or_(Player.height >=2.08, Player.height <=2.10)).all()

这里我们使用了 SQLAlchemy 的 or_ 操作符，在使用它之前你需要进行引入，即：from sqlalchemy import or_。

除了多条件查询，SQLAlchemy 也同样支持分组操作、排序和返回指定数量的结果。

比如我想要按照 team_id 进行分组，同时筛选分组后数据行数大于 5 的分组，并且按照分组后数据行数递增的顺序进行排序，显示 team_id 字段，以及每个分组的数据行数。那么代码如下：

from sqlalchemy import func

rows = session.query(Player.team_id, func.count(Player.player_id)).group_by(Player.team_id).having(func.count(Player.player_id)>5).order_by(func.count(Player.player_id).asc()).all()

print(rows)

运行结果：

[(1001, 20), (1002, 17)]

这里有几点需要注意：

我们把需要显示的字段 Player.team_id, func.count(Player.player_id) 作为 query 的参数，其中我们需要用到 sqlalchemy 的 func 类，它提供了各种聚集函数，比如 func.count 函数。

在 query() 后面使用了 group_by() 进行分组，参数设置为 Player.team_id 字段，再使用 having 对分组条件进行筛选，参数为func.count(Player.player_id)>5。

使用 order_by 进行排序，参数为func.count(Player.player_id).asc()，也就是按照分组后的数据行数递增的顺序进行排序，最后使用.all() 方法需要返回全部的数据。

你能看到 SQLAlchemy 使用的规则和使用 SELECT 语句的规则差不多，只是封装到了类中作为方法进行调用。

接着，我们再来看下如何删除数据。如果我们想要删除某些数据，需要先进行查询，然后再从 session 中把这些数据删除掉。

比如我们想要删除姓名为约翰·科林斯的球员，首先我们需要进行查询，然后从 session 对象中进行删除，最后进行 commit 提交，代码如下：

row = session.query(Player).filter(Player.player_name=='约翰 - 科林斯').first()

session.delete(row)

session.commit()

session.close()

需要说明的是，判断球员姓名是否为约翰·科林斯，这里需要使用（==）。

同样，如果我们想要修改某条数据，也需要进行查询，然后再进行修改。比如我想把球员索恩·马克的身高改成 2.17，那么执行完之后直接对 session 对象进行 commit 操作，代码如下：

row = session.query(Player).filter(Player.player_name=='索恩 - 马克').first()

row.height = 2.17

session.commit()

session.close()

总结

今天我们使用 SQLAlalchemy 对 MySQL 进行了操作，你能看到这些实现并不复杂，只是需要事先掌握一些使用方法，尤其是如何创建 seesion 对象，以及如何通过 session 对象来完成对数据的增删改查等操作。建议你把文章里的代码都跑一遍，在运行的过程中一定会有更深入的体会。

当然除了学习掌握 SQLAlalchemy 这个 Python ORM 工具以外，我还希望你能了解到 ORM 的价值和不足。如果项目本身不大，那么自己动手写 SQL 语句会比较简单，你可以不使用 ORM 工具，而是直接使用上节课讲到的 mysql-connector。但是随着项目代码量的增加，为了在业务逻辑层与数据库底层进行松耦合，采用 ORM 框架是更加适合的。

我今天讲解了 SQLAlalchemy 工具的使用，为了更好地让你理解，我出一道练习题吧。还是针对 player 数据表，请你使用 SQLAlalchemy 工具查询身高为 2.08 米的球员，并且将这些球员的身高修改为 2.09。

欢迎你在评论区写下你的答案，也欢迎把这篇文章分享给你的朋友或者同事，一起交流。

19丨基础篇总结：如何理解查询优化、通配符以及存储过程？

到这一篇的时候，意味着 SQL 专栏的基础部分正式更新完毕。在文章更新的时候，谢谢大家积极地评论和提问，让专栏增色不少。我总结了一些基础篇的常见问题，希望能对你有所帮助。答疑篇主要包括了 DBMS、查询优化、存储过程、事务处理等一些问题。

关于各种 DBMS 的介绍

答疑 1

文章中有句话不太理解，“列式数据库是将数据按照列存储到数据库中，这样做的好处是可以大量降低系统的 I/O”，可以解释一些“降低系统 I/O”是什么意思吗？

解答

行式存储是把一行的数据都串起来进行存储，然后再存储下一行。同样，列式存储是把一列的数据都串起来进行存储，然后再存储下一列。这样做的话，相邻数据的数据类型都是一样的，更容易压缩，压缩之后就自然降低了 I/O。

我们还需要从数据处理的需求出发，去理解行式存储和列式存储。数据处理可以分为 OLTP（联机事务处理）和 OLAP（联机分析处理）两大类。

OLTP 一般用于处理客户的事务和进行查询，需要随时对数据表中的记录进行增删改查，对实时性要求高。

OLAP 一般用于市场的数据分析，通常数据量大，需要进行复杂的分析操作，可以对大量历史数据进行汇总和分析，对实时性要求不高。

那么对于 OLTP 来说，由于随时需要对数据记录进行增删改查，更适合采用行式存储，因为一行数据的写入会同时修改多个列。传统的 RDBMS 都属于行式存储，比如 Oracle、SQL Server 和 MySQL 等。

对于 OLAP 来说，由于需要对大量历史数据进行汇总和分析，则适合采用列式存储，这样的话汇总数据会非常快，但是对于插入（INSERT）和更新（UPDATE）会比较麻烦，相比于行式存储性能会差不少。

所以说列式存储适合大批量数据查询，可以降低 I/O，但如果对实时性要求高，则更适合行式存储。

关于查询优化

答疑 1

在 MySQL 中统计数据表的行数，可以使用三种方式：SELECT COUNT(*)、SELECT COUNT(1)和SELECT COUNT(具体字段)，使用这三者之间的查询效率是怎样的？之前看到说是：SELECT COUNT(*)＞ SELECT COUNT(1)＞ SELECT COUNT(具体字段)。

解答

在 MySQL InnoDB 存储引擎中，COUNT(*)和COUNT(1)都是对所有结果进行COUNT。如果有 WHERE 子句，则是对所有符合筛选条件的数据行进行统计；如果没有 WHERE 子句，则是对数据表的数据行数进行统计。

因此COUNT(*)和COUNT(1)本质上并没有区别，执行的复杂度都是O(N)，也就是采用全表扫描，进行循环 + 计数的方式进行统计。

如果是 MySQL MyISAM 存储引擎，统计数据表的行数只需要O(1)的复杂度，这是因为每张 MyISAM 的数据表都有一个 meta 信息存储了row_count值，而一致性则由表级锁来保证。因为 InnoDB 支持事务，采用行级锁和 MVCC 机制，所以无法像 MyISAM 一样，只维护一个row_count变量，因此需要采用扫描全表，进行循环 + 计数的方式来完成统计。

需要注意的是，在实际执行中，COUNT(*)和COUNT(1)的执行时间可能略有差别，不过你还是可以把它俩的执行效率看成是相等的。

另外在 InnoDB 引擎中，如果采用COUNT(*)和COUNT(1)来统计数据行数，要尽量采用二级索引。因为主键采用的索引是聚簇索引，聚簇索引包含的信息多，明显会大于二级索引（非聚簇索引）。对于COUNT(*)和COUNT(1)来说，它们不需要查找具体的行，只是统计行数，系统会自动采用占用空间更小的二级索引来进行统计。

然而如果想要查找具体的行，那么采用主键索引的效率更高。如果有多个二级索引，会使用 key_len 小的二级索引进行扫描。当没有二级索引的时候，才会采用主键索引来进行统计。

这里我总结一下：

一般情况下，三者执行的效率为 COUNT(*)= COUNT(1)> COUNT(字段)。我们尽量使用COUNT(*)，当然如果你要统计的是某个字段的非空数据行数，则另当别论，毕竟比较执行效率的前提是结果一样才可以。

如果要统计COUNT(*)，尽量在数据表上建立二级索引，系统会自动采用key_len小的二级索引进行扫描，这样当我们使用SELECT COUNT(*)的时候效率就会提升，有时候可以提升几倍甚至更高。

答疑 2

在 MySQL 中，LIMIT关键词是最后执行的，如果可以确定只有一条结果，那么就起不到查询优化的效果了吧，因为LIMIT是对最后的结果集过滤，如果结果集本来就只有一条，那就没有什么用了。

解答

如果你可以确定结果集只有一条，那么加上LIMIT 1的时候，当找到一条结果的时候就不会继续扫描了，这样会加快查询速度。这里指的查询优化针对的是会扫描全表的 SQL 语句，如果数据表已经对字段建立了唯一索引，那么可以通过索引进行查询，不会全表扫描的话，就不需要加上LIMIT 1了。

关于通配符的解释

关于查询语句中通配符的使用理解，我举了一个查询英雄名除了第一个字以外，包含“太”字的英雄都有谁的例子，使用的 SQL 语句是：

SQL> SELECT name FROM heros WHERE name LIKE '_% 太 %'

（_）匹配任意一个字符，（%） 匹配大于等于 0 个任意字符。

所以通配符'_%太%'说明在第一个字符之后需要有“太”字，这里就不能匹配上“太乙真人”，但是可以匹配上“东皇太一”。如果数据表中有“太乙真人太太”，那么结果集中也可以匹配到。

另外，单独的LIKE '%'无法查出 NULL 值，比如：SELECT * FROM heros WHERE role_assist LIKE '%'。

答疑 4

可以理解在 WHERE 条件字段上加索引，但是为什么在 ORDER BY 字段上还要加索引呢？这个时候已经通过 WHERE 条件过滤得到了数据，已经不需要再筛选过滤数据了，只需要根据字段排序就好了。

解答

在 MySQL 中，支持两种排序方式，分别是 FileSort 和 Index 排序。在 Index 排序中，索引可以保证数据的有序性，不需要再进行排序，效率更高。而 FileSort 排序则一般在内存中进行排序，占用 CPU 较多。如果待排结果较大，会产生临时文件 I/O 到磁盘进行排序的情况，效率较低。

所以使用 ORDER BY 子句时，应该尽量使用 Index 排序，避免使用 FileSort 排序。当然你可以使用 explain 来查看执行计划，看下优化器是否采用索引进行排序。

优化建议：

SQL 中，可以在 WHERE 子句和 ORDER BY 子句中使用索引，目的是在 WHERE 子句中避免全表扫描，在 ORDER BY 子句避免使用 FileSort 排序。当然，某些情况下全表扫描，或者 FileSort 排序不一定比索引慢。但总的来说，我们还是要避免，以提高查询效率。一般情况下，优化器会帮我们进行更好的选择，当然我们也需要建立合理的索引。

尽量使用 Index 完成 ORDER BY 排序。如果 WHERE 和 ORDER BY 后面是相同的列就使用单索引列；如果不同就使用联合索引。

无法使用 Index 时，需要对 FileSort 方式进行调优。

答疑 5

ORDER BY 是对分的组排序还是对分组中的记录排序呢？

解答

ORDER BY 就是对记录进行排序。如果你在 ORDER BY 前面用到了 GROUP BY，实际上这是一种分组的聚合方式，已经把一组的数据聚合成为了一条记录，再进行排序的时候，相当于对分的组进行了排序。

答疑 6

请问下关于 SELECT 语句内部的执行步骤。

解答

一条完整的 SELECT 语句内部的执行顺序是这样的：

FROM 子句组装数据（包括通过 ON 进行连接）；

WHERE 子句进行条件筛选；

GROUP BY 分组 ；

使用聚集函数进行计算；

HAVING 筛选分组；

计算所有的表达式；

SELECT 的字段；

ORDER BY 排序；

LIMIT 筛选。

答疑 7

不太理解哪种情况下应该使用 EXISTS，哪种情况应该用 IN。选择的标准是看能否使用表的索引吗？

解答

索引是个前提，其实选择与否还是要看表的大小。你可以将选择的标准理解为小表驱动大表。在这种方式下效率是最高的。

比如下面这样：

 SELECT * FROM A WHERE cc IN (SELECT cc FROM B)

 SELECT * FROM A WHERE EXISTS (SELECT cc FROM B WHERE B.cc=A.cc)

当 A 小于 B 时，用 EXISTS。因为 EXISTS 的实现，相当于外表循环，实现的逻辑类似于：

 for i in A

     for j in B

         if j.cc == i.cc then ...

当 B 小于 A 时用 IN，因为实现的逻辑类似于：

 for i in B

     for j in A

         if j.cc == i.cc then ...

哪个表小就用哪个表来驱动，A 表小就用 EXISTS，B 表小就用 IN。

关于存储过程

答疑 1

在使用存储过程声明变量时，都支持哪些数据类型呢？

解答

不同的 DBMS 对数据类型的定义不同，你需要查询相关的 DBMS 文档。以 MySQL 为例，常见的数据类型可以分成三类，分别是数值类型、字符串类型和日期／时间类型。

答疑 2

“IN 参数必须在调用存储过程时指定”的含义是什么？我查询了 MySQL 的存储过程定义，可以不包含 IN 参数。当存储过程的定义语句里有 IN 参数时，存储过程的语句中必须用到这个参数吗?

解答

如果存储过程定义了 IN 参数，就需要在调用的时候传入。当然在定义存储过程的时候，如果不指定参数类型，就默认是 IN 类型的参数。因为 IN 参数在存储过程中是默认值，可以省略不写。比如下面两种定义方式都是一样的：

CREATE PROCEDURE `add_num`(IN n INT)

CREATE PROCEDURE `add_num`(n INT)

在存储过程中的语句里，不一定要用到 IN 参数，只是在调用的时候需要传入这个。另外 IN 参数在存储过程中进行了修改，也不会进行返回的。如果想要返回参数，需要使用 OUT，或者 INOUT 参数类型。

关于事务处理

答疑 1

如果INSERT INTO test SELECT '关羽';之后没有执行 COMMIT，结果应该是空。但是我执行出来的结果是'关羽'，为什么 ROLLBACK 没有全部回滚？

代码如下：

 CREATE TABLE test(name varchar(255), PRIMARY KEY (name)) ENGINE=InnoDB;

 BEGIN;

 INSERT INTO test SELECT '关羽';

 BEGIN;

 INSERT INTO test SELECT '张飞';

 INSERT INTO test SELECT '张飞';

 ROLLBACK;

 SELECT * FROM test;

解答

先解释下连续 BEGIN 的情况。

在 MySQL 中 BEGIN 用于开启事务，如果是连续 BEGIN，当开启了第一个事务，还没有进行 COMMIT 提交时，会直接进行第二个事务的 BEGIN，这时数据库会隐式地 COMMIT 第一个事务，然后再进入到第二个事务。

为什么 ROLLBACK 没有全部回滚呢？

因为 ROLLBACK 是针对当前事务的，在 BEGIN 之后已经开启了第二个事务，当遇到 ROLLBACK 的时候，第二个事务都进行了回滚，也就得到了第一个事务执行之后的结果即“关羽”。

关于事务的 ACID，以及我们使用 COMMIT 和 ROLLBACK 来控制事务的时候，有一个容易出错的地方。

在一个事务的执行过程中可能会失败。遇到失败的时候是进行回滚，还是将事务执行过程中已经成功操作的来进行提交，这个逻辑是需要开发者自己来控制的。

这里开发者可以决定，如果遇到了小错误是直接忽略，提交事务，还是遇到任何错误都进行回滚。如果我们强行进行 COMMIT，数据库会将这个事务中成功的操作进行提交，它会认为你觉得已经是 ACID 了（就是你认为可以做 COMMIT 了，即使遇到了一些小问题也是可以忽略的）。

我在今天的文章里重点解答了一些问题，还有一些未解答的会留在评论里进行回复。最后出一道思考题吧。

请你自己写出下面操作的运行结果（你可以把它作为一道笔试题，自己写出结果，再与实际的运行结果进行比对）：

DROP TABLE IF EXISTS test;

CREATE TABLE test(name varchar(255), PRIMARY KEY (name)) ENGINE=InnoDB;

BEGIN;

INSERT INTO test SELECT '关羽';

BEGIN;

INSERT INTO test SELECT '张飞';

INSERT INTO test SELECT '张飞';

COMMIT;

SELECT * FROM test;

欢迎你在评论区写下你的思考，我会与你一起交流，也欢迎把这篇文章分享给你的朋友或者同事，一起交流一下。

20丨当我们思考数据库调优的时候，都有哪些维度可以选择？

从这一篇开始，我们正式进入了 SQL 性能优化篇。在这一模块中，我们会关注如何提升 SQL 查询的效率。你可以思考一下，如何你是一名 DBA 或者开发人员，都有哪些维度可以进行数据库调优？

其实关于数据库调优的知识点非常分散。不同的 DBMS，不同的公司，不同的职位，不同的项目遇到的问题都不尽相同。为了能让你对数据库调优有一个整体的概览，我把这些知识点做了一个梳理，希望能对你有一些帮助。

今天的课程你需要掌握以下几个方面的内容：

数据库调优的目标是什么？

如果要进行调优，都有哪些维度可以选择？

如何思考和分析数据库调优这件事？

数据库调优的目标

简单来说，数据库调优的目的就是要让数据库运行得更快，也就是说响应的时间更快，吞吐量更大。

不过随着用户量的不断增加，以及应用程序复杂度的提升，我们很难用“更快”去定义数据库调优的目标，因为用户在不同时间段访问服务器遇到的瓶颈不同，比如双十一促销的时候会带来大规模的并发访问；还有用户在进行不同业务操作的时候，数据库的事务处理和 SQL 查询都会有所不同。因此我们还需要更加精细的定位，去确定调优的目标。

如何确定呢？一般情况下，有两种方式可以得到反馈。

用户的反馈

用户是我们的服务对象，因此他们的反馈是最直接的。虽然他们不会直接提出技术建议，但是有些问题往往是用户第一时间发现的。我们要重视用户的反馈，找到和数据相关的问题。

日志分析

我们可以通过查看数据库日志和操作系统日志等方式找出异常情况，通过它们来定位遇到的问题。

除了这些具体的反馈以外，我们还可以通过监控运行状态来整体了解服务器和数据库的运行情况。

服务器资源使用监控

通过监控服务器的 CPU、内存、I/O 等使用情况，可以实时了解服务器的性能使用，与历史情况进行对比。

数据库内部状况监控

在数据库的监控中，活动会话（Active Session）监控是一个重要的指标。通过它，你可以清楚地了解数据库当前是否处于非常繁忙的状态，是否存在 SQL 堆积等。

除了活动会话监控以外，我们也可以对事务、锁等待等进行监控，这些都可以帮助我们对数据库的运行状态有更全面的认识。

对数据库进行调优，都有哪些维度可以进行选择？

我们需要调优的对象是整个数据库管理系统，它不仅包括 SQL 查询，还包括数据库的部署配置、架构等。从这个角度来说，我们思考的维度就不仅仅局限在 SQL 优化上了。

听起来比较复杂，但其实我们可以一步步通过下面的步骤进行梳理。

第一步，选择适合的 DBMS

我们之前讲到了 SQL 阵营和 NoSQL 阵营。在 RDBMS 中，常用的有 Oracle，SQL Server 和 MySQL 等。如果对事务性处理以及安全性要求高的话，可以选择商业的数据库产品。这些数据库在事务处理和查询性能上都比较强，比如采用 SQL Server，那么单表存储上亿条数据是没有问题的。如果数据表设计得好，即使不采用分库分表的方式，查询效率也不差。

除此以外，你也可以采用开源的 MySQL 进行存储，我们之前讲到过，它有很多存储引擎可以选择，如果进行事务处理的话可以选择 InnoDB，非事务处理可以选择 MyISAM。

NoSQL 阵营包括键值型数据库、文档型数据库、搜索引擎、列式存储和图形数据库。这些数据库的优缺点和使用场景各有不同，比如列式存储数据库可以大幅度降低系统的 I/O，适合于分布式文件系统和 OLAP，但如果数据需要频繁地增删改，那么列式存储就不太适用了。原因我在答疑篇已经讲过，这里不再赘述。

DBMS 的选择关系到了后面的整个设计过程，所以第一步就是要选择适合的 DBMS。如果已经确定好了 DBMS，那么这步可以跳过，但有时候我们要根据业务需求来进行选择。

第二步，优化表设计

选择了 DBMS 之后，我们就需要进行表设计了。RDBMS 中，每个对象都可以定义为一张表，表与表之间的关系代表了对象之间的关系。如果用的是 MySQL，我们还可以根据不同表的使用需求，选择不同的存储引擎。除此以外，还有一些优化的原则可以参考：

表结构要尽量遵循第三范式的原则（关于第三范式，我在后面章节会讲）。这样可以让数据结构更加清晰规范，减少冗余字段，同时也减少了在更新，插入和删除数据时等异常情况的发生。

如果分析查询应用比较多，尤其是需要进行多表联查的时候，可以采用反范式进行优化。反范式采用空间换时间的方式，通过增加冗余字段提高查询的效率。

表字段的数据类型选择，关系到了查询效率的高低以及存储空间的大小。一般来说，如果字段可以采用数值类型就不要采用字符类型；字符长度要尽可能设计得短一些。针对字符类型来说，当确定字符长度固定时，就可以采用 CHAR 类型；当长度不固定时，通常采用 VARCHAR 类型。

数据表的结构设计很基础，也很关键。好的表结构可以在业务发展和用户量增加的情况下依然发挥作用，不好的表结构设计会让数据表变得非常臃肿，查询效率也会降低。

第三步，优化逻辑查询

当我们建立好数据表之后，就可以对数据表进行增删改查的操作了。这时我们首先需要考虑的是逻辑查询优化，什么是逻辑查询优化呢？

SQL 查询优化，可以分为逻辑查询优化和物理查询优化。逻辑查询优化就是通过改变 SQL 语句的内容让 SQL 执行效率更高效，采用的方式是对 SQL 语句进行等价变换，对查询进行重写。重写查询的数学基础就是关系代数。

SQL 的查询重写包括了子查询优化、等价谓词重写、视图重写、条件简化、连接消除和嵌套连接消除等。

比如我们在讲解 EXISTS 子查询和 IN 子查询的时候，会根据小表驱动大表的原则选择适合的子查询。在 WHERE 子句中会尽量避免对字段进行函数运算，它们会让字段的索引失效。

我举一个例子，假设我想对商品评论表中的评论内容进行检索，查询评论内容开头为 abc 的内容都有哪些，如果在 WHERE 子句中使用了函数，语句就会写成下面这样：

SELECT comment_id, comment_text, comment_time FROM product_comment WHERE SUBSTRING(comment_text, 1,3)='abc'

我们可以采用查询重写的方式进行等价替换：

SELECT comment_id, comment_text, comment_time FROM product_comment WHERE comment_text LIKE 'abc%'

你会发现在数据量大的情况下，第二条 SQL 语句的查询效率要比前面的高很多，执行时间为前者的 1/10。

第四步，优化物理查询

物理查询优化是将逻辑查询的内容变成可以被执行的物理操作符，从而为后续执行器的执行提供准备。它的核心是高效地建立索引，并通过这些索引来做各种优化。

但你要知道索引不是万能的，我们需要根据实际情况来创建索引。那么都有哪些情况需要考虑呢？

如果数据重复度高，就不需要创建索引。通常在重复度超过 10% 的情况下，可以不创建这个字段的索引。比如性别这个字段（取值为男和女）。

要注意索引列的位置对索引使用的影响。比如我们在 WHERE 子句中对索引字段进行了表达式的计算，会造成这个字段的索引失效。

要注意联合索引对索引使用的影响。我们在创建联合索引的时候会对多个字段创建索引，这时索引的顺序就很重要了。比如我们对字段 x, y, z 创建了索引，那么顺序是 (x,y,z) 还是 (z,y,x)，在执行的时候就会存在差别。

要注意多个索引对索引使用的影响。索引不是越多越好，因为每个索引都需要存储空间，索引多也就意味着需要更多的存储空间。此外，过多的索引也会导致优化器在进行评估的时候增加了筛选出索引的计算时间，影响评估的效率。

查询优化器在对 SQL 语句进行等价变换之后，还需要根据数据表的索引情况和数据情况确定访问路径，这就决定了执行 SQL 时所需要消耗的资源。SQL 查询时需要对不同的数据表进行查询，因此在物理查询优化阶段也需要确定这些查询所采用的路径，具体的情况包括：

单表扫描：对于单表扫描来说，我们可以全表扫描所有的数据，也可以局部扫描。

两张表的连接：常用的连接方式包括了嵌套循环连接、HASH 连接和合并连接。

多张表的连接：多张数据表进行连接的时候，顺序很重要，因为不同的连接路径查询的效率不同，搜索空间也会不同。我们在进行多表连接的时候，搜索空间可能会达到很高的数据量级，巨大的搜索空间显然会占用更多的资源，因此我们需要通过调整连接顺序，将搜索空间调整在一个可接收的范围内。

物理查询优化是在确定了逻辑查询优化之后，采用物理优化技术（比如索引等），通过计算代价模型对各种可能的访问路径进行估算，从而找到执行方式中代价最小的作为执行计划。在这个部分中，我们需要掌握的重点是对索引的创建和使用。

第五步，使用 Redis 或 Memcached 作为缓存

除了可以对 SQL 本身进行优化以外，我们还可以请外援提升查询的效率。

因为数据都是存放到数据库中，我们需要从数据库层中取出数据放到内存中进行业务逻辑的操作，当用户量增大的时候，如果频繁地进行数据查询，会消耗数据库的很多资源。如果我们将常用的数据直接放到内存中，就会大幅提升查询的效率。

键值存储数据库可以帮我们解决这个问题。

常用的键值存储数据库有 Redis 和 Memcached，它们都可以将数据存放到内存中。

从可靠性来说，Redis 支持持久化，可以让我们的数据保存在硬盘上，不过这样一来性能消耗也会比较大。而 Memcached 仅仅是内存存储，不支持持久化。

从支持的数据类型来说，Redis 比 Memcached 要多，它不仅支持 key-value 类型的数据，还支持 List，Set，Hash 等数据结构。 当我们有持久化需求或者是更高级的数据处理需求的时候，就可以使用 Redis。如果是简单的 key-value 存储，则可以使用 Memcached。

通常我们对于查询响应要求高的场景（响应时间短，吞吐量大），可以考虑内存数据库，毕竟术业有专攻。传统的 RDBMS 都是将数据存储在硬盘上，而内存数据库则存放在内存中，查询起来要快得多。不过使用不同的工具，也增加了开发人员的使用成本。

第六步，库级优化

库级优化是站在数据库的维度上进行的优化策略，比如控制一个库中的数据表数量。另外我们可以采用主从架构优化我们的读写策略。

如果读和写的业务量都很大，并且它们都在同一个数据库服务器中进行操作，那么数据库的性能就会出现瓶颈，这时为了提升系统的性能，优化用户体验，我们可以采用读写分离的方式降低主数据库的负载，比如用主数据库（master）完成写操作，用从数据库（slave）完成读操作。

除此以外，我们还可以对数据库分库分表。当数据量级达到亿级以上时，有时候我们需要把一个数据库切成多份，放到不同的数据库服务器上，减少对单一数据库服务器的访问压力。如果你使用的是 MySQL，就可以使用 MySQL 自带的分区表功能，当然你也可以考虑自己做垂直切分和水平切分。

什么情况下做垂直切分，什么情况下做水平切分呢？

如果数据库中的数据表过多，可以采用垂直分库的方式，将关联的数据表部署在一个数据库上。

如果数据表中的列过多，可以采用垂直分表的方式，将数据表分拆成多张，把经常一起使用的列放到同一张表里。

如果数据表中的数据达到了亿级以上，可以考虑水平切分，将大的数据表分拆成不同的子表，每张表保持相同的表结构。比如你可以按照年份来划分，把不同年份的数据放到不同的数据表中。2017 年、2018 年和 2019 年的数据就可以分别放到三张数据表中。

采用垂直分表的形式，就是将一张数据表分拆成多张表，采用水平拆分的方式，就是将单张数据量大的表按照某个属性维度分成不同的小表。

但需要注意的是，分拆在提升数据库性能的同时，也会增加维护和使用成本。

我们该如何思考和分析数据库调优这件事

做任何事情之前，我们都需要确认目标。在数据库调优中，我们的目标就是响应时间更快，吞吐量更大。利用宏观的监控工具和微观的日志分析可以帮我们快速找到调优的思路和方式。

虽然每个人的情况都不一样，但我们同样需要对数据库调优这件事有一个整体的认知。在思考数据库调优的时候，可以从三个维度进行考虑。

首先，选择比努力更重要。

在进行 SQL 调优之前，可以先选择 DBMS 和数据表的设计方式。你能看到，不同的 DBMS 直接决定了后面的操作方式，数据表的设计方式也直接影响了后续的 SQL 查询语句。

另外，你可以把 SQL 查询优化分成两个部分，逻辑查询优化和物理查询优化。

虽然 SQL 查询优化的技术有很多，但是大方向上完全可以分成逻辑查询优化和物理查询优化两大块。逻辑查询优化就是通过 SQL 等价变换提升查询效率，直白一点就是说，换一种查询写法执行效率可能更高。物理查询优化则是通过索引和表连接方式等技术来进行优化，这里重点需要掌握索引的使用。

最后，我们可以通过外援来增强数据库的性能。

单一的数据库总会遇到各种限制，不如取长补短，利用外援的方式。

另外通过对数据库进行垂直或者水平切分，突破单一数据库或数据表的访问限制，提升查询的性能。

本篇文章中涉及到的概念和知识点比较多，也有可能出现纰漏，不过没有关系，我会在在后续的文章中陆续进行讲解。希望这篇文章可以让你站在一个宏观的角度对数据库的调优有系统性的认知，对今后的工作有一些启发。

你不妨说一下，在日常的工作中你是如何发现数据库性能瓶颈的？又是怎么解决这个问题的？另外我在文章中从 6 个维度阐述了如何对数据库进行调优，前两个维度在于选择，中间两个维度在于 SQL 的查询优化，后两个维度在于外援技术。你可以说一说你对这些维度的理解吗？

欢迎你在评论区分享你的心得，也欢迎把这篇文章分享给你的朋友或者同事。

21丨范式设计：数据表的范式有哪些，3NF指的是什么？

在日常工作中，我们都需要遵守一定的规范，比如签到打卡、审批流程等，这些规范虽然有一定的约束感，却是非常有必要的，这样可以保证正确性和严谨性，但有些情况下，约束反而会带来效率的下降，比如一个可以直接操作的任务，却需要通过重重审批才能执行。

实际上，数据表的设计和工作流程的设计很像，我们既需要规范性，也要考虑到执行时的方便性。

今天，我来讲解一下数据表的设计范式。范式是数据表设计的基本原则，又很容易被忽略。很多时候，当数据库运行了一段时间之后，我们才发现数据表设计得有问题。重新调整数据表的结构，就需要做数据迁移，还有可能影响程序的业务逻辑，以及网站正常的访问。所以在开始设置数据库的时候，我们就需要重视数据表的设计。

今天的课程你需要掌握以下几个方面的内容：

数据库的设计范式都有哪些？

数据表的键都有哪些？

1NF、2NF 和 3NF 指的是什么？

数据库的设计范式都包括哪些

我们在设计关系型数据库模型的时候，需要对关系内部各个属性之间联系的合理化程度进行定义，这就有了不同等级的规范要求，这些规范要求被称为范式（NF）。你可以把范式理解为，一张数据表的设计结构需要满足的某种设计标准的级别。

目前关系型数据库一共有 6 种范式，按照范式级别，从低到高分别是：1NF（第一范式）、2NF（第二范式）、3NF（第三范式）、BCNF（巴斯 - 科德范式）、4NF（第四范式）和 5NF（第五范式，又叫做完美范式）。

数据库的范式设计越高阶，冗余度就越低，同时高阶的范式一定符合低阶范式的要求，比如满足 2NF 的一定满足 1NF，满足 3NF 的一定满足 2NF，依次类推。

你可能会问，这么多范式是不是都要掌握呢？

一般来说数据表的设计应尽量满足 3NF。但也不绝对，有时候为了提高某些查询性能，我们还需要破坏范式规则，也就是反规范化。

数据表中的那些键

范式的定义会使用到主键和候选键（因为主键和候选键可以唯一标识元组），数据库中的键（Key）由一个或者多个属性组成。我总结了下数据表中常用的几种键和属性的定义：

超键：能唯一标识元组的属性集叫做超键。

候选键：如果超键不包括多余的属性，那么这个超键就是候选键。

主键：用户可以从候选键中选择一个作为主键。

外键：如果数据表 R1 中的某属性集不是 R1 的主键，而是另一个数据表 R2 的主键，那么这个属性集就是数据表 R1 的外键。

主属性：包含在任一候选键中的属性称为主属性。

非主属性：与主属性相对，指的是不包含在任何一个候选键中的属性。

通常，我们也将候选键称之为“码”，把主键也称为“主码”。因为键可能是由多个属性组成的，针对单个属性，我们还可以用主属性和非主属性来进行区分。

看到上面的描述你可能还是有点懵，我举个简单的例子。

我们之前用过 NBA 的球员表（player）和球队表（team）。这里我可以把球员表定义为包含球员编号、姓名、身份证号、年龄和球队编号；球队表包含球队编号、主教练和球队所在地。

对于球员表来说，超键就是包括球员编号或者身份证号的任意组合，比如（球员编号）（球员编号，姓名）（身份证号，年龄）等。

候选键就是最小的超键，对于球员表来说，候选键就是（球员编号）或者（身份证号）。

主键是我们自己选定，也就是从候选键中选择一个，比如（球员编号）。

外键就是球员表中的球队编号。

在 player 表中，主属性是（球员编号）（身份证号），其他的属性（姓名）（年龄）（球队编号）都是非主属性。

从 1NF 到 3NF

了解了数据表中的 4 种键之后，我们再来看下 1NF、2NF 和 3NF，BCNF 我们放在后面讲。

1NF 指的是数据库表中的任何属性都是原子性的，不可再分。这很好理解，我们在设计某个字段的时候，对于字段 X 来说，就不能把字段 X 拆分成字段 X-1 和字段 X-2。事实上，任何的 DBMS 都会满足第一范式的要求，不会将字段进行拆分。

2NF 指的数据表里的非主属性都要和这个数据表的候选键有完全依赖关系。所谓完全依赖不同于部分依赖，也就是不能仅依赖候选键的一部分属性，而必须依赖全部属性。

这里我举一个没有满足 2NF 的例子，比如说我们设计一张球员比赛表 player_game，里面包含球员编号、姓名、年龄、比赛编号、比赛时间和比赛场地等属性，这里候选键和主键都为（球员编号，比赛编号），我们可以通过候选键来决定如下的关系：

(球员编号, 比赛编号) → (姓名, 年龄, 比赛时间, 比赛场地，得分)

上面这个关系说明球员编号和比赛编号的组合决定了球员的姓名、年龄、比赛时间、比赛地点和该比赛的得分数据。

但是这个数据表不满足第二范式，因为数据表中的字段之间还存在着如下的对应关系：

(球员编号) → (姓名，年龄)

(比赛编号) → (比赛时间, 比赛场地)

也就是说候选键中的某个字段决定了非主属性。你也可以理解为，对于非主属性来说，并非完全依赖候选键。这样会产生怎样的问题呢？

数据冗余：如果一个球员可以参加 m 场比赛，那么球员的姓名和年龄就重复了 m-1 次。一个比赛也可能会有 n 个球员参加，比赛的时间和地点就重复了 n-1 次。

插入异常：如果我们想要添加一场新的比赛，但是这时还没有确定参加的球员都有谁，那么就没法插入。

删除异常：如果我要删除某个球员编号，如果没有单独保存比赛表的话，就会同时把比赛信息删除掉。

更新异常：如果我们调整了某个比赛的时间，那么数据表中所有这个比赛的时间都需要进行调整，否则就会出现一场比赛时间不同的情况。

为了避免出现上述的情况，我们可以把球员比赛表设计为下面的三张表。

球员 player 表包含球员编号、姓名和年龄等属性；比赛 game 表包含比赛编号、比赛时间和比赛场地等属性；球员比赛关系 player_game 表包含球员编号、比赛编号和得分等属性。

这样的话，每张数据表都符合第二范式，也就避免了异常情况的发生。某种程度上 2NF 是对 1NF 原子性的升级。1NF 告诉我们字段属性需要是原子性的，而 2NF 告诉我们一张表就是一个独立的对象，也就是说一张表只表达一个意思。

3NF 在满足 2NF 的同时，对任何非主属性都不传递依赖于候选键。也就是说不能存在非主属性 A 依赖于非主属性 B，非主属性 B 依赖于候选键的情况。

我们用球员 player 表举例子，这张表包含的属性包括球员编号、姓名、球队名称和球队主教练。现在，我们把属性之间的依赖关系画出来，如下图所示：

你能看到球员编号决定了球队名称，同时球队名称决定了球队主教练，非主属性球队主教练就会传递依赖于球员编号，因此不符合 3NF 的要求。

如果要达到 3NF 的要求，需要把数据表拆成下面这样：

球员表的属性包括球员编号、姓名和球队名称；球队表的属性包括球队名称、球队主教练。

我再总结一下，1NF 需要保证表中每个属性都保持原子性；2NF 需要保证表中的非主属性与候选键完全依赖；3NF 需要保证表中的非主属性与候选键不存在传递依赖。

总结

我们今天讲解了数据表设计的三种范式。关系型数据库的设计都是基于关系模型的，在关系模型中存在着 4 种键，这些键的核心作用就是标识。

在这些概念的基础上，我又讲了 1NF，2NF 和 3NF。我们经常会与这三种范式打交道，利用它们建立冗余度小、结构合理的数据库。

有一点需要注意的是，这些范式只是提出了设计的标准，实际上设计数据表时，未必要符合这些原则。一方面是因为这些范式本身存在一些问题，可能会带来插入，更新，删除等的异常情况（这些会在下一讲举例说明），另一方面，它们也可能降低会查询的效率。这是为什么呢？因为范式等级越高，设计出来的数据表就越多，进行数据查询的时候就可能需要关联多张表，从而影响查询效率。

2NF 和 3NF 相对容易混淆，根据今天的内容，你能说下这两个范式之间的区别吗？另外，如果我们现在有一张学生选课表，包含的属性有学号、姓名、课程名称、分数、系别和系主任，如果要改成符合 3NF 要求的设计，需要怎么修改呢？

欢迎你在评论区写下你的答案，也欢迎把这篇文章分享给你的朋友或者同事，一起来交流。

22丨反范式设计：3NF有什么不足，为什么有时候需要反范式设计？

上一篇文章中，我们介绍了数据表设计的三种范式。作为数据库的设计人员，理解范式的设计以及反范式优化是非常有必要的。

为什么这么说呢？了解以下几个方面的内容之后你就明白了。

3NF 有什么不足？除了 3NF，我们为什么还需要 BCNF？

有了范式设计，为什么有时候需要进行反范式设计？

反范式设计适用的场景是什么？又可能存在哪些问题？

BCNF（巴斯范式）

如果数据表的关系模式符合 3NF 的要求，就不存在问题了吗？我们来看下这张仓库管理关系 warehouse_keeper 表：

在这个数据表中，一个仓库只有一个管理员，同时一个管理员也只管理一个仓库。我们先来梳理下这些属性之间的依赖关系。

仓库名决定了管理员，管理员也决定了仓库名，同时（仓库名，物品名）的属性集合可以决定数量这个属性。

这样，我们就可以找到数据表的候选键是（管理员，物品名）和（仓库名，物品名），

然后我们从候选键中选择一个作为主键，比如（仓库名，物品名）。

在这里，主属性是包含在任一候选键中的属性，也就是仓库名，管理员和物品名。非主属性是数量这个属性。

如何判断一张表的范式呢？我们需要根据范式的等级，从低到高来进行判断。

首先，数据表每个属性都是原子性的，符合 1NF 的要求；其次，数据表中非主属性”数量“都与候选键全部依赖，（仓库名，物品名）决定数量，（管理员，物品名）决定数量，因此，数据表符合 2NF 的要求；最后，数据表中的非主属性，不传递依赖于候选键。因此符合 3NF 的要求。

既然数据表已经符合了 3NF 的要求，是不是就不存在问题了呢？我们来看下下面的情况：

增加一个仓库，但是还没有存放任何物品。根据数据表实体完整性的要求，主键不能有空值，因此会出现插入异常；

如果仓库更换了管理员，我们就可能会修改数据表中的多条记录；

如果仓库里的商品都卖空了，那么此时仓库名称和相应的管理员名称也会随之被删除。

你能看到，即便数据表符合 3NF 的要求，同样可能存在插入，更新和删除数据的异常情况。

这种情况下该怎么解决呢？

首先我们需要确认造成异常的原因：主属性仓库名对于候选键（管理员，物品名）是部分依赖的关系，这样就有可能导致上面的异常情况。人们在 3NF 的基础上进行了改进，提出了BCNF，也叫做巴斯 - 科德范式，它在 3NF 的基础上消除了主属性对候选键的部分依赖或者传递依赖关系。

根据 BCNF 的要求，我们需要把仓库管理关系 warehouse_keeper 表拆分成下面这样：

仓库表：（仓库名，管理员）

库存表：（仓库名，物品名，数量）

这样就不存在主属性对于候选键的部分依赖或传递依赖，上面数据表的设计就符合 BCNF。

反范式设计

尽管围绕着数据表的设计有很多范式，但事实上，我们在设计数据表的时候却不一定要参照这些标准。

我们在之前已经了解了越高阶的范式得到的数据表越多，数据冗余度越低。但有时候，我们在设计数据表的时候，还需要为了性能和读取效率违反范式化的原则。反范式就是相对范式化而言的，换句话说，就是允许少量的冗余，通过空间来换时间。

如果我们想对查询效率进行优化，有时候反范式优化也是一种优化思路。

比如我们想要查询某个商品的前 1000 条评论，会涉及到两张表。

商品评论表 product_comment，对应的字段名称及含义如下：

用户表 user，对应的字段名称及含义如下：

下面，我们就用这两张表模拟一下反范式优化。

实验数据：模拟两张百万量级的数据表

为了更好地进行 SQL 优化实验，我们需要给用户表和商品评论表随机模拟出百万量级的数据。我们可以通过存储过程来实现模拟数据。

下面是给用户表随机生成 100 万用户的代码：

CREATE DEFINER=`root`@`localhost` PROCEDURE `insert_many_user`(IN start INT(10), IN max_num INT(10))

BEGIN

DECLARE i INT DEFAULT 0;

DECLARE date_start DATETIME DEFAULT ('2017-01-01 00:00:00');

DECLARE date_temp DATETIME;

SET date_temp = date_start;

SET autocommit=0;

REPEAT

SET i=i+1;

SET date_temp = date_add(date_temp, interval RAND()*60 second);

INSERT INTO user(user_id, user_name, create_time)

VALUES((start+i), CONCAT('user_',i), date_temp);

UNTIL i = max_num

END REPEAT;

COMMIT;

END

我用 date_start 变量来定义初始的注册时间，时间为 2017 年 1 月 1 日 0 点 0 分 0 秒，然后用 date_temp 变量计算每个用户的注册时间，新的注册用户与上一个用户注册的时间间隔为 60 秒内的随机值。然后使用 REPEAT … UNTIL … END REPEAT 循环，对 max_num 个用户的数据进行计算。在循环前，我们将 autocommit 设置为 0，这样等计算完成再统一插入，执行效率更高。

然后我们来运行 call insert_many_user(10000, 1000000); 调用存储过程。这里需要通过 start 和 max_num 两个参数对初始的 user_id 和要创建的用户数量进行设置。运行结果：

你能看到在 MySQL 里，创建 100 万的用户数据用时 1 分 37 秒。

接着我们再来给商品评论表 product_comment 随机生成 100 万条商品评论。这里我们设置为给某一款商品评论，比如 product_id=10001。评论的内容为随机的 20 个字母。以下是创建随机的 100 万条商品评论的存储过程：

CREATE DEFINER=`root`@`localhost` PROCEDURE `insert_many_product_comments`(IN START INT(10), IN max_num INT(10))

BEGIN

DECLARE i INT DEFAULT 0;

DECLARE date_start DATETIME DEFAULT ('2018-01-01 00:00:00');

DECLARE date_temp DATETIME;

DECLARE comment_text VARCHAR(25);

DECLARE user_id INT;

SET date_temp = date_start;

SET autocommit=0;

REPEAT

SET i=i+1;

SET date_temp = date_add(date_temp, INTERVAL RAND()*60 SECOND);

SET comment_text = substr(MD5(RAND()),1, 20);

SET user_id = FLOOR(RAND()*1000000);

INSERT INTO product_comment(comment_id, product_id, comment_text, comment_time, user_id)

VALUES((START+i), 10001, comment_text, date_temp, user_id);

UNTIL i = max_num

END REPEAT;

COMMIT;

END

同样的，我用 date_start 变量来定义初始的评论时间。这里新的评论时间与上一个评论的时间间隔还是 60 秒内的随机值，商品评论表中的 user_id 为随机值。我们使用 REPEAT … UNTIL … END REPEAT 循环，来对 max_num 个商品评论的数据进行计算。

然后调用存储过程，运行结果如下：

MySQL 一共花了 2 分 7 秒完成了商品评论数据的创建。

反范式优化实验对比

如果我们想要查询某个商品 ID，比如 10001 的前 1000 条评论，需要写成下面这样：

SELECT p.comment_text, p.comment_time, u.user_name FROM product_comment AS p

LEFT JOIN user AS u

ON p.user_id = u.user_id

WHERE p.product_id = 10001

ORDER BY p.comment_id DESC LIMIT 1000

运行结果（1000 条数据行）：

运行时长为 0.395 秒，查询效率并不高。

这是因为在实际生活中，我们在显示商品评论的时候，通常会显示这个用户的昵称，而不是用户 ID，因此我们还需要关联 product_comment 和 user 这两张表来进行查询。当表数据量不大的时候，查询效率还好，但如果表数据量都超过了百万量级，查询效率就会变低。这是因为查询会在 product_comment 表和 user 表这两个表上进行聚集索引扫描，然后再嵌套循环，这样一来查询所耗费的时间就有几百毫秒甚至更多。对于网站的响应来说，这已经很慢了，用户体验会非常差。

如果我们想要提升查询的效率，可以允许适当的数据冗余，也就是在商品评论表中增加用户昵称字段，在 product_comment 数据表的基础上增加 user_name 字段，就得到了 product_comment2 数据表。

你可以在百度网盘中下载这三张数据表 product_comment、product_comment2 和 user 表，密码为 n3l8。

这样一来，只需单表查询就可以得到数据集结果：

SELECT comment_text, comment_time, user_name FROM product_comment2 WHERE product_id = 10001 ORDER BY comment_id DESC LIMIT 1000

运行结果（1000 条数据）：

优化之后只需要扫描一次聚集索引即可，运行时间为 0.039 秒，查询时间是之前的 1/10。 你能看到，在数据量大的情况下，查询效率会有显著的提升。

反范式存在的问题 & 适用场景

从上面的例子中可以看出，反范式可以通过空间换时间，提升查询的效率，但是反范式也会带来一些新问题。

在数据量小的情况下，反范式不能体现性能的优势，可能还会让数据库的设计更加复杂。比如采用存储过程来支持数据的更新、删除等额外操作，很容易增加系统的维护成本。

比如用户每次更改昵称的时候，都需要执行存储过程来更新，如果昵称更改频繁，会非常消耗系统资源。

那么反范式优化适用于哪些场景呢？

在现实生活中，我们经常需要一些冗余信息，比如订单中的收货人信息，包括姓名、电话和地址等。每次发生的订单收货信息都属于历史快照，需要进行保存，但用户可以随时修改自己的信息，这时保存这些冗余信息是非常有必要的。

当冗余信息有价值或者能大幅度提高查询效率的时候，我们就可以采取反范式的优化。

此外反范式优化也常用在数据仓库的设计中，因为数据仓库通常存储历史数据，对增删改的实时性要求不强，对历史数据的分析需求强。这时适当允许数据的冗余度，更方便进行数据分析。

我简单总结下数据仓库和数据库在使用上的区别：

数据库设计的目的在于捕获数据，而数据仓库设计的目的在于分析数据；

数据库对数据的增删改实时性要求强，需要存储在线的用户数据，而数据仓库存储的一般是历史数据；

数据库设计需要尽量避免冗余，但为了提高查询效率也允许一定的冗余度，而数据仓库在设计上更偏向采用反范式设计。

总结

今天我们讲了 BCNF，它是基于 3NF 进行的改进。你能看到设计范式越高阶，数据表就会越精细，数据的冗余度也就越少，在一定程度上可以让数据库在内部关联上更好地组织数据。但有时候我们也需要采用反范进行优化，通过空间来换取时间。

范式本身没有优劣之分，只有适用场景不同。没有完美的设计，只有合适的设计，我们在数据表的设计中，还需要根据需求将范式和反范式混合使用。

我们今天举了一个反范式设计的例子，你在工作中是否有做过反范式设计的例子？欢迎你在评论区与我们一起分享，也欢迎把这篇文章分享给你的朋友或者同事，一起交流一下。

23丨索引的概览：用还是不用索引，这是一个问题

提起优化 SQL，你可能会把它理解为优化索引。简单来说这也不算错，索引在 SQL 优化中占了很大的比重。索引用得好，可以将 SQL 查询的效率提升 10 倍甚至更多。但索引是万能的吗？既然索引可以提升效率，只要创建索引不就好了吗？实际上，在有些情况下，创建索引反而会降低效率。

今天我们就来讲一下索引，索引涉及到的内容比较多，今天先来对索引有个整体的认知。

什么情况下创建索引，什么时候不需要索引？

索引的种类有哪些？

索引的原理很好理解，在今天的内容里，我依然会通过 SQL 查询实验验证今天的内容，帮你进一步加深理解。

索引是万能的吗？

首先我们需要了解什么是索引（Index）。数据库中的索引，就好比一本书的目录，它可以帮我们快速进行特定值的定位与查找，从而加快数据查询的效率。

索引就是帮助数据库管理系统高效获取数据的数据结构。

如果我们不使用索引，就必须从第 1 条记录开始扫描，直到把所有的数据表都扫描完，才能找到想要的数据。既然如此，如果我们想要快速查找数据，就只需要创建更多的索引就好了呢？

其实索引不是万能的，在有些情况下使用索引反而会让效率变低。

索引的价值是帮我们从海量数据中找到想要的数据，如果数据量少，那么是否使用索引对结果的影响并不大。

在数据表中的数据行数比较少的情况下，比如不到 1000 行，是不需要创建索引的。另外，当数据重复度大，比如高于 10% 的时候，也不需要对这个字段使用索引。我之前讲到过，如果是性别这个字段，就不需要对它创建索引。这是为什么呢？如果你想要在 100 万行数据中查找其中的 50 万行（比如性别为男的数据），一旦创建了索引，你需要先访问 50 万次索引，然后再访问 50 万次数据表，这样加起来的开销比不使用索引可能还要大。

当然，空口无凭，我们来做两个实验，更直观地了解索引。

实验 1：数据行数少的情况下，索引效率如何

我在百度网盘上提供了数据表，heros_without_index.sql 和 heros_with_index.sql，提取码为 wxho。

在第一个数据表中，除了自增的 id 以外没有建立额外的索引。第二张数据表中，我对 name 字段建立了唯一索引。

heros 数据表一共有 69 个英雄，数据量很少。当我们对 name 进行条件查询的时候，我们观察一下创建索引前后的效率。

SELECT id, name, hp_max, mp_max FROM heros_without_index WHERE name = '刘禅'

运行结果（1 条数据，运行时间 0.072s）：

我对 name 字段建立索引后，再进行查询：

SELECT id, name, hp_max, mp_max FROM heros_with_index WHERE name = '刘禅'

运行结果（1 条数据，运行时间 0.080s）：

你能看到运行结果相同，但是创建了 name 字段索引的效率比没有创建索引时效率更低。在数据量不大的情况下，索引就发挥不出作用了。

实验 2：性别（男或女）字段真的不应该创建索引吗？

如果一个字段的取值少，比如性别这个字段，通常是不需要创建索引的。那么有没有特殊的情况呢？

下面我们来看一个例子，假设有一个女儿国，人口总数为 100 万人，男性只有 10 个人，也就是占总人口的 10 万分之 1。

女儿国的人口数据表 user_gender 见百度网盘中的 user_gender.sql。其中数据表中的 user_gender 字段取值为 0 或 1，0 代表女性，1 代表男性。

如果我们要筛选出这个国家中的男性，可以使用：

SELECT * FROM user_gender WHERE user_gender = 1

运行结果（10 条数据，运行时间 0.696s）：

你能看到在未创建索引的情况下，运行的效率并不高。如果我们针对 user_gender 字段创建索引呢？

SELECT * FROM user_gender WHERE user_gender = 1

同样是 10 条数据，运行结果相同，时间却缩短到了 0.052s，大幅提升了查询的效率。

其实通过这两个实验你也能看出来，索引的价值是帮你快速定位。如果想要定位的数据有很多，那么索引就失去了它的使用价值，比如通常情况下的性别字段。不过有时候，我们还要考虑这个字段中的数值分布的情况，在实验 2 中，性别字段的数值分布非常特殊，男性的比例非常少。

我们不仅要看字段中的数值个数，还要根据数值的分布情况来考虑是否需要创建索引。

索引的种类有哪些？

虽然使用索引的本质目的是帮我们快速定位想要查找的数据，但实际上，索引有很多种类。

从功能逻辑上说，索引主要有 4 种，分别是普通索引、唯一索引、主键索引和全文索引。

普通索引是基础的索引，没有任何约束，主要用于提高查询效率。唯一索引就是在普通索引的基础上增加了数据唯一性的约束，在一张数据表里可以有多个唯一索引。主键索引在唯一索引的基础上增加了不为空的约束，也就是 NOT NULL+UNIQUE，一张表里最多只有一个主键索引。全文索引用的不多，MySQL 自带的全文索引只支持英文。我们通常可以采用专门的全文搜索引擎，比如 ES(ElasticSearch) 和 Solr。

其实前三种索引（普通索引、唯一索引和主键索引）都是一类索引，只不过对数据的约束性逐渐提升。在一张数据表中只能有一个主键索引，这是由主键索引的物理实现方式决定的，因为数据存储在文件中只能按照一种顺序进行存储。但可以有多个普通索引或者多个唯一索引。

按照物理实现方式，索引可以分为 2 种：聚集索引和非聚集索引。我们也把非聚集索引称为二级索引或者辅助索引。

聚集索引可以按照主键来排序存储数据，这样在查找行的时候非常有效。举个例子，如果是一本汉语字典，我们想要查找“数”这个字，直接在书中找汉语拼音的位置即可，也就是拼音“shu”。这样找到了索引的位置，在它后面就是我们想要找的数据行。

非聚集索引又是什么呢？

在数据库系统会有单独的存储空间存放非聚集索引，这些索引项是按照顺序存储的，但索引项指向的内容是随机存储的。也就是说系统会进行两次查找，第一次先找到索引，第二次找到索引对应的位置取出数据行。非聚集索引不会把索引指向的内容像聚集索引一样直接放到索引的后面，而是维护单独的索引表（只维护索引，不维护索引指向的数据），为数据检索提供方便。我们还以汉语字典为例，如果想要查找“数”字，那么按照部首查找的方式，先找到“数”字的偏旁部首，然后这个目录会告诉我们“数”字存放到第多少页，我们再去指定的页码找这个字。

聚集索引指表中数据行按索引的排序方式进行存储，对查找行很有效。只有当表包含聚集索引时，表内的数据行才会按找索引列的值在磁盘上进行物理排序和存储。每一个表只能有一个聚集索引，因为数据行本身只能按一个顺序存储。

聚集索引与非聚集索引的原理不同，在使用上也有一些区别：

聚集索引的叶子节点存储的就是我们的数据记录，非聚集索引的叶子节点存储的是数据位置。非聚集索引不会影响数据表的物理存储顺序。

一个表只能有一个聚集索引，因为只能有一种排序存储的方式，但可以有多个非聚集索引，也就是多个索引目录提供数据检索。

使用聚集索引的时候，数据的查询效率高，但如果对数据进行插入，删除，更新等操作，效率会比非聚集索引低。

实验 3：使用聚集索引和非聚集索引的查询效率

还是针对刚才的 user_gender 数据表，我们来看下使用聚集索引和非聚集索引的查询效率有什么区别。在 user_gender 表中，我设置了 user_id 为主键，也就是聚集索引的字段是 user_id。这里我们查询下 user_id=90001 的用户信息：

SELECT user_id, user_name, user_gender FROM user_gender WHERE user_id = 900001

运行结果（1 条数据，运行时间 0.043s）：

我们再直接对 user_name 字段进行条件查询，此时 user_name 字段没有创建索引：

SELECT user_id, user_name, user_gender FROM user_gender WHERE user_name = 'student_890001'

运行结果（1 条数据，运行时间 0.961s）：

你能看出对没有建立索引的字段进行条件查询，查询效率明显降低了。

然后我们对 user_name 字段创建普通索引，进行 SQL 查询：

SELECT user_id, user_name, user_gender FROM user_gender WHERE user_name = 'student_890001'

运行结果（1 条数据，运行时间 0.050s）：

通过对这 3 次 SQL 查询结果的对比，我们可以总结出以下两点内容：

对 WHERE 子句的字段建立索引，可以大幅提升查询效率。

采用聚集索引进行数据查询，比使用非聚集索引的查询效率略高。如果查询次数比较多，还是尽量使用主键索引进行数据查询。

除了业务逻辑和物理实现方式，索引还可以按照字段个数进行划分，分成单一索引和联合索引。

索引列为一列时为单一索引；多个列组合在一起创建的索引叫做联合索引。

创建联合索引时，我们需要注意创建时的顺序问题，因为联合索引 (x, y, z) 和 (z, y, x) 在使用的时候效率可能会存在差别。

这里需要说明的是联合索引存在最左匹配原则，也就是按照最左优先的方式进行索引的匹配。比如刚才举例的 (x, y, z)，如果查询条件是 WHERE x=1 AND y=2 AND z=3，就可以匹配上联合索引；如果查询条件是 WHERE y=2，就无法匹配上联合索引。

实验 4：联合索引的最左原则

还是针对 user_gender 数据表，我们把 user_id 和 user_name 字段设置为联合主键，然后看下 SQL 查询效率有什么区别。

SELECT user_id, user_name, user_gender FROM user_gender WHERE user_id = 900001 AND user_name = 'student_890001'

运行结果（1 条数据，运行时间 0.046s）：

SELECT user_id, user_name, user_gender FROM user_gender WHERE user_id = 900001

运行结果（1 条数据，运行时间 0.046s）：

我们再来看下普通的条件查询是什么样子的：

SELECT user_id, user_name, user_gender FROM user_gender WHERE user_name = 'student_890001'

运行结果（1 条数据，运行时间 0.943s）：

你能看到当我们使用了联合索引 (user_id, user_name) 的时候，在 WHERE 子句中对联合索引中的字段 user_id 和 user_name 进行条件查询，或者只对 user_id 进行查询，效率基本上是一样的。当我们对 user_name 进行条件查询时，效率就会降低很多，这是因为根据联合索引的最左原则，user_id 在 user_name 的左侧，如果没有使用 user_id，而是直接使用 user_name 进行条件查询，联合索引就会失效。

总结

使用索引可以帮助我们从海量的数据中快速定位想要查找的数据，不过索引也存在一些不足，比如占用存储空间、降低数据库写操作的性能等，如果有多个索引还会增加索引选择的时间。当我们使用索引时，需要平衡索引的利（提升查询效率）和弊（维护索引所需的代价）。

在实际工作中，我们还需要基于需求和数据本身的分布情况来确定是否使用索引，尽管索引不是万能的，但数据量大的时候不使用索引是不可想象的，毕竟索引的本质，是帮助我们提升数据检索的效率。

今天的内容到这里就结束了，给你留个问题。关于联合索引的最左原则指的是什么？在使用联合索引时，有哪些需要注意的地方呢？

欢迎你在评论区写下你的答案，也欢迎把这篇文章分享给你的朋友或者同事，一起交流一下。

24丨索引的原理：我们为什么用B+树来做索引？

上节课我讲到了索引的作用，是否需要建立索引，以及建立什么样的索引，需要我们根据实际情况进行选择。我之前说过，索引其实就是一种数据结构，那么今天我们就来看下，索引的数据结构究竟是怎样的？对索引底层的数据结构有了更深入的了解后，就会更了解索引的使用原则。

今天的文章内容主要包括下面几个部分：

为什么索引要存放到硬盘上？如何评价索引的数据结构设计的好坏？

使用平衡二叉树作为索引的数据结构有哪些不足？

B 树和 B+ 树的结构是怎样的？为什么我们常用 B+ 树作为索引的数据结构？

如何评价索引的数据结构设计好坏

数据库服务器有两种存储介质，分别为硬盘和内存。内存属于临时存储，容量有限，而且当发生意外时（比如断电或者发生故障重启）会造成数据丢失；硬盘相当于永久存储介质，这也是为什么我们需要把数据保存到硬盘上。

虽然内存的读取速度很快，但我们还是需要将索引存放到硬盘上，这样的话，当我们在硬盘上进行查询时，也就产生了硬盘的 I/O 操作。相比于内存的存取来说，硬盘的 I/O 存取消耗的时间要高很多。我们通过索引来查找某行数据的时候，需要计算产生的磁盘 I/O 次数，当磁盘 I/O 次数越多，所消耗的时间也就越大。如果我们能让索引的数据结构尽量减少硬盘的 I/O 操作，所消耗的时间也就越小。

二叉树的局限性

二分查找法是一种高效的数据检索方式，时间复杂度为 O(log2n)，是不是采用二叉树就适合作为索引的数据结构呢？

我们先来看下最基础的二叉搜索树（Binary Search Tree），搜索某个节点和插入节点的规则一样，我们假设搜索插入的数值为 key：

如果 key 大于根节点，则在右子树中进行查找；

如果 key 小于根节点，则在左子树中进行查找；

如果 key 等于根节点，也就是找到了这个节点，返回根节点即可。

举个例子，我们对数列（34，22，89，5，23，77，91）创造出来的二分查找树如下图所示：

但是存在特殊的情况，就是有时候二叉树的深度非常大。比如我们给出的数据顺序是 (5, 22, 23, 34, 77, 89, 91)，创造出来的二分搜索树如下图所示：

你能看出来第一个树的深度是 3，也就是说最多只需 3 次比较，就可以找到节点，而第二个树的深度是 7，最多需要 7 次比较才能找到节点。

第二棵树也属于二分查找树，但是性能上已经退化成了一条链表，查找数据的时间复杂度变成了 O(n)。为了解决这个问题，人们提出了平衡二叉搜索树（AVL 树），它在二分搜索树的基础上增加了约束，每个节点的左子树和右子树的高度差不能超过 1，也就是说节点的左子树和右子树仍然为平衡二叉树。

这里说一下，常见的平衡二叉树有很多种，包括了平衡二叉搜索树、红黑树、数堆、伸展树。平衡二叉搜索树是最早提出来的自平衡二叉搜索树，当我们提到平衡二叉树时一般指的就是平衡二叉搜索树。事实上，第一棵树就属于平衡二叉搜索树，搜索时间复杂度就是 O(log2n)。

我刚才提到过，数据查询的时间主要依赖于磁盘 I/O 的次数，如果我们采用二叉树的形式，即使通过平衡二叉搜索树进行了改进，树的深度也是 O(log2n)，当 n 比较大时，深度也是比较高的，比如下图的情况：

每访问一次节点就需要进行一次磁盘 I/O 操作，对于上面的树来说，我们需要进行 5 次 I/O 操作。虽然平衡二叉树比较的效率高，但是树的深度也同样高，这就意味着磁盘 I/O 操作次数多，会影响整体数据查询的效率。

针对同样的数据，如果我们把二叉树改成 M 叉树（M>2）呢？当 M=3 时，同样的 31 个节点可以由下面的三叉树来进行存储：

你能看到此时树的高度降低了，当数据量 N 大的时候，以及树的分叉数 M 大的时候，M 叉树的高度会远小于二叉树的高度。

什么是 B 树

如果用二叉树作为索引的实现结构，会让树变得很高，增加硬盘的 I/O 次数，影响数据查询的时间。因此一个节点就不能只有 2 个子节点，而应该允许有 M 个子节点 (M>2)。

B 树的出现就是为了解决这个问题，B 树的英文是 Balance Tree，也就是平衡的多路搜索树，它的高度远小于平衡二叉树的高度。在文件系统和数据库系统中的索引结构经常采用 B 树来实现。

B 树的结构如下图所示：

B 树作为平衡的多路搜索树，它的每一个节点最多可以包括 M 个子节点，M 称为 B 树的阶。同时你能看到，每个磁盘块中包括了关键字和子节点的指针。如果一个磁盘块中包括了 x 个关键字，那么指针数就是 x+1。对于一个 100 阶的 B 树来说，如果有 3 层的话最多可以存储约 100 万的索引数据。对于大量的索引数据来说，采用 B 树的结构是非常适合的，因为树的高度要远小于二叉树的高度。

一个 M 阶的 B 树（M>2）有以下的特性：

根节点的儿子数的范围是 [2,M]。

每个中间节点包含 k-1 个关键字和 k 个孩子，孩子的数量 = 关键字的数量 +1，k 的取值范围为 [ceil(M/2), M]。

叶子节点包括 k-1 个关键字（叶子节点没有孩子），k 的取值范围为 [ceil(M/2), M]。

假设中间节点节点的关键字为：Key[1], Key[2], …, Key[k-1]，且关键字按照升序排序，即 Key[i]<Key[i+1]。此时 k-1 个关键字相当于划分了 k 个范围，也就是对应着 k 个指针，即为：P[1], P[2], …, P[k]，其中 P[1] 指向关键字小于 Key[1] 的子树，P[i] 指向关键字属于 (Key[i-1], Key[i]) 的子树，P[k] 指向关键字大于 Key[k-1] 的子树。

所有叶子节点位于同一层。

上面那张图所表示的 B 树就是一棵 3 阶的 B 树。我们可以看下磁盘块 2，里面的关键字为（8，12），它有 3 个孩子 (3，5)，(9，10) 和 (13，15)，你能看到 (3，5) 小于 8，(9，10) 在 8 和 12 之间，而 (13，15) 大于 12，刚好符合刚才我们给出的特征。

然后我们来看下如何用 B 树进行查找。假设我们想要查找的关键字是 9，那么步骤可以分为以下几步：

我们与根节点的关键字 (17，35）进行比较，9 小于 17 那么得到指针 P1；

按照指针 P1 找到磁盘块 2，关键字为（8，12），因为 9 在 8 和 12 之间，所以我们得到指针 P2；

按照指针 P2 找到磁盘块 6，关键字为（9，10），然后我们找到了关键字 9。

你能看出来在 B 树的搜索过程中，我们比较的次数并不少，但如果把数据读取出来然后在内存中进行比较，这个时间就是可以忽略不计的。而读取磁盘块本身需要进行 I/O 操作，消耗的时间比在内存中进行比较所需要的时间要多，是数据查找用时的重要因素，B 树相比于平衡二叉树来说磁盘 I/O 操作要少，在数据查询中比平衡二叉树效率要高。

什么是 B+ 树

B+ 树基于 B 树做出了改进，主流的 DBMS 都支持 B+ 树的索引方式，比如 MySQL。B+ 树和 B 树的差异在于以下几点：

有 k 个孩子的节点就有 k 个关键字。也就是孩子数量 = 关键字数，而 B 树中，孩子数量 = 关键字数 +1。

非叶子节点的关键字也会同时存在在子节点中，并且是在子节点中所有关键字的最大（或最小）。

非叶子节点仅用于索引，不保存数据记录，跟记录有关的信息都放在叶子节点中。而 B 树中，非叶子节点既保存索引，也保存数据记录。

所有关键字都在叶子节点出现，叶子节点构成一个有序链表，而且叶子节点本身按照关键字的大小从小到大顺序链接。

下图就是一棵 B+ 树，阶数为 3，根节点中的关键字 1、18、35 分别是子节点（1，8，14），（18，24，31）和（35，41，53）中的最小值。每一层父节点的关键字都会出现在下一层的子节点的关键字中，因此在叶子节点中包括了所有的关键字信息，并且每一个叶子节点都有一个指向下一个节点的指针，这样就形成了一个链表。

比如，我们想要查找关键字 16，B+ 树会自顶向下逐层进行查找：

与根节点的关键字 (1，18，35) 进行比较，16 在 1 和 18 之间，得到指针 P1（指向磁盘块 2）

找到磁盘块 2，关键字为（1，8，14），因为 16 大于 14，所以得到指针 P3（指向磁盘块 7）

找到磁盘块 7，关键字为（14，16，17），然后我们找到了关键字 16，所以可以找到关键字 16 所对应的数据。

整个过程一共进行了 3 次 I/O 操作，看起来 B+ 树和 B 树的查询过程差不多，但是 B+ 树和 B 树有个根本的差异在于，B+ 树的中间节点并不直接存储数据。这样的好处都有什么呢？

首先，B+ 树查询效率更稳定。因为 B+ 树每次只有访问到叶子节点才能找到对应的数据，而在 B 树中，非叶子节点也会存储数据，这样就会造成查询效率不稳定的情况，有时候访问到了非叶子节点就可以找到关键字，而有时需要访问到叶子节点才能找到关键字。

其次，B+ 树的查询效率更高，这是因为通常 B+ 树比 B 树更矮胖（阶数更大，深度更低），查询所需要的磁盘 I/O 也会更少。同样的磁盘页大小，B+ 树可以存储更多的节点关键字。

不仅是对单个关键字的查询上，在查询范围上，B+ 树的效率也比 B 树高。这是因为所有关键字都出现在 B+ 树的叶子节点中，并通过有序链表进行了链接。而在 B 树中则需要通过中序遍历才能完成查询范围的查找，效率要低很多。

总结

磁盘的 I/O 操作次数对索引的使用效率至关重要。虽然传统的二叉树数据结构查找数据的效率高，但很容易增加磁盘 I/O 操作的次数，影响索引使用的效率。因此在构造索引的时候，我们更倾向于采用“矮胖”的数据结构。

B 树和 B+ 树都可以作为索引的数据结构，在 MySQL 中采用的是 B+ 树，B+ 树在查询性能上更稳定，在磁盘页大小相同的情况下，树的构造更加矮胖，所需要进行的磁盘 I/O 次数更少，更适合进行关键字的范围查询。

今天我们对索引的底层数据结构进行了学习，你能说下为什么数据库索引采用 B+ 树，而不是平衡二叉搜索树吗？另外，B+ 树和 B 树在构造和查询性能上有什么差异呢？

欢迎你在评论区写下你的思考，也欢迎把这篇文章分享给你的朋友或者同事，一起来交流。

25丨Hash索引的底层原理是什么？

我们上节课讲解了 B+ 树的原理，今天我们来学习下 Hash 的原理和使用。Hash 本身是一个函数，又被称为散列函数，它可以帮助我们大幅提升检索数据的效率。打个比方，Hash 就好像一个智能前台，你只要告诉它想要查找的人的姓名，它就会告诉你那个人坐在哪个位置，只需要一次交互就可以完成查找，效率非常高。大名鼎鼎的 MD5 就是 Hash 函数的一种。

Hash 算法是通过某种确定性的算法（比如 MD5、SHA1、SHA2、SHA3）将输入转变为输出。相同的输入永远可以得到相同的输出，假设输入内容有微小偏差，在输出中通常会有不同的结果。如果你想要验证两个文件是否相同，那么你不需要把两份文件直接拿来比对，只需要让对方把 Hash 函数计算得到的结果告诉你即可，然后在本地同样对文件进行 Hash 函数的运算，最后通过比较这两个 Hash 函数的结果是否相同，就可以知道这两个文件是否相同。

Hash 可以高效地帮我们完成验证的工作，它在数据库中有广泛的应用。今天的课程主要包括下面几个部分：

动手写程序统计一下 Hash 检索的效率。

了解 MySQL 中的 Hash 索引，理解使用它的优点和不足。

Hash 索引和 B+ 树索引的区别以及使用场景。

动手统计 Hash 检索效率

我们知道 Python 的数据结构中有数组和字典两种，其中数组检索数据类似于全表扫描，需要对整个数组的内容进行检索；而字典是由 Hash 表实现的，存储的是 key-value 值，对于数据检索来说效率非常快。

对于 Hash 的检索效率，我们来个更直观的认知。下面我们分别看一下采用数组检索数据和采用字典（Hash）检索数据的效率到底有怎样的差别。

实验 1：在数组中添加 10000 个元素，然后分别对这 10000 个元素进行检索，最后统计检索的时间。

代码如下：

import time

# 插入数据

result = []

for i in range(10000):

       result.append(i)

# 检索数据

time_start=time.time()

for i in range(10000):

       temp = result.index(i)

time_end=time.time()

print('检索时间', time_end-time_start)

运行结果：

检索时间为 1.2436728477478027 秒

实验 2：采用 Hash 表的形式存储数据，即在 Python 中采用字典方式添加 10000 个元素，然后检索这 10000 个数据，最后再统计一下时间。代码如下：

import time

# 插入数据

result = {}

for i in range(1000000):

       result[i] = i

# 检索数据

time_start=time.time()

for i in range(10000):

       temp = result[i]

time_end=time.time()

print('检索时间：',time_end-time_start)

运行结果：

检索时间为 0.0019941329956054688 秒。

你能看到 Hash 方式检索差不多用了 2 毫秒的时间，检索效率提升得非常明显。这是因为 Hash 只需要一步就可以找到对应的取值，算法复杂度为 O(1)，而数组检索数据的算法复杂度为 O(n)。

MySQL 中的 Hash 索引

采用 Hash 进行检索效率非常高，基本上一次检索就可以找到数据，而 B+ 树需要自顶向下依次查找，多次访问节点才能找到数据，中间需要多次 I/O 操作，从效率来说 Hash 比 B+ 树更快。

我们来看下 Hash 索引的示意图：

键值 key 通过 Hash 映射找到桶 bucket。在这里桶（bucket）指的是一个能存储一条或多条记录的存储单位。一个桶的结构包含了一个内存指针数组，桶中的每行数据都会指向下一行，形成链表结构，当遇到 Hash 冲突时，会在桶中进行键值的查找。

那么什么是 Hash 冲突呢？

如果桶的空间小于输入的空间，不同的输入可能会映射到同一个桶中，这时就会产生 Hash 冲突，如果 Hash 冲突的量很大，就会影响读取的性能。

通常 Hash 值的字节数比较少，简单的 4 个字节就够了。在 Hash 值相同的情况下，就会进一步比较桶（Bucket）中的键值，从而找到最终的数据行。

Hash 值的字节数多的话可以是 16 位、32 位等，比如采用 MD5 函数就可以得到一个 16 位或者 32 位的数值，32 位的 MD5 已经足够安全，重复率非常低。

我们模拟一下 Hash 索引。关键字如下所示，每个字母的内部编码为字母的序号，比如 A 为 01，Y 为 25。我们统计内部编码平方的第 8-11 位（从前向后）作为 Hash 值：

Hash 索引与 B+ 树索引的区别

我们之前讲到过 B+ 树索引的结构，Hash 索引结构和 B+ 树的不同，因此在索引使用上也会有差别。

Hash 索引不能进行范围查询，而 B+ 树可以。这是因为 Hash 索引指向的数据是无序的，而 B+ 树的叶子节点是个有序的链表。

Hash 索引不支持联合索引的最左侧原则（即联合索引的部分索引无法使用），而 B+ 树可以。对于联合索引来说，Hash 索引在计算 Hash 值的时候是将索引键合并后再一起计算 Hash 值，所以不会针对每个索引单独计算 Hash 值。因此如果用到联合索引的一个或者几个索引时，联合索引无法被利用。

Hash 索引不支持 ORDER BY 排序，因为 Hash 索引指向的数据是无序的，因此无法起到排序优化的作用，而 B+ 树索引数据是有序的，可以起到对该字段 ORDER BY 排序优化的作用。同理，我们也无法用 Hash 索引进行模糊查询，而 B+ 树使用 LIKE 进行模糊查询的时候，LIKE 后面前模糊查询（比如 % 开头）的话就可以起到优化作用。

对于等值查询来说，通常 Hash 索引的效率更高，不过也存在一种情况，就是索引列的重复值如果很多，效率就会降低。这是因为遇到 Hash 冲突时，需要遍历桶中的行指针来进行比较，找到查询的关键字，非常耗时。所以，Hash 索引通常不会用到重复值多的列上，比如列为性别、年龄的情况等。

总结

我今天讲了 Hash 索引的底层原理，你能看到 Hash 索引存在着很多限制，相比之下在数据库中 B+ 树索引的使用面会更广，不过也有一些场景采用 Hash 索引效率更高，比如在键值型（Key-Value）数据库中，Redis 存储的核心就是 Hash 表。

另外 MySQL 中的 Memory 存储引擎支持 Hash 存储，如果我们需要用到查询的临时表时，就可以选择 Memory 存储引擎，把某个字段设置为 Hash 索引，比如字符串类型的字段，进行 Hash 计算之后长度可以缩短到几个字节。当字段的重复度低，而且经常需要进行等值查询的时候，采用 Hash 索引是个不错的选择。

另外 MySQL 的 InnoDB 存储引擎还有个“自适应 Hash 索引”的功能，就是当某个索引值使用非常频繁的时候，它会在 B+ 树索引的基础上再创建一个 Hash 索引，这样让 B+ 树也具备了 Hash 索引的优点。

今天的内容到这里就结束了，我留两道思考题吧。查找某个固定值时 Hash 索引比 B+ 树更快，为什么 MySQL 还要采用 B+ 树的存储索引呢？另外，当两个关键字的 Hash 值相同时会发生什么？

欢迎你在评论区写下你的思考，我会和你一起交流，也欢迎把这篇文章分享给你的朋友或者同事，一起交流一下。

26丨索引的使用原则：如何通过索引让SQL查询效率最大化？

我之前讲了索引的使用和它的底层原理，今天我来讲一讲索引的使用原则。既然我们的目标是提升 SQL 的查询效率，那么该如何通过索引让效率最大化呢？

今天的课程主要包括下面几个部分：

什么情况下使用索引？当我们进行数据表查询的时候，都有哪些特征需要我们创建索引？

索引不是万能的，索引设计的不合理可能会阻碍数据库和业务处理的性能。那么什么情况下不需要创建索引？

创建了索引不一定代表一定用得上，甚至在有些情况下索引会失效。哪些情况下，索引会失效呢？又该如何避免这一情况？

创建索引有哪些规律？

创建索引有一定的规律。当这些规律出现的时候，我们就可以通过创建索引提升查询效率，下面我们来看看什么情况下可以创建索引：

1. 字段的数值有唯一性的限制，比如用户名

索引本身可以起到约束的作用，比如唯一索引、主键索引都是可以起到唯一性约束的，因此在我们的数据表中，如果某个字段是唯一性的，就可以直接创建唯一性索引，或者主键索引。

2. 频繁作为 WHERE 查询条件的字段，尤其在数据表大的情况下

在数据量大的情况下，某个字段在 SQL 查询的 WHERE 条件中经常被使用到，那么就需要给这个字段创建索引了。创建普通索引就可以大幅提升数据查询的效率。

我之前列举了 product_comment 数据表，这张数据表中一共有 100 万条数据，假设我们想要查询 user_id=785110 的用户对商品的评论。

如果我们没有对 user_id 字段创建索引，进行如下查询：

SELECT comment_id, product_id, comment_text, comment_time, user_id FROM product_comment WHERE user_id = 785110

运行结果：

运行时间为 0.699s，你能看到查询效率还是比较低的。当我们对 user_id 字段创建索引之后，运行时间为 0.047s，不到原来查询时间的 1/10，效率提升还是明显的。

3. 需要经常 GROUP BY 和 ORDER BY 的列

索引就是让数据按照某种顺序进行存储或检索，因此当我们使用 GROUP BY 对数据进行分组查询，或者使用 ORDER BY 对数据进行排序的时候，就需要对分组或者排序的字段进行索引。

比如我们按照 user_id 对商品评论数据进行分组，显示不同的 user_id 和商品评论的数量，显示 100 个即可。

如果我们不对 user_id 创建索引，执行下面的 SQL 语句：

SELECT user_id, count(*) as num FROM product_comment group by user_id limit 100

运行结果（100 条记录，运行时间 1.666s）：

如果我们对 user_id 创建索引，再执行 SQL 语句：

SELECT user_id, count(*) as num FROM product_comment group by user_id limit 100

运行结果（100 条记录，运行时间 0.042s）：

你能看到当对 user_id 创建索引后，得到的结果中 user_id 字段的数值也是按照顺序展示的，运行时间却不到原来时间的 1/40，效率提升很明显。

同样，如果是 ORDER BY，也需要对字段创建索引。我们再来看下同时有 GROUP BY 和 ORDER BY 的情况。比如我们按照 user_id 进行评论分组，同时按照评论时间降序的方式进行排序，这时我们就需要同时进行 GROUP BY 和 ORDER BY，那么是不是需要单独创建 user_id 的索引和 comment_time 的索引呢？

当我们对 user_id 和 comment_time 分别创建索引，执行下面的 SQL 查询：

SELECT user_id, count(*) as num FROM product_comment group by user_id order by comment_time desc limit 100

运行结果（运行时间 >100s）：

实际上多个单列索引在多条件查询时只会生效一个索引（MySQL 会选择其中一个限制最严格的作为索引），所以在多条件联合查询的时候最好创建联合索引。在这个例子中，我们创建联合索引 (user_id, comment_time)，再来看下查询的时间，查询时间为 0.775s，效率提升了很多。如果我们创建联合索引的顺序为 (comment_time, user_id) 呢？运行时间为 1.990s，同样比两个单列索引要快，但是会比顺序为 (user_id, comment_time) 的索引要慢一些。这是因为在进行 SELECT 查询的时候，先进行 GROUP BY，再对数据进行 ORDER BY 的操作，所以按照这个联合索引的顺序效率是最高的。

4.UPDATE、DELETE 的 WHERE 条件列，一般也需要创建索引

我们刚才说的是数据检索的情况。那么当我们对某条数据进行 UPDATE 或者 DELETE 操作的时候，是否也需要对 WHERE 的条件列创建索引呢？

我们先看一下对数据进行 UPDATE 的情况。

如果我们想要把 comment_text 为 462eed7ac6e791292a79 对应的 product_id 修改为 10002，当我们没有对 comment_text 进行索引的时候，执行 SQL 语句：

UPDATE product_comment SET product_id = 10002 WHERE comment_text = '462eed7ac6e791292a79'

运行结果为 Affected rows: 1，运行时间为 1.173s。

你能看到效率不高，但如果我们对 comment_text 字段创建了索引，然后再把刚才那条记录更新回 product_id=10001，执行 SQL 语句：

UPDATE product_comment SET product_id = 10001 WHERE comment_text = '462eed7ac6e791292a79'

运行结果为 Affected rows: 1，运行时间仅为 0.1110s。你能看到这个运行时间是之前的 1/10，效率有了大幅的提升。

如果我们对某条数据进行 DELETE，效率如何呢？

比如我们想删除 comment_text 为 462eed7ac6e791292a79 的数据。当我们没有对 comment_text 字段进行索引的时候，执行 SQL 语句：

DELETE FROM product_comment WHERE comment_text = '462eed7ac6e791292a79'

运行结果为 Affected rows: 1，运行时间为 1.027s，效率不高。

如果我们对 comment_text 创建了索引，再来执行这条 SQL 语句，运行时间为 0.032s，时间是原来的 1/32，效率有了大幅的提升。

你能看到，对数据按照某个条件进行查询后再进行 UPDATE 或 DELETE 的操作，如果对 WHERE 字段创建了索引，就能大幅提升效率。原理是因为我们需要先根据 WHERE 条件列检索出来这条记录，然后再对它进行更新或删除。如果进行更新的时候，更新的字段是非索引字段，提升的效率会更明显，这是因为非索引字段更新不需要对索引进行维护。

不过在实际工作中，我们也需要注意平衡，如果索引太多了，在更新数据的时候，如果涉及到索引更新，就会造成负担。

5.DISTINCT 字段需要创建索引

有时候我们需要对某个字段进行去重，使用 DISTINCT，那么对这个字段创建索引，也会提升查询效率。

比如我们想要查询商品评论表中不同的 user_id 都有哪些，如果我们没有对 user_id 创建索引，执行 SQL 语句，看看情况是怎样的。

SELECT DISTINCT(user_id) FROM `product_comment`

运行结果（600637 条记录，运行时间 2.283s）：

如果我们对 user_id 创建索引，再执行 SQL 语句，看看情况又是怎样的。

SELECT DISTINCT(user_id) FROM `product_comment`

运行结果（600637 条记录，运行时间 0.627s）：

你能看到 SQL 查询效率有了提升，同时显示出来的 user_id 还是按照递增的顺序进行展示的。这是因为索引会对数据按照某种顺序进行排序，所以在去重的时候也会快很多。

6. 做多表 JOIN 连接操作时，创建索引需要注意以下的原则

首先，连接表的数量尽量不要超过 3 张，因为每增加一张表就相当于增加了一次嵌套的循环，数量级增长会非常快，严重影响查询的效率。

其次，对 WHERE 条件创建索引，因为 WHERE 才是对数据条件的过滤。如果在数据量非常大的情况下，没有 WHERE 条件过滤是非常可怕的。

最后，对用于连接的字段创建索引，并且该字段在多张表中的类型必须一致。比如 user_id 在 product_comment 表和 user 表中都为 int(11) 类型，而不能一个为 int 另一个为 varchar 类型。

举个例子，如果我们只对 user_id 创建索引，执行 SQL 语句：

SELECT comment_id, comment_text, product_comment.user_id, user_name FROM product_comment JOIN user ON product_comment.user_id = user.user_id

WHERE comment_text = '462eed7ac6e791292a79'

运行结果（1 条数据，运行时间 0.810s）：

这里我们对 comment_text 创建索引，再执行上面的 SQL 语句，运行时间为 0.046s。

如果我们不使用 WHERE 条件查询，而是直接采用 JOIN…ON…进行连接的话，即使使用了各种优化手段，总的运行时间也会很长（>100s）。

什么时候不需要创建索引

我之前讲到过索引不是万能的，有一些情况是不需要创建索引的，这里再进行一下说明。

WHERE 条件（包括 GROUP BY、ORDER BY）里用不到的字段不需要创建索引，索引的价值是快速定位，如果起不到定位的字段通常是不需要创建索引的。举个例子：

SELECT comment_id, product_id, comment_time FROM product_comment WHERE user_id = 41251

因为我们是按照 user_id 来进行检索的，所以不需要对其他字段创建索引，即使这些字段出现在 SELECT 字段中。

第二种情况是，如果表记录太少，比如少于 1000 个，那么是不需要创建索引的。我之前讲过一个 SQL 查询的例子（第 23 篇中的 heros 数据表查询的例子，一共 69 个英雄不用索引也很快），表记录太少，是否创建索引对查询效率的影响并不大。

第三种情况是，字段中如果有大量重复数据，也不用创建索引，比如性别字段。不过我们也需要根据实际情况来做判断，这一点我在之前的文章里已经进行了说明，这里不再赘述。

最后一种情况是，频繁更新的字段不一定要创建索引。因为更新数据的时候，也需要更新索引，如果索引太多，在更新索引的时候也会造成负担，从而影响效率。

什么情况下索引失效

我们创建了索引，还要避免索引失效，你可以先思考下都有哪些情况会造成索引失效呢？下面是一些常见的索引失效的例子：

1. 如果索引进行了表达式计算，则会失效

我们可以使用 EXPLAIN 关键字来查看 MySQL 中一条 SQL 语句的执行计划，比如：

EXPLAIN SELECT comment_id, user_id, comment_text FROM product_comment WHERE comment_id+1 = 900001

运行结果：

+----+-------------+-----------------+------------+------+---------------+------+---------+------+--------+----------+-------------+

| id | select_type | table           | partitions | type | possible_keys | key  | key_len | ref  | rows   | filtered | Extra       |

+----+-------------+-----------------+------------+------+---------------+------+---------+------+--------+----------+-------------+

|  1 | SIMPLE      | product_comment | NULL       | ALL  | NULL          | NULL | NULL    | NULL | 996663 |   100.00 | Using where |

+----+-------------+-----------------+------------+------+---------------+------+---------+------+--------+----------+-------------+

你能看到如果对索引进行了表达式计算，索引就失效了。这是因为我们需要把索引字段的取值都取出来，然后依次进行表达式的计算来进行条件判断，因此采用的就是全表扫描的方式，运行时间也会慢很多，最终运行时间为 2.538 秒。

为了避免索引失效，我们对 SQL 进行重写：

SELECT comment_id, user_id, comment_text FROM product_comment WHERE comment_id = 900000

运行时间为 0.039 秒。

2. 如果对索引使用函数，也会造成失效

比如我们想要对 comment_text 的前三位为 abc 的内容进行条件筛选，这里我们来查看下执行计划：

EXPLAIN SELECT comment_id, user_id, comment_text FROM product_comment WHERE SUBSTRING(comment_text, 1,3)='abc'

运行结果：

+----+-------------+-----------------+------------+------+---------------+------+---------+------+--------+----------+-------------+

| id | select_type | table           | partitions | type | possible_keys | key  | key_len | ref  | rows   | filtered | Extra       |

+----+-------------+-----------------+------------+------+---------------+------+---------+------+--------+----------+-------------+

|  1 | SIMPLE      | product_comment | NULL       | ALL  | NULL          | NULL | NULL    | NULL | 996663 |   100.00 | Using where |

+----+-------------+-----------------+------------+------+---------------+------+---------+------+--------+----------+-------------+

你能看到对索引字段进行函数操作，造成了索引失效，这时可以进行查询重写：

SELECT comment_id, user_id, comment_text FROM product_comment WHERE comment_text LIKE 'abc%'

使用 EXPLAIN 对查询语句进行分析：

+----+-------------+-----------------+------------+-------+---------------+--------------+---------+------+------+----------+-----------------------+

| id | select_type | table           | partitions | type  | possible_keys | key          | key_len | ref  | rows | filtered | Extra                 |

+----+-------------+-----------------+------------+-------+---------------+--------------+---------+------+------+----------+-----------------------+

|  1 | SIMPLE      | product_comment | NULL       | range | comment_text  | comment_text | 767     | NULL |  213 |   100.00 | Using index condition |

+----+-------------+-----------------+------------+-------+---------------+--------------+---------+------+------+----------+-----------------------+

你能看到经过查询重写后，可以使用索引进行范围检索，从而提升查询效率。

3. 在 WHERE 子句中，如果在 OR 前的条件列进行了索引，而在 OR 后的条件列没有进行索引，那么索引会失效。

比如下面的 SQL 语句，comment_id 是主键，而 comment_text 没有进行索引，因为 OR 的含义就是两个只要满足一个即可，因此只有一个条件列进行了索引是没有意义的，只要有条件列没有进行索引，就会进行全表扫描，因此索引的条件列也会失效：

EXPLAIN SELECT comment_id, user_id, comment_text FROM product_comment WHERE comment_id = 900001 OR comment_text = '462eed7ac6e791292a79'

运行结果：

+----+-------------+-----------------+------------+------+---------------+------+---------+------+--------+----------+-------------+

| id | select_type | table           | partitions | type | possible_keys | key  | key_len | ref  | rows   | filtered | Extra       |

+----+-------------+-----------------+------------+------+---------------+------+---------+------+--------+----------+-------------+

|  1 | SIMPLE      | product_comment | NULL       | ALL  | PRIMARY       | NULL | NULL    | NULL | 996663 |    10.00 | Using where |

+----+-------------+-----------------+------------+------+---------------+------+---------+------+--------+----------+-------------+

如果我们把 comment_text 创建了索引会是怎样的呢？

+----+-------------+-----------------+------------+-------------+----------------------+----------------------+---------+------+------+----------+------------------------------------------------+

| id | select_type | table           | partitions | type        | possible_keys        | key                  | key_len | ref  | rows | filtered | Extra                                          |

+----+-------------+-----------------+------------+-------------+----------------------+----------------------+---------+------+------+----------+------------------------------------------------+

|  1 | SIMPLE      | product_comment | NULL       | index_merge | PRIMARY,comment_text | PRIMARY,comment_text | 4,767   | NULL |    2 |   100.00 | Using union(PRIMARY,comment_text); Using where |

+----+-------------+-----------------+------------+-------------+----------------------+----------------------+---------+------+------+----------+------------------------------------------------+

你能看到这里使用到了 index merge，简单来说 index merge 就是对 comment_id 和 comment_text 分别进行了扫描，然后将这两个结果集进行了合并。这样做的好处就是避免了全表扫描。

4. 当我们使用 LIKE 进行模糊查询的时候，后面不能是 %

EXPLAIN SELECT comment_id, user_id, comment_text FROM product_comment WHERE comment_text LIKE '%abc'

运行结果：

+----+-------------+-----------------+------------+------+---------------+------+---------+------+--------+----------+-------------+

| id | select_type | table           | partitions | type | possible_keys | key  | key_len | ref  | rows   | filtered | Extra       |

+----+-------------+-----------------+------------+------+---------------+------+---------+------+--------+----------+-------------+

|  1 | SIMPLE      | product_comment | NULL       | ALL  | NULL          | NULL | NULL    | NULL | 996663 |    11.11 | Using where |

+----+-------------+-----------------+------------+------+---------------+------+---------+------+--------+----------+-------------+

这个很好理解，如果一本字典按照字母顺序进行排序，我们会从首位开始进行匹配，而不会对中间位置进行匹配，否则索引就失效了。

5. 索引列与 NULL 或者 NOT NULL 进行判断的时候也会失效。

这是因为索引并不存储空值，所以最好在设计数据表的时候就将字段设置为 NOT NULL 约束，比如你可以将 INT 类型的字段，默认值设置为 0。将字符类型的默认值设置为空字符串 (’’)。

6. 我们在使用联合索引的时候要注意最左原则

最左原则也就是需要从左到右的使用索引中的字段，一条 SQL 语句可以只使用联合索引的一部分，但是需要从最左侧开始，否则就会失效。我在讲联合索引的时候举过索引失效的例子。

总结

今天我们对索引的使用原则进行了梳理，使用好索引可以提升 SQL 查询的效率，但同时 也要注意索引不是万能的。为了避免全表扫描，我们还需要注意有哪些情况可能会导致索引失效，这时就需要进行查询重写，让索引发挥作用。

实际工作中，查询的需求多种多样，创建的索引也会越来越多。这时还需要注意，我们要尽可能扩展索引，而不是新建索引，因为索引数量过多需要维护的成本也会变大，导致写效率变低。同时，我们还需要定期查询使用率低的索引，对于从未使用过的索引可以进行删除，这样才能让索引在 SQL 查询中发挥最大价值。

针对 product_comment 数据表，其中 comment_time 已经创建了普通索引。假设我想查询评论时间在 2018 年 10 月 1 日上午 10 点到 2018 年 10 月 2 日上午 10 点之间的评论，SQL 语句为：

SELECT comment_id, comment_text, comment_time FROM product_comment WHERE DATE(comment_time) >= '2018-10-01 10:00:00' AND comment_time <= '2018-10-02 10:00:00'

你可以想一下这时候索引是否会失效，为什么？如果失效的话，要进行查询重写，应该怎样写？

欢迎你在评论区写下你的答案，也欢迎把这篇文章分享给你的朋友或者同事，一起来交流。

27丨从数据页的角度理解B+树查询

我们之前已经了解了 B+ 树和 Hash 索引的原理，这些索引结构给我们提供了高效的索引方式，不过这些索引信息以及数据记录都是保存在文件上的，确切说是存储在页结构中。

对数据库的存储结构以及页结构的底层进行了解，可以加深我们对索引运行机制的认识，从而你对索引的存储、查询原理，以及对 SQL 查询效率有更深的理解。

今天的课程主要包括下面几个部分：

数据库中的存储结构是怎样的？页、区、段和表空间分别指的是什么？

为什么页（Page）是数据库存储空间的基本单位？

从数据页的角度来看，B+ 树是如何进行查询的？

数据库中的存储结构是怎样的

记录是按照行来存储的，但是数据库的读取并不以行为单位，否则一次读取（也就是一次 I/O 操作）只能处理一行数据，效率会非常低。因此在数据库中，不论读一行，还是读多行，都是将这些行所在的页进行加载。也就是说，数据库管理存储空间的基本单位是页（Page）。

一个页中可以存储多个行记录（Row），同时在数据库中，还存在着区（Extent）、段（Segment）和表空间（Tablespace）。行、页、区、段、表空间的关系如下图所示：

从图中你能看到一个表空间包括了一个或多个段，一个段包括了一个或多个区，一个区包括了多个页，而一个页中可以有多行记录，这些概念我简单给你讲解下。

区（Extent）是比页大一级的存储结构，在 InnoDB 存储引擎中，一个区会分配 64 个连续的页。因为 InnoDB 中的页大小默认是 16KB，所以一个区的大小是 64*16KB=1MB。

段（Segment）由一个或多个区组成，区在文件系统是一个连续分配的空间（在 InnoDB 中是连续的 64 个页），不过在段中不要求区与区之间是相邻的。段是数据库中的分配单位，不同类型的数据库对象以不同的段形式存在。当我们创建数据表、索引的时候，就会相应创建对应的段，比如创建一张表时会创建一个表段，创建一个索引时会创建一个索引段。

表空间（Tablespace）是一个逻辑容器，表空间存储的对象是段，在一个表空间中可以有一个或多个段，但是一个段只能属于一个表空间。数据库由一个或多个表空间组成，表空间从管理上可以划分为系统表空间、用户表空间、撤销表空间、临时表空间等。

在 InnoDB 中存在两种表空间的类型：共享表空间和独立表空间。如果是共享表空间就意味着多张表共用一个表空间。如果是独立表空间，就意味着每张表有一个独立的表空间，也就是数据和索引信息都会保存在自己的表空间中。独立的表空间可以在不同的数据库之间进行迁移。

你可以通过下面的命令来查看 InnoDB 的表空间类型：

mysql > show variables like 'innodb_file_per_table';

你能看到 innodb_file_per_table=ON，这就意味着每张表都会单独保存为一个.ibd 文件。

数据页内的结构是怎样的

页（Page）如果按类型划分的话，常见的有数据页（保存 B+ 树节点）、系统页、Undo 页和事务数据页等。数据页是我们最常使用的页。

表页的大小限定了表行的最大长度，不同 DBMS 的表页大小不同。比如在 MySQL 的 InnoDB 存储引擎中，默认页的大小是 16KB，我们可以通过下面的命令来进行查看：

mysql> show variables like '%innodb_page_size%';

在 SQL Server 的页大小为 8KB，而在 Oracle 中我们用术语“块”（Block）来代表“页”，Oralce 支持的块大小为 2KB，4KB，8KB，16KB，32KB 和 64KB。

数据库 I/O 操作的最小单位是页，与数据库相关的内容都会存储在页结构里。数据页包括七个部分，分别是文件头（File Header）、页头（Page Header）、最大最小记录（Infimum+supremum）、用户记录（User Records）、空闲空间（Free Space）、页目录（Page Directory）和文件尾（File Tailer）。

页结构的示意图如下所示：

这 7 个部分到底有什么作用呢？我简单梳理下：

实际上，我们可以把这 7 个数据页分成 3 个部分。

首先是文件通用部分，也就是文件头和文件尾。它们类似集装箱，将页的内容进行封装，通过文件头和文件尾校验的方式来确保页的传输是完整的。

在文件头中有两个字段，分别是 FIL_PAGE_PREV 和 FIL_PAGE_NEXT，它们的作用相当于指针，分别指向上一个数据页和下一个数据页。连接起来的页相当于一个双向的链表，如下图所示：

需要说明的是采用链表的结构让数据页之间不需要是物理上的连续，而是逻辑上的连续。

我们之前讲到过 Hash 算法，这里文件尾的校验方式就是采用 Hash 算法进行校验。举个例子，当我们进行页传输的时候，如果突然断电了，造成了该页传输的不完整，这时通过文件尾的校验和（checksum 值）与文件头的校验和做比对，如果两个值不相等则证明页的传输有问题，需要重新进行传输，否则认为页的传输已经完成。

第二个部分是记录部分，页的主要作用是存储记录，所以“最小和最大记录”和“用户记录”部分占了页结构的主要空间。另外空闲空间是个灵活的部分，当有新的记录插入时，会从空闲空间中进行分配用于存储新记录，如下图所示：

第三部分是索引部分，这部分重点指的是页目录，它起到了记录的索引作用，因为在页中，记录是以单向链表的形式进行存储的。单向链表的特点就是插入、删除非常方便，但是检索效率不高，最差的情况下需要遍历链表上的所有节点才能完成检索，因此在页目录中提供了二分查找的方式，用来提高记录的检索效率。这个过程就好比是给记录创建了一个目录：

将所有的记录分成几个组，这些记录包括最小记录和最大记录，但不包括标记为“已删除”的记录。

第 1 组，也就是最小记录所在的分组只有 1 个记录；最后一组，就是最大记录所在的分组，会有 1-8 条记录；其余的组记录数量在 4-8 条之间。这样做的好处是，除了第 1 组（最小记录所在组）以外，其余组的记录数会尽量平分。

在每个组中最后一条记录的头信息中会存储该组一共有多少条记录，作为 n_owned 字段。

页目录用来存储每组最后一条记录的地址偏移量，这些地址偏移量会按照先后顺序存储起来，每组的地址偏移量也被称之为槽（slot），每个槽相当于指针指向了不同组的最后一个记录。如下图所示：

页目录存储的是槽，槽相当于分组记录的索引。我们通过槽查找记录，实际上就是在做二分查找。这里我以上面的图示进行举例，5 个槽的编号分别为 0，1，2，3，4，我想查找主键为 9 的用户记录，我们初始化查找的槽的下限编号，设置为 low=0，然后设置查找的槽的上限编号 high=4，然后采用二分查找法进行查找。

首先找到槽的中间位置 p=(low+high)/2=(0+4)/2=2，这时我们取编号为 2 的槽对应的分组记录中最大的记录，取出关键字为 8。因为 9 大于 8，所以应该会在槽编号为 (p,high] 的范围进行查找

接着重新计算中间位置 p’=(p+high)/2=(2+4)/2=3，我们查找编号为 3 的槽对应的分组记录中最大的记录，取出关键字为 12。因为 9 小于 12，所以应该在槽 3 中进行查找。

遍历槽 3 中的所有记录，找到关键字为 9 的记录，取出该条记录的信息即为我们想要查找的内容。

从数据页的角度看 B+ 树是如何进行查询的

MySQL 的 InnoDB 存储引擎采用 B+ 树作为索引，而索引又可以分成聚集索引和非聚集索引（二级索引），这些索引都相当于一棵 B+ 树，如图所示。一棵 B+ 树按照节点类型可以分成两部分：

叶子节点，B+ 树最底层的节点，节点的高度为 0，存储行记录。

非叶子节点，节点的高度大于 0，存储索引键和页面指针，并不存储行记录本身。

我们刚才学习了页结构的内容，你可以用页结构对比，看下 B+ 树的结构。

在一棵 B+ 树中，每个节点都是一个页，每次新建节点的时候，就会申请一个页空间。同一层上的节点之间，通过页的结构构成一个双向的链表（页文件头中的两个指针字段）。非叶子节点，包括了多个索引行，每个索引行里存储索引键和指向下一层页面的页面指针。最后是叶子节点，它存储了关键字和行记录，在节点内部（也就是页结构的内部）记录之间是一个单向的链表，但是对记录进行查找，则可以通过页目录采用二分查找的方式来进行。

当我们从页结构来理解 B+ 树的结构的时候，可以帮我们理解一些通过索引进行检索的原理：

1.B+ 树是如何进行记录检索的？

如果通过 B+ 树的索引查询行记录，首先是从 B+ 树的根开始，逐层检索，直到找到叶子节点，也就是找到对应的数据页为止，将数据页加载到内存中，页目录中的槽（slot）采用二分查找的方式先找到一个粗略的记录分组，然后再在分组中通过链表遍历的方式查找记录。

2. 普通索引和唯一索引在查询效率上有什么不同？

我们创建索引的时候可以是普通索引，也可以是唯一索引，那么这两个索引在查询效率上有什么不同呢？

唯一索引就是在普通索引上增加了约束性，也就是关键字唯一，找到了关键字就停止检索。而普通索引，可能会存在用户记录中的关键字相同的情况，根据页结构的原理，当我们读取一条记录的时候，不是单独将这条记录从磁盘中读出去，而是将这个记录所在的页加载到内存中进行读取。InnoDB 存储引擎的页大小为 16KB，在一个页中可能存储着上千个记录，因此在普通索引的字段上进行查找也就是在内存中多几次“判断下一条记录”的操作，对于 CPU 来说，这些操作所消耗的时间是可以忽略不计的。所以对一个索引字段进行检索，采用普通索引还是唯一索引在检索效率上基本上没有差别。

总结

今天我们学习了数据库中的基本存储单位，也就是页（Page），磁盘 I/O 都是基于页来进行读取的，在页之上还有区、段和表空间，它们都是更大的存储单位。我们在分配空间的时候会按照页为单位来进行分配，同一棵树上同一层的页与页之间采用双向链表，而在页里面，记录之间采用的单向链表的方式。

链表这种数据结构的特点是增加、删除比较方便，所以在对记录进行删除的时候，有时候并不是真的删除了记录，而只是逻辑上的删除，也就是在标记为上标记为“已删除”。但链表还有个问题就是查找效率低，因此在页结构中还专门设计了页目录这个模块，专门给记录做一个目录，通过二分查找法的方式进行检索提升效率。

今天的内容到这里就结束了，最后我给你留两道思考题吧。按照聚集索引存储的行记录在物理上连续的，还是逻辑上连续的？另外，通过 B+ 树进行记录的检索流程是怎样的？

欢迎你在评论区写下你的思考，我会和你一起交流，也欢迎把这篇文章分享给你的朋友或者同事，一起来交流。

28丨从磁盘I/O的角度理解SQL查询的成本

在开始今天的内容前，我们先来回忆一下之前的内容。

数据库存储的基本单位是页，对于一棵 B+ 树的索引来说，是先从根节点找到叶子节点，也就是先查找数据行所在的页，再将页读入到内存中，在内存中对页的记录进行查找，从而得到想要数据。你看，虽然我们想要查找的，只是一行记录，但是对于磁盘 I/O 来说却需要加载一页的信息，因为页是最小的存储单位。

那么对于数据库来说，如果我们想要查找多行记录，查询时间是否会成倍地提升呢？其实数据库会采用缓冲池的方式提升页的查找效率。

为了更好地理解 SQL 查询效率是怎么一回事，今天我们就来看看磁盘 I/O 是如何加载数据的。

这部分的内容主要包括以下几个部分：

数据库的缓冲池在数据库中起到了怎样的作用？如果我们对缓冲池内的数据进行更新，数据会直接更新到磁盘上吗？

对数据页进行加载都有哪些方式呢？

如何查看一条 SQL 语句需要在缓冲池中进行加载的页的数量呢？

数据库缓冲池

磁盘 I/O 需要消耗的时间很多，而在内存中进行操作，效率则会高很多，为了能让数据表或者索引中的数据随时被我们所用，DBMS 会申请占用内存来作为数据缓冲池，这样做的好处是可以让磁盘活动最小化，从而减少与磁盘直接进行 I/O 的时间。要知道，这种策略对提升 SQL 语句的查询性能来说至关重要。如果索引的数据在缓冲池里，那么访问的成本就会降低很多。

那么缓冲池如何读取数据呢？

缓冲池管理器会尽量将经常使用的数据保存起来，在数据库进行页面读操作的时候，首先会判断该页面是否在缓冲池中，如果存在就直接读取，如果不存在，就会通过内存或磁盘将页面存放到缓冲池中再进行读取。

缓存在数据库中的结构和作用如下图所示：

如果我们执行 SQL 语句的时候更新了缓存池中的数据，那么这些数据会马上同步到磁盘上吗？

实际上，当我们对数据库中的记录进行修改的时候，首先会修改缓冲池中页里面的记录信息，然后数据库会以一定的频率刷新到磁盘上。注意并不是每次发生更新操作，都会立刻进行磁盘回写。缓冲池会采用一种叫做 checkpoint 的机制将数据回写到磁盘上，这样做的好处就是提升了数据库的整体性能。

比如，当缓冲池不够用时，需要释放掉一些不常用的页，就可以采用强行采用 checkpoint 的方式，将不常用的脏页回写到磁盘上，然后再从缓冲池中将这些页释放掉。这里脏页（dirty page）指的是缓冲池中被修改过的页，与磁盘上的数据页不一致。

查看缓冲池的大小

了解完缓冲池的工作原理后，你可能想问，我们如何判断缓冲池的大小？

如果你使用的是 MySQL MyISAM 存储引擎，它只缓存索引，不缓存数据，对应的键缓存参数为 key_buffer_size，你可以用它进行查看。

如果你使用的是 InnoDB 存储引擎，可以通过查看 innodb_buffer_pool_size 变量来查看缓冲池的大小，命令如下：

mysql > show variables like 'innodb_buffer_pool_size'

你能看到此时 InnoDB 的缓冲池大小只有 8388608/1024/1024=8MB，我们可以修改缓冲池大小为 128MB，方法如下：

然后再来看下修改后的缓冲池大小，此时已成功修改成了 128MB：

在 InnoDB 存储引擎中，我们可以同时开启多个缓冲池，这里我们看下如何查看缓冲池的个数，使用命令：

mysql > show variables like 'innodb_buffer_pool_instances'

你能看到当前只有一个缓冲池。实际上innodb_buffer_pool_instances默认情况下为 8，为什么只显示只有一个呢？这里需要说明的是，如果想要开启多个缓冲池，你首先需要将innodb_buffer_pool_size参数设置为大于等于 1GB，这时innodb_buffer_pool_instances才会大于 1。你可以在 MySQL 的配置文件中对innodb_buffer_pool_size进行设置，大于等于 1GB，然后再针对innodb_buffer_pool_instances参数进行修改。

数据页加载的三种方式

我们刚才已经对缓冲池有了基本的了解。

如果缓冲池中没有该页数据，那么缓冲池有以下三种读取数据的方式，每种方式的读取效率都是不同的：

1. 内存读取

如果该数据存在于内存中，基本上执行时间在 1ms 左右，效率还是很高的。

2. 随机读取

如果数据没有在内存中，就需要在磁盘上对该页进行查找，整体时间预估在 10ms 左右，这 10ms 中有 6ms 是磁盘的实际繁忙时间（包括了寻道和半圈旋转时间），有 3ms 是对可能发生的排队时间的估计值，另外还有 1ms 的传输时间，将页从磁盘服务器缓冲区传输到数据库缓冲区中。这 10ms 看起来很快，但实际上对于数据库来说消耗的时间已经非常长了，因为这还只是一个页的读取时间。

3. 顺序读取

顺序读取其实是一种批量读取的方式，因为我们请求的数据在磁盘上往往都是相邻存储的，顺序读取可以帮我们批量读取页面，这样的话，一次性加载到缓冲池中就不需要再对其他页面单独进行磁盘 I/O 操作了。如果一个磁盘的吞吐量是 40MB/S，那么对于一个 16KB 大小的页来说，一次可以顺序读取 2560（40MB/16KB）个页，相当于一个页的读取时间为 0.4ms。采用批量读取的方式，即使是从磁盘上进行读取，效率也比从内存中只单独读取一个页的效率要高。

通过 last_query_cost 统计 SQL 语句的查询成本

我们先前已经讲过，一条 SQL 查询语句在执行前需要确定查询计划，如果存在多种查询计划的话，MySQL 会计算每个查询计划所需要的成本，从中选择成本最小的一个作为最终执行的查询计划。

如果我们想要查看某条 SQL 语句的查询成本，可以在执行完这条 SQL 语句之后，通过查看当前会话中的 last_query_cost 变量值来得到当前查询的成本。这个查询成本对应的是 SQL 语句所需要读取的页的数量。

我以 product_comment 表为例，如果我们想要查询 comment_id=900001 的记录，然后看下查询成本，我们可以直接在聚集索引上进行查找：

mysql> SELECT comment_id, product_id, comment_text, user_id FROM product_comment WHERE comment_id = 900001;

运行结果（1 条记录，运行时间为 0.042s）：

然后再看下查询优化器的成本，实际上我们只需要检索一个页即可：

mysql> SHOW STATUS LIKE 'last_query_cost';

如果我们想要查询 comment_id 在 900001 到 9000100 之间的评论记录呢？

mysql> SELECT comment_id, product_id, comment_text, user_id FROM product_comment WHERE comment_id BETWEEN 900001 AND 900100;

运行结果（100 条记录，运行时间为 0.046s）：

然后再看下查询优化器的成本，这时我们大概需要进行 20 个页的查询。

mysql> SHOW STATUS LIKE 'last_query_cost';

你能看到页的数量是刚才的 20 倍，但是查询的效率并没有明显的变化，实际上这两个 SQL 查询的时间基本上一样，就是因为采用了顺序读取的方式将页面一次性加载到缓冲池中，然后再进行查找。虽然页数量（last_query_cost）增加了不少，但是通过缓冲池的机制，并没有增加多少查询时间。

总结

上一节我们了解到了页是数据库存储的最小单位，这一节我们了解了在数据库中是如何加载使用页的。SQL 查询是一个动态的过程，从页加载的角度来看，我们可以得到以下两点结论：

位置决定效率。如果页就在数据库缓冲池中，那么效率是最高的，否则还需要从内存或者磁盘中进行读取，当然针对单个页的读取来说，如果页存在于内存中，会比在磁盘中读取效率高很多。

批量决定效率。如果我们从磁盘中对单一页进行随机读，那么效率是很低的（差不多 10ms），而采用顺序读取的方式，批量对页进行读取，平均一页的读取效率就会提升很多，甚至要快于单个页面在内存中的随机读取。

所以说，遇到 I/O 并不用担心，方法找对了，效率还是很高的。我们首先要考虑数据存放的位置，如果是经常使用的数据就要尽量放到缓冲池中，其次我们可以充分利用磁盘的吞吐能力，一次性批量读取数据，这样单个页的读取效率也就得到了提升。

最后给你留两道思考题吧。你能解释下相比于单个页面的随机读，为什么顺序读取时平均一个页面的加载效率会提高吗？另外，对于今天学习的缓冲池机制和数据页加载的方式，你有什么心得体会吗？

欢迎在评论区写下你的答案，如果你觉得这篇文章有帮助，不妨把它分享给你的朋友或者同事吧。

29丨为什么没有理想的索引？

我之前讲过页这个结构，表和索引都会存储在页中，不同的 DBMS 默认的页的大小是不同的，同时我们也了解到 DBMS 会有缓冲池的机制，在缓冲池里需要有足够多的空间，存储经常被使用到的页，尽可能减少直接的磁盘 I/O 操作。这种策略对 SQL 查询的底层执行来说非常重要，可以从物理层面上最大程度提升 SQL 的查询效率。

但同时我们还需要关注索引的设计，如果只是针对 SQL 查询，我们是可以设计出理想的索引的，不过在实际工作中这种理想的索引往往会带来更多的资源消耗。这是为什么呢？今天我们就来对这部分内容进行学习，内容包括以下几个部分：

什么是索引片？如何计算过滤因子？

设计索引的时候，可以遵循哪些原则呢？

为什么理想的索引很难在实际工作中应用起来？

索引片和过滤因子

索引片就是 SQL 查询语句在执行中需要扫描的一个索引片段，我们会根据索引片中包含的匹配列的数量不同，将索引分成窄索引（比如包含索引列数为 1 或 2）和宽索引（包含的索引列数大于 2）。

如果索引片越宽，那么需要顺序扫描的索引页就越多；如果索引片越窄，就会减少索引访问的开销。比如在 product_comment 数据表中，我们将 comment_id 设置为主键，然后执行下面的 SQL 查询语句：

SELECT comment_id, product_id, comment_text, user_id FROM product_comment WHERE user_id between 100001 and 100100

针对这条 SQL 查询语句，我们可以设置窄索引（user_id）。需要说明的是，每个非聚集索引保存的数据都会存储主键值，然后通过主键值，来回表查找相应的数据，因此每个索引都相当于包括了主键，也就是（comment_id, user_id）。

同样我们可以设置宽索引（user_id, product_id, comment_text），相当于包括了主键，也就是（comment_id, user_id, product_id, comment_text）。

如何通过宽索引避免回表

刚才我讲到了宽索引需要顺序扫描的索引页很多，不过它也可以避免通过索引找到主键，再通过主键回表进行数据查找的情况。回表指的就是数据库根据索引找到了数据行之后，还需要通过主键再次到数据表中读取数据的情况。

我们可以用不同索引片来运行下刚才的 SQL 语句，比如我们采用窄索引（user_id）的方式，来执行下面这条语句：

SELECT comment_id, product_id, comment_text, user_id FROM product_comment WHERE user_id between 100001 and 100100

运行结果（110 条记录，运行时间 0.062s）：

同样，如果我们设置宽索引（user_id, product_id, comment_text），然后执行相同的 SQL 语句，运行结果相同，运行时间为 0.043s，你能看到查询效率有了一些提升。这就是因为我们可以通过宽索引将 SELECT 中需要用到的列（主键列可以除外）都设置在宽索引中，这样就避免了回表扫描的情况，从而提升 SQL 查询效率。

什么是过滤因子

在索引片的设计中，我们还需要考虑一个因素，那就是过滤因子，它描述了谓词的选择性。在 WHERE 条件语句中，每个条件都称为一个谓词，谓词的选择性也等于满足这个条件列的记录数除以总记录数的比例。

举个例子，我们在 player 数据表中，定义了 team_id 和 height 字段，我们也可以设计个 gender 字段，这里 gender 的取值都为 male。

在 player 表中记录比较少，一共 37 条记录，不过我们也可以统计以下字段：gender、team_id、height 和 name，以便评估过滤因子的筛选能力，如下表所示：

你能看到gender='male'不是个好过滤因子，因为所有球员都是男性，同样team_id=1001也不是个好过滤因子，因为这个比例在这个特定的数据集中高达 54%，相比之下height=2.08具有一定的筛选性，过滤因子能力最强的是 name 字段。

这时如果我们创建一个联合的过滤条件（height, team_id），那么它的过滤能力是怎样的呢？

联合过滤因子有更高的过滤能力，这里还需要注意一个条件，那就是条件列的关联性应该尽量相互独立，否则如果列与列之间具有相关性，联合过滤因子的能力就会下降很多。比如城市名称和电话区号就有强相关性，这两个列组合到一起不会加强过滤效果。

你能看到过滤因子决定了索引片的大小（注意这里不是窄索引和宽索引），过滤因子的条件过滤能力越强，满足条件的记录数就越少，SQL 查询需要扫描的索引片也就越小。同理，如果我们没有选择好索引片中的过滤因子，就会造成索引片中的记录数过多的情况。

针对 SQL 查询的理想索引设计：三星索引

刚才我介绍了宽索引和窄索引，有些时候宽索引可以提升 SQL 的查询效率，那么你可能会问，如果针对 SQL 查询来说，有没有一个标准能让 SQL 查询效率最大化呢？

实际上，存在着一个三星索引的标准，这就好比我们在学习数据表设计时提到的三范式一样。三星索引具体指的是：

在 WHERE 条件语句中，找到所有等值谓词中的条件列，将它们作为索引片中的开始列；

将 GROUP BY 和 ORDER BY 中的列加入到索引中；

将 SELECT 字段中剩余的列加入到索引片中。

你能看到这样操作下来，索引片基本上会变成一个宽索引，把能添加的相关列都加入其中。为什么对于一条 SQL 查询来说，这样做的效率是最高的吗？

首先，如果我们要通过索引查找符合条件的记录，就需要将 WHERE 子句中的等值谓词列加入到索引片中，这样索引的过滤能力越强，最终扫描的数据行就越少。

另外，如果我们要对数据记录分组或者排序，都需要重新扫描数据记录。为了避免进行 file sort 排序，可以把 GROUP BY 和 ORDER BY 中涉及到的列加入到索引中，因为创建了索引就会按照索引的顺序来存储数据，这样再对这些数据按照某个字段进行分组或者排序的时候，就会提升效率。

最后，我们取数据的时候，可能会存在回表情况。回表就是通过索引找到了数据行，但是还需要通过主键的方式在数据表中查找完成的记录。这是因为 SELECT 所需的字段并不都保存在索引中，因此我们可以将 SELECT 中的字段都保存在索引中避免回表的情况，从而提升查询效率。

为什么很难存在理想的索引设计

从三星索引的创建过程中，你能看到三星索引实际上分析了在 SQL 查询过程中所有可能影响效率的环节，通过在索引片中添加索引的方式来提升效率。通过上面的原则，我们可以很快创建一个 SQL 查询语句的三星索引（有时候可能只有两星，比如同时拥有范围谓词和 ORDER BY 的时候）。

但就同三范式一样，很多时候我们并没有遵循三范式的设计原则，而是采用了反范式设计。同样，有时候我们并不能需要完全遵循三星索引的原则，原因主要有以下两点：

采用三星索引会让索引片变宽，这样每个页能够存储的索引数据就会变少，从而增加了页加载的数量。从另一个角度来看，如果数据量很大，比如有 1000 万行数据，过多索引所需要的磁盘空间可能会成为一个问题，对缓冲池所需空间的压力也会增加。

增加了索引维护的成本。如果我们为所有的查询语句都设计理想的三星索引，就会让数据表中的索引个数过多，这样索引维护的成本也会增加。举个例子，当我们添加一条记录的时候，就需要在每一个索引上都添加相应的行（存储对应的主键值），假设添加一行记录的时间成本是 10ms（磁盘随机读取一个页的时间），那么如果我们创建了 10 个索引，添加一条记录的时间就可能变成 0.1s，如果是添加 10 条记录呢？就会花费近 1s 的时间。从索引维护的成本来看消耗还是很高的。当然对于数据库来说，数据的更新不一定马上回写到磁盘上，但即使不及时将脏页进行回写，也会造成缓冲池中的空间占用过多，脏页过多的情况。

总结

你能看到针对一条 SQL 查询来说，三星索引是个理想的方式，但实际运行起来我们要考虑更多维护的成本，在索引效率和索引维护之间进行权衡。

三星索引会让索引变宽，好处就是不需要进行回表查询，减少了磁盘 I/O 的次数，弊端就是会造成频繁的页分裂和页合并，对于数据的插入和更新来说，效率会降低不少。

那我们该如何设计索引呢？

首先一张表的索引个数不宜过多，否则一条记录的增加和修改，会因为过多的索引造成额外的负担。针对这个情况，当你需要新建索引的时候，首先考虑在原有的索引片上增加索引，也就是采用复合索引的方式，而不是新建一个新的索引。另外我们可以定期检查索引的使用情况，对于很少使用到的索引可以及时删除，从而减少索引数量。

同时，在索引片中，我们也需要控制索引列的数量，通常情况下我们将 WHERE 里的条件列添加到索引中，而 SELECT 中的非条件列则不需要添加。除非 SELECT 中的非条件列数少，并且该字段会经常使用到。

另外单列索引和复合索引的长度也需要控制，在 MySQL InnoDB 中，系统默认单个索引长度最大为 767 bytes，如果单列索引长度超过了这个限制，就会取前缀索引，也就是取前 255 字符。这实际上也是告诉我们，字符列会占用较大的空间，在数据表设计的时候，尽量采用数值类型替代字符类型，尽量避免用字符类型做主键，同时针对字符字段最好只建前缀索引。

给你留一道思考题吧，针对下面的 SQL 语句，如果创建三星索引该如何创建？使用三星索引和不使用三星索引在查询效率上又有什么区别呢？

SELECT comment_id, comment_text, user_id FROM product_comment where user_id BETWEEN 100000 AND 200000

欢迎你在评论区写下你的思考，也欢迎把这篇文章分享给你的朋友或者同事，一起来交流。

30丨锁：悲观锁和乐观锁是什么？

索引和锁是数据库中的两个核心知识点，不论在工作中，还是在面试中，我们都会经常跟它们打交道。之前我们已经从不同维度对索引进行了了解，比如 B+ 树、Hash 索引、页结构、缓冲池和索引原则等，了解它们的工作原理可以加深我们对索引的理解。同时在基础篇的部分中，我也讲解了事务的 4 大原则以及不同的隔离级别。这些隔离级别的实现都是通过锁来完成的，你可以思考下为什么我们需要给数据加锁呢？

实际上加锁是为了保证数据的一致性，这个思想在程序开发领域中同样很重要。在程序开发中也会存在多线程同步的问题。当多个线程并发访问某个数据的时候，尤其是针对一些敏感的数据（比如订单、金额等），我们就需要保证这个数据在任何时刻最多只有一个线程在进行访问，保证数据的完整性和一致性。

今天的内容主要包括以下几个方面：

就分类而言，锁的划分有多种方式，这些划分方式都包括哪些？

为什么共享锁会发生死锁？

乐观锁和悲观锁的思想是什么？乐观锁有两种实现方式，这两种实现方式是什么？

多个事务并发，发生死锁时该如何解决？怎样降低死锁发生的概率？

按照锁粒度进行划分

锁用来对数据进行锁定，我们可以从锁定对象的粒度大小来对锁进行划分，分别为行锁、页锁和表锁。

顾名思义，行锁就是按照行的粒度对数据进行锁定。锁定力度小，发生锁冲突概率低，可以实现的并发度高，但是对于锁的开销比较大，加锁会比较慢，容易出现死锁情况。

页锁就是在页的粒度上进行锁定，锁定的数据资源比行锁要多，因为一个页中可以有多个行记录。当我们使用页锁的时候，会出现数据浪费的现象，但这样的浪费最多也就是一个页上的数据行。页锁的开销介于表锁和行锁之间，会出现死锁。锁定粒度介于表锁和行锁之间，并发度一般。

表锁就是对数据表进行锁定，锁定粒度很大，同时发生锁冲突的概率也会较高，数据访问的并发度低。不过好处在于对锁的使用开销小，加锁会很快。

行锁、页锁和表锁是相对常见的三种锁，除此以外我们还可以在区和数据库的粒度上锁定数据，对应区锁和数据库锁。不同的数据库和存储引擎支持的锁粒度不同，InnoDB 和 Oracle 支持行锁和表锁。而 MyISAM 只支持表锁，MySQL 中的 BDB 存储引擎支持页锁和表锁。SQL Server 可以同时支持行锁、页锁和表锁，如下表所示：

这里需要说明下，每个层级的锁数量是有限制的，因为锁会占用内存空间，锁空间的大小是有限的。当某个层级的锁数量超过了这个层级的阈值时，就会进行锁升级。锁升级就是用更大粒度的锁替代多个更小粒度的锁，比如 InnoDB 中行锁升级为表锁，这样做的好处是占用的锁空间降低了，但同时数据的并发度也下降了。

从数据库管理的角度对锁进行划分

除了按照锁粒度大小对锁进行划分外，我们还可以从数据库管理的角度对锁进行划分。共享锁和排它锁，是我们经常会接触到的两把锁。

共享锁也叫读锁或 S 锁，共享锁锁定的资源可以被其他用户读取，但不能修改。在进行SELECT的时候，会将对象进行共享锁锁定，当数据读取完毕之后，就会释放共享锁，这样就可以保证数据在读取时不被修改。

比如我们想给 product_comment 在表上加共享锁，可以使用下面这行命令：

LOCK TABLE product_comment READ;

当对数据表加上共享锁的时候，该数据表就变成了只读模式，此时我们想要更新 product_comment 表中的数据，比如下面这样：

UPDATE product_comment SET product_id = 10002 WHERE user_id = 912178;

系统会做出如下提示：

ERROR 1099 (HY000): Table 'product_comment' was locked with a READ lock and can't be updated

也就是当共享锁没有释放时，不能对锁住的数据进行修改。

如果我们想要对表上的共享锁进行解锁，可以使用下面这行命令：

UNLOCK TABLE;

如果我们想要给某一行加上共享锁呢，比如想对 user_id=912178 的数据行加上共享锁，可以像下面这样：

SELECT comment_id, product_id, comment_text, user_id FROM product_comment WHERE user_id = 912178 LOCK IN SHARE MODE

排它锁也叫独占锁、写锁或 X 锁。排它锁锁定的数据只允许进行锁定操作的事务使用，其他事务无法对已锁定的数据进行查询或修改。

如果我们想给 product_comment 数据表添加排它锁，可以使用下面这行命令：

LOCK TABLE product_comment WRITE;

这时只有获得排它锁的事务可以对 product_comment 进行查询或修改，其他事务如果想要在 product_comment 表上查询数据，则需要等待。你可以自己开两个 MySQL 客户端来模拟下。

这时我们释放掉排它锁，使用这行命令即可。

UNLOCK TABLE;

同样的，如果我们想要在某个数据行上添加排它锁，比如针对 user_id=912178 的数据行，则写成如下这样：

SELECT comment_id, product_id, comment_text, user_id FROM product_comment WHERE user_id = 912178 FOR UPDATE;

另外当我们对数据进行更新的时候，也就是INSERT、DELETE或者UPDATE的时候，数据库也会自动使用排它锁，防止其他事务对该数据行进行操作。

当我们想要获取某个数据表的排它锁的时候，需要先看下这张数据表有没有上了排它锁。如果这个数据表中的某个数据行被上了行锁，我们就无法获取排它锁。这时需要对数据表中的行逐一排查，检查是否有行锁，如果没有，才可以获取这张数据表的排它锁。这个过程是不是有些麻烦？这里就需要用到意向锁。

意向锁（Intent Lock），简单来说就是给更大一级别的空间示意里面是否已经上过锁。举个例子，你可以给整个房子设置一个标识，告诉它里面有人，即使你只是获取了房子中某一个房间的锁。这样其他人如果想要获取整个房子的控制权，只需要看这个房子的标识即可，不需要再对房子中的每个房间进行查找。这样是不是很方便？

返回数据表的场景，如果我们给某一行数据加上了排它锁，数据库会自动给更大一级的空间，比如数据页或数据表加上意向锁，告诉其他人这个数据页或数据表已经有人上过排它锁了，这样当其他人想要获取数据表排它锁的时候，只需要了解是否有人已经获取了这个数据表的意向排他锁即可。

如果事务想要获得数据表中某些记录的共享锁，就需要在数据表上添加意向共享锁。同理，事务想要获得数据表中某些记录的排他锁，就需要在数据表上添加意向排他锁。这时，意向锁会告诉其他事务已经有人锁定了表中的某些记录，不能对整个表进行全表扫描。

为什么共享锁会发生死锁的情况？

当我们使用共享锁的时候会出现死锁的风险，下面我们用两个 MySQL 客户端来模拟一下事务查询。

首先客户端 1 开启事务，然后采用读锁的方式对user_id=912178的数据行进行查询，这时事务没有提交的时候，这两行数据行上了读锁。

然后我们用客户端 2 开启事务，同样对user_id=912178获取读锁，理论上获取读锁后还可以对数据进行修改，比如执行下面这条语句：

UPDATE product_comment SET product_i = 10002 WHERE user_id = 912178;

当我们执行的时候客户端 2 会一直等待，因为客户端 1 也获取了该数据的读锁，不需要客户端 2 对该数据进行修改。这时客户端 2 会提示等待超时，重新执行事务。

你能看到当有多个事务对同一数据获得读锁的时候，可能会出现死锁的情况。

从程序员的角度对进行划分

如果从程序员的视角来看锁的话，可以将锁分成乐观锁和悲观锁，从名字中也可以看出这两种锁是两种看待数据并发的思维方式。

乐观锁（Optimistic Locking）认为对同一数据的并发操作不会总发生，属于小概率事件，不用每次都对数据上锁，也就是不采用数据库自身的锁机制，而是通过程序来实现。在程序上，我们可以采用版本号机制或者时间戳机制实现。

乐观锁的版本号机制

在表中设计一个版本字段 version，第一次读的时候，会获取 version 字段的取值。然后对数据进行更新或删除操作时，会执行UPDATE ... SET version=version+1 WHERE version=version。此时如果已经有事务对这条数据进行了更改，修改就不会成功。

这种方式类似我们熟悉的 SVN、CVS 版本管理系统，当我们修改了代码进行提交时，首先会检查当前版本号与服务器上的版本号是否一致，如果一致就可以直接提交，如果不一致就需要更新服务器上的最新代码，然后再进行提交。

乐观锁的时间戳机制

时间戳和版本号机制一样，也是在更新提交的时候，将当前数据的时间戳和更新之前取得的时间戳进行比较，如果两者一致则更新成功，否则就是版本冲突。

你能看到乐观锁就是程序员自己控制数据并发操作的权限，基本是通过给数据行增加一个戳（版本号或者时间戳），从而证明当前拿到的数据是否最新。

悲观锁（Pessimistic Locking）也是一种思想，对数据被其他事务的修改持保守态度，会通过数据库自身的锁机制来实现，从而保证数据操作的排它性。

从这两种锁的设计思想中，你能看出乐观锁和悲观锁的适用场景：

乐观锁适合读操作多的场景，相对来说写的操作比较少。它的优点在于程序实现，不存在死锁问题，不过适用场景也会相对乐观，因为它阻止不了除了程序以外的数据库操作。

悲观锁适合写操作多的场景，因为写的操作具有排它性。采用悲观锁的方式，可以在数据库层面阻止其他事务对该数据的操作权限，防止读 - 写和写 - 写的冲突。

总结

今天我们讲解了数据库中锁的划分，你能看到从不同维度都可以对锁进行划分，需要注意的是，乐观锁和悲观锁并不是锁，而是锁的设计思想。

既然有锁的存在，就有可能发生死锁的情况。死锁就是多个事务（如果是在程序层面就是多个进程）在执行过程中，因为竞争某个相同的资源而造成阻塞的现象。发生死锁，往往是因为在事务中，锁的获取是逐步进行的。

我在文章中举了一个例子，在客户端 1 获取某数据行共享锁的同时，另一个客户端 2 也获取了该数据行的共享锁，这时任何一个客户端都没法对这个数据进行更新，因为共享锁会阻止其他事务对数据的更新，当某个客户端想要对锁定的数据进行更新的时候，就出现了死锁的情况。当死锁发生的时候，就需要一个事务进行回滚，另一个事务获取锁完成事务，然后将锁释放掉，很像交通堵塞时候的解决方案。

我们都不希望出现死锁的情况，可以采取一些方法避免死锁的发生：

如果事务涉及多个表，操作比较复杂，那么可以尽量一次锁定所有的资源，而不是逐步来获取，这样可以减少死锁发生的概率；

如果事务需要更新数据表中的大部分数据，数据表又比较大，这时可以采用锁升级的方式，比如将行级锁升级为表级锁，从而减少死锁产生的概率；

不同事务并发读写多张数据表，可以约定访问表的顺序，采用相同的顺序降低死锁发生的概率。

当然在数据库中，也有一些情况是不会发生死锁的，比如采用乐观锁的方式。另外在 MySQL MyISAM 存储引擎中也不会出现死锁，这是因为 MyISAM 总是一次性获得全部的锁，这样的话要么全部满足可以执行，要么就需要全部等待。

最后你有没有想过，使用 MySQL InnoDB 存储引擎时，为什么对某行数据添加排它锁之前，会在数据表上添加意向排他锁呢？

欢迎你在评论区写下你的思考，也欢迎把这篇文章分享给你的朋友或者同事，一起来进步。

31丨为什么大部分RDBMS都会支持MVCC？

上一篇文章中，我们讲到了锁的划分，以及乐观锁和悲观锁的思想。今天我们就来看下 MVCC，它就是采用乐观锁思想的一种方式。那么它到底有什么用呢？

我们知道事务有 4 个隔离级别，以及可能存在的三种异常问题，如下图所示：

在 MySQL 中，默认的隔离级别是可重复读，可以解决脏读和不可重复读的问题，但不能解决幻读问题。如果我们想要解决幻读问题，就需要采用串行化的方式，也就是将隔离级别提升到最高，但这样一来就会大幅降低数据库的事务并发能力。

有没有一种方式，可以不采用锁机制，而是通过乐观锁的方式来解决不可重复读和幻读问题呢？实际上 MVCC 机制的设计，就是用来解决这个问题的，它可以在大多数情况下替代行级锁，降低系统的开销。

今天的课程主要包括以下几个方面的内容：

MVCC 机制的思想是什么？为什么 RDBMS 会采用 MVCC 机制？

在 InnoDB 中，MVCC 机制是如何实现的 ？

Read View 是如何工作的？

MVCC 是什么，解决了什么问题

MVCC 的英文全称是 Multiversion Concurrency Control，中文翻译过来就是多版本并发控制技术。从名字中也能看出来，MVCC 是通过数据行的多个版本管理来实现数据库的并发控制，简单来说它的思想就是保存数据的历史版本。这样我们就可以通过比较版本号决定数据是否显示出来（具体的规则后面会介绍到），读取数据的时候不需要加锁也可以保证事务的隔离效果。

通过 MVCC 我们可以解决以下几个问题：

读写之间阻塞的问题，通过 MVCC 可以让读写互相不阻塞，即读不阻塞写，写不阻塞读，这样就可以提升事务并发处理能力。

降低了死锁的概率。这是因为 MVCC 采用了乐观锁的方式，读取数据时并不需要加锁，对于写操作，也只锁定必要的行。

解决一致性读的问题。一致性读也被称为快照读，当我们查询数据库在某个时间点的快照时，只能看到这个时间点之前事务提交更新的结果，而不能看到这个时间点之后事务提交的更新结果。

什么是快照读，什么是当前读

那么什么是快照读呢？快照读读取的是快照数据。不加锁的简单的 SELECT 都属于快照读，比如这样：

SELECT * FROM player WHERE ...

当前读就是读取最新数据，而不是历史版本的数据。加锁的 SELECT，或者对数据进行增删改都会进行当前读，比如：

SELECT * FROM player LOCK IN SHARE MODE;

SELECT * FROM player FOR UPDATE;

INSERT INTO player values ...

DELETE FROM player WHERE ...

UPDATE player SET ...

这里需要说明的是，快照读就是普通的读操作，而当前读包括了加锁的读取和 DML 操作。

上面讲 MVCC 的作用，你可能觉得有些抽象。我们用具体的例子体会一下。

比如我们有个账户金额表 user_balance，包括三个字段，分别是 username 用户名、balance 余额和 bankcard 卡号，具体的数据示意如下：

为了方便，我们假设 user_balance 表中只有用户 A 和 B 有余额，其他人的账户余额均为 0。下面我们考虑一个使用场景。

用户 A 和用户 B 之间进行转账，此时数据库管理员想要查询 user_balance 表中的总金额：

SELECT SUM(balance) FROM user_balance

你可以思考下，如果数据库不支持 MVCC 机制，而是采用自身的锁机制来实现的话，可能会出现怎样的情况呢？

情况 1：因为需要采用加行锁的方式，用户 A 给 B 转账时间等待很久，如下图所示。

你能看到为了保证数据的一致性，我们需要给统计到的数据行都加上行锁。这时如果 A 所在的数据行加上了行锁，就不能给 B 转账了，只能等到所有操作完成之后，释放行锁再继续进行转账，这样就会造成用户事务处理的等待时间过长。

情况 2：当我们读取的时候用了加行锁，可能会出现死锁的情况，如下图所示。比如当我们读到 A 有 1000 元的时候，此时 B 开始执行给 A 转账：

UPDATE user_balance SET balance=balance-100 WHERE username ='B'

执行完之后马上执行下一步：

UPDATE user_balance SET balance=balance+100 WHERE username ='A'

我们会发现此时 A 被锁住了，而管理员事务还需要对 B 进行访问，但 B 被用户事务锁住了，此时就发生了死锁。

MVCC 可以解决读写互相阻塞的问题，这样提升了效率，同时因为采用了乐观锁的思想，降低了死锁的概率。

InnoDB 中的 MVCC 是如何实现的？

我刚才讲解了 MVCC 的思想和作用，实际上 MVCC 没有正式的标准，所以在不同的 DBMS 中，MVCC 的实现方式可能是不同的，你可以参考相关的 DBMS 文档。今天我来讲一下 InnoDB 中 MVCC 的实现机制。

在了解 InnoDB 中 MVCC 的实现方式之前，我们需要了解 InnoDB 是如何存储记录的多个版本的。这里的多版本对应的就是 MVCC 前两个字母的释义：Multi Version，我们需要了解和它相关的数据都有哪些，存储在哪里。这些数据包括事务版本号、行记录中的隐藏列和 Undo Log。

事务版本号

每开启一个事务，我们都会从数据库中获得一个事务 ID（也就是事务版本号），这个事务 ID 是自增长的，通过 ID 大小，我们就可以判断事务的时间顺序。

行记录的隐藏列

InnoDB 的叶子段存储了数据页，数据页中保存了行记录，而在行记录中有一些重要的隐藏字段，如下图所示：

db_row_id：隐藏的行 ID，用来生成默认聚集索引。如果我们创建数据表的时候没有指定聚集索引，这时 InnoDB 就会用这个隐藏 ID 来创建聚集索引。采用聚集索引的方式可以提升数据的查找效率。

db_trx_id：操作这个数据的事务 ID，也就是最后一个对该数据进行插入或更新的事务 ID。

db_roll_ptr：回滚指针，也就是指向这个记录的 Undo Log 信息。

Undo Log

InnoDB 将行记录快照保存在了 Undo Log 里，我们可以在回滚段中找到它们，如下图所示：

从图中你能看到回滚指针将数据行的所有快照记录都通过链表的结构串联了起来，每个快照的记录都保存了当时的 db_trx_id，也是那个时间点操作这个数据的事务 ID。这样如果我们想要找历史快照，就可以通过遍历回滚指针的方式进行查找。

Read View 是如何工作的

在 MVCC 机制中，多个事务对同一个行记录进行更新会产生多个历史快照，这些历史快照保存在 Undo Log 里。如果一个事务想要查询这个行记录，需要读取哪个版本的行记录呢？这时就需要用到 Read View 了，它帮我们解决了行的可见性问题。Read View 保存了当前事务开启时所有活跃（还没有提交）的事务列表，换个角度你可以理解为 Read View 保存了不应该让这个事务看到的其他的事务 ID 列表。

在 Read VIew 中有几个重要的属性：

trx_ids，系统当前正在活跃的事务 ID 集合。

low_limit_id，活跃的事务中最大的事务 ID。

up_limit_id，活跃的事务中最小的事务 ID。

creator_trx_id，创建这个 Read View 的事务 ID。

如图所示，trx_ids 为 trx2、trx3、trx5 和 trx8 的集合，活跃的最大事务 ID（low_limit_id）为 trx8，活跃的最小事务 ID（up_limit_id）为 trx2。

假设当前有事务 creator_trx_id 想要读取某个行记录，这个行记录的事务 ID 为 trx_id，那么会出现以下几种情况。

如果 trx_id < 活跃的最小事务 ID（up_limit_id），也就是说这个行记录在这些活跃的事务创建之前就已经提交了，那么这个行记录对该事务是可见的。

如果 trx_id > 活跃的最大事务 ID（low_limit_id），这说明该行记录在这些活跃的事务创建之后才创建，那么这个行记录对当前事务不可见。

如果 up_limit_id < trx_id < low_limit_id，说明该行记录所在的事务 trx_id 在目前 creator_trx_id 这个事务创建的时候，可能还处于活跃的状态，因此我们需要在 trx_ids 集合中进行遍历，如果 trx_id 存在于 trx_ids 集合中，证明这个事务 trx_id 还处于活跃状态，不可见。否则，如果 trx_id 不存在于 trx_ids 集合中，证明事务 trx_id 已经提交了，该行记录可见。

了解了这些概念之后，我们来看下当查询一条记录的时候，系统如何通过多版本并发控制技术找到它：

首先获取事务自己的版本号，也就是事务 ID；

获取 Read View；

查询得到的数据，然后与 Read View 中的事务版本号进行比较；

如果不符合 ReadView 规则，就需要从 Undo Log 中获取历史快照；

最后返回符合规则的数据。

你能看到 InnoDB 中，MVCC 是通过 Undo Log + Read View 进行数据读取，Undo Log 保存了历史快照，而 Read View 规则帮我们判断当前版本的数据是否可见。

需要说明的是，在隔离级别为读已提交（Read Commit）时，一个事务中的每一次 SELECT 查询都会获取一次 Read View。如表所示：

你能看到，在读已提交的隔离级别下，同样的查询语句都会重新获取一次 Read View，这时如果 Read View 不同，就可能产生不可重复读或者幻读的情况。

当隔离级别为可重复读的时候，就避免了不可重复读，这是因为一个事务只在第一次 SELECT 的时候会获取一次 Read View，而后面所有的 SELECT 都会复用这个 Read View，如下表所示：

InnoDB 是如何解决幻读的

不过这里需要说明的是，在可重复读的情况下，InnoDB 可以通过 Next-Key 锁 +MVCC 来解决幻读问题。

在读已提交的情况下，即使采用了 MVCC 方式也会出现幻读。如果我们同时开启事务 A 和事务 B，先在事务 A 中进行某个条件范围的查询，读取的时候采用排它锁，在事务 B 中增加一条符合该条件范围的数据，并进行提交，然后我们在事务 A 中再次查询该条件范围的数据，就会发现结果集中多出一个符合条件的数据，这样就出现了幻读。

出现幻读的原因是在读已提交的情况下，InnoDB 只采用记录锁（Record Locking）。这里要介绍下 InnoDB 三种行锁的方式：

记录锁：针对单个行记录添加锁。

间隙锁（Gap Locking）：可以帮我们锁住一个范围（索引之间的空隙），但不包括记录本身。采用间隙锁的方式可以防止幻读情况的产生。

Next-Key 锁：帮我们锁住一个范围，同时锁定记录本身，相当于间隙锁 + 记录锁，可以解决幻读的问题。

在隔离级别为可重复读时，InnoDB 会采用 Next-Key 锁的机制，帮我们解决幻读问题。

还是这个例子，我们能看到当我们想要插入球员艾利克斯·伦（身高 2.16 米）的时候，事务 B 会超时，无法插入该数据。这是因为采用了 Next-Key 锁，会将 height>2.08 的范围都进行锁定，就无法插入符合这个范围的数据了。然后事务 A 重新进行条件范围的查询，就不会出现幻读的情况。

总结

今天关于 MVCC 的内容有些多，通过学习你应该能对采用 MVCC 这种乐观锁的方式来保证事务的隔离效果更有体会。

我们需要记住，MVCC 的核心就是 Undo Log+ Read View，“MV”就是通过 Undo Log 来保存数据的历史版本，实现多版本的管理，“CC”是通过 Read View 来实现管理，通过 Read View 原则来决定数据是否显示。同时针对不同的隔离级别，Read View 的生成策略不同，也就实现了不同的隔离级别。

MVCC 是一种机制，MySQL、Oracle、SQL Server 和 PostgreSQL 的实现方式均有不同，我们在学习的时候，更主要的是要理解 MVCC 的设计思想。

最后给你留几道思考题吧，为什么隔离级别为读未提交时，不适用于 MVCC 机制呢？第二个问题是，读已提交和可重复读这两个隔离级别的 Read View 策略有何不同？

欢迎你在评论区写下你的思考，也欢迎把这篇文章分享给你的朋友或者同事，让我们一起来学习进步。

32丨查询优化器是如何工作的？

我们总是希望数据库可以运行得更快，也就是响应时间更快，吞吐量更大。想要达到这样的目的，我们一方面需要高并发的事务处理能力，另一方面需要创建合适的索引，让数据的查找效率最大化。事务和索引的使用是数据库中的两个重要核心，事务可以让数据库在增删查改的过程中，保证数据的正确性和安全性，而索引可以帮数据库提升数据的查找效率。

如果我们想要知道如何获取更高的 SQL 查询性能，最好的方式就是理解数据库是如何进行查询优化和执行的。

今天我们就来看看查询优化的原理是怎么一回事。今天的主要内容包括以下几个部分：

什么是查询优化器？一条 SQL 语句的执行流程都会经历哪些环节，在查询优化器中都包括了哪些部分？

查询优化器的两种优化方式分别是什么？

基于代价的优化器是如何统计代价的？总的代价又如何计算？

什么是查询优化器

了解查询优化器的作用之前，我们先来看看一条 SQL 语句的执行都需要经历哪些环节，如下图所示：

你能看到一条 SQL 查询语句首先会经过分析器，进行语法分析和语义检查。我们之前讲过语法分析是检查 SQL 拼写和语法是否正确，语义检查是检查 SQL 中的访问对象是否存在。比如我们在写 SELECT 语句的时候，列名写错了，系统就会提示错误。语法检查和语义检查可以保证 SQL 语句没有错误，最终得到一棵语法分析树，然后经过查询优化器得到查询计划，最后交给执行器进行执行。

查询优化器的目标是找到执行 SQL 查询的最佳执行计划，执行计划就是查询树，它由一系列物理操作符组成，这些操作符按照一定的运算关系组成查询的执行计划。在查询优化器中，可以分为逻辑查询优化阶段和物理查询优化阶段。

逻辑查询优化就是通过改变 SQL 语句的内容来使得 SQL 查询更高效，同时为物理查询优化提供更多的候选执行计划。通常采用的方式是对 SQL 语句进行等价变换，对查询进行重写，而查询重写的数学基础就是关系代数。对条件表达式进行等价谓词重写、条件简化，对视图进行重写，对子查询进行优化，对连接语义进行了外连接消除、嵌套连接消除等。

逻辑查询优化是基于关系代数进行的查询重写，而关系代数的每一步都对应着物理计算，这些物理计算往往存在多种算法，因此需要计算各种物理路径的代价，从中选择代价最小的作为执行计划。在这个阶段里，对于单表和多表连接的操作，需要高效地使用索引，提升查询效率。

在这两个阶段中，查询重写属于代数级、语法级的优化，也就是属于逻辑范围内的优化，而基于代价的估算模型是从连接路径中选择代价最小的路径，属于物理层面的优化。

查询优化器的两种优化方式

查询优化器的目的就是生成最佳的执行计划，而生成最佳执行计划的策略通常有以下两种方式。

第一种是基于规则的优化器（RBO，Rule-Based Optimizer），规则就是人们以往的经验，或者是采用已经被证明是有效的方式。通过在优化器里面嵌入规则，来判断 SQL 查询符合哪种规则，就按照相应的规则来制定执行计划，同时采用启发式规则去掉明显不好的存取路径。

第二种是基于代价的优化器（CBO，Cost-Based Optimizer），这里会根据代价评估模型，计算每条可能的执行计划的代价，也就是 COST，从中选择代价最小的作为执行计划。相比于 RBO 来说，CBO 对数据更敏感，因为它会利用数据表中的统计信息来做判断，针对不同的数据表，查询得到的执行计划可能是不同的，因此制定出来的执行计划也更符合数据表的实际情况。

但我们需要记住，SQL 是面向集合的语言，并没有指定执行的方式，因此在优化器中会存在各种组合的可能。我们需要通过优化器来制定数据表的扫描方式、连接方式以及连接顺序，从而得到最佳的 SQL 执行计划。

你能看出来，RBO 的方式更像是一个出租车老司机，凭借自己的经验来选择从 A 到 B 的路径。而 CBO 更像是手机导航，通过数据驱动，来选择最佳的执行路径。

CBO 是如何统计代价的

大部分 RDBMS 都支持基于代价的优化器（CBO），CBO 随着版本的迭代也越来越成熟，但是 CBO 依然存在缺陷。通过对 CBO 工作原理的了解，我们可以知道 CBO 可能存在的不足有哪些，有助于让我们知道优化器是如何确定执行计划的。

能调整的代价模型的参数有哪些

首先，我们先来了解下 MySQL 中的COST Model，COST Model就是优化器用来统计各种步骤的代价模型，在 5.7.10 版本之后，MySQL 会引入两张数据表，里面规定了各种步骤预估的代价（Cost Value） ，我们可以从mysql.server_cost和mysql.engine_cost这两张表中获得这些步骤的代价：

SQL > SELECT * FROM mysql.server_cost

server_cost 数据表是在 server 层统计的代价，具体的参数含义如下：

disk_temptable_create_cost，表示临时表文件（MyISAM 或 InnoDB）的创建代价，默认值为 20。

disk_temptable_row_cost，表示临时表文件（MyISAM 或 InnoDB）的行代价，默认值 0.5。

key_compare_cost，表示键比较的代价。键比较的次数越多，这项的代价就越大，这是一个重要的指标，默认值 0.05。

memory_temptable_create_cost，表示内存中临时表的创建代价，默认值 1。

memory_temptable_row_cost，表示内存中临时表的行代价，默认值 0.1。

row_evaluate_cost，统计符合条件的行代价，如果符合条件的行数越多，那么这一项的代价就越大，因此这是个重要的指标，默认值 0.1。

由这张表中可以看到，如果想要创建临时表，尤其是在磁盘中创建相应的文件，代价还是很高的。

然后我们看下在存储引擎层都包括了哪些代价：

SQL > SELECT * FROM mysql.engine_cost

engine_cost主要统计了页加载的代价，我们之前了解到，一个页的加载根据页所在位置的不同，读取的位置也不同，可以从磁盘 I/O 中获取，也可以从内存中读取。因此在engine_cost数据表中对这两个读取的代价进行了定义：

io_block_read_cost，从磁盘中读取一页数据的代价，默认是 1。

memory_block_read_cost，从内存中读取一页数据的代价，默认是 0.25。

既然 MySQL 将这些代价参数以数据表的形式呈现给了我们，我们就可以根据实际情况去修改这些参数。因为随着硬件的提升，各种硬件的性能对比也可能发生变化，比如针对普通硬盘的情况，可以考虑适当增加io_block_read_cost的数值，这样就代表从磁盘上读取一页数据的成本变高了。当我们执行全表扫描的时候，相比于范围查询，成本也会增加很多。

比如我想将io_block_read_cost参数设置为 2.0，那么使用下面这条命令就可以：

UPDATE mysql.engine_cost

  SET cost_value = 2.0

  WHERE cost_name = 'io_block_read_cost';

FLUSH OPTIMIZER_COSTS;

我们对mysql.engine_cost中的io_block_read_cost参数进行了修改，然后使用FLUSH OPTIMIZER_COSTS更新内存，然后再查看engine_cost数据表，发现io_block_read_cost参数中的cost_value已经调整为 2.0。

如果我们想要专门针对某个存储引擎，比如 InnoDB 存储引擎设置io_block_read_cost，比如设置为 2，可以这样使用：

INSERT INTO mysql.engine_cost(engine_name, device_type, cost_name, cost_value, last_update, comment)

  VALUES ('InnoDB', 0, 'io_block_read_cost', 2,

  CURRENT_TIMESTAMP, 'Using a slower disk for InnoDB');

FLUSH OPTIMIZER_COSTS;

然后我们再查看一下mysql.engine_cost数据表：

从图中你能看到针对 InnoDB 存储引擎可以设置专门的io_block_read_cost参数值。

代价模型如何计算

总代价的计算是一个比较复杂的过程，上面只是列出了一些常用的重要参数，我们可以根据情况对它们进行调整，也可以使用默认的系统参数值。

那么总的代价是如何进行计算的呢？

在论文《Access Path Selection-in a Relational Database Management System》中给出了计算模型，如下图所示：

你可以简单地认为，总的执行代价等于 I/O 代价 +CPU 代价。在这里 PAGE FETCH 就是 I/O 代价，也就是页面加载的代价，包括数据页和索引页加载的代价。W*(RSI CALLS) 就是 CPU 代价。W 在这里是个权重因子，表示了 CPU 到 I/O 之间转化的相关系数，RSI CALLS 代表了 CPU 的代价估算，包括了键比较（compare key）以及行估算（row evaluating）的代价。

为了让你更好地理解，我说下关于 W 和 RSI CALLS 的英文解释：W is an adjustable weight between I/O and CPU utilization. The number of RSI calls is used to approximate CPU utilization。

这样你应该能明白为了让 CPU 代价和 I/O 代价放到一起来统计，我们使用了转化的系数 W，

另外需要说明的是，在 MySQL5.7 版本之后，代价模型又进行了完善，不仅考虑到了 I/O 和 CPU 开销，还对内存计算和远程操作的代价进行了统计，也就是说总代价的计算公式演变成下面这样：

总代价 = I/O 代价 + CPU 代价 + 内存代价 + 远程代价

这里对内存代价和远程代价不进行讲解，我们只需要关注 I/O 代价和 CPU 代价即可。

总结

我今天讲解了查询优化器，它在 RDBMS 中是个非常重要的角色。在优化器中会经历逻辑查询优化和物理查询优化阶段。

最后我们只是简单梳理了下 CBO 的总代价是如何计算的，以及包括了哪些部分。CBO 的代价计算是个复杂的过程，细节很多，不同优化器的实现方式也不同。另外随着优化器的逐渐成熟，考虑的因素也会越来越多。在某些情况下 MySQL 还会把 RBO 和 CBO 组合起来一起使用。RBO 是个简单固化的模型，在 Oracle 8i 之前采用的就是 RBO，在优化器中一共包括了 15 种规则，输入的 SQL 会根据符合规则的情况得出相应的执行计划，在 Oracle 10g 版本之后就用 CBO 替代了 RBO。

CBO 中需要传入的参数除了 SQL 查询以外，还包括了优化器参数、数据表统计信息和系统配置等，这实际上也导致 CBO 出现了一些缺陷，比如统计信息不准确，参数配置过高或过低，都会导致路径选择的偏差。除此以外，查询优化器还需要在优化时间和执行计划质量之间进行平衡，比如一个执行计划的执行时间是 10 秒钟，就没有必要花 1 分钟优化执行计划，除非该 SQL 使用频繁高，后续可以重复使用该执行计划。同样 CBO 也会做一些搜索空间的剪枝，以便在有效的时间内找到一个“最优”的执行计划。这里，其实也是在告诉我们，为了得到一个事物，付出的成本过大，即使最终得到了，有时候也是得不偿失的。

最后留两道思考题吧，RBO 和 CBO 各自的特点是怎样的呢？为什么 CBO 也存在不足？你能用自己的话描述一下其中的原因吗？

欢迎你在评论区写下你的思考，也欢迎把这篇文章分享给你的朋友或者同事，一起来学习进步。

33丨如何使用性能分析工具定位SQL执行慢的原因？

在上一篇文章中，我们了解了查询优化器，知道在查询优化器中会经历逻辑查询优化和物理查询优化。需要注意的是，查询优化器只能在已经确定的情况下（SQL 语句、索引设计、缓冲池大小、查询优化器参数等）决定最优的查询执行计划。

但实际上 SQL 执行起来可能还是很慢，那么到底从哪里定位 SQL 查询慢的问题呢？是索引设计的问题？服务器参数配置的问题？还是需要增加缓存的问题呢？今天我们就从性能分析来入手，定位导致 SQL 执行慢的原因。

今天的内容主要包括以下几个部分：

数据库服务器的优化分析的步骤是怎样的？中间有哪些需要注意的地方？

如何使用慢查询日志查找执行慢的 SQL 语句？

如何使用 EXPLAIN 查看 SQL 执行计划？

如何使用 SHOW PROFILING 分析 SQL 执行步骤中的每一步的执行时间？

数据库服务器的优化步骤

当我们遇到数据库调优问题的时候，该如何思考呢？我把思考的流程整理成了下面这张图。

整个流程划分成了观察（Show status）和行动（Action）两个部分。字母 S 的部分代表观察（会使用相应的分析工具），字母 A 代表的部分是行动（对应分析可以采取的行动）。

我们可以通过观察了解数据库整体的运行状态，通过性能分析工具可以让我们了解执行慢的 SQL 都有哪些，查看具体的 SQL 执行计划，甚至是 SQL 执行中的每一步的成本代价，这样才能定位问题所在，找到了问题，再采取相应的行动。

我来详细解释一下这张图。

首先在 S1 部分，我们需要观察服务器的状态是否存在周期性的波动。如果存在周期性波动，有可能是周期性节点的原因，比如双十一、促销活动等。这样的话，我们可以通过 A1 这一步骤解决，也就是加缓存，或者更改缓存失效策略。

如果缓存策略没有解决，或者不是周期性波动的原因，我们就需要进一步分析查询延迟和卡顿的原因。接下来进入 S2 这一步，我们需要开启慢查询。慢查询可以帮我们定位执行慢的 SQL 语句。我们可以通过设置 long_query_time 参数定义“慢”的阈值，如果 SQL 执行时间超过了 long_query_time，则会认为是慢查询。当收集上来这些慢查询之后，我们就可以通过分析工具对慢查询日志进行分析。

在 S3 这一步骤中，我们就知道了执行慢的 SQL，这样就可以针对性地用 EXPLAIN 查看对应 SQL 语句的执行计划，或者使用 show profile 查看 SQL 中每一个步骤的时间成本。这样我们就可以了解 SQL 查询慢是因为执行时间长，还是等待时间长。

如果是 SQL 等待时间长，我们进入 A2 步骤。在这一步骤中，我们可以调优服务器的参数，比如适当增加数据库缓冲池等。如果是 SQL 执行时间长，就进入 A3 步骤，这一步中我们需要考虑是索引设计的问题？还是查询关联的数据表过多？还是因为数据表的字段设计问题导致了这一现象。然后在这些维度上进行对应的调整。

如果 A2 和 A3 都不能解决问题，我们需要考虑数据库自身的 SQL 查询性能是否已经达到了瓶颈，如果确认没有达到性能瓶颈，就需要重新检查，重复以上的步骤。如果已经达到了性能瓶颈，进入 A4 阶段，需要考虑增加服务器，采用读写分离的架构，或者考虑对数据库进行分库分表，比如垂直分库、垂直分表和水平分表等。

以上就是数据库调优的流程思路。如果我们发现执行 SQL 时存在不规则延迟或卡顿的时候，就可以采用分析工具帮我们定位有问题的 SQL，这三种分析工具你可以理解是 SQL 调优的三个步骤：慢查询、EXPLAIN 和 SHOW PROFILING。

使用慢查询定位执行慢的 SQL

好慢询可以帮我们找到执行慢的 SQL，在使用前，我们需要先看下慢查询是否已经开启，使用下面这条命令即可：

mysql > show variables like '%slow_query_log';

我们能看到 slow_query_log=OFF，也就是说慢查询日志此时是关上的。我们可以把慢查询日志打开，注意设置变量值的时候需要使用 global，否则会报错：

mysql > set global slow_query_log='ON';

然后我们再来查看下慢查询日志是否开启，以及慢查询日志文件的位置：

你能看到这时慢查询分析已经开启，同时文件保存在 DESKTOP-4BK02RP-slow 文件中。

接下来我们来看下慢查询的时间阈值设置，使用如下命令：

mysql > show variables like '%long_query_time%';

这里如果我们想把时间缩短，比如设置为 3 秒，可以这样设置：

mysql > set global long_query_time = 3;

我们可以使用 MySQL 自带的 mysqldumpslow 工具统计慢查询日志（这个工具是个 Perl 脚本，你需要先安装好 Perl）。

mysqldumpslow 命令的具体参数如下：

-s：采用 order 排序的方式，排序方式可以有以下几种。分别是 c（访问次数）、t（查询时间）、l（锁定时间）、r（返回记录）、ac（平均查询次数）、al（平均锁定时间）、ar（平均返回记录数）和 at（平均查询时间）。其中 at 为默认排序方式。

-t：返回前 N 条数据 。

-g：后面可以是正则表达式，对大小写不敏感。

比如我们想要按照查询时间排序，查看前两条 SQL 语句，这样写即可：

perl mysqldumpslow.pl -s t -t 2 "C:\ProgramData\MySQL\MySQL Server 8.0\Data\DESKTOP-4BK02RP-slow.log"

你能看到开启了慢查询日志，并设置了相应的慢查询时间阈值之后，只要大于这个阈值的 SQL 语句都会保存在慢查询日志中，然后我们就可以通过 mysqldumpslow 工具提取想要查找的 SQL 语句了。

如何使用 EXPLAIN 查看执行计划

定位了查询慢的 SQL 之后，我们就可以使用 EXPLAIN 工具做针对性的分析，比如我们想要了解 product_comment 和 user 表进行联查的时候所采用的的执行计划，可以使用下面这条语句：

EXPLAIN SELECT comment_id, product_id, comment_text, product_comment.user_id, user_name FROM product_comment JOIN user on product_comment.user_id = user.user_id

EXPLAIN 可以帮助我们了解数据表的读取顺序、SELECT 子句的类型、数据表的访问类型、可使用的索引、实际使用的索引、使用的索引长度、上一个表的连接匹配条件、被优化器查询的行的数量以及额外的信息（比如是否使用了外部排序，是否使用了临时表等）等。

SQL 执行的顺序是根据 id 从大到小执行的，也就是 id 越大越先执行，当 id 相同时，从上到下执行。

数据表的访问类型所对应的 type 列是我们比较关注的信息。type 可能有以下几种情况：

在这些情况里，all 是最坏的情况，因为采用了全表扫描的方式。index 和 all 差不多，只不过 index 对索引表进行全扫描，这样做的好处是不再需要对数据进行排序，但是开销依然很大。如果我们在 extra 列中看到 Using index，说明采用了索引覆盖，也就是索引可以覆盖所需的 SELECT 字段，就不需要进行回表，这样就减少了数据查找的开销。

比如我们对 product_comment 数据表进行查询，设计了联合索引 composite_index (user_id, comment_text)，然后对数据表中的 comment_id、comment_text、user_id 这三个字段进行查询，最后用 EXPLAIN 看下执行计划：

EXPLAIN SELECT comment_id, comment_text, user_id FROM product_comment

你能看到这里的访问方式采用了 index 的方式，key 列采用了联合索引，进行扫描。Extral 列为 Using index，告诉我们索引可以覆盖 SELECT 中的字段，也就不需要回表查询了。

range 表示采用了索引范围扫描，这里不进行举例，从这一级别开始，索引的作用会越来越明显，因此我们需要尽量让 SQL 查询可以使用到 range 这一级别及以上的 type 访问方式。

index_merge 说明查询同时使用了两个或以上的索引，最后取了交集或者并集。比如想要对 comment_id=500000 或者 user_id=500000 的数据进行查询，数据表中 comment_id 为主键，user_id 是普通索引，我们可以查看下执行计划：

EXPLAIN SELECT comment_id, product_id, comment_text, user_id FROM product_comment WHERE comment_id = 500000 OR user_id = 500000

你能看到这里同时使用到了两个索引，分别是主键和 user_id，采用的数据表访问类型是 index_merge，通过 union 的方式对两个索引检索的数据进行合并。

ref 类型表示采用了非唯一索引，或者是唯一索引的非唯一性前缀。比如我们想要对 user_id=500000 的评论进行查询，使用 EXPLAIN 查看执行计划：

EXPLAIN SELECT comment_id, comment_text, user_id FROM product_comment WHERE user_id = 500000

这里 user_id 为普通索引（因为 user_id 在商品评论表中可能是重复的），因此采用的访问类型是 ref，同时在 ref 列中显示 const，表示连接匹配条件是常量，用于索引列的查找。

eq_ref 类型是使用主键或唯一索引时产生的访问方式，通常使用在多表联查中。假设我们对 product_comment 表和 usre 表进行联查，关联条件是两张表的 user_id 相等，使用 EXPLAIN 进行执行计划查看：

EXPLAIN SELECT * FROM product_comment JOIN user WHERE product_comment.user_id = user.user_id

const 类型表示我们使用了主键或者唯一索引（所有的部分）与常量值进行比较，比如我们想要查看 comment_id=500000，查看执行计划：

EXPLAIN SELECT comment_id, comment_text, user_id FROM product_comment WHERE comment_id = 500000

需要说明的是 const 类型和 eq_ref 都使用了主键或唯一索引，不过这两个类型有所区别，const 是与常量进行比较，查询效率会更快，而 eq_ref 通常用于多表联查中。

system 类型一般用于 MyISAM 或 Memory 表，属于 const 类型的特例，当表只有一行时连接类型为 system（我在 GitHub 上上传了 test_myisam 数据表，该数据表只有一行记录，下载地址：https://github.com/cystanford/SQL_MyISAM）。我们查看下执行计划：

EXPLAIN SELECT * FROM test_myisam

你能看到除了 all 类型外，其他类型都可以使用到索引，但是不同的连接方式的效率也会有所不同，效率从低到高依次为 all < index < range < index_merge < ref < eq_ref < const/system。我们在查看执行计划的时候，通常希望执行计划至少可以使用到 range 级别以上的连接方式，如果只使用到了 all 或者 index 连接方式，我们可以从 SQL 语句和索引设计的角度上进行改进。

使用 SHOW PROFILE 查看 SQL 的具体执行成本

SHOW PROFILE 相比 EXPLAIN 能看到更进一步的执行解析，包括 SQL 都做了什么、所花费的时间等。默认情况下，profiling 是关闭的，我们可以在会话级别开启这个功能。

mysql > show variables like 'profiling';

通过设置 profiling='ON’来开启 show profile：

mysql > set profiling = 'ON';

我们可以看下当前会话都有哪些 profiles，使用下面这条命令：

mysql > show profiles;

你能看到当前会话一共有 2 个查询，如果我们想要查看上一个查询的开销，可以使用：

mysql > show profile;

我们也可以查看指定的 Query ID 的开销，比如 show profile for query 2 查询结果是一样的。在 SHOW PROFILE 中我们可以查看不同部分的开销，比如 cpu、block.io 等：

通过上面的结果，我们可以弄清楚每一步骤的耗时，以及在不同部分，比如 CPU、block.io 的执行时间，这样我们就可以判断出来 SQL 到底慢在哪里。

不过 SHOW PROFILE 命令将被弃用，我们可以从 information_schema 中的 profiling 数据表进行查看。

总结

我今天梳理了 SQL 优化的思路，从步骤上看，我们需要先进行观察和分析，分析工具的使用在日常工作中还是很重要的。今天只介绍了常用的三种分析工具，实际上可以使用的分析工具还有很多。

我在这里总结一下今天文章里提到的三种分析工具。我们可以通过慢查询日志定位执行慢的 SQL，然后通过 EXPLAIN 分析该 SQL 语句是否使用到了索引，以及具体的数据表访问方式是怎样的。我们也可以使用 SHOW PROFILE 进一步了解 SQL 每一步的执行时间，包括 I/O 和 CPU 等资源的使用情况。

今天我介绍了 EXPLAIN 和 SHOW PROFILE 这两个工具，你还使用过哪些分析工具呢？

另外我们在进行数据表连接的时候，会有多种访问类型，你可以讲一下 ref、eq_ref 和 const 这三种类型的区别吗？查询效率有何不同？

欢迎你在评论区写下你的思考，也欢迎把这篇文章分享给你的朋友或者同事，一起交流进步。

34丨答疑篇：关于索引以及缓冲池的一些解惑

这篇文章是进阶篇的最后一篇，在这一模块中，我主要针对 SQL 运行的底层原理进行了讲解，其中还有很多问题没有回答，我总结了进阶篇中常见的一些问题，希望能对你有所帮助。下面的内容主要包括了索引原则、自适应 Hash、缓冲池机制和存储引擎等。

关于索引（B+ 树索引和 Hash 索引，以及索引原则）

什么是自适应 Hash 索引？

在回答这个问题前，让我们先回顾下 B+ 树索引和 Hash 索引：

因为 B+ 树可以使用到范围查找，同时是按照顺序的方式对数据进行存储，因此很容易对数据进行排序操作，在联合索引中也可以利用部分索引键进行查询。这些情况下，我们都没法使用 Hash 索引，因为 Hash 索引仅能满足（=）（<>）和 IN 查询，不能使用范围查询。此外，Hash 索引还有一个缺陷，数据的存储是没有顺序的，在 ORDER BY 的情况下，使用 Hash 索引还需要对数据重新排序。而对于联合索引的情况，Hash 值是将联合索引键合并后一起来计算的，无法对单独的一个键或者几个索引键进行查询。

MySQL 默认使用 B+ 树作为索引，因为 B+ 树有着 Hash 索引没有的优点，那么为什么还需要自适应 Hash 索引呢？这是因为 Hash 索引在进行数据检索的时候效率非常高，通常只需要 O(1) 的复杂度，也就是一次就可以完成数据的检索。虽然 Hash 索引的使用场景有很多限制，但是优点也很明显，所以 MySQL 提供了一个自适应 Hash 索引的功能（Adaptive Hash Index）。注意，这里的自适应指的是不需要人工来制定，系统会根据情况自动完成。

什么情况下才会使用自适应 Hash 索引呢？如果某个数据经常被访问，当满足一定条件的时候，就会将这个数据页的地址存放到 Hash 表中。这样下次查询的时候，就可以直接找到这个页面的所在位置。

需要说明的是自适应 Hash 索引只保存热数据（经常被使用到的数据），并非全表数据。因此数据量并不会很大，因此自适应 Hash 也是存放到缓冲池中，这样也进一步提升了查找效率。

InnoDB 中的自适应 Hash 相当于“索引的索引”，采用 Hash 索引存储的是 B+ 树索引中的页面的地址。如下图所示：

你能看到，采用自适应 Hash 索引目的是方便根据 SQL 的查询条件加速定位到叶子节点，特别是当 B+ 树比较深的时候，通过自适应 Hash 索引可以明显提高数据的检索效率。

我们来看下自适应 Hash 索引的原理。

自适应 Hash 采用 Hash 函数映射到一个 Hash 表中，如下图所示，查找字典类型的数据非常方便。

Hash 表是数组 + 链表的形式。通过 Hash 函数可以计算索引键值所对应的 bucket（桶）的位置，如果产生 Hash 冲突，就需要遍历链表来解决。

我们可以通过innodb_adaptive_hash_index变量来查看是否开启了自适应 Hash，比如：

mysql> show variables like '%adaptive_hash_index';

我来总结一下，InnoDB 本身不支持 Hash 索引，但是提供自适应 Hash 索引，不需要用户来操作，存储引擎会自动完成。自适应 Hash 是 InnoDB 三大关键特性之一，另外两个分别是插入缓冲和二次写。

什么是联合索引的最左原则？

关于联合索引的最左原则，读者 @老毕 给出了一个非常形象的解释：

假设我们有 x、y、z 三个字段，创建联合索引（x, y, z）之后，我们可以把 x、y、z 分别类比成“百分位”、“十分位”和“个位”。

查询“x=9 AND y=8 AND z=7”的过程，就是在一个由小到大排列的数值序列中寻找“987”，可以很快找到。

查询“y=8 AND z=7”，就用不上索引了，因为可能存在 187、287、387、487………这样就必须扫描所有数值。

我在这个基础上再补充说明一下。

查询“z=7 AND y=8 AND x=9”的时候，如果三个字段 x、y、z 在条件查询的时候是乱序的，但采用的是等值查询（=）或者是 IN 查询，那么 MySQL 的优化器可以自动帮我们调整为可以使用联合索引的形式。

当我们查询“x=9 AND y>8 AND z=7”的时候，如果建立了 (x,y,z) 顺序的索引，这时候 z 是用不上索引的。这是因为 MySQL 在匹配联合索引最左前缀的时候，如果遇到了范围查询，比如（<）（>）和 between 等，就会停止匹配。索引列最多作用于一个范围列，对于后面的 Z 来说，就没法使用到索引了。

通过这个我们也可以知道，联合索引的最左前缀匹配原则针对的是创建的联合索引中的顺序，如果创建了联合索引（x,y,z），那么这个索引的使用顺序就很重要了。如果在条件语句中只有 y 和 z，那么就用不上联合索引。

此外，SQL 条件语句中的字段顺序并不重要，因为在逻辑查询优化阶段会自动进行查询重写。

最后你需要记住，如果我们遇到了范围条件查询，比如（<）（<=）（>）（>=）和 between 等，那么范围列后的列就无法使用到索引了。

Hash 索引与 B+ 树索引是在建索引的时候手动指定的吗？

如果使用的是 MySQL 的话，我们需要了解 MySQL 的存储引擎都支持哪些索引结构，如下图所示（参考来源 https://dev.mysql.com/doc/refman/8.0/en/create-index.html）。如果是其他的 DBMS，可以参考相关的 DBMS 文档。

你能看到，针对 InnoDB 和 MyISAM 存储引擎，都会默认采用 B+ 树索引，无法使用 Hash 索引。InnoDB 提供的自适应 Hash 是不需要手动指定的。如果是 Memory/Heap 和 NDB 存储引擎，是可以进行选择 Hash 索引的。

关于缓冲池

缓冲池和查询缓存是一个东西吗？

首先我们需要了解在 InnoDB 存储引擎中，缓冲池都包括了哪些。

在 InnoDB 存储引擎中有一部分数据会放到内存中，缓冲池则占了这部分内存的大部分，它用来存储各种数据的缓存，如下图所示：

从图中，你能看到 InnoDB 缓冲池包括了数据页、索引页、插入缓冲、锁信息、自适应 Hash 和数据字典信息等。

我们之前讲过使用缓冲池技术的原因。这里重新回顾一下。InnoDB 存储引擎基于磁盘文件存储，访问物理硬盘和在内存中进行访问，速度相差很大，为了尽可能弥补这两者之间 I/O 效率的差值，我们就需要把经常使用的数据加载到缓冲池中，避免每次访问都进行磁盘 I/O。

“频次 * 位置”这个原则，可以帮我们对 I/O 访问效率进行优化。

首先，位置决定效率，提供缓冲池就是为了在内存中可以直接访问数据。

其次，频次决定优先级顺序。因为缓冲池的大小是有限的，比如磁盘有 200G，但是内存只有 16G，缓冲池大小只有 1G，就无法将所有数据都加载到缓冲池里，这时就涉及到优先级顺序，会优先对使用频次高的热数据进行加载。

了解了缓冲池的作用之后，我们还需要了解缓冲池的另一个特性：预读。

缓冲池的作用就是提升 I/O 效率，而我们进行读取数据的时候存在一个“局部性原理”，也就是说我们使用了一些数据，大概率还会使用它周围的一些数据，因此采用“预读”的机制提前加载，可以减少未来可能的磁盘 I/O 操作。

那么什么是查询缓存呢？

查询缓存是提前把查询结果缓存起来，这样下次不需要执行就可以直接拿到结果。需要说明的是，在 MySQL 中的查询缓存，不是缓存查询计划，而是查询对应的结果。这就意味着查询匹配的鲁棒性大大降低，只有相同的查询操作才会命中查询缓存。因此 MySQL 的查询缓存命中率不高，在 MySQL8.0 版本中已经弃用了查询缓存功能。

查看是否使用了查询缓存，使用命令：

show variables like '%query_cache%';

缓冲池并不等于查询缓存，它们的共同点都是通过缓存的机制来提升效率。但缓冲池服务于数据库整体的 I/O 操作，而查询缓存服务于 SQL 查询和查询结果集的，因为命中条件苛刻，而且只要数据表发生变化，查询缓存就会失效，因此命中率低。

其他

很多人对 InnoDB 和 MyISAM 的取舍存在疑问，到底选择哪个比较好呢？

我们需要先了解 InnoDB 和 MyISAM 各自的特点。InnoDB 支持事务和行级锁，是 MySQL 默认的存储引擎；MyISAM 只支持表级锁，不支持事务，更适合读取数据库的情况。

如果是小型的应用，需要大量的 SELECT 查询，可以考虑 MyISAM；如果是事务处理应用，需要选择 InnoDB。

这两种引擎各有特点，当然你也可以在 MySQL 中，针对不同的数据表，可以选择不同的存储引擎。

最后给大家提供一下专栏中学习资料的下载。

如果你想导入文章中的“product_comment”表结构和数据，点击这里即可。你也可以在网盘里下载，提取码为 32ep。

关于文章中涉及到的思维导图，点击这里下载即可。

最后留一道思考题，供你消化今天答疑篇里的内容。

假设我们有 x、y、z 三个字段，创建联合索引（x, y, z）。数据表中的数据量比较大，那么对下面语句进行 SQL 查询的时候，哪个会使用联合索引？如果使用了联合索引，分别使用到了联合索引的哪些部分？

A

SELECT x, y, z FROM table WHERE y=2 AND x>1 AND z=3

B

SELECT x, y, z FROM table WHERE y=2 AND x=1 AND z>3

C

SELECT x, y, z FROM table WHERE y=2 AND x=1 AND z=3

D

SELECT x, y, z FROM table WHERE y>2 AND x=1 AND z=3

欢迎你在评论区写下你的答案，我会和你一起交流，也欢迎把这篇文章分享给你的朋友或者同事，一起交流一下。

35丨数据库主从同步的作用是什么，如何解决数据不一致问题？

我们之前讲解了 Redis，它是一种高性能的内存数据库；而 MySQL 是基于磁盘文件的关系型数据库，相比于 Redis 来说，读取速度会慢一些，但是功能强大，可以用于存储持久化的数据。在实际工作中，我们常常将 Redis 作为缓存与 MySQL 配合来使用，当有数据访问请求的时候，首先会从缓存中进行查找，如果存在就直接取出，如果不存在再访问数据库，这样就提升了读取的效率，也减少了对后端数据库的访问压力。可以说使用 Redis 这种缓存架构是高并发架构中非常重要的一环。

当然我们也可以对 MySQL 做主从架构并且进行读写分离，让主服务器（Master）处理写请求，从服务器（Slave）处理读请求，这样同样可以提升数据库的并发处理能力。不过主从架构的作用不止于此，我们今天就从以下几个方面了解一下它：

为什么需要主从同步，设置主从同步有什么样的作用？

主从同步的原理是怎样的？在进行主从同步的同时，会引入哪些问题？

为了保证主从同步的数据一致性，都有哪些方案？

为什么需要主从同步

首先不是所有的应用都需要对数据库进行主从架构的设置，毕竟设置架构本身是有成本的，如果我们的目的在于提升数据库高并发访问的效率，那么首先需要考虑的应该是如何优化你的 SQL 和索引，这种方式简单有效，其次才是采用缓存的策略，比如使用 Redis，通过 Redis 高性能的优势将热点数据保存在内存数据库中，提升读取的效率，最后才是对数据库采用主从架构，进行读写分离。

按照上面的方式进行优化，使用和维护的成本是由低到高的。

主从同步设计不仅可以提高数据库的吞吐量，还有以下 3 个方面的作用。

首先是可以读写分离。我们可以通过主从复制的方式来同步数据，然后通过读写分离提高数据库并发处理能力。

简单来说就是同一份数据被放到了多个数据库中，其中一个数据库是 Master 主库，其余的多个数据库是 Slave 从库。当主库进行更新的时候，会自动将数据复制到从库中，而我们在客户端读取数据的时候，会从从库中进行读取，也就是采用读写分离的方式。互联网的应用往往是一些“读多写少”的需求，采用读写分离的方式，可以实现更高的并发访问。原本所有的读写压力都由一台服务器承担，现在有多个“兄弟”帮忙处理读请求，这样就减少了对后端大哥（Master）的压力。同时，我们还能对从服务器进行负载均衡，让不同的读请求按照策略均匀地分发到不同的从服务器上，让读取更加顺畅。读取顺畅的另一个原因，就是减少了锁表的影响，比如我们让主库负责写，当主库出现写锁的时候，不会影响到从库进行 SELECT 的读取。

第二个作用就是数据备份。我们通过主从复制将主库上的数据复制到了从库上，相当于是一种热备份机制，也就是在主库正常运行的情况下进行的备份，不会影响到服务。

第三个作用是具有高可用性。我刚才讲到的数据备份实际上是一种冗余的机制，通过这种冗余的方式可以换取数据库的高可用性，也就是当服务器出现故障或宕机的情况下，可以切换到从服务器上，保证服务的正常运行。

关于高可用性的程度，我们可以用一个指标衡量，即正常可用时间 / 全年时间。比如要达到全年 99.999% 的时间都可用，就意味着系统在一年中的不可用时间不得超过 5.256 分钟，也就 365*24*60*（1-99.999%）=5.256 分钟，其他时间都需要保持可用的状态。需要注意的是，这 5.256 分钟包括了系统崩溃的时间，也包括了日常维护操作导致的停机时间。

实际上，更高的高可用性，意味着需要付出更高的成本代价。在现实中我们需要结合业务需求和成本来进行选择。

主从同步的原理是怎样的

提到主从同步的原理，我们就需要了解在数据库中的一个重要日志文件，那就是 Binlog 二进制日志，它记录了对数据库进行更新的事件。实际上主从同步的原理就是基于 Binlog 进行数据同步的。在主从复制过程中，会基于 3 个线程来操作，一个主库线程，两个从库线程。

二进制日志转储线程（Binlog dump thread）是一个主库线程。当从库线程连接的时候，主库可以将二进制日志发送给从库，当主库读取事件的时候，会在 Binlog 上加锁，读取完成之后，再将锁释放掉。

从库 I/O 线程会连接到主库，向主库发送请求更新 Binlog。这时从库的 I/O 线程就可以读取到主库的二进制日志转储线程发送的 Binlog 更新部分，并且拷贝到本地形成中继日志（Relay log）。

从库 SQL 线程会读取从库中的中继日志，并且执行日志中的事件，从而将从库中的数据与主库保持同步。

所以你能看到主从同步的内容就是二进制日志（Binlog），它虽然叫二进制日志，实际上存储的是一个又一个事件（Event），这些事件分别对应着数据库的更新操作，比如 INSERT、UPDATE、DELETE 等。另外我们还需要注意的是，不是所有版本的 MySQL 都默认开启服务器的二进制日志，在进行主从同步的时候，我们需要先检查服务器是否已经开启了二进制日志。

进行主从同步的内容是二进制日志，它是一个文件，在进行网络传输的过程中就一定会存在延迟（比如 500ms），这样就可能造成用户在从库上读取的数据不是最新的数据，也就是主从同步中的数据不一致性问题。比如我们对一条记录进行更新，这个操作是在主库上完成的，而在很短的时间内（比如 100ms）又对同一个记录进行了读取，这时候从库还没有完成数据的更新，那么我们通过从库读到的数据就是一条旧的记录。

这种情况下该怎么办呢？

如何解决主从同步的数据一致性问题

可以想象下，如果我们想要操作的数据都存储在同一个数据库中，那么对数据进行更新的时候，可以对记录加写锁，这样在读取的时候就不会发生数据不一致的情况，但这时从库的作用就是备份，并没有起到读写分离，分担主库读压力的作用。

因此我们还需要继续想办法，在进行读写分离的同时，解决主从同步中数据不一致的问题，也就是解决主从之间数据复制方式的问题，如果按照数据一致性从弱到强来进行划分，有以下 3 种复制方式。

方法 1：异步复制

异步模式就是客户端提交 COMMIT 之后不需要等从库返回任何结果，而是直接将结果返回给客户端，这样做的好处是不会影响主库写的效率，但可能会存在主库宕机，而 Binlog 还没有同步到从库的情况，也就是此时的主库和从库数据不一致。这时候从从库中选择一个作为新主，那么新主则可能缺少原来主服务器中已提交的事务。所以，这种复制模式下的数据一致性是最弱的。

方法 2：半同步复制

MySQL5.5 版本之后开始支持半同步复制的方式。原理是在客户端提交 COMMIT 之后不直接将结果返回给客户端，而是等待至少有一个从库接收到了 Binlog，并且写入到中继日志中，再返回给客户端。这样做的好处就是提高了数据的一致性，当然相比于异步复制来说，至少多增加了一个网络连接的延迟，降低了主库写的效率。

在 MySQL5.7 版本中还增加了一个rpl_semi_sync_master_wait_for_slave_count参数，我们可以对应答的从库数量进行设置，默认为 1，也就是说只要有 1 个从库进行了响应，就可以返回给客户端。如果将这个参数调大，可以提升数据一致性的强度，但也会增加主库等待从库响应的时间。

方法 3：组复制

组复制技术，简称 MGR（MySQL Group Replication）。是 MySQL 在 5.7.17 版本中推出的一种新的数据复制技术，这种复制技术是基于 Paxos 协议的状态机复制。

我刚才介绍的异步复制和半同步复制都无法最终保证数据的一致性问题，半同步复制是通过判断从库响应的个数来决定是否返回给客户端，虽然数据一致性相比于异步复制有提升，但仍然无法满足对数据一致性要求高的场景，比如金融领域。MGR 很好地弥补了这两种复制模式的不足。

下面我们来看下 MGR 是如何工作的（如下图所示）。

首先我们将多个节点共同组成一个复制组，在执行读写（RW）事务的时候，需要通过一致性协议层（Consensus 层）的同意，也就是读写事务想要进行提交，必须要经过组里“大多数人”（对应 Node 节点）的同意，大多数指的是同意的节点数量需要大于（N/2+1），这样才可以进行提交，而不是原发起方一个说了算。而针对只读（RO）事务则不需要经过组内同意，直接 COMMIT 即可。

在一个复制组内有多个节点组成，它们各自维护了自己的数据副本，并且在一致性协议层实现了原子消息和全局有序消息，从而保证组内数据的一致性。（具体原理点击这里可以参考。）

MGR 将 MySQL 带入了数据强一致性的时代，是一个划时代的创新，其中一个重要的原因就是 MGR 是基于 Paxos 协议的。Paxos 算法是由 2013 年的图灵奖获得者 Leslie Lamport 于 1990 年提出的，有关这个算法的决策机制你可以去网上搜一下。或者点击这里查看具体的算法，另外作者在 2001 年发布了一篇简化版的文章，你如果感兴趣的话，也可以看下。

事实上，Paxos 算法提出来之后就作为分布式一致性算法被广泛应用，比如 Apache 的 ZooKeeper 也是基于 Paxos 实现的。

总结

我今天讲解了数据库的主从同步，如果你的目标仅仅是数据库的高并发，那么可以先从 SQL 优化，索引以及 Redis 缓存数据库这些方面来考虑优化，然后再考虑是否采用主从架构的方式。

在主从架构的配置中，如果想要采取读写分离的策略，我们可以自己编写程序，也可以通过第三方的中间件来实现。

自己编写程序的好处就在于比较自主，我们可以自己判断哪些查询在从库上来执行，针对实时性要求高的需求，我们还可以考虑哪些查询可以在主库上执行。同时，程序直接连接数据库，减少了中间件层，相当于减少了性能损耗。

采用中间件的方法有很明显的优势，功能强大，使用简单。但因为在客户端和数据库之间增加了中间件层会有一些性能损耗，同时商业中间件也是有使用成本的。我们也可以考虑采取一些优秀的开源工具，比如 MaxScale。它是 MariaDB 开发的 MySQL 数据中间件。比如在下图中，使用 MaxScale 作为数据库的代理，通过路由转发完成了读写分离。同时我们也可以使用 MHA 工具作为强一致的主从切换工具，从而完成 MySQL 的高可用架构。

今天讲的概念有点多，你能说一下主从复制、读写分离、负载均衡的概念吗？另外你不妨用自己的话说一下你对 MGR 的理解。

欢迎你在评论区写下你的思考，也欢迎把这篇文章分享给你的朋友或者同事，一起交流一下。

36丨数据库没有备份，没有使用Binlog的情况下，如何恢复数据？

我们上节课讲解了 MySQL 的复制技术，通过主从同步可以实现读写分离，热备份，让服务器更加高可用。MySQL 的复制主要是通过 Binlog 来完成的，Binlog 记录了数据库更新的事件，从库 I/O 线程会向主库发送 Binlog 更新的请求，同时主库二进制转储线程会发送 Binlog 给从库作为中继日志进行保存，然后从库会通过中继日志重放，完成数据库的同步更新。这种同步操作是近乎实时的同步，然而也有人为误操作情况的发生，比如 DBA 人员为了方便直接在生产环境中对数据进行操作，或者忘记了当前是在开发环境，还是在生产环境中，就直接对数据库进行操作，这样很有可能会造成数据的丢失，情况严重时，误操作还有可能同步给从库实时更新。不过我们依然有一些策略可以防止这种误操作，比如利用延迟备份的机制。延迟备份最大的作用就是避免这种“手抖”的情况，让我们在延迟从库进行误操作前停止下来，进行数据库的恢复。

当然如果我们对数据库做过时间点备份，也可以直接恢复到该时间点。不过我们今天要讨论的是一个特殊的情况，也就是在没做数据库备份，没有开启使用 Binlog 的情况下，尽可能地找回数据。

今天的内容主要包括以下几个部分：

InnoDB 存储引擎中的表空间是怎样的？两种表空间存储方式各有哪些优缺点？

如果.ibd 文件损坏了，数据该如何找回？

如何模拟 InnoDB 文件的损坏与数据恢复？

InnoDB 存储引擎的表空间

InnoDB 存储引擎的文件格式是.ibd 文件，数据会按照表空间（tablespace）进行存储，分为共享表空间和独立表空间。如果想要查看表空间的存储方式，我们可以对innodb_file_per_table变量进行查询，使用show variables like 'innodb_file_per_table';。ON 表示独立表空间，而 OFF 则表示共享表空间。

如果采用共享表空间的模式，InnoDB 存储的表数据都会放到共享表空间中，也就是多个数据表共用一个表空间，同时表空间也会自动分成多个文件存放到磁盘上。这样做的好处在于单个数据表的大小可以突破文件系统大小的限制，最大可以达到 64TB，也就是 InnoDB 存储引擎表空间的上限。不足也很明显，多个数据表存放到一起，结构不清晰，不利于数据的找回，同时将所有数据和索引都存放到一个文件中，也会使得共享表空间的文件很大。

采用独立表空间的方式可以让每个数据表都有自己的物理文件，也就是 table_name.ibd 的文件，在这个文件中保存了数据表中的数据、索引、表的内部数据字典等信息。它的优势在于每张表都相互独立，不会影响到其他数据表，存储结构清晰，利于数据恢复，同时数据表还可以在不同的数据库之间进行迁移。

如果.ibd 文件损坏了，数据如何找回

如果我们之前没有做过全量备份，也没有开启 Binlog，那么我们还可以通过.ibd 文件进行数据恢复，采用独立表空间的方式可以很方便地对数据库进行迁移和分析。如果我们误删除（DELETE）某个数据表或者某些数据行，也可以采用第三方工具回数据。

我们这里可以使用 Percona Data Recovery Tool for InnoDB 工具，能使用工具进行修复是因为我们在使用 DELETE 的时候是逻辑删除。我们之前学习过 InnoDB 的页结构，在保存数据行的时候还有个删除标记位，对应的是页结构中的 delete_mask 属性，该属性为 1 的时候标记了记录已经被逻辑删除，实际上并不是真的删除。不过当有新的记录插入的时候，被删除的行记录可能会被覆盖掉。所以当我们发生了 DELETE 误删除的时候，一定要第一时间停止对误删除的表进行更新和写入，及时将.ibd 文件拷贝出来并进行修复。

如果已经开启了 Binlog，就可以使用闪回工具，比如 mysqlbinlog 或者 binlog2sql，从工具名称中也能看出来它们都是基于 Binlog 来做的闪回。原理就是因为 Binlog 文件本身保存了数据库更新的事件（Event），通过这些事件可以帮我们重现数据库的所有更新变化，也就是 Binlog 回滚。

下面我们就来看下没有做过备份，也没有开启 Binlog 的情况下，如果.ibd 文件发生了损坏，如何通过数据库自身的机制来进行数据恢复。

实际上，InnoDB 是有自动恢复机制的，如果发生了意外，InnoDB 可以在读取数据表时自动修复错误。但有时候.ibd 文件损坏了，会导致数据库无法正常读取数据表，这时我们就需要人工介入，调整一个参数，这个参数叫做innodb_force_recovery。

我们可以通过命令show variables like 'innodb_force_recovery';来查看当前参数的状态，你能看到默认为 0，表示不进行强制恢复。如果遇到错误，比如 ibd 文件中的数据页发生损坏，则无法读取数据，会发生 MySQL 宕机的情况，此时会将错误日志记录下来。

innodb_force_recovery参数一共有 7 种状态，除了默认的 0 以外，还可以为 1-6 的取值，分别代表不同的强制恢复措施。

当我们需要强制恢复的时候，可以将innodb_force_recovery设置为 1，表示即使发现了损坏页也可以继续让服务运行，这样我们就可以读取数据表，并且对当前损坏的数据表进行分析和备份。

通常innodb_force_recovery参数设置为 1，只要能正常读取数据表即可。但如果参数设置为 1 之后还无法读取数据表，我们可以将参数逐一增加，比如 2、3 等。一般来说不需要将参数设置到 4 或以上，因为这有可能对数据文件造成永久破坏。另外当innodb_force_recovery设置为大于 0 时，相当于对 InnoDB 进行了写保护，只能进行 SELECT 读取操作，还是有限制的读取，对于 WHERE 条件以及 ORDER BY 都无法进行操作。

当我们开启了强制恢复之后，数据库的功能会受到很多限制，我们需要尽快把有问题的数据表备份出来，完成数据恢复操作。整体的恢复步骤可以按照下面的思路进行：

1. 使用innodb_force_recovery启动服务器

将innodb_force_recovery参数设置为 1，启动数据库。如果数据表不能正常读取，需要调大参数直到能读取数据为止。通常设置为 1 即可。

2. 备份数据表

在备份数据之前，需要准备一个新的数据表，这里需要使用 MyISAM 存储引擎。原因很简单，InnoDB 存储引擎已经写保护了，无法将数据备份出来。然后将损坏的 InnoDB 数据表备份到新的 MyISAM 数据表中。

3. 删除旧表，改名新表

数据备份完成之后，我们可以删除掉原有损坏的 InnoDB 数据表，然后将新表进行改名。

4. 关闭innodb_force_recovery，并重启数据库

innodb_force_recovery大于 1 的时候会有很多限制，我们需要将该功能关闭，然后重启数据库，并且将数据表的 MyISAM 存储引擎更新为 InnoDB 存储引擎。

InnoDB 文件的损坏与恢复实例

我们刚才说了 InnoDB 文件损坏时的人工操作过程，下面我们用一个例子来模拟下。

生成 InnoDB 数据表

为了简便，我们创建一个数据表 t1，只有 id 一个字段，类型为 int。使用命令create table t1(id int);即可。

然后创建一个存储过程帮我们生成一些数据：

BEGIN

-- 当前数据行

DECLARE i INT DEFAULT 0;

-- 最大数据行数

DECLARE max_num INT DEFAULT 100;

-- 关闭自动提交

SET autocommit=0;

REPEAT

SET i=i+1;

-- 向 t1 表中插入数据

INSERT INTO t1(id) VALUES(i);

UNTIL i = max_num

END REPEAT;

-- 提交事务

COMMIT;

END

然后我们运行call insert_t1()，这个存储过程帮我们插入了 100 条数据，这样我们就有了 t1.ibd 这个文件。

模拟损坏.ibd 文件

实际工作中我们可能会遇到各种各样的情况，比如.ibd 文件损坏等，如果遇到了数据文件的损坏，MySQL 是无法正常读取的。在模拟损坏.ibd 文件之前，我们需要先关闭掉 MySQL 服务，然后用编辑器打开 t1.ibd，类似下图所示：

文件是有二进制编码的，看不懂没有关系，我们只需要破坏其中的一些内容即可，比如我在 t1.ibd 文件中删除了 2 行内容（文件大部分内容为 0，我们在文件中间部分找到一些非 0 的取值，然后删除其中的两行：4284 行与 4285 行，原 ibd 文件和损坏后的 ibd 文件见GitHub地址。其中 t1.ibd 为创建的原始数据文件,t1- 损坏.ibd 为损坏后的数据文件，你需要自己创建 t1 数据表，然后将 t1- 损坏.ibd 拷贝到本地，并改名为 t1.ibd）。

然后我们保存文件，这时.ibd 文件发生了损坏，如果我们没有打开innodb_force_recovery，那么数据文件无法正常读取。为了能读取到数据表中的数据，我们需要修改 MySQL 的配置文件，找到[mysqld]的位置，然后再下面增加一行innodb_force_recovery=1。

备份数据表

当我们设置innodb_force_recovery参数为 1 的时候，可以读取到数据表 t1 中的数据，但是数据不全。我们使用SELECT * FROM t1 LIMIT 10;读取当前前 10 条数据。

但是如果我们想要完整的数据，使用SELECT * FROM t1 LIMIT 100;就会发生如下错误。

这是因为读取的部分包含了已损坏的数据页，我们可以采用二分查找判断数据页损坏的位置。这里我们通过实验，可以得出只有最后一个记录行收到了损坏，而前 99 条记录都可以正确读出（具体实验过程省略）。

这样我们就能判断出来有效的数据行的位置，从而将它们备份出来。首先我们创建一个相同的表结构 t2，存储引擎设置为 MyISAM。我刚才讲过这里使用 MyISAM 存储引擎是因为在innodb_force_recovery=1的情况下，无法对 innodb 数据表进行写数据。使用命令CREATE TABLE t2(id int) ENGINE=MyISAM;。

然后我们将数据表 t1 中的前 99 行数据复制给 t2 数据表，使用：

INSERT INTO t2 SELECT * FROM t1 LIMIT 99;

我们刚才讲过在分析 t1 数据表的时候无法使用 WHERE 以及 ORDER BY 等子句，这里我们可以实验一下，如果想要查询 id<10 的数据行都有哪些，那么会发生如下错误。原因是损坏的数据页无法进行条件判断。

删除旧表，改名新表

刚才我们已经恢复了大部分的数据。虽然还有一行记录没有恢复，但是能找到绝大部分的数据也是好的。然后我们就需要把之前旧的数据表删除掉，使用DROP TABLE t1;。

更新表名，将数据表名称由 t2 改成 t1，使用RENAME TABLE t2 to t1;。

将新的数据表 t1 存储引擎改成 InnoDB，不过直接修改的话，会报如下错误：

关闭innodb_force_recovery，并重启数据库

因为上面报错，所以我们需要将 MySQL 配置文件中的innodb_force_recovery=1删除掉，然后重启数据库。最后将 t1 的存储引擎改成 InnoDB 即可，使用ALTER TABLE t1 engine = InnoDB;。

总结

我们刚才人工恢复了损坏的 ibd 文件中的数据，虽然没有 100% 找回，但是相比于束手无措来说，已经是不幸中的万幸，至少我们还可以把正确的数据页中的记录成功备份出来，尽可能恢复原有的数据表。在这个过程中相信你应该对 ibd 文件，以及 InnoDB 自身的强制恢复（Force Recovery）机制有更深的了解。

数据表损坏，以及人为的误删除都不是我们想要看到的情况，但是我们不能指望运气，或者说我们不能祈祷这些事情不会发生。在遇到这些情况的时候，应该通过机制尽量保证数据库的安全稳定运行。这个过程最主要的就是应该及时备份，并且开启二进制日志，这样当有误操作的时候就可以通过数据库备份以及 Binlog 日志来完成数据恢复。同时采用延迟备份的策略也可以尽量抵御误操作。总之，及时备份是非常有必要的措施，同时我们还需要定时验证备份文件的有效性，保证备份文件可以正常使用。

如果你遇到了数据库 ibd 文件损坏的情况，并且没有采用任何的备份策略，可以尝试使用 InnoDB 的强制恢复机制，启动 MySQL 并且将损坏的数据表转储到 MyISAM 数据表中，尽可能恢复已有的数据。总之机制比人为更靠谱，我们要为长期的运营做好充足的准备。一旦发生了误操作这种紧急情况，不要慌张，及时采取对应的措施才是最重要的。

今天的内容到这里就结束了，我想问问，在日常工作中，你是否遇到过误操作的情况呢？你又是如何解决的？除了我上面介绍的机制外，还有哪些备份的机制可以增强数据的安全性？

欢迎你在评论区写下你的思考，也欢迎把这篇文章分享给你的朋友或者同事，一起交流一下。

37丨SQL注入：你的SQL是如何被注入的？

我们之前已经讲解了 SQL 的使用及优化，正常的 SQL 调用可以帮我们从数据库中获取想要的数据，然而我们构建的 Web 应用是个应用程序，本身也可能存在安全漏洞，如果不加以注意，就会出现 Web 安全的隐患，比如通过非正常的方式注入 SQL。

在过去的几年中，我们也能经常看到用户信息被泄露，出现这种情况，很大程度上和 SQL 注入有关。所以了解 SQL 注入的原理以及防范还是非常有必要的。

今天我们就通过一个简单的练习看下 SQL 注入的过程是怎样的，内容主要包括以下几个部分：

SQL 注入的原理。为什么用户可以通过 URL 请求或者提交 Web 表单的方式提交非法 SQL 命令，从而访问数据库？

如何使用 sqli-labs 注入平台进行第一个 SQL 注入实验？

如何使用 SQLmap 完成 SQL 注入检测？

SQL 注入的原理

SQL 注入也叫作 SQL Injection，它指的是将非法的 SQL 命令插入到 URL 或者 Web 表单中进行请求，而这些请求被服务器认为是正常的 SQL 语句从而进行执行。也就是说，如果我们想要进行 SQL 注入，可以将想要执行的 SQL 代码隐藏在输入的信息中，而机器无法识别出来这些内容是用户信息，还是 SQL 代码，在后台处理过程中，这些输入的 SQL 语句会显现出来并执行，从而导致数据泄露，甚至被更改或删除。

为什么我们可以将 SQL 语句隐藏在输入的信息中呢？这里举一个简单的例子。

比如下面的 PHP 代码将浏览器发送过来的 URL 请求，通过 GET 方式获取 ID 参数，赋值给 $id 变量，然后通过字符串拼接的方式组成了 SQL 语句。这里我们没有对传入的 ID 参数做校验，而是采用了直接拼接的方式，这样就可能产生 SQL 注入。

$id=$_GET['id'];

$sql="SELECT * FROM users WHERE id='$id' LIMIT 0,1";

$result=mysql_query($sql);

$row = mysql_fetch_array($result);

如果我们在 URL 中的?id= 后面输入’ or 1=1 --+，那么 SQL 语句就变成了下面这样：

SELECT * FROM users WHERE id='' or 1=1 -- LIMIT 0,1

其中我们输入的（+）在浏览器 URL 中相当于空格，而输入的（–）在 SQL 中表示注释语句，它会将后面的 SQL 内容都注释掉，这样整个 SQL 就相当于是从 users 表中获取全部的数据。然后我们使用 mysql_fetch_array 从结果中获取一条记录，这时即使 ID 输入不正确也没有关系，同样可以获取数据表中的第一行记录。

一个 SQL 注入的实例

通常我们希望通过 SQL 注入可以获取更多的信息，比如数据库的名称、数据表名称和字段名等。下面我们通过一个简单的 SQL 实例来操作一下。

搭建 sqli-labs 注入环境

首先我们需要搭建 sqli-labs 注入环境，在这个项目中，我们会面临 75 个 SQL 注入的挑战，你可以像游戏闯关一样对 SQL 注入的原理进行学习。

下面的步骤是关于如何在本地搭建 sqli-labs 注入环境的，成功搭建好的环境类似链接里展现的。

第一步，下载 sqli-labs。

sqli-labs 是一个开源的 SQL 注入平台，你可以从GitHub上下载它。

第二步，配置 PHP、Apache 环境（可以使用 phpStudy 工具）。

运行 sqli-labs 需要 PHP、Apache 环境，如果你之前没有安装过它们，可以直接使用 phpStudy 这个工具，它不仅集成了 PHP、Apache 和 MySQL，还可以方便地指定 PHP 的版本。在今天的项目中，我使用的是 PHP5.4.45 版本。

第三步，配置 sqli-labs 及 MySQL 参数。

首先我们需要给 sqli-labs 指定需要访问的数据库账户密码，对应sqli-labs-master\sql-connections\db-creds.inc文件，这里我们需要修改$dbpass参数，改成自己的 MySQL 的密码。

此时我们访问本地的sqli-labs项目http://localhost/sqli-labs-master/出现如下页面，需要先启动数据库，选择Setup/reset Database for labs即可。

如果此时提示数据库连接错误，可能需要我们手动修改 MySQL 的配置文件，需要调整的参数如下所示（修改 MySQL 密码验证方式为使用明文，同时设置 MySQL 默认的编码方式）：

[client]

default-character-set=utf8

[mysql]

default-character-set=utf8

[mysqld]

character-set-server = utf8

default_authentication_plugin = mysql_native_password

第一个 SQL 注入挑战

在我们成功对 sqli-labs 进行了配置，现在可以进入到第一关挑战环节。访问本地的http://localhost/sqli-labs-master/Less-1/页面，如下所示：

我们可以在 URL 后面加上 ID 参数，获取指定 ID 的信息，比如http://localhost/sqli-labs-master/Less-1/?id=1。

这些都是正常的访问请求，现在我们可以通过 1 or 1=1 来判断 ID 参数的查询类型，访问http://localhost/sqli-labs-master/Less-1/?id=1 or 1=1。

你可以看到依然可以正常访问，证明 ID 参数不是数值查询，然后我们在 1 后面增加个单引号，来查看下返回结果，访问http://localhost/sqli-labs-master/Less-1/?id=1'。

这时数据库报错，并且在页面上返回了错误信息：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 ‘‘1’’ LIMIT 0,1’ at line 1。

我们对这个错误进行分析，首先''1'' LIMIT 0,1'这个语句，我们去掉最外层的单引号，得到'1'' LIMIT 0,1，因为我们输入的参数是1'，继续去掉1'，得到'' LIMIT 0,1。这样我们就能判断出后台的 SQL 语句，类似于下面这样：

$sql="SELECT ... FROM ... WHERE id='$id' LIMIT 0,1";

两处省略号的地方分别代表 SELECT 语句中的字段名和数据表名称。

判断查询语句的字段数

现在我们已经对后台的 SQL 查询已经有了大致的判断，它是通过字符串拼接完成的 SQL 查询。现在我们再来判断下这个查询语句中的字段个数，通常可以在输入的查询内容后面加上 ORDER BY X，这里 X 是我们估计的字段个数。如果 X 数值大于 SELECT 查询的字段数，则会报错。根据这个原理，我们可以尝试通过不同的 X 来判断 SELECT 查询的字段个数，这里我们通过下面两个 URL 可以判断出来，SELECT 查询的字段数为 3 个：

报错：

http://localhost/sqli-labs-master/Less-1/?id=1' order by 4 --+

正确：

http://localhost/sqli-labs-master/Less-1/?id=1' order by 3 --+

获取当前数据库和用户信息

下面我们通过 SQL 注入来获取想要的信息，比如想要获取当前数据库和用户信息。

这里我们使用 UNION 操作符。在 MySQL 中，UNION 操作符前后两个 SELECT 语句的查询结构必须一致。刚才我们已经通过实验，判断出查询语句的字段个数为 3，因此在构造 UNION 后面的查询语句时也需要查询 3 个字段。这里我们可以使用：SELECT 1,database(),user()，也就是使用默认值 1 来作为第一个字段，整个 URL 为：http://localhost/sqli-labs-master/Less-1/?id=' union select 1,database(),user() --+。

页面中显示的security即为当前的数据库名称，root@localhost为当前的用户信息。

获取 MySQL 中的所有数据库名称

我们还想知道当前 MySQL 中所有的数据库名称都有哪些，数据库名称数量肯定会大于 1，因此这里我们需要使用GROUP_CONCAT函数，这个函数可以将GROUP BY产生的同一个分组中的值连接起来，并以字符串形式返回。

具体使用如下：

http://localhost/sqli-labs-master/Less-1/?id=' union select 1,2,(SELECT GROUP_CONCAT(schema_name) FROM information_schema.schemata)--+

这样我们就可以把多个数据库名称拼接在一起，作为字段 3 返回给页面。

你能看到这里我使用到了 MySQL 中的information_schema数据库，这个数据库是 MySQL 自带的数据库，用来存储数据库的基本信息，比如数据库名称、数据表名称、列的数据类型和访问权限等。我们可以通过访问information_schema数据库，获得更多数据库的信息。

查询 wucai 数据库中所有数据表

在上面的实验中，我们已经得到了 MySQL 中所有的数据库名称，这里我们能看到 wucai 这个数据库。如果我们想要看 wucai 这个数据库中都有哪些数据表，可以使用：

http://localhost/sqli-labs-master/Less-1/?id=' UNION SELECT 1,2,(SELECT GROUP_CONCAT(table_name) FROM information_schema.tables WHERE table_schema='wucai') --+

这里我们同样将数据表名称使用 GROUP_CONCAT 函数拼接起来，作为字段 3 进行返回。

查询 heros 数据表中所有字段名称

在上面的实验中，我们从 wucai 数据库中找到了熟悉的数据表 heros，现在就来通过 information_schema 来查询下 heros 数据表都有哪些字段，使用下面的命令即可：

http://localhost/sqli-labs-master/Less-1/?id=' UNION SELECT 1,2,(SELECT GROUP_CONCAT(column_name) FROM information_schema.columns WHERE table_name='heros') --+

这里会将字段使用 GROUP_CONCAT 函数进行拼接，并将结果作为字段 3 进行返回，返回的结果如下所示：

attack_growth,attack_max,attack_range,attack_speed_max,attack_start,birthdate,defense_growth,defense_max,defense_start,hp_5s_growth,hp_5s_max,hp_5s_start,hp_growth,hp_max,hp_start,id,mp_5s_growth,mp_5s_max,mp_5s_start,mp_growth,mp_max,mp_start,name,role_assist,role_main

使用 SQLmap 工具进行 SQL 注入检测

经过上面的实验你能体会到，如果我们编写的代码存在着 SQL 注入的漏洞，后果还是很可怕的。通过访问information_schema就可以将数据库的信息暴露出来。

了解到如何完成注入 SQL 后，我们再来了解下 SQL 注入的检测工具，它可以帮我们自动化完成 SQL 注入的过程，这里我们使用的是 SQLmap 工具。

下面我们使用 SQLmap 再模拟一遍刚才人工 SQL 注入的步骤。

获取当前数据库和用户信息

我们使用sqlmap -u来指定注入测试的 URL，使用--current-db来获取当前的数据库名称，使用--current-user获取当前的用户信息，具体命令如下：

python sqlmap.py -u "http://localhost/sqli-labs-master/Less-1/?id=1" --current-db --current-user

然后你能看到 SQLmap 帮我们获取了相应的结果：

获取 MySQL 中的所有数据库名称

我们可以使用--dbs来获取 DBMS 中所有的数据库名称，这里我们使用--threads参数来指定 SQLmap 最大并发数，设置为 5，通常该参数不要超过 10，具体命令为下面这样：

python sqlmap.py -u "http://localhost/sqli-labs-master/Less-1/?id=1" --threads=5 --dbs

同样 SQLmap 帮我们获取了 MySQL 中存在的 8 个数据库名称：

查询 wucai 数据库中所有数据表

当我们知道 DBMS 中存在的某个数据库名称时，可以使用 -D 参数对数据库进行指定，然后使用--tables参数显示出所有的数据表名称。比如我们想要查看 wucai 数据库中都有哪些数据表，使用：

python sqlmap.py -u "http://localhost/sqli-labs-master/Less-1/?id=1" --threads=5 -D wucai --tables

查询 heros 数据表中所有字段名称

我们也可以对指定的数据表，比如 heros 表进行所有字段名称的查询，使用-D指定数据库名称，-T指定数据表名称，--columns对所有字段名称进行查询，命令如下：

python sqlmap.py -u "http://localhost/sqli-labs-master/Less-1/?id=1" --threads=5 -D wucai -T heros --columns

查询 heros 数据表中的英雄信息

当我们了解了数据表中的字段之后，就可以对指定字段进行查询，使用-C参数进行指定。比如我们想要查询 heros 数据表中的id、name和hp_max字段的取值，这里我们不采用多线程的方式，具体命令如下：

python sqlmap.py -u "http://localhost/sqli-labs-master/Less-1/?id=1" -D wucai -T heros -C id,name,hp_max --dump

完整的结果一共包括 69 个英雄信息都显示出来了，这里我只截取了部分的英雄结果。

总结

在今天的内容中，我使用了 sqli-labs 注入平台作为实验数据，使用了 SQLmap 工具自动完成 SQL 注入。SQL 注入的方法还有很多，我们今天讲解的只是其中一个方式。你如果对 SQL 注入感兴趣，也可以对 sqli-labs 中其他例子进行学习，了解更多 SQL 注入的方法。

在这个过程中，最主要的是理解 SQL 注入的原理。在日常工作中，我们需要对用户提交的内容进行验证，以防止 SQL 注入。当然很多时候我们都在使用编程框架，这些框架已经极大地降低了 SQL 注入的风险，但是只要有 SQL 拼接的地方，这种风险就可能存在。

总之，代码规范性对于 Web 安全来说非常重要，尽量不要采用直接拼接的方式进行查询。同时在 Web 上线之后，还需要将生产环境中的错误提示信息关闭，以减少被 SQL 注入的风险。此外我们也可以采用第三方的工具，比如 SQLmap 来对 Web 应用进行检测，以增强 Web 安全性。

你不妨思考下，为什么开发人员的代码规范对于 Web 安全来说非常重要？以及都有哪些方式可以防止 SQL 注入呢？

欢迎你在评论区写下你的思考，也欢迎把这篇文章分享给你的朋友或者同事，一起交流一下。

35丨如何在Excel中使用SQL语言？

在进阶篇中，我们对设计范式、索引、页结构、事务以及查询优化器的原理进行了学习，了解这些可以让我们更好地使用 SQL 来操作 RDBMS。实际上 SQL 的影响力远不止于此，在数据的世界里，SQL 更像是一门通用的语言，虽然每种工具都会有一些自己的“方言”，但是掌握 SQL 可以让我们接触其它以数据为核心的工具时，更加游刃有余。

比如 Excel。

你一定使用过 Excel，事实上，Excel 的某些部分同样支持我们使用 SQL 语言，那么具体该如何操作呢？

今天的课程主要包括以下几方面的内容：

如何在 Excel 中获取外部数据源？

数据透视表和数据透视图是 Excel 的两个重要功能，如何通过 SQL 查询在 Excel 中完成数据透视表和透视图？

如何让 Excel 与 MySQL 进行数据交互？

如何在 Excel 中获取外部数据源？

使用 SQL 查询数据，首先需要数据源。如果我们用 Excel 来呈现这些数据的话，就需要先从外部导入数据源。这里介绍两种直接导入的方式：

通过 OLE DB 接口获取外部数据源；

通过 Microsoft Query 导入外部数据源。

下面我们通过导入数据源 heros.xlsx 体验一下这两种方式，你可以从这里下载数据源。

通过 OLE DB 接口获取外部数据源

OLE 的英文是 Object Link and Embedding，中文意思是对象连接与嵌入，它是一种面向对象的技术。DB 代表的就是数据库。OLE DB 的作用就是通向不同的数据源的程序接口，方便获取外部数据，这里不仅包括 ODBC，也包括其他非 SQL 数据类型的通路，你可以把 OLE DB 的作用理解成通过统一的接口来访问不同的数据源。

如果你想要在 Excel 中通过 OLE DB 接口导入数据，需要执行下面的步骤：

第一步，选择指定的文件。方法是通过“数据” → “现有连接”按钮选择连接。这里选择“浏览更多”，然后选择指定的 xls 文件。

第二步，选择指定的表格，勾选数据首行包含列标题，目的是将第一行的列名也加载进来。

第三步，通过“属性” → “定义”中的命令文本来使用 SQL 查询，选择我们想要的数据，也可以将整张表直接导入到指定的位置。

如果我们显示方式为“表”，导入全部的数据到指定的 A1（代表 A1 单元格），那么在 Excel 中就可以导入整个数据表，如下图所示：

通过 Microsoft Query 获取外部数据源

第二种方式是利用 Microsoft Query 功能导入外部数据源，具体步骤如下：

第一步，选择指定的文件。方法是通过“数据” → “获取外部数据”按钮选择数据库，这里我选择了“Excel Files”，然后选择我们想要导入的 xls 文件。

第二步。选择可用的表和列，在左侧面板中勾选我们想要导入的数据表及相应的列，点击 （>） 按钮导入到右侧的面板中，然后点击下一步。

最后我们可以选择“将数据返回 Microsoft Excel”还是“在 Microsoft Query 中查看数据或编辑查询”。这里我们选择第一个选项。

当我们选择“将数据返回到 Microsoft Excel”后，接下来的操作和使用 OLE DB 接口方式导入数据一样，可以对显示方式以及属性进行调整：

这里，我们同样选择显示方式为“表”，导入全部的数据到指定的 A1(代表 A1 单元格），同样会看到如下的结果：

使用数据透视表和数据透视图做分析

通过上面的操作你也能看出来，从外部导入数据并不难，关键在于通过 SQL 控制想要的结果集，这里我们需要使用到 Excel 的数据透视表以及数据透视图的功能。

我简单介绍下数据透视表和数据透视图：

数据透视表可以快速汇总大量数据，帮助我们统计和分析数据，比如求和，计数，查看数据中的对比情况和趋势等。数据透视图则可以对数据透视表中的汇总数据进行可视化，方便我们直观地查看数据的对比与趋势等。

假设我想对主要角色（role_main）的英雄数据进行统计，分析他们平均的最大生命值（hp_max），平均的最大法力值 (mp_max)，平均的最大攻击值 (attack_max)，那么对应的 SQL 查询为：

SELECT role_main, avg(hp_max) AS `平均最大生命`, avg(mp_max) AS `平均最大法力`, avg(attack_max) AS `平均最大攻击力`, count(*) AS num FROM heros GROUP BY role_main

使用 SQL+ 数据透视表

现在我们使用 SQL 查询，通过 OLE DB 的方式来完成数据透视表。我们在第三步的时候选择“属性”，并且在命令文本中输入相应的 SQL 语句，注意这里的数据表是 [heros$]，对应的命令文本为：

SELECT role_main, avg(hp_max) AS `平均最大生命`, avg(mp_max) AS `平均最大法力`, avg(attack_max) AS `平均最大攻击力`, count(*) AS num FROM [heros$] GROUP BY role_main

然后我们在右侧面板中选择“数据透视表字段”，以便对数据透视表中的字段进行管理，比如我们勾选 num，role_main，平均最大生命，平均最大法力，平均最大攻击力。

最后会在 Excel 中呈现如下的数据透视表：

操作视频如下：

使用 SQL+ 数据透视图

数据透视图可以呈现可视化的形式，方便我们直观地了解数据的特征。这里我们使用 SQL 查询，通过 Microsoft Query 的方式来完成数据透视图。我们在第三步的时候选择在 Microsoft Query 中查看数据或编辑查询，来看下 Microsoft Query 的界面：

然后我们点击“SQL”按钮，可以对 SQL 语句进行编辑，筛选我们想要的结果集，可以得到：

然后选择“将数据返回 Microsoft Excel”，在返回时选择“数据透视图”，然后在右侧选择数据透视图的字段，就可以得到下面这张图：

你可以看到使用起来还是很方便。

具体操作视频如下：

让 Excel 与 MySQL 进行数据交互

刚才我们讲解的是如何从 Excel 中导入外部的 xls 文件数据，并在 Excel 实现数据透视表和数据透视图的呈现。实际上，Excel 也可以与 MySQL 进行数据交互，这里我们需要使用到 MySQL for Excel 插件：

下载 mysql-for-excel 并安装，地址：https://dev.mysql.com/downloads/windows/excel/

下载 mysql-connector-odbc 并安装，地址：https://dev.mysql.com/downloads/connector/odbc/

这次我们的任务是给数据表增加一个 last_name 字段，并且使用 Excel 的自动填充功能来填充好英雄的姓氏。

第一步，连接 MySQL。打开一个新的 Excel 文件的时候，会在“数据”面板中看到 MySQL for Excel 的插件，点击后可以打开 MySQL 的连接界面，如下：

第二步，导入 heros 数据表。输入密码后，我们在右侧选择想要的数据表 heros，然后选择 Import MySQL Data 导入数据表的导入，结果如下：

第三步，创建 last_name 字段，使用 Excel 的自动填充功能来进行姓氏的填写（Excel 自带的“自动填充”可以帮我们智能填充一些数据），完成之后如下图所示：

第四步，将修改好的 Excel 表导入到 MySQL 中，创建一个新表 heros_xls。选中整个数据表（包括数据行及列名），然后在右侧选择“Export Excel Data to New Table”。这时在 MySQL 中你就能看到相应的数据表 heros_xls 了，我们在 MySQL 中使用 SQL 进行查询：

mysql > SELECT * FROM heros_xls

运行结果（69 条记录）：

需要说明的是，有时候自动填充功能并不完全准确，我们还需要对某些数据行的 last_name 进行修改，比如“夏侯惇”的姓氏应该改成“夏侯”，“百里守约”改成“百里”等。

总结

我们今天讲解了如何在 Excel 中使用 SQL 进行查询，在这个过程中你应该对”SQL 定义了查询的标准“更有体会。SQL 使得各种工具可以遵守 SQL 语言的标准（当然也有各自的方言）。

如果你已经是个 SQL 高手，你会发现原来 SQL 和 Excel 还可以如此“亲密”。Excel 作为使用人数非常多的办公软件，提供了 SQL 查询会让我们操作起来非常方便。如果你还没有使用过 Excel 的这些功能，那么就赶快来用一下吧。

SQL 作为一门结构化查询语言，具有很好的通用性，你还在其他工具中使用过 SQL 语言吗？如果有的话可以分享一下你的体会。

最后留一道动手题吧。你可以创建一个新的 xls 文件，导入 heros.xlsx 数据表，用数据透视图的方式对英雄主要定位为刺客、法师、射手的英雄数值进行可视化，数据查询方式请使用 SQL 查询，统计的英雄数值为平均生命成长 hp_growth，平均法力成长 mp_growth，平均攻击力成长 attack_growth。

欢迎你在评论区写下你的体会与思考，也欢迎把这篇文章分享给你的朋友或者同事，一起来交流。

36丨WebSQL：如何在H5中存储一个本地数据库？

上一篇文章中，我们讲到了如何在 Excel 中使用 SQL 进行查询。在 Web 应用中，即使不通过后端语言与数据库进行操作，在 Web 前端中也可以使用 WebSQL。WebSQL 是一种操作本地数据库的网页 API 接口，通过它，我们就可以操作客户端的本地存储。

今天的课程主要包括以下几方面的内容：

本地存储都有哪些，什么是 WebSQL？

使用 WebSQL 的三个核心方法是什么？

如何使用 WebSQL 在本地浏览器中创建一个王者荣耀英雄数据库，并对它进行查询和页面的呈现？

本地存储都有哪些？什么是 WebSQL？

我刚才讲到了 WebSQL 实际上是本地存储。其实本地存储是个更大的概念，你现在可以打开 Chrome 浏览器，看下本地存储都包括了哪些。

Cookies 是最早的本地存储，是浏览器提供的功能，并且对服务器和 JS 开放，这意味着我们可以通过服务器端和客户端保存 Cookies。不过可以存储的数据总量大小只有 4KB，如果超过了这个限制就会忽略，没法进行保存。

Local Storage 与 Session Storage 都属于 Web Storage。Web Storage 和 Cookies 类似，区别在于它有更大容量的存储。其中 Local Storage 是持久化的本地存储，除非我们主动删除数据，否则会一直存储在本地。Session Storage 只存在于 Session 会话中，也就是说只有在同一个 Session 的页面才能使用，当 Session 会话结束后，数据也会自动释放掉。

WebSQL 与 IndexedDB 都是最新的 HTML5 本地缓存技术，相比于 Local Storage 和 Session Storage 来说，存储功能更强大，支持的数据类型也更多，比如图片、视频等。

WebSQL 更准确的说是 WebSQL DB API，它是一种操作本地数据库的网页 API 接口，通过 API 可以完成客户端数据库的操作。当我们使用 WebSQL 的时候，可以方便地用 SQL 来对数据进行增删改查。而这些浏览器客户端，比如 Chrome 和 Safari 会用 SQLite 实现本地存储。

如果说 WebSQL 方便我们对 RDBMS 进行操作，那么 IndexedDB 则是一种 NoSQL 方式。它存储的是 key-value 类型的数据，允许存储大量的数据，通常可以超过 250M，并且支持事务，当我们对数据进行增删改查（CRUD）的时候可以通过事务来进行。

你能看到本地存储包括了多种存储方式，它可以很方便地将数据存储在客户端中，在使用的时候避免重复调用服务器的资源。

需要说明的是，今天我要讲的 WebSQL 并不属于 HTML5 规范的一部分，它是一个单独的规范，只是随着 HTML5 规范一起加入到了浏览器端。主流的浏览器比如 Chrome、Safari 和 Firefox 都支持 WebSQL，我们可以在 JavaScript 脚本中使用 WebSQL 对客户端数据库进行操作。

如何使用 WebSQL

如果你的浏览器不是上面说的那三种，怎么检测你的浏览器是否支持 WebSQL 呢？这里你可以检查下 window 对象中是否存在 openDatabase 属性，方法如下：

if (!window.openDatabase) {

  alert('浏览器不支持 WebSQL');

}

完整代码如下：

<!DOCTYPE HTML>

<html>

   <head>

      <meta charset="UTF-8">

      <title>SQL 必知必会 </title>

      <script type="text/javascript">

        if (!window.openDatabase) {

                 alert('浏览器不支持 WebSQL');

        }

      </script>

   </head>

   <body>

      <div id="status" name="status">WebSQL Test</div>

   </body>

</html>

如果浏览器不支持 WebSQL，会有弹窗提示“浏览器不支持 WebSQL”，否则就不会有弹窗提示。使用 WebSQL 也比较简单，主要的方法有 3 个。

打开数据库：openDatabase()

我们可以使用 openDatabase 打开一个已经存在的数据库，也可以创建新的数据库。如果数据库已经存在了，就会直接打开；如果不存在则会创建。方法如下：

var db = window.openDatabase(dbname, version, dbdesc, dbsize,function() {});

这里 openDatabase 方法中一共包括了 5 个参数，分别为数据库名、版本号、描述、数据库大小、创建回调。其中创建回调可以缺省。

使用 openDatabase 方法会返回一个数据库句柄，我们可以将它保存在变量 db 中，方便我们后续进行使用。

如果我们想要创建一个名为 wucai 的数据库，版本号为 1.0，数据库的描述是“王者荣耀数据库”，大小是 1024*1024，创建方法为下面这样。

var db = openDatabase('wucai', '1.0', '王者荣耀数据库', 1024 * 1024);

事务操作：transaction()

我们使用 transaction 方法来对事务进行处理，执行提交或回滚操作，方法如下：

transaction(callback, errorCallback, successCallback);

这里的 3 个参数代表的含义如下：

处理事务的回调函数（必选），在回调函数中可以执行 SQL 语句，会使用到 ExecuteSQL 方法；

执行失败时的回调函数（可选）；

执行成功时的回调函数（可选）。

如果我们进行了一个事务处理，包括创建 heros 数据表，想要插入一条数据，方法如下：

db.transaction(function (tx) {

    tx.executeSql('CREATE TABLE IF NOT EXISTS heros (id unique, name, hp_max, mp_max, role_main)');

    tx.executeSql('INSERT INTO heros (id, name, hp_max, mp_max, role_main) VALUES (10000, " 夏侯惇 ", 7350, 1746, " 坦克 ")');

});

这里执行的事务就是一个方法，包括两条 SQL 语句。tx 表示的是回调函数的接收参数，也就是 transaction 对象的引用，方便我们在方法中进行使用。

SQL 执行：executeSql()

ExecuteSQL 命令用来执行 SQL 语句，即增删改查。方法如下：

tx.executeSql(sql, [], callback, errorCallback);

这里包括了 4 个参数，它们代表的含义如下所示：

要执行的 sql 语句。

SQL 语句中的占位符（?）所对应的参数。

执行 SQL 成功时的回调函数。

执行 SQL 失败时的回调函数。

假如我们想要创建一个 heros 数据表，可以使用如下命令：

tx.executeSql('CREATE TABLE IF NOT EXISTS heros (id unique, name, hp_max, mp_max, role_main)');

假如我们想要对刚创建的 heros 数据表插入一条数据，可以使用：

tx.executeSql('INSERT INTO heros (id, name, hp_max, mp_max, role_main) VALUES (10000, " 夏侯惇 ", 7350, 1746, " 坦克 ")');

在浏览器端做一个王者荣耀英雄的查询页面

刚才我讲解了 WebSQL 的基本语法，现在我们就来用刚学到的东西做一个小练习：在浏览器端做一个王者荣耀英雄的创建和查询页面。

具体步骤如下：

初始化数据：我们需要在 HTML 中设置一个 id 为 datatable 的 table 表格，然后在 JavaScript 中创建 init() 函数，获取 id 为 datatable 的元素。

创建 showData 方法：参数为查询出来的数据 row，showData 方法可以方便地展示查询出来的一行数据我们在数据表中的字段为 id、name、hp_max、mp_max 和 role_main，因此我们可以使用 row.id、row.name、row.hp_max、row.mp_max 和 row.role_main 来获取这些字段的数值，并且创建相应的标签，将这 5 个字段放到一个里面。

使用 openDatabase 方法打开数据库：这里我们定义的数据库名为 wucai，版本号为 1.0，数据库描述为“王者荣耀英雄数据”，大小为 1024 * 1024。

使用 transaction 方法执行两个事务：第一个事务是创建 heros 数据表，并且插入 5 条数据。第二个事务是对 heros 数据表进行查询，并且对查询出来的数据行使用 showData 方法进行展示。

完整代码如下（也可以通过 GitHub 下载：https://github.com/cystanford/WebSQL）：

<!DOCTYPE HTML>

<html>

   <head>

      <meta charset="UTF-8">

      <title>SQL 必知必会 </title>

      <script type="text/javascript">

         // 初始化

         function init() {

            datatable = document.getElementById("datatable");

         }

         // 显示每个英雄的数据

         function showData(row){

            var tr = document.createElement("tr");

            var td1 = document.createElement("td");

            var td2 = document.createElement("td");

            var td3 = document.createElement("td");

            var td4 = document.createElement("td");

            var td5 = document.createElement("td");

            td1.innerHTML = row.id;

            td2.innerHTML = row.name;

            td3.innerHTML = row.hp_max;

            td4.innerHTML = row.mp_max;

            td5.innerHTML = row.role_main;

            tr.appendChild(td1);

            tr.appendChild(td2);

            tr.appendChild(td3);

            tr.appendChild(td4);

            tr.appendChild(td5);

            datatable.appendChild(tr);

         }

         // 设置数据库信息

         var db = openDatabase('wucai', '1.0', '王者荣耀英雄数据', 1024 * 1024);

         var msg;

           // 插入数据

         db.transaction(function (tx) {

            tx.executeSql('CREATE TABLE IF NOT EXISTS heros (id unique, name, hp_max, mp_max, role_main)');

            tx.executeSql('INSERT INTO heros (id, name, hp_max, mp_max, role_main) VALUES (10000, " 夏侯惇 ", 7350, 1746, " 坦克 ")');

            tx.executeSql('INSERT INTO heros (id, name, hp_max, mp_max, role_main) VALUES (10001, " 钟无艳 ", 7000, 1760, " 战士 ")');

            tx.executeSql('INSERT INTO heros (id, name, hp_max, mp_max, role_main) VALUES (10002, " 张飞 ", 8341, 100, " 坦克 ")');

            tx.executeSql('INSERT INTO heros (id, name, hp_max, mp_max, role_main) VALUES (10003, " 牛魔 ", 8476, 1926, " 坦克 ")');

            tx.executeSql('INSERT INTO heros (id, name, hp_max, mp_max, role_main) VALUES (10004, " 吕布 ", 7344, 0, " 战士 ")');

            msg = '<p>heros 数据表创建成功，一共插入 5 条数据。</p>';

            document.querySelector('#status').innerHTML =  msg;

         });

         // 查询数据

         db.transaction(function (tx) {

            tx.executeSql('SELECT * FROM heros', [], function (tx, data) {

               var len = data.rows.length;

               msg = "<p> 查询记录条数: " + len + "</p>";

               document.querySelector('#status').innerHTML +=  msg;

                  // 将查询的英雄数据放到 datatable 中

               for (i = 0; i < len; i++){

                  showData(data.rows.item(i));

               }

            });

         });

      </script>

   </head>

   <body>

      <div id="status" name="status"> 状态信息 </div>

      <table border="1" id="datatable"></table>

   </body>

</html>

演示结果如下：

你能看到使用 WebSQL 来操作本地存储还是很方便的。

刚才我们讲的是创建本地存储，那么如何删除呢？你可以直接通过浏览器来删除，比如在 Chrome 浏览器中找到 Application 中的 Clear storage，然后使用 Clear site data 即可：

总结

今天我讲解了如何在浏览器中通过 WebSQL 来操作本地存储，如果想使用 SQL 来管理和查询本地存储，我们可以使用 WebSQL，通过三个核心的方法就可以方便让我们对数据库的连接，事务处理，以及 SQL 语句的执行来进行操作。我在 Github 上提供了操作的 HTML 代码，如果还没有使用过 WebSQL 就快来使用下吧。

我今天讲到了本地存储，在浏览器中包括了 Cookies、Local Storage、Session Storage、WebSQL 和 IndexedDB 这 5 种形式的本地存储，你能说下它们之间的区别么？

最后是一道动手题，请你使用 WebSQL 创建数据表 heros，并且插入 5 个以上的英雄数据，字段为 id、name、hp_max、mp_max、role_main。在 HTML 中添加一个输入框，可以输入英雄的姓名，并对该英雄的数据进行查询，如下图所示：

欢迎你在评论区写下你的答案，我会和你一起交流，也欢迎把这篇文章分享给你的朋友或者同事，与他们一起交流一下。

37丨SQLite：为什么微信用SQLite存储聊天记录？

我在上一篇文章中讲了 WebSQL，当我们在 Chrome、Safari 和 Firefox 等浏览器客户端中使用 WebSQL 时，会直接操作 SQLite。实际上 SQLite 本身是一个嵌入式的开源数据库引擎，大小只有 3M 左右，可以将整个 SQLite 嵌入到应用中，而不用采用传统的客户端／服务器（Client/Server）的架构。这样做的好处就是非常轻便，在许多智能设备和应用中都可以使用 SQLite，比如微信就采用了 SQLite 作为本地聊天记录的存储。

今天我们就来深入了解一下 SQLite，今天的内容主要包括以下几方面：

SQLite 是什么？它有哪些优点和不足？

如何在 Python 中使用 SQLite？

如何编写 SQL，通过 SQLite 查找微信的聊天记录？

SQLite 是什么

SQLite 是在 2000 年发布的，到目前为止已经有 19 年了。一直采用 C 语言编写，采用 C 语言而非 C++ 面向对象的方式，可以提升代码底层的执行效率。但 SQLite 也有一些优势与不足。

它的优势在于非常轻量级，存储数据非常高效，查询和操作数据简单方便。此外 SQLite 不需要安装和配置，有很好的迁移性，能够嵌入到很多应用程序中，与托管在服务器上的 RDBMS 相比，约束少易操作，可以有效减少服务器的压力。

不足在于 SQLite 常用于小到中型的数据存储，不适用高并发的情况。比如在微信本地可以使用 SQLite，即使是几百 M 的数据文件，使用 SQLite 也可以很方便地查找数据和管理，但是微信本身的服务器就不能使用 SQLite 了，因为 SQLite 同一时间只允许一个写操作，吞吐量非常有限。

作为简化版的数据库，SQLite 没有用户管理功能，在语法上也有一些自己的“方言”。比如在 SQL 中的 SELECT 语句，SQLite 可以使用一个特殊的操作符来拼接两个列。在 MySQL 中会使用函数 concat，而在 SQLite、PostgreSQL、Oracle 和 Db2 中使用||号，比如：SELECT MesLocalID || Message FROM "Chat_1234"。

这个语句代表的是从 Chat_1234 数据表中查询 MesLocalID 和 Message 字段并且将他们拼接起来。

但是在 SQLite 中不支持 RIGHT JOIN，因此你需要将右外连接转换为左外连接，也就是 LEFT JOIN，写成下面这样：

SELECT * FROM team LEFT JOIN player ON player.team_id = team.team_id

除此以外 SQLite 仅支持只读视图，也就是说，我们只能创建和读取视图，不能对它们的内容进行修改。

总的来说支持 SQL 标准的 RDBMS 语法都相似，只是不同的 DBMS 会有一些属于自己的“方言”，我们使用不同的 DBMS 的时候，需要注意。

在 Python 中使用 SQLite

我之前介绍过如何在 Python 中使用 MySQL，其中会使用到 DB API 规范（如下图所示）。基于 DB API 规范，我们可以对数据库进行连接、交互以及异常的处理。

在 Python 中使用 SQLite 也会使用到 DB API 规范，与使用 MySQL 的交互方式一样，也会用到 connection、cursor 和 exceptions。在 Python 中集成了 SQLite3，直接加载相应的工具包就可以直接使用。下面我们就来看下如何在 Python 中使用 SQLite。

在使用之前我们需要进行引用 SQLite，使用：

import sqlite3

然后我们可以使用 SQLite3 创建数据库连接：

conn = sqlite3.connect("wucai.db")

这里我们连接的是 wucai.db 这个文件，如果没有这个文件存储，上面的调用会自动在相应的工程路径里进行创建，然后我们可以使用 conn 操作连接，通过会话连接 conn 来创建游标：

cur = conn.cursor()

通过这一步，我们得到了游标 cur，然后可以使用 execute() 方法来执行各种 DML，比如插入，删除，更新等，当然我们也可以进行 SQL 查询，用的同样是 execute() 方法。

比如我们想要创建 heros 数据表，以及相应的字段 id、name、hp_max、mp_max、role_main，可以写成下面这样：

cur.execute("CREATE TABLE IF NOT EXISTS heros (id int primary key, name text, hp_max real, mp_max real, role_main text)")

在创建之后，我们可以使用 execute() 方法来添加一条数据：

cur.execute('insert into heros values(?, ?, ?, ?, ?)', (10000, '夏侯惇', 7350, 1746, '坦克'))

需要注意的是，一条一条插入数据太麻烦，我们也可以批量插入，这里会使用到 executemany 方法，这时我们传入的参数就是一个元组，比如：

cur.executemany('insert into heros values(?, ?, ?, ?, ?)',

           ((10000, '夏侯惇', 7350, 1746, '坦克'),

            (10001, '钟无艳', 7000, 1760, '战士'),

          (10002, '张飞', 8341, 100, '坦克'),

          (10003, '牛魔', 8476, 1926, '坦克'),

          (10004, '吕布', 7344, 0, '战士')))

如果我们想要对 heros 数据表进行查询，同样使用 execute 执行 SQL 语句：

cur.execute("SELECT id, name, hp_max, mp_max, role_main FROM heros")

这时 cur 会指向查询结果集的第一个位置，如果我们想要获取数据有以下几种方法：

cur.fetchone() 方法，获取一条记录；

cur.fetchmany(n) 方法，获取 n 条记录；

cur.fetchall() 方法，获取全部数据行。

比如我想获取全部的结果集，可以写成这样：

result = cur.fetchall()

如果我们对事务操作完了，可以提交事务，使用conn.commit()即可。

同样，如果游标和数据库的连接都操作完了，可以对它们进行关闭：

cur.close()

conn.close()

上面这个过程的完整代码如下：

import sqlite3

# 创建数据库连接

conn = sqlite3.connect("wucai.db")

# 获取游标

cur = conn.cursor()

# 创建数据表

cur.execute("CREATE TABLE IF NOT EXISTS heros (id int primary key, name text, hp_max real, mp_max real, role_main text)")

# 插入英雄数据

cur.executemany('insert into heros values(?, ?, ?, ?, ?)',

           ((10000, '夏侯惇', 7350, 1746, '坦克'),

            (10001, '钟无艳', 7000, 1760, '战士'),

          (10002, '张飞', 8341, 100, '坦克'),

          (10003, '牛魔', 8476, 1926, '坦克'),

          (10004, '吕布', 7344, 0, '战士')))

cur.execute("SELECT id, name, hp_max, mp_max, role_main FROM heros")

result = cur.fetchall()

print(result)

# 提交事务

conn.commit()

# 关闭游标

cur.close()

# 关闭数据库连接

conn.close()

除了使用 Python 操作 SQLite 之外，在整个操作过程中，我们同样可以使用 navicat 数据库可视化工具来查看和管理 SQLite。

通过 SQLite 查询微信的聊天记录

刚才我们提到很多应用都会集成 SQLite 作为客户端本地的数据库，这样就可以避免通过数据库服务器进行交互，减少服务器的压力。

如果你是 iPhone 手机，不妨跟着我执行以下的步骤，来查找下微信中的 SQLite 文件的位置吧。

第一步，使用 iTunes 备份 iPhone；第二步，在电脑中查找备份文件。

当我们备份好数据之后，需要在本地找到备份的文件，如果是 windows 可以在 C:\Users\XXXX\AppData\Roaming\Apple Computer\MobileSync\Backup 这个路径中找到备份文件夹。

第三步，查找 Manifest.db。

在备份文件夹中会存在 Manifest.db 文件，这个文件定义了苹果系统中各种备份所在的文件位置。

第四步，查找 MM.sqlite。

Manifest.db 本身是 SQLite 数据文件，通过 SQLite 我们能看到文件中包含了 Files 数据表，这张表中有 fileID、domain 和 relativePath 等字段。

微信的聊天记录文件为 MM.sqlite，我们可以直接通过 SQL 语句来查询下它的位置（也就是 fileID）。

SELECT * FROM Files WHERE relativePath LIKE '%MM.sqlite'

你能看到在我的微信备份中有 2 个 MM.sqlite 文件，这些都是微信的聊天记录。

第五步，分析找到的 MM.sqlite。

这里我们需要在备份文件夹中查找相关的 fileID，比如 f71743874d7b858a01e3ddb933ce13a9a01f79aa。

找到这个文件后，我们可以复制一份，取名为 weixin.db，这样就可以使用 navicat 对这个数据库进行可视化管理，如下图所示：

微信会把你与每一个人的聊天记录都保存成一张数据表，在数据表中会有 MesLocalID、Message、Status 等相应的字段，它们分别代表在当前对话中的 ID、聊天内容和聊天内容的状态）。

如果聊天对象很多的话，数据表也会有很多，如果想知道都有哪些聊天对象的数据表，可以使用：

SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'Chat\_%' escape '\'

这里需要说明的是 sqlite_master 是 SQLite 的系统表，数据表是只读的，里面保存了数据库中的数据表的名称。聊天记录的数据表都是以 Chat_ 开头的，因为（_）属于特殊字符，在 LIKE 语句中会将（_）作为通配符。所以如果我们想要对开头为 Chat_ 的文件名进行匹配，就需要用 escape 对这个特殊字符做转义。

总结

我今天讲了有关 SQLite 的内容。在使用 SQLite 的时候，需要注意 SQLite 有自己的方言，比如在进行表连接查询的时候不支持 RIGHT JOIN，需要将其转换成 LEFT JOIN 等。同时，我们在使用 execute() 方法的时候，尽量采用带有参数的 SQL 语句，以免被 SQL 注入攻击。

学习完今天的内容后，不如试试用 SQL 查询来查找本地的聊天记录吧。

最后留一道思考题吧。请你使用 SQL 查询对微信聊天记录中和“作业”相关的记录进行查找。不论是 iPhone，还是 Android 手机都可以找到相应的 SQLite 文件，你可以使用 Python 对 SQLite 进行操作，并输出结果。

欢迎你在评论区写下你的答案，也欢迎把这篇文章分享给你的朋友或者同事，一起交流一下。

40丨初识Redis：Redis为什么会这么快？

之前我们讲解了一些 RDBMS 的使用，比如 MySQL、Oracle、SQL Server 和 SQLite 等，实际上在日常工作中，我们还会接触到一些 NoSQL 类型的数据库。如果对比 RDBMS 和 NoSQL 数据库，你会发现 RDBMS 建立在关系模型基础上，强调数据的一致性和各种约束条件，而 NoSQL 的规则是“只提供你想要的”，数据模型灵活，查询效率高，成本低。但同时，相比 RDBMS，NoSQL 数据库没有统一的架构和标准语言，每种数据库之间差异较大，各有所长。

今天我们要讲解的 Redis 属于键值（key-value）数据库，键值数据库会使用哈希表存储键值和数据，其中 key 作为唯一的标识，而且 key 和 value 可以是任何的内容，不论是简单的对象还是复杂的对象都可以存储。键值数据库的查询性能高，易于扩展。

今天我们就来了解下 Redis，具体的内容包括以下几个方面：

Redis 是什么，为什么使用 Redis 会非常快？

Redis 支持的数据类型都有哪些？

如何通过 Python 和 Redis 进行交互？

Redis 是什么，为什么这么快

Redis 全称是 REmote DIctionary Server，从名字中你也能看出来它用字典结构存储数据，也就是 key-value 类型的数据。

Redis 的查询效率非常高，根据官方提供的数据，Redis 每秒最多处理的请求可以达到 10 万次。

为什么这么快呢？

Redis 采用 ANSI C 语言编写，它和 SQLite 一样。采用 C 语言进行编写的好处是底层代码执行效率高，依赖性低，因为使用 C 语言开发的库没有太多运行时（Runtime）依赖，而且系统的兼容性好，稳定性高。

此外，Redis 是基于内存的数据库，我们之前讲到过，这样可以避免磁盘 I/O，因此 Redis 也被称为缓存工具。

其次，数据结构结构简单，Redis 采用 Key-Value 方式进行存储，也就是使用 Hash 结构进行操作，数据的操作复杂度为 O(1)。

但 Redis 快的原因还不止这些，它采用单进程单线程模型，这样做的好处就是避免了上下文切换和不必要的线程之间引起的资源竞争。

在技术上 Redis 还采用了多路 I/O 复用技术。这里的多路指的是多个 socket 网络连接，复用指的是复用同一个线程。采用多路 I/O 复用技术的好处是可以在同一个线程中处理多个 I/O 请求，尽量减少网络 I/O 的消耗，提升使用效率。

Redis 的数据类型

相比 Memcached，Redis 有一个非常大的优势，就是支持多种数据类型。Redis 支持的数据类型包括字符串、哈希、列表、集合、有序集合等。

字符串类型是 Redis 提供的最基本的数据类型，对应的结构是 key-value。

如果我们想要设置某个键的值，使用方法为set key value，比如我们想要给 name 这个键设置值为 zhangfei，可以写成set name zhangfei。如果想要取某个键的值，可以使用get key，比如想取 name 的值，写成 get name 即可。

哈希（hash）提供了字段和字段值的映射，对应的结构是 key-field-value。

如果我们想要设置某个键的哈希值，可以使用hset key field value，如果想要给 user1 设置 username 为 zhangfei，age 为 28，可以写成下面这样：

hset user1 username zhangfei

hset user1 age 28

如果我们想要同时将多个 field-value 设置给某个键 key 的时候，可以使用hmset key field value [field value...]，比如上面这个可以写成：

Hmset user1 username zhangfei age 28

如果想要取某个键的某个 field 字段值，可以使用hget key field，比如想要取 user1 的 username，那么写成hget user1 username即可。

如果想要一次获取某个键的多个 field 字段值，可以使用hmget key field[field...]，比如想要取 user1 的 username 和 age，可以写成hmget user1 username age。

字符串列表（list）的底层是一个双向链表结构，所以我们可以向列表的两端添加元素，时间复杂度都为 O(1)，同时我们也可以获取列表中的某个片段。

如果想要向列表左侧增加元素可以使用：LPUSH key value [...]，比如我们给 heroList 列表向左侧添加 zhangfei、guanyu 和 liubei 这三个元素，可以写成：

LPUSH heroList zhangfei guanyu liubei

同样，我们也可以使用RPUSH key value [...]向列表右侧添加元素，比如我们给 heroList 列表向右侧添加 dianwei、lvbu 这两个元素，可以写成下面这样：

RPUSH heroList dianwei lvbu

如果我们想要获取列表中某一片段的内容，使用LRANGE key start stop即可，比如我们想要获取 heroList 从 0 到 4 位置的数据，写成LRANGE heroList 0 4即可。

字符串集合（set）是字符串类型的无序集合，与列表（list）的区别在于集合中的元素是无序的，同时元素不能重复。

如果想要在集合中添加元素，可以使用SADD key member [...]，比如我们给 heroSet 集合添加 zhangfei、guanyu、liubei、dianwei 和 lvbu 这五个元素，可以写成：

SADD heroSet zhangfei guanyu liubei dianwei lvbu

如果想要在集合中删除某元素，可以使用SREM key member [...]，比如我们从 heroSet 集合中删除 liubei 和 lvbu 这两个元素，可以写成：

SREM heroSet liubei lvbu

如果想要获取集合中所有的元素，可以使用SMEMBERS key，比如我们想要获取 heroSet 集合中的所有元素，写成SMEMBERS heroSet即可。

如果想要判断集合中是否存在某个元素，可以使用SISMEMBER key member，比如我们想要判断 heroSet 集合中是否存在 zhangfei 和 liubei，就可以写成下面这样：

SISMEMBER heroSet zhangfei

SISMEMBER heroSet liubei

我们可以把有序字符串集合（SortedSet，简称 ZSET）理解成集合的升级版。实际上 ZSET 是在集合的基础上增加了一个分数属性，这个属性在添加修改元素的时候可以被指定。每次指定后，ZSET 都会按照分数来进行自动排序，也就是说我们在给集合 key 添加 member 的时候，可以指定 score。

有序集合与列表有一定的相似性，比如这两种数据类型都是有序的，都可以获得某一范围的元素。但它俩在数据结构上有很大的不同，首先列表 list 是通过双向链表实现的，在操作左右两侧的数据时会非常快，而对于中间的数据操作则相对较慢。有序集合采用 hash 表的结构来实现，读取排序在中间部分的数据也会很快。同时有序集合可以通过 score 来完成元素位置的调整，但如果我们想要对列表进行元素位置的调整则会比较麻烦。

如果我们想要在有序集合中添加元素和分数，使用ZADD key score member [...]，比如我们给 heroScore 集合添加下面 5 个英雄的 hp_max 数值，如下表所示：

那么我们可以写成下面这样：

ZADD heroScore 8341 zhangfei 7107 guanyu 6900 liubei 7516 dianwei 7344 lvbu

如果我们想要获取某个元素的分数，可以使用ZSCORE key member，比如我们想要获取 guanyu 的分数，写成ZSCORE heroScore guanyu即可。

如果我们想要删除一个或多元素，可以使用 ZREM key member [member …]，比如我们想要删除 guanyu 这个元素，使用ZREM heroScore guanyu即可。

我们也可以获取某个范围的元素列表。如果想要分数从小到大进行排序，使用ZRANGE key start stop [WITHSCORES]，如果分数从大到小进行排序，使用ZREVRANGE key start stop [WITHSCORES]。需要注意的是，WITHSCORES 是个可选项，如果使用 WITHSCORES 会将分数一同显示出来，比如我们想要查询 heroScore 这个有序集合中分数排名前 3 的英雄及数值，写成ZREVRANGE heroScore 0 2 WITHSCORES即可。

除了这 5 种数据类型以外，Redis 还支持位图（Bitmaps）数据结构，在 2.8 版本之后，增加了基数统计（HyperLogLog），3.2 版本之后加入了地理空间（Geospatial）以及索引半径查询的功能，在 5.0 版本引用了数据流（Streams）数据类型。

如何使用 Redis

我们可以在 Python 中直接操作 Redis，在使用前需要使用pip install redis安装工具包，安装好之后，在使用前我们需要使用 import redis 进行引用。

在 Python 中提供了两种连接 Redis 的方式，第一种是直接连接，使用下面这行命令即可。

r = redis.Redis(host='localhost', port= 6379)

第二种是连接池方式。

pool = redis.ConnectionPool(host='localhost', port=6379)

r = redis.Redis(connection_pool=pool)

你可能会有疑问，这两种连接方式有什么不同？直接连接可能会耗费掉很多资源。通常情况下，我们在连接 Redis 的时候，可以创建一个 Redis 连接，通过它来完成 Redis 操作，完成之后再释放掉。但是在高并发的情况下，这样做非常不经济，因为每次连接和释放都需要消耗非常多的资源。

为什么采用连接池机制

基于直接连接的弊端，Redis 提供了连接池的机制，这个机制可以让我们事先创建好多个连接，将其放到连接池中，当我们需要进行 Redis 操作的时候就直接从连接池中获取，完成之后也不会直接释放掉连接，而是将它返回到连接池中。

连接池机制可以避免频繁创建和释放连接，提升整体的性能。

连接池机制的原理

在连接池的实例中会有两个 list，保存的是_available_connections和_in_use_connections，它们分别代表连接池中可以使用的连接集合和正在使用的连接集合。当我们想要创建连接的时候，可以从_available_connections中获取一个连接进行使用，并将其放到_in_use_connections中。如果没有可用的连接，才会创建一个新连接，再将其放到_in_use_connections中。如果连接使用完毕，会从_in_use_connections中删除，添加到_available_connections中，供后续使用。

Redis 库提供了 Redis 和 StrictRedis 类，它们都可以实现 Redis 命令，不同之处在于 Redis 是 StrictRedis 的子类，可以对旧版本进行兼容。如果我们想要使用连接池机制，然后用 StrictRedis 进行实例化，可以写成下面这样：

import redis

pool = redis.ConnectionPool(host='localhost', port=6379)

r = redis.StrictRedis(connection_pool=pool)

实验：使用 Python 统计 Redis 进行 1 万次写请求和 1 万次读请求的时间

了解了如何使用 Python 创建 Redis 连接之后，我们再来看下怎样使用 Python 对 Redis 进行数据的写入和读取。这里我们使用 HMSET 函数同时将多个field-value值存入到键中。模拟 1 万次的写请求里，设置了不同的 key，和相同的field-value值，然后在 1 万次读请求中，将这些不同的 key 中保存的field-value值读取出来。具体代码如下：

import redis

import time

# 创建 redis 连接

pool = redis.ConnectionPool(host='localhost', port=6379)

r = redis.StrictRedis(connection_pool=pool)

# 记录当前时间

time1 = time.time()

# 1 万次写

for i in range(10000):

    data = {'username': 'zhangfei', 'age':28}

    r.hmset("users"+str(i), data)

# 统计写时间

delta_time = time.time()-time1

print(delta_time)

# 统计当前时间

time1 = time.time()

# 1 万次读

for i in range(10000):

    result = r.hmget("users"+str(i), ['username', 'age'])

# 统计读时间

delta_time = time.time()-time1

print(delta_time)

运行结果：

2.0041146278381348

0.9920568466186523

你能看到 1 万次写请求差不多用时 2 秒钟，而 1 万次读请求用时不到 1 秒钟，读写效率还是很高的。

总结

NoSQL 数据库种类非常多，了解 Redis 是非常有必要的，在实际工作中，我们也经常将 RDBMS 和 Redis 一起使用，优势互补。

作为常见的 NoSQL 数据库，Redis 支持的数据类型比 Memcached 丰富得多，在 I/O 性能上，Redis 采用的是单线程 I/O 复用模型，而 Memcached 是多线程，可以利用多核优势。而且在持久化上，Redis 提供了两种持久化的模式，可以让数据永久保存，这是 Memcached 不具备的。

你不妨思考一下，为什么 Redis 采用了单线程工作模式？有哪些机制可以保证 Redis 即使采用单线程模式效率也很高呢？

欢迎你在评论区写下你的思考，也欢迎把这篇文章分享给你的朋友或者同事，一起交流一下。

41丨如何使用Redis来实现多用户抢票问题

在上一篇文章中，我们已经对 Redis 有了初步的认识，了解到 Redis 采用 Key-Value 的方式进行存储，在 Redis 内部，使用的是 redisObject 对象来表示所有的 key 和 value。同时我们还了解到 Redis 本身用的是单线程的机制，采用了多路 I/O 复用的技术，在处理多个 I/O 请求的时候效率很高。

今天我们来更加深入地了解一下 Redis 的原理，内容包括以下几方面：

Redis 的事务处理机制是怎样的？与 RDBMS 有何不同？

Redis 的事务处理的命令都有哪些？如何使用它们完成事务操作？

如何使用 Python 的多线程机制和 Redis 的事务命令模拟多用户抢票？

Redis 的事务处理机制

在此之前，让我们先来回忆下 RDBMS 中事务满足的 4 个特性 ACID，它们分别代表原子性、一致性、隔离性和持久性。

Redis 的事务处理与 RDBMS 的事务有一些不同。

首先 Redis 不支持事务的回滚机制（Rollback），这也就意味着当事务发生了错误（只要不是语法错误），整个事务依然会继续执行下去，直到事务队列中所有命令都执行完毕。在Redis 官方文档中说明了为什么 Redis 不支持事务回滚。

只有当编程语法错误的时候，Redis 命令执行才会失败。这种错误通常出现在开发环境中，而很少出现在生产环境中，没有必要开发事务回滚功能。

另外，Redis 是内存数据库，与基于文件的 RDBMS 不同，通常只进行内存计算和操作，无法保证持久性。不过 Redis 也提供了两种持久化的模式，分别是 RDB 和 AOF 模式。

RDB（Redis DataBase）持久化可以把当前进程的数据生成快照保存到磁盘上，触发 RDB 持久化的方式分为手动触发和自动触发。因为持久化操作与命令操作不是同步进行的，所以无法保证事务的持久性。

AOF（Append Only File）持久化采用日志的形式记录每个写操作，弥补了 RDB 在数据一致性上的不足，但是采用 AOF 模式，就意味着每条执行命令都需要写入文件中，会大大降低 Redis 的访问性能。启用 AOF 模式需要手动开启，有 3 种不同的配置方式，默认为 everysec，也就是每秒钟同步一次。其次还有 always 和 no 模式，分别代表只要有数据发生修改就会写入 AOF 文件，以及由操作系统决定什么时候记录到 AOF 文件中。

虽然 Redis 提供了两种持久化的机制，但是作为内存数据库，持久性并不是它的擅长。

Redis 是单线程程序，在事务执行时不会中断事务，其他客户端提交的各种操作都无法执行，因此你可以理解为 Redis 的事务处理是串行化的方式，总是具有隔离性的。

Redis 的事务处理命令

了解了 Redis 的事务处理机制之后，我们来看下 Redis 的事务处理都包括哪些命令。

MULTI：开启一个事务；

EXEC：事务执行，将一次性执行事务内的所有命令；

DISCARD：取消事务；

WATCH：监视一个或多个键，如果事务执行前某个键发生了改动，那么事务也会被打断；

UNWATCH：取消 WATCH 命令对所有键的监视。

需要说明的是 Redis 实现事务是基于 COMMAND 队列，如果 Redis 没有开启事务，那么任何的 COMMAND 都会立即执行并返回结果。如果 Redis 开启了事务，COMMAND 命令会放到队列中，并且返回排队的状态 QUEUED，只有调用 EXEC，才会执行 COMMAND 队列中的命令。

比如我们使用事务的方式存储 5 名玩家所选英雄的信息，代码如下：

MULTI

hmset user:001 hero 'zhangfei' hp_max 8341 mp_max 100

hmset user:002 hero 'guanyu' hp_max 7107 mp_max 10

hmset user:003 hero 'liubei' hp_max 6900 mp_max 1742

hmset user:004 hero 'dianwei' hp_max 7516 mp_max 1774

hmset user:005 hero 'diaochan' hp_max 5611 mp_max 1960

EXEC

你能看到在 MULTI 和 EXEC 之间的 COMMAND 命令都会被放到 COMMAND 队列中，并返回排队的状态，只有当 EXEC 调用时才会一次性全部执行。

我们经常使用 Redis 的 WATCH 和 MULTI 命令来处理共享资源的并发操作，比如秒杀，抢票等。实际上 WATCH+MULTI 实现的是乐观锁。下面我们用两个 Redis 客户端来模拟下抢票的流程。

我们启动 Redis 客户端 1，执行上面的语句，然后在执行 EXEC 前，等待客户端 2 先完成上面的执行，客户端 2 的结果如下：

然后客户端 1 执行 EXEC，结果如下：

你能看到实际上最后一张票被客户端 2 抢到了，这是因为客户端 1WATCH 的票的变量在 EXEC 之前发生了变化，整个事务就被打断，返回空回复（nil）。

需要说明的是 MULTI 后不能再执行 WATCH 命令，否则会返回 WATCH inside MULTI is not allowed 错误（因为 WATCH 代表的就是在执行事务前观察变量是否发生了改变，如果变量改变了就不将事务打断，所以在事务执行之前，也就是 MULTI 之前，使用 WATCH）。同时，如果在执行命令过程中有语法错误，Redis 也会报错，整个事务也不会被执行，Redis 会忽略运行时发生的错误，不会影响到后面的执行。

模拟多用户抢票

我们刚才讲解了 Redis 的事务命令，并且使用 Redis 客户端的方式模拟了两个用户抢票的流程。下面我们使用 Python 继续模拟一下这个过程，这里需要注意三点。

在 Python 中，Redis 事务是通过 pipeline 封装而实现的，因此在创建 Redis 连接后，需要获取管道 pipeline，然后通过 pipeline 使用 WATCH、MULTI 和 EXEC 命令。

其次，用户是并发操作的，因此我们需要使用到 Python 的多线程，这里使用 threading 库来创建多线程。

对于用户的抢票，我们设置了 sell 函数，用于模拟用户 i 的抢票。在执行 MULTI 前，我们需要先使用 pipe.watch(KEY) 监视票数，如果票数不大于 0，则说明票卖完了，用户抢票失败；如果票数大于 0，证明可以抢票，再执行 MULTI，将票数减 1 并进行提交。不过在提交执行的时候可能会失败，这是因为如果监视的 KEY 发生了改变，则会产生异常，我们可以通过捕获异常，来提示用户抢票失败，重试一次。如果成功执行事务，则提示用户抢票成功，显示当前的剩余票数。

具体代码如下：

import redis

import threading

# 创建连接池

pool = redis.ConnectionPool(host = '127.0.0.1', port=6379, db=0)

# 初始化 redis

r = redis.StrictRedis(connection_pool = pool)

# 设置 KEY

KEY="ticket_count"

# 模拟第 i 个用户进行抢票

def sell(i):

    # 初始化 pipe

    pipe = r.pipeline()

    while True:

        try:

            # 监视票数

            pipe.watch(KEY)

            # 查看票数

            c = int(pipe.get(KEY))

            if c > 0:

                # 开始事务

                pipe.multi()

                c = c - 1

                pipe.set(KEY, c)

                pipe.execute()

                print('用户 {} 抢票成功，当前票数 {}'.format(i, c))

                break

            else:

                print('用户 {} 抢票失败，票卖完了'.format(i))

                break

        except Exception as e:

            print('用户 {} 抢票失败，重试一次'.format(i))

            continue

        finally:

            pipe.unwatch()

if __name__ == "__main__":

    # 初始化 5 张票

    r.set(KEY, 5)

    # 设置 8 个人抢票

    for i in range(8):

        t = threading.Thread(target=sell, args=(i,))

        t.start()

运行结果：

用户 0 抢票成功，当前票数 4

用户 4 抢票失败，重试一次

用户 1 抢票成功，当前票数 3

用户 2 抢票成功，当前票数 2

用户 4 抢票失败，重试一次

用户 5 抢票失败，重试一次

用户 6 抢票成功，当前票数 1

用户 4 抢票成功，当前票数 0

用户 5 抢票失败，重试一次

用户 3 抢票失败，重试一次

用户 7 抢票失败，票卖完了

用户 5 抢票失败，票卖完了

用户 3 抢票失败，票卖完了

在 Redis 中不存在悲观锁，事务处理要考虑到并发请求的情况，我们需要通过 WATCH+MULTI 的方式来实现乐观锁，如果监视的 KEY 没有发生变化则可以顺利执行事务，否则说明事务的安全性已经受到了破坏，服务器就会放弃执行这个事务，直接向客户端返回空回复（nil），事务执行失败后，我们可以重新进行尝试。

总结

今天我讲解了 Redis 的事务机制，Redis 事务是一系列 Redis 命令的集合，事务中的所有命令都会按照顺序进行执行，并且在执行过程中不会受到其他客户端的干扰。不过在事务的执行中，Redis 可能会遇到下面两种错误的情况：

首先是语法错误，也就是在 Redis 命令入队时发生的语法错误。Redis 在事务执行前不允许有语法错误，如果出现，则会导致事务执行失败。如官方文档所说，通常这种情况在生产环境中很少出现，一般会发生在开发环境中，如果遇到了这种语法错误，就需要开发人员自行纠错。

第二个是执行时错误，也就是在事务执行时发生的错误，比如处理了错误类型的键等，这种错误并非语法错误，Redis 只有在实际执行中才能判断出来。不过 Redis 不提供回滚机制，因此当发生这类错误时 Redis 会继续执行下去，保证其他命令的正常执行。

在事务处理中，我们需要通过锁的机制来解决共享资源并发访问的情况。在 Redis 中提供了 WATCH+MULTI 的乐观锁方式。我们之前了解过乐观锁是一种思想，它是通过程序实现的锁机制，在数据更新的时候进行判断，成功就执行，不成功就失败，不需要等待其他事务来释放锁。事实上，在在 Redis 的设计中，处处体现了这种乐观、简单的设计理念。

最后我们一起思考两个问题吧。Redis 既然是单线程程序，在执行事务过程中按照顺序执行，为什么还会用 WATCH+MULTI 的方式来实现乐观锁的并发控制呢？

我们在进行抢票模拟的时候，列举了两个 Redis 客户端的例子，当 WATCH 的键 ticket 发生改变的时候，事务就会被打断。这里我将客户端 2 的 SET ticket 设置为 1，也就是 ticket 的数值没有发生变化，请问此时客户端 1 和客户端 2 的执行结果是怎样的，为什么？

欢迎你在评论区写下你的思考，我会和你一起交流，也欢迎你把这篇文章分享给你的朋友或者同事，一起交流一下。

43丨如何使用Redis搭建玩家排行榜？

上一篇文章中，我们使用 Redis 模拟了多用户抢票的问题，这里再回顾一下原理。我们通过使用 WATCH+MULTI 的方式实现乐观锁机制，对 ticket_count 这个键进行监视，当这个键发生变化的时候事务就会被打断，重新请求，这样做的好处就是可以保证事务对键进行操作的原子性，当然我们也可以使用 Redis 的 incr 和 decr 来实现键的原子性递增或递减。

今天我们用 Redis 搭建一个玩家的排行榜，假设一个服务器存储了 10 万名玩家的数据，我们想给这个区（这台服务器）上的玩家做个全区的排名，该如何用 Redis 实现呢？

不妨一起来思考下面几个问题：

MySQL 是如何实现玩家排行榜的？有哪些难题需要解决？

如何用 Redis 模拟 10 万名玩家数据？Redis 里的 Lua 又是什么？

Redis 如何搭建玩家排行榜？和 MySQL 相比有什么优势？

使用 MySQL 搭建玩家排行榜

我们如果用 MySQL 搭建玩家排行榜的话，首先需要生成 10 万名玩家的数据，这里我们使用之前学习过的存储过程来模拟。

为了简化，玩家排行榜主要包括 3 个字段：user_id、score、和 create_time，它们分别代表玩家的用户 ID、玩家的积分和玩家的创建时间。

王者荣耀英雄等级说明

这里我们可以模拟王者荣耀的英雄等级，具体等级标准如下：

如果想要英雄要达到最强王者的段位，那么之前需要积累 112 颗（9+12+16+25+25+25）星星，而达到最强王者之后还可以继续积累无上限的星星。在随机数模拟上，我们也分成两个阶段，第一个阶段模拟英雄的段位，我们使用随机数来模拟 score（数值范围是 1-112 之间），当 score=112 的时候，再模拟最强王者等级中的星星个数。如果我们只用一个随机数进行模拟，会出现最强王者的比例变大的情况，显然不符合实际情况。

使用存储过程模拟 10 万名玩家数据

这里我们使用存储过程，具体代码如下：

CREATE DEFINER=`root`@`localhost` PROCEDURE `insert_many_user_scores`(IN START INT(10), IN max_num INT(10))

BEGIN

DECLARE i INT DEFAULT 0;

-- 模拟玩家英雄的星星数

DECLARE score INT;

DECLARE score2 INT;

-- 初始注册时间

DECLARE date_start DATETIME DEFAULT ('2017-01-01 00:00:00');

-- 每个玩家的注册时间

DECLARE date_temp DATETIME;

SET date_temp = date_start;

SET autocommit=0;

REPEAT

SET i=i+1;

SET date_temp = date_add(date_temp, interval RAND()*60 second);

-- 1-112 随机数

SET score = CEIL(RAND()*112);

-- 如果达到了王者，继续模拟王者的星星数

IF score = 112 THEN

           SET score2 = FLOOR(RAND()*100);

           SET score = score + score2;

END IF;

-- 插入新玩家

INSERT INTO user_score(user_id, score, create_time) VALUES((START+i), score, date_temp);

UNTIL i = max_num

END REPEAT;

COMMIT;

END

然后我们使用call insert_many_user_scores(10000,100000);模拟生成 10 万名玩家的得分数据。注意在 insert 之前，需要先设置autocommit=0，也就是关闭了自动提交，然后在批量插入结束之后再手动进行 COMMIT，这样做的好处是可以进行批量提交，提升插入效率。你可以看到整体的用时为 5.2 秒。

如上代码所示，我用 score 来模拟第一阶段的星星数，如果 score 达到了 112 再来模拟 score2 的分数，这里我限定最强王者阶段的星星个数上限为 100。同时我们还模拟了用户注册的时间，这是因为排行榜可以有两种表示方式，第二种方式需要用到这个时间。

第一种表示方式为并列排行榜，也就是分数相同的情况下，允许排名并列，如下所示：

第二种为严格排行榜。当分数相同的时候，会按照第二条件来进行排序，比如按照注册时间的长短，注册时间越长的排名越靠前。这样的话，上面那个排行榜就会变成如下所示的严格排行榜。

你能看到当 10013 和 10015 得分相同的时候，如果按照注册时间来进行排名的话，会将 10013 排到 10015 前面。

上面的数据仅仅为示意，下面我们用实际的 10 万条数据做一个严格排行榜（你可以点击下载地址下载这 10 万条数据， 也可以自己使用上面的存储过程来进行模拟）首先使用 SQL 语句进行查询：

SELECT (@rownum := @rownum + 1) AS user_rank, user_id, score, create_time

FROM user_score, (SELECT @rownum := 0) b

ORDER BY score DESC, create_time ASC

运行结果如下（10 万条数据，用时 0.17s）：

这里有几点需要说明。

MySQL 不像 Oracle 一样自带 rownum 统计行编号的功能，所以这里我们需要自己来实现 rownum 功能，也就是设置 MySQL 的变量@rownum，初始化为@rownum :=0，然后每次 SELECT 一条数据的时候都自动加 1。

通过开发程序（比如 Python、PHP 和 Java 等）统计排名会更方便，这里同样需要初始化一个变量，比如rownum=0，然后每次 fetch 一条数据的时候都将该变量加 1，作为记录的排名。同时，开发程序也可以很方便地实现并列排名，因为程序可以进行上下文的统计，当两名玩家得分相同时，排名相同，否则排名会顺序加 1。

如果想要通过 SQL 来实现，可以写成下面这样：

SELECT user_id, score,

    IFNULL((SELECT COUNT(*) FROM user_score WHERE score > t.score), 0) + 1 AS user_rank

FROM user_score t

ORDER BY user_rank ASC

这样做的原理是查找比当前分数大的数据行数，然后加 1，但是这样执行效率会很低，相当于需要对每个玩家都统计一遍排名。

Lua 是什么，如何在 Redis 中使用

知道如何用 MySQL 模拟数据后，我们再来看下如何在 Redis 中完成这一步。事实上，Redis 本身不提供存储过程的功能，不过在 2.6 版本之后集成了 Lua 语言，可以很方便地实现类似存储过程的函数调用方式。

Lua 是一个小巧的脚本语言，采用标准 C 语言编写，一个完整的 Lua 解析器大小只有 200K。我们之前讲到过采用标准 C 语言编写的好处就在于执行效率高，依懒性低，同时兼容性好，稳定性高。这些特性同样 Lua 也有，它可以嵌入到各种应用程序中，提供灵活的扩展和定制功能。

如何在 Redis 中使用 Lua

在 Redis 中使用 Lua 脚本的命令格式如下：

EVAL script numkeys key [key ...] arg [arg ...]

我来说明下这些命令中各个参数代表的含义。

script，代表的是 Lua 的脚本内容。

numkeys，代表后续参数 key 的个数。

key 就是我们要操作的键，可以是多个键。我们在 Lua 脚本中可以直接使用这些 key，直接通过KEYS[1]、KEYS[2]来获取，默认下标是从 1 开始。

arg，表示传入到 Lua 脚本中的参数，就像调用函数传入的参数一样。在 Lua 脚本中我们可以通过ARGV[1]、ARGV[2]来进行获取，同样默认下标从 1 开始。

下面我们通过 2 个例子来体会下，比如我们使用 eval "return {ARGV[1], ARGV[2]}" 0 cy 123，代表的是传入的 key 的个数为 0，后面有两个 arg，分别为 cy 和 123。在 Lua 脚本中，我们直接返回这两个参数ARGV[1], ARGV[2]，执行结果如下：

比如我们要用这一条语句：

eval "math.randomseed(ARGV[1]); local temp = math.random(1,112); redis.call('SET', KEYS[1], temp); return 'ok';" 1 score 30

这条语句代表的意思是，我们传入 KEY 的个数为 1，参数是 score，arg 参数为 30。在 Lua 脚本中使用ARGV[1]，也就是 30 作为随机数的种子，然后创建本地变量 temp 等于 1 到 112 之间的随机数，再使用 SET 方法对 KEY，也就是用刚才创建的随机数对 score 这个字段进行赋值，结果如下：

然后我们在 Redis 中使用GET score对刚才设置的随机数进行读取，结果为 34。

另外我们还可以在命令中调用 Lua 脚本，使用的命令格式：

redis-cli --eval lua_file key1 key2 , arg1 arg2 arg3

使用 redis-cli 的命令格式不需要输入 key 的个数，在 key 和 arg 参数之间采用了逗号进行分割，注意逗号前后都需要有空格。同时在 eval 后面可以带一个 lua 文件（以.lua 结尾）。

使用 Lua 创建 10 万名玩家数据

如果我们想要通过 Lua 脚本创建 10 万名玩家的数据，文件名为insert_user_scores.lua，代码如下：

-- 设置时间种子

math.randomseed(ARGV[1])

-- 设置初始的生成时间

local create_time = 1567769563 - 3600*24*365*2.0

local num = ARGV[2]

local user_id = ARGV[3]

for i=1, num do

  -- 生成 1 到 60 之间的随机数

  local interval = math.random(1, 60)

  -- 产生 1 到 112 之间的随机数

  local temp = math.random(1, 112)

  if (temp == 112) then

        -- 产生 0 到 100 之间的随机数

        temp = temp + math.random(0, 100)

  end

  create_time = create_time + interval

  temp = temp + create_time / 10000000000

  redis.call('ZADD', KEYS[1], temp, user_id+i-1)

end

return 'Generation Completed'

上面这段代码可以实现严格排行榜的排名，具体方式是将 score 进行了改造，score 为浮点数。整数部分为得分，小数部分为时间差。

在调用的时候，我们通过ARGV[1]获取时间种子的参数，传入的KEYS[1]为user_score，也就是创建有序集合user_score。然后通过 num 来设置生成玩家的数量，通过user_id获取初始的user_id。最后调用如下命令完成玩家数据的创建：

redis-cli -h localhost -p 6379 --eval insert_user_scores.lua user_score , 30 100000 10000

使用 Redis 实现玩家排行榜

我们通过 Lua 脚本模拟完成 10 万名玩家数据，并将其存储在了 Redis 的有序集合user_score中，下面我们就来使用 Redis 来统计玩家排行榜的数据。

首先我们需要思考的是，一个典型的游戏排行榜都包括哪些功能呢？

统计全部玩家的排行榜

按名次查询排名前 N 名的玩家

查询某个玩家的分数

查询某个玩家的排名

对玩家的分数和排名进行更新

查询指定玩家前后 M 名的玩家

增加或移除某个玩家，并对排名进行更新

在 Redis 中实现上面的功能非常简单，只需要使用 Redis 我们提供的方法即可，针对上面的排行榜功能需求，我们分别来看下 Redis 是如何实现的。

统计全部玩家的排行榜

在 Redis 里，统计全部玩家的排行榜的命令格式为ZREVRANGE 排行榜名称 起始位置 结束为止 [WITHSCORES]。

我们使用这行命令即可：

ZREVRANGE user_score 0 -1 WITHSCORES

我们对玩家排行榜user_score进行统计，其中 -1 代表的是全部的玩家数据，WITHSCORES代表的是输出排名的同时也输出分数。

按名次查询排名前 N 名的玩家

同样我们可以使用ZREVRANGE完成前 N 名玩家的排名，比如我们想要统计前 10 名玩家，可以使用：ZREVRANGE user_score 0 9。

查询某个玩家的分数

命令格式为ZSCORE 排行榜名称 玩家标识。

时间复杂度为O(1)。

如果我们想要查询玩家 10001 的分数可以使用：ZSCORE user_score 10001。

查询某个玩家的排名

命令格式为ZREVRANK 排行榜名称 玩家标识。

时间复杂度为O(log(N))。

如果我们想要查询玩家 10001 的排名可以使用：ZREVRANK user_score 10001。

对玩家的分数进行更新，同时排名进行更新

如果我们想要对玩家的分数进行增减，命令格式为ZINCRBY 排行榜名称 分数变化 玩家标识。

时间复杂度为O(log(N))。

比如我们想对玩家 10001 的分数减 1，可以使用：ZINCRBY user_score -1 10001。

然后我们再来查看下玩家 10001 的排名，使用：ZREVRANK user_score 10001。

你能看到排名由 17153 降到了 18036 名。

查询指定玩家前后 M 名的玩家

比如我们想要查询玩家 10001 前后 5 名玩家都是谁，当前已知玩家 10001 的排名是 18036，那么可以使用：ZREVRANGE user_score 18031 18041。

这样就可以得到玩家 10001 前后 5 名玩家的信息。

增加或删除某个玩家，并对排名进行更新

如果我们想要删除某个玩家，命令格式为ZREM 排行榜名称 玩家标识。

时间复杂度为O(log(N))。

比如我们想要删除玩家 10001，可以使用：ZREM user_score 10001。

这样我们再来查询下排名在 18031 到 18041 的玩家是谁，使用：ZREVRANGE user_score 18031 18041。

你能看到玩家 10001 的信息被删除，同时后面的玩家排名都向前移了一位。

如果我们想要增加某个玩家的数据，命令格式为ZADD 排行榜名称 分数 玩家标识。

时间复杂度为O(log(N))。

这里，我们把玩家 10001 的信息再增加回来，使用：ZADD user_score 93.1504697596 10001。

然后我们再来看下排名在 18031 到 18041 的玩家是谁，使用：ZREVRANGE user_score 18031 18041。

你能看到插入了玩家 10001 的数据之后，排名又回来了。

总结

今天我们使用 MySQL 和 Redis 搭建了排行榜，根据相同分数的处理方式，我们可以把排行榜分成并列排行榜和严格排行榜。虽然 MySQL 和 Redis 都可以搭建排行榜，但两者还是有区别的。MySQL 擅长存储数据，而对于数据的运算来说则效率不高，比如统计排行榜的排名，通常还是需要使用后端语言（比如 Python、PHP、Java 等）再进行统计。而 Redis 本身提供了丰富的排行榜统计功能，不论是增加、删除玩家，还是对某个玩家的分数进行调整，Redis 都可以对排行榜实时更新，对于游戏的实时排名来说，这还是很重要的。

在 Redis 中还集成了 Lua 脚本语言，通过 Lua 我们可以更加灵活地扩展 Redis 的功能，同时在 Redis 中使用 Lua 语言，还可以对 Lua 脚本进行复用，减少网络开销，编写代码也更具有模块化。此外 Redis 在调用 Lua 脚本的时候，会将它作为一个整体，也就是说中间如果有其他的 Redis 命令是不会被插入进去的，也保证了 Lua 脚本执行过程中不会被其他命令所干扰。

我们今天使用 Redis 对 10 万名玩家的数据进行了排行榜的统计，相比于用 RDBMS 实现排行榜来说，使用 Redis 进行统计都有哪些优势呢？

我们使用了 Lua 脚本模拟了 10 万名玩家的数据，其中玩家的分数 score 分成了两个部分，整数部分为实际的得分，小数部分为注册时间。例子中给出的严格排行榜是在分数相同的情况下，按照注册时间的长短进行的排名，注册时间长的排名靠前。如果我们将规则进行调整，同样是在分数相同的情况下，如果注册时间长的排名靠后，又该如何编写代码呢？

欢迎你在评论区写下你的思考，也欢迎把这篇文章分享给你的朋友或者同事，一起交流一下。

44丨DBMS篇总结和答疑：用SQLite做词云

在认识 DBMS 篇中，我们讲解了 Excel+SQL、WebSQL、SQLite 以及 Redis 的使用，这些 DBMS 有自己适用的领域，我们可以根据需求选择适合的 DBMS。我总结了一些大家常见的问题，希望能对你有所帮助。

关于 Excel+SQL

答疑 1：关于 mysql-for-excel 的安装

Excel 是我们常用的办公软件，使用 SQL 做数据分析的同学也可以使用 Excel+SQL 作为报表工具，通过它们提取一些指定条件的数据，形成数据透视表或者数据透视图。

但是有同学在安装 mysql-for-excel-1.3.8.msi 时报错，这里感谢同学莫弹弹给出了解答。解决这个问题的办法是在安装时需要 Visual Studio 2010 Tools for Office Runtime 才能运行。

它的下载链接在这里： https://www.microsoft.com/zh-CN/download/confirmation.aspx?id=56961

关于 WebSQL

我在讲解 WebSQL 操作本地存储时，可以使用浏览器中的 Clear Storage 功能。有同学问到：这里只能用户手动删除才可以吗？

事实上，除了在浏览器里手动删除以外，我们完全可以通过程序来控制本地的 SQLite。

使用 executeSql 函数即可，在 executeSql 函数后面有两个 function，分别代表成功之后的调用，以及执行失败的调用。比如想要删除本地 SQLite 的 heros 数据表，可以写成下面这样：

tx.executeSql("DROP TABLE heros",[],

function(tx, result) {alert('Drop 成功');},

function(tx, error) {alert('Drop 失败' + error.message);});

第二个问题是，Session 是什么概念呢？HTTP 请求不是无状态的吗？

我在文章中讲到过 SessionStorage，这里的 Session 指的就是一个会话周期的数据，当我们关闭浏览器窗口的时候，SessionStorage 存储的数据就会被清空。相比之下 localStorage 存储的时间没有限制，一年之后数据依然可以存在。

HTTP 本身是一个无状态的连接协议，想要保持客户端与服务器之间的交互，可以使用两种交互存储方式，即 Cookie 和 Session。

Cookie 是通过客户端保存的数据，也就是可以保存服务器发送给客户端的信息，存储在浏览器中。一般来说，在服务器上也存在一个 Session，这个是通过服务器来存储的状态信息，这时会将浏览器与服务器之间的一系列交互称为一个 Session。这种情况下，Session 会存储在服务器端。

不过我们讲解的 sessionStorage 是本地存储的解决方式，它存放在浏览器里，借用了 session 会话的概念，它指的是在本地存储过程中的一种临时存储方案，数据只有在同一个 session 会话中的页面才能访问，而且当 session 结束后数据也会释放掉。

关于 SQLite

第一个问题关于 SQLite 查找微信本地的聊天记录，有同学说可以导出聊天记录做个词云。

这是个不错的 idea，我们既然有了 SQLite，完全可以动手做个数据分析，做个词云展示。

我在《数据分析 45 讲》里讲到过词云的制作方法，这里使用 Python+SQLite 查询，将微信的聊天记录做个词云，具体代码如下：

import sqlite3

from wordcloud import WordCloud

import matplotlib.pyplot as plt

import jieba

import os

import re

# 去掉停用词

def remove_stop_words(f):

     stop_words = ['你好', '已添加', '现在', '可以', '开始', '聊天', '当前', '群聊', '人数', '过多', '显示', '群成员', '昵称', '信息页', '关闭', '参与人', '还有', '嗯']

     for stop_word in stop_words:

           f = f.replace(stop_word, '')

     return f

# 生成词云

def create_word_cloud(f):

     print('根据微信聊天记录，生成词云!')

     # 设置本地的 simhei 字体文件位置

     FONT_PATH = os.environ.get("FONT_PATH", os.path.join(os.path.dirname(__file__), "simhei.ttf"))

     f = remove_stop_words(f)

     cut_text = " ".join(jieba.cut(f,cut_all=False, HMM=True))

     wc = WordCloud(

           font_path=FONT_PATH,

           max_words=100,

           width=2000,

           height=1200,

    )

     wordcloud = wc.generate(cut_text)

     # 写词云图片

     wordcloud.to_file("wordcloud.jpg")

     # 显示词云文件

     plt.imshow(wordcloud)

     plt.axis("off")

     plt.show()

def get_content_from_weixin():

     # 创建数据库连接

     conn = sqlite3.connect("weixin.db")

     # 获取游标

     cur = conn.cursor()

     # 创建数据表

     # 查询当前数据库中的所有数据表

     sql = "SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'Chat\_%' escape '\\\'"

     cur.execute(sql)

     tables = cur.fetchall()

     content = ''

     for table in tables:

         sql = "SELECT Message FROM " + table[0]

         print(sql)

         cur.execute(sql)

         temp_result = cur.fetchall()

         for temp in temp_result:

              content = content + str(temp)

     # 提交事务

     conn.commit()

     # 关闭游标

     cur.close()

     # 关闭数据库连接

     conn.close()

     return content

content = get_content_from_weixin()

# 去掉 HTML 标签里的内容

pattern = re.compile(r'<[^>]+>',re.S)

content = pattern.sub('', content)

# 将聊天记录生成词云

create_word_cloud(content)

运行结果：

你在Github上也可以找到相应的代码，这个结果图是我运行自己的微信聊天记录得出的。

我来讲解下代码中相关模块的作用。

首先是create_word_cloud函数，通过聊天内容 f，展示出词云。这里会用到 WordCloud 类，通过它配置本地的 simhei 字体（因为需要显示中文），设置显示的最大词数max_words=100，图片的尺寸 width 和 height。

第二个是remove_stop_words函数，用来设置停用词，也就是不需要统计的单词，这里我设置了一些，不过从结果中，你能看到我们需要更多的停用词，要不会统计出一些没有意义的词汇，比如“撤回”“一条”等。

第三个是get_content_from_weixin函数。这里我们通过访问 SQLite 来访问微信聊天记录，首先需要查询数据表都有哪些，在微信的本地存储里每个数据表对应着一个聊天对象，然后我们对这些数据表中的 message 字段进行提取。

最后，因为统计出来的聊天记录会包括大量的 HTML 标签，这里我们还需要采用正则表达式匹配的方式将 content 中的 HTML 标签去掉，然后调用create_word_cloud函数生成词云，结果就是文稿中的图片所示啦。

第二个问题是，Navicat 如何导入weixin.db呢？

事实上，使用 Navicat 导入weixin.db非常简单。首先我们需要创建 SQLite 连接，然后从本地选择数据库文件，这里选中weixin.db。

然后就导入到 Navicat 中了，你在左侧可以看到 weixin 的连接，然后打开 main 数据库就可以看到聊天记录的数据表了。

我制作了演示视频，可以看下。

关于 Redis

第一个问题，MongoDB、Redis 之间有什么区别？实际使用时应该怎么选择呢？

Redis 是 Key-Value 数据库，数据存放在内存中，查询和写入都是在内存中进行操作。当然 Redis 也支持持久化，但持久化只是 Redis 的功能之一，并不是 Redis 的强项。通常，你可以把 Redis 称之为缓存，它支持的数据类型丰富，包括字符串、哈希、列表、集合、有序集合，同时还支持基数统计、地理空间以及索引半径查询、数据流等。

MongoDB 面向文档数据库，功能强大，是非关系型数据库中最像 RDBMS 的，处理增删改查也可以增加条件。

在存储方式上，Redis 将数据放在内存中，通过 RDB 或者 AOF 方式进行持久化。而 MongoDB 实际上是将数据存放在磁盘上的，只是通过 mmap 调用，将数据映射到内存中，你可以将 mmap 理解为加速的方式。mmap 调用可以使得对普通文件的操作像是在内存中进行读写一样，这是因为它将文件映射到调用进程的地址空间中，实现了文件所在的磁盘物理地址与进程空间的虚拟地址一一映射的关系，这样就可以直接在内存中进行操作，然后写完之后同步一下就可以存放到文件中，效率非常高。

不过在使用选择的时候，我们还是将 MongoDB 归为数据库，而将 Redis 归为缓存。

总的来说，Redis 就像一架飞机，查询以及写入性能极佳，但是存储的数据规模有限。MongoDB 就像高铁，在处理货物（数据）的功能上强于 Redis，同时能承载的数据量远高于 Redis，但是查询及写入的效率不及 Redis。

第三个问题是，我们能否用 Redis 中的 DECR 实现多用户抢票问题？

当然是可以的，在专栏文章中我使用了 WATCH+MULTI 的乐观锁方式，主要是讲解这种乐观锁的实现方式。我们也可以使用 Redis 中的 DECR 命令，对相应的 KEY 值进行减 1，操作是原子性的，然后我们判断下 DECR 之后的数值即可，当减 1 之后大于等于 0 证明抢票成功，否则小于 0 则说明抢票失败。

这里我给出了相应的代码，你也可以在Github上下载。

# 抢票模拟，使用 DECR 原子操作

import redis

import threading

# 创建连接池

pool = redis.ConnectionPool(host = '127.0.0.1', port=6379, db=0)

# 初始化 redis

r = redis.StrictRedis(connection_pool = pool)

# 设置 KEY

KEY="ticket_count"

# 模拟第 i 个用户进行抢购

def sell(i):

    # 使用 decr 对 KEY 减 1

    temp = r.decr(KEY)

    if temp >= 0:

        print('用户 {} 抢票成功，当前票数 {}'.format(i, temp))

    else:

        print('用户 {} 抢票失败，票卖完了'.format(i))

if __name__ == "__main__":

    # 初始化 5 张票

    r.set(KEY, 5)

    # 设置 8 个人抢票

    for i in range(8):

        t = threading.Thread(target=sell, args=(i,))

        t.start()

最后有些同学感觉用 Redis，最终还是需要结合程序以及 MySQL 来处理，因为排行榜展示在前端还是需要用户名的，光给个用户 id 不知道是谁，除非 Redis 有序集合的 member 包含了用户 id 和 name。

这里，排行榜中如果要显示用户名称，需要放到有序集合中，这样就不需要再通过 MySQL 查询一次。这种需要实时排名计算的，通过 Redis 解决更适合。如果是排行榜生成之后，用户想看某一个用户具体的信息，比如地区、战绩、使用英雄情况等，可以通过 MySQL 来进行查询。而对于热点数据使用 Redis 进行缓存，可以解决高并发情况下的数据库读压力。

所以你能看到 Redis 通常可以作为 MySQL 的缓存，它存储的数据量有限，适合存储热点数据，可以解决读写效率要求很高的请求。而 MySQL 则作为数据库，提供持久化功能，并通过主从架构提高数据库服务的高可用性。

最后留两个思考题。

我在文稿中，使用 SQLite 对于微信聊天记录进行查询，使用 wordcloud 词云工具对聊天记录进行词云展示。同时，我将聊天记录文本保存下来，一共 4.82M（不包括 HTML 标签内容），你可以使用 SQLite 读取微信聊天记录，然后看下纯文本大小有多少？

第二个问题是，我们使用 Redis 作为 MySQL 的缓存，假设 MySQL 存储了 1000 万的数据，Redis 只保存有限的数据，比如 10 万数据量，如何保证 Redis 存储的数据都是热点数据呢？

欢迎你在评论区写下你的思考，也欢迎把这篇文章分享给你的朋友或者同事，一起交流一下。

45丨数据清洗：如何使用SQL对数据进行清洗？

SQL 可以帮我们进行数据处理，总的来说可以分成 OLTP 和 OLAP 两种方式。

OLTP 称之为联机事务处理，我们之前讲解的对数据进行增删改查，SQL 查询优化，事务处理等就属于 OLTP 的范畴。它对实时性要求高，需要将用户的数据有效地存储到数据库中，同时有时候针对互联网应用的需求，我们还需要设置数据库的主从架构保证数据库的高并发和高可用性。

OLAP 称之为联机分析处理，它是对已经存储在数据库中的数据进行分析，帮我们得出报表，指导业务。它对数据的实时性要求不高，但数据量往往很大，存储在数据库（数据仓库）中的数据可能还存在数据质量的问题，比如数据重复、数据中有缺失值，或者单位不统一等，因此在进行数据分析之前，首要任务就是对收集的数据进行清洗，从而保证数据质量。

对于数据分析工作来说，好的数据质量才是至关重要的，它决定了后期数据分析和挖掘的结果上限。数据挖掘模型选择得再好，也只能最大化地将数据特征挖掘出来。

高质量的数据清洗，才有高质量的数据。今天我们就来看下，如何用 SQL 对数据进行清洗。

想要进行数据清洗有怎样的准则呢？

如何使用 SQL 对数据进行清洗？

如何对清洗之后的数据进行可视化？

数据清洗的准则

我在《数据分析实战 45 讲》里专门讲到过数据清洗的原则，这里为了方便你理解，我用一个数据集实例讲一遍。

一般而言，数据集或多或少地会存在数据质量问题。这里我们使用泰坦尼克号乘客生存预测数据集，你可以从GitHub上下载这个数据集。

数据集格式为 csv，一共有两种文件：train.csv 是训练数据集，包含特征信息和存活与否的标签；test.csv 是测试数据集，只包含特征信息。

数据集中包括了以下字段，具体的含义如下：

训练集给出了 891 名乘客幸存与否的结果，以及相关的乘客信息。通过训练集，我们可以对数据进行建模形成一个分类器，从而对测试集中的乘客生存情况进行预测。不过今天我们并不讲解数据分析的模型，而是来看下在数据分析之前，如何对数据进行清洗。

首先，我们可以通过 Navicat 将 CSV 文件导入到 MySQL 数据库中，然后浏览下数据集中的前几行，可以发现数据中存在缺失值的情况还是很明显的。

数据存在数据缺失值是非常常见的情况，此外我们还需要考虑数据集中某个字段是否存在单位标识不统一，数值是否合法，以及数据是否唯一等情况。要考虑的情况非常多，这里我将数据清洗中需要考虑的规则总结为 4 个关键点，统一起来称之为“完全合一”准则，你可以点这里看一下。

“完全合一”是个通用的准则，针对具体的数据集存在的问题，我们还需要对症下药，采取适合的解决办法，甚至为了后续分析方便，有时我们还需要将字符类型的字段替换成数值类型，比如我们想做一个 Steam 游戏用户的数据分析，统计数据存储在两张表上，一个是 user_game 数据表，记录了用户购买的各种 Steam 游戏，其中数据表中的 game_title 字段表示玩家购买的游戏名称，它们都采用英文字符的方式。另一个是 game 数据表，记录了游戏的 id、游戏名称等。因为这两张表存在关联关系，实际上在 user_game 数据表中的 game_title 对应了 game 数据表中的 name，这里我们就可以用 game 数据表中的 id 替换掉原有的 game_title。替换之后，我们在进行数据清洗和质量评估的时候也会更清晰，比如如果还存在某个 game_title 没有被替换的情况，就证明这款游戏在 game 数据表中缺少记录。

使用 SQL 对预测数据集进行清洗

了解了数据清洗的原则之后，下面我们就用 SQL 对泰坦尼克号数据集中的训练集进行数据清洗，也就是 train.csv 文件。我们先将这个文件导入到 titanic_train 数据表中：

检查完整性

在完整性这里，我们需要重点检查字段数值是否存在空值，在此之前，我们需要先统计每个字段空值的个数。在 SQL 中，我们可以分别统计每个字段的空值个数，比如针对 Age 字段进行空值个数的统计，使用下面的命令即可：

SELECT COUNT(*) as num FROM titanic_train WHERE Age IS NULL

运行结果为 177。

当然我们也可以同时对多个字段的非空值进行统计：

SELECT

SUM((CASE WHEN Age IS NULL THEN 1 ELSE 0 END)) AS age_null_num,

SUM((CASE WHEN Cabin IS NULL THEN 1 ELSE 0 END)) AS cabin_null_num

FROM titanic_train

运行结果：

不过这种方式适用于字段个数较少的情况，如果一个数据表存在几十个，甚至更多的字段，那么采用这种方式既麻烦又容易出错。这时我们可以采用存储过程的方式，用程序来进行字段的空值检查，代码如下：

CREATE PROCEDURE `check_column_null_num`(IN schema_name VARCHAR(100), IN table_name2 VARCHAR(100))

BEGIN

-- 数据表 schema_name 中的列名称

DECLARE temp_column VARCHAR(100);

-- 创建结束标志变量

DECLARE done INT DEFAULT false;

-- 定义游标来操作每一个 COLUMN_NAME

DECLARE cursor_column CURSOR FOR

SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE table_schema = schema_name AND table_name = table_name2;

-- 指定游标循环结束时的返回值

DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = true;

-- 打开游标

OPEN cursor_column;

read_loop:LOOP

           FETCH cursor_column INTO temp_column;

           -- 判断游标的循环是否结束

           IF done THEN

                    LEAVE read_loop;

           END IF;

           -- 这里需要设置具体的 SQL 语句 temp_query

           SET @temp_query=CONCAT('SELECT COUNT(*) as ', temp_column, '_null_num FROM ', table_name2, ' WHERE ', temp_column, ' IS NULL');

           -- 执行 SQL 语句

           PREPARE stmt FROM @temp_query;

           EXECUTE stmt;

END LOOP;

-- 关闭游标

CLOSE cursor_column;

END

我来说下这个存储过程的作用，首先我定义了两个输入的参数 schema_name 和 table_name2，用来接收想要检查的数据库的名称以及数据表名。

然后使用游标来操作读取出来的 column_name，赋值给变量 temp_column。对于列名，我们需要检查它是否为空，但是这个列名在 MySQL 中是动态的，我们无法使用 @temp_column 来表示列名，对其进行判断，在这里我们需要使用 SQL 拼接的方式，这里我设置了 @temp_query 表示想要进行查询的 SQL 语句，然后设置 COUNT(*) 的别名为动态别名，也就是 temp_column 加上 _null_num，同样在 WHERE 条件判断中，我们使用 temp_column 进行动态列名的输出，以此来判断这个列数值是否为空。

然后我们执行这个 SQL 语句，提取相应的结果。

call check_column_null_num('wucai', 'titanic_train');

运行结果如下：

Age_null_num：177

Cabin_null_num：687

Embarked_null_num：2

Fare_null_num：0

Name_null_num：0

Parch_null_num：0

PassengerId_null_num：0

Pclass_null_num：0

Sex_null_num：0

SibSp_null_num：0

Survived_null_num：0

Ticket_null_num：0

为了浏览方便我调整了运行结果的格式，你能看到在 titanic_train 数据表中，有 3 个字段是存在空值的，其中 Cabin 空值数最多为 687 个，Age 字段空值个数 177 个，Embarked 空值个数 2 个。

既然存在空值的情况，我们就需要对它进行处理。针对缺失值，我们有 3 种处理方式。

删除：删除数据缺失的记录；

均值：使用当前列的均值；

高频：使用当前列出现频率最高的数据。

对于 Age 字段，这里我们采用均值的方式进行填充，但如果直接使用 SQL 语句可能会存在问题，比如下面这样。

UPDATE titanic_train SET age = (SELECT AVG(age) FROM titanic_train) WHERE age IS NULL

这时会报错：

1093 - You can't specify target table 'titanic_train' for update in FROM clause

也就是说同一条 SQL 语句不能先查询出来部分内容，再同时对当前表做修改。

这种情况下，最简单的方式就是复制一个临时表 titanic_train2，数据和 titanic_train 完全一样，然后再执行下面这条语句：

UPDATE titanic_train SET age = (SELECT ROUND(AVG(age),1) FROM titanic_train2) WHERE age IS NULL

这里使用了 ROUND 函数，对 age 平均值 AVG(age) 进行四舍五入，只保留小数点后一位。

针对 Cabin 这个字段，我们了解到这个字段代表用户的船舱位置，我们先来看下 Cabin 字段的数值分布情况：

SELECT COUNT(cabin), COUNT(DISTINCT(cabin)) FROM titanic_train

运行结果：

从结果中能看出 Cabin 字段的数值分布很广，而且根据常识，我们也可以知道船舱位置每个人的差异会很大，这里既不能删除掉记录航，又不能采用均值或者高频的方式填充空值，实际上这些空值即无法填充，也无法对后续分析结果产生影响，因此我们可以不处理这些空值，保留即可。

然后我们来看下 Embarked 字段，这里有 2 个空值，我们可以采用该字段中高频值作为填充，首先我们先了解字段的分布情况使用：

SELECT COUNT(*), embarked FROM titanic_train GROUP BY embarked

运行结果：

我们可以直接用 S 来对缺失值进行填充：

UPDATE titanic_train SET embarked = 'S' WHERE embarked IS NULL

至此，对于 titanic_train 这张数据表中的缺失值我们就处理完了。

检查全面性

在这个过程中，我们需要观察每一列的数值情况，同时查看每个字段的类型。

因为数据是直接从 CSV 文件中导进来的，所以每个字段默认都是 VARCHAR(255) 类型，但很明显 PassengerID、Survived、Pclass 和 Sibsp 应该设置为 INT 类型，Age 和 Fare 应该设置为 DECIMAL 类型，这样更方便后续的操作。使用下面的 SQL 命令即可：

ALTER TABLE titanic_train CHANGE PassengerId PassengerId INT(11) NOT NULL PRIMARY KEY;

ALTER TABLE titanic_train CHANGE Survived Survived INT(11) NOT NULL;

ALTER TABLE titanic_train CHANGE Pclass Pclass INT(11) NOT NULL;

ALTER TABLE titanic_train CHANGE Sibsp Sibsp INT(11) NOT NULL;

ALTER TABLE titanic_train CHANGE Age Age DECIMAL(5,2) NOT NULL;

ALTER TABLE titanic_train CHANGE Fare Fare DECIMAL(7,4) NOT NULL;

然后我们将其余的字段（除了 Cabin）都进行 NOT NULL，这样在后续进行数据插入或其他操作的时候，即使发现数据异常，也可以对字段进行约束规范。

在全面性这个检查阶段里，除了字段类型定义需要修改以外，我们没有发现其他问题。

然后我们来检查下合法性及唯一性。合法性就是要检查数据内容、大小等是否合法，这里不存在数据合法性问题。

针对数据是否存在重复的情况，我们刚才对 PassengerId 字段类型进行更新的时候设置为了主键，并没有发现异常，证明数据是没有重复的。

对清洗之后的数据进行可视化

我们之前讲到过如何通过 Excel 来导入 MySQL 中的数据，以及如何使用 Excel 来进行数据透视表和数据透视图的呈现。

这里我们使用 MySQL For Excel 插件来进行操作，在操作之前有两个工具需要安装。

首先是 mysql-for-excel，点击这里进行下载；然后是 mysql-connector-odbc，点击这里进行下载。

安装好之后，我们新建一个空的 excel 文件，打开这个文件，在数据选项中可以找到“MySQL for Excel”按钮，点击进入，然后输入密码连接 MySQL 数据库。

然后选择我们的数据库以及数据表名称，在下面可以找到 Import MySQL Data 按钮，选中后将数据表导入到 Excel 文件中。

在“插入”选项中找到“数据透视图”，这里我们选中 Survived、Sex 和 Embarked 字段，然后将 Survive 字段放到图例（系列）栏中，将 Sex 字段放到求和值栏中，可以看到呈现出如下的数据透视表：

从这个透视表中你可以清晰地了解到用户生存情况（Survived）与 Embarked 字段的关系，当然你也可以通过数据透视图进行其他字段之间关系的探索。

为了让你能更好地理解操作的过程，我录制了一段操作视频。

总结

在数据清洗过程中，你能看到通过 SQL 来进行数据概览的查询还是很方便的，但是使用 SQL 做数据清洗，会有些繁琐，这时你可以采用存储过程对数据进行逐一处理，当然你也可以使用后端语言，比如使用 Python 来做具体的数据清洗。

在进行数据探索的过程中，我们可能也会使用到数据可视化，如果不采用 Python 进行可视化，你也可以选择使用 Excel 自带的数据透视图来进行可视化的呈现，它会让你对数据有个更直观的认识。

今天讲解的数据清洗的实例比较简单，实际上数据清洗是个反复的过程，有时候我们需要几天时间才能把数据完整清洗好。你在工作中，会使用哪些工具进行数据清洗呢？

另外，数据缺失问题在数据清洗中非常常见，我今天列举了三种填充数据缺失的方式，分别是删除、均值和高频的方式。实际上缺失值的处理方式不局限于这三种，你可以思考下，如果数据量非常大，某个字段的取值分布也很广，那么对这个字段中的缺失值该采用哪种方式来进行数据填充呢？

欢迎你在评论区写下你的思考，也欢迎把这篇文章分享给你的朋友或者同事，一起交流一下。

47丨如何利用SQL对零售数据进行分析？

我们通过 OLTP 系统实时捕捉到了用户的数据，还需要在 OLAP 系统中对它们进行分析。之前我们讲解了如何对数据进行清洗，以及如何对分散在不同地方的数据进行集成，今天我们来看下如何使用 SQL 分析这些数据。

关于这部分内容，今天我们一起来学习下：

使用 SQL 进行数据分析都有哪几种姿势？

如何通过关联规则挖掘零售数据中的频繁项集？

如何使用 SQL+Python 完成零售数据的关联分析？

使用 SQL 进行数据分析的 5 种姿势

在 DBMS 中，有些数据库管理系统很好地集成了 BI 工具，可以方便我们对收集的数据进行商业分析。

SQL Server 提供了 BI 分析工具，我们可以通过使用 SQL Server 中的 Analysis Services 完成数据挖掘任务。SQL Server 内置了多种数据挖掘算法，比如常用的 EM、K-Means 聚类算法、决策树、朴素贝叶斯和逻辑回归等分类算法，以及神经网络等模型。我们还可以对这些算法模型进行可视化效果呈现，帮我们优化和评估算法模型的好坏。

PostgreSQL 是免费开源的对象 - 关系数据库（ORDBMS），它的稳定性非常强，功能强大，在 OLTP 和 OLAP 系统上表现都非常出色。同时在机器学习上，配合 Madlib 项目可以让 PostgreSQL 如虎添翼。Madlib 包括了多种机器学习算法，比如分类、聚类、文本分析、回归分析、关联规则挖掘和验证分析等功能。这样我们可以通过使用 SQL，在 PostgreSQL 中使用各种机器学习算法模型，帮我们进行数据挖掘和分析。

2018 年 Google 将机器学习（Machine Learning）工具集成到了 BigQuery 中，发布了 BigQuery ML，这样开发者就可以在大型的结构化或半结构化的数据集上构建和使用机器学习模型。通过 BigQuery 控制台，开发者可以像使用 SQL 语句一样来完成机器学习模型的训练和预测。

SQLFlow 是蚂蚁金服于 2019 年开源的机器学习工具，我们通过使用 SQL 就可以完成机器学习算法的调用，你可以将 SQLFlow 理解为机器学习的翻译器。我们在 SELECT 之后加上 TRAIN 从句就可以完成机器学习模型的训练，在 SELECT 语句之后加上 PREDICT 就可以使用模型来进行预测。这些算法模型既包括了传统的机器学习模型，也包括了基于 Tensorflow、PyTorch 等框架的深度学习模型。

从下图中你也能看出 SQLFlow 的使用过程，首先我们可以通过 Jupyter notebook 来完成 SQL 语句的交互。SQLFlow 支持了多种 SQL 引擎，包括 MySQL、Oracle、Hive、SparkSQL 和 Flink 等，这样我们就可以通过 SQL 语句从这些 DBMS 中抽取数据，然后选择想要进行的机器学习算法（包括传统机器学习和深度学习模型）进行训练和预测。不过这个工具刚刚上线，工具、文档、社区还有很多需要完善的地方。

最后一个方法是 SQL+Python，也是我们今天要讲解的内容。刚才介绍的工具可以说既是 SQL 查询数据的入口，也是数据分析、机器学习的入口。不过这些模块耦合度高，也可能存在使用的问题。一方面工具会很大，比如在安装 SQLFlow 的时候，采用 Docker 方式（下图为使用 Docker 安装 sqlflow 的过程）进行安装，整体需要下载的文件会超过 2G。同时，在进行机器学习算法调参、优化的时候也存在灵活度差的情况。因此最直接的方式，还是将 SQL 与机器学习模块分开，采用 SQL 读取数据，然后通过 Python 来进行机器学习的处理。

案例：挖掘零售数据中的频繁项集与关联规则

刚才我们讲解了如何通过 SQL 来完成数据分析（机器学习）的 5 种姿势，下面我们还需要通过一个案例来进行具体的讲解。

我们要分析的是购物篮问题，采用的技术为关联分析。它可以帮我们在大量的数据集中找到商品之间的关联关系，从而挖掘出经常被人们购买的商品组合，一个经典的例子就是“啤酒和尿布”的例子。

今天我们的数据集来自于一个面包店的 21293 笔订单，字段包括了 Date（日期）、Time（时间）、Transaction（交易 ID）以及 Item(商品名称)。其中交易 ID 的范围是 [1,9684]，在这中间也有一些交易 ID 是空缺的，同一笔交易中存在商品重复的情况。除此以外，有些交易是没有商品的，也就是对应的 Item 为 NONE。具体的数据集你可以从GitHub上下载。

我们采用的关联分析算法是 Apriori 算法，它帮我们查找频繁项集，首先我们需要先明白什么是频繁项集。

频繁项集就是支持度大于等于最小支持度阈值的项集，小于这个最小值支持度的项目就是非频繁项集，而大于等于最小支持度的项集就是频繁项集。支持度是个百分比，指的是某个商品组合出现的次数与总次数之间的比例。支持度越高，代表这个组合出现的频率越大。

我们来看个例子理解一下，下面是 5 笔用户的订单，以及每笔订单购买的商品：

在这个例子中，“牛奶”出现了 4 次，那么这 5 笔订单中“牛奶”的支持度就是 4/5=0.8。同样“牛奶 + 面包”出现了 3 次，那么这 5 笔订单中“牛奶 + 面包”的支持度就是 3/5=0.6。

同时，我们还需要理解一个概念叫做“置信度”，它表示的是当你购买了商品 A，会有多大的概率购买商品 B，在这个例子中，置信度（牛奶→啤酒）=2/4=0.5，代表如果你购买了牛奶，会有 50% 的概率会购买啤酒；置信度（啤酒→牛奶）=2/3=0.67，代表如果你购买了啤酒，有 67% 的概率会购买牛奶。

所以说置信度是个条件概念，指的是在 A 发生的情况下，B 发生的概率是多少。

我们在计算关联关系的时候，往往需要规定最小支持度和最小置信度，这样才可以寻找大于等于最小支持度的频繁项集，以及在频繁项集的基础上，大于等于最小置信度的关联规则。

使用 SQL+Python 完成零售数据的关联分析

针对上面的零售数据关联分析的案例，我们可以使用工具自带的关联规则进行分析，比如使用 SQL Server Analysis Services 的多维数据分析，或者是在 Madlib、BigQuery ML、SQLFlow 工具中都可以找到相应的关联规则，通过写 SQL 的方式就可以完成关联规则的调用。

除此以外，我们还可以直接使用 SQL 完成数据的查询，然后通过 Python 的机器学习工具包完成关联分析。下面我们通过之前讲解的 SQLAlchemy 来完成 SQL 查询，使用 efficient_apriori 工具包的 Apriori 算法。整个工程一共包括 3 个部分。

第一个部分为数据加载，首先我们通过 sql.create_engine 创建 SQL 连接，然后从 bread_basket 数据表中读取全部的数据加载到 data 中。这里需要配置你的 MySQL 账户名和密码

第二步为数据预处理，因为数据中存在无效的数据，比如 item 为 NONE 的情况，同时 Item 的大小写格式不统一，因此我们需要先将 Item 字段都转换为小写的形式，然后去掉 Item 字段中数值为 none 的项。在数据预处理中，我们还需要得到一个 transactions 数组，里面包括了每笔订单的信息，其中每笔订单是以集合的形式进行存储的，这样相同的订单中 item 就不存在重复的情况，同时也可以使用 Apriori 工具包直接进行计算。

最后一步，使用 Apriori 工具包进行关联分析，这里我们设定了参数 min_support=0.02，min_confidence=0.5，也就是最小支持度为 0.02，最小置信度为 0.5。根据条件找出 transactions 中的频繁项集 itemsets 和关联规则 rules。

具体的代码如下：

from efficient_apriori import apriori

import sqlalchemy as sql

import pandas as pd

# 数据加载

engine = sql.create_engine('mysql+mysqlconnector://root:passwd@localhost/wucai')

query = 'SELECT * FROM bread_basket'

data = pd.read_sql_query(query, engine)

# 统一小写

data['Item'] = data['Item'].str.lower()

# 去掉 none 项

data = data.drop(data[data.Item == 'none'].index)

# 得到一维数组 orders_series，并且将 Transaction 作为 index, value 为 Item 取值

orders_series = data.set_index('Transaction')['Item']

# 将数据集进行格式转换

transactions = []

temp_index = 0

for i, v in orders_series.items():

    if i != temp_index:

       temp_set = set()

       temp_index = i

       temp_set.add(v)

       transactions.append(temp_set)

    else:

       temp_set.add(v)

# 挖掘频繁项集和频繁规则

itemsets, rules = apriori(transactions, min_support=0.02,  min_confidence=0.5)

print('频繁项集：', itemsets)

print('关联规则：', rules)

运行结果：

频繁项集： {1: {('alfajores',): 344, ('bread',): 3096, ('brownie',): 379, ('cake',): 983, ('coffee',): 4528, ('cookies',): 515, ('farm house',): 371, ('hot chocolate',): 552, ('juice',): 365, ('medialuna',): 585, ('muffin',): 364, ('pastry',): 815, ('sandwich',): 680, ('scandinavian',): 275, ('scone',): 327, ('soup',): 326, ('tea',): 1350, ('toast',): 318, ('truffles',): 192}, 2: {('bread', 'cake'): 221, ('bread', 'coffee'): 852, ('bread', 'pastry'): 276, ('bread', 'tea'): 266, ('cake', 'coffee'): 518, ('cake', 'tea'): 225, ('coffee', 'cookies'): 267, ('coffee', 'hot chocolate'): 280, ('coffee', 'juice'): 195, ('coffee', 'medialuna'): 333, ('coffee', 'pastry'): 450, ('coffee', 'sandwich'): 362, ('coffee', 'tea'): 472, ('coffee', 'toast'): 224}}

关联规则： [{cake} -> {coffee}, {cookies} -> {coffee}, {hot chocolate} -> {coffee}, {juice} -> {coffee}, {medialuna} -> {coffee}, {pastry} -> {coffee}, {sandwich} -> {coffee}, {toast} -> {coffee}]

从结果中你能看到购物篮组合中，商品个数为 1 的频繁项集有 19 种，分别为面包、蛋糕、咖啡等。商品个数为 2 的频繁项集有 14 种，包括（面包，蛋糕），（面包，咖啡）等。其中关联规则有 8 种，包括了购买蛋糕的人也会购买咖啡，购买曲奇的同时也会购买咖啡等。

总结

通过 SQL 完成机器学习往往还是需要使用到 Python，因为数据分析是 Python 的擅长。通过今天的学习你应该能体会到采用 SQL 工具作为数据查询和分析的入口是一种数据全栈的思路，对于开发人员来说降低了数据分析的技术门槛。

如果你想要对机器学习或者数据分析算法有更深入的理解，也可以参考我的《数据分析实战 45 讲》专栏，相信在当今的数据时代，我们的业务增长会越来越依靠于 SQL 引擎 +AI 引擎。

我在文章中举了一个购物篮分析的例子，如下图所示，其中（牛奶、面包、尿布）的支持度是多少呢？

欢迎你在评论区写下你的思考，也欢迎把这篇文章分享给你的朋友或者同事，一起交流一下。

结束语 | 互联网的下半场是数据驱动的时代

你好，我是陈旸。

看到结束语的时候，就意味着 SQL 专栏已经结束了。我希望这个专栏可以帮你打开数据的大门，从而通过数据发现更多的宝藏。

互联网已经进入到下半场是不争的事实。我们每天不是想着下载某个新的 APP，而是选择卸载掉哪个不经常用的 APP。对于互联网新业务来说，我们处于一个流量稀缺的时代，用户不再野蛮增长。这也是为什么很多互联网企业转向修炼内功，开始注重内部效率的提升。而数据则是这项内功最核心的部分，向内寻找答案，通过数据的力量驱动增长。

回想一下，互联网上半场的打法可以说是营销和资本驱动。几年前我们动不动就搞个活动，送几个 iPhone 手机就可以换取免费流量。我走访过一些大型的互联网企业，它们对于用户数据的重视程度远不如今天，甚至是很多数据都没有及时存储下来，这样就意味着，他们想要使用之前的数据做精细化运营，就无从下手。真是非常可惜。

而互联网的下半场是数据和技术在驱动。当用户不再“便宜”的时候，我们开始重视用户数据的完善，开始重视如何通过数据来挖掘同一个用户更多的价值。实际上，在这个层面上，我看到了更多数据技术的机会，很明显这几年招聘的岗位中，数据相关的工作受到越来越多的重视。如何有效地存储，管理和使用这些数据是企业提升内部效率中的重要环节。

对于和数据打交道的技术人员来说，这是非常好的时代，那该如何构建我们的数据知识体系呢？

越基础的内容，越重要

就像我在专栏开篇讲到的一样，SQL 是一门半衰期很长的语言，经历过长时间的考验，不仅现在在程序语言排行榜名列前茅，未来也依然是主流的语言。

SQL 的通用性也会越来越强，不光是技术人员，很多产品经理，运营人员都在学习 SQL。我的一个初中同学，现在在今日头条负责消费金融业务的运营，他们整个部门的人都在学习使用 SQL，而她是金融专业背景，之前并没有写过代码。同样，我们还看到一些最新的 AI 技术也开始在 SQL 工具中进行集成，比如最后一篇文章中我们介绍的那些工具。

实际上相比于各种 AI 技术来说，SQL 语言很少有大的变化。这是因为越基础的内容，大的改动频率就会越少，半衰期越长。同时作为整个应用的基石，发挥的作用也会越大。而越上层的应用，虽然离用户离场景更近，但是变化更新的频率也会越快，更加多样性。比如在 AI 技术中，我们会存在传统机器学习，深度学习和自动机器学习，同样在深度学习中，也存在着多种神经网络，比如 CNN（卷积神经网络），RNN（循环神经网络），DBN（深度信念网络），GAN（生成对抗网络），DRL（深度强化学习）等。即便是一种神经网络，比如 CNN，也存在着不同的网络结构（Le-Net、VGG、GoogleNet、ResNet、DenseNet 等）。这些技术更新频率非常快，你可能刚掌握了一个网络结构，又有新的网络结构推出来。而 SQL 语言，45 年中并没有大的变化，现在学习和使用的技能，在未来依然可以发挥作用。

同时，越基础的内容，使用频率也会越高。实际工作中，很多时候我们并不需要太多“先进”的技术，对于大多数的数据分析需求来说，一些 SQL 查询就可以帮我们做很多统计的工作。

将 SQL 作为入口，构建你的 DT 大厦

实际上，SQL 已经成为我们获取数据的入口，这也意味着通过 SQL 可能会衍生出一个完整的 DT 生态，各种个性化的数据分析工具将集成在 SQL 中，未来你可以像使用 SQL 语言一样来使用这些机器学习、数据分析的工具。

DT 时代，SQL 是数据的入口，是构建我们整个数据大厦的基石，在此基础上你可以使用现有的各种算法，也可以改进算法对数据进行分析。最后是进行可视化报表的呈现，对于老板们来说这是他们更关心的部分，因为他们更关注结果，而中间分析的过程依靠于各种数据分析算法，底层的数据来源自 DBMS，可以通过 SQL 来进行获取。

每天积累一点，做重要不紧急的事

DT（Data Technology）全栈在互联网的下半场会发挥越来越重要的作用，如果你未来想从事数据相关的工作，那么 SQL 是这个 DT 大厦中非常重要且使用频率很高的基石。在此基础上，我们还可以掌握各种数据分析的能力，以及数据可视化的能力。

如果我们把事情按照重要程度和紧急程度，划分成 4 个象限的话，我希望能在重要不紧急这个象限上，你可以每天积累一点点。

在重要 & 紧急这个象限上的事情，会让你压力无限大，无法做长远的学习积累。比如明天马上要交一个工作汇报，程序 Bug 马上需要 fix 掉然后上线等。

重要 & 不紧急这个象限上的事情能让你做更长远的计划，像是做投资一样播下一棵种子，在未来会有新的收获。

不重要 & 紧急，会让我们忙碌且盲目。不重要 & 不紧急，让我们浪费生命时间。

这两个象限的事情，做与不做其实区别不大。

你可以把技术的学习过程当成是一个重要不紧急的过程，虽然不是你明天马上要完成的工作，但它们却是你未来工作中非常重要的工具。

虽然 SQL 专栏结束了，但我希望这个专栏能开启你的数据学习之旅。不论你是从事 DBA 的工作，还是后端开发，数据算法工作，还是产品，运营岗位，SQL 都将是你未来工作中需要掌握的重要技能，在数据全栈这条道路上，每天积累一点点，终究可以搭建出来属于你的 DT 全栈之路。

最后文章结尾处有一份调查问卷，希望你可以抽出两三分钟填写一下。我想认真倾听你对这个专栏的意见或者建议，期待你的反馈！