测试 小明今天15岁了，它是天水小学上学。
Mybatis 快速创建 创建springboot工程（Spring Initializr），并导入 mybatis的起步依赖、mysql的驱动包。创建用户表user，并创建对应的实体类User 在springboot项目中，可以编写main/resources/application.properties文件，配置数据库连接信息。 #驱动类名称 spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver #数据库连接的url spring.datasource.url=jdbc:mysql://localhost:3306/mybatis #连接数据库的用户名 spring.datasource.username=root #连接数据库的密码 spring.datasource.password=1234 在引导类所在包下，在创建一个包 mapper。在mapper包下创建一个接口 UserMapper @Mapper注解：表示是mybatis中的Mapper接口 ​ -程序运行时：框架会自动生成接口的实现类对象(代理对象)，并交给Spring的IOC容器管理 @Select注解：代表的就是select查询，用于书写select查询语句 @Mapper public interface UserMapper { //查询所有用户数据 @Select("select * from user") public List<User> list(); } 数据库连接池 数据库连接池是一个容器，负责管理和分配数据库连接（Connection）。 在程序启动时，连接池会创建一定数量的数据库连接。 客户端在执行 SQL 时，从连接池获取连接对象，执行完 SQL 后，将连接归还给连接池，以供其他客户端复用。 如果连接对象长时间空闲且超过预设的最大空闲时间，连接池会自动释放该连接。 优势：避免频繁创建和销毁连接，提高数据库访问效率。 Druid（德鲁伊） Druid连接池是阿里巴巴开源的数据库连接池项目 功能强大，性能优秀，是Java语言最好的数据库连接池之一 把默认的 Hikari 数据库连接池切换为 Druid 数据库连接池： 在pom.xml文件中引入依赖 <dependency> <groupId>com.alibaba</groupId> <artifactId>druid-spring-boot-starter</artifactId> <version>1.2.8</version> </dependency> 在application.properties中引入数据库连接配置 spring.datasource.druid.driver-class-name=com.mysql.cj.jdbc.Driver spring.datasource.druid.url=jdbc:mysql://localhost:3306/mybatis spring.datasource.druid.username=root spring.datasource.druid.password=123456 SQL注入问题$和# SQL注入：由于没有对用户输入进行充分检查，而SQL又是拼接而成，在用户输入参数时，在参数中添加一些SQL关键字，达到改变SQL运行结果的目的，也可以完成恶意攻击。 在Mybatis中提供的参数占位符有两种：${...} 、#{...} #{...} 执行SQL时，会将#{…}替换为?，生成预编译SQL，会自动设置参数值 使用时机：参数传递，都使用#{…} ${...} 拼接SQL。直接将参数拼接在SQL语句中，存在SQL注入问题 使用时机：如果对表名、列表进行动态设置时使用 SELECT * FROM ${tableName} WHERE id = #{id} userMapper.selectFromDynamicTable("user_2025", 1); 驼峰命名法 在 Java 项目中，数据库表字段名一般使用 下划线命名法（snake_case），而 Java 中的变量名使用 驼峰命名法（camelCase）。 小驼峰命名（lowerCamelCase）： 第一个单词的首字母小写，后续单词的首字母大写。 例子：firstName, userName, myVariable 大驼峰命名（UpperCamelCase）： 每个单词的首字母都大写，通常用于类名或类型名。 例子：MyClass, EmployeeData, OrderDetails 表中查询的数据封装到实体类中 实体类属性名和数据库表查询返回的字段名一致，mybatis会自动封装。 如果实体类属性名和数据库表查询返回的字段名不一致，不能自动封装。 解决方法： 起别名 结果映射 开启驼峰命名 属性名和表中字段名保持一致 开启驼峰命名(推荐)：如果字段名与属性名符合驼峰命名规则，mybatis会自动通过驼峰命名规则映射 驼峰命名规则： abc_xyz => abcXyz 表中字段名：abc_xyz 类中属性名：abcXyz 增删改 增删改通用！：返回值为int时，表示影响的记录数，一般不需要可以设置为void！ 作用于单个字段 @Mapper public interface EmpMapper { //SQL语句中的id值不能写成固定数值，需要变为动态的数值 //解决方案：在delete方法中添加一个参数(用户id)，将方法中的参数，传给SQL语句 /** * 根据id删除数据 * @param id 用户id */ @Delete("delete from emp where id = #{id}")//使用#{key}方式获取方法中的参数值 public void delete(Integer id); } 上图参数值分离，有效防止SQL注入 作用于多个字段 @Mapper public interface EmpMapper { //会自动将生成的主键值，赋值给emp对象的id属性 @Options(useGeneratedKeys = true,keyProperty = "id") @Insert("insert into emp(username, name, gender, image, job, entrydate, dept_id, create_time, update_time) values (#{username}, #{name}, #{gender}, #{image}, #{job}, #{entrydate}, #{deptId}, #{createTime}, #{updateTime})") public void insert(Emp emp); } 在 @Insert 注解中使用 #{} 来引用 Emp 对象的属性，MyBatis 会自动从 Emp 对象中提取相应的字段并绑定到 SQL 语句中的占位符。 @Options(useGeneratedKeys = true, keyProperty = "id") 这行配置表示，插入时自动生成的主键会赋值给 Emp 对象的 id 属性。 // 调用 mapper 执行插入操作 empMapper.insert(emp); // 现在 emp 对象的 id 属性会被自动设置为数据库生成的主键值 System.out.println("Generated ID: " + emp.getId()); 查 查询案例： 姓名：要求支持模糊匹配 性别：要求精确匹配 入职时间：要求进行范围查询 根据最后修改时间进行降序排序 重点在于模糊查询时where name like '%#{name}%' 会报错。 为什么？ where name like '%#{name}%' MyBatis 会先解析 #{name}，并用 ? 替换： where name like '%?%' 于是 SQL 就变成了一个 非法语法，数据库执行时会报错。 解决方案： 使用MySQL提供的字符串拼接函数：concat('%' , '关键字' , '%') CONCAT() 如果其中任何一个参数为 NULL，CONCAT() 返回 NULL，Like NULL会导致查询不到任何结果！ NULL和''是完全不同的 当 #{name} = '张三' → 结果是 '%张三%'，能正常匹配。 当 #{name} = ''（空字符串） → 结果是 '%%'，等价于 %，会匹配所有字符串。 当 #{name} = NULL → 结果是 NULL，SQL 变成： @Mapper public interface EmpMapper { @Select("select * from emp " + "where name like concat('%',#{name},'%') " + "and gender = #{gender} " + "and entrydate between #{begin} and #{end} " + "order by update_time desc") public List<Emp> list(String name, Short gender, LocalDate begin, LocalDate end); } 为了避免无意义查询，如果name == null或name=='' 就不要拼接like 条件，后面动态SQL会做优化。 XML配置文件规范 使用Mybatis的注解方式，主要是来完成一些简单的增删改查功能。如果需要实现复杂的SQL功能，建议使用XML来配置映射语句，也就是将SQL语句写在XML配置文件中。 在Mybatis中使用XML映射文件方式开发，需要符合一定的规范： XML映射文件的namespace属性为Mapper接口全限定名一致 XML映射文件中sql语句的id与Mapper接口中的方法名一致，并保持返回类型一致。 XML映射文件的名称与Mapper接口名称一致，并且将XML映射文件和Mapper接口放置在相同包下（非必须） 标签：就是用于编写select查询语句的。 resultType属性，指的是查询返回的单条记录所封装的类型(查询必须)。 parameterType属性（可选，MyBatis 会根据接口方法的入参类型（比如 Dish 或 DishPageQueryDTO）自动推断），POJO作为入参，需要使用全类名或是type‑aliases‑package: com.sky.entity 下注册的别名。 <insert id="insert" useGeneratedKeys="true" keyProperty="id"> 实现过程： resources下创与java下一样的包，即edu/whut/mapper，新建xx.xml文件 配置Mapper文件 <?xml version="1.0" encoding="UTF-8" ?> <mapper namespace="edu.whut.mapper.EmpMapper"> </mapper> namespace 属性指定了 Mapper 接口的全限定名（即包名 + 类名）。 编写查询语句 SELECT id, name, gender, entrydate, update_time FROM emp WHERE name = #{name} id="list"：指定查询方法的名称，应该与 Mapper 接口中的方法名称一致。 resultType="edu.whut.pojo.Emp"：resultType 只在 查询操作 中需要指定。指定查询结果映射的对象类型，这里是 Emp 类。 推荐的完整配置 mybatis: #mapper配置文件 mapper-locations: classpath:mapper/*.xml type-aliases-package: com.sky.entity configuration: #开启驼峰命名 map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl type-aliases-package: com.sky.entity把 com.sky.entity 包下的所有类都当作别名注册，XML 里就可以直接写 <resultType="Dish"> 而不用写全限定名。可以多添加几个包，用逗号隔开。 log-impl:org.apache.ibatis.logging.stdout.StdOutImpl只建议开发环境使用：在Mybatis当中我们可以借助日志，查看到sql语句的执行、执行传递的参数以及执行结果 map-underscore-to-camel-case: true 如果都是简单字段，开启之后XML 中不用写 <resultMap>： <resultMap id="dataMap" type="edu.whut.infrastructure.dao.po.PayOrder"> <id column="id" property="id"/> <result column="user_id" property="userId"/> <result column="product_id" property="productId"/> <result column="product_name" property="productName"/> </resultMap> 动态SQL SQL-if,where <if>：用于判断条件是否成立。使用test属性进行条件判断，如果条件为true，则拼接SQL。 <if test="条件表达式"> 要拼接的sql语句 </if> <where>只会在子元素有内容的情况下才插入where子句，而且会自动去除子句的开头的AND或OR,加了总比不加好 select * from emp <where> <if test="name != null"> and name like concat('%',#{name},'%') </if> <if test="gender != null"> and gender = #{gender} </if> <if test="begin != null and end != null"> and entrydate between #{begin} and #{end} </if> </where> order by update_time desc 不加判空条件时 如果 name == null，大多数数据库里 CONCAT('%', NULL, '%') 会返回 NULL，于是条件变成了 WHERE name LIKE NULL ，不会匹配任何行。 如果 name == ""（空串），CONCAT('%','', '%') 得到 "%%"，name LIKE '%%' 对所有非null name 都成立，相当于“不过滤”这段条件，不影响结果，因此可以不判断空串。 加了判空 <if> 之后 <where> <if test="name != null and name != ''"> AND name LIKE CONCAT('%', #{name}, '%') </if> </where> 当 name 为 null 或 "" 时，这段 <if> 块不会被拼到最终的 SQL 里，等价于忽略了 name 这个过滤条件。 SQL-foreach Mapper 接口 @Mapper public interface EmpMapper { //批量删除 public void deleteByIds(@Param("ids") List<Integer> ids); } XML 映射文件 <foreach> 标签用于遍历集合，常用于动态生成 SQL 语句中的 IN 子句、批量插入、批量更新等操作。 <foreach collection="集合参数名" item="当前遍历项" index="当前索引(可选)" separator="每次遍历间的分隔符" open="遍历开始前拼接的片段" close="遍历结束后拼接的片段"> #{item} </foreach> open="("：这个属性表示，在生成的 SQL 语句开始时添加一个 左括号 (。 close=")"：这个属性表示，在生成的 SQL 语句结束时添加一个 右括号 )。 例：批量删除实现 <delete id="deleteByIds"> DELETE FROM emp WHERE id IN <foreach collection="ids" item="id" separator="," open="(" close=")"> #{id} </foreach> </delete> int deleteByIds(@Param("ids") List<Long> ids); #{id} 代表集合里的一个元素。item 里定义的是什么，就要在 #{} 里用相同的名字。 这里一定要加 @Param("ids")，这样 MyBatis 才知道这个集合对应 XML 里的 collection="ids"。 实现效果类似：DELETE FROM emp WHERE id IN (1, 2, 3); Mybatis-Plus MyBatis-Plus 的使命就是——在保留 MyBatis 灵活性的同时，大幅减少模板化、重复的代码编写，让增删改查、分页等常见场景“开箱即用”，以更少的配置、更少的样板文件、更高的开发效率，帮助团队快速交付高质量的数据库访问层。 快速开始 1.引入依赖 <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <!-- <dependency>--> <!-- <groupId>org.mybatis.spring.boot</groupId>--> <!-- <artifactId>mybatis-spring-boot-starter</artifactId>--> <!-- <version>2.3.1</version>--> <!-- </dependency>--> 由于这个starter包含对mybatis的自动装配，因此完全可以替换掉Mybatis的starter。 2.定义mapper 为了简化单表CRUD，MybatisPlus提供了一个基础的BaseMapper接口，其中已经实现了单表的CRUD(增删查改)： 仅需让自定义的UserMapper接口，继承BaseMapper接口： public interface UserMapper extends BaseMapper<User> { } 测试： @SpringBootTest class UserMapperTest { @Autowired private UserMapper userMapper; @Test void testInsert() { User user = new User(); user.setId(5L); user.setUsername("Lucy"); user.setPassword("123"); user.setPhone("18688990011"); user.setBalance(200); user.setInfo("{\"age\": 24, \"intro\": \"英文老师\", \"gender\": \"female\"}"); user.setCreateTime(LocalDateTime.now()); user.setUpdateTime(LocalDateTime.now()); userMapper.insert(user); } @Test void testSelectById() { User user = userMapper.selectById(5L); System.out.println("user = " + user); } @Test void testSelectByIds() { List<User> users = userMapper.selectBatchIds(List.of(1L, 2L, 3L, 4L, 5L)); users.forEach(System.out::println); } @Test void testUpdateById() { User user = new User(); user.setId(5L); user.setBalance(20000); userMapper.updateById(user); } @Test void testDelete() { userMapper.deleteById(5L); } } 3.常见注解 MybatisPlus如何知道我们要查询的是哪张表？表中有哪些字段呢？ 约定大于配置 泛型中的User就是与数据库对应的PO. MybatisPlus就是根据PO实体的信息来推断出表的信息，从而生成SQL的。默认情况下： MybatisPlus会把PO实体的类名驼峰转下划线作为表名 UserRecord->user_record MybatisPlus会把PO实体的所有变量名驼峰转下划线作为表的字段名，并根据变量类型推断字段类型 MybatisPlus会把名为id的字段作为主键 但很多情况下，默认的实现与实际场景不符，因此MybatisPlus提供了一些注解便于我们声明表信息。 @TableName 描述：表名注解，标识实体类对应的表 @TableId 描述：主键注解，标识实体类中的主键字段 TableId注解支持两个属性： 属性 类型 必须指定 默认值 描述 value String 否 "" 主键字段名 type Enum 否 IdType.NONE 指定主键类型 @TableName("user_detail") public class User { @TableId(value="id_dd",type=IdType.AUTO) private Long id; private String name; } 这个例子会，映射到数据库中的user_detail表，主键为id_dd，并且插入时采用数据库自增；能自动回写主键，相当于开启useGeneratedKeys=true，执行完 insert(user) 后，user.getId() 就会是数据库分配的主键值，否则默认获得null，但不影响数据表中的内容。 type=dType.ASSIGN_ID 表示用雪花算法生成密码，更加复杂，而不是简单的AUTO自增。它也能自动回写主键。 @TableField 普通字段注解 一般情况下我们并不需要给字段添加@TableField注解，一些特殊情况除外： 成员变量名与数据库字段名不一致 成员变量是以isXXX命名，按照JavaBean的规范，MybatisPlus识别字段时会把is去除，这就导致与数据库不符。 public class User { private Long id; private String name; private Boolean isActive; // 按 JavaBean 习惯，这里用 isActive，数据表是is_acitive，但MybatisPlus会识别为active } 成员变量名与数据库一致，但是与数据库的**关键字(如order)**冲突。 public class Order { private Long id; private Integer order; // 名字和 SQL 关键字冲突 } 默认MP会生成：SELECT id, order FROM order; 导致报错 一些字段不希望被映射到数据表中，不希望进行增删查改 解决办法： @TableField("is_active") private Boolean isActive; @TableField("`order`") //添加转义字符 private Integer order; @TableField(exist=false) //exist默认是true， private String address; 4.常用配置 大多数的配置都有默认值，因此我们都无需配置。但还有一些是没有默认值的，例如: 实体类的别名扫描包 全局id类型 要改也就改这两个即可 mybatis-plus: type-aliases-package: edu.whut.mp.domain.po global-config: db-config: id-type: auto # 全局id类型为自增长 作用：1.把edu.whut.mp.domain.po 包下的所有 PO 类注册为 MyBatis 的 Type Alias。这样在你的 Mapper XML 里就可以直接写 <resultType="User">（或 <parameterType="User">）而不用写全限定类名 edu.whut.mp.domain.po.User 2.无需在每个 @TableId 上都写 type = IdType.AUTO，统一由全局配置管。 核心功能 前面的例子都是根据主键id更新、修改、查询，无法支持复杂条件where。 条件构造器Wrapper 除了新增以外，修改、删除、查询的SQL语句都需要指定where条件。因此BaseMapper中提供的相关方法除了以id作为where条件以外，还支持更加复杂的where条件。 Wrapper就是条件构造的抽象类，其下有很多默认实现，继承关系如图： QueryWrapper 在AbstractWrapper的基础上拓展了一个select方法，允许指定查询字段，无论是修改、删除、查询，都可以使用QueryWrapper来构建查询条件。 select方法只需用于 查询 时指定所需的列，完整查询不需要，用于update和delete不需要。 QueryWrapper 里对 like、eq、ge 等方法都做了重载 QueryWrapper<User> qw = new QueryWrapper<>(); qw.like("name", name); //两参版本，第一个参数对应数据库中的列名，如果对应不上，就会报错！！！ qw.like(StrUtil.isNotBlank(name), "name", name); //三参，多一个boolean condition 参数 **例1：**查询出名字中带o的，存款大于等于1000元的人的id，username,info,balance: /** * SELECT id,username,info,balance * FROM user * WHERE username LIKE ? AND balance >=? */ @Test void testQueryWrapper(){ QueryWrapper<User> wrapper =new QueryWrapper<User>() .select("id","username","info","balance") .like("username","o") .ge("balance",1000); //查询 List<User> users=userMapper.selectList(wrapper); users.forEach(System.out::println); } UpdateWrapper 基于BaseMapper中的update方法更新时只能直接赋值，对于一些复杂的需求就难以实现。 例1： 例如：更新id为1,2,4的用户的余额，扣200，对应的SQL应该是： UPDATE user SET balance = balance - 200 WHERE id in (1, 2, 4) @Test void testUpdateWrapper() { List<Long> ids = List.of(1L, 2L, 4L); // 1.生成SQL UpdateWrapper<User> wrapper = new UpdateWrapper<User>() .setSql("balance = balance - 200") // SET balance = balance - 200 .in("id", ids); // WHERE id in (1, 2, 4) // 2.更新，注意第一个参数可以给null，告诉 MP：不要从实体里取任何字段值 // 而是基于UpdateWrapper中的setSQL来更新 userMapper.update(null, wrapper); } 例2： // 用 UpdateWrapper 拼 WHERE + SET UpdateWrapper<User> wrapper = new UpdateWrapper<User>() // WHERE status = 'ACTIVE' .eq("status", "ACTIVE") // SET balance = 2000, name = 'Alice' .set("balance", 2000) .set("name", "Alice"); // 把 entity 参数传 null，MyBatis-Plus 会只用 wrapper 里的 set/where userMapper.update(null, wrapper); LambdaQueryWrapper（推荐） 是QueryWrapper和UpdateWrapper的上位选择！！！ 传统的 QueryWrapper/UpdateWrapper 需要把数据库字段名写成字符串常量，既容易拼写出错，也无法在编译期校验。MyBatis-Plus 引入了两种基于 Lambda 的 Wrapper —— LambdaQueryWrapper 和 LambdaUpdateWrapper —— 通过传入实体类的 getter 方法引用，框架会自动解析并映射到对应的列，实现了类型安全和更高的可维护性。 // ——— 传统 QueryWrapper ——— public User findByUsername(String username) { QueryWrapper<User> qw = new QueryWrapper<>(); // 硬编码列名，拼写错了编译不过不了，会在运行时抛数据库异常 qw.eq("user_name", username); return userMapper.selectOne(qw); } // ——— LambdaQueryWrapper ——— public User findByUsername(String username) { // 内部已注入实体 Class 和元数据，方法引用自动解析列名 LambdaQueryWrapper<User> qw = Wrappers.lambdaQuery(User.class) .eq(User::getUserName, username); return userMapper.selectOne(qw); } 自定义sql 即自己编写Wrapper查询条件，再结合Mapper.xml编写SQL **例1：**以 UPDATE user SET balance = balance - 200 WHERE id in (1, 2, 4) 为例： 1）先在业务层利用wrapper创建条件，传递参数 @Test void testCustomWrapper() { // 1.准备自定义查询条件 List<Long> ids = List.of(1L, 2L, 4L); QueryWrapper<User> wrapper = new QueryWrapper<User>().in("id", ids); // 2.调用mapper的自定义方法，直接传递Wrapper userMapper.deductBalanceByIds(200, wrapper); } 2）自定义mapper层把wrapper和其他业务参数传进去，自定义sql语句书写sql的前半部分，后面拼接。 public interface UserMapper extends BaseMapper<User> { /** * 注意：更新要用 @Update * - #{money} 会被替换为方法第一个参数 200 * - ${ew.customSqlSegment} 会展开 wrapper 里的 WHERE 子句 */ @Update("UPDATE user " + "SET balance = balance - #{money} " + "${ew.customSqlSegment}") void deductBalanceByIds(@Param("money") int money, @Param("ew") QueryWrapper<User> wrapper); } @Param("ew")就是给这个方法参数在 MyBatis 的 SQL 映射里起一个别名—— ew ， Mapper 的注解或 XML 里，MyBatis 想要拿到这个参数，就用它的 @Param 名称——也就是 ew： @Param("ew")中ew是 MP 约定的别名！ ${ew.customSqlSegment} 可以自动拼接传入的条件语句 **例2：**查询出所有收货地址在北京的并且用户id在1、2、4之中的用户 普通mybatis： SELECT * FROM user u INNER JOIN address a ON u.id = a.user_id WHERE u.id <foreach collection="ids" separator="," item="id" open="IN (" close=")"> #{id} </foreach> AND a.city = #{city} mp方法： @Test void testCustomJoinWrapper() { // 1.准备自定义查询条件 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); } @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); Service层的常用方法 查询： selectById：根据主键 ID 查询单条记录。 selectBatchIds：根据主键 ID集合 批量查询记录。 selectOne：根据指定条件查询单条记录。 @Service public class UserService { @Autowired private UserMapper userMapper; public User findByUsername(String username) { // 查询 ID 为 1, 2, 3 的用户 List<Long> ids = Arrays.asList(1L, 2L, 3L); List<User> users = userMapper.selectBatchIds(ids); --------------分割线------------- QueryWrapper<User> queryWrapper = new QueryWrapper<>(); queryWrapper.eq("username", username); return userMapper.selectOne(queryWrapper); } } selectList：根据指定条件查询多条记录。 QueryWrapper<User> queryWrapper = new QueryWrapper<>(); queryWrapper.ge("age", 18); List<User> users = userMapper.selectList(queryWrapper); 插入： insert：插入一条记录。 User user = new User(); user.setUsername("alice"); user.setAge(20); int rows = userMapper.insert(user); 更新 updateById：根据主键 ID 更新记录。 User user = new User(); user.setId(1L); user.setAge(25); int rows = userMapper.updateById(user); update：根据指定条件更新记录。 UpdateWrapper<User> updateWrapper = new UpdateWrapper<>(); updateWrapper.eq("username", "alice"); User user = new User(); user.setAge(30); int rows = userMapper.update(user, updateWrapper); 删除操作 类似query deleteById：根据主键 ID 删除记录。 deleteBatchIds：根据主键 ID集合 批量删除记录。 delete：根据指定条件删除记录。 QueryWrapper<User> queryWrapper = new QueryWrapper<>(); queryWrapper.eq("username", "alice"); int rows = userMapper.delete(queryWrapper); IService 基本使用 由于Service中经常需要定义与业务有关的自定义方法，因此我们不能直接使用IService，而是自定义Service接口，然后继承IService以拓展方法。同时，让自定义的Service实现类继承ServiceImpl，这样就不用自己实现IService中的接口了。 首先，定义IUserService，继承IService： public interface IUserService extends IService<User> { // 拓展自定义方法 } 然后，编写UserServiceImpl类，继承ServiceImpl（通用实现类），实现UserService： @Service public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService { } Controller层中写： @RestController @RequestMapping("/users") @Slf4j @Api(tags = "用户管理接口") public class UserController { @Autowired private IUserService userService; @PostMapping @ApiOperation("新增用户接口") public void saveUser(@RequestBody UserFormDTO userFormDTO){ User user=new User(); BeanUtils.copyProperties(userFormDTO, user); userService.save(user); } @DeleteMapping("{id}") @ApiOperation("删除用户接口") public void deleteUserById(@PathVariable Long id){ userService.removeById(id); } @GetMapping("{id}") @ApiOperation("根据id查询接口") public UserVO queryUserById(@PathVariable Long id){ User user=userService.getById(id); UserVO userVO=new UserVO(); BeanUtils.copyProperties(user,userVO); return userVO; } @PutMapping("/{id}/deduction/{money}") @ApiOperation("根据id扣减余额") public void updateBalance(@PathVariable Long id,@PathVariable Long money){ userService.deductBalance(id,money); } } service层： @Service public class IUserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService { @Autowired private UserMapper userMapper; @Override public void deductBalance(Long id, Long money) { //1.查询用户 User user=getById(id); if(user==null || user.getStatus()==2){ throw new RuntimeException("用户状态异常！"); } //2.查验余额 if(user.getBalance()<money){ throw new RuntimeException("用户余额不足！"); } //3.扣除余额 update User set balance=balance-money where id=id userMapper.deductBalance(id,money); } } mapper层： @Mapper public interface UserMapper extends BaseMapper<User> { @Update("update user set balance=balance-#{money} where id=#{id}") void deductBalance(Long id, Long money); } 总结：如果是简单查询，如用id来查询、删除，可以直接在Controller层用Iservice方法，否则自定义业务层Service实现具体任务。 Service层的lambdaQuery IService中还提供了Lambda功能来简化我们的复杂查询及更新功能。 相当于「条件构造」和「执行方法」写在一起 this.lambdaQuery() = LambdaQueryWrapper + 内置的执行方法（如 .list()、.one()） // 返回 LambdaQueryChainWrapper，可以直接执行查询 List<User> users = userService.lambdaQuery() .eq(User::getUsername, "john") .eq(User::getStatus, 1) .list(); // 直接获取结果 或者先构建条件，后面再动态查询： // 只构建条件，不执行查询 LambdaQueryWrapper<User> wrapper = userService.lambdaQuery() .eq(User::getUsername, "john") .eq(User::getStatus, 1); // 后续可能添加更多条件 if (someCondition) { wrapper.like(User::getEmail, "example"); } // 在需要的时候才执行查询 List<User> users = userService.list(wrapper); 而Mapper 层的 lambdaQuery，只构造条件，不负责执行。法一： // 创建 LambdaQueryWrapper 对象 LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<User>() .eq(User::getUsername, "john") .eq(User::getStatus, 1); // 执行查询 List<User> users = userMapper.selectList(wrapper); 法二：Wrappers.lambdaQuery() // 方式1：使用 Wrappers.lambdaQuery() LambdaQueryWrapper<User> wrapper = Wrappers.lambdaQuery(User.class) .eq(User::getUsername, "john") .eq(User::getStatus, 1); 特性 lambdaQuery() lambdaUpdate() 主要用途 构造查询条件，执行 SELECT 操作 构造更新条件，执行 UPDATE（或逻辑删除）操作 支持的方法 .eq(), .like(), .gt(), .orderBy(), .select() 等 .eq(), .lt(), .set(), .setSql() 等 执行方法 .list(), .one(), .page() 等 .update(), .remove()（逻辑删除 **案例一：**实现一个根据复杂条件查询用户的接口，查询条件如下： name：用户名关键字，可以为空 status：用户状态，可以为空 minBalance：最小余额，可以为空 maxBalance：最大余额，可以为空 @GetMapping("/list") @ApiOperation("根据id集合查询用户") public List<UserVO> queryUsers(UserQuery query){ // 1.组织条件 String username = query.getName(); Integer status = query.getStatus(); Integer minBalance = query.getMinBalance(); Integer maxBalance = query.getMaxBalance(); // 2.查询用户 List<User> users = userService.lambdaQuery() .like(username != null, User::getUsername, username) .eq(status != null, User::getStatus, status) .ge(minBalance != null, User::getBalance, minBalance) .le(maxBalance != null, User::getBalance, maxBalance) .list(); // 3.处理vo return BeanUtil.copyToList(users, UserVO.class); } .eq(status != null, User::getStatus, status)，使用User::getStatus方法引用并不直接把'Status'插入到 SQL，而是在运行时会被 MyBatis-Plus 解析成实体属性 Status”对应的数据库列是 status。推荐!!! 可以发现lambdaQuery方法中除了可以构建条件，还需要在链式编程的最后添加一个list()，这是在告诉MP我们的调用结果需要是一个list集合。这里不仅可以用list()，可选的方法有： .one()：最多1个结果 .list()：返回集合结果 .count()：返回计数结果 MybatisPlus会根据链式编程的最后一个方法来判断最终的返回结果。 这里不够规范，业务写在controller层中了。 **案例二：**改造根据id修改用户余额的接口，如果扣减后余额为0，则将用户status修改为冻结状态（2） @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.扣减余额 update tb_user set balance = balance - ? int remainBalance = user.getBalance() - money; lambdaUpdate() //在service层中！！！相当于this.lambdaUpdate() .set(User::getBalance, remainBalance) // 更新余额 .set(remainBalance == 0, User::getStatus, 2) // 动态判断，是否更新status .eq(User::getId, id) .eq(User::getBalance, user.getBalance()) // 乐观锁 .update(); } 批量新增 每 batchSize 条记录作为一个 JDBC batch 提交一次（1000 条就一次） @Test void testSaveBatch() { // 准备10万条数据 List<User> list = new ArrayList<>(1000); long b = System.currentTimeMillis(); for (int i = 1; i <= 100000; i++) { list.add(buildUser(i)); // 每1000条批量插入一次 if (i % 1000 == 0) { userService.saveBatch(list); list.clear(); } } long e = System.currentTimeMillis(); System.out.println("耗时：" + (e - b)); } 之所以把 100 000 条记录分成每 1 000 条一批来插，是为了兼顾 性能、内存 和 数据库／JDBC 限制。 JDBC 或数据库参数限制 很多数据库（MySQL、Oracle 等）对单条 SQL 里 VALUES 列表的长度有上限，一次性插入几十万行可能导致 SQL 过长、参数个数过多，被驱动或数据库拒绝。 即使驱动不直接报错，也可能因为网络包（packet）过大而失败。 内存占用和 GC 压力 JDBC 在执行 batch 时，会把所有要执行的 SQL 和参数暂存在客户端内存里。如果一次性缓存 100 000 条记录的参数（可能是几 MB 甚至十几 MB），容易触发 OOM 或者频繁 GC。 事务日志和回滚压力 一次性插入大量数据，数据库需要在事务日志里记录相应条目，回滚时也要一次性回滚所有操作，性能开销巨大。分批能让每次写入都较为“轻量”，回滚范围也更小。 但是这样拆分插入，本质上还是逐条插入，效率很低 <insert id="insertBatch"> <foreach collection="list" item="item"> INSERT INTO user (username, email, age) VALUES (#{item.username}, #{item.email}, #{item.age}); </foreach> </insert> 实际执行的SQL： INSERT INTO user (username, email, age) VALUES ('user1', 'user1@test.com', 20); INSERT INTO user (username, email, age) VALUES ('user2', 'user2@test.com', 21); INSERT INTO user (username, email, age) VALUES ('user3', 'user3@test.com', 22); -- ... 总共1000条独立的INSERT语句 而如果想要得到最佳性能，最好是将VALUES 多行： <insert id="insertBatch"> INSERT INTO user (username, email, age) VALUES <foreach collection="list" item="item" separator=","> (#{item.username}, #{item.email}, #{item.age}) </foreach> </insert> INSERT INTO user (username, email, age) VALUES ('user1', 'user1@test.com', 20), ('user2', 'user2@test.com', 21), ('user3', 'user3@test.com', 22), 需要修改项目中的application.yml文件，在jdbc的url后面添加参数&rewriteBatchedStatements=true： url: jdbc:mysql://127.0.0.1:3306/mp?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true 或者直接自定义批量SQL，不用mabatis-plus框架。 MQ分页 快速入门 1）引入依赖 <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.9</version> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-jsqlparser-4.9</artifactId> </dependency> 2）定义通用分页查询条件实体 @Data @ApiModel(description = "分页查询实体") public class PageQuery { @ApiModelProperty("页码") private Long pageNo; @ApiModelProperty("页码") private Long pageSize; @ApiModelProperty("排序字段") private String sortBy; @ApiModelProperty("是否升序") private Boolean isAsc; } 3）新建一个 UserQuery 类，让它继承自你已有的 PageQuery @Data @ApiModel(description = "用户分页查询实体") public class UserQuery extends PageQuery { @ApiModelProperty("用户名（模糊查询）") private String name; } 4）Service里使用 @Service public class UserService extends ServiceImpl<UserMapper, User> { /** * 用户分页查询（带用户名模糊 + 动态排序） * * @param query 包含 pageNo、pageSize、sortBy、isAsc、name 等字段 */ public Page<User> pageByQuery(UserQuery query) { // 1. 构造 Page 对象 Page<User> page = new Page<>( query.getPageNo(), query.getPageSize() ); // 2. 构造查询条件 LambdaQueryWrapper<User> qw = Wrappers.<User>lambdaQuery() // 当 name 非空时，加上 user_name LIKE '%name%' .like(StrUtil.isNotBlank(query.getName()), User::getUserName, query.getName()); // 3. 动态排序 if (StrUtil.isNotBlank(query.getSortBy())) { String column = StrUtil.toUnderlineCase(query.getSortBy()); boolean asc = Boolean.TRUE.equals(query.getIsAsc()); qw.last("ORDER BY " + column + (asc ? " ASC" : " DESC")); } // 4. 执行分页查询 return this.page(page, qw); } }
DDD领域驱动设计 什么是 DDD？ DDD（领域驱动设计，Domain-Driven Design）是一种软件设计方法论，它为软件工程设计提供了一套完整的指导思想与实践手段。通过领域、界限上下文、实体、值对象、聚合、工厂、仓储等概念，DDD 帮助我们合理划分工程模型，从而在前期投入更多思考，规划出可持续迭代和演进的系统架构。 在工程实践中，DDD 通常分为两个层面的设计： 1. 战略设计 战略设计关注复杂业务的宏观拆分。通过限界上下文和子域划分，将系统分治为独立模块或服务。拆分是否合理取决于上线效率：若每次改动牵涉多个服务，即是失败的“微服务单体”。更实用的方式是：以少数中等规模的核心应用为主体，构建周边服务生态，既保持灵活性，又避免过度拆分。 2. 战术设计 战术设计关注如何在代码层面表达业务概念。它强调通过面向对象建模，将业务逻辑封装进领域模型，并以实体、值对象、聚合和领域服务来承载业务行为，确保代码贴合业务语义。 传统的 MVC 三层架构往往只是 Service 层加数据模型的简单组合，容易导致 Service 类臃肿、逻辑复杂，甚至出现“贫血模型”问题——数据与行为分离，增加了维护难度。DDD 的战术设计通过丰富的领域模型来规避这一问题，使系统结构更清晰、业务逻辑更可维护。 为什么要用DDD？ 先说说传统Spring MVC： Spring MVC 传统上多采用 分层架构（Controller-Service-DAO）。 对于 简单业务 或 原型开发，这种方式足够清晰，开发成本低，上手快。 说说Spring MVC的不足： 在 复杂业务场景（核心逻辑复杂、规则频繁变化的系统）中，传统分层模式会暴露出明显问题： 1）业务逻辑分散： 大量 if-else、规则判断和外部调用混杂在 Service 中。 代码难以维护，稍有业务变更，就需要在已有方法里继续堆条件分支。 @Service public class OrderService { @Autowired private OrderRepository orderRepository; @Autowired private PaymentGateway paymentGateway; public void payOrder(Long orderId, String payMethod) { Order order = orderRepository.findById(orderId); // 校验订单 if (order == null) { throw new IllegalArgumentException("订单不存在"); } if (!order.getStatus().equals("UNPAID")) { throw new IllegalStateException("订单状态不允许支付"); } // 校验金额 if (order.getAmount().compareTo(BigDecimal.ZERO) <= 0) { throw new IllegalStateException("订单金额异常"); } // 支付逻辑 boolean success = paymentGateway.pay(order.getId(), order.getAmount(), payMethod); if (!success) { throw new RuntimeException("支付失败"); } // 修改状态 order.setStatus("PAID"); orderRepository.save(order); } } 一个 Service 同时承担校验、业务逻辑、状态修改、持久化和外部调用，演变成“上帝类”。 2）贫血模型：Entity/POJO 只存数据，业务逻辑全堆在 Service，对象与业务语义严重脱节，比如Order 类里找不到 pay()，只能在 OrderService 找到 payOrder()。 3）随着需求增长，Service 越来越庞大，修改风险高、测试困难。 引出DDD的价值 1）业务逻辑回归领域模型（充血模型） 通过 实体、值对象、聚合、领域服务 等概念，把业务规则放回领域模型中，实现高内聚： public class Order { private Long id; private BigDecimal amount; private String status; // 领域方法：支付 public void pay(PaymentGateway paymentGateway, String payMethod) { validateBeforePay(); boolean success = paymentGateway.pay(this.id, this.amount, payMethod); if (!success) { throw new RuntimeException("支付失败"); } this.status = "PAID"; } private void validateBeforePay() { if (!"UNPAID".equals(this.status)) { throw new IllegalStateException("订单状态不允许支付"); } if (amount.compareTo(BigDecimal.ZERO) <= 0) { throw new IllegalStateException("订单金额异常"); } } } @Service public class OrderAppService { @Autowired private OrderRepository orderRepository; @Autowired private PaymentGateway paymentGateway; public void payOrder(Long orderId, String payMethod) { Order order = orderRepository.findById(orderId); order.pay(paymentGateway, payMethod); // 业务逻辑放在 Order 里 orderRepository.save(order); } } 扩展更容易：如果要加优惠券逻辑，只需要在 Order 里扩展，而不是在 Service 里继续堆 if-else。 2）统一语言（Ubiquitous Language） DDD 强调与业务专家使用一致的术语建模，保证沟通顺畅： 传统写法: Controller: OrderController.create() Service: OrderService.saveOrder() DAO: OrderRepository.insert() 业务专家说：“下单” 。开发说：“调用 create() 接口，service.saveOrder()，repository.insert()。” DDD 写法: 团队必须先和业务专家一起挖掘、定义业务概念。 然后这些词汇会 直接落到模型、聚合、实体、方法名 上。 Customer.placeOrder() （客户下单） Order.markAsPendingPayment() （订单标记为待支付） OrderRepository.save(order) （仓储保存订单） 业务专家说：“下单 → 待支付” 。开发说：“placeOrder() → markAsPendingPayment()。” 3）技术解耦与可演进性 1）领域层不依赖技术实现，领域模型只关心业务，不关心底层是 MySQL、Redis、ES 还是文件。 所有外部依赖都通过 接口 定义，比如 OrderRepository。具体的存储实现交给 适配器，在基础设施层完成。 2）遵循依赖倒置原则，领域层依赖抽象接口，而不是依赖具体实现，当技术实现需要调整（如 MySQL → Redis），只需要改适配器。领域模型的变更只来自业务规则的变化，而不是技术变更。 **面试官可能追问：**你只是把service层中的逻辑移动到了实体类中，将臃肿的代码逻辑转移到了别处？ 回答： 表面上看，DDD 确实是把 Service 里的逻辑挪到了实体，但本质不是搬家，而是职责重构。 在贫血模型里，实体只是数据容器，业务规则分散在不同 Service 里，导致代码臃肿、逻辑重复。 在充血模型里，规则和实体强绑定，代码语义更贴近业务： 规则归属清晰：订单的支付校验、发货校验都收拢在 Order 聚合里，不会分散在多个 Service。 统一语言：order.pay() 就等于业务里的“订单支付”，减少沟通成本。 复杂度可控：领域服务负责编排，聚合/实体承载业务逻辑，基础设施负责实现，避免出现‘上帝 Service’。 更易维护扩展：新增优惠券逻辑只需在 Order 内扩展，而不是在庞大的 Service 里继续加 if-else。 所以 DDD 的意义在于让领域模型成为业务的表达中心，而不仅仅是逻辑搬家。 如何理解聚合根？ 我把聚合理解为一组强相关的实体和值对象，它们必须作为一个整体来保证业务一致性。聚合根是这组对象对外唯一的入口。所有修改必须通过聚合根来进行，它负责维护不变式，并定义事务边界；仓储也以聚合根为单位。跨聚合我们通过 ID 引用与领域事件实现最终一致，避免大事务。例如在订单域，Order 是聚合根，OrderLine、Address 在聚合内；总价计算、状态流转等不变式在一次事务里由 Order 保证；库存属于另一个聚合，通过“订单已提交”事件去扣减库存。这样既保证一致性，又降低耦合、便于扩展。 DDD概念理论 充血模型 vs 贫血模型 定义 贫血模型：对象仅包含数据属性和简单的 getter/setter，业务逻辑由外部服务处理。 充血模型：对象既包含数据，也封装相关业务逻辑，符合面向对象设计原则。 特点 贫血模型 充血模型 封装性 数据和逻辑分离 数据和逻辑封装在同一对象内 职责分离 服务类负责业务逻辑，对象负责数据 对象同时负责数据和自身的业务逻辑 适用场景 简单的增删改查、DTO 传输对象 复杂的领域逻辑和业务建模 优点 简单易用，职责清晰 高内聚，符合面向对象设计思想 缺点 服务层臃肿，领域模型弱化 复杂度增加，不适合简单场景 面向对象原则 违反封装原则 符合封装原则 贫血模型： // 1. “贫血”的订单实体 (Entity) // 它只是一个数据袋子，没有行为，只有getter/setter public class Order { private Long id; private String status; private BigDecimal amount; // ... 一堆getter和setter方法 } // 2. “贫血”的商品实体 (Entity) public class Product { private Long id; private String name; private Integer stock; // 库存 // ... 一堆getter和setter方法 } // 3. 庞大的“服务层” (Service) 包含所有业务逻辑 @Service public class OrderService { @Autowired private ProductRepository productRepository; public void decreaseStock(Long productId, Integer quantity) { // 步骤1: 查询商品 Product product = productRepository.findById(productId); // 步骤2: 检查库存（业务规则） if (product.getStock() < quantity) { throw new RuntimeException("库存不足"); } // 步骤3: 计算并设置新库存（业务逻辑） Integer newStock = product.getStock() - quantity; product.setStock(newStock); // 对象的状态由外部服务来修改 // 步骤4: 保存回数据库 productRepository.save(product); } } 问题：所有业务逻辑（检查库存、计算新库存）都放在了 OrderService这个外部服务里。Product对象本身只是个“傻傻的”数据载体，它对自己的业务规则（如“库存不能为负”）一无所知，谁都可以随意setStock，非常容易出错。这就是 “贫血模型”。 充血模型： // 1. “充血”的商品实体 (Entity/Aggregate Root) // 它不仅有数据，更有行为（方法），它对自己的业务规则负责 public class Product { private Long id; private String name; private Integer stock; // 库存 // 核心业务行为：减少库存 // 这个方法是直接写在这个实体对象内部的！ public void decreaseStock(Integer quantity) { // 守护业务规则：库存不能减少为负数 if (this.stock < quantity) { throw new DomainException("商品库存不足，无法减少"); } // 业务逻辑：修改自身状态 this.stock -= quantity; } // 其他行为，如增加库存... public void increaseStock(Integer quantity) { this.stock += quantity; } } // 2. 变得很“薄”的服务层 (Service/Application Service) // 它的职责不再是处理业务逻辑，而是协调事务、调用仓库、发布事件等 @Service public class OrderApplicationService { @Autowired private ProductRepository productRepository; public void decreaseStock(Long productId, Integer quantity) { // 步骤1: 获取领域对象（聚合根） Product product = productRepository.findById(productId); // 步骤2: 调用领域对象自身的业务方法！ product.decreaseStock(quantity); // 逻辑在Product内部 // 步骤3: 保存这个发生了变化的对象 productRepository.save(product); } } 这样的方式可以在使用一个对象时，就顺便拿到这个对象的提供的一系列业务方法，所有使用对象的逻辑方法，都不需要自己再次处理同类逻辑。 但不要只是把充血模型，仅限于一个类的设计和一个类内的方法设计。充血还可以是整个包结构**（领域模型）**，一个包下包括了用于实现此包 Service 服务所需的各类零部件（模型、仓储、工厂），也可以被看做充血模型。 同时我们还会再一个同类的类下，提供对应的内部类，如用户实名，包括了通信类、实名卡、银行卡、四要素等。它们都被写进到一个用户类下的内部子类，这样在代码编写中也会清晰的看到子类的所属信息，更容易理解代码逻辑，也便于维护迭代。 我的实体类本身还是偏贫血模型，主要负责承载数据和基本的不变式校验。**但在领域层里，我会把仓储、领域服务和实体组合在一起，所有业务逻辑都在领域模型中闭环实现，不会散落到外层，这样整体上就是充血思想。**实体保证自身一致性，复杂逻辑交给领域服务来实现。 限界上下文 限界上下文是指一个明确的边界，规定了某个子领域的业务模型和语言，确保在该上下文内的术语、规则、模型不与其他上下文混淆。是一个 业务设计概念。 表达 语义环境 实际含义 "我吃得很饱，现在不能动了" 日常用餐 字面意思：吃到肚子很满 "我吃得很饱，今天的演讲让人充实" 知识分享 比喻：得到了很大满足 限界上下文的作用 定义业务边界：类似于语义环境，为通用语言划定范围 消除歧义：确保团队对领域对象、事件的认知一致 领域转换：同一对象在不同上下文有不同名称（goods在电商称"商品"，运输称"货物"） 模型隔离：防止不同业务领域的模型相互干扰 在代码工程里，每个上下文拥有独立包结构 领域模型 指特定业务领域内，业务规则、策略以及业务流程的抽象和封装。在设计手段上，通过风暴模型拆分领域模块，形成界限上下文。最大的区别在于把原有的众多 Service + 数据模型的方式，拆分为独立的有边界的领域模块。每个领域内创建自身所属的；领域对象（实体、聚合、值对象）、仓储服务(DAO 操作)、工厂、端口适配器Port（调用外部接口的手段）等。 在原本的 Service + 贫血的数据模型开发指导下，Service 串联调用每一个功能模块。这些基础设施（对象、方法、接口）是被相互调用的。这也是因为贫血模型并没有面向对象的设计，所有的需求开发只有详细设计。 换到充血模型下，现在我们以一个领域功能为聚合，拆分一个领域内所需的 Service 为领域服务，VO、Req、Res 重新设计为领域对象，DAO、Redis 等持久化操作为仓储等。举例：一套账户服务中的，授信认证、开户、提额降额等，每一个都是一个独立的领域，在每个独立的领域内，创建自身领域所需的各项信息。 领域模型还有一个特点，它自身只关注业务功能实现，不与外部任何接口和服务直连。如：不会直接调用 DAO 操作库，也不会调用缓存操作 Redis，更不会直接引入 RPC 连接其他微服务。而是通过仓库Repository和端口适配器port，定义调用外部数据的含有出入参对象的接口标准，让基础设施层做具体的调用实现——通过这样的方式让领域只关心业务实现，同时做好防腐。(依赖倒置） 领域服务 一组无状态的业务操作，封装那些“不属于任何单个实体/聚合”的领域逻辑。 职责 执行跨聚合、跨实体的业务场景—— 处理一个订单支付时，可能需要处理与 订单、账户、支付信息 等多个实体的交互。 在这种情况下，领域服务负责协调这些实体之间的交互。 协调仓储接口、调用多个聚合根的方法，但本身不持有长期状态。 领域服务自己不持有数据状态，它的职责是调度和协调。它通过调用聚合根（或实体）的方法来完成业务操作。它也不会涉及持久化（数据存储），这些通常是通过仓储层来管理的。 典型示例 订单支付功能： 涉及订单、用户账户、支付信息等多个实体，适合放在领域服务中实现 订单（Order）：包含订单的详细信息。 账户（Account）：用户的账户信息，包括余额。 支付信息（PaymentDetails）：支付的具体信息，例如支付方式、金额等。 @Service public class PaymentService { @Transactional public void processPayment(Order order, PaymentDetails paymentDetails, Account account) { // 调用领域对象的行为 account.pay(paymentDetails.getAmount()); //负责余额检查与扣款 order.markAsPaid(); //负责支付状态变更 paymentDetails.recordPayment(order); // 保存这些聚合（Repository 层操作） orderRepository.save(order); accountRepository.save(account); paymentRepository.save(paymentDetails); } } 领域对象 实体 实体是基于持久化层数据和领域服务功能目标设计的领域对象。与持久化的 PO（持久化对象）不同，PO 只是原子类对象，缺乏业务语义，而实体对象不仅具备业务语义，还具有唯一标识。实体对象与领域服务方法紧密结合，跟随其生命周期进行操作。 例如，用户的 PO 对象可能包括用户开户信息、授信信息和额度信息等多个方面，而订单则可能涉及多个实体，例如商品下单时的购物车实体。实体通常作为领域服务方法的入参对象。 在代码中，实体通常表现为具有唯一标识的业务对象，标识属性（如 ID）是其核心特征。例如： 订单实体：通过订单 ID 唯一标识 用户实体：通过用户 ID 唯一标识 核心特征： 实体的属性随着时间变化而变化。 唯一标识（ID）保持不变，确保实体的唯一性。 实体对象通常在代码中以实体类的形式存在，并且通常采用 充血模型 实现，即将与该实体相关的业务逻辑和行为写入实体类中，而不仅仅是存储数据。 **作用：**实体类的作用是用来建模领域中的“唯一业务对象”，它通过 ID 保证唯一性，随着生命周期发生状态变化，并将与自身相关的业务逻辑和行为封装在内部，是领域建模的核心元素。 值对象 值对象是没有唯一标识的业务对象，具有以下特征： 创建后不可修改（immutable） 只能通过整体替换来更新 通常用于描述实体的属性和特征 在开发值对象的时候，通常不会提供 setter 方法，而是提供构造函数或者 Builder 方法来实例化对象。这个对象通常不会独立作为方法的入参对象，但做可以独立作为出参对象使用。 作用： 表达领域概念：用值对象建模能让代码更贴近业务，比如用枚举类XXStatus代替1、2、3。 保证一致性与正确性：值对象可以在内部封装校验逻辑，比如金额不能为负数。 可复用：多个实体都可以组合使用相同的值对象。 用于：枚举类、VO返回对象 聚合与聚合根 ”高内聚、低耦合“，代码中直观的感受就是仓储层中，传入的如果是聚合根，意味着要对不同的表进行处理，因此对应方法上一般要加@Transactional-------拼团中的锁单、退单都是如此！！！ 锁单：同时操作拼团表和拼团明细表；退单：拼团表+拼团明细表+消息通知表。 在领域驱动设计（DDD）中，聚合是一组紧密关联的 **实体 **和 值对象的组合，这些对象在业务上共同协作，形成一个统一的一致性与事务边界。 聚合根 是聚合的唯一入口，负责对外提供操作接口，并维护聚合内部的一致性和业务规则。 1. 聚合（逻辑边界） 聚合内的所有变更必须在同一事务中完成，要么全部成功，要么全部失败，确保内部业务不变式始终成立。例：订单的总金额必须等于所有订单项金额之和。 一次事务只允许跨越一个聚合，避免分布式事务的复杂性。 外部代码不得直接修改聚合内除聚合根之外的对象，所有操作都必须通过聚合根进行。例：外部不能直接改 OrderItem 数量，而是调用 Order.changeItemQuantity()。 示例： 一个订单聚合可能包含： 订单实体（聚合根）：Order，全局唯一 ID，提供操作方法；包含订单总金额 totalAmount。 订单明细实体：OrderItem，描述商品项（数量、单价）。 收货地址值对象：ShippingAddress，不可变，存储地址信息。 2. 聚合根（物理入口） 唯一入口：对外唯一的访问点Order，聚合内的所有修改必须经由聚合根发起。 全局标识：聚合根是一个拥有全局唯一 ID 的实体。 规则守护者：负责封装聚合内部的业务逻辑、数据校验及不变式维护。 跨聚合交互：与其他聚合交互时，只传递 ID 或使用领域服务，不直接持有对方实体的引用，避免跨边界耦合。 3.代码示例（订单聚合） // 聚合根：订单 public class Order { private final String orderId; private List<OrderItem> items; private ShippingAddress address; private double totalAmount; // 总金额作为不变式 public Order(String orderId, ShippingAddress shippingAddress) { this.orderId = orderId; this.address = shippingAddress; this.items = new ArrayList<>(); this.totalAmount = 0.0; } // 添加商品 public void addItem(String productId, int quantity, double price) { OrderItem item = new OrderItem(productId, quantity, price); items.add(item); recalculateTotalAmount(); } // 修改订单项数量（外部必须通过聚合根调用） public void changeItemQuantity(String productId, int newQuantity) { for (OrderItem item : items) { if (item.getProductId().equals(productId)) { item.changeQuantity(newQuantity); // 修改子实体 recalculateTotalAmount(); // 重新计算总金额 return; } } throw new IllegalArgumentException("未找到商品：" + productId); } // 聚合内规则：每次修改都要维护不变式 private void recalculateTotalAmount() { this.totalAmount = items.stream() .mapToDouble(OrderItem::totalPrice) .sum(); } public double getTotalAmount() { return totalAmount; } } 比如订单聚合根，有自己唯一orderid，以及totalamount总金额，订单明细实体，地址值对象； 每次添加新的商品+数量，就自动调用一次更新总金额；保证事务的一致性。 仓储服务 特征 封装持久化操作：Repository负责封装所有与数据源交互的操作，如创建、读取、更新和删除（CRUD）操作。这样，领域层的代码就可以避免直接处理数据库或其他存储机制的复杂性。 抽象接口：Repository定义了一个与持久化机制无关的接口，这使得领域层的代码可以在不同的持久化机制之间切换，而不需要修改业务逻辑。 职责分离 领域层 只定义 Repository 接口，关注“需要做哪些数据操作”（增删改查、复杂查询），不关心具体实现。 基础设施层 实现这些接口（ORM、JDBC、Redis、ES、RPC、HTTP、MQ 推送等），封装所有外部资源的访问细节。 仓储解耦的手段使用了依赖倒置的设计。 示例： 只定义接口，由基础设施层来实现。 public interface IActivityRepository { GroupBuyActivityDiscountVO queryGroupBuyActivityDiscountVO(String source, String channel); SkuVO querySkuByGoodsId(String goodsId); } 使用：在应用程序中使用依赖注入（DI）来将具体的Repository实现注入到需要它们的领域服务或应用服务中。 聚合和领域服务和仓储服务的比较 有状态（Stateful）： 一个订单（Order）聚合，它可能会记录订单的状态，比如“未支付”或“已支付”，以及订单项（OrderItem）的列表。在处理订单时，这些状态会发生变化（例如，当订单支付时，它的状态从“未支付”变为“已支付”）。 无状态： 一个计算价格的服务（PricingService）是无状态的，它接收输入（例如商品数量、商品价格等），然后计算并返回结果。它不会记住上一次计算的结果，每次计算都是独立的。 特性 聚合（Aggregate） 领域服务（Domain Service） 仓储（Repository） 本质 相关实体和值对象的组合，以“聚合根”为唯一访问入口 无状态的业务逻辑单元，封装跨实体 / 跨聚合规则 抽象的数据访问接口，隐藏底层存储细节，为聚合提供持久化能力 状态 有状态——内部维护数据与不变式 无状态——仅暴露行为 无业务状态；实现层可能有缓存，但对外看作无状态 职责 1. 内部一致性2. 定义事务边界3. 提供领域行为（order.pay() 等） 1. 承载跨实体规则2. 协调多个聚合完成业务动作 1. 加载 / 保存聚合根2. 把 PO ↔️ Entity 映射3. 屏蔽 SQL/ORM/缓存等技术细节 边界 聚合边界：内部操作要么全部成功要么全部失败 无一致性边界，仅调用聚合或仓储 持久化边界：一次操作针对一个聚合；不负责业务事务（由应用层控制） 典型用法 Order.addItem(),Order.cancel() PricingService.calculate(...),InventoryService.reserveStock(...) orderRepository.findById(id),orderRepository.save(order) **自己总结：**领域服务纯编排流程并注入仓储服务； 仓储服务只写接口，规定一个具体的'动作'； 然后基础设施层中子类实现该仓储接口，并注入若干Dao，一个'动作'可能调用多个Dao来实现； Dao直接与数据库打交道，实现增删查改。 API层 提供给其他服务直接依赖、并通过 RPC 调用本服务的契约。 这个 api 模块会被单独打成一个 Jar 包，其他服务只需要 依赖这个 jar，就能拿到： 1.请求 DTO 2.服务接口（通常供 RPC/Dubbo、HTTP 控制器或内部模块调用） 3.统一响应包装 response 基础设施层 1.持久化实现（Repository 实现类） 在领域层你只定义了 仓储接口（Repository Interface），比如 OrderRepository。 在基础设施层才写具体实现，比如用 JPA、MyBatis、Hibernate、JDBC 去操作数据库。 2.外部系统适配 对接第三方服务、消息队列例如：支付服务调用的 HttpClient 实现；拼团通过http请求小型支付商城的xx接口，或发rabbitmq。 3.基础设施组件封装 通用的技术性工具代码，不涉及业务逻辑，比如DCC动态配置中心，邮件的调用，AOP切面类动态限流 4.事件与消息机制 提供消息队列的具体实现，配置与发送。 Trriger触发器层 触发器层主要负责 “接收外部输入，触发应用/领域逻辑” 1.HTTP 接口（Controller） 2.消息监听（Listener / Consumer） 3.定时任务（Job / Scheduler） 比如超时退款、拼团组队成功通知等。 4.RPC 接口（Dubbo、gRPC、Thrift 等） 作为服务提供方，暴露给其他系统调用的接口。 Types通用类型层 目录 作用 示例 annotations 自定义注解及其拦截器。 DCCValue、RateLimiterAccessInterceptor：比如做参数校验、限流、配置注入等。 common 全局常量、通用工具类。 Constants：放系统级别的常量、公共配置Key等。 design.framework 设计模式或通用策略框架的封装。tree、link 子包像是策略路由、责任链等可复用的实现。 AbstractStrategyRouter、StrategyHandler：策略路由器抽象，供业务模块按需继承。 enums 系统级枚举。和业务场景相关但通用的状态、返回码等。 ActivityStatusEnumVO、GroupBuyOrderStatusEnumVO、ResponseCode 等。 event 基础事件类型，领域事件的通用父类。 BaseEvent：其他模块可继承实现自定义事件。 exception 自定义异常体系。 AppException：统一异常封装，便于全局处理。 DDD架构设计 四层架构 用户接口层interface：处理用户交互和展示 应用层application：协调领域对象完成业务用例 领域层domain：包含核心业务逻辑和领域模型 基础设施层infrastructure：提供技术实现支持 如何从MVC架构映射到DDD架构？ 六边形架构 领域模型设计 方式1；DDD 领域科目类型分包，类型之下写每个业务逻辑。 **方式2；**业务领域分包，每个业务领域之下有自己所需的 DDD 领域科目。（拼团营销系统是方式2）
消息队列MQ 初识MQ 同步调用 同步调用有3个问题： 拓展性差，每次有新的需求，现有支付逻辑都要跟着变化，代码经常变动 性能下降，每次远程调用，调用者都是阻塞等待状态。最终整个业务的响应时长就是每次远程调用的执行时长之和 级联失败，当交易服务、通知服务出现故障时，整个事务都会回滚，交易失败。 异步调用 技术选型 RabbitMQ 部署 mq: #消息队列 image: rabbitmq:3.8-management container_name: mq restart: unless-stopped hostname: mq environment: TZ: "Asia/Shanghai" RABBITMQ_DEFAULT_USER: admin RABBITMQ_DEFAULT_PASS: "admin" ports: - "15672:15672" - "5672:5672" volumes: - mq-plugins:/plugins # 持久化数据卷，保存用户/队列/交换机等元数据 - ./mq-data:/var/lib/rabbitmq networks: - hmall-net volumes: mq-plugins: http://localhost:15672/ 访问控制台 架构图 publisher：生产者，发送消息的一方 consumer：消费者，消费消息的一方 queue：队列，存储消息。生产者投递的消息会暂存在消息队列中，等待消费者处理 exchange：交换机，负责消息路由。生产者发送的消息由交换机决定投递到哪个队列。不存储 virtual host：虚拟主机，起到数据隔离的作用。每个虚拟主机相互独立，有各自的exchange、queue（每个项目+环境有各自的vhost） 一个队列最多指定给一个消费者！ Spring AMQP 快速开始 交换机和队列都是直接在控制台创建，消息的发送和接收在Java应用中实现！ 简单案例：直接向队列发送消息，不经过交换机 引入依赖 <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-amqp</artifactId> </dependency> 配置MQ地址，在publisher和consumer服务的application.yml中添加配置： spring: rabbitmq: host: localhost # 你的虚拟机IP port: 5672 # 端口 virtual-host: /hmall # 虚拟主机 username: hmall # 用户名 password: 123 # 密码 消息发送： 然后在publisher服务中编写测试类SpringAmqpTest，并利用**RabbitTemplate**实现消息发送： @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); } } convertAndSend如果 2 个参数，第一个表示队列名，第二个表示消息； 消息接收 @Component public class SpringRabbitListener { // 利用RabbitListener来声明要监听的队列信息 // 将来一旦监听的队列中有了消息，就会推送给当前服务，调用当前方法，处理消息。 // 可以看到方法体中接收的就是消息体的内容 @RabbitListener(queues = "simple.queue") public void listenSimpleQueueMessage(String msg) throws InterruptedException { System.out.println("spring 消费者接收到消息：【" + msg + "】"); } } 然后启动启动类，它能自动从队列中取出消息。取出后队列中就没消息了！ 交换机 无论是 直连Direct、主题Topic 还是 扇形Fanout 交换机，你都可以用 同一个 Binding Key 把多条队列绑定到同一个交换机上。 1）fanout：广播给每个绑定的队列 发送消息： convertAndSend如果 3 个参数，第一个表示交换机，第二个表示RoutingKey，第三个表示消息。 @Test public void testFanoutExchange() { // 交换机名称 String exchangeName = "hmall.fanout"; // 消息 String message = "hello, everyone!"; rabbitTemplate.convertAndSend(exchangeName, "", message); } 2）Direct交换机 队列与交换机的绑定，不能是任意绑定了，而是要指定一个RoutingKey（路由key） 消息的发送方在 向 Exchange发送消息时，也必须指定消息的 RoutingKey。 Exchange不再把消息交给每一个绑定的队列，而是根据消息的Routing Key进行判断，只有队列的BindingKey与消息的 Routing key完全一致，才会接收到消息 注意，RoutingKey不等于队列名称 3）Topic交换机 Topic类型的交换机与Direct相比，都是可以根据RoutingKey把消息路由到不同的队列。 只不过Topic类型交换机可以让队列在绑定BindingKey 的时候使用通配符！ BindingKey一般都是有一个或多个单词组成，多个单词之间以.分割 通配符规则： #：匹配一个或多个词 *：匹配不多不少恰好1个词 举例： item.#：能够匹配item.spu.insert 或者 item.spu item.*：只能匹配item.spu 转发过程：把发送者传来的 Routing Key 按点分成多级，和各队列的 Binding Key（可以带 *、# 通配符）做模式匹配，匹配上的队列统统都能收到消息。 注意：生产者在发送消息时，必须指定一个明确的 RoutingKey，而队列绑定到 Topic Exchange 时，指定的 BindingKey 可以包含通配符。 Routing Key和Binding Key Routing Key（路由键） 由发送者（Producer）在发布消息时指定，附着在消息头上。 用来告诉交换机：“我的这条消息属于哪类/哪个主题”。 Binding Key（绑定键） 由消费者（在应用启动或队列声明时）指定，是把队列绑定到交换机时用的规则。有些 UI 里 Routing Key 等同于 Binding Key! 告诉交换机：“符合这个键的消息，投递到我这个队列”。 交换机本身不设置 Routing Key 或 Binding Key，它只根据类型（Direct/Topic/Fanout/Headers）和已有的“队列–绑定键”关系，把 incoming Routing Key 匹配到对应的队列。 Direct Exchange 路由规则：Routing Key === Binding Key（完全一致） 场景：一对一或一对多的精确路由 Topic Exchange 路由规则 ：支持通配符 *：匹配一个单词 #：匹配零个或多个单词 例： Binding Key绑定键 order.* → 能匹配 order.created、order.paid 绑定键 order.# → 能匹配 order.created.success、order 等 Fanout Exchange 路由规则：忽略 Routing/Binding Key，消息广播到所有绑定队列 场景：聊天室广播、缓存失效通知等 消费者处理消息 不同队列： 同一个交换机 + 相同 routing key 绑定到 多个不同的队列 → 每个队列都会收到一份消息，各自独立处理。 👉 相当于多个队列订阅了同类信息，TOPIC 同一个队列： 多个消费者（不管是一个应用里开多个 listener，还是多台实例部署）监听 同一个队列 → 一条消息只会被其中一个消费者消费，起到负载均衡作用。 👉 常用于“任务分摊”。 基于注解声明交换机、队列 前面都是在 RabbitMQ 管理控制台手动创建队列和交换机，开发人员还得把所有配置整理一遍交给运维，既繁琐又容易出错。更好的做法是在应用启动时自动检测所需的队列和交换机，若不存在则直接创建。 基于注解方式来声明 type 默认交换机类型为ExchangeTypes.DIRECT @RabbitListener(bindings = @QueueBinding( value = @Queue(name = "direct.queue1"), exchange = @Exchange(name = "hmall.direct", type = ExchangeTypes.DIRECT), key = {"red", "blue"} )) 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 + "】"); } 检查队列 如果 RabbitMQ 中已经有名为 direct.queue1 的队列，就不会重复创建； 如果不存在，RabbitAdmin 会自动帮你创建一个。 检查交换机 同理，会查看有没有名为 hmall.direct、类型为 direct 的交换机，若不存在就新建。 检查绑定 最后再去声明绑定关系：把 direct.queue1 绑定到 hmall.direct，并且 routing-key 为 "red" 和 "blue"。 如果已有相同的绑定（队列、交换机、路由键都一致），也不会再重复创建。 消息转换器 使用JSON方式来做序列化和反序列化，替换掉默认方式。 更小或可压缩的消息体、易读、易调试 1）引入依赖 <dependency> <groupId>com.fasterxml.jackson.dataformat</groupId> <artifactId>jackson-dataformat-xml</artifactId> <version>2.9.10</version> </dependency> 2）配置消息转换器，在publisher和consumer两个服务的启动类中添加一个Bean即可： @Bean public MessageConverter messageConverter(){ // 1.定义消息转换器 Jackson2JsonMessageConverter jackson2JsonMessageConverter = new Jackson2JsonMessageConverter(); // 2.配置自动创建消息id，用于识别不同消息，也可以在业务中基于ID判断是否是重复消息 jackson2JsonMessageConverter.setCreateMessageIds(true); return jackson2JsonMessageConverter; } MQ高级 我们要解决消息丢失问题，保证MQ的可靠性，就必须从3个方面入手： 确保生产者一定把消息发送到MQ 确保MQ不会将消息弄丢 确保消费者一定要处理消息 发送者的可靠性 发送者重试 修改发送者模块的application.yaml文件，添加下面的内容： 主要是针对网络连接失败的场景，会自动重试；交换机不存在，不会触发重试。 spring: rabbitmq: connection-timeout: 1s # 设置MQ的连接超时时间 template: retry: enabled: true # 开启超时重试机制 initial-interval: 1000ms # 失败后的初始等待时间 multiplier: 1 # 失败后下次的等待时长倍数，下次等待时长 = initial-interval * multiplier max-attempts: 3 # 最大重试次数 阻塞重试，一般不建议开启。 发送者确认机制 一、机制概述 RabbitMQ 提供两种发送者确认机制，确保消息投递的可靠性： Publisher Confirm：确认消息是否到达 RabbitMQ 服务器 Publisher Return：确认消息是否成功路由到队列 二、配置开启 1.在发送者模块的application.yaml中添加配置： spring: rabbitmq: publisher-confirm-type: correlated # 开启异步confirm机制 publisher-returns: true # 开启return机制 confirm类型说明： none（默认模式）：关闭confirm机制，消息由于网络连接失败也不会提醒。 simple：同步阻塞等待MQ的回执 correlated：MQ异步回调返回回执 2.每个RabbitTemplate只能配置一个ReturnCallback，因此我们可以在配置类中统一设置。 @Slf4j @Configuration @RequiredArgsConstructor public class MqConfig { private final RabbitTemplate rabbitTemplate; @PostConstruct public void init() { // 设置全局ReturnCallback rabbitTemplate.setReturnsCallback(returned -> { log.error("消息路由失败 - Exchange: {}, RoutingKey: {}, ReplyCode: {}, ReplyText: {}", returned.getExchange(), returned.getRoutingKey(), returned.getReplyCode(), returned.getReplyText()); // 可在此添加告警或重试逻辑 sendAlert(returned); }); } } 三、ConfirmCallback 使用 消息发送时设置确认回调CorrelationData 这里的CorrelationData中包含两个核心的东西： id：消息的唯一标示，MQ对不同的消息的回执以此做判断，避免混淆 SettableListenableFuture：回执结果的Future对象 public void sendMessageWithConfirmation(String exchange, String routingKey, Object message) { // 1. 创建关联数据 CorrelationData correlationData = new CorrelationData(); // 2. 添加确认回调 correlationData.getFuture().addCallback( result -> { if (result.isAck()) { log.info("✅ 消息成功到达MQ服务器"); } else { log.error("❌ 消息发送失败: {}", result.getReason()); // 可在此添加重试逻辑 } }, ex -> { log.error("⚠️ 确认过程发生异常", ex); } ); // 3. 发送消息 rabbitTemplate.convertAndSend(exchange, routingKey, message, correlationData); } 四、消息投递结果分析 场景 网络状态 路由状态 ConfirmCallback ReturnsCallback 最终结果 完全成功 ✅ 成功 ✅ 成功 ACK 不触发 消息入队 网络失败 ❌ 失败 - NACK 不触发 发送失败 路由失败 ✅ 成功 ❌ 失败 ACK 触发 消息丢弃 交换机不存在 ✅ 成功 ❌ 失败 ACK 触发 消息丢弃 端到端投递保障 ConfirmCallback 只告诉你：消息“到”了 RabbitMQ 服务器吗？（ACK：到；NACK：没到） ReturnCallback 只告诉你：到达服务器的消息，能“进”队列吗？（能进就不回；进不了就退） 两者都成功，才能确认：“这条消息真的安全地进了队列，等着消费者去拿。” 🟢 ACK：消息到达MQ服务器（可能路由失败） 🔴 NACK：消息未到达MQ服务器（网络问题） 🔵 Return：消息到达但路由失败（配置问题） 通过组合使用这两种机制，可以实现完整的端到端消息投递保障。如果由于网络问题，NACK了，那么会被correlationData.getFuture().addCallback(...)回调函数捕捉！！！ MQ的可靠性 数据持久化 MQ消息持久化就是指当RabbitMQ服务重启后，消息仍然会保留在队列中不会丢失。 非持久化消息：只存储在内存中;持久化消息：同时存储在内存和磁盘中 为了保证数据的可靠性，必须配置数据持久化（从内存保存到磁盘上），包括： 交换机持久化（选Durable） 队列持久化（选Durable） 消息持久化（选Persistent） 控制台方式： 代码方式，默认都是持久化的，不用变动。 消费者可靠性 消费者确认机制 消费者确认机制 (Consumer Acknowledgement) 是为了确认消费者是否成功处理消息。当消费者处理消息结束后，应该向 RabbitMQ 发送一个回执，告知 RabbitMQ 自己消息处理状态： ack：成功处理消息，RabbitMQ 从队列中删除该消息 nack：消息处理失败，RabbitMQ 需要再次投递消息 reject：消息处理失败并拒绝该消息，RabbitMQ 从队列中删除该消息 上述的NACK状态时，MQ会不断向消费者重投消息，直至被正确处理！！！ 在消费者方，通过下面的配置可以修改消费者收到消息后的处理方式： none:消费者收到消息后，RabbitMQ 立即自动确认（ACK） manual，手动实现ack； auto（默认模式），自动档，业务逻辑异常返回nack， 消息解析异常 返回reject，其他ack spring: rabbitmq: listener: simple: acknowledge-mode: auto 消费者重试 类似发送者的重试机制，在消费者出现异常时利用本地重试，而不是无限制的requeue到mq队列。 重试达到最大次数后，会返回reject，消息会被丢弃 修改consumer服务的application.yml文件，添加内容： spring: rabbitmq: listener: simple: retry: enabled: true # 开启消费者失败重试 initial-interval: 1000ms # 初识的失败等待时长为1秒 multiplier: 1 # 失败的等待时长倍数，下次等待时长 = multiplier * last-interval max-attempts: 3 # 最大重试次数 stateless: true # true无状态（默认）；如果业务中包含事务，这里改为false有状态 核心概念：一次事务 vs. 多次事务 想象一下这个场景：你是一个消费者，从MQ收到一条消息，内容是“给用户A的账户增加10元”。你的服务需要执行两个步骤： 处理业务逻辑（更新数据库，给用户A加钱）。 确认消息（告诉MQ消息处理成功了）。 这个“处理业务逻辑”和“确认消息”的过程，可以放在一个数据库事务里。 特性 无状态重试 (stateless: true) 有状态重试 (stateless: false) 本质 本地方法重试 消息重新投递 事务范围 所有重试在同一个事务中 每次重试是独立的事务 MQ感知 MQ完全不知情（只投递1次） MQ完全知情（多次投递） 性能 高（无网络开销） 较低（有网络开销） 安全性 低（易导致重复操作） 高（每次失败都回滚） 适用场景 幂等操作、非DB操作（如HTTP调用） 非幂等操作、数据库事务操作 为什么用了 @Transactional必须有状态重试？ 假设是无状态重试，重试是在同一次方法调用/同一事务里循环进行的（拦截器内部重试）。 第一次失败抛出异常后，当前事务被标记为 rollback-only。 接下来即便你第2次、第3次尝试都“业务成功”，提交时也会失败（因为事务早已不可提交）。 结果：不适合与 @Transactional 搭配做数据库更新；更适合无事务或幂等且不涉及DB提交的调用（如外部HTTP、缓存写入等）。 假设是有状态重试（stateless: false） 重试通过把异常抛回给容器，让消息重新投递来实现。 每次投递 → 监听方法重新执行 → 新的事务开启。 每次失败都会完整回滚该次事务；下一次重试是干净的事务上下文。 达到最大次数后，按照你的配置reject（可配合死信队列/失败交换器），从而避免“消息风暴”。 ！！！有状态重试相比RabbitMq的默认重试机制：可以配置有限次重试次数，更加灵活。 失败处理策略 只有在开启了消费者重试机制（即配置了 spring.rabbitmq.listener.simple.retry.enabled: true）时才会生效。 当消息消费重试达到最大次数后，默认会直接丢弃，这在要求高可靠性的场景中不可接受。Spring 提供了 MessageRecoverer接口来自定义最终处理策略，主要有三种实现： RejectAndDontRequeueRecoverer 默认策略。直接拒绝消息并丢弃。 ImmediateRequeueMessageRecoverer 让消息重新进入队列，再次被消费（可能导致循环）。 RepublishMessageRecoverer ✅ 推荐方案 将消息路由到一个专用的异常交换机，最终进入异常队列。 优势：实现故障隔离，便于后续人工干预或自动化修复，是保证消息不丢失的优雅方案。 业务幂等性 在程序开发中，幂等则是指同一个业务，执行一次或多次对业务状态的影响是一致的。如： 根据id删除数据 查询数据 新增数据 但数据的更新往往不是幂等的，如果重复执行可能造成不一样的后果。比如： 取消订单，恢复库存的业务。如果多次恢复就会出现库存重复增加的情况 退款业务。重复退款对商家而言会有经济损失。 所以，我们要尽可能避免业务被重复执行：MQ消息的重复投递、页面卡顿时频繁刷新导致表单重复提交、服务间调用的重试 法一：唯一ID 每一条消息都生成一个唯一的id，与消息一起投递给消费者。 消费者接收到消息后处理自己的业务，业务处理成功后将消息ID保存到数据库。 如果下次又收到相同消息，去数据库查询判断是否存在，存在则为重复消息放弃处理。 法一存在业务侵入，因为mq的消息ID与业务无关，现在却多了一张专门记录 ID 的表或结构 法二：业务判断，基于业务本身的逻辑或状态来判断是否是重复的请求或消息，不同的业务场景判断的思路也不一样。 综上，支付服务与交易服务之间的订单状态一致性是如何保证的？ 首先，支付服务会正在用户支付成功以后利用MQ消息通知交易服务，完成订单状态同步。 其次，为了保证MQ消息的可靠性，我们采用了生产者确认机制、消费者确认、消费者失败重试等策略，确保消息投递的可靠性 最后，我们还在交易服务设置了定时任务，定期查询订单支付状态。这样即便MQ通知失败，还可以利用定时任务作为兜底方案，确保订单支付状态的最终一致性。 延迟消息 对于超过一定时间未支付的订单，应该立刻取消订单并释放占用的库存。 方案：利用延迟消息实现超时检查 以“订单支付超时时间为30分钟”为例，具体实现流程如下： 创建订单时：在订单入库的同时，向消息队列发送一条延迟时间为30分钟的消息。 消息等待：此消息不会立即被消费，而是由MQ服务器暂存至延迟时间到期。 延迟触发：30分钟后，消息队列自动将该消息投递给消费者服务。 执行检查与操作：消费者接收到消息后，查询该订单的当前支付状态： 若订单仍为“未支付”：则执行取消订单、释放库存等后续操作。 若订单已支付：则忽略此消息，流程结束。 实现延迟消息法一 延迟消息插件 1.下载 GitHub - rabbitmq/rabbitmq-delayed-message-exchange: Delayed Messaging for RabbitMQ 2.上传插件，由于之前docker部署MQ挂载了数据卷 docker volume ls #查看所有数据卷 docker volume inspect hmall_all_mq-plugins #获取数据卷的目录 #"Mountpoint": "/var/lib/docker/volumes/hmall_all_mq-plugins/_data" 我们上传插件到该目录下。 3.安装插件 docker exec -it mq rabbitmq-plugins enable rabbitmq_delayed_message_exchange 声明延迟交换机 额外指定参数 delayed = "true" @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); } 发送延迟消息 @Test void testPublisherDelayMessage() { // 1.创建消息 String message = "hello, delayed message"; // 2.发送消息，利用消息后置处理器添加消息头 rabbitTemplate.convertAndSend("delay.direct", "delay", message, new MessagePostProcessor() { @Override public Message postProcessMessage(Message message) throws AmqpException { // 添加延迟消息属性 message.getMessageProperties().setDelay(5000); return message; } }); } 实现延迟消息法二 RabbitMQ (TTL + 死信队列) 1.配置类（配置交换机和队列） 类型 名称 作用 路由键 交换机 order.exchange 业务交换机：接收原始延迟消息 order.delay.key 队列 order.delay.queue 等待队列：消息在此等待TTL过期 - 交换机 order.delay.exchange 死信交换机：接收过期消息 order.delay.key 队列 order.process.queue 处理队列：最终消费消息的队列 - @Configuration public class RabbitMQDelayConfig { // 业务交换机 @Bean public DirectExchange orderExchange() { return new DirectExchange("order.exchange"); } // 死信交换机（作为延迟消息的目标） @Bean public DirectExchange orderDelayExchange() { return new DirectExchange("order.delay.exchange"); } // 业务队列 - 设置死信参数 @Bean public Queue orderDelayQueue() { Map<String, Object> args = new HashMap<>(); // 消息到期后转发的死信交换机 args.put("x-dead-letter-exchange", "order.delay.exchange"); // 死信路由键 args.put("x-dead-letter-routing-key", "order.delay.key"); return new Queue("order.delay.queue", true, false, false, args); } // 最终消费队列 @Bean public Queue orderProcessQueue() { return new Queue("order.process.queue"); } // 绑定：业务队列 -> 业务交换机 @Bean public Binding orderDelayBinding() { return BindingBuilder.bind(orderDelayQueue()) .to(orderExchange()) .with("order.delay.key"); } // 绑定：最终队列 -> 死信交换机 @Bean public Binding orderProcessBinding() { return BindingBuilder.bind(orderProcessQueue()) .to(orderDelayExchange()) .with("order.delay.key"); } } 2. 发送消息（设置TTL） @Service @RequiredArgsConstructor public class OrderService { private final RabbitTemplate rabbitTemplate; public void createOrder(Order order) { // 创建订单逻辑... // 发送延迟消息（30分钟） rabbitTemplate.convertAndSend("order.exchange", "order.delay.key", order.getId(), message -> { // 设置消息的TTL为30分钟 message.getMessageProperties().setExpiration("1800000"); // 毫秒 return message; }); } } 3. 消费者 @Component public class OrderDelayConsumer { @RabbitListener(queues = "order.process.queue") public void processExpiredOrder(String orderId) { // 查询订单状态，如果未支付则取消订单 System.out.println("处理超时订单：" + orderId); } } 超时订单问题 死信交换机 当消息在一个队列中变成“死信（Dead Letter）”后，能被重新投递到的另一个交换机，就是死信交换机（DLX）。 绑定到 DLX 的队列叫死信队列（DLQ），专门用来存放这些“死信”消息。 触发条件 消费者拒绝并不再重投（Consumer Rejection） “消费者这一端”的情况。当消费者明确拒绝消息（发送 basic.reject或 basic.nack）并且设置 requeue=false时，消息会成为死信。 场景：消费者处理消息时遇到无法处理的错误（如业务逻辑错误、数据格式错误），明确告知MQ不要重新投递了。 消息过期（Message TTL Expired） 这与消费者无关。消息在队列中等待的时间超过了设定的生存时间（TTL），会被自动删除并变成死信。 场景：常用于实现延迟队列。例如，下单15分钟未支付订单取消，就可以将消息TTL设为15分钟，过期后成为死信转到DLQ，由DLQ的消费者来处理取消逻辑。 队列溢出（Queue Length Limit Exceeded） 这也与消费者无关。当队列的消息数量达到上限时，新来的消息或队列头部的消息（取决于配置）会被丢弃并变成死信。 场景：用于限制队列容量，防止消息无限堆积，保护系统。 配置 必须用编程式方式来声明，不可用注解式。 @Configuration public class RabbitMQConfig { @Value("${spring.rabbitmq.config.producer.exchange}") private String businessExchangeName; @Value("${spring.rabbitmq.config.producer.topic_team_success.queue}") private String businessQueueName; @Value("${spring.rabbitmq.config.producer.topic_team_success.routing_key}") private String businessRoutingKey; // 1. 定义死信交换机（通常一个应用一个就够了） @Bean public TopicExchange dlxExchange() { return new TopicExchange(businessExchangeName + ".dlx", true, false); } // 2. 定义死信队列 @Bean public Queue dlq() { return new Queue(businessQueueName + ".dlq", true); } // 3. 将死信队列绑定到死信交换机 @Bean public Binding dlqBinding() { return BindingBuilder.bind(dlq()) .to(dlxExchange()) .with(businessRoutingKey + ".dead"); // 使用新的路由键 } // 4. 定义业务交换机 @Bean public TopicExchange businessExchange() { return new TopicExchange(businessExchangeName, true, false); } // 5. 定义业务队列，并配置死信规则（核心！） @Bean public Queue businessQueue() { Map<String, Object> args = new HashMap<>(); // 指定死信交换机 args.put("x-dead-letter-exchange", businessExchangeName + ".dlx"); // 指定死信的路由键（可选，不指定则使用原消息的路由键） args.put("x-dead-letter-routing-key", businessRoutingKey + ".dead"); // 还可以设置其他导致消息成为死信的参数 // args.put("x-message-ttl", 60000); // 消息60秒过期 // args.put("x-max-length", 1000); // 队列最大长度1000条 return new Queue(businessQueueName, true, false, false, args); } // 6. 将业务队列绑定到业务交换机 @Bean public Binding businessBinding() { return BindingBuilder.bind(businessQueue()) .to(businessExchange()) .with(businessRoutingKey); } }
Jmeter快速入门 1.安装Jmeter Jmeter依赖于JDK，所以必须确保当前计算机上已经安装了JDK，并且配置了环境变量。 1.1.下载 可以Apache Jmeter官网下载，地址：http://jmeter.apache.org/download_jmeter.cgi 1.2.解压 因为下载的是zip包，解压缩即可使用，目录结构如下： 其中的bin目录就是执行的脚本，其中包含启动脚本： 1.3.运行 双击即可运行，但是有两点注意： 启动速度比较慢，要耐心等待 启动后黑窗口不能关闭，否则Jmeter也跟着关闭了 2.快速入门 2.1.设置中文语言 默认Jmeter的语言是英文，需要设置： 效果： 注意：上面的配置只能保证本次运行是中文，如果要永久中文，需要修改Jmeter的配置文件 打开jmeter文件夹，在bin目录中找到 jmeter.properties，添加下面配置： language=zh_CN 注意：前面不要出现#，#代表注释，另外这里是下划线，不是中划线 2.2.基本用法 在测试计划上点鼠标右键，选择添加 > 线程（用户） > 线程组： 在新增的线程组中，填写线程信息： 给线程组点鼠标右键，添加http取样器： 编写取样器内容： 添加HTTP Header Content-Type=application/json 添加监听报告： 添加监听结果树： 汇总报告结果： 结果树： 清理结果，一个个监听器清理 或者全部清理： 2.3.保存/导入执行计划 .jmx文件！！！
Mybatis 快速创建 创建springboot工程（Spring Initializr），并导入 mybatis的起步依赖、mysql的驱动包。创建用户表user，并创建对应的实体类User 在springboot项目中，可以编写main/resources/application.properties文件，配置数据库连接信息。 #驱动类名称 spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver #数据库连接的url spring.datasource.url=jdbc:mysql://localhost:3306/mybatis #连接数据库的用户名 spring.datasource.username=root #连接数据库的密码 spring.datasource.password=1234 在引导类所在包下，在创建一个包 mapper。在mapper包下创建一个接口 UserMapper @Mapper注解：表示是mybatis中的Mapper接口 ​ -程序运行时：框架会自动生成接口的实现类对象(代理对象)，并交给Spring的IOC容器管理 @Select注解：代表的就是select查询，用于书写select查询语句 @Mapper public interface UserMapper { //查询所有用户数据 @Select("select * from user") public List<User> list(); } 数据库连接池 数据库连接池是一个容器，负责管理和分配数据库连接（Connection）。 在程序启动时，连接池会创建一定数量的数据库连接。 客户端在执行 SQL 时，从连接池获取连接对象，执行完 SQL 后，将连接归还给连接池，以供其他客户端复用。 如果连接对象长时间空闲且超过预设的最大空闲时间，连接池会自动释放该连接。 优势：避免频繁创建和销毁连接，提高数据库访问效率。 Druid（德鲁伊） Druid连接池是阿里巴巴开源的数据库连接池项目 功能强大，性能优秀，是Java语言最好的数据库连接池之一 把默认的 Hikari 数据库连接池切换为 Druid 数据库连接池： 在pom.xml文件中引入依赖 <dependency> <groupId>com.alibaba</groupId> <artifactId>druid-spring-boot-starter</artifactId> <version>1.2.8</version> </dependency> 在application.properties中引入数据库连接配置 spring.datasource.druid.driver-class-name=com.mysql.cj.jdbc.Driver spring.datasource.druid.url=jdbc:mysql://localhost:3306/mybatis spring.datasource.druid.username=root spring.datasource.druid.password=123456 SQL注入问题 SQL注入：由于没有对用户输入进行充分检查，而SQL又是拼接而成，在用户输入参数时，在参数中添加一些SQL关键字，达到改变SQL运行结果的目的，也可以完成恶意攻击。 在Mybatis中提供的参数占位符有两种：${...} 、#{...} #{...} 执行SQL时，会将#{…}替换为?，生成预编译SQL，会自动设置参数值 使用时机：参数传递，都使用#{…} ${...} 拼接SQL。直接将参数拼接在SQL语句中，存在SQL注入问题 使用时机：如果对表名、列表进行动态设置时使用 日志输出 只建议开发环境使用：在Mybatis当中我们可以借助日志，查看到sql语句的执行、执行传递的参数以及执行结果 打开application.properties文件 开启mybatis的日志，并指定输出到控制台 #指定mybatis输出日志的位置, 输出控制台 mybatis.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl 驼峰命名法 在 Java 项目中，数据库表字段名一般使用 下划线命名法（snake_case），而 Java 中的变量名使用 驼峰命名法（camelCase）。 小驼峰命名（lowerCamelCase）： 第一个单词的首字母小写，后续单词的首字母大写。 例子：firstName, userName, myVariable 大驼峰命名（UpperCamelCase）： 每个单词的首字母都大写，通常用于类名或类型名。 例子：MyClass, EmployeeData, OrderDetails 表中查询的数据封装到实体类中 实体类属性名和数据库表查询返回的字段名一致，mybatis会自动封装。 如果实体类属性名和数据库表查询返回的字段名不一致，不能自动封装。 解决方法： 起别名 结果映射 开启驼峰命名 属性名和表中字段名保持一致 开启驼峰命名(推荐)：如果字段名与属性名符合驼峰命名规则，mybatis会自动通过驼峰命名规则映射 驼峰命名规则： abc_xyz => abcXyz 表中字段名：abc_xyz 类中属性名：abcXyz 推荐的完整配置： mybatis: #mapper配置文件 mapper-locations: classpath:mapper/*.xml type-aliases-package: com.sky.entity configuration: #开启驼峰命名 map-underscore-to-camel-case: true type-aliases-package: com.sky.entity把 com.sky.entity 包下的所有类都当作别名注册，XML 里就可以直接写 <resultType="Dish"> 而不用写全限定名。可以多添加几个包，用逗号隔开。 增删改 增删改通用！：返回值为int时，表示影响的记录数，一般不需要可以设置为void！ 作用于单个字段 @Mapper public interface EmpMapper { //SQL语句中的id值不能写成固定数值，需要变为动态的数值 //解决方案：在delete方法中添加一个参数(用户id)，将方法中的参数，传给SQL语句 /** * 根据id删除数据 * @param id 用户id */ @Delete("delete from emp where id = #{id}")//使用#{key}方式获取方法中的参数值 public void delete(Integer id); } 上图参数值分离，有效防止SQL注入 作用于多个字段 @Mapper public interface EmpMapper { //会自动将生成的主键值，赋值给emp对象的id属性 @Options(useGeneratedKeys = true,keyProperty = "id") @Insert("insert into emp(username, name, gender, image, job, entrydate, dept_id, create_time, update_time) values (#{username}, #{name}, #{gender}, #{image}, #{job}, #{entrydate}, #{deptId}, #{createTime}, #{updateTime})") public void insert(Emp emp); } 在 @Insert 注解中使用 #{} 来引用 Emp 对象的属性，MyBatis 会自动从 Emp 对象中提取相应的字段并绑定到 SQL 语句中的占位符。 @Options(useGeneratedKeys = true, keyProperty = "id") 这行配置表示，插入时自动生成的主键会赋值给 Emp 对象的 id 属性。 // 调用 mapper 执行插入操作 empMapper.insert(emp); // 现在 emp 对象的 id 属性会被自动设置为数据库生成的主键值 System.out.println("Generated ID: " + emp.getId()); 查 查询案例： 姓名：要求支持模糊匹配 性别：要求精确匹配 入职时间：要求进行范围查询 根据最后修改时间进行降序排序 重点在于模糊查询时where name like '%#{name}%' 会报错。 解决方案： 使用MySQL提供的字符串拼接函数：concat('%' , '关键字' , '%') CONCAT() 如果其中任何一个参数为 NULL，CONCAT() 返回 NULL，Like NULL会导致查询不到任何结果！ NULL和''是完全不同的 @Mapper public interface EmpMapper { @Select("select * from emp " + "where name like concat('%',#{name},'%') " + "and gender = #{gender} " + "and entrydate between #{begin} and #{end} " + "order by update_time desc") public List<Emp> list(String name, Short gender, LocalDate begin, LocalDate end); } XML配置文件规范 使用Mybatis的注解方式，主要是来完成一些简单的增删改查功能。如果需要实现复杂的SQL功能，建议使用XML来配置映射语句，也就是将SQL语句写在XML配置文件中。 在Mybatis中使用XML映射文件方式开发，需要符合一定的规范： XML映射文件的名称与Mapper接口名称一致，并且将XML映射文件和Mapper接口放置在相同包下（同包同名） XML映射文件的namespace属性为Mapper接口全限定名一致 XML映射文件中sql语句的id与Mapper接口中的方法名一致，并保持返回类型一致。 标签：就是用于编写select查询语句的。 resultType属性，指的是查询返回的单条记录所封装的类型(查询必须)。 parameterType属性（可选，MyBatis 会根据接口方法的入参类型（比如 Dish 或 DishPageQueryDTO）自动推断），POJO作为入参，需要使用全类名或是type‑aliases‑package: com.sky.entity 下注册的别名。 <insert id="insert" useGeneratedKeys="true" keyProperty="id"> 实现过程： resources下创与java下一样的包，即edu/whut/mapper，新建xx.xml文件 配置Mapper文件 <?xml version="1.0" encoding="UTF-8" ?> <mapper namespace="edu.whut.mapper.EmpMapper"> </mapper> namespace 属性指定了 Mapper 接口的全限定名（即包名 + 类名）。 编写查询语句 select * from emp where name like concat('%',#{name},'%') and gender = #{gender} and entrydate between #{begin} and #{end} order by update_time desc id="list"：指定查询方法的名称，应该与 Mapper 接口中的方法名称一致。 resultType="edu.whut.pojo.Emp"：resultType 只在 查询操作 中需要指定。指定查询结果映射的对象类型，这里是 Emp 类。 这里有bug！！！ concat('%',#{name},'%')这里应该用<where> <if>标签对name是否为NULL或''进行判断 动态SQL SQL-if,where <if>：用于判断条件是否成立。使用test属性进行条件判断，如果条件为true，则拼接SQL。 <if test="条件表达式"> 要拼接的sql语句 </if> <where>只会在子元素有内容的情况下才插入where子句，而且会自动去除子句的开头的AND或OR,加了总比不加好 select * from emp <where> <if test="name != null"> and name like concat('%',#{name},'%') </if> <if test="gender != null"> and gender = #{gender} </if> <if test="begin != null and end != null"> and entrydate between #{begin} and #{end} </if> </where> order by update_time desc SQL-foreach Mapper 接口 @Mapper public interface EmpMapper { //批量删除 public void deleteByIds(List<Integer> ids); } XML 映射文件 <foreach> 标签用于遍历集合，常用于动态生成 SQL 语句中的 IN 子句、批量插入、批量更新等操作。 <foreach collection="集合名称" item="集合遍历出来的元素/项" separator="每一次遍历使用的分隔符" open="遍历开始前拼接的片段" close="遍历结束后拼接的片段"> </foreach> open="("：这个属性表示，在生成的 SQL 语句开始时添加一个 左括号 (。 close=")"：这个属性表示，在生成的 SQL 语句结束时添加一个 右括号 )。 例：批量删除实现 <delete id="deleteByIds"> DELETE FROM emp WHERE id IN <foreach collection="ids" item="id" separator="," open="(" close=")"> #{id} </foreach> </delete> 实现效果类似：DELETE FROM emp WHERE id IN (1, 2, 3); Mybatis-Plus MyBatis-Plus 的使命就是——在保留 MyBatis 灵活性的同时，大幅减少模板化、重复的代码编写，让增删改查、分页等常见场景“开箱即用”，以更少的配置、更少的样板文件、更高的开发效率，帮助团队快速交付高质量的数据库访问层。 快速开始 1.引入依赖 <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <!-- <dependency>--> <!-- <groupId>org.mybatis.spring.boot</groupId>--> <!-- <artifactId>mybatis-spring-boot-starter</artifactId>--> <!-- <version>2.3.1</version>--> <!-- </dependency>--> 由于这个starter包含对mybatis的自动装配，因此完全可以替换掉Mybatis的starter。 2.定义mapper 为了简化单表CRUD，MybatisPlus提供了一个基础的BaseMapper接口，其中已经实现了单表的CRUD(增删查改)： 仅需让自定义的UserMapper接口，继承BaseMapper接口： public interface UserMapper extends BaseMapper<User> { } 测试： @SpringBootTest class UserMapperTest { @Autowired private UserMapper userMapper; @Test void testInsert() { User user = new User(); user.setId(5L); user.setUsername("Lucy"); user.setPassword("123"); user.setPhone("18688990011"); user.setBalance(200); user.setInfo("{\"age\": 24, \"intro\": \"英文老师\", \"gender\": \"female\"}"); user.setCreateTime(LocalDateTime.now()); user.setUpdateTime(LocalDateTime.now()); userMapper.insert(user); } @Test void testSelectById() { User user = userMapper.selectById(5L); System.out.println("user = " + user); } @Test void testSelectByIds() { List<User> users = userMapper.selectBatchIds(List.of(1L, 2L, 3L, 4L, 5L)); users.forEach(System.out::println); } @Test void testUpdateById() { User user = new User(); user.setId(5L); user.setBalance(20000); userMapper.updateById(user); } @Test void testDelete() { userMapper.deleteById(5L); } } 3.常见注解 MybatisPlus如何知道我们要查询的是哪张表？表中有哪些字段呢？ 约定大于配置 泛型中的User就是与数据库对应的PO. MybatisPlus就是根据PO实体的信息来推断出表的信息，从而生成SQL的。默认情况下： MybatisPlus会把PO实体的类名驼峰转下划线作为表名 UserRecord->user_record MybatisPlus会把PO实体的所有变量名驼峰转下划线作为表的字段名，并根据变量类型推断字段类型 MybatisPlus会把名为id的字段作为主键 但很多情况下，默认的实现与实际场景不符，因此MybatisPlus提供了一些注解便于我们声明表信息。 @TableName 描述：表名注解，标识实体类对应的表 @TableId 描述：主键注解，标识实体类中的主键字段 TableId注解支持两个属性： 属性 类型 必须指定 默认值 描述 value String 否 "" 主键字段名 type Enum 否 IdType.NONE 指定主键类型 @TableName("user_detail") public class User { @TableId(value="id_dd",type=IdType.AUTO) private Long id; private String name; } 这个例子会，映射到数据库中的user_detail表，主键为id_dd，并且插入时采用数据库自增；能自动回写主键，相当于开启useGeneratedKeys=true，执行完 insert(user) 后，user.getId() 就会是数据库分配的主键值，否则默认获得null，但不影响数据表中的内容。 type=dType.ASSIGN_ID 表示用雪花算法生成密码，更加复杂，而不是简单的AUTO自增。它也能自动回写主键。 @TableField 普通字段注解 一般情况下我们并不需要给字段添加@TableField注解，一些特殊情况除外： 成员变量名与数据库字段名不一致 成员变量是以isXXX命名，按照JavaBean的规范，MybatisPlus识别字段时会把is去除，这就导致与数据库不符。 public class User { private Long id; private String name; private Boolean isActive; // 按 JavaBean 习惯，这里用 isActive，数据表是is_acitive，但MybatisPlus会识别为active } 成员变量名与数据库一致，但是与数据库的**关键字(如order)**冲突。 public class Order { private Long id; private Integer order; // 名字和 SQL 关键字冲突 } 默认MP会生成：SELECT id, order FROM order; 导致报错 一些字段不希望被映射到数据表中，不希望进行增删查改 解决办法： @TableField("is_active") private Boolean isActive; @TableField("`order`") //添加转义字符 private Integer order; @TableField(exist=false) //exist默认是true， private String address; 4.常用配置 大多数的配置都有默认值，因此我们都无需配置。但还有一些是没有默认值的，例如: 实体类的别名扫描包 全局id类型 要改也就改这两个即可 mybatis-plus: type-aliases-package: edu.whut.mp.domain.po global-config: db-config: id-type: auto # 全局id类型为自增长 作用：1.把edu.whut.mp.domain.po 包下的所有 PO 类注册为 MyBatis 的 Type Alias。这样在你的 Mapper XML 里就可以直接写 <resultType="User">（或 <parameterType="User">）而不用写全限定类名 edu.whut.mp.domain.po.User 2.无需在每个 @TableId 上都写 type = IdType.AUTO，统一由全局配置管。 核心功能 前面的例子都是根据主键id更新、修改、查询，无法支持复杂条件where。 条件构造器Wrapper 除了新增以外，修改、删除、查询的SQL语句都需要指定where条件。因此BaseMapper中提供的相关方法除了以id作为where条件以外，还支持更加复杂的where条件。 Wrapper就是条件构造的抽象类，其下有很多默认实现，继承关系如图： QueryWrapper 在AbstractWrapper的基础上拓展了一个select方法，允许指定查询字段，无论是修改、删除、查询，都可以使用QueryWrapper来构建查询条件。 select方法只需用于 查询 时指定所需的列，完整查询不需要，用于update和delete不需要。 QueryWrapper 里对 like、eq、ge 等方法都做了重载 QueryWrapper<User> qw = new QueryWrapper<>(); qw.like("name", name); //两参版本，第一个参数对应数据库中的列名，如果对应不上，就会报错！！！ qw.like(StrUtil.isNotBlank(name), "name", name); //三参，多一个boolean condition 参数 **例1：**查询出名字中带o的，存款大于等于1000元的人的id，username,info,balance: /** * SELECT id,username,info,balance * FROM user * WHERE username LIKE ? AND balance >=? */ @Test void testQueryWrapper(){ QueryWrapper<User> wrapper =new QueryWrapper<User>() .select("id","username","info","balance") .like("username","o") .ge("balance",1000); //查询 List<User> users=userMapper.selectList(wrapper); users.forEach(System.out::println); } UpdateWrapper 基于BaseMapper中的update方法更新时只能直接赋值，对于一些复杂的需求就难以实现。 例1： 例如：更新id为1,2,4的用户的余额，扣200，对应的SQL应该是： UPDATE user SET balance = balance - 200 WHERE id in (1, 2, 4) @Test void testUpdateWrapper() { List<Long> ids = List.of(1L, 2L, 4L); // 1.生成SQL UpdateWrapper<User> wrapper = new UpdateWrapper<User>() .setSql("balance = balance - 200") // SET balance = balance - 200 .in("id", ids); // WHERE id in (1, 2, 4) // 2.更新，注意第一个参数可以给null，告诉 MP：不要从实体里取任何字段值 // 而是基于UpdateWrapper中的setSQL来更新 userMapper.update(null, wrapper); } 例2： // 用 UpdateWrapper 拼 WHERE + SET UpdateWrapper<User> wrapper = new UpdateWrapper<User>() // WHERE status = 'ACTIVE' .eq("status", "ACTIVE") // SET balance = 2000, name = 'Alice' .set("balance", 2000) .set("name", "Alice"); // 把 entity 参数传 null，MyBatis-Plus 会只用 wrapper 里的 set/where userMapper.update(null, wrapper); LambdaQueryWrapper（推荐） 是QueryWrapper和UpdateWrapper的上位选择！！！ 传统的 QueryWrapper/UpdateWrapper 需要把数据库字段名写成字符串常量，既容易拼写出错，也无法在编译期校验。MyBatis-Plus 引入了两种基于 Lambda 的 Wrapper —— LambdaQueryWrapper 和 LambdaUpdateWrapper —— 通过传入实体类的 getter 方法引用，框架会自动解析并映射到对应的列，实现了类型安全和更高的可维护性。 // ——— 传统 QueryWrapper ——— public User findByUsername(String username) { QueryWrapper<User> qw = new QueryWrapper<>(); // 硬编码列名，拼写错了编译不过不了，会在运行时抛数据库异常 qw.eq("user_name", username); return userMapper.selectOne(qw); } // ——— LambdaQueryWrapper ——— public User findByUsername(String username) { // 内部已注入实体 Class 和元数据，方法引用自动解析列名 LambdaQueryWrapper<User> qw = Wrappers.lambdaQuery(User.class) .eq(User::getUserName, username); return userMapper.selectOne(qw); } 自定义sql 即自己编写Wrapper查询条件，再结合Mapper.xml编写SQL **例1：**以 UPDATE user SET balance = balance - 200 WHERE id in (1, 2, 4) 为例： 1）先在业务层利用wrapper创建条件，传递参数 @Test void testCustomWrapper() { // 1.准备自定义查询条件 List<Long> ids = List.of(1L, 2L, 4L); QueryWrapper<User> wrapper = new QueryWrapper<User>().in("id", ids); // 2.调用mapper的自定义方法，直接传递Wrapper userMapper.deductBalanceByIds(200, wrapper); } 2）自定义mapper层把wrapper和其他业务参数传进去，自定义sql语句书写sql的前半部分，后面拼接。 public interface UserMapper extends BaseMapper<User> { /** * 注意：更新要用 @Update * - #{money} 会被替换为方法第一个参数 200 * - ${ew.customSqlSegment} 会展开 wrapper 里的 WHERE 子句 */ @Update("UPDATE user " + "SET balance = balance - #{money} " + "${ew.customSqlSegment}") void deductBalanceByIds(@Param("money") int money, @Param("ew") QueryWrapper<User> wrapper); } @Param("ew")就是给这个方法参数在 MyBatis 的 SQL 映射里起一个别名—— ew ， Mapper 的注解或 XML 里，MyBatis 想要拿到这个参数，就用它的 @Param 名称——也就是 ew： @Param("ew")中ew是 MP 约定的别名！ ${ew.customSqlSegment} 可以自动拼接传入的条件语句 **例2：**查询出所有收货地址在北京的并且用户id在1、2、4之中的用户 普通mybatis： SELECT * FROM user u INNER JOIN address a ON u.id = a.user_id WHERE u.id <foreach collection="ids" separator="," item="id" open="IN (" close=")"> #{id} </foreach> AND a.city = #{city} mp方法： @Test void testCustomJoinWrapper() { // 1.准备自定义查询条件 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); } @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); Service层的常用方法 查询： selectById：根据主键 ID 查询单条记录。 selectBatchIds：根据主键 ID 批量查询记录。 selectOne：根据指定条件查询单条记录。 @Service public class UserService { @Autowired private UserMapper userMapper; public User findByUsername(String username) { QueryWrapper<User> queryWrapper = new QueryWrapper<>(); queryWrapper.eq("username", username); return userMapper.selectOne(queryWrapper); } } selectList：根据指定条件查询多条记录。 QueryWrapper<User> queryWrapper = new QueryWrapper<>(); queryWrapper.ge("age", 18); List<User> users = userMapper.selectList(queryWrapper); 插入： insert：插入一条记录。 User user = new User(); user.setUsername("alice"); user.setAge(20); int rows = userMapper.insert(user); 更新 updateById：根据主键 ID 更新记录。 User user = new User(); user.setId(1L); user.setAge(25); int rows = userMapper.updateById(user); update：根据指定条件更新记录。 UpdateWrapper<User> updateWrapper = new UpdateWrapper<>(); updateWrapper.eq("username", "alice"); User user = new User(); user.setAge(30); int rows = userMapper.update(user, updateWrapper); 删除操作 deleteById：根据主键 ID 删除记录。 deleteBatchIds：根据主键 ID 批量删除记录。 delete：根据指定条件删除记录。 QueryWrapper<User> queryWrapper = new QueryWrapper<>(); queryWrapper.eq("username", "alice"); int rows = userMapper.delete(queryWrapper); IService 基本使用 由于Service中经常需要定义与业务有关的自定义方法，因此我们不能直接使用IService，而是自定义Service接口，然后继承IService以拓展方法。同时，让自定义的Service实现类继承ServiceImpl，这样就不用自己实现IService中的接口了。 首先，定义IUserService，继承IService： public interface IUserService extends IService<User> { // 拓展自定义方法 } 然后，编写UserServiceImpl类，继承ServiceImpl，实现UserService： @Service public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService { } Controller层中写： @RestController @RequestMapping("/users") @Slf4j @Api(tags = "用户管理接口") public class UserController { @Autowired private IUserService userService; @PostMapping @ApiOperation("新增用户接口") public void saveUser(@RequestBody UserFormDTO userFormDTO){ User user=new User(); BeanUtils.copyProperties(userFormDTO, user); userService.save(user); } @DeleteMapping("{id}") @ApiOperation("删除用户接口") public void deleteUserById(@PathVariable Long id){ userService.removeById(id); } @GetMapping("{id}") @ApiOperation("根据id查询接口") public UserVO queryUserById(@PathVariable Long id){ User user=userService.getById(id); UserVO userVO=new UserVO(); BeanUtils.copyProperties(user,userVO); return userVO; } @PutMapping("/{id}/deduction/{money}") @ApiOperation("根据id扣减余额") public void updateBalance(@PathVariable Long id,@PathVariable Long money){ userService.deductBalance(id,money); } } service层： @Service public class IUserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService { @Autowired private UserMapper userMapper; @Override public void deductBalance(Long id, Long money) { //1.查询用户 User user=getById(id); if(user==null || user.getStatus()==2){ throw new RuntimeException("用户状态异常！"); } //2.查验余额 if(user.getBalance()<money){ throw new RuntimeException("用户余额不足！"); } //3.扣除余额 update User set balance=balance-money where id=id userMapper.deductBalance(id,money); } } mapper层： @Mapper public interface UserMapper extends BaseMapper<User> { @Update("update user set balance=balance-#{money} where id=#{id}") void deductBalance(Long id, Long money); } 总结：如果是简单查询，如用id来查询、删除，可以直接在Controller层用Iservice方法，否则自定义业务层Service实现具体任务。 Service层的lambdaQuery IService中还提供了Lambda功能来简化我们的复杂查询及更新功能。 相当于「条件构造」和「执行方法」写在一起 this.lambdaQuery() = LambdaQueryWrapper + 内置的执行方法（如 .list()、.one()） 特性 lambdaQuery() lambdaUpdate() 主要用途 构造查询条件，执行 SELECT 操作 构造更新条件，执行 UPDATE（或逻辑删除）操作 支持的方法 .eq(), .like(), .gt(), .orderBy(), .select() 等 .eq(), .lt(), .set(), .setSql() 等 执行方法 .list(), .one(), .page() 等 .update(), .remove()（逻辑删除 **案例一：**实现一个根据复杂条件查询用户的接口，查询条件如下： name：用户名关键字，可以为空 status：用户状态，可以为空 minBalance：最小余额，可以为空 maxBalance：最大余额，可以为空 @GetMapping("/list") @ApiOperation("根据id集合查询用户") public List<UserVO> queryUsers(UserQuery query){ // 1.组织条件 String username = query.getName(); Integer status = query.getStatus(); Integer minBalance = query.getMinBalance(); Integer maxBalance = query.getMaxBalance(); // 2.查询用户 List<User> users = userService.lambdaQuery() .like(username != null, User::getUsername, username) .eq(status != null, User::getStatus, status) .ge(minBalance != null, User::getBalance, minBalance) .le(maxBalance != null, User::getBalance, maxBalance) .list(); // 3.处理vo return BeanUtil.copyToList(users, UserVO.class); } .eq(status != null, User::getStatus, status)，使用User::getStatus方法引用并不直接把'Status'插入到 SQL，而是在运行时会被 MyBatis-Plus 解析成实体属性 Status”对应的数据库列是 status。推荐!!! 可以发现lambdaQuery方法中除了可以构建条件，还需要在链式编程的最后添加一个list()，这是在告诉MP我们的调用结果需要是一个list集合。这里不仅可以用list()，可选的方法有： .one()：最多1个结果 .list()：返回集合结果 .count()：返回计数结果 MybatisPlus会根据链式编程的最后一个方法来判断最终的返回结果。 这里不够规范，业务写在controller层中了。 **案例二：**改造根据id修改用户余额的接口，如果扣减后余额为0，则将用户status修改为冻结状态（2） @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.扣减余额 update tb_user set balance = balance - ? int remainBalance = user.getBalance() - money; lambdaUpdate() .set(User::getBalance, remainBalance) // 更新余额 .set(remainBalance == 0, User::getStatus, 2) // 动态判断，是否更新status .eq(User::getId, id) .eq(User::getBalance, user.getBalance()) // 乐观锁 .update(); } 批量新增 每 batchSize 条记录作为一个 JDBC batch 提交一次（1000 条就一次） @Test void testSaveBatch() { // 准备10万条数据 List<User> list = new ArrayList<>(1000); long b = System.currentTimeMillis(); for (int i = 1; i <= 100000; i++) { list.add(buildUser(i)); // 每1000条批量插入一次 if (i % 1000 == 0) { userService.saveBatch(list); list.clear(); } } long e = System.currentTimeMillis(); System.out.println("耗时：" + (e - b)); } 之所以把 100 000 条记录分成每 1 000 条一批来插，是为了兼顾 性能、内存 和 数据库／JDBC 限制。 JDBC 或数据库参数限制 很多数据库（MySQL、Oracle 等）对单条 SQL 里 VALUES 列表的长度有上限，一次性插入几十万行可能导致 SQL 过长、参数个数过多，被驱动或数据库拒绝。 即使驱动不直接报错，也可能因为网络包（packet）过大而失败。 内存占用和 GC 压力 JDBC 在执行 batch 时，会把所有要执行的 SQL 和参数暂存在客户端内存里。如果一次性缓存 100 000 条记录的参数（可能是几 MB 甚至十几 MB），容易触发 OOM 或者频繁 GC。 事务日志和回滚压力 一次性插入大量数据，数据库需要在事务日志里记录相应条目，回滚时也要一次性回滚所有操作，性能开销巨大。分批能让每次写入都较为“轻量”，回滚范围也更小。 这种本质上是多条单行 INSERT Preparing: INSERT INTO user ( username, password, phone, info, balance, create_time, update_time ) VALUES ( ?, ?, ?, ?, ?, ?, ? ) Parameters: user_1, 123, 18688190001, "", 2000, 2023-07-01, 2023-07-01 Parameters: user_2, 123, 18688190002, "", 2000, 2023-07-01, 2023-07-01 Parameters: user_3, 123, 18688190003, "", 2000, 2023-07-01, 2023-07-01 而如果想要得到最佳性能，最好是将多条SQL合并为一条，像这样： INSERT INTO user ( username, password, phone, info, balance, create_time, update_time ) VALUES (user_1, 123, 18688190001, "", 2000, 2023-07-01, 2023-07-01), (user_2, 123, 18688190002, "", 2000, 2023-07-01, 2023-07-01), (user_3, 123, 18688190003, "", 2000, 2023-07-01, 2023-07-01), (user_4, 123, 18688190004, "", 2000, 2023-07-01, 2023-07-01); 需要修改项目中的application.yml文件，在jdbc的url后面添加参数&rewriteBatchedStatements=true： url: jdbc:mysql://127.0.0.1:3306/mp?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true 但是会存在上述上事务的问题！！！ MQ分页 快速入门 1）引入依赖 <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.9</version> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-jsqlparser-4.9</artifactId> </dependency> 2）定义通用分页查询条件实体 @Data @ApiModel(description = "分页查询实体") public class PageQuery { @ApiModelProperty("页码") private Long pageNo; @ApiModelProperty("页码") private Long pageSize; @ApiModelProperty("排序字段") private String sortBy; @ApiModelProperty("是否升序") private Boolean isAsc; } 3）新建一个 UserQuery 类，让它继承自你已有的 PageQuery @Data @ApiModel(description = "用户分页查询实体") public class UserQuery extends PageQuery { @ApiModelProperty("用户名（模糊查询）") private String name; } 4）Service里使用 @Service public class UserService extends ServiceImpl<UserMapper, User> { /** * 用户分页查询（带用户名模糊 + 动态排序） * * @param query 包含 pageNo、pageSize、sortBy、isAsc、name 等字段 */ public Page<User> pageByQuery(UserQuery query) { // 1. 构造 Page 对象 Page<User> page = new Page<>( query.getPageNo(), query.getPageSize() ); // 2. 构造查询条件 LambdaQueryWrapper<User> qw = Wrappers.<User>lambdaQuery() // 当 name 非空时，加上 user_name LIKE '%name%' .like(StrUtil.isNotBlank(query.getName()), User::getUserName, query.getName()); // 3. 动态排序 if (StrUtil.isNotBlank(query.getSortBy())) { String column = StrUtil.toUnderlineCase(query.getSortBy()); boolean asc = Boolean.TRUE.equals(query.getIsAsc()); qw.last("ORDER BY " + column + (asc ? " ASC" : " DESC")); } // 4. 执行分页查询 return this.page(page, qw); } }
微信小程序 转载自黑马程序员。 微信小程序开发 介绍 小程序是一种新的开放能力，开发者可以快速地开发一个小程序。可以在微信内被便捷地获取和传播，同时具有出色的使用体验。 **官方网址：**https://mp.weixin.qq.com/cgi-bin/wx?token=&lang=zh_CN 小程序主要运行微信内部，可通过上述网站来整体了解微信小程序的开发。 **首先，**在进行小程序开发时，需要先去注册一个小程序，在注册的时候，它实际上又分成了不同的注册的主体。我们可以以个人的身份来注册一个小程序，当然，也可以以企业政府、媒体或者其他组织的方式来注册小程序。那么，不同的主体注册小程序，最终开放的权限也是不一样的。比如以个人身份来注册小程序，是无法开通支付权限的。若要提供支付功能，必须是企业、政府或者其它组织等。所以，不同的主体注册小程序后，可开发的功能是不一样的。 **然后，**微信小程序我们提供的一些开发的支持，实际上微信的官方是提供了一系列的工具来帮助开发者快速的接入 并且完成小程序的开发，提供了完善的开发文档，并且专门提供了一个开发者工具，还提供了相应的设计指南，同时也提供了一些小程序体验DEMO，可以快速的体验小程序实现的功能。 **最后，**开发完一个小程序要上线，也给我们提供了详细地接入流程。 准备工作 开发微信小程序之前需要做如下准备工作： 注册小程序 完善小程序信息 下载开发者工具 1). 注册小程序 注册地址：https://mp.weixin.qq.com/wxopen/waregister?action=step1 2). 完善小程序信息 登录小程序后台：https://mp.weixin.qq.com/ 两种登录方式选其一即可 完善小程序信息、小程序类目 查看小程序的 AppID与AppSecret 3). 下载开发者工具 资料中已提供，无需下载，熟悉下载步骤即可。 下载地址： https://developers.weixin.qq.com/miniprogram/dev/devtools/stable.html 扫描登录开发者工具 创建小程序项目 熟悉开发者工具布局 设置不校验合法域名 **注：**开发阶段，小程序发出请求到后端的Tomcat服务器，若不勾选，请求发送失败。 入门案例 实际上，小程序的开发本质上属于前端开发，主要使用JavaScript开发，咱们现在的定位主要还是在后端，所以，对于小程序开发简单了解即可。 小程序目录结构 小程序包含一个描述整体程序的 app 和多个描述各自页面的 page。一个小程序主体部分由三个文件组成，必须放在项目的根目录，如下： 文件说明： **app.js：**必须存在，主要存放小程序的逻辑代码 **app.json：**必须存在，小程序配置文件，主要存放小程序的公共配置 app.wxss: 非必须存在，主要存放小程序公共样式表，类似于前端的CSS样式 对小程序主体三个文件了解后，其实一个小程序又有多个页面。比如说，有商品浏览页面、购物车的页面、订单支付的页面、商品的详情页面等等。那这些页面会放在哪呢？ 会存放在pages目录。 每个小程序页面主要由四个文件组成： 文件说明： **js文件：**必须存在，存放页面业务逻辑代码，编写的js代码。 **wxml文件：**必须存在，存放页面结构，主要是做页面布局，页面效果展示的，类似于HTML页面。 **json文件：**非必须，存放页面相关的配置。 **wxss文件：**非必须，存放页面样式表，相当于CSS文件。 编写和编译小程序 1). 编写 进入到index.wxml，编写页面布局 <view class="container"> <view>{{msg}}</view> <view> 获取用户信息 <image style="width: 100px;height: 100px;" src="{{avatarUrl}}"></image> {{nickName}} </view> <view> 微信登录 授权码：{{code}} </view> <view> 发送请求 响应结果：{{result}} </view> </view> 进入到index.js，编写业务逻辑代码 Page({ data:{ msg:'hello world', avatarUrl:'', nickName:'', code:'', result:'' }, getUserInfo:function(){ wx.getUserProfile({ desc: '获取用户信息', success:(res) => { console.log(res) this.setData({ avatarUrl:res.userInfo.avatarUrl, nickName:res.userInfo.nickName }) } }) }, wxlogin:function(){ wx.login({ success: (res) => { console.log("授权码："+res.code) this.setData({ code:res.code }) } }) }, sendRequest:function(){ wx.request({ url: 'http://localhost:8080/user/shop/status', method:'GET', success:(res) => { console.log("响应结果：" + res.data.data) this.setData({ result:res.data.data }) } }) }}) 2). 编译 点击编译按钮 3). 运行效果 点击获取用户信息 点击微信登录 点击发送请求 因为请求http://localhost:8080/user/shop/status，先要启动后台项目。 **注：**设置不校验合法域名，若不勾选，请求发送失败。 发布小程序 小程序的代码都已经开发完毕，要将小程序发布上线，让所有的用户都能使用到这个小程序。 点击上传按钮： 指定版本号： 上传成功： 把代码上传到微信服务器就表示小程序已经发布了吗？ **其实并不是。**当前小程序版本只是一个开发版本。 进到微信公众平台，打开版本管理页面。 需提交审核，变成审核版本，审核通过后，进行发布，变成线上版本。 一旦成为线上版本，这就说明小程序就已经发布上线了，微信用户就可以在微信里面去搜索和使用这个小程序了。 微信登录 导入小程序代码 开发微信小程序，本质上是属于前端的开发，我们的重点其实还是后端代码开发。所以，小程序的代码已经提供好了，直接导入到微信开发者工具当中，直接来使用就可以了。 1). 找到资料 2). 导入代码 AppID：使用自己的AppID 3). 查看项目结构 主体的文件:app.js app.json app.wxss 项目的页面比较多，主要存放在pages目录。 4). 修改配置 因为小程序要请求后端服务，需要修改为自己后端服务的ip地址和端口号(默认不需要修改) common-->vendor.js-->搜索(ctrl+f)-->baseUri 微信登录流程 微信登录：https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/login.html 流程图： 步骤分析： 小程序端，调用wx.login()获取code，就是授权码。 小程序端，调用wx.request()发送请求并携带code，请求开发者服务器(自己编写的后端服务)。 开发者服务端，通过HttpClient向微信接口服务发送请求，并携带appId+appsecret+code三个参数。 开发者服务端，接收微信接口服务返回的数据，session_key+opendId等。opendId是微信用户的唯一标识。 开发者服务端，自定义登录态，生成令牌(token)和openid等数据返回给小程序端，方便后绪请求身份校验。 小程序端，收到自定义登录态，存储storage。 小程序端，后绪通过wx.request()发起业务请求时，携带token。 开发者服务端，收到请求后，通过携带的token，解析当前登录用户的id。 开发者服务端，身份校验通过后，继续相关的业务逻辑处理，最终返回业务数据。 接下来，我们使用Postman进行测试。 说明： 调用 wx.login() 获取 临时登录凭证code ，并回传到开发者服务器。 调用 auth.code2Session 接口，换取 用户唯一标识 OpenID 、 用户在微信开放平台帐号下的唯一标识UnionID（若当前小程序已绑定到微信开放平台帐号） 和 会话密钥 session_key。 code是临时的，同一个用户，同一个小程序中，使用不同的code，可以获得唯一的openid！ 之后开发者服务器可以根据用户标识来生成自定义登录态，用于后续业务逻辑中前后端交互时识别用户身份。 实现步骤： 1). 获取授权码 点击确定按钮，获取授权码，每个授权码只能使用一次，每次测试，需重新获取。 2). 明确请求接口 请求方式、请求路径、请求参数 3). 发送请求 获取session_key和openid 若出现code been used错误提示，说明授权码已被使用过，请重新获取 需求分析和设计 产品原型 用户进入到小程序的时候，微信授权登录之后才能点餐。需要获取当前微信用户的相关信息，比如昵称、头像等，这样才能够进入到小程序进行下单操作。是基于微信登录来实现小程序的登录功能，没有采用传统账户密码登录的方式。若第一次使用小程序来点餐，就是一个新用户，需要把这个新的用户保存到数据库当中完成自动注册。 登录功能原型图： 业务规则： 基于微信登录实现小程序的登录功能 如果是新用户需要自动完成注册 接口设计 通过微信登录的流程，如果要完成微信登录的话，最终就要获得微信用户的openid。在小程序端获取授权码后，向后端服务发送请求，并携带授权码，这样后端服务在收到授权码后，就可以去请求微信接口服务。最终，后端向小程序返回openid和token等数据。 基于上述的登录流程，就可以设计出该接口的请求参数和返回数据。 **说明：**请求路径/user/user/login,第一个user代表用户端，第二个user代表用户模块。
Maven Maven仓库分为： 本地仓库：自己计算机上的一个目录(用来存储jar包) 中央仓库：由Maven团队维护的全球唯一的。仓库地址：https://repo1.maven.org/maven2/ 远程仓库(私服)：一般由公司团队搭建的私有仓库 POM文件导入依赖的时候，先看本地仓库有没有，没有就看私服，再没有就从中央仓库下载。 Maven创建/导入项目 创建Maven项目 勾选 Create from archetype（可选），也可以选择 maven-archetype-quickstart 等模版。 点击 Next，填写 GAV 坐标 。 GroupId：标识组织或公司（通常使用域名反写，如 com.example） ArtifactId：标识具体项目或模块（如 my-app、spring-boot-starter-web）。 Version：标识版本号（如 1.0-SNAPSHOT、2.7.3） 导入Maven项目 （一）单独的Maven项目 打开 IDEA，在主界面选择 Open（或者在菜单栏选择 File -> Open）。 在文件选择对话框中，定位到已有项目的根目录（包含 pom.xml 的目录）。 选择该目录后，IDEA 会检测到 pom.xml 并询问是否导入为 Maven 项目，点击 OK 或 Import 即可。 IDEA 会自动解析 pom.xml，下载依赖并构建项目结构。 （二）在现有Maven项目中导入独立的Maven项目 在已经打开的 IDEA 窗口中，使用 File -> New -> Module from Existing Sources... 选择待导入项目的根目录（其中包含 pom.xml），IDEA 会将其导入为同一个工程下的另一个模块（Module）。 它们 看起来在一个工程里了，但仍然是两个独立的 Maven 模块。 （三）两个模块属于同一个工程下 可以用一个父pom进行统一管理！ 1.新建一个上层目录，如下，MyProject1和MyProject2的内容拷贝过去。 ParentProject/ ├── pom.xml <-- 父模块（聚合模块） ├── MyProject1/ <-- 子模块1 │ └── pom.xml └── MyProject2/ <-- 子模块2 └── pom.xml 2.创建父级pom 父模块 pom.xml 示例： <project> <modelVersion>4.0.0</modelVersion> <groupId>com.example</groupId> <artifactId>ParentProject</artifactId> <version>1.0-SNAPSHOT</version> <packaging>pom</packaging> //必写 <modules> <module>MyProject1</module> //必写 <module>MyProject2</module> </modules> </project> 3.修改子模块 pom.xml ，加上： <parent> <groupId>com.example</groupId> <artifactId>ParentProject</artifactId> <version>1.0-SNAPSHOT</version> <relativePath>../pom.xml</relativePath> </parent> 如果子模块中无需与父级不同的配置，可以不写，就自动继承父级配置；若写了同名配置，则表示你想要覆盖或合并父级配置。 4.File -> Open选择父级的pom，会自动导入其下面两个项目。 但是，仅仅这样无法让模块之间产生联动！需要在此基础上进行（四）的操作！ （四）通过 Maven 依赖引用 如果你的两个模块之间存在依赖关系（如第一个模块需要使用第二个模块的类）还必须在 MyProject1 的 POM 里显式声明对 MyProject2 的依赖。 MyProject1的pom.xml: <project> <parent> <groupId>com.example</groupId> <artifactId>ParentProject</artifactId> <version>1.0-SNAPSHOT</version> <relativePath>../pom.xml</relativePath> </parent> <artifactId>MyProject1</artifactId> <packaging>jar</packaging> <dependencies> <dependency> <groupId>com.example</groupId> <artifactId>MyProject2</artifactId> <!-- 不写 <version>，Maven 会自动用父 POM 的 version --> </dependency> </dependencies> </project> 如何打包？ 在父 POM 根目录执行 mvn clean package／mvn clean install。 先构建 MyProject2（因为 MyProject1 依赖它） 父 POM 自身不产物，模块的 JAR 都在各自的 target/ 下。 Maven坐标 什么是坐标？ Maven中的坐标是 == 资源的唯一标识 == 通过该坐标可以唯一定位资源位置 使用坐标来定义项目或引入项目中需要的依赖 依赖管理 可以到mvn的中央仓库（https://mvnrepository.com/）中搜索获取依赖的坐标信息 <dependencies> <dependency> <groupId>ch.qos.logback</groupId> <artifactId>logback-classic</artifactId> <version>1.2.11</version> </dependency> <dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> <version>4.12</version> <scope>test</scope> </dependency> </dependencies> 更改之后可以在界面上看到一个maven刷新按钮，点击一下就开始联网下载依赖了，成功后可以看到 排除依赖 A依赖B，B依赖C，如果A不想将C依赖进来，可以同时排除C，被排除的资源无需指定版本。 <dependency> <groupId>com.itheima</groupId> <artifactId>maven-projectB</artifactId> <version>1.0-SNAPSHOT</version> <exclusions> <exclusion> <groupId>junit</groupId> <artifactId>junit</artifactId> </exclusion> </exclusions> </dependency> 依赖范围 scope值 主程序 测试程序 打包（运行） 范例 compile（默认） Y Y Y log4j test - Y - junit provided Y Y - servlet-api runtime - Y Y jdbc驱动 注意！！！这里的scope如果是test，那么它的作用范围在src/test/java下，在src/main/java下无法导包！ Maven 多模块工程 父 POM 用 <dependencyManagement> 锁版本，子模块按需在 <dependencies> 中声明自己用的依赖。对“真正所有模块都要”的依赖，可以放到父 POM 顶层 <dependencies>，让它们自动继承。 父 POM（pom.xml）: <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.example</groupId> <artifactId>parent-project</artifactId> <version>1.0.0</version> <packaging>pom</packaging> <modules> <module>service-a</module> <module>service-b</module> </modules> <!-- 1. 统一锁定版本号（子模块引用时不用写 <version>） --> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <version>2.7.3</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.20</version> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <!-- 不用写 <version>，会从上面 dependencyManagement 拿 --> <scope>provided</scope> </dependency> </dependencies> </project> 子pom <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>com.example</groupId> <artifactId>parent-project</artifactId> <version>1.0.0</version> <relativePath>../pom.xml</relativePath> </parent> <artifactId>service-a</artifactId> <packaging>jar</packaging> <dependencies> <!-- 1. 从父 dependencyManagement 拿版本，不需要写 <version> --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> </dependencies> </project> 父pom的<packaging>pom</packaging>表示它只是一个 POM 模块，不会产出任何可执行的 JAR。 子pom的<relativePath>../pom.xml</relativePath>告诉 Maven 去哪个相对路径找父 POM 文件 注意：如果子模块A依赖于B模块，那么B模块中的依赖会传递给A，比如B中引入了org.apache.httpcomponents，那么A模块的类中可以直接import这个库。反过来不行！大坑！ Maven生命周期 主要关注以下几个： • clean：移除上一次构建生成的文件 （Target文件夹） • compile：编译 src/main/java 中的 Java 源文件至 target/classes • test：使用合适的单元测试框架运行测试(junit) • package：将编译后的文件打包，如：jar、war等 • install：将打包后的产物（如 jar）安装到本地仓库 后面的生命周期执行的时候会自动执行前面所有生命周期！ compile: src/ ├── main/ │ ├── java/ │ │ └── com/ │ │ └── example/ │ │ └── App.java │ └── resources/ │ ├── application.yml │ └── static/ │ └── logo.png └── test/ ├── java/ │ └── com/ │ └── example/ │ └── AppTest.java └── resources/ └── test-data.json 映射到 target/ 后： target/ ├── classes/ ← 主代码和资源的输出根目录 │ ├── com/ │ │ └── example/ │ │ └── App.class ← 编译自 src/main/java/com/example/App.java │ ├── application.yml ← 复制自 src/main/resources/application.yml │ └── static/ │ └── logo.png ← 复制自 src/main/resources/static/logo.png └── test-classes/ ← 测试代码和测试资源的输出根目录 ├── com/ │ └── example/ │ └── AppTest.class ← 编译自 src/test/java/com/example/AppTest.java └── test-data.json ← 复制自 src/test/resources/test-data.json test： 扫描 src/test/java 下所有符合默认命名规则的测试类： **/Test*.java **/*Test.java **/*TestCase.java 编译 这些测试类到 target/test-classes。 逐个执行（默认是串行）所有这些编译后的测试类。 package： 打包失败的把test步骤去掉！ Maven镜像加速 maven构建依赖可能比较慢，需创建 .mvn/settings.xml （这个提速非常多！务必添加） <settings xmlns="http://maven.apache.org/SETTINGS/1.1.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.1.0 https://maven.apache.org/xsd/settings-1.1.0.xsd"> <mirrors> <mirror> <id>aliyun</id> <name>aliyun maven</name> <url>https://maven.aliyun.com/repository/public</url> <mirrorOf>central,apache.snapshots</mirrorOf> </mirror> </mirrors> </settings> 使用阿里云镜像加速，.mvn 放在项目根目录下
JAVA基础 JDK和JRE JDK 是开发环境，JRE 是运行环境。 JDK（Java Development Kit） = JRE + 开发工具（如 javac 编译器、调试器等）； JRE（Java Runtime Environment） = JVM + 核心类库，只负责运行 Java 程序。 开发用 JDK，运行用 JRE。 Java 面向对象的四大特性 封装（Encapsulation） 含义：把数据和操作数据的方法封装在对象内部，对外只暴露必要的接口，隐藏实现细节。 好处：降低耦合、提高安全性、便于维护。 继承（Inheritance） 含义：子类继承父类，复用代码，同时可以扩展或重写父类功能。 好处：提高代码复用性，建立层次结构。 多态（Polymorphism） 含义：同一方法调用在不同对象上表现出不同的行为（编译时多态/运行时多态）。 好处：增强灵活性、可扩展性。 在框架层面的应用 封装：Spring 的 Bean的字段通常 private，通过 setter/getter 控制访问。 继承：模板方法模式提供了执行流程的骨架,子类必须继承才能扩展步骤。 而策略模式继承只是辅助，真正的精髓是组合和可替换性。 // 伪代码示例：JdbcTemplate的execute骨架 public abstract class JdbcTemplate { public final void execute() { // final防止子类重写算法骨架 getConnection(); // 固定步骤1 doStatement(); // 抽象方法，由子类实现 releaseConnection(); // 固定步骤2 } protected abstract void doStatement(); // 可变部分，子类实现 } 多态：多态的前提之一是继承/实现。Spring 的依赖注入（DI）注入接口类型，运行时由容器选择具体实现（Servcie）。多态强调的是运行时动态分派。 编译期用的是 接口引用，运行期执行的是 实现类方法，这就是多态。 JAVA创建对象的方法 1.运用反射 Class 类的 newInstance() 方法 MyClass obj = (MyClass) Class.forName("MyClass").newInstance(); 依赖无参构造方法； 2.运用反射 Constructor 类的 newInstance() 方法 Constructor<MyClass> cons = MyClass.class.getConstructor(); MyClass obj = cons.newInstance(); 这是 反射创建对象 的推荐方式； 可调用有参或无参构造函数； 3.调用对象的 clone() 方法 MyClass obj1 = new MyClass(); MyClass obj2 = (MyClass) obj1.clone(); clone() 创建的是一个新的对象（地址不同），但复制了字段值。 是一种基于已有对象复制的创建方式。 实现类必须实现 Cloneable 接口： class MyClass implements Cloneable { int x = 10; @Override protected Object clone() throws CloneNotSupportedException { return super.clone(); // 调用 Object 的 clone() } } 4.使用 new 关键字创建 MyClass obj = new MyClass(); 说说 Java 中 HashMap 的原理？ HashMap 允许 1 个 null key、多条 null value，因为单线程场景下没歧义。 为什么引入红黑树: 当hash冲突较多的时候，链表中的元素会增多，插入、删除、查询的效率会变低，退化成O(n)使用红黑树可以优化插入、删除、查询的效率，logN级别。 转换时机: 链表上的元素个数大于等于8 且 数组长度大于等于64； 链表上的元素个数小于等于6的时候，红黑树退化成链表。 JDK8为什么从"头插法"改为"尾插法" 头插法特点： 插入时不需要遍历链表 直接替换头结点 扩容时会导致链表逆序 多线程环境下可能产生死循环 尾插法改进： 避免扩容时的链表逆序 解决多线程环境下的潜在死循环问题 死循环产生场景 旧桶链表： A -> B -> C -> null 线程1 迁移时： 先拿到 A，放到新桶头部。 此时新桶是 A -> null。 线程 2 也在迁移，刚好也拿到 B，打算放到新桶头： 修改 B.next = A，新桶成了 B -> A -> null。 线程 1 继续迁移 B（因为它也在遍历旧链表）： 此时它的 B.next 已经被线程2 改过了！ 它会把 B 再放到新桶头，把指针一改，可能出现： A.next = B B.next = A 扩容 假设某个key的hash = ...10010b，旧容量n = 16： oldIndex = hash & 01111b = 2 扩容后 2n = 32： newIndex = hash & 11111b = (hash & 01111b) + (hash & 10000b) = oldIndex + 16(如果第 5 位是 1) 于是只需要 检查 hash 的第 5 位（10000b）： 如果为 0 → newIndex = 2 如果为 1 → newIndex = 2 + 16 = 18 JAVA8及之后，扩容时只需要看 hash 的新高位，而不用重新计算哈希！！！这个 hash 会存进 Node 对象里，而不是每次都重新算。 扩容的时机：HashMap 的总元素个数，包括桶+链表+红黑树中的，而不只是看桶被占用了多少。 Java 中 ConcurrentHashMap 1.7 和 1.8 的区别？ ConcurrentHashMap不允许null的key和value 在并发环境中，返回 null 可能同时表示两种情况： 1.key 不存在 2.key 对应的 value 是 null。无法区分这两种情况，会导致线程安全逻辑混乱。 HashMap 是非线程安全的，通常在单线程环境中使用，containsKey(key)可以区分“key 不存在”还是“value 为 null”。ConcurrentHashMap 在多线程环境下，即便先判断了 containsKey(key) 为 true，在你下一行 get(key) 时，另一个线程可能已经删除了这个 key，导致返回 null，从而产生逻辑混乱。 不同JDK版本的实现对比 1.数据结构 JDK1.7： 使用 Segment（分段锁） + HashEntry数组 + 链表 的数据结构 JDK1.8及之后： 使用 数组 + 链表/红黑树 的数据结构（与HashMap类似） 2.锁的类型与宽度 JDK1.7： 分段锁(Segment)继承了 ReentrantLock Segment容量默认16，不会扩容 → 默认支持16个线程并发访问 JDK1.8： 使用 synchronized + CAS 保证线程安全 空节点：通过CAS添加（put操作，多个线程可能同时想要将一个新的键值对插入到同一个桶中，这时它们会使用 CAS 来比较当前桶中的元素（或节点）是否已经被修改过。） 非空节点：通过synchronized加锁，只锁住该桶，其他桶可以并行访问。 3.渐进式扩容（JDK1.8+） 触发条件：元素数量 ≥ 数组容量 × 负载因子(默认0.75) 扩容过程： 创建2倍大小的新数组 线程操作数据时，逐步迁移旧数组数据到新数组 使用 transferIndex 标记迁移进度 直到旧数组数据完全迁移完成 == 和 equals 的区别是什么？ 基本数据类型 ==：比较的是 值。 equals：基本数据类型（int、double、char 等）不是对象，没有 equals 方法，所以只能用 == 比较。 引用数据类型（对象）以及集合类 ==：比较的是 引用是否相同，即两个变量是否指向同一个对象。 equals：默认继承自 Object.equals，比较地址；但很多类（如 String、Integer、List）都 重写了 equals 方法，用于比较对象的 值。 List<String> list1 = Arrays.asList("A", "B"); List<String> list2 = Arrays.asList("A", "B"); System.out.println(list1 == list2); // false：不同对象 System.out.println(list1.equals(list2)); // true ：元素顺序和值完全一致 HashSet<Point> 大坑 在 Java 中，有一个 HashSet<Point> points，Point 是自定义类，里面有两个成员 x 和 y。 如果我往里面 add 了一些 Point，之后想通过新构造一个同样 x、y 的 Point 来调用 points.remove(point)，能否成功删除？ HashSet和HashMap两者底层都用 哈希表（JDK8 之后是数组 + 链表 + 红黑树）： 数组：用 hashCode() 定位桶（数组下标）。 链表/红黑树：解决哈希冲突。 扩容机制：负载因子超过阈值时，数组扩容并重新计算 hash。 HashSet.remove(Object o) 的底层流程： 计算哈希值 调用传入对象（o）的 hashCode() 方法，得到哈希值。 定位桶 根据哈希值取模，找到可能存放该对象的 桶。 如果 hashCode 不一样，元素直接定位到不同的桶，根本不会去比较 equals。 遍历桶中链表/红黑树 在对应的桶里，依次拿已有元素跟传入对象比较： 先比 hashCode（已经定位桶了，这里一般相同）。 再比 equals，确认是否是逻辑相等的对象。 删除成功 如果找到了 equals 为 true 的对象，就把它移除。否则返回 false。 contains也是类似的流程。 回答 默认情况下不能删除 自定义类 Point 没有重写 equals 和 hashCode，继承的是 Object 的实现。 这样比较时： equals 比较的是 引用地址，不是内容； hashCode 也是基于对象地址生成。 即使 x、y 相同，new Point(1,2) 和之前插入的对象也被视为 不同对象，所以 remove 会失败。 正确做法 必须同时重写 equals 和 hashCode，让两个 Point 在 x 和 y 相同的情况下被认为相等。 class Point { int x, y; Point(int x, int y) { this.x = x; this.y = y; } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Point)) return false; Point p = (Point) o; return x == p.x && y == p.y; } //只要两个 Point 的 x 和 y 相同，它们的 hashCode 就相同。 @Override public int hashCode() { return Objects.hash(x, y); // Objects.hash() 是可变参数方法 Objects.hash(a, b, c); } } 这样，下面的代码才会返回 true： points.remove(new Point(1, 2)); // 成功删除 扩展 List<Point> 在使用contains和remove的时候，必须重写Point中的equals方法，但不用重写HashCode，因为用不上。 限流算法 1.固定窗口算法 将时间划分为固定的窗口（如1分钟），在每个窗口内对请求进行计数。 如果计数超过阈值，则后续请求被拒绝。 时间窗口重置时，计数器归零。 缺点： 在窗口切换的临界点附近，可能会遭遇两倍的流量冲击。 2.滑动窗口 滑动窗口将一个大窗口划分为多个更小的时间片（Slice）。每个时间片独立计数，窗口随着时间向前滑动，计算的是最近N个时间片内的总和。 3.漏桶算法 请求来是加++++ 想象一个底部有固定开口的桶： 请求像水一样以任意速率流入桶中。 如果水超过桶的容量，则溢出（拒绝请求）。 处理请求像水从桶底以恒定速率漏出。 // 伪代码概念 long capacity; // 桶的容量 long outflowRate; // 漏出的速率（如：100ms/个） long waterLevel; // 当前水位 long lastLeakTime; // 上次漏水的时间 synchronized boolean tryAcquire() { long now = System.currentTimeMillis(); // 1. 先计算上次到现在漏掉了多少水 long waterToLeak = (now - lastLeakTime) / outflowRate; waterLevel = Math.max(0, waterLevel - waterToLeak); lastLeakTime = now; // 2. 判断加水后是否会溢出 if (waterLevel < capacity) { waterLevel++; return true; } return false; } 4.令牌桶 请求来是减------- 想象一个以恒定速率生成令牌的桶： 令牌以固定速率放入桶中，直到桶满为止。 每个请求需要从桶中获取一个令牌才能被处理。 如果桶中有令牌，请求立即被处理（允许突发流量）。 如果桶中无令牌，则请求被拒绝或等待。 // 伪代码概念 long capacity; // 桶的容量 long refillRate; // 添加令牌的速率（如：每秒10个） long tokens; // 当前令牌数量 long lastRefillTime; // 上次添加令牌的时间 synchronized boolean tryAcquire() { long now = System.currentTimeMillis(); // 1. 计算上次到现在应该添加多少令牌 long tokensToAdd = (now - lastRefillTime) * refillRate / 1000; tokens = Math.min(capacity, tokens + tokensToAdd); // 桶不能超过容量 lastRefillTime = now; // 2. 取一个令牌 if (tokens > 0) { tokens--; return true; } return false; } 全局变量和局部变量 public class Person { // 实例变量（对象的属性，每个对象一份） String name; int age; // 静态变量（类变量，所有对象共享一份） static String species = "Human"; public void introduce() { // 局部变量（方法里的变量，每次调用方法时创建） String greeting = "Hello, my name is "; System.out.println(greeting + name + ", age " + age + ", species " + species); } } ① 实例变量（name, age） 定义位置：类中、方法外，且没有 static 修饰。 归属：属于对象，每个对象都有自己独立的副本。 存储位置：堆内存（Heap），随对象存活。 默认值：如果没赋值，自动有默认值（null、0 等）。 Person p1 = new Person(); p1.name = "Alice"; p1.age = 20; Person p2 = new Person(); p2.name = "Bob"; p2.age = 25; // p1 和 p2 的实例变量互不影响 System.out.println(p1.name); // Alice System.out.println(p2.name); // Bob p1、p2都是 局部变量（引用变量），存储在栈里。 p1 指向一个堆内存中新建的 Person 对象，这个对象有自己的实例变量 name = "Alice", age = 20。 ② 静态变量（species） 定义位置：用 static 修饰，写在类中。 归属：属于类本身，不属于某个对象。所有对象共享一份。 存储位置：方法区（JDK 8 以后在堆里的元空间）。 默认值：和实例变量一样，有默认值。 ③ 局部变量（greeting） 定义位置：在方法、构造方法、代码块里。 归属：只在当前方法或代码块有效。 存储位置：栈内存（Stack），随方法调用创建，方法结束销毁。 默认值：必须手动赋值，否则不能使用。 500. 什么是 Java 的 CAS（Compare-And-Swap）操作？ CAS操作包含三个基本操作数： 内存位置(V)：要更新的变量 预期原值(A)：认为变量当前应该具有的值 新值(B)：想要更新为的值 CAS 工作原理： 读取内存位置V的当前值为A 计算新值B 当且仅当V的值等于A时，将V的值设置为B 如果不等于A，则操作失败(通常重试) 伪代码表示： if (V == A) { V = B; return true; } else { return false; } ABA问题 假设一个共享变量 V = A，线程 T1 和 T2 同时操作： T1 读取 V 的值，得到 A，准备把它改成 C T2抢先执行： 把 V 改成 B 又把 V 改回 A T1继续执行 CAS： 发现 V 还是 A CAS 成功，把 V 改成 C 但实际上 V 已经被 T2 改过一次（A→B→A），T1 却完全察觉不到 解决方案：比较值 + 版本号，避免被 A→B→A 误导 class AtomicStampedValue { volatile int value; volatile int version; boolean compareAndSwap(int expectedValue, int expectedVersion, int newValue) { synchronized (this) { // 同时比较 value 和 version if (value == expectedValue && version == expectedVersion) { value = newValue; version++; // 每次修改版本号 +1 return true; } else { return false; } } } } JAVA异常体系 两大核心子类：Error 和 Exception Error (错误)指程序无法处理的严重问题，通常与代码编写无关，而是JVM运行时本身出现的错误。应用程序不应该尝试捕获（catch） 这些错误，因为通常无法恢复。 Exception(异常)指程序本身可以处理的问题，是开发者需要关心的异常。 Checked Exception:这些异常都继承自 Exception（但不是 RuntimeException），编译期必须显式处理：要么 try…catch 捕获，要么 throws 向上抛出。 类别 常见检查异常 典型触发场景 I/O 文件 IOException、FileNotFoundException 文件读写、网络传输、文件路径不存在 反射 / 类加载 ClassNotFoundException、IllegalAccessException Class.forName() 找不到类、无权限反射 数据库 JDBC SQLException 执行 SQL、数据库连接失败或语法错误 并发线程 InterruptedException 线程 sleep()、wait()、join() 被中断 解析 / 格式化 ParseException 解析日期、数字、文本格式错误 捕获： try { methodA(); } catch (IOException e) { ... } 向上抛出： public void methodB() throws IOException { methodA(); } // Thread.sleep 静态方法 Thread.sleep(1000); // 任何地方都能用，暂停当前线程 1 秒 // Object.wait 实例方法 synchronized(lock) { lock.wait(); // 必须先获得 lock 的监视器锁 } 补充：Compilation Error Compilation Error（编译错误） 是指 在程序编译阶段由编译器发现的问题。 分类 常见情况 举例说明 ① 语法错误 拼写错误、少了分号、括号不匹配 System.out.println("Hi")（缺少分号） ② 类型错误 不兼容的类型赋值 int x = "abc"; ③ 访问控制错误 访问 private 成员、包外访问 default 方法 obj.y （y 是 private） ⑧ 抽象类或方法使用错误 实例化抽象类 A obj = new A();（A 是 abstract） ⑨ 异常处理错误（受检异常） 未捕获或声明 Checked Exception new FileReader("a.txt"); 未加 try-catch 或 throws 补充：异常捕获顺序很重要 public class Test { public int div(int a, int b) { try { return a / b; } catch (Exception e) { System.out.println("Exception"); } catch (NullPointerException e) { System.out.println("NullPointerException"); } catch (ArithmeticException e) { System.out.println("ArithmeticException"); } finally { System.out.println("finally"); } return 0; } public static void main(String[] args) { Test demo = new Test(); System.out.println("商是：" + demo.div(9, 0)); } } 编译无法通过，因为catch 顺序要从小到大（子类 → 父类） Stream流 流的基本使用 Java 8 引入的 Stream API 是函数式编程风格的集合操作工具，能让你用声明式写法来处理数据。 1.创建流 List<String> list = List.of("a","b","c"); // 创建固定大小的列表，不能增删 //List<String> list = Arrays.asList("a","b","c"); //效果同上 Stream<String> s = list.stream(); 2.中间操作 filter：过滤 list.stream().filter(x -> x.startsWith("a")) map：转换 list.stream().map(s -> s.toUpperCase()) sorted：排序 list.stream().sorted() distinct：去重 list.stream().distinct() limit / skip：截取 list.stream().skip(1).limit(2) 函数的参数基本上都是函数式接口，可以用lambda表达式实现。 3.终止操作（常用的） forEach：遍历 list.stream().forEach(System.out::println); collect：收集 List<String> res = list.stream().collect(Collectors.toList()); count：计数 long c = list.stream().count(); 示例： import java.util.*; import java.util.stream.*; public class StreamDemo { public static void main(String[] args) { List<String> names = Arrays.asList("Tom", "Jerry", "Alice", "Tom", "Bob"); // 示例：取长度大于 3 的名字，去重，按字母逆序，转成大写，收集到 List List<String> result = names.stream() .filter(name -> name.length() > 3) // 筛选 .distinct() // 去重 .sorted((a, b) -> b.compareTo(a)) // 排序 .map(s -> s.toUpperCase()) // 转换 .collect(Collectors.toList()); // 收集 System.out.println(result); // [ALICE, JERRY] } } 使用注意点: 流只能用一次 Stream<String> s = names.stream(); s.forEach(System.out::println); s.forEach(System.out::println); // ❌ 报错：stream has already been operated upon or closed 补充：方法引用 引用现有方法作为 lambda 的实现，语法格式为： 类名或对象名::方法名 示例1：任意对象实例方法引用 class Person { private String name; public String getName() { return name; } } 我们想提取所有人的名字： List<String> names = people.stream() .map(Person::getName) .collect(Collectors.toList()); 等价于： .map(p -> p.getName()) 示例2：特定对象的实例方法引用 list.forEach(s -> System.out.println(s)); //等价于 list.forEach(System.out::println); 示例3：静态方法引用 Stream.of("1", "2", "3") .map(Integer::parseInt) .forEach(System.out::println); JAVA序列化 二进制序列化和 JSON 序列化 对比维度 二进制序列化（如 Protobuf、Kryo） JSON 序列化 数据格式 二进制（紧凑、不可读） 文本（可读、结构化） 体积大小 更小（无冗余字段名，高效编码） 更大（带字段名，格式冗余） 解析速度 更快（直接二进制解析，无词法分析） 较慢（需解析字符串） 可读性 不可读（需工具解析） 可读（直接查看） 适用场景 高性能 RPC（gRPC）、游戏、金融高频交易 REST API、前端交互、日志存储 序列化和反序列化在哪一层？ 属于 应用层，解决应用数据的跨网络传输或持久化问题，例如： 将 Java 对象转为 JSON/二进制，通过 HTTP（REST API）或 TCP（Dubbo RPC）发送。 将 Redis 中的字符串反序列化为程序中的对象。 四种引用类型 引用类型 强度 GC 回收时机 常见用途 代码示例 强引用 (Strong Reference) 最强 永远不会被 GC 回收（除非失去所有强引用） 普通对象赋值 Object obj = new Object(); 软引用 (Soft Reference) 次强 内存不足时回收 内存敏感缓存（如图片缓存） Bitmap bitmap = new Bitmap();SoftReference<Bitmap> cache = new SoftReference<>(bitmap);bitmap = null; 弱引用 (Weak Reference) 较弱 下一次 GC 时回收 临时缓存、ThreadLocal.Key WeakReference<Metadata> metaRef = new WeakReference<>(data); 虚引用 (Phantom Reference) 最弱 GC 时回收，但需手动清理 对象销毁跟踪（如堆外内存回收） ReferenceQueue<File> queue = new ReferenceQueue<>(); PhantomReference<File> phantomRef = new PhantomReference<>(file, queue); Integer 为什么Java需要 Integer 这种封装类？ 1）Java 是纯面向对象语言，很多集合类（如 ArrayList、HashMap）泛型只能存对象，不能存基本类型。 2）封装类提供了很多实用方法（如 Integer.parseInt、Integer.valueOf、进制转换、比较）；继承自 java.lang.Object，可以使用 Object 的通用方法（如 equals()、hashCode()、toString()） 3）基本类型没有 null 值，封装类可以用 null 表示“缺失/未知”。 4）自动装箱/拆箱，int ↔ Integer 自动转换，提升了开发便利性。 Integer.valueOf()缓存机制 Integer.valueOf(int i) 在内部会先判断 i 是否在 [-128, 127] 之间。 如果在这个区间，会直接返回缓存池里的对象；否则才会 new 一个新的 Integer。 因为小整数是最常用的，比如循环下标、常量、集合大小等，可节省内存 Integer a = Integer.valueOf(100); Integer b = Integer.valueOf(100); System.out.println(a == b); // true（因为在缓存范围） Integer x = Integer.valueOf(200); Integer y = Integer.valueOf(200); System.out.println(x == y); // false（超出缓存范围，new 出来的对象） 补充1： new Integer(127) == new Integer(127) -》false，new Integer(...) 每次都会新建对象，不会走缓存机制 Long.valueOf(127L) == Long.valueOf(127L) -》true，和 Integer.valueOf() 类似，Long.valueOf() 也有缓存机制，默认缓存范围同样是 [-128, 127]。 补充2： Integer 的缓存池cache[]在 类加载时 就会被创建，缓存的对象存在 堆（Heap）中，默认范围是 [-128, 127]。之后所有 Integer.valueOf() 调用只是在 堆中的缓存数组中取引用，而不是重新 new。 String vs StringBuilder vs StringBuffer String：不可变，底层是 final char[]，一旦创建内容不可改。 StringBuilder / StringBuffer：可变字符串，底层是一个可变数组，内容可以增删改。 区别： StringBuffer：线程安全，方法上加了 synchronized。 StringBuilder：非线程安全，但性能更快。 StringBuffer JDK 8 及以前:StringBuffer 内部维护一个 char[] 数组来存储字符。 JDK 9 及以后:Java 对 String、StringBuilder、StringBuffer 做了优化，引入了 byte[] + coder（编码标识） 的实现，称为 Compact Strings。 byte[] 用来存储字符内容。 coder 表示编码方式（0 = Latin1，1 = UTF-16）。 这样在大多数只包含拉丁字符的字符串里可以节省一半的内存。 面试官：“StringBuffer 会不会直接引用常量池里的字符串？” 你： “不会。字面量 "abc" 会进入常量池，但 StringBuffer 在构造时会把 "abc" 的字符复制到自己内部的数组里。这样它就可以在堆里维护一个可变的副本，而不会破坏常量池的不变性。如果它直接用常量池对象，那修改 sb 的时候就会污染常量池，导致整个 JVM 中所有引用 "abc" 的地方都被影响，这是设计上不允许的。” StringBuffer sb = new StringBuffer("abc"); sb.append("d"); // 现在变成 "abcd" "abc" 作为字面量，一定在字符串常量池里。 sb.append("d") 的过程是： 取出字面量 "d"（常量池已有）。 把 'd' 加到 sb 的内部数组中（堆内存）。 最终结果 "abcd" 只存在于 sb 的堆内数组里，不会自动放入常量池。 字符串常量池 String a = "1"; String b = "1"; 变量 a和变量 b会指向同一个内存中的 String对象。 原理：常量池中有，就直接返回其引用；没有，就创建一个放进去再返回。 存放位置： Java 7 之前：字符串常量池逻辑上属于方法区（Method Area） 的运行时常量池（Runtime Constant Pool） 的一部分。而方法区的具体实现是 永久代（PermGen）。 问题：永久代大小有限且难以调整，容易发生 OutOfMemoryError: PermGen space。 Java 7 开始：字符串常量池被从永久代移动到了 Java 堆（Heap） 中。 Java 8 及以后：永久代被彻底移除，取而代之的是元空间（Metaspace）（用于存类元信息、方法码等）。而字符串常量池依然留在堆中。 注意：逻辑上一直属于方法区（运行时常量池的一部分） 常见操作：intern() 方法 String s1 = new String("abc"); String s2 = s1.intern(); String s3 = "abc"; System.out.println(s1 == s2); // false System.out.println(s2 == s3); // true String s1 = new String("abc"); 创建了 两个对象（常量池 + 堆各一个）： "abc" 常量在类加载时进入字符串常量池； s1 是一个新建的堆中对象； String s2 = s1.intern(); 如果池中已有 "abc"，则返回池中的引用； 如果没有，就把这个堆中的对象引用放进常量池，以后重用。 LeetCode 中常见操作类型与推荐结构 需求类型 推荐数据结构 原因 随机访问（按索引 get/set） ArrayList / 原生数组 int[] O(1) 访问，最快 在尾部插入或删除 ArrayList / ArrayDeque 尾部操作快，连续内存友好 在头部插入或删除 LinkedList / ArrayDeque ArrayList 头插删除太慢（O(n)） 在中间频繁插入、删除 LinkedList（前提是已有节点引用） 直接修改指针，不移动数组 队列（FIFO） ArrayDeque（首选） / LinkedList ArrayDeque 性能更好 栈（LIFO） ArrayDeque（首选） 比 Stack 更高效、线程安全性不强制 需要排序、二分、索引定位 ArrayList / 数组 支持 Collections.sort()、二分查找 双端队列 ArrayDeque 头尾操作都是 O(1) 频繁查找是否存在 HashSet / HashMap O(1) 查找，比 List 快得多 算法中直接用LinkedList比较少。 JUC多线程并发 线程的生命周期 新建状态（New）：调用 new Thread() 时 ，线程对象被创建，但尚未调用 start() 方法。 就绪状态（Runnable）：调用了 start() 方法后，线程进入就绪状态，等待被 JVM 的线程调度程序分配 CPU 时间片。 （注意，start后不一定马上运行！！要等时间片） 运行状态（Running）：线程获得 CPU 时间片后开始执行 run() 方法中的代码。 阻塞状态（Blocked）：线程因某些原因（如等待资源）进入阻塞状态，暂时停止执行。 Blocked：等待锁（如同步块） Waiting：无限期等待（如 wait()、join()） Timed Waiting：有限期等待（如 sleep(ms)、wait(ms)） 终止状态（Terminated）：线程执行完毕或被notifyAll()强制终止。 wait、notify、notifyall wait() —— 线程主动让出锁并等待 让当前线程主动释放对象锁，进入等待状态（Waiting）。 等待其他线程调用该对象的 notify() 或 notifyAll() 来唤醒。 notify() —— 唤醒一个等待线程 唤醒一个正在 wait() 的线程，使其从等待队列转入锁的争用队列。 被唤醒的线程还需要重新获取锁才能继续执行。 notifyAll() —— 唤醒所有等待线程 唤醒所有在该对象上等待的线程； 所有线程都会从等待队列移动到争用队列； 只有拿到锁的线程能继续执行，其他会重新进入等待。 场景：消费者和生产者模型中，如果用 notify() 可能只唤醒了同类线程（比如生产者唤醒另一个生产者），造成假死。 线程池 线程池创建与原理 1.通过 Executors 工具类 ExecutorService pool = Executors.newFixedThreadPool(5); 固定线程数，任务多时放在无界队列里排队。适合 任务量大但稳定 的场景。 2.通过 ThreadPoolExecutor 创建（推荐） import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.util.concurrent.*; @Configuration public class ThreadPoolConfig { @Bean public ThreadPoolExecutor threadPoolExecutor() { return new ThreadPoolExecutor( 2, // corePoolSize 核心线程数 5, // maximumPoolSize 最大线程数 60, // keepAliveTime 空闲线程存活时间 TimeUnit.SECONDS,// 时间单位 new ArrayBlockingQueue<>(10), // 队列 Executors.defaultThreadFactory(), // 线程工厂 new ThreadPoolExecutor.AbortPolicy() // 拒绝策略 ); } } 创建线程池推荐项目里全局复用这一个线程池 使用 import org.springframework.stereotype.Service; import java.util.concurrent.ThreadPoolExecutor; @Service public class MyService { private final ThreadPoolExecutor threadPoolExecutor; public MyService(ThreadPoolExecutor threadPoolExecutor) { //构造器注入 this.threadPoolExecutor = threadPoolExecutor; } public void doSomething() { threadPoolExecutor.execute(() -> { System.out.println("任务执行中：" + Thread.currentThread().getName()); }); } } 为什么阿里巴巴 Java 开发手册里禁止直接用 Executors？ 答：因为 Executors 创建的线程池容易导致 任务队列无限膨胀（OOM），比如 newFixedThreadPool 用的是无界队列。用 ThreadPoolExecutor 可以自己设定队列大小，更安全 3.ForkJoinPool JAVA线程池工作原理 当调用 execute(Runnable task) 提交任务时，逻辑大致是这样的： 如果当前线程数 < corePoolSize 直接新建一个线程来处理任务。 否则（线程数 ≥ corePoolSize） 尝试把任务放进工作队列（workQueue）。 如果队列没满 → 入队成功，等候线程来取。 如果队列已满 判断当前线程数是否 < maximumPoolSize： 如果是 → 新建一个非核心线程处理任务。 如果不是 → 执行拒绝策略。 核心线程的默认行为：不会因为空闲就被回收，即使没有任务，也会一直存在。非核心线程空闲时间超过 keepAliveTime 才会被回收。 拒绝策略 策略类 名称 行为说明 AbortPolicy (默认) 中止策略 直接抛出 RejectedExecutionException 异常 🚫 CallerRunsPolicy 调用者运行策略 由提交任务的线程（即调用 execute() 的线程）自己执行任务 🧵 DiscardPolicy 丢弃策略 直接丢弃该任务，不抛异常 ❌ DiscardOldestPolicy 丢弃最旧策略 丢弃队列中最早的一个任务，然后尝试执行当前任务 🔁 如果用无界队列，会怎样？ workQueue.offer(task) 永远返回 true（队列永远不满）； 因此永远不会进入创建非核心线程的分支； 所以线程数固定在 corePoolSize。 如果生产速度 > 消费速度，队列会持续增长 无界队列不断堆积任务对象，最终撑爆内存 等待队列过长，任务响应越来越慢 如何知道线程池中线程运行状态 由于任务的执行是在另一个线程中异步进行的，所以我们在提交任务的线程（主线程）中尝试用 try-catch 是捕捉不到的。我们需要使用一些回调机制或定制化手段来捕获和处理异常。 try { threadPoolExecutor.execute(() -> { // 任务代码 int i = 1 / 0; // 故意制造异常 }); } catch (Exception e) { System.out.println("捕获异常: " + e); } 这样是无法捕捉异常的！！！ **方案 A：**通过 Future捕获异常（推荐） ThreadPoolExecutor executor = new ThreadPoolExecutor( 2, 4, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>() ); Future<?> future = executor.submit(() -> { int a = 1 / 0; // 抛出 ArithmeticException }); try { future.get(); // 这里会抛出 ExecutionException，其 cause 是真正的异常 } catch (ExecutionException e) { Throwable realException = e.getCause(); System.out.println("捕获到异常: " + realException.getMessage()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); // 恢复中断状态 } **方案 B：**重写 afterExecute，全局捕获 自定义 ThreadPoolExecutor，重写 afterExecute方法捕获异常（包括 submit()提交的任务）。 public class CustomThreadPoolExecutor extends ThreadPoolExecutor { // 构造器省略... @Override protected void afterExecute(Runnable r, Throwable t) { super.afterExecute(r, t); if (t == null && r instanceof Future<?>) { try { Future<?> future = (Future<?>) r; if (future.isDone()) { future.get(); // 触发 ExecutionException } } catch (ExecutionException e) { t = e.getCause(); // 获取真实异常 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } if (t != null) { System.err.println("线程池捕获到异常: " + t.getMessage()); // 这里可记录日志、告警或重启任务 } } } // 使用自定义线程池 ThreadPoolExecutor executor = new CustomThreadPoolExecutor(...); executor.submit(() -> { int a = 1 / 0; }); // 异常会被 afterExecute 捕获 这里重写了 afterExecute，无论是普通 Runnable 还是 Callable，都能统一捕获异常，并打印是哪一个线程出问题。 方案C：在任务内部 try-catch （但是主线程是无感的，不太符合！！！） executor.submit(() -> { try { int a = 1 / 0; } catch (Exception e) { System.out.println("任务内部处理异常: " + e.getMessage()); // 可记录日志、重试或补偿 } }); 虚线程 定义：虚线程是 JDK21 为了解决高并发下线程数量瓶颈而引入的一种 轻量级线程。 虚线程前，主要靠线程池+IO多路复用来实现高并发连接，缺点是编程复杂！！！ 区别：传统的线程（称为平台线程）是直接映射到操作系统内核线程；而虚线程则由 JVM 调度，并且不与内核线程一一绑定。 平台线程：笨重 → 每个线程都有 1MB 默认栈内存，数量有限（几千 ~ 几万）,否则会内存溢出 虚线程：轻量 → 几乎没有额外内存开销，可以轻松创建百万级。 作用： 1）当虚线程执行阻塞调用（如 IO）时，JVM 会把它挂起，释放底层内核线程，不会浪费资源。 2）不用像以前那样必须写异步回调（CompletableFuture/Reactive），同步代码就能写高并发逻辑。 public class VirtualThreadDemo { public static void main(String[] args) throws InterruptedException { try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { for (int i = 0; i < 1_000_000; i++) { int id = i; executor.submit(() -> { Thread.sleep(1000); // 看似阻塞，但虚线程会挂起，不占用内核线程 System.out.println("Task " + id + " done"); return null; }); } } } } 普通线程1000000个线程sleep，系统几乎撑不住（线程数上限、内存爆炸）。 用虚线程JVM 碰到虚线程阻塞调用（如 sleep、IO 等），会把虚线程 挂起，把底层 OS 线程还给调度器，到点后再把虚线程唤醒，继续执行。 虚线程和epoll协同 虚线程和 epoll 并不冲突，而是协同：虚线程在遇到阻塞 IO 时会挂起（unmount），JVM 在底层通过 epoll 等多路复用机制等待 IO 事件，当 IO 就绪后再恢复虚线程执行。 虚线程更高级，epoll是底层。 用户代码层（同步写法） ┌──────────────────────────────────────┐ │ VirtualThread: │ │ out.println(); │ │ String line = in.readLine(); ← 阻塞调用点 └──────────────────────────────────────┘ ↓ JVM 调度器检测阻塞 ↓ ┌──────────────────────────────────────┐ │ JVM IO Poller: 使用 epoll_wait 等待 │ │ - 注册 fd → epoll_ctl │ │ - 等待事件 → epoll_wait │ └──────────────────────────────────────┘ ↓ IO 就绪（EPOLLIN） ↓ ┌──────────────────────────────────────┐ │ JVM 调度器唤醒虚线程 │ │ 恢复 Continuation → 继续执行 │ └──────────────────────────────────────┘ synchronized synchronized (obj) { // 临界区代码 } 锁升级 偏向→轻量级→重量级是 synchronized 的 JVM 优化路径，锁的升级方向是不可逆的 第一阶段：无锁 -> 偏向锁 (适用于：几乎无竞争)： 场景：假设一个同步代码块或方法，在绝大多数情况下都只有一个线程在访问。 当一个线程第一次访问同步块时，它会通过 CAS 操作，将线程ID 写入对象头的 Mark Word。 对象进入“偏向锁”状态，表示此锁偏向于该线程。 同一线程再次进入时，只比较 Mark Word 中的线程 ID；匹配则直接进入;不匹配则撤销偏向锁,升级为轻量级锁。 **第二阶段：**偏向锁 -> 轻量级锁 触发条件：有另外一个线程来尝试获取这个锁。 1）偏向锁被撤销后，锁对象进入轻量级锁状态。 2）线程在进入同步块时，会在自己栈帧中创建一份 锁记录（Lock Record），并通过 CAS 尝试将对象头的 Mark Word 更新为指向该锁记录的指针。 3）如果 CAS 成功，则该线程成功获取轻量级锁。 4）若 CAS 失败，表示锁已被其他线程持有，此时线程不会立即阻塞，而是**自旋（Spin）**等待 通过多次重试 CAS 操作，期望前一个线程很快释放锁 如果在短时间内获取到锁，可避免线程挂起与唤醒的系统开销 **第三阶段：**轻量级锁 -> 重量级锁 触发条件： 当自旋失败次数超过阈值，或有多个线程同时参与竞争时。 现在抢东西的人太多了，文明排队的方式（自旋）太浪费体力（CPU）且效率低下。于是叫来了管理员（操作系统内核）来维护秩序，没拿到的人先去睡觉（挂起），等有了再被叫醒。 锁消除 含义：JIT 编译器在运行时即时编译优化时，通过逃逸分析发现某段同步代码块里的锁对象 不会被多线程共享，就会把这把锁直接“消除”，不生成加锁字节码。 结果：虽然你写了 synchronized，但实际运行时不会真的加锁。 public String concatString(String s1, String s2) { StringBuffer sb = new StringBuffer(); // 局部变量，不会逃逸出方法 sb.append(s1).append(s2); return sb.toString(); } StringBuffer.append() 本身是 synchronized 方法。 但是这里的 sb 是局部变量，不可能被其他线程访问。 JIT 通过逃逸分析发现 sb 不会“逃逸”出当前方法 → 就会触发 锁消除，把 synchronized 去掉。 实际运行时，这个方法的执行效率就和 StringBuilder 一样。 volatile 在 Java 中，volatile 是一个 轻量级同步机制，主要有两个作用： 保证可见性：当一个线程修改了 volatile 变量的值，其他线程能立即“看到”最新值。 禁止指令重排序：保证对该变量的读写操作不会和前后的指令发生重排序，从而在多线程下避免出现“乱序执行”的问题。（双重检查单例模式中） 原理: JVM 底层通过 内存屏障（Memory Barrier） 来实现这两个效果： 可见性： volatile 写操作 → 编译器会在写操作 前 插入一个 StoreStore 屏障 在写操作 后 插入一个 StoreLoad 屏障 这样能保证：写之前的所有操作对其他线程可见，写之后其他线程能立刻读到。 volatile 读操作 → 在读操作 前 插入一个 LoadLoad 屏障 在读操作 后 插入一个 LoadStore 屏障 这样能保证：读之后的所有操作一定拿到的是最新值，不会被重排到读之前。 禁止重排序： 内存屏障同时约束了编译器和 CPU 的优化，不允许把 volatile 读写与其前后的操作交换顺序。 happens-before: volatile 写 → happens-before → 后续对该 volatile 变量的读。也就是说，线程 A 写入一个 volatile 变量，线程 B 之后读取到这个 volatile 变量时，能看到 A 写入之前的所有修改。 normalVar = 1; // 普通写 flag = true; // volatile 写 根据 happens-before 规则，其他线程只要看到 flag == true（volatile 读），就一定能看到 normalVar = 1。 volatile 不能保证原子性，比如 count++ 这样的复合操作仍然存在线程安全问题。 ThreadLocal 每个线程内部维护一个私有变量 ThreadLocalMap,每个 ThreadLocal 对象 作为键（Key），存储的值（Value）由 ThreadLocalMap管理。 假设你在代码中定义了两个 ThreadLocal 变量并设置值： ThreadLocal<String> userContext = new ThreadLocal<>(); ThreadLocal<Integer> requestId = new ThreadLocal<>(); userContext.set("Alice"); // 存储第一个字段 requestId.set(123); // 存储第二个字段 底层存储结构: // 伪代码展示当前线程的存储结构 Thread.currentThread().threadLocals = { // Entry[] table (ThreadLocalMap 内部数组) [0]: Entry(userContext, "Alice"), // Key 是 ThreadLocal 对象，Value 是存储的值 [1]: Entry(requestId, 123) }; Entry的 Key（ThreadLocal 对象）是弱引用，避免内存泄漏（但 Value 是强引用，需手动 remove()）。 void someMethod() { ThreadLocal<Long> localVar = new ThreadLocal<>(); // 局部变量（强引用） localVar.set(100L); } // 方法结束，localVar 被回收（key 变成 null） 这样的话，key会在下一次GC的时候被回收。 try { userContext.set("Alice"); // ...业务逻辑 } finally { userContext.remove(); // 必须清理！ } 在 Spring Boot 中，通过 HandlerInterceptor实现 ThreadLocal 的安全清理。 具体做法是：在拦截器的 preHandle方法中设置 ThreadLocal 值，在 afterCompletion方法（无论请求成功或异常都会触发）中调用 remove()彻底清理。这样能确保线程池中的线程被重用时，不会残留脏数据。 不调用 remove()的后果 Key（ThreadLocal 对象）：由于 ThreadLocalMap.Entry的 Key 是弱引用（WeakReference），当 ThreadLocal对象失去强引用时（如置为 null），Key 会在下一次 GC 时被回收。 Value（存储的值）：Value 是强引用，即使 Key 被回收，Value 仍会保留在 ThreadLocalMap中，除非显式调用 remove()或线程终止。 CountDownLatch 字面意思就是 “倒计数锁存器”。 作用：让一个或多个线程 等待，直到一组操作完成。 内部维护一个 计数器（初始值由构造函数指定），每次调用 countDown() 就减 1，当计数器减到 0 时，所有在 await() 上等待的线程都会被唤醒。 使用场景：1)主线程等待子任务完成 CountDownLatch latch = new CountDownLatch(3); for (int i = 0; i < 3; i++) { new Thread(() -> { try { // 执行任务 } finally { latch.countDown(); // 完成一个任务 } }).start(); } latch.await(); // 主线程阻塞，直到3个子任务都完成 System.out.println("所有任务完成，主线程继续"); 2)多个线程等待统一起点 CountDownLatch latch = new CountDownLatch(1); for (int i = 0; i < 10; i++) { new Thread(() -> { try { latch.await(); // 等待统一起跑 System.out.println(Thread.currentThread().getName() + " 开始执行"); } catch (InterruptedException e) {} }).start(); } Thread.sleep(2000); System.out.println("主线程发令"); latch.countDown(); // 所有等待的线程同时被唤醒 AtomicLong 在多线程开发中，AtomicLong 是最常见、最实用的原子类之一。它提供高性能的无锁原子操作，非常适合管理计数、金额、库存等需要保证一致性的数值。 对单个变量实现无锁的原子操作 AtomicLong 示例：多线程安全计数器 import java.util.concurrent.atomic.AtomicLong; public class AtomicLongDemo { private static final AtomicLong counter = new AtomicLong(0); public static void main(String[] args) throws InterruptedException { int threadCount = 10; Thread[] threads = new Thread[threadCount]; for (int i = 0; i < threadCount; i++) { threads[i] = new Thread(() -> { // 每个线程自增1000次 for (int j = 0; j < 1000; j++) { counter.incrementAndGet(); // 原子 +1 } }, "线程-" + i); threads[i].start(); } // 等所有线程执行完 for (Thread t : threads) t.join(); System.out.println("最终计数值：" + counter.get()); } } AtomicLong 常用方法速查表 方法名 说明 返回值 示例 get() 获取当前值 当前值 long v = counter.get(); set(long newValue) 设置新值（非原子操作） 无 counter.set(100); getAndSet(long newValue) 原子地设置新值，并返回旧值 旧值 long old = counter.getAndSet(50); compareAndSet(long expect, long update) 如果当前值等于预期值，则更新为新值 true / false counter.compareAndSet(100, 200); incrementAndGet() 原子地加 1，返回新值 新值 counter.incrementAndGet(); // ++i getAndIncrement() 原子地加 1，返回旧值 旧值 counter.getAndIncrement(); // i++ addAndGet(long delta) 原子地加上 delta，返回新值 新值 counter.addAndGet(10); // +=10 getAndAdd(long delta) 原子地加上 delta，返回旧值 旧值 counter.getAndAdd(10); // +=10，但返回加前值 getAndXxx() 形式的返回 旧值 XxxAndGet() 形式的返回 新值 compareAndSet() 是实现“无锁并发”的核心操作 getAndAdd() 是存款/累计类操作最常用的方法 如何多线程循环打印1-100数字？ public class AlternatePrint { private static int count = 1; private static final Object lock = new Object(); public static void main(String[] args) { new Thread(() -> { while (count <= 100) { synchronized (lock) { if (count % 2 == 1) { System.out.println(Thread.currentThread().getName() + ": " + count++); lock.notify(); } else { try { lock.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } } } }, "Thread-1").start(); new Thread(() -> { while (count <= 100) { synchronized (lock) { if (count % 2 == 0) { System.out.println(Thread.currentThread().getName() + ": " + count++); lock.notify(); } else { try { lock.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } } } }, "Thread-2").start(); } } 线程间是如何通信的？ 共享内存：线程通过共享变量通信，需要同步机制保证可见性和原子性。比如 volatile、synchronized、Lock、wait/notify。 消息传递：线程通过消息队列或管道传递数据，比如 BlockingQueue、PipedStream、并发工具类。 在 Java 里，最常见的做法是用 volatile 做标志位通知，或者用 BlockingQueue 实现生产者-消费者模型。 共享内存方式 —— 用 volatile 做标志位 public class VolatileFlagDemo { private static volatile boolean running = true; public static void main(String[] args) throws InterruptedException { // 工作线程 Thread worker = new Thread(() -> { while (running) { System.out.println("Working..."); try { Thread.sleep(500); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } System.out.println("Stopped."); }); worker.start(); // 主线程 2 秒后修改标志位 Thread.sleep(2000); running = false; System.out.println("Main thread set running=false"); } } 消息传递方式 —— 用 BlockingQueue 实现生产者-消费者 生产者调用 queue.put(msg) 把消息放入队列： 如果队列满了，会阻塞，直到有空间。 消费者调用 queue.take()： 如果队列为空，消费者会阻塞，直到有新消息进入。 import java.util.concurrent.*; public class BlockingQueueDemo { private static final BlockingQueue<String> queue = new LinkedBlockingQueue<>(); public static void main(String[] args) { // 生产者线程 Thread producer = new Thread(() -> { try { for (int i = 1; i <= 5; i++) { String msg = "Message-" + i; queue.put(msg); // 放入队列 System.out.println("Produced: " + msg); Thread.sleep(300); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); // 消费者线程 Thread consumer = new Thread(() -> { try { while (true) { String msg = queue.take(); // 从队列取出 System.out.println("Consumed: " + msg); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); producer.start(); consumer.start(); } } 前面的多线程循环打印1-100，线程之间通过 共享变量 count 来传递状态，同时用 wait/notify 来协调线程的执行顺序，属于共享内存模型 下的通信。 CountDownLatch内部其实也是用 共享内存 + AQS，线程通过 await() 进入阻塞队列，等计数器归零后被唤醒，但从使用者角度，它更像是 消息/信号通知 的抽象。 Reentrant Reentrant是可重入锁，可重入是指：同一个线程可以重复获取自己已经持有的锁，不会死锁。 import java.util.concurrent.locks.ReentrantLock; public class Example { private final ReentrantLock lock = new ReentrantLock(); public void methodA() { lock.lock(); // 第一次获取锁 try { System.out.println("Method A"); methodB(); // 调用 methodB，会再次获取锁（可重入） } finally { lock.unlock(); // 释放锁 } } public void methodB() { lock.lock(); // 第二次获取锁（可重入） try { System.out.println("Method B"); } finally { lock.unlock(); // 释放锁 } } public static void main(String[] args) { Example example = new Example(); example.methodA(); // 测试调用 } } 但它是独占锁，同一时刻只允许一个线程持有锁。 AQS AQS 是作为构建各种锁/同步器的基础。 基于它实现的有： 独占锁：ReentrantLock 共享锁：Semaphore、CountDownLatch 读写锁：ReentrantReadWriteLock 其他同步工具：FutureTask 等 获取资源失败的线程 → 被放入 等待队列（FIFO）。 唤醒机制 → 当资源释放时，队列中的线程按顺序被唤醒，尝试再次获取资源。 公平/非公平 公平/非公平 → AQS 提供了公平/非公平两种获取策略，子类可选择。 公平锁（Fair）： 按照 FIFO 顺序 获取锁，谁先来谁先拿。线程必须排队，避免“插队”。 非公平锁（NonFair）： 允许线程在 CAS 抢占时“插队”。锁释放时，队头线程会被唤醒尝试获取，但与此同时，外部新来的线程也能立刻尝试 CAS 抢占。 独占模式 vs 共享模式 独占模式：同一时刻只能有一个线程获取资源。 代表：ReentrantLock、ReentrantReadWriteLock.WriteLock 共享模式：同一时刻多个线程可以共享资源。 代表：Semaphore、CountDownLatch、ReentrantReadWriteLock.ReadLock 示例1：Semaphore Semaphore semaphore = new Semaphore(3); // 最多允许 3 个线程同时访问 Runnable task = () -> { try { semaphore.acquire(); // 请求许可，拿不到就阻塞 System.out.println(Thread.currentThread().getName() + " 获取许可，执行中"); Thread.sleep(1000); // 模拟工作 } catch (InterruptedException e) { e.printStackTrace(); } finally { semaphore.release(); // 释放许可 } }; // 启动 10 个线程 for (int i = 0; i < 10; i++) { new Thread(task, "线程-" + i).start(); } 最多只能看到3 个线程同时在执行；其他线程会阻塞，直到前面的释放许可。 示例2：ReentrantReadWriteLock ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock(); ReentrantReadWriteLock.ReadLock readLock = rwLock.readLock(); ReentrantReadWriteLock.WriteLock writeLock = rwLock.writeLock(); Runnable readTask = () -> { readLock.lock(); try { System.out.println(Thread.currentThread().getName() + " 读数据中..."); Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } finally { readLock.unlock(); } }; Runnable writeTask = () -> { writeLock.lock(); try { System.out.println(Thread.currentThread().getName() + " 写数据中..."); Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } finally { writeLock.unlock(); } }; // 启动多个读线程 for (int i = 0; i < 3; i++) { new Thread(readTask, "读线程-" + i).start(); } Thread.sleep(200); // 稍等一会儿启动写线程 new Thread(writeTask, "写线程").start(); 多个读线程可以并行执行；写线程必须等待所有读线程完成；读线程也会在写线程执行时阻塞。 AQS为什么用双向链表 最主要的原因是为了支持高效的节点移除操作。在 AQS 的实现中，线程可能会因为超时、中断等原因需要从队列中移除。 在单向链表中，要删除一个节点，必须从头开始遍历找到它的前驱节点，时间复杂度是 O(n)。而双向链表可以直接通过前驱指针（prev）快速定位前驱节点，实现 O(1) 时间复杂度的节点移除。 手撕限流器 import com.google.common.util.concurrent.RateLimiter; import java.util.concurrent.ConcurrentHashMap; public class IpRateLimiterGuava { private final ConcurrentHashMap<String, RateLimiter> ipRateLimiters = new ConcurrentHashMap<>(); private final double permitsPerSecond = 10.0; // 每秒10个许可 public boolean allowAccess(String ip) { // 为每个IP创建独立的RateLimiter（懒加载） RateLimiter limiter = ipRateLimiters.computeIfAbsent(ip, k -> RateLimiter.create(permitsPerSecond)); return limiter.tryAcquire(); // 尝试获取许可 } public static void main(String[] args) { IpRateLimiterGuava limiter = new IpRateLimiterGuava(); String ip = "192.168.1.1"; for (int i = 0; i < 15; i++) { boolean allowed = limiter.allowAccess(ip); System.out.println("Access " + i + ": " + (allowed ? "Allowed" : "Denied")); try { Thread.sleep(50); } catch (InterruptedException e) { e.printStackTrace(); } } } } JAVA高级 垃圾回收 垃圾回收算法 1）标记-清除算法 首先标记出所有需要回收的对象。统一回收所有被标记的对象。 缺点：内存碎片化 2）标记-整理算法 与“标记-清除”一样，标记所有需要回收的对象。让所有存活的对象都向内存空间的一端移动，然后直接清理掉边界以外的内存。 避免了内存碎片，但是移动存活对象并更新所有引用地址是一个负重操作。 3）复制算法 将可用内存按容量分为大小相等的两块，每次只使用其中一块。当这一块用完了，就将还存活着的对象复制到另一块上面，然后再把已使用过的内存空间一次清理掉。 没有内存碎片，但是可用内存缩小为原来的一半。 垃圾回收器 1）CMS：并发标记清除器 回收过程（仅针对老年代，与ParNew搭配使用）： 初始标记： 动作：标记与 GC Roots 直接关联 的对象。 特点：速度极快，但需要 “Stop The World”。 并发标记： 动作：从初始标记的对象开始，进行可达性分析，标记所有存活对象。 特点：与用户线程并发执行，耗时较长，但不会暂停应用。 重新标记： 动作：修正并发标记期间，因用户程序继续运行而导致标记产生变动的那部分对象。 特点：需要 “Stop The World”，但采用了优化算法（如增量更新），比初始标记稍长，但远短于并发标记。 并发清除： 动作：清理死亡对象，回收内存空间。 特点：与用户线程并发执行。回收期间，用户线程仍可创建新对象。 特点：CMS主要负责的是 老年代，以并发、低停顿为目标，通过多阶段标记和清除来避免长时间的 Stop-The-World。它需要与一个“新生代收集器”配合使用。 在 CMS 时代，最常见的搭配是： 新生代用：ParNew（并行的新生代收集器），使用复制算法。 老年代用：CMS（并发标记清除收集器） 2）G1：垃圾回收器 回收过程（混合回收）： 初始标记： 同CMS，标记GC Roots直接关联的对象，需要 STW。 优化：这个阶段会借一次 Young GC 的机会完成，所以成本极低。 并发标记： 同CMS，从GC Root开始进行可达性分析，找出存活对象，并发执行。 最终标记： 同CMS的“重新标记”，修正并发标记期间的变动，需要 STW。 优化：G1使用 SATB 算法，通常比CMS的增量更新更快。 筛选回收： 动作：G1的核心阶段。根据用户设定的期望停顿时间，计算出回收哪些Region（垃圾比例高的Region）效益最高。 过程：对这些选中的Region进行回收，采用复制算法，将存活对象复制到空的Region中，同时清空整个旧Region。 特点：需要 STW，但可以多线程并行执行，并且是多个Region一起回收。 算法层面，G1 是一种基于 分区 + 复制 + 标记整理 混合算法的回收器： 年轻代回收（Young GC）：使用 复制算法，把存活对象从 Eden 复制到 Survivor 或老年代。 老年代或混合回收（Mixed GC）：采用 标记-整理 思想，将存活对象复制到新的 Region 并清空旧的 Region。 双亲委派 双亲委派（Parent Delegation）是 Java 类加载器（ClassLoader）的一种工作模式，加载一个类的流程是： 一个类加载器收到类加载请求。 它不会自己先尝试加载，而是先把请求交给父类加载器。 父类加载器再交给它的父类，层层向上，直到BootstrapClassLoader。 如果父类加载器能成功加载，就直接返回。 如果父类都找不到，再由当前类加载器自己去加载。 常见类加载器层次： BootstrapClassLoader：加载 JDK 核心类（rt.jar） ExtClassLoader：加载 JDK 扩展库 AppClassLoader：加载应用 classpath 下的类 为什么？ 1）安全性。避免用户自定义类“伪造”JDK 核心类。比如有人自己写了一个 java.lang.String，如果没有双亲委派，可能被应用加载，造成安全问题。 2）避免重复加载。如果每个 ClassLoader 都自己加载一遍 java.util.List，就会有多份不同的 Class 对象。 JVM的组成部分 1）类加载器子系统（ClassLoader） 负责把编译好的 .class 字节码文件加载到 JVM 内存中。 它遵循 双亲委派机制，优先交给父加载器加载，避免重复加载和核心类被篡改。 2）运行时数据区 方法区（Method Area）：存放类的结构信息（类元数据、常量、静态变量、JIT 编译后的代码等）。 堆（Heap）：存放对象实例，几乎所有对象都在堆中分配。 栈（Stack）：每个线程都有一个独立的栈，存放方法调用的局部变量、操作数栈、方法出口信息。 程序计数器：记录当前线程执行的字节码指令地址。 本地方法栈：为本地代码（例如 C/C++ 函数）运行时提供栈空间。 3） 执行引擎 负责把字节码指令翻译成底层机器指令交给 CPU 执行。 包含 解释器（逐条解释执行）和 JIT 编译器（热点代码编译成本地机器码，提高性能）。 4）本地方法接口 提供调用非 Java 语言（如 C/C++）编写的本地方法的能力。 通过 JNI 连接到本地库（Native Libraries），比如操作系统底层函数等（创建线程、读写文件、网络 IO）。 5）本地库 实际的本地代码实现，JNI 会调用它们来完成底层操作。 怎么理解Java堆是线程共享的？ 所有线程都可以访问堆中的对象（只要它们持有该对象的引用）。 注意：虽然堆是共享的，但每个线程的栈是私有的！！！！ public class MethodLocalObjectExample { public static void main(String[] args) { // 方法内创建对象 Object obj = new Object(); // obj 是局部变量，但 new Object() 在堆上 System.out.println(obj); // 输出对象地址 } } new Object()是在堆上分配的，所有线程都能访问（如果有引用）。 但 obj是局部变量（栈私有），其他线程无法直接访问这个引用，因此这个对象默认不共享。除非这个引用传递给其他线程！！！ JMM内存模型 JMM (Java Memory Model) 是 Java 语言规范里定义的一套内存可见性与有序性规则。 因为底层硬件（CPU、缓存、多核架构）和编译器优化会导致： 指令重排（out-of-order execution） 缓存不一致（每个线程可能有本地缓存） 编译器优化导致的“读旧值” JMM 主要解决 可见性 一个线程对共享变量的修改，能不能被其他线程及时看到？ volatile / synchronized / Lock 都能保证可见性。JMM 会在读写时插入 内存屏障，强制刷新到主内存、强制从主内存读取，确保线程之间的修改可见。 当线程进入 synchronized 块时： JVM 会执行 加锁（monitorenter） 指令，**清空工作内存（私有缓冲区）**中该共享变量的值，从 主内存中重新读取最新的值； 当线程退出 synchronized 块时： JVM 会执行 释放锁（monitorexit） 指令，把该线程对共享变量的修改 刷新回主内存； 并且会插入 内存屏障，禁止指令重排。 有序性 编译器和 CPU 会对指令做重排序优化，JMM 提供了 happens-before 规则 来约束哪些操作必须有序。 happens-before 规则定义了哪些操作必须有序。 原子性 单次操作不可分割。 复合操作（如 i++）不是原子操作，需要用 锁（synchronized/Lock） 或 原子类（AtomicInteger 等，利用 CAS 实现） 来保证原子性。 happens-before 规则 如果操作 A happens-before 操作 B，那么： A 的结果对 B 可见； 并且 A 的执行顺序在 B 之前（逻辑顺序，而非物理执行顺序）。 常见的 happens-before 规则： 程序顺序规则：允许指令重排（物理顺序可以变），但重排不能改变程序在单线程中的逻辑结果。 int a = 1; int b = 2; int c = a + b; 编译器分析后发现 a 和 b 没依赖；它可能重排为： b = 2; a = 1; int c = a + b; 物理顺序变了，但逻辑结果不变 锁规则：解锁 happens-before 后续的加锁,即前一个线程在释放锁之前对共享变量的修改， 对随后获得同一锁的线程 可见。 volatile 规则：对 volatile 变量的写 happens-before 后续的读。 线程启动与终止规则：线程 start() happens-before 线程中所有操作；线程中所有操作 happens-before 另一个线程成功从 join() 返回。 ​ 即主线程对共享变量的修改，对新启动的线程 可见；子线程结束 → 主线程通过 join() 可见子线程的结果。 元空间 元空间（Metaspace）是 JDK8 之后用来存储类的元信息（class metadata）的内存区域(方法区)，位于本地内存（Native Memory）中。在 JDK 8 之后，它取代了以前的 永久代（PermGen） 什么是类的元数据 元数据内容 详细说明 类名和修饰符 public final class User 父类信息 extends Object（显式或隐式） 实现的接口 implements Serializable, Cloneable 字段信息 名称、类型、偏移量（如 private String name） 方法信息 签名、字节码、异常表（如 public void setName(String)的字节码） 运行时常量池 符号引用（类/方法/字段）、字面量（如 final int MAX=100） 虚方法表（vtable） 支持多态的方法调用表（如 toString()的动态绑定） 注解信息 类/方法/字段上的注解（如 @Override） 用 元空间（Metaspace） 替代永久代 对比维度 永久代（PermGen） 元空间（Metaspace） 存储位置 JVM 堆内 本地内存（Native memory） 是否固定大小 是，需手动指定 否，默认按需增长 容易 OOM 吗？ 是，类多时易崩 少见，但可用参数限制防爆 是否会被 GC 扫描 会 一般不会（但类卸载可手动触发） 从哪个版本开始移除 JDK8 JDK8 引入 Java 代码的编译过程 编写源代码（.java 文件） 编译阶段（javac 编译器） javac HelloWorld.java (1) 词法分析:将源代码分解为最小语法单元（tokens），例如关键字 public、标识符 main、分号等。 (2) 语法分析:根据 Java 语法规则生成抽象语法树（AST），判断语法是否正确（例如括号是否匹配、语句是否完整）。 (3) 语义分析:检查语义上的正确性，例如类型是否匹配、变量是否声明、继承是否合理。 (4) 字节码生成:编译器最终会生成 .class 文件 类加载 JVM启动后，会通过 类加载器 加载 .class 文件到内存。 字节码执行 加载完成后，JVM 会执行字节码： 解释执行：逐条解释执行字节码。 JIT 编译：热点代码（频繁执行的部分）会被即时编译为机器码，提高性能。 类加载过程 类加载过程分为 加载（Loading）→ 链接（Linking）→ 初始化（Initialization） 三个阶段。 **加载：**将 .class文件的二进制字节流加载到 JVM 内存，并生成一个 Class<?>对象。 链接： (1) 验证：检查字节码合法性，确保符合 JVM 规范，防止恶意代码（如篡改字节码）。 (2) 准备：为静态变量分配内存并赋默认值（非初始值！）基本类型：int→ 0，boolean→ false。 (3) 解析：将符号引用转为直接引用 符号引用：类/方法/字段的文本描述（如 com.example.User.getName）。 直接引用：指向方法区内存地址的指针或句柄。 **初始化：**静态代码块和静态变量赋值 类加载时机： new实例化对象。 访问类的静态字段或调用静态方法。 反射调用（如 Class.forName("com.example.User")）。 子类初始化时，父类需先初始化。 静态代理、动态代理 动态代理代理类（如 $Proxy0或 UserService$$EnhancerByCGLIB$$123456）在程序运行时动态生成。代理类的字节码在内存中生成，不会出现在你的源代码或编译后的 .class文件中。 静态代理代理类（如 UserServiceProxy）是你手动编写的，编译前就已经存在。 静态代理 静态代理 是一种在编译期（ Java 源码编译成字节码（.class 文件）的阶段）就已经确定的代理模式，它通过手动编写一个代理类，在不修改原始类代码的情况下，增强或控制目标对象的行为。 示例： 假设我们有一个 UserService接口，它有一个方法 getUser()，我们想在调用这个方法前后 添加日志，但不修改 UserServiceImpl的代码。 1. 定义接口和实现类 // 接口 public interface UserService { String getUser(int id); } // 真实实现类 public class UserServiceImpl implements UserService { @Override public String getUser(int id) { return "User-" + id; } } 2. 编写静态代理类 // 代理类（手动编写） public class UserServiceProxy implements UserService { private UserService target; // 持有真实对象 public UserServiceProxy(UserService target) { this.target = target; } @Override public String getUser(int id) { System.out.println("【日志】开始查询用户，id=" + id); // 增强逻辑（日志） String result = target.getUser(id); // 调用真实对象的方法 System.out.println("【日志】查询结果：" + result); // 增强逻辑（日志） return result; } } 3. 使用代理类 public class Main { public static void main(String[] args) { // 真实对象 UserService realService = new UserServiceImpl(); // 代理对象（包装真实对象） UserService proxy = new UserServiceProxy(realService); // 调用代理对象的方法 String user = proxy.getUser(100); System.out.println("最终结果：" + user); } } 优点：不侵入目标类：无需修改 UserServiceImpl的代码。 缺点：灵活性差：代理关系在编译期固定，无法动态调整。如果要代理多个类，需要为每个类写一个代理类。 JDK Proxy动态代理 JDK 动态代理是 Java 原生提供的、基于接口的代理技术，通过反射动态生成代理类，在运行时拦截目标方法调用，实现增强逻辑（如日志、事务等）。 简单示例 (1) 定义接口和实现类 // 接口 public interface UserService { String getUser(int id); void deleteUser(int id); } // 真实实现类 public class UserServiceImpl implements UserService { @Override public String getUser(int id) { return "User-" + id; } @Override public void deleteUser(int id) { System.out.println("删除用户: " + id); } } (2) 实现 InvocationHandler import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; public class UserServiceInvocationHandler implements InvocationHandler { private final Object target; // 被代理的真实对象 public UserServiceInvocationHandler(Object target) { this.target = target; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { // 前置增强：日志 System.out.println("【日志】调用方法: " + method.getName()); // 调用真实对象的方法 Object result = method.invoke(target, args); // 后置增强：记录结果 System.out.println("【日志】方法结果: " + result); return result; } } 3.使用 Proxy.newProxyInstance生成代理对象 import java.lang.reflect.Proxy; public class Main { public static void main(String[] args) { // 1. 创建真实对象 UserService realService = new UserServiceImpl(); // 2. 创建 InvocationHandler，传入真实对象 InvocationHandler handler = new UserServiceInvocationHandler(realService); // 3. 动态生成代理对象 UserService proxy = (UserService) Proxy.newProxyInstance( realService.getClass().getClassLoader(), // 使用真实对象的类加载器 realService.getClass().getInterfaces(), // 代理的接口（UserService） handler // 方法调用处理器 ); // 4. 调用代理对象的方法 String user = proxy.getUser(100); // 会触发 InvocationHandler.invoke() proxy.deleteUser(200); } } 生成的代理类结构 // JDK 动态生成的代理类（简化版） public final class $Proxy0 extends Proxy implements UserService { private InvocationHandler handler; public $Proxy0(InvocationHandler h) { this.handler = h; } @Override public String getUser(int id) { return handler.invoke( this, UserService.class.getMethod("getUser", int.class), new Object[]{id} ); } @Override public void deleteUser(int id) { handler.invoke( this, UserService.class.getMethod("deleteUser", int.class), new Object[]{id} ); } } 局限性：如果类没有实现接口了，JDK Proxy 无法代理；接口中的 final 方法无法被代理。 应用： Spring AOP 如果目标类实现了接口，Spring 默认使用 JDK Proxy。 示例：@Transactional的事务管理就是通过动态代理实现的。 RPC 框架（如 Dubbo） 客户端通过 JDK Proxy 生成远程服务的本地代理，调用时触发网络请求。 CGLIB 动态代理 它通过 继承目标类 的方式在运行时动态生成代理类。与 JDK Proxy 不同，CGLIB 不需要目标类实现接口，可以直接代理普通类。 (1) 定义目标类（无需接口） // 目标类（没有实现任何接口） public class UserService { public String getUser(int id) { return "User-" + id; } public void deleteUser(int id) { System.out.println("删除用户: " + id); } } (2)实现 MethodInterceptor import net.sf.cglib.proxy.MethodInterceptor; import net.sf.cglib.proxy.MethodProxy; import java.lang.reflect.Method; public class UserServiceMethodInterceptor implements MethodInterceptor { @Override public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable { // 前置增强：日志 System.out.println("【CGLIB日志】调用方法: " + method.getName()); // 调用父类（即真实对象）的方法 Object result = proxy.invokeSuper(obj, args); // 后置增强：记录结果 System.out.println("【CGLIB日志】方法结果: " + result); return result; } } (3) 使用 Enhancer生成代理对象 import net.sf.cglib.proxy.Enhancer; public class Main { public static void main(String[] args) { // 1. 创建 Enhancer（CGLIB 的核心类） Enhancer enhancer = new Enhancer(); // 2. 设置父类（即被代理的目标类） enhancer.setSuperclass(UserService.class); // 3. 设置回调（方法拦截器） enhancer.setCallback(new UserServiceMethodInterceptor()); // 4. 创建代理对象 UserService proxy = (UserService) enhancer.create(); // 5. 调用代理对象的方法 String user = proxy.getUser(100); // 会触发 MethodInterceptor.intercept() proxy.deleteUser(200); } } Enhancer是 CGLIB 的核心工具类，它会在运行时动态生成一个继承目标类（如 UserService）的子类，而这一切对开发者是透明的。 角色 作用 目标类（如 UserService） 被代理的原始类 MethodInterceptor 定义代理逻辑（如日志、事务） Enhancer CGLIB 的核心类，用于生成代理类 动态生成的代理类 继承目标类，覆盖其方法 UserService（目标类） ↑ 继承 UserService$$EnhancerByCGLIB$$123456（动态生成的代理类） 代理类 只在第一次调用 newProxyInstance()时生成，后续调用复用已生成的类。 Netty核心流程 数据处理流程 注册与监听： 首先，将所有的Channel注册到Selector上，并告诉Selector关心每个Channel的什么事件（比如“可读”事件）。 事件就绪： Selector作为调度员，在一个线程中循环检查。当某个客户端发送数据，对应的Channel连接上有数据可读时，Selector会检测到这个OP_READ事件就绪，并将其汇报出来。 注意：epoll是它的底层，epoll是事件驱动 + 就绪列表返回，不是轮询。 数据入站： 系统根据Selector的汇报，找到就绪的Channel，然后从该Channel中将数据读取到缓冲区（ByteBuf）中。至此，网络I/O操作完成。 流水线加工： 数据到达缓冲区后，就开始在ChannelPipeline这条流水线上流动。ChannelHandler链被依次触发： 首先，解码Handler（如ByteToMessageDecoder）将缓冲区里的原始字节解码成一个有意义的业务请求对象（比如一个HTTP请求对象）。 然后，这个业务请求对象被传递给业务逻辑Handler，在这里执行具体的业务计算（比如查询数据库）。 业务逻辑Handler处理完后，通常会生成一个响应结果。 数据出站（响应）： 响应结果的发送是反向流程： 业务Handler将响应对象传递给Pipeline中的编码Handler（如MessageToByteEncoder）。 编码Handler将响应对象编码回字节，并写入缓冲区（ByteBuf）。 最后，这些在缓冲区中的字节数据再通过原来的Channel发送回客户端。在此过程中，Selector可能同时也在监听Channel是否“可写”，以高效地完成发送任务。 Channel = 水管（连接） ByteBuf = 水桶（存放数据） Pipeline = 加工流水线（加工数据） 零拷贝 传统 I/O 数据拷贝路径（有多次拷贝） byte[] buf = new byte[4096]; //申请一个缓冲区 int len = fileInputStream.read(buf); //从文件中读取数据到缓冲区 socketOutputStream.write(buf, 0, len); //把缓冲区的数据写到网络 socket 底层会发生 4 次数据拷贝 和多次上下文切换： 磁盘 → 内核缓冲区（DMA 拷贝） 内核缓冲区 → 用户态缓冲区（CPU 拷贝） 用户态缓冲区 → socket 内核缓冲区（CPU 拷贝） socket 内核缓冲区 → 网卡（DMA 拷贝） 问题： 读的时候从内核缓冲区拷贝到用户态 buf;写的时候又从用户态 buf 拷贝回 socket 内核缓冲区。有两次内核 ↔ 用户空间的数据搬运和两次系统调用（read + write） 零拷贝优化：减少或消除第 2、3 步的用户态拷贝，让数据在内核态直接完成“文件缓冲区 → socket 缓冲区”的传输。 场景题 设计一个会议室预约系统，多个会议室，每次最少定半小时，最多定两小时，一天一个人最多定两次，问题 1)存储结构怎么设计2)核心预定流程是什么3)抢会议室如何实现 1） ① 用户表（User） 字段 类型 说明 user_id INT 用户唯一标识 name VARCHAR 用户姓名 dept VARCHAR 部门等信息 ② 会议室表（Room） 字段 类型 说明 room_id INT 会议室唯一标识 name VARCHAR 名称 capacity INT 容量 location VARCHAR 位置描述 ③ 预定表（Booking） 字段 类型 说明 booking_id INT 预定唯一标识 room_id INT 外键，关联会议室 user_id INT 外键，关联用户 start_time DATETIME 会议开始时间 end_time DATETIME 会议结束时间 status ENUM 状态（已预定、取消、过期） created_at DATETIME 创建时间 2） 用户发起预定请求 输入：会议室ID、开始时间、结束时间。 系统校验： 会议时长是否在 0.5h～2h； 用户当天预定次数 ≤ 2； 时间段是否冲突（会议室是否已被占用）。 时间冲突判断 查询 Booking 表 ： SELECT * FROM booking WHERE room_id = ? AND status = 'booked' AND (start_time < ? AND end_time > ?); 若有记录 → 冲突，拒绝预约。 写入预定记录 使用 事务 + 乐观锁 或 数据库唯一约束 保证并发安全； 成功后写入 Booking 表； 通知用户（可选：发邮件/推送）。 3)抢会议室的实现 当多个用户同时预定同一个会议室、同一时间段时，必须保证： 不能重复预定； 只有一个用户能成功。 数据库层防重 Redis 分布式锁 消息队列串行化请求（高并发优化） 高效判断若干个接口是否可用 有一批远程接口（比如几十个 API），需要检查它们是否可正常访问。 系统中已经有一个方法： boolean checkApi(String apiName) 该方法会调用远程接口，并返回： true：接口可用 false：接口不可用 请设计一段高效的代码，判断所有接口的可用性，并输出每个接口的检测结果。 思路： 1）不能串行检查，因为每次远程调用都要等待响应（可能几百毫秒甚至几秒），如果一个个调会非常慢。 所以要并发执行检查任务。 2）可以用 线程池（ThreadPoolExecutor） 管理线程，避免创建过多线程浪费资源。 3）每个接口检查都封装成一个任务（Callable），提交到线程池执行。 4）用 Future 或 CompletableFuture 收集每个任务的执行结果。 5）等所有任务完成后输出结果。 示例代码： import java.util.*; import java.util.concurrent.*; public class ApiCheckerSimple { public static void main(String[] args) throws Exception { // 模拟接口列表 List<String> apis = List.of("apiA", "apiB", "apiC", "apiD", "apiE"); // 1️⃣ 创建线程池 ExecutorService executor = new ThreadPoolExecutor( 2, 5, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>(apis.size()) ); //分别是 核心线程数，最大线程数，空闲存活时间，单位（秒），workQueue（任务队列） // 2️⃣ 提交任务（每个接口一个检测任务） List<Future<Boolean>> futures = new ArrayList<>(); for (String api : apis) { futures.add(executor.submit(() -> checkApi(api))); } // 3️⃣ 收集结果（顺序与接口列表一致） for (int i = 0; i < apis.size(); i++) { Boolean ok = futures.get(i).get(); // 阻塞等待执行结果 System.out.println(apis.get(i) + " => " + (ok ? "可用" : "不可用")); } // 4️⃣ 关闭线程池 executor.shutdown(); } // 模拟接口检测 static boolean checkApi(String name) { //省略具体逻辑，概率返回true or false } } 接口变慢的排查思路 1）检查网络是否存在延迟或带宽瓶颈 常见于：带宽满、跨服务 / 跨区域 / 跨 VPC 调用 工具：ping、traceroute、netstat 2）查看接口日志 核心：是否一直慢，还是在某些时间段或特定请求下变慢？ log.info("营销锁单开始执行：拼团交易锁单入参 userId={} req={}", userId, JSON.toJSONString(lockMarketPayOrderRequestDTO)); log.error("拼团交易锁单业务异常 userId={} req={}", ..., e); 接口开始/结束时间戳差值，看这次请求花了多久。 日志里是否有重试、异常堆栈、timeout、fallback、熔断等字眼？ 3）观察数据库指标 是否存在慢查询、锁等待、连接池爆满等？ 工具：数据库日志、EXPLAIN、慢 SQL 查询工具 -- 1. 开启慢查询日志功能 SET GLOBAL slow_query_log = 'ON'; -- 2. 设置慢查询阈值（单位：秒，默认 10 秒，建议设置为 1 秒或更小） SET GLOBAL long_query_time = 1; MySQL 会把 执行时间超过 1 秒的 SQL 语句 自动写入到 慢查询日志文件（slow query log） 中 内容形如： # Time: 2025-09-09T11:45:12.123456Z # User@Host: app_user[app_user] @ 192.168.1.10 # Thread_id: 12345 Schema: mydb QC_hit: No # Query_time: 3.214654 Lock_time: 0.000123 Rows_sent: 1 Rows_examined: 30542 SET timestamp=1694259912; SELECT * FROM trade_order WHERE status = 'PAID'; 开启慢日志找到耗时 SQL 后，我会用 EXPLAIN 看执行计划，重点关注是否走了全表扫描、索引有没有命中、扫描行数是否过多以及是否有临时表或排序；如果发现问题，就通过建合适的组合索引、做覆盖索引、或者重写 SQL（比如避免函数、只查必要字段、改用游标分页替代大 OFFSET）来优化。 4）查看代码 & 发布变更 是否新上线的代码引入了性能问题？ 排查逻辑是否复杂、是否有死循环、递归、重复查询等问题？ 5）查看服务监控 观察：系统资源是否有瓶颈？（CPU、内存、磁盘、网络 等） 如何排查 OOM 问题？ 一、第一步：识别 OOM 类型 OOM 错误信息本身就提供了最重要的线索。常见的 OOM 类型及含义如下： OOM 类型 关联内存区域 常见原因 java.lang.OutOfMemoryError: Java heap space 堆内存 最常见。对象太多或太大，无法在堆中分配。 java.lang.OutOfMemoryError: Metaspace 元空间（方法区） 加载的类太多（如反射、动态代理、字节码增强框架）。 java.lang.OutOfMemoryError: Direct buffer memory 直接内存 NIO 的 DirectByteBuffer使用过多，未释放。 java.lang.OutOfMemoryError: Unable to create new native thread 系统内存 创建的线程数过多，超出系统或进程限制。 java.lang.OutOfMemoryError: GC overhead limit exceeded 堆内存 GC 花费了太多时间（如98%）却只回收了很少的内存（如2%），这是一种“几乎内存泄漏”的信号。 StackOverflowError 栈空间 递归、深层嵌套 行动： 首先查看错误日志，明确是哪种 OOM。 二、第二步：根据类型采取不同排查策略 场景 1：堆内存 OOM (Java heap space) 最常见 这是最复杂的场景，核心是分析堆转储文件（Heap Dump）。 1. 在启动时添加 JVM 参数，以便在发生 OOM 时自动生成堆转储文件（.hprof）： -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/save/dump.hprof （如果未提前设置，在 OOM 发生后也可以用 jmap命令手动抓取，但可能已错过最佳现场） 2. 使用分析工具加载堆转储文件： Eclipse MAT (Memory Analyzer Tool)：功能强大，首选。 3. 分析步骤（以 MAT 为例）： 查看 Dominator Tree（支配树）：列出占用内存最大的对象线程，快速找到“罪魁祸首”。 按对象实例（而不是类）占用的保留大小 (Retained Heap) 排序。保留大小是指这个对象本身加上它直接或间接引用的所有对象的总大小。 查看 Leak Suspects Report（泄漏嫌疑报告）：MAT 会自动生成一个分析报告，直接给出可能发生内存泄漏的点，非常有用。 查看 Histogram（直方图）：按类名统计实例数量和总大小，看看哪个类的对象异常得多。 分析引用链：对疑似泄漏的对象，查看其到 GC Roots 的引用链，重点排查为什么这些对象无法被垃圾回收。常见原因： 静态集合类（如 static Map）持有对象引用，忘记移除。 private static Map<Long, User> cache = new HashMap<>(); 所有存入的 User对象会永久存活，即使业务上已不再需要。 各种缓存（如本地缓存）没有淘汰策略或清理机制。 线程池中的线程局部变量（ThreadLocal）未清理。 （ThreadLocalMap 的 key 是弱引用指向 ThreadLocal 实例，而value是强引用） 数据库连接、网络连接、文件流等未显式关闭。 // 错误示例：文件流未关闭 public void readFile(String filePath) { FileInputStream fis = new FileInputStream(filePath); byte[] buffer = new byte[1024]; while (fis.read(buffer) != -1) { System.out.println(new String(buffer)); } // 忘记 fis.close()！ } 排查 CPU 100% 的问题 第1步：找到罪魁祸首的 Java 进程 使用 top命令找到哪个 Java 进程最耗 CPU。 top 第2步：找到这个进程下的问题线程 看 %CPU 列，找到最耗 CPU 的线程，记下它的 TID（线程 ID）。 -H：显示线程模式 -p <PID>：只监控指定进程 top -Hp <PID> 第3步：将线程ID转换为16进制 printf "%x\n" <TID> 第4步：抓取整个进程的线程堆栈 jstack <PID> > jstack_dump.txt 第5步：定位问题线程 在生成的 jstack_dump.txt文件中搜索第3步得到的十六进制 nid。 grep -n <nid> jstack_dump.txt # 例如：grep -n 1a4e jstack_dump.txt 如何定位哪个容器里的Java 占用 CPU 高 1.docker status 效果类似 top，会显示每个容器的 CPU %、内存、网络、I/O，比如： CONTAINER ID NAME CPU % MEM USAGE / LIMIT ... 386e85737b31 smile-picture-backend 250.00% 517MiB / 2GiB be7f319061c9 gitea 1.20% 530MiB / 2GiB ... 2.docker top smile-picture-backend 会列出容器里的所有进程（宿主机 PID、用户、启动命令）。 3.后续同前面第二步开始！根据PID找线程，使用Top命令。 但是这里有个问题，容器是运行在隔离环境中的，jstack <PID> > jstack_dump.txt 貌似无法查看容器内进程的运行情况！！！ 你会怎样设计一个RPC框架 1.动态代理 (Proxy Layer) - 对开发者透明 是什么：自动生成客户端代理类，伪装成本地接口。用户调用 userService.getUser(1)，感觉像调用本地方法，但实际上是由代理类发起网络请求。 为什么：这是 RPC 的初心——隐藏远程调用的复杂性，让开发者专注于业务逻辑。这是框架易用性的最关键体现。 2. 序列化与反序列化 (Serialization/Deserialization) - 协议层核心 是什么：将 Java 对象转换成可在网络上传送的二进制字节流的过程叫序列化；反之叫反序列化。 为什么：网络只能传输二进制数据。这是通信的基石。 3. 网络传输 (Transport) - 通信基础 是什么：负责在客户端和服务端之间收发序列化后的二进制数据。 为什么：这是通信的物理基础。 考量点： 协议：基于 TCP 还是 HTTP？TCP 性能更优，HTTP 更通用、易调试。 IO 模型：主流选择是 NIO，能够用少量线程处理大量连接，性能极高（Netty 是事实标准）。 连接管理：使用长连接+连接池，避免频繁创建和销毁 TCP 连接的开销。 4. 服务发现与注册 (Service Discovery & Registry) - 治理基石 是什么： 服务注册：服务提供者启动时，将自己的地址（IP:Port）、服务名等信息上报到“注册中心”。 服务发现：消费者调用前，先连接到“注册中心”，根据服务名拉取所有可用提供者的地址列表。 为什么：在微服务动态环境中，服务的 IP 和端口是随时变化的（扩容、缩容、宕机）。不能将服务地址硬编码在配置文件中，需要一个中心化的目录服务来动态管理。 常见选型：ZooKeeper, Nacos 5. 负载均衡 (Load Balancing) - 公平调度 是什么：当从一个服务名下获取到多个提供者地址时，需要选择一个来发起调用。选择策略就是负载均衡。 为什么：将请求均匀分散到多个提供者实例上，避免单机过热，提高整体吞吐量和可用性。 常见策略：随机（Random）、轮询（RoundRobin）、加权（根据性能分配权重）、一致性哈希（同一用户请求总是落到同一台机器，适用于有状态或缓存场景）。 6. 容错机制 (Fault Tolerance) - 系统韧性 网络是不可靠的，服务可能会宕机。必须有完善的容错策略来保证系统的鲁棒性。 超时控制 (Timeout)：防止调用者无限期等待，快速释放资源。 重试机制 (Retry)：对超时或失败调用进行有限次数的重试（注意：对非幂等操作要谨慎）。 熔断器 (Circuit Breaker)：类似电路保险丝。当失败率达到阈值，熔断器“跳闸”，后续请求直接失败，不再发起远程调用。给服务提供者恢复的时间。一段时间后进入“半开状态”尝试放行少量请求。 降级 (Fallback)：调用失败后，提供一种备选方案，如返回默认值、缓存数据等，保证主流程可用。 如何优化？ 1）序列化层优化，优化目标是：更小、更快。 2）网络传输层优化，使用长连接 (Keep-Alive) 和连接池：避免每次 RPC 调用都经历三次握手和四次挥手；采用高性能 NIO 框架：使用 Netty 作为网络通信框架。 Mysql如何设计索引 表t有查询： SELECT a FROM t WHERE b > 1 AND c <> 5 AND d = 10; # <> 等价于 '不等于' 在 MySQL(InnoDB) 中，应该如何设计索引？为什么？优化器会自动调整这些条件的顺序吗？ 1.索引设计 建复合覆盖索引 INDEX(d, b, a) d=10 是等值 → 放首列，先把范围收窄。 b>1 是范围 → 放第二列，在 d=10 的段内做 range 扫描。 a 放末位做覆盖，减少回表。 c<>5 不等值对 B-Tree 不友好，通常由 ICP（索引条件下推）或残留过滤处理，不强求入键位。 索引字段选择： ​ =、IN 能很好地用上索引 ​ >、< 能部分利用索引（范围扫描） ​ <>（不等于）会导致“两段范围”（(-∞,5) 和 (5,+∞)），多数时候优化器宁愿直接全表扫或残留过滤。所以通常 不会专门为了 <> 建索引。 为什么把 a 放进索引 索引作用点主要是 WHERE 条件，帮你缩小扫描范围，但是查询里 SELECT 的列如果不在索引里，MySQL 要回到 聚簇索引（主键数据页）去拿数据，这叫“回表”。如果你在二级索引里同时包含了要查询的列，那结果就可以直接从索引里拿出来 → 减少回表 I/O，速度会快很多。 2.优化器“会不会自动调整顺序 谓词求值顺序：优化器会重排，选择性强的会先评估。 索引列顺序：不会自动调换；B-Tree 的物理顺序由你建索引时决定，所以仍需遵循“等值在前、范围其后”的建模原则。 如何避免分布式系统出现故障 1）多实例部署 数据库：主从复制；自动故障切换；分片/分库分表，避免单机压力过大 缓存：哨兵模式，本地缓存 + 远程缓存双层兜底 服务实例：多实例部署，负载均衡，Nacos监控 2）降级、熔断和限流 服务降级：非核心功能先停掉，保证主流程可用。 熔断机制：当依赖系统不可用时，快速失败，避免请求堆积拖垮自己。 限流保护：用漏桶/令牌桶保护后端，防止雪崩。 3）监控、告警、自动恢复 实时监控、告警，结合容器编排。 4）数据保障：日志、备份、幂等设计，确保出故障后能快速恢复。 Mysql统计字段中xx出现的次数 在 SQL 里，没有直接的“数某个字符出现次数”的函数。 但我们可以用 字符串长度差 来实现： LENGTH(content)：原始字符串长度。 LENGTH(REPLACE(content, ',', ''))：把逗号都去掉之后的长度。 两者之差，就是逗号的个数。 SELECT id, content, LENGTH(content) - LENGTH(REPLACE(content, ',', '')) AS num FROM information; Mysql查询语句性能分析 ① SELECT * FROM ClassA WHERE id IN (SELECT id FROM ClassB); ② SELECT * FROM ClassA A WHERE EXISTS (SELECT * FROM ClassB B WHERE A.id = B.id); 假设： ClassB 表数据量非常大； ClassB.id 上有 索引。 问：哪一段执行效率更高？ 答： ① IN 子查询 IN 会先执行子查询： SELECT id FROM ClassB 得到一个结果集，然后外层再判断： A.id IN (结果集) 如果 ClassB 很大，结果集会非常大； 系统可能会将整个结果集加载到内存中再比较； 即使有索引，仍可能导致性能下降，因为 IN 需要逐个匹配。 ② EXISTS 子查询 EXISTS 的逻辑是： 对 ClassA 的每一行，去 ClassB 里查是否存在匹配； 一旦找到匹配（即 A.id = B.id），就立即返回 TRUE； 不再继续扫描 ClassB。 是以数据量比较少的A来驱动的！！！ 谈谈你对分布式系统的理解 优点： 1）可扩展性，水平扩展，突破单机 CPU/内存/IO 的限制 2）高可用，多副本冗余，节点挂了还能切换 3）高性能，负载均衡，请求分散到多节点并行处理 4）灵活性 & 弹性，弹性扩缩容，按需节省成本 缺点： 1）复杂性增加，部署、运维、监控、排查问题都更难 2）网络不可靠带来的问题，延迟、丢包、脑裂、超时重试 3）一致性问题，多副本更新要考虑一致性 4）成本更高 CAP理论 在一个分布式系统里，最多只能同时满足以下三项中的两项： C（Consistency，一致性） 所有节点对外看到的数据是强一致的，像访问单机一样，读到的一定是最新值。 A（Availability，可用性） 每个请求都能在有限时间内返回（不一定是最新数据，但不能一直挂起）。 P（Partition tolerance，分区容错性） 即使网络出现分区（节点之间通信失败、丢包、延迟），系统也能继续对外提供服务。 雪花算法底层实现 0 | 41位时间戳 | 10位机器ID | 12位序列号 0：符号位，固定为 0。 时间戳 (41 bits)：相对时间戳，能用约 69 年。 机器 ID (10 bits)：最多支持 1024 个节点。 序列号 (12 bits)：每毫秒最多 4096 个 ID。 生成流程： 1）获取当前毫秒时间戳。 2）如果时间戳和上次相同 → 序列号 +1；超过 4095 → 等下一毫秒。 3）如果时间戳比上次大 → 序列号重置为 0。 4）拼接得到唯一 ID。 所以 在同一毫秒、同一机器上，最多可以生成 4096 个不同 ID。 大数据分布式计算 + TopK 问题 问题抽象 数据规模：100 亿个数。 环境：5 台机器，每台 1 CPU，512 MB 内存，1 TB 硬盘，1 G 网卡。 目标：选出最大的 1000 个数。 特点： 数据量极大（10¹⁰），远远超过单机内存。 硬盘和网络资源有限，必须流式处理、分布式处理。 TopK（K=1000）相对很小。 算法层面：TopK 的经典方法 单机思路 使用 小根堆（min-heap）： 维护一个大小为 1000 的小根堆。 遍历所有数据，每来一个数： 如果比堆顶大 → 替换堆顶并调整堆。 否则丢弃。 最终堆里就是最大的 1000 个数。 复杂度： 时间：O(N log K) ≈ 10¹⁰ * log 1000。 空间：O(K)，只需存 1000 个数（几 KB）。 问题：单机 CPU / IO 扛不住 100 亿规模，需要分布式。 分布式系统设计 我们有 5 台机器 → 可以用 分布式 TopK 聚合： Step 1：数据分片 假设数据分布在 5 台机器上（或通过切分 / 分布式存储）。 每台机器 只处理自己那部分数据。 Step 2：局部 TopK 每台机器用小根堆求 本地前 1000 个数。 这样 100 亿数据在每台机器先被压缩成 1000 个候选。 本地计算代价：O((N/5) log K)，只需几百 MB 内存，很轻量。 Step 3：全局归并 将 5 * 1000 = 5000 个候选数汇总到一台机器。 在这 5000 个数里再用小根堆求前 1000。 这一步开销很小，5000 个数据完全可以在内存里做。 追问：算法层面可以采用负载均衡、数据分块并结合堆来实现，那么如何从工程的角度来保证这个系统的稳定性？ 1）流式处理： 避免一次性加载数据，采用顺序读写硬盘（避免随机 IO）， 流式扫描（边读边处理）。这样即使单机内存有限（512 MB），也能稳定运行，不会 OOM。 2）负载均衡 使用 统一调度器（可以是 Nginx、任务队列、或 K8s 调度器），合理分配任务，防止某台机器过载。 3）容错机制 如果某台机器宕机或任务失败，调度器能 重试任务 或 转移任务到其他机器。 4）扩展性 系统应该支持 水平扩展：增加机器数量 → 按数据分块比例缩小每台的工作量。 5）资源隔离、系统监控、报警、记录日志。 追问：一个机器处理某个数据块时宕机了，如何不让其从头开始处理数据，而是找到上一次处理数据的位置继续处理？怎么做持久化？怎么做数据恢复？怎么实现数据处理进度的标记？ 问题分析：如果没有机制，只能 从头重新处理整个数据块 → 浪费大量资源。 核心思路： 1）持久化中间状态 定期把处理进度和关键数据写到可靠存储（如磁盘/分布式存储）。 1）假设数据块存在文件里，可以用「字节偏移量」或「行号」来记录当前处理到哪。offset=123456789，表示已经处理到文件的第 1.23 GB。 2）每处理一批数据（比如 1 万条），写一次进度到磁盘/数据库。写入内容包括：任务ID、数据块ID、偏移量、已处理的TopK候选堆内容。 2）数据恢复机制 任务失败后重新启动时，能读到之前的持久化状态（偏移量 + 堆内容），从断点继续。 断点续传如何实现？ 核心目标是：文件传输中断后，不用从头重新传，只需从上次中断的位置继续。 本质：把大文件分成多个小块传输，并记录哪些块已经成功。 前端： 1）文件分块，把大文件分成多个小块（chunk），每块单独上传。 2）给文件（原始大文件或切片）算一个唯一的 ID（比如 MD5），用来让后端识别这是同一个文件。 3）记录哪些块已经上传成功，能知道上次传到哪一块。 4）续传控制，重新连接后，先询问后端“哪些块已经传完”；然后只上传剩余的块。 后端： 1）接收分块，要区分属于哪个文件、是第几块。 2）记录状态，每个文件有哪些块已上传。 常用数据库或 Redis 存储上传进度。 3）当所有分块上传完后，后端进行完整性校验+按顺序合并 4）提供一个接口让前端询问“我这个文件哪些块已经传过了？” 查找第K大元素 OR 查找某个数 如果查找无序数组第K大元素？ 选择一个枢轴（pivot），通常取区间的第一个元素。执行 partition 操作：把小于 pivot 的元素放左边，大于 pivot 的放右边；返回 pivot 的最终位置 pos。如果我们不需要整个数组排序，只需要第 k 大的元素， 就没必要递归左右两边 —— 只递归包含第 k 大的那一边即可。这样每次都能排除掉一半数据，平均时间复杂度是 O(n)。 给你一个 有序数组，请找到目标值的下标。 二分查找的思想是利用有序性， 每次把搜索区间折半，逐步缩小范围，直到找到目标或确定不存在。 具体做法： 设两个指针 left 和 right 分别指向数组两端； 每次取中点 mid = (left + right) / 2； 比较 nums[mid] 和目标值： 若相等 → 返回 mid； 若 nums[mid] < target → 说明目标在右边，left = mid + 1； 若 nums[mid] > target → 说明目标在左边，right = mid - 1； 循环直到 left > right，表示没找到。 Spring 谈谈String的核心特性 Spring 的核心特性可以概括为以下几点，其中 IoC 和 AOP 是基石中的基石： 控制反转与依赖注入 面向切面编程 Spring 声明式事务管理 数据访问与集成 提供了统一的数据访问抽象，比如 JdbcTemplate、HibernateTemplate，简化了 JDBC 和 ORM 框架的使用。 Web MVC 框架 支持 REST 风格接口，配合注解（@Controller, @RequestMapping 等）开发高效 强大的测试支持 JUnit5，@SpringBootTest，Spring 会启动容器，把真实 Bean 注入进来，测试组件之间的协作。 Spring Boot 的“约定大于配置” 基于 Spring 的一站式框架，强调快速开发与自动化配置；通过 Starter 依赖、自动配置机制减少样板代码和繁琐的 XML 配置；内置 Tomcat，开箱即用。 SpringBoot的原理 如果我们直接基于Spring框架进行项目的开发，会比较繁琐。SpringBoot框架之所以使用起来更简单更快捷，是因为SpringBoot框架底层提供了两个非常重要的功能：一个是起步依赖，一个是自动配置。 起步依赖 Spring Boot 只需要引入一个起步依赖（例如 springboot-starter-web）就能满足项目开发需求。这是因为： Maven 依赖传递： 起步依赖内部已经包含了开发所需的常见依赖（如 JSON 解析、Web、WebMVC、Tomcat 等），无需开发者手动引入其它依赖。 结论： 起步依赖的核心原理就是 Maven 的依赖传递机制。 自动装配 在传统 Spring 开发中，需要在 applicationContext.xml 或 @Configuration 类里手工声明大量 Bean。Spring Boot 为了简化配置，引入了 自动装配（Auto Configuration），让我们只需少量注解就能“开箱即用”。 自动配置原理源码入口就是@SpringBootApplication注解，在这个注解中封装了3个注解，分别是： @SpringBootConfiguration 声明当前类是一个配置类，作用类似 @Configuration，但作为 Spring Boot 的专用配置入口标识。 @ComponentScan 用于 扫描项目自定义的 Bean，比如 @Component、@Service、@Repository、@Controller 等。 默认扫描 启动类所在包及其子包。 如果项目分为 server、common 等模块，可以通过： @ComponentScan({"com.your.package.server", "com.your.package.common"}) 来显式指定需要扫描的包路径。 @EnableAutoConfiguration（自动配置核心注解） 导入自动配置类 通过元注解 @Import(AutoConfigurationImportSelector.class)，在启动时读取所有 JAR 包中 META‑INF/spring.factories 中声明的自动配置类，并注册到容器。 按条件注册 Bean 配置类内部使用条件注解（@ConditionalOnClass、@ConditionalOnMissingBean、@ConditionalOnProperty 等）。 仅在 类路径、配置属性、已有 Bean 满足条件时，才创建对应 Bean。 典型示例 DataSourceAutoConfiguration：检测到 JDBC 驱动和 spring.datasource. 配置时自动创建数据源。 WebMvcAutoConfiguration：配置 Spring MVC 默认组件；若用户提供了自定义 MVC 配置，则自动“退让”（Back Off）。 自动配置的效果： Spring Bean 生命周期与 AOP 代理生成流程 Bean 从类加载到定义注册 本笔记以 UserService为例，详细说明一个 Bean 从.class文件到成为代理对象的完整过程。 一、类加载（JVM 层面）：负责把 .class 文件加载进内存，生成 Class 对象。Spring 要使用某个类（比如反射扫描注解）时，前提是这个类已经被 JVM 加载过了。 触发时机： 组件扫描时（@Component、@Service、@Controller 等）； 配置类解析时（@Configuration、@Bean 方法、自动配置类）； 反射访问或依赖注入时（如第一次 getBean() 调用懒加载 Bean）。 二、Bean 定义加载（Spring 层面） 旨在收集所有 Bean 的元数据（BeanDefinition）。 【配置解析】：Java 配置类：通过 @Configuration + @Bean 方法； 【组件扫描】：@Component、@Service、@Repository、@Controller 等； 【注册定义】：将为每个 Bean 解析出的元信息（如类名、作用域、是否懒加载等）封装为一个 BeanDefinition对象，并注册到 BeanFactory的 beanDefinitionMap中。 ​ - class = UserService.class ​ - scope = singleton ​ - lazy = false 此时的状态：UserService类已经被类加载器加载，JVM中有它的 Class对象，Spring容器中有它的 BeanDefinition，但尚未创建任何实例，也未生成任何代理。 Spring Bean 生命周期 容器启动时（中后期），容器创建 Bean 实例，AOP 代理对象即在此过程中生成。 以下Bean例外： 1.被标记为 @Lazy 的 Bean，只有第一次被依赖或调用时才实例化 2.条件化注入（@Conditional），若配置项不满足，则这个 BeanDefinition 根本不会注册进容器 3.作用域为 prototype，每次 getBean() 才会创建一个新实例。 序号 阶段 说明 关键点 1 **实例化 ** Spring 容器根据配置（如 @Component、@Bean、XML）创建 Bean 的实例。通常通过 反射 调用无参构造方法创建对象。 此时是原始对象，非代理。 2 属性填充、**依赖注入 ** 按照注解（如 @Autowired）或配置，为原始对象注入其所需的依赖。 3 BeanPostProcessor 前置处理 执行所有 BeanPostProcessor的 postProcessBeforeInitialization 方法。 可用于修改 Bean 实例，此时仍是原始对象。 4 **初始化 ** 1. 执行 @PostConstruct注解的方法。 2. 执行 InitializingBean.afterPropertiesSet()方法。 3. 执行自定义的 init-method。 Bean 自身定义的初始化逻辑。 5 BeanPostProcessor 后置处理 (AOP 切入点！) 执行所有 BeanPostProcessor的 postProcessAfterInitialization 方法。这是 AOP 代理生成的核心时机！ - AnnotationAwareAspectJAutoProxyCreator 会检查当前 Bean 是否需要被代理（如是否被 @Transactional、AOP 切面匹配）。 - 如果需要：则调用 JDK Proxy 或 CGLIB 动态生成代理对象，并返回此代理对象。 - 如果不需要：则直接返回原始对象。 代理对象在此刻诞生，并替换了原始对象。 6 Bean 就绪 经过上述步骤的最终对象（可能是代理对象，也可能是原始对象）被放入单例池（singletonObjects），标志着 Bean 完全就绪，可供容器和其他 Bean 使用。 使用代理 Bean 当其他组件（如 UserController）通过 @Autowired注入 UserService时，它们拿到的是第二步第5步中返回的代理对象，而非最初的原始对象。 当调用 userService.someMethod()时，调用的是代理对象的方法。 代理对象会先执行增强逻辑（如事务管理、日志记录），然后通过反射调用原始对象（rawInstance）的相应方法。 SpringBoot启动流程 Spring Boot 启动顺序就是： JVM 类加载 → 创建 SpringApplication → 加载配置 → 扫描并注册 BeanDefinition → 实例化 & 注入 & 初始化 → AOP 代理 → 启动 Web 容器 → 发布启动完成事件。 入口：main 方法 做什么：所有 Spring Boot 应用从带有 @SpringBootApplication 注解的类的 main 方法启动。 关键代码 public static void main(String[] args) { SpringApplication.run(MyApplication.class, args); } 作用：调用 SpringApplication.run() 启动整个流程。 前提：启动类本身（MyApplication.class）已经被 JVM 类加载器加载到内存。 初始化 SpringApplication 做什么 创建 SpringApplication 实例，负责管理整个启动流程。 准备一系列监听器（Listeners）、初始化器（Initializers）。 加载扩展组件：从 META-INF/spring.factories 读取自动配置、监听器等第三方扩展点。 事件机制：发布 ApplicationStartingEvent 等事件，允许开发者在早期介入。 读取配置文件 做什么 加载配置：读取 application.properties 或 application.yml。 设置环境变量：构建 ConfigurableEnvironment，整合系统属性、JVM 参数、配置文件参数。 刷新应用上下文（核心步骤） 调用 context.refresh() → Spring Boot 启动的心脏。 关键子流程： 加载 Bean 定义 组件扫描：解析 @Component、@Service、@Controller 等注解类。 配置类解析：读取 @Configuration 类中的 @Bean 方法。 注册成 BeanDefinition，存放在 beanDefinitionMap 中。 注意：此时只是“配方”，尚未 new 出对象。 自动配置 触发 @EnableAutoConfiguration。 加载 META-INF/spring.factories 中的自动配置类。 条件化配置 在加载自动配置类时，@ConditionalOnXxx 会通过 ConditionEvaluator 判断是否满足条件，决定 BeanDefinition 是否注册。 实例化与依赖注入 所有非懒加载的单例 Bean 被实例化 执行依赖注入（@Autowired、@Resource 等）。 Bean 初始化 调用 @PostConstruct、InitializingBean.afterPropertiesSet()、init-method 等初始化方法。 后置处理器（AOP 切入点） BeanPostProcessor.postProcessAfterInitialization() 阶段，如果匹配到切面（如事务、日志），生成 代理对象，替换原始 Bean。 启动内嵌 Web 服务器（Web 场景） 做什么：如果是 Web 应用，会自动启动内嵌的 Tomcat/Jetty/Undertow。 关键点：端口监听完成后，Spring Boot 应用对外提供服务。 完成启动 做什么：容器进入就绪状态。 回调执行：执行 ApplicationRunner、CommandLineRunner 中的逻辑。 事件发布：发布 ApplicationReadyEvent，通知应用完全启动成功。 Spring循环依赖为什么需要三级缓存 在Spring的默认单例Bean创建过程中，三级缓存指的是三个Map： 一级缓存 ：存放完全初始化好的、成熟的Bean。这是我们实际用到Bean的地方。 二级缓存 ：存放早期的Bean引用（已实例化但未完成属性填充和初始化）。 三级缓存 ：存放Bean的工厂对象（ObjectFactory）。这个工厂能根据条件（如是否需要AOP）返回一个早期的Bean引用。 如果只有二级缓存（早期暴露） 开始创建 A 实例化 A（调用构造函数，得到一个原始对象 A@123） 将原始对象 A@123放入二级缓存（earlySingletonObjects） 为 A 填充属性时发现需要注入 B → 触发 getBean("B") 开始创建 B 实例化 B（得到原始对象 B@456） 将 B@456放入二级缓存 为 B 填充属性时发现需要注入 A → 从二级缓存拿到 A@123（原始对象） B 完成属性注入（此时 B 中持有的是 A@123） B 完成初始化，放入一级缓存（singletonObjects） 回到 A 的创建 从一级缓存拿到 B@456，注入到 A 中 问题来了：如果 A 需要被 AOP 代理（比如有 @Transactional注解），Spring 会生成代理对象 A$Proxy@789，并放入一级缓存 最终结果： 一级缓存中是 A$Proxy@789（代理对象） 但 B 中已经注入了 A@123（原始对象），导致 B 依赖的 A 和最终暴露的 A 不是同一个对象！ （AOP 失效，事务等增强不生效） 引入三级缓存 开始创建 A 实例化 A（得到原始对象 A@123） 向三级缓存暴露一个工厂（ObjectFactory），而非直接放入二级缓存 （工厂的作用：在需要时能返回原始对象或代理对象） 为 A 填充属性时发现需要注入 B → 触发 getBean("B") 开始创建 B 实例化 B（得到 B@456） 向三级缓存暴露 B 的工厂 为 B 填充属性时发现需要注入 A → 从三级缓存拿到 A 的工厂 工厂执行 getObject()，此时发现 A 需要 AOP 代理 → 生成代理对象 A$Proxy@789 （代理逻辑会根据 A 的最终状态生成，但此时仅提前生成引用） 将 A$Proxy@789放入二级缓存（标记为“早期引用”） B 完成属性注入（此时 B 中持有的是 A$Proxy@789） B 完成初始化，放入一级缓存 回到 A 的创建 从一级缓存拿到 B@456，注入到 A 中 A 完成初始化，检查二级缓存： 如果存在（说明有工厂提前生成过代理），则确保最终暴露的对象与早期引用的代理一致 （这里直接返回二级缓存的 A$Proxy@789，避免重复生成代理） 将 A$Proxy@789放入一级缓存 Spring事务失效的情况 一、自调用 同一个类中，一个方法调用另一个标注了 @Transactional 的方法时，事务不会生效。 原因：Spring AOP 是基于代理的，调用必须通过 Spring 生成的代理对象进行。如果是类内部调用，相当于 this.method()，绕过了代理。 @Service public class UserService { @Transactional public void methodA() { methodB(); // 自调用，事务失效 } @Transactional public void methodB() { // 不会开启事务 } } 正确调用方式： 将 methodB() 提取到另一个类中 @Service public class MethodBService { @Transactional public void methodB() { // 事务生效 } } @Service public class UserService { @Autowired private MethodBService methodBService; public void methodA() { methodBService.methodB(); // ✅ 通过 Spring 管理的代理对象调用，事务生效 } } 进阶：通过代理调用自身方法（推荐） 你可以从 Spring 容器中获取当前 Bean 的代理对象，然后用代理对象调用 methodB()： @Service public class UserService { @Autowired private ApplicationContext context; @Transactional public void methodA() { // 从容器中获取代理对象 UserService proxy = context.getBean(UserService.class); proxy.methodB(); // 经过代理调用 } @Transactional public void methodB() { // 这里的事务才会生效 } } 二、方法不是 public 导致事务失效 被 @Transactional 注解的方法如果不是 public 的，事务不会生效。 原因：Spring 默认使用 JDK 或 CGLIB 代理，而代理只对 public 方法进行增强。 三、异常未被正确抛出 如果抛出的异常是 checked exception 或者被 catch 捕获而没有重新抛出，事务不会回滚。 默认情况下，只有 RuntimeException 和 Error 会触发回滚，可以通过设置 rollbackFor 参数来指定其他异常： @Transactional(rollbackFor = Exception.class) 被 catch 捕获而没有重新抛出，正确做法：捕获后继续抛出 @Transactional public void saveUser() { try { userRepository.save(null); } catch (Exception e) { throw new RuntimeException("数据库保存失败", e); // 重新抛出，触发回滚 } } 四、数据库引擎不支持事务 使用的数据库或数据库表不支持事务（如 MySQL 的 MyISAM 引擎），即使开启事务也不会生效。 分布式事务 好的，我来分别简洁说明 2PC、3PC、TCC 三种分布式事务控制方式： 1. 2PC（两阶段提交） 阶段一：准备（Prepare） 协调者向所有参与者发送“准备提交”请求，参与者执行事务但不提交，只写入日志并锁定资源，返回“可以提交/回滚”。 阶段二：提交（Commit） 如果所有参与者都同意，协调者下发“提交”；若有一个失败，则下发“回滚”。 优点：实现简单，强一致性。 缺点：同步阻塞、单点故障风险大、可能导致资源长时间锁定。 同步阻塞：在事务提交前，参与者资源会被锁定，导致其它请求被阻塞，效率较低。 单点故障：协调者是单点，一旦出问题，整个事务无法推进，严重依赖其可用性。 数据不一致：由于网络异常或消息丢失，可能导致部分参与者提交，部分未提交，从而产生数据不一致。 2. 3PC（三阶段提交） 在 2PC 基础上增加 “预提交”阶段，拆成三步： CanCommit：协调者询问是否可提交。 PreCommit：若所有参与者都回复可行，协调者发送“预提交”，参与者执行事务并进入“可提交”状态。 DoCommit：协调者最终发出提交命令，参与者完成提交。 优点：减少单点故障导致的阻塞，超时可自动决策。 缺点：实现复杂，依然可能出现数据不一致。 在 3PC 中，参与者在 PreCommit 阶段已经进入“可以提交”的状态，如果后续协调者消息丢失，参与者还能根据超时策略自主提交或回滚。 3. TCC（Try-Confirm-Cancel） 应用层实现的补偿事务模型： Try：尝试阶段，预留资源（如冻结资金）。 Confirm：确认阶段，正式执行业务操作。 Cancel：取消阶段，释放预留资源，回滚业务。 要求： 三套接口：开发者需要在业务代码里写冻结、确认、释放三套接口 幂等性：Cancel 接口必须设计成可安全多次调用。 如果没有冻结，就什么都不做，返回成功； 如果已经冻结，就解冻并返回成功。 空补偿（Null Compensation）：允许 Cancel 在没有 Try 的情况下被调用，业务逻辑要能识别并正确处理。 计算机网络 三次握手 三次握手过程 TCP三次握手的目标是双方 同步序列号 和 确认序列号，并建立一条可靠的连接。 假设客户端（Client）想要与服务器（Server）建立连接。 第一次握手（SYN） 客户端向服务器发送一个请求连接的报文，标志位 SYN=1，并携带一个随机的初始序列号 seq=x， 表示希望建立连接。 发送完后，客户端进入 SYN-SENT 状态。 第二次握手（SYN-ACK） 服务器收到客户端的 SYN 报文后，如果同意建立连接，会回复一个标志位为 SYN=1、ACK=1 的报文， 此时服务器自己也生成一个初始序列号 seq=y，并将 ack=x+1 作为确认号， 表示已收到客户端的请求。 服务器进入 SYN-RCVD 状态。 第三次握手（ACK） 客户端收到服务器的 SYN-ACK 后，再回复一个 ACK=1 的确认报文， 其中 seq=x+1、ack=y+1，表示双方的握手确认成功。 此时客户端进入 ESTABLISHED 状态；服务器收到该报文后，也进入 ESTABLISHED 状态。 为什么两次握手不行 防止已失效的连接请求报文突然传到，导致错误（核心原因） 场景：假设客户端发送了一个SYN请求（第一次握手），但这个报文在网络中滞留了，迟迟没有到达服务器。客户端超时后未收到回复，于是重发了一个新的SYN请求，这次成功建立连接，数据传输完毕后关闭了连接。 问题：此时，那个滞留的SYN报文终于到达了服务器。服务器误以为这是客户端发起的一个新连接请求，于是回复SYN-ACK（第二次握手）并提前分配了资源（如缓冲区、连接变量）。如果只有两次握手，服务器发出SYN-ACK后就会认为连接已经建立，并一直等待客户端发送数据。 结果：但客户端根本没有想要建立这个新连接！客户端不会发送任何数据。这将导致服务器的资源被白白浪费，等待一个永远不会到来的数据请求，造成资源耗尽和服务不可用的风险。 三次握手如何解决？ 在三次握手的机制下，服务器收到滞留的SYN报文后，会回复SYN-ACK。但客户端不会回复第三个ACK（因为它没有请求这个新连接）。服务器在长时间收不到ACK后，会超时重传SYN-ACK，最终失败并释放资源，从而避免了错误。 四次挥手 四次挥手过程 TCP是全双工通信，这意味着数据在两个方向上可以独立传输。因此，关闭连接需要每个方向都单独被关闭。 假设客户端（Client）主动发起关闭连接。 第一步：FIN (Finish) 动作：客户端希望关闭连接，于是发送一个TCP报文段。 标志位：设置 FIN = 1（表示它没有更多数据要发送了）。 序列号：seq = u（u等于客户端之前已传送数据的最后一个字节的序列号加1）。 状态变化：客户端进入 FIN-WAIT-1状态。这意味着客户端已经没有数据要发送了，但如果服务器还有数据要发送，客户端仍然需要接收。 第二步：ACK (Acknowledge) 动作：服务器收到客户端的FIN报文后，对其进行确认。 标志位：设置 ACK = 1。 确认号：ack = u + 1（确认收到了客户端的关闭请求）。 状态变化：服务器进入 CLOSE-WAIT状态。 此时状态：从客户端到服务器的这个方向的连接已经关闭。客户端收到这个ACK后，进入 FIN-WAIT-2状态。但服务器到客户端的连接仍然打开，服务器可能还有未发送完的数据需要继续发送。 第三步：FIN (Finish) 动作：当服务器也完成了所有数据的发送后，它也会发送一个FIN报文。 标志位：设置 FIN = 1（有时这个报文会和第二步的ACK合并，此时标志位为 FIN = 1, ACK = 1）。 序列号：seq = w（w等于服务器之前已传送数据的最后一个字节的序列号加1）。 确认号：ack = u + 1（通常还会再次确认客户端的FIN）。 状态变化：服务器进入 LAST-ACK状态。 第四步：ACK (Acknowledge) 动作：客户端收到服务器的FIN报文后，必须发送一个确认报文。 标志位：设置 ACK = 1。 确认号：ack = w + 1。 状态变化：客户端进入 TIME-WAIT状态。等待一段时间（2MSL，Maximum Segment Lifetime）后，客户端才进入 CLOSED状态，彻底释放资源。服务器在收到这个最终的ACK后，立即进入 CLOSED状态，释放资源。 为什么客户端需要 TIME-WAIT 状态？ 客户端发送完最后一个ACK后，必须等待 2MSL 的时间长度。 主要原因有两个： 可靠地终止连接：确保最后一个ACK能被服务器收到。如果这个ACK报文丢失，服务器会在超时后重传它的FIN报文（第三次挥手）。客户端在TIME-WAIT状态下可以收到这个重传的FIN，并重新发送ACK，从而保证双方都能正常关闭。如果没有这个等待，客户端直接关闭，服务器将永远收不到ACK，会一直处于LAST-ACK状态，无法正常关闭。 让旧连接的报文在网络中消散：等待2MSL时间，可以确保本次连接所产生的所有报文都从网络中消失，这样就不会影响到未来可能建立的、具有相同四元组（源IP、源端口、目的IP、目的端口）的新连接。 TCP和UDP 特性 TCP UDP 连接 面向连接 (需三次握手) 无连接 可靠性 高可靠，不丢包、不乱序 不可靠，可能丢包、乱序 速度/延迟 慢，开销大 快，延迟低 控制机制 有流量控制、拥塞控制 无控制，直接发送 数据形式 字节流 (无边界) 数据报文 (有边界) 特性 流量控制 (Flow Control) 拥塞控制 (Congestion Control) 控制对象 发送方 vs 接收方 (一对一) 发送方 vs 网络 (一对多) 解决什么问题 发送太快，接收方处理不过来（缓冲区溢出） 发送太快，网络处理不过来（路由器丢包） 根本原因 接收方处理能力、缓冲区大小有限 网络带宽、路由器缓存等公共资源有限 谁在控制 接收方主导（通过通知发送方自己的窗口大小） 发送方主动探测和调整（根据网络反馈） 实现机制 滑动窗口协议 (TCP头部中的窗口大小字段) 多种算法 (慢启动、拥塞避免、快重传、快恢复) 目标 防止接收方被淹没 防止网络被塞满瘫痪 使用场景： TCP：用于需要“万无一失”的场景 Web浏览 (HTTP/HTTPS) 文件传输 (FTP) 邮件发送 (SMTP) 远程登录 (SSH) UDP：用于需要“越快越好”的场景 视频直播/视频会议 在线实时游戏 (如吃鸡、王者荣耀) 语音通话 (VoIP) DNS域名解析 TCP是面向连接的、可靠的协议，通过三次握手、确认重传等机制保证数据正确送达，但速度慢，适合网页、邮件、文件传输。UDP是无连接的、不可靠的协议，不做任何保证，但速度快、延迟低。 应用层的拼包、拆包 TCP 是“面向字节流”的、无边界的协议，它只保证数据按顺序、无丢失地到达，但—— 并不会告诉你消息的边界。 应用层必须自己定义消息边界。 1.固定长度协议 规定每个消息固定长度，比如 100 字节。 优点：实现简单。 缺点：浪费空间，不灵活。 2.特殊分隔符 在每条消息末尾加上特殊字符，比如 \n、#END# 等。 优点：简单直观。 缺点：内容里不能包含分隔符（要转义）。 3.消息长度前缀 在每条消息前加上一个定长的“长度字段”，告诉接收方消息体有多长。 最常用、最通用的方式。 | 4 bytes length | <length> bytes data | TCP保证按序接收（乱序到达，但是内部会重排再传给应用层），根据事先约定的协议，前4个字节是代表这条消息的长度，然后再读取对应长度的字节流，就可以组装成完整的一条消息了。 4.高级协议 像 HTTP、WebSocket、gRPC 等都在 TCP 之上定义了自己的消息边界： HTTP：以请求头中的 Content-Length 或分块编码表示长度。 WebSocket：帧格式中定义了帧长度。 gRPC / Netty：自动处理粘包拆包问题。 HTTP 版本对比表 特性/版本 HTTP/1.0 HTTP/1.1 HTTP/2 连接方式 每次请求都新建 TCP 连接，用完立即关闭（短连接） 默认支持 长连接（Connection: keep-alive），一个 TCP 连接可复用 多路复用：一个 TCP 连接里并发处理多个请求/响应 请求并发 不支持，需要并发就建多个连接 支持管道化（pipelining），但存在队头阻塞，实际应用少 真正解决队头阻塞，多路复用并行传输 带宽利用 头部重复多，浪费带宽 引入 Host 头字段，支持虚拟主机；依然是明文文本传输 头部压缩（HPACK），大幅减少头部开销 传输效率 每次请求-响应一个连接，效率低 长连接提升效率，但仍有队头阻塞问题 二进制分帧、并行传输、服务器推送，效率最高 使用场景 早期 Web 目前最广泛使用（浏览器默认） 大型应用、高并发、移动端、CDN，逐渐普及 HTTP/1.1 解决了 1.0 的短连接问题，但仍然存在队头阻塞、并发效率低、头部冗余的问题。 HTTP/2 的主要优势体现在： 二进制分帧：高效传输，方便扩展； 多路复用：一个连接并发多个请求，解决了 1.1 的队头阻塞； 头部压缩：减少带宽浪费； 服务器推送：服务器可以主动把可能用到的资源（CSS/JS）推给客户端，减少延迟。 访问一个网站的过程 1.建立连接 DNS 解析：浏览器先把域名（如 www.example.com）解析成 IP 地址。 TCP 三次握手：和目标服务器建立 TCP 连接（默认端口 80，若是 HTTPS 还要做 TLS 握手）。 建立 HTTP/1.1 长连接 请求头里会带上 Connection: keep-alive（默认启用），同一个 TCP 连接可以复用多次请求。 2.浏览器先请求 HTML 页面： GET /index.html HTTP/1.1 Host: www.example.com Connection: keep-alive User-Agent: Chrome/... Accept: text/html,application/xhtml+xml 3.服务器返回响应 服务器处理请求，返回 HTML 页面： HTTP/1.1 200 OK Content-Type: text/html Content-Length: 1234 Cache-Control: max-age=3600 ... 4.浏览器解析 HTML 解析 HTML 时，发现里面引用了 CSS、JS、图片 等静态资源。 浏览器会发起新的请求去获取这些资源。 5.并发请求资源 HTTP/1.1 一个 TCP 连接虽然可以复用，但请求必须顺序执行 → 存在队头阻塞。 为了提高并发性能，浏览器会：同时打开多个 TCP 连接（6~8 个/域名），每个连接里顺序下载多个资源； 6.页面渲染 CSS、JS、图片加载完成后，浏览器开始渲染页面。JS 可能会再发起 Ajax 请求（同样走 HTTP/1.1 长连接）。 HTTPS和HTTP的区别是？ HTTP 是超文本传输协议，数据以明文方式传输； HTTPS 是基于 SSL/TLS 的加密版 HTTP，通信过程经过加密、认证和完整性校验。 主要区别： 传输安全性： HTTP 明文传输，容易被窃听、篡改、伪造（中间人攻击）； HTTPS 通过非对称加密 + 对称加密 + 数字证书三重机制确保保密性、完整性、身份验证。 连接建立： HTTPS 在 TCP 三次握手后还需进行 TLS 握手（协商加密算法、交换密钥）； 端口与证书： HTTP 用 80 端口，无证书； HTTPS 用 443 端口，需要 CA 签发证书（如 Let’s Encrypt 免费证书）。 “HTTP 使用 80 端口” 指的是：服务器端（Web Server）默认监听 80 端口，浏览器（客户端）会默认向服务器的 80 端口发起连接请求。 当你在浏览器输入： http://example.com 实际上等价于： http://example.com:80 假设后端java监听8080，那生产环境为什么能直接访问域名不带端口？ 原因：只有对外提供 Web 访问的入口服务（网关 / 反向代理）监听 80 端口。 用户访问 → http://www.xxx.com（默认走 80） ↓ DNS 解析 —— 把域名解析成 IP ↓ Nginx 监听 80 端口 ↓ 反向代理到 8080（Spring Boot/Tomcat） 浏览器(80) → Nginx(80) → 内部应用(8080) ,这样外部用户访问时不用加 :8080，通过nginx访问后端服务。 HTTPS能否抓包解密？ 抓包本质是监听网络上的数据包。 正常情况：HTTPS 的数据虽然可以被抓到（因为数据包仍然经过网卡），但它是 经过 TLS 加密 的，内容是密文，看不懂。 抓包工具 抓包工具（如 Fiddler、Charles）之所以能“看到明文”，是因为它们在本机充当了一个 中间代理服务器（MITM，中间人） 并安装了自己的“根证书”，拦截并解密 HTTPS 流量。 具体步骤 ① 安装自己的“根证书”到系统/浏览器信任区 ，让浏览器信任它 ② 拦截请求并伪造中间证书，工具假装是服务器，与浏览器建立加密连接，再与真正服务器建立另一个加密连接 这样 Charles 能在中间看到明文内容（相当于翻译官）。 注意：这是本地信任的中间人攻击！因为用户主动信任了 Charles 的证书； 而且不是配置浏览器或者服务器的私钥！！！ F12开发者模式 浏览器 F12 的 Network 面板能看到所有该标签页发出的请求与响应（包括 HTTPS 的明文内容），因为浏览器在显示给你之前已经完成了 TLS 解密。但它不能抓取网络层的原始加密包。 为什么能看到 HTTPS 明文？ 浏览器本身在内核里完成 TLS 握手并解密数据，DevTools 是运行在浏览器进程内的开发者工具，所以它能读取到浏览器已经解密后的请求/响应内容。 HTTPS连接建立过程 使用的主要技术手段：非对称加密、对称加密、数字证书、摘要算法。 建立过程 1）TCP 三次握手 和普通 HTTP 一样，首先通过三次握手建立 TCP 连接。 2）客户端发起请求 客户端发送支持的 TLS 版本、加密套件列表、随机数（Client Random）。 随机数将用于后续生成会话密钥。 3）服务端响应 服务器确认选择使用的 TLS 版本、加密算法，并返回服务器的 随机数（Server Random）。 服务器同时发送 数字证书（包含公钥、域名信息、签发机构等）。 4）客户端验证证书 客户端检查证书是否由 可信 CA 签发、域名是否匹配、是否过期/吊销。 验证通过后，客户端生成一个 Pre-Master Secret（预主密钥），并用服务器公钥加密后发送给服务端。 5）生成会话密钥 客户端和服务端根据： Client Random Server Random Pre-Master Secret 共同计算出 会话密钥（对称加密密钥）。 6）握手完成 双方用会话密钥加密一段测试数据，互相验证可解密成功。 之后正式使用 对称加密 进行数据传输。 非对称加密 常见非对称加密算法：RSA DSA ECC 使用一对密钥：公钥 (Public Key) 和 私钥 (Private Key)。 这里表现： 客户端用 服务器证书里的公钥 加密 Pre-Master Secret，发送给服务器。 服务器用 私钥 解密，得到 Pre-Master Secret。 对称加密 常见对称加密算法： DES AES 加密和解密使用 同一把密钥。 优点：计算速度快，效率高，适合大规模数据加密。 缺点：密钥分发困难，如果密钥在传输过程中被截获，通信就不安全。 这里表现： Client Random、Server Random、Pre-Master Secret生成 会话密钥。客户端和服务端都有这个密钥，之后的请求响应都用这个加密。 操作系统 多个进程同时执行一个代码块会产生冲突吗？ 不会产生内存级别的冲突，因为进程的内存空间是隔离的。 但是，如果这些进程要操作 同一个外部资源（比如：同一个文件、同一个数据库表、同一个 socket），就可能发生冲突或竞争。 例如：两个进程同时写一个文件 → 文件可能被覆盖或写乱，需要 文件锁 来保护。 两个进程同时更新数据库记录 → 可能出现脏数据，需要 事务机制 / 乐观锁 / 悲观锁。 如果是线程呢？ 多线程在同一个进程内，会共享堆和方法区的数据。如果多个线程同时执行修改同一个共享变量，就会出现 线程安全问题（比如 i++ 的经典例子）。所以多线程比多进程更容易因为共享数据而冲突，需要 synchronized、ReentrantLock等手段保证安全。 进程和线程和程序区别 程序是蓝图，是静态的代码文件，还没运行； 进程是运行起来的实例，是资源分配的最小单位； 线程是 CPU 调度的最小单位。 1）进程（Process）是程序在系统中的一次运行实例；线程（Thread）是进程内的一个独立执行单元。 2）进程拥有独立的内存空间和系统资源；线程共享进程的内存（堆、方法区等），但有自己的栈和程序计数器。 3）CPU 调度线程执行（线程是最小调度单位），进程之间切换开销更大，线程切换更轻量。 4）进程间通信（IPC）需通过管道、Socket、共享内存等；线程间通信更简单（共享变量即可）。 操作系统调度算法 周转时间 = 完成时间 − 到达时间 等待时间 = 完成时间 − 到达时间 − 运行时间 响应时间 = 第一次开始运行 − 到达时间 算法 是否抢占 简要说明 特点 / 优缺点 FCFS（先来先服务） ❌ 非抢占 按到达先后顺序执行 简单易实现；对短作业不利，平均等待时间较长 SJF（短作业优先） ❌ 非抢占 优先执行运行时间最短的作业 平均等待时间最小，但长作业易“饥饿”；需要已知运行时间 SRTF（最短剩余时间优先） ✅ 抢占式 SJF 到达新作业时比较剩余时间，短者先执行 提高响应速度，但长作业可能长期不运行 HRRN（最高响应比优先） ❌ 非抢占 优先级 = (等待时间 + 运行时间) / 运行时间 兼顾短作业与长作业；避免饥饿；计算复杂度略高 RR（时间片轮转） ✅ 抢占 每个进程分配相等时间片，时间片到就切换 公平性好；时间片太小开销大，太大退化为 FCFS 数据结构 迪杰斯特拉算法 用途：解决 单源最短路径问题 —— 从一个起点出发，找到到所有其他节点的最短路径。 前提：边权重必须是 非负数。 核心思想 利用 贪心策略：每次从未确定的节点中，选择距离起点最近的节点，把它加入“已确定集合”。 然后用新加的点作为中间跳板，计算当前邻接点的最短距离。 循环，直到所有节点都确定最短路径。 算法步骤 初始化： 起点到自身距离设为 0，其余节点设为 ∞。 已确定集合为空。 重复以下操作： 从未确定的节点里，选一个当前距离最小的节点 u。 把 u 加入“已确定集合”。 用 u 更新它的邻居 v： if dist[v] > dist[u] + w(u,v): dist[v] = dist[u] + w(u,v) 直到所有节点都被处理。 笔试选择题 节点的度：指这个节点的子树的数量。 树的结点个数为 n。 树的分支数 = 所有节点的度之和 = n-1 A /|\ B C D / \ E F 度为3的节点有1个（A）；度为2的节点有1个（B）；度为0的节点有4个 数据库Mysql 什么是约束 约束名称 英文名称 主要作用 示例 主键约束 Primary Key 唯一标识表中每一行数据，且不能为空 PRIMARY KEY (id) 外键约束 Foreign Key 保证一个表中的值引用另一个表中存在的值 FOREIGN KEY (dept_id) REFERENCES department(id) 唯一约束 Unique 确保列中的数据唯一，但可为空 UNIQUE (email) 检查约束 Check 限制列中数据的取值范围 CHECK (age >= 18) 非空约束 Not Null 确保列的值不能为空 name VARCHAR(50) NOT NULL 什么是事务？事务有哪些特性？ 什么是事务 事务是一组操作的集合，它是一个不可分割的工作单位。事务会把所有的操作作为一个整体一起向系统提交或撤销操作请求，即这些操作要么同时成功，要么同时失败。 BEGIN; 和 START TRANSACTION; 都能开启事务； BEGIN; -- 或者 START TRANSACTION; 在 MySQL 的事务型引擎（InnoDB）中，单条 SQL 本身就是一个事务；执行成功自动提交，失败自动回滚； UPDATE users SET balance = balance - 100 WHERE id = 1; 流程实际上是： BEGIN; UPDATE users SET balance = balance - 100 WHERE id = 1; COMMIT; 手动提交事务使用步骤： 第1种情况：开启事务 => 执行SQL语句 => 成功 => 提交事务 第2种情况：开启事务 => 执行SQL语句 => 失败 => 回滚事务 -- 开启事务 start transaction ; -- 删除学工部 delete from tb_dept where id = 1; -- 删除学工部的员工 delete from tb_emp where dept_id = 1; 上述的这组SQL语句，如果执行成功，则提交事务 commit ; 上述的这组SQL语句，如果执行失败，则回滚事务 rollback ; 注意：要手动 COMMIT; 或者 ROLLBACK; 才能结束当前事务。 场景 操作结果 数据最终状态 ✅ 所有 SQL 成功执行，输入 COMMIT; 事务提交 数据永久保存 ✅ 所有 SQL 成功执行，但输入 ROLLBACK; 手动回滚 所有修改撤销 ⚠️ 某条 SQL 执行失败，输入 COMMIT; 部分提交 仅前面成功的语句生效，失败语句无效 ⚠️ 某条 SQL 执行失败，输入 ROLLBACK; 全部回滚 所有语句都撤销 Spring 的 @Transactional 注解,就是在 Java 层面自动帮你包裹事务。 事务有哪些特性？ 1）原子性（Atomicity）：事务是不可分割的最小单元，要么全部成功，要么全部失败。 2）一致性（Consistency）：事务完成时，必须使所有的数据都保持一致状态。 约束层面一致性主要指 数据要符合数据库定义的各种约束，主键约束、外键约束、唯一约束等必须始终成立。 业务层面一致性：部门和该部门下的员工数据全部删除。 3）隔离性（Isolation）：数据库系统提供的隔离机制，保证事务在不受外部并发操作影响的独立环境下运行（事务还没commit，那么别的窗口就看不到该修改 ）。在 MySQL（InnoDB 引擎）里，隔离性主要是通过 事务隔离级别 + MVCC（多版本并发控制） + 锁机制 来实现的。 SQL 标准定义了 4 种隔离级别（MySQL 都支持）： 读未提交 (READ UNCOMMITTED) 能读到别的事务未提交的数据（脏读）。 几乎没隔离，效率最高。 读已提交 (READ COMMITTED) 只能读到别的事务已提交的数据。 Oracle、PostgreSQL 默认级别，避免脏读，但会出现不可重复读。 可重复读 (REPEATABLE READ) MySQL 默认级别。 在同一个事务里，多次读同一行结果一致（避免不可重复读）。 InnoDB 在此级别下还能避免幻读（通过间隙锁）。 串行化 (SERIALIZABLE) 所有事务串行执行（加锁），最安全但效率最低。 4）持久性（Durability）：事务一旦提交或回滚，它对数据库中的数据的改变就是永久的。 事务的四大特性简称为：ACID 第一、二、三范式含义 第一范式： 表中每一列（字段）都必须是原子性的，不能再拆分。 反例： 学号 姓名 电话 001 张三 13888888888, 13999999999 第二范式： 在满足 1NF 的基础上，非主键字段必须完全依赖于主键（如果表的主键是复合主键，则非主键字段必须完全依赖于整个主键，不能只依赖主键的一部分）。 反例： 学号 课程号 学生姓名 成绩 001 C01 张三 90 001 C02 张三 85 主键是（学号, 课程号） “成绩”依赖于（学号, 课程号） → ✅ 完全依赖 “学生姓名”只依赖于学号 → ❌ 部分依赖（不满足 2NF） 关键是需要学号+课程号才能确定一条记录，缺一不可，而这里只需要学号这个姓名就确定了！！！ 正确做法： 拆成两张表： 学生表（student） 学号 姓名 001 张三 成绩表（score） 学号 课程号 成绩 001 C01 90 001 C02 85 → 消除部分依赖，满足 2NF。 第三范式 必须先满足 2NF；非主键字段必须直接依赖于主键，不能存在传递依赖。 反例： 学号 姓名 系编号 系名称 001 张三 D01 计算机系 这里： “系编号”依赖于“学号”（主键）； “系名称”依赖于“系编号”； → “系名称”间接依赖于主键（学号），出现传递依赖，不满足 3NF。 正确做法： 拆成两张表： 学生表（student） 学号 姓名 系编号 001 张三 D01 系别表（department） 系编号 系名称 D01 计算机系 → 消除了传递依赖，满足 3NF。 B+树结构 计算三层B+树大致存多少数据： 数据页大小：16KB（16384字节） 单条记录大小：1KB（主键+数据，实际通常更小） 内部节点存储内容：指向子节点的指针 + 索引键 指针大小：6字节 索引键大小：8字节（bigint类型） 计算： 叶子节点层（第三层）：每个叶子节点页可存储记录数：16KB ÷ 1KB = 16条 每个中间节点页可指向的子节点数：16 × 1024 ÷ (6 + 8) = 1170个 根节点可指向的中间节点数：1170个 1170 × 1170 × 16 = 21,902,400条，一棵三层的B+树在MySQL中可存储约2000万条记录 1. 非叶子节点（Internal Node） 作用：索引导航，决定往哪个子节点走。 内容 键值（key，可能是单列值，也可能是联合索引的前缀组合值）。 子节点指针。 特点 非叶节点里的键值是**“分隔键”**，用于划分范围。 不是表里的真实记录，不存完整数据，也不存主键。 它就像一本书的目录，告诉你“第 1-100 页是 A，101-200 页是 B”。 2. 叶子节点（Leaf Node） 聚簇索引 (PRIMARY KEY)：叶子节点存整行数据（所有列）。 二级索引 叶子节点存 索引列 + 主键值 因为二级索引不是数据行存储的地方，需要主键值才能回表。 联合索引：叶子节点存 (col1, col2, …, colN) + 主键值。 它就像图书馆的书架，摆放着书的完整信息（至少包含你要定位的索引列 + 主键）。 3. 排序 & 查找规则 联合索引按 字典序排序 先比第一列； 相同再比第二列； 相同再比第三列； …依此类推。 查找过程： 在非叶子节点里二分定位范围； 进入叶子节点，顺序扫描（或精确匹配）到目标记录。 节点内部查找 页目录分了多个槽，每个槽都指向对应一个分组内的最后一条记录，每个分组内都会包含若干条记录。 通过二分查询，利用槽就能直接定位到记录所在的组，从而就能获取到对应的记录。 举个例子，现在有5个槽，如果想查找主键为3的记录，此时的流程是： 1）通过二分得到槽的中间位置，low=0，high=4，(0+4)/2=2； 2）通过槽定位到第二个分组中的主键为4的记录，4大于3，low=0不变，high=2； 3）继续二分（0+2)/2=1；槽1中主键2小于3，low=1，high=2； 4）此时high-low=1，可以确定值在high即槽2中，但是槽2只能定位到主键为4的记录，又因为槽之间是挨着的，所以可以得到槽1的位置，从槽1入手拿到主键2的记录，然后因为记录是通过单向链表串起来的，往下遍历即可定位到主键3的记录。 聚簇索引和二级索引 聚簇索引和二级索引都用 B+ 树实现，区别在于叶子节点存的内容。聚簇索引的叶子节点存整行数据，所以查主键不需要回表；而二级索引的叶子节点只存索引列和主键值，如果要取不在索引里的列，就要用主键回表去聚簇索引查数据。 什么是最左匹配原则？ 当你为多列创建复合索引时，例如： CREATE INDEX idx_d_b_a ON test_optimizer(d, b, a); 索引本质是按照 (d, b, a) 这个顺序生成的 B+Tree。只有从最左边的列开始，连续的条件才能被索引用上。 一旦中间某一列无法利用，就会“断掉”，后面的列通常用不上。 针对的是查询条件的顺序。 例子1： WHERE b=1 AND a=123 没有 d 的条件,最左列 d 没匹配 → 索引基本用不上 例子2： WHERE d=10 AND a=123 有 d，但缺了中间的 b。索引利用度仅止于 (d)。 例子3： WHERE d = 5 AND b > 10 AND a = 123 d = 5 用上了索引，b > 10 也能用上索引，但是由于b是范围索引，因此后面的a = 123 不能再走索引查找，只能在扫描结果里再做过滤。 关键总结！！！ WHERE 子句的顺序无关紧要。 索引的定义顺序才重要（最左列必须命中，才能往后用）。 一旦遇到 范围条件(b>1)，匹配就停止，后续列不能再用于索引过滤，但可以用于覆盖索引。因为b>1的结果集已经是一整片连续范围，需要顺序扫描，不可以用a 来做定位了 还可以用于索引下推过滤！！！ 什么是覆盖索引？ 查询所需要的字段（包括 SELECT 的列和 WHERE 条件中用来过滤的列）都能在某一个索引里找到，这样数据库只需扫描索引，不用回表访问数据行。 非覆盖索引：索引只能帮你定位行，还得回到表里再取其他字段。 覆盖索引：索引里就有全部需要的列，直接返回，不用回表。 举例1： CREATE TABLE user ( id INT PRIMARY KEY, name VARCHAR(50), age INT, city VARCHAR(50) ); CREATE INDEX idx_age_city ON user(age, city); SELECT city FROM user WHERE age = 30; 需要的列是 age（用于条件）和 city（用于结果）。 这两个列都在 idx_age_city 里。所以查询只访问索引即可，覆盖索引。 举例2： SELECT name FROM user WHERE age = 30; 过滤用的 age 在索引里。但查询要返回 name，而 name 不在索引里。必须回表去拿 name，所以不是覆盖索引。 索引下推 把本来需要在回表之后才能判断的条件，尽量提前“下推”到索引扫描阶段，用索引本身的数据来过滤掉更多无效行。 例一： CREATE TABLE user ( id INT PRIMARY KEY, name VARCHAR(50), age INT, city VARCHAR(50), KEY idx_age_city (age, city) ); SELECT id FROM user WHERE age > 30 AND city = 'Beijing'; MySQL 5.5 及以前： 使用 age > 30，在索引里做范围扫描，得到一批候选主键 id。 回表（访问聚簇索引/数据页），再判断 city='Beijing' 是否成立。 换句话说，只有最左前缀能利用索引，后续条件只能在回表之后判断。这会带来大量“无效回表”，I/O 消耗很大。 MySQL 5.6 之后： 用 age > 30 在索引里做范围扫描。 在扫描索引的过程中，直接判断索引里是否满足 city='Beijing'。 因为 (age, city) 索引的叶子节点里就有 city 的值。 只有满足两个条件的行，才去回表拿 id。 例二： -- 索引 CREATE INDEX idx_zip_last_addr ON people(zipcode, lastname, address); -- 查询 SELECT * FROM people WHERE zipcode='95054' AND lastname LIKE '%etrunia%' AND address LIKE '%Main Street%'; **无索引下推：**lastname LIKE '%etrunia%'，注意 % 前缀通配，不能利用 B-Tree 前缀有序性，因此这一步索引无法继续用来缩小范围，索引利用度其实只到第一列 (zipcode)。理论上这会退化成： “找到 zipcode=95054 的所有记录 → 回表 → 再判断 lastname 和 address 条件”。 使用索引下推：MySQL 在扫描 (zipcode, lastname, address) 索引时，已经能拿到 lastname 和 address 的值，因为它们就在复合索引的叶子节点上。 即使 lastname/address 不能帮你缩小索引扫描范围，MySQL 也可以把它们的过滤条件“下推”到索引扫描阶段： 扫描到某条 zipcode=95054 的索引记录时，立刻在索引层检查 lastname LIKE '%etrunia%' AND address LIKE '%Main Street%'。 如果不满足，就直接丢掉，不去回表。 只有满足的记录才回表取出 *。 ICP 的触发条件总结 在 MySQL 里，索引下推的触发条件大致是： 使用了二级索引扫描（ICP 对主键聚簇索引没意义，ICP的核心意义就是减少回表次数）。 查询条件涉及的列都包含在索引中。 某些列因为最左匹配或范围条件导致不能直接用于索引定位，但它们的值在索引叶子节点里存在，可以被“下推”来过滤。 ICP 必须在叶子节点做，因为只有叶子节点才有“完整的、逐条的索引值”。 索引失效的情况 1）对索引列做了函数/计算/表达式 2）模糊匹配以通配符开头 3）不等于和范围条件的特殊情况 WHERE status != 1 WHERE a = 1 AND b > 5 AND c = 3 如果有 (a, b, c) 联合索引，c 就失效了，因为范围查询（b > 5）后索引顺序性被破坏。 4）OR 条件 WHERE id = 1 OR name = 'Tom' 如果 id 和 name 都有索引，可能无法同时利用，MySQL 会选择走一个或直接全表扫描。 5）优化器选择了全表扫描 即使你建了索引，但全表扫描比走索引更快时，MySQL 会直接放弃索引，比如： 表数据很少 过滤条件选中率高（几乎全表都要返回） 6）最左前缀原则 -- 有联合索引 (a, b, c) WHERE b = 2 AND c = 3 -- 索引失效，因为没有用到 a MySQL 大数据排序（ORDER BY）实现机制 MySQL 处理大数据排序时，会根据数据量大小和可用资源采用不同的策略，主要涉及两种核心算法：内存排序和外部排序。 一、排序基本流程 确定排序字段和顺序 读取需要排序的数据 根据数据量选择排序算法 将排序后的数据返回客户端 二、排序模式（sort_mode） 通过 EXPLAIN的 Extra字段可以查看排序模式： 模式 含义 触发条件 Using filesort 需要额外排序 无合适索引时 Using index 索引覆盖排序 索引满足排序顺序 三、大数据排序实现策略 内存排序（单路排序） 适用场景： 排序数据量 < sort_buffer_size 查询字段较少（特别是使用 LIMIT时） 特点： 一次性读取所有需要排序的字段（select *） SELECT * FROM employees ORDER BY salary DESC; -- 示例：需要读取所有列 当使用覆盖索引时： -- 示例：索引覆盖了查询字段 CREATE INDEX idx_salary_name ON employees(salary, name); SELECT name, salary FROM employees ORDER BY salary; 在内存中快速排序（通常使用快速排序） 无需临时文件 配置参数： SET sort_buffer_size = 4M; -- 默认值通常为256K-2M 外部排序（双路排序） 适用场景： 排序数据量 > sort_buffer_size 包含 TEXT/BLOB等大字段 实现过程： 第一次扫描：只读取排序字段和行指针rowid -- 假设employees表有10万行数据 SELECT name FROM employees ORDER BY salary DESC; 强制分阶段读取，即使查询name，第一阶段还是只读取salary和rowid 内存排序：对这些指针进行排序 第二次扫描：根据排序结果回表读取完整数据 多轮归并：如果数据极大，会使用多路归并算法 Mysql8.0为什么移除缓存 工作原理： 当执行一个 SELECT语句时，MySQL 会先计算该语句的“哈希值”作为键（Key）。 然后检查查询缓存中是否存在这个键。 如果存在（即缓存命中），则直接返回缓存的结果集，完全跳过解析、优化和执行等所有后续步骤，效率极高。 如果不存在（即缓存未命中），则正常执行查询流程，并将执行得到的结果集存储到查询缓存中，以便下次使用。 性能原因： 1：严重的锁竞争，查询缓存有一个全局锁（lock_query_cache）。任何试图访问查询缓存的操作（检查缓存或失效缓存）都需要先获得这个全局锁。 2.缓存失效策略过于粗暴，缓存失效是以“表”为粒度的。只要表有任何变动，所有涉及到该表的查询缓存，无论其具体内容是否真的被影响，都会被全部清除。 insert操作背后执行了什么？ 1.客户端发送 SQL 应用端通过 **连接器（Connector）**先通过 JDBC 连接建立连接，此时进行账号验证。 把 INSERT ... 语句发送给 MySQL 服务器。 2.语法解析 & 优化 词法分析是哪种执行语句，SELECT还是INSERT..语法分析SQL 语法是否正确 优化器决定具体执行计划（比如选择哪个索引、怎样写数据）。 3.执行器 打开目标表（Table handler）。 检查当前用户是否对目标表有相应权限（如 INSERT 权限）。 检查表的读写锁、元数据锁。 准备调用存储引擎写数据。 4.写入存储引擎 写入 Buffer Pool（内存缓存）：新行先写入缓冲区，而不是直接落盘。 更新索引结构：修改 B+ 树，把新记录插入主键索引和必要的二级索引。 5.日志记录 为了保证 崩溃恢复和事务一致性，InnoDB 会记录两类日志： Redo Log（重做日志） 记录“做了什么修改”。 先写入 redo log buffer，再刷到磁盘（prepare → commit 两阶段提交）。 事务提交后，redo log 不会马上清空，而是被标记为“已提交”，之后可以被覆盖（循环写，固定大小的 log file）。 Undo Log（回滚日志） 记录“如何撤销修改”，保证事务回滚时能恢复。 存在系统表空间或 Undo 表空间里。 提交后，Undo Log 并不会立刻删除，因为可能有其他正在执行的快照事务还需要它。 6.返回结果 当 redo log 写入成功，并且 binlog 写入成功（双写保证），事务提交成功。 Mysql二阶段提交 binlog的作用: 1）数据恢复。binlog 会记录所有修改数据的 SQL 语句或行事件（如 INSERT、UPDATE、DELETE 等）。 当数据库崩溃或需要恢复到某个时间点时，可以先恢复最近一次完整备份，再通过回放 binlog 中的事件，将数据恢复到指定时刻。 2）主从复制。主库将写入操作记录在 binlog，从库通过 I/O 线程 读取 binlog，再由 SQL 线程 重放这些事件，实现主从数据同步。 二阶段提交的两个阶段： 准备阶段（Prepare Phase）：在事务提交时，MySQL 的 InnoDB 引擎会先写入 redo log，并将其状态标记为 prepare，表示事务已经准备提交但还未真正完成。此时的 redo log 是预提交状态，还未标记为完成提交。 提交阶段（Commit Phase）：当 redo log 的状态变为 prepare 后，MySQL Server 会写入 binlog（记录用户的 DML 操作）。binlog 写入成功后，MySQL 会通知 InnoDB，将 redo log 状态改为 commit，完整整个事务的提交过程。 为什么需要二阶段提交 如果没有二阶段提交，关于这两个日志，要么就是先写完 redo log，再写 binlog，或者先写 binlog 再写 redo log。我们来分析一下会产生什么后果。 1) 先写 redo log，再写 binlog redo log 已经落盘 → 主库事务恢复时能看到数据。但 binlog 还没写入 → 从库永远不会收到这个事务。 结果：主库比从库多一条数据（主库领先，从库落后）。 2) 先写 binlog，再写 redo log binlog 已经写入 → 从库收到并重放事务。但 redo log 没写入 → 主库宕机恢复后，事务回滚，数据丢失。 结果：从库比主库多一条数据（从库领先，主库落后）。 有了二阶段提交，MySQL 异常宕机恢复后如何保证数据一致呢？ 情况 1：redo log = prepare，binlog 未写入 崩溃恢复时，因为 redo log 没有 commit，binlog 也没记录 → 事务直接回滚，数据一致。 情况 2：redo log = prepare，binlog 已写入但 redo log 未 commit 崩溃恢复时，对比 redo log 与 binlog：一致就提交，不一致就回滚。 所以才需要 **两阶段提交（2PC）**让 redo log 和 binlog 的提交状态严格绑定，要么一起成功，要么一起失败，保证主从一致。 MVCC 多版本并发控制。 ungo log 1）当你执行 insert (1, XX) 时，除了在表里存储 ID 和 name，还会自动记录两个隐藏字段： trx_id：事务 ID。 roll_pointer：指向对应的 undo log。 这个 undo log 是一条类型为 TRX_UNDO_INSERT_REC 的日志，表示它是由 insert 操作产生的。undo log 会保存主键等必要信息，如果事务回滚，InnoDB 就能根据这些信息删除对应记录，从而恢复到执行前的状态。 2）当事务 1 提交后，另一个事务（事务 ID=5）执行 update id=1 时，新的记录会带有 trx_id=5 和新的 roll_pointer，同时生成一条新的 undo log。 这条 undo log 类型是 TRX_UNDO_UPD_EXIST_REC，它会保存： 旧的 trx_id 旧的 roll_pointer 被修改前的字段值（比如 name 原本是 XX） 旧值 → 用于回滚。 old_trx_id + old_roll_pointer → 用于构建版本链，支持 MVCC 下的一致性读。 与 insert 的 undo log 不同： insert 生成的 undo log 在事务提交后就会被清理（因为不会有人需要看到插入之前不存在的版本）。 update 生成的 undo log 则会保留一段时间，用于回滚和多版本并发控制（MVCC，其他事务可能还需要访问旧值）。 3）又有id=11的事务执行 update yes where id=1 ReadView readView 用来判断 某个版本的数据 对当前事务是否可见。 它依赖四个核心概念： creator_trx_id：当前事务 ID。 m_ids：生成 readView 时，系统里所有未提交事务的 ID 集合。 min_trx_id：m_ids 中最小的事务 ID。 max_trx_id：生成 readView 时，系统将分配给下一个事务的 ID（比现有所有事务 ID 都大）。 注意，如果一个事务是查询，它的creator_trx_id 为0 可见性判断流程 判断逻辑是：从最新版本开始，沿着版本链往回找，遇到第一个符合条件的版本就返回。 具体条件： trx_id = creator_trx_id → 说明是当前事务自己修改的，可见。 trx_id < min_trx_id → 修改这个数据的事务在 readView 生成前就提交了，所以可见。 min_trx_id ≤ trx_id < max_trx_id： 如果 trx_id ∈ m_ids（事务未提交），不可见。 如果 trx_id ∉ m_ids（事务已提交），可见。 trx_id ≥ max_trx_id → 说明修改事务在 readView 生成时还没开始，所以不可见。 读已提交下的MVCC 场景说明： 事务 1 已经提交。 事务 5 执行了 update，但还没提交。 此时新开一个事务（事务 ID=6），去查询 select name where id=1。 readView 的关键参数： creator_trx_id = 6 → 当前查询事务的 ID。 m_ids = {5,6} → 活跃事务 ID 集合（未提交的事务）。 min_trx_id = 5 → m_ids 中最小值。 max_trx_id = 7 → 下一个待分配事务 ID。 查询过程： 查询到 id=1 的最新版本，其 trx_id=5。 不小于 min_trx_id，说明不是历史提交版本。 又在 m_ids 集合里，说明事务 5 还没提交。 所以这个版本 不可见。 根据 roll_pointer 追溯到上一个版本（undo log）。 该版本 trx_id=1，小于 min_trx_id=5，说明生成 readView 时它已经提交。 所以这个版本 可见。 然后事务 5 提交 此时再次查询 select name where id = 1，这时候又会生成新的 readView。 此时 creator_trx_id 为 0，因为还是没有修改操作。 此时 m_ids 为 [6]，因为事务 5 提交了。 此时 min_trx_id 为 6。 此时 max_trx_id 为 7，此时没有新的事务申请。 同样还是查询的是 ID 为 1 的记录，所以还是先找到 ID 为 1 的这条记录，此时的版本如下 此时最新版本的记录上 trx_id 为 5，比 min_trx_id 小，说明事务已经提交了，是可以访问的，因此返回结果 name 为 NO。 可重复读下的MVCC 可重复读和读已提交的 MVCC 判断版本的过程是一样的，唯一的差别在生成 readView 上。 上面的读已提交每次查询都会重新生成一个新的 readView，而可重复读在第一次生成 readView 之后的所有查询都共用同一个 readView。 套用上面的情境，差别就在第二次执行 select name where id = 1，不会生成新的 readView，而是用之前的 readView，所以第二次查询时： m_ids 还是为 [5,6]，虽说事务 5 此时已经提交了，但是这个 readView 是在事务 5 提交之前生成的，所以当前还是认为这两个事务都未提交，为活跃的。 此时 min_trx_id 为 5。 Mysql如何解决幻读 在标准 SQL 隔离级别中，可重复读（REPEATABLE READ）理论上仍可能出现幻读，但在 MySQL 的 InnoDB 引擎中，它“额外”解决了幻读问题。 在 InnoDB 的实现里，可重复读级别下结合了两种机制： MVCC（多版本并发控制） + Next-Key Lock（间隙锁） 普通 SELECT（非锁定读） 使用 MVCC（快照读）。 快照读是通过 ReadView 实现的：事务第一次查询时生成一个一致性视图，之后所有查询都基于这个 ReadView。 因此，即使别的事务插入了新行，当前事务的 ReadView 里看不到它们。 锁定读 如 SELECT ... FOR UPDATE 或 UPDATE、DELETE，也即当前读，能用快照，因为要修改数据。 此时 InnoDB 使用 Next-Key Lock（行锁 + 间隙锁） 来防幻读。 它不仅锁住已存在的记录，还锁住记录之间的“间隙”。 SELECT * FROM user WHERE age BETWEEN 20 AND 30 FOR UPDATE; InnoDB 会加锁： 记录本身（20、30）→ Record Lock 区间 (10,20)、(20,30)、(30,∞) → Gap Lock 这样其他事务： 不能在这些区间内插入新记录； 从而避免“幻影行”出现在当前事务的查询结果里。 Explain查询优化 EXPLAIN 会展示执行计划的关键信息，常见字段有： id：查询中执行步骤的顺序和嵌套关系，id 越大优先级越高。 select_type：查询的类型，比如 SIMPLE、PRIMARY、SUBQUERY、DERIVED 等。 table：当前访问的表。 type：最关键，表示表的访问方式，也就是执行效率。 possible_keys / key：优化器认为可能使用的索引、实际使用的索引。 rows：预计扫描的行数，越少越好。 filtered：返回记录的百分比。 Extra：额外信息，比如 Using index（覆盖索引）、Using where、Using filesort（额外排序）、Using temporary（临时表）。 type 的性能排序（从好到差，大致顺序） system：表只有一行（特殊情况）。 const：通过主键/唯一索引等一次命中。（单表直接查唯一值。） CREATE TABLE user ( id INT PRIMARY KEY, email VARCHAR(100) UNIQUE, name VARCHAR(50) ); EXPLAIN SELECT * FROM user WHERE id = 1; -- type = const eq_ref：唯一索引等值连接。（多表 join 时） ref：普通索引等值匹配,返回多行。 CREATE TABLE product ( id INT PRIMARY KEY, category_id INT, KEY idx_category(category_id) ); EXPLAIN SELECT * FROM product WHERE category_id = 10; -- type = ref range：索引范围扫描（BETWEEN、> < 等）。 EXPLAIN SELECT * FROM product WHERE category_id BETWEEN 1 AND 5; -- type = range index：全索引扫描（比全表稍好）。 EXPLAIN SELECT category_id FROM product; -- 如果只扫索引列，不回表，type = index ALL：全表扫描（最差）。 mysql查询优化 如果查询性能出现问题，我会分层次地进行优化： 首先是 单库层面 的优化，我会通过慢查询日志配合 EXPLAIN 分析执行计划，定位慢 SQL 的具体瓶颈。 针对问题语句，会从 索引设计 入手，比如调整索引结构（如建立组合索引、覆盖索引），避免索引失效，或者通过 SQL 改写减少全表扫描和不必要的排序、临时表。 其次，在业务层面，我会引入 缓存机制（如 Redis），对热点数据进行缓存，降低数据库的读压力，并合理设置缓存策略防止缓存穿透和雪崩。 如果单库性能已达到上限，我会考虑 架构层面的优化，例如： 通过 读写分离 提升并发读性能；(主从集群) 使用 分库分表 或 冷热数据拆分，解决数据量和并发量增长带来的性能瓶颈； 必要时配合异步化、消息队列等手段削峰填谷，进一步提升系统整体吞吐能力。 Mysql删除数据的三种方法 命令 示例 结果 结构是否还在 是否可回滚 DELETE DELETE FROM demo WHERE name='A'; 只删除 name='A' 的那行，剩下 B、C ✅ 保留 ✅ 可回滚（事务内） TRUNCATE TRUNCATE TABLE demo; 表中数据全部清空，B、C 也没了 ✅ 保留 ❌ 不可回滚 DROP DROP TABLE demo; 整个 demo 表被删掉，表结构和数据都没了 ❌ 删除 ❌ 不可回滚 DELETE：删行，可加条件，事务可回滚。 TRUNCATE：清空表，比 DELETE 快，不能回滚。 DROP：连表带结构一起删。 MySQL InnoDB 常见锁对比 锁类型 粒度 作用 是否互斥 常见触发场景 示例 表级锁（Table Lock） 整个表 锁住整张表，其他事务无法并发写 强互斥 LOCK TABLES，DDL 操作（ALTER TABLE） LOCK TABLE t1 WRITE; 行锁（Record Lock） 单条记录 锁住已有的一行，保证行级并发一致性 与同一行冲突 基于索引的 SELECT ... FOR UPDATE、UPDATE SELECT * FROM user WHERE id=1 FOR UPDATE; 间隙锁（Gap Lock） 记录之间的区间 锁住一个范围内的“空隙”，防止幻读 不允许别的事务在该间隙插入 RR 隔离级别下范围查询加锁 SELECT * FROM user WHERE id BETWEEN 10 AND 20 FOR UPDATE; 临键锁（Next-Key Lock） 记录本身 + 前面的间隙 行锁 + 间隙锁组合，防止幻读 会阻止在该范围插入/修改 RR 隔离级别下的范围条件 SELECT * FROM user WHERE id=10 FOR UPDATE;（锁住 id=10 以及 (前一条,10) 间隙） 插入意向锁（Insert Intention Lock） 间隙（特殊） 插入前声明要往某个间隙插入，允许并发插入 不互斥（不同插入位置可并发） INSERT 语句，自动加 INSERT INTO user(id) VALUES(15);（加在 (10,20) 间隙上） 意向锁（Intention Lock） 表级 标记事务准备在某些行加共享/排他锁 与表锁冲突，不与行锁冲突 InnoDB 内部自动加 SELECT * FROM user WHERE id=1 FOR UPDATE; 会自动加 意向排他锁 IX 插入意向锁 之间一般不冲突，与间隙锁/临键锁会冲突。 什么是乐观锁？ 乐观锁是一种“并发控制策略”。 核心思想： “我认为别人不会改数据，所以我不加锁，更新时再检查一下数据有没有被改过。” 这个思想可以应用在不同层面上： 数据库层（通过 SQL + version 字段） Java 应用层（通过 CAS、版本号、时间戳等） 数据库层： 在表里加一个字段（version 或 update_time），在 UPDATE 语句时加上版本检查条件 UPDATE user SET balance = balance - 100, version = version + 1 WHERE id = 1 AND version = 5; Java 层 AtomicInteger counter = new AtomicInteger(0); counter.compareAndSet(expectedValue, newValue); compareAndSet() 方法就是典型的乐观锁： 它假设没有其他线程改过值； 如果值没被改（expectedValue 一致），就更新成功； 否则失败（需要重试）。 数据库连接池的底层原理 数据库连接是重量级资源，创建一次 TCP 连接 + 认证 + 分配资源的开销很大，如果每个请求都新建/销毁连接，性能会非常差。因此我们用连接池事先创建好一定数量的连接，后续请求直接复用，减少连接建立开销。 1）预创建与池管理 启动时按配置创建一定数量的连接放到池子里（最小空闲连接数） 用一个容器保存这些连接（常见是阻塞队列）。 2）连接借出与归还 当业务线程需要访问数据库时： 若池中有空闲连接 → 直接取出使用； 若没有空闲连接但尚未达到最大连接数 → 动态创建新连接； 若已达上限 → 等待连接归还或超时报错。 3）并发控制 连接池内部通过 锁（ReentrantLock） 或 CAS + 队列机制 实现高并发下的借还操作安全。 某些连接池（如 HikariCP）采用 无锁队列 + 原子操作 来提升并发性能。 4）健康检查与回收 连接池会周期性地检测空闲连接是否可用： 若连接失效（网络断开、超时） → 自动销毁并重建； 若连接长期空闲 → 主动回收，避免“僵尸连接”； 同时维持池中连接数在配置的最小/最大区间内，防止资源浪费或过载。 5）超时和泄露检测 设置获取连接超时，防止线程长时间阻塞； 某些连接池（如 Druid、HikariCP）支持 连接泄露检测，当连接长期未归还时，会自动打印警告日志，帮助排查潜在问题。 多表查询 students（学生表） student_id name class_id 1 张三 101 2 李四 102 3 王五 101 classes（班级表） class_id class_name teacher_id 101 一年级一班 1001 102 一年级二班 1002 teachers（教师表） teacher_id teacher_name 1001 王老师 1002 李老师 问：想查询每个学生的姓名、班级名称，以及对应的教师姓名。 SELECT s.name AS student_name, c.class_name, t.teacher_name FROM students AS s JOIN classes AS c ON s.class_id = c.class_id JOIN teachers AS t ON c.teacher_id = t.teacher_id; 杂项 UTF-8 名称 类型 作用 类比 Unicode 字符集（标准） 定义“每个字符的唯一编号” “所有语言的字典” UTF-8 / UTF-16 / UTF-32 编码方式（实现） 把 Unicode 的编号存进文件或内存的方式 “字典的存储格式” ASCII 早期的字符集（也是编码） 只有英文和控制符号（共 128 个） “字典最早的简化版” 1）UTF-8是编码规则，本身与编程语言无关。 也就是说：“你” 在 UTF-8 下一定是 E4 BD A0（3 个字节）；不会因为语言不同而改变。 2）UTF-8 对于前 128 个字符（U+0000～U+007F）与 ASCII 完全相同。 UTF-8 是“可变长度”的编码 字节数 二进制前缀 说明 示例 1 字节 0xxxxxxx 英文、数字（ASCII） A → 0x41 2 字节 110xxxxx 10xxxxxx 拉丁文扩展 é 3 字节 1110xxxx 10xxxxxx 10xxxxxx 中文、日文、韩文等 你 4 字节 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx emoji、冷僻字 😊 一个英文字符串：Hello 共5个字节。 中文：你好 共6个字节。 UTF-8和JAVA中的Char有关系吗 UTF-8 是“编码规则”，规定一个字符对应的字节序列（1～4 字节） char 是“Java 的数据类型”，决定的是“Java 内存里一个字符占多大空间”。在java中，char 类型在 内存中永远占用 2 个字节（16 位）；它存储的是 Unicode 编码值的 UTF-16 单元。 中文在 UTF-8 编码下是 3 个字节，但那是外部存储格式； Java 内部使用 UTF-16，每个 char 是 2 字节的 Unicode 码单元， 中文（U+4E00–U+9FFF）都在基本平面范围内，用一个 char 就能表示。 例1️⃣ 英文字母 字符：A 层级 表示 占用 UTF-8 0x41 1 字节 Java char 0x0041 2 字节 例2️⃣ 中文字符 字符：你 （Unicode U+4F60） 层级 表示 占用 UTF-8 E4 BD A0 3 字节 Java char 0x4F60 2 字节 MyBatis JDBC 与 MyBatis 的关系 层次 内容 举例 最底层 JDBC 原生 API Connection, Statement, ResultSet 中间层封装 Spring JDBC Template 自动管理连接、关闭资源 ORM 框架 MyBatis / Hibernate 提供 SQL 映射、对象封装、事务管理 简单说： MyBatis 的底层就是在帮你自动调用 JDBC 接口（包括 Statement、Connection、ResultSet）。 例如： @Select("SELECT * FROM user WHERE id = #{id}") User selectById(int id); MyBatis 实际做的就是： 从连接池拿到一个 Connection 创建一个 PreparedStatement（预编译语句对象） 执行 SQL 并拿到 ResultSet 映射结果到 User 对象 关闭资源 PreparedStatement（预编译语句对象）： 是 JDBC 提供的一种 预先编译好 SQL 模板、可重复执行 的对象。 防止 SQL 注入 ResultSet（结果集对象） 数据库返回的数据表（查询结果）的 Java 视图。 MyBatis的缓存机制 怎样算在同一个sqlsession: 1.同一个对象实例 SqlSession session = sqlSessionFactory.openSession(); UserMapper mapper1 = session.getMapper(UserMapper.class); UserMapper mapper2 = session.getMapper(UserMapper.class); // mapper1 和 mapper2 使用的是同一个 SqlSession 2.在同一个事务范围内 @Transactional public void processOrders(List<Order> orders) { for (Order order : orders) { User user = userMapper.findById(order.getUserId()); // 处理订单逻辑 } } 一级缓存（本地缓存） 作用范围：SqlSession 级别（默认开启） 特点： 同一个 SqlSession 中执行相同的 SQL 查询时，会直接从缓存中获取结果 执行 INSERT/UPDATE/DELETE 操作或调用 commit()、close()、clearCache()时会清空一级缓存 生命周期：与 SqlSession 相同 二级缓存（全局缓存） 存储位置：JVM 堆内存（SqlSession 内部的一个 HashMap） 作用范围：Mapper 命名空间级别（需要手动配置开启） 配置方式： <mapper namespace="..."> <cache eviction="LRU" flushInterval="60000" size="512" readOnly="true"/> SELECT * FROM group_buy WHERE team_id = #{teamId} </mapper> 特点： 多个 SqlSession 可以共享缓存 缓存数据存储在内存中，可以配置存储到硬盘或其他存储介质 执行 INSERT/UPDATE/DELETE 操作会清空对应命名空间的二级缓存 缓存工作流程 查询顺序：二级缓存 → 一级缓存 → 数据库 更新顺序：数据库 → 清空相关缓存 二级缓存的数据不一致情况： SqlSession1 读取了数据A，并将其缓存 SqlSession2 将数据更新为B（这会清除二级缓存） 但当SqlSession1再次读取时，如果配置不当，可能仍然从自己的缓存中获取到旧的A数据 Redis Redis 是单线程的还是多线程的 Redis 6.0 之前 单线程模型：核心的网络 I/O 和命令执行都在一个主线程里完成。 while (true) { # 1. 监听多个 socket 事件 active_fds = epoll_wait(); # 2. 依次处理每个就绪的 socket for fd in active_fds: readRequest(fd); // 读网络数据 processCommand(fd); // 解析 + 执行命令 sendReply(fd); // 写回响应 } 为什么单线程也快？ 纯内存操作； 使用高效的 I/O 多路复用（epoll/kqueue）； 数据结构高度优化； 避免了线程切换和锁竞争。 多线程存在的位置：并不是彻底“单线程”，比如： AOF 日志刷盘、RDB 持久化、复制等后台任务本来就是单独线程/子进程。 但核心逻辑——请求解析、命令执行、结果返回——只有一个主线程。 Redis 6.0 之后 引入了 I/O 多线程，但本质上还是 “单线程为主 + 多线程辅助”： 多线程做什么？ 主要用于 网络 I/O 的读写，即把请求数据从网络读入内存、把结果写回客户端。 读 socket：从客户端读取数据到内存缓冲区。 写 socket：把执行结果写回客户端。 单线程仍然做什么？ 命令解析、数据结构操作、业务逻辑执行依然在主线程完成。 好处：解决高并发场景下，网络读写成为瓶颈的问题。 Redis 的过期删除策略 基本策略 ① 定时删除（Timer） 每个 key 设置过期时间时，都会创建一个定时器，到时间就立刻删除。 优点：内存能及时释放，不会有“脏数据”。 缺点：要维护大量定时器，CPU 消耗大，不适合 Redis 这种高性能场景。 Redis 实际上并没有采用纯定时删除。 ② 惰性删除（Lazy） 客户端访问 key 时，Redis 会先检查它是否过期。 如果过期了，就先删除再返回“不存在”；如果没过期就正常返回。 优点：对 CPU 友好，只有访问时才检查。 缺点：如果有些 key 过期后从未被访问，就会一直占用内存。 ③ 定期删除（Periodic） Redis 会定期（默认每 100ms）随机抽样部分设置了过期时间的 key，检查是否过期，如果过期就清理掉。 优点：在性能和内存之间折中，避免内存被过期 key 堆满。 缺点：清理不一定及时，依赖随机抽样，可能有遗漏。 实际采用的策略 Redis 实际上是 惰性删除 + 定期删除 两种策略结合： 惰性删除保证了“访问时不会读到脏数据”。 定期删除保证了“即便没人访问，也会逐渐释放内存”。 内存淘汰机制（兜底措施） 如果过期 key 清理不及时，导致内存占满，Redis 会触发 内存淘汰策略(7种）： noeviction：默认，直接报错，不再写入新数据。 allkeys-lru：所有 key 中淘汰最久未使用的。 volatile-lru：只在设置了过期时间的 key 中淘汰最久未使用的。 allkeys-random：所有 key 中随机淘汰。 volatile-random：只在设置了过期时间的 key 中随机淘汰。 volatile-ttl：淘汰即将过期的 key（TTL 最短）。 Redis主从集群 Redis 主从架构通常用于 数据备份 和 读写分离 场景，非常适合 读多写少 的业务模型。 主节点（Master）：负责写操作和数据同步。 从节点（Slave）：负责读操作，数据由主节点异步复制过来。 1）如果判断redis从节点是第一次来建立连接？ Redis 主从同步依靠一个 复制 ID（replid） 来区分是否为第一次连接： 每个节点在启动时都会生成一个唯一的 replid，默认认为自己是主节点。 当执行 SLAVEOF 或 REPLICAOF 时，从节点会向主节点发起复制请求。 这时主节点会生成一个新的 replid，并把它同步给从节点。 之后： 如果从节点再次发起连接请求，携带的 replid 与主节点一致 → 表示是断线重连（增量复制）； 如果 replid 不一致 → 表示是第一次连接（全量复制）。 2）第一次来建立连接，怎么全量复制？ 主节点执行 BGSAVE，生成一个 RDB 快照文件。 主节点将 RDB 文件发送给从节点，从节点清空现有数据，加载 RDB。 在加载完成后，主节点会把在此期间新增的命令日志（增量部分）发送给从节点， 确保数据最终同步。 3）Slave如果宕机，少执行一些命令咋办？ 如果从节点中途宕机或网络短暂中断，不一定需要重新全量同步。 Redis 通过 复制偏移量 和 复制积压缓冲区 实现 增量复制： 复制偏移量（offset）： 主从双方各自维护一个偏移量，表示当前同步到的位置。 复制积压缓冲区（backlog）： 主节点在内存中保留一段最近写命令的缓存（默认大小 1MB）。 同步过程： 从节点重连时会携带上次同步的 replid 和 offset； 主节点检查 backlog 中是否包含这段缺失数据； 若包含 → 增量复制：只发送缺失部分； 若不包含（数据太久被覆盖） → 回退到全量复制。 RDB（快照） 方式：某个时间点，把 Redis 整个内存的数据写成一个二进制文件。 记录的是某一时刻的全量状态（snapshot）。 快照之间的所有写入操作不会被记录。 AOF（写日志） 方式：把 Redis 执行的每一条写命令，按顺序追加到日志文件（appendonly.aof）。 特点： 记录的是操作过程，几乎每条写入都能还原。 根据 appendfsync 配置： always → 每次写命令都落盘，理论上不会丢任何写。 everysec（默认）→ 最多丢 1 秒内的写。 no → 可能丢更多，取决于 OS 缓冲。 backlog（复制积压缓冲区） 本质：Redis 主从复制用的一个内存环形缓冲区。 位置：只存在于 Master 节点的内存里。 内容：存放最近一段时间 Master 执行的 写命令流，不是持久化文件。 Redis主从+哨兵 哨兵本质是一个独立运行的进程，可以部署多个实例，形成一个哨兵集群。 它的作用有三个： 监控：定期向主从节点发心跳，判断是否存活。 通知：发现节点宕机，会通知客户端或者其他服务。 自动故障转移：主节点宕机时，选举出一个新的主节点，并让其他从节点改为复制它。 工作流程 正常运行 主节点负责写，从节点负责读，哨兵不断检测主从的健康状态。 主节点故障 哨兵集群通过投票机制确认主节点不可用（超过一定数量的哨兵认为它下线）。 选举出一个从节点作为新的主节点。 故障转移 通知其他从节点去复制新的主节点。 通知客户端更新主节点地址，继续访问。 主观下线：某个 哨兵 觉得主节点不可达（可能是网络原因误判）。 客观下线：当 多数哨兵 都认为主节点不可达时，就进入“客观下线”状态，这个时候，才会真正发起故障转移流程。 Redis分片集群 分片集群可以看作多个主从集群+分片路由机制 哈希槽 Redis Cluster 并不是随便把 key 平均分到节点上，而是用 16384 个固定的哈希槽 (slot) 来管理。 每个 key 先经过哈希计算，然后被分配到某个槽，槽再映射到对应的节点。 路由机制： 计算槽号 算法：CRC16(key) % 16384 即用 CRC16 算法对 key 做哈希，再取模 16384。 得到一个槽号（范围 0 ~ 16383）。 槽到节点的映射 集群启动时，先看有几个主节点，会把 16384 个槽分配给不同的主节点。 比如： 节点 A 管理槽 0 ~ 5460 节点 B 管理槽 5461 ~ 10922 节点 C 管理槽 10923 ~ 16383 定位节点 客户端要访问一个 key 时，先算出槽号。 然后根据槽分布表，找到负责这个槽的主节点，直接发请求过去。 缓存一致性 什么是Cache Aside Pattern？ 更新数据库的同时更新缓存。 先删缓存再删数据库 线程A（右边）删除数据，先删Redis；此时换到线程B来查询，发现Redis为空，它去查mysql，查到了旧数据（线程A还没来得及删mysql），然后回写到redis中；此时A缓过神来，把mysql旧数据删除了。这样redis中有数据，但mysql中无，造成了数据的不一致。这种情况出现的概率比较大，因为线程B查询操作一般比线程A更新操作速度快！！！ 先删数据库，再删缓存 线程A（左边）先查询redis，未命名，查mysql，查到了数据；此时换到线程B来删除数据，它先删mysql，再删缓存；再轮到A执行，它回写Redis。此时又出现了数据的不一致：redis中有脏数据，mysql中该数据被删了。但这种概率比较低，因此推荐该方法。 默认情况下这种出现概率极低，尽管出现也可以依靠TTL过期来达到最终一致性。如果实在想避免这种短暂的不一致，可以使用延迟双删策略，就是过一段时间后，再主动把缓存删除。 压缩列表 zlbytes(4字节) 作用：记录整个压缩列表占用的总字节数。 目的：用于内存重分配时，无需遍历整个列表即可知道需要分配多少内存。 zltail(4字节) 作用：记录从压缩列表起始地址到最后一个节点（entry）的起始地址的偏移量。 目的：允许程序直接定位到列表尾部，从而在尾部进行 push或 pop操作时非常高效，时间复杂度为 O(1)。 zllen(2字节) 作用：记录压缩列表中包含的节点（entry）数量。 注意：由于它只有 2 字节（16 位），最大能表示 2^16 - 1 = 65535。当节点数超过这个值时，这个字段的值会置为 65535，此时如果需要获取真实节点数，必须遍历整个列表。 entryX(可变长度) 作用：存储实际数据的节点。每个节点可以存储一个字节数组或一个整数值。 特点：为了节省内存，每个节点的长度是可变的，其结构设计得非常精巧（下文详述）。 zlend(1字节) 作用：一个特殊的标记，用于标识压缩列表的结束。 值：恒定为 0xFF（十进制 255）。 每个 entry由三部分组成，其长度都是可变的： previous_entry_length encoding content 1字节或5字节 1字节、2字节或5字节 实际存储的数据 previous_entry_length（前一个节点的长度） 目的：有了这个字段，程序可以从列表的任意位置（特别是尾部）向前遍历。当前节点的地址减去这个长度，就能找到前一个节点的起始地址。 encoding（编码） 作用：这个字段非常关键，它同时编码了两方面信息： content 中存储的数据类型（是整数还是字节数组？） content 的数据长度（如果是字节数组）或整数值的具体类型（如果是整数）。 content（内容） 作用：实际存储节点的值，可以是一个字节数组（如字符串 "hello"）或一个整数值（如整数 10086）。 压缩列表是 “时间换空间” 的经典设计。它通过精巧的结构设计，用 CPU 计算时间来换取极致的内存节省。 在 Redis 中，它被用于存储少量、小体积的数据元素。例如： 当 Hash 类型的所有键值对都较小时，底层使用 ziplist。 当 List 的元素都是小字符串时，底层使用 ziplist。 当 Sorted Set 的元素数量和值都较小时，底层使用 ziplist。 跳表 跳表是一种 基于链表的多层有序数据结构，可以看作是“加了多层索引的有序链表”。 普通有序链表查找某个元素需要从头开始，时间复杂度是 O(n)。 跳表在链表基础上增加多级“跳跃索引”，让查找平均复杂度降低到 O(log n)，与平衡树接近。 查找 1.从最高层索引开始向右移动，直到下一个节点大于目标值。 2.向下一层继续搜索，重复以上过程。 3.最终在底层链表找到目标元素或确认不存在。 范围查询[x, y]：先定位起点x，再在底层链表顺序遍历，直至超过y。 注意：最底层完整链表被称为 第 0 层（Level 0），是必定存在的底层，包含所有元素。 插入 1.查找新元素应插入的位置。 2.在底层链表插入新节点。 3.通过随机算法（比如抛硬币）决定该节点是否“晋升”为上层索引。 晋升概率常设为 1/2，这样平均每层节点数减半，层数大约是 log n。 Zset底层数据结构 Redis 的 有序集合（ZSet） 就是通过 跳表（SkipList）+ 哈希表（HashMap） 组合实现的 哈希表：擅长根据 key 快速查找（O(1)）。 跳表：擅长根据 score 做排序、区间查找（O(log n) + O(k)）。 ZSet 的需求既要支持： 通过成员名快速查分数（ZSCORE）， 根据分数范围排序输出（ZRANGEBYSCORE、ZREVRANGE 等）。 分布式锁 用什么命令实现锁？ SET key value NX EX seconds NX：只有 key 不存在时才设置，避免被覆盖。 EX seconds：设置过期时间，防止死锁。 补充： PX 单位是毫秒 锁过期了怎么办？ 看门狗机制: 获取锁时设置默认过期时间（比如 30s）。 后台启动一个定时任务（“看门狗”），每隔一段时间（如 10s）检查持锁线程是否还活着。 如果还在执行，就自动延长锁的过期时间，避免任务没跑完锁就过期。 如果线程挂了，看门狗也会消失，锁自然到期释放。 RedLock算法 1.Redis 使用 主从架构；线程 A 在 主节点（Master） 上执行： SET lock_key mylock NX PX 10000 → 成功获得锁； 2.Redis 异步复制机制下，主节点将这个写操作 异步发送 给从节点； 3.主节点宕机 💥，还没来得及把这条写命令同步给从节点； 4.Sentinel 或其他机制把从节点提升为新的 Master； 5.线程 B 在新的 Master 上执行： GET lock_key → 发现这个锁不存在（因为没有复制过来）。 根本原因：Redis 主从复制是 异步 的，如果主节点在复制完成前宕机，从节点就会丢失那部分数据。 解决方案： RedLock 红锁算法 不依赖主从复制，而是在**多个独立的 Redis 实例（推荐 5 个）**上分别加锁。只要在半数以上节点成功加锁（≥3），就认为加锁成功。 这样即使部分实例宕机，也能保证锁的可靠性。 RabbitMq Rabbit mq怎么保证消息消费的顺序性？ 想要最简单的严格顺序：单队列 + 单线程 + prefetch=1 就行，但吞吐拉胯、易被卡。 想要既有顺序又要并发：把 Key 做 hash→shard，每个 shard 单线程消费，不同 shard 并行，既保同 Key 有序，又能扩展。 消息顺序错乱的原因 1）多并发消费 多个消费者同时从一个队列拉消息执行（配置同一个queue），顺序就不可控了 队列中的消息 消费者1 执行耗时 消费者2 执行耗时 最终结果 msg1 1000ms ✔️ msg2 100ms ✔️ 虽然 msg1 在前面，但由于 msg2 执行更快，先被确认，所以“处理顺序”乱了。 2）重试回退（requeue） 失败的消息重新放回队列头或尾，扰乱原有顺序 3）批量预取（prefetch > 1） 消费者一次拿多个消息，哪条先被处理完就先 ack，顺序不再可靠。因为既然消费者批量拉取了，大概率是使用多线程来处理，那么多线程的话就不能保证顺序了！！！ 分片 + 单线程消费 在高并发业务中，“要求顺序处理”的通常不是全部消息，而是同一个业务 key 的消息。按 key 有序的原理：“分片 + 单线程消费” 要求： 同一个 key（比如 userId = 123）内的消息，必须按照发送顺序处理 不同 key（userId = 123 vs 456）之间可以并行处理，不要求顺序 举例：假设你要处理 userId 相关消息，希望同一个用户按顺序消费 发消息时： int shard = userId.hashCode() % 4; 发送到：queue.user.shard.0~3 之一 消费时： queue.user.shard.0 → consumer0（串行处理） queue.user.shard.1 → consumer1（串行处理） ... 效果： userId=101 落到 shard 1，所有与之相关的消息在 consumer1 串行处理 userId=202 落到 shard 2，由 consumer2 处理，互不影响 但是以上简单 %N 改 N 会导致大规模迁移，可以使用一致性哈希来解决！ 一致性哈希 1）环形空间，假设哈希函数输出的范围是一个 0~2³² 的整数，把它想象成一个“环”，把每个分片（或节点）映射到环上的一个点。 2）每个消息 key 也通过哈希函数算出位置，从该 key 的位置顺时针找到的第一个节点，就是它的归属节点。 3）如果增加一个新节点，只会接管它在环上“前一个节点 → 自己”区间的 key，其它区间的 key 不受影响。 核心步骤 分片路由 生产者按 key → shard 路由到不同队列：queue.user.shard.N。 避免手写 % 带来的重分片风暴，优先考虑： 一致性哈希交换器（x-consistent-hash 插件）：只需设置队列权重=分片数，按 routing key 的 hash 自动路由。 每分片单线程消费 每个队列仅 1 个活跃消费者。 设 prefetch=1，同一队列串行处理，避免并发导致“完成顺序≠入队顺序”。 重试/失败处理（关键） 不要直接 requeue 回原队列（会打乱顺序，甚至阻塞后续）。 用 DLX + 延迟（TTL/延迟交换器） 做“定时重试”。同一 key 的失败消息最好： 要么“停到旁路队列”并报警（跳过这条，继续处理其它 key）， 要么“阻塞该分片直到重试成功/超时熔断”，以维持该 key 的顺序一致性。 明确“毒丸消息”策略：重试 N 次后送到 死信队列，避免永久卡住分片。 幂等与去重 消费端必须幂等（包括重投递/重复确认场景）。为每条消息携带 msgId/版本号，落库前做去重/比序。 可观测性 监控每个分片队列的堆积、处理时延、失败率；对“热点分片”做自动告警。 注意： 1）数据库侧幂等，利用msgId唯一约束 2）redis侧幂等，SETNX msg:{msgId} 1 EX 86400 3）版本号比序（保证顺序），每条消息携带业务 key（比如 userId）+ 版本号（比如 orderVersion）；消费时，查库里该 key 的最新版本 背压机制 背压是一种 流量控制机制，当下游（消费者 / 处理器）处理不过来时，通知上游（生产者）减速或暂停，从而避免系统过载。 常见的背压机制 1）阻塞生产者 生产者往队列放消息，如果队列满了，阻塞等待。 BlockingQueue<String> queue = new ArrayBlockingQueue<>(1000); queue.put("msg"); // 队列满时阻塞 2）丢弃或拒绝策略 队列满了，直接丢弃新消息，或者返回错误给生产者。RabbitMQ 中可以配置： .overflow(QueueBuilder.OverflowPolicy.rejectPublish) 3）生产者端处理 publish 被拒绝 当队列满时，如果用了 rejectPublish 策略，生产者会收到异常： try { rabbitTemplate.convertAndSend("order-queue", orderMessage); } catch (AmqpException e) { // 背压触发：队列已满，生产者需要降速 or 丢弃 Thread.sleep(100); // 简单降速 } 4）限流 (Rate Limit / Prefetch) 默认情况下，消费者会一次性接收尽可能多的消息 开发者需要显式设置： channel.basicQos(50); // 每次最多取 50 条 表示消费者告诉 MQ：一次只给我 N 条，避免自己被压垮。这样消息会堆积在MQ的消息队列中！！ Git git merge和git rebase的区别 git merge 把两个分支合并在一起，产生一个新的 merge commit。 保留了两个分支的提交历史。 历史可能会有「分叉」和「合并点」。 git rebase 把一个分支的提交“挪到”另一个分支上，好像在新的基底上重新提交一遍。 没有 merge commit，历史会更干净。 实质上是「修改历史」。 示例： main: A --- B --- C \ feature: D --- E 1）使用merge git checkout main git merge feature Git 会生成一个 新的合并提交（merge commit），把两个分支的历史接到一起： main: A --- B --- C ------- F (merge commit) \ / feature: D --- E --- 历史完整保留（能看到分支是怎么分叉和合并的）。 但历史中会有很多「叉路」，可能显得比较乱。 2）使用 rebase git checkout feature git rebase main Git 会把 feature 的提交 D、E 拿下来，重新放到 main 的最新提交后面，好像是“重新提交”一遍： main: A --- B --- C \ feature: D' --- E'
JavaWeb——后端 好用的操作 右键文件/文件夹选择Copy Path/Reference，可以获得完整的包路径 Java版本解决方案 单个Java文件运行： Edit Configurations 针对单个运行配置：每个 Java 运行配置（如主类、测试类等）可以独立设置其运行环境（如 JRE 版本、程序参数、环境变量等）。 不影响全局项目：修改某个运行配置的环境不会影响其他运行配置或项目的全局设置。 如何调整全局项目的环境 打开 File -> Project Structure -> Project。 在 Project SDK 中选择全局的 JDK 版本（如 JDK 17）。 在 Project language level 中设置全局的语言级别（如 17）。 Java Compiler File -> Settings -> Build, Execution, Deployment -> Compiler -> Java Compiler Maven Runner File -> Settings -> Build, Execution, Deployment -> Build Tools -> Maven -> Runner 三者之间的关系 全局项目环境 是基准，决定项目的默认 JDK 和语言级别。 Java Compiler 控制编译行为，可以覆盖全局的 Project language level。 Maven Runner 控制 Maven 命令的运行环境，可以覆盖全局的 Project SDK。 Maven 项目： 确保 pom.xml 中的 <maven.compiler.source> 和 <maven.compiler.target> 与 Project SDK 和 Java Compiler 的配置一致。 确保 Maven Runner 中的 JRE 与 Project SDK 一致。 如果还是不行，pom文件右键点击maven->reload project HTTP协议 响应状态码 状态码分类 说明 1xx 响应中 --- 临时状态码。表示请求已经接受，告诉客户端应该继续请求或者如果已经完成则忽略 2xx 成功 --- 表示请求已经被成功接收，处理已完成 3xx 重定向 --- 重定向到其它地方，让客户端再发起一个请求以完成整个处理 4xx 客户端错误 --- 处理发生错误，责任在客户端，如：客户端的请求一个不存在的资源，客户端未被授权，禁止访问等 5xx 服务器端错误 --- 处理发生错误，责任在服务端，如：服务端抛出异常，路由出错，HTTP版本不支持等 状态码 英文描述 解释 200 OK 客户端请求成功，即处理成功，这是我们最想看到的状态码 302 Found 指示所请求的资源已移动到由Location响应头给定的 URL，浏览器会自动重新访问到这个页面 304 Not Modified 告诉客户端，你请求的资源至上次取得后，服务端并未更改，你直接用你本地缓存吧。隐式重定向 400 Bad Request 客户端请求有语法错误，不能被服务器所理解 403 Forbidden 服务器收到请求，但是拒绝提供服务，比如：没有权限访问相关资源 404 Not Found 请求资源不存在，一般是URL输入有误，或者网站资源被删除了 405 Method Not Allowed 请求方式有误，比如应该用GET请求方式的资源，用了POST 429 Too Many Requests 指示用户在给定时间内发送了太多请求（“限速”），配合 Retry-After(多长时间后可以请求)响应头一起使用 500 Internal Server Error 服务器发生不可预期的错误。服务器出异常了，赶紧看日志去吧 503 Service Unavailable 服务器尚未准备好处理请求，服务器刚刚启动，还未初始化好 开发规范 REST风格 在前后端进行交互的时候，我们需要基于当前主流的REST风格的API接口进行交互。 什么是REST风格呢? REST（Representational State Transfer），表述性状态转换，它是一种软件架构风格。 传统URL风格如下： http://localhost:8080/user/getById?id=1 GET：查询id为1的用户 http://localhost:8080/user/saveUser POST：新增用户 http://localhost:8080/user/updateUser PUT：修改用户 http://localhost:8080/user/deleteUser?id=1 DELETE：删除id为1的用户 我们看到，原始的传统URL，定义比较复杂，而且将资源的访问行为对外暴露出来了。 基于REST风格URL如下： http://localhost:8080/users/1 GET：查询id为1的用户 http://localhost:8080/users POST：新增用户 http://localhost:8080/users PUT：修改用户 http://localhost:8080/users/1 DELETE：删除id为1的用户 其中总结起来，就一句话：通过URL定位要操作的资源，通过HTTP动词(请求方式)来描述具体的操作。 REST风格后端代码： @RestController @RequestMapping("/depts") //定义当前控制器的请求前缀 public class DeptController { // GET: 查询资源 @GetMapping("/{id}") public Dept getDept(@PathVariable Long id) { ... } // POST: 新增资源 @PostMapping public void createDept(@RequestBody Dept dept) { ... } // PUT: 更新资源 @PutMapping public void updateDept(@RequestBody Dept dept) { ... } // DELETE: 删除资源 @DeleteMapping("/{id}") public void deleteDept(@PathVariable Long id) { ... } } GET：查询，用 URL 传参，不能带 body。 POST：创建／提交，可以用 body 传数据（JSON、表单）。 PUT：更新，可以用 body 。 DELETE：删除，一般无 body，只要 -X DELETE。 开发流程 查看页面原型明确需求 根据页面原型和需求，进行表结构设计、编写接口文档(已提供) 阅读接口文档 思路分析 功能接口开发 就是开发后台的业务功能，一个业务功能，我们称为一个接口(Controller 中一个完整的处理请求的方法) 功能接口测试 功能开发完毕后，先通过Postman进行功能接口测试，测试通过后，再和前端进行联调测试 前后端联调测试 和前端开发人员开发好的前端工程一起测试 SpringBoot Servlet 容器 是用于管理和运行 Web 应用的环境，它负责加载、实例化和管理 Servlet 组件，处理 HTTP 请求并将请求分发给对应的 Servlet。常见的 Servlet 容器包括 Tomcat、Jetty、Undertow 等。 SpringBoot的WEB默认内嵌了tomcat服务器，非常方便！！！ 浏览器与 Tomcat 之间通过 HTTP 协议进行通信，而 Tomcat 则充当了中间的桥梁，将请求路由到你的 Java 代码，并最终将处理结果返回给浏览器。 查看springboot版本：查看pom文件 <parent> <artifactId>spring-boot-starter-parent</artifactId> <groupId>org.springframework.boot</groupId> <version>2.7.3</version> </parent> 版本为2.7.3 快速启动 新建spring initializr project 删除以下文件 新建HelloController类 @RestController public class HelloController { @RequestMapping("/hello") public String hello(){ System.out.println("hello"); return "hello"; } } 然后启动服务器,main程序 package edu.whut; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class SprintbootQuickstartApplication { public static void main(String[] args) { SpringApplication.run(SprintbootQuickstartApplication.class, args); } } 然后浏览器访问 localhost:8080/hello。 SpringBoot请求 简单参数 在Springboot的环境中，对原始的API进行了封装，接收参数的形式更加简单。 如果是简单参数，参数名与形参变量名相同，定义同名的形参即可接收参数。 @RestController public class RequestController { // http://localhost:8080/simpleParam?name=Tom&age=10 // 第1个请求参数： name=Tom 参数名:name，参数值:Tom // 第2个请求参数： age=10 参数名:age , 参数值:10 //springboot方式 @RequestMapping("/simpleParam") public String simpleParam(String name , Integer age ){//形参名和请求参数名保持一致 System.out.println(name+" : "+age); return "OK"; } } 如果方法形参名称与请求参数名称不一致，controller方法中的形参还能接收到请求参数值吗？ 解决方案：可以使用Spring提供的@RequestParam注解完成映射 在方法形参前面加上 @RequestParam 然后通过value属性执行请求参数名，从而完成映射。代码如下： @RestController public class RequestController { // http://localhost:8080/simpleParam?name=Tom&age=20 // 请求参数名：name //springboot方式 @RequestMapping("/simpleParam") public String simpleParam(@RequestParam("name") String username , Integer age ){ System.out.println(username+" : "+age); return "OK"; } } 实体参数 复杂实体对象指的是，在实体类中有一个或多个属性，也是实体对象类型的。如下： User类中有一个Address类型的属性（Address是一个实体类） 复杂实体对象的封装，需要遵守如下规则： 请求参数名与形参对象属性名相同，按照对象层次结构关系即可接收嵌套实体类属性参数。 注意：这里User前面不能加@RequestBody是因为请求方式是 Query 或 路径 参数；如果是JSON请求体（Body）就必须加。 @RequestMapping("/complexpojo") public String complexpojo(User user){ System.out.println(user); return "OK"; } @Data @NoArgsConstructor @AllArgsConstructor public class User { private String name; private Integer age; private Address address; } @Data @NoArgsConstructor @AllArgsConstructor public class Address { private String province; private String city; } 数组参数 数组参数：请求参数名与形参数组名称相同且请求参数为多个，定义数组类型形参即可接收参数 @RestController public class RequestController { //数组集合参数 @RequestMapping("/arrayParam") public String arrayParam(String[] hobby){ System.out.println(Arrays.toString(hobby)); return "OK"; } } 路径参数 请求的URL中传递的参数 称为路径参数。例如： http://localhost:8080/user/1 http://localhost:880/user/1/0 注意，路径参数使用大括号 {} 定义 @RestController public class RequestController { //路径参数 @RequestMapping("/path/{id}/{name}") public String pathParam2(@PathVariable Integer id, @PathVariable String name){ System.out.println(id+ " : " +name); return "OK"; } } 在路由定义里用 {id} 只是一个占位符，实际请求时 不要 带大括号 JSON格式参数 { "backtime": [ "与中标人签订合同后 5日内", "投标截止时间前撤回投标文件并书面通知招标人的，2日内", "开标现场投标文件被拒收，开标结束后，2日内" ], "employees": [ { "firstName": "John", "lastName": "Doe" }, { "firstName": "Anna", "lastName": "Smith" }, { "firstName": "Peter", "lastName": "Jones" } ] } JSON 格式的核心特征 接口文档中的请求参数中是 'Body' 发送数据 数据为键值对：数据存储在键值对中，键和值用冒号分隔。在你的示例中，每个对象有两个键值对，如 "firstName": "John"。 使用大括号表示对象：JSON 使用大括号 {} 包围对象，对象可以包含多个键值对。 使用方括号表示数组：JSON 使用方括号 [] 表示数组，数组中可以包含多个值，包括数字、字符串、对象等。在该示例中："employees" 是一个对象数组，数组中的每个元素都是一个对象。 Postman如何发送JSON格式数据： 服务端Controller方法如何接收JSON格式数据： 传递json格式的参数，在Controller中会使用实体类进行封装。 封装规则：JSON数据键名与形参对象属性名相同，定义POJO类型形参即可接收参数。需要使用 @RequestBody标识。 @Data @NoArgsConstructor @AllArgsConstructor public class DataDTO { private List<String> backtime; private List<Employee> employees; } @Data @NoArgsConstructor @AllArgsConstructor public class Employee { private String firstName; private String lastName; } @RestController public class DataController { @PostMapping("/data") public String receiveData(@RequestBody DataDTO data) { System.out.println("Backtime: " + data.getBacktime()); System.out.println("Employees: " + data.getEmployees()); return "OK"; } } JSON格式工具包 用于高效地进行 JSON 与 Java 对象之间的序列化和反序列化操作。 引入依赖： <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.76</version> </dependency> 使用： import com.alibaba.fastjson.JSON; public class FastJsonDemo { public static void main(String[] args) { // 创建一个对象 User user = new User("Alice", 30); // 对象转 JSON 字符串 String jsonString = JSON.toJSONString(user); System.out.println("JSON String: " + jsonString); // JSON 字符串转对象 User parsedUser = JSON.parseObject(jsonString, User.class); System.out.println("Parsed User: " + parsedUser); } } // JSON String: {"age":30,"name":"Alice"} // Parsed User: User(name=Alice, age=30) SpringBoot响应 @ResponseBody注解： 位置：书写在Controller方法上或类上 作用：将方法返回值直接响应给浏览器 如果返回值类型是实体对象/集合，将会转换为JSON格式后在响应给浏览器 @RestController = @Controller + @ResponseBody 统一响应结果: 下图返回值分别是字符串、对象、集合。 定义统一返回结果类 响应状态码：当前请求是成功，还是失败 状态码信息：给页面的提示信息 返回的数据：给前端响应的数据（字符串、对象、集合） 定义在一个实体类Result来包含以上信息。代码如下： @Data @NoArgsConstructor @AllArgsConstructor public class Result { private Integer code;//响应码，1 代表成功; 0 代表失败 private String msg; //响应信息 描述字符串 private Object data; //返回的数据 //增删改 成功响应 public static Result success(){ return new Result(1,"success",null); } //查询 成功响应 public static Result success(Object data){ return new Result(1,"success",data); } //失败响应 public static Result error(String msg){ return new Result(0,msg,null); } } Spring分层架构 三层架构 Controller层接收请求，调用Service层；Service层先调用Dao层获取数据，然后实现自己的业务逻辑处理部分，最后返回给Controller层；Controller层再响应数据。可理解为递归的过程。 **传统模式：**对象的创建、管理和依赖关系都由程序员手动编写代码完成，程序内部控制对象的生命周期。 例如： public class A { private B b; public A() { b = new B(); // A 自己创建并管理 B 的实例 } } 假设有类 A 依赖类 B，在传统方式中，类 A 可能在构造方法或方法内部直接调用 new B() 来创建 B 的实例。 如果 B 的创建方式发生变化，A 也需要修改代码。这就导致了耦合度较高。 软件设计原则：高内聚低耦合。 高内聚指的是：一个模块中各个元素之间的联系的紧密程度，如果各个元素(语句、程序段)之间的联系程度越高，则内聚性越高，即 "高内聚"。 低耦合指的是：软件中各个层、模块之间的依赖关联程序越低越好。 IOC控制反转 将对象的创建和依赖关系的管理交给容器，而不是由程序中的各个组件自行管理。 在 IOC 中，容器负责以下几件事： 管理对象的创建：容器根据配置或注解实例化项目中的类，将它们变成“Bean”。 维护依赖关系：容器跟踪各个 Bean 之间的依赖，并在需要时自动注入它们的依赖。 管理生命周期：容器负责控制对象的生命周期，包括对象的初始化和销毁。 依赖注入DI 类 A 不再自己创建 B，而是声明自己需要一个 B，容器在创建 A 时会自动将 B 的实例提供给 A。 DI 是实现 IOC 的一种方式。 public class A { private B b; // 通过构造器注入依赖 public A(B b) { this.b = b; } } 1.Autowird注入 @Autowired private PaymentClient paymentClient; // 字段直接加 @Autowired 2.构造器注入（推荐） 1）手写构造器 public class OrderService { private final PaymentClient paymentClient; // 在构造器上无需加 @Autowired（Spring Boot 下可省略） public OrderService(PaymentClient paymentClient) { this.paymentClient = paymentClient; } } 2）Lombok @RequiredArgsConstructor 用 Lombok 自动为所有 final 字段生成构造器，进一步简化写法： @RequiredArgsConstructor public class OrderService { private final PaymentClient paymentClient; // Lombok 会在编译期生成构造器 } controller层应注入接口类，而不是子类，如果只有一个子类实现类，那么直接注入即可，否则需要指定注入哪一个 @Service("categoryServiceImplV1") public class CategoryServiceImplV1 implements CategoryService { … } @Service("categoryServiceImplV2") public class CategoryServiceImplV2 implements CategoryService { … } @RestController @RequiredArgsConstructor // 推荐构造器注入 public class CategoryController { @Qualifier("categoryServiceImplV2") // 指定注入 V2 private final CategoryService categoryService; } 分层解耦 Bean 对象：在 Spring 中，被容器管理的对象称为 Bean。通过注解（如 @Component, @Service, @Repository, @Controller），可以将一个普通的 Java 类声明为 Bean，容器会负责它的创建、初始化以及生命周期管理。 Component衍生注解 注解 说明 位置 @Controller @Component的衍生注解 标注在控制器类上Controller @Service @Component的衍生注解 标注在业务类上Service @Repository @Component的衍生注解 标注在数据访问类上（由于与mybatis整合，用的少）DAO @Component 声明bean的基础注解 不属于以上三类时，用此注解 注：@Mapper 注解本身并不是 Spring 框架提供的，是用于 MyBatis 数据层的接口标识，但效果类似。 SpringBoot原理 配置文件 配置优先级 在SpringBoot项目当中，常见的属性配置方式有5种， 3种配置文件，加上2种外部属性的配置(Java系统属性、命令行参数)。优先级(从低到高)： application.yaml（忽略） application.yml application.properties java系统属性（-Dxxx=xxx） 命令行参数（--xxx=xxx） 在 Spring Boot 项目中，通常使用的是 application.yml 或 application.properties 文件，这些文件通常放在项目的 src/main/resources 目录下。 如果项目已经打包上线了，这个时候我们又如何来设置Java系统属性和命令行参数呢？ java -Dserver.port=9000 -jar XXXXX.jar --server.port=10010 在这个例子中，由于命令行参数的优先级高于 Java 系统属性，最终生效的 server.port 是 10010。 properties 位置：src/main/resources/application.properties 将配置信息写在application.properties，用注解@Value获取配置文件中的数据 yml配置文件(推荐！！!) 位置：src/main/resources/application.yml 了解下yml配置文件的基本语法： 大小写敏感 数据前边必须有空格，作为分隔符 使用缩进表示层级关系，缩进时，不允许使用Tab键，只能用空格（idea中会自动将Tab转换为空格） 缩进的空格数目不重要，只要相同层级的元素左侧对齐即可 #表示注释，从这个字符一直到行尾，都会被解析器忽略 对象/map集合 user: name: zhangsan detail: age: 18 password: "123456" 数组/List/Set集合 hobby: - java - game - sport //获取示例 @Value("${hobby}") private List<String> hobby; 以上获取配置文件中的属性值，需要通过@Value注解，有时过于繁琐！！！ @ConfigurationProperties 是用来将外部配置（如 application.yml）映射到一个 POJO 上的。 在 Spring Boot 中，根据 驼峰命名转换规则，自动将 YAML 配置文件中的 键名（例如 user-token-name user_token_name）映射到 Java 类中的属性（例如 userTokenName）。 @Data @Component @ConfigurationProperties(prefix = "aliyun.oss") public class AliOssProperties { private String endpoint; private String accessKeyId; private String accessKeySecret; private String bucketName; } Spring提供的简化方式套路： 需要创建一个实现类，且实体类中的属性名和配置文件当中key的名字必须要一致 比如：配置文件当中叫endpoints，实体类当中的属性也得叫endpoints，另外实体类当中的属性还需要提供 getter / setter方法 ==》@Data 需要将实体类交给Spring的IOC容器管理，成为IOC容器当中的bean对象 ==>@Component 在实体类上添加@ConfigurationProperties注解，并通过perfix属性来指定配置参数项的前缀 (可选)引入依赖pom.xml （自动生成配置元数据，让 IDE 能识别并补全你在 application.properties/yml 中的自定义配置项，提高开发体验，不加不影响运行！） <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-configuration-processor</artifactId> </dependency> 隐私数据配置 通常设置一个 application-local.yml 存放隐私数据，且加入 .gitignore ，表示仅存储在本地；然后application.yml 通过占位符引入该文件，eg: application.yml # 主配置文件，导入本地隐私文件，并通过占位符引用 spring: config: # Spring Boot 2.4+ 推荐用 import import: optional:classpath:/application-local.yml myapp: datasource: url: jdbc:mysql://localhost:3306/pay_mall # 下面两个会从 application-local.yml 里拿 username: ${datasource.username} password: ${datasource.password} application-local.yml # 本地专属配置，激活时才会加载 datasource: username: root password: 123456 这里有个松散绑定的原则，对于 ${datasource.username} 这里匹配有效： userName username user-name user_name 这四个是等价的 底层做法是： 全部字符转小写 → username 去掉分隔符（-、_、.、空格） → username 再匹配到 Java Bean 里的驼峰字段 userName（或直接 username，视你写的字段而定） Bean 的获取和管理 获取Bean 1.自动装配（@Autowired） @Service public class MyService { @Autowired private MyRepository myRepository; // 自动注入 MyRepository Bean } 2.手动获取（ApplicationContext） ApplicationContext 是 Spring 的 IoC 容器，可以手动获取 Bean： Spring 会默认采用类名并将首字母小写作为 Bean 的名称。例如，类名为 DeptController 的组件默认名称就是 deptController。 @RunWith(SpringRunner.class) @SpringBootTest public class SpringbootWebConfig2ApplicationTests { @Autowired private ApplicationContext applicationContext; // IoC 容器 @Test public void testGetBean() { // 根据 Bean 名称获取 DeptController bean = (DeptController) applicationContext.getBean("deptController"); System.out.println(bean); } } 默认情况下，Spring 在容器启动时会创建所有单例 Bean（饿汉模式）；使用 @Lazy 注解则可实现延迟加载（懒汉模式） bean的作用域 作用域 说明 singleton 容器内同名称的bean只有一个实例（单例）（默认） prototype 每次使用该bean时会创建新的实例（非单例） 在设计单例类时，通常要求它们是无状态的，不仅要确保成员变量不可变，还需要确保成员方法不会对共享的、可变的状态进行不受控制的修改，从而实现整体的线程安全。 @Service public class CalculationService { // 不可变的成员变量 private final double factor = 2.0; // 成员方法仅依赖方法参数和不可变成员变量 public double multiply(double value) { return value * factor; } } 更改作用域方法： 在bean类上加注解@Scope("prototype")（或其他作用域标识）即可。 第三方 Bean配置 如果需要管理的 Bean 不是自己写的类（如第三方库提供的对象），不能用 @Component，必须借助 @Bean 注册： @Configuration // 配置类 public class CommonConfig { // 定义第三方 Bean，并交给 IoC 容器管理 @Bean public SAXReader reader(DeptService deptService) { System.out.println(deptService); return new SAXReader(); } } 默认 Bean 名称：方法名 → "reader"。 Bean 类型：返回值类型 → SAXReader。 使用： @Service public class XmlProcessingService { // 按类型注入 @Autowired private SAXReader reader; public void parse(String xmlPath) throws DocumentException { Document doc = reader.read(new File(xmlPath)); // ... 处理 Document ... } } 只有一个同类型 Bean 时：@Autowired 按类型注入，字段名如（reader）不必和 @Bean 方法名一致。 多个同类型 Bean 时：需要用 @Qualifier 或 @Resource 指定 Bean 名称。 SpirngBoot原理 如果我们直接基于Spring框架进行项目的开发，会比较繁琐。SpringBoot框架之所以使用起来更简单更快捷，是因为SpringBoot框架底层提供了两个非常重要的功能：一个是起步依赖，一个是自动配置。 起步依赖 Spring Boot 只需要引入一个起步依赖（例如 springboot-starter-web）就能满足项目开发需求。这是因为： Maven 依赖传递： 起步依赖内部已经包含了开发所需的常见依赖（如 JSON 解析、Web、WebMVC、Tomcat 等），无需开发者手动引入其它依赖。 结论： 起步依赖的核心原理就是 Maven 的依赖传递机制。 自动装配 在传统 Spring 开发中，需要在 applicationContext.xml 或 @Configuration 类里手工声明大量 Bean。Spring Boot 为了简化配置，引入了 自动装配（Auto Configuration），让我们只需少量注解就能“开箱即用”。 自动配置原理源码入口就是@SpringBootApplication注解，在这个注解中封装了3个注解，分别是： @SpringBootConfiguration 声明当前类是一个配置类，作用类似 @Configuration，但作为 Spring Boot 的专用配置入口标识。 @ComponentScan 用于 扫描项目自定义的 Bean，比如 @Component、@Service、@Repository、@Controller 等。 默认扫描 启动类所在包及其子包。 如果项目分为 server、common 等模块，可以通过： @ComponentScan({"com.your.package.server", "com.your.package.common"}) 来显式指定需要扫描的包路径。 @EnableAutoConfiguration（自动配置核心注解） 导入自动配置类 通过元注解 @Import(AutoConfigurationImportSelector.class)，在启动时读取所有 JAR 包中 META‑INF/spring.factories 中声明的自动配置类，并注册到容器。 按条件注册 Bean 配置类内部使用条件注解（@ConditionalOnClass、@ConditionalOnMissingBean、@ConditionalOnProperty 等）。 仅在 类路径、配置属性、已有 Bean 满足条件时，才创建对应 Bean。 典型示例 DataSourceAutoConfiguration：检测到 JDBC 驱动和 spring.datasource. 配置时自动创建数据源。 WebMvcAutoConfiguration：配置 Spring MVC 默认组件；若用户提供了自定义 MVC 配置，则自动“退让”（Back Off）。 自动配置的效果： 如何让第三方bean以及配置类生效？ 在 Spring Boot 中，默认只会扫描 启动类所在包及其子包 下的组件。 如果某些类或配置类（如 CommonConfig）不在扫描路径内，就不会被容器加载，需要手动导入。 常见的三种方式如下： 1.使用 @ComponentScan（批量导入） 通过指定包路径，扩大扫描范围，让其中的组件或配置类被容器加载： @SpringBootApplication(scanBasePackages = {"com.example.server", "com.example.common"}) public class DemoApplication {} 特点：适合批量导入，但可能引入无关类，配置繁琐、性能相对较低。 2.使用 @Import（精确导入） @Import 可以直接将类或配置类导入到容器中，更加灵活精准。 导入普通类 即使类上没有 @Component，也能被 Spring 管理： public class TokenParser { public void parse() { System.out.println("TokenParser ... parse ..."); } } @SpringBootApplication @Import(TokenParser.class) // 导入普通类 public class DemoApplication {} 导入配置类 配置类中集中定义多个 Bean： @Configuration public class HeaderConfig { @Bean public HeaderParser headerParser() { return new HeaderParser(); } @Bean public HeaderGenerator headerGenerator() { return new HeaderGenerator(); } } @SpringBootApplication @Import(HeaderConfig.class) // 导入配置类 public class DemoApplication {} 3.使用 @EnableXxx 注解（推荐） 很多第三方依赖会提供一个 @EnableXxx 注解，内部基于 @Import 封装，可以一次性导入多个配置类或 Bean。 @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) @Import(MyImportSelector.class) // 指定要导入的 Bean 或配置类 public @interface EnableHeaderConfig {} 应用启动时，只需加上该注解即可： @EnableHeaderConfig // 开启第三方依赖提供的功能 @SpringBootApplication public class DemoApplication {} 特点：最常用、最优雅的方式，Spring Boot 内置的很多功能（如 @EnableScheduling、@EnableAsync、@EnableWebMvc）都是通过这种模式实现的。 如何自定义 Starter 实现自动装配 1.准备一个服务类 这是你希望自动装配到 Spring 容器中的类，比如一个简单的业务类。 package com.example.demo.service; public class HelloService { public String sayHello(String name) { return "Hello, " + name; } } 2.编写自动配置类 使用 @Configuration 注解，把上面的服务注册为一个 Spring Bean。 package com.example.demo.autoconfig; import com.example.demo.service.HelloService; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class HelloServiceAutoConfiguration { @Bean public HelloService helloService() { return new HelloService(); } } 3.在 META-INF/spring.factories 中声明 在你的项目（或者 starter jar 包）里新建一个文件： src/main/resources/META-INF/spring.factories ,内容如下： org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ com.example.demo.autoconfig.HelloServiceAutoConfiguration Spring Boot 启动时会读取所有依赖里的 spring.factories 文件，把里面的配置类自动加载。 4.打包成 Starter Jar 把项目打成一个独立的 Jar（通常命名为 xxx-spring-boot-starter），发布到本地或远程仓库。 5.在应用中使用 只要在别的 Spring Boot 应用里引入这个依赖，就能直接注入并使用： @RestController public class TestController { private final HelloService helloService; public TestController(HelloService helloService) { this.helloService = helloService; //构造器注入 } @GetMapping("/hi") public String hi(String name) { return helloService.sayHello(name); } } Spring Boot 的 自动配置类（通过 spring.factories 声明的）通常是 打包成一个独立的 Jar（Starter），然后给别的项目引用。这样别人只要在 pom.xml 或 build.gradle 里加上依赖，就能自动获得你配置的 Bean。 常见的注解！！ @RequestMapping("/jsonParam")：可以用于控制器级别，也可以用于方法级别。 用于方法：HTTP 请求路径为 /jsonParam 的请求将调用该方法。 @RequestMapping("/jsonParam") public String jsonParam(@RequestBody User user){ System.out.println(user); return "OK"; } 用于控制器: 所有方法的映射路径都会以这个前缀开始。 @RestController @RequestMapping("/depts") public class DeptController { @GetMapping("/{id}") public Dept getDept(@PathVariable Long id) { // 实现获取部门逻辑 } @PostMapping public void createDept(@RequestBody Dept dept) { // 实现新增部门逻辑 } } @RequestBody：这是一个方法参数级别的注解，用于告诉Spring框架将请求体的内容解析为指定的Java对象。 @RestController：这是一个类级别的注解，它告诉Spring框架这个类是一个控制器（Controller），并且处理HTTP请求并返回响应数据。与 @Controller 注解相比，@RestController 注解还会自动将控制器方法返回的数据转换为 JSON 格式，并写入到HTTP响应中，得益于@ResponseBody 。 @RestController = @Controller + @ResponseBody @PathVariable 注解用于将路径参数 {id} 的值绑定到方法的参数 id 上。当请求的路径是 "/path/123" 时，@PathVariable 会将路径中的 "123" 值绑定到方法的参数 id 上。 public String pathParam(@PathVariable Integer id) { System.out.println(id); return "OK"; } //参数名与路径名不同 @GetMapping("/{id}") public ResponseEntity<User> getUserById(@PathVariable("id") Long userId) { } @RequestParam, 1）如果方法的参数名与请求参数名不同，需要在 @RequestParam 注解中指定请求参数的名字。 类似@PathVariable，可以指定参数名称。 @RequestMapping("/example") public String exampleMethod(@RequestParam String name, @RequestParam("age") int userAge) { // 在方法内部使用获取到的参数值进行处理 System.out.println("Name: " + name); System.out.println("Age: " + userAge); return "OK"; } 2）还可以设置默认值 @RequestMapping("/greet") public String greet(@RequestParam(defaultValue = "Guest") String name) { return "Hello, " + name; } 3）如果既改请求参数名字，又要设置默认值 @RequestMapping("/greet") public String greet(@RequestParam(value = "age", defaultValue = "25") int userAge) { return "Age: " + userAge; } 4）如果方法参数是简单类型（int/Integer、String、boolean/Boolean 等及它们的一维数组），那么无需使用@RequestParam，如果是Collection集合类型，必须使用。 List<ItemDTO> queryItemByIds(@RequestParam("ids") Collection<Long> ids); 控制反转与依赖注入： @Component、@Service、@Repository 用于标识 bean 并让容器管理它们，从而实现 IoC。 @Autowired、@Configuration、@Bean 用于实现 DI，通过容器自动装配或配置 bean 的依赖。 数据库相关。 @Mapper注解：表示是mybatis中的Mapper接口，程序运行时，框架会自动生成接口的实现类对象(代理对象)，并交给Spring的IOC容器管理 @Select注解：代表的就是select查询，用于书写select查询语句 @SpringBootTest：它会启动 Spring 应用程序上下文，并在测试期间模拟运行整个 Spring Boot 应用程序。这意味着你可以在集成测试中使用 Spring 的各种功能，例如自动装配、依赖注入、配置加载等。 lombok的相关注解。非常实用的工具库。 在pom.xml文件中引入依赖 <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency> 在实体类上添加以下注解(加粗为常用) 注解 作用 @Getter/@Setter 为所有的属性提供get/set方法 @ToString 会给类自动生成易阅读的 toString 方法 @EqualsAndHashCode 根据类所拥有的非静态字段自动重写 equals 方法和 hashCode 方法 @Data 提供了更综合的生成代码功能（@Getter + @Setter + @ToString + @EqualsAndHashCode） @NoArgsConstructor 为实体类生成无参的构造器方法 @AllArgsConstructor 为实体类生成除了static修饰的字段之外带有各参数的构造器方法。 @Slf4j 可以log.info("输出日志信息"); //equals 方法用于比较两个对象的内容是否相同 Address addr1 = new Address("SomeProvince", "SomeCity"); Address addr2 = new Address("SomeProvince", "SomeCity"); System.out.println(addr1.equals(addr2)); // 输出 true log: log.info("应用启动成功"); Long empId = 12L; log.info("当前员工id：{}", empId); //带占位符，推荐！ log.info("当前员工id：" + empId); //不错，但不推荐 log.info("当前员工id：", empId); //错误的！ @Test，Junit测试单元，可在测试类中定义测试函数，一次性执行所有@Test注解下的函数，不用写main方法 @Override，当一个方法在子类中覆盖（重写）了父类中的同名方法时，为了确保正确性，可以使用 @Override 注解来标记这个方法，这样编译器就能够帮助检查是否正确地重写了父类的方法。 @DateTimeFormat将日期转化为指定的格式。Spring会尝试将接收到的字符串参数转换为控制器方法参数的相应类型。 @RestController public class DateController { // 例如：请求 URL 为 /search?begin=2025-03-28 @GetMapping("/search") public String search(@RequestParam("begin") @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate begin) { // 此时 begin 已经是 LocalDate 类型，可以直接使用 return "接收到的日期是: " + begin; } } @RestControllerAdvice= @ControllerAdvice + @ResponseBody。加上这个注解就代表我们定义了一个全局异常处理器，而且处理异常的方法返回值会转换为json后再响应给前端 @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(Exception.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public String handleException(Exception ex) { // 返回错误提示或错误详情 return "系统发生异常：" + ex.getMessage(); } } @Configuration和@Bean配合使用，可以对第三方bean进行集中的配置管理，依赖注入！！@Bean用于方法上。加了@Configuration，当Spring Boot应用启动时，它会执行一系列的自动配置步骤。 @ComponentScan指定了Spring应该在哪些包下搜索带有@Component、@Service、@Repository、@Controller等注解的类，以便将这些类自动注册为Spring容器管理的Bean.@SpringBootApplication它是一个便利的注解，组合了@Configuration、@EnableAutoConfiguration和@ComponentScan注解。 @Async 注解，异步执行 1.在你的配置类或主启动类上添加： @Configuration @EnableAsync public class AsyncConfig { // 可以自定义线程池 Bean（可选） } 2.在你希望异步执行的方法或它所在的 Bean 上，添加 @Async @Service public class EmailService { @Async public void sendWelcomeEmail(String userId) { // 这个方法会在独立线程中执行 // 调用线程会立即返回，不会等待方法内部逻辑完成 // … 发送邮件的耗时操作 … } } 登录校验 会话技术 会话是和浏览器关联的，当有三个浏览器客户端和服务器建立了连接时，就会有三个会话。同一个浏览器在未关闭之前请求了多次服务器，这多次请求是属于同一个会话。比如：1、2、3这三个请求都是属于同一个会话。当我们关闭浏览器之后，这次会话就结束了。而如果我们是直接把web服务器关了，那么所有的会话就都结束了。 会话跟踪技术有三种： Cookie（客户端会话跟踪技术） Session（服务端会话跟踪技术） 令牌技术 Cookie 原理：会话数据存储在客户端浏览器中，通过浏览器自动管理。 优点：HTTP协议中支持的技术（像Set-Cookie 响应头的解析以及 Cookie 请求头数据的携带，都是浏览器自动进行的，是无需我们手动操作的） 缺点： 移动端APP(Android、IOS)中无法使用Cookie 不安全，用户可以自己禁用Cookie Cookie不能跨域传递 Session 1.基本原理 服务端：存储会话数据（内存、Redis 等）。 客户端：仅保存会话 ID（如 JSESSIONID），通常通过 Cookie 传递。 2.数据结构 服务端会话存储（Map 或 Redis） { "abc123" -> HttpSession 实例 } HttpSession 结构： HttpSession ├─ id = "abc123" ├─ creationTime = ... ├─ lastAccessedTime = ... └─ attributes └─ "USER_LOGIN_STATE" -> user 实体对象 3.请求流程 首次请求 浏览器没有 JSESSIONID，服务端调用 createSession() 创建一个新会话（ID 通常是 UUID）。 服务端返回响应头 Set-Cookie: JSESSIONID=<新ID>; Max-Age=2592000（30 天有效期）。 浏览器将 JSESSIONID 写入本地 Cookie（持久化保存）。 后续请求 浏览器自动在请求头中附带 Cookie: JSESSIONID=<ID>。 服务端用该 ID 在会话存储中查找对应的 HttpSession 实例，恢复用户状态。 ┌───────────────┐ (带 Cookie JSESSIONID=abc123) │ Browser │ ───────►│ Tomcat │ └───────────────┘ └──────────┘ │ │ 用 abc123 做 key ▼ {abc123 → HttpSession} ← 找到 │ ▼ 取 attributes["USER_LOGIN_STATE"] → 得到 userrequest.getSession().setAttribute(UserConstant.USER_LOGIN_STATE, user); 4.后端使用示例 保存登录状态: request.getSession().setAttribute(UserConstant.USER_LOGIN_STATE, user); request.getSession() 会自动获取当前请求关联的 HttpSession 实例。 获取登录状态: User user = (User) request.getSession().getAttribute(UserConstant.USER_LOGIN_STATE); 退出登录: request.getSession().removeAttribute(UserConstant.USER_LOGIN_STATE); 相当于清空当前会话中的用户信息。浏览器本地的 JSESSIONID 依然存在，只不过后端啥也没了。 优点 会话数据保存在服务端，相比直接将数据存储在客户端更安全（防篡改）。 缺点 分布式集群下 Session 无法自动共享（需借助 Redis 等集中存储）。 客户端禁用 Cookie 时，Session 会失效。 服务端需要维护会话数据，高并发环境下可能带来内存或性能压力。 令牌JWT（推荐） 优点： 支持PC端、移动端 解决集群环境下的认证问题 减轻服务器的存储压力（无需在服务器端存储） 缺点：需要自己实现（包括令牌的生成、令牌的传递、令牌的校验） 跨域问题 跨域问题指的是在浏览器中，一个网页试图去访问另一个域下的资源时，浏览器出于安全考虑，默认会阻止这种操作。这是浏览器的同源策略（Same-Origin Policy）导致的行为。 同源策略（Same-Origin Policy） 同源策略是浏览器的一种安全机制，它要求： 协议（如 http、https） 域名/IP（如 example.com） 端口（如 80 或 443） 这三者必须完全相同，才能被视为同源。 举例： ​ http://192.168.150.200/login.html ----------> https://192.168.150.200/login [协议不同，跨域] ​ http://192.168.150.200/login.html ----------> http://192.168.150.100/login [IP不同，跨域] ​ http://192.168.150.200/login.html ----------> http://192.168.150.200:8080/login [端口不同，跨域] ​ http://192.168.150.200/login.html ----------> http://192.168.150.200/login [不跨域] 解决跨域问题的方法： CORS（Cross-Origin Resource Sharing）是解决跨域问题的标准机制。它允许服务器在响应头中加上特定的 CORS 头部信息，明确表示允许哪些外域访问其资源。 服务器端配置：服务器返回带有 Access-Control-Allow-Origin 头部的响应，告诉浏览器允许哪些域访问资源。 Access-Control-Allow-Origin: *（表示允许所有域访问） Access-Control-Allow-Origin: http://site1.com（表示只允许 http://site1.com 访问） 全局统一配置 import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class WebCorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") // 匹配所有 /api/** 路径 .allowedOrigins("http://allowed-domain.com") // 允许的域名 .allowedMethods("GET","POST","PUT","DELETE","OPTIONS") .allowedHeaders("Content-Type","Authorization") .allowCredentials(true) // 是否允许携带 Cookie .maxAge(3600); // 预检请求缓存 1 小时 } } Nginx解决方案 统一域名入口： 前端和 API 均通过 Nginx 以相同的域名（例如 https://example.com）提供服务。前端发送 AJAX 请求时，目标也是该域名的地址，如 https://example.com/api，从而避免了跨域校验。 Nginx 作为中间代理： Nginx 将特定路径（例如 /api/）的请求转发到后端服务器。对浏览器来说，请求和响应均来自同一域名，代理过程对浏览器透明。 “黑匣子”处理： 浏览器只与 Nginx 交互，不关心 Nginx 内部如何转发请求。无论后端位置如何，浏览器都认为响应源自统一域名，从而解决跨域问题。 总结 普通的跨域请求依然会送达服务器，服务器并不主动拦截；它只是通过响应头声明哪些来源被允许访问，而真正的拦截与安全检查，则由浏览器根据同源策略来完成。 JWT令牌 特性 Session JWT（JSON Web Token） 存储方式 服务端存储会话数据（如内存、Redis） 客户端存储完整的令牌（通常在 Header 或 Cookie） 标识方式 客户端持有一个 Session ID 客户端持有一个自包含的 Token 状态管理 有状态（Stateful），服务器要维护会话 无状态（Stateless），服务器不存会话 生成和校验 引入依赖 <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency> 生成令牌与解析令牌： public class JwtUtils { private static String signKey = "zy123"; private static Long expire = 43200000L; //单位毫秒 12小时 /** * 生成JWT令牌 * @param claims JWT第二部分负载 payload 中存储的内容 * @return */ public static String generateJwt(Map<String, Object> claims){ String jwt = Jwts.builder() .addClaims(claims) .signWith(SignatureAlgorithm.HS256, signKey) .setExpiration(new Date(System.currentTimeMillis() + expire)) .compact(); return jwt; } /** * 解析JWT令牌 * @param jwt JWT令牌 * @return JWT第二部分负载 payload 中存储的内容 */ public static Claims parseJWT(String jwt){ Claims claims = Jwts.parser() .setSigningKey(signKey) .parseClaimsJws(jwt) .getBody(); return claims; } } 令牌可以存储当前登录用户的信息：id、username等等，传入claims Object 类型能够容纳字符串、数字等各种对象。 Map<String, Object> claims = new HashMap<>(); claims.put("id", emp.getId()); // 假设 emp.getId() 返回一个数字（如 Long 类型） claims.put("name", e.getName()); // 假设 e.getName() 返回一个字符串 claims.put("username", e.getUsername()); // 假设 e.getUsername() 返回一个字符串 String jwt = JwtUtils.generateJwt(claims); 解析令牌： @Autowired private HttpServletRequest request; String jwt = request.getHeader("token"); Claims claims = JwtUtils.parseJWT(jwt); // 解析 JWT 令牌 // 获取存储的 id, name, username Long id = (Long) claims.get("id"); // 如果 "id" 是 Long 类型 String name = (String) claims.get("name"); String username = (String) claims.get("username"); JWT 登录认证流程 用户登录 用户发起登录请求，校验密码、登录成功后，生成 JWT 令牌，并将其返回给前端。 前端存储令牌 前端接收到 JWT 令牌，存储在浏览器中（通常存储在 LocalStorage 或 Cookie 中）。 // 登录成功后，存储 JWT 令牌到 LocalStorage const token = response.data.token; // 从响应中获取令牌 localStorage.setItem('token', token); // 存储到 LocalStorage // 在后续请求中获取令牌并附加到请求头 const storedToken = localStorage.getItem('token'); fetch("https://your-api.com/protected-endpoint", { method: "GET", headers: { "token": storedToken // 添加 token 到请求头 } }) .then(response => response.json()) .then(data => console.log(data)) .catch(error => console.log('Error:', error)); 请求带上令牌 后续的每次请求，前端将 JWT 令牌携带上。 服务端校验令牌 服务端接收到请求后，拦截请求并检查是否携带令牌。若没有令牌，拒绝访问；若令牌存在，校验令牌的有效性（包括有效期），若有效则放行，进行请求处理。 注意，使用APIFOX测试时，需要在headers中添加 {token:"jwt令牌..."}否则会无法通过拦截器。 拦截器(Interceptor) 在拦截器当中，我们通常也是做一些通用性的操作，比如：我们可以通过拦截器来拦截前端发起的请求，将登录校验的逻辑全部编写在拦截器当中。在校验的过程当中，如发现用户登录了(携带JWT令牌且是合法令牌)，就可以直接放行，去访问spring当中的资源。如果校验时发现并没有登录或是非法令牌，就可以直接给前端响应未登录的错误信息。 快速入门 定义拦截器，实现HandlerInterceptor接口，并重写其所有方法 //自定义拦截器 @Component public class JwtTokenUserInterceptor implements HandlerInterceptor { //目标资源方法执行前执行。 返回true：放行 返回false：不放行 @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { System.out.println("preHandle .... "); return true; //true表示放行 } //目标资源方法执行后执行 @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { System.out.println("postHandle ... "); } //视图渲染完毕后执行，最后执行 @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { System.out.println("afterCompletion .... "); } } 注意： ​ preHandle方法：目标资源方法执行前执行。 返回true：放行 返回false：不放行 ​ postHandle方法：目标资源方法执行后执行 ​ afterCompletion方法：视图渲染完毕后执行，最后执行 注册配置拦截器，实现WebMvcConfigurer接口，并重写addInterceptors方法 @Configuration public class WebConfig implements WebMvcConfigurer { //自定义的拦截器对象 @Autowired private JwtTokenUserInterceptor jwtTokenUserInterceptor; @Override protected void addInterceptors(InterceptorRegistry registry) { log.info("开始注册自定义拦截器..."); registry.addInterceptor(jwtTokenUserInterceptor) .addPathPatterns("/user/**") .excludePathPatterns("/user/user/login") .excludePathPatterns("/user/shop/status"); } } WebMvcConfigurer接口: 拦截器配置 通过实现 addInterceptors 方法，可以添加自定义的拦截器，从而在请求进入处理之前或之后执行一些逻辑操作，如权限校验、日志记录等。 静态资源映射 通过 addResourceHandlers 方法，可以自定义静态资源（如 HTML、CSS、JavaScript）的映射路径，这对于使用前后端分离或者集成第三方文档工具（如 Swagger/Knife4j）非常有用。 消息转换器扩展 通过 extendMessageConverters 方法，可以在默认配置的基础上，追加自定义的 HTTP 消息转换器，如将 Java 对象转换为 JSON 格式。 跨域配置 使用 addCorsMappings 方法，可以灵活配置跨域资源共享（CORS）策略，方便前后端跨域请求。 拦截路径 addPathPatterns指定拦截路径； 调用excludePathPatterns("不拦截的路径")方法，指定哪些资源不需要拦截。 拦截路径 含义 举例 /* 一级路径 能匹配/depts，/emps，/login，不能匹配 /depts/1 /** 任意级路径 能匹配/depts，/depts/1，/depts/1/2 /depts/* /depts下的一级路径 能匹配/depts/1，不能匹配/depts/1/2，/depts /depts/** /depts下的任意级路径 能匹配/depts，/depts/1，/depts/1/2，不能匹配/emps/1 登录校验 主要在preHandle中写逻辑 public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { //判断当前拦截到的是Controller的方法还是其他资源 if (!(handler instanceof HandlerMethod)) { //当前拦截到的不是动态方法，直接放行 return true; } //1、从请求头中获取令牌 String token = request.getHeader(jwtProperties.getUserTokenName()); //2、校验令牌 try { log.info("jwt校验:{}", token); Claims claims = JwtUtil.parseJWT(jwtProperties.getUserSecretKey(), token); Long userId = Long.valueOf(claims.get(JwtClaimsConstant.USER_ID).toString()); log.info("当前用户id：", userId); BaseContext.setCurrentId(userId); //3、通过，放行 return true; } catch (Exception ex) { //4、不通过，响应401状态码 response.setStatus(401); return false; } } 全局异常处理 **当前问题：**如果程序因不知名原因报错，响应回来的数据是一个JSON格式的数据，但这种JSON格式的数据不符合开发规范当中所提到的统一响应结果Result，导致前端不能解析出响应的JSON数据。 当我们没有做任何的异常处理时，我们三层架构处理异常的方案： Mapper接口在操作数据库的时候出错了，此时异常会往上抛(谁调用Mapper就抛给谁)，会抛给service。 service 中也存在异常了，会抛给controller。 而在controller当中，我们也没有做任何的异常处理，所以最终异常会再往上抛。最终抛给框架之后，框架就会返回一个JSON格式的数据，里面封装的就是错误的信息，但是框架返回的JSON格式的数据并不符合我们的开发规范。 如何解决： 方案一：在所有Controller的所有方法中进行try…catch处理 缺点：代码臃肿（不推荐） 方案二：全局异常处理器 好处：简单、优雅（推荐） 全局异常处理 定义全局异常处理器非常简单，就是定义一个类，在类上加上一个注解**@RestControllerAdvice**，加上这个注解就代表我们定义了一个全局异常处理器。 在全局异常处理器当中，需要定义一个方法来捕获异常，在这个方法上需要加上注解**@ExceptionHandler**。通过 @ExceptionHandler注解当中的value属性来指定我们要捕获的是哪一类型的异常。 @RestControllerAdvice public class GlobalExceptionHandler { //处理 RuntimeException 异常 @ExceptionHandler(RuntimeException.class) public Result handleRuntimeException(RuntimeException e) { e.printStackTrace(); return Result.error("系统错误，请稍后再试"); } // 处理 NullPointerException 异常 @ExceptionHandler(NullPointerException.class) public Result handleNullPointerException(NullPointerException e) { e.printStackTrace(); return Result.error("空指针异常，请检查代码逻辑"); } //处理异常 @ExceptionHandler(Exception.class) //指定能够处理的异常类型，Exception.class捕获所有异常 public Result ex(Exception e){ e.printStackTrace();//打印堆栈中的异常信息 //捕获到异常之后，响应一个标准的Result return Result.error("对不起,操作失败,请联系管理员"); } } 模拟NullPointerException String str = null; // 调用 null 对象的方法会抛出 NullPointerException System.out.println(str.length()); // 这里会抛出 NullPointerException 模拟RuntimeException int res=10/0; 事务 场景与问题： @Slf4j @Service public class DeptServiceImpl implements DeptService { @Autowired private DeptMapper deptMapper; @Autowired private EmpMapper empMapper; //根据部门id，删除部门信息及部门下的所有员工 @Override public void delete(Integer id){ //根据部门id删除部门信息 deptMapper.deleteById(id); //模拟：异常发生 int i = 1/0; //删除部门下的所有员工信息 empMapper.deleteByDeptId(id); } } 问题：出现异常后，部门已被删除，但员工记录仍然存在，造成数据不一致。 原因：整组操作没有被事务包裹，无法做到“要么全部成功，要么全部失败”。 @Transactional 注解 位置 作用 方法级 仅当前方法受事务管理 类级 类中所有方法受事务管理 接口级 接口下 所有实现类 的全部方法受事务管理 在实际开发中，推荐只在 Service 层的方法或类上标注，保持粒度清晰。 常用属性 属性 说明 默认值 rollbackFor 指定哪些异常触发回滚 仅 RuntimeException propagation 指定事务传播行为 Propagation.REQUIRED ① 回滚规则（rollbackFor） @Transactional(rollbackFor = Exception.class) // 捕获所有异常并回滚 public void delete(Integer id) { ... } 如果只写 @Transactional，则 仅 运行时异常（RuntimeException）会触发回滚。 如要让 检查时异常（Exception）也能回滚，就需显式指定 rollbackFor。 ② 事务传播行为（propagation） 传播行为 父事务已存在时 父事务不存在时 典型用途 / 说明 REQUIRED (默认) 加入父事务→ 共提交 / 回滚 创建新事务 日常业务写操作，保持一致性 REQUIRES_NEW 挂起父事务→ 自己新建事务 自己新建事务 写日志、发送 MQ 等：外层失败也要单独成功 SUPPORTS 加入父事务 非事务方式执行 只读查询：有事务跟随一致性，没有就轻量查询 NOT_SUPPORTED 挂起父事务→ 非事务方式执行 非事务方式执行 大批量/耗时操作，避免长事务锁表 MANDATORY 加入父事务 立即抛异常 防御性编程：强制要求调用方已开启事务 NEVER 立即抛异常 非事务方式执行 禁止在事务里跑的代码（如特殊 DDL） NESTED 同一物理事务，打 SAVEPOINT→ 子回滚只回到保存点 创建新事务（与 REQUIRED 效果相同） 分段回滚；需 DB / JDBC 支持保存点 需要“互不影响”时用 REQUIRES_NEW——强制新建事务： @Transactional // 外层保存订单 public void saveOrder(Order order){ orderMapper.insert(order); // 总是单独提交日志 logService.saveLog(...); // 后面出现异常 if(order.getAmount() < 0){ throw new IllegalArgumentException("非法金额"); } } //操作日志、审计表、MQ 消息等，不能因为业务失败而丢记录。 @Transactional(propagation = Propagation.REQUIRES_NEW) public void saveLog(Log log) { ... } 调试：Spring事务日志开关 在 application.yml 中添加： logging: level: org.springframework.jdbc.support.JdbcTransactionManager: debug 效果：控制台会打印事务生命周期日志（开启、提交、回滚等），方便排查。 总结 当 Service 层发生异常 时，Spring 会按照以下顺序处理： 事务的回滚：如果 Service 层抛出了一个异常（如 RuntimeException），并且这个方法是 @Transactional 注解标注的，Spring 会在方法抛出异常时 回滚事务。Spring 事务管理器会自动触发回滚操作。 异常传播到 Controller 层：如果异常在 Service 层处理后未被捕获，它会传播到 Controller 层（即调用 Service 方法的地方）。 全局异常处理器：当异常传播到 Controller 层时，全局异常处理器（@RestControllerAdvice 或 @ControllerAdvice）会捕获并处理该异常，返回给前端一个标准的错误响应。 AOP AOP（Aspect-Oriented Programming，面向切面编程）是一种编程思想，旨在将横切关注点（如日志、性能监控等）从核心业务逻辑中分离出来。简单来说，AOP 是通过对特定方法的增强（如统计方法执行耗时）来实现代码复用和关注点分离。 快速入门 实现业务方法执行耗时统计的步骤 定义模板方法：将记录方法执行耗时的公共逻辑提取到模板方法中。 记录开始时间：在方法执行前记录开始时间。 执行原始业务方法：中间部分执行实际的业务方法。 记录结束时间：在方法执行后记录结束时间，计算并输出执行时间。 通过 AOP，我们可以在不修改原有业务代码的情况下，完成对方法执行耗时的统计。 实现步骤： 导入依赖：在pom.xml中导入AOP的依赖 <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency> 编写AOP程序：针对于特定方法根据业务需要进行编程 @Component @Aspect //当前类为切面类 @Slf4j public class TimeAspect { ////第一个星号表示任意返回值，第二个星号表示类/接口，第三个星号表示所有方法。 @Around("execution(* edu.whut.zy123.service.*.*(..))") public Object recordTime(ProceedingJoinPoint pjp) throws Throwable { //记录方法执行开始时间 long begin = System.currentTimeMillis(); //执行原始方法 Object result = pjp.proceed(); //记录方法执行结束时间 long end = System.currentTimeMillis(); //计算方法执行耗时，pjp.getSignature()获得函数名 log.info(pjp.getSignature()+"执行耗时: {}毫秒",end-begin); return result; } } 我们通过AOP入门程序完成了业务方法执行耗时的统计，那其实AOP的功能远不止于此，常见的应用场景如下： 记录系统的操作日志 权限控制 事务管理：我们前面所讲解的Spring事务管理，底层其实也是通过AOP来实现的，只要添加@Transactional注解之后，AOP程序自动会在原始方法运行前先来开启事务，在原始方法运行完毕之后提交或回滚事务 核心概念 1. 连接点：JoinPoint，可以被AOP控制的方法，代表方法的执行位置 2. 通知：Advice，指对目标方法的“增强”操作 （体现为额外的代码） 3. 切入点：PointCut，是一个表达式，匹配连接点的条件，它指定了 在目标方法的哪些位置插入通知，比如在哪些方法调用之前、之后、或者哪些方法抛出异常时进行增强。 4. 切面：Aspect，通知与切入点的结合 5.目标对象：Target，被 AOP 代理的对象，通知会作用到目标对象的对应方法上。 示例： @Slf4j @Component @Aspect public class MyAspect { @Before("execution(* edu.whut.zy123.service.MyService.doSomething(..))") public void beforeMethod(JoinPoint joinPoint) { // 连接点：目标方法执行位置 System.out.println("Before method: " + joinPoint.getSignature().getName()); } } joinPoint 代表的是 doSomething() 方法执行的连接点。 beforeMethod() 方法就是一个前置通知 "execution(* com.example.service.MyService.doSomething(..))"是切入点 MyAspect是切面。 com.example.service.MyService 类的实例是目标对象 通知类型 @Around：环绕通知。此通知会在目标方法前后都执行。 @Before：前置通知。此通知在目标方法执行之前执行。 @After ：后置通知。此通知在目标方法执行后执行，无论方法是否抛出异常。 @AfterReturning ： 返回后通知。此通知在目标方法正常返回后执行，发生异常时不会执行。 @AfterThrowing ： 异常后通知。此通知在目标方法抛出异常后执行。 在使用通知时的注意事项： @Around 通知必须调用 ProceedingJoinPoint.proceed() 才能执行目标方法，其他通知不需要。 @Around 通知的返回值必须是 Object 类型，用于接收原始方法的返回值。 只有@Around需要在通知中主动执行方法，其他通知只能获取目标方法的参数等。 通知执行顺序 默认情况下，不同切面类的通知执行顺序由类名的字母顺序决定。 可以通过 @Order 注解指定切面类的执行顺序，数字越小，优先级越高。 例如：@Order(1) 表示该切面类的通知优先执行。 @Aspect @Order(1) // 优先级1 @Component public class AspectOne { @Before("execution(* edu.whut.zy123.service.MyService.*(..))") public void beforeMethod() { System.out.println("AspectOne: Before method"); } } @Aspect @Order(2) // 优先级2 @Component public class AspectTwo { @Before("execution(* edu.whut.zy123.service.MyService.*(..))") public void beforeMethod() { System.out.println("AspectTwo: Before method"); } } 如果调用 MyService 中的某个方法,AspectOne切面类中的通知会先执行。 结论：目标方法前的通知方法，Order小的或者类名的字母顺序在前的先执行。 目标方法后的通知方法，Order小的或者类名的字母顺序在前的后执行。 相对于显式设置（Order）的通知，默认通知的优先级最低。 切入点表达式 作用：主要用来决定项目中的哪些方法需要加入通知 常见形式： execution(……)：根据方法的签名来匹配 @annotation(……) ：根据注解匹配 execution execution主要根据方法的返回值、包名、类名、方法名、方法参数等信息来匹配，语法为： execution(访问修饰符? 返回值 包名.类名.?方法名(方法参数) throws 异常?) 其中带?的表示可以省略的部分 访问修饰符：可省略（比如: public、protected） 包名.类名.： 可省略,但不建议 throws 异常：可省略（注意是方法上声明抛出的异常，不是实际抛出的异常） 示例: //如果希望匹配 public void delete(Integer id) @Before("execution(void edu.whut.zy123.service.impl.DeptServiceImpl.delete(java.lang.Integer))") //如果希望匹配 public void delete(int id) @Before("execution(void edu.whut.zy123.service.impl.DeptServiceImpl.delete(int))") 在 Pointcut 表达式中，为了确保匹配准确，通常建议对非基本数据类型使用全限定名。这意味着，对于像 Integer 这样的类，最好写成 java.lang.Integer 可以使用通配符描述切入点 * ：单个独立的任意符号，可以通配任意返回值、包名、类名、方法名、任意类型的一个参数，也可以通配包、类、方法名的一部分 execution(* edu.*.service.*.update*(*)) 这里update后面的'星'即通配方法名的一部分，() 中的'*'表示有且仅有一个任意参数 可以匹配： package edu.zju.service; public class UserService { public void updateUser(String username) { // 方法实现 } } .. ：多个连续的任意符号，可以通配任意层级的包，或任意类型、任意个数的参数 execution(* com.example.service.UserService.*(..)) 公共表示@Pointcut 使用 @Pointcut 注解可以将切点表达式提取到一个独立的方法中，提高代码复用性和可维护性。 @Aspect @Component public class LoggingAspect { // 定义一个切点，匹配com.example.service包下 UserService 类的所有方法 @Pointcut("execution(public * com.example.service.UserService.*(..))") public void userServiceMethods() { // 该方法仅用来作为切点标识，无需实现任何内容 } // 在目标方法执行前执行通知，引用上面的切点 @Before("userServiceMethods()") public void beforeUserServiceMethods() { System.out.println("【日志】即将执行 UserService 中的方法"); } } @annotation 在实际项目中，有时我们需要对多个方法（比如 list() 和 delete()）进行统一拦截，这些方法可能命名无规律、无法用 execution() 之类的表达式轻松匹配。 这时就可以： 给这些方法统一加一个自定义注解； 在 AOP 切面里用 @annotation(...) 表达式匹配这些方法； 这样写的切入点既简单又易维护。 实现步骤： ① 定义注解 import java.lang.annotation.*; @Retention(RetentionPolicy.RUNTIME) // 运行时可反射获取 @Target(ElementType.METHOD) // 只能标记方法 public @interface MyLog { String description() default "default description"; // 描述信息 int value() default 0; // 额外参数 } @Retention(RUNTIME) 保证运行时可以通过反射拿到注解。 @Target(METHOD) 限制只能用于方法。 ②在业务方法上加注解 @Service public class DeptService { @MyLog(description = "删除部门", value = 1) public void delete(Integer id) { deptMapper.delete(id); } @MyLog(description = "查询部门列表") public List<Dept> list() { return deptMapper.findAll(); } } ③定义切面 @Aspect @Component public class MyLogAspect { @Before("@annotation(myLog)") // 绑定注解对象到参数 public void before(JoinPoint joinPoint, MyLog myLog) { String methodName = joinPoint.getSignature().getName(); System.out.println("方法：" + methodName); System.out.println("注解描述：" + myLog.description()); System.out.println("注解值：" + myLog.value()); } } @annotation(myLog) 表示匹配所有带 @MyLog 的方法； myLog 参数 会直接被赋值为该方法上的注解实例，可以直接读取注解里的属性值； 不需要手动反射去找注解，Spring AOP 自动完成了注解解析和注入。 连接点JoinPoint 执行： ProceedingJoinPoint 和 JoinPoint 都是调用 proceed() 就会执行被代理的方法 Object result = joinPoint.proceed(); 获取调用方法时传递的参数 ,即使只有一个参数, 也以数组形式返回： Object[] args = joinPoint.getArgs(); getSignature(): 返回一个Signature类型的对象，这个对象包含了被拦截点的签名信息。在方法调用的上下文中，这包括了方法的名称、声明类型等信息。 方法名称：可以通过调用getName()方法获得。 声明类型：方法所在的类或接口的完全限定名，可以通过getDeclaringTypeName()方法获取。 返回类型（对于方法签名）：可以通过将Signature对象转换为更具体的MethodSignature类型，并调用getReturnType()方法获取。 WEB开发总体图