如何设计一个支持 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 核心业务对象
会员系统至少包含四类对象:
- 会员账户:用户当前是否是会员、有效期、等级、状态;
- 等级成长:成长值累计、等级规则、升级和降级;
- 积分账户:可用积分、冻结积分、过期积分和收支流水;
- 会员权益:折扣、包邮、会员券、次数型权益和外部合作权益。
它们不能只用一个 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 本地缓存如何失效
可选组合:
- 短 TTL 保证最终过期;
- 会员状态变更事件主动失效;
- 缓存值携带
aggregateVersion; - 对结算等关键链路绕过 L1 或携带最低可接受版本;
- 故障时只对允许旧值的展示接口使用 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 一条故障排查路径
现象:大促开始后,商品页会员标识偶发消失。
- 看调用方是否被限流、L1 命中率是否突然下降;
- 看 Redis 单分片延迟、热 Key 和带宽;
- 看读服务回源 QPS、数据库连接池和慢查询;
- 对比命令事实版本、读模型版本和缓存版本;
- 若只是展示链路,开启短期旧值或隐藏装饰标识止血;
- 限制回源并分批预热,恢复后复盘失效事件和 TTL 分布。
十三、测试与验证证据
13.1 正确性测试
- 同一支付成功事件重复 100 次,只生成一个会员周期;
- 并发续费时,有效期正确串接,不丢失购买时长;
- 同一订单重复增加积分,只产生一条业务流水;
- 两个订单争抢最后一次权益,只有一个占用成功;
- 订单取消后权益只释放一次;
- 旧版本事件晚到,不覆盖新会员状态;
- 到期任务漏执行,强校验仍判定过期,扫描任务能补偿。
13.2 10 万 QPS 容量验证
容量报告不能只写“JMeter 10 万 QPS 成功”。至少分别验证:
- 热缓存稳态;
- 冷启动和批量缓存失效;
- Redis 单分片故障后的剩余容量;
- 读模型库回源限流是否保护数据库;
- 真实批量大小、用户分布和热点账号;
- 5% 写命令及 MQ 投影同时运行;
- 一个可用区或部分实例退出后的容量;
- 压测停止后积压多久清空。
报告需要保留环境、实例规格、数据规模、流量模型、持续时间、各分位延迟、错误率、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 高频追问
- 10 万 QPS 有多少落到 Redis 和数据库?用流量比例和实测命中率计算,不能凭空报数。
- 为什么需要本地缓存?减少网络调用和 Redis 热点,但必须控制 TTL、失效和关键链路绕过。
- Redis 挂了能否全部回源?不能,无界回源会打垮数据库,要限流并按业务等级降级。
- 会员刚开通却读到非会员怎么办?支付结果页使用命令返回版本强制回源,普通展示靠事件和 TTL 收敛。
- 积分为什么不只存余额?需要幂等、审计、过期、退款冲正和对账。
- 同一用户并发扣积分怎么办?条件余额加版本 CAS,必要时用户维度串行化。
- 会员过期任务漏了怎么办?强校验看
valid_until,扫描和对账补物化状态及事件。 - 10 万 QPS 为什么还需要数据库?缓存服务读,数据库和账本保存业务事实、命令和恢复证据。
- 如何处理 MQ 乱序?事件带聚合版本,发现跳跃时回源重建。
- 会员系统要不要一开始分库?看写 TPS、数据量和分片故障容量证据,不由总读 QPS 单独决定。
15.3 项目口径
如果只做过会员查询缓存,就讲清快照、命中率、失效和回源保护;如果只做过积分模块,就围绕账本和幂等讲。没有 10 万 QPS 压测报告,应该说“这是按 10 万 QPS 目标推导的方案”,不能说项目已经稳定支撑。
总结
10 万 QPS 会员系统的关键不是把所有请求塞进一个更大的 Redis,而是识别四种不同事实:会员账户、等级成长、积分账本和权益实例。展示型读通过本地缓存、Redis 和读模型分层吸收;开通、积分和权益命令通过唯一业务键、状态机、账本和 Outbox 保持可追溯;缓存失效、事件乱序、任务遗漏和外部 Provider 结果未知都有恢复证据。
只有给出真实流量模型、每层容量公式、过载降级和压测方法,“支持 10 万 QPS”才是可验证的设计目标,而不是一句中间件口号。