优酷投票系统设计和重构

本文涉及的产品
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
云数据库 Tair(兼容Redis),内存型 2GB
简介:      介绍优酷投票系统,承载了优酷暑假战役、寒假战役、双十一促销,以及平时各自营销套路活动,系统设计相对合理,实现一塌糊涂,有代码为证(参考:列举一些神奇的代码)。重构的理由:代码僵化,难以改动,新增一个字段改动多出代码代码脆弱,改动会出现意想不到的情况,改一个bug引发无数bug代码晦涩,代码难以阅读理解,多位前任改bug的代码实现套娃式的修复投票系统的设计基础名词:投票活动,投票渠道,投票

     介绍优酷投票系统,承载了优酷暑假战役、寒假战役、双十一促销,以及平时各自营销套路活动,系统设计相对合理,实现一塌糊涂,有代码为证(参考:列举一些神奇的代码)。

重构的理由:

  1. 代码僵化 ,难以改动,新增一个字段改动多出代码
  2. 代码脆弱 ,改动会出现意想不到的情况,改一个bug引发无数bug
  3. 代码晦涩 ,代码难以阅读理解,多位前任改bug的代码实现套娃式的修复

投票系统的设计

基础名词:投票活动,投票渠道,投票选项

动态名词:用户账户,用户流水,选项账户

备注:选项是抽象的投票对象,可以是用户,节目,CP等

         选项账户汇总表,有个日期关键字,是为了把榜单可以按照时间维度切割,设计实现支持到小时级别,就是每个选项在指定渠道下没小时一条记录,一天最多24条,考虑到一个投票活动大概一个月左右,每个选项产生720条记录,既能保障业务等灵活性又能兼顾性能

动词流程:投票,加票,查询(选项排行榜,粉丝排行榜)

 

投票系统的重构

遇到的问题:

  1. 表结构问题,只有用户id,没有用户类型,在二三方的合作中,用户流水傻傻分不清,用户账户相关的数据和接口不能给合作方使用,一切都是基于优酷id设计
  2. 性能问题,黄色的三张表:用户账户,用户流水,选项账户都是单表
  3. 流程问题,变更中存在大量加锁,既要读写ldb,redis,又要查询数据库,为了数据一致和前期实现不合理补丁改bug,写了大量锁和读数据刷缓存的逻辑
  4. 质量问题,实现不合理,每个方法单独看貌似挺合理的,多个方法组合到一起看,为了打补丁修正之前的不合理实现,多处加锁还是数据不一致,忍无可忍

这些问题都是站在技术视角看的,换成业务视角,做个投票活动都提心吊胆的,各种小问题不断,天天做消防:刚得的票丢失,每日免费票没帐,活动结束了排行榜票数还在变化等等

列举一些神奇的代码:

  1. 票数过期逻辑,得票后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));
}
  1. 获取用户账户信息: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);
        }

梳理投票系统:

  1. 整体设计思路没啥大问题可以保留,这起神奇的实现需要重新规划设计
  2. 用了一周的时间梳理每个方法,了解业务的本意,画出各个方法直接的连线
  3. 整体重构的思路: 同步操作全部在缓存中完成,持久化全部异步完成,用户数据和选项账户汇总分库分表

梳理后的业务实现

重构后的业务实现

[文件: 投票.xmind] 请在PC端预览或下载

[文件: 投票-优化.xmind] 请在PC端预览或下载

重构后的业务图

 

相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore &nbsp; &nbsp; ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库&nbsp;ECS 实例和一台目标数据库&nbsp;RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&amp;RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
相关文章
|
8月前
|
存储 安全 区块链
DAPP众筹互助模式系统开发|技术方案
智能合约的主要目的之一是在多方之间去信任化地自动达成金融协议
|
4月前
|
缓存 监控 前端开发
设计一个高性能网站
设计一个高性能网站
41 1
|
6月前
|
缓存 前端开发 测试技术
什么是七星创客系统丨七星创客系统开发规则玩法/设计方案/逻辑需求/案例项目/源码功能
七星创客系统开发指南是一个帮助开发人员理解并完成七星创客系统的开发任务的指南。以下是一个简要的开发指南需求:
|
开发框架 小程序 前端开发
七星创客丨推三返一丨系统开发案例详细,七星创客推三返一丨七星创客系统开发规则玩法丨成熟方案丨源码逻辑
  随着互联网的普及和电商的迅速发展,越来越多的消费者开始选择在线购物。为了吸引更多的消费者,许多电商平台和卖家推出了各种促销模式,其中推三返一模式系统备受青睐
|
存储 安全 区块链
Jogger慢跑者跑鞋/链游项目系统开发(开发方案),Jogger跑鞋NFT链游模式系统开发详细案例及源码技术
  区块链是一种将数据区块按照时间顺序组合成的链式结构,是去中心化系统中各节点共享且共同维护的分布式数据账本,具体的:各节点由P2P组网方式相互连通和交互,受激励机制激励贡献自身算力,
DAPP公排互助拆分系统开发详情原理丨DAPP拆分互助公排系统开发玩法功能/方案设计/案例分析/成熟技术/源码版
The lifecycle of smart contracts can be summarized into six stages based on their operational mechanisms: negotiation, development, deployment, operation and maintenance, learning, and self destruction. The development stage includes contract testing before contract chaining, while the learning sta
|
存储 安全 算法
TechFinger游戏搬砖平台系统开发方案详细丨TechFinger搬砖游戏系统开发案例项目/源码功能/成熟技术
去中心化:以分布式网络为基础结构,对数据进行验证、记账、存储、维护和传输等操作,利用纯数学方法建立节点之间的交互信任关系,进而形成去中心化、可信任的分布式系统;
|
NoSQL Redis 容器
浅谈泰山众筹系统开发*成熟技术解决代码方案
浅谈泰山众筹系统开发*成熟技术解决代码方案
104 0
|
负载均衡 安全 测试技术
洽谈区块链游戏项目系统开发逻辑讲解方案及技术参考原理
洽谈区块链游戏项目系统开发逻辑讲解方案及技术参考原理
304 0
|
存储 算法 安全
关于泰山众筹模式系统开发逻辑分析丨泰山众筹DAPP模式详细开发方案(源码部署)
关于泰山众筹模式系统开发逻辑分析丨泰山众筹DAPP模式详细开发方案(源码部署)
130 0