java高并发高可用场景解决方案

文章目录

相信大部人初级开发跟我一样,平时接触不到什么高并发场景,虽然也能花心思做,但总是受困于杀鸡用牛刀或工资配不上努力或没时间等。故本人整理一份分场景的高并发解决方案,也会包括高可用场景。

高并发场景

高并发秒杀

下单

第一阶段:抗住流量(漏斗模型)

秒杀的本质是"大流量漏斗"。

  • 用户请求:1000 万
  • 网关层拦截:剩下 100 万
  • Redis 扣减:剩下 1 万
  • MySQL 下单:剩下 1000(真实的库存数)

如果没有 Redis 在中间挡着,1000 万请求直接打到 MySQL,数据库瞬间爆炸。

第二阶段:核心扣减(Redis + Lua)

这是你问的重点。为什么一定要用 Lua?

1. 为什么不用 Java 代码直接调 Redis?

如果你在 Java 里写:

java 复制代码
// ❌ 错误示范:会有并发安全问题
int stock = redis.get("stock:1001");
if (stock > 0) {
    redis.set("stock:1001", stock - 1);
    return true;
}

问题一(原子性):get 和 set 是两步操作。高并发下,线程 A 读了 1,线程 B 也读了 1,结果两个人都卖出去了,超卖!

问题二(性能):一来一回(RTT)网络请求两次,慢。

2. Lua 脚本登场

Redis 执行 Lua 脚本是原子性的(Atomic)。Redis 把它当做一个命令执行,中间不会被其他命令插入。

标准的秒杀 Lua 脚本:

java 复制代码
-- KEYS[1]: 商品库存 Key (stock:1001)
-- KEYS[2]: 用户去重 Key (users:1001) - 这是一个 Set
-- ARGV[1]: 用户 ID
-- ARGV[2]: 购买数量 (通常是 1)

local stockKey = KEYS[1]
local userSetKey = KEYS[2]
local userId = ARGV[1]
local num = tonumber(ARGV[2])

-- 1. 校验:用户是否重复购买
-- sismember 返回 1 表示存在
if redis.call('sismember', userSetKey, userId) == 1 then
    return -1 -- 重复购买
end

-- 2. 校验:库存是否足够
-- get 获取当前库存
local currentStock = tonumber(redis.call('get', stockKey))
if currentStock == nil or currentStock < num then
    return -2 -- 库存不足
end

-- 3. 执行:扣库存 & 记录用户
redis.call('decrby', stockKey, num)
redis.call('sadd', userSetKey, userId)

return 1 -- 抢购成功
3. Java 怎么调?

在 Spring Boot 中使用 Redisson 或 StringRedisTemplate 调用这个脚本。

第三阶段:异步下单(MQ 削峰)

Redis 扣完库存,返回 1(成功)给 Java 后端。

这时候,千万不要去直接插 MySQL!

原因:MySQL 的写入性能(TPS)只有几千,Redis 却是几万甚至十万。Redis 放行了 1 万个请求,MySQL 还是接不住。

做法 :发消息队列(RabbitMQ / RocketMQ / Kafka)。

流程:

  • Redis (Lua):扣减成功。
  • Java:发送一条消息到 MQ:{userId: 888, goodsId: 1001, time: ...}。
  • Java:立马返回给前端:"排队中,请稍后查询结果"。
  • MQ 消费者:
    • 慢慢地从 MQ 里拉消息。
    • 一条一条地写入 MySQL (INSERT INTO order ...)。
    • 这个过程叫削峰填谷。
进阶:大厂还会做什么?(防坑指南)
  1. 缓存预热

    秒杀开始前 5 分钟,通过定时任务把 MySQL 里的库存同步到 Redis 里。否则秒杀一开始,Redis 里没数据,请求穿透到 DB,DB 卒。

  2. 内存标记(本地缓存)

如果 Redis 也扛不住(比如 1000w QPS),大厂会在 Java 本地内存(JVM)里再设一道防线。

  • 用 AtomicBoolean hasStock = true。
  • 如果 Redis 返回库存空了,就把 JVM 里的这个标记设为 false。
  • 后续请求连 Redis 都不用查,直接在 Java 层返回"没货了"。
  1. 库存回滚
    如果用户抢到了(Redis 扣了),但发 MQ 失败了,或者 MySQL 写入失败了,或者用户 15 分钟没支付。

必须有一个补偿机制(延时队列),把 Redis 里的库存加回去 (INCR),并把用户从去重 Set 里踢出去。

一个标准的轻量级秒杀架构:
Nginx(限流) -> Java(内存标记) -> Redis(Lua原子扣减) -> MQ(异步) -> MySQL(最终落库)

支付是怎么做的?(和第三方交互)

我们刚才讲了 秒杀下单(Redis扣减 -> MQ异步入库),现在订单已经在 MySQL 数据库里了,状态是 "待支付" (UNPAID)。

支付环节的核心是"回调(Callback)"。你的后端不需要去"拉"支付状态,而是等支付宝/微信来"推"给你。

流程图解:

  1. 用户动作:在前端页面看到"抢购成功,请支付",点击【立即支付】按钮。
  2. 请求支付参数 :前端请求你的后端接口 getPayParams(orderId)。
    • 你的后端去调用支付宝 SDK,生成一串加密的支付串(或者 URL)。
    • 关键点:你在调用 SDK 时,必须填一个参数叫 notify_url(回调地址),告诉支付宝:"钱收到了以后,发个请求通知我的 https://api.mydomain.com/pay/callback 这个接口"。
  3. 唤起收银台:前端拿到串,唤起支付宝 App。用户输入密码,付钱。
  4. 支付宝回调(核心)
    • 用户付钱成功瞬间,支付宝服务器会异步发送一个 POST 请求给你的 notify_url。
    • 你的接口收到请求:
      • 验签:确认是支付宝发的,不是黑客伪造的。
      • 查状态:查数据库,如果订单已经是 PAID,直接返回 success。
      • 改状态:把数据库订单状态从 UNPAID 改为 PAID。
      • 记流水:记录支付流水号。
    • 返回结果:告诉支付宝 "我收到了"(返回 success 字符串),否则支付宝会过一会再重发通知(重试机制)。

超时订单取消是怎么实现的?(关单)

如果用户抢到了(库存扣了),但是占着茅坑不拉屎,15 分钟还不付钱,我们必须把订单关掉,并且把库存还回去,让别人能买。

方案 1:定时任务轮询(Quartz / XXL-JOB)------ 最土但最简单
  • 做法 :写一个定时任务,每分钟跑一次。
    • SQL:SELECT * FROM order WHERE status = 'UNPAID' AND create_time < (NOW() - 15min)
    • 遍历结果,执行关单逻辑。
  • 缺点
    • 时效性差:可能误差一分钟。
    • 性能差:如果订单表有几千万数据,每分钟全表扫一次,数据库会挂。
    • 适用:小项目,数据量少。
方案 2:Redis 过期监听 ------ 看起来很美,实际是个坑
  • 做法:下单时,往 Redis 存一个 key order:timeout:1001,设置 TTL 为 15 分钟。开启 Redis 的 notify-keyspace-events,监听 key 过期事件。
  • 缺点:不可靠! Redis 的过期事件不仅可能会延迟,甚至可能会丢失(Redis 不保证消息必达)。生产环境严禁使用此方案做核心业务。
方案 3:延迟队列(Delay Queue)------ 大厂标准

这是最稳健的方案。利用消息队列(MQ)的特性。

RabbitMQ 实现:
  1. 死信队列(DLX)
    • 建一个普通队列 A,设置消息过期时间(TTL)为 15 分钟。这个队列不设消费者。
    • 建一个死信队列 B,绑定到队列 A 上。
  2. 发送:下单成功后,发一条消息 {orderId: 1001} 到队列 A。
  3. 流转:消息在队列 A 里躺了 15 分钟,没人领,过期了。RabbitMQ 会自动把它踢到死信队列 B。
  4. 消费:你的消费者监听队列 B。收到的消息一定是 15 分钟前的。
  5. 逻辑
    • 收到 {orderId: 1001}。
    • 查数据库:这单现在是 PAID 吗?
    • 如果是 PAID:说明用户付钱了,忽略这条消息。
    • 如果是 UNPAID:说明超时了,执行关单。
RocketMQ 实现:

RocketMQ 自带延迟级别 messageDelayLevel

发送消息时设为 level=18 (对应 30分钟,具体看配置),30 分钟后消费者才能收到。

关单时的"库存回滚"

这一点非常重要,也是秒杀闭环的最后一步。

当延迟队列的消费者发现订单超时,执行关单时,必须做两件事(事务一致性):

  1. MySQL:UPDATE order SET status = 'CANCELED' ...
  2. Redis :把库存加回去!
    • INCR stock:1001 (库存 +1)
    • SREM users:1001 user_888 (把用户从黑名单移除,让他能再抢)

注意: 如果你用了本地内存(AtomicBoolean)做前置拦截,记得也要把本地内存的标记改回 true(表示有货了)。

总结

  • 支付:靠第三方的回调通知来改状态。
  • 超时:靠 MQ 延迟队列 来触发检查。
  • 核心动作:超时未付 -> 改状态 -> Redis 库存回滚。
相关推荐
code_li5 小时前
聊聊支付宝架构
java·开发语言·架构
少控科技5 小时前
QT高阶日记01
开发语言·qt
CC.GG5 小时前
【Linux】进程概念(五)(虚拟地址空间----建立宏观认知)
java·linux·运维
无限进步_5 小时前
【C++】大数相加算法详解:从字符串加法到内存布局的思考
开发语言·c++·windows·git·算法·github·visual studio
“抚琴”的人5 小时前
C#上位机工厂模式
开发语言·c#
巨大八爪鱼5 小时前
C语言纯软件计算任意多项式CRC7、CRC8、CRC16和CRC32的代码
c语言·开发语言·stm32·crc
C+-C资深大佬6 小时前
C++ 数据类型转换是如何实现的?
开发语言·c++·算法
木千6 小时前
Qt全屏显示时自定义任务栏
开发语言·qt
以太浮标6 小时前
华为eNSP模拟器综合实验之- AC+AP无线网络调优与高密场景
java·服务器·华为
Mr__Miss6 小时前
JAVA面试-框架篇
java·spring·面试