上次生产环境出了个挺有意思的 Bug。下午三点,我接到用户反馈,订单状态在“待支付”和“已支付”之间反复横跳,甚至出现了同一笔订单被两个不同的支付流水匹配上的诡异情况。
排查日志的时候,发现时间戳几乎完全重合。两个请求在毫秒级的时间差内同时到达了服务端。而触发这一切的,正是前端界面上那个手抖的“立即提交”按钮。我的同事小李,可能也是网络有点卡,或者单纯想表达一下迫切的心情,在0.5秒内连点了两次。
这事儿看着简单,实际上是前端开发中一个非常经典且隐蔽的坑:竞态条件(Race Condition)。
很多刚入行的同学可能觉得:“不就是点快了吗?后端处理慢点不就好了。”但事实上,并发请求带来的问题远不止“处理慢”这么简单。它会导致数据不一致、重复提交、资源浪费,甚至在某些极端情况下造成严重的业务逻辑错误。
今天我们就来聊聊,如何在 AJAX 并发请求中,通过合理的锁机制和状态管理,避免这些“同事手抖”导致的灾难。
一、 先搞清楚:什么是竞态条件?
在深入代码之前,我们需要用大白话把“竞态条件”这个概念讲透。你可以把它想象成两个同事同时去抢公司最后一杯免费咖啡。
假设你的代码逻辑是这样的:
- 检查库存:还有咖啡吗?
- 如果没有,去下单买。
- 买回来之后,分给大家。
现在,小李和小王同时点击了“领取咖啡”按钮。
- 时刻 T1:小李点击。代码执行到第1步,检查库存:还有1杯。
- 时刻 T2:几乎同一瞬间,小王点击。代码也执行到第1步,检查库存:还有1杯。
注意,这时候小李的“下单”动作还没开始,库存数据只是被“读取”了,并没有被“修改”。
- 时刻 T3:小李发起 AJAX 请求,去后台下单。
- 时刻 T4:小王也发起 AJAX 请求,去后台下单。
结果呢?后台可能收到了两个下单请求,买了两杯咖啡,但库存只够一杯。或者更糟,如果后台没有做幂等性校验,可能产生了两笔订单,但用户只拿到一杯咖啡的体验,或者系统账目对不上了。
这就是典型的竞态条件:多个线程或请求同时访问并修改共享资源,而最终结果取决于这些请求执行的相对时间顺序,这种不确定性就是 Bug 的温床。
在前端 AJAX 场景下,共享资源通常是:
- 表单数据
- 用户的登录状态
- 某个商品的库存
- 当前页面的编辑内容
当多个请求同时发起,且它们都依赖于这些共享资源的状态时,竞态条件就发生了。
二、 为什么前端容易产生这个问题?
你可能会问,后端不是有更严格的控制吗?为什么前端要这么麻烦?
原因在于用户体验和网络不确定性之间的博弈。
1. 用户行为不可控
用户不是机器,他们不会严格按照“点击 -> 等待结果 -> 再点击”的流程操作。他们可能:
- 手抖,快速连点。
- 觉得网络卡,没反应,就多点几次。
- 在页面还没加载完时就进行操作。
尤其是移动端,屏幕小,误触概率更高。
2. 网络延迟的不确定性
AJAX 请求是异步的。当你点击按钮,请求发出去,你可能需要等 200ms,也可能等 2s。在这段等待时间里,用户可以继续操作界面。
如果前端没有做防护,用户就会在“等待期”内重复点击,导致多个请求并发发出。
3. 浏览器多线程模型
浏览器的 JavaScript 引擎虽然是单线程的,但网络请求是异步的,由浏览器内核的其他线程处理。当多个事件(比如多次点击)触发时,它们会创建多个独立的 AJAX 请求,这些请求在网络层是并行处理的,到达服务端的时间可能非常接近,甚至重叠。
4. 状态管理的前后端分离
前端的状态(比如表单内容)存在浏览器的内存中,后端的状态存在数据库里。如果前端在发起请求前没有锁定状态,而是直接把当前状态发出去,那么两次点击发出的状态可能是一样的,导致后端处理了两次相同的操作。
三、 常见的错误做法及其后果
在讨论解决方案之前,我们先看看大家容易踩的坑。
错误做法一:仅靠后端幂等性校验
有些团队认为,只要后端做了幂等性校验(比如用请求 ID、用户 ID + 订单号 作为唯一键),前端就不用管了。
后果:
- 用户体验差:用户点了两次,前端没任何反馈,他不知道成功了没有,可能还会再点第三次。
- 浪费服务器资源:两次请求都打到了后端,虽然第二次被拒绝了,但网络传输、数据库查询、日志记录等开销依然存在。
- 并发压力:如果大量用户同时手抖,后端会收到大量无效请求,增加负载。
错误做法二:在前端用全局变量标记,但忘记重置
let isSubmitting = false;
function handleSubmit() {
if (isSubmitting) return;
isSubmitting = true;
fetch('/api/submit', { method: 'POST', body: formData })
.then(res => res.json())
.then(data => {
// 成功
})
.catch(err => {
// 失败
});
}
后果:
- 如果请求成功,
isSubmitting没有置回false,用户下次再点击就没反应了,按钮永久禁用,需要刷新页面才能恢复。 - 如果请求失败,
isSubmitting也没有置回false,同样的问题。 - 必须在
finally块中重置,但很多人会忘记。
错误做法三:用 setTimeout 做防抖,但时间设得太短
function handleSubmit() {
if (this.isSubmitting) return;
this.isSubmitting = true;
setTimeout(() => {
fetch('/api/submit', ...).then(...);
}, 100); // 100ms 防抖
}
后果:
- 100ms 对于网络慢的情况来说太短了。用户可能还没看到加载状态,就又在 200ms 时点了第二次。
- 防抖和节流是用于事件频率控制的,不是用于防止并发请求的。两者概念不同。
错误做法四:依赖按钮的 disabled 属性,但没有处理异步状态
<button id="submitBtn" :disabled="isLoading">提交</button>
后果:
- 如果
isLoading没有及时更新,按钮可能一直可用。 - 在某些框架(如 Vue/React)中,如果状态更新不及时,可能出现点击后按钮变灰,但请求已经发出去了的情况。
- 更复杂的是,如果有多个按钮,一个按钮的加载状态可能影响其他按钮的逻辑。
四、 正确的解决方案:前端锁机制
要解决竞态条件,核心思想是:在请求发出前,锁定操作;在请求结束后,解锁操作。确保同一时间只有一个请求在进行。
这里提供几种实用的方案,从简单到复杂。
方案一:基于请求 ID 的锁(推荐用于简单表单提交)
这是最直观的方法。用一个标志位来记录当前是否有请求在进行。
class FormSubmitter {
constructor() {
this.isSubmitting = false;
this.pendingRequests = new Map(); // 用于跟踪多个请求
}
async submit(formId, data) {
// 1. 检查是否已经在提交中
if (this.isSubmitting) {
console.warn(`Form ${formId} is already submitting.`);
return { success: false, error: 'Request in progress' };
}
// 2. 设置锁
this.isSubmitting = true;
const requestId = Date.now();
this.pendingRequests.set(requestId, { startTime: Date.now(), status: 'pending' });
try {
// 3. 发起请求
const response = await fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
// 4. 更新请求状态
this.pendingRequests.get(requestId).status = 'success';
return { success: true, data: result };
} catch (error) {
// 5. 处理错误
this.pendingRequests.get(requestId).status = 'error';
this.pendingRequests.get(requestId).error = error.message;
return { success: false, error: error.message };
} finally {
// 6. 移除请求记录
this.pendingRequests.delete(requestId);
// 7. 解锁
this.isSubmitting = false;
}
}
}
// 使用示例
const submitter = new FormSubmitter();
document.getElementById('submitBtn').addEventListener('click', async () => {
const btn = document.getElementById('submitBtn');
btn.disabled = true;
btn.textContent = '提交中...';
const result = await submitter.submit('orderForm', {
orderId: '12345',
amount: 100,
userId: 'user001'
});
btn.disabled = false;
btn.textContent = '提交';
if (result.success) {
alert('提交成功!');
} else {
alert('提交失败:' + result.error);
}
});
关键点:
- 使用
async/await让代码更清晰。 - 在
finally块中解锁,确保无论成功还是失败,锁都会被释放。 - 用
Map跟踪多个请求,方便后续扩展。
方案二:基于请求优先级的锁(适用于多个相关操作)
有时候,我们不仅需要一个锁,还需要决定哪些请求应该被处理,哪些应该被忽略。
比如,用户在填写表单时,可能先点“保存草稿”,然后又点“正式提交”。这时候,“正式提交”的请求应该优先,或者“保存草稿”的请求应该被取消。
class RequestManager {
constructor() {
this.activeRequests = new Set();
this.requestCounter = 0;
}
addRequest(requestId, requestFn) {
// 如果存在同类型的请求,取消它
this.activeRequests.forEach(existingId => {
if (this.isSameType(requestId, existingId)) {
this.cancelRequest(existingId);
}
});
const id = ++this.requestCounter;
this.activeRequests.add(id);
requestFn(id).finally(() => {
this.activeRequests.delete(id);
});
return id;
}
isSameType(newId, existingId) {
// 这里可以根据业务逻辑判断是否属于同一类请求
// 比如,都是“提交订单”请求
return true;
}
cancelRequest(id) {
// 实际项目中,这里可能需要使用 AbortController
console.log(`Request ${id} cancelled.`);
}
}
// 使用 AbortController 来实际取消请求
class AbortableRequestManager extends RequestManager {
addRequest(requestId, requestFn) {
const controller = new AbortController();
const id = super.addRequest(requestId, (id) => {
return requestFn(controller);
});
// 返回一个方法,用于外部取消
return {
id,
cancel: () => controller.abort()
};
}
}
// 使用示例
const manager = new AbortableRequestManager();
document.getElementById('submitBtn').addEventListener('click', () => {
const { id, cancel } = manager.addRequest('submitOrder', (controller) => {
return fetch('/api/submit', {
method: 'POST',
body: JSON.stringify({ orderId: '12345' }),
signal: controller.signal // 绑定 AbortController
}).then(res => res.json());
});
});
关键点:
- 使用
AbortController来实际取消 HTTP 请求,而不是仅仅在逻辑上忽略。 - 这样可以节省网络带宽和服务器资源。
- 适合处理“最后一个请求优先”或“取消所有同类请求”的场景。
方案三:基于状态机的锁(适用于复杂业务流程)
如果业务流程比较复杂,比如“填写表单 -> 验证 -> 提交 -> 支付 -> 确认”,那么简单的锁可能不够用。我们需要一个状态机来管理整个流程。
class OrderStateMachine {
constructor() {
this.states = {
IDLE: 'idle',
VALIDATING: 'validating',
SUBMITTING: 'submitting',
PAYING: 'paying',
CONFIRMED: 'confirmed',
ERROR: 'error'
};
this.currentState = this.states.IDLE;
this.listeners = [];
}
transition(newState) {
const allowedTransitions = {
[this.states.IDLE]: [this.states.VALIDATING, this.states.ERROR],
[this.states.VALIDATING]: [this.states.SUBMITTING, this.states.ERROR],
[this.states.SUBMITTING]: [this.states.PAYING, this.states.ERROR],
[this.states.PAYING]: [this.states.CONFIRMED, this.states.ERROR],
[this.states.CONFIRMED]: [this.states.IDLE],
[this.states.ERROR]: [this.states.IDLE]
};
if (allowedTransitions[this.currentState]?.includes(newState)) {
this.currentState = newState;
this.notifyListeners();
} else {
console.warn(`Invalid transition from ${this.currentState} to ${newState}`);
}
}
onStateChange(callback) {
this.listeners.push(callback);
}
notifyListeners() {
this.listeners.forEach(cb => cb(this.currentState));
}
async submitOrder(orderData) {
if (this.currentState !== this.states.IDLE) {
throw new Error(`Cannot submit order in state: ${this.currentState}`);
}
this.transition(this.states.VALIDATING);
try {
// 1. 验证
await this.validateOrder(orderData);
this.transition(this.states.SUBMITTING);
// 2. 提交
const submissionResult = await this.submitToServer(orderData);
this.transition(this.states.PAYING);
// 3. 支付
const paymentResult = await this.processPayment(submissionResult);
this.transition(this.states.CONFIRMED);
return paymentResult;
} catch (error) {
this.transition(this.states.ERROR);
throw error;
}
}
async validateOrder(data) {
// 模拟验证逻辑
return new Promise(resolve => setTimeout(resolve, 500));
}
async submitToServer(data) {
// 模拟提交逻辑
return new Promise(resolve => setTimeout(resolve, 1000));
}
async processPayment(data) {
// 模拟支付逻辑
return new Promise(resolve => setTimeout(resolve, 1500));
}
}
// 使用示例
const orderMachine = new OrderStateMachine();
orderMachine.onStateChange((state) => {
const btn = document.getElementById('submitBtn');
const statusText = document.getElementById('statusText');
switch (state) {
case orderMachine.states.VALIDATING:
btn.disabled = true;
statusText.textContent = '验证中...';
break;
case orderMachine.states.SUBMITTING:
statusText.textContent = '提交中...';
break;
case orderMachine.states.PAYING:
statusText.textContent = '支付中...';
break;
case orderMachine.states.CONFIRMED:
btn.disabled = false;
statusText.textContent = '提交成功!';
break;
case orderMachine.states.ERROR:
btn.disabled = false;
statusText.textContent = '提交失败,请重试';
break;
}
});
document.getElementById('submitBtn').addEventListener('click', async () => {
try {
await orderMachine.submitOrder({
orderId: '12345',
amount: 100
});
} catch (error) {
console.error('Order submission failed:', error);
}
});
关键点:
- 状态机明确定义了每个阶段允许的状态转换。
- 在任何非
IDLE状态下,都无法发起新的请求。 - 每个状态都可以绑定 UI 更新逻辑,提供清晰的用户反馈。
- 适合复杂的多步骤业务流程。
五、 后端如何配合?
虽然前端锁机制很重要,但不能完全依赖前端。因为用户可能绕过前端,直接发请求(比如用 Postman 或浏览器控制台)。所以后端也需要有相应的防护。
1. 幂等性设计
确保同一个操作执行多次的结果和执行一次的结果相同。
实现方式:
- 数据库唯一索引:比如订单号、支付流水号设置唯一索引。
- 分布式锁:使用 Redis 的
SETNX命令,在操作前先加锁,操作完成后释放。 - Token 机制:前端发起请求前,先获取一个一次性 Token,请求时携带该 Token,后端验证后删除 Token。
”`java // 伪代码示例:使用 Redis 分布式锁 @RestController public class OrderController {
@Autowired
private StringRedisTemplate redisTemplate;
@PostMapping("/submit")
public ResponseEntity<?> submitOrder(@RequestBody OrderRequest request) {
String lockKey = "order_submit:" + request.getOrderId();
