34 KiB
智能协同云图库
待完善功能:
用户模块扩展功能:
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
ObjUtil.isNotNull(Object obj)
,仅判断对象是否 不为 null
,不关心对象内容是否为空,比如空字符串 ""
、空集合 []
、数字 0
等都算是“非 null”。
ObjUtil.isNotEmpty(Object obj)
判断对象是否 不为 null 且非“空”
- 对不同类型的对象判断逻辑不同:
CharSequence
(String):长度大于 0Collection
:size > 0Map
:非空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的消费者来负责按顺序进行处理。