1. 性能瓶颈诊断与优化思路第一次接触谷粒商城这个项目时系统刚上线就遇到了严重的性能问题。在促销活动期间用户访问商品页面的响应时间经常超过5秒后台数据库CPU使用率直接飙到90%以上。作为技术负责人我当时的第一反应是必须立即找到性能瓶颈所在。性能优化的第一步永远是定位问题。我常用的三板斧是压力测试、性能监控和日志分析。使用JMeter模拟1000并发用户访问商品详情页时TPS每秒事务数只有可怜的150而理想值至少应该在500以上。通过jvisualvm监控发现Tomcat线程大量阻塞在数据库查询上特别是商品分类和商品详情的查询。这里有个重要经验优化前一定要先收集数据。盲目优化往往事倍功半。我记录了三个关键指标平均响应时间4.8秒数据库QPS1200次/秒Redis命中率仅有15%通过这些数据可以明显看出系统过度依赖数据库缓存利用率极低。这为我们指明了第一个优化方向减少数据库访问提升缓存命中率。2. Nginx动静分离实战2.1 为什么需要动静分离在排查性能问题时我发现一个有趣的现象虽然商品详情页是动态内容但页面中80%的请求其实都是静态资源图片、CSS、JS。这些静态资源每次都要经过Tomcat处理造成了巨大的资源浪费。动静分离的核心思想很简单让专业的工具做专业的事。Nginx处理静态资源的性能是Tomcat的5-10倍而Tomcat应该专注于处理动态业务逻辑。在我们的案例中实施动静分离后静态资源的响应时间从原来的800ms降到了50ms左右。2.2 具体配置方案在Nginx中配置动静分离其实很简单关键配置如下server { listen 80; server_name www.gulimall.com; # 静态资源路径 location ~ .*\.(gif|jpg|jpeg|png|css|js|ico)$ { root /opt/static; expires 30d; } # 动态请求转发 location / { proxy_pass http://tomcat_cluster; proxy_set_header Host $host; } }这里有几个优化点值得注意给静态资源设置了30天的缓存过期时间利用浏览器缓存减少请求使用expires指令而非Cache-Control兼容性更好静态资源单独存放在SSD磁盘上I/O性能更好实施后效果立竿见影Nginx的静态资源处理吞吐量达到了8000req/sTomcat的负载下降了40%。3. 多级缓存架构设计3.1 本地缓存与Redis的结合最初我们尝试使用简单的HashMap做本地缓存很快就遇到了两个致命问题在集群环境下缓存无法共享命中率极低数据更新时各节点缓存不一致于是我们引入了多级缓存架构第一层本地Caffeine缓存100ms过期第二层Redis集群缓存30分钟过期第三层数据库这个架构的关键在于缓存过期时间的阶梯式设计。以下是我们的实现代码public Product getProduct(Long id) { // 一级缓存查询 Product product caffeineCache.get(id); if (product ! null) { return product; } // 二级缓存查询 String redisKey product: id; product redisTemplate.opsForValue().get(redisKey); if (product ! null) { caffeineCache.put(id, product); return product; } // 数据库查询 product productMapper.selectById(id); if (product ! null) { redisTemplate.opsForValue().set(redisKey, product, 30, TimeUnit.MINUTES); caffeineCache.put(id, product); } return product; }3.2 缓存问题解决方案在高并发场景下我们遇到了经典的缓存三连问题缓存穿透恶意请求不存在的商品ID解决方案缓存空对象设置短过期时间2分钟缓存雪崩大量缓存同时失效解决方案基础过期时间随机偏移量30±5分钟缓存击穿热点key突然失效解决方案Redisson分布式锁后面会详细讲这里特别提醒缓存空对象时一定要设置较短的过期时间否则会浪费大量内存。我们在实践中发现2分钟是个比较合适的值既防止了穿透又不会占用太多内存。4. Redisson分布式锁深度解析4.1 为什么需要分布式锁在秒杀场景中我们遇到了超卖问题。使用synchronized或者ReentrantLock在单机环境下没问题但在集群环境下完全失效。这就是分布式锁的用武之地。Redisson的分布式锁有几个重要特性互斥性同一时刻只有一个客户端能持有锁可重入性同一个线程可以多次获取同一把锁自动续期看门狗机制防止业务未执行完锁过期高可用支持Redis集群模式4.2 最佳实践代码示例以下是我们在商品库存扣减场景中的实现public boolean deductStock(Long productId, int num) { String lockKey stock:lock: productId; RLock lock redissonClient.getLock(lockKey); try { // 尝试加锁最多等待100ms锁自动释放时间30s boolean locked lock.tryLock(100, 30000, TimeUnit.MILLISECONDS); if (!locked) { return false; } // 业务逻辑 Product product productMapper.selectById(productId); if (product.getStock() num) { product.setStock(product.getStock() - num); productMapper.updateById(product); return true; } return false; } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }这里有几个关键点使用tryLock而非lock避免长时间阻塞设置合理的等待时间和自动释放时间必须在finally块中检查锁归属后再释放4.3 读写锁的应用对于商品详情这种读多写少的场景我们使用了Redisson的读写锁public Product getProductDetail(Long id) { RReadWriteLock lock redissonClient.getReadWriteLock(product:lock: id); RLock rLock lock.readLock(); try { rLock.lock(); // 查询逻辑 return productService.getById(id); } finally { rLock.unlock(); } } public void updateProduct(Product product) { RReadWriteLock lock redissonClient.getReadWriteLock(product:lock: product.getId()); RLock wLock lock.writeLock(); try { wLock.lock(); // 更新逻辑 productService.updateById(product); } finally { wLock.unlock(); } }读写锁的特点是读读不互斥读写互斥写写互斥这种设计可以大幅提升系统的并发读取能力。在我们的测试中使用读写锁后商品详情的读取吞吐量提升了3倍。5. Spring Cache高级应用5.1 缓存一致性解决方案使用Spring Cache时最大的挑战是如何保证缓存与数据库的一致性。我们尝试了两种方案方案一双写模式先更新数据库再删除缓存问题在并发更新时可能出现短暂的不一致方案二Canal监听binlog通过Canal监听数据库变更异步更新缓存优点完全解耦不影响主流程缺点有一定延迟最终我们采用了折中方案对于实时性要求高的场景使用双写模式其他场景使用Canal方案。5.2 自定义缓存配置Spring Cache默认的序列化方式JDK序列化效率低且不直观。我们通过自定义配置改用了JSON格式Configuration EnableCaching public class CacheConfig { Bean public RedisCacheConfiguration redisCacheConfiguration() { return RedisCacheConfiguration.defaultCacheConfig() .serializeKeysWith(RedisSerializationContext.SerializationPair .fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair .fromSerializer(new GenericJackson2JsonRedisSerializer())) .entryTtl(Duration.ofMinutes(30)); } }这个配置做了三件事键使用String序列化值使用JSON序列化设置默认过期时间为30分钟5.3 缓存注解的高级用法在实际开发中我们总结了一些Spring Cache注解的使用技巧Cacheable(value products, key #id, sync true) public Product getProduct(Long id) { // ... } Caching(evict { CacheEvict(value products, key #product.id), CacheEvict(value product:list, allEntries true) }) public void updateProduct(Product product) { // ... }特别说明synctrue可以解决缓存击穿问题内部使用本地锁Caching可以组合多个缓存操作allEntriestrue用于清空整个缓存区域6. 实战经验与避坑指南在谷粒商城的性能优化过程中我们积累了不少经验教训。这里分享几个典型的坑坑一缓存key设计不合理初期我们简单使用ID作为key结果出现大量冲突。后来采用业务前缀:ID的格式如product:123清晰且不易冲突。坑二锁粒度过大最初我们使用全局锁来保护库存操作导致性能瓶颈。后来改为按商品ID加锁并发量提升了10倍。坑三过度依赖缓存有一次缓存集群故障直接导致数据库被打垮。现在我们都会做缓存降级方案当Redis不可用时自动切换为本地缓存或直接访问数据库。性能优化是个持续的过程。在完成上述优化后我们的系统在双11期间平稳支撑了每秒5000的订单创建量商品详情页的响应时间从最初的4.8秒降到了200毫秒以内。
谷粒商城性能调优与分布式缓存实战(一)
1. 性能瓶颈诊断与优化思路第一次接触谷粒商城这个项目时系统刚上线就遇到了严重的性能问题。在促销活动期间用户访问商品页面的响应时间经常超过5秒后台数据库CPU使用率直接飙到90%以上。作为技术负责人我当时的第一反应是必须立即找到性能瓶颈所在。性能优化的第一步永远是定位问题。我常用的三板斧是压力测试、性能监控和日志分析。使用JMeter模拟1000并发用户访问商品详情页时TPS每秒事务数只有可怜的150而理想值至少应该在500以上。通过jvisualvm监控发现Tomcat线程大量阻塞在数据库查询上特别是商品分类和商品详情的查询。这里有个重要经验优化前一定要先收集数据。盲目优化往往事倍功半。我记录了三个关键指标平均响应时间4.8秒数据库QPS1200次/秒Redis命中率仅有15%通过这些数据可以明显看出系统过度依赖数据库缓存利用率极低。这为我们指明了第一个优化方向减少数据库访问提升缓存命中率。2. Nginx动静分离实战2.1 为什么需要动静分离在排查性能问题时我发现一个有趣的现象虽然商品详情页是动态内容但页面中80%的请求其实都是静态资源图片、CSS、JS。这些静态资源每次都要经过Tomcat处理造成了巨大的资源浪费。动静分离的核心思想很简单让专业的工具做专业的事。Nginx处理静态资源的性能是Tomcat的5-10倍而Tomcat应该专注于处理动态业务逻辑。在我们的案例中实施动静分离后静态资源的响应时间从原来的800ms降到了50ms左右。2.2 具体配置方案在Nginx中配置动静分离其实很简单关键配置如下server { listen 80; server_name www.gulimall.com; # 静态资源路径 location ~ .*\.(gif|jpg|jpeg|png|css|js|ico)$ { root /opt/static; expires 30d; } # 动态请求转发 location / { proxy_pass http://tomcat_cluster; proxy_set_header Host $host; } }这里有几个优化点值得注意给静态资源设置了30天的缓存过期时间利用浏览器缓存减少请求使用expires指令而非Cache-Control兼容性更好静态资源单独存放在SSD磁盘上I/O性能更好实施后效果立竿见影Nginx的静态资源处理吞吐量达到了8000req/sTomcat的负载下降了40%。3. 多级缓存架构设计3.1 本地缓存与Redis的结合最初我们尝试使用简单的HashMap做本地缓存很快就遇到了两个致命问题在集群环境下缓存无法共享命中率极低数据更新时各节点缓存不一致于是我们引入了多级缓存架构第一层本地Caffeine缓存100ms过期第二层Redis集群缓存30分钟过期第三层数据库这个架构的关键在于缓存过期时间的阶梯式设计。以下是我们的实现代码public Product getProduct(Long id) { // 一级缓存查询 Product product caffeineCache.get(id); if (product ! null) { return product; } // 二级缓存查询 String redisKey product: id; product redisTemplate.opsForValue().get(redisKey); if (product ! null) { caffeineCache.put(id, product); return product; } // 数据库查询 product productMapper.selectById(id); if (product ! null) { redisTemplate.opsForValue().set(redisKey, product, 30, TimeUnit.MINUTES); caffeineCache.put(id, product); } return product; }3.2 缓存问题解决方案在高并发场景下我们遇到了经典的缓存三连问题缓存穿透恶意请求不存在的商品ID解决方案缓存空对象设置短过期时间2分钟缓存雪崩大量缓存同时失效解决方案基础过期时间随机偏移量30±5分钟缓存击穿热点key突然失效解决方案Redisson分布式锁后面会详细讲这里特别提醒缓存空对象时一定要设置较短的过期时间否则会浪费大量内存。我们在实践中发现2分钟是个比较合适的值既防止了穿透又不会占用太多内存。4. Redisson分布式锁深度解析4.1 为什么需要分布式锁在秒杀场景中我们遇到了超卖问题。使用synchronized或者ReentrantLock在单机环境下没问题但在集群环境下完全失效。这就是分布式锁的用武之地。Redisson的分布式锁有几个重要特性互斥性同一时刻只有一个客户端能持有锁可重入性同一个线程可以多次获取同一把锁自动续期看门狗机制防止业务未执行完锁过期高可用支持Redis集群模式4.2 最佳实践代码示例以下是我们在商品库存扣减场景中的实现public boolean deductStock(Long productId, int num) { String lockKey stock:lock: productId; RLock lock redissonClient.getLock(lockKey); try { // 尝试加锁最多等待100ms锁自动释放时间30s boolean locked lock.tryLock(100, 30000, TimeUnit.MILLISECONDS); if (!locked) { return false; } // 业务逻辑 Product product productMapper.selectById(productId); if (product.getStock() num) { product.setStock(product.getStock() - num); productMapper.updateById(product); return true; } return false; } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }这里有几个关键点使用tryLock而非lock避免长时间阻塞设置合理的等待时间和自动释放时间必须在finally块中检查锁归属后再释放4.3 读写锁的应用对于商品详情这种读多写少的场景我们使用了Redisson的读写锁public Product getProductDetail(Long id) { RReadWriteLock lock redissonClient.getReadWriteLock(product:lock: id); RLock rLock lock.readLock(); try { rLock.lock(); // 查询逻辑 return productService.getById(id); } finally { rLock.unlock(); } } public void updateProduct(Product product) { RReadWriteLock lock redissonClient.getReadWriteLock(product:lock: product.getId()); RLock wLock lock.writeLock(); try { wLock.lock(); // 更新逻辑 productService.updateById(product); } finally { wLock.unlock(); } }读写锁的特点是读读不互斥读写互斥写写互斥这种设计可以大幅提升系统的并发读取能力。在我们的测试中使用读写锁后商品详情的读取吞吐量提升了3倍。5. Spring Cache高级应用5.1 缓存一致性解决方案使用Spring Cache时最大的挑战是如何保证缓存与数据库的一致性。我们尝试了两种方案方案一双写模式先更新数据库再删除缓存问题在并发更新时可能出现短暂的不一致方案二Canal监听binlog通过Canal监听数据库变更异步更新缓存优点完全解耦不影响主流程缺点有一定延迟最终我们采用了折中方案对于实时性要求高的场景使用双写模式其他场景使用Canal方案。5.2 自定义缓存配置Spring Cache默认的序列化方式JDK序列化效率低且不直观。我们通过自定义配置改用了JSON格式Configuration EnableCaching public class CacheConfig { Bean public RedisCacheConfiguration redisCacheConfiguration() { return RedisCacheConfiguration.defaultCacheConfig() .serializeKeysWith(RedisSerializationContext.SerializationPair .fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair .fromSerializer(new GenericJackson2JsonRedisSerializer())) .entryTtl(Duration.ofMinutes(30)); } }这个配置做了三件事键使用String序列化值使用JSON序列化设置默认过期时间为30分钟5.3 缓存注解的高级用法在实际开发中我们总结了一些Spring Cache注解的使用技巧Cacheable(value products, key #id, sync true) public Product getProduct(Long id) { // ... } Caching(evict { CacheEvict(value products, key #product.id), CacheEvict(value product:list, allEntries true) }) public void updateProduct(Product product) { // ... }特别说明synctrue可以解决缓存击穿问题内部使用本地锁Caching可以组合多个缓存操作allEntriestrue用于清空整个缓存区域6. 实战经验与避坑指南在谷粒商城的性能优化过程中我们积累了不少经验教训。这里分享几个典型的坑坑一缓存key设计不合理初期我们简单使用ID作为key结果出现大量冲突。后来采用业务前缀:ID的格式如product:123清晰且不易冲突。坑二锁粒度过大最初我们使用全局锁来保护库存操作导致性能瓶颈。后来改为按商品ID加锁并发量提升了10倍。坑三过度依赖缓存有一次缓存集群故障直接导致数据库被打垮。现在我们都会做缓存降级方案当Redis不可用时自动切换为本地缓存或直接访问数据库。性能优化是个持续的过程。在完成上述优化后我们的系统在双11期间平稳支撑了每秒5000的订单创建量商品详情页的响应时间从最初的4.8秒降到了200毫秒以内。