普通视图

发现新文章,点击刷新页面。
昨天 — 2026年1月14日首页

2025年终总结-都在喊前端已死,这一年我的焦虑、挣扎与重组:AI 时代如何摆正自己的位置

2026年1月14日 19:32

前言

今年这一年,我整个人一直处于一种紧绷的焦虑状态。

这种焦虑来自于一种真切的危机感:作为前端,我发现自己曾经引以为傲的“技术壁垒”在 AI 面前像纸一样薄。但最近,我突然想通了。当我意识到 AI 只是工具,全栈才是唯一出路,知识广度才是护城河 的时候,我的焦虑缓解了。

这一年我写了很多文章、折腾了开源项目、学了新语言,原本以为是在浪费时间“乱撞”,现在回头看,这恰恰是我加速转型的护城河。

今天想把这一年的思考总结出来,分享一下。


一、 认清时代的洪流:人是阻挡不了趋势的

很多人私下里对 AI 处于一种**“间歇性积极,持续性排斥”**的状态。这种心态通常分为三个阶段:

  1. 轻视/嘲讽: 找 AI 的逻辑错误,通过嘲讽来获得心理安慰。
  2. 焦虑/抗拒: 意识到它很强,但通过拒绝接触来延缓危机,觉得用了它就“输了”。
  3. 妥协/共生: 既然无法打败,就把它当成身体的一部分。

我们要清醒一点:人是阻挡不了时代洪流的。 现在的业务逻辑,你手动写 100 遍也不会有任何提升。把时间浪费在无意义的重复劳动上,不仅是在消耗生命,更是对职业生涯的自杀。


二、 核心思维:从“单一职业”向“学科重组”切换

这是我今年最大的认知提升:作为“岗位”的前端和后端会逐渐消亡,但作为“学科”的前端和后端会发生重组。

1. 摆脱单一职业的束缚

不要把自己锁死在“前端”这个标签里。如果你只盯着那几个 API 和框架,你的路会越走越窄。AI 时代,我们需要的是知识广度

2. 思维改变是第一步,但要有“作品”支撑

不要只是空想,要去实施一套 “AI 驱动的开发流”

  • 你可以不写代码,但你必须能瞬间看出 AI 写的代码哪里有坑。
  • 你的价值不在于“使用 AI 编码”,而在于你拥有全局思考的能力,能定义边界,能把控风险。

三、 实操方法:如何把 AI 练成你的“外骨骼”?

1. 建立场景化的 Prompt 资产库

不要把 AI 当成简单的搜索引擎。你需要针对不同的场景(如:复杂表单逻辑、组件边界设计、性能优化)建立专用的 Prompt。

心得: 明确设计组件的边界,给 AI 定好规矩,它产出的代码才不会跑偏。

2. 在掌控范围内使用

不要把代码完全交给 AI 后就撒手不管。要把 AI 限制在你能把控的范围内,防止代码库由于不受控而崩坏。这种“把控力”就是 senior 和 junior 的分水岭。

3. 数据对比:感知效率的代差

我曾做过对比,传统的 Spec 确认到开发完成,和现在的 Spec Coding(基于规格说明书编程) 模式相比,效率是量级的差别。这种效率红利,就是你转型的动力。


四、 关于全栈:唯一的路,也是最好的练习场

很多人问:我想切全栈,但后端语法、环境配置乱七八糟,怎么学?

  1. 拿公司项目练手: 不要怕环境乱。环境配置、部署链路这些繁琐的事,恰恰是 AI 最擅长的。让 AI 带你跑通公司的后端流程,这是成本最低的练习方式。
  2. 快速切换语法: 不要死背语法书。利用 AI 快速对比新旧语言的异同(比如:TS 的 Interface 在 Go 里怎么实现?)。
  3. 时间规划: 抛弃那种“等我学完再做”的想法。直接带着任务去问 AI,在实战中扩充技术栈。

五、 总结:护城河从未消失,只是换了地方

这一年,我虽然焦虑,但并没闲着。我发现:

  • 焦虑时写的文章,打磨了我的逻辑表达
  • 焦虑时折腾的开源项目,扩充了我的技术底座
  • 焦虑时学的后端语言,提升了我的系统观

这些看似乱撞的经历,最终都转化成了我快速转型的技术能力

当开发模式从传统编程演变成 Spec Coding 时,你的认知、你的经验、你对复杂业务的洞察,依然是 AI 无法拿走的护城河。

不要在没有意义的事情上浪费时间。顺应时代,保持思考。如果这个时代注定要重组,那我们要做的,就是成为那个亲手重组自己的人。

如何一次性生成 60 种语气表达?RWKV 模型告诉你答案 ❗❗❗

作者 Moment
2026年1月14日 15:00

在日常沟通中,我们经常需要根据不同的对象和场景调整语气。向老板汇报工作时需要正式严谨,和同事交流时可以轻松随意,写文案时又需要符合品牌调性。手动调整这些语气不仅耗时,还容易词穷。特别是在需要快速产出多种风格文案的场景下,比如社交媒体运营需要同时准备正式版、幽默版、情感版等多个版本,传统的逐个改写方式效率极低。

基于这样的痛点,我开发了这个 RWKV 并行语气转换工具。它能够接收一段文本,通过 RWKV 大语言模型,一次性并行生成 60 多种不同语气和风格的表达方式,涵盖职场、生活、方言、文学、网络等多个维度,极大提升了内容创作和沟通表达的效率。

项目效果图

从上图可以看到,工具的界面简洁直观。用户只需在底部输入框中输入原始文本,点击发送按钮,系统就会同时生成多种语气版本。每个卡片代表一种风格,包含风格图标、名称和转换后的内容。所有结果实时流式返回,用户可以立即看到生成进度,并且每个结果都支持一键复制,方便快速使用。

核心特性与技术实现

这个项目最大的特点是并行生成能力。传统的语气转换工具通常是串行处理,即逐个风格依次生成,这样会导致等待时间过长。而本工具通过在后端同时处理多个转换请求,前端采用流式渲染技术,实时展示每个风格的生成进度,整体响应速度大幅提升。

在前端技术选型上,项目采用了 React 19 作为 UI 框架,配合 Rsbuild 作为构建工具。相比传统的 WebpackViteRsbuild 提供了更快的构建速度和更简洁的配置体验。样式层面使用了 Tailwind CSS 4,通过精心设计的渐变色彩和流畅的动画效果,打造出现代化的视觉体验。整个界面采用浅色主题,柔和的紫粉渐变背景配合玻璃态效果,既美观又不影响内容的阅读。

项目完整的技术栈包括:React 19 提供强大的 UI 渲染能力,TypeScript 确保类型安全,Rsbuild 负责快速构建,Tailwind CSS 4 处理样式,Lucide React 提供图标支持,Class Variance Authority 管理组件变体。这套组合既保证了开发效率,也确保了运行时性能。

接口请求参数

从接口请求参数可以看到,后端接收的核心数据结构相对简单。contents 字段是一个数组,包含了所有需要转换的 prompt 内容。每个 prompt 都是一个完整的指令,包含了风格要求和用户输入的原始文本。系统会根据这些 prompt 并行调用 RWKV 模型进行生成,同时还支持多种参数调优,比如 temperaturetop_ktop_p 等,以获得更好的生成效果。

接口响应数据

响应数据采用了 Server-Sent EventsSSE)的流式传输方式。每个数据块都是一个 JSON 对象,包含了 choices 数组,其中每个 choice 对应一个风格的生成结果。通过 index 字段标识具体是哪个风格,delta 中的 content 字段则包含了本次推送的文本片段。前端接收到这些数据后,会实时更新对应卡片的内容,用户可以看到文字逐字生成的效果,体验非常流畅。

并发生成的核心实现

整个项目的精髓在于如何实现真正的并发生成。先看生成 contents 数组的逻辑:

function generateStyleContents(userInput: string): string[] {
  const configs = getMergedStyleConfigs();
  return configs.map((config) => {
    if (config.prompt.includes("${{input}}")) {
      return config.prompt.replace(/\$\{\{input\}\}/g, userInput);
    }
    return `${config.prompt}\n\nUser: ${userInput}\n\nAssistant: <think>\n</think>`;
  });
}

这个函数做的事情很简单:遍历所有风格配置,将每个风格的 prompt 模板中的 ${{input}} 占位符替换为用户的真实输入。generateStyleContents 函数会调用 getMergedStyleConfigs() 获取所有风格配置。假设用户输入"明天要开会",经过这个函数处理后,会得到一个包含 60 个完整 prompt 的数组。每个 prompt 都是独立的,包含了该风格的要求描述、约束条件,以及用户输入。

有了这个数组,接下来就是发送请求了。关键在于,我们把整个 contents 数组一次性发送给后端:

const response = await fetch(config.apiUrl, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Accept: "*/*",
    "Accept-Language": "zh-CN,zh;q=0.9",
  },
  body: JSON.stringify({
    contents, // 这里是 60 个 prompt 的数组
    max_tokens: 100,
    temperature: 0.95,
    top_k: 50,
    top_p: 0.9,
    pad_zero: true,
    alpha_presence: 1.0,
    alpha_frequency: 1.0,
    alpha_decay: 0.996,
    chunk_size: 128,
    stream: true,
    password: config.password,
  }),
  signal,
});

注意看请求体中的 contents 字段,这就是我们刚才通过 generateStyleContents 函数生成的 60 个 prompt。后端收到这个数组后,会同时启动 60 个生成任务,每个任务对应数组中的一个 prompt。数组的索引位置(0, 1, 2, ..., 59)就是每个任务的 ID,这个 ID 会在返回的 index 字段中体现。

流式响应的解析机制

后端采用 Server-Sent EventsSSE)格式返回流式数据。每个数据块的格式大致是这样的:

data: {"object":"chat.completion.chunk","choices":[{"index":12,"delta":{"content":"明"}},{"index":23,"delta":{"content":"今"}},{"index":5,"delta":{"content":"后"}}]}

data: {"object":"chat.completion.chunk","choices":[{"index":12,"delta":{"content":"天"}},{"index":23,"delta":{"content":"天"}}]}

data: [DONE]

看到了吗?每个 choice 对象都有一个 index 字段。这个 index 就是对应 contents 数组中的位置。比如 index 为 12 的 choice,对应的就是 contents[12] 这个 prompt 的生成结果。前端正是靠这个 index,知道把返回的文本片段更新到哪个风格卡片上。

解析流式数据的代码使用了 fetch API 的流式读取能力:

const reader = response.body?.getReader();
const decoder = new TextDecoder();
let buffer = "";

while (true) {
  const { done, value } = await reader.read();

  if (done) break;

  buffer += decoder.decode(value, { stream: true });
  const lines = buffer.split("\n");
  buffer = lines.pop() || "";

  for (const line of lines) {
    const trimmedLine = line.trim();
    if (!trimmedLine || !trimmedLine.startsWith("data: ")) continue;

    const data = trimmedLine.slice(6);
    if (data === "[DONE]") {
      // 所有任务完成
      const completedResults = initialResults.map((result) => ({
        ...result,
        isComplete: true,
      }));
      onUpdate(completedResults);
      continue;
    }

    try {
      const json = JSON.parse(data);
      if (json.choices && Array.isArray(json.choices)) {
        json.choices.forEach((choice: any) => {
          const index = choice.index;
          const deltaContent = choice.delta?.content || "";
          if (deltaContent && initialResults[index]) {
            // 根据 index 找到对应的结果对象,追加文本片段
            initialResults[index].content += deltaContent;
          }
        });
        onUpdate([...initialResults]);
      }
    } catch (e) {
      console.warn("解析 JSON 失败:", e);
    }
  }
}

这段代码的核心逻辑是:

  1. 使用 TextDecoder 逐块解码二进制流,通过 response.body?.getReader() 获取流读取器
  2. 按行分割数据,因为每行是一个完整的 SSE 消息
  3. 提取 "data: " 后面的 JSON 数据
  4. 解析出 choices 数组,遍历每个 choice
  5. 通过 choice.index 找到对应的结果对象,将 choice.delta.content 追加上去
  6. 调用 onUpdate 触发界面更新

这种增量更新的方式非常高效。不同风格的生成速度可能不一样,有的快有的慢,但每个风格的更新是完全独立的,互不干扰。用户可以实时看到每个卡片的内容逐字增加,体验非常流畅。

为什么这种方式能实现真并发

传统的做法是循环调用 API,每次生成一种风格,等这个风格生成完了再生成下一个。如果有 60 种风格,每个风格平均生成 2 秒,那总共需要 120 秒。这种串行的方式效率极低。

而我们这种方式,是把 60 个 prompt 打包成一个数组,一次性发送给后端。后端收到后,会并发地处理这 60 个任务。虽然每个任务还是需要 2 秒,但因为是并发执行,所以总耗时只有 2 秒多一点(加上一些网络延迟和任务调度开销)。

关键点在于:

  • contents 数组的长度决定了并发数量
  • 后端通过 index 标识每个任务的结果
  • 前端通过 index 将结果精确地更新到对应位置

这样就实现了真正的并行生成,效率提升了几十倍。

部署和使用

项目的部署很简单。如果你熟悉 Node.js,直接 npm install 安装依赖,npm run dev 启动开发服务器就能用。构建生产版本也就是一个 npm run build 的事。

如果你更喜欢用 Docker,项目也提供了完整的 Docker 支持。docker compose up --build -d 一条命令搞定,不用操心环境配置的问题。

API 配置

API 配置就两个参数:服务地址和密码。项目根目录下有个 .env 文件,里面写好了默认值。如果你有自己的 RWKV 后端服务,改一下这个文件就行,改完重启一下开发服务器。

PUBLIC_RWKV_API_URL=http://192.168.0.12:8000/v1/chat/completions
PUBLIC_RWKV_PASSWORD=rwkv7_7.2b_webgen

就这么简单。

60 种风格是怎么设计出来的

60 种风格不是拍脑袋想出来的,而是根据实际使用场景一点点积累起来的。最开始只有十几种,后来发现不够用,就不断补充。

职场类是最早做的一批。面向老板、面向客户、面向同事,这三个场景的语气差异非常大。跟老板汇报工作,得用"敬请指示"、"恭候佳音"这种正式表达。跟客户沟通,得强调"为您服务"、"满足您的需求"。跟同事交流,就可以"咱们商量一下"、"一起搞定"。

文学类是后来加的。有用户反馈说想要古风文案,于是就做了红楼梦、三国演义、水浒传这些经典名著的风格。还有诗词歌赋、文言文这些。效果还不错,生成出来的内容确实有那个味道。

方言类比较有意思。东北话、四川话、广东话、上海话,每种方言都有自己的特色词汇。东北话喜欢说"咋整"、"贼拉",四川话爱用"哦豁"、"巴适",广东话常说"饮茶"、"搞掂"。这些方言风格在做地方性推广时特别有用,能快速拉近和用户的距离。

网络用语风格是必须有的。现在的年轻人说话都是"yyds"、"绝绝子"、"EMO 了"这些梗。如果做社交媒体运营,不用这些网络语言,内容就会显得很生硬。所以专门做了几个网络用语风格,紧跟最新的流行趋势。

除了这些大类,还有一些更细分的场景风格。比如道歉、感谢、邀请、拒绝、催促等。这些在日常沟通中经常用到,但很多人不知道怎么表达得既礼貌又不失分寸。有了这些风格,直接套用就行。

实时看到生成进度

因为是流式响应,所以你可以实时看到每个风格的生成进度。不同风格的生成速度可能不一样,有的快有的慢,但每个都是独立更新的,互不影响。

这种体验比传统的"转圈等待"好太多了。你能看到文字一个个蹦出来,知道 AI 确实在工作,而不是卡住了。而且因为是并发的,所以很多风格会同时在生成,界面上到处都在更新内容,看起来特别有动感。

每个卡片右上角有个复制按钮,点一下就复制到剪贴板了。如果对结果不满意,底部有个"重新生成"按钮,会用同样的输入再跑一遍。

后端 API 要求

后端 API 需要支持以下特性:

  1. 接收一个 contents 数组,数组里有多少个 prompt 就要并发处理多少个任务
  2. 返回 SSE 格式的流式数据,每个 choice 必须包含 index 字段用于标识对应的任务
  3. 所有任务完成后发送 "data: [DONE]" 标记

推荐使用 RWKV Lightning 作为后端服务(github.com/RWKV-Vibe/r…

写在最后

这个工具的核心价值就是一个字:快。

传统方式要生成 60 种风格,得等 2 分钟。现在并发生成,只要 2 秒钟。效率提升了 60 倍,这才是真正有用的工具。

当然,60 种风格只是开始。随着使用场景的增加,肯定还会有更多风格加进来。好在添加新风格很简单,改几行配置就行。

如果你有什么想法或建议,欢迎提 IssuePR。这个工具会持续优化,让更多人受益。

项目地址:rwkv-parallel-tone

后端服务:RWKV Lightning

2025 年终总结 - Agent 元年

作者 EricLee
2026年1月14日 01:12

前言 - 变革的到来

回想 2024 年的时候,公司内部大多人建设思路,还停留在传统产品开发设计上。其实在 2023 年 Q2 的时候,我就开始涉及 LLM 相关的功能开发,但那个时候并没有想象到,AI 能在 2023 年之后的 2 年间,发展到现在这种全民级 Agent 级应用的情况,这场变革对互联网局内人实属是一场革命了,对传统编程与产品模式都造成巨大冲击。

如何跟上 AI 这趟车

互联网从业者

现在 AI 普及之后,对于程序员等的职能要重新定义了。从前对于国内互联网环境,我们会把岗位拆的很细(为了提升开发效率)。现在即便是自己不擅长的领域,有了 AI 能力之后,也多少能够进行一些不擅长技术栈的开发,也能学习前端、后端、数开等其他领域知识

未来的软件开发工程师,会更加注重对 AI 工具的运用,一个是对于未知知识的学习能力,另一个是运用 AI 工具开发不熟悉领域的能力。随着 AI 模型的进步,生成时长与采纳率提升,在 DevOps 场景下,一站式自动完成 PRD、前后端、数据运营等能力。

产品功能

从前我们的产品更关注于用户体验,如某些成熟的产品,很多产品由于基本功能迭代完毕,都在做用户体验优化(如 UI 交互升级等)。

在新的 AI 时代,更关注数据的获取速度,提炼的是否足够精准。用户希望产品作为信息入口,能直接把需要的最终结果,加工吐给用户,加快信息的流转速度。

AI 提效的领域

需要思考的是,AI 需要什么?

我们在平常做 prompt 开发的时候,会注入很多 context,那么核心还是在 context 上,以此为视角,尝试去找到需要的领域。

上下文是什么,本质还是数据,所以只要自身所在的业务,掌握数据,就有 Agent 应用的方向。

产品领域

举个例子,抖音 App,一般我们只在平常去刷抖音。但尝试去思考其中的信息,每个 UGC 创作者的视频内,有大量的有效信息。那我们是不是就可以收集视频中的文案,尝试让 AI 汇总一些关键内容,在搜索场景下做 RAG。对于这点当前抖音、小红书等 App 已经在金刚位放置了 AI 总结能力,对于我日常检索来说,也会经常用到。

在这个激烈竞争的移动互联网时代,作为用户我是更关注在更短的时间完成所需信息的收集,目前看这类能力还是有很大空间的,各个 App 接下来的 1 年还会持续不断的把 AI 融合到各产品中。

技术领域

  • 可以做代码生成
    • 基于低代码 DSL 的生成
    • hive、sql 语句的生成,这里要解决的问题是,转移范式,用户只需要描述场景,屏蔽相关具体库表的细节
    • Proocde 生成
  • 问题处理:自动归因,智能修复
  • 上下文提炼:智能对话助手、Oncall 排查助手

创业方向寻找

全球目前经过 2 年的厮杀之后,AI 这场游戏,上桌的国家目前基本只剩下中美两国。相比于美国来说,国内互联网公司对于 Agent 应用竞争力更强。实际表现于产品的表达升级,很多 Agent 能力快速融入了各类亿级 DAU App 中,对于非大厂的创业者来说,创业简直就是炼狱难度了。

如果有创业的念头,建议还是考虑国外人均消费高的发展中国家,如阿根廷、巴西等南美国家,或亚洲日本、泰国等国家。

我们可以做的方向有以下等:

  • 数据总结:帮助用户更快的理解数据,加工数据,如果做的好的话,用户会对这个效果买单
  • Agent 应用:如 AI 客服等系统,这种对 b 端用户来说,可以大幅释放人力,只要把精准率做上去,竞争力就很强,这种的典型是 Manus 这类产品了,当然年末也刚刚被 Meta 收购了,在应用这块是中美差距的重点,也是 Meta 和 Google 等大厂之间的核心差距

关于自己 - 旅行记录

今年一共去了 9 个国家 2 个地区,遍及欧洲非洲东南亚,简单记录一下自己的感受吧

非洲

🇪🇬 埃及

埃及对于一个人自由行还是很友好的,一路预约不同司机的包车,飞驰在戈壁的高速上,真的就很有自由的感觉。有黄沙漫天,还有单手打电话飙车的司机师傅,还有看到亚洲面孔就冲上来拍合照的埃及本地人,这就是埃及,他拥有独特的特色还有历史底蕴,值得去探索。

  • 景点管理:政府对景区还是投入了资源的,比如统一了门票管理,另外在景区周围可以看到有很多持枪军警管理秩序。不过,比如埃博里的展品,还有卢克索这些地方的,很多都没有做好防护,还是有文物损毁风险的
  • 价钱低廉:打车尤其是包车真的很便宜,indrive 的杀价简直疯狂,尤其是当时 app 的昵称叫穆罕默德,很多司机就按当地人价格来开价,吃饭还有住宿的价钱也是非常便宜了,红海的全包式住宿基本在东南亚都很少有,还有 90 块钱的出海包餐一日游,极为划算
  • 秩序混乱:进入民众的日常生活,还是有一丝丝混乱的,比如开罗闯红绿灯的汽车,还有单手打电话的司机师傅,还有一路上要 1 dollar 的老人和小孩,也是经济问题导致的。
  • 国家全景:一路从阿斯旺往北到开罗,可以感受到越往南越穷,越干旱缺水,往北才可以看到一些高楼与绿洲

🇰🇪 肯尼亚:

  • 整体体验:全程是联系的非洲地接,走的全包 safari 团,2300 刀,所以其实并没有深入当地民众的生活。各式各样的国家公园,对于初次看到真实的动物世界的我来说的确很震撼。从干燥扬沙的安博塞利一路经过纳瓦沙、纳库鲁到马赛马拉,见证乞力马扎罗下的象群,纳瓦沙的新月岛,马赛马拉的狮王。还享受了 2 次在马赛马拉露营午餐,下面的这次的行程

暂时无法在飞书文档外展示此内容

  • 酒店:全程住的 5 星酒店,比预期中的好太多了,每天都有自助餐,可以在酒店里看河马、斑马、鸵鸟,在酒店就可以看到马赛马拉草原的动物,即便是帐篷酒店,也是热水、洗澡这些应有尽有,在帐篷外就可以仰望星空,但不得不提的是缺点是全程 wifi 信号很差,基本上没有。可能也是为了让旅客尽可能放松,享受 safari 的旅程吧
  • 交通:从内罗毕去马赛马拉的路上,大货车很多,会压着吉普的速度,司机会不得不超车,其实是很危险,很多人会因为躲对向大货车而翻车,所以东非这些国家,如果考虑安全性的话,还是少去,一次性尽量的都玩到
  • 路途:司机会带着在公园内寻找动物,本地人之间说话会用肯尼亚语,但基本上每个人都会说英语。去安博塞利的路非常难走

欧洲

🇦🇹 奥地利

  • 整体印象:相比与维也纳,湖区是很让人流连忘返,可以算是瑞士平替。如果想远离尘嚣,就躺平在巴德伊舍这种地方,一条河把小镇隔开,每周都会开一次市集,也是最热闹的时候
  • 交通:
    • 公交车:宁静的湖区中转站巴德伊舍,可以坐公交车,去任何附近的地方,比如圣沃尔夫冈、哈尔施塔特、戈绍等,公交车发车就在火车站旁边,很方便。
    • 铁路:奥地利的 OBB 火车系统也很发达,软件上清晰的展示去任何地方方式,但想吐槽的是换乘时间真的是太短了,基本都在 3 - 8 分钟之间,如果上一趟有延误,对赶下一趟车就很赶了

🇨🇿 捷克

  • 捷克只去了 CK 小镇,从奥地利湖区巴德伊舍出发,坐奥铁到 Linz,林茨市内有公交车能到 Flixbus 的上车点,再坐 flixbus 可以直接到 CK 小镇,整体单程耗费大约 4 小时时间,当天出发的时候天气还挺阴的,但到 CK 小镇之后,放晴了!一切都是值得的,flixbus 会提供往返大巴,大概能在 ck 小镇玩 3 个小时
  • 到小镇城堡的顶部,可以一眼望到远处的高山草甸,与 ck 小镇整体融合成绝美的画卷。再顺着城堡往上走,还有一个小花园,很不错

🇩🇪 德国

  • 印象:总的来说相比于西班牙人、意大利人,德国人还是比较的高冷,整个国家有点像北京的感觉,不知是不是 10 月的原因,整体在德国期间的天气不是特别好,一直是比较阴天的状态,不过也和慕尼黑整体的色调有点搭。
  • 在慕尼黑住 2 天,其中一天坐 Flixbus 去新天鹅堡,这也是此行的主要目的,大巴会把大家拉到城堡底下的小镇,接下来顺着人流一路爬升,可以到新天鹅堡的大门,可惜没有提前买票,也就没有入内了,城堡主要是从外部看比较震撼。
  • 那如何才能抵达下图拍照的地点呢,其实是在玛利亚桥,但这个桥其实只是一个晃晃悠悠的小桥,真的站上去的时候还挺让人紧张的,旁边也会有牌子显示当前的人数,是会做限流的,站在桥上往下俯瞰,德国乡村的平原尽收眼底,虽然天气不是很好,但也能想象出太阳出来时候下面绿油油的感觉了,德皇修在这里的选址还是很不错的

🇫🇷 法国

  • 已经是第二次去法国了,这次主要是去圣米歇尔山,山上的修道院很有中世纪的感觉,但山下的小羊才是旅途中的惊喜。唯一缺点是从巴黎往返要 8 个小时,待不了太久,有点小遗憾。
  • 牧场进来的人不多,在吊桥出来之后,有一扇小门,只需要把门栓打开就可以进去(记得随手关上),如果是小羊肖恩爱好者强烈建议在这里多停留一些时间,非常治愈。

东南亚

🇲🇻 马尔代夫

目前去过的最顶级的海岛,从迪拜转机,飞的时候坐在左侧,视角会很好,可以欣赏到美丽的岛景 下午 4 点落地,出了机场之后就是码头,有很多船开往不同的居民岛,这次去的是马富士,快艇开了将近 1 个小时到达,码头旁边有很多家出海项目的店,可以砍价5🔪左右一般,其中 kanni icom arena 家比较火,如果不想要太多人,可以考虑 ocean 等小店

  • day1 行程:shark bay 早上 8 点 30,下午3点返回,晚上 5 点开始夜钓,钓了 5 条,包晚饭

    • shark 55 刀,包 video
    • 夜钓 25 刀,大概有 2 个小时左右时间调
  • day2 行程:sun siyam 双鱼岛一日游,115刀 salt beach hotel 家,包午饭,早上 8 点集合,下午 5 点返程

    岛由几个小个岛,旧岛和梦岛,新岛构成,套餐不包含新岛,岛上的摆渡车可以任意乘坐,可以参观水屋的栈道,有 3 个浮潜点可以浮潜。但岛上的项目会比较贵,200 多刀起步

  • day3 行程:Ocean vista hotel 浮潜花园 25刀

    第一个潜点看不到鱼 第二个潜点不错,在某个度假岛旁边,可以看到一些鱼,但水下的能见度不太好 第三个点拉到无人沙洲,和 shark bay 不一样的是,不会直接停靠在沙洲旁,需要蹚水到沙洲上,会比较扎脚,但风景绝美,值得

    马富士参加了 3 天出海项目,分别是护士鲨+无人沙洲,在双鱼岛

出海项目是在马富士玩的,kanni 主要是出片占的时间会比较多,如果对出片不感兴趣,可以报别人家的

🇸🇬 新加坡

  • 新加坡的体验从出了机场的地铁开始,从地铁上明确的罚款提示,可以看出社会秩序的约束,地铁是在高架上的,所以一路上可以看到两侧各种彩色的房子,南洋的感觉扑面而来。
  • 华人主导的国家就是基本说中文就可以完成日常生活的沟通,另外每个人的英文说的都非常溜。

🇲🇾 马来西亚

  • 飞到吉隆坡的时候是晚上了,从上空俯瞰夜景其实就很震惊了,灯光看起来有点像东京那种特大城市的感觉。airbnb 上伊顿公寓的民宿很多,很多都可以在顶层的无边泳池在夜晚还有白天看双子塔。
  • 10 月的吉隆坡还是很闷热,只要不在购物中心里就一直在流汗,对北方人这气候简直是受不了
  • 吉隆坡整体建设还是很繁华的,中国文化入侵很严重,地铁里打中国手游的一大片,支付宝也是很多商户都有接入

中国

🇭🇰 香港

  • 和日本一样的窄街道,一切都是那么原汁原味,饭其实没有网上说的贵,一倍的价钱但能吃到饱,有机会再去一趟香港的海岛,因为城市实在是太臃肿了,大城市的吸引力没有那么大

🇲🇴 澳门

  • 住在官也街,楼下的小街道很有葡式特色,坐着轻轨从氹仔岛去老城,衣湾斜巷,相比香港更喜欢澳门多一些,原因还是人少,节奏慢,生活的气息浓厚

今年解锁了很多新的国家,看到了很多新的风景,看到许多历史课上老师曾经讲到的实实在在发生过的痕迹,也享受了 Safari 过程的洒脱,再次见证了阿尔卑斯山另一侧的绝美湖区风景。

希望自己一直能这么自由,享受走在路上的过程,感谢这美好的 27 岁,感受到世界每一个角落的发生的一切。

昨天以前首页

这两个网站,一个可以当时间胶囊,一个充满了赛博菩萨。

作者 why技术
2026年1月12日 20:27

你好呀,我是歪歪。

前两天不是发了这篇《可怕,看到一个如此冷血的算法。》嘛。

文章中有这样的一个链接:

我当时放这个链接的目的是为了方便大家直达吃瓜现场。

但是,由于这个帖子最终被证实是假的,所以被官方给“夹”了:

幸好,原文本来就不长,所以我在我的文章中把原文全部给截下来了。

也算是以另外一种形式保留了吃瓜现场。

如果这个“爆料”的帖子再长一点,按照我的习惯,我可能就不会把整个帖子搬运过来了,只会留取我认为关键的部分。

但是这种“我认为关键的部分”是非常主观的,有的人就是想看原贴长什么样,但是原贴又被删除了,怎么办?

我教你一招,老好用了。

时间胶囊

在万能的互联网上,有这样一个仿佛是时间胶囊一般存在的神奇的网站:

archive.org/

这个网站是叫做"互联网档案馆"(Internet Archive),于 1996 年成立的非营利组织维护的网站。

自 1996 年以来,互联网档案库与世界各地的图书馆和合作伙伴合作,建立了一个人类在线历史的共享数字图书馆。

这个网站有一个非常宏大的愿景:

捕捉大小不一的网站,从突发新闻到被遗忘的个人页面,使它们能够为子孙后代保持可访问性。

所以里面收藏了的内容有免费书籍、电影、软件、音乐、网站等。

截至目前,该网站收集了这么多的数据:

其中网站的数量是最多的,有 1T,超过 1T 的时候,官方还发文庆祝了一下:

这个 1T 中的 T 指的是什么呢?

Trillion。

一个非常小众的词汇啊,歪师傅也不认识,所以我去查了一下:

这个图片上一眼望去全是 0。

1 Trillion 就是 1,000,000,000,000

反正是数不过来了。

感觉成都都没有这么多 0。

这个网站怎么用呢?

很简单。

拿前面 reddit 中被“夹”了的帖子举例。

我不是给了吃瓜现场的链接嘛。

你把链接往“时光机”的这个地方一粘:

你就会看到这个有一个时间轴的页面:

把鼠标浮到有颜色的日期上,就能看到各个时间点的页面快照了。

颜色越深代表那一天的快照越多:

比如,我们看一下这个网站收集到的第一个快照:

点进去,就是我们要找的吃瓜现场。

发帖后的两小时就被收集到了,速度还是挺快的。

从数据上看,这个时候已经有 3.7k 个点赞和 255 个评论,已经有要起飞的预兆了。

换个时间的快照,还可以看到点赞和评论的数据变化,比如发帖一天后:

点赞量已经是 71k,评论数来到了 3.8K,直接就是一个起飞的大动作。

这里只是用这个帖子举个例子。

再举一个例子。

也是我的真实使用场景。

有一次我在研究平滑加权轮询负载均衡策略算法为什么是平滑的。

和各类 AI 讨论了半天,它们也给出了各种参考文献。

我在其中一个参考文献中看到了这样一个链接:

tenfy.cn/2018/11/12/…

我知道这个链接的内容就是我要找的内容,但是这个链接跳转过去已经是 404 了:

于是,时间胶囊就派上用场了。

我直接把这个链接扔它:

找到了这个网页在 2019 年 12 月 10 日的快照:

通过这种方式就找到了原本已经被 404 的网页内容。

在看一些时间比较久远的文章的时候,参考链接打不开的情况,还是比较常见的。

所以这个方式是我最常用的一个场景。

此外,还有另外一个场景,就是偶尔去怀旧一下。

比如,中文互联网的一滴眼泪:天涯论坛。

这是 20 年前,2006 年 1 月的天涯论坛首页,一股浓烈的早期互联网风格:

在图片的右下角你还能看到“2006 天涯春晚”的字样。

另外,你不要觉得这只是一个静态页面。

里面的部分链接还是可以正常跳转的。

比如,这个链接:

点进去,你可以看到最最古早的一种直播形式:文字直播。

2006 年 1 月 2 日,《武林外传》开播。

天涯这个文字直播的时间是 2006 年 1 月 19 日,《武林外传》当时正在全国热播。

天涯网友在这个页面下提出自己关于《武林外传》的问题,作为天涯的知名写手,宁财神本人会选择部分问题进行回复。

我截取了几个我觉得有意思的回复:

这种行为这算不算是官方剧透了?

当年祝无双这个角色是真的不让人讨喜啊。幸好当时的网络还不发达,不然我觉得真有可能“网爆祝无双”。

DVD,一个多么具有年代感的词。

写文章的时候,我本来是想截几张图就走的,最多五分钟搞定。

结果我竟然一页页的翻完了这个帖子,看完之后才发现在这个帖子里面待了半个多小时。

时间过的还是很快的。

站在 2026 年,看 2006 的帖子,中间有 20 年的光阴。

但是就像是 2006 年佟掌柜对要给她干二十年工才能还清债务的小郭说的那样:不要怕,二十年快得很,弹指一挥间。

前几天小郭在微博上还回应了正式赎身这个梗。

去了六里桥、去了同福夹道、去了左家庄站、还去了祥蚨瑞,最后在人来人往的北京街头,一个猝不及防的回眸:

这是我的童年回头看了我一眼。

十几岁的不了解佟掌柜的这句话,三十出头了,一下就理解了:20 年,真的很快呀。

看到 2006 年的天涯的时候,我依稀想起了一些当年的往事。

那个时候我才 12 岁,看电视剧是真的在电视机上看,我还记得家里的电视机都是这样的“大屁股”电视机:

还记得《武林外传》每集开始,唱主题曲的时候,电视上面会显示一个电脑的桌面:

所以每次开头的时候,我就会叫表妹过来,对她说:你看,我等下把电视变成电脑。

那个时候表妹才 7 岁,我这个 12 岁的哥哥当然是把她唬的一愣一愣的。

那个时候电脑也还是一个稀奇的物品,虽然是乡下的学校,但是也还是有一个微机室,去微机室上课必须要带鞋套的那种。

所以 2006 年的天涯,我肯定是没有看过的,但是在 2026 年看到 2006 的天涯,我还是想起了很多童年往事。

对了,前几天才给表妹过完 27 岁的生日:

看着这张照片,再想起 7 岁时那个相信哥哥可以把电视变成电脑给她看《武林外传》的妹妹。

“二十年快得很,弹指一挥间”。

你说这不叫时间胶囊,叫什么?

再看一下 10 年前,2016 年 1 月 1 日的天涯,彼时的天涯可以说是如日中天,非常多的网友天天泡在论坛里面,谈古论今,激扬文字。

这是那天的天涯首页截图:

热帖榜第一的是一个关于纯电动汽车的帖子,我进去看了一下:

这个帖子的点击量是 10w,有 816 个回复。

可见这确实是当时的一个非常热门的话题。

按照作者的观点,纯电汽车代替燃油汽车,还很长的路要走。

站在 10 年后的今天,其实我们已经知道答案了。

但是,当我看到这个回复的时候,我还是佩服天涯网友的眼光:

除了天涯,还可以考古很多其他的网站。

比如,B 站:

从 2011 年开始有了网页快照,我随便点开一看,满满的历史感:

而这是 2016 年,10 年前的 B 站首页:

当时还有一个专门的鬼畜区:

而这里的一些视频甚至还是可以播放的。

比如这个“启蒙作品”:

现在在 B 站有 160w 的播放:

在这个视频的评论区,你能找到大量来“考古”的人:

二十年都弹指一挥间了,别说区区十年了。

从 B 站怀旧完成后,随便,我也去磨房、马蜂窝、穷游网看了一圈,随便选了 2012 年到 2016 年间的一些页面,感谢它们陪我度过了一整个美好的大学生活。

是我当时认识、感知、体验这个的广阔世界的一个重要窗口。

感谢磨房 4 年的陪伴:

感谢马蜂窝 4 年的陪伴:

感谢穷游网 4 年的陪伴:

如果你也有想要寻找的记忆,可以尝试在这个网站上去找一找。

存档

既然已经聊到“archive”了,那就顺便再分享一个“archive.today”。

archive.ph/

这个网站和前面的“互联网档案馆”最大的一个差异是“互联网档案馆”是它主动去做“网页快照”,什么时候做,什么页面做,并不一定。

而“archive.today”是一个你可以去主动存档的网站。

比如,还是说回 reddit 上的那个帖子。

帖子下面有这样的一个回复:

这个回复中的超链接就是回复者找到的关于这个“爆料”是 AI 生成的证据。

点过去是这样的:

他提供的是一个网页存档。

为什么他要这么做呢?

你想想,如果他提供一个原始链接,但是这个原始链接突然有一天找不到了,岂不是很尴尬?

但是先在“archive.today”上存档一下,然后把这个存档后的链接贴出来,就稳当多了。

以后你要保存证据的话,你就可以使用这个网站。

另外,这个网站还有一个骚操作。

反而是骚操作让这个网站的打开率更高一点。

国外的一些网站可能有些文章是要付费才能看到的。

比如纽约时报:

但是,如果你一不小心把付费文章的链接贴在这个网站上去搜索。

有一些“好事之人”已经帮你把文章在这个网站上做了快照了,这些人可以称之为“赛博菩萨”,因为这些“菩萨”,你就可能看到免费的原文了:

在这里叠个甲啊,偶尔看到一两篇的话可以这样操作一下,就当时是试看了。

如果经常要看的话,还是充点钱吧。

对了,多说一句,上面提到的神奇的网站既然叫做时光胶囊,还有一些赛博菩萨,这些魔法世界中才有的东西,那肯定需要你会对应的魔法咒语才能访问到。如果你不会魔法,强行访问,那你肯定要撞到墙上。

前端算法必备:滑动窗口从入门到很熟练(最长/最短/计数三大类型)

作者 颜酱
2026年1月12日 12:15
前端算法必备:滑动窗口从入门到很熟练(最长/最短/计数三大类型) 在算法面试中,子串、子数组相关的问题频繁出现,暴力枚举往往因 O(n²) 时间复杂度超时。而滑动窗口算法,凭借其 O(n) 的高效性能

lecen:一个更好的开源可视化系统搭建项目--数据、请求、寄连对象使用--全低代码|所见即所得|利用可视化设计器构建你的应用系统-做一个懂你的人

作者 晴虹
2026年1月10日 19:34

基本定义

前端系统主要由在线编写代码与可视化操作两种方式来构建。页面结构主要通过在线可视化拖拽组合生成,一些页面元素model的绑定及交互事件通过在元素属性面板编辑来操作。

我们提供了多个数据源来获取需要的数据,由于数据类型的不同,我们使用对象来对他们进行分类管理。

从功能性上主要分为下面几类:

属性值、预置函数、寄连、视图、元素、工具类。

由于细分下来属性特别多。因此我们通过不同的对象来对这些值进行访问。

名称 定义 说明
B 基础对象 包含initData,collectionData等
G 全局对象 包含page,menu,user等
R 请求链接对象 包含一些链接调用的方法和属性
P 执行寄连对象 包含一些寄连调取的方法和属性

几乎所有的变量都能够通过这四个对象访问到,每个页面都有自己的 BGRP 对象,除了 G 之外,不同页面之间的 BRP 对象都是相互独立的

这四个对象既可以独立访问,也可以在某个对象中访问另一个对象

页面中所有的地方都能够访问这些对象,所有可以写脚本的地方都能通过 this 来获取这些变量

除了当前页面下的公共对象之外,还有两个对象是只在当前组件的事件中能访问到的属性

名称 定义 说明
describe 当前组件视图配置 包含了渲染该组建所需的所有配置
scopeData 渲染数据 当前作用域插槽的数据

数据对象

initData

该对象主要存储一些初始化数据,一般情况下它的属性值不会发生变化,可以通过寄连、请求的方式进行设定,我们也可以手动指定初始化的数据,供之后访问、比较等操作。

该对象并没有严格的限制说能够存储哪些数据,只是约定好只存储页面初始化数据,或者一些其他的信息等

initData 在页面加载的时候,会默认填充两个属性:formCodeserviceTable,分别表示当前页面的编码和当前页面存储的数据表

initData默认填充

然后我们可以在任意其他位置对它进行设定

比如我们创建一个请求链接,然后在回调函数里面把接口的返回值放到 initData 里面

请求链接的回调函数里面写上 this.B.setInitData(data)

先看下接口的返回值:

接口返回值

给页面添加该接口之后,接口调用完毕会执行回调函数,将数据填充到 initData

initData赋值

collectionData

所有在视图中带有model字段的属性都会被收集到这个对象中,我们也可以给该对象赋值一些临时的其他属性,这样方便在别的地方都可以访问到。

详见 collectionData收集model

requestData

如果请求链接配置了绑定数据字段,那么通过请求返回的数据就会被存储到该对象中,它还包含了两个特殊的属性:

handle:所有请求中如果设定了code,那么就会被保存在该对象中,以备之后手动触发请求。

code:带有权限控制的code可以通过这个对象访问到,我们也可以手动指定code属性。

关于如何将请求链接返回的数据绑定到 collectionData 对象中,以及它们的绑定机制和手动触发请求,可参考 请求链接配置

关于 requestData.code 对象的作用和运行机制,可参考 权限控制

controlData

页面中的所有数据视图都会以code为标识存储在该对象中,我们手动设定的dom和view也会通过它进行访问。

关于组件的 domview 设置规则和它们的使用方式,可参考 元素和视图的引用

setInitData(data)

这是 B 对象暴露出来的用于设置 initData 对象的方法,直接传入需要设置的数据对象即可

setCollectionData(data)

这是 B 对象暴露出来的用于设置 collectionData 对象的方法,直接传入需要设置的数据对象即可

请求链接对象

管理页面中所有的请求链接。

请求链接对象

每个 请求链接 的回调函数中的this都指向这个对象。

除了在请求链接中使用,在页面的其他任何地方都可以通过 this.R 拿到这个对象。

也可以通过这个对象获取到 GPcollectionDatacontrolDatarequestData 等等公共对象。

主要有以下方法:

  • doCallBack:用于执行请求链接返回数据之后的回调函数

  • filterCancel:用于过滤掉被取消的请求

  • getLists:实际发起接口请求获取数据

  • initLists:根据请求链接的配置信息初始化请求

  • pitchRequest:根据请求策略拆分出不同的请求集合

  • prepare:发起实际请求之前的准备

  • request:管理一个请求从开始到结束的整个周期

  • setLists:根据请求链接的配置将返回的数据进行处理

  • trigger:根据请求编码发起请求

除了 preparetrigger 之外,其他方法均属于内部使用的方法,一般用不到

prepare

这是一个请求预处理,主要用来在发起一个请求前做一些逻辑处理,比如设置参数等。

如果页面中的一个请求链接具有 code 值,那么通过调用 prepare 方法,传入对应请求的 code,就会对该请求进行初始化并返回一个 promise

在then方法传递回来的参数是一个对象,其中包含两个属性:requestrun

request 代表当前的请求,可以通过它修改参数或执行其他操作。

run 是一个函数,执行之后将会发起真正的请求。

关于具体的使用方式可参照 请求编码

trigger

根据传入的对应请求的 code 发起请求,主要是用来处理手动触发的请求。

具体的示例可见 请求编码

寄连对象

用来管理页面中的执行寄连

函数 说明
bond 执行策略为before的寄连
doCallBack 自动执行指定的寄连
pitchBond 根据寄连策略拆分出不同的寄连集合
runIt 手动执行指定的寄连

还有一个 handle 的对象属性和一个 prepare 的数组属性

handle 主要用来存放需要手动执行的寄连

prepare 存放了执行策略为before的寄连

RunIt

通过它来手动执行寄连,第一个参数表示要执行的寄连 code,从第二个参数开始,都是要传入寄连函数的参数。

比如现在有一个编码为 getAverage 的寄连,它用来计算多个数的平均数,寄连内容如下:

计算平均数

现在在一个按钮的点击事件里面来调用这个执行寄连

执行寄连

点击按钮之后就会执行该寄连

计算结果

关于手动执行寄连的配置可参考 寄连执行策略

【项目体验】

系统管理端地址www.lecen.top/manage

系统用户端地址www.liudaxianer.com/user

系统文档地址www.lnsstyp.com/web

前端必备动态规划的10道经典题目

作者 颜酱
2026年1月10日 21:36

前端必备动态规划:10道经典题目详解(DP五部曲实战)

动态规划是前端算法面试的高频考点。本文通过「DP五部曲」框架,手把手带你掌握10道前端必备的DP题目,从基础递推到背包问题,每道题都包含详细注释、易错点分析和前端实际应用场景。

动态规划零基础的可以先补下

一、动态规划五部曲(核心框架)

无论什么DP问题,都可以按以下5个步骤拆解,这是解决DP问题的「万能钥匙」:

  1. 确定dp数组及下标的含义:明确 dp[i](或二维 dp[i][j])代表什么物理意义(比如"第i阶台阶的爬法数")
  2. 确定递推公式:找到 dp[i] 与子问题 dp[i-1]/dp[i-2] 等的依赖关系(核心)
  3. dp数组如何初始化:根据问题边界条件,初始化无法通过递推得到的基础值
  4. 确定遍历顺序:保证计算 dp[i] 时,其依赖的子问题已经被计算完成
  5. 打印dp数组(验证):通过打印中间结果,验证递推逻辑是否正确(调试必备)

下面结合具体问题,逐一实战这套框架。


二、入门级(3道,理解DP核心三步法,必刷)

1. LeetCode70. 爬楼梯 ★

题目链接70. 爬楼梯

难度:简单

核心:单状态转移,入门必做,会基础版 + 空间优化版

前端场景:步数计算、递归转迭代优化、分页器跳转步数计算、游戏角色移动路径数计算

题目描述

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。

每次你可以爬 12 个台阶。你有多少种不同的方法可以爬到楼顶呢?

示例 1

输入:n = 2
输出:2
解释:有两种方法可以爬到楼顶。
1. 1 阶 + 1 阶
2. 2 阶

示例 2

输入:n = 3
输出:3
解释:有三种方法可以爬到楼顶。
1. 1 阶 + 1 阶 + 1 阶
2. 1 阶 + 2 阶
3. 2 阶 + 1 阶
DP五部曲分析
  1. dp数组含义dp[i] 表示爬到第i阶台阶的不同方法数
  2. 递推公式dp[i] = dp[i-1] + dp[i-2](到第i阶的方法=到i-1阶爬1步 + 到i-2阶爬2步)
  3. 初始化dp[1] = 1(1阶只有1种方法),dp[2] = 2(2阶有2种方法)
  4. 遍历顺序:从左到右(i从3到n)
  5. 打印验证:遍历过程中打印dp[i],验证方法数是否符合预期
完整版代码(二维DP思想,但实际用一维数组)
/**
 * LeetCode70. 爬楼梯
 * 时间复杂度:O(n)
 * 空间复杂度:O(n)
 */
function climbStairs(n) {
  // 【步骤1】确定dp数组及下标的含义
  // dp[i] 表示爬到第i阶台阶的不同方法数
  const dp = new Array(n + 1);

  // 【步骤3】dp数组如何初始化
  // 边界条件:1阶只有1种方法,2阶有2种方法
  if (n === 1) return 1;
  if (n === 2) return 2;

  dp[1] = 1; // 1阶:只有1种方法(直接爬1阶)
  dp[2] = 2; // 2阶:有2种方法(1+1 或 2)

  // 【步骤4】确定遍历顺序:从左到右,保证计算dp[i]时dp[i-1]和dp[i-2]已计算
  for (let i = 3; i <= n; i++) {
    // 【步骤2】确定递推公式
    // 到达第i阶只有两种方式:
    // 1. 从第i-1阶爬1步到达 → 方法数 = dp[i-1]
    // 2. 从第i-2阶爬2步到达 → 方法数 = dp[i-2]
    // 总方法数 = 两种方式的方法数之和(加法原理)
    dp[i] = dp[i - 1] + dp[i - 2];

    // 【步骤5】打印dp数组(验证) - 调试时可以取消注释
    // console.log(`dp[${i}] = ${dp[i]}`);
  }

  return dp[n];
}

// 测试用例
console.log(climbStairs(2)); // 2
console.log(climbStairs(3)); // 3
console.log(climbStairs(4)); // 5
console.log(climbStairs(5)); // 8
空间优化版(滚动数组)
/**
 * 空间优化版:滚动数组
 * 时间复杂度:O(n)
 * 空间复杂度:O(1) ← 从O(n)优化到O(1)
 *
 * 【优化思路】
 * 观察递推公式:dp[i] = dp[i-1] + dp[i-2]
 * 发现dp[i]只依赖前两个状态,不需要保存整个数组
 * 可以用三个变量滚动更新:prevPrev(dp[i-2]), prev(dp[i-1]), cur(dp[i])
 */
function climbStairs(n) {
  // 【易错点1】边界处理:n=1或n=2时需要提前返回
  // 如果n=1时进入循环,prevPrev=1, prev=2会计算出错误结果
  if (n === 1) return 1;
  if (n === 2) return 2;

  // 初始化:对应dp[1]=1, dp[2]=2
  let prevPrev = 1; // dp[i-2],初始表示dp[1]=1
  let prev = 2; // dp[i-1],初始表示dp[2]=2
  let cur;

  // 从第3阶开始计算
  for (let i = 3; i <= n; i++) {
    // 计算当前阶的方法数
    cur = prevPrev + prev;

    // 【易错点2】滚动更新顺序很重要:先更新prevPrev,再更新prev
    // 如果顺序错误(如先更新prev),会导致prevPrev获取到错误的值
    prevPrev = prev; // 下一轮的dp[i-2] = 当前的dp[i-1]
    prev = cur; // 下一轮的dp[i-1] = 当前的dp[i]
  }

  return cur;
}

前端应用场景

  • 分页器组件:计算从第1页跳转到第n页的不同路径数(每次可以跳1页或2页)
  • 游戏开发:角色在台阶上移动,每次可以走1步或2步,计算到达目标位置的方案数
  • 动画路径计算:计算元素从位置A到位置B的不同动画路径数量

2. LeetCode53. 最大子数组和 ★

题目链接53. 最大子数组和

难度:简单

核心:贪心 + DP 结合,理解「状态转移的条件选择」

前端场景:数据趋势统计、收益/数值最值分析、股票K线图最大收益区间、用户行为数据峰值分析

题目描述

给你一个整数数组 nums ,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。

子数组是数组中的一个连续部分。

示例 1

输入:nums = [-2,1,-3,4,-1,2,1,-5,4]
输出:6
解释:连续子数组 [4,-1,2,1] 的和最大,为 6。

示例 2

输入:nums = [1]
输出:1

示例 3

输入:nums = [5,4,-1,7,8]
输出:23
DP五部曲分析
  1. dp数组含义dp[i] 表示以 nums[i] 为结尾的最大子数组和(注意:必须以nums[i]结尾)
  2. 递推公式dp[i] = Math.max(nums[i], dp[i-1] + nums[i])(要么重新开始,要么延续前面的和)
  3. 初始化dp[0] = nums[0](第一个元素的最大子数组和就是它自己)
  4. 遍历顺序:从左到右(i从1到n-1)
  5. 打印验证:打印dp数组,找到最大值
完整版代码
/**
 * LeetCode53. 最大子数组和
 * 时间复杂度:O(n)
 * 空间复杂度:O(n)
 */
function maxSubArray(nums) {
  const len = nums.length;

  // 【易错点1】边界处理:空数组返回0
  if (len === 0) return 0;
  if (len === 1) return nums[0];

  // 【步骤1】确定dp数组及下标的含义
  // dp[i] 表示以nums[i]为结尾的最大子数组和(注意:必须以nums[i]结尾)
  // 这个定义很关键:保证子数组是连续的
  const dp = new Array(len);

  // 【步骤3】dp数组如何初始化
  // 第一个元素的最大子数组和就是它自己(没有前面的元素可以延续)
  dp[0] = nums[0];

  // 用于记录全局最大值(因为dp[i]只表示以i结尾的最大和,不一定全局最大)
  let maxSum = dp[0];

  // 【步骤4】确定遍历顺序:从左到右
  for (let i = 1; i < len; i++) {
    // 【步骤2】确定递推公式
    // 核心思想:如果前面的和是负数,不如重新开始(贪心思想)
    // 两种选择:
    // 1. 重新开始:只选当前元素nums[i](前面的和是负数,拖累总和)
    // 2. 延续前面的:dp[i-1] + nums[i](前面的和是正数,可以继续累加)
    dp[i] = Math.max(nums[i], dp[i - 1] + nums[i]);

    // 【易错点2】必须实时更新全局最大值
    // 因为dp[i]只是以i结尾的最大和,最终答案不一定是dp[len-1]
    maxSum = Math.max(maxSum, dp[i]);

    // 【步骤5】打印验证
    // console.log(`dp[${i}] = ${dp[i]}, maxSum = ${maxSum}`);
  }

  return maxSum;
}

// 测试用例
console.log(maxSubArray([-2, 1, -3, 4, -1, 2, 1, -5, 4])); // 6
console.log(maxSubArray([1])); // 1
console.log(maxSubArray([5, 4, -1, 7, 8])); // 23
console.log(maxSubArray([-1])); // -1
空间优化版(只需一个变量)
/**
 * 空间优化版:滚动变量
 * 时间复杂度:O(n)
 * 空间复杂度:O(1) ← 从O(n)优化到O(1)
 *
 * 【优化思路】
 * dp[i]只依赖dp[i-1],不需要保存整个数组
 * 用一个变量prev保存上一个状态即可
 */
function maxSubArray(nums) {
  const len = nums.length;
  if (len === 0) return 0;
  if (len === 1) return nums[0];

  // 用prev代替dp[i-1],初始值为dp[0]
  let prev = nums[0];
  let maxSum = prev;

  for (let i = 1; i < len; i++) {
    // 计算当前状态:要么重新开始,要么延续前面的
    prev = Math.max(nums[i], prev + nums[i]);

    // 更新全局最大值
    maxSum = Math.max(maxSum, prev);
  }

  return maxSum;
}

前端应用场景

  • 股票K线图:计算某段时间内买入卖出的最大收益(价格差的最大连续子数组和)
  • 用户行为分析:分析用户在某段时间内的活跃度峰值(数据流的最大连续区间和)
  • 性能监控:找到服务器响应时间最差的连续时间段(负值转换为响应时间)
  • 数据可视化:在折线图中高亮显示数据增长最快的连续区间

3. LeetCode198. 打家劫舍 ★

题目链接198. 打家劫舍

难度:简单

核心:状态转移的「条件限制」(相邻不选),基础空间优化

前端场景:资源筛选、最优选择问题、权限分配优化、任务调度(不能同时执行相邻任务)

题目描述

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

给定一个代表每个房屋存放金额的非负整数数组 nums,请计算你不触动警报装置的情况下,一夜之内能够偷窃到的最高金额。

示例 1

输入:nums = [1,2,3,1]
输出:4
解释:偷窃 1 号房屋(金额 = 1),然后偷窃 3 号房屋(金额 = 3)。总金额 = 1 + 3 = 4

示例 2

输入:nums = [2,7,9,3,1]
输出:12
解释:偷窃 1 号房屋(金额 = 2),然后偷窃 3 号房屋(金额 = 9),接着偷窃 5 号房屋(金额 = 1)。总金额 = 2 + 9 + 1 = 12
DP五部曲分析
  1. dp数组含义dp[i] 表示前i间房屋能偷到的最高金额
    • 可以用二维状态:dp[i][0] 表示第i间不偷,dp[i][1] 表示第i间偷
  2. 递推公式
    • dp[i][0] = Math.max(dp[i-1][0], dp[i-1][1])(不偷当前,前一间可偷可不偷)
    • dp[i][1] = dp[i-1][0] + nums[i-1](偷当前,前一间必须不偷)
  3. 初始化dp[0] = [0, 0](前0间,偷或不偷都是0)
  4. 遍历顺序:从左到右(i从1到n)
  5. 打印验证:打印dp数组验证
完整版代码(二维状态)
/**
 * LeetCode198. 打家劫舍
 * 时间复杂度:O(n)
 * 空间复杂度:O(n)
 */
function rob(nums) {
  const len = nums.length;

  // 【易错点1】边界处理
  if (len === 0) return 0;
  if (len === 1) return nums[0];

  // 【步骤1】确定dp数组及下标的含义
  // dp[i][0] = 前i间房屋,第i间不偷能获取的最高金额
  // dp[i][1] = 前i间房屋,第i间偷能获取的最高金额
  // 使用二维状态可以清晰地表达"相邻不能同时偷"的约束
  const dp = new Array(len + 1);

  // 【步骤3】dp数组如何初始化
  // 前0间房屋:不偷和偷的金额都是0(没有房屋可偷)
  dp[0] = [0, 0];

  // 【步骤4】确定遍历顺序:从左到右
  for (let i = 1; i <= len; i++) {
    // 【易错点2】数组索引转换:dp[i]对应nums[i-1]
    // dp[1]对应nums[0](第1间房屋),dp[2]对应nums[1](第2间房屋)
    const curVal = nums[i - 1];

    // 【步骤2】确定递推公式
    // 状态1:第i间不偷 → 前i-1间可以偷也可以不偷,取最大值
    const valNotThief = Math.max(...dp[i - 1]);

    // 状态2:第i间偷 → 前i-1间必须不偷(相邻不能同时偷),加上当前金额
    // 【易错点3】必须是dp[i-1][0],不能是dp[i-1][1](违反相邻规则)
    const valThief = curVal + dp[i - 1][0];

    // 更新当前状态
    dp[i] = [valNotThief, valThief];

    // 【步骤5】打印验证
    // console.log(`dp[${i}] = [不偷:${valNotThief}, 偷:${valThief}]`);
  }

  // 最终结果:前len间房屋偷或不偷的最大值
  return Math.max(...dp[len]);
}

// 测试用例
console.log(rob([1, 2, 3, 1])); // 4
console.log(rob([2, 7, 9, 3, 1])); // 12
console.log(rob([2, 1, 1, 2])); // 4
空间优化版(两个变量)
/**
 * 空间优化版:滚动变量
 * 时间复杂度:O(n)
 * 空间复杂度:O(1) ← 从O(n)优化到O(1)
 *
 * 【优化思路】
 * 观察递推公式:dp[i]只依赖dp[i-1]的两个值
 * 可以用两个变量vNot和vYes滚动更新
 */
function rob(nums) {
  const len = nums.length;
  if (len === 0) return 0;
  if (len === 1) return nums[0];

  // 初始化:对应dp[0] = [0, 0]
  let vNot = 0; // 前i间不偷的最大值
  let vYes = 0; // 前i间偷的最大值

  for (let i = 1; i <= len; i++) {
    const curVal = nums[i - 1];

    // 【易错点4】关键:提前保存上一轮的所有状态,避免更新时覆盖
    // 如果直接使用vNot和vYes,更新vNot时可能会用到已经更新的vYes值
    const prevNot = vNot;
    const prevYes = vYes;

    // 不偷当前间:上一轮偷或不偷的最大值
    vNot = Math.max(prevNot, prevYes);

    // 偷当前间:上一轮不偷的最大值 + 当前金额
    // 【易错点5】必须用prevNot,不能用vNot(因为vNot已经更新了)
    vYes = curVal + prevNot;
  }

  return Math.max(vNot, vYes);
}

前端应用场景

  • 任务调度:在任务列表中,某些任务不能同时执行(有依赖关系),求最大收益
  • 权限分配:某些权限不能同时授予(互斥权限),求最大权限价值组合
  • 资源选择:在资源列表中,相邻资源有冲突,求最优选择方案
  • 广告投放优化:相邻时段的广告不能同时投放,求最大收益的投放方案

三、经典应用级(4道,DP核心考点,高频考)

4. LeetCode62. 不同路径 ★

题目链接62. 不同路径

难度:中等

核心:二维DP基础(可优化为一维),理解「路径型DP」

前端场景:可视化布局路径计算、网格类问题、Canvas/SVG路径绘制、游戏地图路径规划

题目描述

一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 "Start" )。

机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 "Finish")。

问总共有多少条不同的路径?

示例 1

输入:m = 3, n = 7
输出:28

示例 2

输入:m = 3, n = 2
输出:3
解释:从左上角开始,总共有 3 条路径可以到达右下角:
1. 向右 → 向下 → 向下
2. 向下 → 向下 → 向右
3. 向下 → 向右 → 向下
DP五部曲分析
  1. dp数组含义dp[i][j] 表示从起点(0,0)走到位置(i,j)的不同路径数
  2. 递推公式dp[i][j] = dp[i-1][j] + dp[i][j-1](只能从上方或左方来)
  3. 初始化
    • 第一行所有位置:dp[0][j] = 1(只能从左边来)
    • 第一列所有位置:dp[i][0] = 1(只能从上边来)
  4. 遍历顺序:从上到下、从左到右(双重循环)
  5. 打印验证:打印dp数组验证
完整版代码(二维DP)
/**
 * LeetCode62. 不同路径
 * 时间复杂度:O(m * n)
 * 空间复杂度:O(m * n)
 */
function uniquePaths(m, n) {
  // 【步骤1】确定dp数组及下标的含义
  // dp[i][j] 表示从左上角(0,0)走到位置(i,j)的不同路径数
  // 为了方便处理边界,使用dp[i+1][j+1]表示网格(i,j)的路径数
  const dp = new Array(m + 1).fill(0).map(() => new Array(n + 1).fill(0));

  // 【步骤3】dp数组如何初始化
  // 第一行(i=1):所有位置只能从左边来,路径数都是1
  for (let j = 1; j <= n; j++) {
    dp[1][j] = 1;
  }

  // 第一列(j=1):所有位置只能从上边来,路径数都是1
  for (let i = 1; i <= m; i++) {
    dp[i][1] = 1;
  }

  // 【步骤4】确定遍历顺序:从上到下、从左到右
  // 从(2,2)开始,因为第一行和第一列已经初始化
  for (let i = 2; i <= m; i++) {
    for (let j = 2; j <= n; j++) {
      // 【步骤2】确定递推公式
      // 走到(i,j)只有两种方式:
      // 1. 从上方(i-1,j)向下走一步 → 路径数 = dp[i-1][j]
      // 2. 从左方(i,j-1)向右走一步 → 路径数 = dp[i][j-1]
      // 总路径数 = 两种方式的路径数之和(加法原理)
      dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
    }
  }

  // 【步骤5】打印验证(调试时取消注释)
  // console.log('DP数组:');
  // for (let i = 1; i <= m; i++) {
  //   console.log(dp[i].slice(1).join(' '));
  // }

  return dp[m][n];
}

// 测试用例
console.log(uniquePaths(3, 7)); // 28
console.log(uniquePaths(3, 2)); // 3
console.log(uniquePaths(7, 3)); // 28
空间优化版(一维DP)
/**
 * 空间优化版:一维DP
 * 时间复杂度:O(m * n)
 * 空间复杂度:O(n) ← 从O(m*n)优化到O(n)
 *
 * 【优化思路】
 * 观察递推公式:dp[i][j] = dp[i-1][j] + dp[i][j-1]
 * 计算第i行时,只需要用到:
 * 1. 上一行第j列的值(dp[i-1][j])→ 对应更新前的dp[j]
 * 2. 当前行第j-1列的值(dp[i][j-1])→ 对应更新后的dp[j-1]
 * 可以用一维数组dp[j]滚动更新
 */
function uniquePaths(m, n) {
  // 【步骤1】一维dp数组:dp[j]表示当前行第j列的路径数
  // 初始化为第一行的值:所有位置路径数都是1(只能从左边来)
  const dp = new Array(n + 1).fill(1);

  // 【步骤4】确定遍历顺序:从第2行开始遍历
  for (let i = 2; i <= m; i++) {
    // 【易错点1】j从2开始,因为第1列(j=1)的值永远是1(只能从上边来)
    for (let j = 2; j <= n; j++) {
      // 【步骤2】递推公式(一维版)
      // dp[j](更新前)= 上一行第j列的路径数(dp[i-1][j])
      // dp[j-1](更新后)= 当前行第j-1列的路径数(dp[i][j-1])
      // 更新:dp[j] = dp[j](旧值,来自上方)+ dp[j-1](新值,来自左方)
      dp[j] = dp[j] + dp[j - 1];

      // 【易错点2】注意:这里dp[j-1]是已经更新的值(当前行),
      // 而dp[j]是旧值(上一行),正好符合递推公式的需求
    }
  }

  return dp[n];
}

前端应用场景

  • Canvas/SVG路径绘制:计算从起点到终点的不同绘制路径数量
  • 游戏地图:计算角色从起点到终点的移动方案数(只能向右或向下)
  • 网格布局计算:在CSS Grid或Flex布局中,计算元素排列的不同路径数
  • 路由规划:在地图应用中,计算从A点到B点的不同路线数量

5. LeetCode63. 不同路径 II

题目链接63. 不同路径 II

难度:中等

核心:带障碍的路径DP,学会「状态转移的边界判断」

前端场景:网格布局中的障碍物处理、表单验证路径计算、游戏地图障碍物路径规划

题目描述

一个机器人位于一个 m x n 网格的左上角(起始点在下图中标记为 "Start" )。

机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 "Finish")。

现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?

网格中的障碍物和空位置分别用 10 来表示。

示例 1

输入:obstacleGrid = [[0,0,0],[0,1,0],[0,0,0]]
输出:2
解释:3x3 网格的正中间有一个障碍物。
从左上角到右下角一共有 2 条不同的路径:
1. 向右 → 向右 → 向下 → 向下
2. 向下 → 向下 → 向右 → 向右

示例 2

输入:obstacleGrid = [[0,1],[0,0]]
输出:1
DP五部曲分析
  1. dp数组含义dp[i][j] 表示从起点到达位置(i,j)的路径数
  2. 递推公式
    • 如果(i,j)是障碍物:dp[i][j] = 0
    • 否则:dp[i][j] = dp[i-1][j] + dp[i][j-1]
  3. 初始化
    • 第一行:遇到障碍物前都是1,遇到障碍物后都是0
    • 第一列:遇到障碍物前都是1,遇到障碍物后都是0
  4. 遍历顺序:从上到下、从左到右
  5. 打印验证:打印dp数组验证
完整版代码(二维DP)
/**
 * LeetCode63. 不同路径 II(带障碍物)
 * 时间复杂度:O(m * n)
 * 空间复杂度:O(m * n)
 */
function uniquePathsWithObstacles(obstacleGrid) {
  const m = obstacleGrid.length;
  const n = obstacleGrid[0].length;

  // 【易错点1】边界处理:起点或终点是障碍物,直接返回0
  if (obstacleGrid[0][0] === 1 || obstacleGrid[m - 1][n - 1] === 1) {
    return 0;
  }

  // 【步骤1】确定dp数组及下标的含义
  // dp[i][j] 表示从起点到达位置(i-1,j-1)的路径数(索引从1开始便于处理边界)
  const dp = new Array(m + 1).fill(0).map(() => new Array(n + 1).fill(0));

  // 【步骤3】dp数组如何初始化
  // 初始化第一行:只能从左边来,遇到障碍物则后续位置无法到达
  for (let j = 1; j <= n; j++) {
    const curGrid = obstacleGrid[0][j - 1]; // 网格索引转换
    if (curGrid === 1) {
      // 【易错点2】遇到障碍物,后续位置都无法到达,直接跳出
      break;
    }
    dp[1][j] = 1;
  }

  // 初始化第一列:只能从上边来,遇到障碍物则后续位置无法到达
  for (let i = 2; i <= m; i++) {
    // 【易错点3】从i=2开始,因为dp[1][1]已在第一行初始化
    const curGrid = obstacleGrid[i - 1][0];
    if (curGrid === 1) {
      break;
    }
    dp[i][1] = 1;
  }

  // 【步骤4】确定遍历顺序:从第2行第2列开始
  for (let i = 2; i <= m; i++) {
    for (let j = 2; j <= n; j++) {
      // 网格索引转换:dp[i][j]对应网格(i-1, j-1)
      const curGrid = obstacleGrid[i - 1][j - 1];

      // 【步骤2】确定递推公式
      if (curGrid === 1) {
        // 【易错点4】当前位置是障碍物,无法到达,路径数为0
        dp[i][j] = 0;
      } else {
        // 当前位置不是障碍物,可以从上方或左方到达
        dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
      }
    }
  }

  return dp[m][n];
}
空间优化版(一维DP)
/**
 * 空间优化版:一维DP
 * 时间复杂度:O(m * n)
 * 空间复杂度:O(n)
 */
function uniquePathsWithObstacles(obstacleGrid) {
  const m = obstacleGrid.length;
  const n = obstacleGrid[0].length;

  if (obstacleGrid[0][0] === 1 || obstacleGrid[m - 1][n - 1] === 1) {
    return 0;
  }

  // 一维dp数组:dp[j]表示当前行第j列的路径数
  const dp = new Array(n + 1).fill(0);

  // 初始化第一行
  for (let j = 1; j <= n; j++) {
    const curGrid = obstacleGrid[0][j - 1];
    if (curGrid === 1) break;
    dp[j] = 1;
  }

  // 从第2行开始遍历
  for (let i = 2; i <= m; i++) {
    for (let j = 1; j <= n; j++) {
      const curGrid = obstacleGrid[i - 1][j - 1];

      if (curGrid === 1) {
        // 【易错点5】障碍物位置路径数置0
        dp[j] = 0;
      } else if (j === 1) {
        // 第一列:只能从上边来,保持dp[j]不变(如果上边是障碍物,dp[j]已经是0)
        // 不需要更新,因为第一列的路径数在初始化时已经确定
      } else {
        // 非第一列:可以从上方或左方到达
        dp[j] = dp[j] + dp[j - 1];
      }
    }
  }

  return dp[n];
}

前端应用场景

  • 表单验证:在复杂的多步骤表单中,某些步骤可能被禁用(障碍物),计算完成表单的不同路径
  • 游戏地图:在游戏中,某些格子是障碍物,计算从起点到终点的路径数
  • 权限路由:在权限系统中,某些路由节点被禁用,计算用户可访问的路由路径数
  • 工作流设计:在工作流中,某些节点可能被跳过,计算完成流程的不同路径

6. LeetCode213. 打家劫舍 II

题目链接213. 打家劫舍 II

难度:中等

核心:环形DP,拆分为两个基础DP问题(分治思想)

前端场景:环形资源分配、循环任务调度、权限系统中的循环依赖处理

题目描述

你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都围成一圈,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

给定一个代表每个房屋存放金额的非负整数数组 nums,请计算你在不触动警报装置的情况下,今晚能够偷窃到的最高金额。

示例 1

输入:nums = [2,3,2]
输出:3
解释:你不能先偷窃 1 号房屋(金额 = 2),然后偷窃 3 号房屋(金额 = 2),因为他们是相邻的。

示例 2

输入:nums = [1,2,3,1]
输出:4
解释:你可以先偷窃 1 号房屋(金额 = 1),然后偷窃 3 号房屋(金额 = 3)。偷窃到的最高金额 = 1 + 3 = 4

示例 3

输入:nums = [1,2,3]
输出:3
DP五部曲分析(分治思想)

核心思路:环形问题转化为两个线性问题

  • 情况1:不偷第一间(可以偷最后一间)→ 转换为线性问题:偷 [1, len-1]
  • 情况2:不偷最后一间(可以偷第一间)→ 转换为线性问题:偷 [0, len-2]
  • 取两种情况的最大值
完整版代码
/**
 * LeetCode213. 打家劫舍 II(环形)
 * 时间复杂度:O(n)
 * 空间复杂度:O(n)
 */
function rob(nums) {
  const len = nums.length;

  // 【易错点1】边界处理
  if (len === 0) return 0;
  if (len === 1) return nums[0];
  if (len === 2) return Math.max(nums[0], nums[1]);

  // 【核心思路】环形问题拆分为两个线性问题
  // 情况1:不偷第一间(可以偷最后一间)→ 范围 [1, len-1]
  // 情况2:不偷最后一间(可以偷第一间)→ 范围 [0, len-2]
  // 取两种情况的最大值

  /**
   * 辅助函数:线性数组的打家劫舍(LeetCode198的解法)
   * @param {number[]} arr - 线性房屋数组
   * @returns {number} - 能偷到的最大金额
   */
  function robLinear(arr) {
    const n = arr.length;
    if (n === 0) return 0;
    if (n === 1) return arr[0];

    // 二维状态DP
    const dp = new Array(n + 1);
    dp[0] = [0, 0]; // 前0间:不偷和偷都是0

    for (let i = 1; i <= n; i++) {
      const curVal = arr[i - 1];
      // 不偷当前间:前i-1间偷或不偷的最大值
      const valNotThief = Math.max(...dp[i - 1]);
      // 偷当前间:前i-1间必须不偷
      const valThief = curVal + dp[i - 1][0];
      dp[i] = [valNotThief, valThief];
    }

    return Math.max(...dp[n]);
  }

  // 情况1:不偷第一间,范围是nums[1]到nums[len-1]
  const case1 = robLinear(nums.slice(1));

  // 情况2:不偷最后一间,范围是nums[0]到nums[len-2]
  const case2 = robLinear(nums.slice(0, len - 1));

  // 【易错点2】返回两种情况的最大值
  return Math.max(case1, case2);
}

// 测试用例
console.log(rob([2, 3, 2])); // 3
console.log(rob([1, 2, 3, 1])); // 4
console.log(rob([1, 2, 3])); // 3
空间优化版(使用滚动变量)
/**
 * 空间优化版:robLinear函数使用滚动变量
 */
function rob(nums) {
  const len = nums.length;
  if (len === 0) return 0;
  if (len === 1) return nums[0];
  if (len === 2) return Math.max(nums[0], nums[1]);

  // 辅助函数:线性数组打家劫舍(空间优化版)
  function robLinear(arr) {
    const n = arr.length;
    if (n === 0) return 0;
    if (n === 1) return arr[0];

    let vNot = 0; // 不偷的最大值
    let vYes = 0; // 偷的最大值

    for (let i = 0; i < n; i++) {
      const curVal = arr[i];
      const prevNot = vNot;
      const prevYes = vYes;

      vNot = Math.max(prevNot, prevYes);
      vYes = curVal + prevNot;
    }

    return Math.max(vNot, vYes);
  }

  const case1 = robLinear(nums.slice(1));
  const case2 = robLinear(nums.slice(0, len - 1));

  return Math.max(case1, case2);
}

前端应用场景

  • 循环任务调度:在循环列表中,某些任务不能同时执行,求最大收益的调度方案
  • 环形权限分配:在权限环中,相邻权限互斥,求最大权限价值组合
  • 资源循环利用:在循环资源池中,某些资源不能同时使用,求最优资源分配
  • 时间轮调度:在时间轮算法中,计算最优的任务执行方案

7. LeetCode322. 零钱兑换 ★

题目链接322. 零钱兑换

难度:中等

核心:完全背包基础版,理解「最值型DP」的状态转移

前端场景:金额/资源最优分配、最少步骤问题、支付找零算法、资源最小化配置

题目描述

给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。

计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1

你可以认为每种硬币的数量是无限的。

示例 1

输入:coins = [1, 2, 5], amount = 11
输出:3
解释:11 = 5 + 5 + 1

示例 2

输入:coins = [2], amount = 3
输出:-1
解释:无法凑成总金额 3

示例 3

输入:coins = [1], amount = 0
输出:0
DP五部曲分析
  1. dp数组含义dp[i][j] 表示用前i种硬币凑出金额j所需的最少硬币个数
  2. 递推公式
    • 不选第i种硬币:dp[i][j] = dp[i-1][j]
    • 选第i种硬币:dp[i][j] = dp[i][j-coins[i-1]] + 1(注意是dp[i]不是dp[i-1],因为可以重复选)
    • 取最小值:dp[i][j] = Math.min(dp[i-1][j], dp[i][j-coins[i-1]] + 1)
  3. 初始化
    • dp[0][0] = 0(0种硬币凑0元需要0个)
    • dp[0][j>0] = Infinity(0种硬币无法凑正数金额)
    • dp[i][0] = 0(凑0元永远需要0个)
  4. 遍历顺序:外层遍历硬币种类,内层遍历金额(正序,因为是完全背包)
  5. 打印验证:打印dp数组验证
完整版代码(二维DP)
/**
 * LeetCode322. 零钱兑换(完全背包-最值型)
 * 时间复杂度:O(coins.length * amount)
 * 空间复杂度:O(coins.length * amount)
 */
function coinChange(coins, amount) {
  const coinCount = coins.length;
  const target = amount;

  // 【易错点1】边界处理:凑0元直接返回0
  if (target === 0) return 0;

  // 【步骤1】确定dp数组及下标的含义
  // dp[i][j] = 用前i种硬币凑出金额j所需的最少硬币个数
  // 初始化:所有值先填Infinity(表示初始无法凑出)
  const dp = new Array(coinCount + 1).fill(0).map(() => new Array(target + 1).fill(Infinity));

  // 【步骤3】dp数组如何初始化
  dp[0][0] = 0; // 0种硬币凑0元,需要0个
  // 0种硬币凑正数金额,无法凑出(保持Infinity)
  for (let j = 1; j <= target; j++) {
    dp[0][j] = Infinity;
  }

  // 【步骤4】确定遍历顺序:外层遍历硬币种类,内层遍历金额(正序)
  // 正序遍历是因为完全背包:每种硬币可以使用无限次
  for (let i = 1; i <= coinCount; i++) {
    dp[i][0] = 0; // 凑0元永远需要0个硬币
    const curCoin = coins[i - 1]; // 【易错点2】数组索引转换:第i种硬币对应coins[i-1]

    for (let j = 1; j <= target; j++) {
      if (j < curCoin) {
        // 金额不足,无法使用当前硬币,继承前i-1种硬币的结果
        dp[i][j] = dp[i - 1][j];
      } else {
        // 【步骤2】确定递推公式
        // 完全背包核心:用当前硬币时是dp[i][j-curCoin]+1(而非dp[i-1])
        // 因为硬币可以重复使用,所以用dp[i](已经考虑了当前硬币)
        dp[i][j] = Math.min(
          dp[i - 1][j], // 不用第i种硬币
          dp[i][j - curCoin] + 1 // 用第i种硬币(注意是dp[i],可以重复选)
        );
      }
    }
  }

  // 【易错点3】无法凑出时返回-1,而非Infinity
  return dp[coinCount][target] === Infinity ? -1 : dp[coinCount][target];
}

// 测试用例
console.log(coinChange([1, 2, 5], 11)); // 3
console.log(coinChange([2], 3)); // -1
console.log(coinChange([1], 0)); // 0
空间优化版(一维DP)
/**
 * 空间优化版:一维DP
 * 时间复杂度:O(coins.length * amount)
 * 空间复杂度:O(amount) ← 从O(coins.length * amount)优化到O(amount)
 *
 * 【优化思路】
 * 观察递推公式:dp[i][j] = Math.min(dp[i-1][j], dp[i][j-coins[i-1]] + 1)
 * 计算dp[i][j]时只需要:
 * 1. 上一行第j列的值(dp[i-1][j])→ 对应更新前的dp[j]
 * 2. 当前行第j-coins[i-1]列的值(dp[i][j-coins[i-1]])→ 对应更新后的dp[j-coins[i-1]]
 * 可以用一维数组dp[j]正序遍历(完全背包特征:正序)
 */
function coinChange(coins, amount) {
  const coinCount = coins.length;
  const target = amount;

  if (target === 0) return 0;

  // 【步骤1】一维dp数组:dp[j] = 凑出金额j所需的最少硬币个数
  const dp = new Array(target + 1).fill(Infinity);

  // 【步骤3】初始化
  dp[0] = 0; // 凑0元需要0个硬币

  // 【步骤4】确定遍历顺序:外层遍历硬币,内层正序遍历金额
  // 【易错点4】完全背包必须正序遍历:保证每种硬币可以使用无限次
  // 如果倒序遍历,就变成了01背包(每种硬币只能用一次)
  for (let i = 1; i <= coinCount; i++) {
    const curCoin = coins[i - 1];

    // 【易错点5】从curCoin开始遍历,避免j<curCoin的无效判断
    for (let j = curCoin; j <= target; j++) {
      // 【步骤2】递推公式(一维版)
      // dp[j](更新前)= 不用当前硬币的最少个数(上一轮的结果)
      // dp[j - curCoin] + 1 = 用当前硬币的最少个数(当前轮已更新的结果)
      dp[j] = Math.min(dp[j], dp[j - curCoin] + 1);
    }
  }

  // 【易错点6】返回前检查是否为Infinity
  return dp[target] === Infinity ? -1 : dp[target];
}

前端应用场景

  • 支付找零:在支付系统中,计算用最少硬币数找零给用户
  • 资源最小化配置:在资源分配中,用最少的资源组合达到目标值
  • API调用优化:计算用最少的API调用次数完成某个任务
  • 组件懒加载:计算用最少的组件加载次数完成页面渲染

8. LeetCode518. 零钱兑换 II

题目链接518. 零钱兑换 II

难度:中等

核心:完全背包的「组合数型DP」,与322(最值型)做区分

前端场景:组合方案统计、支付方式组合数计算、资源配置方案数统计

题目描述

给你一个整数数组 coins 表示不同面额的硬币,另给一个整数 amount 表示总金额。

请你计算并返回可以凑成总金额的硬币组合数。如果任何硬币组合都无法凑出总金额,返回 0 。

假设每一种面额的硬币有无限个。

示例 1

输入:amount = 5, coins = [1, 2, 5]
输出:4
解释:有四种方式可以凑成总金额:
5=5
5=2+2+1
5=2+1+1+1
5=1+1+1+1+1

示例 2

输入:amount = 3, coins = [2]
输出:0
解释:只用面额 2 的硬币无法凑成总金额 3。

示例 3

输入:amount = 10, coins = [10]
输出:1
DP五部曲分析(与322的区别)
  1. dp数组含义dp[i][j] 表示用前i种硬币凑出金额j的组合数(注意:是组合数,不是最少个数)
  2. 递推公式
    • 不选第i种硬币:dp[i][j] = dp[i-1][j]
    • 选第i种硬币:dp[i][j] = dp[i][j-coins[i-1]](注意是加法,不是取最小值)
    • 总组合数:dp[i][j] = dp[i-1][j] + dp[i][j-coins[i-1]]
  3. 初始化
    • dp[0][0] = 1(0种硬币凑0元,有1种组合:不选任何硬币)
    • dp[i][0] = 1(凑0元永远只有1种组合)
    • dp[0][j>0] = 0(0种硬币无法凑正数金额)
  4. 遍历顺序:外层遍历硬币(保证组合不重复),内层正序遍历金额
  5. 打印验证:打印dp数组验证
完整版代码(二维DP)
/**
 * LeetCode518. 零钱兑换 II(完全背包-组合数型)
 * 时间复杂度:O(coins.length * amount)
 * 空间复杂度:O(coins.length * amount)
 *
 * 【与322的区别】
 * - 322求:最少的硬币个数(最值型)→ Math.min
 * - 518求:组合数(计数型)→ 加法
 */
function change(amount, coins) {
  const coinCount = coins.length;
  const targetAmount = amount;

  // 【易错点1】边界处理:凑0元返回1(唯一组合:不选任何硬币)
  if (targetAmount === 0) return 1;

  // 【步骤1】确定dp数组及下标的含义
  // dp[i][j] = 用前i种硬币凑出金额j的组合数
  const dp = new Array(coinCount + 1).fill(0).map(() => new Array(targetAmount + 1).fill(0));

  // 【步骤3】dp数组如何初始化
  // 【易错点2】凑0元的组合数是1(不选任何硬币),不是0
  for (let i = 0; i <= coinCount; i++) {
    dp[i][0] = 1; // 凑0元永远只有1种组合
  }

  // 【步骤4】确定遍历顺序:外层遍历硬币,内层正序遍历金额
  // 【关键】外层遍历硬币保证了组合不重复:如[1,2]和[2,1]被视为同一种组合
  for (let i = 1; i <= coinCount; i++) {
    const currentCoin = coins[i - 1]; // 【易错点3】数组索引转换

    for (let j = 1; j <= targetAmount; j++) {
      if (j < currentCoin) {
        // 金额不足,无法用当前硬币,继承前i-1种的组合数
        dp[i][j] = dp[i - 1][j];
      } else {
        // 【步骤2】确定递推公式(组合数 = 不用 + 用)
        // dp[i-1][j]:不用第i种硬币的组合数
        // dp[i][j-currentCoin]:用第i种硬币的组合数(注意是dp[i],可重复选)
        dp[i][j] = dp[i - 1][j] + dp[i][j - currentCoin];
      }
    }
  }

  // 无法凑出时自然返回0(符合题目要求)
  return dp[coinCount][targetAmount];
}

// 测试用例
console.log(change(5, [1, 2, 5])); // 4
console.log(change(3, [2])); // 0
console.log(change(10, [10])); // 1
console.log(change(0, [1, 2])); // 1
空间优化版(一维DP)
/**
 * 空间优化版:一维DP
 * 时间复杂度:O(coins.length * amount)
 * 空间复杂度:O(amount)
 */
function change(amount, coins) {
  const coinCount = coins.length;
  const targetAmount = amount;

  if (targetAmount === 0) return 1;

  // 【步骤1】一维dp数组:dp[j] = 凑出金额j的组合数
  const dp = new Array(targetAmount + 1).fill(0);
  dp[0] = 1; // 【核心初始化】凑0元的组合数为1

  // 【步骤4】遍历顺序:外层遍历硬币,内层正序遍历金额
  // 【关键理解】外层遍历硬币 → 保证组合数不重复
  // 如果外层遍历金额,内层遍历硬币,会得到排列数(顺序有关)
  for (let i = 1; i <= coinCount; i++) {
    const currentCoin = coins[i - 1];

    // 【易错点4】完全背包:金额正序遍历(从currentCoin开始)
    for (let j = currentCoin; j <= targetAmount; j++) {
      // 【步骤2】递推公式(一维版)
      // dp[j](更新前)= 不用当前硬币的组合数(上一轮的结果)
      // dp[j - currentCoin](更新后)= 用当前硬币的组合数(当前轮已更新的结果)
      dp[j] = dp[j] + dp[j - currentCoin];
    }
  }

  return dp[targetAmount];
}

前端应用场景

  • 支付方式组合:计算用户可以用多少种不同的支付方式组合完成支付
  • 资源配置方案:计算有多少种不同的资源配置方案可以达到目标
  • 功能组合统计:计算有多少种不同的功能组合可以满足用户需求
  • 优惠券组合:计算有多少种不同的优惠券组合可以使用

四、进阶拓展级(3道,中大厂加分,理解即可)

9. LeetCode300. 最长递增子序列

题目链接300. 最长递增子序列

难度:中等

核心:单维度DP的经典拓展,理解「非连续状态转移」

前端场景:数据趋势分析、序列统计、时间线组件、用户行为序列分析

题目描述

给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。

子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。

示例 1

输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4。

示例 2

输入:nums = [0,1,0,3,2,3]
输出:4
解释:最长递增子序列是 [0,1,2,3],长度为 4

示例 3

输入:nums = [7,7,7,7,7,7,7]
输出:1
解释:最长递增子序列是 [7],长度为 1
DP五部曲分析
  1. dp数组含义dp[i] 表示以 nums[i] 为最后一个元素的最长严格递增子序列的长度
  2. 递推公式
    • 对于每个 nums[i],遍历前面所有元素 nums[j] (j < i)
    • 如果 nums[i] > nums[j],则 nums[i] 可以接在 nums[j] 的子序列后面
    • dp[i] = Math.max(dp[i], dp[j] + 1) (在所有满足条件的j中取最大值)
  3. 初始化dp[i] = 1(每个元素自身构成长度为1的子序列)
  4. 遍历顺序:外层遍历i(从1到n-1),内层遍历j(从0到i-1)
  5. 打印验证:打印dp数组,返回最大值(注意:最长子序列不一定以最后一个元素结尾)
完整版代码
/**
 * LeetCode300. 最长递增子序列
 * 时间复杂度:O(n²)
 * 空间复杂度:O(n)
 */
function lengthOfLIS(nums) {
  const count = nums.length;

  // 【易错点1】边界处理:空数组/单元素数组
  if (count <= 1) return count;

  // 【步骤1】确定dp数组及下标的含义
  // dp[i] = 以nums[i]为最后一个元素的最长严格递增子序列的长度
  // 【易错点2】初始化错误:必须初始化为1,不能初始化为0
  // 因为每个元素自身至少构成长度为1的子序列
  const dp = new Array(count).fill(1);

  // 【步骤4】确定遍历顺序:外层遍历i,内层遍历j
  // 【易错点3】i从1开始:i=0时前面没有元素,无法计算
  for (let i = 1; i < count; i++) {
    const curNum = nums[i]; // 当前元素

    // 内层遍历:检查i前面所有元素j(而非仅j=i-1)
    // 【易错点4】必须遍历所有j<i,不能只遍历j=i-1
    // 因为子序列可以非连续,nums[i]可以接在任意满足条件的nums[j]后面
    for (let j = 0; j < i; j++) {
      // 【步骤2】确定递推公式
      // 【易错点5】递增条件:必须是严格递增(>),不能是>=
      if (curNum > nums[j]) {
        // 如果nums[i] > nums[j],则nums[i]可以接在nums[j]的子序列后面
        // 取所有满足条件的dp[j]+1的最大值
        dp[i] = Math.max(dp[i], dp[j] + 1);
      }
    }

    // 【步骤5】打印验证
    // console.log(`dp[${i}] = ${dp[i]}`);
  }

  // 【易错点6】返回错误:不能返回dp[count-1]
  // 因为最长递增子序列不一定以最后一个元素结尾
  // 必须返回dp数组中的最大值
  return Math.max(...dp);
}

// 测试用例
console.log(lengthOfLIS([10, 9, 2, 5, 3, 7, 101, 18])); // 4
console.log(lengthOfLIS([0, 1, 0, 3, 2, 3])); // 4
console.log(lengthOfLIS([7, 7, 7, 7, 7, 7, 7])); // 1
console.log(lengthOfLIS([1, 3, 6, 7, 9, 4, 10, 5, 6])); // 6

前端应用场景

  • 时间线组件:在时间线中,找到最长连续增长的时间段
  • 用户行为分析:分析用户行为序列中最长的正向发展趋势
  • 数据可视化:在图表中高亮显示数据的最长递增区间
  • 版本号比较:找到版本号序列中最长的递增子序列

10. LeetCode121. 买卖股票的最佳时机 ★

题目链接121. 买卖股票的最佳时机

难度:简单

核心:DP + 贪心结合,也可纯DP实现,理解「状态定义的简化」

前端场景:数据趋势、收益计算、股票K线图分析、价格波动分析

题目描述

给定一个数组 prices ,它的第 i 个元素 prices[i] 表示一支给定股票第 i 天的价格。

你只能选择 某一天 买入这只股票,并选择在 未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。

如果你不能获取任何利润,返回 0 。

示例 1

输入:[7,1,5,3,6,4]
输出:5
解释:在第 2 天(股票价格 = 1)的时候买入,在第 5 天(股票价格 = 6)的时候卖出,最大利润 = 6-1 = 5。
     注意利润不能是 7-1 = 6, 因为卖出价格需要大于买入价格;同时,你不能在买入前卖出股票。

示例 2

输入:prices = [7,6,4,3,1]
输出:0
解释:在这种情况下, 交易无法完成, 所以返回 0。
DP五部曲分析
  1. dp数组含义dp[i] 表示第i天卖出股票能获得的最大利润
  2. 递推公式dp[i] = prices[i] - minPrice(当天价格减去之前的最小价格)
  3. 初始化dp[0] = 0(第0天无法卖出),minPrice = prices[0]
  4. 遍历顺序:从左到右(i从1到n-1),同时维护最小价格
  5. 打印验证:打印dp数组,返回最大值(注意:最大利润不一定在最后一天卖出)
完整版代码
/**
 * LeetCode121. 买卖股票的最佳时机
 * 时间复杂度:O(n)
 * 空间复杂度:O(n)
 */
function maxProfit(prices) {
  const count = prices.length;

  // 【易错点1】边界处理:空数组/单元素数组
  if (count <= 1) return 0;

  // 【步骤1】确定dp数组及下标的含义
  // dp[i] = 第i天卖出股票能获得的最大利润
  const dp = new Array(count).fill(0);

  // 【步骤3】dp数组如何初始化
  // 第0天无法卖出(必须先买入),利润为0
  dp[0] = 0;

  // 维护遍历到当前的最小价格(用于计算利润)
  let minPrice = prices[0]; // 初始买入价格是第0天的价格

  // 【步骤4】确定遍历顺序:从左到右
  for (let i = 1; i < count; i++) {
    const curPrice = prices[i]; // 当天价格

    // 【核心逻辑1】更新最小价格(必须先更新,再计算利润)
    // 【易错点2】顺序错误:如果先计算利润再更新minPrice,会导致"当天买当天卖"的逻辑错误
    // 正确的顺序:先更新minPrice(基于之前的价格),再计算当天卖出的利润
    minPrice = Math.min(minPrice, curPrice);

    // 【步骤2】确定递推公式
    // 第i天卖出的最大利润 = 当天价格 - 之前的最小价格(最佳买入点)
    // 如果结果为负,dp[i]保持0(等价于不交易)
    dp[i] = Math.max(0, curPrice - minPrice);

    // 【步骤5】打印验证
    // console.log(`第${i}天:价格=${curPrice}, 最小价格=${minPrice}, 利润=${dp[i]}`);
  }

  // 【易错点3】返回错误:不能返回dp[count-1]
  // 因为最大利润不一定在最后一天卖出(如示例1中最大利润在第5天,不是最后一天)
  // 必须返回dp数组中的最大值
  return Math.max(...dp);
}

// 测试用例
console.log(maxProfit([7, 1, 5, 3, 6, 4])); // 5
console.log(maxProfit([7, 6, 4, 3, 1])); // 0
console.log(maxProfit([2, 4, 1])); // 2
console.log(maxProfit([3, 2, 6, 5, 0, 3])); // 4
空间优化版(只需一个变量)
/**
 * 空间优化版:贪心思想
 * 时间复杂度:O(n)
 * 空间复杂度:O(1) ← 从O(n)优化到O(1)
 *
 * 【优化思路】
 * 观察:dp[i]只依赖dp[i-1]和minPrice
 * 而且我们只需要最大值,不需要保存整个dp数组
 * 用一个变量maxProfit实时更新最大值即可
 */
function maxProfit(prices) {
  const count = prices.length;
  if (count <= 1) return 0;

  let minPrice = prices[0]; // 最小买入价格
  let maxProfit = 0; // 最大利润

  for (let i = 1; i < count; i++) {
    const curPrice = prices[i];

    // 更新最小价格
    minPrice = Math.min(minPrice, curPrice);

    // 计算当天卖出的利润,并更新最大利润
    maxProfit = Math.max(maxProfit, curPrice - minPrice);
  }

  return maxProfit;
}

前端应用场景

  • 股票K线图:在股票图表中,计算买入卖出的最佳时机和最大收益
  • 价格趋势分析:分析商品价格变化,找到最佳买卖点
  • 收益计算器:在投资应用中,计算投资组合的最大收益
  • 数据波动分析:分析数据序列中的最大正向波动(类似股票收益)

五、总结

通过这10道动态规划经典题目,我们掌握了:

核心框架:DP五部曲

  1. 确定dp数组及下标的含义
  2. 确定递推公式
  3. dp数组如何初始化
  4. 确定遍历顺序
  5. 打印dp数组(验证)

题目分类

类别 题目 核心特点 空间优化
基础递推 爬楼梯、最大子数组和、打家劫舍 一维DP,状态转移简单 滚动变量 O(1)
路径型DP 不同路径、不同路径II 二维DP,网格问题 一维数组 O(n)
背包问题 零钱兑换、零钱兑换II 完全背包,最值/计数 一维数组 O(amount)
序列问题 最长递增子序列 非连续状态转移 难优化
状态机DP 买卖股票、打家劫舍II 状态转换,环形问题 滚动变量 O(1)

易错点总结

  1. 边界处理:空数组、单元素、索引转换
  2. 初始化:根据问题特点正确初始化(0、1、Infinity等)
  3. 遍历顺序:完全背包正序,01背包倒序
  4. 返回值:注意是返回dp[n]还是Math.max(...dp)
  5. 空间优化:注意更新顺序,避免覆盖未使用的值

前端应用价值

动态规划在前端开发中广泛应用于:

  • 性能优化:资源分配、组件懒加载优化
  • 业务逻辑:支付找零、权限分配、任务调度
  • 数据可视化:趋势分析、K线图、时间线组件
  • 算法优化:路径规划、组合统计、最值计算

掌握这10道题目,足以应对前端算法面试中的大部分DP问题。记住:先理解DP五部曲框架,再套用到具体问题,最后优化空间复杂度


相关资源

深度实践:得物算法域全景可观测性从 0 到 1 的演进之路

作者 得物技术
2026年1月8日 15:09

一、前言

在得物(Poizon)业务场景中,算法生态已演进为涵盖交易搜索、社区推荐、图像识别及广告策略的多维复杂系统。请求从Java网关下发,进入 C++ 构建的高性能算法核心(DSearch检索、DGraph图计算、DFeature特征提取等)。

随着系统复杂度的指数级增长,我们对现有系统的可观测性进行了全面梳理,为了提高稳定性,我们希望建设一个业务场景维度全链路变更事件中心, 以“聚焦做好可观测性”为核心目标,通过建设监控平台的事件中心与全链路可观测的核心产品,整合各平台资源与数据,提升系统的整体透明度和稳定性,从而提升业务稳定性和故障止血效率,为产品迭代奠定坚实的基础。

二、可观测性的“四大支柱”与联动愿景

在业界,可观测性通常被定义为Trace、Metric和Log三位一体。我们的目标是打造一套 “以场景为魂,以联动为骨” 的可观测体系,打破数据孤岛,实现算法治理的智能化转型。提出了 “四大支柱联动”:

  • Trace为径: 超越单纯的拓扑记录。通过Baggage机制,将复杂的业务语义与算法策略注入链路,实现调用流与业务流的深度耦合。
  • Metric为脉: 通过Trace自动生成场景化的性能指标。并结合元数据关联服务端业务指标,实现指标间的联动。
  • Log为证: 推动全链路日志格式化治理。规范异常码和业务码。
  • Event为源: 算法系统的灵魂在于演进。打通算法侧10+个变更平台, 将日均上万+的变更事件实时映射至链路拓扑。

三、核心攻坚:可观测性标准化

Trace标准化

在得物算法生态中,DMerge、DScatter、DGraph、DSearch、DFeature等核心组件承载着极致的性能诉求。由于C++侧Trace SDK的长期缺失,算法服务曾处于微服务观测体系的“孤岛”,难以与上下游实现全链路串联。

C++ Trace2.0(得物分布式链路追踪Trace2.0基于OpenTelemetry二次开发,目前已经支持Java/Go/JS/Python语言)并没有基于OpenTelemetry CPP进行二次开发主要考虑以下几点:

  • 极致性能与可控开销要求: C++侧服务位于请求链路关键路径,对RT与尾延迟极其敏感,需要对Span创建、上下文传播、属性写入等操作进行严格的CPU与内存开销控制,并对内存分配、锁竞争及线程切换具备严格可控性。相比之下,OpenTelemetry C++ SDK更偏向通用性与标准完备性, 其抽象层次与扩展点在部分高QPS场景下存在不可忽略的性能不确定性。
  • 原生SDK行为不透明带来的工程风险: OpenTelemetry C++ SDK 内部实现较为复杂,可能包含隐式线程、后台任务或复杂生命周期管理,在极端并发或异常场景下的问题定位与边界控制成本较高,而对源码完整评估的成本同样高昂。
  • brpc+bthread运行模型的兼容性担忧: C++ 服务大量基于brpc与bthread用户态调度模型,若SDK内部依赖pthread或引入额外系统线程,可能影响bthread worker的调度行为,存在运行时的兼容风险。
  • 工程依赖与符号冲突风险(尤其是Protobuf): 现有工程依赖特定版本的protobuf,而OpenTelemetry C++ SDK对其依赖栈有独立版本要求,在静态或混合链接场景下存在符号泄漏与ABI冲突风险,整体工程稳定性不可控。

SDK框架

  • APM Cpp SDK: 实现Span的创建、采集和上报,同时与控制平面对接实现心跳和配置热更新,基于kafka上报Trace。
  • brpc-tracer: brpc框架适配层,支持http与baidu-std协议的自动上报探针。
  • 引擎接入: 业务侧通过依赖brpc-tracer,支持链路上报。

报文压缩方案

通过对报文进行压缩,显著降低Trace上报过程中的带宽消耗,减少链路数据与业务请求在带宽上的竞争,避免对正常请求响应时延产生干扰,保障业务服务稳定性。

压缩策略:

长度过滤: 对写的属性、事件、异常进行key、value长度限制,对Span的整体进行长度限制,超出阈值部分进行截断,阈值实现了控制平面的动态更新。

字段压缩: 尽可能的对协议中的所有字段进行了压缩,例如,16进制字符串打包为2进制,通用字段省略key,通过差值替代结束时间等。

批量聚合: 将多条Span进行合并,作为一条报文进行上报,增加吞吐量的同时,减少kafka集群和带宽压力。聚合阈值也实现了控制平面动态更新。

静态信息抽取: 对进程级别的静态信息从Span对象中剥离,每个聚合报文只添加一个静态信息副本。

Snappy压缩: 先对聚合后的消息序列化,再进行Snappy压缩,经验压缩比是30%左右。

异步上报和MPSC无锁环队列

  • 异步上报: Span采集后写入队列,由异步线程批量处理并投递至Kafka;当队列已满或上报失败时直接丢弃,避免阻塞业务线程及内存膨胀。
  • MPSC无锁循环队列: MPSC是支持多生产者单消费者的无锁队列结构,利用循环数组实现高效数据传递。通过原子操作避免加锁,减少线程竞争带来的上下文切换和性能开销。在高并发场景下能提供更稳定的吞吐量和更低的延迟,保证队列操作的高效性和可预测性。

RPC探针

RPC 探针实现了在协议层对请求生命周期的统一感知与Trace自动化处理,支持BRpc客户端与服务端在无业务侵入的前提下完成Trace的自动采样与上报。

针对不同通信场景,在协议层引入统一的RPC探针,通过埋点回调对请求生命周期进行拦截,实现Trace的自动采样与埋点。

上线效果

  • 支持trace_id链路查询。

  • 支持指标维度(异常,RT范围等)的链路查询。

Log标准化

在全链路可观测体系中,日志是还原业务现场的最终证据。针对算法侧Java 侧规范、C++ 侧杂乱的现状,我们实施了深度对齐与语义重构。

  • 跨语言语义对齐:以Java侧成熟的标准化日志为标杆,通过自研C++ Log SDK推行结构化日志协议。
  • 业务语义锚定:在日志规范中首次引入了“场景 (Scene) + 异常码 (Error Code)”。
  • 场景化建模: 将具体的业务上下文(如推荐、搜索)注入日志元数据,使日志具备了清晰的业务属性。
  • 异常码标准化: 建立算法侧统一的错误字典,实现从“模糊描述”到“精确指纹”的跨越。

日志格式规范

1.统一文件名

 /logs/应用名/{应用名}-error.log
  • 文件目录在/logs/应用名/
  • 统一文件名叫{应用名}-error.log,比如引擎的叫:doe-server-error.log
  • 日志采集时按pattern: *-error.log采集

2.日志格式

  • 按照竖线 “|”分隔符分隔
 时间戳|进程ID:线程ID|日志等级|[应用名,trace_id,span_id,scene,errCode,]|接口名|代码行号|[可用区,集群名,,]|异常名|message
  • 字段详细介绍

日志模板聚类算法

模板聚类流程

规则:以正则掩码+Drain解析树为基础

  • 正则掩码: 通过正则对日志进行预处理,如时间,IP地址,数字,等等。例如“2025-12-01 10:20:30 ERROR host 10.0.1.2 connect timeout”经过正则掩码后,得到“<:TIME:> ERROR host <:IP:> connect timeout”
  • Drain算法: Drain算法是一种用于处理日志数据的结构化分析算法,广泛应用于日志解析和日志模板抽取领域。它是一种基于层次聚类的在线日志解析算法,其主要目标是从原始日志中提取日志模板,从而将非结构化日志转换为半结构化数据格式,这有助于后续的日志分析、故障检测和系统监控。

Drain算法主要分为以下几个步骤

  • 预处理

首先需要对日志进行预处理,包括前文的正则掩码,以减少冗余信息对解析的影响。另外,需要对日志进行分词,按空格和其他分割符划分为多个片段。

  • drain解析树

接下来构建了一种层次结构的树,称为parse tree,用于记录和组织日志消息。

  • 在树的第一级节点,日志将会依据其长度(分词后片段数目)进行分类。不同长度的日志会被分配到不同的路径上。
  • 然后在树的后续层级中,每一层级都尝试根据其他的静态关键字对日志消息进行进一步细化分类。
  • 树的叶子节点为日志聚类桶,逐个遍历桶中的聚类,分别判断当前日志与对应日志聚类的相似度是否达到阈值,相似度算法为相同位置的相同token占token总数的比例。
  • 如果相似则将判断当前的日志匹配该聚类,如果都不相似则创建新的聚类并加入桶中。

上线效果

日志模板聚类维度支持:应用名、集群名、异常名、code码、异常日志模版等。

四、以“场景”为魂:构建算法知识图谱

场景化建模 (AlgoScene)

在得物APP中,用户每一次搜索或进入社区频道,底层都会触发一次复杂的RPC调用流。流量在算法域内穿梭时,会经历多次不同“场景”算子的串行与并行计算,最终才将推荐结果反馈给客户端。正是由于这种调用路径极其复杂且具备高度的业务特性,我们决定打破传统的物理链路视角,转而以 “场景”为核心单元构建知识图谱。

如图所示,

  • 一个场景由多个算子组合
  • 每个算子由0..多个组件构成
  • 组件一般通过RPC(HTTP/GRPC/Dubbo/Redis/BRPC/场景)方式调用下

AlgoScene场景名

在确定以“场景”为核心的串联逻辑后,由于单次 RPC 调用往往横跨多个算法节点,我们必须实现对多场景动态链的支持。

考虑到算法任务编排天然以场景为基本单元,我们通过在Trace SDK中封装putAlgoSceneToBaggage方法,利用Baggage机制将场景信息透传至全链路。在每个服务的场景入口处,只需通过以下代码即可实现场景上下文的注入,确保全链路中的每个Span都能自动携带algo_scene字段:

Context ctx = AlgoBaggageOperator.putAlgoSceneToBaggage("trans_product");
try (Scope scope = ctx.activate()) {
    // 业务逻辑执行
}

在数据清洗阶段,我们通过对algo_scene字段进行逗号切分,解析出完整的场景路径链:

  • algoScene: 记录全链路经过的所有场景名(逗号分隔)。
  • rootScene: 切分后的第一个场景名,代表流量进入算法域的原始触发源。
  • currentScene: 切分后的最后一个场景名,代表当前节点所属的具体算子场景。

最终Trace效果

传播链“Baggage” VS “InnerBaggage”

Baggage是OpenTelemetry观测标准中的一个核心组件。如果说TraceID是用来串联整个调用链的“身份证”,那么Baggage就像是随身携带的“行李箱”。

它允许开发人员在整个请求链路中携带自定义的键值对(Key-Value Pairs)。 这些数据会随着HTTP Header或RPC元数据在各个微服务之间自动“漂流”,确保下游服务能够感知上游传递的业务上下文。

核心原理

Baggage是基于HTTP Header协议实现的。根据W3C标准,它会将数据存放在名为baggage的Header中进行透传:

  • 格式: baggage: algoScene=recommend_v1,isTest=true
  • 传播方式: 自动随请求从Service A流转至Service B、C,无需在每个服务的业务代码中手动添加参数。

底层实现

如何将baggage信息应用到每个span呢?我们增强了spanProcessor代码如下:

Baggage baggage = Baggage.fromContext(parentContext);
baggage.forEach((s, baggageEntry) -> {
    if (s.startsWith(OTEL_TO_SPAN_BAGGAGE_PREFIX)) {
        String value = baggageEntry.getValue();
        if (value == null) {
            value = NULL_VALUE;
        } else if (value.isEmpty()) {
            value = EMPTY_VALUE;
        }
        span.setAttribute("baggage:" + s.substring(OTEL_TO_SPAN_BAGGAGE_PREFIX.length()), value);
    }
});

InnerBaggage

在全链路追踪中,如果说Baggage解决了服务之间的跨站传递,确保业务信息能跨越机器送达下游;那么InnerBaggage则负责服务内部的进程传递,确保在同一个进程里,无论业务逻辑经过多少个组件,当前的“算子名”等信息都能自动同步到每一个执行步骤中,无需在代码里层层手动传递参数。

示例

// 在算子入口处,定义一个 InnerBaggage 作用域
try (Scope ignored = InnerBaggage.with("search_processor_biz_component""content_agg")) {     
    // 这里的逻辑无论是调用数据库还是计算,生成的 Span 都会自动带上 search_processor_biz_component=content_agg     
    runComponentLogic();  
}  
// 作用域结束,InnerBaggage 自动清理,防止污染下一个算子

最终效果

一个远程Dubbo-client被成功标记algo_scene和业务算子名“content_agg”。

动态元数据与流式计算

配置中心元数据

在复杂的算法场景中,由于变更频率极高,硬编码显然无法满足需求,我们构建了一套基于配置中心的动态元数据订阅体系。

  • 建立“应用-配置集”订阅关系

  • 元数据模型定义

为了支撑应用与配置之间的多对多关系,我们设计了如下核心表结构,用于记录订阅逻辑与元数据画像:

场景拓扑图 (Neo4j)

在完成业务侧的全链路埋点后,后端数据清洗层负责将海量的原始Trace数据进行结构化处理:它实时解析并提取Baggage中的全局场景信息与InnerBaggage中的局部算子标签,从而将离散的链路信息转化为标准化的业务计算流。

流式计算引擎

借助流式计算引擎强大的EPL能力,我们通过类SQL的声明式语法,精炼地实现了从实时多维聚合到复杂模式匹配的逻辑表达,目前已沉淀出12个覆盖核心业务场景的标准SQL算子,显著提升了实时数据处理的开发效率与灵活性。SQL示例如下:

@TimeWindow(10)
@Metric(name = 'algo_redis_client', tags = {'algoScene','rootScene','currentScene','props','env','clusterName','serviceName','redisUrl','statusCode'}, fields = {'timerCount''timerSum''timerMax'}, sampling='sampling')
SELECT algoScene                                as algoScene,
       rootScene                                as rootScene,
       currentScene                             as currentScene,
       get_value(origin.props)                  as props,
       env                                      as env,
       serviceName                              as serviceName,
       clusterName                              as clusterName,
       statusCode                               as statusCode,
       redisUrl                                 as redisUrl,
       trunc_sec(startTime, 10)                 as timestamp,
       max(duration)                            as timerMax,
       sum(duration)                            as timerSum,
       count(1)                                 as timerCount,
       sampling(new Object[]{duration,traceId})                   as sampling
FROM algoRedisSpan as origin
GROUP BY algoScene, rootScene, currentScene, props,env,serviceName, clusterName, redisUrl,statusCode,trunc_sec(startTime, 10)
  • @TimeWindow(10): 定义了一个10秒的滚动窗口,引擎会把这10秒内产生的所Redis访问记录(Span)攒在一起进行一次计算
  • @Metric(...): 这定义了输出结果的结构。将计算结果转化为指标(Metric),其中tags是维度,fields是数值。
  • sampling(...): 采样功能,通过采样逻辑记录耗时最大的traceId。

场景拓扑图

前面构造了以“场景”为中心的算法域调用指标,后面构造怎样的数据模型决定了用户从什么角度去观察和分析数据。我们摒弃了不够直观的传统的表格式展示,借助强大的图数据存储数据库Neo4j,实时存储和更新算法场景的算子调用拓扑图。实时调用指标关系存储时序数据库Victoriametrics,实时调用关系存储Neo4j。

图模型

  • 节点(Node):代表实体,如:App、AppCluster、ArkGroup、ArkDataId、AlgoComponent、AlgoDGraph等
  • 关系(Relationship):连接节点,如:SceneRelation、AppRelation等
  • 属性(Properties):存储在节点和关系上的键值对,如:appName、clusterName、scene、componentName、updateTimestamp等

数据模型设计

// app节点
CREATE (a:App {
    id1,
    hash: -6545781662466553124,
    appName"sextant"
})
// appCluster节点
CREATE (c:AppCluster {
    id23,
    hash: -8144086133777820909,
    appName"sextant",
    clusterName"sextant-csprd-01"
})
// index
CREATE INDEX index_app_name FOR (a:App) ON (a.appName)
// 关系
MATCH (a:App {id1}),(c:AppCluster {id:23})
MERGE (a)-[r:HAS_CLUSTER]->(c)
ON CREATE SET r.updateTs = timestamp()
ON MATCH SET r.updateTs = timestamp()
return r;

时序指标设计

{
    "metric": {                 
        "__name__":"algo_client_metric_timerCount",
        "from":"hashcodexxx",
        "to":"hashcodexxx",
        "statusCode": 0,
        "type": "Dgraph"
    },
    "values":[42,32,15],
    "timestamps":[1767573600,1767573620,1767573640]
}

上线效果

  • 通过apoc获取实体间的调用关系
CALL apoc.meta.graph()

  • 通过cypher语句查询某场景下的调用拓扑
MATCH 
    p = (entry {appName: 'app'})-[r:USES_SCENE*1..]->(to) 
WHERE all(rel IN r WHERE rel.type = 'CURRENT_SCENE' AND rel.scene CONTAINS 'scene'          and rel.updateTs >= 1767675780000 and rel.updateTs <= 1767679380000) 
RETURN nodes(p) AS allNodes, relationships(p) AS allRels LIMIT 1000
sum(sum_over_time(algo_client_metric_timerSum{scene="xxx"}[1m] offset 1m)) by (to) / sum(sum_over_time(otel_algo_client_metric_timerCount{scene="xxx"}[1m] offset 1m)) by (to) 
/ 1000
sum(sum_over_time(algo_client_metric_timerCount{scene="xxx"}[1m] offset 1m) / 60) by (to)

五、智能化演进:异常检测与事件联动

异常检测:改进型IQR算法

通过构建以“场景”为核心的监控维度,我们可以精准捕捉异常总数及其演进趋势。接下来聚焦周期性规律识别与异常检测算法优化两大核心领域:

周期性规律:从傅里叶变换到自适应识别

在电商微服务架构中,指标波动深度耦合人类行为的“昼夜节律”;而在算法业务场景下,频繁的实验任务使周期性特征更趋复杂且多变;

  • 通用方案:传统的傅里叶变换(FFT)虽能捕捉频域特征,但在时域噪声干扰下难以推导出高精度的物理周期;
  • 落地方案:采用自适应周期识别算法, 能够根据时序数据的动态演变,自动、精确地推测出各场景特有的周期步长;

给定一些候选周期,通过计算时间序列的滞后1周期的自相关性,验证时间序列是否匹配候选周期。对不同的候选周期,取不同长度的历史数据,候选周期越大,需要历史数据越久远,相关性要求较低。

周期识别算法示意图

异常检测算法:从 3-Sigma 到改进型 IQR

面对流量激增产生的“随机突刺”以及低流量场景下的“零水位”常态,检测算法需要具备极高的鲁棒性。

  • 通用方案:标准3-Sigma算法预设数据符合正态分布,而错误数指标往往呈现正偏态、高峰度特征,直接应用会导致虚假告警频繁,产生大量“告警噪音”;
  • 落地方案:基于四分位距(IQR)算法进行深度改进。通过动态调整比例系数与阈值边界,完美适配非正态分布的错误数指标,在确保灵敏度的同时显著降低了误报率;

综合考虑,使用IQR异常检测:

  • IQR是指:上四分位数与下四分位数(25%分位数)之差,即箱型图中箱体的高度。
  • IQR异常检测是指:超过上四分位数1.5倍的IQR,或低于下四分位数1.5倍的IQR,则为异常。

结合错误数指标特征,对IQR异常检测进行了一些改进:

  • 零基线自适应处理:当时间序列大量为0时,自动排除0值计算基线,避免误报。
  • 双阈值约束:错误数超过多少必为异常,超过基线多少必为异常。
  • 图中高亮部分(75%, 25%, +1.5, -1.5 )均设置为可调参数,针对不同算法场景做微调。

落地效果

一般异常检测

零基指标的异常检测:噪音显著降低

周期性指标的异常检测:能发现局部异常点

事件标准化:因果关联的最后一公里

在得物算法生态中,日均变更次数达万级,涵盖了模型迭代、配置分发、代码部署等多个维度。事件标准化的核心目标是:让每一次变更都有迹可循,并能自动与链路抖动建立因果关联。

统一事件协议

我们对来自配置中心、发布平台、算法实验平台等10+个源头的事件进行了协议标准化。每一个进入可观测底座的事件都必须具备以下条件:

  • Source (变更源): 变更的平台(配置中心 / 发布平台 / AB实验平台 / 特征平台 / 机器学习平台等 )
  • ChangeObject (主体): 变更对象(如:某个应用名、某个配置文件)
  • ChangeStatus (状态): PENDING / APPROVED / CANCELED / FINISHED 等
  • StartTime(时间): 变更开始时间 
  • ChangeName (标题): 变更主体
  • Severity (等级): 评估变更风险等级(P0-P3)
  • beforeChangeContent (上一次版本): 记录变更前的内容
  • changeContent (版本): 记录变更后的内容
  • extraInfo (附加信息): 可选字段如下:
    • <scene: 场景名>,<isGlobal: 全局变更>,<isReboot: 自动变更> ...

事件流

  • 各平台通过OpenAPI方式上报到事件中心,数据存储在ES中
  • 算法域累计10+个平台100+种变更入口类型,每天10+万的变更事件

场景事件关联

算法侧一些核心的平台的事件只能串联上业务域,这一期我们用在线Trace埋点的方式,串联通了核心平台从一/多个场景,比如:社区搜索主搜索,通过在线Trace清洗后就可以关联上,搜推AB实验管理平台、索引平台、无矩机器学习平台等等。

上线效果

六、总结—算法域全景可观测性的 0 到 1

算法域全景可观测性的构建,从零开始摸索,我们经历了多次技术方案的迭代与修正。这让我们意识到,监控建设不能不结合业务场景,否则产生的数据很难在实际排查中发挥价值。

一期建设中,我们聚焦于实用性,通过整合链路(Trace)、指标(Metric)、日志(Log)以及变更事件,打通了从基础架构到业务应用的纵向关联。这套体系为二线运维提供了清晰的下钻能力,使得故障边界的锁定更加快速准确。

进入二期阶段,我们将重点解决存量离线变更的接入以及ErrLog/业务码的标准化问题。同时,我们将观测维度延伸至业务效果指标,通过构建集SLA监控、事件中心与异常大盘于一体的“算法业务场景NOC-SLA保障体系”,实现从“系统运行可见”到“业务运行稳定”的闭环。

往期回顾

1.前端平台大仓应用稳定性治理之路|得物技术

2.RocketMQ高性能揭秘:承载万亿级流量的架构奥秘|得物技术 

3.PAG在得物社区S级活动的落地

4.Ant Design 6.0 尝鲜:上手现代化组件开发|得物技术

5.Java 设计模式:原理、框架应用与实战全解析|得物技术

文 /南风

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

RocketMQ高性能揭秘:承载万亿级流量的架构奥秘|得物技术

作者 得物技术
2025年12月30日 16:42

一、前言

在分布式系统架构中,消息队列如同畅通的“信息神经网络”,承担着解耦、削峰与异步通信的核心使命。在众多成熟方案中,RocketMQ凭借其阿里巴巴与Apache双重基因,以卓越的金融级可靠性、万亿级消息堆积能力和灵活的分布式特性脱颖而出,成为构建高可用、高性能数据流转枢纽的关键技术选型。本文将深入解析RocketMQ的核心架构、设计哲学与实践要义。

二、RocketMQ架构总览

官网图片

RocketMQ架构上主要分为四部分,如上图所示: 

RocketMQ作为一款高性能、高可用的分布式消息中间件,其核心架构采用了经典的四组件协同设计,实现了消息生产、存储、路由与消费的全链路解耦与高效协同。四大组件——生产者(Producer)、消费者(Consumer)、路由中心(NameServer)和代理服务器(Broker)——各司其职,共同构建了其坚实的基石。

生产者(Producer) 作为消息的源头,负责将业务消息高效、可靠地发布到系统中。它支持分布式集群部署,并通过内置的智能负载均衡机制,自动选择最优的Broker节点与队列进行投递。

消费者(Consumer) 是消息的处理终端,同样以集群化方式工作,支持推送(Push)和拉取(Pull)两种消息获取模式。它提供了集群消费与广播消费两种模式,并能动态维护其订阅关系。

路由中心(NameServer) 是整个架构的“注册中心”,扮演着轻量级服务发现的角色。所有Broker节点都会向NameServer注册,并通过定期心跳汇报健康状态。生产者与消费者则从NameServer获取实时的主题路由与Broker信息,从而实现消息寻址的完全解耦。

代理服务器(Broker) 是消息存储与流转的核心,负责消息的持久化存储、投递与查询。为了保障高可用性,Broker通常采用主从(Master-Slave)部署架构,确保数据与服务在故障时能无缝切换。其内部集成了通信处理、存储引擎、索引服务和高可用复制等核心模块。

三、核心组件深度解析

NameServer:轻量级服务发现枢纽

NameServer是RocketMQ的轻量级服务发现与路由中心, 其核心目标是实现生产消费与Broker服务的解耦。 它不存储消息数据,仅管理路由元数据。

核心是一张的路由表 HashMap<String/* Topic */, List>,记录了每个Topic对应在哪些Broker的哪些队列上。

客户端内置了故障规避机制。如果从某个NameServer获取路由失败,或根据路由信息访问Broker失败,会自动重试其他NameServer或Broker。

1. 核心角色与设计哲学: NameServer的设计哲学是 “简单、无状态、最终一致” 。 每个NameServer节点独立运行,节点间互不通信, 这使其具备极强的水平扩展能力和极高的可用性。客户端会配置所有NameServer地址,并向其广播请求。

2. 核心工作机制: 其运作围绕路由信息的生命周期展开,可通过下图一览其核心流程:

3. 和kafka注册中心对比

  • NameServer 采用 “去中心化” 和 “最终一致” 思想,追求极致的简单、轻量和水平扩展, 牺牲了强一致性,以换取架构的简洁和高可用。这非常适合路由信息变动不频繁、客户端具备容错能力的消息场景。
  • Kafka (KRaft) 采用 “中心化” 和 “强一致” 思想,追求数据的精确和系统的自包含。 它将元数据管理深度内化,通过共识协议保证全局一致,但代价是架构复杂度和运维成本更高。

优劣分析: NameServer在运维简易性、集群扩展性、无外部依赖上占优;而Kafka KRaft在元数据强一致性、系统自包含、架构统一性上更胜一筹。选择取决于你对一致性、复杂度、运维成本的具体权衡。

Broker:消息存储与转发的核心引擎

解密存储文件设计

Broker目录下的文件结构

所有核心存储文件均位于Broker节点的 ${storePathRootDir}/store/ 目录下(默认路径为 ~/store/),其下各子目录职责分明:

目录/文件 核心职责 关键设计说明
commitlog/ 消息实体存储库 • 设计:所有Topic的消息顺序混合追加写入。• 文件:以起始物理偏移量命名(20位数字),默认每个1GB。lock文件确保同一时刻只有一个进程写入,保障严格顺序写。
consumequeue/ 逻辑消费队列索引 • 结构:按 {Topic}/{QueueId}/三级目录组织。 • 文件:存储定长记录(20字节/条),包含物理偏移量、长度和Tag哈希码。 • 作用:为消费者提供按Topic和队列分组的逻辑视图,实现高效拉取。
index/ 消息键哈希索引 • 文件:以创建时间戳命名(如20240515080000000)。 • 结构:采用 “哈希槽 + 链表” 结构。 • 用途:支持根据 Message Key 或时间范围进行消息查询,用于运维排查。
config/ 运行时元数据 • 存储Broker运行期间生成的动态数据,如所有Topic的配置消费者组的消费进度(offset) 等。
checkpoint 状态检查点文件 • 记录 commitlog、consumequeue、index等文件最后一次刷盘的时间戳,用于崩溃恢复时确定数据恢复的起点。
abort 异常关闭标志文件 • 该文件存在即表明Broker上一次是非正常关闭,重启时会触发恢复流程。
lock 锁文件 • lock文件确保同一时刻只有一个进程写入,保障严格顺序写。

commitLog

消息主体以及元数据的存储主体, 存储Producer端写入的消息主体内容,消息内容不是定长的。 单个文件大小默认1G, 文件名长度为20位,左边补零,剩余为起始偏移量, 比如00000000000000000000代表了第一个文件,起始偏移量为0,文件大小为1G=1073741824;当第一个文件写满了,第二个文件为00000000001073741824,起始偏移量为1073741824,以此类推。消息主要是顺序写入日志文件,当文件满了,写入下一个文件;

当我们消息发送到RocketMQ以后,消息在commitLog中,因为body大小是不固定的,所以每个消息的长度也是不固定的,其存储格式如下:

下面每个表格列举了每个字段的含义

字段 字段名 数据类型 字节数 说明与用途
1 MsgLen / TOTALSIZE int 4 消息总长度,即从本字段开始到结束的总字节数,是解析消息的起点。
2 MagicCode int 4 魔术字,固定值(如 0xdaa320a7),用于标识这是一个有效的消息存储起始点,也用于区分消息体文件末尾空白填充区
3 BodyCRC int 4 消息体内容的CRC校验码, 用于校验消息体在存储过程中是否损坏。
4 QueueId int 4 队列ID,标识此消息属于Topic下的哪个逻辑队列。
5 Flag int 4 消息标志位,供应用程序自定义使用,RocketMQ内部未使用。
6 QueueOffset long 8 消费队列偏移量,即此消息在其对应ConsumeQueue中的顺序索引,是连续的
7 PhysicalOffset long 8 物理偏移量,即此消息在所有CommitLog文件中的起始字节偏移量。由于消息长度不定,此偏移量不是连续的
8 SysFlag int 4 系统标志位,是一个二进制组合值,用于标识消息特性,如:是否压缩、是否为事务消息、是否等待事务提交等。
9 BornTimestamp long 8 消息生成时间戳,由Producer客户端在发送时生成。
10 BornHost 8字节 8 消息发送者地址。其编码并非简单字符串,而是将IP的4个段和端口号的2个字节,共6个字节,按大端序组合并填充到8字节中。
11 StoreTimestamp long 8 消息存储时间戳,即Broker收到消息并写入内存的时间。
12 StoreHost 8字节 8 Broker存储地址,编码方式同BornHost。
13 ReconsumeTimes int 4 消息重试消费次数,用于死信队列判断。
14 PreparedTransationOffset long 8 事务消息专用,存储与之关联的事务日志(Transaction Log)的偏移量
15 BodyLength int 4 消息体实际长度,后跟Body内容。
16 Body byte[] 不定 消息体内容,即Producer发送的原始业务数据。
17 TopicLength byte 1 Topic名称的长度(1字节,因此Topic名不能超过255字符)。
18 Topic byte[] 不定 Topic名称的字节数组。
19 PropertiesLength short 2 消息属性长度,后跟Properties内容。
20 Properties byte[] 不定 消息属性,用于存储用户自定义的Key-Value扩展信息。在编码时,Key和Value之间用特殊不可见字符(如\u0001)分隔,因此属性中不能包含这些字符。

ConsumeQueue

消息消费索引,引入的目的主要是提高消息消费的性能。 由于RocketMQ是基于主题topic的订阅模式,消息消费是针对主题进行的,如果要遍历commitlog文件,根据topic检索消息是非常低效的。

为了解决这个问题中,提高消费时候的速度,RocketMQ会启动后台的 dispatch 线程源源不断的将消息从 commitLog 取出消息在 CommitLog 中的物理偏移量,消息长度以及 Tag Hash 等信息作为单条消息的索引,分发到对应的消费队列,构成了对 CommitLog 的引用。

consumer可根据ConsumeQueue来查找待消费的消息。其中,ConsumeQueue作为消费消息的索引,保存了指定Topic下的队列消息在CommitLog中的起始物理偏移量offset,消息大小size和消息Tag的HashCode值。

consumequeue文件可以看成是基于topic的commitlog索引文件, 故consumequeue文件夹的组织方式如下:

$HOME/store/consumequeue/{topic}/{queueId}/{fileName}

consumequeue文件采取定长设计, 每一个条目共20个字节,前8字节的commitlog物理偏移量、中间4字节的消息长度、8字节tag的hashcode。

indexFile

RocketMQ的IndexFile索引文件提供了通过消息Key或时间区间查询消息的能力,其存储路径为$HOME/store/index/{fileName},其中文件名以创建时间戳命名。单个IndexFile文件大小固定约为400M,可保存2000W个索引,其底层采用类HashMap的哈希索引结构实现。

IndexFile是一个固定大小的文件(约400MB),其物理结构由三部分组成

1.IndexHeader(索引头,40字节)

beginTimestamp: 第一条消息存储时间

endTimestamp: 最后一条消息存储时间

beginPhyoffset: 第一条消息在CommitLog中的物理偏移量

endPhyoffset: 最后一条消息在CommitLog中的物理偏移量

hashSlotCount: 已使用的哈希槽数量

indexCount: 索引单元总数

2.Slots(哈希槽)

每个IndexFile包含500万个哈希槽位,每个Slot槽位(4字节)存储的是链式索引的第一个索引序号,每个槽位可挂载多个索引单元,形成链式结构。

  • 如果Slot值为0:表示该槽位没有索引链
  • 如果Slot值为N:表示该槽位对应的索引链头节点索引序号为N

3.Indexes(索引单元,20字节/个)

每个索引单元包含以下字段:

  • keyHash: 消息Key的哈希值
  • phyOffset: 消息在CommitLog中的物理偏移量
  • timeDiff: 消息存储时间与IndexFile创建时间的差值
  • preIndexNo: 同一哈希槽中前一个索引单元的序号

这个结构和hashmap结构很像,但是支持每个key通过时间排序,就可以进行时间范围的检索。

通过定长索引结构和整体设计可以通过key快速定位索引数据,拿到真实数据的物理偏移量。

4.索引查询流程

消费者通过消息Key查询时,执行以下步骤:

  1. 计算槽位序号slot序号 = key哈希值 % 500万
  2. 定位槽位地址slot位置 = 40 + (slot序号 - 1) × 4
  3. 获取首个索引位置index位置 = 40 + 500万 × 4 + (索引序号 - 1) × 20
  4. 遍历索引链从槽位指向的索引开始,沿preIndexNo链式查找,匹配目标Key并校验时间范围
  5. 获取物理偏移量从匹配的索引单元中读取phyOffset,最终从CommitLog获取完整消息内容

通过此机制,IndexFile实现了基于Key的高效点查和基于时间范围的快速检索。

整体流程

RocketMQ 高性能存储的核心,在于其 “混合存储” 架构,这正是一种精妙的存储层读写分离设计。

其工作流程可以这样理解:

  1. 统一写入,保证极致性能: 所有消息顺序追加写入一个统一的 CommitLog 文件。这种单一的顺序写操作,是它能承受海量消息写入的根本。
  2. 异步构建,优化读取路径: 消息一旦持久化至 CommitLog,即视为安全。随后,后台服务线程会异步地构建出专供消费的 ConsumerQueue(逻辑队列索引)和用于查询的 IndexFile。这相当于为数据建立了高效的“目录”。
  3. 消费消息: 消费者实际拉取消息时,是先读取 ConsumerQueue 找到消息在 CommitLog 中的物理位置,再反查 CommitLog 获取完整消息内容。
  4. 可靠的消费机制: 基于上述持久化保障,配合消费者自身的偏移量管理及Broker的长轮询机制,共同实现了消息的可靠投递与高效获取。

这种 “读写分离” 设计的好处在于:将耗时的写操作(顺序写CommitLog)与复杂的读操作(构建索引、分散查询)解耦,让两者可以异步、独立地进行优化,从而在整体上获得更高的吞吐量和更低的延迟。这体现了“各司其职,异步协同”的经典架构思想。

下图是官方文档的流程图

写入流程

1.消息预处理

基础校验: 检查Topic名称、消息体长度等是否合法。

生成唯一ID: 结合Broker地址和CommitLog偏移量等,生成全局唯一的MsgID。

设置系统标志: 根据消息属性(如是否事务消息、是否压缩)设置SysFlag。

2.CommitLog核心写入

获取MappedFile: 根据当前写入位置,定位或创建对应的1GB内存映射文件。这里采用双重检查锁模式来保证性能和安全。

串行加锁写入: 获取全局或文件级锁(PutMessageLock),确保同一时刻只有一个线程写入文件,严格保证顺序性。

序列化与追加: 将消息按照之前分析的二进制协议, 序列化到MappedByteBuffer中,并更新写入指针。

3.刷盘(Flush)

同步刷盘: 消息写入内存映射区后,会创建一个GroupCommitRequest并放入请求组。写入线程会等待,直到刷盘线程完成该请求对应文件的物理刷盘后,才返回成功给Producer。数据最可靠,但延迟较高。

异步刷盘(默认): 消息写入内存映射区后,立即返回成功给Producer。同时唤醒异步刷盘线程, 该线程会定时或当PageCache中待刷盘数据积累到一定量时,执行一次批量刷盘。性能高,但有宕机丢数风险。

4.异步索引构建

由独立的ReputMessageService线程处理。它不断检查CommitLog中是否有新消息到达。

一旦有新消息被确认持久化(对于同步刷盘是已落盘,对于异步刷盘是已写入映射区),该线程就会读取消息内容。

随后,它会为这条消息在对应的consumequeue目录下构建消费队列索引(记录CommitLog物理偏移量和消息长度),更新index索引文件。

消费流程

1.启动与负载均衡

消费者启动后,会向NameServer获取Topic的路由信息(包含哪些队列、分布在哪些Broker上)。

如果消费者组内有多个实例,会触发队列负载均衡(默认策略是平均分配)。例如,一个Topic有8个队列,两个消费者实例,则通常每个消费者负责消费4个队列。这一步决定了每个消费者“认领”了哪些消息队列。

2.拉取消息循环

每个消费者实例内部都有一个PullMessageService线程,它循环从一个PullRequest队列中获取任务。

PullRequest包含了拉取目标(如Broker-A, 队列3)以及下一次要拉取的位点(offset)。

消费者向指定的Broker发送网络请求,请求体中就携带了这个offset。

3.Broker端处理与返回

Broker收到请求后,根据Topic、队列ID和offset,去查询对应的ConsumeQueue索引文件。

ConsumeQueue中存储的是定长(20字节)的记录,包含消息在CommitLog中的物理偏移量和长度。

Broker根据物理偏移量,从CommitLog文件中读取完整的消息内容,通过网络返回给消费者。

4.消息处理与位点提交

消费者将拉取到的消息提交到内部的消费线程池进行处理,你的业务逻辑就在这里执行。

消费位点的管理至关重要:

位点存储: 位点由OffsetStore管理。在集群模式(CLUSTER) 下,消费位点存储在Broker上;在广播模式(BROADCAST) 下,位点存储在本地。

位点提交: 消费成功后,消费者会异步(默认方式)向Broker提交已消费的位点。Broker将其持久化到store/config/consumerOffset.json文件中。

5.消息重试与死信

如果消息消费失败(抛出异常或超时未返回CONSUME_SUCCESS),RocketMQ会触发重试机制。

对于普通消息,消息会被发回Broker上一个特殊的重试主题(%RETRY%),延迟一段时间(延迟级别:1s、5s、10s…)后再被原消费者组拉取。

如果重试超过最大次数(默认16次),消息会被投递到死信主题(%DLQ%),等待人工干预。死信队列中的消息不会再被自动消费。

一体与分离:Kafka和RocketMQ的核心架构博弈

说起RocketMQ就不能不提起Kafka了,两者都是消息中间件这个领域的霸主,但它们的核心架构设计差异, 直接决定了各自不同的性能特性和适用场景,这也是技术选型时必须深入理解的重点。

核心架构设计差异

Kafka:读写一体的“分区日志”模型, Kafka的架构哲学是极简与统一。 它将每个主题分区抽象为一个仅追加(append-only)的物理日志文件。 生产者和消费者都直接与这个日志文件交互:生产者顺序写入尾部,消费者通过维护偏移量顺序读取。这种设计下,数据的读写路径完全一致, 逻辑与物理结构高度统一。

RocketMQ:读写分离的“二级制”模型 , RocketMQ的架构哲学是分工与优化。 它采用了物理CommitLog + 逻辑ConsumeQueue的二级结构。 所有消息都顺序写入一个统一的CommitLog物理文件,实现磁盘的最高效顺序写。同时,为每个消息队列异步构建一个轻量级的ConsumeQueue索引文件,消费者读取时先查询内存中的ConsumeQueue定位,再到CommitLog中获取消息体。这是一种逻辑与物理分离的设计。

优劣势对比

基于上述架构设计根本差异,两者在关键指标上各显优劣:

维度 Kafka(读写一体) RocketMQ(读写分离)
核心优势 极致吞吐与低延迟:读写同路径,数据写入后立即可读,端到端延迟极低。架构简单:无中间状态,副本同步、故障恢复逻辑清晰。 高并发读与丰富功能:索引与数据分离,支持海量消费者并发读。业务友好:原生支持事务消息、定时/延时消息、消息轨迹查询。
存储效率 磁盘顺序IO最大化:生产和消费都是严格顺序IO,尤其适合机械硬盘。 写性能极致化:所有消息顺序写CommitLog,但存在“写放大” ,一条消息需写多次(1次CommitLog + N次ConsumeQueue)。
读性能 消费者落后时可能触发随机读:若消费者要读取非尾部历史数据,可能需磁盘寻道。但现代SSD和预读机制已大大缓解此问题。 读路径优化:ConsumeQueue小而固定,可全量缓存至内存,读操作变为“内存寻址 + CommitLog顺序/随机读”。在PageCache命中率高时表现优异。
扩展性与成本 文件句柄(inode)开销大:每个分区都是独立目录和文件,海量分区时运维成本高。 存储成本与效率更优:多Topic共享CommitLog,文件数少,特别适合中小消息体、多Topic的场景
典型场景 日志流、指标监控、实时流处理:作为大数据管道,与Flink/Spark生态无缝集成。 电商交易、金融业务、异步解耦:需要严格顺序、事务保障、业务查询的在线业务场景。

总而言之,Kafka像一个设计精良的高速公路系统, 核心目标是让数据车辆(消息)能够高吞吐、低延迟地持续流动,并方便地引向各个处理工厂(流计算)。而RocketMQ则像一个高度可靠的快递网络, 不仅确保包裹(消息)准确送达,还提供预约配送(定时)、签收确认(事务)、异常重投(重试)等一系列服务于业务逻辑的增值功能。

RocketMQ对于随机读取的优化

RocketMQ在消费时候的流程

消费者请求 → ConsumeQueue(内存/顺序)获取commitlog上的物理偏移量 → 根据物理偏移量定位CommitLog(磁盘/随机) → 返回消息

从ConsumeQueue获取到消息在commitlog中的偏移量的时候,回查时候可能产生随机IO

  1. 第一次随机IO: 根据ConsumeQueue中的物理偏移量,在CommitLog中定位消息位置
  2. 可能的连续随机IO: 如果一次拉取多条消息,这些消息在CommitLog中可能物理不连续

为了保证RocketMQ的高性能,采用一些优化措施,尽量避免随机IO

1. ConsumeQueue的内存映射优化

实际上,RocketMQ将ConsumeQueue映射到内存,每个ConsumeQueue约5.72MB,可完全放入PageCache,读索引操作几乎是内存操作。

public class ConsumeQueue {
    private MappedFile mappedFile;  // 内存映射文件
    // 20字节每条:8(offset) + 4(size) + 8(tagHashCode)
}

2. PageCache的充分利用

Linux PageCache工作流程: 

  1. 消息写入CommitLog → 进入PageCache
  2. 消费者读取 → 优先从PageCache获取
  3. 如果PageCache命中:内存速度(≈100ns)
  4. 如果PageCache未命中:磁盘随机读取(≈10ms)

3. 批量读取优化

// DefaultMessageStore.java
public GetMessageResult getMessage(...) {
    // 一次读取多条消息(默认最多32条)
    // 即使这些消息物理不连续,通过批量读取减少IO次数
    for (int i = 0; i < maxMsgNums; i++) {
        // 使用同一个文件channel批量读取
        readMessage(ctx, msgId, consumerGroup);
    }
}

4. 读取顺序性的保持

虽然CommitLog中不同Topic的消息是随机存放的,但同一个Queue的消息在CommitLog中是基本连续的:

Queue1: | Msg1 | Msg3 | Msg5 | ... | 在ConsumeQueue中连续
        ↓      ↓      ↓
CommitLog: | Msg1 | Msg2(T2) | Msg3 | Msg4(T3) | Msg5 |
          ↑_________________________↑
          物理上相对连续,减少磁头寻道

高可用设计:双轨并行的可靠性架构

主从架构(Master-Slave)

经典主从模式: RocketMQ早期采用Master-Slave架构,Master处理所有读写请求,Slave仅作为热备份。这种模式下,故障切换依赖人工干预或半自动脚本, 恢复时间通常在分钟级别。

Dledger高可用集群: RocketMQ 4.5引入的Dledger基于Raft协议实现真正的主从自动切换。 当Master故障时,集群能在秒级(通常2-10秒)内自动选举新Leader,期间消息仍可写入(写入请求会阻塞至新Leader选出)。

多副本机制: 现代部署中,建议采用2主2从或3主3从架构。例如在阿里云上,每个Broker组包含1个Master和2个Slave,形成跨可用区的三副本, 单机房故障不影响服务可用性。

同步/异步复制

同步复制保证强一致(消息不丢失),异步复制追求更高性能。

// Broker配置示例
brokerRole = SYNC_MASTER
// 生产者发送消息后,必须等待至少一个Slave确认
// 确保即使Master宕机,消息也不会丢失
  • 强一致性保证:消息写入Master后,同步复制到Slave才返回成功
  • 性能代价:延迟增加约30-50%,TPS下降约20-40%
  • 适用场景:金融交易、资金变动等对数据一致性要求极高的业务

同步/异步刷盘

同步刷盘保证消息持久化不丢失,异步刷盘提升吞吐。

brokerRole = ASYNC_MASTER
// 消息写入Master即返回成功,Slave异步复制
// 存在极短时间的数据丢失风险
  • 高性能模式: 延迟降低,吞吐量接近单节点性能
  • 风险窗口: Master宕机且数据未同步时,最近几秒消息可能丢失
  • 适用场景: 日志收集、监控数据、可容忍微量丢失的业务消息

刷盘策略的工程优化

同步刷盘(SYNC_FLUSH)

生产者 → Broker内存 → 磁盘强制刷盘 → 返回成功
  • 零数据丢失: 即使机器掉电,消息也已持久化到磁盘
  • 性能瓶颈: 每次写入都触发磁盘IO,机械硬盘下TPS通常<1000
  • 优化手段: 使用SSD硬盘可大幅提升性能

异步刷盘(ASYNC_FLUSH)

生产者 → Broker内存 → 立即返回成功 → 异步批量刷盘
  • 高性能选择: 依赖PageCache,SSD下TPS可达数万至数十万
  • 可靠性依赖: 依赖操作系统的刷盘机制(通常5秒刷盘一次)
  • 配置调优:
# 调整刷盘参数
flushCommitLogLeastPages = 4    # 至少4页(16KB)才刷盘
flushCommitLogThoroughInterval = 10000  # 10秒强制刷盘一次

四、Producer与Consumer:高效的生产与消费模型

Producer

消息路由策略:

// 内置多种队列选择算法
DefaultMQProducer producer = new DefaultMQProducer("ProducerGroup");
// 1. 轮询(默认):均匀分布到所有队列
// 2. 哈希:相同Key的消息路由到同一队列,保证局部顺序
// 3. 机房就近:优先选择同机房的Broker
producer.send(msg, new MessageQueueSelector() {
    @Override
    public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
        // 自定义路由逻辑
        return mqs.get(arg.hashCode() % mqs.size());
    }
});

发送模式对比:

模式 特点 性能 适用场景
同步发送 阻塞等待Broker响应 TPS约5000-20000 重要业务消息,需立即知道发送结果
异步发送 回调通知结果 TPS可达50000+ 高并发场景,如日志、监控数据
单向发送 发送后不等待 TPS最高(100000+) 可容忍少量丢失的非关键数据

失败重试与熔断:

  • 智能重试: 发送失败时自动重试(默认2次),可配置退避策略
  • 故障规避: 自动检测Broker可用性,故障期间路由到健康节点
  • 慢请求熔断: 统计发送耗时,自动隔离响应慢的Broker

Consumer

负载均衡策略:

// 集群模式:同一ConsumerGroup内消费者均分队列
consumer.setMessageModel(MessageModel.CLUSTERING);
// 广播模式:每个消费者消费全量队列
consumer.setMessageModel(MessageModel.BROADCASTING);

消费进度管理:

Broker托管: 默认方式,消费进度存储在Broker

本地维护: 某些场景下可自主管理offset(如批量处理)

重置策略:

// 支持多种消费起点
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_LAST_OFFSET);  // 从最后
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET); // 从头
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_TIMESTAMP);    // 从时间点

并发控制优化:

// 关键并发参数
consumer.setConsumeThreadMin(20);     // 最小消费线程数
consumer.setConsumeThreadMax(64);     // 最大消费线程数
consumer.setPullBatchSize(32);        // 每次拉取消息数
consumer.setConsumeMessageBatchMaxSize(1); // 批量消费大小
// 流控机制
consumer.setPullThresholdForQueue(1000);  // 队列堆积阈值
consumer.setPullInterval(0);              // 拉取间隔(0为长轮询)

五、核心流程与特性背后的架构支撑

1 .顺序消息如何保证?

全局顺序: 单Topic单队列(牺牲并发)。

分区顺序: 通过MessageQueue选择器确保同一业务键(如订单ID)的消息发往同一队列,Consumer端按队列顺序消费。

2.事务消息的两阶段提交

流程详解: Half Message -> 执行本地事务 -> Commit/Rollback。

架构支撑: Op消息回查机制,解决分布式事务的最终一致性,是架构设计中“状态可回溯”思想的体现。

3.延时消息的实现奥秘

并非真正延迟投递: 为不同延迟级别预设独立的SCHEDULE_TOPIC, 定时任务扫描到期后投递至真实Topic。

设计权衡: 以存储和计算换取功能的灵活与可靠。

六、其他性能优化关键技术点

  1. 零拷贝(Zero-copy): 通过sendfile或mmap+write方式,减少内核态与用户态间数据拷贝,大幅提升网络发送与文件读写效率。
  2. 堆外内存与内存池: 避免JVM GC对大数据块处理的影响,实现高效的内存管理。
  3. 文件预热: 启动时将存储文件映射到内存并写入“假数据”,避免运行时缺页中断。

七、总结:RocketMQ架构设计的启示

RocketMQ的架构设计,尤其是其在简洁性、高性能和云原生演进方面的平衡,为构建现代分布式系统提供了许多宝贵启示。

  1. 在简单与完备间权衡: RocketMQ没有采用强一致性的ZooKeeper,而是自研了极其简单的NameServer。这说明在非核心路径上,牺牲一定的功能完备性来换取简单性和高可用性,可能也是个不错的选择。
  2. 以写定存储,以读优查询: 其存储架构是典型的写优化设计。所有消息顺序追加写入,保证了最高的写入性能。而针对消费和查询这两种主要的“读”场景,则分别通过异步构建索引数据结构(ConsumeQueue和IndexFile)来优化。

八、参考资料

往期回顾

1.PAG在得物社区S级活动的落地

2.Ant Design 6.0 尝鲜:上手现代化组件开发|得物技术 

3.Java 设计模式:原理、框架应用与实战全解析|得物技术

4.Go语言在高并发高可用系统中的实践与解决方案|得物技术

5.从0到1搭建一个智能分析OBS埋点数据的AI Agent|得物技术

文 /磊子

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

百度一站式全业务智能结算中台

作者 百度Geek说
2025年12月23日 16:41

导读

本文深入介绍了百度一站式全业务智能结算中台,其作为公司财务体系核心,支撑多业务线精准分润与资金流转。中台采用通用化、标准化设计,支持广告、补贴、订单等多种结算模式,实现周结与月结灵活管理。通过业务流程标准化、分润模型通用化及账单测算自动化,大幅提升结算效率与准确性,确保数据合规与业务稳健发展。未来,中台将推进全业务线结算立项线上化、数据智能分析,进一步提升数据分析智能化水平,为公司业务发展提供坚实保障。

01 概述

结算中台作为公司财务体系的核心组成部分,承担着多业务线分润计算、结算及资金流转的关键职能。采用通用化、标准化的设计理念,结算中台能够高效支撑公司内数十个业务线的分润需求,确保广告收入、订单收入、内容分发的精准结算,为公司的财务健康与业务稳健发展提供坚实保障。结算中台建设的核心目标是: 构建高效、标准化、智能化的结算中台体系,支撑多业务线分润计算与资金流转,确保结算数据准确性、高时效披露及业务快速迭代能力,同时降低运维复杂度,推动全业务线结算线上化管理。

结算中台已对接了百家号业务、搜索业务、智能体业务、小说等多个业务线的结算需求, 支持广告分润、补贴分润、订单分润三种结算模式。不同业务线根据各自的业务场景使用不同的结算模式,确保每个业务的收益分配准确无误。结算中台功能分层如图:

图片

02 基本功能

1. 结算模式

结算中台支持三种结算模式,以适应不同业务场景的结算需求:

  • 订单结算:基于直接订单数据,按照订单实际金额与分成策略进行分润计算。

  • 补贴结算:针对特定业务或用户群体,提供额外的收益补贴,以增强业务的市场竞争力。

  • 广告结算:根据分发内容的广告变现与渠道分成比例,精确计算媒体与内容的实际收益。

2. 结算能力

结算中台支持周结与月结两种结算能力:

  • 周结:适用于需要快速资金回笼的业务场景,比如短剧快速回款以后能够再次用于投流, 确保资金流转的高效性。

  • 月结:作为默认的结算周期,便于公司进行统一的财务管理与账务处理。

3. 账单测算自动化

结算中台支持重点业务账单自动测算,通过预设的分润模型,自动计算每个渠道、每位作者的应得收益,生成测算报告。这一自动化过程显著提升工作效率,减少人为错误,确保结算数据的绝对准确。

03 需求分析

在推进公司结算业务时,我们致力于实现统一化、标准化,规范业务流程,并确保数据合规化治理,我们面临着诸多问题与挑战,具体表现如下:

1. 流程与规范缺失

  • 结算流程管理混乱:存在结算需求未备案即已上线的情况,或者备案内容与实际实现不一致,甚至缺乏备案流程。

  • 日志规范陈旧:广告分润场景中,内容日志打点冗余,同时缺少扩展性,导致对新的业务场景无法很好兼容。

2. 烟囱式开发成本高

  • 标准化与统一化需求迫切:之前,各个结算业务维护各自的结算系统,涉及不同的技术栈和结算模型,线下、线上结算方式并存,导致人工处理环节多,易出错,case多,管理难度大。为提高效率,需实现结算业务的标准化与统一化,并拓展支持多种业务结算模式。

  • 分润模型通用化设计:多数业务结算方式相同,同时账单计算逻辑也相似或者相同,没有必要每个业务设计一套逻辑,需要做通用化设计。

3. 业务迭代中的新诉求

  • 测算系统需求凸显:在业务快速迭代的过程中,许多业务希望尽快看到结算效果,以推进项目落地。因此,构建高效的测算系统成为迫切需求,以加速业务迭代和决策过程。

  • 提升作者体验:为提升作者等合作伙伴的满意度和忠诚度,结算数据需实现高时效披露,确保他们能及时、准确地获取收益信息。结算账单数据的产出依赖百余条数据源,要保证数据在每天12点前产出,困难重重

  • 数据校验与监控机制:结算数据的准确性和质量直接关系到公司的财务健康和业务发展。因此,需建立完善的数据校验和监控机制,确保结算数据的准确无误和高质量。

04 技术实现

根据结算中台建设的核心目标,结合业务痛点,在结算系统建设中,基于通用化、标准化的理念,从以下五个方面来搭建统一的、规范化的结算中台。

  • 业务流程标准化:建设一套标准来定义三类结算模式下每个数据处理环节的实现方式,包括业务处理流程、数据处理过程。

  • 分润模型通用化:实现不同的账单计算算法,支持各个业务的各类作者收入分配诉求,并且实现参数配置线上化。

  • 技术架构统一:统一整个结算业务的技术栈、部署环境、功能入口和数据出口。

  • 建设账单测算能力:模拟线上结算流程的账单测算能力,支持业务快速验证分润模型参数调整带来的作者收入影响效果。

  • 建设质量保证体系:建设全流程预警机制,通过日志质检、自动对账、数据异常检测来保障账单产出数据时效性、准确性。

1. 业务流程标准化

不同业务场景,采用了通用化、标准化的设计来满足业务的特异性需求,下面是三大结算模式业务流程简图:

图片

在广告模式、补贴模式、订单模式结算流程设计中, 从日志打点、线上化、计算逻辑等方向考虑了通用化、标准化设计, 具体如下:

(1) 日志打点统一化

统一日志标准, 针对业务日志规范陈旧问题,要求所有接入的业务方严格按照统一格式打点日志,删除冗余字段, 确保数据的规范性与一致性,同时保证设计能够覆盖所有业务场景,为后续处理奠定坚实基础。

针对某些业务定制化的需求, 在广告模式、补贴模式、订单模式三种结算方式中,在设计日志打点规范时, 会预留一些扩展字段, 使用时以 JSON 形式表示, 不使用时打默认值。

(2) 账单计算线上化

在补贴结算模式中,之前不同业务都有各自的账单格式设计,同时存在离线人工计算账单的非规范化场景,账单无法统一在线计算、存储、监管。新的结算中台的补贴结算模式,将所有离线结算模式,使用统一的账单格式,全部实现线上化结算,实现了业务结算流程规范化。

(3) 账单计算逻辑优化

比如在广告模式中,百家号业务的公域视频、图文、动态场景中,由于收入口径调整,迭代效率要求,不再需要进行广告拼接,所以专门对账单计算流程做了优化调整。不仅满足业务诉求,同时做了通用化设计考虑,保证后续其他业务也可以使用这套流程的同时, 也能兼容旧的业务流程。

广告模式结算流程优化前:

图片

广告模式结算流程优化后:

图片

2. 分润模型通用化

不同业务场景,不同结算对象,有不同的结算诉求,不仅要满足业务形态多样化要求,还要具有灵活性。因此抽取业务共性做通用性设计,同时通过可插拔式设计灵活满足个性化需求。

图片

(1) 基于流量变化模型

以合作站点的优质用户投流方为代表的用户,他们在为百度提供海量数据获得收益的同时,也有自己的诉求,那就是自己内容的收益不能受到其他用户内容的影响。自己优质内容不能被其他用户冲淡,当然自己的低质内容也不会去拉低别人的收益水平。

对于此部分用户我们提供“基于流量变现的分润”策略,简单来说就是,某一篇内容的收益仅仅由它自己内容页面挂载的广告消费折算而来,这样就保证了优质用户投流方收益的相对独立,也促使优质用户产出更加多的内容。

(2) 基于内容分发模型

  • 部分作者只关注收益回报: 对百家号的某些作者来说,他们的目的很单纯,他们只关注产出的内容是否获得具有竞争力的收益回报,至于收益怎么来他们并不关心。

  • “基于流量变现”策略不妥此时,我们再使用“基于流量变现”的策略的话,就有些不妥,举个极端里的例子,有一个作者比较倒霉,每次分发都没有广告的渲染,那他是不是颗粒无收?这对作者是很不友好的。

  • “基于内容分发的分润”模型: 基于收益平衡性考虑,我们推出了更加适合百家号用户的“基于内容分发的分润”模型。在这种模型下,只要内容有分发,就一定有收益,而不管本身是否有广告消费。

  • 策略平衡考虑: 当然,为了防止海量产出低质内容来刷取利润,在分润模型上,我们同时将内容质量分和运营因子作为分润计算的权重,也就是说作者最终的收益由内容的质量和内容的分发量共同决定,以达到通过调整分润来指导内容产出的目的。

(3) 基于作者标签模型

为了实现对百家号头部优质作者进行激励,促进内容生态良性发展, 会对不同的作者进行打标, 并且使用不同的分润模型, 比如对公域的百家号作者进行打标, 优质作者, 通过动态单价及内容质量权重策略来给到他们更加的分成, 其他的普通作者, 通过内容分发模型来分润。这样不仅保证了优质作者取得高收益,也保证了其他作者也有一定的收益

另外,出于对预算的精确控制,发挥每一笔预算的钱效,优质的作者会占用较大的预算资金池,而普通作者使用占用较少的预算资金池。同时也会对每类资金池进行上下限控制,保证预算不会花超。

(4) 基于运营场景模型

为了实现对百家号作者的精细化运营,比如对一些参与各类短期活动的作者给予一定的阶段性的奖励,可以通过补贴模型来实现。在一些运营活动中,需要控制部分作者的分成上限,分润模型会进行多轮分成计算,如果作者的收益未触顶并且资金池还有余额的情况下,会对余额进行二次分配,给作者再分配一些收益。此类模型主要是为了实现灵活多变的作者分润策略。

3. 技术架构统一

根据业务流程标准化、分润模型通用化的设计原则,建设统一的结算中台。以下是结算中台统一结算业务前后的对比:

图片

图片

4. 建设账单测算能力

为各个需要测算能力的业务,设计了一套通用的测算流程,如下图:

图片

针对每个测算业务,设计了独立的测算参数管理后台,用于管理业务相关的分润模型参数,如下图:

图片

测算流程设计

(1) 功能简述: 每个测算业务, 产品需要登录模型参数管理后台,此后台支持对分润模型参数进行创建、查看、编辑、测算、复制、上线、删除,以及查看测算结果等操作, 出于业务流程合规化的要求, 每次模型参数上线前, 需要对变更的参数完成线上备案流程才可以上线,实现分润流程合规线上化。

(2) 流程简述

  • 流程简述概览: 每次测算时, 产品需要先创建一个版本的账单模型测算参数,并发起参数测算,参数状态变成待测算 。

  • 离线任务与收益计算: 此后,离线任务会轮询所有的待测算参数,提交Spark任务,调用账单计算模型来计算作者收益,最后生成TDA报告。

  • 查看与评估测算报告: 产品在管理平台看到任务状态变成测算完成时, 可以点击 TDA 链接来查看测算报告, 评估是否符合预期。

  • 根据预期结果的操作:如果不符合预期,可以编辑参数再次发起测算;如果符合预期,则可以发起备案流程,流程走完后可以提交上线。

(3) 收益明显: 通过账单测算建设, 不仅解决结算需求未备案即已上线或者备案内容与实际实现不一致,甚至缺乏备案流程的业务痛点问题,  而且把业务线下账单计算的流程做到了线上, 做到留痕可追踪。同时也满足了业务高效迭代的诉求, 一次账单测算耗时从半天下降到分钟级, 大大降低了账单测算的人力成本与时间成本。

5. 建设质量保障体系

为了保证业务质量,从以下几方面来建设:

(1) 建设数据预警机制:为保证作者账单数据及时披露, 分润业务涉及的 百余条数据源都签订了 SLA, 每份数据都关联到具体的接口人, 通过如流机器人来监控每个环节的数据到位时间, 并及时发出报警信息, 并推送给具体的接口负责人。对于产出延迟频次高的数据流,会定期拉相关负责人相关复盘,不断优化数据产出时效,保证账单数据在每天如期产出

(2) 数据异常检测机制:对账单数据进行异常波动性检测, 确保数据准确性 ,及时发现并处理潜在异常问题

(3) 自动对账机制:每天自动进行上下游系统间账单明细核对,保证出账数据流转的准确无误。

(4) 日志质检机制:每日例行对日志进行全面质检分析, 及时发现日志打点日志。

05 中台收益

结算中台作为公司财务体系的核心,承担多业务线分润计算与资金流转重任。

(1) 通过通用化、标准化设计,高效支撑数十个业务线的精准结算,确保广告、订单、内容分发的业务结算稳定、健康。近一年,结算业务零事故、零损失。

(2) 中台支持多种结算模式与灵活周期管理,实现账单测算自动化,账单测算时间从天级降到小时级。提升效率并减少错误,提升业务需求迭代效率。

(3) 通过业务流程标准化、分润模型通用化、账单测算能力建设及质量保证体系,解决了结算业务规范缺失、业务形态多样等问题。累计解决历史结算case数十个,涉及结算金额达千万级。

未来,结算中台将推进全业务线结算立项线上化、周结与测算能力落地、项目全生命周期管理,并依托大模型能力实现数据智能分析,进一步提升数据分析智能化水平,为公司业务稳健发展提供坚实保障。

06 未来规划

1、推进全业务线结算实现立项线上化;

2、推进周结 、测算能力在各业务线上落地;

3、推进项目全生命周期管理,实现项目从上线到下线整体生命周期变化线上化存档,可随时回顾复盘。

4、数据智能分析,依托公司大模型能力,实现通过多轮对话问答来进行数据分析,针对业务问题进行答疑解惑,提升数据分析的智能化水平。

Java 设计模式:原理、框架应用与实战全解析|得物技术

作者 得物技术
2025年12月18日 14:03

一、概述

简介

设计模式(Design Pattern)是前辈们对代码开发经验的总结,它不是语法规定,是解决特定问题的一系列思想,是面向对象设计原则的具象化实现, 是解决 “需求变更” 与 “系统复杂度” 矛盾的标准化方案 —— 并非孤立的 “代码模板”,而是 “高内聚、低耦合” 思想的落地工具。其核心价值在于提升代码的可复用性、可维护性、可读性、稳健性及安全性。

1994 年,GoF(Gang of Four:Erich Gamma、Richard Helm、Ralph Johnson、John Vlissides)合著的《Design Patterns - Elements of Reusable Object-Oriented Software》(中文译名《设计模式 - 可复用的面向对象软件元素》)出版,收录 23 种经典设计模式,奠定该领域的行业标准,即 “GoF 设计模式”。

核心思想

  • 对接口编程,而非对实现编程
  • 优先使用对象组合,而非继承
  • 灵活适配需求:简单程序无需过度设计,大型项目 / 框架必须借助模式优化架构

组件生命周期

模式类型 核心关注点 生命周期阶段 代表模式
创建型模式 对象创建机制 (解耦创建与使用) 组件的创建 单例、工厂方法、抽象工厂、原型、建造者
结构型模式 对象 / 类的组合方式 组件的使用 代理、适配器、装饰器、外观、享元、桥接、组合、过滤器
行为型模式 对象 / 类的运行时协作流程 组件的交互与销毁 策略、观察者、责任链、模板方法、命令、状态、中介者、迭代器、访问者、备忘录、解释器

七大设计原则

原则名称 核心定义 关联模式 实际开发决策逻辑
开闭原则(OCP) 对扩展开放,对修改关闭 (新增功能通过扩展类实现,不修改原有代码) 所有模式的终极目标 新增需求优先考虑 “加类”,而非 “改类”
依赖倒转原则(DIP) 依赖抽象而非具体实现 (面向接口编程,不依赖具体类) 工厂、策略、桥接 类的依赖通过接口注入,而非直接 new 具体类
合成复用原则(CRP) 优先使用组合 / 聚合,而非继承 (降低耦合,提升灵活性) 装饰器、组合、桥接 复用功能时,先考虑 “组合”,再考虑 “继承”
单一职责原则(SRP) 一个类仅负责一项核心职责 (避免 “万能类”) 策略、适配器、装饰器 当一个类有多个修改原因时,立即拆分
接口隔离原则(ISP) 使用多个专用接口替代单一万能接口 (降低类与接口的耦合) 适配器、代理 接口方法拆分到 “最小粒度”,避免实现类冗余
里氏代换原则(LSP) 子类可替换父类,且不破坏原有逻辑 (继承复用的核心前提) 模板方法、策略 子类重写父类方法时,不能改变父类契约
迪米特法则(LOD) 实体应尽量少与其他实体直接交互 (通过中间者解耦) 中介者、外观、责任链 两个无直接关联的类,通过第三方间接交互

二、原理与框架应用

创建型模式

为什么用创建型模式?

  • 创建型模式关注点“怎样创建出对象?”“将对象的创建与使用分离”
  • 降低系统的耦合度
  • 使用者无需关注对象的创建细节
  • 对象的创建由相关的工厂来完成;(各种工厂模式)
  • 对象的创建由一个建造者来完成;(建造者模式)
  • 对象的创建由原来对象克隆完成;(原型模式)
  • 对象始终在系统中只有一个实例;(单例模式)

创建型模式之单例模式

单例模式(Singleton Pattern)是 Java 中最简单的设计模式之一,提供了一种创建对象的最佳方式。这种模式涉及到一个单一的类,该类负责创建自己的对象,同时确保只有单个对象被创建。这个类提供了一种访问其唯一的对象的方式,可以直接访问,不需要实例化该类的对象。

意图: 保证一个类仅有一个实例,并提供一个访问它的全局访问点。

主要解决: 一个全局使用的类频繁地创建与销毁。

何时使用: 当您想控制实例数目,节省系统资源的时候。

如何解决: 判断系统是否已经有这个单例,如果有则返回,如果没有则创建。

优点:

1、在内存里只有一个实例,减少了内存的开销,尤其是频繁的创建和销毁实例(比如首页页面缓存)。

2、避免对资源的多重占用(比如写文件操作)。

缺点:

没有接口,不能继承,与单一职责原则冲突,一个类应该只关心内部逻辑,而不关心外面怎么样来实例化。

使用场景:

1、要求生产唯一序列号。

2、多线程中的线程池。

3、创建的一个对象需要消耗的资源过多,比如 I/O 与数据库的连接等。

4、系统环境信息(System.getProperties())。

单例模式四种实现方案

饿汉式

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
 * 饿汉式单例(线程安全)
 * 核心原理:依赖类加载机制(JVM保证类初始化时线程安全)
 * 适用场景:实例占用资源小、启动时初始化可接受的场景
 */
public class LibifuTestSingleton {
    private static final Logger log = LoggerFactory.getLogger(LibifuTestSingleton.class);


    // 类加载时直接初始化实例(无延迟加载)
    private static final LibifuTestSingleton INSTANCE = new LibifuTestSingleton();
    // 私有构造器(禁止外部实例化)
    private LibifuTestSingleton() {
        log.info("LibifuTestSingleton 实例初始化完成");
    }
    // 全局访问点(无锁,高效)
    public static LibifuTestSingleton getInstance() {
        return INSTANCE;
    }
    // 业务方法示例
    public void doBusiness() {
        log.info("饿汉式单例(LibifuTestSingleton)执行业务逻辑");
    }
}

懒汉式

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
 * 懒汉式单例(线程安全)
 * 核心原理:第一次调用时初始化,synchronized保证线程安全
 * 适用场景:实例使用频率极低、无性能要求的场景
 */
public class LibifuTestLazySingleton {
    private static final Logger log = LoggerFactory.getLogger(LibifuTestLazySingleton.class);


    // 私有静态实例(初始为null,延迟加载)
    private static LibifuTestLazySingleton instance;
    // 私有构造器(禁止外部实例化)
    private LibifuTestLazySingleton() {
        log.info("LibifuTestLazySingleton 实例初始化完成");
    }
    // 同步方法(保证多线程下唯一实例)
    public static synchronized LibifuTestLazySingleton getInstance() {
        if (instance == null) {
            instance = new LibifuTestLazySingleton();
        }
        return instance;
    }
    // 业务方法示例
    public void doBusiness() {
        log.info("懒汉式单例(LibifuTestLazySingleton)执行业务逻辑");
    }
}

双检锁 (DCL,JDK1.5+)

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
 * 双检锁单例(线程安全,高效)
 * 核心原理:volatile禁止指令重排序,双重校验+类锁保证唯一性
 * 适用场景:大多数高并发场景
 */
public class LibifuTestDclSingleton {
    private static final Logger log = LoggerFactory.getLogger(LibifuTestDclSingleton.class);


    // volatile关键字:禁止instance = new LibifuTestDclSingleton()指令重排序
    private volatile static LibifuTestDclSingleton instance;
    // 私有构造器(禁止外部实例化,含防反射攻击)
    private LibifuTestDclSingleton() {
        log.info("LibifuTestDclSingleton 实例初始化完成");
        // 防反射攻击:若实例已存在,直接抛出异常
        if (instance != null) {
            throw new IllegalStateException("单例实例已存在,禁止重复创建");
        }
    }
    // 全局访问点(双重校验+类锁,兼顾线程安全与效率)
    public static LibifuTestDclSingleton getInstance() {
        // 第一次校验:避免频繁加锁(提高效率)
        if (instance == null) {
            // 类锁:保证同一时刻只有一个线程进入实例创建逻辑
            synchronized (LibifuTestDclSingleton.class) {
                // 第二次校验:确保唯一实例(防止多线程并发绕过第一次校验)
                if (instance == null) {
                    instance = new LibifuTestDclSingleton();
                }
            }
        }
        return instance;
    }
    // 防序列化漏洞:反序列化时返回已有实例(而非创建新实例)
    private Object readResolve() {
        return getInstance();
    }
    // 业务方法示例
    public void doBusiness() {
        log.info("双检锁单例(LibifuTestDclSingleton)执行业务逻辑");
    }
}

枚举单例(JDK1.5+)

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
 * 枚举单例(天然线程安全、防反射、防序列化)
 * 核心原理:枚举类的实例由JVM管理,天然唯一
 * 适用场景:安全性要求极高的场景(如配置中心、加密工具类)
 */
public enum LibifuTestEnumSingleton {
    INSTANCE;
    private static final Logger log = LoggerFactory.getLogger(LibifuTestEnumSingleton.class);
    // 枚举构造器(默认私有,无需显式声明)
    LibifuTestEnumSingleton() {
        log.info("LibifuTestEnumSingleton 实例初始化完成");
    }
    // 业务方法示例
    public void doBusiness() {
        log.info("枚举单例(LibifuTestEnumSingleton)执行业务逻辑");
    }
}

框架应用

Spring 框架中 Bean 默认作用域为singleton(单例),核心通过AbstractBeanFactory类的缓存机制 + 单例创建逻辑实现 —— 确保每个 Bean 在 Spring 容器中仅存在一个实例,且由容器统一管理创建、缓存与销毁,降低对象频繁创建销毁的资源开销,契合单例模式 “唯一实例 + 全局访问” 的核心思想。

核心逻辑:Bean 创建后存入singletonObjects(单例缓存池),后续获取时优先从缓存读取,未命中则触发创建流程,同时通过同步机制保证多线程安全。

以下选取AbstractBeanFactory中实现单例 Bean 获取的核心代码片段:

// 1. 对外暴露的获取Bean的公共接口,接收Bean名称参数
@Override
public Object getBean(String name) throws BeansException {
    // 2. 委托doGetBean方法实现具体逻辑,参数分别为:Bean名称、所需类型(null表示不指定)、构造参数(null)、是否仅类型检查(false)
    return doGetBean(name, nullnullfalse);
}
// 3. 核心获取Bean的实现方法,泛型T保证类型安全
@SuppressWarnings("unchecked")
protected <T> T doGetBean(
        String name, Class<T> requiredType, Object[] args, boolean typeCheckOnly) throws BeansException {
    // 4. 处理Bean名称:转换别名、去除FactoryBean前缀(如&),得到原始Bean名称
    String beanName = transformedBeanName(name);
    // 5. 从单例缓存中获取Bean实例(核心:优先复用已有实例)
    Object sharedInstance = getSingleton(beanName);
    // 6. 缓存命中(存在单例实例)且无构造参数(无需重新创建)
    if (sharedInstance != null && args == null) {
        // 7. 处理特殊Bean(如FactoryBean):如果是FactoryBean,返回其getObject()创建的实例,而非FactoryBean本身
        T bean = (T) getObjectForBeanInstance(sharedInstance, name, beanName, null);
    } else {
        // 8. 缓存未命中或需创建新实例(非单例、原型等作用域)的逻辑(此处省略,聚焦单例)
    }
    // 9. 返回最终的Bean实例(类型转换后)
    return (T) bean;
}
// 10. 从单例缓存中获取实例的核心方法,allowEarlyReference表示是否允许早期引用(循环依赖场景)
protected Object getSingleton(String beanName, boolean allowEarlyReference) {
    // 11. 从一级缓存(singletonObjects)获取已完全初始化的单例实例(key=Bean名称,value=Bean实例)
    Object singletonObject = this.singletonObjects.get(beanName);


    // 12. 缓存未命中,且当前Bean正在创建中(解决循环依赖)
    if (singletonObject == null && isSingletonCurrentlyInCreation(beanName)) {
        // 13. 对一级缓存加锁,保证多线程安全(避免并发创建多个实例)
        synchronized (this.singletonObjects) {
            // 14. 从二级缓存(earlySingletonObjects)获取早期暴露的实例(未完全初始化,仅解决循环依赖)
            singletonObject = this.earlySingletonObjects.get(beanName);


            // 15. 二级缓存未命中,且允许早期引用
            if (singletonObject == null && allowEarlyReference) {
                // 16. 从三级缓存(singletonFactories)获取Bean的工厂对象(用于创建早期实例)
                ObjectFactory<?> singletonFactory = this.singletonFactories.get(beanName);


                // 17. 工厂对象存在,通过工厂创建早期实例
                if (singletonFactory != null) {
                    singletonObject = singletonFactory.getObject();
                    // 18. 将早期实例存入二级缓存,同时移除三级缓存(避免重复创建)
                    this.earlySingletonObjects.put(beanName, singletonObject);
                    this.singletonFactories.remove(beanName);
                }
            }
        }
    }
    // 19. 返回单例实例(可能是完全初始化的,也可能是早期实例)
    return singletonObject;
}

入口: getBean(String name)是获取 Bean 的入口,委托doGetBean实现细节;

名称处理: transformedBeanName统一 Bean 名称格式,避免别名、FactoryBean 前缀导致的识别问题;

缓存优先: 通过getSingleton从三级缓存(singletonObjects→earlySingletonObjects→singletonFactories)获取实例,优先复用已有实例,契合单例模式核心;

线程安全: 对单例缓存加锁,防止多线程并发创建多个实例;

特殊处理: getObjectForBeanInstance区分普通 Bean 和 FactoryBean,确保返回用户预期的实例。

整个流程围绕 “缓存复用 + 安全创建” 实现 Spring 单例 Bean 的管理,是单例模式在框架级的经典落地。

结构型模式

为什么用结构型模式?

  • 结构型模式关注点“怎样组合对象/类”
  • 类结构型模式关心类的组合,由多个类可以组合成一个更大的(继承)
  • 对象结构型模式关心类与对象的组合,通过关联关系在一个类中定义另一个类的实例对象(组合)根据“合成复用原则”,在系统中尽量使用关联关系来替代继承关系,因此大部分结构型模式都是对象结构型模式。
  • 适配器模式(Adapter Pattern):两个不兼容接口之间适配的桥梁
  • 桥接模式(Bridge Pattern):相同功能抽象化与实现化解耦,抽象与实现可以独立升级
  • 过滤器模式(Filter、Criteria Pattern):使用不同的标准来过滤一组对象
  • 组合模式(Composite Pattern):相似对象进行组合,形成树形结构
  • 装饰器模式(Decorator Pattern):向一个现有的对象添加新的功能,同时又不改变其结构
  • 外观模式(Facade Pattern):向现有的系统添加一个接口,客户端访问此接口来隐藏系统的复杂性
  • 享元模式(Flyweight Pattern):尝试重用现有的同类对象,如果未找到匹配的对象,则创建新对象
  • 代理模式(Proxy Pattern):一个类代表另一个类的功能

结构型模式之外观模式

外观模式(Facade Pattern)为复杂子系统提供统一高层接口,隐藏内部复杂性,简化客户端调用。这种模式涉及到一个单一的类,该类提供了客户端请求的简化方法和对现有系统类方法的委托调用。

意图: 为子系统中的一组接口提供一个一致的界面,外观模式定义了一个高层接口,这个接口使得这一子系统更加容易使用。

主要解决: 降低访问复杂系统的内部子系统时的复杂度,简化客户端之间的接口。

何时使用:

1、客户端不需要知道系统内部的复杂联系,整个系统只需提供一个"接待员"即可。

2、定义系统的入口。

如何解决: 客户端不与系统耦合,外观类与系统耦合。

优点:

1、减少系统相互依赖。

2、提高灵活性。

3、提高了安全性。

缺点:

不符合开闭原则,如果要改东西很麻烦,继承重写都不合适。

使用场景:

1、JAVA 的三层开发模式

2、分布式系统的网关

外观模式简单应用

程序员这行,主打一个 “代码虐我千百遍,我待键盘如初恋”—— 白天 debug ,深夜改 Bug ,免疫力堪比未加 try-catch 的代码,说崩就崩。现在医院就诊(挂号、缴费、取药等子系统)都是通过 “微信自助程序”来统一入口,下面就使用外观模式简单实现:

子系统组件(就诊各窗口)

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
 * 子系统1:挂号窗口
 */
public class LibifuTestRegisterWindow {
    private static final Logger log = LoggerFactory.getLogger(LibifuTestRegisterWindow.class);
    /**
     * 挂号业务逻辑
     * @param name 患者姓名
     * @param department 就诊科室
     */
    public void register(String name, String department) {
        log.info(" {} 已完成{}挂号,挂号成功", name, department);
    }
}
/**
 * 子系统2:医保缴费窗口
 */
public class LibifuTestPaymentWindow {
    private static final Logger log = LoggerFactory.getLogger(LibifuTestPaymentWindow.class);
    /**
     * 医保结算业务逻辑
     * @param name 患者姓名
     * @param amount 缴费金额(元)
     */
    public void socialInsuranceSettlement(String name, double amount) {
        log.info("{} 医保结算完成,缴费金额:{}元", name, amount);
    }
}
/**
 * 子系统3:取药窗口
 */
public class LibifuTestDrugWindow {
    private static final Logger log = LoggerFactory.getLogger(LibifuTestDrugWindow.class);
    /**
     * 取药业务逻辑
     * @param name 患者姓名
     * @param drugNames 药品名称列表
     */
    public void takeDrug(String name, String... drugNames) {
        String drugs = String.join("、", drugNames);
        log.info("{} 已领取药品:{},取药完成", name, drugs);
    }
}

外观类(微信自助程序)

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
 * 外观类:微信自助程序(统一就诊入口)
 */
public class LibifuTestWeixinHospitalFacade {
    private static final Logger log = LoggerFactory.getLogger(LibifuTestWeixinHospitalFacade.class);
    // 依赖子系统组件(外观类与子系统耦合,客户端与子系统解耦)
    private final LibifuTestRegisterWindow registerWindow;
    private final LibifuTestPaymentWindow paymentWindow;
    private final LibifuTestDrugWindow drugWindow;
    // 构造器初始化子系统(也可通过依赖注入实现)
    public LibifuTestWeixinHospitalFacade() {
        this.registerWindow = new LibifuTestRegisterWindow();
        this.paymentWindow = new LibifuTestPaymentWindow();
        this.drugWindow = new LibifuTestDrugWindow();
    }
    /**
     * 统一就诊流程(封装子系统调用,对外暴露单一接口)
     * @param name 患者姓名
     * @param department 就诊科室
     * @param amount 缴费金额
     * @param drugNames 药品名称
     */
    public void processMedicalService(String name, String department, double amount, String... drugNames) {
        log.info("\n===== {} 发起微信自助就诊流程 =====", name);
        try {
            // 1. 调用挂号子系统
            registerWindow.register(name, department);
            // 2. 调用医保缴费子系统
            paymentWindow.socialInsuranceSettlement(name, amount);
            // 3. 调用取药子系统
            drugWindow.takeDrug(name, drugNames);
            log.info("===== {} 就诊流程全部完成 =====", name);
        } catch (Exception e) {
            log.error("===== {} 就诊流程失败 =====", name, e);
            throw new RuntimeException("就诊流程异常,请重试", e);
        }
    }
}

测试类

/**
 * 客户端:测试外观模式调用
 */
public class LibifuTestFacadeClient {
    public static void main(String[] args) {
        // 1. 获取外观类实例(仅需与外观类交互)
        LibifuTestWeixinHospitalFacade weixinFacade = new LibifuTestWeixinHospitalFacade();
        // 2. 调用统一接口,完成就诊全流程(无需关注子系统细节)
        weixinFacade.processMedicalService(
            "libifu", 
            "呼吸内科", 
            198.5, 
            "布洛芬缓释胶囊""感冒灵颗粒"
        );
    }
}

运行结果

框架应用

Spring 框架中外观模式(Facade Pattern) 最经典的落地是 ApplicationContext 接口及其实现类。

ApplicationContext 作为「外观类」,封装了底层多个复杂子系统:

  • BeanFactory(Bean 创建 / 管理核心);
  • ResourceLoader(配置文件 / 资源加载);
  • ApplicationEventPublisher(事件发布);
  • MessageSource(国际化消息处理);
  • EnvironmentCapable(环境变量 / 配置解析)。

开发者无需关注这些子系统的交互细节,仅通过 ApplicationContext 提供的统一接口(如 getBean()、publishEvent())即可完成 Spring 容器的所有核心操作 —— 就像程序员通过「微信自助程序」看病,不用关心医院内部挂号 / 缴费 / 取药的流程,只调用统一入口即可,这正是外观模式「简化复杂系统交互」的核心价值。

以下选取ApplicationContext 、AbstractApplicationContext核心代码片段,展示外观模式的落地逻辑:

package org.springframework.context;
import org.springframework.beans.factory.HierarchicalBeanFactory;
import org.springframework.beans.factory.ListableBeanFactory;
import org.springframework.beans.factory.config.AutowireCapableBeanFactory;
import org.springframework.core.env.EnvironmentCapable;
import org.springframework.core.io.support.ResourcePatternResolver;
/**
 * 外观接口:整合多个子系统接口,提供统一的容器操作入口
 */
public interface ApplicationContext extends EnvironmentCapable, ListableBeanFactory, 
        HierarchicalBeanFactory, MessageSource, ApplicationEventPublisher, ResourcePatternResolver {
    // 1. 获取应用上下文唯一ID(封装底层无,仅统一暴露)
    String getId();
    // 2. 获取应用名称(统一接口)
    String getApplicationName();
    // 3. 获取上下文显示名称(统一接口)
    String getDisplayName();
    // 4. 获取上下文首次加载的时间戳(统一接口)
    long getStartupDate();
    // 5. 获取父上下文(封装层级BeanFactory的父容器逻辑)
    ApplicationContext getParent();
    // 6. 获取自动装配BeanFactory(封装底层BeanFactory的自动装配能力,核心子系统入口)
    AutowireCapableBeanFactory getAutowireCapableBeanFactory() throws IllegalStateException;
}
package org.springframework.context.support;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.context.ConfigurableApplicationContext;
import java.util.concurrent.atomic.AtomicBoolean;
public abstract class AbstractApplicationContext extends DefaultResourceLoader
        implements ConfigurableApplicationContext {
    // ========== 核心1:refresh() - 封装所有子系统的初始化逻辑 ==========
    @Override
    public void refresh() throws BeansException, IllegalStateException {
        synchronized (this.startupShutdownMonitor) {
            // 1. 封装子系统初始化前置检查
            prepareRefresh();
            // 2. 封装BeanFactory子系统的创建/刷新(子类实现具体BeanFactory,如DefaultListableBeanFactory)
            ConfigurableListableBeanFactory beanFactory = obtainFreshBeanFactory();
            // 3. 封装BeanFactory子系统的基础配置
            prepareBeanFactory(beanFactory);
            try {
                // xxx 其他源码省略
                // 4. 封装BeanFactory后置处理器执行、事件系统初始化、单例Bean初始化等所有子系统逻辑
                finishBeanFactoryInitialization(beanFactory);
                // 5. 封装容器激活、刷新完成事件发布(子系统收尾)
                finishRefresh();
            } catch (BeansException ex) {
                // 6. 封装子系统初始化失败的回滚逻辑
            }
        }
    }
    // ========== 核心2:getBean() - 封装BeanFactory子系统的调用 + 状态检查 ==========
    @Override
    public <T> T getBean(Class<T> requiredType) throws BeansException {
        // 外观层封装:子系统状态检查(客户端无需关注BeanFactory是否活跃)
        assertBeanFactoryActive();
        // 外观层委托:调用底层BeanFactory子系统的getBean,客户端无需关注BeanFactory具体实现
        return getBeanFactory().getBean(requiredType);
    }
    // ========== 抽象方法:委托子类实现具体BeanFactory获取(屏蔽子系统实现) ==========
    public abstract ConfigurableListableBeanFactory getBeanFactory() throws IllegalStateException;
}

Spring 通过 ApplicationContext(外观接口)和 AbstractApplicationContext(外观实现)封装了其他子系统的复杂逻辑:

  • 客户端只需调用 ApplicationContext.getBean() 即可获取 Bean,无需关注底层 Bean 的缓存、实例化、状态检查等细节;
  • 外观类屏蔽了子系统的复杂度,降低了客户端与底层 BeanFactory 的耦合,符合外观模式的设计思想。

行为型模式

为什么用行为型模式?

  • 行为型模式关注点“怎样运行对象/类”关注类/对象的运行时流程控制。
  • 行为型模式用于描述程序在运行时复杂的流程控制,描述多个类或对象之间怎样相互协作共同完成单个对象都无法单独完成的任务,它涉及算法与对象间职责的分配。
  • 行为型模式分为类行为模式和对象行为模式,前者采用继承机制来在类间分派行为后者采用组合或聚合在对象间分配行为。由于组合关系或聚合关系比继承关系耦合度低,满足“合成复用原则”,所以对象行为模式比类行为模式具有更大的灵活性。
  • 模板方法(Template Method)模式:父类定义算法骨架,某些实现放在子类
  • 策略(Strategy)模式:每种算法独立封装,根据不同情况使用不同算法策略
  • 状态(State)模式:每种状态独立封装,不同状态内部封装了不同行为
  • 命令(Command)模式:将一个请求封装为一个对象,使发出请求的责任和执行请求的责任分割开
  • 责任链(Chain of Responsibility)模式:所有处理者封装为链式结构,依次调用
  • 备忘录(Memento)模式:把核心信息抽取出来,可以进行保存
  • 解释器(Interpreter)模式:定义语法解析规则
  • 观察者(Observer)模式:维护多个观察者依赖,状态变化通知所有观察者
  • 中介者(Mediator)模式:取消类/对象的直接调用关系,使用中介者维护
  • 迭代器(Iterator)模式:定义集合数据的遍历规则
  • 访问者(Visitor)模式:分离对象结构,与元素的执行算法

除了模板方法模式和解释器模式是类行为型模式,其他的全部属于对象行为型模式。

行为型模式之策略模式

策略模式(Strategy Pattern)指的是一个类的行为或其算法可以在运行时更改,在策略模式中,我们创建表示各种策略的对象和一个行为随着策略对象改变而改变的 context 对象,策略对象改变 context 对象的执行算法。

意图: 定义一系列的算法,把它们一个个封装起来, 并且使它们可相互替换。

主要解决: 在有多种算法相似的情况下,使用 if...else 所带来的复杂和难以维护。

何时使用: 一个系统有许多许多类,而区分它们的只是它们之间的行为。

如何解决: 将这些算法封装成一个一个的类,任意地替换。

优点:

1、算法可以自由切换。

2、避免使用多重条件判断。

3、扩展性良好。

缺点:

1、策略类会增多。

2、所有策略类都需要对外暴露。

使用场景:

1、如果在一个系统里面有许多类,它们之间的区别仅在于它们的行为,那么使用策略模式可以

动态地让一个对象在许多行为中选择一种行为。

2、一个系统需要动态地在几种算法中选择一种。

3、线程池拒绝策略。

策略模式简单应用

在电商支付系统中,都会支持多种支付方式(微信、支付宝、银联),每种支付方式对应一种 “支付策略”,客户端可根据用户选择动态切换策略,无需修改支付核心逻辑,下面就使用策略模式简单实现:

策略接口(定义统一算法规范)

/**
 * 策略接口:支付策略(定义所有支付方式的统一规范)
 */
public interface LibifuTestPaymentStrategy {
    /**
     * 执行支付逻辑
     * @param amount 支付金额(元)
     * @param orderId 订单ID
     * @return 支付结果(成功/失败)
     */
    String pay(double amount, String orderId);
}

具体策略类 1:微信支付

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
 * 具体策略:微信支付(实现支付策略接口)
 */
public class LibifuTestWechatPayStrategy implements LibifuTestPaymentStrategy {
    private static final Logger log = LoggerFactory.getLogger(LibifuTestWechatPayStrategy.class);
    @Override
    public String pay(double amount, String orderId) {
        log.info("【微信支付】开始处理订单:{},金额:{}元", orderId, amount);
        // 模拟微信支付核心逻辑(签名、调用微信接口等)
        boolean isSuccess = true// 模拟支付成功
        if (isSuccess) {
            String result = String.format("【微信支付】订单%s支付成功,金额:%.2f元", orderId, amount);
            log.info(result);
            return result;
        } else {
            String result = String.format("【微信支付】订单%s支付失败", orderId);
            log.error(result);
            return result;
        }
    }
}

具体策略类 2:支付宝支付

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
 * 具体策略:支付宝支付(实现支付策略接口)
 */
public class LibifuTestAlipayStrategy implements LibifuTestPaymentStrategy {
    private static final Logger log = LoggerFactory.getLogger(LibifuTestAlipayStrategy.class);
    @Override
    public String pay(double amount, String orderId) {
        log.info("【支付宝支付】开始处理订单:{},金额:{}元", orderId, amount);
        // 模拟支付宝支付核心逻辑(验签、调用支付宝接口等)
        boolean isSuccess = true// 模拟支付成功
        if (isSuccess) {
            String result = String.format("【支付宝支付】订单%s支付成功,金额:%.2f元", orderId, amount);
            log.info(result);
            return result;
        } else {
            String result = String.format("【支付宝支付】订单%s支付失败", orderId);
            log.error(result);
            return result;
        }
    }
}

具体策略类 3:银联支付

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
 * 具体策略:银联支付(实现支付策略接口)
 */
public class LibifuTestUnionPayStrategy implements LibifuTestPaymentStrategy {
    private static final Logger log = LoggerFactory.getLogger(LibifuTestUnionPayStrategy.class);
    @Override
    public String pay(double amount, String orderId) {
        log.info("【银联支付】开始处理订单:{},金额:{}元", orderId, amount);
        // 模拟银联支付核心逻辑(加密、调用银联接口等)
        boolean isSuccess = true// 模拟支付成功
        if (isSuccess) {
            String result = String.format("【银联支付】订单%s支付成功,金额:%.2f元", orderId, amount);
            log.info(result);
            return result;
        } else {
            String result = String.format("【银联支付】订单%s支付失败", orderId);
            log.error(result);
            return result;
        }
    }
}

上下文类(封装策略调用,屏蔽算法细节)

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
 * 上下文类:支付上下文(持有策略对象,提供统一调用入口)
 * 作用:客户端仅与上下文交互,无需直接操作具体策略
 */
public class LibifuTestPaymentContext {
    private static final Logger log = LoggerFactory.getLogger(LibifuTestPaymentContext.class);
    // 持有策略对象(可动态替换)
    private LibifuTestPaymentStrategy paymentStrategy;
    /**
     * 构造器:初始化支付策略
     * @param paymentStrategy 具体支付策略
     */
    public LibifuTestPaymentContext(LibifuTestPaymentStrategy paymentStrategy) {
        this.paymentStrategy = paymentStrategy;
    }
    /**
     * 动态切换支付策略
     * @param paymentStrategy 新的支付策略
     */
    public void setPaymentStrategy(LibifuTestPaymentStrategy paymentStrategy) {
        log.info("【支付上下文】切换支付策略:{}", paymentStrategy.getClass().getSimpleName());
        this.paymentStrategy = paymentStrategy;
    }
    /**
     * 统一支付入口(屏蔽策略细节,对外暴露简洁方法)
     * @param amount 支付金额
     * @param orderId 订单ID
     * @return 支付结果
     */
    public String executePay(double amount, String orderId) {
        log.info("【支付上下文】开始处理订单{}的支付请求", orderId);
        return paymentStrategy.pay(amount, orderId);
    }
}

测试类

/**
 * 客户端:测试策略模式(动态切换支付方式)
 */
public class LibifuTestStrategyClient {
    public static void main(String[] args) {
        // 1. 订单信息
        String orderId"ORDER_20251213_001";
        double amount199.99;
        // 2. 选择微信支付策略
        LibifuTestPaymentContext paymentContext = new LibifuTestPaymentContext(new LibifuTestWechatPayStrategy());
        String wechatResult = paymentContext.executePay(amount, orderId);
        System.out.println(wechatResult);
        // 3. 动态切换为支付宝支付策略
        paymentContext.setPaymentStrategy(new LibifuTestAlipayStrategy());
        String alipayResult = paymentContext.executePay(amount, orderId);
        System.out.println(alipayResult);
        // 4. 动态切换为银联支付策略
        paymentContext.setPaymentStrategy(new LibifuTestUnionPayStrategy());
        String unionPayResult = paymentContext.executePay(amount, orderId);
        System.out.println(unionPayResult);
    }
}

运行结果

框架应用

在Spring 中 ,ResourceLoader 接口及实现类是策略模式的典型落地:

  • 策略接口:ResourceLoader(定义 “加载资源” 的统一规范);
  • 具体策略:DefaultResourceLoader(默认资源加载)、FileSystemResourceLoader(文件系统加载)、ClassPathXmlApplicationContext(类路径加载)等;
  • 核心价值:不同资源(类路径、文件系统、URL)的加载逻辑封装为独立策略,可灵活切换且不影响调用方。
  • 以下选取ResourceLoader 、FileSystemResourceLoader核心代码片段,展示策略模式的落地逻辑:

package org.springframework.core.io;
import org.springframework.lang.Nullable;
/**
 * 策略接口:定义资源加载的统一规范(策略模式核心接口)
 */
public interface ResourceLoader {
    // 类路径资源前缀(常量,子系统细节)
    String CLASSPATH_URL_PREFIX = "classpath:";
    /**
     * 策略核心方法:根据资源路径加载Resource(所有具体策略需实现此方法)
     * @param location 资源路径(如classpath:application.xml、file:/data/config.xml)
     * @return 封装后的Resource对象
     */
    Resource getResource(String location);
    /**
     * 辅助方法:获取类加载器(策略实现时依赖)
     */
    @Nullable
    ClassLoader getClassLoader();
}
package org.springframework.core.io;
/**
 * 具体策略:文件系统资源加载器(覆盖扩展点实现文件系统加载)
 */
public class FileSystemResourceLoader extends DefaultResourceLoader {
    /**
     * 覆盖策略扩展点:实现文件系统路径加载
     */
    @Override
    protected Resource getResourceByPath(String path) {
        // 若路径为绝对路径,直接创建FileSystemResource
        if (path.startsWith("/")) {
            return new FileSystemResource(path);
        }
        // 否则创建文件系统上下文资源(支持相对路径)
        else {
            return new FileSystemContextResource(path);
        }
    }
    /**
     * 内部类:文件系统上下文资源(策略辅助实现)
     */
    private static class FileSystemContextResource extends FileSystemResource {
        public FileSystemContextResource(String path) {
            super(path);
        }
        // xxx
    }
}
角色 类 / 接口 作用
策略接口 ResourceLoader 定义getResource统一加载规范,屏蔽不同资源加载的细节
抽象策略 DefaultResourceLoader 实现通用加载逻辑(类路径、URL),提供扩展点getResourceByPath
具体策略 FileSystemResourceLoader 覆盖扩展点,实现文件系统资源加载的专属逻辑
调用方 ApplicationContext(如ClassPathXmlApplicationContext) 依赖ResourceLoader接口,无需关注具体加载策略,可灵活切换

三、实战

背景

除了大家熟悉的"出价还价"列表外,现在订单列表、"想要"收藏列表等场景也能看到心仪商品的还价信息——还价功能,在用户体验上逐步从单一场景向多场景持续演进。

1.0 版本:

在功能初期,我们采用轻量级的设计思路:

  • 聚焦核心场景:仅在还价列表页提供精简高效的还价服务
  • 极简技术实现:通过线性调用商品/库存/订单等等服务,确保功能稳定交付
  • 智能引导策略:内置还价优先级算法,帮助用户快速决策

2.0 版本:

但随着得物还价功能不断加强,系统面临了一些烦恼:

  • 场景维度:订单列表、想要<收藏>列表等新场景接入
  • 流量维度:部分页面的访问量呈指数级增长,峰值较初期上升明显

我们发现原有设计逐渐显现出一些局限性:

  • 用户体验优化:随着用户规模快速增长,如何在高并发场景下依然保持丝滑流畅的还价体验,成为重要关注点
  • 迭代效率:每次新增业务场景都需要重复开发相似逻辑
  • 协作效率:功能迭代的沟通和对接成本增加

改造点

针对上述问题,我们采用策略模式进行代码结构升级,核心改造点包括:

抽象策略接口

public interface xxxQueryStrategy {
    /**
     * 策略类型
     *
     * @return 策略类型
     */
    String matchType();
    /**
     * 前置校验
     *
     * @param ctx xxx上下文
     * @return true-校验通过;false-校验未通过
     */
    boolean beforeProcess(xxxCtx ctx);
    /**
     * 执行策略
     *
     * @param ctx xxx上下文
     * @return xxxdto
     */
    xxxQueryDTO handle(xxxtx ctx);
    /**
     * 后置处理
     *
     * @param ctx xxx上下文
     */
    void afterProcess(xxxCtx ctx);
}

抽象基类 :封装公共数据查询逻辑

@Slf4j
@Component
public abstract class AbstractxxxStrategy {
        /**
         * 执行策略
         *
         * @param ctx xxx上下文
         */
        public void doHandler(xxxCtx ctx) {
            // 初始化xxx数据
            initxxx(ctx);
            // 异步查询相关信息
            supplyAsync(ctx);
            // 初始化xxx上下文
            initxxxCtx(ctx);
            // 查询xxxx策略
            queryxxxGuide(ctx);
            // 查询xxx底部策略
            queryxxxBottomGuide(ctx);
        }
        /**
         * 初始化xxx数据
         *
         * @param ctx xxx上下文
         */
        protected abstract void initxxx(xxxCtx ctx);




        /**
         * 异步查询相关信息
         *
         * @param ctx xxx上下文
         */
        protected abstract void supplyAsync(xxxCtx ctx);


        /**
         * 初始化xxx上下文
         *
         * @param ctx xxx上下文
         */
        protected abstract void initxxxCtx(xxxCtx ctx);


        /**
         * 查询xxx策略
         *
         * @param ctx xxx上下文
         */
        protected abstract void queryxxxGuide(xxxCtx ctx);


        /**
         * 查询xxx底部策略
         *
         * @param ctx xxx上下文
         */
        protected abstract void queryxxxBottomGuide(xxxCtx ctx);


        /**
         * 构建出参
         *
         * @param ctx xxx上下文
         */
        protected abstract void buildXXX(xxxCtx ctx);
}

具体策略 :实现场景特有逻辑

public class xxxStrategy extends AbstractxxxxStrategy implements xxxStrategy {
    /**
     * 策略类型
     *
     * @return 策略类型
     */
    @Override
    public String matchType() {
        // XXX
    }


    /**
     * 前置校验
     *
     * @param ctx xxx上下文
     * @return true-校验通过;false-校验未通过
     */
    @Override
    public boolean beforeProcess(xxxCtx ctx) {
        // XXX
    }


    /**
     * 执行策略
     *
     * @param ctx  xxx上下文
     * @return 公共出参
     */
    @Override
    public BuyerBiddingQueryDTO handle(xxxCtx ctx) {
        super.doHandler(ctx);
        // XXX
    }


    /**
     * 后置处理
     *
     * @param ctx xxx上下文
     */
    @Override
    public void afterProcess(xxxCtx ctx) {
       // XXX
    }


    /**
     * 初始化xxx数据
     *
     * @param ctx xxx上下文
     */
    @Override
    protected void initxxx(xxxCtx ctx) {
        // XXX
    }


    /**
     * 异步查询相关信息
     *
     * @param ctx  XXX上下文
     */
    @Override
    protected void supplyAsync(xxxCtx ctx) {
        // 前置异步查询
        super.preBatchAsyncxxx(ctx);
        // 策略定制业务
        // XXX
    }


    /**
     * 初始化XXX上下文
     *
     * @param ctx XXX上下文
     */
    @Override
    protected void initGuideCtx(xxxCtx ctx) {
        // XXX
    }


    /**
     * 查询XXX策略
     *
     * @param ctx XXX上下文
     */
    @Override
    protected void queryXXXGuide(xxxCtx ctx) {
        // XXX
    }


    /**
     * 查询XXX策略
     *
     * @param ctx XXX上下文
     */
    @Override
    protected void queryXXXBottomGuide(XXXCtx ctx) {
        // XXX
    }


    /**
     * 构建出参
     *
     * @param ctx XXX上下文
     */
    @Override
    protected void buildXXX(XXXCtx ctx) {
        // XXX
    }
}

运行时策略路由

@Component
@RequiredArgsConstructor
public class xxxStrategyFactory {
    private final List<xxxStrategy> xxxStrategyList;


    private final Map<String, xxxStrategy> strategyMap = new HashMap<>();


    @PostConstruct
    public void init() {
        CollectionUtils.emptyIfNull(xxxStrategyList)
                .stream()
                .filter(Objects::nonNull)
                .forEach(strategy -> strategyMap.put(strategy.matchType(), strategy));
    }


    public xxxStrategy select(String scene) {
        return strategyMap.get(scene); 
    }
}

升级收益

1.性能提升 :

  • 同步调用改为CompletableFuture异步编排
  • 并行化独立IO操作,降低整体响应时间

2.扩展性增强 :

  • 新增场景只需实现新的Strategy类
  • 符合开闭原则(对扩展开放,对修改关闭)

3.可维护性改善 :

  • 业务逻辑按场景垂直拆分
  • 公共逻辑下沉到抽象基类
  • 消除复杂的条件分支判断

4.架构清晰度 :

  • 明确的策略接口定义
  • 各策略实现类职责单一

这种架构改造体现了组合优于继承 、面向接口编程等设计原则,通过策略模式将原本复杂的单体式结构拆分为可插拔的组件,为后续业务迭代提供了良好的扩展基础。

四、总结

在软件开发中,设计模式是一种解决特定场景问题的通用方法论,旨在提升代码的可读性、可维护性和可复用性。其核心优势在于清晰的职责分离理念、灵活的行为抽象能力以及对系统结构的优化设计。结合丰富的实践经验,设计模式已经成为开发者应对复杂业务需求、构建高质量软件系统的重要指导原则。

本文通过解析一些经典设计模式的原理、框架应用与实战案例,深入探讨了设计模式在实际开发中的价值与作用。作为代码优化的工具,更作为一种开发哲学,设计模式以简洁优雅的方式解决复杂问题,推动系统的高效与稳健。

当然了,在实际的软件开发中,我们应根据实际需求合理选择和应用设计模式,避免过度设计,同时深入理解其背后的理念,最终实现更加高效、健壮的代码与系统架构。

往期回顾

1.从0到1搭建一个智能分析OBS埋点数据的AI Agent|得物技术

2.数据库AI方向探索-MCP原理解析&DB方向实战|得物技术

3.项目性能优化实践:深入FMP算法原理探索|得物技术

4.Dragonboat统一存储LogDB实现分析|得物技术

5.从数字到版面:得物数据产品里数字格式化的那些事

文 /忘川

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

❌
❌