ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

品牌直播专场高并发实战:缓存预热与秒杀库存扣减方案

品牌直播专场高并发实战:缓存预热与秒杀库存扣减方案 遇到“品牌直播专场”这种活动技术团队最紧张的不是直播间装修得够不够好看而是开播后流量瞬间涌进来时后端能不能扛住。很多开发同学在被临时拉去支援大促直播时第一反应是“不就是一个页面加几个接口嘛”结果开播后看到监控里陡增的并发曲线才发现问题比想象中大得多。本文就从一场品牌官方直播间的技术保障出发梳理从架构设计、核心代码、压测验证到线上排错的完整闭环方案。不管你是后端开发、测试工程师还是刚接触高并发项目的初学者这套方案都能直接落地到类似场景中。1. 品牌直播间到底在考验什么技术先看业务背景。品牌方在 8 月 8 日晚 8 点到 9 点安排一场官方直播通过平台资源位、代言人影响力、社群预告三种方式集中引流。用户的动作非常一致第一时间进入直播间抢限量商品参与评论互动。这种场景的技术特征非常明显流量在开播瞬间飙升呈“洪峰式”到达。热门商品在几秒内被抢完库存扣减压力集中。用户评论、点赞、礼物消息高频写入。直播页面上商品链接、价格、库存状态需要实时可见。安全团队担心被脚本刷接口、恶意锁库存。很多人容易把“直播带货系统”理解为“普通商城”但两者的流量模型差异很大。普通商城流量是全天摊开的直播间流量是在固定时间段内集中释放的所以对系统的瞬时吞吐能力和降级兜底能力要求更高。从技术体系上看一场品牌直播专场通常由下面几个模块组成模块作用关键技术点直播页面承载播放器、商品列表、评论弹幕CDN、静态化、WebSocket商品服务展示商品信息、价格、库存Redis 缓存、缓存预热秒杀/抢购服务处理限量商品购买请求限流、队列、库存扣减订单服务异步创建订单、处理超时消息队列、分布式事务互动服务评论、点赞、弹幕消息分发消息推送、频道订阅监控告警实时监控 QPS、错误率、依赖状态Prometheus、日志链路下文会围绕这些模块从一个可运行的简化项目出发讲清楚每一层该如何设计。2. 环境准备与项目结构本文的实战代码以 Java 技术栈为主你也可以换成 Go、Python 等语言思路是一样的。2.1 推荐运行环境为了便于复现建议按以下环境准备版本可以结合你本机情况调整软件说明JDKJava 8 及以上Spring Boot 2.x 或 3.x 均可Maven3.6用于构建项目Redis5.0 及以上用于缓存、库存扣减、限流RabbitMQ3.8用于异步解耦也可以用 RocketMQ 替代MySQL5.7 或 8.0用于订单和库存流水持久化Nginx1.18用于网关限流和静态资源分发如果你的机器内存有限可以用 Docker 一次性启动 Redis 和 RabbitMQdocker run -d --name redis -p 6379:6379 redis:6.2-alpine docker run -d --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:3.11-management2.2 项目结构为了便于演示我把工程拆成一个 Spring Boot 项目包结构如下live-promo/ ├── pom.xml └── src/main/java/com/example/livepromo/ ├── LivePromoApplication.java ├── controller/ │ ├── LiveController.java │ ├── ItemController.java │ └── OrderController.java ├── service/ │ ├── ItemService.java │ ├── FlashSaleService.java │ └── OrderService.java ├── repository/ │ └── OrderRepository.java ├── config/ │ ├── RedisConfig.java │ └── RabbitMqConfig.java └── common/ ├── Result.java └── GlobalExceptionHandler.java先创建 Maven 工程pom.xml 引入关键依赖dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-amqp/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency /dependencies这里我不把版本号写死是因为 Spring Boot 2.x 和 3.x 的依赖坐标差异较大使用你当前项目实际的 Boot 版本即可。核心代码不依赖版本特性。3. 核心模块设计与代码实现3.1 商品缓存预热开播前最大的风险是“缓存击穿”。如果用户集中在开播后访问同一个热门商品Key而该 Key 在 Redis 中不存在或者已经过期大量请求会直接穿透到数据库数据库很可能被打挂。所以商品详情在开播前必须做缓存预热。所谓预热就是提前把热点商品数据写入 Redis。// 文件路径src/main/java/com/example/livepromo/service/ItemService.java Service public class ItemService { Autowired private StringRedisTemplate redisTemplate; private static final String ITEM_CACHE_KEY live:item:; /** * 预热商品缓存 */ public void preheatItem(Item item) { String json JSON.toJSONString(item); redisTemplate.opsForValue().set(ITEM_CACHE_KEY item.getId(), json, 30, TimeUnit.MINUTES); } /** * 查询商品详情优先走缓存 */ public Item getItem(Long itemId) { String key ITEM_CACHE_KEY itemId; String json redisTemplate.opsForValue().get(key); if (json ! null) { return JSON.parseObject(json, Item.class); } // 缓存不存在时回源数据库并短暂加锁防止击穿 return loadFromDbWithLock(itemId); } }这里有一个细节预热时设置的过期时间不要过长也不宜过短。过短会导致活动进行中缓存过期过长又会影响价格、库存等数据的及时更新。一般建议设置活动时长加一定余量例如 30 分钟到 1 小时并在后台提供手动刷新接口。3.2 基于 Lua 的库存扣减秒杀系统的核心是“库存扣减不能超卖”。在多线程环境下如果先查库存、再扣库存很容易出现两个人同时读到剩余 1 件然后都扣减成功变成 -1。一般的做法是借助 Redis 的单线程特性通过 Lua 脚本完成“检查库存、扣减库存”的原子操作。-- 文件路径scripts/stock_deduce.lua -- KEYS[1]库存 Key -- ARGV[1]当前用户 ID -- ARGV[2]商品 ID local stock tonumber(redis.call(get, KEYS[1]) or 0) if stock 0 then return -1 end redis.call(decrby, KEYS[1], 1) return stock - 1Spring Boot 中执行这个 Lua 脚本// 文件路径src/main/java/com/example/livepromo/service/FlashSaleService.java Service public class FlashSaleService { Autowired private StringRedisTemplate redisTemplate; private DefaultRedisScriptLong stockScript; PostConstruct public void init() { stockScript new DefaultRedisScript(); stockScript.setLocation(new ClassPathResource(scripts/stock_deduce.lua)); stockScript.setResultType(Long.class); } /** * 尝试扣减库存返回剩余库存 */ public long tryDeduceStock(String stockKey, Long userId, Long itemId) { return redisTemplate.execute(stockScript, Lists.newArrayList(stockKey), userId.toString(), itemId.toString()); } }注意Lua 脚本里的return -1表示库存不足。业务层拿到返回值后要判断返回值大于等于 0 才允许继续创建订单小于 0 则直接返回“已抢光”。Lua 方案的好处是避免使用分布式锁带来的锁竞争和复杂性问题。Redis 自身单线程执行脚本多个请求的扣减操作天然串行化不会超卖。3.3 下单请求异步化扣减库存成功后如果直接在请求线程里写数据库很可能因为数据库连接池满了导致接口阻塞。直播场景的请求量非常大但真正需要立刻同步返回给用户的只是“是否抢到资格”。因此推荐流程是用户请求进入接口。校验用户是否已经抢过。执行 Lua 扣减库存。扣减成功后发送一条消息到消息队列。立即返回“抢购成功订单处理中”。消费者从队列拉取消息落库创建订单。// 文件路径src/main/java/com/example/livepromo/controller/OrderController.java RestController RequestMapping(/api/order) public class OrderController { Autowired private FlashSaleService flashSaleService; Autowired private RabbitTemplate rabbitTemplate; PostMapping(/flash) public Result flashSale(RequestParam Long userId, RequestParam Long itemId) { // 1. 检查用户是否重复购买 if (flashSaleService.hasPurchased(userId, itemId)) { return Result.error(请勿重复下单); } // 2. 扣减库存 String stockKey live:stock: itemId; long remain flashSaleService.tryDeduceStock(stockKey, userId, itemId); if (remain 0) { return Result.error(商品已抢光); } // 3. 发送创建订单的消息 FlashSaleMessage message new FlashSaleMessage(userId, itemId); rabbitTemplate.convertAndSend(flash.order.exchange, flash.order, message); // 4. 立即返回 return Result.success(抢购成功订单处理中); } }消费者端收到消息后再做数据库落库。这里比较重要的是消息发送要保证可靠生产环境建议开启 RabbitMQ 的 publisher confirm 机制。如果消息发送失败当前请求要认为下单失败并补偿恢复库存。3.4 防止接口被脚本刷直播间流量大也会引来恶意脚本。常见的刷法有循环调用抢购接口、用代理 IP 高频请求、通过预约接口提前锁库存。限流方案我推荐两种组合使用。第一种是 Nginx 层按 IP 限流# 文件路径nginx/conf.d/live-promo.conf limit_req_zone $binary_remote_addr zoneflash_limit:10m rate10r/s; server { listen 80; server_name live.example.com; location /api/order/flash { limit_req zoneflash_limit burst20 nodelay; proxy_pass http://backend_server; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }第二种是 Redis 计数器限流针对用户维度// 文件路径src/main/java/com/example/livepromo/common/RateLimiter.java Component public class RateLimiter { Autowired private StringRedisTemplate redisTemplate; public boolean isAllowed(String key, int maxCount, int windowSeconds) { Long count redisTemplate.opsForValue().increment(key); if (count ! null count 1) { redisTemplate.expire(key, windowSeconds, TimeUnit.SECONDS); } return count ! null count maxCount; } }在实际使用时用户 ID、IP、设备指纹都可以作为限流标识。限流的阈值要结合压测结果配置不要拍脑袋。太严会误伤正常用户太松起不到保护作用。3.5 评论与弹幕推送直播间的评论互动对后端来说也是不小的压力。如果前端每秒钟轮询一次评论接口当在线人数达到几万人时接口压力会非常大而且评论实时性也差。更合适的方案是 WebSocket 长连接推送。用户进入直播间后建立连接服务端把评论消息推送到 Redis 的 Pub/Sub 频道或者消息队列再由推送服务分发到每个连接的客户端。一个简化版的推送服务核心逻辑// 文件路径src/main/java/com/example/livepromo/controller/LiveController.java Component ServerEndpoint(/live/{roomId}) public class LiveController { private static CopyOnWriteArraySetSession sessions new CopyOnWriteArraySet(); OnOpen public void onOpen(Session session, PathParam(roomId) String roomId) { sessions.add(session); } OnClose public void onClose(Session session) { sessions.remove(session); } OnMessage public void onMessage(String message, Session session) { // 处理用户发言 broadcast(user: message); } public static void broadcast(String message) { for (Session s : sessions) { try { s.getBasicRemote().sendText(message); } catch (IOException e) { sessions.remove(s); } } } }生产环境下单机CopyOnWriteArraySet的广播模式撑不住大规模在线用户需要引入 Redis Pub/Sub 或者专业的消息服务把广播逻辑抽离成独立的推送集群。但核心思路是一样的前端不轮询服务端主动推。4. 完整实战搭建一个可运行的直播抢购流程为了让上面的代码片段串联起来这一节我给出一个更完整的可运行方案按步骤操作即可复现。4.1 初始化数据库表先准备两张核心表商品表t_item和订单表t_order。CREATE TABLE t_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(128) NOT NULL, stock INT NOT NULL DEFAULT 0, price DECIMAL(10, 2) NOT NULL, status TINYINT NOT NULL DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE t_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, item_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_item (user_id, item_id) );订单表对(user_id, item_id)加唯一索引可以在数据库层面兜底防止重复下单。即使缓存层因为并发出现漏洞数据库也能拦住。4.2 Redis 库存初始化开播前把商品库存写入 RedisSET live:stock:1001 100对应的 Java 初始化方法public void initStock(Long itemId, int stock) { String key live:stock: itemId; redisTemplate.opsForValue().set(key, String.valueOf(stock)); }这里要注意数据库里的库存和 Redis 库存必须一致。实际操作时最好先从数据库读取剩余库存写入 Redis而不是直接手工设置避免两边数据不一致。4.3 核心下单接口这里把之前的OrderController补全加入限流和重复校验逻辑// 文件路径src/main/java/com/example/livepromo/controller/OrderController.java RestController RequestMapping(/api/order) public class OrderController { Autowired private FlashSaleService flashSaleService; Autowired private RateLimiter rateLimiter; Autowired private RabbitTemplate rabbitTemplate; PostMapping(/flash) public Result flashSale(RequestParam Long userId, RequestParam Long itemId) { // 用户维度限流 String limitKey live:limit:user: userId; if (!rateLimiter.isAllowed(limitKey, 5, 10)) { return Result.error(操作过于频繁); } // 重复购买校验 if (flashSaleService.hasPurchased(userId, itemId)) { return Result.error(请勿重复下单); } // 库存扣减 String stockKey live:stock: itemId; long remain flashSaleService.tryDeduceStock(stockKey, userId, itemId); if (remain 0) { return Result.error(商品已抢光); } // 发送订单消息 FlashSaleMessage msg new FlashSaleMessage(userId, itemId); rabbitTemplate.convertAndSend(flash.order.exchange, flash.order, msg); return Result.success(抢购成功订单处理中); } }4.4 订单消费者消费者监听队列负责创建订单并更新 Redis 中的已购买标识// 文件路径src/main/java/com/example/livepromo/mq/FlashOrderConsumer.java Component public class FlashOrderConsumer { Autowired private OrderRepository orderRepository; Autowired private StringRedisTemplate redisTemplate; RabbitListener(queues flash.order.queue) public void handleMessage(FlashSaleMessage msg) { // 先检查是否已经存在订单 if (orderRepository.existsByUserIdAndItemId(msg.getUserId(), msg.getItemId())) { return; } Order order new Order(); order.setUserId(msg.getUserId()); order.setItemId(msg.getItemId()); order.setStatus(0); orderRepository.save(order); // 记录已购买用户 String userItemKey live:purchased: msg.getUserId() : msg.getItemId(); redisTemplate.opsForValue().set(userItemKey, 1, 24, TimeUnit.HOURS); } }消息消费这块有两个高频坑消费者必须做幂等处理因为消息可能被重复投递。上面的existsByUserIdAndItemId就是幂等校验。如果消费失败要做好重试和死信队列。不要无限重试一般重试 3 次后进入死信队列由人工介入处理。4.5 运行与验证启动 Spring Boot 项目后先调用预热接口curl -X POST http://localhost:8080/api/item/preheat?itemId1001stock100再模拟用户抢购curl -X POST http://localhost:8080/api/order/flash?userId1itemId1001正常情况返回{ code: 200, message: 抢购成功订单处理中 }继续用同一用户重复请求会得到{ code: 500, message: 请勿重复下单 }库存扣完后再请求会返回“商品已抢光”。需要注意的是这里商品库存从 100 减少后数据库中的库存并不会立刻变化而是通过消息队列异步落库。你可以查t_order表确认订单是否创建成功。5. 常见问题与排查思路直播活动上线后一定会遇到各种奇怪问题。下面是我认为最高频的几类按“现象-原因-解决”的思路整理成表。问题现象常见原因排查思路开播后接口超时率飙升缓存被击穿请求打到数据库查看 Redis 命中率、数据库连接池监控确认热点缓存是否预热是否设置了合理的过期时间商品显示已抢光但数据库还有库存Redis 库存扣完了消息队列积压订单还没落库查看 RabbitMQ 队列积压量确认消费者是否正常运行是否出现异常退出用户重复下单成功缓存层没有做购买标记或 Redis 标记过期检查hasPurchased逻辑确认 Redis Key 是否有原子性问题同一用户高频刷接口缺少用户维度限流增加 Redis 计数器限流或接入网关层限流评论消息延迟严重WebSocket 广播逻辑在单机上在线人数过多引入 Redis Pub/Sub 或独立推送服务水平扩展推送节点抢购成功后数据库没有订单消息发送失败或消费者消费失败且没有重试开启消息确认机制查看死信队列恢复消息后补偿创建订单快速排查时我习惯按下面顺序走先看监控大盘QPS、错误率、RT 是否异常。再看 Redis命中率、大 Key、热点 Key。再看 MQ队列堆积量、消费速率。最后看数据库慢查询、连接数、锁等待。不要一上来就查代码。直播场景下的问题往往是链路问题不是单点 Bug。6. 最佳实践与工程建议6.1 开播前完成全链路压测活动前必须做一次完整的压测用压测数据反向调整限流阈值和集群规模。你可以使用 JMeter 或阿里云 PTS 模拟高并发请求重点关注每秒能支撑多少下单请求。数据库连接池是否够用。消息队列峰值积压量。Redis 峰值内存和 CPU。如果压测发现 1 万台在线用户就把接口打穿那说明限流阈值、缓存策略、连接池参数都需要调整。6.2 缓存和数据库一致性要有补偿机制秒杀场景下Redis 库存和数据库库存天然存在短暂不一致这是可以接受的。但最终一致性必须保证。推荐的做法是Redis 扣减库存成功。发送 MQ 消息。消费者落库。定时任务扫描 Redis 中剩余库存和数据库库存差异。如果发现 Redis 库存被扣但数据库没有订单说明消息丢失补偿处理。商品下架或者活动结束时需要把 Redis 剩余库存回写到数据库防止数据不一致。6.3 做好日志和链路追踪直播活动出问题时最怕的就是“线上日志满天飞却串不起一条完整链路”。建议在请求入口生成traceId在整条调用链路上透传。如果你还没有接入专业链路追踪系统最低成本的方案是 MDC 配合日志框架// 文件路径src/main/java/com/example/livepromo/common/TraceIdFilter.java Component public class TraceIdFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { String traceId UUID.randomUUID().toString().replaceAll(-, ); MDC.put(traceId, traceId); try { chain.doFilter(request, response); } finally { MDC.remove(traceId); } } }这样每条日志都会带上 traceId排查问题时直接按 traceId 搜索整条链路。6.4 安全红线不能碰直播抢购接口是安全攻击的高发区有几个底线必须守住所有与用户身份相关的接口必须做登录鉴权不能只靠前端传userId。数据库中涉及删除、更新操作的接口必须经过权限校验并在测试环境验证 SQL。不要相信前端传入的库存、价格等参数后端必须重新校验。接口限流不能只针对抢购接口商品详情、评论接口同样需要限流。生产环境任何配置变更、上线操作必须走审批和回滚预案。6.5 降级方案要提前演练活动期间如果某个依赖出现问题要有可执行的降级方案。例如Redis 挂了秒杀接口直接关闭返回“活动火爆”而不是让请求全部打到数据库。MQ 不可用抢购接口可以先写入本地内存队列或降级为限量放行。商品服务异常时展示静态页面上的兜底文案。降级方案不是“到时候再说”而是先写进配置中心提前在测试环境演练一次。7. 总结与下一步学习方向品牌直播间的技术保障本质上是一套高并发场景下的“流量控制 数据一致性 异步化改造”组合方案。本文从直播间业务的流量特征出发带大家走了一遍商品缓存预热、Redis Lua 库存扣减、MQ 异步下单、接口限流、WebSocket 实时互动等核心环节并提供了一个可以本地运行的 Spring Boot 秒杀抢购示例。如果你之前没有接触过秒杀系统可以先重点理解三个知识点为什么 Redis 的 Lua 脚本能保证库存不超卖、为什么下单要异步化、为什么要做多维度限流。这三个点理解透了直播抢购这类业务的大部分问题你都能找到方向。下一步可以继续学习分布式锁的实现与适用场景。Redis 数据结构的底层原理包括 String、Hash、Stream。消息队列的可靠性投递与消费幂等。压测工具的使用以及如何根据压测结果调整系统参数。容器化部署和弹性伸缩比如 K8s 下如何快速扩容。直播活动的研发节奏通常很紧但越紧张越要守住流程预热、压测、监控、降级预案一个都不能少。希望这篇文章能帮你少踩一些坑顺利支撑下一场品牌直播专场。
返回列表