news 2026/7/6 8:37:22

抽奖页高频查询优化:Redis 如何缓存活动详情和中奖记录

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
抽奖页高频查询优化: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}
整体读写流程如下:

命中

未命中

查询请求

Redis 是否命中

反序列化 DTO 返回

查询 MySQL

组装 DTO / 记录列表

写入 Redis

返回结果

状态变更 / 抽奖完成

刷新活动缓存

中奖记录落库

写入中奖记录缓存

异常回滚

删除中奖记录缓存

缓存策略是典型 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)会重新查数据库并覆盖RedispublicvoidcacheActivity(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 有两个维度。

  1. 活动维度
    WINNING_RECORDS_{activityId}
    例如:
    WINNING_RECORDS_1001
    适合查询整个活动的中奖名单。
  2. 奖品维度
    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 是加速层。

当前实现可优化点

  1. 空值缓存
    当前中奖记录查询为空时直接返回空列表,没有缓存空值。如果有人高频查询不存在的记录,可能造成缓存穿透。可以加短 TTL 空缓存:
    WINNING_RECORDS_1001_2001 = []
    TTL = 60s
  2. 缓存击穿
    活动详情缓存过期瞬间,如果大量请求同时进来,可能都打到 MySQL。可以考虑:
    互斥锁;
    逻辑过期;
    热点活动提前续期。
  3. 延迟双删
    如果对一致性要求更高,可以在更新数据库后删除缓存,再延迟删除一次,降低并发读写导致的脏缓存概率。
  4. Key 规范化
    可以统一封装 Key 生成方法,避免字符串拼接散落在业务代码里。

高频相关问题

  1. 为什么活动详情要缓存整个 DTO?
    因为活动详情需要多表查询和组装,整体读取频率高,缓存完整 DTO 可以减少数据库访问和对象组装成本。
  2. 为什么中奖记录要两个 Key?
    业务既有按活动查询,也有按奖品查询。两个 Key 可以分别命中对应场景,避免额外过滤或多次查询。
  3. 缓存和数据库不一致怎么办?
    MySQL 是最终事实来源,Redis 是读优化。写后刷新、回滚删除、TTL 过期共同降低不一致时间。
  4. 空结果要不要缓存?
    生产环境建议缓存短 TTL 空值,防止缓存穿透。当前实现还可以继续优化。
  5. Redis 挂了怎么办?
    当前 RedisUtil 捕获异常,查询可以退回 MySQL。Redis 挂了会影响性能,但不应该影响核心业务正确性。

总结

Redis 在抽奖系统里的作用不是简单“加缓存”,而是围绕具体读场景设计:
活动详情读多写少,缓存完整 DTO;
中奖记录按活动和奖品两个维度缓存;
查询采用 Cache Aside;
状态变更后刷新活动缓存;
中奖记录落库后写缓存;
异常回滚时删除缓存;
TTL 避免长期脏数据。
这套缓存设计能显著降低活动页和中奖记录页对 MySQL 的压力,也能作为面试中讲“缓存设计和一致性取舍”的一个完整案例。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/5 4:40:14

AI智能体:未来已来的数字分身,你准备好了吗?

说实话&#xff0c;写这篇文章之前我犹豫了很久。 并不源自于不懂, 恰恰相反, 是懂那么一点儿, 然而越懂却愈发感觉难以表述清楚, 这种感受仿佛你试着向一个从未吃过火锅的人阐释“毛肚七上八下”究竟是几分钟的时长, 他满脸茫然, 你同样满脸茫然。 AI智能体这个东西&#xff0…

作者头像 李华
网站建设 2026/7/5 4:40:09

DsHidMini:三步让PS3手柄在Windows上完美重生的终极指南

DsHidMini&#xff1a;三步让PS3手柄在Windows上完美重生的终极指南 【免费下载链接】DsHidMini Virtual HID Mini-user-mode-driver for Sony DualShock 3 Controllers 项目地址: https://gitcode.com/gh_mirrors/ds/DsHidMini 还在为PS3手柄在Windows系统上的兼容性问…

作者头像 李华
网站建设 2026/7/5 4:38:45

Power BI中替代Excel COUNTIF的DAX计数逻辑

1. 这不是Excel的COUNTIF&#xff0c;而是Power BI里真正能落地的DAX计数逻辑刚接手一个零售客户的数据看板时&#xff0c;我遇到的第一个“拦路虎”不是数据清洗&#xff0c;也不是模型关系&#xff0c;而是一个看似简单的问题&#xff1a;“上个月销售额超5万元的门店有多少家…

作者头像 李华
网站建设 2026/7/6 8:30:08

【设计原则和建议】 方法

静态方法和实例方法 1.静态方法应该是线程安全的 调用方使用静态方法(如Console.Write),调用方会预期静态方法是线程安全的 public static Dictionary<string, object> dict new Dictionary<string, object>();public static ConcurrentDictionary<string, o…

作者头像 李华