117-后端存储实战课

开篇词 | 从今天起，换种方式学存储

你好，我是李玥，《消息队列高手课》专栏的作者，目前在京东任职架构师。这是我在极客时间上的第二门课程，很高兴在这里遇见你。

在十多年的开发者职业生涯中，我的从业经历应该算是比较丰富的。在传统 IT 行业，做过非常多的企业级 ToB 的系统；转战互联网后，我又曾经带领创业团队体会过从 0 到 1 创业的艰辛，见证过互联网高速增长的高光时刻，也经历过京东数年大促的洗礼。

在工作过程中，我接触过很多系统。不同的系统，它的业务不一样，有做社交的，有做电商的，还有做内容的。系统的规模也不一样，有很小规模的系统，也有像 BAT 这样的巨无霸系统。在构建这些系统时，都会面临五花八门的问题。但总结下来我发现一个“神奇”的规律：

凡是那些特别难解决的、让你付出巨大代价的，或者是损失惨重的技术问题，几乎都可以归为存储系统的问题。

这个规律其实并不神奇，它是有原因的。

你可以想一下，我们开发的各种业务系统，几乎都是 MIS 系统，中文叫“管理信息系统”，有的大学还有这个专业。管理信息系统这个词的含义就是字面的意思：管理信息的系统。这里面的信息是什么？对，其实就是数据。不管你的系统业务是什么样的，最终都要落到对信息的管理，或者通俗点儿说，你系统的业务功能最终的结果就是数据。

只要这个数据是正确的，剩下的都是小问题。数据错了、丢了，甚至数据处理不及时，这些都是损失惨重的大问题。

所以用于承载数据的存储系统就显得非常重要，如果说，你能构建一个安全可靠、快速稳定的存储系统，基于这个基础之上构建的业务系统，显然就让人放心多了。

所以说，存储是系统中最核心、最重要、最关键的组成部分，没有之一。

你要关注存储的哪些特点？

我们常用的存储系统有很多，有单机的也有分布式的，有数据库、文件系统，还有一些介于二者之间的，种类非常多。无论是什么样的存储，比如 MySQL、Redis、ElasticSearch 等等，它们都有几个共同的特点。

第一个特点是难用。怎么难用呢？对于应用程序来说，存储无非就是帮我们安全可靠地保存数据，在我需要的时候能快速读出来也就可以了。很遗憾，几乎没有存储系统能满足这么简单的要求。

有一个非常形象的比喻：我开着车去商场购物，到了停车场发现这个停车场不能存车，只能存零件。我必须自己把车拆了，然后把这些零件分门别类打上标签，存放到停车场货架上，走的时候自己再把零件取出来把车组装起来。

听起来很可笑是吧，你想想咱们用的这些存储系统，不就是这样吗？我们应用程序里管理的数据都是对象是吧？你告诉我哪个存储系统能存对象？没有吧？

拿 MySQL 举例，我要存取对象，必须把对象转换成 MySQL 表中的行，还得写 SQL 语句才能存取。是不是很难用？难用你还不得不用，并且还得把它给用好了，这里面有很多的方法、技巧和实践经验需要学习掌握。

第二个特点是慢。最近几年的技术圈，分布式存储这块儿非常繁荣，你可以看到过一段时间就有一个新的数据库诞生了，不管它们功能怎么样，无一例外，都说自己的有多快，性能多好，顺便把像 MySQL 这样的老家伙拉出来，做个性能对比测试，吊打一遍。

“一个人炫耀什么，说明内心缺少什么”，这个道理放到技术圈同样适用，不断有新的存储刷新性能记录，恰恰说明了存储系统性能不能让人满意。

一个经过良好优化过的业务系统，它的性能瓶颈一定是存储。从性能角度上来说，存储系统就是整个系统中最短的那块儿板子，存储系统有多慢，你的整个系统就多慢。如何优化存储性能，从而让整个系统运转如飞，这里面同样有很多的方法、技巧和经验需要掌握。

第三个特点是杂。存储这块儿不像其他的成熟的技术领域，基本上都是一两种方案包打天下，比如 Java 开发，基本上就被 Spring 统治了，再比如 Web 容器，静态用 Nginx，动态 Tomcat。但存储这块儿却不是这样的，就拿真正广泛应用在生产系统中的存储来说，你看有多少种？

MySQL、Redis、ElasticSearch、HBase、Hive、MongoDB、RocksDB、CockroachDB 等等，这些存储还真就是谁都替代不了谁，每一种都有它擅长的地方，有它适用的场景，当然也有很突出的短板。如何根据业务系统的特点，选择合适的存储来构建我们的系统，这也是需要学习和掌握的。

学习存储的最佳姿势是什么样的？

既然存储有“难用、慢、杂”这几大特点，学习起来就更需要注意方法。如何来学最高效呢？我认为是实战，从问题入手。

存储涉及到很多理论知识和概念，比如各种数据结构、哈希、树以及它们的时间复杂度等等，这些内容往往都是偏数学范畴的一些知识，学起来不容易理解和记忆。并且，理论和实践之间往往存在着非常大的鸿沟，往往是“懂了一堆道理，却还是写不好代码”。

所以，我在极客时间上开了第二个专栏讲存储，我们只讲实践中大家都会遇到的问题，讲这些问题的解决方法，同时在这里面贯穿一些知识和原理。通过这样的学习方式，既可以快速地帮你解决实际问题，同时还能提升你的技术能力。

在接下来的课程中，我会带你一起，从 0 到 1，从小到大，以电商作为场景，讲解不同规模的存储系统应该如何构建。

每一节课，我们一起解决一两个实战的问题，比如：为什么明明数据量和访问量不大，MySQL 还是很慢？数据库宕机了怎么办？

为什么选择电商系统来讲呢？因为我熟啊！开个玩笑，当然不只是因为我做过几年电商系统。其实，很多培训学校、各种技术论坛都特别喜欢讲电商系统。因为电商这个系统，特别有代表性，特别适合作为案例来研究和学习。

首先，电商系统覆盖面足够广泛。特别是是在互联网行业，你会发现几乎所有的互联网公司都在做两个事情：电商和社交。

其次，用电商系统作为案例，直接就能学以致用。即使你面对的业务和电商关系不大，因为电商的系统足够复杂，你在其他业务中可能遇到的技术问题，大多数在电商系统中基本都会遇到，一样有借鉴的意义。另外，电商这个业务领域对所有人来说都很熟悉，拿它作为案例基本上不需要再讲解业务知识，我们可以快速地专注于技术问题本身。

即使是同样一个电商系统，不同的规模，它需要解决的问题也不一样。不少做技术的同学崇尚于海量数据和高并发，认为只有大厂那些高并发、海量数据的核心或者是底层存储系统，才是真正“有技术含量”的系统，能胜任这样系统的开发者，才是真正的技术大牛。这其实是一个技术认知误区。为什么这么说？

因为，并不是规模小的系统就简单，只有大规模的系统才有难度。

创业团队需要快速低成本把系统完整地实现出来，好快速验证它的商业模式；处于高速增长期的团队，它面临的问题是业务高速增长和不断变化，相应的，也要对系统不停地进行升级改造来适应变化，并且要在变化的过程中确保稳定；业务规模足够大的一些大厂，它需要解决的是如何应对高并发、海量数据这些问题。

所以说，不同规模的系统，在技术上没有高低贵贱之分，它们的建设目标不一样，面临的挑战不一样，需要解决的问题也不一样，对于存储系统的选择、架构设计也是不一样的。

所以，我们的课程设计就是按照系统的发展过程，分成了创业篇、高速增长篇和海量数据篇这三个部分。

在创业篇，我们重点解决从 0 到 1 的问题；比如：如何低成本高质量地快速构建一个小规模的订单存储系统。

在高速增长篇，我们关注在高速变化的过程中，你的系统一定会遇到的一些共通问题，以及该如何应对这些问题。比如说，如何从单机的存储系统逐步演进为分布式存储系统；如何在线平滑的扩容我们的存储系统。

在海量数据篇，我们重点解决高并发、海量数据情况下的存储系统该如何设计的问题。比如，海量的埋点数据该怎么存储；如何在各种数据库之前实时迁移和同步海量数据，等等。

通过学习这门课程，你将收获的不仅是案例中那些解决具体问题的方法，在电商系统架构、对存储系统的认知以及存储系统的设计能力这几个方面，都会有所提升。

更重要的是，通过案例来学习常用的数据库和存储系统的使用和实现，可以总结出存储系统最通用、本质的技术原理。掌握了存储系统的本质之后，不仅会让你在面试时更加从容，而且会让你对存储的理解上升一个层次，从“知道怎么用”，升级为“知道为什么这么用”，最终做到活学活用。

在极客时间的一段新旅程即将开始，在开始正式学习之前，我还想再说一些我的想法。技术的发展让使用技术变得越来越简单，但是作为有理想有情怀开发者，不能让技术把我们的变得越来越“简单”。我很开心又能同各位同学一起持续地丰富自己，也希望你能不负时光，认真对待这段学习之旅。

在学习过程中，你都可以在留言区提问，我看到会第一时间给你回复。另外，像《消息队列高手课》专栏一样，我也希望你可以在学习之初立一个 Flag。有了目标指引，持之以恒地探索，成功必不会偏航。

好，开始学习吧！

课前加餐| 电商系统是如何设计的？

你好，我是李玥。

在这个系列课程中，我们会讲电商这个行业在多年系统建设和运维过程中，总结出来的使用分布式存储系统的一些最佳实践。也会以电商系统作为例子来讲解存储相关的技术知识和问题。这都需要你对电商的业务逻辑、系统架构、核心业务流程有一个基本的认知。

虽然说，电商这个业务和你的生活息息相关，你可能对电商多少有一些了解，但是，即使是一个最小化的电商系统，它仍然非常复杂。所以，我们先花一节课的时间，一起以一个创业公司的 CTO 的视角，设计一个最小化的电商系统，在这个过程中帮你理清楚电商系统的架构。

这样，我们在学习后续课程的时候，就不再解释电商的业务和系统了，直接来讲具体的技术问题。

我们的故事从一个电话开始。某一天，一个曾经认识但并不太熟悉的老板突然来了一个电话：

“我有一个改变世界的想法，就差一个程序员了！你来吧！”

新公司很快就成立了，你成了新公司的 CTO。关于要如何改变世界，目前唯一能确定的是，老板要做一个电商系统。具体做成什么样，还不清楚。你需要和老板讨论需求。

你：“咱们要做的业务模式是 C2C、B2C 还是 B2B 呢？”

老板：“什么 B？什么 C？我不懂你们那些技术的东西。”

你：“这么说吧，你要山寨，啊不对，做一个某宝网，还是某东网，还是某 848 网呢？”

老板：“不都是一样的么？有什么区别？你赶紧做一个出来我看看不就知道了？！”

有没有感觉似曾相识？作为研发谁没碰到过几个啥也不懂的需求方不是？那这种情况下，你怎么办呢？

在需求还不太明确的情况下，比较可行的方式就是，先把那些不太会变化的核心系统搭建出来，尽量简单地实现出一个最小化的系统，然后再逐步迭代完善。

电商系统的核心流程是什么样的？

接下来我带你一起来设计这个电商的核心系统。

遵照软件工程的一般规律，我们先从需求阶段开始。如何来做需求分析？理想情况下，应该由系统分析师或者是产品经理来承担这个任务。但现实很骨感，绝大多数情况下，你得到的所谓的“需求”，就是一两句话。需求分析的工作实际上就落在了开发者身上。

很多项目交付以后，还要改来改去，用户不满意，开发者很痛苦，其实就是缺失了需求分析这个步骤。所以，为了自己，每一个开发者都应该掌握一点需求分析的方法。

开发者怎么来做需求分析？这里面我们不讲那些做需求分析的方法和理论，只告诉你最重要、最关键的一个点。

不要一上来就设计功能，而是先要回答下面这两个问题：

这个系统（或者是功能）是给哪些人用的？

这些人使用这个系统来解决什么问题？

这两个问题的答案，我把它们称为业务需求。在我们将要设计的这个电商系统中，它的业务需求是什么？电商的业务，每个人都熟悉，很容易回答这两个问题。

第一个问题，电商系统给哪些人用？

首先得有买东西的人，我们叫“用户”，还得有卖东西的人？我们叫“运营人员”。还有什么人会用这个系统？老板啊！你记住，你在设计任何一个系统的时候，千万不要把老板或者是领导给忘了，他们是给你钱的人，他们的意见非常重要！

然后我们一起回答第二个问题：用户、运营和老板，分别用电商系统来干什么？

这个问题也很容易回答，用户用系统来买东西，运营用系统来卖东西，老板需要在系统中看到他赚了多少钱。这两个问题的答案，或者说是业务需求，稍加细化后，可以用下面这个图来清晰的表述：

这个图在 UML（统一建模语言）中称为用例图（Use Case），是需求分析的时候你需要画的第一张图。它回答的就是，业务需求中的那两个关键的问题，这个系统给谁用？他们用这个系统解决什么问题？

一般来说，业务需求和我们要设计的系统关系不大。为什么这么说呢？你可以看一下上面这个图里面的用例，放在传统的商业企业里面，比如一个小杂货铺、一个线下实体商场商店或者一个做电视购物的公司，是不是也是适用的？所以做业务需求的主要目的，是理清楚业务场景是什么样的。

然后我们来分析电商系统的流程。显然，一个电商系统最主要的业务流程，一定是购物这个流程。你应该很容易就能把这个流程分析出来，它的流程图是这样的：

所有的电商几乎都是这样一个流程，我和你一起来看一下这个流程。

流程从用户选购商品开始，用户先从你的 App 中浏览商品；找到心仪的商品之后，把商品添加到购物车里面；都选好了之后，打开购物车，下一个订单；下单结算之后，就可以支付了；支付成功后，运营人员接下来会给每个已经支付的订单发货；邮寄商品给用户之后，用户确认收货，到这里一个完整的购物流程就结束了。

如何根据流程来划分功能模块？

接下来，我们把这个业务流程再细化，看一下电商系统如何来实现这个流程？我把细化之后的流程，绘制成了下面这个时序图（Sequence Diagram）：

我们一起看下这个时序图中的每个步骤。

用户开始浏览商品，需要有一个商品模块来支撑，给用户展示商品的介绍、价格等等这些信息。

用户把选好的商品加入购物车，这个步骤，也需要一个购物车模块来维护用户购物车中的商品。

用户下单肯定需要一个订单模块来创建这个新订单。订单创建好了之后，需要把订单中的商品从购物车中删除掉。

订单创建完成后，需要引导用户付款，也就是发起支付流程，这里需要有一个支付模块来实现支付功能，用户成功完成支付之后，需要把订单的状态变更为“已支付”。

之后运营人员就可以发货了，在系统中，发货这个步骤，需要扣减对应商品的库存数量，这个功能需要库存模块来实现，发货完成后，还需要把订单状态变更为“已发货”。

最后，用户收货之后，在系统中确认收货，系统把订单状态变更为“已收货”，流程结束。

这个流程涉及到的功能模块有：商品、购物车、订单、支付和库存，这几个模块就是一个电商系统中的核心功能模块。

当然，仅仅有这几个模块还是不够的，因为我们只分析了“购物”这个最主要的流程，并没有完全涵盖业务需求中的全部用例，比如：运营人员进货、老板查看报表这些用例还没有覆盖到。

相比购物这个流程，剩下的几个用例和流程都没那么复杂，用同样的方法就可以把其他功能模块分析出来。在这里我们就省略分析过程，直接给出我们电商系统的功能模块划分：

上面这个图，我使用的是 UML 中的包图 (Package Diagram) 来表示。整个系统按照功能，划分为十个模块，除了购物流程中涉及到的：商品、订单、购物车、支付、库存五个模块以外，还补充了促销、用户、账户、搜索推荐和报表这几个模块，这些都是构建一个电商系统必不可少的功能。我们一个一个来说每个模块需要实现的功能。

商品：维护和展示商品信息和价格。

订单：维护订单信息和订单状态，计算订单金额。

购物车：维护用户购物车中的商品。

支付：负责与系统内外部的支付渠道对接，实现支付功能。

库存：维护商品的库存数量和库存信息。

促销：制定促销规则，计算促销优惠。

用户：维护系统的用户信息，注意用户模块它是一个业务模块，一般不负责用户登录和认证，这是两个完全不同的功能。

账户：负责维护用户的账户余额。

搜索推荐：负责商城中，搜索商品和各种列表页和促销页的组织和展示，简单的说就是决定让用户优先看到哪些商品。

报表：实现统计和分析功能，生成报表，给老板来做经营分析和决策使用。

这里面需要特别说一下促销模块，它是电商系统中，最复杂的一个模块。各种优惠券、满减、返现等等这些促销规则，每个都非常复杂，再加上这些规则叠加计算，常常是复杂到连制定促销规则的人都搞不清楚。

所以每个电商公司无一例外都爆出过，因为促销规则制定失误，而产生非常便宜的“羊毛单”，让精明的消费者薅了“羊毛”。不过五花八门的促销是提升销售最有效的手段，肯定不能因噎废食。

作为系统设计者，我们需要把促销的变化和复杂性封禁在促销模块内部，不能让一个促销模块把整个电商系统都搞得非常复杂，否则就很难去设计和实现。

可行的做法是，把促销模块与其他模块的接口设计的相对简单和固定，这样系统的其他模块就不会因为新的促销玩儿法而改变。

在创建订单时，订单模块把商品和价格信息传给促销模块，促销模块返回一个可以使用的促销列表，用户选择好促销和优惠，订单模块把商品、价格、促销优惠这些信息，再次传给促销模块，促销模块则返回促销价格。

最终生成的订单中，只记录订单使用了哪几种促销，以及最终的促销价就可以了。这样，不管促销这个模块的玩儿法怎么变化，订单和其他模块的业务逻辑不需要随之改变。

至此，我们就完成了一个电商系统的概要设计，你应该对电商系统也有了一个初步的了解。

小结

我们每节课的正文结束之后，都会安排一个小结，对课程的重点内容做一个总结。

今天我们再来回顾一下，一个电商系统的设计中，最核心的几个关键点。

首先，系统的角色是：用户、运营人员和老板。这三个角色对电商系统的需求是：用户使用系统来购物，运营人员负责销售商品，老板关注系统中经营数据。电商系统最核心的流程就是用户购物的流程，流程从用户浏览选购商品开始，加购、下单、支付、运营人员发货、用户确认收货，至此流程结束。

细化这个流程之后，我们可以分析出，支撑这个流程几个核心的功能模块：商品、订单、购物车、支付和库存。此外，还需要促销、用户、账户、搜索推荐和报表这些必备的功能模块支撑，才能构成一个完整的电商系统。

我还和你分享了作为一个开发者，你在做需求分析的时候，需要把握的一个要点：不要一上来就设计功能，而是要先理清业务需求，也就是这节课反复强调的两个问题：这个系统是给哪些人用的？他们分别用这个系统来解决什么问题？这样可以确保你做出来的系统，大体上不会偏离用户的预期。

最后，在讲解系统功能模块划分的时候，我们说了一个有效减少系统复杂度的设计经验。那就是，如果系统业务是复杂而多变的，尽量识别出这部分复杂业务的边界，将复杂封禁在一个模块的内部，避免这种复杂度扩散到整个系统中去。

这节课是我们专栏的课前加餐，目的是为了让你了解电商的组成模块。在后续的课程中，我将和你一起经历这个电商系统在从小到大的发展过程中遇到的存储技术问题，并一起解决它。

思考题

做完了概要设计，就可以来做技术选型了。作为公司的 CTO，课后请你思考一下，这个电商系统的技术选型应该是什么样的？

使用什么编程语言和技术栈？

需要哪些第三方的框架和云服务？

我们最关心的存储系统该怎么选型？

欢迎你在留言区与我讨论，如果你觉得今天的内容对你有帮助，也欢迎把它分享给你的朋友。

01 | 创建和更新订单时，如何保证数据准确无误？

你好，我是李玥。

订单系统是整个电商系统中最重要的一个子系统，订单数据也就是电商企业最重要的数据资产。今天这节课，我来和你说一下，在设计和实现一个订单系统的存储过程中，有哪些问题是要特别考虑的。

一个合格的订单系统，最基本的要求是什么？数据不能错。

一个购物流程，从下单开始、支付、发货，直到收货，这么长的一个流程中，每一个环节，都少不了更新订单数据，每一次更新操作又需要同时更新好几张表。这些操作可能被随机分布到很多台服务器上执行，服务器有可能故障，网络有可能出问题。

在这么复杂的情况下，保证订单数据一笔都不能错，是不是很难？实际上，只要掌握了方法，其实并不难。

首先，你的代码必须是正确没 Bug 的，如果说是因为代码 Bug 导致的数据错误，那谁也救不了你。

然后，你要会正确地使用数据库的事务。比如，你在创建订单的时候，同时要在订单表和订单商品表中插入数据，那这些插入数据的 INSERT 必须在一个数据库事务中执行，数据库的事务可以确保：执行这些 INSERT 语句，要么一起都成功，要么一起都失败。

我相信这些“基本操作”对于你来说，应该不是问题。

但是，还有一些情况下会引起数据错误，我们一起来看一下。不过在此之前，我们要明白，对于一个订单系统而言，它的核心功能和数据结构是怎样的。

因为，任何一个电商，它的订单系统的功能都是独一无二的，基于它的业务，有非常多的功能，并且都很复杂。我们在讨论订单系统的存储问题时，必须得化繁为简，只聚焦那些最核心的、共通的业务和功能上，并且以这个为基础来讨论存储技术问题。

订单系统的核心功能和数据

我先和你简单梳理一下一个订单系统必备的功能，它包含但远远不限于：

创建订单；

随着购物流程更新订单状态；

查询订单，包括用订单数据生成各种报表。

为了支撑这些必备功能，在数据库中，我们至少需要有这样几张表：

订单主表：也叫订单表，保存订单的基本信息。

订单商品表：保存订单中的商品信息。

订单支付表：保存订单的支付和退款信息。

订单优惠表：保存订单使用的所有优惠信息。

这几个表之间的关系是这样的：订单主表和后面的几个子表都是一对多的关系，关联的外键就是订单主表的主键，也就是订单号。

绝大部分订单系统它的核心功能和数据结构都是这样的。

如何避免重复下单？

接下来我们来看一个场景。一个订单系统，提供创建订单的 HTTP 接口，用户在浏览器页面上点击“提交订单”按钮的时候，浏览器就会给订单系统发一个创建订单的请求，订单系统的后端服务，在收到请求之后，往数据库的订单表插入一条订单数据，创建订单成功。

假如说，用户点击“创建订单”的按钮时手一抖，点了两下，浏览器发了两个 HTTP 请求，结果是什么？创建了两条一模一样的订单。这样肯定不行，需要做防重。

有的同学会说，前端页面上应该防止用户重复提交表单，你说的没错。但是，网络错误会导致重传，很多 RPC 框架、网关都会有自动重试机制，所以对于订单服务来说，重复请求这个事儿，你是没办法完全避免的。

解决办法是，让你的订单服务具备幂等性。什么是幂等呢？一个幂等操作的特点是，其任意多次执行所产生的影响均与一次执行的影响相同。也就是说，一个幂等的方法，使用同样的参数，对它进行调用多次和调用一次，对系统产生的影响是一样的。所以，对于幂等的方法，不用担心重复执行会对系统造成任何改变。一个幂等的创建订单服务，无论创建订单的请求发送多少次，正确的结果是，数据库只有一条新创建的订单记录。

这里面有一个不太好解决的问题：对于订单服务来说，它怎么知道发过来的创建订单请求是不是重复请求呢？

在插入订单数据之前，先查询一下订单表里面有没有重复的订单，行不行？不太行，因为你很难用 SQL 的条件来定义“重复的订单”，订单用户一样、商品一样、价格一样，就认为是重复订单么？不一定，万一用户就是连续下了两个一模一样的订单呢？所以这个方法说起来容易，实际上很难实现。

很多电商解决这个问题的思路是这样的。在数据库的最佳实践中有一条就是，数据库的每个表都要有主键，绝大部分数据表都遵循这个最佳实践。一般来说，我们在往数据库插入一条记录的时候，都不提供主键，由数据库在插入的同时自动生成一个主键。这样重复的请求就会导致插入重复数据。

我们知道，表的主键自带唯一约束，如果我们在一条 INSERT 语句中提供了主键，并且这个主键的值在表中已经存在，那这条 INSERT 会执行失败，数据也不会被写入表中。我们可以利用数据库的这种“主键唯一约束”特性，在插入数据的时候带上主键，来解决创建订单服务的幂等性问题。

具体的做法是这样的，我们给订单系统增加一个“生成订单号”的服务，这个服务没有参数，返回值就是一个新的、全局唯一的订单号。在用户进入创建订单的页面时，前端页面先调用这个生成订单号服务得到一个订单号，在用户提交订单的时候，在创建订单的请求中带着这个订单号。

这个订单号也是我们订单表的主键，这样，无论是用户手抖，还是各种情况导致的重试，这些重复请求中带的都是同一个订单号。订单服务在订单表中插入数据的时候，执行的这些重复 INSERT 语句中的主键，也都是同一个订单号。数据库的唯一约束就可以保证，只有一次 INSERT 语句是执行成功的，这样就实现了创建订单服务幂等性。

为了便于你理解，我把上面这个幂等创建订单的流程，绘制成了时序图供你参考：

还有一点需要注意的是，如果是因为重复订单导致插入订单表失败，订单服务不要把这个错误返回给前端页面。否则，就有可能出现这样的情况：用户点击创建订单按钮后，页面提示创建订单失败，而实际上订单却创建成功了。正确的做法是，遇到这种情况，订单服务直接返回订单创建成功就可以了。

如何解决 ABA 问题？

同样，订单系统各种更新订单的服务一样也要具备幂等性。

这些更新订单服务，比如说支付、发货等等这些步骤中的更新订单操作，最终落到订单库上，都是对订单主表的 UPDATE 操作。数据库的更新操作，本身就具备天然的幂等性，比如说，你把订单状态，从未支付更新成已支付，执行一次和重复执行多次，订单状态都是已支付，不用我们做任何额外的逻辑，这就是天然幂等。

那在实现这些更新订单服务时，还有什么问题需要特别注意的吗？还真有，在并发环境下，你需要注意 ABA 问题。

什么是 ABA 问题呢？我举个例子你就明白了。比如说，订单支付之后，小二要发货，发货完成后要填个快递单号。假设说，小二填了一个单号 666，刚填完，发现填错了，赶紧再修改成 888。对订单服务来说，这就是 2 个更新订单的请求。

正常情况下，订单中的快递单号会先更新成 666，再更新成 888，这是没问题的。那不正常情况呢？666 请求到了，单号更新成 666，然后 888 请求到了，单号又更新成 888，但是 666 更新成功的响应丢了，调用方没收到成功响应，自动重试，再次发起 666 请求，单号又被更新成 666 了，这数据显然就错了。这就是非常有名的 ABA 问题。

具体的时序你可以参考下面这张时序图：

ABA 问题怎么解决？这里给你提供一个比较通用的解决方法。给你的订单主表增加一列，列名可以叫 version，也即是“版本号”的意思。每次查询订单的时候，版本号需要随着订单数据返回给页面。页面在更新数据的请求中，需要把这个版本号作为更新请求的参数，再带回给订单更新服务。

订单服务在更新数据的时候，需要比较订单当前数据的版本号，是否和消息中的版本号一致，如果不一致就拒绝更新数据。如果版本号一致，还需要再更新数据的同时，把版本号 +1。“比较版本号、更新数据和版本号 +1”，这个过程必须在同一个事务里面执行。

具体的 SQL 可以这样来写：

UPDATE orders set tracking_number = 666, version = version + 1
WHERE version = 8;

在这条 SQL 的 WHERE 条件中，version 的值需要页面在更新的时候通过请求传进来。

通过这个版本号，就可以保证，从我打开这条订单记录开始，一直到我更新这条订单记录成功，这个期间没有其他人修改过这条订单数据。因为，如果有其他人修改过，数据库中的版本号就会改变，那我的更新操作就不会执行成功。我只能重新查询新版本的订单数据，然后再尝试更新。

有了这个版本号，再回头看一下我们上面那个 ABA 问题的例子，会出现什么结果？可能出现两种情况：

第一种情况，把运单号更新为 666 的操作成功了，更新为 888 的请求带着旧版本号，那就会更新失败，页面提示用户更新 888 失败。

第二种情况，666 更新成功后，888 带着新的版本号，888 更新成功。这时候即使重试的 666 请求再来，因为它和上一条 666 请求带着相同的版本号，上一条请求更新成功后，这个版本号已经变了，所以重试请求的更新必然失败。

无论哪种情况，数据库中的数据与页面上给用户的反馈都是一致的。这样就可以实现幂等更新并且避免了 ABA 问题。下图展示的是第一种情况，第二种情况也是差不多的：

小结

我们把今天这节课的内容做一个总结。今天这节课，实际上就讲了一个事儿，也就是，实现订单操作的幂等的方法。

因为网络、服务器等等这些不确定的因素，重试请求是普遍存在并且不可避免的。具有幂等性的服务可以完美地克服重试导致的数据错误。

对于创建订单服务来说，可以通过预先生成订单号，然后利用数据库中订单号的唯一约束这个特性，避免重复写入订单，实现创建订单服务的幂等性。对于更新订单服务，可以通过一个版本号机制，每次更新数据前校验版本号，更新数据同时自增版本号，这样的方式，来解决 ABA 问题，确保更新订单服务的幂等性。

通过这样两种幂等的实现方法，就可以保证，无论请求是不是重复，订单表中的数据都是正确的。当然，上面讲到的实现订单幂等的方法，你完全可以套用在其他需要实现幂等的服务中，只需要这个服务操作的数据保存在数据库中，并且有一张带有主键的数据表就可以了。

思考题

实现服务幂等的方法，远不止我们这节课上介绍的这两种，课后请你想一下，在你负责开发的业务系统中，能不能用这节课中讲到的方法来实现幂等？除了这两种方法以外，还有哪些实现服务幂等的方法？欢迎你在留言区与我交流互动。

感谢你的阅读，如果你觉得今天的内容对你有所帮助，也欢迎把它分享给你的朋友。

02 | 流量大、数据多的商品详情页系统该如何设计？

你好，我是李玥。

今天这节课我们看一下，如何设计一个快速、可靠的存储架构支撑商品系统。

相对于上节课提到的订单系统，电商的商品系统主要功能就是增删改查商品信息，没有很复杂的业务逻辑，支撑的主要页面就是商品详情页（下文简称：商详）。不过，设计这个系统的存储，你仍然需要着重考虑两个方面的问题。

第一，要考虑高并发的问题。不管是什么电商系统，商详页一定是整个系统中 DAU（日均访问次数）最高的页面之一。这个也不难理解，用户购物么，看商详了不一定买，买之前一定会看好多商详货比三家，所以商详的浏览次数要远比系统的其他页面高。如果说，在设计存储的时候，没有考虑到高并发的问题，大促的时候，支撑商详页的商品系统必然是第一个被流量冲垮的系统。

第二，要考虑的是商品数据规模的问题。商详页的数据规模，我总结了六个字，叫：数量多，重量大。

先说为什么数量多，国内一线的电商，SKU（直译为：库存单元，在电商行业，你可以直接理解为“商品”）的数量大约在几亿到几十亿这个量级。当然实际上并没有这么多种商品，这里面有很多原因，比如同一个商品它有不同版本型号，再比如，商家为了促销需要，可能会反复上下架同一个商品或者给同一个商品配不同的马甲，这都导致了 SKU 数量爆炸。

再说这个“重量大”，你可以打开一个电商商详页看一下，从上一直拉到底，你看看有多长？十屏以内的商详页那都叫短的，并且这里面不光有大量的文字，还有大量的图片和视频，甚至还有 AR/VR 的玩法在里面，所以说，每个商详页都是个“大胖子”。

支持商品系统的存储，要保存这么多的“大胖子”，还要支撑高并发，任务艰巨。

商品系统需要保存哪些数据？

先来看一下，一个商详页都有哪些信息需要保存。我把一个商详页里面的所有信息总结了一下，放在下面这张思维导图里面。

这里面，右边灰色的部分，来自于电商的其他系统，我们暂且不去管这些，左边彩色部分，都是商品系统需要存储的内容。

这么多内容怎么存？能不能像保存订单数据那样，设计一张商品表，把这些数据一股脑儿都放进去？一张表存不下就再加几张子表，这样行不行？你还真别说不行，现在这些电商大厂，在它们发展的早期就是这么干的。现在那么复杂的分布式存储架构，都是一点儿一点儿逐步演进过来的。

这么做的好处，就是糙快猛，简单可靠而且容易实现，但是，撑不了多少数据量，也撑不了多少并发。如果说，你要低成本快速构建一个小规模电商，这么做还真就是一个挺合理的选择。

当然，规模再大一点儿就不能这么干了。不能用数据库，那应该选择哪种存储系统来保存这么复杂的商品数据呢？任何一种存储都是没办法满足的，解决的思路是分而治之，我们可以把商品系统需要存储的数据按照特点，分成商品基本信息、商品参数、图片视频和商品介绍几个部分来分别存储。

商品基本信息该如何存储？

我们先来分析商品的基本信息，它包括商品的主副标题、价格、颜色等一些商品最基本、主要的属性。这些属性都是固定的，不太可能会因为需求或者不同的商品而变化，而且，这部分数据也不会太大。所以，还是建议你在数据库中建一张表来保存商品的基本信息。

然后，还需要在数据库前面，加一个缓存，帮助数据抵挡绝大部分的读请求。这个缓存，你可以使用 Redis，也可以用 Memcached，这两种存储系统都是基于内存的 KV 存储，都能解决问题。

接下来我和你简单看一下，如何来使用前置缓存来缓存商品数据。

处理商品信息的读请求时，先去缓存查找，如果找到就直接返回缓存中的数据。如果在缓存中没找到，再去查数据库，把从数据库中查到的商品信息返回给页面，顺便把数据在缓存里也放一份。

更新商品信息的时候，在更新数据库的同时，也要把缓存中的数据给删除掉。不然就有可能出现这种情况：数据库中的数据变了，而缓存中的数据没变，商详页上看到的还是旧数据。

这种缓存更新的策略，称为 Cache Aside，是最简单实用的一种缓存更新策略，适用范围也最广泛。如果你要缓存数据，没有什么特殊的情况，首先就应该考虑使用这个策略。

除了 Cache Aside 以外，还有 Read/Write Through、Write Behind 等几种策略，分别适用于不同的情况，后面的课程中我会专门来讲。

设计商品基本信息表的时候，有一点需要提醒你的是，一定要记得保留商品数据的每一个历史版本。因为商品数据是随时变化的，但是订单中关联的商品数据，必须是下单那个时刻的商品数据，这一点很重要。你可以为每一个历史版本的商品数据保存一个快照，可以创建一个历史表保存到 MySQL 中，也可以保存到一些 KV 存储中。

使用 MongoDB 保存商品参数

我们再来分析商品参数，参数就是商品的特征。比如说，电脑的内存大小、手机的屏幕尺寸、酒的度数、口红的色号等等。和商品的基本属性一样，都是结构化的数据。但麻烦的是，不同类型的商品，它的参数是完全不一样的。

如果我们设计一个商品参数表，那这个表的字段就会太多了，并且每增加一个品类的商品，这个表就要加字段，这个方案行不通。

既然一个表不能解决问题，那就每个类别分别建一张表。比如说，建一个电脑参数表，里面的字段有 CPU 型号、内存大小、显卡型号、硬盘大小等等；再建一个酒类参数表，里面的字段有酒精度数、香型、产地等等。如果说，品类比较少，在 100 个以内，用几十张表分别保存不同品类的商品参数，这样做也是可以的。但是，有没有更好的方法呢？

大多数数据库，都要求数据表要有一个固定的结构。但有一种数据库，没有这个要求。特别适合保存像“商品参数”这种，属性不固定的数据，这个数据库就是 MongoDB。

MongoDB 是一个面向文档存储的 NoSQL 数据库，在 MongoDB 中，表、行、列对应的概念分别是：collection、document、field，其实都是一回事儿，为了便于你理解，在这里我们不咬文嚼字，还是用“表、行、列”来说明。

MongoDB 最大的特点就是，它的“表结构”是不需要事先定义的，其实，在 MongoDB 中根本没有表结构。由于没有表结构，它支持你把任意数据都放在同一张表里，你甚至可以在一张表里保存商品数据、订单数据、物流信息等这些结构完全不同的数据。并且，还能支持按照数据的某个字段进行查询。

它是怎么做到的呢？MongoDB 中的每一行数据，在存储层就是简单地被转化成 BSON 格式后存起来，这个 BSON 就是一种更紧凑的 JSON。所以，即使在同一张表里面，它每一行数据的结构都可以是不一样的。当然，这样的灵活性也是有代价的，MongoDB 不支持 SQL，多表联查和复杂事务比较孱弱，不太适合存储一般的数据。

但是，对于商品参数信息，数据量大、数据结构不统一，这些 MongoDB 都可以很好的满足。我们也不需要事务和多表联查，MongoDB 简直就是为了保存商品参数量身定制的一样。

使用对象存储保存图片和视频

图片和视频由于占用存储空间比较大，一般的存储方式都是，在数据库中只保存图片视频的 ID 或者 URL，实际的图片视频以文件的方式单独存储。

现在图片和视频存储技术已经非常成熟了，首选的方式就是保存在对象存储（Object Storage）中。各大云厂商都提供对象存储服务，比如国内的七牛云、AWS 的 S3 等等，也有开源的对象存储产品，比如 MinIO，可以私有化部署。虽然每个产品的 API 都不一样，但功能大同小异。

对象存储可以简单理解为一个无限容量的大文件 KV 存储，它的存储单位是对象，其实就是文件，可以是一张图片，一个视频，也可以是其他任何文件。每个对象都有一个唯一的 key，利用这个 key 就可以随时访问对应的对象。基本的功能就是写入、访问和删除对象。

云服务厂商的对象存储大多都提供了客户端 API，可以在 Web 页面或者 App 中直接访问而不用通过后端服务来中转。这样，App 和页面在上传图片视频的时候，直接保存到对象存储中，然后把对应 key 保存在商品系统中就可以了。

访问图片视频的时候，真正的图片和视频文件也不需要经过商品系统的后端服务，页面直接通过对象存储提供的 URL 来访问，又省事儿又节约带宽。而且，几乎所有的对象存储云服务都自带 CDN（Content Delivery Network）加速服务，响应时间比直接请求业务的服务器更短。

国内的很多云厂商的对象存储对图片和视频，都做了非常多的针对性优化。最有用的是，缩放图片和视频转码，你只要把图片和视频丢到对象存储中，就可以随时获得任意尺寸大小的图片，视频也会自动转码成各种格式和码率的版本，适配各种 App 和场景。我只能说，谁用谁知道，真香！

将商品介绍静态化

商品介绍在商详页中占得比重是最大的，包含了大量的带格式文字、图片和视频。其中图片和视频自然要存放在对象存储里面，商品介绍的文本，一般都是随着商详页一起静态化，保存在 HTML 文件中。

什么是静态化呢？静态化是相对于动态页面来说的。一般我们部署到 Tomcat 中的 Web 系统，返回的都是动态页面，也就是在 Web 请求时，动态生成的。比如说商详页，一个 Web 请求过来，带着 SKUID，Tomcat 中的商详页模块，再去访问各种数据库、调用后端服务，动态把这个商详页拼出来，返回给浏览器。

不过，现在基本上没有系统会这么干了，你想，对于每个 SKU 的商详页，你每次动态生成的页面内容不是完全一样的么？生成这么多次，不仅浪费服务器资源，速度还慢，关键问题是，Tomcat 能能抗的并发量和 Nginx 完全不是一个数量级的。

商详页的绝大部分内容都是商品介绍，它是不怎么变的。那不如就把这个页面事先生成好，保存成一个静态的 HTML，访问商详页的时候，直接返回这个 HTML。这就是静态化。

商详页静态化之后，不仅仅是可以节省服务器资源，还可以利用 CDN 加速，把商详页放到离用户最近的 CDN 服务器上，让商详页访问更快。

至于商品价格、促销信息等这些需要频繁变动的信息，不能静态化到页面中，可以在前端页面使用 AJAX 请求商品系统动态获取。这样就兼顾了静态化带来的优势，也能解决商品价格等信息需要实时更新的问题。

小结

最后，我们再来对今天的内容复个盘。商品系统的存储需要提供商品的基本信息、商品参数、图片和视频以及商品介绍等等这些数据。商品的基本信息和商品参数分别保存在 MySQL 和 MongoDB 中，用 Redis 作为前置缓存，图片和视频存放在对象存储中，商品介绍随着商详页一起静态化到商详静态页中。

我把商品系统的存储绘制成下面这张图：

一起来看一下图，这样一个商品系统的存储最终的效果是什么样的？图中实线表示每访问一次商详页，需要真正传输的数据，虚线表示当商详页数据发生变化的时候才需要进行一次数据传输。用户打开一个 SKU 的商详页时，首先去 CDN 获取商详页的 HTML，然后访问商品系统获取价格等频繁变化的信息，这些信息从 Redis 缓存中获取。图片和视频信息，也是从对象存储的 CDN 中获取。

分析一下效果，数据量最大的图片、视频和商品介绍都是从离用户最近的 CDN 服务商获取的，速度快，节约带宽。真正打到商品系统的请求，就是价格这些需要动态获取的商品信息，一般做一次 Redis 查询就可以了，基本不会有流量打到 MySQL 中。

这样一个商品系统的存储的架构，把大部分请求都转移到了又便宜速度又快的 CDN 服务器上，可以用很少量的服务器和带宽资源，抗住大量的并发请求。

思考题

如果说，用户下单这个时刻，正好赶上商品调价，就有可能出现这样的情况：我明明在商详页看到的价格是 10 块钱，下单后，怎么变成 15 块了？你的系统是不是偷偷在坑我？

这样给用户的体验非常不好。你不要以为这是一个小概率事件，当你的系统用户足够多的时候，每时每刻都有人在下单，这几乎是个必然出现的事件。

课后请你想一下，该怎么来解决这个问题？欢迎你在留言区与我交流互动。

感谢你的阅读，如果你觉得今天的内容对你有所帮助，也欢迎把它分享给你的朋友。

03 | 复杂而又重要的购物车系统，应该如何设计？

你好，我是李玥。

今天这节课我们来说一下购物车系统的存储该如何设计。

首先，我们来看购物车系统的主要功能是什么。就是在用户选购商品时，下单之前，暂存用户想要购买的商品。购物车对数据可靠性要求不高，性能也没有特别的要求，在整个电商系统中，看起来是相对比较容易设计和实现的一个子系统。

购物车系统的功能，主要的就三个：把商品加入购物车（后文称“加购”）、购物车列表页、发起结算下单，再加上一个在所有界面都要显示的购物车小图标。

支撑购物车的这几个功能，对应的存储模型应该怎么设计？很简单，只要一个“购物车”实体就够了。它的主要属性有什么？你打开京东的购物车页面，对着抄就设计出来了：SKUID（商品 ID）、数量、加购时间和勾选状态。

备注：图片来源于网络，仅供本文介绍、评论及说明某问题，适当引用。

这个“勾选状态”属性，就是在购物车界面中，每件商品前面的那个小对号，表示在结算下单时，是不是要包含这件商品。至于商品价格和总价、商品介绍等等这些信息，都可以实时从其他系统中获取，不需要购物车系统来保存。

购物车的功能虽然很简单，但是在设计购物车系统的存储时，仍然有一些特殊的问题需要考虑。

设计购物车存储时需要把握什么原则？

比如下面这几个问题：

用户没登录，在浏览器中加购，关闭浏览器再打开，刚才加购的商品还在不在？

用户没登录，在浏览器中加购，然后登录，刚才加购的商品还在不在？

关闭浏览器再打开，上一步加购的商品在不在？

再打开手机，用相同的用户登录，第二步加购的商品还在不在呢？

上面这几个问题是不是有点儿绕？没关系，我们先简单解释一下这四个问题：

如果用户没登录，加购的商品也会被保存在用户的电脑里，这样即使关闭浏览器再打开，购物车的商品仍然存在。

如果用户先加购，再登录，登录前加购的商品就会被自动合并到用户名下，所以登录后购物车中仍然有登录前加购的商品。

关闭浏览器再打开，这时又变为未登录状态，但是之前未登录时加购的商品已经被合并到刚刚登录的用户名下了，所以购物车是空的。

使用手机登录相同的用户，看到的就是该用户的购物车，这时无论你在手机 App、电脑还是微信中登录，只要是相同的用户，看到是同一个购物车，所以第二步加购的商品是存在的。

所以，上面这四个问题的答案依次是：存在、存在、不存在、存在。

如果你没有设计或者开发过购物车系统，你可能并不会想到购物车还有这么多弯弯绕。但是，作为一个开发者，如果你不仔细把这些问题考虑清楚，用户在使用购物车的时候，就会感觉你的购物车系统不好用，不是加购的商品莫名其妙地丢了，就是购物车莫名其妙地多出来一些商品。

要解决上面这些问题，其实只要在存储设计时，把握这几个原则就可以了：

如果未登录，需要临时暂存购物车的商品；

用户登录时，把暂存购物车的商品合并到用户购物车中，并且清除暂存购物车；

用户登陆后，购物车中的商品，需要在浏览器、手机 APP 和微信等等这些终端中都保持同步。

实际上，购物车系统需要保存两类购物车，一类是未登录情况下的“暂存购物车”，一类是登录后的“用户购物车”。

如何设计“暂存购物车”的存储？

我们先来看下暂存购物车的存储该怎么实现。暂存购物车应该存在客户端还是存在服务端？

如果保存在服务端，那每个暂存购物车都需要有一个全局唯一的标识，这个标识并不太容易设计，并且，存在服务端还要浪费服务端的资源。所以，肯定是保存在客户端好，既可以节约服务器的存储资源，也没有购物车标识的问题，因为每个客户端就保存它自己唯一一个购物车就可以了，不需要标识。

客户端的存储可以选择的不太多：Session、Cookie 和 LocalStorage，其中浏览器的 LocalStorage 和 App 的本地存储是类似的，我们都以 LocalStorage 来代表。

存在哪儿最合适？SESSION 是不太合适的，原因是，SESSION 的保留时间短，而且 SESSION 的数据实际上还是保存在服务端的。剩余的两种存储，Cookie 和 LocalStorage 都可以用来保存购物车数据，选择哪种方式更好呢？各有优劣。

在我们这个场景中，使用 Cookie 和 LocalStorage 最关键的区别是，客户端和服务端的每次交互，都会自动带着 Cookie 数据往返，这样服务端可以读写客户端 Cookie 中的数据，而 LocalStorage 里的数据，只能由客户端来访问。

使用 Cookie 存储，实现起来比较简单，加减购物车、合并购物车的过程中，由于服务端可以读写 Cookie，这样全部逻辑都可以在服务端实现，并且客户端和服务端请求的次数也相对少一些。

使用 LocalStorage 存储，实现相对就复杂一点儿，客户端和服务端都要实现一些业务逻辑，但 LocalStorage 的好处是，它的存储容量比 Cookie 的 4KB 上限要大得多，而且不用像 Cookie 那样，无论用不用，每次请求都要带着，可以节省带宽。

所以，选择 Cookie 或者是 LocalStorage 来存储暂存购物车都是没问题的，你可以根据它俩各自的优劣势来选择。比如你设计的是个小型电商，那用 Cookie 存储实现起来更简单。再比如，你的电商是面那种批发的行业用户，用户需要加购大量的商品，那 Cookie 可能容量不够用，选择 LocalStorage 就更合适。

不管选择哪种存储，暂存购物车保存的数据格式都是一样的，参照我们实体模型来设计就可以，我们可以直接用 JSON 表示：

{
    "cart": [
        {
            "SKUID": 8888,
            "timestamp": 1578721136,
            "count": 1,
            "selected": true
        },
        {
            "SKUID": 6666,
            "timestamp": 1578721138,
            "count": 2,
            "selected": false
        }
    ]
}

如何设计“用户购物车”的存储？

接下来，我们再来看下用户购物车的存储该怎么实现。因为用户购物车必须要保证多端的数据同步，所以数据必须保存在服务端。常规的思路是，设计一张购物车表，把数据存在 MySQL 中。这个表的结构同样可以参照刚刚讲的实体模型来设计：

注意，需要在 user_id 上建一个索引，因为查询购物车表时，都是以 user_id 作为查询条件来查询的。

你也可以选择更快的 Redis 来保存购物车数据，以用户 ID 作为 Key，用一个 Redis 的 HASH 作为 Value 来保存购物车中的商品。比如：

{
    "KEY": 6666,
    "VALUE": [
        {
            "FIELD": 8888,
            "FIELD_VALUE": {
                "timestamp": 1578721136,
                "count": 1,
                "selected": true
            }
        },
        {
            "FIELD": 6666,
            "FIELD_VALUE": {
                "timestamp": 1578721138,
                "count": 2,
                "selected": false
            }
        }
    ]
}

这里为了便于你理解，我们用 JSON 来表示 Redis 中 HASH 的数据结构，其中 KEY 中的值 6666 是一个用户 ID，FIELD 里存放的是商品 ID，FIELD_VALUE 是一个 JSON 字符串，保存加购时间、商品数量和勾选状态。

大家都知道，从读写性能上来说，Redis 是比 MySQL 快非常多的，那是不是用 Redis 就一定比用 MySQL 更好呢？我们来比较一下使用 MySQL 和 Redis 两种存储的优劣势：

显然使用 Redis 性能要比 MySQL 高出至少一个量级，响应时间更短，可以支撑更多的并发请求，“天下武功，唯快不破”，这一点 Redis 完胜。

MySQL 的数据可靠性是要好于 Redis 的，因为 Redis 是异步刷盘，如果出现服务器掉电等异常情况，Redis 是有可能会丢数据的。但考虑到购物车里的数据，对可靠性要求也没那么苛刻，丢少量数据的后果也就是，个别用户的购物车少了几件商品，问题也不大。所以，在购物车这个场景下，Redis 的数据可靠性不高这个缺点，并不是不能接受的。

MySQL 的另一个优势是，它支持丰富的查询方式和事务机制，这两个特性，对我们今天讨论的这几个购物车核心功能没什么用。但是，每一个电商系统都有它个性化的需求，如果需要以其他方式访问购物车的数据，比如说，统计一下今天加购的商品总数，这个时候，使用 MySQL 存储数据，就很容易实现，而使用 Redis 存储，查询起来就非常麻烦而且低效。

综合比较下来，考虑到需求总是不断变化，还是更推荐你使用 MySQL 来存储购物车数据。如果追求性能或者高并发，也可以选择使用 Redis。

你可以感受到，我们设计存储架构的过程就是一个不断做选择题的过程。很多情况下，可供选择的方案不止一套，选择的时候需要考虑实现复杂度、性能、系统可用性、数据可靠性、可扩展性等等非常多的条件。需要强调的是，这些条件每一个都不是绝对不可以牺牲的，不要让一些“所谓的常识”禁锢了你的思维。

比如，一般我们都认为数据是绝对不可以丢的，也就是说不能牺牲数据可靠性。但是，像刚刚讲到的用户购物车的存储，使用 Redis 替代 MySQL，就是牺牲了数据可靠性换取高性能。我们仔细分析后得出，很低概率的情况下丢失少量数据，是可以接受的。性能提升带来的收益远大于丢失少量数据而付出的代价，这个选择就是划算的。

如果说不考虑需求变化这个因素，牺牲一点点数据可靠性，换取大幅性能提升，选择 Redis 才是最优解。

小结

今天我们讲了购物车系统的存储该如何设计。

购物车系统的主要功能包括：加购、购物车列表页和结算下单。核心的实体就只有一个“购物车”实体，它至少要包括：SKUID、数量、加购时间和勾选状态这几个属性。

在给购物车设计存储时，为了确保购物车内的数据在多端保持一致，以及用户登录前后购物车内商品能无缝衔接，除了每个用户的“用户购物车”之外还要实现一个“暂存购物车”保存用户未登录时加购的商品，并在用户登录后自动合并“暂存购物车”和“用户购物车”。

暂存购物车存储在客户端浏览器或者 App 中，可以选择存放到 Cookie 或者 LocalStorage 中。用户购物车保存在服务端，可以选择使用 Redis 或者是 MySQL 存储，使用 Redis 存储会有更高的性能，可以支撑更多的并发请求，使用 MySQL 是更常规通用的方式，便于应对变化，系统的扩展性更好。

思考题

课后请你思考一下，既然用户的购物车数据存放在 MySQL 或者是 Redis 中各有优劣势。那能不能把购物车数据存在 MySQL 中，并且用 Redis 来做缓存呢？这样不就可以兼顾两者的优势了么？这样做是不是可行？如果可行，如何来保证 Redis 中的数据和 MySQL 中的数据是一样的呢？

欢迎你在留言区与我讨论，如果你觉得今天学到的知识对你有帮助，也欢迎把它分享给你的朋友。

04 | 事务：账户余额总是对不上账，怎么办？

你好，我是李玥。今天这节课我们来说一下电商的账户系统。

账户系统负责记录和管理用户账户的余额，这个余额就是每个用户临时存在电商的钱，来源可能是用户充值或者退货退款等多种途径。

账户系统的用途也非常广泛，不仅仅是电商，各种互联网内容提供商、网络游戏服务商，电信运营商等等，都需要账户系统来管理用户账户的余额，或者是虚拟货币。包括银行的核心系统，也同样包含一个账户系统。

从业务需求角度来分析，一个最小化的账户系统，它的数据模型可以用下面这张表来表示：

这个表包括用户 ID、账户余额和更新时间三个字段。每次交易的时候，根据用户 ID 去更新这个账户的余额就可以了。

为什么总是对不上账？

每个账户系统都不是孤立存在的，至少要和财务、订单、交易这些系统有着密切的关联。理想情况下，账户系统内的数据应该是自洽的。所有用户的账户余额加起来，应该等于这个电商公司在银行专用账户的总余额。账户系统的数据也应该和其他系统的数据能对的上。比如说，每个用户的余额应该能和交易系统中充值记录，以及订单系统中的订单对的上。

不过，由于业务和系统的复杂性，现实情况却是，很少有账户系统能够做到一点不差的对上每一笔账。所以，稍微大型一点儿的系统，都会有一个专门的对账系统，来核对、矫正账户系统和其他系统之间的数据差异。

对不上账的原因非常多，比如业务变化、人为修改了数据、系统之间数据交换失败等等。那作为系统的设计者，我们只关注“如何避免由于技术原因导致的对不上账”就可以了，有哪些是因为技术原因导致的呢？比如说：网络请求错误，服务器宕机、系统 Bug 等。

“对不上账”是通俗的说法，它的本质问题是，冗余数据的一致性问题。

这里面的冗余数据并不是多余或者重复的数据，而是多份含有相同信息的数据。比如，我们完全可以通过用户的每一笔充值交易数据、消费的订单数据，来计算出这个用户当前的账户余额是多少。也就是说，账户余额数据和这些账户相关的交易记录，都含有“账户余额”这个信息，那它们之间就互为冗余数据。

在设计系统的存储时，原则上不应该存储冗余数据，一是浪费存储空间，二是让这些冗余数据保持一致是一件非常麻烦的事儿。但有些场景下存储冗余数据是必要的，比如用户账户的余额这个数据。

这个数据在交易过程中会被非常频繁地用到，总不能每次交易之前，先通过所有历史交易记录计算一下当前账户的余额，这样做速度太慢了，性能满足不了交易的需求。所以账户系统保存了每个用户的账户余额，这实际上是一种用存储空间换计算时间的设计。

如果说只是满足功能需求，账户系统只记录余额，每次交易的时候更新账户余额就够了。但是这样做有一个问题，如果账户余额被篡改，是没有办法追查的，所以在记录余额的同时，还需要记录每一笔交易记录，也就是账户的流水。流水的数据模型至少需要包含：流水 ID、交易金额、交易时间戳以及交易双方的系统、账户、交易单号等信息。

虽然说，流水和余额也是互为冗余数据，但是记录流水，可以有效地修正由于系统 Bug 或者人为篡改导致的账户余额错误的问题，也便于账户系统与其他外部系统进行对账，所以账户系统记录流水是非常必要的。

在设计账户流水时，有几个重要的原则必须遵守，最好是用技术手段加以限制。

流水记录只能新增，一旦记录成功不允许修改和删除。即使是由于正当原因需要取消一笔已经完成的交易，也不应该去删除交易流水。正确的做法是再记录一笔“取消交易”的流水。

流水号必须是递增的，我们需要用流水号来确定交易的先后顺序。

在对账的时候，一旦出现了流水和余额不一致，并且无法通过业务手段来确定到底是哪儿记错了的情况，一般的处理原则是以交易流水为准来修正余额数据，这样才能保证后续的交易能“对上账”。

那从技术上，如何保证账户系统中流水和余额数据一致呢？

使用数据库事务来保证数据一致性

在设计对外提供的服务接口时，不能提供单独更新余额或者流水的功能，只提供交易功能。我们需要在实现交易功能的时候，同时记录流水并修改余额，并且要尽可能保证，在任何情况下，记录流水和修改余额这两个操作，要么都成功，要么都失败。不能有任何一笔交易出现，记录了流水但余额没更新，或者更新了余额但是没记录流水。

这个事儿说起来挺简单，但实际上是非常难实现的。毕竟应用程序只能先后来执行两个操作，执行过程中，可能会发生网络错误、系统宕机等各种异常的情况，所以对于应用程序来说，很难保证这两个操作都成功或者都失败。

数据库提供了事务机制来解决这个问题，实际上事务这个特性最初就是被设计用来解决交易问题的，在英文中，事务和交易就是同一个单词：Transaction。

我们先看一下如何来使用 MySQL 的事务，实现一笔交易。比如说，在事务中执行一个充值 100 元的交易，先记录一条交易流水，流水号是 888，然后把账户余额从 100 元更新到 200 元。对应的 SQL 是这样的：

mysql> begin;  -- 开始事务
Query OK, 0 rows affected (0.00 sec)

mysql> insert into account_log ...;  -- 写入交易流水
Query OK, 1 rows affected (0.01 sec)

mysql> update account_balance ...;  -- 更新账户余额
Query OK, 1 rows affected (0.00 sec)

mysql> commit; # 提交事务
Query OK, 0 rows affected (0.01 sec)

使用事务的时候，只需要在之前执行begin，标记开始一个事务，然后正常执行多条 SQL 语句，在事务里面的不仅可以执行更新数据的 SQL，查询语句也是可以的，最后执行commit，提交事务就可以了。

我们来看一下，事务可以给我们提供什么样的保证？

首先，它可以保证，记录流水和更新余额这两个操作，要么都成功，要么都失败，即使是在数据库宕机、应用程序退出等等这些异常情况下，也不会出现，只更新了一个表而另一个表没更新的情况。这是事务的原子性（Atomic）。

事务还可以保证，数据库中的数据总是从一个一致性状态（888 流水不存在，余额是 100 元）转换到另外一个一致性状态（888 流水存在，余额是 200 元）。对于其他事务来说，不存在任何中间状态（888 流水存在，但余额是 100 元）。

其他事务，在任何一个时刻，如果它读到的流水中没有 888 这条流水记录，它读出来的余额一定是 100 元，这是交易前的状态。如果它能读到 888 这条流水记录，它读出来的余额一定是 200 元，这是交易之后的状态。也就是说，事务保证我们读到的数据（交易和流水）总是一致的，这是事务的一致性 (Consistency)。

实际上，这个事务的执行过程无论多快，它都是需要时间的，那修改流水表和余额表对应的数据，也会有先后。那一定存在一个时刻，流水更新了，但是余额还没更新，也就是说每个事务的中间状态是事实存在的。

数据库为了实现一致性，必须保证每个事务的执行过程中，中间状态对其他事务是不可见的。比如说我们在事务 A 中，写入了 888 这条流水，但是还没有提交事务，那在其他事务中，都不应该读到 888 这条流水记录。这是事务的隔离性 (Isolation)。

最后，只要事务提交成功，数据一定会被持久化到磁盘中，后续即使发生数据库宕机，也不会改变事务的结果。这是事务的持久性 (Durability)。

你会发现，我上面讲的就是事务的 ACID 四个基本特性。你需要注意的是，这四个特性之间是紧密关联在一起的，不用去纠结每一个特性的严格定义，更重要的是理解事务的行为，也就是我们的系统在使用事务的时候，各种情况下，事务对你的数据会产生什么影响，这是使用事务的关键。

理解事务的隔离级别

有了数据库的事务机制，只要确保每一笔交易都在事务中执行，我们的账户系统就很容易保证流水和余额数据的一致性。但是，ACID 是一个非常严格的定义，或者说是理想的情况。如果要完全满足 ACID，一个数据库的所有事务和 SQL 都只能串行执行，这个性能肯定是不能满足一般系统的要求的。

对账户系统和其他大多数交易系统来说，事务的原子性和持久性是必须要保证的，否则就失去了使用事务的意义，而一致性和隔离性其实可以做适当牺牲，来换取性能。所以，MySQL 提供了四种隔离级别，具体来看一下这个表：

几乎所有讲 MySQL 的事务隔离级别的文章里面，都有这个表，我们也不能免俗，因为这个表太经典了。很多同学看这个表的时候，面对这么多概念都有点儿晕，确实不太好理解。我来跟你说一下怎么来把这四种隔离级别搞清楚，重点在哪里。

这个表里面自上到下，一共有四种隔离级别：RU、RC、RR 和 SERIALIZABLE，这四种级别的隔离性越来越严格，性能也越来越差，在 MySQL 中默认的隔离级别是 RR，可重复读。

先说两种不常用的，第一种 RU 级别，实际上就是完全不隔离。每个进行中事务的中间状态，对其他事务都是可见的，所以有可能会出现“脏读”。我们上一个小节充值的例子中，读到了 888 这条流水，但余额还是转账之前的 100 元，这种情况就是脏读。这种级别虽然性能好，但是存在脏读的可能，对应用程序来说比较难处理，所以基本不用。

第四种“序列化”级别，具备完美的“隔离性”和“一致性”，性能最差，也很少会用到。

常用的隔离级别其实就是 RC 和 RR 两种，其中 MySQL 默认的隔离级别是 RR。这两种隔离级别都可以避免脏读，能够保证在其他事务中是不会读到未提交事务的数据，或者通俗地说，只要你的事务没有提交，那这个事务对数据做出的更新，对其他会话是不可见的，它们读到的还是你这个事务更新之前的数据。

RC 和 RR 唯一的区别在于“是否可重复读”，这个概念也有点儿绕口，但其实也很简单。

在一个事务执行过程中，它能不能读到其他已提交事务对数据的更新，如果能读到数据变化，就是“不可重复读”，否则就是“可重复读”。

我们举个例子来说明，比如，我们把事务的隔离级别设为 RC。会话 A 开启了一个事务，读到 ID 为 0 的账户，当前账户余额是 100 元。

mysql> -- 会话 A
mysql> -- 确认当前设置的隔离级别是RC
mysql> SELECT @@global.transaction_isolation, @@transaction_isolation;
+--------------------------------+-------------------------+
| @@global.transaction_isolation | @@transaction_isolation |
+--------------------------------+-------------------------+
| READ-COMMITTED                 | READ-COMMITTED          |
+--------------------------------+-------------------------+
1 row in set (0.00 sec)

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select log_id, amount, timestamp  from account_log  order by log_id;
+--------+--------+---------------------+
| log_id | amount | timestamp           |
+--------+--------+---------------------+
|      3 |    100 | 2020-02-07 09:40:37 |
+--------+--------+---------------------+
1 row in set (0.00 sec)

mysql> select * from account_balance;  -- 账户余额是100元；
+---------+---------+---------------------+--------+
| user_id | balance | timestamp           | log_id |
+---------+---------+---------------------+--------+
|       0 |     100 | 2020-02-07 09:47:39 |      3 |
+---------+---------+---------------------+--------+
1 row in set (0.00 sec)

这时候另外一个会话 B，对这个账户完成了一笔转账交易，并且提交了事务。把账户余额更新成了 200 元。

mysql> -- 会话 B
mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select log_id, amount, timestamp  from account_log  order by log_id;
+--------+--------+---------------------+
| log_id | amount | timestamp           |
+--------+--------+---------------------+
|      3 |    100 | 2020-02-07 09:40:37 |
+--------+--------+---------------------+
1 row in set (0.00 sec)

mysql> -- 写入流水
mysql> insert into account_log values (NULL, 100, NOW(), 1, 1001, NULL, 0, NULL, 0, 0);
Query OK, 1 row affected (0.00 sec)

mysql> -- 更新余额
mysql> update account_balance
    -> set balance = balance + 100, log_id = LAST_INSERT_ID(), timestamp = NOW()
    -> where user_id = 0 and log_id = 3;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

mysql> -- 当前账户有2条流水记录
mysql> select log_id, amount, timestamp  from account_log  order by log_id;
+--------+--------+---------------------+
| log_id | amount | timestamp           |
+--------+--------+---------------------+
|      3 |    100 | 2020-02-07 09:40:37 |
|      4 |    100 | 2020-02-07 10:06:15 |
+--------+--------+---------------------+
2 rows in set (0.00 sec)

mysql> -- 当前账户余额是200元；
mysql> select * from account_balance;
+---------+---------+---------------------+--------+
| user_id | balance | timestamp           | log_id |
+---------+---------+---------------------+--------+
|       0 |     200 | 2020-02-07 10:06:16 |      4 |
+---------+---------+---------------------+--------+
1 row in set (0.00 sec)
mysql> commit;
Query OK, 0 rows affected (0.00 sec)

注意，这个时候会话 A 之前开启的事务是一直未关闭的。我们再来会话 A 中看一下账户的余额，你觉得应该是多少？

我们来看一下实际的结果。

mysql> -- 会话 A
mysql> -- 当前账户有2条流水记录
mysql> select log_id, amount, timestamp  from account_log  order by log_id;
+--------+--------+---------------------+
| log_id | amount | timestamp           |
+--------+--------+---------------------+
|      3 |    100 | 2020-02-07 09:40:37 |
|      4 |    100 | 2020-02-07 10:06:15 |
+--------+--------+---------------------+
2 rows in set (0.00 sec)

mysql> -- 当前账户余额是200元；
mysql> select * from account_balance;
+---------+---------+---------------------+--------+
| user_id | balance | timestamp           | log_id |
+---------+---------+---------------------+--------+
|       0 |     200 | 2020-02-07 10:06:16 |      4 |
+---------+---------+---------------------+--------+
1 row in set (0.00 sec)
mysql> commit;
Query OK, 0 rows affected (0.00 sec)

可以看到，当我们把隔离级别设置为 RC 时，会话 A 第二次读到的账户余额是 200 元，也就是会话 B 更新后的数据。对于会话 A 来说，在同一个事务内两次读取同一条数据，读到的结果可能会不一样，这就是“不可重复读”。

如果把隔离级别设置为 RR，会话 A 第二次读到的账户余额仍然是 100 元，交易流水也只有一条记录。在 RR 隔离级别下，在一个事务进行过程中，对于同一条数据，每次读到的结果总是相同的，无论其他会话是否已经更新了这条数据，这就是“可重复读”。

理解了 RC 和 RR 这两种隔离级别的区别，就足够应对绝大部分业务场景了。

最后，我来简单说一下“幻读”。在实际业务中，很少能遇到幻读，即使遇到，也基本不会影响到数据准确性，所以你简单了解一下即可。在 RR 隔离级别下，我们开启一个事务，之后直到这个事务结束，在这个事务内其他事务对数据的更新是不可见的，这个我们刚刚讲过。

比如我们在会话 A 中开启一个事务，准备插入一条 ID 为 1000 的流水记录。查询一下当前流水，不存在 ID 为 1000 的记录，可以安全地插入数据。

mysql> -- 会话 A
mysql> select log_id from account_log where log_id = 1000;
Empty set (0.00 sec)

这时候，另外一个会话抢先插入了这条 ID 为 1000 的流水记录。

mysql> -- 会话 B
mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> insert into account_log values
    -> (1000, 100, NOW(), 1, 1001, NULL, 0, NULL, 0, 0);
Query OK, 1 row affected (0.00 sec)

mysql> commit;
Query OK, 0 rows affected (0.00 sec)

然后会话 A 再执行相同的插入语句时，就会报主键冲突错误，但是由于事务的隔离性，它执行查询的时候，却查不到这条 ID 为 1000 的流水，就像出现了“幻觉”一样，这就是幻读。

mysql> -- 会话 A
mysql> insert into account_log values
    -> (1000, 100, NOW(), 1, 1001, NULL, 0, NULL, 0, 0);
ERROR 1062 (23000): Duplicate entry '1000' for key 'account_log.PRIMARY'
mysql> select log_id from account_log where log_id = 1000;
Empty set (0.00 sec)

理解了这几种隔离级别，最后我们给出一种兼顾并发、性能和数据一致性的交易实现。这个实现在隔离级别为 RC 和 RR 时，都是安全的。

我们给账户余额表增加一个 log_id 属性，记录最后一笔交易的流水号。

首先开启事务，查询并记录当前账户的余额和最后一笔交易的流水号。

然后写入流水记录。

再更新账户余额，需要在更新语句的 WHERE 条件中限定，只有流水号等于之前查询出的流水号时才更新。

然后检查更新余额的返回值，如果更新成功就提交事务，否则回滚事务。

需要特别注意的一点是，更新账户余额后，不能只检查更新语句是不是执行成功了，还需要检查返回值中变更的行数是不是等于 1。因为即使流水号不相等，余额没有更新，这条更新语句的执行结果仍然是成功的，只是更新了 0 条记录。

下面是整个交易的 SQL，供你参考：

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql>  -- 查询当前账户的余额和最后一笔交易的流水号。
mysql> select balance, log_id from account_balance where user_id = 0;
+---------+--------+
| balance | log_id |
+---------+--------+
|     100 |      3 |
+---------+--------+
1 row in set (0.00 sec)

mysql>  -- 插入流水记录。
mysql> insert into account_log values
    -> (NULL, 100, NOW(), 1, 1001, NULL, 0, NULL, 0, 0);
Query OK, 1 row affected (0.01 sec)

mysql>  -- 更新余额，注意where条件中，限定了只有流水号等于之前查询出的流水号3时才更新。
mysql> update account_balance
    -> set balance = balance + 100, log_id = LAST_INSERT_ID(), timestamp = NOW()
    -> where user_id = 0 and log_id = 3;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

mysql>  -- 这里需要检查更新结果，只有更新余额成功（Changed: 1）才提交事务，否则回滚事务。
mysql> commit;
Query OK, 0 rows affected (0.01 sec)

最后，我给出流水和余额两个表的 DDL，你自己执行例子的时候可以使用。

CREATE TABLE `account_log` (
  `log_id` int NOT NULL AUTO_INCREMENT COMMENT '流水号',
  `amount` int NOT NULL COMMENT '交易金额',
  `timestamp` datetime NOT NULL COMMENT '时间戳',
  `from_system` int NOT NULL COMMENT '转出系统编码',
  `from_system_transaction_number` int DEFAULT NULL COMMENT '转出系统的交易号',
  `from_account` int DEFAULT NULL COMMENT '转出账户',
  `to_system` int NOT NULL COMMENT '转入系统编码',
  `to_system_transaction_number` int DEFAULT NULL COMMENT '转入系统的交易号',
  `to_account` int DEFAULT NULL COMMENT '转入账户',
  `transaction_type` int NOT NULL COMMENT '交易类型编码',
  PRIMARY KEY (`log_id`)
);

CREATE TABLE `account_balance` (
  `user_id` int NOT NULL COMMENT '用户ID',
  `balance` int NOT NULL COMMENT '余额',
  `timestamp` datetime NOT NULL COMMENT '时间戳',
  `log_id` int NOT NULL COMMENT '最后一笔交易的流水号',
  PRIMARY KEY (`user_id`)
);

小结

账户系统用于记录每个用户的余额，为了保证数据的可追溯性，还需要记录账户流水。流水记录只能新增，任何情况下都不允许修改和删除，每次交易的时候需要把流水和余额放在同一个事务中一起更新。

事务具备原子性、一致性、隔离性和持久性四种基本特性，也就是 ACID，它可以保证在一个事务中执行的数据更新，要么都成功，要么都失败。并且在事务执行过程中，中间状态的数据对其他事务是不可见的。

ACID 是一种理想情况，特别是要完美地实现 CI，会导致数据库性能严重下降，所以 MySQL 提供的四种可选的隔离级别，牺牲一定的隔离性和一致性，用于换取高性能。这四种隔离级别中，只有 RC 和 RR 这两种隔离级别是常用的，它们的唯一区别是在进行的事务中，其他事务对数据的更新是否可见。

思考题

课后希望你能动手执行一下我们今天这节课中给出的例子，看一下多个事务并发更新同一个账户时，RC 和 RR 两种不同的隔离级别，在行为上有什么不同？

欢迎你在留言区与我讨论，如果你觉得今天的内容对你有帮助，也欢迎把它分享给你的朋友。

05 | 分布式事务：如何保证多个系统间的数据是一致的？

你好，我是李玥。

上节课，我和你一起通过账户系统学习了数据库事务，事务很好地解决了交易类系统的数据一致性问题。

事务的原子性和持久性可以确保在一个事务内，更新多条数据，要么都成功，要么都失败。在一个系统内部，我们可以使用数据库事务来保证数据一致性。那如果一笔交易，涉及到跨多个系统、多个数据库的时候，用单一的数据库事务就没办法解决了。

在之前大系统的时代，普遍的做法是，在设计时尽量避免这种跨系统跨数据库的交易。

但是，现在的技术趋势是云原生和微服务，微服务它的理念是什么？大系统被打散成多个小的微服务，每个微服务独立部署并且拥有自己的数据库，大数据库也被打散成多个小的数据库。跨越微服务和数据库的交易就成为一种越来越普遍的情况。我们的业务系统微服务化之后，不可避免地要面对跨系统的数据一致性问题。

如何来解决这种跨系统、跨数据库的数据一致性问题呢？你可能会脱口而出：分布式事务。但是，分布式事务可不像数据库事务那样，在开始和结尾分别加上 begin 和 commit，剩下的问题数据库都可以帮我们搞定。在分布式环境下，没有这么简单的事儿，为什么？

因为在分布式环境中，一个交易将会被分布到不同的系统中，多个微服务进程内执行计算，在多个数据库中执行数据更新操作，这个场景比数据库事务支持的单进程单数据库场景复杂太多了。所以，并没有什么分布式事务服务或者组件能在分布式环境下，提供接近数据库事务的数据一致性保证。

今天这节课我们就来说一下，如何用分布式事务的方法，来解决微服务系统中，我们实际面临的分布式数据一致性问题。

到底什么是分布式事务？

在学习分布式事务这个概念之前，我先跟你说一下为什么一定要搞懂概念。我们这门课程是一门实战课，一般来说，我们更关注的是如何来解决实际问题，而不是理论和概念，所以你看，我们在讲解数据库事务的时候，讲的内容是如何用事务解决交易的问题，而没讲 MySQL 是如何实现 ACID 的。因为数据库已经把事务封装的非常好了，我们只需要掌握如何使用就可以很好地解决问题。

但分布式事务不是这样的，我刚刚说了，并没有一种分布式事务的服务或者组件，能帮我们很简单地就解决分布式系统下的数据一致性问题。我们在使用分布式事务时，更多的情况是，用分布式事务的理论来指导设计和开发，自行来解决数据一致性问题。也就是说，要解决分布式一致性问题，你必须掌握几种分布式事务的实现原理。

我们在讲解数据库事务时讲到了事务的 ACID 四个特性，我们知道即使是数据库事务，它考虑到性能的因素，大部分情况下不能也不需要百分之百地实现 ACID，所以才有了事务四种隔离级别。

理论上，分布式事务也是事务，也需要遵从 ACID 四个特性，但实际情况是，在分布式系统中，因为必须兼顾性能和高可用，所以是不可能完全满足 ACID 的。我们常用的几种分布式事务的实现方法，都是“残血版”的事务，而且相比数据库事务，更加的“残血”。

分布式事务的解决方案有很多，比如：2PC、3PC、TCC、Saga 和本地消息表等等。这些方法，它的强项和弱项都不一样，适用的场景也不一样，所以最好这些分布式事务你都能够掌握，这样才能在面临实际问题的时候选择合适的方法。这里面，2PC 和本地消息表这两种分布式事务的解决方案，比较贴近于我们日常开发的业务系统。

2PC：订单与优惠券的数据一致性问题

2PC 也叫二阶段提交，是一种常用的分布式事务实现方法。我们用订单和优惠券的例子来说明一下，如何用 2PC 来解决订单系统和促销系统的数据一致性问题。在我们购物下单时，如果使用了优惠券，订单系统和优惠券系统都要更新自己的数据，才能完成“在订单中使用优惠券”这个操作。

订单系统需要：

在“订单优惠券表”中写入订单关联的优惠券数据；

在“订单表”中写入订单数据。

订单系统内两个操作的一致性问题可以直接使用数据库事务来解决。促销系统需要操作就比较简单，把刚刚使用的那张优惠券的状态更新成“已使用”就可以了。我们需要这两个系统的数据更新操作保持一致，要么都更新成功，要么都更新失败。

接下来我们来看 2PC 是怎么解决这个问题的。2PC 引入了一个事务协调者的角色，来协调订单系统和促销系统，协调者对客户端提供一个完整的“使用优惠券下单”的服务，在这个服务的内部，协调者再分别调用订单和促销的相应服务。

所谓的二阶段指的是准备阶段和提交阶段。在准备阶段，协调者分别给订单系统和促销系统发送“准备”命令，订单和促销系统收到准备命令之后，开始执行准备操作。准备阶段都需要做哪些事儿呢？你可以理解为，除了提交数据库事务以外的所有工作，都要在准备阶段完成。比如说订单系统在准备阶段需要完成：

在订单库开启一个数据库事务；

在“订单优惠券表”写入这条订单的优惠券记录；

在“订单表”中写入订单数据。

注意，到这里我们没有提交订单数据库的事务，最后给事务协调者返回“准备成功”。类似的，促销服务在准备阶段，需要在促销库开启一个数据库事务，更新优惠券状态，但是暂时不要提交这个数据库事务，给协调者返回“准备成功”。协调者在收到两个系统“准备成功”的响应之后，开始进入第二阶段。

等两个系统都准备好了之后，进入提交阶段。提交阶段就比较简单了，协调者再给这两个系统发送“提交”命令，每个系统提交自己的数据库事务，然后给协调者返回“提交成功”响应，协调者收到所有响应之后，给客户端返回成功响应，整个分布式事务就结束了。以下是这个过程的时序图：

这是正常情况，接下来才是重点：异常情况下怎么办？

我们还是分两个阶段来说明。在准备阶段，如果任何一步出现错误或者是超时，协调者就会给两个系统发送“回滚事务”请求。每个系统在收到请求之后，回滚自己的数据库事务，分布式事务执行失败，两个系统的数据库事务都回滚了，相关的所有数据回滚到分布式事务执行之前的状态，就像这个分布式事务没有执行一样。以下是异常情况的时序图：

如果准备阶段成功，进入提交阶段，这个时候就“只有华山一条路”，整个分布式事务只能成功，不能失败。

如果发生网络传输失败的情况，需要反复重试，直到提交成功为止。如果这个阶段发生宕机，包括两个数据库宕机或者订单服务、促销服务所在的节点宕机，还是有可能出现订单库完成了提交，但促销库因为宕机自动回滚，导致数据不一致的情况。但是，因为提交的过程非常简单，执行很快，出现这种情况的概率非常小，所以，从实用的角度来说，2PC 这种分布式事务的方法，实际的数据一致性还是非常好的。

在实现 2PC 的时候，没必要单独启动一个事务协调服务，这个协调服务的工作最好和订单服务或者优惠券服务放在同一个进程里面，这样做有两个好处：

参与分布式事务的进程更少，故障点也就更少，稳定性更好；

减少了一些远程调用，性能也更好一些。

2PC 是一种强一致的设计，它可以保证原子性和隔离性。只要 2PC 事务完成，订单库和促销库中的数据一定是一致的状态，也就是我们总说的，要么都成功，要么都失败。

所以 2PC 比较适合那些对数据一致性要求比较高的场景，比如我们这个订单优惠券的场景，如果一致性保证不好，有可能会被黑产利用，一张优惠券反复使用，那样我们的损失就大了。

2PC 也有很明显的缺陷，整个事务的执行过程需要阻塞服务端的线程和数据库的会话，所以，2PC 在并发场景下的性能不会很高。并且，协调者是一个单点，一旦过程中协调者宕机，就会导致订单库或者促销库的事务会话一直卡在等待提交阶段，直到事务超时自动回滚。

卡住的这段时间内，数据库有可能会锁住一些数据，服务中会卡住一个数据库连接和线程，这些都会造成系统性能严重下降，甚至整个服务被卡住。

所以，只有在需要强一致、并且并发量不大的场景下，才考虑使用 2PC。

本地消息表：订单与购物车的数据一致性问题

2PC 它的适用场景其实是很窄的，更多的情况下，只要保证数据最终一致就可以了。比如说，在购物流程中，用户在购物车界面选好商品后，点击“去结算”按钮进入订单页面创建一个新订单。这个过程我们的系统其实做了两件事儿。

第一，订单系统需要创建一个新订单，订单关联的商品就是购物车中选择的那些商品。

第二，创建订单成功后，购物车系统需要把订单中的这些商品从购物车里删掉。

这也是一个分布式事务问题，创建订单和清空购物车这两个数据更新操作需要保证，要么都成功，要么都失败。但是，清空购物车这个操作，它对一致性要求就没有扣减优惠券那么高，订单创建成功后，晚几秒钟再清空购物车，完全是可以接受的。只要保证经过一个小的延迟时间后，最终订单数据和购物车数据保持一致就可以了。

本地消息表非常适合解决这种分布式最终一致性的问题。我们一起来看一下，如何使用本地消息表来解决订单与购物车的数据一致性问题。

本地消息表的实现思路是这样的，订单服务在收到下单请求后，正常使用订单库的事务去更新订单的数据，并且，在执行这个数据库事务过程中，在本地记录一条消息。这个消息就是一个日志，内容就是“清空购物车”这个操作。因为这个日志是记录在本地的，这里面没有分布式的问题，那这就是一个普通的单机事务，那我们就可以让订单库的事务，来保证记录本地消息和订单库的一致性。完成这一步之后，就可以给客户端返回成功响应了。

然后，我们再用一个异步的服务，去读取刚刚记录的清空购物车的本地消息，调用购物车系统的服务清空购物车。购物车清空之后，把本地消息的状态更新成已完成就可以了。异步清空购物车这个过程中，如果操作失败了，可以通过重试来解决。最终，可以保证订单系统和购物车系统它们的数据是一致的。

这里面，本地消息表，你可以选择存在订单库中，也可以用文件的形式，保存在订单服务所在服务器的本地磁盘中，两种方式都是可以的，相对来说，放在订单库中更简单一些。

消息队列 RocketMQ 提供一种事务消息的功能，其实就是本地消息表思想的一个实现。使用事务消息可以达到和本地消息表一样的最终一致性，相比我们自己来实现本地消息表，使用起来更加简单，你也可以考虑使用。（我在《消息队列高手课》的专栏中的“如何利用事务消息实现分布式事务？”这节课中有详细的讲解。）

如果看事务的 ACID 四个特性，本地消息表这种方法，它只能满足 D（持久性），A（原子性）C（一致性）、I（隔离性）都比较差，但是，它的优点非常突出。

首先，实现简单，在单机事务的基础上稍加改造就可以实现分布式事务，另外，本地消息表的性能非常好，和单机事务的性能几乎没有差别。在这个基础上，还提供了大部分情况下都能接受的“数据最终一致性”的保证，所以，本地消息表是更加实用的分布式事务实现方法。

当然，即使能接受数据最终一致，本地消息表也不是什么场景都可以适用的。它有一个前提条件就是，异步执行的那部分操作，不能有依赖的资源。比如说，我们下单的时候，除了要清空购物车以外，还需要锁定库存。

库存系统锁定库存这个操作，虽然可以接受数据最终一致，但是，锁定库存这个操作是有一个前提的，这个前提是：库存中得有货。这种情况就不适合使用本地消息表，不然就会出现用户下单成功后，系统的异步任务去锁定库存的时候，因为缺货导致锁定失败。这样的情况就很难处理了。

小结

这节课我们讲解了，如何用分布式事务的几种方法来解决分布式系统中的数据一致性问题。对于订单和优惠券这种需要强一致的分布式事务场景，可以采用 2PC 的方法来解决问题。

2PC 它的优点是强一致，但是性能和可用性上都有一些缺陷。本地消息表适用性更加广泛，虽然在数据一致性上有所牺牲，只能满足最终一致性，但是有更好的性能，实现简单，系统的稳定性也很好，是一种非常实用的分布式事务的解决方案。

无论是哪种分布式事务方法，其实都是把一个分布式事务，拆分成多个本地事务。本地事务可以用数据库事务来解决，那分布式事务就专注于解决如何让这些本地事务保持一致的问题。我们在遇到分布式一致性问题的时候，也要基于这个思想来考虑问题，再结合实际的情况选择分布式事务的方法。

思考题

2PC 也有一些改进版本，比如 3PC、TCC 这些，它们大体的思想和 2PC 是差不多的，解决了 2PC 的一些问题，但是也会带来新的问题，实现起来也更复杂，限于篇幅我们没法每个都详细地去讲解。在理解了 2PC 的基础上，课后请你自行去学习一下 3PC 和 TCC，然后对比一下，2PC、3PC 和 TCC 分别适用于什么样的业务场景？

欢迎你在留言区与我讨论，如果你觉得今天的内容对你有帮助，也欢迎把它分享给你的朋友。

06 | 如何用Elasticsearch构建商品搜索系统？

你好，我是李玥。

搜索这个特性可以说是无处不在，现在很少有网站或者系统不提供搜索功能了，所以，即使你不是一个专业做搜索的程序员，也难免会遇到一些搜索相关的需求。搜索这个东西，表面上看功能很简单，就是一个搜索框，输入关键字，然后搜出来想要的内容就好了。

搜索背后的实现，可以非常简单，简单到什么程度呢？我们就用一个 SQL，LIKE 一下就能实现；也可以很复杂，复杂到什么程度呢？不说百度谷歌这种专业做搜索的公司，其他非专业做搜索的互联网大厂，搜索团队大多是千人规模，这里面不仅有程序员，还有算法工程师、业务专家等等。二者的区别也仅仅是，搜索速度的快慢以及搜出来的内容好坏而已。

今天这节课，我们就以电商中的商品搜索作为例子，来讲一下，如何用 ES(Elasticsearch) 来快速、低成本地构建一个体验还不错的搜索系统。

理解倒排索引机制

刚刚我们说了，既然我们的数据大多都是存在数据库里，用 SQL 的 LIKE 也能实现匹配，也能搜出结果，为什么还要专门做一套搜索系统呢？我先来和你分析一下，为什么数据库不适合做搜索。

搜索的核心需求是全文匹配，对于全文匹配，数据库的索引是根本派不上用场的，那只能全表扫描。全表扫描已经非常慢了，这还不算，还需要在每条记录上做全文匹配，也就是一个字一个字的比对，这个速度就更慢了。所以，使用数据来做搜索，性能上完全没法满足要求。

那 ES 是怎么来解决搜索问题的呢？我们来举个例子说明一下，假设我们有这样两个商品，一个是烟台红富士苹果，一个是苹果手机 iPhone XS Max。

这个表里面的 DOCID 就是唯一标识一条记录的 ID，和数据库里面的主键是类似的。

为了能够支持快速地全文搜索，ES 中对于文本采用了一种特殊的索引：倒排索引（Inverted Index）。那我们看一下在 ES 中，这两条商品数据倒排索引长什么样？请看下面这个表。

可以看到，这个倒排索引的表，它是以单词作为索引的 Key，然后每个单词的倒排索引的值是一个列表，这个列表的元素就是含有这个单词的商品记录的 DOCID。

这个倒排索引怎么构建的呢？当我们往 ES 写入商品记录的时候，ES 会先对需要搜索的字段，也就是商品标题进行分词。分词就是把一段连续的文本按照语义拆分成多个单词。然后 ES 按照单词来给商品记录做索引，就形成了上面那个表一样的倒排索引。

当我们搜索关键字“苹果手机”的时候，ES 会对关键字也进行分词，比如说，“苹果手机”被分为“苹果”和“手机”。然后，ES 会在倒排索引中去搜索我们输入的每个关键字分词，搜索结果应该是：

666 和 888 这两条记录都能匹配上搜索的关键词，但是 888 这个商品比 666 这个商品匹配度更高，因为它两个单词都能匹配上，所以按照匹配度把结果做一个排序，最终返回的搜索结果就是：

苹果Apple iPhone XS Max (A2104) 256GB 金色 移动联通电信 4G手机双卡双待

烟台红富士苹果5kg 一级铂金大果 单果 230g 以上 新鲜水果

看起来搜索的效果还是不错的。

为什么倒排索引可以做到快速搜索？我和你一起来分析一下上面这个例子的查找性能。

这个搜索过程，其实就是对上面的倒排索引做了二次查找，一次找“苹果”，一次找“手机”。注意，整个搜索过程中，我们没有做过任何文本的模糊匹配。ES 的存储引擎存储倒排索引时，肯定不是像我们上面表格中展示那样存成一个二维表，实际上它的物理存储结构和 MySQL 的 InnoDB 的索引是差不多的，都是一颗查找树。

对倒排索引做两次查找，也就是对树进行二次查找，它的时间复杂度，类似于 MySQL 中的二次命中索引的查找。显然，这个查找速度，比用 MySQL 全表扫描加上模糊匹配的方式，要快好几个数量级。

如何在 ES 中构建商品的索引?

理解了倒排索引的原理之后，我们一起用 ES 构建一个商品索引，简单实现一个商品搜索系统。虽然 ES 是为搜索而生的，但本质上，它仍然是一个存储系统。ES 里面的一些概念，基本上都可以在关系数据库中找到对应的名词，为了便于你快速理解这些概念，我把这些概念的对应关系列出来，你可以对照理解。

在 ES 里面，数据的逻辑结构类似于 MongoDB，每条数据称为一个 DOCUMENT，简称 DOC。DOC 就是一个 JSON 对象，DOC 中的每个 JSON 字段，在 ES 中称为 FIELD，把一组具有相同字段的 DOC 存放在一起，存放它们的逻辑容器叫 INDEX，这些 DOC 的 JSON 结构称为 MAPPING。这里面最不好理解的就是这个 INDEX，它实际上类似于 MySQL 中表的概念，而不是我们通常理解的用于查找数据的索引。

ES 是一个用 Java 开发的服务端程序，除了 Java 以外就没有什么外部依赖了，安装部署都非常简单，具体你可以参照它的官方文档先把 ES 安装好。我们这个示例中，使用的 ES 版本是目前的最新版本 7.6。

另外，为了能让 ES 支持中文分词，需要给 ES 安装一个中文的分词插件IK Analysis for Elasticsearch，这个插件的作用就是告诉 ES 怎么对中文文本进行分词。

你可以直接执行下面的命令自动下载并安装：

$elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.6.0/elasticsearch-analysis-ik-7.6.0.zip

安装完成后，需要重启 ES，验证一下是否安装成功：

curl -X POST "localhost:9200/_analyze?pretty" -H 'Content-Type: application/json' -d '{ "analyzer": "ik_smart", "text": "极客时间" }'
{
  "tokens" : [
    {
      "token" : "极",
      "start_offset" : 0,
      "end_offset" : 1,
      "type" : "CN_CHAR",
      "position" : 0
    },
    {
      "token" : "客",
      "start_offset" : 1,
      "end_offset" : 2,
      "type" : "CN_CHAR",
      "position" : 1
    },
    {
      "token" : "时间",
      "start_offset" : 2,
      "end_offset" : 4,
      "type" : "CN_WORD",
      "position" : 2
    }
  ]
}

可以看到，这个分词器把“极客时间”分成了“极”、“客”和“时间”，没认出来“极客”这个词，还是有改进空间的。

为了能实现商品搜索，我们需要先把商品信息存放到 ES 中，首先我们先定义存放在 ES 中商品的数据结构，也就是 MAPPING。

我们这个 MAPPING 只要两个字段就够了，sku_id 就是商品 ID，title 保存商品的标题，当用户在搜索商品的时候，我们在 ES 中来匹配商品标题，返回符合条件商品的 sku_id 列表。ES 默认提供了标准的 RESTful 接口，不需要客户端，直接使用 HTTP 协议就可以访问，这里我们使用curl通过命令行来操作 ES。

接下来我们使用上面这个 MAPPING 创建 INDEX，类似于 MySQL 中创建一个表。

curl -X PUT "localhost:9200/sku" -H 'Content-Type: application/json' -d '{
        "mappings": {
                "properties": {
                        "sku_id": {
                                "type": "long"
                        },
                        "title": {
                                "type": "text",
                                "analyzer": "ik_max_word",
                                "search_analyzer": "ik_max_word"
                        }
                }
        }
}'
{"acknowledged":true,"shards_acknowledged":true,"index":"sku"}

这里面，使用 PUT 方法创建一个 INDEX，INDEX 的名称是“sku”，直接写在请求的 URL 中。请求的 BODY 是一个 JSON 对象，内容就是我们上面定义的 MAPPING，也就是数据结构。这里面需要注意一下，由于我们要在 title 这个字段上进行全文搜索，所以我们把数据类型定义为 text，并指定使用我们刚刚安装的中文分词插件 IK 作为这个字段的分词器。

创建好 INDEX 之后，就可以往 INDEX 中写入商品数据，插入数据需要使用 HTTP POST 方法：

curl -X POST "localhost:9200/sku/_doc/" -H 'Content-Type: application/json' -d '{
        "sku_id": 100002860826,
        "title": "烟台红富士苹果 5kg 一级铂金大果 单果230g以上 新鲜水果"
}'
{"_index":"sku","_type":"_doc","_id":"yxQVSHABiy2kuAJG8ilW","_version":1,"result":"created","_shards":{"total":2,"successful":1,"failed":0},"_seq_no":0,"_primary_term":1}

curl -X POST "localhost:9200/sku/_doc/" -H 'Content-Type: application/json' -d '{
        "sku_id": 100000177760,
        "title": "苹果 Apple iPhone XS Max (A2104) 256GB 金色 移动联通电信4G手机 双卡双待"
}'
{"_index":"sku","_type":"_doc","_id":"zBQWSHABiy2kuAJGgim1","_version":1,"result":"created","_shards":{"total":2,"successful":1,"failed":0},"_seq_no":1,"_primary_term":1}

这里面我们插入了两条商品数据，一个烟台红富士，一个 iPhone 手机。然后就可以直接进行商品搜索了，搜索使用 HTTP GET 方法。

curl -X GET 'localhost:9200/sku/_search?pretty' -H 'Content-Type: application/json' -d '{
  "query" : { "match" : { "title" : "苹果手机" }}
}'
{
  "took" : 23,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 2,
      "relation" : "eq"
    },
    "max_score" : 0.8594865,
    "hits" : [
      {
        "_index" : "sku",
        "_type" : "_doc",
        "_id" : "zBQWSHABiy2kuAJGgim1",
        "_score" : 0.8594865,
        "_source" : {
          "sku_id" : 100000177760,
          "title" : "苹果 Apple iPhone XS Max (A2104) 256GB 金色 移动联通电信4G手机 双卡双待"
        }
      },
      {
        "_index" : "sku",
        "_type" : "_doc",
        "_id" : "yxQVSHABiy2kuAJG8ilW",
        "_score" : 0.18577608,
        "_source" : {
          "sku_id" : 100002860826,
          "title" : "烟台红富士苹果 5kg 一级铂金大果 单果230g以上 新鲜水果"
        }
      }
    ]
  }
}

我们先看一下请求中的 URL，其中的“sku”代表要在 sku 这个 INDEX 内进行查找，“_search”是一个关键字，表示要进行搜索，参数 pretty 表示格式化返回的 JSON，这样方便阅读。再看一下请求 BODY 的 JSON，query 中的 match 表示要进行全文匹配，匹配的字段就是 title，关键字是“苹果手机”。

可以看到，在返回结果中，匹配到了 2 条商品记录，和我们在前面讲解倒排索引时，预期返回的结果是一致的。

我们来回顾一下使用 ES 构建商品搜索服务的这个过程：首先安装 ES 并启动服务，然后创建一个 INDEX，定义 MAPPING，写入数据后，执行查询并返回查询结果，其实，这个过程和我们使用数据库时，先建表、插入数据然后查询的过程，就是一样的。所以，你就把 ES 当做一个支持全文搜索的数据库来使用就行了。

小结

ES 本质上是一个支持全文搜索的分布式内存数据库，特别适合用于构建搜索系统。ES 之所以能有非常好的全文搜索性能，最重要的原因就是采用了倒排索引。倒排索引是一种特别为搜索而设计的索引结构，倒排索引先对需要索引的字段进行分词，然后以分词为索引组成一个查找树，这样就把一个全文匹配的查找转换成了对树的查找，这是倒排索引能够快速进行搜索的根本原因。

但是，倒排索引相比于一般数据库采用的 B 树索引，它的写入和更新性能都比较差，因此倒排索引也只是适合全文搜索，不适合更新频繁的交易类数据。

思考题

我们在电商的搜索框中搜索商品时，它都有一个搜索提示的功能，比如我输入“苹果”还没有点击搜索按钮的时候，搜索框下面会提示“苹果手机”、“苹果 11、苹果电脑”这些建议的搜索关键字，请你课后看一下 ES 的文档，想一下，如何用 ES 快速地实现这个搜索提示功能？

欢迎你在留言区与我讨论，如果你觉得今天的内容对你有帮助，也欢迎把它分享给你的朋友。

07｜MySQL HA：如何将“删库跑路”的损失降到最低？

你好，我是李玥。

对于任何一个企业来说，数据安全的重要性是不言而喻的。我在开篇词中也曾经强调过，凡是涉及到数据的问题，都是损失惨重的大问题。

能够影响数据安全的事件，都是极小概率的事件，比如说：数据库宕机、磁盘损坏甚至机房着火，还有最近频繁出现在段子中“程序员不满老板删库跑路”的梗儿，但这些事儿一旦发生了，我们的业务就会损失惨重。

一般来说，存储系统导致的比较严重的损失主要有两种情况，一是数据丢失造成的直接财产损失，比如大量的坏账；二是由于存储系统损坏，造成整个业务系统停止服务而带来的损失。

所谓防患于未然，你从设计一个系统的第一天起，就需要考虑在出现各种问题的时候，如何来保证这个系统的数据安全性。今天我们来聊一聊，如何提前预防，将“删库跑路”等这类问题导致的损失尽量降到最低。

如何更安全地做数据备份和恢复？

保证数据安全，最简单而且有效的手段就是定期备份数据，这样出现任何问题导致的数据损失，都可以通过备份来恢复数据。但是，如何备份，才能最大程度地保证数据安全，并不是一个简单的事儿。

2018 年还出现过某个著名的云服务商因为硬盘损坏，导致多个客户数据全部丢失的重大故障。这么大的云服务商，数据是不可能没有备份的，按说硬盘损坏，不会导致数据丢失的，但是因为各种各样的原因，最终的结果是数据的三个副本都被删除，数据丢失无法找回。

所以说，不是简单地定期把数据备份一下就可以高枕无忧了。接下来我们还是以大家最常用的 MySQL 为例来说一下，如何更安全地来做数据备份和恢复。

最简单的备份方式就是全量备份。备份的时候，把所有的数据复制一份，存放到文件中，恢复的时候再把文件中的数据复制回去，这样可以保证恢复之后数据库中的数据和备份时是完全一样的。在 MySQL 中，你可以使用mysqldump命令来执行全量备份。

比如我们要全量备份数据库 test：

$mysqldump -uroot -p test > test.sql

备份出来的文件就是一个 SQL 文件，就是创建数据库、表，写入数据等等这些 SQL，如果要恢复数据，直接执行这个备份的 SQL 文件就可以了：

$mysql -uroot test < test.sql

不过，全量备份的代价非常高，为什么这么说呢？

首先，备份文件包含数据库中的所有数据，占用的磁盘空间非常大；其次，每次备份操作都要拷贝大量数据，备份过程中会占用数据库服务器大量的 CPU、磁盘 IO 资源，并且为了保证数据一致性，还有可能会锁表，这些都会导致备份期间，数据库本身的性能严重下降。所以，我们不能经常对数据库执行全量备份。

一般来说，每天执行一次全量备份已经是非常频繁了。那这就意味着，如果数据库中的数据丢了，那只能恢复到最近一次全量备份的那个时间点，这个时间点之后的数据还是丢了。也就是说，全量备份不能做到完全无损地恢复。

既然全量备份代价太高，不能频繁执行，那有没有代价低一点儿的备份方法，能让我们少丢甚至不丢数据呢？还真有，那就是增量备份。相比于全量备份，增量备份每次只备份相对于上一次备份变化的那部分数据，所以每次增量备份速度更快。

MySQL 自带了 Binlog，就是一种实时的增量备份。Binlog 里面记录的就是 MySQL 数据的变更的操作日志，开启 Binlog 之后，我们对 MySQL 中的每次更新数据操作，都会被记录到 Binlog 中。

Binlog 是可以回放的，回放 Binlog，就相当于把之前对数据库所有数据更新操作按照顺序重新执行了一遍，回放完成之后数据自然就恢复了。这就是 Binlog 增量备份的基本原理。很多数据库都有类似于 MySQL Binlog 的日志，原理和 Binlog 是一样的，备份和恢复方法也是类似的。

下面通过一个例子看一下如何使用 Binlog 进行备份和恢复。首先使用“show variables like ‘%log_bin%’”命令确认一下是否开启了 Binlog 功能：

mysql> show variables like '%log_bin%';
+---------------------------------+-----------------------------------+
| Variable_name                   | Value                             |
+---------------------------------+-----------------------------------+
| log_bin                         | ON                                |
| log_bin_basename                | /usr/local/var/mysql/binlog       |
+---------------------------------+-----------------------------------+
mysql> show master status;
+---------------+----------+--------------+------------------+-------------------+
| File          | Position | Binlog_Do_DB | Binlog_Ignore_DB | Executed_Gtid_Set |
+---------------+----------+--------------+------------------+-------------------+
| binlog.000001 |    18745 |              |                  |                   |
+---------------+----------+--------------+------------------+-------------------+

可以看到当前这个数据库已经开启了 Binlog，log_bin_basename 表示 Binlog 文件在服务器磁盘上的具体位置。然后用“show master status”命令可查看当前 Binlog 的状态，显示正在写入的 Binlog 文件，及当前的位置。假设我们每天凌晨用 mysqldump 做一个全量备份，然后开启了 Binlog，有了这些，我们就可以把数据恢复到全量备份之后的任何一个时刻。

下面我们做一个简单的备份恢复演示。我们先模拟一次“删库跑路”的场景，直接把账户余额表清空：

mysql> truncate table account_balance;
Query OK, 0 rows affected (0.02 sec)

mysql> select * from  account_balance;
Empty set (0.00 sec)

然后我们来进行数据恢复，首先执行一次全量恢复，把数据库恢复到今天凌晨的状态。

$mysql -uroot test < dump.sql

mysql> select * from  account_balance;
+---------+---------+---------------------+--------+
| user_id | balance | timestamp           | log_id |
+---------+---------+---------------------+--------+
|       0 |     100 | 2020-02-13 20:24:33 |      3 |
+---------+---------+---------------------+--------+

可以看到，表里面的数据已经恢复了，但还是比较旧的数据。然后我们再用 Binlog 把数据恢复到删库跑路之前的那个时刻：

$mysqlbinlog --start-datetime "2020-02-20 00:00:00" --stop-datetime "2020-02-20 15:09:00" /usr/local/var/mysql/binlog.000001 | mysql -uroot

mysql> select * from  account_balance;
+---------+---------+---------------------+--------+
| user_id | balance | timestamp           | log_id |
+---------+---------+---------------------+--------+
|       0 |     200 | 2020-02-20 15:08:12 |      0 |
+---------+---------+---------------------+--------+

这时候，数据已经恢复到当天的 15 点了。

通过定期的全量备份，配合 Binlog，我们就可以把数据恢复到任意一个时间点，再也不怕程序员删库跑路了。详细的命令你可以参考MySQL 的官方文档中“备份和恢复”这一章。

在执行备份和恢复的时候，有几个要点你需要特别的注意。

第一，也是最重要的，“不要把所有的鸡蛋放在同一个篮子中”，无论是全量备份还是 Binlog，都不要和数据库存放在同一个服务器上。最好能做到不同机房，甚至不同城市，离得越远越好。这样即使出现机房着火、光缆被挖断甚至地震也不怕。

第二，在回放 Binlog 的时候，指定的起始时间可以比全量备份的时间稍微提前一点儿，确保全量备份之后的所有操作都在恢复的 Binlog 范围内，这样可以保证恢复的数据的完整性。

因为回放 Binlog 的操作是具备幂等性的（为了确保回放幂等，需要设置 Binlog 的格式为 ROW 格式)，关于幂等性，我们在《01 | 创建和更新订单时，如何保证数据准确无误？》这节课中讲到过，多次操作和一次操作对系统的影响是一样的，所以重复回放的那部分 Binlog 并不会影响数据的准确性。

配置 MySQL HA 实现高可用

通过全量备份加上 Binlog，我们可以将数据库恢复到任何一个时间点，这样至少不会丢数据了。如果说，数据库服务器宕机了，因为我们有备份数据，完全可以启动一个新的数据库服务器，把备份数据恢复到新的数据库上，这样新的数据库就可以替代宕机的数据库，继续提供服务。

但是，这个恢复数据的时间是很长的，如果数据量比较大的话，有可能需要恢复几个小时。这几个小时，我们的系统是一直不可用的，这样肯定不行。

这个问题怎么解决？很简单，你不要等着数据库宕机了，才开始做恢复，我们完全可以提前来做恢复这些事儿。

我们准备一台备用的数据库，把它的数据恢复成主库一样，然后实时地在主备数据库之间来同步 Binlog，主库做了一次数据变更，生成一条 Binlog，我们就把这一条 Binlog 复制到备用库并立即回放，这样就可以让备用库里面的数据和主库中的数据一直保持是一样的。一旦主库宕机，就可以立即切换到备用库上继续提供服务。这就是 MySQL 的高可用方案，也叫 MySQL HA。

MySQL 自身就提供了主从复制的功能，通过配置就可以让一主一备两台 MySQL 的数据库保持数据同步，具体的配置方法你可以参考MySQ 官方文档中“复制”这一章。

接下来我们说这个方案的问题。当我们对主库执行一次更新操作的时候，主从两个数据库更新数据实际的时序是这样的：

在主库的磁盘上写入 Binlog；

主库更新存储引擎中的数据；

给客户端返回成功响应；

主库把 Binlog 复制到从库；

从库回放 Binlog，更新存储引擎中的数据。

也就是说，从库的数据是有可能比主库上的数据旧一些的，这个主从之间复制数据的延迟，称为“主从延迟”。正常情况下，主从延迟基本都是毫秒级别，你可以认为主从就是实时保持同步的。麻烦的是不正常的情况，一旦主库或者从库繁忙的时候，有可能会出现明显的主从延迟。

而很多情况下，数据库都不是突然宕机的，而是先繁忙，性能下降，最终宕机。这种情况下，很有可能主从延迟很大，如果我们把业务直接切到从库上继续读写，主从延迟这部分数据就丢了，并且这个数据丢失是不可逆的。即使事后你找回了当时主库的 Binlog 也是没法做到自动恢复的，因为它和从库的数据是冲突的。

简单地说，如果主库宕机并且主从存在延迟的情况下，切换到从库继续读写，可以保证业务的可用性，但是主从延迟这部分数据就丢失了。

这个时候你就需要做一个选择题了，第一个选项是，保证不丢数据，牺牲可用性，暂时停止服务，想办法把主库的 Binlog 恢复到从库上之后再提供服务。第二个选项就是，冒着丢一些数据的风险，保证可用性，第一时间切换到从库继续提供服务。

那能不能既保证数据不丢，还能做到高可用呢？也是可以的，那你就要牺牲一些性能。MySQL 也支持同步复制，开启同步复制时，MySQL 主库会等待数据成功复制到从库之后，再给客户端返回响应。

如果说，牺牲的这点儿性能我不在乎，这个方案是不是就完美了呢？也不是，新的问题又来了！你想一下，这种情况下从库宕机了怎么办？本来从库宕机对主库是完全没影响的，因为现在主库要等待从库写入成功再返回，从库宕机，主库就会一直等待从库，主库也卡死了。

这个问题也有解决办法，那就是再加一个从库，把主库配置成：成功复制到任意一个从库就返回，只要有一个从库还活着，就不会影响主库写入数据，这样就解决了从库宕机阻塞主库的问题。如果主库发生宕机，在两个从库中，至少有一个从库中的数据是和主库完全一样的，可以把这个库作为新的主库，继续提供服务。为此你需要付出的代价是，你要至少用三台数据库服务器，并且这三台服务器提供的服务性能，还不如一台服务器高。

我把上面这三种典型的 HA 方案总结成下面这个表格，便于你对比选择：

小结

今天这节课讲了两件事儿，一是如何备份和恢复数据库中的数据，确保数据安全；二是如何来实现数据库的高可用，避免宕机停服。

虽然这是两个不同的问题，但你要知道，解决这两个问题背后的实现原理是一样的。高可用依赖的是数据复制，数据复制的本质就是从一个库备份数据，然后恢复到另外一个库中去。

数据备份时，使用低频度的全量备份配合 Binlog 增量备份是一种常用而且非常实用的方法，使用这种备份方法，我们可以把数据库的数据精确地恢复到历史上任意一个时刻，不仅能解决数据损坏的问题，也不用怕误操作、删库跑路这些事儿了。特别要注意的是，让备份数据尽量地远离数据库。

我们今天讲到的几种 MySQL 典型的 HA 方案，在数据可靠性、数据库可用性、性能和成本几个方面，各有利弊，你需要根据业务情况，做一个最优的选择，并且为可能存在的风险做好准备。

思考题

课后也请你在留言区分享一下，你现在负责系统的数据库是如何来实现高可用的，有什么风险和问题，学习了这节课之后，你会如何来改进这个高可用方案？欢迎你在留言区与我讨论。

感谢阅读，如果你觉得今天的内容对你有帮助，也欢迎把它分享给你的朋友。

08 | 一个几乎每个系统必踩的坑儿：访问数据库超时

你好，我是李玥。

每一个创业公司，它的系统随着公司的发展一起成长的过程中，都难免会发生一些故障或者是事故，严重的会影响业务。搞技术的同学管这个叫：坑儿，分析解决问题的过程，称为：填坑儿。而访问数据库超时这个坑儿，是我见过的被踩的次数最多的一个坑儿，并且这个坑儿还在被不停地踩来踩去。

今天这节课，我和你分享一个典型的数据库超时案例。我也希望你通过和我一起分析这个案例，一是，吸取其中的经验教训，日后不要再踩类似的坑儿；二是，如果遇到类似的问题，你能掌握分析方法，快速地解决问题。最重要的是，学习存储系统架构设计思想，在架构层面限制故障对系统的破坏程度。

事故排查过程

我们一起来看一下这个案例。

每一个做电商的公司都梦想着做社交引流，每一个做社交的公司都梦想着做电商将流量变现。我的一个朋友他们公司做社交电商，当年很有前途的一个创业方向，当时也是有很多创业公司在做。

有一天他找到我，让我帮他分析一下他们系统的问题。这个系统从圣诞节那天晚上开始，每天晚上固定十点多到十一点多这个时段，大概瘫痪一个小时左右的时间，过了这个时段系统自动就恢复了。系统瘫痪时的现象就是，网页和 App 都打不开，请求超时。

这个系统的架构是一个非常典型的小型创业公司的微服务架构。系统的架构如下图：

整个系统托管在公有云上，Nginx 作为前置网关承接前端所有请求，后端按照业务，划分了若干个微服务分别部署。数据保存在 MySQL 中，部分数据用 Memcached 做了前置缓存。数据并没有按照微服务最佳实践的要求，做严格的划分和隔离，而是为了方便，存放在了一起。

这样的存储设计，对于一个业务变化极快的创业公司来说，是合理的。因为它的每个微服务，随时都在随着业务改变，如果做了严格的数据隔离，反而不利于应对需求变化。

听了我朋友对问题的描述，我的一反应是，每天晚上十点到十一点这个时段，是绝大多数内容类 App 的访问量高峰，因为这个时候大家都躺在床上玩儿手机。初步判断，这个故障是和访问量有关系的，看下面这个系统每天的访问量的图，可以印证这个判断。

基于这个判断，排查问题的重点应该放在那些服务于用户访问的功能上。比如说，首页、商品列表页、内容推荐这些功能。

在访问量峰值的时候，请求全部超时，随着访问量减少，系统能自动恢复，基本可以排除后台服务被大量请求打死的可能性，因为如果进程被打死了，一般是不会自动恢复的。排查问题的重点应该放在 MySQL 上。观察下面这个 MySQL 的 CPU 利用率图，发现问题：

从监控图上可以看出来，故障时段 MySQL 的 CPU 利用率一直是 100%。这种情况下，MySQL 基本上处于一个不可用的状态，执行所有的 SQL 都会超时。

MySQL 这种 CPU 利用率高的现象，绝大多数情况都是由慢 SQL 导致的，所以我们优先排查慢 SQL。MySQL 和各大云厂商提供的 RDS 都能提供慢 SQL 日志，分析慢 SQL 日志，是查找类似问题原因最有效的方法。

一般来说，慢 SQL 的日志中，会有这样一些信息：SQL、执行次数、执行时长。通过分析慢 SQL 找问题，并没有什么标准的方法，主要还是依靠经验。

首先，你需要知道的一点是，当数据库非常忙的时候，它执行任何一个 SQL 都很慢。所以，并不是说，慢 SQL 日志中记录的这些慢 SQL 都是有问题的 SQL。大部分情况下，导致问题的 SQL 只是其中的一条或者几条。不能简单地依据执行次数和执行时长进行判断，但是，单次执行时间特别长的 SQL，仍然是应该重点排查的对象。

通过分析这个系统的慢 SQL 日志，首先找到了一个特别慢的 SQL。

这个 SQL 支撑的功能是一个红人排行榜，这个排行榜列出粉丝数最多的 TOP10 红人。

select fo.FollowId as vid, count(fo.id) as vcounts
from follow fo, user_info ui
where fo.userid = ui.userid
and fo.CreateTime between
str_to_date(?, '%Y-%m-%d %H:%i:%s')
and str_to_date(?, '%Y-%m-%d %H:%i:%s')
and fo.IsDel = 0
and ui.UserState = 0
group by vid
order by vcounts desc
limit 0,10

这种排行榜的查询，一定要做缓存。在这个案例中，排行榜是新上线的功能，可能忘记做缓存了，通过增加缓存可以有效地解决问题。

给排行榜增加了缓存后，新版本立即上线。本以为问题就此解决了，结果当天晚上，系统仍然是一样的现象，晚高峰各种请求超时，页面打不开。

再次分析慢 SQL 日志，排行榜的慢 SQL 不见了，说明缓存生效了。日志中的其他慢 SQL，查询次数和查询时长分布的都很均匀，也没有看出明显写的有问题的 SQL。

回过头来再看 MySQL CPU 利用率这个图。

把这个图放大后，发现一些规律：

CPU 利用率，以 20 分钟为周期，非常规律的波动；

总体的趋势与访问量正相关。

那我们是不是可以猜测一下，对 MySQL 的 CPU 利用率的“贡献”来自两部分：红线以下的部分，是正常处理日常访问请求的部分，它和访问量是正相关的。红线以上的部分，来自某一个以 20 分钟为周期的定时任务，和访问量关系不大。

排查整个系统，没有发现有以 20 分钟为周期的定时任务，继续扩大排查范围，排查周期小于 20 分钟的定时任务，最终定位了问题。

App 的首页聚合了非常多的内容，像精选商品、标题图、排行榜、编辑推荐等等。这些内容包含了很多的数据库查询。当初设计的时候，给首页做了一个整体的缓存，缓存的过期时间是 10 分钟。但是需求不断变化，首页需要查询的内容越来越多，导致查询首页的全部内容越来越慢。

通过检查日志发现，刷新一次缓存的时间竟然要 15 分钟。缓存是每隔 10 分钟整点刷一次，因为 10 分钟内刷不完，所以下次刷新就推迟到了 20 分钟之后，这就导致了上面这个图中，红线以上每 20 分钟的规律波形。

由于缓存刷新慢，也会很多请求无法命中缓存，请求直接穿透缓存打到了数据库上面，这部分请求给上图红线以下的部分，做了很多“贡献”。

找到了问题原因，做针对性的优化，问题很快就解决了。新版本上线之后，再没有出现过“午夜宕机”。

对比优化前后 MySQL 的 CPU 利用率，可以明显地看出优化效果。

如何避免悲剧重演

到这里问题的原因找到了，问题也圆满解决了。单从这个案例来看，问题的原因在于，开发人员犯了错误，编写的 SQL 没有考虑数据量和执行时间，缓存的使用也不合理。最终导致在忙时，大量的查询打到 MySQL 上，MySQL 繁忙无法提供服务。

作为系统的开发人员，对于这次事故，我们可以总结两点经验：

第一，在编写 SQL 的时候，一定要小心谨慎地仔细评估。先问自己几个问题：

你的 SQL 涉及到的表，它的数据规模是多少？

你的 SQL 可能会遍历的数据量是多少？

尽量地避免写出慢 SQL。

第二，能不能利用缓存减少数据库查询次数？在使用缓存的时候，还需要特别注意的就是缓存命中率，要尽量避免请求命中不了缓存，穿透到数据库上。

以上两点，是开发人员需要总结的问题。不过你想没想过，谁能保证，整个团队的所有开发人员以后不再犯错误？保证不了吧？那是不是这种的悲剧就无法避免了呢？

其实，还是有办法的。不然，那些大厂，几万开发人员，每天会上线无数的 Bug，系统还不得天天宕机？而实际情况是，大厂的系统都是比较稳定的，基本上不会出现全站无法访问这种情况。

靠的是什么？靠的是架构。

优秀的系统架构，可以在一定程度上，减轻故障对系统的影响。针对这次事故，我给这个系统在架构层面，提了两个改进的建议。

第一个建议是，上线一个定时监控和杀掉慢 SQL 的脚本。这个脚本每分钟执行一次，检测上一分钟内，有没有执行时间超过一分钟（这个阈值可以根据实际情况调整）的慢 SQL，如果发现，直接杀掉这个会话。

这样可以有效地避免一个慢 SQL 拖垮整个数据库的悲剧。即使出现慢 SQL，数据库也可以在至多 1 分钟内自动恢复，避免数据库长时间不可用。代价是，可能会有些功能，之前运行是正常的，这个脚本上线后，就会出现问题。但是，这个代价还是值得付出的，并且，可以反过来督促开发人员更加小心，避免写出慢 SQL。

第二个建议是，做一个简单的静态页面的首页作为降级方案，只要包含商品搜索栏、大的品类和其他顶级功能模块入口的链接就可以了。在 Nginx 上做一个策略，如果请求首页数据超时的时候，直接返回这个静态的首页作为替代。这样后续即使首页再出现任何的故障，也可以暂时降级，用静态首页替代。至少不会影响到用户使用其他功能。

这两个改进建议都是非常容易实施的，不需要对系统做很大的改造，并且效果也立竿见影。

当然，这个系统的存储架构还有很多可以改进的地方，比如说对数据做适当的隔离，改进缓存置换策略，做数据库主从分离，把非业务请求的数据库查询迁移到单独的从库上等等，只是这些改进都需要对系统做比较大的改动升级，需要从长计议，在系统后续的迭代过程中逐步地去实施。

小结

这节课，我和你一起分析了一个由于慢 SQL 导致的全站故障的案例。在“破案”的过程中，有一些很有用的经验，这些经验对于后续你自己“破案”时会非常有用。比如说：

根据故障时段在系统忙时，推断出故障是跟支持用户访问的功能有关。

根据系统能在流量峰值过后自动恢复这一现象，排除后台服务被大量请求打死的可能性。

根据 CPU 利用率曲线的规律变化，推断出可能和定时任务有关。

在故障复盘阶段，除了对故障问题本身做有针对性的预防和改进以外，更重要的是，在系统架构层面进行改进，让整个系统更加健壮，不至于因为某一个小的失误，就导致全站无法访问。

我给系统提出的第一个自动杀慢 SQL 的建议，它的思想是：系统的关键部分要有自我保护机制，避免外部的错误影响到系统的关键部分。第二个首页降级的建议，它的思想是：当关键系统出现故障的时候，要有临时的降级方案，尽量减少故障带来的影响。

这些架构上的改进，虽然并不能避免故障，但是可以很大程度上减小故障的影响范围，减轻故障带来的损失，希望你能仔细体会，活学活用。

思考题

课后请你想一下，以你个人的标准，什么样的 SQL 算是慢 SQL？如何才能避免写出慢 SQL？欢迎你在留言区与我交流互动。

感谢你的阅读，如果你觉得今天的内容对工作有所帮助，也欢迎把它分享给你的朋友。

09 | 怎么能避免写出慢SQL？

你好，我是李玥。

通过上节课的案例，我们知道，一个慢 SQL 就可以直接让 MySQL 瘫痪。今天这节课，我们一起看一下，怎么才能避免写出危害数据库的慢 SQL。

所谓慢 SQL，就是执行特别慢的 SQL 语句。什么样的 SQL 语句是慢 SQL？多慢才算是慢 SQL？并没有一个非常明确的标准或者说是界限。但并不是说，我们就很难区分正常的 SQL 和慢 SQL，在大多数实际的系统中，慢 SQL 消耗掉的数据库资源，往往是正常 SQL 的几倍、几十倍甚至几百倍，所以还是非常容易区分的。

但问题是，我们不能等着系统上线，慢 SQL 吃光数据库资源之后，再找出慢 SQL 来改进，那样就晚了。那么，怎样才能在开发阶段尽量避免写出慢 SQL 呢？

定量认识 MySQL

我们回顾一下上节课的案例，那个系统第一次全站宕机发生在圣诞节平安夜，故障之前的一段时间，系统并没有更新过版本，这个时候，其实慢 SQL 已经存在了，直到平安夜那天，访问量的峰值比平时增加一些，正是增加的这部分访问量，引发了数据库的雪崩。

这说明，慢 SQL 对数据库的影响，是一个量变到质变的过程，对“量”的把握，就很重要。作为一个合格的程序员，你需要对数据库的能力，有一个定量的认识。

影响 MySQL 处理能力的因素很多，比如：服务器的配置、数据库中的数据量大小、MySQL 的一些参数配置、数据库的繁忙程度等等。但是，通常情况下，这些因素对于 MySQL 性能和处理能力影响范围，大概在几倍的性能差距。所以，我们不需要精确的性能数据，只要掌握一个大致的量级，就足够指导我们的开发工作了。

一台 MySQL 数据库，大致处理能力的极限是，每秒一万条左右的简单 SQL，这里的“简单 SQL”，指的是类似于主键查询这种不需要遍历很多条记录的 SQL。根据服务器的配置高低，可能低端的服务器只能达到每秒几千条，高端的服务器可以达到每秒钟几万条，所以这里给出的一万 TPS 是中位数的经验值。考虑到正常的系统不可能只有简单 SQL，所以实际的 TPS 还要打很多折扣。

我的经验数据，一般一台 MySQL 服务器，平均每秒钟执行的 SQL 数量在几百左右，就已经是非常繁忙了，即使看起来 CPU 利用率和磁盘繁忙程度没那么高，你也需要考虑给数据库“减负”了。

另外一个重要的定量指标是，到底多慢的 SQL 才算慢 SQL。这里面这个“慢”，衡量的单位本来是执行时长，但是时长这个东西，我们在编写 SQL 的时候并不好去衡量。那我们可以用执行 SQL 查询时，需要遍历的数据行数替代时间作为衡量标准，因为查询的执行时长基本上是和遍历的数据行数正相关的。

你在编写一条查询语句的时候，可以依据你要查询数据表的数据总量，估算一下这条查询大致需要遍历多少行数据。如果遍历行数在百万以内的，只要不是每秒钟都要执行几十上百次的频繁查询，可以认为是安全的。遍历数据行数在几百万的，查询时间最少也要几秒钟，你就要仔细考虑有没有优化的办法。遍历行数达到千万量级和以上的，我只能告诉你，这种查询就不应该出现在你的系统中。当然我们这里说的都是在线交易系统，离线分析类系统另说。

遍历行数在千万左右，是 MySQL 查询的一个坎儿。MySQL 中单个表数据量，也要尽量控制在一千万条以下，最多不要超过二三千万这个量级。原因也很好理解，对一个千万级别的表执行查询，加上几个 WHERE 条件过滤一下，符合条件的数据最多可能在几十万或者百万量级，这还可以接受。但如果再和其他的表做一个联合查询，遍历的数据量很可能就超过千万级别了。所以，每个表的数据量最好小于千万级别。

如果数据库中的数据量就是很多，而且查询业务逻辑就需要遍历大量数据怎么办？

使用索引避免全表扫描

使用索引可以有效地减少执行查询时遍历数据的行数，提高查询性能。

数据库索引的原理也很简单，我举个例子你就明白了。比如说，有一个无序的数组，数组的每个元素都是一个用户对象。如果说我们要把所有姓李的用户找出来。比较笨的办法是，用一个循环把数组遍历一遍。

有没有更好的办法？很多办法是吧？比如说，我们用一个 Map(在有些编程语言中是 Dictionary) 来给数组做一个索引，Key 保存姓氏，值是所有这个姓氏的用户对象在数组中序号的集合。这样再查找的时候，就不用去遍历数组，先在 Map 中查找，然后再直接用序号去数组中拿用户数据，这样查找速度就快多了。

这个例子对应到数据库中，存放用户数据的数组就是表，我们构建的 Map 就是索引。实际上数据库的索引，和编程语言中的 Map 或者 Dictionary，它们的数据结构都是差不多的，基本上就是各种 B 树和 HASH 表。

绝大多数情况下，我们编写的查询语句，都应该使用索引，避免去遍历整张表，也就是通常说的，避免全表扫描。你在每次开发新功能，需要给数据库增加一个新的查询时，都要评估一下，是不是有索引可以支撑新的查询语句，如果有必要的话，需要新建索引来支持新增的查询。

但是，增加索引付出的代价是，会降低数据插入、删除和更新的性能。这个也很好理解，增加了索引，在数据变化的时候，不仅要变更数据表里的数据，还要去变更每个索引。所以，对于更新频繁并且对更新性能要求较高的表，可以尽量少建索引。而对于查询较多更新较少的表，可以根据查询的业务逻辑，适当多建一些索引。

怎么写 SQL 能更好地使用索引，查询效率更高，这是一门手艺，需要丰富的经验，不是通过一节课的学习能练成的。但是，我们是有方法，可以评估写出来的 SQL 的查询性能怎么样，是不是一个潜在的“慢 SQL”。

逻辑不是很复杂的单表查询，我们可能还可以分析出来，查询会使用哪个索引。但如果是比较复杂的多表联合查询，我们单看 SQL 语句本身，就很难分析出查询到底会命中哪些索引，会遍历多少行数据。MySQL 和大部分数据库，都提供一个帮助我们分析查询功能：执行计划。

分析 SQL 执行计划

在 MySQL 中使用执行计划也非常简单，只要在你的 SQL 语句前面加上 EXPLAIN 关键字，然后执行这个查询语句就可以了。

举个例子说明，比如有一个用户表，包含用户 ID、姓名、部门编号和状态这几个字段：

我们希望查询某个二级部门下的所有人，查询条件就是，部门代号以 00028 开头的所有人。下面这两个 SQL，他们的查询结果是一样的，都满足要求，但是，哪个查询性能更好呢？

SELECT * FROM user WHERE left(department_code, 5) = '00028';
SELECT * FROM user WHERE department_code LIKE '00028%';

我们分别查看一下这两个 SQL 的执行计划：

我带你一起来分析一下这两个 SQL 的执行计划。首先来看 rows 这一列，rows 的含义就是，MySQL 预估执行这个 SQL 可能会遍历的数据行数。第一个 SQL 遍历了四千多行，这就是整个 User 表的数据条数；第二个 SQL 只有 8 行，这 8 行其实就是符合条件的 8 条记录。显然第二个 SQL 查询性能要远远好于第一个 SQL。

为什么第一个 SQL 需要全表扫描，第二个 SQL 只遍历了很少的行数呢？注意看 type 这一列，这一列表示这个查询的访问类型。ALL 代表全表扫描，这是最差的情况。range 代表使用了索引，在索引中进行范围查找，因为 SQL 语句的 WHERE 中有一个 LIKE 的查询条件。如果直接命中索引，type 这一列显示的是 index。如果使用了索引，可以在 key 这一列中看到，实际上使用了哪个索引。

通过对比这两个 SQL 的执行计划，就可以看出来，第二个 SQL 虽然使用了普遍认为低效的 LIKE 查询条件，但是仍然可以用到索引的范围查找，遍历数据的行数远远少于第一个 SQL，查询性能更好。

小结

在开发阶段，衡量一个 SQL 查询语句查询性能的手段是，估计执行 SQL 时需要遍历的数据行数。遍历行数在百万以内，可以认为是安全的 SQL，百万到千万这个量级则需要仔细评估和优化，千万级别以上则是非常危险的。为了减少慢 SQL 的可能性，每个数据表的行数最好控制在千万以内。

索引可以显著减少查询遍历数据的数量，所以提升 SQL 查询性能最有效的方式就是，让查询尽可能多的命中索引，但索引也是一把双刃剑，它在提升查询性能的同时，也会降低数据更新的性能。

对于复杂的查询，最好使用 SQL 执行计划，事先对查询做一个分析。在 SQL 执行计划的结果中，可以看到查询预估的遍历行数，命中了哪些索引。执行计划也可以很好地帮助你优化你的查询语句。

思考题

课后请你想一下，在讲解 SQL 执行计划那个例子中的第一个 SQL，为什么没有使用索引呢？

SELECT * FROM user WHERE left(department_code, 5) = '00028';

欢迎你在留言区与我讨论，如果你觉得今天学到的知识对你有帮助，也欢迎把它分享给你的朋友。

10 | 走进黑盒：SQL是如何在数据库中执行的？

你好，我是李玥。

上一节课我们讲了怎么来避免写出慢 SQL，课后我给你留了一道思考题：在下面这两个 SQL 中，为什么第一个 SQL 在执行的时候无法命中索引呢？

SELECT * FROM user WHERE left(department_code, 5) = '00028';
SELECT * FROM user WHERE department_code LIKE '00028%';

原因是，这个 SQL 的 WHERE 条件中对 department_code 这个列做了一个 left 截取的计算，对于表中的每一条数据，都得先做截取计算，然后判断截取后的值，所以不得不做全表扫描。你在写 SQL 的时候，尽量不要在 WEHER 条件中，对列做任何计算。

到这里这个问题就结束了么？那我再给你提一个问题，这两个 SQL 中的 WHERE 条件，虽然写法不一样，但它俩的语义不就是一样的么？是不是都可以解释成：department_code 这一列前 5 个字符是 00028？从语义上来说，没有任何不同是吧？所以，它们的查询结果也是完全一样的。那凭什么第一条 SQL 就得全表扫描，第二条 SQL 就可以命中索引？

对于我们日常编写 SQL 的一些优化方法，比如说我刚刚讲的：“尽量不要在 WEHER 条件中，对列做计算”，很多同学只是知道这些方法，但是却不知道，为什么按照这些方法写出来的 SQL 就快？

要回答这些问题，需要了解一些数据库的实现原理。对很多开发者来说，数据库就是个黑盒子，你会写 SQL，会用数据库，但不知道盒子里面到底是怎么一回事儿，这样你只能机械地去记住别人告诉你的那些优化规则，却不知道为什么要遵循这些规则，也就谈不上灵活运用。

今天这节课，我带你一起打开盒子看一看，SQL 是如何在数据库中执行的。

数据库是一个非常非常复杂的软件系统，我会尽量忽略复杂的细节，用简单的方式把最主要的原理讲给你。即使这样，这节课的内容仍然会非常的硬核，你要有所准备。

数据库的服务端，可以划分为执行器 (Execution Engine) 和 存储引擎 (Storage Engine) 两部分。执行器负责解析 SQL 执行查询，存储引擎负责保存数据。

SQL 是如何在执行器中执行的？

我们通过一个例子来看一下，执行器是如何来解析执行一条 SQL 的。

SELECT u.id AS user_id, u.name AS user_name, o.id AS order_id
FROM users u INNER JOIN orders o ON u.id = o.user_id
WHERE u.id > 50

这个 SQL 语义是，查询用户 ID 大于 50 的用户的所有订单，这是很简单的一个联查，需要查询 users 和 orders 两张表，WHERE 条件就是，用户 ID 大于 50。

数据库收到查询请求后，需要先解析 SQL 语句，把这一串文本解析成便于程序处理的结构化数据，这就是一个通用的语法解析过程。跟编程语言的编译器编译时，解析源代码的过程是完全一样的。如果是计算机专业的同学，你上过的《编译原理》这门课，其中很大的篇幅是在讲解这一块儿。没学过《编译原理》的同学也不用担心，你暂时先不用搞清楚，SQL 文本是怎么转换成结构化数据的，不妨碍你学习和理解这节课下面的内容。

转换后的结构化数据，就是一棵树，这个树的名字叫抽象语法树（AST，Abstract Syntax Tree）。上面这个 SQL，它的 AST 大概是这样的：

这个树太复杂，我只画了主要的部分，你大致看一下，能理解这个 SQL 的语法树长什么样就行了。执行器解析这个 AST 之后，会生成一个逻辑执行计划。所谓的执行计划，可以简单理解为如何一步一步地执行查询和计算，最终得到执行结果的一个分步骤的计划。这个逻辑执行计划是这样的：

LogicalProject(user_id=[$0], user_name=[$1], order_id=[$5])
    LogicalFilter(condition=[$0 > 50])
        LogicalJoin(condition=[$0 == $6], joinType=[inner])
            LogicalTableScan(table=[users])
            LogicalTableScan(table=[orders])

和 SQL、AST 不同的是，这个逻辑执行计划已经很像可以执行的程序代码了。你看上面这个执行计划，很像我们编程语言的函数调用栈，外层的方法调用内层的方法。所以，要理解这个执行计划，得从内往外看。

最内层的 2 个 LogicalTableScan 的含义是，把 USERS 和 ORDERS 这两个表的数据都读出来。

然后拿这两个表所有数据做一个 LogicalJoin，JOIN 的条件就是第 0 列 (u.id) 等于第 6 列 (o.user_id)。

然后再执行一个 LogicalFilter 过滤器，过滤条件是第 0 列 (u.id) 大于 5。

最后，做一个 LogicalProject 投影，只保留第 0(user_id)、1(user_name)、5(order_id) 三列。这里“投影 (Project)”的意思是，把不需要的列过滤掉。

把这个逻辑执行计划翻译成代码，然后按照顺序执行，就可以正确地查询出数据了。但是，按照上面那个执行计划，需要执行 2 个全表扫描，然后再把 2 个表的所有数据做一个 JOIN 操作，这个性能是非常非常差的。

我们可以简单算一下，如果，user 表有 1,000 条数据，订单表里面有 10,000 条数据，这个 JOIN 操作需要遍历的行数就是 1,000 x 10,000 = 10,000,000 行。可见，这种从 SQL 的 AST 直译过来的逻辑执行计划，一般性能都非常差，所以，需要对执行计划进行优化。

如何对执行计划进行优化，不同的数据库有不同的优化方法，这一块儿也是不同数据库性能有差距的主要原因之一。优化的总体思路是，在执行计划中，尽早地减少必须处理的数据量。也就是说，尽量在执行计划的最内层减少需要处理的数据量。看一下简单优化后的逻辑执行计划：

LogicalProject(user_id=[$0], user_name=[$1], order_id=[$5])
    LogicalJoin(condition=[$0 == $6], joinType=[inner])
        LogicalProject(id=[$0], name=[$1])              // 尽早执行投影
            LogicalFilter(condition=[$0 > 50])          // 尽早执行过滤
                LogicalTableScan(table=[users])
        LogicalProject(id=[$0], user_id=[$1])           // 尽早执行投影
            LogicalTableScan(table=[orders])

对比原始的逻辑执行计划，这里我们做了两点简单的优化：

尽早地执行投影，去除不需要的列；

尽早地执行数据过滤，去除不需要的行。

这样，就可以在做 JOIN 之前，把需要 JOIN 的数据尽量减少。这个优化后的执行计划，显然会比原始的执行计划快很多。

到这里，执行器只是在逻辑层面分析 SQL，优化查询的执行逻辑，我们执行计划中操作的数据，仍然是表、行和列。在数据库中，表、行、列都是逻辑概念，所以，这个执行计划叫“逻辑执行计划”。执行查询接下来的部分，就需要涉及到数据库的物理存储结构了。

SQL 是如何在存储引擎中执行的？

数据真正存储的时候，无论在磁盘里，还是在内存中，都没法直接存储这种带有行列的二维表。数据库中的二维表，实际上是怎么存储的呢？这就是存储引擎负责解决的问题，存储引擎主要功能就是把逻辑的表行列，用合适的物理存储结构保存到文件中。不同的数据库，它们的物理存储结构是完全不一样的，这也是各种数据库之间巨大性能差距的根本原因。

我们还是以 MySQL 为例来说一下它的物理存储结构。MySQL 非常牛的一点是，它在设计层面对存储引擎做了抽象，它的存储引擎是可以替换的。它默认的存储引擎是 InnoDB，在 InnoDB 中，数据表的物理存储结构是以主键为关键字的 B+ 树，每一行数据直接就保存在 B+ 树的叶子节点上。比如，上面的订单表组织成 B+ 树，是这个样的：

这个树以订单表的主键 orders.id 为关键字组织，其中“62:[row data]”，表示的是订单号为 62 的一行订单数据。在 InnoDB 中，表的索引也是以 B+ 树的方式来存储的，和存储数据的 B+ 树的区别是，在索引树中，叶子节点保存的不是行数据，而是行的主键值。

如果通过索引来检索一条记录，需要先后查询索引树和数据树这两棵树：先在索引树中检索到行记录的主键值，然后再用主键值去数据树中去查找这一行数据。

简单了解了存储引擎的物理存储结构之后，我们回过头来继续看 SQL 是怎么在存储引擎中继续执行的。优化后的逻辑执行计划将会被转换成物理执行计划，物理执行计划是和数据的物理存储结构相关的。还是用 InnoDB 来举例，直接将逻辑执行计划转换为物理执行计划：

InnodbProject(user_id=[$0], user_name=[$1], order_id=[$5])
    InnodbJoin(condition=[$0 == $6], joinType=[inner])
        InnodbTreeNodesProject(id=[key], name=[data[1]])
            InnodbFilter(condition=[key > 50])
                InnodbTreeScanAll(tree=[users])
        InnodbTreeNodesProject(id=[key], user_id=[data[1]])
            InnodbTreeScanAll(tree=[orders])

物理执行计划同样可以根据数据的物理存储结构、是否存在索引以及数据多少等各种因素进行优化。这一块儿的优化规则同样是非常复杂的，比如，我们可以把对用户树的全树扫描再按照主键过滤这两个步骤，优化为对树的范围查找。

PhysicalProject(user_id=[$0], user_name=[$1], order_id=[$5])
    PhysicalJoin(condition=[$0 == $6], joinType=[inner])
        InnodbTreeNodesProject(id=[key], name=[data[1]])
            InnodbTreeRangeScan(tree=[users], range=[key > 50])  // 全树扫描再按照主键过滤，直接可以优化为对树的范围查找
        InnodbTreeNodesProject(id=[key], user_id=[data[1]])
            InnodbTreeScanAll(tree=[orders])

最终，按照优化后的物理执行计划，一步一步地去执行查找和计算，就可以得到 SQL 的查询结果了。

理解数据库执行 SQL 的过程，以及不同存储引擎中的数据和索引的物理存储结构，对于正确使用和优化 SQL 非常有帮助。

比如，我们知道了 InnoDB 的索引实现后，就很容易明白为什么主键不能太长，因为表的每个索引保存的都是主键的值，过长的主键会导致每一个索引都很大。再比如，我们了解了执行计划的优化过程后，就很容易理解，有的时候明明有索引却不能命中的原因是，数据库在对物理执行计划优化的时候，评估发现不走索引，直接全表扫描是更优的选择。

回头再来看一下这节课开头的那两条 SQL，为什么一个不能命中索引，一个能命中？原因是 InnoDB 对物理执行计划进行优化的时候，能识别 LIKE 这种过滤条件，转换为对索引树的范围查找。而对第一条 SQL 这种写法，优化规则就没那么“智能”了。

它并没有识别出来，这个条件同样可以转换为对索引树的范围查找，而走了全表扫描。并不是说第一个 SQL 写的不好，而是数据库还不够智能。那现实如此，我们能做的就是尽量了解数据库的脾气秉性，按照它现有能力，尽量写出它能优化好的 SQL。

小结

一条 SQL 在数据库中执行，首先 SQL 经过语法解析成 AST，然后 AST 转换为逻辑执行计划，逻辑执行计划经过优化后，转换为物理执行计划，再经过物理执行计划优化后，按照优化后的物理执行计划执行完成数据的查询。几乎所有的数据库，都是由执行器和存储引擎两部分组成，执行器负责执行计算，存储引擎负责保存数据。

掌握了查询的执行过程和数据库内部的组成，你才能理解那些优化 SQL 的规则，这些都有助于你更好理解数据库行为，更高效地去使用数据库。

最后需要说明的一点是，今天这节课所讲的内容，不只是适用于我们用来举例的 MySQL，几乎所有支持 SQL 的数据库，无论是传统的关系型数据库、还是 NoSQL、NewSQL 这些新兴的数据库，无论是单机数据库还是分布式数据库，比如 HBase、Elasticsearch 和 SparkSQL 等等这些数据库，它们的实现原理也都符合我们今天这节课所讲的内容。

思考题

课后请你选一种你熟悉的非关系型数据库，最好是支持 SQL 的，当然，不支持 SQL 有自己的查询语言也可以。比如说 HBase、Redis 或者 MongoDB 等等都可以，尝试分析一下查询的执行过程，对比一下它的执行器和存储引擎与 MySQL 有什么不同。

欢迎你在留言区与我讨论，如果你觉得今天的内容对你有帮助，也欢迎把它分享给你的朋友。

11 | MySQL如何应对高并发（一）：使用缓存保护MySQL

你好，我是李玥。

通过前面几节课的学习，相信你对 MySQL 这类关系型数据库的能力，已经有了定量的认知。

我们知道，大部分面向公众用户的互联网系统，它的并发请求数量是和在线用户数量正相关的，而 MySQL 能承担的并发读写的量是有上限的，当系统的在线用户超过几万到几十万这个量级的时候，单台 MySQL 就很难应付了。

绝大多数互联网系统，都使用 MySQL 加上 Redis 这对儿经典的组合来解决这个问题。Redis 作为 MySQL 的前置缓存，可以替 MySQL 挡住绝大部分查询请求，很大程度上缓解了 MySQL 并发请求的压力。

Redis 之所以能这么流行，非常重要的一个原因是，它的 API 非常简单，几乎没有太多的学习成本。但是，要想在生产系统中用好 Redis 和 MySQL 这对儿经典组合，并不是一件很简单的事儿。我在《08 | 一个几乎每个系统必踩的坑儿：访问数据库超时》举的社交电商数据库超时故障的案例，其中一个重要的原因就是，对缓存使用不当引发了缓存穿透，最终导致数据库被大量查询请求打死。

今天这节课，我们就来说一下，在电商的交易类系统中，如何正确地使用 Redis 这样的缓存系统，以及如何正确应对使用缓存过程中遇到的一些常见的问题。

更新缓存的最佳方式

要正确地使用好任何一个数据库，你都需要先了解它的能力和弱点，扬长避短。Redis 是一个使用内存保存数据的高性能 KV 数据库，它的高性能主要来自于：

简单的数据结构；

使用内存存储数据。

上节课我们讲到过，数据库可以分为执行器和存储引擎两部分，Redis 的执行器这一层非常的薄，所以 Redis 只能支持有限的几个 API，几乎没有聚合查询的能力，也不支持 SQL。它的存储引擎也非常简单，直接在内存中用最简单的数据结构来保存数据，你从它的 API 中的数据类型基本就可以猜出存储引擎中数据结构。

比如，Redis 的 LIST 在存储引擎的内存中的数据结构就是一个双向链表。内存是一种易失性存储，所以使用内存保存数据的 Redis 不能保证数据可靠存储。从设计上来说，Redis 牺牲了大部分功能，牺牲了数据可靠性，换取了高性能。但也正是这些特性，使得 Redis 特别适合用来做 MySQL 的前置缓存。

虽然说，Redis 支持将数据持久化到磁盘中，并且还支持主从复制，但你需要知道，Redis 仍然是一个不可靠的存储，它在设计上天然就不保证数据的可靠性，所以一般我们都使用 Redis 做缓存，很少使用它作为唯一的数据存储。

即使只是把 Redis 作为缓存来使用，我们在设计 Redis 缓存的时候，也必须要考虑 Redis 的这种“数据不可靠性”，或者换句话说，我们的程序在使用 Redis 的时候，要能兼容 Redis 丢数据的情况，做到即使 Redis 发生了丢数据的情况，也不影响系统的数据准确性。

我们仍然用电商的订单系统来作为例子说明一下，如何正确地使用 Redis 做缓存。在缓存 MySQL 的一张表的时候，通常直接选用主键来作为 Redis 中的 Key，比如缓存订单表，那就直接用订单表的主键订单号来作为 Redis 中的 key。

如果说，Redis 的实例不是给订单表专用的，还需要给订单的 Key 加一个统一的前缀，比如“orders:888888”。Value 用来保存序列化后的整条订单记录，你可以选择可读性比较好的 JSON 作为序列化方式，也可以选择性能更好并且更节省内存的二进制序列化方式，都是可以的。

然后我们来说，缓存中的数据要怎么来更新的问题。我见过很多同学都是这么用缓存的：在查询订单数据的时候，先去缓存中查询，如果命中缓存那就直接返回订单数据。如果没有命中，那就去数据库中查询，得到查询结果之后把订单数据写入缓存，然后返回。在更新订单数据的时候，先去更新数据库中的订单表，如果更新成功，再去更新缓存中的数据。

这其实是一种经典的缓存更新策略: Read/Write Through。这样使用缓存的方式有没有问题？绝大多数情况下可能都没问题。但是，在并发的情况下，有一定的概率会出现“脏数据”问题，缓存中的数据可能会被错误地更新成了旧数据。

比如，对同一条订单记录，同时产生了一个读请求和一个写请求，这两个请求被分配到两个不同的线程并行执行，读线程尝试读缓存没命中，去数据库读到了订单数据，这时候可能另外一个读线程抢先更新了缓存，在处理写请求的线程中，先后更新了数据和缓存，然后，拿着订单旧数据的第一个读线程又把缓存更新成了旧数据。

这是一种情况，还有比如两个线程对同一个条订单数据并发写，也有可能造成缓存中的“脏数据”，具体流程类似于我在之前“如何保证订单数据准确无误？”这节课中讲到的 ABA 问题。你不要觉得发生这种情况的概率比较小，出现“脏数据”的概率是和系统的数据量以及并发数量正相关的，当系统的数据量足够大并且并发足够多的情况下，这种脏数据几乎是必然会出现的。

我在“商品系统的存储该如何设计”这节课中，在讲解如何缓存商品数据的时候，曾经简单提到过缓存策略。其中提到的 Cache Aside 模式可以很好地解决这个问题，在大多数情况下是使用缓存的最佳方式。

Cache Aside 模式和上面的 Read/Write Through 模式非常像，它们处理读请求的逻辑是完全一样的，唯一的一个小差别就是，Cache Aside 模式在更新数据的时候，并不去尝试更新缓存，而是去删除缓存。

订单服务收到更新数据请求之后，先更新数据库，如果更新成功了，再尝试去删除缓存中订单，如果缓存中存在这条订单就删除它，如果不存在就什么都不做，然后返回更新成功。这条更新后的订单数据将在下次被访问的时候加载到缓存中。使用 Cache Aside 模式来更新缓存，可以非常有效地避免并发读写导致的脏数据问题。

注意缓存穿透引起雪崩

如果我们的缓存命中率比较低，就会出现大量“缓存穿透”的情况。缓存穿透指的是，在读数据的时候，没有命中缓存，请求“穿透”了缓存，直接访问后端数据库的情况。

少量的缓存穿透是正常的，我们需要预防的是，短时间内大量的请求无法命中缓存，请求穿透到数据库，导致数据库繁忙，请求超时。大量的请求超时还会引发更多的重试请求，更多的重试请求让数据库更加繁忙，这样恶性循环导致系统雪崩。

当系统初始化的时候，比如说系统升级重启或者是缓存刚上线，这个时候缓存是空的，如果大量的请求直接打过来，很容易引发大量缓存穿透导致雪崩。为了避免这种情况，可以采用灰度发布的方式，先接入少量请求，再逐步增加系统的请求数量，直到全部请求都切换完成。

如果系统不能采用灰度发布的方式，那就需要在系统启动的时候对缓存进行预热。所谓的缓存预热就是在系统初始化阶段，接收外部请求之前，先把最经常访问的数据填充到缓存里面，这样大量请求打过来的时候，就不会出现大量的缓存穿透了。

还有一种常见的缓存穿透引起雪崩的情况是，当发生缓存穿透时，如果从数据库中读取数据的时间比较长，也容易引起数据库雪崩。

这种情况我在《08 | 一个几乎每个系统必踩的坑儿：访问数据库超时》这节课中也曾经提到过。比如说，我们缓存的数据是一个复杂的数据库联查结果，如果在数据库执行这个查询需要 10 秒钟，那当缓存中这条数据过期之后，最少 10 秒内，缓存中都不会有数据。

如果这 10 秒内有大量的请求都需要读取这个缓存数据，这些请求都会穿透缓存，打到数据库上，这样很容易导致数据库繁忙，当请求量比较大的时候就会引起雪崩。

所以，如果说构建缓存数据需要的查询时间太长，或者并发量特别大的时候，Cache Aside 或者是 Read/Write Through 这两种缓存模式都可能出现大量缓存穿透。

对于这种情况，并没有一种方法能应对所有的场景，你需要针对业务场景来选择合适解决方案。比如说，可以牺牲缓存的时效性和利用率，缓存所有的数据，放弃 Read Through 策略所有的请求，只读缓存不读数据库，用后台线程来定时更新缓存数据。

小结

使用 Redis 作为 MySQL 的前置缓存，可以非常有效地提升系统的并发上限，降低请求响应时延。绝大多数情况下，使用 Cache Aside 模式来更新缓存都是最佳的选择，相比 Read/Write Through 模式更简单，还能大幅降低脏数据的可能性。

使用 Redis 的时候，还需要特别注意大量缓存穿透引起雪崩的问题，在系统初始化阶段，需要使用灰度发布或者其他方式来对缓存进行预热。如果说构建缓存数据需要的查询时间过长，或者并发量特别大，这两种情况下使用 Cache Aside 模式更新缓存，会出现大量缓存穿透，有可能会引发雪崩。

顺便说一句，我们今天这节课中讲到的这些缓存策略，都是非常经典的理论，早在互联网大规模应用之前，这些缓存策略就已经非常成熟了，在操作系统中，CPU Cache 的缓存、磁盘文件的内存缓存，它们也都应用了我们今天讲到的这些策略。

所以无论技术发展的多快，计算机的很多基础的理论的知识都是相通的，你绞尽脑汁想出的解决工程问题的方法，很可能早都写在几十年前出版的书里。学习算法、数据结构、设计模式等等这些基础的知识，并不只是为了应付面试。

思考题

课后请你想一下，具体什么情况下，使用 Cache Aside 模式更新缓存会产生脏数据？欢迎你在评论区留言，通过一个例子来说明情况。

感谢阅读，如果你觉得今天的内容对你有帮助，也欢迎把它分享给你的朋友。

12 | MySQL如何应对高并发（二）：读写分离

你好，我是李玥。

上节课我和你讲了，使用 Redis 作为 MySQL 的前置缓存，可以帮助 MySQL 挡住绝大部分的查询请求。这种方法对于像电商中的商品系统、搜索系统这类与用户关联不大的系统，效果特别的好。因为在这些系统中，每个人看到的内容都是一样的，也就是说，对后端服务来说，每个人的查询请求和返回的数据都是一样的。这种情况下，Redis 缓存的命中率非常高，近乎于全部的请求都可以命中缓存，相对的，几乎没有多少请求能穿透到 MySQL。

但是，和用户相关的系统，使用缓存的效果就没那么好了，比如说，订单系统、账户系统、购物车系统等等。在这些系统里面，每个用户需要查询的信息都是和用户相关的，即使是同一个功能界面，那每个人看到的数据都是不一样的。

比如说，“我的订单”这个功能，用户在这里看到的都是自己的订单数据，我打开我的订单缓存的数据，是不能给你打开你的订单来使用的，因为我们两个人的订单是不一样的。这种情况下，缓存的命中率就没有那么高，还是有相当一部分查询请求因为命中不了缓存，打到 MySQL 上。

那随着系统用户数量越来越多，打到 MySQL 上的读写请求也越来越多，当单台 MySQL 支撑不了这么多的并发请求时，我们该怎么办？

读写分离是提升 MySQL 并发的首选方案

当单台 MySQL 无法满足要求的时候，只能用多个 MySQL 实例来承担大量的读写请求。MySQL 和大部分常用的关系型数据库一样，都是典型的单机数据库，不支持分布式部署。用一个单机数据库的多个实例来组成一个集群，提供分布式数据库服务，是一个非常困难的事儿。

在部署集群的时候，需要做很多额外的工作，而且很难做到对应用透明，那你的应用程序也要为此做较大的架构调整。所以，除非系统规模真的大到只有这一条路可以走，不建议你对数据进行分片，自行构建 MySQL 集群，代价非常大。

一个简单而且非常有效的方案是，我们不对数据分片，而是使用多个具有相同数据的 MySQL 实例来分担大量的查询请求，这种方法通常称为“读写分离”。读写分离之所以能够解决问题，它实际上是基于一个对我们非常有利的客观情况，那就是，很多系统，特别是面对公众用户的互联网系统，对数据的读写比例是严重不均衡的。读写比一般都在几十左右，平均每发生几十次查询请求，才有一次更新请求。换句话来说，数据库需要应对的绝大部分请求都是只读查询请求。

一个分布式的存储系统，想要做分布式写是非常非常困难的，因为很难解决好数据一致性的问题。但实现分布式读就相对简单很多，我只需要增加一些只读的实例，只要能够把数据实时的同步到这些只读实例上，保证这这些只读实例上的数据都随时一样，这些只读的实例就可以分担大量的查询请求。

读写分离的另外一个好处是，它实施起来相对比较简单。把使用单机 MySQL 的系统升级为读写分离的多实例架构非常容易，一般不需要修改系统的业务逻辑，只需要简单修改 DAO 代码，把对数据库的读写请求分开，请求不同的 MySQL 实例就可以了。

通过读写分离这样一个简单的存储架构升级，就可以让数据库支持的并发数量增加几倍到十几倍。所以，当你的系统用户数越来越多，读写分离应该是你首先要考虑的扩容方案。

下图是一个典型的读写分离架构：

主库负责执行应用程序发来的所有数据更新请求，然后异步将数据变更实时同步到所有的从库中去，这样，主库和所有从库中的数据是完全一样的。多个从库共同分担应用的查询请求。

然后我们简单说一下，如何来实施 MySQL 的读写分离方案。你需要做两件事儿：

部署一主多从多个 MySQL 实例，并让它们之间保持数据实时同步。

分离应用程序对数据库的读写请求，分别发送给从库和主库。

MySQL 自带主从同步的功能，经过简单的配置就可以实现一个主库和几个从库之间的数据同步，部署和配置的方法，你看MySQL 的官方文档照着做就可以。分离应用程序的读写请求方法有下面这三种：

纯手工方式：修改应用程序的 DAO 层代码，定义读写两个数据源，指定每一个数据库请求的数据源。

组件方式：也可以使用像 Sharding-JDBC 这种集成在应用中的第三方组件来实现，这些组件集成在你的应用程序内，代理应用程序的所有数据库请求，自动把请求路由到对应数据库实例上。

代理方式：在应用程序和数据库实例之间部署一组数据库代理实例，比如说 Atlas 或者 MaxScale。对应用程序来说，数据库代理把自己伪装成一个单节点的 MySQL 实例，应用程序的所有数据库请求被发送给代理，代理分离读写请求，然后转发给对应的数据库实例。

这三种方式，我最推荐的是第二种，使用读写分离组件。这种方式代码侵入非常少，并且兼顾了性能和稳定性。如果你的应用程序是一个逻辑非常简单的微服务，简单到只有几个 SQL，或者是，你的应用程序使用的编程语言没有合适的读写分离组件，那你也可以考虑使用第一种纯手工的方式来实现读写分离。

一般情况下，不推荐使用第三种代理的方式，原因是，使用代理加长了你的系统运行时数据库请求的调用链路，有一定的性能损失，并且代理服务本身也可能出现故障和性能瓶颈等问题。但是，代理方式有一个好处是，它对应用程序是完全透明的。所以，只有在不方便修改应用程序代码这一种情况下，你才需要采用代理方式。

另外，如果你配置了多个从库，推荐你使用“HAProxy+Keepalived”这对儿经典的组合，来给所有的从节点做一个高可用负载均衡方案，既可以避免某个从节点宕机导致业务可用率降低，也方便你后续随时扩容从库的实例数量。因为 HAProxy 可以做 L4 层代理，也就是说它转发的是 TCP 请求，所以用“HAProxy+Keepalived”代理 MySQL 请求，在部署和配置上也没什么特殊的地方，正常配置和部署就可以了。

注意读写分离带来的数据不一致问题

读写分离的一个副作用是，可能会存在数据不一致的情况。原因是，数据库中的数据在主库完成更新后，是异步同步到每个从库上的，这个过程有一个微小的时间差，这个时间差叫主从同步延迟。正常情况下，主从延迟非常小，不超过 1ms。但即使这个非常小的延迟，也会导致在某一个时刻，主库和从库上的数据是不一致的。应用程序需要能接受并克服这种主从不一致的情况，否则就会引发一些由于主从延迟导致的数据错误。

还是拿订单系统来举例，我们自然的设计思路是，用户从购物车里发起结算创建订单，进入订单页，打开支付页面进行支付，支付完成后，按道理应该再返回支付之前的订单页。但如果这个时候马上自动返回订单页，就很可能会出现订单状态还是显示“未支付”。因为，支付完成后，订单库的主库中，订单状态已经被更新了，而订单页查询的从库中，这条订单记录的状态有可能还没更新。怎么解决？

这种问题其实没什么好的技术手段来解决，所以你看大的电商，它支付完成后是不会自动跳回到订单页的，它增加了一个无关紧要的“支付完成”页面，其实这个页面没有任何有效的信息，就是告诉你支付成功，然后再放一些广告什么的。你如果想再看刚刚支付完成的订单，需要手动点一下，这样就很好地规避了主从同步延迟的问题。

上面这个例子还只是订单状态显示错误，刷新一下就好了。我们需要特别注意的，是那些数据更新后，立刻需要查询更新后的数据，然后再更新其他数据这种情况。比如说在购物车页面，如果用户修改了某个商品的数量，需要重新计算优惠和总价。更新了购物车的数据后，需要立即调用计价服务，这个时候如果计价服务去读购物车的从库，非常可能读到旧数据而导致计算的总价错误。

对于这个例子，你可以把“更新购物车、重新计算总价”这两个步骤合并成一个微服务，然后放在一个数据库事务中去，同一个事务中的查询操作也会被路由到主库，这样来规避主从不一致的问题。

对于这种主从延迟带来的数据不一致的问题，没有什么简单方便而且通用的技术方案可以解决，我们需要重新设计业务逻辑，尽量规避更新数据后立即去从库查询刚刚更新的数据。

小结

随着系统的用户增长，当单个 MySQL 实例快要扛不住大量并发的时候，读写分离是首选的数据库扩容方案。读写分离的方案不需要对系统做太大的改动，就可以让系统支撑的并发提升几倍到十几倍。

推荐你使用集成在应用内的读写分离组件方式来分离数据库读写请求，如果很难修改应用程序，也可以使用代理的方式来分离数据库读写请求。如果你的方案中部署了多个从库，推荐你用“HAProxy+Keepalived”来做这些从库的负载均衡和高可用，这个方案的好处是简单稳定而且足够灵活，不需要增加额外的服务器部署，便于维护并且不增加故障点。

主从同步延迟会导致主库和从库之间出现数据不一致的情况，我们的应用程序应该能兼容主从延迟，避免因为主从延迟而导致的数据错误。规避这个问题最关键的一点是，我们在设计系统的业务流程时，尽量不要在更新数据之后立即去查询更新后的数据。

思考题

课后请你对照你现在负责开发或者维护的系统来分享一下，你的系统实施读写分离的具体方案是什么样的？比如，如何分离读写数据库请求？如何解决主从延迟带来的数据一致性问题？欢迎你在留言区与我讨论。

如果你觉得今天的内容对你有帮助，也欢迎把它分享给你的朋友。

13 | MySQL主从数据库同步是如何实现的？

你好，我是李玥。

回顾我们之前讲 MySQL 相关的几节课程，你会发现主从同步有多重要。解决数据可靠性的问题需要用到主从同步；解决 MySQL 服务高可用要用到主从同步；应对高并发的时候，还是要用到主从同步。

我们在运维 MySQL 集群时，遇到的很多常见的问题，比如说，为什么从节点故障会影响到主节点？为什么主从切换之后丢数据了？为什么明明没有更新数据，客户端读到的数据还是变来变去的？这些都和主从同步的配置有密切的关系。

你不但要理解 MySQL 主从同步的原理，还要掌握一些相关配置的含义，才能正确地配置你的集群，知道集群在什么情况下会有什么样的行为，可能会出现什么样的问题，并且知道该如何解决。

今天这节课我们就来详细讲一下，MySQL 的主从同步是怎么实现的，以及如何来正确地配置主从同步。

如何配置 MySQL 的主从同步？

当客户端提交一个事务到 MySQL 的集群，直到客户端收到集群返回成功响应，在这个过程中，MySQL 集群需要执行很多操作：主库需要提交事务、更新存储引擎中的数据、把 Binlog 写到磁盘上、给客户端返回响应、把 Binlog 复制到所有从库上、每个从库需要把复制过来的 Binlog 写到暂存日志中、回放这个 Binlog、更新存储引擎中的数据、给主库返回复制成功的响应。

这些操作的时序非常重要，这里面的“时序”，说的就是这些操作的先后顺序。同样的操作，因为时序不同，对应用程序来说，有很大的差异。比如说，如果先复制 Binlog，等 Binlog 复制到从节点上之后，主节点再去提交事务，这种情况下，从节点的 Binlog 一直和主节点是同步的，任何情况下主节点宕机也不会丢数据。但如果把这个时序倒过来，先提交事务再复制 Binlog，性能就会非常好，但是存在丢数据的风险。

MySQL 提供了几个参数来配置这个时序，我们先看一下默认情况下的时序是什么样的。

默认情况下，MySQL 采用异步复制的方式，执行事务操作的线程不会等复制 Binlog 的线程。具体的时序你可以看下面这个图：

MySQL 主库在收到客户端提交事务的请求之后，会先写入 Binlog，然后再提交事务，更新存储引擎中的数据，事务提交完成后，给客户端返回操作成功的响应。同时，从库会有一个专门的复制线程，从主库接收 Binlog，然后把 Binlog 写到一个中继日志里面，再给主库返回复制成功的响应。

从库还有另外一个回放 Binlog 的线程，去读中继日志，然后回放 Binlog 更新存储引擎中的数据，这个过程和我们今天讨论的主从复制关系不大，所以我并没有在图中画出来。提交事务和复制这两个流程在不同的线程中执行，互相不会等待，这是异步复制。

掌握了异步复制的时序之后，我们就很容易理解之前几节课中讲到的一些问题的原因了。比如说，在异步复制的情况下，为什么主库宕机存在丢数据的风险？为什么读写分离存在读到脏数据的问题？产生这些问题，都是因为异步复制它没有办法保证数据能第一时间复制到从库上。

与异步复制相对的就是同步复制。同步复制的时序和异步复制基本是一样的，唯一的区别是，什么时候给客户端返回响应。异步复制时，主库提交事务之后，就会给客户端返回响应；而同步复制时，主库在提交事务的时候，会等待数据复制到所有从库之后，再给客户端返回响应。

同步复制这种方式在实际项目中，基本上没法用，原因有两个：一是性能很差，因为要复制到所有节点才返回响应；二是可用性也很差，主库和所有从库任何一个数据库出问题，都会影响业务。

为了解决这个问题，MySQL 从 5.7 版本开始，增加一种半同步复制（Semisynchronous Replication）的方式。异步复制是，事务线程完全不等复制响应；同步复制是，事务线程要等待所有的复制响应；半同步复制介于二者之间，事务线程不用等着所有的复制成功响应，只要一部分复制响应回来之后，就可以给客户端返回了。

比如说，一主二从的集群，配置成半同步复制，只要数据成功复制到任意一个从库上，主库的事务线程就直接返回了。这种半同步复制的方式，它兼顾了异步复制和同步复制的优点。如果主库宕机，至少还有一个从库有最新的数据，不存在丢数据的风险。并且，半同步复制的性能也还凑合，也能提供高可用保证，从库宕机也不会影响主库提供服务。所以，半同步复制这种折中的复制方式，也是一种不错的选择。

接下来我跟你说一下，在实际应用过程中，选择半同步复制需要特别注意的几个问题。

配置半同步复制的时候，有一个重要的参数“rpl_semi_sync_master_wait_no_slave”，含义是：“至少等待数据复制到几个从节点再返回”。这个数量配置的越大，丢数据的风险越小，但是集群的性能和可用性就越差。最大可以配置成和从节点的数量一样，这样就变成了同步复制。

一般情况下，配成默认值 1 也就够了，这样性能损失最小，可用性也很高，只要还有一个从库活着，就不影响主库读写。丢数据的风险也不大，只有在恰好主库和那个有最新数据的从库一起坏掉的情况下，才有可能丢数据。

另外一个重要的参数是“rpl_semi_sync_master_wait_point”，这个参数控制主库执行事务的线程，是在提交事务之前（AFTER_SYNC）等待复制，还是在提交事务之后（AFTER_COMMIT）等待复制。默认是 AFTER_SYNC，也就是先等待复制，再提交事务，这样完全不会丢数据。AFTER_COMMIT 具有更好的性能，不会长时间锁表，但还是存在宕机丢数据的风险。

另外，虽然我们配置了同步或者半同步复制，并且要等待复制成功后再提交事务，还是有一种特别容易被忽略、可能存在丢数据风险的情况。

如果说，主库提交事务的线程等待复制的时间超时了，这种情况下事务仍然会被正常提交。并且，MySQL 会自动降级为异步复制模式，直到有足够多（rpl_semi_sync_master_wait_no_slave）的从库追上主库，才能恢复成半同步复制。如果这个期间主库宕机，仍然存在丢数据的风险。

复制状态机：所有分布式存储都是这么复制数据的

在 MySQL 中，无论是复制还是备份恢复，依赖的都是全量备份和 Binlog，全量备份相当于备份那一时刻的一个数据快照，Binlog 则记录了每次数据更新的变化，也就是操作日志。我们这节课讲主从同步，也就是数据复制，虽然讲的都是 MySQL，但是你要知道，这种基于“快照 + 操作日志”的方法，不是 MySQL 特有的。

比如说，Redis Cluster 中，它的全量备份称为 Snapshot，操作日志叫 backlog，它的主从复制方式几乎和 MySQL 是一模一样的。

我再给你举个例子，之前我们讲过的 Elasticsearch，它是一个内存数据库，读写都在内存中，那它是怎么保证数据可靠性的呢？对，它用的是 translog，它备份和恢复数据的原理和实现方式也是完全一样的。这些什么什么 log，都是不同的马甲儿而已，几乎所有的存储系统和数据库，都是用这一套方法来解决备份恢复和数据复制问题的。

既然这些存储系统他们实现数据复制的方法是完全一样的，那这几节课我们讲的 MySQL 主从复制时，讲到的那些问题、丢数据的风险，对于像 Redis Cluster、ES 或者其他分布式存储也都是一样存在的。那我们讲的，如何应对的方法、注意事项、最佳实践，这些也都是可以照搬的。

这一套方法其实是有理论基础的，叫做复制状态机 (Replication State Machine)，我能查到的最早的出处是 1978 年 Lamport 的一篇论文《The Implementation of Reliable Distributed Multiprocess Systems》。

1978 年啊，同学，那时候我们都还没出生呢！这么老的技术到今天仍然在被广泛地应用！无论应用技术发展的多快，实际上解决问题的方法，或者说是理论基础，一直是没什么变化的。所以，你在不断学习新的应用技术的同时，还需要多思考、总结和沉淀，这样会让你学习新技术的时候更快更轻松。

小结

最后，那为了便于你理解复制状态机，我们把这套方法再抽象总结一下。任何一个存储系统，无论它存储的是什么数据，用什么样的数据结构，都可以抽象成一个状态机。

存储系统中的数据称为状态（也就是 MySQL 中的数据），状态的全量备份称为快照（Snapshot），就像给数据拍个照片一样。我们按照顺序记录更新存储系统的每条操作命令，就是操作日志（Commit Log，也就是 MySQL 中的 Binlog）。你可以对照下面这张图来理解上面这些抽象的概念。

复制数据的时候，只要基于一个快照，按照顺序执行快照之后的所有操作日志，就可以得到一个完全一样的状态。在从节点持续地从主节点上复制操作日志并执行，就可以让从节点上的状态数据和主节点保持同步。

主从同步做数据复制时，一般可以采用几种复制策略。性能最好的方法是异步复制，主节点上先记录操作日志，再更新状态数据，然后异步把操作日志复制到所有从节点上，并在从节点执行操作日志，得到和主节点相同的状态数据。

异步复制的劣势是，可能存在主从延迟，如果主节点宕机，可能会丢数据。另外一种常用的策略是半同步复制，主节点等待操作日志最少成功复制到 N 个从节点上之后，再更新状态，这种方式在性能、高可用和数据可靠性几个方面都比较平衡，很多分布式存储系统默认采用的都是这种方式。

思考题

课后请你想一下，复制状态机除了用于数据库的备份和复制以外，在计算机技术领域，还有哪些地方也用到了复制状态机？欢迎你在留言区与我讨论。

感谢你的阅读，如果你觉得今天的内容对你有帮助，也欢迎把它分享给你的朋友。

14 | 订单数据越来越多，数据库越来越慢该怎么办？

你好，我是李玥。

在前面几节课，我们一起学习了在并发持续高速增长的情况下，如何来逐步升级存储。今天这节课我们来聊一聊，如何应对数据的持续增长，特别是像订单数据这种会随着时间一直累积的数据。

为什么数据量越大数据库就越慢？你得理解这里面的根本原因。

我们知道，无论是“增删改查”哪个操作，其实都是查找问题，因为你都得先找到数据才能对数据做操作。那存储系统性能问题，其实就是查找快慢的问题。

无论是什么样的存储系统，一次查询所耗费的时间，都取决于两个因素：

查找的时间复杂度；

数据总量。

这也是为什么大厂面试时总喜欢问“时间复杂度”相关问题的原因。查找的时间复杂度又取决于两个因素：

查找算法；

存储数据的数据结构。

你看，这两个知识点也是面试问题中的常客吧？所以人家面试官并不是非要问你一些用不上的问题来为难你，这些知识点真的不是用不上，而是你不知道怎么用。

我们把话题拉回来。对于我们大多数做业务的系统，用的都是现成的数据库，数据的存储结构和查找算法都是由数据库来实现的，业务系统基本没法去改变它。比如说，我们讲过 MySQL 的 InnoDB 存储引擎，它的存储结构是 B+ 树，查找算法大多就是树的查找，查找的时间复杂度就是 O(log n)，这些都是固定的。那我们唯一能改变的，就是数据总量了。

所以，解决海量数据导致存储系统慢的问题，思想非常简单，就是一个“拆”字，把一大坨数据拆分成 N 个小坨，学名叫“分片（Shard）”。拆开之后，每个分片里的数据就没那么多了，然后让查找尽量落在某一个分片上，这样来提升查找性能。

所有分布式存储系统解决海量数据查找问题都是遵循的这个思想，但是光有思想还不够，还需要落地，下面我们就来说如何拆分数据的问题。

存档历史订单数据提升查询性能

我们在开发业务系统的时候，很多数据都是具备时间属性的，并且随着系统运行，累计增长越来越多，数据量达到一定程度就会越来越慢，比如说电商中的订单数据，就是这种情况。按照我们刚刚说的思想，这个时候就需要拆分数据了。

我们的订单数据一般都是保存在 MySQL 中的订单表里面，说到拆分 MySQL 的表，大多数同学的第一反应都是“分库分表”，别着急，咱现在的数据量还没到非得分库分表那一步呢，下一节课我会和你讲分库分表。当单表的订单数据太多，多到影响性能的时候，首选的方案是，归档历史订单。

所谓归档，其实也是一种拆分数据的策略。简单地说，就是把大量的历史订单移到另外一张历史订单表中。为什么这么做呢？因为像订单这类具有时间属性的数据，都存在热尾效应。大多数情况下访问的都是最近的数据，但订单表里面大量的数据都是不怎么常用的老数据。

因为新数据只占数据总量中很少的一部分，所以把新老数据分开之后，新数据的数据量就会少很多，查询速度也就会快很多。老数据虽然和之前比起来没少多少，查询速度提升不明显，但是，因为老数据访很少会被访问到，所以慢一点儿也问题不大。

这样拆分的另外一个好处是，拆分订单时，需要改动的代码非常少。大部分对订单表的操作都是在订单完成之前，这些业务逻辑都是完全不用修改的。即使像退货退款这类订单完成后的操作，也是有时限的，那这些业务逻辑也不需要修改，原来该怎么操作订单表还怎么操作。

基本上只有查询统计类的功能，会查到历史订单，这些需要稍微做一些调整，按照时间，选择去订单表还是历史订单表查询就可以了。很多电商大厂在它逐步发展壮大的过程中，都用这种订单拆分的方案撑了好多年。你可能还有印象，几年前你在京东、淘宝查自己的订单时，都有一个查“三个月前订单”的选项，其实就是查订单历史表。

归档历史订单，大致的流程是这样的：

首先我们需要创建一个和订单表结构一模一样的历史订单表；

然后，把订单表中的历史订单数据分批查出来，插入到历史订单表中去。这个过程你怎么实现都可以，用存储过程、写个脚本或者写个导数据的小程序都行，用你最熟悉的方法就行。如果你的数据库已经做了主从分离，那最好是去从库查询订单，再写到主库的历史订单表中去，这样对主库的压力会小一点儿。

现在，订单表和历史订单表都有历史订单数据，先不要着急去删除订单表中的数据，你应该测试和上线支持历史订单表的新版本代码。因为两个表都有历史订单，所以现在这个数据库可以支持新旧两个版本的代码，如果新版本的代码有 Bug，你还可以立刻回滚到旧版本，不至于影响线上业务。

等新版本代码上线并验证无误之后，就可以删除订单表中的历史订单数据了。

最后，还需要上线一个迁移数据的程序或者脚本，定期把过期的订单从订单表搬到历史订单表中去。

类似于订单商品表这类订单的相关的子表，也是需要按照同样的方式归档到各自的历史表中，由于它们都是用订单 ID 作为外键来关联到订单主表的，随着订单主表中的订单一起归档就可以了。

这个过程中，我们要注意的问题是，要做到对线上业务的影响尽量的小。迁移这么大量的数据，或多或少都会影响数据库的性能，你应该尽量放在闲时去迁移，迁移之前一定做好备份，这样如果不小心误操作了，也能用备份来恢复。

如何批量删除大量数据？

这里面还有一个很重要的细节问题：如何从订单表中删除已经迁走的历史订单数据？我们直接执行一个删除历史订单的 SQL 行不行？像这样删除三个月前的订单：

delete from orders
where timestamp < SUBDATE(CURDATE(),INTERVAL 3 month);

大概率你会遇到错误，提示删除失败，因为需要删除的数据量太大了，所以需要分批删除。比如说我们每批删除 1000 条记录，那分批删除的 SQL 可以这样写：

delete from orders
where timestamp < SUBDATE(CURDATE(),INTERVAL 3 month)
order by id limit 1000;

执行删除语句的时候，最好在每次删除之间停顿一会儿，避免给数据库造成太大的压力。上面这个删除语句已经可以用了，反复执行这个 SQL，直到全部删除历史订单是可以完成删除任务的。但是这个 SQL 还是有优化空间的，这个 SQL 每执行一次，都要先去 timestamp 对应的索引上找出符合条件的记录，然后再把这些记录按照订单 ID 排序，之后删除前 1000 条记录。

其实每次都排序是没必要的，所以我们可以先通过一次查询，找到符合条件的历史订单中最大的那个订单 ID，然后在删除语句中把删除的条件转换成按主键删除。

select max(id) from orders
where timestamp < SUBDATE(CURDATE(),INTERVAL 3 month);

delete from orders
where id <= ?
order by id limit 1000;

这样每次删除的时候，由于条件变成了主键比较，我们知道在 MySQL 的 InnoDB 存储引擎中，表数据结构就是按照主键组织的一颗 B+ 树，而 B+ 树本身就是有序的，所以不仅查找非常快，也不需要再进行额外的排序操作了。当然这样做的前提条件是订单 ID 必须和订单时间正相关才行，大多数订单 ID 的生成规则都可以满足这个条件，所以问题不大。

然后我们再说一下，为什么在删除语句中非得加一个排序呢？因为按 ID 排序后，我们每批删除的记录，基本都是 ID 连续的一批记录，由于 B+ 树的有序性，这些 ID 相近的记录，在磁盘的物理文件上，大致也是放在一起的，这样删除效率会比较高，也便于 MySQL 回收页。

大量的历史订单数据删除完成之后，如果你检查一下 MySQL 占用的磁盘空间，你会发现它占用的磁盘空间并没有变小，这是什么原因呢？这也是和 InnoDB 的物理存储结构有关系。

虽然逻辑上每个表是一颗 B+ 树，但是物理上，每条记录都是存放在磁盘文件中的，这些记录通过一些位置指针来组织成一颗 B+ 树。当 MySQL 删除一条记录的时候，只能是找到记录所在的文件中位置，然后把文件的这块区域标记为空闲，然后再修改 B+ 树中相关的一些指针，完成删除。其实那条被删除的记录还是躺在那个文件的那个位置，所以并不会释放磁盘空间。

这么做也是没有办法的办法，因为文件就是一段连续的二进制字节，类似于数组，它不支持从文件中间删除一部分数据。如果非要这么删除，只能是把这个位置之后的所有数据往前挪，这样等于是要移动大量数据，非常非常慢。所以，删除的时候，只能是标记一下，并不真正删除，后续写入新数据的时候再重用这块儿空间。

理解了这个原理，你就很容易知道，不仅是 MySQL，很多其他的数据库都会有类似的问题。这个问题也没什么特别好的办法解决，磁盘空间足够的话，就这样吧，至少数据删了，查询速度也快了，基本上是达到了目的。

如果说我们数据库的磁盘空间很紧张，非要把这部分磁盘空间释放出来，可以执行一次 OPTIMIZE TABLE 释放存储空间。对于 InnoDB 来说，执行 OPTIMIZE TABLE 实际上就是把这个表重建一遍，执行过程中会一直锁表，也就是说这个时候下单都会被卡住，这个是需要注意的。另外，这么优化有个前提条件，MySQL 的配置必须是每个表独立一个表空间（innodb_file_per_table = ON），如果所有表都是放在一起的，执行 OPTIMIZE TABLE 也不会释放磁盘空间。

重建表的过程中，索引也会重建，这样表数据和索引数据都会更紧凑，不仅占用磁盘空间更小，查询效率也会有提升。那对于频繁插入删除大量数据的这种表，如果能接受锁表，定期执行 OPTIMIZE TABLE 是非常有必要的。

如果说，我们的系统可以接受暂时停服，最快的方法是这样的：直接新建一个临时订单表，然后把当前订单复制到临时订单表中，再把旧的订单表改名，最后把临时订单表的表名改成正式订单表。这样，相当于我们手工把订单表重建了一次，但是，不需要漫长的删除历史订单的过程了。我把执行过程的 SQL 放在下面供你参考：

-- 新建一个临时订单表
create table orders_temp like orders;

-- 把当前订单复制到临时订单表中
insert into orders_temp
  select * from orders
  where timestamp >= SUBDATE(CURDATE(),INTERVAL 3 month);

-- 修改替换表名
rename table orders to orders_to_be_droppd, orders_temp to orders;

-- 删除旧表
drop table orders_to_be_dropp

小结

对于订单这类具有时间属性的数据，会随时间累积，数据量越来越多，为了提升查询性能需要对数据进行拆分，首选的拆分方法是把旧数据归档到历史表中去。这种拆分方法能起到很好的效果，更重要的是对系统的改动小，升级成本低。

在迁移历史数据过程中，如果可以停服，最快的方式是重建一张新的订单表，然后把三个月内的订单数据复制到新订单表中，再通过修改表名让新的订单表生效。如果只能在线迁移，那需要分批迭代删除历史订单数据，删除的时候注意控制删除节奏，避免给线上数据库造成太大压力。

最后，我要再一次提醒你，线上数据操作非常危险，在操作之前一定要做好数据备份。

思考题

在数据持续增长的过程中，今天介绍的这种“归档历史订单”的数据拆分方法，和直接进行分库分表相比，比如说按照订单创建时间，自动拆分成每个月一张表，两种方法各有什么优点和缺点？欢迎你在留言区与我讨论。

感谢你的阅读，如果你觉得今天的内容对你有帮助，也欢迎把它分享给你的朋友。

15 | MySQL存储海量数据的最后一招：分库分表

你好，我是李玥。

从这节课开始，我们课程将进入最后一部分“海量数据篇”，这节课也是我们最后一节主要讲 MySQL 的课程。解决海量数据的问题，必须要用到分布式的存储集群，因为 MySQL 本质上是一个单机数据库，所以很多场景下不是太适合存 TB 级别以上的数据。

但是，绝大部分的电商大厂，它的在线交易这部分的业务，比如说，订单、支付相关的系统，还是舍弃不了 MySQL，原因是，只有 MySQL 这类关系型数据库，才能提供金融级的事务保证。我们之前也讲过分布式事务，那些新的分布式数据库提供的所谓的分布式事务，多少都有点儿残血，目前还达不到这些交易类系统对数据一致性的要求。

那既然 MySQL 支持不了这么大的数据量，这么高的并发，还必须要用它，怎么解决这个问题呢？还是按照我上节课跟你说的思想，分片，也就是拆分数据。1TB 的数据，一个库撑不住，我把它拆成 100 个库，每个库就只有 10GB 的数据了，这不就可以了么？这种拆分就是所谓的 MySQL 分库分表。

不过，思路是这样没错，分库分表实践起来是非常不容易的，有很多问题需要去思考和解决。

如何规划分库分表？

还是拿咱们的“老熟人”订单表来举例子。首先需要思考的问题是，分库还是分表？分库呢，就是把数据拆分到不同的 MySQL 库中去，分表就是把数据拆分到同一个库的多张表里面。

在考虑到底是分库还是分表之前，我们需要先明确一个原则，那就是能不拆就不拆，能少拆不多拆。原因也很简单，你把数据拆分得越散，开发和维护起来就越麻烦，系统出问题的概率就越大。

基于这个原则我们想一下，什么情况下适合分表，什么情况下不得不分库？

那我们分库分表的目的是为了解决两个问题：

第一，是数据量太大查询慢的问题。这里面我们讲的“查询”其实主要是事务中的查询和更新操作，因为只读的查询可以通过缓存和主从分离来解决，这个我们在之前的“MySQL 如何应对高并发”的两节课中都讲过。那我们上节课也讲到过，解决查询慢，只要减少每次查询的数据总量就可以了，也就是说，分表就可以解决问题。

第二，是为了应对高并发的问题。应对高并发的思想我们之前也说过，一个数据库实例撑不住，就把并发请求分散到多个实例中去，所以，解决高并发的问题是需要分库的。

简单地说，数据量大，就分表；并发高，就分库。

一般情况下，我们的方案都需要同时做分库分表，这时候分多少个库，多少张表，分别用预估的并发量和数据量来计算就可以了。

另外，我个人不建议你在方案中考虑二次扩容的问题，也就是考虑未来的数据量，把这次分库分表设计的容量都填满了之后，数据如何再次分裂的问题。

现在技术和业务变化这么快，等真正到了那个时候，业务早就变了，可能新的技术也出来了，你之前设计的二次扩容方案大概率是用不上的，所以没必要为了这个而增加方案的复杂程度。还是那句话，越简单的设计可靠性越高。

如何选择 Sharding Key？

分库分表还有一个重要的问题是，选择一个合适的列或者说是属性，作为分表的依据，这个属性一般称为 Sharding Key。像我们上节课讲到的归档历史订单的方法，它的 Sharding Key 就是订单完成时间。每次查询的时候，查询条件中必须带上这个时间，我们的程序就知道，三个月以前的数据查订单历史表，三个月内的数据查订单表，这就是一个简单的按照时间范围来分片的算法。

选择合适 Sharding Key 和分片算法非常重要，直接影响了分库分表的效果。我们首先来说如何选择 Sharding Key 的问题。

选择这个 Sharding Key 最重要的参考因素是，我们的业务是如何访问数据的。

比如我们把订单 ID 作为 Sharding Key 来拆分订单表，那拆分之后，如果我们按照订单 ID 来查订单，就需要先根据订单 ID 和分片算法计算出，我要查的这个订单它在哪个分片上，也就是哪个库哪张表中，然后再去那个分片执行查询就可以了。

但是，当我打开“我的订单”这个页面的时候，它的查询条件是用户 ID，这里没有订单 ID，那就没法知道我们要查的订单在哪个分片上，就没法查了。当然你要强行查的话，那就只能把所有分片都查一遍，再合并查询结果，这个就很麻烦，而且性能很差，还不能分页。

那要是把用户 ID 作为 Sharding Key 呢？也会面临同样的问题，使用订单 ID 作为查询条件来查订单的时候，就没办法找到订单在哪个分片了。这个问题的解决办法是，在生成订单 ID 的时候，把用户 ID 的后几位作为订单 ID 的一部分，比如说，可以规定，18 位订单号中，第 10-14 位是用户 ID 的后四位，这样按订单 ID 查询的时候，就可以根据订单 ID 中的用户 ID 找到分片。

那我们系统对订单的查询方式，肯定不只是按订单 ID 或者按用户 ID 这两种啊。比如说，商家希望看到的是自己店铺的订单，还有各种和订单相关的报表。对于这些查询需求，我们一旦对订单做了分库分表，就没法解决了。那怎么办呢？

一般的做法是，把订单数据同步到其他的存储系统中去，在其他的存储系统里面解决问题。比如说，我们可以再构建一个以店铺 ID 作为 Sharding Key 的只读订单库，专门供商家来使用。或者，把订单数据同步到 HDFS 中，然后用一些大数据技术来生成订单相关的报表。

所以你看，一旦做了分库分表，就会极大地限制数据库的查询能力，之前很简单的查询，分库分表之后，可能就没法实现了。所以我们在之前的课程中，先讲了各种各样的方法，来缓解数据多、并发高的问题，而一直没讲分库分表。分库分表一定是，数据量和并发大到所有招数都不好使了，我们才拿出来的最后一招。

如何选择分片算法？

在上节课我给你留的思考题中，我们提到过，能不能用订单完成时间作为 Sharding Key 呢？比如说，我分 12 个分片，每个月一个分片，这样对查询的兼容要好很多，毕竟查询条件中带上时间范围，让查询只落到某一个分片上，还是比较容易的，我在查询界面上强制用户必须指定时间范围就行了。

这种做法有个很大的问题，比如现在是 3 月份，那基本上所有的查询都集中在 3 月份这个分片上，其他 11 个分片都闲着，这样不仅浪费资源，很可能你 3 月那个分片根本抗不住几乎全部的并发请求。这个问题就是“热点问题”。

也就是说，我们希望并发请求和数据能均匀地分布到每一个分片上，尽量避免出现热点。这是选择分片算法时需要考虑的一个重要的因素。一般常用的分片算法就那么几种，刚刚讲到的按照时间范围分片的方法是其中的一种。

基于范围来分片容易产生热点问题，不适合作为订单的分片方法，但是这种分片方法的优点也很突出，那就是对查询非常友好，基本上只要加上一个时间范围的查询条件，原来该怎么查，分片之后还可以怎么查。范围分片特别适合那种数据量非常大，但并发访问量不大的 ToB 系统。比如说，电信运营商的监控系统，它可能要采集所有人手机的信号质量，然后做一些分析，这个数据量非常大，但是这个系统的使用者是运营商的工作人员，并发量很少。这种情况下就很适合范围分片。

一般来说，订单表都采用更均匀的哈希分片算法。比如说，我们要分 24 个分片，选定了 Sharding Key 是用户 ID，那我们决定某个用户的订单应该落到那个分片上的算法是，拿用户 ID 除以 24，得到的余数就是分片号。这是最简单的取模算法，一般就可以满足大部分要求了。当然也有一些更复杂的哈希算法，像一致性哈希之类的，特殊情况下也可以使用。

需要注意的一点是，哈希分片算法能够分得足够均匀的前提条件是，用户 ID 后几位数字必须是均匀分布的。比如说，你在生成用户 ID 的时候，自定义了一个用户 ID 的规则，最后一位 0 是男性，1 是女性，这样的用户 ID 哈希出来可能就没那么均匀，可能会出现热点。

还有一种分片的方法：查表法。查表法其实就是没有分片算法，决定某个 Sharding Key 落在哪个分片上，全靠人为来分配，分配的结果记录在一张表里面。每次执行查询的时候，先去表里查一下要找的数据在哪个分片中。

查表法的好处就是灵活，怎么分都可以，你用上面两种分片算法都没法分均匀的情况下，就可以用查表法，人为地来把数据分均匀了。查表法还有一个特好的地方是，它的分片是可以随时改变的。比如我发现某个分片已经是热点了，那我可以把这个分片再拆成几个分片，或者把这个分片的数据移到其他分片中去，然后修改一下分片映射表，就可以在线完成数据拆分了。

但你需要注意的是，分片映射表本身的数据不能太多，否则这个表反而成为热点和性能瓶颈了。查表法相对其他两种分片算法来说，缺点是需要二次查询，实现起来更复杂，性能上也稍微慢一些。但是，分片映射表可以通过缓存来加速查询，实际性能并不会慢很多。

小结

对 MySQL 这样的单机数据库来说，分库分表是应对海量数据和高并发的最后一招，分库分表之后，将会对数据查询有非常大的限制。

分多少个库需要用并发量来预估，分多少表需要用数据量来预估。选择 Sharding Key 的时候，一定要能兼容业务最常用的查询条件，让查询尽量落在一个分片中，分片之后无法兼容的查询，可以把数据同步到其他存储中去，来解决这个问题。

我们常用三种分片算法，范围分片容易产生热点问题，但对查询更友好，适合适合并发量不大的场景；哈希分片比较容易把数据和查询均匀地分布到所有分片中；查表法更灵活，但性能稍差。

对于订单表进行分库分表，一般按照用户 ID 作为 Sharding Key，采用哈希分片算法来均匀分布用户订单数据。为了能支持按订单号查询的需求，需要把用户 ID 的后几位放到订单号中去。

最后还需要强调一下，我们这节课讲的这些分片相关的知识，不仅仅适用于 MySQL 的分库分表，你在使用其他分布式数据库的时候，一样会遇到如何分片、如何选择 Sharding Key 和分片算法的问题，它们的原理都是一样的，所以我们讲的这些方法也都是通用的。

思考题

课后请你想一下，把订单表拆分之后，那些和订单有外键关联的表，该怎么处理？欢迎你在留言区与我讨论。

感谢你的阅读，如果你觉得今天的内容对你有帮助，也欢迎把它分享给你的朋友。

16 | 用Redis构建缓存集群的最佳实践有哪些？

你好，我是李玥。

之前连续几节课，我们都在以 MySQL 为例子，讲如何应对海量数据，如何应对高并发，如何实现高可用，我先带你简单复习一下。

数据量太大查询慢怎么办？存档历史数据或者分库分表，这是数据分片。

并发太高扛不住怎么办？读写分离，这是增加实例数。

数据库宕机怎么办？增加从节点，主节点宕机的时候用从节点顶上，这是主从复制。但是这里面要特别注意数据一致性的问题。

我在之前课程中，也多次提到过，这些方法不仅仅是 MySQL 特有的，对于几乎所有的存储系统，都是适用的。

今天这节课，我们来聊一聊，如何构建一个生产系统可用的 Redis 缓存集群。你将会看到，几种集群解决方案，它们用到的思想，基本和我们上面讲的都是一样的。

Redis Cluster 如何解决数据量大、高可用和高并发问题？

Redis 从 3.0 版本开始，提供了官方的集群支持，也就是 Redis Cluser。Redis Cluster 相比于单个节点的 Redis，能保存更多的数据，支持更多的并发，并且可以做到高可用，在单个节点故障的情况下，继续提供服务。

为了能够保存更多的数据，和 MySQL 分库分表的方式类似，Redis Cluster 也是通过分片的方式，把数据分布到集群的多个节点上。

Redis Cluster 是如何来分片的呢？它引入了一个“槽（Slot）”的概念，这个槽就是哈希表中的哈希槽，槽是 Redis 分片的基本单位，每个槽里面包含一些 Key。每个集群的槽数是固定的 16384（16 * 1024）个，每个 Key 落在哪个槽中也是固定的，计算方法是：

HASH_SLOT = CRC16(key) mod 16384

这个算法很简单，先计算 Key 的 CRC 值，然后把这个 CRC 之后的 Key 值直接除以 16384，余数就是 Key 所在的槽。这个算法就是我们上节课讲过的哈希分片算法。

这些槽又是如何存放到具体的 Redis 节点上的呢？这个映射关系保存在集群的每个 Redis 节点上，集群初始化的时候，Redis 会自动平均分配这 16384 个槽，也可以通过命令来调整。这个分槽的方法，也是我们上节课讲到过的分片算法：查表法。

客户端可以连接集群的任意一个节点来访问集群的数据，当客户端请求一个 Key 的时候，被请求的那个 Redis 实例先通过上面的公式，计算出这个 Key 在哪个槽中，然后再查询槽和节点的映射关系，找到数据所在的真正节点，如果这个节点正好是自己，那就直接执行命令返回结果。如果数据不在当前这个节点上，那就给客户端返回一个重定向的命令，告诉客户端，应该去连哪个节点上请求这个 Key 的数据。然后客户端会再连接正确的节点来访问。

解决分片问题之后，Redis Cluster 就可以通过水平扩容来增加集群的存储容量，但是，每次往集群增加节点的时候，需要从集群的那些老节点中，搬运一些槽到新节点，你可以手动指定哪些槽迁移到新节点上，也可以利用官方提供的redis-trib.rb脚本来自动重新分配槽，自动迁移。

分片可以解决 Redis 保存海量数据的问题，并且客观上提升了 Redis 的并发能力和查询性能。但是并不能解决高可用的问题，每个节点都保存了整个集群数据的一个子集，任何一个节点宕机，都会导致这个宕机节点上的那部分数据无法访问。

那 Redis Cluster 是怎么解决高可用问题的？

参见上面我们讲到的方法：增加从节点，做主从复制。Redis Cluster 支持给每个分片增加一个或多个从节点，每个从节点在连接到主节点上之后，会先给主节点发送一个 SYNC 命令，请求一次全量复制，也就是把主节点上全部的数据都复制到从节点上。全量复制完成之后，进入同步阶段，主节点会把刚刚全量复制期间收到的命令，以及后续收到的命令持续地转发给从节点。

因为 Redis 不支持事务，所以它的复制相比 MySQL 更简单，连 Binlog 都省了，直接就是转发客户端发来的更新数据命令来实现主从同步。如果某个分片的主节点宕机了，集群中的其他节点会在这个分片的从节点中选出一个新的节点作为主节点继续提供服务。新的主节点选举出来后，集群中的所有节点都会感知到，这样，如果客户端的请求 Key 落在故障分片上，就会被重定向到新的主节点上。

最后我们看一下，Redis Cluster 是如何应对高并发的。

一般来说，Redis Cluster 进行了分片之后，每个分片都会承接一部分并发的请求，加上 Redis 本身单节点的性能就非常高，所以大部分情况下不需要再像 MySQL 那样做读写分离来解决高并发的问题。默认情况下，集群的读写请求都是由主节点负责的，从节点只是起一个热备的作用。当然了，Redis Cluster 也支持读写分离，在从节点上读取数据。

以上就是 Redis Cluster 的基本原理，你可以对照下图来加深理解。

（图片来源：网络）

你可以看到，Redis Cluster 整体的架构完全就是照抄 MySQL 构建集群的那一套东西（当然，这些设计和方法也不是 MySQL 发明的），抄作业抄的就差把名字一起也抄上了。

具体如何搭建 Redis Cluster 以及相关的操作命令你可以看一下Redis 官方的这篇教程。

为什么 Redis Cluster 不适合超大规模集群？

Redis Cluster 的优点是易于使用。分片、主从复制、弹性扩容这些功能都可以做到自动化，通过简单的部署就可以获得一个大容量、高可靠、高可用的 Redis 集群，并且对于应用来说，近乎于是透明的。

所以，Redis Cluster 是非常适合构建中小规模 Redis 集群，这里的中小规模指的是，大概几个到几十个节点这样规模的 Redis 集群。

但是 Redis Cluster 不太适合构建超大规模集群，主要原因是，它采用了去中心化的设计。刚刚我们讲了，Redis 的每个节点上，都保存了所有槽和节点的映射关系表，客户端可以访问任意一个节点，再通过重定向命令，找到数据所在的那个节点。那你有没有想过一个问题，这个映射关系表，它是如何更新的呢？比如说，集群加入了新节点，或者某个主节点宕机了，新的主节点被选举出来，这些情况下，都需要更新集群每一个节点上的映射关系表。

Redis Cluster 采用了一种去中心化的流言 (Gossip) 协议来传播集群配置的变化。一般涉及到协议都比较复杂，这里我们不去深究具体协议和实现算法，我大概给你讲一下这个协议原理。

所谓流言，就是八卦，比如说，我们上学的时候，班上谁和谁偷偷好上了，搞对象，那用不了一天，全班同学都知道了。咋知道的？张三看见了，告诉李四，李四和王小二特别好，又告诉了王小二，这样人传人，不久就传遍全班了。这个就是八卦协议的传播原理。

这个八卦协议它的好处是去中心化，传八卦不需要组织，吃瓜群众自发就传开了。这样部署和维护就更简单，也能避免中心节点的单点故障。八卦协议的缺点就是传播速度慢，并且是集群规模越大，传播的越慢。这个也很好理解，比如说，换成某两个特别出名的明星搞对象，即使是全国人民都很八卦，但要想让全国每一个人都知道这个消息，还是需要很长的时间。在集群规模太大的情况下，数据不同步的问题会被明显放大，还有一定的不确定性，如果出现问题很难排查。

如何用 Redis 构建超大规模集群？

Redis Cluster 不太适合用于大规模集群，所以很多大厂，都选择自己去搭建 Redis 集群。这里面，每一家的解决方案都有自己的特色，但其实总体的架构都是大同小异的。

一种是基于代理的方式，在客户端和 Redis 节点之间，还需要增加一层代理服务。这个代理服务有三个作用。

第一个作用是，负责在客户端和 Redis 节点之间转发请求和响应。客户端只和代理服务打交道，代理收到客户端的请求之后，再转发到对应的 Redis 节点上，节点返回的响应再经由代理转发返回给客户端。

第二个作用是，负责监控集群中所有 Redis 节点状态，如果发现有问题节点，及时进行主从切换。

第三个作用就是维护集群的元数据，这个元数据主要就是集群所有节点的主从信息，以及槽和节点关系映射表。这个架构和我在《12 | MySQL 如何应对高并发（二）：读写分离》这节课中给你讲过的，用 HAProxy+Keepalived 来代理 MySQL 请求的架构是类似的，只是多了一个自动分片路由的功能而已。

像开源的 Redis 集群方案twemproxy和Codis，都是这种架构的。

这个架构最大的优点是对客户端透明，在客户端视角来看，整个集群和一个超大容量的单节点 Redis 是一样的。并且，由于分片算法是代理服务控制的，扩容也比较方便，新节点加入集群后，直接修改代理服务中的元数据就可以完成扩容。

不过，这个架构的缺点也很突出，增加了一层代理转发，每次数据访问的链路更长了，必然会带来一定的性能损失。而且，代理服务本身又是集群的一个单点，当然，我们可以把代理服务也做成一个集群来解决单点问题，那样集群就更复杂了。

另外一种方式是，不用这个代理服务，把代理服务的寻址功能前移到客户端中去。客户端在发起请求之前，先去查询元数据，就可以知道要访问的是哪个分片和哪个节点，然后直连对应的 Redis 节点访问数据。

当然，客户端不用每次都去查询元数据，因为这个元数据是不怎么变化的，客户端可以自己缓存元数据，这样访问性能基本上和单机版的 Redis 是一样的。如果某个分片的主节点宕机了，新的主节点被选举出来之后，更新元数据里面的信息。对集群的扩容操作也比较简单，除了迁移数据的工作必须要做以外，更新一下元数据就可以了。

虽然说，这个元数据服务仍然是一个单点，但是它的数据量不大，访问量也不大，相对就比较容易实现。我们可以用 ZooKeeper、etcd 甚至 MySQL 都能满足要求。这个方案应该是最适合超大规模 Redis 集群的方案了，在性能、弹性、高可用几方面表现都非常好，缺点是整个架构比较复杂，客户端不能通用，需要开发定制化的 Redis 客户端，只有规模足够大的企业才负担得起。

小结

今天这节课我们讲了从小到大三种构建 Redis 集群的方式。

小规模的集群建议使用官方的 Redis Cluster，在节点数量不多的情况下，各方面表现都不错。

再大一些规模的集群，可以考虑使用 twemproxy 或者 Codis 这类的基于代理的集群架构，虽然是开源方案，但是已经被很多公司在生产环境中验证过。

相比于代理方案，使用定制客户端的方案性能更好，很多大厂采用的都是类似的架构。

还有一个小问题需要注意的是，这几种集群方案对一些类似于“KEYS”这类的多 KEY 命令，都没法做到百分百支持。原因很简单，数据被分片了之后，这种多 KEY 的命令很可能需要跨多个分片查询。当你的系统从单个 Redis 库升级到集群时，可能需要考虑一下这方面的兼容性问题。

思考题

很多存储系统之间都存在“互相抄作业”的嫌疑，其实这对于我们这些存储系统的使用者来说是好事儿，比如我们把 MySQL 都学透了，你再去看 Redis，知道它抄了哪些作业，这部分我们就可以迅速掌握了，只要再研究一下不一样的那一小部分内容，我们就可以精通 Redis 了是不？

课后请你再去看一下 HDFS，它在解决分片、复制和高可用这几方面，哪些是“抄作业”，哪些又是自己独创的。欢迎你在留言区与我讨论。

感谢你的阅读，如果你觉得今天的内容对你有帮助，也欢迎把它分享给你的朋友。

17 | 大厂都是怎么做MySQL to Redis同步的?

你好，我是李玥。

之前我们在《11 | MySQL 如何应对高并发（一）：使用缓存保护 MySQL》这一节课中，讲到了 Read/Write Through 和 Cache Aside 这几种更新缓存的策略，这几种策略都存在缓存穿透的可能，如果缓存没有命中，那就穿透缓存去访问数据库获取数据。

一般情况下，只要我们做好缓存预热，这个缓存的命中率很高，能穿透缓存打到数据库上的请求比例就非常低，这些缓存的策略都是没问题的。但是如果说，我们的 Redis 缓存服务的是一个超大规模的系统，那就又不一样了。

今天这节课，我们来说一下，在超大规模系统中缓存会面临什么样的问题，以及应该使用什么样的策略来更新缓存。

缓存穿透：超大规模系统的不能承受之痛

我们上节课讲到了如何构建 Redis 集群，由于集群可以水平扩容，那只要集群足够大，理论上支持海量并发也不是问题。但是，因为并发请求的数量这个基数太大了，即使有很小比率的请求穿透缓存，打到数据库上请求的绝对数量仍然不小。加上大促期间的流量峰值，还是存在缓存穿透引发雪崩的风险。

那这个问题怎么解决呢？其实方法你也想得到，不让请求穿透缓存不就行了？反正现在存储也便宜，只要你买得起足够多的服务器，Redis 集群的容量就是无限的。不如把全量的数据都放在 Redis 集群里面，处理读请求的时候，干脆只读 Redis，不去读数据库。这样就完全没有“缓存穿透”的风险了，实际上很多大厂它就是这么干的。

在 Redis 中缓存全量的数据，又引发了一个新的问题，那就是，如何来更新缓存中的数据呢？因为我们取消了缓存穿透的机制，这种情况下，从缓存读到数据可以直接返回，如果没读到数据，那就只能返回错误了！所以，当系统更新数据库的数据之后，必须及时去更新缓存。

说到这儿，又绕回到那个老问题上了：怎么保证 Redis 中的数据和数据库中的数据同步更新？我们之前讲过用分布式事务来解决数据一致性的问题，但是这些方法都不太适合用来更新缓存，因为分布式事务，对数据更新服务有很强的侵入性。我们拿下单服务来说，如果为了更新缓存增加一个分布式事务，无论我们用哪种分布式事务，或多或少都会影响下单服务的性能。还有一个问题是，如果 Redis 本身出现故障，写入数据失败，还会导致下单失败，等于是降低了下单服务性能和可用性，这样肯定不行。

对于像订单服务这类核心的业务，一个可行的方法是，我们启动一个更新订单缓存的服务，接收订单变更的 MQ 消息，然后更新 Redis 中缓存的订单数据。因为这类核心的业务数据，使用方非常多，本来就需要发消息，增加一个消费订阅基本没什么成本，订单服务本身也不需要做任何更改。

唯一需要担心的一个问题是，如果丢消息了怎么办？因为现在消息是缓存数据的唯一来源，一旦出现丢消息，缓存里缺失的那条数据永远不会被补上。所以，必须保证整个消息链条的可靠性，不过好在现在的 MQ 集群，比如像 Kafka 或者 RocketMQ，它都有高可用和高可靠的保证机制，只要你正确配置好，是可以满足数据可靠性要求的。

像订单服务这样，本来就有现成的数据变更消息可以订阅，这样更新缓存还是一个不错的选择，因为实现起来很简单，对系统的其他模块完全没有侵入。

使用 Binlog 实时更新 Redis 缓存

如果我们要缓存的数据，本来没有一份数据更新的 MQ 消息可以订阅怎么办？很多大厂都采用的，也是更通用的解决方案是这样的。

数据更新服务只负责处理业务逻辑，更新 MySQL，完全不用管如何去更新缓存。负责更新缓存的服务，把自己伪装成一个 MySQL 的从节点，从 MySQL 接收 Binlog，解析 Binlog 之后，可以得到实时的数据变更信息，然后根据这个变更信息去更新 Redis 缓存。

这种收 Binlog 更新缓存的方案，和刚刚我们讲到的，收 MQ 消息更新缓存的方案，其实它们的实现思路是一样的，都是异步订阅实时数据变更信息，去更新 Redis。只不过，直接读取 Binlog 这种方式，它的通用性更强。不要求订单服务再发订单消息了，订单更新服务也不用费劲去解决“发消息失败怎么办？”这种数据一致性问题了。

而且，在整个缓存更新链路上，减少了一个收发 MQ 的环节，从 MySQL 更新到 Redis 更新的时延更短，出现故障的可能性也更低，所以很多大厂更青睐于这种方案。

这个方案唯一的缺点是，实现订单缓存更新服务有点儿复杂，毕竟不像收消息，拿到的直接就是订单数据，解析 Binlog 还是挺麻烦的。

有很多开源的项目就提供了订阅和解析 MySQL Binlog 的功能，下面我们以比较常用的开源项目Canal为例，来演示一下如何实时接收 Binlog 更新 Redis 缓存。

Canal 模拟 MySQL 主从复制的交互协议，把自己伪装成一个 MySQL 的从节点，向 MySQL 主节点发送 dump 请求，MySQL 收到请求后，就会开始推送 Binlog 给 Canal，Canal 解析 Binlog 字节流之后，转换为便于读取的结构化数据，供下游程序订阅使用。下图是 Canal 的工作原理：

在我们这个示例中，MySQL 和 Redis 都运行在本地的默认端口上，MySQL 的端口为 3306，Redis 的端口为 6379。为了便于大家操作，我们还是以《04 | 事务：账户余额总是对不上账，怎么办？》这节课中的账户余额表 account_balance 作为演示数据。

首先，下载并解压 Canal 最新的 1.1.4 版本到本地：

wget https://github.com/alibaba/canal/releases/download/canal-1.1.4/canal.deployer-1.1.4.tar.gz
tar zvfx canal.deployer-1.1.4.tar.gz

然后来配置 MySQL，我们需要在 MySQL 的配置文件中开启 Binlog，并设置 Binlog 的格式为 ROW 格式。

[mysqld]
log-bin=mysql-bin # 开启Binlog
binlog-format=ROW # 设置Binlog格式为ROW
server_id=1 # 配置一个ServerID

给 Canal 开一个专门的 MySQL 用户并授权，确保这个用户有复制 Binlog 的权限：

CREATE USER canal IDENTIFIED BY 'canal';
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%';
FLUSH PRIVILEGES;

重启一下 MySQL，确保所有的配置生效。重启后检查一下当前的 Binlog 文件和位置：

记录下 File 和 Position 两列的值，然后我们来配置 Canal。编辑 Canal 的实例配置文件 canal/conf/example/instance.properties，以便让 Canal 连接到我们的 MySQL 上。

canal.instance.gtidon=false

# position info
canal.instance.master.address=127.0.0.1:3306
canal.instance.master.journal.name=binlog.000009
canal.instance.master.position=155
canal.instance.master.timestamp=
canal.instance.master.gtid=

# username/password
canal.instance.dbUsername=canal
canal.instance.dbPassword=canal
canal.instance.connectionCharset = UTF-8
canal.instance.defaultDatabaseName=test
# table regex
canal.instance.filter.regex=.*\\..

这个配置文件需要配置 MySQL 的连接地址、库名、用户名和密码之外，还需要配置 canal.instance.master.journal.name 和 canal.instance.master.position 这两个属性，取值就是刚刚记录的 File 和 Position 两列。然后就可以启动 Canal 服务了：

canal/bin/startup.sh

启动之后看一下日志文件 canal/logs/example/example.log，如果里面没有报错，就说明启动成功并连接到我们的 MySQL 上了。

Canal 服务启动后，会开启一个端口（11111）等待客户端连接，客户端连接上 Canal 服务之后，可以从 Canal 服务拉取数据，每拉取一批数据，正确写入 Redis 之后，给 Canal 服务返回处理成功的响应。如果发生客户端程序宕机或者处理失败等异常情况，Canal 服务没收到处理成功的响应，下次客户端来拉取的还是同一批数据，这样就可以保证顺序并且不会丢数据。

接下来我们来开发账户余额缓存的更新程序，以下的代码都是用 Java 语言编写的：

while (true) {
    Message message = connector.getWithoutAck(batchSize); // 获取指定数量的数据
    long batchId = message.getId();
    try {
        int size = message.getEntries().size();
        if (batchId == -1 || size == 0) {
            Thread.sleep(1000);
        } else {
            processEntries(message.getEntries(), jedis);
        }

        connector.ack(batchId); // 提交确认
    } catch (Throwable t) {
        connector.rollback(batchId); // 处理失败, 回滚数据
    }
}

这个程序逻辑也不复杂，程序启动并连接到 Canal 服务后，就不停地拉数据，如果没有数据就睡一会儿，有数据就调用 processEntries 方法处理更新缓存。每批数据更新成功后，就调用 ack 方法给 Canal 服务返回成功响应，如果失败抛异常就回滚。下面是 processEntries 方法的主要代码：

for (CanalEntry.RowData rowData : rowChage.getRowDatasList()) {
    if (eventType == CanalEntry.EventType.DELETE) { // 删除
        jedis.del(row2Key("user_id", rowData.getBeforeColumnsList()));
    } else if (eventType == CanalEntry.EventType.INSERT) { // 插入
        jedis.set(row2Key("user_id", rowData.getAfterColumnsList()), row2Value(rowData.getAfterColumnsList()));
    } else { // 更新
        jedis.set(row2Key("user_id", rowData.getAfterColumnsList()), row2Value(rowData.getAfterColumnsList()));
    }
}

这里面根据事件类型来分别处理，如果 MySQL 中的数据删除了，就删除 Redis 中对应的数据。如果是更新和插入操作，那就调用 Redis 的 SET 命令来写入数据。

把这个账户缓存更新服务启动后，我们来验证一下，我们在账户余额表插入一条记录：

mysql> insert into account_balance values (888, 100, NOW(), 999);

然后来看一下 Redis 缓存：

127.0.0.1:6379> get 888
"{\"log_id\":\"999\",\"balance\":\"100\",\"user_id\":\"888\",\"timestamp\":\"2020-03-08 16:18:10\"}"

可以看到数据已经自动同步到 Redis 中去了。我把这个示例的完整代码放在了GitHub上供你参考。

小结

在处理超大规模并发的场景时，由于并发请求的数量非常大，即使少量的缓存穿透，也有可能打死数据库引发雪崩效应。对于这种情况，我们可以缓存全量数据来彻底避免缓存穿透问题。

对于缓存数据更新的方法，可以订阅数据更新的 MQ 消息来异步更新缓存，更通用的方法是，把缓存更新服务伪装成一个 MySQL 的从节点，订阅 MySQL 的 Binlog，通过 Binlog 来更新 Redis 缓存。

需要特别注意的是，无论是用 MQ 还是 Canal 来异步更新缓存，对整个更新服务的数据可靠性和实时性要求都比较高，数据丢失或者更新慢了，都会造成 Redis 中的数据与 MySQL 中数据不同步。在把这套方案应用到生产环境中去的时候，需要考虑一旦出现不同步问题时的降级或补偿方案。

思考题

课后请你思考一下，如果出现缓存不同步的情况，在你负责的业务场景下，该如何降级或者补偿？欢迎你在留言区与我讨论。

感谢你的阅读，如果你觉得今天的内容对你有帮助，也欢迎把它分享给你的朋友。

18 | 分布式存储：你知道对象存储是如何保存图片文件的吗？

你好，我是李玥。

我们都知道，保存像图片、音视频这类大文件，最佳的选择就是对象存储。对象存储不仅有很好的大文件读写性能，还可以通过水平扩展实现近乎无限的容量，并且可以兼顾服务高可用、数据高可靠这些特性。

对象存储之所以能做到这么“全能”，最主要的原因是，对象存储是原生的分布式存储系统。这里我们讲的“原生分布式存储系统”，是相对于 MySQL、Redis 这类单机存储系统来说的。虽然这些非原生的存储系统，也具备一定的集群能力，但你也能感受到，用它们构建大规模分布式集群的时候，其实是非常不容易的。

随着云计算的普及，很多新生代的存储系统，都是原生的分布式系统，它们一开始设计的目标之一就是分布式存储集群，比如说Elasticsearch、Ceph和国内很多大厂推出的新一代数据库，大多都可以做到：

近乎无限的存储容量；

超高的读写性能；

数据高可靠：节点磁盘损毁不会丢数据；

实现服务高可用：节点宕机不会影响集群对外提供服务。

那这些原生分布式存储是如何实现这些特性的呢？

实际上不用我说，你也能猜得到，这里面同样存在严重的“互相抄作业”的情况。这个也可以理解，除了存储的数据结构不一样，提供的查询服务不一样以外，这些分布式存储系统，它们面临的很多问题都是一样的，那实现方法差不多也是可以理解。

对象存储它的查询服务和数据结构都非常简单，是最简单的原生分布式存储系统。这节课，我们就来一起来研究一下对象存储这种最简单的原生分布式存储，通过对象存储来认识一下分布式存储系统的一些共性。掌握了这些共性之后，你再去认识和学习其他的分布式存储系统，也会感觉特别容易。

对象存储数据是如何保存大文件的？

对象存储对外提供的服务，其实就是一个近乎无限容量的大文件 KV 存储，所以对象存储和分布式文件系统之间，没有那么明确的界限。对象存储的内部，肯定有很多的存储节点，用于保存这些大文件，这个就是数据节点的集群。

另外，我们为了管理这些数据节点和节点中的文件，还需要一个存储系统保存集群的节点信息、文件信息和它们的映射关系。这些为了管理集群而存储的数据，叫做元数据 (Metadata)。

元数据对于一个存储集群来说是非常重要的，所以保存元数据的存储系统必须也是一个集群。但是元数据集群存储的数据量比较少，数据的变动不是很频繁，加之客户端或者网关都会缓存一部分元数据，所以元数据集群对并发要求也不高。一般使用类似ZooKeeper或者etcd这类分布式存储就可以满足要求。

另外，存储集群为了对外提供访问服务，还需要一个网关集群，对外接收外部请求，对内访问元数据和数据节点。网关集群中的每个节点不需要保存任何数据，都是无状态的节点。有些对象存储没有网关，取而代之的是客户端，它们的功能和作用都是一样的。

那么，对象存储是如何来处理对象读写请求的呢？这里面处理读和写请求的流程是一样的，我们一起来说。网关收到对象读写请求后，首先拿着请求中的 Key，去元数据集群查找这个 Key 在哪个数据节点上，然后再去访问对应的数据节点读写数据，最后把结果返回给客户端。

以上是一个比较粗略的大致流程，实际上这里面包含很多的细节，我们暂时没有展开讲。目的是让你在整体上对对象存储，以至于分布式存储系统，有一个清晰的认知。

上面这张图，虽然我画的是对象存储集群的结构，但是把图上的名词改一改，完全可以套用到绝大多数分布式文件系统和数据库上去，比如说 HDFS。

对象是如何拆分和保存的？

接下来我们说一下对象存储到底是如何来保存大文件对象的。一般来说，对象存储中保存的文件都是图片、视频这类大文件。在对象存储中，每一个大文件都会被拆成多个大小相等的块儿（Block），拆分的方法很简单，就是把文件从头到尾按照固定的块儿大小，切成一块儿一块儿，最后一块儿长度有可能不足一个块儿的大小，也按一块儿来处理。块儿的大小一般配置为几十 KB 到几个 MB 左右。

把大对象文件拆分成块儿的目的有两个：

第一是为了提升读写性能，这些块儿可以分散到不同的数据节点上，这样就可以并行读写。

第二是把文件分成大小相等块儿，便于维护管理。

对象被拆成块儿之后，还是太过于碎片化了，如果直接管理这些块儿，会导致元数据的数据量会非常大，也没必要管理到这么细的粒度。所以一般都会再把块儿聚合一下，放到块儿的容器里面。这里的“容器”就是存放一组块儿的逻辑单元。容器这个名词，没有统一的叫法，比如在ceph中称为 Data Placement，你理解这个含义就行。容器内的块儿数大多是固定的，所以容器的大小也是固定的。

到这里，这个容器的概念，就比较类似于我们之前讲 MySQL 和 Redis 时提到的“分片”的概念了，都是复制、迁移数据的基本单位。每个容器都会有 N 个副本，这些副本的数据都是一样的。其中有一个主副本，其他是从副本，主副本负责数据读写，从副本去到主副本上去复制数据，保证主从数据一致。

这里面有一点儿和我们之前讲的不一样的是，对象存储一般都不记录类似 MySQL 的 Binlog 这样的日志。主从复制的时候，复制的不是日志，而是整块儿的数据。这么做有两个原因：

第一个原因是基于性能的考虑。我们知道操作日志里面，实际上就包含着数据。在更新数据的时候，先记录操作日志，再更新存储引擎中的数据，相当于在磁盘上串行写了 2 次数据。对于像数据库这种，每次更新的数据都很少的存储系统，这个开销是可以接受的。但是对于对象存储来说，它每次写入的块儿很大，两次磁盘 IO 的开销就有些不太值得了。

第二个原因是它的存储结构简单，即使没有日志，只要按照顺序，整块儿的复制数据，仍然可以保证主从副本的数据一致性。

以上我们说的对象（也就是文件）、块儿和容器，都是逻辑层面的概念，数据落实到副本上，这些副本就是真正物理存在了。这些副本再被分配到数据节点上保存起来。这里的数据节点就是运行在服务器上的服务进程，负责在本地磁盘上保存副本的数据。

了解了对象是如何被拆分并存储在数据节点上之后，我们再来回顾一下数据访问的流程。当我们请求一个 Key 的时候，网关首先去元数据中查找这个 Key 的元数据。然后根据元数据中记录的对象长度，计算出对象有多少块儿。接下来的过程就可以分块儿并行处理了。对于每个块儿，还需要再去元数据中，找到它被放在哪个容器中。

我刚刚讲过，容器就是分片，怎么把块儿映射到容器中，这个方法就是我们在《15 | MySQL 存储海量数据的最后一招：分库分表》这节课中讲到的几种分片算法。不同的系统选择实现的方式也不一样，有用哈希分片的，也有用查表法把对应关系保存在元数据中的。找到容器之后，再去元数据中查找容器的 N 个副本都分布在哪些数据节点上。然后，网关直接访问对应的数据节点读写数据就可以了。

小结

对象存储是最简单的分布式存储系统，主要由数据节点集群、元数据集群和网关集群（或者客户端）三部分构成。数据节点集群负责保存对象数据，元数据集群负责保存集群的元数据，网关集群和客户端对外提供简单的访问 API，对内访问元数据和数据节点读写数据。

为了便于维护和管理，大的对象被拆分为若干固定大小的块儿，块儿又被封装到容器（也就分片）中，每个容器有一主 N 从多个副本，这些副本再被分散到集群的数据节点上保存。

对象存储虽然简单，但是它具备一个分布式存储系统的全部特征。所有分布式存储系统共通的一些特性，对象存储也都具备，比如说数据如何分片，如何通过多副本保证数据可靠性，如何在多个副本间复制数据，确保数据一致性等等。

希望你通过这节课的学习，不仅是学会对象存储，还要对比分析一下，对象存储和其他分布式存储系统，比如 MySQL 集群、HDFS、Elasticsearch 等等这些，它们之间有什么共同的地方，差异在哪儿。想通了这些问题，你对分布式存储系统的认知，绝对会上升到一个全新的高度。然后你再去看一些之前不了解的存储系统，就非常简单了。

思考题

我们刚刚说到过，对象存储并不是基于日志来进行主从复制的。假设我们的对象存储是一主二从三个副本，采用半同步方式复制数据，也就是主副本和任意一个从副本更新成功后，就给客户端返回成功响应。主副本所在节点宕机之后，这两个从副本中，至少有一个副本上的数据是和宕机的主副本上一样的，我们需要找到这个副本作为新的主副本，才能保证宕机不丢数据。

但是没有了日志，如果这两个从副本上的数据不一样，我们如何确定哪个上面的数据是和主副本一样新呢？欢迎你在留言区与我交流讨论。

感谢你的阅读，如果你觉得今天的内容对你有帮助，也欢迎把它分享给你的朋友。

19 | 跨系统实时同步数据，分布式事务是唯一的解决方案吗？

你好，我是李玥。

我们在《15 | MySQL 存储海量数据的最后一招：分库分表》这节课中讲过，数据量太大的时候，单个存储节点存不下，那就只能把数据分片存储。

数据分片之后，我们对数据的查询就没那么自由了。比如订单表如果按照用户 ID 作为 Sharding Key 来分片，那就只能按照用户维度来查询。如果我是一个商家，我想查我店铺的订单，对不起，做不到了。（当然，强行查也不是不行，在所有分片上都查一遍，再把结果聚合起来，又慢又麻烦，实际意义不大。）

对于这样的需求，普遍的解决办法是用空间换时间，毕竟现在存储越来越便宜。再存一份订单数据到商家订单库，然后以店铺 ID 作为 Sharding Key 分片，专门供商家查询订单。

另外，之前我们在《06 | 如何用 Elasticsearch 构建商品搜索系统》这节课也讲到过，同样一份商品数据，如果我们是按照关键字搜索，放在 ES 里就比放在 MySQL 快了几个数量级。原因是，数据组织方式、物理存储结构和查询方式，对查询性能的影响是巨大的，而且海量数据还会指数级地放大这个性能差距。

所以，在大厂中，对于海量数据的处理原则，都是根据业务对数据查询的需求，反过来确定选择什么数据库、如何组织数据结构、如何分片数据，这样才能达到最优的查询性能。同样一份订单数据，除了在订单库保存一份用于在线交易以外，还会在各种数据库中，以各种各样的组织方式存储，用于满足不同业务系统的查询需求。像 BAT 这种大厂，它的核心业务数据，存个几十上百份是非常正常的。

那么问题来了，如何能够做到让这么多份数据实时地保持同步呢？

我们之前讲过分布式事务，可以解决数据一致性的问题。比如说，你可以用本地消息表，把一份数据实时同步给另外两、三个数据库，这样还可以接受，太多的话也是不行的，并且对在线交易业务还有侵入性，所以分布式事务是解决不了这个问题的。

今天这节课我们就来说一下，如何把订单数据实时、准确无误地同步到这么多异构的数据中去。

使用 Binlog 和 MQ 构建实时数据同步系统

早期大数据刚刚兴起的时候，大多数系统还做不到异构数据库实时同步，那个时候普遍的做法是，使用 ETL 工具定时同步数据，在 T+1 时刻去同步上一个周期的数据，然后再做后续的计算和分析。定时 ETL 对于一些需要实时查询数据的业务需求就无能为力了。所以，这种定时同步的方式，基本上都被实时同步的方式给取代了。

怎么来做这么大数据量、这么多个异构数据库的实时同步呢？你还记得我在《17 | 大厂都是怎么做 MySQL to Redis 同步的》这节课中讲到的方法吧？利用 Canal 把自己伪装成一个 MySQL 的从库，从 MySQL 实时接收 Binlog 然后写入 Redis 中。把这个方法稍微改进一下，就可以用来做异构数据库的同步了。

为了能够支撑下游众多的数据库，从 Canal 出来的 Binlog 数据肯定不能直接去写下游那么多数据库，一是写不过来，二是对于每个下游数据库，它可能还有一些数据转换和过滤的工作要做。所以需要增加一个 MQ 来解耦上下游。

Canal 从 MySQL 收到 Binlog 并解析成结构化数据之后，直接写入到 MQ 的一个订单 Binlog 主题中，然后每一个需要同步订单数据的业务方，都去订阅这个 MQ 中的订单 Binlog 主题，消费解析后的 Binlog 数据。在每个消费者自己的同步程序中，它既可以直接入库，也可以做一些数据转换、过滤或者计算之后再入库，这样就比较灵活了。

如何保证数据同步的实时性

这个方法看起来不难，但是非常容易出现性能问题。有些接收 Binlog 消息的下游业务，对数据的实时性要求比较高，不能容忍太高的同步时延。比如说，每个电商在大促的时候，都会有一个大屏幕，实时显示现在有多少笔交易，交易额是多少。这个东西都是给老板们看的，如果说大促的时候，你让老板们半小时之后才看到数字，那估计你就得走人了。

大促的时候，数据量大、并发高、数据库中的数据变动频繁，同步的 Binlog 流量也非常大。为了保证这个同步的实时性，整个数据同步链条上的任何一个环节，它的处理速度都必须得跟得上才行。我们一步一步分析可能会出现性能瓶颈的环节。

源头的订单库，如果它出现繁忙，对业务的影响就不只是大屏延迟了，那就影响到用户下单了，这个问题是数据库本身要解决的，这里我们不考虑。再顺着数据流向往下看，Canal 和 MQ 这两个环节，由于没什么业务逻辑，性能都非常好。所以，一般容易成为性能瓶颈的就是消费 MQ 的同步程序，因为这些同步程序里面一般都会有一些业务逻辑，而且如果下游的数据库写性能跟不上，表象也是这个同步程序处理性能上不来，消息积压在 MQ 里面。

那我们能不能多加一些同步程序的实例数，或者增加线程数，通过增加并发来提升处理能力呢？这个地方的并发数，还真不是随便说扩容就可以就扩容的，我来跟你讲一下为什么。

我们知道，MySQL 主从同步 Binlog，是一个单线程的同步过程。为什么是单线程？原因很简单，在从库执行 Binlog 的时候，必须按顺序执行，才能保证数据和主库是一样的。为了确保数据一致性，Binlog 的顺序很重要，是绝对不能乱序的。 严格来说，对于每一个 MySQL 实例，整个处理链条都必须是单线程串行执行，MQ 的主题也必须设置为只有 1 个分区（队列），这样才能保证数据同步过程中的 Binlog 是严格有序的，写到目标数据库的数据才能是正确的。

那单线程处理速度上不去，消息越积压越多，这不无解了吗？其实办法还是有的，但是必须得和业务结合起来解决。

还是拿订单库来说啊，其实我们并不需要对订单库所有的更新操作都严格有序地执行，比如说 A 和 B 两个订单号不同的订单，这两个订单谁先更新谁后更新并不影响数据的一致性，因为这两个订单完全没有任何关系。但是同一个订单，如果更新的 Binlog 执行顺序错了，那同步出来的订单数据真的就错了。

也就是说，我们只要保证每个订单的更新操作日志的顺序别乱就可以了。这种一致性要求称为因果一致性（Causal Consistency），有因果关系的数据之间必须要严格地保证顺序，没有因果关系的数据之间的顺序是无所谓的。

基于这个理论基础，我们就可以并行地来进行数据同步，具体的做法是这样的。

首先根据下游同步程序的消费能力，计算出需要多少并发；然后设置 MQ 中主题的分区（队列）数量和并发数一致。因为 MQ 是可以保证同一分区内，消息是不会乱序的，所以我们需要把具有因果关系的 Binlog 都放到相同的分区中去，就可以保证同步数据的因果一致性。对应到订单库就是，相同订单号的 Binlog 必须发到同一个分区上。

这是不是和之前讲过的数据库分片有点儿像呢？那分片算法就可以拿过来复用了，比如我们可以用最简单的哈希算法，Binlog 中订单号除以 MQ 分区总数，余数就是这条 Binlog 消息发往的分区号。

Canal 自带的分区策略就支持按照指定的 Key，把 Binlog 哈希到下游的 MQ 中去，具体的配置可以看一下Canal 接入 MQ 的文档。

小结

对于海量数据，必须要按照查询方式选择数据库类型和数据的组织方式，才能达到理想的查询性能。这就需要把同一份数据，按照不同的业务需求，以不同的组织方式存放到各种异构数据库中。因为数据的来源大多都是在线交易系统的 MySQL 数据库，所以我们可以利用 MySQL 的 Binlog 来实现异构数据库之间的实时数据同步。

为了能够支撑众多下游数据库实时同步的需求，可以通过 MQ 解耦上下游，Binlog 先发送到 MQ 中，下游各业务方可以消费 MQ 中的消息再写入各自的数据库。

如果下游处理能力不能满足要求，可以增加 MQ 中的分区数量实现并发同步，但需要结合同步的业务数据特点，把具有因果关系的数据哈希到相同分区上，才能避免因为并发乱序而出现数据同步错误的问题。

思考题

在我们这种数据同步架构下，如果说下游的某个同步程序或数据库出了问题，需要把 Binlog 回退到某个时间点然后重新同步，这个问题该怎么解决？欢迎你在留言区与我讨论。

感谢你的阅读，如果你觉得今天的内容对你有帮助，也欢迎把它分享给你的朋友。

20 | 如何在不停机的情况下，安全地更换数据库？

你好，我是李玥。

随着我们的系统规模逐渐增长，总会遇到需要更换数据库的问题。我们来说几种常见的情况。

对 MySQL 做了分库分表之后，需要从原来的单实例数据库迁移到新的数据库集群上。

系统从传统部署方式向云上迁移的时候，也需要从自建的数据库迁移到云数据库上。

一些在线分析类的系统，MySQL 性能不够用的时候，就需要更换成一些专门的分析类数据库，比如说 HBase。

更换数据库这个事儿，是一个非常大的技术挑战，因为我们需要保证整个迁移过程中，既不能长时间停服，也不能丢数据。

那么，今天这节课我们就来说一下，如何在不停机的情况下，安全地迁移数据更换数据库。

如何实现不停机更换数据库？

我们都知道墨菲定律：“如果事情有变坏的可能，不管这种可能性有多小，它总会发生。”放到这里呢，也就是说，我们在更换数据库的过程中，只要有一点儿可能会出问题的地方，哪怕是出现问题的概率非常小，它总会出问题。

实际上，无论是新版本的程序，还是新的数据库，即使我们做了严格的验证测试，做了高可用方案，刚刚上线的系统，它的稳定性总是没有那么好的，需要一个磨合的过程，才能逐步达到一个稳定的状态，这是一个客观规律。这个过程中一旦出现故障，如果不能及时恢复，造成的损失往往是我们承担不起的。

所以我们在设计迁移方案的时候，一定要做到，每一步都是可逆的。要保证，每执行一个步骤后，一旦出现问题，能快速地回滚到上一个步骤。这是很多同学在设计这种升级类技术方案的时候，容易忽略的问题。

接下来我们还是以订单库为例子，说一下这个迁移方案应该如何来设计。

首先要做的就是，把旧库的数据复制到新库中。因为旧库还在服务线上业务，所以不断会有订单数据写入旧库，我们不仅要往新库复制数据，还要保证新旧两个库的数据是实时同步的。所以，我们需要用一个同步程序来实现新旧两个数据库实时同步。

怎么来实现两个异构数据库之间的数据实时同步，这个方法我们上节课刚刚讲过，我们可以使用 Binlog 实时同步数据。如果源库不是 MySQL 的话，就麻烦一点儿，但也可以参考我们讲过的，复制状态机理论来实现。这一步不需要回滚，原因是，只增加了一个新库和一个同步程序，对系统的旧库和程序都没有任何改变。即使新上线的同步程序影响到了旧库，只要停掉同步程序就可以了。

然后，我们需要改造一下订单服务，业务逻辑部分不需要变，DAO 层需要做如下改造：

支持双写新旧两个库，并且预留热切换开关，能通过开关控制三种写状态：只写旧库、只写新库和同步双写。

支持读新旧两个库，同样预留热切换开关，控制读旧库还是新库。

然后上线新版的订单服务，这个时候订单服务仍然是只读写旧库，不读写新库。让这个新版的订单服务需要稳定运行至少一到二周的时间，期间除了验证新版订单服务的稳定性以外，还要验证新旧两个订单库中的数据是否是一致的。这个过程中，如果新版订单服务有问题，可以立即下线新版订单服务，回滚到旧版本的订单服务。

稳定一段时间之后，就可以开启订单服务的双写开关了。开启双写开关的同时，需要停掉同步程序。这里面有一个问题需要注意一下，就是这个双写的业务逻辑，一定是先写旧库，再写新库，并且以写旧库的结果为准。

旧库写成功，新库写失败，返回写成功，但这个时候要记录日志，后续我们会用到这个日志来验证新库是否还有问题。旧库写失败，直接返回失败，就不写新库了。这么做的原因是，不能让新库影响到现有业务的可用性和数据准确性。上面这个过程如果出现问题，可以关闭双写，回滚到只读写旧库的状态。

切换到双写之后，新库与旧库的数据可能会存在不一致的情况，原因有两个：一是停止同步程序和开启双写，这两个过程很难做到无缝衔接，二是双写的策略也不保证新旧库强一致，这时候我们需要上线一个对比和补偿的程序，这个程序对比旧库最近的数据变更，然后检查新库中的数据是否一致，如果不一致，还要进行补偿。

开启双写后，还需要至少稳定运行至少几周的时间，并且期间我们要不断地检查，确保不能有旧库写成功，新库写失败的情况出现。对比程序也没有发现新旧两个库的数据有不一致的情况，这个时候，我们就可以认为，新旧两个库的数据是一直保持同步的。

接下来就可以用类似灰度发布的方式，把读请求一点儿一点儿地切到新库上。同样，期间如果出问题的话，可以再切回旧库。全部读请求都切换到新库上之后，这个时候其实读写请求就已经都切换到新库上了，实际的切换已经完成了，但还有后续的收尾步骤。

再稳定一段时间之后，就可以停掉对比程序，把订单服务的写状态改为只写新库。到这里，旧库就可以下线了。注意，整个迁移过程中，只有这个步骤是不可逆的。但是，这步的主要操作就是摘掉已经不再使用的旧库，对于在用的新库并没有什么改变，实际出问题的可能性已经非常小了。

到这里，我们就完成了在线更换数据库的全部流程。双写版本的订单服务也就完成了它的历史使命，可以在下一次升级订单服务版本的时候，下线双写功能。

如何实现对比和补偿程序？

在上面的整个切换过程中，如何实现这个对比和补偿程序，是整个这个切换设计方案中的一个难点。这个对比和补偿程序的难度在于，我们要对比的是两个都在随时变换的数据库中的数据。这种情况下，我们没有类似复制状态机这样理论上严谨实际操作还很简单的方法，来实现对比和补偿。但还是可以根据业务数据的实际情况，来针对性地实现对比和补偿，经过一段时间，把新旧两个数据库的差异，逐渐收敛到一致。

像订单这类时效性强的数据，是比较好对比和补偿的。因为订单一旦完成之后，就几乎不会再变了，那我们的对比和补偿程序，就可以依据订单完成时间，每次只对比这个时间窗口内完成的订单。补偿的逻辑也很简单，发现不一致的情况后，直接用旧库的订单数据覆盖新库的订单数据就可以了。

这样，切换双写期间，少量不一致的订单数据，等到订单完成之后，会被补偿程序修正。后续只要不是双写的时候，新库频繁写入失败，就可以保证两个库的数据完全一致。

比较麻烦的是更一般的情况，比如像商品信息这类数据，随时都有可能会变化。如果说数据上有更新时间，那我们的对比程序可以利用这个更新时间，每次在旧库取一个更新时间窗口内的数据，去新库上找相同主键的数据进行对比，发现数据不一致，还要对比一下更新时间。如果新库数据的更新时间晚于旧库数据，那可能是对比期间数据发生了变化，这种情况暂时不要补偿，放到下个时间窗口去继续对比。另外，时间窗口的结束时间，不要选取当前时间，而是要比当前时间早一点儿，比如 1 分钟前，避免去对比正在写入的数据。

如果数据连时间戳也没有，那只能去旧库读取 Binlog，获取数据变化，然后去新库对比和补偿。

有一点需要说明的是，上面这些方法，如果严格推敲，都不是百分之百严谨的，都不能保证在任何情况下，经过对比和补偿后，新库的数据和旧库就是完全一样的。但是，在大多数情况下，这些实践方法还是可以有效地收敛新旧两个库的数据差异，你可以酌情采用。

小结

设计在线切换数据库的技术方案，首先要保证安全性，确保每一个步骤一旦失败，都可以快速回滚。此外，还要确保迁移过程中不丢数据，这主要是依靠实时同步程序和对比补偿程序来实现。

我把这个复杂的切换过程的要点，按照顺序总结成下面这个列表，供你参考：

上线同步程序，从旧库中复制数据到新库中，并实时保持同步；

上线双写订单服务，只读写旧库；

开启双写，同时停止同步程序；

开启对比和补偿程序，确保新旧数据库数据完全一样；

逐步切量读请求到新库上；

下线对比补偿程序，关闭双写，读写都切换到新库上；

下线旧库和订单服务的双写功能。

思考题

我们整个切换的方案中，只有一个步骤是不可逆的，就是由双写切换为单写新库这一步。如果说不计成本，如何修改我们的迁移方案，让这一步也能做到快速回滚？你可以思考一下这个问题，欢迎你在留言区与我交流讨论。

感谢你的阅读，如果你觉得今天的内容对你有帮助，也欢迎把它分享给你的朋友。

21 | 类似“点击流”这样的海量数据应该如何存储？

你好，我是李玥。

对于大部分互联网公司来说，数据量最大的几类数据是：点击流数据、监控数据和日志数据。这里面“点击流”指的是在 App、小程序和 Web 页面上的埋点数据，这些埋点数据记录用户的行为，比如你打开了哪个页面，点击了哪个按钮，在哪个商品上停留了多久等等这些。

当然你不用太担心自己的隐私问题，记录的这些行为数据不是为了监控用户，主要目的是为了从统计上分析群体用户的行为，从而改进产品和运营。比如，某件商品看的人很多，停留时间很长，最后下单购买的人却很少，那采销人员就要考虑是不是这件商品的定价太高了。

除了点击流数据以外，监控和日志数据都是大家常用的，我就不再多解释了。

这类数据都是真正“海量”的数据，相比于订单、商品这类业务的数据，数据量要多出 2～3 个数量级。每天产生的数据量就可能会超过 TB（1 TB = 1024 GB）级别，经过一段时间累积下来，有些数据会达到 PB（1 PB = 1024 TB）级别。

这种量级的数据，在大数据技术出现之前，是没法保存和处理的，只能是通过抽样的方法来凑合着做分析。Hadoop 等大数据技术出现以后，才使得存储和计算海量数据成为可能。

今天这节课，我们来说说，应该选择什么样的存储系统，来保存像“点击流”这样的海量数据。

使用 Kafka 存储海量原始数据

早期对于这类海量原始数据，都倾向于先计算再存储。也就是，在接收原始数据的服务中，先进行一些数据过滤、聚合等初步的计算，将数据先收敛一下，再落存储。这样可以降低存储系统的写入压力，也能节省磁盘空间。

这几年，随着存储设备越来越便宜，并且，数据的价值被不断地重新挖掘，更多的大厂都倾向于先存储再计算，直接保存海量的原始数据，再对数据进行实时或者批量计算。这种方案，除了贵以外都是优点：

不需要二次分发就可以同时给多个流和批计算任务提供数据；

如果计算任务出错，可以随时回滚重新计算；

如果对数据有新的分析需求，上线后直接就可以用历史数据计算出结果，而不用去等新数据。

但是，这种方式对保存原始数据的存储系统要求就很高了：既要有足够大的容量，能水平扩容，还要读写都足够快，跟得上数据生产的写入速度，还要给下游计算提供低延迟的读服务。什么样的存储能满足这样的要求呢？这里我给出几种常用的解决方案。

第一种方案是，使用 Kafka 来存储。有的同学会问了，Kafka 不是一个消息队列么，怎么成了存储系统了？那我告诉你，现代的消息队列，本质上就是分布式的流数据存储系统。

如果你感兴趣的话，你可以仔细去研究一下 Kafka，它的数据是如何存储、分片、复制的？它是如何保证高可用，如何保证数据一致性的？那你会发现它和我们之前讲过的那些分布式存储系统，并没有什么太大的区别。唯一的区别就是，它的查询语言（生产和消费消息）和存储引擎的数据结构（Commit Log）比一般的存储系统要简单很多。但也正是因为这个原因，使得 Kafka 的读写性能远远好于其他的存储系统。Kafka 官方给自己的定位也是“分布式流数据平台”，不只是一个 MQ。

Kafka 提供“无限”的消息堆积能力，具有超高的吞吐量，可以满足我们保存原始数据的大部分要求。写入点击流数据的时候，每个原始数据采集服务作为一个生产者，把数据发给 Kafka 就可以了。下游的计算任务，可以作为消费者订阅消息，也可以按照时间或者位点来读取数据。并且，Kafka 作为事实标准，和大部分大数据生态圈的开源软件都有非常好的兼容性和集成度，像 Flink、Spark 等大多计算平台都提供了直接接入 Kafka 的组件。

当然，Kafka 也不是万能的，你可能注意到了，我刚刚讲 Kafka 提供“无限”的消息堆积能力，我在这个“无限”上打了个引号，这里面还是有一些限制需要注意的。Kafka 也支持把数据分片，这个在 Kafka 中叫 Partition，每个分片可以分布到不同的存储节点上。

写入数据的时候，可以均匀地写到这些分片上，理论上只要分片足够多，存储容量就可以是“无限”的。但是，单个分片总要落到某一个节点上，而单节点的存储容量毕竟是有限的，随着时间推移，单个分片总有写满的时候。

即使它支持扩容分片数量，也没办法像其他分布式存储系统那样，重新分配数据，把已有分片上的数据迁移一部分到新的分片上。所以扩容分片也解决不了已有分片写满的问题。而 Kafka 又不支持按照时间维度去分片，所以，受制于单节点的存储容量，Kafka 实际能存储的数据容量并不是无限的。

Kafka 之外还有哪些解决方案？

所以，需要长时间（几个月 - 几年）保存的海量数据，就不适合用 Kafka 存储。这种情况下，只能退而求其次，使用第二种方案了。

第二种方案是，使用 HDFS 来存储。使用 HDFS 存储数据也很简单，就是把原始数据写成一个一个文本文件，保存到 HDFS 中。我们需要按照时间和业务属性来组织目录结构和文件名，以便于下游计算程序来读取，比如说：“click/20200808/Beijing_0001.csv”，代表 2020 年 8 月 8 日，从北京地区用户收集到的点击流数据，这个是当天的第一个文件。

对于保存海量的原始数据这个特定的场景来说，HDFS 的吞吐量是远不如 Kafka 的。按照平均到每个节点上计算，Kafka 的吞吐能力很容易达到每秒钟大几百兆，而 HDFS 只能达到百兆左右。这就意味着，要达到相同的吞吐能力，使用 HDFS 就要比使用 Kafka，多用几倍的服务器数量。

但 HDFS 也有它的优势，第一个优势就是，它能提供真正无限的存储容量，如果存储空间不够了，水平扩容就可以解决。另外一个优势是，HDFS 能提供比 Kafka 更强的数据查询能力。Kafka 只能按照时间或者位点来提取数据，而 HDFS 配合 Hive 直接就可以支持用 SQL 对数据进行查询，虽然说查询的性能比较差，但查询能力要比 Kafka 强大太多了。

以上这两种方案因为都有各自的优势和不足，在实际生产中，都有不少的应用，你可以根据业务的情况来选择。那有没有兼顾这二者优势的方案呢？最好能做到，既有超高的吞吐能力，又能无限扩容，同时还能提供更好的查询能力，有这样的好事儿么？

我个人的判断是，目前还没有可用大规模于生产的，成熟的解决方案，但未来应该会有的。目前已经有一些的开源项目，都致力于解决这方面的问题，你可以关注一下。

一类是分布式流数据存储，比较活跃的项目有Pravega和 Pulsar 的存储引擎Apache BookKeeper。我所在的团队也在这个方向上持续探索中，也开源了我们的流数据存储项目JournalKeeper，也欢迎你关注和参与进来。这些分布式流数据存储系统，走的是类似 Kafka 这种流存储的路线，在高吞吐量的基础上，提供真正无限的扩容能力，更好的查询能力。

还有一类是时序数据库（Time Series Databases），比较活跃的项目有InfluxDB和OpenTSDB等。这些时序数据库，不仅有非常好的读写性能，还提供很方便的查询和聚合数据的能力。但是，它们不是什么数据都可以存的，它们专注于类似监控数据这样，有时间特征并且数据内容都是数值的数据。如果你有存储海量监控数据的需求，可以关注一下这些项目。

小结

在互联网行业，点击流、监控和日志这几类数据，是海量数据中的海量数据。对于这类数据，一般的处理方式都是先存储再计算，计算结果保存到特定的数据库中，供业务系统查询。

所以，对于海量原始数据的存储系统，我们要求的是超高的写入和读取性能，和近乎无限的容量，对于数据的查询能力要求不高。生产上，可以选择 Kafka 或者是 HDFS，Kafka 的优点是读写性能更好，单节点能支持更高的吞吐量。而 HDFS 则能提供真正无限的存储容量，并且对查询更友好。

未来会有一些开源的流数据存储系统和时序数据库逐步成熟，并陆续应用到生产系统中去，你可以持续关注这些项目。

思考题

课后请你想一下，为什么 Kafka 能做到几倍于 HDFS 的吞吐能力，技术上的根本原因是什么？欢迎你在留言区与我讨论。

感谢你的阅读，如果你觉得今天的内容对你有帮助，也欢迎把它分享给你的朋友。

22 | 面对海量数据，如何才能查得更快?

你好，我是李玥。

我们接着上节课的话题，来继续说海量数据。上节课我们讲了，如何来保存原始数据，那我们知道，原始数据的数据量太大了，能存下来就很不容易了，这个数据是没法直接来给业务系统查询和分析的。有两个原因，一是数据量太大了，二是也没有很好的数据结构和查询能力，来支持业务系统查询。

所以一般的做法是，用流计算或者是批计算，把原始数据再进行一次或者多次的过滤、汇聚和计算，把计算结果落到另外一个存储系统中去，由这个存储再给业务系统提供查询支持。这里的“流计算”，指的是 Flink、Storm 这类的实时计算，批计算是 Map-Reduce 或者 Spark 这类的非实时计算。

上节课我们说过，像点击流、监控和日志这些原始数据是“海量数据中的海量数据”，这些原始数据经过过滤汇总和计算之后，大多数情况下数据量会有量级的下降，比如说从 TB 级别的数据量，减少到 GB 级别。

有的业务，计算后的数据非常少，比如说一些按天粒度的汇总数据，或者排行榜类的数据，用什么存储都能满足要求。那有一些业务，没法通过事先计算的方式解决全部的问题。原始数据经过计算后产生的计算结果，数据量相比原始数据会减少一些，但仍然是海量数据。并且，我们还要在这个海量数据上，提供性能可以接受的查询服务。

今天这节课我们就来聊一聊，面对这样的海量数据，如何才能让查询更快一些。

常用的分析类系统应该如何选择存储？

查询海量数据的系统，大多都是离线分析类系统，你可以简单地理解为类似于做报表的系统，也就是那些主要功能是对数据做统计分析的系统。这类系统是重度依赖于存储的。选择什么样的存储系统、使用什么样的数据结构来存储数据，直接决定了数据查询、聚合和分析的性能。

分析类系统对存储的需求一般是这样的：

一般用于分析的数据量都会比在线业务大出几个数量级，这需要存储系统能保存海量数据；

能在海量的数据上做快速的聚合、分析和查询。注意这里面所说的“快速”，前提是处理 GB、TB 甚至 PB 级别的海量数据，在这么大的数据量上做分析，几十秒甚至几分钟都算很快了，和在线业务要求的毫秒级速度是不一样的；

由于数据大多数情况下都是异步写入，对于写入性能和响应时延，一般要求不高；

分析类系统不直接支撑前端业务，所以也不要求高并发。

然后我们看有哪些可供选择的存储产品。如果你的系统的数据量在 GB 量级以下，MySQL 仍然是可以考虑的，因为它的查询能力足以应付大部分分析系统的业务需求。并且可以和在线业务系统合用一个数据库，不用做 ETL（数据抽取），省事儿并且实时性好。这里还是要提醒你，最好给分析系统配置单独的 MySQL 实例，避免影响线上业务。

如果数据量级已经超过 MySQL 极限，可以选择一些列式数据库，比如：HBase、Cassandra、ClickHouse，这些产品对海量数据，都有非常好的查询性能，在正确使用的前提下，10GB 量级的数据查询基本上可以做到秒级返回。高性能的代价是功能上的缩水，这些数据库对数据的组织方式都有一些限制，查询方式上也没有 MySQL 那么灵活。大多都需要你非常了解这些产品的脾气秉性，按照预定的姿势使用，才能达到预期的性能。

另外一个值得考虑的选择是 Elasticsearch（ES），ES 本来是一个为了搜索而生的存储产品，但是也支持结构化数据的存储和查询。由于它的数据都存储在内存中，并且也支持类似于 Map-Reduce 方式的分布式并行查询，所以对海量结构化数据的查询性能也非常好。

最重要的是，ES 对数据组织方式和查询方式的限制，没有其他列式数据库那么死板。也就是说，ES 的查询能力和灵活性是要强于上述这些列式数据库的。在这个级别的几个选手中，我个人强烈建议你优先考虑 ES。但是 ES 有一个缺点，就是你需要给它准备大内存的服务器，硬件成本有点儿高。

数据量级超过 TB 级的时候，对这么大量级的数据做统计分析，无论使用什么存储系统，都快不到哪儿去。这个时候的性能瓶颈已经是磁盘 IO 和网络带宽了。这种情况下，实时的查询和分析肯定做不了。解决的办法都是，定期把数据聚合和计算好，然后把结果保存起来，在需要时对结果再进行二次查询。这么大量级的数据，一般都选择保存在 HDFS 中，配合 Map-Reduce、Spark、Hive 等等这些大数据生态圈产品做数据聚合和计算。

转变你的思想：根据查询来选择存储系统

面对海量数据，仅仅是根据数据量级来选择存储系统，是远远不够的。

经常有朋友会问：“我的系统，每天都产生几个 GB 的数据量，现在基本已经慢得查不出来了，你说我换个什么数据库能解决问题呢？”那我的回答都是，对不起，换什么数据库也解决不了你的问题。为什么这么说呢？

因为在过去的几十年里面，存储技术和分布式技术，在基础理论方面并没有什么本质上突破。技术发展更多的是体现在应用层面上，比如说，集群管理简单，查询更加自动化，像 Map-Reduce 这些。不同的存储系统之间，并没有本质的差异。它们的区别只是，存储引擎的数据结构、存储集群的构建方式，以及提供的查询能力，这些方面的差异。这些差异，使得每一种存储，在它擅长的一些领域或者场景下，会有很好的性能表现。

比如说，最近很火的 RocksDB、LevelDB，它们的存储结构 LSM-Tree，其实就是日志和跳表的组合，单从数据结构的时间复杂度上来说，和“老家伙”MySQL 采用的 B+ 树，有本质的提升吗？没有吧，时间复杂度都是 O(log n)。但是，LSM-Tree 在某些情况下，它利用日志有更好的写性能表现。没有哪种存储能在所有情况下，都具有明显的性能优势，所以说，存储系统没有银弹，不要指望简单地更换一种数据库，就可以解决数据量大，查询慢的问题。

但是，在特定的场景下，通过一些优化方法，把查询性能提升几十倍甚至几百倍，这个都是有可能的。这里面有个很重要的思想就是，根据查询来选择存储系统和数据结构。我们前面的课程《06 | 如何用 Elasticsearch 构建商品搜索系统》，就是把这个思想实践得很好的一个例子。ES 采用的倒排索引的数据结构，并没有比 MySQL 的 B+ 树更快或者说是更先进，但是面对“全文搜索”这个查询需求，选择使用 ES 的倒排索引，就比使用其他的存储系统和数据结构，性能上要高出几十倍。

再举个例子，大家都知道，京东的物流速度是非常快的。经常是，一件挺贵的衣服，下单之后，还没来得及后悔，已经送到了。京东的物流之所以能做到这么快，有一个很重要的原因是，它有一套智能的补货系统，根据历史的物流数据，对未来的趋势做出预测，来给全国每个仓库补货。这样京东就可以做到，你下单买的商品，很大概率在离你家几公里那个京东仓库里就有货，这样自然很快就送到了。这个系统的背后，它需要分析每天几亿条物流数据，每条物流数据又细分为几段到几十段，那每天的物流数据就是几十亿的量级。

这份物流数据，它的用途也非常多，比如说，智能补货系统要用；调度运力的系统也要用；评价每个站点儿、每个快递小哥的时效达成情况，还要用这个数据；物流规划人员同样要用这个数据进行分析，对物流网络做持续优化。

那用什么样的存储系统保存这些物流数据，才能满足这些查询需求呢？显然，任何一种存储系统，都满足不了这么多种查询需求。我们需要根据每一种需求，去专门选择合适的存储系统，定义适合的数据结构，各自解决各自的问题。而不是用一种数据结构，一个数据库去解决所有的问题。

对于智能补货和运力调度这两个系统，它的区域性很强，那我们可以把数据按照区域（省或者地市）做分片，再汇总一份全国的跨区物流数据，这样绝大部分查询都可以落在一个分片上，查询性能就会很好。

对于站点儿和人的时效达成情况，这种业务的查询方式以点查询为主，那可以考虑事先在计算的时候，按照站点儿和人把数据汇总好，存放到一些分布式 KV 存储中，基本上可以做到毫秒级查询性能。而对于物流规划的查询需求，查询方式是多变的，可以把数据放到 Hive 表中，按照时间进行分片。

我们之前也讲到过，按照时间分片是对查询最友好的分片方式。物流规划人员可以在上面执行一些分析类的查询任务，一个查询任务即使是花上几个小时，用来验证一个新的规划算法，也是可以接受的。

小结

海量数据的主要用途就是支撑离线分析类业务的查询，根据数据量规模不同，由小到大可以选择：关系型数据库，列式数据库和一些大数据存储系统。对于 TB 量级以下的数据，如果可以接受相对比较贵的硬件成本，ES 是一个不错的选择。

对于海量数据来说，选择存储系统没有银弹，重要的是转变思想，根据业务对数据的查询方式，反推数据应该使用什么存储系统、如何分片，以及如何组织。即使是同样一份数据，也要根据不同的查询需求，组织成不同的数据结构，存放在适合的存储系统中，才能在每一种业务中都达到理想的查询性能。

思考题

今天的课后思考题是这样的，我们要做一个日志系统，收集全公司所有系统的全量程序日志，给开发和运维人员提供日志的查询和分析服务，你会选择用什么存储系统来存储这些日志？原因是什么？欢迎你在留言区与我讨论。

感谢你的阅读，如果你觉得今天的内容对你有帮助，也欢迎把它分享给你的朋友。

23 | MySQL经常遇到的高可用、分片问题，NewSQL是如何解决的？

你好，我是李玥。

在这个系列课程中，我们讲的都是如何解决生产系统中面临的一些存储系统相关的问题。在最后两节课里面，我们来说点儿新东西，看一下存储这个技术领域，可能会有哪些值得关注的新技术。当然，技术圈每天都有很多新的技术出现，也会经常发很多论文，出现很多的开源项目，这些大多数都不太靠谱儿。

今天我给你要说的这个 New SQL，它是我个人认为非常靠谱，甚至在未来可能会取代 MySQL 这样的关系型数据库的一个技术。MySQL 是几乎每一个后端开发人员必须要精通的数据库，既然 New SQL 非常有可能在将来替代 MySQL，那我们就非常有必要提前去了解一下了。

什么是 New SQL？

什么是 New SQL？这个说来话长了，还要从存储技术发展的历史来解读。我们知道，早期只有像 MySQL 这样的关系数据库，这种关系型数据库因为支持 SQL 语言，后来被叫做 SQL 或者 Old SQL。

然后，出现了 Redis 和很多 KV 存储系统，性能上各种吊打 MySQL，而且因为存储结构简单，所以比较容易组成分布式集群，并且能够做到水平扩展、高可靠、高可用。因为这些 KV 存储不支持 SQL，为了以示区分，被统称为 No SQL。

No SQL 本来希望能凭借高性能和集群的优势，替代掉 Old SQL。但用户是用脚投票的，这么多年实践证明，你牺牲了 SQL 这种强大的查询能力和 ACID 事务支持，用户根本不买账，直到今天，Old SQL 还是生产系统中最主流的数据库。

这个时候，大家都开始明白了，无论你其他方面做的比 Old SQL 好再多，SQL 和 ACID 是刚需，这个命你革不掉的。你不支持 SQL，就不会有多少人用。所以你看，近几年很多之前不支持 SQL 的数据库，都开始支持 SQL 了，甚至于像 Spark、Flink 这样的流计算平台，也都开始支持 SQL。当然，虽然说支持 SQL，但这里面各个产品的支持程度是参差不齐的，多多少少都有一些缩水。对于 ACID 的支持，基本上等同于就没有。

这个时候，New SQL 它来了！简单地说，New SQL 就是兼顾了 Old SQL 和 No SQL 的优点：

完整地支持 SQL 和 ACID，提供和 Old SQL 隔离级别相当的事务能力；

高性能、高可靠、高可用，支持水平扩容。

像 Google 的 Cloud Spanner、国产的 OceanBase 以及开源的CockroachDB都属于 New SQL 数据库。Cockroach 这个英文单词是蟑螂的意思，所以一般我们都把 CockroachDB 俗称为小强数据库。

这些 New SQL 凭什么就能做到 Old SQL 和 No SQL 做不到的这些特性呢？那我们就以开源的 CockroachDB 为例子，来看一下 New SQL 是不是真的这么厉害。

CockroachDB 是如何实现数据分片和弹性扩容的？

首先，我们一起先来简单看一下 CockroachDB 的架构，从架构层面分析一下，它是不是真的像宣传的那么厉害。我们先来看一下它的架构图 (图片来自于官方文档)：

这是一个非常典型的分层架构，我们从上往下看。最上层是 SQL 层，SQL 层支持和关系型数据库类似的逻辑数据结构，比如说库、表、行和列这些逻辑概念。SQL 层向下调用的是一个抽象的接口层 Structured Data API，实际实现这个 API 的是下面一层：Distributed, Monolithic KV Store，这就是一个分布式的 KV 存储系统。

我们先不深入进去看细节，从宏观层面上分析一下这个架构。你可以看到，这个架构仍然是我们之间讲过的，大部分数据库都采用的二层架构：执行器和存储引擎。它的 SQL 层就是执行器，下面的分布式 KV 存储集群就是它的存储引擎。

那我们知道，MySQL 的存储引擎 InnoDB，实际上是基于文件系统的 B+ 树，像 Hive 和 HBase，它们的存储引擎都是基于 HDFS 构建的。那 CockroachDB 这种，使用分布式 KV 存储来作为存储引擎的设计，理论上也是可行的，并没有什么特别难以逾越的技术壁垒。

而且，使用分布式 KV 存储作为存储引擎，实现高性能、高可靠、高可用，以及支持水平扩容这些特性，就不是什么难事儿了，其中很多分布式 KV 存储系统已经做到了，这里面使用的一些技术和方法，大多我们在之前的课程中也都讲到过。CockroachDB 在实现它的存储引擎这一层，就是大量地借鉴，甚至是直接使用了已有的一些成熟技术。

它的分片算法采用的是范围分片，我们之前也讲到过，范围分片对查询是最友好的，可以很好地支持范围扫描这一类的操作，这样有利于它支撑上层的 SQL 查询。

它采用Raft一致性协议来实现每个分片的高可靠、高可用和强一致。这个 Raft 协议，它的一个理论基础，就是我们之前讲的复制状态机，并且在复制状态机的基础上，Raft 实现了集群自我监控和自我选举来解决高可用的问题。Raft 也是一个被广泛采用的、非常成熟的一致性协议，比如 etcd 也是基于 Raft 来实现的。

CockroachDB 的元数据直接分布在所有的存储节点上，依靠流言协议来传播，这个流言协议，我们在《16 | 用 Redis 构建缓存集群的最佳实践有哪些？》这节课中也讲到过，在 Redis Cluster 中也是用流言协议来传播元数据变化的。

CockroachDB 用上面这些成熟的技术解决了集群问题，在单机的存储引擎上，更是直接使用了 RocksDB 作为它的 KV 存储引擎。RocksDB 也是值得大家关注的一个新的存储系统，下节课我们会专门讲 RocksDB。

你可以看到，CockroachDB 的存储引擎，也就是它的分布式 KV 存储集群，基本上没有什么大的创新，就是重用了已有的一些成熟的技术，这些技术在我们之前讲过的其他存储系统中，全部都见到过。我讲这些并没有贬低 CockroachDB 的意思，相反，站在巨人的肩膀上，才能看得更远，飞得更高，这是一种非常务实的做法。

CockroachDB 能提供金融级的事务隔离性么？

接下来我们说一下 CockroachDB 是怎么实现 ACID 的，它的 ACID 是不是类似于分布式事务的残血版？这是一个非常关键的问题，直接影响到它有没有可能在未来取代 MySQL。

在说 ACID 之前，我们还是要简单说一下 CockroachDB 是怎么解析和执行 SQL 的。我们在《10 | 走进黑盒：SQL 是如何在数据库中执行的？》这节课中讲过 SQL 是如何在 MySQL 中执行的，在 CockroachDB 中，这个执行的流程也是差不多的。同样是先解析 SQL 生成语法树，转换成逻辑执行计划，再转换为物理执行计划，优化后，执行物理执行计划返回查询结果，这样一个流程。

只是在 CockroachDB 中，物理执行计划就更加复杂了，因为它的物理执行计划面对的是一个分布式 KV 存储系统，在涉及到查找、聚合这类操作的时候，有可能需要涉及到多个分片（Range）。大致过程就是类似于 Map-Reduce 的逻辑，先查找元数据确定可能涉及到的分片，然后把物理执行计划转换成每个分片上的物理执行计划，在每个分片上去并行执行，最后，再对这些执行结果做汇总。

然后我们再来说 CockroachDB 的 ACID。我们在《04 | 事务：账户余额总是对不上账，怎么办？》这节课中讲到过四种事务隔离级别，分别是 RU、RC、RR 和 SERIALIZABLE，那 CockroachDB 能提供哪种隔离级别呢？答案是，以上四种都不是。

CockroachDB 提供了另外两种隔离级别，分别是：Snapshot Isolation (SI) 和 Serializable Snapshot Isolation (SSI)，其中 SSI 是 CockroachDB 默认的隔离级别。

这两种隔离级别和之前提到的四种隔离级别是什么关系呢？我们通过下面这张表，和 MySQL 默认的隔离级别 RR 做一个对比。

首先我们看 SI 这一行。我们之前讲到过，RR 这种隔离级别，可以很好地解决脏读和不可重复读的问题，虽然可能会产生幻读，但实际上对绝大多数事务影响不大。SI 不会发生脏读、不可重复读，也不会发生幻读的情况，这个隔离级别似乎比 RR 还要好。

但你要注意一下，我们这个表格比之前那个表格多了一列：写倾斜。可以看到，RR 是不会出现写倾斜问题的，但是 SI 会有写倾斜问题。

什么是写倾斜？我们还是拿账户余额的例子来说明。比如说，我们的账户需要支持主副卡，主卡和副卡都分别有自己的余额，并且这个余额是可以透支的，只要满足主副卡的余额之和大于 0 就行了。如果我们需要给主卡支出 100 元，那 SQL 只要这样写就可以了：

update account
set balance = balance - 100 -- 在主卡中扣减100元
where id = ? and
  (select balance from account where id = ?) -- 主卡余额
  +
  (select balance from account where id = ?) -- 附卡余额
  >= 100; -- 主副卡余额之和必须大于100元

在传统的 RR 隔离级别下，由于更新数据时会对记录加锁，即使更新主副卡的两个 SQL 分别在两个事务中并发执行，也不会出现把主副卡的余额之和扣减成负数的情况。

但是在 SI 级别下，由于它没有加锁，而是采用快照的方式来实现事务的隔离，这个时候，如果并发地去更新主副卡余额，是有可能出现把主副卡余额之和扣减为负数的情况的。这种情况称为写倾斜。这里顺便提一句，写倾斜是普遍的译法，我个人觉得“倾斜”这个词翻译得并不准确，实际上它表达的，就是因为没有检测读写冲突，也没有加锁，导致数据写错了。

SSI 隔离级别在 SI 的基础上，加入了冲突检测的机制，通过检测读写冲突，然后回滚事务的方式来解决写倾斜的问题，当然这种方式付出的代价是降低性能，并且冲突严重的情况下，会频繁地出现事务回滚。

从理论上来说，CockroachDB 支持的 SI 和 SSI 这两种事务隔离级别，能提供的事务隔离性，已经与传统的 RC 和 RR 隔离级别不相上下了，可以满足大多数在线交易类系统对 ACID 的要求。

小结

New SQL 是新一代的分布式数据库，它具备原生分布式存储系统高性能、高可靠、高可用和弹性扩容的能力，同时还兼顾了传统关系型数据库的 SQL 支持。更厉害的是，它还提供了和传统关系型数据库不相上下的、真正的事务支持，具备了支撑在线交易类业务的能力。

CockroachDB 是开源的 New SQL 数据库。它的存储引擎是一个分布式 KV 存储集群，执行器则大量借鉴了 PostgreSQL 的一些设计和实现，是一个集很多现有数据库和分布式存储系统技术于一身，这样的一个数据库产品。

从设计上来看，CockroachDB 这类 New SQL 数据库，有非常大的潜质可以真正地取代 MySQL 这类传统的关系型数据库。但是我们也应该看到，目前这些 New SQL 数据库都还处于高速发展阶段，并没有被大规模地应用到生产系统中去。我也不建议你做小白鼠，在重要的系统上去使用它。

思考题

课后请你去看一下Raft一致性协议，然后简单总结一下，CockroachDB 是如何利用 Raft 协议实现 Range 高可用、高可靠和强一致的。欢迎你在留言区与我讨论。

感谢你的阅读，如果你觉得今天的内容对你有帮助，也欢迎把它分享给你的朋友。

24 | RocksDB：不丢数据的高性能KV存储

你好，我是李玥。

上节课我们在讲解 CockroachDB 的时候提到过，CockroachDB 的存储引擎是一个分布式的 KV 存储集群，它用了一系列成熟的技术来解决集群问题，但是在集群的每个节点上，还需要一个单机的 KV 存储来保存数据，这个地方 CockroachDB 直接使用 RocksDB 作为它的 KV 存储引擎。

RocksDB是 Facebook 开源的一个高性能持久化 KV 存储。目前，你可能很少见到过哪个项目会直接使用 RocksDB 来保存数据，在未来，RocksDB 大概率也不会像 Redis 那样被业务系统直接使用。那我们为什么要关注它呢？

因为越来越多的新生代数据库，都不约而同地选择 RocksDB 作为它们的存储引擎。在将来，很有可能出现什么样的情况呢？我们使用的很多不同的数据库，它们背后采用的存储引擎都是 RocksDB。

我来给你举几个例子。我们上节课讲到的 CockroachDB 用到了 RocksDB 作为它的存储引擎。再说几个比较有名的，MyRocks这个开源项目，你看它这个名字就知道它是干什么的了。它在用 RocksDB 给 MySQL 做存储引擎，目的是取代现有的 InnoDB 存储引擎。并且，MySQL 的亲兄弟 MariaDB 已经接纳了 MyRocks，作为它的一个可选的存储引擎。还有大家都经常用的实时计算引擎Flink，用过的同学都知道，Flink 的 State 就是一个 KV 的存储，它使用的也是 RocksDB。还有包括 MongoDB、Cassandra 等等很多的数据库，都在开发基于 RocksDB 的存储引擎。

今天这节课，我们就一起来了解一下 RocksDB 这颗“未来之星”。

同样是 KV 存储，RocksDB 有哪些不同？

说到 KV 存储，我们最熟悉的就是 Redis 了，接下来我们就来对比一下 RocksDB 和 Redis 这两个 KV 存储。

其实 Redis 和 RocksDB 之间没什么可比性，一个是缓存，一个是数据库存储引擎，放在一起比就像“关公战秦琼”一样。那我们把这两个 KV 放在一起对比，目的不是为了比谁强谁弱，而是为了让你快速了解 RocksDB 能力。

我们知道 Redis 是一个内存数据库，它之所以能做到非常好的性能，主要原因就是，它的数据都是保存在内存中的。从 Redis 官方给出的测试数据来看，它的随机读写性能大约在 50 万次 / 秒左右。而 RocksDB 相应的随机读写性能大约在 20 万次 / 秒左右，虽然性能还不如 Redis，但是已经可以算是同一个量级的水平了。

这里面你需要注意到的一个重大差异是，Redis 是一个内存数据库，并不是一个可靠的存储。数据写到内存中就算成功了，它并不保证安全地保存到磁盘上。而 RocksDB 它是一个持久化的 KV 存储，它需要保证每条数据都要安全地写到磁盘上，这也是很多数据库产品的基本要求。这么一比，我们就看出来 RocksDB 的优势了，我们知道，磁盘的读写性能和内存读写性能差着一两个数量级，读写磁盘的 RocksDB，能和读写内存的 Redis 做到相近的性能，这就是 RocksDB 的价值所在了。

RocksDB 为什么能在保证数据持久化的前提下，还能做到这么强的性能呢？我们之前反复讲到过，一个存储系统，它的读写性能主要取决于什么？取决于它的存储结构，也就是数据是如何组织的。

RocksDB 采用了一个非常复杂的数据存储结构，并且这个存储结构采用了内存和磁盘混合存储方式，使用磁盘来保证数据的可靠存储，并且利用速度更快的内存来提升读写性能。或者说，RocksDB 的存储结构本身就自带了内存缓存。

那我们知道，内存缓存可以很好地提升读性能，但是写入数据的时候，你是绕不过要写磁盘的。因为，要保证数据持久化，数据必须真正写到磁盘上才行。RocksDB 为什么能做到这么高的写入性能？还是因为它特殊的数据结构。

大多数存储系统，为了能做到快速查找，都会采用树或者哈希表这样的存储结构，数据在写入的时候，必须写入到特定的位置上。比如说，我们在往 B+ 树中写入一条数据，必须按照 B+ 树的排序方式，写入到某个固定的节点下面。哈希表也是类似，必须要写入到特定的哈希槽中去。

这些数据结构会导致在写入数据的时候，不得不在磁盘上这里写一点儿，再去那里写一点儿，这样跳来跳去地写，也就是我们说的“随机写”。而 RocksDB 它的数据结构，可以让绝大多数写入磁盘的操作都是顺序写。那我们知道，无论是 SSD 还是 HDD 顺序写的性能都要远远好于随机写，这就是 RocksDB 能够做到高性能写入的根本原因。

那我们在《21 | 类似“点击流”这样的海量数据应该如何存储？》这节课中讲到过，Kafka 也是采用顺序读写的方式，所以它的读写性能也是超级快。但是这种顺序写入的数据基本上是没法查询的，因为数据没有结构，想要查询的话，只能去遍历。RocksDB 究竟使用了什么样的数据结构，在保证数据顺序写入的前提下还能兼顾很好的查询性能呢？这种数据结构就是 LSM-Tree。

LSM-Tree 如何兼顾读写性能？

LSM-Tree 的全称是：The Log-Structured Merge-Tree，是一种非常复杂的复合数据结构，它包含了 WAL（Write Ahead Log）、跳表（SkipList）和一个分层的有序表（SSTable，Sorted String Table）。下面这张图就是 LSM-Tree 的结构图（图片来自于论文: An Efficient Design and Implementation of LSM-Tree based Key-Value Store on Open-Channel SSD）

看起来非常复杂是吧？实际上它的结构比这个图更复杂。那我们尽量忽略没那么重要的细节，把它的核心原理讲清楚。首先需要注意的是，这个图上有一个横向的实线，是内存和磁盘的分界线，上面的部分是内存，下面的部分是磁盘。

我们先来看数据是如何写入的。当 LSM-Tree 收到一个写请求，比如说：PUT foo bar，把 Key foo 的值设置为 bar。首先，这条操作命令会被写入到磁盘的 WAL 日志中（图中右侧的 Log），这是一个顺序写磁盘的操作，性能很好。这个日志的唯一作用就是用于故障恢复，一旦系统宕机，可以从日志中把内存中还没有来得及写入磁盘的数据恢复出来。这个地方用的还是之前我们多次讲过的复制状态机理论。

写完日志之后，数据可靠性的问题就解决了。然后数据会被写入到内存中的 MemTable 中，这个 MemTable 就是一个按照 Key 组织的跳表（SkipList），跳表和平衡树有着类似的查找性能，但实现起来更简单一些。写 MemTable 是个内存操作，速度也非常快。数据写入到 MemTable 之后，就可以返回写入成功了。这里面有一点需要注意的是，LSM-Tree 在处理写入的过程中，直接就往 MemTable 里写，并不去查找这个 Key 是不是已经存在了。

这个内存中 MemTable 不能无限地往里写，一是内存的容量毕竟有限，另外，MemTable 太大了读写性能都会下降。所以，MemTable 有一个固定的上限大小，一般是 32M。MemTable 写满之后，就被转换成 Immutable MemTable，然后再创建一个空的 MemTable 继续写。这个 Immutable MemTable，也就是只读的 MemTable，它和 MemTable 的数据结构完全一样，唯一的区别就是不允许再写入了。

Immutable MemTable 也不能在内存中无限地占地方，会有一个后台线程，不停地把 Immutable MemTable 复制到磁盘文件中，然后释放内存空间。每个 Immutable MemTable 对应一个磁盘文件，MemTable 的数据结构跳表本身就是一个有序表，写入的文件也是一个按照 Key 排序的结构，这些文件就是 SSTable。把 MemTable 写入 SSTable 这个写操作，因为它是把整块内存写入到整个文件中，这同样是一个顺序写操作。

到这里，虽然数据已经保存到磁盘上了，但还没结束，因为这些 SSTable 文件，虽然每个文件中的 Key 是有序的，但是文件之间是完全无序的，还是没法查找。这里 SSTable 采用了一个很巧妙的分层合并机制来解决乱序的问题。

SSTable 被分为很多层，越往上层，文件越少，越往底层，文件越多。每一层的容量都有一个固定的上限，一般来说，下一层的容量是上一层的 10 倍。当某一层写满了，就会触发后台线程往下一层合并，数据合并到下一层之后，本层的 SSTable 文件就可以删除掉了。合并的过程也是排序的过程，除了 Level 0（第 0 层，也就是 MemTable 直接 dump 出来的磁盘文件所在的那一层。）以外，每一层内的文件都是有序的，文件内的 KV 也是有序的，这样就比较便于查找了。

然后我们再来说 LSM-Tree 如何查找数据。查找的过程也是分层查找，先去内存中的 MemTable 和 Immutable MemTable 中找，然后再按照顺序依次在磁盘的每一层 SSTable 文件中去找，只要找到了就直接返回。这样的查找方式其实是很低效的，有可能需要多次查找内存和多个文件才能找到一个 Key，但实际的效果也没那么差，因为这样一个分层的结构，它会天然形成一个非常有利于查找的情况：越是被经常读写的热数据，它在这个分层结构中就越靠上，对这样的 Key 查找就越快。

比如说，最经常读写的 Key 很大概率会在内存中，这样不用读写磁盘就完成了查找。即使内存中查不到，真正能穿透很多层 SStable 一直查到最底层的请求还是很少的。另外，在工程上还会对查找做很多的优化，比如说，在内存中缓存 SSTable 文件的 Key，用布隆过滤器避免无谓的查找等来加速查找过程。这样综合优化下来，可以获得相对还不错的查找性能。

小结

RocksDB 是一个高性能持久化的 KV 存储，被很多新生代的数据库作为存储引擎。RocksDB 在保证不错的读性能的前提下，大幅地提升了写入性能，这主要得益于它的数据结构：LSM-Tree。

LSM-Tree 通过混合内存和磁盘内的多种数据结构，将随机写转换为顺序写来提升写性能，通过异步向下合并分层 SSTable 文件的方式，让热数据的查找更高效，从而获得还不错的综合查找性能。

通过分析 LSM-Tree 的数据结构可以看出来，这种数据结构还是偏向于写入性能的优化，更适合在线交易类场景，因为在这类场景下，需要频繁写入数据。

思考题

我们刚刚讲了 LSM-Tree 是如何读写数据的，但是并没有提到数据是如何删除的。课后请你去看一下 RocksDB 或者是 LevelDB 相关的文档，总结一下 LSM-Tree 删除数据的过程，也欢迎你在留言区分享你的总结。

感谢你的阅读，如果你觉得今天的内容对你有帮助，也欢迎把它分享给你的朋友。