WEBKT

高并发下的数据库写入保护:内存队列与拒绝策略实战

44 0 0 0

在高并发场景下,数据库写入往往是系统的性能瓶颈。直接将海量请求打到数据库,不仅会导致数据库 CPU/IO 飙升,还可能引发连锁反应导致服务雪崩。为了解决这个问题,我们需要在应用层和数据库层之间构建一个缓冲带,这就是所谓的**“削峰填谷”**策略。

核心武器:基于内存队列的缓冲层

最廉价且高效的缓冲方案就是利用应用服务器的内存。当请求洪峰到来时,我们不直接写盘,而是先写入内存队列,由后台线程池慢慢消化,再以数据库能承受的速率写入。

1. 为什么是内存队列?

  • 廉价与高速:相比 Redis 等外部中间件,JVM 内存队列(如 ArrayBlockingQueueLinkedBlockingQueue)没有网络开销,吞吐量极高。
  • 解耦:前端接口的响应不再依赖于数据库的慢 IO,接口可以快速返回“受理成功”,提升用户体验。

2. 经典架构:生产者-消费者模型

这是实现缓冲的标准模式:

  • 生产者(Web 线程):接收到写入请求后,不直接执行 SQL,而是将数据封装成 DTO,快速丢入内存队列。如果队列满了,触发拒绝策略(下文详述)。
  • 消费者(后台线程池):从队列中批量拉取数据(Batch Insert),合并 SQL 语句,提交给数据库执行。

关键细节:如何防止缓冲层反噬?

内存队列虽好,但它是有界的。如果生产者速度远大于消费者速度,队列迟早会满。此时必须要有兜底机制,这就是有界队列拒绝策略的艺术。

1. 有界队列的大小设定

不要使用无界队列(如 LinkedBlockingQueue 无参构造),否则在流量持续暴涨时,内存会被耗尽,导致 OOM。

  • 计算公式队列长度 ≈ (平均响应时间 × 峰值QPS) + 安全冗余
  • 经验值:通常设置为几百到几千,根据内存大小和单条数据占用的内存空间来定。

2. 拒绝策略(Rejection Policy)

当队列满时,必须果断拒绝新的请求,保护下游数据库不被压垮。常见的策略有:

  • 直接抛出异常(Caller Runs Policy):让生产者线程自己去执行 SQL。这是一种负反馈调节,会减慢生产者的生产速度,从而保护系统。适合可接受偶尔请求变慢的场景。
  • 丢弃最旧任务(Discard Oldest Policy):丢弃队列头部的任务,腾出空间给新任务。适合允许丢失少量数据的非核心日志场景。
  • 直接丢弃新任务(Discard Policy):静默丢弃新请求。适合对数据完整性要求不高,但对响应速度要求极高的场景。
  • 自定义降级策略:将数据写入本地磁盘临时文件或发送到备用 MQ(如 Kafka),稍后补偿。

3. 批量写入与合并

消费者在处理队列数据时,不要逐条写入。

  • 攒批(Micro-batching):比如每凑够 100 条或者每隔 200ms 执行一次写入。
  • 合并 SQL:将多条 Insert 语句合并为一条 INSERT INTO ... VALUES (...), (...), (...),大幅减少数据库的网络交互和事务开销。

进阶保护:有界队列之外的防线

除了应用层的内存队列,还可以引入外部组件作为第二道防线,防止单机重启导致数据丢失或流量依然过高。

  1. Redis List 作为二级缓冲
    如果内存队列处理不过来,可以将数据快速转存到 Redis List 中。Redis 相比内存队列更持久,且能跨服务共享消费。

  2. Sentinel 限流
    在请求进入内存队列之前,先使用 Sentinel 或 Guava RateLimiter 进行限流。如果 QPS 超过阈值,直接在入口处拦截,避免无效请求进入后端处理逻辑。

避坑指南

  1. 消费者挂掉怎么办?
    必须对消费者线程进行监控报警。如果队列堆积量持续增长,说明消费者的处理能力跟不上,需要扩容消费者线程数或优化 SQL。
  2. 数据丢失风险
    内存队列中的数据在应用宕机时会丢失。如果业务要求数据绝对不丢,不能只用内存队列,必须结合 Kafka/RocketMQ 这类持久化消息队列。
  3. 延迟问题
    用户感知的写入成功是“接口返回 200”,但数据真正落盘可能有几秒延迟。如果是对实时性要求极高的业务(如支付),这种异步模式需要慎重评估。

总结

“削峰填谷”的本质是空间换时间异步化。通过内存队列作为廉价缓冲,配合有界队列防止内存溢出,再通过批量写入提升吞吐,最后通过拒绝策略兜底,可以构建出一道坚固的防线,确保下游数据库在高并发洪峰下依然稳健运行。

码农架构师 高并发架构数据库保护削峰填谷Java并发编程

评论点评