普通视图

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

社区推荐重排技术:双阶段框架的实践与演进|得物技术

作者 得物技术
2026年2月12日 13:48

一、背景

推荐系统典型pipeline

在推荐系统多阶段Pipeline(召回→粗排→精排→重排)中,重排作为最终决策环节,承担着将精排输出的有限候选集(通常为Top 100–500个Item)转化为最优序列的关键职责。数学定义为在给定候选集C={x1,x2,,xn}C = \lbrace x_1,x_2,……,x_n \rbrace与目标列表长度LL,重排的目标是寻找一个排列πP(C,L)\pi^* \in P(C,L),使得全局收益函数最大化。

在推荐系统、搜索排序等AI领域,Pointwise 建模是精排阶段的核心方法,即对每个 Item 独立打分后排序,pointwise 建模范式面临挑战:

  • 多样性约束:精排按 item 独立打分排序 → 高分 item 往往语义/类目高度同质(如5个相似短视频连续曝光)。
  • 位置偏差:用户注意力随位置显著衰减,且不同item对位置敏感度不同。
  • 上下文建模:用户决策是序列行为,而非独立事件。

二、重排架构演进:生成式模型实践

我们的重排系统采用G-E两阶段协同框架:

  • 生成阶段(Generation):高效生成若干高质量候选排列。
  • 评估阶段(Evaluation):对候选排列进行精细化打分,选出全局最优结果。

不考虑算力和耗时的情况下,通过穷举所有排列P(C,L)P(C,L)

生成阶段主要依赖启发式规则、随机扰动 + beamSearch算法生成候选list,双阶段范式存在显著的痛点:

  • 质量-延迟-多样性的“不可能三角”:在实践中,增加生成候选list数一般可以提升最终list的质量,但边际收益递减;优化过程中,我们通过增加多目标、多样性等策略都取得了消费指标的提升,但在候选list达到百量级时,单纯增加候选集对指标的提升,同时还有:
  1. 增加beam width,系统耗时增加,DCG@K提升逐渐减少。

  2. 增加通道数,通道间重叠度逐渐增加,去重list增加逐渐减少。

  • 阶段间目标不一致:
  1. 分布偏移:启发式生成Beam Search输出的Top排列中,20%被评估模型否定,生成阶段搜索效率浪费。

  2. 梯度断层:Beam Search含argmax操作,双阶段无法端到端优化;生成模型无法感知评估反馈,优化方向偏离全局最优。

生成模型优化

生成分为启发式方法和生成式模型方法, 一般认为生成式模型方法要好于启发式方法。生成式模型逐渐成为重排主流范式,主要分为两类:自回归生成模型、非自回归生成模型。

  • 自回归生成:按位置顺序逐个生成物品,第 t 位的预测依赖前 t-1 位已生成结果。
  1. 优点:

a. 序列依赖建模强,天然捕获物品间的顺序依赖。

b. 训练简单稳定,每步使用真实前序作为输入,收敛快。

c. 生成质量高,逐步细化决策,适合长序列精细优化。

  1. 缺点:

a. 推理延迟高,生成 L 个物品需 L 次前向传播,线上服务难以满足毫秒级要求。

b. 局部最优风险,早期错误决策无法回溯修正,影响整体序列质量。

  • 非自回归生成:一次性预测整个推荐序列的所有位置,各位置预测相互独立。
  1. 优点:

a. 推理速度极快:生成整个序列仅需1次前向传播。

2.缺点:

b.条件独立性假设过强:各位置并行预测,难以显式建模物品间复杂依赖关系。

非自回归模型

为了对齐双阶段一致性,同时考虑线上性能,我们推进了非自回归模型的上线。模型结构如下图:

模型包括Candidates Encoder和Position Encoder,Candidates Encoder是标准的Transformer结构, 用于获取item间的交互信息;Position Encoder额外增加了Cross Attention,期望Position序列同时关注Candidate序列。

  • 模型特征:用户信息、item特征、位置信息、上游精排打分特征。
  • 模型输出:一次性输出 n×L 的位置-物品得分矩阵(n 为候选 item 数,L 为目标列表长度),支持高效并行推理
p^ij=exp(xitj)i=1nexp(xitj)\hat{p}_{ij} = \frac{\exp(\mathbf{x}_i^\top \mathbf{t}_j)}{\sum_{i=1}^n \exp(\mathbf{x}_i^\top \mathbf{t}_j)}
  • 位置感知建模:引入可学习位置嵌入,显式建模“同一 item 在不同位置表现不同”的现象(如首屏效应、位置衰减)。
  • 训练目标:模型使用logloss,让正反馈label序列的生成概率最大, 同时负反馈label序列的生成概率最小:
Llog=i[pijyilog(pij^)+pij(1yi)log(1pij^)]\mathcal{L}_{\log} = -\sum_{i} \big[ p_{ij}y_i \log(\hat{p_{ij}}) + p_{ij}(1-y_i) \log(1-\hat{p_{ij}}) \big]

其中,pijp_{ij}表示位置i上是否展示物品j,yiy_{i}表示位置i上的label。

线上实验及收益:

  • 一期新增了非自回归生成通道,pvctr +0.6%,时长+0.55%。
  • 二期在所有通道排序因子中bagging非自回归模型,pvctr +1.0%,时长+1.13%。

自回归模型

由于条件独立性假设, 非自回归模型对上下文信息建模是不够的,近期我们重点推进了自回归模型的开发。

模型通过Transformer架构建模list整体收益,我们使用单向transformer模拟用户浏览行为的因果性,同时解决自回归生成的暴露偏差问题,保持训练和推理的一致性。结构如下:

  • 模型特征:用户信息、item特征、位置信息、上游精排打分特征。
  • 训练目标:模型使用有序回归loss,在评估多个回合中不同长度的子列表时,能够很好地体现出序列中的增量价值。是用于判断长度为j的子列表是否已经达到i次点击或转化的损失函数。
Li,j(θj)=k=1N([yk<i]log(1pi,j(xk))+[yki]log(pi,j(xk)))L_{i,j}(\theta_j) = -\sum_{k=1}^{N} \left([y_k < i]\log(1-p_{i,j}(x_k)) + [y_k \geq i]\log(p_{i,j}(x_k))\right)

线上模型推理效率优化及实验效果:

自回归生成模型推理延迟高,生成 L 个物品需 L 次前向传播,线上服务难以满足毫秒级要求。因此,我们在传统自回归生成模型的基础上增加MTP(multi token prediction)结构,突破生成式重排模型推理瓶颈。其核心思想是将传统自回归的单步预测扩展为单步多token联合预测,显著减少生成迭代次数。

自回归生成模型在社区推荐已完成了推全,实验中我们新增了自回归生成模型通道,但不是完全体,仅部分位置生成调用了模型:

  • 一期调用两次模型,每次预测4个位置,pvctr +0.69%,有效vv +0.58%。
  • 二期调用两次模型,每次预测5个位置,pvctr +0.54%,有效vv +0.40%。

三、推理性能优化:端到端生成的效率保障

工程架构

为解决CPU推理模型延迟高、制约业务效果的问题,我们对DScatter模型服务进行升级,引入高性能GPU推理能力,具体方案如下:

  • GPU推理框架集成与升级:
  1. 框架升级:将现有依赖的推理框架升级为支持GPU的高性能服务框架。

  2. 硬件资源引入:引入 NVIDIA L20 等专业推理显卡,为当前的listwise评估模型及自回归生成模型提供专用算力,实现模型推理的硬件加速。

  • DScatter模型服务独立部署与容量提升:
  1. 为解决模型部署效率低与资源竞争问题,将DScatter的模型打分逻辑从现有重排服务中完全解耦,构建并部署独立的 DScatter-Model-Server 集群,从根本上消除与重排服务在CPU、内存等关键资源上的竞争。

模型优化

  • 模型格式转换与加速:

导出为 ONNX 格式,使用 TensorRT 进行量化、层融合、动态张量显存等技术加速推理。

  • Item Embedding缓存:

预计算item静态网络,线上直接查询节省计算量。

  • 自回归生成模型核心优化,KV Cache 复用:

缓存已生成token的KV和attention值,仅计算增量token相关值,避免重复计算。

  • 其他LLM推理加速技术应用落地,例如GQA

四、未来规划:迈向端到端序列生成的下一代重排架构

当前“生成-评估”双阶段范式虽在工程落地性上取得平衡,但其本质仍是局部优化:生成阶段依赖启发式规则或浅层模型生成候选,评估阶段虽能识别优质序列,却无法反向指导生成过程,导致系统能力存在理论上限。为突破这一瓶颈,我们规划构建端到端序列生成(End-to-End Sequence Generation) 架构,将重排从“候选筛选”升级为“序列创造”,直接以全局业务目标(如用户停留时长、互动深度、内容生态健康度)为优化目标。

核心架构设计:

  • 统一生成器:以 Transformer 为基础架构,搭建自回归序列建模能力,采用分层混合生成策略:
  1. 粗粒度并行生成:首层预测序列骨架(如类目分布、内容密度)等。

  2. 细粒度自回归精调:在骨架约束下,自回归生成具体 item,确保局部最优。

  • 序列级Reward Modeling:
  1. 构建多目标 reward 函数:xtr、多样性。

  2. Engagement:基于用户滑动轨迹建模序列累积收益(如滑动深度加权CTR)。

  3. Diversity:跨类目/创作者/内容形式的分布熵。

4.Fairness:冷启内容、长尾创作者曝光保障。

训练范式升级:强化学习与对比学习融合

推进自回归生成模型的架构升级与训练体系重构,引入强化学习微调(PPO/DPO)与对比学习机制,提升序列整体效率。

  • 搭建近线系统,生成高质量list候选,提升系统能力上限:

1.基于 DCG 的列表质量打分:

a. 对每个曝光列表L,计算其 DCG@K作为质量分数:

DCG(L)=j=1Kgain(itemj)log2(j+1)\text{DCG}(L) = \sum_{j=1}^{K} \frac{\text{gain}(item_j)}{\log_2(j + 1)}

其中 gain(item)可定义为:

若点击:+1.0

若互动(点赞/收藏):+1.5

若观看 >5s:+0.8

否则:0

2.构造偏好对:

a.对同一用户在同一上下文下的两个列表 LwL_w(win)和 LlL_l(lose)。

b.若 DCG(Lw)>DCG(Ll)+δDCG(L_w) > DCG(L_l) + \delta(δ 为 margin,如 0.1),则构成一个有效偏好对。

  • 引入强化学习微调(PPO/DPO)与对比学习机制,提升序列整体效率:
  1. 模型结构:

a.使用当前自回归生成模型作为策略模型。

b.固定预训练模型作为参考策略 (即 DPO 中的“旧策略”)。

2.DPO损失:

LDPO(θ)=E(x,yw,yl)D[logσ(β(logπθ(ywx)πref(ywx)logπθ(ylx)πref(ylx)))]\mathcal{L}_{\text{DPO}}(\theta) = -\mathbb{E}_{(x, y_w, y_l) \sim \mathcal{D}} \left[ \log \sigma \left( \beta \left( \log \frac{\pi_\theta(y_w \mid x)}{\pi_{\text{ref}}(y_w \mid x)} - \log \frac{\pi_\theta(y_l \mid x)}{\pi_{\text{ref}}(y_l \mid x)} \right) \right) \right]
  • 技术价值:
  1. 突破“质量-延迟-多样性”不可能三角:通过序列级优化,在同等延迟下实现质量与多样性双提升。

  2. 为AIGC与推荐融合铺路:端到端生成器可无缝接入AIGC内容,实现“内容生成-序列编排”一体化。

参考文献:

  1. Gloeckle F, Idrissi B Y, Rozière B, et al. Better & faster large language models via multi-token prediction[J]. arXiv preprint arXiv:2404.19737, 2024.
  2. Ren Y, Yang Q, Wu Y, et al. Non-autoregressive generative models for reranking recommendation[C]//Proceedings of the 30th ACM SIGKDD Conference on Knowledge Discovery and Data Mining. 2024: 5625-5634.
  3. Meng Y, Guo C, Cao Y, et al. A generative re-ranking model for list-level multi-objective optimization at taobao[C]//Proceedings of the 48th International ACM SIGIR Conference on Research and Development in Information Retrieval. 2025: 4213-4218.
  4. Zhao X, Xia L, Zhang L, et al. Deep reinforcement learning for page-wise recommendations[C]//Proceedings of the 12th ACM conference on recommender systems. 2018: 95-103.
  5. Feng Y, Hu B, Gong Y, et al. GRN: Generative Rerank Network for Context-wise Recommendation[J]. arXiv preprint arXiv:2104.00860, 2021.
  6. Pang L, Xu J, Ai Q, et al. Setrank: Learning a permutation-invariant ranking model for information retrieval[C]//Proceedings of the 43rd international ACM SIGIR conference on research and development in information retrieval. 2020: 499-508.

往期回顾

1.Flink ClickHouse Sink:生产级高可用写入方案|得物技术

2.服务拆分之旅:测试过程全揭秘|得物技术

3.大模型网关:大模型时代的智能交通枢纽|得物技术

4.从“人治”到“机治”:得物离线数仓发布流水线质量门禁实践

5.AI编程实践:从Claude Code实践到团队协作的优化思考|得物技术

文 /张卓

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

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

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

【跨域options】为什么你的跨域 POST 请求被浏览器“吞”了?

作者 stayong
2026年2月11日 23:29

引言

在做跨域请求时,你是否遇到过这种怪事:在 Network 面板里,OPTIONS 预检请求返回了 200 OK,看起来一切正常;但紧随其后的 POST 请求却直接报了 CORS error,甚至点开详情还提示 “Provisional headers are shown”

这到底是浏览器的锅,还是后端的错?今晚我们就来扒开这层皮。

image.png


一、 罪魁祸首:被遗忘的“入场券”

在跨域场景下,浏览器会发起 OPTIONS 预检。如果你在请求中使用了非简单的 Header(如 JSON 的 Content-Type 或链路追踪的 traceparent),浏览器会发起“礼貌询问”:

  • 浏览器问: Access-Control-Request-Headers: content-type, traceparent
  • 服务器答: Access-Control-Allow-Headers: Content-Type, Access-Token, ...

坑点就在这里: 如果服务器的白名单里漏掉了哪怕一个 Header(比如本文案例中的 traceparent),浏览器就会认为这场“商业洽谈”失败了。


二、 为什么 OPTIONS 是绿的,POST 却是红的?

这是最让人困惑的地方。既然“洽谈”失败,为什么 OPTIONS 不直接报错?

  1. OPTIONS 是“获取规则”: 服务器成功处理了你的咨询请求,并如实告知了它的配置。从 HTTP 协议看,咨询过程是完美的,所以返回 200 OK
  2. POST 是“执行意图”: 浏览器拿到服务器的规则后,发现和你手里的“会员卡”(traceparent)不匹配。为了安全,浏览器单方面拦截了 POST 请求,不让它真正发往服务器。
  3. “红色”是浏览器的警告: 那个红色的 POST 记录其实是浏览器生成的“虚拟记录”,目的是告诉你: “你原本想发这个,但我把它按下了。” > 核心结论: OPTIONS 的成功代表“问答过程成功”,不代表“准入结果成功”。

三、 关于 Origin 的安全防线

在排查过程中,我们曾想过:能不能手动在 Headers 里改 Origin

  • 答案是:不行。 在浏览器环境下,OriginForbidden Header,由浏览器强制控制。
  • 为什么? 如果开发者能改 Origin,那么钓鱼网站就能轻易伪装成银行官网,CORS 安全机制将形同虚设。
  • 真相: Origin 的存在不由后端决定,但它的“生死存亡”由后端决定。后端通过校验这个自动带上的 Origin 来决定是否给浏览器发放“过路费”(即 Access-Control-Allow-Origin 响应头)。

四、 避坑指南:如何彻底解决?

1. 后端侧(根治方案)

不要只配置 Origin。如果前端有自定义 Header(如分布式追踪、身份令牌),必须在 Access-Control-Allow-Headers 中显式添加。

  • 代码示例(Node.js):

    JavaScript

    res.header("Access-Control-Allow-Headers", "Content-Type, traceparent, Your-Custom-Header");
    

2. 前端侧(调试技巧)

当你看到 “Provisional headers are shown” 且伴随 CORS error 时:

  • 第一步: 检查 OPTIONS 请求头里的 Access-Control-Request-Headers
  • 第二步: 检查 OPTIONS 响应头里的 Access-Control-Allow-Headers
  • 第三步: 找茬,看谁多了,看谁少了。

结语

Web 开发中,CORS 不是敌人,而是保护数据的保镖。当你理解了浏览器、服务器和安全策略之间的那场“礼貌洽谈”,这些诡异的红色报错就不再是迷雾。

昨天以前首页

设计模式-行为型

作者 牛奶
2026年2月11日 16:08

设计模式-行为型

本文主要介绍下行为型设计模式,包括策略模式模板方法模式观察者模式迭代器模式责任链模式命令模式备忘录模式状态模式访问者模式中介者模式解释器模式,提供前端场景和 ES6 代码的实现过程。 供自己以后查漏补缺,也欢迎同道朋友交流学习。

引言

本文主要介绍下行为型设计模式,包括策略模式模板方法模式观察者模式迭代器模式责任链模式命令模式备忘录模式状态模式访问者模式中介者模式解释器模式,提供前端场景和 ES6 代码的实现过程。

什么是行为型

行为型模式(Behavioral Patterns)主要关注对象之间的通信职责分配。这些模式描述了对象之间如何协作共同完成任务,以及如何分配职责。行为型模式不仅关注类和对象的结构,更关注它们之间的相互作用,通过定义清晰的通信机制,解决系统中复杂的控制流问题,使得代码更加清晰、灵活和易于维护。

行为型模式

策略模式(Strategy)

策略模式(Strategy Pattern)定义一系列的算法,把它们一个个封装起来,并使它们可以相互替换。策略模式让算法独立于使用它的客户而变化。

前端中的策略模式场景

  • 表单验证:将不同的验证规则(如非空、邮箱格式、手机号格式)封装成策略,根据需要选择验证策略。
  • 不同业务逻辑处理:例如,根据用户权限(普通用户、VIP、管理员)展示不同的 UI 或执行不同的逻辑。
  • 缓动动画算法:在动画库中,提供多种缓动函数(如 linearease-inease-out)供用户选择。

策略模式-JS实现

// 策略对象
const strategies = {
  "S": (salary) => salary * 4,
  "A": (salary) => salary * 3,
  "B": (salary) => salary * 2
};

// 环境类(Context)
class Bonus {
  constructor(salary, strategy) {
    this.salary = salary;
    this.strategy = strategy;
  }

  getBonus() {
    return strategies[this.strategy](this.salary);
  }
}

// 客户端调用
const bonusS = new Bonus(10000, "S");
console.log(bonusS.getBonus()); // 40000

const bonusA = new Bonus(10000, "A");
console.log(bonusA.getBonus()); // 30000

模板方法模式(Template Method)

模板方法模式(Template Method Pattern)定义一个操作中的算法的骨架,而将一些步骤延迟到子类中实现。

模板方法使得子类可以在不改变算法结构的情况下,重新定义算法的某些步骤。

前端中的模板方法模式场景

  • UI组件生命周期VueReact 组件的生命周期钩子(如 componentDidMountcreated)就是模板方法模式的应用。框架定义了组件渲染的整体流程,开发者在特定的钩子中实现自定义逻辑。
  • HTTP请求封装:定义一个基础的请求类,处理通用的配置(如 URL、Headers),子类实现具体的请求逻辑(如 GET、POST 参数处理)。

模板方法模式-JS实现

// 抽象父类:饮料
class Beverage {
  // 模板方法,定义算法骨架
  makeBeverage() {
    this.boilWater();
    this.brew();
    this.pourInCup();
    this.addCondiments();
  }

  boilWater() {
    console.log("煮沸水");
  }

  pourInCup() {
    console.log("倒进杯子");
  }

  // 抽象方法,子类必须实现
  brew() {
    throw new Error("抽象方法不能调用");
  }

  addCondiments() {
    throw new Error("抽象方法不能调用");
  }
}

// 具体子类:咖啡
class Coffee extends Beverage {
  brew() {
    console.log("用沸水冲泡咖啡");
  }

  addCondiments() {
    console.log("加糖和牛奶");
  }
}

// 具体子类:茶
class Tea extends Beverage {
  brew() {
    console.log("用沸水浸泡茶叶");
  }

  addCondiments() {
    console.log("加柠檬");
  }
}

// 客户端调用
const coffee = new Coffee();
coffee.makeBeverage();
// 输出:
// 煮沸水
// 用沸水冲泡咖啡
// 倒进杯子
// 加糖和牛奶

const tea = new Tea();
tea.makeBeverage();
// 输出:
// 煮沸水
// 用沸水浸泡茶叶
// 倒进杯子
// 加柠檬

观察者模式(Observer)

观察者模式(Observer Pattern)定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。

前端中的观察者模式场景

  • DOM事件监听document.addEventListener 就是最典型的观察者模式。
  • Promisethen 方法也是一种观察者模式,当 Promise 状态改变时,执行相应的回调。
  • Vue响应式系统Dep(目标)和 Watcher(观察者)实现了数据的响应式更新。
  • RxJS:基于观察者模式的响应式编程库。
  • Event Bus:事件总线。

观察者模式-JS实现

// 目标对象(Subject)
class Subject {
  constructor() {
    this.observers = [];
  }

  addObserver(observer) {
    this.observers.push(observer);
  }

  removeObserver(observer) {
    const index = this.observers.indexOf(observer);
    if (index > -1) {
      this.observers.splice(index, 1);
    }
  }

  notify(data) {
    this.observers.forEach(observer => {
      observer.update(data);
    });
  }
}

// 观察者对象(Observer)
class Observer {
  constructor(name) {
    this.name = name;
  }

  update(data) {
    console.log(`${this.name} 收到通知: ${data}`);
  }
}

// 客户端调用
const subject = new Subject();
const observer1 = new Observer("观察者1");
const observer2 = new Observer("观察者2");

subject.addObserver(observer1);
subject.addObserver(observer2);

subject.notify("更新数据了!");
// 输出:
// 观察者1 收到通知: 更新数据了!
// 观察者2 收到通知: 更新数据了!

迭代器模式(Iterator)

迭代器模式(Iterator Pattern)提供一种方法顺序访问一个聚合对象中的各个元素,而又不暴露其内部的表示。

前端中的迭代器模式场景

  • 数组遍历forEachmap 等数组方法。
  • ES6 IteratorSymbol.iterator 接口,使得对象可以使用 for...of 循环遍历。
  • Generators:生成器函数可以生成迭代器。

迭代器模式-JS实现

// 自定义迭代器
class Iterator {
  constructor(items) {
    this.items = items;
    this.index = 0;
  }

  hasNext() {
    return this.index < this.items.length;
  }

  next() {
    return this.items[this.index++];
  }
}

// 客户端调用
const items = [1, 2, 3];
const iterator = new Iterator(items);

while (iterator.hasNext()) {
  console.log(iterator.next());
}
// 输出:1 2 3

// ES6 Iterator 示例
const iterableObj = {
  items: [10, 20, 30],
  [Symbol.iterator]() {
    let index = 0;
    return {
      next: () => {
        if (index < this.items.length) {
          return { value: this.items[index++], done: false };
        } else {
          return { done: true };
        }
      }
    };
  }
};

for (const item of iterableObj) {
  console.log(item);
}
// 输出:10 20 30

责任链模式(Chain of Responsibility)

责任链模式(Chain of Responsibility Pattern)使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系。将这些对象连成一条链,并沿着这条链传递该请求,直到有一个对象处理它为止。

前端中的责任链模式场景

  • 事件冒泡:DOM 事件在 DOM 树中的冒泡机制就是责任链模式。
  • 中间件ExpressKoaRedux 中的中间件机制。
  • 拦截器Axios 的请求和响应拦截器。

责任链模式-JS实现

// 处理器基类
class Handler {
  setNext(handler) {
    this.nextHandler = handler;
    return handler; // 返回 handler 以支持链式调用
  }

  handleRequest(request) {
    if (this.nextHandler) {
      this.nextHandler.handleRequest(request);
    }
  }
}

// 具体处理器
class HandlerA extends Handler {
  handleRequest(request) {
    if (request === 'A') {
      console.log("HandlerA 处理了请求");
    } else {
      super.handleRequest(request);
    }
  }
}

class HandlerB extends Handler {
  handleRequest(request) {
    if (request === 'B') {
      console.log("HandlerB 处理了请求");
    } else {
      super.handleRequest(request);
    }
  }
}

class HandlerC extends Handler {
  handleRequest(request) {
    if (request === 'C') {
      console.log("HandlerC 处理了请求");
    } else {
      console.log("没有处理器处理该请求");
    }
  }
}

// 客户端调用
const handlerA = new HandlerA();
const handlerB = new HandlerB();
const handlerC = new HandlerC();

handlerA.setNext(handlerB).setNext(handlerC);

handlerA.handleRequest('A'); // HandlerA 处理了请求
handlerA.handleRequest('B'); // HandlerB 处理了请求
handlerA.handleRequest('C'); // HandlerC 处理了请求
handlerA.handleRequest('D'); // 没有处理器处理该请求

命令模式(Command)

命令模式(Command Pattern)将一个请求封装为一个对象,从而使用户可用不同的请求对客户进行参数化;对请求排队或记录请求日志,以及支持可撤销的操作。

前端中的命令模式场景

  • 富文本编辑器:执行加粗、斜体、下划线等操作,并支持撤销(Undo)和重做(Redo)。
  • 菜单和按钮:将菜单项或按钮的操作封装成命令对象。

命令模式-JS实现

// 接收者:执行实际命令
class Receiver {
  execute() {
    console.log("执行命令");
  }
}

// 命令对象
class Command {
  constructor(receiver) {
    this.receiver = receiver;
  }

  execute() {
    this.receiver.execute();
  }
}

// 调用者:发起命令
class Invoker {
  constructor(command) {
    this.command = command;
  }

  invoke() {
    console.log("调用者发起请求");
    this.command.execute();
  }
}

// 客户端调用
const receiver = new Receiver();
const command = new Command(receiver);
const invoker = new Invoker(command);

invoker.invoke();
// 输出:
// 调用者发起请求
// 执行命令

备忘录模式(Memento)

备忘录模式(Memento Pattern)在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态。这样以后就可将该对象恢复到原先保存的状态。

前端中的备忘录模式场景

  • 状态管理Redux 等状态管理库的时间旅行(Time Travel)功能。
  • 表单草稿:保存用户输入的表单内容,以便下次恢复。
  • 撤销/重做:编辑器中的撤销和重做功能。

备忘录模式-JS实现

// 备忘录:保存状态
class Memento {
  constructor(state) {
    this.state = state;
  }

  getState() {
    return this.state;
  }
}

// 发起人:需要保存状态的对象
class Originator {
  constructor() {
    this.state = "";
  }

  setState(state) {
    this.state = state;
    console.log(`当前状态: ${this.state}`);
  }

  saveStateToMemento() {
    return new Memento(this.state);
  }

  getStateFromMemento(memento) {
    this.state = memento.getState();
    console.log(`恢复状态: ${this.state}`);
  }
}

// 管理者:管理备忘录
class Caretaker {
  constructor() {
    this.mementos = [];
  }

  add(memento) {
    this.mementos.push(memento);
  }

  get(index) {
    return this.mementos[index];
  }
}

// 客户端调用
const originator = new Originator();
const caretaker = new Caretaker();

originator.setState("状态1");
originator.setState("状态2");
caretaker.add(originator.saveStateToMemento()); // 保存状态2

originator.setState("状态3");
caretaker.add(originator.saveStateToMemento()); // 保存状态3

originator.setState("状态4");

originator.getStateFromMemento(caretaker.get(0)); // 恢复到状态2
originator.getStateFromMemento(caretaker.get(1)); // 恢复到状态3

状态模式(State)

状态模式(State Pattern)允许一个对象在其内部状态改变时改变它的行为。对象看起来似乎修改了它的类。

前端中的状态模式场景

  • 有限状态机(FSM):例如,Promise 的状态(Pending, Fulfilled, Rejected)。
  • 组件状态管理:例如,一个按钮可能有 loadingdisableddefault 等状态,不同状态下点击行为不同。
  • 游戏开发:角色的不同状态(如站立、奔跑、跳跃、攻击)。

状态模式-JS实现

// 状态接口
class State {
  handle(context) {
    throw new Error("抽象方法不能调用");
  }
}

// 具体状态A
class ConcreteStateA extends State {
  handle(context) {
    console.log("当前是状态A");
    context.setState(new ConcreteStateB());
  }
}

// 具体状态B
class ConcreteStateB extends State {
  handle(context) {
    console.log("当前是状态B");
    context.setState(new ConcreteStateA());
  }
}

// 上下文
class Context {
  constructor() {
    this.state = new ConcreteStateA();
  }

  setState(state) {
    this.state = state;
  }

  request() {
    this.state.handle(this);
  }
}

// 客户端调用
const context = new Context();
context.request(); // 当前是状态A
context.request(); // 当前是状态B
context.request(); // 当前是状态A

访问者模式(Visitor)

访问者模式(Visitor Pattern)表示一个作用于某对象结构中的各元素的操作。它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作。

前端中的访问者模式场景

  • AST遍历Babel 插件开发中,通过访问者模式遍历和修改 AST(抽象语法树)节点。
  • 复杂数据结构处理:对树形结构或图形结构进行不同的操作(如渲染、序列化、验证)。

访问者模式-JS实现

// 元素类
class Element {
  accept(visitor) {
    throw new Error("抽象方法不能调用");
  }
}

class ConcreteElementA extends Element {
  accept(visitor) {
    visitor.visitConcreteElementA(this);
  }

  operationA() {
    return "ElementA";
  }
}

class ConcreteElementB extends Element {
  accept(visitor) {
    visitor.visitConcreteElementB(this);
  }

  operationB() {
    return "ElementB";
  }
}

// 访问者类
class Visitor {
  visitConcreteElementA(element) {
    console.log(`访问者访问 ${element.operationA()}`);
  }

  visitConcreteElementB(element) {
    console.log(`访问者访问 ${element.operationB()}`);
  }
}

// 客户端调用
const elementA = new ConcreteElementA();
const elementB = new ConcreteElementB();
const visitor = new Visitor();

elementA.accept(visitor); // 访问者访问 ElementA
elementB.accept(visitor); // 访问者访问 ElementB

中介者模式(Mediator)

中介者模式(Mediator Pattern)用一个中介对象来封装一系列的对象交互。中介者使各对象不需要显式地相互引用,从而使其耦合松散,而且可以独立地改变它们之间的交互。

前端中的中介者模式场景

  • MVC/MVVM框架:Controller 或 ViewModel 充当中介者,协调 View 和 Model 之间的交互。
  • 复杂表单交互:例如,选择省份后,城市下拉框需要更新;选择城市后,区县下拉框需要更新。使用中介者统一管理这些联动逻辑。
  • 聊天室:用户之间不直接发送消息,而是通过服务器(中介者)转发。

中介者模式-JS实现

// 中介者
class ChatMediator {
  constructor() {
    this.users = [];
  }

  addUser(user) {
    this.users.push(user);
    user.setMediator(this);
  }

  sendMessage(message, user) {
    this.users.forEach(u => {
      if (u !== user) {
        u.receive(message);
      }
    });
  }
}

// 用户类
class User {
  constructor(name) {
    this.name = name;
    this.mediator = null;
  }

  setMediator(mediator) {
    this.mediator = mediator;
  }

  send(message) {
    console.log(`${this.name} 发送消息: ${message}`);
    this.mediator.sendMessage(message, this);
  }

  receive(message) {
    console.log(`${this.name} 收到消息: ${message}`);
  }
}

// 客户端调用
const mediator = new ChatMediator();
const user1 = new User("User1");
const user2 = new User("User2");
const user3 = new User("User3");

mediator.addUser(user1);
mediator.addUser(user2);
mediator.addUser(user3);

user1.send("大家好!");
// 输出:
// User1 发送消息: 大家好!
// User2 收到消息: 大家好!
// User3 收到消息: 大家好!

解释器模式(Interpreter)

解释器模式(Interpreter Pattern)给定一个语言,定义它的文法的一种表示,并定义一个解释器,该解释器用来解释语言中的句子。

前端中的解释器模式场景

  • 模板引擎MustacheHandlebars 等模板引擎,解析模板字符串并生成 HTML。
  • 编译器前端:将代码解析为 AST。
  • 数学表达式计算:解析并计算字符串形式的数学表达式。

解释器模式-JS实现

// 抽象表达式
class Expression {
  interpret(context) {
    throw new Error("抽象方法不能调用");
  }
}

// 终结符表达式:数字
class NumberExpression extends Expression {
  constructor(number) {
    super();
    this.number = number;
  }

  interpret(context) {
    return this.number;
  }
}

// 非终结符表达式:加法
class AddExpression extends Expression {
  constructor(left, right) {
    super();
    this.left = left;
    this.right = right;
  }

  interpret(context) {
    return this.left.interpret(context) + this.right.interpret(context);
  }
}

// 非终结符表达式:减法
class SubtractExpression extends Expression {
  constructor(left, right) {
    super();
    this.left = left;
    this.right = right;
  }

  interpret(context) {
    return this.left.interpret(context) - this.right.interpret(context);
  }
}

// 客户端调用:计算 10 + 5 - 2
const expression = new SubtractExpression(
  new AddExpression(new NumberExpression(10), new NumberExpression(5)),
  new NumberExpression(2)
);

console.log(expression.interpret()); // 13

项目地址

设计模式-结构型

作者 牛奶
2026年2月11日 16:00

设计模式-结构型

本文主要介绍下结构型设计模式,包括适配器模式装饰器模式代理模式外观模式桥接模式组合模式享元模式,提供前端场景和 ES6 代码的实现过程。 供自己以后查漏补缺,也欢迎同道朋友交流学习。

引言

本文主要介绍下结构型设计模式,包括适配器模式装饰器模式代理模式外观模式桥接模式组合模式享元模式,提供前端场景和 ES6 代码的实现过程。

什么是结构型

结构型模式(Structural Patterns)主要关注对象组合。这些模式描述了如何将类或对象结合在一起形成更大的结构,同时保持结构的灵活高效。结构型模式通过继承组合的方式来简化系统的设计,解决对象之间的耦合问题,使得系统更加容易扩展和维护。

适配器模式(Adapter)

适配器模式(Adapter Pattern)将一个类的接口转换成客户希望的另一个接口,使原本因接口不兼容而不能一起工作的类可以一起工作。

适配器模式通常用于包装现有的类,以便与新的接口或系统进行交互。

前端中的适配器模式场景

  • 接口数据适配:后端返回的数据结构可能与前端组件需要的数据结构不一致,可以使用适配器模式进行转换。
  • 旧接口兼容:在系统重构或升级时,保持对旧接口的兼容性,使用适配器模式将新接口映射到旧接口。
  • Vue计算属性:Vue 中的 computed 属性可以看作是一种适配器,将原始数据转换为视图需要的数据格式。

适配器模式-JS实现

// 旧接口
class OldCalculator {
  constructor() {
    this.operations = function(term1, term2, operation) {
      switch (operation) {
        case 'add':
          return term1 + term2;
        case 'sub':
          return term1 - term2;
        default:
          return NaN;
      }
    };
  }
}

// 新接口
class NewCalculator {
  add(term1, term2) {
    return term1 + term2;
  }

  sub(term1, term2) {
    return term1 - term2;
  }
}

// 适配器类
class CalculatorAdapter {
  constructor() {
    this.calculator = new NewCalculator();
  }

  operations(term1, term2, operation) {
    switch (operation) {
      case 'add':
        return this.calculator.add(term1, term2);
      case 'sub':
        return this.calculator.sub(term1, term2);
      default:
        return NaN;
    }
  }
}

// 客户端调用
const oldCalc = new OldCalculator();
console.log(oldCalc.operations(10, 5, 'add')); // 15

const newCalc = new NewCalculator();
console.log(newCalc.add(10, 5)); // 15

const adapter = new CalculatorAdapter();
console.log(adapter.operations(10, 5, 'add')); // 15

装饰器模式(Decorator)

装饰器模式(Decorator Pattern动态地给一个对象添加一些额外的职责,而不影响该对象所属类的其他实例。

装饰器模式提供了一种灵活的替代继承方案,用于扩展对象的功能。

前端中的装饰器模式场景

  • 高阶组件(HOC):在 React 中,高阶组件本质上就是装饰器模式的应用,用于复用组件逻辑。
  • 类装饰器:在 ES7 装饰器语法或 TypeScript 中,可以使用装饰器来增强类或类的方法,例如用于日志记录、性能监控、权限控制等。

装饰器模式-JS实现

// 原始对象
class Circle {
  draw() {
    console.log("画一个圆形");
  }
}

// 装饰器基类
class Decorator {
  constructor(circle) {
    this.circle = circle;
  }

  draw() {
    this.circle.draw();
  }
}

// 具体装饰器:添加红色边框
class RedBorderDecorator extends Decorator {
  draw() {
    this.circle.draw();
    this.setRedBorder();
  }

  setRedBorder() {
    console.log("添加红色边框");
  }
}

// 客户端调用
const circle = new Circle();
circle.draw();
// 输出:
// 画一个圆形

const redCircle = new RedBorderDecorator(new Circle());
redCircle.draw();
// 输出:
// 画一个圆形
// 添加红色边框

代理模式(Proxy)

代理模式(Proxy Pattern)为其他对象提供一种代理以控制对这个对象的访问。

代理模式可以在访问对象之前或之后执行额外的操作,如权限验证、延迟加载、缓存等。

前端中的代理模式场景

  • 数据响应式Vue 3 使用 Proxy 对象来实现数据的响应式系统,拦截对象的读取和修改操作。
  • 网络请求代理:在开发环境中,配置代理服务器(如 webpack-dev-server 的 proxy)解决跨域问题。
  • 虚拟代理:例如图片懒加载,先显示占位图,等图片加载完成后再替换为真实图片。
  • 缓存代理:对于开销较大的计算结果或网络请求结果进行缓存,下次请求时直接返回缓存结果。

代理模式-JS实现

// 真实图片加载类
class RealImage {
  constructor(fileName) {
    this.fileName = fileName;
    this.loadFromDisk(fileName);
  }

  loadFromDisk(fileName) {
    console.log("正在从磁盘加载 " + fileName);
  }

  display() {
    console.log("显示 " + this.fileName);
  }
}

// 代理图片类
class ProxyImage {
  constructor(fileName) {
    this.fileName = fileName;
  }

  display() {
    if (!this.realImage) {
      this.realImage = new RealImage(this.fileName);
    }
    this.realImage.display();
  }
}

// 客户端调用
const image = new ProxyImage("test.jpg");

// 第一次调用,加载图片
image.display();
// 输出:
// 正在从磁盘加载 test.jpg
// 显示 test.jpg

// 第二次调用,直接显示
image.display();
// 输出:
// 显示 test.jpg

外观模式(Facade)

外观模式(Facade Pattern)提供一个统一的接口,用来访问子系统中的一群接口。外观模式定义了一个高层接口,让子系统更容易使用。

前端中的外观模式场景

  • 浏览器兼容性封装:封装不同浏览器的 API 差异,提供统一的接口。例如,封装事件监听函数,统一处理 addEventListenerattachEvent
  • 简化复杂库的使用:例如 jQueryAxios,它们为复杂的原生 DOM 操作或 XMLHttpRequest 提供了简单易用的接口。

外观模式-JS实现

// 子系统1:灯光
class Light {
  on() {
    console.log("开灯");
  }
  off() {
    console.log("关灯");
  }
}

// 子系统2:电视
class TV {
  on() {
    console.log("打开电视");
  }
  off() {
    console.log("关闭电视");
  }
}

// 子系统3:音响
class SoundSystem {
  on() {
    console.log("打开音响");
  }
  off() {
    console.log("关闭音响");
  }
}

// 外观类:家庭影院
class HomeTheaterFacade {
  constructor(light, tv, sound) {
    this.light = light;
    this.tv = tv;
    this.sound = sound;
  }

  watchMovie() {
    console.log("--- 准备看电影 ---");
    this.light.off();
    this.tv.on();
    this.sound.on();
  }

  endMovie() {
    console.log("--- 结束看电影 ---");
    this.light.on();
    this.tv.off();
    this.sound.off();
  }
}

// 客户端调用
const light = new Light();
const tv = new TV();
const sound = new SoundSystem();
const homeTheater = new HomeTheaterFacade(light, tv, sound);

homeTheater.watchMovie();
// 输出:
// --- 准备看电影 ---
// 关灯
// 打开电视
// 打开音响

homeTheater.endMovie();
// 输出:
// --- 结束看电影 ---
// 开灯
// 关闭电视
// 关闭音响

桥接模式(Bridge)

桥接模式(Bridge Pattern)将抽象部分与它的实现部分分离,使它们可以独立地变化。

前端中的桥接模式场景

  • UI组件与渲染引擎分离:例如,一个通用的图表库,可以将图表的逻辑(抽象部分)与具体的渲染方式(实现部分,如 Canvas、SVG、WebGL)分离。
  • 事件监听:在绑定事件时,将回调函数(实现部分)与事件绑定(抽象部分)分离,使得回调函数可以复用。

桥接模式-JS实现

// 实现部分接口:颜色
class Color {
  fill() {
    throw new Error("抽象方法不能调用");
  }
}

class Red extends Color {
  fill() {
    return "红色";
  }
}

class Green extends Color {
  fill() {
    return "绿色";
  }
}

// 抽象部分:形状
class Shape {
  constructor(color) {
    this.color = color;
  }

  draw() {
    throw new Error("抽象方法不能调用");
  }
}

class Circle extends Shape {
  draw() {
    console.log(`画一个${this.color.fill()}的圆形`);
  }
}

class Square extends Shape {
  draw() {
    console.log(`画一个${this.color.fill()}的正方形`);
  }
}

// 客户端调用
const redCircle = new Circle(new Red());
redCircle.draw(); // 画一个红色的圆形

const greenSquare = new Square(new Green());
greenSquare.draw(); // 画一个绿色的正方形

组合模式(Composite)

组合模式(Composite Pattern)将对象组合成树形结构以表示“部分-整体”的层次结构。组合模式使得用户对单个对象和组合对象的使用具有一致性。

前端中的组合模式场景

  • DOM树:DOM 树本身就是一个典型的组合模式结构,既包含具体的节点(如 divspan),也包含包含其他节点的容器。
  • 虚拟DOMVirtual DOM 也是树形结构,组件可以包含其他组件或原生元素。
  • 文件目录系统:文件夹可以包含文件或子文件夹。
  • 级联菜单:多级菜单的展示和操作。

组合模式-JS实现

// 组件基类
class Component {
  constructor(name) {
    this.name = name;
  }

  add(component) {
    throw new Error("不支持该操作");
  }

  remove(component) {
    throw new Error("不支持该操作");
  }

  print(indent = "") {
    throw new Error("不支持该操作");
  }
}

// 叶子节点:文件
class File extends Component {
  print(indent = "") {
    console.log(`${indent}- ${this.name}`);
  }
}

// 组合节点:文件夹
class Folder extends Component {
  constructor(name) {
    super(name);
    this.children = [];
  }

  add(component) {
    this.children.push(component);
  }

  remove(component) {
    const index = this.children.indexOf(component);
    if (index > -1) {
      this.children.splice(index, 1);
    }
  }

  print(indent = "") {
    console.log(`${indent}+ ${this.name}`);
    this.children.forEach(child => {
      child.print(indent + "  ");
    });
  }
}

// 客户端调用
const root = new Folder("根目录");
const folder1 = new Folder("文档");
const folder2 = new Folder("图片");

const file1 = new File("简历.doc");
const file2 = new File("照片.jpg");
const file3 = new File("logo.png");

root.add(folder1);
root.add(folder2);
folder1.add(file1);
folder2.add(file2);
folder2.add(file3);

root.print();
// 输出:
// + 根目录
//   + 文档
//     - 简历.doc
//   + 图片
//     - 照片.jpg
//     - logo.png

享元模式(Flyweight)

享元模式(Flyweight Pattern)通过共享来高效地支持大量细粒度的对象。

前端中的享元模式场景

  • 事件委托:在父元素上绑定事件监听器,通过事件冒泡处理子元素的事件,避免为每个子元素绑定监听器,节省内存。
  • 对象池:在游戏开发或复杂动画中,预先创建一组对象放入池中,使用时取出,用完归还,避免频繁创建和销毁对象。
  • DOM复用:在长列表滚动(虚拟滚动)中,只渲染可视区域的 DOM 节点,回收并复用移出可视区域的节点。

享元模式-JS实现

// 享元工厂
class ShapeFactory {
  constructor() {
    this.circleMap = {};
  }

  getCircle(color) {
    if (!this.circleMap[color]) {
      this.circleMap[color] = new Circle(color);
      console.log(`创建新的 ${color} 圆形`);
    }
    return this.circleMap[color];
  }
}

// 具体享元类
class Circle {
  constructor(color) {
    this.color = color;
  }

  draw(x, y) {
    console.log(`在 (${x}, ${y}) 画一个 ${this.color} 的圆形`);
  }
}

// 客户端调用
const factory = new ShapeFactory();

const redCircle1 = factory.getCircle("红色");
redCircle1.draw(10, 10);

const redCircle2 = factory.getCircle("红色");
redCircle2.draw(20, 20);

const blueCircle = factory.getCircle("蓝色");
blueCircle.draw(30, 30);

console.log(redCircle1 === redCircle2); // true
// 输出:
// 创建新的 红色 圆形
// 在 (10, 10) 画一个 红色 的圆形
// 在 (20, 20) 画一个 红色 的圆形
// 创建新的 蓝色 圆形
// 在 (30, 30) 画一个 蓝色 的圆形
// true

项目地址

Flink ClickHouse Sink:生产级高可用写入方案|得物技术

作者 得物技术
2026年2月10日 11:11

一、背景与痛点

业务场景

在实时大数据处理场景中,Flink + ClickHouse 的组合被广泛应用于:

  • 日志处理: 海量应用日志实时写入分析库。
  • 监控分析: 业务指标、APM 数据的实时聚合。

这些场景的共同特点:

  • 数据量大:百万级 TPS,峰值可达千万级。
  • 写入延迟敏感: 需要秒级可见。
  • 数据准确性要求高:不允许数据丢失。
  • 多表写入: 不同数据根据分表策略写入不同的表。

开源 Flink ClickHouse Sink 的痛点

Flink 官方提供的 ClickHouse Sink(flink-connector-jdbc)在生产环境中存在以下严重问题:

痛点一:缺乏基于数据量的攒批机制

问题表现:

// Flink 官方 JDBC Sink 的实现
public class JdbcSink<Textends RichSinkFunction<T> {
    private final int batchSize;  // 固定批次大小
    @Override
    public void invoke(value, Context context) {
        bufferedValues.add(value);
        if (bufferedValues.size() >= batchSize) {
            // 只能基于记录数攒批,无法基于数据量
            flush();
        }
    }

带来的问题:

  1. 内存占用不可控: 100 条 1KB 的日志和 100 条 10MB 的日志占用内存差距 100 倍。
  2. OOM 风险高: 大日志记录(如堆栈转储)会迅速撑爆内存。
  3. 写入性能差: 无法根据记录大小动态调整批次,导致小记录批次过大浪费网络开销。

痛点二:无法支持动态表结构

问题表现:

// Flink 官方 Sink 只能写入固定表
public class JdbcSink {
    private final String sql;  // 固定的 INSERT SQL
    public JdbcSink(String jdbcUrl, String sql, ...) {
        this.sql = sql;  // 硬编码的表结构
    }
}

带来的问题:

  1. 多应用无法隔离: 所有应用的数据写入同一张表,通过特定分表策略区分。
  2. 扩展性差: 新增应用需要手动建表,无法动态路由。
  3. 性能瓶颈: 单表数据量过大(百亿级),查询和写入性能急剧下降。

痛点三:分布式表写入性能问题

问题表现:

// 大多数生产实现直接写入分布式表
INSERT INTO distributed_table_all VALUES (...)

ClickHouse 分布式表的工作原理:

带来的问题:

  1. 网络开销大: 数据需要经过分布式表层转发,延迟增加。
  2. 写入性能差: 分布式表增加了路由和转发逻辑,吞吐量降低。
  3. 热点问题: 所有数据先到分布式表节点,再转发,造成单点瓶颈。

生产级方案的核心改进

针对以上痛点,本方案提供了以下核心改进:

改进一:基于数据量的攒批机制

public class ClickHouseSinkCounter {
    private Long metaSize;  // 累计数据量(字节)
    public void add(LogModel value) {
        this.values.add(value);
        this.metaSize += value.getMetaSize();  // 累加数据量
    }
}
// 触发条件
private boolean flushCondition(String application) {
    return checkMetaSize(application)  // metaSize >= 10000 字节
        || checkTime(application);     // 或超时 30 秒
}

优势:

  • 内存可控: 根据数据量而非记录数攒批。
  • 精确控制: 1KB 的记录攒 10000 条 = 10MB,1MB 的记录攒 10 条 = 10MB。
  • 避免OOM: 大日志记录不会撑爆内存。

改进二:动态表结构与分片策略

public abstract class ClickHouseShardStrategy<T> {
    public abstract String getTableName(T data);
}
//日志侧实现为应用级分表
public class LogClickHouseShardStrategy extends ClickHouseShardStrategy<String> {
    @Override
    public String getTableName(String application) {
        // 动态路由:order-service → tb_logs_order_service
        return String.format("tb_logs_%s", application);
    }
}

优势:

  • 应用隔离: 日志侧内置应用级分表,每个应用独立分表。
  • 动态路由: 根据 application 自动路由到目标表。
  • 扩展性强: 新增应用无需手动建表(配合 ClickHouse 自动建表)。

改进三:本地表写入 + 动态节点发现

public class ClickHouseLocalWriter extends ClickHouseWriter {
    // 直接写本地表,避免分布式表转发
    private final ConcurrentMap<String, HikariDataSource> dataSourceMap;
    @Override
    public HikariDataSource getNextDataSource(Set<String> exceptionHosts) {
        // 1. 动态获取集群节点列表
        List<String> healthyHosts = getHealthyHosts(exceptionHosts);
        // 2. 随机选择健康节点
        return dataSourceMap.get(healthyHosts.get(random.nextInt(size)));
    }
}

优势:

  • 性能提升: 直接写本地表,避免网络转发。
  • 高可用: 动态节点发现 + 故障节点剔除。
  • 负载均衡: 随机选择 + Shuffle 初始化。

技术方案概览

基于以上改进,本方案提供了以下核心能力:

  1. 本地表/分布式表写入: 性能优化与高可用平衡。
  2. 分片策略: 按应用维度路由与隔离。
  3. 攒批与内存控制: 双触发机制(数据量 + 超时)。
  4. 流量控制与限流: 有界队列 + 连接池。
  5. 健壮的重试机制: 递归重试 + 故障节点剔除。
  6. Checkpoint 语义保证: At-Least-Once 数据一致性。

二、核心架构设计

架构图

核心组件

核心流程

三、本地表 vs 分布式表写入

ClickHouse 表结构说明

ClickHouse 推荐直接写本地表,原因:

  1. 写入性能: 避免分布式表的网络分发。
  2. 数据一致性: 直接写入目标节点,减少中间环节故障点,比分布式表写入更安全,利于工程化。
  3. 负载均衡: 客户端路由实现负载分散。
-- 本地表(实际存储数据)
CREATE TABLE tb_logs_local ON CLUSTER 'default' (
    application String,
    environment String,
    message String,
    log_time DateTime
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(log_time)
ORDER BY (application, log_time);
-- 分布式表(逻辑视图,不存储数据)
CREATE TABLE tb_logs_all ON CLUSTER 'default' AS tb_logs_local
ENGINE = Distributed('default', dw_log, tb_logs_local, cityHash64(application));

HikariCP 连接池配置

// HikariCP 连接池配置
public class ClickHouseDataSourceUtils {
    private static HikariConfig getHikariConfig(DataSourceImpl dataSource) {
        HikariConfig config = new HikariConfig();
        config.setConnectionTimeout(30000L);    // 连接超时 30s
        config.setMaximumPoolSize(20);          // 最大连接数 20
        config.setMinimumIdle(2);               // 最小空闲 2
        config.setDataSource(dataSource);
        return config;
    }
    private static Properties getClickHouseProperties(ClickHouseSinkCommonParams params) {
        Properties props = new Properties();
        props.setProperty("user", params.getUser());
        props.setProperty("password", params.getPassword());
        props.setProperty("database", params.getDatabase());
        props.setProperty("socket_timeout""180000");      // Socket 超时 3 分钟
        props.setProperty("socket_keepalive""true");      // 保持连接
        props.setProperty("http_connection_provider""APACHE_HTTP_CLIENT");
        return props;
    }
}

配置说明:

  • maxPoolSize=20:每个 ClickHouse 节点最多 20 个连接。
  • minIdle=2:保持 2 个空闲连接,避免频繁创建。
  • socket_timeout=180s:Socket 超时 3 分钟,防止长时间查询阻塞。

ClickHouseLocalWriter:动态节点发现

public class ClickHouseLocalWriter extends ClickHouseWriter {
    // 本地节点缓存,按 IP 维护
    private final ConcurrentMap<StringHikariDataSource> dataSourceMap;
    // 动态获取集群本地表节点
    private final ClusterIpsUtils clusterIpsUtils;
    // IP 变更标志(CAS 锁,避免并发更新)
    private static final AtomicBoolean IP_CHANGING = new AtomicBoolean(false);
    @Override
    public HikariDataSource getNextDataSource(Set<String> exceptionHosts) {
        // 1️⃣ 检测集群节点变化(通过 CAS 避免并发更新)
        if (clusterIpsChanged() && IP_CHANGING.compareAndSet(falsetrue)) {
            try {
                ipChanged(); // 动态更新 dataSourceMap
            } finally {
                IP_CHANGING.set(false);
            }
        }
        // 2️⃣ 获取异常节点列表(从 Redis + APM 实时查询)
        Set<String> exceptIps = clusterIpsUtils.getExceptIps();
        exceptIps.addAll(exceptionHosts);
        // 3️⃣ 过滤健康节点,随机选择
        List<String> healthyHosts = dataSourceMap.keySet().stream()
            .filter(host -> !exceptIps.contains(host))
            .collect(Collectors.toList());
        if (CollectionUtils.isEmpty(healthyHosts)) {
            throw new RuntimeException("Can't get datasource from local cache");
        }
        return dataSourceMap.get(healthyHosts.get(random.nextInt(healthyHosts.size())));
    }
    private void ipChanged() {
        List<String> clusterIps = clusterIpsUtils.getClusterIps();
        // 新增节点:自动创建连接池
        clusterIps.forEach(ip ->
            dataSourceMap.computeIfAbsent(ip, v ->
                createHikariDataSource(ip, port)
            )
        );
        // 移除下线节点:关闭连接池
        dataSourceMap.forEach((ip, ds) -> {
            if (!clusterIps.contains(ip)) {
                dataSourceMap.remove(ip);
                ds.close();
            }
        });
    }
}

核心逻辑:

  1. 动态节点发现: 从 system.clusters 查询所有节点。
  2. 自动扩缩容: 节点上线自动加入,下线自动剔除。
  3. 故障节点剔除: 通过 APM 监控,自动剔除异常节点。
  4. 负载均衡: 随机选择健康节点,避免热点。

集群节点动态发现(ClusterIpsUtils)

public class ClusterIpsUtils {
    // 从 system.clusters 查询所有节点
    private static final String QUERY_CLUSTER_IPS =
        "select host_address from system.clusters where cluster = 'default'";
    // LoadingCache:定时刷新节点列表(1 小时)
    private final LoadingCache<StringList<String>> clusterIpsCache =
        CacheBuilder.newBuilder()
            .expireAfterAccess(10, TimeUnit.HOURS)
            .refreshAfterWrite(1, TimeUnit.HOURS)
            .build(CacheLoader.asyncReloading(new CacheLoader<>() {
                @Override
                public List<String> load(String dbName) {
                    return queryClusterIps();  // 定时刷新节点列表
                }
            }));
    // 异常节点缓存(1 分钟刷新)
    private final LoadingCache<StringFlinkExceptIpModel> exceptIpsCache =
        CacheBuilder.newBuilder()
            .refreshAfterWrite(1, TimeUnit.MINUTES)
            .build(CacheLoader.asyncReloading(new CacheLoader<>() {
                @Override
                public FlinkExceptIpModel load(String dbName) {
                    return queryExceptIp();  // 从 Redis + APM 查询异常节点
                }
            }));
}

异常节点监控策略:

  • 磁盘使用率 >= 90%: 从 APM 查询 Prometheus 指标,自动加入黑名单。
  • HTTP 连接数 >= 50: 连接数过多说明节点压力大,自动加入黑名单。
  • 人工配置: 通过 Redis 配置手动剔除节点

数据来源:

  1. ClickHouse system.clusters 表: 获取所有集群节点。
  2. APM Prometheus 接口: 监控节点健康状态。
  3. Redis 缓存: 人工配置的异常节点。

负载均衡优化

public class ClickHouseWriter {
    public <T> ClickHouseWriter(...) {
        // Shuffle:随机打乱数据源顺序
        Collections.shuffle(clickHouseDataSources);
        this.clickHouseDataSources = clickHouseDataSources;
    }
    public HikariDataSource getNextDataSource(Set<String> exceptionHosts) {
        // 轮询 + 随机选择(已 shuffle,避免热点)
        int current = this.currentRandom.getAndIncrement();
        if (current >= clickHouseDataSources.size()) {
            this.currentRandom.set(0);
        }
        return clickHouseDataSources.get(currentRandom.get());
    }
}

优势:

  • 初始化时 shuffle,避免所有 writer 同时从第一个节点开始。
  • 轮询 + 随机选择,负载分散更均匀。
  • 故障节点自动剔除。

四、支持分表策略

分片策略抽象

public abstract class ClickHouseShardStrategy<T> {
    private String tableName;      // 表名模板,如 "tb_log_%s"
    private Integer tableCount;    // 分表数量
    // 根据数据决定目标表名
    public abstract String getTableName(T data);
}

日志分片实现

public class LogClickHouseShardStrategy extends ClickHouseShardStrategy<String> {
    @Override
    public String getTableName(String application) {
        // 表名格式:tb_log_{application}
        // 例如:application = "order-service" -> table = "tb_log_order_service"
        return String.format(
            this.getTableName(),
            application.replace("-""_").toLowerCase()
        );
    }
}

按表(应用)维度的缓冲区

日志侧维度降级为应用名称维度缓冲区,实则因为按照应用分表,

业务方可使用自身分表策略携带表名元数据,进行表维度缓冲。

public class ClickHouseShardSinkBuffer {
    // 按 application 分组的缓冲区(ConcurrentHashMap 保证并发安全)
    private final ConcurrentHashMap<StringClickHouseSinkCounter> localValues;
    public void put(LogModel value) {
        String application = value.getApplication();
        // 1️⃣ 检查是否需要 flush
        if (flushCondition(application)) {
            addToQueue(application); // 触发写入
        }
        // 2️⃣ 添加到缓冲区(线程安全的 compute 操作)
        localValues.compute(application, (k, v) -> {
            if (v == null) v = new ClickHouseSinkCounter();
            v.add(value);
            return v;
        });
    }
    private void addToQueue(String application) {
        localValues.computeIfPresent(application, (k, v) -> {
            // 深拷贝并清空(避免并发修改异常)
            List<LogModel> deepCopy = v.copyValuesAndClear();
            // 构造请求 Blank:application + targetTable + values
            String targetTable = shardStrategy.getTableName(application);
            ClickHouseRequestBlank blank = new ClickHouseRequestBlank(deepCopy, application, targetTable);
            // 放入队列
            writer.put(blank);
            return v;
        });
    }
}

核心设计:

  • 应用隔离: 每个表(应用)独立的 buffer,互不影响。
  • 线程安全: 使用 ConcurrentHashMap.compute()保证并发安全。
  • 深拷贝: List.copyOf() 创建不可变副本,避免并发修改。
  • 批量清空: 一次性取出所有数据,清空计数器。

五、攒批与内存控制

双触发机制

public class ClickHouseShardSinkBuffer {
    private final int maxFlushBufferSize;  // 最大批次大小(如 10000)
    private final long timeoutMillis;      // 超时时间(如 30s)
    // 触发条件检查(满足任一即触发)
    private boolean flushCondition(String application) {
        return localValues.get(application) != null
            && (checkMetaSize(application) || checkTime(application));
    }
    // 条件1:达到批次大小
    private boolean checkMetaSize(String application) {
        return localValues.get(application).getMetaSize() >= maxFlushBufferSize;
    }
    // 条件2:超时
    private boolean checkTime(String application) {
        long current = System.currentTimeMillis();
        return current - localValues.get(application).getInsertTime() > timeoutMillis;
    }
}

批次大小计算

public class ClickHouseSinkCounter {
    private final List<LogModel> values;
    private Long metaSize; // 累计的 metaSize(字节)
    public void add(LogModel value) {
        this.values.add(value);
        this.metaSize += value.getMetaSize(); // 累加 metaSize
    }
    public List<LogModel> copyValuesAndClear() {
        List<LogModel> logModels = List.copyOf(this.values); // 深拷贝(不可变)
        this.values.clear();
        this.metaSize = 0L;
        this.insertTime = System.currentTimeMillis();
        return logModels;
    }
}

关键点:

  • 使用 metaSize(字节数)而非记录数控制批次,内存控制更精确。
  • List.copyOf() 创建不可变副本,避免并发修改。
  • 清空后重置 insertTime,保证超时触发准确性。

带随机抖动的超时

private final long timeoutMillis;
public ClickHouseShardSinkBuffer(..., int timeoutSec, ...) {
    // 基础超时 + 10% 随机抖动(避免惊群效应)
    this.timeoutMillis = TimeUnit.SECONDS.toMillis(timeoutSec)
                      + new SecureRandom().nextInt((int) (timeoutSec * 0.1 * 1000));
}

目的: 避免多个TM 同时触发 flush,造成写入流量峰值。

配置示例

ClickHouseShardSinkBuffer.Builder
    .aClickHouseSinkBuffer()
    .withTargetTable("single_table")  //单表时,可直接使用指定表名
    .withMaxFlushBufferSize(10000)  // 对应字节数
    .withTimeoutSec(30)              // 30 秒超时
    .withClickHouseShardStrategy(new LogClickHouseShardStrategy("table_prefix_%s", 8))  //分表策略时,使用
    // 分表策略可根据业务实际情况进行扩展
    .build(clickHouseWriter);

六、写入限流与流量控制

有界队列设计

public class ClickHouseWriter {
    // 有界阻塞队列
    private final BlockingQueue<ClickHouseRequestBlank> commonQueue;
    public ClickHouseWriter(ClickHouseSinkCommonParams sinkParams, ...) {
        // 队列最大容量配置(默认 10)
        this.commonQueue = new LinkedBlockingQueue<>(sinkParams.getQueueMaxCapacity());
    }
    public void put(ClickHouseRequestBlank params) {
        unProcessedCounter.incrementAndGet();
        // put() 方法在队列满时会阻塞,实现背压
        commonQueue.put(params);
    }
}

背压传导:

线程池并发控制

public class ClickHouseWriter {
    private final int numWriters; // 写入线程数
    private ExecutorService service;
    private void buildComponents() {
        ThreadFactory threadFactory = ThreadUtil.threadFactory("clickhouse-writer");
        service = Executors.newFixedThreadPool(numWriters, threadFactory);
        // 创建多个 WriterTask 并提交
        for (int i = 0; i < numWriters; i++) {
            WriterTask task = new WriterTask(i, commonQueue, sinkParams, futures, unProcessedCounter);
            service.submit(task);
        }
    }
}

WriterTask 消费逻辑

class WriterTask implements Runnable {
    @Override
    public void run() {
        isWorking = true;
        while (isWorking || !queue.isEmpty()) {
            // poll() 超时返回(100ms),避免无限等待
            ClickHouseRequestBlank blank = queue.poll(100, TimeUnit.MILLISECONDS);
            if (blank != null) {
                // 创建 Future 并设置超时(3 分钟)
                CompletableFuture<Boolean> future = new CompletableFuture<>();
                future.orTimeout(3, TimeUnit.MINUTES);
                futures.add(future);
                try {
                    send(blank, future, new HashSet<>());
                } finally {
                    // final 进行未知异常兜底,防止为捕获异常造成future状态不完成,永久阻塞
                    if (!future.isDone()) {
                        future.completeExceptionally(new RuntimeException("Unknown exception"));
                    }
                    queueCounter.decrementAndGet();
                }
            }
        }
    }
}

配置参数

七、重试机制与超时控制

Future 超时控制

public class ClickHouseWriter {
    private final int numWriters; // 写入线程数
    private ExecutorService service;
    private void buildComponents() {
        ThreadFactory threadFactory = ThreadUtil.threadFactory("clickhouse-writer");
        service = Executors.newFixedThreadPool(numWriters, threadFactory);
        // 创建多个 WriterTask 并提交
        for (int i = 0; i < numWriters; i++) {
            WriterTask task = new WriterTask(i, commonQueue, sinkParams, futures, unProcessedCounter);
            service.submit(task);
        }
    }
}

超时策略:

  • Future 超时: 3 分钟(orTimeout)。
  • Socket 超时: 3 分钟(socket_timeout=180000)。
  • 连接超时: 30 秒(connectionTimeout=30000)。

重试逻辑

class WriterTask implements Runnable {
    @Override
    public void run() {
        isWorking = true;
        while (isWorking || !queue.isEmpty()) {
            // poll() 超时返回(100ms),避免无限等待
            ClickHouseRequestBlank blank = queue.poll(100, TimeUnit.MILLISECONDS);
            if (blank != null) {
                // 创建 Future 并设置超时(3 分钟)
                CompletableFuture<Boolean> future = new CompletableFuture<>();
                future.orTimeout(3, TimeUnit.MINUTES);
                futures.add(future);
                try {
                    send(blank, future, new HashSet<>());
                } finally {
                    // final 进行未知异常兜底,防止为捕获异常造成future状态不完成,永久阻塞
                    if (!future.isDone()) {
                        future.completeExceptionally(new RuntimeException("Unknown exception"));
                    }
                    queueCounter.decrementAndGet();
                }
            }
        }
    }
}

重试控制逻辑

private void handleUnsuccessfulResponse(..., Set<String> exceptHosts) {
    // 检查 Future 是否已完成(避免重复完成)
    if (future.isDone()) {
        return;
    }
    if (attemptCounter >= maxRetries) {
        // 达到最大重试次数,标记失败
        future.completeExceptionally(new RuntimeException("Max retries exceeded"));
    } else {
        // 递归重试
        requestBlank.incrementCounter();
        send(requestBlank, future, exceptHosts); // 递归调用,排除失败节点
    }
}

重试策略:

  • 递归重试: 失败后递归调用,直到成功或达到最大次数。
  • 异常节点隔离: 每次重试时排除失败的节点(exceptHosts)。
  • 超时控制: Future 超时(3 分钟)防止永久阻塞。

为什么递归重试是更好的选择

递归重试(当前实现)

队列重试(假设方案)

保证一致性

  // ClickHouseWriter.java:139-158
  while (!futures.isEmpty() || unProcessedCounter.get() > 0) {
      CompletableFuture<Void> future = FutureUtil.allOf(futures);
      future.get(3, TimeUnit.MINUTES);  // 阻塞直到全部完成
  }
  • Checkpoint 时所有数据要么全部成功,要么全部失败。
  • 重启后不会有部分数据重复的问题。

简单可靠

  • 代码逻辑清晰。
  • 对于队列重试且不重复,需要复杂的二阶段提交(这里暂不展开),大幅增加代码复杂度。

性能可接受

class WriterTask implements Runnable {
    @Override
    public void run() {
        while (isWorking || !queue.isEmpty()) {
            ClickHouseRequestBlank blank = queue.poll(100, TimeUnit.MILLISECONDS);
            if (blank != null) {
                // 创建 Future 并设置 3 分钟超时
                CompletableFuture<Boolean> future = new CompletableFuture<>();
                future.orTimeout(3, TimeUnit.MINUTES); // 防止永久阻塞
                futures.add(future);
                try {
                    send(blank, future, new HashSet<>());
                } finally {
                    if (!future.isDone()) {
                        future.completeExceptionally(new RuntimeException("Timeout"));
                    }
                    queueCounter.decrementAndGet();
                }
            }
        }
    }
}
  • 虽然阻塞,但有超时保护。
  • ClickHouse 写入通常很快(秒级)。
  • 网络故障时重试也合理。

避开故障节点

  // ClickHouseWriter.java:259-260
  HikariDataSource dataSource = getNextDataSource(exceptHosts);
  • 递归时可以传递 exceptHosts。
  • 自动避开失败的节点。
  • 提高成功率。

异常节点剔除

// 特殊错误码列表(自动加入黑名单)
private final List<Integer> ignoreHostCodes = Arrays.asList(2101002);
public HikariDataSource getNextDataSource(Set<String> exceptionHosts) {
    if (CollectionUtils.isNotEmpty(exceptionHosts)) {
        // 过滤异常节点
        List<HikariDataSource> healthyHosts = clickHouseDataSources.stream()
            .filter(ds -> !exceptionHosts.contains(getHostFromUrl(ds)))
            .collect(Collectors.toList());
        if (CollectionUtils.isEmpty(healthyHosts)) {
            return null// 所有节点都异常
        }
        return healthyHosts.get(random.nextInt(healthyHosts.size()));
    }
    // 正常轮询(已 shuffle,避免热点)
    return clickHouseDataSources.get(currentRandom.getAndIncrement() % size);
}

故障节点剔除策略:

  1. 错误码 210(网络异常): 自动加入黑名单。
  2. 错误码 1002(连接池异常): 自动加入黑名单。
  3. APM 监控: 磁盘 >= 90%、HTTP 连接 >= 50 的节点。
  4. 手动配置: 通过 Redis 配置剔除。

恢复机制:

  • LoadingCache 定时刷新(1 分钟)。
  • 节点恢复健康后自动从黑名单移除。

重试流程图

八、异常处理模式

两种 Sink 模式

public Sink buildSink(String targetTable, String targetCount, int maxBufferSize) {
    IClickHouseSinkBuffer buffer = ClickHouseShardSinkBuffer.Builder
        .aClickHouseSinkBuffer()
        .withTargetTable(targetTable)
        .withMaxFlushBufferSize(maxBufferSize)
        .withClickHouseShardStrategy(new LogClickHouseShardStrategy(targetTable, count))
        .build(clickHouseWriter);
    // 根据配置选择模式
    if (ignoringClickHouseSendingExceptionEnabled) {
        return new UnexceptionableSink(buffer);  // 忽略异常
    } else {
        return new ExceptionsThrowableSink(buffer); // 抛出异常
    }
}

UnexceptionableSink(忽略异常 - At-Most-Once)

public class UnexceptionableSink implements Sink<LogModel> {
    private final IClickHouseSinkBuffer<LogModel> buffer;
    @Override
    public void put(LogModel message) {
        buffer.put(message);  // 不检查 Future 状态
    }
    @Override
    public void flush() {
        buffer.flush();
    }
}

适用场景:

  • 允许部分数据丢失。
  • 不希望因写入异常导致任务失败。
  • 对数据准确性要求不高(如日志统计)。

语义保证:At-Most-Once(最多一次)

ExceptionsThrowableSink(抛出异常 - At-Least-Once)

public class ExceptionsThrowableSink implements Sink<LogModel> {
    private final IClickHouseSinkBuffer<LogModel> buffer;
    @Override
    public void put(LogModel message) throws ExecutionException, InterruptedException {
        buffer.put(message);
        // 每次写入都检查 Future 状态
        buffer.assertFuturesNotFailedYet();
    }
    @Override
    public void flush() throws ExecutionException, InterruptedException {
        buffer.flush();
    }
}

Future 状态检查:

public void assertFuturesNotFailedYet() throws ExecutionException, InterruptedException {
    CompletableFuture<Void> future = FutureUtil.allOf(futures);
    // 非阻塞检查
    if (future.isCompletedExceptionally()) {
        logger.error("There is something wrong with the future. exist sink now");
        future.get(); // 抛出异常,导致 Flink 任务失败
    }
}

适用场景:

  • 数据准确性要求高。
  • 需要保证所有数据写入成功。
  • 异常时希望 Flink 任务失败并重启。

语义保证:At-Least-Once(至少一次)

Future 清理策略与并发控制

定时检查器

public class ClickHouseSinkScheduledCheckerAndCleaner {
    private final ScheduledExecutorService scheduledExecutorService;
    private final List<CompletableFuture<Boolean>> futures;
    // ⚠️ volatile 保证多线程可见性(关键并发控制点)
    private volatile boolean isFlushing = false;
    public ClickHouseSinkScheduledCheckerAndCleaner(...) {
        // 单线程定时执行器
        scheduledExecutorService = Executors.newSingleThreadScheduledExecutor(factory);
        // 定时执行清理任务(每隔 checkTimeout 秒,默认 30 秒)
        scheduledExecutorService.scheduleWithFixedDelay(getTask(), ...);
    }
    private Runnable getTask() {
        return () -> {
            synchronized (this) {
                //  关键:检查是否正在 flush,避免并发冲突
                if (isFlushing) {
                    return// Checkpoint 期间暂停清理
                }
                // 1️⃣ 清理已完成的 Future
                futures.removeIf(filter);
                // 2️⃣ 触发所有 Buffer 的 flush(检查是否需要写入)
                clickHouseSinkBuffers.forEach(IClickHouseSinkBuffer::tryAddToQueue);
            }
        };
    }
    // Checkpoint flush 前调用(暂停 cleaner)
    public synchronized void beforeFlush() {
        isFlushing = true;
    }
    // Checkpoint flush 后调用(恢复 cleaner)
    public synchronized void afterFlush() {
        isFlushing = false;
    }
}

核心设计:

  • volatile boolean isFlushing: 标志位,协调 cleaner 与 checkpoint 线程。
  • synchronized (this): 保证原子性,避免并发冲突。
  • 单线程执行器: 避免 cleaner 内部并发问题。

并发控制机制

问题场景:

时间轴冲突:
T1: Cleaner 线程正在执行 tryAddToQueue()
T2: Checkpoint 触发,调用 sink.flush()
T3: Cleaner 同时也在执行 tryAddToQueue()
    ├─ 可能导致:数据重复写入
    ├─ 可能导致:Buffer 清空顺序混乱
    └─ 可能导致:Future 状态不一致

解决方案:

// ClickHouseSinkManager.flush()
public void flush() {
    // 1️⃣ 暂停定时清理任务(设置标志)
    clickHouseSinkScheduledCheckerAndCleaner.beforeFlush(); // isFlushing = true
    try {
        // 2️⃣ 执行 flush(此时 cleaner 线程会跳过执行)
        clickHouseWriter.waitUntilAllFuturesDone(falsefalse);
    } finally {
        // 3️⃣ 恢复定时清理任务
        clickHouseSinkScheduledCheckerAndCleaner.afterFlush(); // isFlushing = false
    }
}

并发控制流程:

关键设计点:

  1. volatile 保证可见性: isFlushing 使用 volatile,确保多线程间的可见性。
  2. synchronized 保证原子性: getTask() 整个方法体使用 synchronized (this)。
  3. 标志位协调: 通过 isFlushing 标志实现两个线程间的协调。
  4. finally 确保恢复: 即使 waitUntilAllFuturesDone() 异常,也会在 finally 中恢复 cleaner。

避免的并发问题:

  • 数据重复写入: Cleaner 和 Checkpoint 同时 flush。
  • Buffer 状态不一致: 一边清空一边写入。
  • Future 清理冲突: 正在使用的 Future 被清理。

性能影响:

  • Checkpoint flush 期间,cleaner 暂停执行(通常 1-3 秒)。
  • Cleaner 跳过的周期会在下次正常执行时补偿。
  • 对整体吞吐影响极小(cleaner 间隔通常 30 秒)。

九、Checkpoint 语义保证

为什么 Checkpoint 时必须 Flush?

不 Flush 的后果

不Flush导致数据永久丢失

正确做法

@Override
public void snapshotState(FunctionSnapshotContext context) throws Exception {
    logger.info("start doing snapshot. flush sink to ck");
    // 1. 先 flush buffer(将内存数据写入 ClickHouse)
    if (sink != null) {
        sink.flush();
    }
    // 2. 等待所有写入完成
    if (sinkManager != null && !sinkManager.isClosed()) {
        sinkManager.flush();
    }
    // 此时 Checkpoint 才能标记为成功
    logger.info("doing snapshot. flush sink to ck");
}

Flush 实现与并发协调

public class ClickHouseSinkManager {
    public void flush() {
        //  步骤1:暂停定时清理任务
        clickHouseSinkScheduledCheckerAndCleaner.beforeFlush(); // isFlushing = true
        try {
            //  步骤2:执行 buffer flush + 等待所有写入完成
            clickHouseWriter.waitUntilAllFuturesDone(falsefalse);
        } finally {
            //  步骤3:恢复定时清理任务(finally 确保执行)
            clickHouseSinkScheduledCheckerAndCleaner.afterFlush(); // isFlushing = false
        }
    }
}

并发协调详解:

// cleaner 线程执行流程
synchronized (this) {
    if (isFlushing) {
        return// Checkpoint 期间跳过本次执行
    }
    // 正常执行:清理已完成的 Future + 触发 Buffer flush
    futures.removeIf(filter);
    buffers.forEach(Buffer::tryAddToQueue);
}

关键点:

  • volatile 可见性: isFlushing 使用 volatile 确保 cleaner 线程立即看到状态变化。
  • synchronized互斥: getTask()方法体使用 synchronized (this) 确保原子性。
  • 标志位协调: 通过 beforeFlush() / afterFlush() 管理标志位。
  • finally 保证恢复: 即使 flush 异常,也会在 finally 中恢复 cleaner。

等待所有 Future 完成

public synchronized void waitUntilAllFuturesDone(boolean stopWriters, boolean clearFutures) {
    try {
        // 循环等待:直到所有 Future 完成 + 队列清空
        while (!futures.isEmpty() || unProcessedCounter.get() > 0) {
            CompletableFuture<Void> all = FutureUtil.allOf(futures);
            // 最多等待 3 分钟(与 Future 超时一致)
            all.get(3, TimeUnit.MINUTES);
            // 移除已完成的 Future(非异常)
            futures.removeIf(f -> f.isDone() && !f.isCompletedExceptionally());
            // 检查是否有异常 Future
            if (anyFutureFailed()) {
                break; // 有异常则退出
            }
        }
    } finally {
        if (stopWriters) stopWriters();
        if (clearFutures) futures.clear();
    }
}

关键逻辑:

  • 循环等待直到所有 Future 完成 + 队列清空。
  • 超时 3 分钟(与 Future 超时一致)。
  • 移除已完成的非异常 Future。
  • 有异常时退出循环。

三种 Flush 触发方式对比

Checkpoint 参数配置

// Checkpoint 配置建议
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
// 启用 Checkpoint(间隔 1 分钟)
env.enableCheckpointing(60000);
// Checkpoint 超时(必须大于 Future 超时 + 重试时间)
// 建议:CheckpointTimeout > FutureTimeout * MaxRetries
env.getCheckpointConfig().setCheckpointTimeout(600000); // 10 分钟
// 一致性模式
env.getCheckpointConfig().setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE);
// 最小间隔(避免过于频繁)
env.getCheckpointConfig().setMinPauseBetweenCheckpoints(30000); // 30 秒
// 最大并发 Checkpoint 数
env.getCheckpointConfig().setMaxConcurrentCheckpoints(1);

语义保证

推荐配置:

生产环境: 使用 ExceptionsThrowableSink + Checkpoint。

允许部分丢失: 使用 UnexceptionableSink。

十、最佳实践与调优

生产配置

// ========== ClickHouse 连接参数 ==========
clickhouse.sink.target-table = tb_logs_local
clickhouse.sink.max-buffer-size104857600        // 批次大小
clickhouse.sink.table-count0                // 0 表示不分表
// ========== 写入性能参数 ==========
clickhouse.sink.num-writers10               // 写入线程数
clickhouse.sink.queue-max-capacity10        // 队列容量
clickhouse.sink.timeout-sec30               // flush 超时
clickhouse.sink.retries10                   // 最大重试次数
clickhouse.sink.check.timeout-sec30         // 定时检查间隔
// ========== 异常处理参数 ==========
clickhouse.sink.ignoring-clickhouse-sending-exception-enabledfalse
clickhouse.sink.local-address-enabledtrue   // 启用本地表写入
// ========== ClickHouse 集群配置 ==========
clickhouse.access.hosts192.168.1.1:8123,192.168.1.2:8123,192.168.1.3:8123
clickhouse.access.user = default
clickhouse.access.password = ***
clickhouse.access.database = dw_xx_xx
clickhouse.access.cluster = default
// ========== HikariCP 连接池配置 ==========
connectionTimeout30000                      // 连接超时 30s
maximumPoolSize20                           // 最大连接数 20
minimumIdle2                                // 最小空闲 2
socket_timeout180000                        // Socket 超时 3mi

性能调优

故障排查

十一、总结

本文深入分析了 Flink ClickHouse Sink 的实现方案,核心亮点包括:

技术亮点

  • 连接池选型: 使用 HikariCP,性能优异,连接管理可靠。
  • Future 超时控制: orTimeout(3min) 防止永久阻塞。
  • 显式资源管理: Connection 和 PreparedStatement 显式关闭,防止连接泄漏。
  • 负载均衡优化: Shuffle 初始化 + 轮询选择,避免热点。
  • 异常处理增强: future.isDone() 检查,避免重复完成。
  • 本地表写入: 动态节点发现 + 故障剔除,写入性能提升。
  • 分片策略: 按表(应用)维度路由,独立缓冲和隔离。
  • 攒批优化: 双触发机制(大小 + 超时)+ 随机抖动。
  • 流量控制: 有界队列 + 线程池,实现背压。
  • 健壮重试: 递归重试 + 异常节点剔除 + 最大重试限制。

Checkpoint 语义

  • At-Least-Once: ExceptionsThrowableSink + Checkpoint。
  • At-Most-Once: UnexceptionableSink。
  • Exactly-Once: 需要配合 ClickHouse 事务(未实现)。

生产建议

  1. 必须: Checkpoint 时 flush,否则会丢数据。
  2. 推荐: 使用 HikariCP + 本地表写入。
  3. 推荐: 配置合理的超时(Future < Socket < Checkpoint)。
  4. 推荐: 监控队列大小、Future 失败率、重试次数。

该方案已在生产环境大规模验证,能够稳定支撑百万级 TPS 的日志写入场景。

往期回顾

1.服务拆分之旅:测试过程全揭秘|得物技术

2.大模型网关:大模型时代的智能交通枢纽|得物技术

3.从“人治”到“机治”:得物离线数仓发布流水线质量门禁实践

4.AI编程实践:从Claude Code实践到团队协作的优化思考|得物技术

5.入选AAAI-PerFM|得物社区推荐之基于大语言模型的新颖性推荐算法

文 /虚白

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

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

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

图结构完全解析:从基础概念到遍历实现

作者 颜酱
2026年2月9日 21:58

图结构完全解析:从基础概念到遍历实现

图是计算机科学中最核心的数据结构之一,它能抽象现实世界中各类复杂的关系网络——从地图导航的路径规划,到社交网络的好友推荐,再到物流网络的成本优化,都离不开图结构的应用。本文将从图的基础概念出发,逐步讲解图的存储方式、通用实现,以及核心的遍历算法(DFS/BFS),帮助你彻底掌握图结构的核心逻辑。

一、图的核心概念

1.1 基本构成

图由节点(Vertex)边(Edge) 组成:

  • 节点:表示实体,有唯一ID标识;

  • 边:表示节点间的关系,可分为:

    • 有向/无向:有向边(如A→B)仅表示单向关系,无向边(如A-B)等价于双向有向边;

    • 加权/无权:加权边附带数值(如距离、成本),无权边可视为权重为1的特殊情况。

1.2 关键属性

(1)度
  • 无向图:节点的度 = 相连边的条数;

  • 有向图:入度(指向该节点的边数)+ 出度(该节点指向其他的边数)。

(2)稀疏图 vs 稠密图

简单图(无自环、无多重边)中,V个节点最多有 V(V1)/2V(V-1)/2 条边:

  • 稀疏图:边数E远小于 V2V^2 (如社交网络);

  • 稠密图:边数E接近 V2V^2 (如全连接网络)。

1.3 子图相关

  • 子图:节点和边均为原图的子集;

  • 生成子图:包含原图所有节点,仅保留部分边(如最小生成树);

  • 导出子图:选择部分节点,且包含这些节点在原图中的所有边。

1.4 连通性

  • 无向图:

    • 连通图:任意两节点间有路径可达;

    • 连通分量:非连通图中的最大连通子图;

  • 有向图:

    • 强连通:任意两节点间有双向有向路径;

    • 弱连通:忽略边方向后为连通图。

1.5 图与树的关系

图是多叉树的延伸:树无环、仅允许父→子指向,而图可成环、节点间可任意指向。树的遍历逻辑(DFS/BFS)完全适用于图,仅需增加「标记已访问节点」的逻辑避免环导致的死循环。

二、图的存储方式

图的存储核心是「记录节点间的连接关系」,主流方式有邻接表邻接矩阵两种,二者各有适用场景。

2.1 邻接表

核心结构

以节点ID为键,存储该节点的所有出边(包含目标节点+权重):

  • 数组版:graph[x] 存储节点x的出边列表(适用于节点ID为连续整数);

  • Map版:graph.get(x) 存储节点x的出边列表(适用于任意类型节点ID)。

特点
  • 空间复杂度: O(V+E)O(V+E) (仅存储实际存在的边,适合稀疏图);

  • 时间复杂度:增边 O(1)O(1) ,删/查边 O(E)O(E) (E为节点出边数),获取邻居 O(1)O(1)

2.2 邻接矩阵

核心结构

二维数组 matrix[from][to]

  • 无权图:true/false 表示是否有边;

  • 加权图:数值表示权重,null 表示无边(避免0权重歧义)。

特点
  • 空间复杂度: O(V2)O(V^2) (需预分配所有节点组合,适合稠密图);

  • 时间复杂度:增/删/查边/获取权重均为 O(1)O(1) ,获取邻居 O(V)O(V)

2.3 存储方式对比

特性 邻接表 邻接矩阵
空间效率 稀疏图更优 稠密图更优
增删查边 增边快、删/查边慢 所有操作均快
节点ID支持 支持任意类型(Map版) 仅支持连续整数
适用场景 大多数稀疏图场景 节点少、需快速查边

三、图的通用实现

基于邻接表/邻接矩阵,我们实现支持「增删查改」的通用加权有向图类,可灵活适配无向图(双向加边)、无权图(权重默认1)。

3.1 邻接表(数组版):适用于连续整数节点ID


/**
 * 加权有向图(邻接表-数组版)
 * 核心:节点ID为0~n-1的连续整数,二维数组存储出边
 */
class WeightedDigraphArray {
    constructor(n) {
        if (!Number.isInteger(n) || n <= 0) {
            throw new Error(`节点数必须是正整数(当前传入:${n})`);
        }
        this.nodeCount = n;
        this.graph = Array.from({ length: n }, () => []); // 邻接表初始化
    }

    // 私有方法:校验节点合法性
    _validateNode(node) {
        if (!Number.isInteger(node) || node < 0 || node >= this.nodeCount) {
            throw new Error(`节点${node}非法!合法范围:0 ~ ${this.nodeCount - 1}`);
        }
    }

    // 添加加权有向边
    addEdge(from, to, weight) {
        this._validateNode(from);
        this._validateNode(to);
        if (typeof weight !== 'number' || isNaN(weight)) {
            throw new Error(`边${from}${to}的权重必须是有效数字`);
        }
        // 避免重复加边
        if (this.hasEdge(from, to)) {
            this.removeEdge(from, to);
        }
        this.graph[from].push({ to, weight });
    }

    // 删除有向边
    removeEdge(from, to) {
        this._validateNode(from);
        this._validateNode(to);
        const originalLength = this.graph[from].length;
        this.graph[from] = this.graph[from].filter(edge => edge.to !== to);
        return this.graph[from].length < originalLength;
    }

    // 判断边是否存在
    hasEdge(from, to) {
        this._validateNode(from);
        this._validateNode(to);
        return this.graph[from].some(edge => edge.to === to);
    }

    // 获取边权重
    getEdgeWeight(from, to) {
        this._validateNode(from);
        this._validateNode(to);
        const edge = this.graph[from].find(edge => edge.to === to);
        if (!edge) throw new Error(`不存在边${from}${to}`);
        return edge.weight;
    }

    // 获取节点所有出边
    getNeighbors(v) {
        this._validateNode(v);
        return [...this.graph[v]]; // 返回拷贝,避免外部修改
    }

    // 打印邻接表(调试用)
    printAdjList() {
        console.log('=== 加权有向图邻接表 ===');
        for (let i = 0; i < this.nodeCount; i++) {
            const edges = this.graph[i].map(edge => `${edge.to}(${edge.weight})`).join(', ');
            console.log(`节点${i}的出边:${edges || '无'}`);
        }
    }
}

3.2 邻接表(Map版):适用于任意类型节点ID


/**
 * 加权有向图(邻接表-Map版)
 * 核心:支持动态添加节点,节点ID可为任意可哈希类型(数字/字符串等)
 */
class WeightedDigraphMap {
    constructor() {
        this.graph = new Map(); // key: 节点ID,value: 出边列表
    }

    // 添加加权有向边
    addEdge(from, to, weight) {
        if (typeof weight !== 'number' || isNaN(weight)) {
            throw new Error('边的权重必须是有效数字');
        }
        if (!this.graph.has(from)) {
            this.graph.set(from, []);
        }
        this.removeEdge(from, to); // 去重
        this.graph.get(from).push({ to, weight });
    }

    // 删除有向边
    removeEdge(from, to) {
        if (!this.graph.has(from)) return false;
        const edges = this.graph.get(from);
        const filtered = edges.filter(edge => edge.to !== to);
        this.graph.set(from, filtered);
        return filtered.length < edges.length;
    }

    // 判断边是否存在
    hasEdge(from, to) {
        if (!this.graph.has(from)) return false;
        return this.graph.get(from).some(edge => edge.to === to);
    }

    // 获取边权重
    getEdgeWeight(from, to) {
        if (!this.graph.has(from)) {
            throw new Error(`不存在节点${from}`);
        }
        const edge = this.graph.get(from).find(edge => edge.to === to);
        if (!edge) throw new Error(`不存在边${from}${to}`);
        return edge.weight;
    }

    // 获取节点所有出边
    getNeighbors(v) {
        return this.graph.get(v) || [];
    }

    // 获取所有节点
    getNodes() {
        return Array.from(this.graph.keys());
    }
}

3.3 邻接矩阵版:适用于节点数少的场景


/**
 * 加权有向图(邻接矩阵版)
 * 核心:二维数组存储边权重,null表示无边
 */
class WeightedDigraphMatrix {
    constructor(n) {
        if (!Number.isInteger(n) || n <= 0) {
            throw new Error('节点数必须是正整数');
        }
        this.nodeCount = n;
        this.matrix = Array.from({ length: n }, () => Array(n).fill(null));
    }

    // 校验节点合法性
    _validateNode(node) {
        if (!Number.isInteger(node) || node < 0 || node >= this.nodeCount) {
            throw new Error(`节点${node}非法!合法范围:0 ~ ${this.nodeCount - 1}`);
        }
    }

    // 添加加权有向边
    addEdge(from, to, weight) {
        this._validateNode(from);
        this._validateNode(to);
        if (typeof weight !== 'number' || isNaN(weight)) {
            throw new Error(`边${from}${to}的权重必须是有效数字`);
        }
        this.matrix[from][to] = weight;
    }

    // 删除有向边
    removeEdge(from, to) {
        this._validateNode(from);
        this._validateNode(to);
        if (this.matrix[from][to] === null) return false;
        this.matrix[from][to] = null;
        return true;
    }

    // 判断边是否存在
    hasEdge(from, to) {
        this._validateNode(from);
        this._validateNode(to);
        return this.matrix[from][to] !== null;
    }

    // 获取边权重
    getEdgeWeight(from, to) {
        this._validateNode(from);
        this._validateNode(to);
        return this.matrix[from][to];
    }

    // 获取节点所有出边
    getNeighbors(v) {
        this._validateNode(v);
        const neighbors = [];
        for (let to = 0; to < this.nodeCount; to++) {
            const weight = this.matrix[v][to];
            if (weight !== null) {
                neighbors.push({ to, weight });
            }
        }
        return neighbors;
    }

    // 打印邻接矩阵(调试用)
    printMatrix() {
        console.log('=== 邻接矩阵 ===');
        process.stdout.write('    ');
        for (let i = 0; i < this.nodeCount; i++) process.stdout.write(`${i}   `);
        console.log();
        for (let from = 0; from < this.nodeCount; from++) {
            process.stdout.write(`${from} | `);
            for (let to = 0; to < this.nodeCount; to++) {
                const val = this.matrix[from][to] === null ? '∅' : this.matrix[from][to];
                process.stdout.write(`${val}   `);
            }
            console.log();
        }
    }
}

3.4 适配无向图/无权图

  • 无向图:添加/删除边时,同时操作 from→toto→from

  • 无权图:复用加权图类,addEdge 时权重默认传1。

四、图的核心遍历算法

图的遍历是所有图论算法的基础,核心为深度优先搜索(DFS)广度优先搜索(BFS),二者的核心区别是「遍历顺序」:DFS先探到底再回溯,BFS逐层扩散。

4.1 深度优先搜索(DFS)

核心思想

从起点出发,沿着一条路径走到头,再回溯探索其他分支,需通过 visited/onPath 数组避免环导致的死循环。

场景1:遍历所有节点(visited数组)

visited 标记已访问的节点,确保每个节点仅遍历一次:


/**
 * DFS遍历所有节点
 * @param {WeightedDigraphArray} graph - 图实例
 * @param {number} s - 起点
 * @param {boolean[]} visited - 访问标记数组
 */
function dfsTraverseNodes(graph, s, visited) {
    if (s < 0 || s >= graph.nodeCount || visited[s]) return;
    // 前序位置:标记并访问节点
    visited[s] = true;
    console.log(`访问节点 ${s}`);
    // 递归遍历所有邻居
    for (const edge of graph.getNeighbors(s)) {
        dfsTraverseNodes(graph, edge.to, visited);
    }
    // 后序位置:可处理节点相关逻辑(如统计、计算)
}

// 调用示例
const graph = new WeightedDigraphArray(3);
graph.addEdge(0, 1, 1);
graph.addEdge(0, 2, 2);
graph.addEdge(1, 2, 3);
dfsTraverseNodes(graph, 0, new Array(graph.nodeCount).fill(false));
场景2:遍历所有路径(onPath数组)

onPath 标记当前路径上的节点,后序位置撤销标记(回溯),用于寻找所有路径:


/**
 * DFS遍历所有路径(从src到dest)
 * @param {WeightedDigraphArray} graph - 图实例
 * @param {number} src - 起点
 * @param {number} dest - 终点
 * @param {boolean[]} onPath - 当前路径标记
 * @param {number[]} path - 当前路径
 * @param {number[][]} res - 所有路径结果
 */
function dfsTraversePaths(graph, src, dest, onPath, path, res) {
    if (src < 0 || src >= graph.nodeCount || onPath[src]) return;
    // 前序位置:加入当前路径
    onPath[src] = true;
    path.push(src);
    // 到达终点:记录路径
    if (src === dest) {
        res.push([...path]); // 拷贝路径,避免后续修改
        path.pop();
        onPath[src] = false;
        return;
    }
    // 递归遍历邻居
    for (const edge of graph.getNeighbors(src)) {
        dfsTraversePaths(graph, edge.to, dest, onPath, path, res);
    }
    // 后序位置:回溯(移出当前路径)
    path.pop();
    onPath[src] = false;
}

// 调用示例
const res = [];
dfsTraversePaths(graph, 0, 2, new Array(graph.nodeCount).fill(false), [], res);
console.log('所有从0到2的路径:', res); // [[0,1,2], [0,2]]
场景3:有向无环图(DAG)遍历

若图无环,可省略 visited/onPath,直接遍历(如寻找所有从0到终点的路径):


var allPathsSourceTarget = function(graph) {
    const res = [];
    const path = [];
    const traverse = (s) => {
        path.push(s);
        if (s === graph.length - 1) {
            res.push([...path]);
            path.pop();
            return;
        }
        for (const v of graph[s]) traverse(v);
        path.pop();
    };
    traverse(0);
    return res;
};

4.2 广度优先搜索(BFS)

核心思想

从起点出发,逐层遍历所有节点,天然适合寻找「最短路径」(第一次到达终点的路径即为最短)。

基础版:记录遍历步数

/**
 * BFS遍历(记录步数)
 * @param {WeightedDigraphArray} graph - 图实例
 * @param {number} s - 起点
 */
function bfsTraverse(graph, s) {
    const nodeCount = graph.nodeCount;
    const visited = new Array(nodeCount).fill(false);
    const q = [s];
    visited[s] = true;
    let step = -1; // 初始-1,进入循环后++为0(起点步数)

    while (q.length > 0) {
        step++;
        const sz = q.length;
        // 遍历当前层所有节点
        for (let i = 0; i < sz; i++) {
            const cur = q.shift();
            console.log(`访问节点 ${cur},步数 ${step}`);
            // 加入所有未访问的邻居
            for (const edge of graph.getNeighbors(cur)) {
                if (!visited[edge.to]) {
                    q.push(edge.to);
                    visited[edge.to] = true;
                }
            }
        }
    }
}
进阶版:State类适配复杂场景

通过State类封装节点和步数,适配不同权重、不同遍历目标的场景:


// 封装节点状态
class State {
    constructor(node, step) {
        this.node = node;
        this.step = step;
    }
}

/**
 * BFS遍历(State版)
 * @param {WeightedDigraphArray} graph - 图实例
 * @param {number} s - 起点
 */
function bfsTraverseState(graph, s) {
    const nodeCount = graph.nodeCount;
    const visited = new Array(nodeCount).fill(false);
    const q = [new State(s, 0)];
    visited[s] = true;

    while (q.length > 0) {
        const state = q.shift();
        const cur = state.node;
        const step = state.step;
        console.log(`访问节点 ${cur},步数 ${step}`);

        for (const edge of graph.getNeighbors(cur)) {
            if (!visited[edge.to]) {
                q.push(new State(edge.to, step + 1));
                visited[edge.to] = true;
            }
        }
    }
}

4.3 遍历算法总结

算法 核心数据结构 核心标记 适用场景 时间复杂度
DFS 递归栈 visited(遍历节点)/onPath(遍历路径) 遍历所有节点、所有路径 O(V+E)O(V+E)
BFS 队列 visited 寻找最短路径、逐层遍历 O(V+E)O(V+E)

五、总结

图结构的核心是「节点+边」的关系抽象,掌握以下关键点即可应对绝大多数场景:

  1. 存储选择:稀疏图用邻接表(省空间),稠密图用邻接矩阵(查边快);

  2. 遍历逻辑:DFS适合遍历所有路径,BFS适合找最短路径,均需标记已访问节点避免环;

  3. 扩展适配:无向图=双向有向图,无权图=权重为1的加权图,可复用通用图类;

  4. 核心思想:图是树的延伸,遍历的本质是「穷举+剪枝」(标记已访问避免死循环)。

从基础的遍历到进阶的最短路径(Dijkstra)、最小生成树(Kruskal/Prim)、拓扑排序,图论算法的核心都是「基于遍历的优化」,掌握本文的基础内容,后续学习进阶算法会事半功倍。

开源 Claude Code + Codex + 面板 的未来vibecoding平台

作者 朱昆鹏
2026年2月9日 13:38

一句话介绍

CodeMoss =

  • 多AI联动:Claude Code + Codex + Gemini + OpenCode + ......
  • 多端使用:客户端 + Jetbrains + Vscode + 移动端
  • 多周边集成:AI面板 + AI记忆 + Superpowers + OpenSpec + Spec-kit + ...

说了这么多功能,直接放实机图更容易理解

image.png

image.png


总之一句话:CodeMoss 目标打造 下一代的vibecoding 入口

开源地址(感谢你的Star和推荐,这将让更多人用到)

github.com/zhukunpengl…


详细介绍

对话过程页面

image.png

侧边栏GIT模块

image.png

侧边栏文件管理模块

真的可以编辑哦~

image.png

面板模式

这不是普通的面板哦~,是真的可以并行执行任务,有完整交互的AI面板哦~

image.png

image.png

侧边栏展示

支持claude code + codex 多cli数据共同展示

image.png

终端展示

image.png

支持多平台

支持Mac 和 window 多平台


下载安装体验

功能太多了,就不赘述了,大家可以下载之后自行探索

下载地址(纯开源,无商业,放心食用):www.codemoss.ai/download


未来迭代

目前虽然能用,但是细节打磨的还不满意,我至少会每天迭代一个版本,先迭代100个版本,欢迎大家使用提出问题

开源地址(感谢你的Star和推荐,这将让更多人用到)

github.com/zhukunpengl…

再次声明:本项目完全开源,0商业,使用过程全程无广,请放心食用

构建全栈AI应用:集成Ollama开源大模型

2026年2月8日 20:59

在AI技术迅猛发展的今天,开源大模型如 DeepSeek 系列为开发者提供了强大工具,而无需依赖云服务。构建一个全栈AI应用,不仅能深化对前后端分离架构的理解,还能探索AI集成的最佳实践。本文将基于一个实际项目,分享如何使用React前端、Node.js后端,并通过 LangChain 库调用 Ollama 部署的 DeepSeek-R1:8b 模型,实现一个简单的聊天功能。这个项目适用于初学者上手全栈开发,或资深开发者扩展AI能力。

项目需求源于日常场景:开发者常常需要快速测试AI响应,或构建原型应用来验证想法。传统方式可能涉及复杂API调用,而开源Ollama简化了本地部署。技术栈选择上,前端采用React结合Tailwind CSS和Axios,实现响应式UI和网络交互;后端使用Express框架提供API服务;AI部分则集成Ollama,确保模型运行高效且本地化。这不仅仅是代码堆砌,更是关于模块化、容错和跨域处理的综合实践。

接下来,我们将剖析项目架构、代码实现和关键知识点,包括自定义Hook、API管理、提示工程等。通过提供的代码示例,读者可以轻松复现,并根据需要扩展为更复杂的应用,如代码审查或内容生成工具。

项目架构概述

项目采用前后端分离设计,确保各部分独立开发和部署。

  • 前端:浏览器端运行(默认端口5173),处理用户输入和显示AI响应。使用React构建,借助自定义Hook封装逻辑,避免组件复杂化。Axios用于发送请求到后端。
  • 后端:Node.js与Express框架,监听3000端口,提供RESTful API。核心接口/chat接收消息,调用AI模型后返回结果。集成CORS中间件支持跨域。
  • AI集成:Ollama部署DeepSeek-R1:8b模型(端口11434),通过LangChain构建提示链。模型温度设为0.1,确保输出稳定。

这种架构优势在于可扩展性:前端专注交互,后端管理业务,AI作为服务可独立优化。启动时,前端用Vite工具,后端Node运行,Ollama后台启动模型。

前端实现详解

前端核心是创建一个简洁界面,发送消息并展示AI回复。假设初始消息为模拟输入,实际可扩展为用户表单。

App.jsx:主组件

主组件使用Hook获取数据,渲染加载状态或内容:

jsx

import { useEffect } from 'react';
import { useGitDiff } from './hooks/useGitDiff.js'

export default function App() {
  const { loading, content } = useGitDiff('hello');

  return (
    <div className="flex">
      {loading ? 'loading...' : content}
    </div>
  )
}

这里,Hook接收参数(模拟消息),返回loading和content。组件逻辑简单:加载中显示提示,否则渲染回复。结合Tailwind CSS的flex类,实现响应式布局。

useGitDiff.js:自定义Hook

Hook封装状态和副作用:

jsx

import { useState, useEffect } from 'react'
import { chat } from '../api/index.js'

export const useGitDiff = () => {
  const [content, setContent] = useState('');
  const [loading, setLoading] = useState(false);
  useEffect(() => {
    (async () => {
      setLoading(true);
      const { data } = await chat('你好');
      setContent(data.reply);
      setLoading(false);
    })()
  }, [])
  return {
    loading,
    content,
  }
}

使用useState管理状态,useEffect异步调用API。模拟消息“你好”,实际可动态传入。返回对象供组件使用,实现数据驱动渲染。

api/index.js:API管理

统一管理请求:

jsx

import axios from 'axios';

const service = axios.create({
  baseURL: 'http://localhost:3000',
  headers: {
    'Content-Type': 'application/json',
  },
  timeout: 120000,
});

export const chat = (message) => service.post('/chat', {message})

Axios实例设置baseURL、headers和timeout。chat函数封装POST请求,便于复用。

前端设计强调简洁,易于添加输入框扩展为完整聊天UI。

后端实现详解

后端使用Express搭建服务器,支持AI调用。

index.js:服务器文件

代码如下:

JavaScript

import express from 'express';
import cors from 'cors';
import { ChatOllama } from '@langchain/ollama';
import { ChatPromptTemplate } from '@langchain/core/prompts'
import { StringOutputParser } from '@langchain/core/output_parsers'

const model = new ChatOllama({
  baseURL: "http://localhost:11434",
  model: "deepseek-r1:8b",
  temperature: 0.1
})

const app = express();
app.use(cors());
app.use(express.json());

app.get('/hello', (req, res) => {
  res.send('hello world');
})

app.post('/chat', async (req, res) => {
  console.log(req.body, "//////");
  const { message } = req.body;
  if (!message || typeof message !== 'string') {
    return res.status(400).json({
      error: "message 必填,必须是字符串"
    })
  }
  try {
    const prompt = ChatPromptTemplate.fromMessages([
      ["system", "You are a helpful assistent."],
      ["human", '{input}']
    ])
    const chain = prompt.pipe(model).pipe(new StringOutputParser());
    console.log("正在调用大模型");
    const result = await chain.invoke({
      input: message,
    })
    res.json({
      reply: result
    })
  } catch (err) {
    console.log(err);
    res.status(500).json({
      err: "调用大模型失败"
    })
  }
})

app.listen(3000, () => {
  console.log('server is running on port 3000');
})

初始化Ollama模型。app实例使用CORS和JSON中间件。GET /hello测试路由。POST /chat校验message,构建提示链调用模型,返回reply。容错处理确保稳定性。

关键知识点:跨域与中间件

跨域问题是前后端分离的痛点。浏览器同源策略阻塞不同端口请求。使用cors中间件解决,后端允许前端访问。

中间件链:请求经CORS、JSON解析后到达路由。Express的灵活性便于添加日志或认证。

关键知识点:HTTP与路由

HTTP基于请求响应。GET无body,POST适合传输消息。响应码如400、500指示状态。

路由定义资源访问:app.post处理异步AI调用。

AI集成详解

Ollama提供本地API,LangChain简化提示:系统角色定义,用户输入占位。链式pipe确保输出解析。

温度0.1减少随机性。扩展可自定义提示,如添加上下文。

项目优化

  • 用户输入:前端添加表单动态消息。
  • 安全:添加校验或限流。
  • 部署:容器化Ollama,后端云托管。
  • 扩展:多轮对话或工具调用。

结语

通过集成 Ollama 的全栈应用,我们看到AI如何赋能开发。欢迎讨论优化思路,一起推动开源生态。

富文本编辑器在 AI 时代为什么这么受欢迎

作者 Moment
2026年2月8日 22:21

大家好👋,我是Moment,目前我正在使用 NextJs,NestJs,langchain 开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于 Tiptap 的富文本编辑器、NestJs 后端服务、AI 集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了 Tiptap 的深度定制、性能优化和协作功能的实现等核心难点,如果你对这个项目感兴趣,可以添加我微信 yunmz777 了解更多详细的信息,如果觉得不错欢迎 star ⭐️⭐️⭐️。

在 2026 年的今天,富文本编辑器已经不再是单纯的打字框,它演变成了人类与 AI 协作的核心战场。

如果说过去十年是 Markdown 和纯文本的极客复兴,那么 AI 时代的到来,则让富文本编辑器重新夺回了统治地位。

AI 就在光标处

在 AI 普及之前,我们写作是写一段,去 ChatGPT 问一段,再复制回来。这种上下文切换是效率的杀手。

现在的富文本编辑器,如 Notion AI、Lex、WPS AI 等,将 AI 直接植入光标。你只需输入斜杠或空格,AI 就能根据前文自动续写、润色或改变语气。富文本编辑器能够理解文档的层级结构,包括标题、段落、列表,这让 AI 能更精准地执行总结这一段或把这部分转成表格的操作。

结构化数据的转换站

AI 最擅长的事情之一,就是将非结构化信息转化为结构化内容。富文本编辑器的块状结构完美适配了这一点。

你丢给 AI 一堆乱七八糟的会议纪要,富文本编辑器能瞬间将其渲染成带有看板、待办列表和甘特图的精美文档。AI 生成的不再只是文字,还有图表、代码块,甚至动态组件。富文本编辑器是承载这些复杂对象的最佳容器。

终结空白页恐惧症

对于创作者来说,最痛苦的是面对一张白纸。AI 时代的富文本编辑器变成了半自动驾驶。

AI 不再是取代作者,而是成了最好的二号位。它帮你打草稿,你负责做决策和注入灵魂。输入一个主题,AI 自动生成大纲和初稿,用户的工作从无中生有变成了审阅与精修。当你逻辑断层时,AI 可以在侧边栏提醒你,甚至帮你查找事实数据,省去了反复跳出窗口搜资料的麻烦。

传统与 AI 原生的分野

传统富文本编辑器的定位是静态记录工具,核心交互靠键盘输入和顶部工具栏,内容处理停留在简单的字体加粗、颜色修改,逻辑理解仅能识别字符和 HTML 标签,扩展性依赖插件系统。

AI 原生富文本编辑器的定位是动态协作伙伴和内容引擎,核心交互靠自然语言指令和斜杠命令,内容处理涵盖自动排版、风格迁移、跨语言同步翻译,逻辑理解能把握段落意图、自动提取任务项,扩展性支持 AI Agent 接入,可调用外部 API 填充数据。

协作维度的升华

以前的协作是人与人在文档里留言。现在的协作是人、AI、人三者联动。

当你打开一份长文档,AI 会为你总结其他人修改了什么。团队中的成员写出的内容风格不一时,AI 可以一键将全篇统一为公司标准公文包风格或互联网黑话风格。

降低专业感的门槛

富文本编辑器通过 AI 让普通人也能做出有大片感的内容。

以前需要懂点设计才能排得好看,现在只需说帮我把这个方案做得像麦肯锡的报告,编辑器会自动调整字体间距、引用格式和配色方案。语音转文字、文字转视觉,AI 在富文本背后的各种转换逻辑,让创作变得从未如此简单。

为什么不是 Word?

Word 几乎是富文本的代名词,但当我们说 AI 时代富文本编辑器火了时,大家脑子里浮现的通常是 Notion、Lex、Linear 这种现代化的编辑器,而不是那个陪了我们几十年的 Microsoft Word。

原因很简单:Word 是为了打印设计的,而 AI 时代的编辑器是为了信息流动设计的。

纸张思维与块思维

Word 的逻辑本质上是在模拟一张 A4 纸。所有的排版,页边距、分页符、行间距,都是为了最终打印出来好看。

AI 时代的逻辑则是原子化的。现代编辑器如 Notion,每一段话、一张图、一个表格都是一个独立的块。AI 很难理解 Word 那种长达 50 页、格式复杂的 XML 结构。但在块状编辑器里,AI 可以精准地知道:我现在的任务是只针对这一个待办事项块进行扩充,或者把这个文本块转化成代码块。这种精细度的控制,Word 很难做到。

功能堆砌与对话驱动

Word 拥有成千上万个功能,埋藏在密密麻麻的菜单栏里。在 Word 里用 AI,你得去点插件、点侧边栏。

现代编辑器奉行极简主义。界面通常只有一张白纸,所有的功能都通过一个斜杠指令或 AI 对话框呼出。在 Word 里,你是找功能;在 AI 编辑器里,你是下指令。当 AI 已经能帮你调格式、改语气时,Word 顶端那几百个图标反而成了视觉噪音。

数据结构的开放性

Word 是孤岛。docx 文件是一个封装好的压缩包。虽然现在有云端版,但它的数据很难实时与其他工具,如任务管理、数据库、代码库,无缝打通。

现代富文本编辑器往往是 All-in-one。AI 在编辑器里写完一个方案,可以直接将其中的任务转化为看板上的卡片。Word 的数据相对静态,而现代编辑器的内容是活着的,AI 可以轻松地跨页面、跨库调用数据。

协作的实时性与轻量化

Word 诞生于离线时代。即便现在的 OneDrive 协作已经进步很大,但其底层的冲突合并机制依然不如原生 Web 编辑器流畅。AI 时代的创作往往是高频次、碎片化、多人多机协作的,现代富文本编辑器原生支持网页访问,AI 可以在你和同事讨论时实时介入,这种流畅度是老牌软件难以企及的。

从设计目标看,Microsoft Word 面向排版与打印,内容单位是页面;AI 原生编辑器如 Notion、Lex 面向思考与协作,内容单位是块或组件。Word 的 AI 角色是辅助插件,Copilot 是外挂;AI 编辑器的 AI 是核心驱动力,是原生系统的一部分。典型动作上,Word 用户设置页边距、调整字体大小;AI 编辑器用户用斜杠总结、空格键续写。视觉感受上,Word 像是一本沉重的精装书,AI 编辑器像是一张无限延伸的草稿纸。

心理层面的创作压力

这听起来很玄学,但真实存在。

打开 Word,你会觉得自己在写公文,潜意识里会去纠正格式;打开 Notion 或 Lex,你会觉得自己在记录想法。AI 最强大的地方在于辅助灵感爆发,而现代富文本编辑器那种无负担的界面,比 Word 更适合作为 AI 的载体。

结语

当然,Word 也在努力。微软推出的 Microsoft Loop 就在全盘致敬这种块状编辑器逻辑,试图把 Word 的强大功能塞进 AI 时代的瓶子里。

富文本编辑器在 AI 时代受欢迎,是因为它已经从一个容器进化成了一个理解器。它不再只等着你喂数据,而是开始主动帮你处理、组织和美化信息。

当人类与 AI 的边界越来越模糊,那个让我们与智能体并肩写作的编辑器,或许才是这个时代真正的创作伙伴。

AI全栈实战:使用 Python+LangChain+Vue3 构建一个 LLM 聊天应用

作者 Cobyte
2026年2月8日 21:45

1. 引言

在大语言模型(LLM)快速发展的今天,几乎所有产品都在借助大模型进行重塑与升级。在过去一段时间,各类旨在提升效率的 AI Agent 如雨后春笋般涌现,尤其是 Coding Agent 的兴起,在一定程度上对前端开发者的职业前景带来了冲击与挑战。一些走在行业前沿的公司甚至开始提倡“前后端再度融合”,这意味着未来开发者可能需要向具备 AI 能力的全栈工程师转型。因此,掌握 AI 全栈相关的知识与技能变得非常重要。

本文将带你通过实战,从零开始搭建一个基于 Python (FastAPI)LangChain 和 Vue 3 的全栈 LLM 聊天应用程序。另外我们将使用 DeepSeek 作为底层模型进行学习。

技术栈前瞻

  • 后端: Python 3, FastAPI (Web 框架), LangChain (LLM 编排), Uvicorn (ASGI 服务器)
  • 前端: Vue 3, TypeScript, Vite (构建工具)
  • 模型: DeepSeek API (兼容 OpenAI 格式)

我是 Cobyte,欢迎添加 v:icobyte,学习 AI 全栈。

2. 为什么选择 Python ?

在 AI 领域,Python 无疑是首选的开发语言。因此,如果想通过学习 AI 全栈技术来获得一份理想工作,掌握 Python 几乎是必经之路。这就像在国内想从事后端开发,Java 绝对是不二之选。对于前端背景的同学而言,虽然也可以通过 Node.js 入门 AI 开发,但就整体就业前景和发展空间来看,跟 Node.js 相比 Python 的优势也是断层领先。同时,Python 作为一门入门门槛较低的语言,学习起来相对轻松,所以大家无需过于担心学习难度问题。

最后本人提倡在实战中学习 Python,并且完全可以借助 AI 进行辅导学习。

2. Python 环境配置

我们这里只提供 Windows 环境的讲解,其他的环境自行 AI,Python 的环境搭建还是十分简单的。

  1. 访问官网下载安装包

www.python.org/downloads/

选择对应的平台版本:

image.png

  1. 安装时勾选 "Add Python to PATH"

image.png

  1. 验证安装

打开终端命令工具输入以下命令行:

python --version
pip --version

出现如下信息则说明安装成功了。

image.png

最后编辑器我们可以选择 VS Code,只需在拓展中安装以下插件即可。

image.png

我们前面说到了我们是使用 DeepSeek 作为底层模型进行学习,所以我们需要去 DeepSeek 的 API 开放平台申请一个大模型的 API key。申请地址如下:platform.deepseek.com/api_keys 。当然我们需要充一点钱,就充几块也够我们学习用了。

3. Python 快速入门

3.1 Hello World

我们创建一个 simple-llm-app 的项目目录,然后在根目录创建一个 .env 文件,用于存放项目的环境变量配置,跟前端的项目一样。我们这里设置上面申请到的 DeepSeek 开放平台的 API key。

DEEPSEEK_API_KEY=sk-xxxxxx

然后我们可以通过 python-dotenv 库读从 .env 文件中读取它,我们创建一个 test.py 的文件,里面的代码如下:

import os
from dotenv import load_dotenv
# 加载环境变量 (读取 .env 中的 DEEPSEEK_API_KEY)
load_dotenv()
# 打印
print(os.getenv("DEEPSEEK_API_KEY"))

其中 dotenv 库需要安装 python-dotenv 依赖,安装方法也跟安装 npm 包类似,命令如下:

pip install python-dotenv

接着执行 test.py 文件,执行命令跟 Node.js 类似:

python test.py

我们就可以在命令终端看到 .env 文件中的 DeepSeek API key 了。这算是成功输出了 Python 的 Hello world。

3.2 Python 语法入门

接着我们继续了解 Python 的相关语法。在 Python 中,使用 from ... import ...,在 ES6 JavaScript 中,我们使用 import ... from ...。 所以上述代码的 import os -> 类似于 Node.js 中的 import * as os from 'os'os 是一个内置库。 from dotenv import load_dotenv 则类似于从 npm 包中导入一个类,比如: import load_dotenv from 'dotenv'

Python:没有显式的变量声明关键字,直接通过赋值创建变量。

# Python - 直接赋值,无需关键字
name = "张三"
AGE = 25 # 常量(约定)没有内置的常量类型,但通常用全大写变量名表示常量,实际上可以修改
is_student = True

JavaScript:使用 varlet 或 const 声明变量。

// JavaScript - 必须使用关键字
let name = "张三";
const age = 25;  // 常量 使用 `const` 声明常量,不可重新赋值。
var isStudent = true;  // 旧方式

注释对比

Python注释:

  • 单行注释:以 # 开头
# 这是一个Python单行注释
name = "张三"  # 这是行尾注释
  • 多行注释:可以使用三个单引号 ''' 或三个双引号 """ 包裹
'''
这是一个Python多行注释
可以跨越多行
实际上这是字符串,但常用作注释
'''

"""
双引号三引号也可以
这在Python中通常用作文档字符串(docstring)
"""

JavaScript 注释:

  • 单行注释:以 // 开头
// 这是一个JavaScript单行注释
let name = "张三";  // 这是行尾注释
  • 多行注释:以 /* 开头,以 */ 结尾
/*
 这是一个JavaScript多行注释
 可以跨越多行
 这是真正的注释语法
*/


/**
 * 用户类,表示系统中的一个用户
 * @class
 */
class User {
}

好了我们不贪杯,实战中遇到不同的 Python 语法,我们再针对学习或者借助 AI 通过与 JavaScript 语法进行横向对比,对于有一定编程基础的我们,肯定非常容易理解的。相信通过上述 Python 语法的学习,聪明的你再回头看上述示例的 Python 代码,肯定可以看懂了。

我们这里只是简单介绍上面代码中涉及到的 Python 语法,本人推荐在实战中进行学习。更多 JavaScript 视觉学习 Python:langshift.dev/zh-cn/docs/…

3.3 FastAPI 框架快速入门

3.3.1 FastAPI 是什么

FastAPI 是一个现代、高性能(与 NodeJS 和 Go 相当)的 Web 框架,用于构建 API,基于 Python 3.6+ 并使用了标准的 Python 类型提示。但它本身不提供完整的 Web 服务器功能,而是通过 ASGI(Asynchronous Server Gateway Interface)与服务器进行通信。

Uvicorn 是一个高性能的 ASGI 服务器,它支持异步编程,能够运行 FastAPI 这样的异步 Web 应用。所以 FastAPI 需要配合 Uvicorn 使用,这样才能够充分发挥 FastAPI 的异步特性,提供极高的性能。同时,Uvicorn 在开发和部署时都非常方便。

简单来说

  • FastAPI 负责:路由、验证、序列化、依赖注入等应用逻辑
  • Uvicorn 负责:HTTP 协议解析、并发处理、连接管理等服务器功能

两者结合形成了现代 Python Web 开发的黄金组合,既能享受 Python 的便捷,又能获得接近 Go、Node.js 的性能。

3.3.2 基本示例

我们创建一个 server.py 文件,输入以下示例代码:

from fastapi import FastAPI

app = FastAPI()

@app.get("/")
def read_root():
    return {"message": "我是 Cobyte,欢迎添加 v:icobyte,学习 AI 全栈。"}

# 程序的入口点
if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="127.1.1.1", port=9527)

上述代码引用了两个依赖 fastapi 和 uvicorn,我们通过 pip 进行安装一下:

pip install fastapi uvicorn

然后我们在终端启动服务:python server.py,运行结果如下:

image.png

接着我们在浏览器打开 http://127.1.1.1:9527 显示如下:

image.png

3.3.3 路径参数和查询参数

示例:

@app.get("/items/{id}")
def read_item(
    id: int, 
    limit: int = 10,         # 默认值
    q: Optional[str] = None, # 可选参数
    short: bool = False,     # 默认值
    tags: List[str] = []     # 列表参数
):
    item = {"id": id, "limit": limit, "tags": tags}
    if q:
        item.update({"q": q})
    if not short:
        item.update({"desc": "长说明"})
    return item

重启服务,在浏览器输入:http://127.1.1.1:9527/items/1?q=cobyte ,结果如下:

image.png

总结

  • 路径参数:在路径中声明的参数,如 id
  • 查询参数:作为函数参数,但不是路径参数,将自动解释为查询参数。
3.3.4 FastAPI 中的模型定义

在 FastAPI 中,我们经常需要处理来自客户端的请求数据,例如 POST 请求的 JSON 体。为了确保数据的正确性,我们需要验证数据是否符合预期的格式和类型。使用 Pydantic 模型可以让我们以一种声明式的方式定义数据的结构,并自动进行验证。

Pydantic 是一个 Python 库,用于数据验证和设置管理,主要基于 Python 类型提示(type hints)。它可以在运行时提供类型检查,并且当数据无效时提供详细的错误信息。

Pydantic 的核心功能是定义数据的结构(模型),并自动验证传入的数据是否符合这个结构。它非常适用于以下场景:

  • 验证用户输入(例如 API 请求的数据)
  • 配置管理
  • 数据序列化和反序列化(例如将 JSON 数据转换为 Python 对象)

Pydantic 模型使用 Python 的类来定义,类的属性使用类型注解来指定类型,并且可以设置默认值。

请求体(Request Body)和响应模型(Response Model)的示例如:

from pydantic import BaseModel, validator, Field
from typing import Optional, List
import re

# 请求体(Request Body)
class UserRequest(BaseModel):
    username: str = Field(..., min_length=3, max_length=50)
    password: str
    email: str
    @validator('username')
    def username_alphanumeric(cls, v):
        if not re.match('^[a-zA-Z0-9_]+$', v):
            raise ValueError('只能包含字母、数字和下划线')
        return v
    
    @validator('email')
    def email_valid(cls, v):
        if '@' not in v:
            raise ValueError('无效的邮箱地址')
        return v.lower()  # 转换为小写
    
    @validator('password')
    def password_strong(cls, v):
        if len(v) < 6:
            raise ValueError('密码至少6位')
        return v
# 响应模型(Response Model)
class UserResponse(BaseModel):
    username: str
    email: str

@app.post("/user/", response_model=UserResponse)
async def create_user(user: UserRequest):
    # 密码会被过滤,不会出现在响应中
    return user

FastAPI 会自动从 Pydantic 模型生成 API 文档,我们在 server.py 文件中添加了上述示例之后,重启服务,访问 http://127.1.1.1:9527/docs 可以看到:

image.png

并且我们还可以在文档地址中进行测试,这里就不展开讲了。

3.3.5 异步和中间件

示例:

from fastapi import Request

@app.middleware("http")
async def add_process_time_header(request: Request, call_next):
    response = await call_next(request)
    response.headers["X-Process-Time"] = str(process_time)
    return response

我们可以看到 Python 的这个异步语法跟 JavaScript 的 async/await 是一样的语法。

3.3.6 CORS 配置

通过设置 CORS 配置允许前端跨域访问。

from fastapi.middleware.cors import CORSMiddleware
# CORS 配置:允许前端跨域访问
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],  # 在生产环境中建议设置为具体的前端域名
    allow_credentials=True,
    allow_methods=["*"],  # 允许的方法
    allow_headers=["*"],  # 允许的头部
)

到此本文所用到的 FastAPI 知识就基本介绍完毕了,后续再在实战中进行学习,先上了 AI 全栈的车再说。

4. LLM 和 OpenAI 接口快速入门

4.1 入门示例代码

让我们从安装依赖开始,借助 DeepSeek 大模型一起探索 OpenAI 接口规范。

pip install openai

接着我们在 test.py 中添加如下代码:

import os
from dotenv import load_dotenv
# 加载环境变量 (读取 .env 中的 DEEPSEEK_API_KEY)
load_dotenv()
# 加载 OpenAI 库,从这里也可以看到 Python 的库加载顺序跟 JavaScript ES6 import 是不一样,反而有点像 requrie
from openai import OpenAI

# 初始化客户端
client = OpenAI(
    api_key=os.getenv("DEEPSEEK_API_KEY"), # 身份验证凭证,确保你有权访问 API
    base_url="https://api.deepseek.com" # 将请求重定向到 DeepSeek 的服务器(而非 OpenAI)
)
# 构建聊天请求
response = client.chat.completions.create(
  model="deepseek-chat", # 指定模型版本
  temperature=0.5,
  messages=[   # 对话消息数组
      {"role": "user", "content": "你是谁?"}
  ]
)
# 打印结果
print(response.choices[0].message.content.strip())

终端输出结果如下:

image.png

可以看到我们成功调用了 DeepSeek 大模型。

在 openai 中,返回的 response 对象是一个 Pydantic 模型,如果我们想详细查看 response 返回的结果,可以使用它自带的 .model_dump_json() 方法。

# 使用 model_dump_json 并指定缩进
print(response.model_dump_json(indent=2))

可以看到通过上述方式打印大模型响应的信息如下:

image.png

4.2 choices 字段详解

我们从上面打印的结果可以了知道,大模型返回的文本信息是存储在 choices 字段中的,所以我们来了解一下它。

在调用 chat.completions.create 时,如果设置了 n 参数(n>1),那么模型会生成多个输出,此时 choices 字段就会包含多个元素。每个 choice 代表一个可能的响应,我们可以通过遍历 choices 来获取所有响应。

另外,即使 n=1(默认值),choices 也是一个列表,只不过只包含一个元素。所以我们上述例子中才通过 response.choices[0] 来获取大模型的返回结果。

4.3 流式响应

因为大模型本质上是一个预测生成器,简单来说就是你输入一句话,大模型就预测下一个字。因此我们希望在模型生成文本的同时就显示给用户,提高交互的实时性。这就是流式响应。代码设置如下:

# 构建聊天请求
response = client.chat.completions.create(
  model="deepseek-chat", # 指定模型版本
  temperature=0.5,
  messages=[   # 对话消息数组
      {"role": "user", "content": "你是谁?"}
  ],
+  stream=True, # 启用流式传输
)

+# response是一个生成器,在Python中,生成器是一种迭代器,每次迭代返回一个值。这里,每次迭代返回一个chunk(部分响应)。
+for chunk in response:                           # 1. 遍历响应流
+    if chunk.choices[0].delta.content:           # 2. 检查是否有内容
+        print(chunk.choices[0].delta.content,    # 3. 打印内容
+              end="",                            # 4. 不换行
+              flush=True)                        # 5. 立即刷新缓冲区

输出结果如下:

20260207-132313.gif

4.4 temperature 参数

我个人觉得那么多大模型参数中 temperature 参数还是比较重要的,值得我们了解一下。模型在生成每一个词时,都会计算一个所有可能的下一个词的概率分布(例如,“苹果”概率0.3,“香蕉”概率0.5,“水果”概率0.2)。temperature 的值会影响这个概率分布的形状,从而改变模型最终根据这个分布进行“抽样”选择的结果。

一个简单的比喻:选餐厅吃饭

  • Temperature = 0.0永远只去评分最高、去过无数次的那一家最保险的餐厅。结果最稳定,但永远没有新体验。
  • Temperature = 1.0大多数时候去那家最好的,但偶尔也会根据评价试试附近其他不错的餐厅。平衡了可靠性和新鲜感。
  • Temperature = 1.5经常尝试新餐厅,甚至包括一些评价奇特或小众的地方。体验非常丰富,但有时会“踩雷”。

总结与建议

  1. 追求确定性时调低 (接近0) :当你需要精确、可靠、可复现的结果时,如生成代码、数学推导、事实问答、指令严格遵循。
  2. 追求创造性和多样性时调高 (>1.0) :当你需要创意、多样化表达、故事生成、诗歌时。
  3. 通用场景用中间值 (0.8-1.2) :大多数对话、摘要、分析等任务,这个范围能提供既连贯又有一定灵活性的输出。

4.5 消息角色

在 OpenAI API 中,messages 数组中的每条消息都有一个 role 字段,它定义了消息的来源和用途。消息角色主要有三种:system、user、assistant。此外,在后续的更新中,还引入了 tool 和 function 等角色,但最基础的是前三种。

1. system (系统)

  • 作用: 设置助手的背景、行为、指令等。

  • 特点:

    • 通常作为第一条消息,用于设定对话的上下文和规则。
    • 不是必须的,但可以显著影响助手的行为。
  • 示例:

    {"role": "system", "content": "你是一个专业的翻译助手,只能将中文翻译成英文,其他问题一律不回答。"}
    

2. user (用户)

  • 作用: 用户输入的问题或指令

  • 特点:

    • 代表对话中的人类用户
    • 每个请求必须至少包含一条 user 消息
    • 通常是最后一条消息(除了流式响应)
  • 示例:

    messages = [
        {"role": "system", "content": "你是一个有帮助的助手"},
        {"role": "user", "content": "什么是机器学习?"}
    ]
    

3. assistant (助手)

  • 作用: 代表助手之前的回复。

  • 特点:

    • 在多轮对话中保存历史回复
    • 帮助模型保持对话连贯性
    • 在单轮对话中不需要此角色
  • 示例:

    messages = [
        {"role": "system", "content": "你是一个数学老师"},
        {"role": "user", "content": "2+2等于多少?"},
        {"role": "assistant", "content": "2+2等于4"},
        {"role": "user", "content": "那3+3呢?"}  # 模型知道这是新问题
    ]
    

通过合理组合这些角色,你可以构建从简单问答到复杂多轮对话的各种应用场景。记住:清晰的角色定义和恰当的消息组织是获得高质量回复的基础。我们这里先介绍前三种核心角色。

5. LangChain 入门

5.1 怎么理解 LangChain 框架

从前端的视角来理解,LangChain 就好比是 Vue 或 React 这类框架。在前端开发中,如果没有 Vue 或 React,我们就需要直接编写大量操作浏览器 API 的底层代码;而有了这类框架,它们封装了常见的交互逻辑和状态管理,让开发变得更高效、更结构化。类似地,LangChain 实际上是一套封装了大型语言模型常见操作模式的方案,它帮助开发者更便捷地调用、组合与管理大模型的能力,而无需每次都从头编写复杂的模型交互代码。

5.2 LangChain 调用 LLM 示例

接着我们在项目根目录下创建一个 llm-app.py 文件,输入以下内容:

import os
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from dotenv import load_dotenv

# 1. 加载环境变量 (读取 .env 中的 DEEPSEEK_API_KEY)
load_dotenv()

# 2. 创建组件
# 相对于上面的使用 OpenAI 的接口,现在经过 LangChain 封装后确实简洁了很多
llm = ChatOpenAI(
    model="deepseek-chat", 
    temperature=0.7,
    api_key=os.getenv("DEEPSEEK_API_KEY"),
    base_url="https://api.deepseek.com/v1"
)

# 创建了一个人类角色的提示模板,`from_template` 方法允许我们通过一个字符串模板来定义提示,默认是人类角色。
prompt = ChatPromptTemplate.from_template("{question}")

# 创建解析器
parser = StrOutputParser()
# 将AI响应转换为字符串,通过前面的知识我们知道大模型返回的数据一般包含很多数据,
# 很多时候我们只需要其中的文本内容。`StrOutputParser` 就是用来提取这个文本内容的

# 3. 组合链 (LCEL 语法) Python LangChain 常见的链式调用
chain = prompt | llm | parser
# 等价于:输入 → 模板填充 → AI处理 → 结果解析

# 4. 执行
result = chain.invoke({"question": "你是谁?"})
# 内部执行:填充"你是谁?" → 调用API → 解析响应 → 返回字符串

# 5. 打印结果
print(result)

然后在终端安装对应的依赖(这个步骤跟前端也很像,所以学习 Python 是很简单的):

pip install langchain_openai langchain_core dotenv

接着在终端执行

# 跟前端的 node llm-app.js 等价
python llm-app.py

终端输出结果如下:

image.png

可以看到我们成功执行了一个 Python + LangChain 的应用程序。

5.2 消息模板系统

我们上面的注释讲解了 prompt = ChatPromptTemplate.from_template("{question}") 这句代码默认创建了一个人类角色的提示模板,也就是 {"role": "user", "content": "用户输入的内容"}

LangChain 作为一个强大的 LLM 应用开发框架, 为了让开发者能够精确控制对话的流程和结构,提供了灵活且强大的消息模板系统。LangChain 的消息模板系统基于角色(role)的概念,将对话分解为不同类型的信息单元。目前的类型如下:

角色 用途 对应 OpenAI 角色
SystemMessagePromptTemplate system 系统指令、设定 system
HumanMessagePromptTemplate human 用户输入 user
AssistantMessagePromptTemplate assistant AI 回复 assistant
AIMessagePromptTemplate ai AI 回复(别名) assistant
ToolMessagePromptTemplate tool 工具调用结果 tool
FunctionMessagePromptTemplate function 函数调用结果 function

ChatPromptTemplate 则是消息系统的核心容器,负责协调各种消息类型:

from langchain_core.prompts import (
    SystemMessagePromptTemplate,
    HumanMessagePromptTemplate,
    AssistantMessagePromptTemplate
)
system = SystemMessagePromptTemplate.from_template(...)
human = HumanMessagePromptTemplate.from_template(...)
assistant = AssistantMessagePromptTemplate.from_template(...)
prompt = ChatPromptTemplate.from_messages([system, human, assistant])

所以上述入门实例代码可以进行以下修改:

-from langchain_core.prompts import ChatPromptTemplate
+from langchain_core.prompts import ChatPromptTemplate,HumanMessagePromptTemplate
# 省略...
-# 创建了一个人类角色的提示模板,`from_template` 方法允许我们通过一个字符串模板来定义提示,默认是人类角色。
-prompt = ChatPromptTemplate.from_template("{question}")
+human = HumanMessagePromptTemplate.from_template("{question}")
+prompt = ChatPromptTemplate.from_messages([human])
# 省略...

然后重新在终端执行 python llm-app.py 依然正常输出。

同时通过 LangChain 消息模型来理解大模型的调用过程也变得十分的清晰,所以整个流程是:

输入 → prompt → llm → parser → 输出
     ↓
{"question": "你是谁?"}
     ↓
prompt 处理:创建消息 "你是谁?"
     ↓
llm 处理:调用 LLM 处理,返回 AIMessage 对象
     ↓
parser 处理:提取文本内容
     ↓
最终结果字符串

在 LangChain 中还有一个最基础的模板类 PromptTemplate 用于构建字符串提示。下面我们也来了解一下它的基本用法。

from langchain_core.prompts import PromptTemplate

# 方式1:使用 from_template 类方法(最常用)
prompt = PromptTemplate.from_template("请解释什么是{concept}。")

# 方式2:直接实例化
prompt = PromptTemplate(
    input_variables=["concept"], 
    template="请解释什么是{concept}。"
)

综上所述我们通过理解和掌握 LangChain 这些核心概念,才能高效地构建可靠、可维护的 LLM 应用。此外,LangChain 的消息模板系统仍在不断发展当中,我们需要不断地持续关注。

5.3 LangChain 链式调用(管道操作符)

在 LangChain 中所谓的链式调用是通过管道操作符 | 来实现的,也就是通过 | 实现将一个函数的输出作为下一个函数的输入。

例如上述的示例代码中的:

# LangChain 中的管道操作
chain = prompt | llm | output_parser
  • 等价于手动执行链的每一步:
# 第一步:prompt 处理
messages = prompt.invoke({"question": "你是谁?"})
# messages = [HumanMessage(content="你是谁?")]

# 第二步:llm 处理
response = llm.invoke(messages)
# response = AIMessage(content="我是DeepSeek...")

# 第三步:parser 处理
result = parser.invoke(response)
# result = "我是DeepSeek..."

在标准 Python 语法中,| 是按位或操作符,用于:

  • 整数的按位运算:5 | 3 = 7
  • 集合的并集运算:{1, 2} | {2, 3} = {1, 2, 3}
  • 从 Python 3.10 开始,用于类型联合:int | str

但 LangChain 通过 重载(overload)  | 操作符,赋予了它新的含义:

  • | 在 LangChain 中是一种语法糖,让链式操作更直观
  • 它不是 Python 的新语法,而是通过操作符重载实现的框架特定功能
  • 这种设计让 LangChain 的代码更加简洁和易读

6. LLM 聊天应用后端

6.1 后端架构设计

我们遵循单一职责原则(SRP)进行分层架构设计,将系统划分为API层、业务层和数据层,旨在实现高内聚、低耦合,提升代码的可维护性、可测试性和可扩展性。

API层  专注于处理 HTTP 协议相关的逻辑,包括路由定义、请求验证、响应序列化和跨域处理等。它作为系统的入口点,负责与客户端进行通信,并将业务逻辑委托给下层。这种设计使得我们可以独立地调整 API 暴露方式(如支持 WebSocket)而不影响核心业务逻辑。

业务层  封装 LLM 的核心应用逻辑,例如与 AI 模型的交互、对话历史管理和流式生成等。这一层独立于 Web 框架,使得业务逻辑可以复用于其他场景(如命令行界面或批处理任务)。同时,业务层的单一职责确保了我们能够针对 LLM 交互进行优化和测试,而无需关心 HTTP 细节。

数据层  通过 Pydantic 定义系统的数据模型,包括请求、响应结构和内部数据传输对象。通过集中管理数据模型,我们确保了数据格式的一致性,并便于进行数据验证和类型提示。这种分离使得数据结构的变更更加可控,同时也为生成 API 文档提供了便利。

6.1 实现业务层和数据层

实现业务层其实就是封装 LLM 的核心应用逻辑。通过将复杂的 LLM 调用逻辑、提示工程和流式处理封装在独立的类中,这样 API 层只需关注请求与响应,而无需了解 LangChain 或特定 API 的细节。这使得底层技术栈的迭代或更换(例如从 LangChain 切换到其他操作大模型的框架或更改 LangChain 最新的 API)变得轻而易举,只需修改封装类内部实现,而对外接口保持不变,实现了有效隔离。

创建 ./backend/llm_app.py 文件,内容如下:

import os
from typing import Generator
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langchain_core.prompts import PromptTemplate
from langchain_core.output_parsers import StrOutputParser

# 加载环境变量
load_dotenv()

class LLMApp:
    def __init__(self, model_name="deepseek-chat", temperature=0.7):
        """
        初始化 LLM 应用程序
        """
        # 检查 DeepSeek API 密钥
        if not os.getenv("DEEPSEEK_API_KEY"):
            raise ValueError("请在 .env 文件中设置 DEEPSEEK_API_KEY 环境变量")
        
        # 初始化配置
        self.model_name = model_name
        self.temperature = temperature
        self.api_key = os.getenv("DEEPSEEK_API_KEY")
        self.base_url = "https://api.deepseek.com/v1"
        
        # 初始化非流式 LLM (用于普通任务)
        self.llm = self._create_llm(streaming=False)
        
        # 初始化流式 LLM (用于流式对话)
        self.streaming_llm = self._create_llm(streaming=True)
        
        # 输出解析器
        self.output_parser = StrOutputParser()
        
        # 初始化对话链
        self._setup_chains()
    
    def _create_llm(self, streaming: bool = False):
        """创建 LLM 实例"""
        return ChatOpenAI(
            model_name=self.model_name,
            temperature=self.temperature,
            api_key=self.api_key,
            base_url=self.base_url,
            streaming=streaming
        )
    
    def _setup_chains(self):
        """设置处理链"""
        # 带上下文的对话 Prompt
        conversation_prompt = PromptTemplate(
            input_variables=["chat_history", "user_input"],
            template="""你是一个有用的 AI 助手。请根据对话历史回答用户的问题。
            
            对话历史:
            {chat_history}
            
            用户:{user_input}
            助手:"""
        )
        # 注意:这里我们只定义 prompt,具体执行时再组合
        self.conversation_prompt = conversation_prompt

    def format_history(self, history_list) -> str:
        """格式化聊天历史"""
        if not history_list:
            return "无历史对话"
        
        formatted = []
        for msg in history_list:
            # 兼容 Pydantic model 或 dict
            if isinstance(msg, dict):
                role = msg.get('role', 'unknown')
                content = msg.get('content', '')
            else:
                role = getattr(msg, 'role', 'unknown')
                content = getattr(msg, 'content', '')
                
            formatted.append(f"{role}: {content}")
        
        return "\n".join(formatted[-10:])  # 只保留最近 10 条

    def stream_chat(self, user_input: str, chat_history: list) -> Generator[str, None, None]:
        """流式对话生成器"""
        try:
            history_text = self.format_history(chat_history)
            
            # 构建链:Prompt | StreamingLLM | OutputParser
            chain = self.conversation_prompt | self.streaming_llm | self.output_parser
            
            # 执行流式生成
            for chunk in chain.stream({
                "chat_history": history_text,
                "user_input": user_input
            }):
                yield chunk
                
        except Exception as e:
            yield f"Error: {str(e)}"

接下来我们对上述封装的 LLM 类的功能进行测试,测试前先在 ./backend/.env 文件中添加 DeepSeek 开放平台的 API key。

DEEPSEEK_API_KEY=sk-xxxxxx

接着创建 ./backend/test.py 文件写上以下测试代码。

from llm_app import LLMApp

# 测试
llmApp = LLMApp()

# 模拟聊天历史
chat_history = [
    {"role": "user", "content": "你好"},
    {"role": "assistant", "content": "你好!有什么可以帮助你的吗?"},
]
# 模拟用户输入
user_input = "请介绍一下人工智能"

# 收集流式响应
response_chunks = []
for chunk in llmApp.stream_chat(user_input, chat_history):
    response_chunks.append(chunk)
    # 模拟实时显示
    print(chunk, end="", flush=True)

# 合并响应
full_response = "".join(response_chunks)
print(f"\n完整响应: {full_response}")

测试结果如下:

20260208-172852.gif

接着我们通过 Pydantic 来定义数据的结构(模型)

创建 ./backend/models.py 文件,内容如下:

from pydantic import BaseModel
from typing import List, Optional

class ChatMessage(BaseModel):
    """单条聊天消息"""
    role: str  # "user" 或 "assistant"
    content: str

class ChatRequest(BaseModel):
    """聊天请求模型"""
    message: str
    chat_history: Optional[List[ChatMessage]] = []

修改 ./backend/test.py 文件,内容如下:

import json
import asyncio
from llm_app import LLMApp
from models import ChatRequest, ChatMessage


# 测试
llmApp = LLMApp()

# 模拟聊天历史
chat_history = [
    {"role": "user", "content": "你好"},
    {"role": "assistant", "content": "你好!有什么可以帮助你的吗?"},
]
# 模拟用户输入
user_input = "请介绍一下人工智能"
# 模拟 SSE 的流式聊天响应
async def chat_stream(request: ChatRequest):
    # 1. 发送开始事件
    yield f"data: {json.dumps({'type': 'start'})}\n\n"
    await asyncio.sleep(0.01) # 让出控制权,以便运行其他任务。
    
    full_response = ""
    
    # 2. 生成并发送 token
    for token in llmApp.stream_chat(request.message, request.chat_history):
        full_response += token
        yield f"data: {json.dumps({'type': 'token', 'content': token})}\n\n"
        await asyncio.sleep(0.01)
    
    # 3. 发送结束事件
    yield f"data: {json.dumps({'type': 'end', 'full_response': full_response})}\n\n"

# 异步测试函数
async def test_chat_stream():
    # 使用 Pydantic 模型实现数据序列化和反序列化(即将JSON数据转换为Python对象)
    request = ChatRequest(message=user_input, chat_history=chat_history)
    async for chunk in chat_stream(request):
        print(chunk)
# 在异步编程中,我们使用asyncio.run()来运行一个异步函数(coroutine)作为程序的入口点。
asyncio.run(test_chat_stream())

打印结果如下:

image.png

在上述的测试代码中的 chat_stream 函数实现一个基于 Server-Sent Events (SSE) 的流式聊天响应的异步生成器,它接收一个 ChatRequest 对象,然后逐步生成事件流。事件流格式遵循 SSE 规范,每个事件以 "data: " 开头,后跟 JSON 字符串,并以两个换行符结束。

  1. 首先,发送一个开始事件,通知客户端开始生成响应。
  2. 然后,通过调用 llmApp.stream_chat 方法,逐个获取 token,并将每个 token 作为一个事件发送。
  3. 在发送每个 token 事件后,使用 await asyncio.sleep(0.01) 来让出控制权,这样其他任务可以运行,避免阻塞。
  4. 同时,将每个 token 累加到 full_response 中,以便在最后发送整个响应。
  5. 最后,发送一个结束事件,并包含完整的响应内容。

这样设计的好处:

  • 流式传输:可以逐步将响应发送给客户端,客户端可以实时看到生成的 token,提升用户体验(如打字机效果)。
  • 异步:使用异步生成器,可以在等待模型生成下一个 token 时让出控制权,提高并发性能。
  • 事件驱动:通过定义不同类型的事件(开始、token、结束),客户端可以方便地根据事件类型进行处理。

6.2 实现 API 层

上面测试代码中实现的 chat_stream 函数,其实就是我们接下来要实现的 流式对话接口,即接收用户的消息和聊天历史,通过流式方式返回 LLM 的响应。同时我们再实现一个健康检查接口,提供服务器的健康状态,包括 LLM 应用是否初始化成功、模型名称等,便于监控。

根据上面所学的知识,我们实现一个基于 FastAPI 的 LLM 聊天 API 服务。

我们创建 ./backend/server.py 文件,内容如下:

import json
import asyncio
from datetime import datetime
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from llm_app import LLMApp
from models import ChatRequest, HealthResponse

app = FastAPI(title="Cobyte LLM Chat API")

# CORS 配置:允许前端跨域访问
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],  # 在生产环境中建议设置为具体的前端域名
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# 全局 LLM 应用实例
llm_app = None

@app.on_event("startup")
async def startup_event():
    """应用启动时初始化 LLM"""
    global llm_app
    try:
        print("正在初始化 LLM 应用...")
        llm_app = LLMApp()
        print("✅ LLM 应用初始化成功")
    except Exception as e:
        print(f"❌ LLM 应用初始化失败: {e}")

@app.get("/api/health")
async def health_check():
    """健康检查接口"""
    return HealthResponse(
        status="healthy" if llm_app else "unhealthy",
        model="deepseek-chat",
        api_configured=llm_app is not None,
        timestamp=datetime.now().isoformat()
    )

@app.post("/api/chat/stream")
async def chat_stream(request: ChatRequest):
    """流式对话接口"""
    if not llm_app:
        raise HTTPException(status_code=500, detail="LLM 服务未就绪")
    
    async def generate():
        try:
            # 1. 发送开始事件
            yield f"data: {json.dumps({'type': 'start'})}\n\n"
            await asyncio.sleep(0.01) # 让出控制权
            
            full_response = ""
            
            # 2. 生成并发送 token
            # 注意:llm_app.stream_chat 是同步生成器,但在 FastAPI 中可以正常工作
            # 如果需要完全异步,需要使用 AsyncChatOpenAI,这里为了简单保持同步调用
            for token in llm_app.stream_chat(request.message, request.chat_history):
                full_response += token
                yield f"data: {json.dumps({'type': 'token', 'content': token})}\n\n"
                await asyncio.sleep(0.01)
            
            # 3. 发送结束事件
            yield f"data: {json.dumps({'type': 'end', 'full_response': full_response})}\n\n"
            
        except Exception as e:
            error_msg = str(e)
            print(f"生成错误: {error_msg}")
            yield f"data: {json.dumps({'type': 'error', 'message': error_msg})}\n\n"

    return StreamingResponse(generate(), media_type="text/event-stream")

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)

至此我们基于 FastAPI 实现了 API 层。核心功能就是提供了两个 API:

  1. 流式对话接口 /api/chat/stream

    • 支持 Server-Sent Events (SSE) 流式响应
    • 接收用户消息,实时返回 AI 生成的回复
    • 支持对话历史管理
  2. 健康检查接口 /api/health

    • 检查服务状态
    • 返回 API 配置信息

6.3 依赖管理

为了更好地管理我们的依赖,我们可以创建一个 ./backend/requirements.txt 文件,将使用到的依赖都设置到这个文件中:

fastapi>=0.109.0
uvicorn>=0.27.0
python-dotenv>=1.0.0
langchain>=1.2.9
langchain-openai>=0.0.5
pydantic>=2.5.0

这样我们就可以进行以下方式进行安装依赖了。

# 安装依赖
pip install -r requirements.txt

7. 前端聊天界面

先创建一个 Vue3 + TS 的前端项目,我们在根目录下执行以下命令:

npm create vite@latest frontend --template vue-ts

接下来我们主要实现以下核心功能:

  1. 对话界面

    • 消息列表展示(用户消息 + AI 回复)
    • 输入框 + 发送按钮
    • 流式显示 AI 回复(逐字显示效果)
    • 加载状态提示
  2. 交互功能

    • 发送消息(Enter 键/点击按钮)
    • 清空对话历史
    • 滚动到最新消息

./frontend/src/types/chat.ts 文件如下:

export interface Message {
  id: string
  role: 'user' | 'assistant'
  content: string
  timestamp: number
  streaming?: boolean  // 是否正在流式生成
}

export interface ChatRequest {
  message: string
  chat_history: Array<{
    role: string
    content: string
  }>
}

export interface SSEEvent {
  type: 'start' | 'token' | 'end' | 'error'
  content?: string
  full_response?: string
  message?: string
}

./frontend/src/api/chat.ts 文件内容如下:

import type { ChatRequest, SSEEvent } from '../types/chat'

const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000'

export class ChatAPI {
  /**
   * 流式对话接口
   */
  static streamChat(
    payload: ChatRequest,
    onToken: (token: string) => void,
    onComplete: (fullResponse: string) => void,
    onError: (error: string) => void
  ): () => void {
    // 使用 fetch API 配合 ReadableStream 来处理 POST 请求的流式响应
    // 因为标准的 EventSource 不支持 POST 请求
    const controller = new AbortController()
    
    const fetchStream = async () => {
      try {
        const response = await fetch(`${API_BASE_URL}/api/chat/stream`, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify(payload),
          signal: controller.signal,
        })

        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`)
        }

        const reader = response.body?.getReader()
        const decoder = new TextDecoder()
        
        if (!reader) throw new Error('Response body is null')

        let buffer = ''

        while (true) {
          const { done, value } = await reader.read()
          if (done) break
          
          const chunk = decoder.decode(value, { stream: true })
          buffer += chunk
          
          // 处理 buffer 中的每一行
          const lines = buffer.split('\n\n')
          buffer = lines.pop() || '' // 保留最后一个可能不完整的块
          
          for (const line of lines) {
            if (line.startsWith('data: ')) {
              const jsonStr = line.slice(6)
              try {
                const data: SSEEvent = JSON.parse(jsonStr)
                
                switch (data.type) {
                  case 'start':
                    break
                  case 'token':
                    if (data.content) onToken(data.content)
                    break
                  case 'end':
                    if (data.full_response) onComplete(data.full_response)
                    return // 正常结束
                  case 'error':
                    onError(data.message || 'Unknown error')
                    return
                }
              } catch (e) {
                console.error('JSON parse error:', e)
              }
            }
          }
        }
      } catch (error: any) {
        if (error.name === 'AbortError') return
        onError(error.message)
      }
    }

    fetchStream()

    // 返回取消函数
    return () => controller.abort()
  }

  /**
   * 健康检查
   */
  static async healthCheck() {
    try {
      const response = await fetch(`${API_BASE_URL}/api/health`)
      return await response.json()
    } catch (error) {
      console.error('Health check failed', error)
      return { status: 'error' }
    }
  }
}

./frontend/src/composables/useChat.ts 文件内容如下:

import { ref, nextTick } from 'vue'
import type { Message } from '../types/chat'
import { ChatAPI } from '../api/chat'

export function useChat() {
  const messages = ref<Message[]>([])
  const isLoading = ref(false)
  const currentStreamingMessage = ref<Message | null>(null)
  
  // 用于取消当前的请求
  let cancelStream: (() => void) | null = null

  /**
   * 滚动到底部
   */
  const scrollToBottom = () => {
    nextTick(() => {
      const container = document.querySelector('.message-list')
      if (container) {
        container.scrollTo({
          top: container.scrollHeight,
          behavior: 'smooth'
        })
      }
    })
  }

  /**
   * 发送消息
   */
  const sendMessage = async (content: string) => {
    if (!content.trim() || isLoading.value) return

    // 1. 添加用户消息
    const userMessage: Message = {
      id: Date.now().toString(),
      role: 'user',
      content: content.trim(),
      timestamp: Date.now()
    }
    messages.value.push(userMessage)
    
    // 准备发送给后端的历史记录(去掉刚加的这一条,因为后端只要之前的)
    // 或者你可以根据设计决定是否包含当前条,通常 API 设计是:新消息 + 历史
    // 我们的后端设计是:message + chat_history
    const historyPayload = messages.value.slice(0, -1).map(m => ({
      role: m.role,
      content: m.content
    }))

    // 2. 创建 AI 消息占位符
    const aiMessage: Message = {
      id: (Date.now() + 1).toString(),
      role: 'assistant',
      content: '',
      timestamp: Date.now(),
      streaming: true
    }
    messages.value.push(aiMessage)
    currentStreamingMessage.value = aiMessage
    isLoading.value = true
    
    scrollToBottom()

    // 3. 调用流式 API
    cancelStream = ChatAPI.streamChat(
      {
        message: content.trim(),
        chat_history: historyPayload
      },
      // onToken
      (token) => {
        if (currentStreamingMessage.value) {
          currentStreamingMessage.value.content += token
          scrollToBottom()
        }
      },
      // onComplete
      (fullResponse) => {
        if (currentStreamingMessage.value) {
          // 确保内容完整
          if (currentStreamingMessage.value.content !== fullResponse && fullResponse) {
             currentStreamingMessage.value.content = fullResponse
          }
          currentStreamingMessage.value.streaming = false
        }
        currentStreamingMessage.value = null
        isLoading.value = false
        cancelStream = null
        scrollToBottom()
      },
      // onError
      (error) => {
        if (currentStreamingMessage.value) {
          currentStreamingMessage.value.content += `\n[错误: ${error}]`
          currentStreamingMessage.value.streaming = false
        }
        currentStreamingMessage.value = null
        isLoading.value = false
        cancelStream = null
        scrollToBottom()
      }
    )
  }

  /**
   * 清空历史
   */
  const clearHistory = () => {
    if (cancelStream) {
      cancelStream()
      cancelStream = null
    }
    messages.value = []
    isLoading.value = false
    currentStreamingMessage.value = null
  }

  return {
    messages,
    isLoading,
    sendMessage,
    clearHistory
  }
}

./frontend/src/App.vue 文件内容如下:

<template>
  <div class="app-container">
    <header class="chat-header">
      <div class="header-content">
        <h1>🤖 DeepSeek 对话助手</h1>
        <div class="status-badge" :class="{ online: isServerOnline }">
          {{ isServerOnline ? '在线' : '离线' }}
        </div>
      </div>
      <button @click="clearHistory" class="clear-btn" title="清空对话">
        🗑️
      </button>
    </header>

    <main class="message-list">
      <div v-if="messages.length === 0" class="empty-state">
        <p>👋 你好!我是基于 DeepSeek 的 AI 助手。</p>
        <p>请在下方输入问题开始对话。</p>
      </div>

      <div 
        v-for="msg in messages" 
        :key="msg.id" 
        class="message-wrapper"
        :class="msg.role"
      >
        <div class="avatar">
          {{ msg.role === 'user' ? '👤' : '🤖' }}
        </div>
        <div class="message-content">
          <div class="bubble">
            {{ msg.content }}
            <span v-if="msg.streaming" class="cursor">|</span>
          </div>
        </div>
      </div>
    </main>

    <footer class="input-area">
      <div class="input-container">
        <textarea
          v-model="inputContent"
          placeholder="输入消息... (Enter 发送, Shift+Enter 换行)"
          @keydown.enter.exact.prevent="handleSend"
          :disabled="isLoading"
          rows="1"
          ref="textareaRef"
        ></textarea>
        <button 
          @click="handleSend" 
          :disabled="isLoading || !inputContent.trim()"
          class="send-btn"
        >
          {{ isLoading ? '...' : '发送' }}
        </button>
      </div>
    </footer>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { useChat } from './composables/useChat'
import { ChatAPI } from './api/chat'

const { messages, isLoading, sendMessage, clearHistory } = useChat()
const inputContent = ref('')
const textareaRef = ref<HTMLTextAreaElement | null>(null)
const isServerOnline = ref(false)

// 检查服务器状态
onMounted(async () => {
  const health = await ChatAPI.healthCheck()
  isServerOnline.value = health.status === 'healthy'
})

// 自动调整输入框高度
watch(inputContent, () => {
  if (textareaRef.value) {
    textareaRef.value.style.height = 'auto'
    textareaRef.value.style.height = textareaRef.value.scrollHeight + 'px'
  }
})

const handleSend = () => {
  if (inputContent.value.trim() && !isLoading.value) {
    sendMessage(inputContent.value)
    inputContent.value = ''
    // 重置高度
    if (textareaRef.value) {
      textareaRef.value.style.height = 'auto'
    }
  }
}
</script>

<style>
:root {
  --primary-color: #4a90e2;
  --bg-color: #f5f7fa;
  --chat-bg: #ffffff;
  --user-msg-bg: #e3f2fd;
  --bot-msg-bg: #f5f5f5;
  --border-color: #e0e0e0;
}

* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
  background-color: var(--bg-color);
  height: 100vh;
  overflow: hidden;
}

.app-container {
  max-width: 800px;
  margin: 0 auto;
  height: 100%;
  display: flex;
  flex-direction: column;
  background-color: var(--chat-bg);
  box-shadow: 0 0 20px rgba(0,0,0,0.05);
}

/* Header */
.chat-header {
  padding: 1rem;
  border-bottom: 1px solid var(--border-color);
  display: flex;
  justify-content: space-between;
  align-items: center;
  background: white;
  z-index: 10;
}

.header-content h1 {
  font-size: 1.2rem;
  color: #333;
}

.status-badge {
  font-size: 0.8rem;
  padding: 2px 6px;
  border-radius: 4px;
  background: #ff5252;
  color: white;
  display: inline-block;
  margin-left: 8px;
}

.status-badge.online {
  background: #4caf50;
}

.clear-btn {
  background: none;
  border: none;
  cursor: pointer;
  font-size: 1.2rem;
  padding: 5px;
  border-radius: 50%;
  transition: background 0.2s;
}

.clear-btn:hover {
  background: #f0f0f0;
}

/* Message List */
.message-list {
  flex: 1;
  overflow-y: auto;
  padding: 20px;
  display: flex;
  flex-direction: column;
  gap: 20px;
}

.empty-state {
  text-align: center;
  margin-top: 50px;
  color: #888;
}

.message-wrapper {
  display: flex;
  gap: 12px;
  max-width: 85%;
}

.message-wrapper.user {
  align-self: flex-end;
  flex-direction: row-reverse;
}

.avatar {
  width: 36px;
  height: 36px;
  border-radius: 50%;
  background: #eee;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 1.2rem;
  flex-shrink: 0;
}

.bubble {
  padding: 12px 16px;
  border-radius: 12px;
  line-height: 1.5;
  white-space: pre-wrap;
  word-break: break-word;
}

.message-wrapper.user .bubble {
  background: var(--user-msg-bg);
  color: #0d47a1;
  border-radius: 12px 2px 12px 12px;
}

.message-wrapper.assistant .bubble {
  background: var(--bot-msg-bg);
  color: #333;
  border-radius: 2px 12px 12px 12px;
}

.cursor {
  display: inline-block;
  width: 2px;
  height: 1em;
  background: #333;
  animation: blink 1s infinite;
  vertical-align: middle;
}

@keyframes blink {
  0%, 100% { opacity: 1; }
  50% { opacity: 0; }
}

/* Input Area */
.input-area {
  padding: 20px;
  border-top: 1px solid var(--border-color);
  background: white;
}

.input-container {
  display: flex;
  gap: 10px;
  align-items: flex-end;
  background: #f8f9fa;
  padding: 10px;
  border-radius: 12px;
  border: 1px solid var(--border-color);
}

textarea {
  flex: 1;
  border: none;
  background: transparent;
  resize: none;
  max-height: 150px;
  padding: 8px;
  font-size: 1rem;
  font-family: inherit;
  outline: none;
}

.send-btn {
  background: var(--primary-color);
  color: white;
  border: none;
  padding: 8px 20px;
  border-radius: 8px;
  cursor: pointer;
  font-weight: 600;
  transition: opacity 0.2s;
}

.send-btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}
</style>

./frontend/src/style.css 文件内容如下:

:root {
  font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
  line-height: 1.5;
  font-weight: 400;

  color-scheme: light dark;
  color: rgba(255, 255, 255, 0.87);
  background-color: #242424;

  font-synthesis: none;
  text-rendering: optimizeLegibility;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

body {
  margin: 0;
  display: flex;
  place-items: center;
  min-width: 320px;
  min-height: 100vh;
}

#app {
  width: 100%;
  height: 100vh;
}

.container {
  max-width: 600px;
  margin: 0 auto;
  padding: 2rem;
  background-color: #1a1a1a;
  border-radius: 12px;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}

.header {
  text-align: center;
  margin-bottom: 2rem;
}

.header h1 {
  font-size: 2rem;
  color: #ffffff;
  margin: 0;
}

.header p {
  font-size: 1rem;
  color: #bbbbbb;
  margin: 0;
}

.form-group {
  margin-bottom: 1.5rem;
}

.form-group label {
  display: block;
  margin-bottom: 0.5rem;
  color: #ffffff;
  font-size: 0.9rem;
}

.form-group input {
  width: 100%;
  padding: 0.75rem;
  border: 1px solid #444;
  border-radius: 6px;
  background-color: #2a2a2a;
  color: #ffffff;
  font-size: 1rem;
}

.form-group textarea {
  width: 100%;
  padding: 0.75rem;
  border: 1px solid #444;
  border-radius: 6px;
  background-color: #2a2a2a;
  color: #ffffff;
  font-size: 1rem;
  resize: vertical;
}

.form-group button {
  width: 100%;
  padding: 0.75rem;
  border: none;
  border-radius: 6px;
  background-color: #4caf50;
  color: #ffffff;
  font-size: 1rem;
  cursor: pointer;
  transition: background-color 0.3s ease;
}

.form-group button:hover {
  background-color: #45a049;
}

.error-message {
  color: #ff4d4d;
  font-size: 0.8rem;
  margin-top: 0.5rem;
  display: none;
}

.success-message {
  color: #4caf50;
  font-size: 0.8rem;
  margin-top: 0.5rem;
  display: none;
}

@media (max-width: 600px) {
  .container {
    padding: 1rem;
  }

  .form-group input,
  .form-group textarea {
    font-size: 0.9rem;
  }

  .form-group button {
    font-size: 0.9rem;
  }
}

前端比较简单,前端部分的实现就不进行详细讲解了。

8. 运行与验证

8.1 启动后端

打开一个终端窗口:

cd backend
# 1. 安装依赖
pip install -r requirements.txt

# 2. 设置 API Key (重要!)
# 编辑 .env 文件,填入你的 DeepSeek API Key
# DEEPSEEK_API_KEY=sk-... 

# 3. 启动服务器
python server.py
# 服务将运行在 http://0.0.0.0:8000

8.2 启动前端

打开一个新的终端窗口:

cd frontend
# 1. 安装依赖
npm install

# 2. 启动开发服务器
npm run dev

访问前端地址,你就可以看到一个简洁的聊天界面。

image.png

当你输入问题并点击发送时,请求会经过: 前端 -> FastAPI -> LangChain -> DeepSeek API -> 返回结果

9. 总结

通过本文,我们完成了一个最小可行性产品(MVP)。从零开始搭建一个基于 Python (FastAPI)LangChain 和 Vue 3 的全栈 LLM 聊天应用程序。

这个项目虽然简单,但它包含了一个 AI 应用的完整骨架。你可以在此基础上扩展更多功能,例如添加对话历史记忆 (Memory)  或 RAG (知识库检索)

接下来我将继续输出更多 AI 全栈的相关知识,欢迎大家关注本栏目。我是 Cobyte,欢迎添加 v:icobyte,学习 AI 全栈。

《 Koa.js 》教程 | 一份不可多得的 Node.js 的 Web 框架 Koa.js 教程

2026年2月8日 15:49

第一章 安装和配置 koa

Koa 是一个轻量级、现代化的框架, 由 Express 原班人马开发

初始化配置文件 package.json

npm init -y

配置 package.json (ESM规范)

{
     "type": "module",
     "name": "demo",
     "version": "1.0.0",
     "main": "index.js",
     "scripts": {
          "dev":"nodemon index.js",
           "test": "echo \"Error: no test specified\" && exit 1"
     },
     "keywords": [],
     "author": "",
     "license": "ISC",
     "description": ""
}

npm 官网

     www.npmjs.com

安装koa      

npm i koa

     全局安装 nodemon

  .  npm i nodemon -g

     当 nodemon 检测到监视的文件发生更改时, 会自动重新启动应用

第二章 创建并启动 http 服务器

中间件

中间件是处理 HTTP 请求和响应的函数,它们可以做以下操作:

  • 处理请求(例如解析请求体、验证用户身份等)
  • 修改响应(例如设置响应头、发送响应体等)
  • 执行后续中间件

中间件 - 很重要的概念 !!!!!!!

注意 : app.use() 方法用于注册 中间件

中间件 是处理 http 请求和响应的函数 , 当一个请求到达服务器时, 会从第一个中间件开始执行, 直到最后一个中间件

上下文对象 ctx

在 Koa 中,ctx(上下文)对象是每个中间件函数的核心,它包含了请求和响应的所有信息。所有的 HTTP 请求和响应都通过 ctx 进行处理。

上下文对象 ctx ( context ) 包含了与当前 http 请求相关的所有信息

如: http方法、url、请求头、请求体、查询参数等

import Koa from 'koa'

const hostname = "127.0.0.1" //服务器监听的ip地址
const port = 8008 //服务器监听的端口号

/*
    实例化一个 Koa 对象
    实例化是指根据一个类创建具体对象的过程
*/
const app = new Koa()

app.use(async ctx => {
    ctx.body = "juejin.cn" // 使用 ctx.body 设置响应体的内容
})

//启动 http 服务器, 并在指定的ip地址(127.0.0.1)和端口(8008)上监听连接请求
app.listen(port, hostname, () => {
    console.log(`服务器已启动: http://${hostname}:${port}`)
})

第三章 洋葱模型

洋葱模型

当你处理一个请求时,

可以想象成是在 "剥洋葱" ,从外向内一层一层地往里剥,直到剥到中心部分

这个过程涉及对 请求 的多个层面进行解析、验证、处理

在处理完洋葱(请求)后,

构建 响应 的过程就像是从精心准备的食材 ( 处理请求 后得到的数据) 开始,

从内向外逐层添加调料(格式化、封装等),最终形成一道色香味俱佳的菜肴(响应)

image.png

import Koa from 'koa'

const hostname = "127.0.0.1" //服务器监听的ip地址
const port = 8008 //服务器监听的端口号

/*
    实例化一个 Koa 对象
    实例化是指根据一个类创建具体对象的过程
*/
const app = new Koa()

/*
    app.use() 方法用于注册中间件
    中间件是处理 http 请求和响应的函数
    当一个请求到达服务器时, 会从第一个中间件开始执行, 直到最后一个中间件
    
    上下文对象 ctx(context) 包含了与当前 http 请求相关的所有信息
    如: http方法、url、请求头、请求体、查询参数等
*/
app.use(async (ctx,next) => {
    console.log(1)
    await next() //若中间件调用了next(),会暂停当前中间件的执行,将控制权传递给下一个中间件
    console.log(2)
})

app.use(async (ctx,next) => { 
    console.log(3)
    await next()
    console.log(4)
})

//当中间件没有再调用next(),则不需要再将控制权传递给下一个中间件,控制权会按照相反的顺序执行
app.use(async (ctx,next) => {
    console.log(5)
    ctx.body = "dengruicode.com" // 使用 ctx.body 设置响应体的内容
})

//启动 http 服务器, 并在指定的ip地址(127.0.0.1)和端口(8008)上监听连接请求
app.listen(port, hostname, () => {
    console.log(`服务器已启动: http://${hostname}:${port}`)
})

第四章 安装和配置路由 - get请求

在 Koa 中,koa-router 是一个轻量级的路由中间件,它可以帮助你定义路由、处理 HTTP 请求并解析请求参数。通过使用 koa-router,你可以创建一个灵活的路由系统,轻松地组织和管理 Koa 应用的各个部分。

安装 koa-router

首先,你需要安装 koa-router

npm install @koa/router       # 注意:新版 koa-router 包名是 @koa/router
import Koa from 'koa'
import Router from '@koa/router'

const hostname = "127.0.0.1"
const port = 8008

const app = new Koa()
const router = new Router() //实例化一个 Router 对象

//------ get请求
//路由是根据客户端发送的请求(包括请求的路径、方法等)调用与之匹配的处理函数
//根路由 http://127.0.0.1:8008/
router.get('/', async ctx => { //get请求
    ctx.body = "dengruicode.com"
})

//查询参数 http://127.0.0.1:8008/test?id=001&web=dengruicode.com
router.get('/test', async ctx => { //get请求
    let id = ctx.query.id
    let web = ctx.query.web
    ctx.body = id + " : " + web
})

//路径参数 http://127.0.0.1:8008/test2/id/002/web/www.dengruicode.com
router.get('/test2/id/:id/web/:web', async ctx => {
    let id = ctx.params.id
    let web = ctx.params.web
    ctx.body = id + " : " + web
})

//重定向路由 http://127.0.0.1:8008/test3
router.redirect('/test3', 'https://www.baidu.com')

app.use(router.routes()) //将定义在 router 对象中的路由规则添加到 app 实例中

//------ 路由分组
//http://127.0.0.1:8008/user/add
//http://127.0.0.1:8008/user/del

const userRouter = new Router({ prefix: '/user' })
userRouter.get('/add', async ctx => {
    ctx.body = "添加用户"
})
userRouter.get('/del', async ctx => {
    ctx.body = "删除用户"
})
app.use(userRouter.routes())

// 在所有路由之后添加404处理函数
app.use(async ctx => {
    if (!ctx.body) { //若没有设置 ctx.body, 则说明没有到匹配任何路由
        ctx.status = 404
        ctx.body = '404 Not Found'
    }
})

app.listen(port, hostname, () => {
    console.log(`服务器已启动: http://${hostname}:${port}`)
})

第五章 post请求

安装 koa-body

Koa 原生不支持解析 POST 请求体,需安装 koa-body 中间件:

npm install koa-body

POST 请求处理示例

修改 src/index.js,新增 POST 路由:

import Koa from 'koa';
import Router from '@koa/router';
import { koaBody } from 'koa-body';

const app = new Koa();
const router = new Router();
const port = 8008;

// 注册 koa-body 中间件:解析 JSON、表单、文件类型的 POST 数据
app.use(koaBody({
  multipart: true, // 支持文件上传(后续第八章用)
  json: true, // 解析 JSON 格式
  urlencoded: true // 解析表单格式(application/x-www-form-urlencoded)
}));

// 1. 处理 JSON 格式 POST 请求
router.post('/api/json', async (ctx) => {
  const { name, age } = ctx.request.body;
  ctx.body = {       // ctx.request.body 是 koa-body 解析后的 POST 数据
    code: 200,
    msg: "JSON 数据接收成功",
    data: { name, age }
  };
});

// 2. 处理表单格式 POST 请求
router.post('/api/form', async (ctx) => {
  const { username, password } = ctx.request.body;
  ctx.body = {
    code: 200,
    msg: "表单数据接收成功",
    data: { username, password }
  };
});

app.use(router.routes());

// 404 处理
app.use(async (ctx) => {
  ctx.status = 404;
  ctx.body = '404 Not Found';
});

app.listen(port, () => {
  console.log(`POST 服务器启动:http://localhost:${port}`);
});

测试 POST 请求(两种方式)

方式 1:Postman 测试

  • 请求地址:http://localhost:8008/api/json

  • 请求方法:POST

  • 请求体:选择 raw > JSON,输入:

    { "name": "张三", "age": 20 }
    
  • 响应:{"code":200,"msg":"JSON 数据接收成功","data":{"name":"张三","age":20}}

方式 2:curl 命令测试

# 测试 JSON 格式
curl -X POST -H "Content-Type: application/json" -d '{"name":"张三","age":20}' http://localhost:8008/api/json

# 测试表单格式
curl -X POST -d "username=admin&password=123456" http://localhost:8008/api/form

第六章 错误处理

import Koa from 'koa'
import Router from '@koa/router'

const hostname = "127.0.0.1"
const port = 8008

const app = new Koa()
const router = new Router()

//http://127.0.0.1:8008/
router.get('/', async ctx => {
    throw new Error("测试")
})

/*
    将 '错误处理中间件' 放在 '路由处理中间件' 之前, 当一个请求到达时,
    会先经过 '错误处理中间件', 然后才会进入 '路由处理中间件',
    是为了确保可以捕获错误
*/
app.use(async (ctx, next) => {  // 错误处理中间件
    try {
        await next()
    } catch (err) {
        //console.log('err:', err)
        ctx.status = 500
        ctx.body = 'err: ' + err.message
    }
})

app.use(router.routes())   // 路由处理中间件

app.listen(port, hostname, () => {
    console.log(`服务器已启动: http://${hostname}:${port}`)
})

第七章 允许跨域请求

安装跨域中间件

npm install @koa/cors

跨域配置示例

import Koa from 'koa';
import Router from '@koa/router';
import Cors from '@koa/cors';

const app = new Koa();
const router = new Router();
const port = 8008;

app.use(Cors()) //允许跨域请求

// 测试跨域路由
router.get('/api/cors', async (ctx) => {
  ctx.body = {
    code: 200,
    msg: "跨域请求成功"
  };
});

app.use(router.routes());

app.listen(port, () => {
  console.log(`跨域服务器启动:http://localhost:${port}`);
});

测试跨域

在任意前端项目(如 Vue / React / HTML 文件)中发送请求:

// 前端代码示例
fetch('http://localhost:8008/api/cors')
  .then(res => res.json())
  .then(data => console.log(data)) // 输出 {code:200, msg:"跨域请求成功"}
  .catch(err => console.error(err));

无跨域报错即配置成功。

第八章 上传图片

依赖准备(复用 koa-body)

koa-body 已支持文件上传,无需额外安装依赖,只需确保配置 multipart: true

图片上传示例

import Koa from 'koa';
import Router from '@koa/router';
import { koaBody } from 'koa-body';
import fs from 'fs';
import path from 'path';

const app = new Koa();
const router = new Router();
const port = 8008;

// 1. 创建上传目录(不存在则创建)
const uploadDir = path.join(__dirname, 'uploads');
if (!fs.existsSync(uploadDir)) {
  fs.mkdirSync(uploadDir, { recursive: true });
}

// 2. 配置 koa-body 支持文件上传
app.use(koaBody({
  multipart: true, // 开启文件上传
  formidable: {
    uploadDir: uploadDir, // 临时存储目录
    keepExtensions: true, // 保留文件扩展名(如 .png/.jpg)
    maxFieldsSize: 2 * 1024 * 1024, // 限制文件大小 2MB
    filename: (name, ext, part, form) => {
      // 自定义文件名:时间戳 + 原扩展名,避免重复
      return Date.now() + ext;
    }
  }
}));

// 3. 图片上传接口
router.post('/api/upload', async (ctx) => {
  // ctx.request.files 是上传的文件对象
  const file = ctx.request.files.file; // 前端上传的文件字段名需为 file
  if (!file) {
    ctx.status = 400;
    ctx.body = { code: 400, msg: "请选择上传的图片" };
    return;
  }

  // 返回文件信息
  ctx.body = {
    code: 200,
    msg: "图片上传成功",
    data: {
      filename: file.newFilename, // 自定义后的文件名
      path: `/uploads/${file.newFilename}`, // 访问路径
      size: file.size // 文件大小(字节)
    }
  };
});

// 4. 静态文件访问:让上传的图片可通过 URL 访问
app.use(async (ctx, next) => {
  if (ctx.path.startsWith('/uploads/')) {
    const filePath = path.join(uploadDir, ctx.path.replace('/uploads/', ''));
    if (fs.existsSync(filePath)) {
      ctx.type = path.extname(filePath).slice(1); // 设置响应类型(如 png/jpg)
      ctx.body = fs.createReadStream(filePath); // 读取文件并返回
      return;
    }
    ctx.status = 404;
    ctx.body = "文件不存在";
    return;
  }
  await next();
});

app.use(router.routes());

app.listen(port, () => {
  console.log(`图片上传服务器启动:http://localhost:${port}`);
});

测试图片上传

方式 1:Postman 测试

  • 请求地址:http://localhost:8008/api/upload
  • 请求方法:POST
  • 请求体:选择 form-data,Key 为 file,Type 选 File,上传一张图片。
  • 响应:返回文件路径,如 http://localhost:8008/uploads/1738987654321.png,访问该 URL 可查看图片。

方式 2:curl 命令测试

终端输入 bash 命令

curl -X POST -F "file=@/你的图片路径/xxx.png" http://localhost:8008/api/upload

第九章 cookie

Cookie 是存储在客户端浏览器的小型文本数据,Koa 内置 ctx.cookies API 可以操作 Cookie。

Cookie 操作示例

import Koa from 'koa'
import Router from '@koa/router'
 
const app = new Koa();
const router = new Router();
const port = 8008;

// 1. 设置 Cookie
router.get('/cookie/set', async (ctx) => {
  // ctx.cookies.set(名称, 值, 配置)
  ctx.cookies.set(
    'username', 
    encodeURIComponent('张三'), 
    {
      maxAge: 24 * 60 * 60 * 1000, // 过期时间 1 天(毫秒)
      httpOnly: true, // 仅允许服务端访问,防止 XSS 攻击
      secure: false, // 开发环境设为 false(HTTPS 环境设为 true)
      path: '/', // 生效路径(/ 表示全站)
      sameSite: 'lax' // 防止 CSRF 攻击
    }
  );
  ctx.body = { code: 200, msg: "Cookie 设置成功" };
});

// 2. 获取 Cookie
router.get('/cookie/get', async (ctx) => {
  const username = ctx.cookies.get('username');
  ctx.body = {
    code: 200,
    msg: "Cookie 获取成功",
    data: { username }
  };
});

// 3. 删除 Cookie
router.get('/cookie/delete', async (ctx) => {
  ctx.cookies.set('username', '', { maxAge: 0 }); // 设置 maxAge 为 0 即删除
  ctx.body = { code: 200, msg: "Cookie 删除成功" };
});

app.use(router.routes());

app.listen(port, () => {
  console.log(`Cookie 服务器启动:http://localhost:${port}`);
});

测试 Cookie

  1. 访问 http://localhost:8008/cookie/set → 设置 Cookie;
  2. 访问 http://localhost:8008/cookie/get → 获取 Cookie,输出 {username: "张三"}
  3. 访问 http://localhost:8008/cookie/delete → 删除 Cookie,再次获取则为 undefined

第十章 session

安装 Session 中间件

Koa 原生不支持 Session,需安装 koa-session

npm install koa-session

Session 配置示例

import Koa from 'koa'
import Router from '@koa/router'
import session  from 'koa-session'

const app = new Koa();
const router = new Router();
const port = 8008;

// 1. 配置 Session 密钥(生产环境需改为随机字符串)
app.keys = ['dengruicode_secret_key'];

// 2. Session 配置
const CONFIG = {
  key: 'koa:sess', // Session Cookie 名称
  maxAge: 24 * 60 * 60 * 1000, // 过期时间 1 天
  autoCommit: true,
  overwrite: true,
  httpOnly: true, // 仅服务端访问
  signed: true, // 签名 Cookie,防止篡改
  rolling: false, // 不刷新过期时间
  renew: false, // 快过期时自动续期
  secure: false, // 开发环境 false
  sameSite: 'lax'
};

// 3. 注册 Session 中间件
app.use(session(CONFIG, app));

// 4. Session 操作
// 设置 Session
router.get('/session/set', async (ctx) => {
  ctx.session.user = {
    id: 1,
    name: "张三",
    age: 20
  };
  ctx.body = { code: 200, msg: "Session 设置成功" };
});

// 获取 Session
router.get('/session/get', async (ctx) => {
  const user = ctx.session.user;
  ctx.body = {
    code: 200,
    msg: "Session 获取成功",
    data: { user }
  };
});

// 删除 Session
router.get('/session/delete', async (ctx) => {
  ctx.session = null; // 清空 Session
  ctx.body = { code: 200, msg: "Session 删除成功" };
});

app.use(router.routes());

app.listen(port, () => {
  console.log(`Session 服务器启动:http://localhost:${port}`);
});

测试 Session

  1. 访问 http://localhost:8008/session/set → 设置 Session;
  2. 访问 http://localhost:8008/session/get → 获取 Session,输出用户信息;
  3. 访问 http://localhost:8008/session/delete → 清空 Session,再次获取则为 undefined

注意:koa-session 是基于 Cookie 的内存 Session,生产环境建议使用 koa-redis 将 Session 存储到 Redis,避免服务重启丢失数据。

第十一章 jwt

安装 JWT 依赖

npm install jsonwebtoken koa-jwt
  • jsonwebtoken:生成 / 解析 JWT 令牌;
  • koa-jwt:验证 JWT 令牌的中间件。

JWT 完整示例

import Koa from 'koa'
import Router from '@koa/router'
import jwt  from 'jsonwebtoken'
import koaJwt  from 'koa-jwt'

const app = new Koa();
const router = new Router();
const port = 8008;

// 1. JWT 密钥(生产环境需加密存储)
const JWT_SECRET = 'dengruicode_jwt_secret';
// JWT 过期时间:1 小时(秒)
const JWT_EXPIRES_IN = 3600;

// 2. 登录接口:生成 JWT 令牌
router.post('/api/login', async (ctx) => {
  // 模拟验证用户名密码(生产环境需查数据库)
  const { username, password } = ctx.request.body;
  if (username === 'admin' && password === '123456') {
    // 生成 JWT 令牌
    const token = jwt.sign(
      { id: 1, username }, // 载荷:存储用户信息(不要存敏感数据)
      JWT_SECRET,
      { expiresIn: JWT_EXPIRES_IN }
    );
    ctx.body = {
      code: 200,
      msg: "登录成功",
      data: { token }
    };
  } else {
    ctx.status = 401;
    ctx.body = { code: 401, msg: "用户名或密码错误" };
  }
});

// 3. 受保护的接口:需要 JWT 验证
// koa-jwt 中间件会自动解析 Authorization 头中的 token
app.use(koaJwt({ secret: JWT_SECRET }).unless({
  path: [/^/api/login/] // 排除登录接口,无需验证
}));

// 4. 获取用户信息接口(需验证 JWT)
router.get('/api/user/info', async (ctx) => {
  // ctx.state.user 是 koa-jwt 解析后的 JWT 载荷
  const { id, username } = ctx.state.user;
  ctx.body = {
    code: 200,
    msg: "获取用户信息成功",
    data: { id, username }
  };
});

app.use(router.routes());

// 5. JWT 错误处理
app.use(async (ctx, next) => {
  try {
    await next();
  } catch (err) {
    if (err.status === 401) {
      ctx.status = 401;
      ctx.body = { code: 401, msg: "token 无效或过期" };
    } else {
      throw err;
    }
  }
});

app.listen(port, () => {
  console.log(`JWT 服务器启动:http://localhost:${port}`);
});

测试 JWT

步骤 1:登录获取 token

curl -X POST -d "username=admin&password=123456" http://localhost:8008/api/login
# 响应:{"code":200,"msg":"登录成功","data":{"token":"xxx.xxx.xxx"}}

步骤 2:携带 token 访问受保护接口

curl -H "Authorization: Bearer 你的token" http://localhost:8008/api/user/info
# 响应:{"code":200,"msg":"获取用户信息成功","data":{"id":1,"username":"admin"}}

步骤 3:token 无效 / 过期测试

携带错误 token 或过期 token 访问,会返回 {"code":401,"msg":"token 无效或过期"}

总结

  1. 核心流程:Koa 开发的核心是「中间件 + 路由」,所有功能(跨域、上传、JWT)都通过中间件扩展;

  2. 关键依赖@koa/router(路由)、koa-body(POST / 上传)、@koa/cors(跨域)、koa-session(Session)、jsonwebtoken/koa-jwt(JWT);

  3. 生产建议

    • Session/JWT 密钥需随机生成并加密存储;

    • 文件上传需限制大小和类型,防止恶意上传;

    • 跨域需指定具体域名,而非 *

    • JWT 载荷不要存敏感数据,过期时间不宜过长。

Nginx 路径映射深度解析:从本地开发到生产交付的底层哲学

2026年2月8日 13:01

Nginx 静态资源映射:从原理到生产环境的最佳实践

摘要:在现代前后端分离架构中,Nginx 不仅是高性能的静态资源服务器,更是不可或缺的反向代理枢纽。然而,由于对资源映射(root/alias)及请求转发(proxy_pass)逻辑的理解偏差,往往会导致从 Windows 开发环境迁移至 Linux 生产环境时出现 404 或转发异常。本文将从 HTTP 协议视角出发,深度剖析“路径映射三剑客”的底层逻辑,并提供一套可落地的工程化配置规范与避坑指南。


1. 业务场景与工程痛点

在实际的工程链路中,我们经常遇到这样的场景: 前端同学在 Windows 本地使用 Nginx 调试 SPA(单页应用)或静态站点,一切运行正常。但当 CI/CD 流水线将代码部署到 Linux 生产服务器后,访问特定资源(如图片、次级路由)却频频出现 404 错误。

这并非玄学,而是由于对 Nginx 路径解析机制操作系统文件系统差异 理解不足导致的。要解决这个问题,我们需要先建立正确的路径映射心智模型。

2. 核心模型解析:URL 与文件系统的映射

Nginx 的核心职责之一,就是将抽象的 HTTP URI 映射到具体的 服务器文件系统路径

2.1 URI 的语义差异

在配置之前,必须明确 URL 尾部斜杠的协议语义:

  • /images:客户端请求名为 images资源实体(可能是文件,也可能是目录)。
  • /images/:客户端明确请求名为 images目录容器

工程细节: 当用户访问 /images(不带斜杠)且服务器上存在同名目录时,Nginx 默认会返回 301 Moved Permanently,自动重定向到 /images/。这是为了确保相对路径资源(如 ./logo.png)能基于正确的 Base URL 加载。


3. 资源映射三剑客:Root、Alias 与 Proxy_Pass

rootaliasproxy_pass 是 Nginx 流量分发的核心指令。前两者解决的是如何将 URI 映射到 本地文件系统,而后者解决的是如何将请求转发到 网络服务接口

3.1 Root:追加逻辑 (Append)

root 指令采用追加策略。它将请求的 URI 完整拼接到 root 指定的路径之后。

  • 计算公式最终物理路径 = root路径 + 完整URI
  • 配置示例
    location /static/ {
        root /var/www/app;
    }
    
  • 解析过程:请求 GET /static/css/style.css -> 物理路径:/var/www/app/static/css/style.css

3.2 Alias:替换逻辑 (Replace)

alias 指令采用替换策略。它用 alias 指定的路径替换掉 location 匹配到的部分。

  • 计算公式最终物理路径 = alias路径 + (完整URI - location匹配部分)
  • 配置示例
    location /static/ {
        alias /var/www/app/public/;
    }
    
  • 解析过程:请求 GET /static/css/style.css -> 匹配 /static/ -> 剩余 css/style.css -> 最终访问:/var/www/app/public/css/style.css

3.3 Proxy_Pass:请求转发逻辑 (Forward)

与处理本地文件的指令不同,proxy_pass 处理的是网络协议栈的转发。其路径处理逻辑遵循相似的“追加”与“替换”哲学,由目标 URL 结尾是否有 / 决定。

场景 A:不带斜杠(透明转发,对应 Root 逻辑)

proxy_pass 的目标 URL 不带路径(即没有结尾的 /)时,Nginx 会将原始请求的 URI 完整地传递给后端服务。

  • 配置示例
    location /api/ {
        proxy_pass http://127.0.0.1:3000; 
    }
    
  • 路径解析:请求 GET /api/user -> 转发到 http://127.0.0.1:3000/api/user
  • 工程特征location 匹配路径被完整保留。适用于后端服务本身就包含 /api 前缀的场景。
场景 B:带斜杠(路径重写,对应 Alias 逻辑)

proxy_pass 的目标 URL 包含路径(即使只有一个结尾的 /)时,Nginx 会将 URI 中匹配 location 的部分替换为该路径。

  • 配置示例
    location /api/ {
        proxy_pass http://127.0.0.1:3000/; 
    }
    
  • 路径解析:请求 GET /api/user -> 转发到 http://127.0.0.1:3000/user
  • 工程特征location 匹配路径被“剥离”。适用于后端服务是纯净接口,仅通过 Nginx 统一前缀入口的场景。

3.4 资源映射三剑客对比表

假设统一配置 location /api/,观察不同指令下的映射结果:

指令 映射目标 URI 处理方式 示例配置 实际请求 -> 结果映射 典型场景
Root 本地磁盘 追加 (Append) root /data; /api/user -> /data/api/user 静态站点默认部署
Alias 本地磁盘 替换 (Replace) alias /data/v1/; /api/user -> /data/v1/user 虚拟路径、资源别名
Proxy_Pass (无/) 远程服务 透明转发 proxy_pass http://node:3000; /api/user -> node:3000/api/user 后端服务自带前缀
Proxy_Pass (带/) 远程服务 路径重写 proxy_pass http://node:3000/; /api/user -> node:3000/user 统一入口,后端无前缀

4. 工程化落地:跨平台环境差异处理

在团队协作中,统一开发环境(Windows/Mac)与生产环境(Linux)的配置规范至关重要。

4.1 Windows 开发环境的陷阱

Windows 文件系统有“盘符”概念,且对路径分隔符不敏感。

  • 绝对路径问题: 在 Windows 下配置 root /html;,Nginx 会将其解析为当前盘符的根目录(如 D:\html),而非 Nginx 安装目录。
  • 最佳实践使用相对路径
    # 推荐:相对于 Nginx 安装目录 (prefix)
    location / {
        root html; 
        index index.html;
    }
    

4.2 Linux 生产环境的规范

Linux 环境强调权限控制与路径的确定性。

  • 绝对路径强制: 生产配置必须使用绝对路径,避免因启动方式不同导致的工作目录漂移。

    root /usr/share/nginx/html;
    
  • 权限隔离 (Permission): 常见的 403 Forbidden 错误通常并非配置错误,而是权限问题。

    • 要求:Nginx 运行用户(通常是 nginxwww-data)必须拥有从根目录到目标文件全路径的 x (执行/搜索) 权限,以及目标文件的 r (读取) 权限。
    • 排查命令
      namei -om /var/www/project/static/image.png
      
  • Alias 的斜杠对称性: 这是一个容易被忽视的 Bug 源。在 Linux 下使用 alias 时,如果 location 只有尾部斜杠,建议 alias 也加上尾部斜杠,保持对称,避免路径拼接错位。

    # Good
    location /img/ {
        alias /var/www/images/;
    }
    

5. 调试与排错指南

当出现 404 或 403 时,不要盲目猜测,请遵循以下排查路径:

  1. Check Error Log: 这是最直接的证据。Nginx 的 error.log 会明确打印出它试图访问的完整物理路径。

    open() "/var/www/app/static/css/style.css" failed (2: No such file or directory)
    

    对比日志中的路径与你预期的路径,通常能立刻发现 rootalias 的误用。

  2. 验证文件存在性: 直接复制日志中的路径,在服务器上执行 ls -l <path>,确认文件是否存在以及权限是否正确。


总结: Nginx 的路径映射与转发逻辑虽然细碎,但其背后遵循着高度一致的“追加”与“替换”哲学。掌握 rootaliasproxy_pass 的底层差异,不仅能解决 404/403 等表象问题,更能帮助开发者构建出优雅、可维护的配置体系。在工程实践中,建议通过规范化路径命名(如统一使用 /api/ 前缀)与环境感知配置(如 Linux 绝对路径强制化)来降低运维复杂度,确保从本地开发到生产交付的丝滑顺畅。

主管:”人家 Node 框架都用 Nest.js 了 , 你怎么还在用 Express ?“

作者 千寻girling
2026年2月8日 12:43

我反驳主管道 : “我自己做项目做着玩 ! 你管我用哪一个 !”

回家之后 , 我开始好奇那么多 Node 框架 , 到底有什么区别啊?

Node.js Web 框架各式各样 , 下面简单的介绍一下这些 Node.js Web 框架 !

一、分类

Node.js Web 框架主要分 3 类:

分类 核心特点 代表框架 适用场景
极简核心框架 仅封装 HTTP 基础能力,无冗余功能 Express、Koa 中小型 API、自定义业务系统
全栈 / 企业级框架 内置路由、ORM、验证、鉴权等全套能力 NestJS、AdonisJS 大型企业应用、团队协作项目
高性能框架 基于异步 I/O/ 编译优化,极致性能 Fastify、Hapi 高并发 API、微服务
特殊场景框架 针对特定场景优化(如 SSR、低代码) Next.js、Nuxt.js(Node 端)、Sails.js 前端 SSR、低代码平台

二、主流框架详细介绍

⚠️ : 排名不分先后顺序

1. Express(最经典的极简框架)

  • 核心定位:Node.js Web 框架的 “鼻祖”,极简、灵活,无内置冗余功能,生态最丰富。

  • 核心特性

    • 中间件机制(线性中间件,req -> 中间件1 -> 中间件2 -> res);
    • 简洁的路由系统;
    • 无内置 ORM / 验证,需手动集成第三方库(如 mongooseexpress-validator)。
  • 示例代码

    const express = require('express');
    const app = express();
    
    // 中间件
    app.use(express.json());
    
    // 路由
    app.get('/api/user', (req, res) => {
      res.json({ name: '张三', age: 20 });
    });
    
    app.listen(3000, () => console.log('Express 启动在 3000 端口'));
    
  • 优点:生态极全(几乎所有 Node 库都兼容)、学习成本低、灵活度高;

  • 缺点:回调嵌套(易出现 “回调地狱”)、无内置类型支持(TS 需手动配置)、无统一规范(团队协作易混乱);

  • 适用场景:中小型 API 服务、快速原型开发、个人项目。

2. Koa(Express 团队升级版)

  • 核心定位:Express 原团队开发,解决 Express 回调地狱问题,基于 async/await 重构。

  • 核心特性

    • 洋葱模型中间件(中间件可双向执行,如 “请求进来执行 -> 响应出去再执行”);
    • 原生支持 async/await,无回调地狱;
    • 比 Express 更精简(甚至没有内置路由,需装 koa-router)。
  • 示例代码

    const Koa = require('koa');
    const koaRouter = require('koa-router');
    const koaBody = require('koa-body');
    
    const app = new Koa();
    const router = new koaRouter();
    
    // 洋葱模型中间件
    app.use(async (ctx, next) => {
      console.log('请求开始');
      await next(); // 执行下一个中间件
      console.log('请求结束'); // 响应时执行
    });
    
    app.use(koaBody());
    router.get('/api/user', async (ctx) => {
      ctx.body = { name: '张三', age: 20 };
    });
    
    app.use(router.routes());
    app.listen(3000, () => console.log('Koa 启动在 3000 端口'));
    
  • 优点:异步体验好、洋葱模型灵活(适合日志 / 鉴权 / 异常捕获)、轻量;

  • 缺点:生态比 Express 略少、需手动集成更多第三方库;

  • 适用场景:中小型 API 服务、需要灵活中间件的场景、嫌弃 Express 回调的项目。

3. NestJS(企业级 TypeScript 框架)

  • 核心定位:对标 Spring Boot,基于 TypeScript,强调模块化、依赖注入,适合大型团队协作。

  • 核心特性

    • 强制 TypeScript 开发,类型安全;
    • 模块化架构(Module + Controller + Service);
    • 内置依赖注入、拦截器、管道、守卫(鉴权)、过滤器;
    • 兼容 Express/Koa,可无缝集成第三方库;
    • 支持微服务、GraphQL、WebSocket。
  • 示例代码

    // user.controller.ts
    import { Controller, Get } from '@nestjs/common';
    import { UserService } from './user.service';
    
    @Controller('api/user')
    export class UserController {
      constructor(private readonly userService: UserService) {}
    
      @Get()
      getUser() {
        return this.userService.getUser();
      }
    }
    
    // user.service.ts
    import { Injectable } from '@nestjs/common';
    
    @Injectable()
    export class UserService {
      getUser() {
        return { name: '张三', age: 20 };
      }
    }
    
  • 优点:规范统一、类型安全、适合大型项目 / 团队、生态完善(官方封装了大量企业级能力);

  • 缺点:学习成本高、入门门槛高、小型项目用着 “重”;

  • 适用场景:大型企业应用、微服务、团队协作项目、需要强类型的项目。

4. Fastify(高性能极简框架)

  • 核心定位:极致性能,比 Express 快 2-3 倍,专为高并发 API 设计。

  • 核心特性

    • 基于 JSON Schema 验证请求参数,性能优于传统验证库;
    • 内置日志、压缩、路由缓存,无需额外配置;
    • 兼容 Express 中间件;
    • 支持 TypeScript。
  • 示例代码

    const fastify = require('fastify')({ logger: true });
    
    // 路由 + 参数验证
    fastify.get('/api/user', {
      schema: {
        querystring: {
          age: { type: 'number' }
        }
      }
    }, async (request, reply) => {
      return { name: '张三', age: request.query.age || 20 };
    });
    
    fastify.listen({ port: 3000 }, (err) => {
      if (err) throw err;
      console.log('Fastify 启动在 3000 端口');
    });
    
  • 优点:性能极高、内置功能丰富(无需装大量中间件)、轻量;

  • 缺点:生态比 Express 小、部分特性(如 Schema 验证)有学习成本;

  • 适用场景:高并发 API、微服务、对性能要求高的项目。

5. Hapi(稳定的企业级框架)

“还记得当初在 沃尔玛 买了虾 , 自己回家自己做 鸡油炒河虾仁 , 艾玛 , 老香了!!! ”

  • 核心定位:由 Walmart ( 沃尔玛 ) 开发,强调配置优于编码,适合稳定的企业级服务。

  • 核心特性

    • 内置路由、验证、缓存、日志,无需第三方库;
    • 插件化架构,扩展能力强;
    • 稳定性极高(适合金融 / 电商等核心系统)。
  • 优点:稳定、内置功能全、安全性高;

  • 缺点:学习成本高、灵活性低、性能不如 Fastify;

  • 适用场景:金融 / 电商等核心系统、对稳定性要求极高的项目。

6. Next.js(前端 SSR/SSG 框架,Node 端核心)

  • 核心定位:React 生态的全栈框架,Node 端负责服务端渲染(SSR)、静态生成(SSG)。

  • 核心特性

    • 服务端渲染(提升 SEO、首屏加载速度);
    • 自动路由(基于文件系统);
    • 内置 API 路由(无需额外搭后端);
    • 支持静态生成、增量静态再生。
  • 适用场景:React 前端项目、需要 SEO 的网站(如博客、电商)、全栈 React 应用。

7. Sails.js(低代码 / 快速开发框架)

  • 核心定位:对标 Ruby on Rails,内置 ORM、蓝图 API、实时通信,适合快速开发全栈应用。

  • 核心特性

    • 自动生成 CRUD API(蓝图路由);
    • 内置 Waterline ORM(支持多数据库);
    • 支持 WebSocket 实时通信;
  • 优点:开发速度极快、低代码;

  • 缺点:灵活性低、定制化成本高;

  • 适用场景:快速原型开发、低代码平台、小型全栈应用。

8. AdonisJS(Node.js 版的 Laravel,全栈企业级框架)

  • 核心定位:对标 PHP 界的 Laravel,是 Node.js 生态中 “开箱即用” 的全栈框架,内置全套企业级能力,强调 “约定优于配置”。

  • 核心特性

    • 内置 ORM(Lucid ORM):支持 MySQL、PostgreSQL 等,无需手动集成第三方 ORM;
    • 内置身份验证(用户注册 / 登录 / 权限)、表单验证、CSRF 保护;
    • 支持 MVC 架构、路由分组、中间件、任务调度;
    • 原生支持 TypeScript,类型安全;
    • 内置模板引擎(Edge),也支持前后端分离;
  • 示例代码(核心路由 + ORM)

    // start/routes.ts
    import Route from '@ioc:Adonis/Core/Route'
    import User from 'App/Models/User'
    
    // 路由 + 数据库查询
    Route.get('/api/user', async () => {
      const user = await User.find(1) // Lucid ORM 查用户
      return { name: user?.name, age: user?.age }
    })
    
    // 表单验证
    Route.post('/api/user', async ({ request }) => {
      const data = request.validate({
        schema: {
          name: schema.string(),
          age: schema.number()
        }
      })
      await User.create(data) // 新增用户
      return { success: true }
    })
    
  • 优点:开箱即用(无需装大量依赖)、Laravel 开发者易上手、规范统一、内置安全特性;

  • 缺点:生态比 Express/NestJS 小、灵活性略低、国内使用较少(中文文档有限);

  • 适用场景:全栈 Node.js 应用、Laravel 转 Node 开发的团队、中小型企业应用、需要快速搭建带数据库的业务系统。

9. Nuxt.js(Vue 生态全栈框架,Node 端负责 SSR/SSG)

  • 核心定位:Vue 生态的官方全栈框架,基于 Vue + Node.js 实现服务端渲染(SSR)、静态站点生成(SSG),解决 Vue 单页应用 SEO 差的问题。

  • 核心特性

    • 自动路由(基于 pages 目录,无需手动配置路由);
    • 服务端渲染(SSR)、静态生成(SSG)、增量静态再生(ISR);
    • 内置 API 路由(server/api 目录,无需额外搭后端服务);
    • 支持 Vue3 + TypeScript、自动代码分割、缓存优化;
    • 集成 Pinia(状态管理)、Nuxt Modules(生态插件);
  • 示例代码(核心用法)

    <!-- pages/api/user.vue (页面路由) -->
    <template>
      <div>{{ user.name }}</div>
    </template>
    
    <script setup lang="ts">
    // 服务端获取数据(SSR)
    const { data: user } = await useAsyncData('user', () => 
      $fetch('/api/user') // 调用内置 API 路由
    )
    </script>
    
    // server/api/user.ts (内置 API 路由)
    export default defineEventHandler(() => {
      return { name: '张三', age: 20 }
    })
    
  • 优点:Vue 开发者无缝上手、解决 SEO 问题、全栈一体化(前端 + Node 端)、生态完善;

  • 缺点:仅适配 Vue 技术栈、Node 端逻辑定制化能力有限、大型项目需深入理解其生命周期;

  • 适用场景:Vue 全栈应用、需要 SEO 的网站(博客 / 电商 / 官网)、静态站点生成、中小型 Vue 项目。

10. Egg.js(阿里开源,企业级 Node.js 框架)

  • 核心定位:阿里开源的企业级框架,基于 Express/Koa 封装,强调 “约定优于配置”,适合中大型团队协作。

  • 核心特性

    • 基于 Koa2(洋葱模型),兼容 Koa/Express 中间件;
    • 内置多进程模型(Master + Worker),自动利用多核 CPU;
    • 插件化架构(如 egg-mysql、egg-redis),生态丰富(阿里官方维护);
    • 支持 TypeScript、单元测试、日志、监控;
    • 规范的目录结构(controller/service/middleware/config),团队协作友好;
  • 示例代码

    // app/controller/user.js
    const { Controller } = require('egg');
    
    class UserController extends Controller {
      async index() {
        const { ctx } = this;
        // 调用 service 层
        const user = await ctx.service.user.getUser();
        ctx.body = user;
      }
    }
    
    module.exports = UserController;
    
    // app/service/user.js
    const { Service } = require('egg');
    
    class UserService extends Service {
      async getUser() {
        // 用 egg-mysql 查数据库
        return await this.app.mysql.get('user', { id: 1 });
      }
    }
    
    module.exports = UserService;
    
  • 优点:阿里背书、规范统一、多进程性能优、国内生态完善(中文文档 / 社区)、适合团队协作;

  • 缺点:灵活性低于 Express/Koa、学习成本中等、小型项目用着 “重”;

  • 适用场景:中大型企业应用、阿里系技术栈项目、国内团队协作项目、需要多进程优化的 Node 服务。

三、 对比

框架 学习成本 性能 生态 类型支持 适用规模 核心优势 技术栈 / 定位
Express 中等 极丰富 需手动配 小 / 中 灵活、生态全、入门快 极简核心框架
Koa 中等 丰富 需手动配 小 / 中 洋葱模型、async/await 极简核心框架(Express 升级版)
NestJS 中等 丰富 原生 TS 中 / 大 规范、企业级、团队协作 企业级 TS 框架
Fastify 极高 中等 原生 TS 小 / 中 / 大 极致性能、内置功能全 高性能极简框架
Hapi 中高 中等 需手动配 中 / 大 稳定、安全、企业级 企业级配置优先框架
Next.js 中等 极丰富 原生 TS 小 / 中 / 大 React SSR、全栈一体化 React 全栈框架
Sails.js 中等 中等 需手动配 低代码、开发速度快 低代码全栈框架
AdonisJS 中等 中等 原生 TS 小 / 中 Laravel 风格、开箱即用 全栈企业级框架(Node 版 Laravel)
Nuxt.js 中等 极丰富 原生 TS 小 / 中 / 大 Vue SSR、全栈一体化、SEO 优 Vue 全栈框架
Egg.js 中高 丰富 需手动配 中 / 大 阿里背书、多进程、国内生态好 企业级框架(基于 Koa)

四、选型建议

  1. 个人 / 小型项目、快速开发:选 Express(生态全)或 Koa(异步体验好);
  2. 高并发 API、微服务:选 Fastify(性能第一);
  3. 大型企业应用、团队协作:选 NestJS(TS + 规范)或 Hapi(稳定);
  4. React 全栈、需要 SEO:选 Next.js;
  5. 低代码、快速原型:选 Sails.js。

总结

  1. 核心维度:选型优先看「项目规模 + 团队技术栈 + 性能要求」,小型项目别用重框架(如 NestJS),大型项目别用太灵活的框架(如 Express);
  2. 生态优先级:如果需要集成大量第三方库,Express / Koa / Next.js 是首选;
  3. 性能优先级:高并发场景直接选 Fastify;
  4. 团队协作:大型团队优先 NestJS(强规范),避免 Express 因灵活导致的代码混乱。

okokok , 这个文章到这里就结束了 , 我们有缘再会 😁😁😁 !!!

Cursor 500MB 太重?试试这个 5MB 的 CLI 方案

作者 echoVic
2026年2月7日 11:55

Cursor 500MB 太重?试试这个 5MB 的 CLI 方案

为什么我放弃了 Cursor

上个月团队让我试用 Cursor。下载完 500MB 安装包后,我开始怀疑人生。

启动要 10 秒,打开大项目要 30 秒,内存占用 2GB+。我只是想让 AI 帮我写个脚本,为什么要装个这么重的 IDE?

后来发现了 Blade Code。

Blade Code 是什么

一个 5MB 的 Node.js CLI 工具,专门做一件事:让 AI 快速完成编程任务。

不是 IDE,不是编辑器,就是个命令行工具。

对比数据

维度 Cursor Blade Code
安装包大小 500MB 5MB (npm 包)
启动速度 10秒 1秒
内存占用 2GB+ 50MB
适用场景 完整开发环境 快速任务、脚本、自动化
学习成本 需要适应新 IDE 会用命令行就行
价格 $20/月 MIT 开源

真实场景

场景 1:快速重构代码

blade "把这个文件的所有 var 改成 let/const"

3 秒完成,不用打开 IDE。

场景 2:批量处理文件

blade "把 src/ 下所有 .js 文件加上 'use strict'"

20 个文件,5 秒搞定。

场景 3:生成测试用例

blade "给 utils.ts 生成单元测试"

自动分析代码,生成完整测试文件。

为什么这么快

  1. 无 GUI 开销 - 纯命令行,没有渲染负担
  2. 按需加载 - 只加载需要的工具
  3. 流式响应 - 边生成边输出,不等全部完成
  4. 轻量设计 - 核心只有几 MB

20+ 内置工具

Blade Code 不只是个 AI 对话工具,它内置了 20+ 实用工具:

  • 文件操作:读、写、搜索、批量处理
  • 代码分析:AST 解析、依赖分析
  • Shell 执行:安全的命令执行
  • Git 集成:提交、分支、历史查询
  • Web 搜索:实时查询最新信息

安全设计

很多人担心 AI 工具会误删代码。Blade Code 有三层保护:

  1. 权限控制 - 危险操作需要确认
  2. 工具白名单 - 只能用预定义的工具
  3. 操作日志 - 所有操作可追溯

5 分钟上手

安装

npm install -g blade-code

配置 API Key

blade config

支持 OpenAI、Claude、Gemini、国产大模型。

开始使用

blade "帮我重构这个函数"

就这么简单。

适合谁用

适合:

  • 需要快速完成小任务的开发者
  • 喜欢命令行的极客
  • 想要轻量级 AI 工具的人
  • 需要脚本化 AI 能力的场景

不适合:

  • 需要完整 IDE 功能的人
  • 不习惯命令行的人
  • 需要图形界面的场景

和其他工具对比

vs Cursor

  • Cursor:完整 IDE,适合长时间开发
  • Blade Code:快速任务,适合脚本化场景

vs GitHub Copilot

  • Copilot:代码补全,需要在编辑器里用
  • Blade Code:独立工具,可以批量处理

vs OpenCode

  • OpenCode:95K stars,功能全面但复杂
  • Blade Code:专注 CLI,简单直接

开源 + 可扩展

Blade Code 是 MIT 开源的,代码在 GitHub: github.com/echoVic/bla…

支持 MCP (Model Context Protocol),可以自己写插件扩展功能。

总结

如果你觉得 Cursor 太重,需要快速完成小任务,喜欢命令行,想要免费的 AI 编程工具,试试 Blade Code。

5MB,1 秒启动,MIT 开源。

项目地址:github.com/echoVic/bla…

全栈进阶-redis入门实战概念篇

2026年2月5日 20:00

第一阶段:redis基础

1. 简介

Redis是一款开源的、基于内存的键值对数据库,支持将内存持久化到磁盘,还提供了丰富的数据结构、事务、发布订阅等功能,被广泛的用于缓存、消息队列、会话存储等场景。

作为一个前端开发,对于Redis第一影响就是读写操作非常的快,常用于一些需要快速读写数据的场景,比如存储会话sessionRedis之所以这么快,在于Redis利用了内存操作、IO多路复用、避免线程切换开销三大核心优势,让单线程也足以支撑超高并发。

Redis并非纯单线程,只是在接受客户请求的核心处理流程是单线程的,在处理慢操作,比如持久化读写磁盘、异步删除大键、主从复制的网络同步,会启动多个辅助线程。为啥当初Redis不是设计成多线程呢,主要是单线程设计简单,核心逻辑都是串行执行的,后续的维护成本极低,同时也避免了多线程的死锁啊,数据一致性啊这些麻烦的问题;内存的操作足够快,多线程必然会涉及到线程切换和锁竞争,这些都会降低效率;IO的多路复用,Redis运行在网络层,使用的基于Unix系统的IO多路复用机制,就是主线程通过事件循环来监听所有客户端的IO操作,维护一个事件队列去处理IO操作,这种单线程非阻塞的IO多路复用让Redis可以同时管理上万的TCP连接。

2.redis数据基本结构

Redis基本数据结构主要五个,接下来挨个介绍下

首先安装下环境:

pip install redis

先创建一个虚拟环境,然后安装相应的依赖

import redis

# 连接到你的线上 Redis
r = redis.Redis.from_url(
    "redis://:xxx",
    decode_responses=True,  # 返回 str 而不是 bytes
)

# 设置一个 study:string 的 key
r.set("study:string", "hello redis from python")

# 读出来验证一下
value = r.get("study:string")
print("study:string =", value)
2.1 String

StringRedis最基础、最常用的数据结构,所有的键值对的value本质上都可以使用String来存储,单key最大容量512M,所有的操作都是原子性的,支持位运算和过期策略。

比如前面的案例r.set("study:string", "hello redis from python"),就是设置一个字符串

如果要设置一个过期时间的话,也比较简单

r.set("study:string", "hello redis from python", ex=60),这里的ex就是秒数,如果是px就是毫秒数,这里设置的时间就表明key的过期时间,如果过期了key就会被删除。

2.1 Hash

Hash是一个键对应多个键值对的结构,类似于Map和字典,一般用来存储结构化的对象。

r.hset("study:hash", mapping={
    "name": "张三",
    "age": "20",
})

data = r.hgetall("study:hash")
print(data)  # {'name': '张三', 'age': '20'}

如果要删除hash中的指定字段的话,可以使用这个方法hdel

r.hdel("study:hash", "age")
2.3 List

这里的List是按照插入顺序排序的字符串集合,支持两端搞笑增删,中间查询稍慢,是一个双向链表。

# 从右侧依次塞入几个元素
r.rpush("study:list", "apple", "banana", "orange")

# 从左侧再塞一个
r.lpush("study:list", "pear")   # list 现在是: ["pear", "apple", "banana", "orange"]

# 读取整个 list(0 到 -1 表示所有元素)
items = r.lrange("study:list", 0, -1)
print("study:list =", items)

# 弹出一个元素(比如从左边弹)
left = r.lpop("study:list")
print("lpop 之后取出的 =", left)
print("剩下的 =", r.lrange("study:list", 0, -1))

可以用来存储一些任务队列和消息队列

2.4 Set

Set 是无序、元素唯一的字符串集合,支持集合间的交、并、差运算,适合处理 “去重” 和 “关系匹配” 场景。

# 往 set 里加元素(去重)
r.sadd("study:set", "apple", "banana", "orange")
r.sadd("study:set", "banana")  # 再加一次不会重复

# 查看所有成员
members = r.smembers("study:set")
print("study:set =", members)

# 判断某个值是否在 set 中
print("是否包含 apple:", r.sismember("study:set", "apple"))

# 删除一个成员
r.srem("study:set", "banana")
print("删除 banana 后 =", r.smembers("study:set"))

# 给整个 set 设置过期时间 60 秒
r.expire("study:set", 60)
2.5 ZSet

ZSet 是 Set 的升级版,每个元素关联一个 “分数(score)”,Redis 会按分数从小到大排序,兼具 唯一性 和 有序性。

# 往 zset 里加数据:成员 + 分数
r.zadd("study:zset", {
    "Alice": 100,
    "Bob": 80,
    "Charlie": 95,
})

# 按分数从小到大取出所有成员
print("从小到大:", r.zrange("study:zset", 0, -1, withscores=True))

# 按分数从大到小取出前 2 名
print("从大到小前2名:", r.zrevrange("study:zset", 0, 1, withscores=True))

# 给某个人加分(比如 Alice +10)
r.zincrby("study:zset", 10, "Alice")
print("Alice 加分后:", r.zrevrange("study:zset", 0, -1, withscores=True))

# 删除一个成员
r.zrem("study:zset", "Bob")
print("删除 Bob 后:", r.zrange("study:zset", 0, -1, withscores=True))

有一个常见的面试题,HashString都可以用来存储对象,一般用那个来存储对象呢,使用String来存储对象,简单直观,但是它不支持局部更新,改一个字段需要覆盖这个字符串,适合一些整体读写、字段少的场景;Hash存储对象,他就支持局部更新,适合一些复杂对象的存储,比如高频更新字段。

3. redis基本命令

因为Redis都是键值对的存储,所以他的方法也很简单,看下面这个例子:

# 1. SET:设置一个字符串 key
r.set("study:string", "hello")

# 2. GET:读取这个 key
print("GET study:string =", r.get("study:string"))  # hello

# 3. INCR:自增一个数值型 key
# 如果这个 key 不存在,会从 0 开始加 1,变成 "1"
r.delete("study:count")  # 为了方便测试,先删掉
r.incr("study:count")    # 当前值 1
r.incr("study:count")    # 当前值 2
r.incr("study:count", 5) # 加 5 -> 当前值 7
print("study:count =", r.get("study:count"))  # 7

# 4. EXPIRE:给 key 设置过期时间(单位:秒)
r.expire("study:count", 5)  # 5 秒后过期

读、写、自增和设置过期时间,都比较简单。

因为Redis都是键值对,没有表的概念,所以Key管理就成了问题,社区有一个约定的规范:业务标识:模块名称:唯一标识[:子字段],比如ecom:user:1001:name,这就是电商业务:用户模块:用户id:用户名。还有一些额外的补充规范:

  1. 统一小写:避免大小写混乱,User:1user:1是两个key
  2. 简洁且语义化:看到名称基本就能了解存储的内容
  3. 避免特殊字符:比如空格、换行符、下划线

第二阶段:redis核心机制

4.redis内存模型

Redis是一个内存数据库,大多数数据都保存才内存中。它的内存可以分为两大部分,核心内存和辅助内存。其中核心内存存储的就是我们常用的键值对,也就是key内存和value内存,存储的都是我们所用到的数据;辅助内存放的都是非业务数据,就是Redis运行所需的额外内存,比如一些过期字典、进程本身的开销等。

Redis有一套完善的内存管理机制,主要有这么几步

  1. 基于jemalloc内存分配,将内存划分为不同大小的内存页,比如8B,16B,32B等,分配时匹配最接近的页,减少碎片;线程缓存,减少锁竞争,提升分配效率
  2. 内存回收:内存回收主要有两种,惰性删除和定期删除。惰性删除指的是访问key时检查是否过期,过期了就删除;定期删除,每100ms随机抽查过期的key,去删除已经过期的,但是这里有个问题,如果key过期了,但是没有被抽查到呢,为啥不扫描全量的key呢,这就是一个平衡了,全量扫描需要占用大量的CPU,会影响到业务的,这个就叫做延迟回收,也就是说可能一时半会回收不了,但是终归会被回收。

Redis的内存处理机制天然就有一种滞后性,可能就会出现内存满了的情况,这里的内存满了,并不是设置某个key的value大小超过512M,而是Redis进程占用的内存满了,这里的满有两个意思:主动设置的maxmemory,这个在生产环境上是必须要设置的

maxmemory 4GB  # 限制 Redis 最大使用内存为 4GB

还有一种满就是,如果不设置这个最大值,Redis就会无限制的占用服务器的物理内存,直到耗尽服务器所有可用的物理内存,这个时候操作系统会将Redis的内存数据交换到磁盘的swap分区,这个是磁盘模拟的内存,速度巨慢,最终导致Redis性能暴跌,也可能因为服务器内存耗尽而被系统杀死。

当内存满了后,Redis也有一套内存淘汰策略来处理这种情况,当Redis占用的内存超过设置的maxmemory后,然后再去执行写操作,就会去触发我们的内存淘汰策略,主要有这么几种策略:

  1. LRU

    最近最少使用,就是淘汰那些最近访问次数最少的key,标准的LRU需要维护一个访问时间链表,内存和cpu开销大,Redis实现的是近似LRU:维护一个候选池,触发淘汰时,从目标范围随机抽取key,也就是触发淘汰时,随机抽取一批key,然后比一比谁的访问时间最远,然后就淘汰它。

  2. LFU

    优先淘汰访问频率最低的key,Redis实现的LFU并不是简单的访问次数统计,而是通过概率递增的访问计数+时间衰减机制来近似的反应key的长期访问价值;在触发淘汰时,在通过随机采样的方式选择访问评率最低的key去淘汰。

当内存满了后,再去对数据库做读写操作,读的操作没有影响,但是在触发写的操作时,如果内存满了,会先根据maxmemory-policy设置的内存淘汰策略,在写操作触发的同时根据LFU、LRU去更新内存,直到内存会到安全区;如果内存淘汰策略味为noeviction或者无法淘汰,直接回报错。

5.持久化机制

前面也提到了,Redis是一种基于内存的键值数据库,内存的特性就是在服务器重启后会全部丢失,这就需要将数据做持久化,即使服务器重启了,也可以从磁盘中恢复数据至内存。

Redis提供了两种核心的持久化方式:RDB和AOF,快照持久化和追加文件持久化,接下来挨个介绍下:

快照持久化

RDB是定时对Redis内存中的全量数据做一次拍照,生成一个压缩的二进制文件,比如dump.rdb,保存到磁盘的指定目录。Redis重启时直接加载这个二进制文件,将数据恢复到内存中。

RDB有手动触发和自动触发两种方式:手动触发可以使用save来同步触发,同步触发会阻塞主进程,直到RDB文件生成完成,异步触发通过bgsave来触发,Redis会fork一个子进程来执行RDB文件的生成,主进程会继续处理客户端的请求;自动触发是在配置文件redis.conf中通过快照规则来配置的,满足条件就会自动执行bgsave

save 900 1      # 900 秒内至少 1 次写
save 300 10     # 300 秒内至少 10 次写
save 60 10000   # 60 秒内至少 10000 次写

这里就是自动触发RDB的规则:满足其中的任意条件就会触发一次,比如60s内写一次、300s内写10次等。

RDB优点就在于性能开销小,生成RDB由子进程负责,主进程仅做fork操作,几乎不影响业务;二进制文件直接加载到内存速度也很快。但是缺点也很明显,RDB是定时快照,如果Redis意外崩溃,比如服务器断电,就会丢掉最后一次快照前到崩溃前的所有数据。

追加日志持久化

AOF就是为了解决RDB数据库丢失而设计的持久化方式,就是将Redis的操作日志按照顺序记录下来,重启后通过重放AOF文件中所有的写命令去恢复内存数据。默认是关闭的,需要appendonly yes命令来手动重启。

AOF的相关配置在redis.conf文件中

appendonly yes # 开启AOF(默认no,关闭)
appendfilename "appendonly.aof" # AOF文件名,默认保存在Redis工作目录
dir ./ # 持久化文件(RDB/AOF)的保存目录,默认是Redis启动目录

AOF主要分为三个步骤:

  1. 命令追加

    Redis执行完一个写命令后,会将该命令按照协议追加到内存中的AOF缓冲区,避免直接写入磁盘,减少IO开销

  2. 文件写入

    Redis会定期将AOF缓冲区的数据写入到内核页缓存,这个操作是调用操作系统的write方法,属于异步操作,不会阻塞主线程。

  3. 文件同步

    将内核页缓存中的AOF数据写到测盘中,这个是调用的操作系统的同步方法,会阻塞主线程的,直到刷盘完成。

    将AOF缓冲区中的命令刷到磁盘的AOF文件中,有三种策略:

    # appendfsync 有三个取值:
    appendfsync always  # 每次写命令都立即刷盘(同步),数据最安全,性能最差
    appendfsync everysec# 每秒刷盘一次(默认值),平衡数据安全和性能
    appendfsync no      # 由操作系统决定何时刷盘,性能最好,数据丢失风险最高
    

由于AOF是日志追加的形式,会产生大量的中间态,比如set key 1set key 2set key 3 ,这种中间态其实是没有意义的,还会导致AOF文件变得很大,这就需要AOF重写机制了,重写就是遍历内存中的所有的数据,根据当前的键值对生成一套最简的写命令集来替换原有的AOF文件,重写的触发也分为手动触发和自动触发:手动触发需要执行bgrewriteaof命令;自动触发是通过配置文件,当文件的体积增长到达阙值时,自动触发`bgrewriteaof

auto-aof-rewrite-min-size 64mb  # AOF文件的最小体积,低于这个值不触发重写(默认64mb)
auto-aof-rewrite-percentage 100 # 重写触发的百分比,指当前AOF文件体积比上一次重写后的体积增长了多少(默认100%)

AOF的优点就是,可以通过刷盘策略来控制数据丢失的风险,默认的everysec仅丢失1s的数据,alway几乎无丢失;缺点就是AOF文件体积较大,恢复数据时加载较慢。

混合持久化

RDB和AOF单独使用都各有优缺点,在Redis 4.0之后,引入了混合持久化机制,融合恶RDB和AOF的优点,成为了目前生产环境的首选方案。

redis.conf配置文件中开启混合持久化:

aof-use-rdb-preamble yes  # 开启混合持久化(Redis 4.0+,默认no;Redis 6.0+ 部分版本默认yes)

开启后,AOF文件就不再是纯文本了,头部就成了RDB格式的全量数据快照,也就是二进制文件,尾部是AOF格式写的增量命令,记录从生成RDB快照到当前的所有写命令,是纯文本。

其工作流程主要有这么几个步骤:

当AOF触发重写时,

  1. redis主进程进入fork子进程,执行AOF重写
  2. 子进程首先将内存中的全量数据以RDB格式写入到临时的AOF文件头部
  3. 子进程完成RDB写入后,主进程将AOF重写缓冲区中所有的增量写命令,以AOF格式写入到临时的AOF文件尾部
  4. 主进程用临时AOF文件替换掉旧的AOF文件,完成混合持久化的重写。

混合持久化的优点就在于加载速度快,数据丢失风险小,而且文件的体积也不会很大。

下面推荐一个常见的生产环境的配置,开启混合持久化+RBD默认自动快照:

# ===================== RDB 核心配置 =====================
save 900 1
save 300 10
save 60 10000
rdbcompression yes  # 开启RDB压缩
dbfilename dump.rdb # RDB文件名
dir ./              # 持久化文件存储目录(建议修改为独立的磁盘目录)

# ===================== AOF 核心配置 =====================
appendonly yes      # 开启AOF(混合持久化的前提)
appendfilename "appendonly.aof" # AOF文件名
appendfsync everysec # 刷盘策略,生产首选
auto-aof-rewrite-min-size 64mb # AOF重写最小体积
auto-aof-rewrite-percentage 100 # AOF重写增长百分比
aof-use-rdb-preamble yes # 开启混合持久化(Redis 4.0+)
aof-load-truncated yes # 加载AOF时,若尾部损坏则忽略,继续加载(默认yes)

6. redis事务

Redis事务就是提供一种机制,将多个Redis命令打包成一个执行单元,保证这个单元内的命令会按照顺序、无中断的执行,同时支持对命令执行结果的统一处理,解决多命令批量执行的原子性需求。

Redis事务只依赖五个命令:

  1. MULTI 标记事务开始,后续所有的命令都会加入到事务队列中
  2. EXEC 执行事务队列中的所有命令,执行完成后结束事务,返回所有命令的执行结果
  3. DISCARD 放弃事务队列中的所有命令,清空队列结束事务,回到正常的执行模式
  4. WATCH KEY 对key加乐观锁,监控key是否修改,必须在MULTI之前修改
  5. UNWATCH 取消所有被watch监控的key,事务取消或者执行后会自动执行

看下这个最基础的实务流程:

MULTI
SET balance 100
INCR balance
EXEC

执行到MULTI时,会进入事务状态,后续的SETINCR会被放入到一个事务队列中,直行到EXEC时才会执行队列中的所有的命令。

传统的关系型数据库事务严格遵循ACID原则,原子性、一致性、隔离性和持久性,但是Redis事务为了极致的性能,并不是完全遵循ACID原则。接下来介绍下他的区别:

  1. 原子性

    原子性的定义就是事务中的所有的操作,要么全部执行,要么全部不执行,不会出现部分执行的情况,而Redis事务的原子性分为两种情况:

    1) 事务入队前出错,全不执行:当在MULTI后,EXEC前出现语法错误,Redis会立即返回错误,执行EXEC时会直接放弃整个事务

    2) 执行事务中出现错误,部分执行,没有回滚,命令入队时只会做语法检查,不会做逻辑检查,执行时如果出现了运行错误,Redis就会跳过这个命令,继续执行后续的命令,而且不会对已经执行的命令做回滚

    不支持回滚主要也是从性能考量,实现回滚需要记录每个命令的逆操作,比如SET的操作就是恢复原值,这个会增加Redis内核的复杂度,牺牲执行的性能。

  2. 一致性

    一致性就是事务执行的前后,数据库的状态始终保持合法,不会因为事务的执行而出现脏数据。Redis事务可以在所有的异常情况下,比如入队错误、执行错误、宕机,都可以保证数据的一致性。

  3. 隔离性

    隔离就是在多个事务并发执行时,一个事务的执行不会被其他的事务干扰,各个事务之间相互隔离。Redis是单线程处理客户端请求的,这就会导致事务的执行会按照队列中的顺序连续执行,不会被其他的命令打断

  4. 持久性

    持久性是指事务执行成功后,对数据的修改会被永久的保存到磁盘中,不会应为宕机而丢失。Redis事务本身并不保证持久性,持久性是由Redis的持久化机制来实现的,前面也介绍过

接下来写一个小的demo,利用watch来控制库存防止超卖

def try_purchase(stock_key: str, user: str, qty: int = 1) -> bool:
    """使用 WATCH + 事务进行扣库存,避免超卖。

    乐观锁思路:
    1. WATCH 库存 key,监听是否被别人改动;
    2. 读当前库存,判断是否足够;
    3. 使用 MULTI 开启事务,扣减库存;
    4. EXEC 提交,如果在这期间库存被别人改了,EXEC 会失败(抛 WatchError),然后重试。
    """

    with r.pipeline() as pipe:
        while True:
            try:
                # 1. 监听库存 key
                pipe.watch(stock_key)

                # 2. 读取当前库存
                current = pipe.get(stock_key)
                if current is None:
                    print(f"{user}: 商品不存在")
                    pipe.unwatch()
                    return False

                current = int(current)
                if current < qty:
                    print(f"{user}: 库存不足,当前库存={current}")
                    pipe.unwatch()
                    return False

                # 3. 开启事务,扣减库存
                pipe.multi()
                pipe.decrby(stock_key, qty)

                # 4. 提交事务
                pipe.execute()
                print(f"{user}: 抢购成功,扣减 {qty},扣减前库存={current}")
                return True

            except redis.WatchError:
                # 在 WATCH 之后、EXEC 之前,有其他客户端修改了 stock_key,
                # 这次事务会失败,需要重试。
                print(f"{user}: 检测到并发冲突,重试中...")
                continue

第三阶段:高并发&分布式

7. 缓存模式与一致性

Redis作为缓存的核心亮点就在于其高速的读写操作,来降低传统数据库的压力,基于此Redis推出了有大概四种主流的缓存策略,来将缓存融入业务读写流程,同时保证缓存与数据库的一致性。接下来挨个介绍下:

  1. 缓存穿透模式

    缓存和数据库分离,业务代码主动管理缓存和数据库的交互。在读操作时,先查询缓存,如果命中就直接返回,如果没有就查询数据库,同时将数据库的结果写入缓存;在写操作时,先更新数据库,在删除缓存。

    这种模式适合绝大多数的生产场景,是Redis作为缓存的首选模式。优点就是简单易实现,缺点就是需要额外处理缓存穿透、击穿雪崩等场景。

  2. 读写穿透

    业务代码只和缓存交互,不直接操作数据库,缓存作为中间层,主动管理数据库的读写。在读操作时,先查询缓存,如果命中就直接返回,未命中就查询数据库,将结果写入缓存,然后返回;写操作就更新缓存,然后再去更新数据库。

    这种模式的特点就是业务代码只专注于业务,数据库由缓存层来处理,简化了业务代码逻辑,缺点就是缓存层需要额外的代码开发,而且不支持新增数据,因为新增数据要先执行读操作,才能存入缓存,不太符合常规的业务逻辑。

  3. 写穿模式

    这种模式是读写穿模式的增强版,支持新增、更新数据。在读操作时,和读写穿透模式一样,命中返回,未命中查库更新缓存;写操作时和新增数据时,缓存同步更新数据库,然后返回给业务。

    这种模式的特点在于写操作时,缓存和数据库同步更新,缓存和数据库有着非常强的一致性,常用于支付业务的核心数据缓存。

  4. 写回模式

    这种模式是写穿模式的异步版,差异就在与写操作时是异步的。在读操作时,和读写穿透、写穿模式一样,命中就返回,未命中查库更新缓存后返回;在新增和更新的写操作时,缓存立即返回,然后异步去更新数据。

    这种模式适合并发高、一致性要求较低的场景,比如日志缓存等。

生产模式中比较常用和推荐的,就是缓存穿透模式,然后这种模式在一致性的问题上需要额外处理。比如有这么几个场景:

  1. 在写操作时,正常的流程是,更新数据库,然后删除缓存,更新缓存需要下次读的时候去查库更新。但是如果更新数据库后,遇到宕机或者网络异常,就会导致缓存未及时删除,这就出现了脏数据,知道缓存过期。这也是最常见的不一致场景,属于操作中断导致的缓存未更新。
  2. 在并发的场景下,客户端A和客户端B同时分别执行读写操作,A读操作时,缓存未命中就会去查库,B写操作时更新完数据库后,回去删除缓存,这时如果读操作的写缓存的动作晚于写操作的删除动作,就会产生数据库与缓存的不一致场景,数据库是新的数据,缓存中是旧的脏数据。

在数据一致性上有这样一个原则,最终数据一致性即可,而非强制一致性。Redis作为缓存,是没有办法实现和数据库的强一致性。因为缓存和数据库属于两个独立的存储系统,非要强一致性就需要加锁,这就会牺牲Redis的高性能,而且在实际业务中,带短暂的不一致,对于用户来说并没有感知的。

在缓存穿透模式中,有这么几个方案可以解决缓存与数据库的一致性问题

  1. 单实例低并发场景,在一些后台管理,小流量业务汇中,可以直接使用缓存穿透模式的基础逻辑,即读操作时先缓存后数据库,如果不存在就写一个缓存空值,加上过期时间;在写操作时,先更新数据库,在删除缓存,给所有的缓存加上过期时间。

    这里设置缓存空值,就是为了防止缓存杀手-穿透,比如查询一个数据库没有的值,这就会直接访问数据库,万一遇到恶意访问的脚本,就会导致数据库压力;而设置一个空值,在过期时间内,他是一个有效的缓存,虽然没有值,但减轻了数据库的压力,算是为了系统的稳定性做了一次兜底。

  2. 单实例高并发,在电商的商品详情、商品秒杀库存业务中,在基础方案上增加延迟删除缓存,来解决读操作写缓存晚于写操作删缓存的问题。 流程就是在写操作执行更新数据库后,延迟N毫秒删除缓存,让读操作查库、写缓存的动作先完成。

还有一个常见的八股文:缓存的三大杀手,穿透、击穿和雪崩。

  1. 穿透,在前面的穿透模式时介绍过了,就是查询一些数据库中不存在的值时,每次都会去查询数据库,导致缓存失效,数据库增加额外的压力。解决方案有下面几种:

    1)设置空值,前面也介绍过,这是最简通用的方案

    2)布隆过滤器,提前将数据库中所有的合法key存入过滤器,请求先过过滤器,判定不存在就会直接拒绝,就走不到缓存和数据库了

    3)IP/接口 限流熔断,对于穿透请求高频的IP限流,对查询接口做熔断保护。这里的限流就是限制单位时间内允许请求的数量,比如单位时间内某个IP大量请求不存在的ID,加了限流之后直接回报错429错误码,就走不到缓存、数据库了;熔断就是当下游服务器持续失败或者过慢时,暂时切断请求,防止雪崩扩散。

  2. 击穿,某个极高的热点key,恰好过期或者被删除,此时大量并发请求同时访问该key,全部缓存未命中,所有的请求都会访问到数据库。这里的解决方案有:互斥锁串行重建缓存,热点key永不过期,热点key主动更新

  3. 雪崩,大量缓存key在同一时间集体过期,或者Redis缓存集体宕机,导致请求绕过缓存直接访问数据库,导致数据库负载瞬间爆表,引发整个服务链路雪崩。解决方案有:过期时间随机化,Redis集群高可用,服务限流、熔断、降级。

8. 分布式锁

在分布式、微服务架构中,一个服务会运行在多台机器上,这就是多进程的概念,多台机器会共享一个资源,这个时候python的线程锁就会失效,因为这些锁的作用范围是当前进程的内存,只能管自己进程内的线程。这时就需要分布式锁了,分布式锁就是跨进程、跨服务的锁,要保证多个进程对共享资源的互斥访问。

分布式锁有四个核心的特性:

  1. 互斥性,同一时间只有一个客户端持有锁,其他的客户端必须等待
  2. 安全性,锁只能被其持有者释放,不能被其他客户端误删
  3. 避免死锁,即使持有锁的客户端崩溃、中断,也可以在一定时间后自动释放
  4. 可用性,Redis集群环境下,锁服务不能单点故障,要保证大部分节点可用

Redis做分布式锁的优点,就在于其性能极高,获取、释放锁都是毫秒级,而且部署也比较简单。

接下来介绍下Redis单节点分布式锁的几个命令:

  1. key 锁的唯一标识,比如要给ID=111的资源加锁
  2. value 客户端的唯一标识,保证锁只能被持有者释放
  3. NX 全程Not Exist,只有当key不存在时才会设置成功
  4. EX/PX 设置的过期时间,EX单位是秒,PX单位是毫秒
  5. timeout 锁的过期时间,避免死锁

比如这行代码:

SET lock:order:123 uuid:192.168.1.100 NX EX 30

就表明给资源key为lock:order:123的资源加锁,锁的持有者是uuid:192.168.1.100,30秒后过期

而释放锁,就回到刚刚的安全性了,必须只有锁的持有客户端才可以释放。流程就是先判断加锁的客户端是不是自己,如果是才可以去释放,看下相关的Iua脚本:

if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])  -- 标识匹配,删除锁
else
    return 0  -- 标识不匹配,不做任何操作
end

之前写过一个卖票的函数,就是使用Redis的分布式锁来控制库存,防止超卖:

async def sell_one_with_lock(r: Redis, window_name: str) -> bool:
    """使用 Redis 分布式锁保护“检查+扣减”关键区,成功卖出返回 True,售罄或失败返回 False。"""
    lock = r.lock(LOCK_KEY, timeout=5, blocking_timeout=1)  # 超时时间与获取等待时间可调
    acquired = await lock.acquire(blocking=True)
    if not acquired:
        # 未拿到锁,视为本次卖票失败(可重试)
        return False
    try:
        # 关键区:读取剩余、判定、扣减
        remaining_str = await r.get(TICKET_KEY)
        remaining = int(remaining_str) if remaining_str is not None else 0
        if remaining <= 0:
            return False
        # 扣减一张(原子自减命令)
        await r.decr(TICKET_KEY)
        return True
    finally:
        try:
            await lock.release()
        except Exception:
            # 若锁已过期或其他异常,忽略释放错误
            pass

代码第一行就创建了一个分布式锁,传入了一个过期时间防止死锁,lock.acquire是真正的加锁步骤。之前看到这里有个疑惑:每次调用这个方法,都会创建一个分布式锁,如何保证对一个资源加锁,在创建锁的时候传入了LOCK_KEY,这个就是要加锁的key,也就是要加锁的资源,这个方法每次执行都会创建一个锁,但是lock.acquire在资源没有释放的时候,返回的是false,也就是会走到if not这里的。

其实后续章节还有单点故障,主从、哨兵、 Redlock、 扩容、数据分片等,觉得分布式、缓存还需要消化下,后面就不在深入了,打算进入实战环节了,后续打算设计三个实战项目来进一步深入的学习下。

三个实战项目分别是

  1. 信息查询系统,使用MySQL存储用户信息,Redis作为缓存,巩固下前面学习的缓存模式,同时加上压测环节,通过QPS,响应时间更加直观的了解缓存的意义
  2. 抢票系统,学习下Lua脚本,
  3. 秒杀系统,学习下高并发处理、限流、防超卖策略

分享前端项目常用的三个Skills--Vue、React 与 UI 核心 Skills | 掘金一周 2.5

作者 掘金一周
2026年2月5日 16:40

本文字数1500+ ,阅读时间大约需要 4分钟。

【掘金一周】本期亮点:

「上榜规则」:文章发布时间在本期「掘金一周」发布时间的前一周内;且符合各个栏目的内容定位和要求。 如发现文章有抄袭、洗稿等违反社区规则的行为,将取消当期及后续上榜资格。

一周“金”选

掘金一周 文章头图 1303x734.jpg

内容评审们会在过去的一周内对社区深度技术好文进行挖掘和筛选,优质的技术文章有机会出现在下方榜单中,排名不分先后。

前端

分享前端项目常用的三个Skills--Vue、React 与 UI 核心 Skills@去伪存真

文章介绍前端项目常用技能包。Vue-Skills 解决 Vue 3 开发痛点;React-Skills 涵盖组件组合、性能优化等规则;UI-Skills 提供多元风格、适配多技术栈。Skills 让开发者转变角色,提升开发效率与代码质量。

供应链系统中的 Web 打印方案的探索实践@古茗前端团队

本文围绕供应链系统中的Web打印方案展开。调研了基于DOM、可视化模板、本地打印控件三类主流方案并对比。重点分析window.print,给出指定区域与批量打印思路,介绍核心CSS打印配置,指出其仍是高性价比方案。

我接手了一个 10 年前的 jQuery 老项目,用 Web Components 给它续了命@ErpanOmer

作者接手 2015 年上线的 jQuery 老 CRM 项目,需添加 AI 智能客服功能。因业务时间紧,选择 Web Components,利用其 Shadow DOM 解决 CSS 污染。介绍组件定义与使用,该方案侵入性零、框架无关、可渐进迁移,还给出避坑指南。

手把手教你使用LangChain(前端开发程序员版)@前端小豆

本文是面向前端开发者的LangChain教程。先介绍前置准备,接着从环境搭建、核心功能实操、前端项目集成展开教学,还给出避坑指南,最后指明进阶方向,助前端开发者用JS/TS集成AI能力,打造智能应用。

后端

Clawdbot 完整对接飞书教程 手把手搭建你的专属 AI 助手@BingoGo

本文是 Moltbot(原 Clawdbot)对接飞书的教程。先介绍在 Linux 系统安装,包括 Git、Node.js 及 Moltbot 安装步骤,还提及查看服务和访问 Web UI 面板方法。接着说明对接飞书流程,含插件安装、参数配置、权限设置等,完成后可与机器人对话。

Agent Skills工作流:从入门到实战@雨中飘荡的记忆

本文围绕Agent Skills工作流展开,先介绍核心概念与架构设计,接着阐述核心组件、基础技能实现,还涉及交互机制、多Agent协作。以智能数据分析系统为例实战,给出部署、测试策略与设计原则,展现其结合AI与传统开发的优势。

性能告警惊四座 神器出手伏恶龙@Hello_world_

2025年12月29日服务出现CPU阈值告警,作者从代码、流量、JVM维度排查,未找到根本原因。后用神器Arthas分析,发现是业务代码中序列化/反序列化代码使用不当所致。修改代码后,CPU利用率大幅降低。

Android

血压飙升,Flutter & Dart 2025 年度巨坑回顾@恋猫de小郭

本文先提及 Google 发布的 Flutter & Dart 2025 高光回顾,随后着重盘点该年 Flutter 经典巨坑,如宏功能暂停、线程合并问题等,其中超半数为 iOS 相关问题,虽官方积极解决,但平台升级坑多。

Compose中的IntrinsicSize实战指南@稀有猿诉

本文围绕Compose中的IntrinsicSize展开,以构建重叠卡片式UI为例,指出weight修饰符导致布局问题的原因,介绍了IntrinsicSize打破循环依赖的原理,列举常见用例,还提及性能注意事项,强调其对复杂UI构建的重要性。

人工智能

从零开始搭建部署 Moltbot/Clawdbot 完整攻略@出门不下雨

本文是 Moltbot/Clawdbot 搭建部署攻略。介绍其为支持多模型、多平台的 AI 助手框架,阐述系统要求,给出 Windows、Mac 安装步骤,涵盖首次配置、常用指令、日常维护、故障排除及进阶配置内容,还解答常见问题。

Agent Skills 傻瓜式教程,26 年最火 AI 技术就这?@程序员鱼皮

本文围绕 Agent Skills 展开,它是 Anthropic 推出的开放标准,可让 AI 学习专业技能。介绍了入门实战、内部原理、跨工具使用、创建方法,还对比了与 MCP、斜杠命令区别,因其开放复用且降低门槛而大火。

Agent Skills完全指南:核心概念丨设计模式丨实战代码@大模型真好玩

本文围绕 Agent Skills 展开,它是渐进式披露的提示词管理机制,能降低 Token 消耗。以 Claude Code 为例,介绍安装、使用及进阶方法。还对比了与 MCP 的区别,助开发者构建强大智能体。

📖 投稿专区

大家可以在评论区推荐认为不错的文章,并附上链接和推荐理由,有机会呈现在下一期。文章创建日期必须在下期掘金一周发布前一周以内;可以推荐自己的文章、也可以推荐他人的文章。

彻底搞懂大文件上传:从幼儿园到博士阶段的全栈方案

2026年2月5日 15:58

第一层:幼儿园阶段 —— 为什么要搞复杂上传?

想象一下:你要把家里的**一万本书(10GB 文件)**搬到新家。

普通方案(简单上传):你试图一次性把一万本书塞进一个小三轮车。结果:车爆胎了(浏览器内存溢出),或者路上堵车太久,新家管理员等得不耐烦把门关了(请求超时)。

全栈方案(分片上传):你把书装成 100 个小箱子,一箱一箱运。即便路上有一箱丢了,你只需要补发那一箱,而不是重新搬一万本书。

为什么这样做?

  • 降低单次传输的数据量,避免内存溢出
  • 减少单个请求的处理时间,降低超时风险
  • 支持断点续传,提高上传成功率

第二层:小学阶段 —— 简单上传的极限

对于小于 10MB 的图片或文档,我们用 FormData。

前端 (Vue):input type="file" 获取文件,封入 FormData,通过 axios 发送。

后端 (Node):使用 multer 或 formidable 中间件接收。

数据库 (MySQL):切记! 数据库不存文件二进制流,只存文件的访问路径(URL)、文件名、大小和上传时间。

为什么数据库不存文件?

  • 文件体积太大,影响数据库性能
  • 数据库备份和迁移变得困难
  • 磁盘空间浪费,难以清理

简单上传的代码示例

// 前端
const formData = new FormData();
formData.append('file', fileInput.files[0]);
axios.post('/upload', formData);

// 后端
const upload = multer({ dest: 'uploads/' });
app.post('/upload', upload.single('file'), (req, res) => {
  // 处理上传
});

第三层:中学阶段 —— 分片上传 (Chunking) 的逻辑公式

这是面试的核心起点。请背诵流程:

切片:利用 File 对象的 slice 方法(底层是 Blob),将大文件切成 N 份。 标识:给每个片起个名字,通常是 文件名 + 下标。 并发发送:同时发送多个 HTTP 请求。 合并:前端发个"指令",后端把所有碎片按顺序合成一个完整文件。

为什么要用slice方法?

  • 它不会复制整个文件到内存,而是创建一个指向原文件部分的引用
  • 内存占用极小,适合处理大文件
  • 可以精确控制每个分片的大小

分片上传的实现逻辑

// 分片函数
function createFileChunks(file, chunkSize = 1024 * 1024 * 2) { // 2MB per chunk
  const chunks = [];
  let start = 0;
  
  while (start < file.size) {
    const end = Math.min(start + chunkSize, file.size);
    const chunk = file.slice(start, end); // 核心API
    chunks.push({
      blob: chunk,
      index: chunks.length,
      start,
      end
    });
    start = end;
  }
  
  return chunks;
}

第四层:大学阶段 —— 秒传与唯一标识 (MD5)

面试官问:"如果用户上传一个服务器已经有的文件,怎么实现秒传?"

核心:文件的"身份证"。不能用文件名,要用文件的内容生成 MD5 哈希值。

流程

  1. 前端用 spark-md5 计算文件哈希。
  2. 上传前先问后端:"这个 MD5 对应的文件你有没有?"
  3. 后端查 MySQL,如果有,直接返回成功——这就是秒传。

为什么用MD5而不是文件名?

  • 文件名可以重复,但内容不同的文件
  • 文件名可以被修改,但内容不变
  • MD5是内容的数字指纹,相同内容必定有相同的MD5

MD5计算示例

import SparkMD5 from 'spark-md5';

function calculateFileHash(file) {
  return new Promise((resolve) => {
    const fileReader = new FileReader();
    const spark = new SparkMD5.ArrayBuffer();
    let chunkIndex = 0;
    const chunkSize = 2097152; // 2MB
    
    const loadNext = () => {
      const start = chunkIndex * chunkSize;
      const end = Math.min(start + chunkSize, file.size);
      
      fileReader.readAsArrayBuffer(file.slice(start, end));
    };
    
    fileReader.onload = (e) => {
      spark.append(e.target.result);
      chunkIndex++;
      
      if (chunkIndex * chunkSize < file.size) {
        loadNext(); // 继续读取下一个分片
      } else {
        resolve(spark.end()); // 返回最终哈希值
      }
    };
    
    loadNext();
  });
}

第五层:博士阶段 —— 断点续传 (Resumable)

如果传到一半断网了,剩下的怎么办?

方案 A:前端记录(不可靠) 方案 B(推荐):后端 MySQL 记录已收到的分片序号。

流程

  1. 重新上传前,调用 checkChunks 接口
  2. 后端查库返回:{ uploadedList: [1, 2, 5] }
  3. 前端过滤掉已存在的序号,只发剩下的

为什么选择后端记录?

  • 前端存储不可靠(localStorage可能被清除)
  • 支持跨设备续传(从手机上传一半,从电脑继续)
  • 数据一致性更好,不容易出现脏数据

断点续传实现

// 检查已上传分片
async function checkUploadedChunks(fileHash) {
  const response = await api.checkChunks({ fileHash });
  return response.uploadedList || []; // 已上传的分片索引数组
}

// 过滤未上传的分片
const uploadedList = await checkUploadedChunks(fileHash);
const needUploadChunks = allChunks.filter(chunk => 
  !uploadedList.includes(chunk.index)
);

第六层:性能巅峰 —— 只有 1% 的人知道的 Worker 计算

浏览器是"单线程"的,JavaScript 引擎和页面渲染(DOM 树构建、布局、绘制)共用一个线程,如果你在主线程执行一个耗时 10 秒的循环(比如计算大文件的 MD5),浏览器会直接卡死。用户点不动按钮、动画停止、甚至浏览器弹出"页面无响应"。

屏幕刷新率通常是 60Hz,意味着浏览器每 16.7ms 就要渲染一帧。如果你的计算任务占据了这 16.7ms,页面就会掉帧、卡顿。

二、 什么是 Web Worker?(定义)

Web Worker 是 HTML5 标准引入的一项技术,它允许 JavaScript 脚本在后台线程中运行,不占用主线程。

它的地位: 它是主线程的"打工仔"。

它的环境: 它运行在另一个完全独立的环境中,拥有自己的全局对象(self 而不是 window)。

三、 Web Worker 能干什么?(职责与局限)

能干什么:

CPU 密集型计算: MD5 计算、加密解密、图像/视频处理、大数据排序。

网络请求: 可以在后台轮询接口。

不能干什么(面试必考点):

不能操作 DOM: 它拿不到 window、document、parent 对象。

不能弹窗: 无法使用 alert()。

受限通信: 它和主线程之间只能通过 "消息传递"(PostMessage)沟通。

四、 怎么干?(核心 API 实战)

我们要实现的目标是:在不卡顿页面的情况下,计算一个 1GB 文件的 MD5。

步骤 1:主线程逻辑(Vue/JS 环境)

主线程负责雇佣 Worker,并给它派活。

// 1. 创建 Worker 实例 (路径指向 worker 脚本)
const myWorker = new Worker('hash-worker.js');

// 2. 发送任务 (把文件对象传给 Worker)
myWorker.postMessage({ file: fileObject });

// 3. 接收结果 (监听 Worker 回传的消息)
myWorker.onmessage = (e) => {
    const { hash, percentage } = e.data;
    if (hash) {
        console.log("计算完成!MD5 为:", hash);
    } else {
        console.log("当前进度:", percentage);
    }
};

// 4. 异常处理
myWorker.onerror = (err) => {
    console.error("Worker 报错了:", err);
};

步骤 2:Worker 线程逻辑(hash-worker.js)

Worker 负责埋头苦干。

// 引入计算 MD5 的库 (Worker 内部引用脚本的方式)
importScripts('spark-md5.min.js');

self.onmessage = function(e) {
    const { file } = e.data;
    const spark = new SparkMD5.ArrayBuffer(); // 增量计算实例
    const reader = new FileReader();
    const chunkSize = 2 * 1024 * 1024; // 每次读 2MB
    let currentChunk = 0;

    // 分块读取的核心逻辑
    reader.onload = function(event) {
        spark.append(event.target.result); // 将 2MB 数据喂给 spark
        currentChunk++;

        if (currentChunk < Math.ceil(file.size / chunkSize)) {
            loadNext(); // 继续读下一块
            // 反馈进度
            self.postMessage({ 
                percentage: (currentChunk / Math.ceil(file.size / chunkSize) * 100).toFixed(2) 
            });
        } else {
            // 全部读完,生成最终 MD5
            const md5 = spark.end();
            self.postMessage({ hash: md5 }); // 完工,回传结果
        }
    };

    function loadNext() {
        const start = currentChunk * chunkSize;
        const end = Math.min(start + chunkSize, file.size);
        // 关键点:使用 slice 切片读取,避免一次性读入内存
        reader.readAsArrayBuffer(file.slice(start, end));
    }

    loadNext(); // 开始任务
};

五、 关键思路过程(给面试官讲出深度)

当你向面试官复述这个过程时,要按照这个逻辑链条:

实例化(New Worker):

"首先,我通过 new Worker 启动一个独立线程,将耗时的计算逻辑从主线程剥离。"

数据传输(PostMessage):

"主线程将 File 对象发送给 Worker。这里要注意,File 对象是 File 句柄,发送它并不会瞬间占据大量内存,因为它是基于 Blob 的,是惰性的。"

分块读取与增量计算(Chunked Hashing):

"这是最核心的一步。即便在 Worker 内部,我也不能直接读取整个文件(比如 5GB 读进内存会直接让 Worker 进程挂掉)。

我使用了 file.slice 配合 FileReader,每次只读取 2MB 数据。

配合 spark-md5 的 append 方法,将数据'喂'给计算引擎,处理完后,之前的内存块会被释放。"

异步通信(Messaging):

"在计算过程中,我不断通过 self.postMessage 向主线程发送计算进度,以便用户在界面上能看到动态的百分比。

最后计算完成,通过消息回传最终 MD5。"

六、 总结:核心 API 记事本

new Worker('path'): 开启招聘。

postMessage(data): 互发短信。

onmessage: 接收短信。

importScripts(): Worker 内部加载插件。

file.slice(): 物理上的"切片",MD5 不崩溃的秘诀。

FileReader.readAsArrayBuffer(): 将二进制内容读入内存进行计算。

七. 谈 内存管理 (Memory Management)

面试官可能会问: "在Worker内部如何避免内存溢出?"

回答: "在 Worker 内部,我没有使用 fileReader.readAsArrayBuffer(file) 直接读取整个文件。因为 4GB 的文件如果直接读入内存,V8 引擎会直接 OOM (Out of Memory) 崩溃。我采用了 '分块读取 -> 增量哈希' 的策略。利用 spark.append() 每次只处理 2MB 的数据,处理完后 V8 的垃圾回收机制会自动释放这块内存,从而实现用极小的内存开销处理极大的文件。"

八. 谈 抽样哈希 (性能杀手锏) —— 只有 1% 的人知道的黑科技

面试官: "如果文件 100GB,Worker 计算也要好几分钟,用户等不及怎么办?"

你的杀手锏: "如果对完整性校验要求不是 100% 严苛,我会采用 '抽样哈希' 方案:

  • 文件头 2MB 全部取样
  • 文件尾 2MB 全部取样
  • 中间部分:每隔一段距离取样几个字节

这样 10GB 的文件我也只需要计算 10MB 左右的数据,MD5 计算会在 1秒内 完成,配合后端校验,能实现'秒级'预判,极大提升用户体验。"

D. 总结:面试加分关键词

  • 增量计算 (Incremental Hashing):不是一次性算,是攒着算
  • Blob.slice:文件切片的核心底层 API
  • 非阻塞 (Non-blocking):Worker 的核心价值
  • OOM 预防:通过分块读取控制内存峰值
  • 抽样哈希:大文件快速识别的有效手段

"其实 Web Worker 也有开销,创建它需要时间和内存。对于几百 KB 的小文件,直接在主线程算可能更快;但对于大文件上传,Worker 是保证 UI 响应性 的唯一正确解。"

第七层:Node.js 后端压测 —— 碎片合并的艺术

当 1000 个切片传上来,Node 如何高效合并?

初级:fs.readFileSync。 瞬间撑爆内存,Node.js(V8)默认内存限制通常在 1GB~2GB 左右。执行 readFile 合并大文件,服务器会瞬间 Crash。

高级:fs.createReadStream 和 fs.createWriteStream。

利用"流(Stream)"的管道模式,边读边写,内存占用极低。

一、 什么是 Stream?(本质定义)

流(Stream)是 Node.js 提供的处理 流式数据 的抽象接口。它将数据分成一小块一小块(Buffer),像流水一样从一头流向另一头。

Readable(可读流): 数据的源头(如:分片文件)。

Writable(可写流): 数据的终点(如:最终合并的大文件)。

Pipe(管道): 连接两者的水管,数据通过管道自动流过去。

二、 能干什么?

在文件上传场景中,它能:

低内存合并: 无论文件多大(1G 或 100G),内存占用始终稳定在几十 MB。

边读边写: 读入一小块分片,立即写入目标文件,不需要等待整个分片读完。

自动背压处理(Backpressure): 如果写的速度慢,读的速度快,管道会自动让读取慢下来,防止内存积压。

三、 怎么干?(核心 API 与实战)

1. 核心 API 记事本

fs.createReadStream(path):创建一个指向分片文件的水龙头。

fs.createWriteStream(path, { flags: 'a' }):创建一个指向目标文件的接收桶。flags: 'a' 表示追加写入(Append)。

reader.pipe(writer):把水龙头接到桶上。

const fs = require('fs');
const path = require('path');

/**
 * @param {string} targetFile 最终文件存放路径
 * @param {string} chunkDir 分片临时存放目录
 * @param {number} chunkCount 分片总数
 */
async function mergeFileChunks(targetFile, chunkDir, chunkCount) {
    // 1. 创建一个可写流,准备写入最终文件
    // flags: 'a' 表示追加模式,如果文件已存在,就在末尾接着写
    const writeStream = fs.createWriteStream(targetFile, { flags: 'a' });

    for (let i = 0; i < chunkCount; i++) {
        const chunkPath = path.join(chunkDir, `${i}.part`);
        
        // 2. 依次读取每个分片
        const readStream = fs.createReadStream(chunkPath);

        // 3. 核心:通过 Promise 封装,确保"按顺序"合并
        // 必须等第 0 片合完了,才能合第 1 片,否则文件内容会错乱
        await new Promise((resolve, reject) => {
            // 将读取流的内容导向写入流
            // 注意:end: false 表示读完这一个分片后,不要关闭目标文件写入流
            readStream.pipe(writeStream, { end: false });
            
            readStream.on('end', () => {
                // 读取完后,删除这个临时分片(节省空间)
                fs.unlinkSync(chunkPath); 
                resolve();
            });
            
            readStream.on('error', (err) => {
                reject(err);
            });
        });
    }

    // 4. 所有分片读完了,手动关闭写入流
    writeStream.end();
    console.log("合并完成!");
}

为什么用Stream而不是readFileSync?

  • readFileSync会将整个文件加载到内存,大文件会爆内存
  • Stream是流式处理,内存占用固定
  • 性能更好,适合处理大文件

吊打面试官:

"在大文件合并的处理上,我绝对不会使用 fs.readFileSync。我会使用 Node.js 的 Stream API。

具体的实现思路是:

首先,创建一个 WriteStream 指向最终文件。

然后,遍历所有分片,通过 createReadStream 逐个读取。

关键点在于利用 pipe 管道将读流导向写流。为了保证文件的正确性,我会通过 Promise 包装实现串行合并。同时,设置 pipe 的 end 参数为 false,确保写入流在合并过程中不被提前关闭。

这种做法的优势在于:利用了 Stream 的背压机制,内存占用极低(通常只有几十 KB),即便是在低配置的服务器上,也能稳定合并几十 GB 的大文件。"

第八层:MySQL 表结构设计 (实战架构)

你需要两张表来支撑这个系统:

文件表 (Files):id, file_md5, file_name, file_url, status(0:上传中, 1:已完成)。 切片记录表 (Chunks):id, file_md5, chunk_index, chunk_name。

查询优化:给 file_md5 加唯一索引,极大提升查询速度。

-- 文件主表
CREATE TABLE files (
  id INT AUTO_INCREMENT PRIMARY KEY,
  file_md5 VARCHAR(32) UNIQUE NOT NULL COMMENT '文件MD5',
  file_name VARCHAR(255) NOT NULL COMMENT '原始文件名',
  file_size BIGINT NOT NULL COMMENT '文件大小(字节)',
  file_url VARCHAR(500) COMMENT '存储路径',
  status TINYINT DEFAULT 0 COMMENT '状态:0-上传中,1-已完成',
  upload_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  INDEX idx_file_md5 (file_md5)
);

-- 分片记录表
CREATE TABLE chunks (
  id INT AUTO_INCREMENT PRIMARY KEY,
  file_md5 VARCHAR(32) NOT NULL,
  chunk_index INT NOT NULL COMMENT '分片索引',
  chunk_name VARCHAR(100) NOT NULL COMMENT '分片文件名',
  upload_status TINYINT DEFAULT 0 COMMENT '上传状态',
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (file_md5) REFERENCES files(file_md5),
  INDEX idx_file_chunk (file_md5, chunk_index)
);

为什么file_md5要加索引?

  • 秒传查询时需要快速定位文件是否存在
  • 断点续传时需要快速获取某个文件的所有分片
  • 提升查询性能,避免全表扫描

一、 为什么要这么建表?(核心痛点)

如果不存数据库,只存本地文件系统,你会面临三个死穴:

断点续传没依据: 用户刷新网页,前端内存丢了,怎么知道哪些片传过?必须查库。

秒传无法实现: 10GB 的文件,怎么瞬间判断服务器有没有?全盘扫描物理文件?太慢!必须查索引。

并发合并风险: 多个请求同时触发合并逻辑怎么办?需要数据库的状态锁(Status)来控制。

二、 表结构详解:它们各司其职

1. 文件元数据表 (files) —— "身份证"

file_md5 (核心): 这是文件的唯一物理标识。不管文件名叫"高清.mp4"还是"学习资料.avi",只要内容一样,MD5 就一样。

status: 标记文件状态。

0 (Uploading): 还没传完或正在合并。

1 (Completed): 已经合并成功,可以直接访问。

file_url: 最终合并后的访问路径。

2. 切片记录表 (chunks) —— "进度表"

chunk_index: 记录这是第几个分片。

关系: 通过 file_md5 关联。一个 files 记录对应多个 chunks 记录。

三、 怎么建立联系?(逻辑关联图)

一对多关系:

files.file_md5 (1) <————> chunks.file_md5 (N)

秒传逻辑:

前端传 MD5 给后端。

SQL: SELECT file_url FROM files WHERE file_md5 = 'xxx' AND status = 1;

结果: 有记录?直接返回 URL(秒传成功)。没记录?进入下一步。

续传逻辑:

前端问:"这个 MD5 我传了多少了?"

SQL: SELECT chunk_index FROM chunks WHERE file_md5 = 'xxx';

结果: 后端返回 [0, 1, 2, 5],前端发现少了 3 和 4,于是只补传 3 和 4。

四、 关键思路与实战 SQL

1. 为什么加唯一索引(Unique Index)?

file_md5 必须是 UNIQUE。

面试点: "我给 file_md5 加了唯一索引,这不仅是为了查询快,更是一种业务兜底。在高并发下,如果两个用户同时上传同一个文件,数据库的唯一约束能防止产生两条重复的文件记录。"

2. 复合索引优化

在 chunks 表,我建议建立复合索引:INDEX idx_md5_index (file_md5, chunk_index)。

原因: 续传查询时,我们经常要查"某个 MD5 下的索引情况",复合索引能让这种搜索达到毫秒级。

五、 全流程演练(怎么干)

第一步:初始化 (Pre-check)

用户选好文件,计算 MD5,发给后端。

-- 尝试插入主表,如果 MD5 已存在则忽略(或返回已存在记录)
INSERT IGNORE INTO files (file_md5, file_name, file_size, status) 
VALUES ('abc123hash', 'video.mp4', 102400, 0);

第二步:分片上传 (Chunk Upload)

每收到一个片,存入 chunks 表。

-- 记录已收到的分片
INSERT INTO chunks (file_md5, chunk_index, chunk_name) 
VALUES ('abc123hash', 0, 'abc123hash_0.part');

第三步:合并触发 (Merge)

前端发合并指令,后端校验分片数量。

-- 检查分片是否齐全
SELECT COUNT(*) FROM chunks WHERE file_md5 = 'abc123hash';
-- 如果 Count == 总片数,开始 Stream 合并

第四步:完工 (Finish)

合并成功,更新主表。

-- 更新状态和最终路径
UPDATE files SET status = 1, file_url = '/uploads/2023/video.mp4' 
WHERE file_md5 = 'abc123hash';

六、 总结话术(吊打面试官版)

"我的数据库设计核心思路是 '内容标识胜于文件标识'。

我通过 files 表存储文件的 MD5 和全局状态,配合 UNIQUE 索引实现秒传的快速检索和并发控制。

通过 chunks 表记录每一个分片的到达状态,实现断点续传。

值得注意的细节是:

我使用了 BIGINT 来存储 file_size,因为 4GB 以上的文件 INT 会溢出。

我给 file_md5 做了索引优化,确保在百万级文件记录中,校验文件状态依然是 O(1) 的复杂度。

合并逻辑完成后,我会通过事务或状态锁更新 status 字段,确保数据的一致性。"

第九层:上帝视角 —— 并发控制 (Concurrency Control)

这一层是面试中的高光时刻。如果说 MD5 是为了"准确",Stream 是为了"稳健",那么并发控制就是为了"平衡"。

如果一个文件切了 1000 片,浏览器瞬间发出 1000 个请求,会导致浏览器崩溃或服务器宕机。

面试加分项:异步并发限制队列。

限制同时只有 6 个请求在跑(Chrome 的默认限制)。

async function sendRequest(tasks, limit = 6) {
    const pool = new Set();
    for (const task of tasks) {
        const promise = task();
        pool.add(promise);
        promise.then(() => pool.delete(promise));
        if (pool.size >= limit) await Promise.race(pool);
    }
}

为什么限制6个并发?

  • 浏览器对同一域名有最大连接数限制(通常为6)
  • 避免过多HTTP请求导致网络拥堵
  • 平衡上传速度和系统稳定性

一、 为什么要搞并发控制?(痛点)

  1. 浏览器"自保"机制: Chrome 浏览器对同一个域名的 HTTP 连接数有限制(通常是 6 个)。 如果你瞬间发起 1000 个请求,剩下的 994 个会处于 Pending(排队)状态。虽然不会直接崩溃,但会阻塞该域名的其他所有请求(比如你同时想加载一张图片,都要排在 900 多个切片后面)。

  2. 内存与性能压力: 前端: 1000 个 Promise 对象被创建,会瞬间吃掉大量内存。 后端(Node): 服务器瞬间接收 1000 个并发连接,磁盘 IO 会被占满,CPU 可能会飙升,甚至触发服务器的拒绝服务保护。

二、 什么是并发控制?(本质定义)

并发控制(Concurrency Control) 就像是一个"十字路口的红绿灯"或者"银行的排号机"。

它不改变任务总量。

它控制同一时刻正在运行的任务数量。

三、 怎么干?(核心逻辑公式)

我们要实现一个"工作池(Pool)",逻辑如下:

填满: 先一次性发出 limit(比如 6 个)个请求。

接替: 只要这 6 个请求中任何一个完成了,空出一个位子,就立刻补上第 7 个。

循环: 始终保持有 6 个请求在跑,直到 1000 个全部发完。

四、 代码详解:这 10 行代码值 5k 薪资

这是利用 Promise.race 实现的极其精妙的方案。

/**
 * @param {Array} tasks - 所有的上传任务(函数数组,执行函数才发请求)
 * @param {number} limit - 最大并发数
 */
async function sendRequest(tasks, limit = 6) {
    const pool = new Set(); // 正在执行的任务池
    const results = [];     // 存储所有请求结果

    for (const task of tasks) {
        // 1. 开始执行任务 (task 是一个返回 Promise 的函数)
        const promise = task();
        results.push(promise);
        pool.add(promise);

        // 2. 任务执行完后,从池子里删掉自己
        promise.then(() => pool.delete(promise));

        // 3. 核心:如果池子满了,就等最快的一个完成
        if (pool.size >= limit) {
            // Promise.race 会在 pool 中任何一个 promise 完成时 resolve
            await Promise.race(pool);
        }
    }

    // 4. 等最后剩下的几个也跑完
    return Promise.all(results);
}

五、 关键思路拆解(给面试官讲透)

  1. 为什么要传入 tasks 函数数组,而不是 Promise 数组? 回答: "因为 Promise 一旦创建就会立即执行。如果我传 [axios(), axios()],那并发控制就没意义了。我必须传 [() => axios(), () => axios()],这样我才能在循环里手动控制什么时候执行它。"

  2. Promise.race 起到了什么作用? 回答: "它充当了'阻塞器'。当池子满了,await Promise.race(pool) 会让 for 循环停下来。只有当池子里最快的一个请求完成了,race 才会解除阻塞,循环继续,从而发起下一个请求。"

  3. 为什么是 6 个? 回答: "这是基于 RFC 2616 标准建议和主流浏览器(Chrome/Firefox)的默认限制。超过 6 个,浏览器也会让剩下的排队。所以我们将并发数设为 6,既能榨干带宽,又能保持浏览器的响应顺畅。"

六、 进阶:如果请求失败了怎么办?(断点续传的结合)

在实战中,我们还需要加上重试机制。

// 伪代码:带重试的 task
const createTask = (chunk) => {
    return async () => {
        let retries = 3;
        while (retries > 0) {
            try {
                return await axios.post('/upload', chunk);
            } catch (err) {
                retries--;
                if (retries === 0) throw err;
            }
        }
    };
};

七、 总结

"在大文件上传场景下,盲目发起成百上千个切片请求会导致浏览器网络层阻塞和服务器压力过大。

我的解决方案是实现一个 '异步并发控制队列'。

核心思想是利用 Promise.race。我将并发数限制在 6 个。在 for 循环中,我维护一个 Set 结构的执行池。每当一个切片请求开始,就加入池子;完成后移出。当池子达到限制数时,利用 await Promise.race 阻塞循环,实现**'走一个,补一个'**的动态平衡。

这样做不仅遵守了浏览器的连接限制,更重要的是保证了前端页面的流畅度和后端 IO 的稳定性。如果遇到失败的请求,我还会配合重试逻辑和断点续传记录,确保整个上传过程的强壮性。"

第十层:终极回答策略 (架构师收网版)

如果面试官让你总结一套"完美的文件上传方案",请按照五个维度深度收网:

用户体验层 (Performance):

采用 Web Worker 开启后台线程,配合 Spark-MD5 实现增量哈希计算。

核心亮点:通过"分块读取 -> 增量累加"策略避免 4GB+ 大文件导致的浏览器 OOM(内存溢出),并利用 Worker 的非阻塞特性确保 UI 响应始终保持 60fps。对于超大文件,可选抽样哈希方案,秒级生成指纹。

传输策略层 (Strategy):

秒传:上传前预检 MD5,实现"内容即路径"的瞬间完成。

断点续传:以后端存储为准,通过接口查询 MySQL 中已存在的 chunk_index,前端执行 filter 增量上传。

并发控制:手写异步并发池,利用 Promise.race 实现"走一个,补一个"的槽位控制(限制 6 个并发),既榨干带宽又防止 TCP 阻塞及服务器 IO 爆表。

后端处理层 (Processing):

流式合并:放弃 fs.readFile,坚持使用 Node.js Stream (pipe)。

核心亮点:利用 WriteStream 的追加模式与读流对接,通过 Promise 串行化 保证切片顺序,并依靠 Stream 的背压(Backpressure)机制自动平衡读写速度,将内存占用稳定在 30MB 左右。

持久化设计层 (Database):

文件元数据管理:MySQL 记录文件 MD5、状态与最终存储 URL。

查询优化:给 file_md5 加 Unique Index(唯一索引),不仅提升秒传查询效率,更在数据库层面兜底高并发下的重复写入风险。

安全防护层 (Security):

二进制校验:不信任前端后缀名,后端读取文件流前 8 字节的 Magic Number(魔数) 校验二进制头,防止伪造后缀的木马攻击。

总结话术:一分钟"吊打"面试官

"在处理大文件上传时,我的核心思路是 '分而治之' 与 '状态持久化'。

在前端层面,我通过 Web Worker 配合增量哈希 解决了计算大文件 MD5 时的 UI 阻塞和内存溢出问题。利用 Blob.slice 实现逻辑切片后,我没有盲目发起请求,而是设计了一个基于 Promise.race 的并发控制队列,在遵守浏览器 TCP 限制的同时,保证了传输的平稳性。

在状态管理上,我采用 '后端驱动'的断点续传方案。前端在上传前会通过接口查询 MySQL 获取已上传分片列表,这种方案比 localStorage 更可靠,且天然支持跨设备续传。

在后端处理上,我深度使用了 Node.js 的 Stream 流 进行分片合并。通过管道模式与背压处理,我确保了服务器在处理几十 GB 数据时,内存水位依然保持在极低范围。

在安全性与严谨性上,我通过 MySQL 唯一索引 处理并发写冲突,通过 文件头二进制校验 过滤恶意文件。

这套方案不仅是功能的堆砌,更是对浏览器渲染机制、网络拥塞控制、内存管理以及服务器 IO 瓶颈的综合优化方案。"

💡 面试官可能追问的"补丁"

追问:如果合并过程中服务器断电了怎么办?

回答: 由于我们是通过数据库记录 status 的,合并未完成前 status 始终为 0。服务器重启后,可以根据 status=0 且切片已齐全的记录,重新触发合并任务,或者由前端下次触发预检时重新合并。

追问:切片大小设为多少合适?

回答: 通常建议 2MB - 5MB。太小会导致请求碎片过多(HTTP 头部开销大),太大容易触发网络波动导致的单个请求失败。

追问:上传过程中进度条如何实现?

回答: 两个维度。一是 MD5 进度(Worker 返回),二是上传进度(使用 axios 的 onUploadProgress 监控每个分片,结合已上传分片数量计算加权平均进度)。

服务拆分之旅:测试过程全揭秘|得物技术

作者 得物技术
2026年2月5日 14:47

一、引言

代码越写越多怎么办?在线等挺急的! Bidding-interface服务代码库代码量已经达到100w行!!

Bidding-interface应用是出价域核心应用之一,主要面向B端商家。跟商家后台有关的出价功能都围绕其展开。是目前出价域代码量最多的服务。

随着出价业务最近几年来的快速发展,出价服务承接的流量虽然都是围绕卖家出价,但是已远远超过卖家出价功能范围。业务的快速迭代而频繁变更给出价核心链路高可用、高性能都带来了巨大的风险。

经总结有如下几个痛点:

  • 核心出价链路未隔离:

    出价链路各子业务模块间代码有不同程度的耦合,迭代开发可扩展性差,往往会侵入到出价主流程代码的改动。每个子模块缺乏独立的封装,而且存在大量重复的代码,每次业务规则调整,需要改动多处,容易出现漏改漏测的问题。

  • 大单体&功能模块定义混乱:

    历史原因上层业务层代码缺乏抽象,代码无法实现复用,需求开发代码量大,导致需求估时偏高,经常出现20+人日的大需求,需求开发中又写出大量重复代码,导致出价服务代码库快速膨胀,应用启动耗时过长,恶性循环。

  • B/C端链路未隔离:

    B端卖家出价链路流量与C端价格业务场景链路流量没有完全隔离,由于历史原因,有些B端出价链路接口代码还存在于price应用中,偶尔B端需求开发会对C端应用做代码变更。存在一定的代码管控和应用权限管控成本。

  • 发布效率影响:

    代码量庞大,导致编译速度缓慢。代码过多,类的依赖关系更为复杂,持续迭代逐步加大编译成本,随着持续迭代,新的代码逻辑 ,引入更多jar 依赖,间接导致项目部署时长变长蓝绿发布和紧急问题处理时长显著增加;同时由于编译与部署时间长,直接影响开发人员在日常迭代中的效率(自测,debug,部署)。

  • 业务抽象&分层不合理:

    历史原因出价基础能力领域不明确,出价底层和业务层分层模糊,业务层代码和出价底层代码耦合严重,出价底层能力缺乏抽象,上层业务扩展需求频繁改动出价底层能力代码。给出价核心链路代码质量把控带来较高的成本, 每次上线变更也带来一定的风险。

以上,对于Bidding服务的拆分和治理,已经箭在弦上不得不发。否则,持续的迭代会继续恶化服务的上述问题。

经过前期慎重的筹备,设计,排期,拆分,和测试。目前Bidding应用经过四期的拆分节奏,已经马上要接近尾声了。服务被拆分成三个全新的应用,目前在小流量灰度放量中。

本次拆分涉及:1000+Dubbo接口,300+个HTTP接口,200+ MQ消息,100+个TOC任务,10+个 DJob任务。

本人是出价域测试一枚,参与了一期-四期的拆分测试工作。

项目在全组研发+测试的ALL IN投入下,已接近尾声。值此之际输出一篇文章,从测试视角复盘下,Bidding服务的拆分与治理,也全过程揭秘下出价域内的拆分测试过程。

二、服务拆分的原则

首先,在细节性介绍Bidding拆分之前。先过大概过一下服务拆分原则:

  • 单一职责原则 (SRP):  每个服务应该只负责一项特定的业务功能,避免功能混杂。

  • 高内聚、低耦合:  服务内部高度内聚,服务之间松耦合,尽量减少服务之间的依赖关系。

  • 业务能力导向:  根据业务领域和功能边界进行服务拆分,确保每个服务都代表一个完整的业务能力。

拆分原则之下,还有不同的策略可以采纳:基于业务能力拆分、基于领域驱动设计 (DDD) 拆分、基于数据拆分等等。同时,拆分时应该注意:避免过度拆分、考虑服务之间的通信成本、设计合理的 API 接口。

服务拆分是微服务架构设计的关键步骤,需要根据具体的业务场景和团队情况进行综合考虑。合理的服务拆分可以提高系统的灵活性、可扩展性和可维护性,而不合理的服务拆分则会带来一系列问题。

三、Bidding服务拆分的设计

如引言介绍过。Bidding服务被拆分出三个新的应用,同时保留bidding应用本身。目前共拆分成四个应用:Bidding-foundtion,Bidding-interface,Bidding-operation和Bidding-biz。详情如下:

  • 出价基础服务-Bidding-foundation:

出价基础服务,对出价基础能力抽象,出价领域能力封装,基础能力沉淀。

  • 出价服务-Bidding-interfaces:

商家端出价,提供出价基础能力和出价工具,提供商家在各端出价链路能力,重点保障商家出价基础功能和出价体验。

  • 出价运营服务-Bidding-operation:

出价运营,重点支撑运营对出价业务相关规则的维护以及平台其他域业务变更对出价域数据变更的业务处理:

  1. 出价管理相关配置:出价规则配置、指定卖家规则管理、出价应急隐藏/下线管理工具等;
  2. 业务大任务:包括控价生效/失效,商研鉴别能力变更,商家直发资质变更,品牌方出价资质变更等大任务执行。
  • 业务扩展服务-Bidding-biz:

更多业务场景扩展,侧重业务场景的灵活扩展,可拆出的现有业务范围:国补采购单出价,空中成单业务,活动出价,直播出价,现订现采业务,预约抢购,新品上线预出价,入仓预出价。

应用拆分前后流量分布情况:

图片

四、Bidding拆分的节奏和目标收益

服务拆分是项大工程,对目前的线上质量存在极大的挑战。合理的排期和拆分计划是重点,可预期的收益目标是灵魂。

经过前期充分调研和规划。Bidding拆分被分成了四期,每期推进一个新应用。并按如下六大步进行:

图片

Bidding拆分目标

  • 解决Bidding大单体问题: 对Bidding应用进行合理规划,完成代码和应用拆分,解决一直以来Bidding大单体提供的服务多而混乱,维护成本高,应用编译部署慢,发布效率低等等问题。
  • 核心链路隔离&提升稳定性: 明确出价基础能力,对出价基础能力下沉,出价基础能力代码拆分出独立的代码库,并且部署在独立的新应用中,实现出价核心链路隔离,提升出价核心链路稳定性。
  • 提升迭代需求开发效率: 完成业务层代码抽象,业务层做组件化配置化,实现业务层抽象复用,降低版本迭代需求开发成本。
  • 实现出价业务应用合理规划: 各服务定位、职能明确,分层抽象合理,更好服务于企/个商家、不同业务线运营等不同角色业务推进。

预期的拆分收益

  • 出价服务应用结构优化:

    完成对Bidding大单体应用合理规划拆分,向下沉淀出出价基础服务应用层,降低出价基础能力维护成功;向上抽离出业务扩展应用层,能够实现上层业务的灵活扩展;同时把面向平台运营和面向卖家出价的能力独立维护;在代码库和应用层面隔离,有效减少版本迭代业务需求开发变更对应用的影响面,降低应用和代码库的维护成本。

  • 完成业务层整体设计,业务层抽象复用,业务层做组件化配置化,提升版本迭代需求开发效率,降低版本迭代需求开发成本:

    按业务类型对业务代码进行分类,统一设计方案,提高代码复用性,支持业务场景变化时快速扩展,以引导降价为例,当有类似降价换流量/降价换销量新的降价场景需求时,可以快速上线,类似情况每个需求可以减少10-20人日开发工作量。

  • 代码质量提升 :

    通过拆分出价基础服务和对出价流程代码做重构,将出价基础底层能力代码与上层业务层代码解耦,降低代码复杂度,降低代码冲突和维护难度,从而提高整体代码质量和可维护性。

  • 开发效率提升 :

    1. 缩短应用部署时间: 治理后的出价服务将加快编译和部署速度,缩短Bidding-interfaces应用发布(编译+部署)时间 由12分钟降低到6分钟,从而显著提升开发人员的工作效率,减少自测、调试和部署所需的时间。以Bidding服务T1环境目前一个月编译部署至少1500次计算,每个月可以节约150h应用发布时间。
    2. 提升问题定位效率: 出价基础服务层与上层业务逻辑层代码库&应用分开后,排查定位开发过程中遇到的问题和线上问题时可以有效缩小代码范围,快速定位问题代码位置。

五、测试计划设计

服务拆分的前期,研发团队投入了大量的心血。现在代码终于提测了,进入我们的测试环节:

为了能收获更好的质量效果,同时也为了不同研发、测试同学的分工。我们需要细化到最细粒度,即接口维度整理出一份详细的文档。基于此文档的基础,我们确定工作量和人员排期:

如本迭代,我们投入4位研发同学,2位测试同学。完成该200个Dubbo接口和100个HTTP接口,以及20个Topic迁移。对应的提测接口,标记上负责的研发、测试、测试进度、接口详细信息等内容。

基于该文档的基础上,我们的工作清晰而明确。一个大型的服务拆分,也变成了一步一步的里程碑任务。

接下来给大家看一下,关于Bidding拆分。我们团队整体的测试计划,我们一共设计了五道流程。

  • 第一关:自测接口对比:

    每批次拆分接口提测前,研发同学必须完成接口自测。基于新旧接口返回结果对比验证。验证通过后标记在文档中,再进入测试流程。

    对于拆分项目,自测卡的相对更加严格。由于仅做接口迁移,逻辑无变更,自测也更加容易开展。由研发同学做好接口自测,可以避免提测后新接口不通的低级问题。提高项目进度。

    在这个环节中。偶尔遇见自测不充分、新接口参数传丢、新Topic未配置等问题。(三期、四期测试中,我们加强了对研发自测的要求)。

  • 第二关:测试功能回归

    这一步骤基本属于测试的人工验证,同时重点需关注写接口数据验证。

    回归时要测的细致。每个接口,测试同学进行合理评估。尽量针对接口主流程,进行细致功能回归。由于迁移的接口数量多,历史逻辑重。一方面在接口测试任务分配时,要尽量选择对该业务熟悉的同学。另一方面,承接的同学也有做好历史逻辑梳理。尽量不要产生漏测造成的问题。

    该步骤测出的问题五花八门。另外由于Bidding拆分成多个新服务。两个新服务经常彼此间调用会出现问题。比如二期Bidding-foundation迁移完成后,Bidding-operation的接口在迁移时,依赖接口需要从Bidding替换成foundation的接口。

    灰度打开情况下,调用新接口报错仍然走老逻辑。(测试时,需要关注trace中是否走了新应用)。

  • 第三关:自动化用例

    出价域内沉淀了比较完善的接口自动化用例。在人工测试时,测试同学可以借助自动化能力,完成对迁移接口的回归功能验证。

    同时在发布前天,组内会特地多跑一轮全量自动化。一次是迁移接口开关全部打开,一次是迁移接口开关全部关闭即正常的自动化回归。然后全员进行排错。

    全量的自动化用例执行,对迁移接口问题拦截,有比较好的效果。因为会有一些功能点,人工测试时关联功能未考虑到,但在接口自动化覆盖下无所遁形。

  • 第四关:流量回放

    在拆分接口开关打开的情况下,在预发环境进行流量回放。

    线上录制流量的数据往往更加复杂,经常会测出一些意料之外的问题。

    迭代过程中,我们组内仍然会在沿用两次回放。迁移接口开关打开后回放一次,开关关闭后回放一次。(跟发布配置保持一致)。

  • 第五关:灰度过程中,关闭接口开关,功能回滚

    为保证线上生产质量,在迁移接口小流量灰度过程中。我们持续监测线上问题告警群。

    以上,就是出价域测试团队,针对服务拆分的测试流程。同时遵循可回滚的发布标准,拆分接口做了非常完善的灰度功能。下一段落进行介绍。

六、各流量类型灰度切量方案

出价流程切新应用灰度控制从几个维度控制:总开关,出价类型范围,channel范围,source范围,bidSource范围,uid白名单&uid百分比(0-10000):

  • 灰度策略
  • 支持 接口维度 ,按照百分比进行灰度切流;

  • 支持一键回切;

Dubbo接口、HTTP接口、TOC任务迁移、DMQ消息迁移分别配有不同的灰度策略。

七、结语

拆分的过程中,伴随着很多迭代需求的开发。为了提高迁移效率,我们会在需求排期后,并行处理迭代功能相关的接口,把服务拆分和迭代需求一起完成掉。

目前,我们的拆分已经进入尾声。迭代发布后,整体的技术项目就结束了。灰度节奏在按预期节奏进行~

值得一提的是,目前我们的流量迁移仍处于第一阶段,即拆分应用出价域内灰度迁移,上游不感知。目前所有的流量仍然通过bidding服务接口进行转发。后续第二阶段,灰度验证完成后,需要进行上游接口替换,流量直接请求拆分后的应用。

往期回顾

1.大模型网关:大模型时代的智能交通枢纽|得物技术

2.从“人治”到“机治”:得物离线数仓发布流水线质量门禁实践

3.AI编程实践:从Claude Code实践到团队协作的优化思考|得物技术

4.入选AAAI-PerFM|得物社区推荐之基于大语言模型的新颖性推荐算法

5.Galaxy比数平台功能介绍及实现原理|得物技术

文 /寇森

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

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

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

如果你正在使用 Tiptap 做协同编辑器,那么我建议你使用 Monorepo 架构是最舒服的选择

作者 Moment
2026年2月5日 11:37

昨天把 DocFlow 重构成了 Monorepo 架构,主要是为了解决协同编辑中的 Schema 同步问题。

20260205105346

项目使用 Tiptap 做协同编辑,自定义节点较多,而 Yjs 传递的是二进制数据。像警告框 Alert 这类自定义节点,在前端是具体的 UI 组件,但在 Hocuspocus 后端必须有对应的 Transformer 逻辑,才能将二进制数据准确还原成 JSON 或 HTML。

没有 Monorepo 时,每加一个新功能(如 alert.ts),都要在前端和后端分别维护一套 Schema。一旦漏掉同步,后端解析时就不认识这个节点,辛辛苦苦存的数据可能直接丢失。

采用 Monorepo 后,架构清晰多了:

  • 原子化解耦:每个自定义节点如 @syncflow/alert 都是独立包,职责单一,
  • 逻辑共享:transformer 包统一组装这些节点,导出一个全能的解析器
  • 多端复用:前端编辑器用它来渲染,后端 Hocuspocus 用它做数据转换

最终实现一套 Schema 定义,全链路通用,改一下 alert.ts 的规则,全端自动生效,维护效率大幅提升。

❌
❌