<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>ScienJus&#39;s Blog</title>
  
  <subtitle>Science &amp; Justice</subtitle>
  <link href="/atom.xml" rel="self"/>
  
  <link href="http://www.scienjus.com/"/>
  <updated>2022-05-11T09:09:38.256Z</updated>
  <id>http://www.scienjus.com/</id>
  
  <author>
    <name>ScienJus</name>
    
  </author>
  
  <generator uri="http://hexo.io/">Hexo</generator>
  
  <entry>
    <title>[笔记] An Epic Read on Follower Reads</title>
    <link href="http://www.scienjus.com/an-epic-read-on-follower-reads/"/>
    <id>http://www.scienjus.com/an-epic-read-on-follower-reads/</id>
    <published>2022-05-11T07:58:55.000Z</published>
    <updated>2022-05-11T09:09:38.256Z</updated>
    
    <content type="html"><![CDATA[<p>CRDB 的<a href="https://www.cockroachlabs.com/blog/follower-reads-stale-data/" target="_blank" rel="external">博客</a>，记录一些笔记，文章涉及到的一些内容：</p><ul><li>follower read 衍生的各种玩法，主要是在跨地域部署上平衡写入延迟/读取延迟/可用性/成本</li><li>closed ts 的工程实现<ul><li>write tracker</li><li>side transport</li></ul></li><li>closed ts 和 resolved ts 的区别，各自应用于哪些场景</li><li>和 spanner 的对比</li></ul><a id="more"></a><p>注：本文中正体为个人理解后的文章内容，<em>斜体</em>为个人的主观补充和发散。如果本文中的任何内容有理解错误欢迎指出，感谢！</p><h3 id="What-are-Follower-Reads-aka-Stale-Reads"><a href="#What-are-Follower-Reads-aka-Stale-Reads" class="headerlink" title="What are Follower Reads aka Stale Reads?"></a>What are Follower Reads aka Stale Reads?</h3><ul><li>follower read: 通过 follower 副本提供读取功能</li><li>stale read: 不需要强一致性的读取，可以容忍读取到非实时数据</li></ul><p>follower read 是 stale read 的实现，同时也使 stale read 更有意义，因为在跨地域部署的场景下，每个地域的读取请求可以路由到就近的 follower 副本上，从而减少读取的跨地域延迟。</p><p><img src="/uploads/16522567888386.jpg" alt=""></p><h3 id="CockroachDB-context"><a href="#CockroachDB-context" class="headerlink" title="CockroachDB context"></a>CockroachDB context</h3><p>CRDB 将所有数据被分为 512MB 的 range，每个 range 均使用 raft 进行复制，仅以 leaseholder 提供读写服务。</p><p>所带来的的好处：</p><ul><li>不需要做 quorum read</li><li>维护一个称为 timestamp cache 的数据结构，解决了 serializable 的隔离级别下的读写冲突</li></ul><p>但是也有缺点：</p><ul><li>leaseholder 故障后，其他副本需要等到 lease 到期并称为新的 leaseholder 后才能提供读取</li><li>leaseholder 的吞吐限制了整个 range 的吞吐，当 range 成为读热点时会受到影响（CRDB 有负载分裂能解决一部分问题，但是将读流量分散到所有副本也能解决这个问题）</li></ul><p><em>相比其他数据库实现，CRDB 额外抽象出了 leaseholder 角色，其通常是 raft leader，但仍有区别。虽然不影响本文中的其他内容，但笔者实际并不完全理解 leaseholder 的实现和用途，如果有文章能够清晰的描述这些，欢迎告知。</em></p><h3 id="Abstract-matters-When-is-stale-data-OK"><a href="#Abstract-matters-When-is-stale-data-OK" class="headerlink" title="Abstract matters: When is stale data OK?"></a>Abstract matters: When is stale data OK?</h3><p>在某些场景下，过时的数据不光是可接受的，也许还是用户所预期的：</p><blockquote><p>银行的程序需要为客户生成每日的账户报告，无论程序何时运行，都只需要计算出截止到当日凌晨 00:00 的数据，之后的数据应该在次日的报告中体现。</p></blockquote><p>在另一些场景下，用户并不在乎信息是绝对实时的：</p><blockquote><p>打开 Hacker News 时，用户并不在意主页展示的信息是这一刻的还是几秒前的（很多与写完全无关的的 只读操作都适合这种方式）。</p></blockquote><p>不过也有反例，当读取和之前的写入有因果关系时，用户需要强一致读：</p><blockquote><p>你告诉朋友你向他发了一条消息，当他刷新自己的消息列表时，你希望他能看到这条消息。</p></blockquote><p>由于数据库很难感知到这种因果关系，所以默认偏向于提供强一致性的读取。</p><p>除了因果关系以外，还有就是实时性要求很高的场景：</p><blockquote><p>股票自动交易系统需要看到最新的价格（毫秒级）来决定是否进行交易。</p></blockquote><p>上述的例子都是只读事务，而对于读写事务来说，为了保证 serializable 的隔离级别，所有读写操作都应该是原子的，因此也不能提供过时的数据。</p><h3 id="Types-of-stale-reads"><a href="#Types-of-stale-reads" class="headerlink" title="Types of stale reads"></a>Types of stale reads</h3><p>确定的时间点：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">// 具体时间</span><br><span class="line">SELECT * FROM t AS OF SYSTEM TIME &apos;2021-01-02 03:04:05&apos;</span><br><span class="line"></span><br><span class="line">// 偏移时间</span><br><span class="line">SELECT * FROM t AS OF SYSTEM TIME &apos;-30s&apos;</span><br></pre></td></tr></table></figure><p>不确定的时间点：</p><blockquote><p>返回尽量新的数据，不能超过 1 分钟，但可以是 1 分钟内的任意时间点</p></blockquote><p>后者会在一些场景下更加灵活：</p><ul><li>当副本复制出现延迟时，通过容忍返回数据视图的时效性（例如从几秒前变为一分钟前）而维持本地读取的低延迟</li><li>当网络分区时，即使数据视图停留在固定的时间点，但本地副本仍然可以提供读取服务，从而保证可用性</li><li>可以通过更旧的时间点避开存在的锁冲突，避免被阻塞的情况</li></ul><h3 id="Stale-reads-and-multi-region-databases"><a href="#Stale-reads-and-multi-region-databases" class="headerlink" title="Stale reads and multi-region databases"></a>Stale reads and multi-region databases</h3><p>跨地域部署下，leaseholder 可能和客户端有着很高的延迟，使用 stale read 可以将读请求发送到更近的本地副本中，减少读取延迟。</p><p>诞生了两个玩法：</p><ul><li>REGIONAL table：多数派均在特定的地域，以减少写入延迟，其他地域部署 non-voting 副本，提供低延迟的读取</li><li>GLOBAL table：在任何地域都可以提供强一致、低延迟的读取，但是写入开销更大</li></ul><p><em>关于 GLOBAL table，文章没写实现，文档介绍是通过生成一个未来的时间戳，并通过类似 commit wait 的方式等待传播到所有副本。</em></p><p><img src="/uploads/16522580450119.jpg" alt=""></p><p><em>套用 napa 论文中的三角，CRDB 在一致性、读延迟和写延迟中提供了不同的 tradeoffs。</em></p><h3 id="Implementation-of-follower-reads"><a href="#Implementation-of-follower-reads" class="headerlink" title="Implementation of follower reads"></a>Implementation of follower reads</h3><p>leaseholder 定期向 follower 同步一个称之为 closed ts 的时间戳，并承诺在这之后不会接受小于 closed ts 的写入。</p><p>当 follower 收到 closed ts 时，意味着：</p><ul><li>它已经收到了所有时间戳小于 closed ts 的写入</li><li>未来不会有使用小于 closed ts 时间戳的写入</li></ul><p>从执行 sql 的节点来看，如果读取的时间戳足够旧，就可以找到最近的副本（节点之间通过在后台定期探测统计延迟）进行读取，否则还是通过 leaseholder 进行读取。</p><h3 id="Closed-Timestamps"><a href="#Closed-Timestamps" class="headerlink" title="Closed Timestamps"></a>Closed Timestamps</h3><p><img src="/uploads/16522583359807.jpg" alt=""></p><p>leaseholder 通过 raft log 向 follower 同步 closed ts，由上图可知，closed ts 并不是单独的 raft log，而是携带到正常的数据同步日志中。</p><p>当 closed ts 被 apply 后，如果 leaseholder 接收到了时间戳小于 closed ts 的写请求，将会拒绝并推动事务使用更高的时间戳（类似 Read refreshing 的实现，最坏情况下会重启事务）。</p><p>CRDB 支持同一个集群中的不同 range 使用不同的 closed ts 策略（<em>猜测和上述的 GLOBAL table 等有一定关系</em>）。</p><p>简单来说 closed ts 就是 leader 向 follower 实时同步 <code>&lt;log position, timestamp&gt;</code> 的映射关系，即应用了 log position 的日志后，便可以提供小于 timestamp 的读取。</p><h3 id="Publishing-closed-timestamp"><a href="#Publishing-closed-timestamp" class="headerlink" title="Publishing closed timestamp"></a>Publishing closed timestamp</h3><p>CRDB 通过两种方式同步 closed ts：</p><ul><li>raft transport：在 raft log 中携带当前的 closed ts，只要一直有写入，follower 就可以一直拿到最新的 closed ts</li><li>side transport：当 range 没有写入时，通过定时器定期同步给 follower 最新的 closed ts</li></ul><p>通过两种方式推送 closed ts 会带来复杂度，但是仍然遵循着一些规则：</p><ul><li>当两种方式同步了不同的 log position 时，更高的 log position 对应的 closed ts 一定大于等于另一个</li><li>当两种方式同步了相同的 log position 时，side transport 一定会同步更大的 closed ts</li></ul><h3 id="Target-closed-timestamp-policies"><a href="#Target-closed-timestamp-policies" class="headerlink" title="Target closed timestamp policies"></a>Target closed timestamp policies</h3><p>原则上，只要保证以下约束，closed ts 可以取任何值：</p><ul><li>closed ts 一定要小于 lease 的有效期，一般情况下不会出现这个问题，因为 closed ts 一定是过去的时间，而 leaseholder 必须在 lease 有效期内才能同步 closed ts，但是 GLOBAL table 场景下可能会同步一个未来的 closed ts</li><li>closed ts 必须大于等于之前同步的 closed ts，为了不破坏之前的承诺</li><li>closed ts 需要小于正在执行的写请求的时间戳，因为当 t 作为 closed ts 后，小于 t 的写请求需要重试并更换时间戳，这里需要同步处理</li></ul><p>在这个窗口内进行取值，需要权衡：</p><ul><li>closed ts 越大，follower 便可以提供越新的快照，但也会有更多的写入需要更换时间戳并重试</li><li>closed ts 越小，对写入流量的影响也就越小，但 follower 能够提供的数据快照也就越老</li></ul><p>因为更换时间戳并重试的成本比较高，且可能会产生报错并需要客户端重试，所以目前是设置 3s 左右的延迟（可以配置）。</p><h3 id="Writes-Tracker"><a href="#Writes-Tracker" class="headerlink" title="Writes Tracker"></a>Writes Tracker</h3><p>因为会有多个生产者并发 propose，所以同步 closed ts 时需要考虑到并发的写入，避免破坏其承诺。</p><p>通过一个名为 write tracker 的组件记录当前正在进行的写入，以及最小的时间戳（称之 t_eval）。</p><p>两种同步方式均需要考虑到 t_eval：</p><ul><li>raft transport 需要同步获取 t_eval，将 closed ts 降低至 t_eval 之后再进行同步</li><li>side transport 如果发现对应的 range 里有 t_eval，则会跳过该 range，一方面是因为批量发送不能为每个 range 区分 closed ts（之后有提到），另一方面是存在 t_eval 则说明有写入，即可以靠 raft transport 同步</li></ul><p>为了性能考虑，每个 range 都有一个 write tracker，因为每个 range 的 closed ts 是隔离的，这样也可以减少相互影响。</p><p>基础实现是一个<a href="https://github.com/cockroachdb/cockroach/blob/master/pkg/kv/kvserver/closedts/tracker/heap_tracker.go" target="_blank" rel="external">最小堆</a>，不过堆有锁，性能肯定上不去。之后为了性能舍弃了准确度，实现了一个统计近似值的<a href="https://github.com/cockroachdb/cockroach/blob/master/pkg/kv/kvserver/closedts/tracker/lockfree_tracker.go" target="_blank" rel="external">数据结构</a>，大概原理：</p><ol><li>每个 tracker 有两个桶，称之为 B1 和 B2，每个桶都维护一个最小的 ts 和引用计数</li><li>每个桶加入一个请求时，需要增加引用计数，并判断是否要更新 ts 为新的最小值</li><li>每个桶结束一个请求时，需要减少引用计数</li><li>当 B1 引用计数归零时，清空 B1，并交换 B1 和 B2</li><li>新的请求挑选桶时，有两个约束：<ol><li>保证 B1 的 ts 一定小于 B2 的 ts</li><li>请求优先加入到 B2</li></ol></li></ol><p>以 B1 的 ts 是 10，B2 的 ts 是 20 为例，当一个新的请求加入时，有三种情况：</p><ol><li>新请求的 ts 是 25（或任何大于 B1 和 B2 的值），将其放到 B2，需要增加 B2 的引用计数，但不会更新最小值</li><li>新请求的 ts 是 15（或任何大于 B1 且 小于 B2 的值），这里有一个权衡：<ol><li>如果将其放到 B1，当 B1 到期后，closed ts 可以更新为 B2 的 20，但这个请求可能会延后 B1 的到期</li><li>如果将其放到 B2，当 B1 到期后，closed ts 只能更新为 B2 的 15（相较于 20 更差），但这个请求不会延后 B1 的到期</li><li>目前代码的实现是放到 B2</li></ol></li><li>新请求的 ts 是 5（或任何小于 B1 和 B2 的值），为了保证 B1 的 ts 一定小于 B2，这个请求只能放到 B1</li></ol><p>相比最小堆，这个实现只能提供一个近似值，即一个桶内的所有请求都结束后才会推进 closed ts，但是其 track 是无锁的，性能会好很多。</p><blockquote><p>CRDB 的一种常见代码模式，使用读写锁，持有读锁执行无锁操作，而持有写锁执行更复杂的操作并保证互斥</p></blockquote><p>请求退出是在 raft 的 propose 中，当 raft 已经将所有写入请求定序之后，按照顺序从 tracker 中退出并获取 closed ts，这样能够保证 closed ts 在日志序上始终是递增的。</p><h3 id="Performance-considerations"><a href="#Performance-considerations" class="headerlink" title="Performance considerations"></a>Performance considerations</h3><p>对 side transport 的通信层面的优化，简单概括就是只发 delta 不发全量。</p><p>以 n1 发送 closed ts 给 n3 举例：</p><ol><li>sender 和 reciver 初始都是 empty 状态</li><li>通过定时器触发找到 n1 所有 leaseholder 且 n3 有 replica 的 range，通过不同的 closed ts 策略分成不同的组，例如 group 1 的 ts 为 10，range 为 r1…r101</li><li><p>除了无法应用 closed ts 的 range 以外，将 group 中的其它 range 和对应的 ts 发送给 n3，例如 group 1 中除了 r101 无法应用该 closed ts，其他 range 都需要发送<br><img src="/uploads/16522587754075.jpg" alt=""></p></li><li><p>发送消息时，n1 更新 sender state，收到消息时，n3 更新 receiver state<br><img src="/uploads/16522588148917.jpg" alt=""></p></li><li><p>定时器再次触发，对于每个 group，只发送新的 ts 和成员的变更，例如 group 1 的 ts 从 10 变成了 15，成员增加了 r101（可以应用该 closed ts），减少了 r100（无法应用该 closed ts）<br><img src="/uploads/16522588213753.jpg" alt=""></p></li><li><p>发送消息时，n1 更新 sender state，收到消息时，n3 更新 receiver state<br><img src="/uploads/16522588280015.jpg" alt=""></p></li></ol><p>通过分组发送统一的 closed ts 和 range 的变更，使通信的规模不再和 range 的数量相关，而仅和变更的数量相关。因为假设走到 side transport 逻辑的 range 都是没有写入的 range，而这些 range 的变化通常很少，可以有效地减少网络带宽。</p><p>另一个优化是 closed ts 的查询路径，考虑到有两个来源：</p><ul><li>来自 raft 日志，开销相对很低</li><li>来自 side transport，开销相对更高一些，因为需要找到 range 对应的 leaseholder，才能从所在 node 的 receiver state 中获取</li></ul><p>为了尽量减少对 side transport 的访问，做了两个优化：</p><ul><li>对来自 side transport 的状态加了缓存</li><li>修改了查询的语义，从“获取当前的 closed ts”变更为“closed ts 是否满足该时间戳的读取”，这样即使 raft 和缓存中的 closed ts 不够新，只要能满足要求就不需要再去查</li></ul><p><em>这里有些没能理解，side transport 已经是 leaseholder 推给 follower 了，follower 也不需要网络请求，仅是内存查询开销都很大么？还不如直接更新到状态机中呢。</em></p><h3 id="Transaction-commit-and-closed-timestamps-vs-resolved-timestamps"><a href="#Transaction-commit-and-closed-timestamps-vs-resolved-timestamps" class="headerlink" title="Transaction commit and closed timestamps vs resolved timestamps"></a>Transaction commit and closed timestamps vs resolved timestamps</h3><blockquote><p>The semantic difference between closed timestamps and resolved timestamps sometimes trip up even the best of us on occasion.</p></blockquote><p>简单来说就是：上面提到的 closed ts 的承诺只能应用于“写入”，其实就是 write intent，但 CRDB 支持事务，除了 write intent 还有 commit，而 closed ts 的承诺对 commit 是无效的，即虽然不会有小于 closed ts 的 write intent，但是可能会有小于 closed ts 的 commit。</p><p>为什么允许这样做呢？因为  closed ts 是服务于 follower read，而 follower read 是可以处理这种情况的，只需要在遇到 write intent 时阻塞，并等待其提交或者回滚即可。</p><p>这时又引入了另一个时间戳 resolved ts，相当于 closed ts 打了个补丁：取 range 的 closed ts 和其所有未提交的 write intent 的 ts 的最小值，其承诺也仅仅从“写入”变为了“事务提交”：</p><ul><li>所有时间戳小于 resolved ts 的事务均已提交</li><li>未来不会有使用小于 resolved ts 的事务提交</li></ul><p>resolved ts 的使用场景是 CDC，因为 CDC 的消费者必须全量捕获所有已经提交的事务并排序，另外 resolved ts 还可以应用于不确定时间点的 stale read，因为其可以保证不会遇到任何 write intent 并被阻塞，获得更好的读取延迟。</p><p><em>看上去 resolved ts 是功能性需求，既可以满足 stale read 也可以满足 CDC，而 closed ts 是 resolved ts 的一个优化，能够提供更加新的可读取时间点，但有概率被阻塞。</em></p><h3 id="Request-routing"><a href="#Request-routing" class="headerlink" title="Request routing"></a>Request routing</h3><p>CRDB 中负责路由的组件叫 DistSender，它会维护每个 range 所有副本所在的节点信息。</p><p>正常情况下 DistSender 只会将请求路由给对应 range 的 leaseholder，但如果是只读请求，并且 ts 足够旧，就会发给副本所在最近（探测延迟最低）的节点。</p><p><em>看上去是每个副本都会上报其最新的 closed ts 和 resolved ts？</em></p><h3 id="Comparing-CockroachDB-with-Spanner"><a href="#Comparing-CockroachDB-with-Spanner" class="headerlink" title="Comparing CockroachDB with Spanner"></a>Comparing CockroachDB with Spanner</h3><p>spanner 每个副本都可以提供强一致读和 stale read，当 follower 收到强一致读时，需要从 leader 获取对应的 log position，并等待本地回放超过这个 log position 之后才进行读取。</p><p>而对于 stale read，由于 spanner 在事务提交时才真正获取时间戳，并且是当前时间，因此 closed ts 不会影响到 spanner 的写入流程。而对于 CRDB 来说，事务启动时获取时间戳，写入时使用，如果小于之前同步的 closed ts，则需要更新时间戳并重写所有的 key，最坏情况下需要重启事务。</p><p>closed ts 对写入流程的影响导致了 CRDB 必须有几秒的延迟（延迟时间决定于事务的存活时间），spanner 就没这个烦恼。不过 CRDB 的这个设计可以做到不需要读锁而是使用 timestamp cache 就能实现 serializable 的隔离级别，而 spanner 则需要在读取前先加读锁，并在提交后释放读锁。</p><p><em>这块不确定理解的对不对，大概意思是 spanner 既然在 commit 时才取时间戳，那么只需要 track 下 in-flight 的 2pc 请求，取一下它们 prepare ts 的最小值就可以了，在这一刻如果还没开始 commit，那之后获取的 commit ts 也一定大于计算出的 resolved ts（spanner 应该没有 closed ts，对标的就是 resolved ts），实际 tikv 也是类似的实现。而 CRDB 的 commit ts 是开始事务时获取的，相对的临界区就大很多，并且 leaseholder 无法观测到开启事务这一事件，就会有之后拒绝写入的可能性（spanner 使用 prepare ts 就是因为 prepare 是 leaseholder 能观测到的获取 commit ts 前的最后一个事件）。</em></p><p>spanner 的实现是 leader 会追踪每一个 prepare 了但还没有 commit 的事务，然后将最小的时间戳放到 paxos 消息中，这样 follower 就知道这个时间戳之下的所有事务都已经回放到本地、且拥有确定的状态（commit or abort）。所以 CRDB 是在 closed ts 的语义上提供了 follower read，而 spanner 是在 resolved ts 的语义上提供了 follower read，各自优劣上面已经说得很清楚了：</p><ul><li>CRDB 的优势是提供的 ts 更加新（但会影响写入），劣势是在读取 resolved ts 到 closed ts 之间的数据时可能会遇到锁阻塞</li><li>spanner 的优势是 follower read 绝对不会阻塞，劣势是 resolved ts 可能会受到一部分提交较慢事务的影响</li></ul><p>spanner 论文中提到的改进是在 range 内部实现更细粒度的 resolved ts，这样能够减少影响，并且论文已经很久，不确定现在是否已经改进了。</p><p><em>个人理解 spanner 的劣势就是在 2pc 未决的场景下，在未决协商的过程中，即使没有发生实际的 commmit 行为，但 range 的 leaseholder 无法观测到这个状态，它能够感知到的最近的事件就是 prepare。</em></p><p><em>这个对比的结果似乎有些恶趣味，CRDB 的事务实现注定了他们的 closed ts 即使在常态下延迟也很大（否则会影响写入），在笔者看来他们对 closed ts（相比 resolved ts 的提升）所做的努力有些杯水车薪。而对于大部分 spanner 类型的设计（在 commit 阶段明确取“当前时间”作为 commit ts），都可以很轻松的把常态下的延迟压到一个很小的值（没有未决时也就 1~2 个 rpc 的延迟？），这样想想 CRDB 还挺可怜的。</em></p>]]></content>
    
    <summary type="html">
    
      &lt;p&gt;CRDB 的&lt;a href=&quot;https://www.cockroachlabs.com/blog/follower-reads-stale-data/&quot; target=&quot;_blank&quot; rel=&quot;external&quot;&gt;博客&lt;/a&gt;，记录一些笔记，文章涉及到的一些内容：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;follower read 衍生的各种玩法，主要是在跨地域部署上平衡写入延迟/读取延迟/可用性/成本&lt;/li&gt;
&lt;li&gt;closed ts 的工程实现&lt;ul&gt;
&lt;li&gt;write tracker&lt;/li&gt;
&lt;li&gt;side transport&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;closed ts 和 resolved ts 的区别，各自应用于哪些场景&lt;/li&gt;
&lt;li&gt;和 spanner 的对比&lt;/li&gt;
&lt;/ul&gt;
    
    </summary>
    
    
  </entry>
  
  <entry>
    <title>[笔记] TiDB 的 Schema 版本检查</title>
    <link href="http://www.scienjus.com/tidb-schema-validation/"/>
    <id>http://www.scienjus.com/tidb-schema-validation/</id>
    <published>2021-12-03T10:33:08.000Z</published>
    <updated>2021-12-15T04:41:12.198Z</updated>
    
    <content type="html"><![CDATA[<p>这篇笔记主要记录 tidb 在 online schema change 实现中遇到的索引数据不一致问题，以及如何在同步提交和异步提交下通过检查 schema 版本避免该问题。</p><a id="more"></a><h3 id="Online-Schema-Change"><a href="#Online-Schema-Change" class="headerlink" title="Online Schema Change"></a>Online Schema Change</h3><p>参考论文 <a href="https://static.googleusercontent.com/media/research.google.com/zh-CN//pubs/archive/41376.pdf" target="_blank" rel="external">Online, Asynchronous Schema Change in F1</a>，本文就不详细介绍了。</p><p>算法的核心主要有两点：</p><ol><li>将一次 schema 变更拆解为多个中间状态（论文中的 delete-only、write-only 和 tidb 实现中的 reorg），保证相邻的两个状态相互兼容（不会导致数据遗漏、残留或是错误）</li><li>通过 lease 确保了在分布式系统中所有节点同一时间最多只会有两个相邻的 schema 版本生效</li></ol><h3 id="实际的工程问题"><a href="#实际的工程问题" class="headerlink" title="实际的工程问题"></a>实际的工程问题</h3><p>tidb 按照论文中的要求实现了 schema lease，保证所有 tidb 节点中同时只存在两个相邻的 schema 版本，但仍然会遇到一些问题。</p><p>在分布式事务下，「数据组装和写入」以及「数据提交和可见」是两个不同的时间点，而 schema lease 只能保证某一个时间点下的 schema 版本。所以当数据提交时，schema lease 所保护的 schema 版本可能已经不是写入时的版本了，那就破坏了论文中的保证。</p><p>以加索引为例，在这里举两个实际的例子。</p><p>加索引的状态变更流程为：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">Absent =&gt; Delete Only =&gt; Write Only =&gt; Reorg =&gt; Public</span><br></pre></td></tr></table></figure><p>考虑以下时序一：</p><ul><li>此时 lease 处于 Delete Only 状态</li><li>事务 A 创建，并写入一行记录，事务处于 Delete Only 状态，所以不会写入创建中的索引行</li><li>lease 推进到 Write Only 状态</li><li>lease 推进到 Reorg 状态</li><li>Reorg 补全存量数据的索引时，因为事务 A 还没有提交，所以无法扫描到其对应的记录行，也就不会补全对应的索引</li><li>Reorg 完成，lease 推进到 Public 状态</li><li>事务 A 提交，此时数据可见但缺少了索引记录，产生了数据不一致</li></ul><p>考虑以下时序二：</p><ul><li>此时 lease 处于 Absent 状态</li><li>事务 A 创建，获取 schema 版本为 Absent</li><li>lease 推进到 Delete Only 状态</li><li>lease 推进到 Write Only 状态</li><li>事务 B 创建，获取 schema 版本为 Write Only</li><li>事务 B 更新某一行并提交，事务处于 Write Only 状态，所以会写入索引行</li><li>事务 A 删除同一行并提交，事务处于 Absent 状态，所以不会删除索引行，此时出现了索引数据残留，产生了数据不一致</li></ul><p>可以发现，这两个时序都是在 schema lease 推进 schema 版本到 N+1 后，事务提交了 schema 版本为 N-1 的数据。</p><h3 id="同步提交下的解决方案"><a href="#同步提交下的解决方案" class="headerlink" title="同步提交下的解决方案"></a>同步提交下的解决方案</h3><p>对于同步提交，tidb 通过在 2pc 时进一步检查 schema 版本避免该问题。</p><p>具体的时机是在 prewrite 成功且获取 commit ts 之后进行检查，如果当前通过 schema lease 获取的 schema 版本和事务创建时的版本相同，说明全局的 schema 版本最多只有可能推进一个版本，便仍然是可兼容的版本，此时便可以继续 commit，否则本次 2pc 需要失败并重试。</p><p>实际检查会更精细一些，包括推进的 schema 版本是否和本次操作的表有关系，不过这些不在本文的核心内容中，就不详述了。</p><h3 id="Async-Commit"><a href="#Async-Commit" class="headerlink" title="Async Commit"></a>Async Commit</h3><p>上面说完了同步提交，再说说 tidb 5.0 实现的异步提交，同样仅简述一些必要的背景知识。具体可以参考<a href="https://pingcap.com/zh/blog/async-commit-principle" target="_blank" rel="external">官方博客</a>中的介绍或是<a href="https://ericfu.me/timestamp-in-distributed-trans/" target="_blank" rel="external">分布式事务的时间戳</a>这篇文章。</p><p>从流程上来看，同步提交和异步提交的区别为：</p><ul><li>同步提交下，tidb 需要在 prewrite、获取 commit ts 以及 commit primary 之后才能返回客户端结果，响应前需要 3 次 rpc 和 2 次写日志的延迟</li><li>而在异步提交下，tidb 在获取 min commit ts 以及 prewrite 之后便可以返回客户端结果，响应前只需要 2 次 rpc 和 1 次写日志的延迟</li></ul><p>从正确性来看，异步提交的核心实现是 commit ts 由 prewrite 阶段动态计算得出，也就是 commit 的结果和「定序」均在 prewrite 时已经确定并持久化，之后便不可以再更改。</p><h3 id="异步提交下的问题"><a href="#异步提交下的问题" class="headerlink" title="异步提交下的问题"></a>异步提交下的问题</h3><p>异步提交的实现在 prewrite 完成后便将结果返回给了客户端，而回复就意味着承诺，也就表示之后的结果一定不能改变。</p><p>如果还采用同步提交的方案，在 commit 之前才检查 schema 版本，那么就会出现已经返回成功后，检查 schema 版本却失败了的问题，此时违背了之前的承诺。</p><p>看到此处读者可能会想，那在 prewrite 成功返回结果之前就检查 schema 版本，如果检查失败就直接返回失败，不再走 commit 流程，是否就能解决这个问题了。</p><p>很遗憾，答案还是不能，考虑一下极端情况，tidb 节点完成检查 schema 版本后返回成功，但是在发起 commit 之前宕机了。之后的恢复流程需要再次检查 schema 版本（因为不知道是检查前宕机还是检查后宕机），此时有可能检查 schema 版本失败并回滚，同样违背了之前的承诺。</p><h3 id="异步提交下的解决方案（一）"><a href="#异步提交下的解决方案（一）" class="headerlink" title="异步提交下的解决方案（一）"></a>异步提交下的解决方案（一）</h3><p>考虑一下，为什么第一次检查 schema 版本成功后，第二次检查 schema 版本会失败呢？</p><p>因为同步提交下的 schema 版本检查实际是一个「单向屏障」，在获取 commit ts 之后获取最新的 schema 版本并检查，只能做单向保证：「通过检查表示该 commit ts 一定符合要求」，而不是「符合要求的 commit ts 都能通过检查」。因为获取 commit ts 和获取最新的 schema 版本中间会有缝隙，检查失败不一定不符合要求。</p><p>因此，即使 commit ts 不变，随着时间的推移，schema 检查会逐渐趋向于失败，而此时的失败就是被误判的场景。</p><p>那么是否有办法将这个检查变为一个「双向屏障」，即保证 commit ts 和 schema 版本一定是一一对应的，只要 commit ts 不变，对应的 schema 版本也就不会变，那么检查 schema 版本也会变为确定的结果。</p><p>在此介绍 tidb 的第一版解决方案，主要的工作在 <a href="https://github.com/pingcap/tidb/pull/20186" target="_blank" rel="external">txn: add schema version check for async commit recovery #20186</a> 下。</p><p>其核心改动是增加了 <code>checkSchemaVersionForAsyncCommit</code> 函数，实现异步提交下的 schema 版本检查，代码如下：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br></pre></td><td class="code"><pre><span class="line">// checkSchemaVersionForAsyncCommit is used to check schema version change for async commit transactions</span><br><span class="line">// only. For async commit protocol, we need to make sure the check result is the same during common execution</span><br><span class="line">// path and the recovery path. As the schema lease checker has a limited size of cached schema diff version, it&apos;s</span><br><span class="line">// possible the schema cache is changed and the schema lease checker can&apos;t decide if the related table has</span><br><span class="line">// schema version change. So we just check the version from meta snapshot, it&apos;s much stricter.</span><br><span class="line">func checkSchemaVersionForAsyncCommit(ctx context.Context, startTS uint64, commitTS uint64, store Storage) (bool, error) &#123;</span><br><span class="line">if commitTS &gt; 0 &#123;</span><br><span class="line">snapshotAtStart, err := store.GetSnapshot(kv.NewVersion(startTS))</span><br><span class="line">if err != nil &#123;</span><br><span class="line">logutil.Logger(ctx).Error(&quot;get snapshot failed for resolve async startTS&quot;,</span><br><span class="line">zap.Uint64(&quot;startTS&quot;, startTS), zap.Uint64(&quot;commitTS&quot;, commitTS))</span><br><span class="line">return false, errors.Trace(err)</span><br><span class="line">&#125;</span><br><span class="line">snapShotAtCommit, err := store.GetSnapshot(kv.NewVersion(commitTS))</span><br><span class="line">if err != nil &#123;</span><br><span class="line">logutil.Logger(ctx).Error(&quot;get snapshot failed for resolve async commitTS&quot;,</span><br><span class="line">zap.Uint64(&quot;startTS&quot;, startTS), zap.Uint64(&quot;commitTS&quot;, commitTS))</span><br><span class="line">return false, errors.Trace(err)</span><br><span class="line">&#125;</span><br><span class="line">schemaVerAtStart, err := meta.NewSnapshotMeta(snapshotAtStart).GetSchemaVersion()</span><br><span class="line">if err != nil &#123;</span><br><span class="line">return false, errors.Trace(err)</span><br><span class="line">&#125;</span><br><span class="line">schemaVerAtCommit, err := meta.NewSnapshotMeta(snapShotAtCommit).GetSchemaVersion()</span><br><span class="line">if err != nil &#123;</span><br><span class="line">return false, errors.Trace(err)</span><br><span class="line">&#125;</span><br><span class="line">if schemaVerAtStart != schemaVerAtCommit &#123;</span><br><span class="line">logutil.Logger(ctx).Info(&quot;async commit txn need to rollback since schema version has changed&quot;,</span><br><span class="line">zap.Uint64(&quot;startTS&quot;, startTS), zap.Uint64(&quot;commitTS&quot;, commitTS),</span><br><span class="line">zap.Int64(&quot;schema version at start&quot;, schemaVerAtStart),</span><br><span class="line">zap.Int64(&quot;schema version at commit&quot;, schemaVerAtCommit))</span><br><span class="line">return false, nil</span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br><span class="line">return true, nil</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这个实现使用了 start ts 和 commit ts 进行快照读获取当时的 schema 版本，这样当 commit ts 确定时（异步提交在 prewrite 完成后即确定 commit ts，并且在故障恢复时也可以通过持久化的数据计算出相同的值），schema 版本也是确定的，不受真正提交和检查 schema 版本时机的影响。</p><p>不过这个方案的缺点也很明显，在检查 schema 版本时需要进行两次快照读（理论上可以合并为一次），在主路径上增加 rpc 的开销并不小，可能会影响到异步提交带来的性能提升，因此这个方案也没能成为最终方案。</p><h3 id="异步提交下的解决方案（二）"><a href="#异步提交下的解决方案（二）" class="headerlink" title="异步提交下的解决方案（二）"></a>异步提交下的解决方案（二）</h3><p>鉴于方案一有一些不完美的地方，tidb 在 <a href="https://github.com/pingcap/tidb/pull/20550" target="_blank" rel="external">ddl, tikv: add delay during AddIndex DDL and remove schema check for async commit #20550</a> 中又实现了一个新的方案，并沿用至今。</p><p>主要实现是增加了 <code>calculateMaxCommitTS</code> 函数，用于在 prewrite 前计算一个确定合法的 commit ts 上界，代码如下：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br></pre></td><td class="code"><pre><span class="line">func (c *twoPhaseCommitter) calculateMaxCommitTS(ctx context.Context) error &#123;</span><br><span class="line">// Amend txn with current time first, then we can make sure we have another SafeWindow time to commit</span><br><span class="line">currentTS := oracle.ComposeTS(int64(time.Since(c.txn.startTime)/time.Millisecond), 0) + c.startTS</span><br><span class="line">_, _, err := c.checkSchemaValid(ctx, currentTS, c.txn.schemaVer, true)</span><br><span class="line">if err != nil &#123;</span><br><span class="line">logutil.Logger(ctx).Info(&quot;Schema changed for async commit txn&quot;,</span><br><span class="line">zap.Error(err),</span><br><span class="line">zap.Uint64(&quot;startTS&quot;, c.startTS))</span><br><span class="line">return err</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">safeWindow := config.GetGlobalConfig().TiKVClient.AsyncCommit.SafeWindow</span><br><span class="line">maxCommitTS := oracle.ComposeTS(int64(safeWindow/time.Millisecond), 0) + currentTS</span><br><span class="line">logutil.BgLogger().Debug(&quot;calculate MaxCommitTS&quot;,</span><br><span class="line">zap.Time(&quot;startTime&quot;, c.txn.startTime),</span><br><span class="line">zap.Duration(&quot;safeWindow&quot;, safeWindow),</span><br><span class="line">zap.Uint64(&quot;startTS&quot;, c.startTS),</span><br><span class="line">zap.Uint64(&quot;maxCommitTS&quot;, maxCommitTS))</span><br><span class="line"></span><br><span class="line">c.maxCommitTS = maxCommitTS</span><br><span class="line">return nil</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>首先对当前时间生成一个当前时间的 tso（在 start ts 的基础上偏移事务创建时间），如果当前时间通过了 schema 版本检查，就在当前时间的基础上增加 2s（safe window）作为 max commit ts。之后在 prewrite 计算 commit ts 时，如果计算出的 commit ts 超过了 max commit ts，则返回失败。</p><p>除此之外，在执行 reorg 时也需要等待 2.5s（2s 的 safe window + 0.5s 的时钟漂移） 才能真正开始：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line">func (w *worker) runReorgJob(t *meta.Meta, reorgInfo *reorgInfo, tblInfo *model.TableInfo, lease time.Duration, f func() error) error &#123;</span><br><span class="line">// Sleep for reorgDelay before doing reorganization.</span><br><span class="line">// This provides a safe window for async commit and 1PC to commit with an old schema.</span><br><span class="line">// lease = 0 means it&apos;s in an integration test. In this case we don&apos;t delay so the test won&apos;t run too slowly.</span><br><span class="line">if lease &gt; 0 &#123;</span><br><span class="line">cfg := config.GetGlobalConfig().TiKVClient.AsyncCommit</span><br><span class="line">reorgDelay := cfg.SafeWindow + cfg.AllowedClockDrift</span><br><span class="line">logutil.BgLogger().Info(&quot;sleep before reorganization to make async commit safe&quot;,</span><br><span class="line">zap.Duration(&quot;duration&quot;, reorgDelay))</span><br><span class="line">time.Sleep(reorgDelay)</span><br><span class="line">&#125;</span><br><span class="line">// ...</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="异步提交-schema-版本检查的正确性"><a href="#异步提交-schema-版本检查的正确性" class="headerlink" title="异步提交 schema 版本检查的正确性"></a>异步提交 schema 版本检查的正确性</h3><p>我不太清楚该如何系统性的描述整个方案的正确性，因此下面的内容是以 Q&amp;A 的方式进行记录。</p><p>以下部分疑惑请教了上述 PR 的原作者 <a href="https://github.com/sticnarf" target="_blank" rel="external">@sticnarf</a>，感谢他的解答。</p><h4 id="Q：SafeWindow-2s-是否有特殊含义，还是只是一个经验值？"><a href="#Q：SafeWindow-2s-是否有特殊含义，还是只是一个经验值？" class="headerlink" title="Q：SafeWindow = 2s 是否有特殊含义，还是只是一个经验值？"></a>Q：SafeWindow = 2s 是否有特殊含义，还是只是一个经验值？</h4><p>最开始我以为设置的 2s 是依赖了 schema lease 的实现，即每个操作都会等 2 * leasa 的时间。后来想到 tidb 支持在 pd 上注册和监听 schema 版本变更以加速推进 ddl，实际并没有这个保证（并且 lease time 是可配的，肯定不会在代码里写死 2s）。</p><p>所以这应该是一个经验值，认为异步提交的事务在 prewrite 阶段基本都会在 2s 内完成，并且代码中动态计算了 current ts 而不是直接用 start ts 也是为了尽量减少事务时间的限制，对长事务更加友好。</p><h4 id="Q：为什么-reorg-需要等待-SafeWindow（不考虑时钟漂移的情况下）？"><a href="#Q：为什么-reorg-需要等待-SafeWindow（不考虑时钟漂移的情况下）？" class="headerlink" title="Q：为什么 reorg 需要等待 SafeWindow（不考虑时钟漂移的情况下）？"></a>Q：为什么 reorg 需要等待 SafeWindow（不考虑时钟漂移的情况下）？</h4><p>在异步提交下 reorg 出现问题的时序是确定的：</p><ul><li>一个 delete-only 的事务完成 preprocess，准备提交</li><li>事务计算出 current ts</li><li>current ts 通过 schema 版本检查</li><li>计算 max commit ts = current ts + 2s</li><li>reorg 获取 reorg read ts 进行扫描（此时还没有 prewrite 完成，否则会走读等待逻辑）</li><li>事务发起 prewrite，此时需要使用 max commit ts 进行拦截</li></ul><p>根据异步提交的实现，reorg 扫描时会将 tikv 对应的 max ts 推进到 reorg read ts，而之后 prewrite 时事务 commit ts 的计算区间即为 [reorg read ts, max commit ts)，此时如果需要拦截住所有事务提交，就需要让 max commit ts 小于等于 reorg read ts。</p><p>而从上面的时序中可以得到 reorg read ts 一定大于 current ts，那么 reorg read ts + 2s 则一定大于 max commit ts，基于此使得 reorg 在执行前也需要等待 2s。</p><p><img src="/uploads/16395431456913.jpg" alt=""></p><p>注意上述的前提都是不考虑时钟漂移的情况下。</p><h4 id="Q：为什么-reorg-需要额外等待时钟漂移？"><a href="#Q：为什么-reorg-需要额外等待时钟漂移？" class="headerlink" title="Q：为什么 reorg 需要额外等待时钟漂移？"></a>Q：为什么 reorg 需要额外等待时钟漂移？</h4><p>有两处地方会受到始终漂移的影响：</p><ul><li>计算 current ts 时使用了当前时间和 start time 的差值，如果 sql 发生时钟漂移会导致这个值可能比预期更大，也就会使 max commit ts 比预期更大</li><li>请教 @sticnarf 后得知，如果 pd 发生了时钟漂移可能会导致 reorg 时获取的 reorg read ts 比预期更小</li></ul><p>只要发生了上述任意一种场景，都会导致 max commit ts &lt; reorg read ts 这个保证不成立，也就有可能导致索引丢失。</p><p>所以，依赖 tso 和现实时间进行“绑定”是一个有风险的行为，例如：从 pd 拿到一个 tso A，在其基础上增加 2s 得到 A’，然后在本地 sleep 2.1s 后从 pd 在拿到 tso B，此时无法保证 B 一定比 A’ 更大。</p><h4 id="Q：为什么只有-reorg-需要等待，而其他阶段（比如-write-only）不需要等待"><a href="#Q：为什么只有-reorg-需要等待，而其他阶段（比如-write-only）不需要等待" class="headerlink" title="Q：为什么只有 reorg 需要等待，而其他阶段（比如 write-only）不需要等待"></a>Q：为什么只有 reorg 需要等待，而其他阶段（比如 write-only）不需要等待</h4><p>在本文的最开始介绍了发生错误的两个时序，在不同方案中检测出问题的时机也是不同的：</p><ul><li>在同步提交的逻辑下，这两个时序皆是在 commit 前获取当前的 schema 版本并检查出来，没有什么区别</li><li>而在异步提交下，因为有行锁的保护，同一行的写入和提交一定是串行化的，那么时序 2 在 <code>calculateMaxCommitTS</code> 时就能检查出来（因为两个事务都是写操作），只有时序一才需要通过 reorg 的等待进行保证（因为 reorg 是读操作）</li></ul><h3 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h3><ul><li>tidb 的 online schema change 实现，需要在事务提交阶段额外检查 schema 版本以保证数据正确性</li><li>在同步提交的实现中，仅需要在 2pc commit 前进行 schema 版本校验即可避免所有问题</li><li>在异步提交的实现中，需要在 2pc prewrite 前计算出一个 max commit ts，并在 reorg 时额外等待一个安全窗口，才能避免索引缺失的问题</li><li>即便如此，在极端场景下（产生非常大的时钟漂移时），异步提交的实现仍有可能出现问题</li></ul><h3 id="References"><a href="#References" class="headerlink" title="References"></a>References</h3><ul><li><a href="https://static.googleusercontent.com/media/research.google.com/zh-CN//pubs/archive/41376.pdf" target="_blank" rel="external">Online, Asynchronous Schema Change in F1</a></li><li><a href="https://pingcap.com/zh/blog/async-commit-principle" target="_blank" rel="external">Async Commit 原理介绍</a></li><li><a href="https://ericfu.me/timestamp-in-distributed-trans/" target="_blank" rel="external">分布式事务中的时间戳</a></li><li><a href="https://github.com/pingcap/tidb/pull/20186" target="_blank" rel="external">txn: add schema version check for async commit recovery #20186</a></li><li><a href="https://github.com/pingcap/tidb/pull/20550" target="_blank" rel="external">ddl, tikv: add delay during AddIndex DDL and remove schema check for async commit #20550</a></li></ul>]]></content>
    
    <summary type="html">
    
      &lt;p&gt;这篇笔记主要记录 tidb 在 online schema change 实现中遇到的索引数据不一致问题，以及如何在同步提交和异步提交下通过检查 schema 版本避免该问题。&lt;/p&gt;
    
    </summary>
    
    
  </entry>
  
  <entry>
    <title>[论文笔记] Differentiated Key-Value Storage Management for Balanced I/O Performance</title>
    <link href="http://www.scienjus.com/diffkv/"/>
    <id>http://www.scienjus.com/diffkv/</id>
    <published>2021-10-19T06:55:31.000Z</published>
    <updated>2022-05-11T08:12:33.223Z</updated>
    
    <content type="html"><![CDATA[<h3 id="前言"><a href="#前言" class="headerlink" title="前言"></a>前言</h3><p>最早了解到这篇论文是在 2020 TiDB DevCon 上有一个简短的分享，后来发现这篇论文中的一部分贡献也作为了 Titan（PingCAP 的 KV 分离引擎）中的 Level Merge GC 实现，因此产生了兴趣。</p><p>注：本文中涉及到论文内容的章节，<em>斜体</em>为个人的理解和补充，非论文中内容，仅供参考。如果本文中的任何内容有理解错误欢迎指出，感谢！</p><a id="more"></a><h3 id="Introduction"><a href="#Introduction" class="headerlink" title="Introduction"></a>Introduction</h3><p>为了利用顺序 IO 获得更好的性能，以及通过保证数据有序提高扫描性能，现代的 KV 存储一般都使用 LSM-Tree 作为存储结构。LSM-Tree 的实现一般有以下特点：</p><ul><li>将 KV 组织成类似日志的结构，将更新作为追加写到存储中以提高写入性能</li><li>使用多级结构，在每一级的内部保证 KV 完全有序，以减少读取和扫描的开销，并且也不需要额外的内存索引结构</li><li>通过 compaction 将 KV 从较低级别逐渐移动到较高级别，从而减少写入开销。但是由于相邻级别的 KV 需要被回写以保证有序，compaction 会产生大量的 IO</li></ul><p>因此 LSM-Tree 存在着高额的读写放大，尤其是当层数随着数据量增加而增加时。</p><p>为了减少 compaction 的开销，业界有许多优化方向，和本文相关的优化方向主要有两个：</p><ul><li>放松完全有序（例如 Dostoevsky、PebblesDB 和 SlimDB 等），虽然减少了 compaction 的开销，但是也同时降低了扫描的性能</li><li>KV 分离（例如 HashKV、BadgerDB、Atlas、WiscKey、Titan 和 UniKV 等），虽然很适合 Value 较大的负载，但同样降低了扫描性能，并且还引入了 GC 的额外开销</li></ul><p>DiffKV 的思路：</p><ul><li>向传统的 KV 分离一样，将 Key 和 Value 分开存储和管理</li><li>Key 在每一层都保证完全有序，而 Value 则按照 Key 的顺序保证部分有序</li><li>设计了一个称为 vTree 的数据结构用于管理 Value，vTree 中对 Value 的排序由 LSM-Tree 的 compaction 触发，通过这种方式减少开销，并且 Value 的部分有序能够提高扫描的性能</li><li>提供细粒度的 KV 分离进行 Value 的管理，在混合工作负载下保证稳定的性能</li></ul><h3 id="Background-and-Motivation"><a href="#Background-and-Motivation" class="headerlink" title="Background and Motivation"></a>Background and Motivation</h3><h4 id="LSM-tree-Optimizations-and-Limitations"><a href="#LSM-tree-Optimizations-and-Limitations" class="headerlink" title="LSM-tree Optimizations and Limitations"></a>LSM-tree Optimizations and Limitations</h4><p>下图展示了三种形态的 LSM-Tree，分别是传统的 LSM-Tree、放松全局有序和 KV 分离：</p><p><img src="/uploads/16346380007828.jpg" alt=""></p><p>放松全局有序（PebblesDB）的特点是：</p><ul><li>将每一层划分为多个不相交的 Guard，同一个 Guard 内的多个 SSTable 可以相互重叠</li><li>当需要 compact Li 时，只读取 Li 中同一个 Guard 的多个 SSTable，并将合并后的 SSTable 写入到 Li+1</li><li>这种场景下不需要读取和重写 Li+1 的 SSTable，因此减少了 compaction 的开销</li><li>但是由于 Guard 内有重合的 SSTable，扫描性能相对下降，即使可以利用多线程并行读，也会占用了更多的资源且效果有限</li></ul><p>KV 分离（例如 WiscKey 和 Titan）的特点是：</p><ul><li>将 Key 和 Value 分开存储，其中将 Key 和 Value 的引用作为新的 KV 存储在 LSM-Tree 中，而将真正的 Value 单独追加写入到一个日志文件中（在 Titan 中是多个 Blob 文件）</li><li>对于较大的 Value，可以显著减少 LSM-Tree 中的数据量，因此 compaction 的开销和写放大也得到了缓解，并且 LSM-Tree 数据量减少也可以减少读放大，从而提高读性能</li><li>在真实场景中经常可以找到 Value 较大的负载，例如：<ul><li>TiDB 将数据库中的一行映射为一个 KV，因此可能会达到数百字节</li><li>Atlas 的云存储中维护着超过 128kb 的 KV</li><li>Facebook 中用于社交关系图存储的数据均值可达 1kb</li><li>在常见的 KV 存储性能基准测试中，通常会选择较大的 Value（1kb ~ 16kb）</li></ul></li><li>将 Value 放到单独的日志文件中会导致连续 Key 的扫描需要对 Value 进行随机读，导致扫描性能降低，特别是对于 Value 较小的 OLTP 负载（90% 的数据 &lt; 1kb）</li><li>KV 分离需要引入额外的 GC 来回收日志中无效 Value 所占用的空间，频繁的 GC 会造成额外的 IO 开销</li></ul><h4 id="Trade-off-Analysis"><a href="#Trade-off-Analysis" class="headerlink" title="Trade-off Analysis"></a>Trade-off Analysis</h4><p>通过一些测试观测 RocksDB、PebblesDB 和 Titan 的读写以及扫描性能。</p><p>写性能：</p><p><img src="/uploads/16346385067936.jpg" alt=""></p><ul><li>Titan 和 PebblesDB 均有效的降低了写放大，并且当 Value 越大时越明显。当 Value 大小达到 16kb 时，RocksDB、PebblesDB 和 Titan 的写放大分别是 9.3 倍、4.3 倍 和 1.3 倍</li><li>由于写放大的减少，PebblesDB 和 Titan 的写吞吐都会更高，在 Value 大小达到 16kb 时，吞吐为 RocksDB 的 2.7 倍和 8.7 倍</li></ul><p>读和扫描性能：</p><p><img src="/uploads/16346386993638.jpg" alt=""></p><ul><li>对于读性能，放松全局有序降低了读性能，因此 PebblesDB 的读吞吐弱于 RocksDB。而 KV 分离减少了 LSM-Tree 的数据量，因此提升了读性能，所以 Titan 的读吞吐要优于 RocksDB（在 16kb 时为 2x）</li><li>对于扫描性能，PebblesDB 和 Titan 均弱于 RocksDB，尤其是对于较小的 Value，对于 1kb 左右的大小，PebblesDB 和 Titan 的延迟是 RocksDB 的 1.5 倍和 2.4 倍。而且大部分时间都花在了迭代取值的过程中（例如 Titan 超过了 90%）。而随着 Value 变大时（例如 16kb），差距会慢慢变小，因为随机读的开销变得更小。因此在以较小 Value 为主的负载下，PebblesDB 和 Titan 的扫描性能会受到限制</li></ul><p>总结：LSM-Tree 的设计主要在读写和扫描的性能中进行权衡。</p><h3 id="DiffKV-Design"><a href="#DiffKV-Design" class="headerlink" title="DiffKV Design"></a>DiffKV Design</h3><p>DiffKV 的目标是在读写和扫描之间得到更加均衡的表现。</p><h4 id="System-Overview"><a href="#System-Overview" class="headerlink" title="System Overview"></a>System Overview</h4><p><img src="/uploads/16346390098818.jpg" alt=""></p><p>DiffKV 实现了一种类似 LSM-Tree 的数据结构（称之为 vTree）用于管理 Value，在 flush 的过程中将 Key 和 Value 分离。除此之外，为了提高扫描性能，vTree 保证 Value 是部分有序的。</p><p>vTree 和 LSM-Tree 一样有多个级别，每个级别仅能以追加的方式写入，为了实现 Value 的部分有序，vTree 有一个类似 compaction 的操作称之为 merge，并且会和 LSM-Tree 中的 compaction 协调进行，以减少 merge 的开销。</p><p>除此之外，DiffKV 的 memtable 和 WAL 和传统的 LSM-Tree 完全相同。</p><h4 id="Data-Organization"><a href="#Data-Organization" class="headerlink" title="Data Organization"></a>Data Organization</h4><p>vTree 的整体架构由 vTable、Sorted Group 和 vTree 三层组成。</p><p>vTable 的设计：</p><ul><li>每个 vTable 的大小约 8mb</li><li>每次 flush 可能会生成多个 vTable</li><li>vTable 分为元数据域和数据域，数据域存储按 Key 排序的 Value，元数据域存储包括大小、最小 Key 和最大 Key 等元信息，元数据的存储开销很小</li><li>与 LSM-Tree 相反，vTable 不需要 Bloom Filter，因为在 LSM-Tree 中已经有对应的索引了</li></ul><p>Sorted Group 的设计：</p><ul><li>每个 Sorted Group 中的所有 vTable 都是完全有序的</li><li>每一次 flush 均会生成一个 Sorted Group</li><li>Sorted Group 数量反映出当前的有序程度，例如一层中如果只有一个 Sorted Group，说明这一层已经是完全有序的了</li></ul><p>vTree 的设计：</p><ul><li>vTree 由多个层级组成，每个层级中有多个 Sorted Group</li><li>虽然每个 Sorted Group 内部是完全有序的，但是多个 Sorted Group 之间可能会有重叠，因此每一层都只是部分有序</li></ul><h4 id="Compaction-Triggered-Merge"><a href="#Compaction-Triggered-Merge" class="headerlink" title="Compaction-Triggered Merge"></a>Compaction-Triggered Merge</h4><p>vTree 需要定期进行 merge 操作，每次 merge 时会读取一部分 vTable 并检查 Value 是否有效，检查的方式是通过查询 LSM-Tree 判断。并且合并后需要将 Value 的最新位置更新回 LSM-Tree。为了减少 merge 的开销，使用 LSM-Tree 中的 compaction 触发 vTree 的 merge。</p><p>一个设计的前提是让 LSM-Tree 中的每一层和 vTree 中的每一层完全对应（称之为 Li 和 vLi)，即如果 Key 在 Li，那么 Value 一定在 vLi，只是相比之下，Li 是完全有序的，而 vLi 是部分有序的。</p><p><img src="/uploads/16346398578927.jpg" alt=""></p><p>当 Li 需要 compact 到 Li+1 时，同时会触发 vLi 到 vLi+1 的 merge 操作，这里有两个问题：</p><ol><li>哪些 Value 需要参与本次合并</li><li>如何将这些 Value 写到 vLi+1 中</li></ol><p>DiffKV 的实现是：</p><ol><li>只合并 Li 的 Value，而不会去合并 Li+1。例如上图中 LSM-Tree 有 5 个 Key 参与 compaction，但是只有 Li 的 3 个 Key 对应的 Value 需要参与合并</li><li>合并后新生成的 vTable 会通过追加的方式写入到 vLi+1</li></ol><p>每次 merge 都会在 vLi+1 中生成一个新的 Sorted Group，因为不重写 vLi+1 的 vTable 所以减少了写放大。且 vLi 中原有的 vTable 不会被删除，因为其中可能还会包含有效的 Value，需要在之后通过 GC 处理。</p><p>主要有两点收益：</p><ul><li>传统的 merge 需要对 LSM-Tree 进行查询以判断 Value 是否有效，而 compaction 期间本身就会读取到所有需要参与合并的 Key，节约了大量的查询</li><li>在 merge 生成新的 vTable 时需要将最新的 Value 引用更新回 LSM-Tree 中，因此会产生大量回写。而 compaction 流程本身就需要重写 LSM-Tree 中的 KV，可以隐藏掉更新的开销</li></ul><p><em>数据布局和 PebblesDB 接近，也是 PebblesDB 减少写放大的核心思路。</em><br><em>由 compaction 触发 merge 个人觉得是个很好的设计，Key 和 Value 的生命周期是相同的，因此一定会有一方的变更触发另一方的变更。在此基础上 Key 是能够感知到生命周期变更的，而 Value 则做不到，所以传统 WiscKey 的做法以 Value 进行触发必然伴随了大量回查和重写，其中很大程度都能通过 Key 触发的方式避免掉</em></p><h4 id="Merge-Optimizations"><a href="#Merge-Optimizations" class="headerlink" title="Merge Optimizations"></a>Merge Optimizations</h4><p>compaction-triggered merge 减少了回写的开销，但是如果每一次 compaction 都要触发 merge，开销同样很大。并且为了保证 LSM-Tree 中的层级和 vTree 中的层级一定对应，每次 compaction 都必须要触发 merge。</p><p>为此提出了两个优化：Lazy merge 和 Scan-optimized merge。</p><h5 id="Lazy-Merge"><a href="#Lazy-Merge" class="headerlink" title="Lazy Merge"></a>Lazy Merge</h5><p><img src="/uploads/16346403694785.jpg" alt=""></p><p>lazy merge 的实现是将 LSM-Tree 的多个较低级别（例如 L0 到 Ln-2）对应同一个 vTree 中的级别，当这些级别中触发 compaction 时，不会触发 merge 操作。只有到 Ln-1 的 compaction 时，才会触发对应到 vLn-1 的 merge。</p><p>lazy merge 减少了合并的次数和涉及到的数据量，但是牺牲了 vTree 中的有序程度。不过由于大部分数据都应该在最后两层，因此较低层级的有序程度对扫描的影响应该是相对有限的，频繁的合并操作对其的帮助也是有限的。</p><h5 id="Scan-optimized-merge"><a href="#Scan-optimized-merge" class="headerlink" title="Scan-optimized merge"></a>Scan-optimized merge</h5><p><img src="/uploads/16346405395879.jpg" alt=""></p><p>现有的 merge 行为是将 vLi-1 层的数据合并后，追加写入到 vLi 层，vLi 层涉及到的 vTable 并不会重写，这样虽然减少了写放大，但是可能会导致 vLi 层产生过多的 Sorted Group，影响扫描性能。所以希望能够通过检查那些重叠比较多的 vTable，主动参与 merge，使 vTree 中的有序程度更高。</p><p>在正常的 compaction-triggered merge 之后，进一步检查 vLi+1 的 vTable，找到满足以下两个条件的 vTable 集合：</p><ol><li>集合中至少有一个 vTable 与其他 vTable 重合</li><li>集合中 vTable 的数量（也就是集合的大小）比定义的阈值更大</li></ol><p>如果存在这样的集合，就说明对应区域的扫描效率可能会很低，那么就给相关的 vTable 都打上标记，表示需要参加下一次 compaction-triggered merge。这个标记是会持久化到 manifest 中的，因为 merge 本身就需要更新 manifest，因此这个开销可以忽略不计。</p><p>检查的算法是遍历每个 vTable 的最小 Key 和最大 Key，并进行排序。这样可以通过一次扫描得到每个 vTable 有所重叠的其他 vTable 的数量。</p><p>例如上图中，对于 [26-38] 这个文件，找到 38 之前 Start Key 的数量（5 个）和 26 之前 End Key 的数量（1 个），相减就可以得到重叠文件的数量（4 个）。</p><p><em>思路有些像 PebblesDB 的 Seek-based compaction，只是没有基于真实流量去判断。不过从最后的测试效果来看，这个 merge 的成本其实不是很高，因此可能也没必要引入更复杂的判断方式。</em></p><h4 id="Garbage-Collection"><a href="#Garbage-Collection" class="headerlink" title="Garbage Collection"></a>Garbage Collection</h4><p>每次 merge 时，vTree 都会将相关的 Value 重写到新的 vTable 中，因此需要 GC 掉无效 Value 的空间。为了减少 GC 开销，这里提出一种基于感知 vTable 中无效 Value 数量的惰性回收方法。</p><p>DiffKV 通过一个 hash 表记录每一个 vTable 中无效 Value 的数量，每当 vTable 参与 merge 时，都会记录 vTable 中读取 Value 的数量，并更新到 hash 表中。因为只在 merge 时更新，所以 hash 表的性能开销很小，并且对每个 vTable 记录的数据也不多，因此内存开销也很小。</p><p>如果 vTable 中的无效 Value 数量大于阈值，则会成为 GC 候选，类似 scan-optimized merge 一样打一个标记，等待下次 compaction-triggered merge 触发，这样做的好处同样是避免回查和回写 LSM-Tree。</p><p><em>疑问：是否会有极端情况（特殊负载），打完标记的 vTable 没有机会参与到后面的 compaction？</em></p><h4 id="Discussion"><a href="#Discussion" class="headerlink" title="Discussion"></a>Discussion</h4><p>一些其他的小问题：</p><ul><li>Optimizing compaction at L0：因为在 flush 时进行了 KV 分离，所以实际生成的 L0 可能会很小，此时可以将所有 L0 合并成一个更大的 L0，等到 L0 和 L1 的大小接近之后再合并 L1，以此减少写放大。</li><li>Crash consistency：DiffKV 基于 Titan，而 Titan 基于 RocksDB，所以 DiffKV 同样也使用 WAL 保证崩溃一致性。除此之外，上文提到了记录 vTable 中无效 Value 的 hash 表，会在 compaction 之后写入到 manifest 中，由此保证在崩溃后可以恢复。</li></ul><p><em>这个 hash 表还是挺重要的，如果丢了 GC 就完全不准了。</em></p><h3 id="Fine-grained-KV-Separation"><a href="#Fine-grained-KV-Separation" class="headerlink" title="Fine-grained KV Separation"></a>Fine-grained KV Separation</h3><p>KV 分离对大 Value 的效果很显著，但是对小 Value 则是劣势。然而不同 KV 大小的混合负载也很常见，因此需要通过 Value 大小区分 KV，进一步优化以适应混合的工作负载。</p><p><em>分布式数据库就是典型的混合负载，对于同一张表来说，Record 的 Value 可能会很大，而 Index 的 Value 基本都很小。</em></p><h4 id="Differentiated-Value-Management"><a href="#Differentiated-Value-Management" class="headerlink" title="Differentiated Value Management"></a>Differentiated Value Management</h4><p><img src="/uploads/16346414672148.jpg" alt=""></p><p>根据两个参数（value_small/value_large）根据 Value 大小将 KV 分成三组：</p><ul><li>大 KV 在写入时 KV 分离，使用特殊的 vLog 结构进行管理</li><li>中 KV 在 flush 时 KV 分离，使用 vTree 进行管理</li><li>小 KV 直接存储在 LSM-Tree 中</li></ul><p>大 Value 在写 memtable 之前就做了 KV 分离，主要有两个好处：</p><ul><li>memtable 中只存储 Key 和 Value 的引用，节约内存</li><li>WAL 中同样也吃写 Key 和 Value 的引用，节约 IO</li></ul><h4 id="Hotness-aware-vLogs"><a href="#Hotness-aware-vLogs" class="headerlink" title="Hotness-aware vLogs"></a>Hotness-aware vLogs</h4><p>vLog 的实现就是一个简单的环形追加日志（circular append-only log），由一组无序的 vTable 组成（vTable 内部的 KV 也是无序的），因为 KV 都很大，写入时也不需要批量写。</p><p><img src="/uploads/16346416493832.jpg" alt=""></p><p>用一个简单的冷热分离减少 GC 开销：</p><ul><li>用户写的数据追加到 Hot vLog</li><li>GC 会写的数据追加到 Cold vLog</li><li>预期：用户访问新写的值较多，访问 GC 回写的值较少</li><li>优点：实现简单，不需要额外参数</li></ul><p><em>一方面减少空洞以及空洞产生的无效搬运（GC 过很多轮的数据之后会一直存在，而用户的数据是不断写入的，后者会导致前者一直被 GC，就像 WiscKey 论文中的 GC 策略一样），另一方面是可以考虑使用不同存储介质，或是缓存策略提速。</em></p><p>GC 策略：</p><ul><li>贪婪算法：选择无效 Value 最多的 vTable</li><li>在 compaction 时记录 vTable 中无效 Value 的比例，并在内存中进行排序</li><li>触发 GC 时，从队列头找到候选的 vTable，将其中的有效 Value 写回 Cold vLog</li><li>支持多线程以提高性能</li></ul><p><em>疑问：GC 还是需要回写 LSM-Tree 的吧？这点应该和 WiscKey 没什么区别？论文中似乎没有介绍。</em><br><em>如果是和上述 vTree 的 GC 一样通过 compaction 触发的话，一个问题是 vLog 是按照写入的顺序存储的，而 LSM-Tree 是按照 Key 的顺序存储，那么一次 compaction 涉及到的 vTable 是不可控的，可能会造成过多文件的重写，从而阻塞 compaction？</em></p><h3 id="Evaluation"><a href="#Evaluation" class="headerlink" title="Evaluation"></a>Evaluation</h3><p>一些实验，原文中占了很多篇幅，不详细介绍了，只贴结论。</p><h4 id="Microbenchmarks"><a href="#Microbenchmarks" class="headerlink" title="Microbenchmarks"></a>Microbenchmarks</h4><p>场景：加载 100G -&gt; 插入 10G -&gt; 更新 300G -&gt; 查询 10G -&gt; 扫描 10G</p><p><img src="/uploads/16346421102751.jpg" alt=""></p><p>吞吐：</p><ul><li>和 RocksDB/PebblesDB 相比，读写性能均超过 2~4 倍，且扫描性能持平</li><li>和 Titan 相比，读写性能持平，且扫描性能超过 3 倍</li><li>和 Titan FG-GC 相比，更新吞吐量达到接近 1.7 倍</li></ul><p><em>可以看到 GC 的开销还是很大的。</em></p><p>平均延迟：</p><ul><li>和 RocksDB/PebblesDB 相比，读写平均延迟减少了 63%~78%，且扫描延迟接近</li><li>和 Titan 相比，读写平均延迟接近，且扫描延迟减少了 43%</li></ul><p>空间占用：</p><ul><li>加载阶段因为没有 GC（所有数据都是有效数据），所以空间占用差别不大</li><li>更新阶段下，Titan No-GC 和 Titan BG-GC 会使用大量空间，可以减少 18%~53% 的空间使用</li></ul><p><img src="/uploads/16346423339958.jpg" alt=""></p><p>长尾（99 线）：</p><ul><li>和 RocksDB/PebblesDB 相比，长尾要低非常多，插入、更新、读比 RocksDB 减少了 96%/94%/82%，并且扫描的长尾接近</li><li>和 Titan 相比，读写延迟接近，且扫描的长尾减少了 50%</li></ul><h4 id="YCSB-Evaluation"><a href="#YCSB-Evaluation" class="headerlink" title="YCSB Evaluation"></a>YCSB Evaluation</h4><p>场景：加载 100G -&gt; 操作 100M，以下 6 个 workload：</p><p><img src="/uploads/16346424186392.jpg" alt=""></p><p>吞吐：</p><p><img src="/uploads/16346424398002.jpg" alt=""></p><p>长尾：</p><p><img src="/uploads/16346424498598.jpg" alt=""></p><p>结论：各个 workload 下的表现都非常均衡。</p><h4 id="Analysis-on-Merge-Optimizations"><a href="#Analysis-on-Merge-Optimizations" class="headerlink" title="Analysis on Merge Optimizations"></a>Analysis on Merge Optimizations</h4><p>评估 compaction-triggered merge 的效果。</p><p>场景：加载 100G -&gt; 更新 300G</p><p><img src="/uploads/16346425136463.jpg" alt=""></p><p>结论：</p><ul><li>大幅减少了 GC 时间，原因是减少了查询和回写 LSM-Tree 的时间</li><li>略微增加了 compaction 的时间，原因是 compaction 过程中需要进行合并</li></ul><p>评估 lazy merge 和 scan-optimized merge 的效果。</p><p><img src="/uploads/16346426293697.jpg" alt=""></p><p>lazy merge 的效果：</p><ul><li>合并次数和数据量大幅减少，减少了约 65% 左右</li><li>Sorted Group 的数量增加 20% 左右</li><li>扫描吞吐差别不大，但 99 线有略微增加</li></ul><p>scan-optimized merge 的效果：</p><ul><li>合并次数和数据量增加不明显</li><li>Sorted Group 的数量减少了 68% 左右</li><li>增加了 18% 的吞吐以及减少了 27% 的长尾</li></ul><p>结论：以有限的合并开销保证了一定的排序程度，因此在各个方面都表现出均衡的性能。</p><h4 id="Scan-Performance"><a href="#Scan-Performance" class="headerlink" title="Scan Performance"></a>Scan Performance</h4><p>测试不同扫描数量和线程数下的表现。</p><p><img src="/uploads/16346427550414.jpg" alt=""></p><p>结论：</p><ul><li>因为 Titan 以未排序的方式存储 Value，所以扫描性能很差。而 DiffKV 的 Value 有序存储，因此性能会更好</li><li>在不同扫描数量下，DiffKV 可以达到 RocksDB 86%~93% 的扫描性能</li><li>在不同扫描线程下，DiffKV 同样可以达到和 RocksDB 接近的扫描性能</li></ul><h4 id="Tunable-Parameters"><a href="#Tunable-Parameters" class="headerlink" title="Tunable Parameters"></a>Tunable Parameters</h4><p>通过测试得到参数的最佳值。</p><p><img src="/uploads/16346428204358.jpg" alt=""></p><p>结论：</p><ul><li>在 Value 超过 128b 之后，vTree 的写入性能优于 LSM-Tree，因此将 value_small 设置为 128b</li><li>在 Value 超过 8kb 之后，扫描的改善变得更加有限，也就是说使用 vLog 管理 Value 对扫描的影响足够小，并且此时的写入吞吐能达到 1kb 的 80%，因此将 value_large 设置为 8kb</li><li>在 max_sorted_run 超过 10 之后，扫描和写入的变化都变得很小了，因此将 max_sorted_run 设置为 10</li><li>当 gc_threshold 增加时，对写入性能的提升很有限，而对空间的提升相对更明显，因此将 gc_threshold 设置为 0.3</li></ul><p><em>基础炼丹：把所有参数都跑一跑，找一个优势开始提升不明显或劣势开始下降很严重的点作为默认参数。</em></p><h3 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h3><p>vTree 的设计主要是 WiscKey 和 PebblesDB 两篇论文的融合，继承了两篇论文的大部分核心理念。</p><p>从设计来说，LSM-Tree 的性能就是写性能和扫描性能的取舍，也就是写放大和有序程度的取舍，而 WiscKey 和 PebblesDB 等论文告诉我们这个取舍中还有更多因素：</p><ul><li>和 Value 的大小有关，当 Value 越大时，扫描性能越能通过其他方式得到弥补（来自 WiscKey）</li><li>和每一层的重叠程度有关，重叠的层级越多，写放大相对会越低，但扫描的性能会越差（来自 PebblesDB）</li><li>和层级的分布相关，高层级的有序程度对扫描的影响更大，而所有层级对写放大的影响都是接近的（来自 vTree）</li></ul><p>那么是否能理解为：vTree 希望最底层是有序的，这样对扫描性能的影响就是有限的，同时又希望能以更小的写放大将数据 compact 到最底层，所以在中间层将 Value 分离出来并放松有序。</p><p>而 DiffKV 整体的设计是一个很简单但是也很有效的工程实践：把数据按照规则分类并放入到更加合适的存储引擎中，这样每个存储引擎都能充分发挥性能优势且避免遇到劣势场景。</p><p>对个人的感受主要有两点：</p><ul><li>展示了对现有论文的思考、拓展和实践</li><li>展示了优秀的工程实践能力</li></ul><p>PS：这篇论文笔记拖了很久，导致和过几天的 <a href="https://mp.weixin.qq.com/s/RtOZuCVQCa_2ZZ37Dsvsfw" target="_blank" rel="external">DB Paper Reading</a> 撞车了，因此也在这里推荐这个来自论文作者的分享，也许能解答本文中的一些疑问。</p>]]></content>
    
    <summary type="html">
    
      &lt;h3 id=&quot;前言&quot;&gt;&lt;a href=&quot;#前言&quot; class=&quot;headerlink&quot; title=&quot;前言&quot;&gt;&lt;/a&gt;前言&lt;/h3&gt;&lt;p&gt;最早了解到这篇论文是在 2020 TiDB DevCon 上有一个简短的分享，后来发现这篇论文中的一部分贡献也作为了 Titan（PingCAP 的 KV 分离引擎）中的 Level Merge GC 实现，因此产生了兴趣。&lt;/p&gt;
&lt;p&gt;注：本文中涉及到论文内容的章节，&lt;em&gt;斜体&lt;/em&gt;为个人的理解和补充，非论文中内容，仅供参考。如果本文中的任何内容有理解错误欢迎指出，感谢！&lt;/p&gt;
    
    </summary>
    
    
  </entry>
  
  <entry>
    <title>[笔记] My Philosophy on Alerting</title>
    <link href="http://www.scienjus.com/my-philosophy-on-alerting/"/>
    <id>http://www.scienjus.com/my-philosophy-on-alerting/</id>
    <published>2021-08-03T11:28:10.000Z</published>
    <updated>2021-08-03T15:41:04.803Z</updated>
    
    <content type="html"><![CDATA[<p>因为最近被各种乱七八糟的告警的搞得很烦，光吐槽啥也不做也不太好，所以在团队内部分享了下这篇文章。</p><a id="more"></a><p>正体是原文中的内容，斜体是我个人的想法。笔记内容和原文不一定完全对应，原文的翻译可以参考<a href="https://zhuanlan.zhihu.com/p/266870885" target="_blank" rel="external">这篇</a>，本文编写后也通过翻译进行了一些校对，确保没有理解错作者的意思，十分感谢译者。</p><p>文章中涉及到的一些名词：</p><ul><li>symptoms：症状，我理解是指一个问题的表因，本文大半的篇幅都在介绍「基于症状的告警」和「基于原因的告警」之间的区别</li><li>causes：原因，我理解是指一个问题的根因，结合上面的 symptoms 举个例子的话，大概就是因为节点宕机导致客户端报错，那么客户端报错是 symptoms，节点宕机是 causes</li><li>page：原文中的定义是任何引起人注意的东西，笔记中统一都称作告警了</li><li>pager：直接翻译就是寻呼机的意思，在原文中指的是处理告警的事</li><li>rule：原文中的定义是告警规则</li><li>alert：原文中的定义是告警的表现形式，例如表格、邮件等</li></ul><h3 id="Summary"><a href="#Summary" class="headerlink" title="Summary"></a>Summary</h3><p>如何设置告警规则，或者要设置哪些告警规则，才能让我们更愉悦的值班：</p><ul><li>告警一定要是紧急、重要、真实并且可操作的</li><li>能够体现服务正在出现问题，或是即将出现问题</li><li>避免噪音，过度监控比监控不足更难解决</li><li>对问题进行归类：可用性和基本功能、延迟、数据正确性和针对于特定功能的问题等</li><li>基于症状配置告警是一种更好的方法，能够更加全面且稳定的捕获更多的问题</li><li>在表现出症状的图表中应该包含可能产生的原因，但是不要直接对原因设置告警</li><li>在越上层的系统配置告警，一条告警规则能够发现的问题就越多。但是也不能太上层，否则很难推断出有效的信息，也就无法分辨出真正的原因</li><li>如果想要顺利的（原文是「安静」）进行值班轮换，就需要一个系统去记录需要及时响应，但不需要立刻解决的事项</li></ul><h3 id="Introduction"><a href="#Introduction" class="headerlink" title="Introduction"></a>Introduction</h3><p>作者对告警和处理告警的观点：</p><ul><li>每次处理紧急告警时都要<strong>保持紧迫感</strong>，所以紧急告警发生的频率不能太高，否则人会很疲惫</li><li>每个告警都应该是<strong>可操作</strong>的</li><li>每个告警都应该是通过<strong>思考</strong>去解决（原文是「智慧」），而不是通过机械性的操作或是编写脚本去处理问题</li></ul><p>以此为标准，设置告警规则时需要审视一下这些问题：</p><ul><li>它是否能够检测到当前<strong>未检测到的问题</strong>，并且是紧急、可操作的、正在发生或是即将发生的问题？</li><li>它是否会在特定情况下<strong>可以忽略</strong>（原文是「良性的告警」）。在什么样的时机会出现？怎么判断是否能够忽略？，能否在编辑规则的时候避免这个问题？</li><li>它是否真的能<strong>确定用户受到了影响</strong>。一些其他原因（比如有测试流量）是否会触发这个告警？是否可以过滤掉？</li><li>是否需要对这个告警<strong>采取措施</strong>，必须当场处理还是可以之后再处理？</li><li>当出现该问题时，是否需要联系其他人？哪些人可以解决问题？我怎么知道该找哪些人？</li></ul><p>当然如果都能做到未免太理想化，不过作者下面提供了一些技巧可以帮助我们更接近这个目标。</p><h3 id="Monitor-for-your-users"><a href="#Monitor-for-your-users" class="headerlink" title="Monitor for your users"></a>Monitor for your users</h3><p>作者将监控分为两类，称之为<strong>「基于症状的监控」</strong>，与之相对的是<strong>「基于原因的监控」</strong>。作者认为监控的关注点应该是用户，例如用户其实并不会关心我们的 MySQL 服务器宕机了，他只关心他的查询是否失败；用户也不会关心我们的软件在反复重启，他只关心功能是否正常；同样用户也不会关心我们的推送是否失败，他只关心消息是否及时。</p><p>并且用户关心的东西其实很少：</p><ul><li><strong>基本的可用性和正确性</strong>：没有错误、没有非预期的结果，那么就没有故障</li><li><strong>延迟</strong>：当然是越快越好</li><li><strong>数据正确性</strong>：用户的数据都应该是安全的，即使短时间的不可用，在恢复后也不能有任何数据问题</li><li><strong>功能</strong>：用户关心所有功能都在正常工作，例如 google 会在搜索结果中返回计算器或是股票信息</li></ul><p>所以，数据库不可用和用户查询不可用看上去很相似，实际前者是原因，而后者是症状。当我们没有办法模拟用户的真实行为时，我们其实很难区分出这两者的区别，但是如果我们有办法，则应该去尝试关注后者。</p><p><em>我个人比较推崇建设和关注端到端成功率，有些时候可能数据库频繁断连接，但是用户的程序有重试逻辑，只要最终没有报错，那么就没有任何影响。反过来，也许数据库只是延迟上升了一些，但是刚好触发了用户的超时和熔断，那么很可能会出现数据库看上去没有异常，而用户已经大面积报错了的故障。</em></p><h3 id="Cause-based-alerts-are-bad-but-sometimes-necessary"><a href="#Cause-based-alerts-are-bad-but-sometimes-necessary" class="headerlink" title="Cause-based alerts are bad (but sometimes necessary)"></a>Cause-based alerts are bad (but sometimes necessary)</h3><p>为什么作者不推荐配置基于原因的告警，有以下几个理由：</p><ul><li><strong>我们没办法想到一个问题的所有原因</strong>，我们只能为我们已知的原因配置告警</li><li>如果同时配置症状和原因的告警，就会产生<strong>多余的告警</strong>，处理多余的告警会额外浪费精力</li><li><strong>并不是所有原因产生的问题都需要处理</strong>，例如单个节点不可用的故障，在一些情况可能是正常的（例如在做整个集群的滚动重启）。在另一些情况可能是非紧急的（例如在一个集群环境下，单个节点故障通常是可容忍的，也是会偶尔发生的）</li></ul><p>但是在一些场景下，我们也需要基于原因的告警，例如内存或是磁盘空间即将耗尽，这些问题没有症状，并且即将导致严重问题。除此之外，<strong>不推荐为能够配置症状告警的问题配置同样的原因告警</strong>。</p><p><em>以前有个系统，对 uptime 配置了告警，用于监控异常退出后重启的情况。但是滚动重启时也会导致 uptime 归零，因此当时的逻辑是在滚动重启时禁用掉该告警五分钟，看上去这不是一个优雅的做法，因为依赖外部工具动态修改告警配置增加了告警规则的复杂度。也许更好的做法是直接监控异常退出和 OOM。</em></p><h4 id="Alerting-from-the-spout-or-beyond"><a href="#Alerting-from-the-spout-or-beyond" class="headerlink" title="Alerting from the spout (or beyond!)"></a>Alerting from the spout (or beyond!)</h4><p>在 client/server 架构中，在客户端配置告警要优于在服务端配置告警。有以下几个原因：</p><ul><li><strong>客户端能够看到包含重试、以及网络延迟等信息的结果</strong>，能够更好地站在用户的角度去观察延迟和错误</li><li>在一些场景中，客户端需要<strong>聚合来自多个服务端的结果</strong>，观测客户端的<strong>实际操作</strong>，可以使监控更加健壮</li><li>在一些场景中，客户端可以看到更全局的视图，例如一个请求分散到几百台服务器中，每台服务器的信息都非常有限，无法形成有用的告警信息</li></ul><p>对很多服务来说，意味着在离用户最近的负载均衡去评估延迟和错误，这样只会在故障真正影响到用户时才会发送告警信息，也能比服务端发现更多的问题。</p><p>但是也要注意将告警控制在能掌控的范围内，作者举了个例子是如果能够配置基于浏览器的告警，那么就几乎可以感知所有用户可以感知到的问题了。但是同时也会带来大量的噪音（例如用户本身的网络质量或是电脑性能），所以不太可能当做唯一依赖的来源。</p><p><em>之前也遇到过同样的问题，在做分布式事务时，计算层会将一个 2pc 请求并行的发送给所有的存储层参与者，并等待所有参与者返回，因此计算层完整的 2pc 的响应时间由最慢的参与者的响应时间所决定。当某台存储节点的负载明显高于其他节点时，计算层的执行 999 线就可能和存储层的执行 999 线截然不同。</em></p><p><em>前段时间我收到了一个端到端可用性直接降到 80% 的告警（配置的告警阈值是 99.9%），后来发现是因为业务的新写的逻辑没有考虑到数据库中的已有数据，产生了大量主键冲突的异常，这个异常和数据库的可用性其实没有明显关系，但是因为我们这边统一监控了 JDBC 层面的所有异常，很难区分出系统异常（例如超时、断连接等）和用户异常（例如语法错误、主键冲突）等情况。比较有意思的是业务侧没有收到任何告警，而数据库侧光看告警的内容会吓死人。</em></p><h4 id="Causes-are-still-useful"><a href="#Causes-are-still-useful" class="headerlink" title="Causes are still useful"></a>Causes are still useful</h4><p>基于原因的告警仍然是有用的，它的作用是帮助我们快速的从问题的症状跳转到问题的根因。</p><p>如果我们希望能够自动将症状和原因关联起来，就需要减少一些无法控制的原因告警，作者提倡使用以下方法：</p><ol><li>当编写一个基于原因的告警时，需要检查对应的症状是否也有相应的告警，如果没有的话，需要补上</li><li><p>在每个告警中简要描述可能产生的原因，帮助处理告警的人能够快速的确认问题是否已经有确定的对应原因，例如：</p> <figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">TooMany500StatusCodes</span><br><span class="line">Served 10.7% 5xx results in the last 3 minutes!</span><br><span class="line">Also firing:</span><br><span class="line">        JanitorProcessNotKeepingUp</span><br><span class="line">        UserDatabaseShardDown</span><br><span class="line">        FreshnessIndexBehind</span><br></pre></td></tr></table></figure><p> 这里出现了 5xx 过多，可以快速的推断出最大的可能是数据库异常，而如果出现了磁盘空间不足或是页面返回空结果，则更有可能是另外两个原因。</p></li><li><p>删除或调整其他低价值的原因告警，以减少噪音</p></li></ol><p>最后，作者提到基于原因的告警更多是和监控面板的复杂度做取舍，如果我们需要一个干净的监控面板，那么就可以配置更多的原因告警。相反如果我们已经有了一个很完善的监控面板，那么其实不需要原因告警也可以快速的定位问题。</p><p><em>监控面板的复杂度也是我最头疼的问题之一，很多时候监控面板看上去很完善，但是出现问题后其实很难快速的找到某一个异常的指标，也就是从症状推到原因的效率并不高。</em></p><h3 id="Tickets-Reports-and-Email"><a href="#Tickets-Reports-and-Email" class="headerlink" title="Tickets, Reports and Email"></a>Tickets, Reports and Email</h3><p>这里主要介绍如何处理一些不需要立刻处理的告警，作者称为「sub-critical alerts」，下面为了方便称为隐患，作者也提供了一些经验：</p><ul><li><strong>使用 Bug 或者任务跟踪系统</strong>：将多次触发的同一个告警记录在 Bug 或者任务中，需要有人负责及时的分类和关闭这些任务，并且需要防止隐患长时间没有处理而逐渐变成真正的问题</li><li><strong>使用每日报告</strong>：通过一个定时的报告发送这些隐患（例如数据库磁盘空间已经达到 90%），同样需要确保一定要有人负责跟进</li><li><strong>每个告警都需要使用工作流跟踪</strong>：一些过时的或者规则配置错误的告警可能不需要处理，但是也不能忽视它（作者举的例子是单独建了个 email 文件夹或者 channel 把这些告警摘出去），而是要处理掉它们</li></ul><p>作者想表述的重点在于，需要有一个系统能够同时满足两个目标：一定有人会对这类隐患<strong>负责</strong>，并且没有人需要为此付出<strong>高昂的成本</strong>。</p><p><em>大公司的常见毛病，每天都会有乱七八糟的无用告警发来发去，而且没人在乎，想推动相关人员把这些无用告警去掉，他们又怕之后出问题了担责任，从没有认真思考过如何改善这个问题。</em></p><h3 id="Playbooks"><a href="#Playbooks" class="headerlink" title="Playbooks"></a>Playbooks</h3><p>Playbook 是告警系统中的另一个重要组成部分，作者建议给每一个告警项都编写对应的 Playbook，进一步解释告警的含义以及如何解决。</p><p>一般来说每个 Playbook 会是一个详细的流程图，<strong>大部分的篇幅是介绍哪里可能出现问题，少部分的篇幅介绍如何修复它</strong>。此外还有一些情况是这个问题超出控制，必须寻求人工协助。因为一般篇幅不是很多，记录在 wiki 中是一个好的选择。</p><p><em>这里作者介绍的信息很少，可能是因为 google sre 都太牛逼了没什么需要人工处理的问题…</em></p><h3 id="Tracking-amp-Accountability"><a href="#Tracking-amp-Accountability" class="headerlink" title="Tracking &amp; Accountability"></a>Tracking &amp; Accountability</h3><p>如果一个告警正在触发，但是有人说“我看过了，没什么问题”，这表明我们需要重新调整这个告警的规则，或是干脆将它删掉。准确率低于 50% 的告警可以认为是坏掉的，及时是 10% 的误报也需要考虑是否能进行调整。</p><p>需要有个系统（例如<strong>每周审查所有告警，或是每个季度统计告警数据</strong>）帮助我们了解系统的现状，以及分析一些告警在不同人之间转移时出现的问题。</p><h3 id="You’re-being-naive"><a href="#You’re-being-naive" class="headerlink" title="You’re being naïve!"></a>You’re being naïve!</h3><p>理想很丰满，但是现实很骨感，一些可能会出现的情况将会违反上述的规则，但仍然是合理的：</p><ul><li><strong>一些已知的问题混杂在噪音之中</strong>：如果系统配置了一个 99.99% 的可用性告警（很明显这是一个基于症状的告警），但是会有一个常见的问题导致 0.001% 请求失败，这个问题会被掩盖在噪音之中。此时可以不依赖基于症状的告警，而是单独为它配置一个基于原因的告警</li><li><strong>无法在入口处进行监控，因为无法区分出不同的特征</strong>：服务端对不同服务的可用性和延迟的标准是不同的，例如信用卡支付和浏览购物车相比，可用性要求要高得多。此时在负载均衡处配置统一的告警规则肯定是行不通的，应该下沉到能够区分出这些特征的位置配置不同的告警</li><li><strong>当症状出现时一切都晚了</strong>：一些紧急的告警，可能在找到跟因前就已经为时已晚了，因此也需要一个对应的不是很紧急的隐患。例如磁盘空间同时会有「当前用量大于 80% 且按照最近 1 小时的用量估算 4 小时内就会用满」的紧急告警和「当前用量大于 90% 且按照最近 1 天的用量估算 4 天内就会用满」的非紧急隐患。其中后者就可以解决大部分问题</li><li><strong>告警比试图检测的问题还要复杂</strong>：我们的目标是构建简单、健壮、能够自我保护的系统，因此告警规则也不应该过于复杂</li></ul><p><em>个人很赞同最后一点，不要通过复杂的告警规则或是人肉运维去解决系统设计问题，很多问题的发现和降级都应该在系统内部完成。</em></p>]]></content>
    
    <summary type="html">
    
      &lt;p&gt;因为最近被各种乱七八糟的告警的搞得很烦，光吐槽啥也不做也不太好，所以在团队内部分享了下这篇文章。&lt;/p&gt;
    
    </summary>
    
    
  </entry>
  
  <entry>
    <title>2020 年终总结</title>
    <link href="http://www.scienjus.com/2020-year-end-review/"/>
    <id>http://www.scienjus.com/2020-year-end-review/</id>
    <published>2020-12-31T15:12:26.000Z</published>
    <updated>2020-12-31T15:58:36.318Z</updated>
    
    <content type="html"><![CDATA[<p>多灾多难的 2020 马上就要过去了，今年我换了新的工作方向，因为疫情被迫转换了很多生活方式。总体来说这一年我过得焦虑却又充实，并对新的一年充满期待。</p><a id="more"></a><h2 id="工作"><a href="#工作" class="headerlink" title="工作"></a>工作</h2><h3 id="分布式数据库"><a href="#分布式数据库" class="headerlink" title="分布式数据库"></a>分布式数据库</h3><p>在 2019 年年底，我从之前负责微服务/云原生方向的团队转到了现在的分布式数据库团队。去年写年终总结时对这个方向的了解还比较少，所以也没有提到具体的工作内容，今年的认知相对清晰很多，可以简单介绍一下。</p><p>我们团队的工作是做一个 Share-Nothing 架构的分布式数据库，类似市面上更加知名的 TiDB 或 Oceanbase。在架构层面上是标准的存储计算分离，存储层也是分布式事务型的 KV 存储引擎，使用自己实现的 LSM-Tree 作为单机存储引擎，多个副本间使用 Raft 进行同步，并且也实现了分区动态分裂等功能。</p><p>我在加入团队后主要负责存储引擎层的相关工作，其中最重要的一块就是 LSM-Tree 中 Compaction 的实现和优化。</p><p>在我看来，LSM-Tree 的 Compaction 机制是非常值得研究的方向。LSM-Tree 的设计提出许久，Compaction 的设计和优化几乎是其中最重要的部分之一，所以也积累了非常多优秀的论文。不仅有 Dostoevsky 这种偏向理论分析的论文，也有像 Facebook 的几篇 MyRocks 论文会介绍很多工程实现层面遇到的问题和优化，都是非常值得学习和实践的。</p><p>而在分布式系统，尤其上层是一个分布式数据库的场景，能做的事情又会更多一些。一方面是基于分布式数据库的数据存储方式，KV 层的读写负载相对会更加明确。那么当一个实例中存在上千个不同负载的 LSM-Tree 时，如何提高整体的内存利用率、均衡读写放大、减少缓存失效的影响，Compaction 的调度策略是一个非常有意思的研究方向。</p><p>另外，NVM 这样的新型硬件也在挑战着 LSM-Tree 原有的设计，LSM-Tree 在设计之初，在 HDD 上进行随机读写是完全无法接受的行为。而到了 SSD 普及后，随机读的性能劣势相对缓解了很多，所以也才会有像 WiscKey 这种破坏存储强有序以减少写放大的设计。而到了 NVM 中，这种差距在进一步的缩小。另一方面，传统的 LSM-Tree 由于 SST 不可修改的特性，每次 Compaction 后重写一部分文件并产生缓存失效，从而引起系统抖动。而在应用了 NVM 之后，在 NVM 上精心设计的数据结构将会承担起存储系统中「只读暖数据」的职责，使得热数据淘汰更加平滑。</p><p>总而言之，我个人还是比较喜欢这个方向的，所以对明年的工作也充满期待。明年主要有两个目标：一个是在自己实现的数据库上应用更多的 Compaction 优化。像是上面描述的分布式数据库下的 Compaction 调度以及 NVM 的引用，希望能够产生一些真正有价值的思考和创新。另一个是希望能够更加了解分布式数据库会遇到的通用问题和解决方法，例如如何优化分布式事务或是尽可能减少分布式事务、如何进行调度能够将热点均匀的分散到整个集群、如何在保证可用性的前提下减少成本，这些都是需要未来几年不断积累和探索的方向。</p><p>最后说说心态，今年工作上最大的感受是「孤独」，毕竟从云原生这样一个”网红行业“转到了分布式数据库这种”夕阳产业“，日新月异的变化和交流讨论的人都少了很多。但是其实孤独可能也是件好事，因为同样可以远离一些浮躁的人和不靠谱的事，总算能抽出一些时间静下心来看看论文、跑跑 benchmark、以及在夜深人静的时候和兴趣相投的同事畅谈新的灵感。</p><h3 id="大公司"><a href="#大公司" class="headerlink" title="大公司"></a>大公司</h3><p>除了技术方向本身之外，从摩拜这样轻松愉快的小团队转到美团这样严肃的大公司团队，我本身也非常不适应（当然现在美团单车也已经成为一个标准的大公司团队了）。</p><p>我可能是一个对环境很敏感的人，在 ENJOY 工作时就会非常放松，和同事们每天中午在三里屯闲逛、外出吃饭时可以玩 UNO、团建首选是密室、一起在公司通宵看 WWDC。在摩拜时可以发掘亮马桥的日料店，天气好的时候可以在甲板上发呆（摩拜的办公室是亮马河上的一艘船），还有生日会、万圣节这样偶尔放松一下的活动。</p><p>而到了美团，在真正做技术的时间之外。各种完全不感兴趣的培训、晋升（还好今年职级合并了，明年不用操心晋升的事情了）、汇报和会议耗尽了我的所有情绪。但是躲也躲不过，逃也逃不掉，最后就只能躺平了。</p><h3 id="焦虑"><a href="#焦虑" class="headerlink" title="焦虑"></a>焦虑</h3><p>前一周正好在和老板 one on one，聊完之后汇总了一下我今年所有沟通上的反馈，发现有一个词贯穿了我的一整年，就是焦虑。</p><p>当然焦虑本身没有错，我会把焦虑分为三级，即良性焦虑 &gt; 恶性焦虑 &gt; 完全不思考。我很享受因为焦虑的情绪迫使我去思考更多问题，尽更多的努力，并最终获得更好的结果，这便是良性焦虑。但是我每天的焦虑仍有很大一部分都是担忧不会遇到的或是无法解决的问题，这便是恶性焦虑。</p><p>举个栗子，我的一部分恶性焦虑源于对「努力」的认知上，认为只要足够努力就能缩短和他人的差距，就能做好所有事，尤其是在今年刚刚转换工作方向的背景下，基本上就是无时无刻都在焦虑自己是否足够努力，遇到技术问题心态就会很崩溃，和周围一些优秀的同事相比总觉得自己什么都不会。但其实只靠努力是解决不了所有问题的。</p><p>到写这篇文章时仔细想想，我对今年的工作产出以及技术成长还是比较满意的，所以也希望明年能够减少这些无意义的恶性焦虑，对自己有更清晰的认知。</p><h2 id="学习"><a href="#学习" class="headerlink" title="学习"></a>学习</h2><p>今年基本没有学习工作外的技术，博客也写得很少，主要还是把大部分的精力放在掌握工作所需的知识上了。</p><p>我之前没有看论文的习惯，今年大概看了十篇左右的论文，基本都是分布式数据库和 LSM-Tree 的一些知名论文。其实大部分论文阅读后都有对应的笔记，但是却没有放在博客中。一方面是觉得自己积累还不够，可能很多理解会有偏差，另一方面是觉得现有的论文基本上在网上都有相关的笔记了，相比之下也没有什么新的思考。</p><p>今年印象里也只看了《数据库系统内幕》这一本技术书，相比论文，一直没有找到比较感兴趣的书籍。团队内部还有《Oracle Core》的读书计划，但是我对这本书实在一点兴趣都提不起来，也就没有去参加。</p><p>明年可能主要还是以追踪论文为主，还是想把一些论文的笔记发到博客中，尽量多输出自己的想法。另外其实今年在内网还是记录了不少的思考和笔记，不过这些大多涉及到内部项目的背景，很难写成博客。明年希望能够找到一些合适的专题，总结一下现有开源项目的实现方式，尽量剥离开内部项目的实现去讲明白一个知识点。</p><h2 id="生活"><a href="#生活" class="headerlink" title="生活"></a>生活</h2><p>不得不说，和大多数人相比，疫情对我的影响已经很小了，所以我今年的生活还算安稳且充实。</p><h3 id="日常"><a href="#日常" class="headerlink" title="日常"></a>日常</h3><p>在我刚刚转到美团总部，完全适应不了工作环境的时候，疫情发生了，立马切换成了舒适的远程办公环境。我个人还是比较喜欢远程工作的，早上能够自己做点早饭吃，中午能够高质量的休息，而且我一般晚上状态会比较好，自己在家时晚上会更加放松，不像在公司时总觉得加班很压抑。</p><p>从 2018 年三刻停止营业后我就很少再因为兴趣爱好做料理了，但是今年疫情有了大把时间让我自己解决伙食，也把之前失去的热情又找了回来，直到现在我还在坚持着每周末从盒马买菜自己做一顿饭吃。</p><p>另外自从上班地点搬到了望京，我在三里屯的健身卡就彻底废了，而我又不愿意在公司的健身房锻炼，最后深思熟虑还是选择了买了一台划船机，让我家本不富裕的使用面积雪上加霜。不过偶尔能够在家边划船边看美剧，可能也是我目前最好的运动选择了。</p><h3 id="旅行"><a href="#旅行" class="headerlink" title="旅行"></a>旅行</h3><p>今年最意料之外的事情是旅行，本来我今年的大部分旅行计划都是去日本，一直等到下半年彻底放弃了，就把攒下来的年假都放到了国内旅行上。最终整理的时候发现还是去了不少地方，随便贴点废照片记录一下。</p><p><img src="/uploads/16094277601872.jpg" alt=""></p><p>镇江的一碗锅盖面，当地的鹅肉也非常好吃，但是应季的河豚却比在日本吃过的感觉差了很多。</p><p><img src="/uploads/16094277475281.jpg" alt=""></p><p>在丽江的几天是我今年旅行遇到过最好的天气，几乎每天都是蓝天白云。</p><p><img src="/uploads/16094277383747.jpg" alt=""></p><p>有一天在天津闲逛，偶然发现了海河边上老大爷跳水这项神奇的运动，不知不觉看了一下午，是我今年过得最安逸的下午。</p><p><img src="/uploads/16094277305084.jpg" alt=""></p><p>在西安临潼的悦椿泡温泉时刚好在下小雨，人很少并且气温很舒适。很推荐这家悦椿，住一晚还能参观一下兵马俑。</p><p><img src="/uploads/16094277212635.jpg" alt=""></p><p>泉州虽然是沿海城市，但是却不临海，当地也不怎么吃海鲜，反而是鸭子和牛肉比较受欢迎。除了当地的宗教文化给我留下了很深的印象之外，上图的粽子是我至今为止吃过的最好吃的粽子。</p><p><img src="/uploads/16094277039465.jpg" alt=""></p><p>在汕头几乎每天都吃十顿饭，给我留下很深印象的是有一天晚上去喝白粥，看到这个排挡就随意的摆在一个小区里。想起了我小时候也会偶尔吃这样的排挡，只是现在的北京已经几乎见不到了。</p><p><img src="/uploads/16094276945439.jpg" alt=""></p><p>十一的最后请了两天假顺道去了东山岛，只要错开游客和网红店体验真的很好，当地的海鲜好吃又便宜，上图是当时住的民宿，真的超出预期。</p><p>最后是年底去了三亚，之前没去过三亚，可能是我见识少，去了之后感觉旅游业（尤其是酒店）真的是吊打国内其他城市。这一个礼拜除了泡在酒店里哪里也没去，就随便贴几张酒店的照片吧。</p><p><img src="/uploads/16094276743932.jpg" alt=""></p><p>海棠湾的亚特兰蒂斯，水世界太好玩了，就是爬楼太累了，傍晚时分在水族馆里静静地看着游来游去的鱼也很有治愈。</p><p><img src="/uploads/16094276656466.jpg" alt=""></p><p>香水湾的君澜，人很少很安静，步行五分钟就是海滩，每天都能看到十几对情侣在酒店的各个角落拍婚纱照。</p><p><img src="/uploads/16094276315042.jpg" alt=""></p><p>亚龙湾的鸟巢度假村，住在山里的小木屋很有感觉，司机一个个都是秋名山车神。</p><h3 id="ACG"><a href="#ACG" class="headerlink" title="ACG"></a>ACG</h3><p>今年几乎没玩什么游戏，在疫情发生前我屯了一堆游戏，最后也只玩了《幻影异闻录 FE》，剩下的游戏到现在都没有拆封。后来到了三月，跟风玩了一波《动物森友会》，但是发现对社畜实在不太友好，现在就只是偶尔上去随便逛逛了。</p><p>和去年一样，今年也没有什么感兴趣的动画，不过倒是看了不少漫画。其中很多都不是长篇连载，但确实很有意思。想了下我去年没有推荐过漫画，所以今年可以连带着去年的份一起推荐一下。</p><p>我个人非常喜欢的漫画：</p><ul><li>《极道主夫》：今年还真人化了，不过电视剧没有发挥出漫画的笑点，还是更推荐看漫画</li><li>《地狱乐》：剧情一般般，但是画风很喜欢，也算比较长篇了，而且快完结了</li><li>《天地创造设计部》：欢乐又带有科普，脑洞很有意思，可惜太短了</li><li>《公主大人，接下来是“拷问”时间》：欢乐向漫画，汉化组加持</li><li>《疑似后宫》：很有意思的短篇漫画</li><li>《擅长捉弄人的（原）高木同学》：比起高木同学我个人更喜欢这部</li></ul><p>我个人一般喜欢，但是普遍接受程度很高的漫画：</p><ul><li>《电锯人》：精神病</li><li>《女儿的朋友》：抑郁症</li><li>《刃牙道》：不知道该说啥……</li><li>《BEASTARS》：去年很火的动画，我很早之前就开始追漫画了，但是感觉后面没有一开始那么惊艳了</li><li>《辉夜大小姐想让我告白》：是不是没必要推荐？其实这个看动画就挺好</li></ul><p>最后是日剧，今年依旧看了不少日剧，但是一整年下来竟然没有什么印象深刻的，只好再加几部二刷的老剧凑凑数。</p><p>我个人非常喜欢的日剧：</p><ul><li>《机动搜查队 404》：第一集很无聊，差点就弃了，但是从第二集开始渐入佳境</li><li>《极道主夫》：提一下只是为了再次推荐看漫画，电视剧就算了</li><li>《下町火箭2》：很中二、很圣母、但是同时也很热血，联想到自己的工作，我也想在佃制作所这样的团队工作啊</li><li>《到了 30 岁还是处男，似乎会变成魔法师》：I’m Not Gay！但是确实挺有意思的</li><li>《派遣员的品格 2》：谁又不想成为一个万能打工人呢？</li></ul><p>我个人一般喜欢，但是普遍接受程度很高的日剧：</p><ul><li>《半泽直树 2》：我觉得这一季不太好看，但是依旧是我身边人看的最多的日剧，而且下限也很高绝对能让人坚持看完</li><li>《还是要将恋爱进行到底》：虽然我不太喜欢看这类剧，但是这部也是同类型剧集的标杆了</li><li>《我的家政妇渚先生》：同上</li><li>《金装律师 2》：日版的 SUITS，比起美剧更喜欢看日剧的话可以考虑</li><li>《BG：贴身保镖 2》：木村大神粉丝可以看</li></ul><h2 id="结尾"><a href="#结尾" class="headerlink" title="结尾"></a>结尾</h2><p>写完发现我的年终总结真的是一年比一年水… 你看了开头以为我要聊一大堆技术话题，聊聊自己新的一年又卷了多少人，没想到我中途画风一转，直接跳到旅游和推荐日剧/漫画了吧。总之，这一年我仍然能够时刻保持对技术的热情，工作压力和焦虑都是客观存在的，而享受生活也是不可缺少的，在此也祝大家在新的一年也都能够 Work hard, Play hard!</p>]]></content>
    
    <summary type="html">
    
      &lt;p&gt;多灾多难的 2020 马上就要过去了，今年我换了新的工作方向，因为疫情被迫转换了很多生活方式。总体来说这一年我过得焦虑却又充实，并对新的一年充满期待。&lt;/p&gt;
    
    </summary>
    
    
  </entry>
  
  <entry>
    <title>[论文笔记] WiscKey: Separating Keys from Values in SSD-Conscious Storage</title>
    <link href="http://www.scienjus.com/wisckey/"/>
    <id>http://www.scienjus.com/wisckey/</id>
    <published>2020-07-13T14:02:04.000Z</published>
    <updated>2021-10-20T03:35:56.782Z</updated>
    
    <content type="html"><![CDATA[<p>阅读 WiscKey 论文时随手记录一些笔记。</p><p>这篇论文的核心思想理解起来还是很简单的，但是具体涉及到实现还有一些想不明白的地方，后来看到 TiKV 的 Titan 实现也很有趣，索性把这些问题都记录下来并抛出来。</p><p>本文中和论文相关的内容，<em>斜体</em>均为我个人的主观想法，关于 Titan 的实现，我只看过几篇公开文章以及粗浅的扫过一遍代码，如果这两部分的内容有理解错误欢迎指出，感谢！</p><a id="more"></a><h2 id="背景"><a href="#背景" class="headerlink" title="背景"></a>背景</h2><p>基于 LSM 树（Log-Structured Merge-Trees）的键值存储已经广泛应用，其特点是保持了数据的顺序写入和存储，利用磁盘的顺序 IO 得到了很高的性能（在 HDD 上尤其显著）。但是同一份数据会在生命周期中写入多次，随之带来高额的写放大。</p><p><img src="/uploads/15946493854361.jpg" alt=""></p><p>以 LevelDB 为例，数据写入的整个流程为：</p><ol><li>数据首先会被写入 memtable 和 WAL</li><li>当 memtable 达到上限后，会转换为 immutable memtable，之后持久化到 L0（称为 flush），L0 中每个文件都是一个持久化的 immutable memtable，多个文件间可以有相互重叠的 Key</li><li>当 L0 中的文件达到一定数量时，便会和 L1 中的文件进行合并（称为 compaction）</li><li>自 L1 开始所有文件都不会再有相互重叠的 Key，并且每个文件都会按照 Key 的顺序存储。每一层通常是上一层大小的 10 倍，当一层的大小超过限制时，就会挑选一部分文件合并到下一层</li></ol><p>由此可以计算出 LevelDB 的写放大比率：</p><ol><li>由于每一层是上一层大小的 10 倍，所以在最坏情况下，上一层的一个文件可能需要和下一层的十个文件进行合并，所以合并的写放大是 10</li><li>假设每行数据经过一系列 compaction 最终都会落入最终层，每层都需要重新写一次，那么从 L1 到 L6 的写放大为 5</li><li>加上 WAL 和 L0，最终写放大可能会超过 50</li></ol><p>另一方面，由于数据在 LevelDB 中的每一层（memtable/L0/L1~L6）都有可能存在，所以对于读请求，也会有一定的读放大：</p><ol><li>由于 L0 的多个文件允许有数据重叠，所以最坏情况需要查询所有文件</li><li>对于 L1 到 L6，因为数据有序且不重叠，所以每层需要查询一个文件</li><li>为了确认 Key 是否存在，对于每个文件都需要读取 index block、bloom-filter blocks 和 data block</li></ol><p>论文中提供了一个实际的数据：</p><p><img src="/uploads/15946502022182.jpg" alt=""></p><h2 id="WiscKey-介绍"><a href="#WiscKey-介绍" class="headerlink" title="WiscKey 介绍"></a>WiscKey 介绍</h2><h3 id="设计目标"><a href="#设计目标" class="headerlink" title="设计目标"></a>设计目标</h3><p>WiscKey 的核心思想是将数据中的 Key 和 Value 分离，只在 LSM-Tree 中有序存储 Key，而将 Value 存放在单独的 Log 中。这样带来了两点好处：</p><ol><li>当 LSM-Tree 进行 compaction 时，只会对 Key 进行排序和重写，不会影响到没有改变的 Value，也就显著降低了写放大</li><li>将 Value 分离后，LSM-Tree 本身会大幅减小，所以对应磁盘中的层级会更少，可以减少查询时从磁盘读取的次数，并且可以更好的利用缓存的效果</li></ol><p>另外，WiscKey 的设计很大一部分还建立在 SSD 的普及上，相比 HDD，SSD 有一些变化：</p><ol><li>SSD 的随机 IO 和顺序 IO 的性能差距并不像 HDD 那么大，所以 LSM-Tree 为了避免随机 IO 而采用了大量的顺序 IO，反而可能会造成了带宽浪费</li><li>SSD 拥有内部并行性，但 LSM-Tree 并没有利用到该特性</li><li>SSD 会因为大量的重复写入而产生硬件损耗，LSM-Tree 的高写入放大率会降低设备的寿命</li></ol><p>下图展示了在不同请求大小和并发度时，随机读和顺序读的吞吐量，可以看到在请求大于 16KB 时，32 线程的随机读已经接近了顺序读的吞吐：</p><p><img src="/uploads/15946506532648.jpg" alt=""></p><h3 id="实现"><a href="#实现" class="headerlink" title="实现"></a>实现</h3><p>在 LSM-Tree 的基础上，WiscKey 引入了一个额外的存储用于存储分离出的值，称为 Value Log。整体的读写路径为：</p><ol><li>当用户添加一个 KV 时，WiscKey 会先将 Value 写入到 Value-Log 中（顺序写），然后将 Key 和 Value 在 Value Log 中的地址写入 LSM-Tree</li><li>当用户删除一个 Key 时，仅在 LSM-Tree 中删除 Key 的记录，之后通过 GC 清理掉 Value Log 中的数据</li><li>当用户查询一个 Key 时，会先从 LSM-Tree 中查询到 Value 的地址，再根据地址将 Value 真正从 Value-Log 中读取出来（随机读）</li></ol><p>假设 Key 的大小为 16 Bytes，Value 的大小为 1KB，优化后的效果为：</p><ol><li>如果在 LSM-Tree 中的单层的写放大率是 10，那么使用 WiscKey 后单层的写放大率将变为 ((16 x 10) + (1024 x 1)) / (16 + 1024) = 1.14，远小于之前的 10 倍</li><li>如果一个标准的 LSM-Tree 的大小为 100G，那么将 Value 分离后 LSM-Tree 本身的大小将会减少到 2G，层级会减少 1~2 级，并且缓存到内存中的比例会更高，从而降低读放大</li></ol><p>看上去实现很简单，效果也很好，但是背后也存在了一些挑战和优化。</p><h3 id="挑战一：范围查询"><a href="#挑战一：范围查询" class="headerlink" title="挑战一：范围查询"></a>挑战一：范围查询</h3><p>在标准的 LSM-Tree 中，由于 Key 和 Value 是按照顺序存储在一起的，所以范围查询只需要顺序读即可遍历整个 SSTable 的所有数据。但是在 WiscKey 中，每个 Key 都需要额外的一次随机读才能读取到对应的 Value，因此效率会很差。</p><p>论文中的解决方案是利用上文中所提到的 SSD 内部的并行能力。WiscKey 内部会有一个 32 线程的线程池，当用户使用迭代器迭代一行时，迭代器会预先取出多个 Key，并放入到一个队列中，线程池会从队列中读取 Key 并行的查找对应的 Value。</p><p><img src="/uploads/15946512255415.jpg" alt=""></p><p><em>疑问：</em></p><ol><li><em>预取在某些场景是否会有浪费？（用户不准备迭代完所有数据的场景，例如 Limit 或是 Filter）</em></li><li><em>为什么用线程池 + 队列，而不是直接用异步 IO？</em></li></ol><h3 id="挑战二：垃圾收集（GC）"><a href="#挑战二：垃圾收集（GC）" class="headerlink" title="挑战二：垃圾收集（GC）"></a>挑战二：垃圾收集（GC）</h3><p>上文中提到了，当用户删除一个 Key 时，WiscKey 只会将 LSM-Tree 中的 Key 删除掉，所以需要一个额外的方式清理 Value-Log 中的值。</p><p>最简单的方法是定期扫描整个 LSM-Tree，获得所有还有引用的 Value 地址，然后将没有引用的 Value 删除，但是这个逻辑非常重。</p><p><img src="/uploads/15946515427042.jpg" alt=""></p><p>论文中介绍的方式是通过维护一个 Value Log 的有效区间（由 head 和 tail 两个地址组成），通过不断地搬运有效数据来达到淘汰无效数据。整个流程为：</p><ol><li>对于 Value-Log 中的每个值，需要额外存储 Key，为了方便从 LSM-Tree 中进行反查（相对 Value，Key 会比较小，所以写入放大不会增加太多）</li><li>从 tail 的位置读取 KV，通过 Key 在 LSM-Tree 中查询 Value 是否还在被引用</li><li>如果 Value 还在被引用，则将 Value 写入到 head，并将新的 Value 地址写回 LSM-Tree 中</li><li>如果 Value 已经没有被引用，则跳过这行数据，接着读取下一个 KV</li><li>当已经确认数据写入 head 之后，就可以将 tail 之后的数据都删除掉了</li></ol><p><em>因为需要重新写入一次 Value，并且需要将 Key 回填到 LSM-Tree 中，所以这个 GC 策略会造成额外的写放大。并且即使不做 GC，也只会影响到空间放大（删除的数据没有真正清理），所以感觉可以配置一些策略：</em></p><ol><li><em>根据磁盘负载和 LSM-Tree 的负载计算，仅在低峰期执行</em></li><li><em>计算每一段数据中被删除的比例有多少，当空洞变得比较大的时候才触发 GC</em></li></ol><h3 id="挑战三：崩溃一致性"><a href="#挑战三：崩溃一致性" class="headerlink" title="挑战三：崩溃一致性"></a>挑战三：崩溃一致性</h3><p>当系统崩溃时，LSM-Tree 可以保证数据写入的原子性和恢复的有序性，所以 WiscKey 也需要保证这两点。</p><p>WiscKey 通过查询时的容错机制保证 Key 和 Value 的原子性：</p><ol><li>当用户查询时，如果在 LSM-Tree 中找不到 Key，则返回 Key 不存在</li><li>如果在 LSM-Tree 中可以找到 Key，但是通过地址在 Value-Log 中无法找到匹配的 Value，则说明 Value 在写入时丢失了，同样返回不存在</li></ol><p>这个前提建立在于 WiscKey 通过一个 Write Buffer 批量提交 Value Log（下面有详细介绍），所以才会出现 Key 写入成功后 Value 丢失的场景，用户也可以通过设置同步写入，这样在刷新 Value Log 之后，才会将 Key 写入 LSM-Tree 中。</p><p>另外，WiscKey 通过现代的文件系统的特性保证了写入的有序性，即写入一个字节序列 b1, b2, b3…bn，如果 b3 在写入时丢失了，那么 b3 之后的所有值也一定会丢失。</p><h3 id="优化一：Write-Buffer"><a href="#优化一：Write-Buffer" class="headerlink" title="优化一：Write Buffer"></a>优化一：Write Buffer</h3><p>为了提高写入效率，WiscKey 首先会将 Value 写入到 Write Buffer 中，等待 Write Buffer 达到一定大小再一起刷新到文件中。所以查询时首先也要先从 WriteBuffer 中查询。当崩溃时，Write Buffer 中的数据会丢失，此时的行为就是上文中的崩溃一致性。</p><p><em>疑问：</em></p><ol><li><em>根据这个描述，Value-Log 似乎是异步写入？结合上文中崩溃一致性的介绍，会有给用户返回成功但是数据丢失的情况？</em></li></ol><h3 id="优化二：WAL-优化"><a href="#优化二：WAL-优化" class="headerlink" title="优化二：WAL 优化"></a>优化二：WAL 优化</h3><p>LSM-Tree 通过 WAL 保证了在系统崩溃时 memtable 中的数据可恢复，但是也带来了额外的一倍写放大。</p><p>而在 WiscKey 中，Value-Log 和 WAL 都是基于用户的写入顺序进行存储的，并且也具备了恢复数据的所有内容（前提是基于上文中的 GC 实现，Value Log 里存有 Key），所以理论上 Value-Log 是可以同时作为 WAL 的，从而减少 WAL 的写放大。</p><p>由于 Value Log 的 GC 比 WAL 更加低频，并且包含了大量已经持久化的数据，直接通过 Value-Log 进行恢复的话可能会导致回放大量已经持久化到 SST 的数据。所以 WiscKey 会定期将已经持久化到 SST 的 head 写入到 LSM-Tree 中，这样当恢复时只需要从最新持久化的 head 开始恢复即可。</p><p><em>疑问：</em></p><ol><li><em>Delete 操作只需要写 LSM-Tree，但如果需要 Value Log 作为 WAL，则 Delete 也需要写入到 Value Log 中</em></li><li><em>如果不应用这个优化，则可以做到只将大 Value 分离出 LSM-Tree，应用此优化后，小 Value 也必须要额外存到 Value Log 中了</em></li><li><em>与其说是用 Value Log 替代 WAL，不如说是让 WAL 支持读 Value…</em></li></ol><h2 id="效果"><a href="#效果" class="headerlink" title="效果"></a>效果</h2><p>说完实现再看看效果，论文中有 db_bench 和 YCSB 的数据，为了节约篇幅，只贴一部分 db_bench 的数据。</p><p>db_bench 的场景分两种，一种是所有 Key 按顺序写入（这样写放大会更低，数据在每一层会更紧凑），另一种是随机写入（写放大更高，数据在每一层分布更均匀）。</p><h3 id="顺序写入"><a href="#顺序写入" class="headerlink" title="顺序写入"></a>顺序写入</h3><p><img src="/uploads/15946525528572.jpg" alt=""></p><p><em>效果应该来自两部分：</em></p><ol><li><em>WAL 没了直接省了一倍写放大</em></li><li><em>顺序写入，每一层合并可以认为没有写放大，但是数据依旧要在每一层写一次，100 G 可能是 4~5 次（对应 L5 的大小）</em></li></ol><h3 id="随机写入"><a href="#随机写入" class="headerlink" title="随机写入"></a>随机写入</h3><p><img src="/uploads/15946526136478.jpg" alt=""></p><p><em>效果对比顺序写入，如果说为什么差距会这么大，只有可能是每一层合并造成的写放大了。</em></p><h3 id="点查"><a href="#点查" class="headerlink" title="点查"></a>点查</h3><p><img src="/uploads/15946526501920.jpg" alt=""></p><ol><li><em>当 Value 比较小时，WiscKey 的劣势在于额外的一次随机读，而 LevelDB 的劣势在于读放大。当 Value 变得更大时，基于 SSD 内部的并行能力，随机读依旧能读满带宽，但是 LevelDB 读放大造成的带宽浪费却没有改善。</em></li><li><em>另外这个测试场景是数据库大小为 100G，对于 LevelDB 来说，层级和 KV 大小挂钩，对于 WiscKey 来说，层级和 Key 大小挂钩，所以当 Value 越大，WiscKey 中的 LSM-Tree 反而更小，层级也就更低，甚至可能仅在内存中 (例如 Value  为 256KB 时，Key 加起来才 100G / (16 + 256 </em> 1024) <em> 16 ~= 610KB)</em></li></ol><p><img src="/uploads/15946527132087.jpg" alt=""></p><p><em>这个有点看不懂…：</em></p><ol><li><em>在数据集是顺序写的场景下，LevelDB 的性能随着 Value 的增大反而降低了，这个不太理解原因（理论上读放大不会很大，而且是顺序读，很容易就能读满带宽），WiscKey 因为是随机读，并且有上文中提到的 LSM-Tree 本身很小，随着 Value 变大性能越高是符合预期的</em></li><li><em>在数据集是随机写的场景下，一开始 WiscKey 性能低是因为随机读的延迟，随着 Value 增大，优势应该和点查类似</em></li></ol><h3 id="GC"><a href="#GC" class="headerlink" title="GC"></a>GC</h3><p><img src="/uploads/15946527662942.jpg" alt=""></p><p>上文提到了 GC 会重写 Value 以及写回 LSM-Tree，造成额外的写入。当空余空间的占比越高时（大部分数据都已经被删了），回写的数据越少，对性能的影响也就越小。</p><h2 id="Titan-的实现"><a href="#Titan-的实现" class="headerlink" title="Titan 的实现"></a>Titan 的实现</h2><p>BlobDB 和 Badger 的实现都和论文比较接近，并且也都是玩具。反而 TiKV 的 Titan 有一些独特的设计可以学习和讨论，所以下面只介绍这一案例。</p><h3 id="核心实现"><a href="#核心实现" class="headerlink" title="核心实现"></a>核心实现</h3><p><img src="/uploads/15946528563659.jpg" alt=""></p><p>和 WiscKey 的主要区别在于：Titan 在 flush/compaction 时才开始分离键值，并且用于存储分离后 Value 的文件（BlobFile）会按照 Key 的顺序存储，而不是写入的顺序（其实在这个阶段，已经没有写入顺序了）。</p><p>因此导致实现上的差异有：</p><ol><li>范围查询：由于 Value-Log 没有按照 Key 排序，所以 WiscKey 需要将一个范围查询拆解为多个随机读。而 Titan 保证了局部有序，在单个 BlobFile 内部可以顺序读，但是会有多个 BlobFile 的范围有重叠，需要额外做归并。另外对于预取策略，WiscKey 建立在 SSD 并行的优势上，可以靠增加并发预取增加吞吐，而 Titan 暂时没有如此激进的预取策略</li><li>WAL 优化：在 WiscKey 的实现中通过 Value Log 替代 WAL 减少了一倍写放大，而 Titan 在 flush/compaction 时才进行键值分离，肯定是没办法做这个优化的，不过这一点在 Titan 的设计文档里也提到了：「假设 LSM-tree 的 max level 是 5，放大因子为 10，则 LSM-tree 总的写放大大概为 1 + 1 + 10 + 10 + 10 + 10，其中 Flush 的写放大是 1，其比值是 42 : 1，因此 Flush 的写放大相比于整个 LSM-tree 的写放大可以忽略不计。」，个人觉得还是还比较信服的</li><li>GC 策略：Titan 目前有两个版本的 GC 策略，会在下面详细介绍</li></ol><h3 id="GC-策略"><a href="#GC-策略" class="headerlink" title="GC 策略"></a>GC 策略</h3><p>第一种策略（传统 GC）：</p><ol><li>首先挑选一些需要合并的 BlobFile，在 flush/compaction 时可以统计出每个 BlobFile 中已经删除的数据大小，从而挑选出空洞较大的文件</li><li>迭代这些 BlobFile，通过 Key 查询 LSM-Tree，判断 Value 是否还在被引用</li><li>如果 Value 还在被引用就写到新的 BlobFile 中，并把更新后的地址回填到 LSM-Tree 中</li></ol><p>这个实现和论文中的 GC 方案类似，只不过论文为了 WAL 需要写入一条完整的 Value Log，所以需要维护 head 和 tail。Titan 的实现只需要每次都生成新的 BlobFile 即可。</p><p>不同点在于：WiscKey 是随机读，Value Log 的大小不会影响到读 Value 的成本。GC 策略在于写放大和空间放大的权衡，所以 GC 可以更加低频。而 BlobFile 是顺序读，如果 BlobFile 中的无效数据太多，会影响到预取的效率，间接也会影响到读的性能。</p><p>第二种策略（Level-Merge）：</p><ol><li>不存在单独的 GC，由 LSM-Tree 的 compaction 触发</li><li>compaction 时，如果遍历到的值已经是一个 BlobIndex（代表值已经写入了某个 BlobFile），依旧将其读出来重新写入新的 BlobFile，也就是说每次 compaction 都会生成一批与新的 SST 完全对应的 BlobFile</li><li>目前 Level Merge 只在最后两层开启</li></ol><p>开启 Level Merge 后相当于 GC 频率和 compaction 频率持平了（GC 频率最多也只能和 compaction 持平），并且在这个基础上，直接在 compaction 里做 GC，可以减少一次回写 LSM-Tree 的成本（因为在 compaction 的过程中就能将老的 Value 地址替换掉）。</p><p>这种策略的优点在于 BlobFile 中不再有无效数据，可以用更加激进的预取策略提高范围查询的性能，缺点是写放大肯定会比之前更大（个人觉得开启后，写放大就和标准 LSM-Tree 完全一样了吧（一次 compaction 需要合并的 Key 和 Value 都需要重写一遍）？），所以只在最后两层开启。</p><h3 id="效果-1"><a href="#效果-1" class="headerlink" title="效果"></a>效果</h3><p>Titan 的性能测试结果摘自<a href="https://pingcap.com/blog-cn/titan-design-and-implementation/" target="_blank" rel="external">官网的文章</a>，大部分结论都和 WiscKey 类似，并且文章中也分析了原因，就不在此赘述了。</p><p>因为文章是 19 年初的，所以还没有上文中的 Level Merge GC，不过 GC 策略理论上只影响范围查询的性能，所以在此贴一下范围查询的性能：</p><p><img src="/uploads/15946541646207.jpg" alt=""></p><p>在实现 Level Merge GC 的策略之前，Titan 的范围查询只有 RocksDB 的 40%，主要原因应该还是分离后需要额外读一次 Value，以及没办法并行预取增加吞吐。 这点文章最后也提到了：</p><blockquote><p>我们通过测试发现，目前使用 Titan 做范围查询时 IO Util 很低，这也是为什么其性能会比 RocksDB 差的重要原因之一。因此我们认为 Titan 的 Iterator 还存在着巨大的优化空间，最简单的方法是可以通过更加激进的 prefetch 和并行 prefetch 等手段来达到提升 Iterator 性能的目的。</p></blockquote><p>另外在 <a href="https://book.tidb.io/session1/chapter8/titan-in-action.html" target="_blank" rel="external">TiDB in Action</a> 也提到了 Level Merge GC 可以「大幅提升 Titan 的范围查询性能」，不知道除了完全去掉无效数据之外，是否还有其他的优化，还需要再看下代码。</p><h2 id="感受"><a href="#感受" class="headerlink" title="感受"></a>感受</h2><p>个人认为 WiscKey 的核心思想还是比较有意义的，毕竟适用的场景很典型而且还比较常见：大 Value、写多读少、点查多范围查询少，只要业务场景命中一个特点，效果应该就会非常显著了。</p><p>对于论文中的具体实现是否能套用在一个真实的工业实现中，我觉得大部分实现还是简单有效的，但是也有一些设计个人不太喜欢，例如使用 Value Log 替代 WAL 的方案，感觉有些过于追求减少写放大了，可能反而会引入其他问题，以及默认的 GC 策略还要写回 LSM-Tree 也有些别扭。</p><p>在和其他同事讨论内部项目的实现时，也畅想过一些其他玩法，例如只将 Value 中的一部分分离出来单独存储，或是一个分布式的 WAL 是否也能转换为 Value Log，会有哪些问题。包括看到 Titan 的实现时，我也很好奇设计成 BlobFile 这种顺序读的方式是否有什么深意（毕竟论文都把利用 SSD 写到标题里了），或者只是因为从 compaction 才开始分离键值最简单的做法就是按顺序存储 KV。</p><p>总之，期待将来能有更多工业实现落地，看到更多有趣的案例。</p><h2 id="参考资料"><a href="#参考资料" class="headerlink" title="参考资料"></a>参考资料</h2><ul><li><a href="https://www.usenix.org/system/files/conference/fast16/fast16-papers-lu.pdf" target="_blank" rel="external">WiscKey: Separating Keys from Values in SSD-conscious Storage</a></li><li><a href="https://nan01ab.github.io/2018/07/WiscKey.html" target="_blank" rel="external">https://nan01ab.github.io/2018/07/WiscKey.html</a></li><li><a href="https://www.usenix.org/system/files/conference/fast16/fast16-papers-lu.pdf" target="_blank" rel="external">Titan 的设计与实现</a></li><li><a href="https://book.tidb.io/session1/chapter8/titan-internal.html" target="_blank" rel="external">Titan 原理介绍</a></li></ul>]]></content>
    
    <summary type="html">
    
      &lt;p&gt;阅读 WiscKey 论文时随手记录一些笔记。&lt;/p&gt;
&lt;p&gt;这篇论文的核心思想理解起来还是很简单的，但是具体涉及到实现还有一些想不明白的地方，后来看到 TiKV 的 Titan 实现也很有趣，索性把这些问题都记录下来并抛出来。&lt;/p&gt;
&lt;p&gt;本文中和论文相关的内容，&lt;em&gt;斜体&lt;/em&gt;均为我个人的主观想法，关于 Titan 的实现，我只看过几篇公开文章以及粗浅的扫过一遍代码，如果这两部分的内容有理解错误欢迎指出，感谢！&lt;/p&gt;
    
    </summary>
    
    
  </entry>
  
  <entry>
    <title>2019 年终总结</title>
    <link href="http://www.scienjus.com/2019-year-end-review/"/>
    <id>http://www.scienjus.com/2019-year-end-review/</id>
    <published>2020-01-24T15:59:59.000Z</published>
    <updated>2020-12-31T15:24:05.233Z</updated>
    
    <content type="html"><![CDATA[<p>迟来的 2019 年总结，总体来说今年没有什么大变化，在此给关心我的朋友（可能并不存在）同步一下近况，顺便给明年写总结时留下些素材。</p><a id="more"></a><h2 id="工作"><a href="#工作" class="headerlink" title="工作"></a>工作</h2><p>今年工作上做了一个重大的转型，目前来看还算是痛并快乐着。</p><h3 id="云原生"><a href="#云原生" class="headerlink" title="云原生"></a>云原生</h3><p>在 <a href="http://www.scienjus.com/2018-year-end-review/">去年的总结</a> 中曾经提到，我在今年主要的方向是云原生领域，希望能围绕着 Service Mesh 做一些能落地的实践，例如可以细粒度控制流量以及根据监控自动控制灰度的发布系统。</p><p>然而很不幸，随着去年下半年摩拜被美团收购，团队的技术发展路线产生了一些变化：摩拜自身对基础设施的投入变得更少，转而努力融入到美团现有的中间件中。</p><p>被收购的公司融入收购方的技术栈，得到成本更低、稳定性更好的方案，这当然是符合历史规律的。只是我所在的团队在落地上却出现了问题：在云原生方向一边减少实质性的技术投入，一边盲目的跟风 CNCF 引进一些无脑的黑盒项目。</p><p>对我而言，即使是搞 kubernetes 的相关技术，每天写一些简单的 webhook/controller 插件和 CRUD 也没有什么本质区别。再加上业务体量所限制的集群规模，以及业务方对调度策略、资源隔离、QoS、可观测性、灰度等平台赋能并没有很强烈的需求，导致个人觉得运维这样的无状态集群并没有办法积累有深度的经验。</p><p>另一方面我觉得整个云原生技术领域对于中小型公司来说有点太虚无缥缈了，很多流行但是问题多多的黑盒项目可能只是隐藏掉了我们当前可见的问题，却又增加了很多未知的隐患，还会带给很多程序员盲目的自信。在我的身边就有很多的例子，每天连代码都不写的人整天聊着各种 CNCF 的明星项目，执着于用 YAML 跑通一个 hello world 就拿去给别人用，在这点和大公司的应用场景还是有很大区别的。</p><p>所以今年很长的一段时间内都在浑浑噩噩的搞这些黑盒软件并且自我怀疑，后来在晋升答辩的时候被所有评委都评价深度不够也证实了这点，直到最后觉得自己在当前的团队实在是找不到一条出路了，只能被迫寻求改变。</p><p><img src="/uploads/orebana-1.jpg" alt="orebana"></p><h3 id="分布式数据库"><a href="#分布式数据库" class="headerlink" title="分布式数据库"></a>分布式数据库</h3><p>因为一些前同事和摩拜同事的认可，发现转岗还是有一些选择的。具体过程就不细说了，最终选择加入了美团基础平台的分布式数据库团队。</p><p>转岗之前一度担心自己的技术栈和新团队很不匹配，毕竟毕业之后就再也没正经写过 C++，对数据库的实现原理也只停留在书本上。转岗之后之后才发现自己多虑了，新团队的技术氛围很好，让我很快的找回了久违的学习热情，不光新技术栈的上手速度超出预期，而且感觉未来很长一段时间都会过得非常充实。</p><p>在 <a href="https://www.scienjus.com/2016-year-end-review/">2016 年的总结</a> 中我曾经提到自己很爱鼓捣编程语言，当前流行的编程语言也都或多或少的用在一些个人项目和跑在生产的项目中，但是其中并不包含 C++。</p><p>之前我只在学生阶段做作业和学习 Mooc 课程的时候用过一些 C++，在我的固有印象里一直认为 C++ 陈旧、复杂、不安全，并且对新手十分不友好。但是真正开始在实际的大型项目中写 C++ 之后，我才发现自己的很多印象可能是错误的，C++ 实际也是一门很有趣的语言，像智能指针、RAII、SFINAE 等技巧以及其应用场景是在其他编程语言中很难遇到的，所以在学习的过程中我也改变了很多思维方式，对 Rust 中一些相同的功能也有了更深的了解。</p><h3 id="选择"><a href="#选择" class="headerlink" title="选择"></a>选择</h3><p>和去年相同的子标题，去年我觉得自己做了很多错误的选择，可能今年也是一样。但是今年至少还是遵从本心做了一个最重要的决定。并且过了几个月之后来看，虽然失去了很多东西，但我依旧认为这个决定无比正确。</p><p>通过这件事也更新了我的很多认知，举一些例子：</p><ol><li>以前每年都会有很长一段时间不定期的焦虑，觉得自己的职业规划、技术方向很模糊，担心自己的技术提升停滞（我每年的年终总结似乎也都很悲观），但是今年开始感觉自己未来两年都不会有这种感觉了</li><li>以前一直觉得自己只有在擅长的领域才能做好事情，现在发现其实我也积累很多软技能和好习惯，即使在某个领域从零开始也没什么可怕的，重要的是一直在前进</li><li>长期在一个不好的环境对人的影响可能是超乎意料的，就像温水煮青蛙一样。来到新团队之后我发现自己受之前的影响，在学习热情、与同事的协作、沟通方式上都退化的很严重，但是好在还能慢慢恢复过来</li></ol><p>明年的主要目标是积累足够的数据库领域的相关经验，以及能够熟练地写出高质量的 C++ 代码，更重要的是，把手头上的这个项目成为一个能让自己的自豪的项目。</p><p><img src="/uploads/Grand.Maison.Tokyo-01.jpg" alt="Grand.Maison.Tokyo"></p><p><img src="/uploads/Grand.Maison.Tokyo-02.png" alt="Grand.Maison.Tokyo"></p><h2 id="学习"><a href="#学习" class="headerlink" title="学习"></a>学习</h2><p>和工作一样，上半年整体状态都不太好，没有什么学习欲望，零碎的在一些开源项目里打工，最大的产出可能是在 SOFA RPC 中添加了 Hystrix 支持和 Consul 注册中心，也因此成为了这个项目的社区 Committer。</p><p>同样博客的产出也少得可怜，我在上半年做过一些内部分享，不过基本上都是向其他同事介绍 Istio 这种目前比较流行的技术栈，或是 Arthas 这样的排查工具，没有太多技术含量。</p><p>明年除了工作相关的技术栈以外，还想好好学习下 Rust，以及系统性的学些一些系统开发的知识，另外等有一些积累之后，重新开始写一些更有质量的博客。</p><h2 id="生活"><a href="#生活" class="headerlink" title="生活"></a>生活</h2><p>生活上没什么特殊的，主要就是到处乱跑，以及在家窝着看日剧。</p><h3 id="旅行"><a href="#旅行" class="headerlink" title="旅行"></a>旅行</h3><p>今年去了两次日本，并且顺利的办下了三年多次签证（北京户口极简竟然卡在了年龄上，只能老实的提交各种流水证明）。</p><p>第一次是在春节，去了大阪、京都、奈良，最后从东京回程。春节的日本没有想象的人那么多，体验还是很不错的。</p><p><img src="/uploads/15798860301044.jpg" alt=""></p><p>第二次是在秋天枫叶季，结果今年降温慢，枫叶开的普遍较晚，我提前三个月定好的行程不出意外去早了。</p><p><img src="/uploads/15798865305023.jpg" alt=""></p><p>明年暂时打算樱花季再去日本，不过不太想去关西了，可能会去九州地区或是名古屋吧，之前坐船的时候去过一次福冈，印象还挺好的。</p><h3 id="追番-追剧"><a href="#追番-追剧" class="headerlink" title="追番/追剧"></a>追番/追剧</h3><p>感觉自己年龄越大，追番的兴趣越来越小了。今年追的番基本上可以分为两类，一类是以前看过的漫画动画化了（比如 BEASTARS、辉夜姬），另一类是看过的动画出了续集（比如灵能百分百、齐木楠雄），基本没接触全新的番剧，以后可能看的会越来越少吧。</p><p>与之相对的是，今年花了大量的时间看日剧，基本上每季度都看了三、四部，其中最喜欢的是《我的事说来话长》，推荐给各位。</p><p><img src="/uploads/orebana-2.jpg" alt="orebana"></p><p>整体的推荐列表如下：</p><ul><li>个人最推荐：《我的事说来话长》</li><li>推荐接受率最高/评价最好：《东京大饭店》</li><li>个人感触最深：《凪的新生活》</li><li>比较小众但挺有意思的：《这个不可以报销！》</li><li>程序员相关（？）：《我，到点下班》</li><li>我本人的故事：《还是不能结婚的男人》</li></ul><h3 id="游戏"><a href="#游戏" class="headerlink" title="游戏"></a>游戏</h3><p>我一向只玩任天堂，上半年因为工作比较清闲，就一直在玩风花雪月，打完三条线之后觉得自己还是挺难接受剧情上的互相残杀，就没打教团线。后来还玩了一段时间哆啦A梦牧场物语，但是始终找不回小时候玩矿石镇的感觉了，所以矿石镇重置也没买。</p><p>下半年工作比较忙了之后，几乎找不到完整的时间玩 NS 了，所以开始用碎片时间玩明日方舟，氪度适中并且整体不怎么肝，应该还会玩挺长一阵子。</p><p>除此之外，为了保持运动还购入了有氧拳击、健身环、尬舞等游戏，不过除了健身环其他玩的频次都不怎么高。</p>]]></content>
    
    <summary type="html">
    
      &lt;p&gt;迟来的 2019 年总结，总体来说今年没有什么大变化，在此给关心我的朋友（可能并不存在）同步一下近况，顺便给明年写总结时留下些素材。&lt;/p&gt;
    
    </summary>
    
    
  </entry>
  
  <entry>
    <title>MyRocks 学习笔记之创建表</title>
    <link href="http://www.scienjus.com/myrocks-notes-creating-tables/"/>
    <id>http://www.scienjus.com/myrocks-notes-creating-tables/</id>
    <published>2019-07-21T13:03:19.000Z</published>
    <updated>2019-07-22T03:18:26.916Z</updated>
    
    <content type="html"><![CDATA[<h2 id="前言"><a href="#前言" class="headerlink" title="前言"></a>前言</h2><p>目前网上介绍 MyRocks 的文章虽然不少，但是大部分都只介绍了一些 RocksDB 的核心特性和读写原理，却几乎不会提到 MyRocks 在实现 MySQL 存储引擎相关的内容，并且由于 MySQL 官方对于存储引擎的开发资料也提供的很单薄，所以对于新人来说难免有些手足无措。</p><p>这个系列希望通过从 MySQL 存储引擎的 API 作为起点，结合 MyRocks 的实现，记录下每一个功能的全貌，包括自定义的存储引擎在每一个 API 中具体需要实现哪些功能，以及 MyRocks 是如何通过 RocksDB 实现这些功能的，其优缺点是什么。希望能够帮助一些初学者（包括我自己）如何从零开始或是二次开发一个 MySQL 存储引擎。</p><p>这篇笔记是第一章，介绍了创建表（Creating Tables）的流程。</p><a id="more"></a><h2 id="接口定义"><a href="#接口定义" class="headerlink" title="接口定义"></a>接口定义</h2><p>官方文档的 <a href="https://dev.mysql.com/doc/internals/en/creating-tables.html" target="_blank" rel="external">Creating Tables</a> 章节简要的介绍了自定义的存储引擎如何实现创建表的功能，只需要实现 <code>create</code> 这个虚函数即可。</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">virtual int create(const char *name, TABLE *form, HA_CREATE_INFO *info)=0;</span><br></pre></td></tr></table></figure><p>存储引擎需要在这个函数中创建所有与表结构和索引结构相关的数据文件，它有三个参数：</p><ul><li><code>name</code>: 该表的表名</li><li><code>form</code>: 该表的元数据信息，主要包含表结构、字段和索引的信息</li><li><code>info</code>: 创建表时的额外的配置信息，基本都是 <code>CREATE TABLE</code> 时附带的选项</li></ul><h2 id="MyRocks-实现"><a href="#MyRocks-实现" class="headerlink" title="MyRocks 实现"></a>MyRocks 实现</h2><p>MyRocks 的实现在 <code>ha_rocksdb.cc</code> 的 <code>ha_rocksdb::create</code> 方法中。主要逻辑分为两部分：</p><ol><li>对用户提交的信息做一些转换和校验，拒绝 MyRocks 存储引擎不支持的配置</li><li>按照 MyRocks 的存储方式重新组织表结构和索引结构，并存储在内存和 RocksDB 中</li></ol><h3 id="前置处理"><a href="#前置处理" class="headerlink" title="前置处理"></a>前置处理</h3><p>MyRocks 首先会对创建表的配置信息进行前置处理，包括配置的检查和转换，拦截该存储引擎不支持的配置等，主要流程为：</p><ol><li><code>DATA DIRECTORY</code> 和 <code>INDEX DIRECTORY</code> 支持将该表的数据文件和索引文件存放在一个指定的路径。MyRocks 不支持这两个配置，而是通过 <code>rocksdb_datadir</code> 配置 RocksDB 存放数据的地址。</li><li>参数中的表名格式为 <code>./$dbname/$tablename</code>，MyRocks 会将其格式化为 <code>$dbname.$tablename</code>，便于之后处理。</li><li>解析 SQL 语句中是否含有外键定义，MyRocks 不支持外键，如果含有外键也会返回错误。</li></ol><p>接下来还需要检查当前这个表是否已经存在了，在 <code>TRUNCATE TABLE</code> 语句下需要删除重名的表信息，其他情况下报错。</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line">Rdb_tbl_def *tbl = ddl_manager.find(str);</span><br><span class="line">if (tbl != nullptr) &#123;</span><br><span class="line">  if (thd-&gt;lex-&gt;sql_command == SQLCOM_TRUNCATE) &#123;</span><br><span class="line">    err = delete_table(tbl);</span><br><span class="line">    if (err != HA_EXIT_SUCCESS) &#123;</span><br><span class="line">      DBUG_RETURN(err);</span><br><span class="line">    &#125;</span><br><span class="line">  &#125; else &#123;</span><br><span class="line">    my_error(ER_METADATA_INCONSISTENCY, MYF(0), str.c_str(), name);</span><br><span class="line">    DBUG_RETURN(HA_ERR_ROCKSDB_CORRUPT_DATA);</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>其中包含两个细节：</p><p>什么时候会出现 <code>CREATE TABLE</code> 到存储引擎时，ddl_manager 中已经有了表的数据，却没有被上层拦截？</p><p>在<a href="https://github.com/facebook/mysql-5.6/issues/37" target="_blank" rel="external">这个 Issue </a>中提到了一个场景，即 frm 文件丢失（例如被人工删除）的情况，会进入该逻辑，需要做容错处理。</p><p>为什么需要判断 <code>sql_command == SQLCOM_TRUNCATE</code>，什么场景会出现？</p><p>通过看 <code>sql_truncate.cc</code> 中的逻辑猜测，如果存储引擎支持通过重建表实现 <code>TRUNCATE TABLE</code> 功能，那么上层会直接通过 <code>create</code> 方法创建一个结构完全相同的空表，而不是通过存储引擎实现的 <code>truncate</code> 方法。</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line">bool hton_can_recreate;</span><br><span class="line"></span><br><span class="line">if (lock_table(thd, table_ref, &amp;hton_can_recreate))</span><br><span class="line">  DBUG_RETURN(TRUE);</span><br><span class="line"></span><br><span class="line">if (hton_can_recreate)</span><br><span class="line">&#123;</span><br><span class="line">  /*</span><br><span class="line">    The storage engine can truncate the table by creating an</span><br><span class="line">    empty table with the same structure.</span><br><span class="line">  */</span><br><span class="line">  error= dd_recreate_table(thd, table_ref-&gt;db, table_ref-&gt;table_name);</span><br><span class="line"></span><br><span class="line">  if (thd-&gt;locked_tables_mode &amp;&amp; thd-&gt;locked_tables_list.reopen_tables(thd))</span><br><span class="line">      thd-&gt;locked_tables_list.unlink_all_closed_tables(thd, NULL, 0);</span><br><span class="line"></span><br><span class="line">  /* No need to binlog a failed truncate-by-recreate. */</span><br><span class="line">  binlog_stmt= !error;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>并且 MyRocks 是支持 <code>HTON_CAN_RECREATE</code> 功能的。</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">rocksdb_hton-&gt;flags = HTON_TEMPORARY_NOT_SUPPORTED |</span><br><span class="line">                      HTON_SUPPORTS_EXTENDED_KEYS | HTON_CAN_RECREATE;</span><br></pre></td></tr></table></figure><p>所以需要考虑到这种情况，删除当前该表的数据并继续执行创建流程。</p><h3 id="创建表和索引"><a href="#创建表和索引" class="headerlink" title="创建表和索引"></a>创建表和索引</h3><p>创建表和索引的主要流程也就是将表结构以及索引结构存储到硬盘的流程。其中 <code>ddl_manager</code> 对象就是 MyRocks 中对 RocksDB 操作的封装。顾名思义，这个类只负责 DDL 相关操作的存储。</p><h4 id="开启事务"><a href="#开启事务" class="headerlink" title="开启事务"></a>开启事务</h4><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">const std::unique_ptr&lt;rocksdb::WriteBatch&gt; wb = dict_manager.begin();</span><br></pre></td></tr></table></figure><p>WriteBatch 是 RocksDB 中原子操作和批量操作的封装类。之后所有对 RocksDB 的写入操作都将写入到该 WriteBatch 中，这样可以保证这些操作可以合并成一个原子操作提交到 RocksDB 中，不会出现一部分逻辑报错导致数据不一致的情况。</p><h4 id="设置隐藏主键"><a href="#设置隐藏主键" class="headerlink" title="设置隐藏主键"></a>设置隐藏主键</h4><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line">/*</span><br><span class="line">  If no primary key found, create a hidden PK and place it inside table</span><br><span class="line">  definition</span><br><span class="line">*/</span><br><span class="line">if (has_hidden_pk(table_arg)) &#123;</span><br><span class="line">  n_keys += 1;</span><br><span class="line">  // reset hidden pk id</span><br><span class="line">  // the starting valid value for hidden pk is 1</span><br><span class="line">  m_tbl_def-&gt;m_hidden_pk_val = 1;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>MyRocks 支持表不设置主键，但是 RocksDB 底层的 KV 存储强依赖表的主键，所以在这里会自动增加隐藏主键列，并对上层透明。</p><h4 id="检查索引规范"><a href="#检查索引规范" class="headerlink" title="检查索引规范"></a>检查索引规范</h4><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line">/* MyRocks supports only the following collations for indexed columns */</span><br><span class="line">static const std::set&lt;const my_core::CHARSET_INFO *&gt; RDB_INDEX_COLLATIONS = &#123;</span><br><span class="line">    &amp;my_charset_bin, &amp;my_charset_utf8_bin, &amp;my_charset_latin1_bin&#125;;</span><br><span class="line"></span><br><span class="line">static bool rdb_is_index_collation_supported(</span><br><span class="line">    const my_core::Field *const field) &#123;</span><br><span class="line">  const my_core::enum_field_types type = field-&gt;real_type();</span><br><span class="line">  /* Handle [VAR](CHAR|BINARY) or TEXT|BLOB */</span><br><span class="line">  if (type == MYSQL_TYPE_VARCHAR || type == MYSQL_TYPE_STRING ||</span><br><span class="line">      type == MYSQL_TYPE_BLOB) &#123;</span><br><span class="line">    return RDB_INDEX_COLLATIONS.find(field-&gt;charset()) !=</span><br><span class="line">           RDB_INDEX_COLLATIONS.end();</span><br><span class="line">  &#125;</span><br><span class="line">  return true;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>当索引字段为 <code>varchar/string/blob</code> 等字符类型时，MyRocks 只支持编码为 <code>binary/utf8_bin/latin1_bin</code>。</p><p>通过关闭 <code>rocksdb-strict-collat​​ion-check</code> 或是在 <code>rocksdb-strict-collat​​ion-exceptions</code> 配置表名可以跳过这个检查。</p><h4 id="创建-Column-Family"><a href="#创建-Column-Family" class="headerlink" title="创建 Column Family"></a>创建 Column Family</h4><p>在 RocksDB 中，每一个 KV 都会关联一个列族（Column Family，之后简称为 CF），而 MyRocks 是以索引为粒度存储 KV 数据的，所以支持为每个索引配置一个可选的 CF，默认存放在 <code>default</code> 中。</p><p>CF 的名称可以通过索引的整个注释内容或是 <code>cfname=$name</code> 选项进行配置，例如：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">CREATE TABLE sample (</span><br><span class="line">    id INT PRIMARY KEY AUTO_INCREMENT,</span><br><span class="line">    uid INT,</span><br><span class="line">    name VARCHAR(25),</span><br><span class="line">    ts TIMESTAMP,</span><br><span class="line">    KEY(`uid`) COMMENT &apos;cfname=cf_uid&apos;,</span><br><span class="line">    KEY(`name`) COMMENT &apos;cf_name&apos;</span><br><span class="line">) ENGINE=ROCKSDB</span><br></pre></td></tr></table></figure><p>其中 id 的主键索引会关联到 <code>default</code> CF 中，uid 的索引会关联到 <code>cf_uid</code> 中，而 name 的索引会关联到 <code>cf_name</code> 中。</p><p>在代码中的实现逻辑很简单，只是遍历每个索引，通过注释截取出 CF 的值。<br>CF 不能是 <code>__system__</code>，这个 CF 已经预留给了存放系统的数据，包括之后将会存放表结构和索引结构的数据。</p><p>在之前的版本中，还可以通过 <code>cfname=$per_index_cf</code> 自动生成格式为 <code>$tablename.$indexname</code> 的名称，但是在最新版本的代码中已经不支持了。</p><p>光从建表的流程中我们还不知道索引的 CF 具体的用途是什么，会在之后的写入数据的文章再详细介绍。</p><h4 id="读取-TTL-数据"><a href="#读取-TTL-数据" class="headerlink" title="读取 TTL 数据"></a>读取 TTL 数据</h4><p>因为 RocksDB 本身支持 TTL，所以 MyRocks 也支持在建表时设置每一条记录的 TTL 选项，通过表级别的注释 <code>ttl_duration=1;ttl_col=ts</code> 进行设置。</p><h4 id="生成索引-ID"><a href="#生成索引-ID" class="headerlink" title="生成索引 ID"></a>生成索引 ID</h4><p><code>Rdb_ddl_manager</code> 在内存中维护了一个自增的索引 id，启动时会从本地 RocksDB 中读取并初始化。当需要创建索引时，会通过调用 <code>get_and_update_next_number</code> 方法申请一个 id。其会在内存中加锁自增后写入 RocksDB，其格式为：</p><ul><li>Key：<code>Rdb_key_def::MAX_INDEX_ID</code></li><li>Value：<code>Rdb_key_def::MAX_INDEX_ID_VERSION, val</code></li></ul><h4 id="初始化自增起始值"><a href="#初始化自增起始值" class="headerlink" title="初始化自增起始值"></a>初始化自增起始值</h4><p>如果建表时指定自增主键的初始值 <code>auto_increment</code>，MyRocks 则会将其写入 system CF 中，格式为：</p><ul><li>Key：<code>Rdb_key_def::AUTO_INC, cf_id, index_id</code></li><li>Value：<code>Rdb_key_def::AUTO_INCREMENT_VERSION, auto_increment_value</code></li></ul><p>这里通过 RocksDB 的 merge operator 实现了更高性能的自增操作，不过建表时肯定是初始化，所以语义应该和 Put 相同。</p><h4 id="写入-CF-Flags"><a href="#写入-CF-Flags" class="headerlink" title="写入 CF Flags"></a>写入 CF Flags</h4><p>目前 MyRocks 有两个 CF 级别的配置，需要额外存储到以 CF 为单位的数据中，被称为 CF Flags，包括：</p><ol><li><code>is_per_partition_cf</code> 表示这个 CF 是否为某个分区表特定的 CF，例如配置 <code>p0_cfname=cf_p0</code>。</li><li><code>is_reverse_cf</code> 表示这个 CF 中存储的数据是否要反向存储，这样会使降序查询（<code>order by desc</code>）更快，配置方法是 <code>cfname=rev:xxx</code>。</li></ol><p>这两个 flag 分别占用 1bit，最终会合并保存在 RocksDB 中，格式为：</p><ul><li>Key：<code>Rdb_key_def::CF_DEFINITION, cf_id</code></li><li>Value：<code>Rdb_key_def::CF_DEFINITION_VERSION, flags</code></li></ul><p>因为多个索引可以共享同一个 CF，所以需要保证索引在创建时，CF 的配置不能和之前索引的冲突。</p><h4 id="写入索引信息"><a href="#写入索引信息" class="headerlink" title="写入索引信息"></a>写入索引信息</h4><p>在 <code>Rdb_dict_manager::add_or_update_index_cf_mapping</code> 方法中，会将每一个 Index 的信息存储在 RocksDB 中。</p><ul><li>Key：<code>Rdb_key_def::INDEX_INFO, cf_id, index_id</code></li><li>Value：<code>Rdb_key_def::INDEX_INFO_VERSION_LATEST, index_type, kv_version, index_flags, ttl_duration</code></li></ul><h4 id="写入表和索引的映射关系"><a href="#写入表和索引的映射关系" class="headerlink" title="写入表和索引的映射关系"></a>写入表和索引的映射关系</h4><p>在 <code>Rdb_tbl_def::put_dict</code> 中，会将一个表所对应的 CF 和 Index 存储到 RocksDB 中。</p><ul><li>Key：<code>Rdb_key_def::DDL_ENTRY_INDEX_START_NUMBER, db_table_name</code></li><li>Value：<code>DDL_ENTRY_INDEX_VERSION, cf_id1, index_id1[, cf_id2, index_id2...]</code></li></ul><h4 id="缓存表信息"><a href="#缓存表信息" class="headerlink" title="缓存表信息"></a>缓存表信息</h4><p>之后 <code>Rdb_ddl_manager::put</code> 方法会将这些信息同样缓存在内存中，便于之后其他操作使用，主要存放了两部分数据：</p><ol><li>在 <code>m_ddl_map</code> 中缓存 <code>db_table_name</code> 对应的 <code>Rdb_tbl_def</code></li><li>在 <code>m_index_num_to_keydef</code> 中缓存 <code>index_id, cf_id</code> 和 <code>db_table_name, index_no</code> 的映射关系</li></ol><h4 id="提交事务"><a href="#提交事务" class="headerlink" title="提交事务"></a>提交事务</h4><p>在所有操作处理完后，<code>Rdb_dict_manager::commit</code> 方法将以上所有的改动都通过 WriteBatch 提交到 RocksDB 中，同最开始提到的一样，这是一个原子操作，只会全部成功或是全部失败。</p><h2 id="实际验证"><a href="#实际验证" class="headerlink" title="实际验证"></a>实际验证</h2><p>光看代码肯定会存在遗漏或是理解错误的地方，接下来让我们实际创建一张表验证一下。</p><p>以下是用来测试的表，一共有四个字段、一个主键索引和两个普通索引，并且通过注释指定了 CF 和自增起始值。</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">CREATE TABLE sample (</span><br><span class="line">    id INT PRIMARY KEY AUTO_INCREMENT,</span><br><span class="line">    uid INT,</span><br><span class="line">    name VARCHAR(25),</span><br><span class="line">    ts TIMESTAMP,</span><br><span class="line">    KEY(`uid`) COMMENT &apos;cfname=cf_uid&apos;,</span><br><span class="line">    KEY(`name`) COMMENT &apos;cf_name&apos;</span><br><span class="line">) ENGINE=ROCKSDB AUTO_INCREMENT=100;</span><br></pre></td></tr></table></figure><p>创建成功后，我们查看一下存储在系统表中的 DDL 信息。</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">select * from INFORMATION_SCHEMA.ROCKSDB_DDL;</span><br></pre></td></tr></table></figure><p><img src="/uploads/15637654866041.jpg" alt=""></p><p>接下来查看 RocksDB 当中的数据，并与上面的 DDL 信息以及代码分析进行比对。</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line">sst_dump --command=scan --file=&apos;000038.sst&apos; --output_hex</span><br><span class="line">from [] to []</span><br><span class="line">Process /var/lib/mysql/#rocksdb/000038.sst</span><br><span class="line">Sst file format: block-based</span><br><span class="line">&apos;00000001746573742E73616D706C65&apos; seq:16, type:1 =&gt; 0001000000000000010000000002000001010000000300000102</span><br><span class="line">&apos;000000020000000000000100&apos; seq:11, type:1 =&gt; 000601000D000000000000000000000000</span><br><span class="line">&apos;000000020000000200000101&apos; seq:13, type:1 =&gt; 000602000D000000000000000000000000</span><br><span class="line">&apos;000000020000000300000102&apos; seq:15, type:1 =&gt; 000602000D000000000000000000000000</span><br><span class="line">&apos;0000000300000000&apos; seq:6, type:1 =&gt; 000100000000</span><br><span class="line">&apos;0000000300000001&apos; seq:5, type:1 =&gt; 000100000000</span><br><span class="line">&apos;0000000300000002&apos; seq:12, type:1 =&gt; 000100000000</span><br><span class="line">&apos;0000000300000003&apos; seq:14, type:1 =&gt; 000100000000</span><br><span class="line">&apos;00000007&apos; seq:9, type:1 =&gt; 000100000102</span><br><span class="line">&apos;000000090000000000000100&apos; seq:10, type:2 =&gt; 00010000000000000064</span><br></pre></td></tr></table></figure><p>第一条数据为表和索引的对应关系：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line">00000001               =&gt; 常量 DDL_ENTRY_INDEX_START_NUMBER</span><br><span class="line">746573742E73616D706C65 =&gt; &apos;test.sample&apos;，即库表名</span><br><span class="line"></span><br><span class="line">0001                   =&gt; 常量 DDL_ENTRY_INDEX_VERSION</span><br><span class="line">00000000               =&gt; 0，即 PRIMARY 的 cf id</span><br><span class="line">00000100               =&gt; 256，即 PRIMARY 的 index number</span><br><span class="line">00000002               =&gt; 2，即 uid 的 cf id</span><br><span class="line">00000101               =&gt; 257，即 uid 的 index number</span><br><span class="line">00000003               =&gt; 3，即 name 的 cf id</span><br><span class="line">00000102               =&gt; 258，即 name 的 index number</span><br></pre></td></tr></table></figure><p>第二条到第四条数据为索引信息，以第二条为例：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">00000002               =&gt; 常量 INDEX_INFO</span><br><span class="line">00000000               =&gt; 0，即 PRIMARY 的 cf id</span><br><span class="line">00000100               =&gt; 256，即 PRIMARY 的 index number</span><br><span class="line"></span><br><span class="line">0006                   =&gt; 常量 INDEX_INFO_VERSION_LATEST</span><br><span class="line">01                     =&gt; index type，1 是主键索引，2 是索引，3 是隐藏索引</span><br><span class="line">000D                   =&gt; kv format version，primary 和 secondary 都是 13</span><br><span class="line">00000000               =&gt; index flags，目前只有 TTL_FLAG</span><br><span class="line">0000000000000000       =&gt; ttl duration</span><br></pre></td></tr></table></figure><p>第五条到第八条数据为 CF 信息，以第五条为例：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">00000003               =&gt; 常量 CF_DEFINITION</span><br><span class="line">00000000               =&gt; cf 的 id</span><br><span class="line"></span><br><span class="line">0001                   =&gt; 常量 CF_DEFINITION_VERSION</span><br><span class="line">00000000               =&gt; cf flags，因为既没有分区也没有逆序，所以为 0</span><br></pre></td></tr></table></figure><p>第九条数据为当前系统内最大的索引 id：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">00000007               =&gt; 常量 MAX_INDEX_ID</span><br><span class="line"></span><br><span class="line">0001                   =&gt; 常量 MAX_INDEX_ID_VERSION</span><br><span class="line">00000102               =&gt; 当前系统最大的索引 id 是 258</span><br></pre></td></tr></table></figure><p>第十条数据是自增数据：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">00000009               =&gt; 常量 AUTO_INC</span><br><span class="line">00000000               =&gt; 0，即 PRIMARY 的 cf id</span><br><span class="line">00000100               =&gt; 256，即 PRIMARY 的 index number</span><br><span class="line"></span><br><span class="line">0001                   =&gt; 常量 AUTO_INCREMENT_VERSION</span><br><span class="line">0000000000000064       =&gt; 100，即设置的初始自增值</span><br></pre></td></tr></table></figure><h2 id="参考资料"><a href="#参考资料" class="headerlink" title="参考资料"></a>参考资料</h2><ol><li><a href="https://yq.aliyun.com/articles/62648" target="_blank" rel="external">myrocks记录格式分析</a></li><li><a href="http://mysql.taobao.org/monthly/2017/12/10/" target="_blank" rel="external">MySQL · myrocks · 相关tools介绍</a></li><li><a href="https://github.com/facebook/mysql-5.6/wiki/Schema-Design" target="_blank" rel="external">Schema Design</a></li><li><a href="https://github.com/facebook/mysql-5.6/wiki/Column-Families-on-Partitioned-Tables" target="_blank" rel="external">Column Families on Partitioned Tables</a></li><li><a href="https://github.com/facebook/mysql-5.6/wiki/MyRocks-Information-Schema" target="_blank" rel="external">MyRocks Information Schema</a></li><li><a href="http://mysql.taobao.org/monthly/2018/04/04/" target="_blank" rel="external">MySQL · MyRocks · TTL特性介绍</a></li><li><a href="http://mysql.taobao.org/monthly/2018/06/09/" target="_blank" rel="external">MySQL · RocksDB · Column Family介绍</a></li><li><a href="http://mysql.taobao.org/monthly/2018/09/09/" target="_blank" rel="external">MySQL · myrocks · collation 限制</a></li></ol>]]></content>
    
    <summary type="html">
    
      &lt;h2 id=&quot;前言&quot;&gt;&lt;a href=&quot;#前言&quot; class=&quot;headerlink&quot; title=&quot;前言&quot;&gt;&lt;/a&gt;前言&lt;/h2&gt;&lt;p&gt;目前网上介绍 MyRocks 的文章虽然不少，但是大部分都只介绍了一些 RocksDB 的核心特性和读写原理，却几乎不会提到 MyRocks 在实现 MySQL 存储引擎相关的内容，并且由于 MySQL 官方对于存储引擎的开发资料也提供的很单薄，所以对于新人来说难免有些手足无措。&lt;/p&gt;
&lt;p&gt;这个系列希望通过从 MySQL 存储引擎的 API 作为起点，结合 MyRocks 的实现，记录下每一个功能的全貌，包括自定义的存储引擎在每一个 API 中具体需要实现哪些功能，以及 MyRocks 是如何通过 RocksDB 实现这些功能的，其优缺点是什么。希望能够帮助一些初学者（包括我自己）如何从零开始或是二次开发一个 MySQL 存储引擎。&lt;/p&gt;
&lt;p&gt;这篇笔记是第一章，介绍了创建表（Creating Tables）的流程。&lt;/p&gt;
    
    </summary>
    
    
  </entry>
  
  <entry>
    <title>2018 年终总结</title>
    <link href="http://www.scienjus.com/2018-year-end-review/"/>
    <id>http://www.scienjus.com/2018-year-end-review/</id>
    <published>2018-12-31T15:59:59.000Z</published>
    <updated>2019-01-06T13:22:29.048Z</updated>
    
    <content type="html"><![CDATA[<p><img src="/uploads/quartet.jpg" alt="心怀大志的三流，就是四流"></p><p>总体来说，对自己今年的表现并不满意…</p><a id="more"></a><h2 id="工作"><a href="#工作" class="headerlink" title="工作"></a>工作</h2><p>今年一整年都在 Mobike 度过，关于 Mobike 这一年的大起大落，各位读者可能了解的很多了，所以我在此只写技术相关的。</p><h3 id="研发效率"><a href="#研发效率" class="headerlink" title="研发效率"></a>研发效率</h3><p>在<a href="http://www.scienjus.com/2017-year-end-review/">去年的总结</a>中我曾经提到，自己对「如何提升整个团队的工作效率」这个话题很感兴趣。所以在今年年初，我加入了新组建的效能团队。</p><p>工程效率是我个人一直很看重的事情，除了擅长实际编码，包括软硬件工具、时间管理、任务管理、沟通等综合能力对我都很重要。并且在这点上我认为自己比大部分人都要做的更好。</p><p>但是一旦这件事涉及到了整个团队就变得很复杂了，我很难去衡量一件事对其他人的意义和对我一样重要，所以只能靠学习一些其他公司的经验掌握一个度量的标准。我比较推荐《The DevOps Handbook》和《SRE 谷歌运维解密》这两本书，前者在公司层面所描述的目标和我所希望达到的目标是近乎一致的，并且其中的很多方法论确实是可操作的。而后者则是我理想成为的开发者，以及希望加入的团队。</p><p>具体到实施上我主要关注 CI/CD 方向，因为这部分工作包含着相对较多的编码时间，以及可以在短期显著地看到效果。对我个人而言，使用 Groovy 写 Jenkins Pipeline 很有趣，即使是 Jenkins 这种五六年前的产品依旧能和现代的很多理念所融合，例如 Infra as Code 或是 Serverless。其次是哪怕真正编写了几百上千行 Shell 之后，我依旧还是只能靠查 StackOverflow 完成功能，但是日常工作中的简单操作变得顺畅了很多，也算是很有用的能力了。</p><h3 id="应用架构"><a href="#应用架构" class="headerlink" title="应用架构"></a>应用架构</h3><p>从我的第一份工作开始，就在写着一版又一版的 Java Framework，在前几年流行的微服务理念下，无非是一套又一套的 RPC、Service Registration and Discovery、Tracing 等组件。</p><p>在 ENJOY 时，因为 Spring Cloud 才刚刚 Release，我们选择了整合已有的开源技术自己编写一套完整的框架：以 Etcd 作为注册中心，Thrift 作为 RPC 实现，Zipkin 作为链路追踪方案，这些在之前的文章中都或多或少的提到过。</p><p>而 Mobike 则采用的是标准的 Spring Cloud Netflix 体系，包括 Consul、Ribbon、Hystrix、Feign、Sleuth 等，所以我也有机会去学习 Spring Cloud 的团队是如何抽象和实现这些功能，虽然没有刻意生啃过源码，但是在漫长的排查问题和二次开发过程中，也基本上对这些组件的实现有了很深的了解，并且对管理一个大型的微服务集群，框架需要提供哪些赋能有了更清楚的认知。</p><p>另一部分内容是关于容器化相关的技术，在离开 ENJOY 时最悔恨的事情就是没有参与公司自研并开源的容器编排系统 <a href="https://github.com/projecteru2" target="_blank" rel="external">Eru</a>，导致自己对容器相关的知识一直似懂非懂。而来到 Mobike 后，在工作中长时间使用了 Swarm 和 Kubernetes 之后，我对其有了更深的了解，并且也解决了许多只看文档不会遇见的问题。目前还在尝试了一些和容器相关的云原生技术，比如 FaaS 和 Service Mesh。</p><p>明年的主要的方向也主要在云原生技术，希望能把 Service Mesh 落地在我司的业务场景，以及能够把自动灰度发布流程完成，虽然后者的可能性不大，但是目标总是要有的:)</p><h3 id="选择"><a href="#选择" class="headerlink" title="选择"></a>选择</h3><p>很遗憾，今年的工作成果在我看来是交上了一份不及格的答卷，我认为其原因是做了很多错误的「选择」。</p><p>从个人能力来说，我一直更偏向于一个「强力协助者」或是「救火队员」，我的技术能力和执行力使我在这一年中帮助无数项目快速发展，为它们快速落地或是解决核心问题。但是很遗憾的是，这些事中没有一件是由我来 Own 的，包括之前也有一些我非常感兴趣的项目，也因为公司内的资源分配问题转让给了其他人。</p><p>在写本文的前几天，我司的数据库团队开始陆续开源他们的<a href="https://github.com/moiot" target="_blank" rel="external">产品</a>（恭喜他们:)），相比之下，我却没有任何积累能带到新的一年，甚至已经对很多事情失去了热情，让我感到非常挫败。</p><p>自称「当年北京最年轻的架构师」的 CMGS 曾经对我说过：「25岁不成事就转行吧」，当时只有 23 岁的我想的是还有那么多时间，我肯定能做出自己可以持续付出热情且为之自豪的作品，然而在还有半年就到了 25 岁的这个时间点，我才发现自己似乎一直在退步。</p><h2 id="学习"><a href="#学习" class="headerlink" title="学习"></a>学习</h2><h3 id="开源项目"><a href="#开源项目" class="headerlink" title="开源项目"></a>开源项目</h3><p>今年唯一能拿出来说的可能是开源项目了，即使我的 Github 数据并不好看。</p><p><img src="/uploads/15467755539032.jpg" alt=""></p><p>我很认同「一定要尝试了解和贡献你所使用的开源项目」，正确的使用开源项目是一项完整的技能，大多数人对其的认知可能只是引入一个依赖库或是中间件，将其按照 Quick Start 运行成功就算完成了，这实际是不正确的。</p><p>从开源项目的选型，到对其文档以及源码的掌握，包括调试方式、拓展点，遇到问题时查找资料的途径，在社区反馈问题的方式，二次开发等等，这些都属于使用开源项目所需要的能力，并且这些综合能力非常重要。</p><p>很多人会认为像是 Spring、MyBatis 这样的成熟框架是不会存在任何 Bug 的，而事实却不是这样。在我还很短的职业生涯中就遇到过很多成熟框架在特殊场景才会出现的 Bug，其中大部分又是由于使用者不够了解所以用了不规范的写法所导致的，没有任何人愿意看到这些愚蠢的问题造成严重的损失，包括开源软件的作者，所以总要有人为此负起责任。</p><p>在今年我所贡献过的开源项目有：</p><ol><li>Zipkin：修复了一个简单的前端 Bug，提了一些 Feature Request，均是我在公司项目中遇到的</li><li>Jenkins：修复了一个 Gitlab 插件的 Bug，也是工作相关</li><li>Spring/Spring-Cloud：反馈过两三个 Bug 并提了 Pull Request，不过因为沟通问题只有 2 个最终合了。还有一些 Feature Request，不过基本上都被否了，后来觉得 Spring Cloud 社区对这种小改动非常谨慎，几乎不会接受，也就不再提了（即使一部分在我看来是 Bug）</li><li>Nuclio：在最开始调研 FaaS 平台时认为它最符合我们的需求，所以在试用时贡献了 Ruby 的运行时，以及修复一些前端 Bug，后来发现问题有点多，社区也不够活跃，就暂时搁置了</li><li>Skywalking：也是在调研 APM 方案时试用了一下，顺手加了一个 agent options 的小增强，以及修复了一个 Hystrix 的 Bug，后来因为某些原因整个 APM 的事都搁置了</li><li>SOFA：纯粹是个人兴趣，我很喜欢学习其他公司的开源方案寻找灵感，因为 SOFA 团队的维护者都比较 nice，以及是国内开源项目里少数真正在运营社区的项目（想想我司用的某些国产开源项目，近一年都没人维护了，真是走在刀尖上），所以交流很愉快，也学习了很多</li></ol><p>总体来看，这些都不是很重要的贡献，但是也和我最初表达的观点一样，我做这些事都只是为了更好地使用我选择的开源软件，并且这样做对我个人的收获也很大，如果还能为整个开源社区产生一些微末的贡献那就更好了。</p><h3 id="博客"><a href="#博客" class="headerlink" title="博客"></a>博客</h3><p>反观今年的博客的数量非常少，仅有四篇，这是一个很大的退步。</p><p>今年其实积攒了很多博客素材，但是大部分最终都没有写完，我总是觉得自己没有办法把握好一篇博客内容的广度与深度，再加上今年的工作很多都是在解决一些细节问题或是小众场景，这些问题很难整理为一篇对读者有意义的文章（举个例子：我花了好几个小时 debug 一个关于 Spring Cloud 的死锁问题，但是我该如何向读者介绍这个问题呢？发一大堆断点 debug 的截图？这些截图又能对读者产生什么价值呢？）。</p><p>不过之前恰好看到 Jake Wharton 发了一个推文「Writing blog posts which are based on presentations you already prepared/presented is crazy easy.」，这个观点我非常赞同，在这之前我也曾经将一篇介绍 Istio 的博客修改为了 Slide 作为内部分享，整个流程无比顺利，只花了不到半天的时间，我想今后一部分博客可能也会通过这种方式进行创作。</p><p>内部分享今年也做的不太好，我本来有非常多的想法，比如介绍 JUnit 5、Kotlin、Reactor 这些在 2018 年编写 Java 应用需要了解的「Modern Java Frameworks」系列，或是编写爬虫、脚本或是日常办公软件这些软技能技能。但是我司很少有单纯的技术交流分享，大部分都是每个部门介绍自己工作的内容，做的产品，而且就是单纯的产品介绍，很少会涉及到技术细节。在这种氛围下我觉得做分享太流于形式了，反而是在耽误我和其他听众的时间。</p><h2 id="生活"><a href="#生活" class="headerlink" title="生活"></a>生活</h2><p>由于最近几年糟糕的生活习惯，今年上半年身体各处都开始显著地产生不适，最为可怕的是我的左眼每隔一段时间会发生一次短暂的视力损失，表现为视野中会慢慢产生一股白雾状覆盖，越来越浓直到眼睛看不清任何东西，紧接着又会慢慢缓解最后消失，整体持续时间不到五分钟。再加上当时因为身边一些事弄得心情很差，非常焦虑和烦躁，整体状态非常差。</p><p>七月的时候觉得这样下去肯定会出事，就开始调整生活习惯和心情。把年初买的 Switch 拆了，办了张健身卡每周做 3-5 次有氧运动，正常饮食和作息。之后很幸运的是身体的不适缓解了很多，眼疾也再也没有出现过，现在想想真的很庆幸。</p><p><img src="/uploads/15467793317447.jpg" alt=""></p><p>后来为了放松心情还坐游轮去了一次日本，游轮上的生活真的很惬意，不需要做任何规划，饿了就去餐厅吃饭，累了就会房间睡觉，平时就在甲板上晒太阳或是在船里闲逛，很好的缓解了我紧绷的精神。</p><p>总体来说今年算是因祸得福把，目前身体状态和精神状态都变得更好了，而且也更加懂得享受生活的重要性。</p>]]></content>
    
    <summary type="html">
    
      &lt;p&gt;&lt;img src=&quot;/uploads/quartet.jpg&quot; alt=&quot;心怀大志的三流，就是四流&quot;&gt;&lt;/p&gt;
&lt;p&gt;总体来说，对自己今年的表现并不满意…&lt;/p&gt;
    
    </summary>
    
    
  </entry>
  
  <entry>
    <title>在 Java 单元测试中 mock gRPC</title>
    <link href="http://www.scienjus.com/mock-grpc-in-java-unit-test/"/>
    <id>http://www.scienjus.com/mock-grpc-in-java-unit-test/</id>
    <published>2018-05-27T06:00:53.000Z</published>
    <updated>2019-01-06T11:21:38.338Z</updated>
    
    <content type="html"><![CDATA[<p>最近团队内在推广单元测试，我主要做一些 Java 框架和 CI 环境的支持。我们内部的 RPC 框架主要有 HTTP（Spring Cloud Feign）和 gRPC 两种，而在单元测试中一般需要 mock 跨服务之间的请求，相比之下 gRPC 的 mock 较为复杂，在此详细介绍一下。</p><a id="more"></a><h2 id="Mock-Client"><a href="#Mock-Client" class="headerlink" title="Mock Client?"></a>Mock Client?</h2><p>对于大多数 RPC 框架来说，都会有一个封装抽象的比较上层的接口，即不需要考虑序列化以及通信相关的实现。所以只需要直接 mock 这类的接口，作为本地方法调用并返回对应的结果即可，不必进行真实的 RPC 请求。</p><p>以 Spring Cloud Feign 为例，Feign 的定义本身就是完全抽象的 Java 接口，同时每一个 Feign Client 又会注册成一个 Spring Bean，所以就可以通过 Spring 原生提供的 <code>@MockBean</code> 进行 mock，例如：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@SpringBootTest</span></span><br><span class="line"><span class="meta">@RunWith</span>(SpringRunner.class)</span><br><span class="line"><span class="keyword">public</span> <span class="class"><span class="keyword">class</span> <span class="title">UserServiceTest</span> </span>&#123;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@MockBean</span></span><br><span class="line">    <span class="keyword">private</span> UserInfoFeignClient client;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Autowired</span></span><br><span class="line">    <span class="keyword">private</span> UserInfoService service;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Test</span></span><br><span class="line">    <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">updateUserBasicInfos</span><span class="params">()</span> </span>&#123;</span><br><span class="line">        doReturn(<span class="keyword">null</span>).when(client).getByMobile(any());</span><br><span class="line">        UserInfoDetail user = service.register(<span class="string">"18812345678"</span>);</span><br><span class="line">        assertTrue(<span class="string">"user should register success when mobile not exists"</span>, user != <span class="keyword">null</span>);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>在最开始，我以为 gRPC 也会有类似的支持，可以通过现有的框架 mock 一个 Stub，使其返回指定的 protobuf 对象。</p><p>不幸的是，由于 gRPC 的所有源码都是由 protobuf 文件生成而来，而最重要的是：其生成的 Java Class 都是 final 的，这导致我们没有办法使用基于动态代理实现的 mock 框架去直接代理一个 Stub。</p><p>在 gRPC Java 的 <a href="https://github.com/grpc/grpc-java/issues/2160" target="_blank" rel="external">Github Issues</a> 中也有着一些类似的讨论，一部分开发者认为 Stub 不应该被定义为 final 类型，这样就可以进行 mock 了。而核心开发者认为 mock Stub 的做法本身就是错误的，真正的作法应该是 mock 一个 Server 实现，并通过 in-process 的传输方式和 Client 进行通信。</p><h2 id="Mock-Server"><a href="#Mock-Server" class="headerlink" title="Mock Server!"></a>Mock Server!</h2><p>在明确了 gRPC 的 mock 只能在 Server 端进行之后，官方为此也提供了一些对应的支持，其中最核心的实现是一个 Junit4 的 Rule <code>GrpcServerRule</code>。</p><p>在这个 Rule 中，每次进行测试之前都会启动一个 in-process Server 以及一个 <code>MutableHandlerRegistry</code> 作为注册中心。之后使用者可以 mock 对应的 Server 实现并将其添加到其中，之后再使用 in-process Server 返回的 Channel 构造 Stub，最终调用该 Stub 的对应方法就可以进入到对应的 Server 逻辑中了。</p><p>下面是一个最简单的代码实现：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@RunWith</span>(MockitoJUnitRunner.class)</span><br><span class="line"><span class="keyword">public</span> <span class="class"><span class="keyword">class</span> <span class="title">SimpleGrpcClientTests</span> </span>&#123;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Rule</span></span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">final</span> GrpcServerRule grpcServerRule = <span class="keyword">new</span> GrpcServerRule().directExecutor();</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Spy</span></span><br><span class="line">    <span class="keyword">private</span> SimpleGrpc.SimpleImplBase server;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Captor</span></span><br><span class="line">    <span class="keyword">private</span> ArgumentCaptor&lt;HelloRequest&gt; requestArgumentCaptor;</span><br><span class="line">    </span><br><span class="line">    <span class="keyword">private</span> SimpleGrpc.SimpleBlockingStub stub;</span><br><span class="line">    </span><br><span class="line">    <span class="keyword">private</span> GrpcClientService grpcClientService;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Before</span></span><br><span class="line">    <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">setup</span><span class="params">()</span> </span>&#123;</span><br><span class="line">        <span class="comment">// 1. 在 before 阶段将 server 添加到 grpcServerRule 中</span></span><br><span class="line">        grpcServerRule.getServiceRegistry().addService(server);</span><br><span class="line">        <span class="comment">// 2. 使用 grpcServerRule 的 channel 生成 stub</span></span><br><span class="line">        stub = SimpleGrpc.newBlockingStub(grpcServerRule.getChannel());</span><br><span class="line">        <span class="comment">// 3. 通过 stub 生成一个 service</span></span><br><span class="line">        grpcClientService = <span class="keyword">new</span> GrpcClientService(stub);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Test</span></span><br><span class="line">    <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">testSendMessage</span><span class="params">()</span> </span>&#123;</span><br><span class="line">        <span class="comment">// 4. 为 server 添加 mock 的返回值，第一个参数是 request，第二个参数是 observer</span></span><br><span class="line">        doAnswer((invocationOnMock) -&gt; &#123;</span><br><span class="line">            StreamObserver&lt;HelloReply&gt; argument = invocationOnMock.getArgumentAt(<span class="number">1</span>, StreamObserver.class);</span><br><span class="line">            HelloReply reply = HelloReply.newBuilder().setMessage(<span class="string">"World"</span>).build();</span><br><span class="line">            argument.onNext(reply);</span><br><span class="line">            argument.onCompleted();</span><br><span class="line">            <span class="keyword">return</span> <span class="keyword">null</span>;</span><br><span class="line">        &#125;).when(server).sayHello(requestArgumentCaptor.capture(), any());</span><br><span class="line"></span><br><span class="line">        String message = <span class="string">"Hello my world"</span>;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 5. 调用方法判断返回值是否是 mock 的值</span></span><br><span class="line">        assertThat(grpcClientService.sendMessage(message)).isEqualTo(<span class="string">"World"</span>);</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 6. 判断 mock 方法被调用</span></span><br><span class="line">        verify(server).sayHello(requestArgumentCaptor.capture(), any());</span><br><span class="line">        </span><br><span class="line">        <span class="comment">// 7. 判断 mock 方法被调用的参数为 service 传入的参数</span></span><br><span class="line">        assertThat(requestArgumentCaptor.getValue().getName()).isEqualTo(message);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>使用这种方式需要注意的是，Server 的实现必须严格的使用 <code>StreamObserver.class</code> 进行结果返回，否则会一直卡在请求中，无法正确的得到结果。</p><h2 id="Spring-Style"><a href="#Spring-Style" class="headerlink" title="Spring Style"></a>Spring Style</h2><p>当了解了最核心的 mock 实现后，让我们回到真实世界。</p><p>在大多数情况下的实际场景并没有这么简单，例如我们使用了 <a href="https://github.com/yidongnan/grpc-spring-boot-starter" target="_blank" rel="external">yidongnan/grpc-spring-boot-starter</a> 将 gRPC 和 Spring 所结合，其实现了一个 PostBeanProcessor 用于将 Channel 或是 Stub 注入到 Bean 的字段中，例如：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Service</span></span><br><span class="line"><span class="keyword">public</span> <span class="class"><span class="keyword">class</span> <span class="title">GrpcClientService</span> </span>&#123;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@GrpcClient</span>(<span class="string">"local-grpc-server"</span>)</span><br><span class="line">    <span class="keyword">private</span> Channel serverChannel;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@GrpcStub</span>(<span class="string">"local-grpc-server"</span>)</span><br><span class="line">    <span class="keyword">private</span> SimpleBlockingStub stub;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>在这种场景下 Mockito 的 <code>@InjectMocks</code> 和 Spring Boot 的 <code>@MockBean</code> 都是非常优秀的实现，但是由于篇幅有限，这里只展示一个参考 <code>MockitoAnnotations#initMocks</code> 的类似实现。</p><p>这个方法只需要做三件事：</p><ol><li>找到测试类中所有包含 <code>@Mock</code> 或是 <code>@Spy</code> 的字段，如果其是一个 gRPC Server 实现（继承了 <code>BindableService</code>），则将其添加到 <code>grpcServiceRule</code> 中。</li><li>找到测试类中所有包含 <code>@Autowired</code> 的字段，递归遍历所有包含 <code>@GrpcClient</code> 和 <code>@GrpcStub</code> 的字段，将 <code>grpcServiceRule</code> 中的 Channel 注入到其中。</li><li>在实际情况中可能会出现只有部分 Stub、Channel 需要注入的情况，所以在第一步的时候需要收集所有 mock 对象所对应的名称，而在第二步时只注入含有对应名称的字段</li></ol><p>下面是代码示例：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Slf</span>4j</span><br><span class="line"><span class="keyword">public</span> <span class="class"><span class="keyword">class</span> <span class="title">GrpcAnnotations</span> </span>&#123;</span><br><span class="line"></span><br><span class="line">    <span class="function"><span class="keyword">public</span> <span class="keyword">static</span> <span class="keyword">void</span> <span class="title">initMocks</span><span class="params">(Object testClass, GrpcServerRule grpcServerRule)</span> </span>&#123;</span><br><span class="line">        <span class="keyword">if</span> (testClass == <span class="keyword">null</span>) &#123;</span><br><span class="line">            <span class="keyword">throw</span> <span class="keyword">new</span> IllegalArgumentException(<span class="string">"testClass cannot be null"</span>);</span><br><span class="line">        &#125;</span><br><span class="line">        <span class="comment">// 扫描所有的字段，如果含有 @MockGrpc 则注册到 grpcServerRule 中</span></span><br><span class="line">        Set&lt;String&gt; bindingGrpcServiceName = <span class="keyword">new</span> HashSet&lt;&gt;();</span><br><span class="line">        <span class="keyword">for</span> (Field field : testClass.getClass().getDeclaredFields()) &#123;</span><br><span class="line">            <span class="keyword">if</span> (BindableService.class.isAssignableFrom(field.getType()) &amp;&amp; field.isAnnotationPresent(MockGrpc.class)) &#123;</span><br><span class="line">                MockGrpc mockGrpc = field.getAnnotation(MockGrpc.class);</span><br><span class="line">                <span class="keyword">if</span> (!bindingGrpcServiceName.add(mockGrpc.value())) &#123;</span><br><span class="line">                    <span class="keyword">throw</span> <span class="keyword">new</span> IllegalStateException(<span class="string">"Multiple gRPC services have the same name."</span>);</span><br><span class="line">                &#125;</span><br><span class="line">                <span class="keyword">try</span> &#123;</span><br><span class="line">                    Object instance = Mockito.spy(field.getType());</span><br><span class="line">                    ReflectionUtils.makeAccessible(field);</span><br><span class="line">                    field.set(testClass, instance);</span><br><span class="line">                    grpcServerRule.getServiceRegistry().addService((BindableService) instance);</span><br><span class="line">                &#125; <span class="keyword">catch</span> (Exception e) &#123;</span><br><span class="line">                    <span class="keyword">throw</span> <span class="keyword">new</span> IllegalStateException(<span class="string">"Unable to inject because of reflection failure."</span>, e);</span><br><span class="line">                &#125;</span><br><span class="line">            &#125;</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 扫描所有的字段，如果含有 @InjectGrpc 则尝试注入对应的 Channel/Stub</span></span><br><span class="line">        <span class="keyword">for</span> (Field field : testClass.getClass().getDeclaredFields()) &#123;</span><br><span class="line">            <span class="keyword">if</span> (field.isAnnotationPresent(InjectGrpc.class)) &#123;</span><br><span class="line">                ReflectionUtils.makeAccessible(field);</span><br><span class="line">                <span class="keyword">try</span> &#123;</span><br><span class="line">                    injectGrpcFields(field.get(testClass), grpcServerRule, bindingGrpcServiceName);</span><br><span class="line">                &#125; <span class="keyword">catch</span> (Exception e) &#123;</span><br><span class="line">                    <span class="keyword">throw</span> <span class="keyword">new</span> IllegalStateException(<span class="string">"Unable to inject because of reflection failure."</span>, e);</span><br><span class="line">                &#125;</span><br><span class="line">            &#125;</span><br><span class="line"></span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="function"><span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">void</span> <span class="title">injectGrpcFields</span><span class="params">(Object instance,</span></span></span><br><span class="line"><span class="function"><span class="params">                                         GrpcServerRule grpcServerRule,</span></span></span><br><span class="line"><span class="function"><span class="params">                                         Set&lt;String&gt; bindingGrpcServiceName)</span> </span>&#123;</span><br><span class="line">        <span class="keyword">if</span> (instance == <span class="keyword">null</span>) &#123;</span><br><span class="line">            <span class="keyword">return</span>;</span><br><span class="line">        &#125;</span><br><span class="line">        <span class="keyword">for</span> (Field field: instance.getClass().getDeclaredFields()) &#123;</span><br><span class="line">            <span class="keyword">if</span> (Channel.class.isAssignableFrom(field.getType()) &amp;&amp;</span><br><span class="line">                    field.isAnnotationPresent(GrpcClient.class)) &#123;</span><br><span class="line">                GrpcClient grpcClient = field.getAnnotation(GrpcClient.class);</span><br><span class="line">                <span class="keyword">if</span> (bindingGrpcServiceName.contains(grpcClient.value())) &#123;</span><br><span class="line">                    ReflectionUtils.makeAccessible(field);</span><br><span class="line">                    ReflectionUtils.setField(field, instance, grpcServerRule.getChannel());</span><br><span class="line">                &#125;</span><br><span class="line">            &#125; <span class="keyword">else</span> <span class="keyword">if</span> (AbstractStub.class.isAssignableFrom(field.getType()) &amp;&amp; field.isAnnotationPresent(</span><br><span class="line">                    GrpcStub.class)) &#123;</span><br><span class="line">                GrpcStub grpcClient = field.getAnnotation(GrpcStub.class);</span><br><span class="line">                <span class="keyword">if</span> (bindingGrpcServiceName.contains(grpcClient.value())) &#123;</span><br><span class="line">                    ReflectionUtils.makeAccessible(field);</span><br><span class="line">                    ReflectionUtils.setField(field, instance, GrpcClientUtils.createGrpcStub(field, grpcServerRule.getChannel()));</span><br><span class="line">                &#125;</span><br><span class="line">            &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">                ReflectionUtils.makeAccessible(field);</span><br><span class="line">                <span class="keyword">try</span> &#123;</span><br><span class="line">                    injectGrpcFields(field.get(instance), grpcServerRule, bindingGrpcServiceName);</span><br><span class="line">                &#125; <span class="keyword">catch</span> (IllegalAccessException e) &#123;</span><br><span class="line">                    log.warn(<span class="string">"Unable to inject because of reflection failure."</span>);</span><br><span class="line">                &#125;</span><br><span class="line">            &#125;</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>如此一来，使用者只需要在每个测试运行前调用下 <code>GrpcAnnotations#initMocks</code> 即可完成所有 Server 的 mock 声明和对应 Client 的注入了。</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@SpringBootTest</span></span><br><span class="line"><span class="meta">@RunWith</span>(SpringRunner.class)</span><br><span class="line"><span class="keyword">public</span> <span class="class"><span class="keyword">class</span> <span class="title">SpringGrpcClientIntegrationTests</span> </span>&#123;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Rule</span></span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">final</span> GrpcServerRule grpcServerRule = <span class="keyword">new</span> GrpcServerRule().directExecutor();</span><br><span class="line"></span><br><span class="line">    <span class="meta">@MockGrpc</span>(<span class="string">"local-grpc-server"</span>)</span><br><span class="line">    <span class="keyword">private</span> SimpleGrpc.SimpleImplBase server;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Autowired</span></span><br><span class="line">    <span class="meta">@InjectGrpc</span></span><br><span class="line">    <span class="keyword">private</span> GrpcClientService grpcClientService;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Before</span></span><br><span class="line">    <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">setUp</span><span class="params">()</span> </span>&#123;</span><br><span class="line">        GrpcAnnotations.initMocks(<span class="keyword">this</span>, grpcServerRule);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Test</span></span><br><span class="line">    <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">testSendMessage</span><span class="params">()</span> </span>&#123;</span><br><span class="line">        <span class="comment">// ...</span></span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure>]]></content>
    
    <summary type="html">
    
      &lt;p&gt;最近团队内在推广单元测试，我主要做一些 Java 框架和 CI 环境的支持。我们内部的 RPC 框架主要有 HTTP（Spring Cloud Feign）和 gRPC 两种，而在单元测试中一般需要 mock 跨服务之间的请求，相比之下 gRPC 的 mock 较为复杂，在此详细介绍一下。&lt;/p&gt;
    
    </summary>
    
    
  </entry>
  
  <entry>
    <title>基于 Elasticsearch 的 Zipkin 统计（续）</title>
    <link href="http://www.scienjus.com/zipkin-statistics-based-on-elasticsearch-2/"/>
    <id>http://www.scienjus.com/zipkin-statistics-based-on-elasticsearch-2/</id>
    <published>2018-02-25T10:14:55.000Z</published>
    <updated>2018-05-21T11:07:58.000Z</updated>
    
    <content type="html"><![CDATA[<p>在之前的<a href="http://www.scienjus.com/zipkin-statistics-based-on-elasticsearch/">文章</a>中介绍了在前公司使用 Elasticsearch 作为 Zipkin 的底层存储，并横向分析数据的经验，本篇中会继续介绍一些在现公司参与开发另一套同样基于 Zipkin 做二次开发的经验。</p><a id="more"></a><h2 id="Zipkin-Span-V2"><a href="#Zipkin-Span-V2" class="headerlink" title="Zipkin Span V2"></a>Zipkin Span V2</h2><p>在 17 年 9 月的时候，open zipkin 的作者 adriancole 对 Span 模型进行了一些调整，目的是简化原有的 Span 模型，新版 Span 模型主要的变化为：</p><ol><li>使用 <code>kind</code> 字段标识该 Span 是 client 端产生的还是 server 端产生的，原先的方式是通过 <code>annotations</code> 中的 <code>cs</code>、<code>cr</code>、<code>ss</code>、<code>sr</code> 信息进行判断的。</li><li><code>annotations</code> 中记录的调用方/接受方的信息也转移到了 <code>localEndpoint</code> 和 <code>remoteEndpoint</code>中，这两个 object 会记录 <code>ipv4</code>、<code>port</code> 和 <code>serviceName</code> 三个信息。</li><li><code>binaryAnnotations</code> 中记录的自定义 key/value 变成了一个 object <code>tags</code>，从 key/value 数组变为了 field 和 value。</li></ol><p>可以参考下面的模型示例：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line">&#123;</span><br><span class="line">  <span class="attr">"traceId"</span>: <span class="string">"f7f57e186f78c275"</span>,</span><br><span class="line">  <span class="attr">"duration"</span>: <span class="number">9000</span>,</span><br><span class="line">  <span class="attr">"localEndpoint"</span>: &#123;</span><br><span class="line">    <span class="attr">"serviceName"</span>: <span class="string">"api"</span>,</span><br><span class="line">    <span class="attr">"ipv4"</span>: <span class="string">"127.0.0.1"</span>,</span><br><span class="line">    <span class="attr">"port"</span>: <span class="number">8080</span></span><br><span class="line">  &#125;,</span><br><span class="line">  <span class="attr">"timestamp_millis"</span>: <span class="number">1520131123895</span>,</span><br><span class="line">  <span class="attr">"kind"</span>: <span class="string">"CLIENT"</span>,</span><br><span class="line">  <span class="attr">"name"</span>: <span class="string">"grpc:user.user-info-service/get-user-detail-by-id"</span>,</span><br><span class="line">  <span class="attr">"id"</span>: <span class="string">"73421afe3effad2e"</span>,</span><br><span class="line">  <span class="attr">"parentId"</span>: <span class="string">"f7f57e186f78c275"</span>,</span><br><span class="line">  <span class="attr">"timestamp"</span>: <span class="number">1520131123895000</span>,</span><br><span class="line">  <span class="attr">"tags"</span>: &#123;</span><br><span class="line">    <span class="attr">"cluster"</span>: <span class="string">"bj"</span>,</span><br><span class="line">    <span class="attr">"version"</span>: <span class="string">"0.4.5"</span></span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>在 Elasticsearch 的存储模型中，新的格式带来了一个巨大的好处：整个 Span 模型不再存在嵌套（Nested）字段。这样不但查询和聚合语句写起来会方便很多，也避免了 Elasticsearch 不支持嵌套字段聚合时使用外层字段排序的问题。</p><p>但是这个改变也带来一个新的问题：原先的 <code>binaryAnnotations</code> 作为嵌套字段只有 <code>key</code> 和 <code>value</code> 两个子字段，而变为了 <code>tags</code> 这个 object 之后，<code>key</code> 本身也变为了动态的 field。但是在 Elasticsearch 中动态 field 会造成 mapping 过于庞大严重影响性能，所以 Zipkin 默认不对 <code>tags</code> 里面的任何信息做索引。</p><p>那么 Zipkin UI 中的关键词搜索又是如何实现的呢？Zipkin 会将所有的标签都直接拼成了一个词元数组放到了 <code>_q</code> 字段中。</p><p>例如原有的模型为：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">&#123;</span><br><span class="line">  <span class="attr">"tags"</span>: &#123;</span><br><span class="line">    <span class="attr">"cluster"</span>: <span class="string">"beta"</span>,</span><br><span class="line">    <span class="attr">"error"</span>: <span class="string">"read-time-out"</span></span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>则会转变为：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">&#123;</span><br><span class="line">  <span class="attr">"_q"</span>: [<span class="string">"cluster"</span>, <span class="string">"cluster=beta"</span>, <span class="string">"error"</span>, <span class="string">"error=read-time-out"</span>]</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>建立索引之后，前端提交的搜索就可以直接使用 <code>term</code> 在 Elasticsearch 中查询相应的记录。</p><p>不过这种实现在聚合时就有些麻烦了，不但必须要用 <code>include</code> 过滤出真正想要聚合的字段，还会影响一部分聚合性能，但整体还可以接受。当然对于一些固定的标准 tag，例如 cluster、version 等信息，也可以自己额外建立索引。</p><h2 id="采样"><a href="#采样" class="headerlink" title="采样"></a>采样</h2><p>Zipkin 中的 Span 模型可以收集到极其详尽的数据，这让我们既可以纵向的按层级展示出一条链路中所产生的所有调用，分析出一类请求的具体瓶颈。也可以横向的统计某一类 Span 的特征，分析出整个系统中的异常行为。</p><p>但是由于 Span 过于详细以及通用，导致整个系统会产生非常多的 Span，每天可能会产生上百亿的数据，其产生的 Elasticsearch 存储成本相比收益来说实在太大，所以在这种场景下我们必须要设置采样，只采集部分 Trace 的数据。</p><p>此时，如何优化采样率，在系统所能支撑的成本中存储尽量多有意义的数据便成为了新的问题。</p><h3 id="动态采样"><a href="#动态采样" class="headerlink" title="动态采样"></a>动态采样</h3><p>Sleuth 中提供了 <code>PercentageBasedSampler</code>，或是 Brave 提供的 <code>CountingSampler</code> 都可以设置一个采样率，按照一定比例采集数据。这样就可以将数据量控制在一个我们可以接受的范围内。</p><p>但是一个很重要的问题是，通过采样率只能均衡的采集数据，但是在很多时候数据本身的价值却不一样。例如我们可能会有一个首页 Tab 的接口每天调用数千万次，但是这个接口的逻辑极其简单，而且所有请求返回的数据都类似，那么这样的 Trace 其实只采样 1% 甚至 1‰ 即可。而像是充值或是退款之类的重要接口，我们是希望每一次请求都可以完全被采样的，所以其采样率就应该是 100%。</p><p>Sleuth 中的 Sampler 定义的比较尴尬，只能通过 Span 中的信息选择是否采样，所以很难通过判断 Request 的信息决定是否采样，不过依旧可以用比较取巧的方式实现，只需要确保以下前提：</p><ol><li>每个 Trace 是否采样由其 Root Span（第一个生成的 Span）在 Sampler 中的执行结果所决定。</li><li>在一般的 Web 应用中，第一个生成的 Span 永远是 HTTP 请求的 Server Span。</li><li>HTTP 请求的 Server Span，name 的格式为 <code>http:$uri</code>。</li></ol><p>所以我们可以在 Sampler 中通过 Span name 截取出当前的请求的 Uri，并在配置文件中查找该 Uri 所对应的采样率，一个参照的源码如下：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br><span class="line">82</span><br><span class="line">83</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * This sampler can be configured with a &#123;<span class="doctag">@link</span> PercentageBasedSampler&#125; for each uri pattern.</span></span><br><span class="line"><span class="comment"> * for instance. /health|/metrics can be set never sample and /order/create should be set always sample</span></span><br><span class="line"><span class="comment"> *</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@author</span> ScienJus</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Slf</span>4j</span><br><span class="line"><span class="keyword">public</span> <span class="class"><span class="keyword">class</span> <span class="title">UriPatternPercentageBasedSampler</span> <span class="keyword">extends</span> <span class="title">PercentageBasedSampler</span> </span>&#123;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> List&lt;SingleUriPatternPercentageBasedSampler&gt; samplers;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">final</span> String HTTP_SPAN_PREFIX = HTTP_COMPONENT + <span class="string">":"</span>;</span><br><span class="line"></span><br><span class="line">    <span class="function"><span class="keyword">public</span> <span class="title">UriPatternPercentageBasedSampler</span><span class="params">(UriPatternSamplerProperties configuration)</span> </span>&#123;</span><br><span class="line">        <span class="keyword">super</span>(buildSamplerProperties(configuration.getPercentage()));</span><br><span class="line">        <span class="keyword">this</span>.samplers = configuration.getPatterns().stream()</span><br><span class="line">                .map(SingleUriPatternPercentageBasedSampler::<span class="keyword">new</span>)</span><br><span class="line">                .collect(Collectors.toList());</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * Find a matching pattern for the current uri and sampling by it's own &#123;<span class="doctag">@link</span> PercentageBasedSampler&#125;</span></span><br><span class="line"><span class="comment">     *</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> currentSpan</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@return</span></span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@Override</span></span><br><span class="line">    <span class="function"><span class="keyword">public</span> <span class="keyword">boolean</span> <span class="title">isSampled</span><span class="params">(Span currentSpan)</span> </span>&#123;</span><br><span class="line">        String spanName = currentSpan.getName();</span><br><span class="line">        <span class="keyword">if</span> (StringUtils.isBlank(spanName) || !spanName.startsWith(HTTP_SPAN_PREFIX)) &#123;</span><br><span class="line">            <span class="keyword">return</span> <span class="keyword">super</span>.isSampled(currentSpan);</span><br><span class="line">        &#125;</span><br><span class="line">        String uri = StringUtils.removeStart(spanName, HTTP_SPAN_PREFIX);</span><br><span class="line">        <span class="keyword">return</span> samplers.stream()</span><br><span class="line">                .filter(sampler -&gt; sampler.isMatch(uri))</span><br><span class="line">                .findFirst()</span><br><span class="line">                .map(sampler -&gt; sampler.isSampled(currentSpan))</span><br><span class="line">                .orElseGet(() -&gt; <span class="keyword">super</span>.isSampled(currentSpan));</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="function"><span class="keyword">private</span> <span class="keyword">static</span> SamplerProperties <span class="title">buildSamplerProperties</span><span class="params">(<span class="keyword">float</span> percentage)</span> </span>&#123;</span><br><span class="line">        SamplerProperties properties = <span class="keyword">new</span> SamplerProperties();</span><br><span class="line">        properties.setPercentage(percentage);</span><br><span class="line">        <span class="keyword">return</span> properties;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">static</span> <span class="class"><span class="keyword">class</span> <span class="title">SingleUriPatternPercentageBasedSampler</span> <span class="keyword">extends</span> <span class="title">PercentageBasedSampler</span> </span>&#123;</span><br><span class="line"></span><br><span class="line">        <span class="keyword">private</span> <span class="keyword">final</span> List&lt;String&gt; uris;</span><br><span class="line"></span><br><span class="line">        <span class="keyword">private</span> <span class="keyword">final</span> Pattern pattern;</span><br><span class="line"></span><br><span class="line">        <span class="function"><span class="keyword">public</span> <span class="title">SingleUriPatternPercentageBasedSampler</span><span class="params">(UriPatternSamplerProperties.UriPatternProperties pattern)</span> </span>&#123;</span><br><span class="line">            <span class="keyword">super</span>(buildSamplerProperties(pattern.getPercentage()));</span><br><span class="line">            <span class="keyword">this</span>.uris = pattern.getUris();</span><br><span class="line">            <span class="keyword">this</span>.pattern = pattern.getPattern();</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="comment">/**</span></span><br><span class="line"><span class="comment">         * <span class="doctag">@param</span> uri</span></span><br><span class="line"><span class="comment">         * <span class="doctag">@return</span> true if the pattern matches current uri or uri list contains current uri</span></span><br><span class="line"><span class="comment">         */</span></span><br><span class="line">        <span class="function"><span class="keyword">public</span> <span class="keyword">boolean</span> <span class="title">isMatch</span><span class="params">(String uri)</span> </span>&#123;</span><br><span class="line">            <span class="keyword">return</span> Stream.&lt;Predicate&lt;String&gt;&gt;of(<span class="keyword">this</span>::isInUris, <span class="keyword">this</span>::isPatternMatch)</span><br><span class="line">                    .anyMatch(matcher -&gt; matcher.test(uri));</span><br><span class="line"></span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="function"><span class="keyword">private</span> <span class="keyword">boolean</span> <span class="title">isInUris</span><span class="params">(String uri)</span> </span>&#123;</span><br><span class="line">            <span class="keyword">if</span> (CollectionUtils.isNotEmpty(<span class="keyword">this</span>.uris)) &#123;</span><br><span class="line">                <span class="keyword">return</span> <span class="keyword">this</span>.uris.contains(uri);</span><br><span class="line">            &#125;</span><br><span class="line">            <span class="keyword">return</span> <span class="keyword">false</span>;</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="function"><span class="keyword">private</span> <span class="keyword">boolean</span> <span class="title">isPatternMatch</span><span class="params">(String uri)</span> </span>&#123;</span><br><span class="line">            <span class="keyword">if</span> (<span class="keyword">this</span>.pattern != <span class="keyword">null</span>) &#123;</span><br><span class="line">                <span class="keyword">return</span> <span class="keyword">this</span>.pattern.matcher(uri).matches();</span><br><span class="line">            &#125;</span><br><span class="line">            <span class="keyword">return</span> <span class="keyword">false</span>;</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="两阶段采样"><a href="#两阶段采样" class="headerlink" title="两阶段采样"></a>两阶段采样</h3><p>既然 Elasticsearch 的存储成本如此昂贵，那么我们能不能用一种更加廉价的方案完成一部分需求呢？比如直接消费 Kafka 中的 Span 数据，通过 Spark 或是 Flink 实时计算某些指标后存储并展示，就可以节约大量的存储成本了。</p><p><img src="/uploads/15212919975936.jpg" alt=""></p><p>在一般的 Zipkin 实现中，应用会在 Span 上报到 Kafka 之前完成采样，Zipkin Collector 会把从 Kafka 消费到的所有数据都存储在 Elasticsearch 中。而引入了实时计算之后，采样分为两个阶段，应用上报 Kafka 的采样依旧保留，并且 Zipkin Collector 也会再次进行采样，这样可以在保证不增加 Elasticsearch 成本的同时，增加上报 Kafka 的采样率，在 Spark/Flink 任务中消费尽可能多的数据。</p><p>另外，判断某一个 Span 是否采样和具体实施采样这个行为不一定要绑定在一起，个人建议一个 Span 在两个阶段是否采样的结果都在应用上报 Kafka 之前计算，这样有两个好处：</p><ol><li>可以更加方便的关联上下文，在应用端只有 Root Span 会判断是否采样，这样就可以通过 Root Span 的信息动态的调整采样，而在 Collector 计算采样的话，为了保证整个上下文一致，只能通过 TraceId Hash 的算法进行采样，无法动态调整。</li><li>Collector 的采样结果关系到这条链路是否能在 Zipkin UI 中看到，在应用端就计算出来可以更加方便的关联日志系统，无论是普通的 debug 日志还是 Sentry 这样的错误日志收集，都可以更好的作为 Zipkin 的入口。</li></ol><h2 id="Zipkin-UI"><a href="#Zipkin-UI" class="headerlink" title="Zipkin UI"></a>Zipkin UI</h2><p>在大部分时候，使用 Grafana 或是 Kibana 都可以非常快速的生成简单的图表，但是这两者又都很难支持 Elasticsearch 中比较复杂的查询和聚合。所以在某些场景下，我们需要通过自己实现前后端，提供更友好的交互以及更复杂的数据图表。</p><p>在这里不准备介绍后端的实现了，无非是在原有的 Zipkin Server 上增加一个原生的 Elasticsearch client（或是 Jest），定制一些复杂的 query 并将返回结果构造的更简易。对于大部分人来说，比较复杂的反而是如何在现有的 Zipkin UI 上增加自己的页面。</p><p>Zipkin UI 使用的技术栈是 Bootstrap + Flight.js + jQuery，这是一套非常轻量级的前端技术栈，应用于长期的拓展显得有些单薄了。所以综合了我个人的前端技术栈之后，我打算在其之上使用 React + Ant Design + G2 构建自己的图表组件。</p><p>在现有的项目中增加 jsx 支持非常简单，只需要在 babel 中增加对应的 presets 和 plugins 即可：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line">&#123;</span><br><span class="line">  <span class="attr">"presets"</span>: [<span class="string">"es2015"</span>, <span class="string">"stage-0"</span>, <span class="string">"react"</span>],</span><br><span class="line">  <span class="attr">"plugins"</span>: [</span><br><span class="line">    <span class="string">"transform-object-rest-spread"</span>,</span><br><span class="line">    <span class="string">"transform-react-jsx"</span>,</span><br><span class="line">    [</span><br><span class="line">      <span class="string">"import"</span>,</span><br><span class="line">      &#123;</span><br><span class="line">        <span class="attr">"libraryName"</span>: <span class="string">"antd"</span>,</span><br><span class="line">        <span class="attr">"style"</span>: <span class="string">"css"</span></span><br><span class="line">      &#125;</span><br><span class="line">    ]</span><br><span class="line">  ]</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>而使用时其实也很简单，Flight.js 会暴露原生的 dom 节点，只需要使用 ReactDOM 将组件渲染到该节点上。</p><figure class="highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> &#123;component&#125; <span class="keyword">from</span> <span class="string">'flightjs'</span>;</span><br><span class="line"><span class="keyword">import</span> &#123;i18nInit&#125; <span class="keyword">from</span> <span class="string">'../../component_ui/i18n'</span>;</span><br><span class="line"><span class="keyword">import</span> ReactDOM <span class="keyword">from</span> <span class="string">'react-dom'</span>;</span><br><span class="line"><span class="keyword">import</span> React <span class="keyword">from</span> <span class="string">'react'</span>;</span><br><span class="line"><span class="keyword">import</span> ApiDetail <span class="keyword">from</span> <span class="string">'./containers/ApiDetail'</span>;</span><br><span class="line"><span class="keyword">import</span> queryString <span class="keyword">from</span> <span class="string">'query-string'</span>;</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> StatisticsApiDetailComponent = component(<span class="function"><span class="keyword">function</span> <span class="title">StatisticsApiDetailComponent</span>(<span class="params"></span>) </span>&#123;</span><br><span class="line">  <span class="keyword">this</span>.after(<span class="string">'initialize'</span>, <span class="function"><span class="keyword">function</span>(<span class="params"></span>) </span>&#123;</span><br><span class="line">    <span class="built_in">window</span>.document.title = <span class="string">'Zipkin - Api Detail'</span>;</span><br><span class="line">    <span class="keyword">this</span>.trigger(<span class="built_in">document</span>, <span class="string">'navigate'</span>, &#123;<span class="attr">route</span>: <span class="string">'zipkin/statistics'</span>&#125;);</span><br><span class="line"></span><br><span class="line">    <span class="keyword">const</span> query = queryString.parse(<span class="built_in">window</span>.location.search);</span><br><span class="line"></span><br><span class="line">    ReactDOM.render(<span class="xml"><span class="tag">&lt;<span class="name">ApiDetail</span> <span class="attr">api</span>=<span class="string">&#123;query.api&#125;</span> /&gt;</span>, this.node);</span></span><br><span class="line"><span class="xml"></span></span><br><span class="line"><span class="xml">    i18nInit('stats');</span></span><br><span class="line"><span class="xml">  &#125;);</span></span><br><span class="line"><span class="xml">&#125;);</span></span><br><span class="line"><span class="xml"></span></span><br><span class="line"><span class="xml">export default function initializeStatisticsApiDetail(config) &#123;</span></span><br><span class="line"><span class="xml">  StatisticsApiDetailComponent.attachTo('.content', &#123;config&#125;);</span></span><br><span class="line"><span class="xml">&#125;</span></span><br></pre></td></tr></table></figure><p>不过在整体使用上还是会遇到一些小问题：</p><ol><li>Zipkin UI 默认的 eslint 只支持到 es6，所以对于 React 中一些 es6+ 的语法是会报错，我尝试在 eslint 增加对应的 plugin，但是会报一个很奇怪的运行错误，始终无法解决。</li><li>Zipkin UI 自身使用了 Crossroads.js 作为前端路由，我还没验证过其是否可以和 React Router 共存，但是之后页面更加复杂后必然会涉及到 React 组件内的路由。</li></ol><p>其最终的效果大概是：</p><p><img src="/uploads/15212881500925.jpg" alt=""></p>]]></content>
    
    <summary type="html">
    
      &lt;p&gt;在之前的&lt;a href=&quot;http://www.scienjus.com/zipkin-statistics-based-on-elasticsearch/&quot;&gt;文章&lt;/a&gt;中介绍了在前公司使用 Elasticsearch 作为 Zipkin 的底层存储，并横向分析数据的经验，本篇中会继续介绍一些在现公司参与开发另一套同样基于 Zipkin 做二次开发的经验。&lt;/p&gt;
    
    </summary>
    
    
  </entry>
  
  <entry>
    <title>Spring REST Docs 介绍</title>
    <link href="http://www.scienjus.com/introduction-to-spring-restdocs/"/>
    <id>http://www.scienjus.com/introduction-to-spring-restdocs/</id>
    <published>2018-02-25T10:14:55.000Z</published>
    <updated>2018-05-21T14:20:10.000Z</updated>
    
    <content type="html"><![CDATA[<p>Spring REST Docs 是一个为 Spring 项目生成 API 文档的框架，它通过在单元测试中额外添加 API 信息描述，从而自动生成对应的文档片段。</p><p>本文会以一个最简单的示例介绍如何在一个 Spring Boot 应用中使用 Spring REST Docs，并在最后与目前最常见的 SpringFox 进行一些对比，分别介绍其特点和优劣。</p><a id="more"></a><h2 id="使用示例"><a href="#使用示例" class="headerlink" title="使用示例"></a>使用示例</h2><p>本文的示例源码托管在 Github 上，你可以通过<a href="https://github.com/ScienJus/learn-spring-restdocs" target="_blank" rel="external">这个地址</a>下载并在本地运行。</p><h3 id="基础准备"><a href="#基础准备" class="headerlink" title="基础准备"></a>基础准备</h3><p>首先需要一个 Spring Boot 项目，并通过 MockMvc 编写一些简单的测试。</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@RestController</span></span><br><span class="line"><span class="keyword">public</span> <span class="class"><span class="keyword">class</span> <span class="title">HelloController</span> </span>&#123;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@GetMapping</span>(<span class="string">"hello"</span>)</span><br><span class="line">    <span class="function"><span class="keyword">public</span> Result <span class="title">hello</span><span class="params">(@RequestParam(<span class="string">"name"</span>)</span> String name) </span>&#123;</span><br><span class="line">        <span class="keyword">return</span> <span class="keyword">new</span> Result(<span class="number">200</span>, String.format(<span class="string">"Hello %s!"</span>, name));</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>在上面代码中提供了一个最简单的 Controller，其接收请求参数中的 <code>name</code> 属性，并返回一个包含 <code>code</code> 和 <code>msg</code> 的 Result 对象。</p><p>接下来需要为其编写一个测试：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@WebMvcTest</span></span><br><span class="line"><span class="meta">@ExtendWith</span>(SpringExtension.class)</span><br><span class="line"><span class="keyword">public</span> <span class="class"><span class="keyword">class</span> <span class="title">HelloControllerTests</span> </span>&#123;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Autowired</span></span><br><span class="line">    <span class="keyword">private</span> MockMvc mockMvc;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Test</span></span><br><span class="line">    <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">testHello</span><span class="params">()</span> <span class="keyword">throws</span> Exception </span>&#123;</span><br><span class="line">        mockMvc.perform(get(<span class="string">"/hello"</span>).param(<span class="string">"name"</span>, <span class="string">"ScienJus"</span>))</span><br><span class="line">                .andExpect(status().isOk())</span><br><span class="line">                .andExpect(jsonPath(<span class="string">"msg"</span>, <span class="string">"Hello ScienJus!"</span>).exists())</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>在这里使用了 JUnit5 和 Spring 的 MockMvc 编写 API 测试，只是简单的请求这个 API 并校验返回值。</p><p>完成以上工作，就可以开始通过修改测试代码，为这个 API 自动生成相关的描述文档了。</p><h3 id="配置-Spring-REST-Docs"><a href="#配置-Spring-REST-Docs" class="headerlink" title="配置 Spring REST Docs"></a>配置 Spring REST Docs</h3><p>当使用 MockMvc 时，只需要添加 <code>spring-restdocs-mockmvc</code> 依赖：</p><figure class="highlight xml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="tag">&lt;<span class="name">dependency</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">groupId</span>&gt;</span>org.springframework.restdocs<span class="tag">&lt;/<span class="name">groupId</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">artifactId</span>&gt;</span>spring-restdocs-mockmvc<span class="tag">&lt;/<span class="name">artifactId</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">scope</span>&gt;</span>test<span class="tag">&lt;/<span class="name">scope</span>&gt;</span></span><br><span class="line"><span class="tag">&lt;/<span class="name">dependency</span>&gt;</span></span><br></pre></td></tr></table></figure><p>之后，需要修改测试代码，添加对应的文档支持：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@WebMvcTest</span></span><br><span class="line"><span class="meta">@ExtendWith</span>(&#123;RestDocumentationExtension.class, SpringExtension.class&#125;) &lt;<span class="number">1</span>&gt;</span><br><span class="line"><span class="keyword">public</span> <span class="class"><span class="keyword">class</span> <span class="title">HelloControllerTests</span> </span>&#123;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">private</span> MockMvc mockMvc;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@BeforeEach</span> &lt;<span class="number">2</span>&gt;</span><br><span class="line">    <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">setUp</span><span class="params">(WebApplicationContext webApplicationContext,</span></span></span><br><span class="line"><span class="function"><span class="params">                      RestDocumentationContextProvider restDocumentation)</span> </span>&#123;</span><br><span class="line">        <span class="keyword">this</span>.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext)</span><br><span class="line">                .apply(documentationConfiguration(restDocumentation))</span><br><span class="line">                .build();</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Test</span></span><br><span class="line">    <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">testHello</span><span class="params">()</span> <span class="keyword">throws</span> Exception </span>&#123;</span><br><span class="line">        mockMvc.perform(get(<span class="string">"/hello"</span>).param(<span class="string">"name"</span>, <span class="string">"ScienJus"</span>))</span><br><span class="line">                .andExpect(status().isOk())</span><br><span class="line">                .andExpect(jsonPath(<span class="string">"msg"</span>, <span class="string">"Hello ScienJus!"</span>).exists())</span><br><span class="line">                .andDo(document(<span class="string">"hello"</span>)); &lt;<span class="number">3</span>&gt;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><ol><li>在 <code>@ExtendWith</code> 中增加 <code>RestDocumentationExtension</code>（JUnit5 的 Extension 相当于 JUnit4 中的 Rule）。</li><li>将 <code>MockMvc</code> 由直接注入改为手动构建，增加 <code>documentationConfiguration(restDocumentation)</code> 配置。</li><li>在执行测试的最后，调用 <code>andDo(document(&quot;hello&quot;))</code> 给测试调用所生成的文档命名。</li></ol><h3 id="构建文档"><a href="#构建文档" class="headerlink" title="构建文档"></a>构建文档</h3><p>完成配置后，运行 <code>mvn clean package</code> 进行构建，当测试运行成功后查看 <code>target/generated-snippets</code> 下出现的一系列 adoc 文档：</p><p><img src="/uploads/15269065775245.jpg" alt=""></p><p>其中 <code>curl/httpie-request.adoc</code> 记录了测试请求通过 curl 和 httpie 的调用方式， <code>http-request/response.adoc</code> 记录了测试请求和返回的 raw 信息，<code>request/response-body.adoc</code> 记录了请求和返回的 Payload。</p><p>不过这些都只是一个个文档片段，还需要将其拼凑到一起才能成为一份完整的 API 文档，框架本身不提供直接生成完整文档的功能，所以需要编写一个文档主页并引入这些自动生成的文档片段。</p><p>默认的文档主页可以放在 <code>src/main/asciidoc/index.adoc</code> 中，例如：</p><figure class="highlight adoc"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="section">= Learn Spring REST Docs</span></span><br><span class="line"><span class="meta">:toc:</span> left</span><br><span class="line"></span><br><span class="line">Learn how to use Spring REST Docs based on Spring Boot2 and JUnit5.</span><br><span class="line"></span><br><span class="line"><span class="section">== /hello: Say "Hello World!"</span></span><br><span class="line"></span><br><span class="line">operation::hello[]</span><br></pre></td></tr></table></figure><p>其中最重要的一行是 <code>operation::hello[]</code>，它表示将 hello 下的所有片段都引入进入，或者也可以指定 <code>operation::hello[snippets=&#39;curl-request,http-request,http-response&#39;]</code> 的方式只引入部分代码片段。</p><p>编写好文档主页后，需要使用 <code>asciidoctor-maven-plugin</code> 使其可以在打包时与片段整合起来，并生成最终的 HTML 文件：</p><figure class="highlight xml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br></pre></td><td class="code"><pre><span class="line"><span class="tag">&lt;<span class="name">plugin</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">groupId</span>&gt;</span>org.asciidoctor<span class="tag">&lt;/<span class="name">groupId</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">artifactId</span>&gt;</span>asciidoctor-maven-plugin<span class="tag">&lt;/<span class="name">artifactId</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">version</span>&gt;</span>1.5.3<span class="tag">&lt;/<span class="name">version</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">executions</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;<span class="name">execution</span>&gt;</span></span><br><span class="line">            <span class="tag">&lt;<span class="name">id</span>&gt;</span>generate-docs<span class="tag">&lt;/<span class="name">id</span>&gt;</span></span><br><span class="line">            <span class="tag">&lt;<span class="name">phase</span>&gt;</span>prepare-package<span class="tag">&lt;/<span class="name">phase</span>&gt;</span></span><br><span class="line">            <span class="tag">&lt;<span class="name">goals</span>&gt;</span></span><br><span class="line">                <span class="tag">&lt;<span class="name">goal</span>&gt;</span>process-asciidoc<span class="tag">&lt;/<span class="name">goal</span>&gt;</span></span><br><span class="line">            <span class="tag">&lt;/<span class="name">goals</span>&gt;</span></span><br><span class="line">            <span class="tag">&lt;<span class="name">configuration</span>&gt;</span></span><br><span class="line">                <span class="tag">&lt;<span class="name">backend</span>&gt;</span>html<span class="tag">&lt;/<span class="name">backend</span>&gt;</span></span><br><span class="line">                <span class="tag">&lt;<span class="name">doctype</span>&gt;</span>book<span class="tag">&lt;/<span class="name">doctype</span>&gt;</span></span><br><span class="line">            <span class="tag">&lt;/<span class="name">configuration</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;/<span class="name">execution</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;/<span class="name">executions</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">dependencies</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;<span class="name">dependency</span>&gt;</span></span><br><span class="line">            <span class="tag">&lt;<span class="name">groupId</span>&gt;</span>org.springframework.restdocs<span class="tag">&lt;/<span class="name">groupId</span>&gt;</span></span><br><span class="line">            <span class="tag">&lt;<span class="name">artifactId</span>&gt;</span>spring-restdocs-asciidoctor<span class="tag">&lt;/<span class="name">artifactId</span>&gt;</span></span><br><span class="line">            <span class="tag">&lt;<span class="name">version</span>&gt;</span>2.0.1.RELEASE<span class="tag">&lt;/<span class="name">version</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;/<span class="name">dependency</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;/<span class="name">dependencies</span>&gt;</span></span><br><span class="line"><span class="tag">&lt;/<span class="name">plugin</span>&gt;</span></span><br></pre></td></tr></table></figure><p>此时再次运行 <code>mvn clean package</code> 之后，可以看到 <code>target/generated-docs</code> 下生成了最终的网页，其最终效果如下图所示。</p><p><img src="/uploads/15269074752006.jpg" alt=""></p><p>至此，最简单的请求文档便构建完成了。</p><h3 id="添加更多描述"><a href="#添加更多描述" class="headerlink" title="添加更多描述"></a>添加更多描述</h3><p>对于目前这份文档来说，其仅仅记录了最原始的请求信息，却没有任何相关的文字描述，所以接下来需要给请求和返回增加额外的描述信息。</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">mockMvc.perform(get(<span class="string">"/hello"</span>).param(<span class="string">"name"</span>, <span class="string">"ScienJus"</span>))</span><br><span class="line">        .andExpect(status().isOk())</span><br><span class="line">        .andExpect(jsonPath(<span class="string">"msg"</span>, <span class="string">"Hello ScienJus!"</span>).exists())</span><br><span class="line">        .andDo(document(<span class="string">"hello"</span>,</span><br><span class="line">                requestParameters(</span><br><span class="line">                        parameterWithName(<span class="string">"name"</span>).description(<span class="string">"The name to retrieve"</span>)), &lt;<span class="number">1</span>&gt;</span><br><span class="line">                responseFields(</span><br><span class="line">                        fieldWithPath(<span class="string">"code"</span>).description(<span class="string">"Code of the response"</span>),</span><br><span class="line">                        fieldWithPath(<span class="string">"msg"</span>).description(<span class="string">"Message of the response"</span>)))); &lt;<span class="number">2</span>&gt;</span><br></pre></td></tr></table></figure><p>在上面代码中增加了 <code>requestParameters</code> 定义请求参数的描述，以及通过 <code>responseFields</code> 定义返回值的描述，除此之外，还有 <code>pathParameters</code>、<code>requestHeaders</code>、<code>requestFields</code> 等分别用于描述路径变量、Header 信息、Payload 信息的方法。</p><p>需要注意的是，所有增加描述的字段都会在测试请求中进行校验，如果文档中定义的参数在实际的测试中并没有出现，测试会直接失败，这样可以保证文档描述和最终运行结果是一致的。</p><p>再次重新构建，可以看到生成的文档片段中多出了 <code>request-parameters.adoc</code> 和 <code>response-fields.adoc</code> 两个文件，就是在测试代码中定义的描述信息了。</p><p><img src="/uploads/15269084559348.jpg" alt=""></p><p>介于篇幅有限，本文对 Spring REST Docs 的基本使用介绍就到此为止了，更多的配置和自定义项可以在<a href="https://docs.spring.io/spring-restdocs/docs/current/reference/html5/" target="_blank" rel="external">官方文档</a> 中查看。</p><h2 id="和-SpringFox-的对比"><a href="#和-SpringFox-的对比" class="headerlink" title="和 SpringFox 的对比"></a>和 SpringFox 的对比</h2><p>相较于传统且更流行的 SpringFox（Swagger），Spring REST Docs 的实现方式相当新颖，而且有着鲜明的区别，那么不妨在此列举一下两者的区别以及优劣，以便更好的根据实际需求和使用场景选择最合适的工具。</p><p>首先，两者最大的区别就在于根本定位，SpringFox 的定位是和应用一起启动的在线文档，文档的浏览者可以很简单的填写表单并发起一个真实的请求，而 Spring REST Docs 更倾向于导出一份离线文档作为展示，并配合 curl、httpie 这种工具请求真实部署的服务。</p><p>其次，SpringFox 最大的特点是使用简单，只需要在源码中增加一些描述性的注解即可完成整份文档，而使用 Spring REST Docs 的前提条件是需要在项目中对 API 进行单元测试，并且要保证测试是可以稳定执行的，这对于很多团队来说无疑增加了很高的门槛。</p><p>但是对于已经有完整单元测试的团队来说，增加额外的文档描述几乎和 SpringFox 一样简单，并且还能完整的去除源码依赖。除此之外，依靠测试本身也正是 Spring REST Docs 的最大亮点：</p><p>首先，每一次测试都是一个真实的请求（不追究 MockMvc 具体实现细节），它所对应的请求和返回都是真实的，可以轻松将其记录下来作为 Demo 展示。而 SpringFox 只是对 Controller 层的方法进行了扫描，却无法感知 Interceptor、MethodArgumentResolver 这类中间件的存在，只能通过一些全局配置进行额外的描述。</p><p>其次，每一次测试也都是一个独立的请求，使得 Spring REST Docs 可以描述同一个 API 在不同请求参数中返回的不同结果的场景（例如成功或是各种失败情况），而 SpringFox 只能描述单一的方法签名和返回值 Model，却无法描述其具体可能出现的场景。</p><p>最后，错误的文档比没有文档还要糟糕，所以 Spring REST Docs 不仅仅是做 API 文档化，同时也是在做 API 契约化，如果 API 的实现修改破坏了已有的测试，哪怕仅仅是字段定义，都会导致测试的失败。这可以督促 API 的制定者保证对外提供的契约，也可以让 API 的使用者更加放心。</p><p>所以相比之下，如果一个技术氛围良好，对服务严格负责，且愿意尝试 API 单元测试和契约测试的团队来说，我更推荐使用 Spring REST Docs，而如果只是在已有的服务上增加描述性的文档，SpringFox 会是性价比更高的选择。</p>]]></content>
    
    <summary type="html">
    
      &lt;p&gt;Spring REST Docs 是一个为 Spring 项目生成 API 文档的框架，它通过在单元测试中额外添加 API 信息描述，从而自动生成对应的文档片段。&lt;/p&gt;
&lt;p&gt;本文会以一个最简单的示例介绍如何在一个 Spring Boot 应用中使用 Spring REST Docs，并在最后与目前最常见的 SpringFox 进行一些对比，分别介绍其特点和优劣。&lt;/p&gt;
    
    </summary>
    
    
  </entry>
  
  <entry>
    <title>Spring Boot 的配置文件优先级</title>
    <link href="http://www.scienjus.com/spring-boot-properties-priority/"/>
    <id>http://www.scienjus.com/spring-boot-properties-priority/</id>
    <published>2018-02-14T12:55:46.000Z</published>
    <updated>2018-02-15T13:39:45.000Z</updated>
    
    <content type="html"><![CDATA[<p>公司 Config Server 的逻辑越来越复杂了，新同事很难确定多个配置文件的关系和优先级。由于 Spring Cloud Config 是通过创建一个临时的 Spring Boot Application 加载配置文件，完全复用了 Spring Boot 本身的逻辑，于是写了这篇文章介绍一下 Spring Boot 中配置文件的优先级。</p><a id="more"></a><h2 id="一个真实的栗子"><a href="#一个真实的栗子" class="headerlink" title="一个真实的栗子"></a>一个真实的栗子</h2><p>那么，Spring Cloud Config 究竟会配置多么复杂的规则呢？举一个真实的栗子：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line">spring:</span><br><span class="line">  cloud:</span><br><span class="line">    config:</span><br><span class="line">      server:</span><br><span class="line">        git:</span><br><span class="line">          repos:</span><br><span class="line">            uri: /opt/repos/common/config</span><br><span class="line">            searchPaths: &apos;common,&#123;application&#125;&apos;</span><br><span class="line">            user:</span><br><span class="line">              pattern: user-*</span><br><span class="line">              uri: /opt/repos/user/config</span><br><span class="line">              searchPaths: &apos;common,&#123;application&#125;&apos;</span><br></pre></td></tr></table></figure><p>上面是 Spring Cloud Config Server 的配置，它做了两个特殊的配置：</p><ol><li>通过设置 <code>pattern</code> 将不同的应用映射到不同的配置仓库，实现应用间的配置独立管理。</li><li>通过 <code>searchPaths</code> 设置配置文件所在的文件夹，在默认情况 Config Server 只会搜索根目录下的配置文件，而上面的设置可以搜索 <code>/</code>、<code>common</code> 以及和应用同名的文件夹。</li></ol><p>而请求配置时可以传入 <code>application</code>、<code>profile</code> 和 <code>label</code> 三个动态配置，例如：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"># application/profile/label(optional)</span><br><span class="line">http://configserver/user-api/production,beta/master</span><br></pre></td></tr></table></figure><h2 id="配置文件的匹配规则"><a href="#配置文件的匹配规则" class="headerlink" title="配置文件的匹配规则"></a>配置文件的匹配规则</h2><p>这个请求会匹配到哪些配置文件呢？</p><p>在此首先忽略 <code>label</code>，因为在大部分情况下它只和 git 所使用的分支有关，所以它不涉及到具体配置文件的匹配。</p><p>在 Spring Boot 中，配置文件的搜索条件主要由三个参数组成：</p><ol><li><code>spring.config.name</code>：应用的名称，默认是 <code>application</code>（常量）加上 <code>spring.application.name</code> 的值，在 Spring Cloud Config 中对应请求的 <code>application</code>。</li><li><code>spring.profiles.active</code>：当前生效的 profile，在 Spring Cloud Config 中对应请求的 <code>profile</code></li><li><code>spring.config.location</code>：搜索配置文件的路径，在 Spring Cloud Config 对应配置文件中的 <code>searchPaths</code></li></ol><p>Spring Cloud Config 本质是通过创建了一个临时的 Spring Boot Application，设置这些配置并复用 Spring Boot 的逻辑加载对应的配置文件。所以上面的请求最终的结果集为搜索路径 <code>[&#39;./&#39;, &#39;/common&#39;, &#39;/user-api&#39;]</code> 乘以应用名 <code>[&#39;application&#39;, &#39;user-api&#39;]</code> 乘以环境 <code>[&#39;&#39;, &#39;production&#39;, &#39;beta&#39;]</code>，共计 18 个配置文件，也就是：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br></pre></td><td class="code"><pre><span class="line">./application.yml</span><br><span class="line">./user-api.yml</span><br><span class="line">./application-production.yml</span><br><span class="line">./user-api-production.yml</span><br><span class="line">./application-beta.yml</span><br><span class="line">./user-api-beta.yml</span><br><span class="line"></span><br><span class="line">./common/application.yml</span><br><span class="line">./common/user-api.yml</span><br><span class="line">./common/application-production.yml</span><br><span class="line">./common/user-api-production.yml</span><br><span class="line">./common/application-beta.yml</span><br><span class="line">./common/user-api-beta.yml</span><br><span class="line"></span><br><span class="line">./user-api/application.yml</span><br><span class="line">./user-api/user-api.yml</span><br><span class="line">./user-api/application-production.yml</span><br><span class="line">./user-api/user-api-production.yml</span><br><span class="line">./user-api/application-beta.yml</span><br><span class="line">./user-api/user-api-beta.yml</span><br></pre></td></tr></table></figure><h2 id="配置文件的优先级"><a href="#配置文件的优先级" class="headerlink" title="配置文件的优先级"></a>配置文件的优先级</h2><p>那么这些配置文件中的优先级是什么样呢？</p><p>Spring Boot 严格按照以下顺序进行排序：</p><ol><li>profile</li><li>location</li><li>application</li></ol><p>其中定义了多个 profile 和 location 时，越靠后的优先级越高，所以 <code>beta</code> 的优先级要大于 <code>production</code>，<code>./user-api</code> 的优先级要大于 <code>./common</code>。</p><p>所以最终的优先级为：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br></pre></td><td class="code"><pre><span class="line"># profile/location/application 均为最高</span><br><span class="line">./user-api/user-api-beta.yml</span><br><span class="line"></span><br><span class="line"># profile/location 最高，application 次高</span><br><span class="line">./user-api/application-beta.yml</span><br><span class="line"></span><br><span class="line"># profile 最高，location 次高</span><br><span class="line">./common/user-api-beta.yml</span><br><span class="line">./common/application-beta.yml</span><br><span class="line"></span><br><span class="line"># profile 最高，location 最低</span><br><span class="line">./user-api-beta.yml</span><br><span class="line">./application-beta.yml</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"># profile 次高</span><br><span class="line">./user-api/user-api-production.yml</span><br><span class="line">./user-api/application-production.yml</span><br><span class="line">./common/user-api-production.yml</span><br><span class="line">./common/application-production.yml</span><br><span class="line">./user-api-production.yml</span><br><span class="line">./application-production.yml</span><br><span class="line"></span><br><span class="line"># 无 profile</span><br><span class="line">./user-api/user-api.yml</span><br><span class="line">./user-api/application.yml</span><br><span class="line">./common/user-api.yml</span><br><span class="line">./common/application.yml</span><br><span class="line">./user-api.yml</span><br><span class="line">./application.yml</span><br></pre></td></tr></table></figure><h2 id="源码解析"><a href="#源码解析" class="headerlink" title="源码解析"></a>源码解析</h2><p>因为源码实现比较多而且绕，就不在这里大段的贴代码了，基本上只需要关注两处：</p><ol><li>Spring Cloud Config 复用 Spring Boot 逻辑加载配置文件的实现可以看 <code>NativeEnvironmentRepository#findOne</code>。</li><li>Spring Boot 加载配置文件的实现可以看 <code>ConfigFileApplicationListener.Loader#load</code>。</li></ol><h2 id="更改优先级"><a href="#更改优先级" class="headerlink" title="更改优先级"></a>更改优先级</h2><p>默认情况 Spring Cloud Config 的配置会覆盖掉所有本地配置，包括命令行参数和环境变量，不过可以通过以下配置修改：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">spring:</span><br><span class="line">  cloud:</span><br><span class="line">    config:</span><br><span class="line">      allow-override: false # 远端配置是否允许覆盖，默认是 true</span><br><span class="line">      override-none: true # 远端配置是否为最低优先级，不覆盖任何已存在配置，默认是 false</span><br><span class="line">      override-system-properties: false # 远端配置是否可以覆盖系统配置，默认是 true</span><br></pre></td></tr></table></figure><p>注意这些配置本身必须放在 Spring Cloud Config 中才会生效。</p>]]></content>
    
    <summary type="html">
    
      &lt;p&gt;公司 Config Server 的逻辑越来越复杂了，新同事很难确定多个配置文件的关系和优先级。由于 Spring Cloud Config 是通过创建一个临时的 Spring Boot Application 加载配置文件，完全复用了 Spring Boot 本身的逻辑，于是写了这篇文章介绍一下 Spring Boot 中配置文件的优先级。&lt;/p&gt;
    
    </summary>
    
    
  </entry>
  
  <entry>
    <title>2017 年终总结</title>
    <link href="http://www.scienjus.com/2017-year-end-review/"/>
    <id>http://www.scienjus.com/2017-year-end-review/</id>
    <published>2017-12-31T15:36:33.000Z</published>
    <updated>2018-01-15T13:50:52.000Z</updated>
    
    <content type="html"><![CDATA[<p>一年一度的流水账…</p><a id="more"></a><h2 id="工作"><a href="#工作" class="headerlink" title="工作"></a>工作</h2><p>今年工作上最大的改变是离开了 ENJOY，来到了 Mobike。</p><h3 id="ENJOY"><a href="#ENJOY" class="headerlink" title="ENJOY"></a>ENJOY</h3><p>17 年上半年在 ENJOY 完成了优惠券的重构，并开始订单的重构。同时将 Zuul 推上了生产环境，接入了所有线上流量。至此，ENJOY 的后端架构对于同规模公司成熟度已经非常高了。有一套还算好用的微服务开发框架，线上应用全部通过自研的 PaaS 平台部署 Docker 容器。</p><p>我在 ENJOY 工作的一年中主要做了四件事：</p><ol><li>将「优惠券」模块从单体应用拆分到了独立微服务</li><li>将「订单」模块从单体应用拆分到了独立微服务</li><li>开发基于新 APNs 协议的推送平台</li><li>和另一个同事维护了一套类似 Spring Cloud Netflix 的微服务框架</li></ol><p>在 4 月份的时候，这些事基本都进入了尾声，同时因为一些团队内的氛围、工作方式的改变，自己对于工作上的热情开始大幅度下降。我最终决定在 6 月底主动提出了离职。</p><p>纵观在 ENJOY 的一年，实际上是过得非常充实的，同事中有 CMGS、Flex 这样的大牛，也有像 wzyboy、timfeirg 等很多优秀的同龄人。工作上能够真正去实施自己认为正确的方案，能够认同自己最终做出来的东西，能够承担更多责任，并带来更多的技术提升，产生非常好的良性循环，这段经历是非常宝贵的。</p><h3 id="Mobike"><a href="#Mobike" class="headerlink" title="Mobike"></a>Mobike</h3><p>离开 ENJOY 的时候我并没有想过自己要去哪家公司，也一向不擅长找工作和面试，所以最后只参加了三次面试，分别是「出门问问」、「LeanCloud」和「摩拜单车」。我一直都非常认同 LeanCloud 的工程师文化，对里面的大部分工程师都有一些了解，也非常敬佩庄晓丹这样的技术人，但是纠结了很久最终还是选择去了摩拜。</p><p>相比 ENJOY 摩拜的团队更加大一些，而且职责也分得更加细粒度一些，导致我呆了很久也没有完全适应。好在同事也都非常的 Nice，使我在完成本职工作之后，可以和更多的人交流，讨论和学习更多的技术。</p><p>目前我的大部分工作都和在 ENJOY 时没有太大区别，而我在工作外比较感兴趣的事是观察「如何提升整个团队的工作效率」上，举个例子，在 ENJOY 时我们希望一个 10 人的团队能做好 15 人的工作量，而在摩拜更像是希望一个 100 人的团队能做好 80 人的工作量，实现这两个目标努力的方向是完全不同的，有些甚至可能是完全相反的，当我站在完全不同的位置上去解决问题时，会发现给出的答案也会完全不同，这是非常有收获的。</p><p>摩拜是一家还在高速发展的公司，明年希望能够接受更多的技术挑战，做出更多稳定、健壮、优秀的系统，尝试更多新技术，以及支持更多的人更加快速的完成开发工作。</p><h2 id="学习"><a href="#学习" class="headerlink" title="学习"></a>学习</h2><p>今年发生了很多事，我的业余时间并不多，主要做了以下事：</p><ul><li>在 Github 上写了一些小组件，提了一些 MR，修了 Spring Cloud 的两个 Bug，更多的只是一些随意的小改动，比如给 yamllint 加上一个新的规则。</li><li>在 Coursera 上完成了「Functional Programming in Scala Specialization」系列课程，这门课由大名鼎鼎的 Scala 作者 Martin Odersky 开设，课程的质量非常高。</li><li>尝试维护一套自己（或和其他朋友）做 Side Project 的技术栈，主要由 Python 和 React 组成，在此之前我并没有用过这两种语言（框架），所以也学到了很多东西</li></ul><p>我有一个很大的坏习惯就是对于很多事都会很快的付诸行动，但是却没有一个长远的规划，这会导致这些事最终只会持续很短的一段时间便暂时搁置掉了，最终并不会有什么实质性的结果。所以我从下半年开始尝试写子弹笔记，开始重新续费 Things，希望能有所改善。</p><p>明年我希望自己的主要精力放在看书上，因为工作内容很有可能带来更偏向广度的知识增长，所以我需要通过看书获取一些更深度的知识保持平衡，否则很容易变成做了很多事技术能力却没有提升的窘境。</p><p>还有一点是总是感觉自己精力不够用，后来认为还是工作方式有一些问题，浪费了很多时间和精力，明年也希望多系统性的学习一些效率工具相关的知识。</p>]]></content>
    
    <summary type="html">
    
      &lt;p&gt;一年一度的流水账…&lt;/p&gt;
    
    </summary>
    
    
  </entry>
  
  <entry>
    <title>Spring Cloud AutoConfiguration 简介</title>
    <link href="http://www.scienjus.com/spring-cloud-autoconfiguration/"/>
    <id>http://www.scienjus.com/spring-cloud-autoconfiguration/</id>
    <published>2017-08-21T15:31:29.000Z</published>
    <updated>2018-01-15T13:50:52.000Z</updated>
    
    <content type="html"><![CDATA[<p>将公司内部分享的一个 Slide 拆解为两部分。本文是第一部分，主要介绍一下 Spring Boot AutoConfiguration 的组成和原理。</p><a id="more"></a><h2 id="什么是-AutoConfiguration"><a href="#什么是-AutoConfiguration" class="headerlink" title="什么是 AutoConfiguration"></a>什么是 AutoConfiguration</h2><p>Spring Boot 作为加快 Spring 项目开发的扩展框架，其中一个很重要的特性就是引入了 Starter 套件。Starter 可以在程序启动时自动初始化程序所需要的 Bean，开发者只需要关注如何使用组件本身。</p><p>Spring Boot 通过 AutoConfiguration 机制使得应用可以在启动时根据引入的类和配置，自动加载配置类（Configuration），从而在这些类中初始化所需的 Spring Bean。</p><p>一个完善的 AutoConfiguration 组件应该由四部分组成：</p><ol><li>配置类：就像开发者自己在项目中编写的一样，定义了初始化 Spring Bean 的方法。</li><li>自动装载：开发者只需要引入配置类，不用像使用组件扫描（Component Scan）的方式显式的初始化配置类，避免开发者关注组件配置具体的实现。</li><li>条件化加载：通过判断当前应用中引入的类库或是配置项，动态的判断项目是否需要加载某一个配置类，或是初始化某个 Bean。</li><li>配置项映射：定义每个组件可以通过哪些配置项进行配置，遵从约定大于配置的原则，开发者只需要按照定义对应的值。</li></ol><h2 id="配置类"><a href="#配置类" class="headerlink" title="配置类"></a>配置类</h2><p>从 Spring 3 开始，开发者就可以通过 Java Config 的方式配置 Bean 了。</p><p>一个最简单的配置类由 <code>@Configuration</code> 和 <code>@Bean</code> 组成，其中前者将该类声明为一个配置类，同时也作为一个 Spring 的组件以便被扫描、注入。后者作为方法上的注解可以将方法的返回值注册为 Spring Bean。</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">@Configuration</span><br><span class="line">public class GsonConfiguration &#123;</span><br><span class="line"></span><br><span class="line">@Bean</span><br><span class="line">public Gson gson() &#123;</span><br><span class="line">return new Gson();</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>Spring Boot 中的所有配置类都在 <code>spring-boot-autoconfigure</code> 项目中，在这个项目中可以看到 Spring 为每个组件定义了哪些类、初始化了哪些 Bean，以及是如何进行配置的。</p><h2 id="自动装载"><a href="#自动装载" class="headerlink" title="自动装载"></a>自动装载</h2><p>对于传统的配置类，在 Spring 项目中一般都是由组件扫描（<code>@ComponentScan</code>）或是主动引入（<code>@Import</code>）的方式去进行装载。这种方式使得使用者被迫的去了解组件的配制方法和源码，极易出现配置错误等问题。</p><p>而在 Spring Boot 中，则提供了具有自动装载功能的 <code>@EnableAutoConfiguration</code> 注解，只要在项目中使用了该注解（或是其作为元注解的 <code>@SpringBootApplication</code>），就会在应用启动时自动装载所有 Spring Boot 提供的配置类。</p><p>其实现原理也是基于 Spring 现有的组件。</p><h3 id="原理"><a href="#原理" class="headerlink" title="原理"></a>原理</h3><p><img src="/uploads/15032215137843.jpg" alt=""></p><p>逻辑的入口是 <code>@EnableAutoConfiguration</code>，它唯一的功能就是使用了 <code>@Import</code> 作为元注解，并引入了 <code>EnableAutoConfigurationImportSelector</code> 类。</p><p><code>@Import</code> 是在 Spring 3 中作为 Java Config 功能所引入的，其最初作用是为了实现 XML 配置中 <code>&lt;import&gt;</code> 标签的功能：将多个配置引入到主配置中。所以它最开始的用途也是为了引入一些 <code>@Configuration</code> 类。</p><p>其实 Java Config 的配置方式具有更强大的表现力，例如使用者可以在注解中设置不同的值，或是通过运行一段 Java 代码动态的加载不同的配置，于是在 Spring 3.1 中就引入了两个新的功能：</p><ul><li>配合 <code>ImportSelector</code>：通过读取注解属性，动态引入一些配置。</li><li>配合 <code>ImportBeanDefinitionRegistrar</code>：通过读取注解属性，动态注册 Bean。</li></ul><p>其中 <code>EnableAutoConfigurationImportSelector</code> 就是一个 <code>ImportSelector</code> 的实现类。该接口只有一个方法 <code>selectImports</code>，这个方法会传入注解的元信息，最后返回需要装载的配置类的类名。</p><p>在这个实现中，最终会调用 <code>SpringFactoriesLoader.loadFactoryNames</code> 加载配置类的类名。</p><p><img src="/uploads/15032270247665.jpg" alt=""></p><p>而 <code>SpringFactoriesLoader.loadFactoryNames</code> 则会去寻找项目中所有 <code>META-INF/spring.factories</code> 文件，并将其转化为 Properties，读取注解类名对应的值作为配置类的列表返回。</p><p><img src="/uploads/15032279356954.jpg" alt=""></p><p>查看 spring-boot-autoconfigure 项目，确实能发现其中包含着该文件，以及对应的配置项。</p><p><img src="/uploads/15032279639040.jpg" alt=""></p><p>至此可以得出一个结论，如果想要将一个配置类变为自动装载，只需要在项目中增加 <code>META-INF/spring.factories</code> 文件，并该类的类名作为 <code>ENableAutoConfiguration</code> 的值即可。</p><blockquote><p>这个文件不光可以定义配置类，还可以定义 <code>ApplicationListener</code> 或是 <code>ApplicationContextInitializer</code>，一样用来实现组件自动装载的功能。</p></blockquote><h3 id="阻止-AutoConfiguration"><a href="#阻止-AutoConfiguration" class="headerlink" title="阻止 AutoConfiguration"></a>阻止 AutoConfiguration</h3><p>阻止 AutoConfiguration 加载的方式有两种，一种是在 <code>@EnableAutoConfiguration</code> 的 <code>exclude</code> 属性中定义这个配置类的类名，另一种方式是在 <code>spring.autoconfigure.exclude</code> 配置中定义配置类的类名。一般更推荐前者，因为这种场景一般是在开发期都可以确定的。</p><h2 id="条件化加载"><a href="#条件化加载" class="headerlink" title="条件化加载"></a>条件化加载</h2><p>条件化加载是在 Spring 4 中引入的新特性，而到了 Spring Boot 配合自动装载才真正发挥出其强大的功能。</p><p>这个特性就是在配置类上或是某个配置 Bean 的方法上定义一系列条件。而只有这些条件满足时，这个配置类才会进行装载，或是这个方法才会被执行。</p><p>在传统的 Spring 项目中，一般配置类都是由自己定义，所以基本上定义的配置类都是实际需要使用的，也就自然不需要添加额外的加载条件。</p><p>而 Spring Boot 遵从约定大于配置，每一个 AutoConfiguration 都会根据项目中是否引入了必要的依赖，以及是否配置了必须的配置项决定是否加载，完美的契合了条件化加载的使用场景。</p><h3 id="举例"><a href="#举例" class="headerlink" title="举例"></a>举例</h3><p><img src="/uploads/15032299342952.jpg" alt=""></p><p>例如上图中初始化 Freemarker 的配置类，一共使用了 4 个条件化注解：</p><ul><li><code>@ConditionalOnClass</code>：指定的 Class 存在于当前 Classpath </li><li><code>@ConditionalOnWebApplication</code>：是一个 Web 应用</li><li><code>@ConditionalOnMissingBean</code>：指定的 Bean 不存在</li><li><code>@ConditionalOnProperty</code>：指定的配置项满足条件（存在、等于某个值、不等于某个值等）</li></ul><h3 id="ConditionalOnClass"><a href="#ConditionalOnClass" class="headerlink" title="@ConditionalOnClass"></a>@ConditionalOnClass</h3><p>由于用户定义的配置类永远会在 AutoConfiguration 之前进行装载，所以 <code>@ConditionalOnMissingBean</code> 可以很轻易的实现使用者自定义的 Bean 替代掉自动生成的 Bean 的功能。</p><h3 id="Profile"><a href="#Profile" class="headerlink" title="@Profile"></a>@Profile</h3><p>另一个比较特殊的注解是 <code>@Profile</code>，这个注解允许使用者通过 <code>spring.profiles.active</code> 来控制一些 Bean 是否初始化，或是初始化不同的实例。</p><p><code>@Profile</code> 实际是在 Spring 3 就有的功能，但是在 Spring 4 条件化注解出现后，其实现也通过相关 API 进行重写了。</p><h3 id="自定义条件"><a href="#自定义条件" class="headerlink" title="自定义条件"></a>自定义条件</h3><p>虽然 Spring Boot 已经提供了很多常用的条件实现，但是在某些特殊场景依旧需要自定义加载条件。</p><p>所有的条件注解实现都是由 <code>@Conditional</code> 这个注解作为元注解，并指向一个 <code>Condition</code> 接口的实现类。</p><p>以一个具体场景举例，在 Java 应用中开发者一般使用 Slf4j 作为通用的日志桥接，从而隐藏具体的 Log4j 或是 Logback 实现。而当需要开发与日志相关的配置类时，就需要根据不同日志实现加载相关的配置类，就需要自定义一个条件注解。</p><p>首先编写一个自定义的条件注解 <code>@ConditionalOnSlf4jBinding</code>，这个注解用于指定期望 Slf4j 绑定的具体实现，在使用这个注解时可以将期望实现的类名放在 <code>value</code> 中。</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">@Retention(RetentionPolicy.RUNTIME)</span><br><span class="line">@Target(&#123;ElementType.TYPE, ElementType.METHOD&#125;)</span><br><span class="line">@Documented</span><br><span class="line">@Conditional(OnSlf4jBindingCondition.class)</span><br><span class="line">public @interface ConditionalOnSlf4jBinding &#123;</span><br><span class="line"></span><br><span class="line">  String value();</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>同时按照规范，这个注解使用了 <code>@Conditional</code> 作为元注解，并指向了 <code>OnSlf4jBindingCondition</code>。这个类是一个 <code>SpringBootCondition</code> 的实现类，通过调用 Slf4j 的方法获取当前绑定的日志实现，并查看是否与期望值相同：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line">public class OnSlf4jBindingCondition extends SpringBootCondition &#123;</span><br><span class="line"></span><br><span class="line">  @Override</span><br><span class="line">  public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) &#123;</span><br><span class="line">    String bindingClassName = attribute(metadata, &quot;value&quot;);</span><br><span class="line">    StaticLoggerBinder binder = StaticLoggerBinder.getSingleton();</span><br><span class="line">    String loggerFactoryClassName = binder.getLoggerFactoryClassStr();</span><br><span class="line"></span><br><span class="line">    return new ConditionOutcome(</span><br><span class="line">        loggerFactoryClassName.equals(bindingClassName),</span><br><span class="line">        String.format(&quot;Binding: %s, logger factory: %s&quot;, bindingClassName, loggerFactoryClassName));</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  private static String attribute(AnnotatedTypeMetadata metadata, String name) &#123;</span><br><span class="line">    return (String) metadata.getAnnotationAttributes(ConditionalOnSlf4jBinding.class.getName()).get(name);</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>使用时就像这样：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">@ConditionalOnSlf4jBinding(&quot;ch.qos.logback.classic.util.ContextSelectorStaticBinder&quot;)</span><br><span class="line">public class LogbackSentryAppenderInitializer &#123;</span><br><span class="line">  // ...</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>或是更进一步的枚举出所有日志实现：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">@Retention(RetentionPolicy.RUNTIME)</span><br><span class="line">@Target(&#123;ElementType.TYPE, ElementType.METHOD&#125;)</span><br><span class="line">@Documented</span><br><span class="line">@ConditionalOnSlf4jBinding(&quot;ch.qos.logback.classic.util.ContextSelectorStaticBinder&quot;)</span><br><span class="line">public @interface ConditionalOnLogbackBinding &#123;</span><br><span class="line"></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>使用时会更加简单：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">@ConditionalOnLogbackBinding</span><br><span class="line">public class LogbackSentryAppenderInitializer &#123;</span><br><span class="line">  // ...</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="加载状态"><a href="#加载状态" class="headerlink" title="加载状态"></a>加载状态</h3><p>配置 <code>debug</code> 属性启动 Spring Boot 应用时，会打印一系列 AUTO-CONFIGURATION REPORT 的记录。</p><p>在这个记录中首先会把所有 AutoConfiguration 分为两组，一组为命中条件自动加载，另一组为未命中条件不进行自动加载。在这个报告中开发者可以很清晰的看到当前应用中每一个配置类因为满足/不满足某些条件最终会/不会被自动加载。</p><p><img src="/uploads/15032348544518.jpg" alt=""></p><h2 id="配置项映射"><a href="#配置项映射" class="headerlink" title="配置项映射"></a>配置项映射</h2><p>在配置文件中定义属性是 AutoConfiguration 唯一与使用者相关的功能。</p><p>一般在普通的应用开发中，开发者一般会使用 <code>@Value</code> 注入配置文件中的值。而在定义配置类时，更好的做法是定义一个与配置文件映射的 Model，并通过 <code>@ConfigurationProperties</code> 进行标识。</p><p><img src="/uploads/15032355461124.jpg" alt=""></p><p>而当 Configuration 装载时，可以使用 <code>@EnableConfigurationProperties</code> 将对应的配置类引入，会自动注册为 Spring Bean。</p><p><img src="/uploads/15032355276952.jpg" alt=""></p><p>使用这种做法的好处有很多：</p><ul><li>结构化配置：支持 List、Map、URL 等复杂类型以及嵌套 Model 的映射。</li><li>校验：可以通过和 Hibernate Validator 等校验组件结合，通过注解进行自动校验。</li><li>热刷新支持：在 Spring Cloud 环境下可以在应用运行中刷新绑定的配置项。</li><li>配置文件提示：Spring Boot 提供了 <code>spring-configuration-metadata.json</code> 用来描述配置项，配合 IDE 可以在编写配置文件时有提示。</li></ul><p><img src="/uploads/15032352039035.jpg" alt=""></p><blockquote><p><code>@Value</code> 实际也是支持热刷新的，但是必须定义为 <code>@RefreshScope</code>，而且实现也有不同，理论上 <code>@ConfigurationProperties</code> 的热刷新更加轻量级。</p><p>使用 <code>spring-boot-configuration-processor</code> 依赖可以在编译时扫描项目中的 <code>@ConfigurationProperties</code> 类，并自动生成 <code>spring-configuration-metadata.json</code> 文件。</p></blockquote>]]></content>
    
    <summary type="html">
    
      &lt;p&gt;将公司内部分享的一个 Slide 拆解为两部分。本文是第一部分，主要介绍一下 Spring Boot AutoConfiguration 的组成和原理。&lt;/p&gt;
    
    </summary>
    
    
  </entry>
  
  <entry>
    <title>Spring Cloud 是如何实现热更新的</title>
    <link href="http://www.scienjus.com/spring-cloud-refresh/"/>
    <id>http://www.scienjus.com/spring-cloud-refresh/</id>
    <published>2017-07-25T12:12:31.000Z</published>
    <updated>2018-01-15T13:50:52.000Z</updated>
    
    <content type="html"><![CDATA[<p>作为一篇源码分析的文章，本文虽然介绍 Spring Cloud 的热更新机制，但是实际全文内容都不会与 Spring Cloud Config 以及 Spring Cloud Bus 有关，因为前者只是提供了一个远端的配置源，而后者也只是提供了集群环境下的事件触发机制，与核心流程均无太大关系。</p><a id="more"></a><h2 id="ContextRefresher"><a href="#ContextRefresher" class="headerlink" title="ContextRefresher"></a>ContextRefresher</h2><p>顾名思义，<code>ContextRefresher</code> 用于刷新 Spring 上下文，在以下场景会调用其 <code>refresh</code> 方法。</p><ol><li>请求 <code>/refresh</code> Endpoint。</li><li>集成 Spring Cloud Bus 后，收到 <code>RefreshRemoteApplicationEvent</code> 事件（任意集成 Bus 的应用，请求 <code>/bus/refresh</code> Endpoint 后都会将事件推送到整个集群）。</li></ol><p>这个方法包含了整个刷新逻辑，也是本文分析的重点。</p><p>首先看一下这个方法的实现：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line">public synchronized Set&lt;String&gt; refresh() &#123;</span><br><span class="line">  Map&lt;String, Object&gt; before = extract(</span><br><span class="line">      this.context.getEnvironment().getPropertySources());</span><br><span class="line">  addConfigFilesToEnvironment();</span><br><span class="line">  Set&lt;String&gt; keys = changes(before,</span><br><span class="line">      extract(this.context.getEnvironment().getPropertySources())).keySet();</span><br><span class="line">  this.context.publishEvent(new EnvironmentChangeEvent(keys));</span><br><span class="line">  this.scope.refreshAll();</span><br><span class="line">  return keys;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>首先是第一步 <code>extract</code>，这个方法接收了当前环境中的所有属性源（PropertySource），并将其中的非标准属性源的所有属性汇总到一个 Map 中返回。</p><p>这里的标准属性源指的是 <code>StandardEnvironment</code> 和 <code>StandardServletEnvironment</code>，前者会注册系统变量（System Properties）和环境变量（System Environment），后者会注册 Servlet 环境下的 Servlet Context 和 Servlet Config 的初始参数（Init Params）和 JNDI 的属性。个人理解是因为这些属性无法改变，所以不进行刷新。</p><p>第二步 <code>addConfigFilesToEnvironment</code> 是核心逻辑，它创建了一个新的 Spring Boot 应用并初始化：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">SpringApplicationBuilder builder = new SpringApplicationBuilder(Empty.class)</span><br><span class="line">    .bannerMode(Banner.Mode.OFF).web(false).environment(environment);</span><br><span class="line">// Just the listeners that affect the environment (e.g. excluding logging</span><br><span class="line">// listener because it has side effects)</span><br><span class="line">builder.application()</span><br><span class="line">    .setListeners(</span><br><span class="line">        Arrays.asList(new BootstrapApplicationListener(),</span><br><span class="line">            new ConfigFileApplicationListener()));</span><br><span class="line">capture = builder.run();</span><br></pre></td></tr></table></figure><p>这个应用只是为了重新加载一遍属性源，所以只配置了 <code>BootstrapApplicationListener</code> 和 <code>ConfigFileApplicationListener</code>，最后将新加载的属性源替换掉原属性源，至此属性源本身已经完成更新了。</p><p>此时属性源虽然已经更新了，但是配置项都已经注入到了对应的 Spring Bean 中，需要重新进行绑定，所以又触发了两个操作：</p><ol><li><p>将刷新后发生更改的 Key 收集起来，发送一个 <code>EnvironmentChangeEvent</code> 事件。</p></li><li><p>调用 <code>RefreshScope.refreshAll</code> 方法。</p></li></ol><h2 id="EnvironmentChangeEvent"><a href="#EnvironmentChangeEvent" class="headerlink" title="EnvironmentChangeEvent"></a>EnvironmentChangeEvent</h2><p>在上文中，<code>ContextRefresher</code> 发布了一个 <code>EnvironmentChangeEvent</code> 事件，接下来看看这个事件产生了哪些影响。</p><blockquote><p>The application will listen for an EnvironmentChangeEvent and react to the change in a couple of standard ways (additional ApplicationListeners can be added as @Beans by the user in the normal way). When an EnvironmentChangeEvent is observed it will have a list of key values that have changed, and the application will use those to:</p><ol><li><p>Re-bind any @ConfigurationProperties beans in the context</p></li><li><p>Set the logger levels for any properties in logging.level.*</p></li></ol></blockquote><p>官方文档的介绍中提到，这个事件主要会触发两个行为：</p><ol><li>重新绑定上下文中所有使用了 <code>@ConfigurationProperties</code> 注解的 Spring Bean。</li><li>如果 <code>logging.level.*</code> 配置发生了改变，重新设置日志级别。</li></ol><p>这两段逻辑分别可以在 <code>ConfigurationPropertiesRebinder</code> 和 <code>LoggingRebinder</code> 中看到。</p><h3 id="ConfigurationPropertiesRebinder"><a href="#ConfigurationPropertiesRebinder" class="headerlink" title="ConfigurationPropertiesRebinder"></a>ConfigurationPropertiesRebinder</h3><p>这个类乍一看代码量特别少，只需要一个 <code>ConfigurationPropertiesBeans</code> 和一个 <code>ConfigurationPropertiesBindingPostProcessor</code>，然后调用 <code>rebind</code> 每个 Bean 即可。但是这两个对象是从哪里来的呢？</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">public void rebind() &#123;</span><br><span class="line">  for (String name : this.beans.getBeanNames()) &#123;</span><br><span class="line">    rebind(name);</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>ConfigurationPropertiesBeans</code> 需要一个 <code>ConfigurationBeanFactoryMetaData</code>， 这个类逻辑很简单，它是一个 <code>BeanFactoryPostProcessor</code> 的实现，将所有的 Bean 都存在了内部的一个 Map 中。</p><p>而 ConfigurationPropertiesBeans 获得这个 Map 后，会查找每一个 Bean 是否有 <code>@ConfigurationProperties</code> 注解，如果有的话就放到自己的 Map 中。</p><p>绕了一圈好不容易拿到所有需要重新绑定的 Bean 后，绑定的逻辑就要简单许多了：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br></pre></td><td class="code"><pre><span class="line">public boolean rebind(String name) &#123;</span><br><span class="line">  if (!this.beans.getBeanNames().contains(name)) &#123;</span><br><span class="line">    return false;</span><br><span class="line">  &#125;</span><br><span class="line">  if (this.applicationContext != null) &#123;</span><br><span class="line">    try &#123;</span><br><span class="line">      Object bean = this.applicationContext.getBean(name);</span><br><span class="line">      if (AopUtils.isCglibProxy(bean)) &#123;</span><br><span class="line">        bean = getTargetObject(bean);</span><br><span class="line">      &#125;</span><br><span class="line">      this.binder.postProcessBeforeInitialization(bean, name);</span><br><span class="line">      this.applicationContext.getAutowireCapableBeanFactory()</span><br><span class="line">          .initializeBean(bean, name);</span><br><span class="line">      return true;</span><br><span class="line">    &#125;</span><br><span class="line">    catch (RuntimeException e) &#123;</span><br><span class="line">      this.errors.put(name, e);</span><br><span class="line">      throw e;</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;</span><br><span class="line">  return false;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>其中 <code>postProcessBeforeInitialization</code> 方法将 Bean 重新绑定了所有属性，并做了校验等操作。</p><p>而 <code>initializeBean</code> 的实现如下：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line">protected Object initializeBean(final String beanName, final Object bean, RootBeanDefinition mbd) &#123;</span><br><span class="line">  Object wrappedBean = bean;</span><br><span class="line">  if(mbd == null || !mbd.isSynthetic()) &#123;</span><br><span class="line">    wrappedBean = this.applyBeanPostProcessorsBeforeInitialization(bean, beanName);</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  try &#123;</span><br><span class="line">    this.invokeInitMethods(beanName, wrappedBean, mbd);</span><br><span class="line">  &#125; catch (Throwable var6) &#123;</span><br><span class="line">    throw new BeanCreationException(mbd != null?mbd.getResourceDescription():null, beanName, &quot;Invocation of init method failed&quot;, var6);</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  if(mbd == null || !mbd.isSynthetic()) &#123;</span><br><span class="line">    wrappedBean = this.applyBeanPostProcessorsAfterInitialization(wrappedBean, beanName);</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  return wrappedBean;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>其中主要做了三件事：</p><ol><li><code>applyBeanPostProcessorsBeforeInitialization</code>：调用所有 <code>BeanPostProcessor</code> 的 <code>postProcessBeforeInitialization</code> 方法。</li><li><code>invokeInitMethods</code>：如果 Bean 继承了 <code>InitializingBean</code>，执行 <code>afterPropertiesSet</code> 方法，或是如果 Bean 指定了 <code>init-method</code> 属性，如果有则调用对应方法</li><li><code>applyBeanPostProcessorsAfterInitialization</code>：调用所有 <code>BeanPostProcessor</code> 的 <code>postProcessAfterInitialization</code> 方法。</li></ol><p>之后 <code>ConfigurationPropertiesRebinder</code> 就完成整个重新绑定流程了。</p><h3 id="LoggingRebinder"><a href="#LoggingRebinder" class="headerlink" title="LoggingRebinder"></a>LoggingRebinder</h3><p>相比之下 <code>LoggingRebinder</code> 的逻辑要简单许多，它只是调用了 <code>LoggingSystem</code> 的方法重新设置了日志级别，具体逻辑就不在本文详述了。</p><h2 id="RefreshScope"><a href="#RefreshScope" class="headerlink" title="RefreshScope"></a>RefreshScope</h2><p>首先看看这个类的注释：</p><blockquote><p>Note that all beans in this scope are <em>only</em> initialized when first accessed, so the scope forces lazy initialization semantics. The implementation involves creating a proxy for every bean in the scope, so there is a flag</p><p>If a bean is refreshed then the next time the bean is accessed (i.e. a method is executed) a new instance is created. All lifecycle methods are applied to the bean instances, so any destruction callbacks that were registered in the bean factory are called when it is refreshed, and then the initialization callbacks are invoked as normal when the new instance is created. A new bean instance is created from the original bean definition, so any externalized content (property placeholders or expressions in string literals) is re-evaluated when it is created.</p></blockquote><p>这里提到了两个重点：</p><ol><li>所有 <code>@RefreshScope</code> 的 Bean 都是延迟加载的，只有在第一次访问时才会初始化</li><li>刷新 Bean 也是同理，下次访问时会创建一个新的对象</li></ol><p>再看一下方法实现：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">public void refreshAll() &#123;</span><br><span class="line">  super.destroy();</span><br><span class="line">  this.context.publishEvent(new RefreshScopeRefreshedEvent());</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这个类中有一个成员变量 <code>cache</code>，用于缓存所有已经生成的 Bean，在调用 <code>get</code> 方法时尝试从缓存加载，如果没有的话就生成一个新对象放入缓存，并通过 <code>getBean</code> 初始化其对应的 Bean：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line">public Object get(String name, ObjectFactory&lt;?&gt; objectFactory) &#123;</span><br><span class="line">  if (this.lifecycle == null) &#123;</span><br><span class="line">    this.lifecycle = new StandardBeanLifecycleDecorator(this.proxyTargetClass);</span><br><span class="line">  &#125;</span><br><span class="line">  BeanLifecycleWrapper value = this.cache.put(name,</span><br><span class="line">      new BeanLifecycleWrapper(name, objectFactory, this.lifecycle));</span><br><span class="line">  try &#123;</span><br><span class="line">    return value.getBean();</span><br><span class="line">  &#125;</span><br><span class="line">  catch (RuntimeException e) &#123;</span><br><span class="line">    this.errors.put(name, e);</span><br><span class="line">    throw e;</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>所以在销毁时只需要将整个缓存清空，下次获取对象时自然就可以重新生成新的对象，也就自然绑定了新的属性：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line">public void destroy() &#123;</span><br><span class="line">  List&lt;Throwable&gt; errors = new ArrayList&lt;Throwable&gt;();</span><br><span class="line">  Collection&lt;BeanLifecycleWrapper&gt; wrappers = this.cache.clear();</span><br><span class="line">  for (BeanLifecycleWrapper wrapper : wrappers) &#123;</span><br><span class="line">    try &#123;</span><br><span class="line">      wrapper.destroy();</span><br><span class="line">    &#125;</span><br><span class="line">    catch (RuntimeException e) &#123;</span><br><span class="line">      errors.add(e);</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;</span><br><span class="line">  if (!errors.isEmpty()) &#123;</span><br><span class="line">    throw wrapIfNecessary(errors.get(0));</span><br><span class="line">  &#125;</span><br><span class="line">  this.errors.clear();</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>清空缓存后，下次访问对象时就会重新创建新的对象并放入缓存了。</p><p>而在清空缓存后，它还会发出一个 <code>RefreshScopeRefreshedEvent</code> 事件，在某些 Spring Cloud 的组件中会监听这个事件并作出一些反馈。</p><h3 id="Zuul"><a href="#Zuul" class="headerlink" title="Zuul"></a>Zuul</h3><p>Zuul 在收到这个事件后，会将自身的路由设置为 dirty 状态：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line">private static class ZuulRefreshListener implements ApplicationListener&lt;ApplicationEvent&gt; &#123;</span><br><span class="line"></span><br><span class="line">  @Autowired</span><br><span class="line">  private ZuulHandlerMapping zuulHandlerMapping;</span><br><span class="line">  </span><br><span class="line">  @Override</span><br><span class="line">  public void onApplicationEvent(ApplicationEvent event) &#123;</span><br><span class="line">    if (event instanceof ContextRefreshedEvent</span><br><span class="line">        || event instanceof RefreshScopeRefreshedEvent</span><br><span class="line">        || event instanceof RoutesRefreshedEvent) &#123;</span><br><span class="line">      this.zuulHandlerMapping.setDirty(true);</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>并且当路由实现为 <code>RefreshableRouteLocator</code>  时，会尝试刷新路由：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">public void setDirty(boolean dirty) &#123;</span><br><span class="line">  this.dirty = dirty;</span><br><span class="line">  if (this.routeLocator instanceof RefreshableRouteLocator) &#123;</span><br><span class="line">    ((RefreshableRouteLocator) this.routeLocator).refresh();</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>当状态为 dirty 时，Zuul 会在下一次接受请求时重新注册路由，以更新配置：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">if (this.dirty) &#123;</span><br><span class="line">  synchronized (this) &#123;</span><br><span class="line">    if (this.dirty) &#123;</span><br><span class="line">      registerHandlers();</span><br><span class="line">      this.dirty = false;</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="Eureka"><a href="#Eureka" class="headerlink" title="Eureka"></a>Eureka</h3><p>在 Eureka 收到该事件时，对于客户端和服务端都有不同的处理方式：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br></pre></td><td class="code"><pre><span class="line">protected static class EurekaClientConfigurationRefresher &#123;</span><br><span class="line"></span><br><span class="line">  @Autowired(required = false)</span><br><span class="line">  private EurekaClient eurekaClient;</span><br><span class="line"></span><br><span class="line">  @Autowired(required = false)</span><br><span class="line">  private EurekaAutoServiceRegistration autoRegistration;</span><br><span class="line"></span><br><span class="line">  @EventListener(RefreshScopeRefreshedEvent.class)</span><br><span class="line">  public void onApplicationEvent(RefreshScopeRefreshedEvent event) &#123;</span><br><span class="line">    //This will force the creation of the EurkaClient bean if not already created</span><br><span class="line">    //to make sure the client will be reregistered after a refresh event</span><br><span class="line">    if(eurekaClient != null) &#123;</span><br><span class="line">      eurekaClient.getApplications();</span><br><span class="line">    &#125;</span><br><span class="line">    if (autoRegistration != null) &#123;</span><br><span class="line">      // register in case meta data changed</span><br><span class="line">      this.autoRegistration.stop();</span><br><span class="line">      this.autoRegistration.start();</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>对于客户端来说，只是调用了下 <code>eurekaClient.getApplications</code>，理论上这个方法是没有任何效果的，但是查看上面的注释，以及联想到 <code>RefreshScope</code> 的延时初始化特性，这个方法调用应该只是为了强制初始化新的 <code>EurekaClient</code>。</p><p>事实上这里很有趣的是，在 <code>EurekaClientAutoConfiguration</code> 中，实际为了 <code>EurekaClient</code> 提供了两种初始化方案，分别对应是否有 <code>RefreshScope</code>，所以以上的猜测应该是正确的。</p><p>而对于服务端来说，<code>EurekaAutoServiceRegistration</code> 会将服务端先标记为下线，在进行重新上线。</p><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>至此，Spring Cloud 的热更新流程就到此结束了，从这些源码中可以总结出以下结论：</p><ol><li>通过使用 <code>ContextRefresher</code> 可以进行手动的热更新，而不需要依靠 Bus 或是 Endpoint。</li><li>热更新会对两类 Bean 进行配置刷新，一类是使用了 <code>@ConfigurationProperties</code> 的对象，另一类是使用了 <code>@RefreshScope</code> 的对象。</li><li>这两种对象热更新的机制不同，前者在同一个对象中重新绑定了所有属性，后者则是利用了 <code>RefreshScope</code> 的缓存和延迟加载机制，生成了新的对象。</li><li>通过自行监听 <code>EnvironmentChangeEvent</code> 事件，也可以获得更改的配置项，以便实现自己的热更新逻辑。</li><li>在使用 Eureka 的项目中要谨慎的使用热更新，过于频繁的更新可能会使大量项目频繁的标记下线和上线，需要注意。</li></ol>]]></content>
    
    <summary type="html">
    
      &lt;p&gt;作为一篇源码分析的文章，本文虽然介绍 Spring Cloud 的热更新机制，但是实际全文内容都不会与 Spring Cloud Config 以及 Spring Cloud Bus 有关，因为前者只是提供了一个远端的配置源，而后者也只是提供了集群环境下的事件触发机制，与核心流程均无太大关系。&lt;/p&gt;
    
    </summary>
    
    
  </entry>
  
  <entry>
    <title>聊聊 API Gateway 和 Netflix Zuul</title>
    <link href="http://www.scienjus.com/api-gateway-and-netflix-zuul/"/>
    <id>http://www.scienjus.com/api-gateway-and-netflix-zuul/</id>
    <published>2017-05-30T05:03:21.000Z</published>
    <updated>2018-01-15T13:50:52.000Z</updated>
    
    <content type="html"><![CDATA[<p>最近参与了公司 API Gateway 的搭建工作，技术选型是 Netflix Zuul，主要聊一聊其中的一些心得和体会。</p><a id="more"></a><p>本文主要是介绍使用 Zuul 且在不强制使用其他 Neflix OSS 组件时，如何搭建生产环境的 Gateway，以及能使用 Gateway 做哪些事。不打算介绍任何关于如何快速搭建 Zuul，或是一些轻易集成 Eureka 之类的的方法，这些在官方文档上已经介绍的很明确了。</p><h2 id="API-Gateway"><a href="#API-Gateway" class="headerlink" title="API Gateway"></a>API Gateway</h2><p>API Gateway 是随着微服务（Microservice）这个概念一起兴起的一种架构模式，它用于解决微服务过于分散，没有一个统一的出入口进行流量管理的问题。</p><p>用 Kong 官网的两张图来解释再合适不过。</p><p><img src="https://getkong.org/assets/images/homepage/diagram-left.png" alt="分散的 API 管理"></p><p>当使用微服务构建整个 API 服务时，一般会有许许多多职责不同的应用在运行着，这些应用会需要一些通用的功能，例如鉴权、流控、监控、日志统计。</p><p>在传统的单体应用中，这些功能一般都是内嵌在应用中，作为一个组件运行。但是在微服务模式下，不同种类且独立运行的应用可能会有数十甚至数百种，继续使用这种方式会造成非常高的管理和发布成本。所以就需要在这些应用上抽象出一个统一的流量入口，完成这些功能的实现。</p><p><img src="https://getkong.org/assets/images/homepage/diagram-right.png" alt="统一的 API 管理"></p><p>在我看来，API Gateway 的职责主要分为两部分：</p><ol><li>对服务应用有感知且重要的功能，例如鉴权。</li><li>对服务应用无感知的边缘服务，例如流控、监控、页面级缓存等。</li></ol><h2 id="Netflix-Zuul"><a href="#Netflix-Zuul" class="headerlink" title="Netflix Zuul"></a>Netflix Zuul</h2><p>对于 API Gateway，常见的选型有基于 Openresty 的 Kong、基于 Go 的 Tyk 和基于 Java 的 Zuul。</p><p>这三个选型本身没有什么明显的区别，主要还是看技术栈是否能满足快速应用和二次开发，例如我司原有的技术栈就是使用 Go/Openresty 的平台组和使用 Java 的后端组，讨论后觉得 API Gateway 未来还是处理业务功能的场景更多些，而且后端这边有很多功能可以直接移植过来，最终就选择了 Zuul。</p><p>关于 Zuul，大部分使用 Java 做微服务的人可能都会或多或少了解 Spring Cloud 和 Netflix 全家桶。而对于完全不了解的人，可以暂时将它想象为一个类似于 Servlet 中过滤器（Filter）的概念。</p><p><img src="https://camo.githubusercontent.com/4eb7754152028cdebd5c09d1c6f5acc7683f0094/687474703a2f2f6e6574666c69782e6769746875622e696f2f7a75756c2f696d616765732f7a75756c2d726571756573742d6c6966656379636c652e706e67" alt="Zuul"></p><p>就像上图中所描述的一样，Zuul 提供了四种过滤器的 API，分别为前置（Pre）、后置（Post）、路由（Route）和错误（Error）四种处理方式。</p><p>一个请求会先按顺序通过所有的前置过滤器，之后在路由过滤器中转发给后端应用，得到响应后又会通过所有的后置过滤器，最后响应给客户端。在整个流程中如果发生了异常则会跳转到错误过滤器中。</p><p>一般来说，如果需要在请求到达后端应用前就进行处理的话，会选择前置过滤器，例如鉴权、请求转发、增加请求参数等行为。在请求完成后需要处理的操作放在后置过滤器中完成，例如统计返回值和调用时间、记录日志、增加跨域头等行为。路由过滤器一般只需要选择 Zuul 中内置的即可，错误过滤器一般只需要一个，这样可以在 Gateway 遇到错误逻辑时直接抛出异常中断流程，并直接统一处理返回结果。</p><h2 id="应用场景"><a href="#应用场景" class="headerlink" title="应用场景"></a>应用场景</h2><p>以下介绍一些 Zuul 中不同过滤器的应用场景。</p><h3 id="前置过滤器"><a href="#前置过滤器" class="headerlink" title="前置过滤器"></a>前置过滤器</h3><h4 id="鉴权"><a href="#鉴权" class="headerlink" title="鉴权"></a>鉴权</h4><p>一般来说整个服务的鉴权逻辑可以很复杂。</p><ul><li>客户端：App、Web、Backend</li><li>权限组：用户、后台人员、其他开发者</li><li>实现：OAuth、JWT</li><li>使用方式：Token、Cookie、SSO</li></ul><p>而对于后端应用来说，它们其实只需要知道请求属于谁，而不需要知道为什么，所以 Gateway 可以友善的帮助后端应用完成鉴权这个行为，并将用户的唯一标示透传到后端，而不需要、甚至不应该将身份信息也传递给后端，防止某些应用利用这些敏感信息做错误的事情。</p><p>Zuul 默认情况下在处理后会删除请求的 <code>Authorization</code> 头和 <code>Set-Cookie</code> 头，也算是贯彻了这个原则。</p><h4 id="流量转发"><a href="#流量转发" class="headerlink" title="流量转发"></a>流量转发</h4><p>流量转发的含义就是将指向 <code>/a/xxx.json</code> 的请求转发到指向 <code>/b/xxx.json</code> 的请求。这个功能可能在一些项目迁移、或是灰度发布上会有一些用处。</p><p>在 Zuul 中并没有一个很好的办法去修改 Request URI。在某些 <a href="https://github.com/spring-cloud/spring-cloud-netflix/issues/435" target="_blank" rel="external">Issue</a> 中开发者会建议设置 <code>requestURI</code> 这个属性，但是实际在 Zuul 自身的 <code>PreDecorationFilter</code> 流程中又会被覆盖一遍。</p><p>不过对于一个基于 Servlet 的应用，使用 <code>HttpServletRequestWrapper</code> 基本可以解决一切问题，在这个场景中只需要重写其 <code>getRequestURI</code> 方法即可。</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line">class RewriteURIRequestWrapper extends HttpServletRequestWrapper &#123;</span><br><span class="line"></span><br><span class="line">  private String rewriteURI;</span><br><span class="line"></span><br><span class="line">  public RewriteURIRequestWrapper(HttpServletRequest request, String rewriteURI) &#123;</span><br><span class="line">    super(request);</span><br><span class="line">    this.rewriteURI = rewriteURI;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  @Override</span><br><span class="line">  public String getRequestURI() &#123;</span><br><span class="line">    return rewriteURI;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="后置过滤器"><a href="#后置过滤器" class="headerlink" title="后置过滤器"></a>后置过滤器</h3><h4 id="跨域"><a href="#跨域" class="headerlink" title="跨域"></a>跨域</h4><p>使用 Gateway 做跨域相比应用本身或是 Nginx 的好处是规则可以配置的更加灵活。例如一个常见的规则。</p><ol><li>对于任意的 AJAX 请求，返回 <code>Access-Control-Allow-Origin</code> 为 <code>*</code>，且 <code>Access-Control-Allow-Credentials</code> 为 <code>true</code>，这是一个常用的允许任意源跨域的配置，但是不允许请求携带任何 Cookie</li><li>如果一个被信任的请求者需要携带 Cookie，那么将它的 <code>Origin</code> 增加到白名单中。对于白名单中的请求，返回 <code>Access-Control-Allow-Origin</code> 为该域名，且 <code>Access-Control-Allow-Credentials</code> 为 <code>true</code>，这样请求者可以正常的请求接口，同时可以在请求接口时携带 Cookie</li><li>对于 302 的请求，即使在白名单内也必须要设置 <code>Access-Control-Allow-Origin</code> 为 <code>*</code>，否则重定向后的请求携带的 <code>Origin</code> 会为 <code>null</code>，有可能会导致 iOS 低版本的某些兼容问题</li></ol><h4 id="统计"><a href="#统计" class="headerlink" title="统计"></a>统计</h4><p>Gateway 可以统一收集所有应用请求的记录，并写入日志文件或是发到监控系统，相比 Nginx 的 access log，好处主要也是二次开发比较方便，比如可以关注一些业务相关的 HTTP 头，或是将请求参数和返回值都保存为日志打入消息队列中，便于线上故障调试。也可以收集一些性能指标发送到类似 Statsd 这样的监控平台。</p><h3 id="错误过滤器"><a href="#错误过滤器" class="headerlink" title="错误过滤器"></a>错误过滤器</h3><p>错误过滤器的主要用法就像是 Jersey 中的 <code>ExceptionMapper</code> 或是 Spring MVC 中的 <code>@ExceptionHandler</code> 一样，在处理流程中认为有问题时，直接抛出统一的异常，错误过滤器捕获到这个异常后，就可以统一的进行返回值的封装，并直接结束该请求。</p><h2 id="配置管理"><a href="#配置管理" class="headerlink" title="配置管理"></a>配置管理</h2><p>虽然将这些逻辑都切换到了 Gateway，省去了很多维护和迭代的成本，但是也面临着一个很大的问题，就是 Gateway 只有逻辑却没有配置，它并不知道一个请求要走哪些流程。</p><p>例如同样是后端服务 API，有的可能是给网页版用的、有的是给客户端用的，亦或是有的给用户用、有的给管理人员用，那么 Gateway 如何知道到底这些 API 是否需要登录、流控以及缓存呢？</p><p>理论上我们可以为 Gateway 编写一个管理后台，里面有当前服务的所有 API，每一个开发者都可以在里面创建新的 API，以及为它增加鉴权、缓存、跨域等功能。为了简化使用，也许我们会额外的增加一个权限组，例如 <code>/admin/*</code> 下的所有 API 都应该为后台接口，它只允许内部来源的鉴权访问。</p><p>但是这样做依旧太复杂了，而且非常硬编码，当开发者开发了一个新的 API 之后，即使这个应用已经能正常接收特定 URI 的请求并处理之后，却还要通过人工的方式去一个管理后台进行额外的配置，而且可能会因为不谨慎打错了路径中的某个单词而造成不必要的事故，这都是不合理的。</p><p>我个人推荐的做法是，在后端应用中依旧保持配置的能力，即使应用里已经没有真实处理的逻辑了。例如在 Java 中通过注解声明式的编写 API，且在应用启动时自动注册 Gateway 就是一种比较好的选择。</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line">/**</span><br><span class="line"> * 这个接口需要鉴权，鉴权方式是 OAuth</span><br><span class="line"> */</span><br><span class="line">@Authorization(OAuth)</span><br><span class="line">@RequestMapping(value = &quot;/users/&#123;id&#125;&quot;, method = RequestMethod.DELETE)</span><br><span class="line">public void del(@PathVariable int id) &#123;</span><br><span class="line">  //...  </span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">/**</span><br><span class="line"> * 这个接口可以缓存，并且每个 IP/User 每秒最多请求 10 次</span><br><span class="line"> */</span><br><span class="line">@Cacheable</span><br><span class="line">@RateLimiting(limit = &quot;10/1s&quot;, scope = &#123;IP, USER&#125;)</span><br><span class="line">@RequestMapping(value = &quot;/users/&#123;id&#125;&quot;, method = RequestMethod.GET)</span><br><span class="line">public void info(@PathVariable int id) &#123;</span><br><span class="line">  //...  </span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这样 API 的编写者就会根据业务场景考虑该 API 需要哪些功能，也减少了管理的复杂度。</p><p>除此之外还会有一些后端应用无关的配置，有些是自动化的，例如恶意请求拦截，Gateway 会将所有请求的信息通过消息队列发送给一些实时数据分析的应用，这些应用会对请求分析，发现恶意请求的特征，并通过 Gateway 提供的接口将这些特征上报给 Gateway，Gateway 就可以实时的对这些恶意请求进行拦截。</p><h2 id="稳定性"><a href="#稳定性" class="headerlink" title="稳定性"></a>稳定性</h2><p>在 Nginx 和后端应用之间又建立了一个 Java 应用作为流量入口，很多人会去担心它的稳定性，亦或是担心它能否像 Nginx 一样和后端的多个 upstream 进行交互，以下主要介绍一下 Zuul 的隔离机制以及重试机制。</p><h3 id="隔离机制"><a href="#隔离机制" class="headerlink" title="隔离机制"></a>隔离机制</h3><p>在微服务的模式下，应用之间的联系变得没那么强烈，理想中任何一个应用超过负载或是挂掉了，都不应该去影响到其他应用。但是在 Gateway 这个层面，有没有可能出现一个应用负载过重，导致将整个 Gateway 都压垮了，已致所有应用的流量入口都被切断？</p><p>这当然是有可能的，想象一个每秒会接受很多请求的应用，在正常情况下这些请求可能在 10 毫秒之内就能正常响应，但是如果有一天它出了问题，所有请求都会 Block 到 30 秒超时才会断开（例如频繁 Full GC 无法有效释放内存）。那么在这个时候，Gateway 中也会有大量的线程在等待请求的响应，最终会吃光所有线程，导致其他正常应用的请求也受到影响。</p><p>在 Zuul 中，每一个后端应用都称为一个 Route，为了避免一个 Route 抢占了太多资源影响到其他 Route 的情况出现，Zuul 使用 Hystrix 对每一个 Route 都做了隔离和限流。</p><p>Hystrix 的隔离策略有两种，基于线程或是基于信号量。Zuul 默认的是基于线程的隔离机制，这意味着每一个 Route 的请求都会在一个固定大小且独立的线程池中执行，这样即使其中一个 Route 出现了问题，也只会是某一个线程池发生了阻塞，其他 Route 不会受到影响。</p><p>一般使用 Hystrix 时，只有调用量巨大会受到线程开销影响时才会使用信号量进行隔离策略，对于 Zuul 这种网络请求的用途使用线程隔离更加稳妥。</p><h3 id="重试机制"><a href="#重试机制" class="headerlink" title="重试机制"></a>重试机制</h3><p>一般来说，后端应用的健康状态是不稳定的，应用列表随时会有修改，所以 Gateway 必须有足够好的容错机制，能够减少后端应用变更时造成的影响。</p><p>Zuul 的路由主要有 Eureka 和 Ribbon 两种方式，由于我一直使用的都是 Ribbon，所以简单介绍下 Ribbon 支持哪些容错配置。</p><p>重试的场景分为三种：</p><ul><li><code>okToRetryOnConnectErrors</code>：只重试网络错误</li><li><code>okToRetryOnAllErrors</code>：重试所有错误</li><li><code>OkToRetryOnAllOperations</code>：重试所有操作（这里不太理解，猜测是 GET/POST 等请求都会重试）</li></ul><p>重试的次数有两种：</p><ul><li><code>MaxAutoRetries</code>：每个节点的最大重试次数</li><li><code>MaxAutoRetriesNextServer</code>：更换节点重试的最大次数</li></ul><p>一般来说我们希望只在网络连接失败时进行重试、或是对 5XX 的 GET 请求进行重试（不推荐对 POST 请求进行重试，无法保证幂等性会造成数据不一致）。单台的重试次数可以尽量小一些，重试的节点数尽量多一些，整体效果会更好。</p><p>如果有更加复杂的重试场景，例如需要对特定的某些 API、特定的返回值进行重试，那么也可以通过实现 <code>RequestSpecificRetryHandler</code> 定制逻辑（不建议直接使用 <code>RetryHandler</code>，因为这个子类可以使用很多已有的功能）。</p>]]></content>
    
    <summary type="html">
    
      &lt;p&gt;最近参与了公司 API Gateway 的搭建工作，技术选型是 Netflix Zuul，主要聊一聊其中的一些心得和体会。&lt;/p&gt;
    
    </summary>
    
    
  </entry>
  
  <entry>
    <title>文档化 Apache Thrift</title>
    <link href="http://www.scienjus.com/document-apache-thrift/"/>
    <id>http://www.scienjus.com/document-apache-thrift/</id>
    <published>2017-05-14T05:03:21.000Z</published>
    <updated>2018-01-15T13:50:52.000Z</updated>
    
    <content type="html"><![CDATA[<p>在 RPC 选型中，相较于最基础的 HTTP/JSON API，基于 IDL 约束的 Thrift 在跨语言、序列化性能上占有很多优势。但是在实际使用中由于无法享受 HTTP 丰富的资源库，也带来了不少困扰，其中一个比较常见的麻烦问题就是 IDL 的共享以及协议迭代。</p><a id="more"></a><h2 id="IDL-共享的问题"><a href="#IDL-共享的问题" class="headerlink" title="IDL 共享的问题"></a>IDL 共享的问题</h2><p>一般在相同语言的多个项目中如何共享 IDL 呢？</p><p>在我们大部分的 Java 项目中，服务提供方都会定义一个 <code>$project-api</code> 的模块，专门用来放给其他项目调用相关类，其中自然也包括了 Thrift IDL 生成后的类。我们甚至会在 <code>Thrift.Iface/Client</code> 上再包一层自己实现的 Client，使整个接口定义与 Thrift 这个实现方案彻底无关，即使未来有一天我们将通讯协议换成了其他方案（HTTP/gRPC），使用方也只需要更新一下依赖版本，而不需要改任何代码。</p><p>而在多语言的项目中又该如何共享 IDL 呢？</p><p>一般的 RPC 服务会存在一个 Service 和多个 Client，在我们开发的 Python 和 Java 项目中，除了 Java Client 使用 Java Service 可以用上述的方式直接共享生成后的 class 文件，其他使用方式都需要将 IDL 文件放入项目源码中，这样就会导致一份 IDL 会存在与多个项目中，而随着项目的迭代，很难做到所有项目之间的版本是相同的，混乱由此而生。而 Thrift 序列化的高效只建立在 Field Id 作为序列化索引实现的基础上，一旦 Field Id 出现了不一致，就会出现很难排查的数据丢失问题。</p><h2 id="解决方案"><a href="#解决方案" class="headerlink" title="解决方案"></a>解决方案</h2><p>面对这个问题，核心诉求就是希望 IDL 可以只存一份，并对所有项目共享（而不仅仅是通过 Java/Maven 的方式在部分语言中共享）。</p><p>使用 Git 子模块是一个很简单的解决方案，但是我很难认同这种做法，大多数情况下它增加了普通项目管理的复杂度，如果目前服务的调用方都是 Java，那么我们根本不需要这种方式，而如果有一天一个新的 Python 项目需要接入了，Service 就需要把 IDL 放到一个子模块中，那么之前的那些 Java 项目要不要也跟着进行修改？其实修改是无意义的，但是不修改又会造成多个项目之间管理的不统一。</p><p>另一种方式是单独使用一个仓库存放 IDL 文件，在发生变更的时候去触发所有语言的构建和包管理，例如打一个 Java 包上传到 Maven、生成一些 Python 文件放到 pip（虽然 Python 的 thriftpy 已经强大到不需要任何依赖就可以直接在运行时读取并解析 IDL 文件了）。</p><p>我个人认为理想的解决方案是不在任何项目中直接引用 IDL 文件，而是引用其被托管的一个网络地址（如果你了解 Java，应该会联想到 Spring Cloud Config）。当 Java 或 Python 项目<br>构建时可以选择性的去拉取变更的 IDL 文件，和上面的区别主要就是 Push 和 Pull 的区别，而且不需要为了几个文件去发一个个依赖包。</p><p>比较遗憾的是：这种做法在 Gradle 中或许只是几行代码的事情，而在 Maven 中却需要额外的添加自定义插件或是去魔改 Maven Thrift Plugin，而前者依旧会增加项目的复杂度，后者的话我连这个插件的源码托管在何处都不知道。</p><h2 id="文档化"><a href="#文档化" class="headerlink" title="文档化"></a>文档化</h2><p>回到问题的源头，我们究竟为什么希望所有项目中共享的 IDL 完全同步？</p><p>IDL 不同步带来的最坏结果就是由序列化/反序列化无法对应导致的字段丢失或是报错，其次是可能出现服务升级后提供新的 API 无法有效地通知调用方升级。</p><p>而这些在 HTTP/JSON 服务中也会出现，而且其没有任何强约束办法，只能通过 Swagger 这类文档工具进行信息同步。换言之，对于一个能够做好向后兼容的服务来说（当然这也是一个服务的最基本要求），调用方的所有更新都应该是可选的，我们只需要一个平台去展示每一个版本的 IDL 和其描述信息（文档）。</p><h2 id="实施"><a href="#实施" class="headerlink" title="实施"></a>实施</h2><h3 id="Armeria"><a href="#Armeria" class="headerlink" title="Armeria"></a>Armeria</h3><p><a href="https://github.com/line/armeria" target="_blank" rel="external">Armeria</a> 由 Netty 作者 Trustin Lee 开发的 RPC 框架，也是我目前知道的唯一可以将 Thrift 生成文档的框架。</p><p>但是由于一些坑，我们无法直接使用 Armeria 生成整套文档</p><h4 id="Thrift-Java-Compiler-的坑"><a href="#Thrift-Java-Compiler-的坑" class="headerlink" title="Thrift Java Compiler 的坑"></a>Thrift Java Compiler 的坑</h4><p>Thrift Java Compiler 本身的有一个 Bug，当 IDL 中 Struct 没有严格按照使用顺序定义时，生成的 class 文件中的 <code>FieldMetaData</code> 是错误的。</p><p>例如一个 IDL 正确的顺序应该如下：</p><figure class="highlight thrift"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="class"><span class="keyword">struct</span> <span class="title">A</span> </span>&#123;</span><br><span class="line">  <span class="number">1</span>:<span class="built_in">i64</span> id;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="class"><span class="keyword">struct</span> <span class="title">B</span> </span>&#123;</span><br><span class="line">  <span class="number">1</span>:A a;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>在某些语言的 Thrift Compiler 实现中，因为 B 引用了 A，所以定义顺序必须是 A 在 B 前面（例如 thriftpy 就强制要求，否则会解析错误）。</p><p>但是在 Java 中却可以将 A 定义在 B 之后，而且使用时完全没有任何问题。直到我们开始用 FieldMetaData 去生成文档。</p><p>在正常顺序下生成的 B.class 当中 A 的 FieldMetaData 为：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">tmpMap.put(B._Fields.A, new FieldMetaData(&quot;a&quot;, (byte)3, new StructMetaData((byte)12, A.class)));</span><br></pre></td></tr></table></figure><p>它正确的使用了 <code>StructMetaData</code> 并引用到了 A.class，这样我们就可以找到这个字段相关的 Struct。</p><p>但是如果顺序是错误的，将 A 定义在了 B 的后面，生成的 FieldMetaData 为：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">tmpMap.put(B._Fields.A, new FieldMetaData(&quot;a&quot;, (byte)3, new FieldValueMetaData((byte)12, &quot;A&quot;)));</span><br></pre></td></tr></table></figure><p>可以看到 <code>StructMetaData</code> 变成了 <code>FieldValueMetaData</code>，并且丢失了 Class 信息。</p><h4 id="扩展性问题"><a href="#扩展性问题" class="headerlink" title="扩展性问题"></a>扩展性问题</h4><p>Armeria 的文档系统是建立在其基础方案之上的，这意味着如果你本身就使用其作为 RPC 框架，那么生成文档只需要额外的加一个 <code>DocService</code> 即可。并且它还能直接在文档页面中通过 <a href="https://github.com/line/armeria/blob/master/thrift/src/main/java/com/linecorp/armeria/common/thrift/text/TTextProtocol.java" target="_blank" rel="external">TText</a> 的协议方式直接进行在线调用。</p><p>但是我目前所使用的系统并没有直接使用 Armeria，而是内部通过 Etcd 实现的 Thrift 服务注册与发现，这样使得不光没办法使用自带的在线调用功能，连生成文档界面都需要额外引入 Thrift 相关类并将空实现注册到 Armeria，整个系统能直接用到的功能非常少。</p><h3 id="thriftpy"><a href="#thriftpy" class="headerlink" title="thriftpy"></a>thriftpy</h3><p>如果你之前了解过 Swagger（一个 HTTP 文档的协议规范），你应该能明白一个良好的文档工具最重要的就是 Schema，它能够将生成程序和页面渲染程序直接解耦，方便对已有组件进行改造和复用。</p><p>很幸运的是，Armeria 就拥有一套用来描述 Thrift API 的 Schema。这样我们可以不去使用它通过读取 Java class 生成 Schema 的组件，只是用将 Schema 渲染成页面的组件去渲染我们自己生成的 Schema。</p><p>在与 Python 同事联调时，我发现 thriftpy 就可以帮助我生成 Schema，而且实际写下来整个代码逻辑会非常简单，一共只需要不到 100 行便可以完成。</p><p>由于我本身写 Python 很少，代码看上去比较丑陋就不展示了，只是介绍下大概思路：</p><ol><li>通过 <code>thriftpy.load</code> 加载 IDL 生成的模块拥有 <code>__thrift_meta__</code> 属性，可以从中获取这个 IDL 中有哪些 Struct、Service、Enum 和 Exception。</li><li>对于 Struct，通过 <code>thrift_spec</code> 可以拿到所有字段信息，这是个字典，Key 是字段 ID，Val 是一个元祖，包含字段信息。</li><li>字段信息的长度可能为 3 或是 4。是 3 的话值分别为字段类型、名称和是否必须，如果为 4 说明是 List/Set/Map，值分别为字段类型、名称、泛型和是否必须。</li><li>对于 Enum，直接通过 <code>_NAMES_TO_VALUES</code> 就可以拿到所有枚举和值之间的对应关系了。</li><li>对于 Service，需要拿到参数 <code>${service_name}_args</code> 和返回值 <code>${service_name}_result</code>，解析这两个结构基本和解析 Struct 一样。</li><li>Exception 也是一种特殊的 Struct，不再详述。</li></ol><p>当然在使用中也需要魔改部分 thriftpy 的源码，比如在 [Parser][3] 中恢复对 namespace 的执行，并读取的 Java 的 namespace。因为我们在 Etcd 上注册节点就是以 namespace 为路径的，用于查看当前节点或是未来重新支持在线调试都是必不可少的。</p><p>最后整个项目的目录为：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">idl/</span><br><span class="line">-- thrift/ # 放 Thrift IDL</span><br><span class="line">-- public/ # 放 Armeria 的静态页面</span><br><span class="line">-- make_docs.py # 读取 thrift 中的文件，生成一个 JSON 文件放到 public 中</span><br></pre></td></tr></table></figure><p>目前我将其托管为了一个 Gitlab Pages 项目，只要该项目由变更（多数为 IDL 修改）就会触发 Ci build 重新生成最新的文档。</p><h2 id="一些思考"><a href="#一些思考" class="headerlink" title="一些思考"></a>一些思考</h2><p>长久以来用 Thrift 积累了很多经验和疑惑，现在个人认为对于一般小公司，如果网络请求还没有成为整个 RPC 调用的性能瓶颈，使用 HTTP/JSON API 可能会更适合一些，毕竟其拥有更多的可扩展能力以及更丰富的生态环境，能够节省很多精力（例如 Java 就可以选择 Netflix 全家桶啊！）。</p><p>比如说一些我曾遇到的使用场景：</p><ol><li>使用分布式链路追踪时，HTTP 协议可以很简单的将 Trace 信息放入 Header 头，而 Thrift 就只能通过去魔改序列化协议达到这个功能。鉴权之类的需要统一带额外信息的场景也是如此。</li><li>错误情况的返回结果 HTTP 拥有 status code，而且 JSON 协议要更加方便，Thrift 最好是自定义 TException，但是这个受检异常在实际代码编写中又会很蛋疼。</li><li>日志，如果做通用的日志，目前只能在反序列化时进行记录，但是字段信息又是写死在 Struct 字节码里的，所以这时候打印的日志是没有字段名的。</li><li>对于 HTTP 请求，监控 200/4xx/5xx 的方案实在是太多了，但是对于 Thrift，监控成功返回、业务方自定义的 TException 和程序中自然抛出的 Runtime Exception 就要麻烦很多。</li></ol><p><del>所以虽然本文的内容是如何更好地使用 Apache Thrift，但是最终要表达的意思却是没遇到性能瓶颈之前，用 HTTP/JSON 可能会更好。</del></p>]]></content>
    
    <summary type="html">
    
      &lt;p&gt;在 RPC 选型中，相较于最基础的 HTTP/JSON API，基于 IDL 约束的 Thrift 在跨语言、序列化性能上占有很多优势。但是在实际使用中由于无法享受 HTTP 丰富的资源库，也带来了不少困扰，其中一个比较常见的麻烦问题就是 IDL 的共享以及协议迭代。&lt;/p&gt;
    
    </summary>
    
    
  </entry>
  
  <entry>
    <title>[Functional Programming Principles in Scala] 学习笔记（二） 高阶函数和类</title>
    <link href="http://www.scienjus.com/profun1-week2/"/>
    <id>http://www.scienjus.com/profun1-week2/</id>
    <published>2017-05-01T13:03:21.000Z</published>
    <updated>2018-01-15T13:50:52.000Z</updated>
    
    <content type="html"><![CDATA[<p>本周主要介绍了 Scala 中的高阶函数和类的相关定义，包含高阶函数和柯里化、类的构造与抽象等内容。</p><a id="more"></a><h2 id="高阶函数"><a href="#高阶函数" class="headerlink" title="高阶函数"></a>高阶函数</h2><p>函数式语言将函数作为一等公民，这意味着函数可以像其他值一样作为参数或是返回值，这种做法提高了程序的灵活性。</p><p>将其他函数作为参数或者返回值的函数被称为高阶函数（Higher Order Functions）。</p><p>一个例子，下面这个方法用于计算两个整数之间的所有整数之和：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">def</span> <span class="title">sumInts</span></span>(a: <span class="type">Int</span>, b: <span class="type">Int</span>): <span class="type">Int</span> =</span><br><span class="line">  <span class="keyword">if</span> (a &gt; b) <span class="number">0</span> <span class="keyword">else</span> a + sumInts(a + <span class="number">1</span>, b)</span><br></pre></td></tr></table></figure><p>另一个方法用于计算两个整数之间的所有整数立方的和：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">def</span> <span class="title">cube</span></span>(x: <span class="type">Int</span>): <span class="type">Int</span> = x * x * x</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">def</span> <span class="title">sumCubes</span></span>(a: <span class="type">Int</span>, b: <span class="type">Int</span>): <span class="type">Int</span> =</span><br><span class="line">  <span class="keyword">if</span> (a &gt; b) <span class="number">0</span> <span class="keyword">else</span> cube(a) + sumCubes(a + <span class="number">1</span>, b)</span><br></pre></td></tr></table></figure><p>还有一个方法用于计算两个整数之间所有整数阶乘的和：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">def</span> <span class="title">fact</span></span>(x: <span class="type">Int</span>): <span class="type">Int</span> = <span class="keyword">if</span> (x == <span class="number">0</span>) <span class="number">1</span> <span class="keyword">else</span> x * fact(x - <span class="number">1</span>)</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">def</span> <span class="title">sumFactorials</span></span>(a: <span class="type">Int</span>, b: <span class="type">Int</span>): <span class="type">Int</span> =</span><br><span class="line">  <span class="keyword">if</span> (a &gt; b) <span class="number">0</span> <span class="keyword">else</span> fact(a) + sumFactorials(a + <span class="number">1</span>, b)</span><br></pre></td></tr></table></figure><p>可以看出，这三个方法的大部分模式都是相同的，它们都是通过递归获得 a 到 b 的所有整数，通过某个方法进行转换，最后将转换得到的值进行累加。</p><p>那么就可以将这个转换的函数提取成为方法参数：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">def</span> <span class="title">sum</span></span>(f: <span class="type">Int</span> =&gt; <span class="type">Int</span>, a: <span class="type">Int</span>, b: <span class="type">Int</span>): <span class="type">Int</span> =</span><br><span class="line">  <span class="keyword">if</span> (a &gt; b) <span class="number">0</span></span><br><span class="line">  <span class="keyword">else</span> f(a) + sum(f, a + <span class="number">1</span>, b)</span><br></pre></td></tr></table></figure><p>使用时在不同的实现中传入不同的函数即可：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">def</span> <span class="title">id</span></span>(x: <span class="type">Int</span>): <span class="type">Int</span> = x</span><br><span class="line"><span class="function"><span class="keyword">def</span> <span class="title">sumInts</span></span>(a: <span class="type">Int</span>, b: <span class="type">Int</span>) = sum(id, a, b)</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">def</span> <span class="title">cube</span></span>(x: <span class="type">Int</span>): <span class="type">Int</span> = x * x * x</span><br><span class="line"><span class="function"><span class="keyword">def</span> <span class="title">sumCubes</span></span>(a: <span class="type">Int</span>, b: <span class="type">Int</span>) = sum(cube, a, b)</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">def</span> <span class="title">fact</span></span>(x: <span class="type">Int</span>): <span class="type">Int</span> = <span class="keyword">if</span> (x == <span class="number">0</span>) <span class="number">1</span> <span class="keyword">else</span> x * fact(x - <span class="number">1</span>)</span><br><span class="line"><span class="function"><span class="keyword">def</span> <span class="title">sumFactorials</span></span>(a: <span class="type">Int</span>, b: <span class="type">Int</span>) = sum(fact, a, b)</span><br></pre></td></tr></table></figure><p>在 Scala 中， <code>A =&gt; B</code> 代表一个接受一个 <code>A</code> 类型参数，并返回一个 <code>B</code> 类型参数的方法。例如上文中的 <code>Int =&gt; Int</code> 代表将一个整数转换为另一个整数的方法。</p><h2 id="匿名函数"><a href="#匿名函数" class="headerlink" title="匿名函数"></a>匿名函数</h2><p>在使用高阶函数时，不可避免的需要定义很多小函数，但是其实很多时候不需要通过 <code>def</code> 定义函数并为其起一个名字。</p><p>以字符串举例，当需要打印一个常量字符串时，以下的代码是多余的：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">def</span> <span class="title">str</span> </span>= <span class="string">"ABC"</span></span><br><span class="line">println(str)</span><br></pre></td></tr></table></figure><p>它可以直接写为：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">println(<span class="string">"ABC"</span>)</span><br></pre></td></tr></table></figure><p>就像字符串一样，函数也可以作为一个常量存在，它们被称为匿名函数（Anonymous Functions）。</p><p>上文中 <code>cube</code> 的匿名函数形式为：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">(x: <span class="type">Int</span>) =&gt; x * x * x</span><br></pre></td></tr></table></figure><p>其中 <code>(x: Int)</code> 是该函数的参数，<code>x * x * x</code> 是该函数的函数体。</p><p>如果函数有多个参数，那么彼此之间需要用 <code>,</code> 分隔，例如：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">(x: <span class="type">Int</span>, y: <span class="type">Int</span>) =&gt; x + y</span><br></pre></td></tr></table></figure><p>如果函数的类型可以通过上下文推断得出，那么是可以省略的。</p><p>一个匿名函数 </p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">(x1 : T1, ..., xn : Tn) =&gt; E</span><br><span class="line">``` </span><br><span class="line"></span><br><span class="line">可以被定义为一个形如</span><br></pre></td></tr></table></figure><p>{ def f(x1 : T1, …, xn : Tn) = E; f }<br><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">的表达式，其中 `f` 是一个任意的未被占用的名称。所以可以把匿名函数当做一个语法糖（Syntactic Sugar）。</span><br><span class="line"></span><br><span class="line">通过匿名函数，上文中的方法又可以进一步简化：</span><br><span class="line"></span><br><span class="line">```scala</span><br><span class="line">def sumInts(a: Int, b: Int) = sum(x =&gt; x, a, b)</span><br><span class="line"></span><br><span class="line">def sumCubes(a: Int, b: Int) = sum(x =&gt; x * x * x, a, b)</span><br></pre></td></tr></table></figure></p><h2 id="柯里化"><a href="#柯里化" class="headerlink" title="柯里化"></a>柯里化</h2><p>再次观察上面的函数，它们是否还有进一步优化的空间？</p><p>在上面的函数实现中，参数 <code>a</code> 和 <code>b</code> 都没有经过任何处理，而是直接传到了 <code>sum</code> 函数中，是否有更好的写法隐藏这些参数呢？</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">def</span> <span class="title">sum</span></span>(f: <span class="type">Int</span> =&gt; <span class="type">Int</span>): (<span class="type">Int</span>, <span class="type">Int</span>) =&gt; <span class="type">Int</span> = &#123;</span><br><span class="line">  <span class="function"><span class="keyword">def</span> <span class="title">sumF</span></span>(a: <span class="type">Int</span>, b: <span class="type">Int</span>): <span class="type">Int</span> =</span><br><span class="line">    <span class="keyword">if</span> (a &gt; b) <span class="number">0</span></span><br><span class="line">    <span class="keyword">else</span> f(a) + sumF(a + <span class="number">1</span>, b)</span><br><span class="line">  sumF</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这个重写的函数不再接受两个 Int 类型的参数，而是直接将另一个函数作为了返回值，这个返回的函数才接受两个 Int 类型参数，并返回最终的结果。</p><p>上文中的函数定义将会变得更加简单：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">def</span> <span class="title">sumInts</span> </span>= sum(x =&gt; x)</span><br><span class="line"><span class="function"><span class="keyword">def</span> <span class="title">sumCubes</span> </span>= sum(x =&gt; x * x * x)</span><br><span class="line"><span class="function"><span class="keyword">def</span> <span class="title">sumFactorials</span> </span>= sum(fact)</span><br></pre></td></tr></table></figure><p>甚至可以避免定义这些中间变量，直接通过原始方法调用：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">sum(cube)(<span class="number">1</span>, <span class="number">10</span>)</span><br></pre></td></tr></table></figure><p>在这当中 <code>sum(cube)</code> 返回了一个计算阶乘之和的方法，它和 <code>sumCubes</code> 是完全一样的，并且可以直接通过紧接着的 <code>(1, 10)</code> 参数调用这个方法。</p><p>在函数中返回另一个函数是非常有用的，为此 Scala 有一种特殊的语法：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">def</span> <span class="title">sum</span></span>(f: <span class="type">Int</span> =&gt; <span class="type">Int</span>)(a: <span class="type">Int</span>, b: <span class="type">Int</span>): <span class="type">Int</span> =</span><br><span class="line">  <span class="keyword">if</span> (a &gt; b) <span class="number">0</span> </span><br><span class="line">  <span class="keyword">else</span> f(a) + sum(f)(a + <span class="number">1</span>, b)</span><br></pre></td></tr></table></figure><p>这段方法和上面返回 <code>sumF</code> 的实现几乎是一样的，但是写起来更简洁。</p><p>如果定义了一个含有多个参数列表的方法：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">def</span> <span class="title">f</span></span>(args1)...(argsn) = <span class="type">E</span></span><br></pre></td></tr></table></figure><p>它实际等同于：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">def</span> <span class="title">f</span></span>(args1)...(argsn−<span class="number">1</span>) = &#123; <span class="function"><span class="keyword">def</span> <span class="title">g</span></span>(argsn) = <span class="type">E</span>; g &#125;</span><br></pre></td></tr></table></figure><p>或是像匿名函数一样：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">def</span> <span class="title">f</span></span>(args1)...(argsn−<span class="number">1</span>) =  (argsn ⇒ <span class="type">E</span>)</span><br></pre></td></tr></table></figure><p>往复替换 n 次之后，就会变为：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">def</span> <span class="title">f</span> </span>= (args1 ⇒ (args2 ⇒ ...(argsn ⇒ <span class="type">E</span>)...)</span><br></pre></td></tr></table></figure><p>这种风格被称为柯里化（Currying）。</p><p>最终定义的 <code>sum</code> 方法的类型为：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">(<span class="type">Int</span> =&gt; <span class="type">Int</span>) =&gt; (<span class="type">Int</span>, <span class="type">Int</span>) =&gt; <span class="type">Int</span></span><br><span class="line">``` </span><br><span class="line"></span><br><span class="line">因为函数类型的关联是从右向左的，所以实际等同于：</span><br><span class="line"></span><br><span class="line">```scala</span><br><span class="line">(<span class="type">Int</span> =&gt; <span class="type">Int</span>) =&gt; ((<span class="type">Int</span>, <span class="type">Int</span>) =&gt; <span class="type">Int</span>)</span><br></pre></td></tr></table></figure><h2 id="Scala-语法汇总"><a href="#Scala-语法汇总" class="headerlink" title="Scala 语法汇总"></a>Scala 语法汇总</h2><p>以下定义中所用到的符号的含义为：</p><ul><li><code>|</code> ：替代关系</li><li><code>[...]</code> 0 或 1 个</li><li><code>{...}</code> 0 或 多个</li></ul><h3 id="类型（Types）"><a href="#类型（Types）" class="headerlink" title="类型（Types）"></a>类型（Types）</h3><p><img src="/uploads/types.png" alt="Types"></p><p>类型可以是：</p><ul><li>数字：Int、Double（Byte、Short、Char、Long、Float）</li><li>布尔：true 或 false</li><li>字符串</li><li>函数：像是 <code>Int =&gt; Int</code> 或者 <code>(Int, Int) =&gt; Int</code></li></ul><h3 id="表达式（Expressions）"><a href="#表达式（Expressions）" class="headerlink" title="表达式（Expressions）"></a>表达式（Expressions）</h3><p><img src="/uploads/expressions.png" alt="Expressions"></p><p>表达式可以是：</p><ul><li>标识符：例如 <code>x</code> 或是 <code>isGoodEnough</code></li><li>常量：例如 <code>0</code>、<code>1.0</code> 或是 <code>&quot;abc&quot;</code></li><li>执行函数：例如 <code>sqrt(x)</code></li><li>执行运算符：例如 <code>-x</code> 、<code>x + y</code></li><li>选择表达式：例如 <code>math.abs</code> （这里不太懂 <code>selection</code>是指的什么，该方法的内部实现是用的选择表达式？）</li><li>条件表达式：例如 <code>if (x &lt; 0) -x else x</code></li><li>代码块：例如 <code>{ val x = math.abs(y) ; x * 2 }</code></li><li>匿名函数：例如 <code>x =&gt; x + 1</code></li></ul><h3 id="定义（Definitions）"><a href="#定义（Definitions）" class="headerlink" title="定义（Definitions）"></a>定义（Definitions）</h3><p><img src="/uploads/definitions.png" alt="Definitions"></p><p>定义可以是：</p><ul><li>方法定义：例如 <code>def square(x: Int) = x</code></li><li>值定义：例如 <code>val y = square(2)</code></li></ul><p>其中参数可以是：</p><ul><li>值调用：例如 <code>(x: Int)</code></li><li>名称调用：例如 <code>(y: =&gt; Double)</code></li></ul><h2 id="函数和数据"><a href="#函数和数据" class="headerlink" title="函数和数据"></a>函数和数据</h2><p>本节通过一个例子介绍如何在 Scala 中使用函数创建和封装结构体。</p><p>一个分数由一个整数分子和另一个整数分母组成。如果需要计算两个分数的和，就需要定义两个如下的方法：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">def</span> <span class="title">addRationalNumerator</span></span>(n1: <span class="type">Int</span>, d1: <span class="type">Int</span>, n2: <span class="type">Int</span>, d2: <span class="type">Int</span>): <span class="type">Int</span></span><br><span class="line"><span class="function"><span class="keyword">def</span> <span class="title">addRationalDenominator</span></span>(n1: <span class="type">Int</span>, d1: <span class="type">Int</span>, n2: <span class="type">Int</span>, d2: <span class="type">Int</span>): <span class="type">Int</span></span><br></pre></td></tr></table></figure><p>但是这样做明显增加了代码的维护成本，一种更好的方式是将分子和分母共同维护在一个结构体中。</p><h3 id="类"><a href="#类" class="headerlink" title="类"></a>类</h3><p>在 Scala 中，可以用下面这种方式定义一个类（Classes）：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="class"><span class="keyword">class</span> <span class="title">Rational</span>(<span class="params">x: <span class="type">Int</span>, y: <span class="type">Int</span></span>) </span>&#123;</span><br><span class="line">  <span class="function"><span class="keyword">def</span> <span class="title">numer</span> </span>= x</span><br><span class="line">  <span class="function"><span class="keyword">def</span> <span class="title">denom</span> </span>= y</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这段定义包含了两部分：</p><ol><li>一个新的类型（Type）：Rational</li><li>一个可以用于创建 Rational 实例的构造方法（Constructor）</li></ol><p>Scala 会保证定义的名称和值在不同的命名空间（Namespace）中，所以多个 Rational 定义彼此之间不会冲突（？）</p><h3 id="对象"><a href="#对象" class="headerlink" title="对象"></a>对象</h3><p>每个类型的元素被称为对象（Objects），通过 <code>new</code> 加上构造方法可以创建一个新的对象：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">new</span> <span class="type">Rational</span>(<span class="number">1</span>, <span class="number">2</span>)</span><br></pre></td></tr></table></figure><p>每个 Rational 对象都有两个成员变量（Members）：<code>numer</code> 和 <code>denom</code>。通过 <code>.</code> 操作符可以获取对象的成员变量：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">val</span> x = <span class="keyword">new</span> <span class="type">Rational</span>(<span class="number">1</span>, <span class="number">2</span>) &gt; x: <span class="type">Rational</span> = <span class="type">Rational</span>@<span class="number">2</span>abe0e27</span><br><span class="line">x.numer                    &gt; <span class="number">1</span></span><br><span class="line">x.denom                    &gt; <span class="number">2</span></span><br></pre></td></tr></table></figure><h3 id="方法"><a href="#方法" class="headerlink" title="方法"></a>方法</h3><p>在拥有 Rational 对象之后，就可以对其定义一些计算函数了：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">def</span> <span class="title">addRational</span></span>(r: <span class="type">Rational</span>, s: <span class="type">Rational</span>): <span class="type">Rational</span> =</span><br><span class="line">  <span class="keyword">new</span> <span class="type">Rational</span>(r.numer * s.denom + s.numer * r.denom, r.denom * s.denom)</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">def</span> <span class="title">makeString</span></span>(r: <span class="type">Rational</span>) =</span><br><span class="line">  r.numer + ”/” + r.denom</span><br><span class="line">  </span><br><span class="line">makeString(addRational(<span class="keyword">new</span> <span class="type">Rational</span>(<span class="number">1</span>, <span class="number">2</span>), <span class="keyword">new</span> <span class="type">Rational</span>(<span class="number">2</span>, <span class="number">3</span>))) &gt; <span class="number">7</span>/<span class="number">6</span></span><br></pre></td></tr></table></figure><p>在此之上，还可以直接将函数抽象为结构体本身，这样的函数被称为方法（Methods）。</p><p>Rational 类本身就可以有 <code>add</code> 和 <code>toString</code> 方法：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="class"><span class="keyword">class</span> <span class="title">Rational</span>(<span class="params">x: <span class="type">Int</span>, y: <span class="type">Int</span></span>) </span>&#123;</span><br><span class="line">  <span class="function"><span class="keyword">def</span> <span class="title">numer</span> </span>= x</span><br><span class="line">  <span class="function"><span class="keyword">def</span> <span class="title">denom</span> </span>= y</span><br><span class="line"></span><br><span class="line">  <span class="function"><span class="keyword">def</span> <span class="title">add</span></span>(r: <span class="type">Rational</span>) =</span><br><span class="line">    <span class="keyword">new</span> <span class="type">Rational</span>(numer * r.denom + r.numer * denom, denom * r.denom)</span><br><span class="line">  </span><br><span class="line">  <span class="keyword">override</span> <span class="function"><span class="keyword">def</span> <span class="title">toString</span> </span>= numer + ”/” + denom</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>注意：<code>toString</code> 是由 <code>java.lang.Object</code> 继承而来的方法，所以需要加上 <code>override</code> 关键词。</p><p>这样调用时就可以变为：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">val</span> x = <span class="keyword">new</span> <span class="type">Rational</span>(<span class="number">1</span>, <span class="number">3</span>)</span><br><span class="line"><span class="keyword">val</span> y = <span class="keyword">new</span> <span class="type">Rational</span>(<span class="number">5</span>, <span class="number">7</span>)</span><br><span class="line"><span class="keyword">val</span> z = <span class="keyword">new</span> <span class="type">Rational</span>(<span class="number">3</span>, <span class="number">2</span>)</span><br><span class="line">x.add(y).add(z)</span><br></pre></td></tr></table></figure><h3 id="抽象"><a href="#抽象" class="headerlink" title="抽象"></a>抽象</h3><p>在上面的例子中，可以发现通过计算而得出的分数有可能不是最简形态（例如 <code>3/6</code> 可以被约为 <code>1/2</code>）。</p><p>为此我们可以在每一个计算分数的方法中都加入化简的逻辑，但是这会使代码难以维护，很有可能在某个计算中忘记加入这部分逻辑。</p><p>一个更好的办法是直接在构造分数时就进行化简：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="class"><span class="keyword">class</span> <span class="title">Rational</span>(<span class="params">x: <span class="type">Int</span>, y: <span class="type">Int</span></span>) </span>&#123;</span><br><span class="line">  <span class="keyword">private</span> <span class="function"><span class="keyword">def</span> <span class="title">gcd</span></span>(a: <span class="type">Int</span>, b: <span class="type">Int</span>): <span class="type">Int</span> = <span class="keyword">if</span> (b == <span class="number">0</span>) a <span class="keyword">else</span> gcd(b, a % b)</span><br><span class="line">  <span class="keyword">private</span> <span class="keyword">val</span> g = gcd(x, y)</span><br><span class="line"></span><br><span class="line">  <span class="function"><span class="keyword">def</span> <span class="title">numer</span> </span>= x / g</span><br><span class="line">  <span class="function"><span class="keyword">def</span> <span class="title">denom</span> </span>= y / g</span><br><span class="line"></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>上面方法中的 <code>gcd</code> 和 <code>g</code> 都是私有成员，它们只能在该对象内部被访问到。</p><p>另一种方式是将 <code>numer</code> 和 <code>denom</code> 都声明为 <code>val</code>，然后直接用 <code>gcd</code> 方法去计算，这样可以保证 <code>numer</code> 和 <code>denom</code> 只会初始化一次：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="class"><span class="keyword">class</span> <span class="title">Rational</span>(<span class="params">x: <span class="type">Int</span>, y: <span class="type">Int</span></span>) </span>&#123;</span><br><span class="line">  <span class="keyword">private</span> <span class="function"><span class="keyword">def</span> <span class="title">gcd</span></span>(a: <span class="type">Int</span>, b: <span class="type">Int</span>): <span class="type">Int</span> = <span class="keyword">if</span> (b == <span class="number">0</span>) a <span class="keyword">else</span> gcd(b, a % b)</span><br><span class="line"></span><br><span class="line">  <span class="keyword">val</span> numer = x / gcd(x, y)</span><br><span class="line">  <span class="keyword">val</span> denom = y / gcd(x, y)</span><br><span class="line"></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>上面两种方式对调用方都是无感知的，但是可以通过具体的情况选择不同的实现方案，这种方式称之为抽象（Abstraction）。</p><p>抽象是软件工程中的基石。</p><h3 id="自引用"><a href="#自引用" class="headerlink" title="自引用"></a>自引用</h3><p>在类的内部可以使用 <code>this</code> 关键词指代当前执行方法的对象，也就是自引用（Self Reference）。</p><p>例如为 Rational 添加 <code>less</code> 和 <code>max</code> 方法：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="class"><span class="keyword">class</span> <span class="title">Rational</span>(<span class="params">x: <span class="type">Int</span>, y: <span class="type">Int</span></span>) </span>&#123;  ...  <span class="function"><span class="keyword">def</span> <span class="title">less</span></span>(that: <span class="type">Rational</span>) =    <span class="keyword">this</span>.numer * that.denom &lt; that.numer * <span class="keyword">this</span>.denom</span><br><span class="line"></span><br><span class="line">  <span class="function"><span class="keyword">def</span> <span class="title">max</span></span>(that: <span class="type">Rational</span>) =    <span class="keyword">if</span> (<span class="keyword">this</span>.less(that)) that <span class="keyword">else</span> <span class="keyword">this</span>&#125;</span><br></pre></td></tr></table></figure><h3 id="前提检验"><a href="#前提检验" class="headerlink" title="前提检验"></a>前提检验</h3><p>假设 Rational 类要求分母必须是一个正整数，就可以通过 <code>require</code> 方法进行校验：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="class"><span class="keyword">class</span> <span class="title">Rational</span>(<span class="params">x: <span class="type">Int</span>, y: <span class="type">Int</span></span>) </span>&#123;  require(y &gt; <span class="number">0</span>, ”denominator must be positive”)  ...&#125;</span><br></pre></td></tr></table></figure><p><code>require</code> 是一个预定义方法，它需要一个条件以及可选的提示信息。当条件为假时，将会抛出一个携带提示信息的 <code>IllegalArgumentException</code> 异常。</p><h3 id="断言"><a href="#断言" class="headerlink" title="断言"></a>断言</h3><p>另一种校验的方式是使用断言（Assert），它同样接受一个条件和可选的提示信息，而当条件不满足时，它会抛出 <code>AssertionError</code> 异常。</p><p>两个异常的不同代表着这两种方式分别适合用于不同的场景：</p><ul><li><code>require</code> 适合在方法执行前校验外部传入的参数</li><li><code>assert</code> 用于校验方法执行过程中的逻辑</li></ul><h3 id="构造函数"><a href="#构造函数" class="headerlink" title="构造函数"></a>构造函数</h3><p>在 Scala 中，类定义就会隐式的引入一个构造函数，它被称为主构造函数（Primary Constructor）。</p><p>构造函数的主要用途是：</p><ul><li>接收传入的参数</li><li>执行类体中的所有语句</li></ul><p>除了主构造函数以外，还可以定义辅助构造函数，例如：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="class"><span class="keyword">class</span> <span class="title">Rational</span>(<span class="params">x: <span class="type">Int</span>, y: <span class="type">Int</span></span>) </span>&#123;  <span class="function"><span class="keyword">def</span> <span class="title">this</span></span>(x: <span class="type">Int</span>) = <span class="keyword">this</span>(x, <span class="number">1</span>)  ...&#125;<span class="keyword">new</span> <span class="type">Rational</span>(<span class="number">2</span>) &gt; <span class="number">2</span>/<span class="number">1</span></span><br></pre></td></tr></table></figure><h2 id="类中的代换模型"><a href="#类中的代换模型" class="headerlink" title="类中的代换模型"></a>类中的代换模型</h2><p>在之前的笔记中有提到 Scala 的函数执行是通过一种称为代换模型的计算模型，在类和对象中也是如此。</p><p>当构建一个类实例 <code>new C(e1, ..., em)</code> 时，它的表达式参数依旧会像普通函数一样会被返回值所替代，成为 <code>new C(v1, ..., vm)</code>。</p><p>假设有一个包含方法的类定义：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="class"><span class="keyword">class</span> <span class="title">C</span>(<span class="params">x1, ..., xm</span>)</span>&#123; ... <span class="function"><span class="keyword">def</span> <span class="title">f</span></span>(y1, ..., yn) = b ... &#125;</span><br></pre></td></tr></table></figure><p>它拥有类的形参 <code>x1, ..., xn</code> 和类实例方法的形参 <code>y1, ..., yn</code>，那么当执行 <code>new C(v1, ..., vm).f(w1, ..., wn)</code> 时，这整个表达式会被重写为：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">[w1/y1, ..., wn/yn][v1/x1, ..., vm/xm][<span class="keyword">new</span> <span class="type">C</span>(v1, ..., vm)/<span class="keyword">this</span>] b</span><br></pre></td></tr></table></figure><p>这里有三处地方被代换了：</p><ul><li><code>w1, ..., wn</code> 被代换为了方法 <code>f</code> 的形参 <code>y1, ..., yn</code></li><li><code>v1, ..., vn</code> 被代换为了类 <code>C</code> 的形参 <code>x1, ..., xm</code></li><li>表达式 <code>new C(v1, ..., vn)</code> 被代换为了自引用 <code>this</code></li></ul><p>以 Rational 作为一个具体的例子，当调用以下方法时：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">new</span> <span class="type">Rational</span>(<span class="number">1</span>, <span class="number">2</span>).less(<span class="keyword">new</span> <span class="type">Rational</span>(<span class="number">2</span>, <span class="number">3</span>))</span><br></pre></td></tr></table></figure><p>首先会进行代换：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">[<span class="number">1</span>/x, <span class="number">2</span>/y] [newRational(<span class="number">2</span>, <span class="number">3</span>)/that] [<span class="keyword">new</span> <span class="type">Rational</span>(<span class="number">1</span>, <span class="number">2</span>)/<span class="keyword">this</span>]</span><br></pre></td></tr></table></figure><p>于是该方法的实现：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">this</span>.numer * that.denom &lt; that.numer * <span class="keyword">this</span>.denom</span><br></pre></td></tr></table></figure><p>就会被替换为：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">new</span> <span class="type">Rational</span>(<span class="number">1</span>, <span class="number">2</span>).numer * <span class="keyword">new</span> <span class="type">Rational</span>(<span class="number">2</span>, <span class="number">3</span>).denom &lt; <span class="keyword">new</span> <span class="type">Rational</span>(<span class="number">2</span>, <span class="number">3</span>).numer * <span class="keyword">new</span> <span class="type">Rational</span>(<span class="number">1</span>, <span class="number">2</span>).denom</span><br></pre></td></tr></table></figure><p>最后可以轻松的计算出结果：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="number">1</span> * <span class="number">3</span> &lt; <span class="number">2</span> * <span class="number">2</span></span><br><span class="line"><span class="literal">true</span></span><br></pre></td></tr></table></figure><h2 id="运算符"><a href="#运算符" class="headerlink" title="运算符"></a>运算符</h2><p>原则上来说，通过 Rational 定义的分数和整数没有什么区别，但是在使用时却有一些差异。</p><p>当我们想要计算两个整数的和时，只需要调用 <code>x + y</code>，而当需要计算两个 Rational 的和时，却需要调用 <code>r.add(s)</code>。</p><p>在 Scala 中，可以通过两步消除这种差异。</p><h3 id="中缀运算"><a href="#中缀运算" class="headerlink" title="中缀运算"></a>中缀运算</h3><p>任何只有一个参数的方法都可以使用中缀运算符（Infix Operator）的方式进行调用：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">r add s  = r.add(s)</span><br><span class="line">r less s = r.less(s)</span><br><span class="line">r max s  = r.max(s)</span><br></pre></td></tr></table></figure><h3 id="标识符"><a href="#标识符" class="headerlink" title="标识符"></a>标识符</h3><p>在 Scala 中标识符可以有两种形态：</p><ul><li>字母数字（Alphanumeric）：以字母为起始字符，字母和数字组成的序列</li><li>符号（Symbolic）：以一个运算符为起始字符，后面可以跟着其他的运算符</li><li>下划线（<code>_</code>）也算是字母的一种</li><li>字母数字的标识符可以以下划线结尾，之后跟着一些运算符，例如 <code>vector_++</code></li></ul><p>所以 Rational 类中的部分方法可以通过运算符进行替换：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="class"><span class="keyword">class</span> <span class="title">Rational</span>(<span class="params">x: <span class="type">Int</span>, y: <span class="type">Int</span></span>) </span>&#123;</span><br><span class="line">  <span class="keyword">private</span> <span class="function"><span class="keyword">def</span> <span class="title">gcd</span></span>(a: <span class="type">Int</span>, b: <span class="type">Int</span>): <span class="type">Int</span> = <span class="keyword">if</span> (b == <span class="number">0</span>) a <span class="keyword">else</span> gcd(b, a % b)</span><br><span class="line">  <span class="keyword">private</span> <span class="keyword">val</span> g = gcd(x, y)</span><br><span class="line">  </span><br><span class="line">  <span class="function"><span class="keyword">def</span> <span class="title">numer</span> </span>= x / g</span><br><span class="line">  <span class="function"><span class="keyword">def</span> <span class="title">denom</span> </span>= y / g</span><br><span class="line">  </span><br><span class="line">  <span class="function"><span class="keyword">def</span> <span class="title">+</span> </span>(r: <span class="type">Rational</span>) =</span><br><span class="line">    <span class="keyword">new</span> <span class="type">Rational</span>(numer * r.denom + r.numer * denom, denom * r.denom)</span><br><span class="line">  </span><br><span class="line">  <span class="function"><span class="keyword">def</span> <span class="title">-</span> </span>(r: <span class="type">Rational</span>) = ...</span><br><span class="line">  </span><br><span class="line">  <span class="function"><span class="keyword">def</span> <span class="title">*</span> </span>(r: <span class="type">Rational</span>) = ...</span><br><span class="line">  ...</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这样使用时就可以像 Int 或是 Double 一样了：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">val x = new Rational(1, 2)</span><br><span class="line">val y = new Rational(1, 3)</span><br><span class="line"></span><br><span class="line">(x * x) + (y * y)</span><br></pre></td></tr></table></figure><p>运算符的优先级由其第一个字符决定，下图为优先级有低至高的运算符。</p><p><img src="/uploads/precedence.png" alt="precedence"></p>]]></content>
    
    <summary type="html">
    
      &lt;p&gt;本周主要介绍了 Scala 中的高阶函数和类的相关定义，包含高阶函数和柯里化、类的构造与抽象等内容。&lt;/p&gt;
    
    </summary>
    
    
  </entry>
  
  <entry>
    <title>博客迁移到 Github Pages 了</title>
    <link href="http://www.scienjus.com/moved-blog-to-github-pages/"/>
    <id>http://www.scienjus.com/moved-blog-to-github-pages/</id>
    <published>2017-04-23T08:11:51.000Z</published>
    <updated>2018-01-15T13:50:52.000Z</updated>
    
    <content type="html"><![CDATA[<p>还有两个月专门放博客的主机就要到期了，仔细一想现在也懒得折腾 WordPress 了，干脆最后折腾一把弄成静态博客的丢到 Github Pages 吧！</p><a id="more"></a><h2 id="选型"><a href="#选型" class="headerlink" title="选型"></a>选型</h2><p>这几年静态博客相关的方案已经有很多了，但是基本上都是基于 Markdown 去写文章，然后生成为 HTML 直接发布。</p><p>本来作为一个 Ruby 爱好者，再加上有 Github Pages 官方支持的加成，自然应该会去选择 Jekyll。但是无奈找了半天都找不到一款合适的主题，又懒得自己折腾，就选择了烂大街的 Hexo 和 <a href="https://github.com/iissnan/hexo-theme-next" target="_blank" rel="external">NexT</a>。这个主题功能很全面，配置起来很简单，非常适合我这种懒人。</p><p>关于如何安装 Hexo 以及相关配置就不在这里详述了，感兴趣可以直接去 <a href="https://hexo.io/zh-cn/docs/" target="_blank" rel="external">Hexo</a> 官网查看。</p><h2 id="文章迁移"><a href="#文章迁移" class="headerlink" title="文章迁移"></a>文章迁移</h2><p>虽然我很早开始就通过 Markdown 写博客了，但是最开始在 WordPress 上找不到一个合适的 Markdown 渲染插件，一时脑残就选择了自己在本地渲染成 HTML 然后再通过 WordPress 发布，导致迁移的时候造成了不必要的麻烦。</p><p>我的文章迁移的流程为：</p><ol><li>通过 WordPress 的后台将所有博客的导出成 XML 文件</li><li>将文章内容的 HTML 转换为 Markdown</li><li>修改一些边角的转移字符</li><li>按照文章链接输出到对应的文件</li></ol><p>我通过一个 Ruby 脚本完成这一切，使用 <code>oga</code> 解析 XML，<code>reverse_markdown</code> 将 HTML 转为 Markdown，<code>auto-correct</code> 优化排版，整个脚本的代码大概如下（因为只是用一次所以写的比较随意）：</p><figure class="highlight ruby"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">require</span> <span class="string">'oga'</span></span><br><span class="line"><span class="keyword">require</span> <span class="string">'reverse_markdown'</span></span><br><span class="line"><span class="keyword">require</span> <span class="string">'time'</span></span><br><span class="line"><span class="keyword">require</span> <span class="string">'auto-correct'</span></span><br><span class="line"></span><br><span class="line">template = <span class="string">&lt;&lt;-TEMPLATE</span></span><br><span class="line"><span class="string">---</span></span><br><span class="line"><span class="string">title: '%s'</span></span><br><span class="line"><span class="string">date: %s</span></span><br><span class="line"><span class="string">permalink: %s</span></span><br><span class="line"><span class="string">---</span></span><br><span class="line"><span class="string"></span></span><br><span class="line"><span class="string">%s</span></span><br><span class="line"><span class="string">TEMPLATE</span></span><br><span class="line"></span><br><span class="line">handle = File.open(<span class="string">'/path/to/wordpress.xml'</span>)</span><br><span class="line"></span><br><span class="line">doc = Oga.parse_xml(handle)</span><br><span class="line"></span><br><span class="line">doc.xpath(<span class="string">'channel/item'</span>).each <span class="keyword">do</span> <span class="params">|item|</span></span><br><span class="line">  title = item.at_xpath(<span class="string">'title'</span>).text</span><br><span class="line">  time = item.at_xpath(<span class="string">'pubDate'</span>).text</span><br><span class="line">  link = item.at_xpath(<span class="string">'link'</span>).text</span><br><span class="line">  content = item.at_xpath(<span class="string">'content:encoded'</span>).text</span><br><span class="line">  <span class="comment"># html 2 markdown</span></span><br><span class="line">  content = ReverseMarkdown.convert(content, <span class="symbol">github_flavored:</span> <span class="literal">true</span>).inspect</span><br><span class="line">  <span class="comment"># auto correct</span></span><br><span class="line">  content = content.auto_correct!</span><br><span class="line"></span><br><span class="line">  File.open(<span class="string">"/path/to/blog/<span class="subst">#&#123;link&#125;</span>.md"</span>, <span class="string">'w'</span>).write(template % [title, time, link, content])</span><br><span class="line"><span class="keyword">end</span></span><br></pre></td></tr></table></figure><p>需要注意的是在将 HTML 转成 Markdown 后可能会出现一些边边角角的转义字符，上面的程序并没有列出这部分逻辑，需要自己观察博客并修改。</p><h2 id="图片迁移"><a href="#图片迁移" class="headerlink" title="图片迁移"></a>图片迁移</h2><p>之前的图片是直接上传到 WordPress 资源库，用七牛云做 CDN，如果当初直接把图片丢到七牛也就没这破事了，但是我这次依旧没有把图片丢到七牛（主要就是懒，应该也不会再迁移第三回了）。</p><p>个人比较建议将所有图片都放在 <code>source</code> 下，也就是和 <code>_posts</code> 平级，这样当使用 MWeb 这样对其有支持的编辑器时，可以比较好的提供本地预览的支持</p><p><img src="/uploads/mweb.png" alt="mweb"></p><p>Hexo 官方推荐的另外一种做法是设置 <code>post_asset_folder: true</code> 然后将每个文章使用的资源都放在与文章同名的文件夹下。但是由于我需要将以前的图片迁移过来，这样做只会增加额外的迁移工作量。</p><p>而且这种做法据说还会带来图片在首页或归档页无法正常显示的问题，以至于兼容这个问题还需要在 Markdown 里人为的添加 Hexo 独有的标签，这太不清真了，果断拒绝。</p><h2 id="评论迁移"><a href="#评论迁移" class="headerlink" title="评论迁移"></a>评论迁移</h2><p>在最开始就用了 Disqus，之后依旧会使用它，所以整个迁移过程很简单。</p><p>Disqus 是通过 URL 识别每篇文章的，官方也提供了非常丰富的迁移工具，如果只是域名发生了更改可以直接使用 <a href="https://help.disqus.com/customer/portal/articles/912627-domain-migration-wizard" target="_blank" rel="external">Domain Migration Tool</a>，如果文章的地址也发生了改变就需要使用 <a href="https://help.disqus.com/customer/portal/articles/912757-url-mapper" target="_blank" rel="external">URL Mapper</a> 了。</p><p>在博客完全替换之前，为了预览效果我给新博客分配了一个二级域名，而当我在博客迁移完、将主域名转到新博客后，却发现评论没有如预期的那样同步过来。</p><p>此时我在 Disqus 上正常评论是可以显示的，看后台导出的记录发现新评论依旧是挂在二级域名下，最后将所有评论都从主域名转到二级域名下，评论终于能够在新博客上默认显示了。</p><p>所以在此也建议在博客完全迁移完之前，不要进行 Disqus 相关的配置。</p><h2 id="版本管理"><a href="#版本管理" class="headerlink" title="版本管理"></a>版本管理</h2><p>在文章的最初曾经提到 Github Pages 默认支持的引擎是 Jekyll，这意味着如果你使用 Jekyll 写博客，只需要将作为源文件的 Markdown 发布到 Github，就能自动跑一套 CI 生成静态页面，整个版本管理会简单很多。</p><p>很遗憾的是 Hexo 目前并不具备这种条件，这意味着只能先在本地生成静态页面，再将静态页面发布到 Github 上，而源文件没有任何版本控制，只有本地存储了一份。</p><p>目前我在同一仓库中又建立了一个 <code>source</code> 分支，每次写好博客后，先会通过普通 Git 操作流程将源文件发布到该分支，然后再通过 <code>hexo g -d</code> 将最新的页面发布到 <code>master</code> 分支。也有朋友建议我提交源文件后通过第三方 CI 进行发布，但是我觉得写博客作为一个低频行为（至少对于我来说是低频行为），不值得搞得这么复杂。</p><hr><p>最后，虽然还有很多东西没有弄（统计、计数、CDN），但是至少可以开始产出文章了，愿这个新平台给我带来更良好、更高效的写作体验。</p>]]></content>
    
    <summary type="html">
    
      &lt;p&gt;还有两个月专门放博客的主机就要到期了，仔细一想现在也懒得折腾 WordPress 了，干脆最后折腾一把弄成静态博客的丢到 Github Pages 吧！&lt;/p&gt;
    
    </summary>
    
    
  </entry>
  
</feed>
