介绍优酷投票系统,承载了优酷暑假战役、寒假战役、双十一促销,以及平时各自营销套路活动,系统设计相对合理,实现一塌糊涂,有代码为证(参考:列举一些神奇的代码)。
重构的理由:
-
代码僵化 ,难以改动,新增一个字段改动多出代码
-
代码脆弱 ,改动会出现意想不到的情况,改一个bug引发无数bug
-
代码晦涩 ,代码难以阅读理解,多位前任改bug的代码实现套娃式的修复
投票系统的设计
基础名词:投票活动,投票渠道,投票选项
动态名词:用户账户,用户流水,选项账户
备注:选项是抽象的投票对象,可以是用户,节目,CP等
选项账户汇总表,有个日期关键字,是为了把榜单可以按照时间维度切割,设计实现支持到小时级别,就是每个选项在指定渠道下没小时一条记录,一天最多24条,考虑到一个投票活动大概一个月左右,每个选项产生720条记录,既能保障业务等灵活性又能兼顾性能
动词流程:投票,加票,查询(选项排行榜,粉丝排行榜)
投票系统的重构
遇到的问题:
-
表结构问题,只有用户id,没有用户类型,在二三方的合作中,用户流水傻傻分不清,用户账户相关的数据和接口不能给合作方使用,一切都是基于优酷id设计
-
性能问题,黄色的三张表:用户账户,用户流水,选项账户都是单表
-
流程问题,变更中存在大量加锁,既要读写ldb,redis,又要查询数据库,为了数据一致和前期实现不合理补丁改bug,写了大量锁和读数据刷缓存的逻辑
-
质量问题,实现不合理,每个方法单独看貌似挺合理的,多个方法组合到一起看,为了打补丁修正之前的不合理实现,多处加锁还是数据不一致,忍无可忍
这些问题都是站在技术视角看的,换成业务视角,做个投票活动都提心吊胆的,各种小问题不断,天天做消防:刚得的票丢失,每日免费票没帐,活动结束了排行榜票数还在变化等等
列举一些神奇的代码:
-
票数过期逻辑,得票后n天票失效:chargeOffTicket方法,类似这种缓存和数据库放在一起的代码在投票环节也有
Date currDate = new Date();
//这个set集合,放入的是需要清票的时间戳,记录需要清票的时间
//这边看着没啥大问题,就是记录用户得票当天所得票数,以及把过期时间记录下,实现起来如此复杂
//简化后: 得票时间+过期设置 = 过期时间:票数
//我用了一个zset集合,member记录过期时间,value为所得票数
//这样到期后要失效多少票,在set集合里面都可以计算出来
Set<String> chargeOffSet = redisFactory.getRedisCacheService(votingDTO.getId()).smembers(getUserChargeOffCacheKey(votingDTO.getId(), ytid));
if (chargeOffSet == null || chargeOffSet.size() == 0) {
StringBuilder sb = new StringBuilder();
sb.append("redisCache.smember get fail, chargeOffSet=")
.append((chargeOffSet == null) ? "" : JSON.toJSONString(chargeOffSet));
GlobalLogger.info("getUserAccountInfo", sb.toString());
return;
}
String chargeOffKey = BaseUtil.buildKey("chargeOffTicket", votingDTO.getId(), ytid,
DateUtil.getDefaultFormatDate(new Date()));
int count = ldbTairManager.incrAndGet(chargeOffKey, 1, 86400);
if (count > 1) {
// logArgs.put("info", "今日已核销");
// logArgs.put("ytid", ytid);
// GlobalLogger.info("chargeOffTicket", logArgs.toJSONString());
return;
}
List<String> chargeOffList = new ArrayList<>();
for (String chargeOff : chargeOffSet) {
chargeOffList.add(chargeOff);
}
List<String> sortChargeOffList = chargeOffList.stream().sorted(Comparator.reverseOrder()).collect(
Collectors.toList());
int index = -1;
boolean chargeOffFlag = false;
//这里是对时间戳的判断
for (String str : sortChargeOffList) {
index++;
Long chargeOffTime = Long.valueOf(str);
if (chargeOffTime <= currDate.getTime()) {
chargeOffFlag = true;
break;
}
}
//这步可以防止用户每次进来都走核销的逻辑,这样每天最多走一次核销的逻辑
if (!chargeOffFlag) {
//不需要核销
return;
}
//这些看着好像也没啥问题,在看下面
if (voteLimitDTO.getTicketExp() == 1) {
//当天有效直接将用户账号清零,看看这段神奇的代码,删除用户账户缓存,再去查数据,再写缓存,
//其中这个getUserRemainTicketCount实现我放在2,看下面
ldbTairManager.delete(getRemainTicketCacheKey(votingDTO.getId(), ytid));
long chargeOffTicket = getUserRemainTicketCount(ytid, votingDTO.getId());
if (chargeOffTicket < 0) {
logArgs.put("info", "核销票数小于0");
logArgs.put("chargeOffTicket", String.valueOf(chargeOffTicket));
logArgs.put("ytid", String.valueOf(ytid));
logArgs.put("voteId", String.valueOf(votingDTO.getId()));
GlobalLogger.errorArgs("chargeOffTicket", logArgs.toJSONString());
userTicketAccountMapper.resetTicket(votingDTO.getId(), ytid, currDate);
} else {
userTicketAccountMapper.decreaseTicket(votingDTO.getId(), ytid, chargeOffTicket, currDate);
}
//核销票后删除用户票数信息缓存
ldbTairManager.delete(getRemainTicketCacheKey(votingDTO.getId(), ytid));
} else {
//这段查询数据库,然后删除1天以上过去的票
UserTicketDetailDO userTicketDetailDO = userTicketDetailCustomMapper.getUserTicketCountByCondition(
votingDTO.getId(), ytid, currDate);
if (userTicketDetailDO == null) {
return;
}
long userRemainTicket = getUserRemainTicketCount(ytid, votingDTO.getId());
long chargeOffTicket;
if (userRemainTicket >= userTicketDetailDO.getTicketCount()) {
chargeOffTicket = userTicketDetailDO.getTicketCount();
} else {
chargeOffTicket = userRemainTicket;
}
userTicketAccountMapper.decreaseTicket(votingDTO.getId(), ytid, chargeOffTicket, currDate);
TicketStatementDTO statementDTO = new TicketStatementDTO();
statementDTO.setCurrDate(currDate);
statementDTO.setVoteId(votingDTO.getId());
statementDTO.setBalance(userTicketDetailDO.getTicketCount().longValue());
statementDTO.setTicketCount((int)chargeOffTicket);
statementDTO.setTicketType(VoteConstants.CHARGE_OFF_TICKET);
statementDTO.setYtid(ytid);
recordTicketStatement(statementDTO, votingDTO);
//核销票后删除用户票数信息缓存
ldbTairManager.delete(getRemainTicketCacheKey(votingDTO.getId(), ytid));
}
-
获取用户账户信息:getUserRemainTicketCount,这个神奇的循环加锁
//如果不加锁当多个请求同时调该方法时可能会导致用户缓存中的票比实际的多,导致超投
String lockKey = BaseUtil.buildKey("userRemainTicketLock", String.valueOf(voteId), String.valueOf(ytid));
try {
int value = ldbTairManager.incrAndGet(lockKey, 1, 60);
int index = 0;
while (value > 1 && index < 10) {
index++;
JSONObject args = new JSONObject();
args.put("info", "出现锁竞争");
args.put("ytid", ytid);
args.put("voteId", voteId);
GlobalLogger.info("possibleError,getUserRemainTicketCount", args.toJSONString());
try {
Thread.sleep(10);
} catch (InterruptedException e) {
}
value = ldbTairManager.incrAndGet(lockKey, 1, 60);
}
if (index == 10) {
JSONObject args = new JSONObject();
args.put("info", "严重隐患,可能导致超投");
args.put("ytid", ytid);
args.put("voteId", voteId);
GlobalLogger.info("possibleError,getUserRemainTicketCount", args.toJSONString());
}
long returnTicketCount;
userVoteCount = ldbTairManager.get(getRemainTicketCacheKey(voteId, ytid));
if (userVoteCount == null || !(userVoteCount instanceof Integer)) {
UserTicketAccountExample example = new UserTicketAccountExample();
example.createCriteria().andVoteIdEqualTo(voteId).andYtidEqualTo(ytid);
List<UserTicketAccountDO> accountDO = userTicketAccountMapper.selectByExample(example);
Long ticketCount;
if (accountDO == null || accountDO.size() == 0) {
ticketCount = 0L;
} else {
ticketCount = accountDO.get(0).getBalance();
}
ldbTairManager.incr(getRemainTicketCacheKey(voteId, ytid), ticketCount.intValue(), DateUtil.getHour());
returnTicketCount = ticketCount;
} else {
returnTicketCount = (Integer)userVoteCount;
}
return returnTicketCount;
} finally {
ldbTairManager.delete(lockKey);
}
梳理投票系统:
-
整体设计思路没啥大问题可以保留,这起神奇的实现需要重新规划设计
-
用了一周的时间梳理每个方法,了解业务的本意,画出各个方法直接的连线
-
整体重构的思路: 同步操作全部在缓存中完成,持久化全部异步完成,用户数据和选项账户汇总分库分表
梳理后的业务实现
重构后的业务实现
重构后的业务图