11/10 ~ 11/23
重构原因
- 原来考试系统bug很多,redis使用也不合理,30人的考试就可能存在有人进不去考试、交不了卷的情况
- 原来的代码耦合性高,练习模式、考试模式可能开始考试用的不同函数、进入考试用的不同的函数,有时候改一个地方的功能影响到了另外的地方
重构基本思想
- 考试数据分为共享数据(题目、考试等)和个人做题数据,共享数据使用redis进行缓存,缓存时间可以小一点,个人做题数据时间需要改大一些,至少要超过考试的时间,防止学生答案丢失
- 原来代码做了预热逻辑,该部分对系统整体提升不大,删去;redis判活部分没有必要,redis可靠性还是很高的,如果想做redis开关也不应该用threadlocal,应该是一个统一开关
- 原来缓存答案逻辑是定时缓存,定时缓存系统负载很大,很多请求都是无效的请求,改成上下题切换时进行缓存
- 目前系统支持的考试类型包含:练习、考试、模拟考试、课程评价。不同的模式有不同的处理,使用多态来优化代码结构;《以多态取代条件表达式》
- 缓存答案有两种思想:一题一个key-value,一个key保存一个answerList,最终选择方案为answerList,可以避免进入考试时对redis的遍历插入以及考试结束时的遍历读取
重构步骤
- 删除原来所有有关redis缓存的全部代码,测试
- 编写redis缓存通用类,将 (redis缓存不存在时,自动取数据库读取并插回redis)封装
- 编写公共缓存管理类,全部有关缓存的代码都在此进行,包括缓存不存在时的mybatisplus数据库读取操作,service必须使用这里暴露的公共方法(使用缓存),禁止在service中直接查询数据库。
- 在考试模式跑通测试后,编写多态代码扩展到(练习、考试、模拟考试、课程评价)模式
- 引入消息队列对交卷进行优化
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
| 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 { public Map<String, Object> getNotice(Exam exam){ throw new UnsupportedOperationException("This method is not implemented"); }
public ExamDataRes getExamInfo(String resourceId) { } }
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
注意:
- pod扩容应该在考试前进行,动态扩容瞬间系统会炸
- 每次压测前注意清理oom的pod
参数:
- 人数x=6k~10k
- warm-up=100s
- 题目延时5~180s,平均delay = 92.5s
进入峰值QPS = 在进入考试warm结尾:x/warm * 3(notices\startExam\getExamInfo) + x/delay
另外当交卷开始时会达到第二个峰值
过程
- 3pod压2k人,无压力
- 开启动态扩容,压4k人,扩容瞬间系统崩溃
- 提前给开好10pod 6k人,不使用mq,峰值在进入考试结尾以及交卷,尤其是交卷。交卷90%达到了10sRT。
- 分析:exam-pod的cpu利用率不高,推测是gateway usercenter的原因(第一时间运维同学不在没有找到mysql的pod),扩容gateway usercenter发现还是没有用
- rt:
- 在找到mysql机器后发现mysql负载很高,理论上如果同一场考试同一批人连续压测,不应该有任何mysql请求,全部数据都进入了缓存,回到本地机器调试开启gateway usercenter的debug发现,任何一个请求都进行了额外数据库的查询,该查询不合理需要优化掉,因此本次压测暂时到此结束。
- 此外skywalking也给出了慢SQL,但是第一时间没有关注到
结论
- 在系统中,除了要关注自己写的代码外, 还需要注意整条链路;自己写的代码再好有别的瓶颈也没有用
- cpu利用率是最简单的查看瓶颈的方式
- 可观测的重要性