简介
本周想分享以下几个内容:
- mysql有就执行update,没有就执行insert
- 高并发之事务和锁的正面目
- try-catch对事务的影响
一、mysql有就执行update,没有就执行insert
这是我在阿里面试被问到一个题,目前来看应该有两种方式(都需要有一个唯一键)实现这个功能
建表sql:
CREATE TABLE `test_a` (
`id` int(11) NOT NULL,
`column1` varchar(255) DEFAULT NULL,
`column2` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
INSERT INTO test_a (id, column1, column2) VALUES ('123', '小明', '30');
1、on duplicate key update:
原理: 这个函数个人觉得是先执行了一下insert,然后如果有冲突(因为有唯一键)就执行update;如果没有冲突数据就插进去了。
语法:
insert into tablename (唯一键字段) VALUES (唯一键字段值) on duplicate key update column1=value1,column2=value2 . . . . ;
例子: (其实tablename后面的括号里面可以写其他字段,对应values后面的括号填满就好,但是唯一键必须写,不然不起作用)
INSERT INTO test_a (id) VALUES (123) on duplicate key update column2=31 ,column1='小红';
2、replace :
原理: 这个函数是先执行insert语句,如果有冲突(因为有唯一键),则删除这条冲突的,然后继续insert;如果没有冲突就插进去。
语法:
replace into test_a (唯一键字段,column1,column2) VALUES (唯一键字段值,value1,value2);
例子:
replace into test_a (id,column1,column2) VALUES (123,'小红',34);
思考: 如果我们现在有两个唯一键,即a、b都是唯一键的时候,使用上面两种方法会产生什么结果呢?
表数据:
CREATE TABLE `test_b` (
`a` varchar(255) NOT NULL unique,
`b` varchar(255) NOT NULL unique,
`c` varchar(255) DEFAULT NULL,
`d` varchar(255) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
INSERT INTO test_b (a, b, c, d) VALUES ('1', '浙江', '小明', '20');
INSERT INTO test_b (a, b, c, d) VALUES ('2', '天津', '小红', '32');
a | b | c | d |
---|---|---|---|
1 | 浙江 | 小明 | 20 |
2 | 天津 | 小红 | 32 |
执行语句: on duplicate key update
INSERT INTO test_b (a, b, c, d) VALUES ('1', '天津', '小王', '33') on duplicate key update c='小王',d='33';
执行完结果:
a | b | c | d |
---|---|---|---|
1 | 浙江 | 小王 | 33 |
2 | 天津 | 小红 | 32 |
执行语句: replace
replace INTO test_b (a, b, c, d) VALUES ('1', '天津', '小王', '33');
执行完结果:
a | b | c | d |
---|---|---|---|
1 | 天津 | 小王 | 33 |
结论:on duplicate key update只会修改第一条匹配到冲突的记录,而replace 是将所有冲突的记录全部删除,然后进行insert操作。
二、高并发之事务和锁的正面目
看完上面的一个知识点,是不是觉得还不够呢,下面要分享的是本周的重头戏:事务和锁的关系,我们在这之前需要清楚几个知识点:
1、事务的隔离级别
2、行锁和表锁
3、乐观锁
4、悲观锁
其实在这之前,我一直觉得高并发产生的线程安全问题只是针对于并发访问统一资源的,对于数据库而言没有这种现象,但是现在看来这是一个错误的知识体系。我们拿下单这个场景来揭开这层神秘的面纱:
整理思路: 我们应该是要做三个操作:查询库存,下单,扣库存
第一种写法: 在controller中控制这三个操作,即在controller中判断库存是否大于1,大于1就下单成功,然后扣库存。这种写法显然是有问题的,首先都不在同一个事务里面,显然会有数据不一致的问题。
第二种写法: 在service里面完成所有操作
@Transactional(isolation = Isolation.DEFAULT)
public boolean dotest() {
//查询牛奶这件商品现在还有多少库存
Goods goods = testDao.setlect_cout("牛奶");
if (goods.getInventory() >= 1) {
//如果大于1就可以下单
Order order = new Order();
order.setGoodsname(goods.getGoodsname());
//下单
int i = testDao.saveorder(order);
if (i > 0) {
//扣库存
int j = testDao.updategoods();
if (j > 0) {
return true;
}
}
} else {
return false;
}
return false;
}
这种写法看上去好像没啥问题,至少是写在了一个事务里面,要么全部成功,要么全部失败是吧。嗯,一致性问题是解决了,但是又出现了一个线程安全性问题,怎么理解呢,就比如牛奶实际库存有1000,但是2000个并发下单操作却能成功1050个?这显然是不合理的,那么为什么会发生这种情况呢,我们来具体分析一下!
场景1:
线程1(下单请求1) | 线程2(下单请求2) |
---|---|
查询牛奶库存(开启事务),还剩10件 | - |
- | 查询牛奶库存(开启事务),还剩10件 |
下单(向购买记录表插入一条数据) | 下单(向购买记录表插入一条数据) |
- | 更新库存(等待) |
更新库存(10-1=9,在java代码中计算剩余库存) | - |
成功 | 更新库存(10-1=9,在java代码中计算剩余库存) |
- | 成功 |
产生的结果: 库存是没啥问题,但是下单成功可以超过1000个
线程2更新库存等待解释: 看过事务隔离级别的应该清楚,mysql默认的隔离级别是可重复读,即:线程1开启事务之后,线程2只能insert和select(所以线程1开启事务之后线程2还能查询牛奶的库存)操作,不能进行update(所以线程2必须要等线程1update完,即关闭事务之后才能update,如果线程1迟迟不关闭事务的话,线程2很有可能会超时)和delete操作,但是这种隔离级别会造成幻读,这是能insert引起的。在这里暂时还没涉及到。
场景2:
线程1(下单请求1) | 线程2(下单请求2) |
---|---|
查询牛奶库存(开启事务),还剩10件 | - |
- | 查询牛奶库存(开启事务),还剩10件 |
下单(向购买记录表插入一条数据) | 下单(向购买记录表插入一条数据) |
- | 更新库存(等待) |
更新库存(10-1=9,在sql代码中计算剩余库存) | - |
成功 | 更新库存(9-1=8,在sql代码中计算剩余库存) |
- | 成功 |
产生的结果: 库存是没啥问题,但是下单成功可以超过1000个,而且,库存还会产生负数!!!
第三种写法: 使用乐观锁,goods对象里面加一个字段version(版本号)
@Transactional(isolation = Isolation.DEFAULT)
public boolean dotest3() {
Goods goods = testDao.setlect_cout("牛奶");
if (goods.getInventory() >= 1) {
//获取版本号
int version = goods.getVersion();
//先扣库存---在java里面操作库存
int Inventory=goods.getInventory()-1;
int j = testDao.updategoods2(version,Inventory);
if (j > 0) {
//如果库存扣成功了
Order order = new Order();
order.setGoodsname(goods.getGoodsname());
int i = testDao.saveorder(order);
if (i > 0) {
return true;
}
}
}
return false;
}
update goods set inventory=#{inventory} ,version=version+1 where version=#{version}
场景描述:
线程1(下单请求1) | 线程2(下单请求2) |
---|---|
查询牛奶得到库存剩下10和版本号100(开启事务) | - |
- | 查询牛奶得到库存剩下10和版本号100(开启事务) |
- | 更新牛奶版本为100的那条记录,然后库存变为为9,然后版本变成101(等待) |
更新牛奶版本为100的那条记录,然后库存变为为9,然后版本变成101,发现成功了 | 更新牛奶版本为100的那条记录,然后库存变为为9,然后版本变成101(等待) |
向下单记录插入 | 更新牛奶版本为100的那条记录,然后库存变为为9,然后版本变成101(等待) |
成功 | 更新牛奶版本为100的那条记录,然后库存变为为9,然后版本变成101,发现失败了,因为现在牛奶的版本号是101,没有版本号是100的牛奶商品 |
- | 失败 |
这种乐观锁的写法,能有效的解决这种并发超卖的现象。
第四种写法: 直接扣库存,然后在插入下单记录(目前测出来好像没啥线程问题)
update goods set inventory=inventory-1 where inventory>0
@Transactional(isolation = Isolation.DEFAULT)
public boolean dotest4() {
//查询和扣库存同时做
int j = testDao.updategoods3("牛奶");
if (j > 0) {
Order order = new Order();
order.setGoodsname("牛奶");
int i = testDao.saveorder(order);
if (i > 0) {
return true;
}
}
return false;
}
三、try-catch对事务的影响
这是一个很容易被忽视的问题,包括我自己也是在出了事故之后才发现这个问题的:在service(配置了事务的那一层)中的方法,如果使用了try-catch消化了异常,那么spring的事务控制将不会被检测到有异常,也就是说事务将不会被回滚。这样一来如果一个service处理了两个update操作,第二个失败之后,第一个将不会回滚,这样就产生了不一致的问题!
注意: 如果想要在service中try-catch消化抛出的异常,那就必须在catch里面再抛出一个异常,不然就是一段bug代码(有些可能想统一使用自定义的异常)