<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://shitao.me/feed.xml" rel="self" type="application/atom+xml" /><link href="https://shitao.me/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-05-05T11:26:02+00:00</updated><id>https://shitao.me/feed.xml</id><title type="html">Stone &amp;amp; Wave</title><subtitle>A gem-based responsive simple texture styled Jekyll theme.</subtitle><author><name>shitao</name></author><entry><title type="html">Git 高可用探讨</title><link href="https://shitao.me/blog/2025/09/15/git/" rel="alternate" type="text/html" title="Git 高可用探讨" /><published>2025-09-15T00:00:00+00:00</published><updated>2025-09-15T00:00:00+00:00</updated><id>https://shitao.me/blog/2025/09/15/git</id><content type="html" xml:base="https://shitao.me/blog/2025/09/15/git/"><![CDATA[<p>如果你比较熟悉 KV 存储，那么在做 Git 的高可用方案设计时很容易把 KV 的方案往里套，即 oplog 中记录文件的内容和 <code class="language-plaintext highlighter-rouge">Create/Delete/Update</code> 操作，其他节点通过 replay log 来恢复 Git 仓库数据。很不幸，你会发现不同副本间的 commit 不一致 —— 即使在 oplog 中存储了 commit 的 date，这也是一个脆弱的保证。</p>

<p>oplog 关注的是 <strong>数据层面确定性的物理变更</strong>，上面这种方案类比到 KV 存储里就像把 语义 API 写到了 oplog 中，本质上是没有理解 Git 的底层存储原理，<code class="language-plaintext highlighter-rouge">.git/</code> 才是 "数据层面确定性的物理变更"。</p>

<h2 id="heading-git-的特性">Git 的特性</h2>

<p>Git 是基于内容寻址的键值系统， <code class="language-plaintext highlighter-rouge">.git/</code> 目录下是真正的数据。我们主要关注目录下的 objects 和 refs。</p>

<h3 id="heading-objects">objects</h3>
<p>对象分为以下几种类型：</p>

<ul>
  <li>commit
    <ul>
      <li>存储 commit 信息，包含 author、date、message</li>
    </ul>
  </li>
  <li>tree
    <ul>
      <li>存储某个目录下的信息</li>
    </ul>
  </li>
  <li>blob
    <ul>
      <li>存储文件内容</li>
    </ul>
  </li>
</ul>

<p>对象以 key-value 的形式管理，key 是对象内容的 hash，value 是 zlib 压缩后的对象内容，可使用 <code class="language-plaintext highlighter-rouge">git cat-file -p</code> 查看某个对象的内容。</p>

<p>对象在磁盘上有两种存储格式：</p>

<ul>
  <li>松散对象 （loose objects）</li>
  <li>打包文件（packfile）</li>
</ul>

<p>当松散对象超过 6700 或者 打包文件超过 50 个时（默认值）并开启了 <code class="language-plaintext highlighter-rouge">gc.auto</code>，执行 git 命令会触发 gc。</p>

<h3 id="heading-refs">refs</h3>
<p>refs 目录存储 git 的引用，包括 branch、tag、remote ref，以及一些自定义 namespace 的分支。</p>

<p>refs 也按照 key-value 的形式管理。key 是 ref 的名字，比如 <code class="language-plaintext highlighter-rouge">refs/heads/master</code>，value 可以是 hash 也可以指向另一个 ref（Symbolic Reference），类似文件系统中的软链。</p>

<p>refs 在磁盘上也有两种存储格式：</p>

<ul>
  <li>loose refs
    <ul>
      <li>refs/ 目录下每个文件存储一个 reference</li>
    </ul>
  </li>
  <li>packed refs
    <ul>
      <li>存储在 packed-refs 文件中，每行代表一个 reference</li>
    </ul>
  </li>
</ul>

<h3 id="heading-git-smart-http">git smart http</h3>

<p>git 使用 <code class="language-plaintext highlighter-rouge">git clone/fetch</code>同步仓库数据，实际是一套自定义的 pkt-line 有状态的传输协议，称之为 <code class="language-plaintext highlighter-rouge">git smart http</code>。</p>

<p>主要分为三个阶段：</p>

<ul>
  <li>引用发现
    <ul>
      <li>server 返回 refs 列表和 hash</li>
    </ul>
  </li>
  <li>Packfile 协商
    <ul>
      <li>server 根据 client 提供的 refs haves、wants 列表，计算出来最小的 packfile</li>
    </ul>
  </li>
  <li>数据传输
    <ul>
      <li>server 将 packfile 数据发送给 client</li>
    </ul>
  </li>
</ul>

<p>再结合上面的分析，不难发现，如果我们能保证</p>

<ul>
  <li>refs 的一致性</li>
  <li>能够引用到的对象都是存在的</li>
</ul>

<p>是可以保证 git repo 的数据一致性的。注意: objects 无需保证一致性，允许出现悬空对象，各个节点可以在 gc 时自行清理。</p>

<h2 id="heading-分布式文件系统的美好想象">分布式文件系统的美好想象</h2>

<p>Git 是基于本地文件系统的，如果能把本地文件系统替换为分布式文件系统，不就解决了高可用和存储上限的难题了吗？ 尤其是 go-git/libgit2 还提供了 Storage 的 interface 可替换 backend。我可真是个天才。</p>

<p>两盆冷水泼下来。</p>

<p>Gitee 早期尝试过 <a href="https://zhuanlan.zhihu.com/p/362855087">Ceph 的方案</a>，仓促上线后海量小文件的遍历 IO 性能惨不忍睹，后来又回退到主备架构。</p>

<p>阿里早期也做过类似的尝试，将 libgit2 的后端嫁接到 oss, 有同事在 RailsConf 2016 中做了 <a href="https://ruby-china.org/topics/30146">专题分享</a></p>

<ul>
  <li>oss 替换掉 odb，存储 commit、tree、blob 对象。refdb 存储 refs 等指针信息</li>
  <li>oss bucket 分为 loose 和 pack 两种 ，用户如果上传的 pack 太大，就调用 <code class="language-plaintext highlighter-rouge">git-index-pack</code>生成 idx，读的时候利用 http get 的 range header 先读 idx 拿到 pack 的数据范围，递归解析 base 直到 root 对象。</li>
</ul>

<p>最终也因为性能问题和越来越无法收敛的底层改动成本放弃。</p>

<p><img src="/assets/images/posts/git-distributed-fs-issue.png" alt="分布式文件系统问题" width="542" /></p>

<p>目前业界基本达成了共识，git loose object 大量小文件、随机访问的特点，恰恰是分布式文件系统的弱项。</p>

<h2 id="heading-主从同步--读写分离">主从同步 + 读写分离</h2>

<p>以 Gitee 为例， 路由分片，不同用户的仓库分布在不同的机器上。这里应该用仓库的 ID 做分片键，而不是依赖于仓库路径（可能变化）。</p>

<p>大概的流程：</p>

<ol>
  <li>写请求路由到主节点</li>
  <li>主节点完成 Git 操作（更新 objects + refs），通过 Git 钩子触发一个同步队列</li>
  <li>备节点消费任务，执行 git fetch 或自定义协议拉取数据，至少一个备机同步成功才算写成功</li>
  <li>校验 refs 的 checksum，确认一致性，管理变更同步状态，从节点可读</li>
</ol>

<p><img src="/assets/images/posts/git-master-slave-sync.png" alt="主从同步流程" width="607" /></p>

<p><img src="/assets/images/posts/git-master-slave-arch.png" alt="主从架构" width="514" /></p>

<p>主从架构简单可靠，足以满足大部分的业务场景。但也有缺点：</p>

<ul>
  <li>同步写增加了写操作的延迟</li>
  <li>如果写 QPS 非常高，备节点一直在同步数据，会回退到单节点读写的形态</li>
</ul>

<h2 id="heading-多写高可用">多写高可用</h2>

<p>阿里云的 <a href="https://developer.aliyun.com/article/786954?utm_content=m_1000289491">Codeup </a>采用了一种更激进的方案：多写高可用。</p>

<p>大概的流程：</p>

<ul>
  <li>使用仓库 ID 作为存储和分片的基本单位</li>
  <li>通过 gRPC 将写请求分发到多个副本</li>
  <li>同步写三副本，多数节点写成功即返回成功</li>
  <li>写失败或数据不一致通过 refs 的 checksum 校验发现</li>
  <li>更新路由层，避免读到问题数据</li>
</ul>

<p>这个方案的优势：任意节点都可以处理写请求，没有单点瓶颈。</p>

<p>我觉得这里是有 corner case 的。考虑如下场景：</p>

<ul>
  <li>三个并发请求 r1、r2、r3 同时写入同一个仓库</li>
  <li>三个副本节点 A、B、C</li>
  <li>由于分布式系统的网络延迟不可控，可能出现：
    <ul>
      <li>A 节点：r1 成功</li>
      <li>B 节点：r2 成功</li>
      <li>C 节点：r3 成功</li>
    </ul>
  </li>
</ul>

<p>结果是三副本的 refs 都不一致，checksum 全部对不上。这种场景下，系统无法自动判断哪个是正确的，只能人工介入处理。</p>

<p>好消息是：Git 仓库的写 QPS 通常不会很高（不像 KV 存储），内网环境网络延迟也相对可控，这种极端并发冲突的概率较低。</p>

<h2 id="heading-多副本同步">多副本同步</h2>

<blockquote>
  <p>再结合上面对 objects 和 refs 的分析，不难发现，如果我们能保证</p>

  <ul>
    <li>refs 的一致性</li>
    <li>能够引用到的对象都是存在的</li>
  </ul>

</blockquote>

<p>通过 etcd/raft 来保证各节点上 refs 的一致性，raft 同步日志时将 packfile 传输给其他节点。</p>

<p>raft 框架中需要做的操作：</p>

<ul>
  <li>Apply：保存 packfile 到本地的 git 仓库，并更新 refs</li>
  <li>SaveSnapshot：raft 只会维护 refdb，同步 refdb 到其他节点，节点再通过 git fetch 拉取 objects</li>
</ul>

<p>不再展开。</p>

<h2 id="heading-完全抛弃-git-的大库存储方案">完全抛弃 git 的大库存储方案</h2>

<p>前面的方案都是在「兼容 Git 协议」的前提下做高可用。</p>

<p>蚂蚁的 HugeSCM 是一个大胆的尝试。它保留了 Git 的用户体验（commit、branch、merge），但完全重写了底层存储。</p>

<blockquote>
  <p>HugeSCM 的核心创新在于<strong>数据分离原则</strong>，将版本控制系统的数据分为两类：</p>

  <p><strong>元数据（Metadata）</strong></p>

  <p>包括提交对象（commit）、目录对象（tree）、分片对象（fragments）和标签对象（tag）。这些对象体积较小，但访问频繁，适合存储在分布式数据库中，支持快速索引和查询。</p>

  <p><strong>文件数据（Blob）</strong></p>

  <p>文件内容数据，体积可能非常大，存储在分布式文件系统或对象存储中。Blob 采用压缩存储，支持多种压缩算法（ZSTD、Brotli、Deflate 等）。</p>

  <p>这种分离设计带来了显著优势：</p>

</blockquote>

<pre><code class="language-plain">+------------------+     +------------------+
|   元数据数据库    |     |   对象存储/OSS   |
|  (分布式数据库)   |     |  (分布式文件系统) |
+------------------+     +------------------+
        ↑                         ↑
        │                         │
   commit/tree              blob 数据
   fragments/tag            (压缩存储)
   (高频访问)               (大文件优化)
</code></pre>

<p><a href="https://github.com/antgroup/hugescm/blob/master/docs/design.md">https://github.com/antgroup/hugescm/blob/master/docs/design.md</a></p>

<p>我觉得是一种不错的尝试，尤其是现代 AI 模型训练产生的 checkpoint 文件动辄数十 GB 甚至上百 GB，一个存储库可能包含多个版本，总体积轻易突破 TB 级别。Git LFS 虽然缓解了这一问题，但引入了额外的存储开销和管理复杂度，仍属于一种「外挂」机制。</p>

<p>Git 协议既是利刃也是枷锁，这或许就是技术发展的宿命：每一次进化，都是对过往荣光的告别与超越。</p>]]></content><author><name>shitao</name></author><category term="tech" /><category term="git" /><category term="raft" /><summary type="html"><![CDATA[如果你比较熟悉 KV 存储，那么在做 Git 的高可用方案设计时很容易把 KV 的方案往里套，即 oplog 中记录文件的内容和 Create/Delete/Update 操作，其他节点通过 replay log 来恢复 Git 仓库数据。很不幸，你会发现不同副本间的 commit 不一致 —— 即使在 oplog 中存储了 commit 的 date，这也是一个脆弱的保证。]]></summary></entry><entry><title type="html">Raft 工程实践中的注意点</title><link href="https://shitao.me/blog/2023/06/09/raft/" rel="alternate" type="text/html" title="Raft 工程实践中的注意点" /><published>2023-06-09T00:00:00+00:00</published><updated>2023-06-09T00:00:00+00:00</updated><id>https://shitao.me/blog/2023/06/09/raft</id><content type="html" xml:base="https://shitao.me/blog/2023/06/09/raft/"><![CDATA[<p>Raft 工程实践中的注意点</p>

<h2 id="heading-双主问题">双主问题</h2>

<p>Raft 写不存在「脑裂」问题。</p>

<p>网络分区场景，旧 leader 收不到更高 term 的 message，依旧在提供服务，并不知道已经选举出新 leader 对外提供服务，出现了两个 StateLeader 的节点。但是 Raft 严格保证同一个 term 只会有一个 leader，不同 term 的 leader 即使共存，也无法同时获取 Quorum 节点提交冲突的数据。所以双主不会导致数据被写坏。</p>

<p>旧 leader 虽然不能写，但它仍然可以响应读请求。如果一个 client 刚在新 leader 上写入成功，转身去旧 leader 上读，却读不到刚写入的数据了，也就是发生了 <strong>Stale Read</strong>。</p>

<h2 id="heading-stale-read">Stale Read</h2>

<p>单/多 client 读 leader 节点数据，有可能出现写新 Leader 成功，读旧 leader 数据的情况。</p>

<p>业界一般有两种处理思路：</p>

<h3 id="heading-lease-read">Lease Read</h3>
<ul>
  <li>Leader 节点维护一个略小于 election timeout 的 lease，每个 heartbeat 周期向 follower 发送心跳，收到 Quorum ack 续期 lease；follower 在 election timeout 内没收到心跳才会发起选举，这期间是 Lease Read 的安全窗口。</li>
  <li>然而正确性依赖于时钟同步，如果 leader 的时钟比 follower 慢，leader 根据本机时钟判断 lease 没有过期，但是 follower 端已经过期，选举为 leader，就会破坏强一致读的保证。</li>
</ul>

<pre><code class="language-mermaid">gantt
    title Lease Read Safety Window
    dateFormat  YYYY-MM-DD
    axisFormat  T+%S s

    section Leader
    lease valid period      : crit, done, 2024-01-01, 3d
    heartbeat 1             : milestone, h1, 2024-01-01, 0d
    heartbeat 2             : milestone, h2, 2024-01-02, 0d
    heartbeat 3             : milestone, h3, 2024-01-03, 0d

    section Follower
    election timeout (reject vote) : active, 2024-01-01, 3d

    section Lease Read
    return local committed index (0 RTT) : done, 2024-01-01, 3d
</code></pre>

<h3 id="heading-readindex">ReadIndex</h3>

<ul>
  <li>leader 收到读请求，记录 committed index，发心跳确认自己是 leader，等到 apply index &gt;= committed index 返回请求</li>
  <li>follower 读也是同样的逻辑</li>
</ul>

<p>Lease Read 用时钟假设换 0 RTT，效率更高；ReadIndex 用一次网络 RTT 换绝对安全。</p>

<h2 id="heading-statemachine">StateMachine</h2>

<h3 id="heading-aba-问题">ABA 问题</h3>

<p>在 Raft 中，leader 可能在任意时刻发生切换。考虑以下场景：</p>

<ol>
  <li>业务层读取到当前节点是 leader，term = 5，准备提交一个写请求</li>
  <li>在提交之前，网络抖动导致 leader 切换：term 变成 6（新 leader），然后又切回来，term 变成 7（本节点重新当选）</li>
  <li>业务层调用 Propose(task) 时，节点确实还是 leader，但 term 已经从 5 变成了 7</li>
</ol>

<p>这是个 ABA 问题，leader 状态没变，但是 term 变化了，内存中的数据可能已经不是最新的了。</p>

<p>braft 的解决方式是在 Propose 的时候带上了 expected_term，进入 propose 队列后校验 current term 和 expected term，不一致会返回错误。</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">void</span> <span class="n">NodeImpl</span><span class="o">::</span><span class="n">apply</span><span class="p">(</span><span class="k">const</span> <span class="n">Task</span><span class="o">&amp;</span> <span class="n">task</span><span class="p">)</span> <span class="p">{</span>
  <span class="n">LogEntry</span><span class="o">*</span> <span class="n">entry</span> <span class="o">=</span> <span class="k">new</span> <span class="n">LogEntry</span><span class="p">;</span>
  <span class="n">entry</span><span class="o">-&gt;</span><span class="n">AddRef</span><span class="p">();</span>
  <span class="n">entry</span><span class="o">-&gt;</span><span class="n">data</span><span class="p">.</span><span class="n">swap</span><span class="p">(</span><span class="o">*</span><span class="n">task</span><span class="p">.</span><span class="n">data</span><span class="p">);</span>
  <span class="n">LogEntryAndClosure</span> <span class="n">m</span><span class="p">;</span>
  <span class="n">m</span><span class="p">.</span><span class="n">entry</span> <span class="o">=</span> <span class="n">entry</span><span class="p">;</span>
  <span class="n">m</span><span class="p">.</span><span class="n">done</span> <span class="o">=</span> <span class="n">task</span><span class="p">.</span><span class="n">done</span><span class="p">;</span>
  <span class="n">m</span><span class="p">.</span><span class="n">expected_term</span> <span class="o">=</span> <span class="n">task</span><span class="p">.</span><span class="n">expected_term</span><span class="p">;</span>
  <span class="k">if</span> <span class="p">(</span><span class="n">_apply_queue</span><span class="o">-&gt;</span><span class="n">execute</span><span class="p">(</span><span class="n">m</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">bthread</span><span class="o">::</span><span class="n">TASK_OPTIONS_INPLACE</span><span class="p">,</span> <span class="nb">NULL</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">task</span><span class="p">.</span><span class="n">done</span><span class="o">-&gt;</span><span class="n">status</span><span class="p">().</span><span class="n">set_error</span><span class="p">(</span><span class="n">EPERM</span><span class="p">,</span> <span class="s">"Node is down"</span><span class="p">);</span>
    <span class="n">entry</span><span class="o">-&gt;</span><span class="n">Release</span><span class="p">();</span>
    <span class="k">return</span> <span class="n">run_closure_in_bthread</span><span class="p">(</span><span class="n">task</span><span class="p">.</span><span class="n">done</span><span class="p">);</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="heading-noop-日志">noop 日志</h3>

<p>Raft 论文里的一个关键约束：leader 只能提交本 term 的日志，不能直接提交前任 term 的日志。新 leader 在 noop commit 之前，不能响应任何读请求，否则可能读到旧数据。</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// stepLeader 处理 MsgReadIndex</span>
<span class="k">if</span> <span class="o">!</span><span class="n">r</span><span class="o">.</span><span class="n">committedEntryInCurrentTerm</span><span class="p">()</span> <span class="p">{</span>
    <span class="c">// Noop 还没 commit, 先 pending, 等 Noop commit 后再处理</span>
    <span class="n">r</span><span class="o">.</span><span class="n">pendingReadIndexMessages</span> <span class="o">=</span> <span class="nb">append</span><span class="p">(</span><span class="n">r</span><span class="o">.</span><span class="n">pendingReadIndexMessages</span><span class="p">,</span> <span class="n">m</span><span class="p">)</span>
    <span class="k">return</span> <span class="no">nil</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="heading-snapshot">snapshot</h2>

<p>snapshot 和日志要配合保证整体的数据一致性；快照本身比较耗时，需要异步实现。</p>

<h2 id="heading-成员变更">成员变更</h2>

<p>「Joint Consensus」分为两个阶段是为了避免 Cold 和 Cnew 各自形成不相交的多数派。生产中推荐每次只变更一个节点，这样的操作本身就能够满足「Cold 与 Cnew 无法独自形成多数派」。</p>

<h3 id="heading-扩容">扩容</h3>

<ul>
  <li>进程启动时不传入成员列表，直接以空的状态启动。由于没有成员列表，进程无法成为 raft leader，管控服务通过成员变更先将节点以 learner 角色加入 raft 组，追齐数据后提升为 follower。这种模式甚至可以支持 (A,B,C) -&gt; (D,E,F) 的替换。</li>
</ul>

<h3 id="heading-缩容">缩容</h3>

<ul>
  <li>至少保证多数派节点健康，leader 缩容前先调用 transfer leader 减少服务不可用时间。</li>
</ul>

<h3 id="heading-单副本恢复集群服务">单副本恢复集群服务</h3>

<ul>
  <li>本质上是放弃 Consistency，换取 Availability</li>
  <li>重写成员列表，只保留单节点成员，bootstrap 集群恢复服务，流程参考 etcdctl snapshot restore、braft reset_peer，都是一样的思路</li>
</ul>]]></content><author><name>shitao</name></author><category term="tech" /><category term="distributed-systems" /><category term="raft" /><summary type="html"><![CDATA[Raft 工程实践中的注意点]]></summary></entry><entry><title type="html">正确使用分布式锁</title><link href="https://shitao.me/blog/2023/06/07/lock/" rel="alternate" type="text/html" title="正确使用分布式锁" /><published>2023-06-07T00:00:00+00:00</published><updated>2023-06-07T00:00:00+00:00</updated><id>https://shitao.me/blog/2023/06/07/lock</id><content type="html" xml:base="https://shitao.me/blog/2023/06/07/lock/"><![CDATA[<p>单机环境做资源互斥比较简单，内核提供的 mutex、信号量这些机制，本质上依赖的是同一台机器上共享内存的可见性。所有线程都能看到同一块内存，CAS 一条指令就能完成状态判断和更新，原子性由硬件保证。</p>

<p>到了分布式环境，事情变得麻烦了。不同机器之间没有共享内存，无法实现原子操作。我们需要一个第三方服务来判断谁能拿到锁，这就是分布式锁的出发点。</p>

<h2 id="heading-严格互斥性">严格互斥性</h2>

<p>分布式锁最核心的承诺是：同一时刻，只有一个 client 能持有这把锁。</p>

<p>大多数实现（比如 etcd、ZooKeeper）采用的都是 lease 机制。服务端把每把锁和一个 session 绑定，client 拿到锁之后，需要定期发送心跳来 refresh lease。心跳断了，lease 到期，服务端自动释放锁，其他 client 才有机会抢占。</p>

<p>关键点：</p>

<ul>
  <li>客户端先于服务端发现锁过期
    <ul>
      <li>client 维护一个比 lease TTL 更短的计时器，更早停止对共享资源的操作，减少网络问题造成的「临界区重叠」</li>
    </ul>
  </li>
  <li>客户端抢到锁后，等一个心跳周期再开始干活
    <ul>
      <li>给持有锁的 client 留出足够的窗口停止对共享资源的操作</li>
    </ul>
  </li>
</ul>

<p>即使做了这两点优化，分布式锁本身仍然做不到绝对互斥。</p>

<p>Martin Kleppmann 在 2016 年那篇著名的文章里画过一个时序图：</p>

<p><a href="https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html">https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html</a></p>

<ul>
  <li>client1 持有锁，GC 暂停期间 lease 过期</li>
  <li>client2 抢到锁并开始写资源</li>
  <li>GC 结束后 client1 恢复执行，也写资源，数据被破坏了。</li>
</ul>

<p><img src="/assets/images/posts/distributed-lock-gc-issue.png" alt="分布式锁 GC 问题" width="1234" /></p>

<p>要真正解决这个问题，只靠分布式锁是不行的，依赖存储层提供 <strong>fencing</strong> 能力。思路很简单：每次写操作带一个单调递增的 token，存储层只接受 token 最大的那次写入。</p>

<p>常见做法：</p>

<ul>
  <li>利用 etcd 的 revision 做 CAS 比较，写入时带上预期的 revision，不匹配就拒绝</li>
  <li>数据库 update 语句的 where 条件带上版本号</li>
  <li>分布式文件系统提供 seal file + meta CAS 的能力</li>
</ul>

<p>分布式锁负责协调竞争，fencing 负责保证写操作的正确性。如果要实现严格的互斥性，两者缺一不可。</p>

<h2 id="heading-可用性">可用性</h2>

<ul>
  <li>lease 的时长设置影响服务可用性
    <ul>
      <li>正常流程：client 完成任务后主动 revoke lease，锁立刻释放，其他 client 抢锁</li>
      <li>异常场景（进程 crash、机器宕机、网络分区）：client 没机会主动释放锁，只能干等 lease 超时，其他 client 才能抢锁，期间服务不可用</li>
      <li>服务发现场景：lease 的时长 <strong>30s</strong>，每 <strong>1/3 TTL</strong> 续约，是实践中的推荐值</li>
    </ul>
  </li>
  <li>高 Load 导致的进程假死，lease 心跳还在保持，但是 worker 线程不工作，如何释放锁？
    <ul>
      <li>停进程</li>
      <li>加黑 session 心跳，让 lease 自动过期释放，优雅安全</li>
      <li>如果是锁的「非持有者」删除锁点，原持有者因为各种原因未放锁，可能破坏锁的互斥性，不建议</li>
    </ul>
  </li>
</ul>

<h2 id="heading-其他">其他</h2>

<p>多个 client 同时抢同一把锁的时候，如果所有人都轮询同一个 key，锁一释放容易产生<strong>惊群</strong>，对服务端压力比较大。etcd 的做法是每个 client 创建带序号的 key 之后，只去 watch 排在自己前面的 key 的删除操作，排队抢锁。</p>]]></content><author><name>shitao</name></author><category term="tech" /><category term="distributed-systems" /><category term="lock" /><summary type="html"><![CDATA[单机环境做资源互斥比较简单，内核提供的 mutex、信号量这些机制，本质上依赖的是同一台机器上共享内存的可见性。所有线程都能看到同一块内存，CAS 一条指令就能完成状态判断和更新，原子性由硬件保证。]]></summary></entry><entry><title type="html">使用 runtime.SetFinalizer 优雅关闭后台 goroutine</title><link href="https://shitao.me/blog/2018/08/13/go-runtime/" rel="alternate" type="text/html" title="使用 runtime.SetFinalizer 优雅关闭后台 goroutine" /><published>2018-08-13T00:00:00+00:00</published><updated>2018-08-13T00:00:00+00:00</updated><id>https://shitao.me/blog/2018/08/13/go-runtime</id><content type="html" xml:base="https://shitao.me/blog/2018/08/13/go-runtime/"><![CDATA[<p>在日常项目开发中，总会使用后台 goroutine 做一些定期清理或更新的任务，这就涉及到 goroutine 生命周期的管理。</p>

<h2 id="heading-常规做法显式-stop">常规做法：显式 Stop()</h2>

<p>对于和主程序生命周期基本一致的后台 goroutine，一般采用如下显式的 <code class="language-plaintext highlighter-rouge">Stop()</code> 来进行优雅退出：</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">IApp</span> <span class="k">interface</span> <span class="p">{</span>
    <span class="c">//...</span>
    <span class="n">Stop</span><span class="p">()</span>
<span class="p">}</span>

<span class="k">type</span> <span class="n">App</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="n">running</span>   <span class="kt">bool</span>
    <span class="n">stop</span>      <span class="k">chan</span> <span class="k">struct</span><span class="p">{}</span>
    <span class="n">onStopped</span> <span class="k">func</span><span class="p">()</span>
<span class="p">}</span>

<span class="k">func</span> <span class="n">New</span><span class="p">()</span> <span class="o">*</span><span class="n">App</span> <span class="p">{</span>
    <span class="n">app</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="n">App</span><span class="p">{</span>
        <span class="n">running</span><span class="o">:</span> <span class="no">true</span><span class="p">,</span>
        <span class="n">stop</span><span class="o">:</span>    <span class="nb">make</span><span class="p">(</span><span class="k">chan</span> <span class="k">struct</span><span class="p">{}),</span>
    <span class="p">}</span>
    <span class="k">go</span> <span class="n">watch</span><span class="p">()</span>
    <span class="k">return</span> <span class="n">app</span>
<span class="p">}</span>

<span class="k">func</span> <span class="p">(</span><span class="n">app</span> <span class="o">*</span><span class="n">App</span><span class="p">)</span> <span class="n">watch</span><span class="p">()</span> <span class="p">{</span>
    <span class="n">ticker</span> <span class="o">:=</span> <span class="n">time</span><span class="o">.</span><span class="n">NewTicker</span><span class="p">(</span><span class="n">time</span><span class="o">.</span><span class="n">Second</span><span class="p">)</span>
    <span class="k">defer</span> <span class="n">ticker</span><span class="o">.</span><span class="n">Stop</span><span class="p">()</span>

    <span class="k">for</span> <span class="p">{</span>
        <span class="k">select</span> <span class="p">{</span>
        <span class="k">case</span> <span class="o">&lt;-</span><span class="n">app</span><span class="o">.</span><span class="n">stop</span><span class="o">:</span>
            <span class="k">if</span> <span class="n">app</span><span class="o">.</span><span class="n">onStopped</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
                <span class="n">app</span><span class="o">.</span><span class="n">onStopped</span><span class="p">()</span>
            <span class="p">}</span>
            <span class="k">return</span>
        <span class="k">case</span> <span class="o">&lt;-</span><span class="n">ticker</span><span class="o">.</span><span class="n">C</span><span class="o">:</span>
            <span class="c">// do something</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="k">func</span> <span class="p">(</span><span class="n">app</span> <span class="o">*</span><span class="n">App</span><span class="p">)</span> <span class="n">Stop</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">if</span> <span class="o">!</span><span class="n">app</span><span class="o">.</span><span class="n">running</span> <span class="p">{</span>
        <span class="k">return</span>
    <span class="p">}</span>
    <span class="nb">close</span><span class="p">(</span><span class="n">app</span><span class="o">.</span><span class="n">stop</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>这种方式除了需要在程序终止之前显式调用 <code class="language-plaintext highlighter-rouge">Stop()</code>，没有啥问题，事实上在业务层也推荐这种显式的处理方式。</p>

<h2 id="heading-问题场景cache-内存泄露">问题场景：cache 内存泄露</h2>

<p>比如现在想实现一个 cache 模块，接口很简单：</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">Cache</span> <span class="k">interface</span> <span class="p">{</span>
    <span class="n">Get</span><span class="p">(</span><span class="n">key</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="k">interface</span><span class="p">{},</span> <span class="kt">bool</span><span class="p">)</span>
    <span class="n">Set</span><span class="p">(</span><span class="n">key</span> <span class="kt">string</span><span class="p">,</span> <span class="n">value</span> <span class="k">interface</span><span class="p">{})</span>
<span class="p">}</span>
</code></pre></div></div>

<p>由于需要定时清理过期的缓存，会使用一个后台 goroutine 执行清理工作。但这对使用者来说应该是透明的，然而有时会出现意料之外的情况：</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">main</span><span class="p">()</span> <span class="p">{</span>
    <span class="n">c</span> <span class="o">:=</span> <span class="n">cache</span><span class="o">.</span><span class="n">New</span><span class="p">()</span>
    <span class="n">c</span><span class="o">.</span><span class="n">Set</span><span class="p">(</span><span class="s">"key1"</span><span class="p">,</span> <span class="n">obj</span><span class="p">)</span>
    <span class="n">val</span><span class="p">,</span> <span class="n">exist</span> <span class="o">:=</span> <span class="n">c</span><span class="o">.</span><span class="n">Get</span><span class="p">(</span><span class="s">"key1"</span><span class="p">)</span>
    <span class="c">// ...</span>
    <span class="n">c</span> <span class="o">=</span> <span class="no">nil</span>
    <span class="c">// do other things</span>
<span class="p">}</span>
</code></pre></div></div>

<p>在使用者看来，cache 已经没有引用了，会在 GC 时被回收。但实际上由于后台 goroutine 的存在，cache 始终不能满足不可达的条件，也就不会被 GC 回收，从而产生了内存泄露。</p>

<p>解决方法当然可以显式增加一个 <code class="language-plaintext highlighter-rouge">Close()</code> 方法，靠 channel 通知关闭 goroutine。但这无疑增加了使用成本，而且也不能避免使用者忘记调用 <code class="language-plaintext highlighter-rouge">Close()</code> 的场景。</p>

<p>有没有更好的方式，不需要用户显式关闭，在检测到没有引用之后主动终止 goroutine？当然有。<code class="language-plaintext highlighter-rouge">runtime.SetFinalizer</code> 可以帮助我们达到这个目的。</p>

<h2 id="heading-setfinalizer">SetFinalizer</h2>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">SetFinalizer</span><span class="p">(</span><span class="n">obj</span> <span class="k">interface</span><span class="p">{},</span> <span class="n">finalizer</span> <span class="k">interface</span><span class="p">{})</span>
</code></pre></div></div>

<blockquote>
  <p>SetFinalizer sets the finalizer associated with obj to the provided finalizer function.
When the garbage collector finds an unreachable block with an associated finalizer,
it clears the association and runs finalizer(obj) in a separate goroutine.
This makes obj reachable again, but now without an associated finalizer. Assuming that SetFinalizer is not called again,
the next time the garbage collector sees that obj is unreachable, it will free obj.</p>
</blockquote>

<p>上面是官方文档对 SetFinalizer 的一些解释，主要含义是对象可以关联一个 SetFinalizer 函数， 当 GC 检测到 unreachable 对象有关联的 SetFinalizer 函数时，会执行关联的 SetFinalizer 函数， 同时取消关联。 这样当下一次 GC 的时候，对象重新处于 unreachable 状态并且没有 SetFinalizer 关联， 就会被回收。</p>

<p>仔细看文档，还有几个需要注意的点：</p>

<ul>
  <li>即使程序正常结束或者发生错误，在对象被 GC 选中并回收之前，SetFinalizer 都不会执行。所以不要在 SetFinalizer 中执行将内存内容 flush 到磁盘这类操作。</li>
  <li>SetFinalizer 最大的问题是延长了对象生命周期。在第一次回收时执行 Finalizer 函数，目标对象重新变成可达状态，直到第二次才真正「销毁」。这对于有大量对象分配的高并发场景，可能会造成很大麻烦。</li>
  <li>指针构成的「循环引用」加上 <code class="language-plaintext highlighter-rouge">runtime.SetFinalizer</code> 会导致内存泄露。</li>
</ul>

<h3 id="heading-正确姿势">正确姿势</h3>

<p>如何利用 SetFinalizer 来清理 cache 后台 goroutine？istio 的 lrucache 给了我们一种巧妙的思路：</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">lruWrapper</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="o">*</span><span class="n">lruCache</span>
<span class="p">}</span>

<span class="c">// We return a 'see-through' wrapper for the real object such that</span>
<span class="c">// the finalizer can trigger on the wrapper. We can't set a finalizer</span>
<span class="c">// on the main cache object because it would never fire, since the</span>
<span class="c">// evicter goroutine is keeping it alive</span>
<span class="n">result</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="n">lruWrapper</span><span class="p">{</span><span class="n">c</span><span class="p">}</span>
<span class="n">runtime</span><span class="o">.</span><span class="n">SetFinalizer</span><span class="p">(</span><span class="n">result</span><span class="p">,</span> <span class="k">func</span><span class="p">(</span><span class="n">w</span> <span class="o">*</span><span class="n">lruWrapper</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">w</span><span class="o">.</span><span class="n">stopEvicter</span> <span class="o">&lt;-</span> <span class="no">true</span>
    <span class="n">w</span><span class="o">.</span><span class="n">evicterTerminated</span><span class="o">.</span><span class="n">Wait</span><span class="p">()</span>
<span class="p">})</span>
</code></pre></div></div>

<p>在 lrucache 外面加上一层 wrapper，lrucache 作为 wrapper 的匿名字段存在，并在 wrapper 上注册 SetFinalizer 函数来终止后台 goroutine。由于后台 goroutine 和 lrucache 关联，当没有引用指向 wrapper 时，GC 就会执行关联的 SetFinalizer 终止 lrucache 的后台 goroutine，最终 lrucache 也会变成不可达状态被 GC 回收。</p>

<h3 id="heading-完整实现">完整实现</h3>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">Cache</span> <span class="o">=</span> <span class="o">*</span><span class="n">wrapper</span>

<span class="k">type</span> <span class="n">wrapper</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="o">*</span><span class="n">cache</span>
<span class="p">}</span>

<span class="k">type</span> <span class="n">cache</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="n">content</span>   <span class="kt">string</span>
    <span class="n">stop</span>      <span class="k">chan</span> <span class="k">struct</span><span class="p">{}</span>
    <span class="n">onStopped</span> <span class="k">func</span><span class="p">()</span>
<span class="p">}</span>

<span class="k">func</span> <span class="n">newCache</span><span class="p">()</span> <span class="o">*</span><span class="n">cache</span> <span class="p">{</span>
    <span class="k">return</span> <span class="o">&amp;</span><span class="n">cache</span><span class="p">{</span>
        <span class="n">content</span><span class="o">:</span> <span class="s">"some thing"</span><span class="p">,</span>
        <span class="n">stop</span><span class="o">:</span>    <span class="nb">make</span><span class="p">(</span><span class="k">chan</span> <span class="k">struct</span><span class="p">{}),</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="k">func</span> <span class="n">NewCache</span><span class="p">()</span> <span class="n">Cache</span> <span class="p">{</span>
    <span class="n">w</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="n">wrapper</span><span class="p">{</span>
        <span class="n">cache</span><span class="o">:</span> <span class="n">newCache</span><span class="p">(),</span>
    <span class="p">}</span>
    <span class="k">go</span> <span class="n">w</span><span class="o">.</span><span class="n">cache</span><span class="o">.</span><span class="n">run</span><span class="p">()</span>
    <span class="n">runtime</span><span class="o">.</span><span class="n">SetFinalizer</span><span class="p">(</span><span class="n">w</span><span class="p">,</span> <span class="p">(</span><span class="o">*</span><span class="n">wrapper</span><span class="p">)</span><span class="o">.</span><span class="n">stop</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">w</span>
<span class="p">}</span>

<span class="k">func</span> <span class="p">(</span><span class="n">w</span> <span class="o">*</span><span class="n">wrapper</span><span class="p">)</span> <span class="n">stop</span><span class="p">()</span> <span class="p">{</span>
    <span class="n">w</span><span class="o">.</span><span class="n">cache</span><span class="o">.</span><span class="n">stop</span><span class="p">()</span>
<span class="p">}</span>

<span class="k">func</span> <span class="p">(</span><span class="n">c</span> <span class="o">*</span><span class="n">cache</span><span class="p">)</span> <span class="n">run</span><span class="p">()</span> <span class="p">{</span>
    <span class="n">ticker</span> <span class="o">:=</span> <span class="n">time</span><span class="o">.</span><span class="n">NewTicker</span><span class="p">(</span><span class="n">time</span><span class="o">.</span><span class="n">Second</span><span class="p">)</span>
    <span class="k">defer</span> <span class="n">ticker</span><span class="o">.</span><span class="n">Stop</span><span class="p">()</span>

    <span class="k">for</span> <span class="p">{</span>
        <span class="k">select</span> <span class="p">{</span>
        <span class="k">case</span> <span class="o">&lt;-</span><span class="n">ticker</span><span class="o">.</span><span class="n">C</span><span class="o">:</span>
            <span class="c">// do some thing</span>
        <span class="k">case</span> <span class="o">&lt;-</span><span class="n">c</span><span class="o">.</span><span class="n">stop</span><span class="o">:</span>
            <span class="k">if</span> <span class="n">c</span><span class="o">.</span><span class="n">onStopped</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
                <span class="n">c</span><span class="o">.</span><span class="n">onStopped</span><span class="p">()</span>
            <span class="p">}</span>
            <span class="k">return</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="k">func</span> <span class="p">(</span><span class="n">c</span> <span class="o">*</span><span class="n">cache</span><span class="p">)</span> <span class="n">stop</span><span class="p">()</span> <span class="p">{</span>
    <span class="nb">close</span><span class="p">(</span><span class="n">c</span><span class="o">.</span><span class="n">stop</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>对于对象是否被回收， 最靠谱的方式就是靠test来检测并保证这一行为：</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">TestFinalizer</span><span class="p">(</span><span class="n">t</span> <span class="o">*</span><span class="n">testing</span><span class="o">.</span><span class="n">T</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">s</span> <span class="o">:=</span> <span class="n">assert</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="n">t</span><span class="p">)</span>

    <span class="n">w</span> <span class="o">:=</span> <span class="n">NewCache</span><span class="p">()</span>
    <span class="k">var</span> <span class="n">cnt</span> <span class="kt">int</span> <span class="o">=</span> <span class="m">0</span>
    <span class="n">stopped</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="k">chan</span> <span class="k">struct</span><span class="p">{})</span>
    <span class="n">w</span><span class="o">.</span><span class="n">onStopped</span> <span class="o">=</span> <span class="k">func</span><span class="p">()</span> <span class="p">{</span>
        <span class="n">cnt</span><span class="o">++</span>
        <span class="nb">close</span><span class="p">(</span><span class="n">stopped</span><span class="p">)</span>
    <span class="p">}</span>

    <span class="n">s</span><span class="o">.</span><span class="n">Equal</span><span class="p">(</span><span class="m">0</span><span class="p">,</span> <span class="n">cnt</span><span class="p">)</span>

    <span class="n">w</span> <span class="o">=</span> <span class="no">nil</span>

    <span class="n">runtime</span><span class="o">.</span><span class="n">GC</span><span class="p">()</span>

    <span class="k">select</span> <span class="p">{</span>
    <span class="k">case</span> <span class="o">&lt;-</span><span class="n">stopped</span><span class="o">:</span>
    <span class="k">case</span> <span class="o">&lt;-</span><span class="n">time</span><span class="o">.</span><span class="n">After</span><span class="p">(</span><span class="m">10</span> <span class="o">*</span> <span class="n">time</span><span class="o">.</span><span class="n">Second</span><span class="p">)</span><span class="o">:</span>
        <span class="n">t</span><span class="o">.</span><span class="n">Fail</span><span class="p">()</span>
    <span class="p">}</span>

    <span class="n">s</span><span class="o">.</span><span class="n">Equal</span><span class="p">(</span><span class="m">1</span><span class="p">,</span> <span class="n">cnt</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>事实上，在基础库中 SetFinalizer 主要的使用场景是减少用户错误使用导致的资源泄露。比如 <code class="language-plaintext highlighter-rouge">os.NewFile()</code> 和 <code class="language-plaintext highlighter-rouge">net.FD()</code> 都注册了 finalizer 来避免用户由于忘记调用 <code class="language-plaintext highlighter-rouge">Close</code> 导致的 fd leak，有兴趣的读者可以去看一下相关的代码。</p>]]></content><author><name>shitao</name></author><category term="tech" /><category term="go" /><category term="runtime" /><summary type="html"><![CDATA[在日常项目开发中，总会使用后台 goroutine 做一些定期清理或更新的任务，这就涉及到 goroutine 生命周期的管理。]]></summary></entry><entry><title type="html">Bye CSDN, Hello Blog</title><link href="https://shitao.me/blog/2018/04/22/hello-world/" rel="alternate" type="text/html" title="Bye CSDN, Hello Blog" /><published>2018-04-22T00:00:00+00:00</published><updated>2018-04-22T00:00:00+00:00</updated><id>https://shitao.me/blog/2018/04/22/hello-world</id><content type="html" xml:base="https://shitao.me/blog/2018/04/22/hello-world/"><![CDATA[<p>欢迎来到我的个人博客！</p>

<p>学生时代，我一直在 CSDN 上记录学习笔记（<a href="https://blog.csdn.net/NK_test?type=blog">NK_test 的博客</a>）。虽然有些稚嫩，却真实地记录了自己的成长历程。不幸的后期 CSDN 抄袭泛滥、广告横行，已经不再适合做个人技术沉淀了。</p>

<p>所以我决定搭建自己的博客空间，在这里分享真实的技术思考、实践经验，以及值得被记录下来的想法。</p>

<p>感谢你的到来，希望这里的内容对你有所启发。🌊</p>]]></content><author><name>shitao</name></author><category term="uncategorized" /><category term="random" /><summary type="html"><![CDATA[欢迎来到我的个人博客！]]></summary></entry></feed>