1. 项目背景

1.1 整体场景

前面 15 章我们分别掌握了 Redis 的核心数据结构、缓存模式、Lua 原子操作、事务管道和基础排障。每一章都是一个独立技能点——String 做验证码计数器、Hash 做购物车、List 做异步队列、Set 做社交关系、ZSet 做排行榜、Bitmap 做签到、HyperLogLog 做 UV 统计、GEO 做附近门店、Cache Aside 做商品缓存、Lua 做原子扣减、Pipeline 做批量积分、SCAN 做 key 巡检。

但真实项目不会按章节出题。一个电商首页打开后,用户看到的登录入口、商品列表、购物车角标、活动排行榜、签到入口、附近门店——这些功能同时存在,同时读写 Redis。每个功能各写各的 key、各管各的 TTL,很快就会变成一锅"Redis 粥"——key 命名混乱、TTL 参差不齐、热点 key 集中、缓存策略矛盾。

本章的目标是把基础篇所有技能点融会贯通,交付一个"小而全"的电商缓存与互动系统。它不是生产级项目,但它是一个可运行、可验证、可演示的 Redis 实战作品。

1.2 系统功能全景

┌─────────────────────────────────────────────────────────────┐
│  电商缓存与互动系统                                           │
├─────────────┬─────────────┬─────────────┬──────────────────┤
│  用户模块    │  商品模块    │  互动模块    │  运维模块         │
├─────────────┼─────────────┼─────────────┼──────────────────┤
│ 登录验证码   │ 商品详情缓存 │ 积分排行榜   │ key 规范巡检      │
│ 短信防刷     │ 库存快照     │ 月度签到     │ 慢查询监控        │
│ 用户会话     │ 购物车       │ 附近门店     │ 命中率统计        │
│             │ 异步通知队列 │ 好友推荐     │ 大 key 告警       │
│             │ Lua 库存扣减 │ 接口 UV     │ 内存水位          │
└─────────────┴─────────────┴─────────────┴──────────────────┘

1.3 验收标准

维度 标准 验证方式
核心接口命中率 > 80% INFO stats + 业务打点
商品详情 P95 延迟 < 30ms 压测统计
缓存命中率 > 90% (商品详情) 业务维度统计
key 命名规范 全部符合 mall:* 前缀 SCAN 巡检
无 TTL 的临时 key 0 个 自动化巡检脚本
大 key 数量 0 个 (> 1MB) --bigkeys 扫描
慢命令 0 条 (> 10ms) SLOWLOG GET
库存扣减准确性 无超卖 并发压测验证

2. 项目设计

2.1 第一幕:这章像期末大作业

小胖(两眼放光):"这章像期末大作业!我要做一个能登录、能逛商品、能加购物车、还能看排行榜、能签到、能搜附近门店的小商城。Redis 是不是全部 SET 进去就行?"

小白:"你又来了。全用 String 会让结构不可读、不可维护。购物车要按商品维度改数量——应该用 Hash。排行榜要排序——应该用 ZSet。签到只要存 0/1——应该用 Bitmap。门店要按经纬度查——应该用 GEO。商品详情缓存要做读多写少的加速——应该用 Cache Aside。你不根据数据特征选结构,就像用螺丝刀钉钉子,能砸进去但拆的时候会哭。"

大师:"我给你一个技术选型速查表,决策逻辑三句话——需要排序选 ZSet,需要去重集合运算选 Set,需要对象字段操作选 Hash,海量布尔值选 Bitmap,位置查询选 GEO,短生命周期临时值选 String+EX,多步原子逻辑选 Lua。"

数据结构选型决策树:

  数据有什么特征?
  ├── 需要排序(积分/时间/热度)
  │   └── ZSet(排行榜、延时队列、时间线)
  ├── 需要关系运算(交集/并集/差集)
  │   └── Set(共同关注、标签、黑名单)
  ├── 对象有多字段、需要局部更新
  │   └── Hash(购物车、用户画像、配置对象)
  ├── 海量布尔值状态
  │   └── Bitmap(签到、活跃标记、开关矩阵)
  ├── 地理位置查询
  │   └── GEO(附近门店、附近骑手)
  ├── 简单键值 + 过期
  │   └── String + EX(验证码、计数器、开关)
  └── 多步操作需要原子
      └── Lua 脚本(库存扣减、锁释放)

技术映射:数据结构选型不是炫技,而是让业务操作变成 Redis 原生命令。选对了结构,你需要的业务动作就是一条 O(1) 命令;选错了,就是应用层循环加锁加事务。

2.2 第二幕:Key 规范和 TTL 策略必须先定

小胖:"key 命名后面再说不行吗?先写功能。"

大师:"不行。key 规范是地基。15 个模块如果不统一命名,三个月后会有 product:1、p:detail:1、goods_1 三种写法——查一次商品缓存要猜三次 key 名。TTL 也是一样——验证码没设 TTL,三个月前的验证码数据还占着内存。"

本系统 key 规范:

  ┌──────────────────────────────┬──────────┬───────────────┐
  │ Key 模板                      │ 数据类型  │ TTL            │
  ├──────────────────────────────┼──────────┼───────────────┤
  │ mall:captcha:login:{phone}   │ String   │ 300s           │
  │ mall:cooldown:sms:{phone}    │ String   │ 60s            │
  │ mall:limit:sms:{phone}:daily │ String   │ 86400s         │
  │ mall:session:{token}         │ String   │ 7200s           │
  │ mall:cache:product:{id}      │ String   │ 600+rand(0,120)│
  │ mall:cache:product:{id}:null │ String   │ 60s             │
  │ mall:cart:user:{uid}         │ Hash     │ 30天            │
  │ mall:rank:sales:daily:{date} │ ZSet     │ 30天            │
  │ mall:rank:sales:total        │ ZSet     │ -1(不过期)    │
  │ mall:signin:{uid}:{yyyyMM}   │ Bitmap   │ 60天            │
  │ mall:active:{channel}:{date} │ Bitmap   │ 90天            │
  │ mall:followings:{uid}        │ Set      │ -1              │
  │ mall:followers:{uid}         │ Set      │ -1              │
  │ mall:blacklist:{uid}         │ Set      │ -1              │
  │ mall:geo:store:city:{code}   │ GEO(ZSet)│ -1              │
  │ mall:uv:{api}:{date}         │ HLL      │ 90天            │
  │ mall:lock:{purpose}:{id}     │ String   │ 10-30s          │
  │ mall:queue:order:notice      │ List     │ -1              │
  │ mall:switch:{feature}        │ String   │ -1              │
  └──────────────────────────────┴──────────┴───────────────┘

技术映射:key 命名规范让任何一个团队成员都能通过 key 名猜出——哪个模块创建的、什么业务含义、应该活多久。TTL 策略让内存可预测、可审计。

2.3 第三幕:分层实现,验收驱动

小胖:"这么多功能,从哪开始?我怕做到最后不知道怎么验收。"

大师把项目拆成四层:

第一层:环境与规范(Docker Compose + key 规范文档)
第二层:核心数据流(验证码→登录→商品缓存→购物车→下单)
第三层:互动功能(排行榜、签到、附近门店、好友关注)
第四层:运维保障(巡检脚本、慢查询监控、命中率看板、压测验收)

大师:"做一层、验收一层。别等到所有功能写完再看 Redis 炸没炸。验收不只是看功能通不通——还要看 SLOWLOG 有没有慢命令、key 有没有没有 TTL 的临时数据、缓存命中率够不够。"


3. 项目实战

3.1 第一层:环境与规范搭建

步骤 1:Docker Compose 启动

# docker-compose-mall.yml
services:
  redis-mall:
    image: redis:8.6
    container_name: redis-mall
    ports:
      - "6379:6379"
    volumes:
      - ./redis-mall.conf:/usr/local/etc/redis/redis.conf
      - ./data:/data
    command: ["redis-server", "/usr/local/etc/redis/redis.conf"]
# 启动
docker compose -f docker-compose-mall.yml up -d
docker exec -it redis-mall redis-cli -a redis123

步骤 2:关键配置

# redis-mall.conf
bind 0.0.0.0
port 6379
requirepass redis123
appendonly yes
appendfsync everysec
maxmemory 512mb
maxmemory-policy allkeys-lru
slowlog-log-slower-than 10000
slowlog-max-len 128

步骤 3:KeyBuilder 工具类

public class MallRedisKeys {
    // 登录相关
    public static String captchaLogin(String phone) { return "mall:captcha:login:" + phone; }
    public static String cooldownSms(String phone) { return "mall:cooldown:sms:" + phone; }
    public static String limitSmsDaily(String phone) { return "mall:limit:sms:" + phone + ":daily"; }

    // 商品相关
    public static String cacheProduct(String id) { return "mall:cache:product:" + id; }
    public static String cacheProductNull(String id) { return "mall:cache:product:" + id + ":null"; }

    // 购物车
    public static String cartUser(String uid) { return "mall:cart:user:" + uid; }

    // 排行榜
    public static String rankSalesDaily(String date) { return "mall:rank:sales:daily:" + date; }
    public static String rankSalesTotal() { return "mall:rank:sales:total"; }

    // 签到
    public static String signinMonthly(String uid, String yyyyMM) { return "mall:signin:" + uid + ":" + yyyyMM; }

    // DAU
    public static String activeChannel(String channel, String date) { return "mall:active:" + channel + ":" + date; }

    // 社交
    public static String followings(String uid) { return "mall:followings:" + uid; }
    public static String followers(String uid) { return "mall:followers:" + uid; }
    public static String blacklist(String uid) { return "mall:blacklist:" + uid; }

    // 地理位置
    public static String geoStoreCity(String cityCode) { return "mall:geo:store:city:" + cityCode; }

    // 统计
    public static String uvApi(String api, String date) { return "mall:uv:" + api + ":" + date; }

    // 分布式锁
    public static String lockSubmitOrder(String uid, String requestId) {
        return "mall:lock:submit:user:" + uid + ":request:" + requestId;
    }

    // 异步队列
    public static String queueOrderNotice() { return "mall:queue:order:notice"; }

    // 功能开关
    public static String switchFeature(String feature) { return "mall:switch:" + feature; }
}

3.2 第二层:核心数据流

模块 1:登录验证码(String + TTL)

# 发送验证码
127.0.0.1:6379> SET mall:captcha:login:13800000000 9527 EX 300
127.0.0.1:6379> SET mall:cooldown:sms:13800000000 1 NX EX 60

# 校验验证码
127.0.0.1:6379> GET mall:captcha:login:13800000000
"9527"
# 校验成功后:
127.0.0.1:6379> DEL mall:captcha:login:13800000000

# 防刷计数
127.0.0.1:6379> EVAL "local c=redis.call('INCR',KEYS[1]); if c==1 then redis.call('EXPIRE',KEYS[1],ARGV[1]) end; return c" 1 mall:limit:sms:13800000000:daily 86400

模块 2:商品详情缓存(Cache Aside)

public class ProductService {
    private final ThreadLocalRandom random = ThreadLocalRandom.current();

    public Product getProduct(String productId) {
        // 1. 参数校验
        if (productId == null || !productId.matches("\\d{1,10}")) return null;

        String cacheKey = MallRedisKeys.cacheProduct(productId);
        String nullKey = MallRedisKeys.cacheProductNull(productId);

        // 2. 查缓存
        String cached = redis.get(cacheKey);
        if (cached != null) return "__NULL__".equals(cached) ? null : deserialize(cached);

        // 3. 空值缓存检测
        if (Boolean.TRUE.equals(redis.hasKey(nullKey))) return null;

        // 4. 回源数据库
        Product product = database.findProduct(productId);
        if (product != null) {
            int ttl = 600 + random.nextInt(120);
            redis.set(cacheKey, serialize(product), Duration.ofSeconds(ttl));
        } else {
            redis.set(nullKey, "__NULL__", Duration.ofSeconds(60));
        }
        return product;
    }

    @Transactional
    public void updateProduct(String productId, ProductUpdateCommand cmd) {
        database.updateProduct(productId, cmd);
        redis.del(MallRedisKeys.cacheProduct(productId));  // 删除缓存,不是更新
    }
}

模块 3:购物车(Hash)

# 添加商品到购物车
127.0.0.1:6379> HINCRBY mall:cart:user:1001 sku:2001 1
127.0.0.1:6379> EXPIRE mall:cart:user:1001 2592000

# 查看购物车
127.0.0.1:6379> HGETALL mall:cart:user:1001
127.0.0.1:6379> HLEN mall:cart:user:1001

# 修改数量
127.0.0.1:6379> HINCRBY mall:cart:user:1001 sku:2001 1
127.0.0.1:6379> HINCRBY mall:cart:user:1001 sku:2001 -1

# 删除商品
127.0.0.1:6379> HDEL mall:cart:user:1001 sku:2001

模块 4:下单 + 库存扣减 + 异步通知(Lua + List)

-- order_submit.lua
-- KEYS[1]: 库存 key
-- KEYS[2]: 已购买用户 Set
-- KEYS[3]: 订单通知 List
-- ARGV[1]: 用户 ID
-- ARGV[2]: SKU ID
-- ARGV[3]: 购买数量
-- ARGV[4]: 请求 ID (幂等键)
-- 返回: 1=成功, 0=库存不足, -1=重复购买

local stockKey = KEYS[1]
local buyerKey = KEYS[2]
local noticeKey = KEYS[3]
local userId = ARGV[1]
local skuId = ARGV[2]
local quantity = tonumber(ARGV[3])
local requestId = ARGV[4]

-- 限购检查
if redis.call('SISMEMBER', buyerKey, userId) == 1 then
    return -1
end

-- 库存检查
local stock = tonumber(redis.call('GET', stockKey) or '0')
if stock < quantity then
    return 0
end

-- 原子扣减 + 记录 + 通知
redis.call('DECRBY', stockKey, quantity)
redis.call('SADD', buyerKey, userId)
redis.call('RPUSH', noticeKey, string.format(
    '{"requestId":"%s","userId":"%s","skuId":"%s","quantity":%d}', 
    requestId, userId, skuId, quantity))
return 1
public class OrderService {
    private static final String SUBMIT_LUA = "local sk=KEYS[1]; local bk=KEYS[2]; local nk=KEYS[3]; ...";

    public SubmitResult submit(String userId, String skuId, int quantity) {
        String requestId = UUID.randomUUID().toString();

        // 1. 防重复提交锁
        String lockKey = MallRedisKeys.lockSubmitOrder(userId, requestId);
        Boolean locked = redis.setIfAbsent(lockKey, requestId, Duration.ofSeconds(10));
        if (!Boolean.TRUE.equals(locked)) return SubmitResult.DUPLICATE;

        try {
            // 2. Lua 原子扣减
            Long result = redis.eval(SUBMIT_LUA,
                Arrays.asList(
                    "mall:stock:sku:" + skuId,
                    "mall:buyers:sku:" + skuId,
                    MallRedisKeys.queueOrderNotice()),
                Arrays.asList(userId, skuId, String.valueOf(quantity), requestId));

            return switch (result.intValue()) {
                case 1 -> SubmitResult.SUCCESS_PROCESSING;
                case 0 -> SubmitResult.STOCK_NOT_ENOUGH;
                case -1 -> SubmitResult.ALREADY_BOUGHT;
                default -> SubmitResult.UNKNOWN;
            };
        } finally {
            releaseLock(lockKey, requestId);
        }
    }
}

模块 5:异步通知消费者(List BLPOP)

public class OrderNoticeConsumer implements Runnable {
    private final AtomicBoolean running = new AtomicBoolean(true);

    @Override
    public void run() {
        while (running.get()) {
            try {
                String task = redis.brpop(30, MallRedisKeys.queueOrderNotice());
                if (task == null) continue;

                OrderNotice notice = objectMapper.readValue(task, OrderNotice.class);

                if (isProcessed(notice.getRequestId())) continue;  // 幂等

                // 发短信/推送/积分
                processNotice(notice);
                markProcessed(notice.getRequestId());

            } catch (Exception e) {
                log.error("通知处理异常", e);
            }
        }
    }
}

3.3 第三层:互动功能

模块 6:积分排行榜(ZSet)

# 初始化榜单
127.0.0.1:6379> ZADD mall:rank:sales:daily:20260718 0 user:1001 0 user:1002

# 增加积分(每次下单后)
127.0.0.1:6379> ZINCRBY mall:rank:sales:daily:20260718 299 user:1001
127.0.0.1:6379> ZINCRBY mall:rank:sales:total 299 user:1001

# 查询 Top 10
127.0.0.1:6379> ZREVRANGE mall:rank:sales:daily:20260718 0 9 WITHSCORES

# 查询我的排名
127.0.0.1:6379> ZREVRANK mall:rank:sales:daily:20260718 user:1001
# 业务排名(并列处理):
127.0.0.1:6379> ZSCORE mall:rank:sales:daily:20260718 user:1001
127.0.0.1:6379> ZCOUNT mall:rank:sales:daily:20260718 (299 +inf
# → 严格大于我的人数 + 1 = 业务排名

# 清理
127.0.0.1:6379> EXPIRE mall:rank:sales:daily:20260718 2592000

模块 7:月度签到(Bitmap)

# 用户 1001 在 7 月 18 日签到 (offset = 17)
127.0.0.1:6379> SETBIT mall:signin:1001:202607 17 1

# 检查是否签到
127.0.0.1:6379> GETBIT mall:signin:1001:202607 17

# 本月签到天数
127.0.0.1:6379> BITCOUNT mall:signin:1001:202607

# 连续签到判断(Java):
public int getContinuousSignInDays(String uid, LocalDate today) {
    String key = MallRedisKeys.signinMonthly(uid, today.format(DateTimeFormatter.ofPattern("yyyyMM")));
    int count = 0;
    LocalDate cursor = today;
    while (true) {
        if (!Boolean.TRUE.equals(redis.getBit(key, cursor.getDayOfMonth() - 1))) break;
        count++;
        cursor = cursor.minusDays(1);
        // 跨月处理
        if (cursor.getDayOfMonth() == cursor.lengthOfMonth()) {
            key = MallRedisKeys.signinMonthly(uid, cursor.format(DateTimeFormatter.ofPattern("yyyyMM")));
        }
    }
    return count;
}

模块 8:附近门店(GEO)

# 写入门店位置
127.0.0.1:6379> GEOADD mall:geo:store:city:bj 116.397128 39.916527 store:1001
127.0.0.1:6379> GEOADD mall:geo:store:city:bj 116.407526 39.904030 store:1002

# 搜索附近门店(用户定位: 116.40, 39.91)
127.0.0.1:6379> GEOSEARCH mall:geo:store:city:bj FROMLONLAT 116.40 39.91 BYRADIUS 3 km ASC WITHDIST COUNT 10

# 门店属性过滤(GEO 不存属性,用 Hash 补充)
127.0.0.1:6379> HSET mall:store:info:1001 name "王府井店" type "pickup" open 1

模块 9:社交关系(Set)

# 关注(双写正反索引)
127.0.0.1:6379> SADD mall:followings:1001 2001 2002
127.0.0.1:6379> SADD mall:followers:2001 1001
127.0.0.1:6379> SADD mall:followers:2002 1001

# 共同关注
127.0.0.1:6379> SINTER mall:followings:1001 mall:followings:1002

# 好友推荐候选(朋友的朋友)
127.0.0.1:6379> SUNIONSTORE mall:recommend:temp:1001 mall:followings:2001 mall:followings:2002
127.0.0.1:6379> SDIFF mall:recommend:temp:1001 mall:followings:1001 mall:blacklist:1001
127.0.0.1:6379> EXPIRE mall:recommend:temp:1001 3600

# 黑名单
127.0.0.1:6379> SADD mall:blacklist:1001 3001
127.0.0.1:6379> SISMEMBER mall:blacklist:1001 3001

模块 10:接口 UV 统计(HyperLogLog)

# 每次接口调用
127.0.0.1:6379> PFADD mall:uv:product:detail:20260718 uid:1001

# 查询日 UV
127.0.0.1:6379> PFCOUNT mall:uv:product:detail:20260718

# 周 UV(跨天合并)
127.0.0.1:6379> PFMERGE mall:uv:product:detail:2026W29 mall:uv:product:detail:20260712 mall:uv:product:detail:20260713 mall:uv:product:detail:20260714 mall:uv:product:detail:20260715 mall:uv:product:detail:20260716 mall:uv:product:detail:20260717 mall:uv:product:detail:20260718
127.0.0.1:6379> PFCOUNT mall:uv:product:detail:2026W29

模块 11:DAU 统计(Bitmap)

# 记录日活
127.0.0.1:6379> SETBIT mall:active:app:20260718 1001 1
127.0.0.1:6379> SETBIT mall:active:app:20260718 1002 1

# 单日 DAU
127.0.0.1:6379> BITCOUNT mall:active:app:20260718

# 多日 MAU(并集)
127.0.0.1:6379> BITOP OR mall:active:app:mau:202607 mall:active:app:20260716 mall:active:app:20260717 mall:active:app:20260718
127.0.0.1:6379> BITCOUNT mall:active:app:mau:202607
127.0.0.1:6379> EXPIRE mall:active:app:mau:202607 86400

3.4 第四层:运维保障

巡检脚本(Python)

import redis
import sys

r = redis.Redis(host='localhost', port=6379, password='redis123', decode_responses=True)

issues = []

# 1. 命名合规检查
cursor = 0
while True:
    cursor, keys = r.scan(cursor, match='*', count=100)
    for key in keys:
        if not key.startswith('mall:'):
            issues.append(f"命名不合规: {key}")

        # 2. 无 TTL 临时 key 检查
        ttl = r.ttl(key)
        if ttl == -1:
            lower = key.lower()
            if any(kw in lower for kw in ['captcha', 'cache', 'limit', 'cooldown']):
                issues.append(f"临时 key 无 TTL: {key}")

        # 3. 大 key 告警
        mem = r.memory_usage(key)
        if mem and mem > 1024 * 1024:
            issues.append(f"大 key ({mem} bytes): {key}")

    if cursor == 0:
        break

# 4. 慢查询检查
slowlogs = r.slowlog_get(10)
for log in slowlogs:
    if log['duration'] > 10000:  # > 10ms
        issues.append(f"慢查询: {log['command']} ({log['duration']}us)")

# 5. 内存水位
info = r.info('memory')
used_pct = info['used_memory'] / info['maxmemory'] * 100 if info['maxmemory'] > 0 else 0
if used_pct > 80:
    issues.append(f"内存使用率: {used_pct:.1f}%")

# 6. 命中率
stats = r.info('stats')
hits = stats['keyspace_hits']
misses = stats['keyspace_misses']
total = hits + misses
if total > 0:
    hit_rate = hits / total * 100
    if hit_rate < 50:
        issues.append(f"命中率偏低: {hit_rate:.1f}%")

# 输出报告
if issues:
    print("===== Redis 巡检发现问题 =====")
    for i in issues:
        print(f"  ❌ {i}")
    sys.exit(1)
else:
    print("✅ Redis 巡检通过")

压测验收脚本(bash)

# 1. 预热商品缓存
for i in $(seq 1 100); do
  redis-cli -a redis123 SET "mall:cache:product:$i" '{"id":"$i","name":"商品$i","price":299}' EX 637
done

# 2. 压测 GET
docker exec redis-mall redis-benchmark -a redis123 -t get -n 100000 -c 100 -q

# 3. 压测后查看指标
redis-cli -a redis123 INFO stats | grep keyspace
redis-cli -a redis123 SLOWLOG GET 5

# 4. 验证库存扣减(并发 Lua)
for i in $(seq 1 100); do
  redis-cli -a redis123 EVAL "local sk=KEYS[1]; local s=tonumber(redis.call('GET',sk) or '0'); if s<=0 then return -1 end; redis.call('DECR',sk); return s-1" 1 mall:stock:test &
done
wait
redis-cli -a redis123 GET mall:stock:test
# 结果应该为 0,不能为负数

验收检查清单

# 1. PING 通
redis-cli -a redis123 PING

# 2. 内存 < 80%
redis-cli -a redis123 INFO memory | grep used_memory_human

# 3. 慢查询 0 条 (>10ms)
redis-cli -a redis123 SLOWLOG GET 5

# 4. key 前缀合规
redis-cli -a redis123 SCAN 0 MATCH * COUNT 10
# 全部以 mall: 开头

# 5. 商品缓存命中率 > 80%
redis-cli -a redis123 INFO stats | grep keyspace

# 6. 无大 key
docker exec redis-mall redis-cli -a redis123 --bigkeys

# 7. 库存扣减准确性
# 并发 100 个请求扣 100 件库存 → 最后库存 = 0

3.5 完整项目代码结构

redis-mall-project/
├── docker-compose.yml              # Redis + RedisInsight
├── redis-mall.conf                 # Redis 配置
├── src/
│   ├── main/java/com/mall/
│   │   ├── config/
│   │   │   └── MallRedisKeys.java          # Key 规范工具类
│   │   ├── service/
│   │   │   ├── LoginService.java           # 登录验证码
│   │   │   ├── ProductCacheService.java    # 商品缓存
│   │   │   ├── CartService.java            # 购物车
│   │   │   ├── OrderService.java           # 下单+Lua
│   │   │   ├── RankService.java            # 排行榜
│   │   │   ├── SignInService.java          # 签到
│   │   │   ├── GeoStoreService.java        # 附近门店
│   │   │   ├── SocialService.java          # 社交关系
│   │   │   ├── UvStatsService.java         # UV 统计
│   │   │   ├── DauService.java             # DAU 统计
│   │   │   └── OrderNoticeConsumer.java    # 异步通知消费者
│   │   ├── lua/
│   │   │   ├── order_submit.lua            # 下单原子脚本
│   │   │   ├── seckill_deduct.lua          # 库存扣减脚本
│   │   │   └── release_lock.lua            # 锁释放脚本
│   │   └── util/
│   │       └── RedisLock.java              # 分布式锁工具
│   └── test/java/com/mall/
│       ├── CacheHitRateTest.java           # 缓存命中率测试
│       ├── StockConcurrencyTest.java       # 库存并发测试
│       └── KeyInspectionTest.java          # Key 巡检测试
├── scripts/
│   ├── warmup.sh                   # 缓存预热脚本
│   ├── inspection.py               # 日常巡检脚本
│   └── benchmark.sh                # 压测脚本
└── docs/
    ├── key-specification.md        # Key 规范文档
    └── ttl-policy.md               # TTL 策略文档

3.6 完整的系统初始化脚本

#!/bin/bash
# init_mall_redis.sh - 电商缓存系统初始化

REDIS="redis-cli -a redis123"

echo "=== 1. 初始化商品缓存 ==="
for i in $(seq 1 100); do
  $REDIS SET "mall:cache:product:$i" "{\"id\":$i,\"name\":\"商品$i\",\"price\":299}" EX $(shuf -i 600-720 -n 1) > /dev/null
done
echo "初始化 100 条商品缓存完成"

echo "=== 2. 初始化排行榜 ==="
$REDIS DEL mall:rank:sales:daily:$(date +%Y%m%d)
for i in $(seq 1 50); do
  score=$(shuf -i 100-10000 -n 1)
  $REDIS ZADD "mall:rank:sales:daily:$(date +%Y%m%d)" $score "user:$i" > /dev/null
  $REDIS ZADD "mall:rank:sales:total" $score "user:$i" > /dev/null
done
$REDIS EXPIRE "mall:rank:sales:daily:$(date +%Y%m%d)" 2592000
echo "初始化排行榜完成"

echo "=== 3. 初始化门店位置 ==="
$REDIS GEOADD mall:geo:store:city:bj 116.397128 39.916527 store:1001
$REDIS GEOADD mall:geo:store:city:bj 116.407526 39.904030 store:1002
$REDIS GEOADD mall:geo:store:city:sh 121.473701 31.230416 store:2001
echo "初始化门店位置完成"

echo "=== 4. 初始化功能开关 ==="
$REDIS SET mall:switch:activity:618 on
$REDIS SET mall:switch:feature:newchat off
echo "初始化功能开关完成"

echo "=== 5. 初始化 Stream 消费组 ==="
$REDIS XGROUP CREATE mall:stream:seckill:orders order-workers 0 MKSTREAM 2>/dev/null
echo "初始化 Stream 完成"

echo "=== 6. 验证 ==="
$REDIS DBSIZE
$REDIS INFO stats | grep keyspace
echo "系统初始化完成!"

3.7 前端调用示例(API 接口设计)

GET  /api/mall/product/{id}          → 商品详情(Cache Aside)
POST /api/mall/cart/add              → 添加购物车(Hash HINCRBY)
GET  /api/mall/cart/{userId}         → 查看购物车(Hash HGETALL)
POST /api/mall/order/submit          → 提交订单(Lua 原子扣减)
GET  /api/mall/rank/sales/daily      → 日销量榜 Top 50(ZSet ZREVRANGE)
GET  /api/mall/rank/sales/my/{uid}   → 我的排名(ZSet ZREVRANK)
POST /api/mall/signin                → 签到(Bitmap SETBIT)
GET  /api/mall/signin/calendar/{uid} → 签到日历(Bitmap GETBIT × 31)
GET  /api/mall/store/nearby          → 附近门店(GEO GEOSEARCH)
POST /api/mall/social/follow         → 关注(Set SADD 双写)
GET  /api/mall/social/common/{a}/{b} → 共同关注(Set SINTER)
GET  /api/mall/stats/uv/product      → 商品 UV(HLL PFCOUNT)
GET  /api/mall/admin/inspection      → 系统巡检报告

4. 项目总结

4.1 数据结构分布总结

模块 数据结构 核心命令 关键点
登录验证码 String SET ... EX 300s TTL,校验后删除
短信防刷 String SET NX EX, Lua 冷却+日限制
商品缓存 String GET/SET Cache Aside,TTL 抖动
购物车 Hash HINCRBY/HDEL 原子增减数量
异步通知 List RPUSH/BLPOP 轻量队列+失败重试
库存扣减 String+Lua Lua 原子 库存+限购 同一脚本
排行榜 ZSet ZINCRBY/ZREVRANGE 并列名次处理
签到 Bitmap SETBIT/BITCOUNT 按月分 key,连续判断
DAU Bitmap BITOP OR/BITCOUNT 日期维度
附近门店 GEO GEOSEARCH GEO 召回+属性过滤
社交关系 Set SADD/SINTER/SDIFF 正反索引双写
接口 UV HLL PFADD/PFMERGE 跨天/跨渠道合并
功能开关 String GET/SET 降级默认策略

4.2 验收结果模板

指标 目标 实际 是否达标
商品缓存命中率 > 80% __%
商品详情 P95 延迟 < 30ms __ms
慢命令数量 0 __
命名不合规 key 0 __
无 TTL 临时 key 0 __
大 key (> 1MB) 0 __
库存扣减准确 100% __
内存使用率 < 80% __%

4.3 常见集成问题

问题 表现 解决
key 命名不统一 缓存命中率低(读和写用的不同 key 名) 强制使用 KeyBuilder 工具类
TTL 全相同 每 10 分钟数据库 CPU 飙一次 TTL 加随机抖动
购物车 Hash 过大 单个用户 5000+ 商品,HGETALL 卡住 限购物车上限 + HSCAN 分批
排行榜热 key 活动高峰期 ZSet 成为瓶颈 分 shard + 本地缓存
异步通知丢失 消费者宕机后任务丢失 加 Stream 消费组(第 20 章)

4.4 思考题

  1. 架构题:如果日活从 10 万增长到 1000 万,本系统的哪些模块最可能先出问题?对应的优化方向是什么?

  2. 综合题:如果要支持"一个用户退货后库存自动回补",当前的下单 Lua 脚本需要增加什么逻辑?请写出修改后的关键代码。

(答案提示将在下一章末尾给出。)

4.5 完整测试用例集

// CacheHitRateTest.java - 缓存命中率验证
@Test
public void testProductCacheHitRate() {
    // 预热 100 条商品缓存
    for (int i = 1; i <= 100; i++) {
        productService.cacheWarmUp(String.valueOf(i));
    }

    // 模拟 10000 次随机访问(90% 集中在 Top 20 商品)
    int hits = 0, misses = 0;
    ThreadLocalRandom random = ThreadLocalRandom.current();
    for (int i = 0; i < 10000; i++) {
        String productId = random.nextInt(100) < 90 
            ? String.valueOf(random.nextInt(20) + 1)  // 热点
            : String.valueOf(random.nextInt(1000) + 1); // 非热点
        Product p = productService.getProduct(productId);
        if (p != null) hits++;
        else misses++;
    }

    double hitRate = (double) hits / (hits + misses) * 100;
    assertTrue(hitRate > 80, "缓存命中率应 > 80%, 实际: " + hitRate + "%");
}

// StockConcurrencyTest.java - 库存并发验证
@Test
public void testNoOversell() throws Exception {
    // 初始库存 100
    redis.set("mall:stock:test", "100");
    int concurrentRequests = 200;  // 超过库存的并发
    CountDownLatch latch = new CountDownLatch(concurrentRequests);
    AtomicInteger successCount = new AtomicInteger(0);

    for (int i = 0; i < concurrentRequests; i++) {
        String userId = "user-" + i;
        executor.submit(() -> {
            try {
                Long result = redis.eval(SECKILL_LUA, 
                    Arrays.asList("mall:stock:test", "mall:buyers:test"),
                    Arrays.asList(userId, "1"));
                if (result != null && result == 1) successCount.incrementAndGet();
            } finally { latch.countDown(); }
        });
    }

    latch.await(30, TimeUnit.SECONDS);
    String remaining = redis.get("mall:stock:test");

    assertEquals(0, Integer.parseInt(remaining), "库存应为 0(无超卖)");
    assertEquals(100, successCount.get(), "成功数应正好 100(无少卖无重复)");
}

4.6 故障演练手册

故障场景 模拟方式 预期表现 恢复方式
Redis 短暂不可用 docker pause redis-mall 30s 本地缓存兜底返回旧数据 docker unpause
商品缓存全部过期 FLUSHDB(测试环境) 命中率骤降但数据库扛住 预热脚本重新加载
热点商品缓存击穿 删热点 key + 1000 并发 只有 1 个请求回源数据库 互斥锁生效
恶意穿透攻击 循环请求不存在的 ID 空值缓存拦截,数据库 QPS 不涨 空值缓存自动过期
购物车大 Hash 用户购物车塞 5000 商品 HSCAN 分批读取 限购物车 200 件上限
排行榜热 key 10000 并发抢 Top 10 ZSet 支撑,P99 < 5ms 本地缓存 Top 50

4.7 上线检查清单

## Redis 上线检查清单

### 配置检查
- [ ] requirepass 已设置强密码
- [ ] maxmemory 已配置且策略非 noeviction
- [ ] appendonly 已开启
- [ ] slowlog-log-slower-than 已设置(建议 10000us)
- [ ] rename-command: 已禁用 FLUSHALL/FLUSHDB/KEYS/CONFIG

### Key 规范检查
- [ ] 所有 key 符合命名规范(`mall:module:entity:id`)
- [ ] 所有临时 key 已设置 TTL
- [ ] 无大 key(> 1MB)
- [ ] 无热 key 未保护

### 缓存策略检查
- [ ] 商品缓存 TTL 含随机抖动
- [ ] 空值缓存 TTL 设置合理(30-120s)
- [ ] 互斥锁有 TTL+token 校验释放
- [ ] 热点商品有逻辑过期或永不过期

### 监控告警检查
- [ ] 缓存命中率监控已接入
- [ ] 慢查询告警已配置
- [ ] 内存水位告警已配置(> 80%)
- [ ] 驱逐 key 告警已配置(evicted_keys > 0)

### 容灾检查
- [ ] 本地缓存兜底已启用
- [ ] 数据库连接池未满(Redis 全 miss 仍能扛住)
- [ ] 缓存预热脚本已准备好

4.8 常见集成故障排查

故障 排查 解决
缓存命中率 < 50% key 命名不一致?TTL 过短? 统一 KeyBuilder;TTL 600+random
购物车 HGETALL 慢 HLEN 确认字段数 限制 200 件;用 HSCAN
排行榜不更新 ZSCORE 确认分数 检查 ZINCRBY 调用
签到跨月断裂 8月1日没查7月31日 跨月切换 key 继续查
门店搜索为空 GEOPOS 检查经纬度 确认经纬度顺序
UV 与数据库差异 PFCOUNT vs COUNT DISTINCT 确认 visitorId 口径一致

4.9 运行效果一览

=== Redis 电商缓存系统验收 ===
Docker:     redis-mall (Up)
内存:        45.2M / 512M (8.8%)
Key 总数:    8,435
慢查询:      0 条
商品命中率:   93.7%
今日签到:    1,287 人
排行榜Top1:  user:1001 (12,880分)
附近门店:    2家(天安门3km)
UV(商品):    ~12,500
验收结果:    ✅ 全部通过

5. 总结与展望

5.1 本系统用到的 Redis 技能清单

章节 技能 本系统应用
第3章 String 验证码、计数器、功能开关
第4章 Hash 购物车、门店属性
第5章 List 订单异步通知队列
第6章 Set 社交关注、黑名单
第7章 ZSet 销量排行榜
第8章 Bitmap 用户签到、DAU 统计
第9章 HyperLogLog 接口 UV 统计
第10章 GEO 附近门店搜索
第11章 Key 设计 mall:* 命名规范
第12章 Cache Aside 商品详情缓存
第13章 Lua 下单原子脚本、锁释放
第14章 Pipeline 缓存批量预热
第15章 运维排查 巡检脚本、慢查询监控

5.2 后续进阶方向

完成本系统后,你已经具备独立搭建 Redis 缓存系统的基础能力。下一步建议:

  1. 缓存治理深化(第17章):系统化应对穿透、击穿、雪崩。
  2. 分布式锁进阶(第18章):从单实例到 Redlock 的工程取舍。
  3. 高并发秒杀(第19章):库存拆分、异步下单、对账补偿。
  4. Stream 消息流(第20章):从 List 到 Stream 的可靠消息升级。
  5. 持久化与高可用(第22-25章):RDB/AOF、主从、Sentinel、Cluster。
  6. 性能调优(第26-27章):内存治理、慢命令优化、火焰图分析。
  7. 源码深度(第32-37章):事件循环、数据结构内部编码、复制链路。
  • 开发团队:以本章为模板,建立公司内部的 Redis 实战脚手架项目。新功能接入 Redis 前,先对照数据结构选型决策树确认选型。
  • 测试团队:本章的验收检查清单可直接转为自动化测试用例。压测脚本集成到 CI/CD 中,每次上线前验证。
  • 运维团队:本章的巡检脚本可作为日常值班工具,每日执行一次。告警阈值根据业务实际调整。

5.3 关键代码清单(可在项目中直接引用)

以下是本系统核心文件的速查索引,方便后续查阅:

文件 关键内容 对应章节
MallRedisKeys.java 全部 key 生成入口 第11章
ProductCacheService.java Cache Aside + 空值缓存 + TTL 抖动 第12章
CartService.java Hash 购物车 + HINCRBY 第4章
OrderService.java Lua 原子下单 + List 通知 第13章
RankService.java ZSet 排行榜 + 并列名次 第7章
SignInService.java Bitmap 签到 + 连续判断 第8章
GeoStoreService.java GEO 附近搜索 + 属性过滤 第10章
SocialService.java Set 关注/粉丝双向索引 第6章
UvStatsService.java HLL UV + 跨天合并 第9章
DauService.java Bitmap DAU + BITOP OR 第8章
RedisLock.java 分布式锁 try-with-resources 第14章
scripts/inspection.py SCAN 巡检脚本 第11章
scripts/warmup.sh 缓存预热脚本 第12章
scripts/init_mall.sh 系统初始化脚本 本章

5.4 本章常见疑问解答

Q: 为什么购物车用 Hash 而不是 Set 或 String?
A: 购物车是"用户→商品→数量"的二级映射,Hash 的 field-value 天然匹配这个模型。HINCRBY 原子增减数量是 Set 和 String 做不到的。

Q: 排行榜 ZSet 的 member 为什么不直接存用户名?
A: ZSet 的 member 是排序键,存 ID 而非用户名可以保证唯一性,同时信息变更(改名)不影响排名。用户名通过 member(ID) 再去 Hash 或数据库查。

Q: 签到用 Bitmap 好在哪里?
A: 每个用户每月只需 31 位(约 4 字节),相比每天一条数据库记录(约 50 字节),存储节省 92%。而且 BITCOUNT 统计签到天数比 SQL COUNT 快。

Q: GEO 为什么按城市拆分 key?
A: 全国门店放一个 key 的 ZSet 太大,方圆搜索效率下降。按城市拆分后每次搜索只在一个小集合中进行,按需补偿相邻城市搜索。

Q: 为什么需要同时用 HyperLogLog 和 Bitmap?
A: HLL 统计 UV(大概多少独立用户),Bitmap 需要内部编号故难用于 UV。但如果用户 ID 连续,Bitmap 也可以做 UV(更精确)。两者互为补充——HLL 省内存,Bitmap 支持交集运算。


基础篇结语:恭喜你完成了基础篇全部 16 章的学习。从第 1 章的"Redis 是什么"到这里"用 Redis 搭建一个完整系统",你已经掌握了 Redis 的核心操作。

回顾一下你在这个项目中学到的能力:用 String 做验证码和计数器、用 Hash 做购物车、用 List 做异步队列、用 Set 做社交关系、用 ZSet 做排行榜、用 Bitmap 做签到、用 HyperLogLog 做 UV 统计、用 GEO 做附近门店、用 Lua 做原子扣减、用 Pipeline 做批量预热、用 Cache Aside 做缓存加速、用 SCAN 做安全巡检、用 SLOWLOG 做慢查询排查。这 13 项技能覆盖了 Redis 日常开发的 80% 场景。

从下一章开始的中级篇,你会面对更接近生产环境的问题——缓存穿透/击穿/雪崩的系统化治理、分布式锁的工程取舍、秒杀系统的完整设计、Stream 可靠消息流、RDB/AOF 持久化、主从复制与 Sentinel 高可用、Cluster 集群、内存治理与性能调优。准备好迎接挑战了吗?

延伸阅读与资源

Redis 8 实战精讲:从 CRUD 到源码,构建高可用缓存系统
Java 工程师进阶:从 JVM 生产排障到OpenJDK原理
MySQL Server 9从入门到进阶的实战之旅
Nacos 3.x 注册配置中心实战修炼:从入门到源码扩展
Elasticsearch从入门到进阶的实战之旅
RabbitMQ从入门到进阶的实战之旅:从单机到大促高可用架构
Celery 入门到进阶之路:从异步任务到自研调度平台
LangGraph 生产级实战进阶:从零到生产级Agent工作流开发
Dify 从入门到源码:LLM 应用平台实战修炼
从零到生产级:FastAPI 异步高并发、源码与 SRE 实战
实战SQLAlchemy 2.0: 从 CRUD 到生产级架构
从零打造企业级 AI 助手:LangChain RAG、Agent 与生产实战
后端工程师 AI 转型课:Ollama 私有化大模型从入门到生产
MongoDB 实战进阶与内核修炼
NumPy 从入门到生产落地:全链路实战指南(科学计算/向量化/性能调优)
Milvus向量数据库实战修炼:从 0 到 1 精通向量检索与生产落地
Python 3实战精进:从脚本到高并发订单引擎
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经

posted on 2026-11-11 10:06  一天不进步,就是退步  阅读(0)  评论(0)    收藏  举报