<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="/rss/atom-styles.xsl"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>JunBlog</title>
  <subtitle>个人博客，记录阅读、技术与日常。</subtitle>
  <link href="https://junaoe.dev/atom.xml" rel="self" type="application/atom+xml"/>
  <link href="https://junaoe.dev" rel="alternate" type="text/html"/>
  <updated>2026-10-15T13:03:21.256Z</updated>
  <language>zh</language>
  <id>https://junaoe.dev/</id>
  <author>
    <name>junaoe</name>
    <uri>https://junaoe.dev</uri>
  </author>
  <generator uri="https://github.com/Dnzzk2/Litos" version="5.0">Astro Litos Theme</generator>
  <rights>Copyright © 2026 junaoe</rights>
  
  <entry>
    <title>上线了，给自己的第一封信</title>
    <link href="https://junaoe.dev/posts/blog-launched" rel="alternate" type="text/html"/>
    <id>https://junaoe.dev/posts/blog-launched</id>
    <updated>2026-10-15T00:00:00.000Z</updated>
    <published>2026-10-15T00:00:00.000Z</published>
    <author>
      <name>junaoe</name>
    </author>
    <summary type="text">博客上线的这个晚上，写给自己的一封信。记录搭博客这件事，以及为什么要写下去。</summary>
    <content type="html"><![CDATA[
<p>写于博客上线的这个晚上。</p>
<p>其实最早想搭这个博客的时候，我自己都没当回事。那会儿大概是看到别人的技术博客，心里冒出一句：“我是不是也可以做一个？” 然后第二天醒来该干嘛干嘛，这个念头就沉到了水底。</p>
<p>再后来它浮起来，是因为了解到个人博客基本没什么费用，我就开始准备了。</p>
<hr />
<h2>搭博客这件事</h2>
<p>我严重低估了它的难度。</p>
<p>不是技术上的难度——虽然我确实是新手但因为有了 ai ——而是心理上的。我以为把页面写出来、跑起来，就结束了。其实从”有一个想法”到”网页设计”，中间隔着很多个我在沙发上发呆的下午。</p>
<p>我反复改过样式、推翻过结构、结果总是不尽如人意。有一段时间我甚至怀疑自己是否要纠结博客的样式，总是想法太多要不要简单解决这件事——明明只是想写点东西，为什么要过于关注页面长什么样子啊？</p>
<p>因为这个事，我拖了很久，看过很多人的博客。直到今天，才完成搭台子。我现在搭的这个博客，用了别人的主题，希望它可以陪我一段时间写点东西。</p>
<hr />
<h2>我不会给这个博客定一个明确的主题</h2>
<p>我想写技术踩坑，也想写生活的琐碎；想记下读过的书，也想记下某天的心情比昨天更好一点。这些事情之间没有逻辑关系，但都是我。我不想为了”显得专注”，把自己阉割成一个标签。</p>
<p>能写多少写多少，写得烂也无所谓。重要的不是输出，是保持”在写”这个状态本身。</p>
<hr />
<h2>写到这里的时候是晚上十一点</h2>
<p>窗外的城市还没睡，楼下偶尔有车开过的声音。</p>
<p>我打开浏览器，看到自己写的第一篇文章显示在屏幕上，什么都没说，就那么看了一会儿。这种感觉很难形容——不是骄傲，更像是一种”终于踏实了”的安定。</p>
<p>博客上线了。我也算有了一个可以慢慢填的角落。</p>
<p>剩下的，慢慢来。</p>
<p>——写给未来的自己，希望你还记得今晚这种感觉。</p>]]></content>
    <category term="随笔" />
    <category term="博客" />
  </entry>
  <entry>
    <title>API 设计的原则与实践</title>
    <link href="https://junaoe.dev/posts/api-design" rel="alternate" type="text/html"/>
    <id>https://junaoe.dev/posts/api-design</id>
    <updated>2026-10-14T00:00:00.000Z</updated>
    <published>2026-10-14T00:00:00.000Z</published>
    <author>
      <name>junaoe</name>
    </author>
    <summary type="text">RESTful 不是把动词拼进 URL 就完事了。这篇讲资源建模、HTTP 方法语义、错误格式、分页、幂等性，以及版本管理里那些真正会踩的坑。</summary>
    <content type="html"><![CDATA[
<p>写 API 的人大概都经历过这个阶段：明明按 RESTful 的规范写了，评审时还是被挑出一堆问题。问题往往不在「有没有用 RESTful」，而在<strong>资源建模的方式</strong>和<strong>边界情况的处理</strong>上。</p>
<p>这篇按重要性从高到低讲，前面几条是地基，后面几条是打磨。</p>
<h2>资源建模：先想清楚「东西」，再想「动作」</h2>
<p>最常见的错误是把动词塞进 URL：</p>
<pre><code>❌  POST /createUser
❌  GET  /getUserList?page=1
❌  POST /deleteUser?id=123
</code></pre>
<p>这说明设计者还在用「过程」而不是「资源」思考。正确的做法是先回答一个问题：<strong>系统里有哪些东西？</strong></p>
<p>用户、订单、商品、评论 —— 这些是名词，是资源。然后用 HTTP 方法表达对资源的操作：</p>
<pre><code>POST   /users            创建
GET    /users            列表
GET    /users/123        读取单个
PATCH  /users/123        部分更新
DELETE /users/123        删除
</code></pre>
<p>那「登录」这种动作怎么办？它不是 CRUD 里的操作，<strong>动作可以作为资源存在</strong>：</p>
<pre><code>POST /sessions        登录，创建一个会话
DELETE /sessions/current   注销，销毁当前会话
</code></pre>
<p>用户点「取消订单」也不该是 <code>PUT /orders/123/cancel</code>，而应该是把订单状态作为一个子资源来表达：</p>
<pre><code>POST /orders/123/cancellation    创建一个取消动作
GET  /orders/123/cancellation    查询是否已取消
DELETE /orders/123/cancellation  撤销取消
</code></pre>
<p>这样每个动作都有自己的 URI 和生命周期，比在一个大对象上堆 <code>status</code> 字段清晰得多。</p>
<div><div><div></div><div>TIP</div></div><div><p>一个判断标准：如果同一个名词能搭配 GET/POST/PATCH/DELETE 四种方法，那它大概率是个真正的资源。如果只能用 POST + 某个动作名，那它可能更适合建模成动作资源。</p></div></div>
<h2>HTTP 方法语义不是随便选的</h2>
<p>HTTP 规范对方法的语义有明确定义，选错方法会直接影响缓存、幂等性和重试的安全性。</p>






















































<table><thead><tr><th>方法</th><th>幂等</th><th>有请求体</th><th>成功状态码</th><th>语义</th></tr></thead><tbody><tr><td>GET</td><td>是</td><td>否</td><td>200</td><td>查询，不应有副作用</td></tr><tr><td>HEAD</td><td>是</td><td>否</td><td>200</td><td>只取响应头，常用于探测资源是否存在</td></tr><tr><td>POST</td><td>否</td><td>是</td><td>201 / 200</td><td>创建或执行非幂等动作</td></tr><tr><td>PUT</td><td>是</td><td>是</td><td>200 / 204</td><td>整体替换，要求完整</td></tr><tr><td>PATCH</td><td>否</td><td>是</td><td>200 / 204</td><td>部分更新</td></tr><tr><td>DELETE</td><td>是</td><td>可有</td><td>204</td><td>删除，重复调用结果相同</td></tr></tbody></table>
<p><strong>幂等</strong>是这个表里最重要的概念。幂等的意思是：同一个请求执行一次和执行多次，对服务端状态的影响相同。</p>
<p>为什么重要？因为网络会超时。客户端不知道请求有没有到达服务端，只能重试。</p>
<ul>
<li><code>GET /users/123</code> 重试十次，只是读了十次，没有副作用</li>
<li><code>POST /payments</code> 重试两次，可能扣了两次款 —— 这是事故</li>
</ul>
<p>所以<strong>任何会产生副作用的 POST，都要想办法让它变得可重试安全</strong>，方法在后面讲。</p>
<p>还有一个容易踩的坑：<strong>GET 绝不能有副作用</strong>。很多人图省事，把「删除文章」写成 <code>GET /posts/123/delete</code>，理由是「方便用浏览器访问」。后果是浏览器预加载、爬虫抓取、链接预览都会触发删除。</p>
<h2>错误响应：给调用方能用的信息</h2>
<p>后端返回错误时，最糟的做法是这样：</p>
<pre><code>{
  "success": false,
  "message": "操作失败"
}
</code></pre>
<p>调用方拿这个信息什么都做不了。你至少要告诉他<strong>哪出了问题</strong>、<strong>怎么改</strong>。</p>
<p>一个通用的错误结构：</p>
<pre><code>{
  "error": {
    "code": "INSUFFICIENT_BALANCE",
    "message": "账户余额不足，当前余额 12.50，本次需要 50.00",
    "details": [
      {
        "field": "amount",
        "reason": "must be less than or equal to available balance"
      }
    ],
    "requestId": "01HQ8XK2M9N3P7Q5"
  }
}
</code></pre>
<p>几个设计要点：</p>
<p><strong>1. <code>code</code> 给机器读，<code>message</code> 给人读</strong></p>
<p>前端要判断「余额不足」来弹窗，用的是 <code>code</code>；直接显示给用户看的是 <code>message</code>。两者不能混用 —— 让前端去匹配中文文案，是把埋雷留给下一个人。</p>
<p><strong>2. HTTP 状态码要真的用起来</strong></p>









































<table><thead><tr><th>状态码</th><th>场景</th></tr></thead><tbody><tr><td>400</td><td>请求格式错误、参数校验失败</td></tr><tr><td>401</td><td>没登录或凭证失效</td></tr><tr><td>403</td><td>登录了但没权限</td></tr><tr><td>404</td><td>资源不存在</td></tr><tr><td>409</td><td>状态冲突，比如重复下单、版本号不匹配</td></tr><tr><td>422</td><td>语法对但语义不对，比如邮箱格式</td></tr><tr><td>429</td><td>触发限流</td></tr><tr><td>500</td><td>服务端未预期的错误</td></tr></tbody></table>
<p><strong>403 和 404 的区别</strong>经常被搞混。要看产品需求：如果一个用户不该看到某条私有资源存在，返回 403（我承认它存在但你不能看）；如果连存在都不该暴露，返回 404（装作没有这回事）。</p>
<p><strong>3. 带上 requestId</strong></p>
<p>线上排查问题的时候，最花时间的往往不是定位故障，而是找到「用户那次请求对应的日志」。每个请求生成唯一 ID，响应里带上，日志里也带上，出问题时让用户提供这个 ID，就能精确捞出整条链路。</p>
<h2>分页：别用 offset</h2>
<p>offset 分页的写法是 <code>LIMIT 20 OFFSET 100</code>，意思是「跳过前 100 条，取 20 条」。它有个致命问题：<strong>数据变了，分页就会错乱或漏数据</strong>。</p>
<p>用户翻到第 3 页时，有人插入了两条新记录，那么第 3 页的第 1 条会和第 2 页重复，有一条被跳过。删除数据时同理。</p>
<p>游标分页（keyset pagination）解决了这个问题：<strong>用上一页最后一条记录的排序值作为起点</strong>。</p>
<pre><code>-- 第一次请求
SELECT id, title, created_at
FROM posts
WHERE status = 'published'
ORDER BY created_at DESC, id DESC
LIMIT 20;

-- 下一页，把上一页最后的 created_at 和 id 传回来
SELECT id, title, created_at
FROM posts
WHERE status = 'published'
  AND (created_at, id) &lt; ('2026-10-01T10:00:00Z', 8451)
ORDER BY created_at DESC, id DESC
LIMIT 20;
</code></pre>
<p><code>WHERE (created_at, id) &lt; (...)</code> 这种写法叫<strong>行值比较</strong>，PostgreSQL 和 MySQL 都支持。它能正常工作有个前提：<code>(created_at, id)</code> 上必须有索引。</p>
<div><div><div></div><div>NOTE</div></div><div><p>加上 <code>id</code> 作为第二排序键是为了处理 <code>created_at</code> 重复的情况。如果只用时间戳排序，而两篇文章时间戳一模一样，其中一篇就会永远翻不到。</p></div></div>
<p>offset 分页什么时候还能用？<strong>数据不会变、且需要跳页</strong>的场景，比如后台管理系统里的表格 —— 用户要能直接跳到第 50 页。这两种分页不冲突，按场景选。</p>
<h2>幂等性：让 POST 可以安全重试</h2>
<p>回到前面那个问题：网络超时时，客户端怎么知道要不要重试？</p>
<p>答案是<strong>幂等键（idempotency key）</strong>。客户端为每个「意图」生成一个唯一 ID，重试时用同一个 ID，服务端记录这个 ID 是否已经处理过。</p>
<pre><code>import uuid
from redis import Redis

redis = Redis()


def handle_payment(idem_key: str, payload: dict) -&gt; dict:
    # 标记处理中，并设置过期时间
    lock = redis.set(f"idem:{idem_key}", "processing", nx=True, ex=300)

    if lock is None:
        # 已经在处理了，返回之前的结果
        cached = redis.get(f"idem_result:{idem_key}")
        if cached:
            return json.loads(cached)
        raise ConflictError("请求正在处理中，请稍后重试")

    try:
        result = do_payment(payload)

        # 保存结果，后续重复请求直接返回
        redis.setex(f"idem_result:{idem_key}", 86400, json.dumps(result))
        return result
    except Exception:
        # 失败则删除标记，允许客户端重试
        redis.delete(f"idem:{idem_key}")
        raise
</code></pre>
<p>三个关键点：</p>
<ol>
<li><strong>幂等键由客户端生成</strong>，服务端只是记录它。服务端生成的话，重试时会生成新的键，起不到作用。</li>
<li><strong>必须保存处理结果</strong>，不只是记录「处理过」。否则重试时你不知道上一次的结果是什么，只能报错说「请查询」—— 体验很差。</li>
<li><strong>过期时间要合理</strong>。通常比客户端的重试窗口长一点，24 小时是常见选择。</li>
</ol>
<p>这套模式对<strong>支付、下单、发短信、扣库存</strong>这类操作特别重要。</p>
<h2>版本管理：别急着上</h2>
<p>看到 <code>/api/v1/</code> 就想讨论怎么演进，但多数项目活不到需要第二个版本。</p>
<p>真正务实的分级：</p>
<p><strong>路径版本</strong>（<code>/api/v1/users</code>）—— 简单粗暴，但一旦发出去就很难删，最终会有 <code>/v1</code> <code>/v2</code> <code>/v3</code> 并存的局面。</p>
<p><strong>请求头版本</strong>（<code>Accept: application/vnd.app.v2+json</code>）—— 干净，但调试麻烦，很多人不会。</p>
<p><strong>字段级兼容</strong> —— 多数问题的正确答案。<strong>加字段是向后兼容的，改字段类型和删字段不是。</strong></p>
<pre><code>// v1 返回
{
  "name": "张三",
  "age": 30
}

// 改成 v2 —— 破坏兼容
{
  "fullName": "张三",
  "age": "30"    // number 变 string，老客户端会崩
}

// 更好的做法
{
  "name": "张三",
  "fullName": "张三",   // 新增，老客户端忽略
  "age": 30,
  "ageStr": "30"        // 需要字符串格式时另开字段
}
</code></pre>
<p>还有几个常被忽略的破坏性变更：</p>
<ul>
<li>把可选参数改成必填</li>
<li>把数组改成对象（<code>[1,2,3]</code> → <code>{"a":1}</code>）</li>
<li>把字段名改掉（哪怕只是改了大小写）</li>
<li>收紧校验规则（原来允许的输入现在报 400）</li>
<li>改变枚举值的含义（比如 <code>status: 1</code> 原来表示「已完成」，改成了「已取消」）</li>
</ul>
<h2>字段命名的细节</h2>
<p>这部分没什么技术含量，但踩坑的人最多。</p>
<p><strong>时间统一用 UTC 存储</strong>，展示时转本地时区。</p>
<pre><code>{
  "createdAt": "2026-10-14T08:30:00Z",     // ✅ ISO 8601，带 Z
  "createdAt": 1759823400,                 // ✅ Unix 时间戳，机器友好
  "createdAt": "2026/10/14 16:30:00",      // ❌ 时区不明，解析器各异
  "createdAt": "Wed Oct 14 2026"           // ❌ 不要用这种
}
</code></pre>
<p><strong>布尔值用 <code>is</code> / <code>has</code> 前缀</strong>，或者干脆用正反义明确的词：<code>isActive</code> 比 <code>active</code> 好，因为 <code>active</code> 到底表示「启用」还是「活跃」有歧义。</p>
<p><strong>金额用最小单位整数</strong>，别用浮点数：</p>
<pre><code>{ "price": 19.99 }     // ❌ 浮点数有精度问题
{ "price": 1999, "currency": "CNY" }   // ✅ 分为单位
</code></pre>
<p>这不是洁癖。<code>0.1 + 0.2 !== 0.3</code> 在任何语言都成立，而对账时差一分钱就是事故。存 <code>19.99</code> 到数据库、用浮点数计算、再取整 —— 每一环都在引入误差。</p>
<h2>请求校验：信任边界</h2>
<p>任何来自客户端的数据都要当成不可信输入。原则是<strong>在系统边界校验一次，之后内部代码信任它</strong>。</p>
<pre><code>from pydantic import BaseModel, Field, field_validator


class CreateUserRequest(BaseModel):
    email: str = Field(max_length=255)
    age: int = Field(ge=0, le=150)
    nickname: str = Field(min_length=1, max_length=30)

    @field_validator('email')
    @classmethod
    def check_email(cls, v: str) -&gt; str:
        if '@' not in v or '.' not in v.split('@')[-1]:
            raise ValueError('邮箱格式不正确')
        return v.lower().strip()

    @field_validator('nickname')
    @classmethod
    def no_control_chars(cls, v: str) -&gt; str:
        if any(ord(c) &lt; 32 for c in v):
            raise ValueError('昵称不能包含控制字符')
        return v
</code></pre>
<p>注意 <code>no_control_chars</code> 这个校验器 —— 它防的是控制字符，而不只是空格。如果不拦，<code>\n</code>、<code>\x00</code>、零宽字符就能进数据库，在某些前端和日志系统里造成显示错乱甚至注入。</p>
<p><strong>SQL 注入</strong>的防线是参数化查询，不是字符串拼接：</p>
<pre><code># ❌ 拼接字符串
cursor.execute(f"SELECT * FROM users WHERE name = '{name}'")

# ✅ 参数化
cursor.execute("SELECT * FROM users WHERE name = %s", [name])
</code></pre>
<p>同理，<strong>富文本要在渲染前做 HTML 消毒</strong>。允许 <code>&lt;script&gt;</code> 通过就等于让用户在你的域名下执行任意 JavaScript。</p>
<h2>速率限制</h2>
<p>没有限流的接口，迟早会被爬一遍或者被 DDoS 拖垮。</p>
<p>常见的几种算法：</p>
<p><strong>固定窗口</strong>（最简单）—— 每分钟计数，60 秒清零。问题是窗口边界会被打两次流量。</p>
<p><strong>滑动窗口</strong> —— 记录每个请求的时间戳，统计最近 N 秒内的数量。准确但存储成本高。</p>
<p><strong>令牌桶</strong>（推荐）—— 桶里放令牌，以固定速率放，取令牌才能通过，桶可设置容量允许突发流量。</p>
<pre><code>class TokenBucket:
    def __init__(self, capacity: int, refill_rate: float):
        self.capacity = capacity
        self.refill_rate = refill_rate  # 每秒放几个令牌
        self.tokens = capacity
        self.last = time.monotonic()

    def allow(self) -&gt; bool:
        now = time.monotonic()
        # 先按时间流逝补充令牌
        self.tokens = min(
            self.capacity,
            self.tokens + (now - self.last) * self.refill_rate
        )
        self.last = now

        if self.tokens &gt;= 1:
            self.tokens -= 1
            return True
        return False
</code></pre>
<p>令牌桶的好处是<strong>允许合理的突发</strong>（比如页面加载瞬间发 10 个请求），同时限制长期平均速率 —— 这符合真实用户行为。</p>
<p>限流触发时返回 <strong>429</strong>，并带上 <code>Retry-After</code> 头告诉客户端多久后重试：</p>
<pre><code>HTTP/1.1 429 Too Many Requests
Retry-After: 30
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1759823430
</code></pre>
<h2>长任务：用异步接口</h2>
<p>上传视频、生成报表、导出数据这类操作，几十秒到几小时都可能。HTTP 请求挂在那里等是行不通的 —— 网关有超时，用户关页面就断了。</p>
<p>标准做法是<strong>返回 202 Accepted，让客户端轮询或订阅回调</strong>：</p>
<pre><code>POST /exports
→ 202 Accepted
  {
    "jobId": "exp_01HQ8X...",
    "statusUrl": "/exports/exp_01HQ8X..."
  }

GET /exports/exp_01HQ8X...
→ 200 OK
  {
    "status": "processing",
    "progress": 0.42
  }
</code></pre>
<p>轮询要带退避间隔，不要固定间隔 —— 否则服务端压力反而更大：</p>
<pre><code>delay = 1.0
while True:
    resp = get_status(job_id)
    if resp['status'] in ('completed', 'failed'):
        break
    sleep(delay + random.uniform(0, delay))  # 抖动，避免同时打过来
    delay = min(delay * 1.5, 30)             # 指数退避，封顶 30 秒
</code></pre>
<h2>一份检查清单</h2>
<p>写完一个 API，问自己这些问题：</p>
<ul>
<li> URL 里有没有动词？动作能否建模成资源？</li>
<li> 有副作用的操作，能安全重试吗？</li>
<li> 错误响应里有稳定的 <code>code</code> 和 <code>requestId</code>？</li>
<li> 分页在数据变动时会不会错乱？</li>
<li> 新增字段向后兼容吗？有没有意外收紧校验？</li>
<li> 时间格式带时区了吗？金额用整数了吗？</li>
<li> 所有用户输入都校验了？SQL 都参数化了？</li>
<li> 有速率限制吗？超限返回 429 了吗？</li>
<li> 长任务会不会超过网关超时？</li>
<li> 时间字段、金额、布尔值的命名是否无歧义？</li>
</ul>
<p>这十条过一遍，API 的质量基本就稳了。</p>]]></content>
    <category term="后端" />
    <category term="API" />
  </entry>
  <entry>
    <title>数据库选型与索引设计</title>
    <link href="https://junaoe.dev/posts/database-and-index" rel="alternate" type="text/html"/>
    <id>https://junaoe.dev/posts/database-and-index</id>
    <updated>2026-10-13T00:00:00.000Z</updated>
    <published>2026-10-13T00:00:00.000Z</published>
    <author>
      <name>junaoe</name>
    </author>
    <summary type="text">关系型、文档型、KV 存储怎么选；索引为什么快；最左前缀、覆盖索引、回表这些概念到底怎么回事。这篇把后端最容易踩的性能坑讲透。</summary>
    <content type="html"><![CDATA[
<p>数据库是后端性能问题的主要来源。业务代码写得再优雅，一条没走索引的查询就能让整站卡住。</p>
<p>这篇分三部分：选型、索引原理、实际排查。</p>
<h2>选型：先问数据长什么样</h2>
<p>技术选型的常见错误，是从「哪个数据库更先进」开始问。正确顺序是先搞清楚数据的形状。</p>
<h3>关系型数据库（PostgreSQL、MySQL）</h3>
<p>适合<strong>结构稳定、关系复杂、需要事务</strong>的场景。</p>
<p>PostgreSQL 尤其被低估，它能当关系库用，也能当文档库、时序库、地理库用，很多团队到后期才发现当初不需要引入额外的数据库。</p>
<p><strong>什么时候该选它</strong>：订单、用户、账目 —— 任何「钱和数量算错要出事」的地方。ACID 事务不是可选项。</p>
<h3>文档数据库（MongoDB）</h3>
<p>适合<strong>结构多变、按文档整体读写</strong>的场景。</p>
<p>典型的成功案例是日志和事件存储：字段经常变、几乎只按时间范围查、很少做多表关联。</p>
<p><strong>失败案例</strong>是把关系型的数据塞进去然后写一堆 <code>$lookup</code> 聚合 —— 文档库没帮你省事，反而失去了关系库的能力。</p>
<h3>KV 存储（Redis、etcd）</h3>
<p>两类用途完全不同：</p>
<ul>
<li><strong>Redis</strong> 是内存缓存，快的代价是数据在特定情况下会丢</li>
<li><strong>etcd</strong> 是分布式协调存储，强一致、持久化，但吞吐远不如 Redis</li>
</ul>
<p>别把 etcd 当缓存用，也别把 Redis 当共识存储用。</p>
<h3>一个务实的原则</h3>
<p><strong>能用一种数据库解决的，不要引入第二种。</strong> 每多一种数据库，就多一份运维成本、一份备份策略、一份监控、一套故障预案。很多团队同时维护 MySQL、Redis、MongoDB、ES，最后 P99 延迟的问题排查了半天，发现是某个冷门的数据源没连接池上限。</p>
<p>先把关系库用好，它能承接绝大多数业务。</p>
<h2>B+ 树：索引为什么快</h2>
<p>理解索引，先理解数据结构。</p>
<p>关系型数据库的索引几乎都是 B+ 树。这个结构的关键特性是：</p>
<pre><code>                [50]
               /    \
        [10,20,30]   [70,80,90]     ← 非叶子节点只存索引
          /   \        /   \
      [1..9] [21..29] [71..79] [91..99]   ← 叶子节点存数据
        ↖ 链表相连，可以顺序扫描 ↗
</code></pre>
<p>对比二叉查找树：</p>

























<table><thead><tr><th></th><th>B+ 树</th><th>二叉查找树</th></tr></thead><tbody><tr><td>树高</td><td>百万级数据约 3–4 层</td><td>可能退化到上千层</td></tr><tr><td>磁盘 IO</td><td>每层 1 次</td><td>每层 1 次</td></tr><tr><td>范围查询</td><td>叶子链表直接扫</td><td>需要回溯整棵树</td></tr></tbody></table>
<p><strong>B+ 树的层数之所以低</strong>，是因为它的分支因子极大（几百到上千）。假设每页能放 500 个索引项，一棵三层的 B+ 树能容纳 <span><span>5003=1.25×108500^3 = 1.25 \times 10^8</span><span><span><span></span><span>50</span><span><span>0</span><span><span><span><span><span><span></span><span><span>3</span></span></span></span></span></span></span></span><span></span><span>=</span><span></span></span><span><span></span><span>1.25</span><span></span><span>×</span><span></span></span><span><span></span><span>1</span><span><span>0</span><span><span><span><span><span><span></span><span><span>8</span></span></span></span></span></span></span></span></span></span></span> 条记录。</p>
<p>查一条记录：读一次根节点 → 读一次中间节点 → 读一次叶子节点。<strong>固定 3 次磁盘 IO</strong>，与数据量无关。这就是为什么索引能把千万级的查询压到几毫秒。</p>
<div><div><div></div><div>NOTE</div></div><div><p>树高会随数据量缓慢增长，但增长的是对数。数据量翻 100 倍，树高只增加 1 层左右。</p></div></div>
<h2>最左前缀原则</h2>
<p>这是复合索引里最容易搞错的规则。</p>
<p>假设有复合索引：</p>
<pre><code>CREATE INDEX idx_user_time ON orders (user_id, created_at);
</code></pre>
<p><strong>索引在数据库内部是这个顺序排列的：</strong></p>
<pre><code>(user_id=1, created_at=2024-01-01)
(user_id=1, created_at=2024-06-01)
(user_id=1, created_at=2025-01-01)
(user_id=2, created_at=2024-03-01)
(user_id=2, created_at=2024-09-01)
(user_id=3, created_at=2025-02-01)
...
</code></pre>
<p>注意：<strong>先按 <code>user_id</code> 排，同一个 <code>user_id</code> 内部再按 <code>created_at</code> 排。</strong></p>
<p>于是：</p>
<pre><code>-- ✅ 用上了：user_id 是索引第一列，能定位
WHERE user_id = 1 AND created_at &gt; '2025-01-01'

-- ✅ 用上了：只查第一列也能用
WHERE user_id = 1

-- ❌ 用不上：created_at 单独查时，索引里是乱序的
WHERE created_at &gt; '2025-01-01'
</code></pre>
<p>第三条为什么不行？因为索引里 <code>created_at</code> 的值是 <code>2024-01-01, 2024-06-01, 2025-01-01, 2024-03-01...</code> —— 跨 <code>user_id</code> 重复且不连续，数据库没法用它做有序查找，只能全表扫描。</p>
<p>规则一句话：<strong>能从索引最左列开始，连续、不间断地用到后面的列</strong>。中间断了，后面就失效。</p>
<pre><code>-- 索引 (a, b, c)
WHERE a = 1 AND c = 3        -- b 没用到，c 用不上
WHERE a = 1 AND b = 2 AND c = 3   -- ✅ 全都用上
WHERE b = 2 AND a = 1 AND c = 3   -- ✅ 顺序不影响（优化器会重排）
</code></pre>
<p>最后一条能用的原因：<strong>SQL 里的书写顺序不影响索引使用</strong>，优化器会自己重排。但<strong>索引定义的列顺序</strong>是有讲究的。</p>
<h3>索引列顺序怎么定</h3>
<p>按「区分度」从高到低排。区分度 = 该列不同值的数量 / 总行数。</p>
<pre><code>-- status 只有 3 个值，区分度极低；user_id 千万人各不相同
-- ❌ 低区分度的列放前面
CREATE INDEX idx_bad ON orders (status, user_id);

-- ✅ 高区分度的列放前面
CREATE INDEX idx_good ON orders (user_id, status);
</code></pre>
<p>不过这条规则有个例外：<strong>等值查询放前面，范围查询放最后</strong>。</p>
<pre><code>-- 查询：WHERE user_id = 1 AND created_at &gt; '2025-01-01' ORDER BY created_at
-- ✅ 等值在前、范围在后，范围列还能顺带排序
CREATE INDEX idx ON orders (user_id, created_at);
</code></pre>
<p>原因是 B+ 树只能对<strong>第一列</strong>做有序定位。从第二列开始，如果前面的列没被完全固定，顺序就没意义了。</p>
<h2>覆盖索引与回表</h2>
<p><strong>回表</strong>：通过索引找到主键值，再拿这个主键去聚簇索引里查完整的行数据。多一次 IO。</p>
<p><strong>覆盖索引</strong>：查询需要的字段全部在索引里，不用回表。</p>
<pre><code>-- 索引只含 (user_id, created_at)

-- 需要回表：还要取 amount
SELECT * FROM orders WHERE user_id = 1 AND created_at &gt; '2025-01-01';

-- 覆盖索引：索引里就够了，不用回表
SELECT user_id, created_at FROM orders WHERE user_id = 1 AND created_at &gt; '2025-01-01';
</code></pre>
<p><code>SELECT *</code> 是回表的最大来源，因为它几乎必然需要索引外的字段。</p>
<p>覆盖索引需要额外存储，宽索引会显著增大数据库体积。<strong>判断标准</strong>：如果查询极热且返回字段少，加宽索引划算；如果是列表页要展示十几个字段，那不如直接回表。</p>
<p>MySQL 和 PostgreSQL 的执行计划里能看到 <code>Using index</code>（覆盖索引）和 <code>Using where</code>（需要额外过滤）。</p>
<h2>索引不是越多越好</h2>
<p>每加一个索引，写入就要多付一次成本。</p>
<pre><code>INSERT INTO orders (user_id, created_at, amount) VALUES (...);
</code></pre>
<p>数据库要维护<strong>每一个</strong>包含被修改列的索引：主键索引、<code>idx_user_time</code>、可能还有 <code>idx_status</code>、<code>idx_merchant</code>…… 每个都是一次 B+ 树的插入和可能的页分裂。</p>
<p><strong>写多的表，索引要克制；读多的表，索引可以多一点。</strong> 典型的写入密集表是日志、事件流、订单流水，这些表上 3 个索引以内通常就够了。</p>
<p>另一个常被忽略的代价：<strong>索引会阻止某些数据库做某些优化</strong>。比如 MySQL 在某些情况下选择全表扫描反而更快，因为顺序读磁盘的效率远高于随机 IO —— 加了不合适的索引，反而把顺序扫描变成了随机查找。</p>
<h2>慢查询排查</h2>
<p>出问题时的标准流程。</p>
<p><strong>第一步：找到慢查询日志</strong></p>
<pre><code># MySQL
slow_query_log = ON
long_query_time = 1        # 超过 1 秒记录
</code></pre>
<p><strong>第二步：用 EXPLAIN 看执行计划</strong></p>
<pre><code>EXPLAIN ANALYZE
SELECT o.id, o.amount, u.name
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.status = 'paid'
  AND o.created_at &gt; '2026-01-01'
ORDER BY o.created_at DESC
LIMIT 100;
</code></pre>
<p>关键看这几列：</p>






























<table><thead><tr><th>字段</th><th>含义</th><th>危险信号</th></tr></thead><tbody><tr><td><code>type</code></td><td>访问类型</td><td><code>ALL</code> 是全表扫描；<code>index</code> 是全索引扫描</td></tr><tr><td><code>key</code></td><td>实际用的索引</td><td><code>NULL</code> 说明没用上索引</td></tr><tr><td><code>rows</code></td><td>预估扫描行数</td><td>和实际表行数同量级 → 索引失效</td></tr><tr><td><code>Extra</code></td><td>额外信息</td><td><code>Using filesort</code> / <code>Using temporary</code> 要警惕</td></tr></tbody></table>
<p><code>Using filesort</code> 意味着排序没走索引，数据库额外创建临时文件排序。数据量小无所谓，百万级以上就是性能杀手。</p>
<p><strong>第三步：对照实际数据量</strong></p>
<p><code>EXPLAIN</code> 给的是<strong>估算</strong>。索引统计信息过期时，估算会离谱地错。</p>
<pre><code>ANALYZE TABLE orders;   -- 更新统计信息
</code></pre>
<p>MySQL 8.0 的 <code>EXPLAIN ANALYZE</code> 会实际执行并给出真实行数，能直接看出估算偏差。</p>
<h3>一个典型场景</h3>
<pre><code>-- 慢：8.2 秒
SELECT * FROM orders
WHERE DATE(created_at) = '2026-10-01'
  AND status = 'paid';
</code></pre>
<p>问题出在 <code>DATE(created_at)</code> —— <strong>对索引列套函数会让索引失效</strong>，因为 B+ 树存的是原始值，而函数计算后的值没法直接匹配。</p>
<p>改写成范围查询：</p>
<pre><code>-- 快：12 毫秒
SELECT * FROM orders
WHERE created_at &gt;= '2026-10-01 00:00:00'
  AND created_at &lt;  '2026-10-02 00:00:00'
  AND status = 'paid';
</code></pre>
<p>这也是为什么日期条件要用<strong>左闭右开</strong>的半开区间：最后那个 <code>&lt; '2026-10-02'</code> 而不是 <code>&lt;= '2026-10-01 23:59:59'</code> —— 后者漏掉了当天最后一秒内带毫秒或微秒的数据。</p>
<p>同理，下面这些写法都会让索引失效：</p>
<pre><code>WHERE YEAR(created_at) = 2026          -- 函数
WHERE created_at LIKE '%10%'           -- 前导通配符
WHERE name = 'abc' COLLATE xxx         -- 隐式类型转换
WHERE status = 1                       -- 列是 varchar 却传数字
</code></pre>
<p><strong>隐式类型转换</strong>特别隐蔽：字段是 <code>VARCHAR</code>，你传了数字 <code>123</code>，数据库为了匹配会把<strong>每一行</strong>的字段转成数字再比较，索引直接失效。Java、Go、Python 这类动态语言不会在编译期拦你。</p>
<h2>事务隔离级别</h2>
<p>四个级别，按并发程度递减、正确性递增。</p>








































<table><thead><tr><th>级别</th><th>脏读</th><th>不可重复读</th><th>幻读</th><th>说明</th></tr></thead><tbody><tr><td>Read Uncommitted</td><td>可能</td><td>可能</td><td>可能</td><td>基本不用</td></tr><tr><td>Read Committed</td><td>否</td><td>可能</td><td>可能</td><td>PostgreSQL 默认</td></tr><tr><td>Repeatable Read</td><td>否</td><td>否</td><td>可能*</td><td>MySQL 默认</td></tr><tr><td>Serializable</td><td>否</td><td>否</td><td>否</td><td>完全隔离</td></tr></tbody></table>
<p>* MySQL 的 InnoDB 在 RR 级别下通过间隙锁基本避免了幻读。</p>
<p>三个经典问题：</p>
<ul>
<li><strong>脏读</strong>：读到别的事务还没提交的数据。对方一回滚，你读到的就是从未存在过的数据。</li>
<li><strong>不可重复读</strong>：同一事务内两次读同一行，结果不同（期间别人改了）。</li>
<li><strong>幻读</strong>：同一事务内两次范围查询，第二次多出了行（期间别人插入了）。</li>
</ul>
<p><strong>Read Committed</strong> 是很多团队的合理选择，它避免了脏读（最严重的问题），锁的持有时间也更短，并发更好。代价是需要应用层保证可重复读的场景（比如先查余额再扣减）用其他手段解决。</p>
<h2>唯一约束应该由数据库保证</h2>
<p>很多系统的「唯一性」是靠应用层先查一遍再插入：</p>
<pre><code># ❌ 并发下会失效
if not db.exists(username=username):
    db.add(User(username=username))
    db.commit()
</code></pre>
<p>两个请求同时通过检查，然后都插入，产生重复。<strong>这个 check-then-act 在并发下必然出问题。</strong></p>
<p>正确做法是让数据库用唯一约束兜底：</p>
<pre><code>CREATE UNIQUE INDEX idx_username ON users (username);
</code></pre>
<pre><code>try:
    db.add(User(username=username))
    db.commit()
except IntegrityError:
    raise ConflictError("用户名已被占用")
</code></pre>
<p><strong>应用层检查可以用来给出友好提示，但不能作为唯一的保障。</strong> 数据库约束是最后一道防线，且不会因为代码改动或并发时序而失效。</p>
<p>同样的道理适用于外键、检查约束、非空约束 —— 能交给数据库的，就别在应用里手写一遍。</p>
<h2>事务里不要做慢操作</h2>
<p>一个事务持有锁的时间越长，并发能力越差。</p>
<pre><code># ❌ 事务里发 HTTP 请求，可能阻塞 3 秒
with db.begin():
    order = create_order(data)
    send_email(order.id)          # 网络 IO
    call_payment_gateway(data)    # 网络 IO
    db.commit()

# ✅ 先提交，再做慢操作
with db.begin():
    order = create_order(data)
    order.set_status('pending')

send_email(order.id)
call_payment_gateway(data)
</code></pre>
<p>上面这个改法还涉及另一个要点：<strong>状态机</strong>。不要让一个事务同时负责「建订单」和「支付」，拆成 <code>pending → paid → shipped</code> 的状态流转，每步独立事务。这样任何一步失败都能安全重试，状态也是可查询的。</p>
<h2>连接池</h2>
<p>每个数据库连接都是重量级资源（内存 + 文件描述符 + TCP 状态），必须复用。</p>
<pre><code>engine = create_engine(
    DATABASE_URL,
    pool_size=20,        # 常驻连接数
    max_overflow=10,     # 峰值时可临时开的连接
    pool_timeout=30,     # 等待连接的超时时间
    pool_recycle=3600,   # 连接存活时间，防止中间设备掐断
)
</code></pre>
<p><strong><code>pool_recycle</code> 容易被忽略。</strong> MySQL 默认 8 小时后主动断开连接（<code>wait_timeout</code>），如果你的连接池里有一条连接已经躺了 9 小时，下一次用它就会报「MySQL server has gone away」。定期回收能避免这个问题 —— 让连接比服务端的超时短就行。</p>
<p>连接池大小的经验值：CPU 核数 × 2 + 有效磁盘数。<strong>不是越大越好</strong>，数据库能同时处理的连接数有上限，连接池超过这个数只会让数据库更慢。</p>
<h2>最后</h2>
<p>排查顺序永远是这个顺序：</p>
<ol>
<li><strong>先看是不是索引问题</strong> —— <code>EXPLAIN</code> 看有没有走索引、<code>rows</code> 扫描了多少行</li>
<li><strong>再看是不是数据量问题</strong> —— 同样的 SQL，数据量小的时候飞快</li>
<li><strong>最后看是不是代码问题</strong> —— N+1 查询、循环里调接口、没批量处理</li>
</ol>
<p><strong>90% 的数据库性能问题在第一步就能定位。</strong> 别一上来就加机器，先看执行计划。</p>]]></content>
    <category term="后端" />
    <category term="数据库" />
  </entry>
  <entry>
    <title>缓存设计：穿透、击穿、雪崩与一致性</title>
    <link href="https://junaoe.dev/posts/cache-design" rel="alternate" type="text/html"/>
    <id>https://junaoe.dev/posts/cache-design</id>
    <updated>2026-10-12T00:00:00.000Z</updated>
    <published>2026-10-12T00:00:00.000Z</published>
    <author>
      <name>junaoe</name>
    </author>
    <summary type="text">加一层缓存容易，难的是知道它什么时候会坏。这篇讲三种典型缓存故障、缓存与数据库的一致性策略、分布式锁的正确用法，以及本地缓存和分布式缓存怎么配合。</summary>
    <content type="html"><![CDATA[
<p>缓存是后端性价比最高的优化 —— 加一层缓存，P99 延迟能从 800ms 降到 8ms。</p>
<p>但缓存也是后端最常见的故障源。问题几乎总是同一个：<strong>缓存和数据库对不上的时候，系统会怎样</strong>。</p>
<h2>先理解 Redis 的位置</h2>
<pre><code>用户请求
   ↓
[本地缓存]  ← 最快，微秒级，但每个进程一份
   ↓ 未命中
[Redis]     ← 次快，毫秒级，全局共享
   ↓ 未命中
[数据库]    ← 最慢，但唯一真相来源
</code></pre>
<p>三层各有各的问题，本地缓存的一致性尤其麻烦。后面单独讲。</p>
<h2>缓存穿透：查不存在的数据</h2>
<p><strong>现象</strong>：请求一个数据库里根本没有的数据。</p>
<pre><code>def get_user(user_id: int):
    key = f"user:{user_id}"
    data = redis.get(key)

    if data is None:
        data = db.query(User, user_id)      # 每次都查库，永远返回 None

        if data:
            redis.setex(key, 3600, serialize(data))

    return data       # 永远是 None
</code></pre>
<p><strong>问题</strong>：恶意用户用随机 ID 疯狂请求，每次都穿透到数据库。数据库本身没压力，但正常请求被挤占了。</p>
<p><strong>解法：缓存空值</strong></p>
<pre><code>def get_user(user_id: int):
    key = f"user:{user_id}"
    cached = redis.get(key)

    if cached is not None:
        # 特殊标记：负缓存
        return None if cached == b'__NULL__' else deserialize(cached)

    data = db.query(User, user_id)

    if data:
        redis.setex(key, 3600, serialize(data))
    else:
        # 空结果也缓存，但 TTL 要短
        redis.setex(key, 60, b'__NULL__')

    return data
</code></pre>
<p>TTL 设短是关键 —— 数据可能几分钟后就有了，缓存 60 秒足够挡住穿透流量，又不会让负缓存太陈旧。</p>
<p><strong>另一种解法：布隆过滤器</strong></p>
<p>如果 ID 是自增整数、或者你能提前拿到所有合法 ID，用布隆过滤器挡在前面：</p>
<pre><code>class BloomFilter:
    def __init__(self, size_bits: int, num_hashes: int):
        self.size = size_bits
        self.num_hashes = num_hashes
        self.bits = bytearray(size_bits // 8)

    def _hashes(self, item: str):
        h1 = hashlib.md5(item.encode()).digest()
        h2 = hashlib.sha1(item.encode()).digest()
        for i in range(self.num_hashes):
            yield (int.from_bytes(h1[i % 16:i % 16 + 4], 'big') % self.size
                   + int.from_bytes(h2[i % 16:i % 16 + 4], 'big')) % self.size

    def add(self, item: str):
        for h in self._hashes(item):
            self.bits[h // 8] |= 1 &lt;&lt; (h % 8)

    def might_contain(self, item: str) -&gt; bool:
        return all(self.bits[h // 8] &amp; (1 &lt;&lt; (h % 8)) for h in self._hashes(item))
</code></pre>
<p>布隆过滤器的特性：<strong>说「可能有」时可能有 1% 的概率没有，但说「没有」时一定没有</strong>。所以用来做否定判断是安全的，用来肯定判断则要继续查数据库。</p>
<p>不适合的场景是数据频繁变化的场景 —— 过滤器要跟着更新。</p>
<h2>缓存击穿：某个热点 key 过期</h2>
<p><strong>现象</strong>：一个被大量请求的 key 恰好过期，瞬时几百个请求同时打到数据库。</p>
<p><strong>解法一：互斥锁</strong></p>
<pre><code>def get_hot_article(article_id: int):
    key = f"article:{article_id}"
    data = redis.get(key)

    if data:
        return data

    # 只让一个线程去重建缓存
    lock_key = f"lock:{key}"
    acquired = redis.set(lock_key, 1, nx=True, ex=10)

    if acquired:
        try:
            # 拿到锁的线程去查库
            data = db.query(Article, article_id)
            if data:
                redis.setex(key, 3600, serialize(data))
            return data
        finally:
            redis.delete(lock_key)
    else:
        # 没拿到锁的线程短暂等待后重试
        # 此时第一个线程很可能已经重建好了
        time.sleep(0.05)
        return redis.get(key) or get_hot_article(article_id)
</code></pre>
<p>注意锁的 <code>ex=10</code>：必须有过期时间，否则持锁的进程崩溃后会<strong>死锁</strong>，所有请求永久卡住。</p>
<p><strong>解法二：逻辑过期</strong>（更优雅）</p>
<p>缓存值里存一个「逻辑过期时间」，过期了也不真的删，而是返回旧值同时异步重建：</p>
<pre><code>def get_hot_article(article_id: int):
    key = f"article:{article_id}"
    raw = redis.get(key)

    if raw is None:
        return rebuild_and_return(key, article_id)

    entry = json.loads(raw)

    if entry['expired_at'] &gt; time.time():
        return entry['data']          # 未过期，正常返回

    # 已逻辑过期：返回旧数据，同时异步重建
    rebuild_async(key, article_id)   # 丢进线程池，不阻塞
    return entry['data']             # 用户永远不等待
</code></pre>
<p><strong>解法三：永不过期 + 后台刷新</strong></p>
<p>适合数据变化不频繁、且必须保证可用性的数据。定时任务主动刷新，缓存本身不过期。</p>
<h2>缓存雪崩：大量 key 同时过期</h2>
<p><strong>现象</strong>：缓存批量预热时设置了相同 TTL，成千上万个 key 在同一秒集体失效，数据库瞬间被压垮。</p>
<p><strong>解法一：TTL 加随机抖动</strong></p>
<pre><code>import random

def set_cache(key: str, value: str, base_ttl: int = 3600):
    # 基础 TTL 加 0–300 秒随机值
    ttl = base_ttl + random.randint(0, 300)
    redis.setex(key, ttl, value)
</code></pre>
<p>这个改动的效果：<strong>过期时间被打散到 5 分钟的窗口里</strong>，不会有大批 key 同时失效。</p>
<p><strong>解法二：Redis 高可用 + 本地缓存兜底</strong></p>
<p>Redis 主从切换或集群故障时，本地缓存还能撑一段时间。</p>
<p><strong>解法三：熔断降级</strong></p>
<p>缓存完全挂掉时，直接返回数据库的结果（哪怕慢），而不是让请求全部失败。</p>
<pre><code>class CircuitBreaker:
    def __init__(self, threshold: int = 10, reset_after: int = 30):
        self.failures = 0
        self.threshold = threshold
        self.reset_after = reset_after
        self.opened_at = None

    def allow(self) -&gt; bool:
        if self.opened_at is None:
            return True
        if time.time() - self.opened_at &gt; self.reset_after:
            self.failures = 0        # 冷却结束，半开状态
            self.opened_at = None
            return True
        return False                 # 熔断中

    def record_success(self):
        self.failures = 0
        self.opened_at = None

    def record_failure(self):
        self.failures += 1
        if self.failures &gt;= self.threshold:
            self.opened_at = time.time()
</code></pre>
<h2>缓存一致性：最难的部分</h2>
<p>先说结论：<strong>缓存和数据库的强一致性，在分布式环境下基本做不到。</strong> 不要假装能做到。</p>
<p>剩下的问题是：能做到什么程度，以及不一致的窗口有多小。</p>
<h3>策略一：Cache Aside（最常用）</h3>
<pre><code>def update_user(user_id: int, data: dict):
    # 1. 先更新数据库
    db.update(User, user_id, **data)

    # 2. 再删除缓存
    redis.delete(f"user:{user_id}")
</code></pre>
<p><strong>为什么是「更新库 → 删缓存」，不是反过来？</strong></p>
<p>如果先删缓存再更新库，中间有一个窗口，别的请求会读到旧库的数据然后把旧值写回缓存 —— 之后缓存里就是脏数据，而且会一直脏到 TTL 过期。</p>
<p>而先更新库再删缓存，最坏情况是别的请求在两步之间读了旧库值，缓存里存了旧值，但<strong>下一次删缓存操作会把它清掉</strong>。不一致窗口更短。</p>
<div><div><div></div><div>WARNING</div></div><div><p>Cache Aside 有一致性问题：删缓存之后、下次读缓存之前，如果有人更新了库并删了缓存，然后有个更早的读请求把旧值写回了缓存，就会脏。更稳的做法是延迟双删，或用消息队列串行化。</p></div></div>
<p><strong>更稳的写法：延迟双删</strong></p>
<pre><code>def update_user(user_id: int, data: dict):
    db.update(User, user_id, **data)
    redis.delete(f"user:{user_id}")

    # 延迟再删一次，清理可能写回的旧值
    schedule_in(0.5, lambda: redis.delete(f"user:{user_id}"))
</code></pre>
<p>第二次删除是为了处理「更新库之后、删缓存之前，有并发读把旧值写回」这个窗口。</p>
<h3>策略二：先删缓存，更新库，再删缓存</h3>
<p>这是在分布式环境下被验证过更稳的顺序：</p>
<pre><code>1. 删除缓存
2. 更新数据库
3. 再次删除缓存
</code></pre>
<p><strong>先删</strong>能尽早让后续读请求去读库（拿到即将更新的新值），第二次删兜底清理。这个顺序比 Cache Aside 的不一致窗口更小。</p>
<h3>策略三：订阅 binlog（更可靠）</h3>
<p>用 Canal、Debezium 或云厂商的 binlog 工具订阅数据库变更事件，异步失效缓存。</p>
<pre><code>class CacheInvalidator:
    def on_binlog_event(self, event):
        table = event.table
        if table not in INVALIDATE_TABLES:
            return

        for row in event.rows:
            # 拿到主键，构造缓存 key
            key = f"{CACHE_KEY_PREFIX[table]}:{row['id']}"

            if event.op == 'UPDATE':
                # 有变更就删，让下次读重新加载
                redis.delete(key)
            elif event.op == 'DELETE':
                redis.delete(key)
            elif event.op == 'INSERT':
                redis.delete(key)
</code></pre>
<p>好处是<strong>应用代码完全不用关心缓存失效</strong>，改库的代码自然就生效了。这比在每个 update 方法里手写 <code>redis.delete</code> 可靠得多 —— 手写总会有人忘。</p>
<h3>哪些数据不该放缓存</h3>
<ul>
<li><strong>强一致性要求的数据</strong>：余额、库存、优惠券状态 —— 算错要出事故</li>
<li><strong>变化极快的数据</strong>：秒杀计数器、实时排名</li>
<li><strong>本身就很快的数据</strong>：小表、配置项 —— 查库可能只要 1ms，加缓存反而多一跳</li>
</ul>
<h2>本地缓存：快，但难搞</h2>
<p>Redis 再快也有网络往返（同城约 0.5–1ms）。用进程内的字典做二级缓存，能省掉这一跳。</p>
<p>但麻烦在于<strong>失效</strong>：删了 Redis，本地缓存不知道，还会继续返回旧数据。</p>
<h3>广播失效</h3>
<pre><code># 每个实例订阅失效频道
class LocalCache:
    def __init__(self, instance_id: str):
        self.instance_id = instance_id
        self.cache = {}
        self.lock = threading.RLock()

        # 订阅失效消息（发布订阅而非队列，任何实例发都所有实例收）
        self.pubsub = redis.pubsub()
        self.pubsub.subscribe('cache:invalidate')
        threading.Thread(target=self._listen, daemon=True).start()

    def _listen(self):
        for message in self.pubsub.listen():
            if message['type'] != 'message':
                continue

            key = message['data'].decode()
            with self.lock:
                self.cache.pop(key, None)

    def get(self, key: str):
        with self.lock:
            return self.cache.get(key)

    def set(self, key: str, value, ttl: int = 300):
        with self.lock:
            self.cache[key] = (value, time.time() + ttl)

    def invalidate(self, key: str):
        # 先删 Redis（全局）
        redis.delete(key)
        # 再广播，让所有实例删本地
        redis.publish('cache:invalidate', key)
</code></pre>
<p>注意：<strong>必须先删 Redis 再广播</strong>。反过来会出现「A 实例删了本地并广播，但 Redis 还没删，B 实例收到广播删了本地，然后 C 实例读缓存未命中去查 Redis 又拿到旧值写回本地」的竞态。</p>
<p><strong>本地缓存的 TTL 要短</strong>（30–60 秒），这样即使失效消息丢了，也能自动恢复一致。</p>
<h3>什么时候值得</h3>
<ul>
<li>QPS 极高，且延迟预算很紧（要求 P99 &lt; 5ms）</li>
<li>数据变化不频繁</li>
<li>实例数不多（几十个以内）</li>
</ul>
<p>实例数上千时，广播消息本身会成为瓶颈，得不偿失。</p>
<h2>分布式锁的几个坑</h2>
<p>用 Redis 做分布式锁，<strong>没有绝对安全的实现</strong>。但有几个必须注意的点。</p>
<pre><code>def acquire_lock(lock_key: str, owner: str, ttl: int = 30) -&gt; bool:
    # NX：只在不存在时设置
    ok = redis.set(lock_key, owner, nx=True, ex=ttl)
    return bool(ok)
</code></pre>
<p><strong>坑 1：必须用 <code>SET NX EX</code>，不能分开写</strong></p>
<pre><code># ❌ 两步之间有竞态，两个进程都可能认为自己拿到了锁
if redis.exists(lock_key) == 0:
    redis.set(lock_key, owner, ex=ttl)

# ✅ 原子操作
redis.set(lock_key, owner, nx=True, ex=ttl)
</code></pre>
<p><strong>坑 2：必须校验持有者再释放</strong></p>
<pre><code># ❌ 自己的锁可能已经超时被别人拿走了，这行会删掉别人的锁
redis.delete(lock_key)

# ✅ Lua 脚本保证「检查」和「删除」是原子的
RELEASE_SCRIPT = """
if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('del', KEYS[1])
else
    return 0
end
"""

def release_lock(lock_key: str, owner: str):
    redis.eval(RELEASE_SCRIPT, 1, lock_key, owner)
</code></pre>
<p><strong>坑 3：TTL 必须大于业务执行时间</strong></p>
<p>否则业务还没跑完锁就过期了，第二个进程拿到锁，两个同时执行 —— 锁就白加了。</p>
<p>如果无法预估业务耗时，可以<strong>看门狗续期</strong>：</p>
<pre><code>class LockWatchdog:
    def __init__(self, lock_key: str, owner: str):
        self.lock_key = lock_key
        self.owner = owner
        self.running = False

    def start(self):
        self.running = True
        threading.Thread(target=self._loop, daemon=True).start()

    def _loop(self):
        while self.running:
            # 每 10 秒续期到 30 秒
            redis.eval(RENEW_SCRIPT, 1, self.lock_key, self.owner)
            time.sleep(10)

    def stop(self):
        self.running = False
        release_lock(self.lock_key, self.owner)
</code></pre>
<p><strong>什么时候不该用分布式锁</strong></p>
<p>如果业务本身能用数据库约束或唯一索引保证正确性，就别用分布式锁 —— 少一个组件少一份风险。</p>
<p>比如「同一用户只能有一个进行中的订单」，与其加锁，不如在数据库加部分唯一索引。</p>
<h2>缓存的度量</h2>
<p>没有监控的缓存是黑盒，出问题都不知道从哪查。</p>
<p><strong>至少盯这几个指标</strong>：</p>






























<table><thead><tr><th>指标</th><th>含义</th><th>异常信号</th></tr></thead><tbody><tr><td><code>hit_rate</code></td><td>命中率</td><td>突然下降说明有大量未命中</td></tr><tr><td><code>evicted_keys</code></td><td>被淘汰的键数</td><td>内存不足的信号</td></tr><tr><td><code>used_memory</code></td><td>内存占用</td><td>接近上限要出事</td></tr><tr><td><code>keyspace_misses</code></td><td>未命中次数</td><td>配合命中率一起看</td></tr></tbody></table>
<p>命中率的健康值：</p>
<ul>
<li><strong>90% 以上</strong>：良好</li>
<li><strong>70–90%</strong>：正常但有优化空间</li>
<li><strong>低于 70%</strong>：要么缓存了不该缓存的东西，要么 TTL 设置有问题</li>
</ul>
<p><strong>命中率不是越高越好</strong>。如果命中率 99.99%，说明缓存了本不该缓存的数据（比如用户权限这种强一致数据），那是有风险的。</p>
<p>常用命令：</p>
<pre><code>INFO stats | grep keyspace_hits
INFO stats | grep keyspace_misses

# 命中率
redis-cli INFO stats | python3 -c "
import sys
hits = misses = 0
for line in sys.stdin:
    if 'keyspace_hits' in line: hits = int(line.split(':')[1])
    if 'keyspace_misses' in line: misses = int(line.split(':')[1])
total = hits + misses
print(f'命中率: {hits/total*100:.2f}%' if total else '无数据')
"
</code></pre>
<h2>一份排查清单</h2>
<p>线上出问题时按这个顺序查：</p>
<ul>
<li> 命中率是否异常下降？用 <code>MONITOR</code> 或慢日志看是不是有异常 key</li>
<li> 是否有大量 key 同时过期？看 TTL 分布</li>
<li> 数据库 QPS 是否同步飙升？是的话基本就是缓存失效了</li>
<li> Redis 内存是否接近上限？<code>INFO memory</code> 看 <code>used_memory</code> 和 <code>maxmemory</code></li>
<li> 是否有大批量写入导致缓存被逐出？</li>
<li> 本地缓存和 Redis 是否不一致？消息是否丢失？</li>
</ul>
<p>最后一条最隐蔽 —— 如果用了本地缓存 + 广播失效，一定要有 TTL 兜底，<strong>不要假设广播一定送达</strong>。网络会抖，订阅会断。</p>]]></content>
    <category term="后端" />
    <category term="缓存" />
  </entry>
  <entry>
    <title>消息队列与异步架构</title>
    <link href="https://junaoe.dev/posts/message-queue" rel="alternate" type="text/html"/>
    <id>https://junaoe.dev/posts/message-queue</id>
    <updated>2026-10-11T00:00:00.000Z</updated>
    <published>2026-10-11T00:00:00.000Z</published>
    <author>
      <name>junaoe</name>
    </author>
    <summary type="text">MQ 到底解决什么问题，什么时候不该用它；可靠投递、消费幂等、顺序性、死信队列，以及本地消息表和事务消息这两条实现路径。</summary>
    <content type="html"><![CDATA[
<p>消息队列在后端的位置很尴尬：概念上都知道「解耦、削峰、异步」，但真到设计的时候，往往说不出具体解了什么耦、削了什么峰。</p>
<p>这篇从实际问题出发，讲清楚 MQ 的适用场景和实现细节。</p>
<h2>MQ 到底解决什么</h2>
<h3>解耦：把「同时知道」变成「只需要知道」</h3>
<p>一个典型的反例。订单创建后要做五件事：扣库存、发通知、更新搜索索引、记账、风控检查。</p>
<pre><code># ❌ 紧耦合
def create_order(data):
    order = db.insert(Order, data)

    inventory.deduct(order.id)      # 挂了，整个下单失败
    notify.send(order)              # 挂了，整个下单失败
    search.index(order)             # 挂了，整个下单失败
    ledger.write(order)             # 挂了，整个下单失败
    risk.check(order)               # 挂了，整个下单失败

    return order
</code></pre>
<p>这段代码有两个问题：<strong>响应时间叠加</strong>（五个服务串行），<strong>可用性相乘</strong>（每个服务 99.9% 可用，整条链路只剩 <span><span>0.9995=99.5%0.999^5 = 99.5\%</span><span><span><span></span><span>0.99</span><span><span>9</span><span><span><span><span><span><span></span><span><span>5</span></span></span></span></span></span></span></span><span></span><span>=</span><span></span></span><span><span></span><span>99.5%</span></span></span></span>）。</p>
<pre><code># ✅ 松耦合
def create_order(data):
    order = db.insert(Order, data)
    mq.publish('order.created', order.to_dict())   # 只管发消息
    return order                                    # 立刻返回

# 五个消费者各自独立，互不影响
def handle_inventory(msg):
    inventory.deduct(msg['id'])

def handle_notify(msg):
    notify.send(msg)
</code></pre>
<p>响应时间从 5 个服务的总和降到「一次写库 + 一次发消息」。可用性从相乘变成相加：现在只有数据库是硬依赖。</p>
<p><strong>但要注意</strong>：解耦的代价是<strong>你失去了对流程的控制</strong>。以前代码里能一眼看出「先扣库存再发货」，现在得在多个消费者之间追踪调用关系。所以队列要有清晰的命名和文档。</p>
<h3>削峰：让生产者不必等消费者</h3>
<p>秒杀场景：1 万件商品，10 万请求涌入。同步处理数据库只能扛住 500 QPS。</p>
<pre><code># ❌ 数据库直接被打爆
def seckill(item_id, user_id):
    stock = redis.get(f'stock:{item_id}')
    if stock &gt; 0:
        redis.decr(f'stock:{item_id}')
        create_order(item_id, user_id)
</code></pre>
<pre><code># ✅ 入队即返回，DB 慢慢消费
def seckill(item_id, user_id):
    mq.publish('seckill.request', {'item_id': item_id, 'user_id': user_id})
    return {'code': 0, 'msg': '排队中'}

# 消费者按数据库能承受的速率消费
def consume_seckill(msg):
    # 内存扣减 + 异步写库（pipeline 批量）
    if redis.decr(f'stock:{msg["item_id"]}') &gt; 0:
        orders_buffer.append(msg)
</code></pre>
<p>峰值流量进了队列，消费者按自己的节奏处理。<strong>队列在这里扮演了缓冲区</strong>。</p>
<div><div><div></div><div>NOTE</div></div><div><p>削峰不只是「保护数据库」。更重要的是<strong>用户体验可控</strong> —— 用户看到的是「排队中，第 1234 位」，比页面转圈 30 秒后报错好得多。</p></div></div>
<h3>异步：把非必要的工作挪出主流程</h3>
<p>有些工作用户根本不需要等结果：</p>
<ul>
<li>发短信、邮件、推送</li>
<li>数据同步到数仓</li>
<li>生成报表</li>
<li>图片缩略图处理</li>
</ul>
<p>这些放进队列，主流程只返回必要结果。</p>
<h2>什么时候不该用 MQ</h2>
<p>MQ 不是银弹，用错地方反而更糟。</p>
<p><strong>1. 流程简单、调用方少的时候</strong></p>
<p>就两个下游服务，同步调用清清楚楚。为了「架构先进」引入 Kafka，然后多了一套部署、监控、故障处理 —— 不值。</p>
<p><strong>2. 需要立刻知道结果的时候</strong></p>
<p>用户提交表单，期望看到「保存成功」。如果这个结果依赖 MQ 下游的处理，那 MQ 就成了阻塞点，还得加轮询、加回调，绕一圈回到同步。</p>
<p><strong>3. 数据量很小的时候</strong></p>
<p>每天几千条消息，用数据库表当队列就够了（<code>SELECT ... FOR UPDATE SKIP LOCKED</code>）。专门部署 Kafka 是杀鸡用牛刀。</p>
<p><strong>4. 事务要求很强的时候</strong></p>
<p>订单状态和库存扣减必须原子完成时，跨服务的分布式事务比 MQ 更麻烦。这时候往往需要业务补偿逻辑，而不是消息队列。</p>
<h2>选型：Kafka 还是 RabbitMQ</h2>













































<table><thead><tr><th></th><th>Kafka</th><th>RabbitMQ</th></tr></thead><tbody><tr><td>模型</td><td>持久化日志</td><td>队列 + 路由</td></tr><tr><td>顺序保证</td><td>分区内有序</td><td>队列内有序</td></tr><tr><td>堆积能力</td><td>极强（磁盘）</td><td>有上限（内存/磁盘告警）</td></tr><tr><td>延迟</td><td>毫秒级</td><td>亚毫秒</td></tr><tr><td>消费模式</td><td>广播 / 扇出</td><td>推 / 拉</td></tr><tr><td>运维复杂度</td><td>高（ZK/KRaft）</td><td>低</td></tr><tr><td>典型场景</td><td>日志流、埋点、流处理</td><td>业务消息、任务分发</td></tr></tbody></table>
<p><strong>判断标准很简单</strong>：</p>
<ul>
<li>业务消息、需要精细路由和低延迟 → <strong>RabbitMQ</strong></li>
<li>海量数据流、需要回放和多个独立消费组 → <strong>Kafka</strong></li>
<li>团队没人专门运维 → 选 RabbitMQ，或者云厂商托管的 Kafka</li>
</ul>
<h2>可靠投递：消息可能丢的三个环节</h2>
<p>消息从生产者到消费者，中间有三个可能丢的地方。</p>
<h3>环节一：生产者 → MQ</h3>
<p>生产者发完消息就崩溃，或者网络抖动消息没到。</p>
<p><strong>做法：生产者确认（Publisher Confirm）</strong></p>
<pre><code>def publish(topic: str, msg: dict):
    future = channel.basic_publish(
        exchange=topic,
        routing_key='',
        body=json.dumps(msg).encode(),
        properties=pika.BasicProperties(
            delivery_mode=2,          # 持久化
            message_id=str(uuid.uuid4()),
        ),
    )

    # 等待 broker 确认，不确认不算成功
    if not future.is_ready():
        raise PublishError('消息未被确认')

    # broker 持久化失败也要知道
    if not future.get(timeout=5):
        raise PublishError('消息写入失败')
</code></pre>
<p><code>delivery_mode=2</code> 保证消息落盘而不只是进内存。<strong>但这只解决了 broker 侧</strong> —— 如果是多节点集群，还要确认副本数大于 0。</p>
<h3>环节二：MQ 内部</h3>
<p>broker 自己挂了。靠<strong>持久化 + 多副本</strong>解决：</p>
<pre><code># RabbitMQ 队列定义
{
  "durable": true,          # 队列持久化
  "auto_delete": false,
  "arguments": {
    "x-queue-type": "quorum" # 仲裁队列，Raft 复制
  }
}
</code></pre>
<h3>环节三：MQ → 消费者</h3>
<p>消费者拿到消息，处理到一半崩溃，消息却已经从队列移除了。这是<strong>最危险的一种丢消息</strong>，因为它同时丢了数据和「重试机会」。</p>
<p><strong>解法：手动 ACK</strong></p>
<pre><code>def on_message(ch, method, properties, body):
    msg = json.loads(body)

    try:
        handle(msg)
        ch.basic_ack(delivery_tag=method.delivery_tag)     # 处理成功才确认
    except RetryableError:
        # 不 ACK，消息会重新投递
        raise
    except Exception:
        # 不可重试的错误，进死信队列
        ch.basic_reject(delivery_tag=method.delivery_tag, requeue=False)
</code></pre>
<p><strong>关键</strong>：ACK 必须在业务处理<strong>完成之后</strong>。如果先 ACK 再处理，崩溃时消息就永久丢失了。</p>
<h2>消费幂等：MQ 给你的是「至少一次」</h2>
<p>即使配好了 ACK，消息仍然可能重复投递 —— 消费者处理完但 ACK 丢了，broker 重投。</p>
<p><strong>所以消费端必须幂等。</strong> 这是使用 MQ 的硬性要求，不是可选项。</p>
<pre><code>def handle(msg: dict):
    event_id = msg['eventId']

    # 方案一：唯一索引去重（最可靠）
    try:
        db.insert(
            'processed_events',
            {'event_id': event_id, 'processed_at': now()},
        )
    except DuplicateKeyError:
        log.info(f'事件 {event_id} 已处理，跳过')
        return

    do_business_logic(msg)
</code></pre>
<pre><code># 方案二：条件更新，只处理特定状态的数据
def handle_payment_success(msg):
    rows = db.update(
        'orders',
        {'status': 'paid', 'paid_at': msg['timestamp']},
        where="id = :id AND status = 'pending'",
    )

    if rows == 0:
        # 要么订单不存在，要么已经处理过
        return
</code></pre>
<p><strong>方案二更优</strong>，因为它把幂等和业务逻辑合成了一步，不需要额外的表。</p>
<p><strong>不要用 Redis 去重做关键操作</strong>。Redis 可能因故障丢数据，丢一次就导致重复扣款。关键路径的幂等必须落在数据库上。</p>
<h3>表结构应该为幂等设计</h3>
<pre><code>-- 天然幂等：唯一约束兜底
CREATE TABLE webhook_events (
    id            BIGSERIAL PRIMARY KEY,
    provider      VARCHAR(50) NOT NULL,
    external_id   VARCHAR(128) NOT NULL,
    payload       JSONB NOT NULL,
    processed_at  TIMESTAMPTZ,
    UNIQUE (provider, external_id)     -- 关键约束
);
</code></pre>
<h2>顺序性：全局有序是个陷阱</h2>
<p>「消息必须按顺序处理」这个需求，实现成本比想象中高得多。</p>
<p><strong>分区/队列内的顺序可以保证，全局的不行。</strong> 因为消费有并发：消费者 1 处理第 3 条时，消费者 2 可能正在处理第 4 条。</p>
<h3>方案一：单队列单消费者</h3>
<p>最简单，但吞吐也最低 —— 一个消费者串行处理，QPS 上限就那么高。</p>
<h3>方案二：按 key 哈希到固定分区</h3>
<pre><code>QUEUE_COUNT = 8

def get_partition(msg: dict) -&gt; int:
    # 同一个订单的消息永远进同一个分区
    key = msg['order_id'].encode()
    return int.from_bytes(hashlib.md5(key).digest()[:4], 'big') % QUEUE_COUNT
</code></pre>
<p>同一个订单的消息进同一个分区，分区内串行消费 —— <strong>订单级别的顺序有保证，不同订单之间并发</strong>。</p>
<p>这通常是够用的：「同一订单的状态变更要有序」合理，「全站所有消息严格有序」不合理。</p>
<h3>方案三：版本号</h3>
<p>每条消息带一个递增的版本号，消费时检查：</p>
<pre><code>def handle(msg):
    current = redis.get(f'version:{msg["order_id"]}')

    if current and msg['version'] &lt;= int(current):
        return          # 旧消息，丢弃

    do_business_logic(msg)
    redis.set(f'version:{msg["order_id"]}', msg['version'])
</code></pre>
<p>这个方案不依赖队列的顺序保证，但引入了状态管理成本。</p>
<h2>事务消息：数据库和 MQ 的一致性</h2>
<p>最难的问题是：<strong>数据库写成功但消息发失败，或反过来。</strong></p>
<pre><code># ❌ 两个独立的操作，没有原子性
db.insert(order)
mq.publish('order.created', order)
</code></pre>
<p>如果 <code>db.insert</code> 成功、<code>mq.publish</code> 失败，订单建了但下游永远不知道。</p>
<h3>方案一：本地消息表</h3>
<p>在同一个数据库事务里写业务数据和消息记录，由独立的进程扫描并投递。</p>
<pre><code>CREATE TABLE local_messages (
    id           BIGSERIAL PRIMARY KEY,
    topic        VARCHAR(100) NOT NULL,
    payload      JSONB NOT NULL,
    status       SMALLINT DEFAULT 0,      -- 0待发送 1已发送 2失败
    retry_count  SMALLINT DEFAULT 0,
    next_retry_at TIMESTAMPTZ,
    created_at   TIMESTAMPTZ DEFAULT now()
);

CREATE INDEX idx_pending ON local_messages (status, next_retry_at)
    WHERE status = 0;
</code></pre>
<pre><code>def create_order(data):
    with db.begin():
        order = db.insert(Order, **data)
        # 同一事务写入待发消息
        db.insert('local_messages', {
            'topic': 'order.created',
            'payload': order.to_dict(),
        })
    return order


def scan_and_publish():          # 独立进程，秒级轮询
    msgs = db.query(
        "SELECT * FROM local_messages "
        "WHERE status = 0 AND next_retry_at &lt;= now() "
        "ORDER BY id LIMIT 100"
    )

    for msg in msgs:
        try:
            mq.publish(msg['topic'], msg['payload'])
            db.update('local_messages', msg['id'],
                      {'status': 1, 'sent_at': now()})
        except Exception:
            # 指数退避重试
            delay = min(2 ** msg['retry_count'], 3600)
            db.update('local_messages', msg['id'], {
                'retry_count': msg['retry_count'] + 1,
                'next_retry_at': now() + timedelta(seconds=delay),
            })
</code></pre>
<p><strong>优点</strong>：不依赖 MQ 的特性，任意 MQ 都能用，逻辑完全可控。
<strong>缺点</strong>：消息投递有延迟（取决于轮询间隔），多一个表要维护。</p>
<h3>方案二：RocketMQ 事务消息</h3>
<p>利用 MQ 自身提供的两阶段提交：</p>
<pre><code>tx_producer = transactional_producer.lookup('order-topic')

@register_listener(topic='order.created')
def check(order_id: str) -&gt; TransactionStatus:
    order = query_order(order_id)

    if order:
        return TransactionStatus.COMMIT_MESSAGE   # 订单存在，提交消息
    return TransactionStatus.ROLLBACK_MESSAGE       # 不存在，回滚


def create_order(data):
    result = tx_producer.send_message_in_transaction(
        build_message(topic='order.created', data=data),
        business_arg=generate_order_id(),
        check_listener=check,
    )
    return result
</code></pre>
<p>流程：先发半消息（消费者看不到）→ 执行本地事务 → 提交或回滚。如果中间崩溃，MQ 定期回查 <code>check_listener</code> 来决定最终状态。</p>
<p><strong>优点</strong>：无额外存储，对业务代码侵入小。
<strong>缺点</strong>：只能用 RocketMQ，且回查逻辑要保证幂等。</p>
<h3>怎么选</h3>
<ul>
<li>团队已经有本地消息表的基础设施 → <strong>方案一</strong></li>
<li>想少写代码且接受绑定 RocketMQ → <strong>方案二</strong></li>
<li><strong>对一致性要求不高</strong>（比如统计、日志）→ 直接发消息 + 重试就行，不用上这一套</li>
</ul>
<h2>死信队列：给失败的消息一个归宿</h2>
<p>处理失败的消息不能无限重试，也不能直接丢掉。</p>
<pre><code>RETRY_QUEUE = 'order.created.retry'
DLQ_QUEUE = 'order.created.dlq'
MAX_RETRIES = 5

def on_message(ch, method, properties, body):
    headers = properties.headers or {}
    retry_count = headers.get('x-retry-count', 0)

    try:
        handle(json.loads(body))
        ch.basic_ack(delivery_tag=method.delivery_tag)
    except NonRetryableError:
        # 业务上不可能成功，直接进死信
        ch.basic_reject(delivery_tag=method.delivery_tag, requeue=False)
    except Exception as e:
        if retry_count &gt;= MAX_RETRIES:
            publish_to_dlq(body, reason=str(e))
            ch.basic_ack(delivery_tag=method.delivery_tag)
        else:
            # 延迟重试，不是立即重投
            publish_with_delay(RETRY_QUEUE, body, retry_count + 1)
            ch.basic_ack(delivery_tag=method.delivery_tag)
</code></pre>
<p><strong>区分「可重试」和「不可重试」很重要</strong>。参数格式错误、记录不存在这类错误，重试一万次也是一样的结果；网络抖动、锁超时这类才值得重试。</p>
<p>死信队列需要配套的<strong>告警和处理界面</strong> —— 死信堆积是问题的信号，不能只放在那里不看。</p>
<h2>一份设计检查清单</h2>
<ul>
<li> 每个队列都有明确的名字和职责？</li>
<li> 生产者开了 confirm？消息设了持久化？</li>
<li> 消费者是<strong>处理完成后</strong>才 ACK？</li>
<li> 消费逻辑幂等？幂等落在数据库还是缓存？</li>
<li> 有死信队列？死信有告警和处理流程？</li>
<li> 有没有区分可重试和不可重试错误？</li>
<li> 顺序性需求是「全局有序」还是「按 key 有序」？后者才可实现</li>
<li> 数据库和 MQ 的一致性方案是什么？接受多大的延迟窗口？</li>
<li> 队列积压时怎么限流生产？还是让生产者一起崩？</li>
<li> 消费延迟有没有监控？</li>
</ul>
<p>最后一条尤其重要。<strong>队列延迟是所有后端指标里最容易被忽略、也最能提前预警的</strong> —— 它涨上去通常意味着流量涨了、消费者变慢了、或者数据库出问题了。等到下游开始超时才发现，往往已经积压到几千条了。</p>]]></content>
    <category term="后端" />
    <category term="消息队列" />
  </entry>
  <entry>
    <title>认证与授权：Session、JWT 与 OAuth2</title>
    <link href="https://junaoe.dev/posts/auth-and-authorization" rel="alternate" type="text/html"/>
    <id>https://junaoe.dev/posts/auth-and-authorization</id>
    <updated>2026-10-10T00:00:00.000Z</updated>
    <published>2026-10-10T00:00:00.000Z</published>
    <author>
      <name>junaoe</name>
    </author>
    <summary type="text">登录状态存哪、JWT 怎么签发和撤销、OAuth2 四种授权流程分别用在哪、密码该怎么存。这篇把后端认证的核心问题和常见误区讲清楚。</summary>
    <content type="html"><![CDATA[
<p>认证（Authentication）解决「你是谁」，授权（Authorization）解决「你能做什么」。这两件事经常被混为一谈，代码里也经常互相渗透，导致一改就出漏洞。</p>
<h2>先把概念分清</h2>

























<table><thead><tr><th></th><th>认证 Authentication</th><th>授权 Authorization</th></tr></thead><tbody><tr><td>问题</td><td>你是谁？</td><td>你能做什么？</td></tr><tr><td>手段</td><td>密码、验证码、第三方登录</td><td>角色、权限、ACL、策略</td></tr><tr><td>失败表现</td><td>401 Unauthorized</td><td>403 Forbidden</td></tr></tbody></table>
<p>常见错误是<strong>用 403 表示认证失败</strong>。认证失败应该返回 401 并带上 <code>WWW-Authenticate</code> 头，客户端才知道「该去登录了」；403 表示身份已确认，只是权限不够。</p>
<h2>密码存储：不要自己做</h2>
<p><strong>绝对不要</strong>：MD5、SHA1、直接存明文、只加盐不做慢哈希。</p>
<pre><code># ❌ 全是错的
hashlib.md5(password.encode()).hexdigest()
hashlib.sha1(password.encode()).hexdigest()

# ❌ 自己造的"加密"（彩虹表秒破）
encrypt(password, my_own_algorithm)
</code></pre>
<p>MD5 和 SHA1 是为速度设计的，攻击者可以用 GPU 每秒尝试几十亿次。而且无盐哈希可以直接查彩虹表 —— 加盐是必要的，但更根本的问题是<strong>要用慢哈希</strong>。</p>
<p><strong>应该用 bcrypt、scrypt 或 Argon2</strong>：</p>
<pre><code>import bcrypt

def hash_password(password: str) -&gt; bytes:
    # cost=12 大约需要 250ms，OWASP 推荐的量级
    salt = bcrypt.gensalt(rounds=12)
    return bcrypt.hashpw(password.encode('utf-8'), salt)


def verify_password(password: str, hashed: bytes) -&gt; bool:
    try:
        return bcrypt.checkpw(password.encode('utf-8'), hashed)
    except ValueError:
        return False
</code></pre>
<p>三个算法的取舍：</p>





















<table><thead><tr><th>算法</th><th>特点</th></tr></thead><tbody><tr><td><strong>Argon2id</strong></td><td>内存困难，抗 GPU/ASIC 并行破解，当前首选</td></tr><tr><td><strong>bcrypt</strong></td><td>成熟稳定，生态好，72 字节输入上限</td></tr><tr><td><strong>scrypt</strong></td><td>内存困难，参数调优需要经验</td></tr></tbody></table>
<p>慢哈希的代价是<strong>故意让登录变慢</strong>。这正是目的：正常用户一次登录几百毫秒无感，攻击者每分钟只能试几十次。</p>
<div><div><div></div><div>WARNING</div></div><div><p>bcrypt 的 72 字节上限容易被忽略。超长密码在某些实现里会被<strong>静默截断</strong>，用户设的 80 字符密码只有前 72 字节生效。如果需要支持超长密码，用 Argon2 或先做一次 SHA-256 预哈希（注意这会消除 null 字节注入风险）。</p></div></div>
<p><strong>永远不要把用户密码放进日志</strong>，包括「登录失败」这类调试日志 —— 密码出现在日志里就等于泄露给了所有有日志权限的人。</p>
<h2>Session：简单可靠的传统方案</h2>
<p>服务端存会话状态，客户端只拿一个会话 ID。</p>
<pre><code>def login(request):
    user = db.find_user_by_email(request.email)

    if not user or not verify_password(request.password, user.password_hash):
        # 统一提示，不告诉用户到底是邮箱不存在还是密码错
        raise AuthenticationError('邮箱或密码错误')

    session_id = secrets.token_urlsafe(32)    # 32 字节随机，足够安全
    session_data = {
        'user_id': user.id,
        'created_at': time.time(),
        'ip': request.remote_addr,
    }
    redis.setex(f'session:{session_id}', 86400, serialize(session_data))

    response.set_cookie(
        'session_id', session_id,
        httponly=True,      # JavaScript 读不到，防 XSS 窃取
        secure=True,        # 只走 HTTPS
        samesite='lax',     # 防跨站请求伪造
        path='/',
    )
    return {'ok': True}
</code></pre>
<h3>三个 Cookie 属性不能少</h3>
<p><strong>HttpOnly</strong> —— 加上后 <code>document.cookie</code> 读不到这个值。防的是 XSS 攻击：即使页面上有注入的脚本，也偷不走会话。</p>
<p><strong>Secure</strong> —— 只在 HTTPS 下传输。HTTP 下明文 Cookie 等于把登录凭证贴在墙上。</p>
<p><strong>SameSite</strong> —— <code>Lax</code> 是现代浏览器的合理默认值，能防住大部分 CSRF。设为 <code>None</code> 时<strong>必须</strong>同时有 Secure。</p>
<h3>Session 存储的取舍</h3>

























<table><thead><tr><th>方案</th><th>优点</th><th>缺点</th></tr></thead><tbody><tr><td>内存</td><td>最快</td><td>多实例不共享，重启丢失</td></tr><tr><td>Redis</td><td>快，可共享</td><td>需要额外组件</td></tr><tr><td>数据库</td><td>无额外组件</td><td>每个请求都查库</td></tr></tbody></table>
<p>多实例部署基本都用 Redis。单机小项目可以先内存 + 定期持久化。</p>
<h3>Session 的真正缺点</h3>
<p><strong>无法跨站共享。</strong> 用户在 <code>a.com</code> 登录了，<code>b.com</code> 不知道。这催生了 OAuth；也让「移动端 App + 多个前端」变得麻烦。</p>
<p>另一个问题是<strong>服务端存储开销</strong>。百万用户在线就意味着百万条会话记录。</p>
<p>JWT 常被当作 Session 的替代品，但它解决的是<strong>不同的问题</strong>，而且引入了新的麻烦。</p>
<h2>JWT：不是 Session 的银弹</h2>
<p>JWT（JSON Web Token）的卖点是「自包含」—— 把信息签进 token 本身，服务端不需要存储任何东西。</p>
<pre><code>import jwt
from datetime import datetime, timedelta, timezone

SECRET = os.environ['JWT_SECRET']
ALGORITHM = 'HS256'


def create_access_token(user_id: int, roles: list) -&gt; str:
    now = datetime.now(timezone.utc)
    payload = {
        'sub': str(user_id),
        'roles': roles,
        'iat': now,
        'exp': now + timedelta(minutes=15),      # 短有效期
        'jti': str(uuid.uuid4()),                 # 唯一 ID，用于撤销
        'iss': 'my-api',                          # 签发者
        'aud': 'my-app',                          # 接收方
    }
    return jwt.encode(payload, SECRET, algorithm=ALGORITHM)


def verify_token(token: str) -&gt; dict:
    options = {
        'verify_signature': True,
        'verify_exp': True,
        'verify_iss': True,
        'verify_aud': True,
        'require': ['exp', 'iat', 'sub'],
    }
    return jwt.decode(
        token, SECRET, algorithms=[ALGORITHM],
        issuer='my-api', audience='my-app', options=options,
    )
</code></pre>
<p><strong>必须校验的项</strong>：<code>签名</code>、<code>exp</code>（过期）、<code>iss</code>（签发者）、<code>aud</code>（接收者）、<code>alg</code>（算法）。</p>
<p>最后一项尤其重要 —— <strong>必须固定算法，不要从 token 里读</strong>：</p>
<pre><code># ❌ 致命漏洞：攻击者可以把 alg 改成 "none"，或改成对称算法
jwt.decode(token, key, algorithms=jwt.algorithms.get_default_algorithms())

# ✅ 显式指定允许的算法
jwt.decode(token, SECRET, algorithms=['HS256'])
</code></pre>
<p>如果服务用 HS256（非对称算法的一半），攻击者拿到公钥就能伪造签名。<strong>混用 HS256 和 RS256 曾经导致大量系统被攻破</strong>，因为 RS256 的公钥可以被当作 HMAC 密钥使用。</p>
<h3>JWT 的三个硬伤</h3>
<p><strong>1. 无法撤销</strong></p>
<p>Token 在有效期内一直有效。用户「登出」了，但那个 token 还能继续用 —— 因为服务端根本不记录它。</p>
<p>缓解办法是维护一个黑名单：</p>
<pre><code>def revoke(token: str):
    payload = jwt.decode(token, options={'verify_signature': False})
    # 黑名单 TTL 设为 token 的剩余寿命
    redis.setex(
        f"jwt:blacklist:{payload['jti']}",
        int(payload['exp'] - payload['iat']),
        '1',
    )


def verify_and_check_revoked(token: str) -&gt; dict:
    payload = jwt.decode(token, SECRET, algorithms=['HS256'])

    if redis.exists(f"jwt:blacklist:{payload['jti']}"):
        raise AuthenticationError('token 已撤销')

    return payload
</code></pre>
<p>注意：<strong>这实质上又回到了有状态</strong>。所以如果你的系统需要频繁撤销（比如「改密码后踢掉所有设备」），Session 反而更简单。</p>
<p><strong>2. 无法更新</strong></p>
<p>用户改了权限，但旧 token 里的 <code>roles</code> 还是旧的。要等它自然过期。解决办法同上 —— 发新 token，或者加黑名单。</p>
<p><strong>3. Token 太大</strong></p>
<p>JWT 的 header 和 payload 都是 Base64 编码（<strong>不是加密</strong>，任何人都能解开看内容）。如果往里塞太多信息，token 可能膨胀到几 KB，每次请求都带着跑。</p>
<p><strong>不要在 JWT 里放敏感信息。</strong> 它只是签名保证没被篡改，不是保密的。</p>
<h3>实际建议</h3>
<p>JWT 适合这些场景：</p>
<ul>
<li><strong>纯 API 服务</strong>，无状态是硬需求（大量微服务、跨域调用）</li>
<li><strong>第三方集成</strong> —— 别人用你的 API，你不需要管理会话</li>
<li><strong>短生命周期的访问令牌</strong>（15 分钟以内）+ 刷新令牌</li>
</ul>
<p><strong>如果你的场景是「同一个网站的多个页面」—— 用 Session。</strong> 它更简单，能立即撤销，浏览器兼容性也没问题。</p>
<p>一个折中的实用方案：<strong>Access Token（JWT，15 分钟）+ Refresh Token（Session 存储，可撤销）</strong>。短期内的无状态，加上需要撤销时的控制力。</p>
<h3>刷新令牌的设计</h3>
<pre><code>def refresh(refresh_token: str):
    data = redis.get(f'refresh:{hash(refresh_token)}')

    if not data:
        raise AuthenticationError('refresh token 无效')

    session = deserialize(data)

    # 检测重放：如果同一个 token 用过两次，说明可能被窃取
    used = redis.getset(f'refresh_used:{hash(refresh_token)}', '1', ex=86400)
    if used:
        # 撤销整个 token 家族
        revoke_family(session['family_id'])
        raise AuthenticationError('检测到 token 重放，已撤销全部会话')

    user = db.get(User, session['user_id'])
    return create_access_token(user.id, user.roles)
</code></pre>
<p><strong>轮换（rotation）是必须的</strong>：每次刷新都发新的 refresh token，旧的作废。否则 refresh token 长期有效，等于给了攻击者永久访问权。</p>
<p>「检测重放 → 撤销全家」这个设计能定位到 token 泄露：受害者正常刷新时如果收到「token 已使用」错误，说明有人拿着偷来的 token 在用。</p>
<h2>OAuth 2.0：第三方登录的标准</h2>
<p>OAuth 解决的是<strong>授权委托</strong>：「我允许应用 B 访问我在应用 A 上的数据，不需要把密码给 B」。</p>
<h3>四种授权流程</h3>






























<table><thead><tr><th>流程</th><th>用途</th><th>前提</th></tr></thead><tbody><tr><td><strong>授权码</strong> Authorization Code</td><td>有后端的 Web 应用</td><td>最常用</td></tr><tr><td><strong>隐式</strong> Implicit</td><td>纯前端 SPA</td><td><strong>已不推荐</strong></td></tr><tr><td><strong>密码</strong> Resource Owner Password</td><td>高度可信的第一方应用</td><td>基本不用</td></tr><tr><td><strong>客户端凭证</strong> Client Credentials</td><td>服务间调用，无用户参与</td><td>机器对机器</td></tr></tbody></table>
<p><strong>授权码流程的标准步骤</strong>：</p>
<pre><code>1. 用户点击「用 Google 登录」
2. 前端把用户重定向到授权服务器的授权端点，带上 client_id、redirect_uri、state
3. 用户在授权服务器上登录并同意授权
4. 授权服务器重定向回 redirect_uri，附带 ?code=授权码&amp;state=...
5. 前端把 code 发送到自己的后端
6. 后端用 code + client_secret 换取 access_token
7. 后端用 access_token 调用资源服务器获取用户信息
</code></pre>
<p><strong>为什么必须是后端换 token？</strong> 因为 <code>client_secret</code> 绝不能出现在前端代码里。如果用隐式流程，token 直接在浏览器里传递，攻击者通过 XSS 就能拿到。</p>
<p><strong>PKCE 是授权码流程的现代增强</strong>，即使没有 client_secret 也安全 —— 移动端和单页应用应该用「授权码 + PKCE」。</p>
<h3>state 参数不能省</h3>
<p><code>state</code> 是防 CSRF 的关键：客户端生成随机值放进请求，授权服务器原样返回，回来时比对。</p>
<pre><code># 发起授权前
state = secrets.token_urlsafe(24)
session['oauth_state'] = state
redirect_to_authorize(client_id, redirect_uri, state, scope)

# 回调时
if request.args.get('state') != session.pop('oauth_state'):
    raise AuthenticationError('state 校验失败，可能是 CSRF 攻击')
</code></pre>
<p>没有这个校验，攻击者可以构造一个回调链接把自己的账号绑到受害者的账户上（账号接管）。</p>
<h3>refresh_token grant</h3>
<p>自己实现第三方登录时的关键一步：access_token 过期后用 refresh_token 换新的。</p>
<pre><code>def refresh_tokens(session_id: str):
    session = get_session(session_id)
    token = session['refresh_token']

    resp = http.post(
        f"{PROVIDER}/oauth/token",
        data={
            'grant_type': 'refresh_token',
            'refresh_token': token,
            'client_id': CLIENT_ID,
            'client_secret': CLIENT_SECRET,
        },
        timeout=10,
    )

    new_token = resp.json()['access_token']

    if 'refresh_token' in resp.json():
        session['refresh_token'] = resp.json()['refresh_token']

    session['access_token'] = new_token
    session['expires_at'] = time.time() + resp.json()['expires_in']

    save_session(session_id, session)
    return new_token
</code></pre>
<p><strong>每个用户会话各自存一份 token</strong>，不要全系统共用 —— 共用会导致「A 用户的请求用了 B 用户的 token」这种极难排查的问题。并发场景下多个请求同时刷新时要加锁，否则后一个请求会用已失效的 refresh token，覆盖掉前一个刷新的结果。</p>
<h2>权限设计：别只靠角色</h2>
<p><strong>RBAC</strong>（基于角色）简单直观：</p>
<pre><code>ROLES = {
    'user':    ['post:read', 'comment:create'],
    'editor':  ['post:read', 'post:write', 'comment:create', 'comment:delete'],
    'admin':   ['*'],
}

def can(user, permission: str) -&gt; bool:
    perms = ROLES.get(user.role, [])
    return '*' in perms or permission in perms
</code></pre>
<p>问题是<strong>角色会爆炸</strong>。「能编辑自己文章但不能编辑别人的编辑」这种权限，RBAC 要新建一堆角色（「初级编辑」「高级编辑」「区域编辑」），最后没人说得清每个角色到底能干什么。</p>
<p><strong>ABAC</strong>（基于属性）更灵活：</p>
<pre><code>def can_edit_post(user, post) -&gt; bool:
    if user.is_admin:
        return True
    if post.author_id != user.id:
        return False                    # 不是自己的文章
    return post.status in ('draft', 'published')   # 已归档的不能改
</code></pre>
<p><strong>最实用的往往是混合</strong>：用角色做粗粒度控制（哪些人能进管理后台），用属性做细粒度判断（能不能改这一篇）。</p>
<h3>检查放在哪一层</h3>
<p><strong>不要只在接口里检查</strong>。一个真实案例：接口检查了权限，但<strong>数据库查询没有限制范围</strong>：</p>
<pre><code># ❌ 检查了是不是作者，但没检查是不是自己的文章
def update_post(post_id):
    post = db.get(Post, post_id)
    if not can_edit_post(current_user, post):     # 作者判断
        raise Forbidden()

    # 但是！post_id 是用户传的，他可以传别人的 post_id
    # 如果 post 查询没带 author_id 条件，作者判断就形同虚设
</code></pre>
<p>正确的做法是<strong>把权限条件下推到查询里</strong>：</p>
<pre><code>def update_post(post_id):
    post = db.query_one(
        "SELECT * FROM posts WHERE id = :id AND author_id = :author",
        {'id': post_id, 'author_id': current_user.id},
    )
    if not post:
        raise NotFound()          # 不区分「不存在」和「无权限」
</code></pre>
<p>这样权限和数据范围在同一条 SQL 里，不给中间环节留空隙。</p>
<h2>常见攻击与防御</h2>
<p><strong>SQL 注入</strong> —— 参数化查询，别拼字符串。</p>
<p><strong>越权访问（IDOR）</strong> —— 上面说的，把权限条件写进查询。</p>
<p><strong>CSRF</strong> —— 传统 Web 用 SameSite Cookie + CSRF Token；纯 API（Bearer Token）天然免疫，因为浏览器不会自动附带 <code>Authorization</code> 头。</p>
<p><strong>XSS 窃取 Token</strong> —— 存在 localStorage 的 token 会被 XSS 读到。所以：</p>
<ul>
<li>有 HttpOnly Cookie 可用时优先用它</li>
<li>必须存 localStorage 时，严格做输出转义、CSP 策略</li>
</ul>
<p><strong>令牌泄露在日志里</strong> —— 请求日志别打完整的 <code>Authorization</code> 头和 Cookie。</p>
<pre><code>SENSITIVE = {'authorization', 'cookie', 'set-cookie', 'x-api-key'}

def scrub(log_record: dict) -&gt; dict:
    return {
        k: ('***' if k.lower() in SENSITIVE else v)
        for k, v in log_record.items()
    }
</code></pre>
<h2>一份检查清单</h2>
<p><strong>密码与凭证</strong></p>
<ul>
<li> 密码用 bcrypt / Argon2 存储，不是 MD5/SHA1/自定义加密？</li>
<li> 日志里没有明文密码？</li>
<li> 数据库泄露时，攻击者拿到的密码无法直接使用？</li>
</ul>
<p><strong>Session / Cookie</strong></p>
<ul>
<li> Cookie 设置了 HttpOnly、Secure、SameSite？</li>
<li> 会话 ID 用密码学安全的随机数生成？</li>
<li> 会话有过期时间？长期不活跃的会话会被清理？</li>
</ul>
<p><strong>JWT（如果用了）</strong></p>
<ul>
<li> 有效期短（15 分钟内）？</li>
<li> 验证时显式指定算法，不用 token 里的？</li>
<li> 校验了 iss 和 aud？</li>
<li> 需要撤销时有黑名单机制？</li>
<li> token 里没有敏感信息，且体积可控？</li>
</ul>
<p><strong>OAuth</strong></p>
<ul>
<li> 用了 state 参数防 CSRF？</li>
<li> client_secret 只在后端？</li>
<li> redirect_uri 做了严格校验（防止授权码被劫持）？</li>
<li> 每个用户会话独立存储 token？</li>
</ul>
<p><strong>授权</strong></p>
<ul>
<li> 权限检查在服务端，不是只在前端隐藏按钮？</li>
<li> 数据范围的限制下推到查询条件里？</li>
<li> 认证失败返回 401，权限不足返回 403？</li>
<li> 关键操作的日志记录了操作人和时间？</li>
</ul>
<p>最后一条在合规审计时经常被问到，而且补起来最难 —— <strong>要记录就得从一开始就记</strong>。</p>]]></content>
    <category term="后端" />
    <category term="安全" />
  </entry>
  <entry>
    <title>后端可观测性：日志、指标与链路追踪</title>
    <link href="https://junaoe.dev/posts/observability" rel="alternate" type="text/html"/>
    <id>https://junaoe.dev/posts/observability</id>
    <updated>2026-10-09T00:00:00.000Z</updated>
    <published>2026-10-09T00:00:00.000Z</published>
    <author>
      <name>junaoe</name>
    </author>
    <summary type="text">出问题时怎么快速定位。这篇讲三大支柱各自的用途、结构化日志怎么写、Prometheus 指标该怎么埋、分布式追踪如何串起一次跨服务请求，以及告警怎么设才不会被淹没。</summary>
    <content type="html"><![CDATA[
<p>系统出问题时，最耗时间的从来不是修复，而是<strong>搞清楚发生了什么</strong>。</p>
<p>可观测性的目标不是「收集尽可能多的数据」，而是：<strong>在故障发生时，能用最少的时间定位到问题所在。</strong></p>
<h2>三大支柱</h2>

























<table><thead><tr><th>支柱</th><th>回答什么问题</th><th>数据形态</th></tr></thead><tbody><tr><td><strong>日志 Logs</strong></td><td>具体发生了什么？</td><td>离散的事件记录</td></tr><tr><td><strong>指标 Metrics</strong></td><td>系统整体健康吗？趋势如何？</td><td>随时间聚合的数值</td></tr><tr><td><strong>追踪 Traces</strong></td><td>一次请求经过了哪些服务？</td><td>请求的完整调用链</td></tr></tbody></table>
<p>它们的定位不同，缺一不可：</p>
<ul>
<li>只有日志 → 知道出了错，但不知道「错误率是不是在涨」</li>
<li>只有指标 → 发现 QPS 掉了，但不知道具体哪笔请求、哪里出错</li>
<li>只有追踪 → 看到某个请求很慢，但不知道服务整体的健康状况</li>
</ul>
<h2>结构化日志：别再打字符串拼接</h2>
<p>最常见的写法，也是最难排查的写法：</p>
<pre><code># ❌ 无法检索，无法聚合
log.info(f"用户 {user_id} 下单成功，订单 {order_id}，金额 {amount}，耗时 {elapsed}ms")
</code></pre>
<p>要查「昨天所有金额超过 1000 的订单」，你得写正则去 grep 几百万行文本。</p>
<p><strong>结构化日志把数据变成键值对</strong>：</p>
<pre><code>import structlog

logger = structlog.get_logger()

logger.info(
    "order_created",
    user_id=user_id,
    order_id=order_id,
    amount=float(amount),        # 金额用数字，不是字符串
    elapsed_ms=elapsed,
    status="success",
)
</code></pre>
<p>输出是 JSON，可以被 Elasticsearch / Loki 直接索引：</p>
<pre><code>{
  "event": "order_created",
  "user_id": 12345,
  "order_id": "ord_01HQ8X",
  "amount": 199.00,
  "elapsed_ms": 42,
  "status": "success",
  "timestamp": "2026-10-09T08:30:15.123Z",
  "level": "info",
  "service": "order-api",
  "version": "1.4.2",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736"
}
</code></pre>
<p>现在同一个查询变成一行：</p>
<pre><code>order_created AND amount &gt; 1000 AND status:"success"
</code></pre>
<h3>关键字段的设计</h3>
<p><strong>1. 事件名用统一的命名规范</strong></p>
<p><code>order_created</code>、<code>order_failed</code>、<code>payment_timeout</code> —— 过去式，动宾结构。</p>
<p>不要写「处理订单」这种没法聚合的名字。同一个事件名在代码里只出现一次，字段可变化。</p>
<p><strong>2. 上下文自动注入</strong></p>
<p>手写 <code>user_id=current_user.id</code> 到处都会忘。用中间件统一注入：</p>
<pre><code>import structlog
import contextvars

# 用 contextvars 让日志自动带上当前请求的上下文
request_ctx = contextvars.ContextVar('request_ctx')

def logging_middleware(request, call_next):
    request_id = request.headers.get('X-Request-Id') or str(uuid.uuid4())
    user_id = getattr(request.state, 'user_id', None)
    trace_id = getattr(request.state, 'trace_id', None)

    # 绑定上下文，这个值在这个协程的所有日志里都可见
    request_ctx.set({
        'request_id': request_id,
        'user_id': user_id,
        'trace_id': trace_id,
    })

    response = call_next(request)
    response.headers['X-Request-Id'] = request_id
    return response


structlog.configure(
    processors=[
        structlog.contextvars.merge_contextvars,    # 合并上下文
        structlog.processors.add_log_level,
        structlog.processors.TimeStamper(fmt='iso'),
        structlog.processors.JSONRenderer(),
    ],
    wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
)
</code></pre>
<p>之后业务代码只管记自己关心的：</p>
<pre><code>logger.info("order_created", order_id=order.id, amount=amount)
# 自动带上 request_id / user_id / trace_id
</code></pre>
<p><strong>3. 错误要记栈，但别记敏感信息</strong></p>
<pre><code>try:
    process_payment(order)
except PaymentError as e:
    logger.error(
        "payment_failed",
        order_id=order.id,
        provider=e.provider,
        error_code=e.code,
        exc_info=True,        # 记完整栈
    )
    raise
finally:
    logger.debug(
        "payment_request",
        card_number=mask(e.card),      # 脱敏
        cvv=undefined,                 # 绝不能出现
    )
</code></pre>
<p><strong>4. 日志级别要用对</strong></p>






























<table><thead><tr><th>级别</th><th>什么时候用</th><th>生产环境</th></tr></thead><tbody><tr><td>ERROR</td><td>需要人介入处理</td><td>开启，触发告警</td></tr><tr><td>WARN</td><td>异常但已处理</td><td>开启</td></tr><tr><td>INFO</td><td>关键业务事件</td><td>开启，但要控制量</td></tr><tr><td>DEBUG</td><td>调试细节</td><td>关闭，用时动态开</td></tr></tbody></table>
<p><strong>最常见的错误是 INFO 打太多</strong>。每个循环、每次查询都打 INFO，日志量爆炸后存储成本飙升，还淹没了真正重要的信息。原则：<strong>INFO 只记关键的业务状态变更。</strong></p>
<h2>指标：选对类型比选对名字重要</h2>
<p>Prometheus 的四种指标类型各有适用场景。</p>
<h3>Counter（计数器）—— 只增不减</h3>
<p>适合：请求总数、错误总数、任务处理数。</p>
<pre><code>from prometheus_client import Counter

REQUESTS = Counter(
    'http_requests_total',
    'HTTP 请求总数',
    ['method', 'path', 'status'],      # label
)

REQUESTS.labels(method='GET', path='/orders', status='200').inc()
</code></pre>
<p><strong>不要用 Counter 记「当前有多少」</strong> —— 它只增不减，进程重启会归零，画图出来全是对的但实际含义不对。</p>
<h3>Gauge（仪表）—— 可增可减</h3>
<p>适合：连接池使用量、队列长度、内存占用。</p>
<pre><code>from prometheus_client import Gauge

QUEUE_LENGTH = Gauge('mq_queue_length', '消息队列积压量', ['queue_name'])

QUEUE_LENGTH.labels(queue_name='order.created').set(len(queue))
</code></pre>
<p><strong>队列积压是最值得监控的指标之一</strong>，它通常在故障发生前很久就开始上涨。</p>
<h3>Histogram（直方图）—— 观测分布</h3>
<p>适合：延迟、响应体大小。</p>
<pre><code>REQUEST_DURATION = Histogram(
    'http_request_duration_seconds',
    '请求耗时',
    ['method', 'path'],
    buckets=(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0),
)

REQUEST_DURATION.labels(method='GET', path='/orders').observe(elapsed)
</code></pre>
<p><strong>Histogram 是延迟监控的核心。</strong> 它不只给你平均值，而是让你能回答「99 分位的延迟是多少」。</p>
<div><div><div></div><div>IMPORTANT</div></div><div><p><strong>永远不要用平均值看延迟。</strong> 平均 100ms 听起来不错，但如果 1% 的请求是 10 秒，用户体验就是灾难。SLO 应该基于 P95/P99。</p></div></div>
<h3>Label 是双刃剑</h3>
<p>Label 让指标有了维度，但<strong>基数爆炸</strong>是常见事故来源。</p>
<pre><code># ❌ 灾难：每个用户一个时间序列
REQUESTS.labels(user_id=str(user.id)).inc()      # 百万用户 = 百万序列

# ❌ path 没归一化，每个 ID 一个序列
REQUESTS.labels(path=f'/orders/{order_id}').inc()

# ✅ 用路由模板
REQUESTS.labels(path='/orders/{id}').inc()
</code></pre>
<p>规则：<strong>label 的值必须来自有限集合</strong>。判断标准是「这个值有上界吗」—— 用户 ID 没有上界，路由模板有。</p>
<p>监控本身的资源消耗也可能拖垮服务，这是真实发生过的故障。</p>
<h2>链路追踪：串起一次请求</h2>
<p>一次用户请求可能经过网关、订单服务、库存服务、支付服务、数据库。没有追踪的话，你看到的是四个服务各自正常的日志，无法知道谁慢。</p>
<h3>OpenTelemetry</h3>
<pre><code>from opentelemetry import trace
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor

provider = TracerProvider()
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(endpoint="http://collector:4317")))
trace.set_tracer_provider(provider)

FastAPIInstrumentor.instrument_app(app)
tracer = trace.get_tracer(__name__)


def get_order(order_id: str):
    with tracer.start_as_current_span("load_order") as span:
        span.set_attribute("order.id", order_id)
        span.set_attribute("db.system", "postgresql")

        result = db.query_one(
            "SELECT * FROM orders WHERE id = :id",
            {'id': order_id},
        )

        span.set_attribute("db.rows_found", 1 if result else 0)
        return result
</code></pre>
<p>自动插桩（<code>FastAPIInstrumentor</code>）会记录 HTTP 层和数据库调用的耗时，你只需要给关键的<strong>业务逻辑</strong>手动加 span。</p>
<h3>上下文如何在服务间传递</h3>
<p>W3C Trace Context 标准，通过 HTTP 头传递：</p>
<pre><code>traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
              └─ trace-id ──────────────────────────────┘ └span-id┘ └flags┘
</code></pre>
<p><strong>跨服务调用必须透传这个头</strong>，否则追踪链会断。网关或服务调用下游时，要么用支持 OTel 的客户端库自动传递，要么手动带：</p>
<pre><code>import httpx

def call_downstream():
    headers = {}
    trace.get_current_span().get_span_context()
    # 注入器会自动把 traceparent 加到 headers
    inject(headers)

    httpx.get("http://inventory-service/stock", headers=headers)
</code></pre>
<h3>采样策略</h3>
<p>全量追踪成本很高（存储 + 网络）。采样是必需的。</p>
<p><strong>头部采样</strong>（在入口决定要不要记录）：按比例或按规则。</p>
<pre><code>class CustomSampler(Sampler):
    def should_sample(self, parent_context, trace_id, name, kind, attributes, links):
        # 错误请求全采，成功请求采 5%
        if attributes.get('http.status_code', 200) &gt;= 500:
            return SamplingResult(Decision.RECORD_AND_SAMPLE)
        return SamplingResult(Decision.RECORD_AND_SAMPLE
                              if random.random() &lt; 0.05
                              else Decision.DROP)
</code></pre>
<p><strong>尾部采样</strong>（等请求结束后再决定）：先在内存里缓存整个 trace，如果里面有错误或延迟超阈值就上报，否则丢弃。<strong>它能保证「所有出错的请求都有完整追踪」，同时只存少量成功请求的追踪</strong>，成本更低。</p>
<div><div><div></div><div>NOTE</div></div><div><p>采样率别低于 1%。低于这个数，你在排查问题时经常会发现「这个请求恰好没被采到」。</p></div></div>
<h3>关键 span 要打什么</h3>
<p>不是所有操作都值得记录。优先级：</p>
<ol>
<li><strong>跨服务调用</strong> —— 一定要有</li>
<li><strong>数据库查询</strong> —— 记录 SQL 模板（不是参数！参数可能含敏感信息）</li>
<li><strong>缓存操作</strong> —— 命中率、耗时</li>
<li><strong>消息队列的生产和消费</strong> —— 把两个 trace 关联起来</li>
<li><strong>重要的业务操作</strong> —— 手动加</li>
</ol>
<h2>告警：少而准才有价值</h2>
<p><strong>告警疲劳是监控系统失败的最常见方式</strong>：告警太多 → 没人看 → 真出事了也不信告警。</p>
<h3>告警基于症状，不基于原因</h3>
<pre><code># ❌ 原因告警：CPU 高不一定有问题
alert: pod_cpu_usage &gt; 80%

# ✅ 症状告警：用户真的受影响了吗
alert: api_error_rate &gt; 1% for 5m
alert: api_p99_latency &gt; 1s for 5m
</code></pre>
<p>原因告警会在正常的高峰期疯狂触发，最后没人理。<strong>症状告警直接对应用户体感</strong>，才值得叫醒人。</p>
<h3>分级</h3>

























<table><thead><tr><th>级别</th><th>定义</th><th>响应</th></tr></thead><tbody><tr><td>P1</td><td>核心功能完全不可用</td><td>立刻叫人</td></tr><tr><td>P2</td><td>核心功能降级</td><td>工作时间内处理</td></tr><tr><td>P3</td><td>非核心异常</td><td>记录，工作时间处理</td></tr></tbody></table>
<p><strong>半夜叫人起床的告警应该少到让团队愿意响应。</strong> 如果每周有五件事半夜找你，你会开始忽略所有 P1。</p>
<h3>具体怎么设</h3>
<pre><code>groups:
  - name: api
    rules:
      # 错误率：5 分钟内超过 1%
      - alert: HighErrorRate
        expr: |
          sum(rate(http_requests_total{status=~"5.."}[5m]))
          / sum(rate(http_requests_total[5m])) &gt; 0.01
        for: 5m
        labels:
          severity: page       # 半夜叫人
        annotations:
          summary: "错误率 {{ $value | humanizePercentage }}"

      # 队列积压：10 分钟内持续增长
      - alert: QueueBacklogGrowing
        expr: |
          deriv(mq_queue_length[10m]) &gt; 0
          and mq_queue_length &gt; 1000
        for: 10m
        labels:
          severity: ticket     # 工作时间处理

      # 延迟：用 P99 而不是平均
      - alert: HighLatency
        expr: |
          histogram_quantile(0.99,
            sum(rate(http_request_duration_seconds_bucket[5m])) by (le)
          ) &gt; 1
        for: 5m
        labels:
          severity: ticket
</code></pre>
<p>注意三个细节：</p>
<ul>
<li><strong><code>for: 5m</code> 的持续时间</strong> —— 避免瞬时抖动触发告警</li>
<li><strong><code>deriv()</code> 判断趋势</strong> —— 队列积压 1000 条可能是正常的，但<strong>持续增长</strong>意味着上游有变化、下游跟不上，这才是问题</li>
<li><strong><code>severity</code> 标签</strong> —— 路由到不同通道</li>
</ul>
<h2>黄金四指标（Google SRE）</h2>
<p>每个服务至少监控这四个：</p>






























<table><thead><tr><th>指标</th><th>定义</th><th>关注点</th></tr></thead><tbody><tr><td><strong>延迟 Latency</strong></td><td>请求耗时分布</td><td>P50 体验、P99 尾部</td></tr><tr><td><strong>流量 Traffic</strong></td><td>请求速率</td><td>突增突降</td></tr><tr><td><strong>错误 Errors</strong></td><td>失败请求比例</td><td>5xx 比例</td></tr><tr><td><strong>饱和度 Saturation</strong></td><td>资源接近上限的程度</td><td>队列长度、连接池、CPU</td></tr></tbody></table>
<p><strong>饱和度是最容易被漏掉的。</strong> 队列长度、连接池使用率、内存水位 —— 这些资源在真正耗尽之前，通常已经积压了一段时间。这段积压时间就是你发现问题的窗口。</p>
<h2>排查流程</h2>
<p>出问题时按这个顺序走，能省掉大量盲目搜索。</p>
<pre><code>1. 现象是什么？
   └─ 指标：哪个指标异常？什么时间开始的？

2. 影响范围多大？
   └─ 指标：全部用户还是部分？哪个接口？

3. 什么时候开始的？
   └─ 指标：对比历史，看有无关联变更

4. 变了什么？
   └─ 追踪：找一个 traceId 作为入口

5. 哪个环节慢/错？
   └─ 追踪：看各 span 的耗时，定位到具体服务

6. 具体报错是什么？
   └─ 日志：用 traceId 检索该次请求的完整日志
</code></pre>
<p><strong>关键是第 4 步 —— 用指标定位时间点，再用追踪找到具体请求，最后用日志看细节。</strong></p>
<p>直接从日志开始搜是常见错误：几百万行日志里找到一个相关的 stack trace 极其困难，而且你事先不知道该搜什么关键词。</p>
<h2>一份最小可行清单</h2>
<p>不要一开始就上全套工具。<strong>先把这几样做好，价值比堆工具大得多</strong>：</p>
<ul>
<li> 日志是结构化的（JSON），关键事件有统一命名</li>
<li> 每个请求都有 request_id，贯穿所有日志</li>
<li> 四个黄金指标都有监控面板</li>
<li> 延迟用 Histogram，能看 P99</li>
<li> 告警基于用户可感知的症状，且有分级</li>
<li> 队列积压、消费延迟这类「先兆指标」有监控</li>
<li> 跨服务调用传递了 trace 上下文</li>
<li> 有能力按 traceId 检索出完整调用链</li>
</ul>
<p><strong>先做到能快速定位问题，再考虑上更 fancy 的工具。</strong> 大多数团队的监控问题不是工具不够，而是没把基础这几样做扎实。</p>]]></content>
    <category term="后端" />
    <category term="可观测性" />
  </entry>
</feed>