(缓存预热)购票人业务架构分析
视频
引子:一个看似简单的功能
购票人功能有什么特别的? 不就是用户下面管理几个购票人嘛,顶多也就是增删改查的操作。生成订单时,用户选择购票人信息,传入后台不就行了吗?
其实真的没有这么简单。
表面上的业务逻辑
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='购票人表';
看起来确实很普通 但问题的关键在于 这个功能在什么场景下被调用。
为什么“购票人”功能如此重要?
场景分析
购票人信息在什么时候会被查询:
注意到了吗?这两次操作都要从数据库中查询。
问题的关键:这是在抢票业务中!
抢票业务是大麦项目的 核心高并发业务。想象一下周杰伦演唱会开票的瞬间:
虽然我们用了 user_id 作为分库分表的分片键,避免了读扩散问题。但当请求量达到一定量级时,数据库超时现象很快就会爆发。
解决方案:使用缓存
既然数据库压力这么大,最好的解决方法就是 使用缓存。
而且购票人信息有个特点:修改的概率很低。用户一般设置好常用的购票人后,很少去改动。这就意味着缓存和数据库一致性的问题很好解决。
但问题来了:什么时候把购票人信息放入缓存?
方案一:在生成订单过程中放入缓存?
这个方案看起来合理,但其实 不可行。
为什么呢?让我们从业务角度来分析:
结论:放入缓存的时机必须在生成订单之前!
另一个问题:需要把所有用户的购票人都放进缓存吗?
如果把所有用户的购票人信息都放入缓存,缓存的内存压力会非常大。
让我们再从业务角度思考:
还有一个问题:查看详情不需要登录,怎么知道用户要抢票?
查看演唱会详情是不需要登录的。有些用户只是随便逛逛,点进去看看,但并不一定会抢票。
那么,通过什么办法能知道用户是 真的想抢票 而不是随便看看呢?
最终方案确定
综合以上分析,我们确定了缓存预热的策略:
“热点预热 + 异步加载”
数据库设计
使用 user_id 作为分库分表的分片键。
- 优势:确保同一个用户的所有购票人数据都在同一个分片上,避免“读扩散”(查询一个用户的数据需要扫描所有库)。
缓存加载时机(核心创新点)
不能等到用户点击“提交订单”时再查缓存(那时候可能已经晚了,且如果是缓存未命中会回源查DB,不仅慢还有击穿风险)。
策略逻辑:
- 时机:用户**浏览“节目详情页”**时。
- 条件:
- 节目被标记为热门(High Heat)。
- 用户处于登录状态。
- 动作:异步将该用户的常用购票人列表加载到 Redis 中。
用户行为流程分析
核心设计要点
1. 缓存预热条件
位于 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);
}
});
}
代码亮点解析:
- 卫语句(Guard Clauses)的使用:
- 代码中没有使用深层嵌套的
if-else,而是判断不符合条件直接return。 - 好处:代码可读性极高,逻辑清晰,减少了“箭头型代码”的复杂度。
- 代码中没有使用深层嵌套的
- 异步执行:
BusinessThreadPool.execute(() -> { ... })- 原因:加载购票人只是辅助操作,绝不能因为这个操作慢而阻塞主线程,导致用户打不开详情页。
- 双重检查(Double Check)与 幂等性:
- 检查1:
redisCache.hasKey(...)。如果缓存里已经有了,直接跳过。防止用户频繁刷新详情页导致重复查询数据库。 - 检查2:
userLogin检查。只有登录用户才需要购票,未登录用户看详情页不需要加载数据(节省 Redis 内存)。
- 检查1:
在节目详情查询中调用预热方法
ProgramService中的 getDetail``getDetailV2
/**
* 查询节目详情执行
*/
public ProgramVo getDetail(ProgramGetDto programGetDto) {
// ... 查询节目基本信息 ...
// 🔑 关键:在查询详情时预热购票人缓存
preloadTicketUserList(programVo.getHighHeat());
// ... 其他业务逻辑 ...
return programVo;
}
2. 缓存读取策略(旁路缓存模式)
位于 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);
}
缓存与数据库一致性处理
为什么一致性问题好解决?
从业务角度分析:
解决策略:写后删除缓存
@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));
}
一致性保证流程
整体架构图
核心优势总结
| 问题 | 分析 | 解决方案 |
|---|---|---|
| 单库压力大 | 购票人数据量大 | 使用 user_id 分库分表,避免读扩散 |
| 高并发DB压力 | 抢票瞬间大量查询 | 缓存预热,将压力转移到 Redis |
| 缓存何时加载 | 下单时加载太晚 | 查看详情时异步预热 |
| 缓存浪费内存 | 不能全量预热 | 只预热热门节目 + 登录用户 |
| 阻塞主流程 | 预热不能影响详情查询 | 使用线程池异步执行 |
| 重复预热 | 用户多次查看详情 | 双重判定,缓存存在则跳过 |
企业级项目导航:⬅️ 03-(缓存穿透)用户注册业务深度解析 | 04-(缓存预热)购票人业务架构分析 | ➡️ 05-JWT + Redis 双重会话管理 学习笔记
💬 评论