抽奖页高频查询优化:Redis 如何缓存活动详情和中奖记录
摘要
抽奖系统里,读请求往往比写请求多得多。
活动创建和抽奖执行并不是每秒都发生,但活动详情页、中奖记录页可能被运营人员、参与用户反复刷新。如果每次都直接查 MySQL,尤其是活动详情还要关联活动表、活动奖品表、活动用户表、奖品表,多表查询压力会越来越大。
我的抽奖系统使用 Redis 缓存两类高频数据:
活动详情:ACTIVITY_{activityId},缓存活动、奖品、参与用户的完整 DTO;
中奖记录:WINNING_RECORDS_{activityId} 和 WINNING_RECORDS_{activityId}_{prizeId},
分别支持活动维度和奖品维度查询。
本文从项目真实代码出发,复盘 Redis 在抽奖系统中的缓存设计、Key 设计、Cache Aside 查询模式、状态变更后的缓存刷新,以及异常回滚时的缓存清理。
为什么抽奖系统需要缓存
抽奖系统的读场景很多:
活动列表
活动详情
奖品列表
中奖记录
运营问答查询活动详情
运营问答查询中奖记录
其中最值得缓存的是:
活动详情
活动详情不是单表数据,它包含:
活动基础信息;
活动关联奖品;
奖品名称、图片、价格;
参与用户;
奖品状态;
用户状态。
中奖记录
中奖记录在活动结束后会被频繁查看,比如:
查看整个活动中奖名单;
查看某个奖项中奖人;
AI 运营助手查询中奖记录;
后台导出中奖记录。
这些数据读多写少,非常适合 Redis 缓存。
缓存整体设计
项目中主要有两类缓存:
活动详情缓存:
ACTIVITY_{activityId}
中奖记录缓存:
WINNING_RECORDS_{activityId}
WINNING_RECORDS_{activityId}_{prizeId}
整体读写流程如下:
缓存策略是典型 Cache Aside:
读:先 Redis,未命中查 DB,再回写 Redis
写:业务写 DB 后主动刷新或删除缓存
Redis 工具封装
项目中封装了 RedisUtil,底层使用 StringRedisTemplate:
@ConfigurationpublicclassRedisUtil{@AutowiredprivateStringRedisTemplatestringRedisTemplate;publicbooleansetEx(Stringkey,Stringvalue,Longtime){try{stringRedisTemplate.opsForValue().set(key,value,time,TimeUnit.SECONDS);returntrue;}catch(Exceptione){logger.error("RedisUtil error,set({},{},{})",key,value,time,e);returnfalse;}}publicStringget(Stringkey){try{returnStringUtils.hasText(key)?stringRedisTemplate.opsForValue().get(key):null;}catch(Exceptione){logger.error("RedisUtil error,get({})",key,e);returnnull;}}publicbooleandelete(String...keys){try{if(keys==null||keys.length==0){returntrue;}if(keys.length==1){stringRedisTemplate.delete(keys[0]);}else{stringRedisTemplate.delete(Arrays.asList(keys));}returntrue;}catch(Exceptione){logger.error("RedisUtil delete error, keys: {}",Arrays.toString(keys),e);returnfalse;}}}这里选择 String JSON 存储,而不是复杂 Redis Hash。原因是:
活动详情和中奖记录通常是整体读取;
DTO 结构比较复杂;
JSON 方便序列化和调试;
学习型项目实现成本更低。
活动详情缓存设计
活动详情缓存前缀和 TTL:
privatefinalStringACTIVITY_PREFIX="ACTIVITY_";// 3 dayprivatefinalLongACTIVITY_TIMEOUT=60*60*24*3L;Key 示例:
ACTIVITY_1001
活动详情查询逻辑:
@OverridepublicActivityDetailDTOgetActivityDetail(LongactivityId){if(null==activityId){returnnull;}ActivityDetailDTOdetailDTO=getActivityFromCache(activityId);if(null!=detailDTO){returndetailDTO;}ActivityDOactivityDO=activityMapper.selectById(activityId);List<ActivityPrizeDO>activityPrizeDOList=activityPrizeMapper.selectByActivityId(activityId);List<ActivityUserDO>activityUserDOList=activityUserMapper.selectByActivityId(activityId);List<Long>prizeIds=activityPrizeDOList.stream().map(ActivityPrizeDO::getPrizeId).distinct().collect(Collectors.toList());List<PrizeDO>prizeDOList=prizeMapper.batchSelectByIds(prizeIds);ActivityDetailDTOactivityDetailDTO=convertToActivityDetailDTO(activityDO,prizeDOList,activityUserDOList,activityPrizeDOList);cacheActivity(activityDetailDTO);returnactivityDetailDTO;}这段代码体现了 Cache Aside:
先查 Redis;
命中直接返回;
未命中查多张表;
组装 DTO;
回写 Redis。
读取缓存:
privateActivityDetailDTOgetActivityFromCache(LongactivityId){StringcacheKey=ACTIVITY_PREFIX+activityId;Stringjson=redisUtil.get(cacheKey);if(StringUtils.isBlank(json)){returnnull;}try{returnJacksonUtil.readValue(json,ActivityDetailDTO.class);}catch(Exceptione){redisUtil.delete(cacheKey);returnnull;}}这里有个细节:如果缓存反序列化失败,会删除损坏缓存,避免后续一直读到坏数据。
写入缓存:
privatevoidcacheActivity(ActivityDetailDTOactivityDetailDTO){if(null==activityDetailDTO||null==activityDetailDTO.getActivityId()){return;}redisUtil.setEx(ACTIVITY_PREFIX+activityDetailDTO.getActivityId(),JacksonUtil.writeValueAsString(activityDetailDTO),ACTIVITY_TIMEOUT);}活动创建后立即缓存
创建活动时,系统会保存三类数据:
活动基础信息;
活动奖品关联;
活动用户关联。
保存完成后,立即组装完整详情并写入 Redis:
ActivityDetailDTOactivityDetailDTO=convertToActivityDetailDTO(activityDO,prizeDOList,activityUserDOList,activityPrizeDOList);cacheActivity(activityDetailDTO);这样活动创建成功后,后续查询详情可以直接命中 Redis。
状态变更后刷新活动缓存
抽奖过程中,活动、奖品、用户状态会发生变化。
比如:
奖品 INIT -> COMPLETED
中奖用户 INIT -> COMPLETED
活动 RUNING -> COMPLETED
状态流转后,ActivityStatusManagerImpl 会刷新活动缓存:
if(update){activityService.cacheActivity(convertActivityStatusDTO.getActivityId());}cacheActivity(activityId)会重新查数据库并覆盖Redis:publicvoidcacheActivity(LongactivityId){ActivityDOactivityDO=activityMapper.selectById(activityId);List<ActivityPrizeDO>activityPrizeDOList=activityPrizeMapper.selectByActivityId(activityId);List<ActivityUserDO>activityUserDOList=activityUserMapper.selectByActivityId(activityId);List<Long>prizeIds=activityPrizeDOList.stream().map(ActivityPrizeDO::getPrizeId).distinct().collect(Collectors.toList());List<PrizeDO>prizeDOList=prizeMapper.batchSelectByIds(prizeIds);ActivityDetailDTOactivityDetailDTO=convertToActivityDetailDTO(activityDO,prizeDOList,activityUserDOList,activityPrizeDOList);cacheActivity(activityDetailDTO);}这就是“写后刷新缓存”。
中奖记录缓存设计
中奖记录缓存前缀和 TTL:
privatefinalStringWINNING_RECORDS_PREFIX="WINNING_RECORDS_";privatefinalLongWINNING_RECORDS_TIMEOUT=60*60*24*2L;Key 有两个维度。
- 活动维度
WINNING_RECORDS_{activityId}
例如:
WINNING_RECORDS_1001
适合查询整个活动的中奖名单。 - 奖品维度
WINNING_RECORDS_{activityId}_{prizeId}
例如:
WINNING_RECORDS_1001_2001
适合查询某个活动下某个奖品的中奖人。
为什么要两个维度?
因为业务查询场景不同。如果只缓存活动维度,每次查某个奖品中奖人都要在内存中过滤;如果只缓存奖品维度,查整个活动中奖名单又要拼多个奖品结果。
两个维度能让查询更直接。
中奖记录落库后写缓存
抽奖完成后,系统会批量插入中奖记录:winningRecordMapper.batchInsert(winningRecordDOList);
然后写入奖品维度缓存:
cacheWinningRecords(param.getActivityId()+"_"+param.getPrizeId(),winningRecordDOList,WINNING_RECORDS_TIMEOUT);如果活动已经完成,还会写入活动维度缓存:
if(activityDO.getStatus().equalsIgnoreCase(ActivityStatusEnum.COMPLETED.name())){List<WinningRecordDO>allList=winningRecordMapper.selectByActivityId(param.getActivityId());cacheWinningRecords(String.valueOf(param.getActivityId()),allList,WINNING_RECORDS_TIMEOUT);}缓存方法:
privatevoidcacheWinningRecords(Stringkey,List<WinningRecordDO>winningRecordDOList,Longtime){if(!StringUtils.hasText(key)||CollectionUtils.isEmpty(winningRecordDOList)){return;}redisUtil.setEx(WINNING_RECORDS_PREFIX+key,JacksonUtil.writeValueAsString(winningRecordDOList),time);}中奖记录查询:Cache Aside
查询入口:
publicList<WinningRecordDTO>getRecords(ShowWinningRecordsParamparam){Stringkey=null==param.getPrizeId()?String.valueOf(param.getActivityId()):param.getActivityId()+"_"+param.getPrizeId();List<WinningRecordDO>winningRecords=getWinningRecords(key);if(!CollectionUtils.isEmpty(winningRecords)){returnconvertToWinningRecordDTOList(winningRecords);}winningRecords=winningRecordMapper.selectByActivityIdOrPrizeId(param.getActivityId(),param.getPrizeId());if(CollectionUtils.isEmpty(winningRecords)){returnArrays.asList();}cacheWinningRecords(key,winningRecords,WINNING_RECORDS_TIMEOUT);returnconvertToWinningRecordDTOList(winningRecords);}流程:
根据 activityId / prizeId 拼 Key
-> 查 Redis
-> 命中:返回
-> 未命中:查 MySQL
-> 查到:写 Redis
-> 返回 DTO
回滚时删除缓存
抽奖消费异常时,会回滚中奖记录:
privatevoidrollbackWinner(DrawPrizeParamparam){drawPrizeService.deleteRecords(param.getActivityId(),param.getPrizeId());}删除中奖记录时,同时删除缓存:
publicvoiddeleteRecords(LongactivityId,LongprizeId){winningRecordMapper.deleteRecords(activityId,prizeId);if(null!=prizeId){deleteWinningRecords(activityId+"_"+prizeId);}deleteWinningRecords(String.valueOf(activityId));}这样可以避免 MySQL 已回滚,但 Redis 仍保留旧中奖记录。
缓存一致性的取舍
本项目采用的是比较常见的弱一致缓存策略:
MySQL 是最终事实来源
Redis 是读优化缓存
因此允许短时间内出现轻微不一致,但通过这些手段降低风险:
写中奖记录后主动写缓存;
状态变更后主动刷新活动缓存;
回滚中奖记录时删除相关缓存;
缓存设置 TTL,避免长期脏数据;
缓存反序列化失败时删除坏缓存。
对于抽奖系统来说,核心一致性仍在 MySQL 和状态流转逻辑,Redis 是加速层。
当前实现可优化点
- 空值缓存
当前中奖记录查询为空时直接返回空列表,没有缓存空值。如果有人高频查询不存在的记录,可能造成缓存穿透。可以加短 TTL 空缓存:
WINNING_RECORDS_1001_2001 = []
TTL = 60s - 缓存击穿
活动详情缓存过期瞬间,如果大量请求同时进来,可能都打到 MySQL。可以考虑:
互斥锁;
逻辑过期;
热点活动提前续期。 - 延迟双删
如果对一致性要求更高,可以在更新数据库后删除缓存,再延迟删除一次,降低并发读写导致的脏缓存概率。 - Key 规范化
可以统一封装 Key 生成方法,避免字符串拼接散落在业务代码里。
高频相关问题
- 为什么活动详情要缓存整个 DTO?
因为活动详情需要多表查询和组装,整体读取频率高,缓存完整 DTO 可以减少数据库访问和对象组装成本。 - 为什么中奖记录要两个 Key?
业务既有按活动查询,也有按奖品查询。两个 Key 可以分别命中对应场景,避免额外过滤或多次查询。 - 缓存和数据库不一致怎么办?
MySQL 是最终事实来源,Redis 是读优化。写后刷新、回滚删除、TTL 过期共同降低不一致时间。 - 空结果要不要缓存?
生产环境建议缓存短 TTL 空值,防止缓存穿透。当前实现还可以继续优化。 - Redis 挂了怎么办?
当前 RedisUtil 捕获异常,查询可以退回 MySQL。Redis 挂了会影响性能,但不应该影响核心业务正确性。
总结
Redis 在抽奖系统里的作用不是简单“加缓存”,而是围绕具体读场景设计:
活动详情读多写少,缓存完整 DTO;
中奖记录按活动和奖品两个维度缓存;
查询采用 Cache Aside;
状态变更后刷新活动缓存;
中奖记录落库后写缓存;
异常回滚时删除缓存;
TTL 避免长期脏数据。
这套缓存设计能显著降低活动页和中奖记录页对 MySQL 的压力,也能作为面试中讲“缓存设计和一致性取舍”的一个完整案例。
