12 KiB
12 KiB
统计服务技术方案
目标
statistics-service 是后台数据大屏的统计读模型服务。它只消费各 owner service 已提交并发布到 RocketMQ 的业务事实,写入本服务 MySQL 聚合表,再向 server/admin 提供内部查询 API。
硬性规则:
- 统计查询不查全表。
- 统计查询不回查 owner service 明细库。
- 统计服务不直连
hyapp_room、hyapp_wallet、hyapp_user、hyapp_game扫描业务表或 outbox。 - 所有数据大屏指标只能查询
hyapp_statistics聚合表。 - 所有统计时间范围统一使用 UTC
[start_ms, end_ms)。
服务边界
| 服务 | 职责 |
|---|---|
user-service |
用户注册、用户主数据 owner;写 user_outbox.UserRegistered 并发布 MQ |
wallet-service |
账务和充值 owner;写 wallet_outbox.WalletRechargeRecorded 并发布 MQ |
room-service |
房间状态 owner;写 room_outbox.RoomUserJoined、RoomGiftSent 并发布 MQ |
game-service |
游戏订单 owner;写 game_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、国家维度聚合 |
| 活跃用户 | RoomUserJoined 和 GameOrderSettled 写入 stat_user_day_activity,按用户去重 |
| 次日留存 | 注册 cohort 中,注册日 + 1 天存在活跃记录的用户数 / 注册用户数 |
| 7 日留存 | 注册 cohort 中,注册日 + 7 天存在活跃记录的用户数 / 注册用户数 |
| 30 日留存 | 注册 cohort 中,注册日 + 30 天存在活跃记录的用户数 / 注册用户数 |
留存只 join stat_user_registration 和 stat_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.gift_value 累加 |
| 幸运礼物流水 | RoomGiftSent.pool_id 非空时的 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
真实链路验证:
- 启动 MySQL、Redis、RocketMQ、owner service、
statistics-service。 - 创建 topic 和 consumer group。
- 清空
hyapp_statistics聚合表。 - 将已有 owner outbox 的目标事件标记为
pending。 - 启动 owner outbox worker 发布 MQ。
- 等待
statistics_event_consumption和聚合表增长。 - 调用
/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;
当前限制
- 历史库如果没有
UserRegisteredoutbox,留存 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 同口径;产品调整定义时必须同步修改契约、字段和测试。
扩展规则
新增统计指标时按固定顺序推进:
- 明确 owner service 和事实事件。
- 在 owner service 同一事务内写 outbox。
- 发布到 RocketMQ topic。
- 在
statistics-service新增 consumer 解析和幂等消费。 - 新增或扩展聚合表。
- 扩展查询 API。
- 补 UTC
[start_ms, end_ms)边界测试。 - 用本地 MQ 真实链路验证。
不要从“数据大屏缺一个字段”直接跳到“查某个业务表”。统计系统的可扩展性来自稳定事件事实和聚合表,不来自临时 SQL。