A. 开篇:我们聊聊"SpringBoot天花板"
你是否也觉得,工作两三年,每天都在用 SpringBoot 写着 REST 接口和 CRUD,技术水平仿佛进入了平台期?
我们聊起技术时,口中的是 Spring MVC 的三层架构、是 MyBatis-Plus 带来的便捷、是 Lombok 的神奇之处。简历上最亮眼的项目,可能还是那个用 SpringBoot 构建的单体应用。我们熟练地使用着框架提供的便利,却很少去思考其背后的原理。
然而,当面试官冷不丁地抛出这些问题时,我们却常常陷入沉默:
- “在高并发场景下,如何保证你这段业务逻辑的线程安全?”
- “分布式的环境下,你是如何实现锁的?”
- “在使用消息队列时,你如何确保消息的最终一致性?”
- “能聊聊你对服务熔断、降级和限流的理解吗?”
我们开始支支吾吾,那些曾经让我们引以为傲的"业务开发能力"在这些问题面前显得如此苍白。这时我们才恍然大悟:我们熟练掌握的 SpringBoot,只是现代后端技术的"入场券",而非安身立命的"护城河"。在我们与真正的"架构师"之间,隔着一道看似模糊却难以逾越的鸿沟。
B. 这份手册是为你准备的吗?
首先,必须明确:这不是一本零基础教程。
它不会教你 Java 的 for 循环怎么写,也不会讲解 SpringBoot 的 Hello World 如何运行。我假定你已经具备了独立使用 SpringBoot 进行业务开发的能力。
这份手册是为这样一群"你"量身打造的:
- 你对
AQS的设计哲学、Redis的持久化机制、RabbitMQ的主题交换机、Nacos的服务发现原理这些名词耳熟能详,甚至在面试前背诵过它们。 - 但你从未在真实的项目中,系统性地将这些"屠龙之技"串联起来,解决一个复杂的工程问题。
- 你渴望突破现状,渴望构建一个能让自己在面试中脱颖而出、引以为傲的复杂项目。
- 你希望建立起自己的技术体系,而不仅仅是零散知识点的堆砌。
如果上述描述戳中了你的痛点,那么,恭喜你,这份手册就是你一直在寻找的答案。
C. 破局之路:我们设计的四步进阶法
这本手册不是一本技术字典,它不会罗列所有 API。它更像一张我们为你精心设计的"闯关地图",旨在引导你走一条从 SpringBoot 到微服务架构的"最短路径"。
我们将整个学习过程划分为四个核心阶段:
-
第一阶段 - 重塑地基:深入框架原理
我们不回头学习基础语法,而是站在一个更高的维度,重新审视 Spring 的核心原理(如IOC、AOP)、MyBatis 的生命周期以及 Spring Security 的认证授权流程。这一阶段的目标是为你理解后续的复杂分布式框架打下坚实的地基。 -
第二阶段 - 修炼内功:征服并发编程
我们将一头扎进 Java 并发编程的腹地,从JMM、synchronized到AQS思想和JUC工具集。这部分内容将直接决定你的代码质量和系统性能的上限,是区分代码"能用"与"好用"的关键分水岭。 -
第三阶段 - 跨入分布式:构建高可用系统
从这里开始,你将正式推开新世界的大门。我们将亲手搭建和使用Redis作为分布式缓存、引入消息队列处理异步任务、并探讨分库分表等核心技术。你将真正具备构建一个高可用、高扩展性应用的核心能力。 -
第四阶段 - 加冕微服务:从零到一的实战
这是我们的终极挑战,也是你技术蜕变的最好证明。我们将把前面学到的所有知识融会贯通,遵循企业级标准,从零到一构建一个可以写入简历的"Treademo"商城微服务项目。这不仅是你学习之路的终点,更是你职业生涯新起点的里程碑。
D. 在这里,你将收获什么?
学完这本手册,你得到的绝不仅仅是一些代码片段。你将收获:
- 一份硬核的项目经验:一个可以让你在面试中面对架构问题时侃侃而谈的微服务实战项目。
- 一套完整的知识体系:从底层的并发原理到顶层的微服务治理,你将形成知识的闭环,不再是零散和碎片化的理解。
- 一种全新的思考方式:你将初步具备架构师的思维,在面对业务需求时,能从可用性、性能、扩展性等多个维度去思考和设计解决方案。
- 一份挑战高薪的底气:让你拥有足够的信心和实力,去挑战那些对技术深度和广度有更高要求的岗位。
E. 写在开始之前
坦白说,这条路并不轻松,它需要你投入大量的时间和精力去消化、实践。但请相信,你的每一份付出,都会在未来的某一个节点得到回报。
这本手册是我多年一线架构经验的沉淀与总结,现在,我将它毫无保留地分享给你。
准备好了吗?让我们一起掀开第一章,开启这段激动人心的进阶之旅!

第一章:MyBatis - 让SQL再次伟大
A. 引言
在软件开发的江湖中,与数据库打交道是后端工程师的每日必修课。曾几何时,我们依赖JDBC(Java Database Connectivity)作为连接Java应用与数据库的桥梁。JDBC尽职尽责,但它的繁琐也让开发者叫苦不迭:
- 连接管理:每次操作都需手动获取
Connection,并在操作结束后小心翼翼地在finally块中关闭,以防资源泄漏。 - SQL拼接地狱:动态条件的SQL语句需要进行大量的字符串拼接,代码丑陋且极易出错。
- 重复的模板代码:创建
Statement或PreparedStatement,执行SQL,遍历ResultSet,手动将结果映射到Java对象,再加上繁琐的异常处理… 每个数据库操作几乎都充斥着这些样板戏。
正是为了将开发者从这些重复、低效的劳动中解放出来,持久层框架应运而生。而MyBatis,正是其中的佼佼者。它巧妙地保留了SQL的灵活与强大,同时又极大地简化了数据库操作的编码过程。它不完全"自动"——你仍然需要手写SQL,但也正因如此,它赋予了你对SQL的绝对控制力,能够进行深度优化。MyBatis自称"半自动"ORM框架,它的出现,让SQL再次变得伟大而优雅。
B. 核心概念与快速入门
要掌握MyBatis,首先需要理解两个核心对象:SqlSessionFactory和SqlSession。
SqlSessionFactory: 顾名思义,它是创建SqlSession的工厂。每个MyBatis应用的核心就是SqlSessionFactory的实例。它是一个重量级、线程安全的对象,生命周期应与应用的生命周期相同。因此,最佳实践是使用单例模式来管理它,避免重复创建。SqlSession: 它是与数据库进行一次会话的代表。你可以通过它来执行SQL命令、获取映射器(Mapper)以及管理事务。SqlSession实例不是线程安全的,因此不能被共享,它的最佳作用域是请求或方法作用域。每次数据库操作结束后,都必须关闭它。
它们从创建到使用的过程可以用下图清晰地表示:
快速入门:一次简单的查询
让我们通过一个最简单的示例,感受MyBatis的魅力。假设我们已经配置好了MyBatis环境,并有一个UserMapper.xml文件定义了SQL语句。
UserMapper.xml:
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.mapper.UserMapper">
<select id="selectUserById" resultType="com.example.model.User">
SELECT * FROM users WHERE id = 1
</select>
</mapper>
Java代码:
// 1. 加载配置文件,创建SqlSessionFactory (实际项目中由框架管理)
String resource = "mybatis-config.xml";
InputStream inputStream = Resources.getResourceAsStream(resource);
SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
// 2. 从SqlSessionFactory获取SqlSession
try (SqlSession session = sqlSessionFactory.openSession()) {
// 3. 执行查询
User user = session.selectOne("com.example.mapper.UserMapper.selectUserById");
System.out.println("查询到的用户:" + user);
}
看,没有了繁琐的JDBC代码,我们只需定义好SQL,然后通过一行代码即可完成查询,MyBatis在底层为我们处理了所有复杂工作。
C. 动手实践与常用功能
理论结合实践是最好的学习方式。在本节,我们将设定一个贯穿始终的Book实体和数据库表,并一步步实现对它的增、删、改、查(CRUD)操作。
- 实体类
Book.java:
public class Book {
private Long id;
private String bookName;
private String author;
private Double price;
// ... getters and setters
}
- 数据库表
t_book:
CREATE TABLE t_book (
id INT PRIMARY KEY AUTO_INCREMENT,
book_name VARCHAR(255) NOT NULL,
author VARCHAR(100),
price DECIMAL(10, 2)
);
注意,我们特意让Java属性名(bookName)和数据库列名(book_name)不完全一致,为后续讲解<resultMap>埋下伏笔。
配置BookMapper.xml实现CRUD
BookMapper.xml是定义Book相关SQL语句的地方。
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.mapper.BookMapper">
<!-- 1. 新增一本书 (Create) -->
<insert id="insertBook" parameterType="com.example.model.Book">
INSERT INTO t_book (book_name, author, price)
VALUES (#{bookName}, #{author}, #{price})
</insert>
<!-- 2. 根据ID查询一本书 (Read) -->
<select id="selectBookById" resultType="com.example.model.Book">
SELECT id, book_name, author, price FROM t_book WHERE id = #{id}
</select>
<!-- 3. 更新一本书的信息 (Update) -->
<update id="updateBook" parameterType="com.example.model.Book">
UPDATE t_book
SET book_name = #{bookName}, author = #{author}, price = #{price}
WHERE id = #{id}
</update>
<!-- 4. 根据ID删除一本书 (Delete) -->
<delete id="deleteBookById">
DELETE FROM t_book WHERE id = #{id}
</delete>
</mapper>
重点讲解:动态SQL
动态SQL是MyBatis最强大的特性之一。它允许我们根据不同的条件动态地生成SQL语句,极大地提高了SQL的复用性和灵活性。
场景一:使用<if>实现条件查询
需求:根据书名进行模糊查询,如果书名未提供,则查询全部。
<select id="findBooks" resultType="com.example.model.Book">
SELECT id, book_name, author, price FROM t_book
<where>
<if test="bookName != null and bookName != ''">
AND book_name LIKE CONCAT('%', #{bookName}, '%')
</if>
</where>
</select>
<where>标签很智能,它会自动处理SQL语句开头的AND或OR。<if>标签通过test属性中的表达式判断是否要包含内部的SQL片段。
场景二:使用<foreach>实现批量查询
需求:根据ID列表,查询多本书籍。
<select id="findBooksByIds" resultType="com.example.model.Book">
SELECT id, book_name, author, price FROM t_book
WHERE id IN
<foreach item="id" collection="list" open="(" separator="," close=")">
#{id}
</foreach>
</select>
collection="list": 指定了传入的参数类型是List。如果是Array,则为array。item="id": 每次遍历时的元素别名。open、close、separator: 分别定义了整个循环片段的开头、结尾和元素之间的分隔符。MyBatis会智能地将其拼接为(id1, id2, id3)的形式。
[嵌入学习环节]
需求:请将一个只能查询全部图书的方法,重构为可以根据书名(可选)、作者(可选)、价格区间(可选)动态查询的方法。
参考实现:
<select id="findBooksByCriteria" resultType="com.example.model.Book"> SELECT id, book_name, author, price FROM t_book <where> <if test="bookName != null and bookName != ''"> AND book_name LIKE CONCAT('%', #{bookName}, '%') </if> <if test="author != null and author != ''"> AND author = #{author} </if> <if test="minPrice != null"> AND price >= #{minPrice} </if> <if test="maxPrice != null"> AND price <= #{maxPrice} </if> </where> </select>注意:在XML中,大于号
>应转义为>,小于号<应转义为<。
D. 深化理解与最佳实践
#{} vs ${}:安全与风险的博弈
这是MyBatis面试中的高频题,也是每个使用者必须深刻理解的知识点。
| 特性 | #{} | ${} |
|---|---|---|
| 本质 | 预编译参数占位符 (PreparedStatement的?) | 字符串直接拼接 |
| 安全性 | 安全,能有效防止SQL注入 | 不安全,有SQL注入风险,需由应用代码保证数据安全 |
| 功能 | 将传入值作为一个参数,并自动为其添加引号 | 直接将传入值原样拼接到SQL中 |
| 使用场景 | 绝大多数数据传递场景 | 动态表名、列名、ORDER BY子句等无法使用占位符的场景 |
SQL注入风险案例
假设我们有一个按指定列排序的需求,如果使用${}且未对输入做任何校验:
<select id="getBooksOrderBy" resultType="com.example.model.Book">
SELECT * FROM t_book ORDER BY ${columnName}
</select>
正常调用时,传入"price",SQL为 ... ORDER BY price,一切正常。
但如果一个恶意用户传入了 price; DROP TABLE t_book; -- 这样的字符串呢?
最终拼接的SQL将变成:
SELECT * FROM t_book ORDER BY price; DROP TABLE t_book; --
如果数据库支持多语句执行,这将会导致t_book表被直接删除,造成灾难性后果。而使用#{}则完全不会有此问题,因为它会把整个恶意字符串当作一个普通的列名参数(并带上引号),这只会导致数据库报错,而不会执行恶意代码。
使用<resultMap>解决列名与属性名不一致
在前面的selectBookById查询中,MyBatis无法将book_name列的值映射到bookName属性上,因为名字不匹配。这时,<resultMap>就派上了用场。
<resultMap>是MyBatis中最强大、最重要的元素,它能让你从JDBC的ResultSet中解脱出来,定义你自己的映射规则。
<!-- 1. 定义一个ResultMap -->
<resultMap id="BookResultMap" type="com.example.model.Book">
<id property="id" column="id" />
<result property="bookName" column="book_name" />
<result property="author" column="author" />
<result property="price" column="price" />
</resultMap>
<!-- 2. 在查询语句中使用resultMap属性代替resultType -->
<select id="selectBookById" resultMap="BookResultMap">
SELECT id, book_name, author, price FROM t_book WHERE id = #{id}
</select>
通过<resultMap>,我们明确告诉MyBatis:
- 将
id列映射到id属性(<id>标签用于主键,有性能优化)。 - 将
book_name列映射到bookName属性。 - 其他同名列会自动映射。
这样,即使数据库设计与Java对象模型存在差异,我们也能优雅地解决。
E. 总结与预告
1. 本章总结
恭喜你,已经掌握了MyBatis的核心用法!回顾本章,我们:
- 理解了MyBatis作为"半自动"持久层框架,如何将我们从繁琐的JDBC中解放出来。
- 掌握了通过
SqlSessionFactory和SqlSession执行数据库操作的基本流程。 - 能够熟练使用XML配置完成对单表的增、删、改、查。
- 掌握了
<if>、<foreach>等动态SQL标签,编写出灵活、可复用的SQL。 - 深刻理解了
#{}(安全)与${}(风险)的本质区别,并学会了使用<resultMap>处理列名与属性名的映射关系。
MyBatis的核心优势在于它让你回归SQL本身,在享受框架便利的同时,不失对SQL的掌控力。
[嵌入学习环节] - 迷你项目
任务:完成一个简单的个人博客系统的"文章管理"模块后端。
要求:
- 设计一张
article表(包含ID、标题title、内容content、作者author、标签tags如’Java,后端,Tech’等)。- 创建一个
Article实体类。- 使用MyBatis实现对
Article的增、删、改、查功能。- 挑战:实现一个动态查询接口,可以根据**标题(模糊查询)和标签(模糊查询)**进行筛选。例如,查询所有标题包含"Java"且标签包含"后端"的文章。
2. 下章预告
恭喜你掌握了数据持久化的利器!但现代应用中,对象之间的复杂关系管理、组件的生命周期、以及业务逻辑的解耦是个更大的挑战。下一章,我们将进入Spring的世界,学习其核心思想——**IoC(控制反转)和AOP(面向切面编程)**是如何像魔法一样解耦我们的代码,并成为Java后端开发事实上的标准基石的。
第二章:Spring - 现代Java应用的基石
A. 引言:告别"自己盖房",拥抱"精装公寓"
想象一下,在没有现代框架的时代,我们开发一个应用程序,就像从零开始盖一栋房子。你需要亲自去"创建"每一个"房间"(对象),并用"水泥"和"钢筋"(硬编码)将它们牢固地连接在一起。比如,书房需要一张桌子,你就得在书房的构造图纸里明确写上 桌子 = new 桌子()。如果有一天,你想给所有书房换一款更高级的桌子,那将是一场噩梦——你得翻遍所有图纸,逐一修改。这就是传统开发的窘境:组件之间紧密耦合,牵一发而动全身。
现在,让我们换一种思路。如果你直接入住一个"精装修的公寓小区",情况会怎样?你只需要告诉公寓管理员(Spring框架):“我需要一个书房,它得有一张桌子、一把椅子和一个书架。“管理员便会根据你的"需求清单”(配置),从"家具市场”(Spring容器)中挑选合适的家具,将它们"搬运"并"摆放"到你的房间里,你拎包入住即可。
这个"公寓管理员"接管了所有家具(对象)的"创建"和"组装"工作,我们开发者从繁琐的对象创建和依赖管理中解放出来,这就是Spring框架最核心的理念——控制反转 (Inversion of Control, IoC)。我们不再控制对象的创建,而是将这个权力"反转"给了Spring。这不仅是开发方式的转变,更是一次思想上的解放。
B. 核心概念与快速入门 (IoC)
IoC与DI:一枚硬币的两面
初学者常常对IoC和DI(Dependency Injection,依赖注入)感到困惑。其实它们描述的是同一件事,只是角度不同:
- IoC (控制反转):是目标,是一种思想。它强调的是"谁控制谁"的问题。以前是我们自己代码控制依赖对象的创建,现在这个控制权交给了"第三方"(Spring容器)。
- DI (依赖注入):是实现IoC的具体方式。既然控制权交出去了,那我们的类如何获得它所依赖的对象呢?答案是"注入"。就像公寓管理员把家具"搬进"房间一样,Spring容器会把一个对象所依赖的其他对象"注入"到这个对象中。
所以,我们可以说:Spring通过依赖注入(DI)的方式,实现了控制反转(IoC)的设计思想。
快速入门:从new到@Autowired的进化
让我们通过一个简单的例子,直观感受一下Spring带来的变化。
场景:UserService(业务层)需要调用UserDao(数据访问层)来获取用户信息。
传统方式:
// 数据访问层
public class UserDao {
public String getUserById(String id) {
System.out.println("从数据库获取用户:" + id);
return "User" + id;
}
}
// 业务层
public class UserService {
// UserService 主动创建 UserDao,产生了强依赖
private UserDao userDao = new UserDao();
public void displayUser(String id) {
String userName = userDao.getUserById(id);
System.out.println("业务层展示用户:" + userName);
}
}
在上面的代码中,UserService和UserDao紧紧地"捆绑"在了一起。如果UserDao的构造函数发生变化,所有依赖它的类都需要修改。
Spring方式:
首先,我们告诉Spring哪些类需要它来管理。
// UserDao.java
@Repository // 或者 @Component,@Repository更语义化,表明是数据访问组件
public class UserDao {
public String getUserById(String id) {
System.out.println("从数据库获取用户:" + id);
return "User" + id;
}
}
// UserService.java
@Service // 或者 @Component,@Service更语义化,表明是业务逻辑组件
public class UserService {
// 声明一个依赖,但不去创建它
// Spring会自动找到一个UserDao类型的Bean并注入进来
@Autowired
private UserDao userDao;
public void displayUser(String id) {
String userName = userDao.getUserById(id);
System.out.println("业务层展示用户:" + userName);
}
}
看到变化了吗?UserService中那行刺眼的 new UserDao() 不见了!取而代之的是 @Autowired 注解。我们只是"声明"了需要一个UserDao,Spring就会在运行时自动地、动态地将一个UserDao的实例"注入"进来。代码变得更加简洁、清晰,最重要的是,UserService不再关心UserDao是如何被创建的,实现了解耦。
[嵌入学习环节] Bean的生命周期之旅
一个普通的Java对象,从new开始,到被垃圾回收结束,生命周期简单明了。但一个交由Spring管理的Bean,其生命周期则要丰富得多,就像一位经验丰富的演员,在登上舞台前后要经历一系列精心的准备(化妆、换装、对词)和谢幕流程。
以下是Bean生命周期的简化核心流程图:
关键节点说明:
- 实例化 (Instantiation): Spring容器根据配置信息,通过反射调用构造函数,创建Bean的实例。这仅仅是创建了一个"毛坯房",里面的家具(属性)还是空的。
- 填充属性 (Populate Properties): Spring检查Bean的依赖关系,使用依赖注入(如
@Autowired)将所需的其他Bean注入进来。这相当于把"家具"搬进了"毛坯房"。 - 初始化 (Initialization): 这是Bean生命周期中最重要的一环,提供了多个扩展点,让我们可以自定义Bean的行为。
- Aware接口: Spring会检查Bean是否实现了特定的
Aware接口(如BeanNameAware,ApplicationContextAware),如果实现了,就会把相应的资源(如Bean的名字、应用上下文)设置给它。 @PostConstruct: 这是JSR-250规范定义的注解。如果一个方法被此注解标记,它会在依赖注入完成后、Bean正式提供服务前被自动调用。这是执行自定义初始化逻辑的首选方式。InitializingBean接口: 实现该接口的afterPropertiesSet()方法也能达到同样的效果,但它与Spring框架耦合较紧,不如@PostConstruct通用。- BeanPostProcessor: 这是Spring的"终极武器",它能对容器中所有的Bean进行"批量加工"。它可以在初始化前后(
postProcessBeforeInitialization和postProcessAfterInitialization)对Bean执行任意操作,AOP的动态代理就是在这里实现的。
- Aware接口: Spring会检查Bean是否实现了特定的
- Bean可用 (In Use): 完成初始化后,Bean就进入了"服役"状态,可以被应用程序随时调用。
- 销毁 (Destruction): 当Spring容器关闭时,会进入销毁流程。
@PreDestroy: 同样是JSR-250规范的注解。被此注解标记的方法会在容器销毁Bean之前被调用,是执行资源释放、清理工作的首选方式。DisposableBean接口: 实现该接口的destroy()方法也能达到同样的效果,同样存在与Spring框架耦合的问题。
了解Bean的生命周期,可以帮助我们更精准地控制Bean的行为,在合适的时机执行代码,写出更健壮、更优雅的程序。
C. 动手实践与常用功能 (AOP)
AOP:优雅地"植入"功能
想象一下,你是一位小区物业经理,老板要求你给小区所有公寓的客厅都装上监控,记录家人每天看电视的时长。你该怎么做?
最笨的办法是,挨家挨户地去改造他们的客厅:砸墙、布线、装摄像头……这不仅工程浩大,而且会破坏原有的装修,住户肯定不会同意。
聪明的做法是,利用一种"黑科技",在每家客厅的"入口"(比如门口),悄无声息地加一个"时间记录仪"。家人进门时按一下开始计时,出门时再按一下结束计时。这样,你完全不需要改动客厅内部的任何结构,就完成了老板交代的任务。
这个"黑科技",在软件开发中就是面向切面编程 (Aspect-Oriented Programming, AOP)。它允许我们将那些与核心业务逻辑无关,但又散布在多个地方的通用功能(如日志记录、性能监控、事务管理、权限控制等),从业务代码中"抽离"出来,形成一个独立的"切面"(Aspect),然后在不修改业务代码本身的情况下,将这些功能"织入"到需要它们的地方。
AOP核心概念
- 连接点 (Join Point): 程序执行过程中的某个特定点,比如方法的调用、异常的抛出。在Spring AOP中,连接点总是方法的执行。
- 切点 (Pointcut): 一个"查询"表达式,它精确地定义了AOP要作用于哪些连接点。你可以把它看成是筛选条件。
- 通知 (Advice): 在切点匹配的连接点上要执行的具体操作。通知有多种类型:
Before: 在目标方法执行前执行。After: 在目标方法执行后执行(无论成功还是失败)。AfterReturning: 仅在目标方法成功执行后执行。AfterThrowing: 仅在目标方法抛出异常后执行。Around: 功能最强大的通知,它能完全"环绕"目标方法,你可以在方法执行前后自定义行为,甚至可以决定是否执行目标方法。
- 切面 (Aspect): 是切点和通知的结合体。它告诉AOP:“在什么地方(Pointcut),做什么事(Advice)”。
实战:计算Service层所有方法的执行时间
让我们用AOP来实现这个常见的需求。
第一步:定义切面类
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;
@Aspect // 1. 声明这是一个切面类
@Component // 2. 将这个切面交由Spring管理
public class PerformanceMonitorAspect {
// 3. 定义一个切点,匹配所有在.service包及其子包下的类的所有public方法
@Pointcut("execution(public * com.example.demo.service..*.*(..))")
public void serviceLayerMethods() {}
// 4. 定义一个环绕通知,并引用上面的切点
@Around("serviceLayerMethods()")
public Object measureExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
long startTime = System.currentTimeMillis();
// 执行目标方法
Object result = joinPoint.proceed();
long endTime = System.currentTimeMillis();
long executionTime = endTime - startTime;
// 打印日志
System.out.println(
"方法 [" + joinPoint.getSignature().toShortString() + "] " +
"执行耗时: " + executionTime + " ms"
);
return result;
}
}
代码解析:
@Aspect: 告诉Spring,PerformanceMonitorAspect不是一个普通的Bean,而是一个切面。@Component: 确保Spring能够扫描并创建这个切面的实例。@Pointcut: 定义了一个名为serviceLayerMethods的切点。execution(...)是切点表达式,这串"黑话"的含义是:public *: 匹配任何公共方法。com.example.demo.service..: 匹配com.example.demo.service包及其所有子包。*.*: 匹配任何类(第一个*)的任何方法(第二个*)。(..): 匹配任何数量、任何类型的参数。
@Around: 定义了环绕通知。ProceedingJoinPoint是AOP提供的一个特殊参数,通过它我们可以调用proceed()方法来手动执行被拦截的目标方法。
现在,你无需修改任何Service层的代码。只要应用启动,这个切面就会自动对所有符合切点规则的方法生效,在控制台打印出它们的执行时间。这就是AOP的魔力:在不侵入业务代码的前提下,实现功能的增强。
D. 深化理解与最佳实践
Bean的作用域:单例还是原型?
在Spring中,Bean并不仅仅只有一种存在形式。通过@Scope注解,我们可以指定Bean的作用域(Scope),决定了Spring如何创建和管理Bean实例。
singleton(单例): 默认作用域。在整个Spring容器中,一个Bean定义只会创建一个实例。每次请求该Bean时,容器返回的都是同一个对象。- 适用场景: 无状态的Bean,如
Service、Dao、Controller、配置类等。它们不存储与特定调用相关的数据,可以被多线程安全地共享。这是绝大多数场景下的选择。
- 适用场景: 无状态的Bean,如
prototype(原型): 每次向容器请求该Bean时,都会创建一个全新的实例。容器创建后交给你,就不再管理它的后续生命周期了。- 适用场景: 有状态的Bean,即那些需要保存每个用户或每次请求特定信息的对象。比如一个购物车对象,每个用户的购物车都必须是独立的。
request: 在一次HTTP请求的生命周期内,只会创建一个实例。仅适用于Web应用。session: 在一个HTTP Session的生命周期内,只会创建一个实例。仅适用于Web应用。application: 在整个ServletContext的生命周期内,只会创建一个实例。仅适用于Web应用。
选择正确的作用域,对于应用的性能和线程安全至关重要。
循环依赖:一个"先有鸡还是先有蛋"的问题
什么是循环依赖?
简单来说,就是A依赖B,同时B又依赖A。
@Service
public class AService {
@Autowired private BService bService;
}
@Service
public class BService {
@Autowired private AService aService;
}
当Spring试图创建AService时,发现它需要BService。于是Spring转而去创建BService,结果发现BService又需要AService。这就陷入了一个死循环,一个"我等你,你等我"的尴尬境地。
Spring如何解决(部分)循环依赖?
对于singleton作用域的Bean,如果它们是通过构造器注入发生循环依赖,Spring是无能为力的,会直接抛出异常。因为在创建对象时,构造函数必须被完整执行。
但对于setter注入或字段注入(如@Autowired),Spring通过一个巧妙的三级缓存机制解决了这个问题。我们可以这样概念性地理解:
- 一级缓存 (Singleton Objects): 存放已经完全初始化好的Bean,我们称之为"成品仓"。
- 二级缓存 (Early Singleton Objects): 存放已实例化但未初始化(未填充属性)的Bean,可以理解为"半成品仓"。这个"半成品"是一个代理对象(如果需要AOP代理的话),或者是原始对象。
- 三级缓存 (Singleton Factories): 存放一个"工厂对象",这个工厂的唯一作用就是生产某个Bean的(可能是代理后的)“半成品”。这是"半成品的图纸库"。
解决流程(简化版):
- 创建A时,先实例化A(调用构造函数),然后将"能创建A的半成品的工厂"放入三级缓存。
- 接着为A填充属性,发现它依赖B。
- 转而去创建B,同样,先实例化B,将"B的工厂"放入三级缓存。
- 为B填充属性,发现它依赖A。
- 此时,B去缓存中找A。它会从一级找到三级。最终在三级缓存中找到了"A的工厂"。
- B通过这个工厂,拿到了A的"半成品"(一个早期引用),并把自己创建完成(填充了A的半成品引用)。然后将"成品B"放入一级缓存。
- 回到A的创建流程,现在A可以成功拿到B的成品实例,完成自己的属性填充和初始化,最终也将"成品A"放入一级缓存。
通过提前暴露一个"半成品"的引用,Spring成功地打破了循环。
我们应该如何避免?
尽管Spring能解决部分循环依赖,但这通常是代码结构设计不良的信号。最佳实践是:
- 使用构造器注入: 它能让你在编码阶段就发现循环依赖问题(IDE会提示,程序启动会失败)。
- 重构代码: 审视A和B的职责,是否可以将它们共同依赖的部分抽象成一个新的C,让A和B都去依赖C,从而打破循环。
E. 总结与预告
1. 本章总结
在本章中,我们揭开了Spring框架的神秘面纱,深入探索了其两大核心支柱:控制反转 (IoC) 和 面向切面编程 (AOP)。
- 通过IoC,我们将对象的创建和管理权交给了Spring容器,利用依赖注入极大地降低了代码间的耦合度,使得系统更加灵活、易于维护。我们还了解了Bean从诞生到消亡的完整生命周期,学会了如何通过
@PostConstruct等注解在关键时刻介入其生命流程。 - 借助AOP,我们学会了将日志、监控等横切关注点从主业务逻辑中优雅地剥离,形成独立的"切面",在不修改源码的情况下增强了已有功能,让代码职责更单一、更清晰。
掌握了IoC和AOP,就等于掌握了现代Java开发的"任督二脉"。它们是构建复杂、可扩展、高质量应用的基础。
2. [嵌入学习环节] 代码重构练习
背景: 你接手了一个早期的遗留项目,该项目分为了Controller, Service, Dao三层,但未使用任何框架,所有对象都通过new关键字手动创建,层与层之间耦合严重。
项目结构 (伪代码):
// ProductDao.java
public class ProductDao {
public void getProduct() { /* ... */ }
}
// OrderDao.java
public class OrderDao {
public void createOrder() { /* ... */ }
}
// ProductService.java
public class ProductService {
private ProductDao productDao = new ProductDao();
public void findProduct() {
productDao.getProduct();
}
}
// OrderService.java
public class OrderService {
private OrderDao orderDao = new OrderDao();
private ProductService productService = new ProductService(); // 依赖其他Service
public void submitOrder() {
orderDao.createOrder();
productService.findProduct();
}
}
// OrderController.java
public class OrderController {
private OrderService orderService = new OrderService();
public void placeOrder() {
orderService.submitOrder();
}
}
你的任务:
请使用本章学习的Spring知识,对以上代码进行全面的"Spring化"改造。
要求:
- 移除所有
new关键字创建的依赖对象。 - 为
Controller,Service,Dao层的类添加合适的Spring注解(如@Controller,@Service,@Repository)。 - 使用
@Autowired注解实现依赖的自动注入。 - 创建一个
PerformanceMonitorAspect切面,要求能统计并打印出所有Service层方法的执行耗时。 - 思考并回答:改造后,
OrderService中的productService字段,Spring注入的是singleton还是prototype的实例?为什么?
这个练习将极大地巩固你对IoC和AOP的理解与应用能力。
3. 下章预告
Spring的功能虽然强大,但其XML配置曾一度让开发者头疼。幸运的是,一个名为Spring Boot的"脚手架"彻底改变了这一切。下一章,我们将见证Spring Boot如何实现**“约定优于配置”**,让开发变得如此简单快捷。准备好,我们将开启一段全新的、更高效的编码旅程!
第三章:Spring Boot - 起步依赖与自动配置的魔力
A. 引言
在上一章的结尾,我们经历了一场将MyBatis与Spring框架集成的"婚礼"。虽然最终它们幸福地走到了一起,但过程却不轻松:我们需要手动配置DataSource、SqlSessionFactoryBean,还要操心事务管理器,并用@MapperScan指定扫描路径…每一步都像是在小心翼翼地搭建精密的乐高积木,少一步、错一步,应用就无法启动。
这正是Spring家族在很长一段时间里面临的"甜蜜的烦恼":它功能强大,生态完善,但强大的灵活性也带来了配置的复杂性。搭建一个基础的Web项目,往往需要编写大量的XML或Java Config配置。我们不禁要问:难道就没有一种更简单的方式吗?难道框架不能"猜到"我想要什么,并帮我自动完成吗?
为了回应开发者的呼声,为了让Java开发重归"简单、快乐",Spring Boot应运而生。它的使命不是重新发明轮子,而是提供一套"约定优于配置"的开发模式,将开发者从繁琐的配置和依赖管理中彻底解放出来,让我们能更专注于业务逻辑本身。Spring Boot的出现,如同一位魔法师,轻轻一挥魔杖,便让复杂的Spring应用搭建过程变得妙趣横生。
B. 核心概念与快速入门
Spring Boot的魔力主要来源于两大核心:起步依赖 (Starters) 和 自动配置 (Auto-configuration)。
-
起步依赖 (Starters): 它像一个精心打包的"全家桶"。比如,你想开发一个Web应用,在过去,你需要分别引入SpringMVC、Tomcat、JSON处理等多个依赖,并头疼于它们之间可能存在的版本冲突。现在,你只需要引入一个
spring-boot-starter-web,这个"Web全家桶"就会自动将所有相关的、且版本兼容的依赖一次性全部带进来。开发者无需再关心琐碎的依赖列表和版本号,版本冲突的烦恼一扫而光。 -
自动配置 (Auto-configuration): 这是Spring Boot最神奇的地方。它会像一位聪明的管家,在启动时检查应用的"classpath"上有什么"食材"(jar包),然后自动"烹饪"出开发者可能需要的"菜肴"(配置好的Bean)。例如,当它看到classpath上有
spring-boot-starter-jdbc和数据库驱动,它就自动猜测你可能需要一个数据库连接池,于是它会帮你配置好一个DataSourceBean。整个过程悄无声息,顺理成章。
快速入门:五分钟拥有你的第一个Web应用
理论千遍,不如动手一遍。让我们通过官方提供的start.spring.io网站,快速创建一个Web项目。
-
访问
start.spring.io:- Project: 选择
Maven - Language: 选择
Java - Spring Boot: 选择一个稳定的版本 (例如 3.1.5,非SNAPSHOT版本)
- Project Metadata:
- Group:
com.example - Artifact:
demo - Name:
demo - Packaging:
Jar - Java:
17(或其他你已安装的LTS版本)
- Group:
- Dependencies: 点击 “ADD DEPENDENCIES…”,搜索并选择
Spring Web。 - 在Spring Initializr页面中,你会看到一个友好的Web界面,左侧是项目配置选项,右侧是依赖选择区域。界面简洁明了,即使是初学者也能轻松上手。
- Project: 选择
-
生成并下载项目: 点击 “GENERATE” 按钮,网站会生成一个完整的项目压缩包,下载并解压它。
-
编写一个Controller: 使用你喜欢的IDE(如IntelliJ IDEA或VS Code)打开项目。在
src/main/java/com/example/demo下创建一个controller包,并在包里新建一个HelloController.java文件。package com.example.demo.controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @RestController public class HelloController { @GetMapping("/hello") public String sayHello() { return "Hello, Spring Boot!"; } } -
运行它: 找到
DemoApplication.java文件,它包含一个main方法。直接像运行普通Java程序一样运行它。当看到控制台输出了Spring的Logo和Tomcat启动信息时,说明你的第一个Spring Boot应用已经成功运行了! -
验证: 打开浏览器,访问
http://localhost:8080/hello,你将看到页面上显示 “Hello, Spring Boot!”。
看,没有一行XML配置,没有复杂的依赖管理,一个功能完备的Web应用就这么简单地运行起来了。这就是Spring Boot的魅力。
C. 动手实践与常用功能
现在,让我们学以致用,将之前章节的"图书管理"项目改造为一个Spring Boot项目。
项目改造
假设我们已经整合好了Spring和MyBatis,现在要做的是"Boot化"。
-
pom.xml改造: 删除掉之前手动引入的spring-context, spring-webmvc, mybatis, mybatis-spring等依赖,替换为Spring Boot的起步依赖。
<!-- pom.xml --> <!-- 继承spring-boot-starter-parent,它定义了所有依赖的兼容版本 --> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.1.5</version> <relativePath/> <!-- lookup parent from repository --> </parent> <dependencies> <!-- Web应用起步依赖 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- MyBatis起步依赖 --> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>3.0.2</version> </dependency> <!-- MySQL驱动 --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <!-- 其他如Lombok等 --> </dependencies> -
删除配置类: 删掉我们之前手写的
DataSource,SqlSessionFactoryBean等Java配置类或XML配置文件。因为MyBatis的Starter会自动完成这一切! -
创建
application.yml: 在src/main/resources目录下,删除application.properties(如果存在),并创建一个名为application.yml的文件。YAML格式比传统的properties格式更简洁,层次感更强。
核心配置 (application.yml)
现在,我们只需要在application.yml中提供必要的配置信息即可。
# 服务器配置
server:
port: 8081 # 将服务器端口从默认的8080改为8081
# Spring配置
spring:
# 数据库连接信息
datasource:
url: jdbc:mysql://localhost:3306/your_database?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: your_password
driver-class-name: com.mysql.cj.jdbc.Driver
# MyBatis配置
mybatis:
# 指定Mapper XML文件的位置
mapper-locations: classpath:mappers/*.xml
# 为实体类指定别名,方便在XML中直接使用,而不用写完整的包名
type-aliases-package: com.example.model
# 开启驼峰命名自动映射,如数据库的user_name会自动映射到实体的userName属性
configuration:
map-underscore-to-camel-case: true
做完这些,启动主程序,你会发现,之前需要大量代码才能完成的整合工作,现在只需要一份清晰的配置文件就搞定了。
在代码中读取配置
将配置信息写入配置文件只是第一步,我们还需要在代码中读取它们。Spring Boot提供了两种主要方式:
-
@Value: 用于读取单个配置项,简单直接。import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @Component public class AppInfo { @Value("${server.port}") private String serverPort; public void printPort() { System.out.println("当前应用运行的端口是:" + serverPort); } } -
@ConfigurationProperties: 当需要映射一组相关的配置到一个Java对象时,这是更好的选择。它支持更复杂的结构,并且有更好的类型安全和IDE提示。1. 在
application.yml中添加自定义配置:app: info: name: "图书管理系统" version: "1.0.0" author: "TechEvangelist"2. 创建一个配置属性类:
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; @Component @ConfigurationProperties(prefix = "app.info") // 绑定前缀为app.info的配置 public class AppProperties { private String name; private String version; private String author; // Getters and Setters ... }现在,你可以在任何需要的地方注入
AppProperties这个Bean,并直接使用它的属性。
使用Profile进行环境隔离
在实际开发中,我们通常有多套环境:开发(dev)、测试(test)、生产(prod)。不同环境的数据库地址、服务器端口等配置都不相同。Spring Boot的Profile功能可以完美解决这个问题。
约定大于配置,我们只需按application-{profile}.yml的格式创建不同环境的配置文件。
application-dev.yml(开发环境配置)server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/db_dev username: root password: dev_passwordapplication-prod.yml(生产环境配置)server: port: 80 spring: datasource: url: jdbc:mysql://prod_server:3306/db_prod username: prod_user password: prod_password- 在主配置文件
application.yml中激活Profilespring: profiles: active: dev # 激活dev配置
启动应用时,Spring Boot会先加载application.yml,然后根据spring.profiles.active的值,加载对应的application-dev.yml。后者的配置会覆盖前者的相同配置项。这样,我们只需要修改一个active属性,就能轻松地在不同环境间切换,代码无需任何改动。
D. 深化理解与最佳实践
探秘自动配置原理
Spring Boot的自动配置看似神奇,但其原理清晰明了。核心在于@SpringBootApplication这个注解。
@SpringBootApplication
public class DemoApplication {
// ...
}
@SpringBootApplication其实是一个复合注解,它包含了@EnableAutoConfiguration。这个注解告诉Spring Boot开启自动配置功能。
@EnableAutoConfiguration: 它会利用Spring的ImportSelector机制,导入一个AutoConfigurationImportSelector类。AutoConfigurationImportSelector: 这个类的作用,就是去扫描所有jar包中符合特定规则的自动配置类。- Spring Boot 2.7以前: 它会去扫描所有jar包的
META-INF/spring.factories文件,这个文件里列出了所有可用的自动配置类 (org.springframework.boot.autoconfigure.EnableAutoConfiguration=\...)。 - Spring Boot 3.0以后: 为了提升性能,
spring.factories机制被废弃。取而代之的是,框架会去扫描所有jar包下的META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件。这个新文件的格式更简单,每行就是一个自动配置类的全限定名,解析速度更快。
- Spring Boot 2.7以前: 它会去扫描所有jar包的
- 条件注解 (
@ConditionalOn...): 每一个自动配置类(例如DataSourceAutoConfiguration)都不是无条件生效的。它们通常被@ConditionalOn...系列注解所标记,例如:@ConditionalOnClass({ DataSource.class, EmbeddedDatabaseType.class }): 只有当classpath下存在DataSource类时,此配置才生效。@ConditionalOnMissingBean(DataSource.class): 只有当用户自己没有定义一个DataSource类型的Bean时,此配置才生效。
总结一下自动配置的流程:Spring Boot启动 -> @EnableAutoConfiguration生效 -> 扫描所有jar包的.imports文件,加载大量XxxAutoConfiguration类 -> 根据@ConditionalOn...注解的条件判断,决定哪些配置类生效 -> 生效的配置类会自动配置好所需的Bean。正是这种"按需加载、用户优先"的策略,使得自动配置既强大又灵活。
自定义一个Starter
理解了原理后,我们甚至可以自定义一个Starter,将我们自己的功能打包分享给他人。例如,创建一个my-info-starter,它能在项目启动时自动打印项目的基本信息。
一个Starter通常包含两个模块:
-
my-info-autoconfigure: 负责自动配置的核心逻辑。- 创建一个
MyInfoProperties类,用于接收配置。 - 创建一个
MyInfoAutoConfiguration类,它读取MyInfoProperties,并配置一个CommandLineRunnerBean,让它在启动时打印信息。 - 在
src/main/resources/META-INF/spring/下创建org.springframework.boot.autoconfigure.AutoConfiguration.imports文件,并写入MyInfoAutoConfiguration的全路径。
- 创建一个
-
my-info-starter: 这是一个空壳项目,它只做一件事:通过pom.xml依赖my-info-autoconfigure模块和其他必要的依赖。用户只需要引入这一个starter,就能获得所有功能。
这个过程虽然不复杂,但能极大地加深我们对Spring Boot自动配置原理的理解。
E. 总结与预告
1. 本章总结
在本章中,我们深入探索了Spring Boot的魔力世界。我们了解到,Spring Boot并非一种新技术,而是Spring框架的一位"效率管家"。它通过起步依赖(Starters) 解决了繁琐的依赖管理和版本冲突问题,又通过自动配置(Auto-configuration),智能地免去了绝大部分的样板化配置。我们亲手创建了一个Web应用,将现有项目改造为Spring Boot项目,并掌握了在application.yml中进行核心配置、通过Profile管理多环境、以及使用@Value和@ConfigurationProperties读取配置的核心技能。最后,我们还揭开了自动配置的神秘面纱,为今后更深入地使用和扩展Spring Boot打下了坚实的基础。
2. [嵌入学习环节] 迷你项目
任务:将第一章结尾的"博客文章管理"项目,用今天所学的知识彻底重写一遍。
要求:
- 使用
start.spring.io创建一个全新的Spring Boot项目。 - 整合
mybatis-spring-boot-starter来操作数据库。 - 在
application.yml中完成所有配置,不要有任何XML或Java配置类。 - 创建
application-dev.yml和application-prod.yml两个配置文件,分别配置连接到本地开发数据库和线上生产数据库(地址可以虚构)。 - 通过在主配置文件中切换
spring.profiles.active的值,来验证不同环境的配置能够被正确加载。
3. 下章预告
我们的应用程序已经能快速运行起来了,但它就像一座没有门禁的大楼,任何人都可以随意进出和破坏。是时候给它装上坚固的安保系统了!下一章,我们将学习Spring Security,为我们的应用保驾护航。
第四章:Spring Security - 为你的应用穿上盔甲
引言
想象一下,你的应用程序是一栋重要的大楼。不是任何人都能随意进出的。
首先,门口的保安需要确认你的身份。他会问:“你是谁?” 这时你需要出示你的身份证或工牌。这个核对身份的过程,就是 认证(Authentication)。保安需要确保你是你所声称的那个人。
身份确认后,你进入了大楼,但你并非所有楼层都能去。你的门禁卡可能只授权你进入办公区,而无法进入顶层的CEO办公室或地下的机房。你能在哪些区域活动,取决于你的身份和职位被授予的权限。这个决定你能做什么、能去哪里的过程,就是 授权(Authorization)。
在网络世界里,Spring Security 就是我们应用程序大楼的"首席安全官"。它强大而灵活,既负责"认证"这道大门,也精通"授权"这套内部访问控制体系,为我们的应用保驾护航。
本章,我们将一起学习如何聘请并配置好这位"安全官",为我们的应用穿上坚不可摧的盔甲。
核心概念与快速入门
过滤器链 (Filter Chain) - Spring Security 的"安检通道"
Spring Security 的核心就是一串过滤器(Filter)。当一个请求(Request)发往你的应用时,它必须先依次通过这条"安检通道",然后才能到达目的地(你的Controller)。每个过滤器都有特定的职责,有的负责检查登录状态,有的负责处理特定登录逻辑,有的负责检查访问权限。
这个安检通道就是 过滤器链(Filter Chain)。下面这张图清晰地展示了用户请求经过核心过滤器的流程:
核心过滤器解析:
SecurityContextPersistenceFilter: 这是"记忆"过滤器。它在请求开始时,尝试从HttpSession中加载之前已认证成功的用户信息(SecurityContext),并在请求结束时将可能发生变化的SecurityContext存回去。这样用户就不必每次请求都登录了。UsernamePasswordAuthenticationFilter: 这是处理"表单登录"的专家。它默认会拦截指向/login的POST请求,从请求中抓取用户名和密码,然后交给AuthenticationManager去进行认证。ExceptionTranslationFilter: “异常处理调度员”。它不处理认证,但它会捕获后续过滤器抛出的安全异常。如果是认证失败(AuthenticationException),它会引导用户去登录;如果是权限不足(AccessDeniedException),它会返回一个"拒绝访问"的响应(通常是 403 错误)。FilterSecurityInterceptor: 这是"最终决策者",也是过滤器链的最后一站。它根据我们配置的访问规则(例如,哪个URL需要什么角色),来判断当前已认证的用户是否有权访问请求的资源。如果无权,它就会抛出AccessDeniedException。
快速入门:一键开启全面防护
Spring Boot 的美妙之处在于其"约定优于配置"。让我们来感受一下 Spring Security 的"一键启动"魔力。
假设你有一个现成的 Spring Boot 项目,现在你只需在 pom.xml 中加入一个依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
什么都不要配置!
现在,直接启动你的项目。然后尝试用浏览器访问你项目中的任意一个接口,比如 http://localhost:8080/api/books。
你会惊奇地发现,你被自动重定向到了一个默认的登录页面。你的所有接口,瞬间都被保护起来了!这就是 Spring Security 的威力,它提供了一个非常安全的默认配置,防止你意外地暴露任何不设防的接口。
动手实践与常用功能
默认的登录页和用户(用户名是 user,密码在启动日志里)虽然方便,但在真实世界中远远不够。我们需要自定义认证和授权的逻辑。
自定义认证
1. 从内存中读取用户(用于测试)
在真实开发前,我们经常使用内存用户进行快速测试。我们需要创建一个SecurityConfig类,并覆盖默认配置。
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import static org.springframework.security.config.Customizer.withDefaults;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public UserDetailsService userDetailsService() {
// 创建一个基于内存的用户信息管理器
UserDetails user = User.withDefaultPasswordEncoder() // 注意:withDefaultPasswordEncoder() 已过时,仅用于演示
.username("user")
.password("password123")
.roles("USER")
.build();
UserDetails admin = User.withDefaultPasswordEncoder()
.username("admin")
.password("password123")
.roles("ADMIN", "USER") // admin 同时拥有 ADMIN 和 USER 角色
.build();
return new InMemoryUserDetailsManager(user, admin);
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.anyRequest().authenticated()
)
.formLogin(withDefaults()) // 开启表单登录,使用默认配置
.httpBasic(withDefaults()); // 开启 HTTP Basic 认证
return http.build();
}
}
注意:
User.withDefaultPasswordEncoder()是一个已过时不推荐使用的方法,因为它使用的密码编码器noop,即不加密,非常不安全。我们稍后会用BCryptPasswordEncoder替代它。
2. 从数据库中读取用户(生产级方案)
这才是真实项目中采用的方式。核心是实现 UserDetailsService 接口。Spring Security 会在需要用户信息时,调用这个接口的 loadUserByUsername 方法。
第一步:创建 JpaUserDetailsService
这个服务会注入你的UserRepository(JPA仓库),通过它来查询数据库。
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
public class JpaUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public JpaUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 你的 User 实体需要映射成 Spring Security 的 UserDetails 对象
return userRepository.findByUsername(username)
.map(SecurityUser::new) // SecurityUser 是一个实现了 UserDetails 接口的包装类
.orElseThrow(() -> new UsernameNotFoundException("用户不存在: " + username));
}
}
SecurityUser是一个你需要自己创建的类,它包装了你的领域用户实体(User),并实现了UserDetails接口,将实体中的username,password,roles等字段提供给Spring Security。
第二步:配置 Spring Security 使用你的 Service
在SecurityConfig中,你需要告诉 Spring Security 两件事:
- 使用你自定义的
JpaUserDetailsService。 - 使用一个强大的密码编码器,而不是明文。
// ... 在 SecurityConfig.java 中 ...
@Bean
public PasswordEncoder passwordEncoder() {
// 使用 BCrypt 强哈希函数
return new BCryptPasswordEncoder();
}
// Spring Security 会自动找到你实现了 UserDetailsService 的 Bean,无需显式配置。
// 但如果你有多个 UserDetailsService 的 Bean,则需要指定使用哪一个。
自定义授权
现在我们能从数据库登录了,但所有页面都需要登录才能访问。我们需要更精细的控制。
1. 配置公开和受保护的路径
假设我们希望/首页和/public/**下的所有路径都可以匿名访问,而其他所有路径都需要登录。
// ... 在 SecurityConfig.java 的 filterChain 方法中 ...
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/public/**").permitAll() // permitAll() 表示允许所有用户(包括匿名)访问
.anyRequest().authenticated() // 其他任何请求都需要认证
)
// ... 其他配置
return http.build();
}
2. [嵌入学习环节] 实现 RBAC 场景
RBAC(Role-Based Access Control)是企业应用中最常见的权限模型。让我们实现一个经典场景:
- 管理员接口(
/api/admin/**)只有 “ADMIN” 角色的用户能访问。 - 普通用户接口(
/api/user/**)只要登录了(拥有任意角色)就能访问。
// ... 在 SecurityConfig.java 的 filterChain 方法中 ...
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/admin/**").hasRole("ADMIN") // .hasRole() 会自动为你添加 "ROLE_" 前缀
.requestMatchers("/api/user/**").authenticated()
.anyRequest().permitAll() // 将其他所有请求(如首页)设置为公开访问
)
// ... 其他配置
return http.build();
}
重要提示:
hasRole("ADMIN")方法在检查时,会自动寻找名为ROLE_ADMIN的权限。因此,在你的数据库或UserDetailsService实现中,授予用户的权限字符串应该是ROLE_ADMIN,或者直接是ADMIN然后在hasRole这里使用它。为保持一致性,建议在数据库中就存储完整的名称,如ROLE_ADMIN。
深化理解与最佳实践
密码千万条,安全第一条
永远、永远、永远不要明文存储密码! 如果数据库泄露,所有用户的账户都将瞬间暴露。
我们必须使用一种"单向加密"或"哈希算法"来存储密码。这意味着,密码可以被加密成一串无法被逆向解密的字符串(哈希值)。当用户登录时,我们只需将用户输入的密码用同样的算法加密一次,然后比较两个哈希值是否相等即可。
BCrypt是一种专为密码存储设计的、非常强大的哈希算法。它的一大优点是"慢",这使得黑客即使拿到了哈希值,想要通过暴力破解(不断尝试不同密码并计算哈希值)的成本也极高。
在 Spring Security 中,启用它非常简单,只需在配置类中声明一个PasswordEncoder的 Bean:
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
当这个 Bean 存在时,Spring Security 会自动使用它来:
- 匹配密码:在认证过程中,将用户提交的原始密码与数据库中存储的BCrypt哈希值进行比较。
- 加密密码:你可以在用户注册时,注入
PasswordEncoder来加密密码后再存入数据库。// 在你的用户服务(UserService)中 public void registerUser(UserDto userDto) { User newUser = new User(); newUser.setUsername(userDto.getUsername()); // 加密密码 newUser.setPassword(passwordEncoder.encode(userDto.getPassword())); userRepository.save(newUser); }
CSRF 攻击:你看不见的威胁
CSRF (Cross-Site Request Forgery, 跨站请求伪造) 是一种常见的Web攻击。简单来说,攻击者会诱导你(一个已登录的合法用户)在不知情的情况下,点击一个恶意链接或访问一个恶意网站,该网站会向你的应用发送一个伪造的请求,例如"转账1000元到攻击者账户"。因为这个请求是你浏览器发出的,所以它会带上你的登录凭证(如Cookie),服务器会误以为这是你的真实操作。
Spring Security 如何防御?
Spring Security 默认开启了 CSRF 防护。它的原理是:
- 当用户访问一个页面(特别是包含表单的页面)时,Spring Security 会生成一个独一无二的、随机的 CSRF 令牌(Token)。
- 这个令牌会作为隐藏字段(默认名
_csrf)嵌入到页面的所有表单中。 - 当用户提交表单(或发起POST/PUT/DELETE等会改变状态的请求)时,必须同时带上这个 CSRF 令牌。
- 服务器在收到请求时,会验证这个令牌是否匹配。如果不匹配,或没有令牌,请求将被拒绝。
由于攻击者的恶意网站无法得知这个随机生成的令牌,因此他们伪造的请求就会因为缺少合法令牌而被拦截。你无需进行额外配置,Spring Security 已经默默为你做好了这一切。
总结与预告
本章总结
恭喜你,你已经掌握了Spring Security的核心武器!我们从 认证 与 授权 的基本概念出发,深入理解了作为其架构核心的 过滤器链。我们亲自动手,将默认的安全配置替换为从内存和数据库中加载用户的 自定义认证,并学习了如何通过配置实现基于URL和角色的 精细化授权。最后,我们还探讨了 BCrypt密码加密 和 CSRF防护 这两大重要的安全实践,为我们的应用构建了坚实的防线。
[嵌入学习环节] 综合实战
理论终须实践。现在,请为我们之前的 "图书管理"Spring Boot项目 集成Spring Security,实现以下需求:
- 自定义登录页面: 创建一个自己的HTML登录页面,替代Spring Security的默认页面。
- 数据库认证: 用户信息(用户名、密码、角色)存储在数据库中,应用启动时,可以自动创建两个用户:
admin / admin123,角色为ADMINuser / user123,角色为USER
- 权限控制:
- 拥有
ADMIN角色的用户,可以对图书进行增、删、改、查所有操作。 - 拥有
USER角色的用户,只能进行查询图书操作。 - 首页和登录页允许所有人访问。
- 拥有
这个实战将完美地把本章所有核心知识点串联起来,是你迈向安全应用开发的重要一步。
下章预告
恭喜你!到目前为止,你已经掌握了构建一个现代化Java单体应用的四大核心框架:MyBatis、Spring、Spring Boot与Spring Security。你的"大楼"已经不仅能拔地而起,而且坚固、安全、功能齐全。
但是,当用户量激增,请求像潮水般涌来,单体应用开始感到压力,响应变慢,这时我们该怎么办?我们需要探索新的性能优化之道。
下一阶段,我们将深入Java的"内功心法"——并发编程,学习如何唤醒沉睡的CPU核心,榨干硬件的每一分性能,让你的应用从容应对高并发的挑战!
第五章:并发编程核心 - JMM、synchronized与volatile
A. 引言
想象一下,一个繁忙的五星级餐厅厨房,多位顶级大厨正在同时烹饪各自的拿手好菜。这就像一个高性能服务器,在同一时间运行着多个线程。
起初,一切似乎都井然有序。但很快,问题出现了:
- 资源竞争:A厨师想用唯一的顶级料理台(资源),B厨师也正好需要,两人互不相让,都无法继续工作。
- 信息不一致:A厨师往汤里加了盐,但没有通知B厨师。B厨师尝了一口,觉得味道淡,又加了一份盐,结果汤就咸得没法喝了。
这正是我们在并发编程中面临的挑战:多个线程(厨师)在共享数据(食材、厨具)时,如果缺乏有效的协调机制,就会导致数据错乱(菜做砸了)和程序崩溃(厨房大乱)。本章,我们将深入Java并发的底层,从Java内存模型(JMM)出发,掌握volatile和synchronized这两个最核心的工具,确保我们的多线程程序能像一支配合默契的顶级厨师团队一样,高效、准确地完成任务。
B. 核心概念与快速入门
Java内存模型 (JMM) 与可见性问题
要理解并发问题,首先要明白Java代码是如何在计算机中运行的。在Java虚拟机(JVM)中,定义了一个"Java内存模型"(JMM),它规定了线程如何与主内存(Main Memory)以及各自的工作内存(Working Memory)进行交互。
- 主内存 (Main Memory):所有线程共享的区域,存储了所有的实例字段、静态字段和数组元素。
- 工作内存 (Working Memory):每个线程私有的区域,存储了该线程需要用到的主内存变量的副本。线程对变量的所有操作(读取、赋值等)都必须在自己的工作内存中进行,不能直接读写主内存。
它们的关系可以用下图表示:
可见性问题就源于这种模型。当一个线程修改了共享变量后,它首先更新的是自己工作内存中的副本,然后才在某个不确定的时机将其写回主内存。在此期间,其他线程无法"看到"这个最新的值,它们仍然在使用自己工作内存中过期的副本。
让我们看一个直观的例子:
public class VisibilityProblem {
// 共享标志位
private static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
System.out.println("子线程等待 flag 变为 false...");
while (flag) {
// 因为JMM的可见性问题,子线程可能永远无法读到主线程对flag的修改
// 导致CPU空转,陷入死循环
}
System.out.println("子线程结束。");
}).start();
// 确保子线程已经启动并进入while循环
Thread.sleep(1000);
System.out.println("主线程将 flag 修改为 false。");
flag = false;
}
}
运行以上代码,你会惊奇地发现,程序很可能永远不会结束!主线程明明已经将flag改为了false,但子线程却像没看见一样,一直在while(flag)循环里空转。这就是典型的可见性问题。
volatile:保证可见性的轻量级武器
如何解决这个问题?Java提供了一个非常方便的关键字:volatile。
volatile被称为"轻量级的同步机制",它能确保对一个变量的修改对所有线程都是立即可见的。我们只需给上面的flag变量加上volatile:
// 使用 volatile 保证共享变量的可见性
private static volatile boolean flag = true;
现在,再次运行程序。你会看到,主线程修改flag后,子线程几乎立刻就停止了循环,程序正常结束。
volatile的原理是什么?
它主要做了两件事:
- 强制写回主存:当一个线程修改
volatile变量时,JMM会强制将该线程工作内存中的值立即写回到主内存。 - 强制从主存读取:当一个线程读取
volatile变量时,JMM会使该线程工作内存中的副本失效,强制它重新从主内存中加载最新值。
通过这一写一读的强制同步,volatile保证了共享变量在多线程之间的可见性。
原子性与count++的陷阱
我们解决了可见性,但并发编程还有另一个棘手的问题:原子性。
原子性指的是一个或多个操作,要么全部执行成功,要么全部不执行,中间不能被任何其他操作打断。
一个最经典的非原子性操作就是count++。我们以为它是一步完成的,但在JVM层面,它至少包含了三步操作:
getfield: 从主内存读取count的值到工作内存。iadd: 在工作内存中执行+1操作。putfield: 将计算后的新值写回主内存。
如果两个线程同时对count(初始值为0)执行count++,可能发生以下情况:
- 线程A读取
count,得到0。 - 线程B也读取
count,同样得到0。 - 线程A执行
+1,得到1。 - 线程B执行
+1,也得到1。 - 线程A将1写回主内存,
count变为1。 - 线程B也将1写回主内存,
count仍然是1。
我们期望的结果是2,但最终结果却是1。这就是线程安全问题。volatile只能保证每次读取的是最新值,但无法保证"读-改-写"这个复合操作的原子性。要解决原子性问题,我们需要更强大的工具:synchronized。
C. 动手实践与常用功能
synchronized:保证原子性的重量级武器
synchronized是Java中用于实现原子性和可见性的内置锁机制。它可以修饰方法或代码块,确保在同一时刻,只有一个线程可以执行被它保护的代码。
让我们用一个计数器实验来感受一下它的威力。
1. 创建一个线程不安全的计数器
public class UnsafeCounter {
private int count = 0;
public void increment() {
count++;
}
public int getCount() {
return count;
}
}
2. 编写多线程测试代码
我们使用1000个线程,每个线程对计数器increment100次,理论上最终结果应该是1000 * 100 = 100000。为了确保所有线程都执行完毕再看结果,我们使用CountDownLatch这个并发工具进行协调。
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class CounterTest {
public static void main(String[] args) throws InterruptedException {
final int threadCount = 1000;
final UnsafeCounter counter = new UnsafeCounter();
final CountDownLatch latch = new CountDownLatch(threadCount);
// 创建一个固定大小的线程池
ExecutorService executorService = Executors.newFixedThreadPool(100);
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
for (int j = 0; j < 100; j++) {
counter.increment();
}
latch.countDown(); // 每个线程完成后,计数器减1
});
}
latch.await(); // 主线程等待,直到所有子线程执行完毕
executorService.shutdown();
System.out.println("期望结果: 100000");
System.out.println("实际结果: " + counter.getCount());
}
}
多次运行上述代码,你会发现,"实际结果"几乎总是一个小于100000的随机数,完美复现了我们之前分析的原子性问题。
[嵌入学习环节]
任务:修复上述UnsafeCounter,使其线程安全。
提示:你有两种方法可以修复它。
- 使用
synchronized修饰方法:这是最简单的方式。 - 使用
synchronized(this)代码块:将非原子操作包裹起来。
修复方案1:synchronized修饰方法
只需在increment方法上加上synchronized关键字。
// ...
public synchronized void increment() {
count++;
}
// ...
修复方案2:synchronized代码块
只锁定count++这一行关键代码。
// ...
public void increment() {
synchronized (this) {
count++;
}
}
// ...
用以上任意一种方式修改UnsafeCounter后,再次运行CounterTest,你会发现无论运行多少次,最终结果都是精确的100000。synchronized通过加锁,保证了count++操作的原子性,解决了线程安全问题。
D. 深化理解与最佳实践
synchronized的底层原理:对象监视器锁(Monitor)
synchronized是如何实现锁的?这要归功于Java对象头中的一个重要部分和JVM层面的Monitor(监视器锁)。
- 锁的是对象,不是代码:当你使用
synchronized(this)时,锁住的是this这个对象实例。当使用synchronized修饰方法时,对于非静态方法,锁住的也是this对象实例;对于静态方法,锁住的是当前类的Class对象。 - Monitor机制:每个Java对象都可以关联一个
Monitor。当一个线程试图进入一个synchronized保护的代码块时,它必须先获得该对象关联的Monitor的所有权。如果获取成功,它就可以执行代码;如果Monitor已被其他线程持有,该线程就会被阻塞,并放入该Monitor的等待队列中,直到持有锁的线程释放Monitor。
理解"锁的是对象"至关重要。这意味着,如果两个线程试图同时进入同一个对象的两个不同synchronized方法,只有一个能成功,另一个必须等待。但如果它们操作的是不同对象的synchronized方法,则可以并行执行,互不影响。
volatile 与 synchronized 的区别
现在,我们来对比一下这两个关键字,以便在合适的场景做出正确的选择。
| 特性 | volatile | synchronized |
|---|---|---|
| 作用机制 | 强制主存读写 | 对象监视器锁(Monitor) |
| 保证特性 | 仅可见性(不保证原子性) | 可见性 + 原子性 |
| 性能开销 | 轻量级,性能开销小 | 重量级,涉及线程阻塞和唤醒,性能开销大 |
| 使用粒度 | 只能修饰变量 | 可以修饰方法和代码块 |
| 是否阻塞 | 不阻塞 | 会导致线程阻塞 |
最佳实践:
- 当你只需要保证一个共享变量的可见性时(例如,一个线程写,多个线程读的标志位),优先使用
volatile,因为它更轻量。 - 当你需要保证一个代码块的原子性(例如,
count++这种复合操作),或者需要同时保证可见性和原子性时,必须使用synchronized。
E. 总结与预告
1. 本章总结
本章我们从并发问题的根源——Java内存模型(JMM)——出发,理解了由主内存和工作内存分离导致的可见性问题。接着,我们学习了两个核心关键字:
volatile:一个轻量级的解决方案,通过强制与主内存同步,保证了共享变量的可见性。synchronized:一个重量级的解决方案,通过Monitor锁机制,不仅保证了可见性,更重要的是保证了代码块的原子性,是解决线程安全问题的瑞士军刀。
掌握了这两个工具,你就拥有了编写基本线程安全程序的核心能力。
2. [嵌入学习环节] Debug调试任务
下面的代码存在一个严重的并发问题:一个线程获取锁后长时间不释放,导致其他线程永远无法执行。请你找出问题所在并修复它。
public class DeadLockSimulation {
private static final Object lock = new Object();
public static void main(String[] args) {
// 线程A:获取锁后长时间持有
new Thread(() -> {
synchronized (lock) {
System.out.println("Thread A acquired lock.");
// 模拟一个耗时操作或错误逻辑,长时间不释放锁
while (true) {
// Oops, forgot to release the lock!
}
// System.out.println("Thread A released lock."); // 这行代码永远无法到达
}
}, "Thread A").start();
// 线程B:等待获取锁
new Thread(() -> {
System.out.println("Thread B is waiting for lock...");
synchronized (lock) {
System.out.println("Thread B acquired lock.");
// ... do something
}
System.out.println("Thread B finished.");
}, "Thread B").start();
}
}
/*
* 任务:
* 1. 运行代码,观察到 "Thread B is waiting for lock..." 后程序卡住。
* 2. 分析原因:为什么Thread B无法获取锁?
* 3. 修复方案:修改Thread A的代码,让它在完成任务后能够正确释放锁。
* (提示:在真实场景中,可能是因为循环条件错误或在同步块中执行了无限等待的IO操作)
*
* 修复后的期望输出:
* Thread A acquired lock.
* (Thread A 任务完成并释放锁)
* Thread B is waiting for lock...
* Thread B acquired lock.
* Thread B finished.
*/
答案剖析:
问题在于线程A在synchronized(lock)块中进入了一个while(true)死循环,导致它永远不会退出同步块,从而永远不会释放lock对象的Monitor锁。线程B在请求同一个锁时,会无限期地阻塞等待。修复方法是打破这个死循环,例如将其改为一个有终止条件的循环或直接移除。
3. 下章预告
synchronized虽然强大,但它像一把"非黑即白"的锁,要么拿到锁执行,要么无限期等待,不够灵活。如果我们想要更精细的锁控制,比如:
- 可中断的等待:如果等了太久还拿不到锁,就不等了,先去做别的事。
- 可尝试的获取:试一下能否拿到锁,拿不到立刻返回
false,而不是阻塞。 - 公平的竞争:让等待时间最长的线程优先获得锁,而不是随机抢占。
这些高级功能,synchronized都无法实现。下一章,我们将学习JUC(java.util.concurrent)包下的核心工具——Lock接口及其实现类(如ReentrantLock),以及更强大的线程协作工具Condition,进入一个更加灵活和强大的并发编程世界。
第六章:更灵活的锁 - Lock、Condition与AQS思想
A. 引言
在前面的章节中,我们已经熟练掌握了synchronized关键字,它是Java并发编程的基石,能够解决绝大多数场景下的线程安全问题。然而,synchronized并非万能药。它更像是一把"自动挡"的锁,简单易用,但灵活性不足。
它的局限性主要体现在:
- 不可中断:一个线程在等待
synchronized锁时,除了JVM因异常或正常退出,否则无法被中断,只能死等,这在某些情况下会降低系统的响应性。 - 非公平性:
synchronized是一种非公平锁,JVM不保证等待时间最长的线程能优先获得锁,可能会导致某些线程长时间"饥饿"。 - 功能单一:一把
synchronized锁只能关联一个等待/通知队列。如果我们有更复杂的线程协作需求(例如,生产者-消费者模型中,需要精确唤醒特定类型的消费者),synchronized就显得力不从心了。
为了解决这些问题,JUC(java.util.concurrent)包的设计者们为我们提供了Lock接口。Lock是一种"手动挡"的锁,它提供了比synchronized更强大、更灵活的锁机制,允许我们实现更复杂的并发控制策略。本章,我们将一起揭开Lock接口的神秘面纱。
B. 核心概念与快速入门
ReentrantLock: 一个可重入的实现
ReentrantLock是Lock接口最经典的实现,它和synchronized一样,也是一个可重入的互斥锁。
标准使用范式
使用ReentrantLock,我们必须遵循一个严格的模板,以确保锁的正确释放,尤其是在面对异常时。
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
class Counter {
private final Lock lock = new ReentrantLock();
private int count = 0;
public void increment() {
// 1. 获取锁
lock.lock();
try {
// 2. 在 try-finally 块中执行受保护的代码
count++;
System.out.println(Thread.currentThread().getName() + " -> " + count);
} finally {
// 3. 必须在 finally 块中释放锁
lock.unlock();
}
}
}
黄金法则: lock()调用必须紧跟try-finally块,并且unlock()方法必须在finally子句的第一行被调用。这是为了保证即使业务逻辑(try块内)抛出异常,锁也能被确定性地释放,避免造成死锁。
"可重入"特性演示
"可重入"意味着一个线程已经持有该锁,可以再次成功获取该锁而不会被自己阻塞。
class ReentrantExample {
private final ReentrantLock lock = new ReentrantLock();
public void outerMethod() {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + " executed outerMethod.");
// 在持有锁的情况下,调用另一个需要相同锁的方法
innerMethod();
} finally {
lock.unlock();
}
}
public void innerMethod() {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + " executed innerMethod.");
} finally {
// 注意:每次加锁都必须对应一次解锁
lock.unlock();
}
}
}
// 调用
// new ReentrantExample().outerMethod();
// 输出:
// Thread-0 executed outerMethod.
// Thread-0 executed innerMethod.
在上面的例子中,outerMethod获取了锁,然后调用了innerMethod。因为ReentrantLock是可重入的,所以在innerMethod中线程可以再次成功获取已经被自己持有的锁。ReentrantLock内部维护了一个计数器,每次加锁计数加一,每次解锁减一,直到计数器归零,锁才被真正释放。
AQS思想:构建JUC同步器的基石
ReentrantLock的底层是基于AQS(AbstractQueuedSynchronizer,抽象队列同步器) 实现的。AQS是JUC包的灵魂,许多同步工具(如Semaphore, CountDownLatch)都依赖于它。
我们不必深入源码,但理解其核心思想至关重要。AQS的设计精髓可以概括为:一个整型状态变量(state)+ 一个FIFO双向等待队列。
graph TD
subgraph AQS 核心思想
A[state (volatile int)] -- 控制同步状态 --> B{线程尝试获取锁}
B -- "成功 (CAS更新state)" --> D[<center>线程获得锁<br/>执行业务逻辑</center>]
B -- "失败" --> C[<center>线程被封装成Node<br/>加入FIFO等待队列并挂起</center>]
subgraph "FIFO 等待队列 (CLH Queue)"
direction LR
HEAD(Head) <--> Node1(ThreadA) <--> Node2(ThreadB) <--> Node3(ThreadC)
end
C --> Node1
D -- "释放锁 (unlock)" --> F{<center>修改state<br/>唤醒队列头部的后继节点(Node1)</center>}
F --> Node1
end
- state变量: 一个
volatile int类型的变量,代表同步状态。对于ReentrantLock,state=0表示锁未被占用,state>0表示锁被一个线程持有,其值表示重入的次数。 - FIFO等待队列: 当一个线程尝试获取锁失败后,它不会原地自旋,而是被打包成一个节点(
Node),加入到一个先进先出的队列中,然后被挂起(park),等待被唤醒。 - 获取与释放:
- 获取(
lock): 线程尝试使用CAS(Compare-And-Swap)原子操作修改state。成功则获取锁。失败则进入等待队列。 - 释放(
unlock): 持有锁的线程修改state。如果state归零,则唤醒(unpark)等待队列中的第一个线程,使其有机会再次尝试获取锁。
- 获取(
ReentrantLock正是AQS的一个完美实践。理解了AQS,你就掌握了理解JUC大部分同步工具的钥匙。
C. 动手实践与常用功能
公平锁 vs 非公平锁
ReentrantLock默认创建的是非公平锁,但它也允许我们创建公平锁。
- 非公平锁(默认): 性能更高。当锁被释放时,新来的线程和队列中等待的线程会一起竞争锁,新来的线程可能"插队"成功,减少了线程挂起和唤醒的开销。但可能导致队列中的线程"饥饿"。
- 公平锁: 严格按照FIFO顺序,等待时间最长的线程将优先获得锁。保证了公平性,但由于频繁的线程上下文切换,吞吐量通常较低。
通过构造函数 new ReentrantLock(true) 即可创建公平锁。
// 场景:5个线程竞争一把锁,观察获取锁的顺序
// true for a fair lock
final Lock fairLock = new ReentrantLock(true);
// false for a non-fair lock
final Lock nonFairLock = new ReentrantLock(false);
Runnable task = () -> {
// 使用 nonFairLock 或 fairLock 进行测试
Lock lock = nonFairLock;
System.out.println(Thread.currentThread().getName() + " 准备获取锁...");
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + " 成功获取锁!");
} finally {
lock.unlock();
}
};
for (int i = 0; i < 5; i++) {
new Thread(task, "线程-" + i).start();
}
实验观察:
- 使用非公平锁:你会发现输出的"成功获取锁"的顺序是随机的,与线程启动顺序(
线程-0到线程-4)没有必然联系。 - 使用公平锁:你会观察到,线程几乎总是严格按照
线程-0, 线程-1, 线程-2, 线程-3, 线程-4的顺序获取到锁。
Condition: 精准的线程通信
synchronized的wait()/notify()机制是"无差别通知",它唤醒的是在该对象监视器上等待的任意一个线程。如果我们需要更精细的控制,比如"只唤醒特定类型的线程",Condition就派上了用场。
一个Lock对象可以创建多个Condition实例,每个Condition都拥有自己的等待队列和通知机制,如同将单一的休息室分成了多个精准的"候车室"。
[嵌入学习环节]:生产者-消费者的精准通知
问题: 假设一个共享资源,有两个生产者(P1, P2)和两个消费者(C1, C2)。我们要求P1生产的数据只能由C1消费,P2生产的数据只能由C2消费。
分析: 如果使用synchronized配合wait()/notifyAll(),当P1生产完毕后调用notifyAll(),C1和C2都会被唤醒。C2被唤醒后发现不是自己需要的数据,只好再次wait(),这造成了不必要的上下文切换和CPU浪费。这就是Condition的用武之地。
实现:
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
class PreciseNotification {
private final Lock lock = new ReentrantLock();
// 为 P1 -> C1 这条线创建一个 Condition
private final Condition condition1 = lock.newCondition();
// 为 P2 -> C2 这条线创建另一个 Condition
private final Condition condition2 = lock.newCondition();
private boolean data1Ready = false;
private boolean data2Ready = false;
// 生产者1
public void produce1() throws InterruptedException {
lock.lock();
try {
while (data1Ready) { // 防止虚假唤醒
condition1.await();
}
System.out.println("P1 生产数据...");
data1Ready = true;
System.out.println("P1 -> 精准唤醒 C1");
condition1.signal(); // 只唤醒在 condition1 上等待的线程
} finally {
lock.unlock();
}
}
// 消费者 1
public void consume1() throws InterruptedException {
lock.lock();
try {
while (!data1Ready) {
condition1.await();
}
System.out.println("C1 消费数据...");
data1Ready = false;
// 此时可以唤醒 P1 继续生产,也可以不唤醒,取决于业务
// condition1.signal();
} finally {
lock.unlock();
}
}
// P2 和 C2 的逻辑与 P1/C1 类似,但使用 condition2
public void produce2() throws InterruptedException {
lock.lock();
try {
while (data2Ready) {
condition2.await();
}
System.out.println("P2 生产数据...");
data2Ready = true;
System.out.println("P2 -> 精准唤醒 C2");
condition2.signal();
} finally {
lock.unlock();
}
}
public void consume2() throws InterruptedException {
lock.lock();
try {
while (!data2Ready) {
condition2.await();
}
System.out.println("C2 消费数据...");
data2Ready = false;
} finally {
lock.unlock();
}
}
}
通过为不同的协作场景创建不同的Condition对象,我们实现了线程间的"精准滴灌",极大地提升了复杂场景下线程协作的效率和可控性。
D. 深化理解与最佳实践
[嵌入学习环节]:使用tryLock避免死锁
死锁是并发编程中最棘手的问题之一。synchronized一旦发生死锁,程序就会永久卡死。而ReentrantLock提供了一种主动避免死锁的强大武器:tryLock()。
死锁场景: 两个线程互相等待对方持有的锁。
// 一个经典的死锁代码
class DeadlockDemo {
private static final Lock lockA = new ReentrantLock();
private static final Lock lockB = new ReentrantLock();
public void methodA() {
lockA.lock();
try {
System.out.println(Thread.currentThread().getName() + " 持有 LockA, 尝试获取 LockB");
Thread.sleep(100); // 确保另一个线程有时间获取 LockB
lockB.lock();
try {
System.out.println(Thread.currentThread().getName() + " 同时持有 LockA 和 LockB");
} finally {
lockB.unlock();
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lockA.unlock();
}
}
public void methodB() {
lockB.lock();
try {
System.out.println(Thread.currentThread().getName() + " 持有 LockB, 尝试获取 LockA");
Thread.sleep(100);
lockA.lock();
try {
System.out.println(Thread.currentThread().getName() + " 同时持有 LockB 和 LockA");
} finally {
lockA.unlock();
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lockB.unlock();
}
}
}
// 启动线程
// new Thread(() -> new DeadlockDemo().methodA(), "Thread-A").start();
// new Thread(() -> new DeadlockDemo().methodB(), "Thread-B").start();
// 程序会卡住,发生死锁
使用tryLock改造:
lock.tryLock(long timeout, TimeUnit unit)方法会尝试在给定的时间内获取锁。如果在超时时间内成功获取,则返回true;如果超时仍未获取到,则返回false,而不是永久等待。这给了我们机会打破死锁循环。
import java.util.concurrent.TimeUnit;
// 改造 methodA
public void methodA_fixed() {
try {
if (lockA.tryLock(1, TimeUnit.SECONDS)) { // 尝试获取 lockA,最多等1秒
try {
System.out.println(Thread.currentThread().getName() + " 持有 LockA, 尝试获取 LockB");
if (lockB.tryLock(1, TimeUnit.SECONDS)) { // 尝试获取 lockB
try {
System.out.println(Thread.currentThread().getName() + " 太棒了! 同时持有 LockA 和 LockB");
} finally {
lockB.unlock();
}
} else {
System.out.println(Thread.currentThread().getName() + " 获取 LockB 失败,放弃...");
}
} finally {
lockA.unlock();
}
} else {
System.out.println(Thread.currentThread().getName() + " 获取 LockA 失败,放弃...");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// methodB 也做类似改造
通过使用限时等待的tryLock,我们把一个可能导致程序崩溃的死锁问题,转变成了一个可以被优雅处理的"获取锁失败"的逻辑分支,极大地增强了程序的健壮性。
ReentrantLock vs synchronized 全面对比
| 特性 | synchronized | ReentrantLock | 备注 |
|---|---|---|---|
| 本质 | Java关键字,JVM层面实现 | JUC包中的API,JDK层面实现 | Lock是接口,ReentrantLock是实现 |
| 使用方式 | 自动加锁解锁,简单 | 手动加锁(lock)、解锁(unlock) | lock需配合try-finally使用 |
| 可中断 | 不可中断等待 | 可中断 (lockInterruptibly) | 提供了更强的灵活性 |
| 公平性 | 非公平 | 默认非公平,可配置为公平 | 公平锁牺牲性能保证调度公平 |
| 功能 | 内置wait/notify | 配合Condition实现 | Condition可实现分组唤醒、精准通知 |
| 死锁处理 | 发生死锁只能等待或重启 | 可用tryLock超时机制避免死锁 | 提升了代码的健壮性 |
| 性能 | Java 1.6后优化明显,与Lock相近 | 略优于synchronized,尤其在高竞争下 | 在功能满足时,synchronized更简洁 |
选择建议:
- 在并发竞争不激烈、功能简单的场景,优先使用
synchronized,代码更简洁,不易出错。 - 在需要**可中断获取、限时获取、公平性保证、或复杂的线程通信(
Condition)**等高级功能的场景,ReentrantLock是你的不二之选。
E. 总结与预告
1. 本章总结
本章,我们从synchronized的局限性出发,探索了JUC提供的更为强大的锁机制——Lock接口及其实现ReentrantLock。我们掌握了:
ReentrantLock的优势:它提供了可中断、可超时、可配置公平性的高级锁特性。- 标准使用范式:牢记
lock()与try-finally-unlock()的黄金组合,保证锁的正确释放。 Condition的强大:通过Condition对象,我们实现了超越wait/notify的线程间"精准通知"能力。- AQS的核心思想:理解了"state + FIFO队列"是构建JUC同步组件的通用蓝图。
Lock和Condition为我们编写复杂的并发程序提供了强大的控制力,是Java并发工具箱中不可或缺的一环。
2. [嵌入学习环节]:编码实现
任务: 基于ReentrantLock和Condition,实现一个线程安全的有界阻塞队列(BoundedBlockingQueue<T>)。
要求:
- 定义一个泛型类
BoundedBlockingQueue<T>。 - 包含
put(T element)和T take()方法。 - 当队列满时,调用
put的线程应阻塞等待。 - 当队列空时,调用
take的线程应阻塞等待。 - 使用
ReentrantLock保证入队和出队操作的原子性。 - 使用两个
Condition对象(notFull和notEmpty)分别管理生产者和消费者的等待/唤醒。
这个练习将极好地检验你对本章知识的掌握程度。
3. 下章预告
除了"锁"这种重量级的同步机制,JUC还为我们提供了许多轻量级、场景化的并发"瑞士军刀",它们能优雅地解决特定的线程协作问题。下一章,我们将探索 **CountDownLatch(倒数门闩)、CyclicBarrier(循环栅栏)、Semaphore(信号量)**这些神奇的同步工具集,看看它们如何简化我们的并发编程。
第七章:JUC并发工具集 - 优雅的线程协作
A. 引言
想象一场百米赛跑,这不仅仅是运动员们各自奋力冲刺那么简单。一场组织有序的比赛,背后需要精密的"协作":
- 发令枪: 裁判扣下发令枪前,所有运动员都必须在起跑线上准备就绪,不能抢跑。这支"发令枪"就是我们即将认识的
CountDownLatch。 - 成绩公布: 比赛结束后,必须等待所有运动员都冲过终点,裁判们收集并核对了所有成绩,才能最终公布排名。这种"等待所有人到达一个节点"的机制,正是
CyclicBarrier的拿手好戏。 - 赛道限制: 如果这是一个小型运动会,可能只有8条赛道。这意味着,同一时间最多只能有8名运动员比赛。后来者需要等待,直到有赛道空出来。这种对有限资源的"许可"式访问,就是
Semaphore的核心思想。
在复杂的并发场景中,线程间的协作远比百米赛跑要复杂。幸运的是,Java的java.util.concurrent (JUC) 包为我们提供了强大的工具集,让这些复杂的协作变得优雅而高效。本章,我们将一起掌握CountDownLatch、CyclicBarrier和Semaphore这三大协作神器,并深入理解一个特殊工具ThreadLocal,它能巧妙地解决多线程中变量的隔离问题。
B. 核心概念与动手实践
CountDownLatch (倒数门闩)
CountDownLatch 就像一个倒数计数器。我们初始化一个计数值,当一个或多个线程调用 await() 方法时,它们会被阻塞。其他线程则通过调用 countDown() 方法来让计数器减一。当计数器减到零时,所有在 await() 上等待的线程都会被唤醒,继续执行。
这个过程是一次性的,一旦计数器归零,CountDownLatch 就完成了它的使命,无法重置。
场景: 主线程等待所有初始化任务完成
假设我们的应用程序在启动时,需要执行多个独立的初始化任务(如加载配置、初始化数据库连接池等)。主线程必须等待所有这些任务都完成后,才能正式对外提供服务。
代码实践:
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class CountDownLatchExample {
public static void main(String[] args) throws InterruptedException {
// 创建一个CountDownLatch,计数器为3
CountDownLatch latch = new CountDownLatch(3);
ExecutorService executor = Executors.newFixedThreadPool(3);
System.out.println("主线程:开始等待所有初始化任务完成...");
// 提交三个初始化任务
for (int i = 1; i <= 3; i++) {
final int taskId = i;
executor.submit(() -> {
try {
System.out.println("任务 " + taskId + " 正在初始化...");
Thread.sleep((long) (Math.random() * 2000)); // 模拟耗时
System.out.println("任务 " + taskId + " 初始化完成。");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
// 任务完成,计数器减一
latch.countDown();
}
});
}
// 主线程在此处阻塞,直到latch的计数器归零
latch.await();
System.out.println("主线程:所有初始化任务均已完成,应用启动成功!");
executor.shutdown();
}
}
在上面的例子中,主线程调用 latch.await() 后会立即暂停,直到三个初始化子线程分别完成了自己的任务并调用了 latch.countDown(),使计数器从3变为0,主线程才会继续执行。
CyclicBarrier (循环栅栏)
CyclicBarrier 的字面意思是"循环的栅栏"。它允许一组线程互相等待,直到所有线程都到达一个公共的屏障点(barrier point)。与 CountDownLatch 不同,当所有线程都越过栅栏后,它可以被重置(循环使用),以便下次再用。
场景: 公司团建,等待所有人集合
想象一下公司组织团建活动,大家约定在某个地点集合,然后一起坐大巴出发。大巴司机(或者一个指定的线程)必须等待所有人都到齐了,才能发车。
代码实践:
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class CyclicBarrierExample {
public static void main(String[] args) {
// 创建一个CyclicBarrier,需要3个线程到达,并且在到达时执行一个Runnable任务
CyclicBarrier barrier = new CyclicBarrier(3, () -> {
System.out.println("----------------------------------");
System.out.println("所有员工都已到达集合点,大巴车准备出发!");
System.out.println("----------------------------------");
});
ExecutorService executor = Executors.newFixedThreadPool(3);
for (int i = 1; i <= 3; i++) {
final int employeeId = i;
executor.submit(() -> {
try {
System.out.println("员工 " + employeeId + " 前往集合点...");
Thread.sleep((long) (Math.random() * 3000));
System.out.println("员工 " + employeeId + " 到达集合点,开始等待其他人。");
// 在此等待,直到所有3个线程都调用了await()
barrier.await();
System.out.println("员工 " + employeeId + " 上车出发!");
} catch (InterruptedException | BrokenBarrierException e) {
Thread.currentThread().interrupt();
}
});
}
executor.shutdown();
}
}
在这个例子中,每个"员工"线程在到达集合点后调用 barrier.await()。只有当第三个线程也调用 await() 时,栅栏才会打开。此时,作为构造函数第二个参数传入的Runnable任务(打印"大巴车准备出发")会被执行,然后所有等待的线程被唤醒,一起"上车出发"。
Semaphore (信号量)
Semaphore 用于控制同时访问特定资源的线程数量。它维护了一个"许可"集。线程可以通过 acquire() 方法获取一个许可,如果许可已经发完,那么线程将被阻塞,直到有其他线程通过 release() 方法释放一个许可。
场景: 共享停车场
一个停车场只有3个停车位,但有10辆车都想停进来。这时候就需要一个管理员来协调,谁能进,谁需要等。Semaphore 就是这个管理员。
代码实践:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
public class SemaphoreExample {
public static void main(String[] args) {
// 创建一个Semaphore,初始化3个许可(3个车位)
Semaphore semaphore = new Semaphore(3);
ExecutorService executor = Executors.newFixedThreadPool(10);
// 模拟10辆车
for (int i = 1; i <= 10; i++) {
final int carId = i;
executor.submit(() -> {
try {
System.out.println("车辆 " + carId + " 到达停车场门口,试图进入。");
// 尝试获取一个许可(进入停车场)
semaphore.acquire();
System.out.println("车辆 " + carId + " 成功进入停车场,开始停车。");
TimeUnit.SECONDS.sleep((long) (Math.random() * 4)); // 模拟停车时间
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
System.out.println("车辆 " + carId + " 离开停车场。");
// 释放许可(离开停车场)
semaphore.release();
}
});
}
executor.shutdown();
}
}
运行代码,你会看到,任何时候,打印"成功进入停车场"的车辆都不会超过3辆。当一辆车调用 release() 离开后,另一辆在 acquire() 处等待的车辆才能获得许可进入。
C. 深化理解与最佳实践
ThreadLocal (线程本地变量)
ThreadLocal 提供了一种创建线程本地变量的机制。任何一个线程都可以通过set(T value)方法为其设置一个值,并通过get()方法获取。关键在于,如果当前线程没有设置过值,get()会返回null(或通过initialValue()方法指定的初始值)。每个线程拿到的都是自己独立的副本,互不干扰。
场景: 解决用户信息和数据库连接的传递问题
在一个Web应用中,一个请求通常由一个线程处理。这个线程可能需要经过多个方法调用(Controller -> Service -> DAO)。在处理过程中,我们经常需要获取当前登录的用户信息。一种笨方法是把User对象作为参数在每个方法间传来传去,非常繁琐。
使用ThreadLocal可以优雅地解决这个问题。在请求开始时(例如在一个Filter中),我们将用户信息存入ThreadLocal;在后续的任何方法中,都可以随时通过ThreadLocal.get()获取到,无需传递参数。
代码实践:
public class ThreadLocalExample {
// 1. 创建一个ThreadLocal变量来存储用户信息
private static final ThreadLocal<String> userContext = new ThreadLocal<>();
public static void main(String[] args) {
// 创建两个线程,模拟两个不同的用户请求
new Thread(() -> {
String userName = "张三";
// 2. 在请求开始时,将用户信息存入ThreadLocal
userContext.set(userName);
System.out.println(Thread.currentThread().getName() + " 设置用户: " + userName);
new ServiceLayer().process();
}, "Thread-1-User-ZhangSan").start();
new Thread(() -> {
String userName = "李四";
// 2. 在另一个请求中,存入另一个用户信息
userContext.set(userName);
System.out.println(Thread.currentThread().getName() + " 设置用户: " + userName);
new ServiceLayer().process();
}, "Thread-2-User-LiSi").start();
}
static class ServiceLayer {
public void process() {
// 3. 在业务逻辑的任何地方,直接获取当前线程的用户信息
String currentUser = userContext.get();
System.out.println(Thread.currentThread().getName() + " 在Service层获取到用户: " + currentUser);
// !! 最佳实践:在请求处理完毕后,务必清理ThreadLocal
userContext.remove();
System.out.println(Thread.currentThread().getName() + " 清理了用户上下文。");
}
}
}
运行结果会清晰地显示,每个线程都只能获取到自己设置的用户信息,证明了ThreadLocal的隔离性。
[嵌入学习环节] ThreadLocal 的内存泄漏风险与最佳实践
这是一个非常重要的知识点,也是面试高频题。
为什么会内存泄漏?
简单来说,每个Thread对象都有一个ThreadLocalMap类型的成员变量,它才是真正存储数据的地方。这个Map的Key是ThreadLocal对象本身,Value是我们存入的对象(比如用户信息)。
关键在于,ThreadLocalMap中的Key被设计为弱引用 (WeakReference)。当外部不再有对ThreadLocal对象的强引用时(比如userContext = null),垃圾回收器(GC)就会回收这个ThreadLocal对象。这时,Map中的Key就变成了null。
但问题是,Map中的Value仍然被ThreadLocalMap强引用着。只要这个线程不销毁,ThreadLocalMap就一直存在,这个Value也就永远无法被回收,从而造成了内存泄漏。
在线程池场景下,这个问题尤为严重。因为线程池会复用线程,线程的生命周期很长,那么这个线程对应的ThreadLocalMap里积累的"幽灵"Value会越来越多,最终可能导致内存溢出(OOM)。
最佳实践:必须在 finally 块中调用 remove()
为了避免内存泄漏,ThreadLocal官方给出的解决方案是:在每次使用完ThreadLocal后,都主动调用其remove()方法,将Entry从Map中移除。
最保险的做法是放在finally块中,确保无论代码是否抛出异常,清理操作都会被执行。
public class ThreadLocalBestPractice {
private static final ThreadLocal<String> userContext = new ThreadLocal<>();
public void processRequest(String userName) {
userContext.set(userName);
try {
// ... 执行核心业务逻辑 ...
System.out.println("Processing request for user: " + userContext.get());
} finally {
// 确保在请求处理结束时,无论成功还是失败,都清理ThreadLocal
userContext.remove();
System.out.println("User context removed.");
}
}
}
D. 总结与预告
-
本章总结:
我们学习了JUC中三个核心的协作工具:CountDownLatch: 一次性的"倒数门闩",用于一个或多个线程等待其他一系列线程完成某项操作。CyclicBarrier: 可循环使用的"栅栏",用于让一组线程在达到某个屏障点时保持同步。Semaphore: “信号量”,用于控制对共享资源的并发访问数量。
同时,我们还深入探讨了ThreadLocal,一个实现线程数据隔离的利器,并重点强调了其在线程池中使用时必须调用remove()方法以防止内存泄漏的最佳实践。
-
下章预告:
我们已经掌握了大部分并发工具,但还剩下一个最重要、最高频的并发数据结构没有攻克——ConcurrentHashMap。以及一个性能优化的’大杀器’——线程池。下一章,我们将深入这两个面试中的’明星’。第八章:高性能并发之道 - ConcurrentHashMap与线程池
A. 引言
HashMap 是我们日常开发中使用最频繁的集合类之一,但它有一个致命的弱点:线程不安全。如果在多线程环境下对 HashMap进行读写操作,可能会导致数据不一致甚至死循环。那么,如果想在多线程环境中使用Map,该怎么办呢?
也许你会立刻想到古老的 Hashtable 或者 Collections.synchronizedMap(new HashMap<>())。它们确实是线程安全的,其实现方式非常"简单粗暴":就是给所有读写操作都加上一把全局锁(synchronized)。这意味着,无论你是在操作哪个数据,都会锁住整个Map。当一个线程正在写入时,其他所有线程的读写操作都必须等待。在高并发场景下,这会成为一个巨大的性能瓶颈,所有线程都得排队访问,并发度几乎为零。
为了解决这个性能问题,ConcurrentHashMap 应运而生。它如同一位高明的交通调度员,通过精巧的设计,实现了对锁的粒度细化,允许多个线程在不冲突的情况下同时操作Map,从而极大地提升了并发性能。本章,我们就来一探ConcurrentHashMap和另一个并发编程利器——线程池的奥秘。
B. 核心概念与原理 (ConcurrentHashMap)
ConcurrentHashMap 的核心思想是"锁分离",即通过降低锁的粒度,来减少锁竞争,提升并发度。这个思想在JDK 1.7和1.8中有不同的实现方式,也展示了其原理的演进过程。
原理演进: JDK 1.7 - 分段锁 (Segment)
在JDK 1.7中,ConcurrentHashMap 的内部结构由一个 Segment 数组和每个 Segment 内部的 HashEntry 数组组成。Segment 本身继承自 ReentrantLock,扮演着"锁"的角色。
简单来说,ConcurrentHashMap 将整个哈希表拆分成了多个独立的段(Segment),默认是16个。当你需要对某个数据进行操作时,不是锁住整个Map,而是先根据key的哈希值定位到它所属的那个 Segment,然后只锁定这一个 Segment。
- 写操作:只需获取对应
Segment的锁。 - 读操作:大多数情况下不需要加锁,只有在需要读取最新数据时才可能需要(通过
volatile保证可见性)。
这样一来,只要多个线程操作的数据不属于同一个 Segment,它们就可以并行执行,互不影响。锁的粒度从整个Map缩小到了一个 Segment,并发度理论上可以提高到默认的16倍。
图示:JDK 1.7 分段锁原理
在上图中,线程1和线程2分别操作不同的
Segment,因此可以完全并行。而线程2和线程3因为要操作同一个Segment,其中一个线程必须等待另一个释放锁。
原理演进: JDK 1.8 - CAS + synchronized
虽然分段锁已经大大提升了性能,但Java的开发者们并未止步。在JDK 1.8中,ConcurrentHashMap 的实现被彻底重构,摒弃了 Segment 的概念,回归到 Node 数组 + 链表/红黑树的结构,与 HashMap 的结构非常相似。
那么,它是如何保证线程安全的呢?锁的粒度被进一步降低了。
- 基础结构:一个
Node数组,每个Node可以是一个键值对,也可以是链表或红黑树的头节点。Node的核心字段(如val和next)使用volatile修饰,保证可见性。 - 写操作 (put):
- 首先,根据key的哈希值计算出在数组中的索引位置。
- 如果该位置为
null,则使用CAS(Compare-And-Swap) 操作尝试直接写入新节点。CAS是一种乐观锁技术,它不加锁,而是假设没有冲突,在更新时检查一下数据是否被其他线程修改过。如果CAS成功,操作完成。 - 如果
CAS失败,说明有其他线程在此期间已经占据了这个位置,或者该位置已经有节点了(发生了哈希冲突)。这时,就会退化为使用synchronized锁住该数组桶位(即Node数组的头节点),然后在这个桶位上进行链表或红黑树的插入操作。
这种设计将锁的粒度细化到了数组的单个桶位。只有当多个线程恰好要操作同一个桶位时,才会发生锁竞争。相比于JDK 1.7锁定整个Segment(一个Segment包含多个桶位),性能又得到了巨大的飞跃。
图示:JDK 1.8 CAS + synchronized 原理
在上图中,线程1和4可以直接通过
CAS写入空桶位,几乎没有开销。线程2和3因为哈希冲突,需要竞争同一个桶位Node[1]的synchronized锁。
C. 核心概念与动手实践 (线程池)
为什么需要线程池?
在Java中,new Thread() 可以创建一个新线程,这很简单。但如果我们的程序需要频繁、大量地创建线程来处理任务,会遇到两个严重问题:
- 资源开销大:每次
new Thread()都会调用操作系统内核API来创建真正的内核线程,这涉及到内存分配、CPU调度等一系列"重"操作。频繁创建和销毁线程会消耗大量系统资源,影响性能。 - 管理失控:如果无限制地创建线程,当并发量过高时,可能会耗尽系统内存或CPU资源,导致
OutOfMemoryError或整个系统崩溃。我们无法对线程的数量、生命周期进行有效管理。
线程池正是为了解决这些问题而生的。它就像一个"员工池",预先创建好一定数量的常驻线程。当任务来了,就从池里取一个空闲线程去执行;任务执行完了,线程并不销毁,而是回到池里等待下一个任务。
使用线程池的好处:
- 降低资源消耗:通过复用已创建的线程,避免了频繁创建和销毁的开销。
- 提高响应速度:任务来了可以直接用池中的线程,省去了创建线程的时间。
- 提高可管理性:可以统一分配、调优和监控池中的所有线程。可以控制最大并发数,防止资源耗尽。
ThreadPoolExecutor
ThreadPoolExecutor 是Java线程池最核心、最底层的实现类。理解了它,就理解了Java线程池的本质。Executors 工具类提供的 newFixedThreadPool、newCachedThreadPool 等方法,内部也都是 ThreadPoolExecutor 的封装。
[嵌入学习环节] 七大核心参数
手动创建一个ThreadPoolExecutor需要七个参数,我们用一个"银行窗口"的比喻来彻底理解它们:
public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler)
-
corePoolSize(核心线程数)- 比喻: 银行的正式窗口数量。
- 解释: 线程池中保持存活的核心线程数量。即使这些线程处于空闲状态,也不会被销毁(除非设置了
allowCoreThreadTimeOut)。
-
maximumPoolSize(最大线程数)- 比喻: 银行的总窗口数量(正式窗口 + 临时窗口)。
- 解释: 线程池能容纳的最大线程数量。当任务量太大,核心线程都在忙,并且阻塞队列也满了,线程池才会创建新线程,直到总数达到
maximumPoolSize。
-
keepAliveTime(存活时间)- 比喻: 临时窗口的空闲等待时间。
- 解释: 当线程池中的线程数量超过
corePoolSize时,多余的空闲线程(临时工)能存活的最长时间。超过这个时间,这些临时线程就会被销毁。
-
unit(时间单位)- 比喻: 等待时间的单位(秒、分钟等)。
- 解释:
keepAliveTime的时间单位,如TimeUnit.SECONDS。
-
workQueue(阻塞队列)- 比喻: 银行的等候区。
- 解释: 当核心线程都在忙时,新提交的任务会先被放到这个队列中等待执行。它是一个
BlockingQueue,常用的有ArrayBlockingQueue(有界队列)、LinkedBlockingQueue(近乎无界队列)、SynchronousQueue(不存储元素的队列)等。
-
threadFactory(线程工厂)- 比喻: 负责招聘和培训新员工的部门。
- 解释: 一个用于创建新线程的工厂。我们可以通过它来自定义线程的名称、是否为守护线程、优先级等。
-
rejectedExecutionHandler(拒绝策略)- 比喻: 当等候区也满了,临时窗口也全开,如何处理新来的顾客。
- 解释: 当线程池和阻塞队列都满了,无法再处理新任务时,所采取的策略。默认有四种:
AbortPolicy(默认):直接抛出RejectedExecutionException异常。 (银行:直接告诉你办不了,请回吧)CallerRunsPolicy:由提交任务的那个线程自己来执行这个任务。(银行:大堂经理亲自帮你办)DiscardPolicy:默默地丢弃这个任务,不抛异常也不执行。(银行:当没看见你,直接忽略)DiscardOldestPolicy:丢弃队列中最老的那个任务,然后尝试重新提交新任务。(银行:把等最久的那位顾客请出去,让你顶上)
线程池工作流程图
当一个新任务通过 execute() 方法提交给线程池后,它的"命运"之旅如下:
流程解读:
- 检查核心线程: 判断当前运行的线程数是否小于
corePoolSize。如果是,就创建一个新的核心线程来执行任务。 - 尝试入队: 如果核心线程池已满,判断
workQueue是否已满。如果没满,就把任务丢进队列里排队。 - 创建临时线程: 如果队列也满了,判断当前运行的线程数是否小于
maximumPoolSize。如果是,就创建一个新的非核心(临时)线程来执行任务。 - 执行拒绝策略: 如果总线程数也达到了最大值,说明线程池已经超负荷运转。此时,必须执行
rejectedExecutionHandler来处理这个无法承载的任务。
最佳实践:为什么不推荐使用Executors?
阿里巴巴的《Java开发手册》中强制规定,不允许使用Executors去创建线程池,而是要通过ThreadPoolExecutor的方式。这是为什么?
Executors提供的一些快捷方法,如newFixedThreadPool(int n)、newSingleThreadExecutor()和newCachedThreadPool(),虽然用起来方便,但存在潜在的资源耗尽风险:
-
newFixedThreadPool和newSingleThreadExecutor:- 问题: 使用的阻塞队列是
LinkedBlockingQueue,它的容量默认是Integer.MAX_VALUE(约21亿)。 - 风险: 如果任务生产速度远远大于消费速度,会导致大量任务在队列中堆积,最终可能耗尽内存,导致
OutOfMemoryError。
- 问题: 使用的阻塞队列是
-
newCachedThreadPool:- 问题:
maximumPoolSize被设置为Integer.MAX_VALUE。 - 风险: 当任务量激增时,会无限制地创建新线程。每个线程都需要消耗一定的栈内存。最终可能因为创建了过多线程而耗尽系统资源,导致
OutOfMemoryError。
- 问题:
因此,最安全、最可控的方式是根据自己的业务场景,手动创建ThreadPoolExecutor,并明确指定每一个参数,尤其是为阻塞队列设置一个合理的有界容量。
D. 总结与预告
1. 本章总结
本章我们深入探讨了Java并发编程中的两个核心组件。我们了解到 ConcurrentHashMap 如何通过从JDK 1.7的分段锁演进到JDK 1.8的CAS+synchronized,不断地降低锁粒度,从而实现卓越的并发性能。
接着,我们学习了线程池的核心价值——复用与管理。通过对 ThreadPoolExecutor 的七大核心参数(核心线程、最大线程、存活时间、单位、阻塞队列、线程工厂、拒绝策略)的剖析,我们掌握了其内部的完整工作流。更重要的是,我们理解了为什么在生产环境中必须手动创建线程池以避免资源耗尽的风险。
2. [嵌入学习环节] 实践题
请根据以下要求,手动创建一个线程池,并模拟提交任务,观察其行为:
- 创建线程池:
- 核心线程数: 5
- 最大线程数: 10
- 空闲线程存活时间: 1分钟
- 阻塞队列:
ArrayBlockingQueue,容量为 100
- 模拟任务:
- 循环提交 200 个任务到线程池。
- 每个任务打印当前线程名,并休眠一小段时间(如200毫秒)来模拟执行耗时。
- 观察行为:
- 观察并解释任务执行过程中的线程变化情况。
- 探索拒绝策略:
- 将拒绝策略分别改为
AbortPolicy(默认,需要try-catch捕获异常) 和CallerRunsPolicy。 - 重新运行程序,描述并解释这两种策略在任务被拒绝时的不同表现。
- 将拒绝策略分别改为
提示: 你可以写一个简单的Java main函数来完成这个实验。通过
Thread.currentThread().getName()获取并打印线程名称,可以清晰地看到是哪个线程在执行任务。
3. 下章预告
并发编程的世界日新月异。在Java 8之后,我们有了一种全新的、更优雅的异步编程方式来处理复杂的任务依赖和组合。下一章,我们将学习CompletableFuture,开启函数式异步编程的大门。
第九章:从单体到分布式 - 架构演进与消息队列
A. 引言:一个"不堪重负"的开始
让我们从一个故事开始。想象一下,你和几个伙伴基于一个绝妙的创意,开发了一款名为"闪电购"的电商App。项目初期,为了快速验证市场,你们选择了最直接、最高效的开发方式——单体架构。所有功能,包括用户注册、商品浏览、下订单、库存管理、支付、甚至是后台管理,都被打包在同一个工程里,部署在同一台服务器上。
业务初期,一切都很美好。用户量不大,功能迭代迅速,每次发布只需要打包整个应用,然后重启服务。这种简单直接的方式让你们的小团队如鱼得水。
然而,随着"闪电购"的用户量从一千、一万激增到百万,麻烦接踵而至:
- 开发效率雪崩:App越来越臃肿,几十个工程师挤在一个代码库里"贴膏药"。代码冲突频繁,仅仅修改一个商品描述的小功能,也需要整个应用重新编译、测试和部署,一次上线耗费半天成了家常便饭。
- 部署牵一发而动全身:订单模块的一个小bug就可能导致整个App瘫痪。每次上线都像是一场赌博,运维团队提心吊胆。
- 技术栈僵化:想给评论功能引入新的NoSQL数据库?对不起,整个应用基于MySQL,技术栈难以升级,任何新的技术尝试都举步维艰。
- 性能瓶颈:大促期间,用户模块的流量洪峰直接打垮了数据库,导致整个App无法访问,而商品浏览这类非核心功能也跟着一起"陪葬"。
团队终于意识到,那个曾经的"小甜甜"单体应用,如今变成了"牛夫人"。为了让"闪电购"重获新生,一场深刻的架构变革势在必行。这,就是我们踏上分布式架构演进之路的起点。
B. 核心概念
架构演进:从"一锅端"到"各自为政"
应用架构的演进,本质上是一个"拆分"与"治理"的过程。下面这张图清晰地描绘了这条路径:
-
单体架构 (Monolithic): 所有功能模块(如用户、商品、订单)都耦合在同一个应用中。
- 优点: 开发简单,易于测试和部署。
- 缺点: 随着业务增长,变得笨重、脆弱,技术栈受限。
-
垂直应用架构 (Vertical): 当单体应用过于庞大时,第一步是按照业务线进行切割。例如,将电商应用拆分为"电商系统"、“后台管理系统”、"CMS系统"等。每个系统都是一个独立的单体应用。
- 解决: 解决了部分开发效率和部署问题。
- 问题: 应用之间存在功能重叠(如用户管理),数据割裂,调用关系混乱。
-
SOA (Service-Oriented Architecture): 面向服务的架构。将垂直应用中重叠的功能抽取出来,作为独立的服务(如用户服务、订单服务)。通过一个称为"企业服务总线"(ESB)的中心化组件来协调服务间的调用。
- 解决: 提高了服务的复用性,简化了系统间的集成。
- 问题: ESB可能成为新的性能瓶颈和单点故障点,服务治理依旧复杂。
-
微服务架构 (Microservices): SOA的"去中心化"演进。它将应用彻底拆分成一组小而独立的服务,每个服务都运行在自己的进程中,可以独立开发、部署和扩展。服务间通过轻量级的HTTP API或消息队列进行通信。
- 解决: 实现了服务的彻底解耦和自治,技术选型灵活,故障隔离性强。
- 问题: 分布式系统本身的复杂性,如服务发现、熔断、分布式事务、运维成本等。
分布式三要素:解耦、异步、削峰
当我们开始拆分服务,分布式系统带来了三个核心的好处,而这三者往往通过**消息队列(Message Queue, MQ)**这个关键中间件来实现。
-
解耦 (Decoupling)
- 场景: 在"闪电购"中,用户下订单后,需要调用库存系统来锁定库存,调用积分系统来增加积分。
- 问题: 如果库存系统出现故障或升级,订单系统也会被卡住,甚至下单失败。这种紧密的调用关系就是"耦合"。
- 解耦方案: 订单系统完成核心的订单创建后,不再直接去调用库存和积分系统。它只需要向消息队列中发送一条"订单已创建"的消息,然后就可以立刻返回。库存和积分系统各自去监听这条消息,然后执行自己的业务逻辑。这样一来,订单系统根本不关心库存系统是否存在、是否正常,实现了服务间的解耦。
-
异步 (Asynchronicity)
- 场景: 承接上例,传统的同步调用方式下,用户点击"下单"按钮后,程序需要依次执行:创建订单 -> 调用库存服务 -> 调用积分服务,所有操作完成后,才向用户返回"下单成功"。
- 问题: 如果库存和积分服务的处理比较耗时(比如需要复杂的计算或数据库操作),用户就需要在线上"罚站",等待很长时间,严重影响用户体验。
- 异步方案: 采用消息队列后,订单系统发送完消息就立即向用户返回"下单成功"。而耗时的库存和积分操作,由其他系统在后台默默处理,用户对此无感知。这就将一个同步的耗时操作,变成了非核心流程的异步执行,极大提升了系统的响应速度和用户体验。
-
削峰 (Peak Shaving)
- 场景: "双十一"零点秒杀活动,百万用户在同一瞬间涌入"闪电购"App下单。数据库的写入能力是有限的,比如每秒只能处理2000个订单。
- 问题: 瞬时流量洪峰(比如每秒10万个请求)会像洪水一样直接冲垮数据库,导致整个系统瘫痪。
- 削峰方案: 消息队列就像一个巨大的"水库"。我们将这10万个下单请求先快速地写入到消息队列中(写入MQ的速度非常快,远高于写入数据库)。后端订单系统则根据自己的实际处理能力,以每秒2000个的速度,平稳地从消息队列中拉取请求进行处理。这样,就有效地将流量洪峰"削平",保护了后端的核心服务。
C. 动手实践 (RabbitMQ)
理论讲了这么多,让我们亲自动手,用RabbitMQ来实现服务的异步化改造。
核心概念
RabbitMQ是实现了AMQP(高级消息队列协议)的经典消息中间件。它的核心流转过程如下:
- Publisher (生产者): 消息的发送方。
- Exchange (交换机): 接收来自生产者的消息,并根据规则(Binding)将消息路由到一个或多个队列。它本身不存储消息。
- Queue (队列): 消息的存储容器,直到消费者来取走它。
- Binding (绑定): 定义了Exchange和Queue之间的路由关系。它包含一个
RoutingKey。 - Consumer (消费者): 消息的接收方,它从队列中获取并处理消息。
消息流转过程:生产者将带有RoutingKey的消息发送给Exchange,Exchange根据Binding规则,找到匹配的Queue,并将消息投递进去。消费者则监听Queue来获取消息。
[嵌入学习环节] 场景:从同步到异步的"发送欢迎邮件"
1. 同步实现 (改造前)
在一个典型的Spring Boot应用中,同步实现的代码可能如下:
// UserController.java
@RestController
public class UserController {
@Autowired
private UserService userService;
@Autowired
private MailService mailService; // 邮件服务
@PostMapping("/register")
public String register(String username, String password) {
// 1. 核心业务:注册用户
long userId = userService.register(username, password);
// 2. 非核心业务:发送欢迎邮件(同步调用)
// 假设发送邮件需要耗时2秒
mailService.sendWelcomeMail(userId, username);
return "注册成功!"; // 总耗时 = 注册耗时 + 2秒
}
}
痛点: 用户点击注册后,必须等待邮件发送完成才能得到响应,体验很差。
2. 异步改造 (引入RabbitMQ)
第一步:添加依赖
在pom.xml中引入spring-boot-starter-amqp。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
第二步:配置RabbitMQ连接
在application.properties中配置RabbitMQ服务器地址、端口和凭证。
spring.rabbitmq.host=localhost
spring.rabbitmq.port=5672
spring.rabbitmq.username=guest
spring.rabbitmq.password=guest
第三.步:改造注册接口 (成为生产者)
我们改造UserController,使其不再直接调用MailService,而是发送一条消息。
// UserController.java (改造后)
@RestController
public class UserController {
@Autowired
private UserService userService;
@Autowired
private RabbitTemplate rabbitTemplate; // Spring Boot自动配置好了
// 定义交换机和路由键的名称,便于管理
public static final String EXCHANGE_NAME = "user.direct";
public static final String ROUTING_KEY = "user.register";
@PostMapping("/register")
public String register(String username, String password) {
// 1. 核心业务:注册用户
long userId = userService.register(username, password);
// 2. 发送消息到RabbitMQ
// 我们将用户ID作为消息内容发送出去
rabbitTemplate.convertAndSend(EXCHANGE_NAME, ROUTING_KEY, userId);
return "注册成功!"; // 总耗时 = 注册耗时 (几乎瞬间完成)
}
}
3. 创建消费者 (邮件服务)
现在,我们创建一个独立的邮件服务来监听队列,处理消息。
// MailConsumer.java
@Service
public class MailConsumer {
@Autowired
private MailService mailService;
// 定义队列名称
public static final String QUEUE_NAME = "mail.queue";
// 使用@RabbitListener注解,监听指定队列
@RabbitListener(
bindings = @QueueBinding(
value = @Queue(name = QUEUE_NAME, durable = "true"), // 创建一个持久化的队列
exchange = @Exchange(name = UserController.EXCHANGE_NAME, type = ExchangeTypes.DIRECT), // 绑定到直连交换机
key = UserController.ROUTING_KEY // 绑定的RoutingKey
)
)
public void listenMailQueue(Long userId) {
// 收到消息,这里的userId就是生产者发送过来的内容
System.out.println("收到用户注册消息,用户ID: " + userId + ",准备发送邮件...");
// 调用邮件服务,执行耗时操作
mailService.sendWelcomeMail(userId, "some_username"); // 实际场景会查询数据库获取用户名
System.out.println("欢迎邮件发送完毕!");
}
}
效果: 注册接口的响应时间大大缩短。邮件发送的耗时操作被转移到了消费者中异步执行,系统成功解耦,用户体验得到提升。
D. 深化理解与最佳实践
RabbitMQ的Exchange类型
- Direct Exchange (直连交换机): 完全匹配。消息的
RoutingKey必须与Binding的Key完全一致,消息才会被投递到对应的队列。最简单、最常用的模式,适用于点对点通信。 - Fanout Exchange (扇形交换机): 广播。它会忽略
RoutingKey,将收到的所有消息广播到所有绑定到它的队列中。适用于需要将同一消息通知给所有订阅者的场景。 - Topic Exchange (主题交换机): 模式匹配。
Binding Key可以包含通配符(*匹配一个单词,#匹配零个或多个单词)。消息的RoutingKey会与Binding Key进行模式匹配,匹配成功则投递。非常灵活,适用于复杂的、多维度的消息路由。 - Headers Exchange (头交换机): 基于消息头匹配。不依赖
RoutingKey,而是通过匹配消息头(headers)中的键值对来进行路由。性能稍差,使用较少。
消息投递的可靠性
如何确保消息"一定"被送达,不会丢失?
- Publisher Confirms (生产者确认): 确保消息成功从生产者发送到了RabbitMQ服务器。RabbitMQ会给生产者一个回执(ack)。如果没收到回执(如网络中断),生产者可以进行重发。
- Consumer Acknowledgements (消费者确认): 确保消息成功被消费者处理。默认情况下,消息一旦被投递给消费者,就会从队列中删除。但如果此时消费者宕机,消息就丢失了。开启手动ACK后,消费者必须在代码中显式地调用
channel.basicAck(),RabbitMQ才会删除消息。如果消费者处理失败或宕机,消息会重新回到队列,等待被其他消费者处理。
E. 总结与预告
1. 本章总结
本章,我们跟随着"闪电购"App的成长,理解了架构从单体到微服务演进的必然性。我们深入探讨了分布式系统赖以生存的三大思想:解耦、异步和削峰。最后,通过RabbitMQ的实战,我们掌握了利用消息队列这一关键工具,将这些思想付诸实践,从而构建出更具弹性、更高性能的现代应用。
2. [嵌入学习环节] 实践任务
在"用户注册成功后发送欢迎邮件"的场景基础上,产品经理提出了新需求:
新用户注册成功后,需要异步地为其发放一张8折优惠券。
请你思考并尝试完成以下任务:
- 应该使用哪种
Exchange类型更合适?(提示:一个"用户注册成功"事件,可能需要被多个下游服务消费) - 请自行增加一个新的队列
coupon.queue和一个对应的消费者服务CouponConsumer来处理此任务。
3. 下章预告
服务解耦后,我们获得了灵活性,但新的问题随之而来:如何应对高频访问的热点数据?如何降低数据库的压力?“闪电购"的商品详情页被频繁访问,每次都查数据库显然扛不住。下一章,我们将学习分布式系统中的性能"核武器”——分布式缓存Redis。
第十章:高性能的秘诀 - 分布式缓存Redis
1. 学习目标
在本章学习结束后,你应该能够:
- 熟练掌握 Redis五种核心数据类型(String, Hash, List, Set, ZSet)及其典型应用场景。
- 在Spring Boot项目中整合
spring-data-redis,并使用注解和RedisTemplate进行缓存操作。 - 深刻理解 缓存"三座大山"(穿透、击穿、雪崩),并能阐述其主流解决方案。
2. 引言:便利店与中央仓库
想象一下,你住在一个大型社区里。如果每次想买一瓶水,都必须开车半小时去遥远的城市中央仓库提货,那将是多么低效和痛苦的体验。这个"中央仓库"就像是我们系统中的数据库,它存放着所有的数据,但访问速度相对较慢,且能承受的并发访问量有限。
现在,小区门口开了一家24小时便利店。大部分日常所需的商品,比如水、零食、日用品,这里应有尽有。你下楼一分钟就能买到水,生活变得无比便捷。这家"便利店"就是我们今天要学习的缓存(Cache),特别是分布式缓存领域的王者——Redis。
在应用架构中,缓存扮演着"便利店"的角色。它将最常访问的热点数据(比如首页信息、爆款商品详情)存放在内存中,内存的读写速度比磁盘快几个数量级。当请求到来时,系统首先访问缓存,如果数据命中(Hit),则直接返回,避免了对后端数据库的慢速访问。这不仅极大地提升了应用的响应速度,也有效保护了脆弱的数据库,使其免受高并发流量的冲击。引入缓存,是系统性能优化的第一步,也是最立竿见airflow-with-data-team-in-mind-part-2)竿见影的一步。
3. 核心概念与快速入门
A. Redis五大核心数据类型
Redis不仅仅是一个简单的键值对(Key-Value)存储,它提供了多种强大的数据结构,这也是它广受欢迎的重要原因。
| 数据类型 | 经典业务场景 | 描述 |
|---|---|---|
| String | 存储用户信息JSON串、Session共享、常规计数器 | 最基础的类型,一个Key对应一个Value,Value可以是字符串、数字或二进制数据。 |
| Hash | 存储商品属性(名称、价格、库存)、用户购物车 | 一个Key对应一个Map,适合存储对象的多个字段,便于对单个字段进行读写。 |
| List | 实现文章/微博的时间线(Timeline)、简单的消息队列 | 一个Key对应一个有序的字符串列表,支持在两端进行快速的Push/Pop操作。 |
| Set | 存储文章点赞的用户ID集合、抽奖系统中的中奖用户 | 一个Key对应一个无序且唯一的字符串集合,支持高效的集合运算(交、并、差)。 |
| ZSet | 实现用户积分排行榜、商品销量榜、带有权重的任务队列 | Set的升级版,每个元素都关联一个分数(score),并按分数自动排序。 |
B. 快速入门:在Spring Boot中使用Redis
第一步:使用Docker启动Redis
对于学习和开发环境,Docker是启动Redis最便捷的方式。
docker run -d --name my-redis -p 6379:6379 redis
这条命令会在后台启动一个名为
my-redis的Redis容器,并将容器的6379端口映射到宿主机的6379端口。
第二步:在Spring Boot项目中集成
-
添加依赖: 在
pom.xml中引入spring-boot-starter-data-redis。<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> -
配置连接: 在
application.yml中配置Redis服务器的地址和端口。spring: redis: host: localhost port: 6379 # password: your-password # 如果设置了密码
第三步:使用RedisTemplate进行操作
Spring Boot自动配置了RedisTemplate,我们可以直接注入并使用它来操作Redis。
@Autowired
private RedisTemplate<String, String> redisTemplate;
public void testRedis() {
// 写入数据
redisTemplate.opsForValue().set("user:100", "{\"name\":\"Alice\", \"age\":25}");
System.out.println("成功向Redis写入数据。");
// 读取数据
String userJson = redisTemplate.opsForValue().get("user:100");
System.out.println("从Redis读取到用户数据:" + userJson);
}
RedisTemplate针对不同的数据类型提供了不同的操作视图,如opsForValue()、opsForHash()、opsForList()等,API设计非常直观。
4. 动手实践:为"图书管理"项目加上缓存
现在,我们将以之前章节的"图书管理"项目为基础,利用Spring Cache注解,轻松地为业务方法添加缓存逻辑。
首先,确保你的启动类上有@EnableCaching注解,以开启Spring的缓存功能。
@SpringBootApplication
@EnableCaching // 开启缓存功能
public class BookManagementApplication {
// ...
}
A. @Cacheable: 缓存查询结果
为"根据ID查询图书"的方法添加缓存。当第一次调用该方法时,它会执行方法体内的逻辑(查询数据库),并将返回值放入缓存。后续以相同的ID再次调用时,将直接从缓存中获取结果,不再执行方法体。
cacheNames或value: 指定缓存的名称,可以理解为缓存的命名空间。key: 缓存的键。这里使用SpEL表达式#id来获取方法的参数id作为键。
@Service
public class BookServiceImpl implements BookService {
@Autowired
private BookRepository bookRepository;
@Override
@Cacheable(cacheNames = "books", key = "#id")
public Book findBookById(Long id) {
System.out.println("正在从数据库中查询ID为 " + id + " 的图书...");
// 模拟慢查询
try { Thread.sleep(1000); } catch (InterruptedException e) {}
return bookRepository.findById(id).orElse(null);
}
}
演示效果:连续调用两次findBookById(1L),你会发现控制台只会打印一次"正在从数据库中查询…",第二次请求的响应速度会明显快于第一次。
B. @CachePut: 更新缓存数据
当更新图书信息后,我们希望缓存中的数据也能同步更新,而不是返回旧的脏数据。@CachePut可以保证方法被执行,并且执行成功后,将返回值更新到缓存中。
key的写法是#result.id或#book.id,因为更新操作后我们需要用图书的ID作为键,而返回结果Book对象中包含了ID。
@Override
@CachePut(cacheNames = "books", key = "#book.id")
public Book updateBook(Book book) {
System.out.println("正在更新数据库和缓存中ID为 " + book.getId() + " 的图书...");
return bookRepository.save(book);
}
C. @CacheEvict: 清除缓存
当图书被删除时,对应的缓存也应该被清除。@CacheEvict用于从缓存中移除一条或多条数据。
beforeInvocation = false(默认值): 在方法成功执行后才清除缓存,防止因业务方法异常导致数据不一致(数据库记录删除失败,缓存却被清了)。
@Override
@CacheEvict(cacheNames = "books", key = "#id")
public void deleteBook(Long id) {
System.out.println("正在从数据库和缓存中删除ID为 " + id + " 的图书...");
bookRepository.deleteById(id);
}
通过这三个注解,我们就以一种非侵入的方式,为图书管理的CRUD操作集成了缓存逻辑,代码优雅且高效。
5. 深化理解:翻越缓存的"三座大山"
虽然缓存能极大地提升性能,但"水能载舟,亦能覆舟",使用不当也会引发严重的系统问题。其中最著名的就是"缓存穿透"、“缓存击穿"和"缓存雪崩”,我们必须理解它们并掌握其解决方案。
A. 缓存穿透 (Cache Penetration)
- 场景: 黑客利用一个数据库中根本不存在的ID来恶意、高并发地请求你的接口。由于缓存中没有这个ID的数据(缓存未命中),每次请求都会直接打到数据库。数据库也查不到,不会写缓存。这会导致数据库压力剧增,甚至被打垮。
- 解决方案:
- 缓存空对象: 当数据库查询结果为空时,我们依然在缓存中为这个Key设置一个特殊的"空值"(比如一个固定的JSON字符串
{}),并设置一个较短的过期时间。这样,后续对该Key的请求就会命中这个"空值",直接返回,从而保护数据库。 - 布隆过滤器 (Bloom Filter): 这是一种高效的数据结构,可以用来判断一个元素"一定不存在"或者"可能存在"。我们将所有可能存在的Key(比如所有图书的ID)提前存入布隆过滤器。当请求到来时,先去布隆过滤器查询这个ID是否存在。如果不存在,直接拒绝请求;如果存在,再继续后续的缓存/数据库查询。
- 缓存空对象: 当数据库查询结果为空时,我们依然在缓存中为这个Key设置一个特殊的"空值"(比如一个固定的JSON字符串
B. 缓存击穿 (Cache Breakdown)
- 场景: 某个热点数据(比如一个爆款商品的详情页)的缓存突然失效了。在这一瞬间,成千上万的并发请求同时涌入,发现缓存未命中,于是这些请求全部穿过缓存,直接打向数据库,导致数据库瞬间崩溃。这就像在一个点上将堤坝击穿。
- 解决方案:
- 热点数据预热且永不过期: 对于可预知的热点数据(如秒杀活动商品),可以在活动开始前就将其加载到缓存中,并且不设置过期时间(或在低峰期通过定时任务更新)。
- 使用分布式锁: 当缓存未命中时,不是所有请求都去查数据库。而是先尝试获取一个分布式锁(例如使用Redis的
SETNX命令)。只有成功获取锁的那个线程,才有资格去查询数据库、重建缓存。其他线程则可以选择等待一小段时间后重试(此时缓存可能已被重建),或者直接返回一个友好的提示。
C. 缓存雪崩 (Cache Avalanche)
- 场景:
- 大量缓存同时过期: 比如在系统启动时,你将大量数据在同一时间点加载到缓存,并设置了相同的过期时间。在未来的某个时刻,这些缓存将集体失效,导致所有请求瞬间涌向数据库。
- Redis服务宕机: Redis实例本身发生故障,无法提供服务。这会导致整个缓存层瘫痪,所有请求全部打到数据库上。
- 解决方案:
- 过期时间加随机值: 在设置缓存过期时间时,在一个基础时间上增加一个随机的偏移量(比如
30分钟 + 随机1~5分钟)。这样可以把过期时间打散,避免集中失效。 - 构建高可用集群: 针对Redis宕机问题,需要搭建高可用的Redis集群。常见的模式有Sentinel(哨兵模式)和Cluster(集群模式),它们能够在主节点宕机时自动进行故障转移,保证缓存服务的高可用性。
- 过期时间加随机值: 在设置缓存过期时间时,在一个基础时间上增加一个随机的偏移量(比如
6. 总结与预告
A. 本章总结
本章我们深入探讨了分布式缓存Redis,它如同系统架构中的"高速便利店",是提升性能的关键。我们掌握了:
- Redis五大核心数据类型(String, Hash, List, Set, ZSet)的精髓及其在真实业务中的应用。
- 如何在Spring Boot中通过
spring-boot-starter-data-redis和@EnableCaching快速集成和使用缓存。 - 利用
@Cacheable、@CachePut、@CacheEvict三个核心注解,优雅地为业务代码赋能缓存。 - 深刻剖析了缓存穿透、击穿、雪崩这"三座大山"的成因与业界主流的解决方案。
B. 挑战任务
请为我们"图书管理"项目中"根据ID查询图书"的接口(findBookById)设计并实现一个防止缓存穿透的方案。你可以选择"缓存空对象"或"布隆过滤器"中的任意一种来实现。思考两种方案的优缺点和适用场景。
C. 下章预告
缓存极大地提升了"读"性能,但当"写"操作的压力变得巨大,单台数据库的容量和性能都达到极限时,我们又该怎么办呢?下一章,我们将学习"分而治之"的终极奥义——分库分表,它将带领我们进入海量数据存储与处理的新世界。同时,我们也将探讨在分布式环境下,如何可靠地管理定时任务。敬请期待!
第十一章:自动化与无限扩展 - 分布式调度和分库分表
1. 章节目标
在本章学习结束后,你将能够:
- 解释为何 Spring 自带的
@Scheduled在集群环境下会失效(任务重复执行)。 - 掌握分布式任务调度框架(以 XXL-Job 为例)的基本架构和用法。
- 理解垂直分片和水平分片的区别,并知道何时应该进行分库分表。
- 能够使用
ShardingSphere-JDBC实现简单的水平分表功能。
A. 引言:从两个"意外"谈起
作为架构师,我们经常面临一些幸福的烦恼。业务发展太快,用户量激增,系统开始出现一些在单体时代闻所未闻的"意外"。
意外一:重复的报表
“一切都很完美。我用 Spring 的
@Scheduled(cron = "0 0 2 * * ?")注解写了一个定时任务,每天凌晨2点为老板生成一份前一天的运营报表。在我的开发环境里,它安静而准确地工作着。但为了保证双十一期间的高可用,运维同学将服务部署了两个实例。灾难发生了,老板的邮箱里每天都准时躺着两份一模一样的报表!这是怎么回事?”
意外二:卡顿的用户查询
“我们的核心业务——用户系统,用户表(
t_user)的数据量在两年内悄然突破了一亿行。现在,一个简单的SELECT * FROM t_user WHERE username = ?都可能需要十几秒,更别提那些复杂的关联查询了。数据库的 CPU 占用率居高不下,DBA 团队已经发出了多次警告。整个应用的响应速度都被这一张表拖垮了。”
这两个场景,精准地指向了分布式系统中两个不同维度的问题:
- 控制流的分布式问题:当同一个应用(代码)在多个地方运行时,如何确保一个指令(例如"执行定时任务")在全局只被执行一次?
- 数据流的分布式问题:当数据量大到单个数据库节点无法高效处理时,如何将数据分散到多个节点,同时让应用层几乎感受不到这种变化?
解决这两个问题,是我们的系统能否从"能用"迈向"高性能、高可用"的关键一步。本章,我们将学习业界主流的两种解决方案:分布式任务调度框架 XXL-Job 和 数据分片中间件 ShardingSphere。
B. 分布式任务调度 (XXL-Job)
Spring 的 @Scheduled 本质上是一个"嵌入式"的定时器,它与应用实例的生命周期完全绑定。当应用启动时,定时器线程就在这个实例的 JVM 中开始工作。因此,部署 N 个实例,就会有 N 个独立的定时器在各自运行,自然会导致任务的重复执行。
要解决这个问题,我们需要将"调度"和"执行"这两个角色进行分离。即,有一个全局的"大脑"(调度中心)负责在正确的时间发出任务指令,而我们的业务应用实例则作为"四肢"(执行器)来接收指令并完成具体工作。
XXL-Job 就是这种思想的优秀实现者。
核心概念:调度中心与执行器
XXL-Job 将系统分为两个核心部分:
- 调度中心 (XXL-Job Admin): 一个独立的 Web 应用,负责统一管理任务信息、触发调度、监控执行状态、查看日志等。它是整个调度系统的"大脑"。
- 执行器 (Executor): 以客户端 Jar 包的形式被集成到我们的业务应用中。它负责接收来自调度中心的指令,执行本地代码(我们定义的 JobHandler),并将执行结果(成功、失败、日志)上报给调度中心。
它们的关系可以用下图清晰地表示:
graph TD
subgraph "调度中心 (XXL-JOB Admin)"
A[任务管理界面]
B[API接口]
C[调度模块]
D[任务日志]
E[数据库]
end
subgraph "业务应用集群 (执行器)"
F1[服务A-实例1 (Executor)]
G1[服务A-实例2 (Executor)]
end
A -- "创建/管理任务" --> C
B -- "API触发" --> C
C -- "心跳/注册" --> F1 & G1
C -- "任务触发 (只选一个)" --> G1
F1 & G1 -- "上报心跳和执行结果" --> C
C -- "读写任务/日志" --> E
linkStyle 3 stroke:red,stroke-width:2px;
linkStyle 4 stroke:blue,stroke-width:2px;
关键流程:
- 业务应用(执行器)启动后,会周期性地向调度中心"心跳注册",告知自己的地址。
- 调度中心通过这些心跳来维持一个可用的"执行器地址列表"。
- 当任务到达触发时间,调度中心根据配置的路由策略(例如:第一个、最后一个、轮询、随机等),从地址列表中选择一个健康的执行器实例,向它发出执行请求。
- 执行器收到请求后,调用本地对应的 JobHandler,并将执行日志实时回传给调度中心。
[嵌入学习环节] 动手实践:解决"重复报表"问题
下面,我们来亲手解决引言中的问题。
1. 部署调度中心
- 从 XXL-Job 的 GitHub Release 页面 下载最新版本的源码。
- 找到
xxl-job-admin模块,其doc/db/tables_xxl_job.sql文件包含了初始化数据库的脚本,在你的 MySQL 中执行它。 - 修改
xxl-job-admin的application.properties,配置好数据库连接信息。 - 启动
XxlJobAdminApplication,访问http://localhost:8080/xxl-job-admin(默认端口8080,账号admin/123456),看到控制台界面即表示成功。
2. 在 Spring Boot 项目中集成执行器
- 在你的 Spring Boot 项目(例如生成报表的项目)的
pom.xml中加入执行器依赖:<dependency> <groupId>com.xuxueli</groupId> <artifactId>xxl-job-core</artifactId> <version>...</version> </dependency> - 在
application.yml中配置执行器信息:xxl: job: admin: addresses: http://127.0.0.1:8080/xxl-job-admin # 调度中心地址 executor: appname: report-service # 执行器名称,用于在调度中心识别 port: 9999 # 执行器内嵌 Netty 服务的端口 logpath: /data/applogs/xxl-job/jobhandler logretentiondays: 30 accessToken: default_token # 通信令牌 - 创建一个配置类来初始化执行器 Bean:
@Configuration public class XxlJobConfig { // ... 从 yml 读取配置 ... @Bean public XxlJobSpringExecutor xxlJobExecutor() { XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor(); // ... 设置 yml 中的配置 ... return xxlJobSpringExecutor; } }
3. 创建一个定时任务
- 告别
@Scheduled,我们用@XxlJob注解来定义一个任务。@Component public class ReportJobHandler { @XxlJob("generateDailyReportJob") // 值要与调度中心配置的 JobHandler 名称一致 public ReturnT<String> execute(String param) throws Exception { XxlJobHelper.log("开始生成每日报表..."); System.out.println("正在生成报表,实例IP:" + XxlJobHelper.getExecutorIp() + ",端口:" + XxlJobHelper.getExecutorPort()); // 模拟报表生成耗时 Thread.sleep(5000); XxlJobHelper.log("每日报表生成完毕。"); return ReturnT.SUCCESS; } }
4. 验证集群环境
- 在调度中心界面,"执行器管理"中新增一个名为
report-service的执行器。 - 在"任务管理"中新增一个任务:
- Cron:
0 * * * * ?(为了方便测试,每分钟执行一次) - 路由策略: 轮询
- JobHandler:
generateDailyReportJob
- Cron:
- 关键步骤: 使用不同的端口号(例如
server.port=8081和server.port=8082)分别启动两个报表服务实例。 - 观察调度中心的"执行器管理",你会看到两个
report-service的实例都注册了上来。 - 观察"调度日志",你会发现,在每一分钟,任务虽然被触发,但有且仅有一个实例接收到请求并执行了任务。日志中打印出的 IP 和端口会交替变化。
至此,我们完美地解决了任务重复执行的问题!
C. 分库分表 (ShardingSphere-JDBC)
当数据量增长到单台数据库服务器的物理极限(磁盘容量、IOPS、CPU)时,垂直扩展(升级硬件)的成本会急剧上升且效果递减。此时,水平扩展,即"分片 (Sharding)",就成了必然选择。
核心概念:垂直分片 vs 水平分片
分片主要有两种方式:
-
垂直分片 (Vertical Sharding): 基于业务维度对数据库进行拆分。例如,将一个包含用户、商品、订单所有表的庞大数据库,拆分成独立的用户库、商品库和订单库。这种方式能将不同业务的压力隔离开,但它没有解决"单张表数据量过大"的问题。
-
水平分片 (Horizontal Sharding): 基于规则将同一张表的数据拆分到多个物理位置(不同的表,甚至不同的库)。例如,将一亿行的
t_order表,按照user_id的奇偶性,分别存入t_order_0和t_order_1两张表中。这是解决单表性能瓶颈的终极武器。
下图清晰地展示了它们的区别:
graph TD
subgraph "原始状态"
A[大型数据库<br/>t_user, t_product, t_order]
end
subgraph "方案一: 垂直分片 (按业务拆分)"
direction LR
B[用户库<br/>t_user]
C[商品库<br/>t_product]
D[订单库<br/>t_order]
end
subgraph "方案二: 水平分片 (拆分 t_order 表)"
E[原 t_order 表] --> F[t_order_0<br/>(user_id % 2 == 0)]
E --> G[t_order_1<br/>(user_id % 2 == 1)]
end
A -- "系统初期,优先考虑" --> B & C & D
C -- "订单业务压力巨大" --> E
ShardingSphere-JDBC 是一个极其优秀的实现方案,它以 Jar 包的形式嵌入应用,让我们在不改变原有 ORM 框架(如 MyBatis, JPA)代码的前提下,透明地实现分库分表。
[嵌入学习环节] 动手实践:拯救"一亿用户的订单表"
假设我们的"图书管理系统"中,订单表 t_order 不堪重负。我们决定将其水平分片。
1. 引入依赖
在 Spring Boot 项目的 pom.xml 中引入 ShardingSphere Starter:
<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>shardingsphere-jdbc-core-spring-boot-starter</artifactId>
<version>...</version>
</dependency>
2. 设定场景与准备
- 目标: 将
t_order表根据user_id水平分片。偶数user_id的订单进t_order_0,奇数user_id的订单进t_order_1。 - 准备: 在你的数据库中,手动创建
t_order_0和t_order_1两张表,它们的表结构与原t_order表完全一致。
3. 编写 application.yml 配置
这是 ShardingSphere 的魔法核心。它通过 YAML 配置,劫持并改写你的 SQL。
spring:
shardingsphere:
# 开启 SQL 解析和日志,方便调试
props:
sql-show: true
# 定义数据源
datasource:
names: ds0 # 只有一个数据源,在同一个库里分表
ds0:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://localhost:3306/book_management?serverTimezone=UTC
username: root
password: password
# 定义分片规则
sharding:
# 绑定数据源和表的映射关系
binding-tables:
- t_order
# 定义所有分片表
tables:
# 配置 t_order 表的规则
t_order:
# 定义实际的数据节点
actual-data-nodes: ds0.t_order_$->{0..1} # ds0库的t_order_0和t_order_1表
# 定义分片键
table-strategy:
inline:
sharding-column: user_id
# 分片算法:Groovy 表达式
algorithm-expression: t_order_$->{user_id % 2}
4. 保持业务代码不变,执行并验证
-
关键点: 你的
OrderMapper.java(MyBatis) 或OrderRepository.java(JPA) 接口不需要做任何改动!你的代码中操作的逻辑表名依然是t_order。// Mapper 接口定义 (无任何变化) @Mapper public interface OrderMapper { @Insert("INSERT INTO t_order (order_id, user_id, amount) VALUES (#{orderId}, #{userId}, #{amount})") void insert(Order order); @Select("SELECT * FROM t_order WHERE user_id = #{userId}") List<Order> findByUserId(Long userId); } // Service 层调用 (无任何变化) @Service public class OrderService { @Autowired private OrderMapper orderMapper; public void createOrders() { orderMapper.insert(new Order(1L, 100L, 50.0)); // user_id = 100 (偶数) orderMapper.insert(new Order(2L, 101L, 75.5)); // user_id = 101 (奇数) } } -
启动应用,调用
createOrders()方法。 -
验证:
- 去数据库中查看,你会发现
user_id = 100的订单被插入了t_order_0表。 user_id = 101的订单被插入了t_order_1表。t_order这张逻辑表本身可以不存在。- 在应用日志中,你能看到 ShardingSphere 打印出的 SQL 改写信息,例如
Logic SQL: INSERT ...和Actual SQL: ds0 ::: INSERT INTO t_order_0 ...。
- 去数据库中查看,你会发现
ShardingSphere-JDBC 透明地完成了 SQL 路由,业务代码零侵入,这正是它的强大之处。
D. 深化理解与最佳实践
分库分表是柄双刃剑,它解决了扩展性问题,但也引入了新的复杂性:
- 分布式事务: 如果一个操作需要同时修改
t_order_0和t_order_1中的数据(例如转账),如何保证它们要么都成功,要么都失败?这就引出了分布式事务问题。业界有 Seata、LCN 等框架尝试解决,但它始终是分布式系统中的一大难题。 - 跨库 Join: 一旦数据被分散到不同库,传统数据库的
JOIN操作就失效了。你需要将JOIN拆解成多次单表查询,在应用内存中进行数据聚合,这对业务代码有一定侵入性。 - 全局唯一 ID: 数据库的自增主键在分片后不再适用,因为
t_order_0和t_order_1会产生冲突的 ID。你需要引入全局唯一的 ID 生成策略,如 Snowflake 算法、Redis 生成等。
因此,分库分表是"最后的核武器",只有在其他优化手段(如索引优化、读写分离、缓存)都已穷尽时才应启用。
E. 总结与预告
-
本章总结:
我们解决了服务集群化后带来的两个典型问题。通过 XXL-Job,我们将调度与执行解耦,实现了对任务的统一、可靠、高可用的管理。通过 ShardingSphere-JDBC,我们掌握了数据层水平扩展的终极方案,让应用在数据量爆炸式增长时依然能保持高性能,而这一切对业务代码几乎是透明的。 -
下章预告:
“恭喜你!你已经掌握了构建一个高性能、高可用分布式系统的所有核心’零件’:从底层的并发编程,到分布式的缓存、消息队列,再到本章的调度与数据分片。现在,是时候将它们组装成一个有机的、易于管理的整体了。在最终的第四阶段,我们将进入微服务的世界,学习如何治理这个庞大而复杂的系统。”第十二章:微服务治理体系 - Spring Cloud Alibaba核心实践
A. 引言:从"巨型兵营"到"特种部队"
想象一下,一个传统的单体应用就像一个"巨型兵营"。所有的士兵(功能模块)都居住在同一个营房里,睡通铺,吃大锅饭。这种模式下,纪律严明,管理直接,但缺点也显而易见:任何一个小小的调动(比如更新一个功能),都需要整个兵营熄灯就寝再起床(整个应用重启);如果一个士兵生了病(一个模块出现Bug),可能会传染给整个兵营,导致全军覆没(整个应用宕机)。随着战争规模(业务体量)的扩大,这个笨重的兵营将变得行动迟缓,难以应对瞬息万变的战场。
现在,让我们换一种思路,组建一支"特种部队"——这就是微服务架构。我们将庞大的兵营拆分成一个个独立、精干的小分队(微服务),比如爆破组(商品服务)、狙击组(订单服务)、医疗组(用户服务)。每个小分队各司其职,可以独立训练(开发)、独立部署、独立扩缩容,极大地提升了灵活性和战斗力。
然而,小分队协同作战,也带来了新的挑战:
- “无线电通信”: 狙击组如何精确地呼叫爆破组进行火力支援?(服务之间如何调用)
- “中央情报局”: 总指挥部如何知道每个小分队当前的位置和状态?(服务的注册与发现)
- “司令部命令”: 如何将新的作战计划(配置信息)快速下达到每个小分队,并让他们立即执行?(统一配置管理)
- “战场应急预案”: 如果爆破组在执行任务时暂时失联,狙击组如何避免原地干等,甚至能启动备用方案?(服务的容错与降级)
这便是"服务治理"的核心诉求。而 Spring Cloud Alibaba,就是我们为这支特种部队配备的一整套高科技作战系统,它提供了服务治理所需的全套解决方案,让我们的微服务部队真正做到令行禁止、协同高效、坚不可摧。本章,我们将一起学习如何使用其核心组件,为我们的"特种部队"注入灵魂。
B. 核心概念与动手实践
在动手之前,我们需要先创建两个基础的"小分队":product-service(商品服务)和 order-service(订单服务)。它们都是标准的Spring Boot项目,这里我们略去创建过程,专注于如何将它们纳入Spring Cloud Alibaba的治理体系。
服务注册与发现 (Nacos Discovery)
“中央情报局”(Nacos)的首要职责,就是让所有"小分队"(服务)报到登记,并实时更新它们的位置和状态。
1. 部署Nacos Server
首先,你需要一个Nacos服务器。你可以从 Nacos官网 下载并参照官方文档启动它。启动成功后,访问 http://localhost:8848/nacos 即可看到管理控制台。
2. 服务提供者与消费者的配置
让 product-service 和 order-service 都向Nacos注册。
第一步:添加依赖
在两个服务的 pom.xml 中都加入Nacos Discovery的起步依赖:
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
第二步:修改配置
在 application.yml 中配置服务名和Nacos地址:
spring:
application:
name: product-service # 对于订单服务,这里是 order-service
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848 # Nacos服务器地址
第三步:启动与验证
分别启动两个服务,你会在启动日志中看到向Nacos注册的信息。刷新Nacos控制台,在"服务管理" -> "服务列表"中,就能看到 product-service 和 order-service 都已经成功在线。
3. 服务调用
现在,order-service 需要调用 product-service 的接口。传统方式需要硬编码IP和端口,但在微服务中,我们借助Nacos和Spring Cloud的负载均衡能力来实现。
在 order-service 中,配置一个 RestTemplate 并用 @LoadBalanced 注解,它会自动从Nacos查询 product-service 的可用实例地址并发起调用。
@Configuration
public class RestTemplateConfig {
@Bean
@LoadBalanced // 赋予RestTemplate负载均衡的能力
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
@RestController
public class OrderController {
@Autowired
private RestTemplate restTemplate;
@GetMapping("/create")
public String createOrder() {
// 直接使用服务名调用,而不是硬编码的IP和端口
String productInfo = restTemplate.getForObject("http://product-service/product/info", String.class);
return "Order created successfully with product info: " + productInfo;
}
}
声明式服务调用 (OpenFeign)
RestTemplate虽然可行,但拼接URL、处理参数和响应都略显繁琐。OpenFeign 提供了一种更优雅的方式,让我们像调用本地方法一样调用远程HTTP服务。
1. 引入依赖
在 order-service 的 pom.xml 中加入OpenFeign的起步依赖:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
2. 开启Feign功能
在 order-service 的主启动类上添加 @EnableFeignClients 注解。
3. [嵌入学习环节] 创建并使用Feign客户端
第一步:创建ProductFeignClient接口
定义一个接口,使用 @FeignClient 注解指向目标服务名 product-service。接口中的方法签名与 product-service 中Controller的方法完全对应。
// @FeignClient的value值是目标服务的spring.application.name
@FeignClient("product-service")
public interface ProductFeignClient {
// 完整复制目标服务Controller方法的签名
@GetMapping("/product/info")
String getProductInfo();
}
第二步:注入并使用
现在,在 OrderController 中,我们可以像注入一个本地Service一样注入并使用 ProductFeignClient。
@RestController
public class OrderController {
// 不再需要RestTemplate
// @Autowired
// private RestTemplate restTemplate;
@Autowired
private ProductFeignClient productFeignClient;
@GetMapping("/create")
public String createOrder() {
// 调用远程服务,就像调用一个本地方法一样,优雅!
String productInfo = productFeignClient.getProductInfo();
return "Order created successfully with product info: " + productInfo;
}
}
对比: OpenFeign 将HTTP调用的细节完全封装,提供了类型安全的客户端,代码可读性和可维护性远超 RestTemplate。
统一配置管理 (Nacos Config)
“司令部”(Nacos)的第二个重要职责是下发命令(管理配置)。我们希望配置的修改能够动态推送到各个服务,而无需重启。
1. 在Nacos中创建配置
登录Nacos控制台,进入"配置管理" -> “配置列表”,点击"+"号创建新配置。
- Data ID:
order-service.yaml(通常格式为{spring.application.name}.{file-extension}) - Group:
DEFAULT_GROUP - 配置格式:
YAML - 配置内容:
order:
payment:
timeout: 5000 # 支付超时时间
creation:
enable: true # 是否开启下单功能
2. order-service接入Nacos配置
第一步:添加依赖
在 order-service 的 pom.xml 中加入Nacos Config的起步依赖:
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
第二步:创建bootstrap.yml
Spring Cloud应用会优先加载 bootstrap.yml 中的配置来连接配置中心。在该文件中指定Nacos地址。
spring:
application:
name: order-service
cloud:
nacos:
config:
server-addr: 127.0.0.1:8848 # Nacos服务器地址
file-extension: yaml # 指定配置文件后缀
第三步:读取动态配置
在需要读取配置的类上,使用 @Value 注解,并添加 @RefreshScope 注解使其支持动态刷新。
@RestController
@RefreshScope // 允许该Bean中的配置动态刷新
public class OrderConfigController {
@Value("${order.creation.enable:false}") // 读取配置,:false表示默认值
private boolean creationEnable;
@GetMapping("/config/status")
public String getConfigStatus() {
if (creationEnable) {
return "当前允许创建订单。";
} else {
return "当前已暂停创建订单。";
}
}
}
动态刷新演示: 启动 order-service,访问 /config/status,会看到"当前允许创建订单"。现在,去Nacos控制台将 order.creation.enable 的值改为 false 并发布。稍等片刻,再次访问该接口,无需重启服务,你会发现返回内容已变为"当前已暂停创建订单。" 这就是动态配置的魔力!
服务容错 (Sentinel)
"战场应急预案"是保障部队持续作战能力的关键。当某个小分队(服务)遭遇意外(如宕机、网络延迟、流量洪峰),Sentinel能够确保其他小分队不受其拖累,并执行备用方案。
1. 部署Sentinel Dashboard
同样,从 Sentinel官网 下载Dashboard的jar包并启动它。
2. order-service接入Sentinel
第一步:添加依赖
在 order-service 的 pom.xml 中加入Sentinel的起步依赖:
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>
第二步:修改配置
在 application.yml 中配置Dashboard地址,让服务的心跳和监控数据上报。
spring:
cloud:
sentinel:
transport:
dashboard: localhost:8080 # Sentinel Dashboard地址
第三. [嵌入学习环节] 配置流控与熔断规则
启动 order-service,并随意调用几次其API(例如 /create)。然后访问Sentinel Dashboard(默认端口8080),你应该能在左侧看到 order-service。
1. QPS流量控制
- 场景: 防止恶意请求或突发流量冲垮服务。
- 操作:
- 在Dashboard中找到
order-service,点击"簇点链路"。 - 找到你想保护的资源名,例如
GET:/create,点击右侧的"流控"按钮。 - 在弹出的对话框中,将"阈值类型"设为
QPS,“单机阈值"设为1。点击"新增”。
- 在Dashboard中找到
- 验证:
使用JMeter、Apache Bench (ab) 或简单的浏览器快速刷新,以超过1 QPS的频率请求/create接口。你会观察到,一部分请求成功,另一部分请求会快速失败并看到Sentinel默认的阻塞提示Blocked by Sentinel (flow limiting)。这表明限流已生效,服务被保护了起来。
2. 熔断降级
- 场景: 当
order-service依赖的product-service不可用时,避免请求堆积在order-service,使其快速失败并返回友好提示。 - 操作:
- 在Dashboard中为
order-service点击"熔断规则"。 - 资源名填写我们通过OpenFeign调用的资源,其格式为
HTTP GET:http://product-service/product/info。(具体资源名请在"簇点链路"中查找确认) - “熔断策略"选择"慢调用比例”,"最大RT"设为50ms(假设正常调用在50ms内),"比例阈值"设为0.4(40%的请求慢调用则熔断),"熔断时长"设为5s。
- 在Dashboard中为
- 验证:
- 手动停止
product-service。 - 连续、快速地访问
order-service的/create接口。 - 起初的几次请求会因为等待超时而非常缓慢,并最终报错。但很快你会发现,后续的请求会立即失败,这就是熔断器打开了。
- 等待5秒(我们设置的熔断时长)后,熔断器进入半开状态,会尝试放行一个请求。如果
product-service已恢复,则关闭熔断器;如果依然失败,则继续保持熔断。
- 手动停止
为了给用户更友好的提示,我们可以为Feign客户端指定一个FallbackFactory,在发生熔断时执行其中的逻辑。
D. 深化理解与最佳实践
微服务划分的原则
如何划分微服务是实施微服务架构的第一个关键决策。一个普遍推荐的最佳实践是遵循领域驱动设计(DDD) 的思想。
- 按业务领域划分: 将紧密关联的业务功能、数据聚合在一起,形成一个"限界上下文(Bounded Context)“,每个限界上下文就可以是一个或多个微服务。例如,电商系统中的"商品域”、“订单域”、"用户域"都是天然的划分边界。
- 高内聚,低耦合: 服务内部的功能应该是高度相关的(高内聚),而服务与服务之间的依赖应该尽可能少(低耦合)。
Sentinel 与 Hystrix 的设计理念差异
Hystrix是Netflix开源的元老级容错组件,而Sentinel是后起之秀,它们在设计上有一些关键区别:
- 资源隔离方式: Hystrix主要通过线程池隔离或信号量隔离来实现。为每个依赖创建一个独立的线程池,优点是隔离性非常彻底,一个依赖的故障不会影响其他依赖。缺点是线程池的开销较大,不适用于依赖数量非常多的场景。
- Sentinel的创新: Sentinel借鉴了Hystrix的思想,但采用了更轻量级和灵活的方式。它通过并发线程数进行隔离,而不是创建重量级的线程池。更重要的是,Sentinel的核心是基于实时监控的、动态的规则。你可以在运行时通过控制台动态修改流控和熔断规则,而Hystrix的许多配置是静态的,需要重启才能更改。Sentinel的规则更加丰富,能应对更多样的场景。
E. 总结与预告
1. 本章总结
在本章中,我们为我们的微服务"特种部队"装备了Spring Cloud Alibaba提供的三大核心武器:
- Nacos: 担当了"中央情报局"和"司令部"的角色,解决了服务注册发现和统一配置管理两大难题。
- OpenFeign: 提供了优雅的"无线电通信"方案,让服务间调用如丝般顺滑。
- Sentinel: 制定了完善的"战场应急预案",通过流量控制和熔断降级,极大地提升了系统的稳定性和韧性。
掌握了这些工具,你就拥有了构建一套可靠、健壮的微服务通信和治理体系的核心能力。
2. 下章预告
“理论的终点是实践。你已经学会了打造赛车的所有顶级零件,现在,是时候亲手组装一辆属于你自己的、能在赛道上飞驰的F1赛车了!在最终的毕业设计章节,我们将从零开始,综合运用所有知识,构建一个完整的电商微服务项目。”
第13章:毕业设计 - 从零到一构建’Treademo’商城微服务
1. 角色与任务
欢迎来到《Java后端学习手册》的最终章!在本章,你不再仅仅是一个学习者,你的角色是一位经验丰富的项目总架构师,正在指导一位准工程师(也就是你自己)完成他的毕业设计。忘记"教程",拥抱"实战"。
2. 章节定位
- 当前阶段: 【第四阶段:微服务系列专题】
- 当前章节: 【最终章:毕业设计 - 从零到一构建’Treademo’商城微服务】
3. 学习目标
在本章学习结束后,你将能够:
- 独立完成一个小型微服务系统的全流程:需求分析、架构设计、技术选型、开发实现、联调测试。
- 获得一个可以写入简历、充分展示个人技术栈广度和深度的、有价值的实战项目。
- 将本书所有零散的知识点融会贯通,形成一个完整的知识体系。
4. 内容撰写
A. 项目愿景与需求分析
- 项目名称: “Treademo” 在线商城
- 愿景: “这不是一个玩具,这是一个真实项目的缩影。你将扮演架构师和核心开发者的双重角色,目标是构建一个稳定、高可用、可扩展的电商平台后端。”
- 核心需求:
- 用户模块:
- 用户注册
- 用户登录
- 商品模块:
- 商品列表查询
- 商品详情查看
- 订单模块:
- 创建订单
- 用户模块:
B. 架构设计与技术选型
最终系统架构图
在你动手编码之前,必须先有蓝图。下图就是我们即将构建的 “Treademo” 商城微服务架构。请仔细研究它,理解每个组件的职责和它们之间的调用关系。
技术栈清单 (Tech Stack)
这是我们项目的"军备清单",确保你对每一项技术都有基本的了解。
- 核心框架: Spring Boot 3.x
- 微服务框架: Spring Cloud Alibaba 2022.x
- 服务治理: Nacos (服务注册、发现、配置)
- 服务网关: Spring Cloud Gateway
- 服务调用: OpenFeign
- 熔断限流: Sentinel
- 数据库: MySQL 8.0
- ORM: MyBatis-Plus
- 分布式缓存: Redis
- 消息队列: RabbitMQ
- 构建工具: Maven
- 容器化: Docker & Docker Compose
C. 开发路线图 (Milestones)
我们将采用敏捷开发的"冲刺"(Sprint)模式,分阶段、有条不紊地完成整个项目。
Sprint 0: 万丈高楼平地起 (地基搭建)
-
一键启动所有中间件:
- 编写
docker-compose.yml文件。 - 定义
mysql,redis,rabbitmq,nacos,sentinel-dashboard服务。 - 使用
docker-compose up -d命令一键启动所有依赖的中间件环境。
- 编写
-
创建Maven父工程
Treademo:- 使用
spring-boot-starter-parent作为父依赖。 - 在
<dependencyManagement>中引入spring-cloud-alibaba-dependencies,统一管理微服务组件版本。 - 定义全局的公共属性,如
spring-boot.version,spring-cloud.version等。
- 使用
-
创建微服务模块与通用模块:
gateway: API网关服务。user-service: 用户服务。product-service: 商品服务。order-service: 订单服务。common-util: 通用工具包(存放POJO、工具类、统一异常处理等)。
Sprint 1: 用户是上帝 (用户服务开发)
-
设计用户表
t_user:- 包含
id,username,password,create_time等基础字段。 - 使用 MyBatis Plus 配合
@TableName注解创建实体类User。 - 编写
UserMapper接口,继承BaseMapper<User>,获得基础CRUD能力。
- 包含
-
实现注册与登录接口:
- 在
user-service中创建UserController。 - 提供
/register和/login两个POST接口。 - 注册时对密码进行加密处理(例如使用 BCrypt)。
- 在
-
整合Redis - 增加用户详情缓存:
- 为
user-service添加spring-boot-starter-data-redis依赖。 - 改造"根据ID查询用户"的方法:
- 先从 Redis 查询,Key可以是
user:{id}。 - 如果命中,直接返回。
- 如果未命中,查询 MySQL,将结果写入 Redis 并设置过期时间,然后返回。
- 先从 Redis 查询,Key可以是
- 为
Sprint 2: 商品琳琅满目 (商品服务开发)
-
设计商品表
t_product:- 包含
id,product_name,price,stock(库存),description等字段。 - 同样使用 MyBatis Plus 完成
Product实体类和ProductMapper的开发。
- 包含
-
整合Redis - 增加商品详情缓存:
- 在
product-service中,为"根据ID查询商品详情"这个热点接口增加缓存。 - 缓存逻辑同用户服务,Key可以是
product:{id}。这是电商系统的核心优化点。
- 在
Sprint 3: 交易的艺术 (订单服务与服务整合)
-
设计订单与订单详情表:
t_order: 订单主表,包含id,order_no,user_id,total_amount,status,create_time。t_order_item: 订单详情表,包含id,order_no,product_id,quantity,price。
-
核心开发 - "创建订单"接口:
- 在
order-service中引入spring-cloud-starter-openfeign依赖。 - 创建一个 Feign客户端
UserFeignClient,用于调用user-service的接口。 - 创建另一个 Feign客户端
ProductFeignClient,用于调用product-service的接口。 - "创建订单"接口的内部逻辑:
- 接收前端传来的
userId和商品列表List<productId, quantity>。 - 通过
UserFeignClient查询用户信息(虽然本需求不直接用,但这是典型调用链路)。 - 通过
ProductFeignClient循环查询每个商品的信息,并校验库存是否充足。 - 如果库存充足,则创建订单(写入
t_order和t_order_item表)。
- 接收前端传来的
- 在
-
整合RabbitMQ - 异步解耦:
- 在
order-service中引入spring-boot-starter-amqp依赖。 - 当订单创建成功后,使用
RabbitTemplate发送一条内容为{ "orderNo": "xxx", "productId": "xxx", "count": 2 }的消息到名为order.exchange的交换机。 - (模拟)在
product-service中创建一个消费者,监听order.queue队列。收到消息后,执行"扣减库存"的数据库操作。这体现了最终一致性的思想。
- 在
Sprint 4: 统一入口与安全保障 (网关与治理)
-
配置 Spring Cloud Gateway:
- 在
gateway服务的application.yml中配置路由规则。 - 将
/api/user/**的请求路由到lb://user-service。 - 将
/api/product/**的请求路由到lb://product-service。 - 将
/api/order/**的请求路由到lb://order-service。 lb代表从Nacos中进行负载均衡。
- 在
-
整合 Sentinel - 为核心接口保驾护航:
- 为
order-service引入spring-cloud-starter-alibaba-sentinel依赖。 - 登录 Sentinel Dashboard。
- 找到
order-service的/create接口。 - 配置流控规则: 比如设置 QPS > 10 时,直接拒绝请求。
- 配置熔断规则: 比如当接口在5秒内,错误率超过50%,则将此接口熔断(在接下来10秒内直接返回错误,不再调用业务逻辑),防止雪崩。
- 使用
JMeter或Postman等工具对"创建订单"接口进行压力测试,观察Sentinel仪表盘的效果。
- 为
D. 交付标准与未来展望
项目完成清单 (Definition of Done)
请逐项核对,确保你的项目达到了毕业标准:
-
docker-compose.yml可以一键启动所有中间件。 -
user-service的注册、登录、缓存查询接口工作正常。 -
product-service的商品查询、缓存查询接口工作正常。 -
order-service的创建订单接口可以正确地通过 OpenFeign 调用用户和商品服务。 -
order-service成功创建订单后,RabbitMQ 能接收到消息。 -
product-service(或模拟的消费者) 能监听到消息并处理。 - 所有对内服务的请求都已通过 Gateway 进行统一路由。
- Sentinel 为创建订单接口配置的流控、熔断规则在压力测试下生效。
能力拓展
恭喜你!完成了上述所有内容,你已经具备了中级Java开发的水平。但技术之路永无止境,如果你渴望继续深造,可以从以下方向探索:
- 分布式事务: 在我们的设计中,创建订单和扣减库存是异步的,这属于"最终一致性"。如果要求"强一致性",该如何保证?
- 学习方向: 了解并引入 Seata 框架,使用其AT模式或TCC模式来管理分布式事务。
- 可观测性 (Observability): 微服务数量增多后,如何快速定位是哪个服务出了问题?如何聚合所有服务的日志?
- 学习方向: 了解并搭建 ELK (Elasticsearch, Logback, Kibana) 或 EFK (Elasticsearch, Fluentd, Kibana) 日志收集与检索系统。
- 自动化运维 (DevOps): 如何告别手动打包和部署,实现代码提交后自动发布上线?
- 学习方向: 学习使用 Jenkins 或 GitLab CI/CD 编写流水线(Pipeline),将所有服务打包成 Docker 镜像,并推送到镜像仓库,最终使用 Kubernetes (K8s) 进行容器编排和自动化部署。
E. 手册终章寄语
朋友,恭喜你,抵达了终点站!
当你完成 “Treademo” 这个项目时,你已经完成了一次意义非凡的远征。从 MyBatis 的 SQL 魔法,到 Spring 的庞大生态,从并发编程的烧脑细节,到微服务架构的宏伟蓝图,你不仅收获了知识,更重要的是,你亲手将它们 превратить (转变为) 了一个有血有肉的项目。
这个项目,就是你技术能力的最佳证明,是你简历上最闪亮的徽章。带着它,带着你在本书中学到的一切,去自信地迎接属于你的职业挑战吧!
但请记住,技术的海洋没有尽头。毕业,只是新的开始。永远保持好奇,永远保持学习的热情。
The Force will be with you, always.
祝前程似锦,武运昌隆!
——StackingSu
1286

被折叠的 条评论
为什么被折叠?



