抽奖页高频查询优化: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:
@Configuration
public class RedisUtil {
@Autowired
private StringRedisTemplate stringRedisTemplate;
public boolean setEx(String key, String value, Long time) {
try {
stringRedisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);
return true;
} catch (Exception e) {
logger.error("RedisUtil error,set({},{},{})", key, value, time, e);
return false;
}
}
public String get(String key) {
try {
return StringUtils.hasText(key)
? stringRedisTemplate.opsForValue().get(key)
: null;
} catch (Exception e) {
logger.error("RedisUtil error,get({})", key, e);
return null;
}
}
public boolean delete(String... keys) {
try {
if (keys == null || keys.length == 0) {
return true;
}
if (keys.length == 1) {
stringRedisTemplate.delete(keys[0]);
} else {
stringRedisTemplate.delete(Arrays.asList(keys));
}
return true;
} catch (Exception e) {
logger.error("RedisUtil delete error, keys: {}", Arrays.toString(keys), e);
return false;
}
}
}
这里选择 String JSON 存储,而不是复杂 Redis Hash。原因是:
活动详情和中奖记录通常是整体读取;
DTO 结构比较复杂;
JSON 方便序列化和调试;
学习型项目实现成本更低。
活动详情缓存设计
活动详情缓存前缀和 TTL:
private final String ACTIVITY_PREFIX = "ACTIVITY_";
// 3 day
private final Long ACTIVITY_TIMEOUT = 60 * 60 * 24 * 3L;
Key 示例:
ACTIVITY_1001
活动详情查询逻辑:
@Override
public ActivityDetailDTO getActivityDetail(Long activityId) {
if (null == activityId) {
return null;
}
ActivityDetailDTO detailDTO = getActivityFromCache(activityId);
if (null != detailDTO) {
return detailDTO;
}
ActivityDO activityDO = 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);
ActivityDetailDTO activityDetailDTO = convertToActivityDetailDTO(
activityDO,
prizeDOList,
activityUserDOList,
activityPrizeDOList
);
cacheActivity(activityDetailDTO);
return activityDetailDTO;
}
这段代码体现了 Cache Aside:
先查 Redis;
命中直接返回;
未命中查多张表;
组装 DTO;
回写 Redis。
读取缓存:
private ActivityDetailDTO getActivityFromCache(Long activityId) {
String cacheKey = ACTIVITY_PREFIX + activityId;
String json = redisUtil.get(cacheKey);
if (StringUtils.isBlank(json)) {
return null;
}
try {
return JacksonUtil.readValue(json, ActivityDetailDTO.class);
} catch (Exception e) {
redisUtil.delete(cacheKey);
return null;
}
}
这里有个细节:如果缓存反序列化失败,会删除损坏缓存,避免后续一直读到坏数据。
写入缓存:
private void cacheActivity(ActivityDetailDTO activityDetailDTO) {
if (null == activityDetailDTO || null == activityDetailDTO.getActivityId()) {
return;
}
redisUtil.setEx(
ACTIVITY_PREFIX + activityDetailDTO.getActivityId(),
JacksonUtil.writeValueAsString(activityDetailDTO),
ACTIVITY_TIMEOUT
);
}
活动创建后立即缓存
创建活动时,系统会保存三类数据:
活动基础信息;
活动奖品关联;
活动用户关联。
保存完成后,立即组装完整详情并写入 Redis:
ActivityDetailDTO activityDetailDTO = convertToActivityDetailDTO(
activityDO,
prizeDOList,
activityUserDOList,
activityPrizeDOList
);
cacheActivity(activityDetailDTO);
这样活动创建成功后,后续查询详情可以直接命中 Redis。
状态变更后刷新活动缓存
抽奖过程中,活动、奖品、用户状态会发生变化。
比如:
奖品 INIT -> COMPLETED
中奖用户 INIT -> COMPLETED
活动 RUNING -> COMPLETED
状态流转后,ActivityStatusManagerImpl 会刷新活动缓存:
if (update) {
activityService.cacheActivity(convertActivityStatusDTO.getActivityId());
}
cacheActivity(activityId) 会重新查数据库并覆盖 Redis:
public void cacheActivity(Long activityId) {
ActivityDO activityDO = 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);
ActivityDetailDTO activityDetailDTO = convertToActivityDetailDTO(
activityDO,
prizeDOList,
activityUserDOList,
activityPrizeDOList
);
cacheActivity(activityDetailDTO);
}
这就是“写后刷新缓存”。
中奖记录缓存设计
中奖记录缓存前缀和 TTL:
private final String WINNING_RECORDS_PREFIX = "WINNING_RECORDS_";
private final Long WINNING_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
);
}
缓存方法:
private void cacheWinningRecords(String key,
List<WinningRecordDO> winningRecordDOList,
Long time) {
if (!StringUtils.hasText(key) || CollectionUtils.isEmpty(winningRecordDOList)) {
return;
}
redisUtil.setEx(
WINNING_RECORDS_PREFIX + key,
JacksonUtil.writeValueAsString(winningRecordDOList),
time
);
}
中奖记录查询:Cache Aside
查询入口:
public List<WinningRecordDTO> getRecords(ShowWinningRecordsParam param) {
String key = null == param.getPrizeId()
? String.valueOf(param.getActivityId())
: param.getActivityId() + "_" + param.getPrizeId();
List<WinningRecordDO> winningRecords = getWinningRecords(key);
if (!CollectionUtils.isEmpty(winningRecords)) {
return convertToWinningRecordDTOList(winningRecords);
}
winningRecords = winningRecordMapper.selectByActivityIdOrPrizeId(
param.getActivityId(),
param.getPrizeId()
);
if (CollectionUtils.isEmpty(winningRecords)) {
return Arrays.asList();
}
cacheWinningRecords(key, winningRecords, WINNING_RECORDS_TIMEOUT);
return convertToWinningRecordDTOList(winningRecords);
}
流程:
根据 activityId / prizeId 拼 Key
-> 查 Redis
-> 命中:返回
-> 未命中:查 MySQL
-> 查到:写 Redis
-> 返回 DTO
回滚时删除缓存
抽奖消费异常时,会回滚中奖记录:
private void rollbackWinner(DrawPrizeParam param) {
drawPrizeService.deleteRecords(param.getActivityId(), param.getPrizeId());
}
删除中奖记录时,同时删除缓存:
public void deleteRecords(Long activityId, Long prizeId) {
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 的压力,也能作为面试中讲“缓存设计和一致性取舍”的一个完整案例。
更多推荐




所有评论(0)