后端两个接口需分开写,前端需不串行并同时刷新调用但数据不同步NOTE
前言:
首先简述一下业务与问题
getUserTotalScoreList 接口是获取当前考试考生的总得分值列表信息(读数据)
getItemTotalScore 接口是自动计算当前考试考生的对应类型题目的总分值(写数据)
目前前端是需要同时刷新页面并同时调用以上两个接口,由于第一个接口是读数据,第二个接口是写数据,导致同步刷新获取的时候,数据不一致,getItemTotalScore 计算完成后进行返回考生 score 为18,之后从 getUserTotalScoreList 进行获取时还是老数据 score 为15,需要再次调用这个接口才能获取最新的 score 18
由上可知遇到了典型的数据不一致问题,前提是前端那边不进行串行调用接口的情况下
以下我是使用的 分布式锁 + Redis状态标记 进行处理的
这里是 Redisson 分布式锁工具类 以及 定义了一个 Redis 常量接口
/**** 分布式锁工具类 **/
public class RedissonUtil {// 计算得分方法正在进行public static final String RUNNING = "0";// 计算得分方法已经完成public static final String FINISHED = "1";/*** 获取锁*/static public RLock getLock(String key, RedissonClient redissonClient) {return redissonClient.getLock("exam:score:rwlock:" + key);}
}/**** Redis 常量接口 **/
public interface RedisConstants {/*** 判断计算得分是否已经完成*/String SCORE_TASK_FINISHED_KEY = "score_task_finished:";
}
这里是 getItemTotalScore 方法(写操作)
首先是使用 Redisson 中的分布式锁方法进行加锁,若加锁失败,意味着当前 (
examId,uerId)
正在被其他线程处理或处于竞争状态,这时进行相关处理
若加锁成功,首先先将将当前考试考生之前已经完成计算的旧数据状态给 delete 删除(注意,状态删除操作不要写在读方法中),然后基于 examId + uerId 为 key 来进行 redis 的状态标记处理(注意这里的 key 必须保证唯一,避免全局大锁),向其他人告知这个考生的成绩正在计算中(Running)
在计算考生成绩结束后,再修改当前 key 的状态为结束,告知其他人“我”已经完成这位考生的分数计算(Finished)
最后释放锁
@Resource
private RedissonClient redissonClient;@Resource
private RedisTemplate<String, Object> redisTemplate;public UserAnswerChooseAndEssayDTO getItemTotalScore(Integer examId, Integer userId) {
// 【解决前端同时刷新调用接口的并发问题】
// 1.获取 redisson 锁
RLock lock = RedissonUtil.getLock(examId + ":" + userId, redissonClient);
try {// 1.1 尝试加锁,等待最多15秒,持有锁最多30秒boolean isLocked = lock.tryLock(15, 30, TimeUnit.SECONDS);if (!isLocked) {String status = (String) redisTemplate.opsForValue().get(RedisConstants.SCORE_TASK_FINISHED_KEY + examId + ":" + userId);if (RUNNING.equals(status)) {throw new MyException(HttpStatus.SERVICE_UNAVAILABLE.value(), "成绩正在计算中,请稍后重试!");} else {throw new MyException(HttpStatus.REQUEST_TIMEOUT.value(), "成绩计算被阻塞,请重试!");}}// 1.2 先将当前考试考生之前已经完成计算的旧数据状态给删除redisTemplate.delete(RedisConstants.SCORE_TASK_FINISHED_KEY + examId + ":" + userId);// 1.3 进行标记,表示写操作正在运行中redisTemplate.opsForValue().set(RedisConstants.SCORE_TASK_FINISHED_KEY + examId + ":" + userId, RUNNING);///////////// 这是计算过程代码.......// 10.1 进行标记,表示写操作已经运行完成redisTemplate.opsForValue().set(RedisConstants.SCORE_TASK_FINISHED_KEY + examId + ":" + userId, FINISHED);return resultDTO;
} catch (InterruptedException e) {Thread.currentThread().interrupt();throw new MyException(HttpStatus.INTERNAL_SERVER_ERROR.value(), "计算过程被中断!");
} finally {if (lock.isHeldByCurrentThread()) {lock.unlock();}
}}
这里是 getUserTotalScoreList 方法(读操作)
由于需要避免数据不同步问题,读操作肯定需要比写操作要“慢”,才能保证数据的实时/最终一致性
首先,我这里是设置最大循环重试三次(根据自己项目的响应情况来)
如果从 Redis 状态标记中获取当前 examId + userId key 状态为 Running 的话,则表示还在计算中,这时需要进行等待(我这里是设置等待时间为 700 毫秒)
若在循环重试的过程中,发现其状态为 Finished 了,则表示当前考试考生的得分计算完成了,跳出
接着,再次进行获取 Redis 状态标记,查看是否还在计算中,进行相关处理
最后,这里的话我是直接进行返回查询了,如果有其他的情况或更严谨的流程,可以在相应的模块进行加固代码逻辑
@Resource
private RedisTemplate<String, Object> redisTemplate;// 重试的次数
public static final int MAX_RETRY_TIMES = 3;
// 每次等待的时间(毫秒)
public static final long WAIT_MILLIS = 700;public List<UserExamCustom> getUserTotalScoreList(Integer examId, Integer userId) {
// 【解决前端同时刷新调用接口的并发问题】
// 1.首先进行循环检查,若得分计算已完成则直接返回
for (int i = 0; i < MAX_RETRY_TIMES; i++) {String status = redisTemplate.opsForValue().get(RedisConstants.SCORE_TASK_FINISHED_KEY + examId + ":" + userId);if (status == null || Objects.equals(status, RUNNING)) {try {Thread.sleep(WAIT_MILLIS);} catch (InterruptedException e) {Thread.currentThread().interrupt();throw new MyException(HttpStatus.SERVICE_UNAVAILABLE.value(), "成绩查询被中断,请稍后重试!");}} else if (Objects.equals(status, FINISHED)) {break;}
}// 2.循环完毕,若还未完成,则进行提示,其他情况直接返回
if (Objects.equals(redisTemplate.opsForValue().get(RedisConstants.SCORE_TASK_FINISHED_KEY + examId + ":" + userId), RUNNING)) {throw new MyException(HttpStatus.BAD_REQUEST.value(), "成绩正在计算中,请稍后刷新页面!");
} else {return userExamMapper.selectByExamId(examId);
}}