后端常见项目与架构设计面试题:秒杀、会员、积分、余额系统
如果你正在准备后端系统设计面试,你几乎一定会遇到高流量事务系统的设计问题——秒杀、会员体系、积分引擎、余额账本,这些都是高级后端岗位的核心考点。我在实际工作中长期维护这类系统,本文将从架构设计、数据模型到一致性方案,逐一拆解生产环境中真正有效的做法。
秒杀 / 高并发抢购系统
秒杀系统整体架构思路?
核心矛盾:瞬时流量极大(读多写少)、库存有限、不能超卖、要抗住峰值。
分层设计:
**前端 / 网关层**
答题、滑块、验证码:把一部分请求挡在入口,削峰、防脚本。按钮置灰、前端限流:减少无效点击。静态化:活动页、商品详情 CDN + 静态页,减少回源。
**接入层**
限流:Nginx/网关按 IP、用户、接口 QPS 限流。防重:同一用户同一活动短时间只允许一次"下单请求"(token/幂等键)。
**服务层**
读多写少:缓存库存 / 活动信息(Redis),读请求尽量走缓存,减轻 DB。写一致:预扣减在 Redis(DECR),扣到 0 或负数即售罄,快速失败;异步或定时同步到 DB,或 DB 只做最终一致性校验。队列削峰:请求通过 Redis 预检后,写入 MQ,下游订单服务按能力消费,避免 DB 被打挂。
**数据层**
库存表:可加乐观锁(version)或 `WHERE stock >= ?` 做扣减,防止超卖。分库分表:按订单/用户分片,分散写压力。
小结:前端与网关削峰防刷 → 缓存扛读与预扣减 → MQ 削峰写 → DB 做最终一致与防超卖。
秒杀如何防止超卖?
多层保证:
1. Redis 预扣减:用 `DECR` 扣减库存,扣前判断 `GET` 是否 > 0;或 Lua 脚本把"判断 + 扣减"做成原子操作。扣成功再进下单流程;扣失败直接返回"已售罄"。
2. 数据库防超卖:乐观锁——表加 `version`,更新时 `UPDATE ... SET stock=stock-1, version=version+1 WHERE id=? AND version=? AND stock>=1`,影响行数为 0 则重试或返回失败。条件更新——`UPDATE product SET stock=stock-1 WHERE id=? AND stock>=1`,依赖 DB 的行锁/原子性,未更新到则说明已不足。
3. 唯一约束防重复:用户+活动+商品 唯一索引,同一用户同一秒杀只能成功一笔,配合幂等接口。
建议:Redis 做"快速预扣减 + 售罄判断",DB 做"最终扣减 + 防超卖",两者结合。
秒杀库存 Redis 和 DB 如何保持一致?
以 DB 为准,Redis 做"可售数量"的缓存与预扣减。
初始化:活动开始前,把 DB 库存同步到 Redis(如 `SET stock:activity_id 1000`)。扣减:先 Redis DECR(或 Lua),扣成功再落单;异步或定时任务把"已扣减量"或"剩余量"同步回 DB;或下单成功时写 MQ,消费者去 DB 扣减。对账:定时任务对比 Redis 剩余与 DB 剩余,差异大时以 DB 为准修正 Redis,并记录告警。注意:允许短时间 Redis 与 DB 不完全一致,但最终一致;关键是不超卖(DB 扣减时用条件更新再次校验)。
会员系统(等级、权益、成长值)
会员等级 / 成长值系统怎么设计?
表设计要点:
- 用户会员表:user_id、等级、成长值、过期时间、开通/续费时间等。
- 等级配置表:level、所需成长值、权益(折扣、免邮、专属客服等)。
- 成长值流水表:user_id、变动值、来源(订单、签到、任务)、业务单号、时间,便于对账与审计。
等级计算:
- 方案一(实时计算):查流水汇总或当前成长值,和配置表比对得出等级;适合规则简单、查询量不大。
- 方案二(等级缓存):成长值变动时异步或定时重算等级,写入用户会员表;读时直接取等级,适合高并发。
一致性:成长值变更——先写流水表(可加唯一约束防重),再更新用户当前成长值;用事务或消息表+异步补偿,保证"有流水就有更新"。等级变更——由成长值更新触发重算,或定时任务扫"成长值跨档"的用户批量更新等级。
会员权益(如折扣、次数)如何不超发、不重复用?
权益 = 总量 + 已使用量,每次使用做"扣减 + 校验"。
权益表:用户维度或 用户+权益类型 维度,记录总次数/总量、已用次数、重置周期(月/年)。使用:先查剩余次数,再 `UPDATE ... SET used = used + 1 WHERE user_id=? AND type=? AND used < total`(或乐观锁),更新成功再发权益(发券、打折等)。幂等:每次使用带业务单号(订单号、请求 id),表或 Redis 做唯一约束/幂等键,同一单号只生效一次。过期与重置:定时任务按周期把 used 清零或按规则重置 total,注意和"使用中"的并发(用版本号或时间窗口)。
积分系统
积分系统表与流程设计?
表设计:
- 账户表:user_id、当前积分余额、更新时间。
- 流水表:user_id、变动积分(正负)、余额快照(可选)、业务类型(下单、退款、活动、过期)、业务单号、创建时间。流水表可分表分库按 user_id 或时间。
流程:
- 发放:先插流水(业务单号唯一防重),再 `UPDATE account SET balance = balance + ? WHERE user_id = ?`;用事务保证"有流水就改余额",失败则整体回滚。
- 扣减:先判断余额是否足够,再插扣减流水,再 `UPDATE ... SET balance = balance - ? WHERE user_id = ? AND balance >= ?`,防止超扣。
- 过期:定时任务扫"将过期积分",生成扣减流水并更新余额;流水类型标为"过期扣减"。
一致性要点:流水与余额在同一事务中变更;扣减用条件更新防超扣;业务单号唯一防重复发放/扣减。
积分和订单、支付如何保证一致?(例如下单送积分)
思路:最终一致性 + 幂等 + 补偿。
下单送积分:支付成功或订单完成事件发 MQ,积分服务消费消息,按"订单号+类型"做幂等:已处理过则跳过;否则写流水并增加余额。退款扣积分:退款成功发 MQ,积分服务扣减该订单所得积分,同样订单号幂等;扣减时用 `balance >= ?` 条件更新,不足则记录异常人工/自动处理。一致性:不强求与订单库同一事务,而是"支付/订单状态为准,积分异步跟单";对账任务定期用订单与积分流水核对,差异则告警或自动补发/扣减。
积分/余额的两种统计设计:流水汇总 vs 账户字段
问题:当前余额/积分,是每次从流水表实时汇总,还是用账户表的一个字段实时加减、读时直接读该字段?
设计一(以流水表为准,实时汇总):不单独存"余额"字段(或只作缓存);当前余额 = `SUM(变动额) WHERE user_id = ?`(或按账户维度汇总)。优点:流水是唯一真相,不会出现"余额和流水对不上";对账简单甚至不需要;审计、查历史任意时点余额都自然支持。缺点:每次查余额都要聚合流水,数据量大时慢、费资源;高并发读需要做汇总层或缓存,架构更复杂。适用:强审计、合规、对账要求高,且读量可控或能接受缓存的场景(如部分 To B 资金)。
设计二(账户表存余额字段,写时加减、读时直接读字段,常用):账户表有 `balance`(及可选 `freeze`);每笔变动先插流水,再 `UPDATE account SET balance = balance ± ?`;读余额直接读该字段。优点:读简单、快,高并发友好;实现简单,流水记"发生了什么",余额表示"当前是多少"。缺点:余额与流水是两处数据,可能因 bug 或异常不一致;必须通过对账发现并修复:用"期初 + 流水汇总"与当前 `balance` 比对,差异告警或自动纠偏。适用:高并发、读多写多的互联网积分、余额、钱包等。
结论:积分/余额场景多数采用设计二(账户字段 + 流水表),读用字段,写时"插流水 + 改余额"同一事务,再通过对账兜底;设计一适合读量不大或强合规、以账为准的场景。
余额 / 资金账户系统
用户余额系统如何设计(充值、消费、退款)?
表设计:
- 账户表:user_id、余额、冻结金额、版本号(乐观锁)、更新时间。
- 流水表:user_id、变动金额、方向(收入/支出)、类型(充值/消费/退款/提现/冻结/解冻)、业务单号、余额快照、时间。流水表建议按 user_id 分表。
操作规范:
- 充值:插收入流水(订单号幂等),`UPDATE account SET balance = balance + ?, version = version + 1 WHERE user_id = ? AND version = ?`。
- 消费:先判断余额 >= 消费额,再插支出流水,再 `UPDATE ... SET balance = balance - ? WHERE user_id = ? AND version = ? AND balance >= ?`;失败则返回余额不足。
- 退款:插收入流水(原订单号+退款单号幂等),余额增加,同上条件更新。
- 冻结/解冻:先扣 balance、加 freeze;解冻时扣 freeze、加 balance 或直接扣 freeze 转支出;流水分别记录,保证 balance + freeze 与流水汇总一致。
一致性:流水与余额更新在同一 DB 事务;所有变更带业务单号并做唯一约束防重;用乐观锁或条件更新防并发超扣。
余额场景如何保证高并发下不超扣、不错账?
1. 数据库层:扣减用条件更新——`UPDATE account SET balance = balance - ? WHERE user_id = ? AND balance >= ?`,未更新到则返回失败;或用乐观锁 `WHERE version = ?`,更新失败重试或返回。
2. 幂等:每笔扣款/退款对应唯一业务单号(订单号、支付单号),流水表或单独幂等表唯一约束,重复请求只生效一次。
3. 流水与余额同一事务:先插流水,再改余额,事务提交;避免"余额改了流水没写"或反过来。
4. 对账:定时用"期初 + 流水汇总"与当前余额、冻结比对,不一致则告警并修复(以流水为准调余额或人工处理)。
分布式下余额/积分"扣减"如何避免重复扣?(网络重试、重复请求)
幂等键:请求方生成唯一 request_id 或使用业务单号(订单号+操作类型),服务端以该键做"唯一约束"或"先查再插流水"。流水表唯一索引:如 (user_id, biz_type, biz_no),同一业务单号只能插一条流水,重复请求会唯一冲突,直接返回"已处理"或原结果。状态机:扣减有"处理中/成功/失败",先置为处理中再落库,成功再改成功;重复请求看到已成功则直接返回。
通用设计题
如何设计一个"防止重复提交 / 重复下单"的机制?
- 前端:提交后按钮禁用、防重复点击。
- 后端:同一接口带幂等键(如 token、order_token),用户进入下单页时下发,提交时带上;服务端 Redis 或 DB 记录"该 token 已使用",用后即废。或用业务维度唯一:用户+商品+活动+时间窗口 唯一,重复则返回"请勿重复下单"。
- DB:订单表 用户+商品+活动 唯一索引,从根上杜绝重复单。
分布式环境下如何保证"扣库存 / 扣余额"这类操作的一致性?
- 尽量单库事务:流水与余额/库存同库同事务,先写流水再改数值,条件更新防超扣。
- 若跨服务:最终一致——上游先改自己的库并发 MQ,下游消费 MQ 做本地扣减,幂等 + 重试;对账补齐差异。Saga / 补偿——先扣 A,再扣 B;若 B 失败则发"补偿 A"的消息,A 回滚或记欠款。两阶段/Seata——强一致场景,成本高,一般只在核心资金场景考虑。
- 幂等:所有参与方用同一业务单号做幂等,避免重试导致重复扣。
缓存和 DB 不一致时,先更新缓存还是先更新 DB?有哪些常见方案?
- Cache Aside:读时先读缓存,没有则读 DB 并回写缓存;写时先更新 DB,再删缓存(而不是更新缓存),下次读时再加载。避免"更新缓存失败"或并发写导致脏缓存。
- 延迟双删:更新 DB 后删缓存,再延迟几百 ms 再删一次,降低"读旧数据并回写"的脏读窗口。
- 订阅 binlog:DB 变更后通过 canal 等同步到 MQ,专门服务消费后删/更新缓存,业务只写 DB,解耦一致性问题。
原则:以 DB 为准;写路径尽量"改 DB + 删缓存",读路径"没缓存再加载";复杂场景用 binlog 同步或延迟双删。
接口幂等如何设计?(支付回调、重试、重复点击)
- 唯一业务键:支付回调用"支付单号"或"订单号+支付渠道+状态",流水表或处理表唯一约束,同一单只处理一次;处理前先查是否已存在,存在则直接返回成功。
- 幂等表:request_id / token 存 Redis 或表,设置过期时间;"先查再 set"或" set NX",未设置过才执行业务并标记已用。
- 状态机:订单/单号有状态(待支付/已支付/已退款),只有从"待支付"到"已支付"才加余额、发积分,重复回调看到已支付则直接返回成功。
大促/活动时,如何从架构上保护系统?(限流、降级、熔断)
- 限流:网关或接入层按 IP、用户、接口做 QPS/并发限制;令牌桶、漏桶;核心接口单独限流。
- 降级:非核心功能关掉或返回默认值(如推荐、积分展示),保证下单、支付、库存主链路。
- 熔断:下游超时或错误率过高时,短时间内不再调用,直接返回降级结果,避免雪崩。
- 扩容与隔离:核心库、核心服务独立资源;读从库、缓存扛读;MQ 削峰,避免 DB 被打满。
- 预案:提前压测、容量评估、开关配置(如关掉积分、关掉部分活动页),随时可降级。