应采用引用而非嵌入发放记录,因优惠券主文档需保持轻量高频读取,而发放记录属高基数写多读少数据,须独立为coupon_usages集合并通过couponid引用,配合唯一索引防重领、findoneandupdate原子扣库存及ttl索引清理预占脏数据。

优惠券主文档该用嵌入还是引用发放记录?
优惠券本身是强状态、低基数、高频读取的实体,发放记录却是高基数、写多读少、可能无限增长的数据。硬把每次领取都塞进 coupons 文档里,很快会触发 16MB 文档大小限制,且每次更新都要重写整个文档,IO 压力陡增。
- 发放记录必须独立成集合(如
coupon_usages),用couponId字段做引用 -
coupons文档只保留核心字段:code、stock、status、validFrom、validTo、lastUsedAt - 若需快速统计某张券发了多少张,可在
coupons里冗余一个usedCount字段,靠findOneAndUpdate原子递增(但注意:它不等于真实发放数,仅作近似展示用)
发放记录要不要存用户信息?怎么避免重复领同一张券?
直接在 coupon_usages 文档里嵌入用户基础信息(如 userId、phoneHash)是合理做法——既能防刷(比如同手机号限领 1 张),又避免查 users 集合的额外开销。
- 必须建复合唯一索引:
db.coupon_usages.createIndex({ couponId: 1, userId: 1 }, { unique: true }) - 如果业务要求“同一手机号只能领一次”,那就再加一层索引:
db.coupon_usages.createIndex({ couponId: 1, phoneHash: 1 }, { unique: true }) - 不要依赖应用层判断“是否已领过”再插入——网络延迟或重试会让这个判断失效;唯一索引才是最终防线
怎么防止并发超发还保证高性能?
根本矛盾在于:既要原子扣减库存,又不能锁整张表或拖慢查询。MongoDB 的解法很明确——用 findOneAndUpdate,而不是 updateOne。
- 正确写法示例:
db.coupons.findOneAndUpdate( { code: "ABC123", status: "active", stock: { $gt: 0 } }, { $inc: { stock: -1 }, $set: { lastUsedAt: new Date() } }, { returnDocument: "after" } ) - 返回结果为空 → 没抢到;返回文档中
stock为 0 → 这是最后一张;返回文档中stock≥ 0 → 成功发出 - 错误写法:
updateOne({ code: "ABC123", stock: { $gt: 0 } }, { $inc: { stock: -1 } }),它可能匹配成功但modifiedCount === 0,业务层无法区分是库存耗尽还是其他原因失败
过期未使用的预占记录怎么自动清理?
真实场景中,用户点击领券后前端卡住、或调用发券接口后没收到响应就重试,会导致大量“已扣库存但无对应 usage 记录”的脏数据。这类记录不能靠人工巡检。
- 在
coupon_usages集合上建 TTL 索引:db.coupon_usages.createIndex({ createdAt: 1 }, { expireAfterSeconds: 3600 }) - 插入时务必带
createdAt: new Date()字段 - 注意:TTL 删除不是实时的,后台线程每 60 秒扫描一次,所以设置过期时间要留出缓冲(比如预占有效期设为 1 小时,就别设
expireAfterSeconds: 60)
真正容易被忽略的是:TTL 索引只删 coupon_usages,不会回补 coupons 里的 stock。所以得配套一个异步任务,定期扫描“有扣减记录但无 usage 文档”的券,并执行补偿性加库存——这步没人做,超发缺口就永远存在。











