文章详情

短信预约-IT技能 免费直播动态提醒

请输入下面的图形验证码

提交验证

短信预约提醒成功

空间换时间-将查询数据性能提升100倍的计数系统实践

2024-11-28 14:09

关注

1 背景

侠客汇的业务运营,根据目前公司的业务体量和运营方式,结合市场上对标竞品的DAU数据分析,再借鉴国际上有很多会员制的自由交易市场玩法,决定建立一个B2B的二手同行自由交易平台。通过提供担保交易能力,让所有交易能在平台内完成闭环,平台通过真实数据为商户提供信息认证,打造具有公信力的背书。通过推荐和风控能力形成护城河,让用户留在平台,实现合作共赢。

2 前言

在信息爆炸的时代,计数系统几乎无处不在,从社交平台上的点赞量、评论数,到电商平台的浏览量、订单数量,再到内容网站的访问量统计,计数系统为各种业务提供了实时、准确的数据支持。这些数据不仅是简单的数字,它们可以反映出用户对内容的兴趣、商品的受欢迎程度、市场的需求变化,甚至可以用于预测未来趋势。对于企业和平台而言,计数系统能够提供一个有效的量化依据,帮助优化产品、制定营销策略、提升用户体验,因此它成为了数据统计和用户分析的核心工具。

3 需要统计的计数维度

3.1 按照统计内容维度划分

3.2 按照统计时间维度划分

具体案例:

个人主页会有我关注的数量,关注我的数量,靠谱数量,交易数量,以及一系列的按时间段统计的跑马灯数据。多数用户维度相关的数据都是在个人中心的上半部分进行展示,而下半部分,则是对这个用户内容维度,交易维度统计的一个汇总。同时,统计的数据,既有实时维度,也会有时间段维度,例如,我们会统计用户的总的交易单量,也会统计这个用户最近N天的交易数量。

图片

在首页找同行页面,顾名思义,找同行页面就是用来寻找用户的,所以这个页面会重点展示这个用户在用户维度的数据统计,同行关注数,交易人数,靠谱数等。

图片

在自由市场页面,我们同样会重点展示这个用户在用户维度的数据统计,但是与此同时,我们还会展示他的交易评分,方便有需要的用户,能够多维度的筛选自己的交易伙伴。

图片

在消息通知页面,我们则着重统计这个用户需要关注的动态数据,点赞数,评论数,新增关注数量等等。

图片

4 为什么要做自己的计数系统

上面已经陈述了我们所需要的统计的计数业务场景,以及不同的统计维度和统计方式。简单的将我们需要的计数功能做一个总结:

功能总结完成,从节省时间和资源的角度来说,公司的其他部门是不是已经有对应的功能可以提供了呢?我们首先想到的是数据组,毕竟数据组在整理和统计数据这方面是专业的。其次我们想到的是中台系统,是不是有对应的功能可以直接提供。结果调研之后发现,两个部门现有的功能,并不能完全支持我们的需求。

基于以上调研结果,并没有现有的功能能够直接满足我们所有的需求,我们只能自己来实现这个计数的功能。

5 计数系统的设计方案

既然已经决定要自己来做,那么就要从业务实际需要的业务场景来设计我们需要实现的功能。计数系统总的来说,实现我们所需要的功能并不是什么难点,设计方案的选择,主要还是看我们自己的侧重点,如果是开发时间角度考虑,可以选能够快速实现功能的短期方案。如果是从长远角度考虑,想让计数系统独立承担一个类似中台的角色,那也可以将计数系统作为一个独立的通用模块进行开发。

5.1 计数内置的架构设计

在前言中已经说过,本次开发是临危受命,时间很紧张,那么如果想要在有效的时间内完成本次开发,时间是不可忽略的客观条件。那么,最快速的方案莫过于直接采用count数据表+缓存的方式,这种方式在体量较小时,成本低、性能高、绝对精准,但随着统计数据的体量逐渐变大、微服务拆分越来越细之后,该方案就会越来越难以支撑业务。

count表方案,基本可以总结为:

所以这种方案一定会造成以下这几种问题:

综上所属,短期的方案虽然短小精悍,但是后期的隐患比较大,维护成本会很高,不是合理的选择方案。

5.2 计数外置的架构设计

结合侠客汇当前的业务现状、体量以及考虑中长期体量增长的规划,我们也调研了业内比较常见的一些实现方案,最终决定单独维护一套计数系统。由计数系统来统计侠客汇所有的计数逻辑,计数系统和具体的业务逻辑完全脱离,只负责统计各个业务场景的计数,具体的流程图如下:图片

5.2.1 计数方案中字段的定义

之所以设计了这几个字段,是因为我们可以通过最小的代价来实现最通用的功能。

以上字段,同样是redis的key的重要组成,redis的存储实体如下图

图片

接下来说下计数系统的具体功能,我们对外提供的接口如下图:

public interface BizCountService {
    
    ZZOpenScfBaseResult reportCount(ReportCountRequest request);

    
    boolean saveDateOpt(BizCountRecordMsg msg);

    
    boolean updateDateOpt(HeroBizCountDate heroBizCountDate, BizCountRecordMsg msg);

    
    ZZOpenScfBaseResult total(TotalRequest request);

    
    ZZOpenScfBaseResult countBatch(CountBatchRequest request);

    
    ZZOpenScfBaseResult clear(ClearRequest request);

    
    ZZOpenScfBaseResult countTimeBetween(CountTimeBetweenRequest request);

    
    ZZOpenScfBaseResult> batchCountTimeBetween(CountTimeBetweenBatchRequest request);

    
    ZZOpenScfBaseResult countRecent(CountRecentRequest request);

    
    ZZOpenScfBaseResult> batchCountRecent(CountRecentBatchRequest request);

    
    Long combineTotal(CombineTotalRequest request);
}

我们整体的业务统计维度如下:

public enum BizCountType {

    
    USER_POST_COUNT("userPostCount","用户发帖数"),
    
    POST_REMARK_COUNT("postRemarkCount", "帖子评论数"),
    
    POST_APPROVE_COUNT("postApproveCount", "帖子点赞数"),
    
    REMARK_UNREAD_COUNT("remarkUnreadCount", "评论列表未读总数"),
    
    ATTENTION_UNREAD_COUNT("attentionUnreadCount", "关注列表未读总数"),
    
    APPROVE_UNREAD_COUNT("approveUnreadCount", "点赞列表未读总数"),
    
    REMARK_APPROVE_COUNT("remarkApproveCount", "评论点赞数"),
    
    RELIABLE_COUNT("reliableCount", "靠谱数"),
    
    UNRELIABLE_COUNT("unreliableCount", "不靠谱数"),
    
    RECENT_COUNT("recentCount", "近期动态个数"),
    
    USER_RECYCLE_ORDER_COUNT("userRecycleOrderCount", "用户回收单成交数"),
    
    USER_ATTENTION_COUNT("userAttentionCount", "用户关注数"),

    
    USER_FOLLOWER_COUNT("userFollowerCount", "用户粉丝数"),
    
    USER_SUBMISSION_COUNT("userSubmissionCount", "用户送检数"),

    
    PRICE_SHEET_COUNT("priceSheetCount", "报价单计数"),

    
    RELIABLE_UNREAD_COUNT("reliableUnreadCount", "靠谱未读计数"),
    
    FELLOW_CERTIFICATE_UNREAD_COUNT("fellowCertificateUnreadCount", "同行认证未读计数"),

    
    USER_B2B_ORDER_SOLD_COUNT("b2bOrderSoldCount", "用户B2B订单卖出计数"),

    
    USER_B2B_ORDER_PURCHASE_COUNT("b2bOrderPurchaseCount", "用户B2B订单买入计数"),

    
    USER_COMMODITY_PUBLISH_COUNT("userProductPublishCount", "用户发布商品计数"),

    
    USER_COMMODITY_SOLD_COUNT("userProductSoldCount", "用户已售商品计数"),

    
    USER_COMMODITY_PURCHASE_COUNT("userProductPurchaseCount", "用户已购商品计数"),
    
    COMMODITY_BROWSE_COUNT("commodityBrowseCount", "商品详情浏览数量"),
    
    COMMODITY_NEGATIVE_FEEDBACK_COUNT("commodityNegativeFeedbackCount", "商品负反馈数量"),
    ;
    
    private String code;
    
    private String desc;

    public static BizCountType getByCode(String code) {
        return EnumParser.parse(BizCountType.class, BizCountType::getCode, code);
    }
}

5.2.2 计数系统的上报流程

在整个上报的流程中,主要分为三个部分,获取上报数据,处理上报数据,上报数据持久化。

获取上报数据

数据的获取一般有两种方式,通过接口或通过MQ的方式,本次我们采取的是直接接口调用的方式进行处理。

之所以考虑直接用接口调用的方式进行处理,主要考虑一下几个方面:

处理上报数据

每个接口,会有一些逻辑上的校验,例如,业务类型和实体id不能为空,不做其他的业务逻辑的校验,保持计数系统的通用性,避免业务的侵入。

上报数据持久化

持久化部分主要分为两块,一是DB持久化,二是对于缓存的更新。

我们整体的流程是,将数据库的变更和redis的缓存放在同一个事务中,优先更新数据库,然后将计数流水发送mq消息,由另一个接口单独进行流水统计,最后更新redis缓存,如果事务失败的话,可以保证整体的一致性。至于数据的加减,由业务方来控制,加减的大小也由业务方来控制,我们只进行傻瓜式操作。具体代码如下:

public ZZOpenScfBaseResult reportCount(ReportCountRequest request) {
        boolean valid = checkAndProcessReportRequest(request);
        if(!valid){
            return ZZOpenScfBaseResult.buildErr(-1,"参数不合法");
        }

        //执行插入、更新总数的逻辑
        boolean locked = redissionLockHelper.tryLockBizCountTotal(request.getEntityId(), request.getBizType(), () -> {
            heroBizCountTotalManager.saveOrUpdate(request.getEntityId(), request.getBizType(), request.getCount());
        });
        if(!locked){
            log.error("lock failed,request:{}", request);
            WxWarnTemplateUtil.warnOutService("计数上报-获取锁异常");
            return ZZOpenScfBaseResult.buildErr(-1,"获取锁失败");
        }

        //发送消息
        try {
            bizCountRecordProducer.sendBizCountRecordMsg(buildRecordMsg(request));
        }catch (Exception e){
            WxWarnTemplateUtil.warnOutService("计数上报-发送消息异常");
            log.error("send report msg error, request:{}", request, e);
        }
        //同步总量至缓存,不影响最终一致性,且缓存有有效期,所以不阻塞流程
        try {
            syncTotal2Cache(request.getBizType(), request.getEntityId());
        }catch (Exception e){
            log.error("sync total from db error, request:{}", request, e);
            WxWarnTemplateUtil.warnOutService("计数上报-同步数据至缓存异常");
        }
        return ZZOpenScfBaseResult.buildSucc("");
    }

不得不说的技术设计细节:以空间换时间

从以上代码中可以看出,我们在整个存储的过程中是发送了一条MQ消息,还记得我们之前提过,我们是有时间段维度的数据统计的,这个消息就是帮助我们缩短时间段查询响应时间的关键,是真正实现了我们以空间换时间的地方。具体代码逻辑如下:

public boolean bizCountRecord(String msgId, BizCountRecordMsg body) {
        log.info("bizCountRecord msgId={} body={}", msgId, GsonUtil.toJson(body));
        AtomicBoolean rst = new AtomicBoolean();
        boolean locked = redissionLockHelper.tryLockBizCountTotal(body.getEntityId(), body.getBizType(), () -> {
            HeroBizCountDate heroBizCountDate = heroBizCountDateManager.get(body.getEntityId(), body.getBizType().getCode(), body.getTimestamp());
            if(heroBizCountDate == null){
                rst.set(bizCountService.saveDateOpt(body));
            }
            else{
                rst.set(bizCountService.updateDateOpt(heroBizCountDate, body));
            }
        });
        if(!locked){
            log.info("bizCountRecord msgId={} body={} lock failed", msgId, GsonUtil.toJson(body));
            WxWarnTemplateUtil.warnOutService("计数消费-获取锁失败");
            return false;
        }
        return rst.get();
    }

从以上代码可以看出,我们在接受到了消息的同时,又单独维护了一条以业务类型和实体id为组合key的,以天为维度的数据汇总表。有了这个数据表之后,我们就有了一条天然的时间维度。如果需要查询N天的数据,就不在需要count上报数据的流水表,可以直接通过当前的数据表,以天的问题来进行查询。如果同一个业务类型和实体id,每天有1000的数据上报,在流水表中我们需要查询3000条数据,而在这个以天为维度的汇总表中,我们只需要查询3条数据。这个比例会随着上报计数数量级的增加,越来越大,让我们的设计方案优势变得更加突出。

5.2.2 计数领域的读取流程

public Map countTimeBetweenInternal(List entityIds, BizCountType bizType, Date start, Date end) {
        Map totalMap = Maps.newHashMapWithExpectedSize(entityIds.size());
        if(!BizCommonDateUtils.containsWholeDays(start, end)){
            return heroBizCountRecordManager.computeRestTime(entityIds, bizType, start, end, Maps.newHashMap());
        }else{
            Map dateRangeMap = heroBizCountDateManager.getDateRange(entityIds, bizType, start, end);
            Map dateCountMap = heroBizCountDateManager.countDateBetween(entityIds, bizType, start, end);
            Map restCountMap= heroBizCountRecordManager.computeRestTime(entityIds, bizType, start, end,dateRangeMap);
            entityIds.forEach(entityId -> {
                Long dateCount = dateCountMap.get(entityId);
                Long restCount = restCountMap.get(entityId);
                if(dateCount != null && restCount != null){
                    totalMap.put(entityId, dateCount + restCount);
                }else if(dateCount != null){
                    totalMap.put(entityId, dateCount);
                }else if(restCount != null){
                    totalMap.put(entityId, restCount);
                }
            });
        }
        return totalMap;
    }

6 总结与规划

计数系统外置的架构设计也是业内比较通用的设计方案。计数系统外置的架构设计和传统的计数系统内置的架构设计相比,它能够显著降低各业务在复杂计数场景下的维护成本,增强代码功能的复用性与通用性,提高迭代效率并提升系统稳定性。独立出来后,一旦出现异常,业务可在短时间内进行降级处理,进而减小对核心业务的影响范围。此外,针对时间段查询,采用以空间换时间的设计方式,能够减少数据的查询数量,从而提升查询性能,缩短查询时间。当然,我们本次受限于开发时间,也有一些不足之处:

时间范围的查询直接是DB查询。目前的时间段查询还是通过count表直接进行的查询,不过目前时间段查询数据统计需要用到的地方不多,暂时不会有性能方面的影响,后续可以通过持续迭代来进行改进。

没有根据业务的使用场景来进行划分。统计数据的使用也有读多写少的场景,使用缓存来保存读多写少的计数,其实一致性要求不高的计数,也可以先用缓存保存,然后定期刷到数据库中,以降低数据库的读写压力。

来源:转转技术内容投诉

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

软考中级精品资料免费领

  • 历年真题答案解析
  • 备考技巧名师总结
  • 高频考点精准押题
  • 2024年上半年信息系统项目管理师第二批次真题及答案解析(完整版)

    难度     813人已做
    查看
  • 【考后总结】2024年5月26日信息系统项目管理师第2批次考情分析

    难度     354人已做
    查看
  • 【考后总结】2024年5月25日信息系统项目管理师第1批次考情分析

    难度     318人已做
    查看
  • 2024年上半年软考高项第一、二批次真题考点汇总(完整版)

    难度     435人已做
    查看
  • 2024年上半年系统架构设计师考试综合知识真题

    难度     224人已做
    查看

相关文章

发现更多好内容

猜你喜欢

AI推送时光机
位置:首页-资讯-后端开发
咦!没有更多了?去看看其它编程学习网 内容吧
首页课程
资料下载
问答资讯