微服务学习笔记(黑马商城)

MyBatis-Plus快速实现单表 CRUD

要理解MyBatis-Plus(简称MP)快速实现单表CRUD的过程,需从依赖管理、Mapper接口设计、单元测试验证三个核心环节拆解,每个环节都体现了MP"简化MyBatis开发"的核心思想。

一、环节1:引入依赖(自动装配核心组件)

MyBatis-Plus提供了专门的Spring Boot启动器依赖(mybatis-plus-boot-starter),作用是自动完成MyBatis和MP的核心组件装配,无需手动配置SqlSessionFactoryMapperScannerConfigurer等基础组件。

依赖解析:
复制代码

<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中resultMapid,用于自定义结果映射(当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保证扩展性。


QueryWrapperUpdateWrapperLambdaQueryWrapper

MyBatis-Plus提供的QueryWrapperUpdateWrapperLambdaQueryWrapper是构建动态条件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:避免字段名硬编码(更安全的条件构造)

QueryWrapperUpdateWrapper在写条件时,字段名需用字符串(如like("username", "o")),存在两个问题:

  1. 字符串"硬编码"(魔法值),不符合编程规范;

  2. 字段名拼写错误或重构时,编译期无法发现,容易出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"的用户(涉及useraddress两表关联)。

步骤1:业务层构建多表条件

QueryWrapper指定多表字段的条件(注意表别名,如u.ida.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:后端返回给前端的结果(隐藏密码等敏感字段,只暴露用户名、余额等需要展示的内容)。

  • 属性拷贝 :用HutoolBeanUtil实现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 :继承IServiceServiceImpl,直接复用封装好的增删改查,无需重复开发。

  • 业务拓展:通过"自定义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)中,conditiontrue时才会添加该更新字段(如remainBalance == 0时,才更新status)。

  • 条件约束.eq(User::getId, id)确保只更新目标用户;.eq(User::getBalance, user.getBalance())是乐观锁,防止多线程下的余额错乱(例如:A操作查询余额为100,B操作先扣减到50,A再扣减就会失败)。

  • 无需手动写SQL:通过链式调用自动生成更新SQL,无需在Mapper中定义自定义方法。

总结:Lambda功能的核心价值

  1. 简化代码 :用链式编程替代繁琐的if判断和new Wrapper操作。

  2. 类型安全 :通过实体类方法引用(User::getXxx)避免字段名拼写错误。

  3. 动态灵活 :支持条件判断(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个大包裹,一次送过去"。

五、配置开启 + 测试:性能暴涨

  1. 配置参数 :在项目的 application.yml 中,给MySQL的JDBC连接URL添加 &rewriteBatchedStatements=true

    复制代码

    spring: datasource: url: jdbc:mysql://...?useUnicode=true&...&rewriteBatchedStatements=true

  2. 再次测试 :用 saveBatch 批量插入10万条数据。

    1. 实际效果:SQL被重写成"一条多值插入"的形式,数据库只需解析少量SQL。

    2. 测试耗时:约 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方法(如 getByIdlambdaQuerylambdaUpdate 等),但无需注入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虽然简化了逻辑删除的代码,但这种方案本身有缺陷:

  1. 数据膨胀:因为没真删,数据库里的"已删除数据"会越来越多,导致表越来越大,查询时要扫描更多行,影响速度。

  2. 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接收:

  • 要获取ageintro等属性,得手动解析JSON字符串(比如用JSON工具转成Map或自定义对象),代码繁琐且易出错。

二、解决方案:用「类型处理器 + 实体类」自动转换

MyBatis-Plus提供JacksonTypeHandler(或其他JSON处理器),能自动把数据库JSON转成Java对象,反之亦然。步骤如下:

步骤1:定义与JSON结构匹配的实体类

创建UserInfo类,属性与JSON中的字段一一对应(如ageintrogender):

复制代码

@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),只需把UserVOinfo字段也改为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类型(如UserVOGoodsVO)。

复制代码

@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. PageQuerytoMpPage系列方法

MyBatis-Plus的分页需要用Page对象,PageQuery的工具方法可以自动把"通用分页/排序参数"转成Page对象,无需手动new Page()、设置排序。

例如:

复制代码

// 业务中只需一行代码,就能生成带"默认按update_time倒序"的Page对象 Page<User> page = query.toMpPageDefaultSortByUpdateTimeDesc();

2. PageDTOof系列方法

查询后,需要把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

命令执行流程(结合截图)

当执行这条命令时:

  1. Docker先检查本地有没有mysql镜像。如果没有(如截图中"Unable to find image 'mysql:latest' locally"),就去Docker Hub(官方镜像仓库)拉取mysql镜像。

  2. 拉取完成后,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(静态资源)。

  • 我们创建两个数据卷:confhtml,分别把容器内的confhtml目录与数据卷"挂钩"。

  • 数据卷又会指向宿主机的默认目录:/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用这些配置和脚本初始化。

步骤:

  1. 删除旧容器 :如果之前有MySQL容器,先删了(docker rm -f mysql)。

  2. 运行新容器,挂载本地目录

    复制代码

    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 # 镜像名

  3. 验证挂载效果

    1. 宿主机的./mysql/data会自动生成,里面存着MySQL的数据库文件。

    2. 进入MySQL容器,查看编码(show variables like "%char%";),会发现是utf8mb4(因为用了conf里的配置)。

    3. 查看数据库(show databases;),能看到hmall数据库,里面还有addresscart等表(因为用了init里的hmall.sql初始化)。

总结逻辑链

容器隔离 → 需操作容器内文件/数据 → 数据卷作为"桥梁"解耦容器与宿主机 → 有「命名数据卷」「匿名数据卷」→ 但数据卷目录太深,所以直接挂载本地目录/文件更方便 → 通过Nginx、MySQL的例子,验证"挂载后能灵活操作配置、数据、静态资源"。


Docker镜像的构建与使用

要搞懂Docker镜像的构建与使用,可以从「为什么要做镜像」「镜像长啥样」「怎么描述镜像(Dockerfile)」「怎么生成镜像(构建)」这几个环节,结合例子逐步梳理:

一、为什么要自己构建镜像?

之前我们用的是别人做好的镜像(比如Nginx、MySQL),但实际开发中,需要把自己的应用(比如Java项目)也打包成镜像,这样才能用Docker快速部署自己的服务(不用再手动装环境、配依赖)。

二、镜像的结构:"分层叠加"的文件集合

镜像不是一个"大胖子文件",而是分层(Layer)叠加的结构。每一层对应"部署应用的一个步骤",且每层独立、可复用。

以"部署Java应用"为例,手动流程是:

  1. 准备Linux运行环境(如Ubuntu)。

  2. 安装并配置JDK。

  3. 上传Jar包(应用本身)。

  4. 运行Jar包。

镜像把这些步骤"固化"成分层的文件:

  • 基础镜像(BaseImage) :最底层,提供最基本的运行环境(比如Linux系统核心库、命令)。Docker官方已做好很多基础镜像(如ubuntucentos,或专门为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命令生成镜像。

步骤(看第三、四张图和讲义)
  1. 准备文件 :把需要的文件(比如docker-demo.jar和写好的Dockerfile)放到同一个目录(比如虚拟机的/root/demo)。非常关键!!!!

  2. 执行构建命令

    复制代码

    docker build -t docker-demo:1.0 .

    1. docker build:告诉Docker"我要构建镜像了"。

    2. -t docker-demo:1.0:给镜像起名字(docker-demo是"仓库名",1.0是"标签/版本号")。

    3. .:指定Dockerfile所在的目录(.表示"当前目录",也可以写绝对路径如/root/demo)。

  3. 查看构建过程 :执行命令后,Docker会一步步执行Dockerfile里的指令,输出日志(比如"加载基础镜像""拷贝文件""执行RUN命令")。如果某一层之前构建过(有缓存),会直接用缓存(日志里的CACHED提示),加快速度。

  4. 验证镜像 :构建完成后,用docker images查看本地镜像列表,能看到docker-demo:1.0

  5. 启动容器运行应用

    复制代码

    docker run -d --name dd -p 8080:8080 docker-demo:1.0

    1. -d:后台运行容器。

    2. --name dd:给容器起个名字(叫dd)。

    3. -p 8080:8080:端口映射(宿主机的8080端口 → 容器的8080端口)。

    4. docker-demo:1.0:用刚才构建的镜像启动容器。

  6. 测试应用 :用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实现"远程调用商品服务"

CartServiceImplhandleCartItems 方法中,通过 RestTemplate 调用 item-service 的接口,获取商品信息。步骤分解如下:

1. 收集需要查询的商品ID

从购物车视图对象(CartVO)中,提取所有商品的ID,存入 Set 中(避免重复):

复制代码

Set<Long> itemIds = vos.stream().map(CartVO::getItemId).collect(Collectors.toSet());

2. 发送HTTP请求(调用商品服务接口)

使用 RestTemplateexchange 方法发送 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-servicepom.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-serviceapplication.yml 中,指定 Nacos 注册中心的地址:

复制代码

spring: cloud: nacos: server-addr: 192.168.150.101:8848 # Nacos 服务器的 IP:端口

这样 cart-service 就能和 Nacos 通信,实现"注册自己"和"发现其他服务"。

三、代码中"服务发现 + 动态调用"的逻辑拆解

CartServiceImplhandleCartItems 方法为例,原来的远程调用是硬编码 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. 后续处理响应... }

逐行解析核心步骤
  1. 发现服务实例

    复制代码

    List<ServiceInstance> instances = discoveryClient.getInstances("item-service");

    1. DiscoveryClient 是 Spring Cloud 提供的服务发现工具(已被 Spring 自动装配,直接注入即可用)。

    2. getInstances("item-service"):向 Nacos 询问"服务名为 item-service 的所有实例",返回包含多个实例的列表(每个实例包含 IP、端口、URI 等信息)。

  2. 负载均衡(随机选实例)

    复制代码

    ServiceInstance instance = instances.get(RandomUtil.randomInt(instances.size()));

    1. 因为 item-service 可能有多个实例,这里用随机算法选一个(也可以用轮询、权重等更复杂的负载均衡策略)。

    2. 这样每次调用可能选中不同实例,实现"请求分摊",避免单实例压力过大。

  3. 动态拼接请求地址

    复制代码

    instance.getUri() + "/items?ids={ids}"

    1. instance.getUri():获取选中实例的统一资源标识符(比如 http://192.168.1.100:8081)。

    2. 拼接接口路径 /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-servicepom.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 的商品信息 } }

此时,无需再手动写 DiscoveryClientRestTemplate 的逻辑,一行代码就完成了远程调用,和本地方法调用的体验完全一致。

四、OpenFeign 的优势总结

对比之前的 RestTemplate + DiscoveryClient

  • 代码更简洁:无需关心"服务发现、负载均衡、HTTP 请求细节",像调用本地方法一样调用远程服务。

  • 与 SpringMVC 无缝集成 :用熟悉的 @GetMapping@RequestParam 等注解,学习成本低。

  • 自动集成负载均衡:底层集成 LoadBalancer,自动从 Nacos 选实例,实现请求分摊。

简单来说,OpenFeign 把"复杂的远程 HTTP 调用"封装成"简单的接口方法调用",大幅提升微服务远程调用的开发效率。


Feign 连接池

要理解 Feign 连接池 的作用与验证,可从「为什么要换连接池」「怎么配置连接池」「怎么验证生效」三个角度拆解:

一、为什么要给 Feign 配置连接池?

Feign 底层发送 HTTP 请求时,默认使用 HttpURLConnection,但它有个缺陷:

  • 不支持连接池:每次请求都要"新建连接 → 发请求 → 关闭连接"。频繁创建/销毁连接的开销很大,高并发下性能会严重下降。

Apache HttpClientOKHttp 这类客户端支持连接池:可以复用连接,避免重复创建/销毁连接的开销,大幅提升请求效率。因此,我们通常会替换 Feign 的默认 HTTP 客户端,改用带连接池的实现(比如 OKHttp)。

二、如何配置 Feign 使用 OKHttp 连接池?

只需两步:

步骤 1:引入 OKHttp 依赖

cart-servicepom.xml 中,添加 feign-okhttp 依赖(让 Feign 能找到并使用 OKHttp):

复制代码

<!-- 引入 OKHttp 依赖,使 Feign 底层用 OKHttp 发请求 --> <dependency> <groupId>io.github.openfeign</groupId> <artifactId>feign-okhttp</artifactId> </dependency>

步骤 2:开启 OKHttp 连接池

cart-serviceapplication.yml 中,配置启用 Feign 的 OKHttp 功能:

复制代码

feign: okhttp: enabled: true # 开启 OKHttp 作为 Feign 的 HTTP 客户端

重启 cart-service 后,Feign 就会切换到底层 HTTP 客户端为 OKHttp,并自动使用 OKHttp 的连接池。

三、如何验证 OKHttp 连接池生效?

讲义中用 Debug 断点调试 的方式验证:

  1. 打 Debug 断点 :在 org.springframework.cloud.openfeign.loadbalancer.FeignBlockingLoadBalancerClientexecute 方法处打断点(这个方法是 Feign 发起 HTTP 请求的核心逻辑)。

  2. Debug 启动服务 :用 Debug 模式启动 cart-service,然后调用"查询购物车"接口(该接口会通过 Feign 远程调用其他服务,触发 Feign 的 HTTP 请求)。

  3. 查看底层客户端 :当程序执行到断点时,查看调试器中的变量/调用栈------如果看到底层 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.dtocom.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可删除自己原来的ItemDTOItemClient,直接复用hm-api中的代码。

4. 解决FeignClient扫描问题

问题cart-service的启动类在com.hmall.cart包下,而ItemClientcom.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)"部分可存储userIdusername等信息,并用密钥签名(防止篡改)。

示例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请求拦截器,将当前请求头中的用户信息(如AuthorizationX-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-serviceuser-service等)的地址列表

  • 后续路由转发时,网关会根据服务名从Nacos动态获取可用的服务实例

3. 核心:路由规则配置(spring.cloud.gateway.routes

这部分是网关的核心,定义了"哪些请求应该转发到哪个服务"的规则。每个路由规则包含3个核心属性:iduripredicates

(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-servicelb表示启用负载均衡,如果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服务

整体工作流程

  1. 客户端发送请求到网关的8080端口(如http://网关IP:8080/items/1

  2. 网关根据请求路径(/items/1)匹配路由规则的predicates

  3. 匹配到id: item的规则后,从Nacos获取item-service的可用实例

  4. 通过负载均衡(lb)将请求转发到其中一个item-service实例

  5. 服务处理完成后,响应结果通过网关返回给客户端

通过这种配置,网关统一接收所有请求并转发到对应的微服务,实现了"客户端只需要知道网关地址,无需关心具体服务地址"的效果,同时也为后续的统一认证、限流等功能提供了基础。


上述补充

要清晰理解这些内容,我们可以从"配置 → 底层类 → 核心功能(路由、断言、转发)"的逻辑链条逐步拆解:

一、配置文件:定义路由规则

application.yamlspring.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 为例)

  1. 客户端发送请求到网关(如 http://网关地址:8080/items/1001)。

  2. 网关遍历所有 RouteDefinition,检查每个路由的 predicates

    1. 当匹配到 id: item 的路由时,Path 断言检测到路径 /items/1001 符合 /items/ 规则。
  3. 网关通过 uri: lb://item-service,从注册中心获取 item-service 的所有实例。

  4. 负载均衡选择其中一个实例(如 192.168.1.10:8081),将请求转发过去。

  5. 目标服务处理完请求,响应通过网关返回给客户端。

设计价值

这种架构实现了"客户端只认网关,服务细节全隐藏":

  • 客户端无需关心具体服务的部署地址,只需访问网关。

  • 网关能动态感知服务实例的上下线,通过负载均衡实现高可用转发。


网关过滤器

要清晰理解网关过滤器的工作原理与使用,我们可以从网关请求流程、过滤器的作用与类型、内置过滤器的配置三个维度逐步拆解:

一、网关的请求处理流程(结合流程图)

客户端请求进入网关后,遵循以下步骤处理:

  1. 路由匹配HandlerMapping 根据请求路径,匹配到对应的路由规则(Route)(比如"请求路径是 /items/,匹配 item 路由")。

  2. 过滤器链执行FilteringWebHandler 加载当前路由对应的过滤器链(Filter Chain),并按顺序执行过滤器:

    1. Pre 阶段:过滤器的"请求前逻辑"依次执行(如登录校验、添加请求头)。

    2. 转发请求 :所有 Pre 逻辑执行完毕后,由 NettyRoutingFilter 将请求转发到具体微服务。

    3. Post 阶段:微服务返回响应后,过滤器的"响应后逻辑"倒序执行(如修改响应头、记录日志)。

  3. 返回响应:最终将处理后的响应返回给客户端。

二、过滤器的作用与类型

过滤器是网关"干预请求/响应"的核心手段(比如登录校验必须在请求转发前完成,就需要通过过滤器实现)。Spring Cloud Gateway 有两类核心过滤器:

1. GatewayFilter(路由过滤器)
  • 作用范围 :单个指定的路由(只对配置了它的 Route 生效)。

  • 特点:灵活,可针对不同路由做差异化处理(比如给商品服务的请求加头,订单服务的请求不加)。

2. GlobalFilter(全局过滤器)
  • 作用范围:所有路由(对进入网关的所有请求都生效)。

  • 特点:用于全局统一处理(比如全局登录校验、全局日志记录)。

补充:HttpHeadersFilter

专门用于处理请求头的传递(如代理场景下,将客户端原始的 Host 头传递给下游微服务),属于"请求头增强"的补充型过滤器。

三、过滤器的执行逻辑(方法签名)

GatewayFilterGlobalFilter 的核心方法签名完全一致:

复制代码

Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain);

  • ServerWebExchange:请求的"上下文",包含请求(ServerHttpRequest)、响应(ServerHttpResponse)等所有关键数据。

  • GatewayFilterChain:过滤器链的"执行器",调用 chain.filter(exchange) 表示将请求交给下一个过滤器处理。若当前是链中最后一个过滤器(如 NettyRoutingFilter),则触发"请求转发到微服务"。

四、内置 GatewayFilter 的配置与使用

Spring Cloud Gateway 内置了大量实用的 GatewayFilter(如 AddRequestHeaderStripPrefix 等),无需编码,通过 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 的请求头。

总结逻辑链

  1. 先理解网关处理请求的整体流程:路由匹配 → 过滤器链执行(Pre 逻辑)→ 转发微服务 → 过滤器链执行(Post 逻辑)→ 返回响应。

  2. 再明确过滤器的作用:是"在请求转发前后、响应返回前后"干预请求/响应的关键环节(如登录校验、请求头增强)。

  3. 然后区分过滤器类型:GatewayFilter(单路由)、GlobalFilter(全路由),按需选择作用范围。

  4. 最后掌握内置过滤器的配置:通过 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=1b=2c=3)。

  • 类比:把 Config 想象成一个"参数容器",字段 abc 是容器的"格子",用来装配置值。

步骤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.a2 赋值给 Config.b3 赋值给 Config.c
方式2:指定参数名传参(更灵活)
复制代码

spring: cloud: gateway: default-filters: - name: PrintAny # 过滤器的"前缀名"(类名去掉 GatewayFilterFactory) args: # 显式指定参数名和值,无需关心顺序 a: 1 b: 2 c: 3

  • 逻辑:框架直接根据 args 里的键(abc),把值赋值给 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`。

这样,同一个过滤器工厂,通过不同的参数配置,就能为不同服务的请求"动态添加差异化的请求头"------既复用了"添加请求头"的核心逻辑,又能通过参数灵活定制每个服务的专属行为,无需为每个服务单独写过滤器。

总结流程
  1. 配置文件传递参数 → 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 是参数载体,定义了 serviceNamecustomHeaderKeycustomHeaderValue 三个字段,用于接收配置文件中的参数。

  • getConfigClass() 告诉框架:"用这个 Config 类来解析配置参数"。

  • shortcutFieldOrder() 规定:当配置用"逗号分隔值"(如 ServiceHeader=item, X-Item-Priority, high)时,第一个值对应 serviceName,第二个对应 customHeaderKey,第三个对应 customHeaderValue

(2)过滤器的"逻辑执行"

apply(Config config) 方法中:

  • 接收框架填充好的 Config 对象(里面包含配置文件的参数)。

  • 通过 request.mutate() 构建新请求,动态添加两个请求头:

    • X-Service: 服务名(用于下游服务识别"请求来自哪个微服务")。

    • 自定义头(如 X-Item-Priority: highX-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/ # 商品相关接口

这些配置会被JwtPropertiesAuthProperties读取,分别控制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; // 过滤器执行顺序:值越小,优先级越高(确保登录校验优先执行) } }

逻辑总结
  1. 免校验判断 :先检查请求路径是否在excludePaths中,是则直接放行。

  2. token提取 :从Authorization请求头中提取token。

  3. token校验 :用JwtTool解析token,无效则返回401拦截;有效则提取用户ID。

  4. 用户信息传递 :(示例中仅打印,实际需将userId放入请求头/上下文,让下游服务感知用户身份)。

  5. 请求放行: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。

以购物车服务 CartServiceImplqueryMyCarts 方法为例:

复制代码

@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"的硬编码逻辑。

整体流程总结(结合流程图)

  1. 浏览器请求网关:携带JWT发起请求。

  2. 网关校验JWT :解析出用户ID,将其放入请求头user-info,转发给微服务。

  3. 微服务拦截器拦截 :从请求头取user-info,存入UserContextThreadLocal

  4. 业务代码执行 :通过UserContext.getUser()获取用户ID,执行用户相关业务(如查询个人购物车)。

  5. 请求结束 :拦截器移除ThreadLocal中的用户ID,避免内存泄漏。

这套逻辑实现了"网关与微服务间的用户信息传递",且通过"公共模块+自动装配"让所有微服务能复用代码,保证了架构的简洁与一致性。


OpenFeign传递用户信息

要理解OpenFeign传递用户信息的逻辑,我们可以从业务问题、技术方案、代码实现三个层面拆解:

一、业务问题:微服务间调用"丢失用户信息"

前端请求经过网关时,网关会把"用户信息(如ID)"通过请求头传递给微服务,微服务再通过拦截器+ThreadLocal保存用户信息(如之前的UserContext)。

但微服务之间的调用(如订单服务调用购物车服务)是通过 OpenFeign 实现的,默认情况下:

  • 订单服务的业务代码能通过UserContext拿到用户ID,但Feign发起请求时,不会自动把用户ID带到请求头中。

  • 购物车服务接收到Feign请求后,因为请求头没有用户信息,就无法知道"当前是哪个用户要清理购物车",导致业务逻辑失败。

二、技术方案:Feign拦截器(RequestInterceptor

OpenFeign提供了 RequestInterceptor接口,可以在每次Feign发起请求前执行自定义逻辑。我们可以利用它:

  1. UserContext(ThreadLocal工具类)中获取当前用户ID。

  2. 把用户ID放入请求头,随Feign请求一起发送给下游微服务。

  3. 下游微服务再通过"拦截器从请求头取用户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. 逻辑解释
  • RequestInterceptorapply方法:每次Feign发起请求前,都会执行这个方法。

  • UserContext.getUser():从ThreadLocal中获取"当前请求的用户ID"(因为微服务自己的业务代码已经通过拦截器把用户ID存入ThreadLocal了)。

  • template.header("user-info", ...):给Feign的请求添加"user-info"请求头,值为用户ID。

四、效果:全链路用户信息传递

通过这套逻辑,实现了"网关→微服务→微服务(Feign调用)"的用户信息传递闭环:

  1. 前端请求网关:网关解析JWT,把用户ID放入请求头,转发给微服务。

  2. 微服务接收到网关请求:拦截器从请求头取用户ID,存入UserContext(ThreadLocal)。

  3. 微服务内部Feign调用其他服务:Feign拦截器从UserContext取用户ID,放入Feign请求的头中。

  4. 下游微服务接收到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}

四、核心价值:配置管理的优势

  1. 集中化:公共配置(数据库、日志、Swagger)不再分散在各服务,统一存在 Nacos,维护更高效。

  2. 动态化:Nacos 支持配置热更新,修改配置后无需重启服务,立即生效。

  3. 灵活性:通过"占位符+默认值",既能共享公共逻辑,又能让不同环境(开发/测试/生产)、不同服务自定义配置(如每个服务的 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-serviceresources 目录下创建 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 # 购物车服务独有的数据库名

四、整体流程:配置加载与合并

  1. 微服务启动,先加载 bootstrap.yaml(因 spring-cloud-starter-bootstrap 启用了 Bootstrap 机制)。

  2. Bootstrap 上下文根据 bootstrap.yaml 中的 nacos.server-addr,连接 Nacos 配置中心。

  3. 从 Nacos 拉取 shared-jdbc.yamlshared-log.yamlshared-swagger.yaml 这些共享配置。

  4. 接着加载 application.yaml,将"Nacos 拉取的共享配置"与"本地 application.yaml 的独有配置"合并,完成整个应用的配置初始化。

  5. 重启服务后,所有配置(公共 + 独有)生效,且 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控制台新建配置文件,规则:

  • DataIDcart-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()) ); } } }

三、热更新效果:改配置,不重启服务

  1. 初始测试 :Nacos中maxAmount=1,此时购物车添加第2件商品就会报错(符合限制)。

  2. 修改Nacos配置 :在Nacos控制台把maxAmount改成5(无需重启服务)。

  3. 再次测试:购物车能添加到5件才报错------说明配置已经热更新生效。

核心逻辑:Nacos与Spring的"热更新协同"

  • Nacos作为配置中心,存储动态配置。

  • Spring通过@ConfigurationProperties绑定配置,并监听Nacos的配置变更。

  • 当Nacos配置变化时,Spring自动刷新CartPropertiesmaxAmount值,业务代码立即使用新配置。

这种方式实现了"配置与代码解耦 + 动态调整 + 热更新",让业务参数调整更灵活,无需重启服务。


网关动态路由

要理解网关动态路由的实现,我们可以从「为什么需要动态路由」「核心实现思路」「具体步骤与代码逻辑」「效果验证」四个维度拆解:

一、为什么需要动态路由?

网关的路由默认是启动时加载到内存,之后不会自动更新。如果要修改路由(比如新增服务、调整路径匹配),传统方式得重启网关------这在生产环境会导致服务短暂不可用,非常不灵活。

因此,需要动态路由:修改Nacos配置后,网关自动感知并更新路由,无需重启。

二、核心实现思路

要实现"动态路由",需完成两件事:

  1. 监听Nacos配置变更 :当Nacos里的路由配置(如gateway-routes.json)修改时,网关能及时感知。

  2. 更新网关路由表:把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 方法。
  • 首次拉取配置后,立即调用 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配置 + 同步更新网关路由",实现了动态路由:

  1. 服务启动时,主动拉取Nacos路由配置,初始化网关路由。

  2. 运行过程中,Nacos路由配置一旦变化,监听器立即感知,触发 updateConfigInfo 更新路由。

  3. 通过"先删旧路由,再添新路由"的逻辑,保证网关路由与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" } // 其他服务的路由... ]

四、效果验证:动态路由生效

  1. 初始状态 :网关启动后,application.yaml没有路由配置 → 访问/search/list会返回404。

  2. Nacos配置路由后 :无需重启网关,DynamicRouteLoader监听到Nacos配置变更,自动更新路由表。

  3. 再次访问/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); } }

五、整体流程总结

  1. 用户登录 :前端调用 http://网关IP/user/login → 网关转发到user-service → 生成JWT返回给前端。

  2. 发起需登录请求 :前端携带Token(请求头 Authorization=Token)调用 http://网关IP/order/create → 网关校验Token解析用户ID → 存入请求头 X-User-Id 转发到order-service。

  3. 下游服务处理 :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 中,用 @BeanItemClientFallback 交给Spring管理:

    复制代码

    @Bean public ItemClientFallback itemClientFallback() { return new ItemClientFallback(); }

  • FeignClient指定降级类 :在 ItemClient 接口上,通过 @FeignClientfallbackFactory 配置降级逻辑:

    复制代码

    @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 个跨服务操作(各自有独立数据库):

  1. 订单服务:创建订单记录(写订单库);

  2. 库存服务:扣减商品库存(写库存库);

  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 后,流程变成:

  1. 订单服务(TM)开启全局事务,透传 XID 给购物车、库存、订单服务;

  2. 购物车服务(RM)执行"清空购物车"(本地事务),并向 TC 注册;

  3. 库存服务(RM)执行"扣减库存"失败,向 TC 报告失败;

  4. TC 发现有 RM 失败,通知所有 RM 回滚;

  5. 购物车服务(RM)回滚"清空购物车"操作(购物车数据恢复),订单服务回滚"创建订单"操作,库存服务回滚"扣减库存"操作;

  6. 最终所有数据一致:购物车没被清空,订单没创建,库存没扣减。

总结:三角色的核心价值

  • TM:定义"哪些操作属于同一个全局事务",并发起全局提交/回滚。

  • RM:封装每个微服务的本地事务,向 TC 汇报状态,执行最终提交/回滚。

  • TC:全局事务的"大脑",汇总所有分支事务结果,决定全局提交或回滚,保证数据最终一致。

三者协作,让跨服务、跨数据库的操作像"单体事务"一样可靠,解决了分布式场景下的数据一致性问题。


微服务集成Seata的全过程

要理解微服务集成Seata的全过程,我们可以从"为什么做(解决分布式事务)→ 怎么做(分步骤落地)→ 效果如何(事务一致性保障)"的逻辑展开:

一、核心目的:让跨服务的业务满足"事务一致性"

下单业务涉及多个微服务(交易、购物车、商品)和多个数据库,传统@Transactional(本地事务)无法保证"所有操作同时成功/失败"。Seata通过全局事务协调,让跨服务的操作像"单体事务"一样可靠。

二、分步讲解集成过程

1. 引入依赖:给微服务"装Seata的通信能力"

在每个参与分布式事务的微服务(如trade-service)中,引入3类依赖:

  • nacos-config:让微服务能从Nacos拉取共享配置(避免每个服务重复写Seata配置);

  • bootstrap:优先加载Nacos配置(bootstrap.yamlapplication.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-tradehm-carthm-item)。

5. 标记全局事务入口:告诉Seata"从哪开始管事务"

把业务方法上的@Transactional(Spring本地事务),替换为Seata的@GlobalTransactional

  • @Transactional:只能管当前微服务内的数据库操作;

  • @GlobalTransactional:标记"全局事务的起点",Seata会生成全局事务ID(XID),并在调用其他微服务时,把XID传递下去,让所有相关操作都属于同一个"全局事务"。

复制代码

// 原@Transactional只能管本地事务,替换为Seata的全局事务注解 @GlobalTransactional public Long createOrder(OrderFormDTO orderFormDTO) { // 下单逻辑:创建订单、清购物车、扣库存(跨服务操作) }

6. 测试:验证"跨服务事务一致性"

重启trade-serviceitem-servicecart-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指令提交/回滚。 |

具体交互流程(对应架构图):
  1. TM开启全局事务 :在业务入口方法(如createOrder)上标注@GlobalTransactional,TM向TC申请XID(全局事务唯一标识),并调用各微服务(分支事务)。

  2. RM注册+执行本地事务(不提交):每个微服务(如订单、库存服务)的RM,会:

    1. 把本地事务"注册"到TC(关联XID);

    2. 执行业务SQL(如"创建订单""扣库存"),但不提交(持有数据库锁);

    3. 向TC报告"执行状态(成功/失败)"。

  3. TC决策全局结果:TC收集所有RM的状态:

    1. 若全成功:通知所有RM"提交事务";

    2. 若有失败:通知所有RM"回滚事务"。

  4. 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

阶段一:执行本地事务 + 记录"数据快照"
  1. 注册分支事务:TM(事务管理器,如"下单服务")发起全局事务,RM(资源管理器,如"账户服务")向TC(事务协调者)注册"扣减余额"这个分支事务。

  2. 记录undo log(数据快照) :RM拦截业务SQL,先查询修改前的数据,生成"快照"并存入undo_log表。此时快照是:{"id":1, "money":100}

  3. 执行业务SQL并提交本地事务 :执行update语句,余额从100变为90,然后立即提交本地事务(和XA的"准备但不提交"不同!AT一阶段就提交,数据库锁会立即释放,性能更好)。

  4. 报告状态给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-serviceuser-servicetrade-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-servicetrade-service,若其中一个服务临时不可用,会导致支付失败。可引入消息队列(如RocketMQ):

  • 支付成功后,发送"订单已支付"消息;

  • trade-service订阅消息,异步更新订单状态;

  • 即使trade-service临时故障,消息可重试,不影响支付主流程,提高系统容错性。

4. 超时与降级:避免雪崩

分布式事务中,若某个服务响应极慢,会拖垮整个支付请求。可结合Sentinel:

  • userClient.deductMoneytradeClient.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 连接 :在 publisherapplication.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 类似,在 consumerapplication.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 服务后,运行 publishertestWorkQueue 方法,会发现:

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 模型的关键逻辑:

  1. 多消费者共享队列:多个消费者可以绑定同一个队列,同一条消息只会被一个消费者处理(避免重复处理);

  2. "能者多劳"的分配策略 :通过 prefetch: 1 配置,控制消费者"每次预取的消息数量",让处理快的消费者承担更多任务,最大化资源利用率,解决消息积压问题。

这样的设计,既利用了多消费者的并行能力,又能根据消费者的"处理效率"智能分配任务,非常适合"消息处理耗时且生产快"的场景~


RabbitMQ 交换机(Exchange) 及 Fanout 交换机

我们来详细讲解 RabbitMQ 交换机(Exchange) 及 Fanout 交换机的使用,结合代码逻辑和工作流程,让知识点更清晰:

一、交换机的核心作用

在之前的案例中,生产者直接将消息发送到队列。但实际场景中,消息的路由和分发需要更灵活的控制,因此引入了交换机(Exchange)。

交换机的核心角色:

  1. 接收消息:生产者不再直接发消息到队列,而是将消息发送给交换机;

  2. 路由消息:根据自身类型和规则,将消息转发到与之绑定的队列(如何转发由交换机类型决定);

  3. 不存储消息:交换机本身不缓存消息。如果没有队列绑定到交换机,或没有符合路由规则的队列,消息会直接丢失。

二、交换机的四种类型

RabbitMQ 有四种交换机类型,我们重点学习前三种:

  • Fanout:广播模式,将消息转发给所有绑定的队列;

  • Direct:定向模式,基于「路由键(RoutingKey)」转发给匹配的队列;

  • Topic:通配符模式,基于带通配符的路由键转发;

  • Headers:头匹配模式,基于消息头信息转发(较少使用)。

三、Fanout 交换机(广播模式)

Fanout 交换机的核心逻辑是"广播":将消息无条件转发给所有绑定到它的队列,忽略路由键。

1. 工作流程(结合案例)

我们通过一个案例理解 Fanout 交换机的工作流程:

  • 创建 1 个 Fanout 交换机(hmall.fanout);

  • 创建 2 个队列(fanout.queue1fanout.queue2),并绑定到该交换机;

  • 生产者发送消息到 hmall.fanout 交换机;

  • 交换机会将消息同时转发到 fanout.queue1fanout.queue2

  • 两个队列的消费者分别接收并处理消息。

流程示意图:

复制代码

生产者 → 交换机(hmall.fanout) → 队列1(fanout.queue1) → 消费者1 → 队列2(fanout.queue2) → 消费者2

2. 代码实现步骤
步骤 1:声明交换机和队列(控制台操作)
  • 创建队列 :在 RabbitMQ 控制台创建两个队列 fanout.queue1fanout.queue2(无需特殊配置);

  • 创建交换机 :创建交换机 hmall.fanout,类型选择「Fanout」;

  • 绑定队列 :将两个队列分别绑定到 hmall.fanout 交换机(Fanout 绑定无需设置路由键,直接绑定即可)。

步骤 2:消息发送(publisher 服务)

publisherSpringAmqpTest 中添加发送消息到 Fanout 交换机的方法:

复制代码

@Test public void testFanoutExchange() { // 交换机名称(必须与控制台创建的一致) String exchangeName = "hmall.fanout"; // 要发送的消息 String message = "hello, everyone!"; // 发送消息到交换机:参数分别为「交换机名、路由键、消息」 // Fanout 交换机忽略路由键,因此第二个参数传空字符串 rabbitTemplate.convertAndSend(exchangeName, "", message); }

关键说明

Fanout 交换机的路由规则是"广播",不依赖路由键,因此 convertAndSend 方法的第二个参数(路由键)可以为空。

步骤 3:消息接收(consumer 服务)

consumerSpringRabbitListener 中添加两个消费者,分别监听两个队列:

复制代码

// 监听 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 交换机总结

  1. 核心特点

    1. 广播消息到所有绑定的队列,不处理路由键;

    2. 只要队列绑定到交换机,就一定会收到消息。

  2. 适用场景:需要"一对多"通知的场景(例如:订单支付成功后,同时通知物流、积分、短信服务)。

  3. 交换机的通用作用

    1. 接收生产者的消息;

    2. 按规则路由到绑定的队列;

    3. 不存储消息,路由失败则消息丢失。

通过 Fanout 交换机,我们实现了消息的"广播式分发",解决了"一个消息需要被多个消费者处理"的场景,相比直接操作队列,灵活性大大提升。


Direct 交换机

要清晰讲解 Direct 交换机,我们可以从「场景需求」→「核心逻辑」→「案例实现」→「与 Fanout 的对比」逐步展开,结合代码和运行效果理解:

一、Direct 交换机的场景需求

Fanout 交换机是"广播模式":只要队列绑定了交换机,所有队列都会收到消息。但实际业务中,我们常需要「特定消息只被特定队列消费」(比如"红色警报"消息给应急队列,"蓝色通知"给普通队列)。

此时需要 Direct 交换机,它的核心是 "基于 RoutingKey(路由键)的精确匹配"。

二、Direct 交换机的核心逻辑

Direct 交换机的工作规则:

  1. 队列与交换机绑定需指定 RoutingKey:队列和交换机绑定时,要明确"只有携带某个 RoutingKey 的消息,才能被转发到我这里";

  2. 生产者发送消息需指定 RoutingKey:生产者发消息时,必须携带 RoutingKey,告诉交换机"这条消息要发给谁";

  3. 交换机按 RoutingKey 精准转发:交换机只会把消息,转发给「绑定的 RoutingKey 与消息的 RoutingKey 完全一致」的队列。

三、案例实现(代码 + 操作)

案例需求:

  • 声明 Direct 交换机 hmall.direct

  • 队列 direct.queue1 绑定交换机,RoutingKey 为 bluered;(同一个队列可以有多个RoutingKey)

  • 队列 direct.queue2 绑定交换机,RoutingKey 为 yellowred

  • 生产者发送不同 RoutingKey 的消息,观察队列接收情况。

步骤 1:RabbitMQ 控制台操作(声明交换机、队列、绑定)
  • 创建队列 :在控制台新建两个队列 direct.queue1direct.queue2

  • 创建 Direct 交换机 :新建交换机 hmall.direct,类型选择「Direct」;

  • 绑定队列与交换机

    • direct.queue1hmall.direct 绑定,RoutingKey 设为 bluered

    • direct.queue2hmall.direct 绑定,RoutingKey 设为 yellowred

步骤 2:消息接收(consumer 服务代码)

consumerSpringRabbitListener 类中,编写两个消费者方法,分别监听两个队列:

复制代码

// 监听 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 服务代码)

publisherSpringAmqpTest 类中,编写测试方法,指定不同的 RoutingKey

测试 1:发送 RoutingKey = red 的消息
复制代码

@Test public void testSendDirectExchange() { String exchangeName = "hmall.direct"; // 交换机名称 String message = "红色警报!日本乱排核废水,导致海洋生物变异,惊现哥斯拉!"; // 发送消息:参数为「交换机名、RoutingKey、消息」 rabbitTemplate.convertAndSend(exchangeName, "red", message); }

结果 :两个消费者都收到消息(因为 direct.queue1direct.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.newschina.weather,甚至 china.city.beijing);

  • *:匹配 恰好一个词(例:item.* 只能匹配 item.spu,无法匹配 itemitem.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 接口及实现类:对应不同类型的交换机(如 FanoutExchangeDirectExchangeTopicExchange);

  • 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 要绑定 redblue 两个 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 = "..."):声明交换机,并指定类型(DirectFanoutTopic 等);

    • 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 序列化会包含大量额外元数据,导致消息体积膨胀;

  • 安全隐患:存在反序列化漏洞风险;

  • 可读性差:序列化后的字节是二进制,无法直接阅读。

测试步骤:
  1. 声明测试队列 :在 consumer 服务中,通过 @Configuration 类声明队列 object.queue

    复制代码

    @Configuration public class MessageConfig { @Bean public Queue objectQueue() { return new Queue("object.queue"); } }

  2. 发送 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); }

  3. 查看默认序列化结果 :到 RabbitMQ 控制台查看 object.queue 的消息,会发现:

    1. content_typeapplication/x-java-serialized-object

    2. 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

publisherconsumer 的启动类中,添加 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 消息。

代码改造(PayOrderServiceImpltryPayOrderByBalance 方法):
复制代码

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 包裹,避免"消息发送失败"导致整个支付事务回滚(支付本身已成功,消息失败属于"次要异常",记录日志后不阻断主流程)。

五、改造的核心价值

  1. 解耦:支付服务无需依赖交易服务的接口,只需"发消息";交易服务只需"收消息",双方依赖关系弱化。

  2. 性能提升:支付服务发完消息立即返回,无需等待交易服务处理完成,响应速度更快。

  3. 容错性增强:若交易服务暂时故障,消息会暂存于 MQ,待服务恢复后自动消费,避免了"同步调用时服务故障导致的级联失败"。

通过 RabbitMQ 实现异步通知后,支付与交易服务的协作更灵活、稳定,也更符合分布式系统的"高内聚、低耦合"设计原则。


练习

5.1 抽取共享的MQ配置

在微服务架构中,多个服务(如支付、交易、购物车)都需连接 RabbitMQ,若每个服务重复配置 hostport 等信息,会导致配置冗余且修改不便(如 MQ 地址变更需逐个修改服务配置)。

解决方案:利用 Nacos 配置中心的「共享配置」能力,集中管理 MQ 配置:

  1. Nacos 中创建共享配置 :新建配置文件(如 mq-shared.yaml),写入统一的 RabbitMQ 配置:

    复制代码

    spring: rabbitmq: host: 192.168.150.101 port: 5672 virtual-host: /hmall username: hmall password: 123

  2. 微服务引用共享配置 :每个需用 MQ 的服务,在 bootstrap.yml 中配置"共享配置"的 dataIdgroup,实现配置统一拉取、动态更新:

    复制代码

    spring: cloud: nacos: config: shared-configs: - data-id: mq-shared.yaml group: DEFAULT_GROUP refresh: true # 支持配置动态刷新

5.2 改造下单功能为 MQ 异步通知

原有逻辑是「下单服务同步调用购物车服务的"清理购物车"接口」,存在耦合度高、性能差(下单需等待清理完成才能返回)的问题。改为 MQ 异步通知后,流程解耦:

核心步骤:
  1. 声明 Topic 交换机与队列

    1. 交换机:trade.topic(Topic 类型,支持通配符路由,适合"一类事件"的广播);

    2. 队列:cart.clear.queue(购物车服务专属队列);

    3. 绑定关系:队列绑定到 trade.topicbindingKeyorder.create(表示"下单创建"的消息会路由到该队列)。

  2. 下单服务发送消息

下单成功后,不再调用 Feign 接口,而是通过 RabbitTemplate 发送消息到 trade.topicroutingKeyorder.create,消息体包含「下单商品、登录用户信息」。

  1. 购物车服务消费消息

通过 @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. 开启确认机制(修改publisherapplication.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

  • DurabilityDurable(保证队列持久化);

  • Arguments 中配置 x-queue-modelazy

(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可靠性的两层保障

  1. 数据持久化:通过「交换机、队列、消息的持久化」+「生产者异步确认」,确保MQ重启后消息不丢失,且从生产者到MQ的过程可靠。

  2. 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服务为例)
  1. 声明"异常处理"的交换机、队列、绑定关系

    复制代码

    @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"); } }

  2. 效果

本地重试耗尽后,失败消息会被转发到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集群故障"等极端情况导致消息丢失。此时需兜底策略:主动查询业务状态,确保最终一致。

场景示例(支付服务与交易服务的订单状态同步)
  1. 正常流程:支付成功 → MQ发消息 → 交易服务更新订单为"已支付"。

  2. 兜底流程:若MQ消息丢失,交易服务通过定时任务定期查询支付状态,发现订单已支付则更新状态。

逻辑:
  • 定时任务每隔一段时间(如20秒),查询"未支付但可能已支付"的订单;

  • 调用支付服务接口,查询订单真实支付状态;

  • 若支付状态为"已支付",则更新订单状态,确保最终一致性。

总结:消费者可靠性的全链路保障

从"消息不丢(确认机制)"→"失败后优雅重试(本地重试+异常队列)"→"重复消费不犯错(幂等性)"→"极端情况兜底(定时查询)",层层递进保障消费者侧的消息可靠性。

以"支付服务与交易服务同步订单状态"为例:

  • MQ层面:生产者确认(确保消息到MQ)、消费者确认+本地重试(确保消息被处理);

  • 业务层面:幂等性(避免重复处理)、定时任务兜底(确保极端情况最终一致)。

通过技术+业务的组合策略,实现消费者侧消息的高可靠性。


死信交换机+TTL实现延迟消息

要理解RabbitMQ中"死信交换机+TTL实现延迟消息"的方案,需从「死信与死信交换机的概念」「延迟消息的实现流程」「方案局限性」三个维度拆解,结合业务场景和示例逐步分析:

一、死信与死信交换机:基础概念

1. 什么是「死信(Dead Letter)」?

队列中的消息,满足以下任一条件时,会成为死信("无效/待特殊处理的消息"):

  • 消费失败且不重新入队 :消费者用 basic.rejectbasic.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分钟取消"的逻辑:

  1. 消息内容:包含订单ID等关键信息(用于后续取消订单时查询)。

  2. TTL设置:expiration: 1800000(30分钟,单位为毫秒)。

  3. 路由配置:向order.ttl.exchange发送消息,指定RoutingKey为order.ttl(与order.ttl.exchangeorder.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-keyorder.cancel),因此:

死信会被自动投递到死信交换机dead.order.exchange,且携带的RoutingKey为order.cancel

六、死信路由到「最终消费队列」

dead.order.exchangeDirect类型交换机,会根据RoutingKey order.cancel,将消息路由到与之绑定的order.cancel.queue

七、消费者消费消息,执行「取消订单」逻辑

order.cancel.queue的消费者(如订单服务中的"取消订单消费者")接收到消息后,执行以下逻辑:

  1. 从消息中解析出订单ID

  2. 查询订单当前状态:

    1. 若订单已支付:直接跳过(通过"业务幂等性"保障,避免重复取消);

    2. 若订单未支付:执行"取消订单、释放商品库存"的业务逻辑。

流程总结(核心链路)

生产者发消息(带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-servicepom.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模块)

用户下单成功后,发送"延迟消息",触发后续"检测支付状态"的逻辑。修改OrderServiceImplcreateOrder方法:

复制代码

@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; } }

通过RabbitTemplateconvertAndSend方法,结合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-servicePayController中,实现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); } } }

三、流程总结(结合业务流程图)

  1. 下单环节 :用户创建订单后,trade-service向延迟交换机发送"携带订单ID、延迟10秒"的消息。

  2. 消息延迟:延迟交换机持有消息,等待10秒(实际业务为30分钟)。

  3. 消息投递:时间到后,消息通过路由键,路由到延迟队列。

  4. 消费处理OrderDelayMessageListener监听到消息 → 查询订单状态 → 调用pay-service查询支付单 → 已支付则标记订单,未支付则取消订单。

通过"延迟消息+Feign跨服务调用",既实现了"订单超时自动取消"的业务需求,又保证了服务间的解耦与代码可维护性。

注意:消息在延迟交换机中无法被取消,所以所有消息必须等延迟30分钟后放到延迟队列中等待消费。若用户下单和支付是一气呵成的,可以设置一个消息id和订单id的关联的缓存,当消费延迟队列时,先根据消息Id查询缓存,若查出已支付,则直接跳过后续业务逻辑。


作业5.1:取消订单------确保状态一致性与幂等性

一、需求拆解

核心目标:处理"超时未支付订单",完成 "订单状态改已关闭"+"恢复扣减的库存",且必须保证 幂等性(避免重复取消导致库存重复恢复、状态异常)。

关键约束:

  1. 仅"未支付"状态的订单可取消(假设订单状态:1=未支付,3=已关闭);

  2. 订单状态修改与库存恢复需原子性(用事务保证);

  3. 调用库存服务时需传递订单ID,供库存服务二次幂等判断。

二、实现逻辑
  1. 幂等前置判断:查询订单,若订单不存在或状态非"未支付",直接返回(避免重复操作);

  2. 本地事务控制:开启事务,先更新订单状态为"已关闭",再查询订单商品列表;

  3. 跨服务恢复库存:通过Feign调用库存服务,传递"订单ID+商品列表",恢复对应库存;

  4. 异常处理:若任一环节失败,事务回滚,避免数据不一致。

三、代码实现
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部分:

  1. Nacos共享MQ配置:抽离重复的MQ连接配置,所有服务复用;

  2. RabbitMqHelper工具类:封装"普通消息、延迟消息、带确认的消息"发送逻辑;

  3. 自动配置类:动态声明"错误交换机/队列",统一消费失败后的处理策略。

二、实现逻辑
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工具类,并配置消费者重试,确保:

  1. 核心消息(如支付通知)必达;

  2. 失败消息可重试,最终兜底到错误队列;

  3. 减少重复编码,统一可靠性策略。

二、实现逻辑

|-------|-------------|--------|--------------------------------|
| 服务 | 业务场景 | 消息类型 | 可靠性保障措施 |
| 交易服务 | 下单后发送超时检测消息 | 延迟消息 | 用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

整体总结

三个作业环环相扣,围绕"可靠性"和"复用性"设计:

  1. 取消订单:通过"幂等判断+事务"确保数据一致;

  2. MQ工具:通过"共享配置+工具类+自动配置"解决重复编码,统一错误处理;

  3. 业务改造:通过"工具类+重试机制"确保消息必达,核心业务用确认消息,非核心用普通消息,平衡性能与可靠性。


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 表(字段:idtitleprice)为例:

  • 正向索引的核心是"以文档(行)为中心":

    • id 建了索引(如B+树索引),根据 id 精确查询很快;

    • 但如果要执行 like '%手机%'(模糊搜索title),索引会失效,只能全表扫描:逐条遍历每一行,判断 title 是否包含"手机",效率极低。

结论:正向索引适合"精确匹配索引字段",但"模糊匹配非索引字段"时性能极差。

2. 倒排索引(ES的索引方式)

倒排索引的核心是"以词条(Term)为中心",解决"模糊搜索、部分匹配"的问题。涉及两个关键概念:

  • 文档(Document):要搜索的数据单元(如一条商品信息、一个网页)。

  • 词条(Term):对文档内容分词后,得到的"有意义的词语"(如"华为手机"可拆分为"华为"、"手机")。

倒排索引的创建流程:
  1. 分词:将每个文档的内容(如商品标题)用分词算法拆分,得到多个词条(比如"华为小米充电器"拆为"华为"、"小米"、"充电器")。

  2. 建立倒排索引表:以"词条"为索引,记录每个词条出现在哪些文档中(文档ID列表)。

比如 tb_goods 的倒排索引表大致如下:

|-----|------------|
| 词条 | 包含该词条的文档ID |
| 小米 | 1, 3, 4 |
| 手机 | 1, 2 |
| 华为 | 2, 3 |
| 充电器 | 3 |
| 手环 | 4 |

倒排索引的搜索流程(以搜索"华为手机"为例):
  1. 分词搜索条件:将"华为手机"拆分为词条"华为"、"手机"。

  2. 查倒排索引表 :分别找"华为"和"手机"对应的文档ID------"华为"对应 [2,3],"手机"对应 [1,2]

  3. 合并结果 :取两个文档ID的交集(或并集,依业务需求),得到符合条件的文档ID(如 2)。

  4. 查正向索引 :根据文档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

三、再举一例:搜索"小米"

用户搜索"小米",流程更简单:

  1. 分词:"小米"本身就是一个词条。

  2. 查倒排索引表:"小米"对应的文档ID是 [1, 3, 4]

  3. 查正向索引:获取文档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:离线安装(网速较差时)
  1. 查看ES插件的宿主机挂载目录:

    复制代码

    docker volume inspect es-plugins

  2. 上传并解压IK分词器压缩包到该目录:

    1. 下载与ES版本匹配的IK分词器(如elasticsearch-analysis-ik-7.12.1.zip);

    2. 解压到插件目录;

    3. 重启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默认词典中,导致分词错误。此时需要拓展词典(添加新词)或停用词典(过滤"的""了"等无意义词)。

步骤:拓展词典(以"传智播客""泰裤辣"为例)
  1. 进入IK分词器的config目录(Docker环境下,路径为插件挂载目录/ik/config)。

  2. 修改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>

  3. config目录下新建ext.dic文件,添加要拓展的词条:

    复制代码

    传智播客 泰裤辣

  4. 重启ES容器:docker restart es

  5. 测试分词,此时"传智播客""泰裤辣"会被正确拆分。

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包含firstNamelastName)。

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 | 精确字符串(姓) | ✔️ | ❌ | --- |

  • infotext类型,用IK分词器分词,因此能被"模糊搜索"(如搜"程序员"能命中);

  • emailkeyword类型,不分词、不索引,因此无法被搜索,仅用于存储;

  • nameobject类型,内部嵌套firstNamelastName两个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:请求方法,代表"删除"。
相关推荐
星光一影16 小时前
基于Spring Boot电子签平台,实名认证+CA证书
大数据·spring boot·开源·vue·html5
C++业余爱好者16 小时前
.NET线程池ThreadPool.QueueUserWorkItem
java·数据库·.net
.豆鲨包16 小时前
【Android】Android内存缓存LruCache与DiskLruCache的使用及实现原理
android·java·缓存
superlls16 小时前
(Java基础)集合框架继承体系
java·开发语言
云半S一16 小时前
春招准备之MyBatis框架篇
经验分享·笔记·mybatis
宋哈哈16 小时前
页面水印sdk源码
java·前端·javascript
你不是我我17 小时前
【Java 开发日记】我们来说一下 Mybatis 的缓存机制
java·spring·mybatis
咪咪渝粮17 小时前
112.路径总和
java·数据结构·算法
WKP941817 小时前
原型设计模式
java·设计模式
笃行客从不躺平17 小时前
SQL 注入复习
java·数据库·sql