你好 Redis,能回答我 7 个问题吗?


公众号后台回复“学习”,获取作者独家秘制精品资料


640?wx_fmt=png

640?wx_fmt=jpeg

扫描下方海报二维码,试听课程:

(课程详细大纲,请参见文末)


640?wx_fmt=jpeg


640?wx_fmt=png


Hello,Redis!我们相处已经很多年了,从模糊的认识到现在我们已经深入结合,你的好我一直都知道也一直都记住

能否再让我多问你的几个问题,让我更加深入的去了解你。

一、Redis 的通讯协议是什么

640?wx_fmt=jpeg

Redis 的通讯协议是文本协议,是的,Redis 服务器与客户端通过 RESP(Redis Serialization Protocol)协议通信。

没错,文本协议确实是会浪费流量,不过它的优点在于直观,非常的简单,解析性能极其的好

我们不需要一个特殊的 Redis 客户端仅靠 Telnet 或者是文本流就可以跟 Redis 进行通讯。

客户端的命令格式:

  • 简单字符串 Simple Strings,以 "+"加号开头。

  • 错误 Errors,以"-"减号开头。

  • 整数型 Integer,以 ":" 冒号开头。

  • 大字符串类型 Bulk Strings,以 "$"美元符号开头。

  • 数组类型 Arrays,以 "*"星号开头。


640?wx_fmt=jpeg


简单总结:具体可以见:

https://redis.io/topics/protocol ,

Redis 文档认为简单的实现,快速的解析,直观理解是采用 RESP 文本协议最重要的地方,有可能文本协议会造成一定量的流量浪费,但却在性能上和操作上快速简单,这中间也是一个权衡和协调的过程。

二、Redis 究竟有没有 ACID 事务

640?wx_fmt=jpeg


要弄清楚 Redis 有没有事务,其实很简单,上 Rredis 的官网查看文档,发现:

640?wx_fmt=jpeg

Redis 确实是有事务,不过按照传统的事务定义 ACID 来看,Redis 是不是都具备了 ACID 的特性。

ACID 指的是:

  • 原子性

  • 一致性

  • 隔离性

  • 持久性

我们将使用以上 Redis 事务的命令来检验是否 Redis 都具备了 ACID 的各个特征。

1、原子性

事务具备原子性指的是,数据库将事务中多个操作当作一个整体来执行,服务要么执行事务中所有的操作,要么一个操作也不会执行。

①事务队列

首先弄清楚 Redis 开始事务 multi 命令后,Redis 会为这个事务生成一个队列,每次操作的命令都会按照顺序插入到这个队列中。

这个队列里面的命令不会被马上执行,直到 exec 命令提交事务,所有队列里面的命令会被一次性,并且排他的进行执行。

640?wx_fmt=jpeg

对应如下图:

640?wx_fmt=jpeg

从上面的例子可以看出,当执行一个成功的事务,事务里面的命令都是按照队列里面顺序的并且排他的执行。

但原子性又一个特点就是要么全部成功,要么全部失败,也就是我们传统 DB 里面说的回滚。

当我们执行一个失败的事务:

640?wx_fmt=jpeg

可以发现,就算中间出现了失败,set abc x 这个操作也已经被执行了,并没有进行回滚,从严格的意义上来说 Redis 并不具备原子性。

②为何 Redis 不支持回滚

这个其实跟 Redis 的定位和设计有关系,先看看为何我们的 MySQL 可以支持回滚

这个还是跟写 Log 有关系,Redis 是完成操作之后才会进行 AOF 日志记录,AOF 日志的定位只是记录操作的指令记录。

而 MySQL 有完善的 Redolog,并且是在事务进行 Commit 之前就会写完成 Redolog,Binlog:

640?wx_fmt=jpeg

要知道 MySQL 为了能进行回滚是花了不少的代价,Redis 应用的场景更多是对抗高并发具备高性能,所以 Redis 选择更简单,更快速无回滚的方式处理事务也是符合场景。

2、一致性

事务具备一致性指的是,如果数据库在执行事务之前是一致的,那么在事务执行之后,无论事务是否成功,数据库也应该是一致的。

从 Redis 来说可以从 2 个层面看,一个是执行错误是否有确保一致性,另一个是宕机时,Redis 是否有确保一致性的机制。

①执行错误是否有确保一致性

640?wx_fmt=jpeg

依然去执行一个错误的事务,在事务执行的过程中会识别出来并进行错误处理,这些错误并不会对数据库作出修改,也不会对事务的一致性产生影响。

②宕机对一致性的影响

暂不考虑分布式高可用的 Redis 解决方案,先从单机看宕机恢复是否能满意数据完整性约束。

无论是 RDB 还是 AOF 持久化方案,可以使用 RDB 文件或 AOF 文件进行恢复数据,从而将数据库还原到一个一致的状态。

③再议一致性

上面执行错误和宕机对一致性的影响的观点摘自黄健宏 《Redis 设计与实现》。

当在读这章的时候还是有一些存疑的点,归根到底 Redis 并非关系型数据库。

如果仅仅就 ACID 的表述上来说,一致性就是从 A 状态经过事务到达 B 状态没有破坏各种约束性,仅就 Redis 而言不谈实现的业务,那显然就是满意一致性。

但如果加上业务去谈一致性,例如,A 转账给 B,A 减少 10 块钱,B 增加 10 块钱,因为 Redis 并不具备回滚,也就不具备传统意义上的原子性,所以 Redis 也应该不具备传统的一致性。

其实,这里只是简单讨论下 Redis 在传统 ACID 上的概念怎么进行对接,或许,有可能是我想多了,用传统关系型数据库的 ACID 去审核 Redis 是没有意义的,Redis 本来就没有意愿去实现 ACID 的事务。

3、隔离性

隔离性指的是,数据库中有多个事务并发的执行,各个事务之间不会相互影响,并且在并发状态下执行的事务和串行执行的事务产生的结果是完全相同的。

Redis 因为是单线程操作,所以在隔离性上有天生的隔离机制,当 Redis 执行事务时,Redis 的服务端保证在执行事务期间不会对事务进行中断,所以,Redis 事务总是以串行的方式运行,事务也具备隔离性。

4、持久性

事务的持久性指的是,当一个事务执行完毕,执行这个事务所得到的结果被保存在持久化的存储中,即使服务器在事务执行完成后停机了,执行的事务的结果也不会被丢失。

Redis 是否具备持久化,这个取决于 Redis 的持久化模式:

  • 纯内存运行,不具备持久化,服务一旦停机,所有数据将丢失。

  • RDB 模式,取决于 RDB 策略,只有在满足策略才会执行 Bgsave,异步执行并不能保证 Redis 具备持久化。

  • AOF 模式,只有将 appendfsync 设置为 always,程序才会在执行命令同步保存到磁盘,这个模式下,Redis 具备持久化。(将 appendfsync 设置为 always,只是在理论上持久化可行,但一般不会这么操作)

简单总结:

  • Redis 具备了一定的原子性,但不支持回滚。

  • Redis 不具备 ACID 中一致性的概念。(或者说 Redis 在设计时就无视这点)

  • Redis 具备隔离性。

  • Redis 通过一定策略可以保证持久性。

Redis 和 ACID 纯属站在使用者的角度去思想,Redis 设计更多的是追求简单与高性能,不会受制于传统 ACID 的束缚。

三、Redis 的乐观锁 Watch 是怎么实现的

当我们一提到乐观锁就会想起 CAS(Compare And Set),CAS 操作包含三个操作数:

  • 内存位置的值(V)

  • 预期原值(A)

  • 新值(B)

如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置更新为新值。否则,处理器不做任何操作。

在 Redis 的事务中使用 Watch 实现,Watch 会在事务开始之前盯住 1 个或多个关键变量。

当事务执行时,也就是服务器收到了 exec 指令要顺序执行缓存的事务队列时, Redis 会检查关键变量自 Watch 之后,是否被修改了。

640?wx_fmt=jpeg

①Java 的 AtomicXXX 的乐观锁机制

在 Java 中我们也经常的使用到一些乐观锁的参数,例如 AtomicXXX,这些机制的背后是怎么去实现的,是否 Redis 也跟 Java 的 CAS 实现机制一样?

先来看看 Java 的 Atomic 类,我们追一下源码,可以看到它的背后其实是 Unsafe_CompareAndSwapObject:

640?wx_fmt=jpeg


可以看见 compareAndSwapObject 是 Native 方法,需要在继续追查,可以下载源码或打开 :http://hg.openjdk.java.net/jdk8u/。

640?wx_fmt=jpeg


640?wx_fmt=jpeg

②Cmpxchg

可以发现追查到最终 CAS,“比较并修改”,本来是两个语意,但是最终确实一条 CPU 指令 Cmpxchg 完成。

Cmpxchg 是一条 CPU 指令的命令而不是多条 CPU 指令,所以它不会被多线程的调度所打断,所以能够保证 CAS 的操作是一个原子操作。

当然 Cmpxchg 的机制其实存在 ABA 还有多次重试的问题,这个不在这里讨论。

③Redis 的 Watch 机制

Redis 的 Watch 也是使用 Cmpxchg 吗,两者存在相似之处在用法上也有一些不同

Redis 的 Watch 不存在 ABA 问题,也没有多次重试机制,其中有一个重大的不同是:Redis 事务执行其实是串行的。

简单追一下源码:摘录出来的源码可能有些凌乱,不过可以简单总结出来数据结构图和简单的流程图,之后再看源码就会清晰很多。

640?wx_fmt=jpeg

640?wx_fmt=jpeg

640?wx_fmt=jpeg

640?wx_fmt=jpeg

存储如下图:

640?wx_fmt=jpeg


RedisDb 存放了一个 watched_keys 的 Dcit 结构,每个被 Watch 的 Key 的值是一个链表结构,存放的是一组 Redis 客户端标志。

流程如下图:

640?wx_fmt=jpeg


每一次 Watch,Multi,Exec 时都会去查询这个 watched_keys 结构进行判断,每次 Touch 到被 Watch 的 Key 时都会标志为 CLIENT_DIRTY_CAS。

因为在 Redis 中所有的事务都是串行的,假设有客户端 A 和客户端 B 都 Watch 同一个 Key。

当客户端 A 进行 Touch 修改或者 A 率先执行完,会把客户端 A 从这个 watched_keys 的这个 Key 的列表删除,然后把这个列表所有的客户端都设置成 CLIENT_DIRTY_CAS。

当后面的客户端 B 开始执行时,判断到自己的状态是 CLIENT_DIRTY_CAS,便 discardTransaction 终止事务。

简单总结:Cmpxchg 的实现主要是利用了 CPU 指令,看似两个操作使用一条 CPU 指令完成,所以不会被多线程进行打断。

而 Redis 的 Watch 机制,更多是利用了 Redis 本身单线程的机制,采用了 watched_keys 的数据结构和串行流程实现了乐观锁机制。

四、Redis 是如何持久化的


640?wx_fmt=jpeg


Redis 的持久化有两种机制,一个是 RDB,也就是快照,快照就是一次全量的备份,会把所有 Redis 的内存数据进行二进制的序列化存储到磁盘。

另一种是 AOF 日志,AOF 日志记录的是数据操作修改的指令记录日志,可以类比 MySQL 的 Binlog,AOF 日期随着时间的推移只会无限增量。

在对 Redis 进行恢复时,RDB 快照直接读取磁盘即可恢复,而 AOF 需要对所有的操作指令进行重放进行恢复,这个过程有可能非常漫长。


640?wx_fmt=jpeg

1、RDB

Redis 在进行 RDB 的快照生成有两种方法,一种是 Save,由于 Redis 是单进程单线程,直接使用 Save,Redis 会进行一个庞大的文件 IO 操作。

由于单进程单线程势必会阻塞线上的业务,一般的话不会直接采用 Save,而是采用 Bgsave,之前一直说 Redis 是单进程单线程,其实不然。

在使用 Bgsave 的时候,Redis 会 Fork 一个子进程,快照的持久化就交给子进程去处理,而父进程继续处理线上业务的请求。

①Fork 机制

想要弄清楚 RDB 快照的生成原理就必须弄清楚 Fork 机制,Fork 机制是 Linux 操作系统的一个进程机制。

当父进程 Fork 出来一个子进程,子进程和父进程拥有共同的内存数据结构,子进程刚刚产生时,它和父进程共享内存里面的代码段和数据段。

640?wx_fmt=jpeg


一开始两个进程都具备了相同的内存段,子进程在做数据持久化时,不会去修改现在的内存数据,而是会采用 COW(Copy On Write)的方式将数据段页面进行分离。

当父进程修改了某一个数据段时,被共享的页面就会复制一份分离出来,然后父进程再在新的数据段进行修改。

640?wx_fmt=jpeg

②分裂

这个过程也成为分裂的过程,本来父子进程都指向很多相同的内存块,但是如果父进程对其中某个内存块进行该修改,就会将其复制出来,进行分裂再在新的内存块上面进行修改。

因为子进程在 Fork 的时候就可以固定内存,这个时间点的数据将不会产生变化。

所以我们可以安心的产生快照不用担心快照的内容受到父进程业务请求的影响。

另外可以想象,如果在 Bgsave 的过程中,Redis 没有任何操作,父进程没有接收到任何业务请求也没有任何的背后例如过期移除等操作,父进程和子进程将会使用相同的内存块。

2、AOF

AOF 是 Redis 操作指令的日志存储,类同于 MySQL 的 Binlog,假设 AOF 从 Redis 创建以来就一直执行,那么 AOF 就记录了所有的 Redis 指令的记录。

如果要恢复 Redis,可以对 AOF 进行指令重放,便可修复整个 Redis 实例。

不过 AOF 日志也有两个比较大的问题:

  • 一个是 AOF 的日志会随着时间递增,如果一个数据量大运行的时间久,AOF 日志量将变得异常庞大。

  • 另一个问题是 AOF 在做数据恢复时,由于重放的量非常庞大,恢复的时间将会非常的长。

AOF 写操作是在 Redis 处理完业务逻辑之后,按照一定的策略才会进行些 AOF 日志存盘,这点跟 MySQL 的 Redolog 和 Binlog 有很大的不同。

也因为此原因,Redis 因为处理逻辑在前而记录操作日志在后,也是导致 Redis 无法进行回滚的一个原因。

bgrewriteaof:针对上述的问题,Redis 在 2.4 之后也使用了 bgrewriteaof 对 AOF 日志进行瘦身。

bgrewriteaof 命令用于异步执行一个 AOF 文件重写操作。重写会创建一个当前 AOF 文件的体积优化版本。

3、RDB 和 AOF 混合搭配模式

在对 Redis 进行恢复的时候,如果我们采用了 RDB 的方式,因为 Bgsave 的策略,可能会导致我们丢失大量的数据。

如果我们采用了 AOF 的模式,通过 AOF 操作日志重放恢复,重放 AOF 日志比 RDB 要长久很多。


640?wx_fmt=jpeg


Redis 4.0 之后,为了解决这个问题,引入了新的持久化模式,混合持久化,将 RDB 的文件和局部增量的 AOF 文件相结合。

RDB 可以使用相隔较长的时间保存策略,AOF 不需要是全量日志,只需要保存前一次 RDB 存储开始到这段时间增量 AOF 日志即可,一般来说,这个日志量是非常小的。

五、Redis 在内存使用上是如何开源节流

640?wx_fmt=jpeg


Redis 跟其他传统数据库不同,Redis 是一个纯内存的数据库,并且存储了都是一些数据结构的数据

如果不对内存加以控制的话,Redis 很可能会因为数据量过大导致系统的奔溃。

1、Ziplist

640?wx_fmt=png

当最开始尝试开启一个小数据量的 Hash 结构和一个 Zset 结构时,发现他们在 Redis 里面的真正结构类型是一个 Ziplist。

Ziplist 是一个紧凑的数据结构,每一个元素之间都是连续的内存,如果在 Redis 中,Redis 启用的数据结构数据量很小时,Redis 就会切换到使用紧凑存储的形式来进行压缩存储。

640?wx_fmt=jpeg

例如,上面的例子,我们采用了 Hash 结构进行存储,Hash 结构是一个二维的结构,是一个典型的用空间换取时间的结构。

但是如果使用的数据量很小,使用二维结构反而浪费了空间,在时间的性能上也并没有得到太大的提升,还不如直接使用一维结构进行存储。

在查找的时候,虽然复杂度是 O(n),但是因为数据量少遍历也非常快,增至比 Hash 结构本身的查询更快。

如果当集合对象的元素不断的增加,或者某个 Value 的值过大,这种小对象存储也会升级生成标准的结构。

Redis 也可以在配置中进行定义紧凑结构和标准结构的转换参数:

640?wx_fmt=png

2、Quicklist

640?wx_fmt=png

Quicklist 数据结构是 Redis 在 3.2 才引入的一个双向链表的数据结构,确实来说是一个 Ziplist 的双向链表。

Quicklist 的每一个数据节点是一个 Ziplist,Ziplist 本身就是一个紧凑列表。

假使,Quicklist 包含了 5 个 Ziplist 的节点,每个 Ziplist 列表又包含了 5 个数据,那么在外部看来,这个 Quicklist 就包含了 25 个数据项。

640?wx_fmt=jpeg

Quicklist 的结构设计简单总结起来,是一个空间和时间的折中方案:

  • 双向链表可以在两端进行 Push 和 Pop 操作,但是它在每一个节点除了保存自身的数据外,还要保存两个指针,增加额外的内存开销。

其次是由于每个节点都是独立的,在内存地址上并不连续,节点多了容易产生内存碎片。

  • Ziplist 本身是一块连续的内存,存储和查询效率很高,但是,它不利于修改操作,每次数据变动时都会引发内存 Realloc,如果 Ziplist 长度很长时,一次 Realloc 会导致大批量数据拷贝。

所以,结合 Ziplist 和双向链表的优点,Quciklist 就孕育而生。

3、对象共享

Redis 在自己的对象系统中构建了一个引用计数方法,通过这个方法程序可以跟踪对象的引用计数信息,除了可以在适当的时候进行对象释放,还可以用来作为对象共享。

举个例子,假使键 A 创建了一个整数值 100 的字符串作为值对象,这个时候键 B 也创建保存同样整数值 100 的字符串对象作为值对象。

640?wx_fmt=jpeg

那么在 Redis 的操作时:

  • 讲数据库键的指针指向一个现有的值对象。

  • 讲被共享的值对象引用计数加一。

假使,我们的数据库中指向整数值 100 的键不止键 A 和键 B,而是有几百个,那么 Redis 服务器中只需要一个字符串对象的内存就可以保存原本需要几百个字符串对象的内存才能保存的数据。

六、Redis 是如何实现主从复制

640?wx_fmt=jpeg

几个定义:

  • runID:服务器运行的 ID。

  • Offset:主服务器的复制偏移量和从服务器复制的偏移量。

  • Replication backlog:主服务器的复制积压缓冲区。

在 Redis 2.8 之后,使用 Psync 命令代替 Sync 命令来执行复制的同步操作。

Psync 命令具有完整重同步和部分重同步两种模式:

  • 完整同步用于处理初次复制情况:

    完整重同步的执行步骤和 Sync 命令执行步骤一致,都是通过让主服务器创建并发送 RDB 文件,以及向从服务器发送保存在缓冲区的写命令来进行同步。

  • 部分重同步是用于处理断线后重复制情况:

    当从服务器在断线后重新连接主服务器时,主服务可以将主从服务器连接断开期间执行的写命令发送给从服务器,从服务器只要接收并执行这些写命令,就可以将数据库更新至主服务器当前所处的状态。

640?wx_fmt=jpeg

完整重同步:

  • Slave 发送 Psync 给 Master,由于是第一次发送,不带上 runID 和 Offset。

  • Master 接收到请求,发送 Master 的 runID 和 Offset 给从节点。

  • Master 生成保存 RDB 文件。

  • Master 发送 RDB 文件给 Slave。

  • 在发送 RDB 这个操作的同时,写操作会复制到缓冲区 Replication Backlog Buffer 中,并从 Buffer 区发送到 Slave。

  • Slave 将 RDB 文件的数据装载,并更新自身数据。

如果网络的抖动或者是短时间的断链也需要进行完整同步就会导致大量的开销,这些开销包括了,Bgsave 的时间,RDB 文件传输的时间,Slave 重新加载 RDB 时间,如果 Slave 有 AOF,还会导致 AOF 重写。

这些都是大量的开销,所以在 Redis 2.8 之后也实现了部分重同步的机制。

640?wx_fmt=jpeg

部分重同步:

  • 网络发生错误,Master 和 Slave 失去连接。

  • Master 依然向 Buffer 缓冲区写入数据。

  • Slave 重新连接上 Master。

  • Slave 向 Master 发送自己目前的 runID 和 Offset。

  • Master 会判断 Slave 发送给自己的 Offset 是否存在 Buffer 队列中。

  • 如果存在,则发送 Continue 给 Slave;如果不存在,意味着可能错误了太多的数据,缓冲区已经被清空,这个时候就需要重新进行全量的复制。

  • Master 发送从 Offset 偏移后的缓冲区数据给 Slave。

  • Slave 获取数据更新自身数据。

七、Redis 是怎么制定过期删除策略的

当一个键处于过期的状态,其实在 Redis 中这个内存并不是实时就被从内存中进行摘除,而是 Redis 通过一定的机制去把一些处于过期键进行移除,进而达到内存的释放

那么当一个键处于过期,Redis 会在什么时候去删除?

几时被删除存在三种可能性,这三种可能性也代表了 Redis 的三种不同的删除策略。

  • 定时删除:在设置键过去的时间同时,创建一个定时器,让定时器在键过期时间来临,立即执行对键的删除操作。

  • 惰性删除:放任键过期不管,但是每次从键空间获取键时,都会检查该键是否过期,如果过期的话,就删除该键。

  • 定期删除:每隔一段时间,程序都要对数据库进行一次检查,删除里面的过期键,至于要删除多少过期键,由算法而定。

1、定时删除

设置键的过期时间,创建定时器,一旦过期时间来临,就立即对键进行操作。

这种对内存是友好的,但是对 CPU 的时间是最不友好的,特别是在业务繁忙,过期键很多的时候,删除过期键这个操作就会占据很大一部分 CPU 的时间。

要知道 Redis 是单线程操作,在内存不紧张而 CPU 紧张的时候,将 CPU 的时间浪费在与业务无关的删除过期键上面,会对 Redis 的服务器的响应时间和吞吐量造成影响。

另外,创建一个定时器需要用到 Redis 服务器中的时间事件,而当前时间事件的实现方式是无序链表,时间复杂度为 O(n),让服务器大量创建定时器去实现定时删除策略,会产生较大的性能影响

所以,定时删除并不是一种好的删除策略。

2、惰性删除

与定时删除相反,惰性删除策略对 CPU 来说是最友好的,程序只有在取出键的时候才会进行检查,是一种被动的过程。

与此同时,惰性删除对内存来说又是最不友好的,一个键过期,只要不再被取出,这个过期键就不会被删除,它占用的内存也不会被释放。

很明显,惰性删除也不是一个很好的策略,Redis 是非常依赖内存和较好内存的,如果一些长期键长期没有被访问,就会造成大量的内存垃圾,甚至会操成内存的泄漏。

在对执行数据写入时,通过 expireIfNeeded 函数对写入的 Key 进行过期判断。

其中 expireIfNeeded 在内部做了三件事情,分别是:

  • 查看 Key 是否过期。

  • 向 Slave 节点传播执行过去 Key 的动作。

  • 删除过期 Key。

3、定期删除

上面两种删除策略,无论是定时删除和惰性删除,这两种删除方式在单一的使用上都存在明显的缺陷,要么占用太多 CPU 时间,要么浪费太多内存。

定期删除策略是前两种策略的一个整合和折中:

  • 定期删除策略每隔一段时间执行一次删除过期键操作,并通过限制删除操作执行的时间和频率来减少删除操作对 CPU 时间的影响。

  • 通过合理的删除执行的时长和频率,来达到合理的删除过期键。

八、总结

Redis 可谓博大精深,简单的七连问只是盲人摸象,这次只是摸到了一根象鼻子,还应该顺着鼻子向下摸,下次可能摸到了一只象耳朵。

只要愿意往下深入去了解去摸索,而不只应用不思考,总有一天会把 Redis 这只大象给摸透了。

END

作者:陈于喆,注:部分章节参考和引用黄健宏 《Redis 设计与实现》

简介:十余年的开发和架构经验,国内较早一批微服务开发实施者。曾任职国内互联网公司网易和唯品会高级研发工程师,后在创业公司担任技术总监/架构师,目前在洋葱集团任职技术研发副总监。负责技术部门研发体系建设,团建建设,人才培养,推动整个技术架构演进以及升级,带领技术团队构建微服务架构体系、平台架构体系、自动化运维体系。

如有收获,请划至底部,点击“在看”,谢谢!


640?wx_fmt=png


《21天互联网Java进阶面试训练营(分布式篇)》详细目录,扫描图片末尾的二维码,试听课程

640?wx_fmt=jpeg

640?wx_fmt=jpeg

640?wx_fmt=jpeg


640?wx_fmt=png

欢迎长按下图关注公众号石杉的架构笔记

640?wx_fmt=jpeg

BAT架构经验倾囊相授


猜你喜欢

转载自blog.csdn.net/qq_42046105/article/details/99261525