Giải pháp chống trùng lặp dữ liệu (duplicate request) mà mình muốn nói đến ở đây, chính là việc một user khi thao tác với một nguồn cấp API hoặc bất kì nguồn dữ liệu nào, trên thực tế họ chỉ thao tác 1 lần, tuy nhiên vì lý do lỗi bất kì nào đó như: do người dùng cố tình, hoặc có thể do hacker, nhằm mục đích gây ra sai lệch dữ liệu hệ thống.

Để ngăn chặn điều này xảy ra chúng ta cần phải xây dựng giải pháp chống trùng lặp, ở trong phạm vi bài viết này mình triển khai việc chống trùng dựa trên Redis.

Ý tưởng thực hiện lần lượt sẽ là như thế này:

  1. Lấy một số trường dữ liệu trong Request Body mà người dùng gửi lên, mục đích là để tạo key redis, việc sử dụng trường nào là tuỳ vào mong muốn kinh doanh, cũng như là kiến trúc hệ thống đang đáp ứng được như nào để cân nhắc lựa chọn.
  2. Build key theo một format nào đó tuỳ chọn, sau đó hash lại theo MD5 (việc sử dụng MD5 ở đây là optional, có hoặc không tuỳ vào nhu cầu, nếu sử dụng thì cân nhắc sử dụng Fast MD5 để tốc độ nhanh hơn)
  3. Mỗi lần người dùng call api thì sẽ kiểm tra key trong Redis, nếu tồn tại trả lỗi trùng lặp dữ liệu, nếu không thì tiếp tục xử lý logic tiếp, khi chèn key vào Redis thì cần phải cấu hình một thời gian Expired Time, trong khuôn khổ bài viết mình để khoảng 40 giây để dễ Demo

Ý tưởng là như vậy còn triển khai thực tế sẽ cần thêm một vài kĩ thuật nữa, tuy nhiên mình sẽ đề cập sau, chúng ta đi vào dựng project cũng như chạy thử trước

Cấu trúc dự án

struct project

Trong dự án này mình triển khai dùng Spring Boot 3.3.4, Java 17, Spring AOP

Sau đây sẽ là triển khai code chi tiết của từng phần

package com.cafeincode.demo.aop;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface PreventDuplicateValidator {

    String[] includeFieldKeys() default {};

    String[] optionalValues() default {};

    long expireTime() default 10_000L;

}

PreventDuplicateValidator mình khai báo nó là một annotation, ở đây có ba trường dữ liệu:

includeFieldKeys để khai báo danh sách những trường mà cần để tạo key dựa trên các trường trong Request Body

optionalValues là danh sách những giá trị cần đính kèm vào với key, mình triển khai trường này mục đích để linh hoạt việc chống trùng, có thể thêm bất kì dữ liệu nào tuỳ thích

expireTime là giá trị thời gian hết hạn của key, mặc định là 10 giây

package com.cafeincode.demo.aop;
import com.cafeincode.demo.enums.ErrorCode;
import com.cafeincode.demo.exception.DuplicationException;
import com.cafeincode.demo.exception.HandleGlobalException;
import com.cafeincode.demo.utils.Utils;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Arrays;
import java.util.Collections;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.data.redis.connection.RedisStringCommands;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.types.Expiration;
import org.springframework.stereotype.Component;

/**
 * author: hungtv27
 * email: hungtvk12@gmail.com
 * blog: cafeincode.com
 */

@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class PreventDuplicateValidatorAspect {

    private final RedisTemplate redisTemplate;
    private final ObjectMapper objectMapper;

    @Around(value = "@annotation(preventDuplicateValidator)", argNames = "pjp, preventDuplicateValidator")
    public Object aroundAdvice(ProceedingJoinPoint pjp, PreventDuplicateValidator preventDuplicateValidator)
        throws Throwable {

        var includeKeys = preventDuplicateValidator.includeFieldKeys();
        var optionalValues = preventDuplicateValidator.optionalValues();
        var expiredTime = preventDuplicateValidator.expireTime();

        if (includeKeys == null || includeKeys.length == 0) {
            log.warn("[PreventDuplicateRequestAspect] ignore because includeKeys not found in annotation");
            return pjp.proceed();
        }

        //extract request body in request body
        var requestBody = Utils.extractRequestBody(pjp);
        if (requestBody == null) {
            log.warn(
                "[PreventDuplicateRequestAspect] ignore because request body object find not found in method arguments");
            return pjp.proceed();
        }

        //parse request body to map<String, Object>
        var requestBodyMap = convertJsonToMap(requestBody);

        //build key redis from: includeKeys, optionalValues, requestBodyMap
        var keyRedis = buildKeyRedisByIncludeKeys(includeKeys, optionalValues, requestBodyMap);

        //hash keyRedis to keyRedisMD5: this is Optional, should be using Fast MD5 hash to replace
        var keyRedisMD5 = Utils.hashMD5(keyRedis);

        log.info(String.format("[PreventDuplicateRequestAspect] rawKey: [%s] and generated keyRedisMD5: [%s]", keyRedis,
            keyRedisMD5));

        //handle logic check duplicate request by key in Redis
        deduplicateRequestByRedisKey(keyRedisMD5, expiredTime);

        return pjp.proceed();
    }

    private String buildKeyRedisByIncludeKeys(String[] includeKeys, String[] optionalValues, Map<String, Object> requestBodyMap) {

        var 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 deduplicateRequestByRedisKey(String key, long expiredTime) {
        var firstSet = (Boolean) redisTemplate.execute((RedisCallback<Boolean>) connection ->
            connection.set(key.getBytes(), key.getBytes(), Expiration.milliseconds(expiredTime),
                RedisStringCommands.SetOption.SET_IF_ABSENT));

        if (firstSet != null && firstSet) {
            log.info(String.format("[PreventDuplicateRequestAspect] key: %s has set successfully !!!", key));
            return;
        }
        log.warn(String.format("[PreventDuplicateRequestAspect] key: %s has already existed !!!", key));
        throw new DuplicationException(ErrorCode.ERROR_DUPLICATE.getCode(), ErrorCode.ERROR_DUPLICATE.getMessage());
    }

    public Map<String, Object> convertJsonToMap(Object jsonObject) {
        if (jsonObject == null) {
            return Collections.emptyMap();
        }
        try {
            return objectMapper.convertValue(jsonObject, new TypeReference<>() {
            });
        } catch (Exception ignored) {
            return Collections.emptyMap();
        }
    }

}

Ở đây PreventDuplicateValidatorAspect là một advice, triển khai logic cho Annotation PreventDuplicateValidator, mình dùng Around Advice cho linh hoạt

Việc triển khai logic trong đoạn code trên mình mô tả lần lượt thứ tự các bước như bên dưới:

  1. Trước tiên sẽ cần extract request body từ API
  2. Parse request body sang dạng Map<K, V>
  3. Build raw key từ những trường dữ liệu đã định nghĩa
  4. Build MD5 key
  5. Check duplicate requests by key
  6. Nếu đã tồn tại key trong Redis thì throw Exception
  7. Nếu chưa tồn tại key trong Redis thì chèn key vào Redis, đồng thời bổ sung tham số expired time, sau đó tiếp tục logic của hàm chính thông qua pjp.proceed()
package com.cafeincode.demo.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;

@Configuration
public class BeanConfig {

    @Value("${redis.host}")
    private String redisHost;

    @Value("${redis.port}")
    private int redisPort;

    @Bean(name = "objectMapper")
    @Primary
    public ObjectMapper objectMapper() {
        ObjectMapper mapper = new ObjectMapper();
        mapper.registerModule(new JavaTimeModule());
        return mapper;
    }

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        var config = new RedisStandaloneConfiguration(redisHost, redisPort);
        return new LettuceConnectionFactory(config);
    }

    @Bean
    @Primary
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        var template = new RedisTemplate<>();
        template.setConnectionFactory(redisConnectionFactory);
        return template;
    }

}

trong BeanConfig mình thêm cấu hình bean cho ObjectMapper và bean kết nối Redis

package com.cafeincode.demo.dto;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;

@Data
@AllArgsConstructor
@NoArgsConstructor
@SuperBuilder
public class BaseResponse<T> implements Serializable {

    public static final String OK_CODE = "200";
    public static final String OK_MESSAGE = "Successfully";
    private String code;
    private String message;
    private T data;

    public static <T> BaseResponse<T> ofSucceeded(T data) {
        BaseResponse<T> response = new BaseResponse<>();
        response.code = OK_CODE;
        response.message = OK_MESSAGE;
        response.data = data;
        return response;
    }
}

BaseResponse là class response trả kết quả về qua API, các công ty lớn cũng như hệ thống chuẩn đều định nghĩa các trường: code, message, data ở trong class này (có thể là tên khác, nhưng không quan trọng lắm)

Chúng ta có thể bổ sung thêm các trường khác tuỳ vào nhu cầu sử dụng như: meta data, request id……

package com.cafeincode.demo.dto;
import java.time.Instant;
import lombok.Data;

@Data
public class ProductDto {

    private String productId;
    private String productName;
    private String productDescription;
    private String transactionId;
    private Instant requestTime;
    private String requestId;

}
package com.cafeincode.demo.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;

@AllArgsConstructor
@Getter
public enum ErrorCode {

    ERROR_DUPLICATE("CF_275", "Duplicated data, please try again later");

    private final String code;
    private final String message;
}
package com.cafeincode.demo.exception;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import org.springframework.http.HttpStatus;

@Getter
@Setter
@AllArgsConstructor
@Builder
public class DuplicationException extends RuntimeException {

    private String code;
    private String message;
    private HttpStatus httpStatus;

    public DuplicationException(String code, String message) {
        this.code = code;
        this.message = message;
        httpStatus = HttpStatus.BAD_REQUEST;
    }

}
package com.cafeincode.demo.exception;
import java.util.HashMap;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

@ControllerAdvice
public class HandleGlobalException extends ResponseEntityExceptionHandler {

    @ExceptionHandler(DuplicationException.class)
    private ResponseEntity<?> handleError(Exception ex) {

        //TODO: you should custom more here

        Map<String, String> body = new HashMap<>();
        body.put("code", ((DuplicationException) ex).getCode());
        body.put("message", ex.getMessage());
        return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST);
    }
}

ở class HandleGlobalException này mình sẽ handle DuplicationException được bắn ra trong logic xử lý của PreventDuplicateValidatorAspect

package com.cafeincode.demo.service;
import com.cafeincode.demo.dto.ProductDto;

public interface IProductService {

    ProductDto createProduct(ProductDto dto);

}

Viết một interface mẫu cho phần tạo product

package com.cafeincode.demo.service;
import com.cafeincode.demo.dto.ProductDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Component
@Slf4j
@RequiredArgsConstructor
public class ProductService implements IProductService {

    @Override
    public ProductDto createProduct(ProductDto dto) {
        //TODO: more logic here
        return null;
    }

}

Class Implement thì không có logic gì xử lý trong này, các bạn có thể bổ sung nếu cần, mình chỉ cần trả về null là được phục vụ cho việc demo là chính.

package com.cafeincode.demo.utils;
import jakarta.xml.bind.DatatypeConverter;
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.security.MessageDigest;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.web.bind.annotation.RequestBody;

@Slf4j
public class Utils {

    private Utils() {
    }

    public static Object extractRequestBody(ProceedingJoinPoint pjp) {
        try {
            for (int i = 0; i < pjp.getArgs().length; i++) {
                Object arg = pjp.getArgs()[i];
                if (arg != null && isAnnotatedWithRequestBody(pjp, i)) {
                    return arg;
                }
            }
        } catch (Exception ex) {
            log.error("", ex);
        }
        return null;
    }

    private static boolean isAnnotatedWithRequestBody(ProceedingJoinPoint pjp, int paramIndex) {
        var method = getMethod(pjp);
        var parameterAnnotations = method.getParameterAnnotations();
        for (Annotation annotation : parameterAnnotations[paramIndex]) {
            if (RequestBody.class.isAssignableFrom(annotation.annotationType())) {
                return true;
            }
        }
        return false;
    }

    private static Method getMethod(ProceedingJoinPoint pjp) {
        MethodSignature methodSignature = (MethodSignature) pjp.getSignature();
        return methodSignature.getMethod();
    }

    public static String hashMD5(String source) {
        String res = null;
        try {
            var messageDigest = MessageDigest.getInstance("MD5");
            var mdBytes = messageDigest.digest(source.getBytes());
            res = DatatypeConverter.printHexBinary(mdBytes);
        } catch (Exception e) {
            log.error("", e);
        }
        return res;
    }
}

Class Utils bao gồm những hàm logic extract request body từ ProceedingJoinPoint, hàm hash MD5

redis:
  host: localhost
  port: 6379
spring:
  application:
    name: product-service
server:
  port: 8888

cấu hình file config application-local.yml

version: "3.2"
services:
  redis:
    container_name: demo-service-redis
    image: redis:6.2.5
    ports:
      - '6379:6379'
package com.cafeincode.demo.controller;
import com.cafeincode.demo.aop.PreventDuplicateValidator;
import com.cafeincode.demo.dto.BaseResponse;
import com.cafeincode.demo.dto.ProductDto;
import com.cafeincode.demo.service.ProductService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@Slf4j
@RequestMapping("/products")
@RequiredArgsConstructor
public class ProductController {

    private final ProductService productService;

    @PostMapping
    @PreventDuplicateValidator(
        includeFieldKeys = {"productId", "transactionId"},
        optionalValues = {"CAFEINCODE"},
        expireTime = 40_000L)
    public BaseResponse<?> createProduct(@RequestBody ProductDto request) {
        return BaseResponse.ofSucceeded(productService.createProduct(request));
    }

}

Ở phần Controller chính này mình khai báo sử dụng Annotation PreventDuplicateValidator với những giá thị tham số như trên:

  1. includeFieldKeys: đánh dấu sẽ lấy hai trường productIdtransactionId trong Request Body để làm đầu vào tạo key
  2. optionalValues: giá trị option, mình khai ở đây là CAFEINCODE
  3. expireTime: thời gian tồn tại của dữ liệu trong Redis cache, mình để 40 giây

Oke, giờ chúng ta chạy dự án lên và kiểm tra thôi:

Với máy MacOS và Windows thì cần bật Docker Desktop lên trước, sau đó chạy lệnh docker-compose up -d trong Terminal

Với máy Ubuntu thì cần cài trước docker, sau đó chạy lệnh trên

Còn mình dùng Macbook và đã bật sẵn rồi nên chỉ cần bật lên để dùng

redis docker
test connection redis

Kiểm tra xem kết nối tới Redis đã oke hay chưa, sau khi đã oke thì khởi chạy ứng dụng

config profile local, jdk
start application spring boot

Các bạn mở Postman lên để test thôi, request body mình để ở dưới cho các bạn dễ copy thực hành

{
    "productId": "hungtv27-test-001",
    "productName": "CAFEINCODE",
    "productDescription": "Threat identify buy war manage little friend south really chair",
    "transactionId": "cd076846-ff28-4307-8524-3eb6e1809838",
    "requestTime": 1696069378367,
    "requestId": "{{$randomUUID}}"
}

Nhấn Send và theo dõi kết quả nào

response when first call
validate success, init key to redis

Kiểm tra log console thấy báo với key MD5 là: 6C518A2B1666005572EDFC8240A130F2 chưa tồn tại trong Redis nên sẽ được khởi tạo thành công lần đầu và đánh thời gian hết hạn là 40 giây, giờ sẽ kiểm tra dữ liệu trong Redis

MD5 key in Redis

key 6C518A2B1666005572EDFC8240A130F2 đã được khởi tạo thành công trong Redis, giờ chúng ta sẽ tiếp tục call API 1 lần nữa để kiểm tra kết quả, mong muốn là sẽ phải trả về lỗi CF_275

response when call second
log console

Xem console log báo với key 6C518A2B1666005572EDFC8240A130F2 đã tồn tại trong Redis rồi nên sẽ trả về lỗi CF_275 cho client.

Vậy là chúng ta đã hoàn thành triển khai việc ngăn chặn trùng lặp dựa trên Redis và Spring AOP, ở bài viết này có một số kết luận mà các bạn cần cân nhắc sau đây:

  1. Lựa chọn các trường tham số phù hợp trong Request Body để làm nguồn đầu vào tạo key, nên bỏ qua các trường có dạng thời gian như createTime, updateTime
  2. Đặt giá trị expireTime vừa đủ phù hợp với mong muốn kinh doanh trong dự án
  3. Cân nhắc có thể Hash MD5 hoặc không, nếu muốn tối ưu Performance có thể bỏ hoặc lựa chọn phương án dùng Fast MD5 (phạm vi bài viết này mình không dùng)

Và cuối cùng thì sau khi đã triển khai logic hoàn chỉnh xong thì việc của chúng ta chỉ cần khai báo Annotation trên những đầu Controller mà chúng ta cần sử dụng là được, việc setting các trường dữ liệu theo mình đã rất là linh động rồi nên ít khi cần phải sửa đổi gì thêm.

Các bạn nếu có phương án nào hay, tốt hơn có thể comment bên dưới giúp mình, chúng ta sẽ cùng nhau thảo luận và học hỏi lẫn nhau.

Hiện tại mình có đẩy các bài viết lên trên MediumSubstack, các bạn quan tâm có thể chia sẻ cho bạn bè, nhấn follow, subscriber hoặc đơn giản chỉ cần bấm thả tim, vỗ tay thôi là mình có động lực viết những bài viết mới rồi, mình xin cảm ơn.

Một số bài mình chia sẻ trên Medium

Các bạn có thể xem thêm các bài viết nổi bật ở bên dưới: