优酷投票系统设计和重构

本文涉及的产品
云数据库 Tair(兼容Redis),内存型 2GB
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, 186400);
        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));
}
AI 代码解读
  1. 获取用户账户信息:getUserRemainTicketCount,这个神奇的循环加锁
//如果不加锁当多个请求同时调该方法时可能会导致用户缓存中的票比实际的多,导致超投
        String lockKey = BaseUtil.buildKey("userRemainTicketLock", String.valueOf(voteId), String.valueOf(ytid));
        try {
            int value = ldbTairManager.incrAndGet(lockKey, 160);
            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, 160);
            }
            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);
        }
AI 代码解读

梳理投票系统:

  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
目录
打赏
0
0
0
0
0
分享
相关文章
通用点赞设计思路
点赞作为一个高频率的操作,如果每次操作都读写数据库会增加数据库的压力,所以采用缓存+定时任务来实现。点赞数据是在redis中缓存半小时,同时定时任务是每隔5分钟执行一次,做持久化存储,这里的缓存时间和任务执行时间可根据项目情况而定。
2542 2
go连接RabbitMQ "no access to this vhost"错误
连接的失败报错:RabbitMQ Exception (403) Reason: "no access to this vhost" 因为没有配置该用户的访问权限,可以通过 rabbitmqctl add_vhost admin 来添加,并赋予权限: rabbitmqctl set_permissions -p 用户名 admin ".
7592 0
更换(Pypi)pip源到国内镜像
pip国内的一些镜像 阿里云 http://mirrors.aliyun.com/pypi/simple/ 中国科技大学 https://pypi.mirrors.
246131 2
Tokenformer:基于参数标记化的高效可扩展Transformer架构
本文是对发表于arXiv的论文 "TOKENFORMER: RETHINKING TRANSFORMER SCALING WITH TOKENIZED MODEL PARAMETERS" 的深入解读与扩展分析。主要探讨了一种革新性的Transformer架构设计方案,该方案通过参数标记化实现了模型的高效扩展和计算优化。
481 0
推荐2款免费开源的标注工具,支持大模型对话标注
【LabelLLM】一款开源免费的大模型对话标注平台,专为优化大型语言模型的数据标注过程设计。支持灵活配置与多模态数据(音频、图像、视频),具备全面任务管理和AI辅助标注功能,大幅提升标注效率与准确性。了解更多请前往https://github.com/opendatalab/LabelLLM 【LabelU】一款轻量级开源标注工具,支持图像、视频、音频的高效标注。特色功能包括多功能图像处理、视频和音频分析等,简易灵活,支持多种数据格式输出。了解更多请前往https://github.com/opendatalab/labelU
1817 11
网上投票系统的设计与实现(论文+源码)_kaic
随着全球Internet的迅猛发展和计算机应用的普及,特别是近几年无线网络的广阔覆盖以及无线终端设备的爆炸式增长,使得人们能够随时随地的访问网络,以获取最新信息、参与网络活动、和他人在线互动。为了能及时地了解民情民意,把握人们近期关注的内容,政府机构以及各大门户网站等单位会将一些热点话题以投票的形式发布到他们的网站上面,供人们在线投票。因此,网络在线投票系统应运而生。 本文在此情况下设计了一款网上线投票系统。首先,结合实际的应用开发情况,对该系统做了详细的需求分析。然后给出该系统的结构和各功能模块的分析,通过详细的结构和数据库表的设计,最终构建出一个基于Web的、以Struts2框架和MySQ
liteflow快速开始
liteflow快速开始
158 0
保姆级教程,手把手教你实现SpringBoot自定义starter
保姆级教程,手把手教你实现SpringBoot自定义starter
11361 1
保姆级教程,手把手教你实现SpringBoot自定义starter
AI助理

你好,我是AI助理

可以解答问题、推荐解决方案等