最近在维护公司内部小型招聘ATS系统,线上频繁收到测试反馈:用户疯狂点击投递按钮,会生成多条重复投递记录。尤其是校招高峰期,大量用户集中投递岗位,不仅产生脏数据,还会导致数据库压力骤增、接口响应卡顿。
网上很多教程只讲基础CRUD,完全没覆盖招聘系统的核心痛点------投递接口的幂等性与防抖拦截。折腾了大半天,整合了前端防抖+后端幂等校验+Redis锁的完整方案,亲测有效,分享给做同类项目的小伙伴。
一、问题复盘:为什么普通接口扛不住简历投递场景?
先说说原始代码的问题,也是大部分新手写招聘系统的通病:
前端无任何防抖限制,用户连续点击投递按钮,会短时间内触发多次接口请求;后端仅做了简单的参数校验,没有幂等判断。只要请求到达接口,就会直接insert投递记录。
招聘业务有个强规则:同一个用户,同一个岗位,仅允许投递一次。重复投递不仅业务逻辑错误,还会造成HR后台数据冗余,筛选简历时出现大量重复条目,极大影响办公效率。
单纯靠数据库唯一索引可以解决重复数据问题,但会抛出异常,前端体验极差,而且高并发下数据库锁竞争会拖慢整体接口速度,治标不治本。
二、完整解决方案:前后端双层拦截
1. Vue前端:按钮防抖+投递状态锁定
核心思路:点击投递按钮后,立即禁用按钮,请求结束(成功/失败)后再解锁,杜绝人为连续点击。相比定时器防抖,状态锁定更适配表单提交场景,不会出现临界点击漏洞。
|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| javascript // 核心防抖逻辑 export default { data() { return { submitLoading: false, // 提交加载状态 jobId: '', // 岗位ID userId: '' // 当前登录用户ID } }, methods: { // 简历投递提交 async submitDelivery() { if (this.submitLoading) return this.submitLoading = true try { const res = await this.api.job.delivery({ jobId: this.jobId, userId: this.userId }) if (res.code === 200) { this.message.success('投递成功!') } } catch (err) { this.$message.error(err.msg || '投递失败,请重试') } finally { // 无论成功失败,都解锁按钮 this.submitLoading = false } } } } |
同时在页面加载时,提前调用接口校验用户是否已投递该岗位,直接隐藏投递按钮,从源头避免无效请求,优化用户体验。
2. 后端SpringBoot:Redis幂等锁兜底
前端拦截只能解决普通用户误操作,无法防止恶意刷接口、爬虫批量请求。所以后端必须做幂等兜底,这是企业级项目的必备逻辑。
设计规则:以 用户ID+岗位ID 作为唯一key,设置过期时间(适配招聘投递周期),请求进来先判断key是否存在,存在则直接返回"已投递",不存在则新增投递记录并写入缓存。
|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| java @Service public class JobDeliveryServiceImpl implements JobDeliveryService { @Autowired private RedisTemplate<String, Object> redisTemplate; // 缓存key前缀 private static final String DELIVERY_KEY = "job:delivery:"; // 缓存过期时间:30天(岗位投递有效期) private static final long EXPIRE_TIME = 30 * 24 * 60 * 60; @Override public Result delivery(Long userId, Long jobId) { // 拼接唯一key String key = DELIVERY_KEY + userId + ":" + jobId; // 幂等判断:已存在则直接返回 if (Boolean.TRUE.equals(redisTemplate.hasKey(key))) { return Result.fail("您已投递过该岗位,无需重复投递"); } // 数据库新增投递记录 JobDelivery delivery = new JobDelivery(); delivery.setUserId(userId); delivery.setJobId(jobId); delivery.setDeliveryTime(LocalDateTime.now()); save(delivery); // 写入缓存,设置过期时间 redisTemplate.opsForValue().set(key, "1", EXPIRE_TIME, TimeUnit.SECONDS); return Result.success("投递成功"); } } |
三、补充优化:高并发场景适配
校招高峰期会出现瞬时大量投递请求,单纯的幂等锁还不够稳定,我额外做了两点优化:
-
接口限流:基于Redis实现单用户每秒请求限制,防止恶意刷接口导致系统过载;
-
异步记录日志:投递日志、操作记录通过异步线程处理,不阻塞主业务流程,提升接口响应速度。
四、踩坑总结
-
招聘系统的重复投递问题,绝对不能只依赖数据库唯一索引,高并发下极易触发数据库异常,影响用户体验;
-
前后端双层拦截是最优解:前端优化交互,后端保障数据一致性;
-
缓存过期时间要贴合业务场景,不能设置永久有效,避免岗位过期后缓存残留导致无法投递。
这套方案落地后,线上再也没有出现过重复投递数据,接口稳定性提升非常明显。做招聘系统、求职平台的小伙伴可以直接照搬,有问题评论区交流~