Điều này có thể khiến người dùng nhấp vào nút quá nhanh. Hoặc họ gửi cùng một biểu mẫu nhiều lần. Hoặc họ liên tục thử lại khi phản hồi API chậm.
Những vấn đề này đều dẫn đến việc gửi vòng lặp dữ liệu. Việc này không ảnh hưởng chỉ đến trải nghiệm của người dùng. Nó vẫn có thể gây ra các vấn đề nghiêm trọng. Ví dụ như lỗi dữ liệu và ác chiến logic nghiệp vụ.
Bài viết này sẽ xem xét hệ thống các giải pháp chính để ngăn chặn việc gửi dữ liệu trùng lặp. Giải pháp này được áp dụng cho cả người dùng giao diện (frontend) và phía máy chủ (backend).
I. Tại sao cần ngăn chặn việc gửi dữ liệu trùng lặp?
Khi người dùng gửi một mẫu, họ có thể nhấp vào nút gửi nhiều lần. Lý do là mã hóa hoặc thiếu phản hồi từ trang.
Nếu phần phụ trợ không được xử lý, điều này có thể dẫn đến:
- Tạo vòng lặp hàng đơn.
- Trừ tiền thanh toán nhiều lần.
- Sử dụng nhiều lần một cơ sở quay số.
- Chèn các bản ghi trùng lặp vào cơ sở dữ liệu.
Nguyên tắc cốt lõi: Frontend có thể ngăn chặn các thao tác vô ý. Nhưng backend là tuyến phòng thủ cuối cùng.
II. Chống lặp lặp gửi ở Frontend (Tuyến phòng thủ đầu tiên)
Giải pháp giao diện người dùng không thể thay thế xác thực ở phần phụ trợ. Tuy nhiên, chúng tôi có thể cải thiện đáng kể trải nghiệm của người dùng.
1. Vô hiệu nút
Vô hiệu hóa nút ngay lập tức sau khi gửi. Điều này ngăn chặn các cú nhấp chuột tiếp theo.
<button id="submitBtn" onclick="submitForm()">Submit</button>
function submitForm() {
const btn = document.getElementById("submitBtn");
if (btn.disabled) return;
btn.disabled = true;
btn.innerText = "Đang gửi...";
// Gửi yêu cầu
fetch("/order/submit", {
method: "POST",
body: new FormData(document.getElementById("orderForm"))
}).then(res => {
// Xử lý thành công
}).finally(() => {
btn.disabled = false;
btn.innerText = "Gửi";
});
}
2. Nút chống nhiễu
function debounce(func, wait) {
let timeout;
return function () {
clearTimeout(timeout);
timeout = setTimeout(() => {
func.apply(this, arguments);
}, wait);
};
}
// Ví dụ sử dụng
const submitForm = debounce(function() {
// Logic gửi
}, 1000);
3. yêu cầu
const pendingRequests = new Map();
axios.interceptors.request.use(config => {
const requestKey = `${config.url}/${JSON.stringify(config.data)}`;
if (pendingRequests.has(requestKey)) {
return Promise.reject(new Error('Vui lòng không gửi liên tục'));
}
pendingRequests.set(requestKey, true);
return config;
});
axios.interceptors.response.use(response => {
const requestKey = `${response.config.url}/${JSON.stringify(response.config.data)}`;
pendingRequests.delete(requestKey);
return response;
});
Ưu điểm của giao diện giải pháp: Mang lại trải nghiệm tốt cho người dùng. Hãy chặn các cú nhấp chuột vô ý.
Nhược điểm: Có thể bị vượt qua. Ví dụ: sử dụng cách sửa đổi JavaScript bằng công cụ dành riêng cho nhà phát triển F12. Hoặc bằng cách phát lại yêu cầu với Postman.
III. Chống lặp lặp gửi ở phần cuối (Tuyến phòng thủ cốt lõi)
Phương án 1: Token cơ chế
Đây là giải pháp cổ điển nhất. Nó thường được sử dụng để gửi biểu mẫu, thanh toán và các vấn đề tương tự.
1. Luồng xử lý
Khi người dùng truy cập biểu mẫu trang, máy chủ sẽ tạo một mã thông báo duy nhất. Mã thông báo này được lưu trữ trong Phiên (hoặc Redis). Sau đó, nó được trả về frontend.
Giao diện người dùng bao gồm mã thông báo này khi gửi biểu mẫu.
Phần cuối xác thực xem token có khớp và tồn tại hay không. Sau khi xác thực thành công, token sẽ bị xóa ngay lập tức. Điều này ngăn chặn việc sử dụng lại mã thông báo.
2. Triển khai phần lõi mã hóa
// Tạo Token
public String generateToken(HttpServletRequest request) {
String token = UUID.randomUUID().toString();
request.getSession().setAttribute("FORM_TOKEN", token);
return token;
}
// Xác thực Token
public boolean validateToken(HttpServletRequest request) {
String clientToken = request.getParameter("token");
if (clientToken == null) return false;
String serverToken = (String) request.getSession().getAttribute("FORM_TOKEN");
if (serverToken == null || !serverToken.equals(clientToken)) {
return false;
}
// ⚠️ Phải xóa sau khi xác thực để ngăn chặn việc sử dụng lại
request.getSession().removeAttribute("FORM_TOKEN");
return true;
3. Biểu mẫu Frontend
<form action="/order/submit" method="post">
<input type="hidden" name="token" value="${token}">
<button type="submit">Gửi đơn hàng</button>
</form>
Ưu điểm: Đơn giản, an toàn. Có thể ngăn chặn CSRF (khi được sử dụng với SameSite, vv).
Nhược điểm: Phụ thuộc vào Session. Trong môi trường phân tán, nó yêu cầu phiên chia sẻ hoặc sử dụng Redis để lưu trữ mã thông báo.
Phương án 2: AOP + Redis để chống gửi lặp lặp ( Khuyến khích)
Giải pháp này phù hợp cho các microservice và môi trường phát triển cụm. Nó sử dụng Redis để phát triển khóa phân tán.
1. Chú thích tùy chỉnh
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NoRepeatSubmit {
int lockTime() default 5; // Thời gian khóa tính bằng giây
}
2. Khâu khai Aspect AOP
@Aspect
@Component
@Slf4j
public class NoRepeatSubmitAspect {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Around("@annotation(noRepeatSubmit)")
public Object around(ProceedingJoinPoint pjp, NoRepeatSubmit noRepeatSubmit) throws Throwable {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
String key = buildKey(request);
int lockTime = noRepeatSubmit.lockTime();
// Cố gắng lấy khóa (SETNX + EXPIRE)
Boolean locked = redisTemplate.opsForValue().setIfAbsent(key, "1", lockTime, TimeUnit.SECONDS);
if (!locked) {
throw new RuntimeException("Thao tác quá thường xuyên, vui lòng thử lại sau");
}
try {
return pjp.proceed(); // Thực thi logic nghiệp vụ
} finally {
// Tùy chọn: Giải phóng khóa ngay sau khi thực thi (tùy thuộc vào nhu cầu nghiệp vụ)
// redisTemplate.delete(key);
}
}
private String buildKey(HttpServletRequest request) {
String userId = getCurrentUserId(request); // Lấy từ Token hoặc Session
String uri = request.getRequestURI();
String params = request.getQueryString() != null ? request.getQueryString() : "";
return "repeat:submit:" + userId + ":" + uri + ":" + DigestUtils.md5Hex(params);
}
private String getCurrentUserId(HttpServletRequest request) {
// Ví dụ: Lấy ID người dùng từ Header hoặc Session
return Optional.ofNullable(request.getHeader("X-User-Id"))
.orElse("anonymous");
}
}
3. Cách sử dụng
@PostMapping("/order/submit")
@NoRepeatSubmit(lockTime = 10)
public Result submitOrder(@RequestBody OrderDTO order) {
// Logic nghiệp vụ
return Result.success();
}
Ưu điểm: Không xâm lấn. Hỗ trợ phân tích hệ thống. Kiểm soát hoạt động của khóa thời gian.
Lưu ý:
- Tên khóa phải là duy nhất. Nên bao gồm ID người dùng, URI và tham số tóm tắt.
- Tránh xóa khóa vô ý. Sử dụng Lua command để đảm bảo xóa nguyên tử.
Phương án 3: Interceptor + Token/Redis (Phiên bản nâng cao)
Phương án này đóng gói mã thông báo xác thực logic trong một thiết bị chặn. Điều này tránh việc kiểm tra công cụ trong mỗi phương thức.
public class RepeatSubmitInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if (!(handler instanceof HandlerMethod)) return true;
HandlerMethod hm = (HandlerMethod) handler;
if (hm.getMethodAnnotation(NoRepeatSubmit.class) == null) return true;
if (isRepeatSubmit(request)) {
throw new RuntimeException("Vui lòng không gửi liên tục");
}
return true;
}
private boolean isRepeatSubmit(HttpServletRequest request) {
String key = "submit:" + getSessionId(request) + ":" + request.getRequestURI();
Boolean exists = redisTemplate.hasKey(key);
if (exists) return true;
// Đặt khóa với thời gian hết hạn là 5 giây
redisTemplate.opsForValue().set(key, "1", 5, TimeUnit.SECONDS);
return false;
}
}
Việc đăng ký Interceptor sẽ làm cho nó có hiệu lực trên toàn cầu.
IV. Tối ưu hóa trong môi trường phân tán: Redis + Lua Script để ngăn chặn xóa vô ý
Trong các kịch bản có đồng thời cao, `SETIFABSENT + EXPIRE` không phải là một thao tác nguyên tử. Điều này có thể gây ra vấn đề.
Nên sử dụng Lua command để đảm bảo nguyên tử:
-- SET key value EX seconds NX
if redis.call('set', KEYS[1], ARGV[1], 'EX', ARGV[2], 'NX') then
return 1
else
return 0
end
Gọi từ Java:
String script = "if redis.call('set', KEYS[1], ARGV[1], 'EX', ARGV[2], 'NX') then return 1 else return 0 end";
Boolean result = (Boolean) redisTemplate.execute(
new DefaultRedisScript<>(script, Boolean.class),
Arrays.asList(key), value, String.valueOf(expireTime)
);
Nâng cao hơn: Bạn có thể sử dụng Redisson. Nó phát triển các khóa tái sinh và cơ chế giám sát. Điều này ngăn chặn các khóa được giải phóng sớm nếu logic dịch vụ thực thi quá lâu.
Các khuyến nghị thực hành tốt nhất
- Kết quả Frontend và Backend: Sử dụng frontend để ngăn chặn “nhấp chuột vô ý”. Sử dụng backend để ngăn chặn “ấn công phát lại”.
- Ưu tiên cơ chế Token: Lý tưởng cho các hoạt động dựa trên biểu thức. Nó cũng giúp ngăn chặn CSRF.
- Use Redis cho hệ phân tán: Phương pháp AOP + Annotation là thanh lịch nhất.
- Đặt thời gian hết hạn hợp lý: 5–10 giây thường xuyên. Điều này tránh ảnh hưởng đến hoạt động bình thường.
- Ghi lại các lần gửi trùng lặp: Lưu giữ nhật ký các lần gửi trùng lặp. Việc này giúp giải quyết vấn đề một cách dễ dàng hơn.
- Cung cấp lời nhắc thân thiện: Không trả lời lỗi 500. Thay vào đó, hiển thị thông báo như “Xin vui lòng không gửi liên tục”.
- Lưu ý tính bất biến (Idempotency): chặn lặp lặp không giống với tính bất biến. Logic nghiệp vụ phức tạp vẫn yêu cầu thiết kế các API bất biến.
Ngăn chặn việc gửi trùng lặp dữ liệu là một cơ sở khả thi. Nó đảm bảo sự ổn định của hệ thống và tính chất tốt nhất của dữ liệu.
Chúng tôi không thể dựa vào việc người dùng không nhấp chuột sai. Thay vào đó, chúng tôi sẽ xây dựng một hệ thống phòng thủ thuật nhiều lớp. Điều này được thực hiện thông qua các biện pháp kỹ thuật.
Hãy chọn giải pháp phù hợp dựa trên kịch bản dịch vụ của bạn. Kết quả của chúng tôi nếu cần thiết. Điều này sẽ thực sự tạo ra một hệ thống không có lỗi.
Tham khảo: medium.com

Bài viết liên quan: