秒杀系统设计
书面笔记版
秒杀系统面对的是瞬时高并发,不是普通下单流程。核心目标是抗高并发、防超卖、防重复下单、削峰填谷和最终一致。
典型链路:
关键设计点:
| 设计点 | 说明 |
|---|---|
| 流量拦截 | 前端按钮置灰、验证码、网关限流、活动校验 |
| 数据预热 | 活动页静态化,库存和热点商品信息提前放入 Redis |
| Redis Lua | 原子完成库存判断、扣减和用户去重 |
| MQ 削峰 | Redis 扣成功后写 MQ,后端异步创建订单 |
| DB 兜底 | stock > 0 条件更新防超卖,唯一索引防重复下单 |
| 补偿对账 | MQ 发送失败、落单失败、库存不一致时靠重试和对账修复 |
秒杀入口限流通常放在网关或接入层,常见选型:
| 场景 | 推荐算法 | 说明 |
|---|---|---|
| 网关入口 QPS | 令牌桶或滑动窗口 | 令牌桶控制平均速率并允许短突发;滑动窗口统计最近一段时间,避免固定窗口边界突刺 |
| 用户/IP/商品维度 | 滑动窗口 | 限制某个用户、IP 或活动商品在最近 N 秒内的请求数 |
| 后端写库/下游调用 | 漏桶思想或 MQ 削峰 | 请求先排队,消费者按稳定速率处理,保护 DB 和下游 |
固定窗口计数器实现简单,但秒杀高峰容易在窗口切换瞬间出现双倍流量,一般不作为核心入口限流的唯一方案。
Lua 这里的原子性只指 Redis 内部:Redis 会把脚本当成一个整体命令串行执行,脚本执行期间其他客户端命令不能插到“查库存、判断用户、扣库存、记录用户”中间。但它不保证 Redis、MQ、数据库之间的分布式事务,所以后面仍然需要 MQ 重试、数据库条件更新、唯一索引和补偿对账。
数据库兜底示例:
update product
set stock = stock - 1
where id = #{skuId}
and stock > 0;
订单表加唯一索引:
uniq_user_sku(user_id, sku_id)
常见坑:
- Redis 扣了库存但 MQ 发送失败,需要本地消息表、事务消息或失败重试。
- MQ 可能重复投递,消费者必须幂等。
- 数据库落单失败,需要库存补偿、对账任务或人工兜底。
- 热点 key 压力大,可以考虑库存分片、本地缓存和限流。
面试口述版
秒杀系统我会从削峰、防超卖、防重复下单三个点讲。请求先经过网关限流和活动校验,入口限流可以用令牌桶或滑动窗口,避免固定窗口边界突刺;库存提前预热到 Redis,然后用 Lua 脚本原子完成库存判断、扣减和用户去重,扣成功后写 MQ,后端按漏桶思想平滑消费。Lua 原子是因为 Redis 把脚本作为一个整体串行执行,中间不会被其他客户端命令插队;但它只保证 Redis 内部原子,不保证 MQ 和数据库一起原子,所以还要靠 MQ 重试、数据库 stock > 0 条件更新、唯一索引、补偿和对账兜底。
缓存一致性怎么实现
书面笔记版
缓存一致性要先明确一点:缓存是加速层,数据库是事实来源。多数互联网业务追求最终一致,不追求绝对强一致。
最常用模式是旁路缓存(Cache Aside Pattern)。
| 模式 | 核心思路 | 特点 |
|---|---|---|
| 旁路缓存(Cache Aside) | 读缓存,未命中查 DB 并写缓存;写时更新 DB 再删缓存 | 最常用,简单可控 |
| 读穿透(Read Through) | 应用只访问缓存,缓存层负责回源 DB | 应用简单,但缓存层要理解数据加载 |
| 写穿透(Write Through) | 写缓存时同步写 DB | 一致性较好,但写延迟高 |
| 写回(Write Back) | 先写缓存,异步落 DB | 性能高,但丢数据和不一致风险大 |
| 绕写(Write Around) | 写 DB,不写缓存,下次读再加载 | 避免冷数据污染缓存 |
| 预刷新(Refresh Ahead) | key 快过期前提前刷新 | 适合热点数据 |
旁路缓存(Cache Aside)读流程:
- 先读缓存。
- 命中直接返回。
- 未命中查数据库。
- 查到后写缓存。
写流程推荐:
- 先更新数据库。
- 再删除缓存。
- 后续查询回源数据库并重建缓存。
删除缓存失败时,需要兜底:
- 删除失败重试。
- MQ 异步删除。
- 监听 binlog 删除缓存。
- 设置 TTL 最终过期。
延迟双删流程:
- 先删除缓存。
- 更新数据库。
- 等一小段时间。
- 再删除一次缓存。
它主要降低并发读把旧值回写缓存的概率。延迟时间不要死记,通常按读请求 P99/P999 耗时加 DB、Redis、网络抖动 buffer 估算。普通业务可以先从 500ms ~ 2s 估算,再结合压测和监控调整。
强一致业务,比如核心余额、资金流水,不应只依赖缓存一致性方案,可以考虑不走缓存,或用数据库事务和锁保证。
面试口述版
缓存一致性我一般按旁路缓存讲。读的时候先查缓存,未命中查数据库再写缓存;写的时候先更新数据库,再删除缓存。删除缓存失败要有重试、MQ、binlog 监听或 TTL 兜底。延迟双删主要解决并发读可能把旧值写回缓存的问题,延迟时间按读链路高分位耗时加 buffer 估算。它不是强一致方案,只是降低不一致窗口。
幂等性防止重复提交怎么做
书面笔记版
幂等性指同一个业务请求执行一次和执行多次,最终结果一致。重复提交常见来源包括用户连续点击、客户端超时重试、网关或服务重试、MQ 重复投递、第三方回调重复通知。
常见方案:
| 方案 | 适合场景 | 核心 |
|---|---|---|
| 前端按钮置灰 | 普通表单 | 只优化体验,不能兜底 |
| Token 防重 | 表单提交、创建订单 | 一次性 token,校验后删除 |
Redis SET NX EX |
高并发接口 | 用业务唯一键抢占处理资格 |
| 数据库唯一索引 | 下单、支付、开户 | 最终兜底,防重复写 |
| 状态机 | 订单、支付、退款 | 只有合法状态才能流转 |
| 分布式锁 | 同一资源串行处理 | 同一业务 key 同时只允许一个处理者 |
| 消费记录表 | MQ 消费 | 记录 messageId,消费过就跳过 |
Redis 幂等 key 示例:
idem:order:{userId}:{requestNo}
idem:pay:{payNo}
idem:callback:{thirdTradeNo}
状态机幂等示例:
update pay_order
set status = 'PAID'
where pay_no = #{payNo}
and status = 'WAIT_PAY';
只有未支付状态才能变成已支付,重复回调影响行数为 0,可直接认为已经处理过。
常见坑:
- 只做前端防抖,挡不住绕过前端和服务重试。
- 幂等 key 设计太粗会误拦截,太细挡不住重复请求。
- Redis key 没有过期时间,异常后长期残留。
- 没有数据库唯一索引,上层防重失效后会写重复数据。
- MQ 消费不做幂等,重复投递会重复执行业务。
面试口述版
幂等防重复提交要前后端一起做,但后端必须兜底。前端可以按钮置灰,后端常用一次性 token 或业务唯一号。高并发下可以用 Redis SET NX EX 判断是不是第一次请求,订单、支付这类核心数据还要用数据库唯一索引兜底。对于订单状态和支付回调,要用状态机控制,只允许从合法状态流转。MQ 消费也必须幂等,因为消息可能重复投递。
access_token、refresh_token 和 OAuth2.0
书面笔记版
access_token 和 refresh_token 是常见登录认证、第三方授权和开放平台接口里的两类令牌。它们经常一起出现,但职责不一样:
| Token | 中文理解 | 核心作用 |
|---|---|---|
access_token |
访问令牌 | 访问业务接口或资源接口,证明“当前请求有权限访问这个资源” |
refresh_token |
刷新令牌 | 在 access_token 过期后,用来换新的 access_token,不直接访问业务资源 |
典型用法是:
Authorization: Bearer <access_token>
业务接口一般只认 access_token。refresh_token 只应该发给认证服务器的刷新接口,例如:
POST /oauth/token
grant_type=refresh_token
refresh_token=xxx
为什么需要 refresh_token,不能只用 access_token:
| 方案 | 问题 |
|---|---|
只用长期 access_token |
用户体验好,但一旦泄露,攻击者可以长期访问接口,风险窗口太大 |
只用短期 access_token |
安全性更好,但过期后用户要频繁重新登录,体验很差 |
短期 access_token + 长期 refresh_token |
用短访问令牌降低泄露影响,用刷新令牌保证用户不用频繁登录 |
所以这套机制本质是在安全性和用户体验之间做平衡:
access_token有效期短,比如 15 分钟、30 分钟、2 小时。refresh_token有效期长,比如 7 天、30 天,甚至更长。- 平时访问接口只带
access_token,减少长期凭证暴露频率。 - 只有当
access_token过期时,才使用refresh_token去换新令牌。 - 如果
refresh_token也过期、被吊销或校验失败,就必须重新登录。
两者使用场景区别:
| 对比点 | access_token |
refresh_token |
|---|---|---|
| 主要用途 | 访问业务接口、资源接口 | 刷新或续期 access_token |
| 生命周期 | 短,有效期一般较短 | 长,有效期一般较长 |
| 携带频率 | 每次调用受保护接口都要带 | 只在刷新令牌时携带 |
| 发送对象 | 资源服务器或业务服务 | 授权服务器或认证服务 |
| 泄露影响 | 短期内可访问资源 | 风险更高,可能持续换取新访问令牌 |
| 常见形态 | JWT 或不透明随机字符串 | 通常是不透明随机字符串 |
| 服务端校验 | JWT 可本地验签,不透明 token 需查库或查 Redis | 通常需要服务端存储、校验、吊销 |
| 吊销方式 | 可依赖短过期时间,也可配黑名单 | 必须支持主动吊销、过期、设备维度管理 |
典型登录和刷新流程:
- 用户登录,认证服务校验账号密码、短信验证码、扫码结果或第三方授权结果。
- 登录成功后,认证服务签发
access_token和refresh_token。 - 前端访问业务接口时,在请求头中携带
access_token。 - 业务服务校验
access_token,通过后放行业务请求。 access_token过期后,前端调用刷新接口并携带refresh_token。- 认证服务校验
refresh_token,校验通过后签发新的access_token,也可以同时轮换新的refresh_token。 - 如果
refresh_token过期、被吊销、设备不匹配或账号状态异常,刷新失败,用户需要重新登录。
实际设计时要注意:
access_token不要设置得太长,否则泄露后风险窗口大。refresh_token权限更敏感,不能每个业务请求都带,避免暴露面变大。- 刷新令牌建议服务端保存一份状态,例如 Redis 或数据库中保存用户、设备、过期时间、是否吊销。
- 可以做 refresh token rotation:每次刷新都签发新的
refresh_token,旧的立即失效,降低重放风险。 - 登出、改密码、账号封禁、设备下线时,要让对应的
refresh_token失效。 - 多端登录时,最好按设备维度管理
refresh_token,避免一个设备登出影响所有设备,或者根据业务要求实现全端下线。 - 浏览器场景下,
refresh_token更适合放在HttpOnly、Secure、SameSiteCookie 中,减少被 JS 读取的风险;access_token可以放内存中,降低长期存储风险。 - 如果
access_token用 JWT,优点是业务服务可以本地验签,减少查库;缺点是签发后不容易立即失效,通常要配合短过期时间、黑名单或版本号。
OAuth2.0 是一个授权框架,重点解决“第三方应用如何在用户授权后访问用户资源”的问题。严格来说,OAuth2.0 本身不是单纯的登录协议;如果要做标准登录身份认证,通常会在 OAuth2.0 之上使用 OpenID Connect(OIDC)。但在日常面试和业务开发里,第三方登录经常会和 OAuth2.0 一起讨论。
OAuth2.0 里有四个核心角色:
| 角色 | 说明 | 例子 |
|---|---|---|
| 资源拥有者 Resource Owner | 资源的拥有者,通常是用户本人 | 用户 |
| 客户端 Client | 想访问资源的应用 | 第三方网站、App、小程序 |
| 授权服务器 Authorization Server | 负责登录、授权、签发 token | 微信、GitHub、Google 的授权服务 |
| 资源服务器 Resource Server | 保存用户资源并校验 access_token |
用户信息接口、订单接口、网盘文件接口 |
OAuth2.0 常见参数:
| 参数 | 作用 |
|---|---|
client_id |
标识客户端是谁 |
client_secret |
客户端密钥,只能保存在后端,不能暴露给前端 |
redirect_uri |
授权成功后的回调地址,通常要和平台注册配置完全匹配 |
response_type=code |
表示使用授权码模式,先拿 code,再换 token |
scope |
授权范围,比如读取头像、邮箱、联系人 |
state |
防 CSRF,也可以携带登录前页面状态,回调时要校验 |
code |
授权码,短期有效、一次性使用,用来换 token |
grant_type |
换 token 的授权类型,比如 authorization_code、refresh_token、client_credentials |
常见授权模式:
| 模式 | 适合场景 | 说明 |
|---|---|---|
| 授权码模式 Authorization Code | Web 服务端应用、第三方登录 | 用户授权后返回 code,后端用 code 换 token,安全性最好,最常用 |
| 授权码 + PKCE | App、SPA、小程序等公开客户端 | 没有安全保存 client_secret 的能力,用 PKCE 防止授权码被截获 |
| 客户端模式 Client Credentials | 服务调用服务 | 没有用户参与,客户端用自己的身份换 token |
| 刷新令牌 Refresh Token | token 续期 | access_token 过期后,用 refresh_token 换新的 token |
| 密码模式 Password | 早期自家高度可信客户端 | 用户把账号密码交给客户端,风险大,新系统一般不推荐 |
| 隐式模式 Implicit | 早期浏览器前端应用 | token 直接暴露在浏览器 URL,风险高,现在一般不推荐 |
授权码模式主流程:
1. 用户访问第三方应用,点击“使用某平台登录”。
2. 第三方应用把用户重定向到授权服务器。
3. 用户在授权服务器完成登录,并确认授权。
4. 授权服务器重定向回第三方应用,并带上 code。
5. 第三方应用后端拿 code + client_id + client_secret 去授权服务器换 token。
6. 授权服务器返回 access_token,可选返回 refresh_token。
7. 第三方应用用 access_token 调用资源服务器,获取用户信息或访问用户资源。
面试时要特别区分两个概念:
- 认证 Authentication:你是谁,比如账号密码登录、扫码登录、OIDC 返回身份信息。
- 授权 Authorization:你能访问什么,比如用户授权第三方应用读取头像、邮箱、联系人。
OAuth2.0 更偏授权;access_token 是访问资源的凭证;refresh_token 是续期访问令牌的凭证。
面试口述版
access_token 是访问令牌,用来访问业务接口或资源接口,一般有效期比较短,请求时放在 Authorization: Bearer xxx。refresh_token 是刷新令牌,不直接访问业务接口,只在 access_token 过期后去认证服务换新的访问令牌。之所以需要 refresh token,是因为只用长期 access token 泄露风险太大,只用短期 access token 又会导致用户频繁登录,所以常用“短 access token + 长 refresh token”平衡安全和体验。OAuth2.0 本质是授权框架,常见授权码模式是用户授权后返回 code,后端用 code 换 token,再用 access token 访问资源;如果 refresh token 也过期或被吊销,就需要重新登录。
为什么选择 MinIO
书面笔记版
MinIO 是一个 S3 兼容的对象存储服务,常用于私有化部署和内网部署场景。
常见用途:
- 图片、头像、商品图、合同附件。
- 音视频、压缩包、安装包、导入导出文件。
- 数据备份、归档文件、数据库备份包。
- 日志归档、审计文件、报表文件。
- AI/RAG 场景中的原始文档、PDF 和解析中间文件。
常用能力:
| 能力 | 说明 |
|---|---|
| S3 兼容 | 可使用 AWS S3 SDK,生态成熟 |
| Bucket 管理 | 按业务或环境隔离文件空间 |
| 预签名 URL | 临时授权上传下载,避免业务服务中转大文件 |
| 分片上传 | 适合大文件,提高稳定性 |
| 权限策略 | 可按 bucket、路径、账号控制访问 |
| 版本控制 | 同一对象保留多个版本 |
| 生命周期 | 自动清理过期文件或转归档 |
| 事件通知 | 上传后触发 MQ、Webhook 等处理 |
不用本地磁盘的原因:
- 多实例之间文件不共享。
- 扩容、迁移、备份麻烦。
- 应用服务和文件存储耦合。
- 权限、生命周期、临时 URL 等能力要自己实现。
相比 FastDFS,MinIO 的 S3 生态、SDK、云迁移能力和对象存储能力更完整;如果已经上公有云,OSS/COS/S3 运维更省心;如果私有化、内网部署、数据不能出本地,MinIO 更合适。
面试口述版
选择 MinIO 主要因为它是 S3 兼容的对象存储,生态成熟,适合私有化部署。它不只是存图片,还能存附件、音视频、导入导出文件、备份、日志归档和 AI 文档。相比本地磁盘,它更适合多实例共享、权限控制、预签名上传下载和生命周期管理;相比 FastDFS,它的 S3 生态和云迁移能力更好。如果已经在公有云上,可以优先用云厂商 OSS;如果要私有化和数据本地可控,MinIO 更合适。
Docker 容器化部署和物理机部署区别
书面笔记版
Docker 容器化部署的核心价值是把应用和运行环境一起打包成镜像,让开发、测试、生产尽量使用同一套运行环境。
| 对比点 | 物理机 / 直接部署 | Docker 容器化部署 |
|---|---|---|
| 交付物 | JAR/WAR + 环境安装文档 | 镜像,包含应用和基础运行环境 |
| 环境一致性 | 依赖人工安装,容易不一致 | 镜像一致,环境差异更小 |
| 启停方式 | 手动脚本或 systemd | docker run/ Compose / K8s |
| 扩缩容 | 新机器要重新装环境 | 拉镜像启动新容器 |
| 回滚 | 替换包和配置,操作较重 | 切回旧镜像版本 |
| 隔离性 | 多应用共享系统环境 | 进程、文件系统、网络隔离 |
| 数据持久化 | 直接写本机目录 | 需要 volume 或外部存储 |
典型部署流程:
- 编写
Dockerfile。 - 构建镜像。
- 推送镜像仓库。
- 服务器拉取镜像。
- 启动容器并挂载配置、日志目录和数据卷。
- 查看状态和日志。
- 新版本发布时替换镜像,回滚时切回旧镜像。
注意点:
- 容器本身不适合保存重要数据,数据库、文件、日志要挂载卷或使用外部存储。
- 生产镜像版本要固定,不要长期使用
latest。 - 配置和密钥不要写死在镜像里。
- 容器不是虚拟机,多个容器共享宿主机内核。
- 生产环境通常进一步用 Kubernetes、Docker Compose 或 CI/CD 平台管理。
面试口述版
物理机部署需要先装 JDK、中间件、配置目录和日志目录,再把应用包放上去运行,环境容易不一致。Docker 是把应用和基础运行环境打成镜像,部署时拉镜像启动容器,扩容、回滚和迁移都更方便。典型流程是写 Dockerfile、构建镜像、推送镜像仓库、服务器拉镜像、挂载配置和日志目录、启动容器。要注意容器无状态优先,重要数据要放 volume 或外部存储。
三分钟串讲版
书面笔记版
系统设计串讲可以按秒杀、缓存一致性、幂等、认证授权几个高频问题组织。
秒杀系统的核心是削峰、防超卖、防重复下单。活动页静态化,库存预热到 Redis,请求经过网关令牌桶或滑动窗口限流后,用 Redis Lua 原子完成库存判断、扣减和用户去重;Lua 原子性来自 Redis 对脚本的串行整体执行,但只覆盖 Redis 内部;扣减成功后写 MQ,后端按漏桶思想异步创建订单;数据库通过 stock > 0 条件更新和唯一索引兜底。
缓存一致性一般采用旁路缓存。读请求先查缓存,未命中查数据库再写缓存;写请求先更新数据库,再删除缓存。删除失败要有重试、MQ、binlog 监听或 TTL 兜底。延迟双删可以降低旧值回写缓存概率,但不是强一致方案。
幂等性解决重复提交和重复执行问题。前端按钮置灰只是体验优化,后端要通过 token、业务唯一号、Redis SET NX EX、数据库唯一索引、状态机来兜底。MQ 消费也要幂等。
认证授权常用短 access_token 加长 refresh_token。access_token 负责访问业务接口,短期有效,降低泄露影响;refresh_token 只负责续期,长期有效但要支持服务端吊销、过期和设备维度管理。OAuth2.0 本质是授权框架,常见授权码模式是用户授权后返回 code,后端用 code 换 token,再用 access_token 访问资源。
面试口述版
秒杀系统我会先讲削峰、防超卖、防重复下单:入口用令牌桶或滑动窗口限流,库存预热 Redis,Lua 原子扣减和去重,成功后写 MQ 按漏桶思想异步下单,最后数据库用条件更新和唯一索引兜底。缓存一致性按旁路缓存讲,读缓存未命中查 DB 再写缓存,写时先更新 DB 再删缓存,失败靠重试、MQ、binlog 和 TTL 兜底。幂等则强调后端兜底,token、业务唯一号、Redis SET NX EX、唯一索引和状态机一起用,核心链路不能只靠 Redis。认证授权可以讲短 access token 加长 refresh token,前者访问接口,后者负责续期;OAuth2.0 更偏授权框架,典型是授权码换 token 后访问资源。