<?xml version="1.0" encoding="utf-8"?>
<?xml-stylesheet href="/feeds/atom-style.xsl" type="text/xsl"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <id>https://eryi.dev/</id>
    <title>貳壹</title>
    <updated>2026-04-08T09:53:23.969Z</updated>
    <generator>Astro-Theme-Retypeset with Feed for Node.js</generator>
    <author>
        <name>eryi</name>
        <uri>https://eryi.dev/</uri>
    </author>
    <link rel="alternate" href="https://eryi.dev/"/>
    <link rel="self" href="https://eryi.dev/atom.xml"/>
    <subtitle>螺旋上升</subtitle>
    <rights>Copyright © 2026 eryi</rights>
    <entry>
        <title type="html"><![CDATA[服务限流实战]]></title>
        <id>https://eryi.dev/posts/service-rate-limiting-practice/</id>
        <link href="https://eryi.dev/posts/service-rate-limiting-practice/"/>
        <updated>2025-11-10T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[围绕评论机器人接入 LLM API 的场景，梳理用户侧限流、下游熔断和异步回写的实践设计。]]></summary>
        <content type="html"><![CDATA[<p>设想有这样一个场景，我在我的 UGC 平台接入了一个评论机器人，用户在其他用户的评论区可以通过 <code>@robot</code> 来触发回复。由于我的机器人回复是使用第三方 API，如 OpenRouter，来实现个性化 LLM 回复的，因此这里涉及到限流的问题。</p>
<h2>瓶颈</h2>
<p><img src="https://img.eryi.me/astro-blog/2026/04/aba31bfac98ad57239deef625b80e7f7.png" alt="image.png" /></p>
<ul>
<li>第一个是用户请求层面的，我们不能无限制地纵容用户一直调用机器人评论服务。
<ul>
<li>
<ol>
<li>在功能层面，我们可以限制用户单位时间内的调用次数，比如使用 Redis 记录每分钟调用次数不能超过 5 次。</li>
</ol>
</li>
<li>
<ol>
<li>在技术层面，若已经在功能层面进行限制了，但是用户基数非常大，实际上仍旧有很大规模的用户在调用这个服务，我们可以使用 MQ 或者令牌桶来进行限流。</li>
</ol>
</li>
</ul>
</li>
<li>第二个是 LLM 服务端的流量限制。
<ul>
<li>LLM 服务端如 OpenRouter，对单个用户的请求会有 RPM/TPM 限制，这属于外部瓶颈。</li>
<li>可以使用 MQ + 令牌桶做流量整形，以及加上熔断策略。</li>
</ul>
</li>
<li>第三个是 LLM 返回信息给我们，但是我们写入评论服务流量太大，单个线程可能也来不及处理，因此我们可以使用线程池来并行写入，同时也可以使用 MQ 来削峰填谷。</li>
</ul>
<h2>限流和熔断</h2>
<p>首先明确这两个概念：</p>
<p><strong>限流</strong>：是限制系统的请求频率或内部某些功能的执行频率，防止突发的流量激增导致整个系统不可用，常见的 限流算法 有滑动窗口、漏桶、令牌桶等。</p>
<p><strong>熔断</strong>：当调用外部服务、数据库或微服务时，如果连续出现失败、超时或响应过慢，熔断机制会“断开电路”，暂时中止调用该依赖。在熔断开启期间，系统直接返回错误或降级结果，不再继续发请求。等一段“冷却时间”后，再少量放行测试请求；如果恢复正常，熔断自动关闭。</p>
<h3>熔断特性</h3>
<ul>
<li>慢调用熔断</li>
<li>异常比例熔断</li>
<li>半开状态试探</li>
</ul>
<p>针对上一节提到的三个瓶颈，针对用户请求层面上的，我们主要使用限流策略；针对调用下游 LLM 服务的，我们主要使用熔断策略；针对回写数据库这里属于内部 IO，可以使用 MQ 做异步来进行削峰填谷。</p>
<h2>实战</h2>
<h3>计划</h3>
<p>第一部分分为业务和技术两类限流。</p>
<ul>
<li>业务限流：
<ul>
<li>限制每人每分钟请求次数不超过 5 次</li>
<li>限制每人每天请求次数不超过 20 次
<ul>
<li>key 设计</li>
</ul>
</li>
<li>每次请求需消耗一个金币，金币不足不能被请求</li>
</ul>
</li>
<li>技术限流：
<ul>
<li>使用令牌桶算法，限制总体 QPS 不超过 100</li>
</ul>
</li>
</ul>
<p>第二部分为调用 LLM 外部服务，主要设计熔断策略。</p>
<ul>
<li>要求
<ul>
<li>如果返回大于 60s 就熔断</li>
<li>如果异常比例大于 50% 就熔断</li>
<li>熔断时间过后，拿到真实用户请求，类似令牌桶，给个许可去请求下游</li>
</ul>
</li>
</ul>
<p>第三部分为回写 DB。</p>
<ul>
<li>引入 Kafka 即可</li>
</ul>
<h3>中间件选用</h3>
<ul>
<li>当然我们可以自己实现以上的限流与熔断策略，但是有成熟的中间件给我们使用。</li>
<li>Resilience4j 和 <a href="https://github.com/alibaba/sentinel">alibaba/Sentinel</a> 都有以上的功能，最终选取了 Sentinel。</li>
<li>原因
<ul>
<li>Sentinel 可以配置原生控制台，可视化调整。</li>
<li>Sentinel 在针对 LLM 这类限流时，有 Pacing 匀速排队的功能，可以让突发的请求以均匀的速度发给下游。</li>
<li>同时 Sentinel 还有令牌桶模式，符合我们的各种流量处理需求。</li>
</ul>
</li>
</ul>
]]></content>
        <author>
            <name>eryi</name>
            <uri>https://eryi.dev/</uri>
        </author>
        <published>2025-11-08T00:00:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[BILIBILI点赞架构]]></title>
        <id>https://eryi.dev/posts/bilibililike/</id>
        <link href="https://eryi.dev/posts/bilibililike/"/>
        <updated>2025-10-01T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[BILIBILI点赞架构解析]]></summary>
        <content type="html"><![CDATA[<h2>系统压力</h2>
<h3>流量压力</h3>
<h5>全局流量压力</h5>
<ul>
<li>写流量
<ul>
<li>可以做聚合，比如聚合10s内的点赞数，一次性写入减少IO</li>
<li>DB写入做异步化（MQ等）</li>
</ul>
</li>
<li>每一次更新点赞数据前检查之前的点赞状态，因为取消点赞需要之前没有点赞，反之相同</li>
</ul>
<h5>单点流量压力</h5>
<ul>
<li>热门稿件事件
<ul>
<li>DB热点，缓存热点。要有热点识别机制识别，并将数据缓存到本地，并设置合理的TTL</li>
</ul>
</li>
</ul>
<h3>数据存储压力</h3>
<ul>
<li>KV化存储</li>
</ul>
<h3>容灾压力</h3>
<ul>
<li>DB宕机</li>
<li>Redis集群抖动</li>
<li>机房故障</li>
<li>网络故障</li>
</ul>
<h2>系统架构</h2>
<p><img src="https://i0.hdslb.com/bfs/article/758b2b4bef2f3dd719ef82ccf3bf077f9331d7e4.png" alt="img" /></p>
<h3>三级数据存储层</h3>
<h4>DB-TiDB</h4>
<ul>
<li>TiDB是分布式的就不用分库分表了</li>
<li>点赞记录表</li>
<li>点赞count表</li>
</ul>
<h4>Cache</h4>
<ul>
<li>使用cacheAside</li>
<li>
<pre><code>key-value = user:likes:patten:{mid}:{business_id} - member(messageID)-score(likeTimestamp)
</code></pre>
</li>
<li>直接使用zset，维护最大长度，淘汰最早点赞的消息</li>
</ul>
<h4>本地缓存</h4>
<ul>
<li>应对缓存热点</li>
<li>最小堆算法，在可配置的时间窗口内，统计处访问最频繁的缓存key，并将热Key（Value）按照业务可接受的TTL存储在本地内存中</li>
</ul>
<h4>数据迁移归档</h4>
<ul>
<li>从TiDB迁移到KV（Taishan）数据库，节约成本</li>
</ul>
<h3>点赞服务层</h3>
<h4>存储容灾（DB、redis）</h4>
<ul>
<li>两地机房互为灾备
<ul>
<li>机房A所有写+部分读</li>
<li>机房B部分读</li>
</ul>
</li>
<li>DB故障，使用db-proxy(sidecar)切换读写流量到备用机房</li>
<li>异地缓存一致性通过异步任务消费TiDB的binlog维护。可以在需要时切换机房来保证服务，而不会导致大量冷数据回源数据库（冷数据指redis中没有的，需要到DB查的）</li>
</ul>
<h4>服务容灾</h4>
<ul>
<li>多层数据存储互为灾备</li>
<li>redis-&gt;kv-&gt;DB</li>
<li>点赞操作每一层都无限重试</li>
</ul>
<h4>异步任务层</h4>
<ul>
<li>点赞数据写入、刷新缓存、为下游其他服务（推荐系统等）发送点赞/点赞数消息</li>
<li>binlog断流的容灾
<ul>
<li>先对binlog进行监控</li>
<li>通过业务服务提前透底时间的备用消息，job检测到binlog异常就自动切换fallback消费流。</li>
</ul>
</li>
</ul>
<p>TODO：</p>
<ul>
<li>缓存策略。以及更新我的点赞/收藏操作</li>
</ul>
<h1>Reference</h1>
<p><a href="https://www.bilibili.com/read/cv21576373/?opus_fallback=1">【点个赞吧】 - B站千亿级点赞系统服务架构设计 - 哔哩哔哩</a></p>
]]></content>
        <author>
            <name>eryi</name>
            <uri>https://eryi.dev/</uri>
        </author>
        <published>2025-04-22T00:00:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[Spring Servlet 回顾]]></title>
        <id>https://eryi.dev/posts/spring-servlet-recap/</id>
        <link href="https://eryi.dev/posts/spring-servlet-recap/"/>
        <updated>2024-12-14T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Java Servlet 作为处理 Web 请求的标准化方案，逐步取代早期的 CGI，并通过 DispatcherServlet 构成了 Spring MVC 的核心。]]></summary>
        <content type="html"><![CDATA[<h2>Servlet</h2>
<p>Servlet 是一个用来处理 HTTP 请求并生成响应的 Java 类。
<img src="https://img.eryi.me/image-20241213223146486.png" alt="" /></p>
<ul>
<li>Servlet 容器把 HTTP 请求转换成 <code>HttpServletRequest</code> 对象，并准备好 <code>HttpServletResponse</code> 对象。</li>
<li>随后容器把这两个对象交给某个 Web 组件，它可以访问 Bean 或数据库来创建动态内容。</li>
<li>Web 组件可以直接填充 <code>HttpServletResponse</code>，也可以把它传递给其他组件继续处理。</li>
<li>最终 Servlet 容器把 <code>HttpServletResponse</code> 再次转换成 HTTP 响应，由 Web 服务器返回给客户端。</li>
</ul>
<h3>代码</h3>
<pre><code>package jakarta.servlet;

import java.io.IOException;

public interface Servlet {
    void init(ServletConfig var1) throws ServletException;

    ServletConfig getServletConfig();

    void service(ServletRequest var1, ServletResponse var2) throws ServletException, IOException;

    String getServletInfo();

    void destroy();
}

// HttpServlet 中的 service 方法
    public void service(ServletRequest req, ServletResponse res) throws ServletException, IOException {
        HttpServletRequest request;
        HttpServletResponse response;
        try {
            request = (HttpServletRequest)req;
            response = (HttpServletResponse)res;
        } catch (ClassCastException var6) {
            throw new ServletException(lStrings.getString("http.non_http"));
        }

        this.service(request, response);
    }
</code></pre>
<h3>生命周期</h3>
<ul>
<li>当 Servlet 实例还不存在时，Servlet 容器会：
<ul>
<li>加载 Servlet 类</li>
<li>创建这个类的实例</li>
<li>调用 <code>init()</code>（仅在启动时调用一次）</li>
<li>对每个请求调用 <code>service()</code></li>
<li><code>service()</code> 会根据 HTTP 方法再去调用 <code>doGet()</code> / <code>doPost()</code> 等方法</li>
<li>在结束时调用 <code>destroy()</code>（同样只执行一次）</li>
</ul>
</li>
</ul>
<h3>为什么需要 Servlet</h3>
<p>最初 HTTP 服务器只能提供纯 HTML 这样的静态内容。为了能根据用户输入或数据库结果生成页面，服务器需要扩展为支持动态内容。</p>
<p>早期的服务器扩展方式很多：</p>
<ul>
<li>CGI：所有服务器都能实现的开放标准</li>
<li>像 NSAPI（Netscape）和 ISAPI（Microsoft）这样的私有 API，只能在特定服务器上使用</li>
</ul>
<h4>CGI（Common Gateway Interface）</h4>
<p>CGI 是一个标准协议，用来定义 Web 服务器如何和外部应用或脚本通信。CGI 程序可以由任何语言编写（C、C++、Perl、Python 等），负责处理请求并生成动态内容。</p>
<p><img src="https://img.eryi.me/image-20241214143933871.png" alt="image-20241214143933871" /></p>
<p>可以看到，Web 服务器需要把每个请求都交给 CGI 程序来返回响应，而且服务器必须为每次请求都创建和销毁一个进程。</p>
<blockquote>
<p>FastCGI：它不会为每个请求都创建新进程，而是使用常驻进程来处理一系列请求，这些进程由 FastCGI 服务器而不是 Web 服务器管理。</p>
</blockquote>
<p>之后，Java Servlet 作为 Jakarta EE 的一部分被提出，提供一个标准化、与厂商无关的 API，让 Java 能方便地编写动态 Web 应用。</p>
<h2>DispatcherServlet</h2>
<p>DispatcherServlet 是一个特殊的 Servlet，它继承自 HttpServlet，在 Spring MVC 中扮演前端控制器（Front Controller）。它负责拦截进入的 HTTP 请求并分发到合适的控制器方法。<em>它继承关系是 FrameworkServlet → HttpServletBean → HttpServlet。</em></p>
<h3>Spring MVC 的处理流程</h3>
<h4>请求处理链</h4>
<pre><code>HTTP request
    -&gt; Filter Chain
    -&gt; DispatcherServlet
            - DispatcherServlet consults HandlerMapping to find the right controller
    -&gt; HandlerMapping
        - HandlerMapping based on url to find specific handler(controller)
    -&gt; HandlerExecutionChain
    -&gt; HandlerAdapter
          - DispatcherServlet calls HandlerAdapter to execute controller method and returns ModelAndView
    -&gt; Controller
            - return ModelAndView
    -&gt; ViewResolver
        - ViewResolver translates view name to actual View
    -&gt; View
        - View renders the response
    -&gt; Response
</code></pre>
<h2>Spring 过滤器</h2>
<p>自定义过滤器需要实现 <code>Filter</code> 接口：</p>
<pre><code>public interface Filter {
    default void init(FilterConfig filterConfig) throws ServletException {
    }

    void doFilter(ServletRequest var1, ServletResponse var2, FilterChain var3) throws IOException, ServletException;

    default void destroy() {
    }
}
</code></pre>
<p>HTTP 请求会先经过若干过滤器，然后才到 DispatcherServlet。多个过滤器可以串联组成 FilterChain，并且可以用 <code>@Order</code> 来控制执行顺序。</p>
<h2>Q&amp;A</h2>
<ol>
<li><strong>为什么 Servlet 不是线程安全的？</strong>
<ul>
<li>Servlet 容器只会为每个 Servlet 创建一个实例。</li>
<li>但来自不同客户端的请求会被分发到不同线程，这些线程同时访问这一个实例。</li>
<li>所有线程共享该 Servlet 的实例变量和类变量，因此需要自行保证线程安全。</li>
</ul>
</li>
</ol>
<h2>参考资料</h2>
<p><a href="https://www.geeksforgeeks.org/introduction-java-servlets/">Introduction to Java Servlets - GeeksforGeeks</a></p>
]]></content>
        <author>
            <name>eryi</name>
            <uri>https://eryi.dev/</uri>
        </author>
        <published>2024-12-14T00:00:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[身份验证机制中的cookies,session,token概念解释]]></title>
        <id>https://eryi.dev/posts/cookies-session-token/</id>
        <link href="https://eryi.dev/posts/cookies-session-token/"/>
        <updated>2024-10-30T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[简单的概念复习]]></summary>
        <content type="html"><![CDATA[<p>在web authentication中，经常会出现cookie、session、token这三个概念。这些概念关系到在一个系统中的身份验证机制，在实际开发中是绕不过去的问题。</p>
<h2>Cookie</h2>
<p>cookie 在<a href="https://datatracker.ietf.org/doc/html/rfc6265">RFC 6265 </a>HTTP状态管理机制中解释的很清楚。</p>
<blockquote>
<p>This document defines the HTTP Cookie and Set-Cookie header fields.These header fields can be used by HTTP servers to store state(called cookies) at HTTP user agents, letting the servers maintain a stateful session over the mostly stateless HTTP protocol.
To store state, the origin server includes a Set-Cookie header in an HTTP response.  In subsequent requests, the user agent returns a Cookie request header to the origin server.  The Cookie header contains cookies the user agent received in previous Set-Cookie headers.  The origin server is free to ignore the Cookie header or use its contents for an application-defined purpose.</p>
</blockquote>
<p>实际上就是说，为了在本几乎（HTTP/1.0之前）无状态的HTTP协议上进行会话管理，HTTP服务器可以在用户端存储<strong>状态信息</strong>，即存放在Cookie里。具体过程如下：
<img src="https://img.eryi.me/Pasted%20image%2020241030143916.png" alt="cs-cookie" />
Cookie是HTTP的一个请求头，不一定只存放server set的cookie，还可以存放一些诸如偏好设置，语言设置的数据。只不过由于cookie的特性（每次HTTP请求都会携带），使得其比较适合存放session id（状态信息） 来进行会话管理。</p>
<h2>为什么这样就能进行会话管理（session management）了呢？</h2>
<p>HTTP本身是无状态的，意味着每个请求都是独立的。Cookie通过在客户端存储一些信息（例如：session id），使得客户端每次请求都会携带服务器set的cookie，使得服务器有能力通过cookie信息识别不同的用户。服务器可以维护会话数据库，存放需要的内容如用户登录状态，购物车信息，并以session id 作为标识。当某一用户再次访问时，客户端将存有的session id 通过cookie发送给服务器，服务器根据session id 查询数据库，回复用户相关的数据状态。从而完成会话管理。</p>
<h2>Session</h2>
<p>上面在介绍cookie时其实已经涉及到session了，这里引入<a href="https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html#introduction">OWASP </a>关于session的定义</p>
<blockquote>
<p>A web session is a sequence of network HTTP request and response transactions associated with the same user.</p>
</blockquote>
<p>翻译一下就是：Session 是针对同一用户的一系列 HTTP 请求和响应的集合。在实际的开发中，服务端需要管理session的整个生命流程（生成、存储、验证、销毁）。上述使用cookie设置session id 进行用户识别的方式就是<strong>cookie-based authentication</strong>，其核心就是使用了session ID 充当用户的临时身份标识。</p>
<h3>缺点</h3>
<p>由于使用了cookie作为载体来运输session ID，尽管我们会设置 <code>HttpOnly</code>、<code>Secure</code> 和 <code>SameSit</code>等cookie属性来保证其安全，但仍有很多因素可能会导致session ID泄漏，造成不安全的后果。还有一点问题，若我们使用分布式系统可能还要涉及到session同步的问题，如使用redis来集中管理session。</p>
<h2>Json web token（JWT）</h2>
<p>引入<a href="https://datatracker.ietf.org/doc/html/rfc7519#section-3">RFC 7519)</a>的定义</p>
<blockquote>
<p>JSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between two parties.  The claims in a JWT are encoded as a JSON object that is used as the payload of a JSON Web Signature (JWS) structure or as the plaintext of a JSON Web Encryption (JWE) structure, enabling the claims to be digitally signed or integrity protected with a Message Authentication Code (MAC) and/or encrypted.</p>
</blockquote>
<p>这个定义主要说明了：1.JWT的内容在创建时是JSON格式的；2.支持JWS和JWE两种保护方式（两种实现方式）。
我们先来看JWT在传输时的结构：<code>Header.Payload.Signature</code>。根据 <code>.</code>分成了三部分。下面讲一下JWT是如何生成的。</p>
<h2>JWT的生成</h2>
<ul>
<li><strong>创建</strong>JOSE Header（JSON Object Signing and Encryption Header），定义一些元数据。并使用Bse64编码将其转为字符串</li>
</ul>
<pre><code>{
  "alg": "HS256", //签名算法
  "typ": "JWT" //令牌类型，作标识
}

</code></pre>
<ul>
<li><strong>创建</strong> Payload，定义JWT主体内容。并使用Bse64编码将其转为字符串</li>
</ul>
<pre><code>{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}
</code></pre>
<ul>
<li><strong>生成</strong>签名
<ul>
<li>拼接编码过的header和payload `base64UrlEncode(header) + "." + base64UrlEncode(payload)</li>
<li>使用指定的签名算法进行签名`HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)
<ul>
<li>例如NextAuth就要求我们在环境变量中设置 <code>NEXTAUTH_SECRET</code>来作为密钥，这里的密钥为对称密钥，生成和验证签名都使用该密钥；还有另一种非对称加密，如RSA等就是用的非对称密钥对，私钥生成，公钥验证。</li>
</ul>
</li>
<li>再次Base64编码生成字符串，即signature部分</li>
</ul>
</li>
<li><strong>拼接</strong>形成JWT
<ul>
<li><code>Header.Payload.Signature</code>
可能你已经注意到了，上面包含了签名拼接。这实际上是作为JWT实现之一的JWS的实现。在实际开发中，几乎所有语言框架的JWT库均适用JWS的方式生成JWT，例如Node的jsonwebtoken，Java的jjwt等。这是由于JWS已经满足了常见的安全需求，在大多数的应用场景下，JWT仅用来验证数据的真实性和完整性，而不涉及数据的机密性需求，所以几乎不使用JWE的方式来生成JWT。
JWT和Session有什么区别呢？乍一看好像都是通过编码过的字符串来分辨用户啊？</li>
</ul>
</li>
</ul>
<h2>JWT和Session的对比</h2>
<h2>存储位置</h2>
<p>Session通常由服务端生成并保存在数据库或内存中，每次用户请求都需要在数据库查找会话信息，当服务器端数据发生变化时也能够实时更新会话数据。
而JWT的信息是JWT本身存储的，每次用户请求时，服务器只需要验签即可。</p>
<h2>生命周期</h2>
<p>Session的生命周期是由服务器管理的。Session失效时服务器需要删除对应session数据。
JWT的生命周期是在创建时通过在payload中设置 <code>exp</code>参数设置的，在JWT发出后就不能更改其的到期时间了，只能刷新令牌机制来使其无效。</p>
<h2>安全</h2>
<p>Session本身没有签名，SessionId 可能会有泄漏的风险。
JWT有签名机制来保证其完整性和安全性。</p>
<h2>总结</h2>
<p>Cookie、Session、JWT三者都是web authentication中的重要概念，在实际开发中，需要根据实际来选择使用哪一种方式来进行用户身份验证。当然目前每种成熟的框架都有很完整的库来使用，但是了解这些概念肯定大有裨益。</p>
]]></content>
        <author>
            <name>eryi</name>
            <uri>https://eryi.dev/</uri>
        </author>
        <published>2024-10-30T00:00:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[由obsidian的git插件push失败后引申的ssh相关的问题]]></title>
        <id>https://eryi.dev/posts/obsidian-ssh-issues/</id>
        <link href="https://eryi.dev/posts/obsidian-ssh-issues/"/>
        <updated>2024-08-12T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[本文探讨了在使用 Obsidian 的 Git 插件时遇到的 SSH 连接问题。通过深入分析 SSH 的工作原理、密钥管理和认证过程，文章提供了解决 SSH 连接中频繁要求输入 passphrase 的方案，详细解释了如何配置 SSH Agent 实现自动加载密钥，避免重复输入密码。]]></summary>
        <content type="html"><![CDATA[<h2>问题由来</h2>
<ul>
<li>在配置了obsidian的git插件后，进行上传时提示 <code>permission denied</code>,联想到之前每次 <code>git push</code>时均需要输入passphrase for key id_rsa，判断应该是obsidian在执行插件脚本时，因为未输入passphrase导致push失败。</li>
</ul>
<h2>再进一步</h2>
<ul>
<li>ssh是什么？生成的私钥和公钥是什么？为什么git push会触发passphrase？passphrase保护的是什么？</li>
</ul>
<h2>ssh是什么</h2>
<blockquote>
<p>Secure Shell (SSH) 协议是一种通过不安全网络向计算机安全发送命令的方法。SSH 使用加密技术对设备之间的连接进行验证和加密。SSH 还可以实现<a href="https://www.cloudflare.com/learning/network-layer/what-is-tunneling/">隧道传输</a>或端口转发，这是指<a href="https://www.cloudflare.com/learning/network-layer/what-is-a-packet/">数据包</a>可以穿越原本无法穿越的网络。 SSH 通常用于远程控制服务器、管理基础设施和传输文件。<a href="https://www.cloudflare.com/zh-cn/learning/access-management/what-is-ssh/">cloudflare</a></p>
</blockquote>
<h3>ssh如何工作</h3>
<h4>特点</h4>
<ul>
<li>基于TCP/IP协议套件上运行</li>
<li>采用<a href="https://www.cloudflare.com/learning/ssl/how-does-public-key-encryption-work/">公钥加密</a></li>
</ul>
<h4>流程</h4>
<pre><code>1.版本号协商阶段
	1.【服务端】默认打开22端口，等待客户连接
	2.【客户端】向服务端发起TCP连接
	3.CS互相协商版本号
2.密钥和算法协商阶段
	1.【服务端和客户端】分别发送算法协商报文给对方，协商最终使用算法
	2.【服务端】将服务端公钥发送给客户端，生成会话ID，设成id，发送给客户端
	3.【客户端】生成会话密钥，设为key，计算res = id 异或 key；并将res使用服务端公钥加密发送给服务端
	4.【服务端】使用服务端私钥解密得到res
	5.【服务端】计算res 异或 id得到key，即会话密钥。至此，客户端和服务端均有了服会话密钥和会话ID，之后的data均适用该session密钥进行加解密
3.认证阶段
	1.ssh客户端在进行身份验证时会按照`publickey,gssapi-keyex,gssapi-with-mic,password`的顺序依次尝试身份验证。其中publickey即为密钥对进行认证，password即为使用传统的密码认证
	2.【publickey】
		a.【客户端】使用ssh-keygen生成公钥id_rsa.pub 和私钥 id_rsa，将公钥发送给服务端，放在.ssh目录下
		b.【客户端】使用session密钥加密 账号，认证方法，公钥，将结果发送给服务端
		c.【服务端】使用session密钥解密 报文，服务端检查.ssh目录下是否有对应的公钥，若无则发送失败消息给客户端；若找到并比对成功，并使用该公钥加密一个随机字符串，简称“质询”，再使用session密钥再加密一次
		d.【客户端】使用session密钥和私钥两次解密后，再使用session密钥加密质询发送给服务端
		e.【服务端】使用session密钥解密报文得到质询，和生成的比对是否相同，相同则通过，不同报错给客户端
	3.【password】
		a.【客户端】使用session密钥加密 账号，认证方法，口令，将结果发送给服务端
		b.【服务端】使用session密钥解密 报文得到账号和口令；服务器对账密进行判断，失败即报错。
4.会话请求阶段
	确定会话类型，例如启动shell或执行命令或转发端口，是在键入连接ssh命令时隐式完成的。
5.会话交互阶段
</code></pre>
<h3>passphrase是什么？SSH agent是用来干嘛的？</h3>
<ul>
<li>
<p>passphrase是用于保护SSH私钥的密码，在使用 <code>ssh-keygen</code>生成密钥对时设置</p>
</li>
<li>
<p>SSH agent是一个用于管理和缓存私钥的工具，主要功能是解锁并保存私钥的解锁状态，使得同一用户在同一会话期间无需重复输入 <code>passphrase</code></p>
<ul>
<li>启动：<code>ssh-agent -s</code></li>
<li>添加私钥到SSH Agent：<code>ssh-add --apple-use-keychain ~/.ssh/id_rsa</code>
<ul>
<li>-<code>-apple-use-keychain</code>:改参数将私钥的passphrase存储到macos的钥匙串中了</li>
</ul>
</li>
<li>自启动ssh agent 并自动加载密钥：配置在环境变量中如 <code>.zshrc</code>中</li>
</ul>
<pre><code>ssh-agent -s
ssh-add --apple-use-keychain ~/.ssh/id_rsa
</code></pre>
<ul>
<li>配置SSH客户端配置文件(<code>~/.ssh/config</code>)：</li>
</ul>
</li>
</ul>
<pre><code>Host *
    AddKeysToAgent yes
    UseKeychain yes
    IdentityFile ~/.ssh/id_rsa
</code></pre>
<h2>解决方法</h2>
<ul>
<li>通过上述的解释，可以对上节开始的几个问题有清晰的回答了。</li>
<li>只需要在环境变量中配置好自启动ssh agent就无需每次都手动输入passphrase了；或者也可以在生成密钥对时不设置passphrase，当然后者方法由于安全问题不做推荐。</li>
</ul>
]]></content>
        <author>
            <name>eryi</name>
            <uri>https://eryi.dev/</uri>
        </author>
        <published>2024-08-12T00:00:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[synchronized和volatile-从单例的实现说起]]></title>
        <id>https://eryi.dev/posts/singleton-synchronized-volatile/</id>
        <link href="https://eryi.dev/posts/singleton-synchronized-volatile/"/>
        <updated>2023-12-10T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[从Java实现单例的方式说起，穿插介绍了Synchronized，volatile等关键字的说明，最后介绍了两种更巧妙的单例模式实现]]></summary>
        <content type="html"><![CDATA[<h2>单例模式的实现</h2>
<p>单例我们都很熟悉，从定义上说就是单例对象的类必须保证只有⼀个实例存在，单例模式确保一个类只有一个实例，并提供全局访问点。在实现方式上来说有<strong>懒汉式</strong>和<strong>饿汉式</strong>。</p>
<p>他们之间的区别在于：</p>
<ol>
<li>懒汉式：指全局的单例实例在第⼀次被使⽤时构建</li>
<li>饿汉式：指全局的单例在类装载时构建</li>
</ol>
<h3>懒汉式</h3>
<h4>实现1</h4>
<p>我们先来看第一种实现方式：</p>
<pre><code>//version1
class Singleton {
    private static Singleton instance;
    //构造器私有防止被外部类调用
    private Singleton() {}
    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }


}
public class Main{
    public static void main(String[] args) {
        //Singleton singleton =  new Singleton();
      	//由于构造器私有，我们使用类的静态方法来获取实例
        Singleton obj = Singleton.getInstance();
        System.out.println(obj.getInstance().toString());
    }
}
Output：
Singleton@30f39991
</code></pre>
<ul>
<li>
<p>方法逻辑</p>
<p>这种方式在每次获取instance之前先进⾏判断，如果instance 为空就new⼀个出来，否则就直接返回已存在的 instance。</p>
</li>
<li>
<p>问题</p>
<p>多线程工作，都运行到 <code>if (instance == null)</code>时，均判断为 <code>null</code>，这些线程就都会创建实例，这样就不是单例了。</p>
<p>我们可以使用 <code>CountDownLatch</code>来控制两个线程同时运行到 <code>getInstance()</code>方法的时刻，代码如下：</p>
<pre><code>public class Main{
    public static void main(String[] args) throws InterruptedException {
        int numberOfThreads = 2;
        CountDownLatch latch = new CountDownLatch(numberOfThreads);
        Runnable runnable = () -&gt; {
            try {
                latch.countDown();
                latch.await(); // 等待其他线程就绪
                Singleton instance = Singleton.getInstance();
              	//打印当前线程和实例信息
                System.out.println("当前线程:" + Thread.currentThread().getName() + " - 当前实例为: " + instance.toString());
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        };

        Thread[] threads = new Thread[numberOfThreads];
        for (int i = 0; i &lt; numberOfThreads; i++) {
            threads[i] = new Thread(runnable);
            threads[i].start();
        }

        for (Thread thread : threads) {
          thread.join();
        }
    }

}
output:
case1
当前线程为: Thread-1 - 当前实例为: Singleton@25e4c956
当前线程为: Thread-0 - 当前实例为: Singleton@3e483bf7
case2
当前线程为: Thread-0 - 当前实例为: Singleton@726166f6
当前线程为: Thread-1 - 当前实例为: Singleton@726166f6
</code></pre>
<p>这里我们稍微讲一下CountDownLatch关键字，它的作用是允许一个或多个线程等待其他线程完成操作。主要方法如下（Java21），省略实现细节：</p>
<pre><code>public class CountDownLatch {
    private final Sync sync;
    // CountDownLatch构造函数，接收一个count参数，创建一个Sync对象
    public CountDownLatch(int count) {}
  	// 等待直到计数器减为零
    public void await() throws InterruptedException {}
  	// 在指定的超时时间内等待，直到计数器减为零
    public boolean await(long timeout, TimeUnit unit) throws InterruptedException {}
    // 计数器减一
  	public void countDown() {}
    public long getCount() {}
    public String toString() {}
  	// 内部类Sync，继承自AbstractQueuedSynchronizer，用于控制计数器
    private static final class Sync extends AbstractQueuedSynchronizer {
        private static final long serialVersionUID = 4982264981922014374L;
        Sync(int count) {}
        int getCount() {}
        protected int tryAcquireShared(int acquires) {}
        protected boolean tryReleaseShared(int releases) {}
    }
}

</code></pre>
<p>我们可以发现，这个类内部使用了同步器 <code>Sync</code>，它继承自 <code>AbstractQueuedSynchronizer</code>。受限于篇幅，AQS之后再进行分析。</p>
<p>在上述控制两个线程同时运行到 <code>getInstance()</code>的代码中，我们使用：</p>
<pre><code>latch.countDown();//计数器减一，表示当前线程已经就绪
latch.await(); // 让当前线程等待，直到计数器归零
</code></pre>
<p>使得所有线程在开始执行实例获取操作之前都准备就绪，让两个线程同时开始获取单例。</p>
<p><em>PS.操作系统的线程调度、硬件资源和其他系统因素可能会导致微小的时间差。这些微小的差异可能导致在实际执行中出现极短的时间间隔，因此线程的启动并不会严格同时，<strong>因此会出现不同的Output</strong>。</em></p>
</li>
<li>
<p>解决</p>
<p>解决也很容易想到，我们可以加一个 <code>synchronized</code>关键字在 <code>getInstance</code>方法上，我们来到第二个版本的单例实现。</p>
</li>
</ul>
<h4>实现2（使用synchronized）</h4>
<pre><code>//version2
class Singleton {
    private static Singleton instance;
    //构造器私有防止被外部类调用
    private Singleton() {}
    public static synchronized Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}
Output:
当前线程为: Thread-1 - 当前实例为: Singleton@46bd3fc9
当前线程为: Thread-0 - 当前实例为: Singleton@46bd3fc9
</code></pre>
<ul>
<li>
<p>我们可以成功获得同一个单例。</p>
</li>
<li>
<p>问题</p>
<p>每次调用 <code>getInstance()</code> 方法都需要获得锁，即使实例已经创建，后续的线程仍然会进入同步块，造成了线程阻塞和性能损耗。</p>
</li>
<li>
<p>解决</p>
<p>我们在方法上加锁导致锁的粒度太大了，我们可以缩小锁的粒度，在方法里面加锁，即得到第三个版本的单例实现，也称为双重检查（Double-Check)。</p>
</li>
</ul>
<h4>实现3：双重检查（Double-Check Lock)</h4>
<pre><code>//version3
class Singleton {
    private static Singleton instance;
    private Singleton() {}
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}
</code></pre>
<ul>
<li>
<p>逻辑</p>
<ul>
<li>第一个 <code>if (instance == null)</code>为了提高性能，避免实例已经创建的情况下获得锁</li>
<li>第二个 <code>if (instance == null)</code>确保在即使多个线程同时调用 <code>getInstance()</code>时，只有一个线程创建实例，和version2的作用一样</li>
</ul>
</li>
<li>
<p>问题</p>
<p>首先说明，这种方法在编译器的优化下可能会发生指令的重排序，从而导致线程不安全的情况出现。</p>
<p>我们首先来看下JVM在 <code>singleton = new Singleton()</code>做了什么事情：</p>
<ol>
<li>在堆内存中，给 singleton 分配内存</li>
<li>调⽤ Singleton 的构造函数来初始化成员 变量，形成实例</li>
<li>将singleton对象指向分配的内存空间（完成后此时singleton非null）</li>
</ol>
<p>那么就会存在问题，因为JVM编译器存在指令重排的优化，因此上述的2，3 顺序不能保证。我们可以试想线程A执行初始化时是1-3-2这种情况，在3已经执行完毕，2未执行之前，被线程B抢占了。导致的结果就是，此时线程A得到的是一个 <strong>未初始化但非NULL</strong>的实例，线程B判断instance非null，直接返回，导致错误。</p>
<p>关键点在于： <strong>线程A对instance的写操作没有完成，线程B就执行了读操作</strong></p>
<p>由此我们引出使用volatile关键词的第四个版本</p>
</li>
</ul>
<h4>实现4（使用volatile）</h4>
<pre><code>class Singleton {
    private static volatile Singleton instance;
    //构造器私有防止被外部类调用
    private Singleton() {}
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}
</code></pre>
<p>相比于实现三，只是给 <code>instance</code>的声明加上了 <code>volatile</code>关键字。</p>
<p>我们先来讲下 <code>volatile</code>关键字</p>
<h5>volatile</h5>
<p><strong>volatile关键字有两个作用</strong></p>
<ol>
<li>
<p>保证可见性</p>
<p>当一个变量被声明为 <code>volatile</code> 时，这意味着当一个线程修改了这个变量的值，这个新值会立即被其他线程看到，从而确保了多线程之间对该变量的访问是可见的。这是因为写入 <code>volatile</code> 变量的操作会导致缓存中的数据被刷新到主内存，以确保其他线程可以读取到最新的值。</p>
</li>
<li>
<p>禁止指令重排序</p>
<p><code>volatile</code> 变量的读写操作会禁止编译器和运行时环境对这些操作进行重排序。这可以确保指令不会被乱序执行，从而保证了操作的有序性。</p>
</li>
</ol>
<p>还有需要注意的一点是，volatile<strong>不能</strong>保证完全的原子性，只能保证单次的读/写操作具有原子性。</p>
<p><strong>volatile的实现原理</strong></p>
<ol>
<li>
<p>volatile 变量的内存可见性是基于内存屏障(Memory Barrier)实现。MM 为了保证在不同的编译器和 CPU 上有相同的结果，通过插入特定类型的内存屏障来禁止+ 特定类型的编译器重排序和处理器重排序，插入一条内存屏障会告诉编译器和 CPU：不管什么指令都不能和这条 Memory Barrier 指令重排序。</p>
</li>
<li>
<p>而volatile是使用happens-before原则来保证有序性实现的，原则如下：</p>
<p>对一个 volatile 域的写，happens-before 于任意后续对这个 volatile 域的读。</p>
</li>
</ol>
<p>具体到本例而言，volatile阻⽌的不 <code>singleton = new Singleton()</code>这句话内部[1-2-3]的指令重排，⽽是保证了在⼀个写操作（[1-2-3]） 完成之前，不会调⽤读操作 <code>if (instance == null)</code>。</p>
<p>在查询相关资料时，发现了使用ThreadLocal来修正DCL问题的一种思路：</p>
<h4>实现5：ThreadLocal</h4>
<p>//TODO</p>
<p>至此，我们得到了比较完整的Java的懒汉式单例模式的实现。我们再来看看饿汉式的实现：</p>
<h3>饿汉式</h3>
<p>饿汉式在类加载时就创建并初始化了单例对象。</p>
<pre><code>public class Singleton {
    // 在类加载时就创建并初始化单例对象
    private static final Singleton instance = new Singleton();

    // 私有化构造函数，防止外部实例化
    private Singleton() {}

    // 提供获取实例的静态方法
    public static Singleton getInstance() {
        return instance;
    }
}
</code></pre>
<ul>
<li>
<p>逻辑</p>
<p>为什么把 <code>instance</code>声明为 <code>static final</code>就可以表示为单例呢？</p>
<p>我们先来回顾一下Java类的生命周期：</p>
<ol>
<li>
<p><strong>加载</strong></p>
<p>jvm需要完成：</p>
<ol>
<li>通过类的权限定名获得类的字节码文件</li>
<li>把字节码文件中的静态存储结构转换为方法区的运行时数据结构</li>
<li>在Java堆中生成一个代表这个类的java.lang.Class对象，作为对方法区中这些数据的访问入口。</li>
</ol>
</li>
<li>
<p><strong>链接</strong></p>
<ol>
<li>
<p>验证</p>
</li>
<li>
<p>准备</p>
<p>为类的静态变量分配内存，并将其初始化为默认值。</p>
<p><strong>注意</strong>：</p>
<ul>
<li>仅包括静态变量(<code>static</code>)，实例变量会在对象实例化时随着对象一块分配在Java堆中。</li>
<li>初始值通常情况下是数据类型默认的零值，非显式赋予的值</li>
</ul>
</li>
<li>
<p>解析</p>
<p>常量池内符号引用转换为直接引用，从而确定类、字段、方法等在内存中的具体位置和地址。</p>
</li>
</ol>
</li>
<li>
<p><strong>初始化</strong></p>
<p>主要对静态变量进行初始化</p>
</li>
</ol>
<p>由此我们可以得知，被声明为 <code>static final</code>的instance在类加载的准备阶段就已经被赋予了指定的值，它的初始化是在类加载的链接阶段完成。由于类加载过程是线程安全的，所以静态常量的赋值也是<strong>线程安全</strong>的；同时由于其为 <strong>常量</strong>，在整个程序运行过程中保持不变。可以保证单例的<strong>唯一性</strong>，因为常量在赋值后无法再次修改。</p>
</li>
<li>
<p>问题</p>
<p>对于饿汉式单例，来讲上述写法即为标准写法，饿汉式单例的问题不是写法上的问题，而是饿汉式既有的问题。</p>
<p>饿汉式单例模式的缺点是可能会造成资源浪费，因为无论是否使用，实例都会在类加载时创建，如果这个实例很大或者初始化比较复杂，可能会影响应用程序的启动速度和内存消耗。</p>
</li>
</ul>
<p>此外我们还有静态内部类，枚举类等的方法实现单例模式</p>
<h3>静态内部类</h3>
<pre><code>public class Singleton {
    private Singleton() {
        // 私有化构造函数，防止外部直接实例化
    }

    // 静态内部类
    private static class SingletonHolder {
        private static final Singleton INSTANCE = new Singleton();
    }

    // 公共方法获取单例实例
    public static Singleton getInstance() {
        return SingletonHolder.INSTANCE;
    }
}

</code></pre>
<ul>
<li>
<p>逻辑</p>
<p><code>SingletonHolder</code> 类是 <code>Singleton</code> 类的静态内部类。静态内部类在 <code>Singleton</code> 类加载时就会被初始化，并创建 <code>INSTANCE</code> 变量。于 <code>Singleton</code> 类的构造函数私有化，因此只能在 <code>SingletonHolder</code> 类中创建 <code>Singleton</code> 类的实例。</p>
<p>因此，在 <code>getInstance()</code> 方法被调用之前，<code>INSTANCE</code> 变量已经被初始化为 <code>Singleton</code> 类的实例，因此不会发生竞争。</p>
<p>具体来说，当多个线程同时调用 <code>getInstance()</code> 方法时，会发生以下情况：</p>
<ol>
<li>第一个线程会进入 <code>SingletonHolder</code> 类的初始化代码块，并创建 <code>Singleton</code> 类的实例，并将其赋值给 <code>INSTANCE</code> 变量。</li>
<li>其他线程在进入 <code>getInstance()</code> 方法时，会发现 <code>INSTANCE</code> 变量已经被初始化，因此不会再进入 <code>SingletonHolder</code> 类的初始化代码块，而是直接从 <code>INSTANCE</code> 变量中获取 <code>Singleton</code> 类的实例。</li>
</ol>
<p>因此，只有一个线程会进入 <code>SingletonHolder</code> 类的初始化代码块，从而保证了线程安全。</p>
</li>
</ul>
<h3>枚举类</h3>
<pre><code>public enum Singleton {
    INSTANCE; // 枚举类型实例

    // 可以在这里添加其他方法或属性
}

</code></pre>
<ul>
<li>
<p>逻辑</p>
<p>枚举类型在编译器生成的 Java 代码中会被转换成类，而枚举值本身会被转换成常量，在类加载的过程中，这些常量会被初始化为枚举类型的实例，而且这个过程是线程安全的。因此，无论何时何地，当你引用枚举的某个值时，你得到的都是相同的实例。</p>
<p>上述代码会被编译器转化为类似如下代码：</p>
<pre><code>public final class Singleton extends Enum&lt;Singleton&gt; {
    public static final Singleton INSTANCE = new Singleton();
    // 其他枚举相关代码
}
</code></pre>
<p>枚举类实现单例模式也是在类加载时就创建单例实例，因此也是饿汉式实现方式。</p>
</li>
</ul>
<p>总结一下，单例模式是一种确保类只有一个实例并提供全局访问点的设计模式。在Java中，我们可以使用多种方法实现单例，包括懒汉式、饿汉式、静态内部类和枚举类等。每种实现方式都有其优缺点，需要根据具体需求来选择适合的方式。懒汉式可能存在线程安全问题，需要额外的同步机制来解决；而饿汉式在类加载时就创建实例，可能会带来资源浪费。静态内部类利用类加载机制实现了线程安全和懒加载，而枚举类则通过枚举的特性天然地保证了单例。选择单例实现方式时，需综合考虑线程安全性、资源消耗以及实现复杂度等因素，以便在特定场景中找到最合适的方法。</p>
]]></content>
        <author>
            <name>eryi</name>
            <uri>https://eryi.dev/</uri>
        </author>
        <published>2023-12-10T00:00:00.000Z</published>
    </entry>
</feed>