首页
关于
Search
1
同步本地Markdown至Typecho站点
55 阅读
2
微服务
38 阅读
3
苍穹外卖
27 阅读
4
JavaWeb——后端
24 阅读
5
消息队列MQ
18 阅读
后端学习
项目
杂项
科研
论文
默认分类
登录
找到
16
篇与
后端学习
相关的结果
- 第 2 页
2025-05-21
Mybatis&-Plus
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> <!-- Druid连接池依赖 --> <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>标签:就是用于编写select查询语句的。 resultType属性,指的是查询返回的单条记录所封装的类型(查询必须)。 parameterType属性(可选,MyBatis 会根据接口方法的入参类型(比如 Dish 或 DishPageQueryDTO)自动推断),POJO作为入参,需要使用全类名或是type‑aliases‑package: com.sky.entity 下注册的别名。 <insert id="insert" useGeneratedKeys="true" keyProperty="id"> <select id="pageQuery" resultType="com.sky.vo.DishVO"> <select id="list" resultType="com.sky.entity.Dish" parameterType="com.sky.entity.Dish"> 实现过程: resources下创与java下一样的包,即edu/whut/mapper,新建xx.xml文件 配置Mapper文件 <?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "https://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="edu.whut.mapper.EmpMapper"> <!-- SQL 查询语句写在这里 --> </mapper> namespace 属性指定了 Mapper 接口的全限定名(即包名 + 类名)。 编写查询语句 <select id="list" resultType="edu.whut.pojo.Emp"> select * from emp where name like concat('%',#{name},'%') and gender = #{gender} and entrydate between #{begin} and #{end} order by update_time desc </select> 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 id="list" resultType="com.itheima.pojo.Emp"> select * from emp <where> <!-- if做为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 </select> 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 id="queryUserByIdAndAddr" resultType="com.itheima.mp.domain.po.User"> 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} </select> 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)引入依赖 <!-- 数据库操作:https://mp.baomidou.com/ --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.9</version> </dependency> <!-- MyBatis Plus 分页插件 --> <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); } }
后端学习
zy123
5月21日
0
13
0
2025-04-26
微信小程序
微信小程序 转载自黑马程序员。 微信小程序开发 介绍 小程序是一种新的开放能力,开发者可以快速地开发一个小程序。可以在微信内被便捷地获取和传播,同时具有出色的使用体验。 **官方网址:**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> <button type="default" bindtap="getUserInfo">获取用户信息</button> <image style="width: 100px;height: 100px;" src="{{avatarUrl}}"></image> {{nickName}} </view> <view> <button type="primary" bindtap="wxlogin">微信登录</button> 授权码:{{code}} </view> <view> <button type="warn" bindtap="sendRequest">发送请求</button> 响应结果:{{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代表用户模块。
后端学习
zy123
4月26日
0
10
0
2025-04-17
Maven
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> <!-- 继承父 POM --> <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> <!-- 显式依赖于 MyProject2 --> <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> <!-- 第1个依赖 : logback --> <dependency> <groupId>ch.qos.logback</groupId> <artifactId>logback-classic</artifactId> <version>1.2.11</version> </dependency> <!-- 第2个依赖 : junit --> <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> <!-- Spring Boot Web Starter --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <version>2.7.3</version> </dependency> <!-- Lombok --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.20</version> </dependency> </dependencies> </dependencyManagement> <!-- 2. 所有模块都需要的“公共依赖”放这里,子模块自动继承 --> <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> <!-- 继承父 POM --> <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> <!-- 2. lombok 已经在父 POM 顶层 dependencies 引入,这里如果要用,也可不再声明 --> </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 放在项目根目录下
后端学习
zy123
4月17日
0
5
0
2025-04-03
JAVA面试题
JAVA基础 Java 面向对象的四大特性 封装(Encapsulation) 含义:把数据和操作数据的方法封装在对象内部,对外只暴露必要的接口,隐藏实现细节。 好处:降低耦合、提高安全性、便于维护。 继承(Inheritance) 含义:子类继承父类,复用代码,同时可以扩展或重写父类功能。 好处:提高代码复用性,建立层次结构。 多态(Polymorphism) 含义:同一方法调用在不同对象上表现出不同的行为(编译时多态/运行时多态)。 好处:增强灵活性、可扩展性。 抽象(Abstraction) 含义:只关注核心特征,隐藏非必要细节。通过接口和抽象类来实现。 好处:定义规范,便于扩展和解耦。 在框架层面的应用 封装: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)。 抽象:MyBatis:SqlSession、Executor等都是接口。你只需要和接口交互,无需关心底层是连接哪个数据库、如何执行SQL的。 说说 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 对象里,而不是每次都重新算。 Java 中 ConcurrentHashMap 1.7 和 1.8 之间有哪些区别? ConcurrentHashMap不允许null的key和value ConcurrentHashMap 不同JDK版本的实现对比 数据结构 JDK1.7: 使用 Segment(分段锁) + HashEntry数组 + 链表 的数据结构 JDK1.8及之后: 使用 数组 + 链表/红黑树 的数据结构(与HashMap类似) 锁的类型与宽度 JDK1.7: 分段锁(Segment)继承了 ReentrantLock Segment容量默认16,不会扩容 → 默认支持16个线程并发访问 JDK1.8: 使用 synchronized + CAS 保证线程安全 空节点:通过CAS添加(put操作,多个线程可能同时想要将一个新的键值对插入到同一个桶中,这时它们会使用 CAS 来比较当前桶中的元素(或节点)是否已经被修改过。) 非空节点:通过synchronized加锁,只锁住该桶,其他桶可以并行访问。 渐进式扩容(JDK1.8+) 触发条件:元素数量 ≥ 数组容量 × 负载因子(默认0.75) 扩容过程: 创建2倍大小的新数组 线程操作数据时,逐步迁移旧数组数据到新数组 使用 transferIndex 标记迁移进度 直到旧数组数据完全迁移完成 在 Java 中,== 和 equals 的区别是什么? 基本数据类型 ==:比较的是 值。 equals:基本数据类型(int、double、char 等)不是对象,没有 equals 方法,所以只能用 == 比较。 引用数据类型(对象)以及集合类 ==:比较的是 引用是否相同,即两个变量是否指向同一个对象。 equals:默认继承自 Object.equals,比较地址;但很多类(如 String、Integer、List)都 重写了 equals 方法,用于比较对象的 值。 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() 方法,得到哈希值。 定位桶(bucket) 根据哈希值取模,找到可能存放该对象的 桶。 如果 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); } } 这样,下面的代码才会返回 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; } JAVA异常体系 两大核心子类:Error 和 Exception Error (错误)指程序无法处理的严重问题,通常与代码编写无关,而是JVM运行时本身出现的错误。应用程序不应该尝试捕获(catch) 这些错误,因为通常无法恢复。 Exception(异常)指程序本身可以处理的问题,是开发者需要关心的异常。 接口变慢的排查思路 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、内存、磁盘、网络 等) Stream流 Java 8 引入的 Stream API 是函数式编程风格的集合操作工具,能让你用声明式写法来处理数据。 1.创建流 List<String> list = List.of("a","b","c"); Stream<String> s = list.stream(); 2.中间操作(常用的) filter:过滤 list.stream().filter(x -> x.startsWith("a")) map:转换 list.stream().map(String::toUpperCase) sorted:排序 list.stream().sorted() distinct:去重 list.stream().distinct() limit / skip:截取 list.stream().skip(1).limit(2) 终止操作(常用的) forEach:遍历 list.stream().forEach(System.out::println); collect:收集 List<String> res = list.stream().collect(Collectors.toList()); count:计数 long c = list.stream().count(); anyMatch / allMatch:匹配判断 boolean hasA = list.stream().anyMatch(x -> x.equals("a")); reduce:聚合(比如求和) int sum = List.of(1,2,3).stream().reduce(0, Integer::sum); 示例: 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() // 排序 .map(String::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 二进制序列化和 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); JUC多线程并发 volatile 在 Java 中,volatile 是一个 轻量级同步机制,主要有两个作用: 保证可见性:当一个线程修改了 volatile 变量的值,其他线程能立即“看到”最新值。 禁止指令重排序:保证对该变量的读写操作不会和前后的指令发生重排序,从而在多线程下避免出现“乱序执行”的问题。 原理: JVM 底层通过 内存屏障(Memory Barrier) 来实现这两个效果: 可见性: volatile 写操作 → 编译器在生成的字节码对应的机器指令后面会插入一条 store 屏障,强制把本地工作内存的值刷新到主内存。 volatile 读操作 → 在前面会插入一条 load 屏障,强制从主内存读取最新的值。 禁止重排序: 内存屏障同时约束了编译器和 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 每个线程(Thread)内部维护一个私有变量 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())。 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(); // 所有等待的线程同时被唤醒 如何多线程循环打印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是可重入锁,可重入是指:同一个线程可以重复获取自己已经持有的锁,不会死锁。 lock.lock(); lock.lock(); try { // 重入两次,只有 unlock 两次才能释放 } finally { lock.unlock(); lock.unlock(); } 但它是独占锁,同一时刻只允许一个线程持有锁。 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) 时间复杂度的节点移除。 JAVA高级 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 顺序。 锁规则:解锁 happens-before 后续的加锁。 volatile 规则:对 volatile 变量的写 happens-before 后续的读。 线程启动与终止规则:线程 start() happens-before 线程中所有操作;线程中所有操作 happens-before 另一个线程成功从 join() 返回。 即主线程在 start() 之前对共享变量的写入,对子线程是可见的。 元空间 元空间(Metaspace)是 JDK8 之后用来存储类的元信息(class metadata)的内存区域(方法区),位于本地内存(Native Memory)中。在 JDK 8 之后,它取代了以前的 永久代(PermGen) 什么是类的元数据 元信息内容 举例说明 类的名称、修饰符 public class User 常量池(符号引用) 字面值、字符串常量等 方法信息 方法签名、参数表、字节码等 字段信息 类型、修饰符、偏移量等 方法表、接口表、虚方法表 虚方法调用的支持机制 用 元空间(Metaspace) 替代永久代 对比维度 永久代(PermGen) 元空间(Metaspace) 存储位置 JVM 堆内 本地内存(Native memory) 是否固定大小 是,需手动指定 否,默认按需增长 容易 OOM 吗? 是,类多时易崩 少见,但可用参数限制防爆 是否会被 GC 扫描 会 一般不会(但类卸载可手动触发) 从哪个版本开始移除 JDK8 JDK8 引入 如何排查 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 类加载过程 类加载过程分为 加载(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)是你手动编写的,编译前就已经存在。 静态代理 静态代理 是一种在编译期就已经确定的代理模式,它通过手动编写一个代理类,在不修改原始类代码的情况下,增强或控制目标对象的行为。 示例: 假设我们有一个 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()时生成,后续调用复用已生成的类。 你会怎样设计一个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 作为网络通信框架。 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是否“可写”,以高效地完成发送任务。 Spring Spring Bean 生命周期与 AOP 代理生成流程 类加载(JVM 层面):负责把 .class 文件加载进内存,生成 Class 对象。Spring 要使用某个类(比如反射扫描注解)时,前提是这个类已经被 JVM 加载过了。 Bean 定义加载(Spring 层面):基于 JVM 提供的 Class 对象,Spring 读取类的注解/配置,把信息整理成 BeanDefinition,存到容器里,等待后续实例化。 本笔记以 UserService为例,详细说明一个 Bean 从定义到成为代理对象的完整过程。 第一部分:Bean 定义加载(容器启动阶段) 发生在容器启动时,旨在收集所有 Bean 的元数据(BeanDefinition)。 【配置解析】:容器解析所有配置源(如 XML 文件、Java Config 类、注解等)。 【组件扫描】:ClassPathBeanDefinitionScanner扫描指定的包路径,识别带有 @Component、@Service等注解的类。 【注册定义】:将为每个 Bean 解析出的元信息(如类名、作用域、是否懒加载等)封装为一个 BeanDefinition对象,并注册到 BeanFactory的 beanDefinitionMap中。 - class = UserService.class - scope = singleton - lazy = false 此时的状态:UserService类已经被类加载器加载,JVM中有它的 Class对象,Spring容器中有它的 BeanDefinition,但尚未创建任何实例,也未生成任何代理。 第二部分:Bean 实例化与代理生成(按需触发) 当容器需要某个 Bean 实例时(例如遇到依赖注入,或初始化非懒加载的单例 Bean),将按以下步骤创建。AOP 代理在此过程中生成。 序号 阶段 说明 关键点 1 实例化 (Instantiation) 调用构造函数,创建 Bean 的原始对象。 UserService rawInstance = new UserServiceImpl(); 此时是原始对象,非代理。 2 依赖注入 (Population) 按照注解(如 @Autowired)或配置,为原始对象注入其所需的依赖。 3 BeanPostProcessor 前置处理 执行所有 BeanPostProcessor的 postProcessBeforeInitialization 方法。 可用于修改 Bean 实例,此时仍是原始对象。 4 初始化 (Initialization) 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。 Spring Boot 根据类路径依赖,动态加载相应的配置类(如 spring-boot-autoconfigure 中的模块)。 条件化配置 根据环境(存在某依赖、系统属性、条件注解 @ConditionalOnXxx)决定是否启用配置。 实例化与依赖注入 按需实例化 Bean(new 出对象)。 执行依赖注入(@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: 一级缓存 singletonObjects:存放完全初始化好的、成熟的Bean。这是我们实际用到Bean的地方。 二级缓存 earlySingletonObjects:存放早期的Bean引用(已实例化但未完成属性填充和初始化)。 三级缓存 singletonFactories:存放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 管理的代理对象调用,事务生效 } } 二、方法不是 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 引擎),即使开启事务也不会生效。 计算机网络 三次握手 三次握手过程 TCP三次握手的目标是双方同步序列号(Sequence Number, SN) 和确认序列号(Acknowledgment Number, AN),并建立一条可靠的连接。 假设客户端(Client)想要与服务器(Server)建立连接。 第一步:SYN (Synchronize) 动作:客户端发送一个TCP报文段(Segment)。 标志位:设置 SYN = 1(这是一个连接请求)。 序列号:客户端随机选择一个初始序列号 seq = x。 状态变化:客户端进入 SYN-SENT状态。 第二步:SYN-ACK (Synchronize-Acknowledge) 动作:服务器收到客户端的SYN报文后,如果同意建立连接,则回复一个报文段。 标志位:设置 SYN = 1和 ACK = 1。 序列号:服务器随机选择自己的初始序列号 seq = y。 确认号:设置 ack = x + 1(表明服务器已经收到了客户端的序列号x,期望下次从x+1开始接收数据)。 状态变化:服务器进入 SYN-RCVD状态。 第三步:ACK (Acknowledge) 动作:客户端收到服务器的SYN-ACK报文后,必须再回复一个确认报文。 标志位:设置 ACK = 1。 序列号:seq = x + 1(因为第一次握手发送的序列号是x,这次是下一个数据字节的序列号)。 确认号:设置 ack = y + 1(表明客户端已经收到了服务器的序列号y,期望下次从y+1开始接收数据)。 状态变化:客户端发送完此报文后进入 ESTABLISHED状态。服务器收到这个ACK报文后,也进入 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是无连接的、不可靠的协议,不做任何保证,但速度快、延迟低。 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 <html> ... </html> 4.浏览器解析 HTML 解析 HTML 时,发现里面引用了 CSS、JS、图片 等静态资源。 浏览器会发起新的请求去获取这些资源。 5.并发请求资源 HTTP/1.1 一个 TCP 连接虽然可以复用,但请求必须顺序执行 → 存在队头阻塞。 为了提高并发性能,浏览器会:同时打开多个 TCP 连接(6~8 个/域名),每个连接里顺序下载多个资源; 6.页面渲染 CSS、JS、图片加载完成后,浏览器开始渲染页面。JS 可能会再发起 Ajax 请求(同样走 HTTP/1.1 长连接)。 数据库Mysql 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)。 关键总结!!! 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 必须在叶子节点做,因为只有叶子节点才有“完整的、逐条的索引值”。 场景题:设计索引 表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 的物理顺序由你建索引时决定,所以仍需遵循“等值在前、范围其后”的建模原则。 场景题:统计字段中xx出现的次数 在 SQL 里,没有直接的“数某个字符出现次数”的函数。 但我们可以用 字符串长度差 来实现: LENGTH(content):原始字符串长度。 LENGTH(REPLACE(content, ',', '')):把逗号都去掉之后的长度。 两者之差,就是逗号的个数。 SELECT id, content, LENGTH(content) - LENGTH(REPLACE(content, ',', '')) AS num FROM information; 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 时它已经提交。 所以这个版本 可见。 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:连表带结构一起删。 MyBatis 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 id="queryGroupBuyProgress" resultType="GroupBuyOrder"> SELECT * FROM group_buy WHERE team_id = #{teamId} </select> </mapper> 特点: 多个 SqlSession 可以共享缓存 缓存数据存储在内存中,可以配置存储到硬盘或其他存储介质 执行 INSERT/UPDATE/DELETE 操作会清空对应命名空间的二级缓存 缓存工作流程 查询顺序:二级缓存 → 一级缓存 → 数据库 更新顺序:数据库 → 清空相关缓存 二级缓存的数据不一致情况: SqlSession1 读取了数据A,并将其缓存 SqlSession2 将数据更新为B(这会清除二级缓存) 但当SqlSession1再次读取时,如果配置不当,可能仍然从自己的缓存中获取到旧的A数据 Redis Redis主从集群 数据备份、适合读多写少的场景 主节点(Master):负责写操作和数据复制。 从节点(Slave):负责读操作,数据由主节点异步复制过来。 1)如果判断redis从节点是第一次来建立连接? 根据replid,没建立集群时,每个节点都认为自己是主节点,有自己的replid;如果从节点A slaveof B,那么节点A和B的replid会改为全新的且一样的replid。这样,master节点只要看请求过来的replid和自己是否一样,如果一样就不是第一次建立;如果不一样就是第一次建立连接。 2)第一次来建立连接,怎么全量复制? 主节点执行 BGSAVE,生成一个 RDB 快照文件。 主节点将 RDB 文件发送给从节点,从节点清空现有数据,加载 RDB。 3)Slave如果宕机,少执行一些命令咋办? 有个backlog记录master第一次与从节点建立连接后所执行的所有命令。增量复制,主节点和从节点都维护一个复制偏移量 offset。如果从节点掉线时间不长,重新连上时会带上自己已同步的 offset,主节点只发送缺失的那部分数据。 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。 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)之间可以并行处理,不要求顺序 核心思想: 对业务 key 做哈希,分片(shard) 每个 shard 对应一个 专属队列 每个队列只绑定1 个消费者(线程) 消息自然按顺序进入、顺序消费 这样,同一个 key 一定总是落到同一个 shard → 同一个 queue → 同一个消费者 → 顺序 ✅ 举例:假设你要处理 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 处理,互不影响 背压机制 背压是一种 流量控制机制,当下游(消费者 / 处理器)处理不过来时,通知上游(生产者)减速或暂停,从而避免系统过载。 常见的背压机制 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'
后端学习
zy123
4月3日
0
8
0
2025-03-21
JavaWeb——后端
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原理 容器启动 在 Spring 框架中,“容器启动”指的是 ApplicationContext 初始化过程,主要包括配置解析、加载 Bean 定义、实例化和初始化 Bean 以及完成依赖注入。具体来说,容器启动的时机包括以下几个关键点: 当你启动一个 Spring 应用时,无论是通过直接运行一个包含 main 方法的类,还是部署到一个 Servlet 容器中,Spring 的应用上下文都会被创建和初始化。这个过程包括: 读取配置:加载配置文件或注解中指定的信息,确定哪些组件由 Spring 管理。 注册 Bean 定义:将所有扫描到的 Bean 定义注册到容器中。 实例化 Bean:根据 Bean 定义创建实例。默认情况下,所有单例 Bean在启动时被创建(除非配置为懒加载)。 依赖注入:解析 Bean 之间的依赖关系,并自动注入相应的依赖。 配置文件 配置优先级 在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) @Autowired 自动将 Spring 创建的 ApplicationContext 注入到 applicationContext 字段中, 再通过 applicationContext.getBean(...) 拿到其他 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的,就需要用到**@Bean**注解。 如果需要定义第三方Bean时, 通常会单独定义一个配置类 @Configuration // 配置类 public class CommonConfig { // 定义第三方 Bean,并交给 IoC 容器管理 @Bean public SAXReader reader(DeptService deptService) { System.out.println(deptService); return new SAXReader(); } } 在应用启动时,Spring 会调用配置类中标注 @Bean 的方法,将方法返回值注册为容器中的 Bean 对象。 默认情况下,该 Bean 的名称就是该方法的名字。本例 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 ... } } SpirngBoot原理 如果我们直接基于Spring框架进行项目的开发,会比较繁琐。SpringBoot框架之所以使用起来更简单更快捷,是因为SpringBoot框架底层提供了两个非常重要的功能:一个是起步依赖,一个是自动配置。 起步依赖 Spring Boot 只需要引入一个起步依赖(例如 springboot-starter-web)就能满足项目开发需求。这是因为: Maven 依赖传递: 起步依赖内部已经包含了开发所需的常见依赖(如 JSON 解析、Web、WebMVC、Tomcat 等),无需开发者手动引入其它依赖。 结论: 起步依赖的核心原理就是 Maven 的依赖传递机制。 自动配置 Spring Boot 会自动扫描启动类所在包及其子包中的所有带有组件注解(如 @Component, @Service, @Repository, @Controller, @Mapper 等)的类并加载到IOC容器中。 自动配置原理源码入口就是@SpringBootApplication注解,在这个注解中封装了3个注解,分别是: @SpringBootConfiguration 声明当前类是一个配置类,等价于 @Configuration又与之区分 @ComponentScan 进行组件扫描。如果你的项目有server pojo common模块,启动类在com.your.package.server下,那么只会默认扫描com.your.package及其子包。 @ComponentScan({"com.your.package.server", "com.your.package.common"})可以显示指定扫描的包路径。 @EnableAutoConfiguration(自动配置核心注解,下节详解) 自动配置的效果: 在IOC容器中除了我们自己定义的bean以外,还有很多配置类,这些配置类都是SpringBoot在启动的时候加载进来的配置类。这些配置类加载进来之后,它也会生成很多的bean对象。 当我们想要使用这些配置类中生成的bean对象时,可以使用@Autowired就自动注入了。 如何让第三方bean以及配置类生效? 如果配置类(如 CommonConfig)不在 Spring Boot 启动类的扫描路径内(即不在启动类所在包或其子包下): 1.@ComponentScan添加包扫描路径,适合批量导入(繁琐、性能低) 2.通过 @Import 手动导入该配置类。适合精确导入,如: com └── example └── SpringBootApplication.java // 启动类 src └── com └── config └── CommonConfig.java // 配置类 借助 @Import 注解,我们可以将外部的普通类、配置类或实现了 ImportSelector 的类显式导入到 Spring 容器中。 也就是这些类会加载到IOC容器中。 1.使用@Import导入普通类: 如果某个普通类(如 TokenParser)没有 @Component 注解标识,也可以通过 @Import 导入它,使其成为 Spring 管理的 Bean。 // TokenParser 类没有 @Component 注解 public class TokenParser { public void parse(){ System.out.println("TokenParser ... parse ..."); } } 在启动类上使用 @Import 导入: @Import(TokenParser.class) //导入的类会被Spring加载到IOC容器中 @SpringBootApplication public class SpringbootWebConfig2Application { public static void main(String[] args) { SpringApplication.run(SpringbootWebConfig2Application.class, args); } } 2.使用@Import导入配置类: 配置类中可以定义多个 Bean,通过 @Configuration 和 @Bean 注解实现集中管理。 @Configuration public class HeaderConfig { @Bean public HeaderParser headerParser(){ return new HeaderParser(); } @Bean public HeaderGenerator headerGenerator(){ return new HeaderGenerator(); } } 启动类导入配置类: @Import(HeaderConfig.class) //导入配置类 @SpringBootApplication public class SpringbootWebConfig2Application { public static void main(String[] args) { SpringApplication.run(SpringbootWebConfig2Application.class, args); } } 3.使用第三方依赖@EnableXxxx 注解 如果第三方依赖没有提供自动配置支持, 常见方案是第三方依赖提供一个 @EnableXxxx 注解,这个注解内部封装了 @Import,通过它可以一次性导入多个配置或 Bean。 @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) @Import(MyImportSelector.class)//指定要导入哪些bean对象或配置类 public @interface EnableHeaderConfig { } 在应用启动类上添加第三方依赖提供的 @EnableHeaderConfig 注解,即可导入相关的配置和 Bean。 @EnableHeaderConfig //使用第三方依赖提供的Enable开头的注解 @SpringBootApplication public class SpringbootWebConfig2Application { public static void main(String[] args) { SpringApplication.run(SpringbootWebConfig2Application.class, args); } } 推荐第三种方式! @EnableAutoConfiguration 导入自动配置类 通过元注解 @Import(AutoConfigurationImportSelector.class),在启动时读取所有 JAR 包中 META‑INF/spring.factories 下 EnableAutoConfiguration 对应的自动配置类列表。 将这些配置类当作 @Configuration 导入到 Spring 容器中。 按条件注册 Bean 自动配置类内部使用多种条件注解(如 @ConditionalOnClass、@ConditionalOnMissingBean、@ConditionalOnProperty 等)。 Spring Boot 会检查当前类路径、配置属性和已有 Bean,仅在满足所有条件时,才执行对应的 @Bean 方法,将组件注入 IOC 容器。 @ComponentScan 用于发现和加载应用自身的组件; @EnableAutoConfiguration 则负责加载 Spring Boot 提供的“开箱即用”配置。如: DataSourceAutoConfiguration 检测到常见的 JDBC 驱动(如 HikariCP、Tomcat JDBC)和配置属性(spring.datasource.*)时,自动创建并配置 DataSource。、 WebMvcAutoConfiguration自动配置 Spring MVC 的核心组件,并启用默认的静态资源映射、消息转换器(Jackson JSON)等。但遇到用户自定义的 MVC 支持配置(如继承 WebMvcConfigurationSupport )时会“失效”(Back Off)因为其内部有个注解:@ConditionalOnMissingBean(WebMvcConfigurationSupport.class),一旦容器内有xx类型注解,默认配置自动失效。 常见的注解!! @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文件中引入依赖 <!-- 在springboot的父工程中,已经集成了lombok并指定了版本号,故当前引入依赖时不需要指定version --> <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开发总体图
后端学习
zy123
3月21日
0
24
0
上一页
1
2
3
4
下一页