商品服务架构实战:多数据源切换与分级缓存设计全解析
在电商商品服务中,高并发、海量数据、复杂查询是三大核心挑战。本文将结合真实生产经验,从多数据源切换 和商品详情页分级缓存两个维度,深入剖析实现方案、痛点与最佳实践。内容涵盖:三种多数据源切换方式对比、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 中路由。
实现步骤:
- 定义注解
@DataSource("slave")。 - AOP 切面:
@Around或@Before,将注解值存入DataSourceContextHolder。 - 继承
AbstractRoutingDataSource,重写determineCurrentLookupKey()返回当前线程的数据源 key。 - 配置多个数据源。
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 延时双删(简单,适合低并发)
流程:
- 更新数据库。
- 删除 Redis 缓存。
- 休眠 500ms(业务容忍时间)。
- 再次删除 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
消息队列