外卖试吃活动接口并发抢购场景:Java 分布式锁与库存扣减原子性保障

在互联网外卖试吃、霸王餐等限时限量抢购场景对系统的并发处理能力提出了极高要求。当大量用户在同一时刻涌入,如何保证库存扣减的准确性,防止超卖,是后端开发的核心挑战。本文将基于 baodanbao.com.cn 的业务场景,深入探讨利用 Java 实现分布式锁与保障库存扣减原子性的技术方案。

一、 超卖问题的根源分析

在高并发场景下,典型的库存扣减逻辑通常包含两个步骤:

  1. 查询库存:检查当前库存是否大于0。
  2. 扣减库存:若库存充足,执行扣减操作(如 stock = stock - 1)。

若这两个步骤不具备原子性,当线程A执行完步骤1后被挂起,线程B也读取到相同的库存值(例如为1),两个线程都会认为库存充足并继续执行,最终导致库存变为负数,即发生超卖。

二、 基于数据库乐观锁的初级方案

最简单的防超卖手段是利用数据库的乐观锁。在库存表中增加一个版本号字段(version),更新时校验版本号。

实体类 Item.java:

package com.baodanbao.dao.entity;

/**
 * @author baodanbao.com.cn
 * 商品实体
 */
public class Item {
    private Long id;
    private Integer stock;
    private Integer version;
    // getter and setter
}

Mapper 接口 ItemMapper.java:

package com.baodanbao.dao.mapper;

import com.baodanbao.dao.entity.Item;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;

/**
 * @author baodanbao.com.cn
 */
@Mapper
public interface ItemMapper {

    /**
     * 扣减库存,基于版本号乐观锁
     * @return 影响行数,0表示扣减失败
     */
    @Update("UPDATE item SET stock = stock - 1, version = version + 1 " +
            "WHERE id = #{id} AND version = #{version} AND stock > 0")
    int decreaseStock(@Param("id") Long id, @Param("version") Integer version);
}

此方案利用数据库的行锁和原子性更新,能有效防止超卖,但在高并发下,大量请求会因版本号不匹配而更新失败,造成数据库压力过大,且用户体验不佳。

三、 引入Redis分布式锁(Redisson)

为了减轻数据库压力,需要将并发控制前置到应用层。使用 Redis 实现的分布式锁可以保证同一时刻只有一个线程能执行库存扣减逻辑。

引入 Redisson 依赖:

<!-- pom.xml -->
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.23.5</version>
</dependency>

配置类 RedissonConfig.java:

package com.baodanbao.config;

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * @author baodanbao.com.cn
 * Redisson 分布式锁配置
 */
@Configuration
public class RedissonConfig {

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        // 生产环境请配置真实的Redis地址
        config.useSingleServer().setAddress("redis://127.0.0.1:6379");
        return Redisson.create(config);
    }
}

在这里插入图片描述

四、 Lua脚本保证Redis操作原子性

在扣减库存时,通常先在Redis中检查库存,再进行扣减。这两个操作若分开执行,不具备原子性,可能在检查后、扣减前被其他线程插入。最佳实践是使用 Lua 脚本,由 Redis 单线程原子性执行。

库存服务 StockService.java:

package com.baodanbao.service;

import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import javax.annotation.PostConstruct;
import java.util.Collections;
import java.util.concurrent.TimeUnit;

/**
 * @author baodanbao.com.cn
 * 库存服务
 */
@Service
public class StockService {

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Autowired
    private RedissonClient redissonClient;

    // Lua 脚本:原子性检查并扣减库存
    private static final String SCRIPT_DECREASE_STOCK =
            "local stock = redis.call('GET', KEYS[1]) " +
            "if not stock then return -1 end " +
            "if tonumber(stock) <= 0 then return 0 end " +
            "redis.call('INCRBY', KEYS[1], -1) " +
            "return 1";

    private org.springframework.data.redis.core.script.DefaultRedisScript<Long> redisScript;

    @PostConstruct
    public void init() {
        redisScript = new org.springframework.data.redis.core.script.DefaultRedisScript<>();
        redisScript.setScriptText(SCRIPT_DECREASE_STOCK);
        redisScript.setResultType(Long.class);
    }

    /**
     * 尝试获取分布式锁并扣减库存
     */
    public boolean tryAcquire(Long userId, Long itemId) {
        // 1. 定义锁
        String lockKey = "lock:stock:" + itemId;
        RLock lock = redissonClient.getLock(lockKey);

        try {
            // 2. 尝试获取锁,最多等待100ms,锁自动释放时间3秒
            if (lock.tryLock(100, 3, TimeUnit.SECONDS)) {
                // 3. 执行Lua脚本扣减Redis库存
                Long result = redisTemplate.execute(
                        redisScript,
                        Collections.singletonList("stock:" + itemId),
                        Collections.emptyList()
                );

                if (result != null && result == 1) {
                    // 扣减成功,后续可异步持久化到数据库
                    System.out.println("用户 " + userId + " 抢购成功!");
                    return true;
                } else if (result == 0) {
                    System.out.println("库存不足");
                } else {
                    System.out.println("商品不存在");
                }
            } else {
                System.out.println("获取锁失败,系统繁忙");
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            System.out.println("线程中断");
        } finally {
            // 4. 释放锁
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
        return false;
    }
}

五、 进阶:Redisson 的看门狗机制与信号量

Redisson 的分布式锁内置了“看门狗”(Watchdog)机制。当线程持有锁后,Redisson 会启动一个后台定时任务,每隔一段时间(如 internalLockLeaseTime / 3)自动为锁续期,防止业务逻辑执行时间过长导致锁过期被其他线程获取,从而避免了死锁和并发安全问题。

此外,对于更复杂的场景,如限制接口的总并发数,可以使用 Redisson 的分布式信号量(Semaphore):

// 限制同时只有10个线程能进入临界区
RSemaphore semaphore = redissonClient.getSemaphore("stock:semaphore");
if (semaphore.tryAcquire(10, TimeUnit.MILLISECONDS)) {
    try {
        // 执行业务逻辑
    } finally {
        semaphore.release();
    }
}

六、 异步化与最终一致性

在抢购成功后,不应直接在抢购接口中执行耗时的订单创建、发送MQ等操作,这会拖慢接口响应,增加锁持有时间。应将抢购成功的用户ID和商品ID写入消息队列(如RocketMQ、Kafka),由消费者异步处理后续业务,并最终将库存变更同步到数据库,保证数据的最终一致性。

通过结合数据库乐观锁、Redis分布式锁、Lua脚本原子操作以及异步化处理,可以构建一个高性能、高可靠的外卖试吃活动抢购系统,从容应对高并发挑战。

本文著作权归 俱美开放平台 ,转载请注明出处!

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐