考试重构-压测

11/10 ~ 11/23

重构原因

  • 原来考试系统bug很多,redis使用也不合理,30人的考试就可能存在有人进不去考试、交不了卷的情况
  • 原来的代码耦合性高,练习模式、考试模式可能开始考试用的不同函数、进入考试用的不同的函数,有时候改一个地方的功能影响到了另外的地方

重构基本思想

  • 考试数据分为共享数据(题目、考试等)和个人做题数据,共享数据使用redis进行缓存,缓存时间可以小一点,个人做题数据时间需要改大一些,至少要超过考试的时间,防止学生答案丢失
  • 原来代码做了预热逻辑,该部分对系统整体提升不大,删去;redis判活部分没有必要,redis可靠性还是很高的,如果想做redis开关也不应该用threadlocal,应该是一个统一开关
  • 原来缓存答案逻辑是定时缓存,定时缓存系统负载很大,很多请求都是无效的请求,改成上下题切换时进行缓存
  • 目前系统支持的考试类型包含:练习、考试、模拟考试、课程评价。不同的模式有不同的处理,使用多态来优化代码结构;《以多态取代条件表达式》
  • 缓存答案有两种思想:一题一个key-value,一个key保存一个answerList,最终选择方案为answerList,可以避免进入考试时对redis的遍历插入以及考试结束时的遍历读取

重构步骤

  1. 删除原来所有有关redis缓存的全部代码,测试
  2. 编写redis缓存通用类,将 (redis缓存不存在时,自动取数据库读取并插回redis)封装
  3. 编写公共缓存管理类,全部有关缓存的代码都在此进行,包括缓存不存在时的mybatisplus数据库读取操作,service必须使用这里暴露的公共方法(使用缓存),禁止在service中直接查询数据库。
  4. 在考试模式跑通测试后,编写多态代码扩展到(练习、考试、模拟考试、课程评价)模式
  5. 引入消息队列对交卷进行优化

redis通用类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 序列化方式 GenericFastJsonRedisSerializer 可以直接强转
public class RedisCache<T> {
private final RedisTemplate<String, Object> redisTemplate;
private final long defaultExpiration;
private final TimeUnit defaultExpirationUnit;

public RedisCache(RedisTemplate<String, Object> redisTemplate, long defaultExpiration, TimeUnit defaultExpirationUnit) {
this.redisTemplate = redisTemplate;
this.defaultExpiration = defaultExpiration;
this.defaultExpirationUnit = defaultExpirationUnit;
}

public T get(String key) {
return (T) redisTemplate.opsForValue().get(key);
}

public T get(String key, Supplier<T> supplier, long expiration, TimeUnit unit) {
T value = (T) redisTemplate.opsForValue().get(key);
if (value == null) {
value = supplier.get();
put(key, value, expiration, unit);
}
return value;
}
...
}

公共缓存管理类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class CommonRedisCacheBean {
public static final String EXAM_KEY = "v2Exam:resourceId:%s";

RedisTemplate<String, Object> objectRedisTemplate;

public RedisCache<Exam> EXAM_CACHE;

@Autowired
public CommonRedisCacheBean(@Qualifier("objectRedisTemplate") RedisTemplate<String, Object> objectRedisTemplate) {
this.objectRedisTemplate = objectRedisTemplate;
this.EXAM_CACHE = new RedisCache<>(objectRedisTemplate, 10, TimeUnit.MINUTES);
}

public Exam getExam(String resourceId){
return EXAM_CACHE.get(String.format(EXAM_KEY, resourceId),
() -> examMapper.selectOne(new LambdaQueryWrapper<Exam>().eq(Exam::getResourceId, resourceId)));
}

}

多态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class BaseExamHandler {
// 获取提示getNotice 不实现,交给子类
public Map<String, Object> getNotice(Exam exam){
throw new UnsupportedOperationException("This method is not implemented");
}

// 很多都一样,定义在父类
public ExamDataRes getExamInfo(String resourceId) {
// code
}
}

public class ExerciseHandler extends BaseExamHandler{
@Override // 具体自己的实现
public Map<String, Object> getNotice(Exam exam) {
Map<String, Object> noticeMap = new LinkedHashMap<>();
return noticeMap;
}
// 直接调用父
public ExamDataRes getExamInfo(String resourceId) {
return super.getExamInfo(resourceId);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class ExamHandlerFactory {
private final Map<Integer, BaseExamHandler> handlerMap = new HashMap<>();

@Autowired
public ExamHandlerFactory(
ExerciseHandler exerciseHandler,
ExamHandler examHandler,
SimulationHandler simulationHandler,
EvaluateHandler evaluateHandler) {
handlerMap.put(ExamType.EXERCISE, exerciseHandler);
handlerMap.put(ExamType.EXAM, examHandler);
handlerMap.put(ExamType.EXAM_SIMULATION, simulationHandler);
handlerMap.put(ExamType.Evaluate, evaluateHandler);
}

public BaseExamHandler getHandler(int type) {
BaseExamHandler handler = handlerMap.get(type);
if (handler == null) {
throw new IllegalArgumentException("Invalid exam type");
}
return handler;
}
}
1
2
BaseExamHandler handler = examHandlerFactory.getHandler(exam.getType());
handler.startExam(resourceId, exam);

消息队列

​ 引入原因:考试有两个并发峰值:进入考试阶段以及交卷阶段,进入考试阶段设计数据库的读取操作(考生是否进入过考试,进入过需要取出答案),提交阶段涉及数据库的插入操作。海量的数据库插入操作会影响系统的并发,使用mq来优化可以减轻系统压力,削峰

mq使用逻辑

  • 在学生提交交卷请求时,立马更新redis,之后直接返回。redis更新成功代表考试交卷成功,并同时发送一个mq请求
  • mq消费者消费该消息,将数据从redis插入到数据库
  • mq消费函数是幂等的,mq需要保证至少一次消费

压测

查看系统的负载

准备阶段

可能的影响因素:

  • 代码质量,是否使用缓存,缓存命中率
  • exam pod数量,pod的Xmx
  • gateway 、usercenter pod数量
  • redis
  • mysql

注意:

  1. pod扩容应该在考试前进行,动态扩容瞬间系统会炸
  2. 每次压测前注意清理oom的pod

参数:

  • 人数x=6k~10k
  • warm-up=100s
  • 题目延时5~180s,平均delay = 92.5s

进入峰值QPS = 在进入考试warm结尾:x/warm * 3(notices\startExam\getExamInfo) + x/delay

另外当交卷开始时会达到第二个峰值

过程

  1. 3pod压2k人,无压力
  2. 开启动态扩容,压4k人,扩容瞬间系统崩溃
  3. 提前给开好10pod 6k人,不使用mq,峰值在进入考试结尾以及交卷,尤其是交卷。交卷90%达到了10sRT。
    1. 分析:exam-pod的cpu利用率不高,推测是gateway usercenter的原因(第一时间运维同学不在没有找到mysql的pod),扩容gateway usercenter发现还是没有用
    2. rt:image-20231122140548390
  4. 在找到mysql机器后发现mysql负载很高,理论上如果同一场考试同一批人连续压测,不应该有任何mysql请求,全部数据都进入了缓存,回到本地机器调试开启gateway usercenter的debug发现,任何一个请求都进行了额外数据库的查询,该查询不合理需要优化掉,因此本次压测暂时到此结束。
  5. 此外skywalking也给出了慢SQL,但是第一时间没有关注到

结论

  • 在系统中,除了要关注自己写的代码外, 还需要注意整条链路;自己写的代码再好有别的瓶颈也没有用
  • cpu利用率是最简单的查看瓶颈的方式
  • 可观测的重要性