Redis 与 MySQL 一致性实战:一次多发 317 张优惠券的故障链

举报
霍格沃兹测试学社 发表于 2026/09/08 10:39:21 2026/09/08
【摘要】 一次优惠券超发事故揭示缓存与数据库一致性本质:Redis仅作读加速,最终裁决必须由MySQL事务+唯一约束保障。故障源于缓存删除失败+缺乏原子扣减条件,导致317张券超发。

10 万张限量优惠券在 10 点开抢。监控里 Redis 正常、MySQL 正常,接口成功率也没有突然掉到谷底,活动结束后却多出了 317 条领取记录。

事故复盘发现:最后一张券在数据库里扣减成功后,应用删除 Redis 库存键失败;随后一批请求继续从旧缓存读到“还有 84 张”。更致命的是,数据库写入没有把“库存仍大于 0”作为原子条件,只相信了缓存的预检结果。

image.png

先改一个观念:Redis 不是稀缺资源的最终裁判

缓存适合加速读取和拦截明显无效请求,但限量券、库存、余额和名额一旦产生经济后果,最终裁决必须落在具备事务与唯一约束的存储中。

这意味着缓存短暂不一致可以接受,但不一致只能造成“页面显示还有、提交后告知已领完”,不能造成真实超发。测试目标不是幻想 Redis 与 MySQL 永远同一瞬间相等,而是验证任何故障窗口都无法穿透业务不变量。

image.png

把两个不变量写进数据库,而不是写进注释

第一,同一用户在同一活动只能成功领取一次;第二,活动 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 设得再短,也存在业务高峰中的错误窗口;设得太短还会造成大量回源,把数据库压垮。

image.png

缓存一致性应该怎样测试

正常读写只是起点。至少要在四个位置注入故障:数据库提交前崩溃、提交后删缓存前崩溃、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 一致性真正要守住的,不是每次读取都完全相同,而是任何缓存旧值、消息重复和进程崩溃,都不能让稀缺资源的业务账失真。**这才是缓存一致性测试的终点。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。