业务背景

运动健康存储了海量用户运动健康数据,提供一款报告产品能帮助用户更好感知健康状态。但此前旧周报指标太少,并存在投放泛滥,触达率低,计算周期长,容错率低等问题,已经无法吸引用户提升月活。
我们期望呈现出更专业的健康周报,要求指标丰富度达到行业领先水平,支持用户睡眠大数据指标,提升生成周报速度,降低计算对正常业务影响,同时将成本控制到最低。

需求拆解

运动健康周报可以拆分为两个子需求:

  • 生成周报。将涉及用户一周的运动健康数据汇总、数据清洗,以及统计结果的计算
  • 通知用户。需要一个定时任务通知有周报的用户,并且进行预热

    设计开发

    竞品分析 - 边缘计算

    我们调研了国内穿戴设备市场占有率最高的中国厂商,发现其周报有几个特点:
  1. 打开最近一周的周报时无网络请求
  2. 清空本地存储后,重新打开历史周报无数据
  3. 联网后,快速下划历史周报列表,无论当周有无配搭手表都有周报
  4. 联网后,打开历史周报详情,需要等待较长时间才能看到周报
  5. 同一个账号登录两台手机,保持一周不联网,周报的内容不相同

根据以上特点,可以推断出来竞品采用了边缘计算方案,直接使用存储在手机的运动健康数据做汇总统计。
该方案的好处是当手机本地有数据时,周报可以在脱网状态下流畅展示,并且周报列表也只是个占位符,同样不依赖网络。并且周报的计算不占用云端计算资源,成本上也有一定优势。

自研方案 - 云计算

边缘计算方案虽然很有吸引力,但在用户体验上还是有瑕疵。其中最大的问题是当用户有多台手机的时候,不及时的数据同步会展示出不一样的周报,这样用户会质疑周报的准确性。
为保证用户在任何一台终端看到数据一致的周报,我们最终采纳了云端计算方案:
Image_20220510161108.png
云端计算方案包含5个步骤:

  1. 客户端定时上报运动健康数据,比如步数详情、睡眠详情、运动详情等,云端校验后写入存储
  2. 云端触发任务,批量将存储中的数据经过 ETL 处理后落入离线数仓 Hive
  3. 云端触发任务,统计用户周步数、睡眠、运动等指标,计算历史个人最佳成绩,以及所有用户的大数据统计
  4. 云端触发任务,分批次写入到存储
  5. 云端触发任务,查询存储中的周报数据,根据规则生成概览,并推送至客户端
  6. 用户点开周报,前端发起 HTTP 请求查阅周报详情

    数据结构 ER 图

    userWeekStat 用户周报

    一条记录表示一个用户一个自然周的步数、睡眠、血氧、鼾症、运动统计。
字段 解释
ssoid 账户ID
weekCode 周报唯一标识 20210621
startTime 周报统计开始时间 20210621
endTime 周报统计开始时间 20210627
totalSteps 累计步数
totalCalories 累计消耗 单位卡
complianceDays 达标天数
avgDailyDuration 日均锻炼时长 毫秒
avgDailySportNum 日均运动次数
avgDailySleepTime 日均睡眠时长 单位分钟
avgFallAsleepTime 平均入睡时间 分钟
avgSleepOutTime 平均出睡时间 分钟
sleepDays 符合要求的睡眠天数
deepSleepAnomaDays 深睡比例异常天数 比例小于20%
totalSportDays 累计运动天数
totalSportDuration 累计运动时长 毫秒
totalRunNum 累计跑步次数
totalRunDuration 累计跑步时长 毫秒
totalRunCalories 累计跑步消耗 卡
totalRunDistance 累计跑步里程 米
fastestRunPace 跑步最快配送 秒
totalWalkNum 累计健走次数
totalWalkDuration 累计健走时长 毫秒
totalWalkCalories 累计健走消耗 卡
totalWalkDistance 累计健走里程 米
totalFitnessNum 累计健身次数
totalFitnessDuration 累计健身时长 毫秒
totalFitnessCalories 累计健身消耗 卡
totalSwimNum 累计游泳次数
totalSwimDuration 累计游泳时长 毫秒
totalSwimCalories 累计游泳消耗 卡
totalSwimDistance 累计游泳距离 米
totalYogaNum 累计瑜伽次数
totalYogaDuration 累计瑜伽时长 毫秒
totalYogaCalories 累计瑜伽消耗 卡
state 周报状态 0:未读 1:已读
createTime 记录生成时间

fullWeekStat 用户大数据周报

一条记录表示一周全量健康用户的睡眠、运动、消耗大数据统计。

字段 解释
weekCode 周报唯一标识 20210621
startTime 周报统计开始时间 20210621
endTime 周报统计开始时间 20210627
avgDailyDuration 平均锻炼时长 毫秒
avgDailyCalories 平均消耗卡路里
avgDailySleepTime 日均睡眠时长 单位分钟
avgFallAsleepTime 平均入睡时间 分钟
avgSleepOutTime 平均出睡时间 分钟
createTime 记录生成时间

工具选型

确定了基本的需求,我们进一步分析可能会用到的中间件,以及系统整体的组织方式。我们使用了 MongoDB 作为存储,引入了计算引擎 Spark,缓存方面使用了 Redis,通讯选择公司自研 RPC框架 ESA RPC。这里重点分析下 MongoDB 和 Spark。

文档数据库 MongoDB

默认情况下,MySQL 的 innodb_page_size=16KB,当一行数据为16B,bigint 作为主键有8B,地址指针6B,那么3层索引树可以存储 21902400 条数据。
但周报的需求中,一个记录30多个字段(~150B),一周需要产生 300w 个记录,MySQL 中的单表性能已经不能保证了。当然我们可以选择对数据分片,也就是横向切分,但这么一来我们需要维护 N 个数据库实例,费用直线上升。存储性能和成本的不可兼得。
对于这种动辄几十个字段的宽表,文档数据库 MongoDB 就很合适。默认情况下,MongoDB 底层存储是 B- 树,这种数据结构虽然存储记录数的上限不如堆表,但经过咨询 DBA 和专业同事,单表20亿没问题。我们的查询场景中没有范围查询,因此可以根据 ssoid 和 weekCode 建立联合唯一键,由于 B- 树是自平衡的,因此可以实现时间复杂度 O(logN)的查询。

离线计算引擎 Saprk

旧周报使用了 MySQL 作为存储,但 InnoDB 索引组织表的存储方式决定了它是 OLAP 调教的存储引擎,并不适合 OLTP 场景。因此旧周报把统计运算放到了微服务 task 内存中执行,这是个退而求其次的方案。生成周报是以周为单位的,每次计算的时候都会占用很多资源,导致其它天周期的任务执行缓慢,严重影响了第二天的计划。
由于此前我们的产品同事有使用南天门分析的需求,运动健康数据会定期同步到 Hive。南天门的交互查询支持三种 presto、spark、hive 三种计算引擎,并且建议大数据量使用 Spark。
经过一段时间的自学 Scala 和 Spark,我们决定基于更成熟的 SparkSQL 实现这个需求。

解决问题

复杂规则配置

需求中要求对步数进行分类总结,比如周步数大于 20000 的周报总结为“健步如飞”,但如果同比增加2000步则又要展示“步数飞升”。复杂的规则对应复杂的分支控制,如果使用命令式编程的思路,我们需要硬编码很多 if-else。这些代码维护起来比较困难,如果产品修改了步数范围和总结词,那我们需要修改代码,并等到下一个版本才能发布上线。
于是我们将规则抽象为 WeekRule,以类型 type 区分同比规则、排名规则、步数规则,并且以字段 low、high 表示规则匹配的上下限。工厂模式让我们封装复杂的构造,在这个案例中,我们将规则以 json 格式表示,并写入到配置中心。
这个配置方案有两个好处。其一,配置的增加、修改不再需要硬编码,我们直接在配置中心修改即可;其二,我们封装了一个工具类 Heracles 可以嗅探到配置的变动,这样我们就可以动态修改线上配置而无需发版了。

  1. overview.rule.step=[{"type":1,"code":"步数飞升","low":2000},{"type":2,"code":"三巨头","high":3},{"type":3,"code":"举步生风","low":4000,"high":7999},{"type":3,"code":"悠闲小走","low":1000,"high":3999},{"type":3,"code":"爱压马路","low":8000,"high":9999},{"type":3,"code":"健走达人","low":10000,"high":19999},{"type":3,"code":"足不出户","low":0,"high":999},{"type":3,"code":"健步如飞","low":20000,"high":2147483647}]

高并发下的查询压力

业务上线后,我们收到了查询周报详情接口的超时告警。通过调用链跟踪,我们分析瓶颈出现在存储层。通过 MongoDB 的监控界面我们发现 09:00 总 IOPS 明显上升,这应该是流量突增造成的。于是我们查看接入层监控,确认了查询周报详情接口的确较大的流量,但该接口只有前端 H5 在调用,所以应该是用户的正常流量。
经过排查,我们发现在自研方案的第5步中,云端通过定时任务将300万份新周报推送给用户,大量的用户同时打开周报详情,导致了流量激增。

推送任务中对周报详情预热

由于推送任务需要查询当周的用户周报表,并进一步查询周报详情,既然详情已经加载到内存中了,我们就进一步写入到 Redis,避免流量直接打到存储。
然而第二周 09:01 我们还是收到告警。Redis 所在实例内存占用率超过警戒线 90%。
首先是怀疑流量激增导致 Redis 写入过多。我们查询了服务 server 的调用链,请求统计 显示在 09:05 后有较大的流量,对接口根据流量从大到小排名,发现查询周报详情接口请求激增,近一小时请求数上万。通过 链路跟踪 追查这个接口,发现请求通过 RPC 到了三方微服务,然后访问 Redis 就返回,可见周报预热是生效了。但仔细一看数据微服务的 RPC 调用量,在 09:00 突然增加到上百万,明显大于入口的请求数。初步排除是流量的问题。
通过日志服务,我们回看了数据微服务的日志,发现查询周报详情的行为比较均匀恒定,怀疑是机器发起的。最后,通过 Elastic-Job 确认了推送周报任务在 09:00 开始执行,确认了是全量预热导致。

根据活跃度预热

我们对300万新周报进行了全量预热,实质上是将流量压力转移到缓存中,导致内存占用超过阈值。于是我们查询了上周新周报打开阅读的情况,发现周报的阅读率相比旧周报已经显著提升了,但依然不到50%。这意味着全量预热还有优化的空间。
最后,我们调整了推送策略,只对上周阅读了周报的用户进行预热,第三周开始就没有 Redis 内存占用告警了。

系统测试

我们对系统进行了压力测试,在压力集群下,并发200,吞吐量1795 TPS,平均响应时间 13.58 ms,成功率 100%。满足生产的性能要求。

未来展望

云计算的方案依然存在以下问题:

  1. 客户端出于功耗考虑,运动数据上报会有延迟,假如用户在周日晚进行了大量运动但未及时上报,而我们发生在周一凌晨的计算就不会统计这些运动记录了。在用户角度看来这是周报统计口径的问题。
  2. 由于睡眠数据的缺失,我们在计算大数据睡眠周期时无法分辨夜间睡眠还是午睡,导致整体的出睡时间延迟到 10:23,这个导向就相当差,也会引起一部分用户质疑我们数据造假。

为了解决以上问题,我们需要混合计算。客户端先进行一些基础统计,然后云端负责全量用户的大数据指标计算,如此既能发挥不遗漏统计新数据,也能发挥云端云计算的优势,并且可以减轻云端计算的压力和费用。

参考文献

新周报串讲文档