支付中台 Redis 实战:8 个真实踩坑与优化案例
前置说明:本文参考美团、拼多多、蚂蚁金服等公开技术分享,结合生产真实案例,讲解 Redis 在支付中台的核心应用与避坑经验。
一、Redis 在支付中台的角色定位
1.1 支付系统的 Redis 典型应用
Redis 在支付系统中的五大职责:
1. 分布式锁 → 幂等控制、并发互斥
2. 缓存 → 热点数据、计算结果
3. 计数器 → 限流、订单号生成
4. 会话存储 → 用户 Token、登录状态
5. 发布/订阅 → 实时通知、事件广播
1.2 支付系统的 Redis 选型
| 数据类型 | 业务场景 | Redis 结构 |
|---|---|---|
| String | Token、会话、验证码 | SET key value EX 300 |
| Hash | 用户账户余额快照 | HSET account:{uid} balance 1000 |
| Sorted Set | 延迟队列、超时关单 | ZADD delay:close {timestamp} {orderId} |
| Set | 幂等标记、去重 | SISMEMBER processed:{msgId} 1 |
| Lua | 原子扣款、原子计数 | EVAL "if redis.call('decrby', ...) < 0 then return 0 end" |
二、真实踩坑场景复盘
场景 1:热 key 打爆 Redis,引发支付雪崩
事故描述:
10:00 双十二活动开始
10:00:15 订单量突增 10 倍
10:00:20 Redis CPU 100%,响应超时
10:00:25 支付接口 P99 飙到 10s+
10:01:00 用户大量重复下单(超时重试)
10:05:00 紧急扩容 Redis,但已积压大量请求
根因:热门商品详情页缓存 key item:{itemId},大促期间全部请求打到同一个 Redis 节点。
问题代码:
// ❌ 问题:热点 key 直接缓存,无保护
public ProductVO getProduct(Long itemId) {
Product product = redisTemplate.opsForValue().get("product:" + itemId);
if (product == null) {
product = productService.getById(itemId);
// 大量并发同时查 DB,同时写入 Redis
redisTemplate.opsForValue().set("product:" + itemId, product, 5, TimeUnit.MINUTES);
}
return product;
}
修复方案 1:本地缓存 + Redis 二级缓存:
// ✅ 本地缓存(Caffeine)+ Redis 二级缓存
@Service
public class ProductCacheService {
// 一级:本地缓存,过期时间 10s
private final Cache<Long, ProductVO> localCache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(10, TimeUnit.SECONDS)
.build();
@Autowired
private RedisTemplate<String, ProductVO> redisTemplate;
public ProductVO getProduct(Long itemId) {
// 1. 先查本地缓存
ProductVO product = localCache.getIfPresent(itemId);
if (product != null) return product;
// 2. 查 Redis
product = redisTemplate.opsForValue().get("product:" + itemId);
if (product != null) {
localCache.put(itemId, product);
return product;
}
// 3. 查 DB
product = productService.getById(itemId);
if (product != null) {
redisTemplate.opsForValue().set("product:" + itemId, product, 5, TimeUnit.MINUTES);
localCache.put(itemId, product);
}
return product;
}
}
修复方案 2:热点 key 分散到多个分片:
// ✅ 热点数据分散到多个 key,请求打散
public String buildHotKey(Long itemId) {
int suffix = itemId % 4;
return "product:" + itemId + ":shard" + suffix;
}
场景 2:分布式锁错误实现,导致重复扣款
事故描述:
14:30 用户点击支付
14:30:05 支付回调成功,余额已扣
14:30:06 前端未刷新,用户再次点击
14:30:07 系统执行了第二次扣款
14:31:00 用户发现余额被扣了 2 次
根因:分布式锁实现有 BUG,锁自动过期后未续期,其他线程进入。
问题代码:
// ❌ 问题:SETNX + TTL 有漏洞
public boolean lock(String orderId) {
Boolean success = redisTemplate.opsForValue()
.setIfAbsent("lock:order:" + orderId, "1", 30, TimeUnit.SECONDS);
if (success) {
// 执行业务...
redisTemplate.delete("lock:order:" + orderId);
return true;
}
return false;
}
漏洞详解:
时间线:
T1: 线程A 获取锁,设置 30s TTL
T2: 线程A 开始执行业务(预计 35s)
T3: T1+30s 锁自动过期
T4: 线程B 获取锁成功(线程A 还在执行业务)
T5: 线程A 释放锁(但释放的是线程B 的锁!)
T6: 线程B 执行业务(和线程A 同时执行)
结果:并发执行,重复扣款
修复方案:Redisson 分布式锁:
// ✅ Redisson:自动续期 + 防止误删他人锁
@Service
public class PaymentLockService {
@Autowired
private RedissonClient redisson;
public void processPayment(PaymentRequest request) {
String lockKey = "lock:payment:" + request.getOrderId();
RLock lock = redisson.getLock(lockKey);
try {
// 等待 0 秒,锁定 30 秒,自动续期
boolean locked = lock.tryLock(0, 30, TimeUnit.SECONDS);
if (!locked) {
throw new BusinessException("支付处理中,请勿重复操作");
}
paymentService.execute(request);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
场景 3:Lua 脚本扣款逻辑有 bug,余额变负
事故描述:
11:00 系统监控告警:部分用户余额为负
11:05 紧急排查:共 23 个账户余额为负,最高达 -5000 元
11:10 定位:Lua 脚本判断条件写反
11:30 修复代码 + 补偿余额
修复方案:完整 Lua 原子扣款脚本:
// ✅ 完整的 Lua 原子扣款脚本
public class RedisDeductScript {
public static final String DEDUCT_SCRIPT =
"local balance = tonumber(redis.call('HGET', KEYS[1], 'balance'))\n" +
"if balance == nil then\n" +
" return -2\n" + // 账户不存在
"elseif balance < 0 then\n" +
" return -3\n" + // 账户异常
"elseif balance < tonumber(ARGV[1]) then\n" +
" return -1\n" + // 余额不足
"else\n" +
" redis.call('HINCRBY', KEYS[1], 'balance', -tonumber(ARGV[1]))\n" +
" return redis.call('HGET', KEYS[1], 'balance')\n" +
"end";
public BigDecimal deduct(String accountId, BigDecimal amount) {
Long result = redisTemplate.execute(
new DefaultRedisScript<>(DEDUCT_SCRIPT, Long.class),
Collections.singletonList("account:" + accountId),
amount.toString()
);
switch (result != null ? result.intValue() : -99) {
case -1: throw new InsufficientBalanceException("余额不足");
case -2: throw new AccountNotFoundException("账户不存在");
case -3: throw new SystemException("账户余额异常");
default: return new BigDecimal(result);
}
}
}
场景 4:大 Key 导致集群不稳定
事故描述:
16:00 定时任务执行:对账数据写入 Redis Hash
16:00:05 Redis CPU 飙升 100%
16:00:10 支付接口超时
根因:对账数据用单个大 Hash 存储,字段数超过百万。
修复方案:分桶存储:
// ✅ 按 userId 分桶,每个 Hash 控制在合理大小
public void saveReconcileData(String date, List<TradeRecord> records) {
Map<String, Map<String, String>> bucket = new HashMap<>();
for (TradeRecord record : records) {
int bucketId = record.getUserId().hashCode() % 100;
String bucketKey = "reconcile:" + date + ":bucket:" + bucketId;
bucket.computeIfAbsent(bucketKey, k -> new HashMap<>())
.put(record.getOrderId(), JSON.toJSONString(record));
}
for (Map.Entry<String, Map<String, String>> entry : bucket.entrySet()) {
redisTemplate.opsForHash().putAll(entry.getKey(), entry.getValue());
}
}
大 Key 监控:
# 扫描大 Key
redis-cli --bigkeys
# 设置告警(redis.conf)
notify-keyspace-events ExK
场景 5:缓存与数据库双写不一致
事故描述:
09:00 财务对账:系统余额和数据库余额不一致,差 127 元
09:15 排查:Redis 余额比 DB 多 127 元
09:20 定位:更新 DB 后更新 Redis 时网络抖动失败
问题流程:
❌ 先更新 DB,再更新 Redis,Redis 失败时不一致
1. 更新 DB 余额 = 900
2. 更新 Redis 余额 = 900 ← 失败
3. 其他请求读到 Redis = 1000(旧值)
修复方案:延迟双删:
// ✅ 延迟双删策略
public void updateBalance(String userId, BigDecimal newBalance) {
// 1. 删除缓存
redisTemplate.delete("account:" + userId);
// 2. 更新 DB
accountMapper.updateBalance(userId, newBalance);
// 3. 延迟 500ms 再删除缓存
CompletableFuture.delayedExecutor(500, TimeUnit.MILLISECONDS)
.execute(() -> redisTemplate.delete("account:" + userId));
}
场景 6:Redis Sentinel 脑裂导致数据丢失
事故描述:
03:00 数据中心网络抖动
03:00:05 Redis Master 与 Sentinel 失联
03:00:10 Sentinel 选举新 Master
03:00:15 网络恢复,旧 Master 重连为 Slave
03:00:20 发现部分数据丢失
根因:网络分区期间两个 Master 同时存在,分区期间写入新 Master 的数据被覆盖。
修复方案:合理配置 Sentinel:
# Redis Master 配置
min-slaves-to-write 1 # 至少 1 个从节点,否则拒绝写入
min-slaves-max-lag 10 # 从节点延迟不超过 10s
# Sentinel 配置
sentinel monitor mymaster <master-ip> 6379 2
# 至少 2 个 Sentinel 同意才切换(多数派)
三、支付中台 Redis 最佳实践
3.1 key 命名规范
✅ 推荐:
payment:lock:order:{orderId}
payment:cache:account:{userId}
payment:counter:order:{date}
payment:session:token:{tokenId}
❌ 禁止:
lock:{orderId} ← 无业务前缀
data ← 语义不明
3.2 过期时间策略
| 数据类型 | 过期时间 | 原因 |
|---|---|---|
| 分布式锁 | 10~30s | 与业务超时时间一致 |
| 验证码/Token | 5~30 分钟 | 与有效期一致 |
| 热点缓存 | 5~30 分钟 | 权衡一致性和性能 |
| 对账数据 | 7 天 | 对账完成后清理 |
四、总结:支付中台 Redis 安全红线
Redis 使用红线(绝对禁止):
❌ 禁止使用 KEYS 命令(O(N),会阻塞 Redis)
❌ 禁止不设置过期时间(除非真的不过期)
❌ 禁止存储超过 10MB 的 Value(大 Key)
❌ 禁止单实例撑所有业务(必须分库)
❌ 禁止不带锁的并发更新(Lua 或分布式锁)
❌ 禁止将 Redis 当作数据库(只做缓存,必须有 DB 兜底)
Redis 必做项:
✅ 所有操作必须有超时时间
✅ 所有写入必须有幂等
✅ 所有缓存必须有降级方案
✅ 所有集群必须有监控告警
✅ 所有大 Key 必须拆分
关联阅读: