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 订单超时任务与支付回调并发,如何保证最终状态正确?