前言

Github:https://github.com/HealerJean

博客:http://blog.healerjean.com

image-20210820174331668

一、设计不好有什么问题

image-20210820173706174

1、用户-体验差

设计要求 业务出现的问题
高性能 秒杀开始,系统瞬间承受平时数十倍甚至上百倍的流量,直接宕掉
一致性 用户下单后却付不了款,显示商品已经被其他人买走了

2、商家 -> 商品超卖 + 资金受损

1)商品超卖

设计要求 业务出现的问题
一致性 100 件商品,却出现 200 人下单成功,成功下单买到商品的人数远远超过活动商品数量的上限

2)资金受损

设计要求 业务出现的问题
高可用 竞争对手通过恶意下单的方式将活动商品全部下单,导致库存清零,商家无法正常售卖
高可用 秒杀器猖獗,黄牛通过秒杀器扫货,商家无法达到营销目的

3、平台

1)风险不可控

设计要求 业务出现的问题
高可用 系统的其它与秒杀活动不相关的模块变得异常缓慢,业务影响面扩散

2)拖垮网站

设计要求 业务出现的问题
高性能 在线人数创新高,核心链路涉及的上下游服务从前到后都在告警
高性能 库存只有一份,所有请求集中读写同一个数据,DB出现单点

二、高性能、高并发

核心优化理念

  • 高读场景 → 尽量“少读”或“读少”(减少请求、缓存前置)
  • 高写场景 → 数据拆分、削峰填谷

本节将从 动静分离、热点优化、服务端性能优化 三个方面展开。

image-20210820143442021

1、动静分离:应对流量洪峰的第一道防线

背景:活动页面是用户流量的入口,也是并发压力最大的地方。若所有请求直连服务端,极易导致系统崩溃。

方案:静态化 + 动静分离

原理:活动页中 90% 以上的内容是静态的(如商品名称、描述、图片等)。因此,可通过 静态化改造,仅在关键操作(如点击“秒杀”按钮)时才触发服务端请求。

  • 用户浏览常规信息时,无需访问后端;

  • 秒杀倒计时等动态元素通过轻量级异步更新实现,无需整页刷新

动静分离三步走

  1. 数据拆分:识别并分离动态与静态内容
  2. 静态缓存:将静态内容高效缓存
  3. 数据整合:在合适时机/位置拼接动态数据

image-20210820143519126

1)数据拆分

目标:将页面中可缓存的静态部分与必须实时获取的动态部分解耦。

a、用户相关数据

  • 身份信息:登录状态、用户画像等 → 通过独立 API 异步加载
  • 个性化推荐:如地域偏好、历史行为 → 同样异步获取,避免阻塞主流程

b、时间控制

  • 秒杀开始时间由服务端统一管理,前端通过轻量接口实时同步,确保一致性

2)静态缓存

背景:拆分后,需对静态内容进行高效缓存。关键问题:缓什么?缓在哪?

a、缓存方式:整页 HTTP 缓存

  • 不仅缓存 HTML/CSS/JS,而是直接缓存完整的 HTTP 响应体
  • Web 代理(如 NginxCDN)可根据 URL 直接返回响应,跳过协议解析与重组,极大提升性能
  • URL 天然具备唯一性(如 https://item.taobao.com/item.htm?id=12345),适合作为缓存键

b、缓存位置选择

方案 优点 缺点 / 风险
浏览器 最简单,零成本 用户不刷新则无法更新,不适合秒杀等强时效场景
服务端 可控性强 消耗内存与连接资源;Servlet 容器处理静态请求效率低,挤占核心计算资源
CDN 最优解: 离用户近,延迟低、 支持主动失效 、专为高并发静态请求优化 需解决失效与命中率问题

c、CDN 是什么?

答案:CDN (Content Delivery Network),即内容分发网络,通俗讲其主要功能就是让在各个不同地点的网络用户,都能够快速访问到网站提供的内容,不会经常出现等待或是卡顿的状况。

CDN,简单来讲就是一项非常有效的缩短时延的技术,CDN 这个技术其实说起来并不复杂,最初的核心理念,就是将内容缓存在终端用户附近。内容源不是远么?那么,我们就在靠近用户的地方,建一个缓存服务器,把远端的内容,复制一份,放在这里,不就OK了? 因为这项技术是把内容进行了分发,所以,它的名字就叫做CDN——Content Delivery Network,内容分发网络。

具体来说,CDN 就是采用更多的缓存服务器(CDN边缘节点),布放在用户访问相对集中的地区或网络中。当用户访问网站时,利用全局负载技术,将用户的访问指向距离最近的缓存服务器上,由缓存服务器响应用户请求

image-20210820151517799

问题1:CDN 使用注意事项

答案:CDN 本身更擅长处理大并发的静态文件请求,既可以做到主动失效,又离用户尽可能近,同时规避 Java 语言层面的弱点需要注意的是, CDN 有以下几个问题需要解决:

  • 缓存失效问题:任何一个缓存都应该是有时效的尤其对于一个秒杀场景。所以,系统需要保证全国各地的 CDN 在秒级时间内失效掉缓存信息,这实际对 CDN 的失效系统要求是很高的
  • 缓存命中率:高命中是缓存系统最为核心的性能要求,不然缓存就失去了意义。如果将数据放到全国各地的 CDN ,势必会导致请求命中同一个缓存的可能性降低,那么命中率就成为一个问题

因此,将数据放到全国所有的 CDN 节点是不太现实的,失效问题、命中率问题都会面临比较大的挑战。更为可行的做法是选择若干 CDN 节点进行静态化改造,节点的选取通常需要满足以下几个条件

推荐策略:采用 二级 CDN 缓存架构,仅在满足以下条件的节点部署静态页:

① 临近访问量集中的地区

② 距离主站较远的地区

③ 节点与主站间网络质量良好的地区

基于以上因素:选择 CDN 的二级缓存比较合适,因为二级缓存数量偏少,容量也更大,访问量相对集中,这样就可以较好解决缓存的失效问题以及命中率问题,此方案兼顾 失效可控性、命中率、部署成本,是当前业界主流实践。

image-20210820174934159

3)数据整合:动态内容如何注入?

静态页生成后,需将动态数据(如用户信息、倒计时)注入页面。常见两种方案:

选型建议

  • 对用户体验要求极高 → 选用 ESI(需基础设施支持)
  • 追求系统稳定性与通用性 → 选用 CSI(更主流)

a、方案一:ESIEdge Side Includes)——边缘端包含

原理:

  • CDN 或反向代理层(如 FastlyAkamai)解析 HTML 中的特殊标签(如 <esi:include src="/user-info" />

  • 边缘节点自动向源站发起子请求,获取动态片段,拼接成完整 HTML 后返回给浏览器

  • 用户看到的是 完整的首屏内容,无闪烁或加载占位。

当用户请求该页面时:

<!-- 静态页面中的 ESI 标签 -->
<html>
  <body>
    <h1>欢迎来到活动页</h1>
    <esi:include src="/api/user-info" />
    <div id="countdown">活动即将开始...</div>
  </body>
</html>
  • CDN 收到请求,发现 <esi:include>
  • CDN/api/user-info 发起内部请求,拿到 "<span>你好,张三!</span>"
  • CDN 将其替换进 HTML,最终返回完整页面。

优点:

  • 首屏即完整,用户体验极佳(无白块、无二次渲染)
    • 原因:用户请求页面时,CDN 已经把动态内容“拼好”了,浏览器直接收到一个完整的 HTML
    • 举例:
      • 假设你在做“618大促首页”,页面顶部要显示“欢迎回来,张三!”。
      • 如果用 ESI,用户打开页面瞬间就看到名字,没有“加载中”或空白。
      • 用户不会觉得“卡”或“内容缺失”,转化率更高。
    • 类比:就像餐厅给你上菜时已经摆盘完整,而不是先上个空盘子,再让服务员端个小碟子补上来。
  • 对前端代码侵入小(只需插入 ESI 标签)
    • 原因:你只需在静态 HTML 中写一个 <esi:include src="/user"> 标签,不用写 JS 逻辑。
    • 举例:
      • 你的页面是用 HugoJekyll 生成的纯静态页,根本没引入 React/Vue
      • 但你仍想插入用户信息——只需加一行 ESI 标签,无需改造前端架构。

缺点:

  • 增加边缘节点负担
    • 原因CDN 每次收到主页面请求,都要额外发起子请求(比如 /user-info),然后拼接。
    • 后果
      • 如果 /user-info 接口慢,整个页面响应就慢。
      • 高并发时,CDN 节点 CPU/内存压力大,可能限流或超时。
    • 举例
      • 双11零点,100 万用户同时访问首页。
      • CDN 要为每个请求都调用一次用户服务接口 → 边缘节点被打爆 → 页面加载失败。
  • 依赖 ESI 支持(基础设施限制)
    • 原因:不是所有 CDN 都支持 ESI

适用场景:高并发营销页、对 SEO 和首屏体验要求极高的场景(如电商大促首页)

b、方案二:CSIClient Side Includes)——客户端包含

原理:

  • 静态页面直接返回给浏览器;
  • 前端 JavaScript 在页面加载后,通过 AJAX/Fetch 请求动态接口,将数据填充到 DOM 中。
<!-- 静态 HTML -->
<div id="user-info">加载中...</div>
<script>
  fetch('/api/user-info')
    .then(res => res.json())
    .then(data => {
      document.getElementById('user-info').innerText = `你好,${data.name}!`;
    });
</script>

优点:

  • 通用性强:任何浏览器 + 任何后端都支持
    • 原因:只需要浏览器支持 JavaScript(现代浏览器都支持)。
    • 举例
      • 你用 GitHub Pages 托管静态站(免费、无后端)。
      • 但你想显示“当前用户是否登录” → 只需加一段 JS 调用你的 Auth API
      • 无论部署在哪(NetlifyVercel、自建 Nginx),都能跑。
  • 服务端压力小:动态请求分散到客户端
    • 原因:动态数据由浏览器自己去拉,源站只提供 API,不参与页面拼接。
    • 举例
      • 100 万用户访问静态页 → 源站只返回一个 HTML 文件(可被 CDN 缓存)。
      • 用户信息请求 /api/user 是分散到各个客户端发起的,源站 API 服务可以水平扩展。
      • 即使 API 暂时慢,也不影响主页面加载(只是用户信息晚点出来)。

缺点:

  • 首屏内容不完整:用户先看到“加载中”,再看到真实内容
    • 原因HTML 先加载,JS 再异步填充 → 中间有时间差。
    • 后果
      • 用户看到“加载中…” → 0.5 秒后变成“你好,张三”。
      • 如果网络慢,可能几秒都是空白,用户以为页面坏了。
    • 举例
      • 3G 网络下打开电商首页,顶部导航栏的“我的订单”一直显示“加载中”。
      • 用户可能直接关掉页面 → 跳出率上升
  • SEO 不友好(搜索引擎可能抓不到动态内容)
    • 原因:传统搜索引擎爬虫(尤其旧版)不执行 JS,看不到动态内容。
    • 举例
      • 你的商品详情页用 CSI 插入价格和库存。
      • Google 虽然能执行 JS,但 Bing、百度可能抓不到价格 → 商品无法被搜索到。
      • 影响自然流量。
  • 增加前端复杂度(需处理 loading、错误状态等)
    • 原因:你需要处理:
      • loading 状态
      • 错误状态(如用户未登录)
      • 数据更新(如倒计时每秒刷新)
    • 举例
      • 你要在页面显示“距离活动结束还有 XX:XX:XX”。
        • CSI 就得写定时器、处理时区、防内存泄漏……
        • ESI 只需后端返回一个静态字符串(虽然不实时,但可接受)

c、使用场景

维度 ESI 为什么? CSI 为什么?
首屏体验 ✅ 完整 CDN 拼好再返回 ❌ 异步加载 浏览器先渲染 HTML,再 JS 填充
服务端压力 ❌ 高 CDN 要发子请求拼接 ✅ 低 动态请求由客户端发起
兼容性 ❌ 依赖 CDN 只有部分 CDN 支持 ESI ✅ 通用 只要浏览器支持 JS
SEO ✅ 好 返回完整 HTML ❌ 差 爬虫可能看不到 JS 内容
开发调试 ❌ 难 需线上 CDN 环境 ✅ 易 本地即可测试
适用场景 高体验营销页 用户不能等,要“秒开完整” 通用 Web 应用 快速上线,稳定优先

d、秒杀按钮 - CSI方案

大部分用户怕错过秒杀时间点,一般会提前进入活动页面。此时看到的秒杀按钮是置灰,不可点击的。只有到了秒杀时间点那一时刻,秒杀按钮才会自动点亮,变成可点击的。但此时很多用户已经迫不及待了,通过不停刷新页面,争取在第一时间看到秒杀按钮的点亮。

前提

  • 活动页面已静态化,部署在 CDN
  • 页面中引用一个控制按钮状态的 JS 文件:seckill-btn.js?v=xxx
  • 服务端具备定时任务能力,可在精确时间点生成并推送新 JS
步骤 动作 责任方
1 生成初始 JScanStart=false 服务端
2 静态页 + JS 部署到 CDN 运维/构建系统
3 用户提前访问,加载静态资源 浏览器
4 前端根据 canStart=false 置灰按钮 前端
5 10:00:00 服务端生成新 JScanStart=true 服务端(定时任务)
6 JS 推送 CDN,带新版本号 服务端 + CDN API
7 前端检测到时间到达,动态加载新 JS 前端(轮询/定时器)
8 按钮点亮,用户可点击 前端
9 点击后 10 秒防重 前端
10 真实库存校验在后端完成 服务端

2、热点优化

热点分为热点操作和热点数据,以下分开进行讨论。

1)热点操作 vs 热点数据

核心思想:人(操作)难改,但数据可管;先识别,再隔离,最后优化。

类型 定义 特点 优化思路
热点操作 用户行为(如零点下单) 行为集中、时间敏感、不可控 限流 + 提示 + 削峰
热点数据 被高频访问的数据(如爆款商品) 数据集中、可识别、可缓存 识别 → 隔离 → 优化

2)热点操作:只能“限制

  • 用户在大促零点疯狂刷新页面、点击下单按钮,这是正常行为,无法禁止。
  • 但如果每个请求都穿透到后端,DB/服务会瞬间被打垮。
  • 解决:可以做一些限制保护,比如用户频繁刷新页面时进行提示阻断,把 大量 的无效请求挡在最外层,避免浪费后端资源。

3)热点数据:三步走策略

a、热点识别:静态 vs 动态

  • 静态热点:能够提前预测的热点数据**。大促前夕,可以根据大促的行业特点、活动商家等纬度信息分析出热点商品,或者通过卖家报名的方式提前筛选;另外,还可以通过技术手段提前预测,例如对买家每天访问的商品进行大数据计算,然后统计出 TOP N 的商品,即可视为热点商品

  • 动态热点:无法提前预测的热点数据。冷热数据往往是随实际业务场景发生交替变化的,尤其是如今直播卖货模式的兴起——带货商临时做一个广告,就有可能导致一件商品在短时间内被大量购买。由于此类商品日常访问较少,即使在缓存系统中一段时间后也会被逐出或过期掉,甚至在db中也是冷数据。瞬时流量的涌入,往往导致缓存被击穿,请求直接到达DB,引发DB压力过大

如何动态发现人热点,一个常见的实现思路是:

① 异步采集交易链路各个环节的热点 Key 信息,如 Nginx 采集访问 URLAgent 采集热点日志(一些中间件本身已具备热点发现能力),提前识别潜在的热点数据

② 指标打点观察,聚合分析热点数据,达到一定规则的热点数据,通过订阅分发推送到链路系统,各系统根据自身需求决定如何处理热点数据,或限流或缓存,从而实现热点保护

注意:

1、热点数据采集最好采用异步方式,一方面不会影响业务的核心交易链路,一方面可以保证采集方式的通用性

2、热点发现最好做到秒级实时,这样动态发现才有意义,实际上也是对核心节点的数据采集和分析能力提出了较高的要求

b、热点隔离:不让 1% 拖垮 99%

热点数据识别出来,第一原则就是将热点数据隔离出来,不要让 1% 影响到另外的 99%,可以基于以下几个层次实现热点隔离:

  • 业务隔离。秒杀作为一种营销活动,卖家需要单独报名,从技术上来说,系统可以提前对已知热点做缓存预热

  • 系统隔离。系统隔离是运行时隔离,通过分组部署和另外 99% 进行分离,另外秒杀也可以申请单独的域名,入口层就让请求落到不同的集群中

  • 数据隔离。秒杀数据作为热点数据,可以启用单独的缓存集群或者DB服务组,从而更好的实现横向或纵向能力扩展

当然,实现隔离还有很多种办法。比如,可以按照用户来区分,为不同的用户分配不同的 Cookie,入口层路由到不同的服务接口中;再比如,域名保持一致,但后端调用不同的服务接口;又或者在数据层给数据打标进行区分等等,这些措施的目的都是把已经识别的热点请求和普通请求区分开来。

c、热点优化:缓存 + 限流

热点数据隔离之后,也就方便对这 1% 的请求做针对性的优化,方式无外乎两种:

1、缓存:热点缓存是最为有效的办法。如果热点数据做了动静分离,那么可以长期缓存静态数据

2、限流:流量限制更多是一种保护机制。需要注意的是,各服务要时刻关注请求是否触发限流并及时进行eview

3、系统优化

对于一个软件系统,提高性能可以有很多种手段,如提升硬件水平、调优 JVM 性能,这是代码层面的性能优化

性能优化需要一个基准值,所以系统还需要做好应用基线,通过基线持续关注系统性能,促使系统在代码层面持续提升编码质量、业务层面及时下掉不合理调用、架构层面不断优化改进。比如

1、性能基线(何时性能突然下降)

2、成本基线(去年大促用了多少机器)

3、链路基线(核心流程发生了哪些变化),

1)减少序列化+减少RPC调用

减少 Java 中的序列化操作可以很好的提升系统性能。序列化大部分是在 RPC 阶段发生,因此应该尽量减少 RPC 调用,一种可行的方案是将多个关联性较强的应用进行 “合并部署”,从而减少不同应用之间的 RPC 调用

2)裁剪日志异常堆栈:

无论是外部系统异常还是应用本身异常,都会有堆栈打出,超大流量下,频繁的输出完整堆栈,只会加剧系统当前负载。可以通过日志配置文件控制异常堆栈输出的深度

5)去组件框架:

极致优化要求下,可以去掉一些组件框架,比如去掉传统的 MVC 框架,直接使用 Servlet 处理请求。这样可以绕过一大堆复杂且用处不大的处理逻辑,节省毫秒级的时间,当然,需要合理评估你对框架的依赖程度

三、一致性

秒杀场景下的一致性问题,主要就是库存扣减的准确性问题

秒杀的核心关注是商品库存,有限的商品在同一时间被多个请求同时扣减,而且要保证准确性,显而易见是一个难题。 秒杀系统中,库存是个关键数据,卖不出去是个问题,超卖更是个问题。

1、减库存的方式

电商场景下的购买过程一般分为两步:下单和付款。基于此设定,减库存一般有以下几个方式(减库存的问题主要体现在用户体验和商业诉求两方面,其本质原因在于购物过程存在两步甚至多步操作,在不同阶段减库存,容易存在被恶意利用的漏洞。):

1)下单减库存

买家下单后,扣减商品库存。下单减库存是最简单的减库存方式,也是控制最为精确的一种

优势:用户体验最好。下单减库存是最简单的减库存方式,也是控制最精确的一种。下单时可以直接通过数据库事务机制控制商品库存,所以一定不会出现已下单却付不了款的情况。

劣势:可能卖不出去。正常情况下,买家下单后付款概率很高,所以不会有太大问题。但有一种场景例外,就是当卖家参加某个促销活动时,竞争对手通过恶意下单的方式将该商品全部下单,导致库存清零,那么这就不能正常售卖了——要知道,恶意下单的人是不会真正付款的,这正是 “下单减库存” 的不足之处。

2)付款减库存

买家下单后,并不立即扣减库存,而是等到付款后才真正扣减库存。但因为付款时才减库存,如果并发比较高,可能出现买家下单后付不了款的情况,因为商品已经被其他人买走了

优势:一定实际售卖。“下单减库存” 可能导致恶意下单,从而影响卖家的商品销售, “付款减库存” 由于需要付出真金白银,可以有效避免。

劣势:用户体验较差。用户下单后,付不了款,假设有 100 件商品,就可能出现 200 人下单成功的情况,因为下单时不会减库存,所以也就可能出现下单成功数远远超过真正库存数的情况,这尤其会发生在大促的热门商品上。如此一来就会导致很多买家下单成功后却付不了款,购物体验自然是比较差的。

3)预扣库存

这种方式相对复杂一些,买家下单后,库存为其保留一定的时间(如 15 分钟),超过这段时间,库存自动释放,释放后其他买家可以购买

优势:缓解了以上两种方式的问题。预扣库存实际就是“下单减库存”和 “付款减库存”两种方式的结合,将两次操作进行了前后关联,下单时预扣库存,付款时释放库存。

劣势:并没有彻底解决以上问题。比如针对恶意下单的场景,虽然可以把有效付款时间设置为 10 分钟,但恶意买家完全可以在 10 分钟之后再次下单。

2、实际如何减库存

业界最为常见的是预扣库存。无论是外卖点餐还是电商购物,下单后一般都有个 “有效付款时间”,超过该时间订单自动释放,这就是典型的预扣库存方案。但如上所述,预扣库存还需要解决恶意下单的问题,保证商品卖的出去;另一方面,如何避免超卖,也是一个痛点。

1)卖的出去

恶意下单的解决方案主要还是结合安全和反作弊措施来制止。比如,为大促商品设置单人最大购买件数,一人最多只能买 N 件商品;又或者对重复下单不付款的行为进行次数限制阻断等

2)避免超卖

库存超卖的情况实际分为两种。对于普通商品,秒杀只是一种大促手段,即使库存超卖,商家也可以通过补货来解决;而对于一些商品,秒杀作为一种营销手段,完全不允许库存为负,也就是在数据一致性上,需要保证大并发请求时数据库中的库存字段值不能为负,一般有多种方案:

1、在通过事务来判断,即保证减后库存不能为负,否则就回滚;

2、直接设置数据库字段类型为无符号整数,这样一旦库存为负就会在执行 SQL 时报错;

3、是使用 CASE WHEN 判断语句——

UPDATE item SET inventory = CASE WHEN inventory >= xxx THEN inventory-xxx ELSE inventory END

业务手段保证商品卖的出去,技术手段保证商品不会超卖,库存问题从来就不是简单的技术难题,解决问题的视角是多种多样的。

3、一致性与高性能的平衡:库存系统的优化

库存是个关键数据,更是个热点数据。对系统来说,热点的实际影响就是 “高读” 和 “高写”,也是秒杀场景下最为核心的一个技术难题。高读和高写的两种处理方式大相径庭。读请求的优化空间要大一些,而写请求的瓶颈一般都在存储层,优化思路的本质还是基于 CAP 理论做平衡。

  • 读请求 可通过分层过滤大幅削减无效流量;
  • 写请求 的瓶颈通常在存储层,需在 CAP 理论 框架下权衡一致性、可用性与性能

1)高并发读:分层校验,逐级过滤

核心思想:构建“漏斗式”校验链路,在靠近用户侧尽早拦截无效请求,仅将极少量有效请求传递至数据层。

a、读校验什么

在读链路时,只进行不影响性能的检查操作,

  • 用户是否登录 / 是否具备秒杀资格
  • 商品是否处于可售状态(未下架、未过期)
  • 秒杀活动是否已开始或结束
  • 请求是否合法(防刷、防重放、答题验证等)

b、读不校验什么

不做一致性校验等容易引发瓶颈的检查操作;直到写链路时,才对库存做一致性检查,在数据层保证最终准确性,如果提前校验了,并发下有可能会导致少量原本无库存的下单请求被误认为是有库存的

  • 原因:在高并发下,若在读阶段查询库存,可能因缓存与数据库短暂不一致,导致 “超卖”误判为“有货”,或 “有货”被误判为“无货”
  • 正确做法:库存一致性校验应推迟至 写操作的事务边界内,由数据库保证原子性与最终准确性。

2)高并发写:减库存的两种优化路径

高并发写的优化方式,一种是更换 DB 选型,一种是优化 DB 性能,以下分别进行讨论。

a、方案一:更换数据库选型 —— 缓存承担写压力?

思路:将减库存操作下沉至具备持久化能力的缓存(如 Redis),绕过关系型数据库。

  • 适用场景
    • 减库存逻辑简单(如 DECR stock
    • 无需复杂事务或关联操作
    • 可接受最终一致性(配合异步落库)
  • 局限性
    • 若涉及 多表事务、条件扣减、回滚机制 等复杂逻辑,Redis 难以支撑
    • 数据可靠性依赖持久化策略,存在丢失风险
    • 简单场景可用 Redis 承载;复杂业务仍需依赖数据库事务保障一致性

b、方案二:优化数据库性能 —— 从“争抢”到“排队”

当必须使用 MySQL 等关系型数据库时,核心问题是 InnoDB 行锁竞争。解决方案分为两个层级:

(1)应用层排队(有损优化)

  • 利用分布式锁(如 Redis + Lua)对 同一商品 ID 的请求串行化
  • 优点:控制并发度,避免 DB 连接耗尽
  • 缺点:引入额外延迟,降低吞吐,且无法完全消除锁开销

(2)数据层排队(理想方案)

  • 阿里数据库团队在 InnoDB 层开发了定制化补丁,实现 单行记录的内部排队机制
    • 避免大量线程在锁等待队列中反复唤醒/阻塞
    • 绕过 MySQL ServerInnoDB 引擎间的上下文切换开销
    • 消除死锁检测等非必要计算

3)延伸挑战:不止于“扣减”

减库存只是起点,真实业务中还需解决:

  • 预占库存超时释放:如何高效回补?是否支持自动释放 + 补偿机制?
  • 支付与库存状态一致性
    • 用户下单成功但未支付 → 库存需释放
    • 支付成功但库存已超卖 → 需兜底处理(如退款、通知)
  • 分布式事务协调:跨服务(订单、库存、支付)的一致性保障

四、高可用

盯过秒杀流量监控的话,会发现它不是一条蜿蜒而起的曲线,而是一条挺拔的直线,这是因为秒杀请求高度集中于某一特定的时间点。这样一来就会造成一个特别高的零点峰值,而对资源的消耗也几乎是瞬时的。所以秒杀系统的可用性保护是不可或缺的。

1、流量削峰

对于秒杀的目标场景,最终能够抢到商品的人数是固定的,无论 100 人和 10000 人参加结果都是一样的,即有效请求额度是有限的。并发度越高,无效请求也就越多。但秒杀作为一种商业营销手段,活动开始之前是希望有更多的人来刷页面,只是真正开始后,秒杀请求不是越多越好。因此系统可以设计一些规则,人为的延缓秒杀请求,甚至可以过滤掉一些无效请求。

1)答题

早期秒杀只是简单的点击秒杀按钮,后来才增加了答题。答题目前已经使用的非常普遍了。

问题1:为什么要增加答题呢?

答案:1、本质是通过在入口层削减流量,从而让系统更好地支撑瞬时峰值。 2、通过提升购买的复杂度,达到两个目的:

1、防止作弊。早期秒杀器比较猖獗,存在恶意买家或竞争对手使用秒杀器扫货的情况,商家没有达到营销的目的,所以增加答题来进行限制,答题除了做正确性验证,还需要对提交时间做验证,比如<1s 人为操作的可能性就很小,可以进一步防止机器答题的情况

2、延缓请求。零点流量的起效时间是毫秒级的,答题可以人为拉长峰值下单的时长,由之前的 <1s 延长到 <10s。这个时间对于服务端非常重要,会大大减轻高峰期并发压力;另外,由于请求具有先后顺序,答题后置的请求到来时可能已经没有库存了,因此根本无法下单,此阶段落到数据层真正的写也就非常有限了

2)排队

最为常见的削峰方案是使用消息队列,通过把同步的直接调用转换成异步的间接推送缓冲瞬时流量,

排队本质是在业务层将一步操作转变成两步操作,从而起到缓冲的作用,但鉴于此种方式的弊端,最终还是要基于业务量级和秒杀场景做出妥协和平衡排队方式的弊端主要有两点:

1、请求积压。流量高峰如果长时间持续,达到了队列的水位上限,队列同样会被压垮,这样虽然保护了下游系统,但是和请求直接丢弃也没多大区别

2、用户体验。异步推送的实时性和有序性自然是比不上同步调用的,由此可能出现请求先发后至的情况,影响部分敏感用户的购物体验

3)过滤

过滤的核心目的是通过减少无效请求的数据IO保障有效请求的IO性能。过滤的核心结构在于分层,通过在不同层次过滤掉无效请求,达到数据读写的精准触发。常见的过滤主要有以下几层:

1、读限流:对读请求做限流保护,将超出系统承载能力的请求过滤掉

2、读缓存:对读请求做数据缓存,将重复的请求过滤掉(请求过就记录,直接返回失败)

3、写限流:对写请求做限流保护,将超出系统承载能力的请求过滤掉

4、写校验:对写请求做一致性校验,只保留最终的有效数据

4)小结

系统可以通过入口层的答题、业务层的排队、数据层的过滤达到流量削峰的目的,本质是在寻求商业诉求与架构性能之间的平衡。另外,新的削峰手段也层出不穷,以业务切入居多,比如零点大促时同步发放优惠券或发起抽奖活动,将一部分流量分散到其他系统,这样也能起到削峰的作用

2、执行Plan B

当一个系统面临持续的高峰流量时,其实是很难单靠自身调整来恢复状态的,日常运维没有人能够预估所有情况,意外总是无法避免。尤其在秒杀这一场景下,为了保证系统的高可用,必须设计一个 Plan B 方案来进行兜底。

image-20210820170711120

具体来说,系统的高可用建设涉及架构阶段、编码阶段、测试阶段、发布阶段、运行阶段,以及故障发生时,逐一进行分析:

1、架构阶段:考虑系统的可扩展性和容错性,避免出现单点问题。例如多地单元化部署,即使某个IDC甚至地市出现故障,仍不会影响系统运转

2、编码阶段:保证代码的健壮性,例如RPC调用时,设置合理的超时退出机制,防止被其他系统拖垮,同时也要对无法预料的返回错误进行默认的处理

3、测试阶段:保证CI的覆盖度以及Sonar的容错率,对基础质量进行二次校验,并定期产出整体质量的趋势报告

4、发布阶段:系统部署最容易暴露错误,因此要有前置的checklist模版、中置的上下游周知机制以及后置的回滚机制

5、运行阶段:系统多数时间处于运行态,最重要的是运行时的实时监控,及时发现问题、准确报警并能提供详细数据,以便排查问题

6、故障发生:首要目标是及时止损,防止影响面扩大,然后定位原因、解决问题,最后恢复服务

对于日常运维而言,高可用更多是针对运行阶段而言的,此阶段需要额外进行加强建设,主要有以下几种手段

1、预防:建立常态压测体系,定期对服务进行单点压测以及全链路压测,摸排水位

2、管控:做好线上运行的降级、限流和熔断保护。需要注意的是,无论是限流、降级还是熔断,对业务都是有损的,所以在进行操作前,一定要和上下游业务确认好再进行。就拿限流来说,哪些业务可以限、什么情况下限、限流时间多长、什么情况下进行恢复,都要和业务方反复确认

3、监控:建立性能基线,记录性能的变化趋势;建立报警体系,发现问题及时预警

4、恢复:遇到故障能够及时止损,并提供快速的数据订正工具,不一定要好,但一定要有

在系统建设的整个生命周期中,每个环节中都可能犯错,甚至有些环节犯的错,后面是无法弥补的或者成本极高的。所以高可用是一个系统工程,必须放到整个生命周期中进行全面考虑。同时,考虑到服务的增长性,高可用更需要长期规划并进行体系化建设。

三、读多写少

1、在秒杀的过程中,系统一般会先查一下库存是否足够,如果足够才允许下单,写数据库。如果不够,则直接返回该商品已经抢完。

2、由于大量用户抢少量商品,只有极少部分用户能够抢成功,所以绝大部分用户在秒杀时,库存其实是不足的,系统会直接返回该商品已经抢完。

image-20210820180826649

五、缓存问题

1、缓存存储数据

通常情况下,我们需要在 redis 中保存商品信息,里面包含:商品id、商品名称、规格属性、库存等信息,同时数据库中也要有相关信息,毕竟缓存并不完全可靠。

用户在点击秒杀按钮,请求秒杀接口的过程中,需要传入商品id参数,然后服务端需要校验该商品是否合法。

1)商品信息

image-20210820181101482

1)库存数量

如果有数十万的请求过来,同时通过数据库查缓存是否足够,此时数据库可能会挂掉。因为数据库的连接资源非常有限,比如:mysql,无法同时支持这么多的连接。

image-20210820180953862

2、缓存击穿

比如商品A第一次秒杀时,缓存中是没有数据的,但数据库中有。虽说上面有如果从数据库中查到数据,则放入缓存的逻辑。

然而,在高并发下,同一时刻会有大量的请求,都在秒杀同一件商品,这些请求同时去查缓存中有没有数据,然后又同时访问数据库。结果悲剧了,数据库可能扛不住压力,直接挂掉。

1)加锁

image-20210820193758628

2)缓存预热

当然,针对这种情况,最好在项目启动之前,先把缓存进行预热。即事先把所有的商品,同步到缓存中,这样商品基本都能直接从缓存中获取到,就不会出现缓存击穿的问题了。

是不是上面加锁这一步可以不需要了?

表面上看起来,确实可以不需要。但如果缓存中设置的过期时间不对,缓存提前过期了,或者缓存被不小心删除了,如果不加速同样可能出现缓存击穿。 其实这里加锁,相当于买了一份保险。

3、缓存穿透

1)布隆过滤器

如果有大量的请求传入的商品id,在缓存中和数据库中都不存在,这些请求不就每次都会穿透过缓存,而直接访问数据库了。由于前面已经加了锁,所以即使这里的并发量很大,也不会导致数据库直接挂掉。但很显然这些请求的处理性能并不好,有没有更好的解决方案?这时可以想到布隆过滤器

image-20210820193949626

2)缓存空值

问题1:虽说布隆过滤器可以解决缓存穿透问题,但是又会引出另外一个问题:布隆过滤器中的数据如何跟缓存中的数据保持一致?

答案:这就要求,如果缓存中数据有更新,则要及时同步到布隆过滤器中。如果数据同步失败了,还需要增加重试机制,而且跨数据源,能保证数据的实时一致性吗? 显然是不行的。

问题2:那布隆过滤器不能用了呀

答案:布隆过滤器绝大部分使用在缓存数据更新很少的场景中。如果缓存数据更新非常频繁,又该如何处理呢?这时,就需要把不存在的商品id也缓存起来,缓存空值。

下次,再有该商品id的请求过来,则也能从缓存中查到数据,只不过该数据比较特殊,表示商品不存在。需要特别注意的是,这种特殊缓存设置的超时时间应该尽量短一点。

image-20210820194824832

六、库存设计:从扣减到一致性保障

秒杀中的库存管理远不止“减1”那么简单。核心挑战在于:既要防止超卖,又要支持未支付订单的库存回滚。为此,引入 “预扣库存” 机制,并围绕其构建完整的生命周期管理。

方案 优点 缺点 适用场景
1. 数据库直接扣减 实现简单,强一致性 行锁竞争严重,高并发下性能骤降 低并发、非热点商品
2. 消息队列异步扣减 解耦、削峰 延迟高,无法实时反馈,易超卖 非核心业务
3. Redis 预扣 + 异步确认(推荐) 高性能、防超卖、 可回滚 实现复杂,需处理消息丢失、状态不一致 秒杀/抢购核心场景

image-20210820195000653

1、数据库扣减库存

使用数据库扣减库存,是最简单的实现方案了,假设扣减库存的sql如下:

update product set stock=stock-1 where id=product and stock > 0;

问题:频繁访问数据库,我们都知道数据库连接是非常昂贵的资源。在高并发的场景下,可能会造成系统雪崩。而且,容易出现多个请求,同时竞争行锁的情况,造成相互等待,从而出现死锁的问题。

2、Redis 预扣 + 异步确认(三阶段模型)

1)阶段1:redis 预扣减库存

方案1:错误做法:仅用 INCRBY 扣减

redisincr方法是原子性的,可以用该方法扣减库存。伪代码如下:

// 1、先判断该用户有没有秒杀过该商品,如果已经秒杀过,则直接返回-1。
boolean exist = redisClient.query(productId,userId);
if(exist) {
  return -1;
}

//2、 扣减库存,判断返回值是否小于0,如果小于0,则直接返回0,表示库存不足。
if(redisClient.incrby(productId, -1)<0) {
  return 0;
}
//3、 如果扣减库存后,返回值大于或等于0,则将本次秒杀记录保存起来。然后返回1,表示成功。
redisClient.add(productId,userId);
return 1;

风险:多个并发请求同时扣减,可能导致 Redis 库存为 -5,虽不超卖,但回滚时无法准确还原真实库存

方案2:正确:lua 脚本扣减库存

我们都知道 lua 脚本,是能够保证原子性的,它跟 redis 一起配合使用,能够完美解决上面的问题。

StringBuilder lua = new StringBuilder();
//1、先判断商品id是否存在,如果不存在则直接返回。
lua.append("if (redis.call('exists', KEYS[1]) == 1) then");

// 2、获取该商品id的库存,判断库存如果是-1,则直接返回,表示不限制库存。
lua.append("    local stock = tonumber(redis.call('get', KEYS[1]));");
lua.append("    if (stock == -1) then");
lua.append("        return 1;");
lua.append("    end;");

//3、如果库存大于0,则扣减库存。
lua.append("    if (stock > 0) then");
lua.append("        redis.call('incrby', KEYS[1], -1);");
lua.append("        return stock;");
lua.append("    end;");

//4、如果库存等于0,是直接返回,表示库存不足。
lua.append("    return 0;");
lua.append("end;");
lua.append("return -1;");

2)阶段2:异步创建预订单 & 持久化(MQ 驱动)

订单落库必须在 Redis 扣减之后、MQ 发送之前完成,作为后续补偿的凭证

类型 名称 何时创建 作用
预订单 Pre-order / Holding Order 同步阶段Redis 扣减后立即) 作为“抢购成功”的凭证,防止库存丢失,支持补偿
正式订单 Final Order 异步阶段MQ 消费时) 包含完整业务数据,进入支付、履约流程
  1. 预扣成功后,立即创建“预占订单”(状态:PRE_STOCK_HOLD),并落库
  2. 发送 MQ 消息(含订单 ID、商品 ID、数量)
    • 消息内容:order_id, item_id, quantity
    • 用于生成正式订单记录、减扣数据库中的实际库存

3) 阶段三:一致性保障(三大兜底机制)

a、本地事务表

Redis 扣减成功”后,先创建订单(或预订单,其实可以是下文的事件表)并落库再发送 MQ 消息。这样“订单存在”作为扣减的凭证。

@Service
public class OrderService {

    @Autowired
    private RedisService redisService;

    @Transactional
    public void createOrder(Long userId, Long itemId) {
        // 1. Redis 扣减库存
        boolean success = redisService.deductStock(itemId, 1);
        if (!success) {
            throw new BizException("库存不足");
        }

        // 2. 注册事务回滚回调(关键!)
        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCompletion(int status) {
                // 事务完成后回调
                if (status == TransactionSynchronization.STATUS_ROLLED_BACK) {
                    // 事务回滚 → 回补库存
                    redisService.incrStock(itemId, 1);
                    log.warn("事务回滚,回补库存: item={}, user={}", itemId, userId);
                }
            }
        });

        // 3. 创建预订单(状态:库存已预扣,待下单)
        Order order = new Order();
        order.setStatus("PRE_STOCK_HOLD"); // 关键状态
        order.setUserId(userId);
        order.setItemId(itemId);
        orderMapper.insert(order); // 同一事务

        // 4. 发送MQ消息(创建订单)
        try {
            mqProducer.send(new CreateOrderEvent(order.getId()));
        } catch (Exception e) {
            // 发送失败,保留“PRE_STOCK_HOLD”状态,等待补偿
            log.error("MQ发送失败,订单悬挂: {}", order.getId());
            // ❗不回滚事务!让补偿任务来处理
        }
    }
}

b、补偿任务(定时扫描)

启动一个定时任务(如每5分钟),扫描所有 status = 'PRE_STOCK_HOLD'超过一定时间(如2分钟) 的订单:

// 伪代码
@Scheduled(cron = "*/5 * * * * ?")
public void compensateHangingOrders() {
    List<Order> hangingOrders = orderMapper.findHangingOrders(
        status = "PRE_STOCK_HOLD", 
        createTime < now - 2分钟
    );

    for (Order order : hangingOrders) {
        // 尝试重新发送MQ
        boolean success = mqProducer.send(new CreateOrderEvent(order.getId()));
    }
}

c、缓存和数据库对-账补偿

@Scheduled(cron = "*/5 * * * * ?")
public void compensateLostStock() {
    // 扫描 Redis 扣减了但 DB 没记录的日志(或长时间未确认的)
    List<Long> leakedItems = findLeakedStockItems(); // 通过监控或对账发现
    for (Long itemId : leakedItems) {
        long redisStock = redisService.getStock(itemId);
        long dbSold = itemMapper.getSoldCount(itemId);
        long actualStock = totalStock - dbSold;

        if (redisStock < actualStock) {
            // Redis 库存偏少 → 说明有“幽灵扣减”
            long diff = actualStock - redisStock;
            redisService.incrStock(itemId, diff);
            log.info("补偿幽灵扣减: item={}, count={}", itemId, diff);
        }
    }
}

七、MQ 异步处理:解耦与可靠性设计

而这三个核心流程中,真正并发量大的是秒杀功能,下单和支付功能实际并发量很小。所以,我们在设计秒杀系统时,有必要把下单和支付功能从秒杀的主流程中拆分出来,特别是下单功能要做成mq异步处理的

1、订单同步和异步

名称 方式 做了什么 为什么这样设计
预订单创建 同步 Redis 预扣库存 - 插入一条最小化订单记录(含 user_id, item_id, status=PRE_CREATED) - 返回 order_id 给前端 必须同步!否则无法保证“谁抢到了”,也无法做幂等和补偿
订单确认 异步 补全商品快照、价格、优惠信息 - 扣减数据库持久库存 - 生成支付链接 - 启动超时取消定时器 这些操作耗时、非核心路径,异步可提升吞吐、解耦

image-20210820200417356

image-20210820200515549

1、消息丢失问题

秒杀成功了,往 mq 发送下单消息的时候,有可能会失败。原因有很多,比如:网络问题、broker挂了、mq服务端磁盘问题等。这些情况,都可能会造成消息丢失。应该在生产者发送 mq 消息之前,先把该条消息写入消息发送表,初始状态是待处理,然后再发送mq消息。消费者消费消息时,处理完业务逻辑之后,再回调生产者的一个接口,修改消息状态为已处理。

问题 风险 解决方案
消息丢失 预扣成功但订单未创建,库存“永久占用” 本地消息表 + 定时重试 1. 先写 message_outbox 表(状态=待发送) 2. 再发 MQ 3. 失败则由 Job 定时重发(带最大重试次数)
重复消费 同一订单被创建多次 幂等消费 - 消费前查 event_processed 表 - 业务操作与写表在同一事务
垃圾消息堆积 异常订单无限重试 重试次数限制 + 死信队列 超过 N 次后转入人工审核队列

image-20210820200709741

问题1:如果生产者把消息写入消息发送表之后,再发送 mq 消息到 mq 服务端的过程中失败了,造成了消息丢失

答案:使用job,增加重试机制,用job 每隔一段时间去查询消息发送表中状态为待处理的数据,然后重新发送mq消息。

image-20210824115408959

2、重复消费问题

本来消费者消费消息时,在ack应答的时候,如果网络超时,本身就可能会消费重复的消息。但由于消息发送者增加了重试机制,会导致消费者重复消息的概率增大。

问题1:如何解决重复消息问题呢?

答:加一张消息处理表(下单和写消息处理表,要放在同一个事务中,保证原子操作),消费者读到消息之后,先判断一下消息处理表,是否存在该消息,如果存在,表示是重复消费,则直接返回。如果不存在,则进行下单操作,接着将该消息写入消息处理表中,再返回。

image-20210824115619661

1)方案一:超时 + 补偿扫描

a、数据库事件表增加字段

CREATE TABLE event_journal (
    event_id VARCHAR(64) PRIMARY KEY,
    status ENUM('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED'),
    created_at DATETIME,
    updated_at DATETIME,      -- 关键记录最后更新时间
    retry_count INT DEFAULT 0
);
MQ
   ↓
[Consumer]
   ├── 检查:是否已完成? → 是 → 忽略
   ├── 检查:是否 PROCESSING 且未超时? → 是 → 忽略
   └── 否则:抢占处理权(CAS 更新 updated_at)
           ├── 成功 → 执行业务 → 标记 COMPLETED
           └── 失败 → 增加重试计数 → 重新入队 or 标记 FAILED
                 
[Scheduler] ← 每30秒扫描
   └── 找出 updated_at < now - 5min 的 PROCESSING 记录
       └→ 重新投递到 MQ

b、MQ 监听器逻辑

@RabbitListener(queues = "order.queue")
public void handleOrderEvent(OrderEvent event) {
    String eventId = event.getEventId();

    // 1. 幂等检查:已完成或已达最大重试次数
    if (eventService.isEventCompletedOrMaxRetried(eventId)) {
        return;
    }

    // 2. 尝试抢占:仅当状态=PENDING 或 PROCESSING 超时(如 >5分钟)
    if (!eventService.tryAcquireProcessing(eventId, Duration.ofMinutes(5))) {
        return; // 被其他节点处理 or 未超时
    }

    try {
        orderService.processOrder(event);
        eventService.markEventCompleted(eventId);
    } catch (Exception e) {
        int retryCount = eventService.incrementRetryCount(eventId);
        if (retryCount >= MAX_RETRY) {
            eventService.markEventFailed(eventId);
        } else {
            // 重新入队 or 等待下次轮询
            rabbitTemplate.send("order.queue", event);
        }
        throw e;
    }
}

c、启动后台补偿任务(兜底)

@Scheduled(fixedDelay = 30_000) // 每30秒扫一次
public void recoverStuckEvents() {
    List<String> stuckIds = eventService.findStuckEvents(Duration.ofMinutes(5));
    for (String id : stuckIds) {
        // 重新发送到队列
        OrderEvent event = eventService.getEvent(id);
        rabbitTemplate.send("order.queue", event);
    }
}

2)方案二:不持久化“处理中”状态(简化版)

@RabbitListener(queues = "order.queue")
public void handleOrderEvent(OrderEvent event) {
    // 1. 检查是否已完成(关键!)
    if (eventService.isCompleted(event.getEventId())) {
        return; // 幂等:直接忽略
    }

    try {
        // 2. 执行业务(必须幂等!)
        orderService.processOrder(event);

        // 3. 标记为完成(只有成功才记录)
        eventService.markCompleted(event.getEventId());

    } catch (Exception e) {
        // 4. 不标记失败,让 RabbitMQ 自动 requeue
        throw e; // 触发消息重回队列
    }
}

3、垃圾消息问题

这套方案表面上看起来没有问题,但如果出现了消息消费失败的情况。比如:由于某些原因,消息消费者下单一直失败,一直不能回调状态变更接口,这样job 会不停地重试发消息。最后,会产生大量的垃圾消息。

问题1:如何解决垃圾消息问题

答案:每次在 job 重试时,需要先判断一下消息发送表中该消息的发送次数是否达到最大限制,如果达到了,则直接返回。如果没有达到,则将次数加1,然后发送消息。这样如果出现异常,只会产生少量的垃圾消息,不会影响到正常的业务。

image-20210824115837948

4、延迟消费问题 (预扣库存)

通常情况下,如果用户秒杀成功了,下单之后,在15 分钟之内还未完成支付的话,该订单会被自动取消,回退库存。

步揍1、下单时消息生产者会先生成订单,此时状态为待支付,然后会向延迟队列中发一条消息。达到了延迟时间,消息消费者读取消息之后,会查询该订单的状态是否为待支付。如果是待支付状态,则会更新订单状态为取消状态。如果不是待支付状态,说明该订单已经支付过了,则直接返回。

步揍2:用户完成支付之后,会修改订单状态为已支付。

image-20210824120044537

八、限流

但有些高手,并不会像我们一样老老实实通过秒杀页面点击秒杀按钮,抢购商品。他们可能在自己的服务器上,模拟正常用户登录系统,跳过秒杀页面,直接调用秒杀接口,这种差距实在太明显了,如果不做任何限制,绝大部分商品可能是被机器抢到,而非正常的用户,有点不太公平。

目前有两种常用的限流方式:

1、基于nginx限流

2、基于redis限流

###

如果是我们手动操作,一般情况下,一秒钟只能点击一次秒杀按钮。

image-20210824120957019

但是如果是服务器,一秒钟可以请求成千上万接口。

image-20210824121016932

1、对同一用户限流

为了防止某个用户,请求接口次数过于频繁,可以只针对该用户做限制。

image-20210824121126142

2、对同一ip限流

有时候只对某个用户限流是不够的,有些高手可以模拟多个用户请求,这种 nginx 就没法识别了。这时需要加同一ip限流功能。

限制同一个ip,比如每分钟只能请求5次接口。 但这种限流方式可能会有误杀的情况,比如同一个公司或网吧的出口ip是相同的,如果里面有多个正常用户同时发起请求,有些用户可能会被限制住。

image-20210824121250130

3、对接口限流

别以为限制了用户和ip就万事大吉,有些高手甚至可以使用代理,每次请求都换一个ip。这时可以限制请求的接口总次数。

在高并发场景下,这种限制对于系统的稳定性是非常有必要的。但可能由于有些非法请求次数太多,达到了该接口的请求上限,而影响其他的正常用户访问该接口。看起来有点得不偿失

image-20210824121322729

4、加验证码

相对于上面三种方式,加验证码的方式可能更精准一些,同样能限制用户的访问频次,但好处是不会存在误杀的情况。

image-20210824121424833

九、秒杀下单玉支付全流程

只要 Redis 扣减 + 预订单落库成功,用户的权益就已经被系统承认。后续的异步处理只是“兑现这个权益”的过程。即使慢一点,只要最终能完成或明确失败,就是可接受的。

环节 推荐做法
前端响应 立即告知“抢购成功”,但说明“正在准备支付”
状态同步 轮询 or WebSocket 监听订单状态变更
超时处理 10 秒内未就绪 → 引导至订单列表页
后端保障 预订单落库 + 补偿任务 + 延迟取消 + 死信队列
用户体验 不承诺“立即可付”,而是“资格已锁定,请等待”

1、阶段 0:前置准备

  • 商品已配置秒杀活动,总库存同步至 Redis(如 stock:1001 = 1000
  • 用户已登录,前端持有有效 Token

2、阶段 1:用户发起秒杀请求(前端 → 后端)

1)前端

  • 用户点击“立即秒杀”按钮
  • 按钮置灰 + 显示“正在抢购…”(防重复点击)
  • 生成唯一请求 ID(可选),调用 /api/seckill/buy 接口

2)后端(同步主流程)

1、校验用户资格(是否登录、是否黑名单等)

2、Redis 原子预扣库存(通过 Lua 脚本):

  • 若库存 ≤ 0 → 返回“库存不足”

  • 若用户已参与 → 返回“不可重复抢购”

  • 否则:库存 -1,并记录用户 ID(防重)

3、立即创建“预订单”并写入数据库,状态为 PRE_CREATED(或 STOCK_HELD),

4、尝试发送 MQ 消息(内容:order_id, item_id, quantity

  • 若发送成功 → 继续
  • 若发送失败 → 不回滚事务,仅记录日志(后续由补偿任务处理)

5、返回响应给前端

  • { "code": 200, "message": "抢购成功", "data": { "orderId": "ORD123" } }
    
  • 此刻:用户已获得“支付资格”,库存已被预占,订单 ID 已确定

3、阶段 2:前端响应与等待支付就绪

时间 用户看到的内容 可执行操作
T+0s “抢购成功!正在准备支付…”(跳转支付准备页) 等待、刷新
T+5s 订单详情页:商品图+名称+数量,状态=“处理中”,无支付按钮 刷新、返回订单列表
T+8s 状态变为“待支付”,显示支付宝二维码 立即支付
T+60s(异常) 状态仍为“处理中”,提示“系统繁忙” 去“我的订单”查看,或联系客服

1)前端收到成功响应

  • 跳转至 “支付准备页”(非最终支付页)
  • 页面显示:“正在为您准备支付,请稍候…” + 加载动画

2)前端启动状态轮询

  • 每 1~2 秒调用 /api/order/ORD123/status 查询订单状态
  • 可能返回的状态:
    • PRE_CREATED → 继续等待
    • UNPAID → 支付已就绪,显示支付二维码/跳转支付
    • FAILED → 订单创建失败,提示用户

3)10 秒内未变为 UNPAID

  • 前端提示:“系统繁忙,订单正在处理中”
  • 引导用户前往 “我的订单”页面 查看最新状态
  • 订单状态:显示为:“处理中” / “创建中” / “待确认”
  • 订单内容
    • 商品名称、图片、数量:可显示(这些基础信息在预订单阶段可从商品 ID 关联查询)
    • 商品价格、优惠信息可能为空或显示“加载中”
    • 支付按钮 / 二维码不可点击或隐藏
  • 操作提示
    • 页面会提示:“您的订单已成功抢购,系统正在为您准备支付,请稍后刷新查看。
    • 可能提供 “手动刷新”按钮,触发前端重新查询状态

4、阶段 3:后端异步处理订单(MQ 消费)

  1. MQ 消费者收到消息order_id=ORD123
  2. 查询数据库中的预订单(确保存在)
  3. 执行以下操作(通常在一个本地事务中):
    • 补全订单详情(商品快照、价格、优惠信息等)
    • 正式扣减数据库持久库存(或标记已售数量)
    • 生成支付参数(如支付宝 trade_no)
    • 更新订单状态为 UNPAID(待支付)
  4. 发送延迟消息(如 15 分钟后):用于检查该订单是否仍未支付,若未支付则自动取消

5、阶段 4:用户完成支付 or 超时取消

1)情况 A:用户成功支付

  1. 用户在支付页完成付款(跳转支付宝/微信)
  2. 支付平台异步回调商户系统(/api/pay/notify
  3. 后端验证签名、金额、订单状态
  4. 更新订单为 PAID,触发后续履约(如发券、通知发货)

2)情况 B:用户未支付(超时)

  1. 15 分钟后,延迟消息被消费
  2. 系统检查订单状态:
    • 若仍为 UNPAID → 自动取消订单
    • 释放预占库存(Redis + DB 回补)
  3. 用户在“我的订单”中看到状态为“已取消”

6、阶段 5:异常兜底机制(保障一致性)

异常场景 系统如何兜底
MQ 发送失败 定时任务扫描 status=PRE_CREATED 且超时(如 >2 分钟)的订单,重新发送 MQ 或直接本地重试
MQ 消费失败 消息重试(最多 N 次)→ 失败后进入死信队列,人工介入
用户关闭页面 延迟消息确保 15 分钟后自动取消 + 释放库存
Redis 与 DB 库存不一致 每日对账任务比对总库存,自动修复偏差

7、最终状态闭环

无论成功或失败,订单最终会进入以下状态之一:

  • PAID:支付成功,交易完成
  • CANCELLED:超时未支付,已取消
  • FAILED:系统异常导致无法继续,已释放库存

十、下单-幂等

目标:确保用户在任何网络或操作异常下(如重复点击、自动重试、多端并发),同一笔下单意图仅生成一条有效订单,同时支持合法的多次购买行为

1、核心原则

原则 说明
防重不防买 阻止“同一个请求被多次执行”,但允许用户多次发起“新的购买意图”
纵深防御 前端 + 网关 + 服务层 + 数据库 多层防护
快速失败 幂等校验前置,避免进入复杂业务逻辑
业务语义优先 幂等机制必须适配业务规则(如是否限购、是否允许多单)

2、分层控制策略

1)前端控制(用户体验层)

  • 按钮禁用:用户点击“立即下单”后,按钮立即置灰 + 显示“处理中…”,3 秒内禁止再次点击。
  • Loading 提示: 显示加载动画,避免用户因无反馈而反复点击。
  • 生成唯一请求 IDtoken
    • 每次新下单动作(非重试)生成新 token
    • 格式建议:req_{userId}_{itemId}_{timestamp}_{random4}
    • 示例:req_1001_2001_1712345678901_a3f9
    • 作为 X-Request-ID 请求头传递

2)后端控制(核心保障层)

幂等 Token 防重试,分布式锁防并发,状态机防业务冲突,唯一索引保底

a、幂等 Token:拦截重复提交

作用: 防止因网络超时、客户端自动重试或用户快速连点导致的同一请求被多次处理。

实现要点

  • 前端在每次新下单动作时生成全局唯一的 X-Request-ID(如 req_1001_2001_1712345678901_a3f9),并随请求传递。
  • 后端在执行业务逻辑前,先查询 Redis 中是否存在键 idempotent:{requestId}
    • 若存在,直接返回缓存的原始响应(无论成功或失败);
    • 若不存在,则正常处理,并将结果写入 Redis,设置 TTL(建议 30~60 分钟,覆盖支付超时窗口)。
  • 失败请求也可缓存较短时间(如 5 分钟),防止恶意重试打爆系统。

适用场景 :适用于所有下单类接口,是幂等防护的基础机制。

image-20250903163627522

b、分布式锁:防止并发冲突

作用:避免同一用户通过多设备、多 Tab 或脚本并发发起对同一商品的下单请求,导致库存超卖或状态不一致。

适用场景: 高并发、库存敏感的业务,如秒杀、限量抢购、优惠券领取等。

实现要点

  • 锁的粒度为 (userId, itemId),键格式示例:lock:order:user:1001:item:2001
  • 使用 Redis 的原子命令加锁(如 SET key value EX 10 NX),自动过期时间设为 5~10 秒。
  • 锁仅在“Redis 预扣库存 + 创建预订单”这一关键事务区间内持有,流程结束后立即释放。
  • 注意:该锁是短时、临时性的,不影响用户后续发起新的购买请求。

c、订单状态机:实现业务语义防重

作用:根据订单当前状态和业务规则,判断是否允许创建新订单,从而支持“限购”等业务需求,同时避免误拦合法购买。

适用场景:秒杀、活动商品、会员专享等有明确购买限制的场景。

实现要点

  • 下单前查数据库,检查是否存在用户对目标商品的未完成有效订单,状态包括 PRE_CREATED(预占成功)、UNPAID(待支付)等。
  • 根据商品类型差异化处理:
    • 普通商品:可选择复用原订单(引导支付)或允许新建(支持多次购买);
    • 限购商品(如“每人限1件”):拒绝新建,返回提示“您已参与本次活动,请前往支付”。
  • 状态判断需与产品规则对齐,避免技术逻辑违背业务意图。

d、数据库唯一索引:终极数据兜底

作用:在极端情况下(如缓存失效、锁失效、状态查询漏判),通过数据库强约束确保不会产生重复有效订单。

实现要点

  • 普通商品不应设置 (user_id, item_id) 级别的唯一索引,以免阻碍合法多次购买。
  • 限购商品:应设计有意义的唯一约束,例如:
    • 每日限购:(user_id, item_id, DATE(create_time))
    • 活动周期限购:(user_id, item_id, event_id)
  • 避免无效设计:如 (user_id, item_id, create_time) 因时间戳精度高,几乎不会冲突,无法起到防重作用。

适用场景:对数据一致性要求极高的核心交易链路,作为最后一道防线。

3、token 处理

1)前端传递

  • 在客户端发起下单请求前,生成一个全局唯一的业务 ID
  • 例如一个由用户 ID、商品 ID 和时间戳等信息组成或随机生成的 Token

2)后端处理:

服务器控制:服务器在处理请求时,先检查该唯一 ID 是否已存在于数据库或缓存中。

token 存储

  • 幂等表:建立一个专门的幂等表或者 redis,记录所有成功处理的唯一 ID
  • 过期机制:为幂等表中的记录设置合理的过期时间,避免数据无限制增长。

token 校验

  • 若存在 → 直接返回缓存的响应(如 {code:200, orderId: ORD123}

  • 若不存在 → 执行下单逻辑,成功后写入

    • SET idempotent:req_... "{orderId: 'ORD123', status: 'success'}" EX 3600
      

注意事项

  • 过期时间建议 1~2 小时:覆盖支付超时窗口
  • 不要用纯随机 UUID:建议包含 userId+itemId,便于排查
  • 失败请求也建议缓存(如 5 分钟):避免频繁重试打爆系统

九、秒杀场景

1、高并发抢座系统设计

1)业务背景与挑战

NBA 季后赛现场观赛票务抢购为例:

  • 体育馆总容量:10,000 人(即 10,000 张有效座位);
  • 座位类型多样:单人座、双人连座、3~4 人家庭区等,不同区域价格与优惠策略不同;
  • 用户需求复杂:例如 10 人团体需抢购连续座位,对座位组合的原子性要求极高;
  • 并发压力巨大:开售首秒预计 10 万真实用户 + 90 万机器人请求,峰值 QPS 可达 100 万+

  • 核心难点: 不仅要防超卖,还要保证**多座位组合的原子分配
  • 若只抢到部分座位,整个订单必须失败,否则将导致座位碎片化,影响后续销售。

2)方案一:全异步队列处理(简单但体验差)

实现思路

  • 所有下单请求立即入 MQ(如 Kafka/RocketMQ),前端返回“处理中,请等待”;
  • 后端消费者串行处理每个订单:逐个检查座位可用性 → 分配 → 写库;
  • 若座位冲突(如已被他人占用),直接标记订单失败。

问题分析

问题 说明
响应延迟极高 假设单机每秒处理 1,000 单,10,000 张票需 10 秒;但首秒涌入 100 万请求,队列积压严重
用户体验极差 用户需长时间轮询或等待通知,期间无法得知是否抢到
资源浪费 大量无效请求(如机器人、重复提交)占用队列与处理资源
座位碎片风险 串行处理虽避免并发冲突,但无法解决“组合座位”分配的原子性问题(除非在消费端加复杂锁)

适用场景:低并发、非实时、允许长时间等待的普通商品秒杀。

不适用于:高并发、强实时、座位/库存需组合分配的场景。

3)方案二:同步并发处理(高性能、高体验)

核心思想:在 Redis 层面实现座位分配的原子性与高并发支持,确保:

  • 每个座位只能被一个订单成功锁定;
  • 同一订单中的多个座位要么全部成功,要么全部失败(事务性);
  • 用户在 100ms 内 得知结果,体验接近“实时”。

a、座位预建模:每个物理座位独立

seat:arena:B201
seat:arena:B202
seat:arena:B203
...

b、下单时,按需申请多个单座

  • 用户要 2 人连座 → 传入 [B201, B202]
  • 用户要 4 人连座 → 传入 [B201, B202, B203, B204]

c、原子占座逻辑(基于 INCR

下单时,对订单所需的每个座位 Key 执行 INCR 操作:

  • 若返回值为 1,表示该座位首次被占用,当前请求成功锁定;
  • 若返回值 >1,说明已被其他请求占用,当前请求失败。
  • 部分成功时:由于一个订单通常包含多个座位,必须保证全成功或全失败。 若在处理第 N 个座位时发现冲突(INCR 返回 >1),则立即对前面已成功 INCR 的座位 Key 执行 SET key 0,释放已占资源,确保数据一致性。

d、后端处理:

  • 释放用户锁: 无论订单最终成功或失败,立即释放该用户的分布式锁,允许其发起下一次合法请求(如支付失败后重试)。
  • 异步入库,解耦 MySQL:订单结果确定后,将订单信息发送至消息队列,由消费者异步写入 MySQL,避免数据库成为高并发瓶颈。

ContactAuthor