管理主页分类

拖动分类调整顺序,并选择是否在主页显示。设置仅保存在当前浏览器。

  • 微服务课件 31
  • Java 基础 28
  • Java Web 开发 25
  • 多来买 18
  • LeetCode 题解 8
  • 开发工具 8
  • 网络工具 2
  • 黑马头条 2
  • Java 记忆恢复 1

多来买:Bloom、Redis 缓存与异步查询实战

2831 字
14 分钟
多来买:Bloom、Redis 缓存与异步查询实战

多来买:Bloom、Redis 缓存与异步查询实战#

事实来源:当前 product-service、search-service 的缓存、详情聚合、热度与 Redis 相关源码
证据状态:代码已确认,未构建、未启动、未连接 Redis 或 Elasticsearch。
阅读目标:理解详情请求如何先过滤无效 SKU,再通过缓存切面和 CompletableFuture 组织多分支查询。

1. 本文解决什么问题#

商品详情包含多个数据库查询。如果每次请求都回源,热点商品会反复读取同一批数据;若无效 SKU 大量进入,还会形成缓存穿透压力。

当前代码组合了三层机制:

  1. Redisson RBloomFilter 前置判断;
  2. 自定义 @CsmallCache 与 Redis AOP 缓存;
  3. 每次请求创建固定线程池,用 CompletableFuture 编排独立和依赖任务。

本文只解释代码中真实存在的机制,不把它们表述为已压测或生产验证。

2. 主链路#

sequenceDiagram participant C as 客户端 participant D as ProductDetailServiceImpl participant BF as Redis Bloom participant AOP as RedisAspect participant DB as MySQL participant S as Search Service C->>D: getItemBySkuIdAsync(skuId) D->>BF: contains(skuId) alt 明确不存在 BF-->>D: false D-->>C: 空 ProductDetailDTO else 可能存在或 Bloom 异常 D->>AOP: 多个带注解的子查询 alt 缓存命中 AOP-->>D: 缓存结果 else 缓存未命中 AOP->>AOP: 分布式锁 + double check AOP->>DB: 执行真实查询 AOP->>AOP: 空值占位 + 随机 TTL AOP-->>D: 查询结果 end D->>D: allOf(...).join() D->>S: incrHotScore(skuId) D-->>C: 聚合详情 end

3. Bloom 初始化#

源码:duolaimall-product/product-service/src/main/java/com/cskaoyan/mall/product/runner/MyBloomFilter.javaMyBloomFilter#init

@PostConstruct
public void init() {
RBloomFilter<Long> bloomFilter =
redissonClient.getBloomFilter(RedisConst.SKU_BLOOM_FILTER);
long expectedInsertions = 100000;
double falseProbability = 0.01d;
bloomFilter.tryInit(expectedInsertions, falseProbability);
List<Long> allOnSale = skuService.getAllOnSale();
allOnSale.forEach(bloomFilter::add);
}

getAllOnSaleis_sale = 1 为条件,所以初始化集合表示“当时数据库中的已上架 SKU”。

它不是普通 JVM 集合:RBloomFilter 的数据依赖 Redis。

项目中还存在 MyBloomFilterRunner,它对同一个 key 调用 tryInit,但不加载 SKU;两个初始化组件同时存在是当前代码事实。

4. 详情入口怎样使用 Bloom#

源码:duolaimall-product/product-service/src/main/java/com/cskaoyan/mall/product/service/impl/ProductDetailServiceImpl.javagetItemBySkuIdAsync

RBloomFilter<Object> bloomFilter =
redissonClient.getBloomFilter(RedisConst.SKU_BLOOM_FILTER);
boolean contains;
try {
contains = bloomFilter.contains(skuId);
} catch (Exception e) {
log.warn("BloomFilter check failed, fallback to query. skuId={}",
skuId, e);
contains = true;
}
if (!contains) {
return productDetailDTO;
}
Bloom 结果当前处理语义
false返回空详情 DTO明确不在已加载集合中
true继续聚合可能存在,仍需查事实数据
抛异常true 继续Redis/Bloom 故障时保住主查询

标准 Bloom Filter 有假阳性,没有假阴性这一语义只成立于“数据已正确加载且没有丢失”的前提下。

当前边界:

  • 下架没有从 Bloom 中删除 SKU;
  • Bloom 返回 true 后数据库仍可能没有 SKU;
  • 上架更新、启动加载与 Redis 状态可能短暂不同步;
  • Bloom 不是权限和上架状态的最终事实源。

5. 哪些查询进入统一缓存#

方法前缀数据
SkuServiceImpl#getSkuInfoskuInfo:SKU 与图片
CategoryServiceImpl#getCategoryViewByCategoryIdcategory:分类层级
SpuServiceImpl#findSpuPosterBySpuIdspuPoster:SPU 海报
SkuServiceImpl#getSpuSaleAttrListCheckBySkuspuSaleAttr:销售属性与选中态
SpuServiceImpl#getSkuValueIdsMapskuSaleAttrMap:规格组合映射
SkuServiceImpl#getPlatformAttrInfoBySkuskuPlatformAttr:平台属性

getSkuPrice 没有 @CsmallCache,当前实现每次单独查询价格。

这体现出“详情静态信息可缓存、价格保持实时读取”的设计意图,但是否满足业务实时性并未运行验证。

6. 自定义缓存注解#

源码:duolaimall-product/product-service/src/main/java/com/cskaoyan/mall/product/cache/CsmallCache.javaCsmallCache

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CsmallCache {
String prefix();
}

注解只声明 key 前缀,命中、回源、锁和 TTL 都由切面统一处理。

优点是 Service 不重复缓存模板代码;代价是异常语义、空对象策略和 key 格式也被统一绑定。

7. 缓存 key 与第一次读取#

源码:duolaimall-product/product-service/src/main/java/com/cskaoyan/mall/product/cache/RedisAspect.javaRedisAspect#redisCache

MethodSignature signature =
(MethodSignature) joinPoint.getSignature();
Class<?> returnType = signature.getReturnType();
Method method = signature.getMethod();
Object[] args = joinPoint.getArgs();
CsmallCache annotation = method.getAnnotation(CsmallCache.class);
String prefix = annotation.prefix();
String key = prefix + ":" + Arrays.asList(args).toString();
RBucket<Object> bucket = redissonClient.getBucket(key);
Object obj = bucket.get();
if (obj != null) {
return obj;
}

key 由注解前缀和参数列表构成。

部分前缀自身已带冒号,切面又拼接冒号,所以不要在文档或运维脚本中凭经验硬编码 key 文本,应以真实运行 key 为准。

8. 分布式锁与 double check#

源码同上:RedisAspect#redisCache

String lockKey = key + RedisConst.SKULOCK_SUFFIX;
RLock lock = redissonClient.getLock(lockKey);
try {
lock.lock();
obj = bucket.get();
if (obj != null) {
return obj;
}
obj = joinPoint.proceed();
// 后续写入缓存
} finally {
lock.unlock();
}

热点 key 失效时的顺序:

第一次读取未命中
-> 竞争同一 key 的分布式锁
-> 获锁后再次读取
-> 仍未命中才执行真实 Service
-> 写缓存
-> finally 解锁

第二次读取非常关键:其他请求可能在当前请求等待锁时已经完成回源。

当前使用 lock.lock(),调用处没有显式等待上限或固定租期;实际 watchdog 行为由 Redisson 运行配置决定,本文不作推断。

9. 空值与随机 TTL#

源码同上:RedisAspect#redisCache

if (obj == null) {
if (returnType.equals(List.class)) {
obj = new ArrayList<>();
} else if (returnType.equals(Map.class)) {
obj = new HashMap<>();
} else {
try {
Constructor<?> constructor =
returnType.getDeclaredConstructor();
constructor.setAccessible(true);
obj = constructor.newInstance();
} catch (Exception ex) {
obj = null;
}
}
}
long randomTime = RedisConst.SKUKEY_TIMEOUT
+ new Random().nextInt(100);
bucket.set(obj, randomTime, TimeUnit.SECONDS);
缓存问题当前手段
穿透Bloom 前置过滤;空查询写空集合、空 Map 或空对象
击穿Redisson 锁 + double check
雪崩基础 TTL 增加 0~99 秒抖动

需要如实说明:

  • 空值 TTL 常量存在,但切面没有单独使用;
  • 空结果和正常结果使用同一基础 TTL;
  • 普通对象的空实例与真实空对象难以区分;
  • 无参构造失败后 obj 仍可能是 null
  • 没有看到商品修改时统一删除这些详情缓存。

10. 异步依赖图#

flowchart LR Start["skuId"] --> CF1["cf1: SKU 基础信息"] Start --> CF2["cf2: 实时价格"] Start --> CF7["cf7: 平台属性"] CF1 --> CF3["cf3: 分类层级"] CF1 --> CF4["cf4: SPU 海报"] CF1 --> CF5["cf5: 销售属性"] CF1 --> CF6["cf6: 规格组合映射"] CF2 --> Join["allOf + join"] CF3 --> Join CF4 --> Join CF5 --> Join CF6 --> Join CF7 --> Join Join --> Hot["热度 +1"] Hot --> Result["返回详情"]

cf1 是依赖根节点;价格与平台属性只依赖 skuId,可以直接启动。

11. 每个请求创建线程池#

源码:duolaimall-product/product-service/src/main/java/com/cskaoyan/mall/product/service/impl/ProductDetailServiceImpl.javagetItemBySkuIdAsync

ExecutorService executorService = Executors.newFixedThreadPool(8);
try {
// 创建多个 CompletableFuture
} finally {
executorService.shutdown();
}

这能展示异步编排,但每个 HTTP 请求都创建和销毁 8 线程池。

并发请求增加时,线程创建成本和总线程数量可能被放大;当前没有共享有界线程池、任务超时、取消或隔离配置。

12. 根任务与依赖任务#

源码同上:getItemBySkuIdAsync

CompletableFuture<SkuInfoDTO> cf1 =
CompletableFuture.supplyAsync(() -> {
try {
SkuInfoDTO skuInfo = skuService.getSkuInfo(skuId);
productDetailDTO.setSkuInfo(skuInfo);
return skuInfo;
} catch (Exception e) {
throw new CompletionException(e);
}
}, executorService);

SKU 基础查询异常会继续向 Future 传播,因为多个分支依赖它。

分类任务使用 thenAcceptAsync 消费根结果:

CompletableFuture<Void> cf3 = cf1.thenAcceptAsync(skuInfo -> {
if (skuInfo == null) return;
try {
CategoryHierarchyDTO category =
categoryService.getCategoryViewByCategoryId(
skuInfo.getThirdLevelCategoryId());
productDetailDTO.setCategoryHierarchy(category);
} catch (Exception e) {
log.error("Query category failed. skuId={}", skuId, e);
}
}, executorService);

海报、销售属性、规格映射与分类具有同样的“依赖 SKU、内部捕获异常”结构。

13. 独立任务与局部降级#

价格任务:

CompletableFuture<Void> cf2 = CompletableFuture.runAsync(() -> {
try {
BigDecimal price = skuService.getSkuPrice(skuId);
productDetailDTO.setPrice(price);
} catch (Exception e) {
log.error("Query price failed. skuId={}", skuId, e);
}
}, executorService);

异常被捕获后不再抛出,因此详情可能以 price = null 返回。

平台属性也独立查询,并过滤无有效值的属性:

String valueName = Optional.ofNullable(info.getAttrValueList())
.orElse(Collections.emptyList())
.stream()
.filter(Objects::nonNull)
.map(PlatformAttributeValueDTO::getValueName)
.filter(StringUtils::isNotBlank)
.findFirst()
.orElse(null);
if (valueName == null) {
return null;
}

这说明当前详情采用“SKU 根数据失败则整体失败,部分附属字段失败则返回不完整 DTO”的倾向。

但接口没有显式声明哪些字段允许为空,因此这只是从代码得出的行为判断。

14. 等待点与热度上报#

源码同上:getItemBySkuIdAsync

CompletableFuture<Void> all = CompletableFuture.allOf(
cf2, cf3, cf4, cf5, cf6, cf7);
all.whenComplete((v, ex) -> {
if (ex != null) {
log.error("Assembled ProductDetailDTO with exceptions. skuId={}",
skuId, ex);
}
}).join();
try {
searchApiClient.incrHotScore(skuId);
} catch (Exception e) {
log.warn("incrHotScore failed. skuId={}", skuId, e);
}

join 是返回前同步点。多个线程写同一 DTO 的不同字段,代码依赖“字段互不重叠且全部完成后再返回”。

热度 Feign 失败不会阻断详情响应。

15. 热度的 Redis 与 ES 双层状态#

源码:duolaimall-search/search-service/src/main/java/com/cskaoyan/mall/search/service/impl/SearchServiceImpl.javaincrHotScore

RScoredSortedSet<Object> scores =
redissonClient.getScoredSortedSet(RedisConst.HOT_SCORE);
scores.addScore(skuId, 1);
Double score = scores.getScore(skuId);
if (score != null && score % 2 == 0) {
goodsRepository.findById(skuId).ifPresent(goods -> {
goods.setHotScore(score.longValue());
goodsRepository.save(goods);
});
}

每次详情访问先增加 Redis ZSet 分数;分数为 2 的倍数时再写 ES 文档。

因此 Redis 与 ES 热度允许短暂不一致,而且跨 Redis 与 ES 的流程不是原子事务。

16. 失败与并发边界#

场景当前静态行为
Bloom Redis 异常继续数据库查询
Bloom 返回 false直接返回空 DTO
根 SKU 查询异常Future 异常传播,依赖任务受影响
价格或附属分支异常记录日志,可能返回部分字段
缓存回源异常被包装成“Redis 缓存操作异常”
热度 Feign 异常忽略热度失败,详情继续返回
Future 长时间不完成当前没有任务级超时

17. 已确认限制与候选风险#

17.1 已确认限制#

  • Bloom 集合按已上架 SKU 初始化,但下架不删除单项。
  • 缓存切面未使用独立空值 TTL。
  • 商品写操作没有统一的详情缓存失效证据。
  • 每个详情请求新建 8 线程固定池。
  • 附属任务失败可能产生字段不完整的成功响应。
  • Redis 热度每两次访问才同步一次 ES。

17.2 候选风险#

风险触发机制缺失证据
请求线程放大每个并发请求独立创建 8 线程未压测
Future 长时间占用无任务超时或取消未故障注入
陈旧详情缓存传播到 ES上架构建文档也会调用带缓存商品接口未连接 Redis/ES 验证
热度覆盖或延迟Redis 与 ES 分步写且多实例并发未并发验证
空对象误当真实数据通用切面反射构造返回类型未逐调用方验证

18. 如果重做(非当前实现)#

  1. 使用 Spring 托管、可复用、有界且可观测的线程池;
  2. 为每个依赖任务定义超时、降级和必需字段;
  3. 区分正常值 TTL 与空值 TTL;
  4. 用商品变更事件统一删除或重建详情缓存;
  5. 明确 Bloom 重建、增量更新和下架后的业务语义;
  6. 将热度同步改为可重试、可观测的批处理或消息链路。

以上是演进方向,不是当前实现。

19. 关键源码导航#

阅读问题项目相对路径类/方法
Bloom 初始化duolaimall-product/product-service/src/main/java/com/cskaoyan/mall/product/runner/MyBloomFilter.javainit
详情编排duolaimall-product/product-service/src/main/java/com/cskaoyan/mall/product/service/impl/ProductDetailServiceImpl.javagetItemBySkuIdAsync
缓存注解duolaimall-product/product-service/src/main/java/com/cskaoyan/mall/product/cache/CsmallCache.javaCsmallCache
缓存切面duolaimall-product/product-service/src/main/java/com/cskaoyan/mall/product/cache/RedisAspect.javaredisCache
Redis 常量duolaimall-common/common-service/src/main/java/com/cskaoyan/mall/common/constant/RedisConst.javaBloom、TTL、锁后缀
热度接口调用duolaimall-product/product-service/src/main/java/com/cskaoyan/mall/product/client/SearchApiClient.javaincrHotScore
热度实现duolaimall-search/search-service/src/main/java/com/cskaoyan/mall/search/service/impl/SearchServiceImpl.javaincrHotScore

20. 短复习点#

  1. Bloom 的 false 才表示明确不在集合,true 仍要查事实数据。
  2. 缓存切面用锁、double check、空对象和随机 TTL 分别处理常见缓存问题。
  3. 当前异步依赖根是 SKU,价格与平台属性是独立分支。
  4. 每请求创建线程池和缺少任务超时是必须说明的实现边界。
  5. 热度先写 Redis、偶数分数再写 ES,不是跨存储原子更新。

21. 一句话总结#

当前商品详情用 Bloom 减少无效回源、用 Redis 切面保护热点查询、再用 CompletableFuture 并行聚合;思路完整,但线程池生命周期、缓存失效、空值语义和跨存储热度一致性尚未经过运行验证。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

多来买:Bloom、Redis 缓存与异步查询实战
https://firefly-mu-weld.vercel.app/posts/duolaimai-product-cache-bloom-async/
作者
Daisy
发布于
2026-08-03
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
Daisy
Hello, I'm Daisy.
公告
欢迎来到我的博客!这是一则示例公告。
分类
标签

文章目录