电商系统商品类目树性能演进之路

1835 字 约 7 分钟

在电商系统中,商品类目树是一个访问频次极高、但数据相对稳定的核心模块。它的性能直接影响用户体验和系统吞吐量。以下是一套完整的,从原始数据库查询到高并发、低延迟架构的典型演进方案。

初始阶段:数据库直连

项目初期,业务逻辑简单,我们采用最直接的方式从数据库中读取类目数据并构建树形结构。

  • 问题诊断:

    1. 数据库压力: 每次请求都需要访问数据库,高并发下会给数据库造成巨大压力。
    2. 性能瓶颈: 类目树通常是层级结构,查询时常采用递归查询或多次Join,当层级加深、类目增多时,单次查询的耗时会显著增加,成为系统性能瓶颈。
    3. IO密集: 数据库查询是典型的IO密集型操作,响应延迟较高。
  • 架构图:

        graph TD
        A[客户端] --> B{应用服务};
        B --> C[(数据库)];

第一次优化:引入分布式缓存(Cache-Aside Pattern)

为了降低数据库负载,提升响应速度,我们引入了分布式缓存(如Redis)。

  • 问题诊断:

    • 解决初始阶段的数据库性能瓶颈。
  • 解决方案:

    • 采用经典的 Cache-Aside(旁路缓存) 模式。
    • 查询流程:应用先请求Redis,如果命中(Cache Hit),则直接返回数据;如果未命中(Cache Miss),则查询数据库,将查询结果放入Redis并设置一个合理的过期时间(TTL, Time-To-Live),最后再返回给客户端。
  • 代码示例 (伪代码):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    @Service
    public class CategoryServiceImpl implements CategoryService {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    @Autowired
    private CategoryMapper categoryMapper;

    private static final String CACHE_KEY = "category:tree";
    private static final long CACHE_TTL = 3600; // 1 hour

    @Override
    public List<CategoryNode> getCategoryTree() {
    // 1. 从Redis查询
    String cachedTreeJson = redisTemplate.opsForValue().get(CACHE_KEY);

    if (StringUtils.hasText(cachedTreeJson)) {
    // 缓存命中,反序列化后返回
    return JSON.parseObject(cachedTreeJson, new TypeReference<List<CategoryNode>>(){});
    }

    // 2. 缓存未命中,查询数据库
    // 加锁防止缓存击穿 (DCL - Double-Checked Locking)
    synchronized (this) {
    // 再次检查缓存,因为可能在等待锁的过程中,其他线程已经刷新了缓存
    cachedTreeJson = redisTemplate.opsForValue().get(CACHE_KEY);
    if (StringUtils.hasText(cachedTreeJson)) {
    return JSON.parseObject(cachedTreeJson, new TypeReference<List<CategoryNode>>(){});
    }

    List<CategoryNode> dbResult = categoryMapper.buildTree();

    // 3. 将结果写入Redis
    if (dbResult != null && !dbResult.isEmpty()) {
    String jsonToCache = JSON.toJSONString(dbResult);
    redisTemplate.opsForValue().set(CACHE_KEY, jsonToCache, CACHE_TTL, TimeUnit.SECONDS);
    }
    return dbResult;
    }
    }
    }
  • 引入的新问题:

    1. 缓存雪崩 (Cache Avalanche): 如果大量key在同一时间集中过期,所有请求将同时穿透到数据库,造成瞬间压力。
    2. 缓存击穿 (Cache Breakdown): 对于热点Key,在过期失效的瞬间,大量并发请求会直接打到数据库。上述代码中的DCL锁就是为了解决这个问题。
    3. 缓存穿透 (Cache Penetration): 查询一个不存在的数据,导致每次请求都穿透到数据库。可通过缓存空值或布隆过滤器解决。
    4. 首次加载延迟: 缓存过期后,第一个请求需要等待数据库查询和缓存写入,响应时间较长。
  • 流程图:

        flowchart TD
        Start --> CheckRedis{查询Redis缓存}
        CheckRedis -->|命中| Deserialize{反序列化JSON}
        Deserialize --> End
        CheckRedis -->|未命中| SyncLock{获取分布式锁}
        SyncLock -->|获取成功| DoubleCheckRedis{再次查询Redis}
        DoubleCheckRedis -->|命中| UnlockAndReturn{释放锁并返回}
        DoubleCheckRedis -->|未命中| QueryDB{查询数据库}
        QueryDB --> WriteRedis{结果写入Redis}
        WriteRedis --> UnlockAndReturn
        SyncLock -->|获取失败| WaitAndRetry{等待后重试/直接返回空}
        WaitAndRetry --> CheckRedis
        UnlockAndReturn --> End

第二次优化:定时任务主动预热缓存

为了解决缓存过期后的首次加载延迟问题,并规避缓存雪崩的风险。

  • 问题诊断:

    • 缓存过期策略是被动的,总会有一个请求承担较慢的响应。
    • TTL固定容易导致缓存雪崩。
  • 解决方案:

    • 添加一个定时任务(Scheduled Job),在缓存即将过期前,主动从数据库加载最新数据并刷新到Redis中。
    • 这种方式称为 缓存预热(Cache Pre-heating) 或 主动缓存刷新。
    • 可以将缓存的TTL设置得比定时任务周期稍长一些(例如,任务每55分钟执行一次,缓存有效期设置为60分钟),确保缓存永远不过期,或者将TTL设置为永久,完全由定时任务来管理更新。
  • 代码示例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    @Component
    public class CategoryCacheRefresher {

    @Autowired
    private CategoryService categoryService; // 注入上面重构后的Service

    private static final String REFRESH_LOCK_KEY = "lock:category:refresh";

    // 使用分布式锁确保多实例下只有一个执行刷新任务
    @Autowired
    private RedissonClient redissonClient;

    // 每小时的第55分钟执行
    @Scheduled(cron = "0 55 * * * ?")
    public void refreshCategoryTreeCache() {
    RLock lock = redissonClient.getLock(REFRESH_LOCK_KEY);
    boolean acquired = false;
    try {
    // 尝试获取锁,等待0秒,获取后300秒自动释放
    acquired = lock.tryLock(0, 300, TimeUnit.SECONDS);
    if (acquired) {
    log.info("获取到分布式锁,开始刷新类目树缓存...");
    // 直接调用数据库查询并刷新缓存的逻辑
    categoryService.refreshCacheFromDB();
    }
    } catch (InterruptedException e) {
    log.error("获取分布式锁时被中断", e);
    Thread.currentThread().interrupt();
    } finally {
    if (acquired && lock.isHeldByCurrentThread()) {
    lock.unlock();
    }
    }
    }
    }
  • 引入的新问题:

    • 数据一致性: 数据更新存在延迟,最长延迟为一个刷新周期。对于类目树这种变更不频繁的场景,可以接受。
    • 应用吞吐量瓶颈: 虽然DB压力没了,但所有请求都经过Redis,网络IO和Redis单机性能成为新的瓶颈,导致应用整体QPS(Queries Per Second)提升有限。

第三次优化:引入多级缓存(JVM本地缓存 + Redis)

为了解决Redis的网络IO瓶颈,进一步提升QPS和降低响应延迟。

  • 问题诊断:

    • 每次请求都访问Redis,存在网络开销和序列化/反序列化开销。
    • 高并发下,应用服务器到Redis服务器的网络带宽和Redis的QPS可能成为瓶颈。
  • 解决方案:

    • 引入 多级缓存 架构:L1 Cache (JVM本地缓存) + L2 Cache (Redis)。
    • 使用高性能的本地缓存框架,如Google Guava Cache或Caffeine。
    • 查询顺序: 本地缓存 -> Redis -> 数据库。
    • 数据一致性是关键: 当缓存更新时(例如后台修改了类目),如何保证所有应用实例的本地缓存都失效?答案是使用消息队列(如Redis的Pub/Sub)进行通知。
  • 架构图:

        graph TD
        subgraph 应用服务器1
            App1[应用实例1]
            L1_Cache1[L1: Caffeine]
            App1 --> L1_Cache1
        end
        subgraph 应用服务器2
            App2[应用实例2]
            L1_Cache2[L1: Caffeine]
            App2 --> L1_Cache2
        end
    
        Client --> App1
        Client --> App2
    
        L1_Cache1 -- Miss --> Redis
        L1_Cache2 -- Miss --> Redis
    
        Redis[L2: Redis] -- Miss --> DB[(数据库)]
    
        Scheduler[定时任务] --> DB
        Scheduler --> Redis
        Scheduler -- 发布消息 --> PubSub[Redis Pub/Sub]
    
        PubSub -- 通知 --> App1
        PubSub -- 通知 --> App2
    
        App1 -- 订阅 --> PubSub
        App2 -- 订阅 --> PubSub
  • 引入的新问题:

    • 数据载体过大 (Large Payload): 整个类目树作为一个巨大的JSON对象返回,会消耗大量网络带宽(服务器 -> 客户端),并增加客户端(浏览器)的解析负担,导致前端渲染变慢。
    • Redis大Key问题: 将整个树存成一个Key,可能导致Redis出现”大Key”,操作耗时变长,且在集群模式下造成数据倾斜。

第四次优化:传输层与数据结构优化

针对数据载体过大和Redis大Key问题进行专项优化。

  • 问题诊断:

    1. 网络传输效率低: 未压缩的JSON文本数据量大。
    2. 前端处理慢: 浏览器下载和解析大型JSON耗时。
  • 解决方案:

    • 开启Gzip压缩: 在网关层(如Nginx)或Web服务器(如Tomcat)开启Gzip压缩。HTTP头部会包含Content-Encoding: gzip,浏览器会自动解压。这能将传输数据量减少60%-80%。
  • Nginx配置示例:

    1
    2
    3
    4
    5
    6
    7
    8
    http {
    gzip on;
    gzip_min_length 1k;
    gzip_comp_level 2;
    gzip_types text/plain application/javascript application/x-javascript text/css application/xml application/json;
    gzip_vary on;
    gzip_disable "msie6";
    }
  • 此阶段的优化重点在于网络传输,不涉及应用架构的改变。


第五次优化:数据瘦身与Redis大Key拆分

这是更深层次的优化,从数据本身入手,彻底解决Payload过大和Redis大Key问题。

  • 问题诊断:

    1. Redis大Key: 单个Key存储的Value过大(例如超过1MB),会阻塞Redis、删除/更新困难、影响集群均衡。
    2. 数据冗余: 返回给前端的DTO对象可能包含许多非必需字段。
  • 解决方案:

    1. 数据瘦身 (Data Slimming):

      • 字段裁剪: 创建一个专门用于前端展示的VO(View Object),只包含绝对必要的字段(如id, name, parentId)。
      • 字段名简化: 将JSON中的长字段名(如categoryName)替换为短字段名(如n),并提供一份字段映射表给前端。这能有效减小JSON体积。
      • {"id":1,"name":"Electronics","children":[]} vs {"i":1,"n":"E","c":[]}
    2. 大Key拆分:

      • 放弃全量树结构: 不再将整棵树存为一个大JSON。
      • 按节点存储: 将每个类目节点或每个父节点下的子节点列表作为独立的Key存储在Redis中。
      • 例如,使用Hash结构:hset category:node 1 '{"id":1, "name":"手机"}'
      • 或者使用String/Set结构:set category:children:1 '[2,3,4]' (存储ID列表)
      • 前端按需加载: 前端首次只加载顶级类目,当用户展开某个类目时,再异步请求其子类目。这极大地降低了首次加载的数据量和渲染压力。
  • 最终架构总结:
    这是一个综合了多级缓存、主动刷新、消息通知、数据压缩和结构优化的成熟方案。它兼顾了高性能、高可用和高扩展性,能够轻松应对高并发的电商场景。

通过这一系列演进,我们从一个简单的数据库查询,逐步构建了一个健壮、高效的类目服务体系。每一步优化都针对前一阶段引入的新问题,这正是架构演进的魅力所在。