hyapp-server/docs/统计服务技术方案.md
2026-06-30 14:11:50 +08:00

12 KiB
Raw Permalink Blame History

统计服务技术方案

目标

statistics-service 是后台数据大屏的统计读模型服务。它只消费各 owner service 已提交并发布到 RocketMQ 的业务事实,写入本服务 MySQL 聚合表,再向 server/admin 提供内部查询 API。

硬性规则:

  • 统计查询不查全表。
  • 统计查询不回查 owner service 明细库。
  • 统计服务不直连 hyapp_roomhyapp_wallethyapp_userhyapp_game 扫描业务表或 outbox。
  • 所有数据大屏指标只能查询 hyapp_statistics 聚合表。
  • 所有统计时间范围统一使用 UTC [start_ms, end_ms)

服务边界

服务 职责
user-service 用户注册、用户主数据 owneruser_outbox.UserRegistered 并发布 MQ
wallet-service 账务和充值 ownerwallet_outbox.WalletRechargeRecorded 并发布 MQ
room-service 房间状态 ownerroom_outbox.RoomUserJoinedRoomGiftSent 并发布 MQ
game-service 游戏订单 ownergame_outbox.GameOrderSettled 并发布 MQ
statistics-service 消费 MQ 事实,写统计幂等表和聚合表,提供内部统计查询
server/admin 后台管理 HTTP API调用 statistics-service 查询数据大屏
gateway-service App HTTP 入口;默认不暴露后台数据大屏统计
RocketMQ/TDMQ 事件 fanout 和消费位点,不拥有业务状态
MySQL hyapp_statistics 统计服务自己的幂等事实和聚合读模型

statistics-service 不是业务事实 owner。任何缺失指标都必须先补 owner service 事件,再补统计消费逻辑;不能在统计服务里反查业务表绕过事件边界。

总体架构

flowchart LR
  User["user-service\nuser_outbox"] --> MQ["RocketMQ / TDMQ"]
  Wallet["wallet-service\nwallet_outbox"] --> MQ
  Room["room-service\nroom_outbox"] --> MQ
  Game["game-service\ngame_outbox"] --> MQ

  MQ --> Stats["statistics-service\nconsumer groups"]
  Stats --> StatsDB["hyapp_statistics\nidempotency + aggregate tables"]
  Admin["server/admin"] --> StatsAPI["statistics-service\n/internal/v1/statistics/overview"]
  StatsAPI --> StatsDB
  Admin --> Dashboard["数据大屏"]

事件事实

统计事件只从 MQ 进入。每个事件必须有稳定 event_id,统计服务按 app_code + source + event_id 幂等。

Source Topic Event Owner 语义 统计用途
user hyapp_user_outbox UserRegistered 用户注册事实 新增用户、留存 cohort
wallet hyapp_wallet_outbox WalletRechargeRecorded 充值到账事实 总充值、首充、付费用户、渠道分桶、ARPU、ARPPU、UP 值
room hyapp_room_outbox RoomUserJoined 用户进房成功 活跃用户、房间国家维度活跃
room hyapp_room_outbox RoomGiftSent 送礼已扣费且落房间表现 礼物消耗、幸运礼物流水、幸运礼物付费用户
game hyapp_game_outbox GameOrderSettled 游戏订单已结算 游戏流水、返奖、退款、玩家数、游戏排行

事件字段边界:

  • app_code 必须由 owner service 写入事件,统计服务只做默认值兜底,不做租户推断。
  • occurred_at_ms 是业务事实发生时间,必须使用 epoch milliseconds。
  • 国家维度只能来自事件字段:注册区域、充值目标区域、房间 visible_region_id、游戏事件区域。
  • request_id 只用于链路追踪,不能作为统计幂等键。
  • event_id 是统计幂等键,重复消息必须被本地幂等表收敛。

口径定义

数据大屏字段级口径以 数据大屏统计口径 为准;本节只保留统计服务的核心边界。

用户与活跃

指标 口径
新增用户 UserRegistered 按 UTC 日、app_code、国家维度聚合
活跃用户 RoomUserJoinedGameOrderSettled 写入 stat_user_day_activity,按用户去重
次日留存 注册 cohort 中,注册日 + 1 天存在活跃记录的用户数 / 注册用户数
7 日留存 注册 cohort 中,注册日 + 7 天存在活跃记录的用户数 / 注册用户数
30 日留存 注册 cohort 中,注册日 + 30 天存在活跃记录的用户数 / 注册用户数

留存只 join stat_user_registrationstat_user_day_activity,不回查用户表、登录表或房间 presence。

充值

指标 口径
总充值 WalletRechargeRecorded.recharge_usd_minor 累加
新增充值 recharge_sequence == 1 的充值金额累加
付费用户 stat_recharge_day_payers 按 UTC 日用户去重
币商充值 recharge_type 为空或非 mifapay/google 时归入币商充值
MifaPay 充值 recharge_type == mifapay
Google 充值 recharge_type == google
ARPU 总充值 / 活跃用户
ARPPU 总充值 / 付费用户
UP 值 当前与 ARPPU 同口径,即总充值 / 付费用户

充值统计以到账事实为准。支付创建、支付处理中、支付失败、币商库存进货都不能直接计入玩家充值。

礼物与幸运礼物

指标 口径
礼物消耗 RoomGiftSent.coin_spent 累加,历史事件无 coin_spent 时回退 gift_value
幸运礼物流水 RoomGiftSent.pool_id 非空时的 coin_spent 累加,历史事件无 coin_spent 时回退 gift_value
幸运礼物付费用户 pool_id 非空的送礼用户按 UTC 日去重

房间礼物事件必须在 wallet 扣费成功后产生。统计服务不读取钱包流水来反推礼物消耗。

游戏

指标 口径
游戏流水 GameOrderSettled.op_type in (debit, bet)coin_amount 累加
游戏返奖 op_type in (credit, payout)coin_amount 累加
游戏退款 op_type in (refund, reverse)coin_amount 累加
游戏利润 游戏流水 - 游戏返奖 - 游戏退款
游戏利润率 游戏利润 / 游戏流水
游戏参与人数 投注事件按用户、游戏、UTC 日去重
游戏排行 stat_game_day_country 按游戏流水倒序

游戏统计以游戏订单结算事实为准。统计服务不调用游戏厂商接口、不读取游戏明细表临时汇总。

聚合表设计

所有聚合表位于 hyapp_statistics,由 statistics-service 迁移和写入。

作用 主键/幂等边界
statistics_event_consumption 消费幂等表 (app_code, source, event_id)
stat_app_day_country App/UTC 日/国家总览 (app_code, stat_day, country_id)
stat_user_day_activity 用户日活跃去重 (app_code, stat_day, user_id)
stat_user_registration 注册 cohort (app_code, user_id)
stat_recharge_day_payers 日付费用户去重 (app_code, stat_day, user_id)
stat_lucky_gift_day_payers 幸运礼物日付费用户去重 (app_code, stat_day, country_id, user_id)
stat_game_day_country 游戏日聚合和排行 (app_code, stat_day, country_id, platform_code, game_id)
stat_game_day_players 游戏玩家去重 (app_code, stat_day, country_id, platform_code, game_id, user_id)

写入原则:

  • 先写 statistics_event_consumption,再更新聚合表。
  • 已消费事件直接跳过,保证 MQ 重投不重复累加。
  • 唯一用户类指标使用 INSERT IGNORE 到去重表,再用 affected rows 增量更新聚合表。
  • 聚合表只追加或增量更新当前统计事实;修正历史数据必须通过重新投递事实或明确的重算任务,不允许在线查询扫明细补算。

查询契约

statistics-service 当前提供内部 HTTP 查询 API

GET /internal/v1/statistics/overview?app_code=lalu&start_ms=<UTC ms>&end_ms=<UTC ms>&country_id=<optional>

返回范围:

  • 总览:新增、活跃、付费、充值渠道、礼物、幸运礼物、游戏流水/返奖/退款/利润。
  • ARPU/ARPPU/UP 值。
  • 次留、7 留、30 留。
  • 游戏排行。

调用边界:

  • server/admin 调这个内部 API 后再返回后台前端。
  • gateway-service 默认不调用这个接口。
  • 查询参数必须是 UTC epoch milliseconds。
  • country_id 为空表示汇总全部国家;大于 0 表示指定国家维度。

部署方案

当前低成本部署使用:

CVM / Docker Compose
TencentDB for MySQL
TencentDB Redis
TDMQ for RocketMQ
statistics-service

组件职责:

组件 用途
CVM / Docker Compose 部署 Go 服务和 RocketMQ worker 配置
TencentDB for MySQL 保存 owner service 业务库和 hyapp_statistics 聚合库
TencentDB Redis 房间 lease、路由和热状态
TDMQ for RocketMQ owner outbox 到下游消费者的事件总线
statistics-service 独立 consumer group 消费统计事件并提供内部查询

无 Kubernetes 可以运行。需要保证:

  • 每个服务实例配置唯一 node_id
  • RocketMQ topic 和 consumer group 在线上预先创建或由部署流程管理。
  • statistics-service 多实例消费时必须使用同一个统计 consumer group依赖本地幂等表防重复。
  • MySQL DSN 使用 loc=UTC,容器 TZ=UTC
  • statistics-service 查询端口只暴露给内网或 admin 服务,不对公网开放。

本地验证流程

本地端口:

服务 端口
statistics-service 查询 HTTP 13010
statistics-service health HTTP 13110
MySQL 23306
RocketMQ namesrv 19876 或 compose 当前映射
RocketMQ broker 19009 / 19011 或 compose 当前映射

基础验证:

make proto
make test
docker compose config
docker compose build
docker compose up -d
docker compose ps

真实链路验证:

  1. 启动 MySQL、Redis、RocketMQ、owner service、statistics-service
  2. 创建 topic 和 consumer group。
  3. 清空 hyapp_statistics 聚合表。
  4. 将已有 owner outbox 的目标事件标记为 pending
  5. 启动 owner outbox worker 发布 MQ。
  6. 等待 statistics_event_consumption 和聚合表增长。
  7. 调用 /internal/v1/statistics/overview 检查返回。

典型检查 SQL

SELECT source, event_type, COUNT(*)
FROM hyapp_statistics.statistics_event_consumption
GROUP BY source, event_type
ORDER BY source, event_type;

SELECT COUNT(*) AS rows_count,
       SUM(active_users) AS active_users,
       SUM(paid_users) AS paid_users,
       SUM(recharge_usd_minor) AS recharge_usd_minor,
       SUM(game_turnover) AS game_turnover,
       SUM(game_payout) AS game_payout,
       SUM(game_refund) AS game_refund,
       SUM(gift_coin_spent) AS gift_coin_spent
FROM hyapp_statistics.stat_app_day_country;

当前限制

  • 历史库如果没有 UserRegistered outbox留存 cohort 会按当前事实为 0。
  • 历史库如果没有 game_outbox.GameOrderSettled,游戏统计会按当前事实为 0。
  • MifaPay/Google 需要 owner 支付成功链路写出 WalletRechargeRecorded 且带 recharge_type,统计服务才能分桶。
  • 老版本 RoomUserJoined 如果没有 visible_region_id,历史活跃会落到国家 0新事件必须带房间 visible_region_id
  • 当前 up_value_usd_minor 与 ARPPU 同口径;产品调整定义时必须同步修改契约、字段和测试。

扩展规则

新增统计指标时按固定顺序推进:

  1. 明确 owner service 和事实事件。
  2. 在 owner service 同一事务内写 outbox。
  3. 发布到 RocketMQ topic。
  4. statistics-service 新增 consumer 解析和幂等消费。
  5. 新增或扩展聚合表。
  6. 扩展查询 API。
  7. 补 UTC [start_ms, end_ms) 边界测试。
  8. 用本地 MQ 真实链路验证。

不要从“数据大屏缺一个字段”直接跳到“查某个业务表”。统计系统的可扩展性来自稳定事件事实和聚合表,不来自临时 SQL。