MyBatis-Plus快速实现单表 CRUD
要理解MyBatis-Plus(简称MP)快速实现单表CRUD的过程,需从依赖管理、Mapper接口设计、单元测试验证三个核心环节拆解,每个环节都体现了MP"简化MyBatis开发"的核心思想。
一、环节1:引入依赖(自动装配核心组件)
MyBatis-Plus提供了专门的Spring Boot启动器依赖(mybatis-plus-boot-starter
),作用是自动完成MyBatis和MP的核心组件装配,无需手动配置SqlSessionFactory
、MapperScannerConfigurer
等基础组件。
依赖解析:
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency>
-
com.baomidou
:MyBatis-Plus的组织标识,区分其他开源项目; -
mybatis-plus-boot-starter
:Spring Boot"启动器",内部包含MyBatis核心依赖,并扩展了MP的增强能力(如自动填充、乐观锁等); -
version
:指定MP版本,需与项目整体依赖兼容。
替换MyBatis starter的原因:
该启动器内置了MyBatis的自动装配逻辑,因此无需再单独引入mybatis-spring-boot-starter
,既避免依赖冲突,又能直接使用MP的增强特性。
其他依赖辅助理解:
-
mysql-connector-j
:MySQL的JDBC驱动,运行时用于建立数据库连接; -
lombok
:简化实体类代码(自动生成getter/setter、构造器等); -
spring-boot-starter-test
:Spring Boot测试依赖,用于编写单元测试。
二、环节2:定义Mapper(继承BaseMapper
,复用CRUD方法)
MyBatis-Plus提供了BaseMapper<T>
接口,它预先定义了单表CRUD的所有常用方法(如插入、查询、更新、删除)。开发者自定义的Mapper只需继承BaseMapper
并指定实体类泛型,即可"复用"这些方法,无需手动编写SQL或Mapper方法。
BaseMapper
的方法能力(看截图中BaseMapper
的方法列表):
-
插入:
insert(T entity)
; -
删除:
deleteById(Serializable id)
、deleteBatchIds(Collection<?>)
等; -
更新:
updateById(T entity)
; -
查询:
selectById(Serializable id)
、selectBatchIds(Collection<?>)
、selectList(Wrapper<T>)
等;
这些方法覆盖了单表CRUD的90%以上场景。
自定义UserMapper
示例:
package com.itheima.mp.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.itheima.mp.domain.po.User; public interface UserMapper extends BaseMapper<User> { }
-
泛型
<User>
:指定该Mapper操作的是User
实体类(需保证User
与数据库表user
的字段映射关系,MP支持注解或默认命名规则映射); -
接口无需写任何方法:直接继承
BaseMapper
的所有CRUD能力。
三、环节3:测试(验证CRUD功能)
通过Spring Boot的@SpringBootTest
启动测试环境,自动注入UserMapper
,调用其继承的方法,验证单表CRUD是否生效。
测试类核心逻辑:
@SpringBootTest // 加载Spring Boot应用上下文 class UserMapperTest { @Autowired // 自动注入UserMapper(MP已配置Mapper扫描) private UserMapper userMapper; // 测试插入 @Test void testInsert() { User user = new User(); // 设置User字段... userMapper.insert(user); // 调用BaseMapper的insert方法 } // 测试根据ID查询 @Test void testSelectById() { User user = userMapper.selectById(5L); // 调用BaseMapper的selectById方法 System.out.println(user); } // 其他测试方法(批量查询、更新、删除)同理... }
日志的验证作用:
测试时打印的SQL日志(如Preparing: SELECT ...
、Parameters: ...
),证明MP自动生成了标准SQL,且与预期逻辑一致(如根据ID查询、批量查询等)。开发者无需手动写SQL,却能得到清晰的执行过程。
四、核心优势总结
MyBatis-Plus通过"启动器自动装配 + BaseMapper预定义方法",实现了单表CRUD的"零SQL开发":
-
无需手动编写Mapper.xml或
@Select
等SQL注解; -
无需手动实现增删改查方法;
-
自动生成高效、标准的SQL,同时支持复杂查询的扩展(如Wrapper条件构造器、自定义SQL)。
这让开发者从重复的CRUD代码中解放,更聚焦于业务逻辑,极大提升开发效率。
@TableName
、@TableId
、@TableField
要理解MyBatis-Plus(MP)中@TableName
、@TableId
、@TableField
这三个核心注解,需先明确MP的"默认映射规则",再结合"特殊场景下的覆盖需求"分析每个注解的作用。
一、MP的默认映射规则(注解的"前置背景")
MP会基于实体类的命名和结构,自动推断数据库的"表名、字段名、主键":
-
表名 :实体类名采用「驼峰转下划线」规则。例如:实体类
UserInfo
→ 表名user_info
。 -
字段名 :实体类字段名采用「驼峰转下划线」规则。例如:字段
userName
→ 表字段user_name
。 -
主键 :默认将名为
id
的字段识别为主键。
但实际开发中,表名、字段名往往和"默认规则"不匹配(如:表名带前缀t_
、字段名是数据库关键字、主键名不是id
等)。此时需通过注解明确映射关系。
二、@TableName:指定实体类对应的表名
核心作用
当实体类名 ≠ 数据库表名时,用该注解强制指定"实体类 → 数据库表"的映射关系。
使用位置
实体类的类名上。
示例
假设数据库表是t_user
,实体类是User
,则通过注解指定表名:
@TableName("t_user") public class User { private Long id; private String name; }
属性详解(扩展能力)
|--------------------|------------|------|---------|-----------------------------------------------------------------------------------|
| 属性名 | 类型 | 是否必须 | 默认值 | 描述 |
| value
| String | 否 | ""
| 核心属性,指定数据库表名。 |
| schema
| String | 否 | ""
| 指定数据库的schema
(多schema场景,区分不同逻辑库)。 |
| keepGlobalPrefix
| boolean | 否 | false
| 是否保留全局配置的表前缀(若全局配了mybatis-plus.global-config.db-config.table-prefix
,此属性控制是否叠加)。 |
| resultMap
| String | 否 | ""
| 指定XML中resultMap
的id
,用于自定义结果映射(当MP自动映射不满足时,关联XML的resultMap)。 |
| autoResultMap
| boolean | 否 | false
| 是否自动构建resultMap并使用(适合复杂类型映射,如JSON字段)。 |
| excludeProperty
| String[] | 否 | {}
| 指定实体类中需要排除的属性(这些属性不参与数据库字段映射)。 |
三、@TableId:指定实体类的主键字段
核心作用
-
标记实体类中哪个字段是数据库主键;
-
可指定主键的生成策略(如:自增、雪花算法ID、UUID等)。
使用位置
实体类的主键字段上。
示例
假设实体类主键字段是userId
(而非默认的id
),则标记并指定生成策略:
@TableName("user") public class User { @TableId(type = IdType.ASSIGN_ID) // 用雪花算法生成分布式唯一ID private Long userId; private String name; }
属性详解
|---------|--------|------|---------------|------------------------------------------------------------------------------------------------------------------------------------------------------|
| 属性名 | 类型 | 是否必须 | 默认值 | 描述 |
| value
| String | 否 | ""
| 指定主键对应的数据库字段名(若字段名与属性名不一致时使用)。 |
| type
| Enum | 否 | IdType.NONE
| 主键生成策略,常见枚举值:- IdType.AUTO
:数据库自增(需表设置自增);- IdType.INPUT
:手动输入主键值;- IdType.ASSIGN_ID
:MP自动分配雪花算法ID(分布式唯一);- IdType.ASSIGN_UUID
:自动分配UUID(字符串主键)。 |
四、@TableField:指定普通字段的映射与规则
核心作用
解决"实体字段与数据库字段不匹配""特殊命名格式""关键字冲突"等非主键字段的映射问题。
何时需要用?
-
场景1:实体字段名 ≠ 数据库字段名(如:实体
userName
→ 表字段u_name
,默认驼峰转下划线不满足)。 -
场景2:布尔字段是
isXXX
格式(如:实体isMarried
→ 表字段is_married
,MP默认会去掉is
,需手动指定)。 -
场景3:字段名与数据库关键字冲突(如:实体
concat
→ 表字段concat
,但concat
是SQL关键字,需转义)。
示例
覆盖上述3种特殊场景:
@TableName("user") public class User { @TableId private Long id; private String name; // 符合默认规则,无需注解 @TableField("is_married") // 场景2:修正is开头的布尔字段 private Boolean isMarried; @TableField("`concat`") // 场景3:转义关键字字段 private String concat; }
总结:三个注解的协作逻辑
MyBatis-Plus的核心是"通过实体类自动推断数据库映射",但默认规则无法覆盖所有场景。此时:
-
@TableName
解决「表名不匹配」的问题; -
@TableId
解决「主键标识与生成策略」的问题; -
@TableField
解决「普通字段的特殊映射与规则」的问题。
三者配合,让"实体类 → 数据库表/字段"的映射更灵活、精准,同时保留了MP"零SQL实现CRUD"的开发效率。
@TableField
注解中常用属性的代码示例
以下是@TableField
注解中常用属性的代码示例,结合实际开发场景说明其用法:
1. value
:解决字段名不匹配
场景:实体类字段名与数据库字段名不一致(非驼峰转下划线规则)
例如:实体类用userName
,数据库字段是u_name
@TableName("user") public class User { @TableId private Long id; // 数据库字段是u_name,实体类是userName,通过value指定映射 @TableField("u_name") private String userName; private Integer age; // 符合驼峰转下划线(age→age),无需注解 }
2. exist
:标记非数据库字段
场景:实体类中有临时计算字段(无需存入数据库,CRUD时需忽略)
例如:实体类有fullName
(由firstName+lastName
拼接,不存数据库)
@TableName("user") public class User { @TableId private Long id; private String firstName; private String lastName; // 标记为非数据库字段,MP CRUD操作会忽略该字段 @TableField(exist = false) private String fullName; // 临时字段,仅内存中使用 // 计算fullName的逻辑 public String getFullName() { return firstName + "·" + lastName; } }
3. fill
:自动填充(创建时间/更新时间)
场景 :插入或更新时自动填充时间(如createTime
插入时填充,updateTime
更新时填充)
需配合元对象处理器(MetaObjectHandler) 使用
@TableName("user") public class User { @TableId private Long id; private String name; // 插入时自动填充(如注册时间) @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; // 插入和更新时自动填充(如最后修改时间) @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; } // 配套的元对象处理器(需注入Spring) @Component public class MyMetaObjectHandler implements MetaObjectHandler { // 插入时填充 @Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } // 更新时填充 @Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } }
4. update
:自定义更新规则(乐观锁)
场景 :更新时需要特殊逻辑(如乐观锁version=version+1
,或balance=balance-100
)
@TableName("user") public class User { @TableId private Long id; private String name; // 乐观锁字段:更新时自动执行version=version+1 @TableField(update = "version + 1") private Integer version; // 余额字段:支持自定义更新(如扣减100) @TableField(update = "balance - 100") private Integer balance; } // 使用示例:更新时无需手动设置version和balance的增减 User user = new User(); user.setId(1L); userMapper.updateById(user); // 生成的SQL会自动包含:SET version=version+1, balance=balance-100 WHERE id=1
5. insertStrategy
:插入字段策略
场景:插入时仅当字段非空才写入数据库(避免插入null覆盖默认值)
例如:数据库age
字段默认值为18,实体类age
为null时不插入该字段
@TableName("user") public class User { @TableId private Long id; private String name; // 插入时:仅当age非null才写入,否则用数据库默认值 @TableField(insertStrategy = FieldStrategy.NOT_NULL) private Integer age; // 若age=null,INSERT语句会忽略该字段 }
6. updateStrategy
:更新字段策略
场景:更新时忽略null值(避免覆盖数据库已有值)
例如:仅更新传入的非null字段,null字段不参与更新
@TableName("user") public class User { @TableId private Long id; // 更新时:仅当name非null才更新,null则忽略 @TableField(updateStrategy = FieldStrategy.NOT_NULL) private String name; // 更新时:忽略null(默认策略,可省略) private Integer age; } // 使用示例:仅更新name,age为null则不更新 User user = new User(); user.setId(1L); user.setName("新名字"); user.setAge(null); // 该字段不会出现在UPDATE语句中 userMapper.updateById(user);
7. select
:控制查询是否包含字段
场景 :查询时排除敏感字段(如密码password
)
@TableName("user") public class User { @TableId private Long id; private String username; // 查询时自动排除该字段(select * 不会包含password) @TableField(select = false) private String password; // 敏感字段,无需查询返回 private Integer balance; } // 使用示例:查询结果中不会包含password User user = userMapper.selectById(1L); // SQL实际执行:SELECT id, username, balance FROM user WHERE id=1
这些示例覆盖了@TableField
最常用的属性,核心是解决"映射不匹配""字段策略控制""自动填充"等实际开发问题,配合MyBatis-Plus的CRUD方法可大幅简化代码。
MyBatis-Plus的自定义配置 和 手写SQL能力
这些讲义内容围绕 MyBatis-Plus的自定义配置 和 手写SQL能力 展开,核心是让MP既能"零配置简化单表CRUD",又能"灵活支持复杂SQL场景"。以下分两部分详细讲解:
一、MyBatis-Plus的YAML自定义配置
MyBatis-Plus大部分配置有默认值(无需手动写),但部分关键行为(如实体别名、主键策略)需通过 application.yml
自定义。
1. 实体类别名扫描(type-aliases-package
)
-
作用:指定"实体类所在的包路径",MyBatis-Plus会自动将该包下的类注册为别名(无需写"全限定类名"),简化Mapper XML的编写。
-
配置示例:
mybatis-plus: type-aliases-package: com.itheima.mp.domain.po # 实体类所在的包
-
效果 :在Mapper XML中,
resultType="User"
可直接代替resultType="com.itheima.mp.domain.po.User"
。
2. 全局主键生成策略(global-config.db-config.id-type
)
-
作用:统一设置所有表的主键生成规则(避免每张表单独配置)。
-
常用枚举值:
-
auto
:数据库自增(需数据库表的主键字段开启"自增"); -
assign_id
:MP自动分配雪花算法ID(适合分布式系统,生成Long型唯一ID); -
assign_uuid
:自动分配UUID(生成String型唯一ID)。
-
-
配置示例:
mybatis-plus: global-config: db-config: id-type: auto # 全局主键策略为"数据库自增"
二、手写SQL的配置与使用
MyBatis-Plus虽能"零SQL实现单表CRUD",但复杂查询(多表关联、自定义条件) 仍需手写SQL。此时需配置"Mapper XML文件的扫描路径",让MP加载这些自定义SQL。
1. Mapper XML文件的扫描路径(mapper-locations
)
-
默认配置:
mybatis-plus: mapper-locations: "classpath*:/mapper//*.xml"
-
classpath*:
:扫描所有类路径(包括项目资源目录、依赖的jar包); -
/mapper//*.xml
:匹配mapper
目录及其所有子目录下的.xml
文件。
-
-
效果 :只要把Mapper XML文件放在
resources/mapper
(或其子目录)下,MP会自动加载这些文件中的SQL。
2. 手写SQL示例(以UserMapper.xml
为例)
需完成「XML编写 → 接口定义 → 测试调用」三步:
步骤1:编写Mapper XML文件
在 resources/mapper
下创建 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"> <!-- namespace:必须与Mapper接口的"全限定名"一致 --> <mapper namespace="com.itheima.mp.mapper.UserMapper"> <!-- select标签:定义查询SQL id:与Mapper接口中的"方法名"一致(如 UserMapper.queryById) resultType:返回值类型(用"实体类别名",因配置了type-aliases-package) --> <select id="queryById" resultType="User"> SELECT * FROM user WHERE id = #{id} </select> </mapper>
步骤2:在Mapper接口定义方法
在 UserMapper
接口中定义与XML中 id
一致的方法(无需手动实现,由XML绑定):
public interface UserMapper extends BaseMapper<User> { // 方法名与XML中<select id="queryById">一致 User queryById(Long id); }
步骤3:测试手写SQL
通过Spring Boot的 @SpringBootTest
启动测试环境,注入 UserMapper
并调用方法:
@SpringBootTest class UserMapperTest { @Autowired private UserMapper userMapper; @Test void testQuery() { User user = userMapper.queryById(1L); // 调用手写SQL的方法 System.out.println("user = " + user); } }
核心设计理念
MyBatis-Plus的配置体系体现了 "约定大于配置"+"灵活扩展" 的思想:
-
单表CRUD依赖"默认约定"(零配置即可用);
-
特殊需求(别名、主键策略)通过"少量配置"覆盖;
-
复杂SQL兼容MyBatis的"XML手写"能力,通过
mapper-locations
保证扩展性。
QueryWrapper
、UpdateWrapper
和LambdaQueryWrapper
MyBatis-Plus提供的QueryWrapper
、UpdateWrapper
和LambdaQueryWrapper
是构建动态条件SQL的核心工具,解决了"复杂条件CRUD无需手写SQL"的问题。三者各有侧重,以下结合示例详解:
一、QueryWrapper:通用条件构造器(支持查询、更新、删除)
QueryWrapper
是最基础的条件构造器,能通过链式调用组合WHERE条件,并支持查询时指定返回字段。适用于"基于条件的查询、更新、删除"场景。
1. 用于查询:构建多条件查询
需求:查询"名字中带o"且"存款≥1000元"的用户,只返回id、username、info、balance字段。
代码解析:
// 1. 构建查询条件:WHERE username LIKE "%o%" AND balance >= 1000 QueryWrapper<User> wrapper = new QueryWrapper<User>() .select("id", "username", "info", "balance") // 指定要查询的字段(避免查所有字段) .like("username", "o") // 模糊匹配:username LIKE "%o%" .ge("balance", 1000); // 大于等于:balance >= 1000 // 2. 执行查询:根据条件查询列表 List<User> users = userMapper.selectList(wrapper);
核心方法:
-
select(字段1, 字段2...)
:指定查询字段(类似SQL的SELECT 字段1,字段2
); -
like(字段名, 值)
:模糊匹配(自动拼接%
); -
ge(字段名, 值)
:大于等于(>=
)。
2. 用于更新:基于条件更新字段
需求:将"用户名为Jack"的用户余额改为2000。
代码解析:
// 1. 构建条件:WHERE username = "Jack" QueryWrapper<User> wrapper = new QueryWrapper<User>().eq("username", "Jack"); // 2. 准备更新数据:实体中非null字段会作为SET语句 User user = new User(); user.setBalance(2000); // 只更新balance字段 // 3. 执行更新:根据条件更新匹配的记录 userMapper.update(user, wrapper);
核心逻辑:
-
eq(字段名, 值)
:等值匹配(=
),用于精准定位更新对象; -
update(实体, wrapper)
:实体中的非null字段会生成SET 字段=值
,wrapper中的条件生成WHERE
子句。
二、UpdateWrapper:增强更新能力(支持基于字段现有值的更新)
QueryWrapper
的更新能力有限(只能直接赋值,如balance=2000
),而UpdateWrapper
新增了setSql
方法,支持基于字段现有值的动态更新(如balance=balance-200
)。
场景示例:批量扣减余额
需求 :更新id为1、2、4的用户,余额各扣200(对应SQL:UPDATE user SET balance=balance-200 WHERE id IN (1,2,4)
)。
代码解析:
// 1. 准备要更新的id列表 List<Long> ids = List.of(1L, 2L, 4L); // 2. 构建更新条件和更新规则 UpdateWrapper<User> wrapper = new UpdateWrapper<User>() .setSql("balance = balance - 200") // 核心:基于现有值更新(SET子句) .in("id", ids); // 条件:WHERE id IN (1,2,4) // 3. 执行更新:第一个参数传null(无需实体,更新规则在wrapper中) userMapper.update(null, wrapper);
核心优势:
-
setSql(sql片段)
:直接写SQL片段,支持"字段自增/自减""函数计算"等复杂更新(如update_time=NOW()
); -
无需创建实体类,适合"无需提前知道具体值,只需要基于现有值更新"的场景。
三、LambdaQueryWrapper:避免字段名硬编码(更安全的条件构造)
QueryWrapper
和UpdateWrapper
在写条件时,字段名需用字符串(如like("username", "o")
),存在两个问题:
-
字符串"硬编码"(魔法值),不符合编程规范;
-
字段名拼写错误或重构时,编译期无法发现,容易出bug。
LambdaQueryWrapper
通过Lambda表达式/方法引用传递字段,解决了这些问题。
用法示例:安全的条件查询
需求:同QueryWrapper的查询示例(名字带o且存款≥1000),但用Lambda方式避免字符串字段名。
代码解析:
// 1. 构建条件:WHERE username LIKE "%o%" AND balance >= 1000 QueryWrapper<User> wrapper = new QueryWrapper<>(); wrapper.lambda() // 切换为Lambda模式 .select(User::getId, User::getUsername, User::getInfo, User::getBalance) // 用方法引用指定字段 .like(User::getUsername, "o") // 用User::getUsername代替"username" .ge(User::getBalance, 1000); // 用User::getBalance代替"balance" // 2. 执行查询(和之前一致) List<User> users = userMapper.selectList(wrapper);
核心改进:
-
用
User::getUsername
(方法引用)代替字符串"username"
,字段名由编译器检查,避免拼写错误; -
当实体类字段名重构(如
username
改为userName
)时,方法引用会自动同步,无需手动修改条件构造器。
总结:三者的区别与选用场景
|--------------------|----------------------|---------------------|--------------------|
| 工具类 | 核心能力 | 适用场景 | 优势 |
| QueryWrapper | 构建WHERE条件,支持查询指定字段 | 简单查询、基于条件的直接赋值更新/删除 | 通用、简洁 |
| UpdateWrapper | 支持基于字段现有值的更新(setSql) | 复杂更新(如字段自增/自减、函数计算) | 突破"只能直接赋值"的限制 |
| LambdaQueryWrapper | 基于Lambda表达式传递字段 | 所有需要条件构造的场景(推荐优先使用) | 避免字段名硬编码,编译期检查,更安全 |
实际开发中,优先使用Lambda版本(LambdaQueryWrapper/LambdaUpdateWrapper),减少错误;复杂更新用UpdateWrapper,简单场景用QueryWrapper即可。
自定义SQL功能
要理解MyBatis-Plus的自定义SQL功能,我们可以从「解决的问题」和「具体用法」两部分展开,结合单表复杂操作、多表关联场景讲解:
一、为什么需要「自定义SQL」?
在直接使用UpdateWrapper/QueryWrapper
时(如开篇图片中testUpdateWrapper
的写法),存在两个问题:
-
SQL维护层级不合理 :把SQL片段(如
balance = balance - 200
)写在业务层,而企业通常要求SQL统一维护在持久层(Mapper层)。 -
复杂条件编写繁琐 :如果涉及
IN
、多表关联等复杂条件,传统MyBatis需要在Mapper.xml
中写foreach
等动态SQL,极其繁琐(比如多表关联时的WHERE
条件拼接)。
因此,MyBatis-Plus提供「自定义SQL + Wrapper」的方式,让Wrapper负责生成条件,Mapper负责维护SQL主体,简化开发。
二、自定义SQL的「基本用法」(单表复杂条件场景)
以「批量扣减用户余额」为例,需求是:扣减id
为1、2、4的用户,每人余额减200。
步骤1:业务层构建查询条件
用QueryWrapper
生成"id
在指定列表中"的条件,代码更简洁:
@Test void testCustomWrapper() { // 1. 准备条件:id在[1,2,4]中 List<Long> ids = List.of(1L, 2L, 4L); QueryWrapper<User> wrapper = new QueryWrapper<User>().in("id", ids); // 2. 调用Mapper自定义方法,传递条件和参数(扣减金额200) userMapper.deductBalanceByIds(200, wrapper); }
步骤2:持久层(Mapper)定义SQL
在UserMapper
中,用${ew.customSqlSegment}
引用Wrapper生成的条件(如WHERE id IN (1,2,4)
),SQL主体由Mapper维护:
public interface UserMapper extends BaseMapper<User> { @Update("UPDATE user SET balance = balance - #{money} ${ew.customSqlSegment}") void deductBalanceByIds(@Param("money") int money, @Param("ew") QueryWrapper<User> wrapper); }
-
@Param("ew")
:给QueryWrapper
起别名ew
,让SQL中能通过${ew.customSqlSegment}
引用。 -
${ew.customSqlSegment}
:会自动拼接Wrapper生成的WHERE条件片段(比如案例中就是WHERE id IN (1,2,4)
)。
三、自定义SQL实现「多表关联查询」
MyBatis-Plus本身侧重单表增强,但多表关联可通过「自定义SQL + Wrapper」实现,简化复杂条件编写。
需求:查询"收货地址在北京,且用户id为1、2、4"的用户(涉及user
和address
两表关联)。
步骤1:业务层构建多表条件
用QueryWrapper
指定多表字段的条件(注意表别名,如u.id
、a.city
):
@Test void testCustomJoinWrapper() { // 1. 构建多表条件:u.id在[1,2,4],且a.city为"北京" QueryWrapper<User> wrapper = new QueryWrapper<User>() .in("u.id", List.of(1L, 2L, 4L)) .eq("a.city", "北京"); // 2. 调用Mapper自定义方法,查询结果 List<User> users = userMapper.queryUserByWrapper(wrapper); users.forEach(System.out::println); }
步骤2:持久层定义多表SQL
有两种方式(注解/XML),核心是用${ew.customSqlSegment}
引用Wrapper的条件:
-
注解方式:
@Select("SELECT u.* FROM user u INNER JOIN address a ON u.id = a.user_id ${ew.customSqlSegment}") List<User> queryUserByWrapper(@Param("ew") QueryWrapper<User> wrapper);
-
XML方式 (在
UserMapper.xml
中):<select id="queryUserByIdAndAddr" resultType="com.itheima.mp.domain.po.User"> SELECT * FROM user u INNER JOIN address a ON u.id = a.user_id ${ew.customSqlSegment} </select>
无论哪种方式,${ew.customSqlSegment}
都会自动拼接Wrapper生成的条件(比如WHERE u.id IN (1,2,4) AND a.city = '北京'
),避免了手动写foreach
等复杂动态SQL。
总结:自定义SQL的核心价值
通过「Wrapper生成条件 + Mapper维护SQL主体 + ${ew.customSqlSegment}
拼接条件」,实现:
-
SQL分层维护:业务层不写SQL细节,持久层统一管理。
-
简化复杂条件 :单表
IN
、多表关联等复杂条件,无需手动写foreach
/多表WHERE
拼接,由Wrapper自动生成。
Service接口
MyBatis-Plus的Service接口是为了简化业务层开发,封装了大量常用的"增删改查"等业务方法,核心是 IService
接口和 ServiceImpl
默认实现类。下面按功能分类,通俗易懂地讲解这些方法:
一、新增(Save):往数据库加数据
应对"单条新增、批量新增、新增或更新"等场景:
-
save(T)
:新增1条数据(传单个对象)。 -
saveBatch(Collection<T>)
:批量新增(传对象集合,一次性加多条)。 -
saveBatch(Collection<T>, int)
:带"批次大小"的批量新增(int
控制每批提交多少条)。 -
saveOrUpdate(T)
:新增或更新(根据ID判断:数据库有该ID就更新,没有就新增)。 -
saveOrUpdateBatch(Collection<T>)
:批量新增/更新(集合中每个对象都判断"新增还是更新")。 -
saveOrUpdateBatch(Collection<T>, int)
:带"批次大小"的批量新增/更新。
二、删除(Remove):从数据库删数据
按"ID、条件、批量"等方式删除:
-
removeById(Serializable)
:根据单个ID删除(如删ID=1的用户)。 -
removeByIds(Collection<?>)
:根据多个ID批量删除(如删ID=1、2、3的用户)。 -
removeByMap(Map<String, Object>)
:根据Map键值对条件删除(如{"name": "张三"}
,删名字为张三的记录)。 -
remove(Wrapper<T>)
:根据Wrapper条件删除(Wrapper可构造复杂条件,如删"年龄>30"的用户)。
三、修改(Update):更新数据库已有数据
按"ID、条件、批量"等方式更新:
-
updateById(T)
:根据ID更新(传带ID的对象,把对象字段更新到数据库)。 -
update(Wrapper<T>)
:根据UpdateWrapper更新(Wrapper需包含"set什么值"和"where什么条件")。 -
update(T, Wrapper<T>)
:按对象数据 + Wrapper条件更新(如对象带age=25
,Wrapper带where name='李四'
,即"名字为李四的用户,年龄改25")。 -
updateBatchById(Collection<T>)
:根据ID批量更新(传多个带ID的对象,批量更新)。 -
updateBatchById(Collection<T>, int)
:带"批次大小"的批量更新。
四、查询(Get/List):从数据库查数据
分"查单个"和"查列表":
查单个(Get):
-
getById(Serializable)
:根据单个ID查1条数据(如查ID=1的用户)。 -
getOne(Wrapper<T>)
:根据Wrapper条件查1条数据(如查"名字=张三"的用户)。 -
getBaseMapper()
:获取Service内部的BaseMapper
(如果需要调用Mapper的自定义SQL,用它拿Mapper)。
查列表(List):
-
listByIds(Collection<?>)
:根据多个ID批量查(如查ID=1、2、3的用户列表)。 -
listByMap(Map<String, Object>)
:根据Map键值对条件查列表(如查{"city": "北京"}
的用户)。 -
list(Wrapper<T>)
:根据Wrapper条件查多条数据(如查"年龄>20"的用户列表)。 -
list()
:查所有数据(如查整个用户表的所有记录)。
五、计数(Count):统计数据条数
-
count()
:统计所有数据的总数(如用户表总记录数)。 -
count(Wrapper<T>)
:统计符合Wrapper条件的数据数(如统计"年龄>30"的用户数量)。
核心价值
业务层不用自己写重复的"增删改查"逻辑,只要让Service接口继承 IService
、实现类继承 ServiceImpl
,就能直接用这些封装好的方法,大大减少重复代码~
Service层的实际开发流程
要理解MyBatis-Plus中Service层的实际开发流程,我们可以从「基础结构」「分层协作」「业务拓展」三个维度,结合代码逐步讲解:
一、Service层的"基础骨架":继承IService与ServiceImpl
MyBatis-Plus提供了通用Service接口(IService
)和默认实现类(ServiceImpl
),封装了大量CRUD(增删改查)方法。但实际业务中,我们需要自定义Service来拓展业务方法,步骤如下:
1. 自定义Service接口(继承IService
)
public interface IUserService extends IService<User> { // 将来可在此定义"扣减余额"等业务方法 }
-
继承
IService<User>
后,直接拥有MyBatis-Plus封装的所有通用方法(如save
新增、removeById
删除、getById
查询等)。 -
目的:为业务方法预留拓展入口(比如后续的
deductBalance
扣减余额)。
2. 自定义Service实现类(继承ServiceImpl
)
@Service // 交给Spring管理 public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService { // 无需手动实现IService的通用方法(ServiceImpl已实现) }
-
继承
ServiceImpl<UserMapper, User>
:-
ServiceImpl
已经实现了IService
的所有通用方法,直接复用即可。 -
通过
baseMapper
属性,可直接获取当前实体的Mapper(UserMapper),无需手动@Autowired
注入。
-
二、分层协作:Controller、DTO/VO、Service、Mapper
实际开发中,"前端→Controller→Service→Mapper→数据库"是一条完整链路,各层职责不同,且通过数据传输对象(DTO)和视图对象(VO)解耦。
1. 接口文档与依赖:Swagger/Knife4j
为了让接口更易读,用Knife4j
(Swagger增强版)生成接口文档:
-
引入依赖:
knife4j-openapi2-spring-boot-starter
(替代传统Swagger,更简洁)。 -
配置文档信息:在
application.yml
中指定标题、版本等,让接口文档更规范。
2. DTO与VO:解耦前后端数据格式
-
UserFormDTO:前端新增用户时的表单参数(包含密码、手机号等前端需要的字段)。
-
UserVO:后端返回给前端的结果(隐藏密码等敏感字段,只暴露用户名、余额等需要展示的内容)。
-
属性拷贝 :用
Hutool
的BeanUtil
实现DTO ↔ PO
(数据库实体)、PO ↔ VO
的自动属性拷贝,避免手动set
。
3. Controller:暴露Restful接口
Controller负责接收HTTP请求,调用Service处理业务:
@RestController @RequestMapping("users") public class UserController { private final IUserService userService; // 注入自定义Service // 新增用户:调用Service的通用save方法 @PostMapping public void saveUser(@RequestBody UserFormDTO userFormDTO) { User user = BeanUtil.copyProperties(userFormDTO, User.class); // DTO→PO userService.save(user); // 直接用IService的save方法 } // 删除用户:调用Service的通用removeById方法 @DeleteMapping("/{id}") public void removeUserById(@PathVariable Long userId) { userService.removeById(userId); // 直接用IService的removeById方法 } }
- 这些接口直接复用了MyBatis-Plus的通用方法,因此Service层无需额外编写代码,开发效率极高。
三、业务拓展:自定义Service方法(以"扣减余额"为例)
当业务包含复杂逻辑(如状态校验、余额校验)时,需要自定义Service方法,甚至结合自定义Mapper的SQL。
1. Controller定义业务接口
// 扣减用户余额:调用自定义的Service方法 @PutMapping("{id}/deduction/{money}") public void deductBalance(@PathVariable Long id, @PathVariable Integer money) { userService.deductBalance(id, money); }
2. Service接口声明业务方法
public interface IUserService extends IService<User> { void deductBalance(Long id, Integer money); // 自定义"扣减余额"方法 }
3. ServiceImpl实现业务逻辑
@Override public void deductBalance(Long id, Integer money) { // 1. 通用查询:用IService的getById方法查用户 User user = getById(id); // 2. 业务校验:用户状态是否正常 if (user == null || user.getStatus() == 2) { throw new RuntimeException("用户状态异常"); } // 3. 业务校验:余额是否充足 if (user.getBalance() < money) { throw new RuntimeException("用户余额不足"); } // 4. 自定义SQL:调用Mapper的扣减方法 baseMapper.deductMoneyById(id, money); // baseMapper直接拿到UserMapper }
- 这里既用了MyBatis-Plus的通用查询方法(
getById
),又通过baseMapper
调用自定义Mapper的SQL,实现"通用能力 + 业务定制"的结合。
4. Mapper自定义SQL
public interface UserMapper extends BaseMapper<User> { // 自定义更新SQL:扣减余额 @Update("UPDATE user SET balance = balance - #{money} WHERE id = #{id}") void deductMoneyById(@Param("id") Long id, @Param("money") Integer money); }
- 因为"扣减余额"是定制化的更新逻辑,所以在Mapper中用
@Update
注解写自定义SQL,参数通过@Param
传递。
整体逻辑总结
MyBatis-Plus的Service层设计,是"通用能力 + 自定义业务"的高效结合:
-
通用CRUD :继承
IService
和ServiceImpl
,直接复用封装好的增删改查,无需重复开发。 -
业务拓展:通过"自定义Service接口→实现类(含业务校验)→调用Mapper自定义SQL",灵活应对复杂业务。
-
分层清晰:Controller(接口)、Service(业务)、Mapper(数据库)各司其职,DTO/VO解耦数据格式,Swagger保障接口规范。
这种方式既利用了MyBatis-Plus的便捷性,又能完美适配实际业务需求~
Lambda功能
MyBatis-Plus的Lambda功能是为了简化「复杂查询条件构建」和「动态更新操作」而设计的,核心是通过实体类方法引用(如User::getUsername
)替代字符串字段名,减少拼写错误,同时支持动态条件判断。下面结合两个案例,通俗讲解其用法和优势:
一、案例一:复杂动态查询(多条件可选筛选)
实际业务中,经常需要根据「可选条件」查询数据(比如后台管理系统的多条件筛选)。例如:按用户名关键字、用户状态、余额范围查询用户,这些条件可能为空(用户可选择不填)。
1. 传统方式的问题
如果用普通QueryWrapper
,需要手动new
对象,且字段名用字符串(容易拼错),代码繁琐:
// 传统方式:繁琐且易出错 QueryWrapper<User> wrapper = new QueryWrapper<>(); if (username != null) { wrapper.like("username", username); // 字符串字段名,易拼错 } if (status != null) { wrapper.eq("status", status); } // ... 其他条件
2. LambdaQueryWrapper简化动态查询
Lambda方式通过实体类的方法引用(如User::getUsername
)指定字段,且支持「条件判断」作为第一个参数,直接动态添加条件:
// 定义查询条件实体(接收前端参数) @Data public class UserQuery { private String name; // 用户名关键字(可选) private Integer status; // 状态(可选) private Integer minBalance; // 最小余额(可选) private Integer maxBalance; // 最大余额(可选) } // Controller中使用LambdaQueryWrapper @GetMapping("/list") public List<UserVO> queryUsers(UserQuery query) { // 直接通过lambdaQuery()获取Lambda查询器,链式构建条件 List<User> users = userService.lambdaQuery() // 条件1:如果name不为空,就是构造赋值条件 User::getUsername=query.getName(), //若为空则like这条语句直接不执行 .like(query.getName() != null, User::getUsername, query.getName()) // 条件2:如果status不为空,就按状态精确匹配 .eq(query.getStatus() != null, User::getStatus, query.getStatus()) // 条件3:如果minBalance不为空,就查询余额>=该值 .ge(query.getMinBalance() != null, User::getBalance, query.getMinBalance()) // 条件4:如果maxBalance不为空,就查询余额<=该值 .le(query.getMaxBalance() != null, User::getBalance, query.getMaxBalance()) // 最终返回集合结果 .list(); return BeanUtil.copyToList(users, UserVO.class); }
3. 核心优势
-
类型安全 :用
User::getUsername
替代字符串"username"
,编译期就能检查字段是否存在,避免拼写错误。 -
动态条件简化 :每个条件方法(
like
/eq
/ge
等)的第一个参数是「布尔值」,为true
时才添加该条件,无需手动写if
判断(类似MyBatis的<if>
标签,但更简洁)。 -
链式编程 :直接通过
userService.lambdaQuery()
获取查询器,无需手动new
,代码更流畅。
4. 结果获取方式
链式调用的最后一步,通过以下方法指定返回结果类型:
-
.list()
:返回查询结果集合(最常用)。 -
.one()
:返回单个结果(最多1条)。 -
.count()
:返回符合条件的记录总数。
二、案例二:动态更新(根据条件动态设置字段)
当更新操作需要「根据业务逻辑动态决定更新哪些字段」时,lambdaUpdate
可以简化实现。例如:扣减用户余额后,如果余额为0,就将用户状态改为「冻结」。
1. 业务需求分析
-
扣减用户余额(
balance = balance - 扣减金额
)。 -
动态判断:如果扣减后余额为0,额外更新
status = 2
(冻结)。 -
加乐观锁:确保扣减过程中数据不被其他操作干扰。
2. lambdaUpdate实现动态更新
@Service public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService { @Override @Transactional // 事务保证 public void deductBalance(Long id, Integer money) { // 1.查询用户当前信息 User user = getById(id); // 2.校验:用户不存在或已冻结 if (user == null || user.getStatus() == 2) { throw new RuntimeException("用户状态异常!"); } // 3.校验:余额不足 if (user.getBalance() < money) { throw new RuntimeException("用户余额不足!"); } // 4.计算扣减后余额 int remainBalance = user.getBalance() - money; // 5.用lambdaUpdate动态构建更新操作 lambdaUpdate() // 必更字段:更新余额为扣减后的值 .set(User::getBalance, remainBalance) // 动态字段:如果余额为0,才更新状态为冻结(2) .set(remainBalance == 0, User::getStatus, 2) // 条件:仅更新指定id的用户 .eq(User::getId, id) // 乐观锁:确保扣减前的余额和查询时一致(防止并发问题) .eq(User::getBalance, user.getBalance()) // 执行更新 .update(); } }
3. 核心逻辑解析
-
动态设置字段 :
.set(condition, column, value)
中,condition
为true
时才会添加该更新字段(如remainBalance == 0
时,才更新status
)。 -
条件约束 :
.eq(User::getId, id)
确保只更新目标用户;.eq(User::getBalance, user.getBalance())
是乐观锁,防止多线程下的余额错乱(例如:A操作查询余额为100,B操作先扣减到50,A再扣减就会失败)。 -
无需手动写SQL:通过链式调用自动生成更新SQL,无需在Mapper中定义自定义方法。
总结:Lambda功能的核心价值
-
简化代码 :用链式编程替代繁琐的
if
判断和new Wrapper
操作。 -
类型安全 :通过实体类方法引用(
User::getXxx
)避免字段名拼写错误。 -
动态灵活 :支持条件判断(
true/false
)来动态添加查询/更新条件,适配复杂业务场景。
无论是多条件查询还是动态更新,Lambda功能都能让代码更简洁、更安全、更易维护~
批量新增的性能优化
要理解MyBatis-Plus批量新增的性能优化,可以用"发快递"的生活场景类比,一步步拆解逻辑:
一、场景1:逐条插入 → 性能灾难
假设要给10万个用户"发快递"(插入数据):
-
代码逻辑:循环10万次,每次调用
userService.save()
插入1条数据。 -
实际效果:相当于每次只发1个快递,发完1个再发下1个。
-
数据库需要"建立连接→解析SQL→执行插入→断开连接"10万次,IO和解析开销极大。
-
测试耗时:约 23万毫秒(230秒),速度慢到无法接受。
-
二、场景2:MyBatis-Plus默认批量插入 → 有改进,但不完美
改用MyBatis-Plus的 saveBatch
批量插入(比如每1000条凑一批):
-
代码逻辑:循环10万次,每凑1000条就调用
userService.saveBatch()
批量插入。 -
实际效果:相当于每1000个快递凑成1批发,"叫快递"的次数从10万次减少到100次(10万÷1000)。
-
但数据库仍需逐条执行SQL(MyBatis-Plus默认生成多条
INSERT INTO ... VALUES (...)
语句),每条SQL都要"解析→执行",1000条还是要解析1000次,性能瓶颈仍在。 -
测试耗时:约 2.2万毫秒(22秒),速度有提升,但还能更优。
-
三、理想场景:"一批快递打包成1个大包裹" → 一条SQL插入多条数据
如果能把"1000个快递打包成1个大包裹",快递员一次就能送完,效率会爆炸。对应到SQL,就是写成:
INSERT INTO user (...) VALUES (...), (...), (...) -- 一条SQL插入多条数据
数据库只需解析1次SQL,就能插入所有数据,解析次数从1000次骤减到1次,性能会大幅提升。
四、MySQL的"神助攻":rewriteBatchedStatements
参数
MySQL的JDBC驱动有个参数 rewriteBatchedStatements
,默认是 false
(不开启)。开启后(设为 true
),驱动会自动把"多条预编译的INSERT语句",重写成"一条多值INSERT语句"。
类比:告诉快递系统"把1000个小快递自动打包成1个大包裹,一次送过去"。
五、配置开启 + 测试:性能暴涨
-
配置参数 :在项目的
application.yml
中,给MySQL的JDBC连接URL添加&rewriteBatchedStatements=true
:spring: datasource: url: jdbc:mysql://...?useUnicode=true&...&rewriteBatchedStatements=true
-
再次测试 :用
saveBatch
批量插入10万条数据。-
实际效果:SQL被重写成"一条多值插入"的形式,数据库只需解析少量SQL。
-
测试耗时:约 5千多毫秒(5秒左右),性能直接"起飞"。
-
总结逻辑
-
逐条插入:次数太多 → 慢。
-
普通批量插入:减少了"连接次数",但没减少"SQL解析次数" → 仍有瓶颈。
-
开启
rewriteBatchedStatements
:让批量SQL被重写成"一条多值插入" → 减少SQL解析次数 → 性能拉满。
MyBatis-Plus的Db
静态工具类
要理解MyBatis-Plus的Db
静态工具类及其在避免循环依赖、关联查询中的作用,我们结合"查询用户并返回收货地址列表"的案例,分步骤讲解:
一、什么是「循环依赖」?为什么要避免?
假设 UserService
需要调用 AddressService
的方法,同时 AddressService
也需要调用 UserService
的方法------这种Service之间相互依赖、形成闭环的情况,就是「循环依赖」。
Spring容器在初始化这类Bean时,容易出现"创建A需要B,创建B又需要A"的死锁,导致Bean初始化失败(或需要特殊配置才能解决)。
二、MyBatis-Plus的Db
工具类:解决循环依赖的"捷径"
Db
是MyBatis-Plus提供的静态工具类,它封装了和 IService
类似的CRUD方法(如 getById
、lambdaQuery
、lambdaUpdate
等),但无需注入Service/Mapper,直接通过类名调用。
这样一来,当需要跨Service查询数据时,无需依赖其他Service,直接用 Db
查数据库即可,从根源上避免了"循环依赖"。
三、案例:查询用户 + 关联查询收货地址
需求:根据用户id,查询用户信息的同时,返回该用户的所有收货地址。
步骤1:定义VO(给前端的"视图对象")
-
AddressVO
:封装"收货地址"的信息(省、市、联系人等)。 -
UserVO
:在用户基本信息基础上,新增List<AddressVO> addresses
属性,用来承载"该用户的收货地址列表"。

步骤2:Controller层:暴露查询接口
@GetMapping("/{id}") @ApiOperation("根据id查询用户(含收货地址)") public UserVO queryUserById(@PathVariable("id") Long userId) { // 调用Service的自定义方法,查询"用户+地址" return userService.queryUserAndAddressById(userId); }
步骤3:Service层:定义业务方法
在 IUserService
中声明方法,约定"查询用户并关联地址"的逻辑:
public interface IUserService extends IService<User> { // 其他方法... UserVO queryUserAndAddressById(Long userId); // 新增:查询用户+收货地址 }
步骤4:Service实现层:用Db
避免循环依赖
在 UserServiceImpl
中实现方法,关键是用Db
查询收货地址:
@Override public UserVO queryUserAndAddressById(Long userId) { // 1. 查询用户基本信息(复用IService的getById方法) User user = getById(userId); if (user == null) return null; // 2. 用Db查询"该用户的所有收货地址"(无需注入AddressService) List<Address> addresses = Db.lambdaQuery(Address.class) .eq(Address::getUserId, userId) // 条件:地址的userId = 当前用户id .list(); // 查询结果列表 // 3. 实体转VO(用BeanUtil简化属性拷贝) UserVO userVO = BeanUtil.copyProperties(user, UserVO.class); // 地址列表也转成VO并设置到userVO中 userVO.setAddresses(BeanUtil.copyToList(addresses, AddressVO.class)); return userVO; }
四、Db
的核心价值:避免循环依赖 + 简化代码
-
避免循环依赖 :查询收货地址时,无需注入
AddressService
,直接通过Db.lambdaQuery(Address.class)
查数据库,彻底消除"UserService ↔ AddressService"相互依赖的可能。 -
简化代码:无需手动注入Mapper/Service,一行静态方法调用就能完成查询,代码更简洁。
总结:Db
是MyBatis-Plus为解决"Service循环依赖"和"简化跨表/跨Service查询"提供的工具,让关联查询既安全又高效~
逻辑删除
MyBatis-Plus的逻辑删除功能,是为了简化"假删除(保留数据但标记为已删)"的实现成本,下面从「是什么、怎么用、有啥问题」三个角度讲解:
一、什么是"逻辑删除"?
不是真的把数据从数据库删除,而是用一个字段标记"是否已删":
-
比如给表加个
deleted
字段,0
代表"正常",1
代表"已删除"。 -
删除时,把
deleted
改成1
;查询时,只查deleted=0
的数据。 -
好处:数据可恢复(删错了能改回来);坏处:数据越积越多,影响查询。
二、MyBatis-Plus如何简化逻辑删除?
如果自己写逻辑删除,每次查询都要手动加deleted=0
条件,每次删除都要手动改UPDATE
语句,非常繁琐。MyBatis-Plus帮我们"自动处理这些逻辑"。
步骤1:数据库加"逻辑删除字段"
给要逻辑删除的表(比如address
表)添加标记字段:
alter table address add deleted bit default b'0' null comment '逻辑删除';
deleted
字段:bit
类型,默认0
(未删除),1
代表已删除。
步骤2:实体类加对应字段
在Address
实体类中,添加deleted
属性,对应数据库字段:
public class Address { private Long id; // 其他字段... private Integer deleted; // 逻辑删除标记字段 }
步骤3:配置文件声明规则
在application.yml
中,告诉MyBatis-Plus"逻辑删除的字段名、已删/未删的值":
mybatis-plus: global-config: db-config: logic-delete-field: deleted # 实体里的逻辑删除字段名 logic-delete-value: 1 # "已删除"对应的值 logic-not-delete-value: 0 # "未删除"对应的值
步骤4:像普通CRUD一样写代码
-
删除操作 :调用
removeById
,但MyBatis-Plus会自动把"删除"变成更新deleted
为1。@Test void testDeleteByLogic() { addressService.removeById(59L); // 看似是"删除",实际是改deleted为1 }
-
查询操作 :调用
list()
等查询方法,MyBatis-Plus会自动加deleted=0
的条件,过滤已删除数据。@Test void testQuery() { List<Address> list = addressService.list(); // 自动查deleted=0的数据 list.forEach(System.out::println); }
三、逻辑删除的"隐患"
MyBatis-Plus虽然简化了逻辑删除的代码,但这种方案本身有缺陷:
-
数据膨胀:因为没真删,数据库里的"已删除数据"会越来越多,导致表越来越大,查询时要扫描更多行,影响速度。
-
SQL效率下降 :每次查询都要加
deleted=0
的条件,数据库解析和执行SQL的成本变高,也会拖慢查询。
因此,讲义里提到"不太推荐用逻辑删除"------如果数据不能真删,更建议把数据迁移到"归档表"(主表存正常数据,归档表存历史数据),这样主表保持"干净",查询效率更高。
总结:逻辑删除是"留备份、可恢复"的删数据策略,MyBatis-Plus帮我们自动实现,但它本身有性能隐患,要根据业务场景权衡是否使用~
通用枚举功能
MyBatis-Plus的通用枚举功能,核心是解决「数据库数值型状态」与「Java枚举型状态」的自动转换问题,让代码更直观、易维护。下面分步骤讲解:
一、为什么需要"通用枚举"?
数据库中,用户状态、订单状态等通常用数字存储(如 1=正常
、2=冻结
);但Java代码里,用数字判断状态可读性差(比如 if(status == 1)
不如 if(status == UserStatus.NORMAL)
直观)。
如果手动在"数字↔枚举"之间转换,代码会很繁琐。因此MyBatis-Plus提供了通用枚举功能,自动完成这种转换。
二、如何实现"通用枚举"?
步骤1:定义带@EnumValue
的枚举类
创建枚举类(如UserStatus
),用@EnumValue
标记"与数据库交互的字段":
@Getter // Lombok注解,生成getter public enum UserStatus { NORMAL(1, "正常"), // 枚举常量:value=1(对应数据库),desc="正常"(描述) FREEZE(2, "冻结"); // 枚举常量:value=2(对应数据库),desc="冻结"(描述) @EnumValue // 标记:这个value字段是和数据库交互的"桥梁" private final int value; // 数据库存储的数值 private final String desc; // 业务描述(非数据库字段) UserStatus(int value, String desc) { this.value = value; this.desc = desc; } }
@EnumValue
:告诉MyBatis-Plus,枚举的value
字段要和数据库的数值做映射(存的时候存value
,查的时候根据value
转枚举)。
步骤2:配置"枚举处理器"
在application.yaml
中,指定MyBatis-Plus处理枚举的默认处理器:
mybatis-plus: configuration: # 配置枚举处理器,让MyBatis-Plus知道如何转换枚举与数据库数值 default-enum-type-handler: com.baomidou.mybatisplus.core.handlers.MybatisEnumTypeHandler
步骤3:实体类使用枚举类型
把实体(如User
类)中"状态字段"的类型,从Integer
改为枚举UserStatus
:
public class User { private Long id; private String username; // 其他字段... private UserStatus status; // 状态字段从Integer改为UserStatus枚举 }
三、效果:自动"数值↔枚举"转换
执行查询(如userService.list()
)时:
-
数据库里
status=1
的记录,会自动转换成UserStatus.NORMAL
枚举对象。 -
业务代码中,可直接用枚举判断状态(如
if(user.getStatus() == UserStatus.NORMAL)
),无需再和数字打交道。
执行保存/更新时:
- 若
user.getStatus()
是UserStatus.FREEZE
,会自动把value=2
存到数据库。
四、额外:前端JSON展示优化(@JsonValue
)
如果需要把用户信息转成JSON给前端(如UserVO
),希望前端看到"状态描述"而非枚举名,可在枚举中用@JsonValue
标记展示字段:
@Getter public enum UserStatus { NORMAL(1, "正常"), FREEZE(2, "冻结"); @EnumValue private final int value; @JsonValue // 标记:JSON序列化时,用desc字段展示 private final String desc; UserStatus(int value, String desc) { this.value = value; this.desc = desc; } }
这样,UserVO
转JSON时,status
字段会显示为"正常"
或"冻结"
,前端能直接看懂状态含义。
总结
通用枚举通过「@EnumValue
标记映射字段 + 配置枚举处理器」,实现了:
-
数据库存简洁数值,Java代码用直观枚举做业务逻辑,无需手动转换。
-
结合
@JsonValue
,还能让前端直接看到"人类可读"的状态描述,前后端协同更高效。
JSON类型处理器
MyBatis-Plus的JSON类型处理器,核心是解决「数据库JSON字段」与「Java对象」的自动转换问题,让操作JSON数据更简洁。下面结合场景和步骤,通俗讲解:
一、问题背景:数据库JSON字段 vs Java String类型的痛点
数据库中user
表有个info
字段,类型是JSON(存类似{"age":20, "intro":"佛系青年", "gender":"male"}
的内容)。
如果Java实体类(User
)中info
字段用String
接收:
- 要获取
age
、intro
等属性,得手动解析JSON字符串(比如用JSON工具转成Map或自定义对象),代码繁琐且易出错。
二、解决方案:用「类型处理器 + 实体类」自动转换
MyBatis-Plus提供JacksonTypeHandler
(或其他JSON处理器),能自动把数据库JSON转成Java对象,反之亦然。步骤如下:
步骤1:定义与JSON结构匹配的实体类
创建UserInfo
类,属性与JSON中的字段一一对应(如age
、intro
、gender
):
@Data // Lombok注解,生成getter/setter public class UserInfo { private Integer age; private String intro; private String gender; }
作用:让JSON数据能"自动填充"到这个类的对象中,方便后续以对象属性的方式操作(如userInfo.getAge()
)。
步骤2:在实体类中指定"类型处理器"
修改User
类的info
字段,从String
改为UserInfo
,并通过@TableField
指定JSON类型处理器:
@Data @TableName(value = "user", autoResultMap = true) // 开启自动结果映射 public class User { // 其他字段... @TableField(typeHandler = JacksonTypeHandler.class) // 指定:用Jackson处理JSON转换 private UserInfo info; // 类型从String改为UserInfo }
-
@TableField(typeHandler = JacksonTypeHandler.class)
:告诉MyBatis-Plus,这个字段要通过JacksonTypeHandler
完成"数据库JSON ↔ Java对象"的转换。 -
@TableName(autoResultMap = true)
:开启"自动结果映射",让MyBatis-Plus能识别自定义的类型转换规则。
步骤3:测试效果:JSON自动转对象
执行查询(如userService.list()
)时:
-
数据库中
info
字段的JSON数据,会被自动转成UserInfo
对象。 -
业务代码中可直接操作对象属性(如
user.getInfo().getAge()
),无需手动解析JSON。
执行保存/更新时:
UserInfo
对象会被自动转成JSON字符串,存入数据库的info
字段。
步骤4:前端返回优化(可选)
如果需要把User
数据以JSON格式返回给前端(如通过UserVO
),只需把UserVO
的info
字段也改为UserInfo
类型:
@Data public class UserVO { // 其他字段... private UserInfo info; // 类型为UserInfo,返回时自动转JSON对象 }
这样前端收到的JSON中,info
会是嵌套对象(如{"age":20, "intro":"佛系青年", ...}
),无需前端再解析字符串,更友好。
三、核心价值
通过"类型处理器 + 实体类",MyBatis-Plus帮我们自动完成JSON与Java对象的转换,避免了手动解析JSON的繁琐代码,让数据操作更简洁、易维护。
通用分页实体(半懂)
MyBatis-Plus的通用分页实体是为了解决"不同业务分页查询时,重复代码多、转换逻辑繁琐"的问题,通过抽象通用参数/结果 + 工具方法自动转换,让分页开发更简洁。下面分步骤讲解:
一、为什么需要"通用分页实体"?
不同业务(如用户分页、商品分页)的分页查询逻辑高度相似:
-
都需要「页码、页大小、排序规则」等通用分页参数。
-
都需要返回「总条数、总页数、当前页数据」等通用分页结果。
-
都要做「数据库实体(PO)→ 前端视图对象(VO)」的转换。
如果每个业务都单独写这些逻辑,会有大量重复代码。因此需要抽象出通用的分页实体和工具方法。
二、核心实体设计
1. PageQuery
:通用分页查询参数
-
作用:封装"页码、页大小、排序字段、排序方式"这些所有分页查询都需要的通用参数。
-
扩展:业务专属的查询条件(如"用户名校验、状态过滤"),可以通过继承
PageQuery
来扩展(如UserQuery extends PageQuery
)。
@Data public class PageQuery { private Integer pageNo; // 页码 private Integer pageSize; // 每页条数 private String sortBy; // 排序字段 private Boolean isAsc; // 是否升序 // 工具方法:把通用分页参数转成MyBatis-Plus的Page对象 public <T> Page<T> toMpPageDefaultSortByUpdateTimeDesc() { return toMpPage("update_time", false); // 默认按update_time倒序 } // 其他重载方法:支持自定义排序、手动指定排序等... }
2. PageDTO<T>
:通用分页结果
-
作用:封装"总条数、总页数、当前页数据列表"这些所有分页结果都需要的通用结构。
-
泛型
<T>
:适配不同业务的VO类型(如UserVO
、GoodsVO
)。
@Data public class PageDTO<V> { private Long total; // 总记录数 private Long pages; // 总页数 private List<V> list; // 当前页数据列表 // 工具方法:自动把MyBatis-Plus的Page<PO>转成PageDTO<VO> public static <V, P> PageDTO<V> of(Page<P> page, Class<V> voClass) { // 1. 非空校验:没数据就返回空列表 if (page.getRecords() == null || page.getRecords().isEmpty()) { return new PageDTO<>(page.getTotal(), page.getPages(), Collections.emptyList()); } // 2. PO→VO自动转换(用BeanUtil简化属性拷贝) List<V> vos = BeanUtil.copyToList(page.getRecords(), voClass); // 3. 封装成PageDTO返回 return new PageDTO<>(page.getTotal(), page.getPages(), vos); } // 支持自定义转换逻辑(如用户名脱敏) public static <V, P> PageDTO<V> of(Page<P> page, Function<P, V> convertor) { if (page.getRecords() == null || page.getRecords().isEmpty()) { return new PageDTO<>(page.getTotal(), page.getPages(), Collections.emptyList()); } // 用Lambda自定义PO→VO的转换(如脱敏、特殊字段处理) List<V> vos = page.getRecords().stream().map(convertor).collect(Collectors.toList()); return new PageDTO<>(page.getTotal(), page.getPages(), vos); } }
三、工具方法的价值:简化"转换与封装"
1. PageQuery
的toMpPage
系列方法
MyBatis-Plus的分页需要用Page
对象,PageQuery
的工具方法可以自动把"通用分页/排序参数"转成Page
对象,无需手动new Page()
、设置排序。
例如:
// 业务中只需一行代码,就能生成带"默认按update_time倒序"的Page对象 Page<User> page = query.toMpPageDefaultSortByUpdateTimeDesc();
2. PageDTO
的of
系列方法
查询后,需要把MyBatis-Plus的Page<PO>
(数据库实体)转成PageDTO<VO>
(前端视图对象)。PageDTO
的工具方法可以自动完成"非空校验 + PO→VO转换 + 结果封装"。
例如:
// 自动把Page<User>转成PageDTO<UserVO>,无需手动拷贝属性 return PageDTO.of(page, UserVO.class); // 自定义转换(如用户名脱敏) return PageDTO.of(page, user -> { UserVO vo = BeanUtil.copyProperties(user, UserVO.class); // 自定义逻辑:用户名脱敏(保留前几位,后面加) vo.setUsername(vo.getUsername().substring(0, vo.getUsername().length()-2) + ""); return vo; });
四、业务层代码的简化效果
原本需要"手动创建Page
、设置排序、非空判断、属性拷贝、封装结果"等多个步骤,现在通过通用实体和工具方法,代码可简化为:
@Override public PageDTO<UserVO> queryUserByPage(PageQuery query) { // 1. 通用分页参数转MyBatis-Plus的Page Page<User> page = query.toMpPageDefaultSortByCreateTimeDesc(); // 2. 执行MyBatis-Plus的分页查询 page(page); // 调用userService的page方法,自动分页 // 3. 自动转换PO→VO,封装成PageDTO return PageDTO.of(page, UserVO.class); }
五、最终效果(结合图片)
前端请求带分页参数(如pageNo=1&pageSize=2
),后端通过通用逻辑处理后,返回的JSON会包含:
-
total
:总记录数(如100006
)。 -
pages
:总页数(如50003
)。每页2条 -
list
:当前页的UserVO
列表(且info
是嵌套的JSON对象、status
是枚举描述等,之前配置的JSON处理器、通用枚举也会生效)。
这样既保证了分页功能的统一,又让代码简洁易维护,不同业务的分页查询只需"继承通用实体 + 调用工具方法"即可。

总结:通用分页实体通过抽象共性、工具方法自动转换,解决了"分页逻辑重复、PO→VO转换繁琐"的问题,让多业务的分页开发更高效。
Docker快速部署MySQL
要理解Docker快速部署MySQL的过程,我们可以从"为什么用Docker""Docker核心概念""部署命令解析"三个角度拆解:
一、为什么需要Docker?
传统部署软件(如MySQL)的痛点:
-
命令多且难记:不同软件安装步骤差异大,新手容易忘。
-
环境依赖复杂:不同操作系统(CentOS、Ubuntu、macOS等)的安装包、依赖库不同,部署流程需要"针对性适配",极其繁琐。
-
易出错:步骤多(下载、编译、配置、启动),任何一步失误都可能导致部署失败。
Docker的核心价值:让软件"带环境运行",无论在哪台机器,部署流程都完全一致,彻底解决"环境差异"问题。
二、Docker核心概念:镜像、容器、镜像仓库
-
镜像(Image):可以理解为"软件安装包 + 运行环境 + 配置"的模板。比如"mysql镜像"里,不仅包含MySQL软件,还包含它运行所需的系统库、配置等。
-
容器(Container):镜像"运行起来的实例"。镜像只是"静态模板",容器是"动态运行的隔离环境"。
-
镜像仓库(Docker Registry):存放镜像的"仓库服务器"。比如Docker Hub(官方仓库),里面有MySQL、Nginx等各种软件的镜像,供大家下载使用。
三、docker run
命令:一键部署MySQL
执行如下命令,就能快速部署MySQL:
docker run -d \ --name mysql \ -p 3306:3306 \ -e TZ=Asia/Shanghai \ -e MYSQL_ROOT_PASSWORD=123 \ mysql
命令执行流程(结合截图)
当执行这条命令时:
-
Docker先检查本地有没有
mysql
镜像。如果没有(如截图中"Unable to find image 'mysql:latest' locally"),就去Docker Hub(官方镜像仓库)拉取mysql
镜像。 -
拉取完成后,Docker会基于
mysql
镜像创建并运行容器,让MySQL在容器内启动。
命令参数详解
-
docker run -d
:-
docker run
:创建并启动一个容器。 -
-d
:让容器后台运行(否则容器会占用当前终端,无法做其他操作)。
-
-
--name mysql
:给容器起一个名字(叫mysql
),方便后续通过名字管理容器(比如停止、删除容器)。 -
-p 3306:3306
:端口映射。-
容器是"隔离环境",外界默认无法直接访问容器内的端口。
-
格式:
-p 宿主机端口:容器内端口
。示例中,把宿主机的3306端口和容器内的3306端口做映射------当外界访问"宿主机的3306端口"时,就相当于访问"容器内MySQL的3306端口"。
-
-
-e TZ=Asia/Shanghai
、-e MYSQL_ROOT_PASSWORD=123
:设置环境变量。-
容器内的软件(如MySQL)运行时,需要一些"配置参数"(比如时区、初始密码)。
-
格式:
-e 键=值
,具体"键和值"要参考镜像的文档(比如MySQL镜像要求用MYSQL_ROOT_PASSWORD
设置root密码)。
-
-
mysql
:指定镜像名称。Docker会根据这个名字拉取镜像(默认拉取mysql:latest
,即"最新版MySQL")。
四、Docker的"跨环境一致性"是怎么来的?
镜像中包含了软件运行所需的所有环境(系统库、配置、依赖等),容器运行时是"隔离的独立环境"。因此:
-
无论宿主机是CentOS、Ubuntu还是macOS,只要装了Docker,用相同的
docker run
命令,就能得到"运行环境完全一致"的MySQL服务。 -
再也不用为"不同系统安装包不同、依赖缺失"发愁,部署变得"一键到位"。
总结:Docker通过"镜像(带环境的模板)+ 容器(隔离的运行实例)+ 镜像仓库(统一存储)",让软件部署跨环境一致、步骤极简,彻底解决了传统部署的痛点~
Docker数据卷与本地目录挂载
要理解Docker数据卷与本地目录挂载,可以从「为什么需要它」「它是什么」「怎么用」这几个角度,结合例子逐步梳理:
一、为什么需要数据卷?
容器是隔离环境,容器内的文件(配置、静态资源、数据库数据等)默认"关在容器里"。但实际使用中会遇到很多问题:
-
数据丢失风险:比如销毁MySQL容器时,容器内的数据库数据会不会跟着消失?
-
配置修改麻烦:想改Nginx的配置文件,难道要进入容器内部修改?
-
资源共享困难:Nginx要代理外部的静态图片、网页,怎么把这些资源"给"容器里的Nginx?
所以,我们需要一种方式,让容器内的目录和宿主机的目录"关联起来"------操作宿主机的目录,就等于操作容器内的目录。这就是「数据卷」要解决的核心问题。
二、什么是数据卷?
数据卷(Volume)是容器内目录与宿主机目录之间的"桥梁"。它能让两个目录"同步":宿主机目录的文件变化,会同步到容器内;容器内目录的变化,也会同步到宿主机。
以Nginx为例(看第一张图):
-
Nginx容器内有两个关键目录:
/etc/nginx/conf
(配置文件)、/usr/share/nginx/html
(静态资源)。 -
我们创建两个数据卷:
conf
和html
,分别把容器内的conf
、html
目录与数据卷"挂钩"。 -
数据卷又会指向宿主机的默认目录:
/var/lib/docker/volumes/conf/_data
(对应conf
数据卷)、/var/lib/docker/volumes/html/_data
(对应html
数据卷)。
这样,你往宿主机/var/lib/docker/volumes/html/_data
里放静态文件,Nginx就能直接代理这些文件;修改宿主机/var/lib/docker/volumes/conf/_data
里的配置,Nginx的配置也会同步更新。
为什么不直接让容器目录连宿主机目录?
如果容器直接和宿主机某个固定目录挂钩,环境一变就容易出问题(比如换了服务器,宿主机目录路径变了)。而数据卷是个"中间层":容器先连数据卷(一个逻辑名称),数据卷再连宿主机目录。如果宿主机目录要改,只需要改"数据卷→宿主机目录"的映射,容器完全不用动。
三、匿名数据卷是什么?
有些容器(比如MySQL),创建时没指定数据卷的"名字",Docker会自动生成一个随机哈希值作为数据卷名,这种数据卷叫「匿名数据卷」。
比如MySQL容器:
-
容器内有个关键目录
/var/lib/mysql
(存数据库数据)。 -
创建容器时没指定数据卷名,Docker会自动生成一个像
29524ff09...
这样的哈希值作为数据卷名。 -
这个匿名数据卷会对应宿主机目录
/var/lib/docker/volumes/[随机哈希]/_data
,MySQL的数据就存在这里。
好处是:即使容器被销毁,匿名数据卷(及对应的宿主机数据)还在,数据不会丢失。
四、更简单的方式:直接挂载本地目录/文件
数据卷的默认目录(/var/lib/docker/volumes/...
)太深,操作起来不方便。所以Docker也支持直接让容器目录和宿主机的"指定目录/文件"挂钩,不用数据卷这个"中间层"。
语法:
-
挂载目录:
-v 宿主机目录:容器内目录
-
挂载文件:
-v 宿主机文件:容器内文件
注意:宿主机目录/文件必须以/
或./
开头(比如./mysql/data
表示"当前目录下的mysql/data");如果直接写名字(比如mysql
),会被当成「数据卷名」而不是宿主机目录。
举个MySQL本地挂载的例子(看第二张图和演示步骤):
课前资料里有mysql
目录,里面包含:
-
conf
:放MySQL配置文件(比如hm.cnf
,设置默认编码为utf8mb4)。 -
init
:放初始化SQL脚本(比如hmall.sql
,用于创建"黑马商城"的表和数据)。 -
我们要把这些目录挂载到MySQL容器内的对应位置,让MySQL用这些配置和脚本初始化。
步骤:
-
删除旧容器 :如果之前有MySQL容器,先删了(
docker rm -f mysql
)。 -
运行新容器,挂载本地目录:
docker run -d \ --name mysql \ # 容器名 -p 3306:3306 \ # 端口映射(宿主机3306→容器3306) -e TZ=Asia/Shanghai \ # 时区设置 -e MYSQL_ROOT_PASSWORD=123 \ # root密码 -v ./mysql/data:/var/lib/mysql \ # 数据目录挂载(宿主机→容器) -v ./mysql/conf:/etc/mysql/conf.d \ # 配置目录挂载 -v ./mysql/init:/docker-entrypoint-initdb.d \ # 初始化脚本目录挂载 mysql # 镜像名
-
验证挂载效果:
-
宿主机的
./mysql/data
会自动生成,里面存着MySQL的数据库文件。 -
进入MySQL容器,查看编码(
show variables like "%char%";
),会发现是utf8mb4
(因为用了conf
里的配置)。 -
查看数据库(
show databases;
),能看到hmall
数据库,里面还有address
、cart
等表(因为用了init
里的hmall.sql
初始化)。
-
总结逻辑链
容器隔离 → 需操作容器内文件/数据 → 数据卷作为"桥梁"解耦容器与宿主机 → 有「命名数据卷」「匿名数据卷」→ 但数据卷目录太深,所以直接挂载本地目录/文件更方便 → 通过Nginx、MySQL的例子,验证"挂载后能灵活操作配置、数据、静态资源"。
Docker镜像的构建与使用
要搞懂Docker镜像的构建与使用,可以从「为什么要做镜像」「镜像长啥样」「怎么描述镜像(Dockerfile)」「怎么生成镜像(构建)」这几个环节,结合例子逐步梳理:
一、为什么要自己构建镜像?
之前我们用的是别人做好的镜像(比如Nginx、MySQL),但实际开发中,需要把自己的应用(比如Java项目)也打包成镜像,这样才能用Docker快速部署自己的服务(不用再手动装环境、配依赖)。
二、镜像的结构:"分层叠加"的文件集合
镜像不是一个"大胖子文件",而是分层(Layer)叠加的结构。每一层对应"部署应用的一个步骤",且每层独立、可复用。
以"部署Java应用"为例,手动流程是:
-
准备Linux运行环境(如Ubuntu)。
-
安装并配置JDK。
-
上传Jar包(应用本身)。
-
运行Jar包。
镜像把这些步骤"固化"成分层的文件:
-
基础镜像(BaseImage) :最底层,提供最基本的运行环境(比如Linux系统核心库、命令)。Docker官方已做好很多基础镜像(如
ubuntu
、centos
,或专门为Java准备的openjdk
),不用自己从零做。 -
中间层(Layer):每一步操作(安装JDK、拷贝文件、配置环境)都会生成一层。比如"安装JDK"是一层,"拷贝Jar包"是另一层。
-
入口(Entrypoint) :最上层,指定容器启动时要执行的命令(比如
java -jar xx.jar
)。
分层的好处:多个镜像可以共用相同的层(比如"Linux环境 + JDK"层),不用重复制作,节省空间和时间。
看第一张图:Java应用的镜像像"叠卡片"------最底层是Ubuntu
基础镜像(提供系统环境),往上是"安装JDK、配置环境变量"层,再往上是"拷贝Jar包"层,最上层是"设置启动命令(java -jar
)"层,最终组合成完整的Java应用镜像。
三、Dockerfile:"自动化做镜像"的脚本
手动分层制作镜像太麻烦,Docker提供了Dockerfile------用简单语法记录"每一层要做什么",让Docker自动执行这些步骤,生成镜像。
Dockerfile常用指令(看第二张表格)
|--------------|---------------------------------|------------------------------------------------------------|
| 指令 | 作用 | 例子 |
| FROM
| 指定基础镜像(站在别人的肩膀上,不用从零做环境) | FROM openjdk:11.0-jre-buster
(用带JDK11的基础镜像) |
| ENV
| 设置环境变量(后面的指令可以用这些变量,比如时区、路径) | ENV TZ=Asia/Shanghai
(设置时区为上海) |
| COPY
| 把宿主机的文件拷贝到镜像里 | COPY docker-demo.jar /app.jar
(把本地Jar包拷贝到镜像的/app.jar
) |
| RUN
| 执行Linux命令(比如安装软件、解压文件、配置系统) | RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime
(配置时区) |
| EXPOSE
| 声明容器运行时要监听的端口(给用户看的"说明",不是强制映射) | EXPOSE 8080
(说明应用用8080端口) |
| ENTRYPOINT
| 指定容器启动时的入口命令(容器启动就会执行这个命令跑应用) | ENTRYPOINT ["java", "-jar", "/app.jar"]
(启动Java应用) |
简化的Java项目Dockerfile示例
# 基础镜像:已经包含JDK11的运行环境,省去自己装JDK的步骤 FROM openjdk:11.0-jre-buster # 设置时区为上海 ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone # 把宿主机的docker-demo.jar拷贝到镜像里的/app.jar COPY docker-demo.jar /app.jar # 容器启动时,执行java -jar /app.jar ENTRYPOINT ["java", "-jar", "/app.jar"]
这样写好后,Docker会自动按步骤构建镜像。
四、构建镜像:把Dockerfile变成可运行的镜像
有了Dockerfile,就可以用docker build
命令生成镜像。
步骤(看第三、四张图和讲义)
-
准备文件 :把需要的文件(比如
docker-demo.jar
和写好的Dockerfile
)放到同一个目录(比如虚拟机的/root/demo
)。非常关键!!!! -
执行构建命令:
docker build -t docker-demo:1.0 .
-
docker build
:告诉Docker"我要构建镜像了"。 -
-t docker-demo:1.0
:给镜像起名字(docker-demo
是"仓库名",1.0
是"标签/版本号")。 -
.
:指定Dockerfile
所在的目录(.
表示"当前目录",也可以写绝对路径如/root/demo
)。
-
-
查看构建过程 :执行命令后,Docker会一步步执行
Dockerfile
里的指令,输出日志(比如"加载基础镜像""拷贝文件""执行RUN
命令")。如果某一层之前构建过(有缓存),会直接用缓存(日志里的CACHED
提示),加快速度。 -
验证镜像 :构建完成后,用
docker images
查看本地镜像列表,能看到docker-demo:1.0
。 -
启动容器运行应用:
docker run -d --name dd -p 8080:8080 docker-demo:1.0
-
-d
:后台运行容器。 -
--name dd
:给容器起个名字(叫dd
)。 -
-p 8080:8080
:端口映射(宿主机的8080端口 → 容器的8080端口)。 -
docker-demo:1.0
:用刚才构建的镜像启动容器。
-
-
测试应用 :用
curl localhost:8080/hello/count
访问,能得到响应(比如讲义里的"欢迎访问黑马商城..."),说明Java应用在Docker容器里跑起来了。
总结逻辑链
要部署自己的应用 → 需要把应用打包成镜像 → 镜像用"分层结构"(基础镜像+各步骤层+入口)实现复用 → 用Dockerfile
写每层的操作 → 通过docker build
构建镜像 → 用docker run
启动容器运行应用。
这样一步步下来,就能把"自己的应用"变成"Docker镜像",再用Docker快速部署啦~
容器网络通信
要理解Docker的容器网络通信,可以从「问题→解决方案→操作验证」的逻辑来梳理:
一、问题:容器IP不稳定,直接用IP通信有风险
当Java应用容器(比如dd
)需要访问MySQL容器时,先通过docker inspect
查到MySQL的IP(如172.17.0.2
),在dd
容器内用ping
能通,但存在隐患:
容器的IP是虚拟且不固定的(销毁重建、换环境时可能变化)。如果代码里写死这个IP,部署时容易连接失败。
二、解决方案:Docker自定义网络
Docker允许创建自定义网络,在这个网络内的容器,能通过别名(或容器名)互相访问,无需依赖不稳定的IP。
三、Docker网络管理命令(参考图片表格)
Docker提供了一系列命令管理网络:
|-----------------------------|----------------|
| 命令 | 作用 |
| docker network create
| 创建自定义网络 |
| docker network ls
| 查看所有网络 |
| docker network rm
| 删除指定网络 |
| docker network prune
| 清理未使用的网络 |
| docker network connect
| 让容器加入某网络(可设别名) |
| docker network disconnect
| 让容器离开某网络 |
| docker network inspect
| 查看网络详细信息 |
四、自定义网络的演示步骤
1. 创建自定义网络
docker network create hmall
此时用docker network ls
能看到新增的hmall
网络。
2. 让容器加入自定义网络
把MySQL容器(mysql
)和Java应用容器(dd
)加入hmall
网络,还能给容器设别名(方便记忆):
# MySQL容器加入hmall网络,别名为db docker network connect hmall mysql --alias db # Java应用容器(dd)加入hmall网络 docker network connect hmall dd
3. 测试容器间通信(通过别名/容器名)
进入dd
容器,用别名**db
** 或 容器名mysql
ping MySQL容器:
# 进入dd容器 docker exec -it dd bash # 用别名db ping ping db # 结果能通,因为在hmall网络内,别名生效 # 用容器名mysql ping ping mysql # 结果也能通,容器名默认是网络内的"别名"
五、总结
-
默认情况下,容器能通过IP通信,但IP不稳定,不适合写死。
-
自定义网络中,容器可通过别名(或容器名)互相访问,更稳定、易用。
-
Docker提供了丰富的
docker network
命令,用于创建、管理网络和容器的网络连接。
Docker Compose
要理解 Docker Compose,可以从「为什么需要它」「它是什么」「怎么用(配置+命令)」三个角度梳理,结合讲义里的例子会更清晰:
一、为什么需要Docker Compose?
实际项目往往需要多个关联的容器(比如 Java 应用、MySQL 数据库、Nginx 代理)。如果用之前的 docker run
命令逐个部署:
-
要写一大堆参数(端口、数据卷、网络、环境变量...),很繁琐;
-
容器之间的依赖(比如 Java 应用必须等 MySQL 启动后再启动)得手动控制;
-
管理多个容器的启动/停止也很麻烦。
Docker Compose 就是为了解决「多容器批量部署与管理」的工具 :通过一个 docker-compose.yml
文件,定义所有关联容器的配置,再用一条命令就能批量启动/停止所有容器。
二、Docker Compose 核心:docker-compose.yml
配置文件
docker-compose.yml
用 YAML 格式定义多个「服务(service)」,每个服务对应一个容器。服务的配置和 docker run
的参数是一一对应的(参考第一张对比表):
|-----------------|---------------------|-------|
| docker run
参数 | docker-compose
指令 | 说明 |
| --name
| container_name
| 容器名称 |
| -p
| ports
| 端口映射 |
| -e
| environment
| 环境变量 |
| -v
| volumes
| 数据卷挂载 |
| --network
| networks
| 网络配置 |
黑马商城的 docker-compose.yml
示例解析
讲义里的黑马商城部署文件,定义了 3 个服务(MySQL、Java 应用 hmall
、Nginx)和 1 个自定义网络 hm-net
:
version: "3.8" # Compose 文件版本(和 Docker 版本匹配) services: # 所有服务(容器)的配置都在这下面 mysql: # 服务名:mysql image: mysql # 使用 mysql 官方镜像 container_name: mysql # 容器名 ports: - "3306:3306" # 端口映射:宿主机3306 → 容器3306 environment: # 环境变量(设置时区、root密码) TZ: Asia/Shanghai MYSQL_ROOT_PASSWORD: 123 volumes: # 数据卷挂载(配置、数据、初始化脚本) - "./mysql/conf:/etc/mysql/conf.d" - "./mysql/data:/var/lib/mysql" - "./mysql/init:/docker-entrypoint-initdb.d" networks: # 加入自定义网络 hm-net - hm-net hmall: # 服务名:hmall(Java 应用) build: # 不是用现成镜像,而是从当前目录的 Dockerfile 构建镜像 context: . # 构建上下文:当前目录 dockerfile: Dockerfile # 指定 Dockerfile 文件名 container_name: hmall # 容器名 ports: - "8080:8080" # 端口映射:宿主机8080 → 容器8080 networks: - hm-net depends_on: # 依赖 mysql 服务(等 mysql 启动后,再启动 hmall) - mysql nginx: # 服务名:nginx image: nginx # 使用 nginx 官方镜像 container_name: nginx # 容器名 ports: - "18080:18080" # 端口映射(可多个) - "18081:18081" volumes: # 挂载 Nginx 配置文件和静态资源 - "./nginx/nginx.conf:/etc/nginx/nginx.conf" - "./nginx/html:/usr/share/nginx/html" depends_on: # 依赖 hmall 服务(等 hmall 启动后,再启动 nginx) - hmall networks: - hm-net networks: # 自定义网络配置 hm-net: name: hmall # 网络名称为 hmall
核心亮点:
-
「服务依赖」:通过
depends_on
保证容器启动顺序(MySQL → hmall → Nginx)。 -
「自定义网络」:所有服务加入
hm-net
网络,容器间可通过服务名互相访问(无需记 IP)。 -
「镜像构建」:
hmall
服务用build
字段,从本地 Dockerfile 构建镜像,灵活适配自定义应用。
三、Docker Compose 命令:批量管理容器生命周期
Compose 的命令格式是 docker compose [OPTIONS] [COMMAND]
,常用命令参考第二张表和讲义演示:
1. 常用命令分类
-
Options(可选参数):
-
-f
:指定docker-compose.yml
文件的路径(默认找当前目录的docker-compose.yml
)。 -
-p
:指定「项目(project)」名称(项目是 Compose 里多个服务的逻辑集合)。
-
-
Commands(操作命令):
-
up -d
:创建并后台启动所有服务的容器(-d
表示后台运行)。 -
down
:停止并移除所有容器、网络等资源(彻底清理)。 -
ps
:列出所有正在运行的容器。 -
images
:查看 Compose 管理的所有镜像。 -
logs
:查看指定容器的日志(调试用)。 -
stop
/start
/restart
:停止、启动、重启容器。 -
exec
:在运行中的容器内执行命令(比如docker compose exec hmall bash
进入 hmall 容器)。
-
2. 教学演示步骤解析(部署黑马商城)
# 1. 进入 root 目录(确保 Compose 文件在当前目录) cd /root # 2. 清理旧环境(删除旧容器、旧镜像、清空 MySQL 数据) docker rm -f $(docker ps -qa) # 删除所有容器 docker rmi hmall # 删除旧的 hmall 镜像 rm -rf mysql/data # 清空 MySQL 之前的数据(模拟全新部署) # 3. 启动所有服务(后台运行) docker compose up -d # 此时 Compose 会自动: # - 构建 hmall 服务的镜像(因为配置了 build); # - 创建 hm-net 网络; # - 按依赖顺序启动 mysql → hmall → nginx 容器。 # 4. 查看镜像(确认 Compose 管理的镜像) docker compose images # 5. 查看容器(确认所有服务是否正常启动) docker compose ps # 6. 浏览器访问验证(比如 http://你的虚拟机IP:8080)
总结:Docker Compose 的优势
-
简化部署 :一份
docker-compose.yml
配置所有容器,一键启动/停止整个应用。 -
管理依赖 :通过
depends_on
自动控制容器启动顺序。 -
网络互通:自定义网络让容器间通过服务名通信,无需关心 IP。
-
镜像灵活:支持从 Dockerfile 构建镜像,适配自定义应用。
这样,多容器应用的部署和管理就从"繁琐的手动操作"变成了"配置文件 + 一条命令",效率大大提升~
RestTemplate远程调用
要理解这段内容,我们可以从"为什么要远程调用"→"用什么工具(RestTemplate)"→"怎么配置和使用"→"效果如何"的逻辑逐步拆解:
一、为什么需要"远程调用"?
在微服务架构中,不同服务(如 cart-service
购物车服务、item-service
商品服务)是独立部署的。当 cart-service
需要获取商品的详细信息(价格、库存等)时,无法像"单体应用"那样直接调用本地方法,必须通过 HTTP请求 访问 item-service
提供的接口------这就是"远程调用"。
二、RestTemplate:简化HTTP请求的工具
Spring 提供的 RestTemplate
是专门用来简化HTTP请求发送的工具类。它封装了底层的HTTP客户端(如 JDK 的 HttpURLConnection
、Apache 的 HttpClient
等),让我们不用手动处理"建立连接、设置请求头、解析响应"等繁琐步骤。
三、配置RestTemplate(让Spring管理它)
要使用 RestTemplate
,需要先将它注册为Spring的Bean(这样其他组件能通过"依赖注入"使用它)。
在 cart-service
中创建配置类 RemoteCallConfig
:
@Configuration // 标记为Spring配置类,让Spring扫描并加载 public class RemoteCallConfig { @Bean // 将RestTemplate对象注册为Spring Bean,后续可被注入到其他组件 public RestTemplate restTemplate() { return new RestTemplate(); } }
四、使用RestTemplate实现"远程调用商品服务"
在 CartServiceImpl
的 handleCartItems
方法中,通过 RestTemplate
调用 item-service
的接口,获取商品信息。步骤分解如下:
1. 收集需要查询的商品ID
从购物车视图对象(CartVO
)中,提取所有商品的ID,存入 Set
中(避免重复):
Set<Long> itemIds = vos.stream().map(CartVO::getItemId).collect(Collectors.toSet());
2. 发送HTTP请求(调用商品服务接口)
使用 RestTemplate
的 exchange
方法发送 GET请求,调用 item-service
的 /items
接口(查询多个商品)。
exchange
方法的参数解析:
-
请求路径 :
"http://localhost:8081/items?ids={ids}"
→ 商品服务的接口地址,{ids}
是"商品ID列表"的占位符。 -
请求方式 :
HttpMethod.GET
→ 表示这是GET请求。 -
请求实体 :
null
→ GET请求通常没有请求体(POST请求会在这里传参)。 -
返回值类型 :
new ParameterizedTypeReference<List<ItemDTO>>() {}
→ 因为要返回List<ItemDTO>
(泛型列表),Java泛型存在"类型擦除"问题,所以用ParameterizedTypeReference
指定具体的泛型类型,避免解析错误。 -
路径变量 :
Map.of("ids", CollUtil.join(itemIds, ","))
→ 把商品ID集合用"逗号"拼接成字符串,替换请求路径中的{ids}
占位符。
代码:
ResponseEntity<List<ItemDTO>> response = restTemplate.exchange( "http://localhost:8081/items?ids={ids}", HttpMethod.GET, null, new ParameterizedTypeReference<List<ItemDTO>>() {}, Map.of("ids", CollUtil.join(itemIds, ",")) );
3. 解析HTTP响应
-
判断响应状态码是否为"成功(2xx)":
response.getStatusCode().is2xxSuccessful()
。 -
如果成功,获取响应体(即商品列表):
response.getBody()
。 -
如果商品列表为空,直接返回(避免后续空指针)。
代码:
if(!response.getStatusCode().is2xxSuccessful()){ return; } List<ItemDTO> items = response.getBody(); if (CollUtils.isEmpty(items)) { return; }
4. 构建"商品ID→商品"的映射(方便后续快速查找)
把商品列表转成 Map<Long, ItemDTO>
(键是商品ID,值是商品对象),这样后续遍历购物车时,能快速根据ID找到商品:
Map<Long, ItemDTO> itemMap = items.stream() .collect(Collectors.toMap(ItemDTO::getId, Function.identity())); //键值生成器:键是id,值是商品实体
5. 填充购物车视图对象的商品信息
遍历购物车VO列表,从itemMap
中找到对应的商品,把"价格、状态、库存"等信息设置到VO中:
for (CartVO v : vos) { ItemDTO item = itemMap.get(v.getItemId());//把前端传入的id提取出来,在上一步的map中- //查找完整商品item if (item == null) { continue; } v.setNewPrice(item.getPrice());//将完整商品的价格存入返回给前端的视图中 v.setStatus(item.getStatus()); v.setStock(item.getStock()); }
五、测试效果:远程调用成功
重启 cart-service
后,调用"查询购物车列表"接口,会发现响应中包含了商品的详细信息(价格、库存等)------说明 cart-service
通过 RestTemplate
成功从 item-service
获取到了商品数据,远程调用生效。
总结逻辑链
微服务间需要跨服务获取数据 → 用 RestTemplate
简化HTTP请求 → 先配置 RestTemplate
为Spring Bean → 在业务代码中注入并使用 RestTemplate
发送请求 → 解析响应、处理数据 → 最终实现跨服务的数据同步。
服务发现与动态调用
要理解这段服务发现与动态调用的代码,我们可以从「为什么要服务发现」→「如何实现服务发现」→「代码逻辑拆解」三个角度逐步分析:
一、为什么需要"服务发现"?
在微服务架构中,服务提供者(如 item-service
)可能多实例部署(比如为了高可用,部署在多台服务器上)。如果消费者(如 cart-service
)硬编码提供者的 IP 和端口(比如 http://localhost:8081/...
),会有两个问题:
-
不灵活 :若
item-service
实例扩容/缩容(新增/减少服务器),cart-service
得手动修改代码里的地址,非常麻烦。 -
无负载均衡:所有请求都打到固定实例,容易导致单实例压力过大。
因此,需要"服务发现":让消费者从注册中心(如 Nacos)动态获取服务的所有实例,并通过负载均衡选一个实例调用。
二、服务发现的前置准备(依赖 + 配置)
要实现服务发现,需先完成两步准备:
1. 引入依赖
在 cart-service
的 pom.xml
中添加 Nacos 服务注册发现依赖:
<!-- Nacos 服务注册与发现(既支持自己注册,也支持发现其他服务) --> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> </dependency>
这个依赖会自动集成服务注册(让 cart-service
自己注册到 Nacos)和服务发现(让 cart-service
从 Nacos 拉取其他服务的实例)能力。
2. 配置 Nacos 地址
在 cart-service
的 application.yml
中,指定 Nacos 注册中心的地址:
spring: cloud: nacos: server-addr: 192.168.150.101:8848 # Nacos 服务器的 IP:端口
这样 cart-service
就能和 Nacos 通信,实现"注册自己"和"发现其他服务"。
三、代码中"服务发现 + 动态调用"的逻辑拆解
以 CartServiceImpl
中 handleCartItems
方法为例,原来的远程调用是硬编码 IP 端口,现在要改成动态发现实例 + 负载均衡:
原硬编码调用(问题:不灵活、无负载均衡)
ResponseEntity<List<ItemDTO>> response = restTemplate.exchange( "http://localhost:8081/items?ids={ids}", // 硬编码 IP:端口 HttpMethod.GET, null, new ParameterizedTypeReference<List<ItemDTO>>() {}, CollUtils.join(itemIds, ",") );
改为"服务发现 + 动态调用"(核心逻辑)
private void handleCartItems(List<CartVO> vos) { // 1. 获取购物车中商品 ID Set<Long> itemIds = vos.stream().map(CartVO::getItemId).collect(Collectors.toSet()); // 2. 发现 item-service 的所有实例(服务发现核心步骤) List<ServiceInstance> instances = discoveryClient.getInstances("item-service"); // 3. 负载均衡:随机选一个实例(简单负载均衡策略) ServiceInstance instance = instances.get(RandomUtil.randomInt(instances.size())); // 4. 用选中的实例发起请求(动态拼接地址) ResponseEntity<List<ItemDTO>> response = restTemplate.exchange( instance.getUri() + "/items?ids={ids}", // 用实例的 URI 代替硬编码地址 HttpMethod.GET, null, new ParameterizedTypeReference<List<ItemDTO>>() {}, CollUtils.join(itemIds, ",") ); // 5. 后续处理响应... }
逐行解析核心步骤
-
发现服务实例:
List<ServiceInstance> instances = discoveryClient.getInstances("item-service");
-
DiscoveryClient
是 Spring Cloud 提供的服务发现工具(已被 Spring 自动装配,直接注入即可用)。 -
getInstances("item-service")
:向 Nacos 询问"服务名为item-service
的所有实例",返回包含多个实例的列表(每个实例包含 IP、端口、URI 等信息)。
-
-
负载均衡(随机选实例):
ServiceInstance instance = instances.get(RandomUtil.randomInt(instances.size()));
-
因为
item-service
可能有多个实例,这里用随机算法选一个(也可以用轮询、权重等更复杂的负载均衡策略)。 -
这样每次调用可能选中不同实例,实现"请求分摊",避免单实例压力过大。
-
-
动态拼接请求地址:
instance.getUri() + "/items?ids={ids}"
-
instance.getUri()
:获取选中实例的统一资源标识符(比如http://192.168.1.100:8081
)。 -
拼接接口路径
/items?ids={ids}
后,就得到了"动态的请求地址",代替了原来硬编码的localhost:8081
。
-
四、总结:服务发现的价值
通过 DiscoveryClient
实现"服务发现 + 负载均衡"后,cart-service
调用 item-service
时:
-
无需关心实例 IP/端口:实例增减时,直接从 Nacos 拉取最新列表,不用改代码。
-
请求自动分摊 :通过负载均衡算法(这里是随机),把请求分散到多个
item-service
实例,提升系统可用性和性能。
这就是微服务中"服务发现"的核心作用------让服务间的调用更灵活、更可靠。
OpenFeign
要理解 OpenFeign 的作用与使用,可从「为什么需要它」和「怎么用它简化远程调用」两个核心角度拆解:
一、为什么需要 OpenFeign?
之前用 RestTemplate + DiscoveryClient
实现远程调用时(如第一张图),代码需要手动完成:
-
服务发现(
discoveryClient.getInstances("item-service")
); -
负载均衡选实例(
RandomUtil.randomInt
随机选); -
拼接 HTTP 请求地址(
instance.getUri() + "/items?ids={ids}"
); -
发送 HTTP 请求(
restTemplate.exchange(...)
); -
处理响应结果。
步骤繁琐且与本地方法调用的体验差异大。而 OpenFeign 的目标是:让远程调用像"本地方法调用"一样简单。
二、OpenFeign 的核心思路
远程调用的关键是 4 个要素:请求方式、请求路径、请求参数、返回值类型。
OpenFeign 利用 SpringMVC 注解(如 @GetMapping
、@RequestParam
)声明这 4 个要素,再通过动态代理自动生成"服务发现、负载均衡、发送 HTTP 请求、解析响应"的代码,开发者只需调用接口方法即可。
三、OpenFeign 的使用步骤(以 cart-service
调用 item-service
为例)
步骤 1:引入依赖
在 cart-service
的 pom.xml
中,添加 OpenFeign 和负载均衡依赖:
<!-- OpenFeign:实现"声明式远程调用" --> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-openfeign</artifactId> </dependency> <!-- 负载均衡器:Feign 集成了 LoadBalancer,自动选服务实例 --> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-loadbalancer</artifactId> </dependency>
步骤 2:启用 OpenFeign 功能
在 cart-service
的启动类(CartApplication
)上,添加 @EnableFeignClients
注解,告诉 Spring Boot 开启 OpenFeign:
@EnableFeignClients // 启用 OpenFeign 客户端功能 @MapperScan("com.hmall.cart.mapper") @SpringBootApplication public class CartApplication { public static void main(String[] args) { SpringApplication.run(CartApplication.class, args); } }
步骤 3:编写 Feign 客户端接口
定义一个接口(如 ItemClient
),用注解声明"要调用的服务、请求细节":
@FeignClient("item-service") // 声明要调用的服务名:item-service(Nacos 中注册的名称) public interface ItemClient { // 用 SpringMVC 注解声明 HTTP 请求:GET /items,参数 ids,返回 List<ItemDTO> @GetMapping("/items") List<ItemDTO> queryItemByIds(@RequestParam("ids") Collection<Long> ids); }
-
@FeignClient("item-service")
:指定目标服务名(从 Nacos 中找该服务的实例)。 -
@GetMapping("/items")
:指定HTTP 请求方式(GET)和请求路径(/items)。 -
@RequestParam("ids") Collection<Long> ids
:指定请求参数(参数名ids
,类型Collection<Long>
)。 -
List<ItemDTO>
:指定返回值类型(远程响应的 JSON 会自动转成List<ItemDTO>
)。
OpenFeign 会基于这个接口,动态生成代理对象,自动完成"服务发现、选实例、发请求、解析响应"。
步骤 4:在业务代码中调用 Feign 客户端
在 CartServiceImpl
中,注入 ItemClient
,像调用本地方法一样调用远程服务:
@Service @RequiredArgsConstructor public class CartServiceImpl implements ICartService { private final ItemClient itemClient; // 注入 Feign 客户端 private void handleCartItems(List<CartVO> vos) { // 1. 提取购物车中商品 ID Set<Long> itemIds = vos.stream().map(CartVO::getItemId).collect(Collectors.toSet()); // 2. 调用 Feign 接口(底层自动完成"服务发现、负载均衡、HTTP 请求") List<ItemDTO> items = itemClient.queryItemByIds(itemIds); // 3. 处理结果(如校验、填充购物车信息) if (CollUtils.isEmpty(items)) { throw new BadRequestException("购物车中商品不存在!"); } // ... 后续填充购物车 VO 的商品信息 } }
此时,无需再手动写 DiscoveryClient
、RestTemplate
的逻辑,一行代码就完成了远程调用,和本地方法调用的体验完全一致。
四、OpenFeign 的优势总结
对比之前的 RestTemplate + DiscoveryClient
:
-
代码更简洁:无需关心"服务发现、负载均衡、HTTP 请求细节",像调用本地方法一样调用远程服务。
-
与 SpringMVC 无缝集成 :用熟悉的
@GetMapping
、@RequestParam
等注解,学习成本低。 -
自动集成负载均衡:底层集成 LoadBalancer,自动从 Nacos 选实例,实现请求分摊。
简单来说,OpenFeign 把"复杂的远程 HTTP 调用"封装成"简单的接口方法调用",大幅提升微服务远程调用的开发效率。
Feign 连接池
要理解 Feign 连接池 的作用与验证,可从「为什么要换连接池」「怎么配置连接池」「怎么验证生效」三个角度拆解:
一、为什么要给 Feign 配置连接池?
Feign 底层发送 HTTP 请求时,默认使用 HttpURLConnection
,但它有个缺陷:
- 不支持连接池:每次请求都要"新建连接 → 发请求 → 关闭连接"。频繁创建/销毁连接的开销很大,高并发下性能会严重下降。
而 Apache HttpClient
、OKHttp
这类客户端支持连接池:可以复用连接,避免重复创建/销毁连接的开销,大幅提升请求效率。因此,我们通常会替换 Feign 的默认 HTTP 客户端,改用带连接池的实现(比如 OKHttp)。
二、如何配置 Feign 使用 OKHttp 连接池?
只需两步:
步骤 1:引入 OKHttp 依赖
在 cart-service
的 pom.xml
中,添加 feign-okhttp
依赖(让 Feign 能找到并使用 OKHttp):
<!-- 引入 OKHttp 依赖,使 Feign 底层用 OKHttp 发请求 --> <dependency> <groupId>io.github.openfeign</groupId> <artifactId>feign-okhttp</artifactId> </dependency>
步骤 2:开启 OKHttp 连接池
在 cart-service
的 application.yml
中,配置启用 Feign 的 OKHttp 功能:
feign: okhttp: enabled: true # 开启 OKHttp 作为 Feign 的 HTTP 客户端
重启 cart-service
后,Feign 就会切换到底层 HTTP 客户端为 OKHttp,并自动使用 OKHttp 的连接池。
三、如何验证 OKHttp 连接池生效?
讲义中用 Debug 断点调试 的方式验证:
-
打 Debug 断点 :在
org.springframework.cloud.openfeign.loadbalancer.FeignBlockingLoadBalancerClient
的execute
方法处打断点(这个方法是 Feign 发起 HTTP 请求的核心逻辑)。 -
Debug 启动服务 :用 Debug 模式启动
cart-service
,然后调用"查询购物车"接口(该接口会通过 Feign 远程调用其他服务,触发 Feign 的 HTTP 请求)。 -
查看底层客户端 :当程序执行到断点时,查看调试器中的变量/调用栈------如果看到底层 HTTP 客户端的实现是
OkHttpClient
(如图片中delegate = OkHttpClient
的显示),就说明 Feign 已成功切换到 OKHttp,连接池也随之生效。
总结逻辑链
默认 Feign 用 HttpURLConnection
(无连接池,性能差)→ 为优化性能,替换为支持连接池的 OKHttp
→ 引入依赖 + 配置开启 → 通过 Debug 断点验证 OKHttp 生效 → 最终实现"连接复用,提升请求效率"。
微服务间"Feign客户端重复编码
要理解这段内容,核心是解决微服务间"Feign客户端重复编码"的问题,通过抽取公共API模块+调整Feign扫描范围实现复用。以下是清晰的逻辑拆解:
一、问题:Feign客户端重复编码
多个微服务(如cart-service
、未来的trade-service
)都需要调用item-service
的"根据ID查商品"接口。如果每个服务都自己写ItemClient
(Feign客户端)和ItemDTO
(数据传输对象),会导致代码重复、维护困难。
二、解决思路:抽取公共API模块
把"远程调用item-service
的代码(FeignClient、DTO)"抽取到独立的公共模块(如hm-api
),让所有需要的服务依赖该模块,实现代码复用。
三、具体步骤与代码解析
1. 创建公共API模块(hm-api
)
新建hm-api
模块,专门存放被多个服务共享的Feign客户端、DTO、配置。其pom.xml
引入必要依赖(OpenFeign、负载均衡等),保证Feign功能正常。
2. 迁移共享代码到hm-api
将cart-service
中原本的ItemDTO
(商品数据格式)和ItemClient
(Feign客户端接口),移动到hm-api
的对应包下(如com.hmall.api.dto
、com.hmall.api.client
)。
3. 服务依赖公共API模块
以cart-service
为例,在它的pom.xml
中引入hm-api
:
<dependency> <groupId>com.heima</groupId> <artifactId>hm-api</artifactId> <version>1.0.0</version> </dependency>
此时,cart-service
可删除自己原来的ItemDTO
和ItemClient
,直接复用hm-api
中的代码。
4. 解决FeignClient扫描问题
问题 :cart-service
的启动类在com.hmall.cart
包下,而ItemClient
在com.hmall.api.client
包下。Spring默认只扫描启动类所在包及子包的@FeignClient
,因此找不到ItemClient
,导致启动报错(如"找不到ItemClient
的Bean")。
解决方式 :在cart-service
的启动类上,通过@EnableFeignClients
指定"FeignClient的扫描范围",有两种写法:
-
方式1:指定扫描包
@EnableFeignClients(basePackages = "com.hmall.api.client") // 扫描公共模块的FeignClient包 @MapperScan("com.hmall.cart.mapper") @SpringBootApplication public class CartApplication { public static void main(String[] args) { SpringApplication.run(CartApplication.class, args); } }
-
方式2:指定具体FeignClient类
@EnableFeignClients(clients = {ItemClient.class}) // 直接指定要使用的FeignClient类 @MapperScan("com.hmall.cart.mapper") @SpringBootApplication public class CartApplication { public static void main(String[] args) { SpringApplication.run(CartApplication.class, args); } }
四、总结
通过抽取公共API模块,让多服务复用"远程调用的代码";再通过调整@EnableFeignClients
的扫描范围,解决FeignClient的加载问题。最终实现"一份代码,多服务共享",提升开发效率与可维护性。
微服务间传递用户信息,让每个微服务都能拿到当前登录用户(可随后再看)
要解决"微服务间传递用户信息,让每个微服务都能拿到当前登录用户"的问题,核心思路是在HTTP请求的上下文(请求头)中传递用户身份标识,并通过统一认证、网关转发、Feign拦截器、上下文解析四个环节实现。以下是分步讲解:
一、核心思路:"请求头传递 + 上下文共享"
微服务是独立部署的,用户请求会经过网关或在服务间调用时传递。因此,需要把"用户身份"(如用户ID、令牌)放在HTTP请求头中,让每个微服务都能从请求头中解析出用户信息。
二、具体实现步骤
1. 统一身份认证:生成含用户信息的令牌(如JWT)
用户登录时,由认证服务(或网关)验证身份,生成JWT(JSON Web Token)。JWT的"负载(Payload)"部分可存储userId
、username
等信息,并用密钥签名(防止篡改)。
示例JWT payload(解密后):
{ "userId": 1001, "username": "zhangsan", "exp": 1740000000 // 过期时间 }
用户后续请求时,会在HTTP请求头中携带此JWT,格式如:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
(完整JWT)。
2. 网关层:解析令牌,传递用户信息到下游微服务
网关(如Spring Cloud Gateway)作为请求的"统一入口",负责:
-
校验JWT有效性:验证签名是否合法、是否过期。
-
解析用户信息 :从JWT中取出
userId
等信息。 -
转发用户信息 :将
userId
(或完整JWT)放入新的请求头(如X-User-Id
),再转发请求到下游微服务(如trade-service
)。
这样,下游微服务就能从请求头中拿到用户身份标识。
3. 微服务间Feign调用:传递请求头中的用户信息
当微服务A(如trade-service
)需要调用微服务B(如user-service
)时,Feign默认不会携带原请求的头信息。此时需通过Feign请求拦截器,将当前请求头中的用户信息(如Authorization
或X-User-Id
)复制到Feign的请求头中。
示例Feign拦截器代码:
@Component public class FeignUserInterceptor implements RequestInterceptor { @Override public void apply(RequestTemplate template) { // 从当前请求上下文(ThreadLocal)中获取原请求头 ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attributes != null) { HttpServletRequest request = attributes.getRequest(); // 获取原请求头中的Authorization(JWT) String token = request.getHeader("Authorization"); if (StrUtil.isNotBlank(token)) { // 将token放入Feign请求的头中,传递给下游服务 template.header("Authorization", token); } } } }
这样,trade-service
调用user-service
时,请求头会携带JWT,user-service
就能解析出用户信息。
4. 微服务内:解析请求头,获取用户信息并共享
每个微服务收到请求后,需从请求头中解析用户信息,并放入线程上下文(ThreadLocal),方便业务代码随时获取。
示例:工具类解析JWT并共享用户ID
public class UserContext { private static final ThreadLocal<Long> userIdThreadLocal = new ThreadLocal<>(); // 从请求头解析JWT,提取userId并设置到ThreadLocal public static void setUserIdFromHeader(HttpServletRequest request) { String token = request.getHeader("Authorization"); if (StrUtil.isNotBlank(token)) { // 用JJWT库解析JWT(需引入依赖) Claims claims = Jwts.parser() .setSigningKey("你的密钥") .parseClaimsJws(token.replace("Bearer ", "")) .getBody(); Long userId = claims.get("userId", Long.class); userIdThreadLocal.set(userId); } } // 业务代码中获取当前用户ID public static Long getUserId() { return userIdThreadLocal.get(); } // 请求结束后清理ThreadLocal,防止内存泄漏 public static void clear() { userIdThreadLocal.remove(); } }
再通过拦截器在请求到达时自动调用UserContext.setUserIdFromHeader(request)
,业务层即可通过UserContext.getUserId()
获取当前用户ID,无需写死。
三、总结流程
用户登录 → 生成JWT → 请求携带JWT到网关 → 网关解析JWT并传递用户信息到微服务 → 微服务内解析用户信息到线程上下文 → 微服务间Feign调用时传递请求头 → 业务代码获取用户信息。
通过这套流程,实现了跨微服务的用户身份传递,让每个微服务都能"知道当前登录用户是谁"。
Spring Cloud Gateway网关的核心配置文件
这个application.yaml
文件是Spring Cloud Gateway网关的核心配置文件,主要用于定义网关的端口、服务注册中心地址以及路由转发规则。下面分部分详细讲解:
1. 基础配置部分
server: port: 8080 # 网关服务自身的运行端口,客户端所有请求都先发送到这个端口 spring: application: name: gateway # 网关服务在注册中心的名称(用于服务发现)
这部分配置了网关服务的基本信息:
-
网关自身占用
8080
端口,所有客户端请求都会先到达这个端口 -
网关服务在注册中心(Nacos)中以
gateway
为名称注册
2. 注册中心配置
spring: cloud: nacos: server-addr: 192.168.150.101:8848 # Nacos注册中心的地址和端口
这部分配置了Nacos注册中心的地址:
-
网关需要从Nacos获取其他微服务(如
item-service
、user-service
等)的地址列表 -
后续路由转发时,网关会根据服务名从Nacos动态获取可用的服务实例
3. 核心:路由规则配置(spring.cloud.gateway.routes
)
这部分是网关的核心,定义了"哪些请求应该转发到哪个服务"的规则。每个路由规则包含3个核心属性:id
、uri
、predicates
。
(1)第一个路由规则(商品服务)
- id: item # 路由唯一标识(自定义,不能重复) uri: lb://item-service # 转发目标:lb表示负载均衡,item-service是目标服务在Nacos的名称 predicates: # 路由断言(判断请求是否符合当前规则) - Path=/items/,/search/ # 当请求路径以/items/或/search/开头时,触发该路由
-
作用:客户端发送的请求路径如果是
/items/xxx
(如/items/123
)或/search/xxx
(如/search/phone
),会被转发到item-service
服务 -
lb://item-service
:lb
表示启用负载均衡,如果item-service
有多个实例,网关会自动分配请求
(2)第二个路由规则(购物车服务)
- id: cart uri: lb://cart-service predicates: - Path=/carts/ # 匹配以/carts/开头的路径(如/carts/1001)
- 作用:路径以
/carts/
开头的请求(如查询购物车、添加商品到购物车),会被转发到cart-service
服务
(3)第三个路由规则(用户服务)
- id: user uri: lb://user-service predicates: - Path=/users/,/addresses/ # 匹配用户相关路径(如用户信息、地址管理)
- 作用:路径以
/users/
(如/users/10086
查询用户信息)或/addresses/
(如/addresses/5
查询地址)开头的请求,转发到user-service
服务
(4)第四个路由规则(订单服务)
- id: trade uri: lb://trade-service predicates: - Path=/orders/ # 匹配订单相关路径(如创建订单、查询订单)
- 作用:路径以
/orders/
开头的请求(如/orders/create
创建订单),转发到trade-service
服务
(5)第五个路由规则(支付服务)
- id: pay uri: lb://pay-service predicates: - Path=/pay-orders/ # 匹配支付相关路径(如发起支付、查询支付状态)
- 作用:路径以
/pay-orders/
开头的请求(如/pay-orders/123/pay
发起支付),转发到pay-service
服务
整体工作流程
-
客户端发送请求到网关的
8080
端口(如http://网关IP:8080/items/1
) -
网关根据请求路径(
/items/1
)匹配路由规则的predicates
-
匹配到
id: item
的规则后,从Nacos获取item-service
的可用实例 -
通过负载均衡(
lb
)将请求转发到其中一个item-service
实例 -
服务处理完成后,响应结果通过网关返回给客户端
通过这种配置,网关统一接收所有请求并转发到对应的微服务,实现了"客户端只需要知道网关地址,无需关心具体服务地址"的效果,同时也为后续的统一认证、限流等功能提供了基础。
上述补充
要清晰理解这些内容,我们可以从"配置 → 底层类 → 核心功能(路由、断言、转发)"的逻辑链条逐步拆解:
一、配置文件:定义路由规则
application.yaml
中 spring.cloud.gateway.routes
是网关的核心配置区,每一个 - id: ...
条目都代表一条路由规则,决定"什么样的请求,转发到哪个服务"。
以商品服务的路由为例:
- id: item # 路由唯一标识 uri: lb://item-service # 转发的目标服务(负载均衡 + 服务名) predicates: # 路由断言:判断请求是否符合当前规则 - Path=/items/,/search/ # 路径匹配断言
二、底层类:承载配置的"结构骨架"
Spring Cloud Gateway 通过两个核心类,将配置文件的逻辑转化为代码结构:

1. GatewayProperties
:路由的"容器类"
从第一个类图可知:
-
它通过
@ConfigurationProperties("spring.cloud.gateway")
与配置文件绑定,负责解析spring.cloud.gateway
下的所有配置。 -
其中
private List<RouteDefinition> routes
表示:所有路由规则会被解析为RouteDefinition
的列表(配置里的每个- id: ...
对应一个RouteDefinition
对象)。
2. RouteDefinition
:单个路由的"规则类"

从第二个类图可知,RouteDefinition
包含以下核心属性(与配置字段一一对应):
-
id
:路由的唯一标识(如配置里的item
)。 -
predicates
:断言列表(List<PredicateDefinition>
),用于判断请求是否符合当前路由。 -
uri
:转发的目标地址(如配置里的lb://item-service
)。 -
filters
:路由过滤器(配置中暂未体现,用于修改请求/响应,后续扩展)。
三、核心功能1:路由断言(Predicates)
断言是"判断请求是否符合路由规则"的条件。讲义表格列出了 Spring Cloud Gateway 支持的多种断言类型,每种断言针对请求的不同特征(路径、方法、Cookie 等)做匹配:
|----------|--------------------|--------------------------------------------------------------------|
| 断言类型 | 作用 | 示例 |
| Path
| 匹配请求路径 | - Path=/items/,/search/
(路径以 /items/
或 /search/
开头) |
| Method
| 匹配 HTTP请求方法(如 GET) | - Method=GET,POST
(只允许 GET/POST 请求) |
| Cookie
| 匹配请求Cookie | - Cookie=chocolate, ch.p
(必须包含名为 chocolate
、值匹配 ch.p
的 Cookie) |
| Header
| 匹配请求头 | - Header=X-Request-Id, \d+
(必须包含 X-Request-Id
头,且值为数字) |
例子中用的是 Path
断言:当请求路径以 /items/
或 /search/
开头时,才会触发当前路由。
四、核心功能2:目标地址与负载均衡(uri
)
配置里的 uri: lb://item-service
包含两个关键逻辑:
-
lb://
:表示启用负载均衡(Load Balance)。网关会从注册中心(如 Nacos)拉取item-service
的所有实例列表,并在实例间做负载均衡(如轮询)。 -
item-service
:是目标服务在注册中心的服务名。网关通过服务名即可找到对应服务,无需关心实例的具体 IP/端口。
五、整体工作流程(以请求 /items/1001
为例)
-
客户端发送请求到网关(如
http://网关地址:8080/items/1001
)。 -
网关遍历所有
RouteDefinition
,检查每个路由的predicates
:- 当匹配到
id: item
的路由时,Path
断言检测到路径/items/1001
符合/items/
规则。
- 当匹配到
-
网关通过
uri: lb://item-service
,从注册中心获取item-service
的所有实例。 -
负载均衡选择其中一个实例(如
192.168.1.10:8081
),将请求转发过去。 -
目标服务处理完请求,响应通过网关返回给客户端。
设计价值
这种架构实现了"客户端只认网关,服务细节全隐藏":
-
客户端无需关心具体服务的部署地址,只需访问网关。
-
网关能动态感知服务实例的上下线,通过负载均衡实现高可用转发。
网关过滤器
要清晰理解网关过滤器的工作原理与使用,我们可以从网关请求流程、过滤器的作用与类型、内置过滤器的配置三个维度逐步拆解:

一、网关的请求处理流程(结合流程图)
客户端请求进入网关后,遵循以下步骤处理:
-
路由匹配 :
HandlerMapping
根据请求路径,匹配到对应的路由规则(Route)(比如"请求路径是/items/
,匹配item
路由")。 -
过滤器链执行 :
FilteringWebHandler
加载当前路由对应的过滤器链(Filter Chain),并按顺序执行过滤器:-
Pre 阶段:过滤器的"请求前逻辑"依次执行(如登录校验、添加请求头)。
-
转发请求 :所有 Pre 逻辑执行完毕后,由
NettyRoutingFilter
将请求转发到具体微服务。 -
Post 阶段:微服务返回响应后,过滤器的"响应后逻辑"倒序执行(如修改响应头、记录日志)。
-
-
返回响应:最终将处理后的响应返回给客户端。
二、过滤器的作用与类型
过滤器是网关"干预请求/响应"的核心手段(比如登录校验必须在请求转发前完成,就需要通过过滤器实现)。Spring Cloud Gateway 有两类核心过滤器:
1. GatewayFilter(路由过滤器)
-
作用范围 :单个指定的路由(只对配置了它的
Route
生效)。 -
特点:灵活,可针对不同路由做差异化处理(比如给商品服务的请求加头,订单服务的请求不加)。
2. GlobalFilter(全局过滤器)
-
作用范围:所有路由(对进入网关的所有请求都生效)。
-
特点:用于全局统一处理(比如全局登录校验、全局日志记录)。
补充:HttpHeadersFilter
专门用于处理请求头的传递(如代理场景下,将客户端原始的 Host
头传递给下游微服务),属于"请求头增强"的补充型过滤器。
三、过滤器的执行逻辑(方法签名)
GatewayFilter
和 GlobalFilter
的核心方法签名完全一致:
Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain);
-
ServerWebExchange
:请求的"上下文",包含请求(ServerHttpRequest
)、响应(ServerHttpResponse
)等所有关键数据。 -
GatewayFilterChain
:过滤器链的"执行器",调用chain.filter(exchange)
表示将请求交给下一个过滤器处理。若当前是链中最后一个过滤器(如NettyRoutingFilter
),则触发"请求转发到微服务"。
四、内置 GatewayFilter 的配置与使用
Spring Cloud Gateway 内置了大量实用的 GatewayFilter
(如 AddRequestHeader
、StripPrefix
等),无需编码,通过 YAML 配置即可使用。以 AddRequestHeaderGatewayFilterFactory
(给请求添加请求头)为例:
1. 作用于单个路由
在具体路由的 filters
节点下配置:
spring: cloud: gateway: routes: - id: test_route # 路由唯一标识 uri: lb://test-service # 目标服务(负载均衡转发) predicates: - Path=/test/ # 路径匹配:/test/开头的请求 filters: - AddRequestHeader=X-Request-Id, 123456 # 添加请求头:key=X-Request-Id,value=123456
效果:只有访问 /test/
路径、匹配 test_route
的请求,才会被添加 X-Request-Id: 123456
的请求头。
2. 作用于所有路由
在 default-filters
节点下配置(全局生效):
spring: cloud: gateway: default-filters: # 对所有路由生效的过滤器 - AddRequestHeader=X-Global-Id, abc # 给所有请求添加 X-Global-Id: abc 的头 routes: - id: test_route uri: lb://test-service predicates: - Path=/test/
效果:所有进入网关的请求,都会被添加 X-Global-Id: abc
的请求头。
总结逻辑链
-
先理解网关处理请求的整体流程:路由匹配 → 过滤器链执行(Pre 逻辑)→ 转发微服务 → 过滤器链执行(Post 逻辑)→ 返回响应。
-
再明确过滤器的作用:是"在请求转发前后、响应返回前后"干预请求/响应的关键环节(如登录校验、请求头增强)。
-
然后区分过滤器类型:GatewayFilter(单路由)、GlobalFilter(全路由),按需选择作用范围。
-
最后掌握内置过滤器的配置:通过 YAML 即可快速给请求/响应添加通用逻辑,无需自定义编码。
自定义网关过滤器
要理解自定义网关过滤器,我们可以从两种过滤器的实现方式、代码逻辑、配置方法三个维度展开,结合具体代码逐点解析:
一、自定义 GatewayFilter(路由过滤器)
GatewayFilter
是作用于指定路由的过滤器(可通过配置指定作用范围),自定义时需遵循 Spring Cloud Gateway 的约定,核心是继承 AbstractGatewayFilterFactory
。
1. 无参数的自定义 GatewayFilter
场景:不需要动态参数,仅实现固定逻辑(如简单打印日志)。
代码解析:
// 类名必须以 GatewayFilterFactory 为后缀(Spring 识别的约定) @Component // 交给 Spring 管理,使其能被网关加载 public class PrintAnyGatewayFilterFactory extends AbstractGatewayFilterFactory<Object> { @Override public GatewayFilter apply(Object config) { // 返回实际执行过滤逻辑的 GatewayFilter 实例 return new GatewayFilter() { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { // 1. 获取请求上下文(包含请求、响应等信息) ServerHttpRequest request = exchange.getRequest(); // 2. 编写过滤逻辑(这里是简单打印) System.out.println("过滤器执行了"); // 3. 放行:将请求交给下一个过滤器(若为最后一个则转发到微服务) return chain.filter(exchange); } }; } }
配置使用:
spring: cloud: gateway: default-filters: # 作用于所有路由(也可配置在具体 routes 的 filters 下,仅作用于该路由) - PrintAny # 直接使用类名前缀(PrintAnyGatewayFilterFactory → 前缀是 PrintAny)
核心点:
-
类名后缀必须是
GatewayFilterFactory
(Spring 通过此约定识别自定义路由过滤器)。 -
apply
方法返回的GatewayFilter
实例中,filter
方法是核心逻辑实现处。 -
chain.filter(exchange)
表示"放行",让请求继续向下传递;若不调用则会"拦截"请求。
2.带参数的自定义 GatewayFilter(必看)
要彻底搞懂带参数的自定义 GatewayFilter,我们可以把流程拆成 "参数定义→参数接收→逻辑执行→配置使用" 四个步骤,结合代码逐行解析:
步骤1:定义"参数载体"------内部类 Config
@Data // Lombok 注解,自动生成 get/set 方法 static class Config { private String a; private String b; private String c; }
-
作用:存储配置文件中传递的参数(比如
a=1
、b=2
、c=3
)。 -
类比:把
Config
想象成一个"参数容器",字段a
、b
、c
是容器的"格子",用来装配置值。
步骤2:告诉框架"用哪个容器装参数"------getConfigClass
@Override public Class<Config> getConfigClass() { return Config.class; }
-
作用:向父类
AbstractGatewayFilterFactory
声明:"我要使用内部类Config
来解析配置参数"。 -
逻辑:框架拿到配置后,会自动把参数映射到
Config
的字段中(比如配置里的a=1
会赋值给Config.a
)。
步骤3:指定"按顺序传参"的顺序------shortcutFieldOrder
@Override public List<String> shortcutFieldOrder() { return List.of("a", "b", "c"); }
-
作用:定义"快捷传参"时的参数顺序(对应配置方式1:
PrintAny=1,2,3
)。 -
逻辑:配置里的第一个值(
1
)对应Config.a
,第二个值(2
)对应Config.b
,第三个值(3
)对应Config.c
。
步骤4:生成真正的过滤器------apply
方法
@Override public GatewayFilter apply(Config config) { return new OrderedGatewayFilter(new GatewayFilter() { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { // 从 Config 中取参数 String a = config.getA(); String b = config.getB(); String c = config.getC(); // 用参数做逻辑(这里只是打印,实际可做"加请求头""校验"等) System.out.println("a = " + a + ", b = " + b + ", c = " + c); // 放行请求(让请求继续走过滤器链/转发到微服务) return chain.filter(exchange); } }, 100); // 100 是执行顺序(值越小,优先级越高) }
-
作用:根据配置好的参数,生成可执行的过滤器。
-
细节:
-
config
参数:是框架填充好的Config
实例(里面有配置文件的参数)。 -
OrderedGatewayFilter
:包装过滤器并指定执行顺序(解决多个过滤器"谁先执行"的问题)。 -
chain.filter(exchange)
:表示"放行请求",让请求继续向下传递(若不调用则会"拦截"请求)。
-
步骤5:配置文件中传递参数(两种方式)
方式1:按顺序传参(依赖 shortcutFieldOrder
)
spring: cloud: gateway: default-filters: - PrintAny=1,2,3 # 1→a,2→b,3→c(按 shortcutFieldOrder 顺序匹配)
- 逻辑:框架会把
1
赋值给Config.a
,2
赋值给Config.b
,3
赋值给Config.c
。
方式2:指定参数名传参(更灵活)
spring: cloud: gateway: default-filters: - name: PrintAny # 过滤器的"前缀名"(类名去掉 GatewayFilterFactory) args: # 显式指定参数名和值,无需关心顺序 a: 1 b: 2 c: 3
- 逻辑:框架直接根据
args
里的键(a
、b
、c
),把值赋值给Config
对应的字段。
核心设计意图:过滤器复用
可以用"多服务的请求头动态增强"场景来理解:
假设我们有商品服务、订单服务、用户服务三个微服务,需要给不同服务的请求动态添加差异化的请求头(用于服务内部识别、定制逻辑):
-
对商品服务的请求,要添加两个头:`X-Service=item`(标识服务)、`X-Item-Priority=high`(商品服务专属的"高优先级"标记)。
-
对订单服务的请求,要添加两个头:`X-Service=order`(标识服务)、`X-Order-Limit=100`(订单服务专属的"限流阈值"标记)。
此时,带参数的自定义GatewayFilter就能发挥作用:
我们写一个通用的 `ServiceHeaderGatewayFilterFactory`,内部用 `Config` 类定义三个参数:`serviceName`(服务名)、`customHeaderKey`(自定义头的键)、`customHeaderValue`(自定义头的值)。
然后在路由配置中:
-
商品服务的路由配置:`ServiceHeader=item, X-Item-Priority, high`。
-
订单服务的路由配置:`ServiceHeader=order, X-Order-Limit, 100`。
这样,同一个过滤器工厂,通过不同的参数配置,就能为不同服务的请求"动态添加差异化的请求头"------既复用了"添加请求头"的核心逻辑,又能通过参数灵活定制每个服务的专属行为,无需为每个服务单独写过滤器。
总结流程
- 配置文件传递参数 → 2. 框架用
Config
类接收参数 → 3.apply
方法根据参数生成过滤器 → 4. 过滤器执行时读取参数做逻辑 → 5. 放行/拦截请求。
每一步都围绕"让过滤器能根据配置动态改变行为"设计,是"配置驱动逻辑"的典型体现。
二、自定义 GlobalFilter(全局过滤器)
GlobalFilter
是作用于所有路由的过滤器(无需配置,全局生效),实现更简单,直接实现 GlobalFilter
接口即可。
代码解析:
@Component // 交给 Spring 管理,自动成为全局过滤器 public class PrintAnyGlobalFilter implements GlobalFilter, Ordered { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { // 1. 编写过滤逻辑(这里模拟"未登录拦截") System.out.println("未登录,无法访问"); // 2. 拦截请求(不放行):返回 401 未授权 ServerHttpResponse response = exchange.getResponse(); response.setRawStatusCode(401); // 设置响应状态码 return response.setComplete(); // 结束响应(不再向下传递) // 若要放行,需调用:return chain.filter(exchange); } @Override public int getOrder() { // 执行顺序:值越小,优先级越高(确保在请求转发前执行) return 0; } }
核心点:
-
无需继承特定工厂类,直接实现
GlobalFilter
接口即可。 -
必须实现
Ordered
接口的getOrder
方法,指定执行顺序(登录校验等核心逻辑需设置较小值,确保在转发前执行)。 -
拦截与放行的区别:
-
放行:
return chain.filter(exchange)
(让请求继续向下传递)。 -
拦截:
return response.setComplete()
(直接结束响应,不转发到微服务)。
-
三、两种过滤器的核心区别
|------|-----------------------------------------|--------------------------------|
| 维度 | GatewayFilter(路由过滤器) | GlobalFilter(全局过滤器) |
| 作用范围 | 可指定路由(配置在具体路由或 default-filters) | 所有路由(全局生效) |
| 实现方式 | 继承 AbstractGatewayFilterFactory,类名有固定后缀 | 直接实现 GlobalFilter + Ordered 接口 |
| 参数支持 | 支持动态参数(通过 Config 类和配置文件传递) | 不支持动态参数(逻辑固定,若需参数需自己读取配置) |
| 配置需求 | 需要在 yaml 中声明(指定作用的路由) | 无需配置,加 @Component 即可自动生效 |
| 典型场景 | 针对特定路由的个性化处理(如给商品服务加特殊请求头) | 全局统一处理(如登录校验、全局日志、限流) |
总结
自定义过滤器的核心是在请求转发前后插入业务逻辑:
-
若需针对特定路由配置不同参数,用
GatewayFilter
,需遵循类名约定和配置规则。 -
若需对所有请求做统一处理(如登录校验),用
GlobalFilter
,实现简单且无需额外配置。
两种过滤器最终都会进入网关的过滤器链,按 Order
定义的顺序执行,共同完成对请求/响应的干预。
自定义 GatewayFilter
示例(必看)
我们结合"多服务请求头动态增强"场景,通过代码和配置来讲解带参数自定义 GatewayFilter
的作用:
1. 自定义过滤器工厂代码(ServiceHeaderGatewayFilterFactory
)
@Component // 交给Spring管理,网关启动时自动加载 public class ServiceHeaderGatewayFilterFactory extends AbstractGatewayFilterFactory<ServiceHeaderGatewayFilterFactory.Config> { // 内部类:存储配置参数(服务名、自定义请求头的键和值) @Data // Lombok注解,自动生成get/set方法 static class Config { private String serviceName; // 服务名称(用于X-Service请求头) private String customHeaderKey; // 自定义请求头的"键" private String customHeaderValue; // 自定义请求头的"值" } // 声明配置参数的载体是内部类Config @Override public Class<Config> getConfigClass() { return Config.class; } // 指定"按顺序传参"时的参数顺序(与Config字段一一对应) @Override public List<String> shortcutFieldOrder() { return List.of("serviceName", "customHeaderKey", "customHeaderValue"); } // 生成实际执行的过滤器 @Override public GatewayFilter apply(Config config) { // 包装为OrderedGatewayFilter,指定执行顺序(值越小优先级越高) return new OrderedGatewayFilter((exchange, chain) -> { // 1. 获取原始请求 ServerHttpRequest request = exchange.getRequest(); // 2. 构建新请求,动态添加两个请求头: // - X-Service:标识服务名称(从配置参数取) // - 自定义头:键和值也从配置参数取 ServerHttpRequest.Builder requestBuilder = request.mutate() .header("X-Service", config.getServiceName()) .header(config.getCustomHeaderKey(), config.getCustomHeaderValue()); // 3. 替换请求上下文(让新请求生效) ServerWebExchange newExchange = exchange.mutate() .request(requestBuilder.build()) .build(); // 4. 放行请求(继续执行过滤器链或转发到微服务) return chain.filter(newExchange); }, 200); // 执行顺序设为200(确保在请求转发前执行) } }
2. 网关路由配置(application.yaml
)
spring: cloud: gateway: routes: # 商品服务路由:按"顺序传参"配置过滤器 - id: item-service-route uri: lb://item-service # 转发到商品服务(负载均衡) predicates: - Path=/items/ # 路径以/items/开头的请求匹配 filters: # 按shortcutFieldOrder顺序传参:serviceName=item, customHeaderKey=X-Item-Priority, customHeaderValue=high - ServiceHeader=item, X-Item-Priority, high # 订单服务路由:按"显式指定参数名"配置过滤器 - id: order-service-route uri: lb://order-service # 转发到订单服务(负载均衡) predicates: - Path=/orders/ # 路径以/orders/开头的请求匹配 filters: - name: ServiceHeader # 显式指定过滤器名称 args: # 明确指定每个参数的键值 serviceName: order customHeaderKey: X-Order-Limit customHeaderValue: "100"
3. 代码与配置的逻辑讲解
(1)参数的"定义与接收"
-
内部类
Config
是参数载体,定义了serviceName
、customHeaderKey
、customHeaderValue
三个字段,用于接收配置文件中的参数。 -
getConfigClass()
告诉框架:"用这个Config
类来解析配置参数"。 -
shortcutFieldOrder()
规定:当配置用"逗号分隔值"(如ServiceHeader=item, X-Item-Priority, high
)时,第一个值对应serviceName
,第二个对应customHeaderKey
,第三个对应customHeaderValue
。
(2)过滤器的"逻辑执行"
apply(Config config)
方法中:
-
接收框架填充好的
Config
对象(里面包含配置文件的参数)。 -
通过
request.mutate()
构建新请求,动态添加两个请求头:-
X-Service: 服务名
(用于下游服务识别"请求来自哪个微服务")。 -
自定义头(如
X-Item-Priority: high
或X-Order-Limit: 100
,用于下游服务的专属逻辑)。
-
-
调用
chain.filter(newExchange)
放行请求,让其继续执行过滤器链或转发到微服务。
(3)配置的"灵活传递"
-
商品服务 用"顺序传参":
ServiceHeader=item, X-Item-Priority, high
简洁高效,适合参数顺序固定的场景。 -
订单服务 用"显式指定参数名":通过
args
明确赋值,无需关心参数顺序,更灵活(比如新增参数时,不用调整顺序)。
4. 最终效果
-
当请求商品服务(路径如
/items/123
)时,网关会给请求添加两个头:-
X-Service: item
-
X-Item-Priority: high
-
-
当请求订单服务(路径如
/orders/456
)时,网关会给请求添加两个头:-
X-Service: order
-
X-Order-Limit: 100
-
下游服务可通过这些请求头,执行"识别服务来源""高优先级逻辑""限流阈值判断"等定制行为,而过滤器逻辑只需写一次,通过参数配置实现了"不同服务、不同逻辑"的动态增强。
登录校验的实现
要清晰理解这段登录校验的实现,我们可以从工具准备、过滤器逻辑、测试验证三个维度逐步拆解:
一、JWT工具与配置:登录校验的"基础支撑"
登录校验依赖 JWT(JSON Web Token) 实现"无状态身份认证",因此需要一系列工具和配置来支持JWT的加密、解析、免校验路径定义。
1. 核心组件作用
-
AuthProperties
:配置无需登录校验的路径(如公开的商品查询、登录接口),让这些路径跳过登录校验。 -
JwtProperties
:配置JWT加密的核心参数(秘钥文件位置、别名、密码、token有效期),是JWT安全的关键。 -
SecurityConfig
:自动装配类,确保JWT工具、配置类能被Spring容器管理(方便后续注入使用)。 -
JwtTool
:JWT工具类,提供parseToken
等方法,用于解析token、校验有效性、提取用户信息。 -
hmall.jks
:秘钥文件,JWT的加密/解密依赖此文件,防止token被非法篡改。
2. application.yaml
配置解析
hm: jwt: location: classpath:hmall.jks # 秘钥文件在类路径下的位置 alias: hmall # 秘钥别名 password: hmall123 # 秘钥文件的密码 tokenTTL: 30m # token有效期(30分钟) auth: excludePaths: # 免登录校验的路径(Ant风格通配符) - /search/ # 搜索接口 - /users/login # 登录接口 - /items/ # 商品相关接口
这些配置会被JwtProperties
和AuthProperties
读取,分别控制JWT的加密规则和免校验路径。
二、登录校验过滤器:AuthGlobalFilter
(核心逻辑)
这个类是全局过滤器(对所有请求生效),负责判断请求是否需要登录、token是否有效。
代码分步解释
@Component @RequiredArgsConstructor @EnableConfigurationProperties(AuthProperties.class) public class AuthGlobalFilter implements GlobalFilter, Ordered { private final JwtTool jwtTool; // JWT工具:解析token private final AuthProperties authProperties; // 免校验路径配置 private final AntPathMatcher antPathMatcher = new AntPathMatcher(); // 路径匹配工具 @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { // 1. 获取当前请求 ServerHttpRequest request = exchange.getRequest(); // 2. 判断是否为"免校验路径" if (isExclude(request.getPath().toString())) { return chain.filter(exchange); // 免校验,直接放行 } // 3. 从请求头获取token(前端需将token放在Authorization头中) String token = null; List<String> headers = request.getHeaders().get("authorization"); if (!CollUtils.isEmpty(headers)) { token = headers.get(0); } // 4. 校验并解析token Long userId = null; try { userId = jwtTool.parseToken(token); // 解析token,提取用户ID } catch (UnauthorizedException e) { // token无效(过期、篡改等),返回401未授权 ServerHttpResponse response = exchange.getResponse(); response.setRawStatusCode(401); return response.setComplete(); // 结束响应,拦截请求 } // 5. token有效:传递用户信息(TODO:实际需将userId放入请求头/上下文,供下游服务使用) System.out.println("userId = " + userId); // 6. 放行请求,继续转发到下游微服务 return chain.filter(exchange); } // 判断请求路径是否属于"免校验路径" private boolean isExclude(String requestPath) { for (String excludePath : authProperties.getExcludePaths()) { // 用Ant风格匹配(如"/items/"能匹配"/items/1"、"/items/list"等) if (antPathMatcher.match(excludePath, requestPath)) { return true; // 匹配到免校验路径,返回true } } return false; // 未匹配,需要校验 } @Override public int getOrder() { return 0; // 过滤器执行顺序:值越小,优先级越高(确保登录校验优先执行) } }
逻辑总结
-
免校验判断 :先检查请求路径是否在
excludePaths
中,是则直接放行。 -
token提取 :从
Authorization
请求头中提取token。 -
token校验 :用
JwtTool
解析token,无效则返回401拦截;有效则提取用户ID。 -
用户信息传递 :(示例中仅打印,实际需将
userId
放入请求头/上下文,让下游服务感知用户身份)。 -
请求放行:token有效则转发到下游微服务。
三、测试验证:不同路径的行为差异
通过"免校验路径"和"需校验路径"的对比,验证过滤器逻辑:
1. 访问免校验路径(如/items/page?pageNo=1&pageSize=1
)
-
因为
/items/
在excludePaths
中,过滤器的isExclude
方法会匹配到该路径。 -
直接放行,无需token校验,所以未登录也能正常获取商品数据(返回JSON结果)。
2. 访问需校验路径(如/carts/list
购物车列表)
-
该路径不在
excludePaths
中,需要token校验。 -
未登录时,请求头无有效token,
JwtTool.parseToken
会抛出异常。 -
过滤器捕获异常,返回401状态码,请求被拦截(页面显示"HTTP ERROR 401")。
整体设计价值
通过网关统一拦截 + JWT无状态认证 + 灵活免校验配置,实现了:
-
对"需登录接口"(如购物车、订单)的安全保护。
-
对"公开接口"(如商品查询、登录)的无障碍访问。
-
下游微服务无需重复做登录校验,只需从请求中获取用户ID即可,实现了"职责单一 + 逻辑复用"。
微服务获取网关传递的用户信息
要理解微服务如何获取网关传递的用户信息,我们可以从"网关传递信息 → 微服务拦截接收 → 业务代码使用"的完整链路逐步拆解:
一、网关:将用户信息放入请求头,转发给微服务
网关的登录校验过滤器(如 AuthGlobalFilter
)在解析JWT得到用户ID后,需要把用户信息通过HTTP请求头传递给下游微服务。
核心代码逻辑:
// 解析JWT得到用户ID后,将其存入请求头 String userInfo = userId.toString(); // userId是解析JWT得到的用户ID ServerWebExchange ex = exchange.mutate() .request(builder -> builder.header("user-info", userInfo)) // 设置请求头:key=user-info,value=用户ID .build(); return chain.filter(ex); // 带着新请求头,转发给微服务
作用:让微服务能从请求头中拿到"当前登录用户ID"。
二、微服务:拦截器从请求头取信息,存入 ThreadLocal
微服务需要统一处理"获取用户信息"的逻辑,并让"用户信息在当前请求的所有业务代码中可共享"。因此用拦截器 + ThreadLocal
工具类实现。
1. ThreadLocal
工具类:UserContext
ThreadLocal
的特点是:同一个线程内的所有代码,都能获取到它存储的值(不同线程互不干扰)。适合"一次请求(一个线程)内共享用户信息"。
代码:
public class UserContext { private static final ThreadLocal<Long> tl = new ThreadLocal<>(); // 存入用户ID到ThreadLocal public static void setUser(Long userId) { tl.set(userId); } // 从ThreadLocal获取用户ID public static Long getUser() { return tl.get(); } // 请求结束后,移除ThreadLocal中的值(防止内存泄漏) public static void removeUser() { tl.remove(); } }
2. 拦截器:UserInfoInterceptor
拦截器在请求到达业务控制器之前执行,负责"从请求头取用户信息 → 存入ThreadLocal
"。
代码:
public class UserInfoInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 1. 从请求头取网关传递的"user-info"(用户ID) String userInfo = request.getHeader("user-info"); // 2. 如果有值,转成Long存入UserContext的ThreadLocal if (StrUtil.isNotBlank(userInfo)) { UserContext.setUser(Long.valueOf(userInfo)); } // 3. 放行,让请求继续到控制器 return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { // 请求处理完毕后,移除ThreadLocal中的用户信息(避免线程复用导致的信息污染) UserContext.removeUser(); } }
3. 拦截器的"自动装配":MvcConfig
+ spring.factories
因为所有微服务都需要这个拦截器,所以把配置写在公共模块(如hm-common
)中,并用SpringBoot的"自动装配"让微服务能"自动生效":
-
步骤1:配置类
MvcConfig
:注册拦截器。@Configuration @ConditionalOnClass(DispatcherServlet.class) // 确保SpringMVC环境存在时才生效 public class MvcConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new UserInfoInterceptor()); // 注册用户信息拦截器 } }
-
步骤2:
spring.factories
配置:让SpringBoot启动时自动加载MvcConfig
。
在resources/META-INF/spring.factories
中添加:
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ com.hmall.common.config.MvcConfig
三、业务代码:从 ThreadLocal
获取用户信息
现在,微服务的业务代码(如购物车服务查询"我的购物车")可通过 UserContext.getUser()
无感知获取当前登录用户ID。
以购物车服务 CartServiceImpl
的 queryMyCarts
方法为例:
@Override public List<CartVO> queryMyCarts() { // 1. 查询"当前用户"的购物车:通过UserContext.getUser()获取用户ID List<Cart> carts = lambdaQuery().eq(Cart::getUserId, UserContext.getUser()).list(); // 2. 后续转换VO、处理商品信息等逻辑... return vos; }
这里的 UserContext.getUser()
会从ThreadLocal
中拿到"当前请求的用户ID",从而准确查询"该用户的购物车",替代了之前"写死用户ID"的硬编码逻辑。
整体流程总结(结合流程图)
-
浏览器请求网关:携带JWT发起请求。
-
网关校验JWT :解析出用户ID,将其放入请求头
user-info
,转发给微服务。 -
微服务拦截器拦截 :从请求头取
user-info
,存入UserContext
的ThreadLocal
。 -
业务代码执行 :通过
UserContext.getUser()
获取用户ID,执行用户相关业务(如查询个人购物车)。 -
请求结束 :拦截器移除
ThreadLocal
中的用户ID,避免内存泄漏。
这套逻辑实现了"网关与微服务间的用户信息传递",且通过"公共模块+自动装配"让所有微服务能复用代码,保证了架构的简洁与一致性。
OpenFeign传递用户信息
要理解OpenFeign传递用户信息的逻辑,我们可以从业务问题、技术方案、代码实现三个层面拆解:
一、业务问题:微服务间调用"丢失用户信息"
前端请求经过网关时,网关会把"用户信息(如ID)"通过请求头传递给微服务,微服务再通过拦截器+ThreadLocal保存用户信息(如之前的UserContext
)。
但微服务之间的调用(如订单服务调用购物车服务)是通过 OpenFeign 实现的,默认情况下:
-
订单服务的业务代码能通过
UserContext
拿到用户ID,但Feign发起请求时,不会自动把用户ID带到请求头中。 -
购物车服务接收到Feign请求后,因为请求头没有用户信息,就无法知道"当前是哪个用户要清理购物车",导致业务逻辑失败。
二、技术方案:Feign拦截器(RequestInterceptor
)
OpenFeign提供了 RequestInterceptor
接口,可以在每次Feign发起请求前执行自定义逻辑。我们可以利用它:
-
从
UserContext
(ThreadLocal工具类)中获取当前用户ID。 -
把用户ID放入请求头,随Feign请求一起发送给下游微服务。
-
下游微服务再通过"拦截器从请求头取用户ID → 存入ThreadLocal"的逻辑,让业务代码能拿到用户信息。
三、代码实现:编写Feign拦截器
1. 定义RequestInterceptor
Bean
在公共模块(如hm-api
,因为所有FeignClient都在该模块)的配置类(DefaultFeignConfig
)中,编写如下代码:
@Bean public RequestInterceptor userInfoRequestInterceptor() { return template -> { // 1. 从ThreadLocal(UserContext)中获取当前用户ID Long userId = UserContext.getUser(); // 2. 如果有用户ID,就放入请求头 if (userId != null) { template.header("user-info", userId.toString()); } }; }
2. 逻辑解释
-
RequestInterceptor
的apply
方法:每次Feign发起请求前,都会执行这个方法。 -
UserContext.getUser()
:从ThreadLocal中获取"当前请求的用户ID"(因为微服务自己的业务代码已经通过拦截器把用户ID存入ThreadLocal了)。 -
template.header("user-info", ...)
:给Feign的请求添加"user-info"请求头,值为用户ID。
四、效果:全链路用户信息传递
通过这套逻辑,实现了"网关→微服务→微服务(Feign调用)"的用户信息传递闭环:
-
前端请求网关:网关解析JWT,把用户ID放入请求头,转发给微服务。
-
微服务接收到网关请求:拦截器从请求头取用户ID,存入
UserContext
(ThreadLocal)。 -
微服务内部Feign调用其他服务:Feign拦截器从
UserContext
取用户ID,放入Feign请求的头中。 -
下游微服务接收到Feign请求:拦截器从请求头取用户ID,存入
UserContext
,业务代码可通过UserContext
获取用户ID。
这样,即使是"微服务之间的Feign调用",也能像"网关到微服务的请求"一样,正确传递用户信息,保证业务逻辑(如"清理当前用户购物车")能正常执行。
Nacos 配置管理
要理解 Nacos 配置管理(以"配置共享"为例),可从问题背景、解决思路、操作步骤、核心价值四个维度拆解:
一、问题背景:微服务配置的痛点
之前微服务的配置(如数据库连接、日志、Swagger)是写死在各自服务的 application.yaml
中,存在三大问题:
-
重复配置多:每个服务都要配数据库、日志,改一处需改所有服务,维护繁琐。
-
变更需重启:配置写死在文件里,修改后必须重启服务才能生效。
-
网关路由硬编码:网关的路由规则也写死,改路由就得重启网关,灵活性差。
二、解决思路:Nacos 配置管理
Nacos 不仅是注册中心(管理服务注册与发现),还是配置中心:
-
能集中存储配置,让多个微服务共享公共配置。
-
支持配置热更新:修改配置后,Nacos 主动推送给微服务,无需重启服务。
三、操作步骤:抽取"共享配置"到 Nacos
以 cart-service
为例,将数据库(JDBC)、日志、Swagger 这三类"多服务通用配置"抽取到 Nacos:
1. 共享 JDBC 配置(Nacos 中新建 shared-jdbc.yaml
)
配置内容(含占位符+默认值,兼顾"默认可用"与"自定义覆盖"):
spring: datasource: # 数据库连接:ip/端口/库名支持占位符,默认值兜底 url: jdbc:mysql://${hm.db.host:192.168.150.101}:${hm.db.port:3306}/${hm.db.database}?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai driver-class-name: com.mysql.cj.jdbc.Driver # 用户名/密码也支持占位符+默认值 username: ${hm.db.un:root} password: ${hm.db.pw:123} mybatis-plus: configuration: # MyBatis-Plus 枚举处理器(让枚举字段自动映射) default-enum-type-handler: com.baomidou.mybatisplus.core.handlers.MybatisEnumTypeHandler global-config: db-config: # 更新策略:非空字段才更新(避免空值覆盖数据库已有值) update-strategy: not_null # ID 生成策略:自动(随数据库自增) id-type: auto
- 例如
${hm.db.host:192.168.150.101}
:若 Nacos 中有hm.db.host
配置,就用它;否则用默认值192.168.150.101
。
2. 共享日志配置(Nacos 中新建 shared-log.yaml
)
配置内容(统一日志级别、格式、存储路径):
logging: level: # 项目包下的日志级别设为 debug,方便开发调试 com.hmall: debug pattern: # 日志时间格式(精确到毫秒) dateformat: HH:mm:ss:SSS file: # 日志存储路径:按"服务名"分目录(如 cart-service 的日志存在 logs/cart-service) path: "logs/${spring.application.name}"
3. 共享 Swagger 配置(Nacos 中新建 shared-swagger.yaml
)
配置内容(统一接口文档的基本信息,支持自定义覆盖):
knife4j: enable: true # 开启 Swagger 接口文档 openapi: # 文档标题:默认"黑马商城接口文档",可通过 hm.swagger.title 自定义 title: ${hm.swagger.title:黑马商城接口文档} description: ${hm.swagger.description:黑马商城接口文档} email: ${hm.swagger.email:zhanghuyi@itcast.cn} concat: ${hm.swagger.concat:虎哥} url: https://www.itcast.cn version: v1.0.0 group: default: group-name: default api-rule: package # 按"包"划分接口组 # 接口所在的 Controller 包:通过 hm.swagger.package 自定义(每个服务的 Controller 包不同) api-rule-resources: - ${hm.swagger.package}
四、核心价值:配置管理的优势
-
集中化:公共配置(数据库、日志、Swagger)不再分散在各服务,统一存在 Nacos,维护更高效。
-
动态化:Nacos 支持配置热更新,修改配置后无需重启服务,立即生效。
-
灵活性:通过"占位符+默认值",既能共享公共逻辑,又能让不同环境(开发/测试/生产)、不同服务自定义配置(如每个服务的 Swagger 包路径不同)。
简言之,Nacos 配置管理让微服务的配置从"分散、静态"升级为"集中、动态、灵活",大幅提升了架构的可维护性与扩展性。
从Nacos拉取共享配置
要理解微服务从Nacos拉取共享配置的过程,需聚焦"引导阶段如何先获取Nacos地址,再加载配置"的核心逻辑,拆解为以下步骤:
一、核心问题:配置加载的"先后顺序"矛盾
Spring 应用启动时,存在两个上下文:
-
Bootstrap 上下文(SpringCloud 引导上下文):负责"服务发现、配置中心连接"等基础环境初始化,加载时机早于 SpringBoot 上下文。
-
SpringBoot 上下文 :加载
application.yaml
等业务配置。
如果把"Nacos 地址"写在 application.yaml
里,Bootstrap 上下文在初始化时根本不知道 Nacos 在哪,无法拉取 Nacos 中的配置。
二、解决方案:bootstrap.yaml
+ 依赖支持
通过更早加载的 bootstrap.yaml
配置 Nacos 地址,并引入依赖启用 Bootstrap 机制,让 SpringCloud 在引导阶段就能连接 Nacos。
三、步骤拆解:微服务整合 Nacos 配置管理
以 cart-service
为例:
1. 引入依赖(支持 Nacos 配置 & Bootstrap 文件)
<!-- Nacos 配置管理:让微服务能连接 Nacos 拉取配置 --> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId> </dependency> <!-- 启用 Bootstrap 文件:SpringBoot 2.4+ 后需显式引入,才能优先加载 bootstrap.yaml --> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-bootstrap</artifactId> </dependency>
2. 新建 bootstrap.yaml
(引导阶段配置 Nacos)
在 cart-service
的 resources
目录下创建 bootstrap.yaml
,内容为:
spring: application: name: cart-service # 服务名称(Nacos 按服务名匹配配置) profiles: active: dev # 激活的环境(如 dev、prod,用于多环境配置隔离) cloud: nacos: server-addr: 192.168.150.101 # Nacos 服务器地址(告诉微服务去哪拉取配置) config: file-extension: yaml # Nacos 中配置文件的后缀(需与 Nacos 里的配置文件后缀一致) shared-configs: # 声明"要从 Nacos 拉取的共享配置列表" - dataId: shared-jdbc.yaml # 共享的"数据库 + MyBatis"配置 - dataId: shared-log.yaml # 共享的"日志"配置 - dataId: shared-swagger.yaml # 共享的"Swagger 接口文档"配置
作用:
- 让 Bootstrap 上下文在启动最早期,就知道"Nacos 地址"和"要拉取哪些共享配置"。
3. 简化 application.yaml
(保留服务独有配置)
原来 application.yaml
中"数据库、日志、Swagger"等公共配置已抽到 Nacos 共享配置里,因此只需保留服务独有的配置:
server: port: 8082 # 购物车服务的端口(其他服务不共用,保留在本地) feign: okhttp: enabled: true # Feign 独有配置:启用 OKHttp 连接池(性能优化) hm: swagger: title: 购物车服务接口文档 # 购物车服务特有的 Swagger 标题 package: com.hmall.cart.controller # 购物车 Controller 所在包(每个服务不同) db: database: hm-cart # 购物车服务独有的数据库名
四、整体流程:配置加载与合并
-
微服务启动,先加载
bootstrap.yaml
(因spring-cloud-starter-bootstrap
启用了 Bootstrap 机制)。 -
Bootstrap 上下文根据
bootstrap.yaml
中的nacos.server-addr
,连接 Nacos 配置中心。 -
从 Nacos 拉取
shared-jdbc.yaml
、shared-log.yaml
、shared-swagger.yaml
这些共享配置。 -
接着加载
application.yaml
,将"Nacos 拉取的共享配置"与"本地application.yaml
的独有配置"合并,完成整个应用的配置初始化。 -
重启服务后,所有配置(公共 + 独有)生效,且 Nacos 中的配置支持热更新(修改 Nacos 配置,服务无需重启即可生效)。
五、价值:配置的"集中管理 + 灵活定制"
-
集中管理公共配置 :数据库、日志、Swagger 等公共逻辑不再分散在各服务的
application.yaml
,统一在 Nacos 维护,减少重复劳动。 -
服务独有配置保留本地 :每个服务的端口、业务专属配置(如 Swagger 标题、数据库名)留在
application.yaml
,保证灵活性。 -
热更新:Nacos 配置修改后,微服务能自动感知并刷新,无需重启服务,提升运维效率。
Nacos配置热更新
要理解Nacos配置热更新的逻辑,我们可以从业务痛点、实现步骤、效果验证三个维度拆解:
一、业务痛点:"硬编码配置"的弊端
原来购物车的"最大商品数量限制"是代码里写死的(比如固定为10):
if (count >= 10) { // 硬编码,改这个值要重启服务 throw new BizIllegalException("用户购物车商品不能超过10"); }
如果要调整这个限制(比如改成5),必须:
-
修改代码 → 重新打包 → 重启服务。
-
流程繁琐,且服务重启会导致短暂不可用。
因此,需要"配置动态化 + 热更新":把配置放到Nacos,修改后无需重启服务,直接生效。
二、实现步骤:让配置"动态且热更新"
1. 把配置放到Nacos
在Nacos控制台新建配置文件,规则:
-
DataID :
cart-service.yaml
(格式:[服务名].[后缀]
,让购物车服务能识别)。 -
配置内容:
hm: cart: maxAmount: 1 # 购物车最大数量(初始设为1,后续可改)
2. 微服务中读取Nacos配置
通过 @ConfigurationProperties
注解,让Spring自动把Nacos配置注入到Java类中:
-
步骤1:编写配置属性类
CartProperties
package com.hmall.cart.config; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; @Data // Lombok:自动生成get/set方法 @Component // 交给Spring容器管理,成为Bean @ConfigurationProperties(prefix = "hm.cart") // 绑定配置前缀"hm.cart" public class CartProperties { private Integer maxAmount; // 对应Nacos里的hm.cart.maxAmount }
-
步骤2:业务代码中使用配置
在购物车业务类(如CartServiceImpl
)中,注入CartProperties
,替换硬编码:
@Service public class CartServiceImpl implements CartService { private final CartProperties cartProperties; // 构造器注入CartProperties(也可用@Autowired) public CartServiceImpl(CartProperties cartProperties) { this.cartProperties = cartProperties; } private void checkCartsFull(Long userId) { int count = lambdaQuery().eq(Cart::getUserId, userId).count(); // 查询购物车数量 // 用配置里的maxAmount,替代原来硬编码的10 if (count >= cartProperties.getMaxAmount()) { throw new BizIllegalException( StrUtil.format("用户购物车商品不能超过{}", cartProperties.getMaxAmount()) ); } } }
三、热更新效果:改配置,不重启服务
-
初始测试 :Nacos中
maxAmount=1
,此时购物车添加第2件商品就会报错(符合限制)。 -
修改Nacos配置 :在Nacos控制台把
maxAmount
改成5
(无需重启服务)。 -
再次测试:购物车能添加到5件才报错------说明配置已经热更新生效。
核心逻辑:Nacos与Spring的"热更新协同"
-
Nacos作为配置中心,存储动态配置。
-
Spring通过
@ConfigurationProperties
绑定配置,并监听Nacos的配置变更。 -
当Nacos配置变化时,Spring自动刷新
CartProperties
的maxAmount
值,业务代码立即使用新配置。
这种方式实现了"配置与代码解耦 + 动态调整 + 热更新",让业务参数调整更灵活,无需重启服务。
网关动态路由
要理解网关动态路由的实现,我们可以从「为什么需要动态路由」「核心实现思路」「具体步骤与代码逻辑」「效果验证」四个维度拆解:
一、为什么需要动态路由?
网关的路由默认是启动时加载到内存,之后不会自动更新。如果要修改路由(比如新增服务、调整路径匹配),传统方式得重启网关------这在生产环境会导致服务短暂不可用,非常不灵活。
因此,需要动态路由:修改Nacos配置后,网关自动感知并更新路由,无需重启。
二、核心实现思路
要实现"动态路由",需完成两件事:
-
监听Nacos配置变更 :当Nacos里的路由配置(如
gateway-routes.json
)修改时,网关能及时感知。 -
更新网关路由表:把Nacos里的新路由配置,同步到网关内存中的路由表。
三、具体步骤与代码逻辑
步骤1:网关整合Nacos配置中心
让网关能连接Nacos,拉取配置。
-
引入依赖:
<!-- Nacos配置中心:让网关能连接Nacos --> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId> </dependency> <!-- 启用bootstrap文件(SpringBoot 2.4+需显式引入,保证优先加载) --> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-bootstrap</artifactId> </dependency>
-
配置
bootstrap.yaml
:指定Nacos地址、服务名等,让网关知道"去哪拉取配置"。spring: application: name: gateway # 服务名,Nacos按服务名关联配置 cloud: nacos: server-addr: 192.168.150.101 # Nacos服务器地址 config: file-extension: yaml # 配置文件后缀(如.yaml) shared-configs: - dataId: shared-log.yaml # 可选:共享日志配置(示例用)
-
简化
application.yaml
:原来的硬编码路由删掉,只保留网关自身的端口、JWT等独有配置。
步骤2:编写"动态路由加载器"DynamicRouteLoader
(必看)
这个类是核心,负责监听Nacos配置变更 + 更新网关路由表。
@Slf4j @Component @RequiredArgsConstructor public class DynamicRouteLoader { // 1. 网关路由的"写操作工具":用于增删路由 private final RouteDefinitionWriter writer; // 2. Nacos配置管理器:从中获取ConfigService(连接Nacos的核心工具) private final NacosConfigManager nacosConfigManager; // Nacos中路由配置的标识:DataID + Group private final String dataId = "gateway-routes.json"; private final String group = "DEFAULT_GROUP"; // 记录已加载的路由ID,方便后续删除旧路由 private final Set<String> routeIds = new HashSet<>(); // @PostConstruct:Spring创建该Bean后,自动执行此方法 @PostConstruct public void initRouteConfigListener() throws NacosException { // 步骤1:注册监听器 + 首次拉取配置 String configInfo = nacosConfigManager.getConfigService() .getConfigAndSignListener( dataId, // 要监听的配置文件ID(gateway-routes.json) group, // 配置分组(默认DEFAULT_GROUP) 5000, // 拉取配置的超时时间(毫秒) new Listener() { // 配置变更时的回调监听器 @Override public Executor getExecutor() { return null; // 使用默认线程池 } @Override public void receiveConfigInfo(String configInfo) { // 配置变更时,执行"更新路由"逻辑 updateConfigInfo(configInfo); } }); // 步骤2:首次启动时,立即更新一次路由(因为刚拉取到配置) updateConfigInfo(configInfo); } // 核心逻辑:更新网关路由表 private void updateConfigInfo(String configInfo) { log.debug("监听到路由配置变更,内容:{}", configInfo); // 1. JSON反序列化:把Nacos的JSON配置转成Gateway的RouteDefinition列表 List<RouteDefinition> routeDefinitions = JSONUtil.toList(configInfo, RouteDefinition.class); // 2. 先删除旧路由(避免路由冗余) for (String routeId : routeIds) { writer.delete(Mono.just(routeId)).subscribe(); // 异步删除旧路由 } routeIds.clear(); // 清空已记录的旧路由ID // 3. 若没有新路由,直接返回 if (CollUtils.isEmpty(routeDefinitions)) { return; } // 4. 新增/更新路由 routeDefinitions.forEach(routeDefinition -> { writer.save(Mono.just(routeDefinition)).subscribe(); //异步保存新路由到内存路由表 routeIds.add(routeDefinition.getId()); //记录新路由ID,方便后续删除 }); } }
要彻底理解 DynamicRouteLoader
这个动态路由核心类,我们从类职责、成员变量、关键方法、执行流程四个维度拆解:
一、类的核心职责
实现 "Nacos配置变更监听 + 网关路由表动态更新",让网关无需重启就能感知路由配置变化。
二、成员变量与依赖注入
@Slf4j @Component @RequiredArgsConstructor public class DynamicRouteLoader { // 1. 网关路由的"写操作工具":用于增、删、改路由 private final RouteDefinitionWriter writer; // 2. Nacos配置管理器:获取ConfigService(连接Nacos的核心工具) private final NacosConfigManager nacosConfigManager; // Nacos中路由配置的标识:DataID + Group private final String dataId = "gateway-routes.json"; private final String group = "DEFAULT_GROUP"; // 记录已加载的路由ID,用于后续删除旧路由 private final Set<String> routeIds = new HashSet<>(); }
-
RouteDefinitionWriter
:网关提供的路由操作API,支持"保存(新增/更新)"和"删除"路由。 -
NacosConfigManager
:Nacos的配置管理器,通过它能拿到ConfigService
(Nacos操作配置的核心工具)。 -
dataId
/group
:指定Nacos中路由配置文件的唯一标识(这里配置文件是gateway-routes.json
,分组为默认的DEFAULT_GROUP
)。 -
routeIds
:记录"已加载的路由ID",后续配置变更时,先删除这些旧路由,避免冗余。
三、初始化方法:initRouteConfigListener
(@PostConstruct
)
@PostConstruct
表示:Spring创建该Bean后,自动执行此方法,用于"初始化Nacos配置监听 + 首次加载路由"。
@PostConstruct public void initRouteConfigListener() throws NacosException { // 1. 注册监听器 + 首次拉取配置 String configInfo = nacosConfigManager.getConfigService() .getConfigAndSignListener( dataId, // 要监听的配置文件ID group, // 配置分组 5000, // 拉取配置的超时时间(毫秒) new Listener() { // 配置变更的回调监听器 @Override public Executor getExecutor() { return null; // 使用默认线程池 } @Override public void receiveConfigInfo(String configInfo) { // 配置变更时,执行"更新路由"逻辑 updateConfigInfo(configInfo); } }); // 2. 首次启动时,立即更新路由(因为刚拉取到配置) updateConfigInfo(configInfo); }
-
调用
nacosConfigManager.getConfigService()
获取ConfigService
(Nacos操作配置的核心API)。 -
调用
getConfigAndSignListener
:- 既会首次拉取Nacos中
gateway-routes.json
的配置,又会注册"配置变更监听器"------未来Nacos里的配置一旦变化,就会触发receiveConfigInfo
方法。
- 既会首次拉取Nacos中
-
首次拉取配置后,立即调用
updateConfigInfo
:保证服务启动时,网关能加载Nacos中的路由。
四、核心业务方法:updateConfigInfo
负责实际更新网关的路由表,逻辑分四步:
private void updateConfigInfo(String configInfo) { log.debug("监听到路由配置变更,内容:{}", configInfo); // 步骤1:JSON反序列化 → 转成Gateway的RouteDefinition列表 List<RouteDefinition> routeDefinitions = JSONUtil.toList(configInfo, RouteDefinition.class); // 步骤2:删除旧路由(避免冗余) for (String routeId : routeIds) { writer.delete(Mono.just(routeId)).subscribe(); // 异步删除旧路由 } routeIds.clear(); // 清空旧路由ID记录 // 步骤3:空配置判断 → 无新路由则直接返回 if (CollUtils.isEmpty(routeDefinitions)) { return; } // 步骤4:新增/更新路由 routeDefinitions.forEach(routeDefinition -> {
writer.save(Mono.just(routeDefinition)).subscribe(); // 异步保存新路由 routeIds.add(routeDefinition.getId()); // 记录新路由ID,供下次删除用 }); }
步骤1:JSON反序列化
用 JSONUtil.toList
把Nacos中获取的JSON格式配置,转成Gateway能识别的 RouteDefinition
列表。
RouteDefinition
是网关路由的"定义类",包含id
(路由ID)、predicates
(路由断言,如路径匹配)、filters
(路由过滤器)、uri
(转发目标,如lb://item-service
)等关键信息。
步骤2:删除旧路由
遍历 routeIds
(记录的"旧路由ID"),通过 writer.delete
异步删除旧路由。
-
目的:Nacos的配置是"全量替换",若不删除旧路由,新老路由会共存,导致逻辑混乱。
-
删完后
routeIds.clear()
,为记录"新路由ID"做准备。
步骤3:空配置判断
若 routeDefinitions
为空(Nacos里没配置任何路由),直接返回,避免无效操作。
步骤4:新增/更新路由
遍历新的 routeDefinitions
,对每个 RouteDefinition
:
-
调用
writer.save
异步保存新路由到网关的路由表。 -
把当前路由的
id
存入routeIds
,方便下次配置变更时删除旧路由。
五、整体流程总结
DynamicRouteLoader
通过"监听Nacos配置 + 同步更新网关路由",实现了动态路由:
-
服务启动时,主动拉取Nacos路由配置,初始化网关路由。
-
运行过程中,Nacos路由配置一旦变化,监听器立即感知,触发
updateConfigInfo
更新路由。 -
通过"先删旧路由,再添新路由"的逻辑,保证网关路由与Nacos配置实时一致,无需重启网关即可生效。
步骤3:Nacos中配置路由规则
在Nacos控制台,新建gateway-routes.json
配置,内容是多个RouteDefinition
的JSON数组(每个RouteDefinition
对应一条路由规则):
[ { "id": "item", // 路由唯一ID "predicates": [{ // 路由断言(路径匹配:/items/、/search/) "name": "Path", "args": {"_genkey_0":"/items/", "_genkey_1":"/search/"} }], "filters": [], // 路由过滤器(此处无需) "uri": "lb://item-service" // 转发目标:负载均衡到item-service }, { "id": "cart", "predicates": [{ "name": "Path", "args": {"_genkey_0":"/carts/"} }], "filters": [], "uri": "lb://cart-service" } // 其他服务的路由... ]
四、效果验证:动态路由生效
-
初始状态 :网关启动后,
application.yaml
没有路由配置 → 访问/search/list
会返回404。 -
Nacos配置路由后 :无需重启网关,
DynamicRouteLoader
监听到Nacos配置变更,自动更新路由表。 -
再次访问 :
/search/list
会匹配到item
路由,转发到item-service
→ 成功返回商品数据。
核心逻辑总结
-
Nacos作为"路由配置中心":集中管理所有网关路由规则,支持随时修改。
-
DynamicRouteLoader
作为"桥梁":通过NacosConfigManager
监听配置变更,再通过RouteDefinitionWriter
更新网关路由表。 -
最终效果:路由修改无需重启网关,实现"配置动态化 + 热更新",大幅提升运维灵活性。
苍穹外卖微服务拆分与核心功能实现(附代码与逻辑)
基于需求,我们将苍穹外卖拆分为业务服务和基础服务,通过 Spring Cloud Alibaba
(Nacos服务注册发现)、OpenFeign
(远程调用)、Spring Cloud Gateway
(网关)实现核心功能,以下是分步实现方案:
一、第一步:微服务拆分与技术选型
1. 微服务拆分边界(明确职责,低耦合)
|-----------------------|---------------------|------------------|
| 服务名称 | 核心职责 | 关键接口示例 |
| 用户服务(user-service) | 用户注册/登录、地址管理、用户信息查询 | 登录接口、获取用户地址接口 |
| 产品服务(product-service) | 菜品/套餐管理、分类查询、库存检查 | 菜品列表查询、套餐详情查询接口 |
| 交易服务(order-service) | 订单创建、购物车管理、订单状态更新 | 创建订单接口、查询我的订单接口 |
| 支付服务(pay-service) | 支付单创建、支付状态回调、退款处理 | 创建支付单接口、查询支付状态接口 |
| 网关服务(gateway-service) | 请求路由、登录校验、用户信息传递 | 路由转发、Token拦截校验 |
2. 核心技术依赖(统一引入父工程)
所有微服务依赖统一管理,父工程 pom.xml
关键依赖:
<dependencyManagement> <dependencies> <!-- 1. Spring Boot 基础 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>2.7.10</version> <type>pom</type> <scope>import</scope> </dependency> <!-- 2. 微服务生态(Nacos+OpenFeign+Gateway) --> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-alibaba-dependencies</artifactId> <version>2.2.9.RELEASE</version> <type>pom</type> <scope>import</scope> </dependency> <!-- 3. OpenFeign --> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-openfeign</artifactId> <version>3.1.6</version> </dependency> <!-- 4. Gateway --> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-gateway</artifactId> <version>3.1.6</version> </dependency> <!-- 5. JWT(登录校验) --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> </dependencies> </dependencyManagement>
二、第二步:基于OpenFeign实现服务间远程调用
以交易服务(order-service)调用用户服务(user-service)获取用户地址为例,实现远程调用。
1. 步骤1:用户服务(user-service)提供接口
先在用户服务中编写"获取用户默认地址"的接口(供其他服务调用):
// 1. Controller层(对外提供HTTP接口) @RestController @RequestMapping("/user/address") public class UserAddressController { @Autowired private UserAddressService addressService; // 根据用户ID获取默认地址 @GetMapping("/default") public Result<UserAddress> getDefaultAddress(@RequestParam("userId") Long userId) { UserAddress defaultAddr = addressService.getDefaultByUserId(userId); return Result.success(defaultAddr); } } // 2. Service层(业务逻辑) @Service public class UserAddressServiceImpl implements UserAddressService { @Autowired private UserAddressMapper addressMapper; @Override public UserAddress getDefaultByUserId(Long userId) { // 查询用户的默认地址(SQL:where user_id = ? and is_default = 1) return addressMapper.selectOne( new LambdaQueryWrapper<UserAddress>() .eq(UserAddress::getUserId, userId) .eq(UserAddress::getIsDefault, 1) ); } }
2. 步骤2:交易服务(order-service)定义Feign客户端
交易服务通过Feign"伪装"HTTP请求,像调用本地方法一样调用用户服务接口:
// 1. 定义Feign客户端(绑定用户服务的接口) @FeignClient(value = "user-service") // value=服务名(Nacos中注册的服务名) public interface UserAddressFeignClient { // 接口路径、参数、返回值与用户服务的Controller完全一致 @GetMapping("/user/address/default") Result<UserAddress> getDefaultAddress(@RequestParam("userId") Long userId); } // 2. 启动类添加@EnableFeignClients(开启Feign功能) @SpringBootApplication @EnableFeignClients // 扫描Feign客户端接口 @EnableDiscoveryClient // 注册到Nacos(服务发现) public class OrderServiceApplication { public static void main(String[] args) { SpringApplication.run(OrderServiceApplication.class, args); } } // 3. 业务层调用Feign接口(创建订单时获取用户默认地址) @Service public class OrderServiceImpl implements OrderService { @Autowired private UserAddressFeignClient addressFeignClient; // 注入Feign客户端 @Override public void createOrder(OrderCreateDTO orderDTO, Long userId) { // 远程调用用户服务:获取用户默认地址 Result<UserAddress> addrResult = addressFeignClient.getDefaultAddress(userId); if (!addrResult.isSuccess()) { throw new BizException("获取用户地址失败"); } UserAddress defaultAddr = addrResult.getData(); // 后续逻辑:创建订单(填充地址信息、扣减库存等) Order order = new Order(); order.setUserId(userId); order.setReceiverName(defaultAddr.getReceiverName()); order.setReceiverPhone(defaultAddr.getReceiverPhone()); // ... 其他订单信息赋值 } }
3. 核心逻辑
-
Feign通过
@FeignClient(value = "user-service")
找到Nacos中注册的"用户服务"实例,自动实现负载均衡。 -
交易服务调用
addressFeignClient.getDefaultAddress(userId)
时,Feign会自动发起HTTP请求到用户服务的/user/address/default
接口,无需手动编写HTTP客户端(如OkHttp)。
三、第三步:网关服务实现请求路由
基于 Spring Cloud Gateway
实现"请求路径→微服务"的路由转发,统一入口。
1. 网关服务(gateway-service)依赖
<dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-gateway</artifactId> </dependency> <!-- Nacos服务发现(网关需要找到微服务实例) --> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> </dependency> </dependencies>
2. 网关路由配置(application.yml)
通过配置将不同路径的请求转发到对应的微服务:
spring: application: name: gateway-service # 网关服务名 cloud: nacos: discovery: server-addr: 127.0.0.1:8848 # Nacos地址(找到其他微服务) gateway: routes: # 1. 路由到用户服务:/user/ 路径转发到user-service - id: user-service-route uri: lb://user-service # lb=负载均衡,指向Nacos中的user-service predicates: - Path=/user/ # 匹配路径:所有以/user/开头的请求 filters: - StripPrefix=0 # 转发时不删除路径前缀(如/user/login→/user/login) # 2. 路由到产品服务:/product/ 路径转发到product-service - id: product-service-route uri: lb://product-service predicates: - Path=/product/ # 3. 路由到交易服务:/order/ 路径转发到order-service - id: order-service-route uri: lb://order-service predicates: - Path=/order/ # 4. 路由到支付服务:/pay/ 路径转发到pay-service - id: pay-service-route uri: lb://pay-service predicates: - Path=/pay/ server: port: 80 # 网关端口(前端统一访问80端口)
3. 核心逻辑
-
前端请求
http://网关IP:80/user/login
→ 网关通过Path=/user/
匹配到"user-service-route" → 转发到Nacos中的"user-service"实例(负载均衡选择一个实例)。 -
所有请求通过网关统一入口,无需前端记住每个微服务的IP和端口。
四、第四步:网关实现登录校验与用户信息传递
基于 JWT + 网关GlobalFilter 实现登录校验,并将用户ID传递给下游微服务。
1. 步骤1:用户服务(user-service)生成JWT
用户登录成功后,生成JWT令牌返回给前端:
// 1. JWT工具类(生成Token、解析Token) @Component public class JwtTool { @Value("${hm.jwt.secret}") // 配置文件中的秘钥 private String secret; @Value("${hm.jwt.ttl}") // Token有效期(如2h) private Long ttl; // 生成Token(传入用户ID) public String createToken(Long userId) { return Jwts.builder() .setSubject(userId.toString()) // 存储用户ID .setExpiration(new Date(System.currentTimeMillis() + ttl)) // 过期时间 .signWith(SignatureAlgorithm.HS256, secret) // 加密算法 .compact(); } // 解析Token,获取用户ID public Long parseToken(String token) { try { Claims claims = Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); return Long.valueOf(claims.getSubject()); } catch (Exception e) { throw new UnauthorizedException("Token无效或已过期"); } } } // 2. 登录接口(生成Token返回) @RestController @RequestMapping("/user") public class UserLoginController { @Autowired private UserService userService; @Autowired private JwtTool jwtTool; @PostMapping("/login") public Result<LoginVO> login(@RequestBody LoginDTO loginDTO) { // 1. 校验账号密码(业务逻辑) User user = userService.login(loginDTO.getPhone(), loginDTO.getPassword()); // 2. 生成JWT Token String token = jwtTool.createToken(user.getId()); // 3. 返回结果(Token+用户基本信息) LoginVO loginVO = new LoginVO(); loginVO.setToken(token); loginVO.setUserInfo(user); return Result.success(loginVO); } }
2. 步骤2:网关(gateway-service)实现登录校验Filter
编写全局过滤器,拦截需要登录的请求,校验Token并传递用户ID:
// 1. 登录校验全局过滤器 @Component @RequiredArgsConstructor public class AuthGlobalFilter implements GlobalFilter, Ordered { private final JwtTool jwtTool; private final AntPathMatcher antPathMatcher = new AntPathMatcher(); // 无需登录的白名单(如登录接口、菜品查询接口) private final List<String> WHITE_LIST = Arrays.asList( "/user/login", "/product/dish/list", "/product/setmeal/list" ); @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { // 1. 获取请求路径 String path = exchange.getRequest().getPath().toString(); // 2. 判断是否在白名单:白名单路径直接放行 if (WHITE_LIST.stream().anyMatch(whitePath -> antPathMatcher.match(whitePath, path) )) { return chain.filter(exchange); } // 3. 非白名单:从请求头获取Token String token = exchange.getRequest().getHeaders().getFirst("Authorization"); if (StrUtil.isBlank(token)) { // 无Token:返回401未授权 exchange.getResponse().setRawStatusCode(401); return exchange.getResponse().setComplete(); } // 4. 校验Token,解析用户ID Long userId; try { userId = jwtTool.parseToken(token); } catch (UnauthorizedException e) { // Token无效:返回401 exchange.getResponse().setRawStatusCode(401); return exchange.getResponse().setComplete(); } // 5. 将用户ID存入请求头,传递给下游微服务 ServerHttpRequest newRequest = exchange.getRequest().mutate() .header("X-User-Id", userId.toString()) // 自定义请求头X-User-Id .build(); ServerWebExchange newExchange = exchange.mutate() .request(newRequest) .build(); // 6. 放行请求(带着用户ID到下游服务) return chain.filter(newExchange); } // 过滤器执行顺序(值越小越先执行,确保登录校验优先) @Override public int getOrder() { return 0; } }
3. 步骤3:下游微服务获取用户ID(以交易服务为例)
下游微服务通过拦截器从请求头获取 X-User-Id
,存入ThreadLocal供业务代码使用:
// 1. 微服务拦截器(获取用户ID存入ThreadLocal) @Component public class UserInfoInterceptor implements HandlerInterceptor { // ThreadLocal:同一请求线程内共享用户ID public static final ThreadLocal<Long> USER_ID_THREAD_LOCAL = new ThreadLocal<>(); @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 从请求头获取X-User-Id String userIdStr = request.getHeader("X-User-Id"); if (StrUtil.isNotBlank(userIdStr)) { USER_ID_THREAD_LOCAL.set(Long.valueOf(userIdStr)); } return true; } // 请求结束后清除ThreadLocal(避免内存泄漏) @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { USER_ID_THREAD_LOCAL.remove(); } } // 2. 配置拦截器(让拦截器生效) @Configuration public class MvcConfig implements WebMvcConfigurer { @Autowired private UserInfoInterceptor userInfoInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(userInfoInterceptor) .addPathPatterns("/"); // 所有请求都拦截(获取用户ID) } } // 3. 业务代码获取用户ID(创建订单时自动填充当前用户) @Service public class OrderServiceImpl implements OrderService { @Override public void createOrder(OrderCreateDTO orderDTO) { // 从ThreadLocal获取当前登录用户ID(无需手动传参) Long userId = UserInfoInterceptor.USER_ID_THREAD_LOCAL.get(); if (userId == null) { throw new UnauthorizedException("未登录"); } // 创建订单:自动关联当前用户 Order order = new Order(); order.setUserId(userId); order.setOrderTime(new Date()); // ... 其他订单信息 orderMapper.insert(order); } }
五、整体流程总结
-
用户登录 :前端调用
http://网关IP/user/login
→ 网关转发到user-service → 生成JWT返回给前端。 -
发起需登录请求 :前端携带Token(请求头
Authorization=Token
)调用http://网关IP/order/create
→ 网关校验Token解析用户ID → 存入请求头X-User-Id
转发到order-service。 -
下游服务处理 :order-service拦截器从
X-User-Id
获取用户ID → 存入ThreadLocal → 业务代码直接获取用户ID创建订单。
通过这套方案,实现了微服务拆分、远程调用、网关路由、登录校验与用户传递的完整需求。
微服务保护的核心方案
要理解微服务保护的核心方案,可以用"生活场景类比+核心逻辑拆解"的方式,把技术概念变得通俗易懂:
一、先明确目的:防止"雪崩",让服务更"扛造"
微服务里,一个服务故障可能拖垮所有依赖它的服务(比如商品服务故障,导致购物车、订单服务全瘫痪),这就是"雪崩"。
服务保护的核心,就是通过各种手段,阻止故障扩散,让服务在压力/故障下依然能"降级可用"。
二、逐个理解方案:用生活例子类比
1. 请求限流:游乐园的"项目承载量限制"
-
生活类比:游乐园热门项目(如过山车),每小时最多能安全接待1000人。如果突然来5000人,项目会过载故障,所有人都玩不了。
-
技术逻辑:给服务接口设定并发上限(比如每秒最多处理100个请求)。像"水电站大坝"一样,把突发的"洪水级请求"拦住,只放固定量的请求通过,让服务始终平稳运行。
-
效果:避免服务因"流量突然暴增"直接崩溃,保证大部分请求能被处理。
2. 线程隔离:轮船的"隔离舱设计"
-
生活类比:轮船的船舱被隔板分成多个"隔离舱"。如果某个舱触礁进水,其他舱因为隔离,不会进水,船就不会沉没。
-
技术逻辑:给不同业务接口分配独立的线程池(比如购物车服务中,"查询购物车"用20个线程,"修改购物车"用10个线程)。
-
效果:如果"查询购物车"因为调用商品服务变慢/故障,它最多只占用自己的20个线程,不会把服务的所有线程都占满,其他业务(如修改购物车)依然能正常运行。
3. 服务熔断:家里的"空气开关"
-
生活类比:家里电路短路时,空气开关会"跳闸(熔断)",防止电线烧坏;等故障排除,再合上开关,电路恢复。
-
技术逻辑:分两步走:
-
降级逻辑:预先写好"调用失败后怎么办"。比如查询商品服务失败,购物车服务直接返回"商品信息暂时无法获取,但购物车列表能看"(返回旧数据或友好提示)。
-
熔断开关:统计调用失败的比例,若太高(比如50%请求失败),就暂时断开对故障服务的调用,直接走降级逻辑;等故障服务恢复,再慢慢恢复调用。
-
-
效果:故障时"主动止损",不让调用方在故障服务上浪费资源,同时保证业务"降级可用"。
三、总结:三个方案的协作关系
-
限流:控制"入口流量",不让服务被"撑死";
-
隔离:限制"故障影响范围",不让一个接口拖垮整个服务;
-
熔断:故障时"及时止损",不让调用方在故障服务上耗死,同时保证业务体验降级但可用。
它们都是"服务降级"的手段(会牺牲一点体验),但能大幅提升微服务的整体健壮性,避免雪崩。
服务降级与熔断
要理解服务降级与熔断,可以拆成"降级逻辑怎么写"和"熔断怎么防拖垮"两部分,结合代码和场景讲更清晰:
一、服务降级:"依赖服务挂了,我返回默认结果保体验"
当购物车服务调用商品服务失败时,不直接报错,而是返回"备胎数据"(比如旧商品信息、空列表),保证购物车能正常展示。
1. 用 FallbackFactory
写降级逻辑
Feign(微服务远程调用工具)支持通过 FallbackFactory
定义"调用失败后执行的逻辑"。它的优势是能拿到调用异常的原因,更灵活。
看代码 ItemClientFallback
:
@Slf4j public class ItemClientFallback implements FallbackFactory<ItemClient> { @Override public ItemClient create(Throwable cause) { return new ItemClient() { // 场景1:查询商品列表失败(购物车展示场景) @Override public List<ItemDTO> queryItemByIds(Collection<Long> ids) { log.error("调用商品服务查商品失败,参数:{}", ids, cause); return CollUtils.emptyList(); // 返回空列表,保证购物车能展示 } // 场景2:扣减库存失败(下单场景,核心业务) @Override public void deductStock(List<OrderDetailDTO> items) { throw new BizIllegalException(cause); // 抛异常,让事务回滚 } }; } }
-
对非核心业务(如购物车查商品):失败后返回空列表,优先保证"购物车能展示";
-
对核心业务(如扣库存):失败后抛异常,触发事务回滚,保证数据一致。
2. 把降级逻辑注册到Spring和Feign
要让Feign知道"调用失败该找谁",需两步:
-
注册为Spring Bean :在
DefaultFeignConfig
中,用@Bean
把ItemClientFallback
交给Spring管理:@Bean public ItemClientFallback itemClientFallback() { return new ItemClientFallback(); }
-
FeignClient指定降级类 :在
ItemClient
接口上,通过@FeignClient
的fallbackFactory
配置降级逻辑:@FeignClient( value = "item-service", fallbackFactory = ItemClientFallback.class // 指定降级处理类 ) public interface ItemClient { ... }
二、服务熔断:"依赖服务总拖慢我,干脆暂时不调它了"
如果商品服务一直很慢(比如每次调用要500ms),购物车服务的线程会被大量占用,响应越来越慢,甚至雪崩。这时候需要"熔断"------暂时拦截对商品服务的调用,全部走降级逻辑。
1. 熔断的"状态机"逻辑
Sentinel的熔断通过三个状态切换,自动判断"该不该拦截请求":
-
Closed(关闭):正常放行请求,同时统计"慢调用比例""异常比例";
-
Open(打开):熔断状态,所有请求直接走降级,不真的调用商品服务;
-
Half-Open(半开):Open一段时间后,放行一个请求试探。如果成功,切回Closed;如果失败,继续Open。
2. 配置熔断规则(以"慢调用比例"为例)
讲义里的配置含义:
-
慢调用判定 :请求响应时间(RT)超过
200ms
,就算"慢调用"; -
统计条件 :最近
1000ms
内,至少有5次请求(避免少量请求误判); -
熔断触发:如果"慢调用比例 ≥ 0.5(50%)",就触发熔断;
-
熔断时长 :熔断后,持续
20s
内都拦截请求。
3. 测试效果:熔断后,主服务"活了"
配置后用Jmeter压测:
-
商品服务接口(
GET:/item-service/items
):触发熔断后,"通过QPS"直接变0(所有请求被拦截,走降级); -
购物车接口(
GET:/carts
):不受影响,"通过QPS"稳定,响应时间很短(因为不用等慢服务了),异常比例为0(降级逻辑返回了友好结果,没报错)。
总结:降级与熔断的协作
-
降级:解决"调用失败后返回什么",保证业务"降级可用";
-
熔断:解决"依赖服务慢到拖垮自己",通过拦截请求,防止雪崩,同时让主服务资源不被浪费。
两者配合,既保证用户体验,又保证微服务的健壮性。
TC、TM、RM
要理解 Seata 中 TC、TM、RM 三者的协作,我们可以结合电商下单业务的实际场景,把技术流程拆解成"角色分工 + 执行步骤":
一、场景设定:电商下单的分布式事务流程
用户下单时,需要同时完成 3 个跨服务操作(各自有独立数据库):
-
订单服务:创建订单记录(写订单库);
-
库存服务:扣减商品库存(写库存库);
-
账户服务:扣减用户余额(写账户库)。
这三个操作必须"同时成功或同时失败"------如果库存扣减失败,订单要回滚、余额要回滚;如果订单创建失败,库存和余额也要回滚。
二、角色分工:用"团建活动"类比
把分布式事务比作"组织一次团建",三个角色的分工一目了然:
|----|--------|---------------------------------------------|
| 角色 | 类比身份 | 核心职责 |
| TC | 团建组织者 | 汇总所有人的反馈,决定"活动取消/举办",并通知所有人最终结果。 |
| TM | 发起团建的人 | 确定团建范围(哪些人参与),找组织者申请"活动编号",最后根据反馈决定是否取消。 |
| RM | 每个参与的人 | 收到"活动编号"后,执行自己的任务(如"带零食""订场地"),并向组织者反馈能否参加。 |
三、实际流程拆解:下单时三角色如何协作?
以下单为例,逐步看 TC、TM、RM 的互动:
1. TM:发起全局事务,申请"事务身份证"(XID)
-
谁是 TM :下单业务的入口服务(比如"订单服务"里的
createOrder
方法)。 -
做了什么:
-
用
@GlobalTransactional
注解标记方法,告诉 Seata:"我要开启一个全局事务"。 -
向 TC 发起请求:"请生成一个全局事务 ID(XID),用于关联所有分支事务"。
-
TC 生成 XID 后,TM 会把 XID 透传给所有下游服务(库存、账户、订单服务)。
-
2. RM:执行本地事务,向 TC"报到"
每个参与的微服务(订单、库存、账户)都是 RM,负责:
-
接收 XID:通过请求头或参数,拿到 TM 传递的 XID(相当于"团建活动编号")。
-
执行本地事务:
-
订单服务:插入订单记录(本地事务 1);
-
库存服务:扣减商品库存(本地事务 2);
-
账户服务:扣减用户余额(本地事务 3)。
-
-
向 TC 注册分支事务:每执行完一个本地事务,RM 会向 TC 报告:"我是 XID=xxx 的分支事务,执行结果是成功/失败"。
3. TC:协调全局事务,决定"提交/回滚"
TC 是独立部署的 Seata Server,像"团建组织者"一样汇总信息:
-
收集分支事务结果:TC 会收到订单、库存、账户三个 RM 的执行结果。
-
判断全局事务结果:
-
如果所有 RM 都成功:TC 通知所有 RM "全局提交"------每个 RM 提交本地事务(数据永久保存)。
-
如果任一 RM 失败(比如库存不足,扣减失败):TC 通知所有 RM "全局回滚"------每个 RM 根据" undo_log 日志"回滚本地事务(恢复数据到操作前状态)。
-
四、回到"库存不足导致下单失败"的问题,Seata 如何解决?
之前的问题:库存扣减失败,但购物车已清空(数据不一致)。
用 Seata 后,流程变成:
-
订单服务(TM)开启全局事务,透传 XID 给购物车、库存、订单服务;
-
购物车服务(RM)执行"清空购物车"(本地事务),并向 TC 注册;
-
库存服务(RM)执行"扣减库存"失败,向 TC 报告失败;
-
TC 发现有 RM 失败,通知所有 RM 回滚;
-
购物车服务(RM)回滚"清空购物车"操作(购物车数据恢复),订单服务回滚"创建订单"操作,库存服务回滚"扣减库存"操作;
-
最终所有数据一致:购物车没被清空,订单没创建,库存没扣减。
总结:三角色的核心价值
-
TM:定义"哪些操作属于同一个全局事务",并发起全局提交/回滚。
-
RM:封装每个微服务的本地事务,向 TC 汇报状态,执行最终提交/回滚。
-
TC:全局事务的"大脑",汇总所有分支事务结果,决定全局提交或回滚,保证数据最终一致。
三者协作,让跨服务、跨数据库的操作像"单体事务"一样可靠,解决了分布式场景下的数据一致性问题。
微服务集成Seata的全过程
要理解微服务集成Seata的全过程,我们可以从"为什么做(解决分布式事务)→ 怎么做(分步骤落地)→ 效果如何(事务一致性保障)"的逻辑展开:
一、核心目的:让跨服务的业务满足"事务一致性"
下单业务涉及多个微服务(交易、购物车、商品)和多个数据库,传统@Transactional
(本地事务)无法保证"所有操作同时成功/失败"。Seata通过全局事务协调,让跨服务的操作像"单体事务"一样可靠。
二、分步讲解集成过程
1. 引入依赖:给微服务"装Seata的通信能力"
在每个参与分布式事务的微服务(如trade-service
)中,引入3类依赖:
-
nacos-config
:让微服务能从Nacos拉取共享配置(避免每个服务重复写Seata配置); -
bootstrap
:优先加载Nacos配置(bootstrap.yaml
比application.yaml
优先级高); -
seata
:让微服务能与Seata Server(TC)通信,实现"分支事务注册、状态上报、回滚"等能力。
<!-- Nacos配置中心:拉取共享配置 --> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId> </dependency> <!-- 优先加载bootstrap配置 --> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-bootstrap</artifactId> </dependency> <!-- Seata客户端:与Seata Server通信 --> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-seata</artifactId> </dependency>
2. 共享Seata配置到Nacos:让所有服务"读同一份规则"
在Nacos中创建shared-seata.yaml
,把Seata的核心配置共享给所有微服务(避免每个服务重复配置),关键配置解析:
-
seata.registry
:告诉微服务"去哪里找Seata Server(TC)"。这里用Nacos作为注册中心,配置Nacos地址、分组等,微服务就能通过Nacos发现TC的地址; -
tx-service-group
:给这组分布式事务起个名字(如hmall
),用于标识相关的微服务; -
service.vgroup-mapping
:指定"事务组"对应Seata Server的哪个集群(如hmall
对应default
集群)。
seata: registry: # 找Seata Server(TC)的配置 type: nacos # 注册中心用Nacos nacos: server-addr: 192.168.150.101:8848 # Nacos地址 group: DEFAULT_GROUP # Nacos分组 application: seata-server # Seata Server在Nacos的服务名 tx-service-group: hmall # 事务组名称 service: vgroup-mapping: hmall: "default" # 事务组→Seata集群的映
3. 改造微服务配置:让服务"读取Nacos里的Seata配置"
以trade-service
为例,添加bootstrap.yaml
,指定:
-
Nacos地址;
-
要拉取的共享配置(包括
shared-seata.yaml
)。
这样微服务启动时,会先从Nacos拉取Seata配置,知道"怎么连Seata Server"。
spring: cloud: nacos: server-addr: 192.168.150.101 # Nacos地址 config: shared-configs: # 要共享的Nacos配置 - dataId: shared-seata.yaml # Seata的共享配置
4. 建Seata回滚表:给"事务回滚"存依据
Seata的AT模式需要在业务数据库中创建undo_log
表,用于记录"数据修改前的状态"。当需要回滚时,Seata会根据这张表的日志,把数据恢复到修改前的样子。
因此,要把seata-at.sql
(包含undo_log
表结构)导入所有参与事务的数据库(如hm-trade
、hm-cart
、hm-item
)。
5. 标记全局事务入口:告诉Seata"从哪开始管事务"
把业务方法上的@Transactional
(Spring本地事务),替换为Seata的@GlobalTransactional
。
-
@Transactional
:只能管当前微服务内的数据库操作; -
@GlobalTransactional
:标记"全局事务的起点",Seata会生成全局事务ID(XID),并在调用其他微服务时,把XID传递下去,让所有相关操作都属于同一个"全局事务"。
// 原@Transactional只能管本地事务,替换为Seata的全局事务注解 @GlobalTransactional public Long createOrder(OrderFormDTO orderFormDTO) { // 下单逻辑:创建订单、清购物车、扣库存(跨服务操作) }
6. 测试:验证"跨服务事务一致性"
重启trade-service
、item-service
、cart-service
后,执行下单:
-
如果"扣库存"失败,Seata会协调所有分支事务(创建订单、清购物车、扣库存)一起回滚;
-
最终数据一致:订单没创建、购物车没清空、库存没扣减,解决了分布式事务问题。
三、总结:集成Seata的核心逻辑
通过"共享配置→依赖引入→表结构准备→注解标记",让微服务能与Seata Server通信,并由Seata统一协调"全局事务的提交/回滚",最终保证跨服务、跨数据库操作的事务一致性。
Seata的XA模式
要理解 Seata的XA模式,我们可以从"XA规范本质→Seata如何封装XA→优缺点→落地步骤"逐步拆解,结合图和代码更清晰:
一、XA模式的核心:两阶段提交(2PC)
分布式事务要保证"跨服务/跨库的操作要么全成功,要么全失败",XA规范通过 "两阶段提交" 实现这个目标,流程像"大家一起完成项目,确保同步成功/失败":
阶段1:准备阶段(Prepare)
-
事务协调者(类比"项目负责人")通知所有参与者(各微服务的本地事务):"准备执行任务,但先别提交";
-
每个参与者执行本地事务(如"扣库存""创建订单"),但不提交(保持数据库锁,防止数据被其他操作修改);
-
参与者执行完后,向协调者报告"就绪(成功)"或"失败"。
阶段2:提交/回滚阶段(Commit/Rollback)
-
协调者根据所有参与者的反馈,做全局决策:
-
全成功:通知所有参与者"提交事务",最终数据落库;
-
有失败:通知所有参与者"回滚事务",数据恢复到操作前状态。
-
二、Seata对XA的"微服务化封装"
Seata通过 TM、TC、RM三个角色,把传统XA规范适配到微服务场景,流程更清晰(结合中间架构图):
|---------|---------|------------------------------------------------------------|
| Seata角色 | 类比身份 | 核心动作 |
| TM | 全局事务发起人 | 标记事务入口(如@GlobalTransactional
),向TC申请"全局事务ID(XID)",触发全局事务。 |
| TC | 事务协调者 | 收集所有RM的执行状态,决定"全局提交"或"全局回滚",并通知RM执行最终操作。 |
| RM | 本地事务参与者 | 执行本地SQL(不提交),向TC注册分支事务、报告执行状态,最后根据TC指令提交/回滚。 |
具体交互流程(对应架构图):
-
TM开启全局事务 :在业务入口方法(如
createOrder
)上标注@GlobalTransactional
,TM向TC申请XID(全局事务唯一标识),并调用各微服务(分支事务)。 -
RM注册+执行本地事务(不提交):每个微服务(如订单、库存服务)的RM,会:
-
把本地事务"注册"到TC(关联XID);
-
执行业务SQL(如"创建订单""扣库存"),但不提交(持有数据库锁);
-
向TC报告"执行状态(成功/失败)"。
-
-
TC决策全局结果:TC收集所有RM的状态:
-
若全成功:通知所有RM"提交事务";
-
若有失败:通知所有RM"回滚事务"。
-
-
RM执行最终操作:RM收到TC指令后,提交或回滚本地事务,释放数据库锁。
三、XA模式的"优缺点"
优点:
-
强一致性:严格遵循ACID,最终数据一定一致(适合金融、支付等对一致性要求极高的场景);
-
兼容性好:主流数据库(MySQL、Oracle等)都原生支持XA规范,无需修改业务逻辑(无代码侵入);
-
实现简单:Seata封装后,只需改配置+加注解,开发成本低。
缺点:
-
性能差:第一阶段需一直持有数据库锁,直到第二阶段结束才释放。高并发场景下,数据库锁竞争激烈,吞吐量会大幅下降;
-
依赖数据库:必须用支持XA的关系型数据库,灵活性不足(比如无法对接非关系型数据库)。
四、Seata XA模式的"落地步骤"
步骤1:配置XA模式
在Nacos的共享配置(如shared-seata.yaml
)中,指定Seata使用XA模式:
seata: data-source-proxy-mode: XA # 明确事务模式为XA
这样Seata就会以"两阶段提交"的逻辑管理分布式事务。
步骤2:标记全局事务入口
在业务入口方法(如"创建订单"的createOrder
方法)上,添加Seata的@GlobalTransactional
注解:
@GlobalTransactional // 标记这是全局事务的入口 public Long createOrder(OrderFormDTO orderFormDTO) { // 业务逻辑:创建订单、扣库存、清购物车(跨服务操作) }
这个注解的作用是:告诉TM"从这里开始,要协调所有分支事务的提交/回滚"。
总结
XA模式是"传统且强一致"的分布式事务方案,Seata通过封装让它适配微服务场景。虽然性能有短板,但胜在一致性可靠、实现简单,适合对数据一致性要求极高(如金融交易)、并发压力不极端的业务。
Seata的AT模式
要理解 Seata的AT模式,可以从"解决XA的痛点→核心流程拆解→与XA的区别"三个维度,结合"用户余额扣减"的例子讲清楚:
一、AT模式的核心目标:解决XA"性能差"的痛点
XA模式为了强一致性,一阶段会长期持有数据库锁(执行后不提交,等二阶段决策),导致高并发下性能极低。
AT模式的设计思路是:既保证事务一致性,又让数据库锁快速释放,提升性能。它通过"数据快照(undo log)"实现灵活的提交/回滚,不需要长时间锁库。
二、AT模式的"两阶段流程"(以"用户余额扣减"为例:id=1,余额从100→90)
假设业务SQL是:update tb_account set money = money - 10 where id = 1
。

阶段一:执行本地事务 + 记录"数据快照"
-
注册分支事务:TM(事务管理器,如"下单服务")发起全局事务,RM(资源管理器,如"账户服务")向TC(事务协调者)注册"扣减余额"这个分支事务。
-
记录undo log(数据快照) :RM拦截业务SQL,先查询修改前的数据,生成"快照"并存入
undo_log
表。此时快照是:{"id":1, "money":100}
。 -
执行业务SQL并提交本地事务 :执行
update
语句,余额从100变为90,然后立即提交本地事务(和XA的"准备但不提交"不同!AT一阶段就提交,数据库锁会立即释放,性能更好)。 -
报告状态给TC:RM向TC汇报"本地事务执行成功"。
阶段二:全局提交或回滚
TC根据所有分支事务的状态,决定最终操作:
-
全局提交:如果所有分支都成功(比如"扣库存""清购物车"也成功),TC通知所有RM"删除undo log"。因为本地事务已经提交,数据是对的,只需清理快照日志。
-
全局回滚 :如果有分支失败(比如"扣库存"失败),TC通知账户服务的RM"回滚"。RM从
undo_log
中读取快照({"id":1, "money":100}
),把余额恢复为100,然后删除undo log。
三、AT与XA模式的核心区别(为什么AT性能更好?)
|---------|------------------|----------------------------|
| 对比维度 | XA模式 | AT模式 |
| 一阶段事务处理 | 执行后不提交,长期锁数据库资源 | 执行后直接提交,立即释放锁 |
| 回滚实现方式 | 依赖数据库自身的回滚机制 | 依赖undo log快照恢复数据 |
| 一致性保证 | 强一致性(严格遵循ACID) | 最终一致性(大部分场景及时一致,极端情况靠回滚兜底) |
| 性能表现 | 差(锁资源时间长,高并发易阻塞) | 好(锁资源时间短,适合高并发场景) |
四、总结:AT模式的适用场景
AT模式是对XA的"性能优化版"------既保留了"分布式事务一致性"的核心能力,又通过"一阶段提交+快照回滚",让数据库锁快速释放,性能大幅提升。因此,企业中90%的分布式事务场景(如电商下单、支付、库存扣减)都用AT模式,它无需侵入业务代码,又能平衡一致性和性能。
余额支付的分布式事务问题
要解决余额支付的分布式事务问题,并优化业务,可按"Seata集成→事务标记→业务改进"三步进行:

一、用Seata解决分布式事务
余额支付涉及3个跨服务操作(扣余额、更新支付单、更新订单状态),需保证"要么全成功,要么全失败"。利用Seata的AT模式实现:
步骤1:确保微服务集成Seata
pay-service
、user-service
、trade-service
需完成Seata集成(参考下单业务的集成步骤):
-
引入依赖:每个服务都引入Nacos配置、Seata客户端等依赖;
-
共享配置 :在Nacos的
shared-seata.yaml
中配置TC(事务协调者)的注册信息、事务组等; -
建undo_log表 :三个服务的数据库都导入Seata的
undo_log
表(用于AT模式的"数据快照回滚")。
步骤2:标记全局事务入口
支付业务的核心方法 tryPayOrderByBalance
,需要用Seata的 @GlobalTransactional
替代原有的 @Transactional
(本地事务注解),标记为全局事务的起点:
@Override @GlobalTransactional // 标记为全局事务,Seata会协调跨服务的事务 public void tryPayOrderByBalance(PayOrderDTO payOrderDTO) { // 原有业务逻辑不变:查支付单、判状态、扣余额、改支付单、改订单状态 PayOrder po = getById(payOrderDTO.getId()); if(!PayStatus.WAIT_BUYER_PAY.equalsValue(po.getStatus())){ throw new BizIllegalException("交易已支付或关闭!"); } userClient.deductMoney(payOrderDTO.getPw(), po.getAmount()); // 扣余额(跨user-service) boolean success = markPayOrderSuccess(payOrderDTO.getId(), LocalDateTime.now()); // 改支付单(本地) if (!success) { throw new BizIllegalException("交易已支付或关闭!"); } tradeClient.markOrderPaySuccess(po.getBizOrderNo()); // 改订单状态(跨trade-service) }
步骤3:测试验证
重启三个服务后测试:
-
正常情况:扣余额、更新支付单、更新订单状态,三个操作全部成功;
-
异常情况(如扣余额时密码错误、更新订单状态超时):Seata会协调所有分支事务回滚,最终数据一致(余额没扣、支付单仍为"待支付"、订单状态不变)。
二、业务改进点思考
虽然Seata解决了"一致性"问题,但从用户体验、系统健壮性角度,业务逻辑还有优化空间:
1. 异常处理:更友好的用户提示
当前用BizIllegalException
统一抛错,前端只能收到模糊提示。可细分异常类型:
-
扣余额时,若"密码错误",抛
PasswordInvalidException
; -
若"余额不足",抛
BalanceInsufficientException
; -
前端根据不同异常,展示"密码错误,请重试""余额不足,请充值"等精准提示。
2. 幂等性:防止重复支付
用户重复提交支付请求(如网络波动导致多次点击),可能重复扣余额。需增加幂等性保障:
-
基于"支付单ID"做幂等:在扣余额前,检查"该支付单是否已处理过";
-
或用分布式锁:同一支付单同一时间,只允许一个请求处理。
3. 异步解耦:提高系统容错
当前同步调用user-service
和trade-service
,若其中一个服务临时不可用,会导致支付失败。可引入消息队列(如RocketMQ):
-
支付成功后,发送"订单已支付"消息;
-
trade-service
订阅消息,异步更新订单状态; -
即使
trade-service
临时故障,消息可重试,不影响支付主流程,提高系统容错性。
4. 超时与降级:避免雪崩
分布式事务中,若某个服务响应极慢,会拖垮整个支付请求。可结合Sentinel:
-
给
userClient.deductMoney
、tradeClient.markOrderPaySuccess
设置超时时间和熔断规则; -
触发熔断时,直接走降级逻辑(如提示"支付繁忙,请稍后再试"),避免全局事务超时。
总结
通过Seata的@GlobalTransactional
,能快速解决"跨服务数据一致性"问题;但要让系统更健壮、用户体验更好,还需从异常细分、幂等性、异步解耦、超时降级等方面优化业务逻辑。
SpringAMQP
要清晰讲解这份 SpringAMQP 的内容,我们可以从「为什么用SpringAMQP」「Demo工程结构」「快速入门:队列收发消息」三个维度,结合代码逻辑+运行效果逐步拆解:
一、为什么需要 SpringAMQP?
RabbitMQ 本身支持跨语言(遵循 AMQP 协议),官方也提供了 Java 客户端,但官方 Java 客户端编码繁琐。
Spring 官方基于 RabbitMQ 封装了一套更易用的工具 ------ SpringAMQP,还结合 SpringBoot 实现了自动装配,使用非常便捷。
SpringAMQP 核心能力有三点:
-
自动声明「队列、交换机、绑定关系」;
-
支持注解式监听器,能异步接收消息;
-
封装
RabbitTemplate
工具,简化消息发送。
二、Demo 工程结构
课前提供的 Demo 工程是为了方便学习 SpringAMQP,结构很清晰:
-
mq-demo
:父工程,负责管理整个项目的依赖(避免重复写依赖); -
publisher
:消息发送者,负责向 MQ 发消息; -
consumer
:消息消费者,负责从 MQ 接收并处理消息。
三、快速入门:直接向队列收发消息(测试场景)
生产中消息通常会经「交换机」再到队列,但为了快速测试,我们先演示「发送者直接发消息到队列,消费者监听队列」的简单场景。
步骤 1:RabbitMQ 控制台创建队列
先在 RabbitMQ 控制台手动创建一个队列,命名为 simple.queue
(后续代码直接用这个队列收发消息)。
步骤 2:消息发送(publisher 服务)
要让 publisher
发消息,需先配置 MQ 连接,再用工具类发送。
-
配置 MQ 连接 :在
publisher
的application.yml
中,配置 RabbitMQ 的「主机 IP、端口、虚拟主机、用户名、密码」:spring: rabbitmq: host: 192.168.150.101 # 你的 RabbitMQ 所在机器 IP port: 5672 # AMQP 协议端口 virtual-host: /hmall # 虚拟主机(类似 MQ 的"命名空间") username: hmall # 用户名 password: 123 # 密码
-
编写发送代码 :创建测试类
SpringAmqpTest
,注入 SpringAMQP 提供的RabbitTemplate
(专门用于发消息的工具),调用convertAndSend
方法,指定「队列名」和「消息内容」:@SpringBootTest public class SpringAmqpTest { @Autowired private RabbitTemplate rabbitTemplate; @Test public void testSimpleQueue() { String queueName = "simple.queue"; // 要发送到的队列名 String message = "hello, spring amqp!"; // 要发送的消息 rabbitTemplate.convertAndSend(queueName, message); // 发送消息 } }
步骤 3:消息接收(consumer 服务)
consumer
要接收消息,需先配置 MQ 连接,再写「监听器」监听队列。
-
配置 MQ 连接 :和
publisher
类似,在consumer
的application.yml
中配置相同的 RabbitMQ 连接信息(主机、端口、虚拟主机、账号密码)。 -
编写监听代码 :创建监听器类
SpringRabbitListener
,用@RabbitListener
注解指定要监听的队列(simple.queue
)。当队列有新消息时,Spring 会自动调用被注解的方法,把消息内容传给方法参数:@Component // 让 Spring 扫描到该类,纳入容器管理 public class SpringRabbitListener { // @RabbitListener 指定监听的队列:simple.queue // 队列有消息时,这个方法会被调用,msg 参数就是消息内容 @RabbitListener(queues = "simple.queue") public void listenSimpleQueueMessage(String msg) { System.out.println("spring 消费者接收到消息:【" + msg + "】"); } }
步骤 4:测试效果
-
先启动
consumer
服务(让它持续运行,等待接收消息); -
再运行
publisher
里的testSimpleQueue
测试方法,发送消息; -
此时看
consumer
的控制台,会打印出:spring 消费者接收到消息:【hello, spring amqp!】
(和你提供的图片日志一致),说明消息从 publisher 发送,经 RabbitMQ,被 consumer 成功接收并处理。
这套流程能直观感受到 SpringAMQP 的便捷:发送方用 RabbitTemplate
轻松发消息,接收方用 @RabbitListener
注解轻松收消息,无需关心底层复杂的 AMQP 协议细节~

WorkQueues 模型
要清晰讲解 WorkQueues 模型,我们可以从「场景背景」→「代码实现」→「默认问题」→「优化方案」→「核心总结」逐步拆解,结合代码和运行逻辑让每一步更易懂:
一、WorkQueues 模型的场景背景
当消息处理很耗时(比如要做复杂计算、调用外部接口),且「生产消息的速度」远超过「单个消费者处理消息的速度」时,消息会在队列里越堆越多,无法及时处理。
这时候就需要 Work 模型:让多个消费者绑定同一个队列,共同"分拆"队列里的消息,从而提升整体的消息处理效率。
二、代码实现:模拟"消息堆积 + 多消费者"场景
我们通过代码模拟「快速生产消息」和「不同处理速度的消费者」:
1. 消息发送(模拟"生产快")
在 publisher
服务的 SpringAmqpTest
类中,写一个循环发送 50 条消息的方法:
@Test public void testWorkQueue() throws InterruptedException { String queueName = "work.queue"; // 要发送到的队列名(注意:需和消费者监听的队列一致) String message = "hello, message_"; for (int i = 0; i < 50; i++) { rabbitTemplate.convertAndSend(queueName, message + i); Thread.sleep(20); // 每20毫秒发1条 → 每秒发50条,模拟"生产速度快" } }
2. 消息接收(模拟"不同处理速度的消费者")
在 consumer
服务的 SpringRabbitListener
类中,写两个方法,都监听 work.queue
队列,但处理速度不同:
// 消费者1:处理快(sleep 20ms → 每秒约处理 50 条) @RabbitListener(queues = "work.queue") public void listenWorkQueue1(String msg) throws InterruptedException { System.out.println("消费者1接收到消息:【" + msg + "】" + LocalTime.now()); Thread.sleep(20); } // 消费者2:处理慢(sleep 200ms → 每秒约处理 5 条) @RabbitListener(queues = "work.queue") public void listenWorkQueue2(String msg) throws InterruptedException { System.err.println("消费者2........接收到消息:【" + msg + "】" + LocalTime.now()); Thread.sleep(200); }
三、默认问题:"平均分配"导致资源浪费
启动 consumer
服务后,运行 publisher
的 testWorkQueue
方法,会发现:
RabbitMQ 默认采用"轮询分配"策略------ 不管消费者处理快慢,平均把消息分给每个消费者。
测试结果中,50 条消息会被平均分给两个消费者(各 25 条):
-
消费者 1 处理快,很快就把 25 条处理完,之后处于"空闲"状态;
-
消费者 2 处理慢,要花
25 × 200ms = 5000ms(5秒)
才能处理完 25 条; -
最终整体耗时被"慢消费者"拖垮,且"快消费者"的能力没被充分利用。
四、优化方案:"能者多劳"(通过配置实现)
为了让「处理快的消费者多干活」,只需在 consumer
服务的 application.yml
中添加一行配置:
spring: rabbitmq: listener: simple: prefetch: 1 # 每次只预取 1 条消息,必须等这条处理完,才能取下一条
配置作用:
每个消费者每次只能从队列里"拿 1 条消息",处理完这条后,才能再拿新的。此时 RabbitMQ 会把新消息优先给当前空闲的消费者(谁先处理完,谁就优先拿新消息)。
优化后的测试结果
再次运行测试,会发现:
-
处理快的消费者 1 拿到了更多消息(因为它处理完 1 条后,能快速再拿新的);
-
处理慢的消费者 2 只拿到少量消息(因为它处理 1 条的时间里,新消息都被消费者 1 拿走了);
-
最终整体耗时大幅缩短(从原来的 5 秒左右降到 1 秒左右),充分利用了每个消费者的处理能力,避免了消息积压。
五、Work 模型核心总结
Work 模型的关键逻辑:
-
多消费者共享队列:多个消费者可以绑定同一个队列,同一条消息只会被一个消费者处理(避免重复处理);
-
"能者多劳"的分配策略 :通过
prefetch: 1
配置,控制消费者"每次预取的消息数量",让处理快的消费者承担更多任务,最大化资源利用率,解决消息积压问题。
这样的设计,既利用了多消费者的并行能力,又能根据消费者的"处理效率"智能分配任务,非常适合"消息处理耗时且生产快"的场景~
RabbitMQ 交换机(Exchange) 及 Fanout 交换机
我们来详细讲解 RabbitMQ 交换机(Exchange) 及 Fanout 交换机的使用,结合代码逻辑和工作流程,让知识点更清晰:
一、交换机的核心作用
在之前的案例中,生产者直接将消息发送到队列。但实际场景中,消息的路由和分发需要更灵活的控制,因此引入了交换机(Exchange)。
交换机的核心角色:
-
接收消息:生产者不再直接发消息到队列,而是将消息发送给交换机;
-
路由消息:根据自身类型和规则,将消息转发到与之绑定的队列(如何转发由交换机类型决定);
-
不存储消息:交换机本身不缓存消息。如果没有队列绑定到交换机,或没有符合路由规则的队列,消息会直接丢失。
二、交换机的四种类型
RabbitMQ 有四种交换机类型,我们重点学习前三种:
-
Fanout:广播模式,将消息转发给所有绑定的队列;
-
Direct:定向模式,基于「路由键(RoutingKey)」转发给匹配的队列;
-
Topic:通配符模式,基于带通配符的路由键转发;
-
Headers:头匹配模式,基于消息头信息转发(较少使用)。
三、Fanout 交换机(广播模式)
Fanout 交换机的核心逻辑是"广播":将消息无条件转发给所有绑定到它的队列,忽略路由键。
1. 工作流程(结合案例)
我们通过一个案例理解 Fanout 交换机的工作流程:
-
创建 1 个 Fanout 交换机(
hmall.fanout
); -
创建 2 个队列(
fanout.queue1
、fanout.queue2
),并绑定到该交换机; -
生产者发送消息到
hmall.fanout
交换机; -
交换机会将消息同时转发到
fanout.queue1
和fanout.queue2
; -
两个队列的消费者分别接收并处理消息。
流程示意图:
生产者 → 交换机(hmall.fanout) → 队列1(fanout.queue1) → 消费者1 → 队列2(fanout.queue2) → 消费者2
2. 代码实现步骤
步骤 1:声明交换机和队列(控制台操作)
-
创建队列 :在 RabbitMQ 控制台创建两个队列
fanout.queue1
和fanout.queue2
(无需特殊配置); -
创建交换机 :创建交换机
hmall.fanout
,类型选择「Fanout」; -
绑定队列 :将两个队列分别绑定到
hmall.fanout
交换机(Fanout 绑定无需设置路由键,直接绑定即可)。
步骤 2:消息发送(publisher 服务)
在 publisher
的 SpringAmqpTest
中添加发送消息到 Fanout 交换机的方法:
@Test public void testFanoutExchange() { // 交换机名称(必须与控制台创建的一致) String exchangeName = "hmall.fanout"; // 要发送的消息 String message = "hello, everyone!"; // 发送消息到交换机:参数分别为「交换机名、路由键、消息」 // Fanout 交换机忽略路由键,因此第二个参数传空字符串 rabbitTemplate.convertAndSend(exchangeName, "", message); }
关键说明:
Fanout 交换机的路由规则是"广播",不依赖路由键,因此 convertAndSend
方法的第二个参数(路由键)可以为空。
步骤 3:消息接收(consumer 服务)
在 consumer
的 SpringRabbitListener
中添加两个消费者,分别监听两个队列:
// 监听 fanout.queue1 队列 @RabbitListener(queues = "fanout.queue1") public void listenFanoutQueue1(String msg) { System.out.println("消费者1接收到Fanout消息:【" + msg + "】"); } // 监听 fanout.queue2 队列 @RabbitListener(queues = "fanout.queue2") public void listenFanoutQueue2(String msg) { System.out.println("消费者2接收到Fanout消息:【" + msg + "】"); }
关键说明:
两个消费者分别订阅不同的队列,但由于队列都绑定到同一个 Fanout 交换机,当交换机收到消息时,两个队列都会收到消息,因此两个消费者都会打印相同的内容。
3. 测试结果
运行 testFanoutExchange
方法后,消费者控制台会输出:
消费者1接收到Fanout消息:【hello, everyone!】 消费者2接收到Fanout消息:【hello, everyone!】
说明 Fanout 交换机成功将消息广播到了所有绑定的队列。
四、Fanout 交换机总结
-
核心特点:
-
广播消息到所有绑定的队列,不处理路由键;
-
只要队列绑定到交换机,就一定会收到消息。
-
-
适用场景:需要"一对多"通知的场景(例如:订单支付成功后,同时通知物流、积分、短信服务)。
-
交换机的通用作用:
-
接收生产者的消息;
-
按规则路由到绑定的队列;
-
不存储消息,路由失败则消息丢失。
-
通过 Fanout 交换机,我们实现了消息的"广播式分发",解决了"一个消息需要被多个消费者处理"的场景,相比直接操作队列,灵活性大大提升。
Direct 交换机
要清晰讲解 Direct 交换机,我们可以从「场景需求」→「核心逻辑」→「案例实现」→「与 Fanout 的对比」逐步展开,结合代码和运行效果理解:
一、Direct 交换机的场景需求
Fanout 交换机是"广播模式":只要队列绑定了交换机,所有队列都会收到消息。但实际业务中,我们常需要「特定消息只被特定队列消费」(比如"红色警报"消息给应急队列,"蓝色通知"给普通队列)。
此时需要 Direct 交换机,它的核心是 "基于 RoutingKey(路由键)的精确匹配"。
二、Direct 交换机的核心逻辑
Direct 交换机的工作规则:
-
队列与交换机绑定需指定 RoutingKey:队列和交换机绑定时,要明确"只有携带某个 RoutingKey 的消息,才能被转发到我这里";
-
生产者发送消息需指定 RoutingKey:生产者发消息时,必须携带 RoutingKey,告诉交换机"这条消息要发给谁";
-
交换机按 RoutingKey 精准转发:交换机只会把消息,转发给「绑定的 RoutingKey 与消息的 RoutingKey 完全一致」的队列。
三、案例实现(代码 + 操作)
案例需求:
-
声明 Direct 交换机
hmall.direct
; -
队列
direct.queue1
绑定交换机,RoutingKey 为blue
、red
;(同一个队列可以有多个RoutingKey) -
队列
direct.queue2
绑定交换机,RoutingKey 为yellow
、red
; -
生产者发送不同 RoutingKey 的消息,观察队列接收情况。
步骤 1:RabbitMQ 控制台操作(声明交换机、队列、绑定)
-
创建队列 :在控制台新建两个队列
direct.queue1
、direct.queue2
; -
创建 Direct 交换机 :新建交换机
hmall.direct
,类型选择「Direct」; -
绑定队列与交换机:
-
将
direct.queue1
与hmall.direct
绑定,RoutingKey 设为blue
、red
; -
将
direct.queue2
与hmall.direct
绑定,RoutingKey 设为yellow
、red
。
-
步骤 2:消息接收(consumer 服务代码)
在 consumer
的 SpringRabbitListener
类中,编写两个消费者方法,分别监听两个队列:
// 监听 direct.queue1 @RabbitListener(queues = "direct.queue1") public void listenDirectQueue1(String msg) { System.out.println("消费者1接收到direct.queue1的消息:【" + msg + "】"); } // 监听 direct.queue2 @RabbitListener(queues = "direct.queue2") public void listenDirectQueue2(String msg) { System.out.println("消费者2接收到direct.queue2的消息:【" + msg + "】"); }
步骤 3:消息发送(publisher 服务代码)
在 publisher
的 SpringAmqpTest
类中,编写测试方法,指定不同的 RoutingKey
:
测试 1:发送 RoutingKey = red
的消息
@Test public void testSendDirectExchange() { String exchangeName = "hmall.direct"; // 交换机名称 String message = "红色警报!日本乱排核废水,导致海洋生物变异,惊现哥斯拉!"; // 发送消息:参数为「交换机名、RoutingKey、消息」 rabbitTemplate.convertAndSend(exchangeName, "red", message); }
结果 :两个消费者都收到消息(因为 direct.queue1
和 direct.queue2
都绑定了 red
这个 RoutingKey),对应你提供的"两个消费者都收到消息"的日志。
测试 2:发送 RoutingKey = blue
的消息
@Test public void testSendDirectExchange() { String exchangeName = "hmall.direct"; String message = "最新报道,哥斯拉是居民自治巨型气球,虚惊一场!"; rabbitTemplate.convertAndSend(exchangeName, "blue", message); }
结果 :只有 direct.queue1
收到消息(因为只有它绑定了 blue
这个 RoutingKey),对应你提供的"只有消费者1收到消息"的日志。
四、Direct 与 Fanout 交换机的核心差异
|--------|-----------------------------|-------------------------|
| 交换机类型 | 路由规则 | 适用场景 |
| Fanout | 广播:转发给所有绑定的队列 | 消息需要被"所有订阅者"消费(如全局通知) |
| Direct | 精确匹配:只转发给RoutingKey 完全一致的队列 | 消息需要"定向"到特定队列(如按类型分发消息) |
简单总结:
-
Fanout 是「无差别广播」,不管 RoutingKey,只要绑定就收;
-
Direct 是「精准投递」,必须 RoutingKey 完全匹配才收;
-
如果多个队列绑定了相同的 RoutingKey,Direct 的效果会和 Fanout 类似(多个队列会收到同一条消息)。
通过 Direct 交换机,我们实现了"消息按 RoutingKey 定向分发",相比 Fanout 更灵活,能满足"不同消息给不同队列"的精细化需求。
Topic交换机
Topic交换机是对Direct交换机的灵活增强,核心是通过通配符实现「一类模式的RoutingKey匹配」,让路由规则更具扩展性。
1. 通配符规则(RoutingKey 多单词以 .
分隔,如 china.news
)
-
#
:匹配 一个或多个词(例:china.#
可匹配china.news
、china.weather
,甚至china.city.beijing
); -
*
:匹配 恰好一个词(例:item.*
只能匹配item.spu
,无法匹配item
或item.spu.insert
)。
2. 与 Direct 交换机的核心差异
-
Direct 交换机:要求消息的 RoutingKey 与队列绑定的 bindingKey 完全一致,才能路由消息(是"精确单值匹配");
-
Topic 交换机:通过通配符,可以匹配"一类模式"的 RoutingKey(不用为每个 RoutingKey 单独绑定,更灵活)。
3. 案例理解(结合讲义图示)
假设发送 RoutingKey=china.news
的消息:
-
队列
topic.queue1
绑定bindingKey=china.#
:匹配(因为 RoutingKey 以china.
开头); -
队列
topic.queue2
绑定bindingKey=#.news
:匹配(因为 RoutingKey 以.news
结尾);
因此两个队列都会收到消息,体现了通配符"模式匹配"的灵活性。
简单来说,Topic 用通配符让 RoutingKey 的匹配从"精确单值"变成"一类模式",更适合需要"模糊匹配、批量路由"的场景。
声明队列和交换机
要清晰讲解 SpringAMQP 中「声明队列和交换机」的内容,我们可以从背景原因→API 声明方式→注解声明方式→两种方式对比展开,结合代码和设计逻辑理解:
一、为什么要在代码中声明队列和交换机?
之前在 RabbitMQ 控制台手动创建队列、交换机存在缺陷:
-
开发、运维需手动同步"队列/交换机的定义",容易遗漏或写错;
-
项目启动时,若依赖的队列/交换机不存在,会导致消息收发失败。
因此,SpringAMQP 支持在代码中声明队列、交换机及绑定关系,项目启动时自动检查:若组件不存在,会自动创建,保证环境一致性(开发、测试、生产环境的 MQ 组件能自动对齐)。
二、基于 @Bean
的 API 声明方式
SpringAMQP 提供了一系列类和 Builder 工具,简化队列、交换机、绑定关系的声明:
1. 核心组件
-
Queue
类:用于定义队列(指定名称、是否持久化等); -
Exchange
接口及实现类:对应不同类型的交换机(如FanoutExchange
、DirectExchange
、TopicExchange
); -
ExchangeBuilder
:简化交换机创建(快速生成不同类型的交换机); -
BindingBuilder
:简化「队列 ↔ 交换机」的绑定配置。
2. Fanout 模式示例(代码解析)
以讲义中的 FanoutConfig
类为例,实现"Fanout 交换机 + 两个队列 + 绑定"的声明:
@Configuration // 标记为Spring配置类,启动时加载 public class FanoutConfig { // 1. 声明 Fanout 交换机 @Bean public FanoutExchange fanoutExchange(){ return new FanoutExchange("hmall.fanout"); // 交换机名称 } // 2. 声明第一个队列 fanout.queue1 @Bean public Queue fanoutQueue1(){ return new Queue("fanout.queue1"); // 队列名称 } // 3. 绑定队列1到 Fanout 交换机(Fanout 无 routingKey,直接绑定) @Bean public Binding bindingQueue1(Queue fanoutQueue1, FanoutExchange fanoutExchange){ return BindingBuilder.bind(fanoutQueue1).to(fanoutExchange); } // 4. 声明第二个队列 fanout.queue2 @Bean public Queue fanoutQueue2(){ return new Queue("fanout.queue2"); } // 5. 绑定队列2到 Fanout 交换机 @Bean public Binding bindingQueue2(Queue fanoutQueue2, FanoutExchange fanoutExchange){ return BindingBuilder.bind(fanoutQueue2).to(fanoutExchange); } }
-
@Configuration
:Spring 启动时会扫描该类,加载其中的@Bean
定义; -
@Bean
:方法返回的对象会被 Spring 容器管理,项目启动时,这些 Bean 对应的"队列、交换机、绑定关系"会自动创建到 RabbitMQ; -
FanoutExchange
:明确声明"Fanout 类型"的交换机; -
BindingBuilder.bind(队列).to(交换机)
:完成队列与交换机的绑定(Fanout 是"广播"模式,无需指定 routingKey)。
3. Direct 模式示例(代码解析)
Direct 模式需要指定 routingKey,因此绑定更复杂,以 DirectConfig
类为例:
@Configuration public class DirectConfig { // 1. 声明 Direct 交换机(用 ExchangeBuilder 简化创建) @Bean public DirectExchange directExchange(){ return ExchangeBuilder.directExchange("hmall.direct").build(); } // 2. 声明队列 direct.queue1 @Bean public Queue directQueue1(){ return new Queue("direct.queue1"); } // 3. 绑定队列1到交换机,routingKey = "red" @Bean public Binding bindingQueue1WithRed(Queue directQueue1, DirectExchange directExchange){ return BindingBuilder.bind(directQueue1).to(directExchange).with("red"); } // 4. 再绑定队列1到交换机,routingKey = "blue" @Bean public Binding bindingQueue1WithBlue(Queue directQueue1, DirectExchange directExchange){ return BindingBuilder.bind(directQueue1).to(directExchange).with("blue"); } // 5. 声明队列 direct.queue2 @Bean public Queue directQueue2(){ return new Queue("direct.queue2"); } // 6. 绑定队列2到交换机,routingKey = "red" @Bean public Binding bindingQueue2WithRed(Queue directQueue2, DirectExchange directExchange){ return BindingBuilder.bind(directQueue2).to(directExchange).with("red"); } // 7. 绑定队列2到交换机,routingKey = "yellow" @Bean public Binding bindingQueue2WithYellow(Queue directQueue2, DirectExchange directExchange){ return BindingBuilder.bind(directQueue2).to(directExchange).with("yellow"); } }
-
ExchangeBuilder.directExchange("名称").build()
:快速创建 Direct 类型交换机; -
BindingBuilder.bind(队列).to(交换机).with("routingKey")
:绑定队列到交换机,并指定 routingKey; -
由于
direct.queue1
要绑定red
和blue
两个 routingKey,因此需要两个@Bean
方法分别配置绑定。
三、基于注解的声明方式(更简洁)
用 @Bean
声明的方式较繁琐(尤其是 Direct、Topic 需多组绑定的场景),Spring 提供了基于 @RabbitListener
的注解式声明,将"监听、队列声明、交换机声明、绑定"整合为一体。
1. Direct 模式的注解示例
@RabbitListener(bindings = @QueueBinding( value = @Queue(name = "direct.queue1"), // 声明队列 exchange = @Exchange(name = "hmall.direct", type = ExchangeTypes.DIRECT), // 声明 Direct 交换机 key = {"red", "blue"} // 指定 routingKey(支持多个) )) public void listenDirectQueue1(String msg){ System.out.println("消费者1接收到direct.queue1的消息:【" + msg + "】"); } @RabbitListener(bindings = @QueueBinding( value = @Queue(name = "direct.queue2"), exchange = @Exchange(name = "hmall.direct", type = ExchangeTypes.DIRECT), key = {"red", "yellow"} )) public void listenDirectQueue2(String msg){ System.out.println("消费者2接收到direct.queue2的消息:【" + msg + "】"); }
-
@RabbitListener
:标记方法为"消息监听器",同时支持声明队列、交换机、绑定关系; -
@QueueBinding
:配置"队列 ↔ 交换机 ↔ routingKey"的绑定:-
value = @Queue(name = "...")
:声明队列; -
exchange = @Exchange(name = "...", type = "...")
:声明交换机,并指定类型(Direct
、Fanout
、Topic
等); -
key = {"...", "..."}
:指定 routingKey(支持数组,可配置多个 routingKey);
-
-
无需单独写
@Configuration
类和@Bean
方法,一个注解即可完成所有声明,代码更简洁内聚。
2. Topic 模式的注解示例
@RabbitListener(bindings = @QueueBinding( value = @Queue(name = "topic.queue1"), exchange = @Exchange(name = "hmall.topic", type = ExchangeTypes.TOPIC), // 声明 Topic 交换机 key = "china.#" // Topic 通配符 routingKey )) public void listenTopicQueue1(String msg){ System.out.println("消费者1接收到topic.queue1的消息:【" + msg + "】"); } @RabbitListener(bindings = @QueueBinding( value = @Queue(name = "topic.queue2"), exchange = @Exchange(name = "hmall.topic", type = ExchangeTypes.TOPIC), key = "#.news" )) public void listenTopicQueue2(String msg){ System.out.println("消费者2接收到topic.queue2的消息:【" + msg + "】"); }
-
type = ExchangeTypes.TOPIC
:指定交换机为Topic
类型; -
key = "china.#"
、key = "#.news"
:使用 Topic 的通配符 routingKey(#
匹配一个或多个词),实现"模式匹配"的路由。
四、两种声明方式的对比与选择
|------------|-------------------|-------------------|-------------------------|
| 声明方式 | 优点 | 缺点 | 适用场景 |
| @Bean
方式 | 集中管理"通用的队列/交换机" | 代码繁琐(多绑定需多个方法) | 多个消费者共享的基础组件(如公共交换机) |
| 注解方式 | 代码内聚(监听与声明整合)、更简洁 | 仅适用于"与消费逻辑强关联"的组件 | 单个消费者专属的队列/交换机(如业务专属队列) |
实际开发中,可根据场景灵活选择:若为"公共组件",用 @Bean
集中管理;若为"业务专属组件",用注解方式更简洁。核心目的是让项目启动时自动创建 MQ 组件,保证环境一致性。
SpringAMQP 的消息转换器
要清晰讲解 SpringAMQP 的消息转换器,我们可以从「默认序列化的问题」→「JSON 转换器的配置」→「效果验证」三个环节展开,结合代码和 RabbitMQ 控制台的表现理解:
一、默认 JDK 序列化的缺陷(测试默认行为)
SpringAMQP 发送消息时,消息体(Object
类型)会被序列化为字节传输;接收时,再反序列化为 Java 对象。
默认情况下,Spring 用 JDK 序列化,但它有三大问题:
-
数据体积大:JDK 序列化会包含大量额外元数据,导致消息体积膨胀;
-
安全隐患:存在反序列化漏洞风险;
-
可读性差:序列化后的字节是二进制,无法直接阅读。
测试步骤:
-
声明测试队列 :在
consumer
服务中,通过@Configuration
类声明队列object.queue
:@Configuration public class MessageConfig { @Bean public Queue objectQueue() { return new Queue("object.queue"); } }
-
发送 Map 类型消息 :在
publisher
的测试类中,用RabbitTemplate
发送一个Map
对象(模拟业务数据):@Test public void testSendMap() throws InterruptedException { Map<String,Object> msg = new HashMap<>(); msg.put("name", "柳岩"); msg.put("age", 21); rabbitTemplate.convertAndSend("object.queue", msg); }
-
查看默认序列化结果 :到 RabbitMQ 控制台查看
object.queue
的消息,会发现:-
content_type
为application/x-java-serialized-object
; -
payload
是 base64 编码的JDK 序列化字节,完全不可读(如你提供的"杂乱 base64 字符串"截图)。
-
二、配置 JSON 消息转换器(解决默认缺陷)
为了让消息体积更小、可读性更高、更安全,我们改用 JSON 序列化,步骤如下:
1. 引入 Jackson 依赖
JSON 序列化需要 Jackson 库支持。若项目已引入 spring-boot-starter-web
,则无需额外引入(web
依赖已包含 Jackson);否则需单独添加:
<dependency> <groupId>com.fasterxml.jackson.dataformat</groupId> <artifactId>jackson-dataformat-xml</artifactId> <version>2.9.10</version> </dependency>
2. 配置 MessageConverter
Bean
在 publisher
和 consumer
的启动类中,添加 Jackson2JsonMessageConverter
类型的 Bean(让 SpringAMQP 用 JSON 做序列化/反序列化):
@Bean public MessageConverter messageConverter(){ Jackson2JsonMessageConverter converter = new Jackson2JsonMessageConverter(); // 可选:自动生成消息ID(便于后续做"幂等性",避免重复消费) converter.setCreateMessageIds(true); return converter; }
三、验证 JSON 序列化的效果
配置完成后,需删除 RabbitMQ 中 object.queue
的旧消息(旧消息是 JDK 序列化的,需清空重新测试),然后重新执行「发送 Map 消息」的测试
1. RabbitMQ 控制台查看消息结构
再次发送消息后,到控制台查看:
-
content_type
变为application/json
; -
payload
是JSON 格式的字符串(如{"name":"柳岩","age":21}
),可读性极强; -
message_id
会自动生成(体现了setCreateMessageIds(true)
的配置效果)。
2. 消费者接收 Object 类型消息
在 consumer
中,用 @RabbitListener
监听 object.queue
,方法参数直接声明为 Map<String, Object>
(Spring 会自动将 JSON 反序列化为 Map):
@RabbitListener(queues = "object.queue") public void listenObjectQueue(Map<String, Object> msg) { System.out.println("消费者接收到object.queue消息:【" + msg + "】"); }
启动 consumer
后,会打印出:消费者接收到object.queue消息:【{name=柳岩, age=21}】
,说明 JSON 反序列化成功。
总结:消息转换器的价值
-
默认 JDK 序列化存在"体积大、不安全、可读性差"的问题;
-
通过配置
Jackson2JsonMessageConverter
,可将消息序列化为JSON 格式,解决上述问题; -
配置后,生产者发送"任意 Java 对象",消费者能直接接收为对应类型(如
Map
、自定义实体类),开发更便捷。
支付业务的 RabbitMQ 异步改造
要清晰讲解支付业务的 RabbitMQ 异步改造,我们可以从「架构设计」→「配置准备」→「消费者(交易服务)」→「生产者(支付服务)」→「改造价值」逐步展开:

一、整体架构设计
原有流程是支付服务同步调用交易服务更新订单状态,存在「耦合度高、性能差、级联失败风险」的问题。
改造后采用 RabbitMQ 异步通知,流程变为:
-
支付服务支付成功后,向
pay.direct
(Direct 类型交换机)发送消息(RoutingKey =pay.success
); -
trade.pay.success.queue
队列绑定到pay.direct
(BindingKey =pay.success
); -
交易服务监听
trade.pay.success.queue
,收到消息后更新订单状态。
架构图直观体现了"支付服务发消息 → 交换机路由 → 交易服务收消息处理"的异步解耦逻辑。
二、MQ 基础配置(生产者、消费者通用)
要让服务能收发 MQ 消息,需完成两步配置:
1. 添加 Spring AMQP 依赖
生产者(支付服务)和消费者(交易服务)都要引入依赖,以使用 RabbitMQ 相关功能:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-amqp</artifactId> </dependency>
2. 配置 RabbitMQ 连接信息
在两个服务的 application.yml
中,配置 RabbitMQ 的「主机、端口、虚拟主机、账号密码」:
spring: rabbitmq: host: 192.168.150.101 # RabbitMQ 所在服务器 IP port: 5672 # AMQP 协议端口 virtual-host: /hmall # 虚拟主机(MQ 的"命名空间") username: hmall # 用户名 password: 123 # 密码
三、交易服务:接收并处理消息(消费者逻辑)
交易服务需要监听队列,收到"支付成功"的消息后更新订单状态。
代码解析(PayStatusListener
类):
@Component // 纳入 Spring 容器管理 @RequiredArgsConstructor // Lombok 注解,生成构造器注入 IOrderService public class PayStatusListener { private final IOrderService orderService; // 订单业务逻辑接口 @RabbitListener(bindings = @QueueBinding( value = @Queue(name = "trade.pay.success.queue", durable = "true"), // 声明持久化队列 exchange = @Exchange(name = "pay.direct", type = ExchangeTypes.DIRECT), // 声明 Direct 交换机 key = "pay.success" // 绑定的 RoutingKey )) public void listenPaySuccess(Long orderId) { orderService.markOrderPaySuccess(orderId); // 调用业务方法更新订单状态 } }
-
@RabbitListener
+@QueueBinding
:一站式声明「队列、交换机、绑定关系」:-
@Queue(name = "trade.pay.success.queue", durable = "true")
:声明队列并设置为持久化(MQ 重启后队列不丢失); -
@Exchange(name = "pay.direct", type = ExchangeTypes.DIRECT)
:声明Direct
类型交换机(基于 RoutingKey 精确路由); -
key = "pay.success"
:指定队列与交换机的绑定 Key,只有 RoutingKey 为pay.success
的消息会路由到该队列。
-
-
方法
listenPaySuccess(Long orderId)
:队列收到消息时,自动调用该方法,消息内容(订单 ID)会被反序列化为 Long 类型,然后调用orderService
处理订单状态更新。
四、支付服务:发送支付成功消息(生产者逻辑)
支付服务在「支付成功」后,替换原 Feign 同步调用,改为发送 MQ 消息。
代码改造(PayOrderServiceImpl
的 tryPayOrderByBalance
方法):
private final RabbitTemplate rabbitTemplate; // 注入 Spring 提供的消息发送工具 @Override @Transactional public void tryPayOrderByBalance(PayOrderDTO payOrderDTO) { // 1. 查询支付单、2. 判断状态、3. 扣减余额、4. 修改支付单状态 → 原有同步逻辑,保持不变 // ...(省略原有步骤代码)... // 5. 原逻辑:同步调用交易服务 → 现在改为异步发消息 // tradeClient.markOrderPaySuccess(po.getBizOrderNo());
++try {++
++// 发送消息到 pay.direct 交换机,RoutingKey 为 pay.success,消息内容为订单 ID++
++rabbitTemplate.convertAndSend("pay.direct", "pay.success", po.getBizOrderNo());++
++} catch (Exception e) {++
++// 消息发送失败不影响"支付成功"的核心结果,只需记录日志(后续可做补偿)++
++log.error("支付成功的消息发送失败,支付单id:{}, 交易单id:{}", po.getId(), po.getBizOrderNo(), e);++
++}++ }
-
RabbitTemplate.convertAndSend
:发送消息到指定交换机,参数为「交换机名、RoutingKey、消息内容」; -
异常处理:用
try-catch
包裹,避免"消息发送失败"导致整个支付事务回滚(支付本身已成功,消息失败属于"次要异常",记录日志后不阻断主流程)。
五、改造的核心价值
-
解耦:支付服务无需依赖交易服务的接口,只需"发消息";交易服务只需"收消息",双方依赖关系弱化。
-
性能提升:支付服务发完消息立即返回,无需等待交易服务处理完成,响应速度更快。
-
容错性增强:若交易服务暂时故障,消息会暂存于 MQ,待服务恢复后自动消费,避免了"同步调用时服务故障导致的级联失败"。
通过 RabbitMQ 实现异步通知后,支付与交易服务的协作更灵活、稳定,也更符合分布式系统的"高内聚、低耦合"设计原则。
练习
5.1 抽取共享的MQ配置
在微服务架构中,多个服务(如支付、交易、购物车)都需连接 RabbitMQ,若每个服务重复配置 host
、port
等信息,会导致配置冗余且修改不便(如 MQ 地址变更需逐个修改服务配置)。
解决方案:利用 Nacos 配置中心的「共享配置」能力,集中管理 MQ 配置:
-
Nacos 中创建共享配置 :新建配置文件(如
mq-shared.yaml
),写入统一的 RabbitMQ 配置:spring: rabbitmq: host: 192.168.150.101 port: 5672 virtual-host: /hmall username: hmall password: 123
-
微服务引用共享配置 :每个需用 MQ 的服务,在
bootstrap.yml
中配置"共享配置"的dataId
和group
,实现配置统一拉取、动态更新:spring: cloud: nacos: config: shared-configs: - data-id: mq-shared.yaml group: DEFAULT_GROUP refresh: true # 支持配置动态刷新
5.2 改造下单功能为 MQ 异步通知
原有逻辑是「下单服务同步调用购物车服务的"清理购物车"接口」,存在耦合度高、性能差(下单需等待清理完成才能返回)的问题。改为 MQ 异步通知后,流程解耦:
核心步骤:
-
声明 Topic 交换机与队列:
-
交换机:
trade.topic
(Topic 类型,支持通配符路由,适合"一类事件"的广播); -
队列:
cart.clear.queue
(购物车服务专属队列); -
绑定关系:队列绑定到
trade.topic
,bindingKey
为order.create
(表示"下单创建"的消息会路由到该队列)。
-
-
下单服务发送消息:
下单成功后,不再调用 Feign 接口,而是通过 RabbitTemplate
发送消息到 trade.topic
,routingKey
为 order.create
,消息体包含「下单商品、登录用户信息」。
- 购物车服务消费消息:
通过 @RabbitListener
监听 cart.clear.queue
,收到消息后,根据"用户、商品信息"清理对应购物车。
优势:
-
解耦:下单服务无需等待购物车清理完成,立即返回,提升用户体验;
-
容错:若购物车服务临时故障,消息暂存 MQ,待服务恢复后自动消费,避免流程中断;
-
扩展性 :未来若有其他服务需感知"下单事件",只需绑定到
trade.topic
并设置对应bindingKey
即可。
5.3 登录信息传递优化(让 MQ 异步调用复用 UserContext
)
原有问题:
MQ 是异步通信,消息本身不携带"HTTP 请求上下文"(如登录用户信息);若手动在消息体中传递用户,消费者需每次解析,代码冗余且与同步调用的 UserContext
体验不一致(同步调用可直接用 UserContext
获取用户)。
优化思路:通过「消息拦截器」自动传递用户信息到 UserContext
利用 Spring AMQP 的消息拦截机制,在「发送前」将 UserContext
的用户放入消息头,「接收后」再从消息头取出并设置到 UserContext
,让业务代码无感知。
具体实现:
1. 发送端:消息头附加用户信息
实现 MessagePostProcessor
(消息后置处理器),在消息发送前,从 UserContext
取用户并放入消息头:
@Component public class UserContextMessagePostProcessor implements MessagePostProcessor { @Override public Message postProcessMessage(Message message) throws AmqpException { UserDTO currentUser = UserContext.getUser(); // 从UserContext取当前登录用户 if (currentUser != null) { // 用户信息序列化为JSON,放入消息头 message.getMessageProperties().setHeader("loginUser", JSON.toJSONString(currentUser)); } return message; } }
发送消息时,指定该处理器(自动附加用户信息到消息头):
rabbitTemplate.convertAndSend( "trade.topic", "order.create", messageBody, new UserContextMessagePostProcessor() );
2. 接收端:消息头还原用户到 UserContext
实现 ChannelAwareMessageListener
(消息监听器),在消息被消费前,从消息头取用户并设置到 UserContext
:
@Component public class UserContextMessageListener implements ChannelAwareMessageListener { @Override public void onMessage(Message message, Channel channel) throws Exception { // 从消息头取用户信息 String userJson = message.getMessageProperties().getHeader("loginUser"); if (userJson != null) { UserDTO user = JSON.parseObject(userJson, UserDTO.class); UserContext.setUser(user); // 设置到UserContext,后续业务代码可直接获取 } // 继续处理消息(交给原本的业务监听器) } }
或通过 Spring 配置,让 @RabbitListener
的消息先经过该拦截器(确保消费前 UserContext
已设置用户):
@Configuration public class RabbitConfig { @Bean public SimpleMessageListenerContainer messageListenerContainer(ConnectionFactory connectionFactory) { SimpleMessageListenerContainer container = new SimpleMessageListenerContainer(connectionFactory); container.setMessageListener(new UserContextMessageListener()); // 其他配置(如监听队列、并发数等) return container; } }
效果:
业务代码中,无论是同步 HTTP 请求(用户信息从请求头进入 UserContext
),还是异步 MQ 消息(用户信息从消息头还原到 UserContext
),都可通过 UserContext.getUser()
无差别获取登录用户,既保持了编程体验的一致性,又减少了手动传递用户信息的冗余代码。
综上,三个练习分别解决了「配置统一管理」「服务异步解耦」「上下文透明传递」的问题,让微服务间的 MQ 通信更高效、更易维护。
生产者如何确保消息可靠发送到MQ
要理解生产者如何确保消息可靠发送到MQ,需从「消息丢失场景」「解决方案分类」「具体实现(重试、确认机制)」三个层面逐步分析:
一、消息丢失的场景分析
消息从「生产者」到「消费者」的流程是:生产者发送 → MQ接收/存储 → 消费者接收/处理
。每一步都可能丢消息:
|--------|----------------------------------------------------------------------------------------------------------------|
| 阶段 | 可能的丢失原因 |
| 生产者发送时 | 1. 生产者与MQ网络连接失败2. 消息到MQ后,找不到目标Exchange(如Exchange名写错)3. 消息到Exchange后,找不到匹配的Queue(如RoutingKey错误)4. MQ内部处理消息的进程异常 |
| MQ存储时 | 消息到Queue后,MQ突然宕机(如非持久化Queue,或持久化但未完成磁盘写入就宕机) |
| 消费者处理时 | 消息到消费者后,未处理/处理中宕机、抛异常 |
因此,要保证MQ可靠性,需从三方面入手:生产者确保消息到MQ、MQ确保不丢消息、消费者确保处理消息。本章聚焦「生产者如何确保消息到MQ」。
二、生产者重试机制(解决「网络连接中断」问题)
当生产者与MQ网络波动,导致连接超时时,SpringAMQP提供「重试机制」:连接超时后,多次重试发送。
1. 配置方式(修改publisher
模块的application.yaml
)
spring: rabbitmq: connection-timeout: 1s # MQ连接超时时间(单位:秒,设短些方便测试) template: retry: enabled: true # 开启超时重试 initial-interval: 1000ms # 第一次重试的等待时间(1秒) multiplier: 1 # 重试等待时间的"倍数"(下次等待 = 上次 × multiplier,这里设1,所以每次等待都是1秒) max-attempts: 3 # 最大重试次数
2. 测试验证
用docker stop mq
停止RabbitMQ服务,然后发送消息。会观察到:每隔1秒重试1次,总共重试3次(因max-attempts: 3
)。
3. 注意事项
SpringAMQP的重试是"阻塞式重试"(重试期间,当前线程会被占用)。因此:
-
若业务对性能敏感,建议禁用重试;
-
若必须用,需合理配置「重试时间」和「次数」,或考虑用异步线程执行发送逻辑(避免主线程阻塞)。
三、生产者确认机制(解决「消息到MQ后,因配置/内部异常丢失」问题)
少数场景下,消息到MQ后仍会丢失(如Exchange不存在、RoutingKey路由失败、MQ内部进程异常)。RabbitMQ提供「生产者确认机制」,细分为两种:
|-------------------|-----------------------------------------------------------------|
| 机制类型 | 作用 |
| Publisher Confirm | MQ确认"是否收到并处理消息",返回 ack
(成功)或 nack
(失败) |
| Publisher Return | 消息到MQ后,路由到Queue失败时,返回"路由失败的详细原因"(同时会返回ack
,因为MQ"收到了消息",只是路由失败) |
1. 开启确认机制(修改publisher
的application.yaml
)
spring: rabbitmq: publisher-confirm-type: correlated # 开启Confirm机制,用「异步回调」模式返回回执(推荐,性能好) publisher-returns: true # 开启Return机制,路由失败时返回详细原因
publisher-confirm-type
的可选值:
-
none
:关闭Confirm机制; -
simple
:同步阻塞等待MQ回执(性能差); -
correlated
:MQ异步回调返回回执(推荐,性能好)。
2. 定义「ReturnCallback」(处理「路由失败」)(交换机收到了,但是没找到队列)
ReturnCallback
是"消息路由到Queue失败时,MQ回调给生产者的逻辑"。由于每个RabbitTemplate
只能有一个全局的ReturnCallback
,因此在配置类中统一设置。
示例代码(publisher
模块新建MqConfig.java
):
package com.itheima.publisher.config; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.amqp.core.ReturnedMessage; import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.context.annotation.Configuration; import javax.annotation.PostConstruct; @Slf4j // 日志注解,方便打印信息 @AllArgsConstructor // Lombok自动生成构造函数,注入RabbitTemplate @Configuration // 声明为Spring配置类 public class MqConfig { private final RabbitTemplate rabbitTemplate; // 注入RabbitTemplate(用于操作MQ) @PostConstruct // 容器初始化完成后,执行此方法 public void init() { // 设置ReturnsCallback:处理"路由到Queue失败"的回调 rabbitTemplate.setReturnsCallback(returned -> { log.error("触发return callback(路由失败)"); log.debug("Exchange:{}", returned.getExchange()); // 目标Exchange名称 log.debug("RoutingKey:{}", returned.getRoutingKey()); // 使用的RoutingKey log.debug("Message:{}", returned.getMessage()); // 消息内容 log.debug("ReplyCode:{}", returned.getReplyCode()); // 错误码 log.debug("ReplyText:{}", returned.getReplyText()); // 错误描述(如"NO_ROUTE"代表路由不存在) }); } }
3. 定义「ConfirmCallback」(处理「MQ是否成功处理消息」)(交换机压根没收到)
ConfirmCallback
是"MQ确认「消息是否处理成功(ack/nack)」"的回调。由于每条消息的处理逻辑可能不同,因此需要在「每次发送消息时」单独定义。(只要交换机收到,就会发送ack)
实现步骤:
-
发送消息时,给
RabbitTemplate.convertAndSend
传递CorrelationData
参数(包含"消息唯一标识"和"回执的Future对象"); -
给
CorrelationData
的Future添加回调,处理ack
(成功)或nack
(失败)。
示例测试代码(在测试类中):
@Test void testPublisherConfirm() { // 1. 创建CorrelationData:用于标识消息,接收MQ的回执 CorrelationData cd = new CorrelationData(); // 2. 给CorrelationData的Future添加回调(处理MQ的Confirm回执) cd.getFuture().addCallback( // 成功回调:收到ack或nack时触发 result -> { if (result.isAck()) { // isAck()为true → MQ处理成功(返回ack) log.debug("发送消息成功,收到 ack!"); } else { // isAck()为false → MQ处理失败(返回nack),result.getReason()是失败原因 log.error("发送消息失败,收到 nack,原因:{}", result.getReason()); } }, // 失败回调:Future自身发生异常时触发(一般很少发生) ex -> log.error("发送消息时Future异常", ex) ); // 3. 发送消息(故意用错误的RoutingKey测试「路由失败」) // 交换机:hmall.direct;路由键:q(假设没有Queue匹配这个键);消息内容:"hello" rabbitTemplate.convertAndSend("hmall.direct", "q", "hello", cd); }
4. 执行结果分析
-
路由失败场景:若RoutingKey错误,消息到Exchange后找不到Queue。此时日志会显示:
类似如下日志:
触发return callback(路由失败) 发送消息成功,收到 ack! Exchange:hmall.direct RoutingKey:q ReplyText:NO_ROUTE(代表"路由不存在")
-
触发
return callback
(打印路由失败的详细信息); -
同时收到
ack
(因为MQ"收到了消息",只是路由失败)。
-
-
Exchange错误场景 :若交换机名写错(用了不存在的Exchange),MQ会返回
nack
,此时会触发ConfirmCallback
的"nack分支",日志显示:发送消息失败,收到 nack,原因:...
。 -
正常路由场景 :修改为正确的RoutingKey,消息成功路由到Queue,此时只会收到
ack
,不会触发return callback
。
5. 注意事项
生产者确认机制会消耗MQ性能,因此:
-
多数场景不建议开启;
-
只有"对消息可靠性要求极高"的业务,才需要开启,且通常只需处理
nack
(因为return
多是"编程错误",如RoutingKey/Exchange配置错误,测试阶段即可发现)。
总结:生产者确保消息到MQ的手段
|------|-------------------------|---------------------------------------------------------|
| 机制 | 解决的问题 | 特点 |
| 重试机制 | 生产者与MQ"连接超时/中断" | 简单,但"阻塞式重试"可能影响性能;需合理配置重试参数或异步执行 |
| 确认机制 | 消息到MQ后,因"配置错误/MQ内部异常"丢失 | 细分为Confirm
(ack/nack)和Return
(路由失败);能精准感知消息状态,但消耗MQ性能 |
通过这两种机制,可极大提高「生产者到MQ」环节的消息可靠性。
MQ的可靠性保障
要理解 MQ的可靠性保障,需从「数据持久化(确保MQ重启不丢消息)」和「Lazy Queue(解决消息堆积的内存压力)」两个核心角度分析:
一、数据持久化:确保MQ重启后消息不丢失
默认情况下,RabbitMQ为了性能,会将数据临时存在内存中,重启后会丢失。因此需要配置「交换机持久化、队列持久化、消息持久化」,让数据落盘存储。
1. 交换机持久化
-
作用:MQ重启后,交换机仍能保留(否则重启后交换机消失,后续消息无法路由)。
-
配置方式(控制台):
在RabbitMQ控制台的「Exchanges」页面,添加交换机时设置 Durability
参数:
-
Durable
:持久化模式(MQ重启后,交换机仍存在)。 -
Transient
:临时模式(MQ重启后,交换机消失)。
2. 队列持久化
-
作用:MQ重启后,队列仍能保留;若队列里的消息也配置了持久化,消息也会恢复。
-
配置方式(控制台):
在RabbitMQ控制台的「Queues」页面,添加队列时设置 Durability
参数:
-
Durable
:持久化队列(MQ重启后,队列仍存在)。 -
Transient
:临时队列(MQ重启后,队列消失)。
3. 消息持久化
-
作用:MQ重启后,队列里的消息仍能保留(前提:交换机、队列都已持久化)。
-
配置方式(控制台):
在控制台发送消息时,设置 Delivery mode
参数:
-
1 - Non-persistent
:非持久化(消息存在内存,MQ重启则丢失)。 -
2 - Persistent
:持久化(消息落盘存储,MQ重启后可恢复)。
4. 持久化的联动效果与注意事项
- 当交换机、队列、消息都配置为持久化时,MQ重启后:
交换机、队列会被恢复;队列里的持久化消息也会从磁盘重新加载。
-
若开启「生产者确认机制」,MQ会在消息真正持久化到磁盘后,才给生产者发送
ACK
回执(进一步确保消息可靠)。 -
性能权衡:为减少磁盘IO次数,MQ会批量延迟持久化(约100毫秒一次),这会导致
ACK
有延迟。因此,生产者确认建议用异步方式(避免同步等待ACK
阻塞线程)。
二、Lazy Queue(惰性队列):解决消息堆积的内存压力
默认情况下,RabbitMQ把消息存在内存中,以降低收发延迟。但如果出现消息堆积(如消费者宕机、消费速度跟不上生产速度、消费者业务阻塞),内存会持续上涨,触发「内存预警」后,MQ会把内存里的消息批量刷到磁盘(这个过程叫 PageOut
)。
PageOut
会耗时且阻塞队列进程,导致生产者的所有请求被卡住。为解决此问题,RabbitMQ 3.6.0+ 引入 Lazy Queue
(惰性队列),3.12+ 版本后成为默认队列模式。
1. Lazy Queue的核心特点
-
消息直接存入磁盘,而非内存;
-
消费者需要消费时,才从磁盘懒加载到内存;
-
支持存储数百万条消息,大幅降低内存压力。
2. 配置Lazy Queue的方式
(1)控制台配置
在控制台添加队列时,在「Arguments」中添加参数:x-queue-mode = lazy
。
操作示例:
-
队列名称设为
lazy.queue
; -
Durability
选Durable
(保证队列持久化); -
Arguments
中配置x-queue-mode
为lazy
。
(2)代码配置(SpringAMQP)
方式1:用 QueueBuilder.lazy()
方法
@Bean public Queue lazyQueue() { return QueueBuilder .durable("lazy.queue") // 声明"持久化队列" .lazy() // 开启Lazy模式(底层会设置 x-queue-mode=lazy) .build(); }
从源码可知,QueueBuilder.lazy()
内部就是设置 x-queue-mode
参数为 lazy
。
方式2:基于 @RabbitListener
注解声明队列
@RabbitListener(queuesToDeclare = @Queue( name = "lazy.queue", durable = "true", // 队列持久化 arguments = @Argument(name = "x-queue-mode", value = "lazy") // 开启Lazy模式 )) public void listenLazyQueue(String msg) { log.info("接收到 lazy.queue 的消息:{}", msg); }
(3)更新已有队列为Lazy模式(通过Policy策略)
若队列已存在,可通过「Policy(策略)」批量设置Lazy模式。
-
命令行方式:
rabbitmqctl set_policy Lazy "^lazy-queue$" '{"queue-mode":"lazy"}' --apply-to queues
-
rabbitmqctl
:RabbitMQ命令行工具; -
set_policy
:创建/修改策略; -
Lazy
:策略名称(自定义); -
"^lazy-queue$"
:正则表达式,匹配队列名(如只匹配名为lazy-queue
的队列); -
'{"queue-mode":"lazy"}'
:策略内容,设置队列模式为lazy
; -
--apply-to queues
:策略作用于"队列"。
-
-
控制台方式:
进入RabbitMQ控制台「Admin」页面 → 点击「Policies」→ 添加新策略,配置"匹配规则"和 queue-mode: lazy
。
总结:MQ可靠性的两层保障
-
数据持久化:通过「交换机、队列、消息的持久化」+「生产者异步确认」,确保MQ重启后消息不丢失,且从生产者到MQ的过程可靠。
-
Lazy Queue :通过"消息直接落盘+懒加载",解决消息堆积时的内存压力,避免
PageOut
导致的队列阻塞,支持大规模消息存储。
两者结合,从"数据不丢"和"高容量存储"两个维度,保障了MQ自身的可靠性。
消费者侧的消息可靠性保障
要理解消费者侧的消息可靠性保障,需从「消息丢失风险」「确认机制」「失败重试与处理」「业务幂等性」「兜底方案」五个维度层层分析:
一、消费者处理消息的丢失风险
消息到达消费者后,仍可能因以下原因丢失:
-
消息投递时网络故障;
-
消费者接收到消息后突然宕机;
-
消费者处理消息时抛出异常。
因此,RabbitMQ需要通过「消费者确认机制」,感知消费者的处理状态,确保消息不丢失。
二、消费者确认机制:MQ如何感知消费者处理结果?
RabbitMQ允许消费者处理完消息后,向MQ发送回执(Acknowledgement),告知处理状态。回执分为三种:
-
ack
:消息处理成功,MQ从队列删除该消息; -
nack
:消息处理失败,MQ需要重新投递该消息; -
reject
:消息处理失败且拒绝,MQ直接删除该消息(多用于"消息格式错误"等开发类问题,业务场景少用)。
SpringAMQP对确认机制的封装
SpringAMQP简化了回执逻辑,可通过配置acknowledge-mode
,选择三种ACK处理模式:
|--------|------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|
| 模式 | 配置值 | 行为特点 |
| none | none
| 消息投递给消费者后立即ack,MQ直接删除消息(不管处理是否成功,极不安全) |
| manual | manual
| 需手动在业务代码中调用API发送ack/nack(灵活,但侵入业务) |
| auto | auto
(推荐) | Spring通过AOP自动判断回执:<br>- 业务正常 → 自动ack;<br>- 抛业务异常(如RuntimeException
)→ 自动nack(消息重入队);<br>- 抛消息处理/校验异常(如MessageConversionException
)→ 自动reject(MQ删除消息) |
spring: rabbitmq: listener: simple: acknowledge-mode: auto # 自动ack
auto模式的测试验证
- 场景1:抛「消息处理异常」(如
MessageConversionException
):
消费者代码抛该异常时,MQ控制台显示消息状态为unacked
(未确认);放行后,Spring返回reject
,MQ删除消息(因属于"消息本身无法处理"的错误)。
- 场景2:抛「业务异常」(如
RuntimeException
):
消费者代码抛该异常时,MQ控制台显示消息状态为unacked
;放行后,Spring返回nack
,消息重新入队(回到Ready
状态,等待再次投递)。
三、失败重试机制:避免消息无限重入队
auto模式下,若消费者持续抛业务异常,消息会无限重入队,导致MQ压力飙升(消息反复投递、消费者反复失败)。
Spring提供本地重试机制:消息在消费者本地重试,而非回丢给MQ重入队。
配置方式(consumer的application.yml
)
spring: rabbitmq: listener: simple: retry: enabled: true # 开启消费者失败重试 initial-interval: 1000ms # 首次重试等待1秒 multiplier: 1 # 重试等待时间的"倍数"(下次等待 = 上次 × multiplier,这里每次等1秒) max-attempts: 3 # 最大重试次数 stateless: true # true=无状态重试(不涉及事务);false=有状态(涉及事务时用)
效果
-
消息处理失败时,在消费者本地重试(不回丢给MQ);
-
重试
max-attempts
次后,若仍失败:Spring会抛出AmqpRejectAndDontRequeueException
,最终返回reject
,MQ删除消息。
四、失败处理策略:重试耗尽后如何处理?
本地重试耗尽后,默认会reject
丢弃消息(对可靠性要求高的场景不合适)。Spring通过MessageRecovery
接口,定义了三种"重试耗尽后"的处理策略:
|-----------------------------------|-------------------------------------|
| 策略实现类 | 行为 |
| RejectAndDontRequeueRecoverer(默认) | 重试耗尽后,reject
并丢弃消息 |
| ImmediateRequeueMessageRecoverer | 重试耗尽后,nack
并重新入队 |
| RepublishMessageRecoverer(推荐) | 重试耗尽后,将失败消息投递到指定的"异常队列",后续人工处理(最优雅) |
RepublishMessageRecoverer的实现步骤(以consumer服务为例)
-
声明"异常处理"的交换机、队列、绑定关系:
@Configuration // 仅当"重试开启"时,才加载该配置 @ConditionalOnProperty(name = "spring.rabbitmq.listener.simple.retry.enabled", havingValue = "true") public class ErrorMessageConfig { // 1. 声明"异常消息"的Direct交换机 @Bean public DirectExchange errorMessageExchange() { return new DirectExchange("error.direct"); } // 2. 声明"异常队列"(持久化,确保重启不丢) @Bean public Queue errorQueue() { return new Queue("error.queue", true); } // 3. 绑定交换机与队列(路由键为"error") @Bean public Binding errorBinding(Queue errorQueue, DirectExchange errorMessageExchange) { return BindingBuilder.bind(errorQueue).to(errorMessageExchange).with("error"); } // 4. 配置RepublishMessageRecoverer:重试耗尽后,将消息发往error.direct交换机,路由键error @Bean public MessageRecoverer republishMessageRecoverer(RabbitTemplate rabbitTemplate) { return new RepublishMessageRecoverer(rabbitTemplate, "error.direct", "error"); } }
-
效果:
本地重试耗尽后,失败消息会被转发到error.queue
,既不会丢失,又能由人工后续集中处理。
五、业务幂等性:避免重复消费导致业务异常
幂等性:同一业务执行一次或多次,对业务状态的影响一致(如"查询""删除"是幂等的;"新增""更新库存"非幂等)。
MQ消息可能因"网络重试""重复投递"导致重复消费,若业务不幂等,会引发数据错误(如重复退款、重复加库存)。
实现方案:
1. 唯一消息ID
-
思路:给每条消息生成唯一ID,消费成功后记录该ID;若收到重复ID,跳过处理。
-
SpringAMQP支持自动生成消息ID:配置
MessageConverter
时开启createMessageIds
。@Bean public MessageConverter messageConverter() { Jackson2JsonMessageConverter converter = new Jackson2JsonMessageConverter(); converter.setCreateMessageIds(true); // 自动生成消息唯一ID return converter; }
-
消费时,查询"已处理的消息ID列表",若存在则跳过。
2. 业务状态判断(更推荐,无额外存储)
-
思路:通过业务自身的状态字段,判断是否已处理。
-
示例(订单支付状态同步):
交易服务更新订单为"已支付"时,先判断订单当前状态是否为"未支付"。若不是,说明已处理,直接返回。
@Override public void markOrderPaySuccess(Long orderId) { // 等价SQL:UPDATE `order` SET status=2, pay_time=? WHERE id=? AND status=1 lambdaUpdate() .set(Order::getStatus, 2) .set(Order::getPayTime, LocalDateTime.now()) .eq(Order::getId, orderId) .eq(Order::getStatus, 1) // 仅"未支付(status=1)"时才更新 .update(); }
六、兜底方案:极端情况的最终一致性保障
即使做了上述所有保障,仍可能因"MQ集群故障"等极端情况导致消息丢失。此时需兜底策略:主动查询业务状态,确保最终一致。
场景示例(支付服务与交易服务的订单状态同步)
-
正常流程:支付成功 → MQ发消息 → 交易服务更新订单为"已支付"。
-
兜底流程:若MQ消息丢失,交易服务通过定时任务定期查询支付状态,发现订单已支付则更新状态。
逻辑:
-
定时任务每隔一段时间(如20秒),查询"未支付但可能已支付"的订单;
-
调用支付服务接口,查询订单真实支付状态;
-
若支付状态为"已支付",则更新订单状态,确保最终一致性。
总结:消费者可靠性的全链路保障
从"消息不丢(确认机制)"→"失败后优雅重试(本地重试+异常队列)"→"重复消费不犯错(幂等性)"→"极端情况兜底(定时查询)",层层递进保障消费者侧的消息可靠性。
以"支付服务与交易服务同步订单状态"为例:
-
MQ层面:生产者确认(确保消息到MQ)、消费者确认+本地重试(确保消息被处理);
-
业务层面:幂等性(避免重复处理)、定时任务兜底(确保极端情况最终一致)。
通过技术+业务的组合策略,实现消费者侧消息的高可靠性。
死信交换机+TTL实现延迟消息
要理解RabbitMQ中"死信交换机+TTL实现延迟消息"的方案,需从「死信与死信交换机的概念」「延迟消息的实现流程」「方案局限性」三个维度拆解,结合业务场景和示例逐步分析:
一、死信与死信交换机:基础概念
1. 什么是「死信(Dead Letter)」?
队列中的消息,满足以下任一条件时,会成为死信("无效/待特殊处理的消息"):
-
消费失败且不重新入队 :消费者用
basic.reject
或basic.nack
声明消费失败,且requeue
参数为false
(消息不回队列,直接变死信)。 -
消息过期(TTL到期):消息设置了「有效期(Time To Live,TTL)」,超时后仍无人消费。
-
队列满了,无法投递:队列达到容量上限,新消息无法入队,旧消息变成死信。
2. 什么是「死信交换机(Dead Letter Exchange,DLX)」?
如果一个队列(如 ttl.queue
)通过 dead-letter-exchange
属性,指定了一个死信交换机(如 hmall.direct
),那么该队列中的「死信」会被自动投递到这个死信交换机中。
死信交换机的作用场景:
-
兜底"消费失败被拒绝"的消息,便于人工排查;
-
兜底"队列满了被拒绝"的消息,避免丢失;
-
利用"TTL到期的死信",实现延迟消息(核心场景)。
二、用「死信交换机+TTL」实现延迟消息(结合业务场景+示例流程)
以电商"订单30分钟未支付则取消"为例,需要"消息延迟30分钟后被消费",流程如下(结合提供的图片):
步骤1:定义组件关系
-
普通交换机 :
ttl.fanout
(Fanout类型,负责"广播"消息)。 -
临时队列 :
ttl.queue
(无消费者监听,仅用于"暂存消息直到TTL到期";且配置了dead-letter-exchange=hmall.direct
,指定死信交换机)。 -
死信交换机 :
hmall.direct
(Direct类型,根据RoutingKey路由消息)。 -
目标队列 :
direct.queue1
(与hmall.direct
绑定,RoutingKey为blue
;有消费者监听,负责"延迟后消费消息")。
步骤2:生产者发送"带TTL的消息"
生产者向 ttl.fanout
发送消息,设置:
-
expiration=5000
(TTL为5000毫秒,即5秒后过期); -
RoutingKey为
blue
(虽然ttl.fanout
是Fanout交换机(广播无需RoutingKey),但死信投递时会"沿用该RoutingKey",因此必须设置)。
消息经 ttl.fanout
广播后,进入 ttl.queue
。
步骤3:消息在临时队列中"等待过期"
ttl.queue
没有消费者,所以消息会一直留在队列中,直到TTL到期。
步骤4:消息过期,成为死信
5秒后,消息的TTL到期,满足"死信"的"消息过期"条件,成为死信。
步骤5:死信投递到死信交换机
由于 ttl.queue
配置了 dead-letter-exchange=hmall.direct
,死信会被自动投递到 hmall.direct
交换机,且沿用原消息的RoutingKey(blue
)。
步骤6:死信路由到目标队列
hmall.direct
是Direct交换机,会根据RoutingKey blue
,将消息路由到绑定的 direct.queue1
。
步骤7:消费者消费"延迟消息"
direct.queue1
有消费者监听,此时(5秒后)消费者会收到这条"延迟了5秒"的消息------实现了"消息发送后,延迟指定时间才被消费"。
三、方案的关键注意点:TTL的"不精确性"
RabbitMQ对"消息过期"的处理是"追溯式"的:只有当消息恰好处于队列头部时,才会检查它是否过期。
如果队列中存在消息堆积(比如前面有大量未处理的消息),某条消息即使TTL到期了,但因"不在队首",也不会被及时处理为死信。因此,TTL设置的"延迟时间"不一定完全精确(高并发、消息堆积场景下,实际延迟可能比设置的TTL更长)。
四、总结:方案的逻辑与优缺点
-
核心逻辑:利用"消息TTL到期→成为死信→投递到死信交换机→路由到新队列被消费"的链路,间接实现"延迟消费"。
-
优点:无需额外插件,RabbitMQ原生支持。
-
缺点:延迟时间不精确(受队列消息堆积影响),不适合对"延迟精度"要求极高的场景(如毫秒级延迟)。
通过这一方案,电商场景中"订单30分钟未支付则取消"的需求,可通过"设置消息TTL为30分钟,利用死信交换机转发到消费队列"来实现(尽管延迟可能有误差,但业务上可接受)。
订单30分钟未支付则自动取消
要实现电商场景中 "订单30分钟未支付则自动取消" 的需求,利用RabbitMQ的「死信交换机(DLX)+ 消息TTL」的流程可拆解为以下步骤,结合MQ组件和业务逻辑逐层分析:
一、定义MQ组件(提前配置好交换机、队列及绑定关系)
需提前在RabbitMQ中声明4类组件,明确"消息暂存→过期→死信转发→最终消费"的链路:
|--------|-----------------------|-------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------|
| 组件类型 | 名称 | 作用 | 关键配置/说明 |
| 普通交换机 | order.ttl.exchange
| 负责"初始路由",将订单消息投递给「临时暂存队列」 | 类型:Direct
(也可根据需求选Fanout
等,这里用Direct
更明确路由) |
| 临时暂存队列 | order.ttl.queue
| 暂存订单消息,直到30分钟TTL到期(期间无消费者,仅做"时间容器") | - 无消费者监听;<br>- 配置死信交换机:x-dead-letter-exchange: dead.order.exchange
;<br>- 配置死信RoutingKey:x-dead-letter-routing-key: order.cancel
|
| 死信交换机 | dead.order.exchange
| 接收「临时队列」的死信,再路由给「最终消费队列」 | 类型:Direct
(与死信RoutingKey匹配) |
| 最终消费队列 | order.cancel.queue
| 接收"30分钟后过期的死信",触发「取消订单」逻辑 | 与dead.order.exchange
绑定,绑定RoutingKey为order.cancel
;<br>该队列有消费者,负责执行取消订单业务。 |
二、生产者发送"带30分钟TTL的订单消息"
当用户下单成功但未支付时,订单服务(生产者)向MQ发送消息,触发"延迟30分钟取消"的逻辑:
-
消息内容:包含
订单ID
等关键信息(用于后续取消订单时查询)。 -
TTL设置:
expiration: 1800000
(30分钟,单位为毫秒)。 -
路由配置:向
order.ttl.exchange
发送消息,指定RoutingKey为order.ttl
(与order.ttl.exchange
和order.ttl.queue
的绑定Key一致,确保消息能路由到临时队列)。
此时,消息经order.ttl.exchange
路由,进入order.ttl.queue
,开始"30分钟倒计时"。
三、消息在「临时暂存队列」中等待过期
order.ttl.queue
没有消费者,因此消息会一直留在队列中,直到30分钟的TTL到期。
👉 业务补充:如果用户在30分钟内完成支付,订单服务会更新订单状态为"已支付"。后续"取消订单"的逻辑会通过「业务幂等性」(查询订单状态,若已支付则跳过)避免重复执行。
四、消息TTL到期,成为「死信」
30分钟后,消息的TTL到期,满足"死信"的"消息过期"条件(队列中消息超时无人消费),因此该消息成为死信。
五、死信被投递到「死信交换机」
由于order.ttl.queue
提前配置了x-dead-letter-exchange
(死信交换机dead.order.exchange
)和x-dead-letter-routing-key
(order.cancel
),因此:
死信会被自动投递到死信交换机dead.order.exchange
,且携带的RoutingKey为order.cancel
。
六、死信路由到「最终消费队列」
dead.order.exchange
是Direct
类型交换机,会根据RoutingKey order.cancel
,将消息路由到与之绑定的order.cancel.queue
。
七、消费者消费消息,执行「取消订单」逻辑
order.cancel.queue
的消费者(如订单服务中的"取消订单消费者")接收到消息后,执行以下逻辑:
-
从消息中解析出
订单ID
; -
查询订单当前状态:
-
若订单已支付:直接跳过(通过"业务幂等性"保障,避免重复取消);
-
若订单未支付:执行"取消订单、释放商品库存"的业务逻辑。
-
流程总结(核心链路)
生产者发消息(带30分钟TTL)
→ 普通交换机路由到临时队列
→ 临时队列暂存30分钟
→ 消息过期成死信
→ 死信交换机接收并路由
→ 最终队列消费者执行取消逻辑
。
通过这一流程,实现了"订单30分钟未支付则自动取消"的延迟业务,既利用MQ的死信机制保证消息不丢失,又通过业务幂等性避免重复操作。
DelayExchange插件:简化延迟消息实现
之前用「死信交换机+TTL」实现延迟消息,存在配置繁琐、TTL不精确的问题。RabbitMQ社区提供的 rabbitmq_delayed_message_exchange
插件,可直接创建「延迟交换机」,让消息在交换机中等待指定时间后再投递,大幅简化延迟逻辑。
一、插件安装(Docker环境为例)
要让RabbitMQ支持延迟消息,需先安装并启用插件:
1. 下载插件
插件需与RabbitMQ版本匹配(如MQ为3.8版本,需下载rabbitmq_delayed_message_exchange
的3.8.17版本)。可从 GitHub仓库 下载,或使用课前资料提供的插件。
2. 确定插件存储路径
RabbitMQ的插件目录通过Docker卷挂载,执行命令查看卷的宿主机路径:
docker volume inspect mq-plugins
输出示例(需关注Mountpoint
字段):
[ { "Mountpoint": "/var/lib/docker/volumes/mq-plugins/_data", "Name": "mq-plugins", // 其他字段省略... } ]
将下载的插件文件上传到该Mountpoint
对应的宿主机目录。
3. 启用插件
进入MQ的Docker容器,执行启用命令:
docker exec -it mq rabbitmq-plugins enable rabbitmq_delayed_message_exchange
执行成功后,控制台会提示插件已启用(如 started 1 plugins
),表示 rabbitmq_delayed_message_exchange
生效。
二、声明延迟交换机
延迟交换机需要显式标记为「延迟类型」,支持两种声明方式:
方式1:注解方式(@RabbitListener
)
在消费者的监听方法上,通过 @QueueBinding
声明队列、延迟交换机、路由键的绑定关系:
@RabbitListener(bindings = @QueueBinding( value = @Queue(name = "delay.queue", durable = "true"), // 声明持久化队列 exchange = @Exchange(name = "delay.direct", delayed = "true"), // 关键:标记为延迟交换机 key = "delay" // 路由键 )) public void listenDelayMessage(String msg) { log.info("接收到delay.queue的延迟消息:{}", msg); }
方式2:@Bean
方式(配置类)
在配置类中,通过 ExchangeBuilder
构建延迟交换机,并声明队列、绑定关系:
@Configuration public class DelayExchangeConfig { // 声明延迟交换机 @Bean public DirectExchange delayExchange() { return ExchangeBuilder .directExchange("delay.direct") // 交换机名称+类型(Direct) .delayed() // 核心:标记为延迟交换机 .durable(true) // 持久化(重启MQ不丢失) .build(); } // 声明队列 @Bean public Queue delayedQueue() { return new Queue("delay.queue"); } // 绑定队列到延迟交换机 @Bean public Binding delayQueueBinding() { return BindingBuilder.bind(delayedQueue()).to(delayExchange()).with("delay"); } }
三、发送延迟消息
发送消息时,通过 MessagePostProcessor
设置「延迟时间」(单位:毫秒),消息会在交换机中等待指定时间后,再路由到队列:
@Test void testPublisherDelayMessage() { String message = "hello, delayed message"; rabbitTemplate.convertAndSend( "delay.direct", // 目标延迟交换机 "delay", // 路由键 message, // 消息后置处理器:设置延迟属性 new MessagePostProcessor() { @Override public Message postProcessMessage(Message message) throws AmqpException { // 设置延迟时间:5000毫秒(5秒后投递) message.getMessageProperties().setDelay(5000); return message; } } ); }
执行后,消息会在delay.direct
交换机中等待5秒,再路由到delay.queue
,消费者才能收到消息。
四、注意事项
延迟消息插件的实现原理是:内部维护本地数据库表 + Erlang Timers 计时。因此:
-
若大量消息设置超长延迟(如几小时、几天),会导致延迟消息堆积,增加CPU开销;
-
延迟时间存在微小误差(受系统负载、Timers精度影响)。
因此,该插件更适合中短时间的延迟场景(如分钟级、小时级内的延迟);若需"超长延迟",建议结合定时任务、分布式调度框架(如XXL-Job)等方案。
总结:DelayExchange插件通过"延迟交换机"直接控制消息投递时间,相比「死信+TTL」更简单、延迟精度更高(仍有小误差),是中短延迟场景的优选方案。
电商"订单超时未支付自动取消"
要实现电商"订单超时未支付自动取消"的需求,我们结合 RabbitMQ延迟消息插件(DelayExchange) 和Feign跨服务调用,分步骤完成业务与技术的整合,流程清晰且解耦。
一、业务背景与技术选型
电商场景中,用户下单后若30分钟内未支付,需自动"取消订单、恢复库存"。为实现"延迟触发"逻辑,选择 RabbitMQ延迟消息插件(比"死信交换机+TTL"更简洁、延迟精度更可控);跨服务调用(查询支付状态)则通过 Feign 实现。
二、代码实现步骤(分模块拆解)
1. 定义MQ常量(trade-service
模块)
为统一管理MQ组件(交换机、队列、路由键),避免硬编码,在trade-service
中创建常量类:
package com.hmall.trade.constants; public interface MQConstants { // 延迟交换机名称 String DELAY_EXCHANGE_NAME = "trade.delay.direct"; // 延迟队列名称(接收"订单超时检测"消息) String DELAY_ORDER_QUEUE_NAME = "trade.delay.order.queue"; // 路由键(绑定交换机与队列) String DELAY_ORDER_KEY = "delay.order.query"; }
2. 配置MQ(trade-service
模块)
-
引入依赖 :在
trade-service
的pom.xml
中添加Spring AMQP依赖,支持RabbitMQ交互:<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-amqp</artifactId> </dependency>
-
配置连接 :在
application.yaml
中配置RabbitMQ地址、账号等:spring: rabbitmq: host: 192.168.150.101 # RabbitMQ服务器地址 port: 5672 # 端口 virtual-host: /hmall # 虚拟主机 username: hmall # 用户名 password: 123 # 密码
3. 下单时发送延迟消息(trade-service
模块)
用户下单成功后,发送"延迟消息",触发后续"检测支付状态"的逻辑。修改OrderServiceImpl
的createOrder
方法:
@Service public class OrderServiceImpl implements IOrderService { @Autowired private RabbitTemplate rabbitTemplate; @Override public Order createOrder(OrderFormDTO orderForm) { // 1. 其他业务:创建订单、扣减库存等... // 2. 发送延迟消息(测试用延迟10秒,实际设为30*60*1000毫秒) rabbitTemplate.convertAndSend( MQConstants.DELAY_EXCHANGE_NAME, // 目标延迟交换机 MQConstants.DELAY_ORDER_KEY, // 路由键 order.getId(), // 消息内容:订单ID message -> { // 消息后置处理器:设置延迟时间 message.getMessageProperties().setDelay(10000); // 延迟10秒 return message; } ); return order; } }
通过RabbitTemplate
的convertAndSend
方法,结合MessagePostProcessor
设置delay
属性,让消息在延迟交换机中等待10秒后再投递。
4. 跨服务交互:定义DTO与FeignClient(hm-api
模块)
"检测支付状态"需调用pay-service
的接口,因此通过Feign实现跨服务通信,需先定义:
-
PayOrderDTO
:支付单的数据传输对象(封装支付状态、订单关联等信息):@Data @ApiModel(description = "支付单数据传输实体") public class PayOrderDTO { @ApiModelProperty("ID") private Long id; @ApiModelProperty("业务订单号(关联订单ID)") private Long bizOrderNo; @ApiModelProperty("支付状态:3=支付成功") private Integer status; // 其他字段:支付金额、渠道、时间等... }
-
PayClient
:Feign客户端接口,定义调用pay-service
的方法:@FeignClient(value = "pay-service", fallbackFactory = PayClientFallback.class) public interface PayClient { / * 根据订单ID查询支付单 * @param id 订单ID(bizOrderNo) * @return 支付单信息 */ @GetMapping("/pay-orders/biz/{id}") PayOrderDTO queryPayOrderByBizOrderNo(@PathVariable("id") Long id); }
-
PayClientFallback
:Feign降级逻辑(服务异常时,返回默认值,防止雪崩):@Slf4j public class PayClientFallback implements FallbackFactory<PayClient> { @Override public PayClient create(Throwable cause) { log.error("PayClient调用失败", cause); return new PayClient() { @Override public PayOrderDTO queryPayOrderByBizOrderNo(Long id) { return null; // 降级:返回null,代表查询失败 } }; } }
5. pay-service
提供"查询支付单"接口
在pay-service
的PayController
中,实现PayClient
定义的接口,供trade-service
调用:
@RestController @RequestMapping("/pay-orders") public class PayController { @Autowired private PayOrderService payOrderService; @GetMapping("/biz/{id}") public PayOrderDTO queryPayOrderByBizOrderNo(@PathVariable("id") Long id) { // 根据"订单ID(bizOrderNo)"查询支付单 PayOrder payOrder = payOrderService.lambdaQuery() .eq(PayOrder::getBizOrderNo, id) .one(); // 转换为DTO返回 return BeanUtils.copyBean(payOrder, PayOrderDTO.class); } }
6. 监听延迟消息,处理订单状态(trade-service
模块)
创建OrderDelayMessageListener
,监听"延迟队列"的消息,查询支付状态并处理订单:
@Component @RequiredArgsConstructor // Lombok自动注入final字段 public class OrderDelayMessageListener { private final IOrderService orderService; private final PayClient payClient; @RabbitListener(bindings = @QueueBinding( value = @Queue(name = MQConstants.DELAY_ORDER_QUEUE_NAME), // 监听的队列 exchange = @Exchange( name = MQConstants.DELAY_EXCHANGE_NAME, delayed = "true" // 关键:标记为"延迟交换机" ), key = MQConstants.DELAY_ORDER_KEY // 路由键 )) public void listenOrderDelayMessage(Long orderId) { // 1. 查询订单(确认订单存在且状态为"未支付") Order order = orderService.getById(orderId); if (order == null || order.getStatus() != 1) { // 假设status=1代表"未支付" return; // 订单已处理,无需操作 } // 2. 调用pay-service,查询支付单状态 PayOrderDTO payOrder = payClient.queryPayOrderByBizOrderNo(orderId); // 3. 根据支付状态处理订单 if (payOrder != null && payOrder.getStatus() == 3) { // status=3代表"支付成功" // 3.1 已支付:标记订单为"已支付" orderService.markOrderPaySuccess(orderId); } else { // 3.2 未支付:取消订单、恢复库存(需自行实现`cancelOrder`方法) orderService.cancelOrder(orderId); } } }
三、流程总结(结合业务流程图)
-
下单环节 :用户创建订单后,
trade-service
向延迟交换机发送"携带订单ID、延迟10秒"的消息。 -
消息延迟:延迟交换机持有消息,等待10秒(实际业务为30分钟)。
-
消息投递:时间到后,消息通过路由键,路由到延迟队列。
-
消费处理 :
OrderDelayMessageListener
监听到消息 → 查询订单状态 → 调用pay-service
查询支付单 → 已支付则标记订单,未支付则取消订单。
通过"延迟消息+Feign跨服务调用",既实现了"订单超时自动取消"的业务需求,又保证了服务间的解耦与代码可维护性。
注意:消息在延迟交换机中无法被取消,所以所有消息必须等延迟30分钟后放到延迟队列中等待消费。若用户下单和支付是一气呵成的,可以设置一个消息id和订单id的关联的缓存,当消费延迟队列时,先根据消息Id查询缓存,若查出已支付,则直接跳过后续业务逻辑。
作业5.1:取消订单------确保状态一致性与幂等性
一、需求拆解
核心目标:处理"超时未支付订单",完成 "订单状态改已关闭"+"恢复扣减的库存",且必须保证 幂等性(避免重复取消导致库存重复恢复、状态异常)。
关键约束:
-
仅"未支付"状态的订单可取消(假设订单状态:1=未支付,3=已关闭);
-
订单状态修改与库存恢复需原子性(用事务保证);
-
调用库存服务时需传递订单ID,供库存服务二次幂等判断。
二、实现逻辑
-
幂等前置判断:查询订单,若订单不存在或状态非"未支付",直接返回(避免重复操作);
-
本地事务控制:开启事务,先更新订单状态为"已关闭",再查询订单商品列表;
-
跨服务恢复库存:通过Feign调用库存服务,传递"订单ID+商品列表",恢复对应库存;
-
异常处理:若任一环节失败,事务回滚,避免数据不一致。
三、代码实现
1. 依赖准备(需先定义Feign客户端)
在hm-api
模块定义库存服务Feign客户端(用于恢复库存):
// 库存恢复DTO:传递订单商品信息 @Data public class InventoryRestoreDTO { private Long orderId; // 订单ID(幂等标识) private List<OrderItemDTO> items; // 商品列表(SKU+数量) } @Data class OrderItemDTO { private Long skuId; // 商品SKU ID private Integer num; // 商品数量 } // 库存Feign客户端 @FeignClient(value = "inventory-service", fallbackFactory = InventoryClientFallback.class) public interface InventoryClient { @PostMapping("/inventories/restore") void restoreInventory(@RequestBody InventoryRestoreDTO dto); }
2. 接口与实现类
// 1. 接口定义(IOrderService) public interface IOrderService extends IService<Order> { // 取消订单:入参为订单ID void cancelOrder(Long orderId); } // 2. 实现类(OrderServiceImpl) @Service @RequiredArgsConstructor public class OrderServiceImpl extends ServiceImpl<OrderMapper, Order> implements IOrderService { private final OrderItemMapper orderItemMapper; // 订单商品Mapper private final InventoryClient inventoryClient; // 库存Feign客户端 private static final Logger log = LoggerFactory.getLogger(OrderServiceImpl.class); @Override @Transactional // 本地事务:确保状态修改与库存恢复原子性 public void cancelOrder(Long orderId) { // 步骤1:幂等判断------仅"未支付"订单可取消 Order order = this.getById(orderId); if (order == null || order.getStatus() != 1) { // 1=未支付 log.info("订单无需取消:订单ID={},状态={}", orderId, order == null ? "不存在" : order.getStatus()); return; } // 步骤2:更新订单状态为"已关闭"(3=已关闭) Order updateOrder = new Order(); updateOrder.setId(orderId); updateOrder.setStatus(3); boolean updateSuccess = this.updateById(updateOrder); if (!updateSuccess) { throw new RuntimeException("取消订单失败:订单状态更新异常,订单ID=" + orderId); } // 步骤3:查询订单商品列表(需恢复的库存信息) List<OrderItem> orderItems = orderItemMapper.selectList( new LambdaQueryWrapper<OrderItem>().eq(OrderItem::getOrderId, orderId) ); // 步骤4:调用库存服务,恢复库存 InventoryRestoreDTO restoreDTO = new InventoryRestoreDTO(); restoreDTO.setOrderId(orderId); // 传递订单ID,供库存服务幂等判断 restoreDTO.setItems(orderItems.stream().map(item -> { OrderItemDTO itemDTO = new OrderItemDTO(); itemDTO.setSkuId(item.getSkuId()); itemDTO.setNum(item.getNum()); return itemDTO; }).collect(Collectors.toList())); inventoryClient.restoreInventory(restoreDTO); log.info("订单取消成功:订单ID={},已恢复库存", orderId); } }
作业5.2:抽取MQ工具------解决重复编码与统一配置
一、需求拆解
核心目标:将MQ的"配置、发送、错误处理"封装为通用工具,解决 "重复编码""配置冗余""错误处理不统一" 问题,具体分3部分:
-
Nacos共享MQ配置:抽离重复的MQ连接配置,所有服务复用;
-
RabbitMqHelper工具类:封装"普通消息、延迟消息、带确认的消息"发送逻辑;
-
自动配置类:动态声明"错误交换机/队列",统一消费失败后的处理策略。
二、实现逻辑
1. Nacos共享配置:减少冗余
-
将所有服务通用的MQ配置(地址、账号、确认机制)抽为Nacos共享配置,服务通过
shared-configs
引用,避免每个服务重复写配置; -
服务可覆盖共享配置(如消费者重试开关),满足个性化需求。
2. RabbitMqHelper:统一发送逻辑
-
依赖
RabbitTemplate
,封装3种消息发送方法:-
普通消息:适用于非核心业务(如日志通知);
-
延迟消息:基于DelayExchange插件,设置
setDelay
; -
带确认的消息:用
CorrelationData
接收MQ回执,加本地重试,确保核心消息必达。
-
3. 自动配置类:动态错误处理
-
动态获取当前微服务名(
spring.application.name
),生成"服务名+error.queue"的队列名(避免跨服务队列冲突); -
声明全局错误交换机
error.direct
,将"错误队列"与交换机绑定(路由键=服务名); -
配置
RepublishMessageRecoverer
:消费重试耗尽后,将消息投递到错误队列,统一兜底。
三、代码实现
1. Nacos共享配置(创建mq-config.yaml
)
# Nacos配置:Data ID=mq-config.yaml,Group=HMALL_GROUP spring: rabbitmq: host: 192.168.150.101 # MQ地址 port: 5672 # 端口 virtual-host: /hmall # 虚拟主机 username: hmall # 账号 password: 123 # 密码 # 生产者确认配置(全局生效) publisher-confirm-type: correlated publisher-returns: true # 消费者基础配置(服务可覆盖) listener: simple: acknowledge-mode: auto # 自动ACK retry: enabled: false # 默认关闭重试,服务按需开启 initial-interval: 1000ms # 首次重试间隔 multiplier: 1 # 重试间隔倍数 max-attempts: 3 # 最大重试次数
2. 服务引用共享配置(bootstrap.yaml)
spring: application: name: trade-service # 当前微服务名 cloud: nacos: config: server-addr: 192.168.150.101:8848 file-extension: yaml group: HMALL_GROUP shared-configs: # 引用Nacos共享MQ配置 - data-id: mq-config.yaml group: HMALL_GROUP refresh: true # 支持动态刷新
3. RabbitMqHelper工具类(hm-common模块)
@Component @RequiredArgsConstructor public class RabbitMqHelper { private final RabbitTemplate rabbitTemplate; private static final Logger log = LoggerFactory.getLogger(RabbitMqHelper.class); / * 1. 发送普通消息(非核心业务) */ public void sendMessage(String exchange, String routingKey, Object msg) { try { rabbitTemplate.convertAndSend(exchange, routingKey, msg); log.debug("普通消息发送成功:交换机={},路由键={}", exchange, routingKey); } catch (Exception e) { log.error("普通消息发送失败:交换机={},路由键={}", exchange, routingKey, e); throw e; // 抛出异常,由上层处理 } } / * 2. 发送延迟消息(基于DelayExchange插件) */ public void sendDelayMessage(String exchange, String routingKey, Object msg, int delay) { try {
rabbitTemplate.convertAndSend( exchange, routingKey, msg, message -> { message.getMessageProperties().setDelay(delay); // 设置延迟时间(毫秒) return message; }); log.debug("延迟消息发送成功:交换机={},路由键={},延迟={}ms", exchange, routingKey, delay); } catch (Exception e) { log.error("延迟消息发送失败:交换机={},路由键={}", exchange, routingKey, e); throw e; } } / * 3. 发送带确认+重试的消息(核心业务,确保必达) */ public void sendMessageWithConfirm(String exchange, String routingKey, Object msg, int maxRetries) { if (maxRetries < 1) throw new IllegalArgumentException("最大重试次数不能小于1"); int retryCount = 0; while (retryCount < maxRetries) { // 生成消息唯一ID(用于回执匹配) String msgId = UUID.randomUUID().toString(); CorrelationData correlationData = new CorrelationData(msgId); // 监听MQ回执(ACK/NACK) correlationData.getFuture().addCallback( confirm -> { if (confirm.isAck()) { log.info("消息确认成功:消息ID={}", msgId); } else { log.error("消息确认失败(NACK):消息ID={},原因={}", msgId, confirm.getReason()); } }, ex -> log.error("消息确认回调异常:消息ID={}", msgId, ex) ); try { rabbitTemplate.convertAndSend(exchange, routingKey, msg, correlationData); return; // 发送成功,退出重试 } catch (Exception e) { retryCount++; if (retryCount >= maxRetries) { log.error("消息发送失败:已达最大重试次数={},消息ID={}", maxRetries, msgId, e); throw e; } // 重试间隔:1s * 重试次数(指数退避) long sleepTime = 1000L * retryCount; log.warn("消息发送失败,{}ms后重试(第{}次):消息ID={}", sleepTime, retryCount + 1, msgId); Thread.sleep(sleepTime); } } } }
4. 自动配置类(MqConsumeErrorAutoConfiguration)
@Configuration // 条件:仅当消费者重试开启时生效(spring.rabbitmq.listener.simple.retry.enabled=true) @ConditionalOnProperty( prefix = "spring.rabbitmq.listener.simple.retry", name = "enabled", havingValue = "true" ) public class MqConsumeErrorAutoConfiguration { @Resource private Environment environment; // 用于获取当前微服务名 / * 1. 声明全局错误交换机(error.direct) */ @Bean public DirectExchange errorDirectExchange() { return ExchangeBuilder.directExchange("error.direct") .durable(true) // 持久化,重启不丢失 .build(); } / * 2. 声明动态错误队列(服务名+error.queue) */ @Bean public Queue errorQueue() { // 获取当前微服务名(spring.application.name) String serviceName = environment.getProperty("spring.application.name"); if (serviceName == null || serviceName.isEmpty()) { throw new RuntimeException("微服务名未配置(spring.application.name),无法创建错误队列"); } String queueName = serviceName + ".error.queue"; // 队列名示例:trade-service.error.queue return QueueBuilder.durable(queueName).build(); } / * 3. 绑定错误队列与交换机(路由键=服务名) */ @Bean public Binding errorQueueBinding(Queue errorQueue, DirectExchange errorDirectExchange) { String serviceName = environment.getProperty("spring.application.name"); return BindingBuilder.bind(errorQueue) .to(errorDirectExchange) .with(serviceName); // 路由键=服务名,确保消息路由到当前服务的错误队列 } / * 4. 配置消费失败处理器:重试耗尽后投递到错误交换机 */ @Bean public MessageRecoverer republishMessageRecoverer(RabbitTemplate rabbitTemplate) { String serviceName = environment.getProperty("spring.application.name"); return new RepublishMessageRecoverer( rabbitTemplate, "error.direct", // 错误交换机 serviceName // 路由键(服务名) ); } }
作业5.3:改造业务服务------用工具类确保消息可靠性
一、需求拆解
核心目标:将 交易、支付、购物车服务 的消息发送逻辑,替换为RabbitMqHelper
工具类,并配置消费者重试,确保:
-
核心消息(如支付通知)必达;
-
失败消息可重试,最终兜底到错误队列;
-
减少重复编码,统一可靠性策略。
二、实现逻辑
|-------|-------------|--------|--------------------------------|
| 服务 | 业务场景 | 消息类型 | 可靠性保障措施 |
| 交易服务 | 下单后发送超时检测消息 | 延迟消息 | 用sendDelayMessage
,配置消费者重试 |
| 支付服务 | 支付成功通知交易服务 | 带确认的消息 | 用sendMessageWithConfirm
,3次重试 |
| 购物车服务 | 下单后清空购物车 | 普通消息 | 用sendMessage
,配置消费者重试 |
三、代码实现
1. 交易服务改造(下单发送延迟消息)
@Service @RequiredArgsConstructor public class OrderServiceImpl extends ServiceImpl<OrderMapper, Order> implements IOrderService { private final RabbitMqHelper rabbitMqHelper; // MQ常量(建议抽为常量类) private static final String DELAY_EXCHANGE = "trade.delay.direct"; private static final String DELAY_ROUTING_KEY = "delay.order.query"; private static final int DELAY_TIME = 30 * 60 * 1000; // 30分钟(超时检测) @Override public Order createOrder(OrderFormDTO orderForm) { // 1. 原有逻辑:创建订单、扣减库存... Order order = new Order(); // ... 填充订单数据并保存 ... // 2. 用工具类发送延迟消息(超时检测) rabbitMqHelper.sendDelayMessage( DELAY_EXCHANGE, DELAY_ROUTING_KEY, order.getId(), // 消息内容:订单ID DELAY_TIME ); return order; } } // 消费者重试配置(trade-service的application.yaml) spring: rabbitmq: listener: simple: retry: enabled: true # 开启消费者重试 max-attempts: 3 # 最大重试3次
2. 支付服务改造(支付成功发送确认消息)
@Service @RequiredArgsConstructor public class PayServiceImpl implements IPayService { private final RabbitMqHelper rabbitMqHelper; // MQ常量 private static final String PAY_EXCHANGE = "pay.direct"; private static final String PAY_ROUTING_KEY = "pay.success"; private static final int MAX_RETRIES = 3; // 最大重试3次 @Override public void handlePaySuccess(Long payOrderId) { // 1. 原有逻辑:更新支付单状态为"已支付"... PaySuccessDTO dto = new PaySuccessDTO(); dto.setPayOrderId(payOrderId); dto.setBizOrderNo(123456L); // 关联的订单ID // 2. 用工具类发送带确认的消息(核心业务,确保必达) rabbitMqHelper.sendMessageWithConfirm( PAY_EXCHANGE, PAY_ROUTING_KEY, dto,//支付单id和订单id的组合 MAX_RETRIES ); } } // 消费者重试配置(pay-service的application.yaml) spring: rabbitmq: listener: simple: retry: enabled: true max-attempts: 3
3. 购物车服务改造(下单后清空购物车)
@Service @RequiredArgsConstructor public class CartServiceImpl implements ICartService { private final RabbitMqHelper rabbitMqHelper; // MQ常量 private static final String CART_EXCHANGE = "cart.direct"; private static final String CART_ROUTING_KEY = "cart.clear"; @Override public void clearCartAfterOrder(Long userId) { // 1. 原有逻辑:生成订单后触发清空... // 2. 用工具类发送普通消息(非核心业务,失败不影响主流程) rabbitMqHelper.sendMessage( CART_EXCHANGE, CART_ROUTING_KEY, userId // 消息内容:用户ID ); } } // 消费者重试配置(cart-service的application.yaml) spring: rabbitmq: listener: simple: retry: enabled: true max-attempts: 3
整体总结
三个作业环环相扣,围绕"可靠性"和"复用性"设计:
-
取消订单:通过"幂等判断+事务"确保数据一致;
-
MQ工具:通过"共享配置+工具类+自动配置"解决重复编码,统一错误处理;
-
业务改造:通过"工具类+重试机制"确保消息必达,核心业务用确认消息,非核心用普通消息,平衡性能与可靠性。
Elasticsearch(ES)的核心价值与技术原理
要理解Elasticsearch(ES)的核心价值与技术原理,需从"为什么用ES""ES与Kibana是什么""倒排索引如何实现高性能搜索"三个层面展开:
一、为什么需要Elasticsearch?
传统数据库(如MySQL)的模糊搜索(如 like '%手机%'
)存在两大痛点:
-
性能差:模糊搜索不触发索引,数据量大时需全表扫描(黑马商城不到9万条数据,搜索已明显卡顿;若数据到百万/千万级,差距会极其夸张)。
-
功能弱:仅支持"严格关键字匹配",用户输错字、用拼音、同义词搜索时,无法灵活命中结果。
因此,面对海量数据搜索或复杂搜索需求(如错字容忍、拼音匹配、同义词拓展),需专门的搜索引擎,而 Elasticsearch是业界主流选择
二、Elasticsearch与Kibana是什么?
Elasticsearch是开源分布式搜索引擎,核心能力是「存储、搜索、分析数据」;Kibana是其可视化工具,让ES的操作更简单。
1. Elasticsearch(ES)
-
它是 Elastic技术栈(ELK) 的核心:
-
Logstash/Beats
:负责收集数据(如日志、业务数据); -
Elasticsearch
:负责存储、计算、搜索数据; -
Kibana
:负责数据可视化(图表、监控、开发控制台)。
-
2. Kibana
-
Kibana是ES的"可视化控制台",无需死记ES的RESTful API,可通过图形界面:
-
搜索、展示ES中的数据;
-
统计、聚合数据并生成报表;
-
监控ES集群状态;
-
提供 Dev Tools(开发控制台):对ES的API提供语法提示,方便调试(如发送请求、测试搜索)。
-
三、安装ES与Kibana(Docker方式,快速验证)
1. 安装Elasticsearch
通过Docker启动单节点ES(版本选7.12.1,企业中8.x前版本更通用):
docker run -d \ --name es \ -e "ES_JAVA_OPTS=-Xms512m -Xmx512m" `# 限制JVM内存,避免占用过多资源` \ -e "discovery.type=single-node" `# 单节点模式(测试用)` \ -v es-data:/usr/share/elasticsearch/data `# 挂载数据卷,持久化数据` \ -v es-plugins:/usr/share/elasticsearch/plugins `# 挂载插件目录(如IK分词器)` \ --privileged `# 授予特殊权限(避免权限问题)` \ --network hm-net `# 加入自定义网络(需提前创建)` \ -p 9200:9200 `# HTTP端口:外部访问ES的REST API` \ -p 9300:9300 `# TCP端口:ES集群内部通信` \ elasticsearch:7.12.1
- 验证:访问
http://服务器IP:9200
,若返回ES版本、集群等信息,说明安装成功。
2. 安装Kibana
通过Docker启动Kibana(版本与ES保持一致,7.12.1):
docker run -d \ --name kibana \ -e ELASTICSEARCH_HOSTS=http://es:9200 `# 指定ES地址(同网络下用容器名即可)` \ --network=hm-net `# 加入与ES相同的网络` \ -p 5601:5601 `# Kibana的Web访问端口` \ kibana:7.12.1
- 验证:访问
http://服务器IP:5601
,进入Kibana后选择"Explore on my own",再进入Dev Tools(开发工具),输入GET /
并运行,若返回ES信息,说明Kibana与ES已连通。
四、倒排索引:ES高性能搜索的核心
ES的搜索优势,源于倒排索引技术。要理解它,需对比传统数据库的正向索引。
1. 正向索引(传统数据库的索引方式)
以MySQL的 tb_goods
表(字段:id
、title
、price
)为例:
-
正向索引的核心是"以文档(行)为中心":
-
若
id
建了索引(如B+树索引),根据id
精确查询很快; -
但如果要执行
like '%手机%'
(模糊搜索title
),索引会失效,只能全表扫描:逐条遍历每一行,判断title
是否包含"手机",效率极低。
-
结论:正向索引适合"精确匹配索引字段",但"模糊匹配非索引字段"时性能极差。
2. 倒排索引(ES的索引方式)
倒排索引的核心是"以词条(Term)为中心",解决"模糊搜索、部分匹配"的问题。涉及两个关键概念:
-
文档(Document):要搜索的数据单元(如一条商品信息、一个网页)。
-
词条(Term):对文档内容分词后,得到的"有意义的词语"(如"华为手机"可拆分为"华为"、"手机")。
倒排索引的创建流程:
-
分词:将每个文档的内容(如商品标题)用分词算法拆分,得到多个词条(比如"华为小米充电器"拆为"华为"、"小米"、"充电器")。
-
建立倒排索引表:以"词条"为索引,记录每个词条出现在哪些文档中(文档ID列表)。
比如 tb_goods
的倒排索引表大致如下:
|-----|------------|
| 词条 | 包含该词条的文档ID |
| 小米 | 1, 3, 4 |
| 手机 | 1, 2 |
| 华为 | 2, 3 |
| 充电器 | 3 |
| 手环 | 4 |
倒排索引的搜索流程(以搜索"华为手机"为例):
-
分词搜索条件:将"华为手机"拆分为词条"华为"、"手机"。
-
查倒排索引表 :分别找"华为"和"手机"对应的文档ID------"华为"对应
[2,3]
,"手机"对应[1,2]
。 -
合并结果 :取两个文档ID的交集(或并集,依业务需求),得到符合条件的文档ID(如
2
)。 -
查正向索引 :根据文档ID(如
2
),去正向索引(或文档存储)中获取完整的商品信息(如"华为手机"的详情)。
整个过程中,词条和文档ID都有索引,无需全表扫描,因此搜索速度极快。
3. 正向索引 vs 倒排索引
|------|-----------------------|----------------------|
| 对比项 | 正向索引(传统数据库) | 倒排索引(Elasticsearch) |
| 核心逻辑 | 以"文档(行)"为中心,"根据文档找词条" | 以"词条"为中心,"根据词条找文档" |
| 优势 | 精确匹配索引字段时速度快;支持多字段索引 | 模糊搜索、部分词条匹配时速度极快 |
| 劣势 | 模糊搜索非索引字段时全表扫描,性能差 | 不擅长"根据字段排序";仅能对词条建索引 |
总结
-
Elasticsearch解决了传统数据库"模糊搜索性能差、功能弱"的问题,核心依赖倒排索引。
-
Kibana是ES的可视化工具,让操作与监控更便捷。
-
通过Docker可快速安装ES与Kibana,适合测试与学习。
-
倒排索引通过"先按词条快速找文档ID,再按ID取文档"的流程,实现了海量数据的高效搜索。
倒排索引的搜索流程
要彻底理解倒排索引的搜索流程,我们结合「商品搜索」的具体例子,从"分词→查词条索引→合并结果→取完整文档"四个步骤拆解:
一、准备:商品数据与倒排索引表
假设我们有一张商品表 tb_goods
,数据如下:
|----------|-------------|-------|
| id(文档ID) | title(商品标题) | price |
| 1 | 小米手机 | 3499 |
| 2 | 华为手机 | 4999 |
| 3 | 华为小米充电器 | 49 |
| 4 | 小米手环 | 49 |
步骤1:对商品标题分词,生成词条
用分词算法(如IK分词器)将每个商品的title
拆分为「有意义的词条」:
-
文档1(id=1,title=小米手机)→ 分词为:
小米
、手机
-
文档2(id=2,title=华为手机)→ 分词为:
华为
、手机
-
文档3(id=3,title=华为小米充电器)→ 分词为:
华为
、小米
、充电器
-
文档4(id=4,title=小米手环)→ 分词为:
小米
、手环
步骤2:建立倒排索引表
以「词条」为索引键,记录「包含该词条的文档ID列表」,形成如下倒排索引表:
|---------|--------------|
| 词条(索引键) | 包含该词条的文档ID列表 |
| 小米 | 1, 3, 4 |
| 手机 | 1, 2 |
| 华为 | 2, 3 |
| 充电器 | 3 |
| 手环 | 4 |
二、搜索流程:以"搜索'华为手机'"为例
用户想搜索"华为手机",倒排索引的执行流程如下:
步骤1:对搜索条件分词
将用户输入的"华为手机"拆分为词条:华为
、手机
。
步骤2:根据词条,查倒排索引表,获取文档ID列表
-
查"华为"对应的文档ID → 得到
[2, 3]
(文档2:华为手机;文档3:华为小米充电器)。 -
查"手机"对应的文档ID → 得到
[1, 2]
(文档1:小米手机;文档2:华为手机)。
步骤3:合并文档ID(取交集/并集,这里是"与"逻辑)
"华为"的ID列表是 [2,3]
,"手机"的ID列表是 [1,2]
,两者的交集是 [2]
(只有文档2同时包含"华为"和"手机")。
步骤4:根据文档ID,查正向索引,获取完整商品信息
根据文档ID=2,到「正向索引(或原始数据存储)」中查询,得到完整商品:
id=2,title=华为手机,price=4999
。
三、再举一例:搜索"小米"
用户搜索"小米",流程更简单:
-
分词:"小米"本身就是一个词条。
-
查倒排索引表:"小米"对应的文档ID是
[1, 3, 4]
。 -
查正向索引:获取文档1(小米手机)、文档3(华为小米充电器)、文档4(小米手环)。
四、倒排索引的核心优势
对比传统数据库的「正向索引」(模糊搜索需全表扫描),倒排索引的每个步骤都基于"词条的索引"和"文档ID的索引",无需逐行遍历数据,因此在「模糊搜索、部分词条匹配」场景下,即使数据量达百万/千万级,搜索性能也不会大幅下降。
通过"分词→查词条索引→合并结果→取完整文档"的流程,倒排索引实现了高效的模糊搜索,这也是Elasticsearch等搜索引擎的核心能力。
Elasticsearch(ES)的核心逻辑与工具链
要彻底理解Elasticsearch(ES)的核心逻辑与工具链,我们从"核心概念(与MySQL对比)""与MySQL的分工""IK分词器(中文分词核心)"三个维度展开,结合业务场景和实操步骤讲解:
一、Elasticsearch核心概念(与MySQL对比理解)
ES是面向文档的搜索引擎,为了降低学习成本,我们将其与熟悉的MySQL概念一一对应:
1. 文档(Document)与字段(Field)
- 文档:ES中存储的一条数据,对应MySQL的"一行记录(Row)"。但ES的文档是JSON格式,更灵活(无需严格固定字段)。
示例(MySQL商品表的一行 → ES文档):
MySQL表行:
|----|-------|-------|
| id | title | price |
| 1 | 小米手机 | 3499 |
ES文档(JSON):
{ "id": 1, "title": "小米手机", "price": 3499 }
- 字段 :文档中的每个键值对(如
"title": "小米手机"
的title
和"小米手机"
),对应MySQL的"一列(Column)"。
2. 索引(Index)与映射(Mapping)
- 索引:ES中同一类文档的集合,对应MySQL的"表(Table)"。
示例:
-
所有商品文档 → 「商品索引」;
-
所有用户文档 → 「用户索引」;
-
所有订单文档 → 「订单索引」。
- 映射(Mapping):索引中文档的结构约束(类似MySQL表的"表结构(Schema)"),定义了"字段类型、分词器、是否索引"等规则。
3. MySQL与ES核心概念对比表
|--------|---------------|-----------------------------------|
| MySQL | Elasticsearch | 说明 |
| Table | Index(表) | 索引是"文档的集合",类似MySQL的表。 |
| Row | Document(行) | 文档是"一条数据",类似MySQL的行,格式为JSON。 |
| Column | Field(列) | 字段是"JSON文档的键值对",类似MySQL的列。 |
| Schema | Mapping(结构约束) | 映射是"索引的结构约束",类似MySQL的表结构。 |
| SQL | DSL | DSL是ES的JSON风格请求语句,用来实现CRUD(增删改查)。 |
二、ES与MySQL的分工(为什么要配合使用?)
ES和MySQL各有擅长领域,企业中通常配合使用,互补短板:
-
MySQL:擅长事务型操作(如订单创建、用户注册),能保证数据的安全性和一致性(ACID特性)。
-
ES:擅长海量数据的搜索、分析、计算(如商品模糊搜索、热点商品统计),搜索性能远超MySQL的模糊搜索(MySQL模糊搜索易全表扫描,ES靠倒排索引实现高效搜索)。
因此,常见架构逻辑:
-
写操作(如"新增商品""用户下单"):走MySQL,保证数据安全;
-
读操作(如"搜索商品""统计销量"):走ES,保证查询性能;
-
数据同步:通过Canal(监听MySQL binlog)、定时任务等方式,保证ES与MySQL数据一致。
三、IK分词器:中文分词的核心工具
ES的"倒排索引"依赖分词(把文本拆成"词条"),但ES默认的"标准分词器"对中文不友好(会把中文拆成单字)。因此需要IK分词器实现精准的中文分词。
1. IK分词器的安装(Docker环境为例)
支持在线安装和离线安装,选适合自己的方式:
方式1:在线安装(网速良好时)
# 进入ES容器 docker exec -it es /bin/bash # 安装IK分词器(版本与ES一致,如7.12.1) ./bin/elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.12.1/elasticsearch-analysis-ik-7.12.1.zip # 退出容器 exit # 重启ES容器,使插件生效 docker restart es
方式2:离线安装(网速较差时)
-
查看ES插件的宿主机挂载目录:
docker volume inspect es-plugins
-
上传并解压IK分词器压缩包到该目录:
-
下载与ES版本匹配的IK分词器(如
elasticsearch-analysis-ik-7.12.1.zip
); -
解压到插件目录;
-
重启ES容器:
docker restart es
。
-
2. IK分词器的两种分词模式
IK分词器提供两种分词策略,适配不同场景:
- ik_smart:智能语义切分(粗粒度),会尽可能少地拆分词条(符合人类语义理解)。
测试(在Kibana的Dev Tools中执行):
POST /_analyze { "analyzer": "ik_smart", "text": "黑马程序员学习java太棒了" }
- ik_max_word:最细粒度切分,会把文本拆分成所有可能的词条(用于需要极致匹配的场景)。
测试:
POST /_analyze { "analyzer": "ik_max_word", "text": "黑马程序员学习java太棒了" }
3. IK分词器的拓展与停用词典
互联网新词汇(如"泰裤辣""传智播客")可能不在IK默认词典中,导致分词错误。此时需要拓展词典(添加新词)或停用词典(过滤"的""了"等无意义词)。
步骤:拓展词典(以"传智播客""泰裤辣"为例)
-
进入IK分词器的
config
目录(Docker环境下,路径为插件挂载目录/ik/config)。 -
修改
IKAnalyzer.cfg.xml
,配置拓展词典:<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd"> <properties> <comment>IK Analyzer 扩展配置</comment> <!-- 自定义拓展词典文件名 --> <entry key="ext_dict">ext.dic</entry> </properties>
-
在
config
目录下新建ext.dic
文件,添加要拓展的词条:传智播客 泰裤辣
-
重启ES容器:
docker restart es
。 -
测试分词,此时"传智播客""泰裤辣"会被正确拆分。
4. IK分词器的核心作用
-
创建倒排索引时:对文档的文本字段(如商品标题)分词,生成词条,用于构建倒排索引。
-
用户搜索时:对用户输入的搜索关键词(如"泰裤辣商品")分词,再去倒排索引中匹配词条,实现精准搜索。
总结
-
ES核心概念:通过"文档、字段、索引、映射"组织数据,可与MySQL概念一一对应,降低学习成本。
-
ES与MySQL分工:MySQL负责写操作的"安全性",ES负责读操作的"搜索性能",两者配合+数据同步保证业务完整。
-
IK分词器:解决中文分词痛点,支持"智能切分"和"细粒度切分",还能通过"拓展/停用词典"适配新词汇与业务需求。
ES的索引库操作与Mapping映射
要掌握Elasticsearch(ES)的索引库操作与Mapping映射,需从"Mapping的约束作用""索引库的CRUD"两个核心维度展开,结合类比数据库的"表结构"与"表操作",理解会更清晰:
一、Mapping映射:定义文档的"结构约束"
Mapping是对索引库中文档的规则约束,决定了"字段是什么类型、是否分词、能否被搜索"等核心逻辑,类似数据库的"表结构(Schema)"。
1. 核心Mapping属性
Mapping通过以下属性控制字段行为:
-
type
:字段的数据类型(类似数据库"列类型"),常见类型:-
字符串:
text
(可分词的文本,如商品标题,适合模糊搜索)、keyword
(精确字符串,如品牌、邮箱,不分词,适合精确匹配); -
数值:
long
/integer
/float
等; -
布尔:
boolean
; -
日期:
date
; -
对象:
object
(嵌套复杂结构,如user.name.firstName
)。
-
-
index
:是否为该字段创建索引(默认true
)。若为false
,该字段无法被搜索(仅能存储)。 -
analyzer
:对text
类型字段,指定分词器(如ik_smart
/ik_max_word
,实现中文分词)。 -
properties
:定义字段的子字段(用于嵌套结构,如name
包含firstName
和lastName
)。
2. 示例:JSON文档 → Mapping的对应关系
假设有如下JSON文档(模拟用户信息):
{ "age": 21, "weight": 52.1, "isMarried": false, "info": "黑马程序员Java讲师", "email": "zy@itcast.cn", "score": [99.1, 99.5, 98.9], "name": { "firstName": "云", "lastName": "赵" } }
需为每个字段定义Mapping规则(约束类型、分词、可搜索性):
|------------------|-----------|---------------|------|------|-----|
| 字段名 | 类型 | 说明 | 是否搜索 | 是否分词 | 分词器 |
| age
| integer
| 整数 | ✔️ | ❌ | --- |
| weight
| float
| 浮点数 | ✔️ | ❌ | --- |
| isMarried
| boolean
| 布尔 | ✔️ | ❌ | --- |
| info
| text
| 需分词的文本(如个人简介) | ✔️ | ✔️ | IK |
| email
| keyword
| 精确字符串(如邮箱) | ❌ | ❌ | --- |
| score
| float
| 数组元素类型(浮点数) | ✔️ | ❌ | --- |
| name
| object
| 嵌套对象 | --- | --- | --- |
| name.firstName
| keyword
| 精确字符串(名) | ✔️ | ❌ | --- |
| name.lastName
| keyword
| 精确字符串(姓) | ✔️ | ❌ | --- |
-
info
是text
类型,用IK分词器分词,因此能被"模糊搜索"(如搜"程序员"能命中); -
email
是keyword
类型,不分词、不索引,因此无法被搜索,仅用于存储; -
name
是object
类型,内部嵌套firstName
和lastName
两个keyword
字段。
二、索引库(Index):文档的"集合"
索引库是同一类文档的集合(类似数据库的"表"),每个索引库需提前定义Mapping(类似"表结构")。
索引库的CRUD操作(基于Kibana DevTools)
ES采用Restful风格API,通过"请求方式 + 路径 + JSON参数"完成操作,Kibana的DevTools提供语法提示,非常便捷。
1. 创建索引库 + Mapping
-
请求方式 :
PUT
-
请求路径 :
/索引库名
(自定义,如/heima
) -
请求参数 :
mappings
(定义字段的Mapping规则)
示例 :创建名为heima
的索引库,包含info
(分词文本)、email
(精确字符串)、name
(嵌套对象)三个字段:
PUT /heima { "mappings": { "properties": { "info": { "type": "text", "analyzer": "ik_smart" // 用IK智能分词 }, "email": { "type": "keyword", "index": "false" // 不创建索引,无法搜索 }, "name": { "properties": { // 嵌套对象的子字段 "firstName": { "type": "keyword" } } } } } }
2. 查询索引库
-
请求方式 :
GET
-
请求路径 :
/索引库名
-
请求参数:无
示例 :查询heima
索引库的结构(含Mapping、设置等):
GET /heima
3. 修改索引库
ES的倒排索引特性决定:已有的字段Mapping无法修改(否则需重建倒排索引,代价极大)。但允许添加新字段(不影响已有倒排索引)。
-
请求方式 :
PUT
-
请求路径 :
/索引库名/_mapping
-
请求参数:新字段的Mapping
示例 :给heima
索引库添加age
字段(整数类型):
PUT /heima/_mapping { "properties": { "age": { "type": "integer" } } }
4. 删除索引库
-
请求方式 :
DELETE
-
请求路径 :
/索引库名
-
请求参数:无
示例 :删除heima
索引库(谨慎操作,数据永久删除):
DELETE /heima
操作总结
|-------|----------|------------------|-------------------|
| 操作 | 请求方式 | 请求路径 | 关键说明 |
| 创建索引库 | PUT
| /索引库名
| 需同时定义Mapping |
| 查询索引库 | GET
| /索引库名
| 查看索引库结构(含Mapping) |
| 修改索引库 | PUT
| /索引库名/_mapping
| 仅能添加新字段,无法修改已有字段 |
| 删除索引库 | DELETE
| /索引库名
| 谨慎操作,数据永久删除 |
三、核心逻辑总结
-
Mapping是"结构约束":决定字段的类型、分词规则、可搜索性,是ES实现"精准搜索"的基础。
-
索引库是"文档集合":类似数据库的"表",需提前定义Mapping。
-
ES操作遵循Restful风格:通过"请求方式 + 路径"统一接口,学习成本低。
-
倒排索引的限制:已有字段的Mapping无法修改(否则需重建索引),但可添加新字段。
掌握这些后,就能为后续"文档CRUD"和"复杂搜索"打好基础。
Elasticsearch 的文档操作
要清晰理解 Elasticsearch 的文档操作,我们可以从「增、删、查、改、批处理」五个维度,结合语法、示例和结果逐一讲解:
一、文档的本质
Elasticsearch 中的「文档」是存储数据的基本单位,格式为 JSON(类似数据库的"一行记录",但结构更灵活,支持嵌套)。
二、新增文档(Create)
作用:向指定索引库中添加一条 JSON 格式的文档数据。
语法
POST /索引库名/_doc/文档id { "字段1": "值1", "字段2": "值2", "嵌套字段": { "子字段1": "值3", "子字段2": "值4" } }
-
POST
:请求方法,代表"新增/提交"。 -
/索引库名/_doc/文档id
:请求路径,_doc
是文档操作的固定关键字,文档id
是数据的唯一标识(类似数据库主键)。 -
大括号内:文档的具体内容,支持嵌套结构(如示例中
name
包含firstName
/lastName
)。
示例与响应
示例请求:
POST /heima/_doc/1 { "info": "黑马程序员Java讲师", "email": "zy@itcast.cn", "name": { "firstName": "云", "lastName": "赵" } }
响应结果(对应"新增文档"的图片):
{ "_index": "heima", // 索引库名称 "_type": "_doc", // 文档类型(7.x+版本默认用 _doc) "_id": "1", // 文档id "_version": 1, // 版本号(新增时为1,修改会递增) "result": "created", // 操作结果:created 表示"新增成功" "shards": { // 分片信息(Elasticsearch 是分布式存储,涉及分片) "total": 2, "successful": 1, "failed": 0 }, "_seq_no": 0, "_primary_term": 1 }
解释:result: "created"
表明首次新增 id 为 1 的文档,成功存入 heima
索引库。
三、查询文档(Read)
作用:根据文档 id,从指定索引库中查询对应文档。
语法
GET /{索引库名称}/_doc/{id}
-
GET
:请求方法,代表"查询"。 -
/{索引库名称}/_doc/{id}
:通过"索引库名 + 文档 id",精准查询某条数据。
示例与结果
示例请求:
GET /heima/_doc/1
查询结果(对应"查询文档"的图片):
{ "_index": "heima", "_type": "_doc", "_id": "1", "_version": 1, "_seq_no": 0, "_primary_term": 1, "found": true, // 是否找到:true 表示文档存在 "_source": { // _source 包含文档的原始 JSON 内容 "info": "黑马程序员Java讲师", "email": "zy@itcast.cn", "name": { "firstName": "云", "lastName": "赵" } } }
解释:found: true
说明找到 id 为 1 的文档,_source
中是新增时的原始数据,查询成功。
四、删除文档(Delete)
作用:根据文档 id,从指定索引库中删除对应文档。
语法
DELETE /{索引库名}/_doc/id值
DELETE
:请求方法,代表"删除"。