秒杀开始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 | 平均RT | P99 RT | 数据库CPU |
|---|---|---|---|---|
| 40 | 320/s | 80ms | 150ms | 35% |
| 80 | 590/s | 95ms | 200ms | 52% |
| 120 | 780/s | 120ms | 350ms | 68% |
| 160 | 820/s | 180ms | 600ms | 85% |
| 200 | 790/s | 350ms | 2000ms | 95% |
从这个压测数据看,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 秒,排到他的时候库存已经没了,体验很差。建议在请求入队时就预占一个"虚拟库存",排到了再真正扣减。虚拟库存扣完的直接拒绝,不用排队。
注意四:连接池配置本身也要合理。 信号量是保护连接池的,但连接池的 connectionTimeout、maximumPoolSize、minimumIdle 这些基础配置也得对。建议 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
公众号:服务端技术精选
评论