Java 业务场景深度面试专题

9 篇 · 免费在线阅读

10 万 QPS 会员系统设计:高并发读与权益账本

如何设计一个支持 10 万 QPS 的会员系统?

阅读说明与事实边界

“支持 10 万 QPS”不是先选 Redis Cluster、分库分表和 MQ,而是先问:这 10 万次请求到底在做什么?

如果 10 万 QPS 全是查询“用户是不是会员”,和 10 万 QPS 全是增加积分、发放权益,系统不是一个难度。前者可以通过本地缓存和 Redis 承担,后者会受单用户串行、数据库写入、事件重复和权益发放能力限制。

本文是一份容量设计案例,不是生产成绩。假设大促期间,商品详情、购物车、结算页和权益中心会高频查询会员身份及可用权益,平台给会员提供等级折扣、包邮券、月度权益和积分。容量标尺如下:

指标设计目标说明
外部峰值请求100,000 QPS不是所有请求都进入会员核心服务
会员快照查询85%判断身份、等级和核心权益摘要
权益可用性查询10%结算等强业务链路,需要规则和占用状态
积分及会员变更命令5%写请求,必须有幂等和流水
查询延迟按业务压测设定不在无测试情况下虚构 P99 数字
可用性按业务 SLA 设定需要明确降级时哪些权益可继续使用

比例也是设计输入,真实系统必须从埋点和容量计划获得。本文要回答的是:如何把 10 万外部 QPS 分层吸收,以及怎样保证会员等级、积分和权益事实不被缓存速度牺牲。

一、项目基础:会员系统究竟管什么

1.1 核心业务对象

会员系统至少包含四类对象:

  1. 会员账户:用户当前是否是会员、有效期、等级、状态;
  2. 等级成长:成长值累计、等级规则、升级和降级;
  3. 积分账户:可用积分、冻结积分、过期积分和收支流水;
  4. 会员权益:折扣、包邮、会员券、次数型权益和外部合作权益。

它们不能只用一个 member 表和一个 points 字段解决。会员身份是低频变更的状态;积分是高频、有财务相似性的账本;权益可能需要发放、占用、核销、退回和过期;等级则由规则版本与成长值驱动。

1.2 系统边界

会员系统负责判断和记录会员事实,不负责订单金额的最终结算,也不应让商品页为了展示会员标签同步串行调用十几个权益 Provider。结算系统使用会员返回的规则版本和权益占用凭证,最终价格仍由交易链路校验。

1.3 非目标

  • 不把整个营销系统塞进会员服务;
  • 不在会员接口里同步计算所有历史订单;
  • 不承诺缓存读到的所有字段瞬时一致;
  • 不把“100,000 外部 QPS”写成数据库也要承受 100,000 QPS;
  • 不用一个大 JSON 保存所有会员、积分和券事实。

二、业务闭环:一次会员购买、使用和到期

以用户购买年度会员并在结算页使用包邮权益为主线:

这里有两条不同链路:

  • 展示链路读取会员快照,允许短时间最终一致和合理降级;
  • 权益占用、核销和积分扣减属于命令链路,必须回到事实服务,不能只相信本地缓存里的“还有一次”。

2.1 会员账户状态机

不建议只用 expire_at > now 推导一切。冻结和终止都可能发生在有效期内,账户状态机保存业务原因,expire_at 负责时间边界。

2.2 权益实例状态机

会员有效不等于某项权益必然可用。权益还受次数、适用商品、渠道、地区、订单金额和占用状态影响。因此“会员快照查询”和“权益可用性裁决”要分开。

三、先拆 10 万 QPS,而不是先加机器

3.1 请求分类

100,000 QPS 是入口总量,系统设计的第一目标是让大部分重复读取在靠近调用方的位置结束。可以把计算写成明确的容量公式:

进入 Redis 的 QPS = 会员快照查询 QPS × 本地缓存未命中率
进入数据库的查询 QPS = 进入 Redis 的 QPS × Redis 未命中率 + 强校验查询 QPS
写库 TPS = 有效命令 QPS × 每命令平均事务写次数
事件吞吐 = 成功状态变更 TPS × 平均下游事件数

例如本地缓存命中率和 Redis 命中率都必须由真实流量分布测得,不能为了让结果好看直接假设 99%。新活动上线、用户集中续费和缓存重建时,命中率会明显变化,容量测试要覆盖这些冷启动情况。

3.2 读接口和命令接口分开

类型示例一致性过载策略
展示型快照昵称旁会员标、等级图标秒级最终一致可接受返回短期旧值或隐藏装饰信息
价格提示“会员预计省 8 元”结算前可用快照标记为预估,结算重新校验
权益占用占用一次包邮权益强业务裁决不能用旧缓存直接成功
积分扣减订单抵扣积分账本一致性拒绝或排队,不能静默丢失
会员开通支付成功后开通幂等最终一致可返回处理中,后台持续收敛

读写分离不是把两个 Controller 分开,而是让展示读可以缓存和降级,命令写必须留下唯一业务键、状态和流水证据。

四、技术架构与模块边界

4.1 模块责任

  • 会员快照 SDK:在商品和购物车等高频调用方缓存最小快照,合并并发加载;不执行积分扣减和权益核销。
  • 会员读服务:提供会员摘要、权益展示和版本信息;不直接修改事实。
  • 会员命令服务:开通、续费、冻结、积分记账和权益状态迁移;所有命令要求业务幂等键。
  • 读模型投影器:把账户、等级和权益摘要组合为可缓存快照;重复事件按聚合版本幂等。
  • 任务服务:会员到期、积分过期、权益占用超时和对账;只调用领域命令,不直接改表。

4.2 为什么不是一开始就拆十个微服务

账户、积分和权益可以先作为一个会员域内的模块化服务,数据库按表或 Schema 隔离。只有当团队边界、发布频率、容量热点或合规要求明确不同,才拆成独立服务。为 10 万读 QPS 拆微服务并不会自动提升吞吐,缓存策略、数据访问和热点隔离才是关键。

五、核心技术实现一:会员快照怎样扛高频读

5.1 快照只放高频且稳定的最小字段

{
  "userId": "u1001",
  "memberStatus": "ACTIVE",
  "levelCode": "GOLD",
  "validUntil": "2027-08-13T23:59:59+08:00",
  "benefitSummary": {
    "shipping": true,
    "discountRate": "0.95"
  },
  "aggregateVersion": 182
}

不要把全部积分流水、所有券实例和复杂规则塞进一个缓存值。对象越大,网络和序列化成本越高,任意小字段变化都会导致整块失效。

5.2 两级缓存读取

高频页面常会一次查询多个用户或多个商品关联权益,接口和 SDK 必须支持批量,避免 N+1 远程调用。

5.3 本地缓存如何失效

可选组合:

  1. 短 TTL 保证最终过期;
  2. 会员状态变更事件主动失效;
  3. 缓存值携带 aggregateVersion
  4. 对结算等关键链路绕过 L1 或携带最低可接受版本;
  5. 故障时只对允许旧值的展示接口使用 stale-while-revalidate。

仅靠广播删除并不可靠,实例可能离线错过事件;仅靠 TTL 又可能让会员开通后长时间不生效。因此使用“事件加 TTL”的双保险,并按业务接口定义最大可接受陈旧时间。

5.4 缓存击穿和重建风暴

热点会员或系统级默认配置失效时,大量请求会同时回源。需要:

  • 单实例内合并相同 Key 的并发加载;
  • Redis 回填使用短租约或逻辑过期,避免跨实例同时重建;
  • TTL 加随机抖动,避免同批会员一起失效;
  • 数据库回源有独立限流和线程池;
  • 缓存不可用时按接口等级降级,不让流量无界打到数据库。

六、核心技术实现二:积分为什么必须是账本

6.1 不能只更新余额

UPDATE member_points_account
SET available_points = available_points + :delta
WHERE user_id = :user_id;

只有余额无法回答积分从哪里来、为什么扣、重复事件是否处理过、退款该冲正哪一笔。正确模型至少包含账户和流水。

business_type + business_id 建唯一约束。例如同一订单完成事件无论重复消费多少次,只能产生一笔积分入账。

6.2 积分扣减的并发控制

单用户同时从两个端下单时,使用数据库条件更新或版本 CAS:

UPDATE member_points_account
SET available_points = available_points - :points,
    frozen_points = frozen_points + :points,
    version = version + 1
WHERE user_id = :user_id
  AND available_points >= :points
  AND version = :expected_version;

扣减先进入冻结,订单支付后核销,订单取消后解冻。账户更新与流水写入在同一本地事务。高并发压力通常分散在大量用户上;真正的单用户热点仍需串行化或限制,因为同一个余额无法无限并行修改。

6.3 积分过期

积分若按批次过期,需要记录每批入账的剩余额度和过期时间,消费时按产品规则选择先到期先用。每天直接扫描全量流水不可扩展,可以维护到期桶或账户级下一到期时间,由任务分片处理,再用对账修复遗漏。

七、核心技术实现三:会员开通与续费的一致性

7.1 支付成功事件可能重复

会员购买订单支付成功后,支付服务通过至少一次事件通知会员服务。会员服务用 source_order_id 做唯一业务键:

  • 首次处理:创建会员周期、更新账户有效期、生成权益实例、写 Outbox;
  • 重复处理:返回已经处理的结果;
  • 相同订单但金额或商品不一致:拒绝并告警;
  • 处理超时:上游可以重试,不会重复开通两年。

7.2 续费有效期怎么算

如果当前会员仍有效:

new_valid_until = current_valid_until + purchased_duration

如果已经过期:

new_valid_until = paid_at + purchased_duration

计算必须固定使用购买产品和规则版本,不能在重试时读取已经变更的新配置。会员周期明细保留每次开通来源,账户表只保存当前聚合状态。

7.3 正常与失败时序

数据库已经开通但缓存事件尚未处理时,展示可能短暂读到旧状态。支付结果页可以读取命令结果或使用“最低版本”强制回源,不能只刷新页面碰运气。

八、核心技术实现四:权益占用、核销和回退

8.1 为什么要先占用

一个会员每月有 2 次包邮权益。两个订单同时结算,如果都只查询 remaining_count = 1,两边都可能展示并使用成功。结算时应创建权益占用凭证:

reservation_id
benefit_instance_id
order_id
status: RESERVED / CONSUMED / RELEASED / EXPIRED
expire_at

order_id + benefit_instance_id 唯一,先条件扣减可用次数并写占用记录;订单支付后核销,取消或超时后释放。

8.2 权益服务返回什么

  • AVAILABLE:查询时看起来可用,不代表已经保留;
  • RESERVED:系统已为该订单占用;
  • CONSUMED:业务结局确认并核销;
  • PROCESSING:命令已受理,但事实尚未收敛;
  • REJECTED:规则不满足、次数不足或会员无效。

接口成功只代表平台接受或完成对应操作,不能把 HTTP 200 统一解释成权益已核销。

8.3 外部合作权益

机场贵宾厅、视频会员等权益可能由外部 Provider 发放。本地可以保证任务至少一次和状态幂等,但外部是否支持业务幂等、超时后结果是否可查询,决定端到端边界。结果未知时保存 Provider 请求号并主动查询,不能无脑重试造成重复发放。

九、热点、分片和 10 万 QPS 下的容量设计

9.1 分片键

会员账户、积分账户和用户权益以 user_id 分片,绝大部分用户维度命令可在单分片事务内完成。运营规则和权益定义是小量全局数据,通过版本化配置和本地缓存分发。

9.2 Redis Key 设计

  • member:snapshot:{user_id}:单用户快照;
  • member:config:{version}:版本化规则,只读;
  • 不创建一个包含全站会员的超大 Hash;
  • 批量接口按 Redis 分片分组管道,避免跨槽大事务;
  • 热点公众账号、测试账号和系统账户单独限流,避免单 Key 打满分片。

9.3 实例容量怎么算

不直接说“部署 20 台”。先通过单实例压测得到在目标响应时间、CPU、GC、连接池和网络带宽下的可持续吞吐 q_instance,再计算:

基础实例数 = ceil(目标服务 QPS / 单实例可持续 QPS)
规划实例数 = 基础实例数 × 峰值与故障余量系数

同时验证下游:Redis 每分片操作数与带宽、读模型库回源能力、MQ 分区吞吐、数据库写 TPS、连接数和故障切换后的剩余容量。应用实例够多但 Redis 或数据库只有单点,不能称为支持 10 万 QPS。

9.4 冷启动与容量保护

降级必须按业务等级设计。可以暂时不展示会员徽章,但不能用旧积分余额让订单重复抵扣;可以让开通结果显示“处理中”,但不能丢掉支付成功事件。

十、可靠性保证与不保证

场景系统保证不保证恢复证据
会员快照查询成功返回一个带版本的已知快照不保证所有展示字段瞬时最新TTL、变更事件、版本回源
积分命令成功账户和流水在本地事务一致不保证所有下游展示立刻刷新账本、Outbox、投影水位
开通事件重复同一购买订单只开通一次不保证 MQ 只投递一次唯一键和处理记录
外部权益请求超时保存结果未知和请求号不保证 Provider 未执行主动查询、对账、人工处理
Redis 故障会员事实不因缓存丢失不保证所有展示接口保持原延迟回源保护和缓存重建
读模型积压命令事实继续落库不保证快照瞬时更新消费位点、积压告警、重放

10.1 缓存和数据库双写失败

命令只提交数据库事实和 Outbox,不在事务中同步要求 Redis 成功。投影器异步更新缓存;失败则重试。关键结果页可根据命令返回的 aggregateVersion 强制读取至少该版本的数据。

10.2 事件乱序

同一会员聚合事件带 aggregateVersion。读模型仅接受比当前版本更大的事件;发现版本跳跃时暂停该用户投影并回源重建,不能让旧的 MEMBER_EXPIRED 覆盖后来的续费开通。

10.3 到期任务失败

会员账户的 valid_until 是时间真相。即使到期任务延迟,强校验接口也不能把已过期会员当有效。到期任务负责物化状态、停止新权益占用和发布事件;分片扫描和对账负责补漏。

十一、安全、风控与数据边界

11.1 接口权限

  • 普通用户只能查询自己的积分流水和权益明细;
  • 内部服务使用服务身份,并限制可调用的命令;
  • 运营配置需要 RBAC、审批、版本和审计;
  • 手工加积分、延长会员和重发权益必须写调整流水,禁止直接改余额;
  • 批量导出会员数据需要脱敏和访问审计。

11.2 防止重放和刷权益

命令携带调用方、业务类型、业务 ID 和签名上下文。服务端做唯一幂等、频率限制和规则校验。风控可以冻结账户或权益,但冻结也必须是显式状态变化,不能静默删除数据。

11.3 隐私

会员画像、消费等级和权益使用记录可能属于敏感行为数据。读模型只放业务需要的最小字段,日志不打印完整令牌、手机号和流水载荷,数据保存和导出遵循权限及保留策略。

十二、可观测、运维和对账

12.1 分层指标

层级指标
入口各接口 QPS、限流量、调用方分布、错误率
L1 缓存命中率、淘汰率、合并加载数、旧值返回数
Redis每分片 QPS、带宽、CPU、热 Key、超时、命中率
服务P50/P95/P99、线程池、连接池、GC、批量大小
数据库读写 TPS、慢查询、锁等待、分片倾斜、复制延迟
事件Outbox 未发布数、消费积压、版本跳跃、最老事件年龄
业务开通处理中数量、积分账不平、权益占用超时、异常调整数

12.2 三类对账

积分余额可以从账本按分片抽样或离线重算;会员周期与支付订单对账;权益实例与会员周期、规则版本对账。对账差异不直接粗暴覆盖,先判断是投影延迟、重复业务、规则变化还是事实缺失。

12.3 一条故障排查路径

现象:大促开始后,商品页会员标识偶发消失。

  1. 看调用方是否被限流、L1 命中率是否突然下降;
  2. 看 Redis 单分片延迟、热 Key 和带宽;
  3. 看读服务回源 QPS、数据库连接池和慢查询;
  4. 对比命令事实版本、读模型版本和缓存版本;
  5. 若只是展示链路,开启短期旧值或隐藏装饰标识止血;
  6. 限制回源并分批预热,恢复后复盘失效事件和 TTL 分布。

十三、测试与验证证据

13.1 正确性测试

  • 同一支付成功事件重复 100 次,只生成一个会员周期;
  • 并发续费时,有效期正确串接,不丢失购买时长;
  • 同一订单重复增加积分,只产生一条业务流水;
  • 两个订单争抢最后一次权益,只有一个占用成功;
  • 订单取消后权益只释放一次;
  • 旧版本事件晚到,不覆盖新会员状态;
  • 到期任务漏执行,强校验仍判定过期,扫描任务能补偿。

13.2 10 万 QPS 容量验证

容量报告不能只写“JMeter 10 万 QPS 成功”。至少分别验证:

  1. 热缓存稳态;
  2. 冷启动和批量缓存失效;
  3. Redis 单分片故障后的剩余容量;
  4. 读模型库回源限流是否保护数据库;
  5. 真实批量大小、用户分布和热点账号;
  6. 5% 写命令及 MQ 投影同时运行;
  7. 一个可用区或部分实例退出后的容量;
  8. 压测停止后积压多久清空。

报告需要保留环境、实例规格、数据规模、流量模型、持续时间、各分位延迟、错误率、CPU/GC、Redis 和数据库指标。未执行时标为 NOT_RUN,只能说这是 10 万 QPS 的目标方案。

13.3 故障演练

  • Redis Cluster 部分分片不可用;
  • 读模型投影暂停消费并恢复;
  • MQ 重复和乱序投递;
  • 会员数据库主从切换;
  • 外部权益 Provider 超时但实际发放成功;
  • 热点会员 Key 将某个分片打满;
  • 到期任务停运后重启补偿。

十四、设计取舍与演进

14.1 当前方案的代价

两级缓存和读模型能吸收读流量,但增加了版本、失效和重建复杂度;积分账本保证可追溯,但写放大和存储更大;权益占用能防并发超用,但结算链路多一次状态操作。它们都对应具体业务风险,不是为了堆技术栈。

14.2 什么时候再拆服务

出现以下信号再演进:

  • 积分写入、过期和对账已经拥有独立团队及发布节奏;
  • 外部合作权益的适配与结果未知处理显著拖累会员账户;
  • 会员快照读流量与命令流量需要独立容量池;
  • 合规要求积分或付费会员数据独立隔离;
  • 单库写 TPS、数据量或锁竞争已被压测和监控证明为瓶颈。

14.3 替代方案比较

方案优点适用边界
单体服务加 Redis简单、开发快中小规模,团队和容量尚未分化
模块化会员域加读模型兼顾一致性与高频读本文推荐的目标方案
全面 CQRS 和事件溯源审计和重放能力强团队具备治理能力且业务确实需要
所有逻辑写进缓存脚本单点操作快难以承担账本、审计和复杂外部副作用,不推荐作唯一事实

十五、面试复盘与职责边界

15.1 两分钟主回答

我会先拆 10 万 QPS 的流量结构,而不是假设全部打数据库。会员系统把展示型快照读和账户、积分、权益命令分开。商品等高频调用方通过 SDK 本地缓存吸收重复读,未命中访问 Redis Cluster,再由读服务受控回源读模型库;事件加 TTL 负责缓存最终刷新。会员开通、续费、积分和权益占用进入命令服务,以用户为分片键,使用业务唯一键、账户加流水和条件状态迁移保证幂等。命令事务同时写 Outbox,读模型按聚合版本投影,乱序事件不能覆盖新状态。积分抵扣和权益核销不使用旧缓存直接裁决。容量上通过真实命中率计算各层 QPS,压测热缓存、冷启动、写流量、单分片故障和积压恢复;降级时可以隐藏展示信息,但不能放松积分余额和权益次数校验。

15.2 高频追问

  1. 10 万 QPS 有多少落到 Redis 和数据库?用流量比例和实测命中率计算,不能凭空报数。
  2. 为什么需要本地缓存?减少网络调用和 Redis 热点,但必须控制 TTL、失效和关键链路绕过。
  3. Redis 挂了能否全部回源?不能,无界回源会打垮数据库,要限流并按业务等级降级。
  4. 会员刚开通却读到非会员怎么办?支付结果页使用命令返回版本强制回源,普通展示靠事件和 TTL 收敛。
  5. 积分为什么不只存余额?需要幂等、审计、过期、退款冲正和对账。
  6. 同一用户并发扣积分怎么办?条件余额加版本 CAS,必要时用户维度串行化。
  7. 会员过期任务漏了怎么办?强校验看 valid_until,扫描和对账补物化状态及事件。
  8. 10 万 QPS 为什么还需要数据库?缓存服务读,数据库和账本保存业务事实、命令和恢复证据。
  9. 如何处理 MQ 乱序?事件带聚合版本,发现跳跃时回源重建。
  10. 会员系统要不要一开始分库?看写 TPS、数据量和分片故障容量证据,不由总读 QPS 单独决定。

15.3 项目口径

如果只做过会员查询缓存,就讲清快照、命中率、失效和回源保护;如果只做过积分模块,就围绕账本和幂等讲。没有 10 万 QPS 压测报告,应该说“这是按 10 万 QPS 目标推导的方案”,不能说项目已经稳定支撑。

总结

10 万 QPS 会员系统的关键不是把所有请求塞进一个更大的 Redis,而是识别四种不同事实:会员账户、等级成长、积分账本和权益实例。展示型读通过本地缓存、Redis 和读模型分层吸收;开通、积分和权益命令通过唯一业务键、状态机、账本和 Outbox 保持可追溯;缓存失效、事件乱序、任务遗漏和外部 Provider 结果未知都有恢复证据。

只有给出真实流量模型、每层容量公式、过载降级和压测方法,“支持 10 万 QPS”才是可验证的设计目标,而不是一句中间件口号。