項目中我們經常能見到一些併發問題,現對一些常見併發問題進行總結,知識結構不會很全,但比較實用。
- 基本概念
- 什麼是併發問題
我們以記錄網站的訪問量爲例,先看一下併發問題是如何產生的。
private Integer count=1;
private AtomicInteger atomicCount = new AtomicInteger(1);
/**
* 非線程安全方式
* @param request
* @return
*/
@RequestMapping("/getCount")
public BaseResponse<Integer> visitCount(HttpServletRequest request){
return new BaseResponse<>(true, count++);
}
我們通過jmeter模擬併發1000個請求,會發現我們得到的的值並不一定是1000。這裏因爲count++ 並不是線程安全的,所以在併發情況下會存在線程安全問題。
- 幾組重要概念
同步VS異步
同步和異步通常用來形容一次方法調用。同步方法調用開始後,調用者必須等待被調用的方法結束後,調用者後面的代碼才能執行。而異步調用,指的是,調用者不用管被調用方法是否完成,都會繼續執行後面的代碼,當被調用的方法完成後會通知調用者。
併發與並行
併發和並行是十分容易混淆的概念。併發指的是多個任務交替進行,而並行則是指真正意義上的“同時進行”。實際上,如果系統內只有一個CPU,使用多線程時,在真實系統環境下不能並行,只能通過切換時間片的方式交替進行,從而併發執行任務。真正的並行只能出現在擁有多個CPU的系統中。
阻塞和非阻塞
阻塞和非阻塞通常用來形容多線程間的相互影響,比如一個線程佔有了臨界區資源,那麼其他線程需要這個資源就必須進行等待該資源的釋放,會導致等待的線程掛起,這種情況就是阻塞,而非阻塞就恰好相反,它強調沒有一個線程可以阻塞其他線程,所有的線程都會嘗試地往前運行。
臨界區
臨界區用來表示公共資源或者說是共享數據,可以被多個線程使用。但是每個線程使用時,一旦臨界區資源被一個線程佔有,那麼其他線程必須等待。
- 常見併發問題解決方案
- Java代碼層面
- 使用synchronized
/**
* 使用synchronized
* @param request
* @return
*/
@RequestMapping("/getCountWithSyn")
public BaseResponse<Integer> visitCountWithSynchro(HttpServletRequest request){
synchronized (this){
count++;
}
return new BaseResponse<>(true, count);
}
- 使用Lock
- /** * 使用synchronized * @param request * @return */ @RequestMapping("/getCountWithSyn") public BaseResponse<Integer> visitCountWithSynchro(HttpServletRequest request){ synchronized (this){ count++; } return new BaseResponse<>(true, count); }
- 使用原子操作類
- /** * 使用原子類 * @param request * @return */ @RequestMapping("/getCountWithAct") public BaseResponse<Integer> visitCountWithAct(HttpServletRequest request){ return new BaseResponse<>(true, atomicCount.getAndIncrement()); }
- 數據庫層面
實際項目開發中,經常會遇到併發下單類似的場景,我們以商品購買下單爲例,講解一下如何通過數據庫悲觀鎖樂觀鎖控制併發,本案例有一定的參考性,但其中代碼比較簡單,省去了很多邏輯校驗,遇到類似場景請勿照搬。
所涉及表:
- 商品庫存表:
create table T_STOCK
(
id NUMBER not null,
product_name VARCHAR2(255),
price NUMBER,
num NUMBER,
create_time TIMESTAMP(6),
version NUMBER
);
-- Add comments to the columns
comment on column T_STOCK.id
is 'id';
comment on column T_STOCK.product_name
is '商品名稱';
comment on column T_STOCK.price
is '商品價格';
comment on column T_STOCK.num
is '商品數量';
comment on column T_STOCK.create_time
is '創建時間';
comment on column T_STOCK.version
is '版本號';
- 訂單表:
-- Create table
create table T_ORDER
(
id NUMBER not null,
stock_id NUMBER,
product_name VARCHAR2(255),
num NUMBER,
user_name VARCHAR2(255)
);
-- Add comments to the columns
comment on column T_ORDER.id
is 'id';
comment on column T_ORDER.stock_id
is '庫存id';
- 最Low實現(非線程安全)
日常實現中很容易出現超發或者庫存數量扣爲負數線下,先看下我們初學者很容易實現的方式。
Service層代碼:
@Override
@Transactional
public String dealOrder(String userName, Long num, Long stockId) {
//查詢stock
Stock stock = stockDao.getById(stockId);
if(stock.getNum()>=num){
Order order=new Order();
order.setProductName(stock.getProductName());
order.setUserName(userName);
order.setStockId(stockId);
order.setNum(num);
//保存訂單信息
orderDao.insert(order);
stock.setNum(stock.getNum() - num);
stock.setVersion(stock.getVersion()+1);
//更新庫存信息
stockDao.update(stock);
}
return "success";
}
- 悲觀鎖
- @Override @Transactional public void dealOrderWithPessLock(String userName, Long num, Long stockId) { Stock stock = stockDao.getByIdForUpdate(stockId); if(stock.getNum()>=num){ Order order=new Order(); order.setProductName(stock.getProductName()); order.setUserName(userName); order.setStockId(stockId); order.setNum(num); orderDao.insert(order); stock.setNum(stock.getNum() - num); stock.setVersion(stock.getVersion()+1); stockDao.update(stock); } }
Dao層
@Select("select * from t_stock where id=#{id} for update")
@Results({
@Result(property = "id",column = "id"),
@Result(property = "productName",column = "product_name"),
@Result(property = "price",column = "price"),
@Result(property = "num",column = "num"),
@Result(property = "createTime",column = "create_time"),
@Result(property = "version",column = "version")
})
Stock getByIdForUpdate(Long stockId);
適用場景:
比較適合寫入操作比較頻繁的場景,如果出現大量的讀取操作,每次讀取的時候都會進行加鎖,這樣會增加大量的鎖的開銷,降低了系統的吞吐量。
- 樂觀鎖
Service層:
@Override
@Transactional
public void dealOrderWithOptLock(String userName, Long num, Long stockId) {
Stock stock = stockDao.getById(stockId);
if(stock.getNum()>=num){
Order order=new Order();
order.setProductName(stock.getProductName());
order.setUserName(userName);
order.setStockId(stockId);
order.setNum(num);
stock.setNum(stock.getNum() - num);
int i= stockDao.updateWithVersion(stock);
if(i==1){
orderDao.insert(order);
}
logger.info("更新庫存成功條數:"+i);
}else{
logger.error("庫存不足");
}
}
Dao層:
@Update("update t_stock set num = #{num}, version =version+1 where id = #{id} and version=#{version}")
int updateWithVersion(Stock stock);
適用場景:
比較適合讀取操作比較頻繁的場景,如果出現大量的寫入操作,數據發生衝突的可能性就會增大,爲了保證數據的一致性,應用層需要不斷的重新獲取數據,這樣會增加大量的查詢操作,降低了系統的吞吐量。
- 分佈式鎖層面
我們這裏適用redis 做分佈式鎖控制,這裏僅僅做分佈式鎖,並不做緩存使用。
Service層:
@Override
@Transactional
public void dealOrderWithDisLock(String userName, Long num, Long stockId) {
RedissLockUtil.lock("lock", TimeUnit.SECONDS, 3);
//查詢stock
@Override
@Transactional
public void dealOrderWithDisLock(String userName, Long num, Long stockId) {
RedissLockUtil.lock("lock", TimeUnit.SECONDS, 3);
//查詢stock
Stock stock = stockDao.getById(stockId);
if(stock.getNum()>=num){
Order order=new Order();
order.setProductName(stock.getProductName());
order.setUserName(userName);
order.setStockId(stockId);
order.setNum(num);
//保存訂單信息
orderDao.insert(order);
stock.setNum(stock.getNum() - num);
stock.setVersion(stock.getVersion()+1);
//更新庫存信息
stockDao.update(stock);
logger.info("下單成功:"+userName+"--num:"+num);
}else{
logger.error("庫存不足,下單失敗");
}
RedissLockUtil.unlock("lock");
}
- 性能比較
數據庫悲觀鎖:
數據庫樂觀鎖:
Redis分佈式鎖
- 總結:
在我們日常項目開發中,涉及併發控制的地方一般建議使用數據庫樂觀鎖就可以解決,對於有些場景需要控制插入記錄數量時,可以通過分佈式鎖去解決。