日常工作中最容易犯的幾個併發錯誤

前言

列舉大家平時在工作中最容易犯的幾個併發錯誤,都是在實際項目代碼中看到的鮮活例子,希望對大家有幫助。

First Blood

線上總是出現:ERROR 1062 (23000) Duplicate entry 'xxx' for key 'yyy',我們來看一下有問題的這段代碼:

UserBindInfo info = selectFromDB(userId);
if(info == null){
    info = new UserBindInfo(userId,deviceId);
    insertIntoDB(info);
}else{
    info.setDeviceId(deviceId);
    updateDB(info);
    }

併發情況下,第一步判斷都爲空,就會有2個或者多個線程進入插入數據庫操作,這時候就出現了同一個ID插入多次。

正確處理姿勢:

insert into UserBindInfo values(#{userId},#{deviceId}) on duplicate key update deviceId=#{deviceId}多次的情況,導致插入失敗。

一般情況下,可以用insert...on duplicate key update... 解決這個問題。

注意: 如果UserBindInfo表存在主鍵以及一個以上的唯一索引,在併發情況下,使用insert...on duplicate key,可能會產生死鎖(Mysql5.7),可以這樣處理:

try{
   UserBindInfoMapper.insertIntoDB(userBindInfo);
}catch(DuplicateKeyException ex){
    UserBindInfoMapper.update(userBindInfo);
}

Double Kill

小心你的全局變量,如下面這段代碼:

public class GlobalVariableConcurrentTest {
    private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    public static void main(String[] args) throws InterruptedException {
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(10, 100, 1, TimeUnit.MINUTES, new LinkedBlockingQueue<>(1000));
        while (true){
            threadPoolExecutor.execute(()->{
                String dateString = sdf.format(new Date());
                try {
                    Date parseDate = sdf.parse(dateString);
                    String dateString2 = sdf.format(parseDate);
                    System.out.println(dateString.equals(dateString2));
                } catch (ParseException e) {
                    e.printStackTrace();
                }
            });
        }
    }
}

可以看到有異常拋出

全局變量的SimpleDateFormat,在併發情況下,存在安全性問題,阿里Java規約明確要求謹慎使用它。

除了SimpleDateFormat,其實很多時候,面對全局變量,我們都需要考慮併發情況是否存在問題,如下

@Component
public class Test {
    public static List<String> desc = new ArrayList<>();
    public List<String> getDescByUserType(int userType) {
        if (userType == 1) {
            desc.add("普通會員不可以發送和查看郵件,請購買會員");
            return desc;
        } else if (userType == 2) {
            desc.add("恭喜你已經是VIP會員,盡情的發郵件吧");
            return desc;
        }else {
            desc.add("你的身份未知");
            return desc;
        }
    }
}

因爲desc是全局變量,在併發情況下,請求getDescByUserType方法,得到的可能並不是你想要的結果。

Trible Kill

假設現在有如下業務:控制同一個用戶訪問某個接口的頻率不能小於5秒。一般很容易想到使用redis的 setnx操作來控制併發訪問,於是有以下代碼:

if(RedisOperation.setnx(userId, 1)){
    RedisOperation.expire(userId,5,TimeUnit.SECONDS));
    //執行正常業務邏輯
}else{
    return “訪問過於頻繁”;
}

假設執行完setnx操作,還沒來得及設置expireTime,機器重啓或者突然崩潰,將會發生死鎖。該用戶id,後面執行setnx永遠將爲false,這可能讓你永遠損失那個用戶

那麼怎麼解決這個問題呢,可以考慮用SET key value NX EX max-lock-time ,它是一種在 Redis 中實現鎖的方法,是原子性操作,不會像以上代碼分兩步執行,先set再expire,它是一步到位

客戶端執行以上的命令:

  • 如果服務器返回 OK ,那麼這個客戶端獲得鎖。

  • 如果服務器返回 NIL ,那麼客戶端獲取鎖失敗,可以在稍後再重試。

  • 設置的過期時間到達之後,鎖將自動釋放

Quadra Kill

我們看一下有關ConcurrentHashMap的一段代碼,如下:

//全局變量
Map<String, Integer> map = new ConcurrentHashMap(); 
Integer value = count.get(k);
if(value == null){
       map.put(k,1);
}else{
    map.put(k,value+1);
}

假設兩條線程都進入 value==null,這一步,得出的結果是不是會變小?OK,客官先稍作休息,閉目養神一會,我們驗證一下,請看一個demo:

  public static void main(String[] args)  {
        for (int i = 0; i < 1000; i++) {
            testConcurrentMap();
        }
    }
    private static void testConcurrentMap() {
        final Map<String, Integer> count = new ConcurrentHashMap<>();
        ExecutorService executorService = Executors.newFixedThreadPool(2);
        final CountDownLatch endLatch = new CountDownLatch(2);
        Runnable task = ()->  {
                for (int i = 0; i < 5; i++) {
                    Integer value = count.get("k");
                    if (null == value) {
                        System.out.println(Thread.currentThread().getName());
                        count.put("k", 1);
                    } else {
                        count.put("k", value + 1);
                    }
                }
                endLatch.countDown();
        };
        executorService.execute(task);
        executorService.execute(task);
        try {
            endLatch.await();
            if (count.get("k") < 10) {
                System.out.println(count);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }

表面看,運行結果應該都是10對吧,好的,我們再看運行結果 :

運行結果出現了5,所以這樣實現是有併發問題的,那麼正確的實現姿勢是啥呢?

Map<K,V> map = new ConcurrentHashMap(); 
V v = map.get(k);
if(v == null){
        V v = new V();
        V old = map. putIfAbsent(k,v);
        if(old != null){
                  v = old;
        }
}

可以考慮使用putIfAbsent解決這個問題

(1)如果key是新的記錄,那麼會向map中添加該鍵值對,並返回null。

(2)如果key已經存在,那麼不會覆蓋已有的值,返回已經存在的值

我們再來看看以下代碼以及運行結果:

 public static void main(String[] args)  {
        for (int i = 0; i < 1000; i++) {
            testConcurrentMap();
        }
    }
    private static void testConcurrentMap() {
        ExecutorService executorService = Executors.newFixedThreadPool(2);
        final Map<String, AtomicInteger> map = Maps.newConcurrentMap();
        final CountDownLatch countDownLatch = new CountDownLatch(2);
        Runnable task = ()->  {
                AtomicInteger oldValue;
                for (int i = 0; i < 5; i++) {
                    oldValue = map.get("k");
                    if (null == oldValue) {
                        AtomicInteger initValue = new AtomicInteger(0);
                        oldValue = map.putIfAbsent("k", initValue);
                        if (oldValue == null) {
                            oldValue = initValue;
                        }
                    }
                    oldValue.incrementAndGet();
                }
            countDownLatch.countDown();
        };
        executorService.execute(task);
        executorService.execute(task);
        try {
            countDownLatch.await();
            System.out.println(map);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

Penta Kill

現有如下業務場景:用戶手上有一張現金券,可以兌換相應的現金,

錯誤示範一

if(isAvailable(ticketId){
    1、給現金增加操作
    2、deleteTicketById(ticketId)
}else{
    return “沒有可用現金券”
}

解析: 假設有兩條線程A,B兌換現金,執行順序如下:

  • 1.線程A加現金

  • 2.線程B加現金

  • 3.線程A刪除票標誌

  • 4.線程B刪除票標誌

顯然,這樣有問題了,已經給用戶加了兩次現金了

錯誤示範2

if(isAvailable(ticketId){
    1、deleteTicketById(ticketId)
    2、給現金增加操作
}else{
    return “沒有可用現金券”
}

併發情況下,如果一條線程,第一步deleteTicketById刪除失敗了,也會多添加現金。

正確處理方案

if(deleteAvailableTicketById(ticketId) == 1){
    1、給現金增加操作
}else{
    return “沒有可用現金券”
}

個人公衆號

  • 如果你是個愛學習的好孩子,可以關注我公衆號,一起學習討論。

  • 如果你覺得本文有哪些不正確的地方,可以評論,也可以關注我公衆號,私聊我,大家一起學習進步哈。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章