当前位置: 首页 > news >正文

抽奖页高频查询优化: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 的压力,也能作为面试中讲“缓存设计和一致性取舍”的一个完整案例。

http://www.cnnetsun.cn/news/3152030.html

相关文章:

  • AI智能体:未来已来的数字分身,你准备好了吗?
  • DsHidMini:三步让PS3手柄在Windows上完美重生的终极指南
  • Power BI中替代Excel COUNTIF的DAX计数逻辑
  • Trilium中文版终极指南:免费开源笔记软件如何彻底改变你的知识管理
  • 【设计原则和建议】 方法
  • 如何3分钟为Windows和Linux安装精美macOS光标主题:免费开源桌面美化终极指南
  • 再回到技术面,研究 T-SQL 的 UNION、EXISTS、EXCEPT、INTERSECT 运算符。
  • freerots接口代码示例
  • 《通信电子线路》全套PPT课件
  • OpenClaw 2.7.9 搭建实操,桌面自动化工具避坑完整流程
  • 怎样在5分钟内免费将图片转换为SVG矢量图形:SVGcode实用指南
  • DiffuMeta:基于代数语言与扩散Transformer的3D超材料AI设计
  • 短视频穿搭性别偏好分析程序,区分男女用户对潮流色彩,版型的不同偏好。
  • 5个简单步骤:在Windows上解锁Apple触控板的完整功能
  • 开题撰写告别反复改稿,okbiye 一站式 AI 开题报告创作功能深度解析
  • 告别命令行恐惧:3分钟学会用Crontab UI可视化管理Linux定时任务
  • SciPy L-BFGS-B 实战:3个关键参数调优与收敛速度对比分析
  • 美团 Leaf-snowflake 分布式 ID 生成器 k8s 改造的想法
  • 164、PCIE在VMware中的虚拟化:当硬件变成“软件定义”
  • 解决层高、角柱难题!抚州美伦熙语别墅土建井道定制曳引电梯实录
  • Unitree RL Gym:四足机器人强化学习框架完全指南
  • 轻量级AI智能体:安全、场景与硬件穿透的工程实践
  • AI绘画本地插件部署指南:实现“指哪改哪”的精准图像编辑
  • 终极指南:如何3步免费下载百度文库文档(开源脚本完整教程)
  • 终极指南:用LeetDown轻松为旧款iPhone降级,让设备重获新生
  • 送礼选酒怎么选,鹤壁专业不出错
  • AutoUnipus:智能自动化解放U校园网课学习时间
  • 公务员备考培训班TOP3排名:哪些机构真正值得报?2026年考生实测横评
  • 平阳室内宴会厅布置攻略
  • 程序员应知——善于借鉴