并發是一個讓人很頭疼的問題,通常會在服務端或資料庫端做處理,保證在并發下資料的準确性。
為此,簡要讨論一下,如何通過解決全局生成唯一編号的并發問題。
1 MySQL資料庫的鎖
1-0 鎖的分類
按鎖定的資料粒度
表級鎖
頁級鎖
行級鎖
按鎖定的方式
共享鎖
排他鎖
讀鎖 := 共享鎖(shared Lock) / 寫鎖:= 排他鎖(exclusive Lock)
1-1 讀鎖/共享鎖
1-1-1 定義
事務A 使用共享鎖 擷取了某條(或者某些)記錄時:
事務B 可以讀取這些記錄; 可以繼續添加共享鎖,但是不能修改或删除這些記錄;
否則,當事務B 對這些資料修改或删除時,會進入阻塞狀态,直至:鎖等待逾時或者事務A送出
1-1-2 用法
[表級讀鎖]
SET AUTOCOMMIT=0;
LOCK TABLES tb_student READ, tb_student READ, ...; -- 加鎖
[do something with tables tb_student and tb_student , ... here];
COMMIT;
UNLOCK TABLES; -- 解鎖
[行級讀鎖]
SELECT * FROM table_name WHERE ... LOCK IN SHARE MODE;
1-1-3 注意事項
當使用讀鎖時,避免産生如下操作
[事務1]
BEGIN;
select * from sys_user where id = 1 LOCK IN SHARE MODE; (步驟1)
update sys_user set username = "taven" where id = 1; (步驟3,發生阻塞)
COMMIT;
[事務2]
BEGIN;
select * from sys_user where id = 1 LOCK IN SHARE MODE; (步驟2)
update sys_user set username = "taven" where id = 1; (步驟4,死鎖)
COMMIT;
根據我們之前對讀鎖定義可知:
當有事務拿到一個結果集的讀鎖時: 其他事務想要更新該結果集,需要拿到讀鎖的事務送出(釋放鎖)。
而上述情況2個事務分别拿到了讀鎖,且都有update 操作,2個事務互相等待造成死鎖(2個事務都在等待對方釋放讀鎖)
1-1-4 使用場景
1) 讀取結果集的最新版本,同時防止其他事務産生更新該結果集。
主要用在需要資料依存關系時,确認某行記錄是否存在,并確定沒有其它人對這個記錄進行UPDATE或者DELETE操作
1-2 寫鎖/排他鎖
1-2-1 定義
1個寫鎖會阻塞其他的讀鎖和寫鎖。
事務A 對某些記錄添加寫鎖時:
事務B 無法向這些記錄添加寫鎖或讀鎖;(注:未添加鎖的資料,是可以讀取的)
事務B 也無法執行對 鎖住的資料進行update / delete操作
1-2-2 使用場景
讀取結果集的最新版本,同時防止其他事務産生讀取或者更新該結果集。
例如:并發下對商品庫存的操作
1-2-3 注意事項
在使用讀鎖、寫鎖時,都需要注意,讀鎖、寫鎖屬于行級鎖。
即 事務1 對商品A 擷取寫鎖,和事務2 對商品B 擷取寫鎖互相不會阻塞的。
需要我們注意的是:我們的SQL要合理使用索引,當我們的SQL全表掃描的時候,行級鎖會變成表鎖。
使用EXPLAIN檢視 SQL是否使用了索引,掃描了多少行。
1-2-4 用法
SELECT * FROM table_name WHERE ... FOR UPDATE
1-3 樂觀鎖(非資料庫鎖的邏輯鎖) [推薦]
上述介紹的是行級鎖,可以最大程度地支援并發處理(同時也帶來了最大的鎖開銷)
樂觀鎖是一種邏輯鎖,通過資料的版本号(vesion)的機制來實作,極大降低了資料庫的性能開銷。
(此處的版本号僅僅是對邏輯标記字段一種代稱)
我們為表添加1個字段 version;
讀取資料行時,将此版本号一同讀出;
更新資料行時:
修改此資料行所在的version: version = version + 1
同時,将送出資料的 version 與資料庫中對應記錄的目前 version 進行比對:
如果送出的資料版本号>資料庫表目前版本号,則:予以更新;否則,認為是過期資料
update t_goods
set status=2,version=version+1
where id=#{id} and version < #{version}; // 更新前将 代碼的#{version} 自增
-- SQL: 先查詢(where), 後修改(update)
或者
update t_goods
set status=2,version=version+1
where id=#{id} and version = #{version}; // 更新前 代碼的#{version} 不自增
-- SQL: 先查詢(where), 後修改(update)
2 解決方案
2-1 方案1: [伺服器端] synchronize(鎖 方法或鎖對象) + Java事務(鎖定業務流程原子性) [推薦]
@Transnational
public synchronize generateOrderCode(){
...
}
2-2 方案2: 資料庫【寫鎖/排他鎖】(鎖定多線程/程序的共享資料) + Java事務(鎖定業務流程原子性)
維護資料庫内1張存儲最大值的表,多行;
每一行為過去所使用的曆史最大值的記錄,最後插入的一行為目前最新最大值所在行。
Thread1 (Lock) 讀取表内目标字段最大值Max前,加寫鎖;(此後,在Thread1未釋放寫鎖前,其它Thread将讀取目标字段最大值失敗)
Thread1 (Query) 讀取表内目标字段最大值Max;
Thread1 (Insert) 插入新增的值————Max=Max+1;
Thread1 (UnLock) 釋放寫鎖;
2-3 方案3:資料庫【樂觀鎖】(鎖定多線程/程序的共享資料) + Java事務(鎖定業務流程原子性)
類似 方案1,需要差別2點: 1)資料庫的鎖的變化;2) 維護資料庫的行的意義/行數的變化
維護資料庫内1張存儲最大值的表,1行;
字段等于目标編号業務屬性的字段所在的行 即為目前最大值的所在行記錄;其它行 為其它編号業務屬性的字段所在的行。
2-4 方案4:Redis [推薦]
基于redis單線程的特點,生成全局唯一id,redis性能高,支援叢集分片。
亦可實作分布式鎖
[核心代碼]
思路:日期(yyyyMMddHHmmss)+redis原子生成的數字(不足6位前面補0) / 類似:20191206221953000001
理論上6位字尾支援每秒最多生成999999個訂單号,具體可以根據業務調整日期格式或日期後面的位數。
核心在于對象RedisAtomicLong (可以想下juc包下的AtomicLong),它對于同一個key會一直自增生成數字,這裡我設定的key過期時間為20s,減輕redis的壓力。
@Resource
private RedisTemplate redisTemplate;
public long generate(String key,Date expireTime) {
RedisAtomicLong counter = new RedisAtomicLong(key, redisTemplate.getConnectionFactory());
Long expire = counter.getExpire();
if(expire==-1){
counter.expireAt(expireTime);
}
return counter.incrementAndGet();
}
public String generateOrderId() {//生成id為目前日期(yyMMddHHmmss)+6位(從000000開始不足位數補0)
LocalDateTime now = LocalDateTime.now();
String orderIdPrefix = getOrderIdPrefix(now);//生成yyyyMMddHHmmss
String orderId = orderIdPrefix+String.format("%1$06d", generate(orderIdPrefix,getExpireAtTime(now)));
return orderId;
}
public static String getOrderIdPrefix(LocalDateTime now){
return now.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
}
public Date getExpireAtTime(LocalDateTime now){
ZoneId zoneId = ZoneId.systemDefault();
LocalDateTime localDateTime = now.plusSeconds(20);
ZonedDateTime zdt = localDateTime.atZone(zoneId);
Date date = Date.from(zdt.toInstant());
return date;
}
可讀性好,不能太長。一般訂單都是全數字的。可使用redis的incr指令生成訂單号。
優點:可讀性好,不會重複
缺點:需要搭建redis伺服器
2-5 方案5:UUID 或 雪花算法
uuid生成全球唯一id,生成方式簡單粗暴;常用于生成token令牌。
基于雪花算法snowflake 生成全局id,本地生成,沒有網絡開銷,效率高,但是依賴機器時鐘。
個人建議: 存儲最新值到資料庫前,檢查此值是否已存儲,防止極小機率事件發生
優點:簡單,很難很難很難重複(機率極小),本地生成,沒有網絡開銷,效率高;
缺點:長度較長;沒有遞增趨勢性;可讀性差;不易維護;
2-6 方案6:MySQL的ID自動增長
mysql自帶自增生成id,oracle可以用序列生成id,但在資料庫叢集環境下,擴充性不好。
優點:不需要我們自己生成訂單号,mysql會自動生成。
缺點:如果訂單表數量太大時需要分庫分表,此時訂單号會重複。如果資料備份後再恢複,訂單号會變。
2-7 方案7: 日期+随機數
采用毫秒+随機數。
缺點:仍然有重複的可能。不建議采用此方案。在沒有更好的解決方案之前可以使用。
X 參考與推薦文獻