擼一個月餅預訂系統(一)

中秋前某日,夜黑風高,工程師小A正準備下班,忽然被加急: 中秋要發月餅,作個系統讓員工預訂一下吧。一人只能預訂兩個,杏仁的一共50個,黃桃的有100個。html

這是一家傳統軟件公司,公司CTO有一些技術偏執,堅信MySQL大法好,因此這家公司數據庫只有 MySQLmysql

畢竟小A是久經沙場的CRUD工程師,這種小業務對於小A而言就好像單手開法拉利同樣輕鬆。git

第一步,建表!

  1. 月餅餘量表 mooncake
id name capacity
1 杏仁月餅 50
2 黃桃月餅 100
  1. 月餅領取表 people_mooncake_ref
id user_id mooncake_id create_time
1 131 1 2019/9/10 22:22:00
2 231 1 2019/9/10 22:22:00

第二步,擼馬!

小A猶如雷布斯附體,三下五除二,擼出了預訂的代碼。github

func orderMoon(moonId int64){
    userId = passport.GetUserID()
    ordered = db.query("select count(1) from people_mooncake_ref where userId = ?",userId)
    Assert (ordered < 2, throw Exception("超出最大預訂數量"))
    moon = db.query("select * from mooncake where id = ?", moonId)
    Assert (moon != nil, throw Exception("月餅不存在"))
    Assert (moon.capacity >= 1, throw Exception("月餅餘量不足")
    db.execute("update mooncake set capacity = capacity-1 where moonId = ?",moonId)
    db.insert("insert into people_mooncake_ref values(nill,?,?)",userId,moonId) 
}
複製代碼

寫完,小A看了看時間,纔過去30秒。恩,一次debug,完美運行,美滋滋。正準備回家,忽然想起來公司摳門的程度,暗暗心想:golang

  • 要是月餅發多了,還不得被老闆砍死。
  • 要是月餅發少了,XXXX委員會還不得說我侵吞公司財物?
  • 要是哪位員工預訂不上,知道是誰寫的,360還不得被瘋狂diss。
  • 要是哪位員工預訂多了,再整個月餅事件,豈不是背大鍋。

汗水順着小A的額頭滴下來,小A趕忙掏出墊顯示器的《高性能Mysql》,仔細研讀。恩,果真有問題。sql

1.月餅訂不完的BUG

一旦第9行代碼執行失敗了( 網絡錯誤 / 鏈接超時 / 機器掉電 ... ),月餅就永遠被少定一個了。 久經沙場的小A一眼看出了問題,兩個寫操做,沒有事務。Easy,加!數據庫

加事務!

func orderMoon(moonId int64){
    userId = passport.GetUserID()
    ordered = db.query("select count(1) from people_mooncake_ref where userId = ?",userId)
    Assert (ordered < 2 , throw Exception("超出最大預訂數量"))
    moon = db.query("select * from mooncake where id = ?", moonId)
    Assert (moon != nil, throw Exception("月餅不存在"))
    Assert (moon.capacity >= 1, throw Exception("月餅餘量不足")
    db.begin()
    db.execute("update mooncake set capacity = capacity-1 where id = ?",moonId)
    db.insert("insert into people_mooncake_ref values(nill,?,?,now())",userId,moonId)
    db.commit()
}
複製代碼

小A愉快地回家了,中秋,卒。後來小B接手了小A的代碼。做爲一名更資深的CRUD工程師,一眼看出了問題。性能優化

2.月餅餘量被訂到負數的BUG

先查,再改,有先後依賴。在並行的狀況下,會出現月餅被扣爲負數的狀況。這是一類典型的問題,先查詢進行判斷,而後去修改數據,因爲是非原子操做,在並行的狀況下會出現非預期結果。網絡

加鎖!

加鎖無非,要麼悲觀鎖,要麼樂觀鎖。要麼可重入,要麼不可重入。要麼公平,要麼非公平。架構

悲觀鎖
func orderMoon(moonId int64){
    userId = passport.GetUserID()
    db.begin() // 上移事務開始時機,讓X鎖對整個事務生效
    ordered = db.query("select count(1) from people_mooncake_ref where userId = ?",userId)
    Assert (ordered < 2, throw Exception("超出最大預訂數量"))
    moon = db.query("select * from mooncake where id = ? for update", moonId) // X鎖,由於where是主鍵,鎖行
    Assert (moon != nil, throw Exception("月餅不存在"))
    Assert (moon.capacity >= 1, throw Exception("月餅餘量不足")
    db.execute("update mooncake set capacity = capacity-1 where id = ?",moonId)
    db.insert("insert into people_mooncake_ref values(nill,?,?,now())",userId,moonId)
    db.commit()
}
複製代碼
樂觀鎖
func orderMoon(moonId int64){
    userId = passport.GetUserID()
    ordered = db.query("select count(1) from people_mooncake_ref where userId = ?",userId)
    Assert (ordered < 2, throw Exception("超出最大預訂數量"))
    db.begin()
    effectiveLine = db.execute("update mooncake set capacity = capacity-1 where id = ? and capacity > 1",moonId) // 根據生效行數,判斷是否更新成功
    Assert (moon.capacity != 0, throw Exception("月餅餘量不足")
    db.insert("insert into people_mooncake_ref values(nill,?,?,now())",userId,moonId)
    db.commit()
}
複製代碼

這種方式用RDBMS原生的MVCC來進行自旋,更優雅,高效地解決了這個問題。 其它辦法 固然,這種問題還會有不少其它的解決方案,例如:

  • 代碼裏面加同步: 因爲部署了兩臺機器,卒
  • 修改mysql事務隔離級別爲serialized: 因爲系統太慢,被員工XX圈吐槽,卒

3.員工預訂超過兩個月餅的BUG

小B驚奇地發現,雖然解決了超賣的問題,可是同一個員工可能會定超過兩個月餅。小E就由於寫了個腳本,一下預約了10個月餅,被公司XXX委員會開除。小B深感自責,決定完全修復這個問題。 先檢查,再插入。一樣會由於非原子操做,而致使檢查操做失效。

鎖表!

因爲insert與update不一樣,無法把鎖加在現有的數據行上面,要解決這個問題,只能尋求其它粒度的鎖,好比:鎖表。鎖表有兩種方法。

  • 顯式鎖表: lock table 'people_mooncake_ref' Read/Write。
  • 隱式鎖表: 當for update查詢的時候沒用上索引,就會鎖表。
func orderMoon(moonId int64){
    userId = passport.GetUserID()
    db.begin()
    db.execute("lock table `people_mooncake_ref` WRITE")
    ordered = db.query("select count(1) from people_mooncake_ref where userId = ?",userId)
    Assert (ordered == 0, throw Exception("請勿重複預訂"))
    effectiveLine = db.execute("update mooncake set capacity = capacity-1 where id = ? and capacity > 1",moonId) // 根據生效行數,判斷是否更新成功
    Assert (effectiveLine < 1, throw Exception("月餅餘量不足")
    db.insert("insert into people_mooncake_ref values(nill,?,?,now())",userId,moonId)
    db.execute("unlock tables")
    db.commit()
}
複製代碼

特別要注意的是,當一個事務須要多處鎖的時候,要先加上大粒度的鎖。先加鎖的,後釋放。不然會發生死鎖的狀況。本覺得故事到這裏就圓滿結束了。 小B開開心心寫完,結果次日早會被DBA打進醫院: 你竟然故意加表鎖!!!

惟一索引法!

小B能夠經過添加惟一索引,經過記錄的惟一性來限制員工預訂超過兩個月餅。修改預訂表,增長orderNum字段。

/* 添加順序號 */ ALTER TABLE `people_mooncake_ref` ADD `order_num` INT  NULL  DEFAULT NULL  COMMENT '順序號'  AFTER `people_id`; 
/* 添加惟一索引 */ ALTER TABLE `people_mooncake_ref` ADD UNIQUE INDEX `ORDER_UNION` (`people_id`, `order_num`); 
people_mooncake_ref表:
複製代碼
func orderMoon(moonId int64){
    userId = passport.GetUserID()
    orderNum = db.query("select case when max(order_num) is null then 1 else max(order_num) from people_mooncake_ref where userId = ?",userId)
    Assert (orderNum < 2, throw Exception("超出最大可預訂數量"))
    db.begin()
    effectiveLine = db.execute("update mooncake set capacity = capacity-1 where id = ? and capacity > 1",moonId) // 根據生效行數,判斷是否更新成功
    Assert (effectiveLine < 1, throw Exception("月餅餘量不足")
    effectiveLine = db.insert("insert IGNORE into people_mooncake_ref values(nill,?,?,?,now())",userId,orderNum+1,moonId) // 根據生效行數決定是否成功
    Assert (effectiveLine != 0, throw Exception("系統繁忙,請稍後再試"))
    db.commit()
}
複製代碼

其它方法 固然,這種問題還會有不少其它的解決方案,例如:

  • 代碼裏面加同步: 同上
  • 修改mysql事務隔離級別爲serialized: 同上
  • 某些RDBMS能夠經過insert where子句實現樂觀鎖,mysql不支持

尾章

小B長長地舒了一口氣,不再會由於系統有問題被各類diss了,把系統扔一邊開始刷leetcode。可是畢竟仍是太年輕,惟一不變的就是變化,公司來了一個新的CTO,CTO一看系統架構急了,全公司上下開始了一場轟轟烈烈技術革新的運動,堪比福報廠去IOE。

相關文檔

相關文章
相關標籤/搜索