一個UPDATE語句引發的血案

BLUICE_ZHEN發表於2018-06-14

原文在我的部落格:blog.zlb37.xyz/2018-06-13_…

今天對資料庫的資料進行清查,發現其中有兩個使用者的資料與交易記錄不一致,其中user0少了200虛擬幣,user多了200虛擬幣。幸好使用者還沒有投訴,趕緊把資料改正確,但是也要研究一下發生的原因。

虛擬幣是可以在網站內部流通的一種產品,允許使用者間買賣和贈送。

和使用者交易相關的有三張表,分別是usersell_ordertransfer_ordertransfer_order記錄了所有虛擬幣轉移記錄,sell_order記錄的是買賣虛擬幣。

查一下sell_order

SELECT order_num, seller, buyer, count, complete_time \
    FROM sell_order \
    WHERE seller=user0 AND buyer=user1 \
        AND complete_time > DATE_ADD(NOW, INTERVAL -1 DAY);
複製程式碼
+-----------+--------+-------+-------+--------------------+
| order_num | seller | buyer | count |complete_time       |
+-----------+--------+-------+----------------------------+
| 233333333 | user0  | user1 | 200   |2018-01-01 12:00:00 |
+-----------+--------+-------+-------+--------------------+
複製程式碼

只有一條記錄,沒有異常發生。而清查資料庫的指令碼也依賴的就是這個表。那麼問題可能出在了transfer_order裡。

SELECT order_num, from, to, count, type, complete_time \
    FROM transfer_order \
    WHERE from=user0 AND to=user1 \
        AND complete_time > DATE_ADD(NOW, INTERVAL -1 DAY);
複製程式碼
+-----------+-------+-------+-------+----------------------------+
| order_num | from  | to    | count | type | complete_time       |
+-----------+-------+-------+-------+----------------------------+
| 666666666 | user0 | user1 | 200   | sell | 2018-01-01 12:00:00 |
+-----------+-------+-------+-------+----------------------------+
| 666666667 | user0 | user1 | 200   | sell | 2018-01-01 12:00:00 |
+-----------+-------+-------+-------+----------------------------+
複製程式碼

這就是說雖然只有一筆賣出訂單,但卻生成了兩筆轉賬訂單,初步判斷是使用者瘋狂點選轉賬按鈕所致,那麼這個BUG就應該隱藏在程式碼裡,還原車禍現場:

def sell_order_complete(order_num, seller, buyer, count):
    try:
        session = DBSession()
        
        session.query(TableSellOder) \
            .filter(TableSellOder.order_num == order_num) \
            .update({TableSellOder.complete_time == now(), TableSellOder.status: 'Success'})
            
        session.query(TableUser) \
            .filter(TableUser.id = seller) \
            .update({TableUser.count: TableUser.count - count})
            
        session.query(TableUser) \
            .filter(TableUser.id = buyer) \
            .update({TableUser.count: TableUser.count + count})
            
        session.add(TableTrandferOrder(order_num, seller, buyer, count, "sell"))
        
    except Exception:
        session.rollback()
        rasie_http_error(500)
複製程式碼

在示例程式碼裡使用了Python和SQLAlchemy,此程式碼並非真實程式碼

看上去還算正常,把所有資料庫操作放到了一個事務裡,一旦出現異常回滾。但是SQL UPDATE語句不管有沒有真的修改了語句,都不會報異常。當使用者瘋狂執行這個函式時,雖然表sell_order中的資料已經修改,但是使用相同的引數,這個函式可以無限執行,直到賣家的餘額不足為止。

所以在執行SQL UPDATE語句時,需要檢測SQL UPDATE語句執行的時影響的行數是否為預期的行數,如果不是預期的行數,需要手動出發異常,不再執行。

這是一場深刻的教訓,修改後的程式碼如下:

def sell_order_complete(order_num, seller, buyer, count):
    try:
        session = DBSession()
        
        assert session.query(TableSellOder) \
            .filter(TableSellOder.order_num == order_num) \
            .update({TableSellOder.complete_time == now(), TableSellOder.status: 'Success'})   \
            == 1
            
        assert session.query(TableUser) \
            .filter(TableUser.id = seller) \
            .update({TableUser.count: TableUser.count - count}) \
            == 1
            
        assert session.query(TableUser) \
            .filter(TableUser.id = buyer) \
            .update({TableUser.count: TableUser.count + count}) \
            == 1
            
        session.add(TableTrandferOrder(order_num, seller, buyer, count, "sell"))
        
    except Exception:
        session.rollback()
        rasie_http_error(500)
複製程式碼

相關文章