遇到的问题
最近在维护公司的一个老项目,这个项目是一个完完全全的单体应用。有一个用户签到加积分的接口,为了防止用户多次请求而导致数据库插入多条记录的问题。简而言之就是需要保证一个新增接口的幂等性问题。这里就不赘述其他的解决方案了,比如说可以在数据库做唯一索引、token机制,单体应用可以用synchronized或者JDK自带的ReentrantLock等等。
问题探讨与结论
最初我想到的是,直接在方法上面加一个synchronized关键字,来修饰这个方法。这样毫无疑问是最简单的一种.
public synchronized Rsp addUserIntegral(AddUserIntegralReq req){
// 1、先查询该用户用户今日是否签到了
// 2、如果没有签到,添加积分
}
复制代码
但是这会存在一个问题:修饰方法的synchronized,它会持有一把当前对象的锁(this);同时我们的应用是存在于Spring中的,那么当前对象默认就是一个单例对象,这会导致张三添加积分时李四不能添加!这样并发的性能大大降低。需要另外一种方式,每次请求都不是同一个对象来作为锁。
我又想到了传入一个HttpServletRequest对象作为锁对象(每次请求天然是一个不同对象),结论证明不能这样做。因为这样加锁根本就没有互斥的效果。
睡个午觉起来,很容易想到使用 AddUserIntegralReq req方法参数来作为锁对象,每个用户的请求不一样同个用户的请求一样。这样就可以做到针对不同用户做到互斥。下面是AddUserIntegralReq的类结构
public class AddUserIntegralReq{
private String userAccount; //这个是唯一主键
private Integer points;
//getter setter ...
}
复制代码
我尝试着重新这个类的hashCode和equals方法,将这个对象作为锁对象,下面看看测试结果:
public class Test {
public void test(Demo demo){
synchronized (demo){
if (demo.id.equals("1")){
System.out.println(demo.getId());
while(true);
}else{
System.out.println(demo.getId());
}
}
}
static class Demo{
private String id;
private Integer points;
//getter setter ...
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Demo demo = (Demo) o;
return Objects.equals(id, demo.id) && Objects.equals(points, demo.points);
}
@Override
public int hashCode() {
return Objects.hash(id, points);
}
}
}
复制代码
测试代码:
public static void main(String[] args) throws InterruptedException {
Test test = new Test();
//线程1
new Thread(new Runnable() {
@Override
public void run() {
Demo demo = new Demo();
demo.setId("1");
demo.setPoints(10);
test.test(demo);
}
}).start();
Thread.sleep(1000);
//线程2
new Thread(new Runnable() {
@Override
public void run() {
Demo demo = new Demo();
demo.setId("1");
demo.setPoints(10);
test.test(demo);
}
}).start();
}
复制代码
创建一个“相同对象”,根据我们假设,如果synchronized采用的是equals去判断一个对象是否相同,那么测试代码打印出来应该就只有一 “1”,但是结果两个线程都打印了 “1”,说明了synchronized持有的这个Demo对象并没有完成我想要的互斥效果。推出了synchronized是采用的 == 地址比较,持有的锁对象是否是同一个。
虽然结论得到了synchronized是采用的 == 地址比较,持有的锁对象是否是同一个,但是上面遇到的实际业务情况又该怎么解决呢?我想到了采用 userAccount作为锁对象,因为他是String类型,如果不是new 出来的String对象,它是存放在常量池中的,可以有不同的引用持有。我当时大呼秒啊,于是马上调整了代码
public void test(Demo demo){
synchronized (demo.getId()){
if (demo.id.equals("1")){
System.out.println(demo.getId());
while(true);
}else{
System.out.println(demo.getId());
}
}
}
复制代码
测试代码不变
public static void main(String[] args) throws InterruptedException {
Test test = new Test();
//线程1
new Thread(new Runnable() {
@Override
public void run() {
Demo demo = new Demo();
demo.setId("1");
demo.setPoints(10);
test.test(demo);
}
}).start();
Thread.sleep(1000);
//线程2
new Thread(new Runnable() {
@Override
public void run() {
Demo demo = new Demo();
demo.setId("1");
demo.setPoints(10);
test.test(demo);
}
}).start();
}
复制代码
这次的结果完美符合预期,只是线程1打印出了 “1”
于是我将测试写的Demo的加锁方式腾挪到正式代码中去,偶买噶~!!!翻车了,下面是我的腾挪过去之后的样子
public Rsp addUserIntegral(AddUserIntegralReq req){
synchronized(req.getUserAccount()){
// 1、查询该用户今天是否签到了
// 2、如果没有签到添加积分
}
}
复制代码
纳尼!!! req.getUserAccount() 两次请求getUserAccount()出来的相同对象不是同一个了!这是为何呢?
突然灵光一闪!Spring MVC的问题!!两次请求在Controller中使用@RequestBody注解将前端传入的json字符串转换为对象,破案了破案了,我大呼尼玛。大胆猜测:JackSon(Spring MVC默认的)将json字符串转换为对象之后,该对象的所有属性的值全部在Java堆上,也就是说给字符串赋值的时候采用的是类似于 demo.setId(new String(“1”))这种方式,导致两次获取userAccount对象都是堆上的对象并不是常量池中的同一个对象。
Demo demo = new Demo();
demo.setId("1");
demo.setPoints(10);
String s = JSONUtil.toJsonStr(demo);
Demo demo1 = JSONUtil.toBean(s, Demo.class);
Demo demo2 = JSONUtil.toBean(s, Demo.class);
System.out.println(demo1.getId() == demo2.getId());
复制代码
输出结果 false
案是破了,但是最开始的任务可没有完成呀。最后不得不采用一种很蹩脚的方式来实现(如果看到这里你认为有比我下面更好的方式,麻烦告诉下)
private static final Map<String,Object> lockMap = new ConcurrentHashMap<>();
public Rsp addUserIntegral(AddUserIntegralReq req){
String userAccount = req.getUserAccount();
if (!lockMap.contains(userAccount)){
lockMap.put(userAccount,new Object());
}
try{
synchronized(lockMap.get(userAccount)){
// 业务逻辑
}
}finally{
//防止内存溢出
lockMap.remove(userAccount);
}
}
复制代码
这种方式相当于是做了一次唯一主键到唯一对象的映射。
PS:如果各位同学有更好的方法,请告知我