支付中台 Redis 实战:8 个真实踩坑与优化案例

0 阅读1分钟

支付中台 Redis 实战:8 个真实踩坑与优化案例

前置说明:本文参考美团、拼多多、蚂蚁金服等公开技术分享,结合生产真实案例,讲解 Redis 在支付中台的核心应用与避坑经验。


一、Redis 在支付中台的角色定位

1.1 支付系统的 Redis 典型应用

Redis 在支付系统中的五大职责:

1. 分布式锁        → 幂等控制、并发互斥
2. 缓存            → 热点数据、计算结果
3. 计数器          → 限流、订单号生成
4. 会话存储        → 用户 Token、登录状态
5. 发布/订阅        → 实时通知、事件广播

1.2 支付系统的 Redis 选型

数据类型业务场景Redis 结构
StringToken、会话、验证码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与业务超时时间一致
验证码/Token5~30 分钟与有效期一致
热点缓存5~30 分钟权衡一致性和性能
对账数据7 天对账完成后清理

四、总结:支付中台 Redis 安全红线

Redis 使用红线(绝对禁止):
❌ 禁止使用 KEYS 命令(O(N),会阻塞 Redis)
❌ 禁止不设置过期时间(除非真的不过期)
❌ 禁止存储超过 10MB 的 Value(大 Key)
❌ 禁止单实例撑所有业务(必须分库)
❌ 禁止不带锁的并发更新(Lua 或分布式锁)
❌ 禁止将 Redis 当作数据库(只做缓存,必须有 DB 兜底)

Redis 必做项:
✅ 所有操作必须有超时时间
✅ 所有写入必须有幂等
✅ 所有缓存必须有降级方案
✅ 所有集群必须有监控告警
✅ 所有大 Key 必须拆分

关联阅读