項目中的併發問題(1)

控制併發的方法不少,從最基礎的synchronized,juc中的lock,到數據庫的行級鎖,樂觀鎖,悲觀鎖,再到中間件級別的redis,zookeeper分佈式鎖。特別是初級程序員,對於所謂的鎖一直都是聽的比用的多,第一篇文章不深刻探討併發,更多的是一個入門介紹,適合於初學者,主題是「根據併發出現的具體業務場景,使用合理的控制併發手段」。前端

什麼是併發

由一個你們都瞭解的例子引入咱們今天的主題:併發java

類共享變量遭遇併發

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Demo {
     public Integer count = 0 ;
     public static void main(String[] args) {
         final Demo demo = new Demo();
         Executor executor = Executors.newFixedThreadPool( 10 );
         for ( int i= 0 ;i< 1000 ;i++){
             executor.execute( new Runnable() {
                 @Override
                 public void run() {
                     demo.count++;
                 }
             });
         }
         try {
             Thread.sleep( 5000 );
         } catch (InterruptedException e) {
             e.printStackTrace();
         }
         System.out.println( "final count value:" +demo1.count);
     }
}

final count value:973mysql

本例中建立了一個初始化時具備10個線程的線程池,多線程對類變量count進行自增操做。這個過程當中,自增操做並非線程安全的,happens-before原則並不會保障多個線程執行的前後順序,致使了最終結果並非想要的1000程序員

下面,咱們把併發中的共享資源從類變量轉移到數據庫中。redis

充血模型遭遇併發

1
2
3
4
5
6
7
8
9
10
11
@Component
public class Demo2 {
     @Autowired
     TestNumDao testNumDao;
     @Transactional
     public void test(){
         TestNum testNum = testNumDao.findOne( "1" );
         testNum.setCount(testNum.getCount()+ 1 );
         testNumDao.save(testNum);
     }
}

依舊使用多線程,對數據庫中的記錄進行+1操做spring

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Demo2 demo2;
 
public String test(){
     Executor executor = Executors.newFixedThreadPool( 10 );
     for ( int i= 0 ;i< 1000 ;i++){
         executor.execute( new Runnable() {
             @Override
             public void run() {
                 demo2.test();
             }
         });
     }
     return "test" ;
}

數據庫的記錄sql

1
2
id  | count
1   | 344

初窺門徑的程序員會認爲事務最基本的ACID中便包含了原子性,可是事務的原子性和今天所講的併發中的原子操做僅僅是名詞上有點相似。而有點經驗的程序員都能知道這中間發生了什麼,這只是暴露了項目中併發問題的冰山一角,千萬不要認爲上面的代碼沒有必要列舉出來,我在實際項目開發中,曾經見到有多年工做經驗的程序員仍然寫出了相似於上述會出現併發問題的代碼。數據庫

貧血模型遭遇併發

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@RequestMapping ( "testSql" )
     @ResponseBody
     public String testSql() throws InterruptedException {
         final CountDownLatch countDownLatch = new CountDownLatch( 1000 );
         long start = System.currentTimeMillis();
         Executor executor = Executors.newFixedThreadPool( 10 );
         for ( int i= 0 ;i< 1000 ;i++){
             executor.execute( new Runnable() {
                 @Override
                 public void run() {
                     jdbcTemplate.execute( "update test_num set count = count + 1 where id = '1'" );
                     countDownLatch.countDown();
                 }
             });
         }
         countDownLatch.await();
         long costTime =System.currentTimeMillis() - start;
         System.out.println( "共花費:" +costTime+ " s" );
         return "testSql" ;
     }

數據庫結果: count : 1000 達到了預期效果
這個例子我順便記錄了耗時,控制檯打印:共花費:113 ms安全

簡單對比一下二,三兩個例子,都是想對數據庫的count進行+1操做,惟一的區別就是,後者的+1計算髮生在數據庫,而前者的計算依賴於事先查出來的值,而且計算髮生在程序的內存中。而如今大部分的ORM框架,致使了寫充血模型的程序員變多,不注意併發的話,就會出現問題。下面咱們來看看具體的業務場景。(須要知道數據庫的行鎖)多線程

業務場景

  • 修改我的信息
  • 修改商品信息
  • 扣除帳戶餘額,扣減庫存

業務場景分析

第一個場景,互聯網如此衆多的用戶修改我的信息,這算不算併發?答案是:算也不算。

  • 算,從程序員角度來看,每個用戶請求進來,都是調用的同一個修改入口,具體一點,就是映射到controller層的同一個requestMapping,因此必定是併發的。
  • 不算,雖然程序是併發的,可是從用戶角度來分析,每一個人只能夠修改本身的信息,因此,不一樣用戶的操做實際上是隔離的,因此不算「併發」。這也是爲何不少開發者,在平常開發中一直不注意併發控制,卻也沒有發生太大問題的緣由,大多數初級程序員開發的還都是CRM,OA,CMS系統。

回到咱們的併發,第一種業務場景,是可使用如上模式的,對於一條用戶數據的修改,咱們容許程序員讀取數據到內存中,內存計算修改(耗時操做),提交更改,提交事務。

1
2
3
4
5
6
7
//Transaction start
User user = userDao.findById( "1" );
user.setName( "newName" );
user.setAge(user.getAge()+ 1 );
... //其餘耗時操做
userDao.save(user);
//Transaction commit

這個場景變現爲:幾乎不存在併發,不須要控制,場景樂觀。

爲了嚴謹,也能夠選擇控制併發,但我以爲這須要交給寫這段代碼的同事,讓他自由發揮。

第二個場景已經有所不一樣了,一樣是修改一個記錄,可是系統中可能有多個操做員來維護,此時,商品數據表現爲一個共享數據,因此存在微弱的併發,一般表現爲數據的髒讀,例如操做員A,B同時對一個商品信息維護,咱們但願只能有一個操做員修改爲功,另一個操做員獲得錯誤提示(該商品信息已經發生變化),不然,兩我的都覺得本身修改爲功了,可是其實只有一我的完成了操做,另外一我的的操做被覆蓋了。

這個場景表現爲:存在併發,須要控制,容許失敗,場景樂觀。

一般我建議這種場景使用樂觀鎖,即在商品屬性添加一個version字段標記修改的版本,這樣兩個操做員拿到同一個版本號,第一個操做員修改爲功後版本號變化,另外一個操做員的修改就會失敗了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Goods{
     @Version
     int version;
}
//Transaction start
try {
     Goods goods = goodsDao.findById( "1" );
     goods.setName( "newName" );
     goods.setPrice(goods.getPrice()+ 100.00 );
     ... //其餘耗時操做
     goodsDao.save(goods);
} catch (org.hibernate.StaleObjectStateException e){
     //返回給前臺
}
//Transaction commit

springdata配合jpa能夠自動捕獲version異常,也能夠自動手動對比。

第三個場景
這個場景表現爲:存在頻繁的併發,須要控制,不容許失敗,場景悲觀。

強調一下,本例不該該使用在項目中,只是爲了舉例而設置的一個場景,由於這種貧血模型沒法知足複雜的業務場景,並且依靠單機事務來保證一致性,併發性能和可擴展性能很差。

一個簡易的秒殺場景,大量請求在短期涌入,是不可能像第二種場景同樣,100個併發請求,一個成功,其餘99個所有異常的。

設計方案應該達到的效果是:有足夠庫存時,容許併發,庫存到0時,以後的請求所有失敗;有足夠金額時,容許併發,金額不夠支付時馬上告知餘額不足。

能夠利用數據庫的行級鎖,
update set balance = balance – money where userId = ? and balance >= money;
update stock = stock – number where goodsId = ? and stock >= number ; 而後在後臺 查看返回值是否影響行數爲1,判斷請求是否成功,利用數據庫保證併發。

須要補充一點,我這裏所講的秒殺,並非指雙11那種級別的秒殺,那須要多層架構去控制併發,前端攔截,負載均衡….不能僅僅依賴於數據庫的,會致使嚴重的性能問題。爲了留一下一個直觀的感覺,這裏對比一下oracle,mysql的兩個主流存儲引擎:innodb,myisam的性能問題。

1
2
3
4
5
6
oracle:
10000 個線程共計 1000000 次併發請求:共花費: 101017 ms =>101s
innodb:
10000 個線程共計 1000000 次併發請求:共花費: 550330 ms =>550s
myisam:
10000 個線程共計 1000000 次併發請求:共花費: 75802 ms =>75s

可見,若是真正有大量請求到達數據庫,光是依靠數據庫解決併發是不現實的,因此僅僅只用數據庫來作保障而不是徹底依賴。須要根據業務場景選擇合適的控制併發手段。

相關文章
相關標籤/搜索