1. 从3秒到25毫秒:一次接口性能优化的完整复盘
去年第三季度,我们核心交易接口的响应时间突然从平均300毫秒飙升到3秒以上。作为系统负责人,我带着团队花了三周时间进行优化,最终将响应时间稳定在25毫秒左右。这次优化涉及多个技术层面的改造,其中有些技巧确实称得上"优雅"。现在把完整过程分享出来,或许能给你带来些启发。
这个接口的主要功能是处理用户提交的订单数据,需要完成以下核心操作:验证商品库存、计算优惠金额、生成交易流水、扣减库存。在高峰期,QPS能达到2000左右。当响应时间突破3秒时,前端超时率已经达到15%,严重影响了用户体验。
2. 性能瓶颈定位:从火焰图说起
2.1 初步监控数据分析
我们首先检查了基础监控数据:
- CPU利用率:75%(未饱和)
- 内存使用率:60%(健康)
- 磁盘IO:无明显瓶颈
- 网络带宽:峰值40%(千兆网卡)
这些数据看起来都很正常,说明问题不在基础设施层面。于是我们转向应用层分析。
2.2 Arthas火焰图揭示真相
使用Arthas生成的火线图显示,75%的时间消耗在数据库IO上。进一步分析发现:
- 单个请求平均产生8次SQL查询
- 其中5次是重复查询相同商品信息
- 每次查询平均耗时120ms
- 事务持有时间过长(平均1.8秒)
最令人意外的是,商品基础信息的查询占用了40%的响应时间。这些数据在短时间内本应是不变的,却被反复查询。
3. 多级缓存方案设计
3.1 本地缓存的选择与实现
我们首先考虑使用Caffeine作为本地缓存:
LoadingCache<String, ItemInfo> itemCache = Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(5, TimeUnit.MINUTES) .refreshAfterWrite(1, TimeUnit.MINUTES) .build(key -> itemDao.getById(key));关键设计点:
- 最大缓存1万个商品(覆盖热销商品)
- 写入5分钟后过期
- 1分钟后自动刷新(避免过期时集中冲击DB)
- 使用LoadingCache实现自动加载
注意:refreshAfterWrite需要配置异步刷新线程池,否则会阻塞主线程
3.2 Redis分布式缓存层
为防止缓存击穿,我们增加了Redis二级缓存:
public ItemInfo getItem(String itemId) { // 先查本地缓存 ItemInfo item = localCache.get(itemId); if (item != null) return item; // 再查Redis item = redisTemplate.opsForValue().get(buildRedisKey(itemId)); if (item != null) { localCache.put(itemId, item); return item; } // 最后查DB item = itemDao.getById(itemId); redisTemplate.opsForValue().set(buildRedisKey(itemId), item, 5, TimeUnit.MINUTES); localCache.put(itemId, item); return item; }4. 批量处理改造
4.1 合并数据库查询
原代码对每个商品单独查询:
for (OrderItem item : orderItems) { ItemInfo info = itemDao.getById(item.getItemId()); // 处理逻辑... }改造为批量查询:
List<String> itemIds = orderItems.stream() .map(OrderItem::getItemId) .collect(Collectors.toList()); Map<String, ItemInfo> itemMap = itemDao.batchGetByIds(itemIds) .stream() .collect(Collectors.toMap(ItemInfo::getId, Function.identity())); for (OrderItem item : orderItems) { ItemInfo info = itemMap.get(item.getItemId()); // 处理逻辑... }这个改动使8次独立查询减少到2次批量查询,效果立竿见影。
4.2 并行化处理
对于没有严格顺序依赖的操作,我们引入并行流:
orderItems.parallelStream().forEach(item -> { // 计算优惠 calculateDiscount(item); // 准备库存数据 prepareStock(item); });提示:parallelStream默认使用ForkJoinPool.commonPool(),在高并发场景下建议自定义线程池
5. 异步化改造
5.1 非核心流程异步化
将日志记录、消息通知等非核心操作改为异步:
@Async("bizAsyncExecutor") public void asyncRecordOperationLog(OperationLog log) { operationLogDao.insert(log); }配置专用线程池:
@Bean("bizAsyncExecutor") public Executor asyncExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(5); executor.setMaxPoolSize(10); executor.setQueueCapacity(1000); executor.setThreadNamePrefix("async-biz-"); executor.initialize(); return executor; }5.2 写操作后置处理
对于库存扣减这类需要保证最终一致性的操作,我们改造为:
- 先在内存标记"已扣减"
- 快速返回响应
- 异步执行实际扣减
// 内存标记 InventoryHoldManager.hold(orderId, itemId, quantity); // 异步任务 asyncInventoryService.deduct(orderId, itemId, quantity);6. SQL优化与索引调整
6.1 发现缺失的索引
通过慢查询日志,我们发现商品查询缺少组合索引:
-- 原索引 ALTER TABLE `items` ADD INDEX `idx_category` (`category_id`); -- 新增组合索引 ALTER TABLE `items` ADD INDEX `idx_category_status` (`category_id`, `status`);6.2 优化JOIN操作
将原来的子查询改为JOIN:
-- 优化前 SELECT * FROM orders WHERE id IN ( SELECT order_id FROM order_items WHERE item_id = '123' ); -- 优化后 SELECT o.* FROM orders o JOIN order_items oi ON o.id = oi.order_id WHERE oi.item_id = '123';7. 效果验证与异常处理
7.1 压测对比数据
优化前后对比(JMeter压测,QPS=2000):
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 平均响应时间 | 3200ms | 26ms |
| 错误率 | 12% | 0.1% |
| 99线 | 4800ms | 45ms |
7.2 缓存雪崩防护
为防止缓存集中失效,我们做了两点改进:
- 对Redis过期时间增加随机偏移(±30秒)
- 本地缓存采用阶梯式过期策略
.expireAfterWrite(5 + ThreadLocalRandom.current().nextInt(10), TimeUnit.MINUTES)8. 后续优化方向
虽然当前效果已经很好,但我们还在探索:
- 使用Redis Module实现更智能的缓存(如RedisBloom)
- 尝试GraalVM提升JVM性能
- 部分读场景迁移到TiDB
这次优化给我的最大启示是:性能问题往往不是单一因素导致的,需要从多个维度系统性地分析和解决。那些看似微小的优化点,累积起来可能产生惊人的效果。比如把8次DB查询合并为2次,看似简单,却直接减少了75%的数据库负载。