作者:旷野说
定位:Spring Boot 开发者速查手册 + 高并发交易场景下的可靠性实践指南
关键澄清:真正的可靠性来自“缓存原子准入 + 异步落库 + 唯一索引兜底 + 对账补偿”的纵深防御体系,而非单一技术的幻想
我在交友电商平台踩过的坑:高并发抢限量礼物,事务与缓存如何共舞?
随着用户突破亿级,我们每天都在处理数百万笔“打赏”“礼物领取”“徽章抢购”等微交易。这些看似轻量的操作,一旦在高并发下出错,轻则数据不一致,重则资损投诉。
今天,我想用一次真实的“七夕限量情书”活动复盘,讲清楚三个关键问题:
SELECT ... FOR UPDATE真能防超发吗?- 加了 Redis 缓存后,为什么反而更容易超发?
- 如何在保证高性能的同时,守住数据一致性底线?
一、“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) | 优化后(缓存+异步) |
|---|---|---|
| 峰值 QPS | 30 | 120,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,也别乱用缓存——组合拳,才是高并发的真答案。


被折叠的 条评论
为什么被折叠?



