芥末
发布于 2026-05-28 / 1 阅读
0
0

10 亿用户零点步数清零的高并发架构设计

每天零点,步数类业务都会遇到一个很特殊的高并发场景:昨天的步数要停止累计,今天的步数要从 0 开始,排行榜要结算,好友榜和群榜还要继续被大量用户访问。

这个场景和普通高并发不太一样。

电商秒杀、抢票、抢红包虽然压力大,但用户行为通常有入口页、倒计时页、排队页,系统可以提前做限流、排队和缓存预热。步数零点清零的触发源不是用户点击,而是物理时间。只要时钟走到 00:00:00,所有用户的数据逻辑上都进入新的一天。

如果系统真的在零点对 10 亿用户逐条执行清零,那数据库很容易被打穿。正确的思路不是“把清零做快”,而是让零点那一刻根本不需要清零。

不能用一条 SQL 硬清零

最直接、也最危险的设计是定时任务加全表更新:

UPDATE user_steps
SET step_count = 0
WHERE date = CURRENT_DATE;

如果用户规模是 10 亿,这条 SQL 会带来几个严重问题:

问题 后果
大量行更新 产生巨量 undo、redo、binlog,写放大非常明显
锁竞争 行锁、间隙锁、索引维护都会拖慢正常读写
主从延迟 主库写入洪峰会让从库复制堆积,读到旧数据
IOPS 打满 磁盘和网络被批量写压满,业务请求超时
故障恢复困难 大事务回滚和重放成本极高

全表清零还有一个更隐蔽的问题:它把“业务状态切换”和“物理数据修改”绑定在一起。业务只是希望今天从 0 开始展示,但数据库却被迫修改所有用户的记录。

高并发系统里,能不做物理写,就不要在峰值时做物理写。

MySQL 在这种系统里应该放在哪里

MySQL 不是不能用,而是不适合站在零点洪峰的最前面。

对于步数系统来说,MySQL 更适合承担“最终账本”的角色:保存已经结算完成、访问频率降低、可以异步写入的历史数据。高频上报、实时读取、排行榜计算,应该尽量在缓存层、分布式 KV(Key-Value,键值存储)层和消息队列中完成。

一个更合理的存储链路是这样的:

flowchart LR
    A[手机客户端上报步数] --> B[接入层网关]
    B --> C[分布式 KV / 缓存层]
    C --> D[消息队列 MQ]
    D --> E[异步聚合任务]
    E --> F[(MySQL / HBase / 冷数据存储)]

    C --> G[好友榜 / 群榜查询]

在这个结构里,MySQL 不直接承受零点瞬时流量。它接收到的是已经削峰、聚合、限速后的数据。

即使使用 MySQL,也必须分库分表。常见做法是按用户 ID 做哈希路由:

db_index    = hash(user_id) % db_count
table_index = hash(user_id) % table_count

例如把用户拆到 1024 个库、每个库 1024 张表,单表只保存少量用户数据。这样一来,单个 MySQL 实例看到的不是“10 亿用户清零”,而是自己负责的一小片用户数据在低速写入历史记录。

实时步数更适合放在分布式 KV 层

步数上报有几个特点:

  • 写入频繁,用户走路时会持续产生增量数据。
  • 读写模型简单,核心操作通常是按用户 ID 读写一个数字。
  • 今日数据热度很高,历史数据热度快速下降。
  • 排行榜依赖实时值,但不需要复杂的关系型查询。

这类数据适合放在分布式 KV 或强一致缓存系统里。微信核心业务中使用过 PaxosStore 这类自研分布式存储,它基于 Paxos(一种分布式一致性协议)实现多副本一致,适合承载高并发读写。

可以把它理解成一个大规模、高可用的 KV/Table 存储系统:

flowchart LR
    A[业务请求] --> B[路由层]
    B --> C{按 UIN 哈希}
    C --> D[Shard 1]
    C --> E[Shard 2]
    C --> F[Shard N]

    D --> D1[副本 1]
    D --> D2[副本 2]
    D --> D3[副本 3]

    E --> E1[副本 1]
    E --> E2[副本 2]
    E --> E3[副本 3]

UIN 可以理解为用户的全局唯一 ID。用 UIN 做分片键后,不同用户的读写会被均匀打散到大量分片上。每个分片内部再通过多副本协议保证数据可靠性。

对步数这种数字型数据,核心写入可以设计成原子累加:

INCRBY User:Steps:20260607:UIN_12345 300

这比在关系型数据库里频繁执行 UPDATE step_count = step_count + 300 更适合高并发场景,因为 KV 层可以围绕单 Key 原子操作、内存读写和预写日志(WAL,Write-Ahead Log)做专门优化。

零点清零的核心:逻辑清零,而不是物理清零

10 亿用户零点清零真正的关键,是 Key 设计。

不要把某个用户的步数永远写进同一个 Key:

User:Steps:UIN_12345 = 15400

这种设计到了零点就必须把它改成 0。正确做法是把日期放进 Key 里,让每天的步数天然进入不同账本:

User:Steps:20260605:UIN_12345 = 12800
User:Steps:20260606:UIN_12345 = 15400
User:Steps:20260607:UIN_12345 = 0

当时间从 23:59:59 变成 00:00:00,业务层计算出来的日期从 20260606 变成 20260607。用户再上报或查询步数时,系统自动读写新日期的 Key。

flowchart LR
    A[请求到达] --> B[获取业务日期]
    B --> C[拼接 Key: User:Steps:{date}:{uin}]
    C --> D{Key 是否存在}
    D -- 存在 --> E[返回当前步数]
    D -- 不存在 --> F[默认返回 0]
    F --> G[首次上报时创建新 Key]

这里没有遍历用户,没有批量更新,也没有删除昨天的数据。零点发生的事情只是“日期变量变了”,读写目标自然切换到新 Key。

这就是逻辑清零。

从复杂度看,物理清零需要处理 10 亿条记录,复杂度接近 O(N);逻辑清零只依赖当前请求计算日期,零点那一刻不产生额外批量写入,复杂度可以看作 O(1)

零点前后的时钟漂移怎么处理

分布式系统里,不能假设所有机器的时间完全一致。

即使服务器通过 NTP(Network Time Protocol,网络时间协议)同步时间,客户端、接入层、业务服务器之间仍然可能存在几秒误差。用户手机也可能在 00:00:02 才把 23:59:58 的步数上报上来。

如果系统简单地按服务端当前日期写入,就可能出现两个问题:

场景 风险
昨天的步数迟到 昨天排行榜少算步数
零点后的步数写到昨天 今天初始数据不准确
不同服务器时间略有差异 同一用户请求被写到不同日期 Key
结算任务过早关闭昨天账本 迟到数据被丢弃

解决办法是给零点设置一个缓冲窗口,在窗口内允许旧账本和新账本短暂共存。

timeline
    title 零点双写缓冲窗口
    23:58 : 进入缓冲期
          : 同时维护昨天 Key 和今天 Key
    00:00 : 业务日期切换
          : 查询默认读今天 Key
    00:05 : 缓冲期结束
          : 昨天 Key 冻结为只读
          : 进入异步结算和归档

在缓冲窗口内,系统可以采用双写或按事件时间修正的方式处理数据。

一种常见策略是:

if report_time belongs to yesterday:
    incr User:Steps:yesterday:uin

if server_time is in midnight_buffer_window:
    ensure User:Steps:today:uin initialized

if report_time belongs to today:
    incr User:Steps:today:uin

这里要区分两个时间:

  • server_time:服务器收到请求的时间,用来判断系统是否处于缓冲窗口。
  • report_time:步数事件发生的时间,用来判断这批步数属于哪一天。

缓冲窗口不是为了让昨天的数据永远可写,而是为了容忍零点附近的迟到数据。窗口结束后,昨天的 Key 冻结,只能读不能改,排行榜和历史账本也就有了稳定输入。

多数据中心要避免跨地域同步阻塞

全国用户不可能都写同一个数据中心。跨地域写入会带来明显延迟,也会把零点洪峰集中到少数链路上。

更合理的方式是用户归属地自治:每个用户固定归属某个数据中心,写请求优先回到自己的主数据中心完成。

flowchart LR
    A[南方用户] --> B[深圳数据中心]
    C[北方用户] --> D[上海数据中心]
    E[海外用户] --> F[海外数据中心]

    B --> G[本地步数 KV]
    D --> H[本地步数 KV]
    F --> I[本地步数 KV]

    G -.异步复制.-> H
    H -.异步复制.-> G
    G -.异步复制.-> I
    I -.异步复制.-> G

步数上报、好友榜查询、群榜计算尽量在本数据中心闭环完成。跨数据中心的数据同步走异步复制,不放在用户请求的关键路径上。

这样做的核心收益是降低尾延迟。用户请求不用等远距离网络往返,也不会因为某条跨地域链路抖动导致整个步数系统阻塞。

好友排行榜:不要维护全局大榜

很多人一想到排行榜,就会想到 Redis 的 ZSet(Sorted Set,有序集合)。ZSet 确实适合做排行榜:

ZADD step_rank 15400 UIN_12345
ZREVRANGE step_rank 0 99 WITHSCORES

但如果把 10 亿用户都放进一个 ZSet,就会制造巨大的 BigKey。BigKey 指单个 Key 包含的数据过多,常见风险包括:

风险 说明
单节点内存压力 一个 Key 只能落在一个 Redis 分片上,无法继续拆分
操作阻塞 大 Key 删除、迁移、持久化可能造成明显卡顿
热点集中 大量用户同时查榜会打到同一个节点
扩容困难 无法通过普通哈希分片把一个 Key 内部元素打散

步数业务并不需要全国总榜。用户真正关心的是好友榜和群榜,所以排行榜也应该按社交关系拆小。

好友榜可以按“查询时懒加载”来做:

sequenceDiagram
    participant U as 用户
    participant A as 应用服务
    participant R as 关系链服务
    participant K as 步数 KV

    U->>A: 打开好友步数榜
    A->>R: 查询好友 UIN 列表
    R-->>A: 返回 200 个好友
    A->>K: 批量 MGET 今日步数
    K-->>A: 返回步数列表
    A->>A: 内存排序
    A-->>U: 返回排行榜

如果一个用户有 200 个好友,系统只需要批量读取这 200 个用户今天的步数,然后在应用层排序。排序规模很小,用内存排序就够了。

这种设计把“全局计算”变成“用户访问时的局部计算”。用户打开榜单的时间天然分散,压力也会被分摊到大量请求和大量存储分片上。

群排行榜:按群 ID 分片

群排行榜和好友榜不同。好友榜是每个人看到自己的好友集合,群榜则是每个群有一个相对固定的榜单。这里可以给每个群维护一个小 ZSet:

Group:Steps:20260607:Group_88888

更新群榜时,把群成员的步数写入该群对应的 ZSet:

ZADD Group:Steps:20260607:Group_88888 15400 UIN_12345

为了避免所有群榜集中到少数 Redis 节点,需要按 Group_ID 做哈希分片:

redis_node = hash(group_id) % redis_node_count

整体结构可以这样设计:

flowchart LR
    A[群步数更新] --> B[计算 Group_ID 哈希]
    B --> C{路由到 Redis 节点}
    C --> D[Redis Node 1]
    C --> E[Redis Node 2]
    C --> F[Redis Node N]

    D --> D1[Group A ZSet]
    D --> D2[Group B ZSet]
    E --> E1[Group C ZSet]
    F --> F1[Group D ZSet]

每个群一个小 ZSet,比全网一个大 ZSet 安全得多。即使某些大群比较活跃,热点也被限制在单个群或少数群内,不会拖垮整个排行榜系统。

历史数据要在低峰期归档

今天的步数是热数据,需要放在高性能 KV 或缓存层里。昨天、前天、更早的步数访问频率会快速降低,如果一直留在内存里,成本会非常高。

冷数据处理通常分成两步:异步归档和自动过期。

flowchart LR
    A[昨日步数 Key 冻结] --> B[低峰期限流扫描]
    B --> C[批量写入冷存储]
    C --> D[(MySQL / HBase / 对象存储)]
    C --> E[设置归档完成标记]
    E --> F[旧 Key 等待 TTL 过期]
    F --> G[释放内存]

常见做法是凌晨低峰期启动归档任务,例如 2:00 到 4:00:

  1. 读取已经冻结的昨日步数 Key。
  2. 分批写入 MySQL、HBase 或其他低成本持久化存储。
  3. 控制归档速度,避免影响在线业务。
  4. 给旧 Key 设置 TTL(Time To Live,生存时间),例如保留 2 到 3 天。
  5. 归档完成并超过 TTL 后,内存自动释放。

这里的原则是:内存负责扛实时高并发,冷存储负责保存历史账本。两者不要混在同一层解决。

一套完整的零点清零架构

把前面的设计合起来,一个 10 亿级步数系统可以按下面的链路工作:

flowchart TB
    A[手机客户端] --> B[接入层网关]
    B --> C[鉴权与限流]
    C --> D[按 UIN 路由]
    D --> E[分布式 KV / PaxosStore]

    E --> F[今日步数 Key]
    E --> G[昨日步数 Key]
    E --> H[消息队列 MQ]

    H --> I[异步聚合任务]
    I --> J[(冷数据存储)]

    E --> K[好友榜服务]
    E --> L[群榜服务]
    K --> M[关系链服务]
    L --> N[Redis ZSet 分片]

    J --> O[历史步数查询]

核心设计可以总结成几条规则:

目标 设计
避免零点数据库崩溃 不做全表更新,用日期 Key 实现逻辑清零
扛住高频步数上报 用分布式 KV 承载实时原子累加
控制单点压力 按 UIN、Group_ID 做哈希分片
处理时钟误差 零点前后设置缓冲窗口,允许旧账本和新账本短暂共存
避免 Redis BigKey 不做全国总榜,好友榜懒加载,群榜拆成小 ZSet
降低存储成本 热数据放内存,冷数据低峰期异步归档

设计类似系统时的回答框架

遇到“10 亿用户零点清零”“会员积分跨年清零”“大型游戏每日排行榜结算”这类问题,可以按四层回答。

第一层,先否定物理清零。不能在零点对所有用户执行批量更新,也不能让流量直接打到单体 MySQL。MySQL 只适合做最终归档,并且要按用户 ID 分库分表。

第二层,设计逻辑清零。Key 中加入日期或版本号,例如:

User:Steps:{date}:{uin}

零点后读写新日期 Key,新 Key 不存在时默认值就是 0。系统状态通过时间版本切换完成,而不是通过批量修改数据完成。

第三层,处理分布式时间误差。零点前后设置几分钟缓冲窗口,用双写、事件时间判断或迟到数据修正机制,保证昨天结算准确,今天账本也能平滑开始。

第四层,拆分排行榜和冷数据。好友榜按关系链懒加载并在应用层排序;群榜按 Group_ID 分片到多个 Redis ZSet;历史数据在低峰期异步写入冷存储,旧 Key 依靠 TTL 自动释放。

这种架构的重点不是某个中间件名字,而是几个通用原则:峰值时少写,热点数据分片,状态切换逻辑化,实时链路和归档链路解耦。只要抓住这些原则,零点清零就不会变成 10 亿次数据库更新。


评论