<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Spring Boot on 方糖猫Blog</title>
    <link>https://ftmao.com/blog/tags/spring-boot/</link>
    <description>AI 视频、AI Agent 与工程实践笔记</description>
    <generator>Hugo</generator>
    <language>zh-CN</language>
    <lastBuildDate>Mon, 20 Apr 2026 14:00:00 +0800</lastBuildDate><atom:link href="https://ftmao.com/blog/tags/spring-boot/index.xml" rel="self" type="application/rss+xml" /><item>
      <title>Spring Boot &#43; Python 双栈架构：AI 服务的工程化实践</title>
      <link>https://ftmao.com/blog/posts/java2python/</link>
      <pubDate>Mon, 20 Apr 2026 14:00:00 +0800</pubDate>
      <guid>https://ftmao.com/blog/posts/java2python/</guid>
      <description>&lt;h1 id=&#34;spring-boot--python-双栈架构ai-服务的工程化实践&#34;&gt;Spring Boot + Python 双栈架构：AI 服务的工程化实践&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;我开发的方糖猫 AI 视频平台不是一个纯 Python 项目，也不是一个纯 Java 项目。它同时使用了 Spring Boot 3 和 Python 3.11：Java 负责业务系统和 API 服务，Python 负责 AI 推理、渲染和多媒体处理。这篇文章会分享我们为什么要这样做，以及如何解决两个技术栈之间的协作问题。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2 id=&#34;一为什么必须双栈&#34;&gt;一、为什么必须双栈？&lt;/h2&gt;
&lt;p&gt;做一个 AI 视频生成平台，需要两类能力：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;能力类型&lt;/th&gt;
					&lt;th&gt;典型需求&lt;/th&gt;
					&lt;th&gt;更适合的栈&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;企业级业务系统&lt;/td&gt;
					&lt;td&gt;用户、订单、权限、支付、任务管理&lt;/td&gt;
					&lt;td&gt;Java / Spring Boot&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;AI 推理与多媒体&lt;/td&gt;
					&lt;td&gt;LLM 调用、Whisper、RAG、Remotion、FFmpeg&lt;/td&gt;
					&lt;td&gt;Python&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Java 的优势在于生态成熟、工程规范、团队熟悉；Python 的优势在于 AI 库丰富、模型推理和多媒体处理工具链完善。&lt;/p&gt;
&lt;p&gt;如果全用 Java，AI 库支持差，很多模型没有 Java SDK；如果全用 Python，业务系统的稳定性、事务处理、监控运维又很难做好。&lt;/p&gt;
&lt;p&gt;所以我们的选择是：&lt;strong&gt;Java 做业务底盘，Python 做 AI 引擎，两者通过清晰的接口协作&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;二整体架构&#34;&gt;二、整体架构&lt;/h2&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart TD
    FE[&amp;#34;前端 (Vue 3)&amp;#34;]
    GW[&amp;#34;API 网关 / BFF (Spring Boot)&amp;lt;br/&amp;gt;用户认证、请求路由、限流、任务状态查询、SSE 推送&amp;#34;]
    BIZ[&amp;#34;业务服务&amp;lt;br/&amp;gt;(Spring Boot)&amp;#34;]
    SCHED[&amp;#34;任务调度服务&amp;lt;br/&amp;gt;(Spring Boot)&amp;#34;]
    RES[&amp;#34;资源管理服务&amp;lt;br/&amp;gt;(Spring Boot)&amp;#34;]
    AI[&amp;#34;AI Worker (Python)&amp;lt;br/&amp;gt;ReAct Agent、RAG 检索、Whisper、TTS、Remotion、FFmpeg&amp;#34;]
    STORE[&amp;#34;存储层：MySQL / Redis / OSS / ChromaDB&amp;#34;]

    FE --&amp;gt; GW
    GW --&amp;gt; BIZ
    GW --&amp;gt; SCHED
    GW --&amp;gt; RES
    SCHED -- &amp;#34;RocketMQ / Kafka&amp;#34; --&amp;gt; AI
    AI --&amp;gt; STORE&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;hr&gt;
&lt;h2 id=&#34;三职责划分&#34;&gt;三、职责划分&lt;/h2&gt;
&lt;h3 id=&#34;31-java-侧职责&#34;&gt;3.1 Java 侧职责&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;API 网关&lt;/strong&gt;：统一入口、鉴权、限流、日志&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;用户与任务管理&lt;/strong&gt;：用户注册登录、任务创建、任务状态机、历史记录&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;素材元数据管理&lt;/strong&gt;：素材上传、分类、标签、权限&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;支付与计费&lt;/strong&gt;（如有）：订单、套餐、用量统计&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;任务调度&lt;/strong&gt;：把渲染/生成任务发送到消息队列&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;实时通知&lt;/strong&gt;：SSE 推送任务进度给用户&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;32-python-侧职责&#34;&gt;3.2 Python 侧职责&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;AI Agent 编排&lt;/strong&gt;：ReAct 任务规划、Tool 调用&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;RAG 检索&lt;/strong&gt;：Embedding 计算、向量库查询&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;语音识别&lt;/strong&gt;：Whisper 转写&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;语音合成&lt;/strong&gt;：TTS 配音&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;视频渲染&lt;/strong&gt;：Remotion 组件化渲染&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;后处理&lt;/strong&gt;：FFmpeg 转码、压缩、字幕、多分辨率输出&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;33-边界原则&#34;&gt;3.3 边界原则&lt;/h3&gt;
&lt;p&gt;我们定了几条原则来避免职责混乱：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Python 不直接暴露给用户&lt;/strong&gt;。所有用户请求都走 Java API。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Python 只做无状态计算&lt;/strong&gt;。任务状态、用户信息、素材元数据都在 Java 侧维护。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;通信以异步为主&lt;/strong&gt;。AI 任务通常耗时较长，同步调用容易导致超时和耦合。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;数据以 ID 为纽带&lt;/strong&gt;。Java 和 Python 之间不传大对象，只传任务 ID 和必要参数。&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h2 id=&#34;四服务间通信&#34;&gt;四、服务间通信&lt;/h2&gt;
&lt;h3 id=&#34;41-异步通信rocketmq&#34;&gt;4.1 异步通信：RocketMQ&lt;/h3&gt;
&lt;p&gt;AI 生成和渲染任务都走 RocketMQ：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-java&#34; data-lang=&#34;java&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// Java 侧发送任务&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;SendResult result &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; rocketMQTemplate.&lt;span style=&#34;color:#a6e22e&#34;&gt;syncSend&lt;/span&gt;(
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;video-render&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    MessageBuilder.&lt;span style=&#34;color:#a6e22e&#34;&gt;withPayload&lt;/span&gt;(taskMessage).&lt;span style=&#34;color:#a6e22e&#34;&gt;build&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-python&#34; data-lang=&#34;python&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;# Python 侧消费任务&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;class&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;RenderConsumer&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;def&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;on_message&lt;/span&gt;(self, msg):
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        task &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; json&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;loads(msg&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;body)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        result &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; render_engine&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;process(task)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        self&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;update_task_status(task[&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;task_id&amp;#34;&lt;/span&gt;], &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;completed&amp;#34;&lt;/span&gt;, result)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        &lt;span style=&#34;color:#66d9ef&#34;&gt;return&lt;/span&gt; ConsumeStatus&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;CONSUME_SUCCESS&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;p&gt;异步通信的优点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;解耦 Java 和 Python 服务&lt;/li&gt;
&lt;li&gt;Python Worker 可以独立扩缩容&lt;/li&gt;
&lt;li&gt;任务失败可以重试，不阻塞主流程&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;42-同步通信http--grpc&#34;&gt;4.2 同步通信：HTTP / gRPC&lt;/h3&gt;
&lt;p&gt;少数场景需要同步调用，比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;查询 RAG 素材（用户在前端点击「生成」前希望看到素材推荐）&lt;/li&gt;
&lt;li&gt;获取任务实时状态&lt;/li&gt;
&lt;li&gt;健康检查&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我们主要用 HTTP REST，因为：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;简单直观，Java 和 Python 生态都完善&lt;/li&gt;
&lt;li&gt;调试方便，curl 即可测试&lt;/li&gt;
&lt;li&gt;对延迟要求不极端&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;gRPC 我们也考虑过，但因为团队对 gRPC 熟悉度不高，且收益不明显，暂时没引入。&lt;/p&gt;
&lt;h3 id=&#34;43-数据一致性&#34;&gt;4.3 数据一致性&lt;/h3&gt;
&lt;p&gt;双栈架构下，任务状态是核心数据。我们用数据库作为唯一真相源：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart TD
    A[&amp;#34;用户提交任务&amp;#34;] --&amp;gt; B[&amp;#34;Java 写入任务记录（status=pending）&amp;#34;]
    B --&amp;gt; C[&amp;#34;发送 RocketMQ 消息&amp;#34;]
    C --&amp;gt; D[&amp;#34;Python Worker 消费&amp;#34;]
    D --&amp;gt; E[&amp;#34;Python 更新任务状态（status=processing/completed/failed）&amp;#34;]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;Python Worker 更新数据库时，通过乐观锁防止并发冲突：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-sql&#34; data-lang=&#34;sql&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;UPDATE&lt;/span&gt; render_task
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;SET&lt;/span&gt; status &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;processing&amp;#39;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    worker_id &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;worker-01&amp;#39;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    updated_at &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; NOW()
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;WHERE&lt;/span&gt; task_id &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;xxx&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#66d9ef&#34;&gt;AND&lt;/span&gt; status &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;pending&amp;#39;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;hr&gt;
&lt;h2 id=&#34;五数据库设计&#34;&gt;五、数据库设计&lt;/h2&gt;
&lt;h3 id=&#34;51-按服务分库还是分表&#34;&gt;5.1 按服务分库还是分表？&lt;/h3&gt;
&lt;p&gt;我们没有完全按微服务分库，而是按业务域分表：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;user&lt;/code&gt;：用户信息&lt;/li&gt;
&lt;li&gt;&lt;code&gt;task&lt;/code&gt;：生成任务&lt;/li&gt;
&lt;li&gt;&lt;code&gt;material&lt;/code&gt;：素材元数据&lt;/li&gt;
&lt;li&gt;&lt;code&gt;template&lt;/code&gt;：视频模板&lt;/li&gt;
&lt;li&gt;&lt;code&gt;subscription&lt;/code&gt;：订阅/套餐&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所有表都在一个 MySQL 实例里，通过业务 ID 关联。这种方案在业务量不大时性价比最高，未来可按域拆分。&lt;/p&gt;
&lt;h3 id=&#34;52-python-侧如何访问数据库&#34;&gt;5.2 Python 侧如何访问数据库&lt;/h3&gt;
&lt;p&gt;Python 侧需要读取/更新任务状态，我们用的是 SQLAlchemy + 只读/写分离：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-python&#34; data-lang=&#34;python&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;from&lt;/span&gt; sqlalchemy &lt;span style=&#34;color:#f92672&#34;&gt;import&lt;/span&gt; create_engine
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;from&lt;/span&gt; sqlalchemy.orm &lt;span style=&#34;color:#f92672&#34;&gt;import&lt;/span&gt; sessionmaker
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;engine &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; create_engine(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;mysql+pymysql://user:pass@host/db&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;Session &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; sessionmaker(bind&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;engine)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;def&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;update_task_status&lt;/span&gt;(task_id: str, status: str):
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;with&lt;/span&gt; Session() &lt;span style=&#34;color:#66d9ef&#34;&gt;as&lt;/span&gt; session:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        session&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;execute(
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;            &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;UPDATE render_task SET status = &lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;%s&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt; WHERE task_id = &lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;%s&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;            (status, task_id)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        )
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        session&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;commit()&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;p&gt;为了降低耦合，Python 侧只更新少量任务状态字段，不碰业务核心表。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;六部署与运维&#34;&gt;六、部署与运维&lt;/h2&gt;
&lt;h3 id=&#34;61-容器化&#34;&gt;6.1 容器化&lt;/h3&gt;
&lt;p&gt;两个栈都用 Docker 打包：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-dockerfile&#34; data-lang=&#34;dockerfile&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;# Java 服务 Dockerfile&lt;/span&gt;&lt;span style=&#34;color:#960050;background-color:#1e0010&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;FROM&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;eclipse-temurin:17-jdk&lt;/span&gt;&lt;span style=&#34;color:#960050;background-color:#1e0010&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;COPY&lt;/span&gt; target/app.jar /app.jar&lt;span style=&#34;color:#960050;background-color:#1e0010&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;ENTRYPOINT&lt;/span&gt; [&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;java&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;-jar&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;/app.jar&amp;#34;&lt;/span&gt;]&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-dockerfile&#34; data-lang=&#34;dockerfile&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;# Python Worker Dockerfile&lt;/span&gt;&lt;span style=&#34;color:#960050;background-color:#1e0010&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;FROM&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;python:3.11-slim&lt;/span&gt;&lt;span style=&#34;color:#960050;background-color:#1e0010&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;WORKDIR&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;/app&lt;/span&gt;&lt;span style=&#34;color:#960050;background-color:#1e0010&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;COPY&lt;/span&gt; requirements.txt .&lt;span style=&#34;color:#960050;background-color:#1e0010&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;RUN&lt;/span&gt; pip install -r requirements.txt&lt;span style=&#34;color:#960050;background-color:#1e0010&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;COPY&lt;/span&gt; . .&lt;span style=&#34;color:#960050;background-color:#1e0010&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;CMD&lt;/span&gt; [&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;python&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;worker.py&amp;#34;&lt;/span&gt;]&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;h3 id=&#34;62-cicd&#34;&gt;6.2 CI/CD&lt;/h3&gt;
&lt;p&gt;我们用一个简单的 GitHub Actions / Jenkins 流程：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;代码提交触发构建&lt;/li&gt;
&lt;li&gt;分别构建 Java 镜像和 Python 镜像&lt;/li&gt;
&lt;li&gt;推送到镜像仓库&lt;/li&gt;
&lt;li&gt;生产环境滚动更新&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&#34;63-日志与链路追踪&#34;&gt;6.3 日志与链路追踪&lt;/h3&gt;
&lt;p&gt;双栈环境下，排查问题最大的难点是链路跨语言。我们的做法：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;所有请求携带 &lt;code&gt;trace_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Java 和 Python 都把 &lt;code&gt;trace_id&lt;/code&gt; 打到日志里&lt;/li&gt;
&lt;li&gt;用 ELK / Loki 做日志聚合，按 &lt;code&gt;trace_id&lt;/code&gt; 查询全链路&lt;/li&gt;
&lt;/ul&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-python&#34; data-lang=&#34;python&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;import&lt;/span&gt; logging
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;logger &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; logging&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;getLogger(__name__)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;logger&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;info(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;Start render&amp;#34;&lt;/span&gt;, extra&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;{&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;trace_id&amp;#34;&lt;/span&gt;: trace_id, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;task_id&amp;#34;&lt;/span&gt;: task_id})&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;h3 id=&#34;64-监控告警&#34;&gt;6.4 监控告警&lt;/h3&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;层级&lt;/th&gt;
					&lt;th&gt;监控项&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;Java 服务&lt;/td&gt;
					&lt;td&gt;JVM 内存、GC、接口响应时间、错误率&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Python Worker&lt;/td&gt;
					&lt;td&gt;CPU/GPU 利用率、任务处理速率、队列堆积&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;数据库&lt;/td&gt;
					&lt;td&gt;连接数、慢查询&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;消息队列&lt;/td&gt;
					&lt;td&gt;堆积量、消费速率&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h2 id=&#34;七踩过的坑&#34;&gt;七、踩过的坑&lt;/h2&gt;
&lt;h3 id=&#34;71-序列化不一致&#34;&gt;7.1 序列化不一致&lt;/h3&gt;
&lt;p&gt;Java 侧用 Jackson，Python 侧用默认 json，时间格式、枚举值、空值处理不一致，导致接口对接时经常报错。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;解决&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;统一用 ISO 8601 时间格式&lt;/li&gt;
&lt;li&gt;枚举值用字符串传递&lt;/li&gt;
&lt;li&gt;制定统一的 JSON Schema，用 Pydantic 和 Java Bean Validation 双向校验&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;72-python-服务内存泄漏&#34;&gt;7.2 Python 服务内存泄漏&lt;/h3&gt;
&lt;p&gt;Python Worker 长时间运行后，内存占用越来越高，尤其是加载大模型后。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;解决&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Worker 处理一定数量任务后自动重启&lt;/li&gt;
&lt;li&gt;模型用单例模式加载，避免重复初始化&lt;/li&gt;
&lt;li&gt;显式清理大对象，调用 &lt;code&gt;gc.collect()&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;73-跨语言异常处理&#34;&gt;7.3 跨语言异常处理&lt;/h3&gt;
&lt;p&gt;Python 异常信息传到 Java 侧变成一团乱码，前端无法友好展示。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;解决&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;定义统一的错误码体系&lt;/li&gt;
&lt;li&gt;Python 捕获异常后按规范封装错误响应&lt;/li&gt;
&lt;li&gt;Java 侧根据错误码映射为用户友好的提示&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;74-开发环境复杂度&#34;&gt;7.4 开发环境复杂度&lt;/h3&gt;
&lt;p&gt;开发时需要同时启动 Java 服务、Python Worker、MySQL、Redis、RocketMQ，新成员上手成本高。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;解决&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;提供 &lt;code&gt;docker-compose.yml&lt;/code&gt; 一键启动基础设施&lt;/li&gt;
&lt;li&gt;编写本地开发文档&lt;/li&gt;
&lt;li&gt;支持 Windows 开发环境降级（Python Worker 用 HTTP 模拟 RocketMQ）&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&#34;八双栈架构的收益与代价&#34;&gt;八、双栈架构的收益与代价&lt;/h2&gt;
&lt;h3 id=&#34;81-收益&#34;&gt;8.1 收益&lt;/h3&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;方面&lt;/th&gt;
					&lt;th&gt;说明&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;团队分工清晰&lt;/td&gt;
					&lt;td&gt;Java 工程师做业务，Python 工程师做 AI&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;技术选型合理&lt;/td&gt;
					&lt;td&gt;每个栈都用最合适的工具&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;稳定性高&lt;/td&gt;
					&lt;td&gt;业务系统独立演进，AI 故障不直接影响用户注册/支付&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;扩展灵活&lt;/td&gt;
					&lt;td&gt;Python Worker 可以根据渲染负载单独扩容&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&#34;82-代价&#34;&gt;8.2 代价&lt;/h3&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;方面&lt;/th&gt;
					&lt;th&gt;说明&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;运维复杂&lt;/td&gt;
					&lt;td&gt;两套构建、部署、监控体系&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;链路排查难&lt;/td&gt;
					&lt;td&gt;跨语言日志追踪需要额外设计&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;接口契约维护&lt;/td&gt;
					&lt;td&gt;Java 和 Python 之间需要稳定的 API 契约&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h2 id=&#34;九什么情况下不要双栈&#34;&gt;九、什么情况下不要双栈？&lt;/h2&gt;
&lt;p&gt;虽然双栈适合我们，但不是所有项目都需要：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;纯业务系统&lt;/strong&gt;：全 Java 即可&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;纯模型研究/实验&lt;/strong&gt;：全 Python 即可&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;团队规模小&lt;/strong&gt;：双栈会增加认知负担&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;MVP 早期&lt;/strong&gt;：先用最熟悉的栈跑通，后期再拆分&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我们是在产品验证基本跑通、团队有 5 人以上时才明确拆成双栈。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;十总结&#34;&gt;十、总结&lt;/h2&gt;
&lt;p&gt;Spring Boot + Python 双栈不是最优架构，而是&lt;strong&gt;适合我们团队能力和业务形态的架构&lt;/strong&gt;。几个关键经验：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;职责边界要清晰&lt;/strong&gt;，否则双栈会变成双倍的混乱。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;异步通信是主力&lt;/strong&gt;，同步只用于低延迟查询场景。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;数据库是状态的唯一真相源&lt;/strong&gt;，避免两端各自维护状态。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;日志追踪要跨语言打通&lt;/strong&gt;，否则线上出问题很难排查。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;不要为双栈而双栈&lt;/strong&gt;，MVP 阶段先用单栈验证。&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;如果你也在做 Java + Python 的混合架构，欢迎交流接口契约、部署和监控的经验。&lt;/em&gt;&lt;/p&gt;
</description>
    </item>
    <item>
      <title>我开发的keCache：面向 Spring Boot 3 的 L1 &#43; L2 两级缓存框架</title>
      <link>https://ftmao.com/blog/posts/kecache-intro/</link>
      <pubDate>Thu, 04 Sep 2025 14:16:44 +0800</pubDate>
      <guid>https://ftmao.com/blog/posts/kecache-intro/</guid>
      <description>&lt;p&gt;在高并发 Java 业务里，缓存几乎是标配。但“用缓存”这件事从来不是只有“加 Redis”一种答案：本地缓存快却解决不了多机一致，Redis 能共享却多了网络与序列化开销。我最近实现一个把两者结合起来、并针对集群一致性做了不少细节处理的小而美框架 —— &lt;strong&gt;keCache&lt;/strong&gt;，现在做一次系统梳理。&lt;/p&gt;
&lt;h2 id=&#34;一kecache-是什么&#34;&gt;一、keCache 是什么&lt;/h2&gt;
&lt;p&gt;keCache 是一个基于 &lt;strong&gt;Java 17 + Spring Boot 3.x + Caffeine + Redisson&lt;/strong&gt; 的本地缓存框架，采用 &lt;strong&gt;L1（Caffeine 本地缓存）+ L2（Redis 分布式缓存）&lt;/strong&gt; 两级架构，同时提供：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;声明式注解（&lt;code&gt;@LocalCacheable&lt;/code&gt;、&lt;code&gt;@LocalCacheEvict&lt;/code&gt;、&lt;code&gt;@LocalCacheClear&lt;/code&gt;）&lt;/li&gt;
&lt;li&gt;编程式 API（&lt;code&gt;CacheTemplate&lt;/code&gt;）&lt;/li&gt;
&lt;li&gt;集群失效广播（Redisson Pub/Sub）&lt;/li&gt;
&lt;li&gt;空值保护（防止缓存穿透）&lt;/li&gt;
&lt;li&gt;数据一致性校准 SPI&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;它的定位不是“又一个 Redis 客户端”，而是“让本地缓存能在集群环境下安全使用”的胶水层。&lt;/p&gt;
&lt;h3 id=&#34;11-整体架构&#34;&gt;1.1 整体架构&lt;/h3&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;graph TD
    App[业务 Service] --&amp;gt;|注解或 API| Facade[&amp;#34;CacheTemplate / AOP 切面&amp;#34;]
    Facade --&amp;gt; L1{L1 Caffeine}
    L1 --&amp;gt;|命中| Return[返回数据]
    L1 --&amp;gt;|未命中| L2{L2 Redis}
    L2 --&amp;gt;|命中| Deserialize[Jackson 反序列化]
    Deserialize --&amp;gt; Backfill[回填 L1]
    Backfill --&amp;gt; Return
    L2 --&amp;gt;|未命中| Load[&amp;#34;执行方法 / 加载数据&amp;#34;]
    Load --&amp;gt; Write[&amp;#34;写入 L1 &amp;amp; L2&amp;#34;]
    Write --&amp;gt; Return
    PubSub[&amp;#34;Redisson Pub/Sub&amp;#34;] --&amp;gt;|失效事件| L1
    Calibration[CalibrationProvider SPI] --&amp;gt;|定时校准| L2&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;h2 id=&#34;二开源地址&#34;&gt;二、开源地址&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Gitee：&lt;a href=&#34;https://gitee.com/soulnew/ke-cache&#34;&gt;https://gitee.com/soulnew/ke-cache&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&#34;三为什么需要-kecache与纯-redis--纯-caffeine-的对比&#34;&gt;三、为什么需要 keCache？与纯 Redis / 纯 Caffeine 的对比&lt;/h2&gt;
&lt;p&gt;很多团队在选择缓存方案时，常常在“纯 Redis”和“纯 Caffeine”之间摇摆。keCache 试图在二者之间取得平衡。&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;维度&lt;/th&gt;
					&lt;th&gt;纯 Caffeine&lt;/th&gt;
					&lt;th&gt;纯 Redis&lt;/th&gt;
					&lt;th&gt;keCache（L1 + L2）&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;读取延迟&lt;/td&gt;
					&lt;td&gt;极低（内存内）&lt;/td&gt;
					&lt;td&gt;较高（网络 + 序列化）&lt;/td&gt;
					&lt;td&gt;热数据近似 Caffeine，冷数据走 Redis&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;单机内存压力&lt;/td&gt;
					&lt;td&gt;大（全部数据在本 JVM）&lt;/td&gt;
					&lt;td&gt;小（Redis 承担）&lt;/td&gt;
					&lt;td&gt;小（只有热数据在本地）&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;集群一致性&lt;/td&gt;
					&lt;td&gt;无，各节点独立加载&lt;/td&gt;
					&lt;td&gt;强一致&lt;/td&gt;
					&lt;td&gt;最终一致，写操作广播失效&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;缓存穿透保护&lt;/td&gt;
					&lt;td&gt;需自行实现&lt;/td&gt;
					&lt;td&gt;需自行实现&lt;/td&gt;
					&lt;td&gt;内置 NullValue 短 TTL 保护&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;多机共享&lt;/td&gt;
					&lt;td&gt;不支持&lt;/td&gt;
					&lt;td&gt;支持&lt;/td&gt;
					&lt;td&gt;支持（L2 Redis）&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;适用场景&lt;/td&gt;
					&lt;td&gt;只读/准静态数据、单节点&lt;/td&gt;
					&lt;td&gt;写多读少、需要强一致&lt;/td&gt;
					&lt;td&gt;读多写少、可接受最终一致&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&#34;31-相比纯-caffeine-的好处&#34;&gt;3.1 相比纯 Caffeine 的好处&lt;/h3&gt;
&lt;p&gt;Caffeine 是 JVM 内缓存的佼佼者，但它天然只服务于当前进程。在集群部署时：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;每台机器都要独立加载数据&lt;/strong&gt;，导致数据库在启动/预热时被打 N 次。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;数据更新后其他节点无法感知&lt;/strong&gt;，容易出现 A 节点已更新、B 节点仍读旧值的“ stale data ”问题。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;内存占用随节点数线性增长&lt;/strong&gt;，同样一份热数据在每个 JVM 都存一份。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;keCache 用 L2 Redis 作为共享底座：首次加载从 Redis 取，后续读请求命中本地 Caffeine；写操作时通过 Redis Pub/Sub 通知其他节点清除本地缓存。这样既保留了 Caffeine 的本地速度，又解决了多机一致性难题。&lt;/p&gt;
&lt;h3 id=&#34;32-相比纯-redis-的好处&#34;&gt;3.2 相比纯 Redis 的好处&lt;/h3&gt;
&lt;p&gt;Redis 虽然性能好，但毕竟跨进程：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;网络 RTT 不可忽视&lt;/strong&gt;：高频读取时，哪怕 1ms 也会累积成可观的延迟。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;序列化/反序列化开销&lt;/strong&gt;：Java 对象要先转成 JSON/二进制，再还原，CPU 和 GC 都有压力。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;热点 Key 风险&lt;/strong&gt;：大促期间某些热 Key 会被反复打到同一个 Redis 节点。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;keCache 把最热的数据放在本地 JVM 里，只有本地未命中或更新时才访问 Redis，显著降低网络和序列化开销，也分散了 Redis 热点压力。&lt;/p&gt;
&lt;h3 id=&#34;33-方案选型速查&#34;&gt;3.3 方案选型速查&lt;/h3&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart TD
    Start([开始选缓存方案]) --&amp;gt; Q1{是否需要多机共享?}
    Q1 --&amp;gt;|否| Q2{数据是否只读 /&amp;lt;br/&amp;gt;准静态?}
    Q2 --&amp;gt;|是| Caffeine[纯 Caffeine&amp;lt;br/&amp;gt;简单高效]
    Q2 --&amp;gt;|否| Q3{能否接受最终一致?}
    Q3 --&amp;gt;|能| KeCache[keCache&amp;lt;br/&amp;gt;L1 + L2 加速]
    Q3 --&amp;gt;|否| RedisOnly[纯 Redis /&amp;lt;br/&amp;gt;DB 强一致]
    Q1 --&amp;gt;|是| Q4{写多读少 /&amp;lt;br/&amp;gt;强一致?}
    Q4 --&amp;gt;|是| RedisOnly
    Q4 --&amp;gt;|否| KeCache&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;h3 id=&#34;34-一句话总结&#34;&gt;3.4 一句话总结&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;keCache 适合“读多写少、能接受最终一致、希望兼顾本地速度与集群共享”的业务场景；如果你的业务对实时一致性要求极高（如库存扣减、金融记账），建议直接走 Redis 或数据库方案。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id=&#34;四核心能力速览&#34;&gt;四、核心能力速览&lt;/h2&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;能力&lt;/th&gt;
					&lt;th&gt;说明&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;声明式缓存&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;@LocalCacheable&lt;/code&gt; / &lt;code&gt;@LocalCacheEvict&lt;/code&gt; / &lt;code&gt;@LocalCacheClear&lt;/code&gt;，支持 SpEL&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;多键映射&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;@LocalCacheable.Multi&lt;/code&gt; 一次方法返回构建多个索引 key&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;编程式 API&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;CacheTemplate.get/put/evict/clear&lt;/code&gt;，类型安全&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;二级缓存&lt;/td&gt;
					&lt;td&gt;L1 Caffeine + L2 Redis，读时先 L1 再 L2，未命中回调加载&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;空值保护&lt;/td&gt;
					&lt;td&gt;DB 不存在的 key 缓存短 TTL 的 NullValue，防止缓存穿透&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;集群一致性&lt;/td&gt;
					&lt;td&gt;Redisson Pub/Sub 广播失效事件，其他 JVM 清除本地 L1&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;自失效屏蔽&lt;/td&gt;
					&lt;td&gt;事件携带 &lt;code&gt;sourceId&lt;/code&gt;，本机忽略自己发出的事件，避免误清本地缓存&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;广播风暴防护&lt;/td&gt;
					&lt;td&gt;远程失效事件只清 L1，不再次广播&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;数据校准&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;CalibrationProvider&lt;/code&gt; SPI，支持时间戳扫描或 CDC 扩展&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&#34;41-读取流程&#34;&gt;4.1 读取流程&lt;/h3&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart TD
    Start([读请求]) --&amp;gt; L1[查 L1 Caffeine]
    L1 --&amp;gt;|命中| A[直接返回]
    L1 --&amp;gt;|未命中| L2[查 L2 Redis]
    L2 --&amp;gt;|命中| Deserialize[Jackson 反序列化]
    Deserialize --&amp;gt; Backfill[回填 L1]
    Backfill --&amp;gt; Return[返回数据]
    L2 --&amp;gt;|未命中| Method[执行真实方法]
    Method --&amp;gt; NullCheck{结果是否为 null?}
    NullCheck --&amp;gt;|是| NullVal[写入 NullValue 到 L1/L2&amp;lt;br/&amp;gt;TTL: null-value-ttl]
    NullCheck --&amp;gt;|否| RealVal[写入真实值到 L1/L2]
    NullVal --&amp;gt; ReturnNull[返回 null]
    RealVal --&amp;gt; Return&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;h3 id=&#34;42-写入与集群失效广播流程&#34;&gt;4.2 写入与集群失效广播流程&lt;/h3&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;sequenceDiagram
    participant A as 节点 A（写请求）
    participant DB as 数据库
    participant R as Redis（L2）
    participant P as Redis Pub/Sub
    participant B as 节点 B
    participant C as 节点 C

    A-&amp;gt;&amp;gt;DB: 更新数据
    A-&amp;gt;&amp;gt;R: 删除/覆盖 L2 Key
    A-&amp;gt;&amp;gt;P: 发布 EvictEvent（携带 sourceId）
    A-&amp;gt;&amp;gt;A: 清除本机 L1

    P-&amp;gt;&amp;gt;B: 接收 EvictEvent
    B-&amp;gt;&amp;gt;B: sourceId ≠ 本机？
    B-&amp;gt;&amp;gt;B: 清除本地 L1
    Note over B: 只清 L1，不再次广播

    P-&amp;gt;&amp;gt;C: 接收 EvictEvent
    C-&amp;gt;&amp;gt;C: sourceId ≠ 本机？
    C-&amp;gt;&amp;gt;C: 清除本地 L1&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;h2 id=&#34;五快速开始&#34;&gt;五、快速开始&lt;/h2&gt;
&lt;h3 id=&#34;51-引入-starter&#34;&gt;5.1 引入 Starter&lt;/h3&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-xml&#34; data-lang=&#34;xml&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;com.keke&lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;kecache-spring-boot-starter&lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;version&amp;gt;&lt;/span&gt;1.0.0-SNAPSHOT&lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;/version&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;h3 id=&#34;52-启用缓存&#34;&gt;5.2 启用缓存&lt;/h3&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-java&#34; data-lang=&#34;java&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;import&lt;/span&gt; com.keke.kecache.starter.annotation.EnableKeCache;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;import&lt;/span&gt; org.springframework.boot.autoconfigure.SpringBootApplication;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#a6e22e&#34;&gt;@SpringBootApplication&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#a6e22e&#34;&gt;@EnableKeCache&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;public&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;class&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;Application&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;public&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;static&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;void&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;main&lt;/span&gt;(String&lt;span style=&#34;color:#f92672&#34;&gt;[]&lt;/span&gt; args) {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        SpringApplication.&lt;span style=&#34;color:#a6e22e&#34;&gt;run&lt;/span&gt;(Application.&lt;span style=&#34;color:#a6e22e&#34;&gt;class&lt;/span&gt;, args);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    }
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;h3 id=&#34;53-基础配置&#34;&gt;5.3 基础配置&lt;/h3&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-yaml&#34; data-lang=&#34;yaml&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;local-cache&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;enabled&lt;/span&gt;: &lt;span style=&#34;color:#66d9ef&#34;&gt;true&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;namespace&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;kecache.sample&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;default-ttl&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;30m&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;null-value-ttl&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;5m&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;caffeine&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#f92672&#34;&gt;default&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;      &lt;span style=&#34;color:#f92672&#34;&gt;maximum-size&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;1000&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;      &lt;span style=&#34;color:#f92672&#34;&gt;expire-after-write&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;10m&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;      &lt;span style=&#34;color:#f92672&#34;&gt;record-stats&lt;/span&gt;: &lt;span style=&#34;color:#66d9ef&#34;&gt;true&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;redis&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#f92672&#34;&gt;address&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;redis://127.0.0.1:6379&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#f92672&#34;&gt;broadcast-channel&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;kecache:evict&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#f92672&#34;&gt;default-ttl&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;30m&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;calibration&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#f92672&#34;&gt;enabled&lt;/span&gt;: &lt;span style=&#34;color:#66d9ef&#34;&gt;false&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;h3 id=&#34;54-声明式使用示例&#34;&gt;5.4 声明式使用示例&lt;/h3&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-java&#34; data-lang=&#34;java&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#a6e22e&#34;&gt;@Service&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;public&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;class&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;AccountService&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#a6e22e&#34;&gt;@LocalCacheable&lt;/span&gt;(
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        cacheNames &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;tve_account_info&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        key &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;&amp;#39;id_&amp;#39; + #accountId&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        unless &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;#result == null&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    )
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;public&lt;/span&gt; AccountInfo &lt;span style=&#34;color:#a6e22e&#34;&gt;getById&lt;/span&gt;(&lt;span style=&#34;color:#66d9ef&#34;&gt;long&lt;/span&gt; accountId) {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        &lt;span style=&#34;color:#66d9ef&#34;&gt;return&lt;/span&gt; accountDao.&lt;span style=&#34;color:#a6e22e&#34;&gt;loadById&lt;/span&gt;(accountId);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    }
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#75715e&#34;&gt;// 一次查询构建多个索引 key&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#a6e22e&#34;&gt;@LocalCacheable.Multi&lt;/span&gt;({
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        &lt;span style=&#34;color:#a6e22e&#34;&gt;@LocalCacheable&lt;/span&gt;(cacheNames &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;tve_account_info&amp;#34;&lt;/span&gt;, key &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;&amp;#39;id_&amp;#39; + #accountId&amp;#34;&lt;/span&gt;),
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        &lt;span style=&#34;color:#a6e22e&#34;&gt;@LocalCacheable&lt;/span&gt;(cacheNames &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;tve_account_info&amp;#34;&lt;/span&gt;, key &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;&amp;#39;nm_&amp;#39; + #result.accountName&amp;#34;&lt;/span&gt;, unless &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;#result == null&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    })
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;public&lt;/span&gt; AccountInfo &lt;span style=&#34;color:#a6e22e&#34;&gt;getByIdMultiIndex&lt;/span&gt;(&lt;span style=&#34;color:#66d9ef&#34;&gt;long&lt;/span&gt; accountId) {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        &lt;span style=&#34;color:#66d9ef&#34;&gt;return&lt;/span&gt; accountDao.&lt;span style=&#34;color:#a6e22e&#34;&gt;loadById&lt;/span&gt;(accountId);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    }
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#a6e22e&#34;&gt;@LocalCacheEvict&lt;/span&gt;(cacheNames &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;tve_account_info&amp;#34;&lt;/span&gt;, key &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;&amp;#39;id_&amp;#39; + #accountId&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;public&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;void&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;updateById&lt;/span&gt;(&lt;span style=&#34;color:#66d9ef&#34;&gt;long&lt;/span&gt; accountId, AccountInfo info) {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        accountDao.&lt;span style=&#34;color:#a6e22e&#34;&gt;update&lt;/span&gt;(accountId, info);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    }
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#a6e22e&#34;&gt;@LocalCacheClear&lt;/span&gt;(cacheNames &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;tve_account_info&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;public&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;void&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;clearAll&lt;/span&gt;() {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        accountDao.&lt;span style=&#34;color:#a6e22e&#34;&gt;clear&lt;/span&gt;();
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    }
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;h3 id=&#34;55-编程式使用&#34;&gt;5.5 编程式使用&lt;/h3&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-java&#34; data-lang=&#34;java&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#a6e22e&#34;&gt;@Service&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;public&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;class&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;AccountService&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#a6e22e&#34;&gt;@Autowired&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;private&lt;/span&gt; CacheTemplate cacheTemplate;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;public&lt;/span&gt; AccountInfo &lt;span style=&#34;color:#a6e22e&#34;&gt;getById&lt;/span&gt;(&lt;span style=&#34;color:#66d9ef&#34;&gt;long&lt;/span&gt; accountId) {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        &lt;span style=&#34;color:#66d9ef&#34;&gt;return&lt;/span&gt; cacheTemplate.&lt;span style=&#34;color:#a6e22e&#34;&gt;get&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;tve_account_info&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;id_&amp;#34;&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;+&lt;/span&gt; accountId, key &lt;span style=&#34;color:#f92672&#34;&gt;-&amp;gt;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;            &lt;span style=&#34;color:#66d9ef&#34;&gt;return&lt;/span&gt; accountDao.&lt;span style=&#34;color:#a6e22e&#34;&gt;loadById&lt;/span&gt;(accountId);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        });
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    }
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;h2 id=&#34;六模块结构&#34;&gt;六、模块结构&lt;/h2&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;graph TD
    Parent[kecache-parent] --&amp;gt; API[kecache-api&amp;lt;br/&amp;gt;注解 / SPI / 配置模型 / 事件]
    Parent --&amp;gt; Core[kecache-core&amp;lt;br/&amp;gt;统一门面 / Key策略 / 空值保护]
    Parent --&amp;gt; Caffeine[kecache-provider-caffeine&amp;lt;br/&amp;gt;L1 Caffeine 实现]
    Parent --&amp;gt; Redisson[kecache-provider-redisson&amp;lt;br/&amp;gt;L2 Redis + 失效广播]
    Parent --&amp;gt; Calibration[kecache-calibration&amp;lt;br/&amp;gt;数据一致性校准]
    Parent --&amp;gt; Starter[kecache-spring-boot-starter&amp;lt;br/&amp;gt;自动配置 + AOP]
    Parent --&amp;gt; Sample[kecache-spring-boot-sample&amp;lt;br/&amp;gt;示例应用 + 测试]

    Starter --&amp;gt; API
    Starter --&amp;gt; Core
    Core --&amp;gt; Caffeine
    Core --&amp;gt; Redisson
    Core --&amp;gt; Calibration&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;h2 id=&#34;七关键约定写代码前必读&#34;&gt;七、关键约定（写代码前必读）&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;L1 key&lt;/strong&gt;：&lt;code&gt;cacheName#dataKey&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;L2 key&lt;/strong&gt;：&lt;code&gt;{namespace|cacheName}:dataKey&lt;/code&gt;（使用 hash tag 避免集群模式下 slot 分散）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;默认读写策略&lt;/strong&gt;：Cache-Aside&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;失效广播频道&lt;/strong&gt;：默认 &lt;code&gt;kecache:evict&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;序列化&lt;/strong&gt;：Jackson 启用 default typing，保留类型信息&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&#34;八使用注意点与避坑指南&#34;&gt;八、使用注意点与避坑指南&lt;/h2&gt;
&lt;h3 id=&#34;81-redis-是硬性依赖&#34;&gt;8.1 Redis 是硬性依赖&lt;/h3&gt;
&lt;p&gt;L2 缓存和失效广播都依赖 Redis，测试与运行前请确保 Redis 可达。运行全部测试需要本地启动 Redis：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-bash&#34; data-lang=&#34;bash&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;mvn clean test&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;h3 id=&#34;82-集群一致性是最终一致不是实时一致&#34;&gt;8.2 集群一致性是“最终一致”，不是实时一致&lt;/h3&gt;
&lt;p&gt;本地缓存存在一致窗口：A 节点写入并广播，B 节点收到消息前仍可能读到旧值。对于库存、余额、订单状态等强一致场景，建议绕过本地缓存，直接走 Redis 或数据库。&lt;/p&gt;
&lt;h3 id=&#34;83-空值保护是把双刃剑&#34;&gt;8.3 空值保护是把双刃剑&lt;/h3&gt;
&lt;p&gt;默认对 &lt;code&gt;null&lt;/code&gt; 结果缓存 5 分钟，可防止缓存穿透。但如果业务刚刚从 DB 写入了一条新记录，而之前查询时正好命中了 NullValue，就会出现“写入后 5 分钟内查不到”的延迟。需要时可：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;缩短 &lt;code&gt;null-value-ttl&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;在写入后主动 &lt;code&gt;@LocalCacheEvict&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;对新增数据敏感的 cacheName 关闭空值保护&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;84-序列化类型变更要兼容&#34;&gt;8.4 序列化类型变更要兼容&lt;/h3&gt;
&lt;p&gt;Redis 层使用 Jackson 并保留类型信息。如果缓存对象发生不兼容的类结构变更（如字段类型改变、删除字段、包名调整），旧数据反序列化可能失败。建议：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;对缓存 DTO 做版本控制&lt;/li&gt;
&lt;li&gt;重大变更时清空 Redis 对应 key 或切换 &lt;code&gt;cacheName&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;避免把接口、匿名类、动态代理对象放入缓存&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;85-注意-key-的命名空间隔离&#34;&gt;8.5 注意 Key 的命名空间隔离&lt;/h3&gt;
&lt;p&gt;L2 key 格式为 &lt;code&gt;{namespace|cacheName}:dataKey&lt;/code&gt;。如果多个业务共用同一个 Redis 实例，务必设置不同的 &lt;code&gt;namespace&lt;/code&gt;，否则可能出现 Key 冲突。&lt;/p&gt;
&lt;h3 id=&#34;86-localcacheablemulti-的-result-引用要判空&#34;&gt;8.6 &lt;code&gt;@LocalCacheable.Multi&lt;/code&gt; 的 &lt;code&gt;result&lt;/code&gt; 引用要判空&lt;/h3&gt;
&lt;p&gt;多索引注解里如果通过 &lt;code&gt;#result.xxx&lt;/code&gt; 取字段，必须配合 &lt;code&gt;unless = &amp;quot;#result == null&amp;quot;&lt;/code&gt;，否则方法返回 null 时会触发 SpEL 计算异常。&lt;/p&gt;
&lt;h3 id=&#34;87-aop-自调用不会生效&#34;&gt;8.7 AOP 自调用不会生效&lt;/h3&gt;
&lt;p&gt;和 Spring Cache 一样，keCache 的注解基于 AOP。同类方法内部调用（&lt;code&gt;this.method()&lt;/code&gt;）不会走代理，缓存注解不会生效。需要时注入自身代理或改用编程式 &lt;code&gt;CacheTemplate&lt;/code&gt;。&lt;/p&gt;
&lt;h3 id=&#34;88-谨慎使用-localcacheclear&#34;&gt;8.8 谨慎使用 &lt;code&gt;@LocalCacheClear&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;@LocalCacheClear&lt;/code&gt; 会清空整个 &lt;code&gt;cacheName&lt;/code&gt; 的本地缓存并广播。在高并发场景下，频繁全量清除会：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;导致本地缓存命中率骤降&lt;/li&gt;
&lt;li&gt;引发集体回源，DB 压力陡增&lt;/li&gt;
&lt;li&gt;产生大量广播消息&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;建议优先使用 &lt;code&gt;@LocalCacheEvict&lt;/code&gt; 精确驱逐。&lt;/p&gt;
&lt;h3 id=&#34;89-校准任务不是开箱即用的&#34;&gt;8.9 校准任务不是开箱即用的&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;CalibrationProvider&lt;/code&gt; 目前只是 SPI 骨架，需要业务方自己实现 &lt;code&gt;fetchChanges(Instant start, Instant end)&lt;/code&gt; 逻辑（如查变更表、CDC 日志）。开启 &lt;code&gt;local-cache.calibration.enabled=true&lt;/code&gt; 后才会按 Cron 触发。&lt;/p&gt;
&lt;h3 id=&#34;810-当前-redis-部署模式限制&#34;&gt;8.10 当前 Redis 部署模式限制&lt;/h3&gt;
&lt;p&gt;自动配置里的 &lt;code&gt;RedissonClient&lt;/code&gt; 目前只实现了 &lt;strong&gt;single 单节点&lt;/strong&gt;模式。如果生产环境使用 Redis Cluster、Sentinel 或主从，需要自行扩展配置。&lt;/p&gt;
&lt;h3 id=&#34;811-关注-caffeine-内存与统计&#34;&gt;8.11 关注 Caffeine 内存与统计&lt;/h3&gt;
&lt;p&gt;默认开启 &lt;code&gt;record-stats: true&lt;/code&gt;，本地缓存命中率、加载次数等都会被记录。建议配合 actuator / Micrometer（后续可扩展）监控：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;maximum-size&lt;/code&gt; 是否设置合理&lt;/li&gt;
&lt;li&gt;本地缓存命中率&lt;/li&gt;
&lt;li&gt;加载耗时与异常率&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&#34;九许可证&#34;&gt;九、许可证&lt;/h2&gt;
&lt;p&gt;keCache 采用 &lt;strong&gt;MIT&lt;/strong&gt; 协议开源。&lt;/p&gt;
&lt;h2 id=&#34;十总结&#34;&gt;十、总结&lt;/h2&gt;
&lt;p&gt;keCache 是一个目标聚焦、实现克制的两级缓存框架：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如果你想要 &lt;strong&gt;Caffeine 的速度 + Redis 的共享能力&lt;/strong&gt;，它值得尝试。&lt;/li&gt;
&lt;li&gt;如果你希望 &lt;strong&gt;声明式注解即可解决集群本地缓存一致性问题&lt;/strong&gt;，它提供了失效广播与自失效屏蔽。&lt;/li&gt;
&lt;li&gt;如果你的业务对 &lt;strong&gt;实时一致性零容忍&lt;/strong&gt;，请把它当作“加速层”而非“唯一数据源”。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;有兴趣可以直接到 Gitee 查看源码与示例：&lt;a href=&#34;https://gitee.com/soulnew/ke-cache&#34;&gt;https://gitee.com/soulnew/ke-cache&lt;/a&gt;。&lt;/p&gt;</description>
    </item>
    <item>
      <title>我开发的KE Reliable Executor：基于 Redis/MySQL 的 Spring Boot 可靠任务执行框架</title>
      <link>https://ftmao.com/blog/posts/ke-reliable-executor/</link>
      <pubDate>Mon, 04 Aug 2025 17:10:00 +0800</pubDate>
      <guid>https://ftmao.com/blog/posts/ke-reliable-executor/</guid>
      <description>&lt;h1 id=&#34;我开发的ke-reliable-executor基于-redismysql-的-spring-boot-可靠任务执行框架&#34;&gt;我开发的KE Reliable Executor：基于 Redis/MySQL 的 Spring Boot 可靠任务执行框架&lt;/h1&gt;
&lt;p&gt;在微服务里，异步任务随处可见：支付后通知第三方、订单超时取消、批量对账、报表生成……这些任务有一个共同诉求——&lt;strong&gt;不能丢、不能重复执行、失败能重试、状态要能查&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;传统做法是直接引入 Kafka、RocketMQ 这类重型消息中间件。但对于很多中小型业务来说，这未免太重：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;需要额外维护 Broker、Topic、消费组；&lt;/li&gt;
&lt;li&gt;团队要熟悉一套新的运维和监控体系；&lt;/li&gt;
&lt;li&gt;为了“可靠”还要配置死信队列、事务消息等高级特性。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果你的系统里已经有 Redis（或 MySQL），并且任务量级尚未达到 MQ 的吞吐上限，那么完全可以基于 Redis 搭一个&lt;strong&gt;轻量且可靠的异步任务执行框架&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;我开发是这样一个框架：&lt;strong&gt;KE Reliable Executor&lt;/strong&gt;。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;开源地址&lt;/strong&gt;：https://gitee.com/soulnew/reliableexecutor&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2 id=&#34;一为什么需要这个工具&#34;&gt;一、为什么需要这个工具？&lt;/h2&gt;
&lt;p&gt;先回顾一下常见异步任务场景里的痛点：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;痛点&lt;/th&gt;
					&lt;th&gt;说明&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;任务丢失&lt;/td&gt;
					&lt;td&gt;服务重启或执行线程异常退出后，未执行的任务找不到了&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;重复执行&lt;/td&gt;
					&lt;td&gt;多实例部署时，同一个任务被多个节点同时消费&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;失败即结束&lt;/td&gt;
					&lt;td&gt;业务异常或网络抖动后，没有自动重试机制&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;状态黑盒&lt;/td&gt;
					&lt;td&gt;无法查询任务当前是成功、失败、执行中还是已取消&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;接入成本高&lt;/td&gt;
					&lt;td&gt;引入 MQ 需要改代码、加依赖、搭集群&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;KE Reliable Executor 要解决的就是这些问题。它把“可靠任务执行”抽象成一个 Spring Boot Starter：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;业务方只负责&lt;strong&gt;定义任务处理器&lt;/strong&gt;和&lt;strong&gt;提交任务&lt;/strong&gt;；&lt;/li&gt;
&lt;li&gt;框架负责&lt;strong&gt;存储、调度、加锁、重试、状态流转、清理&lt;/strong&gt;；&lt;/li&gt;
&lt;li&gt;所有状态都可通过 API 查询，也可通过 Micrometer 指标监控。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;适合什么场景&#34;&gt;适合什么场景？&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;已有 Redis/MySQL，不想额外维护 MQ；&lt;/li&gt;
&lt;li&gt;任务量中等，对可靠性和重试有要求；&lt;/li&gt;
&lt;li&gt;需要任务状态可查询、可人工介入；&lt;/li&gt;
&lt;li&gt;团队希望以最小成本获得“类 MQ”的可靠执行能力。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;不适合什么场景&#34;&gt;不适合什么场景？&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;海量消息、高吞吐（Kafka/RocketMQ 更合适）；&lt;/li&gt;
&lt;li&gt;严格顺序消费（ZSET 按时间排序，不保证业务绝对顺序）；&lt;/li&gt;
&lt;li&gt;需要复杂路由、死信队列、事务消息等高级 MQ 特性。&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&#34;二与竞品的对比&#34;&gt;二、与竞品的对比&lt;/h2&gt;
&lt;h3 id=&#34;21-与-kafka--rocketmq-对比&#34;&gt;2.1 与 Kafka / RocketMQ 对比&lt;/h3&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;维度&lt;/th&gt;
					&lt;th&gt;Kafka / RocketMQ&lt;/th&gt;
					&lt;th&gt;KE Reliable Executor&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;基础设施&lt;/td&gt;
					&lt;td&gt;需要独立 Broker 集群&lt;/td&gt;
					&lt;td&gt;复用 Redis 或 MySQL&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;运维成本&lt;/td&gt;
					&lt;td&gt;高（Topic、消费组、监控）&lt;/td&gt;
					&lt;td&gt;低（Spring Boot Starter）&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;接入成本&lt;/td&gt;
					&lt;td&gt;需要学习 SDK 与语义&lt;/td&gt;
					&lt;td&gt;注解 + 注入接口即可&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;吞吐量&lt;/td&gt;
					&lt;td&gt;极高&lt;/td&gt;
					&lt;td&gt;中等&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;状态查询&lt;/td&gt;
					&lt;td&gt;需自建审计表&lt;/td&gt;
					&lt;td&gt;内置状态查询&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;重试机制&lt;/td&gt;
					&lt;td&gt;需配置消费重试&lt;/td&gt;
					&lt;td&gt;内置可配置重试&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;适用阶段&lt;/td&gt;
					&lt;td&gt;大型业务、高并发&lt;/td&gt;
					&lt;td&gt;中小型业务、快速落地&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;结论：KE Reliable Executor 不是取代 MQ，而是&lt;strong&gt;在“已有 Redis/MySQL、任务量可控”的场景下，提供一个更轻量的选择&lt;/strong&gt;。&lt;/p&gt;
&lt;h3 id=&#34;22-与-spring-scheduled--数据库轮询对比&#34;&gt;2.2 与 Spring &lt;code&gt;@Scheduled&lt;/code&gt; + 数据库轮询对比&lt;/h3&gt;
&lt;p&gt;很多团队最早会用数据库表 + &lt;code&gt;@Scheduled&lt;/code&gt; 轮询来实现异步任务。这种做法的问题：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;无分布式锁&lt;/strong&gt;：多实例同时轮询会重复执行；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;无状态机&lt;/strong&gt;：任务状态全靠字段控制，容易混乱；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;无统一重试策略&lt;/strong&gt;：重试间隔、重试次数需要手写；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;无优雅关闭&lt;/strong&gt;：应用关闭时可能丢失正在执行的任务。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;KE Reliable Executor 把这些都封装好了：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Master 选举 + 任务级锁保证不重复；&lt;/li&gt;
&lt;li&gt;明确的状态机（CREATED → RUNNING → PENDING/COMPLETED/FAILED）；&lt;/li&gt;
&lt;li&gt;可配置的重试间隔与次数；&lt;/li&gt;
&lt;li&gt;优雅释放锁与线程池关闭。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;23-与-xxl-job--powerjob-对比&#34;&gt;2.3 与 XXL-JOB / PowerJob 对比&lt;/h3&gt;
&lt;p&gt;XXL-JOB 和 PowerJob 是优秀的分布式任务调度平台，但它们更适合&lt;strong&gt;定时任务&lt;/strong&gt;和&lt;strong&gt;调度中心&lt;/strong&gt;模式：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;需要单独部署调度中心；&lt;/li&gt;
&lt;li&gt;适合 Cron 表达式触发的批处理任务；&lt;/li&gt;
&lt;li&gt;任务由业务主动提交的场景下，接入不够自然。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;KE Reliable Executor 更像一个&lt;strong&gt;嵌入式任务队列&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;无额外服务，随业务应用启动；&lt;/li&gt;
&lt;li&gt;任务由业务代码主动提交；&lt;/li&gt;
&lt;li&gt;强调“提交即可靠执行”，而非定时触发。&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&#34;三整体架构&#34;&gt;三、整体架构&lt;/h2&gt;
&lt;h3 id=&#34;31-模块解构图&#34;&gt;3.1 模块解构图&lt;/h3&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;graph TD
    A[业务服务] --&amp;gt;|submitTask| B(ReliableExecutor API)
    B --&amp;gt; C[TaskScheduler]
    C --&amp;gt; D{Master 锁}
    D --&amp;gt;|持有锁| E[扫描 ZSET 任务]
    E --&amp;gt; F[任务级锁]
    F --&amp;gt;|获取成功| G[提交线程池]
    G --&amp;gt; H[TaskExecutor]
    H --&amp;gt; I[&amp;#34;任务处理器 @ReliableTaskHandler&amp;#34;]
    I --&amp;gt;|成功| J[移入 FINISHED]
    I --&amp;gt;|失败| K[更新为 PENDING 等待重试]
    C --&amp;gt; L[TaskStorage]
    L --&amp;gt; M[RedisTaskStorage]
    L --&amp;gt; N[JdbcTaskStorage]
    H --&amp;gt; O[ReliableTaskListener]
    C --&amp;gt; P[ReliableExecutorMetrics]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;h3 id=&#34;32-两层锁模型&#34;&gt;3.2 两层锁模型&lt;/h3&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;graph TB
    subgraph 全局层
        A[Master 锁] --&amp;gt;|仅一个实例| B[负责调度扫描]
    end
    subgraph 任务层
        C[&amp;#34;任务级锁 task-lock:{taskId}&amp;#34;] --&amp;gt;|仅一个线程| D[执行任务]
    end
    B --&amp;gt; C&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Master 锁&lt;/strong&gt;：同一时刻只有一个实例负责扫描任务，避免多节点拉取同一批任务。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;任务级锁&lt;/strong&gt;：针对每个 CREATED 任务，提交执行前再抢一次锁，保证同一任务不会被并发执行。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;33-队列设计&#34;&gt;3.3 队列设计&lt;/h3&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;graph LR
    A[提交任务] --&amp;gt;|add| B[Redis ZSET running-task-zset]
    B --&amp;gt;|ZRANGE 按 score| C[调度器]
    C --&amp;gt;|执行成功/失败/超时| D[Lua 原子转移]
    D --&amp;gt;|RPUSH + ZREM| E[Redis List finished-task-list]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;使用 ZSET 而非 List 的核心原因：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;天然支持时间调度&lt;/strong&gt;：score 编码创建时间或下次重试时间；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;任务唯一&lt;/strong&gt;：同一个 member 在队列中只有一份；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;状态分区&lt;/strong&gt;：CREATED 与 PENDING 使用不同 score 基数，一次范围查询即可区分。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Score 编码规则：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-text&#34; data-lang=&#34;text&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;score = BASE + epochMillis * 100 + retryCount
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;CREATED 基数：1_000_000_000_000_000
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;PENDING 基数：2_000_000_000_000_000&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;h3 id=&#34;34-任务状态机&#34;&gt;3.4 任务状态机&lt;/h3&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;stateDiagram-v2
    [*] --&amp;gt; CREATED: submitTask
    CREATED --&amp;gt; RUNNING: 调度/立即执行
    RUNNING --&amp;gt; COMPLETED: 执行成功
    RUNNING --&amp;gt; PENDING: 执行失败，未超重试
    PENDING --&amp;gt; RUNNING: 到达重试时间
    RUNNING --&amp;gt; FAILED: 超过重试次数
    RUNNING --&amp;gt; TIMEOUT: 超过最大时长
    CREATED --&amp;gt; CANCELLED: 取消任务
    PENDING --&amp;gt; CANCELLED: 取消任务
    RUNNING --&amp;gt; ERROR: 无处理器/参数错误&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;hr&gt;
&lt;h2 id=&#34;四核心设计要点&#34;&gt;四、核心设计要点&lt;/h2&gt;
&lt;h3 id=&#34;41-lua-原子转移&#34;&gt;4.1 Lua 原子转移&lt;/h3&gt;
&lt;p&gt;任务进入终态时，必须保证“写入 finished list”和“从 ZSET 移除”是原子的，否则可能出现：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;任务已判定成功但还在 ZSET 里，被下一次调度捞出来；&lt;/li&gt;
&lt;li&gt;任务已从 ZSET 移除但 finished list 没写入，查询不到结果。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;框架使用 Lua 脚本完成这一转移：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-lua&#34; data-lang=&#34;lua&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;local&lt;/span&gt; score &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; redis.call(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;ZSCORE&amp;#39;&lt;/span&gt;, KEYS[&lt;span style=&#34;color:#ae81ff&#34;&gt;1&lt;/span&gt;], ARGV[&lt;span style=&#34;color:#ae81ff&#34;&gt;1&lt;/span&gt;]);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;redis.call(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;RPUSH&amp;#39;&lt;/span&gt;, KEYS[&lt;span style=&#34;color:#ae81ff&#34;&gt;2&lt;/span&gt;], ARGV[&lt;span style=&#34;color:#ae81ff&#34;&gt;2&lt;/span&gt;]);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;if&lt;/span&gt; score &lt;span style=&#34;color:#66d9ef&#34;&gt;then&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    redis.call(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;ZREM&amp;#39;&lt;/span&gt;, KEYS[&lt;span style=&#34;color:#ae81ff&#34;&gt;1&lt;/span&gt;], ARGV[&lt;span style=&#34;color:#ae81ff&#34;&gt;1&lt;/span&gt;]);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;return&lt;/span&gt; &lt;span style=&#34;color:#ae81ff&#34;&gt;1&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;else&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;return&lt;/span&gt; &lt;span style=&#34;color:#ae81ff&#34;&gt;0&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;p&gt;逻辑：先查 ZSET，无论是否存在都把终态信息写入 finished list，如果存在再删除。整个脚本在 Redis 单线程执行，天然原子。&lt;/p&gt;
&lt;h3 id=&#34;42-double-check&#34;&gt;4.2 Double-Check&lt;/h3&gt;
&lt;p&gt;调度器拿到任务级锁后、提交线程池前，会再次回查 Redis：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;任务是否仍在 running ZSET 中？&lt;/li&gt;
&lt;li&gt;任务状态是否仍是 CREATED？&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;任一条件不满足，立即释放锁并跳过。这是经典的双重检查模式，把“检查-执行”串在锁保护的临界区内。&lt;/p&gt;
&lt;h3 id=&#34;43-双存储后端&#34;&gt;4.3 双存储后端&lt;/h3&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;graph TD
    A[TaskStorage 接口] --&amp;gt; B[RedisTaskStorage]
    A --&amp;gt; C[JdbcTaskStorage]
    B --&amp;gt; D[Redisson ZSET + List]
    C --&amp;gt; E[reliable_executor_task 表]
    F[配置 reliable-executor.task-storage] --&amp;gt;|redis/mysql| A&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;ul&gt;
&lt;li&gt;&lt;code&gt;redis&lt;/code&gt;：默认，适合已有 Redis 的场景；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;mysql&lt;/code&gt;：适合不希望引入 Redis 或需要更强持久化的场景。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;两种后端通过 Spring 条件装配切换，业务代码无感知。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;五快速开始&#34;&gt;五、快速开始&lt;/h2&gt;
&lt;h3 id=&#34;51-引入依赖&#34;&gt;5.1 引入依赖&lt;/h3&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-gradle&#34; data-lang=&#34;gradle&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;dependencies &lt;span style=&#34;color:#f92672&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    implementation &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;com.ftmao:ke-reliable-executor:1.0.0-SNAPSHOT&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    implementation &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;org.redisson:redisson-spring-boot-starter:3.27.2&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;h3 id=&#34;52-配置&#34;&gt;5.2 配置&lt;/h3&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-yaml&#34; data-lang=&#34;yaml&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;spring&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;application&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#f92672&#34;&gt;name&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;my-app&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;redis&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#f92672&#34;&gt;redisson&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;      &lt;span style=&#34;color:#f92672&#34;&gt;config&lt;/span&gt;: |&lt;span style=&#34;color:#e6db74&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;        singleServerConfig:
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;          address: &amp;#34;redis://127.0.0.1:6379&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;reliable-executor&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;task-storage&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;redis&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;core-pool-size&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;5&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;max-pool-size&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;10&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;queue-capacity&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;100&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;h3 id=&#34;53-定义处理器&#34;&gt;5.3 定义处理器&lt;/h3&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-java&#34; data-lang=&#34;java&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#a6e22e&#34;&gt;@Component&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;public&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;class&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;OrderTaskHandler&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#a6e22e&#34;&gt;@ReliableTaskHandler&lt;/span&gt;(name &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;process_order&amp;#34;&lt;/span&gt;, paramType &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; OrderParam.&lt;span style=&#34;color:#a6e22e&#34;&gt;class&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;public&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;void&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;processOrder&lt;/span&gt;(OrderParam param) {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        &lt;span style=&#34;color:#75715e&#34;&gt;// 业务逻辑&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    }
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#a6e22e&#34;&gt;@ReliableTaskHandler&lt;/span&gt;(name &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;send_notify&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;public&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;void&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;sendNotify&lt;/span&gt;() {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        &lt;span style=&#34;color:#75715e&#34;&gt;// 无参任务&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    }
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;h3 id=&#34;54-提交任务&#34;&gt;5.4 提交任务&lt;/h3&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-java&#34; data-lang=&#34;java&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#a6e22e&#34;&gt;@Service&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#a6e22e&#34;&gt;@RequiredArgsConstructor&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;public&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;class&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;OrderService&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;private&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;final&lt;/span&gt; ReliableExecutor reliableExecutor;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;public&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;void&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;submitOrder&lt;/span&gt;(OrderParam param) {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        TaskSubmitRequest request &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; TaskSubmitRequest.&lt;span style=&#34;color:#a6e22e&#34;&gt;builder&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                .&lt;span style=&#34;color:#a6e22e&#34;&gt;taskId&lt;/span&gt;(UUID.&lt;span style=&#34;color:#a6e22e&#34;&gt;randomUUID&lt;/span&gt;().&lt;span style=&#34;color:#a6e22e&#34;&gt;toString&lt;/span&gt;())
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                .&lt;span style=&#34;color:#a6e22e&#34;&gt;taskName&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;process_order&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                .&lt;span style=&#34;color:#a6e22e&#34;&gt;taskParameter&lt;/span&gt;(param)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                .&lt;span style=&#34;color:#a6e22e&#34;&gt;retryLimit&lt;/span&gt;(3)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                .&lt;span style=&#34;color:#a6e22e&#34;&gt;retryInterval&lt;/span&gt;(2000)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                .&lt;span style=&#34;color:#a6e22e&#34;&gt;maxDurationMillis&lt;/span&gt;(60000)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                .&lt;span style=&#34;color:#a6e22e&#34;&gt;build&lt;/span&gt;();
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        reliableExecutor.&lt;span style=&#34;color:#a6e22e&#34;&gt;submitTask&lt;/span&gt;(request);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    }
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;h3 id=&#34;55-监听生命周期&#34;&gt;5.5 监听生命周期&lt;/h3&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-java&#34; data-lang=&#34;java&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#a6e22e&#34;&gt;@Component&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;public&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;class&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;MyTaskListener&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;implements&lt;/span&gt; ReliableTaskListener {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#a6e22e&#34;&gt;@Override&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;public&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;void&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;onTaskCompleted&lt;/span&gt;(String taskId, String taskName) {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        &lt;span style=&#34;color:#75715e&#34;&gt;// 任务成功&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    }
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#a6e22e&#34;&gt;@Override&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;public&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;void&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;onTaskFailed&lt;/span&gt;(String taskId, String taskName, String status) {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        &lt;span style=&#34;color:#75715e&#34;&gt;// 任务最终失败&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    }
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;hr&gt;
&lt;h2 id=&#34;六使用注意点与避坑指南&#34;&gt;六、使用注意点与避坑指南&lt;/h2&gt;
&lt;h3 id=&#34;61-业务幂等仍然是最后一道防线&#34;&gt;6.1 业务幂等仍然是最后一道防线&lt;/h3&gt;
&lt;p&gt;框架通过两层锁、double-check、Lua 原子转移把重复执行的概率降到极低，但&lt;strong&gt;任何分布式系统都无法 100% 保证幂等&lt;/strong&gt;。业务 Handler 仍然应该保证幂等，尤其是涉及写数据库、调用外部接口的场景。&lt;/p&gt;
&lt;h3 id=&#34;62-taskid-需要业务保证唯一&#34;&gt;6.2 taskId 需要业务保证唯一&lt;/h3&gt;
&lt;p&gt;框架使用业务传入的 &lt;code&gt;taskId&lt;/code&gt; 作为任务唯一标识。如果同一业务单据重复提交相同 &lt;code&gt;taskId&lt;/code&gt;，第二次提交会生成新的 ZSET member（如果业务字段不同）或覆盖（如果完全相同）。&lt;strong&gt;建议用业务主键 + 操作类型组合生成 taskId&lt;/strong&gt;，例如 &lt;code&gt;order_12345_pay&lt;/code&gt;。&lt;/p&gt;
&lt;h3 id=&#34;63-重试间隔与最大时长的权衡&#34;&gt;6.3 重试间隔与最大时长的权衡&lt;/h3&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-yaml&#34; data-lang=&#34;yaml&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;reliable-executor&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;retryInterval&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;2000&lt;/span&gt;      &lt;span style=&#34;color:#75715e&#34;&gt;# 失败后 2s 再试&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;maxDurationMillis&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;60000&lt;/span&gt; &lt;span style=&#34;color:#75715e&#34;&gt;# 任务最长存活 60s&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;ul&gt;
&lt;li&gt;&lt;code&gt;retryInterval&lt;/code&gt; 过小会导致失败任务快速反复执行，放大下游压力；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;maxDurationMillis&lt;/code&gt; 是从任务创建时间算起，不是单次执行时间；&lt;/li&gt;
&lt;li&gt;建议失败任务采用指数退避（当前版本为固定间隔，后续可扩展）。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;64-mysql-分支需要自行引入-jpa&#34;&gt;6.4 MySQL 分支需要自行引入 JPA&lt;/h3&gt;
&lt;p&gt;框架中 &lt;code&gt;spring-boot-starter-data-jpa&lt;/code&gt; 是 &lt;code&gt;compileOnly&lt;/code&gt;，使用 MySQL 存储时需要在业务应用里显式引入：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-gradle&#34; data-lang=&#34;gradle&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;implementation &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;org.springframework.boot:spring-boot-starter-data-jpa&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;runtimeOnly &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;com.mysql:mysql-connector-j&amp;#39;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;p&gt;并执行建表脚本 &lt;code&gt;db/migration/V1__create_reliable_executor_tables.sql&lt;/code&gt;。&lt;/p&gt;
&lt;h3 id=&#34;65-redis-集群环境注意-key-前缀&#34;&gt;6.5 Redis 集群环境注意 Key 前缀&lt;/h3&gt;
&lt;p&gt;框架默认使用 &lt;code&gt;spring.application.name&lt;/code&gt; 作为 Redis Key 前缀。如果多个应用共用同一个 Redis 实例，务必设置不同的 &lt;code&gt;spring.application.name&lt;/code&gt;，否则任务会互相串扰。&lt;/p&gt;
&lt;h3 id=&#34;66-线程池队列满载时会跳过调度&#34;&gt;6.6 线程池队列满载时会跳过调度&lt;/h3&gt;
&lt;p&gt;当线程池队列达到 &lt;code&gt;queueCapacity&lt;/code&gt; 时，调度器会跳过本次周期，避免无限制堆积。如果业务出现持续高峰，建议：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;调大 &lt;code&gt;maxPoolSize&lt;/code&gt; 和 &lt;code&gt;queueCapacity&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;监控 &lt;code&gt;reliable_executor_threadpool_queue_size&lt;/code&gt; 指标；&lt;/li&gt;
&lt;li&gt;必要时配合业务限流。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;67-不要忽略超时任务&#34;&gt;6.7 不要忽略超时任务&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;maxDurationMillis&lt;/code&gt; 是从创建时间算起的总时长。如果一个任务在队列里排队太久，可能还没执行就触发 TIMEOUT。对于允许较长延迟的任务，要把这个值设大；对于实时性任务，要把重试间隔和排队容量一起考虑。&lt;/p&gt;
&lt;h3 id=&#34;68-本地开发无-redis-怎么办&#34;&gt;6.8 本地开发无 Redis 怎么办？&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;使用 MySQL 分支（H2 也可以跑测试）；&lt;/li&gt;
&lt;li&gt;或者本地启动一个 Redis Docker 容器；&lt;/li&gt;
&lt;li&gt;框架的 Redis 集成测试会在无 Redis 时自动跳过，不影响 CI。&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&#34;七监控指标&#34;&gt;七、监控指标&lt;/h2&gt;
&lt;p&gt;框架内置 Micrometer 指标，可接入 Prometheus/Actuator：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;指标名&lt;/th&gt;
					&lt;th&gt;含义&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;reliable_executor_is_master&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;当前节点是否为 Master&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;reliable_executor_tasks_scheduled_total&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;累计调度任务数&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;reliable_executor_tasks_executed_total&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;累计执行任务数&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;reliable_executor_tasks_completed_total&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;累计成功任务数&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;reliable_executor_tasks_failed_total&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;累计失败任务数&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;reliable_executor_tasks_timeout_total&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;累计超时任务数&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;reliable_executor_tasks_retried_total&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;累计重试次数&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;reliable_executor_threadpool_queue_size&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;线程池队列大小&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;reliable_executor_task_execution_seconds&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;任务执行耗时&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h2 id=&#34;八开源与示例&#34;&gt;八、开源与示例&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;开源地址&lt;/strong&gt;：https://gitee.com/soulnew/reliableexecutor&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;示例模块&lt;/strong&gt;：&lt;code&gt;example/&lt;/code&gt; 子项目包含可运行的 Spring Boot 应用和 REST 接口示例，可直接启动体验任务提交与执行。&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&#34;九总结&#34;&gt;九、总结&lt;/h2&gt;
&lt;p&gt;KE Reliable Executor 是一个&lt;strong&gt;轻量、可靠、易接入&lt;/strong&gt;的异步任务执行框架。它用 Redis ZSET + 两层锁 + Lua 原子转移，在“不引入重型 MQ”的前提下，提供了任务提交、调度、重试、状态查询、监控等完整能力；同时通过 MySQL 分支提供了另一种存储选择。&lt;/p&gt;
&lt;p&gt;如果你的业务符合以下特征，不妨试试它：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;已有 Redis/MySQL；&lt;/li&gt;
&lt;li&gt;任务量中等，重视可靠性；&lt;/li&gt;
&lt;li&gt;希望快速落地，不想为 MQ 付出额外运维成本；&lt;/li&gt;
&lt;li&gt;需要任务状态可观测、可人工介入。&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;记住：框架层负责把重复消费的概率降到极低，业务层仍应保证 Handler 幂等，这才是工程上的稳妥做法。&lt;/p&gt;
&lt;/blockquote&gt;
</description>
    </item>
    <item>
      <title>starmall：一个微信小程序电商系统的全栈实战——预约、支付与订单架构解析</title>
      <link>https://ftmao.com/blog/posts/starmall-wechat-miniprogram-ecommerce-fullstack/</link>
      <pubDate>Tue, 20 Jul 2021 10:00:00 +0800</pubDate>
      <guid>https://ftmao.com/blog/posts/starmall-wechat-miniprogram-ecommerce-fullstack/</guid>
      <description>&lt;h1 id=&#34;starmall一个微信小程序电商系统的全栈实战预约支付与订单架构解析&#34;&gt;starmall：一个微信小程序电商系统的全栈实战——预约、支付与订单架构解析&lt;/h1&gt;
&lt;p&gt;这是我 2021 年做的一套完整电商系统 &lt;strong&gt;starmall&lt;/strong&gt;（六爻星辰）：微信小程序端 + Java 后端 + PC 管理后台，支持商品购买、老师咨询时段预约、择吉日查询、在线咨询等业务。整理开源出来，写篇文章做一次完整的技术复盘。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;前端（uni-app 小程序）：&lt;a href=&#34;https://gitee.com/soulnew/starmall&#34;&gt;https://gitee.com/soulnew/starmall&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;后端（Java + 管理端）：&lt;a href=&#34;https://gitee.com/soulnew/crm_server&#34;&gt;https://gitee.com/soulnew/crm_server&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;技术栈：Spring Boot 2.2 + MyBatis-Plus + MySQL + Redis / uni-app + uView / Vue 2 + Element UI（管理端）&lt;/li&gt;
&lt;li&gt;说明：后端是在开源商城 &lt;strong&gt;CRMEB Java 版&lt;/strong&gt; 基础上二次开发的，分销/秒杀/拼团/砍价等营销能力来自底座，预约系统、择吉日、名片、咨询聊天等是自研部分&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&#34;一整体架构&#34;&gt;一、整体架构&lt;/h2&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;graph TD
    MP[&amp;#34;微信小程序端&amp;lt;br/&amp;gt;starmall uni-app + uView&amp;#34;] --&amp;gt;|&amp;#34;https://ftmao.com/api/front/**&amp;#34;| API[&amp;#34;SpringBoot 单体后端&amp;lt;br/&amp;gt;62 个 Mapper / 23 个前台 Controller&amp;#34;]
    H5[&amp;#34;H5 / App&amp;lt;br/&amp;gt;uni-app 多端编译&amp;#34;] --&amp;gt; API
    ADMIN[&amp;#34;PC 管理后台&amp;lt;br/&amp;gt;Vue 2 + Element UI + ECharts&amp;#34;] --&amp;gt;|&amp;#34;后台接口&amp;#34;| API
    API --&amp;gt; MYSQL[&amp;#34;MySQL 5.7&amp;lt;br/&amp;gt;62 张表 Druid 连接池&amp;#34;]
    API --&amp;gt; REDIS[&amp;#34;Redis&amp;lt;br/&amp;gt;token / 订单缓存 / 任务队列 / 配置缓存&amp;#34;]
    API --&amp;gt; WX[&amp;#34;微信开放平台&amp;lt;br/&amp;gt;登录 / 支付 V2 / 订阅消息&amp;#34;]
    API --&amp;gt; QN[&amp;#34;七牛云 OSS&amp;lt;br/&amp;gt;图片上传&amp;#34;]
    API --&amp;gt; QY[&amp;#34;企业微信机器人&amp;lt;br/&amp;gt;部署结果通知&amp;#34;]
    MP --&amp;gt;|&amp;#34;wss://... websocket&amp;#34;| CHAT[&amp;#34;咨询聊天服务&amp;#34;]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;代码规模：后端约 850 个 Java 文件、12.6 万行；小程序前端业务代码约 1.2 万行；数据库初始化 SQL 全量在仓库里，共 62 张表。后端按业务分包：&lt;code&gt;front&lt;/code&gt;（小程序接口）、&lt;code&gt;order/payment&lt;/code&gt;、&lt;code&gt;marketing&lt;/code&gt;、&lt;code&gt;seckill&lt;/code&gt;、&lt;code&gt;task&lt;/code&gt;（定时任务）、&lt;code&gt;wechat&lt;/code&gt;，以及自研的 &lt;code&gt;appoint&lt;/code&gt;（预约）、&lt;code&gt;lunar&lt;/code&gt;（黄历）等模块。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;二功能介绍&#34;&gt;二、功能介绍&lt;/h2&gt;
&lt;h3 id=&#34;21-商品购买全流程&#34;&gt;2.1 商品购买全流程&lt;/h3&gt;
&lt;p&gt;标准的商城闭环：商品详情（多规格 SKU）→ 立即购买/加购物车 → 确认订单（优惠券、积分抵扣、运费计算）→ 创建订单 → 微信支付 → 轮询支付结果 → 收货评价 → 退款/售后。&lt;strong&gt;支付是真实对接的微信支付 V2&lt;/strong&gt;——后端手工实现 XML 报文 + MD5 签名调统一下单（&lt;code&gt;WeChatPayServiceImpl&lt;/code&gt;，746 行），前端弹起 &lt;code&gt;wx.requestPayment&lt;/code&gt;，不是 mock。&lt;/p&gt;
&lt;p&gt;此外还支持：余额支付、运费模板（满额包邮/指定城市包邮/按件数重量体积三种首续费）、物流查询、自提核销码、再次下单。营销玩法（秒杀、砍价、拼团、优惠券）来自 CRMEB 底座，一应俱全。&lt;/p&gt;
&lt;h3 id=&#34;22-老师咨询时段预约自研核心&#34;&gt;2.2 老师咨询时段预约（自研核心）&lt;/h3&gt;
&lt;p&gt;这是这个项目区别于普通商城的地方——卖的不只是实物商品，还有&amp;quot;老师的时间&amp;quot;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;商品分类为&amp;quot;老师类&amp;quot;时，商品详情页出现一个&lt;strong&gt;时段选择器&lt;/strong&gt;（每天 6 个时段：9/11/13/15/17/19 点）；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;teacher_plan&lt;/code&gt; 表按「日期 × 时段」预生成号源，被约走的时段前端置灰；&lt;/li&gt;
&lt;li&gt;下单时携带 &lt;code&gt;teacherPlanId&lt;/code&gt;，&lt;strong&gt;创建订单的同一个事务里&lt;/strong&gt;把该时段标记为已约——号源占用和订单创建要么一起成功要么一起回滚，不会出现&amp;quot;钱付了时段没了&amp;quot;；&lt;/li&gt;
&lt;li&gt;号源由定时任务 &lt;code&gt;TeacherPlanTask&lt;/code&gt; 每 8 小时自动向前补齐未来 8 天，并清理过期的空闲号源。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;23-其他自研功能&#34;&gt;2.3 其他自研功能&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;择吉日查询&lt;/strong&gt;：4.7 万行黄历数据入库（&lt;code&gt;lunar&lt;/code&gt; 系列表），按&amp;quot;宜&amp;quot;事反查吉日，比如查&amp;quot;最近适合搬家的日子&amp;quot;；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;在线咨询&lt;/strong&gt;：基于 WebSocket 长连接的实时聊天，支持文字/语音/图片消息（详见第三章「长连接聊天」）；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;老师名片&lt;/strong&gt;、文章资讯、签到积分、充值、小程序订阅消息提醒；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;管理后台&lt;/strong&gt;：商品/订单/营销/分销/财务/短信/按钮级权限/数据统计大屏（ECharts）。&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&#34;三后端能力全景&#34;&gt;三、后端能力全景&lt;/h2&gt;
&lt;p&gt;后端虽然是 SpringBoot 单体，但能力面相当完整：前台 23 个 Controller、62 个 MyBatis Mapper，按业务分包组织：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;graph TD
    subgraph M1[&amp;#34;业务模块&amp;#34;]
        A1[&amp;#34;front 前台接口&amp;lt;br/&amp;gt;23 个 Controller&amp;#34;]
        A2[&amp;#34;order / payment&amp;lt;br/&amp;gt;订单与支付&amp;#34;]
        A3[&amp;#34;marketing / seckill / bargain / combination&amp;lt;br/&amp;gt;优惠券 秒杀 砍价 拼团&amp;#34;]
        A4[&amp;#34;appoint / lunar / businessCard&amp;lt;br/&amp;gt;预约 择吉日 名片（自研）&amp;#34;]
        A5[&amp;#34;finance / statistics / export&amp;lt;br/&amp;gt;充值提现 数据统计 Excel 导出&amp;#34;]
        A6[&amp;#34;wechat / wechatMessage / sms&amp;lt;br/&amp;gt;微信生态与短信&amp;#34;]
        A7[&amp;#34;task&amp;lt;br/&amp;gt;定时任务体系&amp;#34;]
    end
    subgraph M2[&amp;#34;公共层&amp;#34;]
        B1[&amp;#34;拦截器链&amp;lt;br/&amp;gt;前台 Token / 后台 Token+权限 / Swagger&amp;#34;]
        B2[&amp;#34;工具层 RedisUtil / WxPayUtil / CrmebUtil&amp;#34;]
        B3[&amp;#34;ThreadLocal 上下文传递当前用户&amp;#34;]
    end
    A1 --&amp;gt; B1
    A2 --&amp;gt; B2
    B1 --&amp;gt; B3&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;商城主链路之外，后端还有一批容易被忽略的能力：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;微信公众号消息自动回复&lt;/strong&gt;：&lt;code&gt;wechatMessage&lt;/code&gt; 模块支持文字/语音/图片/图文四种回复类型，关注、取关、扫码等事件都有对应处理；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;模板消息与订阅消息&lt;/strong&gt;：小程序订阅消息、公众号模板消息的增删查与群发，且发送走异步任务（&lt;code&gt;AsyncWeChatProgramTempMessage&lt;/code&gt; 等），不阻塞主流程；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;渠道二维码与素材管理&lt;/strong&gt;：带参数的推广二维码、微信素材的上传管理；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;数据导出与统计&lt;/strong&gt;：POI 实现的 Excel 导出、交易/商品/用户多维统计接口（配合管理端 ECharts 大屏）；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;短信与物流&lt;/strong&gt;：短信验证码/通知、快递物流轨迹查询；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;接口文档&lt;/strong&gt;：Swagger 在线文档 + 仓库内置 admin/app/公共三份离线接口文档。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;31-长连接聊天websocket架构&#34;&gt;3.1 长连接聊天（WebSocket）架构&lt;/h3&gt;
&lt;p&gt;在线咨询是这个系统的&amp;quot;第二前台&amp;quot;，采用的是 &lt;strong&gt;WebSocket 长连接 + HTTP 接口&lt;/strong&gt;的组合方案。部署上两个服务跑在同一台服务器，由 nginx 按路径前缀分流：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;graph TD
    MP[&amp;#34;小程序聊天页&amp;lt;br/&amp;gt;pages/chat&amp;#34;] --&amp;gt;|&amp;#34;wss://.../websocket/userId/sessionId&amp;#34;| NGX[&amp;#34;nginx 按路径分流&amp;#34;]
    MP --&amp;gt;|&amp;#34;HTTP /chat/session/**  /chat/msg/**&amp;#34;| NGX
    MP --&amp;gt;|&amp;#34;HTTP /api/front/**&amp;#34;| NGX
    NGX --&amp;gt; CHAT[&amp;#34;聊天服务&amp;lt;br/&amp;gt;WebSocket 长连接 + 会话/消息接口&amp;#34;]
    NGX --&amp;gt; MALL[&amp;#34;商城后端 crm_server&amp;lt;br/&amp;gt;SpringBoot 18080&amp;#34;]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;从开源的小程序端实现和接口契约，可以看到这套长连接聊天的几个设计要点：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;连接即身份&lt;/strong&gt;：WebSocket 握手 URL 直接携带 &lt;code&gt;userId/sessionId&lt;/code&gt;，连接建立的同时就完成了身份识别与会话路由，不需要额外的登录帧；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;实时走长连接，持久化走 HTTP&lt;/strong&gt;：收发消息走 WebSocket（消息体为 JSON）；会话绑定（&lt;code&gt;session/bindSession&lt;/code&gt;）、会话列表、删除会话、历史消息拉取（&lt;code&gt;msg/msgList&lt;/code&gt;）都走 &lt;code&gt;/chat/&lt;/code&gt; 前缀的 HTTP 接口——长连接管&amp;quot;实时推送&amp;quot;，HTTP 管&amp;quot;可查询、可回溯&amp;quot;，职责分得很干净；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;统一的消息类型协议&lt;/strong&gt;：&lt;code&gt;contentType&lt;/code&gt; 1=文字、2=语音（带 &lt;code&gt;contentDuration&lt;/code&gt; 时长）、3=图片（先经 &lt;code&gt;user/upload/image&lt;/code&gt; 上传拿到 URL，再把 URL 作为消息内容发出）；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;语音消息的移动端交互&lt;/strong&gt;：按住说话（&lt;code&gt;touchstart&lt;/code&gt;/&lt;code&gt;touchend&lt;/code&gt; 控制录音起止）、上滑取消（&lt;code&gt;touchmove&lt;/code&gt;/&lt;code&gt;touchcancel&lt;/code&gt;），体验和微信原生聊天对齐；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;发送体验乐观化&lt;/strong&gt;：消息发出后先本地 push 进消息列表立即上屏，&lt;code&gt;send&lt;/code&gt; 的 fail 回调再 toast 兜底提示。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;需要如实说明：聊天服务端的代码没有随这两个仓库开源（它部署在同一台服务器上，由 nginx 分流），仓库里能看到的生产实现是小程序端；&lt;code&gt;crm_server&lt;/code&gt; 里的 &lt;code&gt;com/im/SocketServer.java&lt;/code&gt; 只是当时练手的原生 TCP Socket demo（12345 端口），并非线上实现。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;四技术亮点&#34;&gt;四、技术亮点&lt;/h2&gt;
&lt;h3 id=&#34;41-下单幂等两段式--唯一键兜底&#34;&gt;4.1 下单幂等：两段式 + 唯一键兜底&lt;/h3&gt;
&lt;p&gt;小程序网络环境下用户双击、重试、断网重发是常态，下单接口必须幂等。这里用了一个两段式设计：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;graph TD
    A[&amp;#34;order/confirm 确认订单页&amp;#34;] --&amp;gt; B[&amp;#34;订单数据序列化存 Redis&amp;lt;br/&amp;gt;key = md5 当前时间&amp;lt;br/&amp;gt;有效期 60 分钟&amp;#34;]
    B --&amp;gt; C[&amp;#34;order/create/orderKey 创建订单&amp;#34;]
    C --&amp;gt; D{&amp;#34;第一步:查库判重&amp;lt;br/&amp;gt;StoreOrder.unique = orderKey&amp;#34;}
    D --&amp;gt;|&amp;#34;已存在&amp;#34;| E[&amp;#34;直接返回已有订单&amp;lt;br/&amp;gt;不重复创建&amp;#34;]
    D --&amp;gt;|&amp;#34;不存在&amp;#34;| F{&amp;#34;第二步:校验 Redis 缓存&amp;lt;br/&amp;gt;是否过期/被用过&amp;#34;}
    F --&amp;gt;|&amp;#34;过期&amp;#34;| G[&amp;#34;拒绝 提示重新确认&amp;#34;]
    F --&amp;gt;|&amp;#34;有效&amp;#34;| H[&amp;#34;事务内创建订单&amp;lt;br/&amp;gt;成功后删除缓存&amp;#34;]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;&lt;code&gt;unique&lt;/code&gt; 字段是最后的兜底——即使 Redis 挂了、缓存丢了，数据库唯一约束也保证同一个 orderKey 不会建出两张单。&lt;/p&gt;
&lt;h3 id=&#34;42-原子扣库存防超卖&#34;&gt;4.2 原子扣库存防超卖&lt;/h3&gt;
&lt;p&gt;库存扣减没有用&amp;quot;先查后改&amp;quot;，而是一条 SQL 完成：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-java&#34; data-lang=&#34;java&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// setSql + 条件改写，数据库层面原子完成&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;stock &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; stock &lt;span style=&#34;color:#f92672&#34;&gt;-&lt;/span&gt; n   且要求   stock &lt;span style=&#34;color:#f92672&#34;&gt;-&lt;/span&gt; n &lt;span style=&#34;color:#f92672&#34;&gt;&amp;gt;=&lt;/span&gt; 0&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;p&gt;数据库单行更新的原子性天然挡住并发超卖，不需要分布式锁。秒杀/砍价/拼团场景更复杂：同一事务内要扣&lt;strong&gt;活动表 + 活动 SKU 表 + 商品表 + 商品 SKU 表&lt;/strong&gt;四份库存，任何一步失败整体回滚。&lt;/p&gt;
&lt;h3 id=&#34;43-编程式事务包住整个建单过程&#34;&gt;4.3 编程式事务包住整个建单过程&lt;/h3&gt;
&lt;p&gt;创建订单是个多写操作：扣库存、占预约时段、写订单主表、写订单详情、核销优惠券、写订单日志。代码用 &lt;code&gt;TransactionTemplate&lt;/code&gt; 编程式事务（而不是 &lt;code&gt;@Transactional&lt;/code&gt; 注解）把这一串操作包起来——好处是事务边界精确可控，不会把整个方法（包括不需要事务的远程调用）都圈进去。订单核心逻辑集中在 &lt;code&gt;OrderUtils.java&lt;/code&gt;（1500+ 行）和 &lt;code&gt;OrderServiceImpl.java&lt;/code&gt;（近 1000 行）。&lt;/p&gt;
&lt;h3 id=&#34;44-支付回调防重--异步化&#34;&gt;4.4 支付回调：防重 + 异步化&lt;/h3&gt;
&lt;p&gt;微信支付的回调处理（&lt;code&gt;CallbackServiceImpl.weChat&lt;/code&gt;，448 行）有几个关键点：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;解析 XML、校验 &lt;code&gt;return_code&lt;/code&gt;/&lt;code&gt;result_code&lt;/code&gt;，按 &lt;code&gt;attach&lt;/code&gt; 里携带的 userId + type 区分是订单支付还是充值；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;已支付直接返回 SUCCESS&lt;/strong&gt;——微信会多次重推回调，幂等是回调接口的生死线；&lt;/li&gt;
&lt;li&gt;事务内完成 &lt;code&gt;updatePaid&lt;/code&gt;（置订单已支付）+ 扣积分 + 拼团状态处理；&lt;/li&gt;
&lt;li&gt;后续的&amp;quot;慢动作&amp;quot;——发短信、推订阅消息、分销结算——&lt;strong&gt;不放在回调里同步做&lt;/strong&gt;，而是 &lt;code&gt;lPush&lt;/code&gt; 进 Redis 队列异步执行，回调接口尽快给微信返回，避免超时重推。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&#34;45-没有-mqredis-list--定时任务的务实替代&#34;&gt;4.5 没有 MQ：Redis List + 定时任务的务实替代&lt;/h3&gt;
&lt;p&gt;项目没有引入 RabbitMQ/RocketMQ，而是用 &lt;strong&gt;Redis List 当队列 + Spring &lt;code&gt;@Scheduled&lt;/code&gt; 定时任务消费&lt;/strong&gt;，撑起了一整套异步任务体系：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;graph LR
    A[&amp;#34;下单成功&amp;lt;br/&amp;gt;lPush ORDER_AUTO_CANCEL_KEY&amp;#34;] --&amp;gt; Q[&amp;#34;Redis List 队列&amp;#34;]
    P[&amp;#34;支付成功回调&amp;lt;br/&amp;gt;lPush 后续动作&amp;#34;] --&amp;gt; Q
    Q --&amp;gt; T1[&amp;#34;OrderAutoCancelTask&amp;lt;br/&amp;gt;每分钟消费&amp;lt;br/&amp;gt;超时未支付自动取消 + 回滚库存&amp;#34;]
    Q --&amp;gt; T2[&amp;#34;自动收货 / 自动好评&amp;#34;]
    Q --&amp;gt; T3[&amp;#34;优惠券过期 / 砍价截止&amp;lt;br/&amp;gt;佣金解冻&amp;#34;]
    Q --&amp;gt; T4[&amp;#34;TeacherPlanTask&amp;lt;br/&amp;gt;每8小时补齐号源&amp;#34;]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;&lt;code&gt;task/order/&lt;/code&gt; 下共有 8 个定时任务。对这个体量的系统，省掉一个 MQ 的部署运维成本，换来的是完全够用的可靠性——这是小项目技术选型上我很认可的一个取舍。&lt;/p&gt;
&lt;h3 id=&#34;46-鉴权链路uuid-token--redis--threadlocal&#34;&gt;4.6 鉴权链路：UUID Token + Redis + ThreadLocal&lt;/h3&gt;
&lt;p&gt;没有用 JWT，而是更传统的方案：登录后签发 UUID 存 Redis（按模块区分 key 前缀），拦截器 &lt;code&gt;FrontTokenInterceptor&lt;/code&gt; → &lt;code&gt;CheckFrontToken&lt;/code&gt; → &lt;code&gt;TokenManagerImpl&lt;/code&gt; 逐级校验，通过后把当前用户放进 &lt;code&gt;ThreadLocal&lt;/code&gt; 供全链路取用。管理端是 Token + 权限双拦截器。好处是&lt;strong&gt;服务端可随时踢人下线&lt;/strong&gt;（删 Redis key 即可），这是纯 JWT 做不到的。&lt;/p&gt;
&lt;h3 id=&#34;47-其他值得提的实现&#34;&gt;4.7 其他值得提的实现&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;订单快照&lt;/strong&gt;：下单时把购物车内容序列化成 JSON 存进 &lt;code&gt;StoreOrderInfo.info&lt;/code&gt;，之后商品改价改名都不影响历史订单的展示；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;运费计算引擎&lt;/strong&gt;：&lt;code&gt;getOrderPriceGroup&lt;/code&gt; 支持满额包邮、指定城市包邮（件数 + 金额双条件）、按件数/重量/体积三种首续计费模式；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;XSS 转义&lt;/strong&gt;：用户输入统一 &lt;code&gt;escapeHtml4&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;部署脚本&lt;/strong&gt;：&lt;code&gt;start.sh&lt;/code&gt; nohup 启动 + 轮询日志判断启动成功 + 企业微信机器人推送部署结果——单机部署也搞出了仪式感。&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&#34;五复盘与遗憾&#34;&gt;五、复盘与遗憾&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;底座与自研的边界&lt;/strong&gt;：秒杀/拼团/砍价/分销这些&amp;quot;重&amp;quot;能力直接复用 CRMEB 底座，把精力集中在业务差异化（预约、择吉日、聊天）上——对个人开发者来说这是正确的杠杆；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;单体的局限&lt;/strong&gt;：所有东西在一个 SpringBoot 应用里，定时任务、支付、前台接口互相影响，量大了必然要拆；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;原子扣库存的返回值没检查&lt;/strong&gt;：&lt;code&gt;setSql&lt;/code&gt; 扣减失败（库存不足）时影响的行数为 0，但代码没有校验这个返回值，极端情况下会漏判超卖——这是现在回头看想补的一个洞；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;聊天服务端未开源&lt;/strong&gt;：如第三章所述，长连接聊天的服务端实现没有随仓库开源，小程序端和接口契约是完整的，算是开源的一点遗憾。&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&#34;六写在最后&#34;&gt;六、写在最后&lt;/h2&gt;
&lt;p&gt;这个项目让我完整走了一遍&amp;quot;真实收钱&amp;quot;的系统该有的样子：幂等、事务、防超卖、回调防重、异步补偿——每一个点背后都是电商系统趟过的坑。代码在 &lt;a href=&#34;https://gitee.com/soulnew/starmall&#34;&gt;https://gitee.com/soulnew/starmall&lt;/a&gt; 和 &lt;a href=&#34;https://gitee.com/soulnew/crm_server&#34;&gt;https://gitee.com/soulnew/crm_server&lt;/a&gt;，建库 SQL、接口文档都在仓库里，希望能给做小程序商城的朋友一个可参照的完整样本。&lt;/p&gt;
</description>
    </item>
    <item>
      <title>教你打麻将</title>
      <link>https://ftmao.com/blog/posts/beimi-chess-card-game-summary/</link>
      <pubDate>Wed, 24 Jun 2020 14:00:00 +0800</pubDate>
      <guid>https://ftmao.com/blog/posts/beimi-chess-card-game-summary/</guid>
      <description>&lt;h1 id=&#34;教你打麻将&#34;&gt;教你打麻将&lt;/h1&gt;
&lt;p&gt;文章面向&lt;strong&gt;从未接触过联网游戏开发&lt;/strong&gt;的读者，所以前半部分会把&amp;quot;联网棋牌游戏到底是怎么跑起来的&amp;quot;讲清楚，中间穿插真实代码层面的例子；后半部分专门开一章讲四川麻将的新手实战策略——毕竟做麻将游戏的人，自己先得会打麻将。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;服务端：Java + Spring Boot + Netty（netty-socketio）+ Hazelcast + MySQL&lt;/li&gt;
&lt;li&gt;客户端：Cocos Creator / JavaScript&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&#34;一先搞明白联网棋牌游戏到底是怎么回事&#34;&gt;一、先搞明白：联网棋牌游戏到底是怎么回事&lt;/h2&gt;
&lt;h3 id=&#34;11-单机游戏和联网游戏的本质区别&#34;&gt;1.1 单机游戏和联网游戏的本质区别&lt;/h3&gt;
&lt;p&gt;单机游戏里，所有逻辑都在你自己的设备上运行，规则由你的设备说了算。联网游戏不行——四个人打麻将，&lt;strong&gt;谁摸了什么牌、谁点了炮，必须有一个&amp;quot;裁判&amp;quot;说了算&lt;/strong&gt;，否则每个人的手机都各算各的，立刻就乱套了。&lt;/p&gt;
&lt;p&gt;这个&amp;quot;裁判&amp;quot;就是&lt;strong&gt;服务器&lt;/strong&gt;。联网棋牌游戏的基本模型是：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;graph TB
    subgraph 玩家设备
        C1[玩家A的手机&amp;lt;br/&amp;gt;Cocos Creator 客户端]
        C2[玩家B的手机]
        C3[玩家C的手机]
        C4[玩家D的手机]
    end
    S[游戏服务器&amp;lt;br/&amp;gt;贝密：Spring Boot + Netty]
    DB[(MySQL&amp;lt;br/&amp;gt;账号/战绩/房卡)]
    CACHE[(内存缓存 Hazelcast&amp;lt;br/&amp;gt;房间/牌局实时状态)]

    C1 &amp;lt;--&amp;gt;|WebSocket 长连接| S
    C2 &amp;lt;--&amp;gt; S
    C3 &amp;lt;--&amp;gt; S
    C4 &amp;lt;--&amp;gt; S
    S --&amp;gt; CACHE
    S -.异步落盘.-&amp;gt; DB&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;几个关键点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;牌在服务器手里&lt;/strong&gt;。洗牌、发牌都在服务器上做，客户端只负责&amp;quot;显示&amp;quot;和&amp;quot;发指令&amp;quot;。你在手机上点&amp;quot;打出三万&amp;quot;，本质是给服务器发了一条消息：&lt;code&gt;doplaycards: &amp;quot;12&amp;quot;&lt;/code&gt;，服务器校验&amp;quot;现在确实轮到你、你手里确实有三万&amp;quot;之后，才广播给全桌人。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;每个人看到的信息不同&lt;/strong&gt;。服务器把同一局牌的状态，按&amp;quot;你能看到什么&amp;quot;过滤后分别发给四个人——你只能看到自己的手牌和别人打出来的牌。这叫&lt;strong&gt;信息不对称&lt;/strong&gt;，也是棋牌游戏和射击游戏最大的不同：状态同步必须分人定制。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;防作弊的第一道防线就是&amp;quot;服务器权威&amp;quot;&lt;/strong&gt;。客户端想赖牌（打出一张自己没有的牌），服务器直接拒绝。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;12-为什么用-websocket-而不是-http&#34;&gt;1.2 为什么用 WebSocket 而不是 HTTP&lt;/h3&gt;
&lt;p&gt;没接触过联网游戏的人容易问：发消息不就是调接口吗，用 HTTP 不行吗？&lt;/p&gt;
&lt;p&gt;不行，因为 HTTP 是&amp;quot;问一句答一句&amp;quot;，服务器没法&lt;strong&gt;主动&lt;/strong&gt;找你。而棋牌游戏里大量事件是服务器主动推的：轮到你出牌了、有人碰了你的牌、房间人齐了……这些都需要服务器&lt;strong&gt;随时&lt;/strong&gt;推消息给客户端。WebSocket 是一条建立后一直保持的双向通道，双方随时可以说话，延迟低、开销小。贝密用的 &lt;code&gt;netty-socketio&lt;/code&gt; 就是在 Netty 上实现了 Socket.IO 协议（跑在 WebSocket 上），客户端 Cocos Creator 里直接用 socket.io 的 JS 库连。&lt;/p&gt;
&lt;h3 id=&#34;13-房卡模式一张虚拟门票&#34;&gt;1.3 房卡模式：一张&amp;quot;虚拟门票&amp;quot;&lt;/h3&gt;
&lt;p&gt;贝密 0.8.0 主打&lt;strong&gt;房卡模式&lt;/strong&gt;，这也是 2016-2018 年棋牌游戏爆发的典型商业模式：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;你在商城购买&amp;quot;房卡&amp;quot;（虚拟道具，存在 MySQL 里）；&lt;/li&gt;
&lt;li&gt;你想和朋友打牌，就&amp;quot;开房&amp;quot;——消耗房卡，服务器生成一个 &lt;strong&gt;6 位数字房号&lt;/strong&gt;（贝密里由 &lt;code&gt;GameEngine.creatGameRoom&lt;/code&gt; 生成）；&lt;/li&gt;
&lt;li&gt;朋友在大厅输入房号进房（客户端发 &lt;code&gt;searchroom&lt;/code&gt; 事件），人齐开局；&lt;/li&gt;
&lt;li&gt;打完约定的局数，服务器出结算单，房间销毁。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;和陌生人随机撮合的&amp;quot;大厅模式&amp;quot;相比，房卡模式本质上卖的是&amp;quot;熟人社交的场地&amp;quot;，技术上则多了一个&amp;quot;按房号精确找房&amp;quot;的入口。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;二贝密的整体架构拆解&#34;&gt;二、贝密的整体架构拆解&lt;/h2&gt;
&lt;h3 id=&#34;21-架构总览&#34;&gt;2.1 架构总览&lt;/h3&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;graph LR
    subgraph 客户端
        A[Cocos Creator&amp;lt;br/&amp;gt;dizhu / majiang 模块]
    end
    subgraph 服务端 com.beimi
        B[GameEventHandler&amp;lt;br/&amp;gt;Socket 事件入口]
        C[GameEngine&amp;lt;br/&amp;gt;游戏总调度]
        D[自研状态机&amp;lt;br/&amp;gt;BeiMiStateMachine]
        E[Board 牌局内存模型&amp;lt;br/&amp;gt;MaJiangBoard / DuZhuBoard]
        F[Action/Task&amp;lt;br/&amp;gt;出牌·结算·计时任务]
    end
    subgraph 存储层
        G[(Hazelcast&amp;lt;br/&amp;gt;房间/牌局/玩家/撮合队列)]
        H[(cache2k&amp;lt;br/&amp;gt;过期回调当计时器)]
        I[Disruptor RingBuffer&amp;lt;br/&amp;gt;异步事件]
        J[(MySQL JPA)]
        K[(Elasticsearch)]
    end

    A &amp;lt;--&amp;gt;|WebSocket /game namespace| B
    B --&amp;gt; C --&amp;gt; D --&amp;gt; E
    D --&amp;gt; F
    E --&amp;gt; G
    F --&amp;gt; H
    C --&amp;gt; I
    I --&amp;gt; J
    I --&amp;gt; K&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;各层职责一句话概括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;GameEventHandler&lt;/strong&gt;：所有 &lt;code&gt;@OnEvent&lt;/code&gt; 端点都在这里，负责把客户端消息翻译成人话（比如把 &lt;code&gt;&amp;quot;12,13&amp;quot;&lt;/code&gt; 解析成字节数组），然后交给 GameEngine；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;GameEngine&lt;/strong&gt;：总调度，加房、开局、出牌、杠碰胡、离房、解散房间都从这儿过；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;自研状态机&lt;/strong&gt;：驱动&amp;quot;房间&amp;quot;这个对象的生命周期（后面细讲）；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Board 内存模型&lt;/strong&gt;：一局牌的完整状态（四家的手牌、桌面牌、轮到谁、倍率），只活在 Hazelcast 内存缓存里；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;存储分层&lt;/strong&gt;：实时状态全在 Hazelcast，MySQL 只存账号/战绩/房卡流水，而且是通过 Disruptor &lt;strong&gt;异步&lt;/strong&gt;写入——牌局进行中的一秒钟都不等数据库。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;22-一次出牌的完整旅程以麻将为例&#34;&gt;2.2 一次出牌的完整旅程（以麻将为例）&lt;/h3&gt;
&lt;p&gt;这是最核心的一条链路，用实际代码里的事件名走一遍：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;sequenceDiagram
    participant P1 as 玩家A客户端
    participant H as GameEventHandler
    participant E as GameEngine
    participant B as MaJiangBoard
    participant T as 计时器(cache2k)
    participant P2 as 其他玩家客户端

    P1-&amp;gt;&amp;gt;H: emit(&amp;#34;doplaycards&amp;#34;, &amp;#34;12&amp;#34;)
    H-&amp;gt;&amp;gt;E: takeCardsRequest(token, card)
    E-&amp;gt;&amp;gt;B: 出牌校验 + 从手牌移除
    E-&amp;gt;&amp;gt;T: remove(roomid) 取消A的出牌倒计时
    E--&amp;gt;&amp;gt;P2: 广播 &amp;#34;command&amp;#34;: takecards
    E-&amp;gt;&amp;gt;E: checkMJCard 遍历其他家&amp;lt;br/&amp;gt;查 杠/碰/吃/胡
    alt 有人能杠碰胡
        E-&amp;gt;&amp;gt;T: 挂5秒等待任务(DEAL事件)
        P2-&amp;gt;&amp;gt;H: emit(&amp;#34;selectaction&amp;#34;, &amp;#34;peng&amp;#34;)
        H-&amp;gt;&amp;gt;E: actionEventRequest 执行碰
    else 无人响应
        E-&amp;gt;&amp;gt;B: dealRequest 下家抓牌
        E--&amp;gt;&amp;gt;P2: 广播 &amp;#34;command&amp;#34;: dealcard
        E-&amp;gt;&amp;gt;T: 挂8秒出牌任务&amp;lt;br/&amp;gt;(超时自动打)
    end&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;&lt;strong&gt;举例说明&lt;/strong&gt;：假设你打出一张&amp;quot;五筒&amp;quot;，服务器干的事依次是——&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;校验：轮到你、你有五筒、这张牌合法；&lt;/li&gt;
&lt;li&gt;把你 8 秒的出牌倒计时从计时器缓存里删掉（你不主动出，超时会由 &lt;code&gt;CreateMJPlayCardsTask&lt;/code&gt; 强制替你出）；&lt;/li&gt;
&lt;li&gt;广播给全桌：&amp;ldquo;A 打了五筒&amp;rdquo;；&lt;/li&gt;
&lt;li&gt;挨个问另外三家：你能碰吗？能杠吗？能胡吗？（定缺了筒子的那家直接跳过——这就是四川麻将的规则在代码里的体现，&lt;code&gt;GameUtils.processMJCard&lt;/code&gt; 里定缺花色不参与判断）；&lt;/li&gt;
&lt;li&gt;如果 B 能碰，给 B 弹&amp;quot;碰/过&amp;quot;按钮，牌局挂起 5 秒等他决定；B 点了&amp;quot;碰&amp;quot;，就执行碰、轮到 B 出牌；&lt;/li&gt;
&lt;li&gt;如果三家都&amp;quot;过&amp;quot;，下家自动摸一张牌，服务器先替他检查一次&amp;quot;摸上来的牌能不能暗杠/自摸&amp;quot;，然后通知他出牌，重新挂上 8 秒倒计时。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;整个过程中，客户端只做两件事：把服务器的消息渲染成动画，把玩家的点击发给服务器。&lt;/p&gt;
&lt;h3 id=&#34;23-房间的状态机流转&#34;&gt;2.3 房间的状态机流转&lt;/h3&gt;
&lt;p&gt;房间是有生命的：创建、等人、人齐、开局、结算、销毁。贝密没有用 Spring StateMachine，而是&lt;strong&gt;自研了一个极简状态机&lt;/strong&gt;（&lt;code&gt;core/statemachine&lt;/code&gt; 包），每种游戏一个 Bean：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;stateDiagram-v2
    [*] --&amp;gt; NONE
    NONE --&amp;gt; CRERATED: ENTER 创建房间
    CRERATED --&amp;gt; WAITTING: JOIN 玩家陆续进房
    WAITTING --&amp;gt; READY: ENOUGH 人齐
    WAITTING --&amp;gt; WAITTING: AUTO 超时补AI机器人
    READY --&amp;gt; BEGIN: RAISEHANDS 全员准备
    BEGIN --&amp;gt; LASTHANDS: 发牌完成
    LASTHANDS --&amp;gt; PLAY: 庄家打出首张
    PLAY --&amp;gt; PLAY: DEAL/PLAYCARDS&amp;lt;br/&amp;gt;摸牌出牌循环
    PLAY --&amp;gt; END: ALLCARDS 有人胡牌/牌摸完
    END --&amp;gt; [*]: 结算发战报&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;状态机里每个&amp;quot;事件&amp;quot;对应一串 Action（比如麻将的链路是 &lt;code&gt;EnterAction → JoinAction → EnoughAction → SelectAction(定缺) → MJRaiseHandsAction → DealMJCardAction → PlayMJCardsAction → AllCardsAction&lt;/code&gt;），业务代码只需要一句 &lt;code&gt;game.change(room, event, intervalSeconds)&lt;/code&gt;，状态机负责找到对应 Action 执行，并由 Action 顺手创建下一个延时任务，形成闭环。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;三断线重连联网游戏逃不掉的工程问题&#34;&gt;三、断线重连：联网游戏逃不掉的工程问题&lt;/h2&gt;
&lt;p&gt;新手最容易低估的就是这块。手机上打牌，来个电话、进个电梯，连接说断就断。贝密的处理方式是&amp;quot;托管 + 快照恢复&amp;quot;：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart TD
    A[玩家网络断开] --&amp;gt; B{服务器收到&amp;lt;br/&amp;gt;OnDisconnect}
    B --&amp;gt;|玩家正在对局中| C[标记 OFFLINE&amp;lt;br/&amp;gt;进入托管状态]
    C --&amp;gt; D[计时器超时后&amp;lt;br/&amp;gt;auto=true 自动出牌]
    D --&amp;gt; E{玩家重连?}
    E --&amp;gt;|emit recovery| F[GameEngine 查缓存&amp;lt;br/&amp;gt;发现该房间有进行中的 Board]
    F --&amp;gt; G[下发 RecoveryData 全量快照:&amp;lt;br/&amp;gt;自己的手牌/桌面牌/轮到谁/剩余倒计时25s]
    G --&amp;gt; H[客户端按快照重建牌桌画面]
    E --&amp;gt;|一直没回来| I[AI托管打到本局结束]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;&lt;strong&gt;举例&lt;/strong&gt;：你出牌出了半局断网了，服务器不会暂停牌局等你（其他三个人不答应），而是把你标记为 OFFLINE，由超时任务替你&amp;quot;摸什么打什么&amp;quot;。30 秒后你连回来了，客户端发 &lt;code&gt;recovery&lt;/code&gt;，服务器把当前牌局的完整快照发给你，你的手机根据快照把桌面重新画出来，无缝接上。注意重连期间你是被托管的——所以掉线久了回来，往往牌已经很难看了，这是规则的一部分。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;四四川麻将新手致胜策略一个结构化的决策框架&#34;&gt;四、四川麻将新手致胜策略：一个结构化的决策框架&lt;/h2&gt;
&lt;p&gt;做了这么久的麻将游戏，经常被问&amp;quot;新手怎么提高胜率&amp;quot;。零散的口诀（&amp;ldquo;记牌&amp;quot;&amp;ldquo;防点炮&amp;rdquo;）新手记不住也用不上，所以这一章我把四川麻将的策略整理成一个&lt;strong&gt;结构化框架&lt;/strong&gt;：先确立唯一的目标函数，再按&amp;quot;战略层 → 信息层 → 战术层 → 概率层&amp;quot;四个层级组织所有决策，最后给出血战到底的特有推论和新手错误清单。逻辑主线只有一句话：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;麻将的每一步都是在信息不完备下做期望收益最大化的决策。四个层级分别回答：往哪走（战略）、知道什么（信息）、这巡打哪张（战术）、什么时候切换路线（概率）。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&#34;40-规则即策略先立规则再推结论&#34;&gt;4.0 规则即策略：先立规则，再推结论&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;定缺&lt;/strong&gt;：开局后每人必须选一门花色（万/筒/条）作为&amp;quot;缺门&amp;rdquo;，胡牌时手里不能有这门牌，摸到缺门的牌必须打掉。牌墙只有 108 张（三门花色，无风牌）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;血战到底&lt;/strong&gt;：一家胡牌不结束牌局，剩下的人继续打，直到牌摸完或只剩一家没胡。点炮只有点炮者付分，最后没胡的人还要给已听牌的人&amp;quot;查叫&amp;quot;赔分。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;从这两条规则可以直接推出三条战略推论，整章都是它们的展开：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;信息压缩&lt;/strong&gt;：定缺把每家的&amp;quot;有效牌域&amp;quot;从三门砍到两门，而且是&lt;strong&gt;公开信息&lt;/strong&gt;。读牌成本比别的麻将低一个量级——新手也能读。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;时间价值&lt;/strong&gt;：牌局不因一人胡牌而结束，每张牌的价值随牌墙减少而衰减。&lt;strong&gt;&amp;ldquo;还剩几巡&amp;quot;是所有决策的隐含变量&lt;/strong&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;止损优先&lt;/strong&gt;：点炮只由点炮者一人付分、没胡要赔查叫——&lt;strong&gt;落后的代价是确定的，追上的收益是概率性的&lt;/strong&gt;。这决定了&amp;quot;不跟随到最后&amp;quot;在数学上往往是对的。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;三条推论的推导关系：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart LR
    R1[规则一: 定缺&amp;lt;br/&amp;gt;缺门公开] --&amp;gt; C1[推论一: 信息压缩&amp;lt;br/&amp;gt;读牌成本低]
    R2[规则二: 血战到底&amp;lt;br/&amp;gt;胡牌不终局] --&amp;gt; C2[推论二: 时间价值&amp;lt;br/&amp;gt;剩几巡是隐含变量]
    R2 --&amp;gt; C3[推论三: 止损优先&amp;lt;br/&amp;gt;点炮独赔+查叫保底]
    C1 --&amp;gt; S[决定 4.2 信息层怎么读]
    C2 --&amp;gt; S2[决定 4.4 何时切换路线]
    C3 --&amp;gt; S3[决定 4.5 防守与保底策略]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;h3 id=&#34;41-战略层起手评估与路线选择&#34;&gt;4.1 战略层：起手评估与路线选择&lt;/h3&gt;
&lt;p&gt;拿到起手牌、定缺之前，先给牌打分。不需要精确，三个数就够：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;向听数&lt;/strong&gt;：还差几张牌能听牌。数一下你手里有几个成型搭子/刻子/对子，13 张牌需要 4 副 + 1 将，差几副就是几向听。新手只要会区分&amp;quot;两向听以内（好牌）&amp;ldquo;和&amp;quot;三向听以上（差牌）&amp;quot;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;对子数&lt;/strong&gt;：4 对以上 → 七对/大对子路线候选；0~1 对 → 别惦记对子路线。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;同花色集中度&lt;/strong&gt;：9 张以上同花色 → 清一色路线候选。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;定缺选择&lt;/strong&gt;是第一个战略决策，原则按优先级排：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;缺张数最少的那门&lt;/strong&gt;——缺门每张都是必打的废牌，张数越少浪费的巡数越少；&lt;/li&gt;
&lt;li&gt;张数相近时，&lt;strong&gt;缺结构最差的那门&lt;/strong&gt;（孤张多、没搭子的）；&lt;/li&gt;
&lt;li&gt;还在纠结时，&lt;strong&gt;缺端张多的那门&lt;/strong&gt;（1、9 多），因为端张成搭能力弱。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;举例&lt;/strong&gt;：起手万子 2 张（三万、八万，无关联）、筒子 5 张（两个搭子）、条子 6 张（一个刻子一个边搭）。毫无疑问题缺万——但如果万子是 2 张而筒子是 3 张但全是散牌，就该缺筒子：定缺看的是&amp;quot;废牌数量&amp;rdquo;，不是&amp;quot;张数&amp;rdquo;。&lt;/p&gt;
&lt;p&gt;定缺公布后立即做第二步战略判断：&lt;strong&gt;看三家的缺门，决定做大还是快跑&lt;/strong&gt;。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;三家中有两家缺你这门 → 你要做的花色没人抢，牌墙里全剩给你，&lt;strong&gt;做大的绿灯&lt;/strong&gt;；&lt;/li&gt;
&lt;li&gt;三家都不缺你这门 → 四个人抢一门牌，你的关键张会被飞速消耗，&lt;strong&gt;果断转快跑&lt;/strong&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;至此，三条路线已经清晰：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;路线&lt;/th&gt;
					&lt;th&gt;触发条件&lt;/th&gt;
					&lt;th&gt;目标&lt;/th&gt;
					&lt;th&gt;核心动作&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;快跑&lt;/td&gt;
					&lt;td&gt;牌型一般、向听小、花色被抢&lt;/td&gt;
					&lt;td&gt;最速听牌、先胡锁定收益&lt;/td&gt;
					&lt;td&gt;牌效优先，不留废搭&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;做大&lt;/td&gt;
					&lt;td&gt;起手 9 张+同花色/4 对+，且无竞争&lt;/td&gt;
					&lt;td&gt;清一色/大对子/七对&lt;/td&gt;
					&lt;td&gt;忍受慢听牌，屯关键张&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;防守&lt;/td&gt;
					&lt;td&gt;有人已听牌且自己手牌差&lt;/td&gt;
					&lt;td&gt;不点炮、保查叫底线&lt;/td&gt;
					&lt;td&gt;只打熟张，听牌优先于胡牌&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;把起手到定路线的全过程画成一张图：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart TD
    A[拿到13张起手牌] --&amp;gt; B[三个数打分:&amp;lt;br/&amp;gt;向听数 / 对子数 / 同花色集中度]
    B --&amp;gt; C{定缺: 缺哪门?}
    C --&amp;gt;|第一优先| C1[缺张数最少的门]
    C --&amp;gt;|张数相近| C2[缺结构最差的门&amp;lt;br/&amp;gt;孤张多没搭子]
    C --&amp;gt;|还纠结| C3[缺端张多的门&amp;lt;br/&amp;gt;1和9成搭弱]
    C1 &amp;amp; C2 &amp;amp; C3 --&amp;gt; D[公布定缺&amp;lt;br/&amp;gt;观察三家缺门]
    D --&amp;gt; E{有人抢我要做的花色?}
    E --&amp;gt;|两家以上缺我这门&amp;lt;br/&amp;gt;绿灯| F{起手支持做大?&amp;lt;br/&amp;gt;9张+同花色 或 4对+}
    F --&amp;gt;|是| G[做大路线&amp;lt;br/&amp;gt;清一色/大对子/七对]
    F --&amp;gt;|否| H[快跑路线&amp;lt;br/&amp;gt;最速听牌]
    E --&amp;gt;|三家都抢我这门| H
    E --&amp;gt;|牌型差 三向听以上| I[保守推进&amp;lt;br/&amp;gt;随时准备转防守]
    style G fill:#ffe6cc
    style H fill:#d4edda
    style I fill:#f8d7da&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;路线不是选定就不变——&lt;strong&gt;每一巡都可以用新信息重估&lt;/strong&gt;，4.4 会给出切换的量化标准。&lt;/p&gt;
&lt;h3 id=&#34;42-信息层记牌与读牌&#34;&gt;4.2 信息层：记牌与读牌&lt;/h3&gt;
&lt;p&gt;信息层回答&amp;quot;我知道什么&amp;quot;。它分两半：&lt;strong&gt;记牌&lt;/strong&gt;是对已发生事实的压缩存储，&lt;strong&gt;读牌&lt;/strong&gt;是从事实反推未知信息。&lt;/p&gt;
&lt;h4 id=&#34;421-记牌分层记忆法&#34;&gt;4.2.1 记牌：分层记忆法&lt;/h4&gt;
&lt;p&gt;让新手背下三家打过的每张牌不现实。按重要性只记三类：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第一层：记&amp;quot;张数&amp;quot;不记&amp;quot;顺序&amp;quot;。&lt;/strong&gt; 每门花色每张牌只有 4 张。你不需要记住&amp;quot;上家第 3 巡打了七万&amp;quot;，只需在脑子里维护计数：&amp;ldquo;七万已见 3 张&amp;rdquo;。重点盯的是&lt;strong&gt;你自己将牌和搭子两端的关键牌&lt;/strong&gt;——你手里 6、8 万等卡 7 万，&amp;ldquo;7 万见了几张&amp;quot;直接决定这个搭子的死活：见 3 张以上，搭子基本死了，趁早拆。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第二层：记&amp;quot;缺门&amp;quot;和&amp;quot;花色倾向&amp;rdquo;。&lt;/strong&gt; 定缺后，对手打出的&lt;strong&gt;非缺门&lt;/strong&gt;花色才是有效信息。下家缺条，从第 2 巡起一直打筒子、摸到万子全留——一句话记住&amp;quot;下家只留万&amp;quot;即可，不用记他打的每一张。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第三层：记&amp;quot;序盘的反常中张&amp;quot;。&lt;/strong&gt; 序盘（前 4~5 巡）正常人先打孤张边张（1、9）。有人序盘就打 4、5、6 中张，说明这门要么极好（搭子齐了中张多余）要么整门不要，结合缺门大概率能分辨。&lt;/p&gt;
&lt;p&gt;口诀：&lt;strong&gt;数自己的关键张，记别人的花色倾向，盯序盘的反常中张。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;三层记忆的工作量递减、收益递增，像一座金字塔——新手从塔底练起：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart BT
    L1[第三层: 序盘反常中张&amp;lt;br/&amp;gt;一两个反常信号即可&amp;lt;br/&amp;gt;精力占比 ~10%] --&amp;gt; L2[第二层: 缺门与花色倾向&amp;lt;br/&amp;gt;每家一句话: 他只留X&amp;lt;br/&amp;gt;精力占比 ~30%]
    L2 --&amp;gt; L3[第一层: 关键张计数&amp;lt;br/&amp;gt;自己搭子两端的牌各见几张&amp;lt;br/&amp;gt;精力占比 ~60%]
    L3 --&amp;gt; R[收益: 搭子死活判断&amp;lt;br/&amp;gt;直接决定拆不拆]
    L2 --&amp;gt; R2[收益: 知道谁在收哪门&amp;lt;br/&amp;gt;直接决定防什么]
    L1 --&amp;gt; R3[收益: 序盘读出牌型方向]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;h4 id=&#34;422-读牌他要什么不要什么打什么危险&#34;&gt;4.2.2 读牌：他要什么、不要什么、打什么危险&lt;/h4&gt;
&lt;p&gt;读牌的本质是&lt;strong&gt;用别人的&amp;quot;打出&amp;quot;反推他的&amp;quot;留下&amp;quot;&lt;/strong&gt;——人留下什么牌，才会打出什么牌。按牌局阶段读不同的东西：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;序盘读&amp;quot;结构&amp;quot;&lt;/strong&gt;：缺门 + 反常中张 + 第一碰，拼出每家的大致牌型方向。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;中盘读&amp;quot;进度&amp;quot;&lt;/strong&gt;：看连续动作。上家碰了&amp;quot;二筒&amp;quot;后摸打筒子以外的牌，说明他筒子上还有货；碰完后打的那张牌的&lt;strong&gt;邻张&lt;/strong&gt;往往是他不需要的（碰二筒后打五万，他大概率没有 3-4 万、4-6 万的搭子）；碰了二筒后打的还是筒子，几乎可断定他在做筒子清一色或已听牌。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;尾盘读&amp;quot;节奏&amp;quot;&lt;/strong&gt;：一个人&lt;strong&gt;连续摸牌就打（摸打）&lt;/strong&gt;，说明手牌已成型，打出的就是刚摸的；反过来&lt;strong&gt;想了很久才打出一张&lt;/strong&gt;，说明这张是从手里&amp;quot;拆&amp;quot;出来的，他在这张牌附近有搭子变动，其邻张不再安全。&lt;/p&gt;
&lt;p&gt;由此得到防点炮 checklist，中后盘（牌墙剩 1/3 以内）每打一张前过一遍：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;这张牌见过几张？&lt;/strong&gt; 见 2~3 张的熟张相对安全；一张没见的生张风险最高。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;有没有人明显在收这门？&lt;/strong&gt; 对家在做万子，你手里的生张五万就是高危，宁可拆搭子打熟张。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;他是不是单钓/卡张？&lt;/strong&gt; 碰了三副、手牌只剩一两张的人，听的牌范围很窄，往往就在他碰的那几副的邻张上——那一片中张全是雷区。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;完整例子&lt;/strong&gt;：对家定缺条子，碰了&amp;quot;三筒&amp;quot;，摸打两巡后思考良久切出&amp;quot;八万&amp;quot;。推断：他在做筒子（碰三筒+留筒），八万是万子里的多余搭子；&lt;strong&gt;你手里五、六、七万从此相对安全&lt;/strong&gt;（他不要万子），但&lt;strong&gt;任何生张筒子都是高危&lt;/strong&gt;，尤其一、二、四筒（三筒邻张）。&lt;/p&gt;
&lt;p&gt;读牌的推理链条固定是&amp;quot;观察 → 反推 → 行动&amp;quot;三段，常见信号对照如下：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart LR
    subgraph 观察[他打出/做了什么]
        O1[缺门公布后&amp;lt;br/&amp;gt;一直打筒留万]
        O2[碰二筒后&amp;lt;br/&amp;gt;又打出筒子]
        O3[碰二筒后&amp;lt;br/&amp;gt;打出五万]
        O4[连续摸打]
        O5[长考后拆出八万]
    end
    subgraph 反推[他留下/在想什么]
        I1[在做万子清一色]
        I2[筒子听牌或做筒]
        I3[万子3-4/4-6搭子不存在&amp;lt;br/&amp;gt;→ 万字邻张变安全]
        I4[手牌已成型&amp;lt;br/&amp;gt;打的就是刚摸的]
        I5[八万附近有搭子变动&amp;lt;br/&amp;gt;→ 邻张不安全]
    end
    subgraph 行动[我该怎么办]
        A1[生张万子列为高危]
        A2[生张筒子绝不打]
        A3[手里4万6万可放心打]
        A4[按他已听牌对待&amp;lt;br/&amp;gt;进入防守]
        A5[七万九万扣住观察]
    end
    O1 --&amp;gt; I1 --&amp;gt; A1
    O2 --&amp;gt; I2 --&amp;gt; A2
    O3 --&amp;gt; I3 --&amp;gt; A3
    O4 --&amp;gt; I4 --&amp;gt; A4
    O5 --&amp;gt; I5 --&amp;gt; A5&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;h3 id=&#34;43-战术层牌效这一巡到底打哪张&#34;&gt;4.3 战术层：牌效——这一巡到底打哪张&lt;/h3&gt;
&lt;p&gt;战术层是执行：在路线已定、信息已有的前提下，每张出牌都按同一套优先级评估。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;出牌优先级（无安全压力时）&lt;/strong&gt;：缺门牌（必打）→ 孤立端张（无邻张的 1、9）→ 孤立中张 → 多余搭子中进张最少的那张 → 拆搭子。&lt;strong&gt;核心原则是&amp;quot;每一巡都最大化有效进张总数&amp;quot;&lt;/strong&gt;：假设搭子 A（4-5 万）有 8 张潜在进张、搭子 B（1-2 筒）只有 3 张潜在进张，要拆就先拆 B——哪怕 B 看起来更&amp;quot;连得顺&amp;quot;。注意&amp;quot;潜在进张&amp;quot;还要减去已见张数，见 3 张的两面搭子不如没见过的卡张。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;碰/杠决策&lt;/strong&gt;是新手最容易犯错的地方，默认答案是&amp;quot;&lt;strong&gt;不碰&lt;/strong&gt;&amp;quot;，除非满足以下至少一条：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;碰了立刻听牌，或碰的是你的将牌方向（对子路线）；&lt;/li&gt;
&lt;li&gt;你在做清一色/大对子，碰能加速成型；&lt;/li&gt;
&lt;li&gt;这张牌不碰就被下家吃碰走、你再也等不到。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因为碰有隐性成本：&lt;strong&gt;暴露信息&lt;/strong&gt;（全桌知道你在收这门）、&lt;strong&gt;破坏门清弹性&lt;/strong&gt;（碰完之后手牌变窄，改路线更难）、&lt;strong&gt;改变摸牌顺序&lt;/strong&gt;（可能把本该你摸的关键张让给下家）。杠同理：明杠多摸一张且血战里&amp;quot;杠上花&amp;quot;有额外收益，但同样暴露信息，尾盘还可能给别人开&amp;quot;杠上炮&amp;quot;。&lt;strong&gt;起手做大时积极碰杠，快跑路线时能不碰就不碰。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;听口选择&lt;/strong&gt;遵循两个数字：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;听口宽度&lt;/strong&gt;：两面听（8 张潜在）&amp;gt; 卡张/边张（4 张）&amp;gt; 对倒/单钓（2~4 张）。能选宽不选窄；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;剩余张数&lt;/strong&gt;：减去已见的。听见过 2 张的两面（剩 4~6 张），通常不如听没见过的卡张（剩 4 张但还有别人扣牌的空间）。尾盘再加一条修正：&lt;strong&gt;听熟张&lt;/strong&gt;——别人敢打的牌，你才能吃到点炮；听生张只能指望自摸。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;把出牌优先级和碰杠决策各画成一张图，实战时按图索骥即可：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart TD
    A[这巡要出牌] --&amp;gt; B{手里有缺门牌?}
    B --&amp;gt;|有| C[打缺门牌]
    B --&amp;gt;|没有| D{有孤立端张?&amp;lt;br/&amp;gt;无邻张的1/9}
    D --&amp;gt;|有| E[打孤立端张]
    D --&amp;gt;|没有| F{有孤立中张?}
    F --&amp;gt;|有| G[打孤立中张]
    F --&amp;gt;|没有| H{搭子进张数比较}
    H --&amp;gt;|拆进张最少的搭子| I[打出该搭子中&amp;lt;br/&amp;gt;价值更低的一张]
    C &amp;amp; E &amp;amp; G &amp;amp; I --&amp;gt; J[打完前最后过一遍&amp;lt;br/&amp;gt;4.2.2 防点炮checklist]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart TD
    P[别人打出一张牌&amp;lt;br/&amp;gt;我可以碰/杠] --&amp;gt; Q{碰了立刻听牌&amp;lt;br/&amp;gt;或是对子路线?}
    Q --&amp;gt;|是| R[碰]
    Q --&amp;gt;|否| S{我在做大&amp;lt;br/&amp;gt;且碰能加速成型?}
    S --&amp;gt;|是| R
    S --&amp;gt;|否| T{不碰就永远&amp;lt;br/&amp;gt;等不到这张?}
    T --&amp;gt;|是| R
    T --&amp;gt;|否| U[不碰, 默认答案]
    U -.成本.-&amp;gt; V1[暴露信息: 全桌知道你收这门]
    U -.成本.-&amp;gt; V2[破坏门清弹性: 手牌变窄]
    U -.成本.-&amp;gt; V3[改变摸牌顺序: 可能放走关键张]
    style U fill:#d4edda
    style R fill:#fff3cd&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;h3 id=&#34;44-概率层期望值决策做大快跑防守的切换点&#34;&gt;4.4 概率层：期望值决策——做大、快跑、防守的切换点&lt;/h3&gt;
&lt;p&gt;这是整个框架的&amp;quot;调度器&amp;quot;，回答&amp;quot;什么时候换路线&amp;quot;。先把 4.3 的直觉量化成一个可心算的模型。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;基本概率感觉&lt;/strong&gt;：任一时刻，你某方向的&amp;quot;期望进展&amp;quot; ≈ 剩余进张数 × 剩余可摸次数。你手里 4-5 万等 3/6 万，若都少见，剩余进张约 6-8 张；牌墙剩 40 张、四家轮流摸，你大约还能摸 8-10 次，摸中概率相当可观。但如果 3/6 万已见 3 张，进张只剩 1~2 张，期望直接砍到四分之一以下——&lt;strong&gt;搭子的价值不看牌面，看剩余进张数&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;切换到&amp;quot;做大&amp;quot;的条件&lt;/strong&gt;（需同时满足）：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;起手结构支持：9 张以上同花色，或 4 对以上；&lt;/li&gt;
&lt;li&gt;进张供给充足：三家缺门不与你抢这门（4.1 的绿灯）；&lt;/li&gt;
&lt;li&gt;时间充足：牌墙过半之前。做牌是&amp;quot;用听牌速度换番数&amp;quot;，起手离目标越近、剩余巡数越多，换得越划算；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;血战规则加成&lt;/strong&gt;：一家胡牌不结束牌局，早胡者只是先锁定小收益，晚胡的大牌仍能兑现——大牌的&amp;quot;被截胡损失&amp;quot;比推倒胡小得多，所以四川麻将天然鼓励做牌。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;切换到&amp;quot;快跑/止损&amp;quot;的条件&lt;/strong&gt;（满足任一即执行）：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;进张枯竭&lt;/strong&gt;：关键牌期望进张 &amp;lt; 2，每一巡等待都是负期望——马上换听、拆死搭子；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;有人已听牌且你生张密集&lt;/strong&gt;：此时你的期望收益为负（点炮赔分 + 被查叫），最优解不是&amp;quot;我也赶紧听&amp;quot;，而是&lt;strong&gt;只打熟张把点炮概率压到最低&lt;/strong&gt;，保住查叫底线。这就是&amp;quot;不跟随到最后&amp;quot;——追不上就不再投入，及时止损；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;牌墙见底（剩 15 张以内）还没听牌&lt;/strong&gt;：做牌的时间价值归零，唯一目标是&lt;strong&gt;听牌防查叫&lt;/strong&gt;——往剩余张数最多的熟张方向听，只听一张也比&amp;quot;做了一半的大牌&amp;quot;值钱。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;一个简化的期望算账例子&lt;/strong&gt;（假设平胡 1 番 = 2 分，清一色 4 番 = 16 分）：你听牌了，可以选听口 A——平胡两面听，剩余进张 6 张；或改听口 B——清一色单钓，剩余进张 2 张。若剩余可摸次数约 6 次、点炮胡与自摸混合，A 的期望收益约是正 6~8 分；B 做成的概率约一半不到，做成了 16 分、做不成还要承担被查叫赔 2 分——期望约 6 分，且方差巨大。&lt;strong&gt;结论：番数翻倍抵不过进张减半，除非时间足够早或你已经落后必须搏。&lt;/strong&gt; 新手默认选宽听口，永远是长期正期望。&lt;/p&gt;
&lt;p&gt;把&amp;quot;什么时候切换路线&amp;quot;画成一张象限图——横轴是你的剩余进张，纵轴是牌局剩余时间，每巡定位一次自己落在哪个象限：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart TB
    UP[纵轴 ↑ 剩余时间多] --- TOP
    subgraph TOP[ ]
        direction LR
        LEFT2[进张少] ~~~ A[换听调整&amp;lt;br/&amp;gt;时间够但方向死了&amp;lt;br/&amp;gt;例: 卡七万已见三张]
        A ~~~ B[做大&amp;lt;br/&amp;gt;时间够且进张足&amp;lt;br/&amp;gt;例: 起手清一色九张]
        B ~~~ RIGHT2[进张多]
    end
    TOP --- BOT
    subgraph BOT[ ]
        direction LR
        LEFT[进张少] ~~~ C[防守保底&amp;lt;br/&amp;gt;先听牌防查叫&amp;lt;br/&amp;gt;例: 剩十二张没听牌]
        C ~~~ D[快跑&amp;lt;br/&amp;gt;进张还在抢时间&amp;lt;br/&amp;gt;例: 两面听六张剩半局]
        D ~~~ RIGHT[进张多]
    end
    BOT --- DOWN[纵轴 ↓ 剩余时间少]
    style A fill:#fff3cd
    style B fill:#ffe6cc
    style C fill:#f8d7da
    style D fill:#d4edda&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;两条边界记死：&lt;strong&gt;进张 &amp;lt; 2 立刻换方向（左半区行动），剩 15 张只保听牌（下半区行动）&lt;/strong&gt;。右下象限是血战到底最常见的好局面——进张还在但时间不多，别犹豫，最快胡法锁收益。&lt;/p&gt;
&lt;h3 id=&#34;45-血战到底的三条特有推论&#34;&gt;4.5 血战到底的三条特有推论&lt;/h3&gt;
&lt;p&gt;规则特殊，策略就有别处用不上的推论：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;先胡是&amp;quot;锁仓&amp;quot;，不是&amp;quot;胜利&amp;quot;&lt;/strong&gt;。你第一个平胡跑了，只是锁定 2 分；剩下三人继续打，大牌仍在你走后兑现。所以领先时&lt;strong&gt;快跑锁收益&lt;/strong&gt;，落后时反而要&lt;strong&gt;做大搏翻盘&lt;/strong&gt;——这和推倒胡&amp;quot;先胡为王&amp;quot;的直觉相反。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;查叫把尾盘变成了保底竞赛&lt;/strong&gt;。没胡的人要给听牌者赔分，所以尾盘的及格线是&amp;quot;听牌&amp;quot;而不是&amp;quot;胡牌&amp;quot;。最后 15 张，一切决策让位于&amp;quot;听上&amp;quot;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;点炮一人付，防守成本低&lt;/strong&gt;。别人自摸三家分摊、你点炮你独赔——你打熟张&amp;quot;放生&amp;quot;别人自摸，损失远小于你点炮。所以防守期打熟张不仅是避险，还是&lt;strong&gt;把损失转移给全桌分摊&lt;/strong&gt;的理性选择。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;三种结算方式的赔付流向对比，一看就懂为什么&amp;quot;防守打熟张&amp;quot;是理性选择：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart LR
    subgraph 点炮[你点炮: 你独赔]
        Y1[你 -8分] --&amp;gt; H1[胡牌者 +8分]
        O1[另两家 0分]
    end
    subgraph 自摸[对手自摸: 三家分摊]
        Y2[你 -3分] --&amp;gt; H2[胡牌者 +9分]
        O2[另两家各 -3分] --&amp;gt; H2
    end
    subgraph 查叫[流局查叫: 没听赔听牌]
        Y3[你没听 -2分] --&amp;gt; H3[听牌者 +2分]
    end
    style Y1 fill:#f8d7da
    style Y2 fill:#fff3cd
    style Y3 fill:#d4edda&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;同样是对手胡牌，你点炮亏 8 分、放生他自摸只亏 3 分——&lt;strong&gt;防守期每打一张熟张，赚的就是这个差价。&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id=&#34;46-新手常见错误清单&#34;&gt;4.6 新手常见错误清单&lt;/h3&gt;
&lt;p&gt;对照检查，改掉一条涨一截胜率：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;只记自己牌，不看池子&lt;/strong&gt;——搭子死活取决于关键张已见几张，不看池子等于闭眼打牌；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;见碰就碰&lt;/strong&gt;——暴露信息、锁死手牌结构，快跑路线尤其要忍住；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;死抱死搭子&lt;/strong&gt;——7 万已见 3 张还抱着 6、8 万不拆，每巡都在付租金；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;做大不看缺门&lt;/strong&gt;——三家都抢你这门还硬做清一色，供给断了；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;尾盘还想着翻盘&lt;/strong&gt;——剩 15 张没听牌，唯一 KPI 是听牌防查叫；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;不跟随到最后反着用&lt;/strong&gt;——该止损时硬追生张点炮，该搏的时候（落后、时间早）却提前保守。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;47-一页纸决策框架&#34;&gt;4.7 一页纸决策框架&lt;/h3&gt;
&lt;p&gt;把整章压缩成一张每巡都走的决策图：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart TD
    A[每一巡] --&amp;gt; B{信息更新:&amp;lt;br/&amp;gt;关键张已见数/&amp;lt;br/&amp;gt;三家缺门/谁听牌了}
    B --&amp;gt; C{阶段判断}
    C --&amp;gt;|序盘: 定缺+定路线| D{起手支持做大?&amp;lt;br/&amp;gt;9张+同花色/4对+&amp;lt;br/&amp;gt;且三家不抢这门}
    D --&amp;gt;|是| E[做大路线:&amp;lt;br/&amp;gt;积极碰杠, 屯关键张]
    D --&amp;gt;|否| F[快跑路线:&amp;lt;br/&amp;gt;牌效优先, 能不碰不碰]
    C --&amp;gt;|中盘| G{有人已听牌&amp;lt;br/&amp;gt;且我生张密集?}
    G --&amp;gt;|是| H[防守: 只打熟张&amp;lt;br/&amp;gt;放生自摸, 不点炮]
    G --&amp;gt;|否| I{我的关键进张&amp;lt;br/&amp;gt;剩余&amp;gt;=4?}
    I --&amp;gt;|是| J[继续推进&amp;lt;br/&amp;gt;选进张最多的打]
    I --&amp;gt;|否| K[拆死搭子&amp;lt;br/&amp;gt;换听熟张方向]
    C --&amp;gt;|尾盘: 剩15张内| L{已听牌?}
    L --&amp;gt;|否| M[唯一目标: 听牌防查叫&amp;lt;br/&amp;gt;听剩张最多的熟张]
    L --&amp;gt;|是| N[宽听口优先&amp;lt;br/&amp;gt;不为一番改窄]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;四个层级的关系：&lt;strong&gt;战略层定方向，信息层供弹药，战术层管执行，概率层负责喊停。&lt;/strong&gt; 新手先把战术层的&amp;quot;进张最大化&amp;quot;练成肌肉记忆，再逐步叠加记牌和读牌，最后学会用期望值喊停——这条学习路径本身也是按依赖关系排的。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;五贝密的技术亮点&#34;&gt;五、贝密的技术亮点&lt;/h2&gt;
&lt;p&gt;最后回到工程，挑几个我认为值得讲的点。&lt;/p&gt;
&lt;h3 id=&#34;51-用-byte-编码整副牌一副麻将只要几十个字节&#34;&gt;5.1 用 byte 编码整副牌：一副麻将只要几十个字节&lt;/h3&gt;
&lt;p&gt;贝密把每张麻将牌编码成一个 &lt;code&gt;byte&lt;/code&gt;：万/筒/条各占 0~107（每门 36 个编号 = 9 个面值 × 4 张），牌面值 = &lt;code&gt;(card % 36) / 4&lt;/code&gt;，花色 = &lt;code&gt;card / 36&lt;/code&gt;；风牌用负数。这样 108 张牌的牌墙就是一个 &lt;code&gt;byte[108]&lt;/code&gt;，序列化后不到 80 字节。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;举例&lt;/strong&gt;：编号 12 这张牌，&lt;code&gt;12 / 36 = 0&lt;/code&gt;（万子），&lt;code&gt;(12 % 36) / 4 = 3&lt;/code&gt;（第 4 个面值）——就是&amp;quot;四万&amp;quot;。一个除法一个取模就解出了花色和面值，不用查表、不用对象，广播出牌消息时网络上跑的就是 &lt;code&gt;&amp;quot;12,13&amp;quot;&lt;/code&gt; 这样的字符串。&lt;/p&gt;
&lt;h3 id=&#34;52-胡牌判断不用-3n2-递归用分堆--贪心摘顺子&#34;&gt;5.2 胡牌判断：不用 3N+2 递归，用&amp;quot;分堆 + 贪心摘顺子&amp;quot;&lt;/h3&gt;
&lt;p&gt;经典麻将胡牌算法是 3N+2 递归枚举。贝密的 &lt;code&gt;GameUtils.processMJCard&lt;/code&gt; 走了一条不同的路：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;把 14 张手牌按 &lt;code&gt;key = 面值 + 9 × 花色&lt;/code&gt; 放进字典计数，&lt;strong&gt;先查定缺&lt;/strong&gt;——缺门花色还在手里，直接不能胡（四川规则前置拦截）；&lt;/li&gt;
&lt;li&gt;按计数把牌分成三堆：&lt;code&gt;others&lt;/code&gt;（单张）、&lt;code&gt;pairs&lt;/code&gt;（对子）、&lt;code&gt;kezi&lt;/code&gt;（刻子）；&lt;/li&gt;
&lt;li&gt;对单张堆排序后&lt;strong&gt;贪心摘顺子&lt;/strong&gt;：同花色且 key 连续的 k/k+1/k+2 三张直接移除；&lt;/li&gt;
&lt;li&gt;终止条件：单张摘空且恰好剩 1 对将（或 7 对——七对牌型天然被&amp;quot;pairs 堆 = 14 张&amp;quot;覆盖）；&lt;/li&gt;
&lt;li&gt;对子多于 1 对时，把多余对子拆开当顺子候选再摘；全无刻子时枚举刻子拆开试。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;源码 &lt;code&gt;main()&lt;/code&gt; 里作者还放了性能测试：&lt;strong&gt;500 万次胡牌判断&lt;/strong&gt;直接计时。要说明的是，这是个简化实现——作者自己在注释里承认某些&amp;quot;三连对&amp;quot;式的特殊牌型没完整处理。对在线棋牌的实际负载来说，这种&amp;quot;足够快、覆盖主流牌型&amp;quot;的取舍是合理的。&lt;/p&gt;
&lt;h3 id=&#34;53-斗地主牌型识别频次直方图一把梭&#34;&gt;5.3 斗地主牌型识别：频次直方图一把梭&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;ActionTaskUtils.identification(byte[])&lt;/code&gt; 的做法很干净：先统计同点数张数的分布（几个单张、几个对子、几个三张、几个炸弹），再用 &lt;code&gt;switch(种类数, 最大点数)&lt;/code&gt; 一把识别全部牌型——单张、对子、三带一/二、顺子、连对、飞机带翅、四带二、炸弹、王炸。比较两手牌大小的 &lt;code&gt;allow()&lt;/code&gt; 则只有三条规则：王炸最大、炸弹管一切非炸弹、同牌型比最大点数。AI 托管出牌也复用这套识别，找&amp;quot;能压住上家的最小牌&amp;quot;。&lt;/p&gt;
&lt;h3 id=&#34;54-自研极简状态机统一驱动两种游戏&#34;&gt;5.4 自研极简状态机统一驱动两种游戏&lt;/h3&gt;
&lt;p&gt;斗地主和麻将的流程差异巨大（抢地主 vs 定缺、出牌规则完全不同），但贝密用一个抽象统一了：&lt;code&gt;BeiMiStateMachine&lt;/code&gt; + 按事件名查 transition 表 + Action 链。新增一种游戏 = 写一组 Action + 一份状态配置，调度、计时、广播的基础设施全部复用。&lt;/p&gt;
&lt;h3 id=&#34;55-把缓存过期回调当游戏计时器用&#34;&gt;5.5 把缓存过期回调当游戏计时器用&lt;/h3&gt;
&lt;p&gt;这是我觉得最&amp;quot;巧&amp;quot;的设计。棋牌游戏里到处是倒计时：出牌 8 秒、杠碰胡决策 5 秒、抢地主超时自动放弃、房间超时补机器人。贝密没用 Quartz、没开线程池，而是用一个 &lt;code&gt;sharpExpiry&lt;/code&gt; 的 &lt;strong&gt;cache2k 缓存&lt;/strong&gt;当计时器：key 是 roomid，value 是任务对象，&lt;strong&gt;到期自动触发过期监听器执行任务&lt;/strong&gt;。玩家主动操作了？&lt;code&gt;remove(roomid)&lt;/code&gt; 就把倒计时取消了；要重置？put 一个新任务进去。零额外线程，语义严丝合缝。&lt;/p&gt;
&lt;h3 id=&#34;56-实时状态与持久化彻底解耦&#34;&gt;5.6 实时状态与持久化彻底解耦&lt;/h3&gt;
&lt;p&gt;牌局进行中的所有状态（房间、Board、玩家手牌、撮合队列）全在 &lt;strong&gt;Hazelcast&lt;/strong&gt; 分布式内存里，MySQL 只在结算时被&lt;strong&gt;异步&lt;/strong&gt;写入——通过 &lt;strong&gt;Disruptor&lt;/strong&gt; 的 RingBuffer 把变更事件丢给消费者，JPA 和 Elasticsearch 双写（ES 用于运营后台的战绩检索）。注释里还有一条重要的工程纪律：&amp;ldquo;只有一个地方对 GameRoom 二次写入，避免分布式锁&amp;rdquo;——用设计约束换掉了锁的开销。另外，房卡模式建房时的局数等规则赋值用了 &lt;strong&gt;Drools&lt;/strong&gt; 规则引擎，规则写在 &lt;code&gt;.drl&lt;/code&gt; 文件里，运营改规则不用改代码。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;六总结与反思&#34;&gt;六、总结与反思&lt;/h2&gt;
&lt;p&gt;回顾这个项目，对新手最有价值的三个认知是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;联网游戏 = 服务器权威 + 按人过滤的状态分发&lt;/strong&gt;。理解了&amp;quot;客户端只是显示器和遥控器&amp;quot;，就看懂了所有联网游戏的骨架。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;棋牌游戏的复杂度不在算法，在工程&lt;/strong&gt;。胡牌判断几百行就写完了，真正难的是断线重连、超时托管、并发房间这些&amp;quot;无聊&amp;quot;的部分——它们决定了玩家半夜打牌掉线时骂不骂人。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;做游戏的人必须会玩游戏&lt;/strong&gt;。第四章的四川麻将策略——分层记牌、由&amp;quot;打出&amp;quot;反推&amp;quot;留下&amp;quot;、用剩余进张 × 剩余巡数做期望决策——不只是给玩家的攻略，也是当初我们校验 AI 托管和出牌提示是否合理的标尺。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;当然也有粗糙之处，如实记录：README 宣称的德州扑克在 0.8.0 里只有枚举占位并未实现；胡牌算法是简化版；源码里散落的 &lt;code&gt;System.out.println&lt;/code&gt; 和作者自注的 bug 注释（比如多人同时可碰时先碰者胜出的竞态问题）都还在。但作为一个学习联网游戏开发的开源样本，它把一个房卡棋牌系统该有的零件——长连接、房间状态机、计时器、托管、异步落盘——都真实地摆在了那里，这比完美更有价值。&lt;/p&gt;
</description>
    </item>
    
  </channel>
</rss>
