首页
关于
Search
1
微服务
34 阅读
2
同步本地Markdown至Typecho站点
29 阅读
3
JavaWeb——后端
18 阅读
4
苍穹外卖
14 阅读
5
智能协同云图库
13 阅读
后端学习
项目
杂项
科研
论文
默认分类
登录
热门文章
34 ℃
微服务
后端学习
3月21日
0
34
0
29 ℃
同步本地Markdown至Typecho站点
项目
3月22日
0
29
0
18 ℃
JavaWeb——后端
后端学习
3月21日
0
18
0
最新发布
2025-06-07
智能协同云图库
智能协同云图库 待完善功能: 用户模块扩展功能: 2.JWT校验,可能要同时改前端,把userId保存到ThreadLocal中 3.目前这些标签写死了,可以用redis、数据库进行动态设置。(根据点击次数) @GetMapping("/tag_category") public BaseResponse<PictureTagCategory> listPictureTagCategory() { PictureTagCategory pictureTagCategory = new PictureTagCategory(); List<String> tagList = Arrays.asList("热门", "搞笑", "生活", "高清", "艺术", "校园", "背景", "简历", "创意"); List<String> categoryList = Arrays.asList("模板", "电商", "表情包", "素材", "海报"); pictureTagCategory.setTagList(tagList); pictureTagCategory.setCategoryList(categoryList); return ResultUtils.success(pictureTagCategory); } 4.图片审核扩展 5.爬图扩展 2)记录从哪里爬的 4)bing直接搜可能也是缩略图,可能模拟手点一次图片,再爬会清晰一点 6.缓存扩展 图片压缩 文件秒传,md5校验,如果已有,直接返回url,不用重新上传(图片场景不必使用) 分片上传和断点续传:对象存储 上传对象_腾讯云 CDN内容分发,后期项目上线之后搞一下。 浏览器缓存 是服务器(或 CDN/静态文件服务器)在返回资源时下发给浏览器的。 用户空间扩展: 图片编辑 AI扩图 创建图片的业务流程 创建图片主要是包括两个过程:第一个过程是上传图片文件本身,第二个过程是将图片信息上传到数据库。 有两种常见的处理方式: 1.先上传再提交数据(大多数的处理方式):用户直接上传图片,系统自动生成图片的url存储地址;然后在用户填写其它相关信息并提交后才将图片记录保存到数据库中。 2.上传图片时直接记录图片信息:云图库平台中图片作为核心资源,只要用户将图片上传成功就应该把这个图片上传到数据库中(即用户上传图片后系统应该立即生成图片的完整数据记录和其它元信息,这里元信息指的是图片的一些基础信息,这些信息应该是在图片上传成功后就能够解析出来),无需等待用户上传提交图片信息就会立即存入数据库中,这样会使整个交互过程更加轻量。这样的话用户只需要再上传图片的其它信息即可,这样就相当于用户对已有的图片信息进行编辑。 当然我们也可以对用户进行一些限制,比如说当用户上传过多的图片资源时就禁止该用户继续上传图片资源。 优化 协同编辑: 扩展 1、为防止消息丢失,可以使用 Redis 等高性能存储保存执行的操作记录。 目前如果图片已经被编辑了,新用户加入编辑时没办法查看到已编辑的状态,这一点也可以利用 Redis 保存操作记录来解决,新用户加入编辑时读取 Redis 的操作记录即可。 2、每种类型的消息处理可以封装为独立的 Handler 处理器类,也就是采用策略模式。 3、支持分布式 WebSocket。实现思路很简单,只需要保证要编辑同一图片的用户连接的是相同的服务器即可,和游戏分服务器大区、聊天室分房间是类似的原理。 4、一些小问题的优化:比如 WebSocket 连接建立之后,如果用户退出了登录,这时 WebSocket 的连接是没有断开的。不过影响并不大,可以思考下怎么处理。 收获 MybatisX插件简化开发 下载MybatisX插件,可以从数据表直接生成Bean、Mapper、Service,选项设置如下: 注意,勾选Actual Column生成的Bean和表中字段一模一样,取消勾选会进行驼峰转换,即user_name->userName 下载GenerateSerailVersionUID插件,可以右键->generate->生成序列ID: private static final long serialVersionUID = -1321880859645675653L; 胡图工具类hutool 引入依赖 <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.8.26</version> </dependency> ObjUtil.isNotNull(Object obj),仅判断对象是否 不为 null,不关心对象内容是否为空,比如空字符串 ""、空集合 []、数字 0 等都算是“非 null”。 ObjUtil.isNotEmpty(Object obj) 判断对象是否 不为 null 且非“空” 对不同类型的对象判断逻辑不同: CharSequence(String):长度大于 0 Collection:size > 0 Map:非空 Array:长度 > 0 其它对象:只判断是否为 null(默认不认为“空”) StrUtil.isNotEmpty(String str) 只要不是 null 且长度大于 0 就算“非空”。 StrUtil.isNotBlank(String str) 不仅要非 null,还要不能只包含空格、换行、Tab 等空白字符 StrUtil.hasBlank(CharSequence... strs)只要 **至少一个字符串是 blank(空或纯空格)**就返回 true,底层其实就是对每个参数调用 StrUtil.isBlank(...) CollUtil.isNotEmpty(Collection<?> coll)用于判断 集合(Collection)是否非空,功能类似于 ObjUtil.isNotEmpty(...) BeanUtil.toBean :用来把一个 Map、JSONObject 或者另一个对象快速转换成你的目标 JavaBean public class BeanUtilExample { public static class User { private String name; private Integer age; // 省略 getter/setter } public static void main(String[] args) { // 1. 从 Map 转 Bean Map<String, Object> data = new HashMap<>(); data.put("name", "Alice"); data.put("age", 30); User user1 = BeanUtil.toBean(data, User.class); System.out.println(user1.getName()); // Alice // 2. 从另一个对象转 Bean class Temp { public String name = "Bob"; public int age = 25; } Temp temp = new Temp(); User user2 = BeanUtil.toBean(temp, User.class); System.out.println(user2.getAge()); // 25 } } 多级缓存 Redis+Session 之前我们每次重启服务器都要重新登陆,既然已经整合了 Redis,不妨使用 Redis 管理 Session,更好地维护登录态。 1)先在 Maven 中引入 spring-session-data-redis 库: <!-- Spring Session + Redis --> <dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session-data-redis</artifactId> </dependency> 2)修改 application.yml 配置文件,更改Session的存储方式和过期时间: 既要设置redis能存30天,发给前端的cookie也要30天有效期。 spring: # session 配置 session: store-type: redis # session 30 天过期 timeout: 2592000 server: port: 8123 servlet: context-path: /api # cookie 30 天过期 session: cookie: max-age: 2592000 为什么用 ConcurrentHashMap<Long,Object> 管理锁更优? 避免污染常量池 String.intern() 会把每一个不同的 userId 字符串都放到 JVM 的字符串常量池里,随着用户量增长,常量池里的内容会越来越多,可能导致元空间(MetaSpace)/永久代(PermGen)压力过大。 显式可控的锁生命周期 用 ConcurrentHashMap 明确地管理——「只要 map 里有这个 key,就有对应的锁对象;不需要时可以删掉。」 相比之下,intern() 后的字符串对象由 JVM 常量池管理,代码里很难清理,存在内存泄漏风险。 高并发性能更好 ConcurrentHashMap 内部采用分段锁或 Node 锁定(取决于 JDK 版本),即便高并发下往 map 里 computeIfAbsent 也能保持较高吞吐。 synchronized (lock) 本身只锁定单个用户对应的那把锁,不影响其他用户;结合 ConcurrentHashMap 的高并发特性,整体性能比直接在一个全局 HashMap + synchronized 好得多。 锁+事务可能出现的问题 @Transactional(声明式) 事务在方法入口打开,很可能在拿锁前就占用连接/数据库资源,导致“空跑事务”+“资源耗尽”。 依赖代理,存在自调用失效的坑。 transactionTemplate.execute()(编程式) 锁先行→事务后发,确保高并发下只有一个连接/事务进数据库,极大降低资源竞争。 全程显式,放到哪儿就是哪儿,杜绝自调用/代理链带来的隐患。 锁+事务@Transactional一起可能出现问题: 线程 A 进入方法,Spring AOP 拦截,立即开启事务 走到 synchronized(lock),拿到锁 在锁里执行 exists → save(但真正的 “提交” 要等到方法返回后才做) 退出 synchronized 块,方法继续执行(其实已经没别的逻辑了) 方法返回,事务拦截器这时才 提交 线程 B(并发进来) 等待 AOP 代理,进入同一个方法,也会马上开启自己的事务 在入口就拿到一个新的连接/事务上下文 然后遇到 synchronized(lock),在这里阻塞 等 A 释放锁 A 一旦走出 synchronized,B 立刻拿到锁——但此时 A 还没真正提交(提交在方法尾被拦截器做) B 在锁里执行 exists:因为 A 的改动还在 A 的未提交事务里,默认隔离级别(READ_COMMITTED)下看不到,所以 exists 会返回 false B 就继续 save,结果就可能插入重复记录,或者引发唯一索引冲突 团队空间 空间和用户是多对多的关系,还要同时记录用户在某空间的角色,所以需要新建关联表 -- 空间成员表 create table if not exists space_user ( id bigint auto_increment comment 'id' primary key, spaceId bigint not null comment '空间 id', userId bigint not null comment '用户 id', spaceRole varchar(128) default 'viewer' null comment '空间角色:viewer/editor/admin', createTime datetime default CURRENT_TIMESTAMP not null comment '创建时间', updateTime datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间', -- 索引设计 UNIQUE KEY uk_spaceId_userId (spaceId, userId), -- 唯一索引,用户在一个空间中只能有一个角色 INDEX idx_spaceId (spaceId), -- 提升按空间查询的性能 INDEX idx_userId (userId) -- 提升按用户查询的性能 ) comment '空间用户关联' collate = utf8mb4_unicode_ci; RBAC模型 团队空间: 一般来说,标准的 RBAC 实现需要 5 张表:用户表、角色表、权限表、用户角色关联表、角色权限关联表,还是有一定开发成本的。由于我们的项目中,团队空间不需要那么多角色,可以简化RBAC 的实现方式,比如将角色和权限直接定义到配置文件中。 本项目角色: 角色 描述 浏览者 仅可查看空间中的图片内容 编辑者 可查看、上传和编辑图片内容 管理员 拥有管理空间和成员的所有权限 本项目权限: 权限键 功能名称 描述 spaceUsername 成员管理 管理空间成员,添加或移除成员 picture:view 查看图片 查看空间中的图片内容 picture:upload 上传图片 上传图片到空间中 picture:edit 修改图片 编辑已上传的图片信息 picture:delete 删除图片 删除空间中的图片 角色权限映射: 角色 对应权限键 可执行功能 浏览者 picture:view 查看图片 编辑者 picture:view, picture:upload, picture:edit, picture:delete 查看图片、上传图片、修改图片、删除图片 管理员 spaceUsername, picture:view, picture:upload, picture:edit, picture:delete 成员管理、查看图片、上传图片、修改图片、删除图片 RBAC 只是一种权限设计模型,我们在 Java 代码中如何实现权限校验呢? 1)最直接的方案是像之前校验私有空间权限一样,封装个团队空间的权限校验方法;或者类似用户权限校验一样,写个注解 + AOP 切面。 2)对于复杂的角色和权限管理,可以选用现成的第三方权限校验框架来实现,编写一套权限校验规则代码后,就能整体管理系统的权限校验逻辑了。( Sa-Token) Sa-Token 快速入门 1)引入: <!-- Sa-Token 权限认证 --> <dependency> <groupId>cn.dev33</groupId> <artifactId>sa-token-spring-boot-starter</artifactId> <version>1.39.0</version> </dependency> 2)让 Sa-Token 整合 Redis,将用户的登录态等内容保存在 Redis 中。 <!-- Sa-Token 整合 Redis (使用 jackson 序列化方式) --> <dependency> <groupId>cn.dev33</groupId> <artifactId>sa-token-redis-jackson</artifactId> <version>1.39.0</version> </dependency> <!-- 提供Redis连接池 --> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> </dependency> 3)基本用法 StpUtil 是 Sa-Token 提供的全局静态工具。 用户登录时调用 login 方法,产生一个新的会话: StpUtil.login(10001); 还可以给会话保存一些信息,比如登录用户的信息: StpUtil.getSession().set("user", user) 接下来就可以判断用户是否登录、获取用户信息了,可以通过代码进行判断: // 检验当前会话是否已经登录, 如果未登录,则抛出异常:`NotLoginException` StpUtil.checkLogin(); // 获取用户信息 StpUtil.getSession().get("user"); 也可以参考 官方文档,使用注解进行鉴权: // 登录校验:只有登录之后才能进入该方法 @SaCheckLogin @RequestMapping("info") public String info() { return "查询用户信息"; } 多账号体系 若项目中存在两套权限校验体系。一套是 user 表的,分为普通用户和管理员;另一套是对团队空间的权限进行校验。 为了更轻松地扩展项目,减少对原有代码的改动,我们原有的 user 表权限校验依然使用自定义注解 + AOP 的方式实现。而团队空间权限校验,采用 Sa-Token 来管理。 这种同一项目有多账号体系的情况下,不建议使用 Sa-Token 默认的账号体系,而是使用 Sa-Token 提供的多账号认证特性,可以将多套账号的授权给区分开,让它们互不干扰。 使用 Kit 模式 实现多账号认证 /** * StpLogic 门面类,管理项目中所有的 StpLogic 账号体系 * 添加 @Component 注解的目的是确保静态属性 DEFAULT 和 SPACE 被初始化 */ @Component public class StpKit { public static final String SPACE_TYPE = "space"; /** * 默认原生会话对象,项目中目前没使用到 */ public static final StpLogic DEFAULT = StpUtil.stpLogic; /** * Space 会话对象,管理 Space 表所有账号的登录、权限认证 */ public static final StpLogic SPACE = new StpLogic(SPACE_TYPE); } 修改用户服务的 userLogin 方法,用户登录成功后,保存登录态到 Sa-Token 的空间账号体系中: //记录用户的登录态 request.getSession().setAttribute(USER_LOGIN_STATE, user); //记录用户登录态到 Sa-token,便于空间鉴权时使用,注意保证该用户信息与 SpringSession 中的信息过期时间一致 StpKit.SPACE.login(user.getId()); StpKit.SPACE.getSession().set(USER_LOGIN_STATE, user); return this.getLoginUserVO(user); 之后就可以在代码中使用账号体系 // 检测当前会话是否以 Space 账号登录,并具有 picture:edit 权限 StpKit.SPACE.checkPermission("picture:edit"); // 获取当前 Space 会话的 Session 对象,并进行写值操作 StpKit.SPACE.getSession().set("user", "zy123"); 权限认证逻辑 Sa-Token 开发的核心是编写权限认证类,我们需要在该类中实现 “如何根据登录用户 id 获取到用户已有的角色和权限列表” 方法。当要判断某用户是否有某个角色或权限时,Sa-Token 会先执行我们编写的方法,得到该用户的角色或权限列表,然后跟需要的角色权限进行比对。 参考 官方文档,示例权限认证类如下: /** * 自定义权限加载接口实现类 */ @Component // 保证此类被 SpringBoot 扫描,完成 Sa-Token 的自定义权限验证扩展 public class StpInterfaceImpl implements StpInterface { /** * 返回一个账号所拥有的权限码集合 */ @Override public List<String> getPermissionList(Object loginId, String loginType) { // 本 list 仅做模拟,实际项目中要根据具体业务逻辑来查询权限 List<String> list = new ArrayList<String>(); list.add("user.add"); list.add("user.update"); list.add("user.get"); list.add("art.*"); return list; } /** * 返回一个账号所拥有的角色标识集合 (权限与角色可分开校验) */ @Override public List<String> getRoleList(Object loginId, String loginType) { // 本 list 仅做模拟,实际项目中要根据具体业务逻辑来查询权限 List<String> list = new ArrayList<String>(); list.add("admin"); list.add("super-admin"); return list; } } Sa-Token 支持按照角色和权限校验,对于权限不多的项目,基于角色校验即可;对于权限较多的项目,建议根据权限校验。二选一即可,最好不要混用! 关键问题:如何在 Sa-Token 中获取当前请求操作的参数? 使用 Sa-Token 有 2 种方式 —— 注解式和编程式 ,但都要实现上面的StpInterface接口。 如果使用注解式,那么在接口被调用时就会立刻触发 Sa-Token 的权限校验,此时参数只能通过 Servlet 的请求对象传递,必须具有指定权限才能进入该方法! 使用 注解合并 简化代码。 @SaSpaceCheckPermission(value = SpaceUserPermissionConstant.PICTURE_UPLOAD) public BaseResponse<PictureVO> uploadPicture() { } 如果使用编程式,可以在函数内的任意位置执行权限校验,只要在执行前将参数放到当前线程的上下文 ThreadLocal 对象中,就能在鉴权时获取到了。 **注意,只要加上了 Sa-Token 注解,框架就会强制要求用户登录,未登录会抛出异常。**所以针对未登录也可以调用的接口,需要改为编程式权限校验 @GetMapping("/get/vo") public BaseResponse<PictureVO> getPictureVOById(long id, HttpServletRequest request) { ThrowUtils.throwIf(id <= 0, ErrorCode.PARAMS_ERROR); // 查询数据库 Picture picture = pictureService.getById(id); ThrowUtils.throwIf(picture == null, ErrorCode.NOT_FOUND_ERROR); // 空间的图片,需要校验权限 Space space = null; Long spaceId = picture.getSpaceId(); if (spaceId != null) { boolean hasPermission = StpKit.SPACE.hasPermission(SpaceUserPermissionConstant.PICTURE_VIEW); ThrowUtils.throwIf(!hasPermission, ErrorCode.NO_AUTH_ERROR); } PictureVO pictureVO = pictureService.getPictureVO(picture, request); // 获取封装类 return ResultUtils.success(pictureVO); } 循环依赖问题 PictureController ↓ 注入 PictureServiceImpl PictureServiceImpl ↓ 注入 SpaceServiceImpl SpaceServiceImpl ↓ 注入 SpaceUserServiceImpl SpaceUserServiceImpl ↓ 注入 SpaceServiceImpl ←—— 又回到 SpaceServiceImpl 解决办法:将一方改成 setter 注入并加上 @Lazy注解 如在SpaceUserServiceImpl中 import org.springframework.context.annotation.Lazy; @Resource @Lazy private SpaceService spaceService; @Lazy为懒加载,直到真正第一次使用它时才去创建或注入。且这里不能用构造器注入的方式!!! 这里有个坑: import groovy.lang.Lazy; 导入这个包的@lazy注解就无效! 分库分表 如果某团队空间的图片数量比较多,可以对其数据进行单独的管理。 1、图片信息数据 可以给每个团队空间单独创建一张图片表 picture_{spaceId},也就是分库分表中的分表,而不是和公共图库、私有空间的图片混在一起。这样不仅查询空间内的图片效率更高,还便于整体管理和清理空间。但是要注意,仅对旗舰版空间生效,否则分表的数量会特别多,反而可能影响性能。 要实现的是会随着新增空间不断增加分表数量的动态分表,会使用分库分表框架 Apache ShardingSphere 带大家实现。 2、图片文件数据 已经实现隔离,存到COS上的不同桶内。 思路主要是基于业务需求设计数据分片规则,将数据按一定策略(如取模、哈希、范围或时间)分散存储到多个库或表中,同时开发路由逻辑来决定查询或写入操作的目标库表。 ShardingSphere 分库分表 <!-- 分库分表 --> <dependency> <groupId>org.apache.shardingsphere</groupId> <artifactId>shardingsphere-jdbc-core-spring-boot-starter</artifactId> <version>5.2.0</version> </dependency> 分库分表的策略总体分为 2 类:静态分表和动态分表 分库分表策略 - 静态分表 静态分表:在设计阶段,分表的数量和规则就是固定的,不会根据业务增长动态调整,比如 picture_0、picture_1。 分片规则通常基于某一字段(如图片 id)通过简单规则(如取模、范围)来决定数据存储在哪个表或库中。 这种方式的优点是简单、好理解;缺点是不利于扩展,随着数据量增长,可能需要手动调整分表数量并迁移数据。 举个例子,图片表按图片 id 对 3 取模拆分: String tableName = "picture_" + (picture_id % 3) // picture_0 ~ picture_2 静态分表的实现很简单,直接在 application.yml 中编写 ShardingSphere 的配置就能完成分库分表,比如: rules: sharding: tables: picture: actualDataNodes: ds0.picture_${0..2} # 3张分表:picture_0, picture_1, picture_2 tableStrategy: standard: shardingColumn: picture_id # 按 pictureId 分片 shardingAlgorithmName: pictureIdMod shardingAlgorithms: pictureIdMod: type: INLINE #内置实现,直接在配置类中写规则,即下面的algorithm-expression props: algorithm-expression: picture_${pictureId % 3} # 分片表达式 甚至不需要修改任何业务代码,在查询picture表(一般叫逻辑表)时,框架会自动帮你修改 SQL,根据 pictureId 将查询请求路由到不同的表中。 分库分表策略 - 动态分表 动态分表是指分表的数量可以根据业务需求或数据量动态增加,表的结构和规则是运行时动态生成的。举个例子,根据时间动态创建 picture_2025_03、picture_2025_04。 String tableName = "picture_" + LocalDate.now().format( DateTimeFormatter.ofPattern("yyyy_MM") ); spring: shardingsphere: datasource: names: smile-picture smile-picture: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/smile-picture username: root password: 123456 rules: sharding: tables: picture: #逻辑表名(业务层永远只写 picture) actual-data-nodes: smile-picture.picture # 逻辑表对应的真实节点 table-strategy: standard: sharding-column: space_id #分片列(字段) sharding-algorithm-name: picture_sharding_algorithm # 使用自定义分片算法 sharding-algorithms: picture_sharding_algorithm: type: CLASS_BASED props: strategy: standard algorithmClassName: edu.whut.smilepicturebackend.manager.sharding.PictureShardingAlgorithm props: sql-show: true 需要实现自定义算法类: public class PictureShardingAlgorithm implements StandardShardingAlgorithm<Long> { @Override public String doSharding(Collection<String> availableTargetNames, PreciseShardingValue<Long> preciseShardingValue) { // 编写分表逻辑,返回实际要查询的表名 // picture_0 物理表,picture 逻辑表 } @Override public Collection<String> doSharding(Collection<String> collection, RangeShardingValue<Long> rangeShardingValue) { return new ArrayList<>(); } @Override public Properties getProps() { return null; } @Override public void init(Properties properties) { } } 本项目分表总体思路: 对 picture 进行分表 一张 逻辑表 picture 业务代码永远只写 picture,不用关心落到哪张真实表。 两类真实表 类型 存谁的数据 例子 公共表 普通 / 进阶 / 专业版空间 picture 分片表 旗舰版 空间(每个空间一张) picture_<spaceId>,如 picture_30001 自定义分片算法: 传入 space_id 时 如果是旗舰,会自动路由到 picture_<spaceId>;否则回落到公共表 picture。 没有 space_id 时 (例如后台批量报表): 广播到 所有 picture_<spaceId> + picture 并做汇聚。 操作 必须带分片键? 若缺少分片键会发生什么 INSERT 是 - 中间件不知道该落到哪张实际表- 直接抛异常:Could not determine actual data nodes / Table xxx route result is empty UPDATE 强烈建议 - ShardingSphere 会把 SQL 广播到所有分表 ,再分别执行- 表越多、数据越大,锁持有时间越长,性能急剧下降- 若所有表都无匹配行,会返回 0,但成本已付出 DELETE 同上 同 UPDATE,且更危险:一次误写可能删光全部分表的数据 SELECT 同上 没分片键就会全表扫描后聚合,数据量大时查询极慢、内存占用高 因此,项目中的业务代码中,对Picture表进行增删查改时,必须确保space_id非空。 协同编辑 相比于生产者直接调用消费者,事件驱动模型的主要优点在于解耦和异步性。在事件驱动模型中,生产者和消费者不需要直接依赖于彼此的实现,生产者只需触发事件并将其发送到事件分发器,消费者则根据事件类型处理逻辑。此外,事件驱动还可以提升系统的 并发性 和 实时性,可以理解为多引入了一个中介来帮忙,通过异步消息传递,减少了阻塞和等待,能够更高效地处理多个并发任务。 如何解决协同冲突? 法一:约定 同一时刻只允许一位用户进入编辑图片的状态,此时其他用户只能实时浏览到修改效果,但不能参与编辑;进入编辑状态的用户可以退出编辑,其他用户才可以进入编辑状态。 事件触发者(用户 A 的动作) 事件类型(发送消息) 事件消费者(其他用户的处理) 用户 A 建立连接,加入编辑 INFO 显示"用户 A 加入编辑"的通知 用户 A 进入编辑状态 ENTER_EDIT 其他用户界面显示"用户 A 开始编辑图片",锁定编辑状态 用户 A 执行编辑操作 EDIT_ACTION 放大/缩小/左旋/右旋当前图片 用户 A 退出编辑状态 EXIT_EDIT 解锁编辑状态,提示其他用户可以进入编辑状态 用户 A 断开连接,离开编辑 INFO 显示"用户 A 离开编辑"的通知,并释放编辑状态 用户 A 发送了错误的消息 ERROR 显示错误消息的通知 法二:实时协同 OT 算法(Operational Transformation),广泛应用于在线文档协作等场景。 操作 (Operation):表示用户对协作内容的修改,比如插入字符、删除字符等。 转化 (Transformation):当多个用户同时编辑内容时,OT 会根据操作的上下文将它们转化,使得这些操作可以按照不同的顺序应用而结果保持一致。 因果一致性:OT 算法确保操作按照用户看到的顺序被正确执行,即每个用户的操作基于最新的内容状态。 举一个简单的例子,假设初始内容是 "abc",用户 A 和 B 同时进行编辑: 用户 A 在位置 1 插入 "x" 用户 B 在位置 2 删除 "b" 如果不使用 OT 算法,结果是: 用户 A 操作后,内容变为 "axbc" 用户 B 操作后,内容变为 "ac" 如果直接应用 B 的操作到 A 的结果,得到的是 "ac",对于 A 来说,相当于删除了 "b",A 会感到一脸懵逼。 如果使用 OT 算法,结果是: 用户 A 的操作,应用后内容为 "axbc" 用户 B 的操作经过 OT 转化为删除 "b" 在 "axbc" 中的新位置 最终用户 A 和 B 的内容都一致为 "axc",符合预期。OT 算法确保无论用户编辑的顺序如何,最终内容是一致的。 OT 算法的难点在于设计如何转化各个用户的操作。 业务流程图 // key: pictureId,value: 这张图下所有活跃的 Session(即各个用户的连接) Map<Long, Set<WebSocketSession>> pictureSessions; 当用户 A 在浏览器里打开了 pictureId=123 的编辑页面,就产生了一个 Session; 如果同一个浏览器又开了一个标签页编辑同一张图,或者不同的浏览器/设备打开,同样又会分别产生新的 Session。 假设有两张图,ID 是 100 和 200: pictureId pictureSessions.get(pictureId) 100 { sessionA, sessionB } (用户 A、B 的连接) 200 { sessionC } (只有用户 C 的连接) Disruptor 优化 调用 Spring MVC 的某个接口时,如果该接口内部的耗时较长,请求线程就会一直阻塞,最终导致 Tomcat 请求连接数耗尽(默认值 200)。 大多数请求是快请求,毫秒级别,直接在请求线程里完成;若有个慢请求,执行一次需要几秒,那么必须将它放入异步线程中执行。 Disruptor 是一种高性能的并发框架,它是一种 无锁的环形队列 数据结构,用于解决高吞吐量和低延迟场景中的并发问题。 Disruptor 的工作流程: 1)环形队列初始化:创建一个固定大小为 8 的 RingBuffer(索引范围 0-7),每个格子存储一个可复用的事件对象,序号初始为 0。 2)生产者写入数据:生产者申请索引 0(序号 0),将数据 "A" 写入事件对象,提交后序号递增为 1,下一个写入索引变为 1。 3)消费者读取数据:消费者检查索引 0(序号 0),读取数据 "A",处理后提交,序号递增为 1,下一个读取索引变为 1。 4)环形队列循环使用:当生产者写入到索引 7(序号 7)后,索引回到 0(序号 8),形成循环存储,但序号会持续自增以区分数据的先后顺序。 5)防止数据覆盖:如果生产者追上消费者,消费者尚未处理完数据,生产者会等待,确保数据不被覆盖。 基于 Disruptor 的异步消息处理机制,可以将原有的同步消息分发逻辑改造为高效解耦的异步处理模型。因为websockt接收到请求,直接往队列里面提交任务,Disruptor的消费者来负责按顺序进行处理。
项目
zy123
6月7日
0
13
0
2025-05-28
消息队列MQ
消息队列MQ 初识MQ 同步调用 同步调用有3个问题: 拓展性差,每次有新的需求,现有支付逻辑都要跟着变化,代码经常变动 性能下降,每次远程调用,调用者都是阻塞等待状态。最终整个业务的响应时长就是每次远程调用的执行时长之和 级联失败,当交易服务、通知服务出现故障时,整个事务都会回滚,交易失败。 异步调用 技术选型 RabbitMQ 部署 mq: #消息队列 image: rabbitmq:3.8-management container_name: mq restart: unless-stopped hostname: mq environment: TZ: "Asia/Shanghai" RABBITMQ_DEFAULT_USER: admin RABBITMQ_DEFAULT_PASS: "admin" ports: - "15672:15672" - "5672:5672" volumes: - mq-plugins:/plugins # 持久化数据卷,保存用户/队列/交换机等元数据 - ./mq-data:/var/lib/rabbitmq networks: - hmall-net volumes: mq-plugins: http://localhost:15672/ 访问控制台 架构图 publisher:生产者,发送消息的一方 consumer:消费者,消费消息的一方 queue:队列,存储消息。生产者投递的消息会暂存在消息队列中,等待消费者处理 exchange:交换机,负责消息路由。生产者发送的消息由交换机决定投递到哪个队列。不存储 virtual host:虚拟主机,起到数据隔离的作用。每个虚拟主机相互独立,有各自的exchange、queue(每个项目+环境有各自的vhost) 一个队列最多指定给一个消费者! Spring AMQP 快速开始 交换机和队列都是直接在控制台创建,消息的发送和接收在Java应用中实现! 简单案例:直接向队列发送消息,不经过交换机 引入依赖 <!--AMQP依赖,包含RabbitMQ--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-amqp</artifactId> </dependency> 配置MQ地址,在publisher和consumer服务的application.yml中添加配置: spring: rabbitmq: host: localhost # 你的虚拟机IP port: 5672 # 端口 virtual-host: /hmall # 虚拟主机 username: hmall # 用户名 password: 123 # 密码 消息发送: 然后在publisher服务中编写测试类SpringAmqpTest,并利用**RabbitTemplate**实现消息发送: @SpringBootTest public class SpringAmqpTest { @Autowired private RabbitTemplate rabbitTemplate; @Test public void testSimpleQueue() { // 队列名称 String queueName = "simple.queue"; // 消息 String message = "hello, spring amqp!"; // 发送消息 rabbitTemplate.convertAndSend(queueName, message); } } convertAndSend如果 2 个参数,第一个表示队列名,第二个表示消息; 消息接收 @Component public class SpringRabbitListener { // 利用RabbitListener来声明要监听的队列信息 // 将来一旦监听的队列中有了消息,就会推送给当前服务,调用当前方法,处理消息。 // 可以看到方法体中接收的就是消息体的内容 @RabbitListener(queues = "simple.queue") public void listenSimpleQueueMessage(String msg) throws InterruptedException { System.out.println("spring 消费者接收到消息:【" + msg + "】"); } } 然后启动启动类,它能自动从队列中取出消息。取出后队列中就没消息了! 交换机 无论是 Direct、Topic 还是 Fanout 交换机,你都可以用 同一个 Binding Key 把多条队列绑定到同一个交换机上。 1)fanout:广播给每个绑定的队列 发送消息: convertAndSend如果 3 个参数,第一个表示交换机,第二个表示RoutingKey,第三个表示消息。 @Test public void testFanoutExchange() { // 交换机名称 String exchangeName = "hmall.fanout"; // 消息 String message = "hello, everyone!"; rabbitTemplate.convertAndSend(exchangeName, "", message); } 2)Direct交换机 队列与交换机的绑定,不能是任意绑定了,而是要指定一个RoutingKey(路由key) 消息的发送方在 向 Exchange发送消息时,也必须指定消息的 RoutingKey。 Exchange不再把消息交给每一个绑定的队列,而是根据消息的Routing Key进行判断,只有队列的Routingkey与消息的 Routing key完全一致,才会接收到消息 注意,RoutingKey不等于队列名称 3)Topic交换机 Topic类型的交换机与Direct相比,都是可以根据RoutingKey把消息路由到不同的队列。 只不过Topic类型交换机可以让队列在绑定BindingKey 的时候使用通配符! BindingKey一般都是有一个或多个单词组成,多个单词之间以.分割 通配符规则: #:匹配一个或多个词 *:匹配不多不少恰好1个词 举例: item.#:能够匹配item.spu.insert 或者 item.spu item.*:只能匹配item.spu 转发过程:把发送者传来的 Routing Key 按点分成多级,和各队列的 Binding Key(可以带 *、# 通配符)做模式匹配,匹配上的队列统统都能收到消息。 Routing Key和Binding Key Routing Key(路由键) 由发送者(Producer)在发布消息时指定,附着在消息头上。 用来告诉交换机:“我的这条消息属于哪类/哪个主题”。 Binding Key(绑定键) 由消费者(在应用启动或队列声明时)指定,是把队列绑定到交换机时用的规则。有些 UI 里 Routing Key 等同于 Binding Key! 告诉交换机:“符合这个键的消息,投递到我这个队列”。 交换机本身不设置 Routing Key 或 Binding Key,它只根据类型(Direct/Topic/Fanout/Headers)和已有的“队列–绑定键”关系,把 incoming Routing Key 匹配到对应的队列。 Direct Exchange 路由规则:Routing Key === Binding Key(完全一致) 场景:一对一或一对多的精确路由 Topic Exchange 路由规则 :支持通配符 *:匹配一个单词 #:匹配零个或多个单词 例: Binding Key绑定键 order.* → 能匹配 order.created、order.paid 绑定键 order.# → 能匹配 order.created.success、order 等 Fanout Exchange 路由规则:忽略 Routing/Binding Key,消息广播到所有绑定队列 场景:聊天室广播、缓存失效通知等 基于注解声明交换机、队列 前面都是在 RabbitMQ 管理控制台手动创建队列和交换机,开发人员还得把所有配置整理一遍交给运维,既繁琐又容易出错。更好的做法是在应用启动时自动检测所需的队列和交换机,若不存在则直接创建。 基于注解方式来声明 type 默认交换机类型为ExchangeTypes.DIRECT @RabbitListener(bindings = @QueueBinding( value = @Queue(name = "direct.queue1"), exchange = @Exchange(name = "hmall.direct", type = ExchangeTypes.DIRECT), key = {"red", "blue"} )) public void listenDirectQueue1(String msg){ System.out.println("消费者1接收到direct.queue1的消息:【" + msg + "】"); } @RabbitListener(bindings = @QueueBinding( value = @Queue(name = "direct.queue2"), exchange = @Exchange(name = "hmall.direct", type = ExchangeTypes.DIRECT), key = {"red", "yellow"} )) public void listenDirectQueue2(String msg){ System.out.println("消费者2接收到direct.queue2的消息:【" + msg + "】"); } 检查队列 如果 RabbitMQ 中已经有名为 direct.queue1 的队列,就不会重复创建; 如果不存在,RabbitAdmin 会自动帮你创建一个。 检查交换机 同理,会查看有没有名为 hmall.direct、类型为 direct 的交换机,若不存在就新建。 检查绑定 最后再去声明绑定关系:把 direct.queue1 绑定到 hmall.direct,并且 routing-key 为 "red" 和 "blue"。 如果已有相同的绑定(队列、交换机、路由键都一致),也不会再重复创建。 消息转换器 使用JSON方式来做序列化和反序列化,替换掉默认方式。 更小或可压缩的消息体、易读、易调试 1)引入依赖 <dependency> <groupId>com.fasterxml.jackson.dataformat</groupId> <artifactId>jackson-dataformat-xml</artifactId> <version>2.9.10</version> </dependency> 2)配置消息转换器,在publisher和consumer两个服务的启动类中添加一个Bean即可: @Bean public MessageConverter messageConverter(){ // 1.定义消息转换器 Jackson2JsonMessageConverter jackson2JsonMessageConverter = new Jackson2JsonMessageConverter(); // 2.配置自动创建消息id,用于识别不同消息,也可以在业务中基于ID判断是否是重复消息 jackson2JsonMessageConverter.setCreateMessageIds(true); return jackson2JsonMessageConverter; } MQ高级 我们要解决消息丢失问题,保证MQ的可靠性,就必须从3个方面入手: 确保生产者一定把消息发送到MQ 确保MQ不会将消息弄丢 确保消费者一定要处理消息 发送者的可靠性 发送者重试 修改发送者模块的application.yaml文件,添加下面的内容: spring: rabbitmq: connection-timeout: 1s # 设置MQ的连接超时时间 template: retry: enabled: true # 开启超时重试机制 initial-interval: 1000ms # 失败后的初始等待时间 multiplier: 1 # 失败后下次的等待时长倍数,下次等待时长 = initial-interval * multiplier max-attempts: 3 # 最大重试次数 阻塞重试,一般不建议开启。 发送者确认 当消息投递到MQ,但是路由失败(没有队列绑定交换机、或者你routingKey设置错误等)时,通过Publisher Return返回异常信息,同时返回ack的确认信息,代表投递成功 临时消息投递到了MQ,并且入队成功,返回ACK,告知投递成功 持久消息投递到了MQ,并且入队完成持久化,返回ACK ,告知投递成功 其它情况都会返回NACK,告知投递失败 1.在发送者模块的application.yaml中添加配置: spring: rabbitmq: publisher-confirm-type: correlated # 开启publisher confirm机制,并设置confirm类型 publisher-returns: true # 开启publisher return机制 none:关闭confirm机制 simple:同步阻塞等待MQ的回执 correlated:MQ异步回调返回回执 2.每个RabbitTemplate只能配置一个ReturnCallback,因此我们可以在配置类中统一设置。我们在publisher模块定义一个配置类: @Slf4j @RequiredArgsConstructor @Configuration public class MqConfig { private final RabbitTemplate rabbitTemplate; @PostConstruct public void init(){ rabbitTemplate.setReturnsCallback(new RabbitTemplate.ReturnsCallback() { @Override public void returnedMessage(ReturnedMessage returned) { log.error("触发return callback,"); log.debug("exchange: {}", returned.getExchange()); log.debug("routingKey: {}", returned.getRoutingKey()); log.debug("message: {}", returned.getMessage()); log.debug("replyCode: {}", returned.getReplyCode()); log.debug("replyText: {}", returned.getReplyText()); } }); } } 3.定义ConfirmCallback 由于每个消息发送时的处理逻辑不一定相同,因此ConfirmCallback需要在每次发消息时定义。具体来说,是在调用RabbitTemplate中的convertAndSend方法时,多传递一个参数: 这里的CorrelationData中包含两个核心的东西: id:消息的唯一标示,MQ对不同的消息的回执以此做判断,避免混淆 SettableListenableFuture:回执结果的Future对象 @Test void testPublisherConfirm() { // 1.创建CorrelationData CorrelationData cd = new CorrelationData(); // 2.给Future添加ConfirmCallback cd.getFuture().addCallback(new ListenableFutureCallback<CorrelationData.Confirm>() { @Override public void onFailure(Throwable ex) { // 2.1.Future发生异常时的处理逻辑,基本不会触发 log.error("send message fail", ex); } @Override public void onSuccess(CorrelationData.Confirm result) { // 2.2.Future接收到回执的处理逻辑,参数中的result就是回执内容 if(result.isAck()){ // result.isAck(),boolean类型,true代表ack回执,false 代表 nack回执 log.debug("发送消息成功,收到 ack!"); }else{ // result.getReason(),String类型,返回nack时的异常描述 log.error("发送消息失败,收到 nack, reason : {}", result.getReason()); } } }); // 3.发送消息 rabbitTemplate.convertAndSend("hmall.direct", "q", "hello", cd); } 端到端投递保障 ConfirmCallback 只告诉你:消息“到”了 RabbitMQ 服务器吗?(ACK:到;NACK:没到) ReturnsCallback 只告诉你:到达服务器的消息,能“进”队列吗?(能进就不回;进不了就退) 两者都成功,才能确认:“这条消息真的安全地进了队列,等着消费者去拿。” 开启生产者确认比较消耗MQ性能,一般不建议开启。 MQ的可靠性 数据持久化 为了保证数据的可靠性,必须配置数据持久化(从内存保存到磁盘上),包括: 交换机持久化(选Durable) 队列持久化(选Durable) 消息持久化(选Persistent) 控制台方式: 这样重启后还能恢复。 代码方式,默认都是持久化的,不用变动。 消费者可靠性 消费者确认机制 当消费者处理消息结束后,向RabbitMQ返回自己的处理状态,MQ做出相应反应... 上述的NACK状态时,MQ会不断向消费者重投消息,直至被正确处理!!! 在消费者方,通过下面的配置可以修改消费者收到消息后的ACK处理方式: none,收到消息就直接返回ack; manual,手动实现ack; auto,自动档,业务异常返回nack, 消息处理异常返回reject,其他ack (默认也是这个模式) spring: rabbitmq: listener: simple: acknowledge-mode: auto 消费者重试 类似发送者的重试机制,在消费者出现异常时利用本地重试,而不是无限制的requeue到mq队列。 重试达到最大次数后,、会返回reject,消息会被丢弃 修改consumer服务的application.yml文件,添加内容: spring: rabbitmq: listener: simple: retry: enabled: true # 开启消费者失败重试 initial-interval: 1000ms # 初识的失败等待时长为1秒 multiplier: 1 # 失败的等待时长倍数,下次等待时长 = multiplier * last-interval max-attempts: 3 # 最大重试次数 stateless: true # true无状态;false有状态。如果业务中包含事务,这里改为false Stateless(无状态重试):所有的重试都在同一个事务上下文里进行,直到重试次数用尽后才会把异常抛回容器并最终回滚或提交事务。 Stateful(有状态重试):每次重试都会被当成一次独立的消息交付,Spring 会为每次尝试开启新的事务,失败时立即回滚并重新投递,下次重试又是一个干净的事务环境。这对事务性操作(@Transactional)来说,能保证“第 N 次重试失败就回滚第 N 次的事务”,避免把所有尝试都裹在一笔大事务里 失败处理策略 前面默认的达到最大重试次数后,消息会被丢弃,对于消息可靠性要求较高的业务场景下,显然不太合适了。 因此Spring允许我们自定义重试次数耗尽后的消息处理策略,这个策略是由MessageRecovery接口来定义的,它有3个不同实现: RejectAndDontRequeueRecoverer:重试耗尽后,直接reject,丢弃消息。默认就是这种方式 ImmediateRequeueMessageRecoverer:重试耗尽后,返回nack,消息重新入队 RepublishMessageRecoverer:重试耗尽后,将失败消息投递到指定的交换机 比较优雅的一种处理方案是RepublishMessageRecoverer,失败后将消息投递到一个指定的,专门存放异常消息的交换机->队列,后续由人工集中处理。 业务幂等性 在程序开发中,幂等则是指同一个业务,执行一次或多次对业务状态的影响是一致的。如: 根据id删除数据 查询数据 新增数据 但数据的更新往往不是幂等的,如果重复执行可能造成不一样的后果。比如: 取消订单,恢复库存的业务。如果多次恢复就会出现库存重复增加的情况 退款业务。重复退款对商家而言会有经济损失。 所以,我们要尽可能避免业务被重复执行:MQ消息的重复投递、页面卡顿时频繁刷新导致表单重复提交、服务间调用的重试 法一:唯一ID 每一条消息都生成一个唯一的id,与消息一起投递给消费者。 消费者接收到消息后处理自己的业务,业务处理成功后将消息ID保存到数据库 如果下次又收到相同消息,去数据库查询判断是否存在,存在则为重复消息放弃处理。 法一存在业务侵入,因为mq的消息ID与业务无关,现在却多了一张专门记录 ID 的表或结构 法二:业务判断,基于业务本身的逻辑或状态来判断是否是重复的请求或消息,不同的业务场景判断的思路也不一样。 综上,支付服务与交易服务之间的订单状态一致性是如何保证的? 首先,支付服务会正在用户支付成功以后利用MQ消息通知交易服务,完成订单状态同步。 其次,为了保证MQ消息的可靠性,我们采用了生产者确认机制、消费者确认、消费者失败重试等策略,确保消息投递的可靠性 最后,我们还在交易服务设置了定时任务,定期查询订单支付状态。这样即便MQ通知失败,还可以利用定时任务作为兜底方案,确保订单支付状态的最终一致性。 延迟消息 对于超过一定时间未支付的订单,应该立刻取消订单并释放占用的库存。 例如,订单支付超时时间为30分钟,则我们应该在用户下单后的第30分钟检查订单支付状态,如果发现未支付,应该立刻取消订单,释放库存。 延迟消息插件 1.下载 GitHub - rabbitmq/rabbitmq-delayed-message-exchange: Delayed Messaging for RabbitMQ 2.上传插件,由于之前docker部署MQ挂载了数据卷 docker volume ls #查看所有数据卷 docker volume inspect hmall_all_mq-plugins #获取数据卷的目录 #"Mountpoint": "/var/lib/docker/volumes/hmall_all_mq-plugins/_data" 我们上传插件到该目录下。 3.安装插件 docker exec -it mq rabbitmq-plugins enable rabbitmq_delayed_message_exchange 声明延迟交换机 额外指定参数 delayed = "true" @RabbitListener(bindings = @QueueBinding( value = @Queue(name = "delay.queue", durable = "true"), exchange = @Exchange(name = "delay.direct", delayed = "true"), key = "delay" )) public void listenDelayMessage(String msg){ log.info("接收到delay.queue的延迟消息:{}", msg); } 发送延迟消息 @Test void testPublisherDelayMessage() { // 1.创建消息 String message = "hello, delayed message"; // 2.发送消息,利用消息后置处理器添加消息头 rabbitTemplate.convertAndSend("delay.direct", "delay", message, new MessagePostProcessor() { @Override public Message postProcessMessage(Message message) throws AmqpException { // 添加延迟消息属性 message.getMessageProperties().setDelay(5000); return message; } }); } 超时订单问题 三种可能性:1.如果用户支付成功,但是消息通知失败(未传递给订单服务),那么会导致数据不一致情况,这时延迟消息到达后,它会先看本地订单状态,发现处于'待支付'状态,此时不确定是否是真的未支付,还是消息通知失败,需要再openfeign调用支付服务,查询支付流水状态,发现支付成功,那么就更新本地订单状态为'已支付'。 2.如果用户支付成功且消息通知成功,那么订单服务会更新订单状态为'已支付',延迟消息到达时查询本地订单状态,确实'已支付',直接return。 3.用户确实到时间了扔未支付,此时本地订单状态和远程的支付流水状态都是'待支付',此时取消订单、恢复库存。
后端学习
zy123
5月28日
0
12
0
2025-05-27
Jmeter快速入门
Jmeter快速入门 1.安装Jmeter Jmeter依赖于JDK,所以必须确保当前计算机上已经安装了JDK,并且配置了环境变量。 1.1.下载 可以Apache Jmeter官网下载,地址:http://jmeter.apache.org/download_jmeter.cgi 1.2.解压 因为下载的是zip包,解压缩即可使用,目录结构如下: 其中的bin目录就是执行的脚本,其中包含启动脚本: 1.3.运行 双击即可运行,但是有两点注意: 启动速度比较慢,要耐心等待 启动后黑窗口不能关闭,否则Jmeter也跟着关闭了 2.快速入门 2.1.设置中文语言 默认Jmeter的语言是英文,需要设置: 效果: 注意:上面的配置只能保证本次运行是中文,如果要永久中文,需要修改Jmeter的配置文件 打开jmeter文件夹,在bin目录中找到 jmeter.properties,添加下面配置: language=zh_CN 注意:前面不要出现#,#代表注释,另外这里是下划线,不是中划线 2.2.基本用法 在测试计划上点鼠标右键,选择添加 > 线程(用户) > 线程组: 在新增的线程组中,填写线程信息: 给线程组点鼠标右键,添加http取样器: 编写取样器内容: 添加监听报告: 添加监听结果树: 汇总报告结果: 结果树:
后端学习
zy123
5月27日
0
0
0
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
7
0
2025-05-10
ZY网络重构分析
多智能体随机网络的全局知识对其模型收敛性影响的研究 智能体网络的现状、包括网络结构(和现有互联网、物联网的差异)、通信协议(A2A(agent)、MCP成为主流,为了智能体之间的通信)传统的协议已经慢慢被替代,不止是传统互联网应用-》大模型 多智能体随机网络与传统互联网不一样,结构基于随机网络(有什么作用,举一些具体的例子),通信协议(没有专门的协议,我们工作的出发点)、应用(联邦学习、图神经网络、强化学习) 网络模型的收敛性,怎么定义收敛性?收敛速度、收敛效率(考虑代价)、收敛的稳定性(换了个环境变化大),联邦学习、强化学习收敛性的问题,和哪些因素有关,网络全局结构对它的影响;推理阶段也有收敛性,多智能体推理结果是否一致;图神经网络推理结果是否一致。 多智能体随机网络全局知识的获取(分布式、集中式) 多智能体随机机会网络、动态谱参数估算、网络重构算法、聚类量化算法、联邦学习、图神经网络 如何确定kmeans的簇数?节点之间的流量,空间转为时间的图。 压缩感知 函数拟合 采样定理 傅里叶变换 谱分解与网络重构 实对称矩阵性质: 对于任意 $n \times n$ 的实对称矩阵 $A$: 秩可以小于 $n$(即存在零特征值,矩阵不可逆)。 但仍然有 $n$ 个线性无关的特征向量(即可对角化)。 特征值有正有负!!! 一个实对称矩阵可以通过其特征值和特征向量进行分解。对于一个 $n \times n$ 的对称矩阵 $A$, 完整谱分解可以表示为: $$ A = Q \Lambda Q^T \\ A = \sum_{i=1}^{n} \lambda_i x_i x_i^T $$ $Q$是$n \times n$的正交矩阵,每一列是一个特征向量;$\Lambda$是$n \times n$的对角矩阵,对角线元素是特征值$\lambda_i$ ,其余为0。 其中,$\lambda_i$ 是矩阵 $A$ 的第 $i$ 个特征值,$x_i$ 是对应的特征向量。(注意!这里的特征向量需要归一化!!!) 如果矩阵 $A$ 的秩为 $r$ ,那么谱分解里恰好有 $r$ 个非零特征值。 用这 $r$ 对特征值/特征向量就能精确重构出 $A$,因为零特征值对矩阵重构不提供任何贡献。 因此,需要先对所有特征值取绝对值,从大到小排序,取前 $r$ 个!!! 截断的谱分解(取前 $\kappa$ 个特征值和特征向量) 如果我们只保留前 $\kappa$ 个绝对值最大的特征值和对应的特征向量,那么: 特征向量矩阵 $U_\kappa$:取 $U$ 的前 $\kappa$ 列,维度为 $n \times \kappa$。 特征值矩阵 $\Lambda_\kappa$:取 $\Lambda$ 的前 $\kappa \times \kappa$ 子矩阵(即前 $\kappa$ 个对角线元素),维度为 $\kappa \times \kappa$。 因此,截断后的近似分解为: $$ A \approx U_\kappa \Lambda_\kappa U_\kappa^T\\ A \approx \sum_{i=1}^{\kappa} \lambda_i x_i x_i^T $$ 推导过程 特征值和特征向量的定义 对于一个对称矩阵 $A$,其特征值和特征向量满足: $$ A x_i = \lambda_i x_i $$ 其中,$\lambda_i$ 是特征值,$x_i$ 是对应的特征向量。 谱分解 将这些特征向量组成一个正交矩阵 $Q$ $A = Q \Lambda Q^T$ $$ Q = \begin{bmatrix} x_1 & x_2 & \cdots & x_n \end{bmatrix}, $$ $$ Q \Lambda = \begin{bmatrix} \lambda_1 x_1 & \lambda_2 x_2 & \cdots & \lambda_n x_n \end{bmatrix}. $$ $$ Q \Lambda Q^T = \begin{bmatrix} \lambda_1 x_1 & \lambda_2 x_2 & \cdots & \lambda_n x_n \end{bmatrix} \begin{bmatrix} x_1^T \\ x_2^T \\ \vdots \\ x_n^T \end{bmatrix}. $$ $$ Q \Lambda Q^T = \lambda_1 x_1 x_1^T + \lambda_2 x_2 x_2^T + \cdots + \lambda_n x_n x_n^T. $$ 可以写为 $$ A = \sum_{i=1}^{n} \lambda_i x_i x_i^T. $$ 网络重构 在随机网络中,网络的邻接矩阵 $A$ 通常是对称的。利用预测算法得到的谱参数 ${\lambda_i, x_i}$ 后,就可以用以下公式重构网络矩阵: $$ A(G) = \sum_{i=1}^{n} \lambda_i x_i x_i^T $$ 性质 特征分解/谱分解 奇异值分解(SVD) 适用矩阵 仅限方阵($n \times n$) 任意矩阵($m \times n$,包括矩形矩阵) 分解形式 $A = P \Lambda P^{-1}$ $A = U \Sigma V^*$ 矩阵类型 可对角化矩阵(如对称、正规矩阵) 所有矩阵(包括不可对角化的方阵和非方阵) 输出性质 特征值($\lambda_i$)可能是复数 奇异值($\sigma_i$)始终为非负实数 正交性 仅当 $A$ 正规时 $P$ 是酉矩阵 $U$ 和 $V$ 始终是酉矩阵(正交) 谱分解的对象为实对称矩阵 奇异值分解 步骤 步骤 1:验证矩阵对称性 确保 $A$ 是实对称矩阵(即 $A = A^\top$),此时SVD可通过特征分解直接构造。 步骤 2:计算特征分解 对 $A$ 进行特征分解: $$ A = Q \Lambda Q^\top $$ 其中: $Q$ 是正交矩阵($Q^\top Q = I$),列向量为 $A$ 的特征向量。 $\Lambda = \text{diag}(\lambda_1, \lambda_2, \dots, \lambda_n)$,$\lambda_i$ 为 $A$ 的特征值(可能有正、负或零)。 步骤 3:构造奇异值矩阵 $\Sigma$ 奇异值:取特征值的绝对值 $\sigma_i = |\lambda_i|$,得到对角矩阵: $$ \Sigma = \text{diag}(\sigma_1, \sigma_2, \dots, \sigma_n) $$ 排列顺序:通常按 $\sigma_i$ 降序排列(可选,但推荐)。 步骤 4:处理符号(负特征值) 符号矩阵 $S$:定义对角矩阵 $S = \text{diag}(s_1, s_2, \dots, s_n)$,其中: $$ s_i = \begin{cases} 1 & \text{if } \lambda_i \geq 0, \ -1 & \text{if } \lambda_i < 0. \end{cases} $$ 左奇异向量矩阵 $U$:调整特征向量的方向: $$ U = Q S $$ 即 $U$ 的列为 $Q$ 的列乘以对应特征值的符号。 步骤 5:确定右奇异向量矩阵 $V$ 由于 $A$ 对称,右奇异向量矩阵 $V$ 直接取特征向量矩阵: $$ V = Q $$ 步骤 6:组合得到SVD 最终SVD形式为: $$ A = U \Sigma V^\top $$ 验证: $$ U \Sigma V^\top = (Q S) \Sigma Q^\top = Q (S \Sigma) Q^\top = Q \Lambda Q^\top = A $$ (因为 $S \Sigma = \Lambda$,例如 $\text{diag}(-1) \cdot \text{diag}(2) = \text{diag}(-2)$)。 例子(含正、负、零特征值) 设对称矩阵 $$ A = \begin{bmatrix} 1 & 0 & 1 \\ 0 & 0 & 0 \\ 1 & 0 & -1 \end{bmatrix} $$ 特征分解 特征值: $$ \lambda_1 = \sqrt{2}, \quad \lambda_2 = -\sqrt{2}, \quad \lambda_3 = 0 $$ 特征向量矩阵和特征值矩阵: $$ Q = \begin{bmatrix} \frac{1 + \sqrt{2}}{2} & \frac{1 - \sqrt{2}}{2} & 0 \ 0 & 0 & 1 \ \frac{1}{2} & \frac{1}{2} & 0 \end{bmatrix}, \quad \Lambda = \begin{bmatrix} \sqrt{2} & 0 & 0 \ 0 & -\sqrt{2} & 0 \ 0 & 0 & 0 \end{bmatrix} $$ 构造SVD 步骤: 按 $|\lambda_i|$ 降序排列:$\sigma_1 = \sqrt{2}, \sigma_2 = \sqrt{2}, \sigma_3 = 0$(取绝对值后排序)。 奇异值矩阵: $$\Sigma = \mathrm{diag}\bigl(\sqrt{2},,\sqrt{2},,0\bigr).$$ 符号调整矩阵: $$ S = \mathrm{diag}\bigl(\operatorname{sign}(\lambda_1),,\operatorname{sign}(\lambda_2),,\operatorname{sign}(\lambda_3)\bigr) = \mathrm{diag}(+1,,-1,,+1), $$ 左奇异向量矩阵: $$ U = Q,S = \begin{bmatrix} \frac{1+\sqrt{2}}{2}\cdot1 & \frac{1-\sqrt{2}}{2}\cdot(-1) & 0\cdot1 \ 0\cdot1 & 0\cdot(-1) & 1\cdot1 \ \tfrac12\cdot1 & \tfrac12\cdot(-1) & 0\cdot1 \end{bmatrix} = \begin{bmatrix} \dfrac{1+\sqrt{2}}{2} & \dfrac{\sqrt{2}-1}{2} & 0 \ 0 & 0 & 1 \ \tfrac12 & -\tfrac12 & 0 \end{bmatrix}. $$ 右奇异向量矩阵: $$ V = Q. $$ 验证 $$ A = U,\Sigma,V^\top $$ 网络重构分析 基于扰动理论的特征向量估算方法 设原矩阵为 $A$,扰动后矩阵为 $A+\zeta C$(扰动矩阵 $\zeta C$,$\zeta$是小参数),令其第 $i$ 个特征值、特征向量分别为 $\lambda_i,x_i$ 和 $\tilde\lambda_i,\tilde x_i$。 特征向量的一阶扰动公式: $$ \Delta x_i =\tilde x_i - x_i \;\approx\; \zeta \sum_{k\neq i} \frac{x_k^T\,C\,x_i}{\lambda_i - \lambda_k}\;x_k, $$ 输出:对应第 $i$ 个特征向量修正量 $\Delta x_i$。 特征值的一阶扰动公式: $$ \Delta\lambda_i = \tilde\lambda_i - \lambda_i \;\approx\;\zeta\,x_i^T\,C\,x_i $$ **关键假设:**当扰动较小( $\zeta\ll1$) 且各模态近似正交均匀时,常作进一步近似 $$ x_k^T\,C\,x_i \;\approx\; x_i^T\,C\,x_i \; $$ 正交: $\{x_k\}$ 本身是正交基,这是任何对称矩阵特征向量天然具有的属性。 均匀:我们把 $C$ 看作“不偏向任何特定模态”的随机小扰动——换句话说,投影到任何两个方向 $(x_i,x_k)$ 上的耦合强度 $x_k^T,C,x_i\quad\text{和}\quad x_i^T,C,x_i$ 在数值量级上应当差不多,因此可以互相近似。 因此,将所有的 $x_k^T C x_i$ 替换为 $x_i^T C x_i$: $$ \Delta x_i \approx \zeta \sum_{k\neq i} \frac{x_i^T C x_i}{\lambda_i - \lambda_k} x_k = \zeta (x_i^T C x_i) \sum_{k\neq i} \frac{1}{\lambda_i - \lambda_k} x_k = \sum_{k\neq i} \frac{\Delta \lambda_i}{\lambda_i - \lambda_k} x_k \tag{*} $$ $$ \Delta x_i \approx\sum_{k\neq i} \frac{\Delta \lambda_i}{\lambda_i - \lambda_k} x_k \tag{*} $$ 问题: 当前时刻的邻接矩阵 $$ A^{(1)}\in\mathbb R^{n\times n},\qquad A^{(1)},x_i^{(1)}=\lambda_i^{(1)},x_i^{(1)},\quad |x_i^{(1)}|=1. $$ 下一时刻的邻接矩阵 $$ A^{(2)}\in\mathbb R^{n\times n}, $$ 已知它的第 $i$ 个特征值 $\lambda_i^{(2)}$(卡尔曼滤波得来). 求当前时刻的特征向量 $x_i^{(2)}$。 下一时刻第 $i$ 个特征向量的预测为 $$ \boxed{ x_i^{(2)} \;=\; x_i^{(1)}+\Delta x_i \;\approx\; x_i^{(1)} +\sum_{k\neq i} \frac{\lambda_i^{(2)}-\lambda_i^{(1)}} {\lambda_i^{(1)}-\lambda_k^{(1)}}\; x_k^{(1)}. } $$ 通过该估算方法可以依次求出下一时刻的所有特征向量。 矩阵符号说明 原始(真实)邻接矩阵 $A$ ,假设 $A$ 的秩为 $r$: $\lambda_{r+1}=\cdots=\lambda_n=0$ $$ A = \sum_{m=1}^n \lambda_m,x_m x_m^T=\begin{align*} \sum_{m=1}^r \lambda_m x_m x_m^T + \sum_{m=r+1}^n \lambda_m x_m x_m^T = \sum_{m=1}^r \lambda_m x_m x_m^T \end{align*}, $$ 滤波估计得到的矩阵及谱分解: $$ \widetilde A = \sum_{m=1}^r \widetilde\lambda_m,\widetilde x_m\widetilde x_m^T, \quad \widetilde\lambda_1\ge\cdots\ge\widetilde\lambda_n; $$ 只取前 $\kappa$ 项重构 : $$ A_\kappa ;=;\sum_{m=1}^\kappa \widetilde\lambda_m,\widetilde x_m\widetilde x_m^T, $$ 对 $A_\kappa$ 进行K-means聚类,得到 $A_{final}$ 目标是让 $A_{final}$ = $A$ 0/1矩阵 其中 $\widetilde{\lambda}_i$ 和 $\widetilde _i$ 分别为通过预测得到矩阵 $\widetilde A$ 的第 $i$ 个特征值和对应特征向量。 然而预测值和真实值之间存在误差,直接进行矩阵重构会使得重构误差较大。 对于这个问题,文献提出一种 0/1 矩阵近似恢复算法。 $$ a_{ij} = \begin{cases} 1, & \text{if}\ \lvert a_{ij} - 1 \rvert < 0.5 \\ 0, & \text{else} \end{cases} $$ 只要我们的估计值与真实值之间差距**小于 0.5**,就能保证阈值处理以后准确地恢复原边信息。 文中提出网络特征值扰动与邻接矩阵扰动具有相同的规律 真实矩阵 $A$ 与预测矩阵 $\widetilde{A} $ 之间的差为 (秩为 $r$) $$ A - \widetilde{A}=\sum_{m=1}^r \lambda_m\,x_m x_m^T-\sum_{m=1}^r \widetilde\lambda_m\,\widetilde x_m\widetilde x_m^T $$ **若假设特征向量扰动可忽略,即$\widetilde x_m\approx x_m$ ,扰动可简化为(这里可能有问题,特征向量的扰动也要计算)** $$ A - \widetilde{A} = \sum_{m=1}^r \Delta \lambda_m _m _m^T. $$ 对于任意元素 $(i, j)$ 上有 $$ |a_{ij} - \widetilde{a}_{ij}|=\left| \sum_{m=1}^r \Delta \lambda_m ( _m _m^T)_{ij} \right| < \frac{1}{2} $$ 于一个归一化的特征向量 $ _m$,非对角线上元素,其外积矩阵$ _m _m^T$ 满足 $$ |( _m _m^T)_{ij}| \leq \frac12. $$ 例: $$ x_m = \begin{bmatrix} \frac{1}{\sqrt{2}} \\ \frac{1}{\sqrt{2}} \end{bmatrix}\\ x_m x_m^T = \begin{bmatrix} \frac{1}{\sqrt{2}} \\ \frac{1}{\sqrt{2}} \end{bmatrix} \begin{bmatrix} \frac{1}{\sqrt{2}} & \frac{1}{\sqrt{2}} \end{bmatrix} = \begin{bmatrix} \frac{1}{2} & \frac{1}{2} \\ \frac{1}{2} & \frac{1}{2} \end{bmatrix} $$ 每个元素的绝对值$\frac12$ $$ \left| \sum_{m=1}^r \Delta \lambda_m (x_m x_m^T)_{ij} \right| \leq \sum_{m=1}^r |\Delta \lambda_m| \cdot |(x_m x_m^T)_{ij}| \leq \frac12\sum_{m=1}^r |\Delta \lambda_m|. $$ 为了确保 $|a_{ij} - \widetilde{a}_{ij}| < \frac{1}{2}$ 对所有 $(i,j)$ 成立,网络精准重构条件为: $$ \sum_{m=1}^r\left| \Delta \lambda_m\right| < 1 $$ 考虑特征向量的扰动: 1 将差分拆成"特征值项 + 特征向量项" 对称矩阵 $A,;\tilde A$ 的前 $r$ 个特征对分别记作 ${(\lambda_m,x_m)}{m=1}^r,; {(\tilde\lambda_m,\tilde x_m)}{m=1}^r$。 $$ \begin{aligned} A-\tilde A &=\sum_{m=1}^r\bigl(\lambda_m x_mx_m^\top-\tilde\lambda_m\tilde x_m\tilde x_m^\top\bigr)\\ &=\underbrace{\sum_{m=1}^r\Delta\lambda_m\,x_mx_m^\top}_{\text{特征值扰动}} \;+\; \underbrace{\sum_{m=1}^r \tilde\lambda_m\bigl(x_mx_m^\top-\tilde x_m\tilde x_m^\top\bigr)}_{\text{特征向量扰动}} . \end{aligned} $$ 2 如何控制"特征向量扰动项" 设 $\theta_m:=\angle(x_m,\tilde x_m)$, 则 rank-1 投影差满足 $$ \|x_mx_m^\top-\tilde x_m\tilde x_m^\top\|_2=\sin\theta_m, $$ 而单个元素绝对值永远不超过谱范数, 所以 $$ \bigl| (x_mx_m^\top-\tilde x_m\tilde x_m^\top)_{ij}\bigr| \;\le\;\sin\theta_m . $$ 要把 $\sin\theta_m$ 换成 只含特征值的量,用 Davis-Kahan sin θ 定理。 设 $$ \gamma_m:=\min_{k\neq m}\lvert\lambda_m-\lambda_k\rvert \quad(\text{与其它特征值的最小间隔}), $$ 当$\|\tilde A-A\|_2$ 足够小(或直接用 Weyl 定理把它替换成 $|\Delta\lambda_m|$)时 $$ \sin\theta_m \;\le\; \frac{\lvert\Delta\lambda_m\rvert}{\gamma_m} \quad\text{(单向版本的 Davis-Kahan)}\; $$ 3 元素级误差的统一上界 把两部分误差放在一起,对 非对角元 ($|x_{mi}x_{mj}|\le\tfrac12$ 的情形) 有 $$ \begin{aligned} \lvert a_{ij}-\tilde a_{ij}\rvert &\le \frac12\sum_{m=1}^r\lvert\Delta\lambda_m\rvert \;+\; \sum_{m=1}^r \lvert\tilde\lambda_m\rvert\, \sin\theta_m\\[4pt] &\le \frac12\sum_{m=1}^r\lvert\Delta\lambda_m\rvert \;+\; \sum_{m=1}^r \lvert\tilde\lambda_m\rvert\, \frac{\lvert\Delta\lambda_m\rvert}{\gamma_m}. \end{aligned} $$ 4 纯"特征值—谱隙"条件 若要保证 所有 非对角元素都 < $\tfrac12$,只需让 $$ \boxed{\; \sum_{m=1}^r \lvert\Delta\lambda_m\rvert \Bigl( \tfrac12+\frac{\lvert\tilde\lambda_m\rvert}{\gamma_m} \Bigr) \;
科研
zy123
5月10日
0
9
0
上一页
1
2
3
4
...
12
下一页