--- title: "04-(缓存预热)购票人业务架构分析" created: 2025-12-11 aliases: - (缓存预热)购票人业务架构分析 tags: - 项目 --- # (缓存预热)购票人业务架构分析 ## 视频 ![[image-ea270072.png]] ## 引子:一个看似简单的功能 **购票人功能有什么特别的?** 不就是用户下面管理几个购票人嘛,顶多也就是增删改查的操作。生成订单时,用户选择购票人信息,传入后台不就行了吗? 其实真的没有这么简单。 ## **表面上的业务逻辑** ### **Controller 层接口** ```java @RestController @RequestMapping("/ticket/user") @Tag(name = "ticket-user", description = "购票人") public class TicketUserController { @Autowired private TicketUserService ticketUserService; @Operation(summary = "查询购票人列表") @PostMapping(value = "/list") public ApiResponse> list(@Valid @RequestBody TicketUserListDto ticketUserListDto){ return ApiResponse.ok(ticketUserService.list(ticketUserListDto)); } @Operation(summary = "添加购票人") @PostMapping(value = "/add") public ApiResponse add(@Valid @RequestBody TicketUserDto ticketUserDto){ ticketUserService.add(ticketUserDto); return ApiResponse.ok(); } @Operation(summary = "删除购票人") @PostMapping(value = "/delete") public ApiResponse delete(@Valid @RequestBody TicketUserIdDto ticketUserIdDto){ ticketUserService.delete(ticketUserIdDto); return ApiResponse.ok(); } } ``` ### **数据库表结构** ```sql CREATE TABLE `d_ticket_user` ( `id` bigint(20) NOT NULL COMMENT '主键id', `user_id` bigint(20) NOT NULL COMMENT '用户id', `rel_name` varchar(256) NOT NULL COMMENT '用户真实名字', `id_type` int(11) NOT NULL DEFAULT '1' COMMENT '证件类型', `id_number` varchar(512) NOT NULL COMMENT '证件号码', `create_time` datetime NOT NULL COMMENT '创建时间', `edit_time` datetime NOT NULL COMMENT '编辑时间', `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1:正常 0:删除', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='购票人表'; ``` 看起来确实很普通 但问题的关键在于 这个功能在什么场景下被调用。 ## 为什么“购票人”功能如此重要? ### **场景分析** 购票人信息在什么时候会被查询: ![[购票人查询场景-0178d778.jpg]] 注意到了吗?**这两次操作都要从数据库中查询**。 ### **问题的关键:这是在抢票业务中!** 抢票业务是大麦项目的 **核心高并发业务**。想象一下周杰伦演唱会开票的瞬间: ![[周杰伦演唱会开票的瞬间-202535cb.jpg]] 虽然我们用了 user\_id 作为分库分表的分片键,避免了读扩散问题。但当请求量达到一定量级时,数据库超时现象很快就会爆发。 #### **解决方案:使用缓存** 既然数据库压力这么大,最好的解决方法就是 **使用缓存**。 而且购票人信息有个特点:**修改的概率很低**。用户一般设置好常用的购票人后,很少去改动。这就意味着缓存和数据库一致性的问题很好解决。 ##### **但问题来了:什么时候把购票人信息放入缓存?** **方案一:在生成订单过程中放入缓存?** 这个方案看起来合理,但其实 **不可行**。 为什么呢?让我们从业务角度来分析: ![[什么时候把购票人信息放入缓存-e1e92e7e.jpg]] **结论:放入缓存的时机必须在生成订单之前!** ### **另一个问题:需要把所有用户的购票人都放进缓存吗?** 如果把所有用户的购票人信息都放入缓存,**缓存的内存压力会非常大**。 让我们再从业务角度思考: ![[需要把所有用户的购票人都放进缓存吗-e191d668.jpg]] ### **还有一个问题:查看详情不需要登录,怎么知道用户要抢票?** 查看演唱会详情是不需要登录的。有些用户只是随便逛逛,点进去看看,但并不一定会抢票。 那么,通过什么办法能知道用户是 **真的想抢票** 而不是随便看看呢? ![[用户意图判断-3f6370bc.jpg]] ## **最终方案确定** 综合以上分析,我们确定了缓存预热的策略: > **“热点预热 + 异步加载”** ![[购票人缓存预热条件-984f3d62.jpg]] ### 数据库设计 使用 `user_id` 作为分库分表的分片键。 - **优势**:确保同一个用户的所有购票人数据都在同一个分片上,避免“读扩散”(查询一个用户的数据需要扫描所有库)。 ### 缓存加载时机(核心创新点) 不能等到用户点击“提交订单”时再查缓存(那时候可能已经晚了,且如果是缓存未命中会回源查DB,不仅慢还有击穿风险)。 **策略逻辑**: 1. **时机**:用户**浏览“节目详情页”**时。 2. **条件**: - 节目被标记为**热门(High Heat)**。 - 用户处于**登录状态**。 3. **动作**:异步将该用户的常用购票人列表加载到 Redis 中。 ### 用户行为流程分析 ![[缓存预热场景对比-2ee7ad61.jpg]] ## 核心设计要点 ### **1. 缓存预热条件** ![[购票人预热决策树-66319ee4.jpg]] 位于 `com.damai.service.ProgramService` 中的 `preloadTicketUserList` 方法。 ```java /** * 预先加载用户下的购票人 */ private void preloadTicketUserList(Integer highHeat){ // ━━━━━━━━━ 条件判断阶段 ━━━━━━━━━ // 条件1:如果节目热度不高,不用预热 if (Objects.equals(highHeat, BusinessStatus.NO.getCode())) { return; } String userId = BaseParameterHolder.getParameter(USER_ID); String code = BaseParameterHolder.getParameter(CODE); // 条件2:如果用户身份信息不完整,无法判断登录状态 if (StringUtil.isEmpty(userId) || StringUtil.isEmpty(code)) { return; } // ━━━━━━━━━ 异步执行阶段 ━━━━━━━━━ // 关键:使用异步,不阻塞查询节目详情的主线程 BusinessThreadPool.execute(() -> { try { // 条件3:验证用户是否真的登录了 Boolean userLogin = redisCache.hasKey( RedisKeyBuild.createRedisKey(RedisKeyManage.USER_LOGIN, code, userId)); if (!userLogin) { return; } // 优化:双重判定,如果已经预热过就不再执行 if (redisCache.hasKey( RedisKeyBuild.createRedisKey(RedisKeyManage.TICKET_USER_LIST, userId))) { return; } // 调用用户服务获取购票人列表 TicketUserListDto ticketUserListDto = new TicketUserListDto(); ticketUserListDto.setUserId(Long.parseLong(userId)); ApiResponse> apiResponse = userClient.select(ticketUserListDto); // 将购票人列表放入缓存 if (Objects.equals(apiResponse.getCode(), BaseCode.SUCCESS.getCode())) { Optional.ofNullable(apiResponse.getData()) .filter(CollectionUtil::isNotEmpty) .ifPresent(ticketUserVoList -> redisCache.set( RedisKeyBuild.createRedisKey(RedisKeyManage.TICKET_USER_LIST, userId), ticketUserVoList)); } else { log.warn("userClient.select 调用失败 apiResponse : {}", JSON.toJSONString(apiResponse)); } } catch (Exception e) { log.error("预热加载购票人列表失败", e); } }); } ``` **代码亮点解析:** 1. **卫语句(Guard Clauses)的使用**: - 代码中没有使用深层嵌套的 `if-else`,而是判断不符合条件直接 `return`。 - *好处*:代码可读性极高,逻辑清晰,减少了“箭头型代码”的复杂度。 2. **异步执行**: - `BusinessThreadPool.execute(() -> { ... })` - *原因*:加载购票人只是辅助操作,绝不能因为这个操作慢而阻塞主线程,导致用户打不开详情页。 3. **双重检查(Double Check)与 幂等性**: - **检查1**:`redisCache.hasKey(...)`。如果缓存里已经有了,直接跳过。防止用户频繁刷新详情页导致重复查询数据库。 - **检查2**:`userLogin` 检查。只有登录用户才需要购票,未登录用户看详情页不需要加载数据(节省 Redis 内存)。 #### **在节目详情查询中调用预热方法** **ProgramService中的** `getDetail``getDetailV2` ```java /** * 查询节目详情执行 */ public ProgramVo getDetail(ProgramGetDto programGetDto) { // ... 查询节目基本信息 ... // 🔑 关键:在查询详情时预热购票人缓存 preloadTicketUserList(programVo.getHighHeat()); // ... 其他业务逻辑 ... return programVo; } ``` ### **2. 缓存读取策略(旁路缓存模式)** ![[购票人缓存读取流程-5fd3491a.jpg]] 位于 `TicketUserService.list` ```java public List list(TicketUserListDto ticketUserListDto) { // Step 1: 先查缓存 List ticketUserVoList = redisCache.getValueIsList( RedisKeyBuild.createRedisKey(RedisKeyManage.TICKET_USER_LIST, ticketUserListDto.getUserId()), TicketUserVo.class); // Step 2: 缓存命中直接返回 if (CollectionUtil.isNotEmpty(ticketUserVoList)) { return ticketUserVoList; // ⚡ 高效返回 } // Step 3: 缓存未命中,查询数据库 LambdaQueryWrapper wrapper = Wrappers.lambdaQuery(TicketUser.class) .eq(TicketUser::getUserId, ticketUserListDto.getUserId()); List ticketUsers = ticketUserMapper.selectList(wrapper); return BeanUtil.copyToList(ticketUsers, TicketUserVo.class); } ``` ### **3. 缓存一致性处理** ```java @Transactional(rollbackFor = Exception.class) public void add(TicketUserDto ticketUserDto) { // ... 添加购票人逻辑 ticketUserMapper.insert(addTicketUser); // 🔑 关键:删除缓存,保证一致性 delTicketUserVoListCache(String.valueOf(ticketUserDto.getUserId())); } @Transactional(rollbackFor = Exception.class) public void delete(TicketUserIdDto ticketUserIdDto) { // ... 删除购票人逻辑 ticketUserMapper.deleteById(ticketUserIdDto.getId()); // 🔑 关键:删除缓存,保证一致性 delTicketUserVoListCache(String.valueOf(ticketUser.getUserId())); } public void delTicketUserVoListCache(String userId) { redisCache.del(RedisKeyBuild.createRedisKey( RedisKeyManage.TICKET_USER_LIST, userId)); } ``` 当用户修改了购票人信息怎么办? - **策略**:**Cache Aside Pattern(旁路缓存模式)的变种** —— **只删不改**。 - **操作**:当发生 `add` (新增) 或 `delete` (删除) 操作时,直接删除 Redis 中对应的 `Key`。 - **逻辑**:下一次用户查看详情页或进入下单页时,会触发重新加载逻辑,保证数据最终一致性。 ## 代码优化技巧 ### **技巧1:卫语句(提前返回)减少嵌套** **在** `preloadTicketUserList` **方法中,当判断条件不符合业务要求时,使用 return 提前结束流程。这样可以大大减少 if 分支的嵌套层数。** ```java // ❌ 不推荐:多层嵌套 private void preloadTicketUserList(Integer highHeat){ if (Objects.equals(highHeat, BusinessStatus.YES.getCode())) { String userId = BaseParameterHolder.getParameter(USER_ID); String code = BaseParameterHolder.getParameter(CODE); if (StringUtil.isNotEmpty(userId) && StringUtil.isNotEmpty(code)) { BusinessThreadPool.execute(() -> { try { Boolean userLogin = redisCache.hasKey(...); if (userLogin) { if (!redisCache.hasKey(...)) { // 终于到这里了... // 但已经嵌套了 4 层! } } } catch (Exception e) { log.error("预热加载购票人列表失败", e); } }); } } } // ✅ 推荐:提前返回 private void preloadTicketUserList(Integer highHeat){ // 不符合条件,提前返回 if (Objects.equals(highHeat, BusinessStatus.NO.getCode())) { return; } String userId = BaseParameterHolder.getParameter(USER_ID); String code = BaseParameterHolder.getParameter(CODE); // 不符合条件,提前返回 if (StringUtil.isEmpty(userId) || StringUtil.isEmpty(code)) { return; } // 条件都满足,执行核心逻辑 BusinessThreadPool.execute(() -> { // ... 逻辑清晰,一目了然 }); } ``` > *💡 **经验法则**:一般建议 if 嵌套不要超过 3 层,否则代码可读性会急剧下降。* ### **技巧2:双重判定避免重复调用** ```java BusinessThreadPool.execute(() -> { try { // 🔑 双重判定:避免重复预热 if (redisCache.hasKey(RedisKeyBuild.createRedisKey( RedisKeyManage.TICKET_USER_LIST, userId))) { return; // 已存在缓存,无需再次加载 } // 调用远程服务获取购票人列表 ApiResponse> apiResponse = userClient.select(...); // ... } catch (Exception e) { log.error("预热加载购票人列表失败", e); } }); ``` 用户第一次查看演唱会A详情→缓存不存在→调用用户服务→放入缓存 用户又查看演唱会B详情→缓存已存在→直接返回,不再调用用户服务 效果:避免重复调用远程服务,节省资源 > 💡 通用经验:只要业务中使用了缓存,就可以利用这个双重判定技巧。 ## **查询购票人列表** 预热完成后,用户在下单时查询购票人列表,就可以直接从缓存获取了: ```java public List list(TicketUserListDto ticketUserListDto) { // Step 1: 先从缓存中查询 List ticketUserVoList = redisCache.getValueIsList(RedisKeyBuild.createRedisKey( RedisKeyManage.TICKET_USER_LIST, ticketUserListDto.getUserId()), TicketUserVo.class); // Step 2: 缓存命中,直接返回(高效!) if (CollectionUtil.isNotEmpty(ticketUserVoList)) { return ticketUserVoList; } // Step 3: 缓存未命中,查询数据库(兜底) LambdaQueryWrapper ticketUserLambdaQueryWrapper = Wrappers.lambdaQuery(TicketUser.class) .eq(TicketUser::getUserId, ticketUserListDto.getUserId()); List ticketUsers = ticketUserMapper.selectList(ticketUserLambdaQueryWrapper); return BeanUtil.copyToList(ticketUsers,TicketUserVo.class); } ``` ## **缓存与数据库一致性处理** ### **为什么一致性问题好解决?** 从业务角度分析: ![[购票人数据的访问特点-22b48aea.jpg]] ### **解决策略:写后删除缓存** ```java @Transactional(rollbackFor = Exception.class) public void add(TicketUserDto ticketUserDto) { // ... 业务校验 ... // 插入数据库 ticketUserMapper.insert(addTicketUser); // 🔑 关键:删除缓存 delTicketUserVoListCache(String.valueOf(ticketUserDto.getUserId())); } @Transactional(rollbackFor = Exception.class) public void delete(TicketUserIdDto ticketUserIdDto) { // ... 业务校验 ... // 从数据库删除 ticketUserMapper.deleteById(ticketUserIdDto.getId()); // 🔑 关键:删除缓存 delTicketUserVoListCache(String.valueOf(ticketUser.getUserId())); } public void delTicketUserVoListCache(String userId){ redisCache.del(RedisKeyBuild.createRedisKey(RedisKeyManage.TICKET_USER_LIST, userId)); } ``` ### **一致性保证流程** ![[购票人缓存一致性-16b07079.jpg]] ## **整体架构图** ![[购票人业务架构-38c8b567.jpg]] ## **核心优势总结** | **问题** | **分析** | **解决方案** | | --- | --- | --- | | **单库压力大** | 购票人数据量大 | 使用 `user_id` 分库分表,避免读扩散 | | **高并发DB压力** | 抢票瞬间大量查询 | 缓存预热,将压力转移到 Redis | | **缓存何时加载** | 下单时加载太晚 | 查看详情时异步预热 | | **缓存浪费内存** | 不能全量预热 | 只预热热门节目 + 登录用户 | | **阻塞主流程** | 预热不能影响详情查询 | 使用线程池异步执行 | | **重复预热** | 用户多次查看详情 | 双重判定,缓存存在则跳过 | --- **企业级项目导航**:⬅️ [[03-(缓存穿透)用户注册业务深度解析|03-(缓存穿透)用户注册业务深度解析]] | 04-(缓存预热)购票人业务架构分析 | ➡️ [[05-JWT + Redis 双重会话管理 学习笔记|05-JWT + Redis 双重会话管理 学习笔记]]