阅读视图

发现新文章,点击刷新页面。

群晖 DSM 更新后 Cloudflare DDNS 失效的排查记录

前言

前两天我的群晖 NAS 提示 DSM 有新版本更新。由于已经好久没更新,一瞄发行说明发现新 feature 和 bugfix 还挺多,想着时间也不短了,那就顺手更一下吧。

系统更新得倒是很顺利,更新后啥异常也没发现,内网/外网访问都正常,我也就没再管。

直到昨晚事情才开始不对:我突然发现通过 DDNS 完全无法访问 NAS 上的任何应用。不过当时家里网络卡得要命,我也没太当回事,只以为是网络抽风。

但今天一早起来网络速度恢复,可域名依然不通,这时候我才知道绝对不是网络速度的问题了,这才开始正式排查。

排查链路复盘

下面是我这次遇到问题后的完整排查过程。一方面是给可能遇到同样坑的朋友提供一个参考思路,另一方面也是给自己做个记录,方便以后再遇到类似情况时能更快定位问题。

1. 先确认 NAS 服务本身是否正常

第一步很简单: 通过内网 IP 访问 NAS,一切正常,系统服务都在。

2. 再确认公网访问是否正常

用家里的 公网 IP 直连 NAS 暴露出去的端口,能通。说明 ISP 没问题,端口也没被封。

3. 检查群晖 DSM 自带的 DDNS 状态

打开 控制面板 → 外部访问 → DDNS

果然看到 DDNS 状态一直卡在 “正在连接...”, 点“测试连接”,转半天圈,最后报 连接超时

至此基本确认问题出在 DDNS。

4. 怀疑是否是 NAS 上的代理导致的问题

我之前遇到过: 只要开了系统代理 → 重启 NAS → 自定义 DDNS 会无法连接

必须是:

  1. 先连上 DDNS
  2. 再手动开启系统代理

这很奇怪,但确实存在这个问题,我也一直没有去解决,所以这次我也先排查了一遍代理设置,结果发现代理是关闭的,这条可能性排除了。

5. 怀疑 Cloudflare 又宕机?

突然想起前两天 Cloudflare 宕机,难不成又宕机了?

我登录 CF 控制台看了眼,没有任何告警。为了确认,我还顺手访问了几个托管在 Vercel、经过 Cloudflare 的服务,一切正常,说明 Cloudflare 没锅。

6. 终于把怀疑对象指向 DSM 更新

这时我不禁想起了福尔摩斯法则:

“当你排除了所有不可能的,剩下的即使再离谱,也必然是真相。”

我先是在 Nas 上添加 DDNS 的地方看了一下服务提供商,果然,没有之前自定义的 Cloudflare 了。

我回忆了一下,群晖默认不支持 Cloudflare DDNS,需要自己手动部署第三方脚本,比如很多玩家都用的 SynologyCloudflareDDNS

但我这是两三年前配置的了,早忘得干干净净,只记得当时是用 SSH 登进去扔了些脚本、改了 ddns_provider 配置,我只好去网上重新查教程。

7. 转折:ddns_provider 配置“消失了”

网上的做法都差不多,我也照着检查了一下 NAS 的 /etc/ddns_provider.conf

结果一打开就发现:我之前手动添加的 Cloudflare provider 配置彻底没了。

八成是这次 DSM 更新覆盖了系统文件,把自定义的 DDNS provider 给清干净了。

于是我把缺失的配置重新补上、保存,然后再回到 DSM 的 DDNS 界面一看,果然有变化: 列表里多出了一个“访问 DDNS 供应商的网站”的按钮。

这说明新增的 provider 已成功加载(因为在 ddns_provider.conf 里设置 website 字段后,DSM 会自动在面板中显示这个入口)。

Pasted image 20251122094435.png

再测试连接,结果:还是失败。

8. 再一查:原先放在 /sbin 的脚本也没了

我继续顺着配置里引用的脚本路径查下去(就是 SynologyCloudflareDDNS 的核心脚本)。

结果:指定路径下根本没有脚本! 我甚至怀疑是不是我之前放在其他地方了,于是全盘搜索了一遍。

找不到,根本找不到,基本可以确认整个 /sbin 目录都被更新重置了。

至此终于搞清楚问题根因:

DSM 更新时覆盖了系统目录,导致自定义 DDNS Provider 配置 + 脚本全部消失。

9. 修复:重新上传脚本 + 重新配置 provider

我重新下载脚本,上传到 /sbin,给可执行权限,再更新 ddns_provider 配置。

测试连接,这次成功了,DDNS 恢复正常!

10. 为什么前两天还能用?

现在回头一想,为什么更新当天没问题?

很简单:因为我的公网 IP 那几天没变。 DDNS 虽然挂了,但只要 Cloudflare 上的 DNS A 记录还指向老 IP,访问就不会出问题。

直到昨天公网 IP 有一点点变动(可能只差一两位),我登陆 CF 又没仔细看 A 记录,这才导致域名完全失效。

总结

这次问题的根因其实非常隐蔽:

  • DSM 更新 → 覆盖系统目录
  • 自定义 DDNS Provider 配置被删
  • /sbin 下的 Cloudflare 更新脚本也清空
  • 但因为公网 IP 没变,所以问题延后了两天才暴露
  • 最后一度误以为是 Cloudflare、代理或网络的问题

最终的解决办法就是:重新放回脚本 + 重写 ddns_provider.conf。

百度大数据成本治理实践

导读

本文概述了在业务高速发展和降本增效的背景下百度MEG(移动生态事业群组)大数据成本治理实践方案,主要包含当前业务面临的主要问题、计算数据成本治理优化方案、存储数据成本治理优化方案、数据成本治理成果以及未来治理方向的一个思路探讨,为业界提供可参考的治理经验。

01 背景

随着百度各业务及产品的快速发展,海量的离线数据成本在持续地增长。在此背景下,通过大数据治理技术来帮助业务降本增效,实现业务的可持续增长变得至关重要。我们通过对当前资源现状、管理现状以及成本现状三个角度进行分析:

  • 资源现状:各个产品线下业务类型繁多,涉及的离线AFS(百度公司分布式文件存储:Appendonly File Storage)存储账号和EMR(百度公司全托管计算集群:E-MapReduce EMR+)队列数量非常多,成百上千,什么时候启动治理,采用什么手段治理,并没有明确规划,且各业务间缺少统一的治理标准。

  • 管理现状:针对离线资源的使用参差不齐,存储账号和计算队列资源的管理和使用较为混乱,有的使用率高,有的使用率低;此外,业务间的离线作业管理并不统一且不完全规范,没有完善的流程机制以及规范来对离线资源以及作业进行管理管控,并且计算任务的执行效率较低,整体运维难度较大。

  • 成本现状:MEG各个产品线离线计算资源达数千万核,存储资源达数千PB,各产品线的离线计算和存储资源成本每年可达数亿元,随着业务的增长,如果不进行成本治理和优化,离线资源成本还会持续增加。

整体来说,目前主要面临数据散乱、资源浪费、成本增加等问题。基于以上存在的问题,我们通过构建统一的治理标准,并利用大数据资源管理平台搭建各产品线下的的离线存储资源视图、计算资源视图、任务视图以及成本视图,基于引擎能力对存储和计算进一步优化,帮助MEG下各产品线下的业务持续进行数据成本治理,接下来将具体阐述我们在大数据成本治理过程的实践方案。

图片

△ 数据成本治理现状

02 数据成本治理实践方案

2.1 数据成本治理总体框架


针对目前存在的问题,我们主要围绕数据资产度量、平台化能力以及引擎赋能三个方面构建数据成本治理总体框架,实现对计算和存储两大方向的治理,来达到降本增效的目的,具体如下图所示,接下来将进行具体地介绍。

图片

△ 数据成本治理总体框架

2.1.1 数据资产健康度量

为了对当前各个业务的计算和存储资源进行合理的评估和治理,我们采用统一的标准:健康分来进行衡量,而健康分计算的数据指标来源依赖于离线数据采集服务,该服务通过对当前计算队列,计算任务,存储账号,数据表等元数据信息进行例行采集,然后再进一步对于采集的数据进行分析和挖掘,形成一个个计算治理项和存储治理项,比如计算治理项可包含:使用率不均衡的计算队列个数、长耗时高资源消耗的计算任务、数据倾斜的任务以及无效任务等;存储治理项可包含1年/2年/N年未访问的冷数据目录、存储目录生命周期异常、inode占比过低以及未认领目录等。通过产生的数据治理项信息汇聚形成计算健康分和存储健康分两大类,如下:

  • 计算健康分:基于队列使用平均水位+队列使用均衡程度+计算治理项进行加权计算获取。

  • 存储健康分:基于存储账号使用平均水位+存储账号使用峰值+冷数据占比+治理项进行加权计算获取。

最终,通过统一规范的健康分来对当前各产品线下业务所属的数据资产进行度量,指导业务进行规范化治理。

2.1.2 平台化能力

此外,为了完成对当前产品线下离线计算和存储资源的全生命周期管理,我们通过搭建大数据资源管理平台,完成对各个产品线的离线计算资源和存储资源的接入,并基于平台能力为业务构建统一的计算视图、存储视图以及离线成本视图,整合离线计算任务需要的存储和计算资源,并将各类工具平台化,帮助业务快速发现和解决各类数据成本治理问题,具体如下:

  • 计算视图:包含各个计算队列资源使用概览和计算治理项详情信息,并提供计算任务注册、管控、调度执行以及算力优化全生命周期管理的能力。

  • 存储视图:汇聚了当前所有存储账号资源使用详情以及各类存储治理项信息,并提供给用户关于存储目录清理、迁移以及冷数据挖掘相关的存储管理以及治理能力。

  • 成本视图:构建各个产品线下关于离线存储和计算资源总成本使用视图,通过总成本使用情况,更直观地展示治理成果。

2.1.3 引擎赋能

在实际离线大数据业务场景中,很多业务接口人对于大数据计算或者存储引擎的原理和特性不是非常熟悉,缺乏或者没有调优意识,通常在任务提交时没有根据任务的实际数据规模、计算复杂度以及集群资源状况进行针对性的参数调整,这种情况就会使得任务执行效率无法达到最优,且计算和存储资源不能得到充分的应用,进而影响业务迭代效率。针对上述计算和存储资源浪费的问题,我们结合大数据引擎能力,来实现对于计算和存储进一步地优化,助力业务提效,为业务的持续发展提供有力的支持。主要包含以下两个场景:

  • 计算场景:结合任务运行历史信息以及机器学习算法模型能力,建立一套完善的智能调参机制,对于提交的任务参数进行动态调整,最大程度保障任务在较优的参数下执行,进一步提升任务执行效率,并高效利用当前计算资源。

  • 存储场景:针对海量的存储数据,我们通过不同类型数据进行深入的分析和特征挖掘,实现了对存储数据智能压缩的能力,从而在不影响业务数据写入和查询的前提下,完成对现有数据存储文件的压缩,帮助业务节约存储资源和成本。

03 计算&存储数据成本治理优化

3.1 计算成本治理

在计算成本治理方向,我们主要基于平台和引擎能力,通过管理管控,混合调度以及智能调参三大方面对现有的计算资源和计算任务进行治理和优化。

3.1.1 管理管控

在MEG离线大数据场景下,主要涉及对上千EMR计算队列、以及上万Hadoop和Spark两大类型的计算任务管理。

  • 一方面,我们针对各个业务的计算队列和计算任务的管理,通过平台能力实现了从计算资源的注册接入,到计算队列和任务数据的采集,再到离线数据分析和挖掘,最终形成如使用率不均衡的计算队列、长耗时高资源消耗的计算任务、数据倾斜的任务、无效任务以及相似任务等多个计算治理项,并基于统一规范的健康分机制来对业务计算资产进行度量,指导业务对计算进行治理。

  • 另一方面,在离线混部场景,可能会存在部分用户对于任务使用不规范,影响离线例行任务或者造成资源浪费,我们针对Hadoop和Spark不同任务类型,分别建立了任务提交时和运行时管控机制,并结合业务实际场景,实现了并发限制、基本参数调优,队列资源限制以及僵尸任务等30+管控策略,对于天级上万的任务进行合理的管理管控,并及时挖掘和治理相关异常任务。目前运行的管控策略已经覆盖多个产品线下离线EMR计算队列上千万核,每天任务触发各种管控次数20万+。

通过对计算资源全生命周期的管理和管控,我们可以及时有效地发现可治理的队列或者任务,并推进业务进行治理。

图片

△ 任务管理管控流程

3.1.2 混合调度

通过对于平台接入的队列资源使用情况以及任务执行情况的深度分析,我们发现当前各个业务使用的计算资源存在以下几个问题:

1. 不同产品线业务特点不同,存在Hadoop和Spark两种类型计算任务,并且Hadoop任务CPU使用较多、内存使用较低,而Spark任务CPU使用较低,内存使用较高。

2. 有些队列整体资源使用率不高,但是存在部分时间段资源使用很满,不同队列资源使用波峰不完全一致,有的高峰在夜间,有的高峰在白天。

3. 存在队列碎片化问题,一些小队列不适合提交大作业且部分使用率不高。

为解决上述问题,我们建设Hadoop和Spark混合调度机制。针对公司不同业务来源的任务,基于Hadoop调度引擎以及Spark调度引擎完成各自任务的智能化调度,并通过调度策略链在多个候选队列中选择最优队列,最终实现任务提交到EMR计算集群上进行执行。具体流程如下:

  • 任务提交:针对不同产品线下的业务提交的Hadoop或者Spark任务,服务端会通过不同任务类型基于优先级、提交时间以及轮数进行全局加权排序,排序后的每个任务会分发到各自的任务调度池中,等待任务调度引擎拉取提交。

  • 任务调度:该阶段,调度引擎中不同任务类型的消费线程,会定时拉取对应任务调度池中的任务,按照FIFO的策略,多线程进行消费调度。在调度过程中,每个任务会依次通过通用调度策略链和专有调度策略链来获取该任务最优提交队列,其中,通用和专有调度策略主要是计算队列资源获取、候选队列过滤、队列排序(数据输入输出地域,计算地域)、队列资源空闲程度以及高优任务保障等20+策略。比如某任务调度过程中,请求提交的队列是A,调度过程中存在三个候选队列A、B、C,其中候选队列A使用率很高,B使用率中等且存储和计算地域相差较远,C使用率低且距离近,最终通过智能调度可分配最优队列C进行提交。

  • 任务执行:通过调度引擎获取到最优队列的任务,最终会提交到对应的EMR计算集群队列上进行执行,进而实现各个队列的使用率更加均衡,并提高低频使用队列的资源使用率。

图片

△ 任务混合调度流程

3.1.3 智能调参

在数据中心业务场景,多以Spark任务为主,天级提交的Spark任务5万+,但这些任务执行过程中,会存在计算资源浪费的情况,具有一定的优化空间,我们通过前期数据分析,发现主要存在以下两类问题:

1. 用户没有调优意识,或者是缺乏调优经验,会造成大量任务资源配置不合理,资源浪费严重,比如并发和内存资源配置偏大,但实际可以继续调低,如case示例1所示;

2. 在Spark计算引擎优化器中, 只有RBO(Rule-Based Optimizer)和CBO(Cost-Based Optimizer)优化器, 前者基于硬规则,后者基于执行成本来优化查询计划,但对于例行任务, 只有RBO和CBO会忽略一些能优化的输入信息,任务性能存在一定的瓶颈,如case示例2所示。

图片

△ 任务参数配置case示例

针对第一类问题,我们实现了对Spark任务基本参数智能调优的能力,在保证任务SLA的情况下,结合模型训练的方式,来支持对例行任务长期调优并降低任务资源消耗。每轮任务例行会推荐一组参数并获取其对应性能,通过推荐参数、运行并获取性能、推荐参数的周期性迭代,在多轮训练迭代后,提供一组满足任务调优目标并且核时最少的近似最优参数,其中涉及的参数主要有spark.executor.instances, spark.executor.cores, spark.executor.memory这三类基本参数。具体实现流程如下:

1. 任务提交流:任务提交过程中,会从调优服务的Web Controller模块获取当前生成的调优参数并进行下发;

2. 结果上报流:通过任务状态监控,在任务执行完成后,调优服务的Backend模块会定时同步更新任务实际运行配置和执行耗时等执行历史数据信息到数据库中;

3. 模型训练流:调优服务的Backend模块定时拉取待训练任务进行数据训练,通过与模型交互,加载历史调优模型checkpoint,基于最新样本数据进行迭代训练,生成新的训练模型checkpoint以及下一轮调优参数,并保存到数据库中。

4. 任务SLA保障:通过设置运行时间上限、超时兜底、限制模型调优范围,以及任务失败兜底等策略来保障任务运行时间以及任务执行的稳定性。

最终,通过任务提交流程、结果上报流程、训练流程实现任务运行时需要的并发和内存基本参数的自动化调优,并基于运行时间保障和任务稳定性保障策略,确保任务的稳定性,整体流程框架如下图。

图片

△ 基本参数智能调优流程

针对第二类问题,我们构建HBO(History Based Optimization)智能调优模块实现对复杂参数场景的任务自动调优能力。首先,通过性能数据收集器完成对运行完成的Spark任务History的详情数据采集和AMP任务画像,然后在任务执行计划阶段和提交阶段,基于任务历史执行的真实运行统计数据来优化未来即将执行的任务性能,从而弥补执行之前预估不精确的问题,具体如下:

  • 执行计划调优阶段:主要进行Join算法动态调整、Join数据倾斜调整、聚合算法动态调整以及Join顺序重排等调优;

  • 任务提交阶段:基于任务运行特点智能添加或者改写当前提交的Spark任务运行参数,比如Input输入、合并小文件读、Output输出、拆分大文件写、Shuffle分区数动态调整以及大shuffle开启Kryo Serialization等参数,从而实现对运行参数的调优;

通过数据采集反馈和动态调参,不断循环,进而完成对于复杂参数场景的智能调优能力,让任务在执行资源有限的条件下,跑得更稳健,更快。整体实现流程图如下:

图片

△ HBO智能调优流程

3.2 存储成本治理


在百度MEG大数据离线场景下,底层存储主要是使用AFS,通过梳理我们发现目前针对离线使用的各个存储账号,缺乏统一管理和规范,主要存在以下几个核心问题:

  • AFS存储账号多且无归属:离散账号繁多,涉及目录数量多且大部分无Quota限制甚至找不到相关负责人,缺乏统一管理和规范;

  • AFS存储不断增加:不少业务对于数据存储缺少优化治理措施,且存在很多历史的无用数据,长期存放,导致数据只增不减;

  • 安全风险:各个账号使用过程中,数据随意读写甚至跨多个账户读写,安全无保障,并且缺少监控报警。

3.2.1 存储生命周期管理

针对上述问题,我们基于平台能力构建存储一站式治理能力,将存储资源的全生命周期管理分为五层:接入层、服务层、存储层、执行层以及用户层,通过建立存储资源使用规范,并基于采集的相关存储元数据,深度分析业务的离线AFS存储账号使用现状,将用户存储相关的问题充分挖掘和暴露出来,针对各种问题提供简单易用的通用化工具来帮助用户快速进行治理和解决,整体实现了各个集群存储账号的存储数据接入,采集,挖掘和分析,自动清理,监控预警全生命周期的管理。整体流程架构图如下:

图片

△ 存储生命周期管理流程

  • 接入层:通过建立规范的存储资源管理机制,比如存储的接入和申请规范、目录的创建规范、使用规范、利用率考核规范(Quota回收规范)以及冷数据的处理规范等通用化的规范,进而来完成用户从存储资源的接入-申请(扩容)、审核、交付、资源的例行审计的整体流程。

  • 服务层:基于离线服务完成对各集群存储目录&存储冷数据Quota采集,然后进一步对数据进行深入分析和挖掘,包含但不限于冷数据、异常目录使用、存储变化趋势以及成本数据等分析。

  • 存储层:建立账号、产品线、目录、任务、负责人以及账号的基本使用信息的元数据存储,通过Mysql进行存储,确保每个集群存储账号有对应归属;对于各个集群目录数据使用详情信息,选择Table(百度公司大规模分布式表格系统)进行存储。

  • 执行层:基于存储管理规范,对于各个集群存储账号进行每天例行的存储自动清理,数据转储和压缩,并提供完善的存储使用监控报警机制。

  • 用户层:通过平台,为用户构建不同维度的AFS存储现状概览视图,以及整合现有数据,对于各个集群的存储账号或目录进行分析,提供优化建议、存储工具以及API接口,帮助业务快速进行存储相关治理和存储相关问题的解决。

3.2.2 存储基础治理

在AFS存储资源的生命周期管理过程中,我们主要基于服务层和执行层为用户提供一套基础的存储AFS账号数据基础治理能力。通过离线解析Quota数据和冷数据目录相关的基础数据,完成对其计算、分析、聚合等处理,实现存储趋势变化、成本计算、异常目录分析、冷数据分析、数据治理项和治理建议等多方面能力支持。之后,用户便可结合存储数据全景视图分析和相关建议,进行存储路径配置、转储集群目录配置、压缩目录配置以及监控账号配置等多维度配置。基于用户的配置,通过后台离线服务定时执行,完成对用户存储的数据清理、空间释放和监控预警,保障各个业务存储账号的合理使用以及治理优化。

图片

△ AFS存储数据基础治理流程

3.2.3 智能压缩

平台侧管理的MEG相关AFS存储数据上千PB,存在一部分数据,是没有进行相应的压缩或者压缩格式设置的并不是非常合理,我们通过结合业务实际使用情况,针对业务存储数据进行智能压缩,同时不影响数据读写效率,进一步优化降低业务存储成本,主要实现方案流程如下图。由于业务场景不同,我们采用不同的压缩方案。

  • 针对数仓表存储数据场景:首先是通过对采集的数仓表元数据信息进行数据画像,完成表字段存储占比和数据分布情况分析,之后基于自动存储优化器,实现对数仓表分区数据读取、压缩规则应用以及分区数据重写,最终完成对数仓表数据的自动压缩,在保证数仓表读写效率的前提下,进一步提升数据压缩效率,降低存储数据成本。其中压缩规则应用主要包含:可排序字段获取、重排序优化、ZSTD压缩格式、PageSize大小以及压缩Leve调整等规则。

  • 针对非数据仓表存储数据场景:在该场景下的存储数据,一般是通过任务直接写入AFS,写入方式各种各样,因此,需要直接对AFS存储数据进行分析和挖掘。我们首先对这部分数据进行冷热分层,将其分为冷数据、温数据以及热数据,并挖掘其中可进行压缩和压缩格式可进一步优化的数据,以及压缩配置可进一步调整的任务;之后,通过自动存储优化器,针对增量热数据,基于例行写入任务历史画像选择合适的压缩参数进行调优,并记录压缩效果;针对存量温冷数据,定期执行离线压缩任务进行自动压缩;最终我们对热数据进行压缩提醒,温冷数据进行自动压缩,从而实现该类型存储数据的压缩智能优化。

图片

△ 智能压缩流程

04 治理成果

通过数据成本治理,我们取得了一些不错的优化和实践效果,主要包含数据开发和成本优化方面以及治理资产两大方面:

1. 数据开发和成本优化

  • 数据开发提效:基于离线资源的全生命周期管理,计算和存储资源交付效率从月级或者周级缩短至天级,效率大幅提升,进而降低数据开发周期,此外基于混合调度和智能调参等能力,任务排队情况大幅降低,数据产出时效性平均提升至少一倍,大幅提高数据开发效率。

  • 计算成本优化:实现了MEG下上千个队列使用更加均衡,并完成了千万核EMR计算队列资源平均使用率提升30%+,增量供给日常业务需求数百万核资源的同时,优化退订数百万核计算资源,年化成本可降低数千万元。

  • 存储运维提效:通过利用存储数据基础治理等能力,完成了对上千个AFS存储账号管理、无用数据挖掘和清理,以及监控预警等,使得存储账号的运维更加可控,效率大幅提升。

  • 存储成本优化:实现了对MEG下上千PB存储资源整体使用率平均提升20%+,增量供给日常业务需求数百PB资源的同时,优化退订数百PB存储资源,年化成本同样降低数千万元。

2. 治理资产

  • 数据开发规范:逐步完善了资源交付规范、计算任务开发规范、存储和计算资源使用规范、以及数据质量和安全规范等多种规范流程。

  • 计算&存储资源成本:形成各个产品线下关于计算资源、存储资源以及成本使用详情的概览视图,对于资源使用和成本变化趋势清晰可见。

  • 数据任务资产:基于任务历史画像,构建任务从提交到运行再到完成的全生命周期的执行详情数据概览视图,帮助业务高效进行任务管理。

  • 数据治理项:通过数据挖掘和分析形成的计算任务,计算队列和存储账号相关的治理项详情数据看板,助力业务快速发现可治理的数据问题。

05 未来规划

目前,通过标准化、平台化以及引擎化的技术能力,进一步完成了对MEG下离线存储和计算资源管理和数据成本治理,并取得一定治理成果,但数据成本治理作为一个长期且持续的一项工作,我们将持续完善和挖掘数据成本治理技术方案,并结合治理过程中的经验、流程和标准,实现更规范、更智能化的治理能力。

全面实测 Gemini 3.0,前端这回真死了吗?

本期视频:www.bilibili.com/video/BV1gP…

众所周知,每次有新的模型发布前端都要失业一次,前端已经成为了大模型编程能力的计量单位,所以广大前端朋友不要破防哈!至于这次是不是真的,我们实战测评后再见分晓。

大家好,欢迎来到 code秘密花园,我是花园老师(ConardLi)。

就在我们还在回味上周 OpenAI 发布的 GPT-5.1 如何用“更有人情味”的交互惊艳全场,还在感叹9月底 Claude 4.5 Sonnet 在编程领域的统治力时,Google 在昨夜(11月18日)终于丢出了它的重磅炸弹 —— Gemini 3.0

“地表最强多模态”、“推理能力断层领先”、“LMArena 首个突破 1500 分的模型” …… Google 这次不仅是来“交作业”的,更是直接奔着“砸场子”来的。

Sundar Pichai 在 X 上自信宣称:“Gemini 3.0 是世界上最好的多模态理解模型,迄今为止最强大的智能体 + Vibe Coding 模型。它能将任何想法变为现实,快速掌握上下文和意图,让您无需过多提示即可获得所需信息。”

这个牛吹的还是挺大的。Gemini 3.0 真的有这么强吗?我熬夜实测了 Gemini 3.0 Pro 的编程能力,挖掘了大量细节,为你带来这篇最全解读。以下是本期内容概览:

榜单解读

盲测打分

我们先来看一下官方放出的榜单,是不是非常炸裂,除了 SWE-Bench 没能打过 Claude Sonnet 4.5,大部分测试简直是全面屠榜,甚至有些是断崖式领先:

https://lmarena.ai/leaderboard

在 LMArena(大模型竞技场) 榜单中,Gemini 3.0 Pro 以 1501 Elo 的积分空降第一,这是人类历史上首个突破 1500 分大关的 AI 模型!

LMArena 是由 LMSYS 组织的大众盲测竞技场。用户输入问题,两个匿名模型回答,用户凭感觉选哪个好。它代表了 “用户体验”和“好用程度”。 很多榜单跑分高的模型不一定真的好用,但 Arena 分高一定好用,因为它是大众凭真实感觉选出来的。Gemini 3.0 突破 1500 分,说明在大众眼中,它的体感确实有了质的飞跃。

推理能力

GPQA Diamond 91.7% 的分数非常恐怖,这代表它在生物、物理、化学等博士级别的专业问题上,正确率极高。在 Humanity’s Last Exam(当前最难的测试基准,号称 AI 的 "终极学术考试")中,在不使用任何工具的情况下达到 37.5% 。

https://www.vals.ai/benchmarks/gpqa

GPQA Diamond (Graduate-Level Google-Proof Q&A) 是一套由领域专家编写的、Google 搜不到答案的博士级难题。它是目前衡量AI“智商”的最硬核指标。 只有真正的推理能力,才能在这里得分。Gemini 3.0 能跑到 90% 以上,意味着它在很多专业领域的判断力已经超过了普通人类专家。

视觉理解

Gemini 系列一直以原生多模态(Native Multimodal)著称,Gemini 3.0 更是将这一优势发挥到了极致,它在 MMMU-Pro 和 Video-MMMU 上分别斩获了 81% 87.6% 的高分,全面领先其他模型。

MMMU 是聚焦大学水平的多学科多模态理解与推理基准。MMMU-proMMMU 的升级强化版,通过过滤纯文本问题、将选项增至10个、引入问题嵌于图像的纯视觉输入设置,大幅降低模型猜测空间,是更贴近真实场景的严格多模态评估基准。

其他基准

另外,在 ARC-AGI-2、ScreenSpot-Pro、MathArena Apex 等基准上更是数倍领先其他模型:

  • MathArena Apex 的题目是年全球顶级奥数比赛的压轴题,难度和 IMO(国际数学奥林匹克)最高级别相当。之前主流 AI 模型做这些题,得分都低于 2%,直到 Gemini 3 Pro 交出 23.4% 的成绩。
  • ARC-AGI-2 是 ArcPrize 基金会 2025 年推出的通用智能测试,能重点考察 AI 的组合推理能力和高效解题思路,还通过成本限制避免 AI 靠 “暴力破解” 得分。
  • ScreenSpot-Pro 是 2025 年新出的专业 GUI 视觉定位测试工具。它的核心任务是让 AI 精准找到界面上的 UI 元素,比如按钮、输入框等。目前多数模型的原始准确率不到 10%,而 Gemini 3 Pro 凭借 72.7% 的准确率创下了当前纪录。

这个榜单看着确实挺恐怖的,实际效果如何,我们一起来测试一下。

使用方法

以下四个位置目前均可以免费使用 Gemini 3.0:

  1. 打开 Google Gemini App 或网页版,可以直接体验 Gemini 3.0,仅限基础对话和简单工具调用,普通 Google 账号即可:

gemini.google.com/app

  1. Google AI Studio Playground ,API 已经开放 Preview 版本(gemini-3-pro-preview)可以更改模型参数,进行基础对话和工具调用:

aistudio.google.com/prompts/new…

  1. Google AI Studio Build ,一个专业的 AI 建站平台,类似 V0,可以编写复杂的前端应用:

aistudio.google.com/apps

  1. Google Antigravity,Google 推出的全新 AI IDE,对标 Cursor。

目前可以直接白嫖 Gemini 3 ProClaude Sonnet 4.5(不过需要美区 Google 账号):

中文写作

我们先来进入 Google Gemini 网页版,测试一下最基础的中文写作能力,我们在右下角切换到 Thinking 模式,即可使用最新的 Gemini 3.0 的推理能力:

我们来让他调研一下昨天比较火的 Cloudflare 宕机事件,并且生成一篇工作号文章,输入如下提示词:

调研最新的 Cloudflare 崩溃事件,然后编写一篇公众号文章来介绍这个事件。注意文章信息的真实性、完整性、可读性。

可以看到,它进行了非常长并且有条理的推理:

然后开始输出正文,先给出了公众号的推荐标题和摘要:

以下是完整的文章,基本没什么 AI 味:

接下来,我们再看看我们的老朋友豆包的生成效果:

大家觉得哪个文笔好一点呢,可以自行评判一下。

开发实测

下面,我们开始测试开发能力,这时我们可以到 Google AI Studio 的 Build 功能,这其实是一个在线的 AI Coding 工具,帮你快速把想法变成可运行的网页。

测试1:物理规律理解

我们先来一个非常经典的测试:

::: block-1 实现一个弹力小球游戏:

  • 环境设置:创建一个旋转的六边形作为小球的活动区域。
  • 物理规律:小球需要受到重力和摩擦力的影响。
  • 碰撞检测:小球与六边形墙壁碰撞时,需要按照物理规律反弹。 :::

理解物理规律一直是众多模型的最大难题之一,所以每次有新的模型出现这都是我首要测试的题目。可以看到,Gemini 依然首先给出了非常详细且有条理的思考:

然后开始编写代码,我们可以切换到 Code,可以看到实时的代码生成,输出速度还是非常快速。一个很明显的区别,在 Build 模式下生成的代码并不是简单的 HTML,而是一个含有多个文件的 React + TS 的应用,这就给了它更高的上限,可以编写非常复杂的网页应用,并且写出的代码也会更容易维护。

生成完成了,我们来看一下效果,可以发现 Gemini 对物理规律的理解是非常不错的,而且页面样式和交互体验也不错。

在生成完成后,我们可以继续对网站提出改进意见让它继续迭代,还可以直接更改网页的代码,还是非常方便的。

测试2:小游戏开发

提示词:请你帮我编写一款赛博朋克风格的马里奥小游戏,要求界面炫酷、可玩性高、功能完整。

最终效果(经过三轮迭代,耗时 8 分钟左右):

游机制还原度还是非常高的,运行效果也很流畅,文章里就不放视频了,具体效果大家可以到 B 站视频中去看。

测试3:3D效果开发

开发一个拥有逼真效果的 3D 风扇 网页,可以真实模拟风扇的运行

最终效果(经过两轮迭代,耗时 5 分钟左右)

这个风扇生成的还是很逼真的,支持开关、调整风扇转速、摇头。甚至还是个 AI 智能风扇,可以直接跟风扇语音对话让他自己决定如何调整转速 ...

测试4:UI还原能力

提示词:帮我编写一个网站,要求尽可能的还原给你的这两张设计图

设计稿原图:

一轮对话直接完成,耗时 3 分钟左右:

最终还原效果:

这效果,基本上算是 1:1 直接还原了,并且界面上的组件都是可交互的,这个必须点赞。

测试5:使用插件开发

在 Build 模式下,我们还可以直接选择官方提供的各种插件,比如前段时间比较火的 Nano Banana(Gemini 的生图模型),以及 Google Map、Veo 等服务:

我们来尝试使用 Nano Banana 生成一个在线的 AI 图片处理网站:

提示词:创建一个在线的 AI 图片处理应用,可以支持多项图片处理能力,页面炫酷、交互友好。

最终效果(经过三轮迭代,耗时 6 分钟左右)

效果非常不错,支持拖动对比图片处理前后的效果,还支持对图片局部进行处理:

测试6:I'm feeling lucky

在 Build 模式下,还有个非常有意思的功能,I'm feeling lucky,点击这个按钮,它会自动帮我生成一些项目灵感,如果你支持想尝试一下 Gemini 3.0 的强大能力,但不知道要做点啥,这就是一个不错的选择:

比如下面这个项目,就是我基于 AI 生成的灵感而创建的:

这是一个 AI 写作工具:支持通过输入提示词和文件附件,让 AI 协助创作内容;并要求 AI 对任意段落、句子等进行迭代优化;AI 也会智能主动介入 —— 当它判断时机合适时,主动提供反馈建议,支持嵌入式修改;

经过这几轮测试我们发现,Gemini 3.0 编写网站的能力确实非常强,不过这也离不开 Build 工具的加持,那脱离了这个工具后究竟效果如何呢,下面我们在本地 AI IDE 环境中来进行测试。

Gemini 3.0 PK Claude Sonnet 4.5

我们让 Gemini 3.0 来 PK 一下目前公认最强的编码模型 Claude Sonnet 4.5

为了保证公平的测试环境,我们使用本地的 AI IDE 来进行测试,可让两个模型拥有同样的调度机制和工具。

我们直接用 Google 这次和 Gemini 3.0 一起发布的 Antigravity 编辑器,这是一款直接对标 Cursro、Windsurf 的本地 AI 编辑器,可以直接白嫖 Gemini 3 ProClaude Sonnet 4.5

Antigravity 也是基于 VsCode 二次开发的,使用体验感觉也和 Cursor 差不多:

  • 输入 @ 可以选择文件、配置 MCP Server、配置 Global Rules 等功能;
  • Coding Agent 可以选择 PlanningFast 两种模式

目前支持选择以下五个模型,都是免费的:

  • Gemini 3 Pro (High)、Gemini 3 Pro (Low)
  • Claude Sonnet 4.5、Claude Sonnet 4.5 (Thinking)
  • GPT-OSS 120B (Medium)

题目1:项目理解能力:大型项目优化分析

第一局,我们来测试一下模型的项目理解能力,我们让他对一个大型的项目,进行整体的分析和产出优化建议,我们选择 Easy Dataset 这个项目。

理解当前项目架构,并告诉我本项目还有哪些需要改进的地方?(无需改动代码,先输出结论)

Gemini 3.0

这是 Gemini 3.0 的情况,它先进行了非常全面的分析,然后为最终的结论创作了一个单独的文件,使用英文编写:

Claude Sonnet 4.5

然后是 Claude 4.5 的分析过程:

最终结论直接输出到了聊天窗口:

对比结果

凭我个人对这个项目的理解,乍一看还是 Claude 4.5 生成的结果更准确,而且查看的文件也很关键,给出的建议也都是正确的。

为了公平的评判,下面我们有请 DeepSeek 老师来担当裁判:

最终结论,Claude Sonnet 4.5 胜出:

其实这里对 Claude 来讲还稍微有点不公平的,因为 Gemini 3.0 我们使用的是长思考模式,而 Claude 4.5 我们选择的是非思考模型,如果是 Claude 4.5 Thinking 模式,最终效果肯定还要更好一点。

题目2:架构设计能力:全栈项目编写

下面,我们再来测试一下综合的架构设计和编码能力,让它帮我们生成一个完整的全栈项目,既要兼顾某一个具体的技术设计,又要兼顾前后端的协作,需求如下:

设计并实现一个 Node.js 的 JWT 认证中间件,考虑安全性和易用性;设计对应的前端页面、业务接口来演示中间件的调用效果;创建 Readme 文档,并编写此中间件的架构设计、使用方式等。

Gemini 3.0

过程省略(感兴趣可以到视频里去看),直接上结果吧:

最后只生成了两个页面,一个登录页,一个登录之后的接口验证:

Claude Sonnet 4.5

Claude Sonnet 4.5 的结果明显就要更好一点了:

首先包含了完整的注册登录功能,在登录后,可以进行多种维度的接口验证:

对比结果

为了保证公平,我们还是要看一下代码具体写的怎么样,下面我们还是让 AI 来分析对比下这两个工程的代码:

最终对比结论还是 Claude Sonnet 4.5 完胜

题目3:前端编写能力:项目官网编写

第三局,我们偏心一点,来对比一下两者的纯前端编码的能力,因为毕竟是 Gemini 3.0 的实测,都输了也不太好,我们这次让他们从零调研并生成一个 Easy Dataset 的官网。

提示词:请你调研并分析这个项目的主要功能 github.com/ConardLi/ea… ,并为它编写一个企业级的官方网站。

Gemini 3.0

首先看 Gemini 3.0 的生成效果,列出的项目计划是这样的,然后中间中断,手动继续了一次,后使用 tailwindcss 的脚手架模版创建了这个项目,在最后的自动化测试环节也是没有完成的。

最终生成的效果是这样的,审美还是挺在线的,不过内容略显单薄了。

Claude Sonnet 4.5

然后我们来看 Claude 4.5 生成的结果,首先他生成的一份非常详细的开发计划,然后对 Easy Dataset 项目进行了调研,然后产出了一份调研报告后才开始开发。任务是一次就完成了,中间没有任何中断,然后他没有选择使用脚手架,而是从零创建了项目代码,最终也顺利完成了自动化测试。

然后我们来看最终的生成效果,这个看起来在视觉体验上就明显不如 Gemini 3.0 了。

但是,因为前期进行了非常充分的调研,所以网站的内容非常充实,基本上涵盖了所有关键信息。

对比结果

所以这最后一局可以说是各有优劣:

  • 视觉体验、项目代码的可维护性 Gemini 3.0 胜出;
  • 网站的内容丰富度,整个编写过程的丝滑程度 Claude 4.5 胜出;

所以这一局,我们判定为平局。

总结

最后我们来根据今天的实测结果总结一下结论。

Gemini 3.0 的前端能力确实超标,在小游戏开发,UI 设计稿还原,视觉效果开发这种对审美能力要求极高的需求中更是强的可怕。得益于 Gemini 原生多模态,以及强大的视觉理解能力,让他这种优势进一步放大了出来。

特别是在有了 AI Studio Build 这种工具的加持,让他在从零生成一个 Web 应用这个场景下更是是如虎添翼。另外,在指令遵循,需求理解的能力上,相比上一代的 Gemini 2.5 确实是有了很大幅度的增强。

但是,这足以让前端失业吗?

在实际的开发中,绘制 UI 可能只占很小一部分的工作。说到这,就不得不说我们的前端祖师爷,最近刚靠开发前端工具链融资了 8000 万啊,当之无愧的前端天花板了。

在后面的实战对比中,我们发现,在复杂项目上下文理解,全栈项目的架构设计和编写等实际开发工作中需要考虑的环节上,相比 ClaudeGemini 3.0 还是略逊一筹的,他依然无法撼动 ClaudeVibe Coding 领域的的霸主地位。

这个其实我们看榜单的 SWE Bentch 就看出来了,这是唯一一个被 Claude超越的指标,这个 Bentch 中包含了大量真实项目开发中要解决的 Issue ,能够衡量模型在真实编程环境中解决问题的能力。

所以这也能体现 Gemini 3.0 在真实的编程工作中并没有带来多大的提升,不过对于完全不会编程的小白来讲,确实可以让你们的想法更快也更好的变成现实了。

所以广大前端程序员不要慌,淘汰的是切图仔,关我前端程序员什么事呢?

不过这是玩笑话,广大程序员们确实应该居安思危了,就算不会在短时间内立刻失业,你们的竞争力确实是在实打实的流失的,其实很多行业也都一样,如果一直是在做简单的重复性工作,那未来被 AI 淘汰已是必然了。

最后

关注《code秘密花园》从此学习 AI 不迷路,相关链接:

如果本期对你有所帮助,希望得到一个免费的三连,感谢大家支持

一文解析得物自建 Redis 最新技术演进

一、前 言

自建 Redis 上线 3 年多以来,一直围绕着技术架构、性能提升、降低成本、自动化运维等方面持续进行技术演进迭代,力求为公司业务提供性能更高、成本更低的分布式缓存集群,通过自动化运维方式提升运维效率。

本文将从接入方式、同城双活就近读、Redis-server 版本与能力、实例架构与规格、自动化运维等多个方面分享一下自建 Redis 最新的技术演进。

二、规模现状

随着公司业务增长,自建 Redis 管理的 Redis 缓存规模也一直在持续增长,目前自建 Redis 总共管理 1000+集群,内存总规格 160T,10W+数据节点,机器数量数千台,其中内存规格超过 1T 的大容量集群数十个,单个集群最大访问 QPS 接近千万。

三、技术演进介绍

3.1 自建Redis系统架构

下图为自建Redis系统架构示意图:

自建 Redis 架构示意图

自建Redis集群由Redis-server、Redis-proxy、ConfigServer 等核心组件组成。

  • Redis-server 为数据存储组件,支持一主多从,主从多可用区部署,提供高可用、高性能的服务;
  • Redis-proxy 为代理组件,业务通过 proxy 可以像使用单点实例一样访问 Redis 集群,使用更简单,并且在Redis-proxy 上提供同区优先就近读、key 维度或者命令维度限流等高级功能;
  • ConfigServer 为负责 Redis 集群高可用的组件。

自建 Redis 接入方式支持通过域名+LB、service、SDK 直连(推荐)等多种方式访问 Redis 集群。

自建 Redis 系统还包含一个功能完善的自动化运维平台,其主要功能包括:

  • Redis 集群实例从创建、proxy 与 server 扩缩容、到实例下线等全生命周期自动化运维管理能力;
  • 业务需求自助申请工单与工单自动化执行;
  • 资源(包含 ECS、LB)精细化管理与自动智能分配能力、资源报表统计与展示;
  • ECS 资源定期巡检、自动均衡与节点智能调度;
  • 集群大 key、热 key 等诊断与分析,集群数据自助查询。

下面将就一些重要的最新技术演进进行详细介绍。

3.2 接入方式演进

自建 Redis 提升稳定性的非常重要的一个技术演进就是自研 DRedis SDK,业务接入自建 Redis 方式从原有通过域名+LB 的方式访问演进为通过 DRedis SDK 连接 proxy 访问。

LB接入问题

在自建 Redis 初期,为了方便业务使用,使用方式保持与云 Redis 一致,通过 LB 对 proxy 做负载均衡,业务通过域名(域名绑定集群对应 LB)访问集群,业务接入简单,像使用一个单点 Redis 一样使用集群,并且与云 Redis 配置方式一致,接入成本低。

随着自建 Redis 规模增长,尤其是大流量业务日渐增多,通过 LB 接入方式的逐渐暴露出很多个问题,部分问题还非常棘手:

  • 自建 Redis 使用的单个 LB 流量上限为5Gb,阈值比较小,对于一些大流量业务单个 LB 难以承接其流量,需要绑定多个LB,增加了运维复杂度,而且多个 LB 时可能会出现流量倾斜问题;
  • LB组件作为访问入口,可能会受到网络异常流量攻击,导致集群访问受损;
  • 由于Redis访问均是TCP连接,LB摘流业务会有秒级报错。

DRedis接入

自建Redis通过自研DRedis SDK,通过SDK直连 proxy,不再强依赖 LB,彻底解决 LB 瓶颈和稳定性风险问题,同时,DRedis SDK 默认优先访问同可用区 proxy,天然支持同城双活就近读。

DRedis SDK系统设计图如下所示:

Redis-proxy 启动并且获取到集群拓扑信息后,自动注册到注册中心;可通过管控白屏化操作向配置中心配置集群使用的 proxy 分组与权重、就近读规则等信息;DRedis SDK 启动后,从配置中心获取到 proxy 分组与权重、就近读规则,从注册中心获取到 proxy 节点信息,然后与对应 proxy 节点建立连接;应用通过 DRedis SDK 访问数据时,DRedis SDK 通过加权轮询算法获取一个 proxy 节点(默认优先同可用区)及对应连接,进行数据访问。

DRedis SDK并且对原生 RESP 协议进行了增强,添加了一部分自定义协议,支持业务灵活开启就近读能力,对于满足就近读规则的 key 访问、或者通过注解指定的就近读请求,DRedis SDK通过自定义协议信息,通知 proxy 在执行对应请求时,优先访问同可用区 server 节点。

DRedis SDK 目前支持 Java、Golang、C++(即将上线)三种开发语言。

  • Java SDK 基于 Redisson 客户端二次开发,后续还会新增基于 Jedis 二次开发版本,供业务灵活选择,并且集成到 fusion 框架中
  • Golang SDK 基于 go-Redis v9 进行二次开
  • C++ SDK 基于 brpc 二次开发

DRedis 接入优势

业务通过 DRedis SDK 接入自建 Redis,在稳定性、性能等方面都能得到大幅提升,同时能降低使用成本。

社区某应用升级后,业务 RT 下降明显,如下图所示:

DRedis 接入现状

DRedis SDK目前在公司内部大部分业务域的应用完成升级。

Java 和 Golang 应用目前接入上线超过300+

3.3 同城双活就近读

自建 Redis 同城双活采用中心写就近读的方案实现,可以降低业务多区部署时访问 Redis RT。

同城双活就近读场景下,业务访问 Redis 时,需要 SDK 优先访问同可用区proxy,proxy 优先访问同可用区 server节点,其中proxy优先访问同区 server 节点由 proxy 实现,但是在自研 DRedis SDK 之前,LB 无法自动识别应用所在同区的 proxy 并自动路由,因此需要借助service 的同区就近路由能力,同城双活就近读需要通过容器 proxy+service 接入。

自建 Redis 自研 DRedis SDK 设计之初便考虑了同城双活就近读需求,DRedis 访问 proxy 时,默认优先访问同区proxy。

service接入问题

目前,自建 Redis server 和 proxy 节点基本都是部署在 ECS 上,并且由于 server 节点主要消耗内存,而 proxy 节点主要消耗 CPU,因此默认采用 proxy + server 节点混部的方式,充分利用机器的 CPU 和内存,降低成本。

而为了支持同城双活就近读,需要在容器环境部署 proxy,并创建 service,会带来如下问题:

  • 运维割裂,运维复杂度增加,除了需要运维 ECS 环境部署节点,额外增加了容器环境部署方式。
  • 成本增加,容器环境 proxy 需要独立机器部署,无法与 server 节点混部,造成成本增加。
  • RT上升,节点 CPU 更高,从实际使用效果来看,容器环境 proxy 整体的 CPU 和响应 RT 都明显高于 ECS 环境部署的节点。
  • 访问不均衡,service 接入时,会出现连接和访问不均衡现象。
  • 无法定制化指定仅仅少量特定key 或者 key 前缀、指定请求开启就近读。

DRedis接入

自建 Redis 自研 DRedis SDK 设计之初便考虑了同城双活就近读需求,DRedis 访问 proxy 时,默认优先访问同区proxy;当同可用区可用 proxy 数量小于等于1个时,启用调用保护,DRedis会主动跨区访问其他可用区 proxy 节点。

通过service接入方式支持同城双活就近读,是需要在 proxy 上统一开启就近读配置,开启后,对全局读请求均生效,所有读请求都默认优先同区访问。

由于 Redis 主从复制为异步复制,主从复制可能存在延迟,理论上在备可用区可能存在读取到的不是最新数据

某些特定业务场景下,业务可能在某些场景能够接受就近读,但是其他一些场景需要保证强一致性,无法接受就近读,通过 service 接入方式时无法灵活应对这种场景。

DRedis SDK 提供了两种方式供这种场景下业务使用:

  • 支持指定 key 精确匹配或者 key 前缀匹配的方式,定向启用就近读。
  • Java 支持通过声明式注解(@NearRead)指定某次请求采用就近读;Golang 新增 80 个类似 xxxxNearby 读命令,支持就近读。

使用以上两种方式指定特定请求使用就近读时,无需 proxy 上统一配置同区优先就近读。默认情况下,所有读请求访问主节点,业务上对 RT 要求高、一致性要求低的请求可以通过以上两种方式指定优先同区就近读。

3.4 Redis-server版本与能力

在自建Redis 初期,由于业务在前期使用云Redis产品时均是使用Redis4.0 版本,因此自建 Redis 初期也是选择 Redis4.0 版本作为主版本,随着 Redis 社区新版本发布,结合当前业界使用的主流版本,自建Redis也新增了 Redis6.2 版本,并且将 Redis6.2 版本作为新集群默认版本。

不管是 Redis4.0 还是 Redis6.2 版本,均支持了多线程特性、实时热 key 统计能力、水平扩容异步迁移 slot 能力,存量集群随着日常资源均衡迁移调度,集群节点版本会自动升级到同版本的最新安装包。

  • 多线程特性

Redis6.2 版本支持 IO 多线程,在 Redis 处理读写业务请求数据时使用多线程处理,提高 IO 处理能力,自建 Redis 将多线程能力也移植到了 Redis4.0 版本,测试团队测试显示,开启多线程,读写性能提升明显。

多线程版本 VS 普通版本

多线程版本 VS 云产品5.0版本

  • 实时热 key 统计

自建 Redis4.0 和 Redis6.2 版本均支持 Redis 服务端实时热 key 统计能力,管控台白屏化展示,方便快速排查热 key 导致的集群性能问题。方案详细可阅读《基于Redis内核的热key统计实现方案》

  • 水平扩容异步迁移

自建 Redis 支持水平扩容异步数据迁移,解决大 key 无法迁移或者迁移失败的稳定性问题,支持多 key 并发迁移,几亿 key 数据在默认配置下水平扩容时间从平均 4 小时缩短到 10 分钟性能提升 20 倍,对业务RT影响下降 90% 以上

算法某实例 2.5 亿 key 水平扩容花费时间和迁移过程对业务 RT 影响

3.5 实例架构与规格

Redis单点主备模式

自建 Redis 实例默认均采用集群架构,但是通过 proxy 代理屏蔽集群架构细节,集群架构对业务透明,业务像使用一个单点 Redis 实例一样使用 Redis 集群。

但是集群架构下,由于底层涉及多个分片,不同 key 可能存在在不同分片,并且随着水平扩容,key所在分片可能会发生变化,因此,集群架构下,对于一些多 key 命令(如 eval、evalsha、BLPOP等)要求命令中所有 key 必须属于同一个slot。因此集群架构下,部分命令访问与单点还是有点差异。

实际使用中,有少数业务由于依赖了一些开源的三方组件,其中可能由于存储非常少量的数据,所以使用到 Redis 单点主备模式实例,因此,考虑到这种场景,自建 Redis 在集群架构基础上,也支持了Redis 单点主备模式可供选择。

一主多从规格

自建 Redis 支持一主多从规格用于跨区容灾,提供更快的 HA 效率,当前支持一主一从(默认),一主两从、一主三从 3 种副本规格,支持配置读写分离策略提升系统性能(一主多从规格下,开启读写分离,可以有多个分片承接读流量)

一主一从

一主两从

一主三从

  • 一主一从时默认主备可用区各部署一个副本(master在主可用区)
  • 一主两从时默认主可用区部署一主一从,备可用区部署一从副本
  • 一主三从时默认主可用区部署一主一从,备可用区部署两从副本

3.6 proxy限流

为了应对异常突发流量导致的业务访问性能下降,自建 Redis-proxy 支持限流能力

有部分业务可能存在特殊的已知大key,业务中正常逻辑也不会调用查询大 key 全量数据命令,如 hgetall、smembers 等,查询大 key 全量数据会导致节点性能下降,极端情况下会导致节点主从切换,因此,自建Redis 也支持配置命令黑名单,在特定的集群,禁用某些特定的命令

  • 支持 key 维度限流,指定 key 访问 QPS 阈值
  • 支持命令维度限流,指定命令访问 QPS 阈值
  • 支持命令黑名单,添加黑名单后,该实例禁用此命令

3.7 自动化运维

自建 Redis 系统还包含一个功能完善的自动化运维平台,一直以来,自建Redis一直在完善系统自动化运维能力,通过丰富的自动化运维能力,实现集群全生命周期自动化管理,资源管理与智能调度,故障自动恢复等,提高资源利用率、降低成本,提高运维效率。

  • 资源池自动化均衡调度

自建 Redis 资源池支持按内存使用率自动化均衡调度、按内存分配率自动化均衡调度、按 CPU 使用率均衡调度、支持指定机器凌晨迁移调度(隐患机器提前维护)等功能,均衡资源池中所有资源的负载,提高资源利用率。

  • 集群自动部署与下线

当业务提交集群申请工单审批通过后,判断是否支持自建,如符合自建则自动化进行集群部署和部署结果校验,校验集群可用性后自动给业务交付集群信息,整个过程高效快速。

业务提交集群下线工单后,自动检测是否满足下线条件,比如是否存在访问连接,如满足下线条件,则自动释放 proxy 资源,保留 7 天后自动回收 server 节点资源,在7 天内,如果存在特殊业务仍在使用的情况,还支持快速恢复使用。

  • 资源管理

对 ECS 机器资源和 LB 资源进行打标,根据特殊业务需要做不同资源池的隔离调度,支持在集群部署与扩容时,资源自动智能化分配。

  • 集群扩缩容

自建 Redis 支持 server 自动垂直扩容,业务申请集群时,可以选择是否开启自动扩容,如果开启自动扩容,当集群内存使用率达到80%时,系统会自动进行垂直扩容,对业务完全无感,快速应对业务容量上涨场景。

ecs-proxy,docker-proxy扩容,server节点的扩缩容也支持工单自动化操作,业务提交工单后,系统自动执行。

  • 工单自动化

当前80%以上的运维场景已完成工单自动化,如 Biz 申请、创建实例、密码申请、权限申请、删除key、实例升降配,集群下线等均完成工单自动化。业务提单审批通过后自动校验执行,执行完成后自动发送工单执行结果通知。

  • 告警自动化处理

系统会自动检测机器宕机事件,如发现机器宕机重启,会自动拉起机器上所有节点,快速恢复故障,提高运维效率。

关于自建 Redis 自动化运维能力提升详细设计细节,后续会专门分享,敬请期待。

四、总结

本文详细介绍了自建 Redis 最新技术演进,详细介绍了自研 DRedis SDK优势与目前使用现状,以及 DRedis 在同城双活就近读场景下,可以更精细化的控制部分请求采用优先同区就近读。

介绍了自建 Redis 目前支持最新的 Redis6.2版本,以及在 Redis4.0 和 Redis6.2 版本均支持多线程 IO 能力、实时热 key 统计能力、水平扩容异步迁移能力。自建 Redis 除了支持集群架构,也支持单点主备架构实例申请,同时支持一主多从副本规格,可以提供可靠性和读请求能力(读写分离场景下)。自建 Redis-proxy 也支持多种限流方式,包括 key 维度、命令维度等。

自建 Redis 自动化运维平台支持强大的自动化运维能力,提高资源利用率,降低成本,提高运维效率。

自建 Redis 经过长期的技术迭代演进,目前支持的命令和功能上完全对比云 Redis,同时,自建 Redis 拥有其他一些特色的能力与优势,比如不再依赖LB、支持自动垂直扩容、支持同区优先就近读等。

往期回顾

1. Golang HTTP请求超时与重试:构建高可靠网络请求|得物技术

2. RN与hawk碰撞的火花之C++异常捕获|得物技术

3. 得物TiDB升级实践

4. 得物管理类目配置线上化:从业务痛点到技术实现

5. 大模型如何革新搜索相关性?智能升级让搜索更“懂你”|得物技术

文 /竹径

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

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

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

后端代码部署到服务器,服务器配置数据库,pm2进程管理发布(四)

前置系列文章

从零开始:在阿里云 Ubuntu 服务器部署 Node+Express 接口(一)

阿里云域名解析 + Nginx 反向代理 + HTTPS 全流程:从 IP 访问到加密域名的完整配置(二)

Node+Express+MySQL 实现注册功能(三)

将代码和数据库发布到阿里云轻量服务器(Ubuntu)的核心流程是:本地准备 → 服务器环境配置 → 代码部署(含安全传输配置文件) → 数据库迁移 → 启动服务。以下是循序渐进的详细步骤,重点解决 .env.production 不提交到 Git 的问题:

一、本地准备工作(确保代码可部署)

1. 检查本地代码规范
  • 确认 .gitignore 配置:确保已忽略敏感文件,避免提交到 Git:
# 项目根目录的 .gitignore 文件必须包含以下内容
.env.development
.env.production
.env.test
node_modules/
2. 导出本地数据库结构(用于服务器初始化)

在mysql workBench中到出本地数据库mydb,只需要导出数据结构,会有一个sql文件导到本地,可以自己命名,我的叫my_db.sql

二、服务器环境准备(阿里云 Ubuntu)

1. 登录服务器

通过终端登录阿里云轻量服务器(替换为你的服务器公网 IP):

ssh root@你的服务器公网IP # 例如:ssh root@120.78.xxx.xxx

输入服务器登录密码

2. 安装必要软件

如果服务器未安装以下软件,依次执行:

# 更新系统
sudo apt update && sudo apt upgrade -y

# 安装 Node.js(推荐 v16+)
curl -fsSL https://deb.nodesource.com/setup_16.x | sudo -E bash -
sudo apt install -y nodejs

# 安装 MySQL 服务器(生产环境数据库)
sudo apt install -y mysql-server

# 安装 PM2(Node 服务进程管理工具)
sudo npm install pm2 -g

# 安装 Nginx(反向代理、静态资源服务)
sudo apt install -y nginx
3. 配置服务器 MySQL(生产环境数据库)
步骤一:初始化 MySQL 并创建生产数据库
# 登录MySQL(Ubuntu初始无密码,直接回车)
sudo mysql -u root -p

# 设置root密码(自定义强密码,如Server@Mysql2024)
ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '你的root密码';
FLUSH PRIVILEGES;

# 创建生产数据库(与项目.env.production一致,mydb_prod是数据库名)
CREATE DATABASE mydb_prod CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

# 创建数据库专用用户(避免root直接操作)
CREATE USER 'prod_user'@'%' IDENTIFIED BY 'Prod@2024';

# 授予用户数据库权限
GRANT ALL PRIVILEGES ON mydb_prod.* TO 'prod_user'@'%';

# 刷新权限
FLUSH PRIVILEGES;

# 退出MySQL
EXIT;

.env.production如下

# 环境标识
NODE_ENV=production

# 数据库配置(服务器数据库信息,变量名与开发环境完全一致)
NODE_ENV=production
DB_HOST=localhost  # 服务器数据库在本地,填localhost
DB_PORT=3306
DB_USER=prod_user  # 步骤3创建的专用用户
DB_PASSWORD=Prod@2024  # 专用用户的密码
DB_NAME=mydb_prod  # 生产数据库名
API_PORT=3000  # 后端服务端口

因为这个文件不能提交到git,所以要在服务器这个项目的根目录创建这个文件,并把上面的内容写进去就行。 我的是放在/root/projects/node-api-test下

image.png

步骤2: 导入本地表结构到服务器数据库
# 1. 在 Mac 终端另开窗口,上传本地 my_db.sql 到服务器的 /tmp 目录
scp /本地路径/my_db.sql root@你的服务器IP:/tmp/
# 例如:scp ~/project/my_db.sql root@120.78.xxx.xxx:/tmp/

# 2. 回到服务器终端,导入表结构到 mydb_prod 数据库
mysql -u root -p mydb_prod < /tmp/my_db.sql
# 输入服务器 MySQL 的 root 密码(步骤1中设置的),完成后表结构导入成功

服务器生产数据库中创建 users 表(表结构迁移)

在生产环境部署中,需将本地开发的 users 表结构迁移到服务器的 mydb_prod 数据库中,确保后端接口能正常读写用户数据。

服务器导入 users 表结构到 mydb_prod

登录服务器终端,将表结构导入生产数据库:

# 服务器终端执行(输入prod_user的密码Prod@2024) 
mysql -u prod_user -p mydb_prod < /tmp/my_db.sql
(4)验证表是否创建成功

登录服务器 MySQL,检查 users 表是否存在:

# 服务器终端登录MySQL
mysql -u prod_user -p mydb_prod

# 查看数据库中的表
SHOW TABLES;  # 应显示 users 表

# 查看表结构(可选)
DESCRIBE users;  # 显示 users 表的字段、类型等结构信息

# 退出MySQL
EXIT;

补充说明

  • 若本地 users 表有初始必要数据(如管理员账号),可去掉 --no-data 参数,导出包含数据的表结构:mysqldump -u 本地用户名 -p 本地数据库名 users > users_with_data.sql,再按相同步骤导入。
  • 表结构迁移是生产部署的关键步骤,确保服务器数据库表结构与本地开发环境一致,否则会出现 “表不存在”“字段缺失” 等接口错误。

发布代码

登录服务器,cd /root/xxx/xxx 到自己的项目根目录,执行git pull ,然后执行pm2 restart node-api-test

这时候会报错,提示环境变量是undefined,process.env.NODE_ENV 打印出的永远是undefined,所以下面这段代码,发布到服务器上就挂了

const env = process.env.NODE_ENV || 'development'
const envPath = path.resolve(__dirname, `../.env.${env}`)
console.log('envPath', envPath) 
// 会打印出undefined

原因分析

  1. 当你在服务器命令行直接输入 pm2 start app.js 时,Linux shell 并没有设置 NODE_ENV,代码里 process.env.NODE_ENV 就会变成 undefined,然后你的代码兜底逻辑将其设为 development,于是去读开发环境的配置,导致报错。

  2. app.js 方式启动: 执行 pm2 start app.js 时,PM2 只是简单地执行 node app.js。除非你在操作系统层面(如 /etc/profile)设置了全局 NODE_ENV,否则它就是空的。

解决方案:

使用 ecosystem.config.cjs(最推荐,工业标准

项目根目录创建ecosystem.config.cjs

// ecosystem.config.cjs
module.exports = {
  apps: [
    {
      name: 'node-api-test',
      script: './app.js',
 
      instances: 1,
      autorestart: true,
      watch: false,
      env: {
        NODE_ENV: 'development',
      },
      env_production: {
        NODE_ENV: 'production',
      },
    },
  ],
}

启动命令
以后在服务器上启动或重启,统一使用以下命令,不要再用 pm2 start app.js:

 #   首次启动(生产):
 pm2 start ecosystem.config.cjs --env production
 #    代码更新后重启 平滑重载(零停机):
 pm2 reload ecosystem.config.cjs --env production
 #   完全重启:
 pm2 restart ecosystem.config.cjs --env production

最终测试

image.png

Prisma 7 重磅发布:告别 Rust,拥抱 TypeScript,性能提升 3 倍

最近在使用 NestJs 和 NextJs 在做一个协同文档 DocFlow,如果感兴趣,欢迎 star,有任何疑问,欢迎加我微信进行咨询 yunmz777

2025 年 11 月 19 日,Prisma 团队正式发布了 Prisma ORM 和 Prisma Postgres 的最新版本——Prisma 7。在过去的一年中,Prisma 团队始终将开发者体验放在首位,致力于让使用 Prisma 构建应用程序变得更加简单、高效。无论开发者使用何种开发工具,或选择在哪个平台部署,Prisma 7 都将带来卓越的开发体验。

为未来而设计

愿景与承诺

2024 年 12 月,Prisma 团队发布了 Prisma ORM 的未来发展路线图,详细阐述了他们的愿景以及实现这些目标的计划。这不仅仅是一份产品规划文档,更是 Prisma 团队对 Prisma ORM 未来发展的明确承诺,以及他们如何持续支持开发者社区的坚定决心。

与此同时,Prisma 团队还推出了自己的托管 Postgres 服务——Prisma Postgres。这款产品以简洁性和高性能为核心设计理念,旨在为开发者提供一个开箱即用的数据库解决方案,让开发者能够享受到与 Prisma ORM 同样出色的开发者体验。

随着 ORM 路线图的发布和 Prisma Postgres 的推出,Prisma 团队为构建下一代工具奠定了坚实的基础。市场反馈超出了他们的预期:ORM 的市场份额和使用量实现了显著增长,而 Prisma Postgres 在个人项目和商业应用中的采用率也令人瞩目。

重大技术突破

从 Rust 到 TypeScript:一次大胆的架构转型

在 Prisma 6.0.0 发布时,Prisma 团队承诺将带来更好的性能、更强的灵活性以及更完善的类型安全性。为了实现这些目标,他们需要做出一些根本性的改变。

为什么选择离开 Rust?

Prisma 团队做出了一个看似反直觉的决定:将 Prisma Client 从 Rust 迁移到 TypeScript。对于许多人来说,这可能是一个令人困惑的选择,毕竟 Rust 以其卓越的性能而闻名。但在 Prisma 的场景下,性能只是故事的一部分。

使用 Rust 构建客户端的一个显著副作用是,它限制了社区贡献者的参与门槛。如果开发者没有深厚的 Rust 开发经验,想要为 ORM 做出有意义的贡献将变得非常困难。从技术角度来看,Rust 与 JavaScript 运行时之间的通信层比纯 JavaScript 实现要慢得多,同时还会在运行时层面引入额外的依赖关系。

Deno 团队的 Luca Casonato 对此深有感触:

"当我们听说 Prisma 要从 Rust 迁移时,我们意识到不再需要处理原生插件 API,这将让在 Deno 中支持 Prisma 变得简单得多。我们所有人都对此感到非常兴奋!"

迁移带来的显著收益

迁移到纯 TypeScript 客户端为更快的运行时、更小的体积和更简单的部署流程奠定了基础。开发者不再需要担心特定运行时的兼容性问题,也不必担心像 Cloudflare Workers 这样的基础设施提供商对应用大小的限制。

经过持续迭代和优化,Prisma 团队取得了令人瞩目的成果:

  • 体积减少 90%:打包输出大幅缩小
  • 性能提升 3 倍:查询执行速度显著加快
  • 资源占用降低:CPU 和内存利用率大幅下降
  • 部署更简单:Vercel Edge 和 Cloudflare Workers 的部署流程更加顺畅

这些改进都有详尽的基准测试数据支持。更重要的是,开发者无需重构整个应用程序就能享受到这些好处,迁移过程非常简单直接。

知名开发者 Kent C. Dodds 在体验后给出了积极反馈:

"我在几周前完成了升级,整个过程非常顺利。切换到新的无 Rust 客户端是如此简单,这真是太棒了。"

生成代码的新归宿:从 node_modules 到项目源码

工作流程的优化

Prisma 团队认真听取了社区关于生成代码处理方式的反馈,并进行了重大改进。此前,Prisma 习惯在项目的 node_modules 目录中生成客户端代码。虽然这样做让生成的客户端看起来像普通库一样,但随着时间推移,他们发现这种方式严重影响了开发者的工作流程。

当客户端需要更新时,所有应用相关的进程都必须先停止,然后才能重新生成类型。这不仅打断了开发流程,还降低了开发效率。

现在,Prisma 默认在项目源代码目录中生成类型和 Prisma Client。这样一来,开发者现有的开发和构建工具会将其视为应用程序的一部分。当开发者修改模型并运行 prisma generate 时,工具和文件监视器可以立即响应这些变化,保持开发工作流程的连续性。整个 Prisma 配置现在真正成为了项目的一部分,而不再是隐藏在 node_modules 中的"黑盒"。

全新的配置文件系统

Prisma 团队还引入了全新的 Prisma 配置文件,支持动态项目配置。这个配置文件的核心目的是将数据定义与 Prisma 的数据交互方式分离开来。

在此之前,项目配置分散在 Prisma schema 文件或 package.json 中。新的 Prisma 配置文件统一了配置管理,开发者可以在一个地方定义 schema 位置、种子脚本或数据库 URL。由于配置文件支持 JavaScript 或 TypeScript 格式,开发者可以使用 dotenv 等工具进行动态配置,这为开发者提供了更大的灵活性,也符合现代开发工具的标准。

类型系统的性能革命

与 ArkType 的合作

类型安全是 Prisma ORM 的核心优势之一。Prisma 团队不仅要确保提供正确的类型,还要以最高效的方式实现。为了改进类型生成系统,他们与 ArkType 的创建者 David Blass 展开了深度合作,全面评估了类型生成表现。

评估结果令人振奋:

  • Schema 评估类型减少 98%:大幅降低了类型系统的复杂度
  • 查询评估类型减少 45%:查询相关的类型开销显著降低
  • 类型检查速度提升 70%:完整的类型检查过程更加快速

为什么这很重要?

与生态系统中的其他 ORM 相比,Prisma 生成的类型不仅评估速度更快,而且需要更少的类型定义就能为开发者提供有用的 TypeScript 信息。这意味着开发者可以更自信地构建应用程序,因为 Prisma 提供的类型安全不仅快速,而且开销更小。

Prisma Postgres:为所有人打造的数据库服务

不仅仅是 ORM

Prisma 不仅仅是一个 ORM 工具。Prisma 团队推出的托管 Postgres 数据库服务旨在解决开发者在开始使用 Prisma ORM 时面临的首要问题——如何快速获得一个可用的数据库。

技术架构优势

Prisma Postgres 基于由 unikernel microVM 驱动的裸机基础设施构建。这意味着数据库服务不仅启动快速,而且能够持续保持高性能。Prisma 团队始终将开发者体验放在首位,自动处理所有复杂的运维工作,让开发者无需关心服务器配置、资源分配等底层细节。

所有配置和管理工作都由 Prisma 团队负责,Prisma Postgres 与 Prisma ORM 实现了原生集成,为开发者提供与 ORM 同样出色的开发者体验。

极简的启动流程

开始使用 Prisma Postgres 非常简单,只需在终端运行一个命令即可。系统会自动为开发者配置数据库,并提供认领链接。对于在开发工作流程中使用 AI 代理的开发者,Prisma 还提供了专用的 API 和 MCP 服务器,可以在需要时自动创建和管理数据库。

Jason Lengstorf 分享了他的使用体验:

"每当我使用 Prisma 时,我都会按照入门指南安装各种工具,然后才意识到我还需要去获取一个数据库。我总是在这个过程中感到困惑,所以能够如此轻松地创建数据库真是太棒了!"

标准协议支持

Prisma Postgres 并没有止步于简单的数据库托管。Prisma 团队现在采用了标准的 Postgres 连接协议,这意味着更广泛的生态系统工具都可以与数据库通信。无论是 Cloudflare Hyperdrive、TablePlus、Retool,还是其他 ORM 工具,都可以无缝使用 Prisma Postgres。

这一切都得益于 Prisma Postgres 基于标准 Postgres 构建,同时针对最佳使用体验进行了优化。

其他重要更新

这个版本包含了大量改进和新功能,如果一一详述,这篇文章会变得非常冗长。

Prisma 团队花费了大量时间处理积压的问题和功能请求,解决了一些最受社区关注的需求,包括:

  • 映射枚举支持
  • 更新了项目所需的最低 Node.js 和 TypeScript 版本
  • 全新版本的 Prisma Studio(通过 npx prisma studio 访问)

开发者可以在 Prisma 的更新日志中找到此版本的所有详细内容,Prisma 团队还提供了完整的迁移指南帮助开发者顺利升级。

结语

对 Prisma 团队而言,Prisma 7 不仅仅是一个版本发布,更是 Prisma ORM 和 Prisma Postgres 未来发展的基石。他们希望构建的工具能够为开发者提供最佳体验,让开发者能够专注于构建和发布出色的应用程序。

Prisma 团队特别感谢他们令人难以置信的社区,以及所有在预发布阶段提供宝贵反馈的开发者。如果开发者正在尝试 Prisma 7,可以向 Prisma 团队反馈想法和体验。

【开源】耗时数月、我开发了一款功能全面【30W行代码】的AI图床

AI编程发展迅猛,现在如果你是一个全栈开发,借助AI编辑器,比如Trae你可以开发很多你以往无法实现的功能,并且效率会大大提示,TareCoding能力已经非常智强了,借助他,我完成了这个30W行代码的开源项目,现在,想把这个项目推荐给你。

当前文章主要是介绍我们开发出的这个项目,在后续,将会新开一个文章专门介绍AI Coding的各种技巧,如何使用他,才能让他在大型项目中依然如虎添翼。

如果你想快速了解此项目、你可以访问PixelPunk官方文档

如果你想快速体验此项目、你可以访问V1版本,前往体验功能。

如果你想完整体验前后台全面的功能、你可以访问测试环境。【root 123456】

如果您认可我的项目,愿意为我献上一个宝贵的Star,你可以前往PixelPunk项目。

image.png

开发这样一个项目在现在这个时间来看似乎没什么必要,实际上开发这样一个项目的原因其实就是在年初的时候失业了一段时间,闲在家里无聊,于是想着做一个自己的开源项目,最开始其实做的是另一个类型的项目,开发了一段时间感觉有些无聊就转战做了现在的这个项目PixelPunk图床, 其实并不只是想要做一个图床,只是当前来说的一期功能比较贴合图床,后期的跌代准备支持更多格式包括视频文档等等,并且由于AI多模态模型的能力发展迅速,后期结合AI可以实现更多可玩性比较高的内容。

市面上已经有了非常多的开源图床了,开发这样一个项目要追求一些差异点才会有价值,本质上来说这类服务其实核心就是个存图片而已,其他功能并不是很重要,但是作为个人使用用户来说,除了存图也愿意去尝试一些便捷的功能,于是思考到这个点之后,我开始了这个项目的开发,项目的命名[PixelPunk] 是我让AI起的,因为要做就要做一个不一样的有特点,我要做一个ui上就不一样的图床出来,于是有了此命名,中文翻译过来是像素朋克,由于像素风格感觉ui不是很适合工具类网站使用,于是我选择了赛博朋克风格,围绕这个ui来开发了此项目。

项目概览

首先呢项目从开发就一个全栈项目,前后端放一起了,采用的技术栈是 go+vue, 前端会将打包的文件放入到go中一起打包为二进制安装包,这样部署起来将非常简单。 项目分为了用户端和管理端,并且同属于一个项目,不分离开发,为了符合我们定制化的ui,所有组件都是自定义的组件,项目使用了70+自定义组件。 项目接入了多模态AI大模型,在以前我们的图床要实现各种功能需要对接各种各样的平台,现在有了AI,我们只需要一个模型就能完成非常多的功能,比如【语义化描述图片】,【**图片OCR识别】,【**图片自动分类】,【**图片自动打标】,【**图标自动审核NSFW、违规图、敏感图、血腥图等等】,【图片颜色提取】等等功能都只需要配置一个AI模型即可,对于成本而言,比之前的三方API可能更加便宜

关于部署

作为一个开源项目,我想的是需要使用者使用起来足够简单,部署起来也足够快,本身来讲,我们项目需要用来这些内容,mysql|Sqlite + Redis|系统内存 + qdrant向量数据库, 这三件套,为了安装简单,我将向量数据库直接集成到安装包,用户可以无需关系任何内容,我们可以做到0配置启动项目,不需要你做任何配置即可超快部署项目。

我们提供了两种部署方式,一种是安装包部署,你可以下载对应平台的安装包**.zip安装包**,下载到你的服务器,解压之后里面有一个install.sh,直接sh ./install.sh即可安装,当然手动部署 可能还需要两个步骤,你可以使用我们的脚本直接进行部署,也可以看看我们的部署文档,PixelPunk部署文档

安装包一键部署 curl -fsSL https://download.pixelpunk.cc/shell/install.sh | bash

docker-compose一键部署脚本 curl -fsSL https://download.pixelpunk.cc/shell/docker-install.sh | bash

我们的部署非常简单,你只需要执行完脚本即可直接启动项目,我们的docker-compose部署方式已经配置了所有数据库缓存等信息,启动项目进入 http://ip:9520 会自动跳转到安装页面,添加管理员账号密码即可完成安装, 如果使用安装包模式呢,那么就支持你可以自定义选择数据库,可以填写自己的mysql,也可以使用系统内置的Sqlite,可以选择自己的向量数据库和缓存,也可以使用系统内置的,自由选择,总之,一键脚本预估20S就可以帮你安装并且启动项目,无需你的任何配置,希望会自己内置生成一些必要信息,比如jwt,系统安装后你可以进入后台管理进行修改。

image.png

项目部分功能

我们的项目功能可以说已经非常全面了,并且还在持续迭代,目前代码总行数已经达到了30W行,很多功能需要你自己体验,我们覆盖了主流图床的全部功能,并且还在进一步持续加入更多有趣的元素,我们可以列举一些功能做简要说明。

10+精美主题

作为个人使用的工具,我一直在持续优化UI和交互,始终认为,UI还是很重要的一个步骤,目前的UI还不够精美,也是后续会持续调整的一个点,目前提供了10多套主题,并且您可以自定义主题,我在项目中放置了主题开发文档,你可以根据模板文件去替换一套变量即可完成一套主题的开发。

image.png

image.png

多语言双风格

我们目前内置了三种语言中英日,并且为了迎合我们PixelPunk风格的特色,我们新增了一种风格选项,你可以选择赛博朋克风格的文案,让系统的所有内容提示充满赛博味道~

image.png

image.png

多布局系统

我们网站为了更好的工作体验,提供了两种的布局方式,传统布局,工作布局,既可以使用传统的轻量化的布局让人轻松,也可以使用工作台布局让工作更高效。并且您还可以在后台限制这些功能,使用固定布局而不开放这些功能,在后台管理部分都可以实现。

image.png

image.png

多种登录方式

我们内置默认使用传统邮箱的登录方式,并且支持关闭注册,邮箱配置也是后台配置即可,同时系统对接了GithubGoogleLinuxDo三种接入成本非常低的快捷登录方式,并且可能由于你是国内服务器,无法直接访问这些服务,所以系统贴心的准备了(代理服务)配置,让你即使是国内服务器也依然可以顺利完成这些快捷登录。

image.png

image.png

强大的特色文件上传

文件上传是一个基础的功能,所有图床都支持,我们当然也支持,我们在此功能上进行了耐心的打磨,支持很多特色功能,

  • 支持后台动态配置允许格式,动态配置上传图片限制尺寸
  • 支持大文件分片上传断点续传等功能。
  • 支持秒传,后台动态开启是否启用
  • 支持游客模式,限制游客上传数量,并限制上传资源有效时间
  • 支持自定义资源保存时间,后台可配置用户允许选择有效期,精确到分钟
  • 支持重复图检测,重复图自动秒传,不占用空间
  • 支持水印,并且特色化水印,开发了一个水印专用面板用于你配置特色化水印,支持文字、图片水印。
  • 支持中断上传,取消上传
  • 支持上传实时进度提示
  • 支持自动优化图片自定义上传文件夹
  • 支持文件夹上传,拖拽上传,项目内全局上传(任意页面都支持上传)
  • 支持原图复制,MD格式复制,HTML格式复制,缩略图格式复制,全部图片链接批量复制
  • 支持公开、私密、受保护,三种权限设置,公开图片可以在任何地方显示并且授权给管理员可以用于推荐,并且在作者首页可以展示对外,私密则仅自己可见,系统其他人不可见,受保护权限图片只能在系统观看打开,无法产生链接,除自己登录系统外,其他人无法观看。
  • 支持持久化,你可以选择图片并且上传中,跳转到任何页面而不会中断你的上传,你可以在上传过程中干任何事情
  • 支持特色悬浮球,当你不在上传页面的其他页面,如果有上传内容,会有一个特色上传球实时告知您上传的进度,并且你可以随意拖动控制悬浮球的位置。

image.png

image.png

image.png

超过上传渠道支持

作为一个图床的基本素养,都会支持对接三方,目前我们已经支持了10+的三方云储存渠道,并且添加时候可以测试渠道保障你的配置,首先我们支持服务器自身存储,这也是系统默认渠道,你可以在后台渠道配置更多,比如阿里云COS,腾讯云OSS,七牛云,雨云,又拍云,S3,Cloudfare R2,WebDav,AZure,Sftp,Ftp,等等渠道,S3协议本身就可以支持非常多的基于S3协议的渠道了,并且,如果你想要更多渠道,可以去往我们官网网站提起诉求,我们可以很快支持新的渠道。

image.png

image.png

特色AI功能

AI也是我们图床的一大特色,我们利用AI做了这些事情

  • 自动分类
  • 自动打标
  • 语义化图片,提取信息,提取色调
  • 实现ai自然语言搜索
  • 实现相似图搜索
  • 实现NSFW违规图审核

而这些,仅仅只需要配置一个openai的gpt4-mini即可完成,后续会支持更多渠道(目前仅支持配置OPENAI格式的渠道),目前测试感觉gpt-4.1-mini足以胜任工作,并且价格低廉,性价比很高。

  • 违规图片检测 可以自定义等级 宽松或严格

image.png

  • 自然语言搜索图片

image.png

  • 相似图搜索

image.png

  • 图片ai描述

image.png

  • 自动分类打标签

image.png

文件夹功能

文件分类是一个和合理的诉求,所以我们可以自定义创建文件夹,同样可以控制不同的权限,并且文件夹可以无限嵌套 你可以自定义多层级的文件夹 合理管理你的文件,并且我们支持文件夹移动图片移动批量操作右键菜单拖拽排序等等特色功能,你可以灵活的管理你的文件。

image.png

  • 右键菜单

image.png

分享系统

我们拥有大量素材,或者一些收藏资源需要分享给好友,我们可以任意创建分享,可以分享自己的文件夹,图片,也可以组合分享,也可以分享用户公开的推荐图,选择任意探索广场的图进行分享,并且可以统计访问次数,限制访问次数,达到访问次数关闭分享,密码分享,限制时间有效期分享,邮箱通知等等功能。

您可以观看我们的演示分享内容

  • 创建分享

image.png

image.png

防盗链管理

图床安全始终是一个问题,经常会遇到被盗刷的风险,由于流量费用贵,鄙人有幸被刷破产过一次(TMD),我们加入了防盗链配置,可以配置白黑名单域名|IP,可以配置网站refer等内容,并且对于违规的用户,我们可以配置让其302到他地址,可以自定义返回固定图,也可以直接拒绝

当我们对接三方渠道的时候,正常情况我们会拿到远程url地址,我们依然可以在后台配置渠道将其隐藏远程url地址,如果配置,那么域名请求将会从我们服务器代理获取,可以隐藏掉三方的地址,或者配置私有存储桶通过秘钥去动态获取图片,防止你的图片被盗刷大量流量

image.png

开放API

图床的基本功能之一,我们可以生成秘钥在三方进行上传文件,不同的是,我们系统支持了设置秘钥时可以限制其使用的空间限制上传次数,可以指定上传的文件夹,指定文件格式等等,并位置提供了完整的上传文档,支持单文件上传,多文件上传

image.png

随机图片

经常会有人需要一个随机图片的API,但是受限于使用别人的不够稳定,也不够灵活,于是我们开放了一个随机API功能,你可以动态的配置,选择其绑定你需要随机的图片,比如指定随机任意文件夹,指定返回方式302重定向,或直接返回图片,你可以点击pixelpunk随机图片API演示 ,每次刷新你可以获取新的图片。

image.png

空间带宽控制

我们允许为用户配置限制的使用空间和流量,后台动态灵活配置这些内容,保证多用户使用的时候限制用户使用量。

更多功能

image.png

image.png

image.png

image.png

image.png

总之我们的功能远不止如此,我们还有很多有意思的功能,一些更多的细节需要你去探索,比如,公告系统、消息通知、活动日志、限制登录、IP登录记录,超全的管理系统、埋点统计、访客系统等等模块,这是我个人第一个花费较多时间开发的一套系统,目前对比市面上所有的开源图床,自我认为是一款相对功能最全面的图床,耗费了我大量时间。

如果佬友花费时间看到了这里,那么希望能收获你的一个宝贵的Star,后续的功能我依然会持续跌代,如果你有任何需求,可以私信我,如果合理,我可以无偿免费优先加入到后续跌代中去。

待更新预期功能

  • 后端多语言适配
  • UI 美化
  • Desktop 端开发
  • 更多格式支持 (视频|文档)
  • 交互体验优化
  • 更多渠道支持
  • 更多AI接入
  • 图片处理工具箱

Node+Express+MySQL 后端生产环境部署,实现注册功能(三)

一、部署前准备

  • 本地环境:MacOS(开发端)
  • 服务器环境:阿里云 Ubuntu 22.04 轻量应用服务器
  • 技术栈:Node.js + Express + MySQL
  • 核心目标:将本地开发完成的 Express 后端项目部署到阿里云,实现公网访问接口

二、服务器环境配置(最终生效配置)

1. 登录服务器

通过 Mac 终端 SSH 连接服务器:

ssh root@你的服务器公网IP # 例如:ssh root@47.101.129.155

输入服务器登录密码即可进入。

2. 安装核心依赖软件
# 更新系统包
sudo apt update && sudo apt upgrade -y

# 安装Node.js(v16+)
curl -fsSL https://deb.nodesource.com/setup_16.x | sudo -E bash -
sudo apt install -y nodejs

# 安装MySQL服务器
sudo apt install -y mysql-server

# 安装PM2(Node服务进程管理)
sudo npm install pm2 -g

# 安装Nginx(反向代理)
sudo apt install -y nginx

创建本地数据库

  1. 确保数据库已创建:用 MySQL Bench 连接本地 MySQL(root/admin123/3306),创建数据库 mydb(字符集 utf8mb4,排序规则 utf8mb4_unicode_ci)。

三、创建用户表(存储注册信息)

在 MySQL Bench 中,对 mydb 数据库执行以下 SQL,创建 users 表(用于存储注册用户):

USE mydb; -- 切换到 mydb 数据库

CREATE TABLE users (
  id INT AUTO_INCREMENT PRIMARY KEY, -- 自增主键
  email VARCHAR(100) NOT NULL UNIQUE, -- 邮箱(唯一,避免重复注册)
  password VARCHAR(255) NOT NULL, -- 加密后的密码(bcrypt 加密后长度固定60)
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP, -- 注册时间
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP -- 更新时间
);

四 新建项目

创建文件夹node-api-test

  1. 安装依赖:需要 mysql2(数据库连接)、bcrypt(密码加密,不能明文存储)、express-validator(参数校验),dotenv 判断环境变量

npm init -y

npm install mysql2 bcrypt express-validator deotnev

2. 多环境配置文件(区分测试 / 正式)

在项目根目录创建 多个 .env 文件,分别对应不同环境:

project/
├── .env.development  # 开发环境(本地调试)
├── .env.production   # 生产环境(正式服务器)
├── .env.test         # 测试环境(可选,测试服务器)
└── .gitignore        # 忽略 .env* 文件,避免提交到 Git

文件内容示例

NODE_ENV=development  # 标识环境
DB_HOST=localhost     # 本地数据库地址
DB_USER=root          # 本地数据库账号
DB_PASSWORD=admin123  # 本地数据库密码
DB_NAME=mydb          # 本地数据库名
API_PORT=3000         # 开发环境端口

.env.production(生产环境):

NODE_ENV=production
DB_HOST=10.0.0.1      # 服务器数据库地址(内网 IP)
DB_USER=prod_user     # 服务器数据库账号(非 root,更安全)
DB_PASSWORD=Prod@123  # 服务器数据库密码(复杂密码)
DB_NAME=mydb_prod     # 生产环境数据库名(可与开发环境不同)
API_PORT=3000         # 生产环境端口
3. 在代码中加载对应环境的配置

db/mysql.js,根据 NODE_ENV 自动加载对应的 .env 文件:

// db/mysql.js(ESM 版)
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
import path from 'path';
import { fileURLToPath } from 'url';

// 解决 ESM 中 __dirname 问题
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

// 1. 确定当前环境(默认 development)
const env = process.env.NODE_ENV || 'development';

// 2. 加载对应环境的 .env 文件(如 .env.development 或 .env.production)
const envPath = path.resolve(__dirname, `../.env.${env}`);
dotenv.config({ path: envPath });  // 加载指定路径的 .env 文件

// 3. 从 process.env 中读取配置(环境变量全部是字符串类型)
const dbConfig = {
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  port: Number(process.env.DB_PORT) || 3306,  // 转换为数字
  connectionLimit: 10,
};

// 创建连接池
const pool = mysql.createPool(dbConfig);

// 测试连接时打印当前环境
export async function testDbConnection() {
  try {
    await pool.getConnection();
    console.log(`✅ 数据库连接成功(环境:${env},数据库:${dbConfig.database}`);
  } catch (err) {
    console.error(`❌ 数据库连接失败(环境:${env}):`, err.message);
    throw err;
  }
}

export { pool };

app.js如下:


import express from 'express'
import bodyParser from 'body-parser'
import userRouter from './routes/user.js'
import { testDbConnection } from './db/mysql.js'
import HttpError from './utils/HttpError.js' // 导入自定义错误类

const app = express()
// 从环境变量读取端口(对应.env中的API_PORT)
const port = process.env.API_PORT || 3000

// 解析JSON请求(必须,否则无法获取req.body)
app.use(bodyParser.json())

// 挂载用户模块路由
app.use('/api/user', userRouter)

// 全局错误处理中间件(必须放在所有路由和中间件之后)
app.use((err, req, res, next) => {
  // 1. 处理自定义HttpError
  if (err instanceof HttpError) {
    return res.status(err.statusCode).json({
      status: err.statusCode, // 业务错误状态码(如400)
      message: err.message, // 错误提示信息
      errors: err.errors, // 详细错误列表(如参数校验错误)
    })
  }

  // 2. 处理系统错误(如数据库连接失败、代码bug等)
  console.error('系统错误堆栈:', err.stack) // 打印堆栈,方便后端调试
  res.status(500).json({
    status: 500,
    message:
      process.env.NODE_ENV === 'production'
        ? '服务器内部错误,请稍后重试' // 生产环境隐藏具体错误
        : `系统错误:${err.message}`, // 开发环境显示具体错误(便于调试)
    errors: [],
  })
})

// 启动服务(端口来自环境变量)
app.listen(port, () => {
  console.log(
    `服务启动成功(环境:${process.env.NODE_ENV}):http://localhost:${port}`
  )
  testDbConnection() // 启动时验证数据库连接
})

注册接口编写

在根目录下新建routes/user.js

import express from 'express'
import { body, validationResult } from 'express-validator'
import bcrypt from 'bcrypt'
import { pool } from '../db/mysql.js'
import HttpError from '../utils/HttpError.js'

const router = express.Router()

// 注册接口:POST /api/user/register
router.post(
  '/register',
  // 参数校验(字段名与前端传入、数据库字段一致)
  [
    body('email').isEmail().withMessage('邮箱格式错误'), // 对应数据库email字段
    body('password').isLength({ min: 6 }).withMessage('密码至少6位'), // 对应password字段
    body('nickname')
      .optional()
      .isLength({ max: 50 })
      .withMessage('昵称最多50字'), // 对应nickname字段
  ],
  async (req, res, next) => {
    try {
      // 校验参数
      const errors = validationResult(req)
      if (!errors.isEmpty()) {
        throw new HttpError(400, '参数校验失败', errors.array())
      }

      // 解构前端传入的参数(字段名与数据库字段一致)
      const { email, password, nickname } = req.body

      // 1. 检查邮箱是否已注册(SQL中使用email字段,与数据库一致)
      const [existingUsers] = await pool.query(
        'SELECT id FROM users WHERE email = ?', // WHERE条件用email字段
        [email]
      )
      if (existingUsers.length > 0) {
        throw new HttpError(400, '该邮箱已被注册')
      }

      // 2. 密码加密
      //   const hashedPassword = await bcrypt.hash(password, 10)

      // 3. 插入数据库(字段名与数据库表完全一致)
      const [result] = await pool.query(
        'INSERT INTO users (email, password, nickname) VALUES (?, ?, ?)', // 字段顺序:email, password, nickname
        [email, password, nickname || null] // 对应字段的值
      )

      // 4. 返回结果(包含数据库自动生成的id和字段)
      res.status(200).json({
        code: 200,
        message: '注册成功',
        data: {
          userId: result.insertId, // 数据库自增id
          email: email, // 与数据库email字段一致
          nickname: nickname || null, // 与数据库nickname字段一致
          createdAt: new Date().toISOString(),
        },
      })
    } catch (err) {
      next(err)
    }
  }
)

// 获取所有用户
router.get('/allUsers', async (req, res, next) => {
  try {
    const [users] = await pool.query('SELECT * FROM users')
    res.status(200).json({
      code: 200,
      message: '获取成功',
      data: users,
    })
  } catch (err) {
    next(err)
  }
})

export default router

本地postman测试

image.png

数据库查看这条数据

image.png

下一篇学习如何把代码发布到服务器,通过域名来访问接口,实现注册,顺便把前端页面也发布上去

跟着TRAE SOLO全链路看看项目部署服务器全流程吧

跟着TRAE SOLO全链路看看项目部署服务器全流程吧

接下来我们新建一个项目,然后将项目部署到服务器上,并且配置好以后可以在外网进行访问

安装nvm
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.35.3/install.sh | bash

1、简单服务器环境搭建

接下来我们就实现把 Node.js 项目部署到 /opt/nexus-node-api 并配置外部访问

进入服务器以后安装环境

# 更新包列表
sudo apt update

# 安装 Node.js 和 npm
sudo apt install nodejs npm

# 验证安装
node --version
npm --version

项目创建

# 创建目录
sudo mkdir -p /opt/nexus-node-api

# 设置所有者和权限
sudo chown -R $USER:$USER /opt/nexus-node-api
chmod -R 755 /opt/nexus-node-api

# 进入目录
cd /opt/nexus-node-api

# 创建一个项目
nano app.js

项目内容

const http = require('http');
const server = http.createServer((req, res) => {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('Hello World!\n');
});
server.listen(3000, '0.0.0.0', () => {
    console.log(`Server running on port 3000`);
});

测试运行以及外网访问

注意点:一定要注意这个时候必须保证你的服务器里面的防火墙(安全组)规则里面有3000这个端口号

node app.js

现在访问 http://你的服务器IP:3000 应该能看到 "Hello World!"

2、正式项目配置

卸载node环境

这里我们使用nvm来配置我们的环境,如果已经有的,我们删除一下已经有的环境

# 卸载 nodejs 和 npm
sudo apt-get remove nodejs npm
sudo apt-get purge nodejs npm

安装nvm

// 安装 nvm
# 建议安装 nvm,方便版本管理
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash

配置环境变量

# 编辑 .bashrc
nano ~/.bashrc

//添加配置 ---一般系统会自动为我们添加
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
[ -s "$NVM_DIR/bash_completion" ] && . "$NVM_DIR/bash_completion"

// 重新加载配置
source ~/.bashrc

// 验证 nvm 安装
nvm --version

安装稳定版本node

ubuntu为例子
// 查看可以安装的稳定版本
nvm ls-remote

// 这里我安转版本
nvm install v22.12.0
// 使用
nvm use v22.12.0

// 设置默认版本
nvm alias default v22.12.0
//   pm2
npm i -g pm2

使用pm2守护进程

PM2 是 Node 应用的进程管理工具,能保证服务在后台持续运行:要不然关闭窗口之后,就无法访问了

# 全局安装 PM2
npm install pm2 -g

# 启动服务并命名(方便管理)
pm2 start app.js --name "node-api-nexus"

# 查看服务状态
pm2 list  # 若 Status 为 online 则表示启动成功

这个时候不管怎么刷新我们的页面或者窗口,可以始终稳定访问我们的接口

pm2 重启对应的服务
pm2 restart "node-api-nexus"

3、服务器安装mysql数据库

环境搭建

接下来我们在服务器上安装mysql数据库,这里需要我们输入服务器密码

# 更新包列表
sudo apt update

# 安装 MySQL 服务器 
// 安装 MySQL 8.0(Ubuntu 默认源即提供 MySQL 8.0)
sudo apt install mysql-server -y

# 安装过程中可能会提示输入服务器密码

# 确认 MySQL 版本
mysql --version

MYSQl数据库安全配置

调整 MySQL 服务器的安全性
# 安全配置(可选但推荐)
sudo mysql_secure_installation

测试可以都选n


按照提示配置:
是否启用强密码   // y 
设置 root 密码   // Le@1996#Lin
移除匿名用户
禁止远程 root 登录(可选)
删除测试数据库
重新加载权限表
登录 MySQL
sudo mysql -u root -p
输入密码即可

配置远程访问(可选)

配置 MySQL 允许本地连接:

sudo nano /etc/mysql/mysql.conf.d/mysqld.cnf

配置信息
[mysqld]
# 确保绑定到本地
bind-address = 127.0.0.1

# 设置端口
port = 3306

# 设置字符集
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
修改mysql数据库配置
[mysqld]
# 注释掉原来的 bind-address 或改为 0.0.0.0
# bind-address = 127.0.0.1
bind-address = 0.0.0.0
port = 3306
重启服务,登录mysql创建远程连接用户
// 重启mysql服务
sudo systemctl restart mysql

// 登录
sudo mysql -u root -p

// 密码
123456

-- 创建远程用户(% 表示允许任何IP连接)
CREATE USER '账号'@'%' IDENTIFIED BY '密码';

-- 授予权限
GRANT ALL PRIVILEGES ON *.* TO '密码'@'%' WITH GRANT OPTION;

-- 或者只授权特定数据库(跳过)
-- GRANT ALL PRIVILEGES ON your_database.* TO 'remote_user'@'%';

-- 刷新权限
FLUSH PRIVILEGES;

-- 查看用户
SELECT User, Host FROM mysql.user;

-- 退出
EXIT;

// 重启 MySQL
sudo systemctl restart mysql

// 设置开机自启(默认应已设置)
sudo systemctl enable mysql

4、navicat远程mysql数据库

切记:一定要保证我们的服务器已经添加了我们的端口3306

服务器允许我们远程连接

# 开放 3306 端口
sudo ufw allow 3306

# 或者只允许特定IP访问(更安全)
sudo ufw allow from 你的本地IP to any port 3306

# 查看防火墙状态
sudo ufw status

远程连接

本地远程mysql数据库,我使用的是navicat工具,这里直接输入我们的信息

连接名:远程服务器,随便起名字
主机:服务器IP
用户名:上面设置的
密码:上面设置的

测试一下,服务器的数据库已经连接成功了

数据库连接测试

新建一个数据库,这里我的名称是nexus

数据库名:nexus
字符集:utf8mb3
排序规则:utf8mb3_bin

新建一个表

DROP TABLE IF EXISTS `sys_user`;
CREATE TABLE `sys_user`  (
  `user_id` int(0) NOT NULL AUTO_INCREMENT COMMENT 'id',
  `name` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '姓名',
  `age` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '年龄',
  `sex` int(0) NULL DEFAULT NULL COMMENT '用户性别 1男 2女 ',
  `create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建时间',
  `address` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '用户的地址',
  `state` tinyint(0) NULL DEFAULT NULL COMMENT '1 正常  0 2  禁用',
  `phone` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '手机号',
  `username` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '用户的登录账号',
  `password` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL DEFAULT '123456' COMMENT '用户的登录密码',
  `avatar` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '头像地址',
  `update_time` datetime(0) NULL DEFAULT NULL COMMENT '更新时间',
  `user_height` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '身高',
  `user_weight` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '体重',
  `disease` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '健康状况,是否有疾病',
  PRIMARY KEY (`user_id`, `password`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 55 CHARACTER SET = utf8mb3 COLLATE = utf8mb3_general_ci ROW_FORMAT = Dynamic;

本地运行项目测试

这里我们现在就本地启动项目连接我们服务器,然后进行测试,这里我以开源的Node项目为例,主要修改四个参数

const dbhost='xx'; // 数据库主机地址,如果是本地数据库则使用localhost
const dbdatabase='xx'; // 数据库名称
const dbuser='xx'; // 数据库用户名
const dbpassword='xxx'; // 数据库密码

本地测试一下,我们的线上数据库已经可以使用了

5、Node项目部署

接下来我们将node项目部署进我们的服务器,首先把我们项目都扔进去

配置环境

这里我用的是yarn,安装一下

npm install yarn -g 


// 配置环境
yarn

// 启动pm2
pm2 start app.js --name "node-api-nexus"

// 重新启动pm2 设置开机自启
pm2 startup
pm2 save

查看详细日志

pm2 logs node-api-nexus

启动以后我们就可以直接在浏览器打开地址对我们的系统后台进行访问了

http://XXXXXX:3200/

6、前端部署

环境安装

接下来我们继续部署我们的前端应用,先用我们的项目连接一下我们的数据库尝试一下 OK,没什么问题,然后我们开始部署前端项目

项目名称为nexus-vue,项目打包好的路径位于 /opt/nexus-vue 下面

// 打包前端项目
yarn build

// 更新和安装nginx 
// 更新可以跳过 之前我们已经进行过
sudo apt update
sudo apt install nginx

// 查看版本
nginx -V

配置nginx

sudo nano /etc/nginx/sites-available/nexus-vue

// 配置如下
server {
    listen 80;
    server_name localhost;  # 替换为你的域名或IP

    root /opt/nexus-vue;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;
    }

    # 如果需要代理API请求
    location /api {
        proxy_pass http://localhost:3000;
    }
}
server {
    listen 8080;
    server_name localhost;  # 替换为你的域名或IP

    # 前端静态文件
    root /opt/nexus-vue;
    index index.html;

    # 前端路由
    location / {
        try_files $uri $uri/ /index.html;
    }

    # 后端API请求
    location /api {
        proxy_pass http://localhost:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }

    # WebSocket连接
    location /ws {
        proxy_pass http://localhost:3001;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
    }
}

开放接口

sudo ufw allow 8080

sudo systemctl restart nginx

处理日志错误

// 检查nginx错误日志
sudo tail -f /var/log/nginx/error.log


//开放文件权限
sudo chmod -R 755 /opt/nexus-vue

// 检查配置
sudo nano /etc/nginx/sites-available/nexus-vue

// 重新启动nginx
sudo systemctl restart nginx

部署

写一个测试页面扔进去

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>我是测试页面</title>
</head>
<body>
<h1>我是测试页面</h1>
</body>
</html>

访问我们的地址http://域名IP:8080/

这个时候已经可以看到我们的项目已经部署上去了

重新加载以后,ok,到这里我们全链路都部署上去了

Maven父子模块Deploy的那些坑

起因

前两天遇到个挺坑的问题。我们有个基础服务框架叫financial-platform,是典型的父子结构,父工程下面挂了common-utils、message-client、db-starter这几个子模块。这次需要升级message-client模块,增加了RocketMQ的一些新特性,版本从1.2.5-SNAPSHOT改到1.3.0-SNAPSHOT。

当时想的挺简单的,就是把整个项目的版本都改了,然后只deploy这个message-client模块上去就行了。毕竟这个模块看起来挺独立的,也不依赖其它兄弟模块,应该没问题吧?

结果被现实教育了。

拉取失败

改完版本号,deploy上去后,业务系统引用这个message-client的时候就报错了:

Could not find artifact com.financial:message-client:jar:1.3.0-SNAPSHOT

我当时就懵了,明明刚deploy上去啊,怎么就找不到呢? 去Nexus私服上看,message-client-1.3.0-SNAPSHOT.jar确实在那儿躺着,但就是拉不下来。

后来发现Maven在尝试下载依赖的时候会报pom找不到的警告:

Could not find artifact com.financial:financial-platform:pom:1.3.0-SNAPSHOT

恍然大悟

这时候才反应过来,虽然message-client不依赖common-utils或db-starter这些兄弟模块,但是它的pom.xml里有这么一段:

<parent>
    <groupId>com.financial</groupId>
    <artifactId>financial-platform</artifactId>
    <version>1.3.0-SNAPSHOT</version>
</parent>

Maven拉取message-client的时候,会先去找它的父pom。父pom找不到,后面的事儿就都黄了。

整个依赖解析的流程是这样的:

sequenceDiagram
    participant B as 业务系统
    participant N as Nexus私服
    participant P as financial-platform
    participant M as message-client
    
    B->>N: 请求message-client:1.3.0-SNAPSHOT
    N->>N: 找到message-client的jar
    N->>N: 读取message-client的pom
    N->>P: 需要financial-platform:1.3.0-SNAPSHOT的pom
    P-->>N: 404 Not Found
    N-->>B: 依赖解析失败

为什么需要父pom

有人可能会问,message-client都已经是个完整的jar了,为什么还要父pom呢?

其实父pom里会定义很多东西:

<!-- financial-platform父pom里通常有这些 -->
<properties>
    <java.version>11</java.version>
    <spring-boot.version>2.7.18</spring-boot.version>
    <rocketmq.version>4.9.7</rocketmq.version>
    ...
</properties>

<dependencyManagement>
    <dependencies>
        <!-- 统一管理RocketMQ、Redis、PostgreSQL等版本 -->
        ...
    </dependencies>
</dependencyManagement>

<build>
    <pluginManagement>
        <!-- 插件配置 -->
        ...
    </pluginManagement>
</build>

message-client的pom可能会引用父pom里定义的属性和配置。Maven需要把父子pom合并起来,才能得到一个完整的、可执行的pom。

Maven构建有效pom的过程很简单:解析子模块pom时,如果发现有parent标签,就去Nexus找父pom。找到后合并父子配置,如果父pom还有parent,就继续往上找。一直找到最顶层,然后从上到下合并所有配置,最后生成一个完整的有效pom。

正确的做法

所以正确的做法是,把父pom和message-client都deploy上去:

# 在financial-platform父工程目录执行
mvn clean deploy

这样Maven会把父pom和所有子模块都发布到Nexus。即使你只改了message-client,父pom也得发上去,因为版本号变了。

Maven的继承和聚合

说到这儿,顺便聊聊Maven的继承和聚合,很多人容易搞混。

继承是子模块继承父pom的配置,通过<parent>标签实现。聚合是父工程管理多个子模块,通过<modules>标签实现。

graph TB
    subgraph 继承关系
    P1[financial-platform<br/>配置和依赖版本] -.继承.-> C1[common-utils<br/>使用父配置]
    P1 -.继承.-> C2[message-client<br/>使用父配置]
    P1 -.继承.-> C3[db-starter<br/>使用父配置]
    end
    
    subgraph 聚合关系
    P2[financial-platform] --聚合--> C4[common-utils]
    P2 --聚合--> C5[message-client]
    P2 --聚合--> C6[db-starter]
    end
    
    style P1 fill:#e1f5ff
    style P2 fill:#ffe1f5

父pom里是这样的:

<!-- 聚合: 管理有哪些子模块 -->
<modules>
    <module>common-utils</module>
    <module>message-client</module>
    <module>db-starter</module>
</modules>

<!-- 继承: 提供给子模块的配置 -->
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.apache.rocketmq</groupId>
            <artifactId>rocketmq-spring-boot-starter</artifactId>
            <version>${rocketmq.version}</version>
        </dependency>
    </dependencies>
</dependencyManagement>

子模块message-client里是这样的:

<!-- 继承: 指定从哪个父pom继承 -->
<parent>
    <groupId>com.financial</groupId>
    <artifactId>financial-platform</artifactId>
    <version>1.3.0-SNAPSHOT</version>
</parent>

<!-- 实际使用的依赖,版本从父pom继承 -->
<dependencies>
    <dependency>
        <groupId>org.apache.rocketmq</groupId>
        <artifactId>rocketmq-spring-boot-starter</artifactId>
        <!-- 版本号从父pom的dependencyManagement继承 -->
    </dependency>
</dependencies>

这两个是独立的机制,可以单独使用。但大部分时候我们会一起用,既让父工程聚合管理子模块,又让子模块继承父配置。

后来我们的处理

我们现在的做法是,每次版本升级,不管改了几个模块,都执行完整的deploy。虽然会把common-utils、message-client、db-starter都发一遍,有点浪费,但起码不会出幺蛾子。

另外在Jenkins的CI流程里加了个检查,如果pom的版本号变了,必须全量deploy,不允许只deploy单个模块。

#!/bin/bash
# Jenkins里的检查脚本
VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout)

if [[ $VERSION == *"SNAPSHOT"* ]]; then
    echo "检测到SNAPSHOT版本: $VERSION"
    echo "执行全量deploy到Nexus"
    mvn clean deploy -DskipTests
else
    echo "Release版本: $VERSION" 
    # release版本走发布审批流程
    echo "需要审批后才能deploy"
    exit 1
fi

实际案例分析

我们再看一个实际的场景。假设业务系统order-service需要引用我们升级后的message-client:

<!-- order-service的pom.xml -->
<dependencies>
    <dependency>
        <groupId>com.financial</groupId>
        <artifactId>message-client</artifactId>
        <version>1.3.0-SNAPSHOT</version>
    </dependency>
</dependencies>

Maven构建order-service的时候,会先从本地或Nexus下载message-client的jar和pom。读取message-client的pom时发现它依赖父pom financial-platform:1.3.0,于是继续去找父pom。如果父pom不存在,整个构建就失败了。找到父pom后,Maven会合并父子配置,然后递归解析所有传递依赖,最后才能成功构建。

所以你看,这是个链式反应。中间任何一环缺失,整个构建都会挂掉。

就这样吧,希望能帮到遇到类似问题的朋友。这个坑我们已经踩过了,你们就别再踩了。下次升级message-client加新功能的时候,记得把整个framework都deploy上去,省得业务系统那边找你麻烦。

专为 LLM 设计的数据格式 TOON,可节省 60% Token

最近在使用 NestJs 和 NextJs 在做一个协同文档 DocFlow,如果感兴趣,欢迎 star,有任何疑问,欢迎加我微信进行咨询 yunmz777

随着社交媒体的深度渗透,朋友圈、微博、Instagram 等平台已成为用户展示生活、分享瞬间的核心场景。其中,"九宫格"排版形式凭借其规整的视觉美感和内容叙事性,成为年轻用户(尤其是女性群体)高频使用的图片发布方式。

在接下来的内容中,我们将使用 NextJs 结合 sharp 来实现图片裁剪的功能。

编写基本页面

首先我们将编写一个图片裁剪的功能,以支持用户来生成不同规格的图片,例如 3x3、2x2 等格式。

如下代码所示:

"use client";

import { useState, useRef } from "react";

const Home = () => {
  const [rows, setRows] = useState(3);
  const [columns, setColumns] = useState(3);
  const [image, setImage] = useState<string | null>(null);
  const [splitImages, setSplitImages] = useState<string[]>([]);
  const [isProcessing, setIsProcessing] = useState(false);
  const [imageLoaded, setImageLoaded] = useState(false);
  const imageRef = useRef<HTMLImageElement>(null);
  const fileInputRef = useRef<HTMLInputElement>(null);

  const handleFileUpload = (file: File) => {
    if (!file.type.startsWith("image/")) {
      alert("请上传图片文件");

      return;
    }

    // 先重置状态
    setImage(null);
    setSplitImages([]);
    setImageLoaded(false);

    const reader = new FileReader();

    reader.onload = (e) => {
      if (e.target?.result) {
        setImage(e.target.result as string);
      }
    };

    reader.readAsDataURL(file);
  };

  // 修改图片加载完成的处理函数
  const handleImageLoad = () => {
    console.log("Image loaded"); // 添加日志以便调试
    setImageLoaded(true);
  };

  // 渲染切割辅助线
  const renderGuideLines = () => {
    if (!image || !imageRef.current || !imageLoaded) return null;

    const commonLineStyles = "bg-blue-500/50 absolute pointer-events-none";
    const imgRect = imageRef.current.getBoundingClientRect();
    const containerRect =
      imageRef.current.parentElement?.getBoundingClientRect();

    if (!containerRect) return null;

    const imgStyle = {
      left: `${imgRect.left - containerRect.left}px`,
      top: `${imgRect.top - containerRect.top}px`,
      width: `${imgRect.width}px`,
      height: `${imgRect.height}px`,
    };

    return (
      <div className="absolute pointer-events-none" style={imgStyle}>
        {/* 垂直线 */}
        {Array.from({ length: Math.max(0, columns - 1) }).map((_, i) => (
          <div
            key={`v-${i}`}
            className={`${commonLineStyles} top-0 bottom-0 w-[1px] md:w-[2px] backdrop-blur-sm`}
            style={{
              left: `${((i + 1) * 100) / columns}%`,
              transform: "translateX(-50%)",
            }}
          />
        ))}
        {/* 水平线 */}
        {Array.from({ length: Math.max(0, rows - 1) }).map((_, i) => (
          <div
            key={`h-${i}`}
            className={`${commonLineStyles} left-0 right-0 h-[1px] md:h-[2px] backdrop-blur-sm`}
            style={{
              top: `${((i + 1) * 100) / rows}%`,
              transform: "translateY(-50%)",
            }}
          />
        ))}
      </div>
    );
  };

  // 处理图片切割
  const handleSplitImage = async () => {
    if (!image) return;

    setIsProcessing(true);

    try {
      const response = await fetch(image);
      const blob = await response.blob();
      const file = new File([blob], "image.jpg", { type: blob.type });

      const formData = new FormData();
      formData.append("image", file);
      formData.append("rows", rows.toString());
      formData.append("columns", columns.toString());

      const res = await fetch("/api/split-image", {
        method: "POST",
        body: formData,
      });

      const data = await res.json();

      if (data.error) {
        throw new Error(data.error);
      }

      setSplitImages(data.pieces);
    } catch (error) {
      console.error("Failed to split image:", error);
      alert("图片切割失败,请重试");
    } finally {
      setIsProcessing(false);
    }
  };

  // 添加下载单个图片的函数
  const handleDownloadSingle = async (imageUrl: string, index: number) => {
    try {
      const response = await fetch(imageUrl);
      const blob = await response.blob();
      const url = window.URL.createObjectURL(blob);
      const link = document.createElement("a");
      link.href = url;
      link.download = `piece_${index + 1}.png`;
      document.body.appendChild(link);
      link.click();
      document.body.removeChild(link);
      window.URL.revokeObjectURL(url);
    } catch (error) {
      console.error("下载失败:", error);
      alert("下载失败,请重试");
    }
  };

  // 添加打包下载所有图片的函数
  const handleDownloadAll = async () => {
    try {
      // 如果没有 JSZip,需要先动态导入
      const JSZip = (await import("jszip")).default;
      const zip = new JSZip();

      // 添加所有图片到 zip
      const promises = splitImages.map(async (imageUrl, index) => {
        const response = await fetch(imageUrl);
        const blob = await response.blob();
        zip.file(`piece_${index + 1}.png`, blob);
      });

      await Promise.all(promises);

      // 生成并下载 zip 文件
      const content = await zip.generateAsync({ type: "blob" });
      const url = window.URL.createObjectURL(content);
      const link = document.createElement("a");
      link.href = url;
      link.download = "split_images.zip";
      document.body.appendChild(link);
      link.click();
      document.body.removeChild(link);
      window.URL.revokeObjectURL(url);
    } catch (error) {
      console.error("打包下载失败:", error);
      alert("打包下载失败,请重试");
    }
  };

  // 修改预览区域的渲染函数
  const renderPreview = () => {
    if (!image) {
      return <p className="text-gray-400">切割后的图片预览</p>;
    }

    if (isProcessing) {
      return <p className="text-gray-400">正在处理中...</p>;
    }

    if (splitImages.length > 0) {
      return (
        <div className="relative w-full h-full flex items-center justify-center">
          <div
            className="grid gap-[3px] bg-[#242c3e]"
            style={{
              gridTemplateColumns: `repeat(${columns}, 1fr)`,
              gridTemplateRows: `repeat(${rows}, 1fr)`,
              width: imageRef.current?.width || "100%",
              height: imageRef.current?.height || "100%",
              maxWidth: "100%",
              maxHeight: "100%",
            }}
          >
            {splitImages.map((src, index) => (
              <div key={index} className="relative group">
                <img
                  src={src}
                  alt={`切片 ${index + 1}`}
                  className="w-full h-full object-cover"
                />
                <button
                  onClick={() => handleDownloadSingle(src, index)}
                  className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity"
                >
                  <svg
                    className="w-6 h-6 text-white"
                    fill="none"
                    stroke="currentColor"
                    viewBox="0 0 24 24"
                  >
                    <path
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      strokeWidth={2}
                      d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
                    />
                  </svg>
                </button>
              </div>
            ))}
          </div>
        </div>
      );
    }

    return <p className="text-gray-400">点击切割按钮开始处理</p>;
  };

  return (
    <>
      <div className="fixed inset-0 bg-[#0B1120] -z-10" />
      <main className="min-h-screen w-full py-16 md:py-20">
        <div className="container mx-auto px-4 sm:px-6 max-w-7xl">
          {/* 标题区域 */}
          <div className="text-center mb-12 md:mb-16">
            <h1 className="text-3xl md:text-4xl lg:text-5xl font-bold mb-4 animate-fade-in">
              <span className="bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500 text-transparent bg-clip-text bg-[size:400%] animate-gradient">
                图片切割工具
              </span>
            </h1>
            <p className="text-gray-400 text-base md:text-lg max-w-2xl mx-auto animate-fade-in-up">
              上传一张图片,快速将其切割成网格布局,支持自定义行列数。
            </p>
          </div>

          {/* 图片区域 - 调整高度和响应式布局 */}
          <div className="grid grid-cols-1 lg:grid-cols-2 gap-6 md:gap-8 mb-12 md:mb-16">
            {/* 上传区域 - 调整高度 */}
            <div className="relative h-[400px] md:h-[500px] lg:h-[600px] bg-[#1a2234] rounded-xl overflow-hidden">
              <input
                ref={fileInputRef}
                type="file"
                accept="image/*"
                className="hidden"
                onChange={(e) => {
                  const file = e.target.files?.[0];
                  if (file) handleFileUpload(file);
                }}
                id="imageUpload"
              />
              <label
                htmlFor="imageUpload"
                className="absolute inset-0 flex flex-col items-center justify-center cursor-pointer"
              >
                {image ? (
                  <div className="relative w-full h-full flex items-center justify-center">
                    <img
                      ref={imageRef}
                      src={image}
                      alt="上传的图片"
                      className="max-w-full max-h-full object-contain"
                      onLoad={handleImageLoad}
                      key={image}
                    />
                    {renderGuideLines()}
                  </div>
                ) : (
                  <>
                    <div className="p-4 rounded-full bg-[#242c3e] mb-4">
                      <svg
                        className="w-8 h-8 text-gray-400"
                        fill="none"
                        stroke="currentColor"
                        viewBox="0 0 24 24"
                      >
                        <path
                          strokeLinecap="round"
                          strokeLinejoin="round"
                          strokeWidth={1.5}
                          d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
                        />
                      </svg>
                    </div>
                    <p className="text-gray-400">点击或拖拽图片到这里上传</p>
                  </>
                )}
              </label>
            </div>

            {/* 预览区域 - 调整高度 */}
            <div className="h-[400px] md:h-[500px] lg:h-[600px] bg-[#1a2234] rounded-xl flex items-center justify-center">
              {renderPreview()}
            </div>
          </div>

          {/* 控制器 - 添加上下边距的容器 */}
          <div className="py-4 md:py-6">
            <div className="flex flex-col sm:flex-row items-center justify-center gap-4 md:gap-6">
              <div className="flex flex-col sm:flex-row items-center gap-4 w-full sm:w-auto">
                <div className="bg-[#1a2234] rounded-xl px-5 py-2.5 flex items-center gap-4 border border-[#242c3e] w-full sm:w-auto">
                  <span className="text-gray-400 font-medium min-w-[40px]">
                    行数
                  </span>
                  <div className="flex items-center gap-2 bg-[#242c3e] rounded-lg p-1 flex-1 sm:flex-none justify-center">
                    <button
                      className="w-8 h-8 flex items-center justify-center text-white rounded-lg hover:bg-blue-500 transition-colors"
                      onClick={() => setRows(Math.max(1, rows - 1))}
                    >
                      -
                    </button>
                    <span className="text-white min-w-[32px] text-center font-medium">
                      {rows}
                    </span>
                    <button
                      className="w-8 h-8 flex items-center justify-center text-white rounded-lg hover:bg-blue-500 transition-colors"
                      onClick={() => setRows(rows + 1)}
                    >
                      +
                    </button>
                  </div>
                </div>

                <div className="bg-[#1a2234] rounded-xl px-5 py-2.5 flex items-center gap-4 border border-[#242c3e] w-full sm:w-auto">
                  <span className="text-gray-400 font-medium min-w-[40px]">
                    列数
                  </span>
                  <div className="flex items-center gap-2 bg-[#242c3e] rounded-lg p-1 flex-1 sm:flex-none justify-center">
                    <button
                      className="w-8 h-8 flex items-center justify-center text-white rounded-lg hover:bg-blue-500 transition-colors"
                      onClick={() => setColumns(Math.max(1, columns - 1))}
                    >
                      -
                    </button>
                    <span className="text-white min-w-[32px] text-center font-medium">
                      {columns}
                    </span>
                    <button
                      className="w-8 h-8 flex items-center justify-center text-white rounded-lg hover:bg-blue-500 transition-colors"
                      onClick={() => setColumns(columns + 1)}
                    >
                      +
                    </button>
                  </div>
                </div>

                <button
                  className="px-5 py-2.5 bg-[#1a2234] text-gray-400 rounded-xl hover:bg-[#242c3e] hover:text-white transition-all font-medium border border-[#242c3e] w-full sm:w-auto"
                  onClick={() => {
                    setRows(3);
                    setColumns(3);
                  }}
                >
                  重置
                </button>
              </div>

              <div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
                <button
                  className="px-6 py-3 bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-xl hover:from-blue-600 hover:to-blue-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 font-medium shadow-lg shadow-blue-500/20 w-full sm:w-auto"
                  disabled={!image || isProcessing}
                  onClick={handleSplitImage}
                >
                  {isProcessing ? "处理中..." : "切割图片"}
                </button>
                <button
                  className="px-6 py-3 bg-gradient-to-r from-red-500 to-red-600 text-white rounded-xl hover:from-red-600 hover:to-red-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed font-medium shadow-lg shadow-red-500/20 w-full sm:w-auto"
                  onClick={() => {
                    setImage(null);
                    setSplitImages([]);
                    setImageLoaded(false);

                    if (fileInputRef.current) {
                      fileInputRef.current.value = "";
                    }
                  }}
                  disabled={!image}
                >
                  清除
                </button>
              </div>

              {/* 下载按钮 */}
              {splitImages.length > 0 && (
                <button
                  onClick={handleDownloadAll}
                  className="px-6 py-3 bg-gradient-to-r from-green-500 to-green-600 text-white rounded-xl hover:from-green-600 hover:to-green-700 transition-all font-medium shadow-lg shadow-green-500/20 flex items-center justify-center gap-2 w-full sm:w-auto"
                >
                  <svg
                    className="w-5 h-5"
                    fill="none"
                    stroke="currentColor"
                    viewBox="0 0 24 24"
                  >
                    <path
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      strokeWidth={2}
                      d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
                    />
                  </svg>
                  打包下载
                </button>
              )}
            </div>
          </div>

          {/* 底部留白 */}
          <div className="h-16 md:h-20"></div>
        </div>
      </main>
    </>
  );
};

export default Home;

在上面的代码中,当用户上传图片兵当图片加载完成之后,renderGuideLines 方法会绘制图片的切割网格,显示垂直和水平的分隔线,便于用户预览切割后的效果。

它会根据用户设置的行数(rows)和列数(columns),这些切割线将被动态渲染。用户设置完行列数后,点击 "切割图片" 按钮,会触发 handleSplitImage 函数,将图片传递到后端进行切割。图片会被发送到后端接口 /api/split-image,并返回切割后的图片数据(即每个小图的 URL)。

最终 ui 效果如下图所示:

20250218163115

设计 API

前面的内容中,我们已经编写了前端的 ui,接下来我们要设计我们的 api 接口以支持前端页面调用。

首先我们要先知道一个概念,sharp 是一个高性能的 Node.js 图像处理库,支持各种常见的图像操作,如裁剪、调整大小、旋转、转换格式等。它基于 libvips(一个高效的图像处理库),与其他一些图像处理库相比,它的处理速度更快,内存消耗也更低。

而 Next.js 是一个服务器端渲染(SSR)的框架,可以通过 API 路由来处理用户上传的文件。在 API 路由中使用 sharp 可以确保图像在服务器端得到处理,而不是在客户端进行,这样可以减轻客户端的负担,并且保证图像在服务器上处理完成后再发送到客户端,从而提高页面加载速度。

如下代码所示:

import sharp from "sharp";
import { NextResponse } from "next/server";

export async function POST(request: Request) {
  try {
    const data = await request.formData();
    const file = data.get("image") as File;
    const rows = Number(data.get("rows"));
    const columns = Number(data.get("columns"));

    if (!file || !rows || !columns) {
      return NextResponse.json(
        { error: "Missing required parameters" },
        { status: 400 }
      );
    }

    if (!file.type.startsWith("image/")) {
      return NextResponse.json({ error: "Invalid file type" }, { status: 400 });
    }

    const buffer = Buffer.from(await file.arrayBuffer());

    if (!buffer || buffer.length === 0) {
      return NextResponse.json(
        { error: "Invalid image buffer" },
        { status: 400 }
      );
    }

    const image = sharp(buffer);
    const metadata = await image.metadata();

    if (!metadata.width || !metadata.height) {
      return NextResponse.json(
        { error: "Invalid image metadata" },
        { status: 400 }
      );
    }

    console.log("Processing image:", {
      width: metadata.width,
      height: metadata.height,
      format: metadata.format,
      rows,
      columns,
    });

    const pieces: string[] = [];
    const width = metadata.width;
    const height = metadata.height;
    const pieceWidth = Math.floor(width / columns);
    const pieceHeight = Math.floor(height / rows);

    if (pieceWidth <= 0 || pieceHeight <= 0) {
      return NextResponse.json(
        { error: "Invalid piece dimensions" },
        { status: 400 }
      );
    }

    for (let i = 0; i < rows; i++) {
      for (let j = 0; j < columns; j++) {
        const left = j * pieceWidth;
        const top = i * pieceHeight;
        const currentWidth = j === columns - 1 ? width - left : pieceWidth;
        const currentHeight = i === rows - 1 ? height - top : pieceHeight;

        try {
          const piece = await image
            .clone()
            .extract({
              left,
              top,
              width: currentWidth,
              height: currentHeight,
            })
            .toBuffer();

          pieces.push(
            `data:image/${metadata.format};base64,${piece.toString("base64")}`
          );
        } catch (err) {
          console.error("Error processing piece:", { i, j, err });
          throw err;
        }
      }
    }

    return NextResponse.json({ pieces });
  } catch (error) {
    console.error("Error processing image:", error);

    return NextResponse.json(
      {
        error: "Failed to process image",
        details: error instanceof Error ? error.message : "Unknown error",
      },
      { status: 500 }
    );
  }
}

export const config = {
  api: {
    bodyParser: {
      sizeLimit: "10mb",
    },
  },
};

在上面的这些代码中,它的具体流程如下:

  1. 接收请求:首先通过 POST 方法接收包含图片和切割参数(行数和列数)的表单数据。request.formData() 用来解析表单数据并提取文件和参数。

  2. 参数验证:检查文件类型是否为图片,确保上传数据完整且有效。如果缺少必需的参数或上传的不是图片,返回相应的错误信息。

  3. 图片处理:通过 sharp 库将图片数据转换为可操作的 Buffer,然后获取图片的元数据(如宽度、高度、格式)。如果图片的元数据无效或获取不到,返回错误。

  4. 计算切割尺寸:根据用户输入的行数和列数计算每个小块的宽高,并检查计算出来的尺寸是否有效。如果计算出的尺寸不合适,返回错误。

  5. 图片切割:使用 sharp 的 extract 方法对图片进行切割。每次提取一个小块后,将其转换为 base64 编码的字符串,并保存到 pieces 数组中。

  6. 返回结果:成功处理后,将切割后的图片数据作为 JSON 响应返回,每个图片切块以 base64 编码形式存储。若遇到错误,捕获并返回详细的错误信息。

  7. 配置:通过 config 配置,设置请求体的最大大小限制为 10MB,防止上传过大的文件导致请求失败。

这里的代码完成之后,我们还要设计一下 next.config.mjs 文件,如下:

/** @type {import('next').NextConfig} */
const nextConfig = {
  webpack: (config) => {
    config.externals = [...config.externals, "sharp"];
    return config;
  },
};

export default nextConfig;

当我们点击切割图片的时候,最终生成的效果如下图所示:

20250218164009

总结

完整项目地址

如果你想参与进来开发或者想进群学习,可以添加我微信 yunmz777,后面还会有很多需求,等这个项目完成之后还会有很多新的并且很有趣的开源项目等着你。

如果该项目对你有帮助或者对这个项目感兴趣,欢迎 Star⭐️⭐️⭐️

最后再来提一些这两个开源项目,它们都是我们目前正在维护的开源项目:

🐒🐒🐒

【LM-PDF】一个大模型时代的 PDF 极速预览方案是如何实现的?

最终效果示例(测试文档:290 页)

Kapture 2025-11-18 at 23.45.35.gif

开源地址: github.com/chennlang/l… (如果觉得还不错,记得留下你的 star,这对我有很大帮助!)

背景

随着 AGI 的日益发展,多模态的大模型也逐渐成为常态,出现在大众视野中,不过对于要求较高的场景,识别效果还是缺点意思,主要还是因为文档解析是一个复杂的流程(layout 分析 + 表格、文字识别 + 切片 + 原文对比、段落划分等),所以传统的 RAG 流程还是主流的方式。

大模型要去 “看” 到世界,首先得理解图片、文档。在 RAG 的流程中,大模型需要去学习本地的文档,从而生成更加专业的回答。而这些存量的文档大多数是 pdf 格式的,或者是以图片存在的。所以我们第一步要解决的问题就是如何把文档中的信息完整、准确的提取出来。

PDF 渲染器其实是文档渲染的一个通用的文档展示方案,如果假设我们是在一个照相机后面看世界,所有图片、文档都能被看做是一张张图片,最终汇总起来就是一本 pdf 文档,所以理论上所有东西都能用 PDF 渲染器显示。

在 AGI 的前端项目中,无论是训练模型语料、还是模型回答原文查看、还是切片来源,都需要把原文档联系起来。所以都需要同时展示原文和回答的功能。

现有 PDF 预览方案

特性 pdf.js react-pdf react-pdf-viewer
类型 JavaScript 库 React 组件库 React 组件库
依赖 pdf.js pdf.js
UI 无(需手动实现) 基础(需手动实现工具栏等) 完整(提供工具栏、缩略图等)
功能 渲染、缩放、搜索等 渲染、页面懒加载等 渲染、搜索、缩放、插件支持等
定制性 高(底层 API) 中(组件化) 中高(插件和主题定制)
性能 取决于 PDF 复杂度和实现 取决于 PDF 复杂度和实现 取决于 PDF 复杂度和实现
学习成本 高(需处理 UI 和交互) 中(React 组件) 中高(API 和插件系统)
适用场景 高度自定义、非 React 环境 React 项目,基础预览 React 项目,功能齐全的查看器

目前主流开源的 pdf 渲染器都是基于 pdf.js 封装实现,其中比较有代表性的就是 react-pdf和 react-pdf-viewer,选型建议:

  • 如果你的项目基于 React,且需要一个开箱即用的 PDF 查看器,推荐选择 react-pdf-viewer
  • 如果你仅需在 React 中渲染 PDF 页面并希望自行设计 UI,建议使用 react-pdf
  • 如果你不在 React 环境中,或需要底层控制,建议直接使用 pdf.js

现存问题

一直以来,我都是使用比较成熟的开源库 react-pdf 渲染 pdf 文档。不过,随着使用的深入,各种问题也随之浮现。例如开源的产品没法满足高度定制化、字体兼容问题导致显示错误.... 而最大的问题,是性能!

  • 场景1: 500 页的 pdf 文档如果不做分页,市面上几乎没有一款 pdf 渲染器能做到流畅的滚动加载。
  • 场景2:加载时间长,100M的文档,需要下载完才能预览,网络差的用户需要等 20分钟后才能看到。

综上问题,本来原文档预览是一个方面使用者快速去对比分片、对比回答结果的快捷方式,却因为以上问题,使用起来特别难受。

react-pdf 兼容性问题可参考:全面解析 React-PDF 的浏览器兼容性及其解决策略背景 最近使用 react-pdf 进行 pdf 文件预览。上线 - 掘金

本文适用范围说明

本文探讨的技术方案基于以下核心需求:

  1. PDF 预览模式:采用无限滚动(Infinite Scroll)方式浏览文件内容,而非传统分页器(Pager)模式(逐页或固定页数翻页)。
  2. 性能要求:需实现 PDF 文件的秒级加载,确保流畅体验。

重点说明: 无限滚动模式更符合现代用户习惯,适用于大多数实际场景。本文内容不涉及分页器模式的实现逻辑。

想法萌生

就在我百思不得其解的时候,我看到了一款闭源的 canvas 实现的 pdf 渲染器。全文只有一个 canvas 元素!当然,简单体验了下,页数很多时依然会很卡,甚至不能用。

受此启发,所以我想,既然 pdf 文件在 OCR 识别之前的第一步,就一定是把每一页切成一张图片,那么基于这个场景下,我们完全可以使用图片来渲染呀,完全不用加载文件。

假设视图内只有一页,那么 canvas 中只会渲染 1 张图片,那速度岂不是秒开?

当然,pdf 文件流是二进制的,也能通过分段获取其中一部分文档。可是如何知道每一页的开始和结束符,这是一个问题。

lm-pdf

为了方便下文讲解,我将此方案先命名为 lm-pdf, 主要是为了体现其出色的加载速度。

技术选型:react-konva

有了以上思路,实现起来就是时间的问题了。我选用了 canvas 作为渲染底座,搜索一圈之后发现 konvajs在这个场景下非常适合。结合 react-konva, 在画布上渲染元素就非常简单了。

示例:在画布上渲染一张图片

import { Stage, Layer, Image } from 'react-konva';

class App extends Component {
  render() {
    return (
      <Stage width={window.innerWidth} height={window.innerHeight}>
        <Layer>
          <Image x={0} y={0} image={...}></ Image>
        </Layer>
      </Stage>
    );
  }
}

当然,使用 canvas 渲染还不够,既然要做到性能最好,我们还需要加上虚拟滚动。

核心功能:canvas 虚拟滚动

很多人会说,都使用 canvas 了还使用什么虚拟滚动?可是你要知道,如果大量的元素常驻在画布上,加上滚动时,所有位置都要偏移,也就是说所有元素的位置都会被重新计算一遍。canvas 是按帧渲染的,这样 GPU 渲染肯定错错有余,不过内存和 CPU 性能却吃不消了!所以,要做就做到最好的! 而虚拟滚动恰好就能解决这个问题,因为视窗内同时显示的元素最多不超过 5 个,那么最多就这 5 个元素的计算量,会非常低。

虚拟滚动是什么

虚拟滚动(Virtual Scrolling)是一种优化长列表渲染性能的技术。其基本原理是只渲染可视区域内的元素,而非整个列表,从而减少DOM节点的数量和提高页面性能。

虚拟滚动本身的原理说起来很简单,无非就是通过容器高度和滚动距离动态渲染子元素。不过,要实现一个基于 canvas 的虚拟滚动器,实现过程中,却有很多小细节值得分享。

传统实现方案

Canvas 的虚拟滚动方案和常规实现方案有所不同,也有相同之处。所以我们需要先了解下传统的滚动条方案是怎么实现的。方便理解文章后面的内容。

完整 Demo 如下:

import React, { useState, useEffect, useRef } from 'react';

// 虚拟滚动列表组件
const VirtualScrollList = ({ items, itemHeight }) => {
    // 状态:可见项的起始和结束索引
    const [startIndex, setStartIndex] = useState(0);
    const [endIndex, setEndIndex] = useState(10);

    // 引用:用于访问滚动容器
    const containerRef = useRef(null);

    const handleScroll = () => {
        // 获取当前滚动位置
        const scrollTop = containerRef.current.scrollTop;

        // 计算新的起始索引
        const newStartIndex = Math.floor(scrollTop / itemHeight);

        // 计算新的结束索引
        const newEndIndex = newStartIndex + Math.ceil(containerRef.current.clientHeight / itemHeight);

        // 更新可见项的索引
        setStartIndex(newStartIndex);
        setEndIndex(newEndIndex);
    };

    // 滚动事件监听
    useEffect(() => {
        const container = containerRef.current;
        container.addEventListener('scroll', handleScroll);

        return () => {
            container.removeEventListener('scroll', handleScroll);
        };
    }, []);

    // 可见项
    const visibleItems = items.slice(startIndex, endIndex);

    // 计算占位符高度
    const placeholderHeight = items.length * itemHeight;

    return (
        <div style={{ height: '300px', overflowY: 'auto' }} ref={containerRef}>
            <div style={{ height: `${placeholderHeight}px`, position: 'relative' }}>
                <div style={{ position: 'absolute', top: `${startIndex * itemHeight}px`, left: 0 }}>
                    {visibleItems.map((item, index) => (
                        // 渲染可见项
                        <div key={index} style={{ height: `${itemHeight}px` }}>
                            {item}
                        </div>
                    ))}
                </div>
            </div>
        </div>
    );
};

// 使用示例
const items = Array.from({ length: 1000 }, (_, i) => `Item ${i + 1}`);
const App = () => (
    <div>
        <h1>虚拟滚动列表</h1>
        <VirtualScrollList items={items} itemHeight={30} />
    </div>
);

export default App;

定义一个父容器,在容器中放一个占位符,占位符高度是所以 item 的总和,从而达到撑开父容器,出现滚动条。然后其子元素 item 使用 absolute 的方式悬浮在占位符 元素上。然后通过滚动的距离计算出要显示的子元素 visibleItems,渲染在页面上即可。

Canvas 虚拟滚动实现方案

下面将使用伪代码展示核心原理。

一、canvas 内并没有滚动条的概念,所以我们需要自己实现一个滚动条。

virtual-scroll-bar 组件

// Item
interface Item {
  id: string | number;
  height: number;
  [k: string]: any;
}

interface Props {
    items: Item [];
    onVisibleItemChange: (items: Item[]) => void;
    onScroll?: (scroll: { left: number; top: number }) => void;
}
const VirtualScrollBar = ({ items }: ) => {
    // 滚动触发
    function handleScroll (scroll) {
        updateVisibleItems(scroll)
        onScroll(scroll)
    }

    // 计算可视元素
    function updateVisibleItems (scroll) {
       const visibleItems = []
       // .....省略计算过程
       onVisibleItemChange(visibleItems)
    }

    return <div ref={divRef} className={`v-scroll-bar ${direction}`}>
        <div style={{
            height: items.reduce((sum, item) => (sum += item.height), 0),
        }}>
        </div>
    </div>
}

同样的方式,我们在 div 中加入一个占位符,高度是所以 item 的总和。然后通过监听 divRef 的滚动,计算出视图内出现的元素。

核心逻辑(伪代码)

// 当前显示元素
const [displayItems, setDisplayItems] = useState<PageItem[]>([]);

const onScroll: VirtualScrollBarProps["onScroll"] = ({ left, top }) => {
    // 整体 y 方向偏移, 这里使用 setData 而不是 setData => old,
    // 因为滚动频繁,利用 setData 更新机制可以做到节流,提升性能
    setDisplayItems((old) =>
      old.map((m) => ({
        ...m,
        y: m.top - top,
      }))
)};


// 对比新旧值,更新 Y 的坐标
// 滚动的过程中,y 会偏移,新的 items 进来,如果有公共的 items ,要和旧的保持一致。
function diffAndUpdateY () {}

function onVisibleItemChange(originItems: VirtualScrollBarProps["items"]) {
    const items = originItems as PageItem[];
    // 这里要做一件事,新的 items 会把 y 的坐标全部重新排过,这会有问题,表现为突然弹跳位置。
    // 如果新的 items 中和旧的 items 中有共同的 item, 那么以旧的 item 的 y 为准,保持不变
    // 那么新出现的,排在旧的上面或下面
    startTransition(() => {
      startTransition(() => {
        setDisplayItems((old) => diffAndUpdateY(old, items));
      });
    });
}


<VirtualScrollBar
    items={pages}
    onVisibleItemChange={onVisibleItemChange}
    onScroll={onScroll}
></VirtualScrollBar>

核心功能:如何实现页面平滑切换

不过你会发现,页面是一卡一卡的,像是幻灯片,子元素没有随着滚动而移动的。只是会到达一定滚动距离后,就会全部替换成新的元素。因为我们还没有做偏移,元素要随着滚动在容器内上下移动,直到移动到容器外,才替换成新的元素。实现偏移:所以我们把整体元素 y 值随着滚动偏移, y = y + offsetY, 这样元素就会滚动效果 。

image.png

要想实现流畅滚动(平滑切换),还要实现新旧元素 Diff ,原理如下:

如上图,假设我们当前视图显示了 [元素1、元素2],子元素高度都是 200px,滚动了 30px 后,显示了 [元素1、元素2、元素 3]。

元素 1、元素 2 在是它们的交集。所以元素 1、元素 2 的位置要保持以前的位置不变(用户界面能看到的已有的元素,不能因为切换了新的元素而改变位置),而元素 3 应该在元素 2 后面。

元素 初始坐标(滚动前) 滚动后坐标(向下滚动 30px)
元素1 [0, 0] [0, -30]
元素2 [0, 200] [0, 170]
元素3 [0, 370]

做完 diff 后,从用户的角度就会发现是连续的滚动,而实际是不停的在切换新元素。

性能优化:异步加载,减少线程阻塞

因为滚动的过程中是连续的,例如从第1页滚到 100页,那么中间的 2-99 都会被渲染一遍,其实我只是想看第 100 页,这会严重拖慢页面的渲染性能。

// page.ts
useEffect(() => {
    if (!blocks.length) return;

    // 延迟渲染定时器
    const timer = setTimeout(() => {
      // 开始渲染
      setDisplayBlocks(blocks);
    }, 500);

    return () => {
      clearTimeout(timer);
    };
  }, [blocks]);

上面我用了一个定时器,完美解决了这个问题,只有等组件出现在可视区域,渲染后且 500 毫秒内没有消失,才会真正渲染。这样就能避免无效的渲染任务。

500 毫秒最终使用过程中被我改成了 200,不然会出现明显的等待渲染过程,影响体验。

性能对比

页面加载速度测试,我采用目前开源中用的最多的 react-pdflm-pdf 作对比:

  • 测试指标: 首页渲染时间
  • 测试网速:13.9 Mbps
PDF 测试文件页码 react-pdf lm-pdf
3页 3.5s 1s
50页 7s 1.5s
344页 109s 2.5s
1000页 240s 2.5s

react-pdf 的加载速度取决于文档的大小,下载的网速影响。而 lm-pdf 的优势在于无论多少页,都趋近于 2.5s,打开的速度取决于单页的图片大小。

lm-pdf 优缺点

优点:

  • 极快的首次加载速度(和文件大小、页数无关)
  • 丝滑的滚动体验
  • 极低的内存占用
  • 极少的页面 DOM

缺点:

  • 目前不支持复制 PDF 文本(研究中)
  • PDF 必须先被切成图片

持续优化的点:其实还可以在远端无损压缩图片,进一步提高渲染速度。

总结

如果你看重的是加速速度和性能,lm-pdf 绝对能满足你的需求,不过此方案还存在一些局限,例如强依赖后端生成 pdf 单页图片、不支持复制 PDF 文本等,不过目前已开源,后续还会持续完善,也希望感兴趣的同学一起 PR 共建。

Golang HTTP请求超时与重试:构建高可靠网络请求|得物技术

一、序 言

在分布式系统中,网络请求的可靠性直接决定了服务质量。想象一下,当你的支付系统因第三方API超时导致订单状态不一致,或因瞬时网络抖动造成用户操作失败,这些问题往往源于HTTP客户端缺乏完善的超时控制和重试策略。Golang标准库虽然提供了基础的HTTP客户端实现,但在高并发、高可用场景下,我们需要更精细化的策略来应对复杂的网络环境。

二、超时控制的风险与必要性

2024年Cloudflare的网络报告显示,78%的服务中断事件与不合理的超时配置直接相关。当一个HTTP请求因目标服务无响应而长时间阻塞时,不仅会占用宝贵的系统资源,更可能引发级联故障——大量堆积的阻塞请求会耗尽连接池资源,导致新请求无法建立,最终演变为服务雪崩。超时控制本质上是一种资源保护机制,通过设定合理的时间边界,确保单个请求的异常不会扩散到整个系统。

超时配置不当的两大典型风险:

  • DoS攻击放大效应:缺乏连接超时限制的客户端,在遭遇恶意慢响应攻击时,会维持大量半开连接,迅速耗尽服务器文件描述符。
  • 资源利用率倒挂:当ReadTimeout设置过长(如默认的0表示无限制),慢请求会长期占用连接池资源。Netflix的性能数据显示,将超时时间从30秒优化到5秒后,连接池利用率提升了400% ,服务吞吐量增长2.3倍。

三、超时参数示例

永远不要依赖默认的http.DefaultClient,其Timeout为0(无超时)。生产环境必须显式配置所有超时参数,形成防御性编程习惯。

以下代码展示如何通过net.Dialer配置连接超时和keep-alive策略:

transport := &http.Transport{
    DialContext: (&net.Dialer{
        Timeout:   3 * time.Second,  // TCP连接建立超时
        KeepAlive: 30 * time.Second, // 连接保活时间
        DualStack: true,             // 支持IPv4/IPv6双栈
    }).DialContext,
    ResponseHeaderTimeout: 5 * time.Second, // 等待响应头超时
    MaxIdleConnsPerHost:   100,             // 每个主机的最大空闲连接
}
client := &http.Client{
    Transport: transport,
    Timeout:   10 * time.Second, // 整个请求的超时时间
}

四、基于context的超时实现

context.Context为请求超时提供了更灵活的控制机制,特别是在分布式追踪和请求取消场景中。与http.Client的超时参数不同,context超时可以实现请求级别的超时传递,例如在微服务调用链中传递超时剩余时间。

4.1 上下文超时传递

如图所示,context通过WithTimeout或WithDeadline创建超时上下文,在请求过程中逐级传递。当父context被取消时,子context会立即终止请求,避免资源泄漏。

4.2 带追踪的超时控制

func requestWithTracing(ctx context.Context) (*http.Response, error) {
    // 从父上下文派生5秒超时的子上下文
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel() // 确保无论成功失败都取消上下文
    
    req, err := http.NewRequestWithContext(ctx, "GET", "https://api.example.com/data", nil)
    if err != nil {
        return nil, fmt.Errorf("创建请求失败: %v", err)
    }
    
    // 添加分布式追踪信息
    req.Header.Set("X-Request-ID", ctx.Value("request-id").(string))
    
    client := &http.Client{
        Transport: &http.Transport{
            DialContext: (&net.Dialer{
                Timeout: 2 * time.Second,
            }).DialContext,
        },
        // 注意: 此处不设置Timeout,完全由context控制
    }
    
    resp, err := client.Do(req)
    if err != nil {
        // 区分上下文取消和其他错误
        if ctx.Err() == context.DeadlineExceeded {
            return nil, fmt.Errorf("请求超时: %w", ctx.Err())
        }
        return nil, fmt.Errorf("请求失败: %v", err)
    }
    return resp, nil
}

关键区别:context.WithTimeout与http.Client.Timeout是叠加关系而非替代关系。当同时设置时,取两者中较小的值。

五、重试策略

网络请求失败不可避免,但盲目重试可能加剧服务负载,甚至引发惊群效应。一个健壮的重试机制需要结合错误类型判断、退避算法和幂等性保证,在可靠性和服务保护间取得平衡。

5.1 指数退避与抖动

指数退避通过逐渐增加重试间隔,避免对故障服务造成二次冲击。Golang实现中需加入随机抖动,防止多个客户端同时重试导致的波峰效应

以下是简单的重试实现示例:

type RetryPolicy struct {
    MaxRetries    int
    InitialBackoff time.Duration
    MaxBackoff    time.Duration
    JitterFactor  float64 // 抖动系数,建议0.1-0.5
}


// 带抖动的指数退避
func (rp *RetryPolicy) Backoff(attempt int) time.Duration {
    if attempt <= 0 {
        return rp.InitialBackoff
    }
    // 指数增长: InitialBackoff * 2^(attempt-1)
    backoff := rp.InitialBackoff * (1 << (attempt - 1))
    if backoff > rp.MaxBackoff {
        backoff = rp.MaxBackoff
    }
    // 添加抖动: [backoff*(1-jitter), backoff*(1+jitter)]
    jitter := time.Duration(rand.Float64() * float64(backoff) * rp.JitterFactor)
    return backoff - jitter + 2*jitter // 均匀分布在抖动范围内
}


// 通用重试执行器
func Retry(ctx context.Context, policy RetryPolicy, fn func() error) error {
    var err error
    for attempt := 0; attempt <= policy.MaxRetries; attempt++ {
        if attempt > 0 {
            // 检查上下文是否已取消
            select {
            case <-ctx.Done():
                return fmt.Errorf("重试被取消: %w", ctx.Err())
            default:
            }
            
            backoff := policy.Backoff(attempt)
            timer := time.NewTimer(backoff)
            select {
            case <-timer.C:
            case <-ctx.Done():
                timer.Stop()
                return fmt.Errorf("重试被取消: %w", ctx.Err())
            }
        }
        
        err = fn()
        if err == nil {
            return nil
        }
        
        // 判断是否应该重试
        if !shouldRetry(err) {
            return err
        }
    }
    return fmt.Errorf("达到最大重试次数 %d: %w", policy.MaxRetries, err)
}

5.2 错误类型判断

盲目重试所有错误不仅无效,还可能导致数据不一致。shouldRetry函数需要精确区分可重试错误类型:

func shouldRetry(err error) bool {
    // 网络层面错误
    var netErr net.Error
    if errors.As(err, &netErr) {
        // 超时错误和临时网络错误可重试
        return netErr.Timeout() || netErr.Temporary()
    }
    
    // HTTP状态码判断
    var respErr *url.Error
    if errors.As(err, &respErr) {
        if resp, ok := respErr.Response.(*http.Response); ok {
            switch resp.StatusCode {
            case 429, 500, 502, 503, 504:
                return true // 限流和服务器错误可重试
            case 408:
                return true // 请求超时可重试
            }
        }
    }
    
    // 应用层自定义错误
    if errors.Is(err, ErrRateLimited) || errors.Is(err, ErrServiceUnavailable) {
        return true
    }
    
    return false
}

行业最佳实践:Netflix的重试策略建议:对5xx错误最多重试3次,对429错误使用Retry-After头指定的间隔,对网络错误使用指数退避(初始100ms,最大5秒)。

六、幂等性保证

重试机制的前提是请求必须是幂等的,否则重试可能导致数据不一致(如重复扣款)。实现幂等性的核心是确保多次相同请求产生相同的副作用,常见方案包括请求ID机制和乐观锁。

6.1 请求ID+Redis实现

基于UUID请求ID和Redis的幂等性检查机制,可确保重复请求仅被处理一次:

type IdempotentClient struct {
    redisClient *redis.Client
    prefix      string        // Redis键前缀
    ttl         time.Duration // 幂等键过期时间
}


// 生成唯一请求ID
func (ic *IdempotentClient) NewRequestID() string {
    return uuid.New().String()
}


// 执行幂等请求
func (ic *IdempotentClient) Do(req *http.Request, requestID string) (*http.Response, error) {
    // 检查请求是否已处理
    key := fmt.Sprintf("%s:%s", ic.prefix, requestID)
    exists, err := ic.redisClient.Exists(req.Context(), key).Result()
    if err != nil {
        return nil, fmt.Errorf("幂等检查失败: %v", err)
    }
    if exists == 1 {
        // 返回缓存的响应或标记为重复请求
        return nil, fmt.Errorf("请求已处理: %s", requestID)
    }
    
    // 使用SET NX确保只有一个请求能通过检查
    set, err := ic.redisClient.SetNX(
        req.Context(),
        key,
        "processing",
        ic.ttl,
    ).Result()
    if err != nil {
        return nil, fmt.Errorf("幂等锁失败: %v", err)
    }
    if !set {
        return nil, fmt.Errorf("并发请求冲突: %s", requestID)
    }
    
    // 执行请求
    client := &http.Client{/* 配置 */}
    resp, err := client.Do(req)
    if err != nil {
        // 请求失败时删除幂等标记
        ic.redisClient.Del(req.Context(), key)
        return nil, err
    }
    
    // 请求成功,更新幂等标记状态
    ic.redisClient.Set(req.Context(), key, "completed", ic.ttl)
    return resp, nil
}

关键设计:幂等键的TTL应大于最大重试周期+业务处理时间。例如,若最大重试间隔为30秒,处理耗时5秒,建议TTL设置为60秒,避免重试过程中键过期导致的重复处理。

6.2 业务层幂等策略

对于写操作,还需在业务层实现幂等逻辑:

  • 更新操作:使用乐观锁(如UPDATE ... WHERE version = ?)
  • 创建操作:使用唯一索引(如订单号、外部交易号)
  • 删除操作:采用"标记删除"而非物理删除

七、性能优化

高并发场景下,HTTP客户端的性能瓶颈通常不在于网络延迟,而在于连接管理和内存分配。通过合理配置连接池和复用资源,可显著提升吞吐量。

7.1 连接池配置

http.Transport的连接池参数优化对性能影响巨大,以下是经过生产验证的配置:

func NewOptimizedTransport() *http.Transport {
    return &http.Transport{
        // 连接池配置
        MaxIdleConns:        1000,  // 全局最大空闲连接
        MaxIdleConnsPerHost: 100,   // 每个主机的最大空闲连接
        IdleConnTimeout:     90 * time.Second, // 空闲连接超时时间
        
        // TCP配置
        DialContext: (&net.Dialer{
            Timeout:   2 * time.Second,
            KeepAlive: 30 * time.Second,
        }).DialContext,
        
        // TLS配置
        TLSHandshakeTimeout: 5 * time.Second,
        TLSClientConfig: &tls.Config{
            InsecureSkipVerify: false,
            MinVersion:         tls.VersionTLS12,
        },
        
        // 其他优化
        ExpectContinueTimeout: 1 * time.Second,
        DisableCompression:    false, // 启用压缩
    }
}

Uber的性能测试显示,将MaxIdleConnsPerHost从默认的2提升到100后,针对同一API的并发请求延迟从85ms降至12ms,吞吐量提升6倍。

7.2 sync.Pool内存复用

频繁创建http.Request和http.Response会导致大量内存分配和GC压力。使用sync.Pool复用这些对象可减少90%的内存分配:

var requestPool = sync.Pool{
    New: func() interface{} {
        return &http.Request{
            Header: make(http.Header),
        }
    },
}


// 从池获取请求对象
func AcquireRequest() *http.Request {
    req := requestPool.Get().(*http.Request)
    // 重置必要字段
    req.Method = ""
    req.URL = nil
    req.Body = nil
    req.ContentLength = 0
    req.Header.Reset()
    return req
}


// 释放请求对象到池
func ReleaseRequest(req *http.Request) {
    requestPool.Put(req)
}

八、总结

HTTP请求看似简单,但它连接着整个系统的"血管"。忽视超时和重试,就像在血管上留了个缺口——平时没事,压力一来就大出血。构建高可靠的网络请求需要在超时控制、重试策略、幂等性保证和性能优化之间取得平衡。

记住,在分布式系统中,超时和重试不是可选功能,而是生存必需。

扩展资源:

往期回顾

  1. RN与hawk碰撞的火花之C++异常捕获|得物技术

  2. 得物TiDB升级实践

  3. 得物管理类目配置线上化:从业务痛点到技术实现

  4. 大模型如何革新搜索相关性?智能升级让搜索更“懂你”|得物技术

  5. RAG—Chunking策略实战|得物技术

文 /梧

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

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

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

我为什么说全栈正在杀死前端?

大家好,我又来了🤣。 打开2025年的招聘软件,十个资深前端岗位,有八个在JD(职位描述)里写着:“有Node.js/Serverless/全栈经验者优先”。 全栈 👉 成了我们前端工程师内卷的一种方

颜色网站为啥都收费?自己做个要花多少钱?

你是小阿巴,一位没有对象的程序员。

这天深夜,你打开了某个颜色网站,准备鉴赏一些精彩的视频教程。

结果一个大大的付费弹窗阻挡了你!

你心想:可恶,为啥颜色网站都要收费啊?

作为一名程序员,你怎能甘心?

于是你决定自己做一个,不就是上传视频、播放视频嘛?

这时,经常给大家分享 AI 和编程知识的 鱼皮 突然从你身后冒了出来:天真!你知道自己做一个要花多少钱么?

你吓了一跳:我又没做过这种网站,怎么知道要花多少?

难道,你做过?

鱼皮一本正经:哼,当然…… 没有。

不过我做过可以看视频的、技术栈完全类似的 编程学习网站,所以很清楚这类网站的成本。

你来了兴趣:哦?愿闻其详。

鱼皮笑了笑:那我就以 编程导航 项目为例,从网站开发、上线到运营的完整流程,给你算算做一个视频网站到底要花多少钱。还能教你怎么省钱哦~

你点了个赞,并递上了两个硬币:好啊,快说快说!


鱼皮特别感谢朋友们的支持,你们的鼓励是我持续创作的动力 🌹!

⚠️ 友情声明:以下成本是基于个人经验 + 专业云服务商价格的估算(不考虑折扣),仅供参考。

⭐️ 推荐观看本文对应视频版:bilibili.com/video/BV1nJ…

服务器

想让别人访问你的网站,首先你要有一台服务器。

你点点头:我知道,代码文件都要放到服务器上运行,用户通过浏览器访问网站,其实是在向服务器请求网页文件和数据。

那服务器怎么选呢?

鱼皮:服务器的配置要看你的网站规模。刚开始做个小型视频网站,可以用入门配置的轻量应用服务器 (比如 2 核 CPU、2G 内存、4M 带宽) ,一年几百块就够了。

等后续用户多了,服务器带宽跟不上了再升级。比如 4 核 CPU、16G 内存、14M 带宽,一年差不多几千块。

你:几百块?比我想的便宜啊。

鱼皮:没错,国内云服务现在竞争很激烈、动不动就搞优惠。

但是要注意,如果你想做 “那种网站”,就要考虑用海外服务器了(好处是不用备案)。

咳咳,我们不谈这个……

数据库

有了服务器,还得有数据库,用来存储网站的用户信息、视频信息、评论点赞这些数据。

你:这个简单,数据库不就是 MySQL、PostgreSQL 这些嘛,装在服务器上不就行了?

鱼皮:是可以的,但我更建议使用云数据库服务,比如阿里云 RDS 或者腾讯云的云数据库。

你:为啥?不是要多花钱吗?

鱼皮:因为云数据库更稳定,而且自带备份、容灾、监控这些功能,你自己搞的话,还要费时费力安装维护,万一数据丢了可就麻烦了。

你:确实,那得多少钱?

鱼皮:入门级的云数据库(比如 2 核 4G 内存、100GB 硬盘)包年大概 2000 元左右。后面用户多了、数据量大了,就要升级配置(比如 4 核 16G),那一年就要 1 万多了。不过那个时候你已经赚麻了……

Redis

鱼皮:对了,我还建议你加个 Redis 缓存。

你挠了挠头:Redis?之前看过你的 讲解视频。这个是必须的吗?

鱼皮:刚开始可以没有,但如果你想让网站数据能更快加载,强烈建议用。

你想啊,视频网站用户一进来都要查看视频列表、热门推荐这些,如果用 Redis 把热点数据缓存起来,响应速度能快好几倍,还能帮数据库分摊查询压力。

你:确实,网站更快用户更爽,也更愿意付费。那 Redis 要多少钱?

鱼皮:Redis 比数据库便宜一些。入门级的 Redis 服务一年大概 1000 元左右。

你松了口气:也还行吧,看来做个视频网站也花不了多少钱啊!

对象存储

鱼皮:别急,接下来才是重点!

我问问你,视频文件保存在哪儿?

你不假思索:当然是存在服务器的硬盘上!

鱼皮哈哈大笑:别开玩笑了,一个高清视频动不动就几百 MB 甚至几个 G,你那点儿服务器硬盘能存几个视频?

而且服务器带宽有限,如果同时有很多用户看视频,服务器根本撑不住!

你:那咋办啊!

鱼皮:更好的做法是用 对象存储,比如阿里云 OSS、腾讯云 COS。

对象存储是专门用来存海量文件的云服务,它容量几乎无限、可以弹性扩展,而且访问速度快、稳定性高,很适合存储图片和音视频这些大文件。

你:贵吗?

鱼皮:存储本身不贵,100GB 一年也就几十块钱。但 真正贵的是流量费用

用户每看一次视频,都要从对象存储下载数据,这就产生了流量。

如果一个 1 GB 的视频被完整播放 1000 次,那就是 1000 GB 的流量,大概 500 块钱。

你看那些视频网站,每天光 1 个视频可能就有 10 万人看过,价格可想而知。

你惊讶地说不出话来:阿巴阿巴……

视频转码

鱼皮接着说:这还不够!对于视频网站,你还要做 视频转码。因为用户上传的视频格式、分辨率、编码方式都不一样,你需要把它们统一转成适合网页播放的格式,还要生成不同清晰度的版本让用户选择(标清、高清、超清)。

你:啊,那不是要多存好几个不同清晰度的视频文件?

鱼皮:没错,而且转码本身也是要钱的!

一般按照清晰度和视频分钟数计费。如果你上传 1000 个小时的高清视频,光转码费就得几千块!

CDN 加速

你急了:怎么做个视频网站处处都要花钱啊!有没有便宜点的办法?

鱼皮笑道:可以用 CDN。

你:CDN是啥?听着就高级!

鱼皮:CDN 叫内容分发网络,简单说就是把你的视频缓存到全国各地的服务器节点上。用户看视频的时候,从最近的节点拿数据,不仅速度更快,而且流量费比对象存储便宜不少。

你眼睛一亮:这么好?那不是必用 CDN!

鱼皮:没错,一般建议对象存储配合 CDN 使用。

而且视频网站 一定要做好流量防刷和安全防护

现在有的平台自带了流量防盗刷功能:

此外,建议手动添加更多流量安全配置。

1)设置访问频率限制,防止短时间被盗刷大量流量

2)还要配置 CDN 的流量告警,超过阈值及时得到通知

3)还要启用 referer 防盗链,防止别人盗用你的视频链接,用你的流量做网站捞钱。

如果不做这些,可能分分钟给你刷破产了!

你:这我知道,之前看过很多你破产和被攻击的视频!

鱼皮:我 ***!

视频点播

你:为了给用户看个视频,我要先用对象存储保存文件、再通过云服务转码视频、再通过 CDN 给用户加速访问,感觉很麻烦啊!

鱼皮神秘一笑:嘿嘿,其实还有更简单的方案 —— 视频点播服务,这是快速实现视频网站的核心。

只需要通过官方提供的 SDK 代码包和示例代码,就能快速完成视频上传、转码、多清晰度切换、加密保护等功能。

此外,还提供了 CDN 内容加速和各端的视频播放器。

你双眼放光:这么厉害,如果我自己从零开发这些功能,至少得好几个月啊!

鱼皮:没错,视频点播服务相当于帮你做了整合,能大幅提高开发效率。

但是它的费用也包含了存储费、转码费和流量费,价格跟前面提到的方案不相上下。

你叹了口气:唉,主要还是流量费太贵了啊……

网站上线还要准备啥?

鱼皮:讲完了开发视频网站需要的技术,接下来说说网站上线还需要的其他东西。

你:啊?还有啥?

鱼皮:首先,你得有个 域名 给用户访问吧?总不能让人家记你的 IP 地址吧?

不过别担心,普通域名一年也就几十块钱(比如我的 codefather.cn 才 38 / 年)。

当然,如果是稀缺的好域名就比较贵了,几百几千万的都有!

你:别说了,俺随便买个便宜的就行……

鱼皮:买了域名还得配 SSL 证书,因为现在做网站都得用 HTTPS 加密传输,不然浏览器会提示 “不安全”,用户看了就跑了。

刚开始可以直接用 Let's Encrypt 提供的免费证书,但只有 3 个月有效期,到期要手动续期,比较麻烦。

想省心的话可以买付费证书,便宜的一年几百块。

你:了解,那我就先用免费的,看来上线也花不了几个钱。

鱼皮:哎,可不能这么说,网站正式上线运营后,花钱的地方可多着呢!尤其是安全防护。

安全防护

做视频网站要面对两大安全威胁。第一个是 内容安全,你总不能让用户随便上传违规视频吧?万一上传了不该传的内容,网站直接就被封了。

你紧张起来:对啊,我人工审核也看不过来啊…… 怎么办?

鱼皮:可以用内容审核服务。视频审核包含画面和声音两部分,比文字审核更贵,审核 1000 小时视频,大概几千块。

你:还有第二个威胁呢?

鱼皮:第二个是最最最难应对的 网络攻击。做视频网站,尤其是有付费内容的,特别容易被攻击。DDoS 流量攻击想把你冲垮、SQL 注入想偷你数据、XSS 攻击想搞你用户、爬虫想盗你视频……

你:这么坏的吗?那我咋防啊!

鱼皮:常用的是 Web 应用防火墙(WAF)和 DDoS 防护服务。Web 防火墙能防 SQL 注入、XSS 攻击这些应用层攻击,而 DDoS 防护能抵御大规模流量冲击。

但是这些商业级服务都挺贵的,可能一年就是几万几十万……

你惊呼:我为了防止被攻击,还要搭这么多钱?!

鱼皮笑了:好消息是,有些云服务商会提供一点点免费的 DDoS 基础防护,还有相对便宜的轻量版 DDoS 防护包。

我的建议是,刚开始就先用免费的,加上代码里做好防 SQL 注入、XSS 这些安全措施,其实够用了。等网站真做起来、有收入了,再花钱买商业级的防护服务就好。

你点了点头:是呀,如果没收入,被攻击就被攻击吧,哼!

鱼皮微笑道:你这心态也不错哈哈。除了刚才说的这些,随着你网站的成熟,还可能会用到很多第三方服务,比如短信验证码、邮件推送、 等等,这些也都是成本。

总成本

讲到这里,你应该已经了解了视频网站的整个技术架构和成本。

最后再总结一下,如果一个人做个小型的视频网站,一年到底要花多少钱?

你看着这个表,倒吸一口凉气:视频网站的成本真高啊……

鱼皮:没错,这还只是保守估计。如果你的网站真火了,每天几万人看视频,一年光流量费就得有几十万吧。

而且刚才说的都只是网站本身的成本,如果你一个人做累了,要组个团队开发呢?

按照一线城市的成本算算,前端开发 + 后端开发 + 测试工程师 + 运维工程师,再加上五险一金,差不多每月要接近 10 万了。

你瞪大眼睛:那一年就是一百万?

鱼皮:没错,人力成本才是最贵的。

你:好了你别说了,我不做了,我不做了!我现在终于理解为什么那些网站都要收费了……

鱼皮:不过说实话,虽然成本不低,但那些网站收费真的太贵了,其实成本远没那么高,更多的是利用人性赚取暴利!

所以比起花钱看那些乱七八糟的网站,把钱和时间投资在学习上,才是最有价值的。

你点了点头:这次一定!再看一期你的教程,我就睡觉啦~

更多

💻 编程学习交流:编程导航 📃 简历快速制作:老鱼简历 ✏️ 面试刷题神器:面试鸭

LangChain 1.0 发布:agent 框架正式迈入生产级

最近在使用 NestJs 和 NextJs 在做一个协同文档 DocFlow,如果感兴趣,欢迎 star,有任何疑问,欢迎加我微信进行咨询 yunmz777

随着大型语言模型(LLM)逐渐从「实验」走向「生产可用」,越来越多开发团队意识到:模型本身已不是唯一挑战,更关键的是 agent、流程编排、工具调用、人机  in‑the‑loop 及持久状态管理等机制的落地。 在这样的背景下,开源框架  LangChain 和 LangGraph 同步迈入  v1.0  版本——这不只是版本号的更新,而是“从实验架构迈向生产级 agent 系统”的关键一步。

核心更新概览

LangChain 1.0

  • LangChain 一直定位于“与 LLM  交互 + 构建 agent” 的高层框架。它通过标准的模型抽象、预构建的 agent 模式,帮助开发者快速上线 AI  能力。

  • 在过去几年里,社区反馈主要集中在:抽象过重、包的 surface area(命名空间、模块)过大、希望对 agent loop 有更多控制但又不想回归调用原始 LLM。

  • 为此,1.0  版重点做了:

    1. 新的 create_agent(或在 JS  里 createAgent)抽象 — 最快构建 agent 的方法。
    2. “标准内容块”(standard content blocks) — 提供跨模型/提供商的一致输出规范。
    3. 精简包的内容(streamlined surface area) — 将不常用或遗留功能移到 langchain‑classic(或类似)以简化主包。

LangGraph 1.0

  • LangGraph 定位较低层,主要用于"状态持久化 + 可控流程 + 人机介入"的场景。换句话说,当 agent 不只是"输入 → 模型 → 工具 → 输出"那么简单,而是需要"多节点、可暂停、可恢复、多人协作"的复杂流程时,LangGraph 在背后支撑底层运行。

  • 核心特性包括:

    • 可耐久状态(durable state)– agent 执行状态自动保存、服务器中断后可恢复。
    • 内置持久化机制 – 无需开发者为数据库状态管理写很多 boilerplate。
    • 图(graph)执行模型 – 支持复杂流程、分支、循环,而不是简单线性流程。
  • 值得注意的是:LangChain 1.0 的 agent 实际上是构建在 LangGraph 的运行时之上。这意味着从高层使用 LangChain  时,确实获得了底层更强的支持。

Node.js 示例

下面以 Node.js 演示两种场景:

  1. 快速构建一个天气 agent(使用  LangChain  高层抽象)
  2. 增强为结构化输出 + 工具调用示例

安装

npm install @langchain/langchain@latest
npm install @langchain/langgraph@latest

注:请根据实际包名/版本确认,因为官方可能更新命名空间或路径。

示例  1:快速 agent

import { createAgent } from "@langchain/langchain/agents";

async function runWeatherAgent() {
  // 定义一个工具函数,假设已实现
  const getWeatherTool = {
    name: "getWeather",
    description: "获取指定城市天气",
    async call(input) {
      // 这里是工具调用逻辑,例如调用天气 API
      const { city } = input;
      // 模拟返回
      return { temperature: 26, condition: "Sunny", city };
    },
  };

  const weatherAgent = createAgent({
    model: "openai:gpt‑5", // 根据实际模型提供者调整
    tools: [getWeatherTool],
    systemPrompt: "Help the user by fetching the weather in their city.",
  });

  const result = await weatherAgent.invoke({
    role: "user",
    content: "What's the weather in San Francisco?",
  });

  console.log("Result:", result);
}

runWeatherAgent().catch(console.error);

示例  2:结构化输出 + 工具调用

import { createAgent } from "@langchain/langchain/agents";
import { ToolStrategy } from "@langchain/langchain/agents/structured_output";

// 定义结构化输出类型(用 TypeScript 更佳)
class WeatherReport {
  constructor(temperature, condition) {
    this.temperature = temperature;
    this.condition = condition;
  }
}

async function runStructuredWeatherAgent() {
  const weatherTool = {
    name: "weatherTool",
    description: "Fetch the weather for a city",
    async call({ city }) {
      // 调用外部天气API
      return { temperature: 20.5, condition: "Cloudy", city };
    },
  };

  const agent = createAgent({
    model: "openai:gpt‑4o‑mini",
    tools: [weatherTool],
    responseFormat: ToolStrategy(WeatherReport),
    prompt: "Help the user by fetching the weather in their city.",
  });

  const output = await agent.invoke({
    role: "user",
    content: "What’s the weather in Tokyo today?",
  });

  console.log("Structured output:", output);
}

runStructuredWeatherAgent().catch(console.error);

实践注意事项

  • 模型提供者(如  OpenAI、Anthropic、Azure  等)具体接入方式、身份认证、费用控制,需要在项目中自行配置。
  • 工具(tools)需要自行定义:名称、描述、调用逻辑、输入/输出格式。务必做好错误处理与超时控制。
  • 结构化输出(如  ToolStrategy)可提升模型结果的一致性、安全性,但需定义好对应的类/接口/类型。上面示例仅为简化版。
  • 当流程更复杂(例如多步、环节审核、人机交互、长期挂起)时,建议使用  LangGraph  底层能力。

何时选用  LangChain vs LangGraph?

虽然二者紧密相关,但从实用视角来看,有以下建议:

场景 推荐框架 理由
快速构建、标准 agent 流程(模型 → 工具 → 响应)、不需要复杂流程控制 LangChain 1.0 高层抽象快上手,已封装常用模式。
需要流程编排、状态持久化、长流程运行、人工介入、分支逻辑 LangGraph 1.0 支持图执行、持久状态、人机互动,适合生产级 agent。

换句话说,如果你需要“快速构建一个 agent”去实验或上线 MVP,用  LangChain  足够。如果你要做“真正可用、需运维、需被监控、流程可暂停可恢复”的 agent 系统,那  LangGraph  是更合适的底层框架。官方也指出,LangChain 的 agent 是构建在 LangGraph 之上的。

总结

LangChain 1.0 标志着 agent 框架从实验阶段正式迈入生产级应用。本次更新重点解决了抽象过重、包体积过大等问题,推出了 createAgent 这一快速构建 agent 的核心 API,并引入标准内容块以实现跨模型的一致输出。更重要的是,LangChain 1.0 的 agent 运行在 LangGraph 运行时之上,为开发者提供了更强的底层支持。对于需要快速构建标准 agent 流程的场景,LangChain 1.0 提供了简洁的高层抽象;而当需求涉及复杂流程编排、状态持久化时,可以深入使用底层的 LangGraph 能力。

得物TiDB升级实践

一、背 景

得物DBA自2020年初开始自建TiDB,5年以来随着NewSQL数据库迭代发展、运维体系逐步完善、产品自身能力逐步提升,接入业务涵盖了多个业务线和关键场景。从第一套TIDB v4.0.9 版本开始,到后来v4.0.11、v5.1.1、v5.3.0,在经历了各种 BUG 踩坑、问题调试后,最终稳定在 TIDB 5.3.3 版本。伴随着业务高速增长、数据量逐步增多,对 TiDB 的稳定性及性能也带来更多挑战和新的问题。为了应对这些问题,DBA团队决定对 TiDB 进行一次版本升级,收敛版本到7.5.x。本文基于内部的实践情况,从架构、新特性、升级方案及收益等几个方向讲述 TiDB 的升级之旅。

二、TiDB 架构

TiDB 是分布式关系型数据库,高度强兼容 MySQL 协议和 MySQL 生态,稳定适配 MySQL 5.7 和MySQL 8.0常用的功能及语法。随着版本的迭代,TiDB 在弹性扩展、分布式事务、强一致性基础上进一步针对稳定性、性能、易用性等方面进行优化和增强。与传统的单机数据库相比,TiDB具有以下优势:

  • 分布式架构,拥有良好的扩展性,支持对业务透明灵活弹性的扩缩容能力,无需分片键设计以及开发运维。
  • HTAP 架构支撑,支持在处理高并发事务操作的同时,对实时数据进行复杂分析,天然具备事务与分析物理隔离能力。
  • 支持 SQL 完整生态,对外暴露 MySQL 的网络协议,强兼容 MySQL 的语法/语义,在大多数场景下可以直接替换 MySQL。
  • 默认支持自愈高可用,在少数副本失效的情况下,数据库本身能够自动进行数据修复和故障转移,对业务无感。
  • 支持 ACID 事务,对于一些有强一致需求的场景友好,满足 RR 以及 RC 隔离级别,可以在通用开发框架完成业务开发迭代。

我们使用 SLB 来实现 TiDB 的高效负载均衡,通过调整 SLB 来管理访问流量的分配以及节点的扩展和缩减。确保在不同流量负载下,TiDB 集群能够始终保持稳定性能。在 TiDB 集群的部署方面,我们采用了单机单实例的架构设计。TiDB Server 和 PD Server 均选择了无本地 SSD 的机型,以优化资源配置,并降低开支。TiKV Server则配置在本地 SSD 的机型上,充分利用其高速读写能力,提升数据存储和检索的性能。这样的硬件配置不仅兼顾了系统的性能需求,又能降低集群成本。针对不同的业务需求,我们为各个组件量身定制了不同的服务器规格,以确保在多样化的业务场景下,资源得到最佳的利用,进一步提升系统的运行效率和响应速度。

三、TiDB v7 版本新特性

新版本带来了更强大的扩展能力和更快的性能,能够支持超大规模的工作负载,优化资源利用率,从而提升集群的整体性能。在 SQL 功能方面,它提升了兼容性、灵活性和易用性,从而助力复杂查询和现代应用程序的高效运行。此外,网络 IO 也进行了优化,通过多种批处理方法减少网络交互的次数,并支持更多的下推算子。同时,优化了Region 调度算法,显著提升了性能和稳定性。

四、TiDB升级之旅

4.1 当前存在的痛点

  • 集群版本过低:当前 TiDB 生产环境(现网)最新版本为 v5.3.3,目前官方已停止对 4.x 和 5.x 版本的维护及支持,TiDB 内核最新版本为 v8.5.3,而被用户广泛采用且最为稳定的版本是 v7.5.x。
  • TiCDC组件存在风险:TiCDC 作为增量数据同步工具,在 v6.5.0 版本以前在运行稳定性方面存在一定问题,经常出现数据同步延迟问题或者 OOM 问题。
  • 备份周期时间长:集群每天备份时间大于8小时,在此期间,数据库备份会导致集群负载上升超过30%,当备份时间赶上业务高峰期,会导致应用RT上升。
  • 集群偶发抖动及BUG:在低版本集群中,偶尔会出现基于唯一键查询的慢查询现象,同时低版本也存在一些影响可用性的BUG。比如在 TiDB v4.x 的集群中,TiKV 节点运行超过 2 年会导致节点自动重启。

4.2 升级方案:升级方式

TiDB的常见升级方式为原地升级和迁移升级,我们所有的升级方案均采用迁移升级的方式。

原地升级

  • 优势:方式较为简单,不需要额外的硬件,升级过程中集群仍然可以对外提供服务。
  • 劣势:该升级方案不支持回退、并且升级过程会有长时间的性能抖动。大版本(v4/v5 原地升级到 v7)跨度较大时,需要版本递增升级,抖动时间翻倍。

迁移升级

  • 优势:业务影响时间较短、可灰度可回滚、不受版本跨度的影响。
  • 劣势:搭建新集群将产生额外的成本支出,同时,原集群还需要部署TiCDC组件用于增量同步。

4.3 升级方案:集群调研

4.4 升级方案:升级前准备环境

4.5 升级方案:升级前验证集群

4.6 升级方案:升级中流量迁移

4.7 升级方案:升级后销毁集群

五、升级遇到的问题

5.1 v7.5.x版本查询SQL倾向全表扫描

表中记录数 215亿,查询 SQL存在合理的索引,但是优化器更倾向走全表扫描,重新收集表的统计信息后,执行计划依然为全表扫描。

走全表扫描执行60秒超时KILL,强制绑定索引仅需0.4秒。

-- 查询SQL
SELECT
  *
FROM
  fin_xxx_xxx
WHERE
  xxx_head_id = 1111111111111111
  AND xxx_type = 'XX0002'
  AND xxx_user_id = 11111111
  AND xxx_pay_way = 'XXX00000'
  AND is_del IN ('N', 'Y')
LIMIT
  1;


-- 涉及索引
KEY `idx_xxx` (`xxx_head_id`,`xxx_type`,`xxx_status`),

解决方案:

  • 方式一:通过 SPM 进行 SQL 绑定。
  • 方式二:调整集群参数 tidb_opt_prefer_range_scan,将该变量值设为 ON 后,优化器总是偏好区间扫描而不是全表扫描。

asktug.com/t/topic/104…

5.2 v7.5.x版本聚合查询执行计划不准确

集群升级后,在新集群上执行一些聚合查询或者大范围统计查询时无法命中有效索引。而低版本v4.x、5.x集群,会根据统计信息选择走合适的索引。

v4.0.11集群执行耗时:12秒,新集群执行耗时2分32.78秒

-- 查询SQL
select 
    statistics_date,count(1) 
from 
    merchant_assessment_xxx 
where 
    create_time between '2025-08-20 00:00:00' and '2025-09-09 00:00:00' 
group by 
    statistics_date order by statistics_date;


-- 涉及索引
KEY `idx_create_time` (`create_time`)

解决方案:

方式一:调整集群参数tidb_opt_objective,该变量设为 determinate后,TiDB 在生成执行计划时将不再使用实时统计信息,这会让执行计划相对稳定。

asktug.com/t/topic/104…

六、升级带来的收益

版本升级稳定性增强:v7.5.x 版本的 TiDB 提供了更高的稳定性和可靠性,高版本改进了SQL优化器、增强的分布式事务处理能力等,加快了响应速度和处理大量数据的能力。升级后相比之前整体性能提升40%。特别是在处理复杂 SQL 和多索引场景时,优化器的性能得到了极大的增强,减少了全表扫描的发生,从而显著降低了 TiKV 的 CPU 消耗和 TiDB 的内存使用。

应用平均RT提升44.62%

原集群RT(平均16.9ms)

新集群RT(平均9.36ms)

新集群平均RT提升50%,并且稳定性增加,毛刺大幅减少

老集群RT(平均250ms)

新集群RT(平均125ms)

提升TiCDC同步性能:新版本在数据同步方面有了数十倍的提升,有效解决了之前版本中出现的同步延迟问题,提供更高的稳定性和可靠性。当下游需要订阅数据至数仓或风控平台时,可以使用TiCDC将数据实时同步至Kafka,提升数据处理的灵活性与响应能力。

缩短备份时间:数据库备份通常会消耗大量的CPU和IO资源。此前,由于备份任务的结束时间恰逢业务高峰期,经常导致应用响应时间(RT)上升等问题。通过进行版本升级将备份效率提升了超过50%。

高压缩存储引擎:新版本采用了高效的数据压缩算法,能够显著减少存储占用。同时,通过优化存储结构,能够快速读取和写入数据,提升整体性能。相同数据在 TiDB 中的存储占用空间更低,iDB 的3副本数据大小仅为 MySQL(主实例数据大小)的 55%。

完善的运维体验:新版本引入更好的监控工具、更智能的故障诊断机制和更简化的运维流程,提供了改进的 Dashboard 和 Top SQL 功能,使得慢查询和问题 SQL 的识别更加直观和便捷,降低 DBA 的工作负担。

更秀更实用的新功能:TiDB 7.x版本提供了TTL定期自动删除过期数据,实现行级别的生命周期控制策略。通过为表设置 TTL 属性,TiDB 可以周期性地自动检查并清理表中的过期数据。此功能在一些场景可以有效节省存储空间、提升性能。TTL 常见的使用场景:

  • 定期删除验证码、短网址记录
  • 定期删除不需要的历史订单
  • 自动删除计算的中间结果

docs.pingcap.com/zh/tidb/v7.…

七、选择 TiDB 的原因

我们不是为了使用TiDB而使用,而是去解决一些MySQL无法满足的场景,关系型数据库我们还是优先推荐MySQL。能用分库分表能解决的问题尽量选择MySQL,毕竟运维成本相对较低、数据库版本更加稳定、单点查询速度更快、单机QPS性能更高这些特性是分布式数据库无法满足的。

  • 非分片查询场景:上游 MySQL 采用了分库分表的设计,但部分业务查询无法利用分片。通过自建 DTS 将 MySQL 数据同步到 TiDB 集群,非分片/聚合查询则使用 TiDB 处理,能够在不依赖原始分片结构的情况下,实现高效的数据查询和分析。
  • 分析 SQL 多场景:业务逻辑比较复杂,往往存在并发查询和分析查询的需求。通过自建 DTS 将 MySQL 数据同步到 TiDB,复杂查询在TiDB执行、点查在MySQL执行。TiDB支持水平扩展,其分布式计算和存储能力使其能够高效处理大量的并发查询请求。既保障了MySQL的稳定性,又提升了整体的查询能力。
  • 磁盘使用大场景:在磁盘使用率较高的情况下,可能会出现 CPU 和内存使用率低,但磁盘容量已达到 MySQL 的瓶颈。TiDB 能够自动进行数据分片和负载均衡,将数据分布在多个节点上, 缓解单一节点的磁盘压力,避免了传统 MySQL 中常见的存储瓶颈问题,从而提高系统的可扩展性和灵活性。
  • 数据倾斜场景:在电商业务场景上,每个电商平台都会有一些销量很好的头部卖家,数据量会很大。即使采取了进行分库分表的策略,仍难以避免大卖家的数据会存储在同一实例中,这样会导致热点查询和慢 SQL 问题,尽管可以通过添加索引或进一步分库分表来优化,但效果有限。采用分布式数据库能够有效解决这一问题。可以将数据均匀地分散存储在多个节点上,在查询时则能够并发执行,从而将流量分散,避免热点现象的出现。随着业务的快速发展和数据量的不断增长,借助简单地增加节点,即可实现水平扩展,满足海量数据及高并发的需求。

八、总结

综上所述,在本次 TiDB 集群版本升级到 v7.5.x 版本过程中,实现了性能和稳定性提升。通过优化的查询计划和更高效的执行引擎,数据读取和写入速度显著提升,大幅度降低了响应延迟,提升了在高并发操作下的可靠性。通过直观的监控界面和更全面的性能分析工具,能够更快速地识别和解决潜在问题,降低 DBA 的工作负担。也为未来的业务扩展和系统稳定性提供了强有力的支持。

后续依然会持续关注 TiDB 在 v8.5.x 版本稳定性、性能以及新产品特性带来应用开发以及运维人效收益进展。目前 TiDB 内核版本 v8.5.x 已经具备多模数据库 Data + AI 能力,在JSON函数、ARRAY 索引以及 Vector Index 实现特性。同时已经具备 Resource Control 资源管理能力,适合进行多业务系统数据归集方案,实现数据库资源池化多种自定义方案。技术研究方面我们数据库团队会持续投入,将产品最好的解决方案引入现网环境。

往期回顾

  1. 得物管理类目配置线上化:从业务痛点到技术实现

  2. 大模型如何革新搜索相关性?智能升级让搜索更“懂你”|得物技术

  3. RAG—Chunking策略实战|得物技术

  4. 告别数据无序:得物数据研发与管理平台的破局之路

  5. 从一次启动失败深入剖析:Spring循环依赖的真相|得物技术

文 /岱影

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

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

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

得物管理类目配置线上化:从业务痛点到技术实现

一、引言

在电商交易领域,管理类目作为业务责权划分、统筹、管理核心载体,随着业务复杂性的提高,其规则调整频率从最初的 1 次 / 季度到多次 / 季度,三级类目的规则复杂度也呈指数级上升。传统依赖数仓底层更新的方式暴露出三大痛点:

  • 行业无法自主、快速调管理类目;
  • 业务管理类目规则调整,不支持校验类目覆盖范围是否有重复/遗漏,延长交付周期;
  • 规则变更成功后、下游系统响应滞后,无法及时应用最新类目规则。

本文将从技术视角解析 “管理类目配置线上化” 项目如何通过全链路技术驱动,将规则迭代周期缩短至 1-2 天。

二、业务痛点与技术挑战:为什么需要线上化?

2.1 效率瓶颈:手工流程与

高频迭代的矛盾

问题场景:业务方需线下通过数仓提报规则变更,经数仓开发、测试、BI需要花费大量精力校验确认,一次类目变更需 3-4 周左右时间才能上线生效,上线时间无法保证。

技术瓶颈:数仓离线同步周期长(T+1),规则校验依赖人工梳理,无法应对 “商品类目量级激增”。

2.2 质量风险:规则复杂度与

校验能力的失衡

典型问题:当前的管理类目映射规则,依赖业务收集提报,但从实际操作看管理三级类目映射规则提报质量较差(主要原因为:业务无法及时校验提报规则是否准确,是否穷举完善,是否完全无交叉),存在大量重复 / 遗漏风险。

2.3 系统耦合:底层变更对

下游应用的多米诺效应

连锁影响:管理类目规则变更会需同步更新交易后台、智能运营系统、商运关系工作台等多下游系统,如无法及时同步,可能会影响下游应用如商运关系工作台的员工分工范围的准确性,影响商家找人、资质审批等场景应用。

三、技术方案:从架构设计到核心模块拆解

3.1 分层架构:解耦业务与数据链路

3.2 核心模块技术实现

规则生命周期管理: 规则操作流程

提交管理类目唯一性校验规则

新增:id为空,则为新增

删除:当前db数据不在提交保存列表中

更新:名称或是否兜底类目或规则改变则发生更新【其中如果只有名称改变则只触发审批,不需等待数据校验,业务规则校验逻辑为将所有规则包含id,按照顺序排序拼接之后结果是否相等】

多级类目查询

构建管理类目树

/**
 * 构建管理类目树
 */
public List<ManagementCategoryDTO> buildTree(List<ManagementCategoryEntity> managementCategoryEntities) {
    Map<Long, ManagementCategoryDTO> managementCategoryMap = new HashMap<>();
    for (ManagementCategoryEntity category : managementCategoryEntities) {
        ManagementCategoryDTO managementCategoryDTO = ManagementCategoryMapping.convertEntity2DTO(category);
        managementCategoryMap.put(category.getId(), managementCategoryDTO);
    }
    
    // 找到根节点
    List<ManagementCategoryDTO> rootNodes = new ArrayList<>();
    for (ManagementCategoryDTO categoryNameDTO : managementCategoryMap.values()) {
        //管理一级类目 parentId是0
        if (Objects.equals(categoryNameDTO.getLevel(), ManagementCategoryLevelEnum.FIRST.getId()) && Objects.equals(categoryNameDTO.getParentId(), 0L)) {
            rootNodes.add(categoryNameDTO);
        }
    }
    // 构建树结构
    for (ManagementCategoryDTO node : managementCategoryMap.values()) {
        if (node.getLevel() > ManagementCategoryLevelEnum.FIRST.getId()) {
            ManagementCategoryDTO parentNode = managementCategoryMap.get(node.getParentId());
            if (parentNode != null) {
                parentNode.getItems().add(node);
            }
        }
    }
    return rootNodes;
}

填充管理类目规则



/**
 * 填充规则信息
 */
private void populateRuleData
(List<ManagementCategoryDTO> managementCategoryDTOS, List<ManagementCategoryRuleEntity> managementCategoryRuleEntities) {
    if (CollectionUtils.isEmpty(managementCategoryDTOS) || CollectionUtils.isEmpty(managementCategoryRuleEntities)) {
        return;
    }
    List<ManagementCategoryRuleDTO> managementCategoryRuleDTOS =managementCategoryMapping.convertRuleEntities2DTOS(managementCategoryRuleEntities);
    // 将规则集合按 categoryId 分组
    Map<Long, List<ManagementCategoryRuleDTO>> rulesByCategoryIdMap = managementCategoryRuleDTOS.stream()
            .collect(Collectors.groupingBy(ManagementCategoryRuleDTO::getCategoryId));
    // 递归填充规则到树结构
    fillRulesRecursively(managementCategoryDTOS, rulesByCategoryIdMap);


}


/**
 * 递归填充规则到树结构
 */
private static void fillRulesRecursively
(List<ManagementCategoryDTO> managementCategoryDTOS, Map<Long, List<ManagementCategoryRuleDTO>> rulesByCategoryIdMap) {
    if (CollectionUtils.isEmpty(managementCategoryDTOS) || MapUtils.isEmpty(rulesByCategoryIdMap)) {
        return;
    }
    for (ManagementCategoryDTO node : managementCategoryDTOS) {
        // 获取当前节点对应的规则列表
        List<ManagementCategoryRuleDTO> rules = rulesByCategoryIdMap.getOrDefault(node.getId(), new ArrayList<>());
        node.setRules(rules);
        // 递归处理子节点
        fillRulesRecursively(node.getItems(), rulesByCategoryIdMap);
    }
}

状态机驱动:管理类目生命周期管理

超时机制 :基于时间阈值的流程阻塞保护

其中,为防止长时间运营处于待确认规则状态,造成其他规则阻塞规则修改,定时判断待确认规则状态持续时间,当时间超过xxx时间之后,则将待确认状态改为长时间未操作,放弃变更状态,并飞书通知规则修改人。

管理类目状态变化级联传播策略

类目生效和失效状态为级联操作。规则如下:

  • 管理二级类目有草稿状态时,不允许下挂三级类目的编辑;
  • 管理三级类目有草稿状态时,不允许对应二级类目的规则编辑;
  • 类目生效失效状态为级联操作,上层修改下层级联修改状态,如果下层管理类目存在草稿状态,则自动更改为放弃更改状态。

规则变更校验逻辑

当一次提交,可能出现的情况如下。一次提交可能会产生多个草稿,对应多个审批流程。

新增管理类目规则:

  • 一级管理类目可以直接新增(点击新增一级管理类目)
  • 二级管理类目和三级管理类目不可同时新增
  • 三级管理类目需要在已有二级类目基础上新增

只有名称修改触发直接审批,有规则修改需要等待数仓计算结果之后,运营提交发起审批。

交互通知中心:飞书卡片推送

  • 变更规则数据计算结果依赖数仓kafka计算结果回调。
  • 基于飞书卡片推送数仓计算结果,回调提交审批和放弃变更事件。

飞书卡片:

卡片结果

卡片操作结果

审批流程:多维度权限控制与飞书集成

提交审批的四种情况:

  • 名称修改
  • 一级类目新增
  • 管理类目规则修改
  • 生效失效变更

审批通过,将草稿内容更新到管理类目表中,将管理类目设置为生效中。

审批驳回,清空草稿内容。

审批人分配机制:多草稿并行审批方案

一次提交可能会产生多个草稿,对应多个审批流程。

审批逻辑

public Map<String, List<String>> buildApprover(
        ManagementCategoryDraftEntity draftEntity,
        Map<Long, Set<String>> catAuditorMap,
        Map<String, String> userIdOpenIdMap,
        Integer hasApprover) {
    
    Map<String, List<String>> nodeApprover = new HashMap<>();


    // 无审批人模式,直接查询超级管理员
    if (!Objects.equals(hasApprover, ManagementCategoryUtils.HAS_APPROVER_YES)) {
        nodeApprover.put(ManagementCategoryApprovalField.NODE_SUPER_ADMIN_AUDIT,
                queryApproverList(0L, catAuditorMap, userIdOpenIdMap));
        return nodeApprover;
    }
    
    Integer level = draftEntity.getLevel();
    Integer draftType = draftEntity.getType();
    boolean isEditOperation = ManagementCategoryDraftTypeEnum.isEditOp(draftType);
    
    // 动态构建审批链(支持N级类目)
    List<Integer> approvalChain = buildApprovalChain(level);
    for (int i = 0; i < approvalChain.size(); i++) {
        int currentLevel = approvalChain.get(i);
        Long categoryId = getCategoryIdByLevel(draftEntity, currentLevel);
        
        // 生成节点名称(如:NODE_LEVEL2_ADMIN_AUDIT)
        String nodeKey = String.format(
                ManagementCategoryApprovalField.NODE_LEVEL_X_ADMIN_AUDIT_TEMPLATE,
                currentLevel
        );
        
        // 编辑操作且当前层级等于提交层级时,添加本级审批人 【新增的管理类目没有还没有对应的审批人】
        if (isEditOperation && currentLevel == level) {
            addApprover(nodeApprover, nodeKey, categoryId, catAuditorMap, userIdOpenIdMap);
        }
        
        // 非本级审批人(上级层级)
        if (currentLevel != level) {
            addApprover(nodeApprover, nodeKey, categoryId, catAuditorMap, userIdOpenIdMap);
        }
    }
    
    return nodeApprover;
}


private List<Integer> buildApprovalChain(Integer level) {
    List<Integer> approvalChain = new ArrayList<>();
    if (level == 3) {
        approvalChain.add(2); // 管二审批人
        approvalChain.add(1); // 管一审批人
    } else if (level == 2) {
        approvalChain.add(2); // 管二审批人
        approvalChain.add(1); // 管一审批人
    } else if (level == 1) {
        approvalChain.add(1); // 管一审批人
        approvalChain.add(0); // 超管
    }
    return approvalChain;
}

3.3 数据模型设计

3.4 数仓计算逻辑

同步数据方式

方案一:

每次修改规则之后通过调用SQL触发离线计算

优势:通过SQL调用触发计算,失效性较高

劣势:ODPS 资源峰值消耗与SQL脚本耦合问题

  • 因为整个规则修改是三级类目维度,如果同时几十几百个类目触发规则改变,会同时触发几十几百个离线任务。同时需要大量ODPS 资源;
  • 调用SQL方式需要把当前规则修改和计算逻辑的SQL一起调用计算。

方案二:

优势:同时只会产生一次规则计算

劣势:实时性受限于离线计算周期

  • 实时性取决于离线规则计算的定时任务配置和离线数据同步频率,实时性不如直接调用SQL性能好
  • 不重不漏为当前所有变更规则维度

技术决策:常态化迭代下的最优解

考虑到管理类目规则平均变更频率不高,且变更时间点较为集中(非紧急场景占比 90%),故选择定时任务方案实现:

  • 资源利用率提升:ODPS 计算资源消耗降低 80%,避免批量变更时数百个任务同时触发的资源峰值;
  • 完整性保障:通过全量维度扫描确保规则校验无遗漏,较 SQL 触发方案提升 20% 校验覆盖率;
  • 可维护性优化:减少 SQL 脚本与业务逻辑的强耦合,维护成本降低 80%。

数据取数逻辑

生效中规则计算

草稿+生效中规格计算

如果是新增管理类目,直接参与计算。

如果是删除管理类目,需要将该删除草稿中对应的生效管理类目排除掉。

如果是更新:需要将草稿中的管理类目和规则替换生效中对应的管理类目和规则。

数仓实现

数据流程图

四、项目成果与技术价值

预期效率提升:从 “周级” 到 “日级” 的跨越

  • 管理一级 / 二级类目变更开发零成本,无需额外人力投入
  • 管理三级类目变更相关人力成本降低 100%,无需额外投入开发资源
  • 规则上线周期压缩超 90%,仅需 1 - 2 天即可完成上线

质量保障:自动化校验替代人工梳理

  • 规则重复 / 遗漏检测由人工梳理->自动化计算
  • 下游感知管理类目规则变更由人工通知->实时感知

技术沉淀:规则模型化能力

沉淀管理类目规则配置模型,支持未来四级、五级多级管理类目快速适配。

五、总结

未来优化方向:

  1. 规则冲突预警:基于AI预测高风险规则变更,提前触发校验
  2. 接入flink做到实时计算管理类目和对应商品关系

技术重构的本质是 “释放业务创造力”

管理类目配置线上化项目的核心价值,不仅在于技术层面的效率提升,更在于通过自动化工具链,让业务方从 “规则提报的执行者” 转变为 “业务策略的设计者”。当技术架构能够快速响应业务迭代时,企业才能在电商领域的高频竞争中保持创新活力。

往期回顾

  1. 大模型如何革新搜索相关性?智能升级让搜索更“懂你”|得物技术

  2. RAG—Chunking策略实战|得物技术

  3. 告别数据无序:得物数据研发与管理平台的破局之路

  4. 从一次启动失败深入剖析:Spring循环依赖的真相|得物技术

  5. Apex AI辅助编码助手的设计和实践|得物技术

文 /维山

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

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

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

从一次启动失败深入剖析:Spring循环依赖的真相|得物技术

一、背 景

预发环境一个后台服务admin突然启动失败,异常如下:



org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'timeoutNotifyController': Injection of resource dependencies failed; nested exception is org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name 'spuCheckDomainServiceImpl': Bean with name 'spuCheckDomainServiceImpl' has been injected into other beans [...] in its raw version as part of a circular reference, but has eventually been wrapped. This means that said other beans do not use the final version of the bean. This is often the result of over-eager type matching - consider using 'getBeanNamesOfType' with the 'allowEagerInit' flag turned off, for example.
        at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor$AutowiredFieldElement.inject(AutowiredAnnotationBeanPostProcessor.java:598)
        at org.springframework.beans.factory.annotation.InjectionMetadata.inject(InjectionMetadata.java:90)
        at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor.postProcessProperties(AutowiredAnnotationBeanPostProcessor.java:376)
        at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.populateBean(AbstractAutowireCapableBeanFactory.java:1404)
        at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:592)
        at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:515)
        at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:320)
        at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:222)
        at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:318)
        at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:199)
        at org.springframework.beans.factory.support.DefaultListableBeanFactory.preInstantiateSingletons(DefaultListableBeanFactory.java:847)
        at org.springframework.context.support.AbstractApplicationContext.finishBeanFactoryInitialization(AbstractApplicationContext.java:877)
        at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:549)
        at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.refresh(ServletWebServerApplicationContext.java:141)
        at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:744)
        at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:391)
        at org.springframework.boot.SpringApplication.run(SpringApplication.java:312)
        at org.springframework.boot.SpringApplication.run(SpringApplication.java:1215)
        at org.springframework.boot.SpringApplication.run(SpringApplication.java:1204)
        at com.shizhuang.duapp.commodity.interfaces.admin.CommodityAdminApplication.main(CommodityAdminApplication.java:100)
        at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
        at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
        at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
        at java.lang.reflect.Method.invoke(Method.java:498)
        at org.springframework.boot.loader.MainMethodRunner.run(MainMethodRunner.java:48)
        at org.springframework.boot.loader.Launcher.launch(Launcher.java:87)
        at org.springframework.boot.loader.Launcher.launch(Launcher.java:51)
        at org.springframework.boot.loader.PropertiesLauncher.main(PropertiesLauncher.java:578)

错误日志中明确写道:“Bean has been injected into other beans ... in its raw version as part of a circular reference, but has eventually been wrapped. ”这不仅仅是一个简单的循环依赖错误。它揭示了一个更深层次的问题:当循环依赖遇上Spring的AOP代理(如@Transactional事务、自定义切面等),Spring在解决依赖的时,不得已将一个“半成品”(原始Bean)注入给了其他30多个Bean。而当这个“半成品”最终被“包装”(代理)成“成品”时,先前那些持有“半成品”引用的Bean们,使用的却是一个错误的版本。

这就像在组装一个精密机器时,你把一个未经质检的零件提前装了进去,等质检完成后,机器里混用着新旧版本的零件,最终的崩溃也就不可避免。

本篇文章将带你一起:

  • 熟悉spring容器的循环依赖以及Spring容器如何解决循环依赖,创建bean相关的流程。
  • 深入解读这条复杂错误日志背后的每一个关键线索;
  • 提供紧急止血方案;
  • 分享如何从架构设计上避免此类问题的实践心得。

二、相关知识点简介

2.1 循环依赖

什么是Bean循环依赖?

循环依赖:说白是一个或多个对象实例之间存在直接或间接的依赖关系,这种依赖关系构成了构成一个环形调用,主要有如下几种情况。

第一种情况:自己依赖自己的直接依赖

第二种情况:两个对象之间的直接依赖

前面两种情况的直接循环依赖比较直观,非常好识别,但是第三种间接循环依赖的情况有时候因为业务代码调用层级很深,不容易识别出来。

循环依赖场景

构造器注入循环依赖:

@Service
public class A {public A(B b) {}}
@Service
public class B {public B(A a) {}}

结果:项目启动失败抛出异常BeanCurrentlyInCreationException

Caused by: org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name 'a': Requested bean is currently in creation: Is there an unresolvable circular reference?
        at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.beforeSingletonCreation(DefaultSingletonBeanRegistry.java:339)
        at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:215)
        at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:318)
        at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:199)

构造器注入构成的循环依赖,此种循环依赖方式无论是Singleton模式还是prototype模式都是无法解决的,只能抛出BeanCurrentlyInCreationException异常表示循环依赖。原因是Spring解决循环依赖依靠的是Bean的“中间态”这个概念,而中间态指的是已经实例化,但还没初始化的状态。而完成实例化需要调用构造器,所以构造器的循环依赖无法解决。

Singleton模式field属性注入(setter方法注入)循环依赖:

这种方式是我们最为常用的依赖注入方式:

@Service
public class A {
    @Autowired
    private B b;
    }
@Service
public class B {
    @Autowired
    private A a;
    }

结果:项目启动成功,正常运行

prototype field属性注入循环依赖:

prototype在平时使用情况较少,但是也并不是不会使用到,因此此种方式也需要引起重视。

@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
@Service
public class A {
    @Autowired
    private B b;
    }
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
@Service
public class B {
    @Autowired
    private A a;
    }

结果:需要注意的是本例中启动时是不会报错的(因为非单例Bean默认不会初始化,而是使用时才会初始化),所以很简单咱们只需要手动getBean()或者在一个单例Bean内@Autowired一下它即可。

// 在单例Bean内注入
    @Autowired
    private A a;

这样子启动就报错:

org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'mytest.TestSpringBean': Unsatisfied dependency expressed through field 'a'; nested exception is org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'a': Unsatisfied dependency expressed through field 'b'; nested exception is org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'b': Unsatisfied dependency expressed through field 'a'; nested exception is org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name 'a': Requested bean is currently in creation: Is there an unresolvable circular reference?
        at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor$AutowiredFieldElement.inject(AutowiredAnnotationBeanPostProcessor.java:596)
        at org.springframework.beans.factory.annotation.InjectionMetadata.inject(InjectionMetadata.java:90)
        at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor.postProcessProperties(AutowiredAnnotationBeanPostProcessor.java:374)

如何解决?可能有的小伙伴看到网上有说使用@Lazy注解解决:

    @Lazy
    @Autowired
    private A a;

此处负责任的告诉你这样是解决不了问题的(可能会掩盖问题),@Lazy只是延迟初始化而已,当你真正使用到它(初始化)的时候,依旧会报如上异常。

对于Spring循环依赖的情况总结如下:

  • 不能解决的情况:构造器注入循环依赖,prototype field属性注入循环依赖
  • 能解决的情况:field属性注入(setter方法注入)循环依赖

Spring如何解决循环依赖

Spring 是通过三级缓存和提前曝光的机制来解决循环依赖的问题。

三级缓存

三级缓存其实就是用三个 Map 来存储不同阶段 Bean 对象。

一级缓存
private final Map<StringObject> singletonObjects = new ConcurrentHashMap<>(256);
二级缓存
private final Map<StringObjectearlySingletonObjects = new HashMap<>(16);
//三级缓存
private final Map<StringObjectFactory<?>> singletonFactories = new HashMap<>(16)
  • singletonObjects:用于存放完全初始化好的 bean,从该缓存中取出的 bean 可以直接使用
  • earlySingletonObjects:提前曝光的单例对象的cache,存放原始的 bean 对象(尚未填充属性),用于解决循环依赖。
  • singletonFactories:单例对象工厂的cache,存放 bean 工厂对象,用于解决循环依赖。

三级缓存解决循环依赖过程

假设现在我们有ServiceA和ServiceB两个类,这两个类相互依赖,代码如下:

@Service
public class ServiceA {
    @Autowired
    private ServiceB serviceB;
    }


@Service
public class ServiceB {
    @Autowired
    private ServiceA serviceA ;
    }

下面的时序图说明了spring用三级缓存解决循环依赖的主要流程:

为什么需要三级缓存?

这是一个理解Spring容器如何解决循环依赖的核心概念。三级缓存是Spring为了解决循环依赖的同时,又能保证AOP代理的正确性而设计的精妙机制。

为了理解为什么需要三级缓存,我们一步步来看。

如果没有缓存(Level 0)

假设有两个Bean:ServiceA  和 ServiceB,它们相互依赖。

Java

@Component
public class ServiceA  {
    @Autowired
    private ServiceB serviceB;
}
@Component
public class ServiceB {
    @Autowired
    private ServiceA serviceA;
}

创建过程(无缓存)

  • 开始创建 ServiceA -> 发现 ServiceA 需要 ServiceB -> 开始创建 ServiceB
  • 开始创建 ServiceB -> 发现 ServiceB 需要 ServiceA -> 开始创建 ServiceA
  • 开始创建 ServiceA -> 发现 ServiceA 需要 ServiceB -> ... 无限循环,StackOverflowError

结论:无法解决循环依赖,直接死循环。

如果只有一级缓存(Singleton Objects)

一级缓存存放的是已经完全创建好、初始化完毕的Bean。

问题:在Bean的创建过程中(比如在填充属性 populateBean 时),ServiceA还没创建完,它本身不应该被放入"已完成"的一级缓存。但如果ServiceB需要ServiceA,而一级缓存里又没有ServiceA的半成品,ServiceB就无法完成创建。这就回到了上面的死循环问题。

结论:一级缓存无法解决循环依赖。

如果使用二级缓存

二级缓存的核心思路是:将尚未完全初始化好的“早期引用”暴露出来。

现在我们有:

  • 一级缓存(成品库) :存放完全准备好的Bean。
  • 二级缓存(半成品库) :存放刚刚实例化(调用了构造方法),但还未填充属性和初始化的Bean的早期引用。

创建过程(二级缓存):

开始创建ServiceA

  • 实例化ServiceA(调用ServiceA的构造方法),得到一个ServiceA的原始对象。
  • 将ServiceA的原始对象放入二级缓存(半成品库)。
  • 开始为ServiceA填充属性 -> 发现需要ServiceB。

开始创建ServiceB

  • 实例化ServiceB(调用B的构造方法),得到一个ServiceB的原始对象。
  • 将ServiceB的原始对象放入二级缓存。
  • 开始为ServiceB填充属性 -> 发现需要ServiceA。

ServiceB从二级缓存中获取A

  • ServiceB成功从二级缓存中拿到了ServiceA的早期引用(原始对象)。
  • ServiceB顺利完成了属性填充、初始化等后续步骤,成为一个完整的Bean。
  • 将完整的ServiceB放入一级缓存(成品库),并从二级缓存移除ServiceB。

ServiceA继续创建:

  • ServiceA拿到了创建好的ServiceB,完成了自己的属性填充和初始化。
  • 将完整的ServiceA放入一级缓存(成品库),并从二级缓存移除ServiceA。

问题来了:如果ServiceA需要被AOP代理怎么办?

如果A类上加了 @Transactional 等需要创建代理的注解,那么最终需要暴露给其他Bean的应该是ServiceA的代理对象,而不是ServiceA的原始对象。

在二级缓存方案中,ServiceB拿到的是A的原始对象。但最终ServiceA完成后,放入一级缓存的是ServiceA的代理对象。这就导致了:

  • ServiceB里面持有的ServiceA是原始对象。
  • 而其他地方注入的ServiceA是代理对象。
  • 这就造成了不一致!如果通过ServiceB的ServiceA去调用事务方法,事务会失效,因为那是一个没有被代理的原始对象。

结论:二级缓存可以解决循环依赖问题,但无法正确处理需要AOP代理的Bean。

三级缓存的登场(Spring的终极方案)

为了解决代理问题,Spring引入了第三级缓存。它的核心不是一个直接存放对象(Object)的缓存,而是一个存放 ObjectFactory(对象工厂) 的缓存。

三级缓存的结构是:Map<String, ObjectFactory<?>> singletonFactories

创建过程(三级缓存,以ServiceA需要代理为例):

  • 开始创建ServiceA
  • 实例化ServiceA,得到ServiceA的原始对象。
  • 三级缓存添加一个ObjectFactory。这个工厂的getObject()方法有能力判断ServiceA是否需要代理,并返回相应的对象(原始对象或代理对象)
  • 开始为ServiceA填充属性 -> 发现需要ServiceB。
  • 开始创建B
  • 实例化ServiceB。
  • 同样向三级缓存添加一个ServiceB的ObjectFactory。
  • 开始为ServiceB填充属性 -> 发现需要ServiceA。
  • ServiceB从缓存中获取ServiceA
  • ServiceB发现一级缓存没有ServiceA,二级缓存也没有ServiceA。
  • ServiceB发现三级缓存有A的ObjectFactory。
  • B调用这个工厂的getObject()方法。此时,Spring会执行一个关键逻辑:
  • 如果ServiceA需要被代理,工厂会提前生成ServiceA的代理对象并返回。
  • 如果ServiceA不需要代理,工厂则返回A的原始对象。
  • 将这个早期引用(可能是原始对象,也可能是代理对象) 放入二级缓存,同时从三级缓存移除A的工厂。
  • ServiceB拿到了ServiceA的正确版本的早期引用。

后续步骤:

  • ServiceB完成创建,放入一级缓存。
  • ServiceA继续用ServiceB完成创建。在ServiceA初始化的最后,Spring会再次检查:如果ServiceA已经被提前代理了(即在第3步中),那么就直接返回这个代理对象;如果没有,则可能在此处创建代理(对于不需要解决循环依赖的Bean)。
  • 最终,将完整的ServiceA(代理对象)放入一级缓存,并清理二级缓存。

总结:为什么需要三级缓存?

需要三级缓存,是因为Spring要解决一个复杂问题:在存在循环依赖的情况下,如何确保所有Bean都能拿到最终形态(可能被AOP代理)的依赖对象,而不是原始的、未代理的对象。 三级缓存通过一个ObjectFactory将代理的时机提前,完美地解决了这个问题。二级缓存主要是为了性能优化而存在的。

spring三级缓存为什么不能解决

@Async注解的循环依赖问题

这触及了 Spring 代理机制的一个深层次区别。@Async注解的循环依赖问题确实比@Transactional 更复杂,三级缓存无法完全解决。让我们深入分析原因。

2.2 Spring创建Bean主要流程

为了容易理解 Spring 解决循环依赖过程,我们先简单温习下 Spring 容器创建 Bean 的主要流程。

从代码看Spring对于Bean的生成过程,步骤还是很多的,我把一些扩展业务代码省略掉:

protected Object doCreateBean(final String beanName, final RootBeanDefinition mbd, final @Nullable Object[] args)
          throws BeanCreationException {
    if (mbd.isSingleton()) {
      instanceWrapper = this.factoryBeanInstanceCache.remove(beanName);
    }
    // Bean初始化第一步:默认调用无参构造实例化Bean
    // 如果是只有带参数的构造方法,构造方法里的参数依赖注入,就是发生在这一步
    if (instanceWrapper == null) {
      instanceWrapper = createBeanInstance(beanName, mbd, args);
    }


    //判断Bean是否需要提前暴露对象用来解决循环依赖,需要则启动spring三级缓存
    boolean earlySingletonExposure = (mbd.isSingleton() && this.allowCircularReferences &&
       isSingletonCurrentlyInCreation(beanName));
   if (earlySingletonExposure) {
     if (logger.isTraceEnabled()) {
       logger.trace("Eagerly caching bean '" + beanName +
             "' to allow for resolving potential circular references");
      }
    addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean));
}


    // Initialize the bean instance.
    Object exposedObject = bean;
    try {
      // bean创建第二步:填充属性(DI依赖注入发生在此步骤)
      populateBean(beanName, mbd, instanceWrapper);
      // bean创建第三步:调用初始化方法,完成bean的初始化操作(AOP的第三个入口)
      // AOP是通过自动代理创建器AbstractAutoProxyCreator的postProcessAfterInitialization()
//方法的执行进行代理对象的创建的,AbstractAutoProxyCreator是BeanPostProcessor接口的实现
      exposedObject = initializeBean(beanName, exposedObject, mbd);




   if (earlySingletonExposure) {
    Object earlySingletonReference = getSingleton(beanName, false);
     if (earlySingletonReference != null) {
        if (exposedObject == bean) {
          exposedObject = earlySingletonReference;
        }
        else if (!this.allowRawInjectionDespiteWrapping && hasDependentBean(beanName)) {
          String[] dependentBeans = getDependentBeans(beanName);
          Set<String> actualDependentBeans = new LinkedHashSet<>(dependentBeans.length);
          for (String dependentBean : dependentBeans) {
             if (!removeSingletonIfCreatedForTypeCheckOnly(dependentBean)) {
                actualDependentBeans.add(dependentBean);
             }
          }
          if (!actualDependentBeans.isEmpty()) {
             throw new BeanCurrentlyInCreationException(beanName,
                   "Bean with name '" + beanName + "' has been injected into other beans [" +
                   StringUtils.collectionToCommaDelimitedString(actualDependentBeans) +
                   "] in its raw version as part of a circular reference, but has eventually been " +
                   "wrapped. This means that said other beans do not use the final version of the " +
                   "bean. This is often the result of over-eager type matching - consider using " +
                   "'getBeanNamesOfType' with the 'allowEagerInit' flag turned off, for example.");
          }
       }
    }
}


    } catch (Throwable ex) {
      // ...
    }
    // ...
    return exposedObject;
    }

从上述代码看出,整体脉络可以归纳成 3 个核心步骤:

  • 实例化Bean:主要是通过反射调用默认构造函数创建 Bean 实例,此时Bean的属性都还是默认值null。被注解@Bean标记的方法就是此阶段被调用的。
  • 填充Bean属性:这一步主要是对Bean的依赖属性进行填充,对@Value、@Autowired、@Resource注解标注的属性注入对象引用。
  • 调用Bean初始化方法:调用配置指定中的init方法,如 xml文件指定Bean的init-method方法或注解 @Bean(initMethod = "initMethod")指定的方法。

三、案例分析

3.1 代码分析

以下是我简化后的类之间大体的依赖关系,工程内实际的依赖情况会比这个简化版本复杂一些。

@RestController
public class OldCenterSpuController {
    @Resource
    private NewSpuApplyCheckServiceImpl newSpuApplyCheckServiceImpl;
}
@RestController
public class TimeoutNotifyController {
    @Resource
    private SpuCheckDomainServiceImpl spuCheckDomainServiceImpl;
}
@Component
public class NewSpuApplyCheckServiceImpl {
    @Resource
    private SpuCheckDomainServiceImpl spuCheckDomainServiceImpl;
}
@Component
@Slf4j
@Validated
public class SpuCheckDomainServiceImpl {
    @Resource
    private NewSpuApplyCheckServiceImpl newSpuApplyCheckServiceImpl;
}

从代码看,主要是SpuCheckDomainServiceImpl和NewSpuApplyCheckServiceImpl 构成了一个依赖环。而我们从正常启动的bean加载顺序发现首先是从OldCenterSpuController开始加载的,具体情况如下所示:

OldCenterSpuController 
    ↓ (依赖)
NewSpuApplyCheckServiceImpl 
    ↓ (依赖)  
SpuCheckDomainServiceImpl 
    ↓ (依赖)
NewSpuApplyCheckServiceImpl 

异常启动的情况bean加载是从TimeoutNotifyController开始加载的,具体情况如下所示:

TimeoutNotifyController 
    ↓ (依赖)
SpuCheckDomainServiceImpl 
    ↓ (依赖)  
NewSpuApplyCheckServiceImpl 
    ↓ (依赖)
SpuCheckDomainServiceImpl 

同一个依赖环,为什么从OldCenterSpuController 开始加载就可以正常启动,而从TimeoutNotifyController 启动就会启动异常呢?下面我们会从现场debug的角度来分析解释这个问题。

3.2 问题分析

在相关知识点简介里面知悉到spring用三级缓存解决了循环依赖问题。为什么后台服务admin启动还会报循环依赖的问题呢?

要得到问题的答案,还是需要回到源码本身,前面我们分析了spring的创建Bean的主要流程,这里为了更好的分析问题,补充下通过容器获取Bean的。

在通过spring容器获取bean时,底层统一会调用doGetBean方法,大体如下:

protected <T> T doGetBean(final String name, @Nullable final Class<T> requiredType,
       @Nullable final Object[] args, boolean typeCheckOnly) throws BeansException {
    
    final String beanName = transformedBeanName(name);
    Object bean;
    
    // 从三级缓存获取bean
    Object sharedInstance = getSingleton(beanName);
    if (sharedInstance != null && args == null) {
       bean = getObjectForBeanInstance(sharedInstance, name, beanName, null);
    }else {
     if (mbd.isSingleton()) {
       sharedInstance = getSingleton(beanName, () -> {
       try {
         //如果是单例Bean,从三级缓存没有获取到bean,则执行创建bean逻辑
          return createBean(beanName, mbd, args);
       }
       catch (BeansException ex) {
          destroySingleton(beanName);
          throw ex;
       }
    });
    bean = getObjectForBeanInstance(sharedInstance, name, beanName, mbd);
  }   
 }

从doGetBean方法逻辑看,在spring从一二三级缓存获取bean返回空时,会调用createBean方法去场景bean,createBean方法底层主要是调用前面我们提到的创建Bean流程的doCreateBean方法。

注意:doGetBean方法里面getSingleton方法的逻辑是先从一级缓存拿,拿到为空并且bean在创建中则又从二级缓存拿,二级缓存拿到为空 并且当前容器允许有循环依赖则从三级缓存拿。并且将对象工厂移到二级缓存,删除三级缓存

doCreateBean方法如下:

protected Object doCreateBean(final String beanName, final RootBeanDefinition mbd, final @Nullable Object[] args)
          throws BeanCreationException {
    if (mbd.isSingleton()) {
      instanceWrapper = this.factoryBeanInstanceCache.remove(beanName);
    }
    // Bean初始化第一步:默认调用无参构造实例化Bean
    // 如果是只有带参数的构造方法,构造方法里的参数依赖注入,就是发生在这一步
    if (instanceWrapper == null) {
      instanceWrapper = createBeanInstance(beanName, mbd, args);
    }


    //判断Bean是否需要提前暴露对象用来解决循环依赖,需要则启动spring三级缓存
    boolean earlySingletonExposure = (mbd.isSingleton() && this.allowCircularReferences &&
       isSingletonCurrentlyInCreation(beanName));
   if (earlySingletonExposure) {
     if (logger.isTraceEnabled()) {
       logger.trace("Eagerly caching bean '" + beanName +
             "' to allow for resolving potential circular references");
      }
    addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean));
}


    // Initialize the bean instance.
    Object exposedObject = bean;
    try {
      // bean创建第二步:填充属性(DI依赖注入发生在此步骤)
      populateBean(beanName, mbd, instanceWrapper);
      // bean创建第三步:调用初始化方法,完成bean的初始化操作(AOP的第三个入口)
      // AOP是通过自动代理创建器AbstractAutoProxyCreator的postProcessAfterInitialization()
//方法的执行进行代理对象的创建的,AbstractAutoProxyCreator是BeanPostProcessor接口的实现
      exposedObject = initializeBean(beanName, exposedObject, mbd);




   if (earlySingletonExposure) {
    Object earlySingletonReference = getSingleton(beanName, false);
     if (earlySingletonReference != null) {
        if (exposedObject == bean) {
          exposedObject = earlySingletonReference;
        }
        else if (!this.allowRawInjectionDespiteWrapping && hasDependentBean(beanName)) {
          String[] dependentBeans = getDependentBeans(beanName);
          Set<String> actualDependentBeans = new LinkedHashSet<>(dependentBeans.length);
          for (String dependentBean : dependentBeans) {
             if (!removeSingletonIfCreatedForTypeCheckOnly(dependentBean)) {
                actualDependentBeans.add(dependentBean);
             }
          }
          if (!actualDependentBeans.isEmpty()) {
             throw new BeanCurrentlyInCreationException(beanName,
                   "Bean with name '" + beanName + "' has been injected into other beans [" +
                   StringUtils.collectionToCommaDelimitedString(actualDependentBeans) +
                   "] in its raw version as part of a circular reference, but has eventually been " +
                   "wrapped. This means that said other beans do not use the final version of the " +
                   "bean. This is often the result of over-eager type matching - consider using " +
                   "'getBeanNamesOfType' with the 'allowEagerInit' flag turned off, for example.");
          }
       }
    }
}


    } catch (Throwable ex) {
      // ...
    }
    // ...
    return exposedObject;
    }

将doGetBean和doCreateBean的逻辑转换成流程图如下:

从流程图可以看出,后台服务admin启动失败抛出UnsatisfiedDependencyException异常的必要条件是存在循环依赖,因为不存在循环依赖的情况bean只会存在单次加载,单次加载的情况bean只会被放进spring的第三级缓存。

而触发UnsatisfiedDependencyException异常的先决条件是需要spring的第一二级缓存有当前的bean。所以可以知道当前bean肯定存在循环依赖。在存在循环依赖的情况下,当前bean被第一次获取(即调用doGetBean方法)会缓存进spring的第三级缓存,然后会注入当前bean的依赖(即调用populateBean方法),在当前bean所在依赖环内其他bean都不在一二级缓存的情况下,会触发当前bean的第二次获取(即调用doGetBean方法),由于第一次获取已经将Bean放进了第三级缓存,spring会将Bean从第三级缓存移到二级缓存并删除第三级缓存。

最终会回到第一次获取的流程,调用初始化方法做初始化。最终在初始化有对当前bean做代理增强的并且提前暴露到二级缓存的对象有被其他依赖引用到,而且allowRawInjectionDespiteWrapping=false的情况下,会导致抛出UnsatisfiedDependencyException,进而导致启动异常。

注意:在注入当前bean的依赖时,这里spring将Bean从第三级缓存移到二级缓存并删除第三级缓存后,当前bean的依赖的其他bean会从二级缓存拿到当前bean做依赖。这也是后续抛异常的先决条件

结合admin有时候启动正常,有时候启动异常的情况,这里猜测启动正常和启动异常时bean加载顺序不一致,进而导致启动正常时当前Bean只会被获取一次,启动异常时当前bean会被获取两次。为了验证猜想,我们分别针对启动异常和启动正常的bean获取做了debug。

debug分析

首先我们从启动异常提取到以下关键信息,从这些信息可以知道是spuCheckDomainServiceImpl的加载触发的启动异常。所以我们这里以spuCheckDomainServiceImpl作为前面流程分析的当前bean。

org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'timeoutNotifyController': Injection of resource dependencies failed; nested exception is org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name 'spuCheckDomainServiceImpl': Bean with name 'spuCheckDomainServiceImpl' has been injected into other beans [...] in its raw version as part of a circular reference, but has eventually been wrapped. This means that said other beans do not use the final version of the bean. This is often the result of over-eager type matching - consider using 'getBeanNamesOfType' with the 'allowEagerInit' flag turned off, for example.

然后提前我们在doCreateBean方法设置好spuCheckDomainServiceImpl加载时的条件断点。我们先debug启动异常的情况。最终断点信息如下:

从红框1里面的两个引用看,很明显调initializeBean方法时spring有对spuCheckDomainServiceImpl做代理增强。导致initializeBean后返回的引用和提前暴露到二级缓存的引用是不一致的。这里spuCheckDomainServiceImpl有二级缓存是跟我们前面分析的吻合,是因为spuCheckDomainServiceImpl被获取了两次,即调了两次doGetBean。

从红框2里面的actualDependentBeans的set集合知道提前暴露到二级缓存的引用有被其他33个bean引用到,也是跟异常提示的bean列表保持一致的。

这里spuCheckDomainServiceImpl的加载为什么会调用两次doGetBean方法呢?

从调用栈分析到该加载链如下:

TimeoutNotifyController  ->spuCheckDomainServiceImpl-> newSpuApplyCheckServiceImpl-> ... ->spuCheckDomainServiceImpl

TimeoutNotifyController注入依赖时第一次调用doGetBean获取spuCheckDomainServiceImpl时,从一二三级缓存获取不到,会调用doCreateBean方法创建spuCheckDomainServiceImpl。

首先会将spuDomainServiceImpl放进spring的第三级缓存,然后开始调populateBean方法注入依赖,由于在循环中间的newSpuApplyCheckServiceImpl是第一次获取,一二三级缓存都获取不到,会调用doCreateBean去创建对应的bean,然后会第二次调用doGetBean获取spuCheckDomainServiceImpl,这时spuCheckDomainServiceImpl在第一次获取已经将bean加载到第三级缓存,所以这次spring会将bean从第三级缓存直接移到第二级缓存,并将第三级缓存里面的spuCheckDomainServiceImpl对应的bean删除,并直接返回二级缓存里面的bean,不会再调doCreateBean去创建spuCheckDomainServiceImpl。最终完成了循环中间的bean的初始化后(这里循环中间的bean初始化时依赖到的bean如果有引用到spuCheckDomainServiceImpl会调用doGetBean方法从二级缓存拿到spuCheckDomainServiceImpl提前暴露的引用),会回到第一次调用doGetBean获取spuCheckDomainServiceImpl时调用的doCreateBean方法的流程。继续调initializeBean方法完成初始化,然后将初始化完成的bean返回。最终拿初始化返回的bean引用跟二级缓存拿到的bean引用做对比,发现不一致,导致抛出UnsatisfiedDependencyException异常。

那么这里为什么spuCheckDomainServiceImpl调用initializeBean方法完成初始化后与提前暴露到二级缓存的bean会不一致呢?

看spuCheckDomainServiceImpl的代码如下:

@Component
@Slf4j
@Validated
public class SpuCheckDomainServiceImpl {
    @Resource
    private NewSpuApplyCheckServiceImpl newSpuApplyCheckServiceImpl;
}

发现SpuCheckDomainServiceImpl类有使用到 @Validated注解。查阅资料发现 @Validated的实现是通过在initializeBean方法里面执行一个org.springframework.validation.beanvalidation.MethodValidationPostProcessor后置处理器实现的,MethodValidationPostProcessor会对SpuCheckDomainServiceImpl做一层代理。导致initializeBean方法返回的spuCheckDomainServiceImpl是一个新的代理对象,从而最终导致跟二级缓存的不一致。

debug视图如下:

那为什么有时候能启动成功呢?什么情况下能启动成功?

我们继续debug启动成功的情况。最终观察到spuCheckDomainServiceImpl只会调用一次doGetBean,而且从一二级缓存拿到的spuCheckDomainServiceImpl提前暴露的引用为null,如下图:

这里为什么spuCheckDomainServiceImpl只会调用一次doGetBean呢?

首先我们根据调用栈整理到当前加载的引用栈:

oldCenterSpuController-> newSpuApplyCheckServiceImpl-> ... ->spuCheckDomainServiceImpl -> newSpuApplyCheckServiceImpl

根据前面启动失败的信息我们可以知道,spuCheckDomainServiceImpl处理依赖的环是:

spuCheckDomainServiceImpl ->newSpuApplyCommandServiceImpl-> ... ->spuCheckDomainServiceImpl

失败的情况我们发现是从spuCheckDomainServiceImpl开始创建的,现在启动正常的情况是从newSpuApplyCheckServiceImpl开始创建的。

创建 newSpuApplyCheckServiceImpl时,发现它依赖环中间这些bean会依次调用doCreateBean方法去创建对应的bean。

调用到spuCheckDomainServiceImpl时,由于是第一次获取bean,也会调用doCreateBean方法创建bean,然后回到创建spuCheckDomainServiceImpl的doCreateBean流程,这里由于没有将spuCheckDomainServiceImpl的三级缓存移到二级缓存,所以不会导致抛出UnsatisfiedDependencyException异常,最终回到newSpuApplyCheckServiceImpl的doCreateBean流程,由于newSpuApplyCheckServiceImpl在调用initializeBean方法没有做代理增强,所以也不会导致抛出UnsatisfiedDependencyException异常。因此最后可以正常启动。

这里我们会有疑问?类的创建顺序由什么决定的呢?

通常不同环境下,代码打包后的jar/war结构、@ComponentScan的basePackages配置细微差别,都可能导致Spring扫描和注册Bean定义的顺序不同。Java ClassLoader加载类的顺序本身也有一定不确定性。如果Bean定义是通过不同的配置类引入的,配置类的加载顺序会影响其中所定义Bean的注册顺序。

那是不是所有的类增强在有循环依赖时都会触发UnsatisfiedDependencyException异常呢?

并不是,比如@Transactional就不会导致触发UnsatisfiedDependencyException异常。让我们深入分析原因。

核心区别在于代理创建时机不同。

@Transactional的代理时机如下:

// Spring 为 @Transactional 创建代理的流程1. 实例化原始 Bean
2. 放入三级缓存(ObjectFactory)
3. 当发生循环依赖时,调用 ObjectFactory.getObject()
4. 此时判断是否需要事务代理,如果需要则提前创建代理
5. 将代理对象放入二级缓存,供其他 Bean 使用

@Validated的代理时机:

// @Validated 的代理创建在生命周期更晚的阶段1. 实例化原始 Bean
2. 放入三级缓存(ObjectFactory)
3. 当发生循环依赖时,调用 ObjectFactory.getObject()
4.  ❌ 问题:此时 @Validated 的代理还未创建!
5. 其他 Bean 拿到的是原始对象,而不是异步代理对象

问题根源:@Transactional的代理增强是在三层缓存生成时触发的, @Validated的增强是在初始化bean后通过后置处理器做的代理增强。

3.3 解决方案

短期方案

  • 移除SpuCheckDomainServiceImpl类上的Validated注解
  • @lazy 解耦
    • 原理是发现有@lazy 注解的依赖为其生成代理类,依赖代理类,只有在真正需要用到对象时,再通过getBean的逻辑去获取对象,从而实现了解耦。

长期方案

严格执行DDD代码规范

这里是违反DDD分层规范导致的循环依赖。

梳理解决历史依赖环

通过梳理修改代码解决历史存在的依赖环。我们内部实现了一个能检测依赖环的工具,这里简单介绍一下实现思路,详情如下。

日常循环依赖环:实战检测工具类解析

在实际项目中,即使遵循了DDD分层规范和注入最佳实践,仍有可能因业务复杂或团队协作不充分而引入循环依赖。为了在开发阶段尽早发现这类问题,我们可以借助自定义的循环依赖检测工具类,在Spring容器启动后自动分析并报告依赖环。

功能概述:

  • 条件启用:通过配置circular.dependecy.analysis.enabled=true开启检测;
  • 依赖图构建:扫描所有单例Bean,分析其构造函数、字段、方法注入及depends-on声明的依赖;
  • 循环检测算法:使用DFS遍历依赖图,识别所有循环依赖路径;
  • 通知上报:检测结果通过飞书机器人发送至指定接收人(targetId)。

简洁代码结构如下:

@Component
@ConditionalOnProperty(value = "circular.dependency.analysis.enabled", havingValue = "true")
public class TimingCircularDependencyHandler extends AbstractNotifyHandler<NotifyData>
    implements ApplicationContextAwareBeanFactoryAware {
    
    @Override
    public Boolean handler(NotifyData data) {
        dependencyGraph = new HashMap<>();
        handleContextRefresh(); // 触发依赖图构建与检测
        return Boolean.TRUE;
    }
    
    private void buildDependencyGraph() {
        // 遍历所有Bean,解析其依赖关系
        // 支持:构造器、字段、方法、depends-on
    }
    
    private void detectCircularDependencies() {
        // 使用DFS检测环,记录所有循环路径
        // 输出示例:循环依赖1: A -> B -> C -> A
    }
}

四、总结

循环依赖暴露了代码结构的设计缺陷。理论上应通过分层和抽象来避免,但在复杂的业务交互中仍难以杜绝。虽然Spring利用三级缓存等机制默默解决了这一问题,使程序得以运行,但这绝不应是懈怠设计的借口。我们更应恪守设计原则,从源头规避循环依赖,构建清晰、健康的架构。

往期回顾

1. Apex AI辅助编码助手的设计和实践|得物技术

2. 从 JSON 字符串到 Java 对象:Fastjson 1.2.83 全程解析|得物技术

3. 用好 TTL Agent 不踩雷:避开内存泄露与CPU 100%两大核心坑|得物技术

4. 线程池ThreadPoolExecutor源码深度解析|得物技术

5. 基于浏览器扩展 API Mock 工具开发探索|得物技术

文 /鲁班

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

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

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

❌