RocksDB WriteImpl 流程

本文对 RocksDB 6.7.3 版本的 WriteImpl 流程进行分析。

概述

RocksDB 写入实现主要在 DBImpl::WriteImpl 中,过程主要分为以下三步:

  • 把 WriteBatch 加入队列,多个 WriteBatch 成为一个 WriteGroup
  • 将该 WriteGroup 所有的记录对应的日志写到 WAL 文件中
  • 将该 WriteGroup 所有的 WriteBatch 中的一条或者多条记录写到内存中的 Memtable 中

其中,每个 WriteBatch 代表一个事务的提交,可以包含多条操作,可以通过调用 WriteBatch::Put/Delete 等操作将对应多条的 key/value 记录加入 WriteBatch 中。

源码分析

WriteThread::JoinBatchGroup

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
static WriteThread::AdaptationContext jbg_ctx("JoinBatchGroup");
void WriteThread::JoinBatchGroup(Writer* w) {
TEST_SYNC_POINT_CALLBACK("WriteThread::JoinBatchGroup:Start", w);
assert(w->batch != nullptr);

bool linked_as_leader = LinkOne(w, &newest_writer_);

if (linked_as_leader) {
SetState(w, STATE_GROUP_LEADER);
}

TEST_SYNC_POINT_CALLBACK("WriteThread::JoinBatchGroup:Wait", w);

if (!linked_as_leader) {
/**
* Wait util:
* 1) An existing leader pick us as the new leader when it finishes
* 2) An existing leader pick us as its follewer and
* 2.1) finishes the memtable writes on our behalf
* 2.2) Or tell us to finish the memtable writes in pralallel
* 3) (pipelined write) An existing leader pick us as its follower and
* finish book-keeping and WAL write for us, enqueue us as pending
* memtable writer, and
* 3.1) we become memtable writer group leader, or
* 3.2) an existing memtable writer group leader tell us to finish memtable
* writes in parallel.
*/
TEST_SYNC_POINT_CALLBACK("WriteThread::JoinBatchGroup:BeganWaiting", w);
AwaitState(w, STATE_GROUP_LEADER | STATE_MEMTABLE_WRITER_LEADER |
STATE_PARALLEL_MEMTABLE_WRITER | STATE_COMPLETED,
&jbg_ctx);
TEST_SYNC_POINT_CALLBACK("WriteThread::JoinBatchGroup:DoneWaiting", w);
}
}

每个事务提交请求都会生成一个 WriteBatch 对象,进入 WriteImpl 函数后各自的线程首先调用 JoinBatchGroup 来加入到队列。该队列主要核心的实现在于 LinkOne 函数,通过 CAS 无锁将多个线程的请求组成请求链表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
bool WriteThread::LinkOne(Writer* w, std::atomic<Writer*>* newest_writer) {
assert(newest_writer != nullptr);
assert(w->state == STATE_INIT);
Writer* writers = newest_writer->load(std::memory_order_relaxed);
while (true) {
// If write stall in effect, and w->no_slowdown is not true,
// block here until stall is cleared. If its true, then return
// immediately
if (writers == &write_stall_dummy_) {
if (w->no_slowdown) {
w->status = Status::Incomplete("Write stall");
SetState(w, STATE_COMPLETED);
return false;
}
// Since no_slowdown is false, wait here to be notified of the write
// stall clearing
{
MutexLock lock(&stall_mu_);
writers = newest_writer->load(std::memory_order_relaxed);
if (writers == &write_stall_dummy_) {
stall_cv_.Wait();
// Load newest_writers_ again since it may have changed
writers = newest_writer->load(std::memory_order_relaxed);
continue;
}
}
}
w->link_older = writers;
if (newest_writer->compare_exchange_weak(writers, w)) {
return (writers == nullptr);
}
}
}

write_group 链表结构如下:

每个 writer 在头部插入,插入时如果发现 link_older 为空,则此 writer 成为 write_group 的 Leader(即链表尾为 Leader)。

在 JoinBatchGroup 中,如果 writer 不是 Leader(在后文把不是 Leader 的 writer 称为 Follower),则会调用 AwaitState 等待被唤醒。

PS:由于条件锁 Context Switches 代价高,Rocksdb 在 AwaitState 也做了优化,将 pthread_cond_wait 拆成 3 步来做,本文不对该优化进行详细描述。

WriteImpl 写日志

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
if (w.state == WriteThread::STATE_GROUP_LEADER) {
...

last_batch_group_size_ =
write_thread_.EnterAsBatchGroupLeader(&w, &wal_write_group);
const SequenceNumber current_sequence =
write_thread_.UpdateLastSequence(versions_->LastSequence()) + 1;
...

if (w.status.ok() && !write_options.disableWAL) {
PERF_TIMER_GUARD(write_wal_time);
stats->AddDBStats(InternalStats::kIntStatsWriteDoneBySelf, 1);
RecordTick(stats_, WRITE_DONE_BY_SELF, 1);
if (wal_write_group.size > 1) {
stats->AddDBStats(InternalStats::kIntStatsWriteDoneByOther,
wal_write_group.size - 1);
RecordTick(stats_, WRITE_DONE_BY_OTHER, wal_write_group.size - 1);
}
w.status = WriteToWAL(wal_write_group, log_writer, log_used,
need_log_sync, need_log_dir_sync, current_sequence);
}

...

write_thread_.ExitAsBatchGroupLeader(wal_write_group, w.status);
}

成为 Leader 的 writer,负责批量写入 WAL。在写 WAL 前,首先调用 EnterAsBatchGroupLeader 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
size_t WriteThread::EnterAsBatchGroupLeader(Writer* leader,
WriteGroup* write_group) {
assert(leader->link_older == nullptr);
assert(leader->batch != nullptr);
assert(write_group != nullptr);

size_t size = WriteBatchInternal::ByteSize(leader->batch);

// Allow the group to grow up to a maximum size, but if the
// original write is small, limit the growth so we do not slow
// down the small write too much.
size_t max_size = max_write_batch_group_size_bytes;
const uint64_t min_batch_size_bytes = max_write_batch_group_size_bytes / 8;
if (size <= min_batch_size_bytes) {
max_size = size + min_batch_size_bytes;
}

leader->write_group = write_group;
write_group->leader = leader;
write_group->last_writer = leader;
write_group->size = 1;
Writer* newest_writer = newest_writer_.load(std::memory_order_acquire);

// This is safe regardless of any db mutex status of the caller. Previous
// calls to ExitAsGroupLeader either didn't call CreateMissingNewerLinks
// (they emptied the list and then we added ourself as leader) or had to
// explicitly wake us up (the list was non-empty when we added ourself,
// so we have already received our MarkJoined).
CreateMissingNewerLinks(newest_writer);

// Tricky. Iteration start (leader) is exclusive and finish
// (newest_writer) is inclusive. Iteration goes from old to new.
Writer* w = leader;
while (w != newest_writer) {
w = w->link_newer;

if (w->sync && !leader->sync) {
// Do not include a sync write into a batch handled by a non-sync write.
break;
}

if (w->no_slowdown != leader->no_slowdown) {
// Do not mix writes that are ok with delays with the ones that
// request fail on delays.
break;
}

if (w->disable_wal != leader->disable_wal) {
// Do not mix writes that enable WAL with the ones whose
// WAL disabled.
break;
}

if (w->batch == nullptr) {
// Do not include those writes with nullptr batch. Those are not writes,
// those are something else. They want to be alone
break;
}

if (w->callback != nullptr && !w->callback->AllowWriteBatching()) {
// dont batch writes that don't want to be batched
break;
}

auto batch_size = WriteBatchInternal::ByteSize(w->batch);
if (size + batch_size > max_size) {
// Do not make batch too big
break;
}

w->write_group = write_group;
size += batch_size;
write_group->last_writer = w;
write_group->size++;
}
TEST_SYNC_POINT_CALLBACK("WriteThread::EnterAsBatchGroupLeader:End", w);
return size;
}

在这里,通过 CreateMissingNewerLinks 函数来生成一个双向链表,使得可以从 Leader 开始顺序写。创建完成反向写请求链表之后,则开始计算有多少个写请求可以批量的进行,同时更新 write_group 中的批量写尺寸以及个数等信息,EnterAsBatchGroupLeader 取队列时会把此刻所有的 writer 一次性全取完。

该操作完成之后,则进入写 WAL 的流程了。调用 WriteToWAL,在 MergeBatch 函数中,将根据 write_group 生成一个 merged_batch,该 merged_batch 中记录着应当被写入 WAL 的内容。接着就通过 WriteToWAL 将 merged_batch 写入 WAL 中,这里会根据是否设置了 sync 来决定是否对 WAL 进行落盘操作。

PS:这里有一个优化点,在生成 merged_batch 的时候,假设该写请求的尺寸为一并且该请求需要写 WAL,则 merged_batch 直接复用了该写请求;反之则会复用一个 tmp_batch_ 对象避免频繁的生成 WriteBatch 对象。在写完 WAL 之后,假设复用了 tmp_batch_,则会清空该对象。

最后,调用 ExitAsBatchGroupLeader,该函数会决定该 Leader 是否为 STATE_MEMTABLE_WRITER_LEADER(MEMTABLE_WRITER_LEADER数量 <= GROUP_LEADER数量),从而进行写 Memtable 流程。

WriteImpl 写 Memtable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
WriteThread::WriteGroup memtable_write_group;
if (w.state == WriteThread::STATE_MEMTABLE_WRITER_LEADER) {
PERF_TIMER_GUARD(write_memtable_time);
assert(w.ShouldWriteToMemtable());
write_thread_.EnterAsMemTableWriter(&w, &memtable_write_group);
if (memtable_write_group.size > 1 &&
immutable_db_options_.allow_concurrent_memtable_write) {
write_thread_.LaunchParallelMemTableWriters(&memtable_write_group);
} else {
memtable_write_group.status = WriteBatchInternal::InsertInto(
memtable_write_group, w.sequence, column_family_memtables_.get(),
&flush_scheduler_, &trim_history_scheduler_,
write_options.ignore_missing_column_families, 0 /*log_number*/, this,
false /*concurrent_memtable_writes*/, seq_per_batch_, batch_per_txn_);
versions_->SetLastSequence(memtable_write_group.last_sequence);
write_thread_.ExitAsMemTableWriter(&w, memtable_write_group);
}
}

if (w.state == WriteThread::STATE_PARALLEL_MEMTABLE_WRITER) {
assert(w.ShouldWriteToMemtable());
ColumnFamilyMemTablesImpl column_family_memtables(
versions_->GetColumnFamilySet());
w.status = WriteBatchInternal::InsertInto(
&w, w.sequence, &column_family_memtables, &flush_scheduler_,
&trim_history_scheduler_, write_options.ignore_missing_column_families,
0 /*log_number*/, this, true /*concurrent_memtable_writes*/,
false /*seq_per_batch*/, 0 /*batch_cnt*/, true /*batch_per_txn*/,
write_options.memtable_insert_hint_per_batch);
if (write_thread_.CompleteParallelMemTableWriter(&w)) {
MemTableInsertStatusCheck(w.status);
versions_->SetLastSequence(w.write_group->last_sequence);
write_thread_.ExitAsMemTableWriter(&w, *w.write_group);
}
}

RocksDB 有一个 allow_concurrent_memtable_write 的配置项,开启后可以并发写 memtable(memtable 能设置并发写,但是 WAL 文件不能,因为 WAL 是一个追加写的文件,多个 writer 必须要串行化),所以接下来分为串行写和并行写来进行分析。

串行写 Memtable

Leader 调用 InsertInto,对 write_group 进行遍历,将 Leader 和 Follower 的 WriteBatch 写入。之后调用 ExitAsMemTableWriter,把所有 Follower 的状态设置为 STATE_COMPLETED,将它们唤醒,最后再把 Leader 的状态设置为 STATE_COMPLETED。

并行写 Memtable

调用 LaunchParallelMemTableWriters,遍历 write_group 把 Leader 和 Follower 的状态都设置为 STATE_PARALLEL_MEMTABLE_WRITER,将等待的线程唤醒。最后所有 writer 通过调用 InsertInto 来将 WriteBatch 写入 MemTable 中。writer 完成了 MemTable 的写操作之后,都会调用 CompleteParallelMemTableWriter 函数。该函数会将该 write_group 中运行的任务数减一,当运行中的任务数为零的时候就代表了所有的线程都完成了操作,调用 ExitAsMemTableWriter 把 Leader 的状态设置为 STATE_COMPLETED,反之则会进入等待状态,等待当前其他的写任务完成。

无论是串行写还是并行写,写入 MemTable 完成之后,还有一项工作,就是在取队列时获取 newest_writer_ 和当前时间点处,可能又有很多的写请求产生了,所以批量任务中最后一个完成的线程必须负责重新指定 Leader 给堆积写请求链表的尾部,让其接过 Leader 角色继续进行批量提交。可以看到,串行写和并行写最后都会调用 ExitAsMemTableWriter,正是在该函数中完成了该项工作。

PS:在高并发场景下,Follow 调用 AwaitState 的平均等待时延差不多是写 WAL 时延的两倍。因为获取 newest_writer_ 后,可能又来了许多写请求,这些写请求先要等待此时的 Leader 完成写流程,还要等待下个 Leader,也就是和这些写请求是同一个 write_group 的 Leader 完成写 WAL 才能被唤醒。

回顾

参考

  1. Rocksdb Source Code 6.7.3
  2. rocksdb写流程DBImpl::WriteImpl()源代码分析
  3. RocksDB写入流程
  4. RocksDB 写流程分析

Vectorization vs. Compilation in Query Execution

当代 CPU 特性

超标量流水线与乱序执行

CPU指令的执行可以分为5个阶段:取指令、指令译码、执行指令、访存取数、结果写回。

流水线:一套控制单元可以同时执行多条指令,不需要等到上一条指令执行完就可以执行下一条指令。

超标量:一个 CPU 核有多套控制单元,因此可以有多条 pipeline 并发执行。CPU 还会维护一个乱序执行的指令窗口,窗口中的无数据依赖的指令就可以被取来并发执行。并发指令越多越好,因为这样指令之间没有依赖,并发流水线的执行会更加的流畅。

分支预测

遇到 if/switch 这种判断跳转的指令时会产生分支预测,分支预测系统会决定流水线接下来是载入紧挨着判断指令的下一条指令,还是载入跳转到另一个地址的指令。如果 CPU 的预测是正确的,那么判断指令结果出来的那一刻,真正需要执行的指令已经执行到尾声了,这时候只需要继续执行即可;如果CPU的预测是错误的,那么会把执行到尾声的错误指令全部清空,恢复到从未执行过的状态,然后再执行正确的指令。

程序分支越少或者是分支预测成功率越高,对流水线的执行就越有利,因为如果预测失败了,是要丢弃当前 pipeline 的所有指令重新 flush,这个过程往往会消耗掉十几个 CPU 周期。

多级存储与数据预取

当数据在寄存器,cache 或者内存中,CPU 取数据的速度并不是在一个个数量级上的。CPU 取指令/数据的时候并不是直接从内存中取的,通常 CPU 和内存中会有多级缓存,分别为 L1,L2,L3 cache,其中 L1 cache 又可以分为 L1-data cache,L1-instruction cache。先从 cache 中取数据,若不存在,才访问内存。访问内存的时候会同时把访问数据相邻的一些数据一起加载进 cache 中。

预取指的是若数据存在线性访问的模式,CPU会主动把后续的内存块预先加载进cache中。

SIMD

单指令多数据流,对于数据密集型的程序来说,可能会需要对大量不同的数据进行相同的运算。SIMD 引入了一组大容量的寄存器,比如 128 位,256 位。可以将这多个数据按次序同时放到一个寄存器。同时,CPU 新增了处理这种大容量寄存器的指令,可以在一个指令周期内完成多个数据的运算。

早期解释执行模型

大多数的 query 解释器模型都是使用基于迭代器的火山模型,如下图所示。每个算子看成一个 iterator,iterator 会提供一个 next 方法,每个 next 方法只会产生一个 tuple,可以理解为一行数据。查询执行的时候,查询树自顶向下调用 next 接口,数据则自底向上被拉取处理,层层计算返回结果。所以火山模型属于 pull 模型。

Volcano 模型简单灵活,且这种设计不用占用过多的内存。火山模型将更多的内存资源用于磁盘 IO 的缓存设计而没有优化 CPU 的执行效率,这在当时的硬件基础上是很自然的权衡。但是现在 CPU 的硬件环境与大数据场景下,性能表现却差强人意。主要有如下几点原因:

  1. 时间都花在了query plan上,而不是计算上
    next 函数实现为虚函数,调用虚函数的时候要去查虚函数表,编译器无法对虚函数进行 inline 优化。同时会带来分支预测的开销,导致一次错误的 CPU 分支预测,需要多花费十几个 CPU 周期的开销。

  2. CPU cache利用率低
    next 方法一次只返回一个元组,元组通常采用行存储,如果仅需访问其中某个字段但是每次都将整行数据填入 CPU cache,将导致那些不会被访问的字段也放在了 Cache 中,使得 cache 利用率非常低。

编译执行

编译执行指的是运行时期的代码生成生成技术。在执行过程中生成编译执行代码,避免过多的虚函数调用和解析执行,因为在执行之初我们是知道关系代数的 Schema 信息。在具备 Schema 信息的情况下,事先生成好的代码,可以有效减少很多执行分支预测开销。

上图右边的代码非常紧凑,有效消除了字段个数,字段大小,字段类型,对于数据量特别多的处理场景,可以大大减少CPU开销,提高性能。

编译执行以数据为中心,消灭了火山模型中的大量虚函数调用开销。甚至使大部分指令执行,可以直接从寄存器取数,极大提高了执行效率。

在 Java 中通过 JIT 来实现,在 C++ 中通过 LLVM 来实现 codegen,对于 OLAP 这种运行时间较长的 query 来说,通常编译的时间是可以忽略的。

向量化执行

向量可以理解为按列组织的一组数据,连续存储的一列数据,在内存中可以表示为一个向量。

向量模型和火山模型的本质区别就在于,数据处理的基本单元不再是按行组织的 tuple,而是按列组织的多个向量,我们常说的一个 chunk 其实就是多个 vector 的集合,就是多个列的意思。

向量化执行好处是:由于每次 next 都是处理一批数据,那么大大减少了虚函数调用的次数,分支预测的成功概率会提升,减少了分支预测的开销,并且充分发挥 SIMD 指令并行计算的优势;还可以和列式存储有效结合在一起,减少数据额外转换的 overhead。

向量化和编译执行比较

向量化执行的主要访存开销在于像 join 这种算子的物化开销,物化就是从寄存器把数据读到内存中。而编译执行,tuple 可以一直留在寄存器中,一个 operator 处理完后,给另外一个 operator 继续处理。除非遇到不得不物化的情况。

向量化执行模型的循环较短,并发度高,可以同时有更多的指令等待取数。编译执行循环内部会包含多个 operator 的运算,这些有依赖关系的指令占据了大部分的乱序执行窗口,并发度低。

参考

  1. Sompolski, J. , M. Zukowski , and P. A. Boncz . “Vectorization vs. Compilation in Query Execution.” International Workshop on Data Management on New Hardware ACM, 2011.
  2. S. Wanderman-Milne and N. Li, “Runtime Code Generation in Cloudera Impala,” IEEE Data Eng. Bull., vol. 37, no. 1, pp. 31–37, 2014.
  3. 向量化与编译执行浅析

缓存设计

概述

在设计与开发高性能的系统时,基本都离不开缓存的设计。没有缓存对系统的加速和阻挡大量的请求直接落到系统的底层,系统是很难撑住高并发的冲击。无论是在 CPU 的 L1,L2,L3 缓存,数据库的 sql 语句执行缓存,系统应用的本地缓存,缓存总是解决性能的一把利器。本文主要探讨缓存带来的问题以及缓存方案的设计。

缓存带来的问题

缓存一致性

引入缓存后,主要是解决读的性能问题,但是数据总是要更新的,会存在操作隔离性更新原子性的问题,是先更新缓存还是先更新数据库呢?

  • 操作隔离性:一条数据的更新涉及到存储和缓存两套系统,如果多个线程同时操作一条数据,并且没有方案保证多个操作之间的有序执行,就可能会发生更新顺序错乱导致数据不一致的问题

  • 更新原子性:引入缓存后,我们需要保证缓存和存储要么同时更新成功,要么同时更新失败,否则部分更新成功就会导致缓存和存储数据不一致的问题

  1. 先更新缓存再更新数据库:更新缓存后,后续的读操作都会先从缓存获取从而获取的是最新的数据,但是如果第二步更新数据库失败,那么数据需要回滚,导致先前获取的数据是脏数据来带不可逆的业务影响
  2. 先更新数据库后更新缓存:先更新数据库,但是缓存没有更新,再将数据从数据库同步到缓存这一过程中,所有的读操作读的都是旧数据,会带来一定问题,牺牲小概率的一致性

缓存击穿

缓存击穿是指:业务操作访问缓存时,没有访问到数据,又去访问数据库,但是从数据库也没有查询到数据,也不写入缓存,从而导致这些操作每次都需要访问数据库,造成缓存击穿。

解决办法一般有两种:

  1. 将每次从数据库获取的数据,即使是空值也先写入缓存,但是过期时间设置得比较短,后续的访问都直接从缓存中获取空值返回即可
  2. 通过 Bloom filter 记录 key 是否存在,从而避免无效数据的查询

缓存雪崩

缓存雪崩是指:由于大量的热数据设置了相同或接近的过期时间,导致缓存在某一时刻密集失效,大量请求全部转发到数据库,或者是某个冷数据瞬间涌入大量访问数据库。

主要解决方法:

  1. 所有数据的过期时间不要设置成一样,防止出现数据批量失效,导致缓存雪崩的情况
  2. 采用互斥锁的方式:这里需要使用到分布式锁,在缓存失效后,如果访问同一数据的操作需要访问数据并去更新缓存时,对这些操作都加锁,保证只有一个线程去访问数据并更新缓存,后续所有操作还是从缓存中获取数据,如果一定时间没有获取到就返回默认值或返回空值。这样可以防止数据库压力增大,但是用户体验会降低
  3. 后台更新:业务操作需要访问缓存没有获取到数据时,不访问数据库更新缓存,只返回默认值。通过后台线程去更新缓存,这里有两种更新方式:
    • 启动定时任务定时扫描所有缓存,如果不存在就更新,该方法导致扫描 key 间隔时间过长,数据更新不实时,期间业务操作一直会返回默认值,用户体验比较差
    • 业务线程发现缓存失效后通过消息队列去更新缓存,这里因为是分布式的所以可能有很多条消息,需要考虑消息的幂等性。这种方式依赖消息队列,但是缓存更新及时,用户体验比较好,缺点是系统复杂度增高了

缓存方案的设计

读取

读数据流程很简单,先去缓存读取数据,如果缓存 MISS,则需要从存储中读取数据,并将数据更新到缓存系统中,整个流程如下所示:

更新

通常选择以下方案,保障数据可靠性,尽量减少数据不一致的出现,通过 TTL 超时机制在一定时间段后自动解决数据不一致现象:

  1. 更新数据库,保证数据可靠性
  2. 更新缓存,有以下 2 个策略:
    • 惰性更新:删除缓存中对应的 item,等待下次读 MISS 再缓存(推荐)
    • 积极更新:将最新的数据更新到缓存

淘汰

缓存的作用是将热点数据缓存到内存实现加速,内存的成本要远高于磁盘,因此我们通常仅仅缓存热数据在内存,冷数据需要定期的从内存淘汰,数据的淘汰通常有两种方案:

  1. 主动淘汰。通过对 Key 设置 TTL 的方式来让 Key 定期淘汰,以保障冷数据不会长久的占有内存(推荐)
  2. 被动淘汰。当缓存已用内存超过 Maxmemory 限定时触发淘汰,在 Maxmemory 的场景下缓存的质量是不可控的,因为每次缓存一个 Key 都可能需要去淘汰一个 Key

参考

  1. 翻越缓存的三座大山