背景
2017年6月27日杨晨值日的过程中发现一个case,经查询发现天玑系统bug导致重复支付了三笔。
情景重现
本次问题的key在于数据库层面没有做幂等,导致连续两次一模一样的数据都可以插入成功。
重现问题的demo代码如下(struts)
public class Constant {
public static List<Integer> list = new ArrayList<>();
//模拟数据库
static {
list.add(1);
list.add(2);
}
}
public class TestAction extends ActionSupport {
@Setter
private int data;
@Getter
private List<Integer> list = Constant.list;
public String showCase() throws InterruptedException {
//模拟向数据库添加数据
list.add(data);
return SUCCESS;
}
}
浏览器请求url为 http://localhost:8080/struts-test/showcase?data=3
由于添加数据时没有做幂等(list.add(data); ) 所以只要浏览器持续请求这个url,系统就会一直执行add方法,导致list中出现重复数据。
解决方案
数据库层做幂等
这种方法可以通过在数据库表中添加一个UK来解决,这样可以防止插入两条一模一样的数据。由于代码中是用list模拟数据库的,所以这种方式不便演示。
在前端解决
此次case中发送生成withdraw请求的来源是点击页面按钮。在点击了一次按钮之后,将按钮设为disabled就可以防止通过点击按钮发送第二次请求。这种方式实现简单,但是并不能从根本上解决问题,因为完全可以通过拼装url达到和点击按钮一样的效果。目前线上系统暂时按照这种方案解决,只能作为缓兵之计。
在应用层解决
简单来讲,就是在插入数据之前先判断数据是否存在。若存在则不允许插入,若不存在则执行插入操作。
方案对比
时间 | 复杂度 | 安全性 | 对现有系统改动 |
---|---|---|---|
方案1 | 中 | 高 | 高 |
方案2 | 低 | 低 | 低 |
方案3 | 中 | 高 | 中 |
初步方案
经过对比以上三种备选方案,选用第三种方案最优。方案实现的demo代码如下
public class TestAction extends ActionSupport {
//由于struts的action默认为原型模式,所以LOCK必须设为static
private static final String LOCK = "LOCK";
private int data;
private List<Integer> list = Constant.list;
public String showCase() throws InterruptedException {
//模拟向数据库添加数据
synchronized (LOCK) {
if (list.contains(data)) {
System.out.println("数据已存在,不可重复添加");
} else {
System.out.println("数据不存在,可以添加");
//模拟一个耗时较长的操作
Thread.sleep(5000);
list.add(data);
}
}
return SUCCESS;
}
}
方案优化
由于所选择的方案在应用层加锁会导致多个请求的代码串行执行,可能会造成线程阻塞。所以可以缩小同步代码块的范围,只在关键部位串行执行。优化后的代码如下
public class TestAction extends ActionSupport {
private static final String LOCK = "LOCK";
private int data;
private List<Integer> list = Constant.list;
public String showCase() throws InterruptedException {
if (list.contains(data)) {
System.out.println("数据已存在,不可重复添加");
} else {
//模拟一个耗时较长的操作
Thread.sleep(5000);
synchronized (LOCK) {
if (!list.contains(data)) {
System.out.println("数据不存在,可以添加");
list.add(data);
} else {
System.out.println("数据已存在,不可重复添加");
}
}
}
return SUCCESS;
}
}
存在的问题
synchronized锁只作用于单个jvm内部的对象,在分布式环境下无效。由于线上机器有两台,一台机器的jvm与另一台机器的jvm是相互独立的,所以这种情况下synchronized锁并不适用。