Post

DDIA笔记-第七章-分布式数据-事务

DDIA笔记-第七章-分布式数据-事务

回顾

对于我自己目前的水平,日常工作以 CURD 来实现业务功能,

向下采用不同的 DB 存储数据,向上定义和提供不同的 API 来提供服务,

偶尔遇到一些重要数据需要复制,

偶尔遇到一些数据量较大的,需要考虑分区,

事务虽然有使用,但是并未深究其实现原理。

本书前面的部分基本总结了我实际的经历,

接下来是更高一个层次的问题的讨论,

先回顾一下前面的内容

  • 数据系统的指标/目标是什么?
  • 数据如何存储以及如何查询?包括理论模型和实现方案
    • 我把关系模型/网状模型,查询语言等等称为理论
    • 我把 SSTable/B 树等存储方案等称为实现方案
  • 除了存储在磁盘的数据之外,每个环节都需要传递数据,他们就需要编码
  • 数据安全和数据量变大,如何应对,复制和分区

这些内容已经可以构成一个系统,已经不会轻易的回头打破前面讨论的结果,更多是做出选择,

接下里讨论的是在这个系统内遇到的麻烦,以及解决方案

基本认识

事务就是组合多个读写操作,

  • 要么提交 (commit)成功表示全部操作成功,
  • 要么中止 (abort)或 回滚 (rollback)表示全部失败,

这是为了简化应用编程模型 而创建的。

ACID

ACID = 原子性(Atomicity)一致性(Consistency)隔离性(Isolation)持久性(Durability)

事实上,现在所谓的符合 ACID 只是营销术语,各家的实现中都一些坑在里面。

  • 原子性
    • 物理上表示这个东西无法再切分
    • 多线程编程中,原子性操作表示只能看到操作之前或之后的状态,不会看到一半的状态
      • 经常通过锁来实现,操作中锁住不让访问,但并没有回滚这种概念
    • 而数据库中表示:如果发生错误,将丢失整个事务所有操作。
      • 个人强行理解为:错误是原子性的,要么成功,要么全失败,不存在失败一半的情况。
  • 一致性
    • “一致性”这个词汇可能因为听起来很厉害,被赋予太多含义,在 ACID 也很“怪”
    • ACID 一致性的概念是, 对数据的一组特定约束必须始终成立 ,即 不变式(invariants)
    • 这更多是应用程序的属性,表示我这个数据应该符合怎么样的业务条件
    • 但数据库只负责存储,提供数据的约束非常有限,程序写入一个脏数据时数据库根本阻止不了
    • 原文提出:应用可能依赖数据库的原子性和隔离性来实现一致性,但这并不仅取决于数据库。因此,字母 C 不属于 ACID。
  • 隔离性
    • 概念上,隔离性意味着, 同时执行的事务是相互隔离的 :它们不能相互冒犯。
    • 传统的数据库教科书将隔离性形式化为 可串行化(Serializability),则并行结果和串行结果一致
    • 但事实上各家的实现都未必如此,Oracle 实现了一个名为“可串行的”隔离级别,但其实是“快照隔离”的功能,比可串行化更弱的保证。
  • 持久性
    • 事务返回成功,保证已经写入磁盘,包括写好了预写日志等用于恢复的数据
    • 有时还提供保证:在复制的数据库中,保证数据已经复制到其他节点(一些?至少一个?)
    • 没有真正意义持久性,比如异步复制中丢失最近的写操作,比如所有机器一起丢失数据

总结一下:

一致性实际上是应用程序基于原子性和隔离性来实现的,

而持久性是存储安全的保证,业务编程时不需要太关心,

所以对于我们编程来说,考虑更多的是原子性和隔离性。

单对象和多对象操作

  • 单对象
    • 原子性
      • 可以通过日志来实现,这在第三章 B 树里讨论过方法
      • 自增操作,一些数据库提供了,实现上相对复杂
      • 比较和设置(CAS, compare-and-set),检查值没有被并发修改过时,才执行写。是一种乐观锁。
    • 而隔离性可以通过锁来实现
    • 注意:严谨来说,单个对象的保证算不上事务乃至 ACID
  • 多对象
    • 事务通常被理解为, 将多个对象上的多个操作合并为一个执行单元的机制
    • 原文列举了一些场景,证明事务在很多情况下是需要的
    • 处理错误和中止
      • ACID 的处理方式是,失败则全部放弃的方式
      • 但有些系统,特别是无主复制的数据存储,更倾向于“尽力而为”

弱隔离级别

首先编程时考虑单个用户的业务流程已经很麻烦了,

如果要考虑并发,数据随时都在改变,就极其困难了,

所以数据库提供事务隔离,是试图让并发变成“假装没有并发发生”,

但事实上没那么简单,不同隔离级别对应不同的性能损失。

接下来,先讨论在弱隔离级别之下,如何规避掉一些问题,确认哪些问题是对事务隔离的刚需

读已提交(Read Committed)

最基本的事务隔离级别是 读已提交(Read Committed),它提供了两个保证:

  • 从数据库读时,只能看到已提交的数据(没有 脏读 ,即 dirty reads)。
    • 脏读:事务 A 进行中写入一些数据,未提交。事务 B 读取到了这些数据,则脏读

  • 写入数据库时,只会覆盖已提交的数据(没有 脏写 ,即 dirty writes)。
    • 脏写:事务 A 比 B 早开始但晚结束。修改了同一个数据。
      • 操作结束先后是程序本身速度和并行之间的调度等问题,实际上事件 A 发生早于 B。
      • 一般认为应该用最后修改的数据,常理应该是事务 B 的值。
      • 但是由于事务 A 比较晚提交,导致数据最后采用了事务 A 的值,称为脏写。
    • 例子:事务 A 和 B 修改同一个数据 new = old + 1
      • 先不要考虑 x = x + 1这种优化,而是简单的,读取数据库的值,代码计算+1,写入数据库。
      • 他们各自都认为自己是 old 都是 1,然后+1,两个事务提交都是 set = 2,但实际代码需要最后结果为 3 才合理
      • 这不算脏写,从数据库的角度它没有做错什么,但最终结果仍然是错误的。应用程序需要自己想办法处理。

脏写的实现一般行锁,脏读也可以锁,但是太影响只读事务的响应时间。

脏读一般在内部实现,数据库同时持有已提交的值 A,和未提交的新值 B,写事务内部返回 B,而其他事务返回 A。直到写事务提交。

快照隔离和可重复读

这里简单文字可能无法快速理解案例了,可能需要翻原文结合图片了。

还是简单试试描述,想象我两个银行卡互相转账:

  • 事务 A 读取账号 1 值为 500
  • 事务 B 对账号 1 操作 500-100 = 400
  • 事务 B 对账号 2 操作 500+100 = 600,转账完成,提交事务
  • 事务 A 读取账号 2 值为 600,两个账号的总额为 1100?

在这个案例里视乎只需要刷新一下就能获得准确数据,

但是如果在备份或者分析统计里发生这样的错误,这个错误就变成了永久性的。

并且因为不确定哪个数据会发生,数据库也变得不可靠,不可信赖。

问题根源是,事务 A 两次读取的是两个时间不一致的数据库,

想要解决,则需要让事务 A 读取到的是“事务 A 开始时的数据库”。

快照隔离(snapshot isolation) 是最常见的方案。

实现快照隔离

脏写同样是用锁来确保的。

脏读问题,只要我们实现了快照隔离,那么事务读取的是事务开始时的整个数据库版本,则不存在脏读了。

我们先考虑“读己提交”中的机制:数据库持有一个对象的多个版本,已提交的版本和被覆盖但未提交的版本。

这种机制称为多版本并发控制(MVCC, multi-version concurrency control)

然后把这种机制使用的范围从“每个查询使用单独的快照”扩大到“整个事务都使用相同的快照”

具体实现方案和细节要翻原文,这里用自己的话描述一次:

首先,当我们要在事务内修改数据时,在对应的数据上添加一份记录,

记录事务的 id,修改的内容,当前的值,

这时我们已经拥有了查询这个数据不同版本的的能力了,

结合事务 ID 是“永远增长”的,可以判断当前事务应该访问哪个版本,

其实这个记录也很像 SSTable 等日志结构存储,SSTable 根据“日志”查询一个 key 的最新值,这里根据“记录”查询特定版本的值。

这样的思路就实现了快照,至于细节如何实现,看原文。

可重复读与命名混淆

概念还要结合历史来说明为什么命名会混淆,我们只需要大概知道,

Oracle 中可串行化(Serializable) 就是快照隔离,

PostgreSQL 和 MySQL 中可重复读(repeatable read) 也是。

于是可重复读从提出,到具体实现,到宣传使用该词汇,

他们都包含了不同的意义,或者没有完全实现功能,

导致其实没人真正知道可重复读的意思。

我们就当可重复读=快照隔离即可,通过快照这个概念,通过实现原理来得出他提供的特性。

防止丢失更新

上面举例到:事务 A 和 B 修改同一个数据 new = old + 1

这不算脏写,但这是错误的,可以概括为丢失了一些更新操作。

  • 原子写
    • 则提供 set a = a + 1这种语法
    • 一般是通过排他锁来实现
    • 也有更简单的方案,就是单线程
    • 如果使用 ORM 框架,非常容易“没有用上原子写”而造成数据不安全的读写。
  • 显示锁定
    • FOR UPDATE 加写锁
    • LOCK IN SHARE MODE / FOR SHARE 加读锁,不能解决上面举例的错误
    • 锁的分类
      • 悲观/乐观
        • 所谓的悲观锁,在 SELECT 语句中显示加上 FOR UPDATE 来加锁
          • 读锁不能解决上面举例的错误
          • 注意这里几乎全文都在事务内讨论,在不开事务时加锁没有任何意义。
        • 乐观锁算不上真的锁,是在 UPDATE 是加上值得判断,确认符合业务一开始查询的情况
          • 不开事务使用乐观锁策略是有意义的
        • 而原子写操作是数据库内部用锁实现了,但是这个实现对于编程人员是隐藏的,我更倾向于这是绕过了锁
      • 读/写
        • 读锁=共享锁,可以同时加读锁,不能加写锁。此时写锁要等没人读才能进入。
        • 写锁=排他锁,不可加读锁,不可加写锁,是独享的。
          • 锁本身的定义确实是不可加读锁,但是数据库有 MVCC,所以数据可读,读的是已提交的版本。
        • 悲观锁不在这个维度,他只是表达业务中修改一个数据时考虑悲观的情况而去加锁,这种策略。
    • 不加 FOR UPDATE 的 SELECT
      • 在“读已提交”和“快照隔离”等级下
        • 默认不加锁,因为没必要加锁,他们有 MVCC 版本控制
        • 但 MVCC 只保证你当前的程序数据一致性
        • 你自己需要预判“丢失更新”的情况,加锁是应对并发的问题
        • 也可以简单理解为,在弱隔离等级下,利用锁,小范围地显式地获得可串行化的特性
      • SERIALIZABLE(可串行化)等级下
        • 默认加上 LOCK IN SHARE MODE,得到共享锁/读锁
  • 自动检测丢失的更新
    • 原子写和锁,其实都是实现了小范围串行化
    • 也有方法允许它们并行执行,事务本身能检测丢失更新的发生,中止事务,让应用程序重新读取新值再计算和写入。
    • 原文提及 MySQL 的可重复读不会检测丢失更新,所以也可以说 MySQL 严格来说不提供快照隔离(这些定义和概念真烦)
    • PostgreSQL/Oracle/SQLServer 都支持,但是他们对应的隔离级别不同,命名也不同,要专门查一下
    • 这个功能是极好的,因为它不需要开发人员担心,减少出错。
  • 比较并设置(CAS)
    • 就是乐观锁,上面已经提了一句了
    • 但是但是,要注意使用的数据库的 where 语句内读取的内容是否快照隔离中的“快照”
    • 常用的 MySQL 和 PostgreSQL 在 update … where 语句提供都是当前读
      • 一致性读=快照读,不加锁的 select 查询
      • 当前读=锁定读,UPDATEDELETEINSERT,以及加锁的 SELECT ... FOR UPDATE / LOCK IN SHARE MODE
  • 冲突解决和复制
    • 上面说的锁和 CAS 的技术都基于一个数据副本的情况,
    • 一旦进入到多主和无主复制的数据库,锁和 CAS 手段都失效了。
    • 可用的手段有:
      • 检测并发写入(上面提及了),冲突了记录下来,再去解决和合并冲突
      • 原子操作可以很好的工作
      • 虽然“最后写入胜利 LWW”的方法很容易丢失更新,但是也有很多数据库使用 LWW 作为默认方案

写入偏差与幻读

结合上面已经提到的概念,区分一下

  • 脏写,两个业务更新相同对象,没有事务,因为写的时序问题,导致结果是不合理的
    • 事情 A 先发生,B 后发生,但是处理 A 的程序运行的慢,导致最后入库的值是 A,期望是 B。
  • 丢失更新,两个业务更新相同对象,有事务,但因为读是并发的,导致结果是不合理的
    • 事务 A/B 同时做+1,但是他们读取时都是 0,+1 后写入都是 1,结果是 1,期望是 2。
  • 写入偏差,两个业务更新不同对象,有事务,但因为读是并发的,导致结果不合理
    • 和丢失更新区分开来,是因为原子操作,(快照隔离的实现中)自动检测丢失更新都无法避免写入偏差
    • 经典的例子是:关注点是更新不同对象
      • 医生值班,至少留 1 人值班
      • 两个人都查询了当前有两个人,自己可以下班
      • 各自计算好之后,写入自己的数据,操作的是不同对象
  • 幻读
    • 定义:一个事务中的写入改变另一个事务的搜索查询的结果,被称为 幻读
    • 快照隔离避免了只读查询中幻读,但是在像我们讨论的例子那样的读写事务中,幻读会导致特别棘手的写入偏差情况。
    • 幻读一种技术现象,写入偏差是一种业务逻辑错误,他们不同一个维度,他们可能同时存在,也可能单独存在
      • 先和脏读区分开,脏读是读了别人未提交的数据,这个数据没提交,是脏的
      • 幻读是:读+写的事务中,如果写之前再读一次,则读+读+写,这时两次读取结果不一样
        • 经典例子是:关注点是多次读取的结果不同
          • 会议室预定,两个人都查询了当前可以预定
          • A 预定写入前,B 预定提交了
          • 如果 A 多查询一下,会发现条件变了,不可预定了
        • MVCC 解决了只读时的幻读问题,他们直接读取事务开始时的版本
        • MVCC 解决不了读写的事务,当你写的时候,之前读到的数据状态已经改变了
        • 又可以细分,两次读之间,其他事务做的是插入还是更新
          • 如果是更新,会比较容易找到可以“物化冲突”的对象
          • 如果是插入,你可能没办法“物化冲突”,因为即使你加锁,新插入的行不在锁的范围里
  • 物化冲突
    • 我自己想到的“找共同数据”的思路在书里就叫“物化冲突”
      • 自己的说法:可以根据业务,找到一个共同的数据,通过悲观锁来处理
      • 记忆:写入偏差指的是更新不同对象的情况,方案是把问题变成类似丢失更新,然后悲观锁
      • 有时未必能轻松找到“共同数据”,比如会议室的预定
        • 因为预定还需要包含时间范围的信息,他一般都是一个独立的预定记录表
        • 很难在会议室对象上直接记录预定信息,也很难在人身上记录
        • 你的查询很可能是 select count(*) from bookings where id = xxx and time .....
        • 隔离等级开可串行化性能不接受,这个查询也不好加锁,锁的范围会很广
        • 让应用程序串行化就好了大概就是分布式锁,让这个业务单线程等等手段,就不是数据库层面的事情了
    • 书中的说法:物化冲突,则人为在数据库引入一个锁对象
      • 实际上就是上面的 select for update,即使时 count 也能锁,只是范围可能大,要根据业务情况判断
      • 但实际上业务是复杂的,实际处理起来很难:
        • 意识到幻读了,然后找到合适的对象来物化冲突
        • 还要考虑代码的优雅,尤其是在用 ORM
      • 而且其实未必有这样的数据可以加锁
        • 比如你的判断条件就是“找不到符合条件的数据”,那就没有数据可以利用来加锁
        • 应用程序做分布式锁,既然没有现成数据可以利用,就额外创建一个专用的数据来“物化”
      • 最终手段其实就是可串行化的隔离级别

可串行化

上面讨论的问题虽然已经可以很好的描述清楚,但是实际情况是很难解决的,

即使在限定条件下很容易理解每个概念,但在实际情况下,所谓的“限定条件”可能都难以确定,

更别说出现问题时,也因为这里提及的“难”,大家都更倾向于先排查其他问题,

比如业务逻辑是不是本身有问题,程序本身有 BUG。

分析写入偏差和幻读的难点在于:

  • 隔离级别难以理解,并且在不同的数据库中实现的不一致(例如,“可重复读” 的含义天差地别)。
  • 光检查应用代码很难判断在特定的隔离级别运行是否安全。特别是在大型应用程序中,你可能并不知道并发发生的所有事情。
  • 没有检测竞争条件的好工具。原则上来说,静态分析可能会有帮助【26】,但研究中的技术还没法实际应用。并发问题的测试是很难的,因为它们通常是非确定性的 —— 只有在倒霉的时序下才会出现问题。

最终手段是可串行化隔离级别,相当于不并发,就防止所有可能的竞争条件。

实现思路有:

  • 字面意义上地串行顺序执行事务(请参阅 “真的串行执行”)
  • 两阶段锁定(2PL, two-phase locking) ,几十年来唯一可行的选择(请参阅 “两阶段锁定”)
  • 乐观并发控制技术,例如 可串行化快照隔离 (serializable snapshot isolation,请参阅 “可串行化快照隔离”)

真的串行执行

经典例子就是 Redis

为什么可行,为什么以前不行?

  • 因为内存便宜了,以前数据库要考虑在磁盘上存储并提供足够的性能,但现在内存能把需要的数据全部存下来了
  • 人们意识到 OLTP 事务通常很短,每个事务都很快,串行起来的性能也不觉得差
    • OLTP/OLAP = 事务/分析,后者也对应数据仓库

存储过程

应用程序,包括客户端和服务端,再把指令发送到数据库执行,这些交互过程耗费了大量时间,

如果应用程序只做数据的输入,然后把整个事务代码作为存储过程提交给数据库,

如果事务所需的所有数据都在内存中,则存储过程可以非常快地执行,而不用等待网络和磁盘 IO。

以前来说,这样的思路把原本各种语言编写的业务逻辑都转换成了 SQL 语言,除非团队都是数据库开发的高手,否则…

但是现在像 Redis 使用 lua 实现存储过程,虽然比 SQL 好(书中列举了 SQL 写存储过程的问题所在)

但是依然有很大的限制,除了语言的限制,另外也把业务压力堆积到数据库里了,一旦代码没写好,业务之间就没有隔离可言。

分区

串行化后,顺序执行让整个事情简单很多,但是性能上被限制在单机单核了,

这时,复制,可以提高只读事务的吞吐量,

而,分区,可以提高写入吞吐量,但实际上真的要利用起来,要求很高。

支持这样做的是 VoltDB,我们设置好分区,找到一种分区办法,

这种分区办法要满足:事务只需要在单个分区中读写数据,这样吞吐量就可以和 CPU 核心数量保持线性伸缩。

一旦需要跨分区,为了保证串行化,需要加锁等等协调操作,性能远不如单分区的情况。

所以采用这种方案很大程度取决于应用数据的结构,

只有把功能拆分得很小才有可能,一般的业务系统都有多种复杂的数据结构。

小结

  • 每个事务都必须小而快,否则会拖累到别的事务
  • 只访问内存,不访问磁盘,否则不能快速处理完,性能就跟不上
  • 如果单个 cpu 的性能不足以承担,就要考虑分区,还要找到“不跨区”的“分法”
  • 跨分区事务也是可能的,但是限制很大,对于常见系统来说,很难一般就等于不可行了

两阶段锁定(2PL,two-phase locking)

2PL = 两阶段锁定(不是 2PC 两阶段提交,第九章)

个人-超简陋理解(不正确)

想象这样的代码就行了:读写操作都采用同一个排他锁。

这在学习并发编程时基本都已经写过这样的代码,一般都是先读写同锁,再仔细分为读锁和写锁的。

实现

当然上面是非常简化的说法,实际操作上:

第一阶段只加锁,第二阶段只解锁。也就是说多个对象的加锁和解锁不能穿插。

一般业务都是先读取,加锁可以先读锁,后面要更新时可以升级为写锁,

升级为写锁和直接加写锁一样,需要独占,这里的独占是包含读锁,则一个对象有读锁时也不能上写锁,

锁必须在事务结束时才能释放全部锁,不能中途释放。(严格两阶段锁)

上面逻辑可能会发生死锁,下面举例会出现,而数据库需要自动检测死锁,

中止其中一个,另一个继续执行,被中止的应用程序要重试。

个人-所以 2PL 提供了什么

听起来好像业务代码不用改动,那当然,这是数据库实现可串行化的内部实现原理,学习只是为了参考其设计思路。

按照这个规则,再去看之前的案例,都是解决了的,

这个方法更像是反推的,是灵感得到这个方法然后发现解决了问题(虽然发明者可能是推导出来的,我不知道)

我们也来观察一下之前的一个案例里,两个客户计数+1,发生了什么:

  • 首先如果只考虑 2PL 的定义,我自己直接想的版本是:
    • A 获取读锁,B 获取读锁
    • A 要写所以想上写锁但药等待
    • B 要写所以想上写锁,这时检测到死锁,中止事务 B,也就解锁了
    • A 等待结束,获得写锁,完成事务
  • 所以其实没有人这样“显式获取读锁”(for share),而是 for update
    • A 获取写锁
    • B 获取写锁,要等
    • A 更新数据
    • B 等待结束,读取并更新数据
    • 两个事务都顺利完成,并且结果合理
  • 不显式获取锁的情况(mysql)
    • A 获取,MVCC,B 获取,MVCC
    • A 更新,成功,B 更新,等待
    • A 提交,B 等待结束,B 更新
    • 最后是丢失更新的情况
    • 为什么要注明 mysql
      • 就是因为各家数据库采用不同的实现方案
      • mysql 采用了 mvcc+2pl
        • 也表明 mysql 采用 2pl 并没有想解决所有问题
        • 他只想解决上面提到写入偏差和幻读的问题(配合 for update)
      • 不同数据库只能根据他们各自的特性去重新分析

到了这里,可以看到 2PL 并不是可串行化的全部,各家数据库的实现也不同,

我们应该记住的是什么?是上面第二个方案:2PL + 显式获得写锁,能解决写入偏差和幻读问题。

脏读/脏写在弱隔离等级已经解决了,然后丢失更新的问题利用 for update 解决,

2PL 是让 for update 的方案也能解决剩下的写入偏差和幻读问题。

两阶段锁定的性能

总之开启 2PL 或者说开启可串行化,性能会大大下降,

所有的并行竞争都需要等待,等待的范围/加锁的范围,都扩大了很多。

直接的效果是高百分位点处的响应会非常的慢,

简单理解就是最热门的功能会变得很卡。

结合性能和死锁的问题,实际上在生产环境开启 2PL 的系统是不太可行的。

谓词锁(predicate lock)

既然读锁/写锁再结合 2PL 还不能解决问题,

那又想出了一个叫谓词锁,谓词这个说法大概想说明锁的是一个动作,

则锁的是一个查询条件,锁当前符合这个查询条件的数据,也要锁将来插入的“符合这个条件的”数据。

但是同样性能不佳。这里性能消耗在于条件判断很频繁。

索引范围锁

事实上大多数 2PL 实现了索引范围锁(index-range locking,也称为 next-key locking

这是一个简化的近似版谓词锁。

原理就是扩大判断范围,扩大到一个索引上,这样利用上索引的性能就“可用”了。

我们可能搜索的是 01:02 <t< 03:04,假设索引建立出来按 00:00 到 05:00 分割的,

那就直接把这个索引包含的范围都锁了,而不是找到精准的对象一个个加锁。

当插入/更新/删除时,也会需要修改这个索引,那自然就会等待锁了。

这种锁最后的问题是没有可以挂载范围锁的索引,比如数据库根本还没有这个范围的数据,

那数据库将会锁整个表,这虽然性能很差,但是稍加注意代码逻辑,可以避免这种情况,发生概率不高。

可串行化快照隔离(SSI, serializable snapshot isolation)

这是 2008 年首次被描述的,是很新的技术。PostgreSQL9.1 以后的可串行化隔离级别支持 SSI。

简单总结就是检测可能发生的写入偏差和幻读问题,然后中止这些事务。

上面 2PL 等尝试处理好他们,也有一定的机制可以发现问题并且处理,问题是处理的性能代价太大。

而检测和中止他们,可以减少非常多锁的使用,提高并发。

但是中止的事务需要重试,这是另一种消耗的增加,具体如何选择,依然取决于具体业务的情况。

因为太新,大多实际使用的数据库,和网络资料,都不是基于这个技术的,

所以很难深刻记住其实现细节,回头再来翻书吧。

This post is licensed under CC BY 4.0 by the author.