# 统计服务技术方案 ## 目标 `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=&end_ms=&country_id= ``` 返回范围: - 总览:新增、活跃、付费、充值渠道、礼物、幸运礼物、游戏流水/返奖/退款/利润。 - 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。