本阶段任务
- 幂等设计
- 线程池隔离.
- 统计分析功能
- 应用分享功能
幂等设计
在本项目的用户答题功能中,虽然调用多次 AI 的问题已经在之前通过缓存解决,但仍然要防止用户不小心点击多次导致产生多条回答记录,因此需要对接口进行幂等处理。
方案选型
数据库唯一索引(实现成本较低)
在之前,我们将数据库用户回答记录表中的回答 id 号字段配置成唯一索引, 用户完成一次完整答题并提交会执行 insert 语句, MySQL根据唯一索引天然阻止相同订单号数据的插入,我们可以 catch 并报错。
try {
boolean result = userAnswerService. save(userAnswer);
ThromUtils.throwIf(!result, ErrorCode.OPERATION ERROR);
} catch(DuplicateKeyException e) {
// 忽视/处理错误
}
当在数据库中插入、更新或执行其他操作时,违反了唯一键约束时,就会抛出 DuplicateKeyException
异常。
数据库乐观锁
使用乐观锁来实现数据库某数据的幂等性是一种常见的解决方案,特别是在高并发环境下。乐观锁通常依赖于版本号或时间戳等机制。
例如一个订单场景:
@Entity
public class Order {
@Id
private Long id;
private String status;
private BigDecimal amount;
@Version
private Integer version;
// Getters and Setters
}
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
@Transactional
public void updateOrder(Long orderId, String newStatus, BigDecimal newAmount) {
try {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new RuntimeException("Order not found"));
// 更新订单状态和金额
order.setStatus(newStatus);
order.setAmount(newAmount);
// 保存订单,JPA 会自动处理乐观锁
orderRepository.save(order);
} catch (OptimisticLockingFailureException e) {
// 处理乐观锁异常
System.out.println("Optimistic lock failure: " + e.getMessage());
// 可以选择重新尝试更新或抛出异常
throw new RuntimeException("Failed to update order due to concurrent modification");
}
}
}
@Version
注解:在实体类中使用 @Version 注解字段,在每次更新时,JPA 会自动检查版本号是否匹配。
乐观锁异常处理:在 updateOrder
方法中捕获 OptimisticLockingFailureException
异常,以处理并发更新冲突。可以选择重新尝试或抛出异常。
事务管理:确保 updateOrder
方法被 @Transactional
注解包裹,以确保事务的一致性和原子性。
分布式锁
导致数据错乱的元凶很多时候都是“并发修改",因此可以通过加锁来限制关键代码的执行。
使用数据库唯一索引的实现
首先,我们将生成答题记录 id 的时间节点从插入数据时提早到前端进入答题页面的时候,然后用户提交回答的时候,前端不仅提交用户的选项,同时也需要带上这个全局唯一id(后端的DTO类要添加字段)。最后,后端将这个 id 保存到数据库的某个唯一索引字段, 利用数据库实现幕等性。
在这里,我们使用了 Hutool 工具类提供的 IdUtil 工具类来基于雪花算法生成唯一 idIdutil.getSnowflakeNextId()
。
使用雪花算法生成唯一 id 的优点如下:
- 分布式高效性
雪花算法的设计可以在分布式环境中高效运行。每个节点都可以独立地生成唯一ID,而不需要相互协调或通信,这极大地提高了系统的可扩展性和效率。 - 唯一性
雪花算法保证生成的ID是全局唯一的。这是通过时间戳、机器ID和序列号的组合实现的,确保在不同机器和不同时间生成的ID不会重复。 - 时间有序性
雪花算法生成的ID是基于时间戳的,因此它们具有递增的时间顺序。这对于需要按时间排序的场景非常有用,例如日志记录、数据库插入等。 - 高性能
雪花算法的计算开销非常低。生成一个ID通常只需要几个纳秒,这使得它非常适合高并发、高吞吐量的应用场景。 - 可配置性
雪花算法的各个部分(时间戳、机器ID、序列号)的位数可以根据实际需求进行调整,以适应不同的系统规模和需求。例如,可以增加机器ID的位数以支持更多的节点,或增加序列号的位数以支持更高的并发度。 - 无需中心协调
与传统的集中式ID生成器(如数据库自增ID)不同,雪花算法不需要一个中心节点进行协调,这避免了单点故障,提高了系统的可靠性和可用性。 - 实现简单
雪花算法的实现相对简单,不需要复杂的依赖或配置,容易集成到各种系统和语言中。
雪花算法的结构:
符号位(1位):固定为0,表示正数。
时间戳(41位):通常表示自定义纪元开始到当前时间的毫秒数。41位的时间戳可以表示69年的时间。
机器ID(10位):可以标识1024台机器或节点。
序列号(12位):同一毫秒内的并发序列号,最多可以表示4096个并发。
在UserAnswerController下新增生成 id 的接口:
@GetMapping("/generate/id")
public BaseResponse<Long> generateUserAnswerId() {
return ResultUtils. sccess(IdUtil.getSnowflakeNextId));
}
并在前端中添加对应的 id 传递逻辑即可。
线程池隔离
上一期使用 RxJava 实现Al题目生成的时候 (平台性能优化),用schedulers.io()
方法一个I/O密集型线程池来处理 Al 返回的流。
Java 8 并发流的线程池是全局共享的,这里最大的问题就是相互影响。
假设系统中的一个服务出 bug 导致的线程池里的所有线程都被阻塞了,其他任务就可能被阻塞住。
所以,我们这里也引入了线程池隔离,优点:
1.故障隔离,缩小事故范围。
2.资源隔离,防止业务之间抢占资源。同时支持更精细化地管理资源,比如不重要的场景给小一点的线程池,核心场景配置大线程池。
3.性能优化,-些业务场景的任务是CPU密集型,一些是I/O密集型,不同任务类型需要配置不同的线程池。
查看schedulers.io()
的源码:
可以发现生成默认线程池确实是全局共享的。
方案设计
综上,我们考虑给 VIP 用户定制一个专用的线程池(配置 VipSchedulerConfig
),改造Al生成题目接口,根据用户类型选择不同的线程池:
@Configuration
@Data
public class VipSchedulerConfig {
@Bean
public Scheduler vipScheduler(){
ThreadFactory threadFactory = new ThreadFactory() {
private final AtomicInteger threadNumber = new AtomicInteger(1);
@Override
public Thread newThread(@NotNull Runnable r) {
Thread thread = new Thread(r, "VIPThreadPoll-" + threadNumber.getAndIncrement());
thread.setDaemon(false); // 设置为非守护线程,确保任务顺利执行完成
return thread;
}
};
ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(10,threadFactory);
return Schedulers.from(scheduledExecutorService);
}
}
// 注入对象
@Resource
private Scheduler vipScheduler;
// 为尊贵的vip用户定制线程池
Scheduler scheduler = Schedulers.io();
if ("vip".equals(loginUser.getUserRole())){
scheduler = vipScheduler;
}
统计分析功能
分析哪些App更热门,每种App当中的答案分布情况。
实现方案-关系型数据库
本项目将采用关系型数据库的方式实现,借助 MyBatis 自主编写 SQL 来实现数据的统计分析。
后端
在这里,我们使用 Java 注解的方式实现:
基于Java注解写在xxMapperjava中,在mapper层的接口类方法利用@select
、@Update
、@Insert
、 @Delete
等注解,在注解内填写自定义SQL询,即可实现查询、更新、存储、删除。
例如,统计回答数最多的前 10 个应用:
@Select("select appId, count(userId) as answerCount from user_answer\n" +
" group by appId order by answerCount desc limit 10;")
List<AppAnswerCountDTO> doAppAnswerCount();
每条回答都会对应一个 userId ,因此统计 userId 的数量即可。
前端
使用的是 vue-echarts ,需要先安装:npm i echarts vue-echarts
在前端展示中,
分析哪些App更热门:柱状图
每种App当中的答案分布情况:饼状图
应用分享功能
为了提高网站和应用的影响力,可以开发便捷应用分享功能。
主要是前端的开发。
主页:
APP 详情页:
qrcode :原理是将链接作为文本,转换为二维码图片。
// 分享链接
const shareLink = `${window.location.protocol}//${window.location.host}/app/detail/${props.id}`;
// 分享
const doShare = (e: Event) => {
if (shareModalRef.value) {
shareModalRef.value.openModal();
}
// 阻止冒泡,防止跳转到详情页
e.stopPropagation();
};
小结
后续还可以在答题评分分析结果等页面增加分享功能,类似于大家做了测评之后可以转发结果,可以在此基础上增加 App 的传播影响力。