续上次用nginx搭建好反向代理负载均衡的俩个实例后,我在项目中关联了如下这张表:
然后写了个接口,去买一个表里的商品,大致思路就是:读取库存,库存减一,回写数据库,返回成功。在单个实例里面加个synchronized后完全正常的减库存,然后当我启动俩个实例后多次疯狂调用接口后出现如下情况:
出现问题的代码如下,其主要原因是俩个实例如果在查询库存时候同时查询数据库的话,由于减库存后没来得及更新,俩次查询的结果是一致的,从而更新库存的值也是一直的,导致卖出俩个商品而只减了一次库存,出现了超卖的情况!
public class StockServiceImpl implements StockService{
@Autowired
StockMapper stockMapper;
@Override
public Stock selectByPrimaryKey(Integer goodsId) {
return stockMapper.selectByPrimaryKey(goodsId);
}
// 加锁也只能保证单个实例线程安全性
public synchronized void byGoods() throws InterruptedException {
// 这里写死,数据库里就一条记录且ID为1,拿到数据
Stock stock = selectByPrimaryKey(1);
// 获取到商品的库存
Long goodsStock = stock.getGoodsStock();
// 减库存
goodsStock -= 1;
stock.setGoodsStock(goodsStock);
// 为了将问题放大这里睡上几秒 拉长查库存和更新库存的之间的时间间隔
Thread.sleep(3000);
// 更新
updateByPrimaryKeySelective(stock);
// 输出
System.out.println("更新后库存为:" + goodsStock);
}
@Override
public int updateByPrimaryKeySelective(Stock record) {
return stockMapper.updateByPrimaryKeySelective(record);
}
}
接下来进行改造,使用数据库层面的“锁”,我们知道向一张表中出入俩条相同主键的数据,只可能成功一条,因为主键具有约束性,所以利用这个特点,当我们向数据库插入成功时,即代表获取到锁,从而去运行我们的业务代码,当我们的业务代码运行完时,我们把数据库的该条记录进行删除,即代表释放锁,从而其他线程即有机会获取到锁,再去跑业务代码,这样即使运行的是俩个实例,同一时间也只能一个线程去运行业务代码,也就不会出现超卖这种情况了。下面给出加锁和解锁的代码:
// 上锁,由于上锁失败的话会直接返回失败,并不会再次
// 获取,是非阻塞的,这里利用循环实现阻塞。
@Override
public boolean tryLock() {
Lock lock = new Lock();
lock.setLockId(1);
while (true) {
try {
if (lockMapper.selectByPrimaryKey(1) == null) {
int i = lockMapper.insert(lock);
if (i == 1) {
return true;
}
}
Thread.sleep(1000);
} catch (InterruptedException e) {
}
}
}
// 解锁代码
@Override
public void unLock() {
deleteByPrimaryKey(1);
}
对service层的购买商品的代码就行加锁:
// 买商品
public void byWithLock() throws InterruptedException {
// 上锁
lockService.tryLock();
// 业务代码
byGoods();
// 释放锁并跳出循环
lockService.unLock();
}
controller层的代码:
@RestController
public class LoadBalance {
@Autowired
StockServiceImpl stockService;
@RequestMapping("/balance")
public String balance() {
try {
stockService.byWithLock();
} catch (InterruptedException e) {
e.printStackTrace();
}
return "success";
}
}
再次跑代码,疯狂点接口后俩个实例控制台输出满意结果如下:
总结:
1、通过insert插入数据库是非阻塞的,这里采用while循环“不优雅”的实现了阻塞。数据库自己也可以使用“for update”来实现阻塞。
2、这种数据库层面的锁其实是很粗糙的,非常依赖于数据库,如果数据库宕机,那么是没有办法再使用锁的。
3、如果有线程加锁后运行业务代码时出现如数据库断电恢复,或者请求超时等导致数据库的锁没能解锁,这时我们可以给每个锁一个时间,定时清理超时的锁。