e签宝亮相2025云栖大会:以签管一体化AI合同平台,构建数字信任“中国方案”
9月24日至9月26日,以“云智一体 · 碳硅共生”为主题的2025云栖大会在杭州召开。大会通过3大主论坛+超110场聚合话题,充分展示 Agentic AI(代理式AI)和 Physical AI(物理AI)的变革性突破,探讨AI 基础设施、大模型、Agent 开发、AI 应用等多个领域和层次的话题内容。
作为亚太地区电子签名领域的领军企业,e签宝受邀出席系列重要活动。在题为《AI Agent崛起,谁会赢得下一代企业服务市场?》的分享环节中,e签宝创始人兼CEO金宏洲先生全面介绍了公司在智能合同、全球合规签署以及数字信任基础设施建设方面的最新成果。
金宏洲强调,在面向ToB的AI Agent领域,要取得成功,需要三个关键:第一,数据闭环,在用户使用过程中积累数据,反哺Agent能力提升,形成数据飞轮,这是做好Agent产品的基础。第二,有领域知识,这是垂直Agent产品做厚的价值点,也是防止被通用Agent吞没的护城河。第三,最终的护城河是用户规模和网络效应,无论是新老创业者,在AI时代都有机会,但不拥抱AI的必然会被淘汰。
大会现场还有 4 万平米的智能科技展区以及丰富的创新活动,将为每一位参会者带来密集的 AI 新思想、新发布、新形态。
人工智能+馆全面呈现了从基础大模型、开发工具到全链路Agent服务的最新进展。通义大模型系列以“全尺寸、全模态”开源矩阵亮相,展示了其在多模态理解与生成上的全面布局;魔搭社区展示其超过7万个模型与1600万开发者的生态力量;瓴羊 AgentOne 提供客服、营销等场景化服务;AI Coding 展区核心展示开发者工作范式的变化……观众可现场体验阿里云百炼、无影AgentBay等智能体开发与应用场景,感受大模型如何从工具走向“数字伙伴”。
计算馆内,硬核技术不再冰冷,而是化作可感知、可交互的趣味场景。无影展区人气爆棚,一块巴掌大的“无影魔方Ultra”竟能流畅运行对GPU要求极高的3A游戏。现场观众坐上模拟驾驶座,即可与大屏幕联动,体验极速飙车的刺激;拿上手柄,闯入《黑神话:悟空》的游戏世界,与BOSS展开激战。“东数西算”展区,戴上VR设备,观众就能“空降”至贵州、内蒙古、青海等西部数据中心,近距离观摩真实运行的机房与算力设备,直观感受国家算力网的建设成果。
前沿应用馆彻底化身为机器人的“演武场”。一位“泰拳手”机器人凌厉出击后稳健收势,被“击倒”后竟能如人类般灵活爬起;另一侧,一只机器狗如履平地般攀上高台,完成后还俏皮地模仿起花滑运动员的庆祝动作;而在模拟工厂区域,一名“工人”指挥着数十只机械臂协同作业,宛若“千手观音”。
除了这些“能动”的机器人,更具渗透力的智能体也正在融入日常生活的方方面面。e签宝展示了基于Agent技术的“统一、智能、互信”的全球签署解决方案。
e签宝展区重点呈现了签管一体化AI合同平台和全球化信任服务体系eSignGlobal。e签宝以“统一签、统一管、统一AI”为核心建设理念,致力于打造企业级统一智能签管底座,帮助企业实现跨系统、跨地域、跨法域的合同签署与管理闭环,构建以技术为驱动力的全球数字信任基础设施。
智能合同Agent
2025年,e签宝发布智能合同Agent,实现从“会聊天”到“会干活”的跨越式发展,引领行业智能化升级。e签宝创始人兼CEO金宏洲先生表示,“智能合同Agent不仅是工具,更是企业数字信任体系的‘神经中枢’”。
针对合同文本结构复杂、多栏排版、嵌套条款等行业共性难题,e签宝自主研发了合同魔方引擎,融合多模态文档解析技术、长文本Chunking技术、合同结构化规范,实现跨栏位、跨页面的精准内容提取。该引擎使合同信息识别准确率高达97%,较通用大模型性能提升10%。
基于深度任务拆解需求,e签宝打造了合同Agent Hub平台,通过“工具增强CoT”技术,结合动态私域知识库与自研工具链,实现复杂合同任务的自动化调度与精准执行。平台可动态优化企业专属知识库,并智能调用嵌入式分析、信息抽取等AI工具,确保业务流程的高效适配。
企业的统一智能签管底座
e签宝提出“统一、智能、互信”的全球签署网络理念,通过签管一体化AI合同平台,帮助企业实现合同全生命周期的数字化管理。
统一签:全流程覆盖、全场景适配、全渠道通用。企业使用e签宝后,无论合同来自于CRM、HR系统、OA还是任何业务系统,都能在一个平台上快捷完成签署。即开即用,复杂业务场景也能轻松适配。这种统一性为后续的合同管理、风险识别和AI赋能奠定了基础。
统一管:统管集团组织、统管业务资源、统管合规风控。e签宝平台能够集中管理企业的合同、印章和组织流程。AI会自动进行智能归档,高效检索合同,并提取关键信息方便后续自动化管理。智能印控中心可确保印章被安全使用,避免违规用印风险,保障体系稳定发展。
统一AI能力确保了企业合同数据的安全性与可靠性。e签宝将所有合同AI能力集中在同一平台上运行,串联全业务流程,避免数据外泄,确保在企业安全范围内处理敏感合同数据,保障安全合规。同时,这些AI能力通过API或MCP服务形式开放,可集成到企业各业务系统中。
eSignGlobal全球合规签署
面对企业全球化运营的需求,e签宝推出了eSignGlobal全球签署服务。eSignGlobal遵循全球各地的相关法规,在中国香港、新加坡、法兰克福设立独立数据中心,通过TrustHub服务连接各地权威的CA机构,确保电子签名的本地化合规性。
e签宝已经从单纯的电子签名服务发展为全方位的数字信任基础设施提供者。截至2025年8月,eSignGlobal已与16个国家和地区签约,服务覆盖全球97个国家和地区,构建起了跨地域、跨法域的“信任网络”。
根据全球权威机构MarketsandMarkets报告,e签宝以“亚太第一、全球第六”的排名跻身全球电子签名领域第一梯队,成为中国唯一跻身全球电子签名领域前十的企业。
AI普惠:让信任更简单的使命践行
“让签署更便捷,让信任更简单”是e签宝的使命。在AI技术赋能下,这一使命正得到更深层次地践行。
2023年,e签宝发布了自己的合同大模型,基于此开发的智能合同产品在商业化方面取得了显著成绩。AI收入占e签宝整体收入的比例已达到20%以上,公司从SaaS到AI的转型相当成功。
今年4月,e签宝在新加坡面向全球发布了AI合同Agent,将智能合同产品进一步升级为Chat交互为主的Agent方式。在过去的半年中,e签宝AI能力的调用量显著增长:智能归档能力达3425万次、智能台账850万次,风险审查11万次,合同比对33万次。
e签宝的AI技术正在深入生活的各个角落。年轻人利用e签宝的AI合同生成能力创建恋爱协议、分手协议、合租协议、宠物共养协议等。这些应用场景完全由用户自己创造,展现了AI技术的普惠价值
“让全球1/4的人用e签宝签合同”,这是e签宝十年前写下的愿景。经过10年努力,这一愿景已取得了显著进展。随着“技术+合规+生态”战略的持续深化,e签宝正以“中国方案”重塑全球信任体系。
如今,e签宝正在构建一个“统一、智能、互信”的全球签署网络,推动全球数字信任基础设施的演进与升级,更深层次地践行“让签署更便捷,让信任更简单”的使命。
什么是Java 的 Lambda 表达式?
一、前言
在Lambda表达式没有出现之前,很多功能的实现需要写冗长的匿名类,这样的代码不仅难以维护,还让人难以理解,用 Lambda 表达式后,代码变得更加简洁,易于维护。今天我们就来聊聊Lambda表达式的一些使用。
二、Lambda表达式的使用
我们之前的编程习惯是利用匿名类去实现一些接口的行为,比如线程的执行,然而,这种写法会导致代码膨胀和冗长,我们先来看看传统的写法:
public static void main(String[] args) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("hello world");
}
});
thread.start();
}
- Thread thread = new Thread(new Runnable() {...}); 这一行创建了一个新的线程,它接受一个
Runnable
类型的对象作为参数,这里使用的是匿名类。
其实上面那段代码是非常冗长的,我们直接来对比一下Lambda表达式的写法就知道了:
public static void main(String[] args) {
//使用Lambda表达式
Thread thread = new Thread(() -> System.out.println("hello world"));
thread.start();
}
简洁明了,只用一行简洁的代码,我们就完成了线程的创建和启动。我们来看一下Lambda表达式的标准格式:
(parameters) -> expression
说明:
(parameters)
是传递给 Lambda 表达式的参数,可以是零个或多个。例如,在我们上面的例子中传递的是() ->
,表示没有参数。->
是箭头操作符,表示 Lambda 表达式的开始,指向 Lambda 体。expression
是 Lambda 表达式的主体,也就是我们要执行的代码。
使用前提
上文中提到,lambda表达式可以在⼀定程度上简化接口的实现。但是,并不是所有的接口都可以使用lambda表达式来简化接口的实现的。
先说结论,lambda表达式,只能实现函数式接口。lambda表达式毕竟只是⼀个匿名方法。
什么是函数式接口?
函数式接口在 Java 中是指: 有且仅有一个抽象方法的接口 。
函数式接口,即适用于函数式编程场景的接口。而 Java 中的函数式编程体现就是Lambda,所以函数式接口就是可以适用于Lambda使用的接口。只有确保接口中有且仅有一个抽象方法,Java中的 Lambda才能顺利地进行推导。
Java 8
中专门为函数式接口引入了一个新的注解:@FunctionalInterface
。一旦使用该注解来定义接口,编译器将会强制检查该接口是否确实有且仅有一个抽象方法,否则将会报错。需要注意的是,即使不使用该注解,只要满足函数式接口的定义,这仍然是一个函数式接口。以下为示例代码:
@FunctionalInterface
public interface TestFunctionalInterface {
void testMethod();
}
语法简化
1.参数类型简化:由于在接口的方法中,已经定义了每⼀个参数的类型是什么。而且在使用lambda表达式实现接口的时候,必须要保证参数的数量和类 型需要和接口中的方法保持⼀致。因此,此时lambda表达式中的参数的类型可以省略不写。例子:
Test test = (name,age) -> {
System.out.println(name+" "+age);
};
2.参数小括号简化:如果方法的参数列表中的参数数量 有且只有⼀个,此时,参数列表的小括号是可以省略不写的。例子:
Test test = name -> {
System.out.println(name);
};
3.方法体部分的简化:当⼀个方法体中的逻辑,有且只有⼀句的情况下,大括号可以省略。例子:
Test test = name -> System.out.println(name);
4.return部分的简化:如果⼀个方法中唯⼀的⼀条语句是⼀个返回语句, 此时在省略掉大括号的同时, 也必须省略掉return。例子:
Test test = (a,b) -> a+b;
三、总结
本文从Lambda表达式的基础概念、基本使用几方面完整的讨论了这一Java8新增的特性,实际开发中确实为我们提供了许多便利,简化了代码。
来源:juejin.cn/post/7555051376284499978
kv数据库-leveldb (16) 跨平台封装-环境 (Env)
在上一章 过滤器策略 (FilterPolicy) 中,我们学习了 LevelDB 如何利用布隆过滤器这样的巧妙设计,在访问磁盘前就过滤掉大量不存在的键查询,从而避免了无谓的 I/O 操作。
至此,我们已经探索了 LevelDB 从用户接口到底层数据结构,再到性能优化的几乎所有核心组件。但我们忽略了一个最基础的问题:LevelDB 是一个 C++ 库,它需要运行在真实的操作系统上。它是如何在不同的操作系统(如 Linux, Windows, macOS)上读写文件、创建线程、获取当前时间的呢?难道 LevelDB 的核心代码里充斥着大量的 #ifdef __linux__
和 #ifdef _WIN32
这样的条件编译指令吗?
如果真是这样,代码将会变得难以维护,移植到新平台也会是一场噩梦。为了优雅地解决这个问题,LevelDB 引入了它的基石——环境(Env)。
什么是环境 (Env)?
Env
是对操作系统底层功能的一个抽象层。你可以把它想象成一个万能工具箱。LevelDB 的核心逻辑(比如 合并 (Compaction) 线程、排序字符串表 (SSTable) 的读写)在工作时,并不直接调用操作系统的原生函数(如 open
, read
, CreateFileW
),而是从这个标准的“工具箱”里取工具来用。
这个工具箱里有什么呢?它定义了一套标准的工具接口:
NewWritableFile(...)
: 给我一把能写文件的“扳手”。StartThread(...)
: 给我一个能启动新线程的“马达”。NowMicros()
: 给我一个能读取当前微秒时间的“秒表”。SleepForMicroseconds(...)
: 让我休息一下的“闹钟”。
有了这个标准的工具箱接口,LevelDB 的核心逻辑就可以完全不关心自己到底运行在哪个操作系统上。它只管向 Env
索要工具。
那么,具体的工具是从哪里来的呢?LevelDB 为每个它支持的平台,都提供了一个具体的工具箱实现。
- 在 Linux/macOS (POSIX) 上,它提供一个
PosixEnv
。这个工具箱里的“扳手”是用open()
和write()
实现的。 - 在 Windows 上,它提供一个
WindowsEnv
。这个工具箱里的“扳手”则是用CreateFileA()
和WriteFile()
实现的。
这种设计带来了巨大的好处:可移植性。当需要将 LevelDB 移植到一个新的操作系统(比如 Fuchsia)时,开发者几乎不需要修改任何核心逻辑代码。他们只需要为新平台实现一个新的 Env
子类——也就是打造一个新的、符合标准的工具箱——然后整个 LevelDB 就可以在这个新平台上运行了。
graph BT
subgraph "具体的平台实现"
C["PosixEnv (Linux, macOS)"]
D["WindowsEnv (Windows)"]
E["MemEnv (用于测试)"]
end
subgraph "LevelDB 核心逻辑"
A["DBImpl, Compaction, SSTable, 等..."]
end
subgraph "Env 抽象接口 (标准工具箱)"
B(Env)
B -- "提供 NewWritableFile()" --> A
B -- "提供 StartThread()" --> A
end
A -- "调用" --> B
C -- "实现" --> B
D -- "实现" --o B
E -- "实现" --o B
style A fill:#cde
style B fill:#f9f
我们如何使用 Env
?
对于绝大多数用户来说,你几乎不需要直接与 Env
交互。LevelDB 会在后台为你处理好一切。
当你打开一个数据库时,选项 (Options) 对象里有一个 env
成员。如果你不设置它,它的默认值就是 Env::Default()
。
Env::Default()
是一个静态方法,它会根据编译时确定的操作系统,返回一个对应平台的 Env
单例对象。在 Linux 上,它返回 PosixEnv
的实例;在 Windows 上,它返回 WindowsEnv
的实例。
#include "leveldb/db.h"
#include "leveldb/env.h"
int main() {
leveldb::Options options;
// 我们没有设置 options.env,
// 所以 LevelDB 会自动使用 Env::Default()
// 在 Linux 上就是 PosixEnv,在 Windows 上就是 WindowsEnv
leveldb::DB* db;
// DB::Open 内部会从 options.env 获取环境对象,
// 并在需要时用它来操作文件、启动线程等。
leveldb::Status status = leveldb::DB::Open(options, "/tmp/testdb", &db);
// ...
delete db;
return 0;
}
所以,Env
虽然至关重要,但它就像空气一样,默默地支撑着一切,而我们通常感觉不到它的存在。
Env
内部是如何工作的?
Env
的强大之处在于它的多态设计。Env
本身是一个抽象基类,定义了所有平台都需要提供的功能接口。
1. Env
的接口定义 (include/leveldb/env.h
)
Env
类定义了许多纯虚函数(以 = 0
结尾),这意味着任何想要成为一个“合格” Env
的子类都必须实现这些函数。
// 来自 include/leveldb/env.h (简化后)
class LEVELDB_EXPORT Env {
public:
virtual ~Env();
// 返回一个适合当前操作系统的默认 Env
static Env* Default();
// 创建一个用于顺序读取的文件对象
virtual Status NewSequentialFile(const std::string& fname,
SequentialFile** result) = 0;
// 创建一个用于随机读取的文件对象
virtual Status NewRandomAccessFile(const std::string& fname,
RandomAccessFile** result) = 0;
// 创建一个用于写操作的文件对象
virtual Status NewWritableFile(const std::string& fname,
WritableFile** result) = 0;
// 启动一个新线程
virtual void StartThread(void (*function)(void* arg), void* arg) = 0;
// 返回当前的微秒时间戳
virtual uint64_t NowMicros() = 0;
// ... 还有很多其他接口, 如文件删除、目录创建等 ...
};
这个接口就是 LevelDB 核心逻辑所依赖的“标准工具箱”的蓝图。
2. POSIX 平台的实现 (util/env_posix.cc
)
PosixEnv
类继承自 Env
,并使用 POSIX 标准的系统调用来实现这些接口。
让我们看看 NewWritableFile
的实现:
// 来自 util/env_posix.cc (简化后)
Status PosixEnv::NewWritableFile(const std::string& filename,
WritableFile** result) {
// 使用 POSIX 的 open() 系统调用来创建文件
int fd = ::open(filename.c_str(),
O_TRUNC | O_WRONLY | O_CREAT, 0644);
if (fd < 0) {
*result = nullptr;
return PosixError(filename, errno); // 返回错误状态
}
// 创建一个 PosixWritableFile 对象来包装文件描述符
*result = new PosixWritableFile(filename, fd);
return Status::OK();
}
这里,PosixEnv
将对“写文件”这个抽象请求,转换成了对 ::open()
这个具体的 POSIX 系统调用。
3. Windows 平台的实现 (util/env_windows.cc
)
与之对应,WindowsEnv
则使用 Windows API 来实现同样的功能。
// 来自 util/env_windows.cc (简化后)
Status WindowsEnv::NewWritableFile(const std::string& filename,
WritableFile** result) {
// 使用 Windows API 的 CreateFileA() 来创建文件
ScopedHandle handle = ::CreateFileA(
filename.c_str(), GENERIC_WRITE, /*share_mode=*/0,
/*security=*/nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL,
/*template=*/nullptr);
if (!handle.is_valid()) {
*result = nullptr;
return WindowsError(filename, ::GetLastError());
}
// 创建一个 WindowsWritableFile 对象来包装文件句柄
*result = new WindowsWritableFile(filename, std::move(handle));
return Status::OK();
}
WindowsEnv
将同样的抽象请求,转换成了对 ::CreateFileA()
这个具体的 Windows API 调用。LevelDB 的上层代码完全不知道也不关心这些差异。
Env::Default()
的魔法
Env::Default()
是如何知道该返回哪个实现的呢?这通常是通过编译时的预处理宏来完成的。
// 位于 env.cc 或平台相关的 env_*.cc 文件中 (概念简化)
#include "leveldb/env.h"
#if defined(LEVELDB_PLATFORM_POSIX)
#include "util/env_posix.h"
#elif defined(LEVELDB_PLATFORM_WINDOWS)
#include "util/env_windows.h"
#endif
namespace leveldb {
Env* Env::Default() {
// 静态变量保证了全局只有一个实例
static SingletonEnv<
#if defined(LEVELDB_PLATFORM_POSIX)
PosixEnv
#elif defined(LEVELDB_PLATFORM_WINDOWS)
WindowsEnv
#else
// Fallback or error for unsupported platforms
#endif
> env_container;
return env_container.env();
}
} // namespace leveldb
在编译时,构建系统会根据目标平台定义 LEVELDB_PLATFORM_POSIX
或 LEVELDB_PLATFORM_WINDOWS
,从而使得 Env::Default()
的代码在编译后,就“硬编码”为返回正确的平台特定 Env
实例。
用于测试的 MemEnv
Env
抽象层的另一个巨大好处是可测试性。LevelDB 提供了一个完全在内存中模拟文件系统的 MemEnv
(位于 helpers/memenv/memenv.h
)。在进行单元测试时,可以使用 MemEnv
来代替真实的 PosixEnv
或 WindowsEnv
。这使得测试可以:
- 非常快:因为没有实际的磁盘 I/O。
- 完全隔离:不会在文件系统上留下任何垃圾文件。
- 可控:可以方便地模拟文件读写错误等异常情况。
总结与回顾
在本章中,我们探索了 LevelDB 的根基——Env
环境抽象层。
Env
是一个对操作系统功能的抽象接口,它将 LevelDB 的核心逻辑与具体的平台实现解耦。- 这个“万能工具箱”的设计使得 LevelDB 具有极高的可移植性。
- 我们通常通过
Env::Default()
间接使用它,它会自动返回适合当前操作系统的Env
实现(如PosixEnv
或WindowsEnv
)。 Env
的抽象也使得编写快速、隔离的单元测试成为可能,例如使用内存文件系统MemEnv
。
至此,我们已经完成了 LevelDB 核心概念的探索之旅!让我们一起回顾一下走过的路:
我们从最基础的数据表示 数据切片 (Slice) 开始,学习了如何通过 选项 (Options)] 配置我们的 数据库实例 (DB)。我们掌握了如何使用 批量写 (WriteBatch) 和 迭代器 (Iterator) 与数据库高效交互。
然后,我们深入内部,揭开了数据持久化的第一道防线 预写日志 (Log / WAL),看到了数据在内存中的临时住所 内存表 (MemTable),并最终见证了它们在磁盘上的永久归宿 排序字符串表 (SSTable)。我们理解了 LevelDB 是如何通过后台的 合并 (Compaction) 任务来保持整洁,以及如何通过 版本集 (VersionSet / Version) 来管理数据快照。
我们还深入到了 SSTable
的微观世界,探索了 数据块 (Block) 的紧凑结构,并了解了 缓存 (Cache) 如何为读取加速。我们学会了用 比较器 (Comparator) 定义秩序,用 过滤器策略 (FilterPolicy) 避免无效查询。最后,我们认识了支撑这一切的平台基石 环境 (Env)。
希望这个系列能帮助你建立起对 LevelDB 内部工作原理的清晰理解。现在,你不仅知道如何使用 LevelDB,更重要的是,你明白了它为何能如此高效、稳定地工作。恭喜你完成了这段旅程!
来源:juejin.cn/post/7554961105325129771
Spec-Kit WBS:技术团队的项目管理新方式
Spec-Kit WBS:技术团队的项目管理新方式
📋 WBS基本概念
什么是WBS?
WBS (Work Breakdown Structure) = 工作分解结构
- 定义: 将项目可交付成果和项目工作分解成较小的、更易于管理的组件的过程
- 目标: 确保项目范围完整,工作不遗漏,便于估算、计划、执行和控制
- 本质: 把复杂项目像搭积木一样,一层一层地分解成可管理的小任务
WBS的核心价值
- 完整性保证 - 确保所有工作都被识别和分解
- 可管理性 - 将复杂项目分解为可管理的小任务
- 责任分配 - 每个任务可以分配给特定的人员
- 进度跟踪 - 可以跟踪每个任务的完成状态
- 成本估算 - 每个任务可以估算时间和成本
🔄 WBS与PDCA的关系
PDCA循环在项目管理中的应用
Plan (计划)
├── 项目范围定义
├── WBS创建 ← 关键工具
├── 时间估算
├── 资源分配
└── 风险管理
Do (执行)
├── 按WBS执行任务
├── 团队协作
├── 质量保证
└── 进度跟踪
Check (检查)
├── 里程碑检查
├── 质量审查
├── 进度评估
└── 偏差分析
Act (行动)
├── 纠正措施
├── 预防措施
├── 经验总结
└── 流程改进
WBS与PDCA的协同效应
关键理解: WBS是PDCA循环中Plan阶段的核心工具,它将抽象的项目目标转化为具体的、可执行的任务,确保项目管理的系统性和完整性。
🏗️ WBS实际示例:开发一个电商网站
1. 项目概述
项目名称: 开发一个在线购物网站
项目目标: 让用户可以浏览商品、下单购买、管理账户
2. WBS分解过程
第一层:主要阶段
电商网站项目
├── 1. 需求分析阶段
├── 2. 设计阶段
├── 3. 开发阶段
├── 4. 测试阶段
└── 5. 部署上线阶段
第二层:每个阶段的工作包
电商网站项目
├── 1. 需求分析阶段
│ ├── 1.1 用户需求调研
│ ├── 1.2 功能需求分析
│ └── 1.3 技术需求分析
├── 2. 设计阶段
│ ├── 2.1 界面设计
│ ├── 2.2 数据库设计
│ └── 2.3 系统架构设计
├── 3. 开发阶段
│ ├── 3.1 前端开发
│ ├── 3.2 后端开发
│ └── 3.3 数据库开发
├── 4. 测试阶段
│ ├── 4.1 功能测试
│ ├── 4.2 性能测试
│ └── 4.3 安全测试
└── 5. 部署上线阶段
├── 5.1 服务器配置
├── 5.2 数据迁移
└── 5.3 上线发布
第三层:具体活动(最详细的任务)
电商网站项目
├── 1. 需求分析阶段
│ ├── 1.1 用户需求调研
│ │ ├── 1.1.1 设计用户问卷
│ │ ├── 1.1.2 进行用户访谈
│ │ └── 1.1.3 分析用户反馈
│ ├── 1.2 功能需求分析
│ │ ├── 1.2.1 列出所有功能点
│ │ ├── 1.2.2 确定功能优先级
│ │ └── 1.2.3 编写需求文档
│ └── 1.3 技术需求分析
│ ├── 1.3.1 确定技术栈
│ ├── 1.3.2 评估性能要求
│ └── 1.3.3 制定技术方案
├── 2. 设计阶段
│ ├── 2.1 界面设计
│ │ ├── 2.1.1 设计首页布局
│ │ ├── 2.1.2 设计商品列表页
│ │ ├── 2.1.3 设计购物车页面
│ │ └── 2.1.4 设计用户中心
│ ├── 2.2 数据库设计
│ │ ├── 2.2.1 设计用户表
│ │ ├── 2.2.2 设计商品表
│ │ ├── 2.2.3 设计订单表
│ │ └── 2.2.4 设计购物车表
│ └── 2.3 系统架构设计
│ ├── 2.3.1 设计整体架构
│ ├── 2.3.2 设计API接口
│ └── 2.3.3 设计安全方案
├── 3. 开发阶段
│ ├── 3.1 前端开发
│ │ ├── 3.1.1 搭建前端框架
│ │ ├── 3.1.2 开发首页组件
│ │ ├── 3.1.3 开发商品展示组件
│ │ ├── 3.1.4 开发购物车组件
│ │ └── 3.1.5 开发用户中心组件
│ ├── 3.2 后端开发
│ │ ├── 3.2.1 搭建后端框架
│ │ ├── 3.2.2 开发用户管理API
│ │ ├── 3.2.3 开发商品管理API
│ │ ├── 3.2.4 开发订单管理API
│ │ └── 3.2.5 开发支付接口
│ └── 3.3 数据库开发
│ ├── 3.3.1 创建数据库
│ ├── 3.3.2 创建数据表
│ ├── 3.3.3 插入测试数据
│ └── 3.3.4 优化数据库性能
├── 4. 测试阶段
│ ├── 4.1 功能测试
│ │ ├── 4.1.1 测试用户注册登录
│ │ ├── 4.1.2 测试商品浏览功能
│ │ ├── 4.1.3 测试购物车功能
│ │ └── 4.1.4 测试下单支付功能
│ ├── 4.2 性能测试
│ │ ├── 4.2.1 测试页面加载速度
│ │ ├── 4.2.2 测试并发用户处理
│ │ └── 4.2.3 测试数据库查询性能
│ └── 4.3 安全测试
│ ├── 4.3.1 测试SQL注入防护
│ ├── 4.3.2 测试XSS攻击防护
│ └── 4.3.3 测试用户数据安全
└── 5. 部署上线阶段
├── 5.1 服务器配置
│ ├── 5.1.1 购买云服务器
│ ├── 5.1.2 配置服务器环境
│ └── 5.1.3 安装必要软件
├── 5.2 数据迁移
│ ├── 5.2.1 备份开发数据
│ ├── 5.2.2 迁移到生产环境
│ └── 5.2.3 验证数据完整性
└── 5.3 上线发布
├── 5.3.1 部署代码到服务器
├── 5.3.2 配置域名和SSL
└── 5.3.3 监控系统运行状态
3. WBS编号规则
1. 第一层:1, 2, 3, 4, 5 (主要阶段)
2. 第二层:1.1, 1.2, 1.3 (工作包)
3. 第三层:1.1.1, 1.1.2, 1.1.3 (具体活动)
4. WBS与PDCA的结合
Plan阶段 (创建WBS)
✅ 1.1.1 设计用户问卷
✅ 1.1.2 进行用户访谈
✅ 1.1.3 分析用户反馈
Do阶段 (执行WBS)
🔄 2.1.1 设计首页布局
🔄 2.1.2 设计商品列表页
⏳ 2.1.3 设计购物车页面
Check阶段 (检查WBS)
✅ 4.1.1 测试用户注册登录 - 通过
✅ 4.1.2 测试商品浏览功能 - 通过
❌ 4.1.3 测试购物车功能 - 发现bug
Act阶段 (改进WBS)
🔧 修复购物车bug
📝 更新测试用例
🔄 重新测试购物车功能
5. 实际项目管理中的应用
任务分配表
任务编号 | 任务名称 | 负责人 | 开始时间 | 结束时间 | 状态 |
---|---|---|---|---|---|
1.1.1 | 设计用户问卷 | 产品经理 | 2024-01-01 | 2024-01-03 | ✅完成 |
1.1.2 | 进行用户访谈 | 产品经理 | 2024-01-04 | 2024-01-10 | 🔄进行中 |
1.1.3 | 分析用户反馈 | 产品经理 | 2024-01-11 | 2024-01-15 | ⏳待开始 |
2.1.1 | 设计首页布局 | UI设计师 | 2024-01-16 | 2024-01-20 | ⏳待开始 |
进度跟踪
项目进度: 15%
├── 需求分析阶段: 60% (3/5个任务完成)
├── 设计阶段: 0% (0/8个任务开始)
├── 开发阶段: 0% (0/12个任务开始)
├── 测试阶段: 0% (0/9个任务开始)
└── 部署阶段: 0% (0/8个任务开始)
🎯 WBS的优势体现
A. 完整性
- ✅ 确保所有工作都被识别
- ✅ 不会遗漏重要任务
- ✅ 项目范围清晰
B. 可管理性
- ✅ 每个任务都有明确的交付物
- ✅ 任务大小适中,便于管理
- ✅ 可以分配给不同的人员
C. 可跟踪性
- ✅ 可以跟踪每个任务的进度
- ✅ 识别瓶颈和风险点
- ✅ 及时调整计划
D. 可估算性
- ✅ 每个任务可以估算时间和成本
- ✅ 便于制定项目预算
- ✅ 便于资源分配
E. 责任分配
- ✅ 每个任务可以分配给特定的人员
- ✅ 明确的责任分工
- ✅ 便于团队协作
🔧 WBS在Spec-Kit中的应用
传统WBS vs Spec-Kit WBS
核心区别对比
分解思路
- 传统WBS:按项目阶段分解(需求→设计→开发→测试→部署)
- Spec-Kit WBS:按技术实现分解(环境→测试→实现→集成→完善)
测试策略
- 传统WBS:测试放在最后,问题发现太晚
- Spec-Kit WBS:测试先行(TDD),质量更有保障
任务标识
- 传统WBS:无特殊标识,按顺序执行
- Spec-Kit WBS:[P]标识并行任务,提高开发效率
适用场景
- 传统WBS:通用项目管理(建筑、市场、产品发布)
- Spec-Kit WBS:软件开发项目(API开发、系统集成、技术重构)
文件管理
- 传统WBS:通用描述,适合各种项目
- Spec-Kit WBS:具体文件路径,便于开发执行
传统WBS
电商网站项目
├── 1. 需求分析 (5个任务)
├── 2. 设计 (8个任务)
├── 3. 开发 (12个任务)
├── 4. 测试 (9个任务)
└── 5. 部署 (8个任务)
Spec-Kit的WBS
联调12个接口
├── 阶段 3.1: 环境设置 (3个任务)
├── 阶段 3.2: 测试先行 (13个任务) [P]
├── 阶段 3.3: 核心实现 (14个任务)
├── 阶段 3.4: 集成 (4个任务)
└── 阶段 3.5: 完善 (4个任务)
Spec-Kit WBS的特点
- 技术实现导向 - 更注重技术实现细节
- 测试先行 - 强调TDD (Test-Driven Development)
- 并行任务标识 - 明确标识可并行执行的任务 [P]
- 具体文件路径 - 每个任务都有明确的文件路径
- 依赖关系管理 - 清晰定义任务间的依赖关系
Spec-Kit WBS示例
联调12个接口
├── 阶段 3.1: 环境与项目设置
│ ├── T001: 创建目录结构
│ ├── T002: 初始化项目
│ └── T003 [P]: 配置工具
├── 阶段 3.2: 测试先行 (TDD)
│ ├── T004-T015: 12个接口的合约测试 [P]
│ └── T016: 集成测试
├── 阶段 3.3: 核心实现
│ ├── T017-T018: 数据模型和服务层
│ └── T019-T030: 12个接口实现
├── 阶段 3.4: 集成
│ ├── T031-T033: 服务连接和配置
│ └── T034: 集成测试
└── 阶段 3.5: 完善
├── T035-T037: 测试和文档
└── T038: 最终验证
📝 总结
WBS是项目管理的核心工具,它将复杂的项目分解为可管理的小任务。与PDCA循环结合使用,可以确保项目的系统性、完整性和可跟踪性。
关键要点:
- WBS是PDCA循环中Plan阶段的核心工具
- 通过层次化分解确保项目完整性
- 每个任务都有明确的交付物和责任人
- 支持进度跟踪和风险管理
- 在Spec-Kit中与规范驱动开发完美结合
实际应用建议:
- 从项目目标开始,逐层分解
- 确保每个任务都有明确的交付物
- 合理分配任务给团队成员
- 定期检查进度,及时调整计划
- 总结经验,持续改进WBS模板
Changelog
V1.0 (2025-09-29)
- [新增] 初稿完成 - 文档基础框架建立
- [新增] 初稿完成 - 基础版本
- [新增] 添加WBS基本概念和实际应用示例
- [新增] 新增传统WBS vs Spec-Kit WBS对比分析
- [新增] 完善文档结构和可读性 - 用户体验
来源:juejin.cn/post/7555327916483870774
SpringBoot多模板引擎整合难题?一篇搞定JSP、Freemarker与Thymeleaf!
关注我的公众号:【编程朝花夕拾】,可获取首发内容。
01 引言
在现代Web
应用开发中,模板引擎是实现前后端分离和视图渲染的重要工具。SpringBoot
作为流行的Java
开发框架,天然支持多种模板引擎。
每一个项目使用单一的模板引擎是标准输出。但是,总有一些老项目经历多轮迭代,人员更替,不同的开发都只是用自己熟悉的模版引擎,导致一个项目中包含了多种模板引擎。从而相互影响,甚至出现异常。这也是小编正在经历的痛苦。
本文将详细介绍如何在SpringBoot项目中同时集成JSP、Freemarker和Thymeleaf三种模板引擎,包括配置方法、使用场景、常见问题及解决方案。
02 项目搭建
本文基于Springboot 3.0.13
,因为不同版本(2.x
)对于部分包的做了更改。由于JSP
的配置会影响其他的模板引擎,所以JSP
的配置,放到最后说明。
2.1 Maven依赖
<!-- freemarker 模版引擎 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-freemarker</artifactId>
</dependency>
<!-- thymeleaf 模版引擎 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
2.3 配置
#配置freemarker
spring.freemarker.template-loader-path=classpath:/templates/ftl/
spring.freemarker.suffix=.ftl
spring.freemarker.cache=false
# 配置thymeleaf
spring.thymeleaf.prefix=classpath:/templates/html/
spring.thymeleaf.suffix=.html
spring.thymeleaf.cache=false
2.4 最佳实践
页面
控制层
@Controller
@RequestMapping("/page")
public class PageController {
@RequestMapping("{engine}")
public String toPage(@PathVariable("engine") String engine, Model model) {
model.addAttribute("date", new Date());
return engine + "_index";
}
}
2.5 测试
到这里,会发现一切顺利。Thymeleaf
和Freemarker
都可以顺利解析。但是,引入JSP
之后,发现不能生效。
03 SpringBoot
继续集成JSP
3.1 Maven依赖
<!-- JSP支持 -->
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-jasper</artifactId>
</dependency>
<!-- jstl 工具 -->
<dependency>
<groupId>jakarta.servlet.jsp.jstl</groupId>
<artifactId>jakarta.servlet.jsp.jstl-api</artifactId>
</dependency>
<dependency>
<groupId>org.glassfish.web</groupId>
<artifactId>jakarta.servlet.jsp.jstl</artifactId>
</dependency>
这里要说明的jstl
,低版本(3.x
一下)的需要引入:
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>jstl</artifactId>
<version>1.2</version>
</dependency>
具体的依赖可以在Springboot
官方文档中查看。
3.2 配置
spring.mvc.view.prefix=/WEB-INF/jsp/
spring.mvc.view.suffix=.jsp
3.3 创建包结构
因为SpringBoot
默认不支持JSP,所以需要我们自己配置支持JSP。
包的路径地址:\src\main\webapp\WEB-INF
3.4 修改pom
打包
在build
下增加resource
<resources>
<!-- 打包时将jsp文件拷贝到META-INF目录下-->
<resource>
<!-- 指定处理哪个目录下的资源文件 -->
<directory>src/main/webapp</directory>
<!--注意此次必须要放在此目录下才能被访问到-->
<targetPath>META-INF/resources</targetPath>
<includes>
<include>**/**</include>
</includes>
</resource>
</resources>
3.5 测试
其他两个不受影响,但是发现配置的JSP
并不生效,根据报错信息来看,默认使用了Thymeleaf
解析的。
04 源码追踪
关键的类:org.springframework.web.servlet.view.ContentNegotiatingViewResolver
断点调试发现,图中①根据jsp_index
视图,可以发现两个候选的View
:ThymelearView
和JstlView
。
图中②获取最优的视图返回了ThymelearView
,从而解析错误。从getBestView()
源码可以看到,仅仅做了遍历操作,并没有个给句特殊的规则去取。如图:
所以影响视图解析器的就是候选视图的顺序。
我们继续看候选视图的取值:
这里仍是只是遍历,我们需要继续追溯this.viewResolvers
的来源:
关键代码AnnotationAwareOrderComparator.sort(this.viewResolvers)
会对所有的视图排序,所以我们只需要指定JSP
的视图为第一个就可以了。
05 配置JSP
视图的顺序
因为JSP
的视图使用的是InternalResourceViewResolver
,所以我们只需要设置其顺序即可。
@Configuration
public class BeanConfig {
@Autowired
InternalResourceViewResolver resolver;
@PostConstruct
public void init() {
resolver.setOrder(1);
}
由于其他的视图解析器默认是最级别,所以这里的设置只要比Integr.MAX小即可。
测试
我们发现源代码已经将JstlView
变成了第一个,最优的视图自然也选择了JstlView
,如图:
效果
我们发现JSP
是正常显示了,但是其他两个又不好了。
真实让人头大!
06 解决JSP
混合问题
6.1 解决方案
其实这里要使用一个属性可以永久的解决问题:viewName
,
每一个ViewResolver
都有一段关键的源码:
这里是匹配关系,可以通过配置的view-names
过滤不符合条件的视图:
6.2 重新修改配置
###配置freemarker
spring.freemarker.template-loader-path=classpath:/templates/
spring.freemarker.view-names=ftl/*
spring.freemarker.suffix=.ftl
spring.freemarker.cache=false
#
### 配置thymeleaf
spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.view-names=html/*
spring.thymeleaf.suffix=.html
spring.thymeleaf.cache=false
##
### 配置JSP
spring.mvc.view.prefix=/WEB-INF/
spring.mvc.view.suffix=.jsp
这里的和之前不同的就是增加了spring.thymeleaf.view-names
、spring.freemarker.view-names
,并且classpath
的路径少了一部分移动到view-names
里面了。
JSP
的spring.mvc.view.prefix
同样少了一部分需要配置。
6.3 重新修改Java配置
@Configuration
public class BeanConfig {
@Autowired
InternalResourceViewResolver resolver;
@PostConstruct
public void init() {
resolver.setViewNames("jsp/*");
}
也可以使用Bean
定义。使用Bean
定义需要删除配置文件关于JSP
的配置。
@Bean
public ViewResolver jspViewResolver() {
InternalResourceViewResolver resolver = new InternalResourceViewResolver();
resolver.setPrefix("/WEB-INF/");
resolver.setSuffix(".jsp");
resolver.setViewNames("jsp/*");
return resolver;
}
6.4 修改控制层
@Controller
@RequestMapping("/page")
public class PageController {
@RequestMapping("{engine}")
public String toFtl(@PathVariable("engine") String engine, Model model) {
model.addAttribute("date", new Date());
return engine + "/" + engine + "_index";
}
}
6.5 效果
来源:juejin.cn/post/7555065224802861066
JVM内存公寓清洁指南:G1与ZGC清洁工大比拼
JVM内存公寓清洁指南:G1与ZGC清洁工大比拼 🧹
引言:当内存公寓遇上"清洁工天团"
当 Java 应用中的对象在"内存公寓"里肆意"开派对"后,未被引用的对象便成了散落各处的"垃圾",此时就需要专业的"清洁团队"——垃圾回收器登场。JVM 内存区域如同"内存公寓"的不同房间,其中线程共享区的堆是最大的"活动空间",按对象生命周期分为新生代(Eden 区占 8/10、Survivor 区各占 1/10)和老年代,如同公寓的"青年宿舍"与"长者公寓";方法区(元空间)则类似"物业档案室",存储类元数据等。
为什么有的"清洁工"习惯按区域分片打扫,有的却能以"闪电速度"完成全屋清洁?这就不得不提到 G1 和 ZGC 两位"王牌清洁工"——前者以"分区管理"策略著称,后者则追求"低延迟闪电清洁",其设计目标是将应用暂停(STW)时间控制在 10ms 以内,且停顿时间不会随堆大小或活跃对象增加而延长。
核心差异预告:G1 采用分代分区管理模式,擅长平衡吞吐量与停顿;ZGC 则通过创新算法突破堆大小限制,主打"毫秒级响应"。本文将拆解两者的"清洁秘籍"(垃圾回收算法)与"工资参数"(调优参数),揭秘谁能成为"内存公寓"的最优解。
G1回收器:精打细算的"分区清洁队长"
Garbage-First (G1) 垃圾收集器作为默认低延迟收集器,其核心设计理念可类比为"内存公寓"的分区清洁管理系统。与传统收集器将堆内存划分为固定大小新生代与老年代的方式不同,G1采用"分区垃圾袋"式的Region机制,将整个堆内存划分为最多2048个独立Region,每个Region容量可在1MB至32MB之间动态调整(默认根据堆大小自动选择)。这些Region并非固定归属新生代或老年代,而是根据应用内存分配模式动态标记为Eden区、Survivor区或Old区,实现内存资源的弹性调度。这种动态分区机制使G1能够灵活应对不同类型应用的内存需求,尤其适用于堆内存4GB至32GB的常规企业应用场景。
G1的垃圾回收策略采用"混合清洁模式"(Mixed GC),其工作流程可形象比喻为"先集中清理垃圾密集的房间(新生代),再抽空打扫老房间(老年代)"。G1优先对新生代Region执行Minor GC,通过复制算法快速回收短期存活对象;当老年代Region占比达到参数-XX:InitiatingHeapOccupancyPercent(默认45%)设定的阈值时,触发Mixed GC,在新生代收集的同时,选取部分垃圾占比高的老年代Region进行回收。这种选择性回收策略使G1能够集中资源处理垃圾密集区域,从而更精准地控制停顿时间,避免传统收集器对整个老年代进行全区域扫描的高昂成本。
在实际调优中,启用G1需通过JVM参数显式配置,基础命令示例如下:java -XX:+UseG1GC -Xms4g -Xmx4g -XX:MaxGCPauseMillis=200 -jar app.jar
。某电商交易系统优化案例显示,在未调优状态下GC停顿时间常达300ms,通过设置MaxGCPauseMillis=200并调整Region大小后,停顿时间稳定降至180ms,同时吞吐量保持98%以上。核心调优参数及说明如下表所示:
参数 | 作用 | 幽默解读 |
---|---|---|
-XX:+UseG1GC | 启用G1回收器 | "任命G1为清洁队长" |
-Xms/-Xmx | 初始/最大堆大小 | "初始/最大垃圾袋容量" |
-XX:MaxGCPauseMillis | 目标停顿时间 | "要求每次清洁不超过X毫秒" |
-XX:G1HeapRegionSize | Region大小 | "每个垃圾袋的容量" |
💡 调优技巧:设置合理的停顿目标(如200ms)是平衡延迟与吞吐量的关键。G1会根据历史回收数据动态调整Region回收数量,过度严苛的停顿目标(如50ms)会迫使收集器频繁进行小范围压缩,反而导致GC次数激增。建议通过-XX:G1HeapRegionSize参数将Region大小设置为堆内存的1/2048,确保每个Region既能容纳大对象,又避免过小Region导致的管理开销。
🚨 常见误区:不要将-Xms和-Xmx设置为不同值!动态堆扩容会导致"内存公寓"频繁调整垃圾袋大小,引发额外性能开销,就像清洁工频繁更换垃圾桶尺寸一样影响效率。
以下是G1调优前后的GC日志对比:
# 调优前(停顿300ms)
[GC pause (G1 Evacuation Pause) (young) 1024M->768M(4096M) 302.5ms]
# 调优后(停顿180ms)
[GC pause (G1 Evacuation Pause) (young) 1024M->768M(4096M) 178.3ms]
ZGC回收器:闪电般的"极速清洁特工"
ZGC作为JVM内存管理的"极速清洁特工",其核心竞争力体现在毫秒级停顿与超大堆支持两大特性上。设计目标明确为停顿时间不超过10ms,且该指标不会随堆大小或活跃对象数量的增加而退化,从根本上解决了传统回收器在大堆场景下的停顿痛点。
ZGC的"闪电清洁"秘籍
ZGC实现"边打扫边让住户正常活动"的核心技术在于染色指针与内存多重映射。染色指针技术在64位指针中嵌入4位元数据,可实时存储对象的标记状态与重定位信息,相当于清洁工佩戴的"AR智能眼镜",能在不中断住户活动的情况下完成垃圾标记。内存多重映射则通过将物理内存同时映射到Marked0、Marked1、Remapped三个虚拟视图,实现并发重定位操作,确保回收过程与应用线程几乎无干扰。实测数据显示,ZGC停顿时间平均仅1.09ms,99.9%分位值为1.66ms,远低于10ms的设计阈值。
大堆管理:从16MB到16TB的"超级公寓"
与G1固定大小的Region(最大32MB)不同,ZGC采用动态Region机制,将内存划分为小页(2MB)、中页(32MB)和大页(N×2MB,最大支持16TB),如同"能伸缩的智能垃圾袋",可根据对象大小自动调整容量。这种设计使其支持从8MB到16TB的堆内存范围,而G1在堆大小超过64GB时易出现停顿失控[1]。动态Region不仅提升了内存利用率,还解决了大对象分配效率问题,实现"小到零食包装,大到家具"的全覆盖管理。
调优参数实战
启用与核心参数配置
启用ZGC需在JDK15+环境中使用以下命令:
java -XX:+UseZGC -Xms16g -Xmx16g -XX:ZCollectionInterval=60 -jar app.jar
该配置指定16GB堆空间(初始与最大堆相同),至少每60秒执行一次回收。以下为核心参数说明:
参数 | 作用 | 幽默解读 |
---|---|---|
-XX:+UseZGC | 启用ZGC回收器 | "召唤闪电清洁特工" |
-Xms/-Xmx | 初始/最大堆大小 | "清洁区域的固定边界" |
-XX:ZCollectionInterval | 最小回收间隔 | "至少每隔X秒打扫一次" |
-XX:ZAllocationSpikeTolerance | 分配尖峰容忍度 | "允许临时垃圾堆积倍数" |
💡 调优黄金法则:ZGC在32GB以上大堆场景优势显著,此时其停顿稳定性远超G1;而8GB以下小堆场景建议保留G1,因ZGC的吞吐量损失(通常<15%)在小堆下性价比更低。
🚨 误区警示:ZGC在JDK15才正式发布,JDK11-14为实验性版本,存在功能限制;JDK11以下版本完全不支持,切勿尝试在低版本JDK中启用。
性能对比与GC日志示例
在64GB堆环境下,ZGC与G1的表现差异显著:
# ZGC日志(停顿8ms)
[0.875s][info][gc] GC(0) Pause Relocate Start 1.56ms
[0.877s][info][gc] GC(0) Pause Relocate End 0.89ms
# G1日志(停顿520ms)
[GC pause (G1 Evacuation Pause) (mixed) 5890M->4520M(65536M) 520.3ms]
某支付系统迁移案例显示,将G1替换为ZGC后,峰值GC停顿从280ms降至8ms,交易成功率提升0.5%,验证了ZGC在关键业务场景的性能优势。
G1 vs ZGC:清洁团队终极PK
衡量垃圾收集器的三项重要指标包括内存占用(Footprint)、吞吐量(Throughput)和延迟(Latency)。吞吐量和延迟通常不可兼得,关注吞吐量的收集器和关注延迟的收集器在算法选择上存在差异。以下从核心能力与场景适配两方面对比 G1 与 ZGC 的差异:
核心能力对比表
能力维度 | G1(分区清洁工) | ZGC(闪电特工) |
---|---|---|
停顿时间 | 100-300 ms | < 10 ms |
堆大小支持 | 最大 64 GB | 最大 16 TB |
吞吐量 | 较高 | 略低(因并发开销) |
适用场景 | 常规应用、中小堆 | 低延迟服务、超大堆 |
电商订单系统:用户下单高峰期需避免卡顿,ZGC 小于 10 ms 的停顿特性可保障交易流畅性。
大数据批处理:当堆大小适中(如 32 GB)且吞吐量优先时,G1 更具成本效益。
实时游戏服务:毫秒级响应要求下,ZGC 是唯一能满足低延迟需求的选择。
总结:选对清洁工,内存公寓更舒心
回到"内存公寓"的管理视角,垃圾回收器的选择本质是匹配"公寓规模"与"住户需求"的过程——正如现实中没有万能的清洁工,JVM 内存管理也不存在绝对最优解,只有最适配场景的选择。
G1 作为"精打细算的分区管理员",擅长处理 4GB~32GB 堆内存的常规企业应用,通过区域化内存布局与增量回收机制,在延迟控制与吞吐量之间取得平衡,成为大多数标准业务场景的默认选择。其设计理念如同经验丰富的物业经理,通过精细化分区管理确保日常运营的稳定高效。
ZGC 则是"追求极致速度的闪电特工",专为 8MB~16TB 超大堆场景打造,尤其适用于金融交易等对停顿时间(<10ms)要求严苛的低延迟应用。它突破传统回收器的性能瓶颈,如同配备尖端装备的特种清洁团队,能在不干扰住户正常活动的前提下完成超大空间的极速清理。
调优核心口诀:"小堆 G1 看停顿,大堆 ZGC 保延迟,参数设置要合理,日志监控不能停"。这一实践准则强调:堆内存规模与延迟需求是选型的首要依据,而持续的参数优化与监控分析则是维持长期稳定的关键。
选择合适的垃圾回收器并合理配置参数(如元空间大小、回收阈值等),是确保"内存公寓"长期整洁(避免内存溢出、减少 GC 停顿)的核心保障。你的内存公寓需要哪种清洁工?评论区聊聊你的调优故事吧!🎉
来源:juejin.cn/post/7552730198288564259
Mysql---领导让下班前把explain画成一张图
Explain总览图 这篇文章主要看图
Explain是啥
1、Explain工具介绍
使用EXPLAIN关键字可以模拟优化器执行SQL语句,分析你的查询语句或是结构的性能瓶颈, 在 select 语句之前增加 explain 关键字,MySQL 会在查询上设置一个标记,执行查询会返回执行计划的信息,而不是执行这条SQL。
注意:如果 from 中包含子查询,仍会执行该子查询,将结果放入临时表中。
2、Explain分析示例
参考官方文档:dev.mysql.com/doc/refman/…
# 示例表:
DROP TABLE IF EXISTS `actor`;
CREATE TABLE `actor` (
`id` int(11) NOT NULL,
`name` varchar(45) DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
INSERT INTO `actor` (`id`, `name`, `update_time`) VALUES (1,'a','2017‐12‐22
15:27:18'), (2,'b','2017‐12‐22 15:27:18'), (3,'c','2017‐12‐22 15:27:18');
DROP TABLE IF EXISTS `film`;
CREATE TABLE `film` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(10) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
INSERT INTO `film` (`id`, `name`) VALUES (3,'film0'),(1,'film1'),(2,'film2');
DROP TABLE IF EXISTS `film_actor`;
CREATE TABLE `film_actor` (
`id` int(11) NOT NULL,
`film_id` int(11) NOT NULL,
`actor_id` int(11) NOT NULL,
`remark` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_film_actor_id` (`film_id`,`actor_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
INSERT INTO `film_actor` (`id`, `film_id`, `actor_id`) VALUES (1,1,1),(2,1,2),(3,2,1);
explain select * from actor;
# 在查询中的每个表会输出一行,如果有两个表通过 join 连接查询,那么会输出两行。
3、explain 两个变种
- 1)explain extended:
会在 explain 的基础上额外提供一些查询优化的信息。紧随其后通过 show warnings 命令可 以得到优化后的查询语句,从而看出优化器优化了什么。额外还有 filtered 列,是一个半分比的值,rows * filtered/100 可以估算出将要和 explain 中前一个表进行连接的行数(前一个表指 explain 中的id值比当前表id值小的表)
explain extended select * from film where id = 1;
show warnings;
- 2)explain partitions:
相比 explain 多了个 partitions 字段,如果查询是基于分区表的话,会显示查询将访问的分区。
4、explain中的列
4.1. id列
id列的编号是 select 的序列号,有几个 select 就有几个id,并且id的顺序是按 select 出现的顺序增长的。 id列越大执行优先级越高,id相同则从上往下执行,id为NULL最后执行。
4.2. select_type列
select_type 表示对应行是简单还是复杂的查询。
- 1)simple:简单查询。查询不包含子查询和union;
- 2)primary:复杂查询中最外层的select ;
- 3)subquery:包含在 select 中的子查询(不在 from 子句中);
- 4)derived:包含在 from 子句中的子查询。MySQL会将结果存放在一个临时表中;
- 5)union:在 union 中的第二个和随后的 select;
explain select * from film where id = 2;
用这个例子来了解 primary、subquery 和 derived 类型:
#关闭mysql5.7新特性对衍生表的合并优化
set session optimizer_switch='derived_merge=off';
explain select (select 1 from actor where id = 1) from (select * from film where id = 1) der;
#还原默认配置
set session optimizer_switch='derived_merge=on';
explain select 1 union all select 1;
4.3. table列
这一列表示 explain 的一行正在访问哪个表。
当 from 子句中有子查询时,table列是格式,表示当前查询依赖 id=N 的查询,于是先执行 id=N 的查询。当有 union 时,UNION RESULT 的 table 列的值为,1和2表示参与 union 的 select 行id。
4.4. type列
这一列表示关联类型或访问类型,即MySQL决定如何查找表中的行,查找数据行记录的大概范围。 依次从最优到最差分别为:system > const > eq_ref > ref > range > index > ALL 。
一般来说,得保证查询达到range级别,最好达到ref ;
NULL:
mysql能够在优化阶段分解查询语句,在执行阶段用不着再访问表或索引。例如:在索引列中选取最小值,可 以单独查找索引来完成,不需要在执行时访问表
mysql> explain select min(id) from film;
const, system:
mysql能对查询的某部分进行优化并将其转化成一个常量(可以看show warnings 的结果)。用于 primary key 或 unique key 的所有列与常数比较时,所以表最多有一个匹配行,读取1次,速度比较快。system是 const的特例,表里只有一条元组匹配时为system;
explain extended select * from (select * from film where id = 1) tmp;
show warnings;
eq_ref:
primary key 或 unique key 索引的所有部分被连接使用 ,最多只会返回一条符合条件的记录。这可能是在 const 之外最好的联接类型了,简单的 select 查询不会出现这种 type。
explain select * from film_actor left join film on film_actor.film_id = film.id;
ref:相比 eq_ref,不使用唯一索引,而是使用普通索引或者唯一性索引的部分前缀,索引要和某个值相比较,可能会 找到多个符合条件的行。
简单 select 查询,name是普通索引(非唯一索引)
explain select * from film where name = 'film1';
关联表查询,idx_film_actor_id是film_id和actor_id的联合索引,这里使用到了film_actor的左边前缀film_id部分。
explain select film_id from film left join film_actor on film.id = film_actor.fi
lm_id;
range:
范围扫描通常出现在 in(), between ,> ,= 等操作中。使用一个索引来检索给定范围的行。
explain select * from actor where id > 1;
index:
扫描全索引就能拿到结果,一般是扫描某个二级索引,这种扫描不会从索引树根节点开始快速查找,而是直接 对二级索引的叶子节点遍历和扫描,速度还是比较慢的,这种查询一般为使用覆盖索引,二级索引一般比较小,所以这 种通常比ALL快一些。
explain select * from film;
ALL:
即全表扫描,扫描你的聚簇索引的所有叶子节点.通常情况下这需要增加索引来进行优化。
explain select * from actor;
4.5. possible_keys列
这一列显示查询可能使用哪些索引来查找 explain 时可能出现 possible_keys 有列,而 key 显示 NULL 的情况,这种情况是因为表中数据不多,mysql认为索引对此查询帮助不大,选择了全表查询。 如果该列是NULL,则没有相关的索引。在这种情况下,可以通过检查 where 子句看是否可以创造一个适当的索引来提 高查询性能,然后用 explain 查看效果。
4.6. key列
这一列显示mysql实际采用哪个索引来优化对该表的访问。 如果没有使用索引,则该列是 NULL。如果想强制mysql使用或忽视possible_keys列中的索引,在查询中使用 force index、ignore index。
4.7. key_len列
这一列显示了mysql在索引里使用的字节数,通过这个值可以算出具体使用了索引中的哪些列。 举例来说,film_actor的联合索引 idx_film_actor_id 由 film_id 和 actor_id 两个int列组成,并且每个int是4字节。通 过结果中的key_len=4可推断出查询使用了第一个列:film_id列来执行索引查找。
explain select * from film_actor where film_id = 2;
key_len计算规则如下:
字符串:
char(n):如果存汉字长度就是 3n 字节
varchar(n):如果存汉字则长度是 3n + 2 字节,加的2字节用来存储字符串长度,
因为 varchar是变长字符串
char(n)和varchar(n),5.0.3以后版本中,n均代表字符数,而不是字节数,
如果是 utf-8,一个数字 或字母占1个字节,一个汉字占3个字节 ;
数值类型:
tinyint:1字节
smallint:2字节
int:4字节
bigint:8字节
时间类型:
date:3字节
timestamp:4字节
datetime:8字节
如果字段允许为 NULL,需要1字节记录是否为 NULL ;索引最大长度是768字节,当字符串过长时,mysql会做一个类似左前缀索引的处理,将前半部分的字符提取出来做索引。
4.8. ref列
这一列显示了在key列记录的索引中,表查找值所用到的列或常量,
常见的有:const(常量),字段名(例:film.id)
4.9. rows列
这一列是mysql估计要读取并检测的行数,注意这个不是结果集里的行数。
4.10. Extra列
这一列展示的是额外信息。常见的重要值如下:
1)Using index:使用覆盖索引
覆盖索引定义:mysql执行计划explain结果里的key有使用索引,如果select后面查询的字段都可以从这个索引的树中 获取,这种情况一般可以说是用到了覆盖索引,extra里一般都有using index;覆盖索引一般针对的是辅助索引,整个 查询结果只通过辅助索引就能拿到结果,不需要通过辅助索引树找到主键,再通过主键去主键索引树里获取其它字段值。
explain select film_id from film_actor where film_id = 1;
2)Using where:
使用 where 语句来处理结果,并且查询的列未被索引覆盖
explain select * from actor where name = 'a';
3)Using index condition:
查询的列不完全被索引覆盖,where条件中是一个前导列的范围;
explain select * from film_actor where film_id > 1;
4)Using temporary:
mysql需要创建一张临时表来处理查询。出现这种情况一般是要进行优化的,首先是想到用索 引来优化。
actor.name没有索引,此时创建了张临时表来distinct
explain select distinct name from actor;
film.name建立了idx_name索引,此时查询时extra是using index,没有用临时表
explain select distinct name from film;
5)Using filesort:
将用外部排序而不是索引排序,数据较小时从内存排序,否则需要在磁盘完成排序。这种情况下一 般也是要考虑使用索引来优化的
- actor.name未创建索引,会浏览actor整个表,保存排序关键字name和对应的id,然后排序name并检索行记录
1 mysql> explain select * from actor order by name
2. film.name建立了idx_name索引,此时查询时extra是using index
explain select * from film order by name;
6)Select tables optimized away:
使用某些聚合函数(比如 max、min来访问存在索引的某个字段是
explain select min(id) from film;
索引最佳实践
# 示例表:
CREATE TABLE `employees` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(24) NOT NULL DEFAULT '' COMMENT '姓名',
`age` int(11) NOT NULL DEFAULT '0' COMMENT '年龄',
`position` varchar(20) NOT NULL DEFAULT '' COMMENT '职位',
`hire_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '入职时间',
PRIMARY KEY (`id`),
KEY `idx_name_age_position` (`name`,`age`,`position`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8 COMMENT='员工记录表';
INSERT INTO employees(name,age,position,hire_time) VALUES('LiLei',22,'manager',NOW());
INSERT INTO employees(name,age,position,hire_time) VALUES('HanMeimei',
23,'dev',NOW());
INSERT INTO employees(name,age,position,hire_time) VALUES('Lucy',23,'dev',NOW());
5.1.全值匹配
1 EXPLAIN SELECT * FROM employees WHERE name= 'LiLei';
1 EXPLAIN SELECT * FROM employees WHERE name= 'LiLei' AND age = 22;
EXPLAIN SELECT * FROM employees WHERE name= 'LiLei' AND age = 22 AND position ='manager';
5.2.最左前缀法则
如果索引了多列,要遵守最左前缀法。则指的是查询从索引的最左前列开始并且不跳过索引中的列。
1 EXPLAIN SELECT * FROM employees WHERE name = 'Bill' and age = 31;
2 EXPLAIN SELECT * FROM employees WHERE age = 30 AND position = 'dev';
3 EXPLAIN SELECT * FROM employees WHERE position = 'manager'
5.3.不在索引列上做任何操作(计算、函数、(自动or手动)类型转换),会导致索引失效而转向全表扫描
1 EXPLAIN SELECT * FROM employees WHERE name = 'LiLei';
2 EXPLAIN SELECT * FROM employees WHERE left(name,3) = 'LiLei';
给hire_time增加一个普通索引:
1 ALTER TABLE employees
ADD INDEX idx_hire_time
(hire_time
) USING BTREE ;
2 EXPLAIN select * from employees where date(hire_time) ='2018‐09‐30';
转化为日期范围查询,有可能会走索引:
1 EXPLAIN select * from employees where hire_time >='2018‐09‐30 00:00:00' and hire_time <='2018‐09‐30 23:59:59';
还原最初索引状态
1 ALTER TABLE employees
DROP INDEX idx_hire_time
;
5.4.存储引擎不能使用索引中范围条件右边的列
1 EXPLAIN SELECT * FROM employees WHERE name= 'LiLei' AND age = 22 AND position ='manager';
2 EXPLAIN SELECT * FROM employees WHERE name= 'LiLei' AND age > 22 AND position ='manager';
5.5.尽量使用覆盖索引(只访问索引的查询(索引列包含查询列)),减少 select * 语句
1 EXPLAIN SELECT name,age FROM employees WHERE name= 'LiLei' AND age = 23 AND position='manager';
1 EXPLAIN SELECT * FROM employees WHERE name= 'LiLei' AND age = 23 AND position ='manager';
5.6.mysql在使用不等于(!=或者<>),not in ,not exists 的时候无法使用索引会导致全表扫描 < 小于、 > 大于、 = 这些,mysql内部优化器会根据检索比例、表大小等多个因素整体评估是否使用索引
1 EXPLAIN SELECT * FROM employees WHERE name != 'LiLei';
5.7.is null,is not null 一般情况下也无法使用索引
1 EXPLAIN SELECT * FROM employees WHERE name is null
5.8.like以通配符开头('$abc...')mysql索引失效会变成全表扫描操作
1 EXPLAIN SELECT * FROM employees WHERE name like '%Lei'
1 EXPLAIN SELECT * FROM employees WHERE name like 'Lei%'
问题:解决like'%字符串%'索引不被使用的方法?
a)使用覆盖索引,查询字段必须是建立覆盖索引字段
1 EXPLAIN SELECT name,age,position FROM employees WHERE name like '%Lei%';
b)如果不能使用覆盖索引则可能需要借助搜索引擎
5.9.字符串不加单引号索引失效
1 EXPLAIN SELECT * FROM employees WHERE name = '1000'; 2 EXPLAIN SELECT * FROM employees WHERE name = 1000;
5.10.少用or或in,
用它查询时,mysql不一定使用索引,mysql内部优化器会根据检索比例、表大小等多个因素整体评 估是否使用索引,详见范围查询优化
1 EXPLAIN SELECT * FROM employees WHERE name = 'LiLei' or name = 'HanMeimei';
5.11.范围查询优化 给年龄添加单值索引
1 ALTER TABLE employees
ADD INDEX idx_age
(age
) USING BTREE ;
2 explain select * from employees where age >=1 and age <=2000;
没走索引原因:mysql内部优化器会根据检索比例、表大小等多个因素整体评估是否使用索引。比如这个例子,可能是 由于单次数据量查询过大导致优化器最终选择不走索引 。
优化方法:可以将大的范围拆分成多个小范围。
1 explain select * from employees where age >=1 and age <=1000;
2 explain select * from employees where age >=1001 and age <=2000;
还原最初索引状态
1 ALTER TABLE employees
DROP INDEX idx_age
;
6、索引使用总结
PS:like KK%相当于=常量,%KK和%KK% 相当于范围
来源:juejin.cn/post/7478888679231193125
叫你别乱封装,你看出事了吧
团队曾为一个订单状态显示问题加班至深夜:并非业务逻辑出错,而是前期封装的订单类过度隐藏核心字段,连获取支付时间都需多层调用,最终只能通过反射绕过封装临时解决,后续还需承担潜在风险。这一典型场景,正是 “乱封装” 埋下的隐患 —— 封装本是保障代码安全、提升可维护性的工具,但违背其核心原则的 “乱封装”,反而会让代码从 “易扩展” 走向 “高耦合”,成为开发流程中的阻碍。
一、乱封装的三类典型形态:偏离封装本质的错误实践
乱封装并非 “不封装”,而是未遵循 “最小接口暴露、合理细节隐藏” 原则,表现为三种具体形态,与前文所述的过度封装、虚假封装、混乱封装高度契合,且每一种都直接破坏代码可用性。
1. 过度封装:隐藏必要扩展点,制造使用障碍
为追求 “绝对安全”,将本应开放的核心参数或功能强行隐藏,仅保留僵化接口,导致后续业务需求无法通过正常途径满足。例如某文件上传工具类,将存储路径、上传超时时间等关键参数设为私有且未提供修改接口,仅支持默认配置。当业务需新增 “临时文件单独存储” 场景时,既无法调整路径参数,又不能复用原有工具类,最终只能重构代码,造成开发资源浪费。
反例代码:
// 文件上传工具类(过度封装)
public class FileUploader {
// 关键参数设为私有且无修改途径
private String storagePath = "/default/path";
private int timeout = 3000;
// 仅提供固定逻辑的上传方法,无法修改路径和超时时间
public boolean upload(File file) {
// 使用默认storagePath和timeout执行上传
return doUpload(file, storagePath, timeout);
}
// 私有方法,外部无法干预
private boolean doUpload(File file, String path, int time) {
// 上传逻辑
}
}
问题:当业务需要 "临时文件存 /tmp 目录" 或 "大文件需延长超时时间" 时,无法通过正常途径修改参数,只能放弃该工具类重新开发。
正确做法:暴露必要的配置接口,隐藏实现细节:
public class FileUploader {
private String storagePath = "/default/path";
private int timeout = 3000;
// 提供修改参数的接口
public void setStoragePath(String path) {
this.storagePath = path;
}
public void setTimeout(int timeout) {
this.timeout = timeout;
}
// 保留核心功能接口
public boolean upload(File file) {
return doUpload(file, storagePath, timeout);
}
2. 虚假封装:形式化隐藏细节,未实现数据保护
表面通过访问控制修饰符(如private)隐藏变量,也编写getter/setter方法,但未在接口中加入必要校验或逻辑约束,本质与 “直接暴露数据” 无差异,却增加冗余代码。以订单类为例,将orderStatus(订单状态)设为私有后,setOrderStatus()方法未校验状态流转逻辑,允许外部直接将 “已发货” 状态改为 “待支付”,违背业务规则,既未保护数据完整性,也失去了封装的核心价值。
反例代码:
// 订单类(虚假封装)
public class Order {
private String orderStatus; // 状态:待支付/已支付/已发货
// 无任何校验的set方法
public void setOrderStatus(String status) {
this.orderStatus = status;
}
public String getOrderStatus() {
return orderStatus;
}
}
// 外部调用可随意修改状态,违背业务规则
Order order = new Order();
order.setOrderStatus("已发货");
order.setOrderStatus("待支付"); // 非法状态流转,封装未阻止
问题:允许状态从 "已发货" 直接变回 "待支付",违反业务逻辑,封装未起到数据保护作用,和直接用 public 变量没有本质区别。
正确做法:在接口中加入校验逻辑:
public class Order {
private String orderStatus;
public void setOrderStatus(String status) {
// 校验状态流转合法性
if (!isValidTransition(this.orderStatus, status)) {
throw new IllegalArgumentException("非法状态变更");
}
this.orderStatus = status;
}
// 隐藏校验逻辑
private boolean isValidTransition(String oldStatus, String newStatus) {
// 定义合法的状态流转规则
return (oldStatus == null && "待支付".equals(newStatus)) ||
("待支付".equals(oldStatus) && "已支付".equals(newStatus)) ||
("已支付".equals(oldStatus) && "已发货".equals(newStatus));
}
}
3. 混乱封装:混淆职责边界,堆砌无关逻辑
将多个独立功能模块强行封装至同一类或组件中,未按职责拆分,导致代码耦合度极高。例如某项目的 “CommonUtil” 工具类,同时包含日期转换、字符串处理、支付签名校验三类无关功能,且内部逻辑相互依赖。后续修改支付签名算法时,误触日期转换模块的静态变量,导致多个依赖该工具类的功能异常,排查与修复耗时远超预期。
反例代码:
// 万能工具类(混乱封装)
public class CommonUtil {
// 日期处理
public static String formatDate(Date date) { ... }
// 字符串处理
public static String trim(String str) { ... }
// 支付签名(与工具类无关)
public static String signPayment(String orderNo, BigDecimal amount) {
// 使用了类内静态变量,与其他方法产生耦合
return MD5.encode(orderNo + amount + secretKey);
}
private static String secretKey = "default_key";
}
问题:当修改支付签名逻辑(如替换加密方式)时,可能误改 secretKey,导致日期格式化、字符串处理等无关功能异常,排查难度极大。
正确做法:按职责拆分封装:
// 日期工具类
public class DateUtil {
public static String formatDate(Date date) { ... }
}
// 字符串工具类
public class StringUtil {
public static String trim(String str) { ... }
}
// 支付工具类
public class PaymentUtil {
private static String secretKey = "default_key";
public static String signPayment(String orderNo, BigDecimal amount) { ... }
}
二、乱封装的核心危害:从开发效率到系统稳定性的双重冲击
乱封装的危害具有 “隐蔽性” 和 “累积性”,初期可能仅表现为局部开发不便,随业务迭代会逐渐放大,对系统造成多重影响。
1. 降低开发效率,增加需求落地成本
乱封装会导致接口设计与业务需求脱节,当需要调用核心功能或获取关键数据时,需额外编写适配代码,甚至重构原有封装。例如某报表功能需获取订单原始字段用于统计,但前期封装的订单查询接口仅返回加工后的简化数据,无法满足需求,开发团队只能协调原封装者新增接口,沟通与开发周期延长,直接影响项目进度。
2. 破坏系统可扩展性,引发连锁故障
未预留扩展点的乱封装,会让后续功能迭代陷入 “牵一发而动全身” 的困境。某项目的缓存工具类未设计 “缓存过期清除” 开关,当业务需临时禁用缓存时,只能修改工具类源码,却因未考虑其他依赖模块,导致多个功能因缓存逻辑变更而异常,引发线上故障。这种因封装缺陷导致的扩展问题,会随系统复杂度提升而愈发严重。
3. 提升调试难度,延长问题定位周期
内部细节的无序隐藏,会让问题排查失去清晰路径。例如某支付接口返回 “参数错误”,但封装时未在接口中返回具体错误字段,且内部日志缺失关键信息,开发人员需逐层断点调试,才能定位到 “订单号长度超限” 的问题,原本十分钟可解决的故障,耗时延长数倍。
三、避免乱封装的实践原则:回归封装本质,平衡安全与灵活
避免乱封装无需复杂的设计模式,核心是围绕 “职责清晰、接口合理” 展开,结合前文总结的经验,可落地为两大原则。
1. 按 “单一职责” 划分封装边界
一个类或组件仅负责一类核心功能,不堆砌无关逻辑。例如用户模块中,将 “用户注册登录”“信息修改”“地址管理” 拆分为三个独立封装单元,通过明确的接口交互(如用户 ID 关联),避免功能耦合。这种拆分方式既能降低修改风险,也让代码结构更清晰,便于后续维护。
2. 接口设计遵循 “最小必要 + 适度灵活”
- 最小必要:仅暴露外部必须的接口,隐藏内部实现细节(如工具类无需暴露临时变量、辅助函数);
- 适度灵活:针对潜在变化预留扩展点,避免接口僵化。例如短信发送工具类,核心接口sendSms(String phone, String content)满足基础需求,同时提供setTimeout(int timeout)方法允许调整超时时间,既隐藏签名验证、服务商调用等细节,又能应对不同场景的参数调整需求。
某商品管理项目的封装实践可作参考:商品查询功能同时提供两个接口 —— 面向前端的 “分页筛选简化接口” 和面向后端统计的 “完整字段接口”,既满足不同场景需求,又未暴露数据库查询逻辑,后续数据库表结构调整时,仅需维护内部实现,外部调用无需改动,充分体现了合理封装的价值。
结语
封装的本质是 “用合理的边界保障代码安全,用清晰的接口提升开发效率”,而非 “为封装而封装”。开发过程中,需避免过度追求形式化封装,也需警惕功能堆砌的混乱封装,多从后续维护、业务扩展的角度权衡接口设计。毕竟,好的封装是开发的 “助力”,而非 “阻力”—— 下次封装前,不妨先思考:“这样的设计,会不会给后续埋下隐患?”
来源:juejin.cn/post/7543911246166556715
苍穹外卖实现员工分页查询
员工分页查询功能开发
1. 需求分析
2. 代码开发
- 根据分页查询接口设计对应的DTO

- 设计controller层
@GetMapping("/page")
@ApiOperation(value = "员工分页查询")
public Result page(EmployeePageQueryDTO employeePageQueryDTO){
//输出日志
log.info("员工分页查询,查询参数: {}",employeePageQueryDTO);
//调用service层返回分页结果
PageResult pageResult = employeeService.pageQuery(employeePageQueryDTO);
//返回result
return Result.success(pageResult);
}
- 设计service层,使用Page Helper进行分页,并返回total和record
@Override
public PageResult pageQuery(EmployeePageQueryDTO employeePageQueryDTO) {
//开始分页
PageHelper.startPage(employeePageQueryDTO.getPage(), employeePageQueryDTO.getPageSize());
//调用mapper方法返回page对象,泛型为内容的类型,(page实际是一个List)
Page page = employeeMapper.pageQuery(employeePageQueryDTO);
//获取总的数据量
long total = page.getTotal();
//获取所有员工对象
List record = page.getResult();
//返回结果
return new PageResult(total,record);
}
- 设计Mapper层
Page pageQuery(EmployeePageQueryDTO employeePageQueryDTO);
- 使用动态SQL进行查询
<select id="pageQuery" resultType="com.sky.entity.Employee">
select * from employee
<where>
<if test="name != null and name != ''">
name like concat('%',#{name},'#')
if>
where>
order by create_time desc
select>
- 根据分页查询接口设计对应的DTO
- 设计controller层
@GetMapping("/page")
@ApiOperation(value = "员工分页查询")
public Result page(EmployeePageQueryDTO employeePageQueryDTO){
//输出日志
log.info("员工分页查询,查询参数: {}",employeePageQueryDTO);
//调用service层返回分页结果
PageResult pageResult = employeeService.pageQuery(employeePageQueryDTO);
//返回result
return Result.success(pageResult);
}
@Override
public PageResult pageQuery(EmployeePageQueryDTO employeePageQueryDTO) {
//开始分页
PageHelper.startPage(employeePageQueryDTO.getPage(), employeePageQueryDTO.getPageSize());
//调用mapper方法返回page对象,泛型为内容的类型,(page实际是一个List)
Page page = employeeMapper.pageQuery(employeePageQueryDTO);
//获取总的数据量
long total = page.getTotal();
//获取所有员工对象
List record = page.getResult();
//返回结果
return new PageResult(total,record);
}
Page pageQuery(EmployeePageQueryDTO employeePageQueryDTO);
<select id="pageQuery" resultType="com.sky.entity.Employee">
select * from employee
<where>
<if test="name != null and name != ''">
name like concat('%',#{name},'#')
if>
where>
order by create_time desc
select>
3. 功能测试
Swagger测试:
问题
createTime这种是数组形式传递的
前后端联调:
问题:
操作时间渲染格式问题
4. 代码完善
方式一:
代码:
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime;
数据返回:
方式二:
代码:
需要在配置类中重写父类的方法,并配置添加消息转换器
@Override
protected void extendMessageConverters(List> converters) {
log.info("扩展消息转换器");
//创建一个消息转换器
MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
//为消息转换器设置一个对象转换器,对象转换器可以将对象数据转换为json数据
converter.setObjectMapper(new JacksonObjectMapper());
//将消息转换器添加到容器中,由于converters内部有很多消息转换器,我们假如的默认排在最后一位
//所以将顺序设置为最靠前
converters.add(0,converter);
}
其中JacksonObjectMapper为自己实现的实体类,写法较为固定
数据返回:
来源:juejin.cn/post/7531791862521151528
灰度和红蓝区
一、灰度和红蓝区
灰度发布
- 定义:
- 灰度发布又称灰度测试、金丝雀发布,是指在软件产品正式全面上线之前,选择部分用户或部分服务器来进行新版本的发布和测试。
- 例如,在一个拥有大量用户的社交应用的更新过程中,只让其中 10% 的用户使用新版本,而其余 90% 的用户仍然使用旧版本。
- 目的:
- 风险控制:将新版本的风险降至最低。由于只对部分用户或服务器进行更新,即使出现问题,影响范围也相对较小。例如,在更新一个金融应用的支付功能时,通过灰度发布,可以先在少量用户中测试,避免大面积的支付功能故障影响大量用户的资金交易。
- 收集反馈:在小范围用户使用的过程中,可以收集用户反馈,包括功能的可用性、性能问题、用户体验等方面的反馈。比如,一款游戏进行版本更新,通过灰度发布可以观察这部分用户的游戏体验和对新功能的接受程度,根据反馈及时调整和优化。
- 性能测试:观察新版本在真实环境下的性能表现,如服务器负载、响应时间等。例如,一个电商平台在上线新的商品推荐算法时,通过灰度测试可以观察在部分用户使用情况下,服务器是否能承受新算法带来的额外计算量和数据请求。
- 实现方式:
- 基于用户的灰度:根据用户的某些特征(如用户 ID、地区、注册时间等)来划分使用新版本的用户。例如,选取新注册用户进行灰度测试,让他们使用新的注册流程版本,而老用户仍然使用旧的注册流程。
- 基于服务器的灰度:将服务器分为不同的集群,一部分集群部署新版本,一部分集群部署旧版本。例如,一个网站将其服务器集群分为 A、B、C 三组,让 A 组服务器先部署并运行新版本,B、C 组仍然运行旧版本,根据不同的负载均衡策略将用户请求引导到不同的服务器组。
红蓝区(我们现在的蓝区是灰度,部分用户,红区是放量)
- 定义:
- 红蓝区通常是将生产环境分成两个相对独立的区域,分别部署不同版本的系统,通常是旧版本(蓝区)和新版本(红区),类似于 AB 测试。
- 例如,在一个内容分发平台中,蓝区使用原有的内容推荐系统,红区使用经过优化的新推荐系统。
- 目的:
- 对比测试:通过将新旧版本分别部署在不同的区域,能够在相同的环境和时间下对新旧系统进行直接对比。可以对比两个版本的性能指标(如吞吐量、响应时间)、业务指标(如用户留存率、点击率)等。例如,在一个新闻网站上,红区使用新的页面布局,蓝区使用旧的布局,对比不同区域用户的点击率和停留时间,以评估新布局的效果。
- 快速回滚:当发现红区的新版本出现严重问题时,可以迅速将流量切换回蓝区的旧版本,降低对业务的影响。例如,在一个在线教育平台的系统更新中,如果红区的新系统出现严重的性能下降,导致用户无法正常上课,可以将用户请求切换回蓝区,保证服务的正常进行。
- 实现方式:
- 负载均衡切换:通过负载均衡器来控制流量分配到红区和蓝区。在正常情况下,根据一定的比例分配流量,如红区和蓝区分别分配 70% 和 30% 的流量。当发现红区出现问题时,将流量全部切换到蓝区。
- 功能切换:可以对不同的功能进行红蓝区划分。例如,在一个企业办公软件中,将文件存储功能部署在红区,将即时通讯功能部署在蓝区,分别测试不同功能的新老版本,最后根据测试结果决定是否进行整体切换。
总之,无论是灰度发布还是红蓝区,都是为了在保证服务稳定性和业务连续性的前提下,更安全、高效地将新系统或新版本推向市场,降低因软件更新带来的风险,并在更新过程中不断收集反馈和数据,以优化系统和提升用户体验。
来源:juejin.cn/post/7553522695750484006
Go语言实战案例:简易图像验证码生成
在 Web 应用中,验证码(CAPTCHA)常用于防止机器人批量提交请求,比如注册、登录、评论等功能。
本篇我们将使用 Go 语言和 Gin 框架,结合第三方库github.com/mojocn/base64Captcha
,快速实现一个简易图像验证码生成接口。
一、功能目标
- 提供一个生成验证码的 API,返回验证码图片(Base64 编码)和验证码 ID。
- 前端展示验证码图片,并在提交时携带验证码 ID 和用户输入。
- 提供一个校验验证码的 API。
二、安装依赖
首先安装 Gin 和 Base64Captcha:
go get github.com/gin-gonic/gin
go get github.com/mojocn/base64Captcha
三、代码实现
package main
import (
"github.com/gin-gonic/gin"
"github.com/mojocn/base64Captcha"
"net/http"
)
// 验证码存储在内存中(也可以换成 Redis)
var store = base64Captcha.DefaultMemStore
// 生成验证码
func generateCaptcha(c *gin.Context) {
driver := base64Captcha.NewDriverDigit(80, 240, 5, 0.7, 80) // 高度80, 宽度240, 5位数字
captcha := base64Captcha.NewCaptcha(driver, store)
id, b64s, err := captcha.Generate()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "验证码生成失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"captcha_id": id,
"captcha_image": b64s, // Base64 编码的图片
})
}
// 校验验证码
func verifyCaptcha(c *gin.Context) {
var req struct {
ID string `json:"id"`
Value string `json:"value"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if store.Verify(req.ID, req.Value, true) { // true 表示验证成功后清除
c.JSON(http.StatusOK, gin.H{"message": "验证成功"})
} else {
c.JSON(http.StatusBadRequest, gin.H{"message": "验证码错误"})
}
}
func main() {
r := gin.Default()
r.GET("/captcha", generateCaptcha)
r.POST("/verify", verifyCaptcha)
r.Run(":8080")
}
四、运行与测试
运行服务:
go run main.go
1. 获取验证码
curl http://localhost:8080/captcha
返回:
{
"captcha_id": "ZffX7Xr7EccGdS4b",
"captcha_image": "data:image/png;base64,iVBORw0KGgoAAAANSUhE..."
}
前端可直接用 <img src="captcha_image" />
渲染验证码。
2. 校验验证码
curl -X POST http://localhost:8080/verify \
-H "Content-Type: application/json" \
-d '{"id":"ZffX7Xr7EccGdS4b","value":"12345"}'
五、注意事项
- 验证码存储
- 本示例使用内存存储,适合单机开发环境。
- 生产环境建议使用 Redis 等共享存储。
- 验证码类型
base64Captcha
支持数字、字母混合、中文等类型,可以根据业务需求选择不同Driver
。 - 安全性
- 不能把验证码 ID 暴露给爬虫(可配合 CSRF、限流等手段)。
- 验证码要有有效期,防止重放攻击。
六、总结
使用 base64Captcha
结合 Gin,可以非常方便地生成和校验验证码。
本篇示例已经可以直接应用到注册、登录等防刷场景中。
来源:juejin.cn/post/7537981628854239282
程序员应该掌握的网络命令telnet、ping和curl
这篇文章源于开发中发现的一个服务之间调用问题,在当前服务中调用了其他团队的一个服务,看日志一直报错没有找到下游的服务实例,然后就拉上运维来一块排查,运维让我先 telnet 一下网络,我一下没反应过来是要干啥!
telnet
telnet是电信(telecommunications)和网络(networks)的联合缩写,它是一种基于 TCP 的网络协议,用于远程登录服务器(数据均以明文形式传输,存在安全隐患,所以现在基本不会用了)或测试主机上的端口开放情况。
# 命令格式
telnet IP或域名 端口
# telnet ip地址
telnet 192.168.1.1 3306
# telnet 域名
telnet cafe123.cn 443
ping
ping 是一种基于 ICMP(Internet Control Message Protocol)的网络工具,用于测试主机之间的网络连通性,它不能指定端口。
# 命令格式
ping IP或域名
# ping ip地址
ping 192.168.1.1
# ping 域名
ping cafe123.cn
日常开发中测试某台服务器上的web后端、数据库、redis等服务的端口是否开放可用,就可以用 telnet 命令;若只需确认服务器主机是否在线,就可以用 ping 命令。
像一般服务之间调用出现问题,我就需要先从服务器网络开始测试,一步步来缩小范围,如果当前服务器上都没法 telnet 通目标服务器的某个端口,那就是网络问题,那就可以从网络入手来排查是网络不让访问还是目标服务压根不存在。
curl
curl(Client URL)是一个强大的网络请求命令工具,可以理解为命令行中的 postman。
比如如果我们要在服务器上去请求某个接口,看能不能请求通,总不能在 Linux 上去装个 postman 来请求吧。这种情况 curl 命令就派上用场了。
1、请求某个网页
# 命令格式
curl 网址
# 示例
curl https://cafe123.cn
2、发送 get 请求
参数 -X 指定 HTTP 方法,不指定默认就是 get
# 示例
curl -X GET https://cafe123.cn?name=zhou&age=18
3、发送 post 请求
请求头用 -H 指定,多个直接分开多次指定就行,-d 指定 post 请求参数
curl -X POST -H "Content-Type: application/json" -H "token: 1345102704" -d '{"name":"ZHOU","age":18}' https://api.cafe123.cn/users
实际上面的这些也不用记,浏览器的 network 前端接口请求查看面板里右键实际是可以直接复制出来对应接口的 curl 命令的,然后直接复制出来去服务器上执行就行了,postman 中也支持直接导入 curl 命令给自动转成 postman 对应的参数。
来源:juejin.cn/post/7554332546579709990
Spring Boot启动时的小助手:ApplicationRunner和CommandLineRunner
一、前言
平常开发中有可能需要实现在项目启动后执行的功能,Springboot中的ApplicationRunner和CommandLineRunner接口都能够帮我们很好地完成这种事情。它们的主要作用是在应用启动后执行一段初始化或任务逻辑,常见于一些启动任务,例如加载数据、验证配置等等。今天我们就来聊聊这两个接口在实际开发中是怎么使用的。
二、使用方式
我们直接看示例代码:
@Component
public class CommandLineRunnerDemo implements CommandLineRunner {
@Override
public void run(String... args) throws Exception {
//执行特定的代码
System.out.println("执行特定的代码");
}
}
@Component
public class ApplicationRunnerDemo implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) throws Exception {
System.out.println("ApplicationRunnerDemo.run");
}
}
从源码上分析,CommandLineRunner
与ApplicationRunner
两者之间只有run()
方法的参数不一样而已。CommandLineRunner#run()
方法的参数是启动SpringBoot
应用程序main
方法的参数列表,而ApplicationRunner#run()
方法的参数则是ApplicationArguments
对象。
如果我们有多个类实现CommandLineRunner或ApplicationRunner接口,可以通过Ordered接口控制执行顺序。下面以ApplicationRunner接口为例子:
直接启动看效果:
可以看到order值越小,越先被执行。
传递参数
Spring Boot应用启动时是可以接受参数的,这些参数通过命令行 java -jar app.jar
来传递。CommandLineRunner
会原封不动照单全收这些参数,这些参数也可以封装到ApplicationArguments
对象中供ApplicationRunner
调用。下面我们来看一下ApplicationArguments
的相关方法:
getSourceArgs()
被传递给应用程序的原始参数,返回这些参数的字符串数组。getOptionNames()
获取选项名称的Set
字符串集合。如--spring.profiles.active=dev --debug
将返回["spring.profiles.active","debug"]
。getOptionValues(String name)
通过名称来获取该名称对应的选项值。如--config=dev --config=test
将返回["dev","eat"]
。containsOption(String name)
用来判断是否包含某个选项的名称。getNonOptionArgs()
用来获取所有的无选项参数。
三、总结
CommandLineRunner 和 ApplicationRunner 常用于应用启动后的初始化任务或一次性任务执行。它们允许你在 Spring 应用启动完成后立即执行一些逻辑。ApplicationRunner 更适合需要处理命令行参数的场景,而 CommandLineRunner 更简单直接。
来源:juejin.cn/post/7555149066134650919
为什么我坚持用git命令行,而不是GUI工具?
上周,我们组里来了个新同事,看我噼里啪啦地在黑窗口里敲git
命令,他很好奇地问我:
“哥,现在VS Code自带的Git工具那么好用,还有Sourcetree、GitKraken这些,你为什么还坚持用命令行啊?不觉得麻烦吗?”
这个问题问得很好。
我完全承认,现代的Git GUI工具做得非常出色,它们直观、易上手,尤其是在处理简单的提交和查看分支时,确实很方便。我甚至会推荐刚接触Git的新人,先从GUI开始,至少能对Git的工作流程有个直观的感受。
但用了8年Git,我最终还是回到了纯命令行。
这不是因为我守旧,也不是为了显得自己多“牛皮”。而是因为我发现,命令行在三个方面,给了我GUI无法替代的价值:速度、能力和理解。
这篇文章,就想聊聊我的一些观点。
速度
对于我们每天要用上百次的工具来说,零点几秒的效率提升,累加起来也是巨大的。在执行高频的、重复性的操作时,键盘的速度,永远比“移动鼠标 -> 寻找目标 -> 点击”这个流程要快。
- 一个最简单的
commit
&push
流程:
- 我的命令行操作:
git add .
->git commit -m "..."
->git push
。配合zsh/oh-my-zsh的自动补全和历史记录,我敲这几个命令可能只需要3-5秒,眼睛甚至不用离开代码。 - GUI操作:我需要在VS Code里切换到Git面板 -> 鼠标移动到“更改”列表 -> 点击“+”号暂存全部 -> 鼠标移动到输入框 -> 输入信息 -> 点击“提交”按钮 -> 再点击“同步更改”按钮。
- 我的命令行操作:
这个过程,再快也快不过我的肌肉记忆。
- 更高效的别名(Alias):
~/.gitconfig文件是我的宝库。我在里面配置了大量的别名,把那些长长的命令,都缩短成了两三个字母。
[alias]
st = status
co = checkout
br = branch
ci = commit
lg = log --color --graph --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)<%an>%Creset' --abbrev-commit
现在,我只需要敲
git st
就能看状态,git lg
就能看到一个非常清晰的分支图。这种个性化定制带来的效率提升,是GUI工具无法给予的。
深入Git
GUI工具做得再好,它本质上也是对Git核心功能的一层“封装”。它会优先把最常用的80%功能,做得非常漂亮。但Git那剩下20%的、极其强大的、但在特定场景下才能发挥作用的高级工具,很多GUI工具并没有提供,或者藏得很深。
而命令行,能让你100%地释放Git的全部能力。
- git rebase -i (交互式变基):
这是我认为命令行最具杀手级的应用之一。当我想清理一个分支的提交记录时,比如合并几个commit、修改commit信息、调整顺序,git rebase -i提供的那个类似Vim编辑器的界面,清晰、高效,能让我像做手术一样精确地操作提交历史。
- git reflog (你的后悔药):
reflog记录了你本地仓库HEAD的所有变化。有一次,我错误地执行了git reset --hard,把一个重要的commit给搞丢了。当时有点慌,但一句git reflog,立刻就找到了那个丢失的commit的哈希值,然后用git cherry-pick把它找了回来。这个救命的工具,很多GUI里甚至都没有入口。
- git bisect (二分法查Bug):
当你想找出是哪个commit引入了一个Bug时,git bisect是你的神器。它会自动用二分法,不断地切换commit让你去验证,能极大地缩小排查范围。这种高级的调试功能,几乎是命令行用户的专属。
会用到理解
这一点,是我认为最核心的。
GUI工具,把Git包装成了一系列按钮,它在帮你隐藏细节。
你点击“拉取(Pull)”,它可能在背后执行了git fetch
+ git merge
,也可能是git fetch
+ git rebase
。你不清楚,也不需要清楚,点就完事了。
这在一切顺利时没问题。但一旦出现复杂的合并冲突,或者你需要回滚一个错误的操作,按钮就不够用了。因为你不理解按钮背后的原理,你不知道Git的HEAD
、工作区、暂存区到底处于一个什么状态,你就会感到恐慌,甚至会因为误操作,把仓库搞得一团糟。
而命令行,强迫你去学习和理解Git的每一个动作和它背后的模型。
你输入的每一个命令,git reset --hard
和git reset --soft
的区别是什么?git merge
和git rebase
的数据流向有什么不同?每一次的输入,都在加深你对Git三区(工作区、暂存区、版本库)模型的理解。
这种对底层模型的深刻理解,才是一个资深工程师真正需要具备的。它能让我在遇到任何复杂情况时,都心里有底,知道该用哪个命令去精确地解决问题。
我从不要求我们组里的新人都用命令行,但我会鼓励他们,在熟悉了GUI之后,一定要花时间去学习一下命令行。
这不仅仅是一个工具选择的问题,更是一个思维方式的选择。
对我来说,用命令行,让我感觉我是在和Git这个工具直接对话。这种掌控感和确定性,是任何漂亮的UI都无法带给我的。
也许,这就是一个程序员的执念吧😀。
来源:juejin.cn/post/7537238517698150451
实现一个简化版的状态机框架
实现一个简化版的状态机框架
我将为你设计一个简化版的状态机框架,包含状态、事件、转换机制,并支持插件扩展功能。这个实现会参考Spring StateMachine的设计理念。
核心组件设计
// 状态枚举接口
public interface State {
String getId();
}
// 事件枚举接口
public interface Event {
String getId();
}
// 状态上下文
public class StateContext<S extends State, E extends Event> {
private S currentState;
private E event;
private Map<String, Object> extendedState = new HashMap<>();
// 获取当前状态
public S getCurrentState() {
return currentState;
}
// 设置当前状态
public void setCurrentState(S currentState) {
this.currentState = currentState;
}
// 获取触发事件
public E getEvent() {
return event;
}
// 设置触发事件
public void setEvent(E event) {
this.event = event;
}
// 获取扩展状态数据
public Map<String, Object> getExtendedState() {
return extendedState;
}
// 添加扩展状态数据
public void addExtendedState(String key, Object value) {
extendedState.put(key, value);
}
// 获取特定扩展状态数据
public Object getExtendedState(String key) {
return extendedState.get(key);
}
}
// 状态转换接口
public interface Transition<S extends State, E extends Event, C extends StateContext<S, E>> {
boolean isTriggered(C context);
S getSource();
S getTarget();
void executeActions(C context);
}
// 状态机接口
public interface StateMachine<S extends State, E extends Event, C extends StateContext<S, E>> {
S getCurrentState();
void sendEvent(E event, Map<String, Object> extendedState);
void addListener(StateMachineListener<S, E, C> listener);
void addTransition(Transition<S, E, C> transition);
}
// 状态机监听器
public interface StateMachineListener<S extends State, E extends Event, C extends StateContext<S, E>> {
void stateChanged(S from, S to);
void eventAccepted(E event);
void transitionStarted(Transition<S, E, C> transition);
void transitionCompleted(Transition<S, E, C> transition);
}
基础实现
// 枚举状态实现
public enum SimpleState implements State {
INITIAL("INIT"),
PROCESSING("PROCESS"),
COMPLETED("COMPLETE"),
ERROR("ERR");
private String id;
SimpleState(String id) {
this.id = id;
}
@Override
public String getId() {
return id;
}
}
// 枚举事件实现
public enum SimpleEvent implements Event {
START_PROCESS("START"),
FINISH_PROCESS("FINISH"),
ERROR_OCCURRED("ERROR");
private String id;
SimpleEvent(String id) {
this.id = id;
}
@Override
public String getId() {
return id;
}
}
// 基础状态机实现
public class BasicStateMachine<S extends State, E extends Event, C extends StateContext<S, E>>
implements StateMachine<S, E, C> {
private S currentState;
private List<Transition<S, E, C>> transitions = new ArrayList<>();
private List<StateMachineListener<S, E, C>> listeners = new ArrayList<>();
private StateMachineLogger logger = new StateMachineLogger();
public BasicStateMachine(S initialState) {
this.currentState = initialState;
}
@Override
public S getCurrentState() {
return currentState;
}
@Override
public void sendEvent(E event, Map<String, Object> extendedState) {
logger.log("Processing event: " + event.getId());
// 创建状态上下文
StateContext<S, E> context = createContext(event, extendedState);
// 通知监听器事件已接受
notifyEventAccepted(event);
// 查找并执行适用的转换
for (Transition<S, E, C> transition : transitions) {
if (transition.getSource().getId().equals(currentState.getId()) && transition.isTriggered((C) context)) {
logger.log("Executing transition from " + currentState.getId() + " on " + event.getId());
// 通知监听器转换开始
notifyTransitionStarted(transition);
// 执行转换动作
transition.executeActions((C) context);
// 更新当前状态
currentState = transition.getTarget();
// 通知监听器状态改变
notifyStateChanged(transition.getSource(), transition.getTarget());
// 通知监听器转换完成
notifyTransitionCompleted(transition);
break;
}
}
}
private StateContext<S, E> createContext(E event, Map<String, Object> extendedState) {
StateContext<S, E> context = new StateContext<>();
context.setCurrentState(currentState);
context.setEvent(event);
if (extendedState != null) {
extendedState.forEach((key, value) -> context.addExtendedState(key, value));
}
return context;
}
@Override
public void addListener(StateMachineListener<S, E, C> listener) {
listeners.add(listener);
}
@Override
public void addTransition(Transition<S, E, C> transition) {
transitions.add(transition);
}
// 通知状态改变
private void notifyStateChanged(S from, S to) {
listeners.forEach(listener -> listener.stateChanged(from, to));
}
// 通知事件接受
private void notifyEventAccepted(E event) {
listeners.forEach(listener -> listener.eventAccepted(event));
}
// 通知转换开始
private void notifyTransitionStarted(Transition<S, E, C> transition) {
listeners.forEach(listener -> listener.transitionStarted(transition));
}
// 通知转换完成
private void notifyTransitionCompleted(Transition<S, E, C> transition) {
listeners.forEach(listener -> listener.transitionCompleted(transition));
}
// 日志工具类
private static class StateMachineLogger {
public void log(String message) {
System.out.println("[StateMachine] " + message);
}
}
}
转换实现
// 条件转换抽象类
public abstract class AbstractTransition<S extends State, E extends Event, C extends StateContext<S, E>>
implements Transition<S, E, C> {
private S source;
private S target;
public AbstractTransition(S source, S target) {
this.source = source;
this.target = target;
}
@Override
public S getSource() {
return source;
}
@Override
public S getTarget() {
return target;
}
@Override
public void executeActions(C context) {
// 子类可以覆盖此方法以执行转换时的操作
}
}
// 基于事件的转换
public class EventBasedTransition<S extends State, E extends Event, C extends StateContext<S, E>>
extends AbstractTransition<S, E, C> {
private E event;
private Consumer<C> action;
public EventBasedTransition(S source, S target, E event) {
this(source, target, event, null);
}
public EventBasedTransition(S source, S target, E event, Consumer<C> action) {
super(source, target);
this.event = event;
this.action = action;
}
@Override
public boolean isTriggered(C context) {
return context.getEvent().getId().equals(event.getId());
}
@Override
public void executeActions(C context) {
super.executeActions(context);
if (action != null) {
action.accept(context);
}
}
}
// 条件+事件混合转换
public class ConditionalTransition<S extends State, E extends Event, C extends StateContext<S, E>>
extends AbstractTransition<S, E, C> {
private E event;
private Predicate<C> condition;
private Consumer<C> action;
public ConditionalTransition(S source, S target, E event, Predicate<C> condition) {
this(source, target, event, condition, null);
}
public ConditionalTransition(S source, S target, E event, Predicate<C> condition, Consumer<C> action) {
super(source, target);
this.event = event;
this.condition = condition;
this.action = action;
}
@Override
public boolean isTriggered(C context) {
return context.getEvent().getId().equals(event.getId()) && condition.test(context);
}
@Override
public void executeActions(C context) {
super.executeActions(context);
if (action != null) {
action.accept(context);
}
}
}
插件系统设计
// 插件接口
public interface StateMachinePlugin<S extends State, E extends Event, C extends StateContext<S, E>> {
void configure(BasicStateMachine<S, E, C> machine);
}
// 插件支持的状态机
public class PluginEnabledStateMachine<S extends State, E extends Event, C extends StateContext<S, E>>
extends BasicStateMachine<S, E, C> {
private List<StateMachinePlugin<S, E, C>> plugins = new ArrayList<>();
public PluginEnabledStateMachine(S initialState) {
super(initialState);
}
public void addPlugin(StateMachinePlugin<S, E, C> plugin) {
plugins.add(plugin);
plugin.configure(this);
}
}
// 示例插件:自动日志记录插件
public class LoggingPlugin<S extends State, E extends Event, C extends StateContext<S, E>>
implements StateMachinePlugin<S, E, C> {
private final StateMachineLogger logger = new StateMachineLogger();
@Override
public void configure(BasicStateMachine<S, E, C> machine) {
machine.addListener(new StateMachineListener<S, E, C>() {
@Override
public void stateChanged(S from, S to) {
logger.log("State changed from " + from.getId() + " to " + to.getId());
}
@Override
public void eventAccepted(E event) {
logger.log("Event accepted: " + event.getId());
}
@Override
public void transitionStarted(Transition<S, E, C> transition) {
logger.log("Transition started: " + transition.getSource().getId() + " -> " + transition.getTarget().getId());
}
@Override
public void transitionCompleted(Transition<S, E, C> transition) {
logger.log("Transition completed: " + transition.getSource().getId() + " -> " + transition.getTarget().getId());
}
});
}
// 内部日志记录器
private static class StateMachineLogger {
public void log(String message) {
System.out.println("[StateMachine-LOG] " + message);
}
}
}
// 示例插件:持久化插件
public class PersistencePlugin<S extends State, E extends Event, C extends StateContext<S, E>>
implements StateMachinePlugin<S, E, C>, StateMachineListener<S, E, C> {
private final StateMachinePersister persister;
public PersistencePlugin(StateMachinePersister persister) {
this.persister = persister;
}
@Override
public void configure(BasicStateMachine<S, E, C> machine) {
machine.addListener(this);
}
@Override
public void stateChanged(S from, S to) {
persister.saveState(to);
}
@Override
public void eventAccepted(E event) {
// 不需要处理
}
@Override
public void transitionStarted(Transition<S, E, C> transition) {
// 不需要处理
}
@Override
public void transitionCompleted(Transition<S, E, C> transition) {
// 不需要处理
}
// 持久化接口
public interface StateMachinePersister {
void saveState(State state);
State loadState();
}
// 示例内存持久化实现
public static class InMemoryPersister implements StateMachinePersister {
private State currentState;
@Override
public void saveState(State state) {
currentState = state;
}
@Override
public State loadState() {
return currentState;
}
}
}
使用示例
public class StateMachineDemo {
public static void main(String[] args) {
// 创建状态机并添加插件
PluginEnabledStateMachine<SimpleState, SimpleEvent, StateContext<SimpleState, SimpleEvent>> machine
= new PluginEnabledStateMachine<>(SimpleState.INIT);
machine.addPlugin(new LoggingPlugin<>());
InMemoryPersister persister = new PersistencePlugin.InMemoryPersister();
machine.addPlugin(new PersistencePlugin<>(persister));
// 定义状态转换
machine.addTransition(new EventBasedTransition<>(
SimpleState.INIT, SimpleState.PROCESSING, SimpleEvent.START_PROCESS,
context -> System.out.println("Starting processing...")));
machine.addTransition(new EventBasedTransition<>(
SimpleState.PROCESSING, SimpleState.COMPLETED, SimpleEvent.FINISH_PROCESS,
context -> System.out.println("Finishing process...")));
machine.addTransition(new EventBasedTransition<>(
SimpleState.PROCESSING, SimpleState.ERROR, SimpleEvent.ERROR_OCCURRED,
context -> System.out.println("Error occurred during processing")));
// 测试状态转换
System.out.println("Initial state: " + machine.getCurrentState().getId());
System.out.println("\nSending START_PROCESS event:");
machine.sendEvent(SimpleEvent.START_PROCESS, null);
System.out.println("Current state: " + machine.getCurrentState().getId());
System.out.println("\nSending FINISH_PROCESS event:");
machine.sendEvent(SimpleEvent.FINISH_PROCESS, null);
System.out.println("Current state: " + machine.getCurrentState().getId());
// 测试持久化
System.out.println("\nTesting persistence...");
((PersistencePlugin.InMemoryPersister) persister).saveState(SimpleState.INIT);
SimpleState restoredState = (SimpleState) persister.loadState();
System.out.println("Restored state: " + restoredState.getId());
}
}
进一步扩展建议
- 分层状态机:实现父子状态机结构,支持复合状态和子状态机
- 历史状态:添加对历史状态的支持,允许状态机返回到之前的某个状态
- 伪状态:实现初始状态、终止状态等特殊状态类型
- 转换类型:增加外部转换、内部转换、本地转换等不同类型的转换
- 配置DSL:创建流畅的API用于配置状态机,类似:
machine.configure()
.from(INIT).on(START_PROCESS).to(PROCESSING)
.perform(action)
- 持久化策略:添加更多持久化选项(数据库、文件等)
- 监控插件:添加性能监控、统计信息收集等插件
- 分布式支持:添加集群环境下状态同步的支持
- 异常处理:完善异常处理机制,支持在转换中处理异常
- 表达式支持:集成SpEL或其他表达式语言支持条件判断
这个实现提供了一个灵活的状态机框架基础,可以根据具体需求进一步扩展和完善。
来源:juejin.cn/post/7512231268420894729
goweb中间件
中间件基本概念
中间件(Middleware)是一种在HTTP请求到达最终处理程序(Handler)之前或之后执行特定功能的机制。
在 Go 语言里,net/http 是标准库中用于构建 HTTP 服务器的包,中间件则是处理 HTTP 请求时常用的技术。中间件其实就是一个函数,它会接收一个 http.Handler 类型的参数,并且返回另一个 http.Handler。中间件能够在请求到达最终处理程序之前或者响应返回客户端之前执行一些通用操作,像日志记录、认证、压缩等。
下面是一个简单的中间件函数示例:
go
package main
import (
"log"
"net/http"
)
// 中间件函数,接收一个 http.Handler 并返回另一个 http.Handler
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 在请求处理之前执行的操作
log.Printf("Received request: %s %s", r.Method, r.URL.Path)
// 调用下一个处理程序
next.ServeHTTP(w, r)
// 在请求处理之后执行的操作
log.Printf("Request completed: %s %s", r.Method, r.URL.Path)
})
}
// 最终处理程序
func helloHandler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello, World!"))
}
func main() {
// 创建一个新的 mux
mux := http.NewServeMux()
// 应用中间件到最终处理程序
mux.Handle("/", loggingMiddleware(http.HandlerFunc(helloHandler)))
// 启动服务器
log.Println("Server started on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
- 中间件函数 loggingMiddleware:
- 它接收一个 http.Handler 类型的参数 next,代表下一个要执行的处理程序。
- 返回一个新的 http.HandlerFunc,在这个函数里可以执行请求处理前后的操作。
- next.ServeHTTP(w, r) 这行代码会调用下一个处理程序。
- 最终处理程序 helloHandler:
helloHandler是实际处理请求的函数,它会向客户端返回 "Hello, World!"。
中间件链式调用
多个中间件可以串联起来形成处理链:
package main
import (
"log"
"net/http"
)
// 日志中间件
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("Received request: %s %s", r.Method, r.URL.Path)
next.ServeHTTP(w, r)
log.Printf("Request completed: %s %s", r.Method, r.URL.Path)
})
}
// 认证中间件
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 简单的认证逻辑
authHeader := r.Header.Get("Authorization")
if authHeader != "Bearer secret_token" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
// 最终处理程序
func helloHandler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello, World!"))
}
func main() {
mux := http.NewServeMux()
// 应用多个中间件到最终处理程序
finalHandler := loggingMiddleware(authMiddleware(http.HandlerFunc(helloHandler)))
mux.Handle("/", finalHandler)
log.Println("Server started on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
- 这里新增了一个 authMiddleware 中间件,用于简单的认证。
- 在 main 函数里,先把 authMiddleware 应用到 helloHandler 上,再把 loggingMiddleware 应用到结果上,这样就实现了多个中间件的组合。
通过使用中间件,能够让代码更具模块化和可维护性,并且可以在多个处理程序之间共享通用的逻辑。
中间件链中传递自定义参数
场景:需要在多个中间件间共享数据(如请求ID、用户会话)
实现方式:通过 context.Context
传递参数
package main
import (
"context"
"fmt"
"net/http"
)
// 中间件函数
func middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 设置上下文值
ctx := context.WithValue(r.Context(), "key", "value")
ctx = context.WithValue(ctx, "user_id", 123)
r = r.WithContext(ctx)
// 调用下一个处理函数
next.ServeHTTP(w, r)
})
}
// 处理函数
func handler(w http.ResponseWriter, r *http.Request) {
// 从上下文中获取值
value := r.Context().Value("key").(string)
userID := r.Context().Value("user_id").(int)
fmt.Fprintf(w, "Received user_id %d value: %v", userID, value)
}
func main() {
// 创建一个处理函数
mux := http.NewServeMux()
mux.HandleFunc("/", handler)
http.Handle("/", middleware(mux))
// 启动服务器
fmt.Println("Server started on :80")
http.ListenAndServe(":80", nil)
}
特点:
- 数据在中间件链中透明传递
- 避免全局变量和参数层层传递
来源:juejin.cn/post/7549113302674587658
别再只会 new 了!八年老炮带你看透对象创建的 5 层真相
别再只会 new 了!八年老炮带你看透对象创建的 5 层真相
刚入行时,我曾在订单系统里写过这样一段 “傻代码”:在循环处理 10 万条数据时,每次都new
一个临时的OrderCalculator
对象,结果高峰期 GC 频繁告警,CPU 利用率飙升到 90%。排查半天才发现,是对象创建太随意导致的 “内存爆炸”。
八年 Java 开发生涯里,从 “随便 new 对象” 到 “精准控制对象生命周期”,从排查OutOfMemoryError
到优化 JVM 内存模型,我踩过的坑让我明白:对象创建看似是new
关键字的一句话事儿,背后藏着 JVM 的复杂逻辑,更关联着系统的性能与稳定性。
今天,我就从 “业务痛点→底层原理→解析思路→实战代码” 四个维度,带你彻底搞懂 Java 对象的创建过程。
一、先聊业务:对象创建不当会踩哪些坑?
在讲底层原理前,先结合我遇到的真实业务场景,说说 “对象创建” 这件事在实战中有多重要 —— 很多性能问题、线程安全问题,根源都在对象创建上。
1. 坑 1:循环中频繁创建临时对象 → GC 频繁
场景:电商秒杀系统的订单校验逻辑,在for
循环里每次都new
一个OrderValidator
(无状态工具类),处理 10 万单时创建 10 万个对象。
后果:新生代 Eden 区快速填满,触发 Minor GC,频繁 GC 导致系统响应延迟从 50ms 飙升到 500ms。
根源:无状态对象无需重复创建,却被当成 “一次性用品”,浪费内存和 GC 资源。
2. 坑 2:单例模式用错 → 线程安全 + 内存泄漏
场景:支付系统用 “懒汉式单例” 创建PaymentClient
(持有 HTTP 连接池),但没加双重检查锁,高并发下创建多个实例,导致连接池耗尽。
后果:支付接口频繁报 “连接超时”,排查后发现 JVM 里有 12 个PaymentClient
实例,每个都占用 200 个连接。
根源:对 “对象创建的线程安全性” 理解不到位,单例模式实现不规范。
3. 坑 3:复杂对象创建参数混乱 → 代码可读性差
场景:物流系统的DeliveryOrder
对象有 15 个字段,创建时用new DeliveryOrder(a,b,c,d,...)
,参数顺序记错导致 “收件地址” 和 “发件地址” 颠倒。
后果:用户投诉 “快递送反了”,排查代码才发现是构造函数参数顺序写错,这种 bug 极难定位。
根源:没有用合适的创建模式(如建造者模式)管理复杂对象的创建逻辑。
这些坑让我明白:不懂对象创建的底层逻辑,就无法写出高效、安全的代码。接下来,我们从 JVM 视角拆解对象创建的完整流程。
二、底层解析:一个 Java 对象的 “诞生五步曲”
当你写下User user = new User("张三", 25)
时,JVM 会执行 5 个核心步骤。这部分是基础,但八年开发告诉我:理解这些步骤,才能在排查问题时 “知其然更知其所以然” 。
步骤 1:类加载检查 → “这个类存在吗?”
JVM 首先会检查:User
类是否已被加载到方法区?如果没有,会触发类加载流程(加载→验证→准备→解析→初始化)。
- 加载:从.class 文件读取字节码,生成
Class
对象(如User.class
)。 - 初始化:执行静态代码块(
static {}
)和静态变量赋值(如public static String ROLE = "USER"
)。
实战影响:如果类加载失败(比如依赖缺失),会抛出NoClassDefFoundError
。我曾在分布式项目中,因 jar 包版本冲突导致OrderService
类加载失败,排查了 3 小时才发现是依赖冲突。
步骤 2:分配内存 → “给对象找块地方放”
类加载完成后,JVM 会为对象分配内存(大小在类加载时已确定)。内存分配有两种核心方式,对应不同的 GC 收集器:
分配方式 | 原理 | 适用 GC 收集器 | 实战注意点 |
---|---|---|---|
指针碰撞 | 内存连续,用指针指向空闲区域边界,分配后移动指针 | Serial、ParNew | 需开启内存压缩(默认开启) |
空闲列表 | 内存不连续,维护空闲区域列表,从中选一块分配 | CMS、G1 | 避免内存碎片,需定期整理 |
实战影响:如果内存不足(Eden 区满了),会触发 Minor GC。我曾在秒杀系统中,因内存分配过快导致 Minor GC 每秒 3 次,后来通过 “对象池复用” 减少了 80% 的创建频率。
步骤 3:初始化零值 → “先把内存清干净”
内存分配完成后,JVM 会将分配的内存空间初始化为零值(如int
设为 0,String
设为null
)。这一步很关键:
- 为什么?因为它保证了对象的字段在未赋值时,也有默认值(避免垃圾值)。
- 实战坑:新人常以为 “没赋值的字段是随机值”,其实 JVM 已经帮你清为零了。
步骤 4:设置对象头 → “给对象贴个身-份-证”
JVM 会在对象内存的头部设置 “对象头”(Object Header),包含 3 类核心信息:
- Mark Word:存储对象的哈希码、锁状态(偏向锁 / 轻量级锁 / 重量级锁)、GC 年龄等。
- 实战用:排查死锁时,通过
jstack
查看线程持有锁的对象,就是靠 Mark Word 里的锁状态。
- 实战用:排查死锁时,通过
- Class Metadata Address:指向对象所属类的
Class
对象(如User.class
)。
- 实战用:反射时
user.getClass()
,就是通过这个指针找到Class
对象。
- 实战用:反射时
- Array Length:如果是数组对象,存储数组长度。
步骤 5:执行<init>()
方法 → “给对象穿衣服”
最后,JVM 会执行对象的构造函数(<init>()
方法),完成:
- 成员变量赋值(如
this.name = "张三"
)。 - 执行构造代码块(
{}
包裹的代码)。
这一步才是对象的 “最终初始化”,完成后,一个完整的对象就诞生了,指针会赋值给user
变量。
三、实战解析:怎么排查对象创建相关的问题?
八年开发中,我总结了 3 套 “对象创建问题排查方法论”,从工具到思路,都是踩坑后的精华。
1. 问题 1:对象创建太多 → 怎么找到 “罪魁祸首”?
症状:GC 频繁、内存占用高、响应延迟增加。
工具:jmap
(查看对象实例数)、Arthas
(实时排查)、VisualVM
(分析 GC 日志)。
实战步骤:
- 用
jmap -histo:live 进程ID | head -20
,查看存活对象 TOP20:
# 示例输出:OrderDTO有12345个实例,明显异常
num #instances #bytes class name
----------------------------------------------
1: 12345 1975200 com.example.OrderDTO
2: 8900 1424000 com.example.UserDTO
- 用 Arthas 的
trace
命令,查看OrderDTO
的创建位置:
trace com.example.OrderService createOrder -n 100
- 定位到循环中创建
OrderDTO
的代码,优化为 “复用对象” 或 “批量创建”。
2. 问题 2:对象创建慢 → 怎么定位瓶颈?
症状:创建对象耗时久(如复杂对象初始化)、类加载慢。
工具:jstat
(查看类加载耗时)、AsyncProfiler
(分析方法执行时间)。
实战步骤:
- 用
jstat -class 进程ID 1000
,查看类加载速度:
Loaded Bytes Unloaded Bytes Time
1234 234560 0 0 123.45 # Time是类加载总耗时,单位ms
- 若类加载慢,检查是否有 “大 jar 包” 或 “类冲突”;若对象初始化慢,用 AsyncProfiler 分析构造函数耗时。
3. 问题 3:单例对象多实例 → 怎么验证?
症状:单例类(如PaymentClient
)出现多实例,导致资源泄漏。
工具:jmap -dump:live,format=b,file=heap.hprof 进程ID
(dump 堆内存)、MAT
(分析堆快照)。
实战步骤:
- Dump 堆内存后,用 MAT 打开,搜索
PaymentClient
类。 - 查看 “Instance Count”,若大于 1,说明单例模式实现有问题(如没加双重检查锁)。
四、核心代码:对象创建的 5 种方式与实战选型
八年开发中,我用过 5 种对象创建方式,每种都有明确的适用场景,选错了就会踩坑。下面结合代码和业务场景对比分析:
1. new 关键字:最基础,但别滥用
代码:
// 普通对象创建
User user = new User("张三", 25);
// 注意:循环中避免频繁new无状态对象
List<User> userList = new ArrayList<>();
// 坑:每次循环都new,10万次循环创建10万个UserValidator
for (Order order : orderList) {
UserValidator validator = new UserValidator(); // 优化:改为单例或局部变量复用
validator.validate(order);
}
适用场景:简单对象、非频繁创建的对象。
八年经验:别在循环中new
临时对象,尤其是无状态工具类(如Validator
、Calculator
),改用单例或对象池。
2. 反射:灵活但性能差,慎用
代码:
try {
// 方式1:通过Class对象创建
Class<User> userClass = User.class;
User user = userClass.newInstance(); // 调用无参构造
// 方式2:通过Constructor创建(支持有参构造)
Constructor<User> constructor = userClass.getConstructor(String.class, int.class);
User user2 = constructor.newInstance("李四", 30);
} catch (Exception e) {
e.printStackTrace();
}
适用场景:框架开发(如 Spring IOC 容器)、动态创建对象。
八年经验:反射性能比new
慢 10-100 倍,业务代码中尽量不用;若用,建议缓存Constructor
对象(避免重复获取)。
3. 单例模式:解决 “重复创建” 问题
代码:枚举单例(线程安全、防反射、防序列化,八年开发首推)
// 枚举单例:支付客户端(持有HTTP连接池,需单例)
public enum PaymentClient {
INSTANCE;
// 初始化连接池(构造方法默认私有,线程安全)
private HttpClient httpClient;
PaymentClient() {
httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3))
.build();
}
// 提供全局访问点
public HttpClient getHttpClient() {
return httpClient;
}
}
// 使用:避免重复创建,全局复用
HttpClient client = PaymentClient.INSTANCE.getHttpClient();
适用场景:工具类、资源密集型对象(如连接池、线程池)。
八年经验:别用 “懒汉式单例”(线程安全问题多),优先用枚举或 “饿汉式 + 静态内部类”。
4. 建造者模式:解决 “复杂对象参数混乱”
代码:订单对象创建(15 个字段,用建造者模式避免参数顺序错误)
// 订单类:复杂对象,字段多
@Data
public class Order {
private String orderId;
private String userId;
private BigDecimal amount;
private String startAddress;
private String endAddress;
// 其他10个字段...
// 私有构造:只能通过建造者创建
private Order(Builder builder) {
this.orderId = builder.orderId;
this.userId = builder.userId;
this.amount = builder.amount;
this.startAddress = builder.startAddress;
this.endAddress = builder.endAddress;
// 其他字段赋值...
}
// 建造者
public static class Builder {
private String orderId;
private String userId;
private BigDecimal amount;
private String startAddress;
private String endAddress;
// 链式调用方法
public Builder orderId(String orderId) {
this.orderId = orderId;
return this;
}
public Builder userId(String userId) {
this.userId = userId;
return this;
}
public Builder amount(BigDecimal amount) {
this.amount = amount;
return this;
}
// 其他字段的set方法...
// 最终创建对象
public Order build() {
// 校验必填字段:避免创建不完整对象
if (orderId == null || userId == null) {
throw new IllegalArgumentException("订单ID和用户ID不能为空");
}
return new Order(this);
}
}
}
// 使用:链式调用,参数清晰,无顺序问题
Order order = new Order.Builder()
.orderId("ORDER_20250903_001")
.userId("USER_123")
.amount(new BigDecimal("99.9"))
.startAddress("重庆市机管局")
.endAddress("重庆市江北区机管局")
.build();
适用场景:字段超过 5 个的复杂对象(如订单、用户信息)。
八年经验:建造者模式不仅解决参数顺序问题,还能在build()
中做参数校验,避免创建 “残缺对象”。
5. 对象池:复用对象,减少创建开销
代码:用 Apache Commons Pool 实现OrderDTO
对象池(秒杀系统中复用临时对象)
// 1. 引入依赖
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
<version>2.11.1</version>
</dependency>
// 2. 定义对象工厂(创建和销毁对象)
public class OrderDTOFactory extends BasePooledObjectFactory<OrderDTO> {
// 创建对象
@Override
public OrderDTO create() {
return new OrderDTO();
}
// 包装对象(池化需要)
@Override
public PooledObject<OrderDTO> wrap(OrderDTO orderDTO) {
return new DefaultPooledObject<>(orderDTO);
}
// 归还对象前重置(避免数据残留)
@Override
public void passivateObject(PooledObject<OrderDTO> p) {
OrderDTO orderDTO = p.getObject();
orderDTO.setOrderId(null);
orderDTO.setUserId(null);
orderDTO.setAmount(null);
// 重置其他字段...
}
}
// 3. 配置对象池
public class OrderDTOPool {
private final GenericObjectPool<OrderDTO> pool;
public OrderDTOPool() {
// 配置池参数:最大空闲数、最大总实例数、超时时间等
GenericObjectPoolConfig<OrderDTO> config = new GenericObjectPoolConfig<>();
config.setMaxIdle(100); // 最大空闲对象数
config.setMaxTotal(200); // 池最大总实例数
config.setBlockWhenExhausted(true); // 池满时阻塞等待
config.setMaxWait(Duration.ofMillis(100)); // 最大等待时间
// 初始化池
this.pool = new GenericObjectPool<>(new OrderDTOFactory(), config);
}
// 从池获取对象
public OrderDTO borrowObject() throws Exception {
return pool.borrowObject();
}
// 归还对象到池
public void returnObject(OrderDTO orderDTO) {
pool.returnObject(orderDTO);
}
}
// 4. 实战使用:秒杀系统处理订单
public class SeckillService {
private final OrderDTOPool objectPool = new OrderDTOPool();
public void processOrders(List<OrderInfo> orderInfoList) {
for (OrderInfo info : orderInfoList) {
OrderDTO orderDTO = null;
try {
// 从池获取对象(复用,不new)
orderDTO = objectPool.borrowObject();
// 赋值并处理
orderDTO.setOrderId(info.getOrderId());
orderDTO.setUserId(info.getUserId());
orderDTO.setAmount(info.getAmount());
orderService.submit(orderDTO);
} catch (Exception e) {
log.error("处理订单失败", e);
} finally {
// 归还对象到池(关键:避免内存泄漏)
if (orderDTO != null) {
objectPool.returnObject(orderDTO);
}
}
}
}
}
适用场景:频繁创建临时对象的场景(如秒杀、批量处理)。
八年经验:对象池虽好,但别滥用 —— 只有当对象创建成本高(如初始化耗时久)且复用率高时才用,否则会增加复杂度。
五、八年开发的 8 条 “对象创建” 最佳实践
最后,总结 8 条实战经验,都是我踩过坑后总结的 “血泪教训”,能帮你避开 90% 的对象创建相关问题:
- 避免在循环中 new 临时对象:无状态工具类用单例,临时 DTO 用对象池。
- 复杂对象优先用建造者模式:字段超过 5 个就别用
new
了,参数顺序错了很难查。 - 单例模式别用懒汉式:优先枚举或静态内部类,线程安全且无反射漏洞。
- 别忽视对象的 “销毁” :使用对象池时,一定要在
finally
中归还对象,避免内存泄漏。 - 慎用 finalize () 方法:它会延迟对象回收(需要两次 GC),建议用
try-with-resources
管理资源。 - 监控对象实例数:线上系统定期用
jmap
检查,避免 “隐形” 的对象爆炸。 - 类加载别踩版本冲突:依赖冲突会导致类加载失败,用
mvn dependency:tree
排查。 - 对象创建不是越多越好:有时候 “复用” 比 “创建” 更高效,比如 String 用
intern()
复用常量池对象。
六、结尾:基础不牢,地动山摇
八年 Java 开发,我越来越觉得:真正的高手,不是会写多复杂的框架,而是能把基础问题理解透彻。对象创建看似简单,却关联着 JVM、GC、设计模式、性能优化等多个维度。
我见过太多新人因为不懂对象创建的底层逻辑,写出 “看似能跑,实则埋满坑” 的代码;也见过资深开发者通过优化对象创建,把系统 QPS 从 1 万提升到 10 万。
希望这篇文章能帮你从 “会用new
” 到 “懂创建”,在实战中写出更高效、更稳定的 Java 代码。如果有对象创建相关的踩坑经历,欢迎在评论区分享~
来源:juejin.cn/post/7545921037286047744
Nginx 内置变量详解:从原理到实战案例
Nginx 内置变量是 Nginx 配置中极具灵活性的核心特性,它们能动态获取请求、连接、服务器等维度的实时数据,让配置从“固定模板”升级为“智能响应”。本文将系统梳理常用内置变量的分类、含义,并结合实战案例说明其应用场景,帮助你真正用好 Nginx 变量。
一、Nginx 内置变量的核心特性
在深入变量前,先明确两个关键特性:
- 动态性:变量值并非固定,而是在每次请求处理时实时生成(如
$remote_addr
会随客户端 IP 变化)。 - 作用域:变量仅在当前请求的处理周期内有效,不同请求的变量值相互独立。
- 命名规则:所有内置变量均以
$
开头,如$uri
、$status
。
二、常用内置变量分类与含义
按“数据来源”可将内置变量分为 5 大类,涵盖请求、连接、服务器、响应等核心场景。
1. 请求相关变量(获取客户端请求信息)
这类变量用于获取客户端发送的请求细节,是最常用的变量类型。
变量名 | 含义 | 示例 |
---|---|---|
$remote_addr | 客户端真实 IP 地址(未经过代理时) | 192.168.230.1 (本地局域网 IP) |
$arg_xxx | 获取 URL 中 xxx 对应的参数值(xxx 为参数名) | 请求 http://xxx/?id=123 时,$arg_id=123 |
$args | 完整的 URL 请求参数(? 后面的所有内容) | 请求 http://xxx/?id=123&name=test 时,$args=id=123&name=test |
$request_method | 客户端请求方法(GET/POST/PUT/DELETE 等) | GET 、POST |
$request_uri | 完整的请求 URI(包含路径和参数,不包含域名) | 请求 http://xxx/api/user?uid=1 时,$request_uri=/api/user?uid=1 |
$uri / $document_uri | 请求的 URI 路径(不含参数,两者功能几乎一致) | 请求 http://xxx/api/user?uid=1 时,$uri=/api/user |
$http_xxx | 获取请求头中 xxx 字段的值(xxx 为请求头名,需将 - 改为小写) | 获取 User-Agent 时用 $http_user_agent ,获取 Referer 时用 $http_referer |
$cookie_xxx | 获取客户端 Cookie 中 xxx 对应的 value | 客户端 Cookie 为 token=abc123 时,$cookie_token=abc123 |
2. 连接相关变量(获取网络连接信息)
用于获取客户端与服务器之间的连接状态,常用于连接追踪和并发控制。
变量名 | 含义 | 示例 |
---|---|---|
$connection | 客户端与服务器的唯一连接 ID(每次新连接会生成新 ID) | 12345 (数字型 ID) |
$connection_requests | 当前连接上已处理的请求次数(长连接场景下会累计) | 同一连接发起第 3 次请求时,值为 3 |
$remote_port | 客户端用于连接的端口号 | 54321 (客户端随机端口) |
$server_port | 服务器监听的端口号(当前请求命中的端口) | 80 (HTTP)、443 (HTTPS) |
3. 服务器相关变量(获取服务器自身信息)
用于获取 Nginx 服务器的配置和系统信息,常用于多服务器部署场景。
变量名 | 含义 | 示例 |
---|---|---|
$server_addr | 服务器处理当前请求的 IP 地址(多网卡时对应绑定的 IP) | 192.168.230.130 (服务器内网 IP) |
$server_name | 当前请求命中的 server 块的 server_name 配置值 | 若 server_name http://www.example.com ,则值为 http://www.example.com |
$hostname | 服务器的系统主机名(与 hostname 命令输出一致) | centos-nginx-server |
4. 响应相关变量(获取 Nginx 响应信息)
用于记录 Nginx 向客户端返回的响应数据,常用于日志统计和性能分析。
变量名 | 含义 | 示例 |
---|---|---|
$status | 响应的 HTTP 状态码 | 200 (成功)、404 (未找到)、502 (网关错误) |
$body_bytes_sent | 发送给客户端的响应体大小(单位:字节,不含响应头) | 返回 1KB 文本时,值为 1024 |
$bytes_sent | 发送给客户端的总字节数(含响应头 + 响应体) | 通常比 $body_bytes_sent 大 100-200 字节(响应头占比) |
$request_time | 请求的总处理耗时(单位:秒,精确到毫秒) | 0.005 (表示 5 毫秒) |
5. 时间相关变量(获取时间信息)
用于记录请求处理的时间,常用于日志时间戳和时间范围控制。
变量名 | 含义 | 示例 |
---|---|---|
$msec | 请求处理完成时的 Unix 时间戳(含毫秒,从 1970-01-01 开始) | 1724325600.123 (对应 2024-08-22 11:20:00.123) |
$time_local | 服务器本地时间(格式化字符串,含时区) | 22/Aug/2024:11:20:00 +0800 (+0800 表示北京时间) |
$time_iso8601 | ISO 8601 标准时间(UTC 时间,无时区偏移) | 2024-08-22T03:20:00+00:00 |
三、内置变量实战案例
了解变量含义后,关键是知道“在什么场景用什么变量”。以下 5 个实战案例覆盖日志、鉴权、跳转、限流等高频场景。
案例 1:自定义访问日志(记录关键请求信息)
默认的 Nginx 访问日志仅包含基础信息,通过变量可自定义日志格式,记录如“客户端 IP、请求方法、参数、耗时”等关键数据,方便后续分析。
配置步骤:
- 在
nginx.conf
的http
块中定义日志格式:
http {
# 1. 定义自定义日志格式(命名为 "detail_log")
log_format detail_log '$remote_addr [$time_local] "$request_method $request_uri" '
'status:$status args:"$args"耗时:$request_time '
'user_agent:"$http_user_agent"';
# 2. 启用自定义日志(指定日志路径和格式)
access_log /usr/local/nginx/logs/detail_access.log detail_log;
# 其他配置...
}
- 重载 Nginx 配置:
/usr/local/nginx/sbin/nginx -t # 检查语法
/usr/local/nginx/sbin/nginx -s reload # 重载生效
日志效果:
访问 http://192.168.230.130/?id=123
后,日志文件会生成如下记录:
192.168.230.1 [22/Aug/2024:11:30:00 +0800] "GET /?id=123" status:200 args:"id=123"耗时:0.002 user_agent:"Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/127.0.0.1"
案例 2:URL 参数鉴权(限制特定参数访问)
场景:仅允许 token
参数为 abc123
的请求访问 /admin
路径,否则返回 403 禁止访问。
配置步骤:
在 server
块中添加 location
规则:
server {
listen 80;
server_name localhost;
# 匹配 /admin 路径
location /admin {
# 1. 检查 $arg_token(URL 中 token 参数)是否等于 abc123
if ($arg_token != "abc123") {
return 403 "Forbidden: Invalid token\n"; # 不匹配则返回 403
}
# 2. 匹配通过时的处理(如返回 admin 页面)
default_type text/html;
return 200 "<h1>Admin Page (Token Valid)</h1>";
}
}
测试效果:
- 合法请求:
http://192.168.230.130/admin?token=abc123
→ 返回 200 和 Admin 页面。 - 非法请求:
http://192.168.230.130/admin?token=wrong
→ 返回 403 和 “Forbidden”。
案例 3:根据客户端 IP 跳转(本地 IP 免验证)
场景:局域网 IP(192.168.230.xxx
)访问 /login
时直接跳转至首页,其他 IP 正常显示登录页。
配置步骤:
利用 $remote_addr
判断客户端 IP,结合 rewrite
实现跳转:
server {
listen 80;
server_name localhost;
location /login {
# 1. 匹配局域网 IP(以 192.168.230. 开头)
if ($remote_addr ~* ^192\.168\.230\.) {
rewrite ^/login$ / permanent; # 301 永久跳转到首页
}
# 2. 其他 IP 显示登录页
default_type text/html;
return 200 "<h1>Login Page (Non-Local IP)</h1>";
}
# 首页配置
location / {
default_type text/html;
return 200 "<h1>Home Page (Local IP Bypassed Login)</h1>";
}
}
测试效果:
- 本地 IP(如
192.168.230.1
)访问/login
→ 自动跳转到/
(首页)。 - 外部 IP(如
10.0.0.1
)访问/login
→ 显示登录页。
案例 4:根据请求头切换后端服务(前后端分离场景)
场景:请求头 X-Request-Type
为 api
时,转发请求到后端 API 服务(127.0.0.1:8080
);否则返回静态页面。
配置步骤:
利用 $http_x_request_type
获取自定义请求头,结合 proxy_pass
实现反向代理:
server {
listen 80;
server_name localhost;
location / {
# 1. 判断请求头 X-Request-Type 是否为 api
if ($http_x_request_type = "api") {
proxy_pass http://127.0.0.1:8080; # 转发到 API 服务
proxy_set_header Host $host; # 传递 Host 头给后端
break; # 跳出 if,避免后续执行
}
# 2. 其他请求返回静态首页
root /usr/local/nginx/html;
index index.html;
}
}
测试效果:
- 发送 API 请求(带请求头):
curl -H "X-Request-Type: api" http://192.168.230.130/api/user
→ 请求被转发到
127.0.0.1:8080/api/user
。 - 普通访问:
http://192.168.230.130
→ 返回/usr/local/nginx/html/index.html
。
案例 5:基于 Cookie 实现灰度发布(部分用户尝鲜新功能)
场景:Cookie 中 version=beta
的用户访问 /feature
时,返回新功能页面;其他用户返回旧页面。
配置步骤:
利用 $cookie_version
获取 Cookie 值,实现灰度分流:
server {
listen 80;
server_name localhost;
location /feature {
default_type text/html;
# 1. 检查 Cookie 中 version 是否为 beta
if ($cookie_version = "beta") {
return 200 "<h1>New Feature (Beta Version)</h1>"; # 新功能
}
# 2. 其他用户显示旧功能
return 200 "<h1>Old Feature (Stable Version)</h1>";
}
}
测试效果:
- 灰度用户(带 Cookie):
curl -b "version=beta" http://192.168.230.130/feature
→ 返回 “New Feature (Beta Version)”。
- 普通用户(无 Cookie):
curl http://192.168.230.130/feature
→ 返回 “Old Feature (Stable Version)”。
四、使用内置变量的注意事项
- 避免过度使用
if
指令:Nginx 的if
指令在某些场景下可能触发意外行为(如与try_files
冲突),复杂逻辑优先用map
指令或 Lua 脚本。 - 代理场景下的 IP 问题:若 Nginx 位于代理服务器后(如 CDN、负载均衡器),
$remote_addr
会变为代理 IP,需通过$http_x_forwarded_for
获取客户端真实 IP(需代理服务器传递该请求头)。 - 变量大小写敏感:
$arg_id
和$arg_ID
是两个不同的变量(前者对应?id=1
,后者对应?ID=1
),配置时需注意参数名大小写。 - 性能影响:内置变量本身性能开销极低,但频繁使用复杂正则匹配(如
if ($remote_addr ~* ...)
)可能增加 CPU 消耗,高并发场景需优化正则。
五、总结
Nginx 内置变量是连接“静态配置”与“动态请求”的桥梁,掌握它们能让你摆脱固定配置的束缚,实现更灵活的请求处理逻辑。本文梳理的 5 大类变量和实战案例,覆盖了日志、鉴权、跳转、代理、灰度等高频场景,建议结合实际需求动手测试——只有在实践中反复使用,才能真正理解变量的威力。
如果需要更复杂的场景(如结合 map
指令批量处理变量、Lua 脚本扩展变量功能),可以进一步深入学习 Nginx 高级配置技巧。
来源:juejin.cn/post/7543193023086575625
分支管理提交代码
前言:公司代码需要先放在开发分支上,后面提交的时候再合并到master主分支
开始复现场景:
从git branch -a
输出来看,你的本地分支是 develop
,而远程仓库中存在两个分支:origin/master
和 origin/develop
。此外,remotes/origin/HEAD
指向了 origin/master
,这表明远程仓库的默认分支是 master
。
问题分析
你提到想把分支合并到 master
主分支上去,但 git branch
没有显示 master
分支。根据输出信息,可以确认以下几点:
- 本地没有
master
分支:当前只有develop
分支。 - 远程存在
master
分支:remotes/origin/master
表明远程仓库中有master
分支。 - 远程默认分支是
master
:remotes/origin/HEAD -> origin/master
表明远程仓库的默认分支是master
。
解决方案
1️⃣ 创建并切换到本地 master
分支
首先,你需要在本地创建一个 master
分支,并将其与远程的 master
分支关联起来。
# 创建并切换到本地 master 分支,跟踪远程 origin/master
git checkout -b master origin/master
2️⃣ 将 develop
分支合并到 master
分支
确保你在 master
分支上,然后将 develop
分支的内容合并进来。
bash
深色版本
# 确保你在 master 分支上
git checkout master
# 合并 develop 分支
git merge develop
3️⃣ 解决可能的冲突 (没有冲突的话不需要执行这两条命令)
如果在合并过程中出现冲突,Git 会提示你手动解决这些冲突。解决冲突后,使用以下命令继续合并过程:
# 解决冲突后,添加已修改的文件
git add .
# 继续合并
git commit
4️⃣ 推送更改到远程 master
分支
最后,将合并后的更改推送到远程 master
分支。
bash
深色版本
# 推送更改到远程 master 分支
git push origin master
🛠️ 进阶操作:设置默认上游分支
如果你希望以后直接使用 git pull
和 git push
而不需要每次都指定分支,可以设置默认上游分支:
bash
深色版本
# 设置 master 分支的上游为 origin/master
git branch --set-upstream-to=origin/master master
这样,当你在 master
分支上时,可以直接使用 git pull
和 git push
命令,而不需要额外指定分支名。
报错了
远程仓库 master
分支包含了本地分支没有的更改。因此,Git 拒绝了你的推送请求,以防止丢失远程仓库中的工作。
错误原因
这个错误通常有以下几种可能的原因:
- 远程仓库有新的提交:在你上次拉取(
git pull
)之后,其他开发者可能已经向远程仓库的master
分支推送了新的提交。 - 非快进式推送:你的本地
master
分支与远程master
分支之间的差异无法通过简单的“快进”操作来解决,需要进行合并或变基。
解决方案
1️⃣ 先拉取远程仓库的最新更改
你需要先从远程仓库拉取最新的更改,然后将这些更改合并到你的本地分支中。
# 切换到 master 分支
git checkout master
# 从远程仓库拉取最新的更改
git pull origin master
如果 git pull
成功执行,它会自动将远程仓库的更改合并到你的本地分支中。如果有冲突,你需要手动解决这些冲突。
2️⃣ 解决冲突(如有必要)
如果在 git pull
过程中出现冲突,Git 会提示你手动解决这些冲突。解决冲突后,使用以下命令继续合并过程:
# 解决冲突后,添加已修改的文件
git add .
# 继续合并
git commit
3️⃣ 再次尝试推送更改
解决了所有冲突并完成合并后,你可以再次尝试将本地更改推送到远程仓库:
# 推送更改到远程 master 分支
git push origin master
还是报错
从你提供的错误信息来看,当前的状态是 MERGING
,这表明你在合并过程中遇到了问题,并且还没有完成合并。因此,Git 拒绝了你的推送请求。
错误原因
- 合并尚未完成:你正在进行一个合并操作(可能是之前执行的
git merge
或git pull
),但还没有解决所有的冲突或完成合并。 - 本地分支落后于远程分支:即使解决了合并冲突,本地分支可能仍然落后于远程分支,需要先拉取最新的更改。
解决方案
1️⃣ 检查并完成合并
首先,你需要确保合并操作已经完成。如果存在未解决的冲突,你需要手动解决这些冲突。
查看当前状态,确认是否有未解决的冲突(查看本地和远程 master 分支的差异)
git status
如果 git status
提示你的本地分支落后于远程分支(例如 Your branch is behind 'origin/master' by X commits
),你需要先拉取最新的更改。
如果有冲突文件,打开这些文件并手动解决冲突
解决冲突后,添加已修改的文件并提交:
# 添加已解决冲突的文件
git add .
# 提交合并
git commit
2️⃣ 拉取远程仓库的最新更改
在完成合并后,再次尝试拉取远程仓库的最新更改,以确保本地分支是最新的。
# 切换到 master 分支
git checkout master
# 从远程仓库拉取最新的更改
git pull origin master
如果 git pull
成功执行,它会自动将远程仓库的更改合并到你的本地分支中。如果有冲突,你需要手动解决这些冲突。
3️⃣ 再次尝试推送更改
解决了所有冲突并完成合并后,你可以再次尝试将本地更改推送到远程仓库:
# 推送更改到远程 master 分支
git push origin master
我本地如果删除了几个文件再更新到仓库去要怎么办?
你只需要用 git add .
或 git rm
告诉 Git “这些文件是我有意删除的”,然后提交并推送到远程仓库即可。Git 会把“删除”也当作一次更改记录下来。
✅ 正确操作步骤(推荐)
1️⃣ 确认你已经删除了文件
你已经在本地手动删除了一些文件(比如用资源管理器或 rm
命令删的)。
2️⃣ 让 Git 检测到这些文件被删除
运行以下命令,Git 会发现哪些文件被删了,并把“删除”操作加入暂存区:
bash
深色版本
git add --all
# 或者简写:
git add .
💡 注意:
虽然 git add .
通常用于“添加新文件”,但它也会检测到被删除的文件,并自动执行 git rm
的效果。
⚠️ 如果你只用
git commit
而不先add
,Git 不会自动把删除纳入提交。
3️⃣ 提交删除操作
bash
深色版本
git commit -m "删除了不再需要的文件:xxx, yyy"
4️⃣ 推送到远程仓库
bash
深色版本
git push origin master
# 或者 main,根据你的分支名
✅ 完成!远程仓库也会删除这些文件。
来源:juejin.cn/post/7549096640340492338
索引失效的场景有哪些?
本文首发于公众号:托尼学长,立个写 1024 篇原创技术面试文章的flag,欢迎过来视察监督~
关于SQL语句的面试题中,有两个被面试官问到的频率特别高,一个是SQL优化,另外一个则是索引失效。
接下来我们就来一一进行盘点,索引失效的场景到底有哪些。
准备工作
我们先来创建一张订单表,并为该表生成100w+的数据,这样SQL语句所对应的执行计划会更加准确,表结构如下:
CREATE TABLE `tony_order` (
`id` bigint UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '订单ID',
`product_id` int NOT NULL COMMENT '商品ID',
`user_id` int NOT NULL COMMENT '用户ID',
`status` tinyint NOT NULL COMMENT '状态',
`discount_amount` int NOT NULL COMMENT '总金额',
`total_amount` int NOT NULL COMMENT '打折金额',
`payable_amount` int NOT NULL COMMENT '实际支付金额',
`receiver_name` varchar(255) DEFAULT NULL COMMENT '收件人名称',
`receiver_phone` varchar(255) DEFAULT NULL COMMENT '收件人手机号',
`receiver_address` varchar(255) DEFAULT NULL COMMENT '收件人地址',
`note` varchar(255) DEFAULT NULL COMMENT '备注',
`payment_time` datetime NULL DEFAULT NULL COMMENT '支付时间',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id` DESC) USING BTREE,
INDEX `idx_product_id`(`product_id` ASC) USING BTREE,
INDEX `idx_user_id_total_amount`(`user_id` ASC, `total_amount` ASC) USING BTREE,
INDEX `idx_create_time`(`create_time` ASC) USING BTREE,
INDEX `idx_update_time`(`update_time` ASC) USING BTREE,
INDEX `idx_status`(`status` ASC) USING BTREE,
INDEX `idx_receiver_phone`(`receiver_phone` ASC) USING BTREE,
INDEX `idx_receiver_name`(`receiver_name` ASC) USING BTREE,
INDEX `idx_receiver_address`(`receiver_address` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 ROW_FORMAT = Dynamic;
接下来我们来一一验证下索引失效的场景。
索引失效场景
1、不遵循最左前缀原则
SELECT * FROM tony_order WHERE total_amount = 100;
我们从执行计划中可以看到,这条SQL语句走的是全表扫描,即使创建了索引idx_user_id_total_amount也没有生效。
但由于其total_amount字段没有在联合索引的最左边,不符合最左前缀原则。
SELECT * FROM tony_order WHERE user_id = 4323 AND total_amount = 101;
当我们把user_id这个字段补上之后,果然就可以用上索引了。
在MySQL 8.0 版本以后,联合索引的最左前缀原则不再那么绝对了,其引入了Skip Scan Range Access Method机制,可对区分度较低的字段进行优化。
感兴趣的同学可以去看下,本文中就不过多展开描述了。
2、LIKE百分号在左边
SELECT * FROM tony_order WHERE receiver_address LIKE '%北京市朝阳区望京SOHO';
SELECT * FROM tony_order WHERE receiver_address LIKE '%北京市朝阳区望京SOHO%';
执行上面这两条SQL语句,结果都是一样的,走了全表扫描。
接下来我们将SQL语句改为%在右边,再执行一次看看。
SELECT * FROM tony_order WHERE receiver_address LIKE '北京市朝阳区望京SOHO%';
这个原理很好理解,联合索引需要遵循最左前缀原则,而单个索引LIKE的情况下,也需要最左边能够匹配上才行,否则就会导致索引失效。
3、使用OR关键字
有一种说法,只要使用OR关键字就会导致索引失效,我们来试试。
SELECT * FROM tony_order WHERE receiver_name = 'Tony学长' OR user_id = 41323;
从结果中我们可以看到,索引并没有失效,聪明的查询优化器将receiver_name和user_id两个字段上的索引进行了合并。
接下来我们再换个SQL试试。
SELECT * FROM tony_order WHERE receiver_phone = '13436669764' OR user_id = 4323;
这次确实索引失效了,由于receiver_phone这个字段上并没有创建索引,所以无法使用索引合并操作了,只能走全表扫描。
有的同学会问,那为什么user_id上的索引也失效了呢?
因为一个字段走索引,另一个字段走全表扫描是没有意义的,反而不如走一次全表扫描查找两个字段更有效率。
所以,有时候索引失效未必是坏事,而是查询优化器做出的最优解。
4、索引列上有函数
SELECT * FROM tony_order WHERE ABS (user_id) = 4323;
SELECT * FROM tony_order WHERE LEFT (receiver_address, 3)
这个不用过多解释了,就是MySQL的一种规范,违反就会导致索引失效。
5、索引列上有计算
SELECT * FROM tony_order WHERE user_id + 1 = 4324;
这个也不用过多解释了,还是MySQL的一种规范,违反就会导致索引失效。
6、字段隐式转换
SELECT * FROM tony_order WHERE receiver_phone = 13454566332;
手机号字段明明是字符类型,却在SQL中不慎写成了数值类型而导致隐式转换,最终导致receiver_phone字段上的索引失效。
SELECT * FROM tony_order WHERE receiver_phone = '13454566332';
当我们把手机号加上单引号之后,receiver_phone字段的索引就生效了,整个天空都放晴了。
SELECT * FROM tony_order WHERE product_id = '12345';
我们接着尝试,把明明是数值型的字段写成了字符型,结果是正常走的索引。
由此得知,当发生隐式转换时,把数值类型的字段写成字符串类型没有影响,反之,但是把字符类型的字段写成数值类型,则会导致索引失效。
7、查询记录过多
SELECT * FROM tony_order WHERE product_id NOT IN (12345,12346);
那么由此得知,使用NOT IN关键字一定会导致索引失效?先别着急下结论。
SELECT * FROM tony_order WHERE status NOT IN (0,1);
从执行计划中可以看到,status字段上的索引生效了,为什么同样使用了NOT IN关键字,结果却不一样呢?
因为查询优化器会对SQL语句的查询记录数量进行评估,如果表中有100w行数据,这个SQL语句要查出来90w行数据,那当然走全表扫描更快一些,毕竟少了回表查询这个步骤。
反之,如果表中有100w行数据,这个SQL语句只需要查出来10行数据,那当然应该走索引扫描。
SELECT * FROM tony_order WHERE status IN (0,1);
同样使用IN关键字进行查询,只要查询出来的记录数过于庞大,都会通过全表扫描来代替索引扫描。
SELECT * FROM tony_order WHERE status = 0;
甚至我们不使用IN、NOT IN、EXISTS、NOT EXISTS这些关键字,只使用等号进行条件筛选同样会走全表扫描,这时不走索引才是最优解。
8、排序顺序不同
SELECT * FROM tony_order ORDER BY user_id DESC,total_amount ASC
我们可以看下,这条SQL语句中的user_id用了降序,而total_amount用了升序,所以导致索引失效。
SELECT * FROM tony_order ORDERBY user_id ASC,total_amount ASC
而下面这两条SQL语句中,无论使用升序还是降序,只要顺序一致就可以使用索引扫描。
来源:juejin.cn/post/7528296510229823530
为什么我的第一个企业级MCP项目上线3天就被叫停?
graph TB
A[企业AI需求] --> B[MCP企业架构]
B --> C[安全体系]
B --> D[运维管理]
B --> E[实施路径]
C --> C1[身份认证]
C --> C2[数据保护]
C --> C3[访问控制]
D --> D1[自动化部署]
D --> D2[监控告警]
D --> D3[成本优化]
E --> E1[MVP阶段]
E --> E2[扩展阶段]
E --> E3[优化阶段]
style A fill:#FFE4B5
style B fill:#90EE90
style C fill:#87CEEB
style D fill:#DDA0DD
style E fill:#F0E68C
3分钟速读:企业级MCP部署不同于个人使用,需要考虑安全合规、高可用性、统一管理等复杂需求。本文提供从架构设计到运维管理的完整企业级MCP平台构建方案,包含安全框架、监控体系和分阶段实施路径,帮助企业构建统一、安全、可扩展的AI工具平台。
"系统上线第三天就被安全部门紧急叫停,所有人都在会议室里看着我。"
那是我职业生涯中最尴尬的时刻之一。作为一家500人科技公司的架构师,我以为把个人版的MCP简单放大就能解决企业的AI工具集成问题。结果呢?权限混乱、数据泄露风险、合规审计不通过...
CEO当时问我:"我们现在有20多个团队在用各种AI工具,每个团队都有自己的一套,你觉得这样下去会不会出问题?"我当时信心满满地说:"没问题,给我两周时间。"
现在想想,那时的我真是太天真了。个人用Claude Desktop配置几个MCP服务器确实10分钟就搞定,但企业级别?完全是另一个世界。
从那次失败中我学到:企业级MCP部署面临的不是技术问题,而是管理和治理的系统性挑战。
🏢 企业AI工具集成的挑战与机遇
个人vs企业:天壤之别的复杂度
当我们从个人使用转向企业级部署时,复杂度呈指数级增长:
个人使用场景:
- 用户:1个人
- 数据:个人文件和少量API
- 安全:基本的API密钥管理
- 管理:手动配置即可
企业级场景:
- 用户:数百到数千人
- 数据:敏感业务数据、客户信息、财务数据
- 安全:严格的合规要求、审计需求
- 管理:统一配置、权限控制、监控告警
从我参与的十几个企业AI项目来看,大家基本都会遇到这几个头疼的问题:
1. 数据安全这道坎
企业数据可不比个人文件,涉及客户隐私、商业机密,动不动就要符合GDPR、HIPAA这些法规。我见过一个金融客户,光是数据分类就搞了3个月,更别说传统的个人化MCP配置根本过不了合规这关。
2. 权限管理的平衡艺术
这个真的很难搞。不同部门、不同级别的人要访问的数据和工具都不一样。既要保证"最小权限原则",又不能让用户觉得太麻烦。我之前遇到过一个案例,权限设置太严格,结果销售团队抱怨查个客户信息都要申请半天。
3. 成本控制的现实考验
这个问题往往被低估。当几百号人同时用AI工具时,API调用费用真的会让财务部门头疼。我见过一家公司,第一个月账单出来,CFO直接找到CTO问是不是系统被攻击了。
4. 运维管理的复杂度爆炸
分散部署最大的问题就是运维。每个团队都有自己的一套,出了问题谁来解决?性能怎么优化?我们之前有个客户,光是梳理现有的AI工具部署情况就花了两周时间。
MCP在企业环境中的价值主张
正是在这样的背景下,MCP的企业级价值才真正显现:
- 统一标准:一套协议解决所有AI工具集成问题
- 集中管理:统一的配置、监控、审计
- 安全可控:标准化的安全框架和权限管理
- 成本透明:集中的资源使用监控和成本分析
我们最近做了个小范围调研,发现用了统一MCP平台的几家企业,AI工具管理成本大概能降低50-70%,安全事件也确实少了很多。虽然样本不大,但趋势还是挺明显的。
📊 企业级需求分析:规模化部署的关键考量
在动手设计企业级MCP方案之前,我觉得最重要的是先搞清楚企业到底需要什么。这些年参与了十几个项目下来,我发现企业级MCP部署基本都绕不开这几个核心需求:
多团队协作需求
场景复杂性:
- 研发团队:需要访问代码仓库、CI/CD系统、Bug跟踪系统
- 销售团队:需要CRM系统、客户数据、销售报表
- 运营团队:需要监控系统、日志分析、业务指标
- 财务团队:需要ERP系统、财务报表、合规数据
每个团队的需求不同,但又需要在统一的安全框架下协作。
安全合规要求
企业级部署必须满足严格的安全合规要求:
合规标准 | 主要要求 | MCP实现方案 |
---|---|---|
GDPR | 数据主体权利、数据最小化 | 细粒度权限控制、数据脱敏 |
SOX | 财务数据完整性、审计跟踪 | 完整审计日志、不可篡改记录 |
ISO27001 | 信息安全管理体系 | 全面安全控制框架 |
HIPAA | 医疗数据保护 | 加密传输、访问控制 |
性能和可用性要求
企业级应用对性能和可用性有严格要求:
- 可用性:99.9%以上(年停机时间<8.77小时)
- 响应时间:95%的请求在2秒内响应
- 并发能力:支持数千用户同时访问
- 数据一致性:确保跨系统数据同步
成本控制需求
企业需要精确的成本控制和预算管理:
- 成本透明:每个部门、每个项目的AI使用成本清晰可见
- 预算控制:设置使用上限,避免成本失控
- 优化建议:基于使用数据提供成本优化建议
🏗️ MCP企业级架构设计:构建统一工具平台
说到架构设计,我必须承认,刚开始接触企业级MCP时,我也走过不少弯路。最开始我想的太简单,以为把个人版的MCP放大就行了,结果第一个项目就翻车了——系统上线第三天就因为权限问题被安全部门叫停。
后来痛定思痛,我重新设计了一套分层的企业级MCP架构。这套架构现在已经在好几个项目中验证过了,既能应对复杂的业务需求,扩展性也不错。
整体架构方案
graph TB
subgraph "用户层"
A[Web界面]
B[IDE插件]
C[移动应用]
D[API接口]
end
subgraph "网关层"
E[MCP网关]
F[负载均衡器]
G[API网关]
end
subgraph "服务层"
H[认证服务]
I[权限服务]
J[MCP服务注册中心]
K[配置管理中心]
end
subgraph "工具层"
L[开发工具MCP服务器]
M[数据工具MCP服务器]
N[业务工具MCP服务器]
O[监控工具MCP服务器]
end
subgraph "数据层"
P[关系数据库]
Q[文档数据库]
R[缓存层]
S[日志存储]
end
A --> E
B --> E
C --> E
D --> G
E --> F
G --> F
F --> H
F --> I
H --> J
I --> J
J --> K
K --> L
K --> M
K --> N
K --> O
L --> P
M --> Q
N --> R
O --> S
核心组件详解
1. MCP网关层
功能职责:
- 路由管理:智能路由请求到合适的MCP服务器
- 负载均衡:分发请求,确保系统稳定性
- 安全认证:统一的身份验证和授权
- 限流控制:防止系统过载,保护后端服务
核心特性:支持智能路由、负载均衡、限流控制和统一认证,确保系统稳定性和安全性。
2. 服务注册中心
核心功能:
- 服务发现:自动发现和注册MCP服务器
- 健康检查:实时监控服务器状态
- 配置同步:统一的配置管理和分发
- 版本管理:支持服务的灰度发布和回滚
技术要点:采用分布式注册中心架构,支持服务自动注册、健康检查和配置热更新。
3. 配置管理中心
管理内容:
- 服务器配置:MCP服务器的连接参数和功能配置
- 权限配置:用户和角色的权限矩阵
- 业务配置:各种业务规则和策略配置
- 环境配置:开发、测试、生产环境的差异化配置
高可用性设计
为确保企业级的可用性要求,架构中集成了多种高可用保障机制:
1. 多活部署
- 多个数据中心同时提供服务
- 自动故障切换,RTO < 30秒
- 数据实时同步,RPO < 5分钟
2. 弹性扩容
- 基于负载自动扩容
- 支持水平扩展和垂直扩展
- 预测性扩容,提前应对流量高峰
3. 容错机制
- 服务熔断,防止雪崩效应
- 优雅降级,保证核心功能可用
- 重试机制,处理临时性故障
🔐 安全架构设计:保障企业数据安全
在企业环境中,安全绝对不是可选项。这个教训我学得特别深刻——前面提到的那个翻车项目,就是因为我低估了企业对安全的要求。现在我设计MCP安全架构时,坚持用"纵深防御"策略,每一层都要有安全控制,宁可麻烦一点,也不能留安全隐患。
身份认证和授权体系
1. 多层次身份认证
graph LR
A[用户登录] --> B[SSO认证]
B --> C[MFA验证]
C --> D[JWT Token]
D --> E[API访问]
B --> B1[LDAP/AD]
B --> B2[OAuth2.0]
B --> B3[SAML]
C --> C1[短信验证码]
C --> C2[TOTP]
C --> C3[生物识别]
技术实现:集成主流SSO提供商(Azure AD、Okta、Google),支持多种MFA方式,采用JWT令牌管理会话。
2. 基于角色的访问控制(RBAC)
权限模型设计:
# 权限配置示例
roles:
- name: developer
permissions:
- mcp:tools:code:read
- mcp:tools:code:execute
- mcp:resources:docs:read
- name: data_analyst
permissions:
- mcp:tools:database:read
- mcp:tools:analytics:execute
- mcp:resources:data:read
- name: admin
permissions:
- mcp:*:*:* # 超级管理员权限
users:
- username: john.doe
roles: [developer]
additional_permissions:
- mcp:tools:deploy:execute # 额外权限
数据安全保护
1. 端到端加密
- 传输加密:所有MCP通信使用TLS 1.3
- 存储加密:敏感数据AES-256加密存储
- 密钥管理:使用HSM或云KMS管理加密密钥
2. 数据脱敏和分类
核心功能:自动识别敏感数据类型(邮箱、手机、身-份-证等),根据预设规则进行脱敏处理,确保数据隐私保护。
网络安全防护
1. API网关安全策略
- DDoS防护:智能识别和阻断攻击流量
- WAF规则:防护SQL注入、XSS等常见攻击
- IP白名单:限制访问来源IP范围
- 请求限流:防止API滥用
2. 网络隔离
安全策略:采用DMZ、内部服务区、数据库区三层网络隔离,通过防火墙规则严格控制服务间通信。
审计日志和合规
1. 全链路审计
审计范围:记录所有MCP访问操作,包括用户身份、操作类型、访问资源、操作结果、IP地址等关键信息,确保操作可追溯。
2. 合规报告自动生成
- 访问报告:用户访问行为分析
- 权限报告:权限使用情况统计
- 异常报告:安全异常事件汇总
- 合规检查:自动化合规性检查
⚙️ 运维管理体系:确保稳定高效运行
运维这块儿,说实话是我最头疼的部分。技术方案设计得再好,如果运维跟不上,照样会出问题。我见过太多项目,前期开发得很顺利,上线后各种运维问题层出不穷。所以现在我做企业级MCP平台时,会把运维管理当作一个系统工程来对待,从部署、监控到优化,每个环节都要考虑周全。
自动化部署体系
1. CI/CD流水线设计
流水线阶段:测试→构建→部署开发环境→预发布→生产环境,每个阶段都包含自动化测试、安全扫描和质量检查。
2. 蓝绿部署和灰度发布
蓝绿部署策略:新版本部署到绿环境→健康检查→流量切换→清理旧环境,确保零停机部署。
监控告警系统
1. 多维度监控指标
监控维度:
- 业务指标:请求总数、成功率、响应时间、活跃用户数
- 系统指标:CPU、内存、磁盘使用率
- 成本指标:按请求计费、部门成本分摊
2. 智能告警系统
# Prometheus告警规则
groups:
- name: mcp-platform
rules:
- alert: MCPHighErrorRate
expr: rate(mcp_requests_failed_total[5m]) / rate(mcp_requests_total[5m]) > 0.05
for: 2m
labels:
severity: critical
annotations:
summary: "MCP平台错误率过高"
description: "过去5分钟MCP请求错误率超过5%"
- alert: MCPHighLatency
expr: histogram_quantile(0.95, rate(mcp_request_duration_seconds_bucket[5m])) > 2
for: 5m
labels:
severity: warning
annotations:
summary: "MCP平台响应延迟过高"
description: "95%的请求响应时间超过2秒"
- alert: MCPServerDown
expr: up{job="mcp-server"} == 0
for: 1m
labels:
severity: critical
annotations:
summary: "MCP服务器宕机"
description: "{{ $labels.instance }} MCP服务器无法访问"
成本优化管理
1. 成本监控和分析
成本分析功能:自动分析计算、存储、网络、API等各项成本,按部门分摊费用,并提供优化建议。
2. 自动扩缩容策略
# Kubernetes HPA配置
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: mcp-server-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: mcp-server
minReplicas: 3
maxReplicas: 50
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
- type: Pods
pods:
metric:
name: mcp_requests_per_second
target:
type: AverageValue
averageValue: "100"
🚀 实施路径和最佳实践
关于实施策略,我觉得最重要的一点是:千万别想着一步到位。我之前就犯过这个错误,想着一次性把所有功能都上线,结果搞得团队疲惫不堪,用户体验也很糟糕。现在我都是推荐分阶段实施,这套策略在好几个项目中都验证过了,确实比较靠谱。
分阶段实施计划
第一阶段:MVP验证(1-2个月)
目标:验证MCP在企业环境中的可行性
实施内容:
- 选择1-2个核心团队作为试点
- 部署基础的MCP服务器(文件系统、Git、简单API)
- 建立基本的安全和监控机制
- 收集用户反馈和性能数据
成功标准:
- 试点团队满意度 > 80%
- 系统可用性 > 99%
- 响应时间 < 2秒
- 零安全事件
第二阶段:功能扩展(2-3个月)
目标:扩展功能覆盖范围,优化用户体验
实施内容:
- 集成更多业务系统(CRM、ERP、数据库)
- 完善权限管理和审计功能
- 优化性能和稳定性
- 扩展到更多团队
成功标准:
- 覆盖50%以上的核心业务场景
- 用户数量增长3倍
- 平均响应时间减少30%
- 成本控制在预算范围内
第三阶段:全面推广(3-6个月)
目标:在全公司范围内推广使用
实施内容:
- 部署完整的企业级架构
- 建立完善的运维体系
- 开展全员培训
- 建立持续优化机制
成功标准:
- 全公司80%以上员工使用
- 系统可用性 > 99.9%
- 用户满意度 > 85%
- ROI > 200%
团队组织和协作
1. 核心团队构成
graph TB
A[项目指导委员会] --> B[项目经理]
B --> C[架构师]
B --> D[开发团队]
B --> E[运维团队]
B --> F[安全团队]
C --> C1[系统架构师]
C --> C2[安全架构师]
D --> D1[后端开发]
D --> D2[前端开发]
D --> D3[MCP服务器开发]
E --> E1[DevOps工程师]
E --> E2[监控工程师]
F --> F1[安全工程师]
F --> F2[合规专员]
2. 协作机制
定期会议制度:
- 周例会:项目进展同步和问题解决
- 月度评审:里程碑检查和计划调整
- 季度总结:成效评估和策略优化
文档管理:
- 架构文档:系统设计和技术规范
- 操作手册:部署和运维指南
- 用户指南:使用教程和最佳实践
风险控制和应急预案
1. 风险识别和评估
风险类型 | 风险等级 | 影响范围 | 应对策略 |
---|---|---|---|
系统故障 | 高 | 全公司 | 多活部署、快速切换 |
安全漏洞 | 高 | 敏感数据 | 安全扫描、及时修复 |
性能问题 | 中 | 用户体验 | 性能监控、弹性扩容 |
合规风险 | 中 | 法律风险 | 合规检查、审计跟踪 |
2. 应急响应流程
应急流程:故障分级→通知相关人员→启动应急响应→执行应急措施→跟踪处理进度→事后总结,确保快速响应和持续改进。
📈 案例研究:中大型企业MCP平台实践
说了这么多理论,我觉得还是用真实案例更有说服力。下面分享几个我亲身参与的项目,有成功的,也有踩坑的,希望对大家有帮助。
案例一:中型科技公司(800人规模)
公司背景:
- 行业:SaaS软件开发
- 规模:800名员工,15个研发团队
- 挑战:AI工具使用分散,成本控制困难
实施方案:
- 架构选择:单数据中心部署,微服务架构
- 核心功能:代码助手、文档管理、项目协作
- 安全措施:RBAC权限控制、API网关防护
实施效果:
实施前后对比:
开发效率:
before: "基线"
after: "+35%"
measurement: "功能交付速度"
成本控制:
before: "月成本$15,000"
after: "月成本$12,000"
savings: "20%"
安全事件:
before: "月均3起"
after: "月均0.5起"
reduction: "83%"
用户满意度:
before: "6.5/10"
after: "8.7/10"
improvement: "+34%"
关键成功因素:
- 高层支持:这个真的很重要,CEO亲自站台,资源要人给人要钱给钱
- 分阶段实施:我们从最积极的两个团队开始,让他们当种子用户,效果好了再推广
- 用户培训:别小看这个,我们光培训就搞了一个月,但确实值得
- 持续优化:每周都会收集用户反馈,有问题马上改,这个习惯一直保持到现在
案例二:大型金融机构(5000+人规模)
公司背景:
- 行业:银行业
- 规模:5000+名员工,严格合规要求
- 挑战:数据安全、合规审计、多地部署
实施方案:
- 架构选择:多活数据中心,容器化部署
- 核心功能:风险分析、客户服务、合规报告
- 安全措施:端到端加密、零信任架构
金融级安全要求:TLS 1.3传输加密、AES-256数据加密、HSM密钥管理、PCI-DSS/SOX合规、7年审计日志保留、本地化数据存储。
实施效果:
- 合规性:通过所有监管审计,零合规违规
- 效率提升:客户服务响应时间减少50%
- 成本节约:年度IT成本降低25%
- 风险控制:欺诈检测准确率提升40%
经验教训总结
通过这些案例,我们总结出企业级MCP实施的关键经验:
成功要素
- 明确的ROI目标:设定可量化的成功指标
- 充分的资源投入:人力、资金、时间的保障
- 渐进式实施:避免大爆炸式部署
- 用户参与:让最终用户深度参与设计和测试
常见陷阱
- 忽视安全合规:在设计初期就要考虑安全要求
- 低估培训成本:用户培训和支持需要充分投入
- 缺乏监控:没有完善的监控就无法及时发现问题
- 一步到位心态:试图一次性解决所有问题
💡 写在最后:从失败到成功的思考
回想起那次项目失败,我现在反而挺感谢那次经历。它让我明白了一个道理:企业级MCP集成绝不是技术的简单堆砌,而是一个涉及人、流程、技术的复杂系统工程。
如果重新来过,我会这样做:
- 先调研,再动手:花更多时间理解企业的真实需求,而不是想当然
- 小步快跑:从最简单的MVP开始,证明价值后再扩展
- 安全第一:把合规和安全放在功能之前考虑
- 拥抱变化:技术在发展,需求在变化,保持架构的灵活性
现在我参与的企业级MCP项目,成功率已经提升到90%以上。不是因为我的技术水平提高了多少,而是因为我学会了从企业的角度思考问题。
最好的架构不是最复杂的,而是最适合的。
如果你正在考虑为企业部署MCP平台,我的建议是:先找一个小团队试点,积累经验和信心,然后再考虑大规模推广。记住,每个企业都有自己的特色,别人的成功方案未必适合你。
🤔 互动时间
分享你的经验:
- 你的企业在AI工具集成方面遇到了什么挑战?
- 你觉得统一的AI工具平台对企业来说最大的价值是什么?
- 有没有类似的项目失败经历想要分享?
实践练习:
- 使用文章中的需求分析框架,评估你所在企业的MCP部署需求
- 基于你的行业特点,设计合适的安全控制措施
- 参考分阶段实施策略,制定适合你企业的部署计划
欢迎在评论区分享你的想法和经验,我会认真回复每一条评论。
📧 如果你正在规划企业级MCP项目,可以私信我,我很乐意分享更多实战经验和踩坑心得。
下期预告:《MCP最佳实践与性能优化》将深入探讨MCP使用过程中的优化技巧和故障排查方法,敬请期待!
关注专栏,获取更多MCP实战干货!
来源:juejin.cn/post/7532742298825768998
java中,使用map实现带过期时间的缓存
在 Java 开发领域,缓存机制的构建通常依赖于 Redis 等专业缓存数据库。这类解决方案虽能提供强大的缓存能力,但引入中间件意味着增加系统架构复杂度、部署成本与运维负担。本文将深入探讨一种轻量级替代方案 —— 基于 Java 原生Map实现的带过期时间的缓存机制。该方案无需引入外部工具,仅依托 Java 标准库即可快速搭建起缓存体系,特别适用于对资源占用敏感、架构追求极简的项目场景,为开发者提供了一种轻量高效的缓存数据管理新选择。
优点:
- 轻量便捷:无需引入 Redis 等外部中间件,直接使用 Java 标准库即可实现,降低了项目依赖,简化了部署流程。
- 快速搭建:基于熟悉的Map数据结构,开发人员能够快速理解和实现缓存逻辑,显著提升开发效率。
- 资源可控:可灵活控制缓存数据的生命周期,通过设置过期时间,精准管理内存占用,适合对资源占用敏感的场景。
缺点:该方案存在明显局限性,即数据无法持久化。一旦应用程序停止运行,缓存中的所有数据都会丢失。相较于 Redis 等具备持久化功能的专业缓存数据库,在需要长期保存缓存数据,或是应对应用重启后数据恢复需求的场景下,基于 Java 原生Map的缓存机制就显得力不从心。
代码实现
package com.sunny.utils;
import java.util.Iterator;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class SysCache {
// 单例实例
private static class Holder {
private static final SysCache INSTANCE = new SysCache();
}
public static SysCache getInstance() {
return Holder.INSTANCE;
}
// 缓存存储结构,Key为String,Value为包含值和过期时间的CacheEntry对象
private final ConcurrentHashMap<String, CacheEntry> cacheMap = new ConcurrentHashMap<>();
// 定时任务执行器
private final ScheduledExecutorService scheduledExecutorService;
// 私有构造方法,初始化定时清理任务
private SysCache() {
scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();
// 每隔1秒执行一次清理任务
scheduledExecutorService.scheduleAtFixedRate(this::cleanUp, 1, 1, TimeUnit.SECONDS);
// 注册JVM关闭钩子以优雅关闭线程池
Runtime.getRuntime().addShutdownHook(new Thread(this::shutdown));
}
/**
* 存入缓存
* @param key 键
* @param value 值
*/
public void set(String key, Object value){
cacheMap.put(key, new CacheEntry(value, -1));
}
/**
* 存入缓存
* @param key 键
* @param value 值
* @param expireTime 过期时间,单位毫秒
*/
public void set(String key, Object value, long expireTime) {
if (expireTime <= 0) {
throw new IllegalArgumentException("expireTime must be greater than 0");
}
cacheMap.put(key, new CacheEntry(value, System.currentTimeMillis() + expireTime));
}
/**
* 删除缓存
* @param key 键
*/
public void remove(String key) {
cacheMap.remove(key);
}
/**
* 缓存中是否包含键
* @param key 键
*/
public boolean containsKey(String key) {
CacheEntry cacheEntry = cacheMap.get(key);
if (cacheEntry == null) {
return false;
}
if (cacheEntry.getExpireTime() < System.currentTimeMillis()) {
remove(key);
return false;
}
return true;
}
/**
*获取缓存值
* @param key 键
*/
public Object get(String key) {
CacheEntry cacheEntry = cacheMap.get(key);
if (cacheEntry == null) {
return null;
}
if (cacheEntry.getExpireTime() < System.currentTimeMillis()) {
cacheMap.remove(key);
return null;
}
return cacheEntry.getValue();
}
private static class CacheEntry {
private final Object value;
private final long expireTime;
public CacheEntry(Object value, long expireTime) {
this.value = value;
this.expireTime = expireTime;
}
public Object getValue() {
return value;
}
public long getExpireTime() {
return expireTime;
}
}
/**
* 定时清理过期条目
*/
private void cleanUp() {
Iterator<Map.Entry<String, CacheEntry>> iterator = cacheMap.entrySet().iterator();
long currentTime = System.currentTimeMillis();
while (iterator.hasNext()) {
Map.Entry<String, CacheEntry> entry = iterator.next();
CacheEntry cacheEntry = entry.getValue();
if (cacheEntry.expireTime < currentTime) {
// 使用iterator移除当前条目,避免ConcurrentModificationException
iterator.remove();
}
}
}
/**
* 关闭线程池释放资源
*/
private void shutdown() {
scheduledExecutorService.shutdown();
try {
if (!scheduledExecutorService.awaitTermination(5, TimeUnit.SECONDS)) {
scheduledExecutorService.shutdownNow();
}
} catch (InterruptedException e) {
scheduledExecutorService.shutdownNow();
Thread.currentThread().interrupt();
}
}
}
测试
如上图,缓存中放入一个值,过期时间为5秒,每秒循环获取1次,循环10次,过期后,获取的值为null
来源:juejin.cn/post/7496335321781829642
使用watchtower更新docker容器
更新方式
定时更新(默认)
执行以下命令后,Watchtower 会在后台每 24 小时自动检查并更新所有运行中的容器:
docker run -d \
--name watchtower \
-v /var/run/docker.sock:/var/run/docker.sock \
containrrr/watchtower
手动立即更新
添加 --run-once
参数启动临时容器,检查更新后自动退出,适合按需触发:
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
containrrr/watchtower --run-once
更新指定容器
在命令末尾添加需要监控的容器名称,多个容器用空格分隔。例如仅监控 nginx
和 redis
容器:
docker run -d \
--name watchtower \
-v /var/run/docker.sock:/var/run/docker.sock \
containrrr/watchtower nginx redis
简化命令
手动更新时,如果使用上面的命令未免太麻烦了,所以我们可以将更新命令设置为别名:
将下面的命令放到对应shell的环境文件中(比如bash
对应~/.bashrc
,zsh对应~/.zshrc
)
alias update-container="docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower --run-once"
编辑完环境文件后,重新打开命令窗口,或使用source ~/.bashrc
或source ~/.zshrc
加载命令。
然后就可以通过下面的方式更新容器了:
update-container 容器标识
比如:
update-container nginx-ui-latest
来源:juejin.cn/post/7541682368329170954
Docker 与 containerd 的架构差异
要深入理解 Docker 与 containerd 的架构差异,首先需要明确二者的定位:Docker 是一套完整的容器平台(含构建、运行、分发等全流程工具),而 containerd 是一个专注于容器生命周期管理的底层运行时(最初是 Docker 内置组件,后独立为 CNCF 项目)。二者的架构设计围绕 “功能边界” 和 “模块化程度” 展开,以下从核心定位、架构分层、关键组件、交互流程四个维度进行对比分析。
一、核心定位与设计目标
架构差异的根源在于二者的定位不同,直接决定了功能范围和模块划分:
维度 | Docker | containerd |
---|---|---|
核心定位 | 一站式容器平台(Build, Ship, Run) | 轻量级容器运行时(专注于容器生命周期管理:启动、停止、销毁、资源隔离) |
设计目标 | 简化开发者体验,提供全流程工具链;兼顾单机开发与简单集群场景 | 满足云原生环境的可扩展性、稳定性;支持多上层调度器(K8s、Swarm 等) |
功能范围 | 包含镜像构建(docker build)、镜像仓库(docker push/pull)、容器运行、网络 / 存储管理、UI 等 | 仅负责镜像拉取、容器运行时管理、底层存储 / 网络对接;无镜像构建功能 |
依赖关系 | 早期内置 containerd 作为底层运行时(Docker 1.11+),2020 年后逐步拆分 | 可独立运行,也可作为 Docker、K8s(默认运行时)、Nomad 等的底层依赖 |
二、架构分层对比
二者均遵循 “分层解耦” 思想,但分层粒度和模块职责差异显著。Docker 架构更 “重”(含上层业务逻辑),containerd 更 “轻”(聚焦底层核心能力)。
1. Docker 架构(2020 年后拆分版)
Docker 经历了从 “单体架构” 到 “模块化拆分” 的演进(核心是将 containerd 独立,自身聚焦上层工具链),当前架构分为 4 层,自下而上分别是:
架构层 | 核心组件 / 功能 | 职责说明 |
---|---|---|
1. 底层运行时层 | containerd、runc | 承接 Docker daemon 的指令,负责容器的实际创建、启动、资源隔离(依赖 runc 作为 OCI runtime) |
2. Docker 守护进程层 | dockerd | Docker 的核心守护进程,负责接收客户端(docker CLI)请求,协调下层组件(如调用 containerd 管理容器,调用 buildkit 构建镜像) |
3. 工具链层 | BuildKit、Docker Registry Client、Docker Network/Volume Plugins | - BuildKit:替代传统 docker build 后端,优化镜像构建效率;- 镜像客户端:处理 docker push/pull 与仓库交互;- 网络 / 存储插件:管理容器网络(如 bridge、overlay)和数据卷 |
4. 客户端层 | docker CLI(命令行工具)、Docker Desktop UI(桌面端) | 提供用户交互入口,将 docker run/build/pull 等命令转化为 HTTP 请求发送给 dockerd |
2. containerd 架构(CNCF 标准化版)
containerd 架构更聚焦 “容器生命周期”,采用 5 层模块化设计,每层职责单一,可独立扩展,自下而上分别是:
架构层 | 核心组件 / 功能 | 职责说明 |
---|---|---|
1. OCI 运行时层 | runc、crun(可选) | 遵循 OCI 规范(Open Container Initiative),负责创建 Linux 容器(如调用 clone() 系统调用实现 PID 隔离,挂载 cgroup 限制资源) |
2. 容器执行层 | containerd-shim(垫片进程) | - 解耦 containerd 与容器进程:即使 containerd 重启,容器也不会退出;- 收集容器日志、监控容器状态、转发信号(如 docker stop 对应 SIGTERM) |
3. 核心服务层 | containerd 守护进程(containerd) | containerd 的核心,通过 gRPC 提供 API 服务,包含 4 个核心模块:- Namespaces:实现多租户资源隔离;- Images:管理镜像(拉取、存储、解压);- Containers:管理容器元数据(配置、状态);- Tasks:管理容器进程(启动、停止、销毁) |
4. 元数据存储层 | BoltDB(嵌入式 key-value 数据库) | 存储容器、镜像、命名空间等元数据,无需依赖外部数据库(如 MySQL),轻量且高效 |
5. 上层适配层 | CRI 插件(containerd-cri)、Docker API 兼容层 | - CRI 插件:将 containerd 的 gRPC API 转化为 K8s 要求的 CRI(Container Runtime Interface),使其成为 K8s 默认运行时;- Docker API 兼容层:支持部分 Docker 命令,确保与老系统兼容 |
三、关键组件差异
架构的核心差异体现在 “组件职责划分” 和 “功能依赖” 上,以下是最关键的组件对比:
组件 / 能力 | Docker | containerd |
---|---|---|
核心守护进程 | dockerd(上层协调)+ containerd(底层运行时,需与 dockerd 配合) | containerd(独立守护进程,直接对接 OCI 运行时,无需依赖其他进程) |
镜像构建 | 内置 BuildKit(或传统后端),支持 docker build 命令 | 无镜像构建功能,需依赖外部工具(如 BuildKit、img) |
容器进程隔离 | dockerd → containerd → containerd-shim → runc → 容器进程(4 层调用) | containerd → containerd-shim → runc → 容器进程(3 层调用,更轻量) |
元数据存储 | 依赖本地文件系统(/var/lib/docker)+ 部分内存缓存 | 内置 BoltDB(/var/lib/containerd),元数据管理更统一、高效 |
API 接口 | 主要提供 HTTP API(供 docker CLI 调用),对下层暴露有限 | 以 gRPC API 为主(更适合跨进程通信),提供细粒度接口(如镜像、容器、任务分别有独立 API) |
上层调度器支持 | 主要支持 Docker Swarm,对接 K8s 需额外配置(早期需 cri-dockerd 插件) | 原生支持 K8s(通过 containerd-cri 插件),也支持 Swarm、Nomad 等 |
四、容器启动流程对比
通过 “容器启动” 这一核心场景,可以直观看到二者的架构交互差异:
1. Docker 启动容器的流程(以 docker run ubuntu 为例)
- 用户交互:用户在终端执行 docker run ubuntu,docker CLI 将命令转化为 HTTP 请求,发送给本地的 dockerd 守护进程。
- dockerd 协调:
- 检查本地是否有 ubuntu 镜像:若无,调用 “镜像客户端” 从 Docker Hub 拉取镜像;
- 拉取完成后,dockerd 向 containerd 发送 gRPC 请求,要求创建并启动容器。
- containerd 处理:
- containerd 接收请求后,创建容器元数据(存储到本地),并启动 containerd-shim 垫片进程;
- containerd-shim 调用 runc,由 runc 遵循 OCI 规范创建容器进程(分配 PID、挂载 cgroup、设置网络 / 存储)。
- 状态反馈:
- containerd-shim 实时收集容器状态(如运行中、退出),反馈给 containerd;
- containerd 将状态转发给 dockerd,最终由 docker CLI 输出给用户(如 docker ps 显示容器列表)。
2. containerd 启动容器的流程(以 ctr run ubuntu my-container 为例,ctr 是 containerd 自带 CLI)
- 用户交互:用户执行 ctr run ubuntu my-container,ctr 直接通过 gRPC 调用 containerd 守护进程。
- containerd 核心处理:
- 检查本地镜像:若无,直接调用内置的 “镜像模块” 从仓库拉取 ubuntu 镜像;
- 创建容器元数据(存储到 BoltDB),并启动 containerd-shim 垫片进程。
- OCI 运行时启动容器:
- containerd-shim 调用 runc 创建容器进程,完成资源隔离和环境初始化;
- 容器启动后,containerd-shim 持续监控容器状态,直接反馈给 containerd。
- 状态反馈:containerd 将容器状态通过 gRPC 返回给 ctr,用户终端显示启动结果。
五、总结:架构差异的核心影响
对比维度 | Docker | containerd |
---|---|---|
轻量级 | 重(含全流程工具,依赖多组件) | 轻(仅核心运行时,组件少、资源占用低) |
扩展性 | 弱(架构耦合度较高,难适配多调度器) | 强(模块化设计,原生支持 K8s 等调度器) |
性能 | 略低(多一层 dockerd 转发,资源消耗多) | 更高(直接对接 OCI 运行时,调用链短) |
使用场景 | 单机开发、测试、小型应用部署 | 云原生集群(如 K8s 集群)、大规模容器管理 |
学习成本 | 低(CLI 友好,文档丰富) | 高(需理解 gRPC、OCI 规范,适合运维 / 底层开发) |
简言之:Docker 是 “面向开发者的容器平台”,架构围绕 “易用性” 和 “全流程” 设计;containerd 是 “面向云原生的底层运行时”,架构围绕 “轻量、可扩展、高兼容” 设计。在当前云原生生态中,containerd 已成为 K8s 的默认运行时,而 Docker 更多用于单机开发场景。
来源:juejin.cn/post/7544381073698848811
代码界的 “建筑师”:建造者模式,让复杂对象构建井然有序
深入理解建造者模式:复杂对象的定制化构建之道
在软件开发中,我们常会遇到需要创建 “复杂对象” 的场景 —— 这类对象由多个部件组成,且部件的组合顺序、配置细节可能存在多种变化。例如,定制一台电脑需要选择 CPU、内存、硬盘等部件;生成一份报告需要包含标题、正文、图表、落款等模块。若直接在客户端代码中编写对象的构建逻辑,不仅会导致代码臃肿、耦合度高,还难以灵活应对不同的定制需求。此时,建造者模式(Builder Pattern) 便能发挥关键作用,它将复杂对象的构建过程与表示分离,让同一构建过程可生成不同的表示。
在软件开发中,我们常会遇到需要创建 “复杂对象” 的场景 —— 这类对象由多个部件组成,且部件的组合顺序、配置细节可能存在多种变化。例如,定制一台电脑需要选择 CPU、内存、硬盘等部件;生成一份报告需要包含标题、正文、图表、落款等模块。若直接在客户端代码中编写对象的构建逻辑,不仅会导致代码臃肿、耦合度高,还难以灵活应对不同的定制需求。此时,建造者模式(Builder Pattern) 便能发挥关键作用,它将复杂对象的构建过程与表示分离,让同一构建过程可生成不同的表示。
一、建造者模式的核心定义与价值
1. 官方定义
建造者模式是 “创建型设计模式” 的重要成员,其核心思想是:将一个复杂对象的构建过程抽象出来,拆分为多个独立的构建步骤,通过不同的 “建造者” 实现这些步骤,再由 “指挥者” 按指定顺序调用步骤,最终组装出完整对象。
简单来说,它就像 “组装家具” 的流程:家具说明书(指挥者)规定了先装框架、再装抽屉、最后装柜门的步骤;而不同品牌的组装师傅(具体建造者),会用不同材质的零件(部件)完成每一步;最终用户(客户端)只需告诉商家 “想要哪种风格的家具”,无需关心具体组装细节。
建造者模式是 “创建型设计模式” 的重要成员,其核心思想是:将一个复杂对象的构建过程抽象出来,拆分为多个独立的构建步骤,通过不同的 “建造者” 实现这些步骤,再由 “指挥者” 按指定顺序调用步骤,最终组装出完整对象。
简单来说,它就像 “组装家具” 的流程:家具说明书(指挥者)规定了先装框架、再装抽屉、最后装柜门的步骤;而不同品牌的组装师傅(具体建造者),会用不同材质的零件(部件)完成每一步;最终用户(客户端)只需告诉商家 “想要哪种风格的家具”,无需关心具体组装细节。
2. 核心价值
- 解耦构建与表示:构建过程(步骤顺序)和对象表示(部件配置)分离,同一过程可生成不同配置的对象(如用相同步骤组装 “游戏本” 和 “轻薄本”)。
- 灵活定制细节:支持对对象部件的精细化控制,客户端可通过选择不同建造者,定制符合需求的对象(如电脑可选择 “i7 CPU+32G 内存” 或 “i5 CPU+16G 内存”)。
- 简化客户端代码:客户端无需关注复杂的构建逻辑,只需与指挥者或建造者简单交互,即可获取完整对象。
- 解耦构建与表示:构建过程(步骤顺序)和对象表示(部件配置)分离,同一过程可生成不同配置的对象(如用相同步骤组装 “游戏本” 和 “轻薄本”)。
- 灵活定制细节:支持对对象部件的精细化控制,客户端可通过选择不同建造者,定制符合需求的对象(如电脑可选择 “i7 CPU+32G 内存” 或 “i5 CPU+16G 内存”)。
- 简化客户端代码:客户端无需关注复杂的构建逻辑,只需与指挥者或建造者简单交互,即可获取完整对象。
二、建造者模式的核心结构
建造者模式通常包含 4 个核心角色,它们分工明确、协作完成对象构建:
角色名称 核心职责 产品(Product) 需要构建的复杂对象,由多个部件组成(如 “电脑”“报告”)。 抽象建造者(Builder) 定义构建产品所需的所有步骤(如 “设置 CPU”“设置内存”),通常包含获取产品的方法。 具体建造者(Concrete Builder) 实现抽象建造者的步骤,定义具体部件的配置(如 “游戏本建造者”“轻薄本建造者”)。 指挥者(Director) 负责调用建造者的步骤,规定构建的顺序(如 “先装 CPU→再装内存→最后装硬盘”)。
建造者模式通常包含 4 个核心角色,它们分工明确、协作完成对象构建:
角色名称 | 核心职责 |
---|---|
产品(Product) | 需要构建的复杂对象,由多个部件组成(如 “电脑”“报告”)。 |
抽象建造者(Builder) | 定义构建产品所需的所有步骤(如 “设置 CPU”“设置内存”),通常包含获取产品的方法。 |
具体建造者(Concrete Builder) | 实现抽象建造者的步骤,定义具体部件的配置(如 “游戏本建造者”“轻薄本建造者”)。 |
指挥者(Director) | 负责调用建造者的步骤,规定构建的顺序(如 “先装 CPU→再装内存→最后装硬盘”)。 |
三、建造者模式的实战案例:定制电脑的构建
为了更直观理解,我们以 “定制电脑” 为例,用 Java 代码实现建造者模式:
为了更直观理解,我们以 “定制电脑” 为例,用 Java 代码实现建造者模式:
1. 第一步:定义 “产品”(电脑)
首先明确需要构建的复杂对象 —— 电脑,它包含 CPU、内存、硬盘、显卡等部件:
// 产品:电脑
public class Computer {
// 电脑的部件
private String cpu;
private String memory;
private String hardDisk;
private String graphicsCard;
// Setter方法(用于建造者设置部件)
public void setCpu(String cpu) {
this.cpu = cpu;
}
public void setMemory(String memory) {
this.memory = memory;
}
public void setHardDisk(String hardDisk) {
this.hardDisk = hardDisk;
}
public void setGraphicsCard(String graphicsCard) {
this.graphicsCard = graphicsCard;
}
// 展示电脑配置(对象的“表示”)
public void showConfig() {
System.out.println("电脑配置:CPU=" + cpu + ",内存=" + memory + ",硬盘=" + hardDisk + ",显卡=" + graphicsCard);
}
}
首先明确需要构建的复杂对象 —— 电脑,它包含 CPU、内存、硬盘、显卡等部件:
// 产品:电脑
public class Computer {
// 电脑的部件
private String cpu;
private String memory;
private String hardDisk;
private String graphicsCard;
// Setter方法(用于建造者设置部件)
public void setCpu(String cpu) {
this.cpu = cpu;
}
public void setMemory(String memory) {
this.memory = memory;
}
public void setHardDisk(String hardDisk) {
this.hardDisk = hardDisk;
}
public void setGraphicsCard(String graphicsCard) {
this.graphicsCard = graphicsCard;
}
// 展示电脑配置(对象的“表示”)
public void showConfig() {
System.out.println("电脑配置:CPU=" + cpu + ",内存=" + memory + ",硬盘=" + hardDisk + ",显卡=" + graphicsCard);
}
}
2. 第二步:定义 “抽象建造者”(电脑建造者接口)
抽象出构建电脑的所有步骤,确保所有具体建造者都遵循统一规范:
// 抽象建造者:电脑建造者接口
public interface ComputerBuilder {
// 构建步骤1:设置CPU
void buildCpu();
// 构建步骤2:设置内存
void buildMemory();
// 构建步骤3:设置硬盘
void buildHardDisk();
// 构建步骤4:设置显卡
void buildGraphicsCard();
// 获取最终构建的电脑
Computer getComputer();
}
抽象出构建电脑的所有步骤,确保所有具体建造者都遵循统一规范:
// 抽象建造者:电脑建造者接口
public interface ComputerBuilder {
// 构建步骤1:设置CPU
void buildCpu();
// 构建步骤2:设置内存
void buildMemory();
// 构建步骤3:设置硬盘
void buildHardDisk();
// 构建步骤4:设置显卡
void buildGraphicsCard();
// 获取最终构建的电脑
Computer getComputer();
}
3. 第三步:实现 “具体建造者”(游戏本 / 轻薄本建造者)
针对不同需求,实现具体的部件配置。例如,“游戏本” 需要高性能 CPU 和显卡,“轻薄本” 更注重便携性(低功耗部件):
// 具体建造者1:游戏本建造者
public class GamingLaptopBuilder implements ComputerBuilder {
private Computer computer = new Computer(); // 持有产品实例
@Override
public void buildCpu() {
computer.setCpu("Intel i9-13900HX(高性能CPU)");
}
@Override
public void buildMemory() {
computer.setMemory("32GB DDR5(高带宽内存)");
}
@Override
public void buildHardDisk() {
computer.setHardDisk("2TB SSD(高速硬盘)");
}
@Override
public void buildGraphicsCard() {
computer.setGraphicsCard("NVIDIA RTX 4080(高性能显卡)");
}
@Override
public Computer getComputer() {
return computer;
}
}
// 具体建造者2:轻薄本建造者
public class UltrabookBuilder implements ComputerBuilder {
private Computer computer = new Computer();
@Override
public void buildCpu() {
computer.setCpu("Intel i5-1335U(低功耗CPU)");
}
@Override
public void buildMemory() {
computer.setMemory("16GB LPDDR5(低功耗内存)");
}
@Override
public void buildHardDisk() {
computer.setHardDisk("1TB SSD(便携性优先)");
}
@Override
public void buildGraphicsCard() {
computer.setGraphicsCard("Intel Iris Xe(集成显卡)");
}
@Override
public Computer getComputer() {
return computer;
}
}
针对不同需求,实现具体的部件配置。例如,“游戏本” 需要高性能 CPU 和显卡,“轻薄本” 更注重便携性(低功耗部件):
// 具体建造者1:游戏本建造者
public class GamingLaptopBuilder implements ComputerBuilder {
private Computer computer = new Computer(); // 持有产品实例
@Override
public void buildCpu() {
computer.setCpu("Intel i9-13900HX(高性能CPU)");
}
@Override
public void buildMemory() {
computer.setMemory("32GB DDR5(高带宽内存)");
}
@Override
public void buildHardDisk() {
computer.setHardDisk("2TB SSD(高速硬盘)");
}
@Override
public void buildGraphicsCard() {
computer.setGraphicsCard("NVIDIA RTX 4080(高性能显卡)");
}
@Override
public Computer getComputer() {
return computer;
}
}
// 具体建造者2:轻薄本建造者
public class UltrabookBuilder implements ComputerBuilder {
private Computer computer = new Computer();
@Override
public void buildCpu() {
computer.setCpu("Intel i5-1335U(低功耗CPU)");
}
@Override
public void buildMemory() {
computer.setMemory("16GB LPDDR5(低功耗内存)");
}
@Override
public void buildHardDisk() {
computer.setHardDisk("1TB SSD(便携性优先)");
}
@Override
public void buildGraphicsCard() {
computer.setGraphicsCard("Intel Iris Xe(集成显卡)");
}
@Override
public Computer getComputer() {
return computer;
}
}
4. 第四步:定义 “指挥者”(电脑组装指导者)
指挥者负责规定构建顺序,避免具体建造者与步骤顺序耦合。例如,统一按 “CPU→内存→硬盘→显卡” 的顺序组装:
// 指挥者:电脑组装指导者
public class ComputerDirector {
// 接收具体建造者,按顺序调用构建步骤
public Computer construct(ComputerBuilder builder) {
builder.buildCpu(); // 步骤1:装CPU
builder.buildMemory(); // 步骤2:装内存
builder.buildHardDisk();// 步骤3:装硬盘
builder.buildGraphicsCard();// 步骤4:装显卡
return builder.getComputer(); // 返回组装好的电脑
}
}
指挥者负责规定构建顺序,避免具体建造者与步骤顺序耦合。例如,统一按 “CPU→内存→硬盘→显卡” 的顺序组装:
// 指挥者:电脑组装指导者
public class ComputerDirector {
// 接收具体建造者,按顺序调用构建步骤
public Computer construct(ComputerBuilder builder) {
builder.buildCpu(); // 步骤1:装CPU
builder.buildMemory(); // 步骤2:装内存
builder.buildHardDisk();// 步骤3:装硬盘
builder.buildGraphicsCard();// 步骤4:装显卡
return builder.getComputer(); // 返回组装好的电脑
}
}
5. 第五步:客户端调用(定制电脑)
客户端只需选择 “具体建造者”,无需关心构建步骤,即可获取定制化电脑:
public class Client {
public static void main(String[] args) {
// 1. 创建指挥者
ComputerDirector director = new ComputerDirector();
// 2. 定制游戏本(选择游戏本建造者)
ComputerBuilder gamingBuilder = new GamingLaptopBuilder();
Computer gamingLaptop = director.construct(gamingBuilder);
gamingLaptop.showConfig(); // 输出:游戏本配置
// 3. 定制轻薄本(选择轻薄本建造者)
ComputerBuilder ultrabookBuilder = new UltrabookBuilder();
Computer ultrabook = director.construct(ultrabookBuilder);
ultrabook.showConfig(); // 输出:轻薄本配置
}
}
运行结果:
电脑配置:CPU=Intel i9-13900HX(高性能CPU),内存=32GB DDR5(高带宽内存),硬盘=2TB SSD(高速硬盘),显卡=NVIDIA RTX 4080(高性能显卡)
电脑配置:CPU=Intel i5-1335U(低功耗CPU),内存=16GB LPDDR5(低功耗内存),硬盘=1TB SSD(便携性优先),显卡=Intel Iris Xe(集成显卡)
客户端只需选择 “具体建造者”,无需关心构建步骤,即可获取定制化电脑:
public class Client {
public static void main(String[] args) {
// 1. 创建指挥者
ComputerDirector director = new ComputerDirector();
// 2. 定制游戏本(选择游戏本建造者)
ComputerBuilder gamingBuilder = new GamingLaptopBuilder();
Computer gamingLaptop = director.construct(gamingBuilder);
gamingLaptop.showConfig(); // 输出:游戏本配置
// 3. 定制轻薄本(选择轻薄本建造者)
ComputerBuilder ultrabookBuilder = new UltrabookBuilder();
Computer ultrabook = director.construct(ultrabookBuilder);
ultrabook.showConfig(); // 输出:轻薄本配置
}
}
运行结果:
电脑配置:CPU=Intel i9-13900HX(高性能CPU),内存=32GB DDR5(高带宽内存),硬盘=2TB SSD(高速硬盘),显卡=NVIDIA RTX 4080(高性能显卡)
电脑配置:CPU=Intel i5-1335U(低功耗CPU),内存=16GB LPDDR5(低功耗内存),硬盘=1TB SSD(便携性优先),显卡=Intel Iris Xe(集成显卡)
四、建造者模式的适用场景
并非所有对象创建都需要建造者模式,以下场景最适合使用:
- 复杂对象的定制化构建:对象由多个部件组成,且部件配置、组合顺序存在多种变化(如定制电脑、生成个性化报告、构建汽车)。
- 需要隐藏构建细节:客户端无需知道对象的具体构建步骤,只需获取最终结果(如用户无需知道电脑 “先装 CPU 还是先装内存”)。
- 同一构建过程生成不同表示:通过更换具体建造者,可让同一指挥者(步骤顺序)生成不同配置的对象(如同一组装流程,既做游戏本也做轻薄本)。
并非所有对象创建都需要建造者模式,以下场景最适合使用:
- 复杂对象的定制化构建:对象由多个部件组成,且部件配置、组合顺序存在多种变化(如定制电脑、生成个性化报告、构建汽车)。
- 需要隐藏构建细节:客户端无需知道对象的具体构建步骤,只需获取最终结果(如用户无需知道电脑 “先装 CPU 还是先装内存”)。
五、建造者模式的优缺点
优点
- 灵活性高:支持对对象部件的精细化定制,轻松扩展新的具体建造者(如新增 “工作站电脑建造者”,无需修改原有代码)。
- 代码清晰:将复杂构建逻辑拆分为独立步骤,职责单一,便于维护(构建步骤由指挥者管理,部件配置由建造者管理)。
- 解耦性强:客户端与具体构建步骤、部件配置分离,降低代码耦合度。
- 灵活性高:支持对对象部件的精细化定制,轻松扩展新的具体建造者(如新增 “工作站电脑建造者”,无需修改原有代码)。
缺点
- 增加类数量:每个具体产品需对应一个具体建造者,若产品类型过多,会导致类数量激增(如电脑有 10 种型号,需 10 个具体建造者)。
- 不适用于简单对象:若对象仅由少数部件组成(如 “用户” 对象仅含姓名、年龄),使用建造者模式会显得冗余,不如直接 new 对象高效。
- 增加类数量:每个具体产品需对应一个具体建造者,若产品类型过多,会导致类数量激增(如电脑有 10 种型号,需 10 个具体建造者)。
六、建造者模式与工厂模式对比表
建造者模式与工厂模式虽同属 “创建型模式”,但核心意图和适用场景差异显著,以下是两者的关键对比:
对比维度 | 建造者模式(Builder Pattern) | 工厂模式(Factory Pattern) |
---|---|---|
核心意图 | 关注 “如何构建”:拆分复杂对象的构建步骤,定制部件细节 | 关注 “创建什么”:统一创建对象,隐藏实例化逻辑 |
产品复杂度 | 适用于复杂对象(由多个部件组成,需分步构建) | 适用于简单 / 标准化对象(单一完整对象,无需分步) |
客户端控制度 | 客户端可控制部件配置(选择不同建造者) | 客户端仅控制产品类型(告诉工厂 “要什么”,不关心细节) |
角色构成 | 产品、抽象建造者、具体建造者、指挥者(4 个角色) | 产品、抽象工厂、具体工厂(3 个角色,无指挥者) |
典型场景 | 定制电脑、组装汽车、生成个性化报告 | 生产标准化产品(如不同品牌的手机、不同类型的日志器) |
类比生活场景 | 按需求定制家具(选材质、定尺寸,分步组装) | 从工厂批量购买标准化家电(直接拿成品,不关心生产) |
来源:juejin.cn/post/7543448572341157927
用户请求满天飞,如何精准『导航』?聊聊流量路由那些事儿
嘿,各位未来的技术大佬们,我是老码小张。
不知道大家有没有遇到过这样的场景:你美滋滋地打开刚部署上线的应用 cool-app.com
,在国内访问速度飞快。结果第二天,海外的朋友跟你吐槽,说访问你的应用慢得像蜗牛。或者更糟,某个区域的用户突然反馈说服务完全访问不了了!这时候你可能会挠头:用户来自天南海北,服务器也可能部署在不同地方,我怎么才能让每个用户都能又快又稳地访问到我的服务呢?
别慌!这其实就是咱们今天要聊的互联网流量路由策略要解决的问题。搞懂了它,你就掌握了给网络请求“精准导航”的秘诀,让你的应用在全球范围内都能提供更好的用户体验。
流量路由:不止是 DNS 解析那么简单
很多初级小伙伴可能觉得,用户访问网站不就是 浏览器 -> DNS 查询 IP -> 连接服务器
嘛?没错,DNS 是第一步,但现代互联网应用远不止这么简单。特别是当你的服务需要部署在多个数据中心、覆盖不同地理区域的用户时,仅仅返回一个固定的 IP 地址是远远不够的。
我们需要更智能的策略,来决定当用户请求 cool-app.com
时,DNS 服务器应该返回哪个(或哪些)IP 地址。这就引出了各种路由策略(Routing Policies)。你可以把它们想象成 DNS 服务器里的“智能导航系统”,根据不同的规则把用户导向最合适的目的地。
下面,咱们就来盘点几种最常见也最实用的路由策略。
策略一:按地理位置『就近安排』 (Geolocation Routing)
这是最直观的一种策略。顾名思义,它根据用户请求来源的 IP 地址,判断用户的地理位置(比如国家、省份甚至城市),然后将用户导向物理位置上距离最近或者预设好的对应区域的服务器。
工作原理示意:
sequenceDiagram
participant User as 用户 (来自北京)
participant DNS as 智能 DNS 服务器
participant ServerCN as 北京服务器 (1.1.1.1)
participant ServerUS as 美国服务器 (2.2.2.2)
User->>DNS: 查询 cool-app.com 的 IP 地址
activate DNS
DNS-->>DNS: 分析来源 IP,判断用户在北京
DNS-->>User: 返回北京服务器 IP (1.1.1.1)
deactivate DNS
User->>ServerCN: 连接 1.1.1.1
啥时候用?
- 需要为特定地区用户提供本地化内容或服务。
- 有合规性要求,比如某些数据必须存储在用户所在国家境内(像 GDPR)。
- 希望降低跨区域访问带来的延迟。
简单配置示例(伪代码):
// 类似 AWS Route 53 或其他云 DNS 的配置逻辑
RoutingPolicy {
Type: Geolocation,
Rules: [
{ Location: '中国', TargetIP: '1.1.1.1' },
{ Location: '美国', TargetIP: '2.2.2.2' },
{ Location: '*', TargetIP: '3.3.3.3' } // * 代表默认,匹配不到具体位置时使用
]
}
策略二:追求极致速度的『延迟优先』 (Latency-Based Routing)
这个策略的目标是:快! 它不关心用户在哪儿,只关心用户访问哪个服务器的网络延迟最低(也就是 RTT,Round-Trip Time 最短)。DNS 服务商会持续监测从全球不同网络到你各个服务器节点的网络延迟,然后把用户导向响应最快的那个节点。
工作原理示意:
sequenceDiagram
participant User as 用户
participant DNS as 智能 DNS 服务器
participant ServerA as 服务器 A (东京)
participant ServerB as 服务器 B (新加坡)
User->>DNS: 查询 cool-app.com 的 IP 地址
activate DNS
DNS-->>DNS: 检测用户到各服务器的延迟 (到 A: 50ms, 到 B: 30ms)
DNS-->>User: 返回延迟最低的服务器 B 的 IP
deactivate DNS
User->>ServerB: 连接服务器 B
啥时候用?
- 对响应速度要求极高的应用,比如在线游戏、实时通讯。
- 全球用户分布广泛,希望动态地为每个用户找到最快接入点。
注意点: 延迟是动态变化的,所以这种策略依赖于 DNS 服务商持续、准确的延迟探测。
策略三:灵活调度的『按权重分配』 (Weighted Routing)
这种策略允许你给不同的服务节点分配不同的权重(百分比),DNS 服务器会按照你设定的比例,把用户的请求随机分配到这些节点上。
工作原理示意:
假设你有两个版本的服务 V1 和 V2,部署在不同的服务器组上。
// 类似云 DNS 配置
RoutingPolicy {
Type: Weighted,
Targets: [
{ TargetIPGr0up: 'V1_Servers', Weight: 90 }, // 90% 流量到 V1
{ TargetIPGr0up: 'V2_Servers', Weight: 10 } // 10% 流量到 V2
]
}
DNS 会根据这个权重,概率性地返回 V1 或 V2 服务器组的 IP。
啥时候用?
- A/B 测试:想测试新功能?分一小部分流量(比如 5%)到新版本,看看效果。
- 灰度发布/金丝雀发布:新版本上线,先给 1% 的用户试试水,没问题再逐步增加权重到 10%、50%、100%。稳!
- 负载均衡:如果你的服务器配置不同(比如有几台是高性能的,几台是普通配置的),可以按性能分配不同权重,让高性能机器承担更多流量。
策略四:保障高可用的『故障转移』 (Failover Routing)
这个策略是为了高可用性。你需要设置一个主服务节点和一个或多个备用节点。DNS 服务器会持续对主节点进行健康检查(比如探测端口是否存活、HTTP 接口是否返回 200 OK)。
- 正常情况:所有流量都导向主节点。
- 主节点挂了:DNS 检测到主节点 N 次健康检查失败后,会自动把流量切换到备用节点。
- 主节点恢复:一旦主节点恢复健康,流量可以自动切回来(取决于你的配置)。
工作原理示意:
graph LR
A[用户请求] --> B{DNS 健康检查};
B -- 主节点健康 --> C[主服务器];
B -- 主节点故障 --> D[备用服务器];
C --> E[提供服务];
D --> E;
啥时候用?
- 任何对可用性要求高的关键服务。谁也不想服务宕机了用户还一直往坏掉的服务器上撞吧?
- 实现基本的灾备能力。
关键点: 健康检查的配置(频率、失败阈值)和 DNS 记录的 TTL(Time-To-Live,缓存时间)设置很关键。TTL 太长,故障切换就不够及时;TTL 太短,会增加 DNS 查询压力和成本。需要权衡。
策略五:CDN 和大厂最爱『任播』 (Anycast)
Anycast 稍微特殊一点,它通常是在更底层的网络层面(BGP 路由协议)实现的,但 DNS 经常与之配合。简单来说,就是你用同一个 IP 地址在全球多个地点宣告你的服务。用户的请求会被沿途的网络路由器自动导向“网络距离”上最近的那个宣告了该 IP 的节点。
效果: 用户感觉就像是连接到了离他最近的“入口”。
啥时候用?
- CDN 服务:为什么你访问各大 CDN 厂商(如 Cloudflare, Akamai)的资源总是很快?Anycast 是核心技术之一,让用户从最近的边缘节点获取内容。
- 公共 DNS 服务:像 Google 的
8.8.8.8
和 Cloudflare 的1.1.1.1
都使用了 Anycast,你在全球任何地方 ping 这个 IP,响应的都是离你最近的数据中心。
对于应用开发者来说,你可能不会直接配置 BGP,但你会选择使用提供了 Anycast 网络的服务商(比如某些云厂商的负载均衡器或 CDN 服务)。
选哪个?一张表帮你捋清楚
这么多策略,到底该怎么选呢?别急,我给你整理了个表格,对比一下:
策略名称 | 核心原理 | 主要应用场景 | 优点 | 缺点 |
---|---|---|---|---|
地理位置路由 | 基于用户 IP 判断地理位置 | 本地化内容、合规性、区域优化 | 实现区域隔离、满足合规 | IP 库可能不准、无法反映真实网络状况 |
延迟路由 | 基于网络延迟 (RTT) | 追求最低访问延迟、全球性能优化 | 用户体验好、动态适应网络变化 | 依赖准确探测、成本可能较高 |
权重路由 | 按预设比例分配流量 | A/B 测试、灰度发布、按能力负载均衡 | 灵活控制流量分配、上线平稳 | 无法基于用户体验动态调整(除非结合其他策略) |
故障转移路由 | 健康检查 + 主备切换 | 高可用、灾备 | 提升服务可靠性、自动化故障处理 | 切换有延迟(受 TTL 和检查频率影响) |
任播 (Anycast) | 同一 IP 多点宣告,网络路由就近转发 | CDN、公共 DNS、全球入口优化 | 显著降低延迟、抵抗 DDoS 攻击(分散) | 配置复杂(通常由服务商提供)、成本高 |
实战经验分享:组合拳出奇效!
在实际项目中,我们很少只用单一策略。更常见的是打组合拳:
- 地理位置 + 故障转移:先按区域分配流量(比如中国用户到上海,美国用户到硅谷),然后在每个区域内部署主备服务器,使用故障转移策略保障区域内的高可用。这是很多应用的标配。
- 地理位置 + 权重路由:在一个特定的地理区域内(比如只在中国区),对新上线的服务 V2 使用权重路由进行灰度发布。
- Anycast + 后端智能路由:使用 Anycast IP 作为全球统一入口,流量到达最近的接入点后,再根据后端服务的实际负载、延迟等情况,通过内部的负载均衡器或服务网格(Service Mesh)进行更精细的二次路由。
别忘了监控! 无论你用哪种策略,监控都至关重要。你需要关注:
- 各节点的健康状况。
- 用户的实际访问延迟(可以用 RUM - Real User Monitoring)。
- DNS 解析成功率和解析耗时。
- 流量分布是否符合预期。
有了监控数据,你才能知道你的路由策略是否有效,是否需要调整。
好了,今天关于互联网流量路由策略就先和大家聊这么多。希望这些内容能帮助你理解,当用户的请求“满天飞”时,我们是如何通过这些“智能导航”技术,确保他们能又快又稳地到达目的地的。这不仅仅是运维同学的事,作为开发者,理解这些原理,能让你在设计和部署应用时考虑得更周全。
我是老码小张,一个喜欢研究技术原理,并且在实践中不断成长的技术人。如果你觉得这篇文章对你有帮助,或者有什么想法想交流,欢迎在评论区留言!咱们下次再见!
来源:juejin.cn/post/7498292516493656098
摆动序列
摆动序列
一、问题描述
LeetCode:376. 摆动序列
如果连续数字之间的差严格地在正数和负数之间交替,则数字序列称为摆动序列。第一个差(如果存在的话)可能是正数或负数。仅有一个元素或者含两个不等元素的序列也视作摆动序列。
例如,[1, 7, 4, 9, 2, 5]
是一个摆动序列,因为差值 (6, -3, 5, -7, 3)
是正负交替出现的。
相反,[1, 4, 7, 2, 5]
和 [1, 7, 4, 5, 5]
不是摆动序列,第一个序列是因为它的前两个差值都是正数,第二个序列是因为它的最后一个差值为零。
给定一个整数数组 nums
,返回 nums
中作为摆动序列的最长子序列的长度。
二、解题思路
摆动序列的关键在于寻找数组中的峰和谷。每当序列发生方向变化时,摆动序列的长度就会增加。因此,可以通过遍历数组,统计方向变化的次数来得到最长摆动序列的长度。
- 记录初始趋势:计算数组前两个元素的差值作为最开始的变化趋势preTrend = nums[1] - nums[0],若差值不为 0,说明前两个元素构成了摆动序列的初始趋势,此时摆动序列长度初始化为 2;若差值为 0,意味着前两个元素相等,不构成摆动趋势,摆动序列长度初始化为 1。
- 遍历数组寻找变化趋势:记录当前变化趋势curTrend = nums[i] - nums[i - 1],若当前变化趋势curTrend 与之前的变化趋势preTrend 不同,preTrend <= 0 && curTrend > 0 或者 preTrend >= 0 && curTrend < 0 时 更新变化趋势preTrend ,摆动序列加1
三、代码实现
以下是使用 JavaScript 实现的代码:
var wiggleMaxLength = function (nums) {
// 统计波峰波谷的数量
// 若长度为1 或为 0
if (nums.length < 2) return nums.length;
let preTrend = nums[1] - nums[0];
let reLen = preTrend !== 0 ? 2 : 1;
for (let i = 2; i < nums.length; i++) {
let curTrend = nums[i] - nums[i - 1];
// 当出现波谷或者波峰时,更新preTrend
if ((preTrend <= 0 && curTrend > 0) || (preTrend >= 0 && curTrend < 0)) {
preTrend = curTrend;
reLen++;
}
}
return reLen;
};
四、注意事项
- 边界条件需谨慎:在处理数组前两个元素确定初始趋势时,要特别注意数组长度为 2 的情况。若两个元素相等,初始化摆动序列长度为 1,此时不能因为后续没有更多元素判断趋势变化,就错误认为长度还能增加。在遍历过程中,若遇到数组结尾,也应保证最后一次趋势变化能正确统计,避免遗漏。
- 趋势判断避免误判:在比较差值判断趋势变化时,条件 (preTrend <= 0 && curTrend > 0) 与 (preTrend >= 0 && curTrend < 0) 中的 “小于等于” 和 “大于等于” 不能随意替换为 “小于” 和 “大于”。例如,当出现连续相等元素后趋势改变的情况,若使用严格的 “小于” 和 “大于” 判断,可能会错过第一个有效趋势变化点,导致结果错误。
五、复杂度分析
- 时间复杂度:O(n),其中 n 是数组的长度。只需要遍历一次数组。
- 空间复杂度:O(1),只需要常数级的额外空间。
来源:juejin.cn/post/7518198430662492223
绿盟科技重磅发布AI安全运营新成果,全面驱动智能攻防体系升级
8月29日,绿盟科技在北京成功举办以“智御新境·安全无界”为主题的AI赋能安全运营线上成果发布会,全面展示了公司在AI安全领域的最新技术成果与实践经验。
会议总结了“风云卫”AI安全能力平台上半年在客户侧的实际运营成效,介绍了AISOC平台的新特性与能力,进一步验证了“AI+安全运营”在降噪、研判、处置闭环以及未知威胁检测等核心场景中的规模化应用价值。
此外,还正式发布了“绿盟AI智能化渗透系统”,该系统依托AI技术全面赋能渗透测试全流程,可广泛应用于常态化扫描和日常安全运维等场景,有效帮助客户降本增效,显著降低渗透测试的专业门槛。
双底座多智能体架构,构建三位一体AI安全生态
2020年至2022年,绿盟科技连续发布三部AI安全白皮书《安全AI赋能安全运营白皮书》、《知识图谱白皮书》和《AI安全分析白皮书》,并于2023年推出“风云卫”安全大模型,深度融合AI与攻防知识。2025年,公司再度升级,构建“风云卫+DeepSeek”双底座与多智能体架构,打造AI驱动的安全生态,覆盖模型生产、场景适配与应用复制三大层面,全面提升安全检测、运营、数据安全及蓝军对抗能力,实现全域智能赋能。
安全运营实现“智防”质变,绿盟“风云卫”AI实战成效显著
绿盟科技产品BG总经理吴天昊
绿盟科技产品BG总经理吴天昊表示,安全运营人员每天面临几万到几十万不等的原始攻击告警,绿盟“风云卫”AI安全能力平台依托千亿级安全样本训练的大模型,能够自动识别系统日志中的无效告警与重复信息,达到百级左右的高价值告警的优先推荐。
针对不同攻击事件,可自动展开研判分析,精准解析攻击路径和手法,并通过可视化分析界面清晰呈现完整攻击链条。通过自主调查,智能开展横向溯源,自动关联跨端、跨网、跨身份数据,构建出完整的攻击图谱;同时进行并案分析,深度挖掘同类攻击线索,精准定位攻击组织;最后通过SOAR剧本自动生成与执行,实现分钟级事件闭环,并为未来同类事件建立自动化处置范式。
实际应用数据显示,绿盟科技的AI降噪率平均达到95%以上,AI综合辅助研判准确率超过90%。在处置响应方面,依托自主响应可实现超过40%的安全事件端到端的自动化响应处置。特别值得关注的是,经过实际观测和验证,针对13大类77小类的攻击类型,绿盟风云卫AI辅助识别准确率超过95%。
会上,绿盟科技全流程演示了AI赋能安全运营的过程,生动体现了AI技术在安全运营各环节的深度融合——从海量告警的智能降噪、攻击链路的自动重构,到复杂事件的自主研判和自动化响应,真正实现了安全运营从"人防"到"智防"的质变升级。
AI赋能安全检测:混淆检测+自主基线,让未知威胁检测变成可能
在攻防演练中,统计数据显示有76%的攻击属于“已知家族、未知变种”类型,这类攻击因具备一定家族特征又存在变异特性,给检测工作带来不小挑战。
绿盟“风云卫”AI安全能力平台在此类场景中展现出显著优势:在混淆检测方面,AI凭借强大的语义理解能力,能够深入剖析恶意程序的本质特征。即便攻击手段经过混淆处理,改变了表面形态,AI也能透过现象看本质,精准识别出其属于已知家族的核心属性,从而有效识破“未知变种”的伪装。
在自主基线构建上,AI能够自主解读并理解全量日志,从中提炼出账号、流量、进程等各类实体在正常时段的行为画像。基于这些画像,AI可以秒级输出动态阈值,形成精准的正常行为基线。当“已知家族、未知变种”的攻击出现,导致相关实体行为偏离动态阈值时,系统能快速察觉异常,为及时发现和应对威胁提供有力支撑。
智能体中心成效显著,20多个安全领域智能体协同赋能
绿盟“风云卫”AI安全能力平台汇聚绿盟安全专家经验,内置20+安全领域智能体,覆盖网络安全多个关键环节,包含钓鱼邮件检测、可疑样本分析、敏感数据识别、零配置日志解析、情报分析、报告生成等多个智能体。这些智能体既可以赋能产品,也可以独立运行。值得一提的是,智能体中心支持智能体可视化编排,这一特性为用户带来了极大便利。即便是非专业的技术人员,也能通过简单的拖拽、连线操作,如同搭建积木一般,将多个智能体按照企业自身的业务逻辑与安全需求,灵活组合成个性化的安全工作流程。
例如,用户可通过可视化方式自定义编排敏感信息检测智能体,将企业特定的敏感信息嵌入其中,从而实现更精准的自定义检测。这种低代码的编排方式不仅大幅降低了使用门槛,还能灵活应对企业不断变化的安全需求,实现安全防护的定制化与敏捷化,全面提升网络安全工作的效能。
多行业落地实践,安全运营效率大幅提升
绿盟科技鹰眼安全运营中心技术经理李昀磊
截至目前,绿盟科技已助力电信、金融、政府、高校等行业的多家客户实现安全运营智能化转型。在近期多项攻防演练中,公司依托“风云卫”AI安全能力平台,为客户提供全面支撑,多项智能体——包括未知威胁检测、行为分析、钓鱼邮件识别等——均发挥关键作用。
绿盟科技鹰眼安全运营中心技术经理李昀磊介绍,绿盟安全运营中心已持续为超2000家企业提供安全运营服务,并于2023年起全面融合“风云卫”大模型,AI赋能成效主要体现在三方面:
●高频场景AI全自动处置:对实时性要求极高的常见攻击,实现从检测、研判、通知到响应的全自动闭环,无需人工干预;
●复杂事件智能辅助调查:针对约占20%+的复杂事件,AI可自主拓展调查路径,完成情报搜集与初步总结,提升分析师决策效率;
●工具调度与客户交互自动化:AI统一调度多类分析工具,并自动完成工单发送、报告生成与客户反馈响应,显著节约人力。
截至目前,绿盟云端安全运营中心约77%的告警日志依托AI实现辅助研判与处置,在客户预授权条件下5分钟内发现确认并处置闭环事件,运营效率大幅提升。
绿盟AI智能化渗透系统发布
绿盟科技产品总监许伟强
绿盟科技产品总监许伟强表示,公司基于多年攻防实战经验与大模型技术,正式推出新一代绿盟AI智能化渗透系统。该系统全面覆盖常态化扫描与日常安全运维等多种场景,在国内首次实现AI智能体在真实网络环境中完成端到端渗透测试,显著提升渗透效率与响应能力。该系统具备四大核心能力:
●智能任务规划:通过多智能体分层协作,结合专业攻防知识库,实现对复杂渗透场景的智能化任务分解;
●智能工具调度:依托工具调度智能体,无缝调用并协同多种渗透工具,破除工具间壁垒,增强协同作战效能;
●渗透路径推荐:基于安全大模型技术,融合知识图谱与漏洞利用知识,提供渗透路径规划、过程可视化及标准化报告输出;
●AI智能对话:支持自然语言交互,可依据用户指令智能推荐并自动执行渗透工具,大幅降低操作门槛。
绿盟AI智能化渗透系统基于“风云卫”平台构建,采用“人在环路”安全机制与多智能体协同架构,具备“直接模式+深度模式”双轨机制,可快速响应常规攻击面,也可深入攻坚复杂高对抗场景,动态适应多样化的实战攻防需求。
随着国务院常务会议审议通过《关于深入实施“人工智能+”行动的意见》,“人工智能+”正成为产业升级的关键方向,各领域在快速发展的同时,安全问题将不容忽视。绿盟科技始终站在技术前沿,目前形成了以风云卫AI安全能力平台为核心,构建“模型生产、场景适配、应用赋智”的“三位一体”AI安全生态体系,可为不同用户提供全方位的智能安全保障。面向未来,绿盟科技将继续以创新为引擎,携手客户与合作伙伴,共同迎接智能安全新时代。
本地Docker部署Transmission,内网穿透无限远程访问教程
Transmission是一款开源、轻量且资源占用极低的BitTorrent客户端。借助Docker,可以在几分钟内把它跑起来;再借助贝锐花生壳的内网穿透功能,又能随时随地从外网安全访问。下面给出一条龙的部署与远程访问流程,全部命令可直接复制粘贴。
一、准备Docker环境
1. 一键安装最新版Docker(已包含阿里云镜像加速):
2. 启动并设为开机自启:
二、拉取Transmission镜像
如果拉取超时,可在 `/etc/docker/daemon.json` 中追加国内镜像源,例如:
三、运行Transmission容器
下面命令把Web端口9091、BT 监听端口41234(TCP/UDP)都映射出来,并把配置、下载目录、监控目录挂到宿主机持久化。按需替换UID/GID、时区、用户名密码以及宿主机路径。
启动后,浏览器访问 http://局域网IP:9091即可看到Transmission Web UI。
点击左上角图标即可上传种子或粘贴磁力链接开始下载。
四、安装并配置贝锐花生壳
1. 下载客户端
在同一内网任意设备上,从花生壳官网下载最新Linux版客户端,可根据实际情况,选择docker安装花生壳。(`phddns_5.x.x_amd64.deb`)。
2. 安装
根据不同位数的系统输入下面的命令进行安装,安装完成会自动生成SN码与登录密码。
3. 激活与登录
浏览器打开 [花生壳管理平台](http://b.oray.com),用SN和默认密码登录。
首次登录需激活设备,支持微信扫码或绑定贝锐账号。
4. 添加映射
激活成功后,进入「内网穿透」→「添加映射」,填写新增的映射信息。
保存后,贝锐花生壳会生成一个 `http://xxxx.hsk.oray.com:端口` 的外网地址。可访问外网地址访问transmission。
五、外网访问与日常使用
任何地点打开浏览器,输入花生壳提供的外网地址,即可远程管理Transmission:添加种子、查看进度、做种、限速等操作与局域网完全一致。
至此,借助贝锐花生壳内网穿透就可以使本地Docker版Transmission已可安全、便捷地实现远程访问。
收起阅读 »一文说透WebSocket协议(秒懂)
本文首发于公众号:托尼学长,立个写 1024 篇原创技术面试文章的flag,欢迎过来视察监督~
为避免同学们概念混淆,先声明一下,其实WebSocket和Socket之间是毫无关系的,就像北大青鸟和北大一样,大家不要被名字给带偏了。
WebSocket是一种建立在TCP底层连接上,使web客户端和服务器端可进行持续全双工通信的协议。
用大白话来说,WebSocket协议最大的特点是支持服务器端给客户端发送消息。
只需先通过HTTP协议进行握手并进行协议升级,即可让服务器端和客户端一直保持连接并实现通信,直到连接关闭。
如下图所示:
一定会有同学存在疑问,WebSocket协议所具备的“支持服务器端给客户端发送消息”的特点,具体适用场景是什么呢?
下面我们就来详细地讲解一下。
适用场景
对于这个问题,我们有必须逆向思考一下,WebSocket协议所适用的场景,必然是其他协议不适用的场景,这个协议就是HTTP。
由于HTTP协议是半双工模式,只能由客户端发起请求并由服务器端进行响应。
所以在线聊天、实时互动游戏、股票行情、物联网设备监控等业务场景下,只能通过客户端以轮询、长轮询的方式去服务器端获取最新数据。
股票行情场景,如下图所示:
这种方式所带来的问题有两点:
1、客户端频繁发送HTTP请求会带来网络开销,也会给服务器端带来负载压力;2、轮询间隔难以把控,间隔过短同样会带来问题(1)中提到的点,间隔过长会导致数据延迟。
而WebSocket协议只有在服务器端有事件发生的时候,才会第一时间给客户端发送消息,彻底杜绝了HTTP轮询所带来的网络开销、服务器负载和数据时延问题。
实现步骤
阶段一、客户端通过 HTTP 协议发送包含特殊头部的请求,触发协议升级:
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
- Upgrade: websocket明确请求升级协议。
- Sec-WebSocket-Key:客户端生成的随机字符串,用于安全验证。
- Sec-WebSocket-Version:指定协议版本(RFC 6455 规定为 13)。
阶段二、服务器端进行响应确认,返回 101 Switching Protocols
响应:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
- Sec-WebSocket-Accept:服务器将客户端的 Sec-WebSocket-Key 与固定字符串拼接后,计算 SHA-1 哈希并进行 Base64 编码,生成验证令牌。
阶段三、此时 TCP 连接从 HTTP 升级为 WebSocket 协议,后续数据可通过二进制帧进行传输。
阶段四、数据传输,WebSocket是一种全双工通信协议,客户端与服务端可同时发送/接收数据,无需等待对方请求,数据帧是以二进制格式进行传输的。
如下图所示:
- FIN (1 bit):标记是否为消息的最后一个分片。
- Opcode (4 bits):定义数据类型(如文本 0x1、二进制 0x2、关闭连接 0x8、Ping 0x9、Pong 0xA)。
- Mask (1 bit):客户端发送的数据需掩码处理(防止缓存污染攻击),服务端发送的数据无需掩码。
- Payload Length (7 or 7+16 or 7+64 bits):帧内容的长度,支持最大 2^64-1 字节。
- Masking-key(32 bits),掩码密钥,由上面的标志位 MASK 决定的,如果使用掩码就是 4 个字节的随机数,否则就不存在。
- payload data 字段:这里存放的就是真正要传输的数据
阶段五、连接关闭,客户端或服务器端都可以发起关闭。
示例代码
前端代码:
<!DOCTYPE html>
<html>
<body>
<input type="text" id="messageInput" placeholder="输入消息">
<button onclick="sendMessage()">发送</button>
<div id="messages"></div>
<script>
// 创建 WebSocket 连接
const socket = new WebSocket('ws://localhost:8080/ws');
// 连接打开时触发
socket.addEventListener('open', () => {
logMessage('连接已建立');
});
// 接收消息时触发
socket.addEventListener('message', (event) => {
logMessage('收到消息: ' + event.data);
});
// 连接关闭时触发
socket.addEventListener('close', () => {
logMessage('连接已关闭');
});
// 错误处理
socket.addEventListener('error', (error) => {
logMessage('连接错误: ' + error.message);
});
// 发送消息
function sendMessage() {
const message = document.getElementById('messageInput').value;
socket.send(message);
logMessage('发送消息: ' + message);
}
// 日志输出
function logMessage(message) {
const messagesDiv = document.getElementById('messages');
const p = document.createElement('p');
p.textContent = message;
messagesDiv.appendChild(p);
}
</script>
</body>
</html>
我们通过 Spring WebSocket 来实现服务器端代码。
1、添加 Maven 依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
<version>2.7.14</version>
</dependency>
2、配置类启用 WebSocket:
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(new MyWebSocketHandler(), "/ws")
.setAllowedOrigins("*");
}
}
3、消息处理器实现:
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
public class MyWebSocketHandler extends TextWebSocketHandler {
private static final Set<WebSocketSession> sessions =
Collections.synchronizedSet(new HashSet<>());
@Override
public void afterConnectionEstablished(WebSocketSession session) {
sessions.add(session);
log("新连接: " + session.getId());
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) {
String payload = message.getPayload();
log("收到消息: " + payload);
// 广播消息
sessions.forEach(s -> {
if (s.isOpen() && !s.equals(session)) {
try {
s.sendMessage(new TextMessage("广播: " + payload));
} catch (Exception e) {
log("发送消息失败: " + e.getMessage());
}
}
});
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
sessions.remove(session);
log("连接关闭: " + session.getId());
}
private void log(String message) {
System.out.println("[MyWebSocketHandler] " + message);
}
}
结语
在本文中,我们先是对WebSocket协议的概念进行了讲解,也对其适用场景、实现步骤进行描述,最后给出了实例代码,旨在帮助大家一站式熟悉WebSocket协议。
来源:juejin.cn/post/7503811248288661558
交替打印最容易理解的实现——同步队列
前言
原创不易,禁止转载!
本文旨在实现最简形式的交替打印。理解了同步队列,你可以轻松解决60%以上的多线程面试题。同步队列作为JUC提供的并发原语之一,使用了无锁算法,性能更好,但是却常常被忽略。
交替打印是一类常见的面试题,也是很多人第一次学习并发编程面对的问题,如:
- 三个线程T1、T2、T3轮流打印ABC,打印n次,如ABCABCABCABC.......
- 两个线程交替打印1-100的奇偶数
- N个线程循环打印1-100
很多文章(如: zhuanlan.zhihu.com/p/370130458 )总结了实现交替打印的多种做法:
- synchronized + wait/notify: 使用synchronized关键字和wait/notify方法来实现线程间的通信和同步。
- join() : 利用线程的join()方法来确保线程按顺序执行。
- Lock: 使用ReentrantLock来实现线程同步,通过锁的机制来控制线程的执行顺序。
- Lock + Condition: 在Lock的基础上,使用Condition对象来实现更精确的线程唤醒,避免不必要的线程竞争。
- Semaphore: 使用信号量来控制线程的执行顺序,通过acquire()和release()方法来管理线程的访问。
- 此外还有LockSupport、CountDownLatch、AtomicInteger 等实现方式。
笔者认为,在面试时能够选择一种无bug实现即可。
缺点
这些实现使用的都是原语,也就是并发编程中的基本组件,偏向于底层,同时要求开发者深入理解这些原语的工作原理,掌握很多技巧。
问题在于:如果真正的实践中实现,容易出现 bug,一般也不推荐在生产中使用;
这也是八股文的弊端之一:过于关注所谓的底层实现,忽略了真正的实践。
我们分析这些组件的特点,不外乎临界区锁定、线程同步、共享状态等。以下分析一个实现,看看使用到了哪些技巧:
class Wait_Notify_ACB {
private int num;
private static final Object LOCK = new Object();
private void printABC(int targetNum) {
for (int i = 0; i < 10; i++) {
synchronized (LOCK) {
while (num % 3 != targetNum) {
try {
LOCK.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
num++;
System.out.print(Thread.currentThread().getName());
LOCK.notifyAll();
}
}
}
public static void main(String[] args) {
Wait_Notify_ACB wait_notify_acb = new Wait_Notify_ACB ();
new Thread(() -> {
wait_notify_acb.printABC(0);
}, "A").start();
new Thread(() -> {
wait_notify_acb.printABC(1);
}, "B").start();
new Thread(() -> {
wait_notify_acb.printABC(2);
}, "C").start();
}
}
整体观之,使用的是 synchronized 隐式锁。使用等待队列实现线程同步,while 循环避免虚假唤醒,维护了多线程共享的 num 状态,此外需要注意多个任务的启动和正确终止。
InterruptedException 的处理是错误的,由于我们没有使用到中断机制,可以包装后抛出 IllegalStateException 表示未预料的异常。实践中,也可以设置当前线程为中断状态,待其他代码进行处理。
Lock不应该是静态的,可以改成非静态或者方法改成静态也行。
总之,经过分析可以看出并发原语的复杂性,那么有没有更高一层的抽象来简化问题呢?
更好的实现
笔者在项目的生产环境中遇到过类似的问题,多个线程需要协作,某些线程需要其他线程的结果,这种结果的交接是即时的,也就是说,A线程的结果直接交给B线程进行处理。
更好的实现要求我们实现线程之间的同步,同时应该避免并发修改。我们很自然地想到 SynchronousQueue,使用 CSP 实现 + CompletableFuture,可以减少我们考虑底层的心智负担,方便写出正确的代码。SynchronousQueue 适用于需要在生产者和消费者之间进行直接移交的场景,通常用于线程之间的切换或传递任务。
看一个具体例子:
以下是两个线程交替打印 1 - 100 的实现,由于没有在代码中使用锁,也没有状态维护的烦恼,这也是函数式的思想(减少状态)。
实现思路为:任务1从队列1中取结果,计算,提交给队列2。任务2同理。使用SynchronousQueue 实现直接交接。
private static Stopwatch betterImpl() {
Stopwatch sw = Stopwatch.createStarted();
BlockingQueue<Integer> q1 = new SynchronousQueue<>();
BlockingQueue<Integer> q2 = new SynchronousQueue<>();
int limit = 100;
CompletableFuture<Void> cf1 = CompletableFuture.runAsync(() -> {
while (true) {
Integer i = Uninterruptibles.takeUninterruptibly(q1);
if (i <= limit) {
System.out.println("thread1: i = " + i);
}
Uninterruptibles.putUninterruptibly(q2, i + 1);
if (i == limit - 1) {
break;
}
}
});
CompletableFuture<Void> cf2 = CompletableFuture.runAsync(() -> {
while (true) {
Integer i = Uninterruptibles.takeUninterruptibly(q2);
if (i <= limit) {
System.out.println("thread2: i = " + i);
}
if (i == limit) {
break;
}
Uninterruptibles.putUninterruptibly(q1, i + 1);
}
});
Uninterruptibles.putUninterruptibly(q1, 1);
CompletableFuture.allOf(cf1, cf2).join();
return sw.stop();
}
Uninterruptibles 是 Guava 中的并发工具,很实用,可以避免 try-catch 中断异常这样的样板代码。
线程池配置与本文讨论内容关系不大,故忽略。
一般实践中,阻塞方法都要设置超时时间,这里也忽略了。
这个实现简单明了,性能也不错。如果不需要即时交接,可以替换成缓冲队列(如 ArrayBlockingQueue)。
笔者简单比较了两种实现,结果如下:
private static Stopwatch bufferImpl() {
Stopwatch sw = Stopwatch.createStarted();
BlockingQueue<Integer> q1 = new ArrayBlockingQueue<>(2);
BlockingQueue<Integer> q2 = new ArrayBlockingQueue<>(2);
// ...
}
public static void main(String[] args) {
for (int i = 0; i < 100; i++) {
betterImpl();
bufferImpl();
// 预热
}
Stopwatch result1 = bufferImpl();
Stopwatch result2 = betterImpl();
System.out.println("result1 = " + result1);
System.out.println("result2 = " + result2);
}
// ...
thread2: i = 92
thread1: i = 93
thread2: i = 94
thread1: i = 95
thread2: i = 96
thread1: i = 97
thread2: i = 98
thread1: i = 99
thread2: i = 100
result1 = 490.3 μs
result2 = 469.1 μs
结论:使用 SynchronousQueue 性能更好,感兴趣的读者可以自己写 JMH 比对。
如果你觉得本文对你有帮助的话,欢迎给个点赞加收藏,也欢迎进一步的讨论。
后续我将继续分享并发编程、性能优化等有趣内容,力求做到全网独一份、深入浅出,一周两更,欢迎关注支持。
来源:juejin.cn/post/7532925096828026899
订单表超10亿数据,如何设计Sharding策略?解决跨分片查询和分布式事务?
订单表超10亿数据,如何设计Sharding策略?解决跨分片查询和分布式事务?
引言:
在电商平台高速发展的今天,海量订单处理已成为技术团队必须面对的挑战。当订单数据突破10亿大关,传统单库架构在查询性能、存储容量和运维复杂度上都会遇到瓶颈。
作为有8年经验的Java工程师,我曾主导多个日订单量百万级系统的分库分表改造。今天我将分享从Sharding策略设计到分布式事务落地的完整解决方案,其中包含核心代码实现和实战避坑指南。
一、业务场景分析
1.1 订单数据特点
- 数据量大:日增订单50万+,年增1.8亿
- 访问模式:
- 写操作:高频下单(峰值5000 TPS)
- 读操作:订单查询(用户端+运营端)
- 数据生命周期:热数据(3个月)占80%访问量
1.2 核心挑战
graph LR
A[10亿级订单] --> B[查询性能]
A --> C[存储瓶颈]
A --> D[跨分片聚合]
A --> E[分布式事务]
二、Sharding策略设计
2.1 分片键选择
候选方案 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
用户ID | 用户维度查询快 | 可能导致数据倾斜 | C端主导业务 |
订单ID | 数据分布均匀 | 用户订单需跨分片查询 | 均匀分布场景 |
商户ID | 商户维度查询快 | C端查询效率低 | B2B平台 |
创建时间 | 冷热数据分离 | 范围查询可能跨分片 | 推荐方案 |
最终方案:复合分片键(用户ID+创建时间)
2.2 分片算法设计
/**
* 自定义复合分片算法
* 分片键:user_id + create_time
*/
public class OrderShardingAlgorithm implements ComplexKeysShardingAlgorithm<Long> {
private static final String USER_KEY = "user_id";
private static final String TIME_KEY = "create_time";
@Override
public Collection<String> doSharding(
Collection<String> availableTargetNames,
ComplexKeysShardingValue<Long> shardingValue) {
Map<String, Collection<Long>> columnMap = shardingValue.getColumnNameAndShardingValuesMap();
List<String> shardingResults = new ArrayList<>();
// 获取用户ID分片值
Collection<Long> userIds = columnMap.get(USER_KEY);
Long userId = userIds.stream().findFirst().orElseThrow();
// 获取时间分片值
Collection<Long> timestamps = columnMap.get(TIME_KEY);
Long createTime = timestamps.stream().findFirst().orElse(System.currentTimeMillis());
// 计算用户分片(16个分库)
int dbShard = Math.abs(userId.hashCode()) % 16;
// 计算时间分片(按月分表)
LocalDateTime dateTime = Instant.ofEpochMilli(createTime)
.atZone(ZoneId.systemDefault())
.toLocalDateTime();
String tableSuffix = dateTime.format(DateTimeFormatter.ofPattern("yyyyMM"));
// 构建目标分片
String targetDB = "order_db_" + dbShard;
String targetTable = "t_order_" + tableSuffix;
shardingResults.add(targetDB + "." + targetTable);
return shardingResults;
}
}
2.3 分片策略配置(ShardingSphere)
# application-sharding.yaml
spring:
shardingsphere:
datasource:
names: ds0,ds1,...,ds15
# 配置16个数据源...
sharding:
tables:
t_order:
actualDataNodes: ds${0..15}.t_order_${202301..202412}
tableStrategy:
complex:
shardingColumns: user_id,create_time
algorithmClassName: com.xxx.OrderShardingAlgorithm
keyGenerator:
column: order_id
type: SNOWFLAKE
三、跨分片查询解决方案
3.1 常见问题及对策
问题类型 | 传统方案痛点 | 优化方案 |
---|---|---|
分页查询 | LIMIT 0,10 扫描全表 | 二次查询法 |
排序聚合 | 内存合并性能差 | 并行查询+流式处理 |
全局索引 | 无法直接建立 | 异步构建ES索引 |
3.2 分页查询优化实现
/**
* 跨分片分页查询优化(二次查询法)
* 原SQL:SELECT * FROM t_order WHERE user_id=1001 ORDER BY create_time DESC LIMIT 10000,10
*/
public Page<Order> shardingPageQuery(Long userId, int pageNo, int pageSize) {
// 第一步:全分片并行查询
List<Order> allShardResults = shardingExecute(
shard -> "SELECT order_id, create_time FROM t_order "
+ "WHERE user_id = " + userId
+ " ORDER BY create_time DESC"
);
// 第二步:内存排序取TopN
List<Long> targetIds = allShardResults.stream()
.sorted(Comparator.comparing(Order::getCreateTime).reversed())
.skip(pageNo * pageSize)
.limit(pageSize)
.map(Order::getOrderId)
.collect(Collectors.toList());
// 第三步:精准查询目标数据
return orderRepository.findByIdIn(targetIds);
}
/**
* 并行执行查询(使用CompletableFuture)
*/
private List<Order> shardingExecute(Function<Integer, String> sqlBuilder) {
List<CompletableFuture<List<Order>>> futures = new ArrayList<>();
for (int i = 0; i < 16; i++) {
final int shardId = i;
futures.add(CompletableFuture.supplyAsync(() -> {
String sql = sqlBuilder.apply(shardId);
return jdbcTemplate.query(sql, new OrderRowMapper());
}, shardingThreadPool));
}
return futures.stream()
.map(CompletableFuture::join)
.flatMap(List::stream)
.collect(Collectors.toList());
}
3.3 聚合查询优化
/**
* 分布式聚合计算(如:用户总订单金额)
* 方案:并行查询分片结果 + 内存汇总
*/
public BigDecimal calculateUserTotalAmount(Long userId) {
List<CompletableFuture<BigDecimal>> futures = new ArrayList<>();
for (int i = 0; i < 16; i++) {
futures.add(CompletableFuture.supplyAsync(() -> {
String sql = "SELECT SUM(amount) FROM t_order WHERE user_id = ?";
return jdbcTemplate.queryForObject(
sql, BigDecimal.class, userId);
}, shardingThreadPool));
}
return futures.stream()
.map(CompletableFuture::join)
.filter(Objects::nonNull)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
四、分布式事务解决方案
4.1 方案对比
方案 | 一致性 | 性能 | 复杂度 | 适用场景 |
---|---|---|---|---|
2PC | 强一致 | 差 | 高 | 银行核心系统 |
TCC | 强一致 | 中 | 高 | 资金交易 |
Saga | 最终一致 | 优 | 中 | 订单系统(推荐) |
本地消息表 | 最终一致 | 良 | 低 | 低要求场景 |
4.2 Saga事务实现(订单创建场景)
sequenceDiagram
participant C as 应用
participant O as 订单服务
participant I as 库存服务
participant P as 支付服务
C->>O: 创建订单
O->>I: 预扣库存
I-->>O: 扣减成功
O->>P: 发起支付
P-->>O: 支付成功
O->>C: 返回结果
alt 支付失败
O->>I: 释放库存(补偿)
end
4.3 核心代码实现
/**
* Saga事务管理器(使用Seata框架)
*/
@Service
@Slf4j
public class OrderSagaService {
@Autowired
private InventoryFeignClient inventoryClient;
@Autowired
private PaymentFeignClient paymentClient;
@Transactional
public void createOrder(OrderCreateDTO dto) {
// 1. 创建本地订单(状态:待支付)
Order order = createPendingOrder(dto);
try {
// 2. 调用库存服务(Saga参与者)
inventoryClient.deductStock(
new DeductRequest(order.getOrderId(), dto.getSkuItems()));
// 3. 调用支付服务(Saga参与者)
paymentClient.createPayment(
new PaymentRequest(order.getOrderId(), order.getTotalAmount()));
// 4. 更新订单状态为已支付
order.paySuccess();
orderRepository.update(order);
} catch (Exception ex) {
// 触发Saga补偿流程
log.error("订单创建失败,触发补偿", ex);
handleCreateOrderFailure(order, ex);
throw ex;
}
}
/**
* 补偿操作(需要幂等)
*/
@Compensable(compensationMethod = "compensateOrder")
private void handleCreateOrderFailure(Order order, Exception ex) {
// 1. 释放库存
inventoryClient.restoreStock(order.getOrderId());
// 2. 取消支付(如果已发起)
paymentClient.cancelPayment(order.getOrderId());
// 3. 标记订单失败
order.cancel("系统异常: " + ex.getMessage());
orderRepository.update(order);
}
/**
* 补偿方法(幂等设计)
*/
public void compensateOrder(Order order, Exception ex) {
// 通过状态判断避免重复补偿
if (order.getStatus() != OrderStatus.CANCELLED) {
handleCreateOrderFailure(order, ex);
}
}
}
五、性能优化实践
5.1 分片路由优化
/**
* 热点用户订单查询优化
* 方案:用户分片路由缓存
*/
@Aspect
@Component
public class ShardingRouteCacheAspect {
@Autowired
private RedisTemplate<String, Integer> redisTemplate;
private static final String ROUTE_KEY = "user_route:%d";
@Around("@annotation(org.apache.shardingsphere.api.hint.Hint)")
public Object cacheRoute(ProceedingJoinPoint joinPoint) throws Throwable {
Long userId = getUserIdFromArgs(joinPoint.getArgs());
if (userId == null) {
return joinPoint.proceed();
}
// 1. 查询缓存
String cacheKey = String.format(ROUTE_KEY, userId);
Integer shardId = redisTemplate.opsForValue().get(cacheKey);
if (shardId == null) {
// 2. 计算分片ID(避免全表扫描)
shardId = calculateUserShard(userId);
redisTemplate.opsForValue().set(cacheKey, shardId, 1, TimeUnit.HOURS);
}
// 3. 设置分片Hint强制路由
try (HintManager hintManager = HintManager.getInstance()) {
hintManager.setDatabaseShardingValue(shardId);
return joinPoint.proceed();
}
}
private int calculateUserShard(Long userId) {
// 分片计算逻辑(与分片算法保持一致)
return Math.abs(userId.hashCode()) % 16;
}
}
5.2 冷热数据分离
-- 归档策略示例(每月执行)
CREATE EVENT archive_orders
ON SCHEDULE EVERY 1 MONTH
DO
BEGIN
-- 1. 创建归档表(按年月)
SET @archive_table = CONCAT('t_order_archive_', DATE_FORMAT(NOW(), '%Y%m'));
SET @create_sql = CONCAT('CREATE TABLE IF NOT EXISTS ', @archive_table, ' LIKE t_order');
PREPARE stmt FROM @create_sql; EXECUTE stmt;
-- 2. 迁移数据(6个月前)
SET @move_sql = CONCAT(
'INSERT INTO ', @archive_table,
' SELECT * FROM t_order WHERE create_time < DATE_SUB(NOW(), INTERVAL 6 MONTH)'
);
PREPARE stmt FROM @move_sql; EXECUTE stmt;
-- 3. 删除原表数据
DELETE FROM t_order WHERE create_time < DATE_SUB(NOW(), INTERVAL 6 MONTH);
END
六、避坑指南
6.1 常见问题及解决方案
问题类型 | 现象 | 解决方案 |
---|---|---|
分片键选择不当 | 数据倾斜(70%数据在1个分片) | 增加分片基数(复合分片键) |
分布式事务超时 | 库存释放失败 | 增加重试机制+人工补偿台 |
跨分片查询性能差 | 分页查询超时 | 改用ES做全局搜索 |
扩容困难 | 增加分片需迁移数据 | 初始设计预留分片(32库) |
6.2 必须实现的监控项
graph TD
A[监控大盘] --> B[分片负载]
A --> C[慢查询TOP10]
A --> D[分布式事务成功率]
A --> E[热点用户检测]
A --> F[归档任务状态]
七、总结与展望
分库分表本质是业务与技术的平衡艺术,经过多个项目的实践验证,我总结了以下核心经验:
- 分片设计三原则:
- 数据分布均匀性 > 查询便捷性
- 业务可扩展性 > 短期性能
- 简单可运维 > 技术先进性
- 演进路线建议:
graph LR
A[单库] --> B[读写分离]
B --> C[垂直分库]
C --> D[水平分表]
D --> E[单元化部署]
- 未来优化方向:
- 基于TiDB的HTAP架构
- 使用Apache ShardingSphere-Proxy
- 智能分片路由(AI预测热点)
最后的话:
处理10亿级订单如同指挥一场交响乐——每个分片都是独立乐器,既要保证局部精准,又要实现全局和谐。
好的分库分表方案不是技术参数的堆砌,而是对业务深刻理解后的架构表达。
来源:juejin.cn/post/7519688814395719714
Nginx+Keepalive 实现高可用并启用健康检查模块
1. 目标效果
keepalived 负责监控 192.168.1.20 和 192.168.1.30 这两台负载均衡的服务器,并自动选择一台作为主服务器。用户访问 http://192.168.1.10 时,由主服务器接收该请求。当 keepalived 检测到主服务器不可访问时,会将备服务器升级为主服务器,从而实现高可用。
在主服务器中,通过 nginx(tengine)实现负载均衡,将访问请求分流到 192.168.1.100 和 192.168.1.200 这两台业务服务器。 nginx 中的健康检查模块会检测业务服务器状态,如果检测到 192.168.1.100 不可访问,则不再将访问请求发送给该服务器。
2. 部署 Keepalived
2.1 主机 IP
主机 | IP |
---|---|
虚拟 IP | 192.168.1.10 |
主服务器 | 192.168.1.20 |
备服务器 | 192.168.1.30 |
2.2 主服务器设置
官方配置说明文档:Keepalived for Linux
yun install vim epel-release keepalived -y
cp /etc/keepalived/keepalived.conf /etc/keepalived/keepalived.conf.bak
cat > /etc/keepalived/keepalived.conf <<EOF
! Configuration File for keepalived
global_defs {
router_id nginx01
}
vrrp_script check_nginx {
script "/etc/keepalived/nginx_check.sh"
interval 2
weight 2
}
vrrp_instance VI_1 {
state MASTER
interface eth0
virtual_router_id 51
priority 100
advert_int 1
authentication {
auth_type PASS
auth_pass linalive
}
virtual_ipaddress {
192.168.1.10
}
track_script {
check_nginx
}
}
EOF
touch /etc/keepalived/nginx_check.sh && chmod +x /etc/keepalived/nginx_check.sh
cat > /etc/keepalived/nginx_check.sh <<EOF
#!/bin/bash
if ! pgrep -x "nginx" > /dev/null; then
systemctl restart nginx
sleep 2
if ! pgrep -x "nginx" > /dev/null; then
pkill keepalived
fi
fi
EOF
2.2 备服务器设置
yun install vim epel-release keepalived -y
cp /etc/keepalived/keepalived.conf /etc/keepalived/keepalived.conf.bak
cat > /etc/keepalived/keepalived.conf <<EOF
! Configuration File for keepalived
global_defs {
router_id nginx02
}
vrrp_script check_nginx {
script "/etc/keepalived/nginx_check.sh"
interval 2
weight 2
}
vrrp_instance VI_1 {
state BACKUP
interface eth0
virtual_router_id 51
priority 90
advert_int 1
authentication {
auth_type PASS
auth_pass linalive
}
virtual_ipaddress {
192.168.1.10
}
track_script {
check_nginx
}
}
EOF
touch /etc/keepalived/nginx_check.sh && chmod +x /etc/keepalived/nginx_check.sh
cat > /etc/keepalived/nginx_check.sh <<EOF
#!/bin/bash
if ! pgrep -x "nginx" > /dev/null; then
systemctl restart nginx
sleep 2
if ! pgrep -x "nginx" > /dev/null; then
pkill keepalived
fi
fi
EOF
3. 部署 Tengine (主备服务器)
3.1 准备 Tengine 压缩文件
下载 tengine 压缩文件,将文件上传到 /opt 文件夹下。下载地址:The Tengine Web Server
本文章编写时,最新版是:tengine-3.1.0.tar.gz
3.2 解压并编译
yum install -y gcc gcc-c++ make pcre-devel zlib-devel openssl-devel
tar zxvf /opt/tengine-3.1.0.tar.gz -C /opt
cd /opt/tengine-3.1.0
# configure 有众多的参数可设置,可使用 ./configure --help 进行查看
# 按照官方说法默认应该是开启了健康检查模块,但实测需要手动添加参数
./configure --add-module=modules/ngx_http_upstream_check_module/
make && make install
3.3 添加服务项
cat > /etc/systemd/system/nginx.service <<EOF
[Unit]
Description=The Tengine HTTP and reverse proxy server
After=network.target
[Service]
Type=forking
PIDFile=/usr/local/nginx/logs/nginx.pid
ExecStartPre=/usr/local/nginx/sbin/nginx -t
ExecStart=/usr/local/nginx/sbin/nginx
ExecReload=/usr/local/nginx/sbin/nginx -s reload
ExecStop=/usr/local/nginx/sbin/nginx -s stop
PrivateTmp=true
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
3.4 编辑 nginx 配置文件
此处配置的内容项可参考官方文档:ngx_http_upstream_check_module
# tengine 默认的安装路径是 /usr/local/nginx
# 配置文件路径: /usr/local/nginx/conf/nginx.conf
# /favicon.ico 是接口地址,需替换成真实的 api 接口
worker_processes auto;
events {
worker_connections 1024;
}
http {
upstream cluster1 {
server 192.168.1.100:8082;
server 192.168.1.200:8089;
check interval=3000 rise=2 fall=5 timeout=1000 type=http;
check_http_send "HEAD /favicon.ico HTTP/1.0\r\n\r\n";
check_http_expect_alive http_2xx http_3xx;
}
server {
listen 80;
server_name localhost;
location / {
index Index.aspx;
proxy_pass http://cluster1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /status {
check_status;
access_log off;
}
}
}
3.5 启动服务并访问
使用 systemctl start nginx
启动服务,并访问 localhost:80/status
查看健康检查报表页
4. 写在最后
来源:juejin.cn/post/7483314478957232138
创建型模式:抽象工厂模式
什么是抽象工厂模式
抽象工厂模式是一种创建型设计模式,它提供一个接口来创建一系列相关或相互依赖的对象家族,而无需指定它们的具体类。简单来说,抽象工厂模式是工厂模式的升级版,它不再只生产一种产品,而是生产一整套产品。
抽象工厂vs工厂方法:关键区别
- 工厂方法模式:关注单个产品的创建,一个工厂创建一种产品
- 抽象工厂模式:关注产品族的创建,一个工厂创建多种相关产品
这就像一个生产手机的工厂(工厂方法)和一个生产整套电子设备(手机、平板、耳机)的工厂(抽象工厂)的区别。
抽象工厂模式的核心实现
// 产品A接口
public interface ProductA {
void operationA();
}
// 产品B接口
public interface ProductB {
void operationB();
}
// 产品A1实现
public class ConcreteProductA1 implements ProductA {
@Override
public void operationA() {
System.out.println("产品A1的操作");
}
}
// 产品A2实现
public class ConcreteProductA2 implements ProductA {
@Override
public void operationA() {
System.out.println("产品A2的操作");
}
}
// 产品B1实现
public class ConcreteProductB1 implements ProductB {
@Override
public void operationB() {
System.out.println("产品B1的操作");
}
}
// 产品B2实现
public class ConcreteProductB2 implements ProductB {
@Override
public void operationB() {
System.out.println("产品B2的操作");
}
}
// 抽象工厂接口
public interface AbstractFactory {
ProductA createProductA();
ProductB createProductB();
}
// 具体工厂1 - 创建产品族1(A1+B1)
public class ConcreteFactory1 implements AbstractFactory {
@Override
public ProductA createProductA() {
return new ConcreteProductA1();
}
@Override
public ProductB createProductB() {
return new ConcreteProductB1();
}
}
// 具体工厂2 - 创建产品族2(A2+B2)
public class ConcreteFactory2 implements AbstractFactory {
@Override
public ProductA createProductA() {
return new ConcreteProductA2();
}
@Override
public ProductB createProductB() {
return new ConcreteProductB2();
}
}
抽象工厂模式的关键点
- 产品接口:为每种产品定义接口
- 具体产品:实现产品接口的具体类
- 抽象工厂接口:声明一组创建产品的方法
- 具体工厂:实现抽象工厂接口,创建一个产品族
- 产品族:一组相关产品的集合(例如PC系列组件、移动系列组件)
实际应用示例:跨平台UI组件库
下面通过一个跨平台UI组件库的例子来展示抽象工厂模式的强大应用:
// ===== 按钮组件 =====
public interface Button {
void render();
void onClick();
}
// Windows按钮
public class WindowsButton implements Button {
@Override
public void render() {
System.out.println("渲染Windows风格的按钮");
}
@Override
public void onClick() {
System.out.println("Windows按钮点击效果");
}
}
// MacOS按钮
public class MacOSButton implements Button {
@Override
public void render() {
System.out.println("渲染MacOS风格的按钮");
}
@Override
public void onClick() {
System.out.println("MacOS按钮点击效果");
}
}
// ===== 复选框组件 =====
public interface Checkbox {
void render();
void toggle();
}
// Windows复选框
public class WindowsCheckbox implements Checkbox {
@Override
public void render() {
System.out.println("渲染Windows风格的复选框");
}
@Override
public void toggle() {
System.out.println("Windows复选框切换状态");
}
}
// MacOS复选框
public class MacOSCheckbox implements Checkbox {
@Override
public void render() {
System.out.println("渲染MacOS风格的复选框");
}
@Override
public void toggle() {
System.out.println("MacOS复选框切换状态");
}
}
// ===== 文本框组件 =====
public interface TextField {
void render();
void getText();
}
// Windows文本框
public class WindowsTextField implements TextField {
@Override
public void render() {
System.out.println("渲染Windows风格的文本框");
}
@Override
public void getText() {
System.out.println("获取Windows文本框内容");
}
}
// MacOS文本框
public class MacOSTextField implements TextField {
@Override
public void render() {
System.out.println("渲染MacOS风格的文本框");
}
@Override
public void getText() {
System.out.println("获取MacOS文本框内容");
}
}
// ===== GUI工厂接口 =====
public interface GUIFactory {
Button createButton();
Checkbox createCheckbox();
TextField createTextField();
}
// Windows GUI工厂
public class WindowsFactory implements GUIFactory {
@Override
public Button createButton() {
return new WindowsButton();
}
@Override
public Checkbox createCheckbox() {
return new WindowsCheckbox();
}
@Override
public TextField createTextField() {
return new WindowsTextField();
}
}
// MacOS GUI工厂
public class MacOSFactory implements GUIFactory {
@Override
public Button createButton() {
return new MacOSButton();
}
@Override
public Checkbox createCheckbox() {
return new MacOSCheckbox();
}
@Override
public TextField createTextField() {
return new MacOSTextField();
}
}
如何使用抽象工厂模式
// 应用类 - 与具体工厂解耦
public class Application {
private Button button;
private Checkbox checkbox;
private TextField textField;
// 构造函数接收一个抽象工厂
public Application(GUIFactory factory) {
button = factory.createButton();
checkbox = factory.createCheckbox();
textField = factory.createTextField();
}
// 渲染表单
public void renderForm() {
System.out.println("=== 开始渲染表单 ===");
button.render();
checkbox.render();
textField.render();
System.out.println("=== 表单渲染完成 ===");
}
// 表单操作
public void handleForm() {
System.out.println("\n=== 表单交互 ===");
button.onClick();
checkbox.toggle();
textField.getText();
}
}
// 客户端代码
public class GUIDemo {
public static void main(String[] args) {
// 检测当前操作系统
String osName = System.getProperty("os.name").toLowerCase();
GUIFactory factory;
// 根据操作系统选择合适的工厂
if (osName.contains("windows")) {
factory = new WindowsFactory();
System.out.println("检测到Windows系统,使用Windows风格UI");
} else {
factory = new MacOSFactory();
System.out.println("检测到非Windows系统,使用MacOS风格UI");
}
// 创建并使用应用 - 注意应用不依赖于具体组件类
Application app = new Application(factory);
app.renderForm();
app.handleForm();
}
}
运行结果(Windows系统上)
检测到Windows系统,使用Windows风格UI
=== 开始渲染表单 ===
渲染Windows风格的按钮
渲染Windows风格的复选框
渲染Windows风格的文本框
=== 表单渲染完成 ===
=== 表单交互 ===
Windows按钮点击效果
Windows复选框切换状态
获取Windows文本框内容
运行结果(MacOS系统上)
检测到非Windows系统,使用MacOS风格UI
=== 开始渲染表单 ===
渲染MacOS风格的按钮
渲染MacOS风格的复选框
渲染MacOS风格的文本框
=== 表单渲染完成 ===
=== 表单交互 ===
MacOS按钮点击效果
MacOS复选框切换状态
获取MacOS文本框内容
抽象工厂模式的常见应用场景
- 跨平台UI工具包:为不同操作系统提供一致的界面组件
- 数据库访问层:支持多种数据库系统(MySQL、Oracle、MongoDB等)
- 游戏开发:创建不同主题的游戏元素(中世纪、未来、童话等)
- 多环境配置系统:为开发、测试、生产环境提供不同实现
- 电子设备生态系统:创建配套的产品(手机、耳机、手表都来自同一品牌)
- 多主题应用:切换应用的视觉主题(暗色模式/亮色模式)
抽象工厂模式的实际案例
许多知名框架和库使用抽象工厂模式,如:
- Java的JDBC:
ConnectionFactory
创建特定数据库的连接 - Spring Framework:通过BeanFactory创建和管理各种组件
- javax.xml.parsers.DocumentBuilderFactory:创建DOM解析器
- Hibernate:
SessionFactory
为不同数据库创建会话
抽象工厂模式的优点
- 产品一致性保证:确保一个工厂创建的产品相互兼容
- 隔离具体类:客户端与具体类隔离,只与接口交互
- 开闭原则:引入新产品族不需要修改现有代码
- 替换产品族:可以整体替换产品族(如UI主题切换)
抽象工厂模式的缺点
- 扩展困难:添加新的产品类型需要修改工厂接口及所有实现
- 复杂度增加:产品较多时,类的数量会急剧增加
- 接口污染:接口中可能包含部分工厂不支持的创建方法
抽象工厂的实现变体
使用反射简化工厂实现
public class ReflectiveFactory implements GUIFactory {
private String packageName;
public ReflectiveFactory(String stylePrefix) {
packageName = "com.example.gui." + stylePrefix.toLowerCase();
}
@Override
public Button createButton() {
return (Button) createComponent("Button");
}
@Override
public Checkbox createCheckbox() {
return (Checkbox) createComponent("Checkbox");
}
@Override
public TextField createTextField() {
return (TextField) createComponent("TextField");
}
private Object createComponent(String type) {
try {
Class<?> clazz = Class.forName(packageName + "." + type);
return clazz.getDeclaredConstructor().newInstance();
} catch (Exception e) {
throw new RuntimeException("无法创建组件", e);
}
}
}
带有默认实现的抽象工厂
public abstract class BaseGUIFactory implements GUIFactory {
// 提供默认实现
@Override
public TextField createTextField() {
return new DefaultTextField(); // 所有平台通用的默认实现
}
// 其他方法需要子类实现
@Override
public abstract Button createButton();
@Override
public abstract Checkbox createCheckbox();
}
实现抽象工厂的设计考虑
- 产品族边界:明确定义哪些产品属于同一族
- 接口设计:保持工厂接口精简,避免方法爆炸
- 工厂选择机制:考虑如何选择/切换具体工厂
- 扩展策略:提前考虑如何添加新产品类型
- 组合与单一职责:大型产品族可考虑拆分为多个子工厂
抽象工厂模式最佳实践
- 适度使用:当确实需要创建一系列相关对象时才使用
- 懒加载:考虑延迟创建产品,而不是一次创建所有产品
- 结合其他模式:与单例、原型、构建者等模式结合使用
- 依赖注入:通过依赖注入框架传递工厂
- 配置驱动:使用配置文件或注解选择具体工厂实现
// 使用配置驱动的工厂
public class ConfigurableGUIFactory {
public static GUIFactory getFactory() {
String factoryType = ConfigLoader.getProperty("ui.factory");
switch (factoryType) {
case "windows": return new WindowsFactory();
case "macos": return new MacOSFactory();
case "web": return new WebFactory();
default: throw new IllegalArgumentException("未知UI工厂类型");
}
}
}
抽象工厂与依赖倒置原则
抽象工厂是实现依赖倒置原则的绝佳方式:高层模块不依赖于低层模块,两者都依赖于抽象。
// 不好的设计:直接依赖具体类
public class BadForm {
private WindowsButton button; // 直接依赖具体实现
private WindowsCheckbox checkbox;
public void createUI() {
button = new WindowsButton(); // 硬编码创建具体类
checkbox = new WindowsCheckbox();
}
}
// 好的设计:依赖抽象
public class GoodForm {
private Button button; // 依赖接口
private Checkbox checkbox;
private final GUIFactory factory; // 依赖抽象工厂
public GoodForm(GUIFactory factory) {
this.factory = factory;
}
public void createUI() {
button = factory.createButton(); // 通过工厂创建
checkbox = factory.createCheckbox();
}
}
抽象工厂模式小结
抽象工厂模式是一种强大但需谨慎使用的创建型模式。它在需要一套相关产品且系统不应依赖于产品的具体类时非常有用。这种模式有助于确保产品兼容性,并为产品族提供统一的创建接口。
适当应用抽象工厂模式可以使代码更具灵活性和可维护性,但也要避免过度设计导致的复杂性。理解产品族的概念和如何设计良好的抽象工厂接口是掌握这一模式的关键。
来源:juejin.cn/post/7491963395284549669
Spring Boot Admin:一站式监控微服务,这个运维神器真香!
关注我的公众号:【编程朝花夕拾】,可获取首发内容。
01 引言
在现代微服务架构中,应用实例的数量动辄成百上千。传统的逐个登录服务器查看日志、检查状态的方式早已变得低效且不现实。
因此,一个集中化、可视化、且能提供实时健康状态的管理平台变得至关重要。Spring Boot Admin
(SBA) 正是为了满足这一需求而生的强大工具。
然而,各种厂商的云服务提供了各种监控服务解决客户的各种痛点。Spring Boot Admin
这样的工具似乎关注度没有那么高。小编也是无意间发现这款产品,分享给大家。
02 简介
Spring Boot Admin
是一个用于管理和监控 Spring Boot
应用程序的开源社区项目。它并非官方 Spring
项目,但在社区中备受推崇并被广泛使用。
其核心原理是:一个作为“服务器”(Server)的中央管理端,通过收集并展示众多作为“客户端”(Client)的 Spring Boot 应用的监控信息。
Spring Boot Admin
通过集成 Spring Boot Actuator 端点来获取应用数据,并提供了一个友好的 Web UI
界面来展示这些信息。
主要分为两部分:
- 服务端:监控平台
- 客户端:业务端
SpringBoot
的版本和Spring Boot Admin
有一定的对应关系:
GitHub
地址:github.com/codecentric…
文档地址:docs.spring-boot-admin.com/
03 Admin服务端
服务的端配置相当简单,只需要引入依赖,启动增加注解。服务端的基础配置就算完成了。
3.1 基础配置
Maven依赖
<dependency>
<groupId>de.codecentric</groupId>
<artifactId>spring-boot-admin-starter-server</artifactId>
<version>${latest.version}</version>
</dependency>
增加注解
@EnableAdminServer
这两个配置就可访问项目的IP+端口
,进入管理页面了。
3.2 增加鉴权
为了数据安全,可以增加鉴权。拥有账号和密码方可进入。
Maven依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
配置文件
# 设置自己的用户名和密码
spring.security.user.name=admin
spring.security.user.password=123456
输入对应的用户名和密码就可以进入了。
3.3 增加邮件推送
官方提供了各种通知,也可以自定义,如图:
我们以邮件通知为例。
Maven依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
配置
# 邮箱配置
spring.mail.host=smtp.163.com
spring.mail.port=25
spring.mail.username=用户名
spring.mail.password=*****[授权码]
# 发送和接受邮箱
spring.boot.admin.notify.mail.to=wsapplyjob@163.com
spring.boot.admin.notify.mail.from=wsapplyjob@163.com
客户端下线之后会触发邮件:
04 Adamin
客户端
因为服务端是依赖Spring Boot Actuator
端点来获取应用数据,所以我们需要开放期其所有的服务。
4.1 基础配置
Maven依赖
<dependency>
<groupId>de.codecentric</groupId>
<artifactId>spring-boot-admin-starter-client</artifactId>
<version>${latest.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
# 服务端地址
spring.boot.admin.client.url=http://127.0.0.1:8081
# 鉴权信息
spring.boot.admin.client.username=admin
spring.boot.admin.client.password=123456
# 开发所有的暴漏的信息
management.endpoints.web.exposure.include=*
4.2 监控界面
进入之后,我们就会发现上面的页面。点击应用墙,就会展示所有监控的实例。进入之后如图:
进入之后就可以看到五大块。其中②就是我们之前看到的日志级别的控制。还包含了缓存、计划任务、映射甚至类之间的依赖关系。
因为界面支持中文,里面具体的功能就不做描述,感兴趣的可以自己的探索。
4.3 日志配置增加日志
默认的日志进去只有日志的级别,并不会打印日志。
这是官方的描述:
我们增加配置:
logging.file.name=/var/log/boot-log.log
logging.pattern.file=%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(%5p) %clr(${PID}){magenta} %clr(---){faint} %clr([.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n%wEx (2)
启动查看结果:
我们就可以看到信的菜单:日志文件
4.4 缓存
【缓存】是没有数据的:
缓存依赖
<!-- 监控缓存需要的依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
代码
触发缓存任务之后,就会出现缓存的管理:
4.5 计划任务
【计划任务】和缓存基本一样,但是无需引入第三方依赖。使用@Scheduled
即可。
监控结果:
05 小结
Spring Boot Admin
以其简洁的配置、强大的功能和友好的界面,成为了 Spring Boot
微服务监控领域的事实标准。它极大地降低了监控和运维的复杂度,让开发者能够更专注于业务逻辑开发。
对于中小型规模的微服务集群,直接使用 SBA 是一个高效且成本低廉的解决方案。
赶快去探索里面不同的功能的吧!
来源:juejin.cn/post/7542450691911155762
别再混淆了!一文彻底搞懂System.identityHashCode与Object.hashCode的区别
在Java开发中,哈希码的使用无处不在,但许多开发者对
System.identityHashCode()
和Object.hashCode()
的区别仍然模糊不清。本文将深入剖析二者的核心区别,并通过实际代码演示它们在不同场景下的行为差异。
一、本质定义:两种哈希码的起源
- Object.hashCode()
- 所有Java对象的默认方法(定义在
Object
类中) - 可被子类重写(通常基于对象内容计算)
// 默认实现(未重写时)
public native int hashCode();
- 所有Java对象的默认方法(定义在
- System.identityHashCode()
System
类提供的静态工具方法- 无视任何重写,始终返回JVM原始哈希码
public static native int identityHashCode(Object x);
二、核心区别对比(表格速查)
特性 | Object.hashCode() | System.identityHashCode() |
---|---|---|
是否可重写 | ✅ 子类可重写改变行为 | ❌ 行为固定不可变 |
对重写的敏感性 | 返回重写后的自定义值 | 永远返回JVM原始哈希码 |
null 处理 | 调用抛NullPointerException | 安全返回0 |
返回值一致性 | 内容改变时可能变化 | 对象生命周期内永不改变 |
典型用途 | HashMap /HashSet 等基于内容的集合 | IdentityHashMap 等身份敏感操作 |
三、关键差异深度解析
1. 重写行为对比(核心区别)
class CustomObject {
private int id;
// 重写hashCode(基于内容)
@Override
public int hashCode() {
return id * 31;
}
}
public static void main(String[] args) {
CustomObject obj = new CustomObject();
obj.id = 100;
System.out.println("hashCode: " + obj.hashCode()); // 3100
System.out.println("identityHashCode: " + System.identityHashCode(obj)); // 356573597
}
输出说明:
✅ hashCode()
返回重写后的计算值
✅ identityHashCode()
无视重写,返回JVM原始哈希
2. null安全性对比
Object obj = null;
// 抛出NullPointerException
try {
System.out.println(obj.hashCode());
} catch (NullPointerException e) {
System.out.println("调用hashCode()抛NPE");
}
// 安全返回0
System.out.println("identityHashCode(null): "
+ System.identityHashCode(obj));
3. 哈希码不变性验证
String str = "Hello";
int initialIdentity = System.identityHashCode(str);
str = str + " World!"; // 修改对象内容
// 身份哈希码保持不变
System.out.println(initialIdentity == System.identityHashCode(str)); // true
四、经典应用场景
1. 使用Object.hashCode()的场景
// 在HashMap中作为键(依赖内容哈希)
Map<Student, Grade> gradeMap = new HashMap<>();
Student s = new Student("2023001", "张三");
gradeMap.put(s, new Grade(90));
// 重写需遵守规范:内容相同则哈希码相同
class Student {
private String id;
private String name;
@Override
public int hashCode() {
return Objects.hash(id, name);
}
}
2. 使用identityHashCode()的场景
场景1:实现身份敏感的容器
// IdentityHashMap基于身份哈希而非内容
IdentityHashMap<Object, String> identityMap = new IdentityHashMap<>();
String s1 = new String("ABC");
String s2 = new String("ABC");
identityMap.put(s1, "第一对象");
identityMap.put(s2, "第二对象"); // 不同对象,均可插入
System.out.println(identityMap.size()); // 2
场景2:检测hashCode是否被重写
boolean isHashCodeOverridden(Object obj) {
return obj.hashCode() != System.identityHashCode(obj);
}
// 测试
System.out.println(isHashCodeOverridden(new Object())); // false
System.out.println(isHashCodeOverridden(new String("Test"))); // true
场景3:调试对象内存关系
Object objA = new Object();
Object objB = objA; // 指向同一对象
// 身份哈希相同证明是同一对象
System.out.println(System.identityHashCode(objA)
== System.identityHashCode(objB)); // true
五、底层机制揭秘
- 存储位置:身份哈希码存储在对象头中
- 生成时机:首次调用
hashCode()
或identityHashCode()
时生成 - 计算规则:通常基于内存地址,但JVM会优化(非直接地址)
- 不变性:一旦生成,在对象生命周期内永不改变
六、总结与最佳实践
方法 | 选用原则 |
---|---|
Object.hashCode() | 需要基于对象内容的哈希逻辑时使用 |
System.identityHashCode() | 需要对象身份标识时使用 |
黄金准则:
- 当对象作为
HashMap
等内容敏感容器的键时 → 重写hashCode()
+equals()
- 当需要对象身份标识(如调试、
IdentityHashMap
)时 → 使用identityHashCode()
- 永远不要在重写的
hashCode()
中调用identityHashCode()
,这违反哈希契约!
通过合理选择这两种哈希码,可以避免常见的
HashMap
逻辑错误和身份混淆问题。理解它们的差异,将使你的Java代码更加健壮高效!
来源:juejin.cn/post/7519797197925367818
别再说你会 new Object() 了!JVM 类加载的真相,绝对和你想的不一样
当我们编写
new Object()
时,JVM 背后到底发生了怎样的故事?类加载过程中的初始化阶段究竟暗藏哪些玄机?
一、引言:从一段简单代码说起
先来看一个看似简单的 Java 代码片段:
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}
当我们执行这段代码时,背后却隐藏着 JVM 复杂的类加载机制。.java
文件经过编译变成 .class
字节码文件,这些"静态"的字节码需要被 JVM 动态地加载、处理并最终执行。这就是类加载过程的神奇之处。
类加载机制是 Java 语言的核心基石,它赋予了 Java "一次编写,到处运行" 的能力。理解这一过程,不仅能帮助我们编写更高效的代码,更是面试中的高频考点。
二、类生命周期:七个阶段的完整旅程
在深入类加载过程之前,我们先来了解类的完整生命周期。一个类在 JVM 中从加载到卸载,总共经历七个阶段:
阶段 | 描述 | 是否必须 | 特点 | JVM规范要求 |
---|---|---|---|---|
加载(Loading) | 查找并加载类的二进制数据 | 是 | 将字节码读入内存,生成Class对象 | 强制 |
验证(Verification) | 确保被加载的类正确无误 | 是 | 安全验证,防止恶意代码 | 强制 |
准备(Preparation) | 为类变量分配内存并设置初始零值 | 是 | 注意:不是程序员定义的初始值 | 强制 |
解析(Resolution) | 将符号引用转换为直接引用 | 否 | 可以在初始化后再进行 | 可选 |
初始化(Initialization) | 执行类构造器 <clinit>() 方法 | 是 | 初始化类而不是对象 | 强制 |
使用(Using) | 正常使用类的功能 | 是 | 类的使命阶段 | - |
卸载(Unloading) | 从内存中释放类数据 | 否 | 由垃圾回收器负责 | 可选 |
前五个阶段(加载、验证、准备、解析、初始化)统称为类加载过程。
三、类加载过程的五个步骤详解
3.1 加载阶段:寻找类的旅程
加载阶段是类加载过程的起点,主要完成三件事情:
- 通过类的全限定名获取定义此类的二进制字节流
- 将这个字节流所代表的静态存储结构转换为方法区的运行时数据结构
- 在内存中生成一个代表这个类的
java.lang.Class
对象,作为方法区这个类的各种数据的访问入口
// 示例:不同的类加载方式
public class LoadingExample {
public static void main(String[] args) throws Exception {
// 通过类加载器加载
Class<?> clazz1 = ClassLoader.getSystemClassLoader().loadClass("java.lang.String");
// 通过Class.forName加载(默认会初始化)
Class<?> clazz2 = Class.forName("java.lang.String");
// 通过字面常量获取(不会触发初始化)
Class<?> clazz3 = String.class;
System.out.println("三种方式加载的类是否相同: " +
(clazz1 == clazz2 && clazz2 == clazz3));
}
}
3.2 验证阶段:安全的第一道防线
验证阶段确保 Class 文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息不会危害虚拟机自身的安全。
验证类型 | 验证内容 | 失败后果 |
---|---|---|
文件格式验证 | 魔数(0xCAFEBABE)、版本号、常量池 | ClassFormatError |
元数据验证 | 语义验证、继承关系(如是否实现抽象方法) | IncompatibleClassChangeError |
字节码验证 | 逻辑验证、跳转指令合法性 | VerifyError |
符号引用验证 | 引用真实性、访问权限(如访问private方法) | NoSuchFieldError、NoSuchMethodError |
3.3 准备阶段:零值初始化的奥秘
这是最容易产生误解的阶段! 在准备阶段,JVM 为**类变量(static变量)**分配内存并设置初始零值,注意这不是程序员定义的初始值。
public class PreparationExample {
// 准备阶段后 value = 0,而不是 100
public static int value = 100;
// 准备阶段后 constantValue = 200(因为有final修饰)
public static final int constantValue = 200;
// 实例变量 - 准备阶段完全不管
public int instanceValue = 300;
}
各种数据类型的零值对照表:
数据类型 | 零值 | 数据类型 | 零值 |
---|---|---|---|
int | 0 | boolean | false |
long | 0L | float | 0.0f |
double | 0.0 | char | '\u0000' |
引用类型 | null | short | (short)0 |
关键区别:只有**类变量(static变量)**在准备阶段分配内存和初始化零值,实例变量会在对象实例化时随对象一起分配在堆内存中。
3.4 解析阶段:符号引用到直接引用的转换
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。这个过程可以在初始化之后再进行,这是为了支持Java的动态绑定特性。
解析主要针对以下四类符号引用:
引用类型 | 解析目标 | 可能抛出的异常 |
---|---|---|
类/接口解析 | 将符号引用解析为具体类/接口 | NoClassDefFoundError |
字段解析 | 解析字段所属的类/接口 | NoSuchFieldError |
方法解析 | 解析方法所属的类/接口 | NoSuchMethodError |
接口方法解析 | 解析接口方法所属的接口 | AbstractMethodError |
3.5 初始化阶段:执行类构造器 <clinit>()
这是类加载过程的最后一步,也是真正开始执行类中定义的Java程序代码的一步。
JVM规范严格规定的六种初始化触发情况:
- 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时
// new指令 - 创建类的实例
Object obj = new Object();
// getstatic指令 - 读取类的静态字段
int value = MyClass.staticField;
// putstatic指令 - 设置类的静态字段
MyClass.staticField = 100;
// invokestatic指令 - 调用类的静态方法
MyClass.staticMethod();
- 使用java.lang.reflect包的方法对类进行反射调用时
// 反射调用会触发类的初始化
Class<?> clazz = Class.forName("com.example.MyClass");
- 当初始化一个类时,发现其父类还没有进行过初始化
class Parent {
static { System.out.println("Parent初始化"); }
}
class Child extends Parent {
static { System.out.println("Child初始化"); }
}
// 初始化Child时会先初始化Parent
- 虚拟机启动时,用户指定的主类(包含main()方法的那个类)
// 执行 java MyApp 时,MyApp类会被初始化
public class MyApp {
public static void main(String[] args) {
System.out.println("应用程序启动");
}
}
- 使用JDK7新加入的动态语言支持时
// 使用MethodHandle等动态语言特性
MethodHandles.Lookup lookup = MethodHandles.lookup();
- 一个接口中定义了JDK8新加入的默认方法时,如果这个接口的实现类发生了初始化,要先将接口进行初始化
interface MyInterface {
// JDK8默认方法会触发接口初始化
default void defaultMethod() {
System.out.println("默认方法");
}
}
3.6 使用阶段:类的使命实现
当类完成初始化后,就进入了使用阶段。这是类生命周期中最长的阶段,类的所有功能都可以正常使用:
public class UsageStageExample {
public static void main(String[] args) {
// 类已完成初始化,进入使用阶段
MyClass obj = new MyClass(); // 创建对象实例
obj.instanceMethod(); // 调用实例方法
MyClass.staticMethod(); // 调用静态方法
int value = MyClass.staticVar;// 访问静态变量
}
}
class MyClass {
public static int staticVar = 100;
public int instanceVar = 200;
public static void staticMethod() {
System.out.println("静态方法");
}
public void instanceMethod() {
System.out.println("实例方法");
}
}
在使用阶段,类可以:
- 创建对象实例
- 调用静态方法和实例方法
- 访问和修改静态字段和实例字段
- 被其他类引用和继承
3.7 卸载阶段:生命的终结
类的卸载是生命周期的最后阶段,但并不是必须发生的。一个类被卸载需要满足以下条件:
- 该类所有的实例都已被垃圾回收
- 加载该类的ClassLoader已被垃圾回收
- 该类对应的java.lang.Class对象没有被任何地方引用
public class UnloadingExample {
public static void main(String[] args) throws Exception {
// 使用自定义类加载器加载类
CustomClassLoader loader = new CustomClassLoader();
Class<?> clazz = loader.loadClass("com.example.TemporaryClass");
// 创建实例并使用
Object instance = clazz.newInstance();
System.out.println("类已加载并使用: " + clazz.getName());
// 解除所有引用,使类和类加载器可被回收
clazz = null;
instance = null;
loader = null;
// 触发GC,可能卸载类
System.gc();
System.out.println("类和类加载器可能已被卸载");
}
}
class CustomClassLoader extends ClassLoader {
// 自定义类加载器实现
}
所以,在 JVM 生命周期内,由 jvm 自带的类加载器加载的类是不会被卸载的。但是由我们自定义的类加载器加载的类是可能被卸载的。
四、关键辨析:类初始化 vs. 对象实例化
这是本文的核心观点,也是大多数开发者容易混淆的概念。让我们通过一个对比表格来清晰区分:
特性 | 类初始化 (Initialization) | 对象实例化 (Instantiation) |
---|---|---|
触发时机 | 类被首次"主动使用"时(JVM控制) | 遇到new关键字时(程序员控制) |
发生次数 | 一次(每个类加载器范围内) | 多次(可以创建多个对象实例) |
核心方法 | <clinit>() 方法 | <init>() 方法(构造函数) |
操作目标 | 类本身(初始化静态变量/类变量) | 对象实例(初始化实例变量) |
内存区域 | 方法区(元空间) | Java堆 |
执行内容 | 静态变量赋值、静态代码块 | 实例变量赋值、实例代码块、构造函数 |
public class InitializationVsInstantiation {
// 类变量 - 在<clinit>()方法中初始化
public static String staticField = initStaticField();
// 实例变量 - 在<init>()方法中初始化
public String instanceField = initInstanceField();
// 静态代码块 - 在<clinit>()方法中执行
static {
System.out.println("静态代码块执行");
}
// 实例代码块 - 在<init>()方法中执行
{
System.out.println("实例代码块执行");
}
public InitializationVsInstantiation() {
System.out.println("构造方法执行");
}
private static String initStaticField() {
System.out.println("静态变量初始化");
return "static value";
}
private String initInstanceField() {
System.out.println("实例变量初始化");
return "instance value";
}
public static void main(String[] args) {
System.out.println("=== 第一次创建对象 ===");
new InitializationVsInstantiation();
System.out.println("\n=== 第二次创建对象 ===");
new InitializationVsInstantiation();
}
}
输出结果:
静态变量初始化
静态代码块执行
=== 第一次创建对象 ===
实例变量初始化
实例代码块执行
构造方法执行
=== 第二次创建对象 ===
实例变量初始化
实例代码块执行
构造方法执行
五、深度实战:初始化顺序全面解析
现在,让我们通过一个综合示例来回答开篇的思考题:如果一个类同时包含静态变量、静态代码块、实例变量、实例代码块和构造方法,它们的执行顺序是怎样的?在存在继承关系时又会如何变化?
5.1 单类初始化顺序
public class InitializationOrder {
// 静态变量
public static String staticField = "静态变量";
// 静态代码块
static {
System.out.println(staticField);
System.out.println("静态代码块");
}
// 实例变量
public String field = "实例变量";
// 实例代码块
{
System.out.println(field);
System.out.println("实例代码块");
}
// 构造方法
public InitializationOrder() {
System.out.println("构造方法");
}
public static void main(String[] args) {
System.out.println("第一次实例化:");
new InitializationOrder();
System.out.println("\n第二次实例化:");
new InitializationOrder();
}
}
输出结果:
静态变量
静态代码块
第一次实例化:
实例变量
实例代码块
构造方法
第二次实例化:
实例变量
实例代码块
构造方法
关键发现:
- 静态代码块只在类第一次加载时执行一次
- 实例代码块在每次创建对象时都会执行
- 执行顺序:静态变量/代码块 → 实例变量/代码块 → 构造方法
5.2 继承关系下的初始化顺序
class Parent {
// 父类静态变量
public static String parentStaticField = "父类静态变量";
// 父类静态代码块
static {
System.out.println(parentStaticField);
System.out.println("父类静态代码块");
}
// 父类实例变量
public String parentField = "父类实例变量";
// 父类实例代码块
{
System.out.println(parentField);
System.out.println("父类实例代码块");
}
// 父类构造方法
public Parent() {
System.out.println("父类构造方法");
}
}
class Child extends Parent {
// 子类静态变量
public static String childStaticField = "子类静态变量";
// 子类静态代码块
static {
System.out.println(childStaticField);
System.out.println("子类静态代码块");
}
// 子类实例变量
public String childField = "子类实例变量";
// 子类实例代码块
{
System.out.println(childField);
System.out.println("子类实例代码块");
}
// 子类构造方法
public Child() {
System.out.println("子类构造方法");
}
public static void main(String[] args) {
System.out.println("第一次实例化子类:");
new Child();
System.out.println("\n第二次实例化子类:");
new Child();
}
}
输出结果:
父类静态变量
父类静态代码块
子类静态变量
子类静态代码块
第一次实例化子类:
父类实例变量
父类实例代码块
父类构造方法
子类实例变量
子类实例代码块
子类构造方法
第二次实例化子类:
父类实例变量
父类实例代码块
父类构造方法
子类实例变量
子类实例代码块
子类构造方法
关键发现:
- 父类静态代码块 → 子类静态代码块 → 父类实例代码块 → 父类构造方法 → 子类实例代码块 → 子类构造方法
- 静态代码块只执行一次,实例代码块每次创建对象都执行
- 父类优先于子类初始化
5.3 进阶案例:包含静态变量初始化的复杂情况
public class ComplexInitialization {
public static ComplexInitialization instance = new ComplexInitialization();
public static int staticVar = 100;
public int instanceVar = 200;
static {
System.out.println("静态代码块: staticVar=" + staticVar);
}
{
System.out.println("实例代码块: instanceVar=" + instanceVar + ", staticVar=" + staticVar);
}
public ComplexInitialization() {
System.out.println("构造方法: instanceVar=" + instanceVar + ", staticVar=" + staticVar);
}
public static void main(String[] args) {
System.out.println("main方法开始");
new ComplexInitialization();
}
}
输出结果:
实例代码块: instanceVar=200, staticVar=0
构造方法: instanceVar=200, staticVar=0
静态代码块: staticVar=100
main方法开始
实例代码块: instanceVar=200, staticVar=100
构造方法: instanceVar=200, staticVar=100
关键发现:
- 静态变量
staticVar
在准备阶段被初始化为0 - 在初始化阶段,按顺序执行静态变量赋值和静态代码块
- 当执行
instance = new ComplexInitialization()
时,staticVar
还未被赋值为100(还是0) - 这解释了为什么第一次输出时
staticVar=0
六、面试常见问题与解答
6.1 高频面试题解析
Q1: 下面代码的输出结果是什么?为什么?
public class InterviewQuestion {
public static void main(String[] args) {
System.out.println(Child.value);
}
}
class Parent {
static int value = 100;
static { System.out.println("Parent静态代码块"); }
}
class Child extends Parent {
static { System.out.println("Child静态代码块"); }
}
A: 输出结果为:
Parent静态代码块
100
解析: 通过子类引用父类的静态字段,不会导致子类初始化,这是类加载机制的一个重要特性。
Q2: 接口的初始化与类有什么不同?
A: 接口的初始化与类类似,但有重要区别:
- 接口也有
<clinit>()
方法,由编译器自动生成 - 接口初始化时不需要先初始化父接口
- 只有当程序首次使用接口中定义的非常量字段时,才会初始化接口
6.2 类加载机制的实际应用
1. 单例模式的优雅实现:
public class Singleton {
private Singleton() {}
private static class SingletonHolder {
static {
System.out.println("SingletonHolder初始化");
}
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return SingletonHolder.INSTANCE;
}
}
这种实现利用了类加载机制的特性:只有在真正调用 getInstance()
时才会加载 SingletonHolder
类,实现了懒加载且线程安全。
2. 常量传播优化:
public class ConstantExample {
public static final String CONSTANT = "Hello";
public static void main(String[] args) {
System.out.println(CONSTANT);
}
}
编译时,常量 CONSTANT
的值会被直接内联到使用处,不会触发类的初始化。
七、总结与思考
通过本文的深入分析,我们可以总结出以下几个关键点:
- 类加载过程五个阶段:加载 → 验证 → 准备 → 解析 → 初始化,每个阶段都有其特定任务
- 关键区别:
- 初始化阶段是初始化类(执行
<clinit>()
),而不是初始化对象(执行<init>()
) - 类静态变量在准备阶段分配内存并设置零值,在初始化阶段赋实际值
- 实例变量在对象实例化时分配内存和初始化
- 初始化阶段是初始化类(执行
- 初始化顺序原则:
- 父类优先于子类
- 静态优先于实例
- 变量定义顺序决定初始化顺序
- 实际应用:理解类加载机制有助于我们编写更高效的代码,如实现懒加载的单例模式、理解常量内联优化等
希望本文能帮助你深入理解JVM类加载机制,下次遇到相关面试题时,相信你一定能游刃有余!
来源:juejin.cn/post/7541339617489797163
计算初始化内存总长度
计算初始化内存总长度
问题背景
在一个系统中,需要执行一系列的内存初始化操作。每次操作都会初始化一个特定地址范围的内存。这些操作范围可能会相互重叠。我们需要计算所有操作完成后,被初始化过的内存空间的总长度。
核心定义
- 操作范围: 每一次内存初始化操作由一个范围
[start, end]
定义,它代表一个左闭右开的区间[start, end)
。这意味着地址start
被包含,而地址end
不被包含。 - 内存长度: 对于一个操作
[start, end]
,其初始化的内存长度为end - start
。
关键假设
- 所有初始化操作都会成功执行。
- 同一块内存区域允许被重复初始化。例如,操作
[2, 5)
和[4, 7)
是允许的,它们有重叠部分[4, 5)
。
任务要求
给定一组内存初始化操作 cmdsOfMemInit
,计算所有操作完成后,被初始化过的内存空间的总长度。这等同于计算所有给定区间的并集的总长度。
输入格式
cmdsOfMemInit
: 一个二维数组(或列表的列表),代表一系列的内存初始化操作。
- 数组长度:
1 <= cmdsOfMemInit.length <= 100000
- 每个元素
cmdsOfMemInit[i]
是一个包含两个整数[start, end]
的数组。 - 区间范围:
0 <= start < end <= 10^9
- 数组长度:
输出格式
- 一个整数,表示最终被初始化过的内存空间的总长度。
样例说明
样例 1
- 输入:
[[2, 4], [3, 7], [4, 6]]
- 输出:
5
- 解释:
- 我们有三个区间:
[2, 4)
,[3, 7)
,[4, 6)
。 - 合并
[2, 4)
和[3, 7)
: 因为它们有重叠部分([3, 4)
),所以可以合并成一个更大的区间[2, 7)
。 - 合并
[2, 7)
和[4, 6)
: 新的区间[4, 6)
完全被[2, 7)
覆盖。合并后的结果仍然是[2, 7)
。 - 所有操作完成后,最终被初始化的内存区域是
[2, 7)
。 - 总长度为
7 - 2 = 5
。
- 我们有三个区间:
样例 2
- 输入:
[[3, 7], [2, 4], [10, 30]]
- 输出:
25
- 解释:
- 我们有三个区间:
[3, 7)
,[2, 4)
,[10, 30)
。 - 合并
[3, 7)
和[2, 4)
: 它们有重叠部分,合并后的区间为[2, 7)
。 - 合并
[2, 7)
和[10, 30)
: 这两个区间没有重叠,因为10
大于7
。它们是两个独立的初始化区域。 - 所有操作完成后,最终的初始化内存区域由两个不相交的区间组成:
[2, 7)
和[10, 30)
。 - 总长度是这两个独立区间长度之和:
(7 - 2) + (30 - 10) = 5 + 20 = 25
。
- 我们有三个区间:
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
/**
* 解决“内存空间长度”问题的方案类。
*/
public class Solution {
/**
* 计算一系列内存初始化操作覆盖的总内存空间长度。
*
* @param cmdsOfMemInit 一个二维数组,每个内部数组 [start, end] 代表一个左闭右开的内存初始化区间。
* @return 最终初始化的内存空间的总长度。
*/
public long totalInitializedLength(int[][] cmdsOfMemInit) {
// --- 1. 处理边界情况 ---
// 如果输入为空或没有操作,则总长度为 0。
if (cmdsOfMemInit == null || cmdsOfMemInit.length == 0) {
return 0;
}
// --- 2. 按区间的起始地址(start)对所有操作进行升序排序 ---
// 这是合并区间的关键前提步骤。
// Comparator.comparingInt(a -> a[0]) 是一个简洁的写法,表示按内部数组a的第一个元素排序。
Arrays.sort(cmdsOfMemInit, Comparator.comparingInt(a -> a[0]));
// --- 3. 合并重叠和连续的区间 ---
// 使用一个 List 来存储合并后的、不重叠的区间。
List<int[]> mergedIntervals = new ArrayList<>();
// 首先将第一个区间(起始地址最小)加入合并列表作为基础。
mergedIntervals.add(cmdsOfMemInit[0]);
// 遍历排序后的其余区间
for (int i = 1; i < cmdsOfMemInit.length; i++) {
int[] currentInterval = cmdsOfMemInit[i];
// 获取合并列表中的最后一个区间,用于比较
int[] lastMerged = mergedIntervals.get(mergedIntervals.size() - 1);
// 检查当前区间是否与最后一个合并区间重叠或连续。
// 因为区间是 [start, end) 左闭右开,所以当 currentInterval 的 start <= lastMerged 的 end 时,
// 它们就需要合并。例如 [2,4) 和 [4,6) 应该合并为 [2,6)。
if (currentInterval[0] <= lastMerged[1]) {
// --- 合并区间 ---
// 如果有重叠/连续,则更新最后一个合并区间的结束地址。
// 新的结束地址是两个区间结束地址中的较大者。
// 例如,合并 [2,7) 和 [4,6) 时,新的 end 是 max(7, 6) = 7,结果为 [2,7)。
lastMerged[1] = Math.max(lastMerged[1], currentInterval[1]);
} else {
// --- 不重叠,添加新区间 ---
// 如果没有重叠,则将当前区间作为一个新的、独立的合并区间添加到列表中。
mergedIntervals.add(currentInterval);
}
}
// --- 4. 计算合并后区间的总长度 ---
// 使用 long 类型来存储总长度,防止因数值过大(坐标可达10^9)而溢出。
long totalLength = 0;
// 遍历所有不重叠的合并区间
for (int[] interval : mergedIntervals) {
// 累加每个区间的长度 (end - start)
totalLength += (long) interval[1] - interval[0];
}
// --- 5. 返回结果 ---
return totalLength;
}
public static void main(String[] args) {
Solution sol = new Solution();
// 样例1
int[][] cmds1 = {{2, 4}, {3, 7}, {4, 6}};
System.out.println("样例1 输入: [[2, 4], [3, 7], [4, 6]]");
System.out.println("样例1 输出: " + sol.totalInitializedLength(cmds1)); // 预期: 5
// 样例2
int[][] cmds2 = {{3, 7}, {2, 4}, {10, 30}};
System.out.println("\n样例2 输入: [[3, 7], [2, 4], [10, 30]]");
System.out.println("样例2 输出: " + sol.totalInitializedLength(cmds2)); // 预期: 25
// 边界测试
int[][] cmds3 = {{1, 5}, {6, 10}};
System.out.println("\n边界测试 输入: [[1, 5], [6, 10]]");
System.out.println("边界测试 输出: " + sol.totalInitializedLength(cmds3)); // 预期: 8 (4+4)
}
*/
}
来源:juejin.cn/post/7527154276223336488
JMeter 多台压力机分布式测试(Windows)
JMeter 多台压力机分布式测试(Windows)
1. 背景
- 在单台压力机运行时,出现了端口冲突问题,如
JMeter port already in use
。 - 压力机机器权限限制,无法修改默认端口配置。
- 为避免端口冲突且提升压力机的压力能力,考虑使用多台机器(多台JMeter压力机)分布式压测。
2.环境说明
- Master IP:
192.20.10.7
- Slave1 IP:
192.20.10.8
- Slave2 IP:
192.20.10.9
- JMeter版本均为 5.5
- Java版本均为 1.8+
- 网络可互通,防火墙端口放通
- RMI 注册端口:1099
- RMI 远程对象端口:50000(默认,可配置)
3. Master 节点配置
3.1 修改 jmeter.properties
(JMETER_HOME/bin/jmeter.properties
)
properties复制# 远程主机列表,逗号分隔
remote_hosts=192.20.10.8,192.20.10.9
# 禁用RMI SSL,避免额外复杂度
server.rmi.ssl.disable=true
# Master的回调地址,设置为本机可达IP(用于Slave回调)
client.rmi.localhostname=192.20.10.7
# 关闭插件联网上报,提升启动速度
jmeter.pluginmanager.report_stats=false
2.2 启动 JMeter GUI
- 直接运行
jmeter.bat
打开GUI - 加载测试脚本(
*.jmx
) - 确认脚本和依赖文件已同步到所有Slave节点同路径
3. Slave 节点配置(192.20.10.8 和 192.20.10.9)
3.1 修改各自的 jmeter.properties
(JMETER_HOME/bin/jmeter.properties
)
Slave1(192.20.10.8):
# 远程RMI服务监听端口
server_port=1099
# RMI通信本地端口(避免冲突,Slave1用50000)
server.rmi.localport=50000
# 禁用RMI SSL
server.rmi.ssl.disable=true
# 远程机器回调绑定IP(本机IP)
java.rmi.server.hostname=192.20.10.8
# 关闭插件联网上报
jmeter.pluginmanager.report_stats=false
Slave2(192.20.10.9):
server_port=1099
server.rmi.localport=50001
server.rmi.ssl.disable=true
java.rmi.server.hostname=192.20.10.9
jmeter.pluginmanager.report_stats=false
3.2 启动Slave服务
在每台Slave机器的 bin
目录,执行:
set JVM_ARGS=-Djava.rmi.server.hostname=192.20.10.8 #可选配置
jmeter-server.bat
(Slave2替换IP为 192.20.10.9
)
看到类似 Using local port: 50002 Created remote object: UnicastServerRef2 [liveRef:XXXX
表示启动成功。
如启动异常,可以打开jmeter-server.log
查看日志。
3.2 验证监听端口
netstat -an | findstr 1099
TCP 0.0.0.0:1099 0.0.0.0:0 LISTENING
TCP [::]:1099 [::]:0 LISTENING
netstat -an | findstr 50002
TCP 0.0.0.0:50002 0.0.0.0:0 LISTENING
TCP [::]:50002 [::]:0 LISTENING
4. 测试执行流程
- 确保Master和所有Slave机器的JMeter和Java版本一致。
- 确保测试脚本和依赖文件(如CSV等)在所有Slave路径相同。
- 启动所有Slave的
jmeter-server.bat
,确保端口监听正常。 - 在Master GUI加载脚本。
- 点击菜单
Run -> Remote Start 或 Remote Start All
,Master向所有Slave发起远程测试。 11.JMeter GUI的日志样例如下:
INFO o.a.j.e.ClientJMeterEngine: Sending properties {}
o.a.j.e.ClientJMeterEngine: sent run command to
o.a.j.e.DistributedRunner: Remote engines have been started
o.a.j.g.u.JMeterMenuBar: setRunning
o.a.j.g.u.JMeterMenuBar: setRunning
jmeter-server.bat的日志如下,出现如下字段
Starting the test on host ...
Finished the test on host ...
- Master GUI的 Listener(Summary Report等)会显示所有Slave合并的结果。
5. 其他注意事项
- 防火墙放通Master和Slave的
server_port
(1099)、server.rmi.localport
端口。 - Master
client.rmi.localhostname
必须是Slave可访问的Master IP。 - 禁用插件联网统计,避免卡顿。
- 避免使用重型监听器,使用轻量级监听器提升性能。
来源:juejin.cn/post/7530141922499149862
Spring高级开发:状态机/事件/插件
Spring 状态机与事件机制插件开发示例
我将为您演示一个完整的 Spring 状态机实现,包含状态机配置、事件机制和可扩展的插件体系。以下是一个订单处理系统的示例:
1. 添加依赖(pom.xml)
<dependencies>
<!-- Spring State Machine -->
<dependency>
<groupId>org.springframework.statemachine</groupId>
<artifactId>spring-statemachine-starter</artifactId>
<version>4.2.0</version>
</dependency>
<!-- Spring Event 相关 -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context-support</artifactId>
<version>6.0.9</version>
</dependency>
</dependencies>
2. 定义核心接口和类
2.1 状态和事件枚举
// 状态枚举
public enum OrderState {
NEW,
PAYMENT_PENDING,
PAID,
SHIPPED,
DELIVERED,
CANCELLED
}
// 事件枚举
public enum OrderEvent {
CREATE_ORDER,
INITIATE_PAYMENT,
PAYMENT_COMPLETED,
SHIP_ORDER,
DELIVERY_CONFIRMED,
CANCEL_ORDER
}
2.2 插件接口定义
// 状态变更监听器接口
public interface StateChangeListener {
void onStateChange(OrderState from, OrderState to, String orderId);
}
// 订单操作插件接口
public interface OrderOperationPlugin {
void execute(String orderId, OrderState currentState);
}
// 插件管理器接口
public interface PluginManager {
void registerStateChangeListener(StateChangeListener listener);
void notifyStateChange(OrderState from, OrderState to, String orderId);
void executePlugins(String orderId, OrderState currentState);
}
3. 实现状态机配置
@Configuration
@EnableStateMachine
public class OrderStateMachineConfig extends EnumStateMachineConfigurerAdapter<OrderState, OrderEvent> {
@Autowired
private List<OrderOperationPlugin> plugins;
@Override
public void configure(StateMachineStateConfigurer<OrderState, OrderEvent> states) throws Exception {
states
.withStates()
.initial(OrderState.NEW)
.state(OrderState.PAYMENT_PENDING)
.junction(OrderState.PAID)
.end(OrderState.DELIVERED)
.end(OrderState.CANCELLED)
.fork(OrderState.SHIPPED);
}
@Override
public void configure(StateMachineTransitionConfigurer<OrderState, OrderEvent> transitions) throws Exception {
transitions
.withExternal()
.source(OrderState.NEW).target(OrderState.PAYMENT_PENDING)
.event(OrderEvent.CREATE_ORDER)
.and().withExternal()
.source(OrderState.PAYMENT_PENDING).target(OrderState.PAID)
.event(OrderEvent.INITIATE_PAYMENT)
.and().withExternal()
.source(OrderState.PAID).target(OrderState.SHIPPED)
.event(OrderEvent.SHIP_ORDER)
.and().withExternal()
.source(OrderState.SHIPPED).target(OrderState.DELIVERED)
.event(OrderEvent.DELIVERY_CONFIRMED)
.and().withExternal()
.source(OrderState.NEW).target(OrderState.CANCELLED)
.event(OrderEvent.CANCEL_ORDER)
.and().withExternal()
.source(OrderState.PAYMENT_PENDING).target(OrderState.CANCELLED)
.event(OrderEvent.CANCEL_ORDER);
}
@Bean
public PluginManager pluginManager() {
return new DefaultPluginManager(plugins);
}
}
4. 插件实现
4.1 默认插件管理器
@Component
public class DefaultPluginManager implements PluginManager {
private final List<StateChangeListener> stateChangeListeners = new ArrayList<>();
private final List<OrderOperationPlugin> operationPlugins;
public DefaultPluginManager(List<OrderOperationPlugin> plugins) {
this.operationPlugins = plugins;
}
@Override
public void registerStateChangeListener(StateChangeListener listener) {
stateChangeListeners.add(listener);
}
@Override
public void notifyStateChange(OrderState from, OrderState to, String orderId) {
stateChangeListeners.forEach(listener -> listener.onStateChange(from, to, orderId));
}
@Override
public void executePlugins(String orderId, OrderState currentState) {
operationPlugins.forEach(plugin -> plugin.execute(orderId, currentState));
}
}
4.2 示例插件实现
// 日志记录插件
@Component
public class LoggingPlugin implements OrderOperationPlugin {
@Override
public void execute(String orderId, OrderState currentState) {
System.out.println("订单 " + orderId + " 当前状态: " + currentState + " - 正在记录日志");
}
}
// 邮件通知插件
@Component
public class EmailNotificationPlugin implements OrderOperationPlugin {
@Override
public void execute(String orderId, OrderState currentState) {
System.out.println("订单 " + orderId + " 当前状态: " + currentState + " - 发送邮件通知");
}
}
// 库存管理插件
@Component
public class InventoryUpdatePlugin implements OrderOperationPlugin {
@Override
public void execute(String orderId, OrderState currentState) {
if (currentState == OrderState.PAID) {
System.out.println("订单 " + orderId + " 已支付,正在更新库存");
}
}
}
5. 状态监听器实现
@Component
public class AuditStateChangeListener implements StateChangeListener {
@Override
public void onStateChange(OrderState from, OrderState to, String orderId) {
System.out.println("订单状态变更: " + orderId + " 从 " + from + " 到 " + to + " - 审计记录已创建");
}
}
6. 服务层实现
@Service
public class OrderService {
@Autowired
private StateMachine<OrderState, OrderEvent> stateMachine;
@Autowired
private PluginManager pluginManager;
public OrderService() {
// 注册监听器
pluginManager.registerStateChangeListener(new AuditStateChangeListener());
}
public void handleOrderEvent(String orderId, OrderEvent event) {
try {
stateMachine.start();
OrderState currentState = stateMachine.getState().getId();
// 发送事件
stateMachine.sendEvent(event);
OrderState newState = stateMachine.getState().getId();
// 触发状态变更监听器
if (currentState != newState) {
pluginManager.notifyStateChange(currentState, newState, orderId);
}
// 执行操作插件
pluginManager.executePlugins(orderId, newState);
} catch (Exception e) {
System.err.println("处理订单事件失败: " + e.getMessage());
} finally {
stateMachine.stop();
}
}
}
7. 控制器示例
@RestController
@RequestMapping("/orders")
public class OrderController {
@Autowired
private OrderService orderService;
@PostMapping("/{orderId}/events")
public ResponseEntity<String> sendEvent(@PathVariable String orderId, @RequestParam String event) {
try {
OrderEvent orderEvent = OrderEvent.valueOf(event.toUpperCase());
orderService.handleOrderEvent(orderId, orderEvent);
return ResponseEntity.ok("事件已处理: " + event);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body("无效的事件类型: " + event);
}
}
@GetMapping("/{orderId}/status")
public ResponseEntity<String> checkStatus(@PathVariable String orderId) {
// 这里应该从存储中获取当前状态,为简化示例返回固定值
return ResponseEntity.ok("订单 " + orderId + " 当前状态: 示例状态");
}
}
8. 可扩展性说明
如何添加新插件:
@Component
public class NewFeaturePlugin implements OrderOperationPlugin {
@Override
public void execute(String orderId, OrderState currentState) {
// 新功能逻辑
}
}
如何添加新状态监听器:
@Component
public class NewStateChangeListener implements StateChangeListener {
@Override
public void onStateChange(OrderState from, OrderState to, String orderId) {
// 新监听器逻辑
}
}
使用示例:
# 创建订单
POST /orders/123/events?event=CREATE_ORDER
# 发起支付
POST /orders/123/events?event=INITIATE_PAYMENT
# 发货
POST /orders/123/events?event=SHIP_ORDER
# 确认送达
POST /orders/123/events?event=DELIVERY_CONFIRMED
# 取消订单
POST /orders/123/events?event=CANCEL_ORDER
这个实现具有以下特点:
- 灵活的状态机配置:使用 Spring StateMachine 配置订单状态流转
- 可扩展的插件系统:通过接口设计支持轻松添加新插件
- 事件驱动架构:利用状态变更事件触发相关业务逻辑
- 良好的分离关注点:核心状态机逻辑与业务插件解耦
- 易于维护和测试:各组件之间通过接口通信,便于单元测试和替换实现
您可以根据具体业务需求扩展更多状态、事件和插件功能。
来源:juejin.cn/post/7512237186647916571
docker容器增加或者修改容器映射端口
前言
在只有使用docker
安装的容器,没有使用docker-compose
或者其他客户端工具,如果要增加或者修改容器端口,也是可以增加或者修改容器端口映射=
容器端口映射
重新安装
这种方法简单粗暴,就是重新把docker容器移除,然后重新用
docker run -p
重新做端口映射
修改配置文件
这里以rabbitmq
为例子
1、 首先使用
docker ps
查看容器id
2、 然后使用
docker inspace 容器id
查看容器配置文件放止于哪里
这里放置于/var/lib/docker/containers/29384a9aa22f4fb53eda66d672b039b997143dc7633694e3455fc12f7dbcac5d
然后使用Linux进入到该目录
3、先把docker容器停止了
systemctl stop docker.socket
4、 修改hostconfig
文件,找到里面的json
数据中的PortBindings
这里将5672
端口修改为5673
保存文件
5、 修改config.v2.json
文件中的内容,找到里面中的ExposedPorts
,把5673
端口开放出来
保存文件
6、 启动docker服务
systemctl start docker.socket
这个时候就会发现5673
端口映射了
总结
修改docker容器映射开放端口方法很多,现在也有很多优秀的客户端可以进行配置
来源:juejin.cn/post/7456094963018006528
.NET 高级开发:反射与代码生成的实战秘籍
在当今快速发展的软件开发领域,灵活性和动态性是开发者不可或缺的能力。.NET 提供的反射机制和代码生成技术,为开发者提供了强大的工具,能够在运行时动态地探索和操作代码。这些技术不仅能够提升开发效率,还能实现一些传统静态代码无法完成的功能。本文将深入探讨 .NET 反射机制的核心功能、高级技巧以及代码生成的实际应用,帮助你在开发中更好地利用这些强大的工具。
.NET 反射:运行时的魔法
反射是 .NET 中一个极其强大的特性,它允许开发者在运行时动态地检查和操作类型信息。通过反射,你可以获取类型信息、动态创建对象、调用方法,甚至访问私有成员。这种能力在许多场景中都非常有用,比如实现插件系统、动态调用方法、序列化和反序列化等。
反射基础
反射的核心是 System.Type
类,它代表了一个类型的元数据。通过 Type
类,你可以获取类的名称、基类、实现的接口、方法、属性等信息。System.Reflection
命名空间提供了多个关键类,如 Assembly
、MethodInfo
、PropertyInfo
和 FieldInfo
,帮助你更深入地探索类型信息。
获取 Type
对象有三种常见方式:
- 使用
typeof
运算符:适用于编译时已知的类型。
Type type = typeof(string);
Console.WriteLine(type.Name); // 输出:String
- 调用
GetType()
方法:适用于运行时已知的对象。
string name = "Hello";
Type type = name.GetType();
Console.WriteLine(type.Name); // 输出:String
- 通过类型名称动态加载:适用于运行时动态加载类型。
Type? type = Type.GetType("System.String");
if (type != null) {
Console.WriteLine(type.Name); // 输出:String
}
反射的常见操作
反射可以完成许多强大的操作,以下是一些常见的用法:
获取类型信息
通过 Type
对象,你可以获取类的各种信息,例如类名、基类、是否泛型等。
Type type = typeof(List<int>);
Console.WriteLine($"类名: {type.Name}"); // 输出:List`1
Console.WriteLine($"基类: {type.BaseType?.Name}"); // 输出:Object
Console.WriteLine($"是否泛型: {type.IsGenericType}"); // 输出:True
动态调用方法
假设你有一个类 Calculator
,你可以通过反射动态调用它的方法。
public class Calculator
{
public int Add(int a, int b) => a + b;
}
Calculator calc = new Calculator();
Type type = calc.GetType();
MethodInfo? method = type.GetMethod("Add");
if (method != null) {
int result = (int)method.Invoke(calc, new object[] { 5, 3 })!;
Console.WriteLine(result); // 输出:8
}
访问私有成员
反射可以绕过访问修饰符的限制,访问私有字段或方法。
public class SecretHolder
{
private string _secret = "Hidden Data";
}
var holder = new SecretHolder();
Type type = holder.GetType();
FieldInfo? field = type.GetField("_secret", BindingFlags.NonPublic | BindingFlags.Instance);
if (field != null) {
string secret = (string)field.GetValue(holder)!;
Console.WriteLine(secret); // 输出:Hidden Data
}
动态创建对象
通过 Activator.CreateInstance
方法,你可以动态实例化对象。
Type type = typeof(StringBuilder);
object? instance = Activator.CreateInstance(type);
StringBuilder sb = (StringBuilder)instance!;
sb.Append("Hello");
Console.WriteLine(sb.ToString()); // 输出:Hello
高级反射技巧
反射的高级用法可以让你在开发中更加灵活,以下是一些进阶技巧:
调用泛型方法
如果方法带有泛型参数,你需要先使用 MakeGenericMethod
指定类型。
public class GenericHelper
{
public T Echo<T>(T value) => value;
}
var helper = new GenericHelper();
Type type = helper.GetType();
MethodInfo method = type.GetMethod("Echo")!;
MethodInfo genericMethod = method.MakeGenericMethod(typeof(string));
string result = (string)genericMethod.Invoke(helper, new object[] { "Hello" })!;
Console.WriteLine(result); // 输出:Hello
性能优化
反射调用比直接调用慢很多,因此在高性能场景下,可以缓存 MethodInfo
或使用 Delegate
来优化性能。
MethodInfo method = typeof(Calculator).GetMethod("Add")!;
var addDelegate = (Func<Calculator, int, int, int>)Delegate.CreateDelegate(
typeof(Func<Calculator, int, int, int>),
method
);
Calculator calc = new Calculator();
int result = addDelegate(calc, 5, 3);
Console.WriteLine($"result: {result}"); // 输出:8
动态加载插件
假设你有一个插件系统,所有插件都实现了 IPlugin
接口,你可以通过反射动态加载插件。
public interface IPlugin
{
void Execute();
}
public class HelloPlugin : IPlugin
{
public void Execute() => Console.WriteLine("Hello from Plugin!");
}
Assembly assembly = Assembly.LoadFrom("MyPlugins.dll");
var pluginTypes = assembly.GetTypes()
.Where(t => typeof(IPlugin).IsAssignableFrom(t) && !t.IsAbstract);
foreach (Type type in pluginTypes)
{
IPlugin plugin = (IPlugin)Activator.CreateInstance(type);
plugin.Execute();
}
代码生成:运行时的创造力
在某些高级场景中,你可能需要在运行时生成新的类型或方法。.NET 提供的 System.Reflection.Emit
命名空间允许你在运行时构建程序集、模块、类型和方法。
使用 Reflection.Emit
生成动态类
以下是一个示例,展示如何使用 Reflection.Emit
生成一个动态类 Person
,并为其添加一个 SayHello
方法。
using System;
using System.Reflection;
using System.Reflection.Emit;
public class DynamicTypeDemo
{
public static void Main()
{
// 创建一个动态程序集
AssemblyName assemblyName = new AssemblyName("DynamicAssembly");
AssemblyBuilder assemblyBuilder =
AssemblyBuilder.DefineDynamicAssembly(assemblyName, AssemblyBuilderAccess.Run);
// 创建一个模块
ModuleBuilder moduleBuilder = assemblyBuilder.DefineDynamicModule("MainModule");
// 定义一个类:public class Person
TypeBuilder typeBuilder = moduleBuilder.DefineType(
"Person",
TypeAttributes.Public
);
// 定义一个方法:public void SayHello()
MethodBuilder methodBuilder = typeBuilder.DefineMethod(
"SayHello",
MethodAttributes.Public,
returnType: typeof(void),
parameterTypes: Type.EmptyTypes
);
// 生成 IL 代码,等价于 Console.WriteLine("Hello from dynamic type!");
ILGenerator il = methodBuilder.GetILGenerator();
il.Emit(OpCodes.Ldstr, "Hello from dynamic type!");
il.Emit(OpCodes.Call, typeof(Console).GetMethod("WriteLine", new[] { typeof(string) })!);
il.Emit(OpCodes.Ret);
// 创建类型
Type personType = typeBuilder.CreateType();
// 实例化并调用方法
object personInstance = Activator.CreateInstance(personType)!;
personType.GetMethod("SayHello")!.Invoke(personInstance, null);
}
}
运行上述代码后,你将看到输出:
Hello from dynamic type!
表达式树:更安全的代码生成
如果你希望在运行时生成代码行为,但又不想深入 IL 层,表达式树(System.Linq.Expressions
)是一个更现代、更安全的替代方案。以下是一个示例,展示如何使用表达式树生成一个简单的 SayHello
方法。
using System;
using System.Linq.Expressions;
public class ExpressionTreeDemo
{
public static void Main()
{
// 表达式:() => Console.WriteLine("Hello from expression tree!")
var writeLineMethod = typeof(Console).GetMethod("WriteLine", new[] { typeof(string) });
// 构建常量表达式 "Hello from expression tree!"
var messageExpr = Expression.Constant("Hello from expression tree!");
// 调用 Console.WriteLine(string) 的表达式
var callExpr = Expression.Call(writeLineMethod!, messageExpr);
// 构建 lambda 表达式:() => Console.WriteLine(...)
var lambda = Expression.Lambda<Action>(callExpr);
// 编译成委托并执行
Action sayHello = lambda.Compile();
sayHello();
}
}
运行上述代码后,你将看到输出:
Hello from expression tree!
Source Generator:编译期代码生成
Source Generator 是 .NET 提供的一种编译期代码生成工具,可以在编译过程中注入额外的源代码。它不依赖反射,无运行时开销,适合构建高性能、可维护的自动化代码逻辑。
以下是一个简单的 Source Generator 示例,展示如何为类自动生成一个 SayHello
方法。
- 创建标记用的 Attribute
// HelloGenerator.Attributes.csproj
namespace HelloGenerator
{
[System.AttributeUsage(System.AttributeTargets.Class)]
public class GenerateHelloAttribute : System.Attribute { }
}
- 创建 Source Generator
// HelloGenerator.Source/HelloMethodGenerator.cs
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
using System.Text;
[Generator]
public class HelloMethodGenerator : ISourceGenerator
{
public void Initialize(GeneratorInitializationContext context)
{
// 注册一个语法接收器,用于筛选出标记了 [GenerateHello] 的类
context.RegisterForSyntaxNotifications(() => new SyntaxReceiver());
}
public void Execute(GeneratorExecutionContext context)
{
if (context.SyntaxReceiver is not SyntaxReceiver receiver)
return;
// 遍历所有被标记的类,生成 SayHello 方法
foreach (var classDecl in receiver.CandidateClasses)
{
var model = context.Compilation.GetSemanticModel(classDecl.SyntaxTree);
var symbol = model.GetDeclaredSymbol(classDecl) as INamedTypeSymbol;
if (symbol is null) continue;
string className = symbol.Name;
string namespaceName = symbol.ContainingNamespace.ToDisplayString();
string source = $@"
namespace {namespaceName}
{{
public partial class {className}
{{
public void SayHello()
{{
System.Console.WriteLine(""Hello from Source Generator!"");
}}
}}
}}";
context.AddSource($"{className}_Hello.g.cs", SourceText.From(source, Encoding.UTF8));
}
}
// 语法接收器
class SyntaxReceiver : ISyntaxReceiver
{
public List<ClassDeclarationSyntax> CandidateClasses { get; } = new();
public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
{
if (syntaxNode is ClassDeclarationSyntax classDecl &&
classDecl.AttributeLists.Count > 0)
{
CandidateClasses.Add(classDecl);
}
}
}
}
- 在主项目中使用 Source Generator
using HelloGenerator;
namespace MyApp
{
[GenerateHello]
public partial class Greeter { }
class Program
{
static void Main()
{
var g = new Greeter();
g.SayHello(); // 自动生成的方法
}
}
}
运行上述代码后,你将看到输出:
Hello from Source Generator!
总结
反射和代码生成是 .NET 中非常强大的特性,它们为开发者提供了运行时动态探索和操作代码的能力。反射机制允许你在运行时检查类型信息、动态创建对象、调用方法,甚至访问私有成员。代码生成技术则让你能够在运行时生成新的类型和方法,或者在编译期生成代码,从而提升开发效率和代码的灵活性。
在实际开发中,反射虽然功能强大,但需要注意性能开销。在需要高性能的场景下,可以考虑使用 Delegate
缓存、表达式树,或 .NET 6 的 Source Generators 来替代反射。通过合理使用这些技术,你可以在开发中更加灵活地应对各种复杂场景,提升代码的可维护性和性能。
希望这篇文章能帮助你更好地理解和应用 .NET 反射和代码生成技术,让你在开发中更加得心应手!
来源:juejin.cn/post/7527559658276323379
深入理解 Java 中的信号机制
观察者模式的困境
在Java中实现观察者模式通常需要手动管理监听器注册、事件分发等逻辑,这会带来以下问题:
- 代码侵入性高:需要修改被观察对象的代码(如添加
addListener()
方法) - 紧耦合:监听器与被观察对象高度耦合,难以复用
- 类型安全缺失:传统
Observable
只能传递Object
类型参数,需强制类型转换 - 事件解耦困难:难以区分触发事件的具体属性变化
下面,我们用一个待办事项的例子说明这个问题。同时利用信号机制的方法改写传统方式,进行对比。
示例:待办事项应用
我们以经典的待办事项应用为例,需要监听以下事件:
- 当单个Todo项发生以下变化时:
- 标题变更
- 完成状态切换
- 当TodoList发生以下变化时:
- 新增条目
- 删除条目
传统实现方案
1. 基础监听器模式
// 监听器接口
public interface Listener {
void onTitleChanged(Todo todo);
void onCompletionChanged(Todo todo);
void onItemAdded(Todo entity, Collection<Todo> todos);
void onItemRemoved(Todo entity, Collection<Todo> todos);
}
// 具体实现
public class ConsoleListener implements Listener {
@Override
public void onTitleChanged(Todo todo) {
System.out.printf("任务标题变更为: %s%n", todo.getTitle());
}
// 其他事件处理...
}
// 被观察对象(侵入式改造)
public class TodosList {
private final List<Listener> listeners = new ArrayList<>();
public void addListener(Listener listener) {
listeners.add(listener);
}
public void removeListener(Listener listener) {
listeners.remove(listener);
}
public Todo add(String title) {
Todo todo = new Todo(UUID.randomUUID(), title, false);
listeners.forEach(l -> l.onItemAdded(todo, todos));
return todo;
}
// 其他操作方法...
}
2. Java 内置的 Observable(已弃用)
// 被观察的Todo类
@Getter @AllArgsConstructor
public class Todo extends Observable {
private UUID id;
@Setter private String title;
@Setter private boolean completed;
public void setTitle(String title) {
this.title = title;
setChanged();
notifyObservers(this); // 通知所有观察者
}
// 其他setter同理...
}
// 观察者实现
public class BasicObserver implements Observer {
@Override
public void update(Observable o, Object arg) {
if (o instanceof Todo todo) {
System.out.println("[Observer] 收到Todo更新事件: " + todo);
}
}
}
信号机制(Signals)解决方案
核心思想:将属性变化抽象为可观察的信号(Signal),通过声明式编程实现事件监听
1. 信号基础用法
// 信号定义(使用第三方库com.akilisha.oss:signals)
public class Todo {
private final Signal<String> title = Signals.signal("");
private final Signal<Boolean> completed = Signals.signal(false);
public void setTitle(String newTitle) {
title.set(newTitle); // 自动触发订阅的副作用
}
public void observeTitleChanges(Consumer<String> effect) {
Signals.observe(title, effect); // 注册副作用
}
}
2. 待办事项列表实现
public class TodosList {
private final SignalCollection<Todo> todos = Signals.signal(new ArrayList<>());
public Todo add(String title) {
Todo todo = Todo.from(title);
todos.add(todo); // 自动触发集合变更事件
// 声明式监听集合变化
Signals.observe(todos, (event, entity) -> {
switch (event) {
case "add":
System.out.printf("新增任务: %s%n", entity);
break;
case "remove":
System.out.printf("删除任务: %s%n", entity);
break;
}
});
return todo;
}
}
3. 效果注册与取消
public class Main {
public static void main(String[] args) {
TodosList list = new TodosList();
// 注册副作用(自动绑定到Todo属性)
list.add("学习Signals")
.observeTitleChanges(title ->
System.out.printf("任务标题变更为: %s%n", title)
);
list.add("实践Signals")
.observeCompletionChanges(completed ->
System.out.printf("任务完成状态: %s%n", completed)
);
// 触发事件
list.todos.get(0).getTitle().set("深入学习Signals");
}
}
技术对比
特性 | 传统监听器模式 | Java Observable | Signals机制 |
---|---|---|---|
类型安全 | ❌ 需强制转换 | ❌ Object类型 | ✅ 泛型类型安全 |
事件解耦 | ❌ 难以区分属性变化 | ❌ 无法区分属性 | ✅ 明确属性变更事件 |
内存泄漏风险 | ⚠️ 需手动移除监听器 | ⚠️ 需手动移除观察者 | ✅ 自动取消订阅 |
代码侵入性 | ❌ 需修改被观察对象 | ❌ 需继承Observable | ✅ 零侵入 |
生态支持 | ✅ 成熟框架 | ❌ 已弃用 | ⚠️ 第三方库 |
关键优势
- 声明式编程:通过
.observe()
方法直接声明副作用逻辑 - 精确事件解耦:可区分
add
/remove
/update
等具体操作 - 组合式API:支持多信号组合(如
Signals.combineLatest()
) - 类型安全:编译期检查事件类型匹配
使用建议
- 新项目推荐:优先考虑使用Signals机制
- 遗留系统改造:可通过适配器模式逐步替换传统监听器
- 复杂场景:结合RxJava等响应式流框架实现高级功能
通过这种现代化的事件处理方式,可以显著提升代码的可维护性和可测试性,特别适合需要精细控制状态变化的复杂业务场景。
来源:juejin.cn/post/7512657698408988713
搞懂 GO 的垃圾回收机制
速通 GO 垃圾回收机制
前言
垃圾回收(Garbage Collection,简称 GC)是编程语言中自动管理内存的一种机制。Go 语言从诞生之初就带有垃圾回收机制,经过多次优化,现在已经相当成熟。本文将带您深入了解 Go 语言的垃圾回收机制。
下面先一起了解下涉及到的垃圾回收相关知识。
标记清除
标记清除(Mark-Sweep)是最基础的垃圾回收算法,分为两个阶段:
- 标记阶段:从根对象出发,标记所有可达对象(可达性分析)
- 清除阶段:遍历整个堆,回收未被标记的对象
标记清除示例
考虑以下场景:
type Node struct {
next *Node
data int
}
func createLinkedList() *Node {
root := &Node{data: 1}
node2 := &Node{data: 2}
node3 := &Node{data: 3}
root.next = node2
node2.next = node3
return root
}
func main() {
list := createLinkedList()
// 此时内存中有三个对象,都是可达的
list.next = nil
// 此时node2和node3变成了不可达对象,将在下次GC时被回收
}
在这个例子中:
- 初始状态:root -> node2 -> node3 形成链表
- 标记阶段:从root开始遍历,标记所有可达对象
- 修改引用后:只有root是可达的
- 清除阶段:node2和node3将被回收
// 伪代码展示标记清除过程
func MarkSweep() {
// 标记阶段
for root := range roots {
mark(root)
}
// 清除阶段
for object := range heap {
if !marked(object) {
free(object)
}
}
}
标记清除算法的优点是实现简单,但存在以下问题:
- 需要 STW(Stop The World),即在垃圾回收时需要暂停程序运行
- 会产生内存碎片,因为清除后最终剩下的活跃对象在堆中的分布是零散不连续的
- 标记和清除的效率都不高
内存碎片示意图
%%{init: {"flowchart": {"htmlLabels": false}} }%%
flowchart LR
subgraph Before["GC前的堆内存"]
direction LR
A1["已分配"] --- B1["已分配"] --- C1["空闲"] --- D1["已分配"] --- E1["已分配"]
end
Before ~~~ After
subgraph After["GC后的堆内存"]
direction LR
A2["已分配"] --- B2["空闲"] --- C2["空闲"] --- D2["已分配"] --- E2["空闲"]
end
classDef default fill:#fff,stroke:#333,stroke-width:2px;
classDef allocated fill:#a8d08d,stroke:#333,stroke-width:2px;
classDef free fill:#f4b183,stroke:#333,stroke-width:2px;
class A1,B1,D1,E1 allocated;
class C1 free;
class A2,D2 allocated;
class B2,C2,E2 free;
如图所示,GC后的内存空间虽然有足够的总空间,但是由于碎片化,可能无法分配较大的连续内存块。
三色标记
为了优化标记清除算法,Go 语言采用了三色标记算法。主要的目的是为了缩短 STW 的时间,提高程序在垃圾回收过程中响应速度。
三色标记将对象分为三种颜色:
- 白色:未被标记的对象
- 灰色:已被标记但其引用对象未被标记的对象
- 黑色:已被标记且其引用对象都已被标记的对象
三色标记过程图解
graph TD
subgraph "最终状态"
A4[Root] --> B4[Object 1]
B4 --> C4[Object 2]
B4 --> D4[Object 3]
D4 --> E4[Object 4]
style A4 fill:#000000
style B4 fill:#000000
style C4 fill:#000000
style D4 fill:#000000
style E4 fill:#000000
end
subgraph "处理灰色对象"
A3[Root] --> B3[Object 1]
B3 --> C3[Object 2]
B3 --> D3[Object 3]
D3 --> E3[Object 4]
style A3 fill:#000000
style B3 fill:#808080
style C3 fill:#FFFFFF
style D3 fill:#FFFFFF
style E3 fill:#FFFFFF
end
subgraph "标记根对象为灰色"
A2[Root] --> B2[Object 1]
B2 --> C2[Object 2]
B2 --> D2[Object 3]
D2 --> E2[Object 4]
style A2 fill:#808080
style B2 fill:#FFFFFF
style C2 fill:#FFFFFF
style D2 fill:#FFFFFF
style E2 fill:#FFFFFF
end
subgraph "初始状态"
A1[Root] --> B1[Object 1]
B1 --> C1[Object 2]
B1 --> D1[Object 3]
D1 --> E1[Object 4]
style A1 fill:#D3D3D3
style B1 fill:#FFFFFF
style C1 fill:#FFFFFF
style D1 fill:#FFFFFF
style E1 fill:#FFFFFF
end
在垃圾回收器开始工作时,所有对象都为白色,垃圾回收器会先把所有根对象标记为灰色,然后后续只会从灰色对象集合中取出对象进行处理,把取出的对象标为黑色,并且把该对象引用的对象标灰加入到灰色对象集合中,直到灰色对象集合为空,则表示标记阶段结束了。
三色标记实际示例
type Person struct {
Name string
Friends []*Person
}
func main() {
alice := &Person{Name: "Alice"}
bob := &Person{Name: "Bob"}
charlie := &Person{Name: "Charlie"}
// Alice和Bob是朋友
alice.Friends = []*Person{bob}
bob.Friends = []*Person{alice, charlie}
// charlie没有朋友引用(假设bob的引用被删除)
bob.Friends = []*Person{alice}
// 此时charlie将在下次GC时被回收
}
详细标志过程如下:
- 初始时所有对象都是白色
- 从根对象开始,将其标记为灰色
- 从灰色对象集合中取出一个对象,将其引用对象标记为灰色,自身标记为黑色
- 重复步骤 3 直到灰色集合为空
- 清除所有白色对象
// 三色标记伪代码
func TriColorMark() {
// 初始化,所有对象设为白色
for obj := range heap {
setWhite(obj)
}
// 根对象入灰色队列
for root := range roots {
setGrey(root)
greyQueue.Push(root)
}
// 处理灰色队列
for !greyQueue.Empty() {
grey := greyQueue.Pop()
scan(grey)
setBlack(grey)
}
// 清除白色对象
sweep()
}
需要注意的是,三色标记清除算法本身是不支持和用户程序并行执行的,因为在标记过程中,用户程序可能会进行修改对象指针指向等操作,导致最终出现误清除掉活跃对象等情况,这对于内存管理而言,是十分严重的错误了。
并发标记的问题示例
func main() {
var root *Node
var finalizer *Node
// GC开始,root被标记为灰色
root = &Node{data: 1}
// 用户程序并发修改引用关系
finalizer = root
root = nil
// 如果这时GC继续运行,finalizer指向的对象会被错误回收
// 因为从root开始已经无法到达该对象
}
所以为了解决这个问题,在一些编程语言中,常见的做法是,三色标记分为 3 个阶段:
- 初始化阶段,需要 STW,包括标记根对象等操作
- 主要标记阶段,该阶段支持并行
- 结束标记阶段,需要 STW,确认对象标记无误
通过这样的设计,至少可以使得标记耗时较长的阶段可以和用户程序并行执行,大幅度缩短了 STW 的时间,但是由于最后一阶段需要重复扫描对象,所以 STW 的时间还是不够理想,因此引入了内存屏障等技术继续优化。
内存屏障技术
三色标记算法在并发环境下会出现对象丢失的问题,为了解决这个问题,Go 引入了内存屏障技术。
内存屏障技术是一种屏障指令,确保屏障指令前后的操作不会被越过屏障重排。
内存屏障工作原理图解
graph TD
subgraph "插入写屏障"
A1[黑色对象] -->|新增引用| B1[白色对象]
B1 -->|标记为灰色| C1[灰色对象]
end
subgraph "删除写屏障"
A2[黑色对象] -->|删除引用| B2[白色对象]
B2 -->|标记为灰色| C2[灰色对象]
end
垃圾回收中的屏障更像是一个钩子函数,在执行指定操作前通过该钩子执行一些前置的操作。
对于三色标记算法,如果要实现在并发情况下的正确标记,则至少要满足以下两种三色不变性中的其中一种:
- 强三色不变性: 黑色对象不指向白色对象,只会指向灰色或黑色对象
- 弱三色不变性:黑色对象可以指向白色对象,但是该白色对象必须被灰色对象保护着(被其他的灰色对象直接或间接引用)
插入写屏障
插入写屏障的核心思想是:在对象新增引用关系时,将被引用对象标记为灰色。
// 插入写屏障示例
type Object struct {
refs []*Object
}
func (obj *Object) AddReference(ref *Object) {
// 写屏障:在添加引用前将新对象标记为灰色
shade(ref)
obj.refs = append(obj.refs, ref)
}
// 插入写屏障伪代码
func writePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
shade(ptr) // 将新引用的对象标记为灰色
*slot = ptr
}
插入写屏障是一种相对保守的策略,相当于有可能存活的对象都会被标灰,满足了强三色不变行
,缺点是会产生浮动垃圾(没有被引用但却没被回收的对象),要到下一轮垃圾回收时才会被回收。
浮动垃圾示例
func main() {
obj1 := &Object{}
obj2 := &Object{}
// obj1引用obj2
obj1.AddReference(obj2) // obj2被标记为灰色
// 立即删除引用
obj1.refs = nil
// 此时obj2虽然已经不可达
// 但因为已被标记为灰色,要等到下一轮GC才会被回收
}
栈上的对象在垃圾回收中也是根对象,但是如果栈上的对象也开启插入写屏障,那么对于写指针的操作会带来较大的性能开销,所以很多时候插入写屏障只针对堆对象启用,这样一来,要保证最终标记无误,在最终标记结束阶段就需要 STW 来重新扫描栈空间的对象进行查漏补缺。实际上这两种方式各有利弊。
删除写屏障
删除写屏障的核心思想是:在对象删除引用关系时,将被解引用的对象标记为灰色。
这种方法可以保证弱三色不变性
,缺点是回收精度低,同样也会产生浮动垃圾。
// 删除写屏障示例
func (obj *Object) RemoveReference(index int) {
// 写屏障:在删除引用前将被删除的对象标记为灰色
shade(obj.refs[index])
obj.refs = append(obj.refs[:index], obj.refs[index+1:]...)
}
// 删除写屏障伪代码
func writePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
shade(*slot) // 将被删除引用的对象标记为灰色
*slot = ptr
}
混合写屏障
Go 1.8 引入了混合写屏障,同时应用了插入写屏障和删除写屏障,结合了二者的优点:
// 混合写屏障示例
func (obj *Object) UpdateReference(index int, newRef *Object) {
// 删除写屏障
shade(obj.refs[index])
// 更新引用
obj.refs[index] = newRef
// 插入写屏障
shade(newRef)
}
// 混合写屏障伪代码
func writePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
shade(*slot) // 删除写屏障
*slot = ptr
shade(ptr) // 插入写屏障
}
GO 中垃圾回收机制
大致演进与版本改进
- Go 1.3之前:传统标记-清除,全程STW(秒级停顿)。
- Go 1.5:引入并发三色标记,STW降至毫秒级。
- Go 1.8:混合写屏障优化,STW缩短至微秒级。
- Go 1.12+:并行标记优化,提升吞吐量。
在 GO 1.7 之前,主要是使用了插入写屏障来保证强三色不变性,由于垃圾回收的根对象包括全局变量、寄存器、栈对象,如果要对所有的 Goroutine 都开启写屏障,那么对于写指针操作肯定会造成很大的性能损耗,所以 GO 并没有针对栈开启写屏障。而是选择了在标记完成时 STW、重新扫描栈对象(将所有栈对象标灰重新扫描),避免漏标错标的情况,但是这一过程是比较耗时的,要占用 10 ~ 100 ms 时间。
于是,GO 1.8 开始就使用了混合写屏障
+ 栈黑化
的方案优化该问题,GC 开始时全部栈对象标记为黑色,以及标记过程中新建的栈、堆对象也标记为黑色,防止新建的对象都错误回收掉,通过这样的机制,栈空间的对象都会为黑色,所以最后也无需重新扫描栈对象,大幅度地缩短了 STW 的时间。当然,与此同时也会有产生浮动垃圾等方面的牺牲,没有完成的方法,只有根据实际需求的权衡取舍。
主要特点
- 并发回收:GC 与用户程序同时运行
- 非分代式:不按对象年龄分代
- 标记清除:使用三色标记算法
- 写屏障:使用混合写屏障
- STW 时间短:平均在 100us 以内
垃圾回收触发条件
- 内存分配达到阈值
- 定期触发
- 手动触发(runtime.GC())
GC 过程
- STW,开启写屏障
- 并发标记
- STW,清除标记
- 并发清除
- 结束
GC触发示例
func main() {
// 1. 内存分配达到阈值触发
for i := 0; i < 1000000; i++ {
_ = make([]byte, 1024) // 大量分配内存
}
// 2. 定期触发
// Go运行时会自动触发GC
// 3. 手动触发
runtime.GC()
}
总结
Go 语言的垃圾回收机制经过多次优化,已经达到了很好的性能。它采用三色标记算法,配合混合写屏障技术,实现了高效的并发垃圾回收。虽然还有一些不足,如不支持分代回收,但对于大多数应用场景来说已经足够使用。
性能优化建议
要优化 Go 程序的 GC 性能,可以:
- 减少对象分配
// 不好的做法
for i := 0; i < 1000; i++ {
data := make([]int, 100)
process(data)
}
// 好的做法
data := make([]int, 100)
for i := 0; i < 1000; i++ {
process(data)
}
- 复用对象
// 使用sync.Pool复用对象
var pool = sync.Pool{
New: func() interface{} {
return make([]byte, 1024)
},
}
func process() {
buf := pool.Get().([]byte)
defer pool.Put(buf)
// 使用buf
}
- 使用合适的数据结构
// 不好的做法:频繁扩容
s := make([]int, 0)
for i := 0; i < 1000; i++ {
s = append(s, i)
}
// 好的做法:预分配容量
s := make([]int, 0, 1000)
for i := 0; i < 1000; i++ {
s = append(s, i)
}
- 控制内存使用量
// 设置GOGC环境变量控制GC频率
// GOGC=100表示当内存扩大一倍时触发GC
os.Setenv("GOGC", "100")
参考资料:
来源:juejin.cn/post/7523256725126873114
牛马的人生,需要Spring Shell
前言
“技术是人类对需求的回应。”
大家好,这里是知行小栈。
最近,一位运营的同学突然给我发来了一串加密的手机号,类似这样:
2f731fb2aea9fb5069adef6e4aa2624e
他让我帮忙解下密,想拿到具体的手机号。
我看了下,也不是啥大事儿。于是找到了对应的项目,直接调用里面的解密方法,将这些号码都打印了出来,给到了他。
本以为事情到此就结束了,结果他隔三岔五的让我去做这个操作(心里os)。判断了下情况,这种需求可能会不间断的发生。顿时,我的大脑就应激了,必须弄个一劳永逸的方案!
命令行
我最先想到的就是命令行。为啥呢?因为命令行有两个特点:
- 易于调用;
- 简短的命令就能完成指定的功能;
只要制作一个自定义的命令行工具,下次就可以通过这种方式减少繁琐的操作,增加摸鱼的时间。
原先项目中,已经有手机号加解密的功能。基于职业的基本素养(不重复造轮子),之前已有的功能我是不会重写的,而是想办法能直接通过命令行调用。类似:
java -jar xxx.jar
这个命令虽然看起来有点长,但可以通过为其起别名的方式,简化命名。实现 ll 等价于 ls -l
的效果。
Spring Shell
想要通过 shell 调用 Java 指定类中的指定方法,方式有许多。我思考了 秒,就决定采用 Spring Shell(因为它与我想要实现的场景匹配度高达 99.999%)。
首先,我仅需要在原先的项目中多引入一个依赖
<dependency>
<groupId>org.springframework.shell</groupId>
<artifactId>spring-shell-starter</artifactId>
<version>2.1.15</version>
</dependency>
然后,实现一个自定义的命令组件
// @ShellComponent 类似 @Component 表明是 Spring 中的一个组件
@ShellComponent
public class Cipher {
// @ShellMethod 定义了一个命令,key 属性是命令的名称,value 属性是命令的描述
// @ShellOption 定义了命令中的参数
@ShellMethod(key = "decrypt", value = "解密操作")
public String decrypt(@ShellOption String cipherText, @ShellOption String key) {
// 调用项目中已有的解密方法
return AesUtil.decrypt(cipherText, key);
}
@ShellMethod(key = "encrypt", value = "加密操作")
public String encrypt(@ShellOption String text, @ShellOption String key) {
// 调用项目中已有的加密方法
return AesUtil.encrypt(text, key);
}
}
最后,重新将 Shell 组件所在的项目打个包,运行项目
执行命令,验证
到这里,还不行。因为我可不会每次都去执行 java -jar xxx.jar
这么长的命令来启动 Spring Shell。windows 终端我一直用的 Git-Bash,这种类 Unix 的终端都可以采用相同的方式为长命令设置一个别名。
于是,我在 .bash_profile
文件中,给这段长命令起了一个别名:
alias shell='java -jar encrypt.jar'
接下来,就可以通过简单的 shell 命令调用 Spring Shell 终端,执行之前定义好的命令了
知行有话
Spring Shell 简直就是开发者的利器。试想一下,我们把日常学习或工作中频繁的操作都弄成这样的终端命令,是不是会节约我们大量的时间?还有一个值得提的点就是它对 Java 开发者十分友好。只要你懂 Java,就可以轻松上手开发自定义的终端命令。
来源:juejin.cn/post/7530521957666914346
docker容器如何打包镜像和部署
1、打包镜像
如图,参考执行步骤。打包你的工程镜像。
2、推送镜像
2.1 仓库申请
首先,你需要申请一个阿里云Docker镜像仓库 cr.console.aliyun.com/cn-beijing/…
2.2 命名空间
创建一个你的命名空间,后面使用到这个空间地址。
2.3 脚本配置
打开 push.sh 填写你的镜像信息,以及你的镜像仓库地址。
push.sh 脚本,需要通过 ./push.sh
运行,mac 电脑可以直接点击绿色小箭头运行。这个操作步骤完成后,会把镜像推送到你的阿里云Docker镜像仓库去。
拉取使用;docker pull crpi-ioutcr0ojmsa4ham.cn-beijing.personal.cr.aliyuncs.com/liwenchao_test/riderwuyou-admin:1.0-SNAPSHOT
设置名称;docker tag crpi-ioutcr0ojmsa4ham.cn-beijing.personal.cr.aliyuncs.com/liwenchao_test/riderwuyou-admin:1.0-SNAPSHOT liwenchao_test/riderwuyou-admin:1.0
注意;你可以重设镜像名称,可以把 liwenchao_test/riderwuyou-admin:1.0
的地方。
- 服务脚本
docker 项目的部署,具有一次构建,多地部署的通用性。所以,你可以在本地 docker 环境部署、nas环境部署、云服务器环境部署。
3.1 部署环境 - 脚本
通过以下脚本,安装mysql、redis等。
3.2 项目部署 - 脚本
镜像,liwenchao_test/riderwuyou-admin:1.0 如果使用阿里云Docker仓库,那么可以使用 image: crpi-ioutcr0ojmsa4ham.cn-beijing.personal.cr.aliyuncs.com/liwenchao_test/riderwuyou-admin:1.0-SNAPSHOT
或者用 docker tag crpi-ioutcr0ojmsa4ham.cn-beijing.personal.cr.aliyuncs.com/liwenchao_test/riderwuyou-admin:1.0-SNAPSHOT liwenchao_test/riderwuyou-admin:1.0
设定镜像名称。
4. 服务部署
4.1 上传脚本
4.2 执行脚本
- 执行脚本01;
docker-compose -f docker-compose-environment-aliyun.yml up -d
- 执行脚本02;
docker-compose -f docker-compose-app-v1.0.yml up -d
- 运行完成后,就可以运行测试了
来源:juejin.cn/post/7529292244571897910
面试官:MySQL单表过亿数据,如何优化count(*)全表的操作?
本文首发于公众号:托尼学长,立个写 1024 篇原创技术面试文章的flag,欢迎过来视察监督~
最近有好几个同学跟我说,他在技术面试过程中被问到这个问题了,让我找时间系统地讲解一下。
其实从某种意义上来说,这并不是一个严谨的面试题,接下来 show me the SQL,我们一起来看一下。
如下图所示,一张有 3000多万行记录的 user 表,执行全表 count 操作需要 14.8 秒的时间。
接下来我们稍作调整再试一次,神奇的一幕出现了,执行全表 count 操作竟然连 1 毫秒的时间都用不上。
这是为什么呢?
其实原因很简单,第一次执行全表 count 操作的时候,我用的是 MySQL InnoDB 存储引擎,而第二次则是用的 MySQL MyISAM 存储引擎。
这两者的差别在于,前者在执行 count(*) 操作的时候,需要将表中每行数据读取出来进行累加计数,而后者已经将表的总行数存储下来了,只需要直接返回即可。
当然,InnoDB 存储引擎对 count(*) 操作也进行了一些优化,如果该表创建了二级索引,其会通过全索引扫描的方式来代替全表扫描进行累加计数,
毕竟,二级索引值只存储了索引列和主键列两个字段,遍历计数肯定比存储所有字段的数据表的 IO 次数少很多,也就意味着其执行效率更高。
而且,MySQL 的优化器会选择最小的那个二级索引的索引文件进行遍历计数。
所以,这个技术面试题严谨的问法应该是 —— MySQL InnoDB 存储引擎单表过亿数据,如何优化 count(*) 全表的操作?
下面我们就来列举几个常见的技术解决方案,如下图所示:
(1)Redis 累加计数
这是一种最主流且简单直接的实现方式。
由于我们基本上不会对数据表执行 delete 操作,所以当有新的数据被写入表的时候,通过 Redis 的 incr 或 incrby 命令进行累加计数,并在用户查询汇总数据的时候直接返回结果即可。
如下图所示:
该实现方式在查询性能和数据准确性上两者兼得,Redis 需要同时负责累加计数和返回查询结果操作,缺点在于会引入缓存和数据库间的数据一致性的问题。
(2)MySQL 累加计数表 + 事务
这种实现方式跟“Redis 累加计数”大同小异,唯一的区别就是将计数的存储介质从 Redis 换成了 MySQL。
如下图所示:
但这么一换,就可以将写入表操作和累加计数操作放在一个数据库事务中,也就解决了缓存和数据库间的数据一致性的问题。
该实现方式在查询性能和数据准确性上两者兼得,但不如“Redis 累加计数”方式的性能高,在高并发场景下数据库会成为性能瓶颈。
(3)MySQL 累加计数表 + 触发器
这种实现方式跟“MySQL 累加计数表 + 事务”的表结构是一样的,如下图所示:
****
唯一的区别就是增加一个触发器,不用在工程代码中通过事务进行实现了。
CREATE TRIGGER `user_count_trigger` AFTER INSERT ON `user` FOR EACH ROW BEGIN UPDATE user_count SET count = count + 1 WHERE id = NEW.id;END
该实现方式在查询性能和数据准确性上两者兼得,与“MySQL 累加计数表 + 事务”方式相比,最大的好处就是不用污染工程代码了。
(4)MySQL 增加并行线程
在 MySQL 8.014 版本中,总算增加了并行查询的新特性,其通过参数 innodb_parallel_read_threads 进行设定,默认值为 4。
下面我们做个实验,将这个参数值调得大一些:
set local innodb_parallel_read_threads = 16;
然后,我们再来执行一次上文中那个 3000 多万行记录 user 表的全表 count 操作,结果如下所示:
参数调整后,执行全表 count 操作的时间由之前的 14.8 秒,降低至现在的 6.1 秒,是可以看到效果的。
接下来,我们继续将参数值调整得大一些,看看是否还有优化空间:
set local innodb_parallel_read_threads = 32;
然后,我们再来执行一次上文中那个 3000 多万行记录 user 表的全表 count 操作,结果如下所示:
参数调整后,执行全表 count 操作的时间竟然变长了,从原来的 6.1 秒变成了 6.8 秒,看样子优化空间已经达到上限了,再多增加执行线程数量只会适得其反。
该实现方式一样可以保证数据准确性,在查询性能上有所提升但相对有限,其最大优势是只需要调整一个数据库参数,在工程代码上不会有任何改动。
不过,如果数据库此时的负载和 IOPS 已经很高了,那开启并行线程或者将并行线程数量调大,会加速消耗数据库资源。
(5)MySQL 增加二级索引
还记得我们在上文中说的内容吗?
InnoDB 存储引擎对 count() 操作也进行了一些优化,如果该表创建了二级索引,其会通过全索引扫描的方式来代替全表扫描进行累加计数,*
毕竟,二级索引值只存储了索引列和主键列两个字段,遍历计数肯定比存储所有字段的数据表的IO次数少很多,也就意味着执行效率更高。
而且,MySQL 的优化器会选择最小的那个二级索引的索引文件进行遍历计数。
为了验证这个说法,我们给 user 表中最小的 sex 字段加一个二级索引,然后通过 EXPLAIN 命令看一下 SQL 语句的执行计划:
果然,这个 SQL 语句的执行计划会使用新建的 sex 索引,接下来我们执行一次看看时长:
果不其然,执行全表 count 操作走了 sex 二级索引后,SQL 执行时间由之前的 14.8 秒降低至现在的 10.6 秒,还是可以看到效果的。
btw:大家可能会觉得效果并不明显,这是因为我们用来测试的 user 表中算上主键 ID 只有七个字段,而且没有一个大字段。
反之,user 表中的字段数量越多,且包含的大字段越多,其优化效果就会越明显。
该实现方式一样可以保证数据准确性,在查询性能上有所提升但相对有限,其最大优势是只需要创建一个二级索引,在工程代码上不会有任何改动。
(6)SHOW TABLE STATUS
如下图所示,通过 SHOW TABLE STATUS 命令也可以查出来全表的行数:
我们常用于查看执行计划的 EXPLAIN 命令也能实现:
只不过,通过这两个命令得出来的表记录数是估算出来的,都不太准确。那到底有多不准确呢,我们来计算一下。
公式为:33554432 / 33216098 = 1.01
就这个 case 而言,误差率大概在百分之一左右。
该实现方式一样可以保证查询性能,无论表中有多大量级的数据都能毫秒级返回结果,且在工程代码方面不会有任何改动,但数据准确性上相差较多,只能用作大概估算。
来源:juejin.cn/post/7444919285170307107
Go实现超时控制
应用场景
交易、金融等事务系统往往会有各种下游,绝大多数时候我们会以同步方式进行访问,如调用RPC、HTTP等。
这些下游在通常延时相对稳定,但有时可能出现极端的超大延时,这些极端case可能具备特定的业务特征,也有可能单纯是硬件、网络的问题造成,最终表现在系统P99或者P999的延时出现了突刺,如果是面向C端的场景,也会向用户报出一些系统错误,造成用户体验的下降。
一种简易的解决方案是,针对关键的下游节点增加超时控制。在特定时间内,如果下游到期还未返回,不再暴露系统级错误,而是做特殊化处理,比如返回「处理中」状态。
Go实现方案
设计一个方法,使用闭包,传入时间和执行的任务,如果任务执行完未到时间,则直接返回,否则通知调用者超时。
为了保证代码简介和使用简单,我们仅定义一个Wrapper方法,方法定义如下
func TimeoutControlWrapper(duration time.Duration, fn func()) (timeout bool)
官方包time有一个After方法,可以在指定时间内,返回一个channel,基于此来判断是否超时。
另外,在Wrapper方法里异步化执行目标方法,执行完成后写入一个finish信号通知。
同时监听这两个channel,判断是否超时,代码如下
func TimeoutControlWrapper(duration time.Duration, fn func()) (timeout bool) {
finish := make(chan struct{})
go func() {
fn()
finish <- struct{}{}
}()
select {
case <-finish:
return false
case <-time.After(duration):
return true
}
}
结合场景,假设系统会调用一个支付系统的接口,接口本身延时不稳定,因此我们套用TimeoutControlWrapper
func CallPaymentSystem(param PayParam) (payStatus PayStatus) {
var payStatus PayStatus
timeout := TimeoutControlWrapper(time.Second, func() {
payStatus = PaymentSystemRPC.Pay(param)
})
if timeout {
warn() // WARN告警
return PROCESSING // 返回处理中
}
return payStatus
}
延伸思考
上述通过一个简单的Wrapper,来实现调用下游时的超时控制。但在引入的场景里,实现上是不严谨的。哪怕不增加超时控制,我们也无法确认请求是否真实到达了下游系统,这本质上是一个分布式事务的问题,需要我们设计更加健全的系统能力保证一致性,比如通过消息的方式、补偿机制、增加对账系统。
来源:juejin.cn/post/7524615282490441779
调试 WebView 旧资源缓存问题:一次从偶发到复现的实战经历
移动端 WebView 与浏览器最大的差异之一就是缓存机制:浏览器支持 DevTools 清理缓存、更新资源非常便利;而 WebView 在 App 中受系统 WebView 组件和应用缓存策略影响,经常会出现资源更新后,部分用户仍加载老版本 JS/CSS,引发奇怪的线上问题。
这类问题难点在于:不是所有用户都能复现,只有特定设备/网络环境/升级路径才会触发。以下是我们在一个活动页迭代中解决用户加载到老版本脚本的问题记录。
背景:活动页面更新后部分用户功能异常
活动页面上线后,我们修复了一个按钮点击无效的 bug,并发布了新 JS 资源。大部分用户恢复正常,但个别用户仍反馈点击无响应。
通过埋点数据统计,这类异常只占总 PV 的 1~2%,但因影响实际参与,必须解决。
第一步:判断用户是否加载到新资源
通过后端接口返回的页面版本号,我们在埋点中发现异常用户请求的是最新页面 HTML,但 HTML 中引用的 JS 文件版本却是旧文件。
我们用 Charles 配合 WebDebugX,在问题设备上连接调试,确认请求路径:
https://cdn.example.com/activity/v1.2.0/main.js
服务器早已上线 v1.3.0 文件,但部分设备仍强制加载 v1.2.0。这说明浏览器或 WebView 从缓存中读取了过期资源。
第二步:复现问题与验证缓存机制
通过 Charles 的 Map Local 功能,我们在真机上强制模拟返回旧版 main.js,验证页面表现是否与用户反馈一致。结果按钮再次失效,证明旧资源是问题根源。
然后用 WebDebugX 查看资源请求的响应 header,确认服务器已正确返回 Cache-Control:
Cache-Control: no-cache, max-age=0
理论上应强制重新拉取最新资源,但部分 Android WebView 未执行 no-cache,而是优先使用 local cache。
第三步:排查 WebView 缓存策略差异
我们协助移动端团队通过 Logcat 查看 WebView 请求日志,发现部分机型仍启用了 LOAD_DEFAULT
缓存模式,该模式下只要缓存有效期内,就会使用本地缓存资源,即便服务器指示不缓存也无法生效。
而大部分新系统使用了 LOAD_NO_CACHE
或 LOAD_CACHE_ELSE_NETWORK
,能更好地遵循服务器缓存头。
第四步:修复方案设计
针对缓存策略问题,我们制定了双向修复方案:
短期前端方案
- 在资源引用 URL 中增加强制更新参数:
<script src="https://cdn.example.com/activity/main.js?v=20240601"></script>
- 每次版本发布更新
v
参数,确保请求路径变化,从而绕开缓存。
中期后端方案
- 通过 CDN 配置给静态文件加上不可缓存策略,确保 CDN 节点不会继续提供过期资源。
长期客户端方案
- 移动端团队将 WebView 缓存策略统一改为
LOAD_NO_CACHE
模式,彻底解决旧资源被缓存的问题。
第五步:验证全流程有效性
修复完成后,我们用以下方法进行多角度验证:
- 使用 Charles 观察请求地址是否携带新版本参数;
- 在 WebDebugX 中查看页面是否加载了最新资源;
- 在 QA 部门用多台低端机和慢网环境回归测试,模拟网络断开重连、App 冷启动后资源拉取表现;
- 监控埋点数据中页面版本和资源版本是否完全一致,确认没有用户再加载到老资源。
最终确认异常用户比例下降到 0%。
工具与协作流程
此次缓存问题排查中,我们的调试和分工是:
工具 | 用途 | 使用人 |
---|---|---|
WebDebugX | 查看资源加载路径、响应 header | 前端 / QA |
Charles | 模拟缓存场景、观察真实请求 | 前端 |
Logcat | 验证 WebView 缓存模式 | 移动端 |
Vysor | 复现低端设备表现、录制操作过程 | QA |
总结:缓存问题的解决要从端到端出发
缓存问题不是“前端清理一下”就能解决,它涉及:
浏览器/WebView 端缓存策略;
后端或 CDN 返回的缓存头;
前端 URL 版本控制;
不同系统/厂商 WebView 兼容性。
要彻底消除老资源顽固缓存,必须让服务器、前端、客户端配置形成闭环。
调试工具(WebDebugX、Charles、Logcat)可以帮助我们还原资源加载链条,但核心是对缓存机制的整体认知与各端的配合。
来源:juejin.cn/post/7522187483762966579
用 Tauri + FFmpeg + Whisper.cpp 从零打造本地字幕生成器
背景:
最近开始尝试做自媒体,录点视频。刚开始就遇到了字幕的问题,于是想先搞个字幕生成工具(为了这点醋才包的这顿饺子😄):SubGen。
这个工具用 Tauri + Rust 做外壳,把 FFmpeg 和 Whisper.cpp 集成进去,能一键把视频转成 SRT 字幕。
这篇文章记录下笔者做这个工具的过程,也分享下用到的核心组件和代码结构。
架构设计
SubGen 采用分层架构,核心组件的交互关系如下:
┌─────────────┐ ┌──────────────┐
│ React UI │ │ Rust Core │
│ (TypeScript)│ <----> │ (Tauri API) │
└─────────────┘ └─────┬────────┘
│
┌─────────────┴───────────────┐
│ │
┌────▼────┐ ┌────▼────┐
│ FFmpeg │ │Whisper │
│ 提取音频 │ │ 离线识别 │
└─────────┘ └─────────┘
为什么用 Tauri?
最开始笔者也考虑过 Electron,但它打包太大了(动辄 100MB 起步),而且资源占用高。后来发现 Tauri,它用 Rust 做后端,前端还是用 React 或者任意 Web 技术,这样:
- 打包后体积很小(十几 MB)。
- 跨平台方便(Windows / macOS / Linux)。
- Rust 调用本地二进制(FFmpeg 和 Whisper)非常顺手。
笔者主要是用 React + TypeScript 写了一个简单的 UI,用户选视频、点按钮,剩下的活就交给 Rust。
FFmpeg:用它来“扒”音频
FFmpeg 是老牌的音视频处理工具了,笔者直接内置了一个编译好的 ffmpeg.exe
/ffmpeg
到资源目录,调用它来:
- 从视频里抽出音频。
- 统一格式(16kHz,单声道 WAV),让 Whisper 可以直接处理。
Rust 这边的调用很简单:
use std::process::Command;
Command::new("resources/ffmpeg")
.args(["-i", &video_path, "-ar", "16000", "-ac", "1", "audio.wav"])
.status()
.expect("FFmpeg 执行失败");
这样一行命令就能把视频转成标准 WAV。
Whisper.cpp:核心的离线识别
笔者选的是 Whisper.cpp,因为它比 Python 版 Whisper 更轻量,直接编译一个 whisper-cli
就能用,不需要装乱七八糟的依赖。
更重要的一点是支持CPU运行,默认4个线程,即使用 ggml-large-v3 也可以跑出来结果,只是稍微慢点。这对于没有好的显卡的童鞋很有用!
调用命令大概是这样:
whisper-cli -m ggml-small.bin -f audio.wav -osrt -otxt
最后会输出一个 output.srt
,直接能用。
Rust 里调用也是 Command::new()
一把梭:
Command::new("resources/whisper-cli")
.args(["-m", "resources/models/ggml-small.bin", "-f", "audio.wav", "-l", "zh", "--output-srt"])
.status()
.expect("Whisper 执行失败");
代码结构和流程
笔者的项目大概是这样分层的:
subgen/
├── src/ # 前端 React + TypeScript
│ └── main.tsx # UI入口
├── src-tauri/ # Tauri + Rust
│ ├── commands.rs # Rust命令逻辑
│ ├── resources/ # ffmpeg、whisper二进制、模型文件
│ └── main.rs # 程序入口
前端用 @tauri-apps/api
的 invoke
调 Rust:
import { invoke } from '@tauri-apps/api';
async function handleGenerate(videoPath: string) {
const result = await invoke<string>('extract_subtitles', { videoPath });
console.log('字幕生成完成:', result);
}
Rust 后端的核心命令:
#[tauri::command]
fn extract_subtitles(video_path: String) -> Result<String, String> {
// 1. 调 FFmpeg
// 2. 调 Whisper.cpp
// 3. 返回 SRT 路径
Ok("output.srt".to_string())
}
用下来的感受
整个工具现在已经能做到“拖进视频 → 等几十秒 → 出字幕”这种体验了。
几个感受:
- Tauri 真香:比 Electron 清爽太多,Rust 后端很适合做这些底层调用。
- FFmpeg 是万能的,直接抽音频,性能还不错。
- Whisper.cpp 虽然 CPU 跑慢点,但好在准确率挺高,还不用联网。
后续想做的事
- 支持批量处理视频。
- 集成一个简单的字幕编辑功能。
- 尝试 GPU 加速 Whisper(Metal / Vulkan)。
截图
主界面:
生成的 SRT:
如果你也想做个自己的字幕工具,可以直接参考 SubGen 的架构,自己改改就能用。
代码已开源:github.com/byteroycai/…
来源:juejin.cn/post/7528457291697012774