要想db操作的性能足夠高,巧妙的設(shè)計(jì)很重要,事務(wù)的操作范圍要盡量的小。一般情況下我們都是使用某個(gè)orm框架來操作db,這一類框架多數(shù)的實(shí)現(xiàn)方式都是夸網(wǎng)絡(luò)多次交互來開啟事務(wù)上下文和執(zhí)行sql操作,是個(gè)黑盒子,包括對(duì) autocommit 設(shè)置的時(shí)機(jī)也會(huì)有一些差異,稍微不注意就會(huì)踩坑。
在大并發(fā)的情況下加上夸網(wǎng)絡(luò)多次交互,就不可避免的由于網(wǎng)絡(luò)延遲、丟包等原因?qū)е率聞?wù)的執(zhí)行時(shí)間過長(zhǎng),出現(xiàn)雪崩概率會(huì)大大增加。建議在性能和并發(fā)要求比較高的場(chǎng)景下盡量少用orm,如果非要用盡量控制好事務(wù)的范圍和執(zhí)行時(shí)間。
大并發(fā)db操作的原則就是事務(wù)操作盡量少跨網(wǎng)絡(luò)交互,一旦跨網(wǎng)絡(luò)使用事務(wù)盡量用樂觀鎖來解決,少用悲觀鎖,盡量縮短當(dāng)前 session 持有鎖的時(shí)間。
下面分享兩個(gè)在mysql innodb engine 上的大并發(fā)更新行的騷操作,這兩個(gè)騷操作都是盡可能的縮小db鎖的范圍和時(shí)間。
轉(zhuǎn)化update為insert
比較常見的大并發(fā)場(chǎng)景之一就是熱點(diǎn)數(shù)據(jù)的 update,比如具有預(yù)算類的庫(kù)存、賬戶等。
update從原理上需要innodb engine 先獲取row數(shù)據(jù),然后進(jìn)行row format轉(zhuǎn)換到mysql服務(wù)層,再通過mysql服務(wù)器層進(jìn)行數(shù)據(jù)修改,最后再通過innodb engine寫回。
這整個(gè)過程每一個(gè)環(huán)節(jié)都有一定的開銷,首先需要一次innodb查詢,然后需要一次row format(如果row比較寬的話性能損失還是比較大的),最后還需要一次更新和一次寫入,大概需要四個(gè)小階段。
一次update就需要上述四過程開銷。此時(shí)如果qps非常大,必然會(huì)有一定性能開銷(這里暫不考慮cache、mq之類的削峰)。那么我們能不能將單個(gè)行的熱點(diǎn)分散開來,同時(shí)將update轉(zhuǎn)換成insert,我們來看下如何騷操作。
我們引入 slot 概念,原來一個(gè)row 我們通過多個(gè)row來表示,結(jié)果通過sum來匯總。為了不讓slot成為瓶頸,我們 rand slot,然后將update轉(zhuǎn)換成insert,通過 on duplicate key update 子句來解決沖突問題。
我們創(chuàng)建一個(gè)sku庫(kù)存表。
CREATE TABLE tb_sku_stock (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
sku_id bigint(20) NOT NULL,
sku_stock int(11) DEFAULT '0',
slot int(11) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY idx_sku_slot (sku_id,slot),
KEY idx_sku_id (sku_id)
) ENGINE=InnoDB AUTO_INCREMENT=30 DEFAULT CHARSET=utf8mb4
表中唯一性索引 idx_sku_slot 用來約束同一個(gè) sku_id 不同 slot 。
庫(kù)存增加操作和減少操作要分開來處理,我們先看增加操作。
insert into tb_sku_stock (sku_id,sku_stock,slot)
values(101010101, 10, round(rand()9)+1)
on duplicate key update sku_stock=sku_stock+values(sku_stock)
我們給 sku_id=101010101 增加10個(gè)庫(kù)存,通過 round(rand()9)+1 將slot控制在10個(gè)以內(nèi)(可以根據(jù)情況放寬或縮?。?,當(dāng) unique key 不沖突的話就一直是insert,一旦發(fā)生 duplicate 就會(huì)執(zhí)行 update。(update也是分散的)
我們來看下減少庫(kù)存,減少庫(kù)存沒有增加庫(kù)存那么簡(jiǎn)單,最大的問題是要做前置檢查,不能超扣。
我們先看庫(kù)存總數(shù)檢查,比如我們扣減10個(gè)庫(kù)存數(shù)。
select sku_id, sum(sku_stock) as ss
from tb_sku_stock
where sku_id= 101010101
group by sku_id having ss>= 10 for update
mysql的查詢是使用mvcc來實(shí)現(xiàn)無鎖并發(fā),所以為了實(shí)時(shí)一致性我們需要加上for update來做實(shí)時(shí)檢查。
如果庫(kù)存是夠扣減的話我們就執(zhí)行 insert into select 插入操作。
insert into tb_sku_stock (sku_id, sku_stock, slot)
select sku_id,-10 as sku_stock,round(rand() *9+ 1)
from(
select sku_id, sum(sku_stock) as ss
from tb_sku_stock
where sku_id= 101010101
group by sku_id having ss>= 10 for update) as tmp
on duplicate key update sku_stock= sku_stock+ values(sku_stock)
整個(gè)操作都是在一次db交互中執(zhí)行完成,如果控制好單表的數(shù)據(jù)量加上 unique key 配合性能是非常高的。
消除 select...for update
大型OLTP系統(tǒng),都會(huì)有一些需要周期性執(zhí)行的任務(wù),比如定期結(jié)算的訂單、定期取消的協(xié)議等,還有很多兜底的檢查、對(duì)賬程序等都會(huì)檢查一定時(shí)間范圍內(nèi)的狀態(tài)數(shù)據(jù),這些任務(wù)一般都需要掃描表里的某個(gè)狀態(tài)字段。
這些查詢基本基于類似status狀態(tài)字段,由于區(qū)分度非常低,所以索引基本上在這類場(chǎng)景下沒有太大作用。
為了保證掃描出來的數(shù)據(jù)不會(huì)發(fā)生并發(fā)重復(fù)執(zhí)行的問題會(huì)對(duì)數(shù)據(jù)加排他鎖,通常就是 select...for update,那么這部分?jǐn)?shù)據(jù)就不會(huì)被重復(fù)讀取到。但是也就意味著當(dāng)前db線程將block在此鎖上,就是一個(gè)串行操作。
由于是排他鎖,數(shù)據(jù)的 insert、update 都會(huì)受到影響,在 repeatable read (可重復(fù)讀)且沒有 unqiue key 的場(chǎng)合下還會(huì)觸發(fā)Gap lock(間隙鎖)。
我們可以通過一個(gè)方式來消除 select...for update,并且提高數(shù)據(jù)并發(fā)處理能力。
CREATE TABLE tb_order (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
order_id bigint(20) NOT NULL,
order_status int(11) NOT NULL DEFAULT '0',
task_id int(11) DEFAULT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
我們簡(jiǎn)單創(chuàng)建一個(gè)訂單表,task_id 是任務(wù)id,先讓數(shù)據(jù)結(jié)構(gòu)支持多任務(wù)并行。
select order_id from tb_order where order_status=0 limit 10 for update
一般做法是通過select...for update 鎖住行。我們換個(gè)方法實(shí)現(xiàn)同樣的效果同時(shí)不會(huì)存在并發(fā)執(zhí)行問題。
update tb_order set task_id=10 where order_status=0 limit 10;
Query OK, 4 rows affected
select order_id from tb_order where task_id=10 limit 4;
假設(shè)我們當(dāng)前有很多并行任務(wù)(1-10),假設(shè)task_id=10任務(wù)執(zhí)行,先update搶占自己的數(shù)據(jù)行。這個(gè)操作基本上在單數(shù)ms內(nèi),然后再通過select 帶上自己的taskid獲取到屬于當(dāng)前task的行,同時(shí)可以帶上準(zhǔn)確的limit,因?yàn)閡pdate是會(huì)返回受影響行數(shù)。
這里會(huì)有一個(gè)問題,就是執(zhí)行的task如果由于某個(gè)原因終止了怎么辦,簡(jiǎn)單方法就是用一個(gè)兜底job定期檢查超過一定時(shí)間的task,然后將task_id置為空。