高并发优化不是简单地把某个参数调大,也不是看到接口慢就立刻加缓存。一个 Java 后端接口从请求进入到响应返回,可能经过网关、Tomcat、Controller、Service、缓存、数据库、消息队列和第三方服务。真正有效的优化,需要先定位瓶颈,再选择合适的手段。

本文以典型 Spring Boot 服务为背景,梳理一套高并发接口优化思路,重点关注后端开发最常遇到的几个方向:

  • 如何判断接口瓶颈在哪里。

  • 线程池应该怎么设置。

  • 缓存如何提升读性能,又如何避免击穿和雪崩。

  • 数据库查询如何优化。

  • 写接口如何削峰填谷。

  • 如何通过限流和降级保护系统。

一、先定位瓶颈,不要凭感觉优化

优化前要先回答三个问题:

  1. 慢在哪里?

  2. 为什么慢?

  3. 优化后怎么证明有效?

常见指标包括:

指标 含义
QPS 每秒请求数
RT 请求响应时间
P95 / P99 95% 或 99% 请求的响应耗时
CPU 使用率 是否存在计算瓶颈
JVM 堆内存 是否频繁 GC
数据库慢查询 SQL 是否拖慢接口
线程池队列长度 是否出现任务堆积
连接池使用率 数据库或 HTTP 连接是否耗尽

一个常见误区是只看平均响应时间。平均值容易掩盖长尾问题,高并发场景更应该关注 P95 和 P99。

例如,一个接口平均 RT 是 80ms,但 P99 是 2s,说明大部分请求正常,少量请求非常慢。此时要重点排查连接池等待、锁竞争、慢 SQL、GC 停顿或外部服务抖动。

二、理解 Tomcat 线程模型

Spring Boot 默认使用内嵌 Tomcat。每个 HTTP 请求进入服务后,会由 Tomcat 工作线程处理。如果请求处理过程中大量阻塞在数据库、Redis 或第三方 HTTP 调用上,Tomcat 线程就会被占住。

常见配置如下:

server:
  tomcat:
    threads:
      max: 300
      min-spare: 20
    accept-count: 200
    max-connections: 8192

几个关键参数:

参数 说明
max-threads 最大工作线程数
min-spare-threads 最小空闲线程数
accept-count 线程都忙时的等待队列长度
max-connections 最大连接数

线程不是越多越好。线程数过高会增加上下文切换成本,也会放大下游资源压力。比如数据库连接池只有 50 个连接,Tomcat 线程开到 800,并不会让数据库更快,反而可能让大量请求阻塞等待连接。

更合理的思路是:入口线程数、业务线程池、数据库连接池、Redis 连接池、下游服务能力要整体匹配。

三、业务线程池:隔离不同类型任务

如果接口中存在耗时但可以异步处理的任务,例如发送通知、写操作日志、同步外部系统,可以使用业务线程池隔离。

@Configuration
public class ThreadPoolConfig {
​
    @Bean
    public Executor orderTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(8);
        executor.setMaxPoolSize(16);
        executor.setQueueCapacity(500);
        executor.setThreadNamePrefix("order-task-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize();
        return executor;
    }
}

使用线程池时要注意:

  • 不同业务类型尽量使用不同线程池,避免互相拖垮。

  • 队列不能无限大,否则请求堆积时问题会被隐藏,最终拖垮 JVM。

  • 拒绝策略要结合业务选择,不能只依赖默认策略。

  • 线程池指标要接入监控,包括活跃线程数、队列长度、拒绝次数。

异步执行示例:

@Service
public class OrderNotifyService {
​
    private final Executor orderTaskExecutor;
​
    public OrderNotifyService(Executor orderTaskExecutor) {
        this.orderTaskExecutor = orderTaskExecutor;
    }
​
    public void notifyAfterOrderCreated(Long orderId) {
        orderTaskExecutor.execute(() -> sendNotify(orderId));
    }
​
    private void sendNotify(Long orderId) {
        // 调用短信、站内信或第三方通知服务
    }
}

注意:异步不是万能的。用户必须立即知道结果的逻辑不能随便异步化,例如支付扣款、库存扣减、权限校验等。

四、缓存:高并发读接口的第一道加速器

对于读多写少的数据,缓存通常是最有效的优化手段。典型场景包括:

  • 商品详情。

  • 用户基础信息。

  • 字典配置。

  • 首页推荐模块。

  • 权限菜单。

使用 Redis 缓存商品详情的示例:

@Service
public class ProductService {
​
    private final StringRedisTemplate redisTemplate;
    private final ProductRepository productRepository;
​
    public ProductService(StringRedisTemplate redisTemplate, ProductRepository productRepository) {
        this.redisTemplate = redisTemplate;
        this.productRepository = productRepository;
    }
​
    public ProductDetailResponse getProductDetail(Long productId) {
        String cacheKey = "product:detail:" + productId;
        String cached = redisTemplate.opsForValue().get(cacheKey);
​
        if (cached != null) {
            return JsonUtils.fromJson(cached, ProductDetailResponse.class);
        }
​
        Product product = productRepository.findById(productId)
                .orElseThrow(() -> new BusinessException("PRODUCT_NOT_FOUND", "商品不存在"));
​
        ProductDetailResponse response = ProductConverter.toDetailResponse(product);
        redisTemplate.opsForValue().set(cacheKey, JsonUtils.toJson(response), Duration.ofMinutes(10));
        return response;
    }
}

缓存能显著降低数据库压力,但也会引入新的问题。

五、缓存击穿、穿透和雪崩

1. 缓存穿透

缓存穿透指请求查询一个数据库中也不存在的数据。因为缓存没有命中,每次请求都会打到数据库。

解决方式:

  • 对空结果做短时间缓存。

  • 对明显非法的 ID 做参数校验。

  • 使用布隆过滤器拦截不存在的 key。

空值缓存示例:

if (product == null) {
    redisTemplate.opsForValue().set(cacheKey, "", Duration.ofMinutes(1));
    throw new BusinessException("PRODUCT_NOT_FOUND", "商品不存在");
}

读取时区分空字符串:

if ("".equals(cached)) {
    throw new BusinessException("PRODUCT_NOT_FOUND", "商品不存在");
}

2. 缓存击穿

缓存击穿指某个热点 key 过期的一瞬间,大量请求同时访问数据库。

常见解决方式:

  • 热点 key 不设置短过期时间,通过后台任务刷新。

  • 使用互斥锁,只有一个线程回源数据库。

  • 使用逻辑过期,先返回旧值,再异步刷新。

互斥锁示例:

public ProductDetailResponse getProductDetail(Long productId) {
    String cacheKey = "product:detail:" + productId;
    String cached = redisTemplate.opsForValue().get(cacheKey);
    if (StringUtils.hasText(cached)) {
        return JsonUtils.fromJson(cached, ProductDetailResponse.class);
    }
​
    String lockKey = "lock:" + cacheKey;
    Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(5));
    if (!Boolean.TRUE.equals(locked)) {
        Thread.sleep(50);
        return getProductDetail(productId);
    }
​
    try {
        Product product = productRepository.findById(productId)
                .orElseThrow(() -> new BusinessException("PRODUCT_NOT_FOUND", "商品不存在"));
        ProductDetailResponse response = ProductConverter.toDetailResponse(product);
        redisTemplate.opsForValue().set(cacheKey, JsonUtils.toJson(response), Duration.ofMinutes(10));
        return response;
    } finally {
        redisTemplate.delete(lockKey);
    }
}

生产环境中要避免无限递归,可以加最大重试次数或短暂降级。

3. 缓存雪崩

缓存雪崩指大量 key 在同一时间失效,导致请求集中打到数据库。

解决方式:

  • 给过期时间增加随机抖动。

  • 热点数据分批预热。

  • 多级缓存,比如本地缓存加 Redis。

  • 数据库层做好限流和降级保护。

过期时间增加随机值:

long minutes = 10 + ThreadLocalRandom.current().nextLong(5);
redisTemplate.opsForValue().set(cacheKey, value, Duration.ofMinutes(minutes));

六、数据库优化:让 SQL 先跑得足够好

很多高并发问题,本质是慢 SQL 在高流量下被放大。优化数据库可以从以下几个方面入手。

1. 建立合适索引

例如订单列表常按用户和创建时间查询:

SELECT id, order_no, amount, status, created_at
FROM orders
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT 20;

可以考虑联合索引:

CREATE INDEX idx_orders_user_created_at ON orders(user_id, created_at DESC);

索引设计要结合查询条件、排序字段和字段区分度,不是每个字段都需要单独建索引。

2. 避免查询过多字段

不要在列表接口里随手写 SELECT *。列表页只需要展示少数字段,就只查需要的字段:

SELECT id, order_no, amount, status, created_at
FROM orders
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT 20;

字段越少,网络传输、内存占用和反序列化成本越低。

3. 避免深分页

深分页会让数据库扫描大量无用数据:

SELECT id, order_no
FROM orders
ORDER BY id DESC
LIMIT 100000, 20;

可以改成基于游标的分页:

SELECT id, order_no
FROM orders
WHERE id < ?
ORDER BY id DESC
LIMIT 20;

接口入参从 pageNo 变成 lastId

public List<OrderListItemResponse> listOrders(Long userId, Long lastId, int size) {
    return orderRepository.findByUserIdAndIdLessThanOrderByIdDesc(userId, lastId, PageRequest.of(0, size));
}

对于移动端信息流、订单流水、消息列表,游标分页通常比传统页码分页更稳定。

七、写接口削峰:消息队列与异步化

高并发写接口最怕所有操作都同步落库、同步调用外部系统。比如下单接口里同时做:

  • 创建订单。

  • 扣减库存。

  • 发优惠券。

  • 发送短信。

  • 推送站内信。

  • 写操作日志。

  • 同步 CRM。

其中有些步骤必须同步完成,有些则可以异步处理。

可以同步完成核心链路:

校验用户 -> 校验库存 -> 创建订单 -> 扣减库存 -> 返回下单结果

再通过消息队列处理非核心链路:

发送通知 -> 写行为日志 -> 同步外部系统 -> 更新推荐画像

发布事件示例:

@Service
public class OrderServiceImpl implements OrderService {

    private final OrderRepository orderRepository;
    private final ApplicationEventPublisher eventPublisher;

    public OrderServiceImpl(OrderRepository orderRepository, ApplicationEventPublisher eventPublisher) {
        this.orderRepository = orderRepository;
        this.eventPublisher = eventPublisher;
    }

    @Transactional(rollbackFor = Exception.class)
    public Long createOrder(CreateOrderRequest request) {
        Order order = Order.create(request.getUserId(), request.getItems());
        orderRepository.save(order);

        eventPublisher.publishEvent(new OrderCreatedEvent(order.getId()));
        return order.getId();
    }
}

如果是单体应用,可以先使用 Spring 事件或本地异步线程池;如果是分布式系统,可以使用 RocketMQ、Kafka、RabbitMQ 等消息队列。

不过要注意,消息队列带来的是最终一致性,不是强一致性。涉及支付、库存、资金这类核心数据时,需要设计幂等、重试、补偿和对账机制。

八、幂等设计:防止重复请求造成脏数据

高并发环境下,重复请求很常见:

  • 用户连续点击按钮。

  • 前端超时后自动重试。

  • 网关或消息队列重复投递。

  • 第三方回调多次通知。

幂等设计的目标是:同一个业务请求执行一次和执行多次,最终结果一致。

常见方案:

  • 使用唯一业务单号,例如 orderNorequestId

  • 数据库增加唯一索引。

  • Redis 记录请求处理状态。

  • 消息消费端根据消息 ID 去重。

以下单接口为例:

CREATE UNIQUE INDEX uk_orders_request_id ON orders(request_id);

Service 中先基于 requestId 查询:

@Transactional(rollbackFor = Exception.class)
public Long createOrder(CreateOrderRequest request) {
    Optional<Order> existingOrder = orderRepository.findByRequestId(request.getRequestId());
    if (existingOrder.isPresent()) {
        return existingOrder.get().getId();
    }

    Order order = Order.create(request.getRequestId(), request.getUserId(), request.getItems());
    orderRepository.save(order);
    return order.getId();
}

数据库唯一索引是最后一道防线。即使两个请求同时通过了查询,也只能有一个插入成功。

九、限流与降级:保护系统边界

当流量超过系统承载能力时,系统需要主动保护自己。否则所有请求一起挤进来,可能导致线程池耗尽、连接池打满、数据库被拖垮。

常见限流维度:

  • 按接口限流。

  • 按用户限流。

  • 按 IP 限流。

  • 按租户限流。

  • 按下游服务限流。

常见限流算法:

算法 特点
固定窗口 实现简单,但窗口边界可能突刺
滑动窗口 比固定窗口平滑
漏桶 稳定匀速处理
令牌桶 允许一定突发流量

本地单机可以使用 Guava RateLimiter,分布式环境可以使用 Redis、Sentinel 或网关层限流。

降级则是当系统压力过大或依赖不可用时,返回一个可接受的兜底结果。例如:

  • 推荐接口返回默认推荐。

  • 评论接口暂时隐藏评论。

  • 报表接口提示稍后再试。

  • 非核心数据使用缓存旧值。

降级不是失败,而是用有限能力保证核心链路可用。

十、JVM 与 GC:关注长尾延迟

Java 后端在高并发场景下还要关注 JVM 状态,尤其是内存分配和 GC。

常见问题包括:

  • 接口创建大量临时对象,导致 Young GC 频繁。

  • 大对象进入老年代,增加 Full GC 风险。

  • 本地缓存无限增长,造成内存泄漏。

  • 日志打印过多,增加对象分配和 IO 压力。

排查时可以关注:

GC 次数
GC 停顿时间
堆内存使用率
对象分配速率
线程数量

接口优化不仅是让平均耗时降低,也要让长尾请求更稳定。一次 Full GC 可能让大量请求同时变慢,最终表现为 P99 抖动。

十一、可观测性:优化必须能被验证

没有监控的优化很容易变成玄学。建议至少具备以下能力:

  • 接口维度的 QPS、RT、P95、P99。

  • 错误率和异常类型统计。

  • 数据库慢 SQL 记录。

  • Redis 命中率和耗时。

  • 线程池活跃线程数、队列长度、拒绝次数。

  • JVM GC 和堆内存指标。

  • Trace 链路追踪,定位一次请求经过哪些服务和依赖。

一次优化前后应该能形成对比:

指标 优化前 优化后
平均 RT 320ms 90ms
P95 900ms 180ms
P99 2.8s 420ms
数据库 QPS 5000 800
缓存命中率 0% 92%

只有能被量化,优化才算真正闭环。

十二、一个实战优化路径

假设有一个商品详情接口,在活动期间 QPS 从 200 提升到 5000,数据库开始出现慢查询,接口 P99 超过 3 秒。

可以按下面顺序优化:

  1. 通过监控确认瓶颈在数据库查询。

  2. 检查 SQL,补充商品 ID、状态等必要索引。

  3. 商品详情增加 Redis 缓存,设置合理过期时间。

  4. 对热点商品提前预热缓存。

  5. 给缓存过期时间增加随机抖动,避免大量 key 同时失效。

  6. 对热点 key 使用逻辑过期或互斥锁,避免缓存击穿。

  7. 在网关或服务层增加限流,保护数据库。

  8. 接入接口、Redis、数据库和 JVM 监控,观察 P95、P99 是否稳定。

这个路径的重点是逐层缩小问题范围,而不是一开始就改架构。

十三、总结

Java 后端高并发优化可以概括为几句话:

  • 先观测,再优化。

  • 线程池要隔离,队列要有界,拒绝策略要明确。

  • 读多写少优先考虑缓存,但要处理穿透、击穿和雪崩。

  • 慢 SQL 在高并发下会被放大,索引和查询结构必须扎实。

  • 写接口要区分核心链路和非核心链路,必要时用消息队列削峰。

  • 重试、回调、消息消费都要考虑幂等。

  • 限流和降级是保护系统的必要手段。

  • 优化结果必须通过监控指标验证。

高并发不是某一个技术点,而是一套系统工程。真正稳定的后端服务,靠的不是单次神奇调参,而是从入口、线程、缓存、数据库、消息、监控到降级的整体设计。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐