1. 项目背景与核心价值
这个点评项目改造笔记源于我在实际工作中的一次系统重构经历。当时我们接手了一个运行三年的老牌点评系统,技术栈陈旧、性能瓶颈明显,日均处理请求量从最初的5万激增到120万。作为核心开发人员,我决定将整个改造过程记录下来,既作为团队知识沉淀,也希望能帮助遇到类似问题的同行。
这种技术笔记的价值在于:它不同于官方文档的理想化案例,而是真实记录了从问题定位、方案选型到具体实施的全过程,包含大量教科书上不会写的"脏细节"。比如在数据库分片时如何兼顾查询效率与事务一致性,缓存穿透时的应急处理方案,以及灰度发布过程中那些只有踩过坑才知道的配置技巧。
2. 架构改造的核心思路
2.1 现存问题诊断
原系统主要存在三个致命问题:
- 单体架构耦合严重,用户服务崩溃会导致整个系统不可用
- MySQL单表数据量超过2000万条,查询延迟高达800ms
- 缓存策略粗放,热门商家页面QPS峰值时Redis CPU飙升至90%
通过APM工具定位到核心瓶颈:
- 评价列表查询占数据库负载的63%
- 地理位置计算消耗了45%的CPU资源
- 分布式锁竞争导致20%的请求超时
2.2 技术选型对比
针对上述问题,我们评估了三种改造方案:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 整体重构 | 架构干净 | 周期长(6个月+) | 全新项目 |
| 渐进式改造 | 风险可控 | 技术债需持续偿还 | 业务稳定期 |
| 服务拆分 | 快速见效 | 分布式复杂度高 | 急需扩容 |
最终选择服务拆分+功能降级的混合方案:
- 先拆用户/商家/评价三个核心服务
- 非核心功能(如标签计算)做异步化处理
- 保留单体架构的支付模块(强事务需求)
3. 关键技术实现细节
3.1 数据库分库分表实战
评价表按照商家ID哈希分片,但面临跨分片查询难题。我们的解决方案:
-- 原单体表查询 SELECT * FROM reviews WHERE shop_id IN (1,5,8) ORDER BY create_time DESC LIMIT 10; -- 改造后方案 -- 步骤1:通过分布式ID路由确定分片 SELECT shard_id FROM shop_sharding WHERE shop_id IN (1,5,8); -- 步骤2:并行查询各分片(代码示例) @Repository public class ReviewRepository { @ShardingSelect(strategy = "parallel") public List<Review> queryByShopIds(List<Long> shopIds) { // 实际会路由到不同分片执行 return reviewMapper.selectByShopIds(shopIds); } } -- 步骤3:内存归并排序 Collections.sort(allResults, (a,b) -> b.getCreateTime().compareTo(a.getCreateTime()));关键配置参数:
- 分片数=16(预留3年增长空间)
- 连接池maxActive=50(避免分片爆炸)
- 归并排序内存限制=512MB
3.2 缓存架构升级
采用多级缓存策略:
- 本地Caffeine缓存(5分钟过期)
- Redis集群缓存(30分钟过期 + 随机抖动)
- 回源数据库时的互斥锁机制
防雪崩配置示例:
# application-cache.yml caffeine: spec: maximumSize=10000,expireAfterWrite=5m redis: expire-time: 1800 jitter-range: 300 # 随机增加0-300秒过期时间4. 性能优化实战记录
4.1 地理位置计算优化
原方案:每次请求计算用户与商家的距离
// 原始Haversine公式计算 public double calculateDistance(double lat1, double lon1, double lat2, double lon2) { // 大量三角函数计算... }优化方案:
- 预处理商家地理网格编码(GeoHash)
- 建立R树索引
- 缓存用户常驻区域的热门商家
改造后性能对比:
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| 计算耗时 | 28ms | 2ms | 14x |
| CPU占用 | 15% | 1.2% | 12.5x |
| 内存消耗 | 无 | 增加800MB | - |
4.2 评价列表渲染加速
发现原生MyBatis的N+1查询问题:
<!-- 错误示范 --> <select id="selectReviews" resultMap="reviewMap"> SELECT * FROM reviews WHERE shop_id = #{shopId} </select> <resultMap id="reviewMap" type="Review"> <association property="user" select="selectUser" column="user_id"/> </resultMap>改造为联合查询+DTO投影:
@Mapper public interface ReviewMapper { @Select("SELECT r.*, u.nickname, u.avatar FROM reviews r " + "JOIN users u ON r.user_id = u.id " + "WHERE r.shop_id = #{shopId}") List<ReviewDTO> selectReviewWithUser(@Param("shopId") Long shopId); }5. 线上问题排查实录
5.1 缓存穿透事故
现象:某商家遭遇恶意刷单,导致缓存大量失效
- 监控指标:Redis命中率从98%暴跌至12%
- 数据库QPS从200激增到8500
应急处理步骤:
- 紧急限流(Sentinel配置QPS=500)
- 布隆过滤器拦截无效ID查询
- 缓存空值(注意设置短过期时间)
// 防御性代码示例 public Review getReviewWithShield(Long id) { // 先查布隆过滤器 if (!bloomFilter.mightContain(id)) { return null; } // 再查缓存 Review review = cacheService.get(id); if (review == NULL_OBJECT) { return null; // 缓存的空值 } if (review == null) { // 回源查询 review = repository.findById(id); cacheService.set(id, review != null ? review : NULL_OBJECT); } return review; }5.2 分布式锁陷阱
错误实现:
// 错误示范:锁过期时间小于业务执行时间 public void updateShopStats(Long shopId) { String lockKey = "lock:stats:" + shopId; try { boolean locked = redisLock.tryLock(lockKey, 3, TimeUnit.SECONDS); if (locked) { // 复杂统计计算耗时5秒 calculateStats(shopId); } } finally { redisLock.unlock(lockKey); // 此时锁已自动过期! } }正确方案:
- 使用Redisson看门狗机制
- 添加线程标识防误删
- 设置合理的锁超时时间
public void safeUpdateShopStats(Long shopId) { RLock lock = redisson.getLock("lock:stats:" + shopId); try { if (lock.tryLock(5, 30, TimeUnit.SECONDS)) { try { calculateStats(shopId); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }6. 改造效果与经验总结
6.1 性能指标对比
上线三个月后的数据:
| 指标 | 改造前 | 改造后 | 变化 |
|---|---|---|---|
| 平均响应时间 | 320ms | 89ms | ↓72% |
| 错误率 | 1.2% | 0.05% | ↓96% |
| 服务器数量 | 12台 | 8台 | ↓33% |
| 扩容耗时 | 4小时 | 15分钟 | ↓94% |
6.2 血泪经验
- 分库分表一定要提前预留足够的分片数,我们最初只分了8个片,半年后就不得不二次扩容
- 缓存空值时务必设置较短TTL(建议30秒),否则可能引发数据一致性问题
- 分布式追踪要贯穿所有服务,我们曾因某个第三方服务没传traceId导致两天无法定位问题
- 灰度发布时要监控上下游所有指标,包括依赖的中间件和外部服务
一个特别容易忽略的点:当引入新缓存层时,务必检查所有写操作是否同步更新了缓存。我们曾因为商品修改后未清除本地缓存,导致用户看到旧数据长达5分钟。