Redis 与 MySQL 一致性实战:一次多发 317 张优惠券的故障链
10 万张限量优惠券在 10 点开抢。监控里 Redis 正常、MySQL 正常,接口成功率也没有突然掉到谷底,活动结束后却多出了 317 条领取记录。
事故复盘发现:最后一张券在数据库里扣减成功后,应用删除 Redis 库存键失败;随后一批请求继续从旧缓存读到“还有 84 张”。更致命的是,数据库写入没有把“库存仍大于 0”作为原子条件,只相信了缓存的预检结果。

先改一个观念:Redis 不是稀缺资源的最终裁判
缓存适合加速读取和拦截明显无效请求,但限量券、库存、余额和名额一旦产生经济后果,最终裁决必须落在具备事务与唯一约束的存储中。
这意味着缓存短暂不一致可以接受,但不一致只能造成“页面显示还有、提交后告知已领完”,不能造成真实超发。测试目标不是幻想 Redis 与 MySQL 永远同一瞬间相等,而是验证任何故障窗口都无法穿透业务不变量。

把两个不变量写进数据库,而不是写进注释
第一,同一用户在同一活动只能成功领取一次;第二,活动 remaining 不得小于 0。可以用唯一约束和条件更新共同保证:
CREATE UNIQUE INDEX uq_claim_campaign_user
ON coupon_claim(campaign_id, user_id);
BEGIN;
UPDATE coupon_campaign
SET remaining = remaining - 1,
version = version + 1
WHERE campaign_id = :campaign_id
AND status = 'ACTIVE'
AND starts_at <= NOW()
AND ends_at > NOW()
AND remaining > 0;
-- 应用必须检查上一条 UPDATE 是否恰好影响 1 行
INSERT INTO coupon_claim(campaign_id, user_id, status)
VALUES (:campaign_id, :user_id, 'CLAIMED');
COMMIT;
真实实现中,若条件更新影响 0 行,应区分活动不存在、时间不合法、库存耗尽等结果;若唯一约束冲突,应返回“你已经领取”,并回滚本次库存扣减。两个动作必须在同一事务里。
数据库提交和缓存更新之间,故障一定会发生
最危险的窗口是:数据库已经提交,进程还没来得及删缓存就崩溃。把删缓存写在 try 的下一行,并不能让两者原子化。
一种可落地方案是在同一事务里写 Outbox 事件,后台工作器按事件刷新或删除缓存。即使进程重启,事件仍可重放。缓存值附带数据库版本,旧事件晚到时也不能覆盖新版本。
def apply_cache_event(cache, event):
key = "coupon:" + str(event["campaign_id"])
current = cache.get(key) or {
"version": -1}
if current["version"] >= event["version"]:
return "ignored_old_event"
cache.set(key, {
"remaining": event["remaining"],
"version": event["version"],
}, ttl_seconds=30)
return "applied"
TTL 是最后一道自愈手段,不是主要一致性方案。TTL 设得再短,也存在业务高峰中的错误窗口;设得太短还会造成大量回源,把数据库压垮。

缓存一致性应该怎样测试
正常读写只是起点。至少要在四个位置注入故障:数据库提交前崩溃、提交后删缓存前崩溃、Outbox 已发送但确认丢失、旧缓存事件晚于新事件到达。再叠加 100 路同用户并发和 1000 路不同用户争抢最后 50 张券。
最终不要只数 HTTP 200,而要查询数据库终态:成功领取数不超过初始库存;remaining 不小于 0;同一用户最多一条成功记录;所有成功记录都能追溯到活动版本。缓存可以暂时落后,但应在约定时间内收敛。
def assert_campaign_invariants(repo, campaign_id, initial_stock):
campaign = repo.get_campaign(campaign_id)
claims = repo.successful_claims(campaign_id)
users = [row.user_id for row in claims]
assert 0 <= campaign.remaining <= initial_stock
assert len(claims) + campaign.remaining == initial_stock
assert len(users) == len(set(users))
监控也要从组件可用升级到业务一致
Redis 命中率和 MySQL QPS 都健康,不代表券没多发。生产上还要观察:缓存版本落后量、Outbox 最老积压时间、库存差值、唯一约束冲突率、提交后回写失败数。它们才能提前暴露一致性正在恶化。
Redis 与 MySQL 一致性真正要守住的,不是每次读取都完全相同,而是任何缓存旧值、消息重复和进程崩溃,都不能让稀缺资源的业务账失真。这才是缓存一致性测试的终点。