业务背景
运动健康存储了海量用户运动健康数据,提供一款报告产品能帮助用户更好感知健康状态。但此前旧周报指标太少,并存在投放泛滥,触达率低,计算周期长,容错率低等问题,已经无法吸引用户提升月活。
我们期望呈现出更专业的健康周报,要求指标丰富度达到行业领先水平,支持用户睡眠大数据指标,提升生成周报速度,降低计算对正常业务影响,同时将成本控制到最低。
需求拆解
运动健康周报可以拆分为两个子需求:
- 生成周报。将涉及用户一周的运动健康数据汇总、数据清洗,以及统计结果的计算
- 通知用户。需要一个定时任务通知有周报的用户,并且进行预热
设计开发
竞品分析 - 边缘计算
我们调研了国内穿戴设备市场占有率最高的中国厂商,发现其周报有几个特点:
- 打开最近一周的周报时无网络请求
- 清空本地存储后,重新打开历史周报无数据
- 联网后,快速下划历史周报列表,无论当周有无配搭手表都有周报
- 联网后,打开历史周报详情,需要等待较长时间才能看到周报
- 同一个账号登录两台手机,保持一周不联网,周报的内容不相同
根据以上特点,可以推断出来竞品采用了边缘计算方案,直接使用存储在手机的运动健康数据做汇总统计。
该方案的好处是当手机本地有数据时,周报可以在脱网状态下流畅展示,并且周报列表也只是个占位符,同样不依赖网络。并且周报的计算不占用云端计算资源,成本上也有一定优势。
自研方案 - 云计算
边缘计算方案虽然很有吸引力,但在用户体验上还是有瑕疵。其中最大的问题是当用户有多台手机的时候,不及时的数据同步会展示出不一样的周报,这样用户会质疑周报的准确性。
为保证用户在任何一台终端看到数据一致的周报,我们最终采纳了云端计算方案:
云端计算方案包含5个步骤:
- 客户端定时上报运动健康数据,比如步数详情、睡眠详情、运动详情等,云端校验后写入存储
- 云端触发任务,批量将存储中的数据经过 ETL 处理后落入离线数仓 Hive
- 云端触发任务,统计用户周步数、睡眠、运动等指标,计算历史个人最佳成绩,以及所有用户的大数据统计
- 云端触发任务,分批次写入到存储
- 云端触发任务,查询存储中的周报数据,根据规则生成概览,并推送至客户端
- 用户点开周报,前端发起 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 可以嗅探到配置的变动,这样我们就可以动态修改线上配置而无需发版了。
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%。满足生产的性能要求。
未来展望
云计算的方案依然存在以下问题:
- 客户端出于功耗考虑,运动数据上报会有延迟,假如用户在周日晚进行了大量运动但未及时上报,而我们发生在周一凌晨的计算就不会统计这些运动记录了。在用户角度看来这是周报统计口径的问题。
- 由于睡眠数据的缺失,我们在计算大数据睡眠周期时无法分辨夜间睡眠还是午睡,导致整体的出睡时间延迟到 10:23,这个导向就相当差,也会引起一部分用户质疑我们数据造假。
为了解决以上问题,我们需要混合计算。客户端先进行一些基础统计,然后云端负责全量用户的大数据指标计算,如此既能发挥不遗漏统计新数据,也能发挥云端云计算的优势,并且可以减轻云端计算的压力和费用。