(缓存预热)购票人业务架构分析

视频

image-ea270072

引子:一个看似简单的功能

购票人功能有什么特别的? 不就是用户下面管理几个购票人嘛,顶多也就是增删改查的操作。生成订单时,用户选择购票人信息,传入后台不就行了吗?

其实真的没有这么简单。

表面上的业务逻辑

Controller 层接口

@RestController
@RequestMapping("/ticket/user")
@Tag(name = "ticket-user", description = "购票人")
public class TicketUserController {
    
    @Autowired
    private TicketUserService ticketUserService;
    
    @Operation(summary  = "查询购票人列表")
    @PostMapping(value = "/list")
    public ApiResponse<List<TicketUserVo>> list(@Valid @RequestBody TicketUserListDto ticketUserListDto){
        return ApiResponse.ok(ticketUserService.list(ticketUserListDto));
    }
    
    @Operation(summary  = "添加购票人")
    @PostMapping(value = "/add")
    public ApiResponse<Void> add(@Valid @RequestBody TicketUserDto ticketUserDto){
        ticketUserService.add(ticketUserDto);
        return ApiResponse.ok();
    }
    
    @Operation(summary  = "删除购票人")
    @PostMapping(value = "/delete")
    public ApiResponse<Void> delete(@Valid @RequestBody TicketUserIdDto ticketUserIdDto){
        ticketUserService.delete(ticketUserIdDto);
        return ApiResponse.ok();
    }
}

数据库表结构

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

注意到了吗?这两次操作都要从数据库中查询

问题的关键:这是在抢票业务中!

抢票业务是大麦项目的 核心高并发业务。想象一下周杰伦演唱会开票的瞬间:

周杰伦演唱会开票的瞬间-202535cb

虽然我们用了 user_id 作为分库分表的分片键,避免了读扩散问题。但当请求量达到一定量级时,数据库超时现象很快就会爆发。

解决方案:使用缓存

既然数据库压力这么大,最好的解决方法就是 使用缓存

而且购票人信息有个特点:修改的概率很低。用户一般设置好常用的购票人后,很少去改动。这就意味着缓存和数据库一致性的问题很好解决。

但问题来了:什么时候把购票人信息放入缓存?

方案一:在生成订单过程中放入缓存?

这个方案看起来合理,但其实 不可行

为什么呢?让我们从业务角度来分析:

什么时候把购票人信息放入缓存-e1e92e7e

结论:放入缓存的时机必须在生成订单之前!

另一个问题:需要把所有用户的购票人都放进缓存吗?

如果把所有用户的购票人信息都放入缓存,缓存的内存压力会非常大

让我们再从业务角度思考:

需要把所有用户的购票人都放进缓存吗-e191d668

还有一个问题:查看详情不需要登录,怎么知道用户要抢票?

查看演唱会详情是不需要登录的。有些用户只是随便逛逛,点进去看看,但并不一定会抢票。

那么,通过什么办法能知道用户是 真的想抢票 而不是随便看看呢?

用户意图判断-3f6370bc

最终方案确定

综合以上分析,我们确定了缓存预热的策略:

“热点预热 + 异步加载”

购票人缓存预热条件-984f3d62

数据库设计

使用 user_id 作为分库分表的分片键。

  • 优势:确保同一个用户的所有购票人数据都在同一个分片上,避免“读扩散”(查询一个用户的数据需要扫描所有库)。

缓存加载时机(核心创新点)

不能等到用户点击“提交订单”时再查缓存(那时候可能已经晚了,且如果是缓存未命中会回源查DB,不仅慢还有击穿风险)。

策略逻辑

  1. 时机:用户**浏览“节目详情页”**时。
  2. 条件
    • 节目被标记为热门(High Heat)
    • 用户处于登录状态
  3. 动作:异步将该用户的常用购票人列表加载到 Redis 中。

用户行为流程分析

缓存预热场景对比-2ee7ad61

核心设计要点

1. 缓存预热条件

购票人预热决策树-66319ee4

位于 com.damai.service.ProgramService 中的 preloadTicketUserList 方法。

/**
 * 预先加载用户下的购票人
 */
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<List<TicketUserVo>> 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)与 幂等性
    • 检查1redisCache.hasKey(...)。如果缓存里已经有了,直接跳过。防止用户频繁刷新详情页导致重复查询数据库。
    • 检查2userLogin 检查。只有登录用户才需要购票,未登录用户看详情页不需要加载数据(节省 Redis 内存)。

在节目详情查询中调用预热方法

ProgramService中的 getDetail``getDetailV2

/**
 * 查询节目详情执行
 */
public ProgramVo getDetail(ProgramGetDto programGetDto) {
    // ... 查询节目基本信息 ...

    // 🔑 关键:在查询详情时预热购票人缓存
    preloadTicketUserList(programVo.getHighHeat());

    // ... 其他业务逻辑 ...

    return programVo;
}

2. 缓存读取策略(旁路缓存模式)

购票人缓存读取流程-5fd3491a

位于 TicketUserService.list

public List<TicketUserVo> list(TicketUserListDto ticketUserListDto) {

    // Step 1: 先查缓存
    List<TicketUserVo> ticketUserVoList = redisCache.getValueIsList(
        RedisKeyBuild.createRedisKey(RedisKeyManage.TICKET_USER_LIST,
            ticketUserListDto.getUserId()),
        TicketUserVo.class);

    // Step 2: 缓存命中直接返回
    if (CollectionUtil.isNotEmpty(ticketUserVoList)) {
        return ticketUserVoList;  // ⚡ 高效返回
    }

    // Step 3: 缓存未命中,查询数据库
    LambdaQueryWrapper<TicketUser> wrapper = Wrappers.lambdaQuery(TicketUser.class)
            .eq(TicketUser::getUserId, ticketUserListDto.getUserId());
    List<TicketUser> ticketUsers = ticketUserMapper.selectList(wrapper);

    return BeanUtil.copyToList(ticketUsers, TicketUserVo.class);
}

3. 缓存一致性处理

@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 分支的嵌套层数。

// ❌ 不推荐:多层嵌套
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:双重判定避免重复调用

BusinessThreadPool.execute(() -> {
    try {
        // 🔑 双重判定:避免重复预热
        if (redisCache.hasKey(RedisKeyBuild.createRedisKey(
                RedisKeyManage.TICKET_USER_LIST, userId))) {
            return;  // 已存在缓存,无需再次加载
        }

        // 调用远程服务获取购票人列表
        ApiResponse<List<TicketUserVo>> apiResponse = userClient.select(...);
        // ...
    } catch (Exception e) {
        log.error("预热加载购票人列表失败", e);
    }
});

用户第一次查看演唱会A详情→缓存不存在→调用用户服务→放入缓存 用户又查看演唱会B详情→缓存已存在→直接返回,不再调用用户服务 效果:避免重复调用远程服务,节省资源

💡 通用经验:只要业务中使用了缓存,就可以利用这个双重判定技巧。

查询购票人列表

预热完成后,用户在下单时查询购票人列表,就可以直接从缓存获取了:

public List<TicketUserVo> list(TicketUserListDto ticketUserListDto) {
        // Step 1: 先从缓存中查询
        List<TicketUserVo> ticketUserVoList = redisCache.getValueIsList(RedisKeyBuild.createRedisKey(
                RedisKeyManage.TICKET_USER_LIST, ticketUserListDto.getUserId()), TicketUserVo.class);

        
// Step 2: 缓存命中,直接返回(高效!)
        if (CollectionUtil.isNotEmpty(ticketUserVoList)) {
            return ticketUserVoList;
        }

        
// Step 3: 缓存未命中,查询数据库(兜底)
        LambdaQueryWrapper<TicketUser> ticketUserLambdaQueryWrapper = Wrappers.lambdaQuery(TicketUser.class)
                .eq(TicketUser::getUserId, ticketUserListDto.getUserId());
        List<TicketUser> ticketUsers = ticketUserMapper.selectList(ticketUserLambdaQueryWrapper);
        return BeanUtil.copyToList(ticketUsers,TicketUserVo.class);
    }

缓存与数据库一致性处理

为什么一致性问题好解决?

从业务角度分析:

购票人数据的访问特点-22b48aea

解决策略:写后删除缓存

@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

整体架构图

购票人业务架构-38c8b567

核心优势总结

问题 分析 解决方案
单库压力大 购票人数据量大 使用 user_id 分库分表,避免读扩散
高并发DB压力 抢票瞬间大量查询 缓存预热,将压力转移到 Redis
缓存何时加载 下单时加载太晚 查看详情时异步预热
缓存浪费内存 不能全量预热 只预热热门节目 + 登录用户
阻塞主流程 预热不能影响详情查询 使用线程池异步执行
重复预热 用户多次查看详情 双重判定,缓存存在则跳过

企业级项目导航:⬅️ 03-(缓存穿透)用户注册业务深度解析 | 04-(缓存预热)购票人业务架构分析 | ➡️ 05-JWT + Redis 双重会话管理 学习笔记