hyapp-server/docs/统计服务技术方案.md
2026-05-25 19:36:27 +08:00

269 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 统计服务技术方案
## 目标
`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 事件,再补统计消费逻辑;不能在统计服务里反查业务表绕过事件边界。
## 总体架构
```mermaid
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
```text
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 表示指定国家维度。
## 部署方案
当前低成本部署使用:
```text
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 当前映射 |
基础验证:
```bash
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
```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。