Say Goodbye to Duplicate Submissions: 6 Spring Boot Tricks
This article explains why duplicate form submissions cause data inconsistency in Spring Boot applications and presents six practical techniques—including disabling the submit button, loading‑state feedback, debounce, Axios request interception, token validation, and AOP interception—to reliably prevent repeated requests in both single‑node and distributed deployments.
Environment
Spring Boot 3.5.0
Front‑end duplicate‑submission prevention
1. Disable‑button method
When the form is submitted the submit button is set disabled=true to block further clicks. After the simulated API call finishes the button is re‑enabled and the form is reset.
form.addEventListener('submit', (e) => {
e.preventDefault();
submitBtn.disabled = true;
setTimeout(() => {
showMessage('表单提交成功!', 'success');
submitBtn.disabled = false;
form.reset();
}, 1500);
});2. Loading‑state method
The button receives a CSS class loading and is disabled during the request, providing visual feedback that the system is busy.
form.addEventListener('submit', (e) => {
e.preventDefault();
submitBtn.classList.add('loading');
submitBtn.disabled = true;
setTimeout(() => {
showMessage('表单提交成功!', 'success');
submitBtn.classList.remove('loading');
submitBtn.disabled = false;
form.reset();
}, 1500);
});3. Debounce method
A timer (500 ms) ensures that only the last click within the interval triggers the submission; rapid repeated clicks are ignored.
let debounceTimer;
form.addEventListener('submit', function(e) {
e.preventDefault();
clearTimeout(debounceTimer);
submitBtn.disabled = true;
debounceTimer = setTimeout(() => {
setTimeout(() => {
showMessage('表单提交成功!', 'success');
submitBtn.disabled = false;
form.reset();
}, 1500);
}, 500); // 500 ms内重复提交将被忽略
});4. Axios request interceptor
The interceptor stores a unique request key in a Map. If the same key appears again before the previous request finishes, the interceptor rejects the request and shows an error.
const pendingRequests = new Map();
function generateRequestKey(config) {
let data = config.data;
if (typeof data === 'string' || data instanceof String) {
data = JSON.parse(data);
}
return `${config.method?.toUpperCase() || 'GET'}${config.url}?${new URLSearchParams(config.params || {}).toString()}#${JSON.stringify(data || {})}`;
}
function axiosConfig() {
axios.interceptors.request.use(config => {
const requestKey = generateRequestKey(config);
if (pendingRequests.has(requestKey)) {
showMessage('错误: 请不要重复提交', 'error');
return Promise.reject(new Error('请不要重复提交'));
}
pendingRequests.set(requestKey, true);
return config;
});
axios.interceptors.response.use(response => {
const requestKey = generateRequestKey(response.config);
pendingRequests.delete(requestKey);
return response;
});
}
axiosConfig();
form.addEventListener('submit', function(e) {
e.preventDefault();
submitBtn.classList.add('loading');
const formData = {
name: document.querySelector('#name').value,
email: document.querySelector('#email').value,
body: document.querySelector('#message').value
};
axios.post('/messages', formData)
.then(response => {
if (response.status == 200) {
showMessage('表单提交成功!', 'success');
} else {
showMessage('请求错误', 'error');
}
})
.catch(error => {
showMessage(`错误: ${error.message}`, 'error');
})
.finally(() => {
submitBtn.classList.remove('loading');
});
});Back‑end duplicate‑submission prevention
5. Token mechanism (server‑side)
A unique token is generated, stored in Redis, and returned to the client. The client includes the token in the request header x-unique-token. The interceptor validates the token and deletes it after a successful check, ensuring one‑time use.
@RestController
@RequestMapping("/gen")
public class GenerateController {
public static final String PREVENT_PREFIX_KEY = "prevent:";
private final StringRedisTemplate stringRedisTemplate;
public GenerateController(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@GetMapping("/token")
public String token() {
String token = UUID.randomUUID().toString().replace("-", "");
this.stringRedisTemplate.opsForValue().setIfAbsent(PREVENT_PREFIX_KEY + token, token, 5 * 60, TimeUnit.SECONDS);
return token;
}
}
@Component
public class PreventDuplicateInterceptor implements HandlerInterceptor {
private final StringRedisTemplate stringRedisTemplate;
private final ObjectMapper objectMapper;
public PreventDuplicateInterceptor(StringRedisTemplate stringRedisTemplate, ObjectMapper objectMapper) {
this.stringRedisTemplate = stringRedisTemplate;
this.objectMapper = objectMapper;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (handler instanceof HandlerMethod hm && hm.hasMethodAnnotation(PreventDuplicate.class)) {
String value = request.getHeader("x-unique-token");
if (value == null || value.trim().isEmpty()) {
response.setContentType("application/json;charset=utf-8");
response.getWriter().println(objectMapper.writeValueAsString(Map.of("code", -1, "message", "非法请求")));
return false;
}
String preventKey = GenerateController.PREVENT_PREFIX_KEY + value;
Boolean result = this.stringRedisTemplate.delete(preventKey);
if (Boolean.TRUE.equals(result)) {
return true;
} else {
response.setContentType("application/json;charset=utf-8");
response.getWriter().println(objectMapper.writeValueAsString(Map.of("code", -1, "message", "请不要重复提交")));
return false;
}
}
return true;
}
}6. AOP‑based duplicate‑submission protection
A custom annotation @PreventDuplicate marks methods that require protection. The aspect extracts a key from the request body (or optional values), hashes it with MD5, and uses a Redis Lua script to atomically check‑and‑set the key. If the key already exists, a DuplicationException is thrown and handled by a global exception handler.
@Target({ ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface PreventDuplicate {
String[] includeFieldKeys() default {};
String[] optionalValues() default {};
long expire() default 10_000L; // 10 seconds
String expireExpression() default "";
}
@Aspect
@Component
public class PreventDuplicateAspect implements ApplicationContextAware {
private ApplicationContext context;
private final StringRedisTemplate stringRedisTemplate;
private final ObjectMapper objectMapper;
public PreventDuplicateAspect(StringRedisTemplate stringRedisTemplate, ObjectMapper objectMapper) {
this.stringRedisTemplate = stringRedisTemplate;
this.objectMapper = objectMapper;
}
@Pointcut("@annotation(pdv)")
private void preventPc(PreventDuplicate pdv) {}
@Around("preventPc(pdv)")
public Object aroundAdvice(ProceedingJoinPoint pjp, PreventDuplicate pdv) throws Throwable {
var includeKeys = pdv.includeFieldKeys();
var optionalValues = pdv.optionalValues();
long expiredTime = pdv.expire();
if (includeKeys == null || includeKeys.length == 0) {
return pjp.proceed();
}
var requestBody = Utils.getBody(pjp);
if (requestBody == null) {
return pjp.proceed();
}
var requestBodyMap = convertJsonToMap(requestBody);
String keyRedis = buildKey(includeKeys, optionalValues, requestBodyMap);
String keyRedisMD5 = Utils.hashMD5(keyRedis);
checkRequestByKey(keyRedisMD5, expiredTime);
return pjp.proceed();
}
private String buildKey(String[] includeKeys, String[] optionalValues, Map<String, Object> requestBodyMap) {
String keyWithIncludeKey = Arrays.stream(includeKeys)
.map(requestBodyMap::get)
.filter(Objects::nonNull)
.map(Object::toString)
.collect(Collectors.joining(":"));
if (optionalValues.length > 0) {
return keyWithIncludeKey + ":" + String.join(":", optionalValues);
}
return keyWithIncludeKey;
}
public void checkRequestByKey(String key, long expired) {
String script = """
if redis.call('EXISTS', KEYS[1]) == 0 then
redis.call('SET', KEYS[1], KEYS[1])
redis.call('EXPIRE', KEYS[1], ARGV[1])
return 1
else
return 0
end
""";
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
Long ret = this.stringRedisTemplate.execute(redisScript, List.of(key), String.valueOf(TimeUnit.MILLISECONDS.toSeconds(expired)));
if (ret == 0) {
throw new DuplicationException("重复提交");
}
}
private Map<String, Object> convertJsonToMap(Object jsonObject) {
try {
return objectMapper.convertValue(jsonObject, new TypeReference<>() {});
} catch (Exception ignored) {
return Collections.emptyMap();
}
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.context = applicationContext;
}
}
@RestControllerAdvice
public class GlobalExceptionAdvice extends ResponseEntityExceptionHandler {
@ExceptionHandler(DuplicationException.class)
public Map<String, Object> handleError(DuplicationException ex) {
return Map.of("code", -1, "message", ex.getMessage());
}
}Backend message endpoint (used by the front‑end examples)
@RestController
@RequestMapping("/messages")
public class MessageController {
@PostMapping
public ResponseEntity<?> save(@RequestBody Message message) {
System.err.printf("收到留言: %s%n", message);
return ResponseEntity.ok("success");
}
}Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Spring Full-Stack Practical Cases
Full-stack Java development with Vue 2/3 front-end suite; hands-on examples and source code analysis for Spring, Spring Boot 2/3, and Spring Cloud.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
