md_files/自学/智能协同云图库.md

34 KiB
Raw Blame History

智能协同云图库

待完善功能:

用户模块扩展功能:

image-20250605171423657

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.图片审核扩展

image-20250613095827698

5.爬图扩展

2记录从哪里爬的

4bing直接搜可能也是缩略图可能模拟手点一次图片再爬会清晰一点

image-20250613151702919

6.缓存扩展

image-20250614102838352

image-20250614103204589

图片压缩

image-20250614144949709

文件秒传md5校验如果已有直接返回url不用重新上传图片场景不必使用

image-20250614150055044

分片上传和断点续传:对象存储 上传对象_腾讯云

CDN内容分发后期项目上线之后搞一下。

浏览器缓存

是服务器(或 CDN/静态文件服务器)在返回资源时下发给浏览器的。

image-20250615110512019

用户空间扩展:

image-20250616180026998

image-20250617142423268

图片编辑

image-20250618103455538

AI扩图

image-20250618131313871

image-20250618133151691

创建图片的业务流程 创建图片主要是包括两个过程:第一个过程是上传图片文件本身,第二个过程是将图片信息上传到数据库。

有两种常见的处理方式:

1.先上传再提交数据(大多数的处理方式):用户直接上传图片,系统自动生成图片的url存储地址;然后在用户填写其它相关信息并提交后才将图片记录保存到数据库中。 2.上传图片时直接记录图片信息:云图库平台中图片作为核心资源,只要用户将图片上传成功就应该把这个图片上传到数据库中(即用户上传图片后系统应该立即生成图片的完整数据记录和其它元信息,这里元信息指的是图片的一些基础信息,这些信息应该是在图片上传成功后就能够解析出来),无需等待用户上传提交图片信息就会立即存入数据库中,这样会使整个交互过程更加轻量。这样的话用户只需要再上传图片的其它信息即可,这样就相当于用户对已有的图片信息进行编辑。 当然我们也可以对用户进行一些限制,比如说当用户上传过多的图片资源时就禁止该用户继续上传图片资源。

优化

image-20250613153115420

协同编辑: 扩展 1、为防止消息丢失可以使用 Redis 等高性能存储保存执行的操作记录。

目前如果图片已经被编辑了,新用户加入编辑时没办法查看到已编辑的状态,这一点也可以利用 Redis 保存操作记录来解决,新用户加入编辑时读取 Redis 的操作记录即可。

2、每种类型的消息处理可以封装为独立的 Handler 处理器类,也就是采用策略模式。

3、支持分布式 WebSocket。实现思路很简单只需要保证要编辑同一图片的用户连接的是相同的服务器即可和游戏分服务器大区、聊天室分房间是类似的原理。

4、一些小问题的优化比如 WebSocket 连接建立之后,如果用户退出了登录,这时 WebSocket 的连接是没有断开的。不过影响并不大,可以思考下怎么处理。

收获

MybatisX插件简化开发

下载MybatisX插件可以从数据表直接生成Bean、Mapper、Service选项设置如下

注意勾选Actual Column生成的Bean和表中字段一模一样取消勾选会进行驼峰转换即user_name->userName

image-20250605174225328

image-20250605174413935

下载GenerateSerailVersionUID插件可以右键->generate->生成序列ID

private static final long serialVersionUID = -1321880859645675653L;

image-20250605181008973

胡图工具类hutool

ObjUtil.isNotNull(Object obj),仅判断对象是否 不为 null,不关心对象内容是否为空,比如空字符串 ""、空集合 []、数字 0 等都算是“非 null”。

ObjUtil.isNotEmpty(Object obj) 判断对象是否 不为 null 且非“空”

  • 对不同类型的对象判断逻辑不同:
    • CharSequenceString长度大于 0
    • Collectionsize > 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
    }
}

多级缓存

image-20250614101747456

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> 管理锁更优?

  1. 避免污染常量池 String.intern() 会把每一个不同的 userId 字符串都放到 JVM 的字符串常量池里随着用户量增长常量池里的内容会越来越多可能导致元空间MetaSpace永久代PermGen压力过大。
  2. 显式可控的锁生命周期
    • ConcurrentHashMap 明确地管理——「只要 map 里有这个 key就有对应的锁对象不需要时可以删掉。」
    • 相比之下,intern() 后的字符串对象由 JVM 常量池管理,代码里很难清理,存在内存泄漏风险。
  3. 高并发性能更好
    • ConcurrentHashMap 内部采用分段锁或 Node 锁定(取决于 JDK 版本),即便高并发下往 map 里 computeIfAbsent 也能保持较高吞吐。
    • synchronized (lock) 本身只锁定单个用户对应的那把锁,不影响其他用户;结合 ConcurrentHashMap 的高并发特性,整体性能比直接在一个全局 HashMap + synchronized 好得多。

锁+事务可能出现的问题

@Transactional(声明式)

  • 事务在方法入口打开,很可能在拿锁前就占用连接/数据库资源,导致“空跑事务”+“资源耗尽”。
  • 依赖代理,存在自调用失效的坑。

transactionTemplate.execute()(编程式)

  • 锁先行→事务后发,确保高并发下只有一个连接/事务进数据库,极大降低资源竞争。
  • 全程显式,放到哪儿就是哪儿,杜绝自调用/代理链带来的隐患。

锁+事务@Transactional一起可能出现问题:

线程 A

  • 进入方法Spring AOP 拦截,立即开启事务
  • 走到 synchronized(lock),拿到锁
  • 在锁里执行 existssave(但真正的 “提交” 要等到方法返回后才做)
  • 退出 synchronized 块,方法继续执行(其实已经没别的逻辑了)
  • 方法返回,事务拦截器这时才 提交

线程 B(并发进来)

  • 等待 AOP 代理,进入同一个方法,也会马上开启自己的事务
  • 在入口就拿到一个新的连接/事务上下文
  • 然后遇到 synchronized(lock)在这里阻塞 等 A 释放锁
  • A 一旦走出 synchronizedB 立刻拿到锁——但此时 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模型

团队空间:

image-20250620092726446

一般来说,标准的 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>

2Sa-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上的不同桶内。

image-20250622160736651

思路主要是基于业务需求设计数据分片规则,将数据按一定策略(如取模、哈希、范围或时间)分散存储到多个库或表中,同时开发路由逻辑来决定查询或写入操作的目标库表。

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非空。

协同编辑

image-20250623104618280

相比于生产者直接调用消费者,事件驱动模型的主要优点在于解耦和异步性。在事件驱动模型中,生产者和消费者不需要直接依赖于彼此的实现,生产者只需触发事件并将其发送到事件分发器,消费者则根据事件类型处理逻辑。此外,事件驱动还可以提升系统的 并发性 和 实时性,可以理解为多引入了一个中介来帮忙,通过异步消息传递,减少了阻塞和等待,能够更高效地处理多个并发任务。

如何解决协同冲突?

法一:约定 同一时刻只允许一位用户进入编辑图片的状态,此时其他用户只能实时浏览到修改效果,但不能参与编辑;进入编辑状态的用户可以退出编辑,其他用户才可以进入编辑状态。

事件触发者(用户 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 算法,结果是:

  1. 用户 A 的操作,应用后内容为 "axbc"
  2. 用户 B 的操作经过 OT 转化为删除 "b" 在 "axbc" 中的新位置 最终用户 AB 的内容都一致为 "axc",符合预期。OT 算法确保无论用户编辑的顺序如何,最终内容是一致的

OT 算法的难点在于设计如何转化各个用户的操作。

业务流程图

image-20250623111212615

// key: pictureIdvalue: 这张图下所有活跃的 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的消费者来负责按顺序进行处理。