商品服务架构实战:多数据源切换与分级缓存设计全解析

商品服务架构实战:多数据源切换与分级缓存设计全解析

在电商商品服务中,高并发、海量数据、复杂查询是三大核心挑战。本文将结合真实生产经验,从多数据源切换商品详情页分级缓存两个维度,深入剖析实现方案、痛点与最佳实践。内容涵盖:三种多数据源切换方式对比、SPU/SKU 数据模型设计、本地+Redis+DB 三级缓存架构、缓存三大问题(穿透/击穿/雪崩)的解决方案,以及基于双删、MQ、Canal 的最终一致性保障。

📌 本文代码示例基于 Spring Boot + MyBatis-Plus + Redis + Caffeine,适用于电商商品服务、内容详情页等读多写少场景。


一、商品服务数据模型与多数据源场景

1.1 核心数据模型

商品服务通常涉及以下实体:

实体 说明 关联关系
品牌 (Brand) 商品所属品牌 1 对多关联 SPU
分类 (Category) 商品分类(树形结构) 1 对多关联 SPU
SPU (Standard Product Unit) 标准产品单元(如 iPhone 14) 关联品牌、分类
SKU (Stock Keeping Unit) 库存量单元(如 iPhone 14 黑色 128G) 属于某个 SPU,拥有规格属性
规格属性 (Spec/Attribute) 规格名(颜色、内存)与规格值 多对多关联 SKU/SPU

典型数据库设计简图:
has
belongs
contains
has
defines
BRAND
SPU
CATEGORY
SKU
SPEC_VALUE
SPEC_NAME

1.2 为什么需要多数据源?

  • 读写分离:主库处理写操作(下单、库存扣减),从库处理读操作(商品详情、列表查询),降低主库压力。
  • 分库分表:商品数据量大,可按品类或哈希分库。
  • 多业务隔离:商品核心库、搜索库、报表库分离。

下面介绍三种主流多数据源切换实现方式。


二、多数据源切换的三种实现方案

2.1 方案一:自定义注解 + AOP(轻量级)

原理 :通过 @DataSource 注解标记方法或类,AOP 切面在方法执行前动态设置 ThreadLocal 中的数据源标识,最后在 AbstractRoutingDataSource 中路由。

实现步骤

  1. 定义注解 @DataSource("slave")
  2. AOP 切面:@Around@Before,将注解值存入 DataSourceContextHolder
  3. 继承 AbstractRoutingDataSource,重写 determineCurrentLookupKey() 返回当前线程的数据源 key。
  4. 配置多个数据源。
java 复制代码
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface DataSource {
    String value() default "master";
}

@Component
@Aspect
public class DataSourceAspect {
    @Before("@annotation(dataSource)")
    public void switchDataSource(JoinPoint point, DataSource dataSource) {
        DataSourceContextHolder.setDataSource(dataSource.value());
    }
    @After("@annotation(dataSource)")
    public void restoreDataSource(DataSource dataSource) {
        DataSourceContextHolder.clear();
    }
}

优点 :灵活、无侵入、轻量。
缺点 :需要手动维护 ThreadLocal,事务环境下数据源切换可能失效(需注意事务传播)。

2.2 方案二:MyBatis-Plus 动态数据源(官方推荐)

MyBatis-Plus 提供了 dynamic-datasource 组件,只需配置多数据源并使用 @DS 注解即可。

配置示例

yaml 复制代码
spring:
  datasource:
    dynamic:
      primary: master
      strict: false
      datasource:
        master:
          url: jdbc:mysql://master:3306/product
          username: root
        slave_1:
          url: jdbc:mysql://slave1:3306/product
          username: root
        slave_2:
          url: jdbc:mysql://slave2:3306/product

使用

java 复制代码
@DS("slave_1")
@Mapper
public interface SkuMapper extends BaseMapper<Sku> {
    // 读从库
}

优点 :配置简单、支持 Seata 分布式事务、内置负载均衡(轮询/随机等)。
缺点:依赖 MyBatis-Plus 生态,非 MyBatis-Plus 项目需引入额外依赖。

2.3 方案三:ShardingJDBC(最强悍)

Apache ShardingJDBC 不仅支持读写分离,还支持分库分表、分布式事务。适用于超大规模商品数据。

配置示例(读写分离 + 分片):

yaml 复制代码
spring:
  shardingsphere:
    datasource:
      names: master,slave1,slave2
      master:
        type: HikariDataSource
        url: jdbc:mysql://master:3306/product
      # ... slave 配置
    rules:
      readwrite-splitting:
        data-sources:
          product_ds:
            type: Static
            props:
              write-data-source-name: master
              read-data-source-names: slave1,slave2
            load-balancer-name: round_robin
    props:
      sql-show: true

优点 :功能最全(分片、加密、影子库),对业务代码零侵入。
缺点:学习曲线较陡,部分复杂 SQL 可能需改写。

2.4 方案对比与选择建议

方案 适用场景 学习成本 功能丰富度
自定义注解+AOP 简单读写分离,团队技术掌控力强
MyBatis-Plus @DS 基于 MyBatis-Plus 的项目,希望快速集成
ShardingJDBC 分库分表 + 读写分离,数据量极大

商品服务建议:初期可用 MyBatis-Plus 动态数据源,后期数据量增长至亿级时迁移到 ShardingJDBC。


三、商品详情页分级缓存架构

商品详情页是典型的高并发读场景,请求量可达数万 QPS。我们采用 本地缓存(Caffeine)→ Redis → DB 三级缓存,结构如下:
命中
未命中
命中
未命中
客户端请求详情页
Caffeine 本地缓存
返回 HTML / JSON
Redis 集群
回填 Caffeine
查询 MySQL
回填 Redis + Caffeine

3.1 关键问题:毛刺现象(Thundering Herd)

当本地缓存设置了固定过期时间(如 30 秒),大量 Key 同时失效时,瞬间请求会穿透到 Redis,造成网络 IO 拥堵,RT 从 1ms 飙升至 20ms+,形成"毛刺"。

解决方案(引用前文门户服务的双缓存机制)

  • 主缓存(Caffeine):永不过期,由后台定时任务从 Redis 刷新。
  • 备份缓存 (Caffeine):expireAfterAccess(5min),仅作为防穿透兜底,不主动回填主缓存。
  • 定时任务:每隔 1 分钟拉取热点 Key 的最新数据,更新主缓存。

具体流程参见《门户服务缓存优化》一文,此处不再赘述。

3.2 Redis 三大缓存问题及对策

问题 描述 商品服务中的解法
缓存穿透 查询不存在的数据(如恶意请求不存在的 SKU ID),请求直达 DB ① 布隆过滤器(Bloom Filter)预判 ID 有效性;② 空结果缓存(TTL 短,如 1 分钟)
缓存击穿 某个热点 Key 在 Redis 失效瞬间,大量并发请求打到 DB ① 互斥锁(Redis setnx);② 逻辑过期(主动刷新,不设物理 TTL)
缓存雪崩 大量 Key 同时到期,或 Redis 宕机,请求全部压垮 DB ① 随机 TTL(基础 TTL ± 随机值);② 多级缓存(本地兜底);③ Redis 集群高可用

互斥锁解决击穿代码示例

java 复制代码
public SkuDetail getSkuDetail(Long skuId) {
    String cacheKey = "sku:" + skuId;
    // 1. 查 Redis
    SkuDetail detail = redisTemplate.opsForValue().get(cacheKey);
    if (detail != null) return detail;

    // 2. 尝试获取分布式锁
    String lockKey = "lock:sku:" + skuId;
    Boolean locked = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, "1", Duration.ofSeconds(3));
    if (Boolean.TRUE.equals(locked)) {
        try {
            // 3. 查询 DB(只让一个线程执行)
            detail = skuMapper.selectDetail(skuId);
            if (detail != null) {
                redisTemplate.opsForValue().set(cacheKey, detail, 
                    randomTtl(), TimeUnit.SECONDS);
            } else {
                // 缓存空对象(防穿透)
                redisTemplate.opsForValue().set(cacheKey, "", 
                    Duration.ofSeconds(60));
            }
        } finally {
            redisTemplate.delete(lockKey);
        }
    } else {
        // 4. 未获得锁的线程:短暂休眠后重试
        try { Thread.sleep(50); } catch (InterruptedException e) {}
        return getSkuDetail(skuId);
    }
    return detail;
}

四、Redis 与 DB 数据一致性方案

商品服务中,库存、价格、上下架状态等数据会频繁更新。如何保证 Redis 缓存与 MySQL 最终一致,同时不影响业务性能?以下是三种主流方案。

4.1 延时双删(简单,适合低并发)

流程

  1. 更新数据库。
  2. 删除 Redis 缓存。
  3. 休眠 500ms(业务容忍时间)。
  4. 再次删除 Redis 缓存。

作用:避免并发请求在第一次删除后、第二次删除前将旧数据重新载入。

java 复制代码
@Transactional
public void updatePrice(Long skuId, BigDecimal newPrice) {
    skuMapper.updatePrice(skuId, newPrice);
    redisTemplate.delete("sku:" + skuId);
    try { Thread.sleep(500); } catch (InterruptedException ignored) {}
    redisTemplate.delete("sku:" + skuId);
}

缺点:休眠时间难以精确控制,对高延时敏感业务不友好。

4.2 MQ 异步补偿(可靠)

架构
失败
更新 DB
发送 MQ 消息
MQ Broker
消费者
删除 Redis 或更新缓存
重试队列

优点 :解耦、保证最终成功。
缺点:引入消息队列,增加运维成本。

4.3 Canal 监听 Binlog(最强一致性)

Canal 伪装成 MySQL 从库,解析 binlog 变更事件,然后异步更新 Redis。
binlog
发送变更消息
消费
MySQL
Canal
MQ
RedisCacheUpdater
Redis

优点 :完全解耦业务代码,无侵入;数据一致性近乎实时。
缺点:部署 Canal 组件,对 DBA 能力有要求。

4.4 方案对比

方案 实时性 业务侵入性 复杂度 推荐场景
延时双删 ~ms 低并发,可容忍短时不一致
MQ 异步 ~100ms 常规读写分离场景
Canal 实时 核心数据强一致性,多服务依赖同一数据源

商品服务建议 :价格、库存对一致性要求较高,采用 MQ 方案Canal;商品描述、图片等对一致性要求低,可用延时双删。


五、总结与最佳实践

问题 解决方案 核心要点
多数据源切换 MyBatis-Plus @DS 或 ShardingJDBC 读写分离,从库负载均衡
本地缓存毛刺 主缓存(永不过期)+ 备份缓存 + 定时刷新 避免批量失效
缓存穿透 布隆过滤器 + 空对象缓存 提前拦截非法 ID
缓存击穿 互斥锁 / 逻辑过期 防止热点 Key 重建 DB
缓存雪崩 随机 TTL + 多级缓存 + Redis 集群 打散过期时间
最终一致性 延时双删(简单)/ MQ(可靠)/ Canal(最强) 根据业务要求选择

商品服务整体架构图
一致性组件
数据层
缓存层
应用层
接入层
负载均衡
API网关
商品服务
Caffeine 本地缓存
动态数据源路由
Redis 集群
MySQL 主库
MySQL 从库1
MySQL 从库2
MySQL
Canal
消息队列

相关推荐
志栋智能40 分钟前
超自动化巡检如何生成“有灵魂”的运维报告?
大数据·运维·人工智能·架构·自动化
草莓熊Lotso44 分钟前
【Redis 进阶】主从复制深度解析:从配置落地到 PSYNC 同步原理
linux·开发语言·网络·数据库·redis·缓存·php
梦帮科技2 小时前
从 Guest 到 Platinum:Prompt 配额、API Key 与访问控制的安全闭环
javascript·git·架构·node.js·reactjs·html5·visual studio
白远山2 小时前
健身场馆无人自动化系统实战指南:从架构设计到部署全流程解析
java·开发语言·架构·需求分析
咖啡八杯3 小时前
Controller 基类封装:BaseController 的模板方法设计
java·架构·设计
她的男孩3 小时前
加了 @Idempotent 还是重复扣款了 3 笔:扒完 1279 行幂等 Starter,我挖出 5 个隐蔽的坑
java·后端·架构
pe7er3 小时前
引子
前端·后端·架构
梦帮科技3 小时前
从提示词到可复现音乐工作流:RNOISE 2.0 的 Prompt Engineering 架构
人工智能·python·mysql·ci/cd·架构·node.js·numpy
AI 小老六4 小时前
Agent 记忆系统难在取舍
人工智能·算法·架构·agent·memory·harness