多来买:Bloom、Redis 缓存与异步查询实战
多来买:Bloom、Redis 缓存与异步查询实战
事实来源:当前 product-service、search-service 的缓存、详情聚合、热度与 Redis 相关源码
证据状态:代码已确认,未构建、未启动、未连接 Redis 或 Elasticsearch。
阅读目标:理解详情请求如何先过滤无效 SKU,再通过缓存切面和CompletableFuture组织多分支查询。
1. 本文解决什么问题
商品详情包含多个数据库查询。如果每次请求都回源,热点商品会反复读取同一批数据;若无效 SKU 大量进入,还会形成缓存穿透压力。
当前代码组合了三层机制:
- Redisson
RBloomFilter前置判断; - 自定义
@CsmallCache与 Redis AOP 缓存; - 每次请求创建固定线程池,用
CompletableFuture编排独立和依赖任务。
本文只解释代码中真实存在的机制,不把它们表述为已压测或生产验证。
2. 主链路
3. Bloom 初始化
源码:duolaimall-product/product-service/src/main/java/com/cskaoyan/mall/product/runner/MyBloomFilter.java,MyBloomFilter#init。
@PostConstructpublic 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);}getAllOnSale 以 is_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.java,getItemBySkuIdAsync。
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#getSkuInfo | skuInfo: | SKU 与图片 |
CategoryServiceImpl#getCategoryViewByCategoryId | category: | 分类层级 |
SpuServiceImpl#findSpuPosterBySpuId | spuPoster: | SPU 海报 |
SkuServiceImpl#getSpuSaleAttrListCheckBySku | spuSaleAttr: | 销售属性与选中态 |
SpuServiceImpl#getSkuValueIdsMap | skuSaleAttrMap: | 规格组合映射 |
SkuServiceImpl#getPlatformAttrInfoBySku | skuPlatformAttr: | 平台属性 |
getSkuPrice 没有 @CsmallCache,当前实现每次单独查询价格。
这体现出“详情静态信息可缓存、价格保持实时读取”的设计意图,但是否满足业务实时性并未运行验证。
6. 自定义缓存注解
源码:duolaimall-product/product-service/src/main/java/com/cskaoyan/mall/product/cache/CsmallCache.java,CsmallCache。
@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.java,RedisAspect#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. 异步依赖图
cf1 是依赖根节点;价格与平台属性只依赖 skuId,可以直接启动。
11. 每个请求创建线程池
源码:duolaimall-product/product-service/src/main/java/com/cskaoyan/mall/product/service/impl/ProductDetailServiceImpl.java,getItemBySkuIdAsync。
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.java,incrHotScore。
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. 如果重做(非当前实现)
- 使用 Spring 托管、可复用、有界且可观测的线程池;
- 为每个依赖任务定义超时、降级和必需字段;
- 区分正常值 TTL 与空值 TTL;
- 用商品变更事件统一删除或重建详情缓存;
- 明确 Bloom 重建、增量更新和下架后的业务语义;
- 将热度同步改为可重试、可观测的批处理或消息链路。
以上是演进方向,不是当前实现。
19. 关键源码导航
| 阅读问题 | 项目相对路径 | 类/方法 |
|---|---|---|
| Bloom 初始化 | duolaimall-product/product-service/src/main/java/com/cskaoyan/mall/product/runner/MyBloomFilter.java | init |
| 详情编排 | duolaimall-product/product-service/src/main/java/com/cskaoyan/mall/product/service/impl/ProductDetailServiceImpl.java | getItemBySkuIdAsync |
| 缓存注解 | duolaimall-product/product-service/src/main/java/com/cskaoyan/mall/product/cache/CsmallCache.java | CsmallCache |
| 缓存切面 | duolaimall-product/product-service/src/main/java/com/cskaoyan/mall/product/cache/RedisAspect.java | redisCache |
| Redis 常量 | duolaimall-common/common-service/src/main/java/com/cskaoyan/mall/common/constant/RedisConst.java | Bloom、TTL、锁后缀 |
| 热度接口调用 | duolaimall-product/product-service/src/main/java/com/cskaoyan/mall/product/client/SearchApiClient.java | incrHotScore |
| 热度实现 | duolaimall-search/search-service/src/main/java/com/cskaoyan/mall/search/service/impl/SearchServiceImpl.java | incrHotScore |
20. 短复习点
- Bloom 的
false才表示明确不在集合,true仍要查事实数据。 - 缓存切面用锁、double check、空对象和随机 TTL 分别处理常见缓存问题。
- 当前异步依赖根是 SKU,价格与平台属性是独立分支。
- 每请求创建线程池和缺少任务超时是必须说明的实现边界。
- 热度先写 Redis、偶数分数再写 ES,不是跨存储原子更新。
21. 一句话总结
当前商品详情用 Bloom 减少无效回源、用 Redis 切面保护热点查询、再用 CompletableFuture 并行聚合;思路完整,但线程池生命周期、缓存失效、空值语义和跨存储热度一致性尚未经过运行验证。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!