Backend Development 15 min read

Understanding Idempotency and Preventing Duplicate Submissions in Backend Systems

This article explains the concept of idempotency, the common causes of duplicate submissions in web applications, and presents multiple backend solutions—including frontend button disabling, Post‑Redirect‑Get, session tokens, local locks with Content‑MD5, AOP aspects, and Redis distributed locks—accompanied by complete Java code examples.

Code Ape Tech Column
Code Ape Tech Column
Code Ape Tech Column
Understanding Idempotency and Preventing Duplicate Submissions in Backend Systems

Idempotency refers to operations that produce the same result no matter how many times they are executed. In database terms, select and delete are naturally idempotent, while insert is not, and update can be either idempotent or non‑idempotent depending on whether it overwrites a value or performs an accumulation.

Duplicate submissions often arise from user actions such as clicking a submit button multiple times, refreshing the page, using browser back/forward navigation, or from network retries, load balancers (e.g., nginx), and distributed RPC retries.

Several mitigation strategies are discussed:

Disable the submit button on the frontend using JavaScript components.

Apply the Post‑Redirect‑Get (PRG) pattern to avoid resubmission on page refresh.

Store a unique token in the server‑side session and embed it as a hidden field in the form; compare the token on each request.

Set appropriate cache‑control headers.

Leverage database constraints such as unique indexes and optimistic locking.

Use pessimistic locking with select … for update or synchronized blocks.

The article emphasizes a local‑lock approach suitable for single‑node deployments, which uses a ConcurrentHashMap with putIfAbsent and a scheduled task to release the lock after a delay. The lock key is generated by calculating the MD5 of the request body (Content‑MD5). The following annotation and lock implementation illustrate this method:

import java.lang.annotation.*;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Resubmit {
    /**
     * Delay time (seconds) before the same request can be submitted again.
     * @return time unit is one second
     */
    int delaySeconds() default 20;
}
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.digest.DigestUtils;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

@Slf4j
public final class ResubmitLock {
    private static final ConcurrentHashMap
LOCK_CACHE = new ConcurrentHashMap<>(200);
    private static final ScheduledThreadPoolExecutor EXECUTOR = new ScheduledThreadPoolExecutor(5, new ThreadPoolExecutor.DiscardPolicy());

    private ResubmitLock() {}

    public static ResubmitLock getInstance() { return SingletonInstance.INSTANCE; }
    private static class SingletonInstance { private static final ResubmitLock INSTANCE = new ResubmitLock(); }

    public static String handleKey(String param) {
        return DigestUtils.md5Hex(param == null ? "" : param);
    }

    public boolean lock(final String key, Object value) {
        return Objects.isNull(LOCK_CACHE.putIfAbsent(key, value));
    }

    public void unLock(final boolean lock, final String key, final int delaySeconds) {
        if (lock) {
            EXECUTOR.schedule(() -> LOCK_CACHE.remove(key), delaySeconds, TimeUnit.SECONDS);
        }
    }
}

An AOP aspect can automatically apply the lock to methods annotated with @Resubmit :

import com.alibaba.fastjson.JSONObject;
import com.cn.xxx.common.annotation.Resubmit;
import com.cn.xxx.common.annotation.impl.ResubmitLock;
import com.cn.xxx.common.dto.RequestDTO;
import com.cn.xxx.common.dto.ResponseDTO;
import com.cn.xxx.common.enums.ResponseCode;
import lombok.extern.log4j.Log4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;

@Log4j
@Aspect
@Component
public class ResubmitDataAspect {
    private static final String DATA = "data";
    private static final Object PRESENT = new Object();

    @Around("@annotation(com.cn.xxx.common.annotation.Resubmit)")
    public Object handleResubmit(ProceedingJoinPoint joinPoint) throws Throwable {
        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
        Resubmit annotation = method.getAnnotation(Resubmit.class);
        int delaySeconds = annotation.delaySeconds();
        Object[] args = joinPoint.getArgs();
        String key = "";
        Object firstParam = args[0];
        if (firstParam instanceof RequestDTO) {
            JSONObject requestDTO = JSONObject.parseObject(firstParam.toString());
            JSONObject data = JSONObject.parseObject(requestDTO.getString(DATA));
            if (data != null) {
                StringBuilder sb = new StringBuilder();
                data.forEach((k, v) -> sb.append(v));
                key = ResubmitLock.handleKey(sb.toString());
            }
        }
        boolean lock = false;
        try {
            lock = ResubmitLock.getInstance().lock(key, PRESENT);
            if (lock) {
                return joinPoint.proceed();
            } else {
                return new ResponseDTO<>(ResponseCode.REPEAT_SUBMIT_OPERATION_EXCEPTION);
            }
        } finally {
            ResubmitLock.getInstance().unLock(lock, key, delaySeconds);
        }
    }
}

For distributed environments, a Redis‑based lock can be used. The following helper class demonstrates lock acquisition, release, and delayed unlock using StringRedisTemplate :

package com.battcn.utils;

import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisStringCommands;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.types.Expiration;
import org.springframework.util.StringUtils;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;

@Configuration
@AutoConfigureAfter(RedisAutoConfiguration.class)
public class RedisLockHelper {
    private static final String DELIMITER = "|";
    private static final ScheduledExecutorService EXECUTOR_SERVICE = Executors.newScheduledThreadPool(10);
    private final StringRedisTemplate stringRedisTemplate;

    public RedisLockHelper(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    public boolean tryLock(final String lockKey, final String value, final long time, final TimeUnit unit) {
        return stringRedisTemplate.execute((RedisCallback
) connection ->
            connection.set(lockKey.getBytes(), value.getBytes(), Expiration.from(time, unit), RedisStringCommands.SetOption.SET_IF_ABSENT));
    }

    public boolean lock(String lockKey, final String uuid, long timeout, final TimeUnit unit) {
        long milliseconds = Expiration.from(timeout, unit).getExpirationTimeInMilliseconds();
        boolean success = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, System.currentTimeMillis() + milliseconds + DELIMITER + uuid);
        if (success) {
            stringRedisTemplate.expire(lockKey, timeout, TimeUnit.SECONDS);
        } else {
            String oldVal = stringRedisTemplate.opsForValue().getAndSet(lockKey, System.currentTimeMillis() + milliseconds + DELIMITER + uuid);
            String[] oldValues = oldVal.split(Pattern.quote(DELIMITER));
            if (Long.parseLong(oldValues[0]) + 1 <= System.currentTimeMillis()) {
                return true;
            }
        }
        return success;
    }

    public void unlock(String lockKey, String value) {
        unlock(lockKey, value, 0, TimeUnit.MILLISECONDS);
    }

    public void unlock(final String lockKey, final String uuid, long delayTime, TimeUnit unit) {
        if (StringUtils.isEmpty(lockKey)) return;
        if (delayTime <= 0) {
            doUnlock(lockKey, uuid);
        } else {
            EXECUTOR_SERVICE.schedule(() -> doUnlock(lockKey, uuid), delayTime, unit);
        }
    }

    private void doUnlock(final String lockKey, final String uuid) {
        String val = stringRedisTemplate.opsForValue().get(lockKey);
        if (val == null) return;
        String[] values = val.split(Pattern.quote(DELIMITER));
        if (uuid.equals(values[1])) {
            stringRedisTemplate.delete(lockKey);
        }
    }
}

By combining these techniques—client‑side safeguards, token‑based validation, local in‑memory locks, AOP interception, and Redis distributed locks—developers can effectively guarantee idempotent behavior and prevent duplicate submissions in both single‑node and clustered backend services.

BackendJavaAOPRedisDistributed LockIdempotencyduplicate submission
Code Ape Tech Column
Written by

Code Ape Tech Column

Former Ant Group P8 engineer, pure technologist, sharing full‑stack Java, job interview and career advice through a column. Site: java-family.cn

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.