Why Your Buttons Get Spammed with Clicks? A Spring Boot Guide to Prevent Duplicate Submissions

64 Views
No Comments

Tired of users spamming buttons and causing duplicate submissions—like 5 identical orders cluttering your database or 2 AM emergency rollbacks? This guide breaks down how to build a full anti-duplicate system with Spring Boot, from simple local locks for single servers to Redis distributed locks for clusters. Packed with practical code snippets, pitfall warnings, and idempotency tips, it’s your go-to solution to stop button-spam chaos and save developers’sleep.

Introduction: Why Do Users Keep Spamming Your Buttons?

Picture this: A user hammers the “Submit Order” button like a kid playing an arcade game—only to trigger 5 identical orders in your system. Next thing you know, customer service lines are blowing up, your database is cluttered with junk data, and you’re woken up at 2 AM to roll back the system. That’s the “horror story” of duplicate submissions.

Duplicate submissions are essentially a chemical reaction of network latency + user impatience. Preventing them isn’t just a technical need—it’s a humanitarian effort to save programmers’sleep! This article will walk you through building a “bronze-to-king” anti-duplicate system with Spring Boot, using a progressive approach from local locks to distributed locks.

Why Your Buttons Get Spammed with Clicks? A Spring Boot Guide to Prevent Duplicate Submissions

1. Basic Approach: Local Lock Annotation (Single-Server Setup)

1.1 Implement a Custom Annotation

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NoRepeatSubmit {int lockTime() default 3; // Lock lasts 3s by default; should be longer than frontend button disable time
}

1.2 Implement Lock Logic with AOP

@Aspect
@Component
public class RepeatSubmitAspect {// Use ConcurrentHashMap as local lock storage (NOTE: Fails in distributed environments!)
    private static final ConcurrentHashMap<String, Object> LOCKS = new ConcurrentHashMap<>();

    @Around("@annotation(noRepeatSubmit)")
    public Object around(ProceedingJoinPoint point, NoRepeatSubmit noRepeatSubmit) throws Throwable {HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();

        // Generate a unique lock key: User ID + API path (use more complex rules in production)
        String lockKey = getUserId(request) + "-" + request.getServletPath();

        // If lock already exists, throw an exception (custom business exceptions recommended)
        if (LOCKS.putIfAbsent(lockKey, Boolean.TRUE) != null) {throw new RuntimeException("Too fast! Slow down a bit."); // User-friendly message
        }

        try {return point.proceed(); // Execute the actual business method
        } finally {// Release lock with delay (ALWAYS use finally to ensure release!)
            Thread.sleep(noRepeatSubmit.lockTime() * 1000);
            LOCKS.remove(lockKey);
        }
    }

    // In production, parse User ID from token; simplified demo here
    private String getUserId(HttpServletRequest request) {return Optional.ofNullable(request.getHeader("Authorization"))
                .orElse("anonymous");
    }
}

1.3 Usage Example: Order Creation API

@RestController
public class OrderController {@NoRepeatSubmit(lockTime = 5) // Block duplicate calls for 5 seconds
    @PostMapping("/createOrder")
    public ResponseEntity createOrder(@RequestBody OrderDTO order) {// Actual order creation logic (usually requires transaction management)
        orderService.create(order);
        return ResponseEntity.ok("Order created successfully");
    }
}

⚠️ Rant Alert:This works for 90% of duplicate submission cases in single-server setups—but it completely breaks in clustered deployments. Why? Because each server’s is isolated, so locks on one server won’t block requests to other servers. Let’s fix that with a distributed solution!

ConcurrentHashMap

2. Advanced Approach: Redis Distributed Lock (Clustered Setup)

2.1 Add Redis Dependency

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

2.2 Distributed Lock Utility Class

@Component
public class RedisLockHelper {
    @Autowired
    private StringRedisTemplate redisTemplate;

    /**
     * Acquire lock (set expiration to prevent deadlocks)
     * @param key Lock key
     * @param expireTime Expiration time (in seconds)
     * @return True if lock is acquired successfully
     */
    public boolean lock(String key, int expireTime) {// Use setIfAbsent for atomic operations (older versions need Lua scripts for setnx + expire atomicity)
        Boolean success = redisTemplate.opsForValue()
                .setIfAbsent(key, "locked", expireTime, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success);
    }

    // Release lock (simply delete the key)
    public void unlock(String key) {redisTemplate.delete(key);
    }
}

2.3 Enhanced Distributed AOP Aspect

@Aspect
@Component
public class DistributedRepeatSubmitAspect {
    @Autowired
    private RedisLockHelper redisLockHelper;

    @Around("@annotation(noRepeatSubmit)")
    public Object around(ProceedingJoinPoint point, NoRepeatSubmit noRepeatSubmit) throws Throwable {HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();

        // Generate a unique key for distributed environments (add client IP to avoid cross-user conflicts)
        String lockKey = "repeat_submit:" + getUserId(request) + ":" +
                request.getServletPath() + ":" +
                DigestUtils.md5DigestAsHex(request.getParameterMap().toString().getBytes());

        // Try to acquire distributed lock
        if (!redisLockHelper.lock(lockKey, noRepeatSubmit.lockTime())) {throw new RuntimeException("Your operations are too frequent. Please try again later.");
        }

        try {return point.proceed();
        } finally {
            // NOTE: No manual release needed—rely on Redis auto-expiration
            // Early release may allow duplicate requests within the lock's validity period
        }
    }
}

2.4 Special Scenario: Idempotent Token Handling

// Generate token when accessing the form page
@GetMapping("/order/page")
public ResponseEntity getOrderPage() {String token = UUID.randomUUID().toString();
    redisTemplate.opsForValue().set("submit_token:" + token, "1", 10, TimeUnit.MINUTES);
    return ResponseEntity.ok().header("submit-token", token).build();}

// Validate token on submission
private boolean validSubmitToken(String token) {
    String key = "submit_token:" + token;
    Long count = redisTemplate.execute(new DefaultRedisScript<>("if redis.call('get', KEYS[1]) =='1'then" +
            "return redis.call('del', KEYS[1])" +
            "else" +
            "return 0" +
            "end",
            Long.class), Collections.singletonList(key));
    return count != null && count > 0;
}

3. Pitfall Guide (Lessons Learned the Hard Way)

🚨 Pitfall 1: Poor Lock Key Design

  • Bad Example: Using only User ID → Blocks requests for different APIs.
  • Correct Approach: Combine business type + User ID + API path + parameter fingerprint for the lock key.

🚨 Pitfall 2: Forgetting Lock Expiration

  • Horrific Consequence: If a server crashes, the lock is never released → System gradually grinds to a halt.
  • Solution: Always set an expiration time. Ensure business execution time is much shorter than the lock’s expiration.

🚨 Pitfall 3: Ignoring Frontend Anti-Duplication

  • Common Mistake: Assuming backend protection is enough.
  • Best Practice:
    • Disable the button immediately after submission (simplest and most effective frontend fix).
    • Add loading spinners to reduce user urge to reclick.
    • Use Axios interceptors in SPAs to auto-cancel duplicate requests.

🚨 Pitfall 4: Duplicate Requests from Network Timeouts

  • Scenario: The first request actually succeeds, but a network timeout makes the client retry.
  • Ultimate Fix:java 运行 @Transactional public void createOrder(OrderDTO dto) {// Check for duplicates using a unique business identifier if (orderRepository.existsByOrderNo(dto.getOrderNo())) {log.warn("Duplicate order request: {}", dto.getOrderNo()); return; // Or return the existing order result directly } // Normal order creation logic }

Conclusion & Practical Recommendations

🔑 Core Takeaways

  • Layered Defense: Frontend anti-duplication → Network-layer protection → Business-layer idempotency.
  • Key Design: Ensure the same user’s same operation is blocked within the same time window.
  • Lock Validity: Set an expiration slightly longer than the expected business execution time.

🛠 Solution Selection Guide

  • Single-Server Deployment: Local lock annotation (simple and efficient).
  • Clustered Environment: Redis distributed lock (mandatory).
  • Financial-Grade Requirements: Redis lock + database unique constraint + business idempotency check (triple insurance).

🤔 Food for ThoughtCan your anti-duplicate system handle the classic scenario: “A user spams F5 while calling support to complain about system lag”?

Final Reminder: Preventing duplicate submissions is essentially a “space-for-time” tradeoff, and implementations vary by business scenario. Always test with JMeter to simulate high-concurrency duplicate requests in staging—don’t wait for a production outage to fix it at 2 AM!

END
 0
Pa2sw0rd
Copyright Notice: Our original article was published by Pa2sw0rd on 2025-09-19, total 7609 words.
Reproduction Note: Unless otherwise specified, all articles are published by cc-4.0 protocol. Please indicate the source of reprint.
Comment(No Comments)