一、需求背景与设计
最近开发的需求中,有一项需要根据评价的内容,实时统计评价的平均分,允许有秒级别的延迟。
这是很传统的统计模型,通过评价的服务保存流水,再通过MQ异步insert or update的形式保存统计数据。由于需要统计平均分,所以库表的设计存储了评价的总分和评价人数。简化的同步流程如图所示。
该方案的优点:
- 防止多次反复地查询当天的历史数据、减少了不必要的计算。
- 有数据直接更新,MySQL唯一约束处理并发情况,无需加锁做查询和更新。
该方案的缺点: 1.需要最大程度保证MQ幂等性,一条课堂id的记录只能被消费一次。如果有消息被重复消费,那么后续的数据统计都受到影响,需要重新手动计算流水数据。
二、保证MQ的幂等性
本次需求前端存在并发请求的情况,有可能在同一时间内有多条同样的唯一标识流水发出,只需统计其中一条。
2.1常见应对MQ幂等方案
传统的保证MQ消费幂等性的方案,原理都是通过一致性标识去防止重复发送-或者重复消费。
- 对于消息生产者:
并发情况下只要在上游保证第一条数据更新的过程其他线程不能查询并更新即可,因为后续进来的请求都会先查询数据库,如图所示。
- 对于消息消费者:
1.业务操作之前进行状态查询:
消费端开始执行业务操作时,通过幂等id首先进行业务状态的查询,如:修改订单状态环节,当订单状态为成功/失败则不需要再进行处理。那么我们只需要在消费逻辑执行之前通过订单号进行订单状态查询,一旦获取到确定的订单状态则对消息进行提交,通知broker消息状态为:ConsumeStatus.CONSUME_SUCCESS 。
2.唯一性约束: 设定好数据库唯一约束,最坏的条件下保证数据的唯一性。
上述第一点操作并不能保证一定不出现重复的数据,如:并发插入的场景下,如果没有乐观锁、分布式锁作为保证的前提下,很有可能出现数据的重复插入操作,因此我们务必要对幂等id添加唯一性索引,这样就能够保证在并发场景下也能保证数据的唯一性。
3.引入锁机制
上述的第一点中,如果是并发更新的情况,没有使用悲观锁、乐观锁、分布式锁等机制的前提下,进行更新,很可能会出现多次更新导致状态的不准确。 如:对订单状态的更新,业务要求订单只能从初始化->处理中,处理中->成功,处理中->失败,不允许跨状态更新。如果没有锁机制,很可能会将初始化的订单更新为成功,成功订单更新为失败等异常的情况。 高并发下,建议通过状态机的方式定义好业务状态的变迁,通过乐观锁、分布式锁机制保证多次更新的结果是确定的,悲观锁在并发环境不利于业务吞吐量的提高因此不建议使用。
4.消息记录表: 这种方案和业务层做的幂等操作类似,指定唯一约束,存储消息流水,间接实现消费的幂等。
首先准备一个消息记录表,在消费成功的同时插入一条已经处理成功的消息id记录到该表中,注意一定要 与业务操作处于同一个事物 中,当新的消息到达的时候,根据新消息的id在该表中查询是否已经存在该id,如果存在则表明消息已经被消费过,那么丢弃该消息不再进行业务操作即可。
2.2结合业务选择方案
首先定义唯一标识,按照业务需求,将课堂id与当天日期作为唯一标识。(其实业务上还有其他字段需要作为唯一标识,在文章中做了简化)
参照以上方案,排除不可用的方案
2.2.1方案选定
经过讨论,由于教评服务的锁和统计服务有所耦合。为了解耦并提升教评服务的代码可读性。决定使用消息记录表的方式做消息去重。
消息记录表存储方案的选定,根据业务只需要满足统计维度到”天“级别即可。最好支持自动过期。
2.2.2额外的防护保证数据正确性
-
前端:做好防抖机制,防止误操作
-
后端:做好事件告警通知,出现sql更新失败、网络问题时及时告警,保留记录。后续统一修复数据。