高并发优惠券系统设计:怎样限制使用次数
1. 概述
一张“满减100元、仅1000份”的优惠券听起来很简单。但在秒杀时刻,数万个请求瞬间涌入。此时系统可能误发1050份而非1000份,那多出的50份就是真金白银的损失。本文通过一个完整的案例分析,涵盖需求、数据模型、API,然后逐一解决难点,即竞争、一致性和流量突增。读完本文,你就知道何时仅需一条原子性UPDATE语句,何时需要引入Redis和Kafka。
2. 需求
2.1. 功能需求
- 创建优惠券,包含代码、优惠额、最小订单金额、有效期。
- 限制总使用次数和每个用户的最多使用次数。
- 结账时,优惠券被保留(reserve)。支付成功后确认(confirm)。取消订单或超时则释放(release)。
2.2. 规模和约束
- 约1000万个客户。秒杀优惠券1000份,但在开始几秒内可能收到5万req/s。
- reserve API的p99延迟低于100ms。
- 绝对不能超售。 暂时少售可以接受,比如一份被保留,15分钟后才释放。
- 重试的请求(retry)不应该再消耗一份。换句话说,API必须按
orderId幂等。
3. 数据模型
数据库(PostgreSQL)是最终的真实来源:
CREATE TABLE voucher (
code VARCHAR(32) PRIMARY KEY,
discount_amount BIGINT NOT NULL,
min_order_amount BIGINT NOT NULL DEFAULT 0,
total_limit INT NOT NULL,
used_count INT NOT NULL DEFAULT 0,
per_user_limit INT NOT NULL DEFAULT 1,
starts_at TIMESTAMP NOT NULL,
ends_at TIMESTAMP NOT NULL,
CONSTRAINT ck_used CHECK (used_count <= total_limit)
);
CREATE TABLE voucher_redemption (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
voucher_code VARCHAR(32) NOT NULL REFERENCES voucher(code),
customer_id BIGINT NOT NULL,
order_id VARCHAR(64) NOT NULL UNIQUE,
status VARCHAR(16) NOT NULL, -- 保留、已确认、已释放
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_voucher_customer UNIQUE (voucher_code, customer_id)
);
几个值得注意的地方:
CHECK (used_count <= total_limit)是最后的防线。即使代码有bug,数据库也不会允许超售。order_id UNIQUE保证幂等性:一个订单只能使用一次。UNIQUE (voucher_code, customer_id)仅在per_user_limit = 1时正确,这是最常见的情况。如果允许每个用户多次使用,请删除此约束,在单独的表中计数。
4. API
POST /vouchers/{code}/reservations body: {customerId, orderId, orderAmount}
201 {orderId, discountAmount}
409 {error: "OUT_OF_STOCK" | "ALREADY_USED"}
422 {error: "NOT_ELIGIBLE"} (已过期、订单金额不足)
POST /vouchers/{code}/reservations/{orderId}/confirm
DELETE /vouchers/{code}/reservations/{orderId}
orderId 由客户端(订单服务)生成。这样,当重试时,客户端会重新发送相同的orderId,服务器会识别出这是重复的请求。
5. 朴素方案及其问题
@Transactional
public boolean redeemNaive(String code) {
Voucher v = voucherRepository.findById(code).orElseThrow();
if (v.getUsedCount() >= v.getTotalLimit()) {
return false;
}
v.setUsedCount(v.getUsedCount() + 1);
return true;
}
这是典型的“检查然后行动”的竞态条件。两个线程同时读取 usedCount = 999,两者都认为有名额,都写入1000。结果是两个客户都获得了折扣,但计数器只增加了1。在默认的 READ COMMITTED 隔离级别下,事务无法在这种情况下救你。
6. 解决方案1:数据库中的条件UPDATE
最简单的修复是将检查和写入合并到同一个原子操作中:
@Service
public class VoucherService {
private final JdbcTemplate jdbc;
public VoucherService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Transactional
public boolean redeem(String code, long customerId, String orderId) {
int updated = jdbc.update("""
UPDATE voucher SET used_count = used_count + 1
WHERE code = ?
AND used_count < total_limit
AND CURRENT_TIMESTAMP BETWEEN starts_at AND ends_at
""", code);
if (updated == 0) {
return false; // 配额用完或已过期
}
jdbc.update("""
INSERT INTO voucher_redemption(voucher_code, customer_id, order_id, status)
VALUES (?, ?, ?, 'CONFIRMED')
""", code, customerId, orderId);
return true;
}
}
代码工作如下:
- UPDATE语句锁定优惠券行。数据库在获取锁后重新评估
used_count < total_limit,所以不会有两个请求都在最后一次通过。 updated == 0表示条件不满足:配额用完或优惠券已过期。- 如果客户已使用优惠券或
orderId被重新发送,INSERT语句抛出DuplicateKeyException。事务回滚,used_count恢复到原值。在控制器层,将此异常映射到409 ALREADY_USED。
6.1. 并发负载下的行为测试
@JdbcTest
@Import(VoucherService.class)
@Transactional(propagation = Propagation.NOT_SUPPORTED)
class VoucherServiceTest {
@Autowired JdbcTemplate jdbc;
@Autowired VoucherService voucherService;
@BeforeEach
void setUp() {
jdbc.update("DELETE FROM voucher_redemption");
jdbc.update("DELETE FROM voucher");
jdbc.update("""
INSERT INTO voucher(code, discount_amount, total_limit, starts_at, ends_at)
VALUES ('FLASH100', 100000, 10, DATEADD('DAY', -1, CURRENT_TIMESTAMP),
DATEADD('DAY', 1, CURRENT_TIMESTAMP))
""");
}
@Test
void shouldNeverExceedTotalLimit() throws Exception {
AtomicInteger success = new AtomicInteger();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 200; i++) {
long customerId = i;
executor.submit(() -> {
if (voucherService.redeem("FLASH100", customerId, "order-" + customerId)) {
success.incrementAndGet();
}
});
}
}
Integer used = jdbc.queryForObject(
"SELECT used_count FROM voucher WHERE code = 'FLASH100'", Integer.class);
assertThat(success.get()).isEqualTo(10);
assertThat(used).isEqualTo(10);
}
}
@Transactional(propagation = NOT_SUPPORTED) 禁用默认事务。否则,setUp 中创建的数据尚未提交,其他线程看不到。有200个虚拟线程争夺10份配额,结果始终是正确的10:
success = 10, used_count = 10
6.2. 解决方案1的限制
这个方法是正确的,对大多数普通优惠券(每秒几百个请求)就足够了。问题仅在负载集中在单一行(热行)时出现。5万req/s都排队等待锁在FLASH100行上。每个事务持有锁几毫秒,所以实际吞吐量被限制在每秒几千。其余请求超时,连接池枯竭,整个数据库变慢,包括与优惠券无关的表。
7. 解决方案2:用Redis Lua脚本作“网关”
思路:将剩余配额的计数器放在Redis中。Redis运行在单线程上,每个Lua脚本原子执行,耗时约几微秒。只有获得名额的请求才会继续到数据库。对于1000份的优惠券,数据库最多收到1000次写入,而非50000次。
依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
Reserve脚本,保存在resources/scripts/reserve.lua:
-- KEYS[1] = voucher:{code}:remaining
-- KEYS[2] = voucher:{code}:users (hash customerId -> 次数)
-- KEYS[3] = voucher:{code}:orders (hash orderId -> customerId)
-- ARGV[1] = customerId, ARGV[2] = perUserLimit, ARGV[3] = orderId
if redis.call('HEXISTS', KEYS[3], ARGV[3]) == 1 then return 0 end -- retry,已保留
if redis.call('EXISTS', KEYS[1]) == 0 then return -3 end -- 未加载优惠券
local used = tonumber(redis.call('HGET', KEYS[2], ARGV[1]) or '0')
if used >= tonumber(ARGV[2]) then return -2 end -- 客户已使用
if tonumber(redis.call('GET', KEYS[1])) <= 0 then return -1 end -- 配额用完
redis.call('DECR', KEYS[1])
redis.call('HINCRBY', KEYS[2], ARGV[1], 1)
redis.call('HSET', KEYS[3], ARGV[3], ARGV[1])
return 1
关键字使用哈希标签 {code}。这样,在Redis Cluster上,三个关键字都在同一个slot中,脚本可以执行。
Java端:
@Component
public class VoucherGate {
public enum Result { RESERVED, DUPLICATE, OUT_OF_STOCK, ALREADY_USED, NOT_LOADED }
private static final RedisScript<Long> RESERVE =
RedisScript.of(new ClassPathResource("scripts/reserve.lua"), Long.class);
private final StringRedisTemplate redis;
public VoucherGate(StringRedisTemplate redis) {
this.redis = redis;
}
public Result reserve(String code, long customerId, int perUserLimit, String orderId) {
String p = "voucher:{" + code + "}:";
Long r = redis.execute(RESERVE,
List.of(p + "remaining", p + "users", p + "orders"),
String.valueOf(customerId), String.valueOf(perUserLimit), orderId);
return switch (r.intValue()) {
case 1 -> Result.RESERVED;
case 0 -> Result.DUPLICATE;
case -1 -> Result.OUT_OF_STOCK;
case -2 -> Result.ALREADY_USED;
default -> Result.NOT_LOADED;
};
}
}
DUPLICATE 被视为成功:客户端重试时会获得相同的结果。NOT_LOADED 表示优惠券尚未被“预热”。你可以用SET ... NX从数据库懒加载,或者更好的办法是在销售开始前就预加载,通过一个job:
redis-cli SET "voucher:{FLASH100}:remaining" 1000
再加一个优化:当remaining已变为0时,API网关或本地缓存标志(local cache,TTL几秒)可以直接返回OUT_OF_STOCK而不调用Redis。数十万个“迟到”的请求会在最外层停止。
8. Redis和数据库之间的一致性
Redis现在是决策者,但数据库才是关于资金的真相。有两种同步方式。
方式A:在Redis之后同步写入DB。 你仍然运行第6节中的UPDATE语句。如果数据库出错,调用release脚本返回配额给Redis。这种方法更简单、一致性更强,适合配额较小时。
方式B:通过Kafka推送事件,当想把DB完全从热路径上分离时:
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
public record VoucherReserved(String code, long customerId, String orderId) {}
@Component
public class RedemptionConsumer {
private final JdbcTemplate jdbc;
public RedemptionConsumer(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@KafkaListener(topics = "voucher-reserved", groupId = "voucher-writer")
@Transactional
public void on(VoucherReserved e) {
int inserted = jdbc.update("""
INSERT INTO voucher_redemption(voucher_code, customer_id, order_id, status)
VALUES (?, ?, ?, 'RESERVED')
ON CONFLICT (order_id) DO NOTHING
""", e.code(), e.customerId(), e.orderId());
if (inserted == 1) {
jdbc.update("UPDATE voucher SET used_count = used_count + 1 WHERE code = ?", e.code());
}
}
}
在服务端,Lua返回RESERVED后,你调用 kafkaTemplate.send("voucher-reserved", code, event) 以code作为key。Consumer有以下特点:
- Kafka提供at-least-once保证,同一事件可能到达两次。
ON CONFLICT (order_id) DO NOTHING使第二次成为no-op,且used_count仅在实际插入新行时增加。 - 约束
CHECK (used_count <= total_limit)仍然存在。如果Redis被错误地加载,DB会拒绝,事件会落入死信topic供运维人员处理。
注意: 如果Redis成功保留但Kafka发送失败,该配额会在Redis中“悬空”。这是少售,资金上是安全的但需要清理。因此你需要对账job(第8.1节)。
8.1. 保留过期和对账
客户保留优惠券后弃购,该配额必须被返还。定期job处理这个:
@Scheduled(fixedDelay = 60_000)
public void releaseExpired() {
List<Map<String, Object>> expired = jdbc.queryForList("""
UPDATE voucher_redemption SET status = 'RELEASED'
WHERE status = 'RESERVED' AND created_at < CURRENT_TIMESTAMP - INTERVAL '15 minutes'
RETURNING voucher_code, customer_id, order_id
""");
expired.forEach(row -> voucherGate.release(
(String) row.get("voucher_code"),
((Number) row.get("customer_id")).longValue(),
(String) row.get("order_id")));
}
UPDATE … RETURNING (PostgreSQL)既改变状态又返回受影响的行。因此两个instance同时运行job也不会重复释放:已转换为RELEASED的行不再匹配条件。你还需要相应地在DB中减少used_count。release函数调用相反的Lua脚本:仅当 HDEL orders orderId 返回1时,才 INCR remaining 并 HINCRBY users customerId -1。这样,调用release两次也是安全的。
再加一个对账job在每次销售后运行:比对DB中的total_limit - used_count和Redis中的remaining,如有偏差则发出警报。
9. 常见错误和何时不应使用
- 使用GET然后DECR作为Java中的两个独立命令。 这又是check-then-act,只是转到Redis上。检查和减法必须在同一个Lua脚本中。
- 仅使用DECR然后检查负数再INCR回去。 这个方法可以工作,但在值为负的瞬间,另一个请求可能读取错误。它也使release操作变得更复杂。
- 对热优惠券使用乐观锁(@Version)。 当50000个请求竞争一行时,几乎全部都失败并重试。负载甚至比悲观锁更糟。
- Redis未启用持久化/复制。 如果Redis重启丢失数据,预热job从
total_limit重新加载,你会超售。始终从DB中的total_limit - used_count重新加载,减去处于RESERVED的配额。 - 对所有优惠券应用Redis + Kafka。 普通优惠券、低负载仅需方案6。多出两个系统就多出两个可能数据不一致的地方。仅对标记的优惠券启用“闪购模式”。
注意: 上面的所有层只是优化。数据库中的约束(CHECK、UNIQUE)才是最后的保证,没有配额被超发。
10. 结论
优惠券使用次数限制问题本质上是竞争下的原子计数问题。对于中等负载,一条UPDATE ... WHERE used_count < total_limit语句加上unique约束既足够正确又足够快。当有闪购和热行时,你使用Redis Lua脚本作为原子网关。Kafka帮助将DB写入从热路径上移开,而带有过期的保留机制和对账job处理差异情况。贯穿始终的原则是:接受暂时的少售,绝不超售,让数据库保留最后的保护层。