最近在帮学弟学妹们看毕业设计,发现很多同学在做“活动发布与参与平台”这类项目时,后端设计上容易踩坑。尤其是当模拟多人同时报名一个热门活动时,常常出现超卖、重复报名、或者页面卡死的情况。今天我就结合自己之前的一个项目实践,聊聊如何用一套相对成熟的技术栈,来构建一个能应对一定并发压力的活动平台后端。

活动平台示意图

1. 背景与常见痛点分析

这类平台的业务核心很简单:发布活动 -> 用户浏览 -> 点击报名。但在技术实现上,如果不加考虑,以下几个问题会非常突出:

  1. 高并发报名(超卖问题):这是最经典的场景。假设一个活动只有100个名额,在最后一刻有1000人同时点击“报名”按钮。如果后端只是简单地“查询库存 -> 判断>0 -> 库存-1 -> 保存”,极有可能导致最终报名人数远超100人。因为多个请求可能同时查询到库存为100,都认为自己可以报名,然后都执行了减1操作。
  2. 重复提交与幂等性:用户可能因为网络延迟连续点击多次按钮,或者提交后刷新页面重复提交。如果不做处理,同一个用户就会成功报名多次,这显然不符合业务逻辑。
  3. 系统响应延迟与雪崩:在活动开始的瞬间,大量请求涌入数据库查询活动详情和库存。数据库连接池被打满,CPU飙升,导致所有请求都变慢甚至超时,进而引发服务雪崩。
  4. 事务复杂与回滚困难:报名成功往往伴随着多个操作:扣减活动库存、生成用户报名记录、可能还要发短信或站内信通知。这些操作需要放在一个事务里,但如果发短信的第三方服务很慢,就会拖累整个事务,影响数据库连接。如果发短信失败,如何优雅地回滚或补偿也是个问题。

2. 技术选型:为什么是这套组合拳?

面对上述问题,我选择了 Spring Boot + MySQL + Redis + RabbitMQ 这套组合。下面简单说说选型理由:

  • Spring Boot:没什么好说的,Java后端快速开发的“全家桶”,自动配置、内嵌容器、丰富的Starter,能让我们聚焦业务逻辑,而不是繁琐的配置。
  • MySQL:关系型数据库,负责存储核心的、需要强一致性的数据,比如用户信息、活动基础信息、最终的报名记录关系。它的ACID特性是数据准确的基石。
  • Redis:这是应对高并发的关键。我们主要用它做三件事:
    • 缓存:缓存活动详情、热门活动列表,抵挡绝大部分的查询请求,保护数据库。
    • 分布式锁:解决超卖和幂等性问题,保证在分布式环境下,同一时刻只有一个请求能执行核心的库存扣减逻辑。
    • 计数器/库存预扣:可以将活动库存提前加载到Redis中,利用Redis的原子操作(如DECR)来扣减,性能极高。
  • RabbitMQ:用于解耦和异步化。将发送通知、记录日志、数据同步等非核心、耗时的操作,通过消息队列异步处理,能极大提升主流程的响应速度,并提高系统的可扩展性和可靠性。

为什么不选别的?比如MongoDB存活动文档?对于强关系型的用户-活动报名记录,关系型数据库更直观可靠。比如用ZooKeeper做分布式锁?对于我们的场景,Redis实现更轻量、性能更好。这套组合在社区成熟度、学习资源、以及应对毕设场景的复杂度上,是一个比较平衡和实用的选择。

3. 核心实现细节拆解

3.1 基于Redis分布式锁实现幂等性控制

幂等性意味着无论用户点击多少次,最终效果和点击一次一样。我们利用分布式锁来保证同一用户对同一活动的报名操作是串行化的。

思路是:用户点击报名时,生成一个唯一的业务键,比如 lock:signup:activityId:userId。尝试在Redis中设置这个键,并设置一个较短的过期时间(如5秒)。

  • 如果设置成功(返回true),说明获取到了锁,可以执行后续的报名逻辑。
  • 如果设置失败(返回false),说明该用户针对此活动的上一个报名请求还在处理中,直接返回“请求处理中”或“请勿重复提交”。

这样,即使用户疯狂点击,也只有第一个请求能进入核心流程,后续请求都被锁挡在外面,实现了幂等。

3.2 活动库存扣减的原子操作

这是防止超卖的核心。我们不能用“查-判-减”的三步走,而要用一步到位的原子操作。有两种主流做法:

  1. 在Redis中预扣库存:活动创建时,将总库存(如100)存入Redis的一个键中(如 stock:activity:123)。用户报名时,使用Redis的 DECR 命令原子性地减少该值。如果 DECR 后的结果大于等于0,说明扣减成功;如果小于0,说明库存不足,需要再执行 INCR 加回去。
  2. 在数据库中用乐观锁:在活动表里设计一个版本号字段 version。扣减库存的SQL写成:
    UPDATE activity SET stock = stock - 1, version = version + 1 WHERE id = #{id} AND stock > 0 AND version = #{oldVersion};
    
    执行后判断影响的行数,如果为1,表示扣减成功;为0,则表示库存不足或版本冲突(被别人修改了),需要回滚或重试。

在超高并发下,第一种(Redis原子操作)性能优势巨大。我们可以采用“Redis预扣库存,异步同步到数据库”的策略来保证最终一致性。

3.3 使用@Async解耦通知逻辑

报名成功后,发送短信或站内信通知用户,这个操作不应该阻塞主流程。Spring Boot的 @Async 注解可以轻松实现方法异步执行。

@Service
public class NotificationService {
    @Async // 声明为异步方法
    public void sendSignUpSuccessNotification(Long userId, Long activityId) {
        // 模拟耗时的通知操作
        log.info("开始发送报名成功通知,用户:{}, 活动:{}", userId, activityId);
        // 调用短信服务或站内信接口...
        try {
            Thread.sleep(2000); // 模拟耗时
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        log.info("通知发送完成");
    }
}

在主业务代码中,直接调用 notificationService.sendSignUpSuccessNotification(userId, activityId); 即可,该方法会立即返回,不会等待通知发送完成。记得要在启动类上加上 @EnableAsync 注解。

4. 关键代码片段(带注释)

下面是一个简化但完整的报名接口核心逻辑:

@RestController
@RequestMapping("/api/activity")
public class ActivitySignUpController {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    @Autowired
    private ActivityService activityService;
    @Autowired
    private NotificationService notificationService;

    @PostMapping("/{activityId}/signup")
    public ResponseEntity<?> signUp(@PathVariable Long activityId,
                                     @RequestHeader Long userId) {
        // 1. 生成分布式锁的Key
        String lockKey = "lock:signup:" + activityId + ":" + userId;
        // 使用UUID作为锁的值,防止误删其他请求的锁
        String lockValue = UUID.randomUUID().toString();
        // 尝试获取锁,设置5秒过期防止死锁
        Boolean lockAcquired = redisTemplate.opsForValue()
                .setIfAbsent(lockKey, lockValue, 5, TimeUnit.SECONDS);

        if (Boolean.FALSE.equals(lockAcquired)) {
            return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
                    .body("操作过于频繁,请稍后再试");
        }

        try {
            // 2. 检查是否已报名(幂等性二次校验)
            if (activityService.hasSignedUp(activityId, userId)) {
                return ResponseEntity.badRequest().body("您已报名该活动");
            }

            // 3. 核心:原子性扣减库存(这里演示Redis预扣)
            String stockKey = "stock:activity:" + activityId;
            // DECR原子操作,返回值是扣减后的值
            Long remainingStock = redisTemplate.opsForValue().decrement(stockKey);

            if (remainingStock == null || remainingStock < 0) {
                // 库存不足,需要加回去
                redisTemplate.opsForValue().increment(stockKey);
                return ResponseEntity.badRequest().body("活动名额已满");
            }

            // 4. 创建报名记录(落数据库)
            activityService.createSignUpRecord(activityId, userId);

            // 5. 异步发送通知(解耦,提升响应速度)
            notificationService.sendSignUpSuccessNotification(userId, activityId);

            return ResponseEntity.ok("报名成功!");

        } finally {
            // 6. 释放锁(使用Lua脚本保证原子性,判断值是否还是自己的再删除)
            String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                               "return redis.call('del', KEYS[1]) " +
                               "else return 0 end";
            redisTemplate.execute(new DefaultRedisScript<>(luaScript, Long.class),
                                  Collections.singletonList(lockKey), lockValue);
        }
    }
}

分布式锁工具类(更优雅的封装):

@Component
public class RedisDistributedLock {
    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    public boolean tryLock(String key, String value, long expire, TimeUnit unit) {
        return Boolean.TRUE.equals(
            redisTemplate.opsForValue().setIfAbsent(key, value, expire, unit)
        );
    }

    public boolean unlock(String key, String value) {
        String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                           "return redis.call('del', KEYS[1]) " +
                           "else return 0 end";
        Long result = redisTemplate.execute(
            new DefaultRedisScript<>(luaScript, Long.class),
            Collections.singletonList(key),
            value
        );
        return result != null && result == 1L;
    }
}

5. 性能与安全考量

  1. 缓存击穿防护:热门活动缓存失效瞬间,大量请求穿透到数据库。解决方法:使用互斥锁(Mutex Lock),即第一个发现缓存为空的线程去加载数据库数据,其他线程等待。或者对热点数据设置永不过期,通过后台任务异步更新。
  2. 接口限流:防止恶意刷接口。可以在网关层或使用 @Aspect 注解,针对用户ID或IP,结合Redis的 INCREXPIRE 命令实现简单的计数器限流。更复杂的可以使用 Sentinel 或 Resilience4j。
  3. SQL注入防范:坚持使用 MyBatis 等框架的 #{} 参数绑定,或者 JPA 的 @Query 配合参数绑定,绝对不要用字符串拼接SQL。

6. 生产环境避坑指南

  1. Redis冷启动与空值缓存:系统刚启动时,Redis是空的。大量请求会直接打到数据库。解决方案是预热缓存,或者对于查询不到的数据,也缓存一个空值(如“NULL”)并设置一个较短过期时间,避免同一时间大量请求去查数据库。
  2. 事务与异步方法的边界@Transactional@Async 一起使用要小心。如果异步方法被同一个类内的其他方法调用(非通过代理对象),@Async 会失效。通常建议将异步方法抽到单独的Service类中。另外,异步方法中如果抛出异常,主线程是感知不到的,需要有完善的异常处理或日志记录。
  3. 分布式锁的过期时间:设置太短,业务没执行完锁就释放了,会导致并发问题。设置太长,业务异常退出后锁迟迟不释放。一个折中方案是设置一个合理的超时时间(如30秒),并启动一个“看门狗”线程,在业务执行期间定期去续期锁。
  4. 最终一致性:我们用了Redis预扣库存,再异步写数据库。要监控并处理可能出现的极端情况:Redis扣减成功,但写数据库失败。这时需要有补偿机制,比如定期对账,或者通过消息队列确保重试。

系统架构简图

结尾与思考

通过上面这套组合拳,一个能应对日常高并发场景的活动平台后端骨架就搭起来了。它解决了超卖、幂等、响应慢等核心痛点,代码结构也比较清晰。

当然,这只是一个起点。如果你想让你的毕设更出彩,可以思考这个问题:如何将这个平台改造成能支持“万人级秒杀活动”的系统?

你可以沿着这些方向去扩展:

  • 流量削峰:引入消息队列(RabbitMQ/Kafka),将瞬时海量报名请求先接入队列,后端服务匀速消费,避免服务被冲垮。
  • 库存分段:将总库存拆分到多个Redis Key或数据库行中,减少单个资源的热点竞争。
  • 静态化与CDN:将活动详情页做成静态页面,推送到CDN,进一步减少后端压力。
  • 服务降级与熔断:在极端压力下,暂时关闭非核心功能(如积分兑换、复杂推荐),保证核心报名流程可用。

毕业设计不仅是功能的堆砌,更是展示你解决复杂问题思路的舞台。希望这篇笔记能给你带来启发,动手去实现、去优化,你一定会收获满满。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐