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 思考题
-
架构题:如果日活从 10 万增长到 1000 万,本系统的哪些模块最可能先出问题?对应的优化方向是什么?
-
综合题:如果要支持"一个用户退货后库存自动回补",当前的下单 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 缓存系统的基础能力。下一步建议:
- 缓存治理深化(第17章):系统化应对穿透、击穿、雪崩。
- 分布式锁进阶(第18章):从单实例到 Redlock 的工程取舍。
- 高并发秒杀(第19章):库存拆分、异步下单、对账补偿。
- Stream 消息流(第20章):从 List 到 Stream 的可靠消息升级。
- 持久化与高可用(第22-25章):RDB/AOF、主从、Sentinel、Cluster。
- 性能调优(第26-27章):内存治理、慢命令优化、火焰图分析。
- 源码深度(第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的网络实战圣经

微信公众号: 架构师日常笔记 欢迎关注!
浙公网安备 33010602011771号