当前位置:首页 > 技术文章 > 正文内容

Spring Boot3单应用并发抢券逻辑:从问题到完美解决

作为互联网软件开发人员,咱们在做活动营销模块时,肯定绕不开并发抢券这个坎吧?是不是经常遇到用户抢券时出现超卖,明明库存只有 100 张,结果最后发出去 120 张的情况?还有就是并发量一上来,系统就卡得不行,用户抱怨连连,领导也跟着着急。这些问题要是解决不好,不仅影响用户体验,严重的还可能给公司带来经济损失和声誉影响。

单应用架构下并发抢券的难点

单应用架构不像分布式架构那样可以通过多节点分担压力,所有的请求都集中在一个应用实例上,这就对咱们的代码逻辑和资源调度提出了更高的要求。而且抢券场景有个特点,就是在某个时间点会有大量用户同时涌入,比如整点抢券的时候,瞬间的高并发很容易让系统 “罢工”。同时,库存的准确性是核心,一旦出现超卖,后续的退款、用户投诉等一系列问题都会接踵而至,处理起来特别麻烦。所以,在单应用架构下实现一套稳定、高效的并发抢券逻辑,是咱们开发过程中必须攻克的难题。

Spring Boot3 并发抢券逻辑实现方案

接下来,就给大家详细讲讲在 Spring Boot3 中如何实现这套并发抢券逻辑,全是干货,代码都给大家准备好了,可以直接参考着用。

(1)用乐观锁解决超卖问题

先解决超卖这个核心问题,这里咱们可以用乐观锁机制。在数据库的优惠券库存表中加一个 version 字段,每次更新库存的时候都检查版本号。

实体类 Coupon 中添加 version 字段:

@Entity
@Table(name = "coupon")
public class Coupon {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private Integer stock;
    @Version
    private Integer version;
    //  getter和setter方法省略
}

Service 层的扣减库存方法:

@Service
public class CouponService {
    @Autowired
    private CouponRepository couponRepository;

    @Transactional
    public boolean deductStock(Long couponId) {
        Coupon coupon = couponRepository.findById(couponId).orElse(null);
        if (coupon == null || coupon.getStock() <= 0) {
            return false;
        }
        coupon.setStock(coupon.getStock() - 1);
        try {
            couponRepository.save(coupon);
            return true;
        } catch (ObjectOptimisticLockingFailureException e) {
            // 乐观锁冲突,说明有其他线程已经修改了数据
            return false;
        }
    }
}

这样一来,当多个线程同时抢券时,只有一个线程能成功扣减库存,有效避免了超卖。

(2)Redis 缓存减轻数据库压力

为了提高系统的并发处理能力,咱们可以用 Redis 做缓存,减少对数据库的访问压力。在项目启动时,把优惠券的库存信息加载到 Redis 中。

配置 Redis:

@Configuration
public class RedisConfig {
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);
        Jackson2JsonRedisSerializer<Object> serializer = new Jackson2JsonRedisSerializer<>(Object.class);
        ObjectMapper mapper = new ObjectMapper();
        mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL);
        serializer.setObjectMapper(mapper);
        template.setValueSerializer(serializer);
        template.setKeySerializer(new StringRedisSerializer());
        template.afterPropertiesSet();
        return template;
    }
}

项目启动时加载库存到 Redis:

@Component
public class CouponStockInitializer implements CommandLineRunner {
    @Autowired
    private CouponRepository couponRepository;
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Override
    public void run(String... args) throws Exception {
        List<Coupon> coupons = couponRepository.findAll();
        for (Coupon coupon : coupons) {
            redisTemplate.opsForValue().set("coupon:stock:" + coupon.getId(), coupon.getStock());
        }
    }
}

抢券时先操作 Redis 再异步更新数据库:

@Service
public class GrabCouponService {
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    @Autowired
    private CouponService couponService;
    @Autowired
    private AsyncTaskService asyncTaskService;

    public boolean grabCoupon(Long couponId, Long userId) {
        // 先检查用户是否已经抢过券,避免重复抢券
        Boolean isGrabbed = redisTemplate.opsForSet().isMember("coupon:grabbed:" + couponId, userId);
        if (Boolean.TRUE.equals(isGrabbed)) {
            return false;
        }
        // 从Redis中扣减库存
        Long stock = redisTemplate.opsForValue().decrement("coupon:stock:" + couponId);
        if (stock != null && stock >= 0) {
            // 记录用户抢券信息
            redisTemplate.opsForSet().add("coupon:grabbed:" + couponId, userId);
            // 异步更新数据库
            asyncTaskService.updateCouponStock(couponId);
            return true;
        } else {
            // 库存不足,把刚才扣减的加回来
            redisTemplate.opsForValue().increment("coupon:stock:" + couponId);
            return false;
        }
    }
}

(3)启用虚拟线程提升吞吐量

咱们可以利用 Spring Boot3 的虚拟线程来提升系统吞吐量,只需要在配置文件中添加:

spring:
  threads:
    virtual:
      enabled: true

然后在 Controller 层的接口上使用 @Async 注解,配合虚拟线程池:

@RestController
@RequestMapping("/coupon")
public class CouponController {
    @Autowired
    private GrabCouponService grabCouponService;

    @PostMapping("/grab")
    public ResponseEntity<String> grabCoupon(@RequestParam Long couponId, @RequestParam Long userId) {
        boolean success = grabCouponService.grabCoupon(couponId, userId);
        if (success) {
            return ResponseEntity.ok("抢券成功");
        } else {
            return ResponseEntity.ok("抢券失败,库存不足或已抢过");
        }
    }
}

(4)配置 Tomcat 线程池

合理配置 Tomcat 线程池也很重要,在配置文件中这样设置:

server:
  tomcat:
    max-threads: 200
    min-spare-threads: 50

(5)异步处理耗时任务

对于一些耗时的操作,比如记录抢券日志、发送通知等,咱们可以用 @Async 注解异步处理:

@Service
public class AsyncTaskService {
    @Autowired
    private CouponRepository couponRepository;
    @Autowired
    private CouponLogRepository couponLogRepository;

    @Async
    public void updateCouponStock(Long couponId) {
        // 异步更新数据库库存
        couponRepository.deductStock(couponId);
        // 记录抢券日志
        CouponLog log = new CouponLog();
        log.setCouponId(couponId);
        log.setCreateTime(new Date());
        couponLogRepository.save(log);
        // 发送通知等其他耗时操作
    }
}

总结

总结一下,在 Spring Boot3 中实现单应用架构下的并发抢券逻辑,核心就是用乐观锁防止超卖,用 Redis 缓存减轻数据库压力,启用虚拟线程和合理配置线程池提升并发能力,再结合异步处理耗时任务。这套方案亲测有效,大家可以放心拿去用。

要是在实际应用中遇到什么问题,或者有更好的优化思路,欢迎在评论区分享出来,咱们一起交流学习,让咱们的代码更健壮,系统更稳定!

相关文章

Vue3开发极简入门(15.1):emits补完-结合v-model

之前代码是通过按钮触发emit,如果希望输入框里的内容在输入之后也能同步到父组件,就可以结合v-model的update事件来操作,具体如下。Son2.vue:<template>...

Vue状态管理:Pinia完整指南(状态管理vuex)

概述本文专注于Vue的状态管理。我们将深入探讨如何使用Pinia来管理Vue应用程序的状态。状态管理使用props和emit进行父子组件间的数据协作虽然方便,但在以下情况下可能不够充分,数据传递往往会...

git的几种分支模式(git分支的概念)

编写代码,是软件开发交付过程的起点,发布上线,是开发工作完成的终点。代码分支模式贯穿了开发、集成和发布的整个过程,是工程师们最亲切的小伙伴。那如何根据自身的业务特点和团队规模来选择适合的分支模式呢?本...

jenkins2.107+tomcat8+jdk1.8的安装和发布代码3种方式

jenkins2.107+tomcat8+jdk1.8的安装和发布代码3种方式如果对运维课程感兴趣,可以在b站上或csdn上搜索我的账号: 运维实战课程,可以关注我,学习更多免费的运维实战技术视频1....

HTML5学习笔记三:HTML5语法规则(html5语法详解)

1.标签要小写2.属性值可加可不加””或”3.可以省略某些标签 html body head tbody4.可以省略某些结束标签 tr td li例:显示效果:5.单标签不用加结束标签img inpu...

Web前端全套教程+视频包含JavaScript、Vue等

写在前面:web前端从入门到精通经典教程,老师精心讲,想从事编程或者数据分析行业的小伙伴点进来,只需你评论并关注私信留言“前端”。便可免费获取。WEB前端简介:WEB前端工程师,也叫Web前端开发工程...