高并发优惠券系统设计:怎样限制使用次数

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处理差异情况。贯穿始终的原则是:接受暂时的少售,绝不超售,让数据库保留最后的保护层。

system-designspring-bootrediskafkaconcurrency