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

1 背景

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

2 前言

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

3 需要统计的计数维度

3.1 按照统计内容维度划分

  • 内容维度:用户发帖数,帖子评论数,帖子点赞数,评论列表未读总数,关注列表未读总数,点赞列表未读总数,评论点赞数,靠谱未读计数,同行认证未读计数,商品详情浏览数量,商品负反馈数量。

  • 用户维度:近期动态个数,用户关注数,用户粉丝数。

  • 交易维度:用户回收单成交数,用户送检数,报价单计数,用户B2B订单卖出计数, 用户B2B订单买入计数,用户发布商品计数,用户已售商品计数,用户已购商品计数。

3.2 按照统计时间维度划分

  • 实时维度:上述内容维度的统计基本都是实时统计的维度。

  • 时间段维度:最近N天发布的商品数,最近N天售卖商品的数量,最近N天发布的帖子数等等。

具体案例:

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

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

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

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

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

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

  • 基础功能:能够实时计算需要统计数量的总数,而且能够批量查询。
  • 拓展功能:能够兼容不同的业务场景,而且能够由业务方自己控制增减的数量。
  • 进阶功能:能够根据时间段进行查询,而且时间段由业务方自由控制,支持不同的时间维度,分钟,小时,天,周,月。

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

  • 数据组方面,能够支持我们不同的业务场景的统计,也可以支持我们的时间段查询。但是,数据组目前的数据提供方式是T+1的模式,并不能够对我们的业务场景进行实时的统计。也就是说,他满足了我们的拓展功能和进阶功能,但是在基础功能方面的支持是有限的。

  • 中台方面,计数系统的功能是有的,但是功能相对简单,由中台来分配计数场景,然后由业务同学来驱动这个计数的加减。基础功能,拓展功能全都支持,如果计数过期,还可以通过回源数据接口来取数据。但是对于根据时间段查询,也就是我们需要的进阶功能,并不支持。

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

5 计数系统的设计方案

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

5.1 计数内置的架构设计

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

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

  • 一次请求多次查询,for循环进行
  • 一次请求多次查询,多个计数的查询
  • 一次查询一个count,每个计数都是一个count语句

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

  • 性能瓶颈:随着数据体量的越来越大,再用count的方式去进行数据统计,性能会变得越来越差。

  • 稳定性风险:如果业务统计规则变得越来越复杂,使用数据库count的方式会使数据查询语句越来越复杂,容易引发慢SQL从而导致数据库不稳定。而且将计数的业务层和缓存与核心的业务逻辑放在一起,如果统计数据出现了问题,会影响核心业务的使用,小的问题会变成大的问题。

  • 一致性问题:部分计数场景下是定时更新缓存的策略,缓存操作和MySQL操作无法在一个事务中完成,会产生不一致的问题,且在越频繁变更的场景下差异值就会越大。

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

5.2 计数外置的架构设计

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

5.2.1 计数方案中字段的定义

  • 全局计数器:记录一个业务key的总量值,比如内容点赞总数,由一个全局计数器记录即可。逻辑结构为(业务类型+实体id):value值。

  • 计数流水:实时上报的计数流水,可以支持按照精确时间维度查询功能。

  • 业务类型:业务类型确定计数的接入业务,如交易单总量计数、内容点赞计数,为不同的接入业务类型。

  • 计数实体 :实体id确定计数的目标对象,比如交易单、社区发布内容等为一个计数实体。实体id在同一业务类型内保证唯一性,如交易单总量计数业务,交易单id不能重复。

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

  • 全局计数器必不可少,这是整个计数系统的灵魂,也是我们最常用的数据统计功能。
  • 计数流水是为了保存原始的数据,后期如果有任何数据迁移,或者数据丢失后需要回源数据的,计数流水都会最后的屏障。
  • 业务类型是为了兼容各个业务场景,如果没有这个字段,那么每个业务场景我们都需要一个接口来进行统计,每个业务场景我们都需要一个表来保存数据,对后续拓展极为不利,也不能体现计数系统的通用性。
  • 计数实体的作用有两个,一方面如果没有实体id,只有业务类型的话,后续数据如果出现问题需要进行查询的时候,我们无从查起。另一方面,有了计数实体,我们才能做一些幂等相关的校验操作。

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

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

java 复制代码
public interface BizCountService {
    /**
     * 计数上报接口
     *
     * @param request
     * @return
     */
    ZZOpenScfBaseResult<String> reportCount(ReportCountRequest request);

    /**
     * 流水记录处理-保存日期计数的情况
     *
     * @param msg
     * @return
     */
    boolean saveDateOpt(BizCountRecordMsg msg);

    /**
     * 流水记录处理-更新日期计数的情况
     *
     * @param heroBizCountDate
     * @param msg
     * @return
     */
    boolean updateDateOpt(HeroBizCountDate heroBizCountDate, BizCountRecordMsg msg);

    /**
     * 计数总量查询接口
     *
     * @param request
     * @return
     */
    ZZOpenScfBaseResult<Long> total(TotalRequest request);

    /**
     * 批量查询总量
     *
     * @param request
     * @return
     */
    ZZOpenScfBaseResult<CountBatchResponse> countBatch(CountBatchRequest request);

    /**
     * 清零接口
     * 总量置为0,插入一条计减当前总量的记录;不影响历史记录查询
     *
     * @param request
     * @return 重置前的旧值
     */
    ZZOpenScfBaseResult<Long> clear(ClearRequest request);

    /**
     * 按时间范围计数接口
     *
     * @param request
     * @return
     */
    ZZOpenScfBaseResult<Long> countTimeBetween(CountTimeBetweenRequest request);

    /**
     * 批量id按时间范围计数接口
     *
     * @param request
     * @return
     */
    ZZOpenScfBaseResult<Map<Long, Long>> batchCountTimeBetween(CountTimeBetweenBatchRequest request);

    /**
     * 最近n天的计数统计接口
     *
     * @param request
     * @return
     */
    ZZOpenScfBaseResult<Long> countRecent(CountRecentRequest request);

    /**
     * 批量-最近n天的计数统计接口
     *
     * @param request
     * @return
     */
    ZZOpenScfBaseResult<Map<Long, Long>> batchCountRecent(CountRecentBatchRequest request);

    /**
     * 组合计数总量查询接口
     *
     * @param request
     * @return
     */
    Long combineTotal(CombineTotalRequest request);
}

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

arduino 复制代码
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", "同行认证未读计数"),

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

    /**
     * 用户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的方式,本次我们采取的是直接接口调用的方式进行处理。

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

  • 实时性高:直接接口调用通常是同步的,调用方可以立即获得响应。

  • 实现简单:接口调用往往更容易实现,不需要额外的消息队列中间件配置和维护,减少了系统复杂度和运维成本。对于只需要简单请求-响应模式的服务来说,接口调用通常是更简单直接的选择。

  • 调用顺序明确:接口调用是同步的,天然保证了调用顺序,特别适合一些需要严格顺序的场景。而 MQ 消息可能会因为分区、并发消费等原因导致消息处理顺序变化,不适用于需要严格顺序的业务。

  • 性能开销小:直接调用避免了消息的中间传输、序列化和反序列化的开销,通常性能更优。

  • 调试方便:接口调用的流程更清晰,调试和问题排查更简单。使用 MQ 消息时,涉及消息的存储、转发、消费等多个环节,排查问题时需要在消息队列和消费端分别查看日志和状态,调试难度更大。

处理上报数据

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

上报数据持久化

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

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

scss 复制代码
public ZZOpenScfBaseResult<String> 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消息,还记得我们之前提过,我们是有时间段维度的数据统计的,这个消息就是帮助我们缩短时间段查询响应时间的关键,是真正实现了我们以空间换时间的地方。具体代码逻辑如下:

scss 复制代码
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 计数领域的读取流程

  • 非时间段取数的读取流程 :整体逻辑比较简洁,就是先查缓存,缓存不存在就查询DB再写入缓存即可。
  • 有时间段取数的读取流程:代码如下图所有,我们会先判断一下,这个时间段内是否有一个完成的自然日,如果没有的话,直接查询相关的流水表读取数量。如果存在,先将时间段里面的自然日从我们按照天维度统计的汇总表里面读取出来,然后其他的数据在从流水表中获取,减少需要查询的数量。
ini 复制代码
    public Map<Long, Long> countTimeBetweenInternal(List<Long> entityIds, BizCountType bizType, Date start, Date end) {
        Map<Long, Long> totalMap = Maps.newHashMapWithExpectedSize(entityIds.size());
        if(!BizCommonDateUtils.containsWholeDays(start, end)){
            return heroBizCountRecordManager.computeRestTime(entityIds, bizType, start, end, Maps.newHashMap());
        }else{
            Map<Long, BizCountDateRange> dateRangeMap = heroBizCountDateManager.getDateRange(entityIds, bizType, start, end);
            Map<Long,Long> dateCountMap = heroBizCountDateManager.countDateBetween(entityIds, bizType, start, end);
            Map<Long,Long> 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 总结与规划

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

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

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


关于作者

朱洪旭 侠客汇JAVA开发工程师

道阻且长,拥抱变化;而困而知,且勉且行。

> 转转研发中心及业界小伙伴们的技术学习交流平台,定期分享一线的实战经验及业界前沿的技术话题。

> 关注公众号「转转技术」(综合性)、「大转转FE」(专注于FE)、「转转QA」(专注于QA),更多干货实践,欢迎交流分享~

相关推荐
一嘴一个橘子5 分钟前
mybatis - 动态语句、批量注册mapper、分页插件
java
组合缺一6 分钟前
Json Dom 怎么玩转?
java·json·dom·snack4
REDcker10 分钟前
RESTful API设计规范详解
服务器·后端·接口·api·restful·博客·后端开发
危险、22 分钟前
一套提升 Spring Boot 项目的高并发、高可用能力的 Cursor 专用提示词
java·spring boot·提示词
kaico201826 分钟前
JDK11新特性
java
钊兵27 分钟前
java实现GeoJSON地理信息对经纬度点的匹配
java·开发语言
jiayong2331 分钟前
Tomcat性能优化面试题
java·性能优化·tomcat
秋刀鱼程序编程35 分钟前
Java基础入门(五)----面向对象(上)
java·开发语言
纪莫1 小时前
技术面:MySQL篇(InnoDB的锁机制)
java·数据库·java面试⑧股
Remember_9931 小时前
【LeetCode精选算法】滑动窗口专题二
java·开发语言·数据结构·算法·leetcode