目前审核分为三种,一种是审核评价文字,会变更评价的整体状态;一种是审核评价带图,会变更评价图片的状态;还有一种是审核回应,会变更回应的状态。现在存在一种情况,当审核方同时或者在很短的时间间隔内提交一个评价的多张图片状态更新请求,会有某张图片的更新操作没有生效。
出现此问题的主要原因是因为,每次更新图片状态时,都会先读整个ReviewData,更新该图片在ReviewData中的状态,然后再根据ReviewData更新Tair & 索引表 & Base表。
NOTE: 目前一条评价会存储在Mysql和Tair中,Tair中存储着ReviewData格式的评价;Mysql中分为2种,评价的完整信息存在BaseReview表,根据评价的特征会将评价存储在Shop/Deal/Vertical维度的索引表中。
审核逻辑:
评价审核:
-
reviewAuditFeedBack(List<UGCReviewAuditDTO> ugcReviewAuditDTOList, int opId)
-
reviewAuditAdministrator.auditReviewStatus(ugcReviewAuditDTO.getReviewId(), opId, ugcReviewAuditDTO.getStatus(), ugcReviewAuditDTO.getStatusCode(), ugcReviewAuditDTO.getComment());
-
从Tair获取ReviewData (这里可能会读到不正确的ReviewData)
-
前后状态一致
-
不执行操作
-
-
前后状态不一致
-
设置ReviewData的status和satuscode
-
更新ReviewData在Tair中的status (可能出错)
-
更新ReviewData在Index索引表中的status (可能出错)
-
- 异步更新
- 记Log OpType=LogOpType.AUDIT
- 更新BaseReview (可能出错)
- 更新各个index维度的评价计数 (可能出错)
- 更新Feed
- 更新点赞计数
- 更新商户待回应
- 更新最优点评
- 清basecache
- 发送Kafka消息
-
-
图片审核:
- updateReviewPicStatusByAdmin(long picId, int newStatus, int opUserId)
- reviewAuditAdministrator.auditReviewPicStatus(picId, opUserId, newStatus).getResult()
- 更新图片在ReviewPic表中的状态
- 从Tair获取ReviewData (这里可能会读到不正确的ReviewData)
- 更新ReviewData中Pic的状态 (可能出错)
- 将更新过的ReviewData写入Tair (可能出错)
- 更新索引表
- Deal表更新HasPic (可能出错)
- Shop表更新HasPic (可能出错)
- Vertical表更新Status
- 异步更新
- 记Log
- 更新BaseReview (可能出错)
- 更新评价计数
- reviewAuditAdministrator.auditReviewPicStatus(picId, opUserId, newStatus).getResult()
回应审核一般和评级&图片审核不是同一时间,暂时不予考虑。
根据以上流程,可以总结出以下特性:
- 审核完一张图片或评价 调用一次update方法,会在间隔很短的时间发起调用;
- 不管是审核图片还是审核文字,都需要构建ReviewData来更新Tair或Mysql,如果上一次更新还没有写入Tair,这一次更新又读了ReviewData,那么上一次的更新就会无效。
下面是一次审核更新图片状态无效的例子:
ID
|
变更类型
|
变更前
|
变更后
|
变更时间
|
解释
|
---|---|---|---|---|---|
213391736 | 信审 | 2016-12-15 17:32:41 | |||
213391748 | 信审 | picId: 2391693565 status: 1 picId: 2391969716 status: 1 picId: 2391113970 status: 1 | picId: 2391693565 status: 2 picId: 2391969716 status: 1 picId: 2391113970 status: 1 | 2016-12-15 17:32:42 | 将照片2391693565置为审核删除状态,图片2391969716和2391113970状态为正常,写入tair&更新图片再mysql中的状态 |
213391749 | 信审 | picId: 2391693565 status: 1 picId: 2391969716 status: 1 picId: 2391113970 status: 1 | picId: 2391693565 status: 1 picId: 2391969716 status: 2 picId: 2391113970 status: 1 | 2016-12-15 17:32:42 | 将照片2391969716置为审核删除状态,图片2391693565和2391113970状态为正常,写入tair&更新图片再mysql中的状态 |
解决方案
1. 用乐观锁解决:
问题:目前Tair 的API不提供 compare and set的功能,需要先读再写,但目前Tair读的99线在3ms左右,在读写操作期间如果数据发生变化,仍会出现脏数据。
2. 用队列解决:
由于是多台机器同时发送消息到队列,多台机器同时从队列消费消息,这样也无法保证时序性。使用延时队列可解决此问题。
队列:
- 使用Kafka延时队列
- 更新评价状态的操作不延时,由于ReviewData中图片顺序是一开始用户写点评的时候就确定的,除非被删除,否则不会变动。图片根据位置设置不同的延时时间,一个评价最多有9张图片,在发消息的时候设置延时,按照图片位置设置延时时间,Kafka最短延时为5s,第一张图延时5s,第二张图延时6s,以此类推。
虽然扩大了图片更新的间隔时间,但是不能彻底解决这个问题,还是有一定几率出现脏数。
- 更新评价状态的操作不延时,由于ReviewData中图片顺序是一开始用户写点评的时候就确定的,除非被删除,否则不会变动。图片根据位置设置不同的延时时间,一个评价最多有9张图片,在发消息的时候设置延时,按照图片位置设置延时时间,Kafka最短延时为5s,第一张图延时5s,第二张图延时6s,以此类推。
- 使用Kafka单机消费模式
- Kafka支持只有一个消费者消费,这个消费者消费该Partition的所有消息,如果这个消费者宕机了,会有后补消费者来消费。
需要额外配置,设置Kafka回调线程数为1,可以保证顺序执行,原则上能解决脏数据问题。但如果qps暴增,单线程单机器能不能撑得住,另外就是消费速度能不能跟上生产速度,如果跟不上,队列积压会出问题。
- Kafka支持只有一个消费者消费,这个消费者消费该Partition的所有消息,如果这个消费者宕机了,会有后补消费者来消费。
3. 用中间状态解决:
在Tair中维护一个审核中评价的队列,在审核一个评价时,在Tair中新建一个Prefix key,评价状态是一个skey, 每个图片是一个skey,每次审核评价或图片时,直接更新对应的skey状态。下面为一个评价审核中存储数据的示例:
审核结束或到指定时间后,整体的回写到Tair和Mysql中。下面是这种方法的操作执行顺序。
- 在调用reviewAuditFeedBack 或updateReviewPicStatusByAdmin时,先检查在Tair中有没有key,如果有,更新该key对应的状态。如果没有,新建该ReviewData的key。比如说review1有3张图,pkey: ugc_audit_review1, skey: review1, pic1, pic2, pic3
- 图片:
- 更新ReviewPic表中图片的状态,写Log
- 评价:
- 等指定时间到了,根据key对应的状态,构建新的ReviewData,按照原来的流程执行。
4. 用分布式锁解决:
使用Tair实现一套简单的分布式锁。tair目前提供了put、setNx,expireLock和expireUnlock等操作。
下面是更新图片状态的步骤:
- updateReviewPicStatusByAdmin
- 根据reviewId构建key, setNx(key, value, expireTime)到Tair中,
- 如果没有key,则setNx成功,执行更新程序;
- 如果有key,则setNx失败,说明该评价正在更新,将更新信息放入队列;
- 如果在此过程中出现异常,则说明与Tair通信出现问题,则直接更新。
- 根据reviewId构建key, setNx(key, value, expireTime)到Tair中,
- 有一个线程专门消费待更新队列,更新流程同上。
Tair目前建议队列或者Set元素个数不超过500个,咨询何中磊后得知,如果队列长度大于500, 只会影响响应时间。
极端情况:如果发生流控或timeout, 直接按当前步骤处理。
问题:
- 如果加锁失败,则后续操作都是异步的,还是有可能失败,对于异步执行的,返回给审核方什么?
- 对于其他更新ReviewData的操作,也有可能出现数据不一致的情况,要不要纳入考虑?
- 队列是继续使用Tair中的List,还是Kafka?