这是《Redis 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 本章通过五个项目串起全书知识:商品详情缓存、秒杀库存、Feed 流、排行榜和订单超时关闭。每个项目都从需求出发,给出架构、key 设计、核心代码、风险点和上线清单。
这些方案不是“能跑的 Demo”,而是可以拿去做架构评审的起点。落地时仍要结合自己的数据库能力、流量规模和一致性要求调整参数。
26.1 项目一:商品详情缓存系统
26.1.1 需求描述
电商商品详情页包含基础信息、价格、库存、评论数、推荐列表和运营详情。假设流量特征如下:
读 QPS:常态 5000,大促 50000
写 QPS:常态 100,大促 1000
一致性:价格和状态允许秒级延迟,但不能长期错
可用性:Redis 故障时详情页要能降级
这类场景的核心不是“把对象放进去”,而是拆分数据、控制失效和防止回源风暴。
26.1.2 方案设计
推荐把商品详情拆成多个缓存对象:
| 数据 | 存储结构 | 建议 TTL | 更新方式 |
|---|---|---|---|
| 基础信息 | String JSON / Hash | 30 到 120 分钟 | 更新数据库后删除 |
| 价格 | String JSON | 5 到 30 分钟 | 价格变更删除 |
| 库存 | String + Lua | 活动周期 | 原子扣减 |
| 评论数 | String | 5 分钟 | 异步聚合 |
| 详情描述 | String 压缩 | 24 小时 | 运营保存后删除 |
| 推荐列表 | ZSet / String | 10 分钟 | 定时任务刷新 |
不要把所有字段塞进一个大 JSON。商品详情可能达到几十 KB,一次返回会放大网络与序列化成本,修改任意字段也要重写整个 value。
26.1.3 key 设计
mall:product:base:10001
mall:product:price:10001
mall:product:detail:10001
mall:product:stat:10001
mall:product:recommend:10001
mall:product:empty:10001
统一使用“业务域 + 对象 + 字段 + ID”的结构,便于按前缀监控、清理和治理。
26.1.4 读路径实现
@Service
public class ProductCacheService {
private final StringRedisTemplate redis;
private final ProductMapper productMapper;
private final ObjectMapper objectMapper;
private final Cache<Long, ProductBase> localCache;
private final RateLimiter dbLimiter = RateLimiter.create(2000);
public ProductCacheService(StringRedisTemplate redis,
ProductMapper productMapper,
ObjectMapper objectMapper) {
this.redis = redis;
this.productMapper = productMapper;
this.objectMapper = objectMapper;
this.localCache = Caffeine.newBuilder()
.maximumSize(50_000)
.expireAfterWrite(Duration.ofSeconds(3))
.recordStats()
.build();
}
public ProductDetail getDetail(Long id) {
if (id == null || id <= 0) {
throw new BadRequestException("invalid product id");
}
ProductBase base = localCache.getIfPresent(id);
if (base == null) {
base = getJson("mall:product:base:" + id,
ProductBase.class, () -> productMapper.selectBase(id));
if (base != null) {
localCache.put(id, base);
}
}
if (base == null) {
return null;
}
ProductPrice price = getJson("mall:product:price:" + id,
ProductPrice.class, () -> productMapper.selectPrice(id));
ProductStat stat = getJson("mall:product:stat:" + id,
ProductStat.class, () -> productMapper.selectStat(id));
return ProductDetail.of(base, price, stat);
}
private <T> T getJson(String key, Class<T> type, Supplier<T> loader) {
String value = redis.opsForValue().get(key);
if (value != null) {
if (CacheValues.NULL.equals(value)) {
return null;
}
try {
return objectMapper.readValue(value, type);
} catch (JsonProcessingException e) {
redis.delete(key);
}
}
if (!dbLimiter.tryAcquire(100, TimeUnit.MILLISECONDS)) {
throw new ServiceUnavailableException("database busy");
}
T loaded = loader.get();
if (loaded == null) {
redis.opsForValue().set(key, CacheValues.NULL, Duration.ofSeconds(60));
return null;
}
redis.opsForValue().set(key, writeJson(loaded), randomTtl());
return loaded;
}
private Duration randomTtl() {
Duration base = Duration.ofMinutes(30);
long jitter = ThreadLocalRandom.current().nextLong(0, 180);
return base.plusSeconds(jitter);
}
}
这段实现包含五个关键保护:
- ID 参数校验;
- 本地缓存承接极热点;
- 空值缓存防穿透;
- TTL 随机抖动防雪崩;
- 数据库回源限流。
26.1.5 写路径与一致性
推荐流程:
1. MySQL 事务更新商品
2. 事务提交成功
3. 删除相关缓存 key
4. 删除失败写入补偿表
5. 后台任务重试删除
实现示例:
@Transactional
public void updateBase(ProductBase base) {
productMapper.updateBase(base);
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
invalidateProduct(base.getId());
}
});
}
private void invalidateProduct(Long id) {
List<String> keys = List.of(
"mall:product:base:" + id,
"mall:product:price:" + id,
"mall:product:detail:" + id
);
try {
redis.delete(keys);
} catch (Exception e) {
invalidationMapper.insert(new CacheInvalidation("mall:product", id, keys));
}
}
后台补偿:
@Scheduled(fixedDelay = 1000)
public void retryInvalidation() {
List<CacheInvalidation> tasks = invalidationMapper.selectPending(100);
for (CacheInvalidation task : tasks) {
try {
redis.delete(task.getKeys());
invalidationMapper.markDone(task.getId());
} catch (Exception e) {
invalidationMapper.increaseRetry(task.getId());
}
}
}
对于大型平台,可以把补偿表替换为 Canal 或 Debezium 监听 MySQL binlog,再通过 Kafka 串行消费变更事件,统一处理缓存失效。
26.1.6 风险点与上线清单
| 风险 | 表现 | 处理 |
|---|---|---|
| 空值缓存过长 | 新上架商品查不到 | 空值 TTL 控制在 60 秒内 |
| 详情 value 过大 | Redis 网络带宽飙升 | 压缩、拆 key、CDN 静态化 |
| 热点商品 | 单分片 CPU 高 | 本地缓存 + key 副本 |
| 缓存删除失败 | 页面长期旧数据 | 补偿表或 binlog |
| Redis 故障 | 数据库被打崩 | 限流 + 降级 |
上线清单:
- 压测读路径,确认 miss 时数据库可承受;
- 记录商品 JSON 平均大小和 P99 大小;
- 明确 TTL、抖动范围和空值策略;
- 缓存命中率按前缀上报;
- 删除失败有重试和告警;
- Redis 超时和数据库限流参数已配置;
- 大促前完成热点预热。
26.2 项目二:秒杀库存系统
26.2.1 需求描述
秒杀的典型特征是开始前大量用户刷新页面,开始后瞬时流量放大百倍,库存快速减少,结束后请求应尽早拒绝。
系统目标:
- 不超卖;
- 少卖可控;
- 保护数据库;
- 用户体验可接受;
- 事故可追踪。
26.2.2 总体架构
flowchart LR
Client[用户] --> Gateway[网关限流]
Gateway --> App[秒杀应用]
App --> Local[本地活动状态]
App --> Redis[(Redis Lua 扣减)]
Redis --> MQ[Kafka]
MQ --> Order[订单服务]
Order --> MySQL[(MySQL)]
分层拦截:
| 层 | 动作 | 拦截效果 |
|---|---|---|
| CDN/前端 | 按钮置灰、静态化 | 减少无效请求 |
| 网关 | 用户限流、黑名单、答题 | 控制总流量 |
| 应用 | 活动状态与资格校验 | 拒绝无资格请求 |
| 本地内存 | 活动结束标记、粗粒度库存标记 | 快速失败 |
| Redis Lua | 原子资格与库存扣减 | 最终竞争层 |
| 数据库 | 唯一约束 + 状态机 | 兜底防重 |
不要把所有请求直接打到 Redis。Redis 虽快,但也有容量和 CPU 上限。
26.2.3 key 设计
seckill:activity:1001 # 活动 Hash
seckill:stock:1001 # 剩余库存
seckill:user:1001 # 已购买用户 Set
seckill:order:1001:uid # 用户订单资格
seckill:result:1001 # 结果 ZSet
seckill:end:1001 # 结束标记
初始化示例:
HSET seckill:activity:1001 skuId 200001 total 1000 start 1787568000000 end 1787571600000 status STARTED
SET seckill:stock:1001 1000
EXPIRE seckill:user:1001 7200
26.2.4 Lua 原子扣减
把“活动校验、用户重复校验、库存扣减、写入资格”放在一个 Lua 脚本中执行:
local activityKey = KEYS[1]
local stockKey = KEYS[2]
local userKey = KEYS[3]
local orderKey = KEYS[4]
local userId = ARGV[1]
local now = tonumber(ARGV[2])
local holdSeconds = tonumber(ARGV[3])
local status = redis.call('HGET', activityKey, 'status')
if status ~= 'STARTED' then
return -1
end
local startAt = tonumber(redis.call('HGET', activityKey, 'start') or '0')
local endAt = tonumber(redis.call('HGET', activityKey, 'end') or '0')
if now < startAt or now > endAt then
return -2
end
if redis.call('SISMEMBER', userKey, userId) == 1 then
return -3
end
local stock = tonumber(redis.call('GET', stockKey) or '0')
if stock <= 0 then
return -4
end
redis.call('DECR', stockKey)
redis.call('SADD', userKey, userId)
redis.call('SET', orderKey, userId, 'EX', holdSeconds)
redis.call('ZADD', activityKey .. ':result', now, userId)
return 1
Java 调用:
@Service
public class SeckillService {
private static final RedisScript<Long> SECKILL_SCRIPT = new DefaultRedisScript<>("""
local status = redis.call('HGET', KEYS[1], 'status')
if status ~= 'STARTED' then return -1 end
if redis.call('SISMEMBER', KEYS[3], ARGV[1]) == 1 then return -3 end
local stock = tonumber(redis.call('GET', KEYS[2]) or '0')
if stock <= 0 then return -4 end
redis.call('DECR', KEYS[2])
redis.call('SADD', KEYS[3], ARGV[1])
redis.call('SET', KEYS[4], ARGV[1], 'EX', tonumber(ARGV[3]))
return 1
""", Long.class);
public SeckillResult submit(Long activityId, Long userId) {
if (!activityStateHolder.isOpen(activityId)) {
return SeckillResult.notStarted();
}
Long result = redisTemplate.execute(
SECKILL_SCRIPT,
List.of(
"seckill:activity:" + activityId,
"seckill:stock:" + activityId,
"seckill:user:" + activityId,
"seckill:order:" + activityId + ":" + userId
),
String.valueOf(userId),
String.valueOf(System.currentTimeMillis()),
"900"
);
if (result == null || result <= 0) {
return SeckillResult.fail(result);
}
seckillOrderProducer.send(SeckillOrderEvent.of(activityId, userId));
return SeckillResult.success();
}
}
Lua 脚本解决的是 Redis 内部多个命令的原子性,不等于整个业务链路的事务。Redis 扣减成功后,还需要消息链路和数据库约束兜底。
26.2.5 数据库兜底
Redis 扣减成功只代表获得下单资格,订单仍要落数据库。
表结构要点:
CREATE TABLE seckill_order (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
activity_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
order_no VARCHAR(64) NOT NULL,
status VARCHAR(20) NOT NULL,
create_time DATETIME(3) NOT NULL,
UNIQUE KEY uk_activity_user (activity_id, user_id),
UNIQUE KEY uk_order_no (order_no)
);
库存预留使用条件更新:
UPDATE seckill_activity
SET reserved_stock = reserved_stock + 1
WHERE activity_id = ?
AND reserved_stock < total_stock;
如果数据库更新失败,要释放 Redis 中的资格并回补库存:
public void compensate(Long activityId, Long userId) {
redisTemplate.execute(releaseScript(),
List.of("seckill:stock:" + activityId, "seckill:user:" + activityId),
String.valueOf(userId));
}
回补脚本同样要保证“检查用户资格、删除用户、增加库存”原子执行。即使如此,仍可能因为消息丢失、重复补偿或应用宕机产生少量误差,活动结束后必须对账。
26.2.6 少卖与超卖
超卖的防线:
- Lua 保证 Redis 内部判断和扣减原子;
- 数据库唯一键防止重复下单;
- 数据库库存条件更新防止超发;
- 订单状态机防止重复支付。
少卖的原因:
- Redis 扣减成功但消息丢失;
- 用户获得资格但未支付;
- 应用宕机导致资格未落库;
- 回补脚本重复执行。
处理方式:
- Kafka 发送使用确认和本地事件表;
- 消费端幂等;
- 15 分钟未支付自动取消并回补库存;
- 活动结束后对账 Redis、数据库和订单;
- 最终库存以数据库为准。
26.2.7 上线清单
- 压测 Lua 脚本,确认 Redis 单分片最大 QPS;
- 活动数据和库存提前预热;
- 用户 Set 设置与活动等长的过期时间;
- 数据库唯一键和状态机已验证;
- 消息链路有重试、死信和幂等;
- 本地结束标记能快速切换;
- 网关限流按用户和 IP 分层;
- 全链路日志包含 activityId、userId 和 traceId;
- 演练 Redis 故障、应用重启、数据库慢查询场景。
26.3 项目三:Feed 流与时间线
26.3.1 需求描述
实现用户主页时间线和关注流:
- 发布动态后,粉丝能尽快看到;
- 支持关注、取消关注;
- 支持分页读取;
- 大 V 粉丝量可能达到千万;
- 热门内容有推荐流量。
常见模式:
| 模式 | 写入方式 | 读成本 | 适用 |
|---|---|---|---|
| 推模式 | 发布时写入每个粉丝收件箱 | 低 | 粉丝少 |
| 拉模式 | 读时查关注者最新动态 | 高 | 大 V |
| 推拉结合 | 普通用户推,大 V 拉 | 可控 | 大多数社交系统 |
26.3.2 key 设计
feed:outbox:2001 # 用户发布箱 ZSet
feed:inbox:3001 # 用户收件箱 ZSet
feed:following:3001 # 关注集合
feed:follower:2001 # 粉丝集合
post:content:90001 # 动态内容
ZSet 的 member 保存动态 ID,score 保存发布时间戳。动态正文不要放进 ZSet,否则同一个 ID 出现在多个收件箱时会被复制大量份。
26.3.3 发布动态
public void publishPost(Post post) {
postMapper.insert(post);
redis.delete("post:content:" + post.getId());
stringRedisTemplate.opsForZSet().add(
"feed:outbox:" + post.getUserId(),
String.valueOf(post.getId()),
post.getPublishedAt().toEpochMilli()
);
if (isBigV(post.getUserId())) {
return;
}
fanoutService.asyncFanout(post);
}
普通用户扩散不要使用 SMEMBERS 一次读取所有粉丝。应分批读取,或把扩散任务交给 Kafka:
public void fanout(Post post) {
String cursor = ScanOptions.SCAN_CURSOR_START;
do {
ScanOptions options = ScanOptions.scanOptions().count(1000).build();
try (Cursor<String> scan = redis.opsForSet()
.scan("feed:follower:" + post.getUserId(), options)) {
while (scan.hasNext()) {
String followerId = scan.next();
stringRedisTemplate.opsForZSet().add(
"feed:inbox:" + followerId,
String.valueOf(post.getId()),
post.getPublishedAt().toEpochMilli()
);
}
cursor = scan.getCursor();
}
} while (!"0".equals(cursor));
}
生产实现中,粉丝关系通常以数据库为主,Redis 为辅,并由异步任务按批次展开。收件箱要裁剪长度:
public void trimInbox(Long userId) {
stringRedisTemplate.opsForZSet()
.removeRange("feed:inbox:" + userId, 0, -1001);
}
26.3.4 读取 Feed
普通用户直接读收件箱:
public List<Post> readInbox(Long userId, long maxScore, int limit) {
Set<String> ids = stringRedisTemplate.opsForZSet()
.reverseRangeByScore("feed:inbox:" + userId,
Double.NEGATIVE_INFINITY, maxScore, 0, limit);
return loadPosts(ids);
}
大 V 内容采用拉模式合并:
public List<Post> readTimeline(Long userId, long maxScore, int limit) {
Set<String> following = stringRedisTemplate.opsForSet()
.members("feed:following:" + userId);
if (following == null || following.isEmpty()) {
return List.of();
}
List<Post> merged = new ArrayList<>();
for (String authorId : following) {
Set<String> ids = stringRedisTemplate.opsForZSet()
.reverseRangeByScore("feed:outbox:" + authorId,
Double.NEGATIVE_INFINITY, maxScore, 0, limit);
merged.addAll(loadPosts(ids));
}
return merged.stream()
.sorted(Comparator.comparingLong(Post::getPublishedAt).reversed())
.limit(limit)
.toList();
}
当关注数很大时,不要在请求路径全量合并。常见优化是:
- 只合并活跃关注者和近期发布者;
- 为用户生成独立缓存时间线;
- 推荐流与关注流分开;
- 深翻页改用搜索服务或数据库游标;
- 热门内容走单独候选池。
26.3.5 取消关注
取消关注不能立刻遍历删除收件箱中的所有该作者内容,代价太高。推荐:
- 收件箱只存 postId 和时间;
- 读取时批量加载动态详情;
- 动态详情中包含作者 ID;
- 过滤已取消关注的作者;
- 不足一页时继续读取补齐。
private List<Post> filterBlockedAuthors(Long userId, List<Post> posts) {
Set<String> following = stringRedisTemplate.opsForSet()
.members("feed:following:" + userId);
return posts.stream()
.filter(post -> following.contains(String.valueOf(post.getUserId())))
.toList();
}
26.3.6 风险点
| 风险 | 处理 |
|---|---|
| 大 V 发布扩散风暴 | 大 V 只写 outbox,读取时拉取合并 |
| 收件箱无限增长 | ZSet 裁剪 + TTL 策略 |
| 动态内容缓存失效 | 数据库为事实源,读时回源 |
| 排序分数重复 | score 加序列号或使用时间戳 + ID |
| 长期冷用户占内存 | 活跃用户才保留 inbox |
| 删除动态 | 删除 post 缓存,读取时过滤不存在动态 |
26.4 项目四:实时排行榜
26.4.1 需求描述
实现商品销量榜、主播人气榜、游戏积分榜:
- 实时更新分数;
- 查询 Top N;
- 查询用户排名;
- 按日、周、月维度统计;
- 支持历史榜单落库。
ZSet 是天然选择:ZINCRBY 更新分数,ZREVRANGE 查询排名,ZREVRANK 查询成员名次。
26.4.2 key 设计
rank:product:daily:20260825
rank:product:weekly:2026-W35
rank:product:monthly:202608
rank:anchor:room:90001
rank:history:product:20260825
按日期拆 key 有三个好处:
- 天然隔离周期;
- 方便设置过期;
- 历史榜单可归档后清理。
26.4.3 更新分数
public void increaseSales(Long productId, int count) {
String daily = "rank:product:daily:" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
String weekly = "rank:product:weekly:" + weekKey();
String monthly = "rank:product:monthly:" + monthKey();
redis.executePipelined(new SessionCallback<Void>() {
@Override
public Void execute(RedisOperations operations) {
operations.opsForZSet().incrementScore(daily, String.valueOf(productId), count);
operations.opsForZSet().incrementScore(weekly, String.valueOf(productId), count);
operations.opsForZSet().incrementScore(monthly, String.valueOf(productId), count);
return null;
}
});
}
分数更新是高频写场景。如果每次销售都写数据库,压力较大,可以用 Redis 聚合,再定时落库。
26.4.4 查询 Top N 和用户排名
public List<RankItem> topN(String rankKey, int n) {
Set<ZSetOperations.TypedTuple<String>> tuples =
stringRedisTemplate.opsForZSet().reverseRangeWithScores(rankKey, 0, n - 1);
if (tuples == null) {
return List.of();
}
List<RankItem> items = new ArrayList<>();
int rank = 1;
for (ZSetOperations.TypedTuple<String> tuple : tuples) {
items.add(new RankItem(rank++, Long.valueOf(tuple.getValue()), tuple.getScore()));
}
return items;
}
public RankItem getUserRank(String rankKey, Long userId) {
Long rank = stringRedisTemplate.opsForZSet()
.reverseRank(rankKey, String.valueOf(userId));
Double score = stringRedisTemplate.opsForZSet()
.score(rankKey, String.valueOf(userId));
if (rank == null || score == null) {
return null;
}
return new RankItem(rank + 1, userId, score);
}
排行榜展示可以再做一层短 TTL 缓存,避免 Top N 每次都打到 Redis。
26.4.5 同分排序
ZSet 排名在 score 相同时按成员字典序,不一定符合业务需求。例如业务要求“分数相同,更早达到者排前面”。
解决方式:
- ZSet 取出候选集;
- 应用层用
score + timestamp精确排序; - 必要时用 Redis Hash 保存成员的扩展排序字段。
List<RankItem> items = loadCandidates(rankKey, 500);
return items.stream()
.sorted(Comparator.comparingDouble(RankItem::score).reversed()
.thenComparingLong(RankItem::achievedAt))
.limit(100)
.toList();
不建议把复杂业务编码进 double score,因为浮点精度和调试成本会让排障变得困难。
26.4.6 历史榜单归档
每日任务:
@Scheduled(cron = "0 10 0 * * ?")
public void archiveDailyRank() {
String key = "rank:product:daily:" + LocalDate.now().minusDays(1)
.format(DateTimeFormatter.BASIC_ISO_DATE);
Set<ZSetOperations.TypedTuple<String>> tuples =
stringRedisTemplate.opsForZSet().reverseRangeWithScores(key, 0, 999);
if (tuples == null || tuples.isEmpty()) {
return;
}
rankHistoryMapper.batchInsert(buildRows(key, tuples));
stringRedisTemplate.expire(key, Duration.ofDays(7));
}
归档原则:
- Redis 保留短期和实时数据;
- 数据库或对象存储保留历史数据;
- 归档任务幂等;
- 归档失败告警;
- 榜单支持重算。
26.5 项目五:订单超时关闭
26.5.1 需求描述
订单创建后 15 分钟未支付要自动关闭,并释放库存、优惠券和积分。
常见方案对比:
| 方案 | 精度 | 可靠性 | 复杂度 | 适用 |
|---|---|---|---|---|
| 定时扫表 | 分钟级 | 高 | 低 | 订单量小 |
| JDK DelayQueue | 秒级 | 低 | 低 | 单机、可丢任务 |
| Redis ZSet 轮询 | 秒级 | 中 | 中 | 中小规模 |
| Redis Stream 消费 | 秒级 | 中高 | 中 | 需要消费确认 |
| RocketMQ 延迟消息 | 取决于队列 | 高 | 中 | 大规模生产 |
这里使用 ZSet 提升时效,用数据库状态机和定时扫表兜底。
26.5.2 key 设计
order:timeout:zset # score 为超时时间戳
order:lock:close:10001 # 关闭互斥锁
order:status:10001 # 状态缓存
创建订单:
@Transactional
public void createOrder(CreateOrderRequest request) {
Order order = orderDomainService.create(request);
orderMapper.insert(order);
stringRedisTemplate.opsForZSet().add(
"order:timeout:zset",
String.valueOf(order.getId()),
order.getCreateTime().plusMinutes(15).toEpochMilli()
);
}
26.5.3 扫描到期订单
@Scheduled(fixedDelay = 1000)
public void closeTimeoutOrders() {
long now = System.currentTimeMillis();
Set<String> orderIds = stringRedisTemplate.opsForZSet()
.rangeByScore("order:timeout:zset", 0, now, 0, 99);
if (orderIds == null || orderIds.isEmpty()) {
return;
}
orderIds.forEach(orderId -> closeIfTimeout(Long.valueOf(orderId)));
}
关闭订单:
public void closeIfTimeout(Long orderId) {
String lockKey = "order:lock:close:" + orderId;
String token = UUID.randomUUID().toString();
Boolean locked = stringRedisTemplate.opsForValue()
.setIfAbsent(lockKey, token, Duration.ofSeconds(10));
if (!Boolean.TRUE.equals(locked)) {
return;
}
try {
int updated = orderMapper.closeIfUnpaid(orderId,
OrderStatus.UNPAID, OrderStatus.CLOSED);
if (updated > 0) {
inventoryService.release(orderId);
couponService.release(orderId);
eventPublisher.publish(OrderClosedEvent.of(orderId));
}
stringRedisTemplate.opsForZSet()
.remove("order:timeout:zset", String.valueOf(orderId));
} finally {
releaseLock(lockKey, token);
}
}
SQL 条件更新是关键:
UPDATE `order`
SET status = 'CLOSED', close_time = NOW(3), version = version + 1
WHERE order_id = ?
AND status = 'UNPAID'
AND create_time + INTERVAL 15 MINUTE <= NOW(3);
只有更新成功才释放资源,避免重复释放。
26.5.4 支付成功与超时关闭竞争
存在两个并发动作:
支付服务:UNPAID -> PAID
超时任务:UNPAID -> CLOSED
必须依赖数据库状态机条件更新:
UPDATE `order`
SET status = 'PAID', pay_time = NOW(3)
WHERE order_id = ?
AND status = 'UNPAID';
谁更新成功谁生效,失败方放弃。不要只依赖 Redis 锁判断订单状态。
26.5.5 可靠性兜底
Redis ZSet 可能丢任务或漏删,因此需要数据库扫描兜底:
@Scheduled(cron = "0 */5 * * * ?")
public void scanDatabaseTimeoutOrders() {
List<Order> orders = orderMapper.selectTimeoutUnpaid(500);
orders.forEach(order -> closeIfTimeout(order.getId()));
}
上线前必须演练:
- Redis 清空;
- 关闭任务重复部署;
- 支付回调和关闭任务同时到达;
- 释放库存接口超时;
- 消息重复消费。
26.6 项目通用工程规范
26.6.1 key 治理
每个项目上线前提交 key 清单:
| key 前缀 | 类型 | 预估数量 | 平均大小 | TTL | 负责人 |
|---|---|---|---|---|---|
mall:product:base |
String | 100 万 | 2KB | 1h | 商品组 |
seckill:stock |
String | 1000 | 8B | 活动期 | 交易组 |
feed:inbox |
ZSet | 500 万 | 20KB | 7d | 社区组 |
order:timeout |
ZSet | 100 万 | 20B | 1h | 订单组 |
没有 TTL、没有负责人、没有容量预估的 key 不允许上线。
26.6.2 幂等设计
所有写操作都要有幂等键:
业务动作 + 实体 ID + 版本或请求 ID
常见幂等手段:
- 数据库唯一键;
- 状态机条件更新;
- Redis
SET NX EX保存请求 ID; - 消费端按事件 ID 去重;
- 乐观锁版本号。
Redis 幂等键要设置 TTL,并用数据库唯一约束兜底。
26.6.3 降级预案
每个项目必须回答:
| 问题 | 商品缓存 | 秒杀 | Feed | 排行榜 | 订单关闭 |
|---|---|---|---|---|---|
| Redis 不可用 | 本地缓存/查库限流 | 活动暂停或排队 | 降级数据库分页 | 返回缓存快照 | 数据库扫表 |
| 数据丢失 | 重新加载 | 数据库对账 | 重建 outbox | 重新统计 | 数据库扫表 |
| 流量突增 | 限流降级 | 网关拦截 | 静态化 | 快照缓存 | 延迟执行 |
| 数据错误 | 删除重建 | 停售/回补 | 重建时间线 | 重算 | 状态机校正 |
26.6.4 观测指标
通用指标:
cache_hit_total
cache_miss_total
cache_load_duration_ms
redis_command_duration_ms
redis_error_total
lua_script_result_total
idempotent_reject_total
database_fallback_total
业务指标:
| 项目 | 指标 |
|---|---|
| 商品缓存 | 命中率、旧值率、回源 QPS |
| 秒杀 | 扣减成功率、售罄时间、消息落库延迟 |
| Feed | inbox 长度、扩散延迟、读取补页次数 |
| 排行榜 | 更新 QPS、TopN 计算耗时、归档成功率 |
| 订单关闭 | 关闭延迟、重复关闭拒绝数、资源释放成功率 |
26.7 本章小结
- 商品详情缓存要拆字段、控制 value 大小,并为删除失败设计补偿;
- 秒杀必须在网关、应用、Redis、数据库多层防护,Lua 只解决 Redis 内部原子性;
- Feed 流根据大 V 与普通用户采用推拉结合,收件箱要裁剪,内容与 ID 分离;
- 排行榜用 ZSet 天然适合,但历史榜单要落库,同分排序要单独设计;
- 订单超时关闭用 Redis 提升时效,用数据库状态机和定时扫表保证可靠性;
- 所有项目都需要 key 清单、容量预估、幂等、降级和监控。
26.8 思考题
- 商品缓存删除成功但 binlog 事件后又到达,如何避免旧事件把缓存重新删除或写入旧值?
- 秒杀 Lua 脚本执行成功但 Kafka 消息发送失败,系统应如何恢复?
- 千万粉丝大 V 发布动态时,为什么不能直接全量推送到收件箱?
- 排行榜 score 相同但业务要求先达到者排前面,如何设计存储结构?
- Redis ZSet 订单超时任务与支付回调并发,如何保证最终状态正确?