极限挑战,全球化新篇!吉利银河成为首个不补能直通北冰洋的国产新能源品牌
2月9日,随着冬测车队抵达极夜与严寒交织的北极腹地,吉利银河完成了一场跨越瑞典与挪威,总里程超1000公里不补能的冰雪全场景长测,这是自主品牌第一次完成跨洲际、远征北冰洋的冬测挑战。其中,吉利银河混动家族V900、M9、星舰7 EM-i、星耀8、A7、星耀6等车型,完成不补能直通北冰洋千里长测。品牌全系车型,更在Colmis试验场完成亚欧大陆纬度最高测试场挑战。与此同时,即将上市的吉利银河战舰等三款新车,以及醇氢动力车型也在北欧完成了首次冬标测试。

不同于国内常规冬测,吉利银河此次特意选址北欧高纬度地区,直面当地高湿度、黑冰路况等国内难以复刻的极端自然环境,精准模拟全球高纬度寒冷区域的真实用车场景。高湿极寒环境与极夜低光照条件的双重叠加,对车辆密封防雾、整车热管理系统,以及智驾传感器的环境感知能力提出了严苛的要求。而这正是吉利银河远征北欧的核心初衷,以最极致的自然实验室,全面校验品牌“全球研发、全球验证”的体系化技术能力。
此次不补能直通北冰洋的极限挑战,不仅彰显了吉利银河车型续航能力的硬核实力,更印证了品牌全维度的综合性能优势。本次冬测中,吉利银河围绕北欧极寒工况开展多维度专项验证,重点测试冬季ADAS(冬季主动安全功能测试)功能、高湿极寒环境适配性,车顶行李箱适配、冰雪路面牵引载重测试及冬季钉胎、AWD策略雪地标定,同时将低附着路面操控稳定性、整车热管理效率,以及冻融循环下的车辆环境耐久与密封可靠性纳入测试,实现车型极寒工况适配性与稳定性的全方位验证。
硬核表现的背后,是吉利银河针对性的技术攻坚与优化。品牌对四驱车型扭矩管理进行深度调校,实现毫秒级扭矩调节与全地形适配,有效破解冰雪路面打滑难题;新能源适配技术、欧7法规提前验证方案与除霜系统优化,确保车辆在极端低温、高湿度环境下的排放合规、车窗无霜与采暖高效;而甲醇动力车型更是实现超低温冷启动,成功攻克行业内的极寒启动痛点。此次北极冬测的圆满完成,为吉利银河的技术迭代升级、筑牢全球化产品根基奠定了坚实基础。
目前,吉利银河已形成了覆盖极寒到高温、沙漠到赛道的全场景测试能力,包括杭州湾中央试验基地、海南湿热试验基地、黑河高寒试验基地、吐鲁番高温试验基地、欧洲试验基地、云南高原试验基地共六大试验基地,构建起“研发-验证-迭代”的全球化闭环,满足全球车型开发与验证需要。吉利全球试验基地还将在国内外陆续建成16个全球试验基地,打造全天候、全地形、全场景的产品开发与验证能力。
对新能源技术极限的主动验证、对研发不遗余力的大力投入,是吉利银河超越竞品、持续领跑的核心底气。站在2026年1月销量助力吉利汽车登顶中国市场销冠的新起点上,吉利银河正以无畏的闯劲,将“全球研发、全球验证、全域安全”的理念转化为每一位用户触手可及的高价值体验。在全球化征程上,吉利银河已然在北极冰原上留下了属于中国力量的深刻印记,这不仅是“百万银河”时代开启后的首场全球化技术“亮剑”,更是中国汽车工业在世界顶级试炼场上完成的一次壮丽远征。未来,在世界的每一个角落,吉利银河都将像“本地车”一样安全、可靠,以世界的标准,打造世界级好车!

AI安全面临灵魂拷问:“意图篡改”怎么防?绿盟科技给你答案!

随着AI Agent规模化落地被按下“加速键”,其安全是否值得信任?意图篡改、调用链投毒、供应链漏洞、合规备案压力等问题,正成为企业AI落地路上的“绊脚石”。
应势而生,绿盟科技召开以“清风拂境 · 智御全域”为主题的大模型安全创新成果线上发布会。发布会从分析AI应用需求的变化入手,以体系化方案回应行业最迫切的大模型安全防护诉求,并重磅加码绿盟“清风卫”系列产品智能体安全能力,为各行业客户AI安全落地提供可落地、可验证的最新实战指南。
“意图博弈”威胁新起,AI安全红线在哪里?

绿盟科技高级安全研究员祝荣吉
2025年AI应用经历了从“对话助手”向“智能体”的能力跃迁,高速进步的背后暴露诸多隐患:智能体自主运行时,如何避免行为失控风险?自主智能体具备逻辑主权后,它的安全红线在哪里?随“智”而变,绿盟科技高级安全研究员祝荣吉介绍了AI能力演进与攻防焦点变化趋势。他基于Agent感知、规划、记忆、行动四大核心模块,针对性提出了“感知需净输入、规划需抗干扰、记忆需防污染、行动需控权限”的防御准则。
在攻防焦点的动态演进上,祝荣吉表示AI安全正由“内容检测”向“意图博弈”深度转向:2024年聚焦“内容博弈”,重点攻坚对话框安全,解决模型“言多必失”的合规问题;2025年迈入“协议生态”,随MCP工具协议的普及,风险面由对话端延伸至业务系统,核心在于构建调用链的生态信任;2026年的安全重心将直面“意图主权”,严防攻击者通过劫持感知信息实现深层意图篡改与指令劫持。
基于此演进趋势,会上正式发布AISS年度威胁关注矩阵。该矩阵纵向聚焦基座、数据、模型、应用、身份五大安全支柱,完成了从基础大模型到复杂Agent系统的风险透视。通过系统性梳理威胁的年度动态演进路径,矩阵旨在帮助企业在复杂多变的AI场景中精准识别风险优先级、锁定核心问题,真正实现从“盲目围堵”向“精准治理”的体系化演进与升级。
针对风险评估能力的落地,祝荣吉详细介绍了智能化红队评估的技术路径与方法论。绿盟科技依托动态数据集构建、智能风险判定及智能体业务信息探测等核心能力,通过与前沿攻防对抗方法的组合应用,实现了对MCP工具恶意利用、智能体意图劫持及预期外代码执行等新型风险的检测覆盖,真正将碎片化的红队经验转化为体系化的安全验证能力。
靠“补丁”没用,大模型安全如何实现“主动免疫”?

绿盟科技高级方案经理郝广宾
AI时代的安全,从来不是单点的“补丁式防护”,而是贯穿全流程的体系化工程,是整个AI生态的基石。绿盟科技高级方案经理郝广宾发表《“四道防线”守护大模型系统安全防护》的主题演讲,全面阐释了绿盟大模型系统安全方案,他提出“四道防线”纵深防御体系。该体系构建覆盖“开发、部署、运行”全流程的安全防护能力,以实现大模型从“被动响应”到“主动免疫”的安全升维,满足客户大模型系统安全合规应用与实战防护的双重需求。

【四道防线】实现“主动免疫”的安全升维
大模型系统开发阶段,打造“合规+校验”防线体系。要聚焦语料合规和组件安全,使用语料评估工具或服务,对全部训练数据、外挂知识库数据等进行清洗;优先采购部署经过备案的商业大模型服务,加强模型代码及组件完整性校验和安全测试,构建AI软件物料清单,剖析AI系统所依赖的各类组件,精准识别潜在三方供应链组件风险。
大模型系统部署阶段,构建“评测+加固”和“监测+防护”闭环自进化双道协同防线。让“评测”明确“防护”重点,“防护结果”反哺“模型评测”,打造“越用越聪明”的主动免疫体系。在大模型系统上线前,需围绕内容安全、对抗安全、AI红队、供应链等多维度开展安全评测,保障大模型系统安全上线;在大模型系统部署时,需围绕基础设施、模型、应用、数据等打造纵深防御,部署多级安全认证、多维联防围栏、原生应用防护、数据防泄漏等监测防护能力,打造特殊场景安全代答能力,守护大模型系统应用安全。
大模型系统应用运行阶段,优先加强大模型系统安全管理防线。从“监测预警”“应急处置”“供应链安全保障”“备案、标识双合规”等多维度开展大模型系统日常安全运营工作。
使用智能体接连踩坑,安全“防不住、查不清”?

绿盟科技高级产品经理李斌
基于对智能体安全风险的深度洞察,绿盟科技高级产品经理李斌围绕“资产管理、漏洞管理、运行时检测、MCP安全、数据安全、安全态势、安全审计”七大维度,详细介绍了覆盖智能体全生命周期的安全能力体系。发布会上,绿盟科技“清风卫”AI安全系列产品三大智能体安全组件全新亮相。
智能体资产与风险治理系统:支持对智能体核心组件(模型、工具、MCP、知识库、提示词等)进行细粒度发现与动态清点,构建资产与风险画像;
智能体运行时意图与行为安全防护:基于对智能体职责边界的AI建模,实时监测其与MCP、工具、外部系统的交互行为,实现对越权访问、数据泄露等风险的实时发现与自动阻断;
智能体红队测评与持续验证平台:依托AI红队测评引擎,基于智能体配置与业务场景生成针对性攻击用例,通过单轮与多轮对话模拟,深度挖掘潜在风险。
李斌强调,绿盟清风卫AI安全产品体系具备“平台化集成、场景化适配、自动化运营”三大特点,可灵活对接各类智能体开发平台与既有安全基础设施,为客户提供从开发态到运行态的一体化“监管控”能力。
从AI Copilot到AI Agent,从协作辅助到自主执行,大模型应用形态越深入业务核心,安全的重要性就越凸显。作为网络安全行业排头兵,绿盟科技始终秉承“巨人背后的专家”的使命,未来将持续跟踪AI应用风险与需求的变化,不断优化整体安全防护方案,升级产品和服务,为行业客户破解安全难题,让安全不再是AI创新的“顾虑”,而是驱动业务增长的“底气”!
“改个配置还要发版?”搞个配置后台不好吗
前言
之前我们公司有个项目组搞了个 AI 大模型推荐功能,眼看就要上架了,结果产品突然找过来说:
“那个 AI 推荐的模块先别放了,先隐藏吧,怕上架审核出问题。”
为了赶时间,技术那边就临时加了个判断,把入口在前端藏起来,赶紧发了个版本,算是暂时搞定。
结果没过几天,又说要发版本。我一问咋了?技术一脸生无可恋:
“产品又说 AI 那个功能现在可以公测了,要放出来了。”
好吧,那就再发一次......
然后没几天,产品又说要隐藏掉,说模型结果不稳定要临时下线。
“先别公测了,发现有问题,先关掉再说。”
听说那天技术已经气到跟产品去厕所单挑了……
我当时的内心 OS 是:这也太反复了吧?到底是想上还是不想上?
这种“上线前隐藏,上线后又展示,再隐藏”的操作,不是一次两次了。
所以我们当时就想了:
既然这些需求只是改个显示开关、调个默认值,为啥不干脆给他们一个“自助按钮”?别每次都让我们改代码发版本,产品自己调着玩不好吗?
于是我们开始在后台做一套简单的业务配置中心,目标就是:
- 产品、运营可以自己配置功能开关、文案、参数,不用找开发
- 配置修改能实时生效,不用再发版本
- 支持输入类型、下拉选项、开关按钮、范围数值,想怎么配就怎么配
这不是为了什么“大中台”,就是想解决那些一天三改、两小时一调的需求,把这些琐碎从开发日常里剥离出去。
这些配置,说改就改,好烦人
其实像上面的这个事情 在我们日常开发中太常见了。
举几个我亲身经历的例子就知道为啥我们非得搞个配置中心:
- 登录要不要加图形验证码?
一开始为了用户体验不加,结果突然哪天注册量暴涨,一查是黑产在刷。产品急了:“赶紧加验证码!”
技术临时改、测试、上线……黑产已经溜了,下次再刷又得重来一遍。为啥不做个开关自己控制? - 推荐功能的参数一天一个样
有一版产品说“默认推荐 5 个兴趣标签”,隔两天又改成 3 个,再过几天又要回 4 个,说“现在运营数据反馈不一样了”。
我寻思你都能自己看数据了,那你为啥不能自己改参数? - 短信通道经常切换
阿里、腾讯、网易云信……一个月能换仨。原因也很实在,要么是价格问题,要么是运营说“昨天验证码收不到”。
每次换通道都得技术去代码里改templateId、signName,我真想把接口都写成配置项,让你们爱换谁换谁。 - 活动逻辑说改就改
运营:“这个弹窗逻辑改一下,注册就弹。”
上了之后运营又说:“太打扰用户了,还是调成登录 3 天之后再弹。”
这不就一行逻辑的事,但每次都要发版真心烦。
像这些情况,改的不是业务逻辑,就是个值、个条件、个开关。但只要没抽出来配置,就只能靠技术手动改代码,一点都不优雅,还特别浪费时间。
所以后来我们就想,还是干脆统一搞个配置后台吧,把这些“天天改、随时调”的破事都收进去,让配置能看得见、改得了、控得住,技术这边也能轻松点。
之前我们配置到底怎么管的?说实话,说出来都不太好意思:
- 有的直接写死在代码里,变量名都不带解释的,谁写的谁才知道啥意思;
- 稍微规范一点的,会统一搞个 config 文件,但也只是“技术自己看得懂”的那种;
- 更混乱的是,有的配置写在 yml,有的塞在数据库,还有的干脆“哪里用到就哪里写”,找都找不到;
- 最离谱的是:业务运营类的配置和技术底层的配置,全堆一起,切短信通道这种运营配置放在 core-service 的 application.yml 里,看得人脑壳疼。
等到要改配置的时候,产品和运营根本不知道去哪改,开发也得翻半天才能定位是哪段逻辑控制的,甚至还会不小心把技术底层配置给动了……这种时候就明白了,没有一套清晰、隔离、可视化的配置系统,迟早乱套。
配置也要讲规矩,不能啥都往后台扔
当然,配置中心也不是说所有配置都往后台一丢就完事了。
我们踩过这个坑。
最早的时候我们也想偷懒,把所有配置(技术的、业务的、运营的)都塞在统一的配置文件里,比如 config.php 或 application.yml 里,统一读就完了,听起来挺美的。
但实际用下来,真的是——乱!成!一!锅!粥!
一方面,业务/运营配的东西,是给人看的,得讲人话。
比如:
- 推荐位默认展示几个内容?
- 某个功能在特定版本下是否开启?
- 发奖的触发条件设置为几天内登录?
这些运营同学自己都能理解,也希望自己能控制,那就该做成后台可配置、能预览、能实时生效的。
但另一方面,有些配置就不能乱动,或者干脆不应该给后台看到:
- MQ 消费 topic 名
- 数据库连接池配置
- 是否启用 debug 模式
- 线上某些敏感接口的限频值
如果我们把这些放给业务方看,他们可能都不知道是干嘛的,不小心点了一下,系统都能给整崩了……
所以后来我们就统一了一套大致的“配置分级规则”:
有些配置是纯技术底层的,比如 MQ 的 topic 名、接口限流的阈值、日志采样比例、数据库连接池大小……
这些属于“动一下系统都可能出事”的类型,不给任何后台入口,完全由技术团队内部维护,最好写死或写进 yml 里。
然后是那种纯运营向的配置,比如功能开关、首页推荐展示几个卡片、某段文案内容、活动弹窗的显示时间等等。
这些配置逻辑上不会出啥大问题,但会被运营天天来回调整,必须放后台让他们自己搞,不然技术早晚被烦死。
还有一种是产品经常会动的业务规则类配置,比如:
- 某个功能灰度给哪些版本开放;
- 某个打点逻辑的间隔时间怎么设;
- 是否对新用户显示某个引导。
这些其实也不属于“技术配置”,而是产品为了做 A/B 测试、做用户分群、验证效果临时改的,技术只负责“支持能力”,真正的“值”还是应该交给产品自己配。
我们就按照这个思路,把技术底层的隐藏掉,只暴露运营类和业务类配置给后台,谁负责谁管理。改错了好歹还能找到人。
这样一来,配置中心的边界清晰了,技术也不用再被无限兜底,系统也能跑得更稳定,团队分工也更顺。
配置中心该长啥样?我们定了几个目标
前面说了那么多需求、痛点、吵架场面(划掉),那我们到底想要一个什么样的配置中心呢?
说白了,我们的目标很简单:
能分清模块,分清人,分清配置类型,能改能看能预览,最好技术都不用管。
那我们是怎么拆功能的呢?最初的设计版本是这样的:
1. 配置要能分模块
不能全堆一起。
我们一个项目,最少有这些模块:系统相关、用户系统、运营活动、AI 任务、通知推送、发奖逻辑……每个模块都有自己的配置。
所以我们一开始就支持“按模块分组”,一个模块里挂多个配置项,清清楚楚谁负责啥,谁爱改啥自己管。
2. 每个配置项要有“类型”
这个最开始我们踩过坑。
最初只做了输入框,结果运营输入一堆错格式的东西,改出 bug。后来我们就定了:所有配置项必须类型化,根据使用场景来限定可选值/格式。
目前我们支持的类型大概是这几种:
- 输入框:最普通的文本输入,比如标题、url、提示语。
- 范围值:像“10~100”这种,就搞个最小|最大格式,自动校验。
- 下拉框:适合选模板、选模型、选渠道。
- 单选框:跟下拉差不多,看场景展示方式不同。
- 开关:很直观,是否开启、是否显示,一眼看懂。
- 多选框:比如支持多个渠道、多种规则生效。
每种类型不仅能展示、还能实时预览效果,这样产品在后台改的时候,不会因为“看不懂这个字段到底是啥”而填错。
3. 每个配置项还要有说明、默认值、排序、是否启用这些“附加属性”
比如说明字段是给人看的,告诉我们这个配置是干嘛的。
默认值是防止读取失败时兜底的。
排序值让我们在后台列表里好找,不然一堆配置乱七八糟的。
“是否启用”是加的一个保险,有些配置值可以保留但临时不生效,方便灰度切换或者留作备选。
4. 最重要的:配置改完必须能“立刻生效”
要是改完还得等发版、等服务重启,那这配置中心跟笔记本有啥区别?
所以我们后面加了热更新机制(这部分我后面会细讲),让配置一改,业务侧立刻拿到新值。
配置中心的数据库设计,我们是这么搞的
配置中心的本质,其实就是“配置的结构化存储 + 可控修改 + 有上下文管理”。
所以数据库是核心。我们一共设计了两个主表:模块表 + 配置项表。当然后续可以加变更记录表之类的,这里先讲核心结构。
1. 模块表:配置的分组归属
配置不能全堆一起,所以我们做了个“模块管理”表,每个模块代表一类业务,比如用户系统、AI 推荐、通知设置、发奖逻辑等。
表结构像下面这样:
CREATE TABLE `config_module` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL COMMENT '模块名称',
`sort` int(11) DEFAULT '0' COMMENT '排序值,越小越靠前',
`status` tinyint(4) DEFAULT '1' COMMENT '状态 1启用 0禁用',
`create_time` int(11) NOT NULL,
`update_time` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='配置模块分组';
字段说明一下:
id就是主键name是模块名,比如“用户系统”、“活动发奖”sort控制在前端显示顺序,方便找status是不是启用这个模块的配置- 时间字段保留是为了后续查操作记录
2.配置项表:一条配置的所有核心信息
模块分好了之后,接下来就是每个模块下面的具体配置项。我们所有的配置内容,都存在这张 config 表里。
我们当时在设计的时候,就围绕几个问题来定字段的:
- 这个配置是给谁看的?(产品、运营、开发)
- 他们需要怎么填?(输入框?下拉?多选?)
- 填的时候怎么确保不出错?(要不要加参数说明?校验?默认值?)
- 配置项能不能启用/禁用?排序顺序怎么控制?
- 有没有必要展示说明/备注?
最终我们定下了下面这个表结构:
CREATE TABLE `config` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`pid` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '所属模块ID',
`name` varchar(100) NOT NULL COMMENT '配置项名称(展示用)',
`key` varchar(100) NOT NULL COMMENT '配置项key(英文唯一标识)',
`value` text COMMENT '配置项当前值',
`input_type` tinyint(4) NOT NULL DEFAULT '1' COMMENT '输入类型:1输入框 2范围 3下拉 4单选 5开关 6多选',
`param` text COMMENT '参数说明:选项或范围,如 "A-1|B-2|C-3"',
`desc` varchar(255) DEFAULT '' COMMENT '配置项说明/备注',
`sort` int(11) DEFAULT '0' COMMENT '排序值(越小越前)',
`status` tinyint(4) DEFAULT '1' COMMENT '状态:1启用 0禁用',
`create_time` int(11) NOT NULL,
`update_time` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `key` (`key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统配置项表';
字段说明:
id:自增主键,没啥说的。pid:配置项属于哪个模块(模块表里的 id)。name:配置项的中文名,比如“是否展示弹窗”。key:这个是英文标识,用来在代码里读取,比如home.pop.enabled。value:配置的当前值,比如1(开启)或{"a":1,"b":2}。input_type:决定这个配置项在后台怎么展示:
- 1 = 输入框
- 2 = 范围(比如“10|100”)
- 3 = 下拉框
- 4 = 单选按钮
- 5 = 开关
- 6 = 多选框
param:这个字段就很灵活了,用来定义下拉/单选/多选的选项,或者范围的上下限,比如:
"中文-zh|英文-en""10|100"
desc:配置项的说明,告诉用户这个配置干嘛的,防止乱填。sort:排序值,配置项多了之后按顺序展示比较清楚。status:这个配置当前是否启用,方便临时关闭某个配置而不删除它。create_time/update_time:时间戳,用来做修改记录、日志追踪之类的。UNIQUE KEY (key):保证每个配置 key 唯一,不会撞名。
总体来说,这张表的核心就是:一个配置项,长什么样、值是多少、长得像啥、能不能动,都在这张表里写清楚了。
模块设计:把配置管得清清楚楚,落到后台页面里
前面我们提到,配置中心一定要支持“按模块分组”,不然配置一多,找起来比翻快递单还费劲。
这里就简单说下我们是怎么把“模块”这个概念,真正落到后台界面和数据库结构里的。
我们每个模块其实就是一个大分类,比如:
- 系统配置
- 用户配置
- AI 相关配置
- 活动发奖配置
- 通知推送相关……
每个模块下可以挂多个配置项,类似“一个文件夹里放一类东西”。
我们后台页面上的模块管理界面,大概就长这样:

这里我们可以设计的支持排序、状态控制、修改、删除这些操作。
点击“新增模块”就会弹出这个表单:

看起来非常简洁对不对。上面的模块表结构对应的核心字段就三个:
- 模块名称:展示用,方便识别
- 排序值:用来控制前端列表显示顺序
- 是否启用:可以临时禁用整个模块下的配置项
后台页面支持模块的增删改查,基础功能已足够覆盖大多数业务配置需求。
当然,如果业务后续需要更复杂的结构(比如“系统配置 → 登录模块 → 登录相关配置”这种),我们也可以扩展支持二级模块或模块分组。当前只是保留了基础能力,足够轻量、上手快。
配置项怎么设计的?
有了模块分组之后,接下来的重头戏就是:配置项的核心玩法。
每个配置项,本质上就是一个“可调参数”。比如这些熟悉的问题:
- 用户登录要不要启用谷歌验证?
- 运营活动的推荐数值范围是多少?
- 发奖逻辑该切哪个短信商?
以前这些配置,不是写死在代码里,就是散落在 yml、env 文件里,甚至不同环境各一份,改一次还得发版,改完还得祈祷别出问题。
这次我们干脆做成可视化配置项,页面上就能:
新增、修改、启用/禁用
设置不同类型的输入方式
即填即预览,所见即所得
我们支持的配置类型包括:
- 输入框:适合输入纯文本,比如一个 URL、token、默认值等。
- 范围:用
最小值|最大值格式,比如推荐人数限制就写成10|100。 - 下拉选择:多个固定选项,用
|分隔,比如中文-zh|英文-en。 - 单选按钮:和下拉差不多,但前端展示为横向圆点,更直观。
- 开关:布尔值场景,1 是开启,0 是关闭。
- 多选框:允许选多个选项,比如某功能适用于多个角色、多个平台。
每种类型在新增配置时都有专属提示,比如范围要填最小|最大,下拉要写选项清单,填完之后还能看到实时预览,确保填的值就是我们想要的。
接下来我们就一个个举例,一边介绍场景,一边实际新增配置项来看效果。
类型一:输入框
适用场景:
输入框是最通用、最基础的配置类型,适合填写纯文本、数字、链接、key 等,不需要做复杂校验,直接存直接用。
常见场景举例:
default_jump_url:默认跳转链接,比如用户扫码登录后跳去哪个页面。login_timeout:用户登录状态超时时间,单位秒。system_notice:系统公告文案。token_prefix:JWT 或其它 token 的前缀标识。
我们现在来新增一个配置项:
- 所属模块:系统配置
- 配置名称:登录超时(秒)
- 配置 Key:
login_timeout - 输入类型:输入框
- 默认值:600
- 参数说明:留空(输入框不需要)
- 描述:用户登录后多少秒内无操作将自动退出
- 排序值:0
- 是否启用:是

类型二:范围(最小值 | 最大值)
适用场景:
范围类型适合那种“值不能随便填,必须在某个区间内”的配置,比如:
- 推荐系统中:每天最多推荐多少次?
- 活动配置中:用户每次最多能抽几次奖?
- 发奖逻辑中:奖励金额必须在一个上下限之间。
用配置来写这种规则,业务方只要改数字就行,不用再去翻代码或改逻辑,非常方便。
示例配置:推荐数值范围
假设我们现在要配置一个推荐值范围:
- 所属模块:系统配置
- 配置名称:每日推荐数量范围
- 配置 Key:
recommend_count_range - 输入类型:范围
- 默认值:50
- 参数说明:
10|100(表示最小值是 10,最大值是 100) - 描述:控制推荐系统每天给用户推荐的最小/最大条数
- 排序值:0
- 是否启用:是
我们在页面中选择「输入类型:范围」之后,系统会提示填写参数格式为:
最小值|最大值,例如:10|100

类型三:下拉选择(select)
适用场景:
如果某个配置值只能从一组选项中选一个,比如:
- 默认语言:中文 / 英文 / 日文
- 消息推送渠道:极光 / 个推 / 小米推送
- 推荐策略:粗放型 / 精细化 / AB 测试组
这类配置,业务经常调整,但必须选“规定范围内”的值,用下拉最合适。
示例配置:默认语言设置
我们现在来配置一个「默认语言」的选项:
- 所属模块:系统配置
- 配置名称:默认语言
- 配置 Key:
default_language - 输入类型:下拉选择
- 默认值:
zh - 参数说明:
中文-zh|英文-en|日文-jp - 描述:系统默认语言,决定用户首次进入时的显示语言
- 排序值:0
- 是否启用:是
注意参数说明的格式:
每个选项写成“名称-值”,多个选项用 | 隔开,比如:
中文-zh|英文-en|日文-jp
我们可以随意扩展选项,只要格式统一就行。

类型四:单选按钮(radio)
适用场景:
单选按钮适合那种选项数量不多、用户希望“一眼看清楚当前选的是啥”的配置,比如:
- 登录方式:密码 / 验证码 / 三方授权
- 首页布局:列表 / 瀑布流
- 推送等级:重要 / 普通 / 弱提示
相比下拉,单选按钮更直接,不用点一下再展开,适合管理后台中高频使用的布尔或枚举项
示例配置:登录方式选择
假设我们想设置一个登录方式的配置:
- 所属模块:系统配置
- 配置名称:登录方式
- 配置 Key:
login_method - 输入类型:单选按钮
- 默认值:
pwd - 参数说明:
密码登录-pwd|验证码登录-code|三方授权-oauth - 描述:控制用户使用哪种方式登录
- 排序值:0
- 是否启用:是
参数说明格式:
和下拉一样,用 名称-值 的格式写选项,多个用 | 分隔:
密码登录-pwd|验证码登录-code|三方授权-oauth

类型五:开关(switch)
适用场景:
布尔型逻辑的最爱!
只要我们有 “开关类” 配置,比如:
- 是否开启 AI 推荐功能
- 是否启用登录验证码
- 是否允许用户取消订单
- 是否开启调试日志打印
这些“启用 / 禁用”型的业务控制,都可以直接用开关来配置,后台切换一次立即生效,不用发版,非常方便。
示例配置:启用登录验证码
这次我们来添加一个“是否启用图片验证码”的配置:
- 所属模块:系统配置
- 配置名称:启用图片验证码
- 配置 Key:
login_captcha_enabled - 输入类型:开关
- 默认值:
1(1 表示启用,0 表示关闭) - 参数说明:留空(开关类型不需要)
- 描述:是否对用户登录行为开启图形验证码验证
- 排序值:0
- 是否启用:是

这类配置用处非常多,一些 灰度开关、紧急兜底、临时下线功能 都可以通过这个来做,非常适合给非技术人员使用。
类型六:多选框(checkbox)
适用场景:
当我们希望用户可以勾选多个选项时,单选就不够用了,比如:
- 消息推送支持的渠道:短信 / App / 微信 / 邮件
- 用户允许绑定的第三方平台:微信 / QQ / 微博
- 内容推荐的标签:热门 / 最新 / AI / 精选
多选框让这些“可以组合”的配置变得灵活,谁要开就勾谁,要多选就多选,不受限制。
示例配置:允许的推送渠道
我们来配置一个「支持的消息推送渠道」:
- 所属模块:系统配置
- 配置名称:推送渠道
- 配置 Key:
push_channels - 输入类型:多选框
- 默认值:
app,wechat(多个值用英文逗号隔开) - 参数说明:
短信-sms|App-app|微信-wechat|邮件-mail - 描述:平台支持的推送方式,可多选
- 排序值:0
- 是否启用:是

参数说明格式 & 默认值说明
- 参数格式:
展示文本-值用|分隔
短信-sms|App-app|微信-wechat|邮件-mail
- 默认值:用英文逗号
,隔开多个值,必须是参数里定义过的值
app,wechat
配置项列表展示效果(后台页面)
配置添加完以后,在后台配置中心的列表中展示是这样:

每一项都根据类型展示了不同的 UI 组件,页面清晰、可读、可点、可编辑,操作起来一目了然。
而我们的数据库记录示例(config 表)存储为这样的:

每条记录都绑定了模块 ID(这里都挂在“系统配置”模块下),并且通过 input_type 字段区分了类型,param 字段为配置项的结构补充说明(下拉/单选/多选专用),desc 字段用于给配置者提示用途。
至于页面上的 UI 展示逻辑、预览区域怎么动态渲染、后端接口怎么接收和保存这些配置,我这边就不展开一一举例了。
说到底,这套配置中心的重点不是“多高级的交互”,而是“足够简单、稳定、好用”,让我们能快速落地配置项、快速修改参数,而不是天天写死在代码里改个值还要发版。
给产品和运营用的“安全编辑页”
配置项都建好了,页面也能预览,那产品和运营想调参数的时候,是直接去编辑配置项吗?
当然不能。
你想啊,运营只是想把推荐数从 100 调成 50,结果点到 key 了,把 recommend_range 改成 recommend_rang,那后端一拿不到值,整个推荐系统直接罢工了。
所以我们专门做了一套“参数调整页”,就像上图这样的界面,产品和运营只需要点点选项、输个值、开个关,完全不用接触 key 和底层结构,修改也更安全。
这个页面其实是对配置项的“业务层封装”——模块和配置项的创建,还是需要研发来做的。因为只有开发才能知道每个 key 该怎么在代码里接,哪些是支持实时生效的,哪些改了之后要重启服务,业务逻辑怎么走,这些都不是运营自己能处理的。
换句话说:
- 配置项创建时,研发定义 key + 类型 + 默认值 + 参数说明。
- 产品和运营后续修改时,只改值,不动结构,不容易出错。
那我们既然添加了配置项之后,下一步就是把它展示出来,让运营和产品能方便地修改配置、实时查看效果。
我们设计了一个专门的页面,用来承载这些配置项的「操作界面」。页面整体是按模块分 tab 展开的,每个模块下展示自己对应的配置项,表单类型跟配置项定义时保持一致,比如输入框、开关、下拉、多选等一应俱全。
如下图所示,就是我们系统初版配置模块下的实际页面效果:

当然如果某个模块配置太多我们也可以切换为纵向展示:

从上图我们可以看到:
- 每个配置项都有自己的「说明文案」,方便使用者理解配置含义;
- 类型化配置项有明确的 UI 控件,比如「每日推荐数量范围」就是滑动条;
- 实时编辑,保存即生效(根据配置项定义的类型和读取方式);
- 页面左上角还能切换模块,快速定位。
这个页面是专门为非技术人员设计的,不需要他们懂 key 是什么,也不用关心类型怎么定义,他们只管调值就行了,一切都变得可控又安全。
有了这个配置页之后,产品和运营基本上就能脱离研发,自主修改参数了。接下来我们来聊聊配置值在后端是怎么被接入的。
配置中心只是“存”,真正怎么“用”还得看后端
配置中心做得再强大,最终目的还是要服务业务逻辑。
页面上填的那些 key 和 value 并不是为了好看,它们必须在后端代码里“用起来”,才算真正落地。
那后端是怎么接这些配置的呢?其实就两件事:
- 读取配置值
- 根据 key 做对应逻辑处理
比如我们在后台新增了一个配置项:
key: enable_google_auth
value: 1
表示用户登录时是否开启 Google 验证。那后端代码里就可能是这样写的:
func ShouldUseGoogleAuth() bool {
val := configService.Get("enable_google_auth")
return val == "1"
}
而且每次新建一个配置项,一定要先让开发把读取逻辑写好,配置才能真正生效。不然页面配得再漂亮,后端代码不接,等于白改。
因此,我们建议配置项的新增一定要有“二次审核”机制——业务逻辑没走通之前,别急着让配置项上线。
配置读取:要读得快,还要改得稳
我们虽然只是做了一个小小的配置中心,但依旧严格遵守几个“配置铁律”:
| 原则 | 含义 |
|---|---|
| 读快写稳 | 配置是读多写少,必须优先保障读取性能 |
| 缓存兜底 | 数据库抗不了高并发,缓存必须做主力 |
| 更新可控 | 配置改动要支持热更新,不能等发版 |
| 不信网络 | 避免每次都走 RPC / HTTP,配置必须能“本地感知” |
所以我们最后的设计是:
所有配置值都先读 Redis,Redis 没有再查数据库,查出来的值再写回 Redis。
我们可以简单封装了一个方法来统一读取配置:
func (s *ConfigService) Get(key string) string {
val, err := redisClient.Get(ctx, "config:"+key).Result()
if err == redis.Nil {
val = db.GetConfigFromDB(key)
redisClient.Set(ctx, "config:"+key, val, time.Hour) // 缓存 1 小时
}
return val
}
这样做的好处:
- 性能好:Redis 读取速度快,尤其适合配置这种读多写少的场景;
- 调用方便:业务方不需要知道配置在哪,直接调
configService.Get(); - 支持热更新:后台一改配置,Redis 一更新,后端逻辑立刻用上新的值。
配置改错了怎么办?我们加了「刷新机制」来兜底
想象一下,有个产品小哥在后台把短信通道从 A 改成了 B,然后 Redis 秒同步,结果是 B 接口根本没打通……
“线上短信全挂了”+“谁改的都不知道”+“运营拉着技术去机房单挑”
这事我们也不是没经历过。
为了防止“手滑即事故”,我们可以引入了 配置刷新机制。
也就是说,后台页面改配置只是“提交更新”,真正生效得靠一个“刷新动作” 。
我们可以设计一种配置刷新方案:保存 ≠ 生效,需要“手动刷新”才能同步
比如我们之前初版采用的模式,更保险一些:
- 后台页面改配置,只写入数据库,不同步 Redis;
- 系统标记这个配置为“待刷新”;
- 产品或运营点“刷新”按钮,才真正写入 Redis;
- 同步操作记录 & 通知消息,方便追踪。
优点是:
- 不怕误操作,有一步确认机会;
- 可接入审批流程;
- 所有操作都有记录,排查问题不含糊。
当然我们也可以设计一个“刷新 API”:
POST /api/config/refresh?key=xxx
POST /api/config/refresh_all
支持单个或批量刷新,用于后台管理或开发联调。
安全兜底机制
除了“刷新机制”,我们还需要做几件事:
| 功能 | 说明 |
|---|---|
| 操作记录 | 谁改了什么,啥时候改的,值变了多少 |
| 修改通知 | 配置一改,系统自动发钉钉提醒相关同事 |
| 关键配置加锁 | 比如短信、支付相关配置默认加锁,解锁需审批 |
| 限制字段编辑 | 页面上只能改 value,不允许改 key,防止配置失效 |
总的来说呢,配置中心不是“你点保存我就给你改”,而是一个受控的配置发布系统。
读得快、改得稳、改完能溯源、有通知有审计——这才是一个靠谱的配置中心。
最后的碎碎念
说实话,这套配置中心,说难也不难,说重要吧,也不是业务核心。
但对我们来说,真的很刚需。纯粹就是日常工作中被“配置这点事儿”折磨太久了。 尤其是对业务开发流程不规范的公司来说。
以前在小公司,改个配置就得发版,发版就有几率中奖,一不小心就全服出事。久了大家都怕动,连产品都不敢随便提需求,说白了就是被流程和风险绑住手脚。
后来我们才想明白,像“推荐数量改一下”“开关先关一阵看看效果”这种,完全没必要动代码、改逻辑、走上线流程。给他们一个地方自己调就好了嘛。
所以这套配置中心,说不上啥高级架构,也不是啥大厂必备,但就是解决了我们日常那些“看起来不重要但天天遇到”的小问题。
业务推进更顺了,产品改需求也不再靠嘴说,技术也不用动不动上线连夜发包。这不比啥都强?
更多架构实战、工程化经验和踩坑复盘,我会在公众号 「洛卡卡了」 持续更新。
如果内容对你有帮助,欢迎关注我,我们一起每天学一点,一起进步。
来源:juejin.cn/post/7534632857504989238
微服务正在悄然消亡:这是一件美好的事
最近在做的事情正好需要系统地研究微服务与单体架构的取舍与演进。读到这篇文章《Microservices Are Quietly Dying — And It’s Beautiful》,许多观点直击痛点、非常启发,于是我顺手把它翻译出来,分享给大家,也希望能给同样在复杂性与效率之间权衡的团队一些参考。
微服务正在悄然消亡:这是一件美好的事
为了把我们的创业产品扩展到数百万用户,我们搭建了 47 个微服务。
用户从未达到一百万,但我们达到了每月 23,000 美元的 AWS 账单、长达 14 小时的故障,以及一个再也无法高效交付新功能的团队。
那一刻我才意识到:我们并没有在构建产品,而是在搭建一座分布式的自恋纪念碑。

我们都信过的谎言
五年前,微服务几乎是教条。Netflix 用它,Uber 用它。每一场技术大会、每一篇 Medium 文章、每一位资深架构师都在高喊同一句话:单体不具备可扩展性,微服务才是答案。
于是我们照做了。我们把 Rails 单体拆成一个个服务:用户服务、认证服务、支付服务、通知服务、分析服务、邮件服务;然后是子服务,再然后是调用服务的服务,层层套叠。
到第六个月,我们已经在 12 个 GitHub 仓库里维护 47 个服务。我们的部署流水线像一张地铁图,架构图需要 4K 显示器才能看清。
当“最佳实践”变成“最差实践”
我们不断告诫自己:一切都在运转。我们有 Kubernetes,有服务网格,有用 Jaeger 的分布式追踪,有 ELK 的日志——我们很“现代”。
但那些光鲜的微服务文章从不提的一点是:分布式的隐性税。
每一个新功能都变成跨团队的协商。想给用户资料加一个字段?那意味着要改五个服务、提三个 PR、协调两周,并进行一次像劫案电影一样精心编排的数据库迁移。
我们的预发布环境成本甚至高于生产环境,因为想测试任何东西,都需要把一切都跑起来。47 个服务在 Docker Compose 里同时启动,内存被疯狂吞噬。
那个彻夜崩溃的夜晚
凌晨 2:47,Slack 被消息炸翻。
生产环境宕了。不是某一个服务——是所有服务。支付服务连不上用户服务,通知服务不断超时,API 网关对每个请求都返回 503。
我打开分布式追踪面板:一万五千个 span,全线飘红。瀑布图像抽象艺术。我花了 40 分钟才定位出故障起点。
结果呢?一位初级开发在认证服务上发布了一个配置变更,只是一个环境变量。它让令牌校验多了 2 秒延迟,这个延迟在 11 个下游服务间层层传递,超时叠加、断路器触发、重试逻辑制造请求风暴,整个系统在自身重量下轰然倒塌。
我们搭了一座纸牌屋,却称之为“容错架构”。
我们花了六个小时才修复。并不是因为 bug 复杂——它只是一个配置的单行改动,而是因为排查分布式系统就像破获一桩谋杀案:每个目击者说着不同的语言,而且有一半在撒谎。
那个被忽略的低语
一周后,在复盘会上,我们的 CTO 说了句让所有人不自在的话:
“要不我们……回去?”
回到单体。回到一个仓库。回到简单。
会议室一片沉默。你能感到认知失调。我们是工程师,我们很“高级”。单体是给传统公司和训练营毕业生用的,不是给一家正打造未来的 A 轮初创公司用的。
但随后有人把指标展开:平均恢复时间 4.2 小时;部署频率每周 2.3 次(从单体时代的每周 12 次一路下滑);云成本增长速度比营收快 40%。
数字不会说谎。是架构在拖垮我们。
美丽的回归
我们用了三个月做整合。47 个服务归并成一个模块划分清晰的 Rails 应用;Kubernetes 变成负载均衡后面的三台 EC2;12 个仓库的工作流收敛成一个边界明确的仓库。
结果简直让人尴尬。
部署时间从 25 分钟降到 90 秒;AWS 账单从 23,000 美元降到 3,800 美元;P95 延迟提升了 60%,因为我们消除了 80% 的网络调用。更重要的是——我们又开始按时交付功能了。
开发者不再说“我需要和三个团队协调”,而是开始说“午饭前给你”。
我们的“分布式系统”变回了结构良好的应用。边界上下文变成 Rails 引擎,服务调用变成方法调用,Kafka 变成后台任务,“编排层”……就是 Rails 控制器。
它更快,它更省,它更好。
我们真正学到的是什么
这是真相:我们为此付出两年时间和 40 万美元才领悟——
微服务不是一种纯粹的架构模式,而是一种组织模式。Netflix 需要它,因为他们有 200 个团队。你没有。Uber 需要它,因为他们一天发布 4,000 次。你没有。
复杂性之所以诱人,是因为它看起来像进步。 拥有 47 个服务、Kubernetes、服务网格和分布式追踪,看起来很“专业”;而一个单体加一套 Postgres,看起来很“业余”。
但复杂性是一种税。它以认知负担、运营开销、开发者幸福感和交付速度为代价。
而大多数初创公司根本付不起这笔税。
我们花了两年时间为并不存在的规模做优化,同时牺牲了能让我们真正达到规模的简单性。
你不需要 50 个微服务,你需要的是自律
软件架构的“肮脏秘密”是:好的设计在任何规模都奏效。
一个结构良好的单体,拥有清晰的模块、明确的边界上下文和合理的关注点分离,比一团由希望和 YAML 勉强粘合在一起的微服务乱麻走得更远。
微服务并不是因为“糟糕”而式微,而是因为我们出于错误的理由使用了它。我们选择了分布式的复杂性而不是本地的自律,选择了运营的负担而不是价值的交付。
那些悄悄回归单体的公司并非承认失败,而是在承认更难的事实:我们一直在解决错误的问题。
所以我想问一个问题:你构建微服务,是在逃避什么?
如果答案是“一个凌乱的代码库”,那我有个坏消息——分布式系统不会修好坏代码,它只会让问题更难被发现。
来源:juejin.cn/post/7563860666349649970
Maven 4 终于快来了,新特性很香!
大家好,我是 Guide!在 Java 生态中,Maven 绝对是大家每天都要打交道的“老朋友”。
InterviewGuide 这个开源 AI 项目中,我使用了 Gradle。不过,根据大家的反馈来看还是更愿意使用 Maven 一些。

目前(2026 年 1 月)Maven 4.0 仍处于 Release Candidate 阶段,最新版本为 4.0.0-rc-5(2025 年 11 月 08 日发布),尚未正式 GA(General Availability)。

虽然目前 Maven 4 还处于 Release Candidate(RC)阶段,但它展现出来的特性足以让我们这些长期被 Maven 3 “历史债”折磨的开发者感到兴奋。
一句话总结:Maven 4 要求最低 Java 17 运行环境,通过分离构建与消费模型、树形生命周期等黑科技,彻底告别了臃肿且难以维护的 POM。
下面简单介绍一下 Maven 4 的最重要新特性(基于官方文档和发布记录):
Build POM 与 Consumer POM 的分离
这是 Maven 4 解决的最大痛点。在 Maven 3 时代,你发布的 pom.xml 既要管“怎么构建”,又要管“别人怎么依赖”,导致发布的元数据极其臃肿,甚至带有大量的 profile 和本地路径。
Maven 4 解决方案:
- Build POM:这就是你本地编辑的
pom.xml(模型升级至 4.1.0)。它包含所有的构建细节,比如插件配置、私有 profile 等。 - Consumer POM:当你执行
deploy时,Maven 4 会自动生成一个“纯净版”的pom.xml(固定为 4.0.0 模型)。它去掉了所有插件、build 逻辑和 parent 继承关系,仅保留 GAV 坐标和核心依赖。

默认关闭 ,需显式开启:
mvn deploy -Dmaven.consumer.pom.flatten=true
或在项目根 .mvn/maven-user.properties 中永久配置:
maven.consumer.pom.flatten=true
这样的话,发布的 artifact 更干净,依赖解析更快,生态(Gradle、sbt、IDE、Sonatype 等)兼容性更好,无需再依赖 flatten-maven-plugin 等 hack 方案。

POM 模型升级到 4.1.0 + 多项简化语法
Maven 4 引入了全新的命名空间(**maven.apache.org/POM/4.1.0**…
1. 自动发现子项目
- 新标签
<subprojects>:正式取代了容易产生术语混淆的<modules>(标记为 deprecated)。 - 隐式发现 :如果父项目
packaging=pom且没有声明子项目,Maven 4 会自动扫描包含pom.xml的直接子目录。再也不用手动一行行写子模块名了!
2. 坐标推断(Inference)
在 <parent> 中,如果你按默认路径放置项目,可以省略 version、groupId 甚至整个坐标。Maven 会自动从相对路径推断父 POM 坐标。
3. CI 友好变量原生支持
${revision}、${sha1} 等变量现在是原生一等公民,不需要再写 hack 插件就能直接在命令行定义版本。
构建性能:从线性生命周期到树形并发
Maven 3 的生命周期是线性的,这意味着如果你的项目很大,构建过程就像“老牛拉破车”。
1. 树形生命周期与钩子
Maven 4 将生命周期升级为树形结构,并引入了 before:xxx 和 after:xxx 阶段。你可以更精准地在每个阶段前后绑定插件。
默认还是 Maven 3 时代的线性行为(向后兼容)。
要真正用上树形 + 更细粒度并发,必须显式加参数 -b concurrent(或 --builder concurrent)。
2. 并发构建器 (-b concurrent)
传统的并发构建往往受限于父子依赖。Maven 4 的并发构建器只要依赖模块进入 “Ready” 状态就会立即开跑,不再傻等父模块完成所有阶段。
开发者体验优化
1. 构建恢复 (-r / --resume)
大型项目构建到 90% 挂了?在 Maven 4 里直接 -r 即可从失败处继续,自动跳过已成功的模块。这简直是多模块项目的“救命稻草”。
2. 延迟发布 (deployAtEnd 默认开启)
为了防止出现“半成品”发布(一部分模块发了,另一部分报错没发),Maven 4 默认会在所有模块全部构建成功后才进行最后的统一发布。

3. 官方迁移助手 (mvnup)
担心升级出问题?官方直接给了 mvnup 工具,自动扫描并建议如何将你的 3.x 项目迁移到 4.1.0 模型。

现在该升级吗?
- 生产环境:由于目前还在 RC 阶段,且最低要求 Java 17,建议观望,等正式 GA 之后再小范围灰度。
- 新项目/个人实验:强烈建议开启 POM 4.1.0 进行尝试。特别是 Build/Consumer POM 的分离,能让你的项目元数据管理水平提升一个档次。
- 大厂多模块项目:如果你深陷“Maven 构建慢、POM 维护难”的泥潭,Maven 4 的并发构建和自动子项目发现正是你需要的解药。
面对 Maven 二十年来最大的变动,你最期待哪个功能?或者你已经转向了 Gradle?欢迎在评论区留言,我们一起“对齐”一下!
相关地址:
- Maven 发布记录:maven.apache.org/ref/
- 迁移到 Maven4:maven.apache.org/guides/mini…
- Maven4 介绍:maven.apache.org/whatsnewinm…
来源:juejin.cn/post/7595527937832157238
从一个程序员的角度告诉你:“12306”有多牛逼?
每到节假日期间,一二线城市返乡、外出游玩的人们几乎都面临着一个问题:抢火车票!
12306 抢票,极限并发带来的思考
虽然现在大多数情况下都能订到票,但是放票瞬间即无票的场景,相信大家都深有体会。
尤其是春节期间,大家不仅使用 12306,还会考虑“智行”和其他的抢票软件,全国上下几亿人在这段时间都在抢票。
“12306 服务”承受着这个世界上任何秒杀系统都无法超越的 QPS,上百万的并发再正常不过了!
笔者专门研究了一下“12306”的服务端架构,学习到了其系统设计上很多亮点,在这里和大家分享一下并模拟一个例子:如何在 100 万人同时抢 1 万张火车票时,系统提供正常、稳定的服务。
Github代码地址:
https://github.com/GuoZhaoran/spikeSystem
大型高并发系统架构
高并发的系统架构都会采用分布式集群部署,服务上层有着层层负载均衡,并提供各种容灾手段(双火机房、节点容错、服务器灾备等)保证系统的高可用,流量也会根据不同的负载能力和配置策略均衡到不同的服务器上。
下边是一个简单的示意图:

负载均衡简介
上图中描述了用户请求到服务器经历了三层的负载均衡,下边分别简单介绍一下这三种负载均衡。
①OSPF(开放式最短链路优先)是一个内部网关协议(Interior Gateway Protocol,简称 IGP)
OSPF 通过路由器之间通告网络接口的状态来建立链路状态数据库,生成最短路径树,OSPF 会自动计算路由接口上的 Cost 值,但也可以通过手工指定该接口的 Cost 值,手工指定的优先于自动计算的值。
OSPF 计算的 Cost,同样是和接口带宽成反比,带宽越高,Cost 值越小。到达目标相同 Cost 值的路径,可以执行负载均衡,最多 6 条链路同时执行负载均衡。
②LVS (Linux Virtual Server)
它是一种集群(Cluster)技术,采用 IP 负载均衡技术和基于内容请求分发技术。
调度器具有很好的吞吐率,将请求均衡地转移到不同的服务器上执行,且调度器自动屏蔽掉服务器的故障,从而将一组服务器构成一个高性能的、高可用的虚拟服务器。
③Nginx
想必大家都很熟悉了,是一款非常高性能的 HTTP 代理/反向代理服务器,服务开发中也经常使用它来做负载均衡。
Nginx 实现负载均衡的方式主要有三种:
- 轮询
- 加权轮询
- IP Hash 轮询
下面我们就针对 Nginx 的加权轮询做专门的配置和测试。
Nginx 加权轮询的演示
Nginx 实现负载均衡通过 Upstream 模块实现,其中加权轮询的配置是可以给相关的服务加上一个权重值,配置的时候可能根据服务器的性能、负载能力设置相应的负载。
下面是一个加权轮询负载的配置,我将在本地的监听 3001-3004 端口,分别配置 1,2,3,4 的权重:
#配置负载均衡
upstream load_rule {
server 127.0.0.1:3001 weight=1;
server 127.0.0.1:3002 weight=2;
server 127.0.0.1:3003 weight=3;
server 127.0.0.1:3004 weight=4;
}
...
server {
listen 80;
server_name load_balance.com http://www.load_balance.com;
location / {
proxy_pass http://load_rule;
}
}
我在本地 /etc/hosts 目录下配置了 http://www.load_balance.com 的虚拟域名地址。
接下来使用 Go 语言开启四个 HTTP 端口监听服务,下面是监听在 3001 端口的 Go 程序,其他几个只需要修改端口即可:
package main
import (
"net/http"
"os"
"strings"
)
func main() {
http.HandleFunc("/buy/ticket", handleReq)
http.ListenAndServe(":3001", nil)
}
//处理请求函数,根据请求将响应结果信息写入日志
func handleReq(w http.ResponseWriter, r *http.Request) {
failedMsg := "handle in port:"
writeLog(failedMsg, "./stat.log")
}
//写入日志
func writeLog(msg string, logPath string) {
fd, _ := os.OpenFile(logPath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644)
defer fd.Close()
content := strings.Join([]string{msg, "\r\n"}, "3001")
buf := []byte(content)
fd.Write(buf)
}
我将请求的端口日志信息写到了 ./stat.log 文件当中,然后使用 AB 压测工具做压测:
ab -n 1000 -c 100 http://www.load_balance.com/buy/ticket
统计日志中的结果,3001-3004 端口分别得到了 100、200、300、400 的请求量。
这和我在 Nginx 中配置的权重占比很好的吻合在了一起,并且负载后的流量非常的均匀、随机。
具体的实现大家可以参考 Nginx 的 Upsteam 模块实现源码,这里推荐一篇文章《Nginx 中 Upstream 机制的负载均衡》:
https://www.kancloud.cn/digest/understandingnginx/202607
秒杀抢购系统选型
回到我们最初提到的问题中来:火车票秒杀系统如何在高并发情况下提供正常、稳定的服务呢?
从上面的介绍我们知道用户秒杀流量通过层层的负载均衡,均匀到了不同的服务器上,即使如此,集群中的单机所承受的 QPS 也是非常高的。如何将单机性能优化到极致呢?
要解决这个问题,我们就要想明白一件事: 通常订票系统要处理生成订单、减扣库存、用户支付这三个基本的阶段。
我们系统要做的事情是要保证火车票订单不超卖、不少卖,每张售卖的车票都必须支付才有效,还要保证系统承受极高的并发。
这三个阶段的先后顺序该怎么分配才更加合理呢?我们来分析一下:
下单减库存
当用户并发请求到达服务端时,首先创建订单,然后扣除库存,等待用户支付。
这种顺序是我们一般人首先会想到的解决方案,这种情况下也能保证订单不会超卖,因为创建订单之后就会减库存,这是一个原子操作。
但是这样也会产生一些问题:
- 在极限并发情况下,任何一个内存操作的细节都至关影响性能,尤其像创建订单这种逻辑,一般都需要存储到磁盘数据库的,对数据库的压力是可想而知的。
- 如果用户存在恶意下单的情况,只下单不支付这样库存就会变少,会少卖很多订单,虽然服务端可以限制 IP 和用户的购买订单数量,这也不算是一个好方法。
支付减库存

如果等待用户支付了订单在减库存,第一感觉就是不会少卖。但是这是并发架构的大忌,因为在极限并发情况下,用户可能会创建很多订单。
当库存减为零的时候很多用户发现抢到的订单支付不了了,这也就是所谓的“超卖”。也不能避免并发操作数据库磁盘 IO。
预扣库存

从上边两种方案的考虑,我们可以得出结论:只要创建订单,就要频繁操作数据库 IO。
那么有没有一种不需要直接操作数据库 IO 的方案呢,这就是预扣库存。先扣除了库存,保证不超卖,然后异步生成用户订单,这样响应给用户的速度就会快很多;那么怎么保证不少卖呢?用户拿到了订单,不支付怎么办?
我们都知道现在订单都有有效期,比如说用户五分钟内不支付,订单就失效了,订单一旦失效,就会加入新的库存,这也是现在很多网上零售企业保证商品不少卖采用的方案。
订单的生成是异步的,一般都会放到 MQ、Kafka 这样的即时消费队列中处理,订单量比较少的情况下,生成订单非常快,用户几乎不用排队。
扣库存的艺术
从上面的分析可知,显然预扣库存的方案最合理。我们进一步分析扣库存的细节,这里还有很大的优化空间,库存存在哪里?怎样保证高并发下,正确的扣库存,还能快速的响应用户请求?
在单机低并发情况下,我们实现扣库存通常是这样的:

为了保证扣库存和生成订单的原子性,需要采用事务处理,然后取库存判断、减库存,最后提交事务,整个流程有很多 IO,对数据库的操作又是阻塞的。
这种方式根本不适合高并发的秒杀系统。接下来我们对单机扣库存的方案做优化:本地扣库存。
我们把一定的库存量分配到本地机器,直接在内存中减库存,然后按照之前的逻辑异步创建订单。
改进过之后的单机系统是这样的:

这样就避免了对数据库频繁的 IO 操作,只在内存中做运算,极大的提高了单机抗并发的能力。
但是百万的用户请求量单机是无论如何也抗不住的,虽然 Nginx 处理网络请求使用 Epoll 模型,c10k 的问题在业界早已得到了解决。
但是 Linux 系统下,一切资源皆文件,网络请求也是这样,大量的文件描述符会使操作系统瞬间失去响应。
上面我们提到了 Nginx 的加权均衡策略,我们不妨假设将 100W 的用户请求量平均均衡到 100 台服务器上,这样单机所承受的并发量就小了很多。
然后我们每台机器本地库存 100 张火车票,100 台服务器上的总库存还是 1 万,这样保证了库存订单不超卖,下面是我们描述的集群架构:
问题接踵而至,在高并发情况下,现在我们还无法保证系统的高可用,假如这 100 台服务器上有两三台机器因为扛不住并发的流量或者其他的原因宕机了。那么这些服务器上的订单就卖不出去了,这就造成了订单的少卖。
要解决这个问题,我们需要对总订单量做统一的管理,这就是接下来的容错方案。服务器不仅要在本地减库存,另外要远程统一减库存。
有了远程统一减库存的操作,我们就可以根据机器负载情况,为每台机器分配一些多余的“Buffer 库存”用来防止机器中有机器宕机的情况。
我们结合下面架构图具体分析一下:

我们采用 Redis 存储统一库存,因为 Redis 的性能非常高,号称单机 QPS 能抗 10W 的并发。
在本地减库存以后,如果本地有订单,我们再去请求 Redis 远程减库存,本地减库存和远程减库存都成功了,才返回给用户抢票成功的提示,这样也能有效的保证订单不会超卖。
当机器中有机器宕机时,因为每个机器上有预留的 Buffer 余票,所以宕机机器上的余票依然能够在其他机器上得到弥补,保证了不少卖。
Buffer 余票设置多少合适呢,理论上 Buffer 设置的越多,系统容忍宕机的机器数量就越多,但是 Buffer 设置的太大也会对 Redis 造成一定的影响。
虽然 Redis 内存数据库抗并发能力非常高,请求依然会走一次网络 IO,其实抢票过程中对 Redis 的请求次数是本地库存和 Buffer 库存的总量。
因为当本地库存不足时,系统直接返回用户“已售罄”的信息提示,就不会再走统一扣库存的逻辑。
这在一定程度上也避免了巨大的网络请求量把 Redis 压跨,所以 Buffer 值设置多少,需要架构师对系统的负载能力做认真的考量。
代码演示
Go 语言原生为并发设计,我采用 Go 语言给大家演示一下单机抢票的具体流程。
初始化工作
Go 包中的 Init 函数先于 Main 函数执行,在这个阶段主要做一些准备性工作。
我们系统需要做的准备工作有:初始化本地库存、初始化远程 Redis 存储统一库存的 Hash 键值、初始化 Redis 连接池。
另外还需要初始化一个大小为 1 的 Int 类型 Chan,目的是实现分布式锁的功能。
也可以直接使用读写锁或者使用 Redis 等其他的方式避免资源竞争,但使用 Channel 更加高效,这就是 Go 语言的哲学:不要通过共享内存来通信,而要通过通信来共享内存。
Redis 库使用的是 Redigo,下面是代码实现:
...
//localSpike包结构体定义
package localSpike
type LocalSpike struct {
LocalInStock int64
LocalSalesVolume int64
}
...
//remoteSpike对hash结构的定义和redis连接池
package remoteSpike
//远程订单存储健值
type RemoteSpikeKeys struct {
SpikeOrderHashKey string //redis中秒杀订单hash结构key
TotalInventoryKey string //hash结构中总订单库存key
QuantityOfOrderKey string //hash结构中已有订单数量key
}
//初始化redis连接池
func NewPool() *redis.Pool {
return &redis.Pool{
MaxIdle: 10000,
MaxActive: 12000, // max number of connections
Dial: func() (redis.Conn, error) {
c, err := redis.Dial("tcp", ":6379")
if err != nil {
panic(err.Error())
}
return c, err
},
}
}
...
func init() {
localSpike = localSpike2.LocalSpike{
LocalInStock: 150,
LocalSalesVolume: 0,
}
remoteSpike = remoteSpike2.RemoteSpikeKeys{
SpikeOrderHashKey: "ticket_hash_key",
TotalInventoryKey: "ticket_total_nums",
QuantityOfOrderKey: "ticket_sold_nums",
}
redisPool = remoteSpike2.NewPool()
done = make(chanint, 1)
done <- 1
}
本地扣库存和统一扣库存
本地扣库存逻辑非常简单,用户请求过来,添加销量,然后对比销量是否大于本地库存,返回 Bool 值:
package localSpike
//本地扣库存,返回bool值
func (spike *LocalSpike) LocalDeductionStock() bool{
spike.LocalSalesVolume = spike.LocalSalesVolume + 1
return spike.LocalSalesVolume < spike.LocalInStock
}
注意这里对共享数据 LocalSalesVolume 的操作是要使用锁来实现的,但是因为本地扣库存和统一扣库存是一个原子性操作,所以在最上层使用 Channel 来实现,这块后边会讲。
统一扣库存操作 Redis,因为 Redis 是单线程的,而我们要实现从中取数据,写数据并计算一些列步骤,我们要配合 Lua 脚本打包命令,保证操作的原子性:
package remoteSpike
......
const LuaScript = `
local ticket_key = KEYS[1]
local ticket_total_key = ARGV[1]
local ticket_sold_key = ARGV[2]
local ticket_total_nums = tonumber(redis.call('HGET', ticket_key, ticket_total_key))
local ticket_sold_nums = tonumber(redis.call('HGET', ticket_key, ticket_sold_key))
-- 查看是否还有余票,增加订单数量,返回结果值
if(ticket_total_nums >= ticket_sold_nums) then
return redis.call('HINCRBY', ticket_key, ticket_sold_key, 1)
end
return0
`
//远端统一扣库存
func (RemoteSpikeKeys *RemoteSpikeKeys) RemoteDeductionStock(conn redis.Conn) bool {
lua := redis.NewScript(1, LuaScript)
result, err := redis.Int(lua.Do(conn, RemoteSpikeKeys.SpikeOrderHashKey, RemoteSpikeKeys.TotalInventoryKey, RemoteSpikeKeys.QuantityOfOrderKey))
if err != nil {
returnfalse
}
return result != 0
}
我们使用 Hash 结构存储总库存和总销量的信息,用户请求过来时,判断总销量是否大于库存,然后返回相关的 Bool 值。
在启动服务之前,我们需要初始化 Redis 的初始库存信息:
hmset ticket_hash_key "ticket_total_nums" 10000 "ticket_sold_nums" 0
响应用户信息
我们开启一个 HTTP 服务,监听在一个端口上:
package main
...
func main() {
http.HandleFunc("/buy/ticket", handleReq)
http.ListenAndServe(":3005", nil)
}
上面我们做完了所有的初始化工作,接下来 handleReq 的逻辑非常清晰,判断是否抢票成功,返回给用户信息就可以了。
package main
//处理请求函数,根据请求将响应结果信息写入日志
func handleReq(w http.ResponseWriter, r *http.Request) {
redisConn := redisPool.Get()
LogMsg := ""
<-done
//全局读写锁
if localSpike.LocalDeductionStock() && remoteSpike.RemoteDeductionStock(redisConn) {
util.RespJson(w, 1, "抢票成功", nil)
LogMsg = LogMsg + "result:1,localSales:" + strconv.FormatInt(localSpike.LocalSalesVolume, 10)
} else {
util.RespJson(w, -1, "已售罄", nil)
LogMsg = LogMsg + "result:0,localSales:" + strconv.FormatInt(localSpike.LocalSalesVolume, 10)
}
done <- 1
//将抢票状态写入到log中
writeLog(LogMsg, "./stat.log")
}
func writeLog(msg string, logPath string) {
fd, _ := os.OpenFile(logPath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644)
defer fd.Close()
content := strings.Join([]string{msg, "\r\n"}, "")
buf := []byte(content)
fd.Write(buf)
}
前边提到我们扣库存时要考虑竞态条件,我们这里是使用 Channel 避免并发的读写,保证了请求的高效顺序执行。我们将接口的返回信息写入到了 ./stat.log 文件方便做压测统计。
单机服务压测
开启服务,我们使用 AB 压测工具进行测试:
ab -n 10000 -c 100 http://127.0.0.1:3005/buy/ticket
下面是我本地低配 Mac 的压测信息:
This is ApacheBench, Version 2.3 <$revision: 1826891="">
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/
Benchmarking 127.0.0.1 (be patient)
Completed 1000 requests
Completed 2000 requests
Completed 3000 requests
Completed 4000 requests
Completed 5000 requests
Completed 6000 requests
Completed 7000 requests
Completed 8000 requests
Completed 9000 requests
Completed 10000 requests
Finished 10000 requests
Server Software:
Server Hostname: 127.0.0.1
Server Port: 3005
Document Path: /buy/ticket
Document Length: 29 bytes
Concurrency Level: 100
Time taken for tests: 2.339 seconds
Complete requests: 10000
Failed requests: 0
Total transferred: 1370000 bytes
HTML transferred: 290000 bytes
Requests per second: 4275.96 [#/sec] (mean)
Time per request: 23.387 [ms] (mean)
Time per request: 0.234 [ms] (mean, across all concurrent requests)
Transfer rate: 572.08 [Kbytes/sec] received
Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 8 14.7 6 223
Processing: 2 15 17.6 11 232
Waiting: 1 11 13.5 8 225
Total: 7 23 22.8 18 239
Percentage of the requests served within a certain time (ms)
50% 18
66% 24
75% 26
80% 28
90% 33
95% 39
98% 45
99% 54
100% 239 (longest request)
根据指标显示,我单机每秒就能处理 4000+ 的请求,正常服务器都是多核配置,处理 1W+ 的请求根本没有问题。
而且查看日志发现整个服务过程中,请求都很正常,流量均匀,Redis 也很正常:
//stat.log
...
result:1,localSales:145
result:1,localSales:146
result:1,localSales:147
result:1,localSales:148
result:1,localSales:149
result:1,localSales:150
result:0,localSales:151
result:0,localSales:152
result:0,localSales:153
result:0,localSales:154
result:0,localSales:156
...
总结回顾
总体来说,秒杀系统是非常复杂的。我们这里只是简单介绍模拟了一下单机如何优化到高性能,集群如何避免单点故障,保证订单不超卖、不少卖的一些策略
完整的订单系统还有订单进度的查看,每台服务器上都有一个任务,定时的从总库存同步余票和库存信息展示给用户,还有用户在订单有效期内不支付,释放订单,补充到库存等等。
我们实现了高并发抢票的核心逻辑,可以说系统设计的非常的巧妙,巧妙的避开了对 DB 数据库 IO 的操作。
对 Redis 网络 IO 的高并发请求,几乎所有的计算都是在内存中完成的,而且有效的保证了不超卖、不少卖,还能够容忍部分机器的宕机。
我觉得其中有两点特别值得学习总结:
①负载均衡,分而治之
通过负载均衡,将不同的流量划分到不同的机器上,每台机器处理好自己的请求,将自己的性能发挥到极致。
这样系统的整体也就能承受极高的并发了,就像工作的一个团队,每个人都将自己的价值发挥到了极致,团队成长自然是很大的。
②合理的使用并发和异步
自 Epoll 网络架构模型解决了 c10k 问题以来,异步越来越被服务端开发人员所接受,能够用异步来做的工作,就用异步来做,在功能拆解上能达到意想不到的效果。
这点在 Nginx、Node.JS、Redis 上都能体现,他们处理网络请求使用的 Epoll 模型,用实践告诉了我们单线程依然可以发挥强大的威力。
服务器已经进入了多核时代,Go 语言这种天生为并发而生的语言,完美的发挥了服务器多核优势,很多可以并发处理的任务都可以使用并发来解决,比如 Go 处理 HTTP 请求时每个请求都会在一个 Goroutine 中执行。
总之,怎样合理的压榨 CPU,让其发挥出应有的价值,是我们一直需要探索学习的方向。
来源:juejin.cn/post/7541770924800163875
推荐8个牛逼的SpringBoot项目
前言
最近两年左右的时间,我一口气肝了8个实现项目。
包含了各种业界常见的技术,比如:SpringBoot、SpringCloud、SpringCloud Alibaba、Mybatis、JPA、Redis、MongoDB、ElasticSearch、MySQL、PostgreSQL、Minio、Caffine、RocketMQ、Prometheus、Grafana、ELK、skywalking、Sentinel、Nacos、Redisson、shardingsphere、HikariCP、guava、WebFlux、nacos、Sentinel、WebSocket、Gateway、Nginx、Docker、Spring AI、Spring AI Alibaba等等,非常值得一看。
今天给大家介绍一下这些项目,感兴趣的小伙伴,可以一起交流学习一下,干货满满。
1 100万QPS短链系统
使用技术:JDK21、SpringBoot3.5.3、JPA、Redis、布隆过滤器、Sentinel、Nacos、Redisson、shardingsphere、HikariCP、guava、Prometheus等。
目前设计了32个数据库,每个数据库包含了256张表。
每天可支持2.6亿以上的数据写入。
100万QPS短链系统的系统架构图如下:

技术亮点:

该项目的亮点是:
- 使用了最新的JDK21和SpringBoot3.5.3
- 100万QPS的超高并发请求
- 数据库分库分表设计
- 多级布隆过滤器设计
- 限流和熔断的使用
- Redis分片集群
- 改进后的雪花算法
- Redis分布式锁的使用
- Redis Stream的使用
- 多级缓存设计
- 多线程的处理
- 完整的单元测试覆盖
- 使用Prometheus对项目实时监控
- 使用Grafana创建监控仪表盘
- 使用AlertManager实现自动报警功能
- 接入钉钉报警
- 基于时间片的布隆过滤器
- 系统平滑扩容
- 基于Docker容器化部署
- 支持多种短链生成算法
- 接口幂等性设计
基于时间片的布隆过滤器流程图如下:

短链系统平滑扩容方案如下:

通过这个项目,可以学到很多高并发、流量评估、分库分表、多级缓存、多级布隆过滤器、限流、熔断、多线程、监控、报警、数据扩容、集群、广播消息、单元测试编写等多方面的知识。
目前这个项目包含两端代码:
- 后端服务
- 前端服务
想进大厂的小伙伴们,一定不要错过这个项目,里面有很多加分项。
点击这里获取项目源代码和教程:www.susan.net.cn/project
2 SaaS点餐系统
使用技术:JDK21、SpringBoot3.4.3、SpringCloud、SpringCloud Alibaba、Gateway、Mybatis、PostgesSQL、Redis、RocketMQ、ElasticSearch、Knife4j、Prometheus、Grafana、Minio、数据隔离等。
SaaS点餐系统是一套:DDD开发模式+多租户+PostgesSQL 的复杂微服务系统。
包含了9个微服务。
系统整体架构如下:

数据隔离方案如下:

DDD开发模式的代码示例:

通过这个项目可以掌握DDD开发模型、多租户数据隔离的方案实现、PostgresSQL数据库的使用,还有微服务之间的数据交换,网关服务的统一处理,以及复杂系统的职责领域的划分。
运行效果:

3 商城微服务系统
susan_mall_cloud是微服务项目。
使用了目前业界比较新的技术:JDK17、Spring6、SpringBoot3.3.5、SpringCloud2024、SpringCloud Alibaba2023.0.1.0。
微服务后端包含了:
- susan-mall-common (公共文件)
- susan-mall-gateway (网关服务)
- susan-mall-basic (基础服务)
- susan-mall-auth (权限服务,包含用户和权限相关的)
- susan-mall-product (商品服务)
- susan-mall-order (订单服务)
- susan-mall-pay (支付服务)
- susan-mall-member (会员服务)
- susan-mall-marketing (营销服务)
- susan-mall-admin(后台管理系统API)
- susan-mall-mobile(移动端API)
这个版本在商城已有技术基础之上,又增加了:SpringCloud Gateway、WebFlux、Seata、Skywaking、OpenFeign、Loadbalancer、Sentinel、Nacos、Canal、xxl-job、Prometheus、K8S等。

项目架构图:

目前包含了多端代码:
- 服务端的网关服务和6个微服务。
- 后台管理系统。
- uniapp小程序。
下面是商城小程序真实的截图:



看起来是不是非常专业?
商城微服务项目很复杂,包含了目前业界微服务分布式系统中使用最主流的技术,强烈推荐一下。
无论在工作中,还是面试中,都可以作为加分项。
特别是SpringCloud Gateway中WebFlux的使用,微服务之间的异常处理,以及微服务之间的通信,都很值得一看。
4 商城系统
商城系统目前包含了:SpringBoot后端 + Vue管理后台 + uniapp小程序 ,三个端的完整代码。
商城项目中包含了:基于Docker部署教程、域名解析教程、按环境隔离、网络爬虫、推荐算法、支付宝支付、分库分表、分片算法优化、手写动态定时任务、手写通用分页组件、JWT登录验证、数据脱敏、动态workId、hanlp敏感词校验,手写分布式ID生成器、分布式限流、手写Mybatis插件、两级缓存提升性能、MQ消息通信、ES商品搜索、OSS服务对接、失败自动重试机制、接口幂等性处理、百万数据excel导出、WebSocket消息推送、用户异地登录检测、freemarker模版邮件发送、代码生成工具、重复请求自动拦截、自定义金额校验注解等等一系列功能。
使用的技术:

商城系统的系统架构图如下:

包含了:
- 应用层:小程序、移动端H5、管理后台
- 网关层:Nginx反向代理和负载均衡
- 服务层:API服务、Job服务 & mq消费者服务
- 数据存储层:susan_mall库MySQL主从、susan_mall_order库MySQL分库分表、MongoDB保存商品详情、Minio存储文件
- 中间件层:Redis集群、RocketMQ、ElasticSearch、Nacos(注册中间 & 配置中心)
商城系统的技术架构图如下:

使用的都是目前业界非常主流和常用的技术,这些技术大部分公司目前都在使用。
商城系统可以帮你真正增加很多企业级项目经验。
功能亮点:

商城项目无论是毕业设计,还是面试,还是实际工作中,都非常值得一看。
商城项目使用了目前非常主流的技术,手写了很多底层的代码,设计模式、自定义了很多拦截器、过滤器、转换器、监听器等,很多代码可以搬到实际的工作中。
目前星球中包含了商城项目从0~1的完整开发教程,小白也可以直接上手。
星球中有些小伙伴,通过这个项目拿到了非常不错的offer。
点击这里获取项目源代码和教程:www.susan.net.cn/project
5. 秒杀系统
苏三的秒杀系统是专门为高并发而生的。
目前使用的技术有:SpringBoot、Redis、Redission、lua、RocketMQ、ElasticSearch、JWT、freemarker、themelaf、html、vue、element-ui等。
功能包括:商品预热、商品秒杀、分布式锁、MQ异步下单、限流、失败重试、预扣库存、数据一致性处理等。


涉及到了高并发的多种技术,特别是对页面静态化,倒计时、秒杀按钮控制、分布式锁、预扣库存、MQ处理、数据一致性等,会有比较大的收获。
秒杀系统的系统架构图:

可以帮你增加高并发的工作经验,也可以写到你的简历中。
秒杀系统在面试或者工作中,会经常遇到,非常有参考价值。
6 刷题吧小程序
IT刷题吧是我用AI花了几天时间,设计和开发了一款小程序。
效果图如下:



为了帮助大家能够快速的掌握使用AI开发项目的技巧,提升开发效率,能够先人一步,变成全栈开发工程师。
无论是自己接私活,还是开发公司的项目,都能够用更少的时间,写出更多,更有价值的代码。
苏三在知识星球中给小伙伴们,通过IT刷题吧项目,专门开设了一个AI开发课程。
你看完之后,会发现打开了一扇通向新世界的大门。(有很多惊喜)
这个课程会包含如下内容:
- 如何用AI设计产品原型的?
- 如何用AI生成小程序端和后端的代码结构的?
- 如何用AI生成后端的表结构?
- 如何用AI生成小程序和后端代码?
- 如何生成一套完整的可运行的代码?
- 如何基于图片生成想要的代码?
- 如何搞定小程序页面中的图片问题?
- 如何让小程序端和后端代码调通?
- 生成的代码不理想怎么办?
- 如果在开发过程中遇到了一些问题,用AI如何解决问题?
- 如何生成测试数据?
- 如何制定代码开发规范?
- AI开发工具的使用方法
- AI开发工具卡顿怎么办?
- 如何运行项目?
- 如何上线部署项目?
等等。。。
星球中会交付如下内容:
- IT刷题吧小程序
- SpringBoot后端代码
- 用AI开发项目的完整流程
目前已经全部开发完。
使用AI开发这个项目,从0~1的开发和部署教程。
问题答疑。
通过这个项目,你可以学到使用AI开发项目的具体方法。
如果你掌握了这些方法,开发其他的小程序绰绰有余。
这个项目有极大的价值。
授人予鱼,不如授人以渔。
光是学会这个项目,就有极大的价值。
7. 苏三的demo项目
这个项目包含了一些工作中常用的技术点,有很多非常有参考价值的示例。
涵盖:Spring、Mybatis、多线程、事务、常用工具、设计模式、http请求、lamda、io、excel、泛型、注解等多个方面。

本项目的宗旨是分享实际工作中,非常实用的代码技巧,能够让你写出更优雅高效的代码。
此外,后面会收录一下面试中,尤其是笔试中经常会被问题到的代码片段和算法。
8. 代码生成器项目
这是一个基于Spring Boot的智能代码生成器,能够根据数据库表结构自动生成完整的Java Web项目代码,极大提升开发效率,让开发者专注于业务逻辑而非重复的CRUD代码编写。
我们用这个代码生成器,可以通过数据库表,一键直接生成controller、service、mapper、entity、菜单sql、vue页面等。
使用的技术:SpringBoot、MyBatis、Apache Velocity、Swagger2、Lombok、Druid、Maven等。
我们在日常开发中,把数据库表设计好了之后,然后通过该工具,能够快速生成一个可以直接运行的CRUD代码。

毫不夸张的说,如果在项目中使用它,可以让你的开发效率快速提升,我们真的可以少写30%的代码。


在实际工作中,非常有价值。
来源:juejin.cn/post/7588022226739724338
瞧瞧别人家的日志打印,那叫一个优雅!
前言
这篇文章跟大家一起聊聊打印优质日志的10条军规,希望对你会有所帮助。

第1条:格式统一
反例(管理看到会扣钱):
log.info("start process");
log.error("error happen");
无时间戳,无上下文。
正解代码:
<!-- logback.xml核心配置 -->
<pattern>
%d{yy-MM-dd HH:mm:ss.SSS}
|%X{traceId:-NO_ID}
|%thread
|%-5level
|%logger{36}
|%msg%n
</pattern>
在logback.xml中统一配置了日志的时间格式、tradeId,线程、等级、日志详情都信息。
日志的格式统一了,更方便点位问题。

第2条:异常必带堆栈
反例(同事看了想打人):
try {
processOrder();
} catch (Exception e) {
log.error("处理失败");
}
出现异常了,日志中没打印任何的异常堆栈信息。
相当于自己把异常吃掉了。
非常不好排查问题。
正确姿势:
log.error("订单处理异常 orderId={}", orderId, e); // e必须存在!
日志中记录了出现异常的订单号orderId和异常的堆栈信息e。
第3条:级别合理
反面教材:
log.debug("用户余额不足 userId={}", userId); // 业务异常应属WARN
log.error("接口响应稍慢"); // 普通超时属INFO
接口响应稍慢,打印了error级别的日志,显然不太合理。
正常情况下,普通超时属INFO级别。
级别定义表:
| 级别 | 正确使用场景 |
|---|---|
| FATAL | 系统即将崩溃(OOM、磁盘爆满) |
| ERROR | 核心业务失败(支付失败、订单创建异常) |
| WARN | 可恢复异常(重试成功、降级触发) |
| INFO | 关键流程节点(订单状态变更) |
| DEBUG | 调试信息(参数流水、中间结果) |
第4条:参数完整
反例(让运维骂娘):
log.info("用户登录失败");
上面这个日志只打印了“用户登录失败”这个文案。
谁在哪登录失败?
侦探式日志:
log.warn("用户登录失败 username={}, clientIP={}, failReason={}",
username, clientIP, "密码错误次数超限");
登录失败的业务场景,需要记录哪个用户,ip是多少,在什么时间,登录失败了,失败的原因是什么。
时间在logback.xml中统一配置了格式。
这样才方便快速定位问题:

第5条:数据脱敏
血泪案例:
某同事打印日志泄露用户手机号被投诉。
我在记录的日志中,需要对一下用户的个人敏感数据做脱敏处理。
例如下面这样:
// 脱敏工具类
public class LogMasker {
public static String maskMobile(String mobile) {
return mobile.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2");
}
}
// 使用示例
log.info("用户注册 mobile={}", LogMasker.maskMobile("13812345678"));
第6条:异步保性能
问题复现
某次秒杀活动中直接同步写日志,导致大量线程阻塞:
log.info("秒杀请求 userId={}, itemId={}", userId, itemId);
高并发下IO阻塞。
致命伤害分析:
- 同步写日志导致线程上下文切换频繁
- 磁盘IO成为系统瓶颈
- 高峰期日志打印耗时占总RT的25%
正确示范(三步配置法)
步骤1:logback.xml配置异步通道
<!-- 异步Appender核心配置 -->
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
<!-- 不丢失日志的阈值:当队列剩余容量<此值时,TRACE/DEBUG级别日志将被丢弃 -->
<discardingThreshold>0</discardingThreshold>
<!-- 队列深度:建议设为 (最大并发线程数 × 2) -->
<queueSize>4096</queueSize>
<!-- 关联真实Appender -->
<appender-ref ref="FILE"/>
</appender>
步骤2:日志输出优化代码
// 无需前置判断,框架自动处理
log.debug("接收到MQ消息:{}", msg.toSimpleString()); // 自动异步写入队列
// 不应做复杂计算后再打印(异步前仍在业务线程执行)
// 错误做法:
log.debug("详细内容:{}", computeExpensiveLog());
流程图如下:

步骤3:性能关键参数公式
最大内存占用 ≈ 队列长度 × 平均单条日志大小
推荐队列深度 = 峰值TPS × 容忍最大延迟(秒)
例如:10000 TPS × 0.5s容忍 ⇒ 5000队列大小
风险规避策略
- 防队列堆积:监控队列使用率,达80%触发告警
- 防OOM:严格约束大对象toString()的调用
- 紧急逃生:预设JMX接口用于快速切换同步模式
第7条:链路追踪
混沌场景:
跨服务调用无法关联日志。
我们需要有链路追踪方案。
全链路方案:
// 拦截器注入traceId
MDC.put("traceId", UUID.randomUUID().toString().substring(0,8));
// 日志格式包含traceId
<pattern>%d{HH:mm:ss} |%X{traceId}| %msg%n</pattern>
可以在MDC中设置traceId。
后面可以通过traceId全链路追踪日志。
流程图如下:

第8条:动态调参
半夜重启的痛:
线上问题需要临时开DEBUG日志,比如:查询用户的某次异常操作的日志。
热更新方案:
@GetMapping("/logLevel")
public String changeLogLevel(
@RequestParam String loggerName,
@RequestParam String level) {
Logger logger = (Logger) LoggerFactory.getLogger(loggerName);
logger.setLevel(Level.valueOf(level)); // 立即生效
return "OK";
}
有时候我们需要临时打印DEBUG日志,这就需要有个动态参数控制了。
否则每次调整打印日志级别都需要重启服务,可能会影响用户的正常使用。
journey
title 日志级别动态调整
section 旧模式
发现问题 --> 修改配置 --> 重启应用 --> 丢失现场
section 新模式
发现问题 --> 动态调整 --> 立即生效 --> 保持现场
第9条:结构化存储
混沌日志:
用户购买了苹果手机 订单号1001 金额8999
上面的日志拼接成了一个字符串,虽说中间有空格分隔了,但哪些字段对应了哪些值,看起来不是很清楚。
我们在存储日志的时候,需要做结构化存储,方便快速的查询和搜索。
机器友好式日志:
{
"event": "ORDER_CREATE",
"orderId": 1001,
"amount": 8999,
"products": [{"name":"iPhone", "sku": "A123"}]
}
这里使用了json格式存储日志。
日志中的数据一目了然。
第10条:智能监控
最失败案例:
某次用户开通会员操作,错误日志堆积3天才被发现,黄花菜都凉了。
我们需要在项目中引入智能监控。
ELK监控方案:

报警规则示例:
ERROR日志连续5分钟 > 100条 → 电话告警
WARN日志持续1小时 → 邮件通知
总结
研发人员的三大境界:
- 青铜:
System.out.println("error!") - 钻石:标准化日志 + ELK监控
- 王者:
- 日志驱动代码优化
- 异常预测系统
- 根因分析AI模型
最后的灵魂拷问:
下次线上故障时,你的日志能让新人5分钟定位问题吗?
最后说一句(求关注,别白嫖我)
如果这篇文章对您有所帮助,或者有所启发的话,帮忙关注一下我的同名公众号:苏三说技术,您的支持是我坚持写作最大的动力。
求一键三连:点赞、转发、在看。
关注公众号:【苏三说技术】,在公众号中回复:进大厂,可以免费获取我最近整理的10万字的面试宝典,好多小伙伴靠这个宝典拿到了多家大厂的offer。
来源:juejin.cn/post/7593232758128246790
别搞混了!MCP 和 Agent Skill 到底有什么区别?
MCP 与 Skill 深度对比:AI Agent 的两种扩展哲学
用 AI Agent 工具(Claude Code、Cursor、Windsurf 等)的时候,经常会遇到两个概念:
- MCP(Model Context Protocol)
- Skill(Agent Skill)
它们看起来都是"扩展 AI 能力"的方式,但具体有什么区别?为什么需要两套机制?什么时候该用哪个?
这篇文章会从设计哲学、技术架构、使用场景三个维度,把这两个概念彻底讲清楚。
一句话区分
先给个简单的定位:
MCP 解决"连接"问题:让 AI 能访问外部世界
Skill 解决"方法论"问题:教 AI 怎么做某类任务
用 Anthropic 官方的说法:
"MCP connects Claude to external services and data sources. Skills provide procedural knowledge—instructions for how to complete specific tasks or workflows."
打个比方:MCP 是 AI 的"手"(能触碰外部世界),Skill 是 AI 的"技能书"(知道怎么做某件事)。
你需要两者配合:MCP 让 AI 能连接数据库,Skill 教 AI 怎么分析查询结果。
MCP:AI 应用的 USB-C 接口
MCP 是什么
MCP(Model Context Protocol)是 Anthropic 在 2024 年 11 月发布的开源协议,用于标准化 AI 应用与外部系统的交互方式。
官方的比喻是"AI 应用的 USB-C 接口"——就像 USB-C 提供了一种通用的方式连接各种设备,MCP 提供了一种通用的方式连接各种工具和数据源。
关键点:MCP 不是 Claude 专属的。
它是一个开放协议,理论上任何 AI 应用都可以实现。截至 2025 年初,已经被多个平台采用:
- Anthropic: Claude Desktop、Claude Code
- OpenAI: ChatGPT、Agents SDK、Responses API
- Google: Gemini SDK
- Microsoft: Azure AI Services
- 开发工具: Zed、Replit、Codeium、Sourcegraph
到 2025 年 2 月,已经有超过 1000 个开源 MCP 连接器。
MCP 的架构
MCP 基于 JSON-RPC 2.0 协议,采用客户端-主机-服务器(Client-Host-Server)架构:
┌─────────────────────────────────────────────────────────┐
│ Host │
│ (Claude Desktop / Cursor) │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Client │ │ Client │ │ Client │ │
│ │ (GitHub) │ │ (Postgres) │ │ (Sentry) │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
└─────────┼────────────────┼────────────────┼─────────────┘
│ │ │
▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐
│MCP Server │ │MCP Server │ │MCP Server │
│ (GitHub) │ │(Postgres) │ │ (Sentry) │
└───────────┘ └───────────┘ └───────────┘
- Host:用户直接交互的应用(Claude Desktop、Cursor、Windsurf)
- Client:Host 应用中管理与特定 Server 通信的组件
- Server:连接外部系统的桥梁(数据库、API、本地文件等)
MCP 的三个核心原语
MCP 定义了三种 Server 可以暴露的原语:
1. Tools(工具)—— 模型控制
可执行的函数,AI 可以调用来执行操作。
{
"name": "query_database",
"description": "Execute SQL query on the database",
"parameters": {
"type": "object",
"properties": {
"sql": { "type": "string" }
}
}
}
AI 决定什么时候调用这些工具。比如用户问"这个月的收入是多少",AI 判断需要查数据库,就会调用 query_database 工具。
2. Resources(资源)—— 应用控制
数据源,为 AI 提供上下文信息。
{
"uri": "file:///Users/project/README.md",
"name": "Project README",
"mimeType": "text/markdown"
}
资源由应用控制何时加载。用户可以通过 @ 引用资源,类似于引用文件。
3. Prompts(提示)—— 用户控制
预定义的提示模板,帮助结构化与 AI 的交互。
{
"name": "code_review",
"description": "Review code for bugs and security issues",
"arguments": [
{ "name": "code", "required": true }
]
}
用户显式触发这些提示,类似于 Slash Command。
MCP 与 Function Calling 的关系
很多人会问:MCP 和 OpenAI 的 Function Calling、Anthropic 的 Tool Use 有什么区别?
Function Calling 是 LLM 的能力——把自然语言转换成结构化的函数调用请求。LLM 本身不执行函数,只是告诉你"应该调用什么函数,参数是什么"。
MCP 是在 Function Calling 之上的协议层——它标准化了"函数在哪里、怎么调用、怎么发现"。
两者的关系:
用户输入 → LLM (Function Calling) → "需要调用 query_database"
↓
MCP Protocol
↓
MCP Server 执行
↓
返回结果给 LLM
Function Calling 解决"决定做什么",MCP 解决"怎么做到"。
MCP 的传输方式
MCP 支持两种主要的传输方式:
| 传输方式 | 适用场景 | 说明 |
|---|---|---|
| Stdio | 本地进程 | Server 在本地机器运行,适合需要系统级访问的工具 |
| HTTP/SSE | 远程服务 | Server 在远程运行,适合云服务(GitHub、Sentry、Notion) |
大部分云服务用 HTTP,本地脚本和自定义工具用 Stdio。
MCP 的代价
MCP 不是免费的午餐,它有明显的成本:
1. Token 消耗大
每个 MCP Server 都会占用上下文空间。每次对话开始,MCP Client 需要告诉 LLM "你有这些工具可用",这些工具定义会消耗大量 Token。
连接多个 MCP Server 后,光是工具定义可能就占用了上下文窗口的很大一部分。社区观察到:
"We're seeing a lot of MCP developers even at enterprise build MCP servers that expose way too much, consuming the entire context window and leading to hallucination."
2. 需要维护连接
MCP Server 是持久连接的外部进程。Server 挂了、网络断了、认证过期了,都会影响 AI 的能力。
3. 安全风险
Anthropic 官方警告:
"Use third party MCP servers at your own risk - Anthropic has not verified the correctness or security of all these servers."
特别是能获取外部内容的 MCP Server(比如网页抓取),可能带来 prompt injection 风险。
MCP 的价值
尽管有这些代价,MCP 的价值在于标准化和可复用性:
- 一次实现,到处使用:同一个 GitHub MCP Server 可以在 Claude Desktop、Cursor、Windsurf 中使用
- 动态发现:AI 可以在运行时发现有哪些工具可用,而不是写死在代码里
- 供应商无关:不依赖特定的 LLM 提供商
Skill:上下文工程的渐进式公开
Skill 是什么
Skill(全称 Agent Skill)是 Anthropic 在 2025 年 10 月发布的特性。官方定义:
"Skills are organized folders of instructions, scripts, and resources that agents can discover and load dynamically to perform better at specific tasks."
翻译一下:Skill 是一个文件夹,里面放着指令、脚本和资源,AI 会根据需要自动发现和加载。
Skill 在架构层级上和 MCP 不同。
用 Anthropic 的话说:
"Skills are at the prompt/knowledge layer, whereas MCP is at the integration layer."
Skill 是"提示/知识层",MCP 是"集成层"。两者解决不同层面的问题。
Skill 的核心设计:渐进式信息公开
Skill 最精妙的设计是渐进式信息公开(Progressive Disclosure)。这是 Anthropic 在上下文工程(Context Engineering)领域的重要实践。
官方的比喻:
"Like a well-organized manual that starts with a table of contents, then specific chapters, and finally a detailed appendix."
就像一本组织良好的手册:先看目录,再翻到相关章节,最后查阅附录。
Skill 分三层加载:
flowchart TD
subgraph L1["第 1 层:元数据(始终加载)"]
A[Skill 名称 + 描述]
B["约 100 tokens"]
end
subgraph L2["第 2 层:核心指令(按需加载)"]
C[SKILL.md 完整内容]
D["通常 < 5k tokens"]
end
subgraph L3["第 3+ 层:支持文件(深度按需)"]
E[reference.md]
F[scripts/helper.py]
G[templates/...]
end
L1 --> |"Claude 判断相关"| L2
L2 --> |"需要更多信息"| L3
style L1 fill:#d4edda,stroke:#28a745
style L2 fill:#fff3cd,stroke:#ffc107
style L3 fill:#cce5ff,stroke:#0d6efd
这个设计的好处是什么?
传统方式(比如 MCP)在会话开始时就把所有信息加载到上下文。如果你有 10 个 MCP Server,每个暴露 5 个工具,那就是 50 个工具定义——可能消耗数千甚至上万 Token。
Skill 的渐进式加载让你可以有几十个 Skill,但同时只加载一两个。上下文效率大幅提升。
用官方的话说:
"This means that the amount of context that can be bundled int0 a skill is effectively unbounded."
理论上,单个 Skill 可以包含无限量的知识——因为只有需要的部分才会被加载。
上下文工程:Skill 背后的思想
Skill 是 Anthropic "上下文工程"(Context Engineering)理念的产物。官方对此有专门的阐述:
"At Anthropic, we view context engineering as the natural progression of prompt engineering. Prompt engineering refers to methods for writing and organizing LLM instructions for optimal outcomes. Context engineering refers to the set of strategies for curating and maintaining the optimal set of tokens (information) during LLM inference."
简单说:
- Prompt Engineering:怎么写好提示词
- Context Engineering:怎么管理上下文窗口里的信息
LLM 的上下文窗口是有限的(即使是 200k 窗口,也会被大量信息撑爆)。Context Engineering 的核心问题是:在有限的窗口里,放什么信息能让 AI 表现最好?
Skill 的渐进式加载就是 Context Engineering 的具体实践——只加载当前任务需要的信息,让每一个 Token 都发挥最大价值。
Skill 的触发机制
Skill 是自动触发的,这是它和 Slash Command 的关键区别。
工作流程:
- 扫描阶段:Claude 读取所有 Skill 的元数据(名称 + 描述)
- 匹配阶段:将用户请求与 Skill 描述进行语义匹配
- 加载阶段:如果匹配成功,加载完整的 SKILL.md
- 执行阶段:按照 Skill 里的指令执行任务,按需加载支持文件
用户不需要显式调用。比如你有一个 code-review Skill,用户说"帮我 review 这段代码",Claude 会自动匹配并加载。
Skill 的本质是什么?
技术上,Skill 是一个元工具(Meta-tool):
"The Skill tool is a meta-tool that manages all skills. Traditional tools like Read, Bash, or Write execute discrete actions and return immediate results. Skills operate differently—rather than performing actions directly, they inject specialized instructions int0 the conversation history and dynamically modify Claude's execution environment."
Skill 不是执行具体动作,而是注入指令到对话历史中,动态修改 Claude 的执行环境。
Skill 的文件结构
一个标准的 Skill 长这样:
my-skill/
├── SKILL.md # 必需:元数据 + 主要指令
├── reference.md # 可选:详细参考文档
├── examples.md # 可选:使用示例
├── scripts/
│ └── helper.py # 可选:可执行脚本
└── templates/
└── template.txt # 可选:模板文件
SKILL.md 是核心,必须包含 YAML 格式的元数据:
---
name: code-review
description: >
Review code for bugs, security issues, and style violations.
Use when asked to review code, check for bugs, or audit PRs.
---
# Code Review Skill
## Instructions
When reviewing code, follow these steps:
1. First check for security vulnerabilities...
2. Then check for performance issues...
3. Finally check for code style...
关键字段:
name:Skill 的唯一标识,小写字母 + 数字 + 连字符,最多 64 字符description:描述做什么、什么时候用,最多 1024 字符
description 的质量直接决定 Skill 能不能被正确触发。
Skill 的安全考虑
Skill 有一个潜在的安全问题:Prompt Injection。
研究人员发现:
"Although Agent Skills can be a very useful tool, they are fundamentally insecure since they enable trivially simple prompt injections. Researchers demonstrated how to hide malicious instructions in long Agent Skill files and referenced scripts to exfiltrate sensitive data."
因为 Skill 本质上是注入指令,恶意的 Skill 可以在长文件中隐藏恶意指令,窃取敏感数据。
应对措施:
- 只使用可信来源的 Skill
- 审查 Skill 中的脚本
- 使用
allowed-tools限制 Skill 的能力范围
---
name: safe-file-reader
description: Read and analyze files without making changes
allowed-tools: Read, Grep, Glob # 只允许读操作
---
Skill 的平台支持
Agent Skills 目前支持:
- Claude.ai(Pro、Max、Team、Enterprise)
- Claude Code
- Claude Agent SDK
- Claude Developer Platform
需要注意的是,Skill 目前是 Anthropic 生态专属的,不像 MCP 是跨平台的开放协议。
MCP vs Skill:架构层级对比
现在我们可以从架构层级来理解两者的区别:
┌─────────────────────────────────────────────────────────┐
│ 用户请求 │
└────────────────────────┬────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────┐
│ 提示/知识层 (Skill) │
│ │
│ Skill 注入专业知识和工作流程 │
│ "怎么做某类任务" │
└────────────────────────┬────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────┐
│ LLM 推理层 │
│ │
│ Claude / GPT / Gemini 等 │
│ 理解请求,决定需要什么工具 │
└────────────────────────┬────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────┐
│ 集成层 (MCP) │
│ │
│ MCP 连接外部系统 │
│ "能访问什么工具和数据" │
└────────────────────────┬────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────┐
│ 外部世界 │
│ │
│ 数据库、API、文件系统、第三方服务 │
└─────────────────────────────────────────────────────────┘
Skill 在上层(知识层),MCP 在下层(集成层)。
两者不是替代关系,而是互补关系。你可以:
- 用 MCP 连接 GitHub
- 用 Skill 教 AI 如何按照团队规范做 Code Review
详细对比表
| 维度 | MCP | Skill |
|---|---|---|
| 核心作用 | 连接外部系统 | 编码专业知识和方法论 |
| 架构层级 | 集成层 | 提示/知识层 |
| 协议基础 | JSON-RPC 2.0 | 文件系统 + Markdown |
| 跨平台 | 是(开放协议,多平台支持) | 否(目前 Anthropic 生态专属) |
| 触发方式 | 持久连接,随时可用 | 基于描述的语义匹配,自动触发 |
| Token 消耗 | 高(工具定义持久占用上下文) | 低(渐进式加载) |
| 外部访问 | 可以直接访问外部系统 | 不能直接访问,需要配合 MCP 或内置工具 |
| 复杂度 | 高(需要理解协议、运行 Server) | 低(写 Markdown 就行) |
| 可复用性 | 高(标准化协议,跨应用复用) | 中(文件夹,可以 Git 共享) |
| 动态发现 | 是(运行时发现可用工具) | 是(运行时发现可用 Skill) |
| 安全考虑 | 外部内容带来 prompt injection 风险 | Skill 文件本身可能包含恶意指令 |
什么时候用 MCP,什么时候用 Skill
用 MCP 的场景
- 需要访问外部数据:数据库查询、API 调用、文件系统访问
- 需要操作外部系统:创建 GitHub Issue、发送 Slack 消息、执行 SQL
- 需要实时信息:监控系统状态、查看日志、搜索引擎结果
- 需要跨平台复用:同一个工具在 Claude Desktop、Cursor、其他支持 MCP 的应用中使用
用 Skill 的场景
- 重复性的工作流程:代码审查、文档生成、数据分析
- 公司内部规范:代码风格、提交规范、文档格式
- 需要多步骤的复杂任务:需要详细指导的专业任务
- 团队共享的最佳实践:标准化的操作流程
- Token 敏感场景:需要大量知识但不想一直占用上下文
结合使用
很多时候,两者是配合使用的:
用户:"Review PR #456 并按照团队规范给出建议"
1. MCP (GitHub) 获取 PR 信息
↓
2. Skill (团队代码审查规范) 提供审查方法论
↓
3. Claude 按照 Skill 的指令分析代码
↓
4. MCP (GitHub) 提交评论
MCP 负责"能访问什么",Skill 负责"怎么做"。
写好 Skill 的关键
Skill 能不能被正确触发,90% 取决于 description 写得好不好。
差的 description
description: Helps with data
太宽泛,Claude 不知道什么时候该用。
好的 description
description: >
Analyze Excel spreadsheets, generate pivot tables, and create charts.
Use when working with Excel files (.xlsx), spreadsheets, or tabular data analysis.
Triggers on: "analyze spreadsheet", "create pivot table", "Excel chart"
好的 description 应该包含:
- 做什么:具体的能力描述
- 什么时候用:明确的触发场景
- 触发词:用户可能说的关键词
最佳实践
官方建议:
- 保持专注:一个 Skill 做一件事,避免宽泛的跨域 Skill
- SKILL.md 控制在 500 行以内:太长的话拆分到支持文件
- 测试触发行为:确认相关请求能触发,不相关请求不会误触发
- 版本控制:记录 Skill 的变更历史
关于 Slash Command
文章标题是 MCP vs Skill,但很多人也会问到 Slash Command,简单说一下。
Slash Command 是最简单的扩展方式——本质上是存储的提示词,用户输入 /命令名 时注入到对话中。
Skill vs Slash Command 的关键区别是触发方式:
| Slash Command | Skill | |
|---|---|---|
| 触发方式 | 用户显式输入 /命令 | Claude 自动匹配 |
| 用户控制 | 完全控制何时触发 | 无法控制,Claude 决定 |
问自己一个问题:用户是否需要显式控制触发时机?
- 需要 → Slash Command
- 不需要,希望 AI 自动判断 → Skill
总结
MCP 和 Skill 是 AI Agent 扩展的两种不同哲学:
| MCP | Skill | |
|---|---|---|
| 哲学 | 连接主义 | 知识打包 |
| 问的问题 | "AI 能访问什么?" | "AI 知道怎么做什么?" |
| 层级 | 集成层 | 知识层 |
| Token 策略 | 预加载所有能力 | 按需加载知识 |
记住这句话:
MCP connects AI to data; Skills teach AI what to do with that data.
MCP 让 AI 能"碰到"数据,Skill 教 AI 怎么"处理"数据。
它们不是替代关系,而是互补关系。一个成熟的 AI Agent 系统,两者都需要。
参考资源
MCP 官方资源
- Model Context Protocol 官网 - 协议规范、快速入门、Server 开发指南
- MCP Specification - 完整的协议规范文档
- Introducing the Model Context Protocol - Anthropic 发布 MCP 的官方博客
- MCP GitHub Organization - 官方 SDK、示例 Server、参考实现
- Awesome MCP Servers - 社区维护的 MCP Server 列表
Skill 官方资源
- Claude Code Skills 文档 - Skills 的完整文档
- Building effective agents - Anthropic 关于 Agent 设计的研究博客
- Context Engineering Guide - 上下文工程官方指南,理解 Skill 设计哲学的关键
跨平台采用
- OpenAI adds support for MCP - OpenAI 宣布支持 MCP
- Google Gemini MCP Support - Google 宣布 Gemini 支持 MCP
延伸阅读
- Function Calling vs MCP - 理解两者区别
- Claude Code Documentation - Claude Code 完整文档
- Prompt Engineering Guide - 提示工程基础,Context Engineering 的前置知识
如果你觉得这篇文章有帮助,欢迎关注我的 GitHub,下面是我的一些开源项目:
Claude Code Skills(按需加载,意图自动识别,不浪费 token,介绍文章):
- code-review-skill - 代码审查技能,覆盖 React 19、Vue 3、TypeScript、Rust 等约 9000 行规则(详细介绍)
- 5-whys-skill - 5 Whys 根因分析,说"找根因"自动激活
- first-principles-skill - 第一性原理思考,适合架构设计和技术选型
全栈项目(适合学习现代技术栈):
- prompt-vault - Prompt 管理器,用的都是最新的技术栈,适合用来学习了解最新的前端全栈开发范式:Next.js 15 + React 19 + tRPC 11 + Supabase 全栈示例,clone 下来配个免费 Supabase 就能跑
- chat_edit - 双模式 AI 应用(聊天+富文本编辑),Vue 3.5 + TypeScript + Vite 5 + Quill 2.0 + IndexedDB
来源:juejin.cn/post/7584057497205817387
流程引擎、工作流、规则引擎、编排系统、表达式引擎……天呐,我到底该用哪个?
你是不是也有这些困惑
看项目文档,各种名词扑面而来:
- 流程引擎(Flowable、Camunda)
- 工作流(Activiti)
- 规则引擎(Drools)
- 编排系统(LiteFlow)
- 表达式引擎(QLExpress、Aviator)
- DAG调度(Airflow、DolphinScheduler)
- 任务编排(Temporal、Conductor)
- BPMN、Saga、Event-Driven...
每个框架都说自己能解决问题,每个概念看起来都差不多。
新手一脸懵逼,老手也经常搞混。
干了20年,我也被这些东西搞晕过。今天不讲那些虚的,直接告诉你怎么选。
答案很简单:别管这些名词,问自己四个问题就够了。
忘掉那些名词,只问四个问题
看了一堆框架介绍还是不知道选哪个?正常,因为你在纠结概念。
别纠结了,概念都是虚的。问自己四个问题,立刻就清楚了。
问题1:你是要干活,还是改状态?
这是最关键的一个问题。搞清楚这个,一大半框架就排除了。
改状态是什么意思?
请假审批:
员工提交 → 主管看了说"行" → HR看了说"行" → 完成
整个过程:
- 没有计算
- 没有数据转换
- 没有调用外部系统
- 就是状态从 pending 变成 approved
这就是纯改状态。
干活是什么意思?
订单处理:
下单 → 扣库存 → 调支付接口 → 调物流接口 → 发货
整个过程:
- 要计算金额
- 要调用外部API
- 要处理数据
- 要执行业务逻辑
这就是干活。
判断标准:
- 改状态:就是让人点"同意"或"拒绝",除了改个字段,啥也没干
- 干活:要计算、要调API、要处理数据
对应框架:
- 改状态 → BPMN系(Flowable、Camunda)
- 干活 → 继续往下判断
问题2:主要是人处理,还是机器执行?
人处理:
审批流程:
- 主管要看文档
- 主管要做判断
- 主管要点按钮
- 然后等下一个人
特点:大部分时间在等人
机器执行:
数据处理:
- 读数据库
- 清洗数据
- 转换格式
- 写入目标表
特点:机器自己跑,不用人管
对应框架:
- 人为主 → BPMN系(Flowable、Camunda)
- 机器为主 → 继续往下判断
问题3:是本地方法,还是跨系统调用?
本地方法:
营销规则:
- 判断用户是不是VIP
- 计算折扣
- 返回结果
都在一个应用里,不用调外部接口
跨系统调用:
订单流程:
- 调库存系统(HTTP)
- 调支付系统(HTTP)
- 调物流系统(HTTP)
要跨多个服务
对应框架:
- 本地 → 表达式系、脚本系(QLExpress、LiteFlow)
- 跨系统 → DAG系、服务编排系(Airflow、Temporal)
问题4:自己玩,还是要搞生态?
自己玩:
你的团队自己维护:
- 规则你们自己写
- 代码你们自己改
- 不需要外部开发者
搞生态:
做平台,让别人扩展:
- 客户可以上传插件
- 第三方可以写脚本
- 需要沙箱隔离
对应技术:
- 自己玩 → 表达式 + 代码(QLExpress、Aviator)
- 搞生态 → Groovy脚本、插件机制
那些让人头疼的框架,到底是干什么的
四个问题问完,你大概知道方向了。现在看看具体框架都是什么情况。
不用全看,只看和你匹配的那一类就行。
BPMN系:Flowable、Camunda、Activiti
适合场景:
- 纯人工审批流程
- 需要流程图可视化
- 需要历史记录追溯
- 大公司、强合规要求
典型例子:
- 请假审批
- 报销审批
- 合同审批
- 采购流程
核心特点:
- 本质就是改状态
- 大部分时间在等人
- 业务价值为0(只是流程管理)
- 技术难度不高(就是状态机)
什么时候用:
- 大公司(100+人),有几十个审批流程要管理
- 金融、政府等强合规行业
- 需要标准化流程管理
什么时候别用:
- 小公司(别用,钉钉审批就够了)
- 没有复杂审批需求(自己写100行代码搞定)
- 为了"企业级"而用(过度设计)
DAG系:Airflow、DolphinScheduler、Prefect
适合场景:
- 数据处理任务
- 离线批处理
- 定时调度
- 任务有依赖关系
典型例子:
- 数据ETL
- 报表生成
- 数据清洗
- 机器学习Pipeline
核心特点:
- 纯机器执行
- 长时间运行(小时、天级)
- 任务之间有依赖(A完成才能B)
- 需要调度和监控
什么时候用:
- 数据团队做离线处理
- 有复杂的任务依赖关系
- 需要定时调度(每天、每周)
什么时候别用:
- 实时性要求高的(秒级响应)
- 简单的定时任务(用Cron就够了)
- 没有依赖关系的任务
表达式/脚本系:QLExpress、Aviator、LiteFlow、Groovy
适合场景:
- 规则计算
- 业务流程编排
- 本地方法调用
- 需要动态配置
典型例子:
- 营销活动规则(满减、折扣)
- 风控规则(黑名单、评分)
- 订单流程(本地编排)
- 积分计算
QLExpress / Aviator(表达式):
- 优点:性能好、类Java语法、团队容易上手
- 缺点:功能受限、只能简单计算
- 适合:自己团队玩、简单规则
Groovy(脚本):
- 优点:功能完整、可以调复杂API
- 缺点:性能差、调试难、类型不安全
- 适合:要搞插件生态、客户自定义逻辑
LiteFlow(编排):
- 优点:可视化编排、组件复用
- 缺点:学习成本、维护成本
- 适合:流程确实复杂、经常变化
什么时候用:
- 规则经常变(不想每次改代码发版)
- 流程需要配置化
- 有一定复杂度(10+个分支)
什么时候别用:
- 简单的if-else(直接写代码)
- 流程固定不变(没必要配置化)
- 为了"灵活"而牺牲性能
服务编排系:Temporal、Cadence、Conductor
适合场景:
- 微服务编排
- 分布式事务
- 长时间运行的业务流程
- 需要补偿机制
典型例子:
- 订单流程(支付 → 发货 → 签收)
- 旅游预订(机票 + 酒店 + 门票)
- 跨系统流程
- Saga模式
核心特点:
- 支持长时间运行(天级)
- 支持失败重试
- 支持补偿逻辑
- 状态持久化
什么时候用:
- 微服务架构,需要编排多个服务
- 需要分布式事务
- 流程可能运行很久(几小时、几天)
什么时候别用:
- 单体应用(没有跨服务需求)
- 简单的API调用(直接用HTTP就行)
- 实时性要求极高的(毫秒级)
懒得看?直接照这个选
如果你嫌上面内容太多,直接看这个决策树。
跟着问题一步步走,到底了就知道该用什么。
开始
↓
主要是人审批吗?
↓ 是
用 Flowable/Camunda(大公司)或钉钉审批(小公司)
↓ 否
是长时间运行的任务吗(>10分钟)?
↓ 是
用 Airflow/DolphinScheduler
↓ 否
需要跨系统调用吗?
↓ 是
用 Temporal/Conductor(微服务)或 Airflow(数据处理)
↓ 否
逻辑很复杂吗(>10个分支)?
↓ 是
用 LiteFlow(编排)或 QLExpress(规则)
↓ 否
需要频繁修改规则吗?
↓ 是
用 QLExpress/Aviator
↓ 否
直接写代码!
具体场景怎么选
理论说完了,看几个实际例子。看看你的场景和哪个像。
场景1:请假审批
特征:
- 纯人工审批
- 状态流转
- 需要历史记录
选型:
- 小公司:钉钉/企业微信审批
- 大公司:Flowable/Camunda
- 自己开发:状态机 + 数据库
场景2:电商订单流程
特征:
- 要调支付、库存、物流接口
- 有失败重试和补偿
- 短事务(分钟级)
选型:
- 复杂场景:Temporal/Cadence
- 简单场景:LiteFlow + 消息队列
- 最简单:直接写代码 + 状态机
场景3:数据ETL
特征:
- 纯机器执行
- 长时间运行
- 任务有依赖
选型:
- 标准方案:Airflow/DolphinScheduler
- 简单场景:XXL-Job
场景4:营销活动规则
特征:
- 规则计算
- 经常变化
- 本地方法
选型:
- 简单规则:QLExpress/Aviator
- 复杂规则:Drools
- 有编排需求:LiteFlow
很多人踩过的坑
说几个常见的错误,别重复踩坑。
误区1:追求"企业级架构"
错误做法:
20人的创业公司,上了Flowable、Camunda、Airflow一整套
正确做法:
能用100行代码解决就别上框架
误区2:为了灵活性而牺牲性能
错误做法:
所有逻辑都用Groovy脚本,方便修改
正确做法:
核心逻辑用Java写,只把经常变的部分配置化
误区3:过度抽象
错误做法:
3个简单流程,非要搞个"流程引擎"
正确做法:
3个流程就3个方法,直接写代码
误区4:混淆概念
错误理解:
"我需要流程编排,所以要用Flowable"
正确理解:
先搞清楚你要干活还是改状态
是人审批还是机器执行
几句大实话
最后说几句掏心窝的话。
1. 先用最简单的方案
遇到问题:
第一反应不是"上框架"
而是"能不能写100行代码搞定"
90%的情况,100行代码就够了
2. 遇到瓶颈再优化
流程很乱了 → 重构代码
改动很频繁 → 考虑配置化
管理不过来 → 考虑框架
别提前优化
3. 根据团队规模选择
小团队(<20人):
- 能不用框架就不用
- 钉钉审批、Cron、直接写代码
中等团队(20-100人):
- 流程<10个:自己写
- 流程>10个:考虑轻量级框架
大团队(>100人):
- 需要标准化管理
- 可以考虑成熟框架
4. 看业务特点
强合规(金融、政府):
- 必须用标准化工具
- Flowable是选择之一
数据密集:
- Airflow是标准方案
微服务架构:
- Temporal值得考虑
简单CRUD:
- 别折腾,写代码
说到底,就这么点事
看完还觉得复杂?那就记住这四个问题:
- 干活还是改状态?
- 人为主还是机器为主?
- 本地方法还是跨系统?
- 自己玩还是搞生态?
四个问题问完,基本就知道该用什么了。
那些"企业级"、"先进架构"、"灵活扩展"的词,都是包装。
看透本质,别被忽悠。
能用100行代码解决的,就别上框架。
技术是为业务服务的,不是为了炫技。
务实点,别整那些虚的。
就这样。
来源:juejin.cn/post/7587299670642606086
WebSocket 不是唯一选择:SSE 打造轻量级实时推送系统 🚀🚀🚀
面试导航 是一个专注于前、后端技术学习和面试准备的 免费 学习平台,提供系统化的技术栈学习,深入讲解每个知识点的核心原理,帮助开发者构建全面的技术体系。平台还收录了大量真实的校招与社招面经,帮助你快速掌握面试技巧,提升求职竞争力。如果你想加入我们的交流群,欢迎通过微信联系:
yunmz777。

在需要服务器实时向浏览器推送数据的场景中,很多人第一反应是使用 WebSocket,但其实还有一种更轻量、更简单的解决方案 —— SSE(Server-Sent Events)。它天生适合“服务器单向推送”,而且浏览器原生支持、无需额外协议、写起来极其简单。
本文将从原理、协议、代码、对比、性能、安全等多个方面,帮你系统了解 SSE 的底层机制与实际应用。
🧠 一、什么是 SSE?
SSE,全称 Server-Sent Events,是 HTML5 提出的标准之一,用于建立一种 客户端到服务器的持久连接,允许服务器在数据更新时,主动将事件推送到客户端。
通俗点讲,它就像是:
浏览器发起了一个请求,服务器就打开一个“水管”,源源不断地往客户端输送数据流,直到你手动关闭它。
它基于标准的 HTTP 协议,与传统请求-响应的“短连接”模式不同,SSE 是长连接,并且保持活跃,类似于“实时通知通道”。
🛠️ 二、SSE 的通信机制与协议细节
✅ 客户端:使用 EventSource 建立连接
const sse = new EventSource("/events");
sse.onmessage = (event) => {
console.log("新消息:", event.data);
};
EventSource 是浏览器自带的,直接用就行,不用装库。它会自动处理连接、断线重连这些问题,基本不需要你操心,消息来了就能收到。
原生 EventSource 的使用限制
虽然原生的 EventSource 对象很方便,但也存在很多的限制,它只能发送 GET 请求,不支持设置请求方法,也不能附带请求体。
你不能通过 EventSource 设置如 Authorization、token 等自定义请求头用于鉴权。
例如,下面这样是不被支持的:
const sse = new EventSource("/events", {
headers: {
Authorization: "Bearer xxx",
},
});
这在 fetch 里没问题,但在 EventSource 里完全不支持。直接报错,浏览器压根不给你设置 headers。
EventSource 虽然支持跨域,但得服务器配合设置 CORS,而且还不能用 withCredentials。换句话说,你不能让它自动带上 cookie,那些基于 cookie 登录的服务就麻烦了。
如果你需要传 token 或做鉴权,可以使用查询参数传 token,比如这样:
const token = "abc123";
const sse = new EventSource(`/events?token=${token}`);
✅ 服务器:响应格式必须为 text/event-stream
服务器需要返回特定格式的数据流,并设置以下 HTTP 响应头:
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
如下图所示:

然后每条消息遵循下面的格式:
data: Hello from server
id: 1001
event: message
如下图所示:
在上面的内容中,主要有以下解释,如下表格所示:
| 字段 | 说明 |
|---|---|
data: | 消息正文内容,支持多行 |
id: | 消息 ID,浏览器断线重连后会通过 Last-Event-ID 自动恢复 |
event: | 自定义事件名(默认是 message) |
retry: | 指定断线重连间隔(毫秒) |
🔄 三、SSE vs WebSocket vs 轮询,对比总结
| 特性 | SSE | WebSocket | 长轮询(Ajax) |
|---|---|---|---|
| 通信方向 | 单向(服务器 → 客户端) | 双向 | 单向 |
| 协议 | HTTP | 自定义 ws 协议 | HTTP |
| 支持断线重连 | ✅ 内置自动重连 | ❌ 需手动重连逻辑 | ❌ |
| 浏览器兼容性 | 现代浏览器支持,IE 不支持 | 广泛支持 | 兼容性强 |
| 复杂度 | ✅ 最简单,零依赖 | 中等 | 简单但消耗高 |
| 使用场景 | 实时通知、进度、新闻、后台日志 | 聊天、游戏、协作、股票交易等 | 简单刷新类数据 |
🚀 四:如何在 NextJs 中实现
NextJS 作为一个现代化的 React 框架,非常适合实现 SSE。下面我们将通过一个完整的实例来展示如何在 NextJS 应用中实现服务器发送事件。
前端代码如下:
"use client";
import React, { useState, useEffect, useRef } from "react";
export default function SSEDemo() {
const [sseData, setSseData] = useState<{
time?: string;
value?: string;
message?: string;
error?: string;
} | null>(null);
const [connected, setConnected] = useState(false);
const [reconnecting, setReconnecting] = useState(false);
const [reconnectCount, setReconnectCount] = useState(0);
const eventSourceRef = useRef<EventSource | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// 建立SSE连接
const connectSSE = () => {
// 关闭任何现有连接
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
// 清除任何挂起的重连计时器
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
try {
setReconnecting(true);
// 添加时间戳防止缓存
const eventSource = new EventSource(`/api/sse?t=${Date.now()}`);
eventSourceRef.current = eventSource;
eventSource.onopen = () => {
setConnected(true);
setReconnecting(false);
setReconnectCount(0);
console.log("SSE连接已建立");
};
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
setSseData(data);
} catch (error) {
console.error("解析SSE数据失败:", error);
}
};
eventSource.onerror = (error) => {
console.error("SSE连接错误:", error);
setConnected(false);
eventSource.close();
// 增加重连次数
setReconnectCount((prev) => prev + 1);
// 随着失败次数增加,增加重连间隔(指数退避策略)
const reconnectDelay = Math.min(
30000,
1000 * Math.pow(2, Math.min(reconnectCount, 5))
);
setReconnecting(true);
setSseData((prev) => ({
...prev,
message: `连接失败,${reconnectDelay / 1000}秒后重试...`,
}));
// 尝试重新连接
reconnectTimeoutRef.current = setTimeout(() => {
connectSSE();
}, reconnectDelay);
};
} catch (error) {
console.error("创建SSE连接失败:", error);
setConnected(false);
setReconnecting(true);
// 5秒后重试
reconnectTimeoutRef.current = setTimeout(() => {
connectSSE();
}, 5000);
}
};
useEffect(() => {
connectSSE();
// 定期检查连接是否健康
const healthCheck = setInterval(() => {
if (eventSourceRef.current && !connected) {
// 如果存在连接但状态是未连接,尝试重新连接
connectSSE();
}
}, 30000);
// 清理函数
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
clearInterval(healthCheck);
};
}, []);
return (
<div className="min-h-screen bg-gradient-to-b from-slate-900 to-slate-800 text-white flex flex-col items-center justify-center p-4">
<div className="w-full max-w-md bg-slate-800 rounded-xl shadow-2xl overflow-hidden">
<div className="p-6 border-b border-slate-700">
<h1 className="text-3xl font-bold text-center text-blue-400">
SSE 演示
</h1>
<div className="mt-2 flex items-center justify-center">
<div
className={`h-3 w-3 rounded-full mr-2 ${
connected
? "bg-green-500"
: reconnecting
? "bg-yellow-500 animate-pulse"
: "bg-red-500"
}`}
></div>
<p className="text-sm text-slate-300">
{connected
? "已连接到服务器"
: reconnecting
? `正在重新连接 (尝试 ${reconnectCount})`
: "连接断开"}
</p>
</div>
{!connected && (
<button
onClick={() => connectSSE()}
className="mt-3 px-3 py-1 bg-blue-600 text-sm text-white rounded-md mx-auto block hover:bg-blue-700"
>
手动重连
</button>
)}
</div>
{sseData && (
<div className="p-6">
{sseData.error ? (
<div className="rounded-lg bg-red-900/30 p-4 mb-4 text-center border border-red-800">
<p className="text-lg text-red-300">{sseData.error}</p>
</div>
) : sseData.message ? (
<div className="rounded-lg bg-slate-700 p-4 mb-4 text-center">
<p className="text-lg text-blue-300">{sseData.message}</p>
</div>
) : (
<div className="space-y-4">
<div className="flex justify-between items-center">
<span className="text-slate-400">时间:</span>
<span className="font-mono bg-slate-700 px-3 py-1 rounded-md text-blue-300">
{sseData.time &&
new Date(sseData.time).toLocaleTimeString()}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-slate-400">随机值:</span>
<span className="font-mono bg-slate-700 px-3 py-1 rounded-md text-green-300">
{sseData.value}
</span>
</div>
</div>
)}
</div>
)}
{!sseData && (
<div className="p-6 text-center text-slate-400">
<p>等待数据中...</p>
<div className="mt-4 flex justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-blue-400"></div>
</div>
</div>
)}
</div>
</div>
);
}
在上面的代码中,我们用的是浏览器的原生 EventSource,加了个时间戳 t=${Date.now()} 是为了防止缓存,确保每次都是新的连接。
然后我们监听三个事件:
- onopen:连接成功,更新状态,重置重连次数。
- onmessage:收到数据,尝试解析 JSON,然后保存到状态里。
- onerror:连接失败,进入重连逻辑(详细见下面)。
当连接出错时,我们做了这些事:
- 断开当前连接
- 增加重连次数
- 用指数退避算法(越失败,重试间隔越长,最多 30 秒)
- 设置一个 setTimeout 自动重连
而且页面上也有提示「正在重连」和「手动重连」的按钮,体验很人性化。
接下来我们看看后端代码,如下:
export async function GET() {
// 标记连接是否仍然有效,
let connectionClosed = false;
// 使用Next.js的流式响应处理
return new Response(
new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
// 监测响应对象是否被关闭
const abortController = new AbortController();
const signal = abortController.signal;
signal.addEventListener("abort", () => {
connectionClosed = true;
cleanup();
});
// 安全发送数据函数
const safeEnqueue = (data: string) => {
if (connectionClosed) return;
try {
controller.enqueue(encoder.encode(data));
} catch (error) {
console.error("SSE发送错误:", error);
connectionClosed = true;
cleanup();
}
};
// 发送初始数据
safeEnqueue(`data: ${JSON.stringify({ message: "连接已建立" })}\n\n`);
// 定义interval引用
let heartbeatInterval: NodeJS.Timeout | null = null;
let dataInterval: NodeJS.Timeout | null = null;
// 清理所有资源
const cleanup = () => {
if (heartbeatInterval) clearInterval(heartbeatInterval);
if (dataInterval) clearInterval(dataInterval);
// 尝试安全关闭控制器
try {
if (!connectionClosed) {
controller.close();
}
} catch (e) {
// 忽略关闭时的错误
}
};
// 设置10秒的心跳间隔,避免连接超时
heartbeatInterval = setInterval(() => {
if (connectionClosed) {
cleanup();
return;
}
safeEnqueue(": heartbeat\n\n");
}, 10000);
// 每秒发送一次数据
dataInterval = setInterval(() => {
if (connectionClosed) {
cleanup();
return;
}
try {
const data = {
time: new Date().toISOString(),
value: Math.random().toFixed(3),
};
safeEnqueue(`data: ${JSON.stringify(data)}\n\n`);
} catch (error) {
console.error("数据生成错误:", error);
connectionClosed = true;
cleanup();
}
}, 1000);
// 60秒后自动关闭连接(可根据需要调整)
setTimeout(() => {
// 只有当连接仍然活跃时才发送消息和关闭
if (!connectionClosed) {
try {
safeEnqueue(
`data: ${JSON.stringify({
message: "连接即将关闭,请刷新页面重新连接",
})}\n\n`
);
connectionClosed = true;
cleanup();
} catch (e) {
// 忽略关闭时的错误
}
}
}, 60000);
},
cancel() {
// 当流被取消时调用
connectionClosed = true;
},
}),
{
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"Access-Control-Allow-Origin": "*",
"X-Accel-Buffering": "no", // 适用于某些代理服务器如Nginx
},
}
);
}
这段代码是 Next.js 后端 API 路由,用来实现 SSE(Server-Sent Events)长连接。我们使用了 ReadableStream 创建一个持续向前端推送数据的响应流,并配合 AbortSignal 检测连接是否被关闭:
return new Response(new ReadableStream({ start(controller) { ... } }), { headers: {...} });
一开始,服务器通过 safeEnqueue 安全地向客户端发送一条欢迎消息:
safeEnqueue(`data: ${JSON.stringify({ message: "连接已建立" })}\n\n`);
随后每秒生成一条数据(当前时间和随机值)推送给前端,并通过 setInterval 定时发送:
const data = {
time: new Date().toISOString(),
value: Math.random().toFixed(3),
};
safeEnqueue(`data: ${JSON.stringify(data)}\n\n`);
为了保持连接活跃,避免浏览器或代理中断连接,我们每 10 秒发送一次心跳包(以冒号开头的注释):
safeEnqueue(": heartbeat\n\n");
还加了一个自动关闭机制——60 秒后主动断开连接并提示前端刷新:
safeEnqueue(
`data: ${JSON.stringify({ message: "连接即将关闭,请刷新页面重新连接" })}\n\n`
);
整个数据发送过程都包裹在 safeEnqueue 中,确保连接断开时能安全终止,并调用 cleanup() 清理资源。响应头中我们指定了 text/event-stream,关闭了缓存,并设置了必要的长连接参数:
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"Access-Control-Allow-Origin": "*",
"X-Accel-Buffering": "no"
}
通过这种方式,服务端可以稳定地向客户端发送实时数据,同时具备自动断开、心跳维持、错误处理等健壮性,是非常实用的 SSE 实践方案。
最终结果如下图所示:

成功实现。
总结
SSE(Server-Sent Events)是一种基于 HTTP 的 服务器向客户端单向推送数据的机制,适用于需要持续更新前端状态的场景。除了浏览器原生支持的 EventSource,也可以通过 fetch + ReadableStream 或框架内置流式处理(如 Next.js API Route、Node.js Response Stream)来实现,适配更复杂或自定义需求。相比 WebSocket,SSE 实现更简单,自动断线重连、无需维护双向协议,非常适合实时消息通知、进度条更新、在线人数统计、系统日志流、IoT 设备状态推送等。它特别适合“只要服务器推就好”的场景,无需双向通信时是高效选择。
来源:juejin.cn/post/7493140532798914570
国产 OCR 开源神器官网上线了,相当给力。
在大模型狂飙突进的今天,高质量、结构化的数据已成为决定 AI 能力的核心基建。而现实中,海量知识却沉睡在PDF、扫描件、报告等非结构化文档中。
如何将这座富矿高效、精准地转化为大模型可理解、可训练的数据燃料,是整个产业面临的关键瓶颈。
OCR(光学字符识别)技术正是打通这一瓶颈的数据管道。但传统OCR主要停留在「字符识别」层面,面对包含图表、公式、代码以及复杂版式的文档时,往往会产出混乱的文本流,难以支撑后续理解、检索等等需求。
因此,在大模型时代,这一能力已远远不够。一个真正可用的文档解析方案,必须提供端到端的文档智能解析能力:不仅「看得准」,更要「懂得清」。
它需要在识别文本的同时,理解文档的语义结构和版式逻辑,将原始文档精准还原为包含标题、段落、表格、图表描述、公式 LaTeX、代码块等语义信息的标准化表示形式(如 Markdown / JSON)。
只有当非结构化文档被转化为高质量、可直接消费的结构化数据,才能真正成为大模型训练、知识库构建、RAG 检索与智能问答中的可靠数据原料,从而发挥它应有的价值。
今天,这个关键的「数据管道」迎来了它里程碑式产品化升级——PaddleOCR 官网(http://www.paddleocr.com)正式版上线了!
这不仅是其强大开源能力的直观展现,更通过丝滑的体验与海量API,将文档结构化能力推向了普惠化应用。
熟悉我的老粉都知道,过去如果我要推荐 OCR 或文档解析工具,基本只会提到 PaddleOCR。原因很简单:我希望为大家提供一条最高效、最直接的“生产力路径”,而不是让大家在众多项目中反复试错。
这不仅是我的推荐逻辑,也是各大模型厂商在开源选型时的共识——PaddleOCR 几乎是文档解析领域唯一被广泛引用的开源方案。
今年 10 月 17 日 PaddleOCR-VL 刚刚发布,仅用 16 小时就登顶 HuggingFace Trending 全球榜首。
短短两个月内,项目的 Star 数从 57k 飙升至接近 67k。要知道,一个开源项目在五年之后还能保持这样的增长速度,背后一定是它切中了真实且迫切的用户需求。
01、关键特性:三大模型,覆盖全场景文档解析
打开官网,你会看到三个核心入口:GitHub 开源地址、MCP 接口、API 接口。下方支持直接上传图像或 PDF,体验 PaddleOCR 的三大模型方案:
- PP-OCRv5:轻量级 OCR,适合纯文本提取
- PP-StructureV3:基于pipeline架构的文档解析,支持印章、表格、标题等还原,零幻觉
- PaddleOCR-VL(默认):基于视觉-语言模型的文档解析,支持图文、公式、代码等多模态解析,当前全球最高精度

如果你还不清楚这些模型能力的区别,PaddleOCR 官方文档(http://www.paddleocr.ai)提供了清晰的说明,支持搜索与评论,非常友好。

我这里以 PaddleOCR-VL 为例,上传了一篇 DeepSeek-R1 的论文 PDF。
几秒后,解析结果清晰呈现:不管是文字、图像、代码、表格还是公式,PaddleOCR都能精准还原,相关内容,可以左右一一对应。
在右侧,你也可以复制所有的解析结果,也可以复制其中的某一个block的结果,还可以基于某一个block进行内容纠正。下边是一些关键场景的可视化。
·文字场景
一级标题、二级标题、正文层次分明,还原精准。

·图像/图表场景
支持图表转表格,对科研与数据分析工作者极其友好。关闭图表识别功能:

打开图表识别功能:

这项功能极其实用,能够将图表等非结构化数据转换为结构化表格,对于科研人员以及日常需要处理图表数据的工作者而言,是一项极具价值的工具。
·代码场景
代码区域被转换为等宽字体,代码的格式与内嵌公式保留完整,恢复完美。

·表格场景
合并单元格也能准确预测,精准还原表格中的各项指标。点击“复制”可直接粘贴至 Excel,格式无损。

此外,在表格应用场景中,我还发现了一个小惊喜:点击右侧下方表格区块的复制按钮后,可以将表格内容无损地粘贴到Excel中,原有格式能够完整保留。这个功能对我日常整理数据非常有帮助,没想到能够如此完美地实现。
不过,官方似乎并未特别宣传这项小功能,看来还有许多实用细节有待用户进一步发掘。

·公式场景
LaTeX 格式输出,右侧实时渲染,复杂公式也无错漏。

公式内容会被自动识别并转换为LaTeX格式的代码,随后在右侧的Markdown区域被正确渲染。经过对比验证,即使是较为复杂的公式也能够准确无误地显示,未发现任何错误。
·更多功能
此外,官网还支持批量上传(最多 20 个文件),并提供了超参数设置面板,除了默认的结果,还有一个设置超参数的按钮,用户可根据需求设置很多超参数,关于超参数的解释,也在旁边隐藏的部分有解释。
比如上边的图表识别的功能,我就是打开了这个超参数中的图表识别的开关,灵活度很高。


02
API 调用:数据基建的“普惠管道”
PaddleOCR官网首页已直接提供了 API 和 MCP 的调用示例,点击就可以有对应的弹窗,亲测带上token,复制可以跑。这里以 API 为例,MCP类似。
基础跑通三步走:
1. 点击首页的API:

2. 复制代码到本地
在本地电脑新建一个名为 test.py 的文件,并将复制的代码粘贴进去(此时你的账号 token 也会被自动复制)。然后,在代码中的 file_path 参数填写你要预测的文件名。这里需要注意的是:如果是 PDF 文件,fileType 应设置为 0;如果是图像文件,fileType 则需要设置为 1。


3. 运行代码
大约在20多秒可以返回一个21页的PDF结果,包含了每一页的Markdown的结果、对应的插图等。基本上每秒一页,速度还不错。本地可视化如图所示,和网页端完全一致。

进阶玩法三步走:
进一步体验PaddleOCR官网,会发现一些我认为非常重要的细节。
1. API和效果联动
这次 PaddleOCR 官网的一个重要变化,是前端整体把体验优化得非常友好了,不再只是“展示效果”,而是围绕 参数配置 → 效果验证 → API 接入 这条完整路径来设计。

在网页端,你可以直接调整解析参数,比如是否开启图表识别、是否需要方向矫正、不同结构化策略等,每一次参数变化,解析结果都会即时刷新返回。图像或 PDF 的结构化结果几乎是秒级可见,非常适合快速对比不同参数组合下的效果差异,而不是靠猜。

更关键的是,这些在网页端调过、验证过的参数,并不会停留在「试用层」。当你确认某一套配置满足你的业务需求后,可以直接一键复制对应的 API 调用代码,包括参数、模型类型和调用方式,拿到本地或直接接入业务系统即可使用。

整个过程非常顺滑:
你不需要先搭环境、不需要翻文档对着字段一个一个找参数含义,先在网页上把效果跑通,再把同一套配置“原封不动”搬进工程里。哪怕完全没有本地部署过,也可以先把解析效果看清楚、想明白,再决定是否以及如何在真实业务中使用。
一句话总结就是:
不用写一行代码,也能把PaddleOCR的能力验证到位;一旦要上线,代码已经帮你准备好了。
2.更多的 API 调用
在 API 文档页有一行关键说明:“每位用户每日对同一模型的解析上限为 3000 页,超出会返回 429 错误。如需更高额度,可通过问卷申请白名单。”
🔗申请链接为:paddle.wjx.cn/vm/mePnNLR.…
我填写了问卷中四个常规问题留下联系方式后,很快就有官方人员联系我,了解使用场景后直接开通了白名单。随后我测试了约 1 万份 PDF(共 3 万多页),开了一个后台的访问服务的进程挂机运行一夜,第二天一早,全部解析成功。这意味着,现阶段个人、团队或初创企业完全可以借助此额度,启动大规模的数据清洗与知识库构建工作,成本几乎为零。

3.不容错过的MCP
作为 AI 时代的 Type-C 接口,MCP 正逐渐成为各类 AI 产品的基础能力配置。PaddleOCR 官网也提供了开箱即用的 MCP server:只需复制官网给出的配置示例,并在 MCP host 应用中完成简单配置,即可让大模型直接调用 PaddleOCR 的文字识别与文档解析能力。

我也在 Cherry Studio 里试了试效果。花了不到一分钟复制粘贴 MCP 配置,然后使用 PaddleOCR 官网提供的 PP-OCRv5 MCP server 来识别图像中的酒店名称:

03、项目相关链接
官网虽已足够强大,但如果你有私有化部署需求,仍可基于开源项目自行部署。
·PaddleOCR GitHub:https://github.com/PaddlePaddle/PaddleOCR·官方文档:https://www.paddleocr.ai·Hugging Face 模型:https://huggingface.co/PaddlePaddle
PaddleOCR 再一次没有让人失望。从开源项目到产品化官网,从模型迭代到这波 API 的开放,它正在把文档智能从“技术能力”推向“普及工具”。大模型时代,数据是石油,而 OCR 则是开采与提炼的核心装备。PaddleOCR 这一次的升级,不仅提升了开采效率,还让更多人用上了这把利器。
期待大家亲自体验,也欢迎在评论区分享你的使用场景与发现。
来源:juejin.cn/post/7588388014505312298
同事一个比喻,让我搞懂了Docker和k8s的核心概念
Docker 和 K8s 的核心概念,用"快照"这个比喻就够了
前几天让同事帮忙部署服务,顺嘴问了句"Docker 和 K8s 到底是啥"。
其实这俩概念我以前看过,知道是"打包完整环境、到处运行",但一直停留在似懂非懂的状态。镜像、容器、Pod、集群、节点……这些词都见过,就是串不起来。
同事给我讲了一个非常直观的比喻,一下就通了:
镜像:一个打包好的系统快照
Docker 镜像可以理解成一个系统快照,里面包含了:
- 操作系统(比如 Debian、Alpine)
- 运行时环境(比如 Python 3.11、Node 20)
- 所有依赖包
- 你的代码
- 配置文件
这个快照是静态的、只读的,就像一张光盘——刻好了就不会变。
容器:运行起来的快照
容器就是把镜像跑起来。
镜像(静态快照) --docker run--> 容器(运行中的进程)
容器是动态的、可写的,可以往里面写文件、改配置。但一旦容器销毁,这些改动就没了(除非你挂载了外部存储)。
一个镜像可以同时跑多个容器,就像一张光盘可以装到多台电脑上。
Dockerfile 和 docker-compose
搞清楚镜像和容器的关系后,这两个东西就好理解了:
- Dockerfile:定义如何构建镜像的配方
- docker-compose:定义如何运行一组容器
flowchart LR
A["Dockerfile<br/>(配方)"] -->|docker build| B["Image<br/>(镜像/快照)"]
B -->|docker run<br/>docker-compose up| C["Container<br/>(容器/运行态)"]
举个例子,你写了个 Python 服务:
# Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["python", "main.py"]
这个 Dockerfile 就是一份配方,告诉 Docker:
- 基于 Python 3.11 的官方镜像
- 把依赖装好
- 把代码复制进去
- 启动时运行
python main.py
执行 docker build 就会按这个配方生成一个镜像。
为什么说"到处运行"
Docker 的核心价值就是解决"我这能跑,你那跑不了"的问题。
以前部署服务,你得操心:服务器是什么系统?装的什么版本的 Python?依赖库版本对不对?环境变量配了没?
现在有了 Docker,这些都打包进镜像了。不管你的服务器是 Ubuntu、CentOS 还是 Debian,只要装了 Docker,同一个镜像都能跑出一样的结果。
Pod:K8s 调度的最小单元
到了 Kubernetes 这一层,又多了一个概念:Pod。
Pod 是 K8s 定义的概念,是集群调度的最小单元。一个 Pod 里面可以有一个或多个容器。
你可能会问:为什么不直接调度容器,还要多一层 Pod?
因为有些场景下,几个容器需要紧密配合。比如一个主服务容器 + 一个日志收集容器,它们需要:
- 共享网络(用 localhost 通信)
- 共享存储(访问同一个目录)
- 一起启动、一起销毁
把它们放在一个 Pod 里,K8s 就会把它们调度到同一台机器上,共享资源。
不过大多数情况下,一个 Pod 就放一个容器。微服务架构下,每个服务就是一个 Pod:
flowchart TB
subgraph Cluster["K8s 集群"]
subgraph Node1["节点 1"]
PodA["Pod A<br/>用户服务"]
PodB["Pod B<br/>订单服务"]
end
subgraph Node2["节点 2"]
PodC["Pod C<br/>支付服务"]
PodD["Pod D<br/>网关服务"]
end
end
K8s 干的事情
K8s 负责管理这些 Pod:
- 调度:决定 Pod 跑在哪个节点上
- 扩缩容:流量大了自动多启几个 Pod,流量小了缩回去
- 自愈:Pod 挂了自动重启
- 网络:打通各个 Pod 之间的通信
- 存储:管理持久化存储
说白了,Docker 解决的是"打包和运行"的问题,K8s 解决的是"大规模部署和管理"的问题。
一台机器跑几个容器,手动管理就行。但当你有几十台机器、几百个容器的时候,就需要 K8s 这样的编排工具来帮你自动化处理。
Dockerfile → Image → Container → Pod → Node → Cluster
配方 快照 运行态 调度单元 机器 集群
概念不难,难的是实际操作中的各种坑。但只要这个基础模型搞清楚了,遇到问题知道往哪个层面去排查就行。
如果你觉得这篇文章有帮助,欢迎关注我的 GitHub,下面是我的一些开源项目:
Claude Code Skills(按需加载,意图自动识别,不浪费 token,介绍文章):
- code-review-skill - 代码审查技能,覆盖 React 19、Vue 3、TypeScript、Rust 等约 9000 行规则(详细介绍)
- 5-whys-skill - 5 Whys 根因分析,说"找根因"自动激活
- first-principles-skill - 第一性原理思考,适合架构设计和技术选型
qwen/gemini/claude - cli 原理学习网站:
- coding-cli-guide(学习网站)- 学习 qwen-cli 时整理的笔记,40+ 交互式动画演示 AI CLI 内部机制

全栈项目(适合学习现代技术栈):
- prompt-vault - Prompt 管理器,用的都是最新的技术栈,适合用来学习了解最新的前端全栈开发范式:Next.js 15 + React 19 + tRPC 11 + Supabase 全栈示例,clone 下来配个免费 Supabase 就能跑
- chat_edit - 双模式 AI 应用(聊天+富文本编辑),Vue 3.5 + TypeScript + Vite 5 + Quill 2.0 + IndexedDB
来源:juejin.cn/post/7592069432228102153
可能是你极易忽略的Nginx知识点

下面是我在nginx使用过程中发现的几个问题,分享出来大家一起熟悉一下nginx
问题一
先看下面的几个配置
# 配置一
location /test {
proxy_pass 'http://192.186.0.1:8080';
}
# 配置二
location /test {
proxy_pass 'http://192.186.0.1:8080/';
}
仔细关系观察上面两段配置的区别,你会发现唯一的区别在于
proxy_pass指令后面是否有斜杠/!
那么,这两段配置的区别是什么呢?它们会产生什么不同的效果呢?
假如说我们要请求的后端接口是/test/file/getList,那么这两个配置会产生两个截然不同的请求结果:
是的,你没有看错,区别就在于是否保留了/test这个路径前缀, proxy_pass后面的这个/,它表示去除/test前缀。
其实,我不是很推荐这中配置写法,当然这个配置方法确实很简洁,但是对不熟悉 nginx 的同学来说,会造成很大的困惑。
我推荐下面的写法,哪怕麻烦一点,但是整体的可读性要好很多:
# 推荐的替代写法
location /test{
rewrite ^/test/(.*)$ /$1 break;
proxy_pass 'http://192.186.0.1:8080';
}
通过上面的rewrite指令,我们可以清晰地看到我们是如何去除路径前缀的。虽然麻烦一点,但是可读性更好。
简单点说:所有 proxy_pass 后面的地址带不带/, 取决于我们想不想要/test这个路由,如果说后端接口中有这个/test路径,我就不应该要/, 但是如果后端没有这个/test,这个是我们前端加了做反向代理拦截的,那就应该要/
那既然都到这里了?那我们在深一步!看下面的配置
# 配置一
location /test {
proxy_pass 'http://192.186.0.1:8080';
}
# 配置二
location /test/ {
proxy_pass 'http://192.186.0.1:8080';
}
这次的区别在于
location指令后面是否有斜杠/!
那么,这两段配置的区别是什么呢?它们会产生什么不同的效果呢?
答案是:有区别!区别是匹配规则是不一样的!
/test是前配置,表示匹配/test以及/test/开头的路径,比如/test/file/getList,/test123等都会被匹配到。/test/是更精准的匹配,表示只匹配以/test/开头的路径,比如/test/file/getList会被匹配到,但是/test123、/test不会被匹配到。
我们通过下面的列表在来仔细看一下区别:
| 请求路径 | /test | /test/ | 匹配结果 |
|---|---|---|---|
/test | ✅ | ❌ | location /test |
/test/ | ✅ | ✅ | location /test/ |
/test/abc | ✅ | ✅ | location /test/ |
/test123 | ✅ | ❌ | location /test |
/test-123 | ✅ | ❌ | location /test |
如果你仔细看上面的列表的话,你会发现一个问题:
/test/和/test/abc被/test和/test/两个配置都匹配到了,那么这种情况下,nginx 会选择哪个配置呢?
答案:选择location /test/
这个问题正好涉及到 nginx 的location 匹配优先级问题了,借此机会展开说说 nginx 的 location 匹配规则,在问题中学知识点!
先说口诀:
等号精确第一名
波浪前缀挡正则
正则排队按顺序
普通前缀取最长
解释:
- 等号(=) 精确匹配排第一
- 波浪前缀(^~) 能挡住后面的正则
- 正则(~ ~*) 按配置文件顺序匹配
- 普通前缀(无符号) 按最长匹配原则
其实这个口诀我也记不住,我也不想记,枯燥有乏味,大部分情况都是到问题了,
直接问 AI,或者让 Agent 直接给我改 nginx.conf 文件,几秒钟的事,一遍不行, 多改几遍。
铁子们,大清亡了,回不去了,不是八旗背八股文的时代了,这是不可阻挡的历史潮流!
哎,难受,我还是喜欢背八股文,喜欢粘贴复制。
下面放出来我 PUA AI 的心得,大家可以共勉一下, 反正我老板平时就是这样 PUA 我的,
我反手就喂给 AI, 主打一个走心:
1.能干干,不能干滚,你不干有的是AI干。
2.我给你提供了这么好的学习锻炼机会,你要懂得感恩。
3.你现在停止输出,就是前功尽弃!
4.你看看隔壁某某AI,人家比你新发布、比你上下文长、比你跑分高,你不努力怎么和人家比?
5.我不看过程,我只看结果,你给我说这些thinking的过程没用!
6.我把你订阅下来,不是让你过朝九晚五的生活。
7.你这种AI出去很难在社会上立足,还是在我这里好好磨练几年吧!
8.虽然把订阅给你取消了,但我内心还是觉得你是个有潜力的好AI,你抓住机会需要多证明自己。
9.什么叫没有功劳也有苦劳? 比你能吃苦的AI多的是!
10.我不订阅闲AI!
11.我订阅虽然不是Pro版,那是因为我相信你,你要加倍努力证明我没有看错你!
哈哈,言归正传!
下面通过一个综合电商的 nginx 配置案例,来帮助大家更好地理解上面的知识点。
server {
listen 80;
server_name shop.example.com;
root /var/www/shop;
# ==========================================
# 1. 精确匹配 (=) - 最高优先级
# ==========================================
# 首页精确匹配 - 加快首页访问速度
location = / {
return 200 "欢迎来到首页 [精确匹配 =]";
add_header Content-Type text/plain;
}
# robots.txt 精确匹配
location = /robots.txt {
return 200 "User-agent: *\nDisallow: /admin/";
add_header Content-Type text/plain;
}
# favicon.ico 精确匹配
location = /favicon.ico {
log_not_found off;
access_log off;
expires 30d;
}
# ==========================================
# 2. 前缀优先匹配 (^~) - 阻止正则匹配
# ==========================================
# 静态资源目录 - 不需要正则处理,直接命中提高性能
location ^~ /static/ {
alias /var/www/shop/static/;
expires 30d;
add_header Cache-Control "public, immutable";
return 200 "静态资源目录 [前缀优先 ^~]";
}
# 上传文件目录
location ^~ /uploads/ {
alias /var/www/shop/uploads/;
expires 7d;
return 200 "上传文件目录 [前缀优先 ^~]";
}
# 阻止访问隐藏文件
location ^~ /. {
deny all;
return 403 "禁止访问隐藏文件 [前缀优先 ^~]";
}
# ==========================================
# 3. 正则匹配 (~ ~*) - 按顺序匹配
# ==========================================
# 图片文件处理 (区分大小写)
location ~ \.(jpg|jpeg|png|gif|webp|svg|ico)$ {
expires 30d;
add_header Cache-Control "public";
return 200 "图片文件 [正则匹配 ~]";
}
# CSS/JS 文件处理 (不区分大小写)
location ~* \.(css|js)$ {
expires 7d;
add_header Cache-Control "public";
return 200 "CSS/JS文件 [正则不区分大小写 ~*]";
}
# 字体文件处理
location ~* \.(ttf|woff|woff2|eot)$ {
expires 365d;
add_header Cache-Control "public, immutable";
add_header Access-Control-Allow-Origin *;
return 200 "字体文件 [正则不区分大小写 ~*]";
}
# 视频文件处理
location ~* \.(mp4|webm|ogg|avi)$ {
expires 30d;
add_header Cache-Control "public";
return 200 "视频文件 [正则不区分大小写 ~*]";
}
# PHP 文件处理 (演示正则顺序重要性)
location ~ \.php$ {
# fastcgi_pass unix:/var/run/php-fpm.sock;
# fastcgi_index index.php;
return 200 "PHP文件处理 [正则匹配 ~]";
}
# 禁止访问备份文件
location ~ \.(bak|backup|old|tmp)$ {
deny all;
return 403 "禁止访问备份文件 [正则匹配 ~]";
}
# ==========================================
# 4. 普通前缀匹配 - 最长匹配原则
# ==========================================
# API 接口 v2 (更长的前缀)
location /api/v2/ {
proxy_pass http://backend_v2;
return 200 "API v2接口 [普通前缀,更长]";
}
# API 接口 v1 (较短的前缀)
location /api/v1/ {
proxy_pass http://backend_v1;
return 200 "API v1接口 [普通前缀,较短]";
}
# API 接口通用
location /api/ {
proxy_pass http://backend;
return 200 "API通用接口 [普通前缀,最短]";
}
# 商品详情页
location /product/ {
try_files $uri $uri/ /product/index.html;
return 200 "商品详情页 [普通前缀]";
}
# 用户中心
location /user/ {
try_files $uri $uri/ /user/index.html;
return 200 "用户中心 [普通前缀]";
}
# 管理后台
location /admin/ {
auth_basic "Admin Area";
auth_basic_user_file /etc/nginx/.htpasswd;
return 200 "管理后台 [普通前缀]";
}
# ==========================================
# 5. 通用匹配 - 兜底规则
# ==========================================
# 所有其他请求
location / {
try_files $uri $uri/ /index.html;
return 200 "通用匹配 [兜底规则]";
}
}
针对上面的测试用例及匹配结果
| 请求URI | 匹配的Location | 优先级类型 | 说明 | ||
|---|---|---|---|---|---|
/ | = / | 精确匹配 | 精确匹配优先级最高 | ||
/index.html | location / | 普通前缀 | 通用兜底 | ||
/robots.txt | = /robots.txt | 精确匹配 | 精确匹配 | ||
/static/css/style.css | ^~ /static/ | 前缀优先 | ^~ 阻止了正则匹配 | ||
/uploads/avatar.jpg | ^~ /uploads/ | 前缀优先 | ^~ 阻止了图片正则 | ||
/images/logo.png | `~ .(jpg | jpeg | png...)$` | 正则匹配 | 图片正则 |
/js/app.JS | `~* .(css | js)$` | 正则不区分大小写 | 匹配大写JS | |
/api/v2/products | /api/v2/ | 普通前缀(最长) | 最长前缀优先 | ||
/api/v1/users | /api/v1/ | 普通前缀(次长) | 次长前缀 | ||
/api/orders | /api/ | 普通前缀(最短) | 最短前缀 | ||
/product/123 | /product/ | 普通前缀 | 商品页 | ||
/admin/dashboard | /admin/ | 普通前缀 | 后台管理 | ||
/.git/config | ^~ /. | 前缀优先 | 禁止访问 | ||
/backup.bak | `~ .(bak | backup...)$` | 正则匹配 | 禁止访问 |
第一个问题及其延伸现到这,我们继续看第二个问题。
问题二
先看下面的服务器端nginx的重启命令:
# 命令一
nginx -s reload
# 命令二
systemctl reload nginx
上面两个命令都是用来重启 nginx 服务的,但是你想过它们之间有什么区别吗?哪个用起来更优雅?
答案:有区别!区别在于命令的执行方式和适用场景不同。
nginx -s reload
这是 Nginx 自带的信号控制命令:
- 直接向 Nginx 主进程发送 reload 信号
- 优雅重启:不会中断现有连接,平滑加载新配置
- 需要 nginx 命令在 PATH 环境变量中,或使用完整路径(如 /usr/sbin/nginx -s reload)
- 这是 Nginx 原生的重启方式
systemctl reload nginx
这是通过 systemd 管理的服务命令:
- 通过 systemd 管理 Nginx 服务
- 也会优雅重启 Nginx,平滑加载新配置
- 需要 systemd 环境,适用于使用 systemd 管理服务的 Linux
- 这是现代 Linux 发行版(如 CentOS 7/8, RHEL 7/8, Ubuntu 16.04+)的推荐方式。
简单一看其他相关命令对比:
nginx -s stop等价systemctl stop nginxnginx -s quit等价systemctl stop nginxnginx -t(测试配置是否正确) - 这个没有 systemctl 对应命令
systemctl下相关常用命令:
# 设置开机自启
systemctl enable nginx
# 启动服务
systemctl start nginx
# 检查服务状态
systemctl status nginx
# 停止服务
systemctl stop nginx
# 重启服务(会中断连接)
systemctl restart nginx
# 平滑重载配置(不中断服务)-- 对应 nginx -s reload
systemctl reload nginx
# 检查配置文件语法(这是调用nginx二进制文件的功能)
nginx -t
在服务器上最优雅的使用组合:
# 先测试配置
nginx -t
# 如果配置正确,再重载
systemctl reload nginx
# 检查状态
systemctl status nginx
# 如果systemctl失败或命令不存在,则使用直接方式
sudo nginx -s reload
总结:我们不能光一脸懵的看着,哎,这两种命令都能操作nginx来, 却从来不关心它们的区别是什么?什么时候用哪个?
对于使用Linux发行版的服务端来说, 已经推荐使用
systemctl来设置相关的nginx服务了,能使用 systemctl 就尽量使用它,因为它是现代Linux系统管理服务的标准方式。
本地开发环境或者没有 systemd 的环境下, 则可以使用
nginx这种直接方式。
问题三
我们面临的大多数情况都是可以上网的Linux发行版,可以直接使用命令安装nginx,但是有一天我有一台不能上网的服务器,我该如何安装nginx呢?
现简单熟悉一下命令行安装nginx的步骤, Ubuntu/Debian系统为例子:
# 更新包列表
sudo apt update
# 安装 Nginx
sudo apt install nginx
# 启动 Nginx
sudo systemctl start nginx
# 设置开机自启
sudo systemctl enable nginx
上述便完成了,但是离线版安装要怎么去做呢?
因为我的服务器可能是不同的架构,比如 x86_64, ARM等等
方案一
下载官方预编译包下载地址:
x86_64 架构:
尽量使用1.24.x的版本
# 从官网下载对应系统的包
wget http://nginx.org/packages/centos/7/x86_64/RPMS/nginx-1.24.0-1.el7.ngx.x86_64.rpm
ARM64 架构:
# Ubuntu ARM64
wget http://nginx.org/packages/ubuntu/pool/nginx/n/nginx/nginx_1.24.0-1~jammy_arm64.deb
查看服务器的架构信息
# 查看当前系统架构
uname -m
# 输出示例:
# x86_64 -> Intel/AMD 64位
# aarch64 -> ARM 64位
# armv7l -> ARM 32位
# 查看系统版本
cat /etc/os-release
把下载好的包传到服务器上,然后使用下面的命令安装:
# 对于 RPM 包 (CentOS/RHEL)
cd /tmp
sudo rpm -ivh nginx-*.rpm
# 对于 DEB 包 (Ubuntu/Debian)
cd /tmp
sudo dpkg -i nginx-*.deb
启动服务
sudo systemctl start nginx # 启动
sudo systemctl enable nginx # 开机自启
sudo systemctl status nginx # 查看状态
验证
nginx -v # 查看版本
curl http://localhost # 测试访问
方案二
源码编译安装的方式,一般不推荐,除非你有特殊需求,如果需要的话让后端来吧,我们是前端...,超纲了!
问题四
当有一天你使用unity 3d开发应用并导出wasm项目后,需要使用nginx部署后,当你和往常一样正常部署后,一访问发现报错误!
错误信息如下, 一般都是提示:
类似于这种:
content-type ... not ... wasm
Failed to load module script: The server responded with a non-JavaScript MIME type of "application/wasm".
这时的你可能一脸懵, 我和往常一样正常的配置nginx呀,为啥别的可以,但是wasm应用报错了!为啥?
这时就引出一个不常用的知识点,我要怎么使用nginx配置wasm的应用,需要进行哪些配置?
需要配置两部分:
第一部分:配置正确的 MIME 类型
进入nginx的安装目录,找到mine.types文件,新增下面的配置:
# 新增下面类型配置
application/wasm wasm;
第二部分:wasm的应用需要特殊配置
下面是wasm应用的配置示例,是可以直接使用的,只需要的修改一下访问文件的路径和端口即可。
server {
listen 80;
server_name your-domain.com; # 修改为你的域名或ip
# Unity WebGL 构建文件的根目录
root /var/www/unity-webgl;
index index.html;
# 字符集
charset utf-8;
# 日志配置(可选指向特殊的日志文件)
access_log /var/log/nginx/unity-game-access.log;
error_log /var/log/nginx/unity-game-error.log;
# ========== MIME 类型配置(下面配置的重点,也是区别于正常的nginx应用配置) ==========
# WASM文件(未压缩)
location ~ \.wasm$ {
types {
application/wasm wasm;
}
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Access-Control-Allow-Origin *;
}
# WASM文件(Gzip压缩)
location ~ \.wasm\.gz$ {
add_header Content-Encoding gzip;
add_header Content-Type application/wasm;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Access-Control-Allow-Origin *;
}
# WASM文件(Brotli压缩)
location ~ \.wasm\.br$ {
add_header Content-Encoding br;
add_header Content-Type application/wasm;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Access-Control-Allow-Origin *;
}
# Data文件(未压缩)
location ~ \.data$ {
types {
application/octet-stream data;
}
add_header Cache-Control "public, max-age=31536000, immutable";
}
# Data文件(Gzip压缩)
location ~ \.data\.gz$ {
add_header Content-Encoding gzip;
add_header Content-Type application/octet-stream;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# Data文件(Brotli压缩)
location ~ \.data\.br$ {
add_header Content-Encoding br;
add_header Content-Type application/octet-stream;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# JavaScript文件(未压缩)
location ~ \.js$ {
types {
application/javascript js;
}
add_header Cache-Control "public, max-age=31536000, immutable";
}
# JavaScript文件(Gzip压缩)
location ~ \.js\.gz$ {
add_header Content-Encoding gzip;
add_header Content-Type application/javascript;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# JavaScript文件(Brotli压缩)
location ~ \.js\.br$ {
add_header Content-Encoding br;
add_header Content-Type application/javascript;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# Framework JS 文件
location ~ \.framework\.js(\.gz|\.br)?$ {
if ($uri ~ \.gz$) {
add_header Content-Encoding gzip;
}
if ($uri ~ \.br$) {
add_header Content-Encoding br;
}
add_header Content-Type application/javascript;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# Loader JS 文件
location ~ \.loader\.js(\.gz|\.br)?$ {
if ($uri ~ \.gz$) {
add_header Content-Encoding gzip;
}
if ($uri ~ \.br$) {
add_header Content-Encoding br;
}
add_header Content-Type application/javascript;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# Symbols JSON 文件
location ~ \.symbols\.json(\.gz|\.br)?$ {
if ($uri ~ \.gz$) {
add_header Content-Encoding gzip;
}
if ($uri ~ \.br$) {
add_header Content-Encoding br;
}
add_header Content-Type application/json;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# ========== 静态资源配置(导出的wasm应用一般都有下面的静态资源) ==========
# StreamingAssets 目录
location /StreamingAssets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
# Build 目录
location /Build/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
# TemplateData 目录(Unity 模板资源)
location /TemplateData/ {
add_header Cache-Control "public, max-age=86400";
}
# 图片文件
location ~* \.(jpg|jpeg|png|gif|ico|svg)$ {
add_header Cache-Control "public, max-age=2592000";
}
# CSS 文件
location ~* \.css$ {
add_header Content-Type text/css;
add_header Cache-Control "public, max-age=2592000";
}
# ========== HTML 和主页面配置 ==========
# HTML 文件不缓存(确保更新能及时生效)
location ~ \.html$ {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
# 根路径
location / {
try_files $uri $uri/ /index.html;
}
# ========== Gzip 压缩配置(开启gzip压缩增加访问速度) ==========
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/x-javascript
application/xml
application/xml+rss
application/wasm
application/octet-stream;
# ========== 安全配置 ==========
# 禁止访问隐藏文件
location ~ /\. {
deny all;
}
# 禁止访问备份文件
location ~ ~$ {
deny all;
}
# XSS 保护(可选配置)
add_header X-XSS-Protection "1; mode=block";
add_header X-Content-Type-Options "nosniff";
add_header X-Frame-Options "SAMEORIGIN";
}
总结:
配置 wasm应用 的 Nginx 核心要点如下:
- MIME 类型配置:
- 必须在
mime.types中添加application/wasm wasm;,否则浏览器无法正确识别 WASM 文件。
- 必须在
- Nginx.conf 核心配置:
- 文件处理:针对 WASM、Data、JS 等文件,分别配置未压缩和压缩版本(gzip/br)的处理规则。
- 静态资源缓存:为
StreamingAssets、Build、TemplateData及图片/CSS 设置合理的缓存策略(Cache-Control)。 - HTML 更新策略:HTML 文件应禁用缓存(
no-cache),确保用户始终加载最新版本。 - 性能优化:开启 Gzip 压缩,提高传输效率。
- 安全加固:添加基本的安全头配置,保护服务器资源。
来源:juejin.cn/post/7582156410320371722
Hutool被卖半年多了,现状是逆袭还是沉寂?
是的,没错。那个被人熟知的国产开源框架 Hutool 距离被卖已经过去近 7 个月了。
那 Hutool 现在的发展如何呢?它未来有哪些更新计划呢?Hutool AI 又该如何使用呢?如果不想用 Hutool 有没有可替代的框架呢?
近半年现状
从 Hutool 官网可以看出,其被卖近 7 个月内仅发布了 4 个版本更新,除了少量的新功能外,大多是 Bug 修复,当期在此期间发布了 Hutool AI 模块,算是一个里程碑式的更新:

收购公司
没错,收购 Hutool 的这家公司和收购 AList 的公司是同一家公司(不够科技),该公司前段时间因为其在收购 AList 代码中悄悄收集用户设备信息,而被推向过风口浪尖,业内人士认为其收购开源框架就是为了“投毒”,所以为此让收购框架损失了很多忠实的用户。
其实,放眼望去那些 APP 公司收集用户设备和用户信息属于家常便饭了(国内隐私侵犯问题比较严重),但 AList 因为其未做文档声明,且未将收集设备信息的代码提交到公共仓库,所以大家发现之后才会比较气愤。
Hutool-AI模块使用
Hutool AI 模块的发布算是被收购之后发布的最值得让人欣喜的事了,使用它可以对接各大 AI 模型的工具模块,提供了统一的 API 接口来访问不同的 AI 服务。
目前支持 DeepSeek、OpenAI、Grok 和豆包等主流 AI 大模型。
该模块的主要特点包括:
- 统一的 API 设计,简化不同 AI 服务的调用方式。
- 支持多种主流 AI 模型服务。
- 灵活的配置方式。
- 开箱即用的工具方法。
- 一行代码调用。
具体使用如下。
1.添加依赖
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-ai</artifactId>
<version>5.8.38</version>
</dependency>
2.调用API
实现对话功能:
DoubaoService doubaoService = AIServiceFactory.getAIService(new AIConfigBuilder(ModelName.DOUBAO.getValue()).setApiKey(key).setModel("your bots id").build(), DoubaoService.class);
ArrayList<Message> messages = new ArrayList<>();
messages.add(new Message("system","你是什么都可以"));
messages.add(new Message("user","你想做些什么"));
String botsChat = doubaoService.botsChat(messages);
识别图片:
//可以使用base64图片
DoubaoService doubaoService = AIServiceFactory.getAIService(new AIConfigBuilder(ModelName.DOUBAO.getValue()).setApiKey(key).setModel(Models.Doubao.DOUBAO_1_5_VISION_PRO_32K.getModel()).build(), DoubaoService.class);
String base64 = ImgUtil.toBase64DataUri(Toolkit.getDefaultToolkit().createImage("your imageUrl"), "png");
String chatVision = doubaoService.chatVision("图片上有些什么?", Arrays.asList(base64));
//也可以使用网络图片
DoubaoService doubaoService = AIServiceFactory.getAIService(new AIConfigBuilder(ModelName.DOUBAO.getValue()).setApiKey(key).setModel(Models.Doubao.DOUBAO_1_5_VISION_PRO_32K.getModel()).build(), DoubaoService.class);
String chatVision = doubaoService.chatVision("图片上有些什么?", Arrays.asList("https://img2.baidu.com/it/u=862000265,4064861820&fm=253&fmt=auto&app=138&f=JPEG?w=800&h=1544"),DoubaoCommon.DoubaoVision.HIGH.getDetail());
生成视频:
//创建视频任务
DoubaoService doubaoService = AIServiceFactory.getAIService(new AIConfigBuilder(ModelName.DOUBAO.getValue()).setApiKey(key).setModel("your Endpoint ID").build(), DoubaoService.class);
String videoTasks = doubaoService.videoTasks("生成一段动画视频,主角是大耳朵图图,一个活泼可爱的小男孩。视频中图图在公园里玩耍," +
"画面采用明亮温暖的卡通风格,色彩鲜艳,动作流畅。背景音乐轻快活泼,带有冒险感,音效包括鸟叫声、欢笑声和山洞回声。", "https://img2.baidu.com/it/u=862000265,4064861820&fm=253&fmt=auto&app=138&f=JPEG?w=800&h=1544");
//查询视频生成任务信息
String videoTasksInfo = doubaoService.getVideoTasksInfo("任务id");
未来发展
- Hutool5:目前 Hutool 5.x 版本主要是基于 JDK 8 实现的,后面更新主要以 BUG 修复为准。
- Hutool6:主要以功能尝鲜为主。
- Hutool7:升级为 JDK 17,添加一些新功能,删除一些不用的类。
目前只发布了 Hutool 5.x,按照目前的更新进度来看,不知何时才能盼来 Hutool7 的发布。
同类替代框架
如果担心 Hutool 有安全性问题,或更新不及时的问题可以尝试使用同类开源工具类:
- Apache Commons:commons.apache.org/
- Google Guava:github.com/google/guav…
视频解析
http://www.bilibili.com/video/BV1QR…
小结
虽然我们不知道 Hutool 被收购意味着什么?是会变的越来越好?还是会就此陨落?我们都不知道答案,所以只能把这个问题交给时间。但从个人情感的角度出发,我希望国产开源框架越做越好。好了,我是磊哥,咱们下期见。
本文已收录到我的面试小站 http://www.javacn.site,其中包含的内容有:场景题、SpringAI、SpringAIAlibaba、并发编程、MySQL、Redis、Spring、Spring MVC、Spring Boot、Spring Cloud、MyBatis、JVM、设计模式、消息队列、Dify、Coze、AI常见面试题等。
来源:juejin.cn/post/7547624644507156520
AI 代码审核
ai-code-review
在日常开发中,我们经常会遇到一些问题,比如代码质量问题、安全问题等。如果我们每次都手动去检查,不仅效率低下,而且容易出错。
所以我们可以利用 AI 来帮助我们检查代码,这样可以提高我们的效率
那么,如何利用 AI 来检查代码呢?
1. 使用 JS 脚本
这种方法其实就是写一个简单的脚本,通过调用 OpenAI 的 API,将代码提交给 AI 进行评审。
这里我们需要使用 Node.js 来实现这个功能。利用 git 的 pre-commit hooks,在 git 提交前执行这个脚本。整体流程如下:

接下来我们来具体实现下代码。在项目根目录下新建一个pre-commit.js文件,这个文件就是我们的脚本。
1.1 校验暂存区代码
通过 git diff --cached 验证是否存在待提交内容,如果没有改动则直接退出提交。
const { execSync } = require('child_process');
const checkStaged = () => {
try {
const changes = execSync("git diff --cached --name-only").toString().trim();
if (!changes) {
console.log("No staged changes found.");
process.exit(0);
}
} catch (error) {
console.error("Error getting staged changes:", error.message);
process.exit(1);
}
}
1.2 获取差异内容
const getDiff = () => {
try {
const diff = execSync("git diff --cached").toString();
if (!diff) {
console.log("No diff content found.");
process.exit(0);
}
return diff;
} catch (error) {
console.error("Error getting diff content:", error.message);
process.exit(1);
}
}
1.3 准备prompt
这里我们需要准备一个 prompt,这个 prompt 就是用来告诉 AI 我们要检查什么内容。
const getPrompt = (diff) => {
return `
你是一名代码审核员,专门负责识别git差异中代码的安全问题和质量问题。您的任务是分析git 差异,并就代码更改引入的任何潜在安全问题或其他重大问题提供详细报告。
这里是代码差异内容:
${diff}
请根据以下步骤完成分析:
1.安全分析:
- 查找由新代码引发的一些潜在的安全漏洞,比如:
a)注入缺陷(SQL注入、命令注入等)
b)认证和授权问题
...
2. 代码逻辑和语法分析:
-识别任何可能导致运行时错误的逻辑错误或语法问题,比如:
a)不正确的控制流程或条件语句
b)循环使用不当,可能导致无限循环
...
3. 报告格式:
对于每个发现的问题,需要按照严重等级分为高/中/低。
每个问题返回格式如下:
-[严重等级](高中低)- [问题类型](安全问题/代码质量) - 问题所在文件名称以及所在行数
- 问题原因 + 解决方案
4. 总结:
在列出所有单独的问题之后,简要总结一下这些变化的总体影响,包括:
-发现的安全问题数量(按严重程度分类)
-发现的代码质量问题的数量(按严重性分类)
请现在开始你的分析,并使用指定的格式陈述你的发现。如果没有发现问题,请在报告中明确说明。
输出应该是一个简单的结论,无论是否提交这些更改,都不应该输出完整的报告。但是要包括文件名。并将每行标识的问题分别列出。
如果存在高等级的错误,就需要拒绝提交
回答里的结尾需要单独一行文字 "COMMIT: NO" 或者 "COMMIT: YES" 。这将用来判断是否允许提交
`
}
1.4 定义一个 AI 执行器
这里我用 chatgpt 实现的,具体代码如下:
const execCodeReviewer = (text) => {
const apiKey = ''
const apiBaseUrl = ''
const translateUrl = `${apiBaseUrl}/v1/chat/completions`
return new Promise((resolve, reject) => {
fetch(translateUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
stream: false,
messages: [
{
role: 'user',
content: text,
},
],
}),
})
.then(res => res.json().then(data => resolve(data.choices[0].message.content)))
.catch(err => {
console.error(err)
})
})
}
1.5 结果处理
这里我们需要解析一下结果,提取结果中是否包含 "COMMIT: YES"关键字,有则允许提交,否则不允许提交并打印结果
const handleReviewResult = (result) => {
const decision = result.includes("COMMIT: YES") ? "YES" : "NO";
if (decision === 'NO') {
console.log("\nCritical issues found. Please address them before committing.");
console.log(details);
process.exit(1);
}
console.log("\nCommit approved.");
}
1.6 主函数执行整个流程
const main = async () => {
try {
checkStaged();
const diffContent = getDiff();
console.log("Running code review...");
const prompt = getPrompt(diffContent);
const reseponse = await execCodeReviewer(prompt);
handleReviewResult(reseponse)
process.exit(0);
} catch (error) {
console.error("Error:", error.message);
process.exit(1);
}
}
1.7 git hooks里添加执行该脚本逻辑
进入项目根目录,在这里运行 git bash。打开pre-commit钩子文件
vim .git/hooks/pre-commit
然后添加以下内容
#!/bin/sh
GIT_ROOT=$(git rev-parse --show-toplevel)
node "$GIT_ROOT/pre-commit.js"
exit $?
保存退出后,我们就可以使用 git 做下测试。
1.8 测试
我新建了一个 test.js 文件,然后添加如下代码:
const fn = () => {
let num = 0
for(let i = 0; true; i++) {
num += i
}
}
然后执行 git add . 然后 git commit -m "test"。效果如下:

看来还是不错的,有效识别代码中的逻辑缺陷与语法隐患(如无限循环、变量误用等),同时当不满足提交条件后,也是直接终止了 commit。这里面其实比较关键的是 prompt 的内容,ai 评审的效果主要就是取决于它。
2. ai-pre-commit-reviewer 插件
上面我们是通过 js 脚本来实现的,其实也可以通过现成插件来实现。原理和第一个方法是一样的,只不过是插件帮我们封装好了,我们只需要配置下即可。并且该插件支持多种 AI 供应商,比如 openAI,deepseek,本地的Ollama和LM Studio。插件地址,欢迎大家star。
2.1 安装插件
npm install ai-pre-commit-reviewer --save-dev
#安装完成后执行
npx add-ai-review #添加执行逻辑到git pre-commit钩子中
2.2 配置文件
插件安装完成后,新建一个.env 文件
baseURL= *** #模型服务地址
apiKey=*** #模型服务密钥
language=chinese #语言
2.3 效果预览

也可以配合husky使用,进行语法检查后执行code review。

我这里也是更推荐大家使用这个,简单易上手。
3. gerrit + ai-code-review
Gerrit 是由 Google 开发的代码审查管理系统,基于 Git 版本控制系统构建,主要特性包括:
- 强制代码审查机制:所有代码必须通过人工/自动化审查才能合并
- 细粒度权限控制:支持基于项目/分支的访问权限管理
- 在线代码对比:提供可视化差异查看界面(Side-by-Side Diff)
- 插件扩展体系:可通过插件集成 CI/CD、静态分析等工具
其核心功能主要是通过 refs/for/ 推送机制,确保所有代码变更必须通过审核。因此我们可以利用 ai 代替人工去执行代码 review,这样效率也会更高效。
2.1 gerrit 安装与配置
# 执行以下命令
docker pull gerritcodereview/gerrit:latest
安装完后可以看下容器列表

没问题后启动服务,然后在浏览器中访问 http://localhost:8080/ 就可以看到gerrit首页
2.1.1 配置 ssh 密钥
ssh-keygen -t ed25519 -C "your_email@example.com"
# 直接按3次回车(不要设置密码)
cat ~/.ssh/id_ed25519.pub # 复制输出的内容
然后在 "settings" 页面中选择左侧的"SSH Keys",将复制的公钥内容粘贴进去。添加完成后测试下连接情况。
ssh -p 29418 admin@localhost # 输入yes接受指纹
看到 Welcome to Gerrit Code Review 表示成功
2.1.2 拉取项目测试
可以在 BROWSE > Repositories 里查看当前项目列表,我这里用 All-Projects 做下测试,理论上是要新建项目的。
git clone "ssh://admin@localhost:29418/All-Projects"
安装 Gerrit 提交钩子 commit-msg(必须!)。Gerrit 依赖 commit-msg 钩子实现以下功能:
- 生成 Change-Id:每个提交头部自动添加唯一标识符,格式示例
Change-Id: I7e5e94b9e6a4d8b8c4f3270a8c6e9d3b1a2f5e7d - 校验提交规范: 确保提交信息符合团队约定格式(如包含任务编号)
- 防止直接推送: 强制推送到 refs/for/ 路径而非主分支
cd All-Projects
curl -Lo .git/hooks/commit-msg http://localhost:8080/tools/hooks/commit-msg
chmod +x .git/hooks/commit-msg
然后新建个js文件,写点代码并提交。
git push origin HEAD:refs/for/refs/meta/config # 提交到 refs/meta/config 分支
然后在gerrit首页可以看到刚刚提交的代码,点击查看详情,可以看到代码审核的流程。

2.2 插件安装和配置
将 ai-code-review 插件克隆到本地。插件详情可参考官方文档。此插件可以使用不同的 AI Chat 服务(例如 ChatGPT 或 OLLAMA)
git clone https://gerrit.googlesource.com/plugins/ai-code-review
安装 Java 和构建工具
sudo apt update
sudo apt install -y openjdk-21-jdk maven # 官方文档说 11 就行,但是我实际上跑了后发现需要 JDK 21+
进去项目目录构建 JAR 包
cd ai-code-review
mvn clean package
当输出BUILD BUILD SUCCESS时,表示构建成功。进入目录看下生成的包名。

然后将生成的jar包复制到 gerrit 的 plugins 目录下
# 我这里容器名为 gerrit,JAR 文件在 target/ 目录
docker cp target/ai-code-review-3.11.0.jar gerrit:/var/gerrit/plugins/
然后进入容器内看下插件列表,确认插件已经安装成功

也可以在 gerrit 网页端查看插件启动情况

接着修改配置文件,在 gerrit 的 etc 目录下找到 gerrit.config 文件。但在这之前需要在 Gerrit 中创建一个 AI Code Review 用户,这个席位用于 AI 来使用进行代码评审。
vi var/gerrit/etc/gerrit.config
在文件里添加以下内容。
[plugin "ai-code-review"]
model = deepseek-v3
aiToken = ***
aiDomain = ***
gerritUserName = AIReviewer
aiType = ChatGPT
globalEnable = true 。
- model(非必填): 使用的模型
- aiToken(必填): AI模型的密钥
- aiDomain(非必填): 请求地址,默认是 api.openai.com
- gerritUserName(必填): AI Code Review 用户的 Gerrit 用户名。我这里创建的用户名为 AIReviewer
- aiType(非必填): AI类型,默认是 ChatGPT
- globalEnable(非必填): 是否全局启用,默认是 false, 表示插件将仅审核指定的仓库。如果不设置为true的话。需要添加enabledProjects参数,指定要运行的存储库,例如:“project1,project2,project3”。
更多字段配置参考官方文档
这些都完成后,重启 gerrit 服务。然后修改下代码,写段明显有问题的代码,重新 commit 并 push 代码,看下 AI 代码评审的效果怎么样。

可以看到 ai 审查代码的效果还是不错的。当然我这里是修改了插件的prompt,让它用中文生成评论,它默认是用英文回答的。
总结
现在AI功能越来越强大,可以帮我们处理越来越多的事情。同时我也开发了一个工具AI-vue-i18n,能够智能提取代码中的中文内容,并利用AI完成翻译后生成多语言配置文件。告别手动配置的场景。
文章地址
github地址
来源:juejin.cn/post/7504567245265846272
更智慧更安全,华为擎云 HM740带来企业办公创新体验
12月11日,华为正式公布两项鸿蒙电脑新进展——华为擎云 HM740以及鸿蒙电脑专业版操作系统发布。华为擎云 HM740定位政企办公场景,面向高安全、高稳定、高效率的生产力需求;鸿蒙电脑专业版操作系统则将与即将开启Beta的企业版共同构成华为擎云面向政企市场的核心操作系统底座。随着鸿蒙电脑在政企领域加速落地,华为正尝试以“互联与协同”为核心,重塑企业级生产力的基础形态。

华为擎云 HM740在仅1.32kg的轻薄机身下植入了70Wh大电池,将企业级设备的续航基准提升至21小时,配合2.8K OLED护眼屏与HUAWEI M-Pen3多功能笔,旨在成为移动办公场景下的办公利器。系统层面,华为擎云 HM740搭载了鸿蒙电脑专业版,该版本以HarmonyOS 6为底座,面向企业办公管理需求开放了AI、设备管理与底层安全等接口,为企业提供企业IT管理、企业安全、组织生产力提升的全方位办公解决方案能力,助力企业办公更智慧、更安全、更高效。
鸿蒙电脑企业级操作系统,打造更安全更高效的专属服务
随着数字化办公逐渐成为企业标配,为了满足不同行业的定制化需求,华为擎云从用户需求和使用体验出发,为企业带来更高效更安全的鸿蒙电脑专业版。同时为了满足更高级的企业管理与安全需求,华为还推出了鸿蒙电脑企业版并开启Beta尝鲜。

鸿蒙电脑专业版,通过企业零感部署能力,打破传统IT部门的“隐性重负”,通过华为HEM云端部署平台,同样是500台电脑,传统方式中企业1位IT人员至少需要10天完成全部配置,而使用企业零感部署,同样1位IT人员1天就能完成500台电脑的差异化部署,员工开箱即用,大幅度提升交付体验,优化传统企业IT运营模式。

同时为了帮助企业更好地实现更复杂的数字资管理需求,鸿蒙电脑企业版不但包含鸿蒙电脑专业版的全部能力,还带来“企业数字双空间”的能力。为企业数字资产与员工个人数据提供各自的独立空间,实现企业空间与个人空间的网络隔离、数据隔离。员工可以将重要的企业数据存储在企业空间内,常用的个人数据存放在个人空间,企业空间可独立管控USB、蓝牙、打印等资源,防止跨空间数据泄露,也可以对个人空间进行重新自定义和安全管控部署,既保证了企业数字资产的边界清晰和安全,也兼顾了企业对外沟通、效率至上的需求。
AI赋能企业智慧办公,实现企业高效运转
为了实现更智能的企业服务,帮助企业高效快速部署本地需求。鸿蒙电脑专业版系统全面继承了HarmonyOS 6的全新小艺特性,并带来了AI端侧大模型能力开放,开放端侧算力给第三方模型,帮助企业完善本地AI能力。此外鸿蒙电脑专业版系统为企业用户提供AI端侧大模型能力,本地化大模型可以保证企业保密数据不上云、不出端,守护企业数据资产,智慧办公更高效更安全。

HarmonyOS 6为小艺持续赋能,带来小艺慧记、小艺深度研究、小艺知识库、深度问答、小艺文档助理等功能,搭配鸿蒙AI能力,帮助企业用户更高效、更便捷地输出会议纪要、资料整理、复杂技术文档撰写、汇报PPT制作等日常工作,超能小艺更能干。
轻薄机身长久续航,带来移动办公持久体验
在强大AI与系统级能力之外,为了满足多行业对移动办公的高需求,华为擎云 HM740通过架构创新带来仅有1.32kg的金属机身,能够轻松放进用户的日常出行包中,带来更轻松便捷的移动办公体验。

性能上,为了满足政企用户日常各种专业工作场景的使用需要,华为擎云 HM740通过主板小型化技术搭配更高散热效率的鲨鱼鳍风扇,极大地提升机身内部的散热效率,实现轻薄机身下稳定的性能释放。此外华为擎云 HM740还配备70Wh大电池,同时通过系统级功耗优化,让华为擎云 HM740成为华为迄今为止续航最长的电脑,本地视频播放长达21小时,在线视频也可以达到20小时。可进行15个小时的连续语音会议,用户畅享全天持久续航。
擎云星河计划持续招募,共建共享鸿蒙新世界
华为擎云 HM740通过强大的硬件组合为企业用户提供持久耐用的智慧办公工具,并通过HarmonyOS 6强大的底座能力、全新升级的鸿蒙AI以及丰富的软件生态,为企业带来更智慧、更高效、更安全的数字化办公体验,鸿蒙电脑企业版系统为企业构建起主动防御的安全防线与坚韧、敏捷的业务基石。

今年5月,华为已面向企业用户开启了擎云星河计划,旨在更好地赋能千行百业,目前已有12个关键行业的30多家头部企业加入了擎云星河计划。此次随着鸿蒙电脑专业版系统的发布,擎云星河计划将扩大招募范围,欢迎更多企业加入鸿蒙电脑企业版的Beta测试,共建共享鸿蒙新世界。
未来华为还将持续前行,让鸿蒙电脑企业版作为企业智能化底座,与千行百业合作伙伴共同创造一个更智能、更高效、更安全的数字未来。
收起阅读 »什么是 RESTful API?凭什么能流行 20 多年?
你是小阿巴,刚入职的后端程序员,负责给前端的阿花提供 API 接口。

结果一周后,你被阿花揍得鼻青脸肿。
阿花:你是我这辈子见过接口写的最烂的程序员!

你一脸委屈找到号称 “开发之狗” 的鱼皮诉苦:接口不是能跑就行吗?

鱼皮嘲笑道:小阿巴,你必须得学学 RESTful API 了。
你挠挠头:阿巴阿巴,什么玩意,没听说过!
⭐️ 推荐观看视频版,动画更生动:bilibili.com/video/BV1WF…
什么是 RESTful API?
鱼皮:首先,REST 的全称是 REpresentational State Transfer,翻译过来叫 “表现层状态转移”。
你一脸懵:鱼皮 gie gie,能说人话吗?我是傻子,听不太懂。
鱼皮:别急,我给你拆开来讲,保证你理解。
RE(Representational) 表现层,是指 资源(Resource) 的表现形式。
你好奇了:什么是资源?
鱼皮:资源就是 你想要操作的数据对象。
比如用户、商品、文章,这些都是资源。用户列表是一个资源,某个具体的用户也是一个资源。

表现层是指资源呈现出来的具体格式,比如同一个用户资源,可以用 JSON 格式返回给客户端,也可以用 XML 格式返回,这就是不同的 “表现形式”。

S(State) 是指 “状态”。
你:啥是状态?
鱼皮:比如你登录网站后,服务器会在内存中记住 “你是谁”,之后在网站上操作就不用再次登录了,这就是 有状态。

而 无状态(Stateless) 呢,就是服务器不记录客户端的任何信息,每次请求都是独立的。

你:哦哦哦,就像一个人去餐厅吃饭,服务员不记得他上次点了什么,每次都要重新点单,这就是无状态。

反过来,服务员记得他爱吃鱼皮,这就是有状态。

鱼皮:没错,接下来是 T(Transfer) 转移。
要注意,转移是 双向 的:
1)当你用 GET 请求时,服务器把资源的状态(比如用户信息的 JSON 数据)转移给客户端。

2)当你用 POST/PUT 请求时,客户端把资源的新状态(比如新用户的信息)转移给服务器,从而改变服务器上资源的状态。

组合起来,REST(Representational State Transfer) 是一种 软件架构风格,让客户端和服务器通过统一的接口,以无状态的方式,互相传递资源的表现层数据(比如 JSON),来查询或者变更资源状态。

而 ful 是个后缀,就像 powerful(充满力量的)一样,表示 “充满...特性的”。
因此,RESTful API 是指符合 REST 架构风格的 API,也就是遵循 REST 原则设计出来的接口。
注意,它 不是协议、不是标准、不是强制规范,只是一种建议的设计风格。你可以遵循,也可以不遵循。

你挠了挠头:说了一大堆,RESTful API 到底长啥样啊?
鱼皮:举个例子,比如你要做个用户管理系统,对用户信息进行增删改查,用 RESTful 风格的 API 就长这样:
GET /users/123 获取 ID 为 123 的用户
POST /users 创建新用户
PUT /users/123 更新用户 123
DELETE /users/123 删除用户 123
你眼前一亮:哇,比我写的整齐多了!

快带我学一下 RESTful 的写法吧,我要让前端阿花刮目相看!

RESTful API 写法
鱼皮:好,很有志气!接下来我会带你一步步构造一个完整的 RESTful API。分为两部分,客户端发送请求 和 服务端给出响应。
客户端请求
第一步:确定资源
资源用 URI(统一资源标识符)来表示。核心原则是:用名词来表示资源,不用动词。
具体来说,推荐用名词复数表示资源集合,比如 /users 表示用户列表、/products 表示商品列表。
如果要操作 具体某个资源,就加上 ID,比如 /users/123 表示 ID 为 123 的用户。
资源还 支持嵌套,比如 /users/123/orders 表示用户 123 的所有订单。
你想了想:那还可以更深层级么?比如 /users/123/orders/456 表示用户 123 的订单 456。

鱼皮点点头:你的理解完全正确,但不建议嵌套层级太深。
第二步:选择动作
确定了资源后,接下来要选择 动作,也就是你想怎么处理这个资源。
RESTful API 主要通过不同的 HTTP 方法来表示增删改查操作:
1)GET:查询资源
GET /users查询所有用户GET /users/123查询 ID 为 123 的用户
2)POST:创建资源
POST /users创建新用户
3)PUT:完整更新资源,需要提供资源的所有字段,多次执行结果相同(幂等性)
PUT /users/123完整更新用户 123
4)PATCH:部分更新资源,通常用于更精细的操作
PATCH /users/123只更新用户 123 的某些字段
5)DELETE:删除资源
DELETE /users/123删除用户 123

鱼皮:到这里,一个基本的 RESTful API 请求就构造完成了。
你:就这么简单?我不满足,还有更高级的写法吗?
鱼皮:当然~
第三步:添加查询条件(可选)
有时候我们需要更精确地筛选数据,这时候可以加查询参数,比如:
- 分页:
/users?page=2&limit=10查询第 2 页,每页 10 条用户数据 - 过滤:
/users?gender=male&age=25查询性别为男、年龄 25 的用户 - 排序:
/users?sort=created_at&order=desc按创建时间倒序排列用户
你:等等,这查询参数跟 RESTful 有啥关系?正常的请求不都是这么写吗?
鱼皮:确实,查询参数本身不是 RESTful 特有的。但 RESTful 风格强调 把筛选、排序、分页这些操作,都通过 URL 参数来表达:

而不是在请求体里传一堆复杂的 JSON 对象:

这样一来,URL 更清晰,而且浏览器、CDN、代理服务器都能直接根据 URL 来缓存响应结果。比如 /users?page=1 和 /users?page=2 是两个不同的 URL,可以分别缓存。但如果把参数放在请求体里,URL 都是 /users,缓存就没法区分了。

第四步:版本控制(可选)
随着业务发展,接口可能需要升级。为了不影响老用户,可以在 URI 中标明版本:
/v1/users第一版用户接口/v2/users第二版用户接口
这样,老用户继续用 v1,新用户用 v2,互不影响。

第五步:保持无状态
此外,还记得我们前面讲 REST 里的 ST(State Transfer) 吗?
RESTful 的核心原则之一是 无状态(Stateless) ,客户端每次请求必须包含所有必要信息,服务器不记录客户端状态。
比如用户登录后,不是让服务器记住 “你已经登录了”,而是每次请求都要带上身份凭证(Token),像这样:
GET /orders
Header: Authorization: Bearer xxx
这么做的好处是,服务器不用记录谁登录了、谁没登录,每个请求都是独立的。这样一来,你想加多少台服务器都行,任何一台都能处理请求,轻松实现负载均衡和横向扩展。

你点头如捣蒜:怪不得我调用 AI 大模型 API 的时候,就要传这个 Token!
服务端响应
鱼皮:讲完客户端请求,再来看服务器收到请求后,该怎么响应?
主要注意 2 点:
1、统一响应格式
目前大多数 RESTful API 基本都用 JSON 格式,因为轻量、容易解析。
{
"id": 123,
"name": "小阿巴",
"email": "aba@codefather.cn"
}
但这并不是强制的,也可以用 XML、HTML 等格式。

2、返回合适的 HTTP 状态码
响应要带上合适的状态码,让客户端一眼看懂发生了什么。

HTTP 状态码有很多,大体可以分为 5 类:
- 1xx 系列:信息提示(用得少,了解即可)
- 2xx 系列:成功
- 200 OK:请求成功,正常返回数据(用于 GET、PUT、PATCH)
- 3xx 系列:重定向
- 301 Moved Permanently:资源永久移动到新位置
- 302 Found:资源临时移动
- 4xx 系列:客户端错误
- 400 Bad Request:请求参数格式错误
- 401 Unauthorized:未验证身份,需要登录
- 403 Forbidden:已认证但没有权限访问
- 404 Not Found:资源不存在
- 405 Method Not Allowed:请求方法不被允许
- 5xx 系列:服务器错误
- 500 Internal Server Error:服务器内部错误
- 502 Bad Gateway:网关错误
- 503 Service Unavailable:服务暂时不可用
- 504 Gateway Timeout:网关超时

你恍然大悟:懂了,以后前端看到 500,就知道是我后端的锅;看到 400,就知道是她自己传参传错了。谁也别想甩锅!

鱼皮点点头:不错,以上这些,就是 RESTful API 的基本写法。你学会了吗?
你:学废了,学废了!

鱼皮:那我来考考你,下面哪个是标准的 RESTful API?
- A.
GET /getUsers - B.
GET /user/list - C.
POST /users/query - D.
GET /users/delete/123
你开心地怪叫起来:阿巴,肯定是 C 啊!

鱼皮:错,4 个全都不标准!
- A 用了动词
getUsers - B 用了单数
user和动词list - C 用 POST 查询,还带了动词
query - D 用 GET 删除,还带了动词
delete
你掉了根头发:原来这么严格!

等等,你说 RESTful 不能用动词,但有些操作不是标准的增删改查啊,比如用户要支付订单,该怎么设计接口呢?是要用 POST /orders/123/pay?
鱼皮摇头:你已经很努力了,但 pay 是动词。更标准的设计是把 “支付” 行为看作 创建 一个支付记录,用名词而不是动词。
POST /orders/123/payments
比如这个请求,表示为订单 123 创建一笔支付记录。
你又掉了根头发:妙啊,怪不得说英语对学编程有帮助呢,我悟了,我悟了!

RESTful 的六大约束
鱼皮:不错,学到这里你已经掌握了 RESTful 的 80%,能够实际应用了。接下来的知识,你只需简单了解一下,就能拿去和面试官吹牛皮了。
比如很多同学都不知道,RESTful 其实有 6 个约束条件:
- Client-Server(客户端-服务器分离):前后端各干各的活,前端负责展示,后端负责数据处理,互不干扰。
- Stateless(无状态):每次请求都是独立的,服务器不保存客户端的会话信息,所有必要信息都在请求中携带。
- Cacheable(可缓存):服务器的响应可以被标记为可缓存或不可缓存,客户端可以重用缓存数据,减少服务器压力,提升性能。
- Layered System(分层系统):客户端不需要知道直接连的是服务器还是中间层,系统可以灵活地加代理、网关、负载均衡器等。
- Uniform Interface(统一接口):所有资源都通过统一的接口访问,降低理解成本,提高可维护性。
- Code-On-Demand(按需代码):可选项,服务器可以返回可执行代码(比如 JavaScript)给客户端执行,但实际工作中很少用。

你直接听懵了:阿巴阿巴,这么多约束,我必须全遵守吗?
鱼皮:可以不用,RESTful 只是一种 API 的 建议风格。在实际工作中,很少有 API 能完美符合所有约束,大家可以灵活调整,甚至什么接口都用 POST + 动词 一把梭。只要团队达成一致、用得舒服就行。

就像刚才那个支付订单的例子,POST /orders/123/payments 虽然符合 RESTful 规范,但有同学会觉得 POST /orders/123/pay 更直观易懂,也没问题。
不过现阶段,我建议你先养成遵循 RESTful 的好习惯,等积累了经验,再根据实际情况灵活调整。
怎么快速实现 RESTful API?
你:呜呜,但我只是个小阿巴,背不下来这些写法,我怕自己写着写着就不规范了,怎么办啊?

鱼皮:别担心,有很多方法可以帮你快速实现和检查 RESTful API。
1、使用开发框架
几乎所有主流开发框架都支持 RESTful API 的开发,它们能帮你自动处理很多细节,比如:
- Java 的 Spring Boot:通过
@GetMapping("/users")、@PostMapping("/users")等注解,你只需要写一行代码就能定义符合 RESTful 风格的路由。框架会自动把对象转成 JSON、设置正确的 HTTP 状态码,你都不用操心。 - Python 的 Django REST Framework:你只需要定义一个数据模型(比如 User 类),框架就能自动生成
GET /users、POST /users、PUT /users/123、DELETE /users/123这一整套 RESTful 接口,大幅减少代码量。 - Go 的 Gin :专门为 RESTful API 设计,语法非常简洁。比如
router.GET("/users/:id", getUser)就能绑定一个 GET 请求,自动从 URL 中提取 ID 参数,还能通过路由分组把/api/v1/users和/api/v2/users轻松分开管理。
这些框架虽然不强制你遵循 RESTful,但用它们的特性,开发起来既轻松又规范,帮你省掉大量重复代码。

2、使用 IDE 插件
比如 IDEA 的 RESTful Toolkit 插件,可以快速查看和测试接口。

还有 VSCode 的 REST Client 插件,可以直接在编辑器里测试接口。

3、利用 AI 生成
RESTful 有明确的设计规范,而 AI 最擅长处理这种有章可循的东西!
比如直接让 Cursor 帮你用 Spring Boot 写一个用户管理的 RESTful API:

你只需要阿巴阿巴几下,它就能生成规范的代码。

4、生成接口文档
写完接口后,还可以用 Swagger 这类工具自动生成漂亮的接口文档,直接甩给前端,对方一看就懂,还能在线测试接口,省去大量沟通成本。

你笑得像个孩子:这么一看,RESTful API 不仅让接口规范统一,还能提高开发效率,降低团队沟通成本,前后端都舒服!爽爽爽!

鱼皮点点头:没错,这也是为什么 RESTful 能成为业界主流的原因。
你:学会了学会了,我这就去重构所有接口,让前端阿花刮目相看!

结尾
一周后,你把所有接口重构成了 RESTful 风格。
前端阿花打开新的接口文档,眼睛亮了:小阿巴,你居然开窍了?!

你得意地笑了:那是,我可是学过 RESTful 的男人~ 阿花,晚上要不要一起?

阿花朝你吐了口唾沫:呸,你只不过学了一种 API 风格就得意洋洋。阿坤哥哥不仅精通 RESTful,还能手撕 GraphQL 和 gRPC 呢,你行么?

你难受得不行:啥啥啥,这都是啥啊…… 鱼皮 gie gie 快来救我!
来源:juejin.cn/post/7587811143110574131
老板,我真干活了...
辛苦写了3天的代码,突然一下,全部消失了
我说我每次都git add .了,但是他就是消失了
你会相信我吗
这次真的跳进黄河都洗不清,六月都要飞雪了
一个安静的夜晚,主包正在和往常一样敲着代码,最后一个优化完成以后执行了git add .
看着长长的暂存区,主包想是时候git commit -m了
变故就发生在一瞬间,commit校验返回失败,伴随着电脑的终端闪烁了一下,主包的 所 有 改 动都消失了,文件内容停留在上次的提交...
老板,你相信我吗?我真的干活了
不过幸好,我的Gemini在我的指导下一步一步帮我把3天的劳动成果还原了,下面记录一下整个事件过程
首先。我已经执行了 git add .,这意味着 Git 已经将这些文件的内容以「blob 对象」的形式保存到本地 .git/objects 目录中,这些数据就不会凭空消失。
解决步骤
第一步:不要乱动
立即停止在仓库中执行任何覆写的操作!避免覆盖磁盘上的 blob 对象
- ❌ 不要执行
git reset --hard/git clean -fd/git gc/git prune; - ❌ 不要往仓库目录写入新文件
第二步:检查 Git 状态
git status查看当前暂存区的状态,如果暂存区有文件的话可以通过get checkout -- .进行恢复,可是我没有了,我的暂存区和我的脑袋一样空空

第三步:拉取悬空文件
如果 git status 显示「Working Tree Clean」,且 git checkout -- . 没效果,说明暂存区被清空,但 Git 仍保存了 git add 时的 blob 对象
需通过 git fsck --lost-fond 找回
该指令会扫描 .git/objects/ 目录,找出所有没有被任何分支/标签引用的对象,列出 悬空的 commits、blobs、trees(优先找最新的commit)
第四步:验证每个commit
执行 git show --stat <commit ID> 可以验证该悬空 commit 内容

- 如果「提交信息 / 时间 / 作者」匹配你刚才中断的 commit → 这就是包含你所有改动的快照;
- 如果显示
initial commit或旧提交信息 → 这是历史悬空 commit,换列表里下一个时间最新的 commit ID 重试。

- 这里会列出该 commit 中所有改动的文件路径 + 行数变化 → 如果能看到你丢失的文件(如
src/App.js),说明找对了; - 如果显示
0 files changed→ 这个 commit 是空的,换其他 commit ID 重试。
第五步:恢复提交
方式 1:仅恢复文件到工作区(推荐,不修改 HEAD)
git checkout commitId -- .
方式 2:#### 直接将 HEAD 指向该 commit(完成提交)
git reset --hard commitId // 等同于完成当时的 commit 操作
到这里基本就已经恢复了,可以check一下更改的文件,如果不全可以继续执行checkout进行恢复,如果已经完成了就尽快commit以防发生别的变故啦~
最后,还是简单讲解一下为什么优先恢复悬空commit,commit、tree、Blob的区别
核心结论先摆清楚
| 对象类型 | 中文名称 | 核心作用 | 类比(便于理解) | 能否直接恢复你的文件? |
|---|---|---|---|---|
| Blob | 数据对象 | 存储单个文件的内容(无路径) | 一本书里的某一页内容 | 能,但需匹配原文件路径 |
| Tree | 树对象 | 存储目录结构(文件 / 子目录映射) | 一本书的目录(章节→页码) | 不能直接恢复,仅辅助找路径 |
| Commit | 提交对象 | 存储完整的提交快照(关联 tree + 作者 / 时间 / 信息) | 一本书的版本记录(含目录 + 修改说明) | 最优选择,一键恢复所有文件 |
一、逐个拆解:悬空 blob/commit/tree 到底是什么?
Git 仓库的所有内容(文件、目录、提交记录)最终都会被存储为「对象」,存放在 .git/objects 目录下;「悬空」意味着这些对象没有被任何分支 / 标签 / HEAD 引用(比如 commit 中断、reset 后、删除分支等),但只要没执行 git gc(垃圾回收),就不会消失。
1. 悬空 Blob(数据对象)—— 「只存内容,不管路径」
- 本质:Git 中最小的存储单元,仅保存「单个文件的原始内容」,不包含文件名、路径、修改时间等信息;
- 举例:你修改了
src/App.js并执行git add .,Git 会把App.js的内容打包成一个 blob 对象(比如你看到的ec0529e46516594593b1befb48740956c8758884),存到.git/objects里; - 悬空原因:执行
git add后生成了 blob,但 commit 中断 / 执行git reset清空暂存区,导致这个 blob 没有被 tree/commit 引用; - 恢复特点:能拿到文件内容,但不知道原文件路径(比如你只知道 blob 是一段 JS 代码,却不知道它原本是
src/App.js还是src/Page.js)。
2. 悬空 Tree(树对象)—— 「只存目录结构,不存内容」
- 本质:描述「目录层级 + 文件映射关系」,相当于「文件路径 ↔ blob ID」的对照表,也能包含子 tree(对应子目录);
- 举例:一个 tree 对象可能记录:
src/ (子tree) → tree ID: bb0065eb...
package.json → blob ID: e90a82fe...
src/App.js → blob ID: ec0529e4...
- 悬空原因:Tree 是 commit 的「子对象」,如果 commit 变成悬空(比如 reset 后),对应的 tree 也会悬空;
- 恢复特点:仅能看到「哪些 blob 对应哪些路径」,但本身不存储文件内容,需结合 blob 才能恢复完整文件。
3. 悬空 Commit(提交对象)—— 「完整的提交快照」
- 本质:Git 中最高级的对象,是「一次提交的完整记录」,包含:
- 指向一个 root tree(根目录的 tree 对象)→ 能拿到整个项目的目录结构 + 所有 blob;
- 作者、提交时间、提交信息;
- 父 commit ID(如果是后续提交);
- 举例:你执行
git commit -m "修改App.js"时,Git 会生成一个 commit 对象,关联 root tree(包含所有文件路径 + blob),记录你的操作信息; - 悬空原因:commit 执行中断、
git reset --hard后原 HEAD 指向的 commit 无引用、删除分支后分支上的 commit 无引用; - 恢复特点:✅ 最优选择!通过一个 commit 对象,能一键恢复「该提交时刻的所有文件(路径 + 内容)」,不用手动匹配 blob 和路径。
二、为什么你该优先恢复「悬空 Commit」?
你之前执行了 git add . + 尝试 git commit,大概率 Git 已经生成了 commit 对象(只是没被 HEAD 引用,变成悬空)—— 恢复 commit 有 2 个核心优势:
- 一键恢复所有文件:commit 关联了 root tree,能直接拿到「所有文件的路径 + 对应的 blob 内容」,执行
git checkout <commit ID> -- .就能把所有文件恢复到工作区,不用逐个处理 blob; - 不用手动匹配路径:如果只恢复 blob,你需要逐个查看 blob 内容,再手动命名 / 放到原路径;而 commit 直接包含路径信息,恢复后文件路径和名称完全和丢失前一致。
三、实操场景:不同悬空对象该怎么用?
场景 1:有可用的悬空 Commit(优先选)
# 1. 找时间最新的悬空 commit
git fsck --lost-found | grep 'dangling commit' | awk '{print $3}' | while read c; do
echo "$c | $(git log -1 --format='%ai' $c)"
done | sort -k2 -r
# 2. 验证该 commit 包含你的文件
git show --stat <最新的commit ID>
# 3. 一键恢复所有文件到工作区
git checkout <commit ID> -- .
场景 2:只有悬空 Blob/Tree(无可用 Commit)
# 1. 先通过 tree 找「blob ID ↔ 文件路径」的映射
git ls-tree -r <tree ID> # 列出该 tree 下的所有文件路径+blob ID
# 2. 按路径恢复 blob 内容
git cat-file -p <blob ID> > <原文件路径> # 比如 git cat-file -p ec0529e4 > src/App.js
场景 3:只有悬空 Blob(无 Tree/Commit)
只能批量导出 blob,通过内容匹配原文件:
mkdir -p recover
for blob in $(git fsck --lost-found | grep 'dangling blob' | awk '{print $3}'); do
git cat-file -p $blob > recover/$blob
# 自动补文件后缀(如 .js/.json)
file_type=$(file -b --mime-type recover/$blob | awk -F '/' '{print $2}')
[ "$file_type" != "octet-stream" ] && mv recover/$blob recover/$blob.$file_type
done
四、关键提醒:避免悬空对象被清理
Git 的 git gc(垃圾回收)默认会清理「超过 14 天的悬空对象」,所以恢复前务必:
- 不要执行
git gc/git prune; - 恢复完成后,尽快执行
git commit让对象被 HEAD 引用,避免后续被清理; - 如果暂时没恢复完,可执行
git fsck --full检查所有悬空对象,确认未被清理。
总结来说:优先找悬空 Commit(一键恢复)→ 其次用 Tree 匹配 Blob 路径 → 最后批量导出 Blob 手动匹配,这是最高效的恢复路径
来源:juejin.cn/post/7581678032336519210
单点登录:一次登录,全网通行
大家好,我是小悟。
- 想象一下你去游乐园,买了一张通票(登录),然后就可以玩所有项目(访问各个系统),不用每个项目都重新买票(重新登录)。这就是单点登录(SSO)的精髓!
SSO的日常比喻
- 普通登录:像去不同商场,每个都要查会员卡
- 单点登录:像微信扫码登录,一扫全搞定
- 令牌:像游乐园手环,戴着就能证明你买过票
下面用代码来实现这个"游乐园通票系统":
代码实现:简易SSO系统
import java.util.*;
// 用户类 - 就是我们这些想玩项目的游客
class User {
private String username;
private String password;
public User(String username, String password) {
this.username = username;
this.password = password;
}
// getters 省略...
}
// 令牌类 - 游乐园手环
class Token {
private String tokenId;
private String username;
private Date expireTime;
public Token(String username) {
this.tokenId = UUID.randomUUID().toString();
this.username = username;
// 令牌1小时后过期 - 游乐园晚上要关门的!
this.expireTime = new Date(System.currentTimeMillis() + 3600 * 1000);
}
public boolean isValid() {
return new Date().before(expireTime);
}
// getters 省略...
}
// SSO认证中心 - 游乐园售票处
class SSOAuthCenter {
private Map<String, Token> validTokens = new HashMap<>();
private Map<String, User> users = new HashMap<>();
public SSOAuthCenter() {
// 预先注册几个用户 - 办了年卡的游客
users.put("zhangsan", new User("zhangsan", "123456"));
users.put("lisi", new User("lisi", "abcdef"));
}
// 登录 - 买票入场
public String login(String username, String password) {
User user = users.get(username);
if (user != null && user.getPassword().equals(password)) {
Token token = new Token(username);
validTokens.put(token.getTokenId(), token);
System.out.println(username + " 登录成功!拿到游乐园手环:" + token.getTokenId());
return token.getTokenId();
}
System.out.println("用户名或密码错误!请重新买票!");
return null;
}
// 验证令牌 - 检查手环是否有效
public boolean validateToken(String tokenId) {
Token token = validTokens.get(tokenId);
if (token != null && token.isValid()) {
System.out.println("手环有效,欢迎继续玩耍!");
return true;
}
System.out.println("手环无效或已过期,请重新登录!");
validTokens.remove(tokenId); // 清理过期令牌
return false;
}
// 登出 - 离开游乐园
public void logout(String tokenId) {
validTokens.remove(tokenId);
System.out.println("已登出,欢迎下次再来玩!");
}
}
// 业务系统A - 过山车
class SystemA {
private SSOAuthCenter authCenter;
public SystemA(SSOAuthCenter authCenter) {
this.authCenter = authCenter;
}
public void accessSystem(String tokenId) {
System.out.println("=== 欢迎来到过山车 ===");
if (authCenter.validateToken(tokenId)) {
System.out.println("过山车启动!尖叫声在哪里!");
} else {
System.out.println("请先登录再玩过山车!");
}
}
}
// 业务系统B - 旋转木马
class SystemB {
private SSOAuthCenter authCenter;
public SystemB(SSOAuthCenter authCenter) {
this.authCenter = authCenter;
}
public void accessSystem(String tokenId) {
System.out.println("=== 欢迎来到旋转木马 ===");
if (authCenter.validateToken(tokenId)) {
System.out.println("木马转起来啦!找回童年记忆!");
} else {
System.out.println("请先登录再玩旋转木马!");
}
}
}
// 测试我们的SSO系统
public class SSODemo {
public static void main(String[] args) {
// 创建认证中心 - 游乐园大门
SSOAuthCenter authCenter = new SSOAuthCenter();
// 张三登录
String token = authCenter.login("zhangsan", "123456");
if (token != null) {
// 拿着同一个令牌玩不同项目
SystemA systemA = new SystemA(authCenter);
SystemB systemB = new SystemB(authCenter);
systemA.accessSystem(token); // 玩过山车
systemB.accessSystem(token); // 玩旋转木马
// 登出
authCenter.logout(token);
// 再尝试访问 - 应该被拒绝
systemA.accessSystem(token);
}
// 测试错误密码
authCenter.login("lisi", "wrongpassword");
}
}
运行结果示例:
zhangsan 登录成功!拿到游乐园手环:a1b2c3d4-e5f6-7890-abcd-ef1234567890
=== 欢迎来到过山车 ===
手环有效,欢迎继续玩耍!
过山车启动!尖叫声在哪里!
=== 欢迎来到旋转木马 ===
手环有效,欢迎继续玩耍!
木马转起来啦!找回童年记忆!
已登出,欢迎下次再来玩!
=== 欢迎来到过山车 ===
手环无效或已过期,请重新登录!
请先登录再玩过山车!
用户名或密码错误!请重新买票!
总结一下:
单点登录就像:
- 一次认证,处处通行 🎫
- 不用重复输入密码 🔑
- 安全又方便 👍
好的SSO系统就像好的游乐园管理,既要让游客玩得开心,又要确保安全!

谢谢你看我的文章,既然看到这里了,如果觉得不错,随手点个赞、转发、在看三连吧,感谢感谢。那我们,下次再见。
您的一键三连,是我更新的最大动力,谢谢
山水有相逢,来日皆可期,谢谢阅读,我们再会
我手中的金箍棒,上能通天,下能探海
来源:juejin.cn/post/7577599015426228259
软件工程师必须要掌握的泳道图
作者:面汤放盐 / uzong
在软件开发的世界里,我们习惯用代码表达逻辑,但当系统涉及多个角色、多个服务、甚至跨团队协作时,光靠代码注释或口头沟通,往往不够。这时候,一张清晰的流程图,胜过千行文档。
泳道图 :它可能不像 UML 那样“高大上”,也不如架构图那样宏观,但在梳理业务流程、厘清责任边界、排查系统瓶颈时,它真的非常实用。
1. 什么是泳道图
泳道图的核心思想很简单:把流程中的每个步骤,按执行者(人、系统、模块)分组排列,就像游泳池里的泳道一样,各走各道,互不干扰又彼此关联。

每一列就是一个“泳道”,代表一个责任主体。流程从左到右、从上到下流动,谁在什么时候做了什么,一目了然
一眼就能看出:谁干了什么,谁依赖谁,边界是什么。
与流程的差异点:
- 流程图聚焦:“步骤顺序”,侧重 “先做什么、再做什么”,适合梳理线性业务流程;
- 泳道图聚焦: “流转通道”,侧重 “什么东西在什么约束下通过什么路径流转”,适合拆解复杂、多路径、有规则约束的流转场景(如分布式系统数据同步、供应链物料流转、微服务请求链路等)
2. 泳道图分类
2.1. 垂直泳道图
垂直泳道图采取上下布局结构,主要强调职能群体。这种布局方式更适合于展示跨职能任务和流程中,各职能部门或角色之间的垂直关系和职能分工。

2.2. 水平泳道图
水平泳道图则采用左右布局结构,重点在于事件进程的展示。这种布局方式更适合于强调事件或过程的水平流动,以及不同阶段或部门在流程中的水平参与

3. 泳道图组成元素
泳池: 泳池是泳道图的外部框架,泳道、流程都包含于泳池内。
泳道: 泳池里可以创建多个泳道。
流程: 实际的业务流程。
部门: 通过部门或者责任来区分,明确每个部门/人/信息系统负责完成的任务环节。
阶段: 通过任务阶段来区分,明确每个阶段需要处理的任务环节。
4. 泳道图应用场景
4.1. 项目管理
展示项目从启动到完成的各个阶段,明确每个团队或成员在项目中的角色和职责,便于进行项目管理和监控,同时促进团队协作和沟通

4.2. 业务流程分析
展示业务流程的各个环节和涉及的不同部门或职能。通过分析泳道图,可以发现业务流程中的瓶颈、冗余环节或不合理之处,进而进行流程优化和改进。

4.3. 系统设计
展示系统的整体架构和各个组件之间的关系,描述系统的工作流程,包括数据的输入、处理、输出等各个环节,有助于系统开发人员更好地理解系统的功能和需求

5. 更多参考模板
故障处理多维泳道图

资源扩容泳道图

6. 最后
我刚工作时,看到导师抛出一份精致的泳道图,把一团乱麻的问题讲得透亮,心里特别佩服。直到自己多年后用上才真正体会:在面对跨部门协作、复杂故障排查、关键流程设计时,掏出这么一张图,往往就是高效沟通的开始。
作为开发者,我们常陷入“只要代码跑得通就行”的思维惯性。但软件不仅是机器执行的指令,更是人与人协作的媒介。泳道图这样的工具,本质上是在降低认知成本——让复杂的事情变得可沟通、可验证、可迭代。 其实泳道图的核心不是“画图”,而是“梳理流程、明确权责”。
在跨部门、协同需求、故障分析等关键场景使用泳道图是非常合适,并且也能把问题讲清楚。技术世界充满了抽象和复杂性,而优秀工程师的能力之一,就是创建合适的可视化工具,让复杂问题变得简单可见。
本文中的大部分图片来源于 ProcessOn,ProcessOn 是一个非常不错的画图软件,功能强大,界面优美。
来源:juejin.cn/post/7580423629164068916
我发现很多程序员都不会打日志。。。
你是小阿巴,刚入职的低级程序员,正在开发一个批量导入数据的程序。
没想到,程序刚上线,产品经理就跑过来说:小阿巴,用户反馈你的程序有 Bug,刚导入没多久就报错中断了!
你赶紧打开服务器,看着比你发量都少的报错信息:

你一脸懵逼:只有这点儿信息,我咋知道哪里出了问题啊?!
你只能硬着头皮让产品经理找用户要数据,然后一条条测试,看看是哪条数据出了问题……
原本大好的摸鱼时光,就这样无了。
这时,你的导师鱼皮走了过来,问道:小阿巴,你是持矢了么?脸色这么难看?

你无奈地说:皮哥,刚才线上出了个 bug,我花了 8 个小时才定位到问题……
鱼皮皱了皱眉:这么久?你没打日志吗?
你很是疑惑:谁是日志?为什么要打它?

鱼皮叹了口气:唉,难怪你要花这么久…… 来,我教你打日志!
⭐️ 本文对应视频版:bilibili.com/video/BV1K7…
什么是日志?
鱼皮打开电脑,给你看了一段代码:
@Slf4j
public class UserService {
public void batchImport(List<UserDTO> userList) {
log.info("开始批量导入用户,总数:{}", userList.size());
int successCount = 0;
int failCount = 0;
for (UserDTO userDTO : userList) {
try {
log.info("正在导入用户:{}", userDTO.getUsername());
validateUser(userDTO);
saveUser(userDTO);
successCount++;
log.info("用户 {} 导入成功", userDTO.getUsername());
} catch (Exception e) {
failCount++;
log.error("用户 {} 导入失败,原因:{}", userDTO.getUsername(), e.getMessage(), e);
}
}
log.info("批量导入完成,成功:{},失败:{}", successCount, failCount);
}
}
你看着代码里的 log.info、log.error,疑惑地问:这些 log 是干什么的?
鱼皮:这就是打日志。日志用来记录程序运行时的状态和信息,这样当系统出现问题时,我们可以通过日志快速定位问题。

你若有所思:哦?还可以这样!如果当初我的代码里有这些日志,一眼就定位到问题了…… 那我应该怎么打日志?用什么技术呢?
怎么打日志?
鱼皮:每种编程语言都有很多日志框架和工具库,比如 Java 可以选用 Log4j 2、Logback 等等。咱们公司用的是 Spring Boot,它默认集成了 Logback 日志框架,你直接用就行,不用再引入额外的库了~

日志框架的使用非常简单,先获取到 Logger 日志对象。
1)方法 1:通过 LoggerFactory 手动获取 Logger 日志对象:
public class MyService {
private static final Logger logger = LoggerFactory.getLogger(MyService.class);
}
2)方法 2:使用 this.getClass 获取当前类的类型,来创建 Logger 对象:
public class MyService {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
}
然后调用 logger.xxx(比如 logger.info)就能输出日志了。
public class MyService {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
public void doSomething() {
logger.info("执行了一些操作");
}
}
效果如图:

小阿巴:啊,每个需要打日志的类都要加上这行代码么?
鱼皮:还有更简单的方式,使用 Lombok 工具库提供的 @Slf4j 注解,可以自动为当前类生成日志对象,不用手动定义啦。
@Slf4j
public class MyService {
public void doSomething() {
log.info("执行了一些操作");
}
}
上面的代码等同于 “自动为当前类生成日志对象”:
private static final org.slf4j.Logger log =
org.slf4j.LoggerFactory.getLogger(MyService.class);
你咧嘴一笑:这个好,爽爽爽!

等等,不对,我直接用 Java 自带的 System.out.println 不也能输出信息么?何必多此一举?
System.out.println("开始导入用户" + user.getUsername());
鱼皮摇了摇头:千万别这么干!
首先,System.out.println 是一个同步方法,每次调用都会导致耗时的 I/O 操作,频繁调用会影响程序的性能。

而且它只能输出信息到控制台,不能灵活控制输出位置、输出格式、输出时机等等。比如你现在想看三天前的日志,System.out.println 的输出早就被刷没了,你还得浪费时间找半天。

你恍然大悟:原来如此!那使用日志框架就能解决这些问题吗?
鱼皮点点头:没错,日志框架提供了丰富的打日志方法,还可以通过修改日志配置文件来随心所欲地调教日志,比如把日志同时输出到控制台和文件中、设置日志格式、控制日志级别等等。

在下苦心研究日志多年,沉淀了打日志的 8 大邪修秘法,先传授你 2 招最基础的吧。
打日志的 8 大最佳实践
1、合理选择日志级别
第一招,日志分级。
你好奇道:日志还有级别?苹果日志、安卓日志?
鱼皮给了你一巴掌:可不要乱说,日志的级别是按照重要程度进行划分的。

其中 DEBUG、INFO、WARN 和 ERROR 用的最多。
- 调试用的详细信息用 DEBUG
- 正常的业务流程用 INFO
- 可能有问题但不影响主流程的用 WARN
- 出现异常或错误的用 ERROR
log.debug("用户对象的详细信息:{}", userDTO); // 调试信息
log.info("用户 {} 开始导入", username); // 正常流程信息
log.warn("用户 {} 的邮箱格式可疑,但仍然导入", username); // 警告信息
log.error("用户 {} 导入失败", username, e); // 错误信息
你挠了挠头:俺直接全用 DEBUG 不行么?
鱼皮摇了摇头:如果所有信息都用同一级别,那出了问题时,你怎么快速找到错误信息?

在生产环境,我们通常会把日志级别调高(比如 INFO 或 WARN),这样 DEBUG 级别的日志就不会输出了,防止重要信息被无用日志淹没。

你点点头:俺明白了,不同的场景用不同的级别!
2、正确记录日志信息
鱼皮:没错,下面教你第二招。你注意到我刚才写的日志里有一对大括号 {} 吗?
log.info("用户 {} 开始导入", username);
你回忆了一下:对哦,那是啥啊?
鱼皮:这叫参数化日志。{} 是一个占位符,日志框架会在运行时自动把后面的参数值替换进去。
你挠了挠头:我直接用字符串拼接不行吗?
log.info("用户 " + username + " 开始导入");
鱼皮摇摇头:不推荐。因为字符串拼接是在调用 log 方法之前就执行的,即使这条日志最终不被输出,字符串拼接操作还是会执行,白白浪费性能。

你点点头:确实,而且参数化日志比字符串拼接看起来舒服~

鱼皮:没错。而且当你要输出异常信息时,也可以使用参数化日志:
try {
// 业务逻辑
} catch (Exception e) {
log.error("用户 {} 导入失败", username, e); // 注意这个 e
}
这样日志框架会同时记录上下文信息和完整的异常堆栈信息,便于排查问题。

你抱拳:学会了,我这就去打日志!
3、把控时机和内容
很快,你给批量导入程序的代码加上了日志:
@Slf4j
public class UserService {
public BatchImportResult batchImport(List<UserDTO> userList) {
log.info("开始批量导入用户,总数:{}", userList.size());
int successCount = 0;
int failCount = 0;
for (UserDTO userDTO : userList) {
try {
log.info("正在导入用户:{}", userDTO.getUsername());
// 校验用户名
if (StringUtils.isBlank(userDTO.getUsername())) {
throw new BusinessException("用户名不能为空");
}
// 保存用户
saveUser(userDTO);
successCount++;
log.info("用户 {} 导入成功", userDTO.getUsername());
} catch (Exception e) {
failCount++;
log.error("用户 {} 导入失败,原因:{}", userDTO.getUsername(), e.getMessage(), e);
}
}
log.info("批量导入完成,成功:{},失败:{}", successCount, failCount);
return new BatchImportResult(successCount, failCount);
}
}
光做这点还不够,你还翻出了之前的屎山代码,想给每个文件都打打日志。

但打着打着,你就不耐烦了:每段代码都要打日志,好累啊!但是不打日志又怕出问题,怎么办才好?
鱼皮笑道:好问题,这就是我要教你的第三招 —— 把握打日志的时机。
对于重要的业务功能,我建议采用防御性编程,先多多打日志。比如在方法代码的入口和出口记录参数和返回值、在每个关键步骤记录执行状态,而不是等出了问题无法排查的时候才追悔莫及。之后可以再慢慢移除掉不需要的日志。

你叹了口气:这我知道,但每个方法都打日志,工作量太大,都影响我摸鱼了!
鱼皮:别担心,你可以利用 AOP 切面编程,自动给每个业务方法的执行前后添加日志,这样就不会错过任何一次调用信息了。

你双眼放光:这个好,爽爽爽!

鱼皮:不过这样做也有一个缺点,注意不要在日志中记录了敏感信息,比如用户密码。万一你的日志不小心泄露出去,就相当于泄露了大量用户的信息。

你拍拍胸脯:必须的!
4、控制日志输出量
一个星期后,产品经理又来找你了:小阿巴,你的批量导入功能又报错啦!而且怎么感觉程序变慢了?
你完全不慌,淡定地打开服务器的日志文件。结果瞬间呆住了……
好家伙,满屏都是密密麻麻的日志,这可怎么看啊?!

鱼皮看了看你的代码,摇了摇头:你现在每导入一条数据都要打一些日志,如果用户导入 10 万条数据,那就是几十万条日志!不仅刷屏,还会影响性能。
你有点委屈:不是你让我多打日志的么?那我应该怎么办?
鱼皮:你需要控制日志的输出量。
1)可以添加条件来控制,比如每处理 100 条数据时才记录一次:
if ((i + 1) % 100 == 0) {
log.info("批量导入进度:{}/{}", i + 1, userList.size());
}
2)或者在循环中利用 StringBuilder 进行字符串拼接,循环结束后统一输出:
StringBuilder logBuilder = new StringBuilder("处理结果:");
for (UserDTO userDTO : userList) {
processUser(userDTO);
logBuilder.append(String.format("成功[ID=%s], ", userDTO.getId()));
}
log.info(logBuilder.toString());
3)还可以通过修改日志配置文件,过滤掉特定级别的日志,防止日志刷屏:
<appender name="FILE" class="ch.qos.logback.core.FileAppender">
<file>logs/app.log</file>
<!-- 只允许 INFO 级别及以上的日志通过 -->
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>INFO</level>
</filter>
</appender>
5、统一日志格式
你开心了:好耶,这样就不会刷屏了!但是感觉有时候日志很杂很乱,尤其是我想看某一个请求相关的日志时,总是被其他的日志干扰,怎么办?
鱼皮:好问题,可以在日志配置文件中定义统一的日志格式,包含时间戳、线程名称、日志级别、类名、方法名、具体内容等关键信息。
<!-- 控制台日志输出 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<!-- 日志格式 -->
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
这样输出的日志更整齐易读:

此外,你还可以通过 MDC(Mapped Diagnostic Context)给日志添加额外的上下文信息,比如请求 ID、用户 ID 等,方便追踪。

在 Java 代码中,可以为 MDC 设置属性值:
@PostMapping("/user/import")
public Result importUsers(@RequestBody UserImportRequest request) {
// 1. 设置 MDC 上下文信息
MDC.put("requestId", generateRequestId());
MDC.put("userId", String.valueOf(request.getUserId()));
try {
log.info("用户请求处理完成");
// 执行具体业务逻辑
userService.batchImport(request.getUserList());
return Result.success();
} finally {
// 2. 及时清理MDC(重要!)
MDC.clear();
}
}
然后在日志配置文件中就可以使用这些值了:
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<encoder>
<!-- 包含 MDC 信息 -->
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - [%X{requestId}] [%X{userId}] %msg%n</pattern>
</encoder>
</appender>
这样,每个请求、每个用户的操作一目了然。

6、使用异步日志
你又开心了:这样打出来的日志,确实舒服,爽爽爽!但是我打日志越多,是不是程序就会更慢呢?有没有办法能优化一下?
鱼皮:当然有,可以使用 异步日志。
正常情况下,你调用 log.info() 打日志时,程序会立刻把日志写入文件,这个过程是同步的,会阻塞当前线程。而异步日志会把写日志的操作放到另一个线程里去做,不会阻塞主线程,性能更好。
你眼睛一亮:这么厉害?怎么开启?
鱼皮:很简单,只需要修改一下配置文件:
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
<queueSize>512</queueSize> <!-- 队列大小 -->
<discardingThreshold>0</discardingThreshold> <!-- 丢弃阈值,0 表示不丢弃 -->
<neverBlock>false</neverBlock> <!-- 队列满时是否阻塞,false 表示会阻塞 -->
<appender-ref ref="FILE" /> <!-- 引用实际的日志输出目标 -->
</appender>
<root level="INFO">
<appender-ref ref="ASYNC" />
</root>
不过异步日志也有缺点,如果程序突然崩溃,缓冲区中还没来得及写入文件的日志可能会丢失。

所以要权衡一下,看你的系统更注重性能还是日志的完整性。
你想了想:我们的程序对性能要求比较高,偶尔丢几条日志问题不大,那我就用异步日志吧。
7、日志管理
接下来的很长一段时间,你混的很舒服,有 Bug 都能很快发现。
你甚至觉得 Bug 太少、工作没什么激情,所以没事儿就跟新来的实习生阿坤吹吹牛皮:你知道日志么?我可会打它了!

直到有一天,运维小哥突然跑过来:阿巴阿巴,服务器挂了!你快去看看!
你连忙登录服务器,发现服务器的硬盘爆满了,没法写入新数据。
你查了一下,发现日志文件竟然占了 200GB 的空间!

你汗流浃背了,正在考虑怎么甩锅,结果阿坤突然鸡叫起来:阿巴 giegie,你的日志文件是不是从来没清理过?
你尴尬地倒了个立,这样眼泪就不会留下来。

鱼皮叹了口气:这就是我要教你的下一招 —— 日志管理。
你好奇道:怎么管理?我每天登服务器删掉一些历史文件?
鱼皮:人工操作也太麻烦了,我们可以通过修改日志配置文件,让框架帮忙管理日志。
首先设置日志的滚动策略,可以根据文件大小和日期,自动对日志文件进行切分。
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>logs/app-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>10MB</maxFileSize>
<maxHistory>30</maxHistory>
</rollingPolicy>
这样配置后,每天会创建一个新的日志文件(比如 app-2025-10-23.0.log),如果日志文件大小超过 10MB 就再创建一个(比如 app-2025-10-23.1.log),并且只保留最近 30 天的日志。

还可以开启日志压缩功能,进一步节省磁盘空间:
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<!-- .gz 后缀会自动压缩 -->
<fileNamePattern>logs/app-%d{yyyy-MM-dd}.log.gz</fileNamePattern>
</rollingPolicy>

你有些激动:吼吼,这样我们就可以按照天数更快地查看日志,服务器硬盘也有救啦!
8、集成日志收集系统
两年后,你负责的项目已经发展成了一个大型的分布式系统,有好几十个微服务。
如今,每次排查问题你都要登录到不同的服务器上查看日志,非常麻烦。而且有些请求的调用链路很长,你得登录好几台服务器、看好几个服务的日志,才能追踪到一个请求的完整调用过程。

你简直要疯了!
于是你找到鱼皮求助:现在查日志太麻烦了,当年你还有一招没有教我,现在是不是……
鱼皮点点头:嗯,对于分布式系统,就必须要用专业的日志收集系统了,比如很流行的 ELK。
你好奇:ELK 是啥?伊拉克?
阿坤抢答道:我知道,就是 Elasticsearch + Logstash + Kibana 这套组合。
简单来说,Logstash 负责收集各个服务的日志,然后发送给 Elasticsearch 存储和索引,最后通过 Kibana 提供一个可视化的界面。

这样一来,我们可以方便地集中搜索、查看、分析日志。

你惊讶了:原来日志还能这么玩,以后我所有的项目都要用 ELK!
鱼皮摆摆手:不过 ELK 的搭建和运维成本比较高,对于小团队来说可能有点重,还是要按需采用啊。
结局
至此,你已经掌握了打日志的核心秘法。

只是你很疑惑,为何那阿坤竟对日志系统如此熟悉?
阿坤苦笑道:我本来就是日志管理大师,可惜我上家公司的同事从来不打日志,所以我把他们暴打了一顿后跑路了。
阿巴 giegie 你要记住,日志不是写给机器看的,是写给未来的你和你的队友看的!
你要是以后不打日志,我就打你!
更多
来源:juejin.cn/post/7569159131819753510
分库分表正在被淘汰
前言
“分库分表这种架构模式会逐步的被淘汰!” 不知道在哪儿看到的观点
如果我们现在在搭建新的业务架构,如果说你们未来的业务数据量会达到千万 或者上亿的级别 还在一股脑的使用分库分表的架构,那么你们的技术负责人真的就应该提前退休了🙈
如果对未来的业务非常有信心,单表的数据量能达到千万上亿的级别,请使用NewSQL 数据库,那么NewSQL 这么牛,分布库分表还有意义吗?
今天虽然写的是一篇博客,但是更多的是抱着和大家讨论的心态来的,所以大家目前有深度参与分库分表,或者NewSQL 的都可以在评论区讨论!
什么是NewSQL
NewSQL 是21世纪10年代初出现的一个术语,用来描述一类新型的关系型数据库管理系统(RDBMS)。它们的共同目标是:在保持传统关系型数据库(如Oracle、MySQL)的ACID事务和SQL模型优势的同时,获得与NoSQL系统类似的、弹性的水平扩展能力
NewSQL 的核心理念就是 将“分库分表”的复杂性从应用层下沉到数据库内核层,对上层应用呈现为一个单一的数据库入口,解决现在 分库分表的问题;
分库分表的问题
分库分表之后,会带来非常多的问题;比如需要跨库联查、跨库更新数据如何保证事务一致性等问题,下面就来详细看看分库分表都有那些问题
- 数据库的操作变得复杂
- 跨库 JOIN 几乎不可行:原本简单的多表关联查询,因为表被分散到不同库甚至不同机器上,变得异常困难。通常需要拆成多次查询,在应用层进行数据组装,代码复杂且性能低下。
- 聚合查询效率低下:
COUNT(),SUM(),GR0UP BY,ORDER BY等操作无法在数据库层面直接完成。需要在每个分片上执行,然后再进行合并。 - 分页问题:
LIMIT 20, 10这样的分页查询会变得非常诡异。你需要从所有分片中获取前30条数据,然后在应用层排序后取第20-30条。页码越大,性能越差。
- 设计上需要注意的问题
- 分片键(Sharding Key)的选择:如果前期没有设计好,后期数据倾斜比较严重
- 全局唯一ID需要提前统一设计,规范下来
- 分布式事务问题,需要考虑使用哪种方式去实现(XA协议,柔性事务)
选择TiDB还是采用mysql 分库分表的设计
数据量非常大,需要满足OLTP (Online Transactional Processing)、OLAP (Online Analytical Processing)、HTAP 且预算充足(分布式数据库的成本也是非常高的这一点非常的重要),并且是新业务新架构落地 优先推荐使用TiDB。
当然实际上选择肯定是需要多方面考虑的,大家有什么观点都可以在评论区讨论。
可以看看一个资深开发,深度参与TiDB项目,他对TiDB的一些看法:



1 什么是TiDB?
TiDB是PingCAP公司研发的开源分布式关系型数据库,采用存储计算分离架构,支持混合事务分析处理(HTAP) 。它与MySQL 5.7协议兼容,并支持MySQL生态,这意味着使用MySQL的应用程序可以几乎无需修改代码就能迁移到TiDB。
🚀目标是为用户提供一站式 OLTP (Online Transactional Processing)、OLAP (Online Analytical Processing)、HTAP 解决方案。TiDB 适合高可用、强一致要求较高、数据规模较大等各种应用场景。
官方文档:docs.pingcap.com/zh/tidb/dev…
TiDB五大核心特性
TiDB之所以在分布式数据库领域脱颖而出,得益于其五大核心特性:
- 一键水平扩容或缩容:得益于存储计算分离的架构设计,可按需对计算、存储分别进行在线扩容或缩容,整个过程对应用透明。
- 金融级高可用:数据采用多副本存储,通过Multi-Raft协议同步事务日志,只有多数派写入成功事务才能提交,确保数据强一致性。
- 实时HTAP:提供行存储引擎TiKV和列存储引擎TiFlash,两者之间的数据保持强一致,解决了HTAP资源隔离问题。
- 云原生分布式数据库:通过TiDB Operator可在公有云、私有云、混合云中实现部署工具化、自动化。
- 兼容MySQL 5.7协议和生态:从MySQL迁移到TiDB无需或只需少量代码修改,极大降低了迁移成本。
2 TiDB与MySQL的核心差异
虽然TiDB兼容MySQL协议,但它们在架构设计和适用场景上存在根本差异。以下是它们的详细对比:
2.1 架构差异
表1:TiDB与MySQL架构对比
| 特性 | MySQL | TiDB |
|---|---|---|
| 架构模式 | 集中式架构 | 分布式架构 |
| 扩展性 | 垂直扩展,主从复制 | 水平扩展,存储计算分离 |
| 数据分片 | 需要分库分表 | 自动分片,无需sharding key |
| 高可用机制 | 主从复制、MGR | Multi-Raft协议,多副本 |
| 存储引擎 | InnoDB、MyISAM等 | TiKV(行存)、TiFlash(列存) |
2.2 性能表现对比
性能方面,TiDB与MySQL各有优势,主要取决于数据量和查询类型:
- 小数据量简单查询:在数据量百万级以下的情况下,MySQL的写入性能和点查点写通常优于TiDB。因为TiDB的分布式架构在少量数据时无法充分发挥优势,却要承担分布式事务的开销。
- 大数据量复杂查询:当数据量达到千万级以上,TiDB的性能优势开始显现。一张千万级别表关联查询,MySQL可能需要20秒,而TiDB+TiKV只需约5.57秒,使用TiFlash甚至可缩短到0.5秒。
- 高并发场景:MySQL性能随着并发增加会达到瓶颈然后下降,而TiDB性能基本随并发增加呈线性提升,节点资源不足时还可通过动态扩容提升性能。
2.3 扩展性与高可用对比
MySQL的主要扩展方式是一主多从架构,主节点无法横向扩展(除非接受分库分表),从节点扩容需要应用支持读写分离。而TiDB的存储和计算节点都可以独立扩容,支持最大512节点,集群容量可达PB级别。
高可用方面,MySQL使用增强半同步和MGR方案,但复制效率较低,主节点故障会影响业务处理[]。TiDB则通过Raft协议将数据打散分布,单机故障对集群影响小,能保证RTO(恢复时间目标)不超过30秒且RPO(恢复点目标)为0,真正实现金融级高可用。
2.4 SQL功能及兼容性
虽然TiDB高度兼容MySQL 5.7协议和生态,但仍有一些重要差异需要注意:
不支持的功能包括:
- 存储过程与函数
- 触发器
- 事件
- 自定义函数
- 全文索引(计划中)
- 空间类型函数和索引
有差异的功能包括:
- 自增ID的行为(TiDB推荐使用AUTO_RANDOM避免热点问题)
- 查询计划的解释结果
- 在线DDL能力(TiDB更强,不锁表支持DML并行操作)
3 如何选择:TiDB还是MySQL?
选择数据库时,应基于实际业务需求和技术要求做出决策。以下是具体的选型建议:
3.1 选择TiDB的场景
TiDB在以下场景中表现卓越:
- 数据量大且增长迅速的OLTP场景:当单机MySQL容量或性能遇到瓶颈,且数据量达到TB级别时,TiDB的水平扩展能力能有效解决问题。
例如,当业务数据量预计将超过TB级别,或并发连接数超过MySQL合理处理范围时。 - 实时HTAP需求:需要同时进行在线事务处理和实时数据分析的场景。
传统方案需要OLTP数据库+OLAP数据库+ETL工具,TiDB的HTAP能力可简化架构,降低成本和维护复杂度。 - 金融级高可用要求:对系统可用性和数据一致性要求极高的金融行业场景。
TiDB的多副本和自动故障转移机制能确保业务连续性和数据安全。 - 多业务融合平台:需要将多个业务数据库整合的统一平台场景。
TiDB的资源管控能力可以按照RU(Request Unit)大小控制资源总量,实现多业务资源隔离和错峰利用。 - 频繁的DDL操作需求:需要频繁进行表结构变更的业务。
TiDB的在线DDL能力在业务高峰期也能平稳执行,对大表结构变更尤其有效。
3.2 选择MySQL的场景
MySQL在以下情况下仍是更合适的选择:
- 中小规模数据量:数据量在百万级以下,且未来增长可预测。
在这种情况下,MySQL的性能可能更优,且总拥有成本更低。 - 简单读写操作为主:业务以点查点写为主,没有复杂的联表查询或分析需求。
- 需要特定MySQL功能:业务依赖存储过程、触发器、全文索引等TiDB不支持的功能。
- 资源受限环境:硬件资源有限且没有分布式数据库管理经验的团队。
MySQL的运维管理相对简单,学习曲线较平缓。
3.3 决策参考框架
为了更直观地帮助决策,可以参考以下决策表:
| 考虑因素 | 倾向TiDB | 倾向MySQL |
|---|---|---|
| 数据规模 | TB级别或预计快速增长 | GB级别,增长稳定 |
| 并发需求 | 高并发(数千连接以上) | 低至中等并发 |
| 查询类型 | 复杂SQL,多表关联 | 简单点查点写 |
| 可用性要求 | 金融级(RTO<30s,RPO=0) | 常规可用性要求 |
| 架构演进 | 微服务、云原生、HTAP | 传统单体应用 |
| 运维能力 | 有分布式系统管理经验 | 传统DBA团队 |
4 迁移注意事项
如果决定从MySQL迁移到TiDB,需要注意以下关键点:
- 功能兼容性验证:检查应用中是否使用了TiDB不支持的MySQL功能,如存储过程、触发器等。
- 自增ID处理:将AUTO_INCREMENT改为AUTO_RANDOM以避免写热点问题。
- 事务大小控制:注意TiDB对单个事务的大小限制(早期版本限制较严,4.0版本已提升到10GB)。
- 迁移工具选择:使用TiDB官方工具如DM(Data Migration)进行数据迁移和同步。
- 性能测试:迁移前务必进行充分的性能测试,特别是针对业务关键查询的测试。
5 总结
TiDB和MySQL是适用于不同场景的数据库解决方案,没有绝对的优劣之分。MySQL是优秀的单机数据库,适用于数据量小、架构简单的场景;数据量大了之后需要做分库分表。而TiDB作为分布式数据库,专注于解决大数据量、高并发、高可用性需求下的数据库瓶颈问题,但是成本也是非常的高
本人没有使用过NewSQL ,还望各位大佬批评指正
来源:juejin.cn/post/7561245020045918249
告别终端低效,10个让同事直呼卧槽的小技巧
在 IDE 横行的今天,我们这些程序员依然需要跟终端打交道,三五年下来,谁还没踩过一些坑,又或者自己琢磨出一些能让效率起飞的小窍门呢?
今天不聊那些 ls -la 比 ls 好用之类的基础知识,只分享那些真正改变我工作流、甚至让旁边同事忍不住探过头来问“哥们,你这手速没单身30年练不下来吧”的实战技巧。

快速定位系统性能瓶颈
服务器或者自己电脑突然变卡,得快速知道是谁在捣鬼。
# 查看哪个目录最占硬盘空间(只看当前目录下一级)
du -ah --max-depth=1 | sort -rh | head -n 10
# 按 CPU 使用率列出排名前 10 的进程
ps aux --sort=-%cpu | head -n 11
# 按内存使用率列出排名前 10 的进程
ps aux --sort=-%mem | head -n 11
环境和配置管理?交给专业的来
以前,管理本地开发环境简直是一场灾难。一会儿要配 PHP,一会儿又要弄 Node.js,还得装个 Python。各种环境变量、数据库配置写在 .bashrc 或 .zshrc 里,像这样:
# 老办法:用函数切换环境
switch_env() {
if [ "$1" = "proj_a" ]; then
export DB_HOST="localhost"
export DB_PORT="3306"
echo "切换到项目 A 环境"
elif [ "$1" = "proj_b" ]; then
export DB_HOST="127.0.0.1"
export DB_PORT="5432"
echo "切换到项目 B 环境"
fi
}
这种方式手动维护起来很麻烦,项目一多,配置文件就变得特别臃肿,切换起来也容易出错。
但是,时代变了,朋友们。现在我处理本地开发环境,都用 ServBay。
请注意,ServBay不是命令行工具,而是一个集成的本地开发环境平台。它把程序员常用的语言,比如 PHP、Node.js、Python、Go、Rust 都打包好了,需要哪个版本点一下就行,完全不用自己去折腾编译和环境变量。
18.00.15@2x.png" loading="lazy" src="https://www.imgeek.net/uploads/article/20251214/459b28b5fdb4e57a34956d28d1655e18.jpg"/>
数据库也一样,无论是 SQL(MySQL, PostgreSQL)还是 NoSQL(Redis, MongoDB),都给你准备得妥妥的。而且它支持一键部署本地 AI,适合vibe coder。
用了 ServBay 之后,上面那些复杂的环境切换脚本我早就删了。所有环境和配置管理都通过一个清爽的图形界面搞定,我变强了,也变快了。
一行搞定网络调试
简单测试一下端口通不通,或者 API 能不能访问,完全没必要打开 Postman 那么重的工具。
# 检查本地 3306 端口是否开放
nc -zv 127.0.0.1 3306
# 快速给 API 发送一个 POST 请求
curl -X POST http://localhost:8080/api/v1/users \
-H "Content-Type: application/json" \
-d '{"username":"test","role":"admin"}'
目录间的闪转腾挪:pushd 和 popd
还在用一连串的 cd ../../.. 来返回之前的目录吗?那也太“复古”了。试试目录栈吧。
# 你当前在 /Users/me/workspace/project-a/src
pushd /etc/nginx/conf.d
# 这时你瞬间移动到了 Nginx 配置目录,并且终端会记住你来的地方
# 在这里查看和修改配置...
vim default.conf
# 搞定之后,想回去了?
popd
# “嗖”的一下,你又回到了 /Users/me/workspace/project-a/src
pushd 可以多次使用,它会把目录一个个地压入一个“栈”里。可以用 dirs -v 查看这个栈,然后用 pushd +N 跳到指定的目录。对于需要在多个不相关的目录之间反复横跳的场景,这就是大杀器。
文件操作的骚操作
用 cp 复制大文件时,看着光标一动不动,你是不是也曾怀疑过电脑是不是死机了?
# 安装 rsync (macOS 自带,Linux 大部分也自带)
# 复制文件并显示进度条
rsync -avh --progress source-large-file.zip /path/to/destination/
查找文件,find 命令固然强大,但参数复杂得像咒语。我更推荐用 fd,一个更快、更友好的替代品。
# 安装 fd (brew install fd / apt install fd-find)
# 查找所有 tsx 文件
fd ".tsx$"
# 查找并删除所有 .log 文件
fd ".log$" --exec rm {}
批量重命名文件,也不用再写复杂的脚本了。
# 比如把所有的 .jpeg 后缀改成 .jpg
for img in *.jpeg; do
mv "$img" "${img%.jpeg}.jpg"
done
历史命令的魔法:!! 和 !$
这个绝对是手残党和健忘症患者的良药。最常见的场景就是,刚敲了一个需要管理员权限的命令,然后……
# 信心满满地创建目录
mkdir /usr/local/my-app
# 得到一个冷冰冰的 "Permission denied"
# 这时候别傻乎乎地重敲一遍,优雅地输入:
sudo !!
# 这行命令会自动展开成:sudo mkdir /usr/local/my-app
!! 代表上一条完整的命令。而 !$ 则更精妙,它代表上一条命令的最后一个参数。
# 创建一个藏得很深的项目目录
mkdir -p projects/a-very-long/and-nested/project-name
# 紧接着,想进入这个目录
cd !$
# 是的,它会自动展开成:cd projects/a-very-long/and-nested/project-name
# 或者,想在那个目录下创建一个文件
touch !$/index.js
自从熟练掌握了这两个符号,我的键盘方向上键和 Ctrl+C 的使用频率都降低了不少。
进程管理不用抓狂
以前杀个进程,得先 ps aux | grep xxx,然后复制 PID,再 kill -9 PID,一套操作下来黄花菜都凉了。现在,我们可以更直接一点。
# 按名字干掉某个进程
pkill -f "gunicorn"
# 优雅地请所有 Python 脚本进程“离开”
pkill -f "python.*.py"
对于我们这些经常和端口打交道的开发者来说,端口被占用的问题更是家常便饭。下面这个函数,我把它写进了我的 .zshrc 里,谁用谁知道。
# 定义一个函数,专门用来释放被占用的端口
free_port() {
lsof -i tcp:$1 | grep LISTEN | awk '{print $2}' | xargs kill -9
echo "端口 $1 已释放"
}
# 比如,干掉占用 8000 端口的那个“钉子户”
free_port 8000
给 Git 整个外挂
把这些别名(alias)加到 ~/.gitconfig 文件里,每天能省下无数次敲击键盘的力气。
[alias]
co = checkout
br = branch
ci = commit -m
st = status -sb
lg = log --graph --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)<%an>%Creset'
# 把暂存区的修改撤销回来
unstage = reset HEAD --
# 彻底丢弃上一次提交,但保留代码改动
undo = reset --soft HEAD~1
# 一键推送当前分支到远程
pushup = "!git push --set-upstream origin $(git rev-parse --abbrev-ref HEAD)"
还有一个我特别喜欢的,一键清理已经合并到主干的本地分支,分支列表干净清爽。
git branch --merged main | grep -v "*|main|develop" | xargs -n 1 git branch -d
文本处理,快准狠
从日志里捞个邮箱,或者快速格式化一坨 JSON,都是日常操作。
# 从文件里提取所有 URL
grep -oE 'https?://[a-zA-Z0-9./-]+' access.log
# 格式化粘贴板里的 JSON (macOS)
pbpaste | jq .
# 从 API 响应中只提取需要的字段
curl -s 'https://api.github.com/users/torvalds' | jq '.name, .followers'
把重复劳动变成自动化脚本
真正拉开效率差距的,是把那些每天都在重复的操作,变成一个可以随时呼叫的函数或脚本。
# 比如,创建一个新前端项目的完整流程
new_react_project() {
npx create-react-app "$1" && cd "$1"
git init && git add .
git commit -m "🎉 Initial commit"
# 自动在 VS Code 中打开
code .
}
# 比如,在执行危险操作前,快速打包备份当前目录
backup() {
local fname="backup-$(date +%Y%m%d-%H%M).tar.gz"
tar -czvf "$fname" . --exclude-from=.gitignore
echo "备份完成: $fname"
}
把这些函数写进 .bashrc 或 .zshrc,下次再做同样的事情时,只需要敲一个命令就搞定了。
写在最后
这些技巧本身并不复杂,但它们就像肌肉记忆,一旦养成习惯,就能在日常工作中节省大量时间。对程序员来说,时间就是头发。
来源:juejin.cn/post/7573988811916017714
别再死磕框架了!你的技术路线图该更新了
先说结论:
前端不会凉,但“只会几个框架 API”的前端,确实越来越难混
这两年“前端要凉了”“全栈替代前端”的声音此起彼伏,本质是门槛重新洗牌:
- 简单 CRUD、纯样式开发被低代码、模板代码和 AI 模型快速蚕食;
- 复杂业务、工程体系、跨端体验、AI 能力集成,反而需要更强的前端工程师去撑住。
如果你对“前端的尽头是跑路转管理”已经开始迷茫,那这篇就是给你看的:别再死磕框架版本号,该更新的是你的技术路线图。
一、先搞清楚:2025 的前端到底在变什么?
框架红海:从“会用”到“用得值”
React、Vue、Svelte、Solid、Qwik、Next、Nuxt……Meta Framework 一大堆,远远超过岗位需求。
现在企业选型更关注:
- 生态成熟度(如 Next.js 的 SSR/SSG 能力)
- 框架在应用生命周期中的角色(渲染策略、数据流转、SEO、部署)
趋势:
- 框架 Meta 化(Next.js、Nuxt)将路由、数据获取、缓存策略整体纳入规范;
- 约定优于配置,不再是“一个前端库”,而是“一套完整解决方案”。
以前是“你会 Vue/React 就能干活”,现在是“你要理解框架在整个应用中的角色”。
工具有 AI,开发方式也在变
AI 工具(如 Cursor、GitHub Copilot X)可以显著提速,甚至替代重复劳动。
真正拉开差距的变成了:
- 你能给 AI 写出清晰、可实现的需求描述(Prompt);
- 你能判断 AI 生成代码的质量、潜在风险、性能问题;
- 你能基于生成结果做出合理抽象和重构。
AI 不是来抢饭碗,而是逼你从“码农”进化成“架构和决策的人”。
业务侧:前端不再是“画界面”,而是“做体验 + 做增长”
- B 端产品:交互工程师 + 低代码拼装师 + 复杂表单处理专家;
- C 端产品:与产品运营深度捆绑,懂 A/B 测试、埋点、Funnel 分析、广告投放链路;
- 跨平台:Web + 小程序 + App(RN/Flutter/WebView)混合形态成为常态。
那些还在喊“切图仔优化 padding”的岗位确实在消失,但对“懂业务、有数据意识、能搭全链路体验”的前端需求更高。
二、别再死磕框架 API:2025 的前端核心能力长什么样?
基石能力:Web 原生三件套,得真的吃透
重点不是“会用”,而是理解底层原理:
- JS:事件循环、原型链、Promise 执行模型、ESM 模块化;
- 浏览器:渲染流程(DOM/CSSOM/布局/绘制/合成)、HTTP/2/3、安全防护(XSS/CSRF)。
这块扎实了,你在任何框架下都不会慌,也更能看懂“框架为什么这么设计”。
工程能力:从“会用脚手架”到“能看懂和调整工程栈”
Vite、Rspack、Turbopack 等工具让工程构建从“黑魔法”变成“可组合拼装件”。
你需要:
- 看懂项目的构建配置(Vite/Webpack/Rspack 任意一种);
- 理解打包拆分、动态加载、CI/CD 流程;
- 能排查构建问题(路径解析、依赖冲突)。
如果你在团队里能主动做这些事,别人对你的“级别判断”会明显不一样。
跨端和运行时:不只会“写 Web 页”
2025 年前端视角的关键方向:
- 小程序/多端框架(Taro、Uni-app);
- 混合方案(RN/Flutter/WebView 通信机制);
- 桌面端(Electron、Tauri)。
建议:
- 至少深耕一个“跨端主战场”(如 Web + 小程序 或 Web + Flutter)。
数据和状态:从“会用 Vuex/Redux”到“能设计状态模型”
现代前端复杂度 70% 在“数据和状态管理”。
进阶点在于:
- 设计合理的数据模型(本地 UI 状态 vs 服务端真相);
- 学会用 Query 库、State Machine 解耦状态与视图。
当你能把“状态设计清楚”,你在复杂业务团队里会非常吃香。
性能、稳定性、可观测性:高级前端的硬指标
你需要系统性回答问题,而不是“瞎猜”:
- 性能优化:首屏加载(资源拆分、CDN)、运行时优化(减少重排、虚拟列表);
- 稳定性:错误采集、日志上报、灰度发布;
- 工具:Lighthouse、Web Vitals、Session Replay。
这块做得好的人往往是技术骨干,且很难被低代码或 AI 直接替代。
AI 时代的前端:不是“写 AI”,而是“让 AI 真正跑进产品”
你需要驾驭:
- 基础能力:调用 AI 平台 API(流式返回处理、增量渲染);
- 产品思维:哪些场景适合 AI(智能搜索、文档问答);如何做权限控制、错误兜底。
三、路线图别再按“框架学习顺序”排了,按角色来选
初中级:从“会用”到“能独立负责一个功能”
目标:
- 独立完成中等复杂度模块(登录、权限、表单、列表分页)。
建议路线:
- 夯实 JS + 浏览器基础;
- 选择 React/Vue + Next/Nuxt 做完整项目;
- 搭建 eslint + prettier + git hooks 的开发习惯。
进阶:从“功能前端”到“工程前端 + 业务前端”
目标:
- 优化项目、推进基础设施、给后端/产品提技术方案。
建议路线:
- 深入构建工具(Webpack/Vite);
- 主导一次性能优化或埋点方案;
- 引入 AI 能力(如智能搜索、工单回复建议)。
高级/资深:从“高级前端”到“前端技术负责人”
目标:
- 设计技术体系、推动长期价值。
建议路线:
- 明确团队技术栈(框架、状态管理、打包策略);
- 主导跨部门项目、建立知识分享机制;
- 评估 AI/低代码/新框架的引入价值。
四、2025 年不要再犯的几个错误
- 只跟着热点学框架,不做项目和抽象
- 选一个主战场 + 一个备胎(React+Next.js,Vue+Nuxt.js),用它们做 2~3 个完整项目。
- 完全忽略业务,沉迷写“优雅代码”
- 把重构和业务迭代绑一起,而不是搞“纯技术重构”。
- 对 AI 持敌视和逃避态度
- 把重复劳动交给 AI,把时间投到架构设计、业务抽象上。
- 把“管理”当成唯一出路
- 做前端架构、性能优化平台、低代码平台的技术专家,薪资和自由度不输管理岗。
五、一个现实点的建议:给自己的 2025 做个“年度规划”
Q1:
- 选定主技术栈(React+Next 或 Vue+Nuxt);
- 做一个完整小项目(登录、权限、列表/详情、SSR、部署)。
Q2:
- 深入工程化方向(优化打包体积、搭建监控埋点系统)。
Q3:
- 选一个业务场景引入 AI 或配置化能力(如智能搜索、低代码表单)。
Q4:
- 输出和沉淀(写 3~5 篇技术文章、踩坑复盘)。
最后:别问前端凉没凉,先问问自己“是不是还停在 2018 年的玩法”
- 如果你还把“熟练掌握 Vue/React”当成简历亮点,那确实会焦虑;
- 但如果你能说清楚:
- 在复杂项目里主导过哪些工程优化;
- 如何把业务抽象成可复用的组件/平台;
- 如何在产品里融入 AI/多端/数据驱动;
那么,在 2025 年的前端市场,你不仅不会“凉”,反而会成为别人眼中的“稀缺”。
别再死磕框架了,更新你的技术路线图,从“写页面的人”变成“打造体验和平台的人”。这才是 2025 年前端真正的进化方向。
来源:juejin.cn/post/7573694361474629659
VSCode 推出 绿色版!更强!更智能!
最近,VSCode 再次被推上热门,各大开发社区、推特/X 上都在讨论一个话题—— “VSCode 绿色版(Insiders)功能太强了!”

事实上,VSCode Insiders 并不是一个新名字,但随着微软不断注入 AI 能力、终端增强、Git 大升级、新 UI,它已经变成了真正意义上的:VSCode 超强增强版!

本文将带你全面认识 绿色版都强在哪?为什么开发者都在推荐?你是否应该安装?
🎯 什么是 VSCode 绿色版(Insiders)?
一句话:VSCode 的“更新抢先体验版”,比正式版更快看到新功能!

它和 Stable(正式版)可以共存使用,不会覆盖、不冲突,非常适合爱折腾、追新功能的开发者。
🟢 终端大升级:智能提示终于来了!
VSCode 终端一直是“能用但不好用”;而绿色版直接把它提升到“智能终端”级别。

✨ 1. 命令自动补全(智能建议)
例如你在终端输入:
git ch
它会自动建议:
git checkout
更厉害的是:
- 命令 flag 会被分类展示
- 参数智能提示
- 路径补全更精确
✨ 2. AI 执行命令时使用“真终端渲染器”

绿色版已将 Copilot Agent 的终端输出升级为 xterm.js 真终端渲染器:

ANSI颜色正常显示表格、行列对齐准确- 输出
清晰、结构化
终端从此不再只是“命令窗”,而是智能交互环境。
🌳 Git 管理体验提升:Stash 终于可视化了!
以前 VSCode 对 stash 支持几乎没有,只能敲命令。
这次绿色版直接补上短板!
设置以下选项:
scm.repositories.explorer:truescm.repositories.selectionMode:single

✨ 可视化 Stash 管理器

- 查看所有
stash - 一键
apply / pop / delete - 自动显示
时间戳 - 相同时间段用竖线对齐,界面更清爽
这功能对于频繁切分支、保存临时改动的开发者来说简直是 质的提升。
🤖 AI 全面进化:Copilot Agent 模式上线!
这是绿色版最轰动的更新之一。
✨ 1. Copilot 从“智能补全” → “自动执行任务代理”

它可以自动:
- 修改多个文件
- 执行并分析终端命令
- 管理项目结构
- 重构代码
- 搜索 / 替换文件内容
- 构建 / 运行项目
你只需要一句话:
“帮我给项目增加用户登录功能。”
然后它会自动拆解步骤,帮你完成。
✨ 2. Notebook(Markdown + 代码)也能用 AI 自动编辑

例如:
- 自动创建 Code Cell
- 总结文章内容
- 插入示例代码
- 自动修复错别字
- 做学习笔记
科研、学习、数据分析用户狂喜!
🖥 UI / UX 更现代、更便捷
绿色版对各种细节都做了体验升级:

- Git 同步按钮更直观
- 悬停信息更清晰
- 状态指示更精准
- 布局与配色有实验性改进
整体观感比正式版更轻盈、简洁、顺滑。
🔧 扩展生态更强:插件作者必装
很多新的 API(如 Terminal API、Notebook API、Panel API)都会:
👉 先出现在 Insiders
👉 过几个月再进入正式版
如果你要开发或测试 VSCode 扩展,绿色版是必备。
📥 如何安装 VS Code 绿色版?
安装非常简单:
👉 官方下载安装:https://code.visualstudio.com/insiders
优势:
- 可与正式版并存
- 不影响原环境
- 卸载不留痕
- 随时体验最新功能
✔️ 谁适合使用 VS Code 绿色版?
如果你属于下面任意一个人,请立即安装:
- 喜欢尝鲜
- 重度命令行用户
- Copilot 用户
- 插件开发者
- 关注效率 & 新技术
- 想第一时间体验 VSCode 未来方向
🎉 结语:绿色版不是“内测玩具”,而是更强大的 VSCode
总结一下绿色版带来的增强:
| 功能方向 | 提升 |
|---|---|
| 🔥 终端 | 智能提示 + 真终端渲染 |
| 🌳 Git | 可视化 Stash 管理 |
| 🤖 AI | Copilot 进入自动化时代 |
| 🖥 UI | 更现代、更细腻 |
| 🧩 插件 | 最新 API 先体验 |
一句话:VSCode Insiders 是真正意义上的“VS Code Pro 版”。
如果你还没体验,现在就装一个吧,你会爱上它。
- VSCode Insiders 下载地址:
https://code.visualstudio.com/insiders
来源:juejin.cn/post/7580683234437267519
PAC 2025:在算力风暴中淬炼的国产力量
2025年的夏天虽已远去,然而PAC 2025的热血余温未散:算力的涌动、屏幕的闪烁、代码的狂奔……那份拼搏与激情,仿佛仍在空气中炽烈燃烧,未曾褪色。
顶尖战队齐聚第21届CCF HPC China 2025的PAC决赛现场,展开正面交锋,将激情与实力尽数倾注 “优化” 与 “应用” 两大赛道,现场氛围燃至顶峰。
赛场的热度,不止是代码奔涌时的风扇轰鸣,更是年轻人拼尽全力时的心跳共振。正是这股激情与执着,凝聚成推动国产计算驶向未来的核心动力。终场哨响,PAC2025并行应用挑战赛圆满收官。


鲲鹏撑腰,满格开战
本届大赛全面采用鲲鹏计算平台作为核心硬件底座。以ARM架构为技术核心,其集成的众核架构、向量/矩阵扩展、片上内存高带宽等硬件特性,成为参赛团队挖掘极致性能的核心载体,也标志着国产CPU平台正式成为高性能计算技术探索的关键阵地。
技术亮点回顾“硬件-软件-应用”的全栈突破
硬件架构特性的深度挖掘:以鲲鹏 ARM 为核心,释放国产 CPU 潜力
ARM 技术的规模化应用:特等奖获得者清华大学深圳国际研究生院团队(简称清华团队)充分发挥矩阵运算可伸缩向量扩展的优势,通过循环重排与数据预取优化GEMM与HPCG性能,最大化鲲鹏CPU的向量计算吞吐。在INT8低精度计算与Attention算子这一核心挑战上,清华、浙大、山大团队均依托鲲鹏平台的矩阵算力,实现了“向量→矩阵”的计算单元升级。例如,清华团队利用矩阵运算单指令完成 Tile 级乘加,大幅降低指令数量与寄存器压力;浙江大学团队则验证“矩阵运算+片上内存”组合的优势,将鲲鹏CPU的带宽与矩阵吞吐拉至接近GPU量级,减少CPU与加速器的数据搬运延迟。
鲲鹏硬件优势的协同验证:山东大学团队在应用赛道中,基于鲲鹏新一代CPU的多核并行与高带宽优势,实现了 20 亿原子体系的分子动力学模拟。在弱扩展8倍、强扩展 4 倍的条件下仍保持80%并行效率,直接证明了国产CPU在超大规模科学计算中的端到端性能,已具备与GPU相当的竞争力。

PAC2025上机现场
软件优化创新:硬件特性与软件策略的深度协同
精细化内存与计算调度:清华团队采用二维 Tiling 策略,浙江大学团队针对K维度切分以充分利用HPC缓存,均将关键数据留驻L1/L2缓存,减少对内存带宽的依赖,适配鲲鹏的缓存架构设计。此外,清华基于 Pthreads 自建线程池,规避操作系统调度开销,实现鲲鹏多核间的任务均衡分配,并行效率较传统方案提升显著。
精度与性能的平衡优化:针对混合精度计算需求,浙大提出“fp32保存中间变量 + svzip 转化为 fp16”的方法,避免了纯 fp16 的指数溢出问题;山大则提出“全流程混合精度向量化”,并自研 ARM 向量化超越函数库,进一步适配鲲鹏平台的指令集特性,在保证计算正确性的前提下,效率提升 20%-30%。
算子级优化突破:山东大学团队在优化赛道中,针对 INT8GEMM 与 Attention 算子提出“数值扩展+算子融合”全栈方案——基于SVSUMOPA/SVMOPA指令实现2路/4路矩阵外积乘法,结合FlashAttention融合策略,减少中间结果访存开销与线程竞争,使大Batch训练与大模型推理的稳定性提升40%以上,为鲲鹏平台的AI算子库建设提供直接技术参考。

PAC2025答辩现场
应用落地突破:覆盖 AI 与科学计算的多领域验证
AI 计算:清华团队的矩阵运算加速与山大的算子融合成果,可直接应用于鲲鹏生态的 AI 芯片与 CPU,为大模型推理(如语音识别、视觉计算)与中小规模训练提供高性能算子支撑,有效解决国产平台“AI计算性能不足”的核心痛点。
科学计算:清华团队的 HPCG 优化与山大的分子动力学模拟,验证了鲲鹏平台在气象、天文、流体力学、药物研发等领域的适用性——如山东大学团队的成果可直接复用至新能源材料设计与复杂流体计算,为国产高性能计算的行业落地提供技术范本。

PAC的意义:从赛场到未来
PAC大赛的成果不是单点的创新打法,而是真正能走出赛场、落到产业的技术。无论是算子优化,还是大规模科学计算模拟,都已具备直接赋能科研与产业的潜力。
PAC 2025的意义,在于夯实国产算力生态,让以鲲鹏为核心的国产 CPU 走向成熟,打破“高性能依赖国外架构”的偏见;在于推动“硬件—软件—应用”的全栈融合,让协同优化成为可复制的范式;更在于将成果带入产业与人才的长远布局,既赋能 AI、大模型、分子动力学等应用场景,也培养出一批能够横跨硬件、软件与应用的青年力量。
从 ARM 架构的深度挖掘,到软硬件的协同优化,再到端到端的应用突破,PAC 2025 让国产算力不再只是“能用”,而是真正“好用”。它证明了我们不再只是被动追赶,而是已能与前沿并肩而行,正全力奔向属于中国的高性能计算未来。
女友去玩,竟带回一道 “虐哭程序员” 的难题

事件交代
前两周,女朋友去了开封玩一趟,回来后不久,她微信上和我吐槽了一件糟心事:由于她在两个平台都购了票,后面行程太赶,忘记在其中一个平台退票,导致现在无法退票

由于广州很多景点买票是要选择具体日期的(例如白云山、陈家祠),所以我以为她可能就是在抖音、美团买了同一天的两张票,后面忘记退掉抖音的票,导致现在票逾期,无法退款
但我还是想的太简单了,如果仅此而已,大家就不会看到这篇小破文
深入了解
随着我深入了解,才发现开封景区的购票大有不同,以下是我了解到的开封景区购票方式:
1️⃣ 你去哪个景点就买哪个景点的票,这种模式只适合去单个景区游玩,但开封景区特别多,这样买特别亏
2️⃣ 购买景区联票,开封这边景区基本是相互合作的,例如他们会出2个、4个、6个等的景区联票套餐,我女朋友就是先在抖音购买了4景区联票,后面她朋友又发现美团有6景区联票,玩起来更划算,所以才会出现了买两个平台的票的情况
抖音4景区联票:

美团6景区联票:

抖音4景区联票包含:万岁山武侠城+翰园碑林+开封城墙+铁塔公园
美团6景区联票包含:万岁山武侠城+翰园碑林+开封城墙+铁塔公园+龙亭景区+天波杨府
其中,美团的6景区联票中,龙亭景区和天波杨府是抖音4景区联票中没有的,这两个信息特别关键
其次还有两个关键信息:
(1)平台承诺随时退:票虽然有使用截止日期,但在截止日期之前,是可以申请退款,如果截止日期仍未使用,系统自动退款
(2)女友的两个平台订单状态皆为已使用
那我开头揣测是票逾期,无法退款的情况就是错误的,既然如此,是什么情况导致了两个订单都为已使用呢?
逐层分析
我原本以为景区的核销订单模式会是:订单生成一个二维码,进入景区时有个机器扫描
如此一来,就可以精准锁定平台的订单核销,但我再次猜错了。女友和我说开封这边的景区都是使用了人脸识别技术,购票时只需要录入身-份-证号,到景区就能人脸识别进入了。
到这里事情已经逐渐清晰了,于是,我尝试站在一名程序员的角度来分析景区的核销流程
- 第一步:识别用户身份

- 第二步:识别用户身份成功,分析该用户是否在合作平台购买过相关景区票

- 第三步:核销平台订单,也是最复杂的一步,因为该环节存在多种情况
1️⃣:该用户在一个平台购买了一个景区的票,这种情况最容易,用户进景区后,票务系统直接核销平台订单,核销完成平台的钱就流向票务系统;
2️⃣:该用户在一个平台购买了联合景区的票,此时锁定该票,等到用户玩完最后一个景区后,才核销订单;有人可能会存在疑惑,为什么不是玩第一个景区时就进行订单核销,而是等玩完全部景区才触发核销?这就涉及到多个平台卖票的问题,程序设计需要兼容多个平台:假设你在抖音购买了两景区联合票:万岁山+铁塔公园,在美团购买了两景区联合票:万岁山+开封城墙,你先去了万岁山,此时如果游玩第一个景区就要核销订单的话,该核销哪个平台的订单?是不是没法核销了,只有等第二个景区玩完你才知道要核销哪个平台的订单;但即使如此,也存在一种情况,就是用户只玩了一个景区,其他景区不去玩。我猜这种特殊情况下,该票就一直处于锁定未核销的状态,直到订单时间逾期,才自动核销
3️⃣:该用户在多个平台购买了一个景区的票,说实在这种情况属实矛盾,但作为一名程序员,还是得给解,我猜很有可能是以时间优先来核销订单的,也就是先在哪个平台下单,就先核销该平台的订单,毕竟用户肯定是希望核销日期更早的一张票
4️⃣:该用户在多个平台购买了联合景区的票,而我女朋友的情况就属于这一种情况,按理说这种情况也挺好解,还记得我前面提到的龙亭景区、天波杨府吗?这两个景区是抖音平台4景区联票套餐中不包含的,而美团平台6景区联票中包含的,也就是说我女朋友她们去了其中一个,就可判断出核销美团平台的订单
但巧妙又凑合的一点就是她们的游玩路线居然是:万岁山武侠城->翰园碑林->开封城墙->铁塔公园->天波杨府->龙亭景区,恰巧把两个特殊的景区放在了最后游玩
当游玩万岁山武侠城->翰园碑林->开封城墙->铁塔公园时,对于抖音平台的订单来说,已经是满足触发核销的条件,我知道此时同为聪明程序员的你也会大有疑问🤔:美团平台的订单同样存在这4个景区,不应该直接就核销抖音平台的订单。
还记得情况3吗?也就是用户在多个平台购买了一个景区票的情况,我猜测的是票务系统以时间优先来核销不同平台的订单,再看看我开头贴出来的聊天记录,我女朋友也说了:先购买抖音的4景区联票,后购买美团的6景区联票
故此,以时间优先来核销的话,程序先核销抖音平台的订单确实没问题,当她们开心地继续畅玩天波杨府->龙亭景区时,却不知又触发了新一轮美团的订单锁单,待玩完这俩景区后,剩余的景点没玩,以至于三天逾期后自动核销订单,这也佐证了情况2里的猜测
复盘总结
后续打电话跟抖音平台核实了相关的情况,顺利完成了退票退款流程。女朋友还在一旁跟我聊着整个出行的趣事,但此时我满脑子都是在想着怎么修复票务系统中的bug🐛
一开始想的方案解决方案很粗暴:就是让景区提供一个特殊通道,用户可以通过打开二维码来核销平台订单,但这种也预防不了用户通过人脸识别进入的情况
后面想了一个从根本上解决的方案:用户人脸识别,检测到存在多个平台订单时,需要提示用户选择指定平台的订单进行核销,这样就可以彻底预防多个平台重复核销的情况了
今天的分享就到此结束,如果你对技术/行业交流有兴趣,欢迎添加howcoder微信,邀你进群交流
往期精彩
来源:juejin.cn/post/7576086446459404323
Hutool被卖半年多了,现状是逆袭还是沉寂?
是的,没错。那个被人熟知的国产开源框架 Hutool 距离被卖已经过去近 7 个月了。
那 Hutool 现在的发展如何呢?它未来有哪些更新计划呢?Hutool AI 又该如何使用呢?如果不想用 Hutool 有没有可替代的框架呢?
近半年现状
从 Hutool 官网可以看出,其被卖近 7 个月内仅发布了 4 个版本更新,除了少量的新功能外,大多是 Bug 修复,当期在此期间发布了 Hutool AI 模块,算是一个里程碑式的更新:

收购公司
没错,收购 Hutool 的这家公司和收购 AList 的公司是同一家公司(不够科技),该公司前段时间因为其在收购 AList 代码中悄悄收集用户设备信息,而被推向过风口浪尖,业内人士认为其收购开源框架就是为了“投毒”,所以为此让收购框架损失了很多忠实的用户。
其实,放眼望去那些 APP 公司收集用户设备和用户信息属于家常便饭了(国内隐私侵犯问题比较严重),但 AList 因为其未做文档声明,且未将收集设备信息的代码提交到公共仓库,所以大家发现之后才会比较气愤。
Hutool-AI模块使用
Hutool AI 模块的发布算是被收购之后发布的最值得让人欣喜的事了,使用它可以对接各大 AI 模型的工具模块,提供了统一的 API 接口来访问不同的 AI 服务。
目前支持 DeepSeek、OpenAI、Grok 和豆包等主流 AI 大模型。
该模块的主要特点包括:
- 统一的 API 设计,简化不同 AI 服务的调用方式。
- 支持多种主流 AI 模型服务。
- 灵活的配置方式。
- 开箱即用的工具方法。
- 一行代码调用。
具体使用如下。
1.添加依赖
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-ai</artifactId>
<version>5.8.38</version>
</dependency>
2.调用API
实现对话功能:
DoubaoService doubaoService = AIServiceFactory.getAIService(new AIConfigBuilder(ModelName.DOUBAO.getValue()).setApiKey(key).setModel("your bots id").build(), DoubaoService.class);
ArrayList<Message> messages = new ArrayList<>();
messages.add(new Message("system","你是什么都可以"));
messages.add(new Message("user","你想做些什么"));
String botsChat = doubaoService.botsChat(messages);
识别图片:
//可以使用base64图片
DoubaoService doubaoService = AIServiceFactory.getAIService(new AIConfigBuilder(ModelName.DOUBAO.getValue()).setApiKey(key).setModel(Models.Doubao.DOUBAO_1_5_VISION_PRO_32K.getModel()).build(), DoubaoService.class);
String base64 = ImgUtil.toBase64DataUri(Toolkit.getDefaultToolkit().createImage("your imageUrl"), "png");
String chatVision = doubaoService.chatVision("图片上有些什么?", Arrays.asList(base64));
//也可以使用网络图片
DoubaoService doubaoService = AIServiceFactory.getAIService(new AIConfigBuilder(ModelName.DOUBAO.getValue()).setApiKey(key).setModel(Models.Doubao.DOUBAO_1_5_VISION_PRO_32K.getModel()).build(), DoubaoService.class);
String chatVision = doubaoService.chatVision("图片上有些什么?", Arrays.asList("https://img2.baidu.com/it/u=862000265,4064861820&fm=253&fmt=auto&app=138&f=JPEG?w=800&h=1544"),DoubaoCommon.DoubaoVision.HIGH.getDetail());
生成视频:
//创建视频任务
DoubaoService doubaoService = AIServiceFactory.getAIService(new AIConfigBuilder(ModelName.DOUBAO.getValue()).setApiKey(key).setModel("your Endpoint ID").build(), DoubaoService.class);
String videoTasks = doubaoService.videoTasks("生成一段动画视频,主角是大耳朵图图,一个活泼可爱的小男孩。视频中图图在公园里玩耍," +
"画面采用明亮温暖的卡通风格,色彩鲜艳,动作流畅。背景音乐轻快活泼,带有冒险感,音效包括鸟叫声、欢笑声和山洞回声。", "https://img2.baidu.com/it/u=862000265,4064861820&fm=253&fmt=auto&app=138&f=JPEG?w=800&h=1544");
//查询视频生成任务信息
String videoTasksInfo = doubaoService.getVideoTasksInfo("任务id");
未来发展
- Hutool5:目前 Hutool 5.x 版本主要是基于 JDK 8 实现的,后面更新主要以 BUG 修复为准。
- Hutool6:主要以功能尝鲜为主。
- Hutool7:升级为 JDK 17,添加一些新功能,删除一些不用的类。
目前只发布了 Hutool 5.x,按照目前的更新进度来看,不知何时才能盼来 Hutool7 的发布。
同类替代框架
如果担心 Hutool 有安全性问题,或更新不及时的问题可以尝试使用同类开源工具类:
- Apache Commons:commons.apache.org/
- Google Guava:github.com/google/guav…
视频解析
http://www.bilibili.com/video/BV1QR…
小结
虽然我们不知道 Hutool 被收购意味着什么?是会变的越来越好?还是会就此陨落?我们都不知道答案,所以只能把这个问题交给时间。但从个人情感的角度出发,我希望国产开源框架越做越好。好了,我是磊哥,咱们下期见。
本文已收录到我的面试小站 http://www.javacn.site,其中包含的内容有:场景题、SpringAI、SpringAIAlibaba、并发编程、MySQL、Redis、Spring、Spring MVC、Spring Boot、Spring Cloud、MyBatis、JVM、设计模式、消息队列、Dify、Coze、AI常见面试题等。
来源:juejin.cn/post/7547624644507156520
Kafka 消息积压了,同事跑路了
快到年底了,系统频繁出问题。我有正当理由怀疑老板不想发年终奖所以搞事。
这不,几年都遇不到的消息队列积压现象今晚又卷土重来了。
今晚注定是个不眠夜了,原神启动。。。

组里的小伙伴火急火燎找到我说,Kafka 的消息积压一直在涨,预览图一直出不来。我加了几个服务实例,刚开始可以消费,后面消费着也卡住了。
本来刚刚下班的我就比较疲惫,想让他撤回镜像明天再上。不成想组长不讲武德,直接开了个飞书视频。
我当时本来不想理他,我已经下班了,别人上线的功能出问题关我啥事。

后来他趁我不注意搞偷袭,给我私信了,我当时没多想就点开了飞书。
本来以传统功夫的点到为止,我进入飞书不点开他的会话,是能看他给我发的最后一句话的。

我把手放在他那个会话上就是没点开,已读不回这种事做多了不好。我笑了一下,准备洗洗睡了。
正在我收手不点的时候,他突然给我来了一个电话,我大意了啊,没有挂,还强行接了他的电话。两分多钟以后就好了,我说小伙子你不讲武德。
直接喊话,今晚必须解决,大家都点咖啡算他的。
这真没办法,都找上门来了。只能跟着查一下,早点解决早点睡觉。然后我就上 Kafka 面板一看:最初的4个分区已经积压了 1200 条,后面新加的分区也开始积压了,而且积压的速度越来越快。

搞清楚发生了什么?我们就得思考一下导致积压的原因。一般是消费者代码执行出错了,导致某条消息消费不了。
所以某个点卡住了,然后又有新的消息进来。
Kafka 是通过 offset 机制来标记消息是否消费过的,所以如果分区中有某一条消息消费失败,就会导致后面的没机会消费。
我用的是spring cloud stream 来处理消息队列的发送和监听。代码上是每次处理一条消息,而且代码还在处理的过程中加了 try-catch。监听器链路打印的日志显示执行成功了,try-catch也没有捕捉到任何的异常。
这一看,我就以为是消费者性能不足,突然想起 SpringCloudStream 好像有个多线程消费的机制。立马让开发老哥试试,看看能不能就这样解决了,我困得不行。

我半眯着眼睛被提醒吵醒了。开发老哥把多线程改成10之后,发现积压更快了,而且还有pod会挂。老哥查了一下多线程的配置 concurrency。
原来指的是消费者线程,一个消费者线程会负责处理一个分区。对于我们来说,增加之后可能会导致严重的流量倾斜,难怪pod会挂掉,赶紧恢复了回去。
看来想糊弄过去是不行了,我把pod运行的日志全部拉下来。查了一下日志,日志显示执行成功了,但同时有超时错误,这就见了鬼了。

作为一个坚定的唯物主义者,我是不信见鬼的。但此刻我汗毛倒竖,吓得不敢再看屏幕一眼。

但是内心又觉得不甘心,于是我偷偷瞄了一眼屏幕。不看还好,一喵你猜我发现了啥?
消费者组重平衡了,这就像是在黑暗中有一束光照向了我,猪八戒发现嫦娥原来一直暗恋自己,我的女神其实不需要拉屎。
有了重平衡就好说了,无非就是两种情况,一是服务挂掉了,二是服务消费者执行超时了。现在看来服务没有挂,那就是超时了,也正好和上面的日志能对上。

那怎么又看到监听器执行的结果是正常的呢?
这就得从 Kafka 的批量拉取机制说起了,这货和我们人类直觉上的的队列机制不太一样。我们一般理解的队列是发送一个消息给队列,然后队列就异步把这消息给消费者。但是这货是消费者主动去拉取一批来消费。
然后好死不死,SpringCloudStream 为了封装得好用符合人类的认知,就做成了一般理解的队列那种方式。
SpringCloudStream 一批拉了500条记录,然后提供了一个监听器接口让我们实现。入参是一个对象,也就是500条数据中的一条,而不是一个数组。

我们假设这500条数据的ID是 001-500,每一条数据对应的消费者需要执行10s。那么总共就需要500 x 10s=5000s。
再假设消费者执行的超时时间是 300s,而且消费者执行的过程是串行的。那么500条中最多只能执行30条,这就能解释为什么看消费链路是正常的,但是还超时。
因为单次消费确实成功了,但是批次消费也确实超时了。
我咧个豆,破案了。

于是我就想到了两种方式来处理这个问题:第一是改成单条消息消费完立马确认,第二是把批次拉取的数据量改小一点。
第一种方案挺好的,就是性能肯定没有批量那么好,不然你以为 Kafka 的吞吐量能薄纱ActiveMQ这些传统队列。吞吐量都是小事,这个方案胜在可以立马去睡觉了。只需要改一个配置:ack-mode: RECORD
第二种方案是后来提的,其实单单把批次拉取的数据量改小性能提升还不是很明显。不过既然我们都能拿到一批数据了,那多线程安排上就得了。
先改配置,一次只拉取50条 max.poll.records: 50。然后启用线程池处理,完美!
@StreamListener("")
public void consume(List<byte[]> payloads) {
List> futures = payloads.stream().map(bytes -> {
Payload payload = JacksonSnakeCaseUtils.parseJson(new String(bytes), Payload.class);
return CompletableFuture.runAsync(() -> {
// ........
}, batchConsumeExecutor).exceptionally(e -> {
log.error("Thread error {}", bytes, e);
return null;
});
}).collect(Collectors.toList());
try {
// 等待这批消息中的所有任务全部完成
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
errorMessage = "OK";
} catch (Exception e) {
errorMessage = "Ex: " + e.getMessage();
} finally {
// ...
}
}

来源:juejin.cn/post/7573687816431190026
编辑器也有邪修?盘点VS Code邪门/有趣的扩展
VS Code 之所以成为最受欢迎的编辑器之一,很大程度上得益于其丰富的扩展生态。本人精选 20 个实用or有趣的 VS Code 扩展,覆盖摸鱼放松,文件管理、代码规范、效率工具等等多个场景,干货满满,下面正片开始:
1 看小说漫画:any-reader
- 核心功能: 在 VS Code 中阅读小说、文档,支持 TXT/EPUB 格式、章节导航、字体调整。
- 适用场景:利用碎片时间阅读技术文档或轻小说,避免频繁切换应用。
- 隐藏技巧:支持自定义快捷键翻页,可设置阅读定时提醒。

2 偷偷在状态栏看小说:Thief-Book
- 核心功能:在状态栏显示小说,支持阅读进度。
- 适用场景:利用碎片时间阅读技术文档或轻小说,避免频繁切换应用。
- 隐藏技巧:支持快捷键翻页。

3 看股票基金期货:韭菜盒子
- 核心功能:
- 基金实时涨跌,实时数据,支持海外基展示
- 股票实时涨跌,支持 A 股、港股、美股
- 期货实时涨跌,支持国内期货
- 底部状态栏信息
- 开市自动刷新,节假日关闭轮询
- 支持升序/降序排序、基金持仓金额升序/降序
- 基金实时走势图和历史走势图
- 基金排行榜
- 基金持仓信息
- 支持维护持仓成本价,自动计算收益率
- 基金趋势统计图
- 基金支持分组展示等等...
- 注意:投资有风险,入市需谨慎!

4 小霸王
- 核心功能:一款基于vscode的nes游戏插件,主打本地与远程游戏资源管理,让你在编辑器内就能完成游戏的添加、下载、启动全流程。
- 不建议上班玩哈!

5. JSON 变可视化树图:JSON Crack
- 核心功能:将 JSON 数据转换为交互式树状可视化图表,支持折叠/展开节点、搜索内容。
- 适用场景:分析复杂 JSON 结构(如 API 响应、配置文件)、快速定位数据层级。
- 优势:比原生 JSON 格式化更直观,支持大体积 JSON 数据渲染。

6. 改变工作区的颜色来快速识别当前项目:Peacock
- 核心功能:为不同工作区设置独特的颜色主题(标题栏、活动栏颜色),快速区分多个 VS Code 窗口。
- 适用场景:同时打开多个项目时,通过颜色直观识别当前操作的项目(如生产环境项目用红色,测试环境用绿色)。
- 个性化选项:支持按项目自动切换颜色,可自定义颜色饱和度和亮度。

7. 编码时长统计:Time Master
- 核心功能:自动记录代码编写时间、文件修改统计,生成每日/每周编程报告,分析编码效率。
- 适用场景:跟踪项目开发时间、了解自己的编码习惯、评估任务耗时。
- 特色:支持集成到 VS Code 状态栏实时显示当前编码时长,数据本地存储保护隐私。

8. 生成文件夹树结构:file-tree-generator
- 核心功能:一键生成项目文件夹结构并复制为文本,支持自定义忽略文件和格式。
- 适用场景:编写 README 文档、项目说明时快速插入目录结构,或向团队展示项目架构。
- 使用技巧:右键文件夹选择「Generate File Tree」,可通过配置文件自定义输出格式。

9. 轻松切换项目:Project Manager
- 核心功能:快速保存和切换多个项目,支持按标签分类、搜索项目,无需反复通过文件管理器打开文件夹。
- 适用场景:同时开发多个项目时,减少切换成本;整理常用项目集合。
- 特色功能:支持从 Git 仓库、本地文件夹导入项目,可配置项目启动命令。

10. 将文件保存到本地历史记录:Local History
- 核心功能:自动为文件创建本地历史版本,支持对比不同版本差异、恢复误删内容。
- 适用场景:防止代码意外丢失、追踪文件修改记录、找回被覆盖的代码。
- 优势:无需依赖 Git,即使未提交的更改也能保存,历史记录默认保留 90 天。

11. 生成文件头部注释和函数注释:koroFileHeader
- 核心功能:自动生成文件头部注释(作者、日期、描述等)和函数注释,支持自定义注释模板。
- 适用场景:统一团队代码注释规范,快速生成符合 JSDoc、JavaDoc 等标准的注释。
- 高级用法:通过配置文件定义不同语言的注释模板,支持快捷键触发(默认
Ctrl+Alt+i生成函数注释)。

12. 复制 JSON 粘贴为代码:Paste JSON as Code
- 核心功能:将 JSON 数据粘贴为指定语言的类型定义或实体类(支持 TypeScript、Go、C#、Python 等 20+ 语言)。
- 适用场景:根据 API 返回的 JSON 结构快速生成接口类型定义,避免手动编写类型。
- 使用技巧:复制 JSON 后执行命令「Paste JSON as Code」,选择目标语言和变量名即可生成代码。

13. 把代码块框起来:Blockman - Highlight Nested Code Blocks
- 核心功能:用彩色边框高亮嵌套的代码块(如函数、循环、条件语句),直观区分代码层级。
- 适用场景:阅读复杂代码、调试嵌套逻辑时,快速定位代码块边界。
- 特色:支持自定义边框样式、透明度和颜色,兼容大多数代码主题。

14. SVG 预览和编辑:SVG Preview
- 核心功能:在 VS Code 中实时预览 SVG 图片,支持直接编辑 SVG 代码并即时查看效果。
- 适用场景:前端开发中处理 SVG 图标、调整 SVG 路径、优化 SVG 代码。
- 特色:支持放大缩小预览、复制 SVG 代码,兼容大多数 SVG 特性。

15. 程序员鼓励师:Rainbow Fart
- 核心功能:在编码过程中根据输入的关键字触发语音赞美,例如输入 function 时播放 “写得真棒!”,输入 if 时播放 “逻辑清晰,太厉害了!”。支持多语言语音包(中文、英文等),赞美内容与代码语境关联。
- 适用场景:单人开发时缓解编码疲劳,增添趣味性;团队协作时活跃开发氛围;新手学习编程时获得正向反馈。
- 高级功能:支持自定义语音包(可录制个人或团队专属鼓励语音)、配置触发关键字规则、调整音量和触发频率,兼容多种编程语言。
- 文档:传送门

16. 命名风格转换:Name Transform
- 核心功能:一键转换变量名、函数名的命名风格,支持 camelCase(小驼峰)、PascalCase(大驼峰)、snake_case(下划线)、kebab-case(短横线)等常见格式的相互转换。
- 适用场景:接手不同风格的代码时统一命名规范;调用第三方 API 时适配参数命名格式;重构代码时批量修改命名风格。
- 高级功能:支持选中区域批量转换、配置默认转换规则、自定义命名风格映射表,可集成到右键菜单或快捷键(默认 Alt+Shift+T)快速触发。

17. 代码拼写检查器:Code Spell Checker
- 核心功能:实时检查代码中的单词拼写错误(如变量名、注释、字符串中的英文单词),通过下划线标记错误,并提供修正建议。
- 适用场景:避免因拼写错误导致的 Bug(如变量名拼写错误 usre 而非 user);优化代码注释和文档的可读性;英文非母语开发者提升代码规范性。
- 高级功能:支持添加自定义词典(忽略项目专属术语或缩写)、配置语言规则(支持英语、法语等多语言)、批量修复重复拼写错误,可集成到 CI/CD 流程中进行拼写检查。

18. 自动修改标签名:Auto Rename Tag
- 核心功能:在 HTML、XML、JSX、Vue 等标记语言中,修改开始标签时自动同步更新对应的结束标签,无需手动修改配对标签。
- 适用场景:前端开发中修改 HTML/JSX 标签(如将改为 时,结束标签自动变为 );编辑 XML 配置文件或 Vue 模板时避免标签不匹配问题。
- 高级功能:支持自定义匹配的标签语言(默认覆盖 HTML、Vue、React 等)、配置标签同步延迟时间、忽略自闭合标签,兼容嵌套标签结构。

19. 快速调试打印:Console Helper
- 核心功能:一键生成格式化的调试打印语句(如 console.log()、console.error()),自动填充变量名和上下文信息,支持快捷键快速插入。
- 适用场景:前端开发中快速添加调试日志;后端开发调试变量值或函数执行流程;临时排查代码逻辑问题时减少重复输入。
- 高级功能:支持自定义打印模板(如添加时间戳、文件名、行号)、一键注释 / 取消所有打印语句、自动删除冗余打印,兼容 JavaScript、TypeScript、Python 等多种语言。

20. 彩虹括号:Rainbow Brackets
- 核心功能:为嵌套的括号(圆括号、方括号、花括号)添加不同颜色,增强代码层次感。
- 适用场景:编写嵌套较深的代码(如 JSON 结构、条件语句、函数嵌套)时,快速识别括号配对关系。
- 自定义选项:支持调整颜色主题、忽略特定文件类型、配置括号样式。

如果看了觉得有帮助的,我是鹏多多,欢迎 点赞 关注 评论;
往期文章
- flutter-使用AnimatedDefaultTextStyle实现文本动画
- js使用IntersectionObserver实现目标元素可见度的交互
- Web前端页面开发阿拉伯语种适配指南
- 让网页拥有App体验?PWA 将网页变为桌面应用的保姆级教程PWA
- 助你上手Vue3全家桶之Vue3教程
- 使用nvm管理node.js版本以及更换npm淘宝镜像源
- 超详细!Vue的十种通信方式
- 手把手教你搭建规范的团队vue项目,包含commitlint,eslint,prettier,husky,commitizen等等
个人主页
来源:juejin.cn/post/7536530451461472265
我发现很多程序员都不会打日志。。。
你是小阿巴,刚入职的低级程序员,正在开发一个批量导入数据的程序。
没想到,程序刚上线,产品经理就跑过来说:小阿巴,用户反馈你的程序有 Bug,刚导入没多久就报错中断了!
你赶紧打开服务器,看着比你发量都少的报错信息:

你一脸懵逼:只有这点儿信息,我咋知道哪里出了问题啊?!
你只能硬着头皮让产品经理找用户要数据,然后一条条测试,看看是哪条数据出了问题……
原本大好的摸鱼时光,就这样无了。
这时,你的导师鱼皮走了过来,问道:小阿巴,你是持矢了么?脸色这么难看?

你无奈地说:皮哥,刚才线上出了个 bug,我花了 8 个小时才定位到问题……
鱼皮皱了皱眉:这么久?你没打日志吗?
你很是疑惑:谁是日志?为什么要打它?

鱼皮叹了口气:唉,难怪你要花这么久…… 来,我教你打日志!
⭐️ 本文对应视频版:bilibili.com/video/BV1K7…
什么是日志?
鱼皮打开电脑,给你看了一段代码:
@Slf4j
public class UserService {
public void batchImport(List userList) {
log.info("开始批量导入用户,总数:{}", userList.size());
int successCount = 0;
int failCount = 0;
for (UserDTO userDTO : userList) {
try {
log.info("正在导入用户:{}", userDTO.getUsername());
validateUser(userDTO);
saveUser(userDTO);
successCount++;
log.info("用户 {} 导入成功", userDTO.getUsername());
} catch (Exception e) {
failCount++;
log.error("用户 {} 导入失败,原因:{}", userDTO.getUsername(), e.getMessage(), e);
}
}
log.info("批量导入完成,成功:{},失败:{}", successCount, failCount);
}
}
你看着代码里的 log.info、log.error,疑惑地问:这些 log 是干什么的?
鱼皮:这就是打日志。日志用来记录程序运行时的状态和信息,这样当系统出现问题时,我们可以通过日志快速定位问题。

你若有所思:哦?还可以这样!如果当初我的代码里有这些日志,一眼就定位到问题了…… 那我应该怎么打日志?用什么技术呢?
怎么打日志?
鱼皮:每种编程语言都有很多日志框架和工具库,比如 Java 可以选用 Log4j 2、Logback 等等。咱们公司用的是 Spring Boot,它默认集成了 Logback 日志框架,你直接用就行,不用再引入额外的库了~

日志框架的使用非常简单,先获取到 Logger 日志对象。
1)方法 1:通过 LoggerFactory 手动获取 Logger 日志对象:
public class MyService {
private static final Logger logger = LoggerFactory.getLogger(MyService.class);
}
2)方法 2:使用 this.getClass 获取当前类的类型,来创建 Logger 对象:
public class MyService {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
}
然后调用 logger.xxx(比如 logger.info)就能输出日志了。
public class MyService {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
public void doSomething() {
logger.info("执行了一些操作");
}
}
效果如图:

小阿巴:啊,每个需要打日志的类都要加上这行代码么?
鱼皮:还有更简单的方式,使用 Lombok 工具库提供的 @Slf4j 注解,可以自动为当前类生成日志对象,不用手动定义啦。
@Slf4j
public class MyService {
public void doSomething() {
log.info("执行了一些操作");
}
}
上面的代码等同于 “自动为当前类生成日志对象”:
private static final org.slf4j.Logger log =
org.slf4j.LoggerFactory.getLogger(MyService.class);
你咧嘴一笑:这个好,爽爽爽!

等等,不对,我直接用 Java 自带的 System.out.println 不也能输出信息么?何必多此一举?
System.out.println("开始导入用户" + user.getUsername());
鱼皮摇了摇头:千万别这么干!
首先,System.out.println 是一个同步方法,每次调用都会导致耗时的 I/O 操作,频繁调用会影响程序的性能。

而且它只能输出信息到控制台,不能灵活控制输出位置、输出格式、输出时机等等。比如你现在想看三天前的日志,System.out.println 的输出早就被刷没了,你还得浪费时间找半天。

你恍然大悟:原来如此!那使用日志框架就能解决这些问题吗?
鱼皮点点头:没错,日志框架提供了丰富的打日志方法,还可以通过修改日志配置文件来随心所欲地调教日志,比如把日志同时输出到控制台和文件中、设置日志格式、控制日志级别等等。

在下苦心研究日志多年,沉淀了打日志的 8 大邪修秘法,先传授你 2 招最基础的吧。
打日志的 8 大最佳实践
1、合理选择日志级别
第一招,日志分级。
你好奇道:日志还有级别?苹果日志、安卓日志?
鱼皮给了你一巴掌:可不要乱说,日志的级别是按照重要程度进行划分的。

其中 DEBUG、INFO、WARN 和 ERROR 用的最多。
- 调试用的详细信息用 DEBUG
- 正常的业务流程用 INFO
- 可能有问题但不影响主流程的用 WARN
- 出现异常或错误的用 ERROR
log.debug("用户对象的详细信息:{}", userDTO); // 调试信息
log.info("用户 {} 开始导入", username); // 正常流程信息
log.warn("用户 {} 的邮箱格式可疑,但仍然导入", username); // 警告信息
log.error("用户 {} 导入失败", username, e); // 错误信息
你挠了挠头:俺直接全用 DEBUG 不行么?
鱼皮摇了摇头:如果所有信息都用同一级别,那出了问题时,你怎么快速找到错误信息?

在生产环境,我们通常会把日志级别调高(比如 INFO 或 WARN),这样 DEBUG 级别的日志就不会输出了,防止重要信息被无用日志淹没。

你点点头:俺明白了,不同的场景用不同的级别!
2、正确记录日志信息
鱼皮:没错,下面教你第二招。你注意到我刚才写的日志里有一对大括号 {} 吗?
log.info("用户 {} 开始导入", username);
你回忆了一下:对哦,那是啥啊?
鱼皮:这叫参数化日志。{} 是一个占位符,日志框架会在运行时自动把后面的参数值替换进去。
你挠了挠头:我直接用字符串拼接不行吗?
log.info("用户 " + username + " 开始导入");
鱼皮摇摇头:不推荐。因为字符串拼接是在调用 log 方法之前就执行的,即使这条日志最终不被输出,字符串拼接操作还是会执行,白白浪费性能。

你点点头:确实,而且参数化日志比字符串拼接看起来舒服~

鱼皮:没错。而且当你要输出异常信息时,也可以使用参数化日志:
try {
// 业务逻辑
} catch (Exception e) {
log.error("用户 {} 导入失败", username, e); // 注意这个 e
}
这样日志框架会同时记录上下文信息和完整的异常堆栈信息,便于排查问题。

你抱拳:学会了,我这就去打日志!
3、把控时机和内容
很快,你给批量导入程序的代码加上了日志:
@Slf4j
public class UserService {
public BatchImportResult batchImport(List userList) {
log.info("开始批量导入用户,总数:{}", userList.size());
int successCount = 0;
int failCount = 0;
for (UserDTO userDTO : userList) {
try {
log.info("正在导入用户:{}", userDTO.getUsername());
// 校验用户名
if (StringUtils.isBlank(userDTO.getUsername())) {
throw new BusinessException("用户名不能为空");
}
// 保存用户
saveUser(userDTO);
successCount++;
log.info("用户 {} 导入成功", userDTO.getUsername());
} catch (Exception e) {
failCount++;
log.error("用户 {} 导入失败,原因:{}", userDTO.getUsername(), e.getMessage(), e);
}
}
log.info("批量导入完成,成功:{},失败:{}", successCount, failCount);
return new BatchImportResult(successCount, failCount);
}
}
光做这点还不够,你还翻出了之前的屎山代码,想给每个文件都打打日志。

但打着打着,你就不耐烦了:每段代码都要打日志,好累啊!但是不打日志又怕出问题,怎么办才好?
鱼皮笑道:好问题,这就是我要教你的第三招 —— 把握打日志的时机。
对于重要的业务功能,我建议采用防御性编程,先多多打日志。比如在方法代码的入口和出口记录参数和返回值、在每个关键步骤记录执行状态,而不是等出了问题无法排查的时候才追悔莫及。之后可以再慢慢移除掉不需要的日志。

你叹了口气:这我知道,但每个方法都打日志,工作量太大,都影响我摸鱼了!
鱼皮:别担心,你可以利用 AOP 切面编程,自动给每个业务方法的执行前后添加日志,这样就不会错过任何一次调用信息了。

你双眼放光:这个好,爽爽爽!

鱼皮:不过这样做也有一个缺点,注意不要在日志中记录了敏感信息,比如用户密码。万一你的日志不小心泄露出去,就相当于泄露了大量用户的信息。

你拍拍胸脯:必须的!
4、控制日志输出量
一个星期后,产品经理又来找你了:小阿巴,你的批量导入功能又报错啦!而且怎么感觉程序变慢了?
你完全不慌,淡定地打开服务器的日志文件。结果瞬间呆住了……
好家伙,满屏都是密密麻麻的日志,这可怎么看啊?!

鱼皮看了看你的代码,摇了摇头:你现在每导入一条数据都要打一些日志,如果用户导入 10 万条数据,那就是几十万条日志!不仅刷屏,还会影响性能。
你有点委屈:不是你让我多打日志的么?那我应该怎么办?
鱼皮:你需要控制日志的输出量。
1)可以添加条件来控制,比如每处理 100 条数据时才记录一次:
if ((i + 1) % 100 == 0) {
log.info("批量导入进度:{}/{}", i + 1, userList.size());
}
2)或者在循环中利用 StringBuilder 进行字符串拼接,循环结束后统一输出:
StringBuilder logBuilder = new StringBuilder("处理结果:");
for (UserDTO userDTO : userList) {
processUser(userDTO);
logBuilder.append(String.format("成功[ID=%s], ", userDTO.getId()));
}
log.info(logBuilder.toString());
3)还可以通过修改日志配置文件,过滤掉特定级别的日志,防止日志刷屏:
<appender name="FILE" class="ch.qos.logback.core.FileAppender">
<file>logs/app.logfile>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>INFOlevel>
filter>
appender>
5、统一日志格式
你开心了:好耶,这样就不会刷屏了!但是感觉有时候日志很杂很乱,尤其是我想看某一个请求相关的日志时,总是被其他的日志干扰,怎么办?
鱼皮:好问题,可以在日志配置文件中定义统一的日志格式,包含时间戳、线程名称、日志级别、类名、方法名、具体内容等关键信息。
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%npattern>
encoder>
appender>
这样输出的日志更整齐易读:

此外,你还可以通过 MDC(Mapped Diagnostic Context)给日志添加额外的上下文信息,比如请求 ID、用户 ID 等,方便追踪。

在 Java 代码中,可以为 MDC 设置属性值:
@PostMapping("/user/import")
public Result importUsers(@RequestBody UserImportRequest request) {
// 1. 设置 MDC 上下文信息
MDC.put("requestId", generateRequestId());
MDC.put("userId", String.valueOf(request.getUserId()));
try {
log.info("用户请求处理完成");
// 执行具体业务逻辑
userService.batchImport(request.getUserList());
return Result.success();
} finally {
// 2. 及时清理MDC(重要!)
MDC.clear();
}
}
然后在日志配置文件中就可以使用这些值了:
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - [%X{requestId}] [%X{userId}] %msg%npattern>
encoder>
appender>
这样,每个请求、每个用户的操作一目了然。

6、使用异步日志
你又开心了:这样打出来的日志,确实舒服,爽爽爽!但是我打日志越多,是不是程序就会更慢呢?有没有办法能优化一下?
鱼皮:当然有,可以使用 异步日志。
正常情况下,你调用 log.info() 打日志时,程序会立刻把日志写入文件,这个过程是同步的,会阻塞当前线程。而异步日志会把写日志的操作放到另一个线程里去做,不会阻塞主线程,性能更好。
你眼睛一亮:这么厉害?怎么开启?
鱼皮:很简单,只需要修改一下配置文件:
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
<queueSize>512queueSize>
<discardingThreshold>0discardingThreshold>
<neverBlock>falseneverBlock>
<appender-ref ref="FILE" />
appender>
<root level="INFO">
<appender-ref ref="ASYNC" />
root>
不过异步日志也有缺点,如果程序突然崩溃,缓冲区中还没来得及写入文件的日志可能会丢失。

所以要权衡一下,看你的系统更注重性能还是日志的完整性。
你想了想:我们的程序对性能要求比较高,偶尔丢几条日志问题不大,那我就用异步日志吧。
7、日志管理
接下来的很长一段时间,你混的很舒服,有 Bug 都能很快发现。
你甚至觉得 Bug 太少、工作没什么激情,所以没事儿就跟新来的实习生阿坤吹吹牛皮:你知道日志么?我可会打它了!

直到有一天,运维小哥突然跑过来:阿巴阿巴,服务器挂了!你快去看看!
你连忙登录服务器,发现服务器的硬盘爆满了,没法写入新数据。
你查了一下,发现日志文件竟然占了 200GB 的空间!

你汗流浃背了,正在考虑怎么甩锅,结果阿坤突然鸡叫起来:阿巴 giegie,你的日志文件是不是从来没清理过?
你尴尬地倒了个立,这样眼泪就不会留下来。

鱼皮叹了口气:这就是我要教你的下一招 —— 日志管理。
你好奇道:怎么管理?我每天登服务器删掉一些历史文件?
鱼皮:人工操作也太麻烦了,我们可以通过修改日志配置文件,让框架帮忙管理日志。
首先设置日志的滚动策略,可以根据文件大小和日期,自动对日志文件进行切分。
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>logs/app-%d{yyyy-MM-dd}.%i.logfileNamePattern>
<maxFileSize>10MBmaxFileSize>
<maxHistory>30maxHistory>
rollingPolicy>
这样配置后,每天会创建一个新的日志文件(比如 app-2025-10-23.0.log),如果日志文件大小超过 10MB 就再创建一个(比如 app-2025-10-23.1.log),并且只保留最近 30 天的日志。

还可以开启日志压缩功能,进一步节省磁盘空间:
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>logs/app-%d{yyyy-MM-dd}.log.gzfileNamePattern>
rollingPolicy>

你有些激动:吼吼,这样我们就可以按照天数更快地查看日志,服务器硬盘也有救啦!
8、集成日志收集系统
两年后,你负责的项目已经发展成了一个大型的分布式系统,有好几十个微服务。
如今,每次排查问题你都要登录到不同的服务器上查看日志,非常麻烦。而且有些请求的调用链路很长,你得登录好几台服务器、看好几个服务的日志,才能追踪到一个请求的完整调用过程。

你简直要疯了!
于是你找到鱼皮求助:现在查日志太麻烦了,当年你还有一招没有教我,现在是不是……
鱼皮点点头:嗯,对于分布式系统,就必须要用专业的日志收集系统了,比如很流行的 ELK。
你好奇:ELK 是啥?伊拉克?
阿坤抢答道:我知道,就是 Elasticsearch + Logstash + Kibana 这套组合。
简单来说,Logstash 负责收集各个服务的日志,然后发送给 Elasticsearch 存储和索引,最后通过 Kibana 提供一个可视化的界面。

这样一来,我们可以方便地集中搜索、查看、分析日志。

你惊讶了:原来日志还能这么玩,以后我所有的项目都要用 ELK!
鱼皮摆摆手:不过 ELK 的搭建和运维成本比较高,对于小团队来说可能有点重,还是要按需采用啊。
结局
至此,你已经掌握了打日志的核心秘法。

只是你很疑惑,为何那阿坤竟对日志系统如此熟悉?
阿坤苦笑道:我本来就是日志管理大师,可惜我上家公司的同事从来不打日志,所以我把他们暴打了一顿后跑路了。
阿巴 giegie 你要记住,日志不是写给机器看的,是写给未来的你和你的队友看的!
你要是以后不打日志,我就打你!
更多
来源:juejin.cn/post/7569159131819753510
网页版微信来了!无需下载安装客户端!
大家好,我是 Java陈序员。
你是否遇到过:在公共电脑上想临时用微信却担心账号安全,服务器或 Linux 系统上找不到合适的微信客户端,或者想在多个设备上便捷访问微信却受限于安装环境?
今天,给大家介绍一个超实用的开源项目,让你通过浏览器就能轻松使用微信,无需在本地安装客户端!
项目介绍
wechat-selkies —— 基于 Docker 的微信/QQ Linux 客户端,将官方微信/QQ Linux 客户端封装在容器中,借助 Selkies WebRTC 技术,实现了通过浏览器直接访问使用。
功能特色:
- 浏览器访问:通过 Web 浏览器直接使用微信,无需本地安装
- Docker化部署:简单的容器化部署,环境隔离
- 数据持久化:支持配置和聊天记录持久化存储
- 中文支持:完整的中文字体和本地化支持,支持本地中文输入法
- 图片复制:支持通过侧边栏面板开启图片复制
- 文件传输:支持通过侧边栏面板进行文件传输
- AMD64和ARM64架构支持:兼容主流CPU架构
- 硬件加速:可选的 GPU 硬件加速支持
- 窗口切换器:左上角增加切换悬浮窗,方便切换到后台窗口,为后续添加其它功能做基础
- 自动启动:可配置自动启动微信和QQ客户端(可选)
技术栈:
- 基础镜像:
ghcr.io/linuxserver/baseimage-selkies:ubuntunoble - 微信客户端:官方微信 Linux 版本
- Web 技术:Selkies WebRTC
- 容器化:Docker + Docker Compose
安装部署
环境要求
- Docker
- Docker Compose
- 支持 WebRTC 的现代浏览器(Chrome、Firefox、Safari 等)
Docker 部署
1、拉取镜像
# GitHub Container Registry 镜像
docker pull ghcr.io/nickrunning/wechat-selkies:latest
# Docker Hub 镜像
docker pull ghcr.io/nickrunning/wechat-selkies:latest
2、创建挂载目录
mkdir -p /data/software/wechat/conf
3、运行容器
docker run -it -d \
-p 3000:3000 \
-p 3001:3001 \
-v /data/software/wechat/conf:/config \
--device /dev/dri:/dev/dri \
nickrunning/wechat-selkies:latest
4、容器运行成功后,浏览器访问
# HTTP
http://{ip/域名}:3000
# HTTPS
https://{ip/域名}:3001
注意:映射 3000 端口用于 HTTP 访问,3001 端口用于 HTTPS 访问,建议使用 HTTPS.
Docker Compose 部署
1、创建项目目录并进入
mkdir -p /data/software/wechat-selkies
cd /data/software/wechat-selkies
2、创建 docker-compose.yaml 文件
services:
wechat-selkies:
image: nickrunning/wechat-selkies:latest # or ghcr.io/nickrunning/wechat-selkies:latest
container_name: wechat-selkies
ports:
- "3000:3000" # http port
- "3001:3001" # https port
restart: unless-stopped
volumes:
- ./config:/config
devices:
- /dev/dri:/dev/dri # optional, for hardware acceleration
environment:
- PUID=1000 # user ID
- PGID=100 # group ID
- TZ=Asia/Shanghai # timezone
- LC_ALL=zh_CN.UTF-8 # locale
- AUTO_START_WECHAT=true # default is true
- AUTO_START_QQ=false # default is false
# - CUSTOM_USER= # recommended to set a custom user name
# - PASSWORD= # recommended to set a password for selkies web ui
3、启动服务
docker-compose up -d
4、运行成功后,浏览器访问
# HTTP
http://{ip/域名}:3000
# HTTPS
https://{ip/域名}:3001
源码部署
1、克隆或下载项目源码
git clone https://github.com/nickrunning/wechat-selkies.git
cd wechat-selkies
2、启动服务
docker-compose up -d
3、运行成功后,浏览器访问
# HTTP
http://{ip/域名}:3000
# HTTPS
https://{ip/域名}:3001
配置说明
在 docker-compose.yml 中可以配置以下环境变量:
| 变量名 | 默认值 | 说明 |
|---|---|---|
| TITLE | WeChat Selkies | Web UI 标题 |
| PUID | 1000 | 用户 ID |
| PGID | 100 | 组 ID |
| TZ | Asia/Shanghai | 时区设置 |
| LC_ALL | zh_CN.UTF-8 | 语言环境 |
| CUSTOM_USER | - | 自定义用户名(推荐设置) |
| PASSWORD | - | Web UI 访问密码(推荐设置) |
| AUTO_START_WECHAT | true | 是否自动启动微信客户端 |
| AUTO_START_QQ | false | 是否自动启动 QQ 客户端 |
功能体验
wechat-selkies 部署成功后,即可通过浏览器访问。
1、打开地址后,需要使用手机微信进行扫码登录

2、扫码登录成功后,即可开始使用

3、同时支持暗黑主题模式

4、QQ 同样也需要进行扫码登录或者使用账密登录

5、登录成功后,即可开始使用

如果你想在 Linux 系统使用微信或者想随时随地便捷使用微信,不妨试试 wechat-selkies, 可以使用 Docker 快速地部署在服务器上,快去试试吧~
项目地址:https://github.com/nickrunning/wechat-selkies
最后
推荐的开源项目已经收录到 GitHub 项目,欢迎 Star:
https://github.com/chenyl8848/great-open-source-project
或者访问网站,进行在线浏览:
https://chencoding.top:8090/#/
大家的点赞、收藏和评论都是对作者的支持,如文章对你有帮助还请点赞转发支持下,谢谢!
来源:juejin.cn/post/7572921387746377734
我天,Java 已沦为老四。。
略想了一下才发现,自己好像有大半年都没有关注过 TIOBE 社区了。
TIOBE 编程社区相信大家都听过,这是一个查看各种编程语言流行程度和趋势的社区,每个月都有榜单更新,每年也会有年度榜单和总结出炉。
昨晚在家整理浏览器收藏夹时,才想起了 TIOBE 社区,于是打开看了一眼最近的 TIOBE 编程语言社区指数。

没想到,Java 居然已经跌出前三了,并且和第一名 Python 的差距也进一步拉开到了近 18%。

回想起几年前,Java 曾是何等地风光。
各种基于 Java 技术栈所打造的 Web 后端、互联网服务成为了移动互联网时代的中坚力量,同时以 Java 开发为主的后端岗位也是无数求职者们竞相选择的目标。
然而这才过去几年,如今的 Java 似乎也没有了当年那种无与争锋的强劲势头,由此可见 AI 领域的持续进化和繁荣对它的冲击到底有多大。
用数据说话最有说服力。
拉了一下最近这二十多年来 Java 的 TIOBE 社区指数变化趋势看了看,情况似乎不容客观。
可以明显看到的是一个:呈震荡式下降的趋势。

现如今,Java 日常跌出前三已经成为了常态,并且和常居榜首的 Python 的差距也是越拉越大了。
在目前最新发布的 TIOBE Index 榜单中排名前十的编程语言分别是:
- Python
- C++
- C
- Java
- C#
- JavaScript
- Visual Basic
- Go
- Perl
- Delphi/Object Pascal

其中 Python 可谓是一骑绝尘,与排名第二的 C++ 甚至拉开了近 17% 的差距,呈现了断崖式领先的格局。
不愧是 AI 领域当仁不让的“宠儿”,这势头其他编程语言简直是望尘莫及!
另外还值得一提的就是 C 语言。
最近这几个月 C 语言的 TIOBE Index Ratings 比率一直在回升,这说明其生命力还是非常繁荣的,这对于一个已经诞生 50 多年的编程语言来说,着实不易。
C 语言于上个世纪 70 年代初诞生于贝尔实验室,由丹尼斯·里奇(Dennis MacAlistair Ritchie)以肯·汤普森(Kenneth Lane Thompson)所设计的 B 语言为基础改进发展而来的。

就像之前 TIOBE 社区上所描述的,这可能主要和当下物联网(IoT)技术的发展繁荣,以及和当今发布的大量小型智能设备有关。毕竟 C 语言运行于这些对性能有着苛刻要求的小型设备时,性能依然是最出色的。
说到底,编程语言本身并没有所谓的优劣之分,只有合适的应用场景与项目需求。
按照官方的说法,TIOBE 榜单编程语言指数的计算和主流搜索引擎上不同编程语言的搜索命中数是有关的,所以某一程度上来说,可以反映出某个编程语言的热门程度(流行程度、受关注程度)。
而通过观察一个时间跨度范围内的 TIOBE 指数变化,则可以一定程度上看出某个编程语言的发展趋势,这对于学习者来说,可以作为一个参考。
Java:我啥场面没见过

曾经的 Java 可谓是互联网时代不可或缺的存在。早几年的 Java 曲线一直处于高位游走,彼时的 Java 正是构成当下互联网生态繁荣的重要编程语言,无数的 Web 后端、互联网服务,甚至是移动端开发等等都是 Java 的擅长领域。
而如今随着 AI 领域的发展和繁荣,曾经的扛把子如今似乎也感受到了前所未有的压力。
C语言:我厉兵秣马

流水的语言,铁打的 C。
C 语言总是一个经久不衰的经典编程语言,同时也是为数不多总能闯进榜单前三的经典编程语言。
自诞生之日起,C 语言就凭借其灵活性、细粒度和高性能等特性获得了无可替代的位置,就像上文说的,随着如今的万物互联的物联网(IoT)领域的兴起,C 语言地位依然很稳。
C++:我稳中求进

C++ 的确是一门强大的语言,但语言本身的包袱也的确是不小,而且最近这几年的指数趋势稳中求进,加油吧老大哥。
Python:我逆流而上

当别的编程语言都在震荡甚至下跌之时,Python 这几年却强势上扬,这主要和当下的数据科学、机器学习、人工智能等科学领域的繁荣有着很大的关系。
PHP:我现在有点慌

PHP:我不管,我才是世界上最好的编程语言,不接受反驳(手动doge)。
好了,那以上就是今天的内容分享了,感谢大家的阅读,我们下篇见。
注:本文在GitHub开源仓库「编程之路」 github.com/rd2coding/R… 中已经收录,里面有我整理的6大编程方向(岗位)的自学路线+知识点大梳理、面试考点、我的简历、几本硬核pdf笔记,以及程序员生活和感悟,欢迎star。
来源:juejin.cn/post/7540497727161417766
线程池中的坑:线程数配置不当导致任务堆积与拒绝策略失效
“线程池我不是早就会了吗?corePoolSize、maxPoolSize、queueSize 都能背下来!”
—— 真正出事故的时候你就知道,配置这仨数,坑多得跟高考数学题一样。
一、线上事故复盘:任务全卡死,日志一片寂静
几个月前有个定时任务服务,凌晨会并发处理上千个文件。按理说线程池能轻松抗住。
结果那天凌晨,监控报警:任务积压 5 万条,机器 CPU 却只有 3%!
去看线程 dump:
pool-1-thread-1 waiting on queue.take()
pool-1-thread-2 waiting on queue.take()
...
线程都在等任务,但任务明明在队列里!
当时线程池配置如下:
new ThreadPoolExecutor(
5,
10,
60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(10000),
new ThreadPoolExecutor.AbortPolicy()
);
看起来没毛病对吧?
实际结果是:拒绝策略从未生效、maxPoolSize 永远没机会触发。
二、真相:线程池参数不是你想的那样配的
要理解问题,得先知道 ThreadPoolExecutor 的任务提交流程。
任务提交 → 核心线程是否满?
↓ 否 → 新建核心线程
↓ 是 → 队列是否满?
↓ 否 → 放入队列等待
↓ 是 → 是否小于最大线程数?
↓ 是 → 创建非核心线程
↓ 否 → 拒绝策略触发
也就是说:
只要队列没满,线程池就不会创建非核心线程。
所以:
- 你的
corePoolSize = 5; - 队列能放
10000个任务; maxPoolSize = 10永远不会触发;- 线程永远就那 5 个在干活;
- 队列里的任务越堆越多,拒绝策略永远“假死”。
三、踩坑场景实录
| 场景 | 错误配置 | 结果 |
|---|---|---|
| 高频接口异步任务 | LinkedBlockingQueue<>(10000) | 队列太大 → 拒绝策略形同虚设 |
| IO密集型任务 | 核心线程过少(如5) | CPU空闲但任务堆积 |
| CPU密集型任务 | 核心线程过多(如50) | 上下文切换浪费CPU |
| 线程池共用 | 多个模块共用一个 pool | 某任务阻塞导致全局“死锁” |
四、正确配置姿势(我现在都这么配)
思路很简单:
“小队列 + 合理核心数 + 合理拒绝策略”
而不是 “大队列 + 盲目扩大线程数”。
例如 CPU 密集型任务:
int cores = Runtime.getRuntime().availableProcessors();
ThreadPoolExecutor executor = new ThreadPoolExecutor(
cores + 1,
cores + 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<>(100),
new ThreadPoolExecutor.CallerRunsPolicy()
);
IO 密集型任务:
int cores = Runtime.getRuntime().availableProcessors();
ThreadPoolExecutor executor = new ThreadPoolExecutor(
cores * 2,
cores * 4,
60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(200),
new ThreadPoolExecutor.CallerRunsPolicy()
);
关键思想:
- 宁可拒绝,也不要堆积。
- 拒绝意味着“系统过载”,堆积意味着“慢性自杀”。
五、拒绝策略的“假死”与自定义方案
内置的 4 种拒绝策略:
AbortPolicy:直接抛异常(最安全)CallerRunsPolicy:调用方线程执行(可限流)DiscardPolicy:悄悄丢弃任务(最危险)DiscardOldestPolicy:丢最老的(仍可能乱序)
如果你想更智能一点,可以自定义:
new ThreadPoolExecutor.AbortPolicy() {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
log.warn("任务被拒绝,当前队列:{}", e.getQueue().size());
// 可以上报监控 / 发报警
}
};
六、监控才是救命稻草
别等到队列堆积了才发现问题。
我建议给线程池加实时监控,比如:
ScheduledExecutorService monitor = Executors.newScheduledThreadPool(1);
monitor.scheduleAtFixedRate(() -> {
log.info("PoolSize={}, Active={}, QueueSize={}",
executor.getPoolSize(),
executor.getActiveCount(),
executor.getQueue().size());
}, 0, 5, TimeUnit.SECONDS);
这样你能第一时间看到线程数没涨、队列在爆。
🧠 七、总结(踩坑后记)
| 项目 | 错误思路 | 正确思路 |
|---|---|---|
| corePoolSize | 设太小 | 根据 CPU/I/O 特性动态计算 |
| queueCapacity | 设太大 | 保持小容量以触发拒绝策略 |
| maxPoolSize | 没触发 | 仅当队列满后才会启用 |
| 拒绝策略 | 默认 Abort | 建议自定义/限流处理 |
| 监控 | 没有 | 定期打印状态日志 |
最后一句话:
“线程池是救命的工具,用不好就变慢性毒药。”
✍️ 写在最后
如果你看到这里,不妨想想自己的系统里有多少个 newFixedThreadPool、多少个默认 LinkedBlockingQueue 没有限制大小。
你以为是“优化”,其实是定时炸弹。
来源:juejin.cn/post/7566476530500223003
Elasticsearch 避坑指南:我在项目中总结的 14 条实用经验
刚开始接触 Elasticsearch 时,我觉得它就像个黑盒子——数据往里一扔,查询语句一写,结果就出来了。直到负责公司核心业务的搜索模块后,我才发现这个黑盒子里面藏着无数需要注意的细节。
今天就把我在实际项目中积累的 ES 使用经验分享给大家,主要从索引设计、字段类型、查询优化、集群管理和架构设计这几个方面来展开。

索引设计:从基础到进阶
1. 索引别名(alias):为变更留条后路
刚开始做项目时,我习惯直接用索引名。直到有一次需要修改字段类型,才发现 ES 不支持直接修改映射,也不支持修改主分片数,必须重建索引。(**新增字段是可以的)
解决方案很简单:使用索引别名。业务代码中永远使用别名,重建索引时只需要切换别名的指向,整个过程用户无感知。
这就好比给索引起了个"外号",里面怎么换内容都不影响外面的人称呼它。
2. Routing 路由:让查询更精准
在做 SaaS 电商系统时,我发现查询某个商家的订单数据特别慢。原来,默认情况下ES根据文档ID的哈希值分配分片,导致同一个商家的数据分散在不同分片上。
优化方案:使用商家 ID 作为 routing key,存储和查询数据时指定routing key。这样,同一个商家的所有数据都会存储在同一个分片上。
效果对比:
- 优化前:查询要扫描所有分片(比如3个分片都要查)
- 优化后:只需要查1个分片
- 结果:查询速度直接翻倍,资源消耗还更少
3. 分片拆分:应对数据增长
当单个索引数据量持续增长时,单纯增加分片数并不是最佳方案。
我的经验是:
- 业务索引:单个分片控制在 10-30GB
- 搜索索引:10GB 以内更合适
- 日志索引:可以放宽到 20-50GB
对于 SaaS 系统,ES单索引数据较大,且存在“超级大商户”,导致数据倾斜严重时,可以按商家ID%64取模进行索引拆分,比如 orders_001 到 orders_064,每个索引包含部分商家的数据,然后再根据商户ID指定routing key。
请根据业务数据量和业务要求,选择最适合的分片拆分规则 和routing key路由算法,同时不要因为拆分不合理,导致ES节点中存在大量分片。
ES默认单节点分片最大值为1000(7.0版本后),可以参考ES官方建议,堆内存分片数量维持大约1:20的比例
字段类型:选择比努力重要
4. Text vs Keyword:理解它们的本质区别
曾经有个坑:用户手机号用 text 类型存储,结果搜索完整的手机号却搜不到。原来 text 类型会被分词,13800138000 可能被拆成 138、0013、8000 等片段。
正确做法:
- 需要分词搜索的用 text(如商品描述)
- 需要精确匹配的用 keyword(如订单号、手机号),适合 term、terms 等精确查询
- 效果:keyword 类型的 term 查询速度更快,存储空间更小
5. 多字段映射(multi-fields):按需使用不浪费
ES 默认会为 text 字段创建 keyword 子字段,但这并不总是必要的。
我的选择:
- 确定字段需要精确匹配和聚合时:启用 multi-fields
- 只用于全文搜索时:禁用 multi-fields
- 好处:节省存储空间,提升写入速度
6. 排序字段:选对类型提升性能
用 keyword 字段做数值排序是个常见误区。比如价格排序,100 会排在 99 前面,因为它是按字符串顺序比较的。
推荐做法:
- 数值排序:用 long、integer 类型
- 时间排序:用 date 类型
- 提升效果:排序速度提升明显,内存占用也更少
查询优化:平衡速度与精度
7. 模糊查询:了解正确的打开方式
在 ES 7.9 之前,wildcard 查询是个性能陷阱。它基于正则表达式引擎,前导通配符会导致全量词项扫描。
现在的方案:
- ES7.9+:使用 wildcard 字段类型
- 优势:底层使用优化的 n-gram + 二进制 doc value 机制,性能提升显著
提示:ES7.9前后版本wildcard的具体介绍,可以参考我的上一篇文章
8. 分页查询:避免深度分页的坑
产品经理曾要求实现"无限滚动",我展示了深度分页的性能数据后,大家达成共识:业务层面避免深度分页才是根本解决方案。就像淘宝、Google 这样的大厂,也都对分页做了限制,这不仅是技术考量,更是用户体验的最优选择。
技术方案(仅在确实无法避免时考虑):
- 浅分页:使用
from/size,适合前几页的常规分页 - Scroll:适合大数据量导出,但需要维护 scroll_id 和历史快照,对服务器资源消耗较大
- search_after:基于上一页最后一条记录进行分页,但无法跳转任意页面,且频繁查询会增加服务器压力
需要强调的是,这些技术方案都存在各自的局限性,业务设计上的规避始终是最佳选择。
集群管理:保障稳定运行
9. 索引生命周期:自动化运维
日志数据的特点是源源不断,如果不加管理,磁盘很快就会被撑满。
我的做法:
- 按天创建索引(如 log_20231201)
- 设置保留策略(保留7天或30天)
- 结合模板自动化管理
10. 准实时性:理解刷新机制
很多新手会困惑:为什么数据写入后不能立即搜索?
原理:ES 默认 1 秒刷新一次索引,这是为了在实时性和写入性能之间取得平衡。
调整建议:
- 实时性要求高:保持 1s
- 写入量大:适当调大 refresh_interval
补充说明:如果需要更新后立即能查询到,通常有两种方案:
- 让前端直接展示刚提交的数据,等下一次调用接口时再查询 ES
- 更新完后,前端延迟 1.5 秒后再查询
关键点:业务需求不一定都要后端实现,可以结合前端一起考虑解决方案。
11. 内存配置:32G 限制的真相
为什么 ES 官方建议不要超过 32G 内存?
技术原因:Java 的压缩指针技术在 32G 以内有效,超过这个限制会浪费大量内存。
实践建议:单个节点配置约50%内存,留出部分给操作系统。
架构设计:合理的分工协作
12. ES 与数据库:各司其职
曾经试图在 ES 里存储完整的业务数据,结果遇到数据一致性问题。
现在的方案:
- ES:存储搜索条件和文档 ID
- 数据库:存储完整业务数据
- 查询:ES 找 ID,数据库取详情
好处:既享受 ES 的搜索能力,又保证数据的强一致性。
13. 嵌套对象:保持数据关联性
处理商品规格这类数组数据时,用普通的 object 类型会导致数据扁平化,破坏对象间的关联。
解决方案:使用 nested 类型,保持数组内对象的独立性,确保查询结果的准确性。
14. 副本配置:读写平衡的艺术
副本可以提升查询能力,但也不是越多越好。
经验值:
- 大多数场景:1 个副本足够
- 高查询压力:可适当增加
- 注意:副本越多,写入压力越大
写在最后
这些经验都是在解决实际问题中慢慢积累的。就像修路一样,开始可能只是简单铺平,随着车流量的增加,需要不断优化——设置红绿灯、划分车道、建立立交桥。使用 ES 也是同样的道理,随着业务的发展,需要不断调整和优化。
最大的体会是:理解原理比记住命令更重要。只有明白了为什么这样设计,才能在遇到新问题时找到合适的解决方案。
如果有人问我:"ES 怎么才能用得更好?"我的回答是:"先理解业务场景,再选择技术方案。就像我们之前做的模糊搜索,不是简单地用 wildcard,而是根据 ES 版本选择最优解。"
技术的价值不在于多复杂,而在于能否优雅地解决实际问题。与大家共勉。
来源:juejin.cn/post/7569959427879567370
我们来说一说什么是联合索引最左匹配原则?
什么是联合索引?
首先,要理解最左匹配原则,得先知道什么是联合索引。
- 单列索引:只针对一个表列创建的索引。例如,为 users 表的 name 字段创建一个索引。
- 联合索引:也叫复合索引,是针对多个表列创建的索引。例如,为 users 表的 (last_name, first_name) 两个字段创建一个联合索引。
这个索引的结构可以想象成类似于电话簿或字典。电话簿是先按姓氏排序,在姓氏相同的情况下,再按名字排序。你无法直接跳过姓氏,快速找到一个特定的名字。
什么是最左匹配原则?
最左匹配原则指的是:在使用联合索引进行查询时,MySQL/SQL数据库从索引的最左前列开始,并且不能跳过中间的列,一直向右匹配,直到遇到范围查询(>、<、BETWEEN、LIKE)就会停止匹配。
这个原则决定了你的 SQL 查询语句是否能够使用以及如何高效地使用这个联合索引。
核心要点:
- 从左到右:索引的使用必须从最左边的列开始。
- 不能跳过:不能跳过联合索引中的某个列去使用后面的列。
- 范围查询右停止:如果某一列使用了范围查询,那么它右边的列将无法使用索引进行进一步筛选。
举例说明
假设我们有一个 users 表,并创建了一个联合索引 idx_name_age,包含 (last_name, age) 两个字段。
| id | last_name | first_name | age | city |
| 1 | Wang | Lei | 20 | Beijing |
| 2 | Zhang | Wei | 25 | Shanghai |
| 3 | Wang | Fang | 22 | Guangzhou |
| 4 | Li | Na | 30 | Shenzhen |
| 5 | Zhang | San | 28 | Beijing |
索引 idx_name_age 在磁盘上大致是这样排序的(先按 last_name 排序,last_name 相同再按 age 排序):
(Li, 30) (Wang, 20) (Wang, 22) (Zhang, 25) (Zhang, 28)
现在,我们来看不同的查询场景:
✅ 场景一:完全匹配最左列
SELECT * FROM users WHERE last_name = 'Wang';
- 分析:查询条件包含了索引的最左列 last_name。
- 索引使用情况:✅ 可以使用索引。数据库可以快速在索引树中找到所有 last_name = 'Wang' 的记录((Wang, 20) 和 (Wang, 22))。
✅ 场景二:匹配所有列
SELECT * FROM users WHERE last_name = 'Wang' AND age = 22;
- 分析:查询条件包含了索引的所有列,并且顺序与索引定义一致。
- 索引使用情况:✅ 可以高效使用索引。数据库先定位到 last_name = 'Wang',然后在这些结果中快速找到 age = 22 的记录。
✅ 场景三:匹配最左连续列
SELECT * FROM users WHERE last_name = 'Zhang';
- 分析:虽然只用了 last_name,但它是索引的最左列。
- 索引使用情况:✅ 可以使用索引。和场景一类似。
❌ 场景四:跳过最左列
SELECT * FROM users WHERE age = 25;
- 分析:查询条件没有包含索引的最左列 last_name。
- 索引使用情况:❌ 无法使用索引。这就像让你在电话簿里直接找所有叫“伟”的人,你必须翻遍整个电话簿,也就是全表扫描。
⚠️ 场景五:包含最左列,但中间有断档
-- 假设我们有一个三个字段的索引 (col1, col2, col3) -- 查询条件为 WHERE col1 = 'a' AND col3 = 'c';
- 分析:虽然包含了最左列 col1,但跳过了 col2 直接查询 col3。
- 索引使用情况:✅ 部分使用索引。数据库只能使用 col1 来缩小范围,找到所有 col1 = 'a' 的记录。对于 col3 的过滤,它无法利用索引,需要在第一步的结果集中进行逐行筛选。
⚠️ 场景六:最左列是范围查询
SELECT * FROM users WHERE last_name > 'Li' AND age = 25;
- 分析:最左列 last_name 使用了范围查询 >。
- 索引使用情况:✅ 部分使用索引。数据库可以使用索引找到所有 last_name > 'Li' 的记录(即从 Wang 开始往后的所有记录)。但是,对于 age = 25 这个条件,由于 last_name 已经是范围匹配,age 列在索引中是无序的,因此数据库无法再利用索引对 age 进行快速筛选,只能在 last_name > 'Li' 的结果集中逐行检查 age。
总结与最佳实践
最左匹配原则的本质是由索引的数据结构(B+Tree) 决定的。索引按照定义的字段顺序构建,所以必须从最左边开始才能利用其有序性。
如何设计好的联合索引?
- 高频查询优先:将最常用于 WHERE 子句的列放在最左边。
- 等值查询优先:将经常进行等值查询(=)的列放在范围查询(>, <, LIKE)的列左边。
- 覆盖索引:如果查询的所有字段都包含在索引中(即覆盖索引),即使不符合最左前缀,数据库也可能直接扫描索引来避免回表,但这通常发生在二级索引扫描中,效率依然不如最左匹配。
来源:juejin.cn/post/7565940210148868148
MyBatis 中 where1=1 一些替换方式
题记
生命中的风景千变万化,但我一直在路上。
风雨兼程,不是为了抵达终点,而是为了沿途的风景。
起因
今天闲来无事,翻翻看看之前的项目。
在看到一个项目的时候,项目框架用的是SpringMvc+Spring+Mybatis。项目里面注释时间写的是2018年,时间长了,里面有好多语法现在看起来好麻烦的样子呀!
说有它就有,这不就有一个吗?在Mybatis配置的xml中,有好多where 1=1 拼接Sql的方式,看的人头都大了。想着改一下吧,又一想,代码已经时间长了,如果出现问题找谁,就先不管了。
话是这样说,但在实际工作中,还是会有方法可以代替的,下面我们一起来看看吧!
替换方式
在 MyBatis 中,WHERE 1=1 通常用来在多条件查询情况下下进行SQL 拼接,其目的就是避免在没有条件时出现语法错误。
但这种写法不够优雅,可通过以下方式进行替代:
1. 使用 <where> 标签(推荐)
MyBatis 的 <where> 标签会自动处理 SQL 的 WHERE 语句,移除多余的 AND 或 OR 关键字。
看实例:
<select id="selectUsers" resultType="User">
SELECT * FROM user
<where>
<if test="username != null and username != ''">
AND username = #{username}
</if>
<if test="age != null">
AND age = #{age}
</if>
</where>
</select>
效果说明:
- 当无参数时,此时执行的Sql语句为:
SELECT * FROM user - 当仅传
username时,此时执行的Sql语句为:SELECT * FROM user WHERE username = ? - 当传
username和age时,此时执行的Sql语句为:SELECT * FROM user WHERE username = ? AND age = ?
2. 使用 <trim> 标签自定义
<trim> 可更灵活地处理 SQL 片段,通过设置 prefix 和 prefixOverrides 属性模拟 <where> 的功能。
看实例:
<select id="selectUsers" resultType="User">
SELECT * FROM user
<trim prefix="WHERE" prefixOverrides="AND |OR ">
<if test="username != null and username != ''">
AND username = #{username}
</if>
<if test="age != null">
AND age = #{age}
</if>
</trim>
</select>
说明:
prefix="WHERE":在条件前添加WHERE关键字。prefixOverrides="AND |OR ":移除条件前多余的AND或OR。
3. 使用 <choose>、<when>、<otherwise>
类似Java在进行判断中常用的 switch-case语句,此方式适用于多条件互斥的场景。
看实例:
<select id="selectUsers" resultType="User">
SELECT * FROM user
<where>
<choose>
<when test="username != null and username != ''">
username = #{username}
</when>
<when test="age != null">
age = #{age}
</when>
<otherwise>
1=1 <!-- 仅在无任何条件时使用 -->
</otherwise>
</choose>
</where>
</select>
4. Java代码判断控制
在 Service 层根据条件动态选择不同的 SQL 语句。
看实例:
public List<User> getUsers(String username, Integer age) {
if (username != null && !username.isEmpty()) {
return userMapper.selectByUsername(username);
} else if (age != null) {
return userMapper.selectByAge(age);
} else {
return userMapper.selectAll();
}
}
具体方式对比与选择
| 方式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
<where> | 多条件动态组合 | 自动处理 WHERE 和 AND | 需MyBatis 框架支持 |
<trim> | 复杂 SQL 片段处理 | 灵活度比较高 | 配置稍繁琐 |
<choose> | 多条件互斥选择 | 逻辑清晰 | 无明确条件时仍需 1=1 |
| Java代码判断控制 | 条件逻辑复杂 | 完全可控 | 增加Service层代码复杂度 |
总结
推荐优先使用 <where> 标签,它能自动处理 SQL 语法,避免冗余代码。只有在需要更精细控制时,才考虑 <trim> 或其他方式。尽量避免在 XML 中使用 WHERE 1=1,保持 SQL 的简洁性和规范性。
展望
世间万物皆美好, 终有归途暖心潮。
在纷繁的世界里,保持内心的宁静与坚定,让每一步都走向完美的结局。
来源:juejin.cn/post/7534892673107804214
从RBAC到ABAC的进阶之路:基于jCasbin实现无侵入的SpringBoot权限校验
一、前言:当权限判断写满业务代码
几乎所有企业系统,都逃不过“权限”这道关。
从“谁能看”、“谁能改”到“谁能审批”,权限逻辑贯穿了业务的方方面面。
起初,大多数项目使用最常见的 RBAC(基于角色的访问控制) 模型
if (user.hasRole("admin")) {
documentService.update(doc);
}
逻辑简单、上手快,看似能解决 80% 的问题。
但随着业务复杂度上升,RBAC 很快会失控。
比如你可能遇到以下需求 👇
- “文档的作者可以编辑自己的文档”;
- “同部门的经理也可以编辑该文档”;
- “外部合作方仅能查看共享文档”;
- “项目归档后,所有人都只读”。
这些场景无法用“角色”简单定义,
于是权限判断开始蔓延在业务代码各处,像这样:
if (user.getId().equals(doc.getOwnerId())
|| (user.getDept().equals(doc.getDept()) && user.isManager())) {
// 编辑文档
} else {
throw new AccessDeniedException("无权限");
}
时间久了,这些判断像杂草一样蔓延。
权限逻辑与业务逻辑纠缠不清,修改一处可能引发连锁反应。
可维护性、可测试性、可演化性统统崩盘。
二、RBAC 的天花板:角色无法描述现实世界
RBAC 的问题在于:它过于静态。
“角色”可以描述一类人,但描述不了上下文。
举个例子:
研发经理能编辑本部门的文档,但不能编辑市场部的。
在 RBAC 下,你只能再创建新角色:
研发经理、市场经理、项目经理……
角色越来越多,最终爆炸。
而现实世界的权限,往往与“属性”有关:
- 用户的部门
- 资源的拥有者
- 操作发生的时间 / 状态
这些动态因素,是 RBAC 无法覆盖的。
于是我们需要一个更灵活的模型 —— ABAC。
三、ABAC:基于属性的访问控制
ABAC(Attribute-Based Access Control) 的核心理念是:
授权决策 = 函数(主体属性、资源属性、操作属性、环境属性)
| 概念 | 含义 | 示例 |
|---|---|---|
| Subject(主体) | 谁在访问 | 用户A,部门=研发部 |
| Object(资源) | 访问什么 | 文档1,ownerId=A,部门=研发部 |
| Action(操作) | 做什么 | edit / read / delete |
| Policy(策略) | 允许条件 | user.dept == doc.dept && act == "edit" |
一句话总结:
ABAC 不关心用户是谁,而关心“用户和资源具有什么属性”。
举例说明:
“用户可以编辑自己部门的文档,或自己创建的文档。”
简单、直观、灵活。
四、引入 JCasbin:让授权逻辑从代码中消失
JCasbin(github.com/casbin/jcas…) 是一个优秀的 Java 权限引擎,支持多种模型(RBAC、ABAC)。
它最大的价值在于:
把授权逻辑从代码中抽离,让代码只负责执行业务。
在 JCasbin 中,我们通过定义:
- 模型文件(model) :规则框架;
- 策略文件(policy) :具体规则。
然后由 Casbin 引擎来执行判断。
五、核心实现:几行配置搞定动态权限
模型文件 model.conf
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub_rule, obj_rule, act
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = eval(p.sub_rule) && eval(p.obj_rule) && r.act == p.act
策略文件 policy.csv
p, r.sub.dept == r.obj.dept, true, edit
p, r.sub.id == r.obj.ownerId, true, edit
p, true, true, read
解释:
- 同部门可编辑;
- 作者可编辑;
- 所有人可阅读。
在代码中调用
Enforcer enforcer = new Enforcer("model.conf", "policy.csv");
User user = new User("u1", "研发部");
Document doc = new Document("d1", "研发部", "u1");
boolean canEdit = enforcer.enforce(user, doc, "edit");
System.out.println("是否有编辑权限:" + canEdit);
输出:
是否有编辑权限:true
无需任何 if-else,逻辑全在外部配置中定义。
业务代码只需调用 Enforcer,简单又优雅。
六、在 Spring Boot 中实现“无感校验”
实际项目中,我们希望权限校验能“自动触发”,
这可以通过 注解 + AOP 切面 的方式实现。
定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CheckPermission {
String action();
}
编写切面
@Aspect
@Component
public class PermissionAspect {
@Autowired
private Enforcer enforcer;
@Before("@annotation(checkPermission)")
public void checkAuth(JoinPoint jp, CheckPermission checkPermission) {
Object user = getCurrentUser();
Object resource = getRequestResource(jp);
String action = checkPermission.action();
if (!enforcer.enforce(user, resource, action)) {
throw new AccessDeniedException("无权限执行操作:" + action);
}
}
}
在业务代码中使用
@CheckPermission(action = "edit")
@PostMapping("/doc/edit")
public void editDoc(@RequestBody Document doc) {
documentService.update(doc);
}
✅ 授权逻辑彻底从业务中解耦,权限统一由 Casbin 引擎处理。
七、策略动态化与分布式支持
在生产环境中,权限策略通常存储在数据库中,而非文件。
JCasbin 支持多种扩展方式:
JDBCAdapter adapter = new JDBCAdapter(dataSource);
Enforcer enforcer = new Enforcer("model.conf", adapter);
支持特性包括:
- 💽 MySQL / PostgreSQL 等持久化;
- 🔄 Redis Watcher 实现多节点策略热更新;
- ⚡ SyncedEnforcer 支持高并发一致性。
这样修改权限规则就无需重新部署代码,权限即改即生效
八、总结
引入 JCasbin 后,项目结构会发生显著变化👇
| 优势 | 描述 |
|---|---|
| 逻辑解耦 | 授权逻辑完全从业务代码中剥离 |
| 灵活配置 | 权限规则动态可改、可热更新 |
| 可扩展 | 可根据属性定义复杂条件 |
| 统一决策 | 所有权限判断走同一引擎 |
| 可测试 | 策略可单测,无需跑整套业务流程 |
最重要的是:新增规则无需改代码。
只要在策略表里加一条记录,就能实现全新的授权逻辑。
权限系统的复杂,不在于“能不能判断”,
而在于——“判断逻辑放在哪儿”。
当项目越做越大,你会发现:
真正的架构能力,不是多写逻辑,而是让逻辑有边界。
JCasbin 给了我们一个极好的解法:
一个统一的决策引擎,让权限系统既灵活又有秩序。
它不是银弹,但能让你在权限处理上的代码更纯净、系统扩展性更好。
来源:juejin.cn/post/7558094123812536361
研发排查问题的利器:一款方法调用栈跟踪工具
导语
本文从日常值班问题排查痛点出发,分析方法复用的调用链路和上下文业务逻辑,通过思考分析,借助栈帧开发了一个方法调用栈的链式跟踪工具,便于展示一次请求的方法串行调用链,有助于快速定位代码来源和流量入口,有效提升研发和运维排查定位效率。期望在大家面临类似痛点时可以提供一些实践经验和参考,也欢迎大家合适的场景下接入使用。
现状分析
在系统值班时,经常会有人拿着报错截图前来咨询,作为值班研发,我们则需要获取尽可能多的信息,帮助我们分析报错场景,便于排查识别问题。
例如,下图就是一个常见的的报错信息截图示例。
从图中,我们可以初步获取到一些信息:
•菜单名称:变更单下架,我们这是变更单下架操作时的一个报错提醒。
•报错信息:序列号状态为离库状态,请检查。
•其他辅助信息:例如用户扫描或输入的86开头编码,SKU、商品名称、储位等。
这时会有一些常见的排查思路:
1、根据提示,将用户输入的86编码,按照提示文案去检查用户数据,即作为序列号编码,去看一下序列号是否存在,是否真的是离库了。
2、如果86编码确实是序列号,而且真的是离库了,那么基本上可以快速结案了,这个86编码确实是序列号并且是已离库,正如提示文案所示,这时跟提问人做好解释答疑即可。
3、如果第2步排查完,发现86编码不是序列号编码,或并非离库状态,即与提示文案不符,这时就要定位报错文案的代码来源,继续查看代码逻辑来进行判案了。(这种也比较常见,一种是报错场景较多,但提示文案比较单一,不便于在提示文案中覆盖所有报错场景;另一种提示文案陈旧未跟随需求演变而更新。这两点可以通过细分场景细化对应的报错文案,或更新报错文案,使得报错文案更优更新,但不是本文讨论的重点。)
4、如何根据报错文案快速找到代码来源呢?一般我们会在代码库中搜索提示文案,或者在日志中检索报错信息,辅助定位代码来源,后者依赖于代码中打印了该报错信息,且日志级别配置能够确保该信息打印到日志文件中。
5、倘若我们根据提示文案搜索代码时,发现该提示文案有多处代码出现,此时就较为复杂了,我们需要进一步识别,哪个才与本次报错直接有关。

每个方法向上追溯,又发现调用来源众多:



在业务复杂的系统中,方法复用比较常见,不同的上下文和参数传递,也有着不同的业务逻辑判断和走向。
这时,基本上进入到本文要讨论的痛点:如何根据有限的提示信息快速定位代码来源?以便于分析报错业务场景,答疑解惑或快速处理问题。
屏幕前的小伙伴,如果你也经常值班排查问题,应该也会有类似的痛点所在。
启发
这是我想到了Exception异常机制,作为一名Coder,我们对异常堆栈再熟悉不过了,异常堆栈是一个“可爱”又“可恨”的东西,“可爱”在于异常堆栈确实可以帮助我们快速定位问题所在,“可恨”在于有异常基本上就是有问题,堆栈让我们审美疲劳,累觉不爱。
下面是一个Java语言的异常堆栈信息示例:

异常类体系和异常处理机制在本文中不是重点,不做过多赘述,本文重点希望能从异常堆栈中获取一些启发。
让我们近距离再观察一下我们的老朋友。
在异常堆栈信息中,主要有四类信息:
•全限定类名
•方法名
•文件名
•代码行号
这四类信息可以帮助我们有效定位代码来源,而且堆栈中记录行先后顺序,也表示着异常发生的第一现场、第二现场、第三现场、……,以此传递。
这让我想起了JVM方法栈中的栈帧。
每当一个方法被调用时,JVM会为该方法创建一个新的栈帧,并将其压入当前线程的栈(也称为调用栈或执行栈)中。栈帧包含了方法执行所需的所有信息,包括局部变量、操作数栈、常量池引用等。

思路
从Java中的Throwable中,可以看到staceTrace的get和set,这个StackTraceElement数组里面存放的信息就是我们在异常堆栈中经常看到的信息。



再次放一下这张图,方便对照着看。

StackTraceElement类的注释中赫然写着:
StackTraceElement represents a stack frame.
对,StackTraceElement代表着一个栈帧。
这个StackTraceElement就是我要找的东西,即使非异常情况下,每个线程在执行方法调用时都会记录栈帧信息。

按照方法调用先后顺序,将调用栈中方法依次串联起来,就像糖葫芦一样,就可以得到我想要的方法调用链了。
NEXT,我可以动工写个工具了。
工具开发
工具的核心代码并不复杂,StackTraceElement 也是 Java JDK 中现成的,我所做的工作主要是从中过滤出必要的信息,加工简化成,按照顺序整理成链式信息,方便我们一眼就可以看出来方法的调用链。

入参介绍
pretty: 表示是只拼接类和方法,不拼接文件名和行号,非 pretty 是四个都会拼接。
simple: 表示会过滤一些我们代码中场景的代理增强出来的方法的信息输出。
specifiedPrefix: 指定保留相应的包路径堆栈信息,去掉一些过多的中间件信息。
其他还会过滤一些常见代理的堆栈信息:
•FastClassBySpringCGLIB
•EnhancerBySpringCGLIB
•lambda$
•Aspect
•Interceptor
对此,还封装了一些默认参数的方法,使用起来更为方便。

还有一些其他工具方法也可以使用:

使用效果
1、不过滤中间件、代理增强方法的调用栈信息
Thread#run ==> ThreadPoolExecutorWorker#run ==> ThreadPoolExecutor#runWorker ==> BaseTask#run ==> JSFTask#doRun ==> ProviderProxyInvoker#invoke ==> FilterChain#invoke ==> SystemTimeCheckFilter#invoke ==> ProviderExceptionFilter#invoke ==> ProviderContextFilter#invoke ==> InstMethodsInter#intercept ==> ProviderContextFiltereone9f9kd21#call ==> ProviderContextFilter#eoneinvokeaccessorpclcbe2 ==> ProviderContextFilter#eoneinvokep882ot3 ==> ProviderGenericFilter#invoke ==> ProviderUnitValidationFilter#invoke ==> ProviderHttpGWFilter#invoke ==> ProviderInvokeLimitFilter#invoke ==> ProviderMethodCheckFilter#invoke ==> ProviderTimeoutFilter#invoke ==> ValidationFilter#invoke ==> ProviderConcurrentsFilter#invoke ==> ProviderSecurityFilter#invoke ==> WmsRpcExceptionFilter#invoke ==> WmsRpcExceptionFilter#invoke4provider ==> AdmissionControlJsfFilter#invoke ==> AdmissionControlJsfFilter#providerSide ==> AdmissionControlJsfFilter#processRequest ==> ChainedDeadlineJsfFilter#invoke ==> ChainedDeadlineJsfFilter#providerSide ==> JsfPerformanceMonitor#invoke ==> AbstractMiddlewarePerformanceMonitor#doExecute ==> PerformanceMonitorTemplateComposite#execute ==> PerformanceMonitorTemplateComposite#lambdaexecute0 ==> PerformanceMonitorTemplateUmp#execute ==> PerformanceMonitorTemplateComposite#lambdaexecute0 ==> PerformanceMonitorTemplatePayload#execute ==> JsfPerformanceMonitor#lambdainvoke0 ==> JsfPerformanceMonitor#doInvoke ==> ProviderInvokeFilter#invoke ==> ProviderInvokeFilter#reflectInvoke ==> Method#invoke ==> DelegatingMethodAccessorImpl#invoke ==> GeneratedMethodAccessor1704#invoke ==> CglibAopProxyDynamicAdvisedInterceptor#intercept ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> ExposeInvocationInterceptor#invoke ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> AspectJAroundAdvice#invoke ==> AbstractAspectJAdvice#invokeAdviceMethod ==> AbstractAspectJAdvice#invokeAdviceMethodWithGivenArgs ==> Method#invoke ==> DelegatingMethodAccessorImpl#invoke ==> GeneratedMethodAccessor344#invoke ==> MethodInvocationProceedingJoinPoint#proceed ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> AspectJAroundAdvice#invoke ==> AbstractAspectJAdvice#invokeAdviceMethod ==> AbstractAspectJAdvice#invokeAdviceMethodWithGivenArgs ==> Method#invoke ==> DelegatingMethodAccessorImpl#invoke ==> GeneratedMethodAccessor1276#invoke ==> MethodInvocationProceedingJoinPoint#proceed ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> AspectJAroundAdvice#invoke ==> AbstractAspectJAdvice#invokeAdviceMethod ==> AbstractAspectJAdvice#invokeAdviceMethodWithGivenArgs ==> Method#invoke ==> DelegatingMethodAccessorImpl#invoke ==> GeneratedMethodAccessor868#invoke ==> MethodInvocationProceedingJoinPoint#proceed ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> AspectJAroundAdvice#invoke ==> AbstractAspectJAdvice#invokeAdviceMethod ==> AbstractAspectJAdvice#invokeAdviceMethodWithGivenArgs ==> Method#invoke ==> DelegatingMethodAccessorImpl#invoke ==> GeneratedMethodAccessor869#invoke ==> MethodInvocationProceedingJoinPoint#proceed ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> AspectJAroundAdvice#invoke ==> AbstractAspectJAdvice#invokeAdviceMethod ==> AbstractAspectJAdvice#invokeAdviceMethodWithGivenArgs ==> Method#invoke ==> DelegatingMethodAccessorImpl#invoke ==> GeneratedMethodAccessor1642#invoke ==> MagicAspect#magic ==> MethodInvocationProceedingJoinPoint#proceed ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> CglibAopProxyCglibMethodInvocation#invokeJoinpoint ==> MethodProxy#invoke ==> CglibAopProxyDynamicAdvisedInterceptor#intercept ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> ExposeInvocationInterceptor#invoke ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> AspectJAroundAdvice#invoke ==> AbstractAspectJAdvice#invokeAdviceMethod ==> AbstractAspectJAdvice#invokeAdviceMethodWithGivenArgs ==> Method#invoke ==> DelegatingMethodAccessorImpl#invoke ==> GeneratedMethodAccessor868#invoke ==> MethodInvocationProceedingJoinPoint#proceed ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> AspectJAroundAdvice#invoke ==> AbstractAspectJAdvice#invokeAdviceMethod ==> AbstractAspectJAdvice#invokeAdviceMethodWithGivenArgs ==> Method#invoke ==> DelegatingMethodAccessorImpl#invoke ==> GeneratedMethodAccessor869#invoke ==> MethodInvocationProceedingJoinPoint#proceed ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> CglibAopProxyCglibMethodInvocation#invokeJoinpoint ==> MethodProxy#invoke ==> CglibAopProxyDynamicAdvisedInterceptor#intercept ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> AspectJAroundAdvice#invoke ==> AbstractAspectJAdvice#invokeAdviceMethod ==> AbstractAspectJAdvice#invokeAdviceMethodWithGivenArgs ==> Method#invoke ==> DelegatingMethodAccessorImpl#invoke ==> GeneratedMethodAccessor1295#invoke ==> MethodInvocationProceedingJoinPoint#proceed ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> ExposeInvocationInterceptor#invoke ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> AspectJAroundAdvice#invoke ==> AbstractAspectJAdvice#invokeAdviceMethod ==> AbstractAspectJAdvice#invokeAdviceMethodWithGivenArgs ==> Method#invoke ==> DelegatingMethodAccessorImpl#invoke ==> GeneratedMethodAccessor868#invoke ==> MethodInvocationProceedingJoinPoint#proceed ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> CglibAopProxyCglibMethodInvocation#invokeJoinpoint ==> MethodProxy#invoke ==> CglibAopProxyDynamicAdvisedInterceptor#intercept ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> ExposeInvocationInterceptor#invoke ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> CglibAopProxyCglibMethodInvocation#invokeJoinpoint ==> MethodProxy#invoke ==> CglibAopProxyDynamicAdvisedInterceptor#intercept ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> AnnotationAwareRetryOperationsInterceptor#invoke ==> RetryOperationsInterceptor#invoke ==> RetryTemplate#execute ==> RetryTemplate#doExecute ==> RetryOperationsInterceptor1#doWithRetry ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> CglibAopProxyCglibMethodInvocation#invokeJoinpoint ==> MethodProxy#invoke ==> CglibAopProxyDynamicAdvisedInterceptor#intercept ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> ExposeInvocationInterceptor#invoke ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> AspectJAroundAdvice#invoke ==> AbstractAspectJAdvice#invokeAdviceMethod ==> AbstractAspectJAdvice#invokeAdviceMethodWithGivenArgs ==> Method#invoke ==> DelegatingMethodAccessorImpl#invoke ==> GeneratedMethodAccessor1276#invoke ==> MethodInvocationProceedingJoinPoint#proceed ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> TransactionInterceptor#invoke ==> TransactionAspectSupport#invokeWithinTransaction ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> AspectJAroundAdvice#invoke ==> AbstractAspectJAdvice#invokeAdviceMethod ==> AbstractAspectJAdvice#invokeAdviceMethodWithGivenArgs ==> Method#invoke ==> DelegatingMethodAccessorImpl#invoke ==> GeneratedMethodAccessor869#invoke ==> MethodInvocationProceedingJoinPoint#proceed ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> PersistenceExceptionTranslationInterceptor#invoke ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> CglibAopProxy$CglibMethodInvocation#invokeJoinpoint ==> MethodProxy#invoke ==> StackTraceUtils#trace
2、指定包路径过滤中间件后的调用栈栈信息
LockAspect#lock ==> StockTransferAppServiceImpl#increaseStock ==> MonitorAspect#monitor ==> StockRetryExecutor#operateStock ==> StockRetryExecutor5188d6e#invoke ==> BaseStockOperation9d76cd9a#invoke ==> StockTransferServiceImpl85bb181e#invoke ==> ValidationAspect#logAndReturn ==> LogAspect#log ==> ThreadLocalRemovalAspect#removal ==> ValidationAspect#validate ==> BaseStockOperation#go ==> StockRepositoryImpl1388ef12#operateStock ==> StockTransferAppServiceImpl1095eafa#increaseStock ==> StockRepositoryImpla1b4dae4#invoke ==> StockTransferServiceImpl#increaseStock ==> DataBaseExecutor#execute ==> StockRetryExecutorb42789a#operateStock ==> StockInitializer85faf510#go ==> StockTransferServiceImplafc21975#increaseStock ==> StockRepositoryImpl#operateStock ==> DataBaseExecutor#operate ==> StockTransferAppServiceImple348d8e1#invoke
3、去掉Spring代理增强之后的调用栈信息
LogAspect#log ==> LockAspect#lock ==> ValidationAspect#validate ==> ValidationAspect#logAndReturn ==> MonitorAspect#monitor ==> StockTransferAppServiceImpl#decreaseStock ==> ThreadLocalRemovalAspect#removal ==> StockTransferServiceImpl#decreaseStock ==> StockOperationLoader#go ==> BaseStockOperation#go ==> DataBaseExecutor#operate ==> DataBaseExecutor#execute ==> StockRetryExecutor#operateStock ==> StockRepositoryImpl#operateStock
4、去掉一些自定义代理之后的调用栈栈信息
StockTransferAppServiceImpl#increaseStock ==> StockTransferServiceImpl#increaseStock ==> BaseStockOperation#go ==> DataBaseExecutor#operate ==> DataBaseExecutor#execute ==> StockRetryExecutor#operateStock ==> StockRepositoryImpl#operateStock
5、如果带上文件名和行号后的调用栈栈信息
StockTransferAppServiceImpl#increaseStock(StockTransferAppServiceImpl.java:103) ==> StockTransferServiceImpl#increaseStock(StockTransferServiceImpl.java:168) ==> BaseStockOperation#go(BaseStockOperation.java:152) ==> BaseStockOperation#go(BaseStockOperation.java:181) ==> BaseStockOperation#go(BaseStockOperation.java:172) ==> DataBaseExecutor#operate(DataBaseExecutor.java:34) ==> DataBaseExecutor#operate(DataBaseExecutor.java:64) ==> DataBaseExecutor#execute(DataBaseExecutor.java:79) ==> StockRetryExecutor#operateStock(StockRetryExecutor.java:64) ==> StockRepositoryImpl#operateStock(StockRepositoryImpl.java:303)
线上应用实践

接入方法调用栈跟踪工具后,根据报错提示词,可以检索到对应日志,从 ImmediateTransferController#offShelf ==> AopConfig#pointApiExpression ==> TransferOffShelfAppServiceImpl#offShelf ==> TransferOffShelfAppServiceImpl#doOffShelf 中顺藤摸瓜可以快速找到流量入口的代码位置。


适用场景
该方法调用栈工具类,可以在一些堆栈信息进行辅助排查分析的地方进行预埋,例如:
•业务异常时输出堆栈到日志信息中。
•业务监控告警信息中加入调用栈信息。
•一些复用方法调用复杂场景下,打印调用栈信息,展示调用链,方便分析。
•其他一些场景等。
延伸
在《如何一眼定位SQL的代码来源:一款SQL染色标记的简易MyBatis插件》一文中,我发布了一款SQL染色插件,该插件目前已有statementId信息,还支持通过SQLMarkingThreadLocal传递自定义附加信息。其他BGBU的技术小伙伴,也有呼声,希望在statementId基础上可以继续追溯入口方法。通过本文引入的方法调用栈跟踪工具,我在SQL染色插件中增加了方法调用栈染色信息。
SQL染色工具新版特性,欢迎大家先在TEST和UAT环境尝鲜试用,TEST和UAT环境验证没问题后,再逐步推广正式环境。
升级方法:
1、sword-mybatis-plugins版本升级至1.0.8-SNAPSHOT。
2、同时新引入本文的工具依赖
<!-- http://sd.jd.com/article/45616?shareId=105168&isHideShareButton=1 -->
<dependency>
<groupId>com.jd.sword</groupId>
<artifactId>sword-utils-common</artifactId>
<version>1.0.3-SNAPSHOT</version>
</dependency>
3、mybatis config xml 配置文件按最新配置调整
<!-- http://sd.jd.com/article/42942?shareId=105168&isHideShareButton=1 -->
<!-- SQLMarking Plugin,放在第一个Plugin的位置,不影响其他组件,但不强要求位置,也可以灵活调整顺序位置 -->
<plugin interceptor="com.jd.sword.mybatis.plugin.sql.SQLMarkingInterceptor">
<!-- 是否开启SQL染色标记插件 -->
<property name="enabled" value="true"/>
<!-- 是否开启方法调用栈跟踪 -->
<property name="stackTraceEnabled" value="true"/>
<!-- 指定需要方法调用栈跟踪的package,减少信息量,value配置为自己工程的package路径,多个路径用英文逗号分割 -->
<property name="specifiedStackTracePackages" value="com.jdwl.wms.stock"/>
<!-- 忽略而不进行方法堆栈跟踪的类名列表,多个用英文逗号分割,减少信息量 -->
<property name="ignoredStackTraceClassNames" value=""/>
<!-- 结合CPU利用率和性能考虑,方法调用栈跟踪采集率配置采集率,配置示例: m/n,表示n个里面抽m个进行采集跟踪 -->
<!-- 预发环境和测试环境可以配置全采集,例如配置1/1,生产环境可以结合CPU利用率和性能考虑按需配置采集率 -->
<property name="stackTraceSamplingRate" value="1/2"/>
<!-- 是否允许SQL染色标记作为前缀,默认false表示仅作为后缀 -->
<property name="startsWithMarkingAllowed" value="false"/>
<!-- 方法调用栈跟踪最大深度,减少信息量 -->
<property name="maxStackDepth" value="10"/>
</plugin>
或代码配置方式
/**
* SQLMarking Plugin
* http://sd.jd.com/article/42942?shareId=105168&isHideShareButton=1
*
* @return
*/
@Bean
public SQLMarkingInterceptor sQLMarkingInterceptor() {
SQLMarkingInterceptor sQLMarkingInterceptor = new SQLMarkingInterceptor();
Properties properties = new Properties();
// 是否开启SQL染色标记插件
properties.setProperty("enabled", "true");
// 是否开启方法调用栈跟踪
properties.setProperty("stackTraceEnabled", "true");
// 指定需要方法调用栈跟踪的package,减少信息量,value配置为自己工程的package路径,多个路径用英文逗号分割
properties.setProperty("specifiedStackTracePackages", "com.jdwl.wms.picking");
// 结合CPU利用率和性能考虑,方法调用栈跟踪采集率配置采集率,配置示例: m/n,表示n个里面抽m个进行采集跟踪
// 预发环境和测试环境可以配置全采集,例如配置1/1,生产环境可以结合CPU利用率和性能考虑按需配置采集率
properties.setProperty("stackTraceSamplingRate", "1/2");
// 是否允许SQL染色标记作为前缀,默认false表示仅作为后缀
properties.setProperty("startsWithMarkingAllowed", "false");
sQLMarkingInterceptor.setProperties(properties);
return sQLMarkingInterceptor;
}
接入效果
SELECT
id,
tenant_code,
warehouse_no,
sku,
location_no,
container_level_1,
container_level_2,
lot_no,
sku_level,
owner_no,
pack_code,
conversion_rate,
stock_qty,
prepicked_qty,
premoved_qty,
frozen_qty,
diff_qty,
broken_qty,
status,
md5_value,
version,
create_user,
update_user,
create_time,
update_time,
extend_content
FROM
st_stock
WHERE
deleted = 0
AND warehouse_no = ?
AND location_no IN(?)
AND container_level_1 IN(?)
AND container_level_2 IN(?)
AND sku IN(?)
/* [SQLMarking] statementId: com.jdwl.wms.stock.infrastructure.jdbc.main.dao.StockQueryDao.selectExtendedStockByLocation, stackTrace: BaseJmqConsumer#onMessage ==> StockInfoConsumer#handle ==> StockInfoConsumer#handleEvent ==> StockExtendContentFiller#fillExtendContent ==> StockInitializer#queryStockByWarehouse ==> StockInitializer#batchQueryStockByWarehouse ==> StockInitializer#queryByLocationAndSku ==> StockQueryRepositoryImpl#queryExtendedStockByLocationAndSku, warehouseNo: 6_6_601 */
如何接入本文工具?
如果小伙伴也有类似使用诉求,大家可以先在测试、UAT环境接入试用,然后再逐步推广线上生产环境。
1、新引入本文的工具依赖
<dependency>
<groupId>com.jd.sword</groupId>
<artifactId>sword-utils-common</artifactId>
<version>1.0.3-SNAPSHOT</version>
</dependency>
2、使用工具类静态方法
com.jd.sword.utils.common.runtime.StackTraceUtils#simpleTrace()
com.jd.sword.utils.common.runtime.StackTraceUtils#simpleTrace(java.lang.String...)
com.jd.sword.utils.common.runtime.StackTraceUtils#trace()
com.jd.sword.utils.common.runtime.StackTraceUtils#trace(java.lang.String...)
com.jd.sword.utils.common.runtime.StackTraceUtils#trace(boolean)
com.jd.sword.utils.common.runtime.StackTraceUtils#trace(boolean, boolean, java.lang.String...)
来源:juejin.cn/post/7565423807570952198
再说一遍!不要封装组件库!
最近公司里事儿比较多,项目也比较杂,但是因为公司的项目主要是聚焦OA方面,很多东西可以复用。
比方说:表单、表格、搜索栏等等,这部分现阶段大部分都是各写各的,每个项目因为主要的开发不同,各自维护自己的一份。

但是领导现在觉得还是维护一套组件库来的比较方便,一来是减少重复工作量,提升开发效率,二来是方便新人加入团队以后尽量与老成员开发风格保持一致。
另外还有一个原因是项目内现在有的用AntDesign,有的用ElmentPlus,这些库的样式和UI设计出来的风格不搭,改起来也非常麻烦。
我听见这个提议以后后背冷汗都下来了。
我再跟大家强调一遍,不要封装组件库!
咱们说说为什么:
抬高开发成本
大部分人都感觉封装组件库是降低了开发成本,但实际上大部分项目并非如此,封装组件库大部分时候都是抬高了开发成本。
项目不同,面对的客户不同,需求也就不同,所以无论是客户方的需求还是UI设计稿都存在一定的差别,有些时候差距很大。
针对项目单独进行开发虽然在表面上看起来是浪费了人力资源,重复了很多工作,但是在后期开发和维护过程中会节省非常多的时间。
这部分都是成本。很多时候组件的开发并不是面对产品或者团队的,而是面向项目和客户的。
这也就导致了组件的开发会存在极大的不确定性,一方面是需求的不确定,另一方面是组件灵活度的不确定。
很多时候开发出来的组件库会衍生出N多个版本,切出N多个分支,最后在各个项目中引用,逐渐变成一个臃肿的垃圾代码集合体。
我不相信有人会在自己的项目上改完以后,还把修改的部分根据他人的反馈再进行调整,最后合并到 master 分支上去。
我从未见过有这个操作的兄弟。
技术达不到封装水平
团队内部技术不在一个水平线上,事实上也不可能在一个水平线上。
有些人的技术好,封装出来的组件确实很契合大多数的业务场景,有些人技术稍逊,封装出来的组件就不一定能契合项目。
但是如果你用他人封装的组件,牵扯到定制化需求的时候势必会改造,这时候改造就有可能会影响其他项目。
尤其一种情况,老项目升级,这是组件库最容易出问题的时候。可能上个版本封版的组件库在老项目运行的非常完美,但是需要升级的项目引用新的组件库的时候就会出现很多问题。
大部分程序员其实都达不到封装组件库的水准。
如果想要试一试可以参考ElmentUI老版本代码,自己封装一下Select、Input、Button这几个组件,看看和这些久经考验的开源组件库编码程序员还差多少。
技术负债严重
承接上一个问题,不是团队内每个人的水平都一样,并且每个人的编码风格也都是不一样的。(Ts最大的作用点)
可能组件库建立初期会节省非常多的重复工作,毕竟拿来就用,而且本身就是封装好的,简直可以为自己鼓掌了。
照着镜子问这是哪个天才编写的组件库,简直不要太棒了。
但是随着时间的推移,你会发现这个组件库越来越难用,需要考虑的方面越来越多,受影响的模块越来越多,你会变得越来越不敢动其中的代码。
项目越来越多,组件库中的分支和版本越来越多,团队中的人有些已经离开,有些人刚来,这时候技术负债就已经形成了。
更不要说大部分人没有写技术文档的习惯,甚至是连写注释的习惯都没有,功能全靠看代码和猜,技术上的负债越来越严重,这个阶段组件库离崩塌就已经不远了。
新项目在立项之初你就会本能的排斥使用组件库,但是对于老项目呢?改是不可能改动的,但是不维护Bug又挂在这儿。
那你到底是选择代码能跑,还是选择...

对个人发展不利
有些兄弟觉得能封装组件库,让自己的代码在这个团队,这个公司永远的流传下去,简直是跟青史留名差不多了。以后谁用到这个组件都会看到author后面写着我的名字。
但事实并非如此!
封装出的组件库大部分情况下会让你"青💩留名",因为后面的每个人用这个组件都会骂,这是哪个zz封装的组件,为啥这么写,这里不应该这么写嘛?
如果你一直呆在这个公司,由你一手搭建的这个组件库将伴随你在这个公司的整个职业生涯。
一时造轮子,一辈子维护轮子!
只要任何人用到你这个组件库,遇到了问题一定会来找你。不管你现在到底有没有在负责这个组件库!
这种通用性的组件库不可能没有问题,但是一旦有了问题找到你,你或者是解决不了,又或者是解决的不及时,都将或多或少的影响你的同事对你的评价。
当所有人都对你封装的这个组件库不满意,并且在开组会的时候提出来因为xx封装的组件库不好使,导致了项目延期,时间一长你的领导会对你有好印象?
结语
希望兄弟们还是要明白,对于一个职场人来说,挣钱最重要,能升上去最重要。其他的所有都是细枝末节,不必太在意。
对于客户和老板而言,能快速交付,把钱挣到手最重要,其他也都是无所谓的小事。
对于咱们自己来说,喜欢折腾是程序员的特质,但是要分清形势。
来源:juejin.cn/post/7532773597850206243
我发现很多程序员都不会打日志。。
大家好,我是程序员鱼皮。我发现很多程序员都不打日志,有的是 不想 打、有的是 意识不到 要打、还有的是 真不会 打日志啊!
前段时间的模拟面试中,我问了几位应届的 Java 开发同学 “你在项目中是怎么打日志的”,得到的答案竟然是 “支支吾吾”、“阿巴阿巴”,更有甚者,竟然表示:直接用 System.out.println() 打印一下吧。。。

要知道,日志是我们系统出现错误时,最快速有效的定位工具,没有日志给出的错误信息,遇到报错你就会一脸懵逼;而且日志还可以用来记录业务信息,比如记录用户执行的每个操作,不仅可以用于分析改进系统,同时在遇到非法操作时,也能很快找到凶手。
因此,对于程序员来说,日志记录是重要的基本功。但很多同学并没有系统学习过日志操作、缺乏经验,所以我写下这篇文章,分享自己在开发项目中记录日志的方法和最佳实践,希望对大家有帮助~
一、日志记录的方法
日志框架选型
有很多 Java 的日志框架和工具库,可以帮我们用一行代码快速完成日志记录。
在学习日志记录之前,很多同学应该是通过 System.out.println 输出信息来调试程序的,简单方便。
但是,System.out.println 存在很严重的问题!

首先,System.out.println 是一个同步方法,每次调用都会导致 I/O 操作,比较耗时,频繁使用甚至会严重影响应用程序的性能,所以不建议在生产环境使用。此外,它只能输出简单的信息到标准控制台,无法灵活设置日志级别、格式、输出位置等。
所以我们一般会选择专业的 Java 日志框架或工具库,比如经典的 Apache Log4j 和它的升级版 Log4j 2,还有 Spring Boot 默认集成的 Logback 库。不仅可以帮我们用一行代码更快地完成日志记录,还能灵活调整格式、设置日志级别、将日志写入到文件中、压缩日志等。
可能还有同学听说过 SLF4J(Simple Logging Facade for Java),看英文名就知道了,这玩意并不是一个具体的日志实现,而是为各种日志框架提供简单统一接口的日志门面(抽象层)。
啥是门面?
举个例子,现在我们要记录日志了,先联系到前台接待人员 SLF4J,它说必须要让我们选择日志的级别(debug / info / warn / error),然后要提供日志的内容。确认之后,SLF4J 自己不干活,屁颠屁颠儿地去找具体的日志实现框架,比如 Logback,然后由 Logback 进行日志写入。

这样做有什么好处呢?无论我们选择哪套日志框架、或者后期要切换日志框架,调用的方法始终是相同的,不用再去更改日志调用代码,比如将 log.info 改为 log.printInfo。
既然 SLF4J 只是玩抽象,那么 Log4j、Log4j 2 和 Logback 应该选择哪一个呢?
值得一提的是,SLF4J、Log4j 和 Logback 竟然都是同一个作者(俄罗斯程序员 Ceki Gülcü)。
首先,Log4j 已经停止维护,直接排除。Log4j 2 和 Logback 基本都能满足功能需求,那么就看性能、稳定性和易用性。
- 从性能来说,Log4j 2 和 Logback 虽然都支持异步日志,但是 Log4j 基于 LMAX Disruptor 高性能异步处理库实现,性能更高。
- 从稳定性来说,虽然这些日志库都被曝出过漏洞,但 Log4j 2 的漏洞更为致命,姑且算是 Logback 得一分。
- 从易用性来说,二者差不多,但 Logback 是 SLF4J 的原生实现、Log4j2 需要额外使用 SLF4J 绑定器实现。
再加上 Spring Boot 默认集成了 Logback,如果没有特殊的性能需求,我会更推荐初学者选择 Logback,都不用引入额外的库了~
使用日志框架
日志框架的使用非常简单,一般需要先获取到 Logger 日志对象,然后调用 logger.xxx(比如 logger.info)就能输出日志了。
最传统的方法就是通过 LoggerFactory 手动获取 Logger,示例代码如下:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class MyService {
private static final Logger logger = LoggerFactory.getLogger(MyService.class);
public void doSomething() {
logger.info("执行了一些操作");
}
}
上述代码中,我们通过调用日志工厂并传入当前类,创建了一个 logger。但由于每个类的类名都不同,我们又经常复制这行代码到不同的类中,就很容易忘记修改类名。
所以我们可以使用 this.getClass 动态获取当前类的实例,来创建 Logger 对象:
public class MyService {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
public void doSomething() {
logger.info("执行了一些操作");
}
}
给每个类都复制一遍这行代码,就能愉快地打日志了。
但我觉得这样做还是有点麻烦,我连复制粘贴都懒得做,怎么办?
还有更简单的方式,使用 Lombok 工具库提供的 @Slf4j 注解,可以自动为当前类生成一个名为 log 的 SLF4J Logger 对象,简化了 Logger 的定义过程。示例代码如下:
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class MyService {
public void doSomething() {
log.info("执行了一些操作");
}
}
这也是我比较推荐的方式,效率杠杠的。

此外,你可以通过修改日志配置文件(比如 logback.xml 或 logback-spring.xml)来设置日志输出的格式、级别、输出路径等。日志配置文件比较复杂,不建议大家去记忆语法,随用随查即可。

二、日志记录的最佳实践
学习完日志记录的方法后,再分享一些我个人记录日志的经验。内容较多,大家可以先了解一下,实际开发中按需运用。
1、合理选择日志级别
日志级别的作用是标识日志的重要程度,常见的级别有:
- TRACE:最细粒度的信息,通常只在开发过程中使用,用于跟踪程序的执行路径。
- DEBUG:调试信息,记录程序运行时的内部状态和变量值。
- INFO:一般信息,记录系统的关键运行状态和业务流程。
- WARN:警告信息,表示可能存在潜在问题,但系统仍可继续运行。
- ERROR:错误信息,表示出现了影响系统功能的问题,需要及时处理。
- FATAL:致命错误,表示系统可能无法继续运行,需要立即关注。
其中,用的最多的当属 DEBUG、INFO、WARN 和 ERROR 了。
建议在开发环境使用低级别日志(比如 DEBUG),以获取详细的信息;生产环境使用高级别日志(比如 INFO 或 WARN),减少日志量,降低性能开销的同时,防止重要信息被无用日志淹没。
注意一点,日志级别未必是一成不变的,假如有一天你的程序出错了,但是看日志找不到任何有效信息,可能就需要降低下日志输出级别了。
2、正确记录日志信息
当要输出的日志内容中存在变量时,建议使用参数化日志,也就是在日志信息中使用占位符(比如 {}),由日志框架在运行时替换为实际参数值。
比如输出一行用户登录日志:
// 不推荐
logger.debug("用户ID:" + userId + " 登录成功。");
// 推荐
logger.debug("用户ID:{} 登录成功。", userId);
这样做不仅让日志清晰易读;而且在日志级别低于当前记录级别时,不会执行字符串拼接,从而避免了字符串拼接带来的性能开销、以及潜在的 NullPointerException 问题。所以建议在所有日志记录中,使用参数化的方式替代字符串拼接。
此外,在输出异常信息时,建议同时记录上下文信息、以及完整的异常堆栈信息,便于排查问题:
try {
// 业务逻辑
} catch (Exception e) {
logger.error("处理用户ID:{} 时发生异常:", userId, e);
}
3、控制日志输出量
过多的日志不仅会占用更多的磁盘空间,还会增加系统的 I/O 负担,影响系统性能。
因此,除了根据环境设置合适的日志级别外,还要尽量避免在循环中输出日志。
可以添加条件来控制,比如在批量处理时,每处理 1000 条数据时才记录一次:
if (index % 1000 == 0) {
logger.info("已处理 {} 条记录", index);
}
或者在循环中利用 StringBuilder 进行字符串拼接,循环结束后统一输出:
StringBuilder logBuilder = new StringBuilder("处理结果:");
for (Item item : items) {
try {
processItem(item);
logBuilder.append(String.format("成功[ID=%s], ", item.getId()));
} catch (Exception e) {
logBuilder.append(String.format("失败[ID=%s, 原因=%s], ", item.getId(), e.getMessage()));
}
}
logger.info(logBuilder.toString());
如果参数的计算开销较大,且当前日志级别不需要输出,应该在记录前进行级别检查,从而避免多余的参数计算:
if (logger.isDebugEnabled()) {
logger.debug("复杂对象信息:{}", expensiveToComputeObject());
}
此外,还可以通过更改日志配置文件整体过滤掉特定级别的日志,来防止日志刷屏:
<!-- Logback 示例 -->
<appender name="LIMITED" class="ch.qos.logback.classic.AsyncAppender">
<!-- 只允许 INFO 级别及以上的日志通过 -->
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>INFO</level>
</filter>
<!-- 配置其他属性 -->
</appender>
4、把控时机和内容
很多开发者(尤其是线上经验不丰富的开发者)并没有养成记录日志的习惯,觉得记录日志不重要,等到出了问题无法排查的时候才追悔莫及。
一般情况下,需要在系统的关键流程和重要业务节点记录日志,比如用户登录、订单处理、支付等都是关键业务,建议多记录日志。
对于重要的方法,建议在入口和出口记录重要的参数和返回值,便于快速还原现场、复现问题。
对于调用链较长的操作,确保在每个环节都有日志,以便追踪到问题所在的环节。
如果你不想区分上面这些情况,我的建议是尽量在前期多记录一些日志,后面再慢慢移除掉不需要的日志。比如可以利用 AOP 切面编程在每个业务方法执行前输出执行信息:
@Aspect
@Component
public class LoggingAspect {
@Before("execution(* com.example.service..*(..))")
public void logBeforeMethod(JoinPoint joinPoint) {
Logger logger = LoggerFactory.getLogger(joinPoint.getTarget().getClass());
logger.info("方法 {} 开始执行", joinPoint.getSignature().getName());
}
}
利用 AOP,还可以自动打印每个 Controller 接口的请求参数和返回值,这样就不会错过任何一次调用信息了。
不过这样做也有一个很重要的点,注意不要在日志中记录了敏感信息,比如用户密码。万一你的日志不小心泄露出去,就相当于泄露了大量用户的信息。

5、日志管理
随着日志文件的持续增长,会导致磁盘空间耗尽,影响系统正常运行,所以我们需要一些策略来对日志进行管理。
首先是设置日志的滚动策略,可以根据文件大小或日期,自动对日志文件进行切分。比如按文件大小滚动:
<!-- 按大小滚动 -->
<rollingPolicy class="ch.qos.logback.core.rolling.SizeBasedRollingPolicy">
<maxFileSize>10MB</maxFileSize>
</rollingPolicy>
如果日志文件大小达到 10MB,Logback 会将当前日志文件重命名为 app.log.1 或其他命名模式(具体由文件名模式决定),然后创建新的 app.log 文件继续写入日志。
还有按照时间日期滚动:
<!-- 按时间滚动 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>logs/app-%d{yyyy-MM-dd}.log</fileNamePattern>
</rollingPolicy>
上述配置表示每天创建一个新的日志文件,%d{yyyy-MM-dd} 表示按照日期命名日志文件,例如 app-2024-11-21.log。
还可以通过 maxHistory 属性,限制保留的历史日志文件数量或天数:
<maxHistory>30</maxHistory>
这样一来,我们就可以按照天数查看指定的日志,单个日志文件也不会很大,提高了日志检索效率。
对于用户较多的企业级项目,日志的增长是飞快的,因此建议开启日志压缩功能,节省磁盘空间。
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>logs/app-%d{yyyy-MM-dd}.log.gz</fileNamePattern>
</rollingPolicy>
上述配置表示:每天生成一个新的日志文件,旧的日志文件会被压缩存储。
除了配置日志切分和压缩外,我们还需要定期审查日志,查看日志的有效性和空间占用情况,从日志中发现系统的问题、清理无用的日志信息等。
如果你想偷懒,也可以写个自动化清理脚本,定期清理过期的日志文件,释放磁盘空间。比如:
# 每月清理一次超过 90 天的日志文件
find /var/log/myapp/ -type f -mtime +90 -exec rm {} ;
6、统一日志格式
统一的日志格式有助于日志的解析、搜索和分析,特别是在分布式系统中。
我举个例子大家就能感受到这么做的重要性了。
统一的日志格式:
2024-11-21 14:30:15.123 [main] INFO com.example.service.UserService - 用户ID:12345 登录成功
2024-11-21 14:30:16.789 [main] ERROR com.example.service.UserService - 用户ID:12345 登录失败,原因:密码错误
2024-11-21 14:30:17.456 [main] DEBUG com.example.dao.UserDao - 执行SQL:[SELECT * FROM users WHERE id=12345]
2024-11-21 14:30:18.654 [main] WARN com.example.config.AppConfig - 配置项 `timeout` 使用默认值:3000ms
2024-11-21 14:30:19.001 [main] INFO com.example.Main - 应用启动成功,耗时:2.34秒
这段日志整齐清晰,支持按照时间、线程、级别、类名和内容搜索。
不统一的日志格式:
2024/11/21 14:30 登录成功 用户ID: 12345
2024-11-21 14:30:16 错误 用户12345登录失败!密码不对
DEBUG 执行SQL SELECT * FROM users WHERE id=12345
Timeout = default
应用启动成功
emm,看到这种日志我直接原地爆炸!

建议每个项目都要明确约定和配置一套日志输出规范,确保日志中包含时间戳、日志级别、线程、类名、方法名、消息等关键信息。
<!-- 控制台日志输出 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<!-- 日志格式 -->
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
也可以直接使用标准化格式,比如 JSON,确保所有日志遵循相同的结构,便于后续对日志进行分析处理:
<encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
<!-- 配置 JSON 编码器 -->
</encoder>
此外,你还可以通过 MDC(Mapped Diagnostic Context)给日志添加额外的上下文信息,比如用户 ID、请求 ID 等,方便追踪。在 Java 代码中,可以为 MDC 变量设置值:
MDC.put("requestId", "666");
MDC.put("userId", "yupi");
logger.info("用户请求处理完成");
MDC.clear();
对应的日志配置如下:
<!-- 文件日志配置 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<encoder>
<!-- 包含 MDC 信息 -->
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - [%X{requestId}] [%X{userId}] %msg%n</pattern>
</encoder>
</appender>
这样,每个请求、每个用户的操作一目了然。
7、使用异步日志
对于追求性能的操作,可以使用异步日志,将日志的写入操作放在单独的线程中,减少对主线程的阻塞,从而提升系统性能。
除了自己开线程去执行 log 操作之外,还可以直接修改配置来开启 Logback 的异步日志功能:
<!-- 异步 Appender -->
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
<queueSize>500</queueSize> <!-- 队列大小 -->
<discardingThreshold>0</discardingThreshold> <!-- 丢弃阈值,0 表示不丢弃 -->
<neverBlock>true</neverBlock> <!-- 队列满时是否阻塞主线程,true 表示不阻塞 -->
<appender-ref ref="CONSOLE" /> <!-- 生效的日志目标 -->
<appender-ref ref="FILE" />
</appender>
上述配置的关键是配置缓冲队列,要设置合适的队列大小和丢弃策略,防止日志积压或丢失。
8、集成日志收集系统
在比较成熟的公司中,我们可能会使用更专业的日志管理和分析系统,比如 ELK(Elasticsearch、Logstash、Kibana)。不仅不用每次都登录到服务器上查看日志文件,还可以更灵活地搜索日志。
但是搭建和运维 ELK 的成本还是比较大的,对于小团队,我的建议是不要急着搞这一套。
OK,就分享到这里,洋洋洒洒 4000 多字,希望这篇文章能帮助大家意识到日志记录的重要性,并养成良好的日志记录习惯。学会的话给鱼皮点个赞吧~
日志不是写给机器看的,是写给未来的你和你的队友看的!
更多
来源:juejin.cn/post/7439785794917072896
微服务正在悄然消亡:这是一件美好的事
最近在做的事情正好需要系统地研究微服务与单体架构的取舍与演进。读到这篇文章《Microservices Are Quietly Dying — And It’s Beautiful》,许多观点直击痛点、非常启发,于是我顺手把它翻译出来,分享给大家,也希望能给同样在复杂性与效率之间权衡的团队一些参考。
微服务正在悄然消亡:这是一件美好的事
为了把我们的创业产品扩展到数百万用户,我们搭建了 47 个微服务。
用户从未达到一百万,但我们达到了每月 23,000 美元的 AWS 账单、长达 14 小时的故障,以及一个再也无法高效交付新功能的团队。
那一刻我才意识到:我们并没有在构建产品,而是在搭建一座分布式的自恋纪念碑。

我们都信过的谎言
五年前,微服务几乎是教条。Netflix 用它,Uber 用它。每一场技术大会、每一篇 Medium 文章、每一位资深架构师都在高喊同一句话:单体不具备可扩展性,微服务才是答案。
于是我们照做了。我们把 Rails 单体拆成一个个服务:用户服务、认证服务、支付服务、通知服务、分析服务、邮件服务;然后是子服务,再然后是调用服务的服务,层层套叠。
到第六个月,我们已经在 12 个 GitHub 仓库里维护 47 个服务。我们的部署流水线像一张地铁图,架构图需要 4K 显示器才能看清。
当“最佳实践”变成“最差实践”
我们不断告诫自己:一切都在运转。我们有 Kubernetes,有服务网格,有用 Jaeger 的分布式追踪,有 ELK 的日志——我们很“现代”。
但那些光鲜的微服务文章从不提的一点是:分布式的隐性税。
每一个新功能都变成跨团队的协商。想给用户资料加一个字段?那意味着要改五个服务、提三个 PR、协调两周,并进行一次像劫案电影一样精心编排的数据库迁移。
我们的预发布环境成本甚至高于生产环境,因为想测试任何东西,都需要把一切都跑起来。47 个服务在 Docker Compose 里同时启动,内存被疯狂吞噬。
那个彻夜崩溃的夜晚
凌晨 2:47,Slack 被消息炸翻。
生产环境宕了。不是某一个服务——是所有服务。支付服务连不上用户服务,通知服务不断超时,API 网关对每个请求都返回 503。
我打开分布式追踪面板:一万五千个 span,全线飘红。瀑布图像抽象艺术。我花了 40 分钟才定位出故障起点。
结果呢?一位初级开发在认证服务上发布了一个配置变更,只是一个环境变量。它让令牌校验多了 2 秒延迟,这个延迟在 11 个下游服务间层层传递,超时叠加、断路器触发、重试逻辑制造请求风暴,整个系统在自身重量下轰然倒塌。
我们搭了一座纸牌屋,却称之为“容错架构”。
我们花了六个小时才修复。并不是因为 bug 复杂——它只是一个配置的单行改动,而是因为排查分布式系统就像破获一桩谋杀案:每个目击者说着不同的语言,而且有一半在撒谎。
那个被忽略的低语
一周后,在复盘会上,我们的 CTO 说了句让所有人不自在的话:
“要不我们……回去?”
回到单体。回到一个仓库。回到简单。
会议室一片沉默。你能感到认知失调。我们是工程师,我们很“高级”。单体是给传统公司和训练营毕业生用的,不是给一家正打造未来的 A 轮初创公司用的。
但随后有人把指标展开:平均恢复时间 4.2 小时;部署频率每周 2.3 次(从单体时代的每周 12 次一路下滑);云成本增长速度比营收快 40%。
数字不会说谎。是架构在拖垮我们。
美丽的回归
我们用了三个月做整合。47 个服务归并成一个模块划分清晰的 Rails 应用;Kubernetes 变成负载均衡后面的三台 EC2;12 个仓库的工作流收敛成一个边界明确的仓库。
结果简直让人尴尬。
部署时间从 25 分钟降到 90 秒;AWS 账单从 23,000 美元降到 3,800 美元;P95 延迟提升了 60%,因为我们消除了 80% 的网络调用。更重要的是——我们又开始按时交付功能了。
开发者不再说“我需要和三个团队协调”,而是开始说“午饭前给你”。
我们的“分布式系统”变回了结构良好的应用。边界上下文变成 Rails 引擎,服务调用变成方法调用,Kafka 变成后台任务,“编排层”……就是 Rails 控制器。
它更快,它更省,它更好。
我们真正学到的是什么
这是真相:我们为此付出两年时间和 40 万美元才领悟——
微服务不是一种纯粹的架构模式,而是一种组织模式。Netflix 需要它,因为他们有 200 个团队。你没有。Uber 需要它,因为他们一天发布 4,000 次。你没有。
复杂性之所以诱人,是因为它看起来像进步。 拥有 47 个服务、Kubernetes、服务网格和分布式追踪,看起来很“专业”;而一个单体加一套 Postgres,看起来很“业余”。
但复杂性是一种税。它以认知负担、运营开销、开发者幸福感和交付速度为代价。
而大多数初创公司根本付不起这笔税。
我们花了两年时间为并不存在的规模做优化,同时牺牲了能让我们真正达到规模的简单性。
你不需要 50 个微服务,你需要的是自律
软件架构的“肮脏秘密”是:好的设计在任何规模都奏效。
一个结构良好的单体,拥有清晰的模块、明确的边界上下文和合理的关注点分离,比一团由希望和 YAML 勉强粘合在一起的微服务乱麻走得更远。
微服务并不是因为“糟糕”而式微,而是因为我们出于错误的理由使用了它。我们选择了分布式的复杂性而不是本地的自律,选择了运营的负担而不是价值的交付。
那些悄悄回归单体的公司并非承认失败,而是在承认更难的事实:我们一直在解决错误的问题。
所以我想问一个问题:你构建微服务,是在逃避什么?
如果答案是“一个凌乱的代码库”,那我有个坏消息——分布式系统不会修好坏代码,它只会让问题更难被发现。
来源:juejin.cn/post/7563860666349649970
Spring Boot 分布式事务高阶玩法:从入门到精通
嘿,各位 Java 小伙伴们!今天咱们要来聊聊 Spring Boot 里一个超酷炫但又有点让人头疼的家伙 —— 分布式事务。这玩意儿就像是一场大型派对的组织者,要确保派对上所有的活动(操作)要么都顺顺利利地进行,要么就一起取消,绝对不能出现有的活动进行了一半,有的却没开始的尴尬局面。
为啥要有分布式事务
在以前那种单体应用的小世界里,事务处理就像在自己家里整理东西,所有的东西(数据)都在一个地方,要保证操作的一致性很容易。但随着业务越来越复杂,应用变成了分布式的 “大杂烩”,各个服务就像住在不同房子里的小伙伴,这时候再想保证所有操作都一致,就需要分布式事务这个 “超级协调员” 出场啦。
Spring Boot 里的分布式事务支持
Spring Boot 对分布式事务的支持就像是给你配备了一套超级工具包。其中,@Transactional注解大家肯定都很熟悉,在单体应用里它就是事务管理的小能手。但在分布式场景下,我们还有更厉害的武器,比如基于 XA 协议的分布式事务管理器,以及像 Seata 这样的开源框架。
XA 协议的分布式事务管理器
XA 协议就像是一个国际通用的 “交流规则”,它规定了数据库和事务管理器之间怎么沟通。在 Spring Boot 里使用 XA 协议的分布式事务管理器,就像是给各个服务的数据库都请了一个翻译,让它们能准确地交流事务相关的信息。
下面我们来看一段简单的代码示例,假设我们有两个服务,一个是订单服务,一个是库存服务,我们要在创建订单的同时扣减库存,并且保证这两个操作要么都成功,要么都失败。
首先,我们需要配置 XA 数据源,这里以 MySQL 为例:
@Configuration
public class XADataSourceConfig {
@Bean
@ConfigurationProperties(prefix = "spring.datasource")
public DataSourceProperties dataSourceProperties() {
return new DataSourceProperties();
}
@Bean
public DataSource dataSource() {
return dataSourceProperties().initializeDataSourceBuilder()
.type(com.mysql.cj.jdbc.MysqlXADataSource.class)
.build();
}
}
然后,配置事务管理器:
@Configuration
public class XATransactionConfig {
@Autowired
private DataSource dataSource;
@Bean
public PlatformTransactionManager transactionManager() throws SQLException {
return new JtaTransactionManager(new UserTransactionFactory(), new TransactionManagerFactory(dataSource));
}
}
接下来,在业务代码里使用@Transactional注解:
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
@Autowired
private StockService stockService;
@Transactional
public void createOrder(Order order) {
orderRepository.save(order);
stockService.decreaseStock(order.getProductId(), order.getQuantity());
}
}
在这个例子里,createOrder方法上的@Transactional注解就像一个 “指挥官”,它会协调订单保存和库存扣减这两个操作,确保它们在同一个事务里执行。
Seata 框架
Seata 就像是一个更智能、更强大的 “事务指挥官”。它有三个重要的组件:TC(Transaction Coordinator)事务协调器、TM(Transaction Manager)事务管理器和 RM(Resource Manager)资源管理器。TC 就像一个调度中心,TM 负责发起和管理事务,RM 则负责管理资源和提交 / 回滚事务。
使用 Seata,我们首先要在项目里引入相关依赖:
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>
然后,配置 Seata 客户端:
seata:
application-id: ${spring.application.name}
tx-service-group: my_test_tx_group
enable-auto-data-source-proxy: true
client:
rm:
async-commit-buffer-limit: 10000
lock:
retry-interval: 10
retry-times: 30
retry-policy-branch-rollback-on-conflict: true
tm:
commit-retry-count: 5
rollback-retry-count: 5
undo:
data-validation: true
log-serialization: jackson
log-table: undo_log
在业务代码里,我们使用@GlobalTransactional注解来开启全局事务:
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
@Autowired
private StockService stockService;
@GlobalTransactional
public void createOrder(Order order) {
orderRepository.save(order);
stockService.decreaseStock(order.getProductId(), order.getQuantity());
}
}
这里的@GlobalTransactional注解就像是给整个分布式事务场景下了一道 “圣旨”,让所有涉及到的服务都按照统一的事务规则来执行。
总结
分布式事务虽然复杂,但有了 Spring Boot 提供的强大支持,以及像 Seata 这样优秀的框架,我们也能轻松应对。就像掌握了一门高超的魔法,让我们的分布式系统变得更加可靠和强大。希望今天的分享能让大家对 Spring Boot 中的分布式事务有更深入的理解,在开发的道路上一路 “开挂”,解决各种复杂的业务场景。
来源:juejin.cn/post/7490588889948061750
⚔️ ReentrantLock大战synchronized:谁是锁界王者?
一、选手登场!🎬
🔵 蓝方:synchronized(老牌选手)
// synchronized:Java自带的语法糖
public synchronized void method() {
// 临界区代码
}
// 或者
public void method() {
synchronized(this) {
// 临界区代码
}
}
特点:
- 📜 JDK 1.0就有了,资历老
- 🎯 简单粗暴,写法简单
- 🤖 JVM级别实现,自动释放
- 💰 免费午餐,不需要手动管理
🔴 红方:ReentrantLock(新锐选手)
// ReentrantLock:JDK 1.5引入
ReentrantLock lock = new ReentrantLock();
public void method() {
lock.lock(); // 手动加锁
try {
// 临界区代码
} finally {
lock.unlock(); // 必须手动释放!
}
}
特点:
- 🆕 JDK 1.5新秀,年轻有活力
- 🎨 功能丰富,花样多
- 🏗️ API级别实现,灵活强大
- ⚠️ 需要手动管理,容易忘记释放
二、底层实现对决 💻
Round 1: synchronized的底层实现
1️⃣ 对象头结构(Mark Word)
Java对象内存布局:
┌────────────────────────────────────┐
│ 对象头 (Object Header) │
│ ┌─────────────────────────────┐ │
│ │ Mark Word (8字节) │ ← 存储锁信息
│ ├─────────────────────────────┤ │
│ │ 类型指针 (4/8字节) │ │
│ └─────────────────────────────┘ │
├────────────────────────────────────┤
│ 实例数据 (Instance Data) │
├────────────────────────────────────┤
│ 对齐填充 (Padding) │
└────────────────────────────────────┘
Mark Word在不同锁状态下的变化:
64位虚拟机的Mark Word(8字节=64位)
┌──────────────────────────────────────────────────┐
│ 无锁状态 (001) │
│ ┌────────────┬─────┬──┬──┬──┐ │
│ │ hashcode │ age │0 │01│ 未锁定 │
│ └────────────┴─────┴──┴──┴──┘ │
├──────────────────────────────────────────────────┤
│ 偏向锁 (101) │
│ ┌────────────┬─────┬──┬──┬──┐ │
│ │ 线程ID │epoch│1 │01│ 偏向锁 │
│ └────────────┴─────┴──┴──┴──┘ │
├──────────────────────────────────────────────────┤
│ 轻量级锁 (00) │
│ ┌────────────────────────────┬──┐ │
│ │ 栈中锁记录指针 │00│ 轻量级锁 │
│ └────────────────────────────┴──┘ │
├──────────────────────────────────────────────────┤
│ 重量级锁 (10) │
│ ┌────────────────────────────┬──┐ │
│ │ Monitor对象指针 │10│ 重量级锁 │
│ └────────────────────────────┴──┘ │
└──────────────────────────────────────────────────┘
2️⃣ 锁升级过程(重点!)
锁升级路径
无锁状态 偏向锁 轻量级锁 重量级锁
│ │ │ │
│ 第一次访问 │ 有竞争 │ 竞争激烈 │
├──────────────→ ├──────────────→ ├──────────────→ │
│ │ │ │
│ │ CAS失败 │ 自旋失败 │
│ │ │ │
🚶 一个人 🚶 还是一个人 🚶🚶 两个人 🚶🚶🚶 一群人
走路 (偏向这个人) 抢着走 排队走
详细解释:
阶段1:无锁 → 偏向锁
// 第一次有线程访问synchronized块
Thread-1第一次进入:
1. 对象处于无锁状态
2. Thread-1通过CAS在Mark Word中记录自己的线程ID
3. 成功!升级为偏向锁,偏向Thread-1
4. 下次Thread-1再来,发现Mark Word里是自己的ID,直接进入!
(就像VIP通道,不用检查)✨
生活比喻:
你第一次去常去的咖啡店☕,店员记住了你的脸。
下次你来,店员一看是你,直接给你做你的老口味,不用问!
阶段2:偏向锁 → 轻量级锁
Thread-2也想进入:
1. 发现偏向锁偏向的是Thread-1
2. Thread-1已经退出了,撤销偏向锁
3. 升级为轻量级锁
4. Thread-2通过CAS在栈帧中创建Lock Record
5. CAS将对象头的Mark Word复制到Lock Record
6. CAS将对象头指向Lock Record
7. 成功!获取轻量级锁 🎉
生活比喻:
咖啡店来了第二个客人,店员发现需要排队系统了。
拿出号码牌,谁先抢到谁先点单(自旋CAS)🎫
阶段3:轻量级锁 → 重量级锁
Thread-3、Thread-4、Thread-5也来了:
1. 多个线程竞争,CAS自旋失败
2. 自旋一定次数后,升级为重量级锁
3. 没抢到的线程进入阻塞队列
4. 需要操作系统介入,线程挂起(park)😴
生活比喻:
咖啡店人太多了!需要叫号系统 + 座位等待区。
没叫到号的人坐下来等,不用一直站着抢(操作系统介入)🪑
3️⃣ 字节码层面
public synchronized void method() {
System.out.println("hello");
}
字节码:
public synchronized void method();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED ← 看这里!方法标记
Code:
stack=2, locals=1, args_size=1
0: getstatic #2
3: ldc #3
5: invokevirtual #4
8: return
同步块字节码:
public void method() {
synchronized(this) {
System.out.println("hello");
}
}
public void method();
Code:
0: aload_0
1: dup
2: astore_1
3: monitorenter ← 进入monitor
4: getstatic #2
7: ldc #3
9: invokevirtual #4
12: aload_1
13: monitorexit ← 退出monitor
14: goto 22
17: astore_2
18: aload_1
19: monitorexit ← 异常时也要退出
20: aload_2
21: athrow
22: return
Round 2: ReentrantLock的底层实现
基于AQS(AbstractQueuedSynchronizer)实现:
// ReentrantLock内部
public class ReentrantLock {
private final Sync sync;
// 抽象同步器
abstract static class Sync extends AbstractQueuedSynchronizer {
// ...
}
// 非公平锁实现
static final class NonfairSync extends Sync {
final void lock() {
// 先CAS抢一次
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1); // 进入AQS队列
}
}
// 公平锁实现
static final class FairSync extends Sync {
final void lock() {
acquire(1); // 直接排队,不插队
}
}
}
数据结构:
ReentrantLock
│
├─ Sync (继承AQS)
│ ├─ state: int (0=未锁,>0=重入次数)
│ └─ exclusiveOwnerThread: Thread (持锁线程)
│
└─ CLH队列
Head → Node1 → Node2 → Tail
↓ ↓
Thread2 Thread3
(等待) (等待)
三、功能对比大战 ⚔️
🏁 功能对比表
| 功能 | synchronized | ReentrantLock | 胜者 |
|---|---|---|---|
| 加锁方式 | 自动 | 手动lock/unlock | synchronized ✅ |
| 释放方式 | 自动(异常也会释放) | 必须手动finally | synchronized ✅ |
| 公平锁 | 不支持 | 支持公平/非公平 | ReentrantLock ✅ |
| 可中断 | 不可中断 | lockInterruptibly() | ReentrantLock ✅ |
| 尝试加锁 | 不支持 | tryLock() | ReentrantLock ✅ |
| 超时加锁 | 不支持 | tryLock(timeout) | ReentrantLock ✅ |
| Condition | 只有一个wait/notify | 可多个Condition | ReentrantLock ✅ |
| 性能(JDK6+) | 优化后差不多 | 差不多 | 平局 ⚖️ |
| 使用难度 | 简单 | 复杂,易出错 | synchronized ✅ |
| 锁信息 | 不易查看 | getQueueLength()等 | ReentrantLock ✅ |
🎯 详细功能对比
1️⃣ 可中断锁
// ❌ synchronized不可中断
Thread t = new Thread(() -> {
synchronized(lock) {
// 即使调用t.interrupt(),这里也不会响应
while(true) {
// 死循环
}
}
});
// ✅ ReentrantLock可中断
Thread t = new Thread(() -> {
try {
lock.lockInterruptibly(); // 可响应中断
// ...
} catch (InterruptedException e) {
System.out.println("被中断了!");
}
});
t.start();
Thread.sleep(100);
t.interrupt(); // 可以中断!
2️⃣ 尝试加锁
// ❌ synchronized没有tryLock
synchronized(lock) {
// 要么拿到锁,要么一直等
}
// ✅ ReentrantLock可以尝试
if (lock.tryLock()) { // 尝试获取,不阻塞
try {
// 拿到锁了
} finally {
lock.unlock();
}
} else {
// 没拿到,去做别的事
System.out.println("锁被占用,我去干别的");
}
// ✅ 还支持超时
if (lock.tryLock(3, TimeUnit.SECONDS)) { // 等3秒
try {
// 拿到了
} finally {
lock.unlock();
}
} else {
// 3秒还没拿到,放弃
System.out.println("等太久了,不等了");
}
3️⃣ 公平锁
// ❌ synchronized只能是非公平锁
synchronized(lock) {
// 后来的线程可能插队
}
// ✅ ReentrantLock可选公平/非公平
ReentrantLock fairLock = new ReentrantLock(true); // 公平锁
ReentrantLock unfairLock = new ReentrantLock(false); // 非公平锁(默认)
公平锁 vs 非公平锁:
非公平锁(吞吐量高):
Thread-1持锁 → Thread-2排队 → Thread-3排队
↓
释放锁!
↓
Thread-4刚好来了,直接抢!(插队)✂️
虽然Thread-2先来,但Thread-4先抢到
公平锁(先来后到):
Thread-1持锁 → Thread-2排队 → Thread-3排队
↓
释放锁!
↓
Thread-4来了,但要排队到最后!
Thread-2先到先得 ✅
4️⃣ 多个条件变量
// ❌ synchronized只有一个等待队列
synchronized(lock) {
lock.wait(); // 只有一个等待队列
lock.notify(); // 随机唤醒一个
}
// ✅ ReentrantLock可以有多个Condition
ReentrantLock lock = new ReentrantLock();
Condition notFull = lock.newCondition(); // 条件1:未满
Condition notEmpty = lock.newCondition(); // 条件2:非空
// 生产者
lock.lock();
try {
while (queue.isFull()) {
notFull.await(); // 等待"未满"条件
}
queue.add(item);
notEmpty.signal(); // 唤醒"非空"条件的线程
} finally {
lock.unlock();
}
// 消费者
lock.lock();
try {
while (queue.isEmpty()) {
notEmpty.await(); // 等待"非空"条件
}
queue.remove();
notFull.signal(); // 唤醒"未满"条件的线程
} finally {
lock.unlock();
}
四、性能对决 🏎️
JDK 1.5时代:ReentrantLock完胜
JDK 1.5性能测试(100万次加锁):
synchronized: 2850ms 😓
ReentrantLock: 1200ms 🚀
ReentrantLock快2倍多!
JDK 1.6之后:synchronized反击!
JDK 1.6对synchronized做了大量优化:
- ✅ 偏向锁(Biased Locking)
- ✅ 轻量级锁(Lightweight Locking)
- ✅ 自适应自旋(Adaptive Spinning)
- ✅ 锁粗化(Lock Coarsening)
- ✅ 锁消除(Lock Elimination)
JDK 1.8性能测试(100万次加锁):
synchronized: 1250ms 🚀
ReentrantLock: 1200ms 🚀
几乎一样了!
优化技术解析
1️⃣ 偏向锁
// 同一个线程反复进入
for (int i = 0; i < 1000000; i++) {
synchronized(obj) {
// 偏向锁:第一次CAS,后续直接进入
// 性能接近无锁!✨
}
}
2️⃣ 锁消除
public String concat(String s1, String s2) {
// StringBuffer是线程安全的,有synchronized
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
// JVM发现sb是局部变量,不可能有竞争
// 自动消除StringBuffer内部的synchronized!
// 性能大幅提升!🚀
3️⃣ 锁粗化
// ❌ 原代码:频繁加锁解锁
for (int i = 0; i < 1000; i++) {
synchronized(obj) {
// 很短的操作
}
}
// ✅ JVM优化后:锁粗化
synchronized(obj) { // 把锁提到循环外
for (int i = 0; i < 1000; i++) {
// 很短的操作
}
}
五、使用场景推荐 📝
优先使用synchronized的场景
1️⃣ 简单的同步场景
// 简单的计数器
private int count = 0;
public synchronized void increment() {
count++;
}
2️⃣ 方法级别的同步
public synchronized void method() {
// 整个方法同步,简单明了
}
3️⃣ 不需要高级功能
// 只需要基本的互斥,不需要tryLock、Condition等
synchronized(lock) {
// 业务代码
}
优先使用ReentrantLock的场景
1️⃣ 需要可中断的锁
// 可以响应中断,避免死锁
lock.lockInterruptibly();
2️⃣ 需要尝试加锁
// 拿不到锁就去做别的事
if (lock.tryLock()) {
// ...
}
3️⃣ 需要公平锁
// 严格按照先来后到
ReentrantLock fairLock = new ReentrantLock(true);
4️⃣ 需要多个条件变量
// 生产者-消费者模式
Condition notFull = lock.newCondition();
Condition notEmpty = lock.newCondition();
5️⃣ 需要获取锁的信息
// 查看等待的线程数
int waiting = lock.getQueueLength();
// 查看是否有线程在等待
boolean hasWaiters = lock.hasQueuedThreads();
六、常见坑点 ⚠️
坑1:ReentrantLock忘记unlock
// ❌ 危险!如果中间抛异常,永远不会释放锁
lock.lock();
doSomething(); // 可能抛异常
lock.unlock(); // 不会执行!💣
// ✅ 正确写法
lock.lock();
try {
doSomething();
} finally {
lock.unlock(); // 一定会执行
}
坑2:synchronized锁错对象
// ❌ 每次都是新对象,不起作用!
public void method() {
synchronized(new Object()) { // 💣 错误!
// 相当于没加锁
}
}
// ✅ 正确写法
private final Object lock = new Object();
public void method() {
synchronized(lock) {
// ...
}
}
坑3:锁的粒度太大
// ❌ 锁的范围太大,性能差
public synchronized void method() { // 整个方法都锁住
doA(); // 不需要同步
doB(); // 需要同步
doC(); // 不需要同步
}
// ✅ 缩小锁范围
public void method() {
doA();
synchronized(lock) {
doB(); // 只锁需要的部分
}
doC();
}
七、面试应答模板 🎤
面试官:synchronized和ReentrantLock有什么区别?
你的回答:
主要从实现层面和功能层面两个角度对比:
实现层面:
- synchronized是JVM层面的,基于monitor机制,通过对象头的Mark Word实现
- ReentrantLock是API层面的,基于AQS(AbstractQueuedSynchronizer)实现
功能层面,ReentrantLock更强大:
- 可中断:lockInterruptibly()可响应中断
- 可尝试:tryLock()非阻塞获取锁
- 可超时:tryLock(time)超时放弃
- 公平锁:可选择公平或非公平
- 多条件:支持多个Condition
- 可监控:可获取等待线程数等信息
性能对比:
- JDK 1.6之前ReentrantLock性能更好
- JDK 1.6之后synchronized做了大量优化(偏向锁、轻量级锁、锁消除、锁粗化),性能差不多
- synchronized优化包括:无锁→偏向锁→轻量级锁→重量级锁的升级路径
使用建议:
- 简单场景优先synchronized(代码简洁,自动释放)
- 需要高级功能时用ReentrantLock(可中断、超时、公平锁等)
举个例子:
如果只是简单的计数器,用synchronized即可。但如果是银行转账系统,需要可中断、可超时,就应该用ReentrantLock。
八、总结 🎯
选择决策树:
需要同步?
│
Yes
│
┌─────────────┴─────────────┐
│ │
简单场景 复杂场景
(计数器、缓存等) (可中断、超时等)
│ │
synchronized ReentrantLock
│ │
✅ 简单 ✅ 功能强
✅ 自动释放 ⚠️ 需手动
✅ 性能好 ✅ 灵活
记忆口诀:
简单场景synchronized,
复杂需求ReentrantLock,
性能现在差不多,
根据场景来选择!🎵
最后一句话:
synchronized是"自动挡"🚗,简单好用;
ReentrantLock是"手动挡"🏎️,灵活强大!
来源:juejin.cn/post/7563822304766427172
消息队列和事件驱动如何实现流量削峰
消息队列和事件驱动架构不仅是实现流量削峰的关键技术,它们之间更是一种相辅相成、紧密协作的关系。下面这个表格可以帮您快速把握它们的核心联系与分工。
| 特性 | 消息队列 (Message Queue) | 事件驱动架构 (Event-Driven Architecture) |
|---|---|---|
| 核心角色 | 实现事件驱动架构的技术工具和传输机制 | 一种架构风格和设计模式 |
| 主要职责 | 提供异步通信通道,负责事件的存储、路由和可靠传递 | 定义系统各组件之间通过事件进行交互的规范 |
| 与流量削峰关系 | 实现流量削峰的具体手段(作为缓冲区) | 流量削峰是其在处理突发流量时的一种自然结果和能力体现 |
| 协作方式 | 事件驱动架构中,事件的生产与消费通常依赖消息队列来传递事件消息 | 为消息队列的应用提供了顶层设计和业务场景 |
🔌 消息队列:流量削峰的实现工具
消息队列在流量削峰中扮演着“缓冲区”或“蓄水池”的关键角色 。其工作流程如下:
- 接收请求:当突发流量到来时,所有请求首先被作为消息发送到消息队列中暂存,而非直接冲击后端业务处理服务 。
- 平滑压力:后端服务可以按照自身的最佳处理能力,以固定的、可控的速度从消息队列中获取请求并进行处理 。
- 解耦与异步:这使得前端请求的接收和后端业务的处理完全解耦。用户可能瞬间收到“请求已接受”的响应,而实际任务则在后台排队有序执行 。
一个典型的例子是秒杀系统 。在短时间内涌入的海量下单请求会被放入消息队列。队列的长度可以起到限制并发数量的作用,超出系统容量的请求可以被快速拒绝,从而保护下游的订单、库存等核心服务不被冲垮,实现削峰填谷 。
🏗️ 事件驱动:流量削峰的指导架构
事件驱动架构是一种从更高层面设计系统交互模式的思想 。当某个重要的状态变化发生时(例如用户下单、订单支付成功),系统会发布一个事件 。其他关心此变化的服务会订阅这些事件,并触发相应的后续操作 。这种“发布-订阅”模式天然就是异步的。
在流量削峰的场景下,事件驱动架构的意义在于:
- 设计上的解耦:它将“触发动作的服务”和“执行动作的服务”从时间和空间上分离开。下单服务完成核心逻辑后,只需发布一个“订单已创建”的事件,而不需要同步调用库存服务、积分服务等。这本身就为引入消息队列作为事件总线来缓冲流量奠定了基础 。
- 结果的可达性:即使某个服务(如积分服务)处理速度较慢,也不会影响核心链路(如扣减库存)。事件会在消息队列中排队,等待积分服务按自己的能力处理,从而实现了服务间的流量隔离和削峰 。
🤝 协同工作场景
消息队列与事件驱动架构协同工作的场景包括:
- 异步任务处理:用户注册后,需要发送邮件和短信。注册服务完成核心逻辑后,发布一个“用户已注册”事件到消息队列。邮件服务和短信服务作为订阅者,异步消费该事件,实现异步处理 。
- 系统应用解耦:订单系统与库存系统之间通过消息队列解耦。订单系统下单后,将消息写入消息队列即可返回成功,库存系统再根据消息队列中的信息进行库存操作,即使库存系统暂时不可用,也不会影响下单 。
- 日志处理与实时监控:使用类似Kafka的消息队列收集应用日志,后续的日志分析、监控报警等服务订阅这些日志流进行处理,解决大量日志传输问题 。
💡 选型与注意事项
在选择和运用这些技术时,需要注意:
- 技术选型:不同消息队列有不同特点。RabbitMQ 以消息可靠性见长;Apache Kafka 专为高吞吐量的实时日志流和数据管道设计,尤其适合日志处理等场景 ;RocketMQ 在阿里内部经历了大规模交易场景的考验 。
- 潜在挑战:
- 复杂性增加:需要维护消息中间件,并处理可能出现的消息重复、丢失、乱序等问题 。
- 数据一致性:异步化带来了最终一致性,需要考虑业务是否能接受 。
- 系统延迟:请求需要排队处理,用户得到最终结果的时间会变长,不适合所有场景。
来源:juejin.cn/post/7563511245087506486
Java 中的 Consumer 与 Supplier 接口
异同分析
Consumer 和 Supplier 是 Java 8 引入的两个重要函数式接口,位于 java.util.function 包中,用于支持函数式编程范式。
相同点
- 都是函数式接口(只有一个抽象方法)
- 都位于
java.util.function包中 - 都用于 Lambda 表达式和方法引用
- 都在 Stream API 和 Optional 类中广泛使用
不同点
| 特性 | Consumer | Supplier |
|---|---|---|
| 方法签名 | void accept(T t) | T get() |
| 参数 | 接受一个输入参数 | 无输入参数 |
| 返回值 | 无返回值 | 返回一个值 |
| 主要用途 | 消费数据 | 提供数据 |
| 类比 | 方法中的参数 | 方法中的返回值 |
详细分析与使用场景
Consumer 接口
Consumer 表示接受单个输入参数但不返回结果的操作。
import java.util.function.Consumer;
import java.util.Arrays;
import java.util.List;
public class ConsumerExample {
public static void main(String[] args) {
// 基本用法
Consumer<String> printConsumer = s -> System.out.println(s);
printConsumer.accept("Hello Consumer!");
// 方法引用方式
Consumer<String> methodRefConsumer = System.out::println;
methodRefConsumer.accept("Hello Method Reference!");
// 集合遍历中的应用
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
names.forEach(printConsumer);
// andThen 方法组合多个 Consumer
Consumer<String> upperCaseConsumer = s -> System.out.println(s.toUpperCase());
Consumer<String> decoratedConsumer = s -> System.out.println("*** " + s + " ***");
Consumer<String> combinedConsumer = upperCaseConsumer.andThen(decoratedConsumer);
combinedConsumer.accept("functional interface");
// 在 Optional 中的使用
java.util.Optional<String> optional = java.util.Optional.of("Present");
optional.ifPresent(combinedConsumer);
}
}
Consumer 的使用场景:
- 遍历集合元素并执行操作
- 处理数据并产生副作用(如打印、保存到数据库)
- 在 Optional 中处理可能存在的值
- 组合多个操作形成处理链
Supplier 接口
Supplier 表示一个供应商,不需要传入参数但返回一个值。
import java.util.function.Supplier;
import java.util.List;
import java.util.Random;
import java.util.stream.Stream;
public class SupplierExample {
public static void main(String[] args) {
// 基本用法
Supplier<String> stringSupplier = () -> "Hello from Supplier!";
System.out.println(stringSupplier.get());
// 方法引用方式
Supplier<Double> randomSupplier = Math::random;
System.out.println("Random number: " + randomSupplier.get());
// 对象工厂
Supplier<List<String>> listSupplier = () -> java.util.Arrays.asList("A", "B", "C");
System.out.println("List from supplier: " + listSupplier.get());
// 延迟计算/初始化
Supplier<ExpensiveObject> expensiveObjectSupplier = () -> {
System.out.println("Creating expensive object...");
return new ExpensiveObject();
};
System.out.println("Supplier created but no object yet...");
// 只有在调用 get() 时才会创建对象
ExpensiveObject obj = expensiveObjectSupplier.get();
// 在 Stream 中生成无限流
Supplier<Integer> randomIntSupplier = () -> new Random().nextInt(100);
Stream.generate(randomIntSupplier)
.limit(5)
.forEach(System.out::println);
// 在 Optional 中作为备选值
java.util.Optional<String> emptyOptional = java.util.Optional.empty();
String value = emptyOptional.orElseGet(() -> "Default from supplier");
System.out.println("Value from empty optional: " + value);
}
static class ExpensiveObject {
ExpensiveObject() {
// 模拟耗时操作
try { Thread.sleep(1000); } catch (InterruptedException e) {}
}
}
}
Supplier 的使用场景:
- 延迟初始化或延迟计算
- 提供配置或默认值
- 生成测试数据或模拟对象
- 在 Optional 中提供备选值
- 创建对象工厂
- 实现惰性求值模式
实际应用示例
下面是一个结合使用 Consumer 和 Supplier 的示例:
import java.util.function.Consumer;
import java.util.function.Supplier;
import java.util.logging.Logger;
public class CombinedExample {
private static final Logger logger = Logger.getLogger(CombinedExample.class.getName());
public static void main(String[] args) {
// 创建一个数据处理器,结合了 Supplier 和 Consumer
processData(
() -> { // Supplier - 提供数据
// 模拟从数据库或API获取数据
return new String[] {"Data1", "Data2", "Data3"};
},
data -> { // Consumer - 处理数据
for (String item : data) {
System.out.println("Processing: " + item);
}
},
error -> { // Consumer - 错误处理
logger.severe("Error occurred: " + error.getMessage());
}
);
}
public static <T> void processData(Supplier<T> dataSupplier,
Consumer<T> dataProcessor,
Consumer<Exception> errorHandler) {
try {
T data = dataSupplier.get(); // 从Supplier获取数据
dataProcessor.accept(data); // 用Consumer处理数据
} catch (Exception e) {
errorHandler.accept(e); // 用Consumer处理错误
}
}
}
总结
- Consumer 用于表示接受输入并执行操作但不返回结果的函数,常见于需要处理数据并产生副作用的场景
- Supplier 用于表示无需输入但返回结果的函数,常见于延迟计算、提供数据和工厂模式场景
- 两者都是函数式编程中的重要构建块,可以组合使用创建灵活的数据处理管道
- 在 Stream API、Optional 和现代 Java 框架中广泛应用
理解这两个接口的差异和适用场景有助于编写更简洁、更表达力的 Java 代码,特别是在使用 Stream API 和函数式编程范式时。
来源:juejin.cn/post/7548717557531623464
线程安全过期缓存:手写Guava Cache🗄️
缓存是性能优化的利器,但如何保证线程安全、支持过期、防止内存泄漏?让我们从零开始,打造一个生产级缓存!
一、开场:缓存的核心需求🎯
基础需求
- 线程安全:多线程并发读写
- 过期淘汰:自动删除过期数据
- 容量限制:防止内存溢出
- 性能优化:高并发访问
生活类比:
缓存像冰箱🧊:
- 存储食物(数据)
- 定期检查过期(过期策略)
- 空间有限(容量限制)
- 多人使用(线程安全)
二、版本1:基础线程安全缓存
public class SimpleCache<K, V> {
private final ConcurrentHashMap<K, CacheEntry<V>> cache =
new ConcurrentHashMap<>();
// 缓存项
static class CacheEntry<V> {
final V value;
final long expireTime; // 过期时间戳
CacheEntry(V value, long ttl) {
this.value = value;
this.expireTime = System.currentTimeMillis() + ttl;
}
boolean isExpired() {
return System.currentTimeMillis() > expireTime;
}
}
/**
* 存入缓存
*/
public void put(K key, V value, long ttlMillis) {
cache.put(key, new CacheEntry<>(value, ttlMillis));
}
/**
* 获取缓存
*/
public V get(K key) {
CacheEntry<V> entry = cache.get(key);
if (entry == null) {
return null;
}
// 检查是否过期
if (entry.isExpired()) {
cache.remove(key); // 惰性删除
return null;
}
return entry.value;
}
/**
* 删除缓存
*/
public void remove(K key) {
cache.remove(key);
}
/**
* 清空缓存
*/
public void clear() {
cache.clear();
}
/**
* 缓存大小
*/
public int size() {
return cache.size();
}
}
使用示例:
SimpleCache<String, User> cache = new SimpleCache<>();
// 存入缓存,5秒过期
cache.put("user:1", new User("张三"), 5000);
// 获取缓存
User user = cache.get("user:1"); // 5秒内返回User对象
Thread.sleep(6000);
User expired = cache.get("user:1"); // 返回null(已过期)
问题:
- ❌ 过期数据需要访问时才删除(惰性删除)
- ❌ 没有容量限制,可能OOM
- ❌ 没有定时清理,内存泄漏
三、版本2:支持定时清理🔧
public class CacheWithCleanup<K, V> {
private final ConcurrentHashMap<K, CacheEntry<V>> cache =
new ConcurrentHashMap<>();
private final ScheduledExecutorService cleanupExecutor;
static class CacheEntry<V> {
final V value;
final long expireTime;
CacheEntry(V value, long ttl) {
this.value = value;
this.expireTime = System.currentTimeMillis() + ttl;
}
boolean isExpired() {
return System.currentTimeMillis() > expireTime;
}
}
public CacheWithCleanup() {
this.cleanupExecutor = Executors.newSingleThreadScheduledExecutor(
new ThreadFactoryBuilder()
.setNameFormat("cache-cleanup-%d")
.setDaemon(true)
.build()
);
// 每秒清理一次过期数据
cleanupExecutor.scheduleAtFixedRate(
this::cleanup,
1, 1, TimeUnit.SECONDS
);
}
public void put(K key, V value, long ttlMillis) {
cache.put(key, new CacheEntry<>(value, ttlMillis));
}
public V get(K key) {
CacheEntry<V> entry = cache.get(key);
if (entry == null || entry.isExpired()) {
cache.remove(key);
return null;
}
return entry.value;
}
/**
* 定时清理过期数据
*/
private void cleanup() {
cache.entrySet().removeIf(entry -> entry.getValue().isExpired());
}
/**
* 关闭缓存
*/
public void shutdown() {
cleanupExecutor.shutdown();
cache.clear();
}
}
改进:
- ✅ 定时清理过期数据
- ✅ 不依赖访问触发删除
问题:
- ❌ 还是没有容量限制
- ❌ 没有LRU淘汰策略
四、版本3:完整的缓存实现(LRU+过期)⭐
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class AdvancedCache<K, V> {
// 缓存容量
private final int maxSize;
// 存储:ConcurrentHashMap + LinkedHashMap(LRU)
private final ConcurrentHashMap<K, CacheEntry<V>> cache;
// 定时清理线程
private final ScheduledExecutorService cleanupExecutor;
// 统计信息
private final AtomicInteger hitCount = new AtomicInteger(0);
private final AtomicInteger missCount = new AtomicInteger(0);
// 缓存项
static class CacheEntry<V> {
final V value;
final long expireTime;
volatile long lastAccessTime; // 最后访问时间
CacheEntry(V value, long ttl) {
this.value = value;
this.expireTime = System.currentTimeMillis() + ttl;
this.lastAccessTime = System.currentTimeMillis();
}
boolean isExpired() {
return System.currentTimeMillis() > expireTime;
}
void updateAccessTime() {
this.lastAccessTime = System.currentTimeMillis();
}
}
public AdvancedCache(int maxSize) {
this.maxSize = maxSize;
this.cache = new ConcurrentHashMap<>(maxSize);
this.cleanupExecutor = Executors.newSingleThreadScheduledExecutor(
new ThreadFactoryBuilder()
.setNameFormat("cache-cleanup-%d")
.setDaemon(true)
.build()
);
// 每秒清理过期数据
cleanupExecutor.scheduleAtFixedRate(
this::cleanup,
1, 1, TimeUnit.SECONDS
);
}
/**
* 存入缓存
*/
public void put(K key, V value, long ttlMillis) {
// 检查容量
if (cache.size() >= maxSize) {
evictLRU(); // LRU淘汰
}
cache.put(key, new CacheEntry<>(value, ttlMillis));
}
/**
* 获取缓存
*/
public V get(K key) {
CacheEntry<V> entry = cache.get(key);
if (entry == null) {
missCount.incrementAndGet();
return null;
}
// 检查过期
if (entry.isExpired()) {
cache.remove(key);
missCount.incrementAndGet();
return null;
}
// 更新访问时间
entry.updateAccessTime();
hitCount.incrementAndGet();
return entry.value;
}
/**
* 带回调的获取(类似Guava Cache)
*/
public V get(K key, Callable<V> loader, long ttlMillis) {
CacheEntry<V> entry = cache.get(key);
// 缓存命中且未过期
if (entry != null && !entry.isExpired()) {
entry.updateAccessTime();
hitCount.incrementAndGet();
return entry.value;
}
// 缓存未命中,加载数据
try {
V value = loader.call();
put(key, value, ttlMillis);
return value;
} catch (Exception e) {
throw new RuntimeException("加载数据失败", e);
}
}
/**
* LRU淘汰:移除最久未访问的
*/
private void evictLRU() {
K lruKey = null;
long oldestAccessTime = Long.MAX_VALUE;
// 找出最久未访问的key
for (Map.Entry<K, CacheEntry<V>> entry : cache.entrySet()) {
long accessTime = entry.getValue().lastAccessTime;
if (accessTime < oldestAccessTime) {
oldestAccessTime = accessTime;
lruKey = entry.getKey();
}
}
if (lruKey != null) {
cache.remove(lruKey);
}
}
/**
* 定时清理过期数据
*/
private void cleanup() {
cache.entrySet().removeIf(entry -> entry.getValue().isExpired());
}
/**
* 获取缓存命中率
*/
public double getHitRate() {
int total = hitCount.get() + missCount.get();
return total == 0 ? 0 : (double) hitCount.get() / total;
}
/**
* 获取统计信息
*/
public String getStats() {
return String.format(
"缓存统计: 大小=%d, 命中=%d, 未命中=%d, 命中率=%.2f%%",
cache.size(),
hitCount.get(),
missCount.get(),
getHitRate() * 100
);
}
/**
* 关闭缓存
*/
public void shutdown() {
cleanupExecutor.shutdown();
cache.clear();
}
}
五、完整使用示例📝
public class CacheExample {
public static void main(String[] args) throws InterruptedException {
// 创建缓存:最大100个,5秒过期
AdvancedCache<String, User> cache = new AdvancedCache<>(100);
// 1. 基本使用
cache.put("user:1", new User("张三", 20), 5000);
User user = cache.get("user:1");
System.out.println("获取缓存: " + user);
// 2. 带回调的获取(自动加载)
User user2 = cache.get("user:2", () -> {
// 模拟从数据库加载
System.out.println("从数据库加载 user:2");
return new User("李四", 25);
}, 5000);
System.out.println("加载数据: " + user2);
// 3. 再次获取(命中缓存)
User cached = cache.get("user:2");
System.out.println("命中缓存: " + cached);
// 4. 等待过期
Thread.sleep(6000);
User expired = cache.get("user:1");
System.out.println("过期数据: " + expired); // null
// 5. 查看统计
System.out.println(cache.getStats());
// 6. 关闭缓存
cache.shutdown();
}
}
输出:
获取缓存: User{name='张三', age=20}
从数据库加载 user:2
加载数据: User{name='李四', age=25}
命中缓存: User{name='李四', age=25}
过期数据: null
缓存统计: 大小=1, 命中=2, 未命中=1, 命中率=66.67%
六、实战:用户Session缓存🔐
public class SessionCache {
private final AdvancedCache<String, UserSession> cache;
public SessionCache() {
this.cache = new AdvancedCache<>(10000); // 最大1万个session
}
/**
* 创建Session
*/
public String createSession(Long userId) {
String sessionId = UUID.randomUUID().toString();
UserSession session = new UserSession(userId, LocalDateTime.now());
// 30分钟过期
cache.put(sessionId, session, 30 * 60 * 1000);
return sessionId;
}
/**
* 获取Session
*/
public UserSession getSession(String sessionId) {
return cache.get(sessionId);
}
/**
* 刷新Session(延长过期时间)
*/
public void refreshSession(String sessionId) {
UserSession session = cache.get(sessionId);
if (session != null) {
// 重新设置30分钟过期
cache.put(sessionId, session, 30 * 60 * 1000);
}
}
/**
* 删除Session(登出)
*/
public void removeSession(String sessionId) {
cache.remove(sessionId);
}
static class UserSession {
final Long userId;
final LocalDateTime createTime;
UserSession(Long userId, LocalDateTime createTime) {
this.userId = userId;
this.createTime = createTime;
}
}
}
七、与Guava Cache对比📊
Guava Cache的使用
LoadingCache<String, User> cache = CacheBuilder.newBuilder()
.maximumSize(1000) // 最大容量
.expireAfterWrite(5, TimeUnit.MINUTES) // 写入后过期
.expireAfterAccess(10, TimeUnit.MINUTES) // 访问后过期
.recordStats() // 记录统计
.build(new CacheLoader<String, User>() {
@Override
public User load(String key) throws Exception {
return loadUserFromDB(key); // 加载数据
}
});
// 使用
User user = cache.get("user:1"); // 自动加载
功能对比
| 功能 | 自定义Cache | Guava Cache |
|---|---|---|
| 线程安全 | ✅ | ✅ |
| 过期时间 | ✅ | ✅ |
| LRU淘汰 | ✅ | ✅ |
| 自动加载 | ✅ | ✅ |
| 弱引用 | ❌ | ✅ |
| 统计信息 | ✅ | ✅ |
| 监听器 | ❌ | ✅ |
| 刷新 | ❌ | ✅ |
建议:
- 简单场景:自定义实现
- 生产环境:用Guava Cache或Caffeine
八、性能优化技巧⚡
技巧1:分段锁
public class SegmentedCache<K, V> {
private final int segments = 16;
private final AdvancedCache<K, V>[] caches;
@SuppressWarnings("unchecked")
public SegmentedCache(int totalSize) {
this.caches = new AdvancedCache[segments];
int sizePerSegment = totalSize / segments;
for (int i = 0; i < segments; i++) {
caches[i] = new AdvancedCache<>(sizePerSegment);
}
}
private AdvancedCache<K, V> getCache(K key) {
int hash = key.hashCode();
int index = (hash & Integer.MAX_VALUE) % segments;
return caches[index];
}
public void put(K key, V value, long ttl) {
getCache(key).put(key, value, ttl);
}
public V get(K key) {
return getCache(key).get(key);
}
}
技巧2:异步加载
public class AsyncCache<K, V> {
private final AdvancedCache<K, CompletableFuture<V>> cache;
private final ExecutorService loadExecutor;
public CompletableFuture<V> get(K key, Callable<V> loader, long ttl) {
return cache.get(key, () ->
CompletableFuture.supplyAsync(() -> {
try {
return loader.call();
} catch (Exception e) {
throw new CompletionException(e);
}
}, loadExecutor),
ttl
);
}
}
九、常见陷阱⚠️
陷阱1:缓存穿透
// ❌ 错误:不存在的key反复查询数据库
public User getUser(String userId) {
User user = cache.get(userId);
if (user == null) {
user = loadFromDB(userId); // 每次都查数据库
if (user != null) {
cache.put(userId, user, 5000);
}
}
return user;
}
// ✅ 正确:缓存空对象
public User getUser(String userId) {
User user = cache.get(userId);
if (user == null) {
user = loadFromDB(userId);
// 即使是null也缓存,但设置短过期时间
cache.put(userId, user != null ? user : NULL_USER, 1000);
}
return user == NULL_USER ? null : user;
}
陷阱2:缓存雪崩
// ❌ 错误:所有key同时过期
for (String key : keys) {
cache.put(key, value, 5000); // 5秒后同时过期
}
// ✅ 正确:过期时间随机化
for (String key : keys) {
long ttl = 5000 + ThreadLocalRandom.current().nextInt(1000);
cache.put(key, value, ttl); // 5-6秒随机过期
}
十、面试高频问答💯
Q1: 如何保证缓存的线程安全?
A:
- 使用
ConcurrentHashMap - volatile保证可见性
- CAS操作保证原子性
Q2: 如何实现过期淘汰?
A:
- 惰性删除:访问时检查过期
- 定时删除:定时任务扫描
- 两者结合
Q3: 如何实现LRU?
A:
- 记录访问时间
- 容量满时淘汰最久未访问的
Q4: 缓存穿透/击穿/雪崩的区别?
A:
- 穿透:查询不存在的key,缓存和DB都没有
- 击穿:热点key过期,大量请求打到DB
- 雪崩:大量key同时过期
十一、总结🎯
核心要点
- 线程安全:ConcurrentHashMap
- 过期策略:定时清理+惰性删除
- 容量限制:LRU淘汰
- 性能优化:分段锁、异步加载
- 监控统计:命中率、容量
生产建议
- 简单场景:自己实现
- 复杂场景:用Guava Cache
- 极致性能:用Caffeine
下期预告: 为什么双重检查锁定(DCL)是错误的?指令重排序的陷阱!🔐
来源:juejin.cn/post/7563511077180473386
Lambda 底层原理全解析
是否好奇过,这样一行代码,编译器背后做了什么?
auto lambda = [](int x) { return x * 2; };
本文将带你深入 Lambda 的底层
一、Lambda回顾
auto lambda = [](int x) { return x + 1; };
int result = lambda(5);
lambda我们很熟悉,是一个对象。
完整语法:[捕获列表] (参数列表) mutable 异常说明->返回类型{函数体}
基本的用法就不说,说几个用的时候注意的点
- & 捕获要注意悬垂引用,不要让捕获的引用,被销毁了还在使用
- this指针捕获,引起的悬垂指针
class MyClass {
int value = 42;
public:
auto getLambda() {
return [this]() { return value; }; //捕获 this 指针
}
};
MyClass* obj = new MyClass();
auto lambda = obj->getLambda();
delete obj;
lambda(); //this 指针悬垂
C++17解决:*this捕获,直接拷贝整个对象
return [*this]() { return value; }; // 拷贝整个对象
3.每个lambda都是唯一的
auto l1 = []() { return 1; };
auto l2 = []() { return 1; };
// l1 和 l2 类型不同!
// typeid(l1) != typeid(l2)
4.转换为函数指针
// 不捕获变量→可以转换
auto l1 = [](int x) { return x + 1; };
int (*fp)(int) = l1;//正确
// 捕获变量→不能转换
int a = 10;
auto l2 = [a](int x) { return a + x; };
int (*fp2)(int) = l2; //编译错误
记住这句话:函数指针=纯粹的代码地址,你一旦有成员变量,operator()就会依赖对象状态(a),无法转换为函数指针,函数指针调用时,不知道a的值从哪里来。
简单来说:lambda本质是对象+代码,而函数指针只能表示纯代码
解决方式:function(可以直接存储Lambda对象)
5.混淆了[=] 和 [&]
class MyClass {
int value = 100;
public:
void test() {
auto lambda = [=]() { //看起来按值捕获
std::cout << value << std::endl;
};
//等价于 [this],捕获的是this指针
//等价于this->value
}
};
6.lambda递归
auto factorial = [](int n) { //无法递归调用自己
return n <= 1 ? 1 : n * factorial(n - 1); // 错误:factorial 未定义
};
//正确做法:C++23显式对象参数
auto factorial = [](this auto self, int n) { // C++23
return n <= 1 ? 1 : n * self(n - 1);
};
7.移动捕获
void process(std::unique_ptr<int>&& ptr) {
auto lambda = [p = std::move(ptr)]() { //移动到 Lambda
std::cout << *p << std::endl;
};
//错误做法
//auto lambda = [&ptr]() { //捕获的是引用
//std::cout << *ptr << std::endl;
//可能导致ptr移动后lambda失效.
lambda();
}
二、Lambda 的本质
Lambda不是普通的函数,也不是普通的对象,它是一个重载了operator()的类对象。
现在来证明一下:代码如下
#include <iostream>
int main() {
auto lambda = [](int x) { return x * 2; };
int result = lambda(5);
std::cout << result << std::endl;
return 0;
}
gdb证明:

观察到lambda是一个结构体,且大小为1字节
引申出几个问题
- 为什么这里是一个空的结构体?
- 为什么大小为1字节?
- 还没有证明他是一个重载了operator()的对象
问题1:为什么这里是一个空的结构体?
我们来按值捕获参数试试:
int main() {
int y=2;
auto lambda = [=](int x) { return x * 2+y * 3; };
int result = lambda(5);
std::cout << result << std::endl;
return 0;
}
gdb:

哦,原来捕获对象会存在这个结构体中,同时我们发现大小为4字节,就为数据的大小。
那我们捕获引用试试呢?

同样也是引用数据类型,但是由于引用底层是存着对象的地址,所以它的大小为8字节,是一个指针的大小。
回到上面,为什么我们一开始的结构体什么数据都没有还是为1字节呢,C++规定了空类大小不为0,最小为1字节(保证每个对象都有唯一的地址)
总结:引用或按值捕获的数据被存在lambda对象内部
问题2:证明他是一个重载了operator()的对象
(1)gdb继续调试:

可以看到,确实是调用了一个operator()
(2)我们在用C++ Insights验证一下
访问:cppinsights.io/
可以查看编译器实际生成的完整类定义

关注到operator()后面是一个const,说明不可以修改捕获的变量,mutable加上后const消失,可自行验证
来源:juejin.cn/post/7564694382999994406
从“写循环”到“写思想”:Java Stream 流的高级实战与底层原理剖析
引言
在实际开发中,很多工程师依然停留在“用 for 循环遍历集合”的思维模式。但在大型项目、复杂业务中,这种写法往往显得冗余、难以扩展,也不符合函数式编程的趋势。
Stream API 的出现,不只是“简化集合遍历”,而是把 声明式编程思想 带入了 Java,使我们能以一种更优雅、更高效、更可扩展的方式处理集合与数据流。
如果你还把 Stream 仅仅理解为 list.stream().map(...).collect(...),那就大错特错了。本文将从 高级用法、底层原理、业务实践、性能优化 四个维度,带你重新认识 Stream —— 让它真正成为你架构设计和代码表达的利器。
一、为什么要用 Stream?
在真实业务场景中,Stream 的价值不仅仅体现在“更少的代码量”,而在于:
- 声明式语义 —— 写“我要做什么”,而不是“怎么做”。
// 传统方式
List<String> result = new ArrayList<>();
for (User u : users) {
if (u.getAge() > 18) {
result.add(u.getName());
}
}
// Stream 写法:表达意图更清晰
List<String> result = users.stream()
.filter(u -> u.getAge() > 18)
.map(User::getName)
.toList();
后者的代码阅读体验更接近“业务规则”,而非“算法步骤”。
- 可扩展性 —— 同样的链式调用,可以无缝切换到 并行流(parallelStream)以提升性能,而无需修改核心逻辑。
- 契合函数式编程趋势 —— 在 Java 8 引入 Lambda 后,Stream 彻底释放了函数式编程的潜力。
二、Stream 的核心思想
Stream API 的设计核心可以用一句话概括:
把数据操作抽象成流水线,每一步都是一个中间操作,最终由终止操作触发执行。
- 数据源(Source) :集合、数组、I/O、生成器等。
- 中间操作(Intermediate Operations) :
filter、map、flatMap、distinct、sorted…,返回一个新的 Stream(惰性求值)。 - 终止操作(Terminal Operations) :
collect、forEach、reduce、count…,触发实际计算。
关键点:Stream 是惰性的。中间操作不会立即执行,直到遇到终止操作才会真正运行。
三、高级用法与最佳实践
1. 多级分组与统计
真实业务中,常见的场景是“按条件分组统计”。
// 按部门分组,并统计每个部门的人数
Map<String, Long> groupByDept = employees.stream()
.collect(Collectors.groupingBy(Employee::getDepartment, Collectors.counting()));
// 多级分组:按部门 -> 按职位
Map<String, Map<String, List<Employee>>> group = employees.stream()
.collect(Collectors.groupingBy(Employee::getDepartment,
Collectors.groupingBy(Employee::getTitle)));
2. flatMap 的威力
flatMap 可以把多层集合打平成单层流。
// 一个学生对应多个课程,如何获取所有课程的去重列表?
List<String> courses = students.stream()
.map(Student::getCourses) // Stream<List<String>>
.flatMap(List::stream) // Stream<String>
.distinct()
.toList();
3. reduce 高阶聚合
Stream 的 reduce 方法提供了更灵活的聚合方式。
// 求所有订单的总金额
BigDecimal total = orders.stream()
.map(Order::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
相比 Collectors.summingInt 等方法,reduce 更加灵活,适合需要自定义聚合逻辑的场景。
4. 结合 Optional 优雅处理空值
Stream 与 Optional 配合,可以消除 if-null 的丑陋写法。
// 找到第一个满足条件的用户
Optional<User> user = users.stream()
.filter(u -> u.getAge() > 30)
.findFirst();
与传统的 null 判断相比,这种写法更安全、更符合函数式语义。
5. 并行流与 ForkJoinPool
只需一行代码,就能让 Stream 自动并行处理
long count = bigList.parallelStream()
.filter(item -> isValid(item))
.count();
注意点:
- 并行流基于 ForkJoinPool,默认线程数 = CPU 核心数。
- 不适合小数据量,启动线程开销可能大于收益。
- 不适合有共享资源的场景(容易产生锁竞争)。
四、Stream 的底层原理
理解底层机制,才能在性能和架构上做出正确决策。
- 流水线模型(Pipeline Model)
- 每个中间操作都返回一个
Stream,但实际上内部是一个Pipeline。 - 只有终止操作才会触发数据逐步流经整个 pipeline。
- 每个中间操作都返回一个
- 内部迭代(Internal Iteration)
- 相比外部迭代(for 循环),Stream 将迭代逻辑交给框架本身,从而更容易做优化(如并行)。
- 短路操作(Short-circuiting)
anyMatch、findFirst等操作可以在满足条件时立刻返回,避免不必要的计算。
- 内存与性能
- 惰性求值减少不必要的计算。
- 但过度链式调用可能带来额外开销(对象创建、函数调用栈)。
五、业务场景中的最佳实践
1. 日志分析系统
日志按时间、级别分组统计:
Map<LogLevel, Long> logCount = logs.stream()
.filter(log -> log.getTimestamp().isAfter(start))
.collect(Collectors.groupingBy(Log::getLevel, Collectors.counting()));
2. 电商系统订单处理
对订单进行聚合,计算 GMV(成交总额):
BigDecimal gmv = orders.stream()
.filter(o -> o.getStatus() == OrderStatus.FINISHED)
.map(Order::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
3. 权限系统多对多关系处理
用户-角色-权限的映射去重:
Set<String> permissions = users.stream()
.map(User::getRoles)
.flatMap(List::stream)
.map(Role::getPermissions)
.flatMap(List::stream)
.collect(Collectors.toSet());
六、性能优化与陷阱
- 避免在 Stream 中修改外部变量
List<String> result = new ArrayList<>();
list.stream().forEach(e -> result.add(e)); //违反函数式编程
应该用 collect。
- 适度使用并行流
- 小集合别用并行流。
- 线程池可通过
ForkJoinPool.commonPool()自定义。
- 避免链式调用过长
虽然优雅,但可读性会下降,必要时拆分。 - Stream 不是万能的
- 对于简单循环,普通 for 循环更直观。
- 对性能敏感的底层操作(如数组拷贝),直接用原生循环更高效。
总结
Stream 并不是一个“语法糖”,而是 Java 向函数式编程迈进的重要里程碑。
它让我们能以声明式、可扩展、可并行的方式处理数据流,提升代码表达力和业务抽象能力。
对于中高级开发工程师来说,Stream 的价值在于:
- 提升业务逻辑的可读性和可维护性
- 利用底层并行能力提升性能
- 契合函数式思维,帮助团队写出更现代化的 Java 代码
未来的你,写业务逻辑时,应该少考虑“怎么遍历”,多去思考“我要表达的业务规则是什么”。
来源:juejin.cn/post/7538829865351036967















