幂等校验一直是系统中极为重要的一环,体现在许多系统架构中。如服务内同步调用,保证多次操作仅产生一个结果(e.g. 用户网络延迟,快速连续点击下单按钮,此时如果不做幂等校验会产生无数条未支付订单);再比如微服务通过消息队列发送消息,消费者异步处理逻辑,如果消息重复发送仅消费一次(e.g. 多次发送支付请求,消费者如果不做幂等校验,会对同一订单多次付款)。
我们就针对生成订单接口,做一个简易的幂等校验逻辑。
1 幂等校验实现思路
用户在商品详情页点击下单时,网络出现延迟,导致多次点击没有响应;但实际上服务端在接受到请求后,会为每次请求都创建一个订单,这样用户点击多少次,就会生成多少条待支付订单,这时就需要做幂等校验。
实现方案很简单,用户在进入商品详情页时,前端自行生成一个唯一ID并保存,在用户点击下单按钮时将ID和订单具体信息一同发送到服务端,服务端在接收到请求后对ID进行校验,如Redis中存在该ID,说明此订单已经创建,直接返回下单成功并返回一个订单支付回调URL;如Redis中不存在该ID,说明还未创建该订单,将订单ID写入Redis并继续处理生成订单逻辑。
下面我们就用拦截器来实现。
2 拦截器实现
先来编写拦截器逻辑并将其注册进Spring,主要逻辑就是读取输入流并解析成Json对象,获取其中的唯一ID并在Redis中进行下单次数校验,如果未重复就放行,重复就直接返回下单失败结果。由于我这没有前端页面,偷个懒直接用下单用户名做校验哈。
这里用到了Redis的setNx方法,并设置了15s的过期时间,一般重复下单也不会超过这个时间。如果setNx设置成功,说明是正常下单;如果设置失败,说明已存在该订单,直接返回错误结果。核心代码如下。
//入参Json字符串解析
JSONObject paramJson = JSONObject.parseObject(param.toString());
String username = paramJson.getString("username");
if (Strings.isNullOrEmpty(username)) {
log.error("入参异常");
Result<Object> error = Result.error("入参异常,请重新发起请求");
byte[] resultByte = JSONObject.toJSONString(error).getBytes("UTF-8");
outputStream.write(resultByte);
return false;
}
String redisKey = "addOrderTimesConstraint:".concat(username);
if (redisUtil.setNx(redisKey, "1", 15, TimeUnit.SECONDS)) {
log.info("用户下单次数校验成功");
} else {
log.error("用户重复下单,直接拒绝");
Result<Object> error = Result.error("请勿重复下单");
byte[] resultByte = JSONObject.toJSONString(error).getBytes("UTF-8");
outputStream.write(resultByte);
return false;
}
return true;
下面就将该拦截器注册进SpringMVC,重写WebMvcConfigurer中的addInterceptors()方法,将拦截器注入并指定拦截路径,我们只需拦截下单接口。
@Override
public void addInterceptors(InterceptorRegistry registry) {
//支付相关接口需要用拦截器做幂等校验
//先仅用用户名进行下单限制,15s内只能下单一次
registry.addInterceptor(webInterceptor)
.addPathPatterns("/pay/addOrder");
}
3 出现的问题及原因
先来试着连续下单两次,日志如下。
2023-02-23 10:28:18.618 INFO 12440 --- [nio-6666-exec-4] c.framework.interceptor.WebInterceptor : 请求URL: http://localhost:6666/pay/addOrder
2023-02-23 10:28:18.619 INFO 12440 --- [nio-6666-exec-4] c.framework.interceptor.WebInterceptor : 支付相关接口入参: { "username": "abcdefg", "stuffId": "566996ae8642ded9a005eefc1b506e70", "stuffCount": "1", "moneyAmount": "5555.55"}
2023-02-23 10:28:19.559 INFO 12440 --- [nio-6666-exec-4] c.framework.interceptor.WebInterceptor : 用户下单次数校验成功
2023-02-23 10:28:19.579 ERROR 12440 --- [nio-6666-exec-4] c.f.h.exception.GlobalExceptionHandler : Exception出现了异常!{}
org.springframework.http.converter.HttpMessageNotReadableException: Required request body is missing: public com.framework.utils.Result<?> com.bank.controller.PayController.addOrder(com.bank.entity.OrderInfo)
2023-02-23 10:33:15.815 INFO 12440 --- [nio-6666-exec-6] c.framework.interceptor.WebInterceptor : 请求URL: http://localhost:6666/pay/addOrder
2023-02-23 10:33:15.815 INFO 12440 --- [nio-6666-exec-6] c.framework.interceptor.WebInterceptor : 支付相关接口入参: { "username": "abcdefg", "stuffId": "566996ae8642ded9a005eefc1b506e70", "stuffCount": "1", "moneyAmount": "5555.55"}
2023-02-23 10:33:15.817 ERROR 12440 --- [nio-6666-exec-6] c.framework.interceptor.WebInterceptor : 用户重复下单,直接拒绝
可以看到拦截器和下单次数校验生效了,也返回了正确的Json参数,但是进入下单方法体后却报错了。报错的内容也很直白,方法入参是用@RequestBody来接收的实体类,意思就是Request Body丢失了,实体类没获取到。为何如此呢?下面来分析一下。
Required request body is missing: public com.framework.utils.Result<?> com.bank.controller.PayController.addOrder(com.bank.entity.OrderInfo)
3.1 InputStream的流式设计
既然是读取出了问题,可以先去看看InputStream的read()方法,read()一共有3种重载形式,如下面的代码。
public abstract int read() throws IOException;
public int read(byte b[]) throws IOException {
return read(b, 0, b.length);
}
public int read(byte b[], int off, int len) throws IOException {
if (b == null) {
throw new NullPointerException();
} else if (off < 0 || len < 0 || len > b.length - off) {
throw new IndexOutOfBoundsException();
} else if (len == 0) {
return 0;
}
int c = read();
if (c == -1) {
return -1;
}
b[off] = (byte)c;
int i = 1;
try {
for (; i < len ; i++) {
c = read();
if (c == -1) {
break;
}
b[off + i] = (byte)c;
}
} catch (IOException ee) {
}
return i;
}
- 无参的抽象方法,有很多种实现。
- 单参方法传入byte数组,相当于buffer缓冲区,将流中的数据暂存到buffer中,实际调用了三参方法。
- 三参方法传入了byte数组,byte数组写入起始位置,读取长度。该方法真正读取实际还是调用了无参抽象方法,呃啊,还得去找个实现方法看看。
在read()的243个实现方法中随便点了一个,ByteArrayInputStream中的read()是这样的。主要逻辑就是对缓冲区数组和 0xff做了按位与运算,0xff = 1111 1111,可以保证缓冲区每个字节(8bit位)不受改变,因为与运算需要都为1才等于1,而8bit正构成了一个byte。但这个不是我们要探究的重点,实际问题集中在"pos"和"count"这两个全局变量。
public synchronized int read() {
return (pos < count) ? (buf[pos++] & 0xff) : -1;
}
"pos"代表buff缓冲区中当前读取的指针位置,"count"代表buff缓冲区的最大长度,如果当前读取位置 < 最大长度,则能够读取;反之则返回-1,停止读取。"pos"和"count"都只会增大,不会减小,即缓冲区的长度和流读取的指针都是逐渐增大的。因此就导致了流的读取是单向的,只能向前不能向后。
3.2 ServletInputStream
ServletInputStream继承了InputStream,且没有重写read()方法和reset()方法,所以他们俩的读取逻辑是一致的,都是一次性读取。
有一种简单的逻辑,就是从ServletInputStream中读取完后,把Body再塞入HttpServletRequest中,设置一个Attribute然后在方法体中获取;但是这样不太优雅,以后每次新增需要校验的接口还要改动方法参数。
我们使用类似的思路,将Body读取完后缓存起来,让他别再是一次性的了;每次调用getInputStream()并读取时,都通过构造器把缓存的Body塞进去,就实现多次读取了。这种思路需要重写HttpServletRequest,有没有这种方法呢?当然有,使用HttpServletRequestWrapper。
4 解决方案:自定义装饰器 + 过滤器注册
HttpServletRequestWrapper的顶级接口是ServletRequest,使用了装饰器模式,装饰器模式的作用就是包装原有类,相当于温柔地增强。下面我们只需要自定义一个HttpServletRequestWrapper,重写getInputStream(),并用他代替原有的Servletequest,就完整地实现了Body重复读取。
4.1 自定义ServletRequest装饰器
构建一个HttpServletRequestWrapper就相当于重写一个ServletRequest,我们能重写任何需要自定义的方法。实现的思路就是读取原有HttpServletRequest中一次性的InputStream,放在本类中缓存,后续调用getInputStream()方法时都读本类缓存。
我们在类中创建一个变量"byte[] bodyCache = null",将他作为本类缓存。这时有人可能会想到线程安全问题,Servlet本身不是线程安全的,因为他是单例模式的;但是ServletRequest会由Dispatcher调度,每个请求分配一个线程来处理,不会存在多个线程操作同一ServletRequest。因此是线程安全的,放心大胆用。
接下来就需要把ServletInputStream读取并写入byte数组中,实现起来很简单,从ServletInputStream -> ByteArrayOutputStream再转数组即可。代码如下。
private byte[] bodyCache = null;
public BodyDuplicateWrapper(HttpServletRequest request) {
super(request);
try {
ServletInputStream inputStream = request.getInputStream();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int len = 0;
while ((len = inputStream.read(buffer)) > -1) {
//3参数: byte b[] int off int len
// buffer字节数组,buffer数组写入位置(偏移量),写入字节数(一般等于读取字节数)
outputStream.write(buffer, 0, len);
}
outputStream.flush();
//复制入本地变量
bodyCache = outputStream.toByteArray();
} catch (IOException e) {
e.printStackTrace();
}
}
流复制的逻辑其实可以用StreamUtils.copyToByteArray(inputStream)来实现,但是多写写总没坏处。在构造器运行完后,bodyCache已经存放了输入流的数据,再重写getInputStream()方法,将read()方法重写为读取本地缓存即可。
/***
* 重复获取输入流方法
* @return
* @throws IOException
*/
@Override
public ServletInputStream getInputStream() throws IOException {
final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bodyCache);
return new ServletInputStream() {
@Override
public int read() throws IOException {
return byteArrayInputStream.read();
}
@Override
public boolean isFinished() {
return false;
}
@Override
public boolean isReady() {
return false;
}
@Override
public void setReadListener(ReadListener readListener) {
}
};
}
/***
* 重复获取reader方法
* @return
* @throws IOException
*/
@Override
public BufferedReader getReader() throws IOException {
ByteArrayInputStream inputStream = new ByteArrayInputStream(bodyCache);
return new BufferedReader(new InputStreamReader(inputStream));
}
4.2 装饰器注入
装饰器光写好摆在那也不行,得配合过滤器把他注入进去,再配置需要过滤的路径。在使用HttpServletRequest时,就将当前request通过构造器传入,并将增强后的装饰器传入FilterChain中,以后就使用装饰后的HttpServletRequest。
//通过构造器将原request传入
ServletRequest requestWrapper = new BodyDuplicateWrapper((HttpServletRequest) servletRequest);
//放入过滤器链
filterChain.doFilter(requestWrapper, servletResponse);
最后只需要把过滤器注册进Spring容器即可。
/***
* Body输入流复制过滤器
* @return
*/
@Bean
public FilterRegistrationBean filterRegistrationBean(){
FilterRegistrationBean bean = new FilterRegistrationBean();
bean.setFilter(new BodyDuplicateFilter());
bean.addUrlPatterns("/pay/addOrder");
bean.setOrder(1);
return bean;
}
5 结果试验
我们来试验一下,能否既正常下单又让幂等校验生效。
{
"code": 200,
"message": "下单成功,请尽快支付",
"success": true,
"timestamp": "1677154192047"
}
{
"code": 500,
"message": "请勿重复下单",
"success": false,
"timestamp": 1677154195615
}
结果看来非常成功,圆满结束。