交友电商平台踩过的坑:高并发抢限量礼物,事务与缓存如何共舞

作者:旷野说
定位:Spring Boot 开发者速查手册 + 高并发交易场景下的可靠性实践指南
关键澄清:真正的可靠性来自“缓存原子准入 + 异步落库 + 唯一索引兜底 + 对账补偿”的纵深防御体系,而非单一技术的幻想


我在交友电商平台踩过的坑:高并发抢限量礼物,事务与缓存如何共舞?

随着用户突破亿级,我们每天都在处理数百万笔“打赏”“礼物领取”“徽章抢购”等微交易。这些看似轻量的操作,一旦在高并发下出错,轻则数据不一致,重则资损投诉。

今天,我想用一次真实的“七夕限量情书”活动复盘,讲清楚三个关键问题:

  1. SELECT ... FOR UPDATE 真能防超发吗?
  2. 加了 Redis 缓存后,为什么反而更容易超发?
  3. 如何在保证高性能的同时,守住数据一致性底线?

一、“FOR UPDATE 能防超发?”——能,但会把系统锁死

活动上线前,我信心满满地写了这段代码:

@Service
public class LetterService {

    @Transactional
    public void claimLetter(Long userId) {
        // 查询已发放数量(带锁)
        int claimed = letterMapper.countByTypeForUpdate("QIXI_2025");
        if (claimed >= 1000) {
            throw new BusinessException("情书已抢光");
        }
        letterMapper.insert(userId, "QIXI_2025");
    }
}

表面逻辑完美:事务 + 当前读 + 临键锁,InnoDB 应该能防止任何幻读或并发插入。

现实却很骨感
活动开始 3 秒,数据库连接池耗尽,99% 用户看到“系统繁忙”。
因为 FOR UPDATE 会让所有请求串行排队——第一个人查完、插完、提交,第二个人才能进。
吞吐量从预估的 5000 QPS 直接跌到 30 QPS

✅ 它确实没超发,但业务目标彻底失败:用户没抢到,只抢到了 frustration。

教训

FOR UPDATE 是“正确但慢”的守门员,适合低频操作(如后台发奖);
高并发场景?它不是解决方案,是性能瓶颈


二、加了 Redis,为什么反而超发了?

我们痛定思痛,决定用 Redis 扛流量:

// ❌ 致命错误:非原子操作!
public boolean tryClaim(Long userId) {
    if (redis.get("letter:stock") > 0) {
        redis.decr("letter:stock"); // 高并发下,100 个线程同时读到 1
        mq.send(...);
        return true;
    }
    return false;
}

结果:1000 份情书,发出去 1327 份。
用户晒图炫耀,财务紧急冻结账户——资损事故

根源
Redis 的单命令是原子的,但“读 → 判断 → 写”是三个命令!
没有 Lua 脚本,就没有真正的原子性

🔥 高并发下,缓存不是银弹,而是放大器——
你写对了,它加速;你写错了,它加速崩盘。


三、我们的最终方案:缓存准入 + 异步落库 + 唯一索引兜底

经过多次事故复盘,我们沉淀出一套高并发防超发的黄金组合

第一步:Redis 原子预扣(用 Lua 脚本)
-- KEYS[1]=stock, KEYS[2]=user_set, ARGV[1]=user_id
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then
    return -1  -- 已领取
end
local stock = tonumber(redis.call('GET', KEYS[1]))
if not stock or stock <= 0 then
    return 0   -- 库存空
end
redis.call('DECR', KEYS[1])
redis.call('SADD', KEYS[2], ARGV[1])
redis.call('EXPIRE', KEYS[1], 86400)
redis.call('EXPIRE', KEYS[2], 86400)
return 1

✅ 10 万 QPS 毫秒响应,零超扣、零重复

第二步:异步落库(消息队列削峰)

用户立刻看到“领取成功”,后台通过 RocketMQ 异步写 MySQL:

giftClaimMQ.send(new ClaimEvent(userId, "QIXI_2025"));

数据库压力降低 99%,连接池稳如泰山。

第三步:唯一索引兜底(数据一致性的最后防线)

即使 Redis 宕机、消息重复、网络分区,MySQL 的唯一索引永远守住底线:

CREATE UNIQUE INDEX uk_user_gift ON gifts(user_id, gift_type);

插入重复?直接 DuplicateKeyException,安全忽略。

第四步:每日对账(兜底中的兜底)

凌晨跑对账任务,比对 Redis 与 DB 的差异,
漏发补发,多发溯源——让每一次资损无处可藏


效果:性能与一致性兼得
指标优化前(FOR UPDATE)优化后(缓存+异步)
峰值 QPS30120,000+
超发率0%0%
用户成功率1%99.98%
DB 负载打满平稳

这才是高并发微交易的正确打开方式。


防超发三字经(我的实战口诀)

缓存扛,Lua 保;
异步落,索引兜;
对账在,心不慌。

  • 缓存扛:用 Redis 扛住瞬时洪峰;
  • Lua 保:扣库存必须原子,Lua 是唯一选择;
  • 异步落:数据库异步写,避免成为瓶颈;
  • 索引兜:唯一索引是数据一致性的终极保险;
  • 对账在:再可靠的系统,也要有兜底校验。

最后提醒:别让依赖管理埋雷

在实现上述方案时,我们还踩过一个隐蔽的坑:Redis 客户端版本冲突

因为我们同时引入了 Spring Boot Data Redis 和第三方消息 SDK,
Maven 没有选择“最新版” Redisson,而是根据“依赖调解规则”(nearest wins)选了一个旧版,
导致 RedisScript 执行异常,原子性失效

关键澄清
Maven 不会自动选“最新版”依赖,而是通过 nearest wins + first declaration 决定版本。
版本冲突必须主动管理——用 <dependencyManagement> 显式锁定,或 mvn dependency:tree 定期扫描。


在交友电商的世界里,用户愿意为“心动”付费,但绝不容忍“系统发错”
我们既要让用户“秒领成功”,也要让财务“账实相符”——而这,正是技术最难也最有价值的地方。

下次你再面对“限量”“抢购”“打赏”这类场景,记住:
别迷信 FOR UPDATE,也别乱用缓存——组合拳,才是高并发的真答案。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值