buguge - Keep it simple,stupid

知识就是力量,但更重要的,是运用知识的能力why buguge?

导航

接口防重复提交的技术解决方案

分布式锁在技术层面有两种应用场景:
1. 可以保证幂等性(防重与幂等有区别:幂等通常是对并发请求的防重控制;防重除了需要分布式保证幂等以外,还需要做数据防重校验,因为重复请求可能不是并发请求过来的,有可能是隔了很长时间的重复数据提交,就是用DCL)
2. 实现进程同步(类似于线程synchronized锁):当锁存在时,需要不断尝试重试取锁,实现自旋等待。

 

无论是http接口,还是rpc接口,防重复提交(接口防重)都是绕不过的话题。

重复提交与幂等,既有区别,又有联系。幂等的意思是,对资源的一次请求与多次请求,作用是相同的。例如,HTTP的POST方法是非幂等的。如果程序处理不好,重复提交会导致非幂等,引起系统数据故障。防重复提交,当属于幂等的范畴,首先通过技术手段来实现,其次,又要有对业务数据的唯一性验证。

 

常见的B/S场景的重复提交,用户手抖或因为网络问题,服务端在极短时间内两次甚至更多次收到同样的http请求。

rpc接口的重复提交,一种是不恰当的程序调用,即程序漏洞导致重复提交。在一种,比如拿dubbo来说,因为网络传输问题,会触发重试调用。

 

防重提交的方案,常见的是加锁。分布式系统,一般是借助redis或zk等分布式锁。对于java单体应用,有网友说可以用语言本身的synchronized锁机制,严格来说,这样是不恰当的,因为synchronized是多线程下的同步锁,只会阻塞线程执行,而不会阻断线程的执行。

 

【说明几点】

  1.  lockKey的设置
  2. 加锁是为了拦截重复请求。key一定要与业务操作请求的请求数据有关,具有系统全局唯一性。通常的命名规范是业务操作前缀+业务数据,比如key="user.add."+userVo.toString(); key="withdraw."+userId。
    反例:key=“withdraw.”;。这是一棒子打死的节奏。锁的颗粒度太大了,一个用户提现操作在执行的过程中,其他用户都无法进行提现操作。对于通常的系统提现场景来说,很显然说不过去。
    反例:key="withdraw."+userId+DateUtils.format(new Date(),"yyyy-MM-dd HH:mm:ss");。因为加了时间戳,那么,非这个时刻的相同用户的提现请求就能重新上锁,从而达不到分布式锁的效果。同样的还有加UUID或雪花算法生成的唯一Id,都达不到分布式锁的控制效果。
  1. 锁的有效期
  2. 下文代码会提到,释放锁的代码在finally中执行,以保证当程序因异常中断的时候仍然能够释放锁。不过,jvm也会导致finally不被执行。加锁的时间需要按业务逻辑执行时间来评估一个保守的值。太短自然是不行的,会导致重入故障;如果太久,在没有及时释放锁时候,其他请求无法进入,就会产生死锁。
  3. 上锁的原子性
  4. 有的上锁方案是利用jedis的setnx(lockKey, requestId)和expire(lockKey, expireTime),因为是两条命令,所以不具备原子性。假如程序执行了setnx后突然崩溃,会导致锁一直存在,最终导致死锁。
  5. 关于释放锁
  6. 释放锁原则:解铃还须系铃人。即,clientA(线程A)上锁,只能由clientA(线程A)来解锁。释放锁的操作同样要具备原子性。另,如上文所述,释放锁的代码在finally代码中执行。

 

redis分布式锁的实现

类图:

 

RedisDistributedLock

package com.emax.zhenghe.rpcapi.provider.config.distributeRedisLock;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.util.Arrays;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

@Component
@Slf4j
public class RedisDistributedLock extends AbstractDistributedLock {

    @Autowired
    @Resource
    private RedisTemplate<Object, Object> redisTemplate;

    private ThreadLocal<String> lockFlag = new ThreadLocal<String>();

    public static final String UNLOCK_LUA;

    static {
        StringBuilder sb = new StringBuilder();
        sb.append("if redis.call(\"get\",KEYS[1]) == ARGV[1] ");
        sb.append("then ");
        sb.append("    return redis.call(\"del\",KEYS[1]) ");
        sb.append("else ");
        sb.append("    return 0 ");
        sb.append("end ");
        UNLOCK_LUA = sb.toString();
    }


    public RedisDistributedLock() {
        super();
    }

    @Override
    public boolean lock(String key, long expire, int retryTimes, long sleepMillis) {
        boolean result = setRedis(key, expire);
        // 如果获取锁失败,按照传入的重试次数进行重试
        while ((!result) && retryTimes-- > 0) {
            try {
                log.debug("lock failed, retrying..." + retryTimes);
                Thread.sleep(sleepMillis);
            } catch (InterruptedException e) {
                return false;
            }
            result = setRedis(key, expire);
        }
        return result;
    }

    /**
     *
     * @param key
     * @param expire MILLISECONDS
     * @return
     */
    private boolean setRedis(final String key, final long expire) {
        try {
            String uuid = UUID.randomUUID().toString();
            lockFlag.set(uuid);
            return redisTemplate.opsForValue().setIfAbsent(key,uuid,expire,TimeUnit.MILLISECONDS);
        } catch (Exception e) {
            log.info("redis lock error.", e);
        }
        return false;
    }


    @Override
    public boolean releaseLock(String key) {
        // 释放锁的时候,有可能因为持锁之后方法执行时间大于锁的有效期,此时有可能已经被另外一个线程持有锁,所以不能直接删除
        try {
            DefaultRedisScript<Boolean> defaultRedisScript = new DefaultRedisScript<Boolean>(UNLOCK_LUA,Boolean.class);
            return redisTemplate.execute(defaultRedisScript,Arrays.asList(key),lockFlag.get());
        } catch (Exception e) {
            log.error("release lock occured an exception", e);
        } finally {
            // 清除掉ThreadLocal中的数据,避免内存溢出
            lockFlag.remove();
        }
        return false;
    }
}
    

 

AbstractDistributedLock

package com.emax.zhenghe.rpcapi.provider.config.distributeRedisLock;

public abstract class AbstractDistributedLock implements DistributedLock {
 
    @Override
    public boolean lock(String key) {
        return lock(key , TIMEOUT_MILLIS, RETRY_TIMES, SLEEP_MILLIS);
    }
 
    @Override
    public boolean lock(String key, int retryTimes, long sleepMillis) {
        return lock(key, TIMEOUT_MILLIS, retryTimes, sleepMillis);
    }
 
    @Override
    public boolean lock(String key, long expire) {
        return lock(key, expire, RETRY_TIMES, SLEEP_MILLIS);
    }
 
    @Override
    public boolean lock(String key, long expire, int retryTimes) {
        return lock(key, expire, retryTimes, SLEEP_MILLIS);
    }
 
}

 

DistributedLock 

package com.emax.zhenghe.rpcapi.provider.config.distributeRedisLock;

public interface DistributedLock {
    
     long TIMEOUT_MILLIS = 30000;
    
     int RETRY_TIMES = 2;
    
     long SLEEP_MILLIS = 500;
    
     boolean lock(String key);boolean lock(String key, int retryTimes, long sleepMillis);
    
     boolean lock(String key, long expire);
    
     boolean lock(String key, long expire, int retryTimes);
    
     boolean lock(String key, long expire, int retryTimes, long sleepMillis);
    
     boolean releaseLock(String key);
}

 

调用:

    @Autowired
    private RedisDistributedLock distributedLock;

    @Test
    public void lock11() throws Exception {
        String key = "examplekey" + System.currentTimeMillis();
        try {
            boolean lock = distributedLock.lock(key, 2000L, 1, 100L);
            log.info("===================" + lock);
        } finally {
            distributedLock.releaseLock(key);
        }
    }

 

 

进一步封装,实现代码解耦

上面的加锁和释放锁都暴露在了业务调用方,增加了业务调用方的职责,同时,如果使用不当,还会产生bug。

接下来,我们稍作重构。看看下面的RedisLockTemplate

package com.emax.zhenghe.rpcapi.provider.config.distributeRedisLock;

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

/**
 * redis分布式锁并发控制模板类
 *
 * @author zhangguozhan
 */
@Slf4j
@Component
public class RedisLockTemplate {
    @Autowired
    private RedisDistributedLock redisDistributedLock;

    /**
     * redis分布式锁控制
     *
     * @param key               锁名
     * @param expireMS          锁的生命周期,单位:毫秒
     * @param redisLockCallback callback方法
     * @return
     */
    public Object execute(String key, long expireMS, RedisLockCallback redisLockCallback) {
        return execute(key, expireMS, redisLockCallback, true, 2);
    }

    /**
     * redis分布式锁控制
     *
     * @param key
     * @param expireMS
     * @param redisLockCallback
     * @param isAutoReleaseLock callback方法执行完成后自动释放锁
     * @return
     */
    public Result execute(String key, long expireMS, RedisLockCallback redisLockCallback,
                          boolean isAutoReleaseLock,
                          int retryTimes) {
        log.info("redis分布式锁控制 key={}", key);
        if (StringUtils.isBlank(key)) {
            log.info("try lock failure:key is null");
            return null;
        }
        boolean lock = redisDistributedLock.lock(key, expireMS, retryTimes);
        if (lock) {
            try {
                Result o = redisLockCallback.doInRedisLock();
                return o;
            } finally {
                if (isAutoReleaseLock) {
                    redisDistributedLock.releaseLock(key);
                }
            }
        } else {
            log.info("###key已存在,终止 key={}", key);
            return Result.error(ResultCodeEnum.GET_LOCK_FAIL, "请勿重复发起");
        }

    }
}

 

RedisLockCallback是一个函数式接口

package com.emax.zhenghe.rpcapi.provider.config.distributeRedisLock;

@FunctionalInterface
public interface RedisLockCallback {
    Result doInRedisLock();
}

 

这样,业务的调用就变得很easy了。例如:

Result result = redisLockTemplate.execute(key, 5000L, () -> {
            List<BillVo> billVos = batchInsert(list);
            return Result.ok(billVos);
});

 

 

关于ajax异步请求

 现在的web项目一般都是采用前后端分离的开发模式了,前端的程序框架也百花齐放,常见的有vue、nodejs等等。

 对于用户手抖导致的重复提交,服务端的做法就是利用上面的分布式控制,非首次的请求因为上锁失败而中断处理,前端收到的是“请勿重复提交”这样的提示。我原以为这样可能会影响用户体验。后来咨询前端同事,原来事实并非如此。

自己写了一个demo,模拟重复提交。页面异步重复发起相同的请求,服务端重复处理。第一次是加锁,正常处理请求,第二次是发现锁已存在,上锁失败,直接返回“请勿重复提交”的提示。页面会收到两次的响应结果。不过,因为第二次的请求上锁失败直接返回错误提示,所以响应早于第一次的响应。ajax判断响应的逻辑是如果是成功(正常响应,视为成功),就触发相应的后续处理,如果是失败(“请勿重复提交”视为失败),就toast提示。 因此,虽然toast了一下,只是一瞬间,第一次请求的响应来了之后,就会正常处理页面逻辑。

 

所以,上面的防重机制,也是比较合适的方案。

当然,应该校验的业务逻辑还是要有的,尤其是数据校验。这属于业务范畴了。

 

本文代码已放到github:https://github.com/buguge/api-idempotent.git

前端页面异步请求API,可参阅其中的代码:https://github.com/buguge/api-idempotent/blob/master/mideng/src/main/webapp/user.jsp

posted on 2020-07-06 19:16  buguge  阅读(6473)  评论(2编辑  收藏  举报