流程引擎、工作流、规则引擎、编排系统、表达式引擎……天呐,我到底该用哪个?
你是不是也有这些困惑
看项目文档,各种名词扑面而来:
- 流程引擎(Flowable、Camunda)
- 工作流(Activiti)
- 规则引擎(Drools)
- 编排系统(LiteFlow)
- 表达式引擎(QLExpress、Aviator)
- DAG调度(Airflow、DolphinScheduler)
- 任务编排(Temporal、Conductor)
- BPMN、Saga、Event-Driven...
每个框架都说自己能解决问题,每个概念看起来都差不多。
新手一脸懵逼,老手也经常搞混。
干了20年,我也被这些东西搞晕过。今天不讲那些虚的,直接告诉你怎么选。
答案很简单:别管这些名词,问自己四个问题就够了。
忘掉那些名词,只问四个问题
看了一堆框架介绍还是不知道选哪个?正常,因为你在纠结概念。
别纠结了,概念都是虚的。问自己四个问题,立刻就清楚了。
问题1:你是要干活,还是改状态?
这是最关键的一个问题。搞清楚这个,一大半框架就排除了。
改状态是什么意思?
请假审批:
员工提交 → 主管看了说"行" → HR看了说"行" → 完成
整个过程:
- 没有计算
- 没有数据转换
- 没有调用外部系统
- 就是状态从 pending 变成 approved
这就是纯改状态。
干活是什么意思?
订单处理:
下单 → 扣库存 → 调支付接口 → 调物流接口 → 发货
整个过程:
- 要计算金额
- 要调用外部API
- 要处理数据
- 要执行业务逻辑
这就是干活。
判断标准:
- 改状态:就是让人点"同意"或"拒绝",除了改个字段,啥也没干
- 干活:要计算、要调API、要处理数据
对应框架:
- 改状态 → BPMN系(Flowable、Camunda)
- 干活 → 继续往下判断
问题2:主要是人处理,还是机器执行?
人处理:
审批流程:
- 主管要看文档
- 主管要做判断
- 主管要点按钮
- 然后等下一个人
特点:大部分时间在等人
机器执行:
数据处理:
- 读数据库
- 清洗数据
- 转换格式
- 写入目标表
特点:机器自己跑,不用人管
对应框架:
- 人为主 → BPMN系(Flowable、Camunda)
- 机器为主 → 继续往下判断
问题3:是本地方法,还是跨系统调用?
本地方法:
营销规则:
- 判断用户是不是VIP
- 计算折扣
- 返回结果
都在一个应用里,不用调外部接口
跨系统调用:
订单流程:
- 调库存系统(HTTP)
- 调支付系统(HTTP)
- 调物流系统(HTTP)
要跨多个服务
对应框架:
- 本地 → 表达式系、脚本系(QLExpress、LiteFlow)
- 跨系统 → DAG系、服务编排系(Airflow、Temporal)
问题4:自己玩,还是要搞生态?
自己玩:
你的团队自己维护:
- 规则你们自己写
- 代码你们自己改
- 不需要外部开发者
搞生态:
做平台,让别人扩展:
- 客户可以上传插件
- 第三方可以写脚本
- 需要沙箱隔离
对应技术:
- 自己玩 → 表达式 + 代码(QLExpress、Aviator)
- 搞生态 → Groovy脚本、插件机制
那些让人头疼的框架,到底是干什么的
四个问题问完,你大概知道方向了。现在看看具体框架都是什么情况。
不用全看,只看和你匹配的那一类就行。
BPMN系:Flowable、Camunda、Activiti
适合场景:
- 纯人工审批流程
- 需要流程图可视化
- 需要历史记录追溯
- 大公司、强合规要求
典型例子:
- 请假审批
- 报销审批
- 合同审批
- 采购流程
核心特点:
- 本质就是改状态
- 大部分时间在等人
- 业务价值为0(只是流程管理)
- 技术难度不高(就是状态机)
什么时候用:
- 大公司(100+人),有几十个审批流程要管理
- 金融、政府等强合规行业
- 需要标准化流程管理
什么时候别用:
- 小公司(别用,钉钉审批就够了)
- 没有复杂审批需求(自己写100行代码搞定)
- 为了"企业级"而用(过度设计)
DAG系:Airflow、DolphinScheduler、Prefect
适合场景:
- 数据处理任务
- 离线批处理
- 定时调度
- 任务有依赖关系
典型例子:
- 数据ETL
- 报表生成
- 数据清洗
- 机器学习Pipeline
核心特点:
- 纯机器执行
- 长时间运行(小时、天级)
- 任务之间有依赖(A完成才能B)
- 需要调度和监控
什么时候用:
- 数据团队做离线处理
- 有复杂的任务依赖关系
- 需要定时调度(每天、每周)
什么时候别用:
- 实时性要求高的(秒级响应)
- 简单的定时任务(用Cron就够了)
- 没有依赖关系的任务
表达式/脚本系:QLExpress、Aviator、LiteFlow、Groovy
适合场景:
- 规则计算
- 业务流程编排
- 本地方法调用
- 需要动态配置
典型例子:
- 营销活动规则(满减、折扣)
- 风控规则(黑名单、评分)
- 订单流程(本地编排)
- 积分计算
QLExpress / Aviator(表达式):
- 优点:性能好、类Java语法、团队容易上手
- 缺点:功能受限、只能简单计算
- 适合:自己团队玩、简单规则
Groovy(脚本):
- 优点:功能完整、可以调复杂API
- 缺点:性能差、调试难、类型不安全
- 适合:要搞插件生态、客户自定义逻辑
LiteFlow(编排):
- 优点:可视化编排、组件复用
- 缺点:学习成本、维护成本
- 适合:流程确实复杂、经常变化
什么时候用:
- 规则经常变(不想每次改代码发版)
- 流程需要配置化
- 有一定复杂度(10+个分支)
什么时候别用:
- 简单的if-else(直接写代码)
- 流程固定不变(没必要配置化)
- 为了"灵活"而牺牲性能
服务编排系:Temporal、Cadence、Conductor
适合场景:
- 微服务编排
- 分布式事务
- 长时间运行的业务流程
- 需要补偿机制
典型例子:
- 订单流程(支付 → 发货 → 签收)
- 旅游预订(机票 + 酒店 + 门票)
- 跨系统流程
- Saga模式
核心特点:
- 支持长时间运行(天级)
- 支持失败重试
- 支持补偿逻辑
- 状态持久化
什么时候用:
- 微服务架构,需要编排多个服务
- 需要分布式事务
- 流程可能运行很久(几小时、几天)
什么时候别用:
- 单体应用(没有跨服务需求)
- 简单的API调用(直接用HTTP就行)
- 实时性要求极高的(毫秒级)
懒得看?直接照这个选
如果你嫌上面内容太多,直接看这个决策树。
跟着问题一步步走,到底了就知道该用什么。
开始
↓
主要是人审批吗?
↓ 是
用 Flowable/Camunda(大公司)或钉钉审批(小公司)
↓ 否
是长时间运行的任务吗(>10分钟)?
↓ 是
用 Airflow/DolphinScheduler
↓ 否
需要跨系统调用吗?
↓ 是
用 Temporal/Conductor(微服务)或 Airflow(数据处理)
↓ 否
逻辑很复杂吗(>10个分支)?
↓ 是
用 LiteFlow(编排)或 QLExpress(规则)
↓ 否
需要频繁修改规则吗?
↓ 是
用 QLExpress/Aviator
↓ 否
直接写代码!
具体场景怎么选
理论说完了,看几个实际例子。看看你的场景和哪个像。
场景1:请假审批
特征:
- 纯人工审批
- 状态流转
- 需要历史记录
选型:
- 小公司:钉钉/企业微信审批
- 大公司:Flowable/Camunda
- 自己开发:状态机 + 数据库
场景2:电商订单流程
特征:
- 要调支付、库存、物流接口
- 有失败重试和补偿
- 短事务(分钟级)
选型:
- 复杂场景:Temporal/Cadence
- 简单场景:LiteFlow + 消息队列
- 最简单:直接写代码 + 状态机
场景3:数据ETL
特征:
- 纯机器执行
- 长时间运行
- 任务有依赖
选型:
- 标准方案:Airflow/DolphinScheduler
- 简单场景:XXL-Job
场景4:营销活动规则
特征:
- 规则计算
- 经常变化
- 本地方法
选型:
- 简单规则:QLExpress/Aviator
- 复杂规则:Drools
- 有编排需求:LiteFlow
很多人踩过的坑
说几个常见的错误,别重复踩坑。
误区1:追求"企业级架构"
错误做法:
20人的创业公司,上了Flowable、Camunda、Airflow一整套
正确做法:
能用100行代码解决就别上框架
误区2:为了灵活性而牺牲性能
错误做法:
所有逻辑都用Groovy脚本,方便修改
正确做法:
核心逻辑用Java写,只把经常变的部分配置化
误区3:过度抽象
错误做法:
3个简单流程,非要搞个"流程引擎"
正确做法:
3个流程就3个方法,直接写代码
误区4:混淆概念
错误理解:
"我需要流程编排,所以要用Flowable"
正确理解:
先搞清楚你要干活还是改状态
是人审批还是机器执行
几句大实话
最后说几句掏心窝的话。
1. 先用最简单的方案
遇到问题:
第一反应不是"上框架"
而是"能不能写100行代码搞定"
90%的情况,100行代码就够了
2. 遇到瓶颈再优化
流程很乱了 → 重构代码
改动很频繁 → 考虑配置化
管理不过来 → 考虑框架
别提前优化
3. 根据团队规模选择
小团队(<20人):
- 能不用框架就不用
- 钉钉审批、Cron、直接写代码
中等团队(20-100人):
- 流程<10个:自己写
- 流程>10个:考虑轻量级框架
大团队(>100人):
- 需要标准化管理
- 可以考虑成熟框架
4. 看业务特点
强合规(金融、政府):
- 必须用标准化工具
- Flowable是选择之一
数据密集:
- Airflow是标准方案
微服务架构:
- Temporal值得考虑
简单CRUD:
- 别折腾,写代码
说到底,就这么点事
看完还觉得复杂?那就记住这四个问题:
- 干活还是改状态?
- 人为主还是机器为主?
- 本地方法还是跨系统?
- 自己玩还是搞生态?
四个问题问完,基本就知道该用什么了。
那些"企业级"、"先进架构"、"灵活扩展"的词,都是包装。
看透本质,别被忽悠。
能用100行代码解决的,就别上框架。
技术是为业务服务的,不是为了炫技。
务实点,别整那些虚的。
就这样。
来源:juejin.cn/post/7587299670642606086
WebSocket 不是唯一选择:SSE 打造轻量级实时推送系统 🚀🚀🚀
面试导航 是一个专注于前、后端技术学习和面试准备的 免费 学习平台,提供系统化的技术栈学习,深入讲解每个知识点的核心原理,帮助开发者构建全面的技术体系。平台还收录了大量真实的校招与社招面经,帮助你快速掌握面试技巧,提升求职竞争力。如果你想加入我们的交流群,欢迎通过微信联系:
yunmz777。

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

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

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

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

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

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

打开图表识别功能:

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

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

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

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

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


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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

资源扩容泳道图

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

✨ 可视化 Stash 管理器

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

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

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

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


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

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

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

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

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

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

美团6景区联票:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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



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

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

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



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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

单工通信代表了最单一、最直接的数据流向。
- 定义:数据只能在一个方向上传输,通信的一方固定为发送端,另一方则固定为接收端。
- 核心特征:方向不可改变。就像一条单行道,数据流只有一个固定的方向。
- 经典比喻:
- 广播电台:电台负责发送信号,广大听众的收音机只能接收信号,无法通过收音机向电台发送数据。
- 电视信号传输:电视台到家庭电视的信号传输。
- 键盘到计算机(在传统概念中):数据从键盘单向传入计算机。
单工通信模式简单、成本低,但交互性为零,无法实现双向信息交流。
二、半双工通信

半双工通信允许了双向交流,但增加了“轮流”的规则。
- 定义:数据可以在两个方向上传输,但在任一时刻,只能有一个方向在进行传输。它需要一种“切换”机制来改变数据传输的方向。
- 核心特征:双向交替,不能同时。
- 经典比喻:
- 对讲机:一方需要按下“通话键”说话,说完后必须说“完毕”并松开按键,切换到接收状态,才能听到对方的回复。双方不能同时讲话。
- 独木桥:同一时间只能允许一个人从一个方向通过。
半双工的局限性:
由于其交替通信的本质,半双工存在几个固有缺陷:
- 效率较低:存在信道空闲和状态切换的时间开销,总吞吐量低。
- 延迟较高:发送方必须等待信道空闲才能发送,接收方必须等待发送方完毕才能接收。
- 可能发生碰撞:在共享信道中,若多个设备同时开始发送,会导致数据冲突,必须重传,进一步降低效率。
- 需要冲突管理:必须引入如CSMA/CD(载波侦听多路访问/冲突检测)等协议来管理信道访问,增加了系统复杂度。
三、全双工通信

全双工通信实现了最自然、最高效的双向交互。
- 定义:数据可以在两个方向上同时进行传输。
- 核心特征:同时双向传输。
- 经典比喻:
- 电话通话:双方可以同时说话和聆听,交流过程自然流畅,无需等待。
- 双向多车道公路:两个方向的车流拥有各自独立的车道,可以同时、高速、互不干扰地行驶。
技术实现:全双工通常需要两条独立的物理信道(如网线中的两对线),或通过频分复用等技术在一条信道上逻辑地划分出上行和下行通道。其最大优势在于彻底避免了半双工中固有的碰撞问题。
三种模式对比总结
| 特性维度 | 单工 | 半双工 | 全双工 |
|---|---|---|---|
| 数据流向 | 仅单向 | 双向,但交替进行 | 双向,同时进行 |
| 经典比喻 | 广播 | 对讲机 | 电话 |
| 信道占用 | 一条单向信道 | 一条共享信道 | 两条独立信道或等效技术 |
| 效率 | 低(无交互) | 较低 | 高 |
| 交互性 | 无 | 有,但不流畅 | 有,且自然流畅 |
| 数据碰撞 | 无 | 可能发生 | 不可能发生 |
| 典型应用 | 广播、电视 | 早期以太网、对讲机 | 现代以太网、电话、视频会议 |
结论
从单工的“只读”模式,到半双工的“轮流对话”,再到全双工的“自由交谈”,通信模式的演进体现了人们对更高效率和更自然交互的不懈追求。全双工凭借其高吞吐量、低延迟和无碰撞的特性,已成为当今主流有线与无线网络(如交换式以太网、4G/5G移动通信)的标配。而半双工和单工则在物联网、传感器网络、广播等特定应用场景中,因其成本或功能需求,依然保有一席之地。理解这三种基础模式,是步入更复杂通信世界的第一步。
来源:juejin.cn/post/7563108340538507318
大数据-133 ClickHouse 概念与基础|为什么快?列式 + 向量化 + MergeTree 对比
TL;DR
场景:要做高并发低延迟 OLAP,且不想上整套 Hadoop/湖仓。
结论:ClickHouse 的核心在列式+向量化+MergeTree+近似统计;适合即席分析与近实时写入,不适合强事务与高频行级更新。
产出:选型决策表 + 分区/排序键速查卡 + 5 条查询模板(安装/集群放到下一章)。


简要概述
ClickHouse 是一个快速开源的OLAP数据库管理系统,它是面向列的,允许使用SQL查询实时生成分析报告。
随着物联网IOT时代的来临,IOT设备感知和报警存储数据越来越大,有用的价值数据需要数据分析师去分析。大数据分析成了非常重要的环节,开源也为大数据分析工程师提供了十分丰富的工具,但这也增加了开发者选择适合的工具的难度,尤其是新入行的开发者来说。
框架的多样化和复杂度成了很大的难题,例如:Kafka、HDFS、Spark、Hive等等组合才能产生最后的分析结果,把各种开源框架、工具、库、平台人工整合到一起所需的工作之复杂,是大数据领域开发和数据分析师常有的抱怨之一,也就是他们支持大数据分析简化和统一化的首要原因。

从业务维度来分析,用户需求会反向促使技术发展。
简要选型
| 需求/约束 | 适合 | 不适合 |
|---|---|---|
| 高并发、低延迟 OLAP | ✅ | |
| 重事务/强一致 OLTP | ❌ | |
| 近实时写入、即席分析 | ✅ | |
| 频繁行级更新/删除 | ⚠️(有 mutations 但代价高) |
- 需要强事务/OLTP → 不是 CH
- OLAP + 近实时 + 自建机房成本敏感 → CH 优先
- 只做离线、成本不敏感且现有湖仓成熟 → SparkSQL/Trino 也行
- 预计算立方体 + 报表固化 → Druid/Kylin 也可
OLTP
OLTP:Online Transaction Processing:联机事务处理过程。
应用场景
- ERP:Enterprise Resource Planning 企业资源计划
- CRM:Customer Relationship Management 客户关系管理
流程审批、数据录入、填报等
具体特点
线下工作线上化,数据保存在各自的系统中,互不相同(数据孤岛)
OLAP
OLAP:On-Line Analytical Processing:联机分析系统
分析报表、分析决策等。
应用场景
方案1:数仓

如上图所示,数据实时写入HBase,实时的数据更新也在HBase完成,为了应对OLAP需求,我们定时(通常是T+1或者T+H)将HBase数据写成静态的文件(如:Parquet)导入到 OLAP引擎(如HDFS,比较常见的是Impala操作Hive)。这一架构又能满足随机读写,又可以支持OLAP分析的场景,但是有如下缺点:
- 架构复杂:从架构上看,数据在HBase、消息队列、HDFS间流转,涉及到的环节过多,运维成本也很高,并且每个环节需要保证高可用,都需要维护多个副本,存储空间也有一定的浪费。最后数据在多个系统上,对数据安全策略、监控都提出了挑战。
- 时效性低:数据从HBase导出静态文件是周期性的,一般这个周期一天(或者一小时),有时效性上不是很高。
- 难以应对后续的更新:真实场景中,总会有数据是延迟到达的,如果这些数据之前已经从HBase导出到HDFS,新到的变更数据更难以处理了,一个方案是把原有数据应用上新的变更后重写一遍,但这代价又很高。
方案2:ClickHouse、Kudu
实现方案2就是 ClickHouse、Kudu
发展历史
Yandex在2016年6月15日开源了一个数据分析数据库,叫做ClickHouse,这对保守的俄罗斯人来说是个特大事件。更让人惊讶的是,这个列式数据库的跑分要超过很多流行的商业MPP数据库软件,例如Vertica。如果你没有Vertica,那你一定听过Michael Stonebraker,2014年图灵奖的获得者,PostgreSQL和Ingres发明者(Sybase和SQL Server都是继承Ingres而来的),Paradigm4和SciDB的创办者。Micheal StoneBraker于2005年创办的Vertica公司,后来该公司被HP收购,HP Vertica成为MPP列式存储商业数据库的高性能代表,Facebook就购买了Vertica数据用于用户行为分析。
ClickHouse技术演变之路
Yandex公司在2011年上市,它的核心产品是搜索引擎。
我们知道,做搜索引擎的公司营收非常依赖流量和在线广告,所以做搜索引擎公司一般会并行推出在线流量分析产品,比如说百度的百度统计,Google的Google Analytics等,Yandex的Yandex.Metricah。ClickHouse就是在这种背景下诞生的。
- ROLAP:传统关系型数据库OLAP,基于MySQL的MyISAM表引擎
- MOLAP:借助物化视图的形式实现数据立方体,预处理的结果存在HBase这类高性能的分布式数据库中
- HOLAP:R和M的结合体H
- ROLAP:ClickHouse
ClickHouse 的核心特点
超高的查询性能
- 列式存储:只读取查询所需的列,减少了磁盘 I/O。
- 向量化计算:批量处理数据,提高了 CPU 使用效率。
- 数据压缩:高效的压缩算法,降低了存储成本。
水平可扩展性
- 分布式架构:支持集群部署,轻松处理 PB 级数据。
- 线性扩展:通过增加节点提升性能,无需停机。
实时数据写入
- 高吞吐量:每秒可插入数百万行数据。
- 低延迟:数据写入后立即可查询,满足实时分析需求。
丰富的功能支持
- 多样的数据类型:支持从基本类型到复杂类型的数据。
- 高级 SQL 特性:窗口函数、子查询、JOIN 等。
- 物化视图:预计算和存储查询结果,进一步提升查询性能。
典型应用场景
- 用户行为分析:电商、游戏、社交平台的实时用户行为跟踪。
- 日志和监控数据存储:处理服务器日志、应用程序日志和性能监控数据。
- 商业智能(BI):支持复杂的报表和数据分析需求。

部署与运维
- 单机部署:适合测试和小规模应用。
- 集群部署:用于生产环境,可通过 Zookeeper 进行协调。
- 运维工具:提供了监控和管理工具,如 clickhouse-client、clickhouse-copier。
最佳实践
- 数据分区:根据时间或其他字段进行分区,提高查询效率。
- 索引优化:使用主键和采样键,加速数据定位。
- 硬件配置:充分利用多核 CPU、高速磁盘和大内存。
ClickHouse支持特性
ClickHouse具体有哪些特性呢:
- 真正的面向列的DBMS
- 数据高效压缩
- 磁盘存储的数据
- 多核并行处理
- 在多个分布式服务器上分布式处理
- SQL语法支持
- 向量化引擎
- 实时数据更新
- 索引
- 适合在线查询
- 支持近似预估计算
- 支持嵌套的数据结构
- 支持数组作为数据类型
- 支持限制查询复杂性以及配额
- 复制数据和对数据完整性的支持
ClickHouse和其他对比
商业OLAP
例如:
- HP Vertica
- Actian the Vector
区别:
- ClickHouse 是开源而且免费的
云解决方案
例如:
- 亚马逊 RedShift
- 谷歌 BigQuery
区别:
- ClickHouse 可以使用自己机器部署,无需云付费
Hadoop生态
例如:
- Cloudera Impala
- Spark SQL
- Facebook Presto
- Apache Drill
区别:
- ClickHouse 支持实时的高并发系统
- ClickHouse不依赖于Hadoop生态软件和基础
- ClickHouse支持分布式机房的部署
开源OLAP数据库
例如:
- InfiniDB
- MonetDB
- LucidDB
区别:
- 应用规模小
- 没有在大型互联网服务中蚕尝试
非关系型数据库
例如:
- Druid
- Apache Kylin
区别:
- ClickHouse 可以支持从原始数据直接查询,支持类SQL语言,提供了传统关系型数据的便利。
真正的面向列DBMS
如果你想要查询速度变快:
- 减少数据扫描范围
- 减少数据传输时的大小
在一个真正的面向列的DBMS中,没有任何无用的信息在值中存储。
例如:必须支持定长数值,以避免在数值旁边存储长度数字,10亿个Int8的值应该大约消耗1GB的未压缩磁盘空间,否则这将强烈影响CPU的使用。由于解压的速度(CPU的使用率)主要取决于未压缩的数据量,即使在未压缩的情况下,紧凑的存储数据也是非常重要的。
因为有些系统可以单独存储独列的值,但由于其他场景的优化,无法有效处理分析查询,例如HBase、BigTable、Cassandra和HypeTable。在这些系统中,每秒可以获得大约十万行的吞吐量,但是每秒不会到达数亿行。
另外,ClickHouse是一个DBMS,而不是一个单一的数据库,ClickHouse允许运行时创建表和数据库,加载数据和运行查询,而不用重新配置或启动系统。


之所以称作 DBMS,因为ClickHouse:
- DDL
- DML
- 权限管理
- 数据备份
- 分布式存储
- 等等功能
数据压缩
一些面向列的DBMS(InfiniDB CE 和 MonetDB)不使用数据压缩,但是数据压缩可以提高性能。
磁盘存储
许多面向列的DBMS(SAP HANA和GooglePower Drill)只能在内存中工作,但即使在数千台服务器上,内存也太小,无法在Yandex.Metrica中存储所有浏览和会话。
多核并行
多核并行进行大型的查询。
在多个服务器上分布式处理
上面列出的DBMS几乎不支持分布式处理,在ClickHouse中,数据可以驻留不同的分片上,每个分片可以是用于容错的一组副本,查询在所有分片上并行处理,这对用户来说是透明的。
SQL支持
- 支持的查询包括 GR0UP BY、ORDER BY
- 子查询在FROM、IN、JOIN子句中被支持
- 标量子查询支持
- 关联子查询不支持
- 真是因为ClickHouse提供了标准协议的SQL查询接口,使得现有可视化分析系统能够轻松的与它集成对接
向量化引擎
数据不仅案列存储,而且由矢量-列的部分进行处理,这使我们能够实现高CPU性能。
向量化执行时寄存器硬件层面上的特性,可以理解为消除程序中循环的优化。
为了实现向量化执行,需要利用CPU的SIMD指令(Single Instrution Multiple Data),即用单条指令处理多条数据。现代计算机系统概念中,它是利用数据并行度来提高性能的一种实现方式,它的原理是在CPU寄存器层面实现数据并行的实现原理。
实时数据更新
ClickHouse支持主键表,为了快速执行对主键范围的查询,数据使用合并树(MergeTree)进行递增排序,由于这个原因,数据可以不断的添加到表中,添加数据时无锁处理。
索引
例如,带有主键可以在特定的时间范围内为特定的客户端(Metrica计数器)抽取数据,并且延迟事件小于几十毫秒。
支持在线查询
我们可以使用该系统作为Web界面的后端,低延迟意味着可以无延迟的实时的处理查询。
支持近似计算
- 系统包含用于近似计算各种值,中位数和分位数的集合函数
- 支持基于部分(样本)数据运行查询并获得近似结果,在这种情况下,从磁盘检索比例较少的数据。
- 支持为有限数量的随机秘钥(而不是所有秘钥)运行聚合,在数据中秘钥分发的特定场景下,这提供了相对准确的结果,同时使用较少的资源。
数据复制和对数据完整性支持
使用异步多主复制,写入任何可用的副本后,数据将分发到所有剩余的副本,系统在不同的副本上保持相同的数据。
要注意的是,ClickHouse并不完美:
- 不支持事务
- 虽然已支持条件 Delete/Update(mutations),只是非事务型、异步、重写分片数据开销大;生产要谨慎,用 TTL/分区替代更常见。
- 支持有限的操作系统
最后总结
在大数据分析领域中,传统的大数据分析需要不同框架和技术组合才能达到最终效果,在人力成本、技术能力、硬件成本、维护成本上,让大数据分析变成了很昂贵的事情,很多中小企业非常痛苦,不得不被迫租赁第三方大型数据分析服务。
ClickHouse开源的出现让许多想做大数据且想做大数据分析的很多公司和企业都耳目一新。ClickHouse正是以不依赖Hadoop生态、安装维护简单、查询快速、支持SQL等特点,在大数据领域越走越远。
来源:juejin.cn/post/7563935896706957363
Maven高级
一. 分模块设计与开发
分模块设计的核心是 “高内聚,低耦合”。
- 高内聚:一个模块只负责一个独立的、明确的职责(如:订单模块只处理所有订单相关业务)。模块内部的代码关联性非常强。
- 低耦合:模块与模块之间的依赖关系尽可能的简单和清晰。一个模块的变化,应该尽量减少对其他模块的影响。
通过Maven,我们可以轻松地实现这一思想。每个模块都是一个独立的Maven项目,它们通过父子工程和依赖管理有机地组织在一起。
以一个经典的电商平台为例,我们可以将其拆分为以下模块:
将pojo和utils模块分出去

)
在tilas-web-management中的pom.xml中引入pojo,utils模块
<dependency>
<groupId>org.example</groupId>
<artifactId>tlias-pojo</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.example</groupId>
<artifactId>tlias-utils</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
分模块的好处
- 代码清晰,职责分明:每个开发人员可以专注于自己的模块,易于理解和维护。
- 并行开发,提升效率:多个模块可以由不同团队并行开发,只需约定好接口即可。
- 构建加速:Maven支持仅构建更改的模块及其依赖模块(使用 mvn -pl 命令),大大节省构建时间。
- 极高的复用性:像 core、dao 这样的模块,可以直接被其他新项目引用,避免重复造轮子。
- 便于单元测试:可以针对单个业务模块进行独立的、深入的测试。
二. 继承
2.1 继承配置
tlias-pojo、tlias-utils、tlias-web-management 中都引入了一个依赖 lombok 的依赖。我们在三个模块中分别配置了一次。

我们可以再创建一个父工程 tlias-parent ,然后让上述的三个模块 tlias-pojo、tlias-utils、tlias-web-management 都来继承这个父工程 。 然后再将各个模块中都共有的依赖,都提取到父工程 tlias-parent中进行配置,只要子工程继承了父工程,依赖它也会继承下来,这样就无需在各个子工程中进行配置了。

将tilas-parent中的pom.xml设置成pom打包方式
Maven打包方式:
- jar:普通模块打包,springboot项目基本都是jar包(内嵌tomcat运行)
- war:普通web程序打包,需要部署在外部的tomcat服务器中运行
- pom:父工程或聚合工程,该模块不写代码,仅进行依赖管理
<packaging>pom</packaging>
通过parent来配置父工程
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.8</version>
<!-- 父工程的pom.xml的相对路径 如果不配置就直接从中央仓库调取 -->
<relativePath/> <!-- lookup parent from repository -->
</parent>
子工程配置 通过relativePath配置父工程的路径
<parent>
<groupId>org.example</groupId>
<artifactId>tlias-parent</artifactId>
<version>1.0-SNAPSHOT</version>
<relativePath>../tilas-parent/pom.xml</relativePath>
</parent>
2.2 版本锁定
如果项目拆分的模块比较多,每一次更换版本,我们都得找到这个项目中的每一个模块,一个一个的更改。 很容易就会出现,遗漏掉一个模块,忘记更换版本的情况。
在maven中,可以在父工程的pom文件中通过 来统一管理依赖版本。
<!--统一管理依赖版本-->
<dependencyManagement>
<dependencies>
<!--JWT令牌-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
······
</dependencies>
</dependencyManagement>
这样在子工程中就不需要进行version版本设置了
<dependencies>
<!--JWT令牌-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
</dependency>
</dependencies>
注意!!!
- 在父工程中所配置的 dependencyManagement只能统一管理依赖版本,并不会将这个依赖直接引入进来。 这点和 dependencies 是不同的。
- 子工程要使用这个依赖,还是需要引入的,只是此时就无需指定 版本号了,父工程统一管理。变更依赖版本,只需在父工程中统一变更。
2.3 自定义属性
<properties>
<lombok.version>1.18.34</lombok.version>
</properties>
通过${属性名}引用属性
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</dependency>
三. 聚合
聚合(Aggregation) 和继承(Inheritance) 是Maven支持分模块设计的两个核心特性,它们目的不同,但又相辅相成。
简单来说,继承是为了统一管理,而聚合是为了统一构建。

在聚合工程中通过modules>module来配置聚合
<!-- 聚合其他模块 -->
<modules>
<module>你要聚合的模块路径</module>
</modules>
四. 私服
私服是一种特殊的远程仓库,它代理并缓存了位于互联网的公共仓库(如MavenCentral),同时允许企业内部部署自己的私有构件(Jar包)。
你可以把它理解为一个 “架设在公司内网里的Maven中央仓库”。

项目版本说明:
- RELEASE(发布版本):功能趋于稳定、当前更新停止,可以用于发行的版本,存储在私服中的RELEASE仓库中。
- SNAPSHOT(快照版本):功能不稳定、尚处于开发中的版本,即快照版本,存储在私服的SNAPSHOT仓库中。
4.1 私服下载
下载Nexus私服 help.sonatype.com/en/download…

在D:\XXXXXXXX\bin目录下 cmd运行nexus /run nexus 显示这个就表示开启成功啦!

浏览器输入localhost:8081

私服仓库说明:
- RELEASE:存储自己开发的RELEASE发布版本的资源。
- SNAPSHOT:存储自己开发的SNAPSHOT发布版本的资源。
- Central:存储的是从中央仓库下载下来的依赖
4.2 资源上传与下载
设置私服的访问用户名/密码(在自己maven安装目录下的conf/settings.xml中的servers中配置)
<server>
<id>maven-releases</id>
<username>admin</username>
<password>admin</password>
</server>
<server>
<id>maven-snapshots</id>
<username>admin</username>
<password>admin</password>
</server>
设置私服依赖下载的仓库组地址(在自己maven安装目录下的conf/settings.xml中的mirrors中配置)
<mirror>
<id>maven-public</id>
<mirrorOf>*</mirrorOf>
<url>http://localhost:8081/repository/maven-public/</url>
</mirror>
设置私服依赖下载的仓库组地址(在自己maven安装目录下的conf/settings.xml中的profiles中配置)
<profile>
<id>allow-snapshots</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<repositories>
<repository>
<id>maven-public</id>
<url>http://localhost:8081/repository/maven-public/</url>
<releases>
<enabled>true</enabled>
</releases>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository>
</repositories>
</profile>
IDEA的maven工程的pom文件中配置上传(发布)地址(直接在tlias-parent中配置发布地址)
<distributionManagement>
<!-- release版本的发布地址 -->
<repository>
<id>maven-releases</id>
<url>http://localhost:8081/repository/maven-releases/</url>
</repository>
<!-- snapshot版本的发布地址 -->
<snapshotRepository>
<id>maven-snapshots</id>
<url>http://localhost:8081/repository/maven-snapshots/</url>
</snapshotRepository>
</distributionManagement>
打开maven控制面板双击deploy

由于当前我们的项目是SNAPSHOT版本,所以jar包是上传到了snapshot仓库中
来源:juejin.cn/post/7549363056862756918
别再被VO、BO、PO、DTO、DO绕晕!今天用一段代码把它们讲透
大家好,我是晓凡。
前阵子晓凡的粉丝朋友面试,被问到“什么是VO?和DTO有啥区别?”
粉丝朋友:“VO就是Value Object,DTO就是Data Transfer Object……”
面试官点点头:“那你说说,一个下单接口里,到底哪个算VO,哪个算DTO?”
粉丝朋友有点犹豫了。
回来后粉丝朋友痛定思痛,把项目翻了个底朝天,并且把面试情况告诉了晓凡,下定决心捋清楚了这堆 XO 的真实含义。
于是乎,这篇文章就来了 今天咱们就用一段“用户下单买奶茶”的故事,把 VO、BO、PO、DTO、DO 全部聊明白。
看完保准你下次面试不卡壳,写代码不纠结。
一、先放结论
它们都是“为了隔离变化”而诞生的马甲
| 缩写 | 英文全称 | 中文直译 | 出现位置 | 核心目的 |
|---|---|---|---|---|
| PO | Persistent Object | 持久化对象 | 数据库 ↔ 代码 | 一张表一行记录的直接映射 |
| DO | Domain Object | 领域对象 | 核心业务逻辑层 | 充血模型,封装业务行为 |
| BO | Business Object | 业务对象 | 应用/服务层 | 聚合多个DO,面向用例编排 |
| DTO | Data Transfer Object | 数据传输对象 | 进程/服务间 | 精简字段,抗网络延迟 |
| VO | View Object | 视图对象 | 控制层 ↔ 前端 | 展示友好,防敏感字段泄露 |
一句话总结: PO 管存储,DO 管业务,BO 管编排,DTO 管网络,VO 管界面。
下面上代码,咱们边喝奶茶边讲。
二、业务场景
用户下一单“芋泥波波奶茶”
需求:
- 用户选好规格(大杯、少冰、五分糖)。
- 点击“提交订单”,前端把数据发过来。
- 后端算价格、扣库存、落库,返回“订单创建成功”页面。
整条链路里,我们到底需要几个对象?
三、从数据库开始:PO
PO是
Persistent Object的简写 PO 就是“一行数据一个对象”,字段名、类型和数据库保持一一对应,不改表就不改它。
// 表:t_order
@Data
@TableName("t_order")
public class OrderPO {
private Long id; // 主键
private Long userId; // 用户ID
private Long productId; // 商品ID
private String sku; // 规格JSON
private BigDecimal price; // 原价
private BigDecimal payAmount; // 实付
private Integer status; // 订单状态
private LocalDateTime createTime;
private LocalDateTime updateTime;
}
注意:PO 里绝不能出现业务方法,它只是一个“数据库搬运工”。
四、核心业务:DO
DO 是“有血有肉的对象”,它把业务规则写成方法,让代码自己说话。
// 领域对象:订单
public class OrderDO {
private Long id;
private UserDO user; // 聚合根
private MilkTeaDO milkTea; // 商品
private SpecDO spec; // 规格
private Money price; // Money是值对象,防精度丢失
private OrderStatus status;
// 业务方法:计算最终价格
public Money calcFinalPrice() {
// 会员折扣
Money discount = user.getVipDiscount();
// 商品促销
Money promotion = milkTea.getPromotion(spec);
return price.minus(discount).minus(promotion);
}
// 业务方法:下单前置校验
public void checkBeforeCreate() {
if (!milkTea.hasStock(spec)) {
throw new BizException("库存不足");
}
}
}
DO 可以引用别的 DO,形成聚合根。它不关心数据库,也不关心网络。
五、面向用例:BO
BO 是“场景大管家”,把多个 DO 攒成一个用例,常出现在 Service 层。
@Service
public class OrderBO {
@Resource
private OrderRepository orderRepository; // 操作PO
@Resource
private InventoryService inventoryService; // RPC或本地
@Resource
private PaymentService paymentService;
// 用例:下单
@Transactional
public OrderDTO createOrder(CreateOrderDTO cmd) {
// 1. 构建DO
OrderDO order = OrderAssembler.toDO(cmd);
// 2. 执行业务校验
order.checkBeforeCreate();
// 3. 聚合逻辑:扣库存、算价格
inventoryService.lock(order.getSpec());
Money payAmount = order.calcFinalPrice();
// 4. 落库
OrderPO po = OrderAssembler.toPO(order, payAmount);
orderRepository.save(po);
// 5. 返回给前端需要的数据
return OrderAssembler.toDTO(po);
}
}
BO 的核心是编排,它把 DO、外部服务、PO 串成一个完整的业务动作。
六、跨进程/服务:DTO
DTO 是“网络快递员”,字段被压缩成最少,只带对方需要的数据。
1)入口 DTO:前端 → 后端
@Data
public class CreateOrderDTO {
@NotNull
private Long userId;
@NotNull
private Long productId;
@Valid
private SpecDTO spec; // 规格
}
2)出口 DTO:后端 → 前端
@Data
public class OrderDTO {
private Long orderId;
private String productName;
private BigDecimal payAmount;
private String statusDesc;
private LocalDateTime createTime;
}
DTO 的字段命名常带 UI 友好词汇(如 statusDesc),并且绝不暴露敏感字段(如 userId 在返回给前端时可直接省略)。
七、最后一步:VO
VO 是“前端专属快递”,字段可能二次加工,甚至带 HTML 片段。
@Data
public class OrderVO {
private String orderId; // 用字符串避免 JS long 精度丢失
private String productImage; // 带 CDN 前缀
private String priceText; // 已格式化为“¥18.00”
private String statusTag; // 带颜色:green/red
}
VO 通常由前端同学自己写 TypeScript/Java 类,后端只负责给 DTO,再让前端 BFF 层转 VO。如果你用 Node 中间层或 Serverless,VO 就出现在那儿。
八、一张图记住流转过程
前端页面
│ JSON
▼
CreateOrderVO (前端 TS)
│ 序列化
▼
CreateOrderDTO (后端入口)
│ BO.createOrder()
▼
OrderDO (充血领域模型)
│ 聚合、计算
▼
OrderPO (落库)
│ MyBatis
▼
数据库
返回时反向走一遍:
数据库
│ SELECT
OrderPO
│ 转换
OrderDTO
│ JSON
OrderVO (前端 TS 渲染)
九、常见疑问答疑
- 为什么 DO 和 PO 不合并? 数据库加索引、加字段不影响业务;业务改规则不改表结构。隔离变化。
- DTO 和 VO 能合并吗? 小项目可以,但一上微服务或多端(App、小程序、管理后台),立马爆炸。比如后台需要用户手机号,App 不需要,合并后前端会拿到不该看的数据。
- BO 和 Service 有什么区别? BO 更贴近用例,粒度更粗。Service 可能细分读写、缓存等。命名随意,关键看团队约定。
十、一句话背下来
数据库里叫 PO,业务里是 DO,编排靠 BO,网络走 DTO,前端看 VO。
下次面试官再问,你就把奶茶故事讲给他听,保证他频频点头。
本期内容到这儿就结束了
我是晓凡,再小的帆也能远航
我们下期再见 ヾ(•ω•`)o (●'◡'●)
来源:juejin.cn/post/7540472612595941422
后端仔狂喜!手把手教你用 Java 拿捏华为云 IoTDA,设备上报数据 so easy
作为天天跟接口、数据库打交道的后端博主,我之前总觉得 IoT 是 “硬件大佬的专属领域”—— 直到我踩坑华为云 IoTDA(物联网设备接入服务)后发现:这玩意儿明明就是 “后端友好型选手”!今天带大家从 0 到 1 玩转 IoTDA,从创建产品到 Java 集成,全程无废话,连小白都能看懂~
一、先唠唠:IoTDA 到底是个啥?
设备接入服务(IoTDA)是华为云的物联网平台,提供海量设备连接上云、设备和云端双向消息通信、批量设备管理、远程控制和监控、OTA升级、设备联动规则等能力,并可将设备数据灵活流转到华为云其他服务。
你可以把 IoTDA 理解成 “IoT 设备的专属管家”:
- 设备想连互联网?管家帮它搭好通道(MQTT/CoAP 等协议);
- 设备要上报数据(比如温湿度、电量)?管家负责接收、存着还能帮你分析;
- 你想给设备发指令(比如让灯关掉)?管家帮你把指令精准送到设备手里。
简单说:有了 IoTDA,后端不用管硬件怎么联网,专心写代码处理 “设备数据” 就行 —— 这不就是咱们的舒适区嘛!
相关的一些概念:
- 服务和物模型:
- 物模型:设备本身拥有的属性(功能数据),像电量,电池温度等等
- 服务:理解为不同物模型的分类(分组),比如电池服务(包含了电量、电池温度等等物模型), 定位服务(经纬度坐标,海拔高度物模型)
- 设备:真实看得见、摸得着的设备实物,设备的信息需要录入到华为云IoTDA平台里
二、实操第一步:给你的设备 “办张身-份-证”(创建产品)
产品:某一类具有相同能力或特征的设备的集合称为一款产品。帮助开发者快速进行产品模型和插件的开发,同时提供端侧集成、在线调试、自定义Topic等多种能力。比如,手表、大门通道门禁、紧急呼叫报警器、滞留报警器、跌倒报警器
就像人要办身-份-证才能出门,IoT 设备也得先在 IoTDA 上 “建档”(创建产品),步骤简单到离谱:
- 登录华为云控制台,搜 “IoTDA” 进入服务页面(记得先开服务,新用户有免费额度!);
- 左侧菜单点【产品】→【创建产品】,填这几个关键信息:

- 产品名称:比如 “我的温湿度传感器”(别起 “test123”,后续找起来头疼);

- 协议类型:选 “MQTT”(后端最常用,设备端也好对接);
- 数据格式:默认 “JSON”(咱们后端处理 JSON 不是手到擒来?);
- 点【确定】,搞定!此时你会拿到一个 “产品 ID”—— 记好它,后续要当 “钥匙” 用。

小吐槽:第一次创建时我把协议选错成 “CoAP”,结果设备端连不上,排查半小时才发现… 大家别学我!
三、设备接入:让传感器 “开口说话”(数据上报)
产品创建好后,得让具体的设备(比如你手里的温湿度传感器)连上来,核心就两步:注册设备→上报数据。 官方案例-设备接入:基于NB-IoT小熊派的智慧烟感
3.1 给单个设备 “上户口”(注册设备)
- 进入刚创建的产品详情页,点【设备】→【注册设备】;
- 填 “设备名称”(比如 “传感器 001”),其他默认就行;

- 注册成功后,会拿到两个关键信息:设备 ID和设备密钥(相当于设备的 “账号密码”,千万别泄露!)。
3.2 数据上报:让设备把数据 “发快递” 过来
前提条件:需要提前创建好产品和对应的物模型,以及该产品的设备
准备实例的接入地址
后续设备上报数据时,需要准备好接入地址。去哪里找接入地址呢?参考下图:

设备要上报数据,本质就是通过 MQTT 协议给 IoTDA 发一条 JSON 格式的消息,举个实际例子:
假设温湿度传感器要上报 “温度 25℃、湿度 60%”,数据格式长这样:
{
"Temperature": 25.0, // 温度
"Humidity": 60.0 // 湿度
}
设备端怎么发?不用你写 C 代码!华为云给了现成的 “设备模拟器”:
在设备详情页点【在线调试】→ 选 “属性上报”→ 把上面的 JSON 粘进去→ 点【发送】,刷新页面就能看到数据已经躺在 IoTDA 里了 —— 是不是比调接口还简单?
四、后端重头戏:Java 项目集成 IoTDA
前面都是控制台操作,后端真正要做的是 “用代码跟 IoTDA 交互”。华为云提供了 Java SDK,咱们不用重复造轮子,直接撸起袖子干!
4.1 先搞懂:关键参数从哪来?
集成前必须拿到这 3 个 “钥匙”,少一个都不行:
- Access Key/Secret Key:华为云账号的 “API 密钥”,在【控制台→我的凭证→访问密钥】里创建;
- 区域 ID:比如 “cn-north-4”(北京四区),IoTDA 控制台首页就能看到;
- 产品 ID / 设备 ID:前面创建产品、注册设备时拿到的(忘了就去控制台查!)。
友情提示:别把 Access Key 硬编码到代码里!用配置文件或者 Nacos 存,不然上线后哭都来不及~
4.2 项目集成:Maven 依赖先安排上
在 pom.xml 里加 IoTDA SDK 的依赖(版本选最新的就行):
<dependency>
<groupId>com.huaweicloud.sdkgroupId>
<artifactId>huaweicloud-sdk-iotdaartifactId>
<version>3.1.47version>
dependency>
<dependency>
<groupId>com.huaweicloud.sdkgroupId>
<artifactId>huaweicloud-sdk-coreartifactId>
<version>3.1.47version>
dependency>
4.3 核心功能实现:代码手把手教你写
先初始化 IoTDA 客户端(相当于建立连接),写个工具类:
import com.huaweicloud.sdk.core.auth.BasicCredentials;
import com.huaweicloud.sdk.iotda.v5.IoTDAClient;
import com.huaweicloud.sdk.iotda.v5.region.IoTDARegion;
public class IoTDAClientUtil {
// 从配置文件读参数,这里先写死方便演示
private static final String ACCESS_KEY = "你的Access Key";
private static final String SECRET_KEY = "你的Secret Key";
private static final String REGION_ID = "cn-north-4"; // 你的区域ID
public static IoTDAClient getClient() {
// 1. 配置凭证
BasicCredentials credentials = new BasicCredentials()
.withAk(ACCESS_KEY)
.withSk(SECRET_KEY);
// 2. 初始化客户端
return IoTDAClient.newBuilder()
.withCredentials(credentials)
.withRegion(IoTDARegion.valueOf(REGION_ID))
.build();
}
}
接下来实现咱们需要的 5 个核心功能,每个功能都带注释,一看就懂:
功能 1:从 IoT 平台同步产品列表
import com.huaweicloud.sdk.iotda.v5.model.ListProductsRequest;
import com.huaweicloud.sdk.iotda.v5.model.ListProductsResponse;
public class IoTDAService {
private final IoTDAClient client = IoTDAClientUtil.getClient();
// 同步产品列表(支持分页,这里查第一页,每页10条)
public void syncProductList() {
ListProductsRequest request = new ListProductsRequest()
.withLimit(10) // 每页条数
.withOffset(0); // 页码,从0开始
try {
ListProductsResponse response = client.listProducts(request);
System.out.println("同步到的产品列表:" + response.getProducts());
} catch (Exception e) {
System.err.println("同步产品列表失败:" + e.getMessage());
}
}
}
项目参考思路

功能 2:查询所有产品列表(分页查询封装)
// 查所有产品(自动分页,直到查完所有)
public void queryAllProducts() {
int offset = 0;
int limit = 20;
while (true) {
ListProductsRequest request = new ListProductsRequest()
.withLimit(limit)
.withOffset(offset);
ListProductsResponse response = client.listProducts(request);
if (response.getProducts().isEmpty()) {
break; // 没有更多产品了,退出循环
}
System.out.println("当前页产品:" + response.getProducts());
offset += limit; // 下一页
}
}
功能 3:注册设备(代码注册,不用手动在控制台点了)
import com.huaweicloud.sdk.iotda.v5.model.RegisterDeviceRequest;
import com.huaweicloud.sdk.iotda.v5.model.RegisterDeviceResponse;
import com.huaweicloud.sdk.iotda.v5.model.RegisterDeviceRequestBody;
public void registerDevice(String productId, String deviceName) {
// 构造注册请求体
RegisterDeviceRequestBody body = new RegisterDeviceRequestBody()
.withDeviceName(deviceName);
RegisterDeviceRequest request = new RegisterDeviceRequest()
.withProductId(productId) // 关联的产品ID
.withBody(body);
try {
RegisterDeviceResponse response = client.registerDevice(request);
System.out.println("设备注册成功!设备ID:" + response.getDeviceId()
+ ",设备密钥:" + response.getDeviceSecret());
} catch (Exception e) {
System.err.println("设备注册失败:" + e.getMessage());
}
}
功能 4:查询设备详细信息(比如设备在线状态、最后上报时间)
import com.huaweicloud.sdk.iotda.v5.model.ShowDeviceRequest;
import com.huaweicloud.sdk.iotda.v5.model.ShowDeviceResponse;
public void queryDeviceDetail(String deviceId) {
ShowDeviceRequest request = new ShowDeviceRequest()
.withDeviceId(deviceId); // 要查询的设备ID
try {
ShowDeviceResponse response = client.showDevice(request);
System.out.println("设备在线状态:" + response.getStatus()); // ONLINE/OFFLINE
System.out.println("最后上报时间:" + response.getLastOnlineTime());
System.out.println("设备详细信息:" + response);
} catch (Exception e) {
System.err.println("查询设备详情失败:" + e.getMessage());
}
}
功能 5:查看设备上报的数据(关键!终于能拿到传感器数据了)
import com.huaweicloud.sdk.iotda.v5.model.ListDevicePropertiesRequest;
import com.huaweicloud.sdk.iotda.v5.model.ListDevicePropertiesResponse;
public void queryReportedData(String deviceId) {
// 查询设备最近上报的10条属性数据
ListDevicePropertiesRequest request = new ListDevicePropertiesRequest()
.withDeviceId(deviceId)
.withLimit(10)
.withAsc(false); // 倒序,最新的先看
try {
ListDevicePropertiesResponse response = client.listDeviceProperties(request);
response.getProperties().forEach(property -> {
System.out.println("数据上报时间:" + property.getReportTime());
System.out.println("上报的属性值:" + property.getPropertyValues());
// 比如取温度:property.getPropertyValues().get("Temperature")
});
} catch (Exception e) {
System.err.println("查询设备上报数据失败:" + e.getMessage());
}
}
五、踩坑总结:这些坑我替你们踩过了!
- 区域 ID 搞错:比如用 “cn-east-2”(上海二区)的客户端去连 “cn-north-4” 的 IoTDA,直接报 “连接超时”;
- 设备密钥泄露:一旦泄露,别人能伪装你的设备上报假数据,一定要存在安全的地方;
- SDK 版本太旧:有些老版本不支持 “查询设备历史数据” 接口,记得用最新版 SDK;
- 免费额度用完:新用户免费额度够测 1 个月,别上来就挂生产设备,先测通再说~
最后说两句
其实 IoTDA 对后端来说真的不难,核心就是 “调用 API 跟平台交互”,跟咱们平时调支付接口、短信接口没啥区别。今天的代码大家可以直接 copy 到项目里,改改参数就能跑通~
你们在集成 IoTDA 时遇到过啥坑?或者有其他 IoT 相关的需求(比如设备指令下发)?评论区聊聊,下次咱们接着唠!
(觉得有用的话,别忘了点赞 + 收藏,后端学 IoT 不迷路~)
来源:juejin.cn/post/7541667597285277731
Code Review 最佳实践 2:业务实战中的审核细节
🧠 本节谈
我们聚焦真实业务模块中的 Code Review,涵盖:
- 🔐 表单校验逻辑
- 🧩 动态权限控制
- 🧱 页面逻辑复杂度管理
- ⚠️ 接口调用规范
- ♻️ 组件解耦重构
- 🧪 单元测试提示
每一条都配备真实反面代码 + 改进建议 + 原因说明,并总结通用审核 checklist。
📍 场景 1:复杂表单校验逻辑
❌ 错误示例:耦合 + 不可维护
const onSubmit = () => {
if (!form.name || form.name.length < 3 || !form.age || isNaN(form.age)) {
message.error('请填写正确信息')
return
}
// ...
}
问题:
- 所有校验写死在事件里,不可复用
- 无法做提示区分
✅ 改进方式:抽离校验 + 可扩展
const validateForm = (form: UserForm) => {
if (!form.name) return '姓名不能为空'
if (form.name.length < 3) return '姓名过短'
if (!form.age || isNaN(Number(form.age))) return '年龄格式错误'
return ''
}
const onSubmit = () => {
const msg = validateForm(form)
if (msg) return message.error(msg)
// ...
}
👉 Review 要点:
- 校验逻辑是否可复用?
- 是否便于单测?
- 提示是否明确?
📍 场景 2:权限控制逻辑写死
❌ 反例
<Button v-if="user.role === 'admin'">删除</Button>
问题:
- 无法集中管理
- 用户身份切换时易出错
- 无法与服务端权限匹配
✅ 推荐:
const hasPermission = (perm: string) => user.permissions.includes(perm)
<Button v-if="hasPermission('can_delete')">删除</Button>
👉 Review 要点:
- 权限是否统一处理?
- 是否可扩展到路由、接口层?
- 是否易于调试?
📍 场景 3:复杂页面组件未解耦
❌ 嵌套组件塞一堆逻辑
// 页面结构
<Table data={data}>
{data.map(row => (
<tr>
<td>{row.name}</td>
<td>
<Button onClick={() => doSomething(row.id)}>操作</Button>
</td>
</tr>
))}
</Table>
- 所有数据/逻辑/视图耦合一起
- 无法复用
- 改动难以测试
✅ 推荐:
<TableRow :row="row" @action="handleRowAction" />
// TableRow.vue
<template>
<tr>
<td>{{ row.name }}</td>
<td><Button @click="emit('action', row.id)">操作</Button></td>
</tr>
</template>
👉 Review 要点:
- 是否具备清晰的“数据流 → 逻辑流 → 视图层”结构?
- 是否把组件职责划分清楚?
- 是否拆分足够颗粒度便于测试?
📍 场景 4:接口调用未封装
❌ 直接 axios 写在组件中:
axios.get('/api/list?id=' + id).then(res => {
this.list = res.data
})
问题:
- 接口不可复用
- 无法集中处理错误
- 改动接口时无法追踪引用
✅ 推荐:
// services/user.ts
export const getUserList = (id: number) =>
request.get('/api/list', { params: { id } })
// 页面中
getUserList(id).then(res => (this.list = res.data))
👉 Review 要点:
- 是否将接口层抽离为服务?
- 是否统一请求拦截、错误处理?
- 是否易于 Mock 和调试?
📍 场景 5:测试个锤子🔨
❌ 错误写法:组件中逻辑混杂难以测试
if (user.age > 18 && user.vipLevel > 3 && user.region === 'CN') {
return true
}
问题:
- 没有语义抽象
- 不可测试
✅ 改进写法:
const isPremiumUser = (user: User) =>
user.age > 18 && user.vipLevel > 3 && user.region === 'CN'
👉 Review 要点:
- 是否具备良好的可测试性?
- 是否便于 Jest/Vitest 测试用例编写?
✅ 最佳实践总结
Review 高级 Checklist
| 检查点 | 检查说明 |
|---|---|
| ✅ 表单逻辑 | 是否抽离,是否健壮 |
| ✅ 权限处理 | 是否统一管理,可扩展 |
| ✅ 页面复杂度 | 是否组件解耦,职责清晰 |
| ✅ 接口调用 | 是否封装为服务层,便于复用 |
| ✅ 可测试性 | 关键逻辑是否抽象、是否测试友好 |
🎯 尾声:从 Code Review 走向“架构推动者”
掌握 Code Review 不止是“找错”,而是:
- 帮助他人提升思维方式
- 用标准统一团队技术认知
- 用习惯推动系统演进
来源:juejin.cn/post/7530437804129239080
负载均衡 LVS vs Nginx 对比!还傻傻分不清?
- Nginx特点
- 正向代理与反向代理
- 负载均衡
- 动静分离
- Nginx的优势
- 可操作性大
- 网络依赖小
- 安装简单
- 支持健康检查以及请求重发
- LVS 的优势
- 抗负载能力强
- 配置性低
- 工作稳定
- 无流量
今天总结一下负载均衡中LVS与Nginx的区别,好几篇博文一开始就说LVS是单向的,Nginx是双向的,我个人认为这是不准确的,LVS三种模式中,虽然DR模式以及TUN模式只有请求的报文经过Director,但是NAT模式,Real Server回复的报文也会经过Director Server地址重写:

图片
首先要清楚的一点是,LVS是一个四层的负载均衡器,虽然是四层,但并没有TCP握手以及分手,只是偷窥了IP等信息,而Nginx是一个七层的负载均衡器,所以效率势必比四层的LVS低很多,但是可操作性比LVS高,后面所有的讨论都是基于这个区别。
为什么四册比七层效率高?
四层是TCP层,使用IP+端口四元组的方式。只是修改下IP地址,然后转发给后端服务器,TCP三次握手是直接和后端连接的。只不过在后端机器上看到的都是与代理机的IP的established而已,LVS中没有握手。
7层代理则必须要先和代理机三次握手后,才能得到7层(HTT层)的具体内容,然后再转发。意思就是代理机必须要与client和后端的机器都要建立连接。显然性能不行,但胜在于七层,人工可操作性高,能写更多的转发规则。
Nginx特点
Nginx 专为性能优化而开发,性能是其最重要的要求,十分注重效率,有报告 Nginx 能支持高达 50000 个并发连接数。
另外,Nginx 系列面试题和答案全部整理好了,微信搜索Java技术栈,在后台发送:面试,可以在线阅读。
正向代理与反向代理
正向代理 :局域网中的电脑用户想要直接访问服务器是不可行的,服务器可能Hold不住,只能通过代理服务器来访问,这种代理服务就被称为正向代理,特点是客户端知道自己访问的是代理服务器。

图片
反向代理 :客户端无法感知代理,因为客户端访问网络不需要配置,只要把请求发送到反向代理服务器,由反向代理服务器去选择目标服务器获取数据,然后再返回到客户端。
此时反向代理服务器和目标服务器对外就是一个服务器,暴露的是代理服务器地址,隐藏了真实服务器 IP 地址。

图片
负载均衡
客户端发送多个请求到服务器,服务器处理请求,有一些可能要与数据库进行交互,服务器处理完毕之后,再将结果返回给客户端。
普通请求和响应过程如下图:

图片
但是随着信息数量增长,访问量和数据量增长,单台的Server以及Database就成了系统的瓶颈,这种架构无法满足日益增长的需求,这时候要么提升单机的性能,要么增加服务器的数量。
关于提升性能,这儿就不赘述,提提如何增加服务器的数量,构建集群,将请求分发到各个服务器上,将原来请求集中到单个服务器的情况改为请求分发到多个服务器,也就是我们说的负载均衡。
图解负载均衡:

图片
关于服务器如何拆分组建集群,这儿主要讲讲负载均衡,也就是图上的Proxy,可以是LVS,也可以是Nginx。假设有 15 个请求发送到代理服务器,那么由代理服务器根据服务器数量,这儿假如是平均分配,那么每个服务器处理 5 个请求,这个过程就叫做负载均衡。
动静分离
为了加快网站的解析速度,可以把动态页面和静态页面交给不同的服务器来解析,加快解析的速度,降低由单个服务器的压力。
动静分离之前的状态

图片
动静分离之后

图片
光看两张图可能有人不理解这样做的意义是什么,我们在进行数据请求时,以淘宝购物为例,商品详情页有很多东西是动态的,随着登录人员的不同而改变,例如用户ID,用户头像,但是有些内容是静态的,例如商品详情页,那么我们可以通过CDN(全局负载均衡与CDN内容分发)将静态资源部署在用户较近的服务器中,用户数据信息安全性要更高,可以放在某处集中,这样相对于将说有数据放在一起,能分担主服务器的压力,也能加速商品详情页等内容传输速度。
Nginx的优势
可操作性大
Nginx是一个应用层的程序,所以用户可操作性的空间大得多,可以作为网页静态服务器,支持 Rewrite 重写规则;支持 GZIP 压缩,节省带宽;可以做缓存;可以针对 http 应用本身来做分流策略,静态分离,针对域名、目录结构等相比之下 LVS 并不具备这样的功能,所以 nginx 单凭这点可以利用的场合就远多于 LVS 了;但 nginx 有用的这些功能使其可调整度要高于 LVS,所以经常要去触碰,人为出现问题的几率也就大
网络依赖小
nginx 对网络的依赖较小,理论上只要 ping 得通,网页访问正常,nginx 就能连得通,nginx 同时还能区分内外网,如果是同时拥有内外网的节点,就相当于单机拥有了备份线路;LVS 就比较依赖于网络环境,目前来看服务器在同一网段内并且 LVS 使用 direct 方式分流,效果较能得到保证。另外注意,LVS 需要向托管商至少申请多于一个 ip 来做 visual ip
安装简单
nginx 安装和配置比较简单,测试起来也很方便,因为它基本能把错误用日志打印出来。LVS 的安装和配置、测试就要花比较长的时间,因为同上所述,LVS 对网络依赖性比较大,很多时候不能配置成功都是因为网络问题而不是配置问题,出了问题要解决也相应的会麻烦的多
nginx 也同样能承受很高负载且稳定,但负载度和稳定度差 LVS 还有几个等级:nginx 处理所有流量所以受限于机器 IO 和配置;本身的 bug 也还是难以避免的;nginx 没有现成的双机热备方案,所以跑在单机上还是风险比较大,单机上的事情全都很难说
支持健康检查以及请求重发
nginx 可以检测到服务器内部的故障(健康检查),比如根据服务器处理网页返回的状态码、超时等等,并且会把返回错误的请求重新提交到另一个节点。目前 LVS 中 ldirectd 也能支持针对服务器内部的情况来监控,但 LVS 的原理使其不能重发请求。比如用户正在上传一个文件,而处理该上传的节点刚好在上传过程中出现故障,nginx 会把上传切到另一台服务器重新处理,而 LVS 就直接断掉了。
LVS 的优势
抗负载能力强
因为 LVS 工作方式的逻辑是非常简单的,而且工作在网络的第 4 层,仅作请求分发用,没有流量,所以在效率上基本不需要太过考虑。LVS 一般很少出现故障,即使出现故障一般也是其他地方(如内存、CPU 等)出现问题导致 LVS 出现问题
配置性低
这通常是一大劣势同时也是一大优势,因为没有太多的可配置的选项,所以除了增减服务器,并不需要经常去触碰它,大大减少了人为出错的几率
工作稳定
因为其本身抗负载能力很强,所以稳定性高也是顺理成章的事,另外各种 LVS 都有完整的双机热备方案,所以一点不用担心均衡器本身会出什么问题,节点出现故障的话,LVS 会自动判别,所以系统整体是非常稳定的
无流量
LVS 仅仅分发请求,而流量并不从它本身出去,所以可以利用它这点来做一些线路分流之用。没有流量同时也保住了均衡器的 IO 性能不会受到大流量的影响
LVS 基本上能支持所有应用,因为 LVS 工作在第 4 层,所以它可以对几乎所有应用做负载均衡,包括 http、数据库、聊天室等。
来源:juejin.cn/post/7517644116592984102
SQL Join 中函数使用对性能的影响与优化方法
在日常开发中,经常会遇到这样的场景:
需要在 大小写不敏感 或 格式化字段 的情况下进行表关联。
如果在 JOIN 或 WHERE 中直接使用函数,往往会带来严重的性能问题。
本文用一个新的示例来说明问题和优化方法。
一、问题场景
假设我们有两张表:
- 用户表 user_info
user_id | username
----------+------------
1 | Alice
2 | Bob
3 | Charlie
- 订单表 order_info
order_id | buyer_name
----------+------------
1001 | alice
1002 | BOB
1003 | dave
我们希望根据用户名和买家名称进行关联(忽略大小写)。
原始写法(低效)
SELECT o.order_id, u.user_id, u.username
FROM order_info o
LEFT JOIN user_info u
ON LOWER(o.buyer_name) = LOWER(u.username);
问题:
LOWER()包裹了字段,导致数据库无法使用索引。- 每一行都要执行函数运算,性能下降。
二、优化方法
1. 子查询提前计算
通过子查询生成派生列,再进行关联。
SELECT o.order_id, u.user_id, u.username
FROM (
SELECT order_id, buyer_name, LOWER(buyer_name) AS buyer_name_lower
FROM order_info
) o
LEFT JOIN (
SELECT user_id, username, LOWER(username) AS username_lower
FROM user_info
) u
ON o.buyer_name_lower = u.username_lower;
优点:
- 避免在
JOIN时重复调用函数。 - 优化器有机会物化子查询并建立临时索引。
2. 建立函数索引(推荐)
如果这种需求非常频繁,可以在表上建立函数索引。
PostgreSQL 示例:
CREATE INDEX idx_username_lower ON user_info(LOWER(username));
CREATE INDEX idx_buyer_name_lower ON order_info(LOWER(buyer_name));
之后即使写:
SELECT ...
FROM order_info o
LEFT JOIN user_info u
ON LOWER(o.buyer_name) = LOWER(u.username);
数据库也能走索引,性能大幅提升。
3. 数据入库时统一格式
如果业务允许,可以在入库时统一转为小写,避免查询时做转换。
INSERT INTO user_info (user_id, username)
VALUES (1, LOWER('Alice'));
这样关联时直接比较即可:
ON o.buyer_name = u.username
三、总结
- 在
JOIN或WHERE中直接使用函数,会 导致索引失效,影响性能。 - 优化方法:
- 子查询提前计算,避免在关联时重复调用函数;
- 建立函数索引(或虚拟列索引);
- 入库时统一数据格式,彻底消除函数依赖。
📌 记忆要点:
- 函数写在
JOIN→ 慢 - 子查询提前算 → 好
- 函数索引 / 数据规范化 → 最优解
来源:juejin.cn/post/7555612267787550772
PaddleOCR-VL,超强文字识别能力,PDF的拯救者
转眼间已经是 2025 年的 Q4 了,年终越来越近,领导给予的 okr 压力越来越大,前段时间,领导提出了一个非常搞的想法,当然也是急需解决的痛点——线上一键翻译功能。
小包当前负责是开发开发面向全球各国的活动,因此活动中不免就会出现各种各样的语言,此时就出现了一个困扰已久的难题,线上体验的同学看不懂,体验过程重重受阻,很容易遗漏掉一些环节,导致一些问题很难暴露出来。
为了这个问题,小包跟进了一段时间了,主要有两个地方的文案来源
- 代码渲染的文本
- 切图中的静态文本
大多数文本来源于是切图中,因此如何应对各种各样的切图成为难题。由此小包提出了两种解决方案:
- 同时保存两种图片资源,分别为中文和当前区服语言
- 直接进行图片翻译
第一种方案被直接拒绝了,主要由于当前的技术架构和同事们的一些抵触,业务中使用的 img、txt 信息都存储在配置平台中,存储两份就需要维护两类配置,严重增加了心智负担。
那我是这么思考的,第一次上传图片资源时,自动进行图片翻译,存储在另一个配置字段中,当开启一键翻译功能后,切换翻译后的图片。
由于是内部使用的工具,因此不需要非常准确,为了节省 token,只在第一次进行翻译。
图片翻译需要两个过程,首先进行 OCR,识别出图片中的文字;其次对识别出的文字进行翻译。
尝试了好几款 OCR 工具,都有些不尽人意,整个过程中,体验最好的是上个月PaddleOCR推出的PP-OCRv5。
在一段时间内,都一直盯着 PaddleOCR 的最新进度,昨天,百度发布并开源自研多模态文档解析模型 PaddleOCR-VL,该模型在最新 OmniDocBench V1.5 榜单中,综合性能全球第一,四项核心能力SOTA,模型已登顶HF全球第一。

这么说我的 OKR 有救了啊,快马加鞭的来试一下。
对于线上翻译,有两种指标是必须要达到的
- 文字区域识别的准确性
- 支持语言的多样性
下面逐一地体验一下
OKR 需求测试
先随便找了一张较为简单的韩服的设计稿,识别效果见右图,识别的区域非常准确,精准的区分开文字区域和图像区域。

右侧有三个 tab,其中第一个 tab:Markdown Preview 预览还支持翻译功能,翻译的文案也是非常准确的

激动了啊,感觉 PaddleOCR-VL 自己就可以解决当前的需求啊。
再换一种比较复杂的语言,阿拉伯语。支持效果也是出奇的好啊,阿语活动开发过程和体验过程是最难受的啊,目前也是最严重的卡点

对于阿语的翻译的效果也非常好,这点太惊喜了,阿服的字体又细又长,字间距又窄,能做到这么好的识别真是让人惊艳

经过一番简单的测试,PaddleOCR-VL 完全可以应对领导的 OKR 要求了(毕竟天下第一难语言阿服都可以较为完美的应对,撒花),爽啊!只需要把 demo 跑出来,就可以去申请经费啦。
更多测试
作为一个程序员,除了要干好本职的工作,更要积极的探索啊,多来几个场景,倒要看看 PaddleOCR VL 能做到什么程度。
糊图识别
日常中经常有这种需求,领导给了一张扫描了一万次或者扫描的一点都不清楚的图片,阅读难度甚大,那时候就想能不能有一种方案直接把内容提取出来。
例如就像下面的糊糊的作文,连划去的内容都成功提取出来了,牛

元素级识别
PaddleOCR-VL 除了文档解析能力,还提供了元素级识别能力,例如公式识别、表格内容识别等,诸如此类都是图片识别中的超难点。
先来个简单公式试一下

效果这么好的吗,全对了,那就要上难度了啊

黑板中的公式繁杂,混乱,且是手写体,没想到识别的整体内容都是非常准确的,只有最后一个公式错误的乘在一起了,效果有些令人惊叹啊。
总结
PaddleOCR-VL 效果真是非常惊艳啊,年底的 okr 实现的信心大增。
PaddleOCR-VL 文字识别感觉像戴了高精度眼镜一般,后续遇到类似的文字识别需求,可以首选 PaddleOCR-VL 啊。
此外小小看了一下论文,PaddleOCR-VL 采用创新的两阶段架构:第一阶段由 PP-DocLayoutV2 模型负责版面检测与阅读顺序预测;第二阶段由 PaddleOCR-VL-0.9B 识别并结构化输出文字、表格、公式、图表等元素。相较端到端方案,能够在复杂版面中更稳定、更高效,有效避免多模态模型常见的幻觉与错位问题。

PaddleOCR-VL在性能、成本和落地性上实现最佳平衡,具备强实用价值。后续遇到文字识别的需求,PaddleOCR-VL 是当之无愧的首选。
体验链接:
- Github:github.com/PaddlePaddl…
- huggingface:huggingface.co/PaddlePaddl…
- Technical report:arxiv.org/pdf/2510.14…
- Technical Blog:
- English: ernie.baidu.com/blog/posts/…
- Chinese: ernie.baidu.com/blog/zh/pos…
来源:juejin.cn/post/7561954132011483188
















