B端接口幂等性解决方案

简介

与我们B端同学打交道最多的无疑就是各种单据了,例如用户手动新建单据、上游数据生成下游单据等等。那么大家是否遇到由于网络问题或者业务问题导致单据重复生成的情况呢?那么针对这些情况,我们如何保证单据生成业务的稳定性和高可用呢?下面是的在工作过程中针对这些问题研究出的一些解决方案。如果小伙伴们有更好的方案,欢迎指正。

这里我是采用Redis来保证接口幂等性的。当然,还有其他很多的解决方案,例如:数据库、JVM锁、前端拦截等等,但是我认为还是用独立的中间件去解决比较好。原因如下:

1.首先,我是后端程序员,前端的东西该做做,但是我们后端要保证接口自身的高可用和稳定性。

2.在幂等性方面,我个人不喜欢将其引入到数据库层面。首先我觉得每个产品都有它存在的意义,让每个产品专心的去做它最应该做的事情。数据库解决这个问题无疑就是加表或者加字段或加查询操作。而这个问题的出现往往是在短时间内并发产生的,非必要时,不要将解决幂等性的东西持久化。

3.JVM锁不适用分布式环境,现在很多公司都是采用微服务架构,即使依然存在着单机部署,但是如果后面要改成分布式呢,难道代码逻辑也要重新写吗?(这里有同学会觉得单机改分布式,锁的问题必然要重写的呀。我个人的观点可以规定禁用synchronized,一律用Lock。这样可以将Lock封装一下,以后改分布式的时候只需要改Lock工具类就好了。个人观点哈)。

单据编号生成策略

我现在所做的项目中,单据编号是按照一定的规则生成的:单据简称_年月日_序号。 面对这种规则的单据编号,我们首先要考虑的就是我们的单据编号是否要严格的保证顺序、不断层。例如序号要严格按照0001、0002、0003这样不断层的递增。而不允许出现0001后直接0003这种断层现象的发生。

(1)不需要保证: 这种情况就非常的简单了,我们可以直接使用redis进行incr来控制序号的递增。

单据编号递增代码

// 生成 Order_20220520_0001 格式的单据编号
String dateFormat = getCurrentDateFormat("yyyyMMdd");
// key
String key = KEY_PREFIX + ":" + tableNoPrefix + ":" + dateFormat;
// 判断key是否存在, 如果存在就自增并返回自增后的结果; 如果不存在就新增value为1, 存活时间为24小时, 并返回1
String script = "if (redis.call('exists', KEYS[1]) == 0) then redis.call('setex', KEYS[1], ARGV[1], ARGV[2]) return 1 else return redis.call('incr', KEYS[1]) end";
DefaultRedisScript<Long> defaultRedisScript = new DefaultRedisScript<>();
defaultRedisScript.setResultType(Long.class);
defaultRedisScript.setScriptText(script);
long count = stringRedisTemplate.execute(defaultRedisScript, Arrays.asList(key), (3600 * 24) + "", "1");
String countStr = null;
switch ((count + "").length()) {
    case 1:
        countStr = "000" + count;
        break;
    case 2:
        countStr = "00" + count;
        break;
    case 3:
        countStr = "0" + count;
        break;
    default:
        countStr = "" + count;
}
return tableNoPrefix + "_" + dateFormat + "_" + countStr;
复制代码

单据编号自减代码

String dateFormat = getCurrentDateFormat("yyyyMMdd");
// key
String key = KEY_PREFIX + ":" + tableNoPrefix + ":" + dateFormat;
// 如果key存在且value大于0时,自减
String script = "if (redis.call('exists', KEYS[1]) == 1) then if (tonumber(redis.call('get', KEYS[1])) > 0) then return redis.call('decr', KEYS[1]) else return 0 end else return 0 end";
DefaultRedisScript<Long> defaultRedisScript = new DefaultRedisScript<>();
defaultRedisScript.setResultType(Long.class);
defaultRedisScript.setScriptText(script);
long count = stringRedisTemplate.execute(defaultRedisScript, Arrays.asList(key));
复制代码

(2)需要保证: 如果需要保证单据编号不断层的话,那么仅仅采用上面的方案就行不通了。比如说两个线程并发,那么可能会出现生成0001的线程失败了,生成0002的线程成功了。这样就是有问题的。那么我觉得这个时候就要加锁了。

image.png

当然也要考虑两种情况:手动新建和上游数据下推

1.手动新建:手动新建的话,我觉得没有什么更好的办法,就直接加锁好了,因为这种情况下用户新建完一张单据肯定是要立刻就在列表中看见它的。而且一般来说这种情况也不会出现较大的并发量,所以直接加阻塞锁。

生成单据业务

// 锁统一接口(JVM锁、redis锁、zk锁统一维护)
ILock lock = new DistributedLock("lockName");
lock.lock();
try {
    // 获取单据编号(上面单据编号递增代码)
    String tableNo = getTableNo("redisKey");
    // 执行业务逻辑
    ....
} catch (Exception e) {
    // 将单据编号自减(上面单据编号自减代码)
    subTableNo("redisKey");
} finally {
    lock.unLock();
}
复制代码

2.上游数据下推:这种情况不能一概而论,一般分为两种场景。

(1)用户勾选列表中的一些数据后,点击按钮生成下游单据。这个时候我们要将下游单据的生成结果返回给用户。因此和上面一样,直接加锁即可。

(2)后台调度采集并统计数据,定期生成单据。因为这个场景是后台操作的,所以一般来说对单据生成的时间没有非常严格的要求,是允许出现短时间延迟的。如果在调度任务过多、调度触发时间相对密集、数据量比较大的情况下,我们就没必要让调度去同步阻塞了,可以直接将操作扔给MQ来执行。

image.png

3.如果系统中的某个单据既可以手动新建、也存在后台调度下推呢?那么我觉得这个时候我们要将上述的生成单据的业务独立成一个同步方法,两个操作都去调用这个方法即可。

接口重复调用

比如说,用户选中一些数据后,连续点击了按钮好几下。这个时候就可能会出现问题。当然还要根据不同的场景去解决问题。(注意:我们都清楚,这个问题可以通过前端做一些笼罩或者按钮状态等效果来缓解这一问题。但是我认为前端防傻子,后端防流量。所以这些东西后端也要做)

1.场景1:一条记录只允许推送一次下游单据

这个时候就要严格的预防接口重复调用的问题了,我们可以获取上游数据中具有唯一性的字段作为标识(例如上游数据的ID)。

但如果是涉及批量选择上游数据推送单据的话,那么取上游数据ID列表作为标识就不太好了。因为我们要保证redis的key尽可能的简短。这个时候可以通过生成唯一标识的方式,有两种方法:

(1) 后端生成标识:每一次调用这个接口之前先请求一下后端,后端生成一个唯一标识返回给前端并存在redis中。调用接口后,首先去查询数据库,判断是否已下推。如果没有下推,则查询redis中是否存在这个唯一标识,如果不存在就拦截。接口调用完成或失败后将这个标识删除。

(2) 前端生成标识:我们可以让前端同学根据一定的规则为我们生成一个标识。比如说前端在请求接口之前生成一个唯一标识,等请求结果返回后去更新这个标识。调用接口后,首先去查询数据库,判断是否已下推。如果没有下推,则去redis中查询这个标识是否存在,如果不存在就新增。这样其他的并发数据就会被拦截到。当然如果在处理业务的过程中出现了异常后,也要及时的手动将这个标识删除。

下面的代码针对前端生成标识的方式

接口代码

// 1.查询数据库,判断上游数据是否以及推送过下游单据(可以新建字段 "是否已推下推单据" 或 "下游单据的单据编号")
if(getIsPush()) {
    return Result.fail("当前数据已经推送过下游单据");
}
// 2.校验标识  如果存在, 返回true; 如果不存在, 新增标识并返回false
if(isHasSourceKey()) {
    return Result.refuse("并发请求拦截");;
}
try {
    // 3.处理业务逻辑
    ....
    return Result.success("下推单据成功");
} catch (Exception e) {
    // 4.删除标识
    delSourceKey();
    return Result.fail("下推单据失败");
}
复制代码

那么现在这段代码就一定没有问题了吗?答案当然是NO,在某种极端情况下还是会有问题的。我举个例子:

比如说有一名小同志在搞事情,使用工具给这个接口发了一万条并发请求。假设大量请求进来后造成服务器延迟,导致我第一条请求与最后一条请求之间间隔时间为31秒(即第一条请求执行完毕后的第31秒后,最后一条请求才从1步骤走到2步骤,校验redis标识)。而我给这个标识设置的存活时间为30秒,那么就必然导致这个请求没有获取这个标识,还是会出现重复数据的情况。

那么这个时候,我们可以根据实际的场景进行接口压测,拿到一个最佳的流量峰值。给标识设置合理的存活时间,以及使用Sentinel进行流量控制。

校验标识代码

//校验标识代码
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent("key", "value", 30, TimeUnit.SECONDS);
复制代码

删除标识代码

//删除标识代码
stringRedisTemplate.delete("key");
复制代码

2.场景2:一条记录可以推送多条下游单据

这个就要视情况而定了,看看业务是否允许在短时间内推送多条数据。不如不允许的话,我们也可以根据场景1的做法来做,或者使用Sentinel进行流量控制, 控制每秒内的并发数。除此之外,如果业务中存在上述生成单据编号的业务时,参照上面单据生成策略中的方案。

总结

说白了,解决方案之所以称之为解决方案,就说明他只是一个解决某个问题在某个场景下存在的一些问题。并不是所有的场景都可以直接拿来套的。上面的一些解决方案虽然是解决了一定的问题,但是总会有极端现象的发生。

要保证效率就必然会牺牲一定的安全性,反过来一样。所以说我们的接口对于效率的要求并不是很严格也没有较大并发量的话。干脆直接用阻塞锁,把锁的颗粒度调整到最大就好了。

猜你喜欢

转载自juejin.im/post/7099865395829407775