秒杀开始3秒,数据库连接池就被打满了,100万用户在页面上干等

去年我们搞了一次周年庆秒杀。活动页面提前一周预热,到点那一刻,后台监控直接变成一片红。

不是 QPS 被打爆——网关和应用层都扛住了。是数据库连接池,三秒之内从 0 飙到 200(最大连接数),然后所有请求开始报 Cannot get JDBC Connection

最离谱的是,库存表一行没动。

因为所有请求刚到数据库门口,就被连接池耗尽的异常挡回去了。秒杀还没开始,数据库先倒下了。


一、连接池为什么瞬间被榨干

常规架构下,每次请求处理到数据库这一步才会获取连接。秒杀场景的流量是瞬时脉冲——0 秒之前无人问津,0 秒之后几十万并发同时冲到数据库门口。

连接池有个坑:它不会拒绝你,它只会让你排队等。

HikariCP 默认最大连接数 10(或者你手动设的 200),当并发的数据库请求数超过这个值时,获取连接的线程进入等待队列。每个请求最多等 connectionTimeout(默认 30 秒)。秒杀场景下 30 秒太长了——

  • 所有线程都在等连接
  • 连接被前面进来的请求占用着(那些请求可能正在执行慢查询)
  • 后面的请求继续涌进来抢连接
  • 线程池里的线程全卡在获取连接上,CPU 空转
  • tomcat 的请求队列迅速打满,开始丢请求

连锁反应形成:连接池耗尽 → 请求堆积 → 连接占用时间更长 → 更多请求排队 → 全部超时。


二、问题的本质

本质上这不是数据库性能问题,是上游没有限流

数据库连接池是有限的物理资源。200 个连接意味着同一时刻最多 200 个请求能跟数据库交互。秒杀场景下几十万个请求同时到来,就算你在网关层做了 QPS 限制,限制粒度是"每秒 5000 个请求",落到数据库连接池这个层面,前 200 个请求瞬间占满连接,后面 4800 个请求全在排队。

就像餐厅只有 200 个座位,但门口排了 5000 个人。服务员不拒绝任何人进店,结果过道里站满了人,连已经坐下的人都没法正常点菜。

解决方案不是扩大连接池——数据库能承受的最大连接数是物理限制,连到 500 个可能数据库自己先 OOM 了。而是在应用层限制"同时操作数据库的请求数",让它跟连接池大小匹配。


三、方案设计:信号量限流 + 快速失败

核心思路:在到达数据库连接池之前,先经过一层信号量。

请求流 → 网关限流(QPS) → 信号量限流(并发度) → 获取连接 → 执行业务
                                ↓
                          信号量不足 → 快速失败
                          "秒杀太火爆,请稍后重试"

信号量的 permits 数量 = 数据库连接池最大连接数的 80%。

为什么是 80%?留 20% 的缓冲给非秒杀业务——后台管理、库存查询、订单回调。如果秒杀把所有连接都占了,后台运营连订单都查不了。

@Component
public class SeckillFlowController {
    
    // 假设 HikariCP 最大连接数 200,信号量只放 160 个
    private final Semaphore semaphore = new Semaphore(160, true);
    
    // 快速失败模式:拿不到许可直接抛异常
    public <T> T executeWithLimit(Supplier<T> businessLogic) {
        boolean acquired = false;
        try {
            acquired = semaphore.tryAcquire(100, TimeUnit.MILLISECONDS);
            if (!acquired) {
                throw new SeckillOverloadException("秒杀太火爆,请稍后重试");
            }
            return businessLogic.get();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new SeckillOverloadException("请求被中断");
        } finally {
            if (acquired) {
                semaphore.release();
            }
        }
    }
}

tryAcquire(100, MILLISECONDS)——只等 100 毫秒,等不到直接抛异常。为什么不是 30 秒?因为线程池的线程是有限的,一个线程在信号量上卡 30 秒,30 秒内它不能处理其他请求——等于白白占着线程资源。

快速失败后,前端收到 429 Too Many Requests,给用户一个友好提示:"排队中,预计等待 X 秒",而不是无限转圈。


四、再加一层:请求排队机制

快速失败解决了连接池耗尽的问题,但用户体验不好。用户看到的是"秒杀太火爆"的提示,而不是"排队中,请等待"。连续点了十几次都被拒绝,最后气到关页面。

所以需要加一层排队:拿不到信号量的请求不直接拒绝,进入一个有序队列等待。

@Component
public class SeckillQueueController {
    
    private final Semaphore semaphore = new Semaphore(160, true);
    private final ExecutorService queueExecutor = Executors.newFixedThreadPool(50);
    
    public <T> CompletableFuture<T> submit(String userId, 
            Supplier<T> businessLogic) {
        
        // 先看能不能直接拿到信号量
        if (semaphore.tryAcquire()) {
            try {
                return CompletableFuture.completedFuture(businessLogic.get());
            } finally {
                semaphore.release();
            }
        }
        
        // 拿不到:进入排队队列,给用户返回排队位置
        CompletableFuture<T> future = new CompletableFuture<>();
        queueExecutor.submit(() -> {
            try {
                semaphore.acquire();
                try {
                    T result = businessLogic.get();
                    future.complete(result);
                } finally {
                    semaphore.release();
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                future.completeExceptionally(e);
            }
        });
        return future;
    }
    
    // 获取当前排队人数(供前端展示)
    public int getQueueLength() {
        return semaphore.getQueueLength();
    }
    
    // 估算排队时间
    public long estimateWaitSeconds() {
        int queueLength = semaphore.getQueueLength();
        // 假设平均每个请求 200ms,160 个并发,每秒处理 800 个
        return (long) Math.ceil(queueLength / 800.0);
    }
}

前端逻辑:

async function submitOrder() {
    // 1. 提交排队请求
    const response = await fetch('/seckill/submit', {
        method: 'POST',
        body: JSON.stringify({ productId, userId })
    });
    
    if (response.status === 202) {
        // 2. 排队中,轮询查询排队位置
        const { position, estimatedWait } = await response.json();
        showQueueStatus(`前方还有 ${position} 人,预计等待 ${estimatedWait} 秒`);
        
        // 3. 每秒轮询一次排队状态
        const intervalId = setInterval(async () => {
            const status = await fetch(`/seckill/queue-status?userId=${userId}`);
            if (status.result === 'COMPLETED') {
                clearInterval(intervalId);
                showResult(status.orderId);
            }
        }, 1000);
    }
}

这样用户看到的不是冷冰冰的拒绝,而是"前方还有 234 人,预计等待 5 秒"——有预期就不焦虑。


五、控制器集成

把信号量排队逻辑和秒杀业务结合:

@RestController
@RequestMapping("/seckill")
public class SeckillController {
    
    @Autowired private SeckillQueueController queue;
    @Autowired private SeckillService seckillService;
    
    @PostMapping("/submit")
    public ResponseEntity<?> submitSeckill(
            @RequestBody SeckillRequest request) {
        
        String userId = request.getUserId();
        
        CompletableFuture<OrderResult> future = queue.submit(userId, () -> {
            return seckillService.execute(
                request.getProductId(), 
                request.getUserId()
            );
        });
        
        // 如果已直接完成,返回结果
        if (future.isDone()) {
            try {
                return ResponseEntity.ok(future.get());
            } catch (Exception e) {
                return ResponseEntity.status(500)
                    .body(Map.of("error", e.getMessage()));
            }
        }
        
        // 排队中:异步等待,先返回排队信息
        int position = queue.getQueueLength();
        long waitSeconds = queue.estimateWaitSeconds();
        
        // 排队结果异步处理
        future.thenAccept(result -> {
            // 通过 WebSocket/SSE 推送给前端
            notifyClient(userId, result);
        });
        
        return ResponseEntity.status(202).body(Map.of(
            "status", "QUEUING",
            "position", position,
            "estimatedWait", waitSeconds
        ));
    }
}

返回 HTTP 202 Accepted——告诉前端"我收到了,正在处理,回头告诉你结果"。


六、信号量大小怎么定

不是拍脑袋设 160。需要根据实际情况计算:

信号量上限 = min(
    数据库最大连接数 × 0.8,      // 留缓冲
    数据库能承受的最大并发查询数    // 实际压测数据
)

压测时关注几个指标:

信号量QPS平均RTP99 RT数据库CPU
40320/s80ms150ms35%
80590/s95ms200ms52%
120780/s120ms350ms68%
160820/s180ms600ms85%
200790/s350ms2000ms95%

从这个压测数据看,160 是最优的——再往上 QPS 不增反降,RT 飙升,数据库 CPU 逼近 95%。过了拐点,再多并发都是负优化。


七、效果对比

上线到下一次秒杀活动后:

指标上线前上线后
数据库连接池耗尽次数每次秒杀必现0
秒杀请求成功率0.3%(大量连接超时)98%(排队机制让请求有序处理)
数据库 CPU瞬间 100% → 雪崩稳定在 85%
P99 RT超时 30s+队列中 5-8s,执行 200ms
用户感知无限转圈 → 报错"前方还有 X 人,预计等待 Y 秒"
非秒杀服务影响后台管理、查询全部卡死不受影响(预留 20% 连接)

最直观的变化是监控大盘:以前秒杀开始那一刻,数据库连接数瞬间拉满成一条水平线,然后告警疯狂弹。现在连接数是一条平滑的曲线,稳稳压在 160 上下。


八、注意事项

注意一:信号量公平模式很重要。 new Semaphore(160, true) 里的 true,开启公平模式。意味着先到的请求先拿许可,后到的排队。如果不加这个,可能某个用户的请求永远轮不到。

注意二:队列不能无限长。 Semaphore 本身没有队列长度限制。如果 100 万人同时排队,JVM 内存先爆了。需要加一个队列上限:

if (semaphore.getQueueLength() > 10000) {
    throw new SeckillOverloadException("排队人数过多,请稍后再试");
}

注意三:秒杀库存扣减和排队的先后顺序。 如果用户排队等了 5 秒,排到他的时候库存已经没了,体验很差。建议在请求入队时就预占一个"虚拟库存",排到了再真正扣减。虚拟库存扣完的直接拒绝,不用排队。

注意四:连接池配置本身也要合理。 信号量是保护连接池的,但连接池的 connectionTimeoutmaximumPoolSizeminimumIdle 这些基础配置也得对。建议 HikariCP 配的是:

spring:
  datasource:
    hikari:
      maximum-pool-size: 200
      minimum-idle: 20
      connection-timeout: 3000    # 3 秒,别等 30 秒
      idle-timeout: 600000
      max-lifetime: 1800000

connection-timeout: 3000——在信号量已经放行的情况下,获取连接最多等 3 秒,超时就抛异常。如果 3 秒还拿不到连接,说明信号量限流逻辑有问题,该报警了。


连接池看起来是数据库的事,其实是应用层的事。把数据库保护好了,秒杀才不会变成"秒挂"。

你们的秒杀活动里,数据库连接池被打满过吗?评论区报个到。


标题:秒杀开始3秒,数据库连接池就被打满了,100万用户在页面上干等
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/06/22/1782008109645.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消