RedisNotes

第 26 章:综合项目实战:把 Redis 用进真实系统

zjc 于 2026-01-26 发布

这是《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);
    }
}

这段实现包含五个关键保护:

  1. ID 参数校验;
  2. 本地缓存承接极热点;
  3. 空值缓存防穿透;
  4. TTL 随机抖动防雪崩;
  5. 数据库回源限流。

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 故障 数据库被打崩 限流 + 降级

上线清单:

26.2 项目二:秒杀库存系统

26.2.1 需求描述

秒杀的典型特征是开始前大量用户刷新页面,开始后瞬时流量放大百倍,库存快速减少,结束后请求应尽早拒绝。

系统目标:

  1. 不超卖;
  2. 少卖可控;
  3. 保护数据库;
  4. 用户体验可接受;
  5. 事故可追踪。

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 少卖与超卖

超卖的防线:

  1. Lua 保证 Redis 内部判断和扣减原子;
  2. 数据库唯一键防止重复下单;
  3. 数据库库存条件更新防止超发;
  4. 订单状态机防止重复支付。

少卖的原因:

  1. Redis 扣减成功但消息丢失;
  2. 用户获得资格但未支付;
  3. 应用宕机导致资格未落库;
  4. 回补脚本重复执行。

处理方式:

26.2.7 上线清单

26.3 项目三:Feed 流与时间线

26.3.1 需求描述

实现用户主页时间线和关注流:

常见模式:

模式 写入方式 读成本 适用
推模式 发布时写入每个粉丝收件箱 粉丝少
拉模式 读时查关注者最新动态 大 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();
}

当关注数很大时,不要在请求路径全量合并。常见优化是:

  1. 只合并活跃关注者和近期发布者;
  2. 为用户生成独立缓存时间线;
  3. 推荐流与关注流分开;
  4. 深翻页改用搜索服务或数据库游标;
  5. 热门内容走单独候选池。

26.3.5 取消关注

取消关注不能立刻遍历删除收件箱中的所有该作者内容,代价太高。推荐:

  1. 收件箱只存 postId 和时间;
  2. 读取时批量加载动态详情;
  3. 动态详情中包含作者 ID;
  4. 过滤已取消关注的作者;
  5. 不足一页时继续读取补齐。
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 需求描述

实现商品销量榜、主播人气榜、游戏积分榜:

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 有三个好处:

  1. 天然隔离周期;
  2. 方便设置过期;
  3. 历史榜单可归档后清理。

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 相同时按成员字典序,不一定符合业务需求。例如业务要求“分数相同,更早达到者排前面”。

解决方式:

  1. ZSet 取出候选集;
  2. 应用层用 score + timestamp 精确排序;
  3. 必要时用 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));
}

归档原则:

  1. Redis 保留短期和实时数据;
  2. 数据库或对象存储保留历史数据;
  3. 归档任务幂等;
  4. 归档失败告警;
  5. 榜单支持重算。

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()));
}

上线前必须演练:

  1. Redis 清空;
  2. 关闭任务重复部署;
  3. 支付回调和关闭任务同时到达;
  4. 释放库存接口超时;
  5. 消息重复消费。

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

常见幂等手段:

  1. 数据库唯一键;
  2. 状态机条件更新;
  3. Redis SET NX EX 保存请求 ID;
  4. 消费端按事件 ID 去重;
  5. 乐观锁版本号。

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 本章小结

26.8 思考题

  1. 商品缓存删除成功但 binlog 事件后又到达,如何避免旧事件把缓存重新删除或写入旧值?
  2. 秒杀 Lua 脚本执行成功但 Kafka 消息发送失败,系统应如何恢复?
  3. 千万粉丝大 V 发布动态时,为什么不能直接全量推送到收件箱?
  4. 排行榜 score 相同但业务要求先达到者排前面,如何设计存储结构?
  5. Redis ZSet 订单超时任务与支付回调并发,如何保证最终状态正确?