269 lines
12 KiB
Markdown
269 lines
12 KiB
Markdown
# 统计服务技术方案
|
||
|
||
## 目标
|
||
|
||
`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。
|