Soluciones de problemas idempotentes

1. ¿Qué es la idempotencia?

En matemáticas, idempotencia significa que los resultados de múltiples operaciones son consistentes. Según el software de trabajo real o el entorno de red, el resultado de la misma operación es el mismo sin importar cuántas veces la opere.
En el proceso de programación, veremos que naturalmente existe cierta idempotencia, como por ejemplo:

  1. seleccionar operación de consulta
  2. En la operación de eliminación, elimine según un determinado valor clave.
  3. actualizar actualiza un valor de campo

2. ¿Por qué ocurre el problema de la idempotencia?

La razón por la que ocurren problemas idempotentes no es más que clics repetidos o reenvíos de red, por ejemplo:
1) Haga clic en el botón enviar dos veces
2) Haga clic en el botón Actualizar mientras la operación está en curso
3) Repita la operación anterior después de regresar al navegador , lo que resulta en duplicación Envíe el formulario
4) Reenvío de Nginx
5) Intente reenviar en un entorno RPC distribuido
6) Consumo repetido de mensajes Cuando se utiliza el middleware de mensajes MQ, el error del middleware de mensajes no se envía a tiempo, lo que resulta en un consumo repetido.

3. Garantizar soluciones idempotentes

Para garantizar la idempotencia, existen principalmente los siguientes métodos:

1) Implementación del identificador antiduplicación (token token)

Este método consiste en que la persona que llama primero solicita una ID global (Token) del backend cuando llama a la interfaz y lleva la ID global con la solicitud. El backend necesita usar este Token como clave y la información del usuario como valor para Redis para contenido clave-valor. Verifique si la clave existe y el valor coincide, ejecute el comando de eliminación y luego ejecute la lógica empresarial posterior. Si la clave correspondiente no existe o el valor no coincide, se devolverá un mensaje de error de ejecución.
El proceso de uso se muestra en la siguiente figura:

①El servidor proporciona una interfaz para obtener el Token, que puede ser un número de serie, una ID distribuida o un UUID. El cliente llama a la interfaz para obtener el Token y el servidor generará una cadena de Token.
② Guarde esta cadena de token en Redis y use el token como clave de Redis (es necesario establecer el tiempo de vencimiento).
③Devuelva el token al cliente y el cliente lo almacenará en el campo oculto del formulario después de recibirlo.
④Cuando el cliente ejecuta y envía el formulario, trae el Token en el encabezado.
⑤El servidor recibe la solicitud, obtiene el token del encabezado y luego busca en Redis para ver si existe la clave correspondiente. Si existe, elimine la clave. Si no existe, se generará una excepción de envío duplicado. Cabe señalar aquí que las operaciones de búsqueda y eliminación deben garantizar la atomicidad; de lo contrario, es posible que no se garantice la idempotencia en situaciones concurrentes. En cuanto a la atomicidad, las operaciones de consulta y eliminación se pueden cerrar mediante bloqueos distribuidos o scripts Lua.
⑥ Devuelve el resultado, ejecuta la lógica empresarial normal o genera un mensaje de error.

Este método se puede aplicar para insertar, actualizar y eliminar operaciones. La limitación es que se debe generar una cadena de token única a nivel mundial y se debe utilizar Redis para la verificación de datos.
Aquí echamos un vistazo detallado a su método de implementación:
la implementación de pom
presenta springboot, Redis, lombok y otras dependencias relacionadas.

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <version>2.3.5.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
            <version>2.2.2.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
            <version>2.6.1</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.16</version>
        </dependency>
    </dependencies>

La aplicación implementa
un archivo de configuración de parámetros relacionados con la conexión Redis.

spring:
  redis:
    ssl: false
    host: 127.0.0.1
    port: 6379
    database: 0
    timeout: 1000
    password:
    lettuce:
      pool:
        max-active: 100
        max-wait: -1
        min-idle: 0
        max-idle: 20

Crear clase de herramienta Token de verificación de token

@Slf4j
@Service
public class TokenUtilService {

    @Autowired
    private StringRedisTemplate redisTemplate;

    /*
    * 存入Redis的Token的前缀
    */
    private static final String IDEMPOTENT_TOKEN_PREFIX = "idempotent_token:";
    
    
    public String generateToken(String value) {
        String token = UUID.randomUUID().toString();
        //设置存入Redis的key
        String key = IDEMPOTENT_TOKEN_PREFIX + token;
        //存储Token到Redis并设置过期时间为5分钟
        redisTemplate.opsForValue().set(key, value, 5, TimeUnit.MiNUTES);
        
        return token;
    }
    
    public boolean validToken(String token, String value) {
        //设置Lus脚本,KEYS[1]是key,KEYS[2]是value
        String script = "if redis.call('get', KEYS[1]) == KEYS[2] then return redis.call('del', KEYS[1]) else return 0 end";
        RedisScript<Long> redisScript= new DefaultRedisScript<>(script, Long.class);
        
        String key = IDEMPOTENT_TOKEN_PREFIX + token;
        //执行Lua脚本
        Long result = redisTemplate.execute(redisScript, Arrays.asList(key, value));
        //根据返回结果判断是否成功匹配并删除,结果不为空或0,验证通过
        if (result != null && result != 0L) {
            log.info("验证 token={}, key={}, value={}, 成功", token, key, value);
            return true;
        }
        log.info("验证 token={}, key={}, value={}, 失败", token, key, value);
        return false;
    }

}

Clase de prueba (simulación de capa de controlador)

@Slf4j
@RestController
public class TokenContoller {
    
    @Autowired
    private TokenUtilService tokenService;
    
    /*
    * 获取Token接口,返回Token串
    */
    @GetMapping("/token")
    public String getToken() {
        //模拟数据,使用token验证是否存在对应的key
        String userInfo = "myInfo";
        
        return tokenService.generateToken(userInfo);
    }
    
    /*
    * 幂等性测试接口
    */
    @PostMapping("/test")
    public String test(@RequestHeader(value = "token") String token) {
        String userInfo = "myInfo";
        boolean result = tokenService.validToken(tolen, userInfo);
        return result ? "正常调用":"重复调用";
    }
}

Finalmente, existe una versión mejorada de esta solución, que consiste en introducir la biblioteca relacional y utilizar las características de transacción de la biblioteca relacional para garantizar la atomicidad de la operación, es decir, insertar los datos procesados ​​en la biblioteca relacional y finalmente insertar la clave idempotente en Redis. , que aún puede garantizar la idempotencia en condiciones concurrentes.

2) Implementación de transmisión de número de serie único en sentido descendente

Cada solicitud al servidor va acompañada de un número de secuencia único y no repetitivo de corto plazo. Este número de secuencia generalmente se genera en sentido descendente. Al llamar a la interfaz del servidor ascendente, se agregan el número de secuencia y la identificación utilizados para la autenticación. El servidor ascendente combina este número de serie con el ID de autenticación descendente para formar una clave utilizada para operar Redis y luego consulta a Redis para ver si existe la clave correspondiente. Si existe, significa que la solicitud de número de secuencia descendente se ha procesado y el mensaje de error de la solicitud repetida se devuelve directamente; si no existe, esta clave se usa como clave de Redis y la información de la clave descendente se usa como el valor almacenado y la clave es Los pares de valores se almacenan en Redis y luego se ejecuta la lógica empresarial normal.
El proceso utilizado se muestra a continuación:

Cabe señalar que al insertar datos en Redis, se debe establecer el tiempo de vencimiento. Esto garantiza que se puedan identificar llamadas repetidas a la interfaz dentro del rango de tiempo; de lo contrario, se puede almacenar una cantidad ilimitada de datos en Redis.
Este método es adecuado para operaciones de inserción, actualización y eliminación, a costa de requerir que un tercero pase un número de secuencia único y usar Redis para la verificación de datos.

3) Implementado con la ayuda de la clave primaria de la base de datos.

Aquí se utiliza la característica de restricción de la clave principal única de la base de datos. Este método es adecuado para la idempotencia durante la inserción y puede garantizar que un valor de tabla almacene un registro con la clave principal. La clave principal utilizada aquí generalmente se refiere a la ID distribuida, de modo que Garantice la unicidad global de la identificación en un entorno distribuido.
El proceso de uso se muestra en la siguiente figura:

①El cliente ejecuta la solicitud de creación y llama a la interfaz del servidor.
② El servidor ejecuta la lógica empresarial y genera una ID distribuida, utilizando la ID como clave principal de los datos insertados para realizar la operación de inserción. El algoritmo de generación de ID aquí puede usar el algoritmo de copo de nieve, o usar el modo de segmento de número de base de datos o el Método de incremento automático de Redis para generar una ID única de fórmula de distribución.
③El servidor realiza la inserción en la base de datos, si la inserción es exitosa, significa que la interfaz no se llama repetidamente. Si se produce una excepción de duplicación de clave principal, se devuelve un mensaje de error al cliente.

Este método es adecuado para operaciones de inserción y eliminación, con la limitación de que es necesario generar una clave primaria.

4) Utilice el bloqueo optimista de la base de datos

El bloqueo optimista de la base de datos se usa generalmente para operaciones de actualización agregando un campo de identificación de versión a la tabla de base de datos correspondiente, de modo que el valor de identificación de versión se verifique para cada actualización.
Su proceso de uso es muy simple como se muestra a continuación:
Lo único a lo que se debe prestar atención es que hay una condición más para juzgar la versión actual al ejecutar la declaración de actualización, por ejemplo:
actualizar my_table establecer precio = precio + 50, versión=versión+1 donde id = 3 y versión = 5;
de esta manera, la versión cambiará cada vez que se ejecute, si se ejecuta repetidamente, el número de versión original no tendrá efecto, asegurando la idempotencia.
Este método solo se puede utilizar para operaciones de actualización y también requiere agregar un campo adicional a la tabla de base de datos correspondiente.
Finalmente, resumimos los cuatro métodos de backend comúnmente utilizados para abordar problemas de idempotencia, de la siguiente manera:

Además de los métodos principales mencionados anteriormente, existen otros métodos que también se pueden utilizar:

5) Con la ayuda del bloqueo local.

Se utilizan el método putIfAbsent del contenedor concurrente ConcurrentHashMap y la tarea de sincronización ScheduledThreadPoolExecutor. También puede usar el mecanismo de caché de guayaba. También es posible tener un tiempo efectivo almacenado en caché en guayaba. La clave se genera a través de Content-MD5. Content-MD5 es único dentro de un cierto rango, se puede considerar aproximadamente único cuando se usa y se puede usar como clave en un entorno de baja concurrencia.
Por supuesto, los bloqueos locales solo son aplicables a aplicaciones implementadas en una sola máquina, echemos un vistazo a su implementación simple:
Anotaciones de configuración:

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 Resubmit {
    /*
     * 延时时间,在延时多久后可以再次提交,单位为秒
     * */
    int delaySeconds() default 20;
}

Crear una instancia del bloqueo:

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() {
    }

    /*
     * 静态内部类的单例模式
     * */
    private static class SingletonInstance {
        private static final ResubmitLock Instance = new ResubmitLock();
    }

    public static ResubmitLock getInstance() {
        return SingletonInstance.Instance;
    }

    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);
        }
    }
}

Aspectos del POA:

import java.lang.reflect.Method;

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

    @Around("@annotation(com.cn.xxx.common.annotation.Resubmit)")
    public Object handleResubmit(ProceedingJoinPoint joinPoint) {
        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
        //获取注解信息
        Resubmit annotation = method.getAnnotation(Resubmit.class);
        int delaySeconds = annotation.delaySeconds();
        Object[] pointArgs = joinPoint.getArgs();
        String key = "";
        //获取第一个参数
        Object firstParam = pointArgs[0];
        if (firstParam instanceof RequestDTO) {
            //解析参数
            JSONObject requestDTO = JSONObject.parseObject(firstParam.toString());
            JSONObject data = JSONObject.parseObject(requestDTO.getString(DATA));

            if (data != null) {
                StringBuffer sb = new StringBuffer();
                data.forEach((k, v) -> {
                    sb.apperd(v);
                });
                key = ResubmitLock.handleKey(sb.toString());
            }
        }

        boolean lock = false;
        try {
            //设置解锁key
            lock = ResubmitLock.getInstance().lock(key, PRESENT);
            if (lock) {
                //放行
                return joinPoint.proceed();
            } else {
                //响应重复提交异常
                return new ResponseDTO<>(RespoinseCode.REPEAT_SUBMIT_OPERATION_EXCEPTION);
            }
        } finally {
            //设置解锁key和解锁时间
            ResubmitLock.getInstance().unLock(lock, key, delaySeconds);
        }
    }
}

Notas de uso:

public class ResponseToSavaPosts {

    @ApiOperation(value = "保存我的帖子接口", notes = "保存我的帖子接口")
    @PostMapping("/posts/save")
    @Resubmit(delaySeconds = 10)
    public void ResponseToSava(@RequestBody @Validated RequestDTOrequestDto) {
        return bbsPostsBizService.saveBbsPosts(requestDto);
    }
}
6) Con la ayuda del bloqueo Redis distribuido

Cualquiera que esté familiarizado con Redis sabe que es seguro para subprocesos. Podemos implementar fácilmente un bloqueo distribuido usando sus características, como opsForValue().setIfAbsent(key). Su función es almacenar en caché y regresar si no hay una clave actual en el cache.true, después del almacenamiento en caché, establece un tiempo de vencimiento para la clave para evitar que el bloqueo se libere debido a fallas del sistema y cause un punto muerto. Podemos pensar que cuando se devuelve verdadero, ha obtenido el bloqueo. Cuando el bloqueo no es liberado, realizamos una excepción.

7) Utilice el bloqueo pesimista de la base de datos

Use select... para actualizar, que tiene el mismo principio que sincronizado: primero se bloquea, luego verifica y luego realiza la operación de actualización o inserción. El problema con esto es considerar cómo evitar el punto muerto y la eficiencia es relativamente pobre. Este método se puede utilizar cuando la concurrencia de una sola aplicación es pequeña.

8) Garantía de la página de inicio

Por lo general, después del envío, el botón de envío se configura para prohibir hacer clic (generalmente se establece un período de tiempo fijo).

9) Utilice el modo Publicar/Redireccionar/Obtener

Este método consiste en realizar la redirección de la página después del envío, modo PRG (Post-Redirect-Get).
Es decir, después de que el usuario envía el formulario, se realiza una redirección del lado del cliente a la página de información del envío exitoso. Esto puede evitar envíos repetidos causados ​​por actualizaciones de página, y no habrá advertencias sobre envíos repetidos de formularios del navegador, y también puede eliminar problemas causados ​​al presionar los botones de avance y retroceso del navegador.

Supongo que te gusta

Origin blog.csdn.net/baidu_38493460/article/details/132619338
Recomendado
Clasificación