對Oracle資料庫表加行鎖控制併發時重複交易
最近遇到一個比較棘手的問題,交易時出現重複交易,並且這個問題是偶爾才出現,公司的產品主要是針對餐飲行業的CRM管理系統,類似於開卡,做消費獎勵活動等 ,一天的交易量大,商戶有幾百家,門店數千個,至於為什麼為出現重複交易,雖然在程式裡面已經控制了是否重複提交的限制(也就是根據transId去查是否已經存在),但是仍然會出現重複交易的現象。在追究為什麼在有重複提交限制還出現這種問題上,答案很模糊,連技術總監也直言,重複交易的原因很不確定,可能由於網路原因造成多次發出請求,操作失誤等(比如多次點選滑鼠)等 。
程式中判斷是否是重複提交的程式碼:
Java程式碼- publicboolean
- Map<String, Object> parameter = new HashMap<String, Object>();
- parameter.put("bizId", bizId);
- parameter.put("posId", posId);
- TransRecord transRecord = (TransRecord) getSqlMapClientTemplate().queryForObject("TransRecord_SqlMap.getInstanceByBizId"
- if (transRecord != null) thrownew AppException(PosErrors.REPEAT_TRADE);
- returntrue;
- }
public boolean checkRepeatTrans(String bizId, String posId) { Map<String, Object> parameter = new HashMap<String, Object>(); parameter.put("bizId", bizId); parameter.put("posId", posId); TransRecord transRecord = (TransRecord) getSqlMapClientTemplate().queryForObject("TransRecord_SqlMap.getInstanceByBizId", parameter); if (transRecord != null) throw new AppException(PosErrors.REPEAT_TRADE); return true; }
if (transRecord != null) throw new AppException(PosErrors.REPEAT_TRADE);
這一句,如果相同的bizId和posId,則表示此交易已經存在,就會丟擲重複交易的異常。看似這樣做已經沒有問題,但是還是出現了重複交易的問題,bizId和posId完全一樣。可以判斷是由於併發造成的重複提交,之前處理防重複交易,大概也就和這個層次一樣,沒有再深入到其他層次。所以問了專案經理解決策略。還是PM有經驗,一看bizId,PosId一樣,然後說了一句,“是重複交易,加個行鎖就能解決了”,之前有了解過Hibernate的悲觀鎖,樂觀鎖,對於鎖機制一知半解,之前所做的都是web網站,流量不高,所以都沒有考慮併發問題。這次算是理解鎖機制,通過蒐集一些有關鎖的機制,今天就來總結一下我自己的理解,分享與交流,經驗有限,總結的或許有不足或者錯誤之處,多提改進修正建議,在此感謝。
先來一段有關鎖,事務的總結的概括吧:
許多對Oracle不太瞭解的技術人員可能會以為每一個TX鎖代表一條被封鎖的資料行,其實不然。TX的本義是Transaction(事務)當一個事務第一次執行資料更改(Insert、Update、Delete)或使用SELECT… FOR UPDATE語句進行查詢時,它即獲得一個TX(事務)鎖,直至該事務結束(執行COMMIT或ROLLBACK操作)時,該鎖才被釋放。所以,一個TX鎖,可以對應多個被該事務鎖定的資料行(在我們用的時候多是啟動一個事務,然後SELECT… FOR UPDATE NOWAIT)。- Oracle只在修改時對資料庫加行級鎖。正常情況下不會升級到塊級鎖或表級鎖(不過兩段提交期間的一段很短的時間內除外,這是一個不常見的操作)。
- 如果只是讀資料,Oracle絕不會對資料鎖定。不會因為簡單的讀操作在資料行上鎖定。
- 寫入器(writer)不會阻塞讀取器(reader)。換種說法:讀(read)不會被寫(write)阻塞。這一點幾乎與其它所有資料庫都不一樣。在其它資料庫中,讀往往會被寫阻塞。儘管聽上去這個特性似乎很不錯(一般情況下確實如此),但是如果你沒有充分理解這個思想,而且想通過應用邏輯對應用施加完整性約束,就極有可能做得不對。
- 寫入器想寫某行資料,但另一個寫入器已經鎖定了這行資料,此時該寫入器才會被阻塞。讀取器絕對不會阻塞寫入器。
- select * from MEMBER_CREDIT_ACCOUNT where merchant_id = '01058121106'
- and customer_id='0010511200000971'forupdate
select * from MEMBER_CREDIT_ACCOUNT where merchant_id = '01058121106'
and customer_id='0010511200000971' for update
,執行後明顯看到,PLSql 左上角有提交或者回滾的鍵變成可點狀態了。這個時候不做任何操作,不提交也不回滾,然後再開啟另一個PLSQL視窗,執行一個讀的操作,也就是select 語句,這個語句能夠馬上查出來。也就是證明,在加了修改鎖的時候,讀是不會阻塞的。然後再寫一個update語句測試寫的操作, 執行的時候發現右下角一直出現
Sql程式碼
- update MEMBER_CREDIT_ACCOUNT set store_id = '332'where merchant_id = '01058121106'
- and customer_id='0010511200000971'
update MEMBER_CREDIT_ACCOUNT set store_id = '332' where merchant_id = '01058121106'
and customer_id='0010511200000971'
解決這次的問題,我採用的是行級鎖。是用select for update 去給某一行加鎖,並且,考慮給哪個表加鎖,還要考慮具體的業務,因為加了行鎖的話,也就是加了一個事務,在這個事務沒有提交或者回滾之前,其他的事務都得排隊等待,在沒有提交事務或者回滾前,假如這一條資料影響的其他操作,比如,鎖定了會員預存表中的某一條資料,
Sql程式碼- select * from MEMBER_ACCOUNT where merchant_id = '01058121106'
- and customer_id='0010511200000971'forupdate
select * from MEMBER_ACCOUNT where merchant_id = '01058121106'
and customer_id='0010511200000971' for update
那麼假如這個時候營業員從管理臺手工調賬,調整這個customer預存,那麼這個操作就會等很久不會執行(一個極端的模擬方式,鎖住這一條資料,專案在除錯狀態,斷點還沒有執行到事務提交或者回滾時,後臺對這個使用者手工調賬的操作就會反應很慢,是因為還在等待這個鎖定的事務提交)。因此,在考慮鎖哪個表的某一行時,一定要找到對整個應用系統中影響最小的那個表。
首先結合我的程式程式碼來看:
Java程式碼- public Map<String, Object> creditConsume(Map<String, String> parameter) {
- String posId = parameter.get(ApiConstants.PARAM_POS_ID);
- String posPwd = parameter.get(ApiConstants.PARAM_POS_PWD);
- String storeId = parameter.get(ApiConstants.PARAM_STORE_ID);
- String cardNum = parameter.get(ApiConstants.PARAM_CARD_ID);
- String transMoney = parameter.get(ApiConstants.PARAM_TRANS_MONEY);
- String bizId = parameter.get(ApiConstants.PARAM_BIZ_ID);
- String batchId = parameter.get(ApiConstants.PARAM_BATCH_ID);
- //判斷是否為重複交易
- apiAuthenticate.checkRepeatTrans(bizId, posId);
public Map<String, Object> creditConsume(Map<String, String> parameter) {
String posId = parameter.get(ApiConstants.PARAM_POS_ID);
String posPwd = parameter.get(ApiConstants.PARAM_POS_PWD);
String storeId = parameter.get(ApiConstants.PARAM_STORE_ID);
String cardNum = parameter.get(ApiConstants.PARAM_CARD_ID);
String transMoney = parameter.get(ApiConstants.PARAM_TRANS_MONEY);
String bizId = parameter.get(ApiConstants.PARAM_BIZ_ID);
String batchId = parameter.get(ApiConstants.PARAM_BATCH_ID);
//判斷是否為重複交易
apiAuthenticate.checkRepeatTrans(bizId, posId);
其中判斷是否為重複交易 呼叫的方法如下:
Java程式碼- publicboolean checkRepeatTrans(String bizId, String posId) {
- Map<String, Object> parameter = new HashMap<String, Object>();
- parameter.put("bizId", bizId);
- parameter.put("posId", posId);
- TransRecord transRecord = (TransRecord) getSqlMapClientTemplate().queryForObject("TransRecord_SqlMap.getInstanceByBizId", parameter);
- if (transRecord != null) thrownew AppException(PosErrors.REPEAT_TRADE);
- returntrue;
- }
public boolean checkRepeatTrans(String bizId, String posId) {
Map<String, Object> parameter = new HashMap<String, Object>();
parameter.put("bizId", bizId);
parameter.put("posId", posId);
TransRecord transRecord = (TransRecord) getSqlMapClientTemplate().queryForObject("TransRecord_SqlMap.getInstanceByBizId", parameter);
if (transRecord != null) throw new AppException(PosErrors.REPEAT_TRADE);
return true;
}
但是這樣做還不夠,當這個bizId, posId不存在時,也就是這個交易是新的交易,表中還不存在時,如果有兩個執行緒同時呼叫這個判斷是否重複提交的方法,那麼這個方法返回的transRecord都是null,那麼就都會執行後面的程式碼,扣減餘額,
插入新的交易等。這樣就有了兩條同樣的資料。
類似以下情況:
交易時間相同,或者是隻相差幾秒,bizId,posId相同。
我處理的方式就是加行鎖,本來在這裡判斷是否有重複提交,是查交易表,以posId和bizId為條件,本來考慮是將trans_record的某個記錄加鎖,但是後來發現有一個問題,如果是一筆新交易,那麼在交易表中是不存在的,那麼這一條記錄就鎖不住,加鎖了也是沒用的。所以我考慮了業務需求,找了影響最小的一個表,也就是掛賬交易賬戶表,並且只鎖這個使用者。在判斷重複交易前加行鎖,然後處理後面的業務,等處理完業務後,再釋放鎖。並且,要考慮處理業務的階段,如果任何一個地方出了錯,就得丟擲異常,這個時候需要rollback。
Java程式碼- @Transactional(readOnly = false, propagation = Propagation.REQUIRED)
- public Map<String, Object> creditConsume(Map<String, String> parameter) {
- String posId = parameter.get(ApiConstants.PARAM_POS_ID);
- String posPwd = parameter.get(ApiConstants.PARAM_POS_PWD);
- String storeId = parameter.get(ApiConstants.PARAM_STORE_ID);
- String cardNum = parameter.get(ApiConstants.PARAM_CARD_ID);
- String transMoney = parameter.get(ApiConstants.PARAM_TRANS_MONEY);
- String bizId = parameter.get(ApiConstants.PARAM_BIZ_ID);
- String batchId = parameter.get(ApiConstants.PARAM_BATCH_ID);
- String transId=null;
- long totalMoney =0;
- Long creditLimit = null;
- Long creditBalance = null;
- Pos pos = apiAuthenticate.posCheck(posId, posPwd);
- apiAuthenticate.isPosAvailable(posId);
- Store store = apiAuthenticate.storeCheck(pos, storeId);
- String merchantId = store.getMerchantId();
- Card card = apiAuthenticate.cardCheck(cardNum, store, false);
- String customerId = card.getCustomerId();
- String cardId = card.getId();
- //加鎖【鎖住MEMBER_CREDIT_ACCOUNT,因為掛賬消費,要修改掛賬使用者表,這裡根據merchantId,customerId兩個條件可以鎖住這一條】
- Connection con = null;
- Statement statement = null;
- try {
- con = this.getSqlMapClient().getDataSource().getConnection();
- con.setAutoCommit(false);
- statement = con.createStatement();
- statement.execute("select customer_id from MEMBER_CREDIT_ACCOUNT where merchant_id='"+merchantId+"' and customer_id='"+customerId+"' for update");
- } catch (SQLException e) {
- e.printStackTrace();
- }
- try {
- //判斷是否為重複交易
- apiAuthenticate.checkRepeatTrans(bizId, posId);
- MerchantMember merchantMember = apiAuthenticate.memberCheck(customerId, card, store, false);
- //選擇主卡帳戶
- String masterCustomerId = null;
- String masterRecordId = null;
- boolean isTeamAccount = certification.isTeamAccount(cardId, storeId);
- if (isTeamAccount) {
- masterCustomerId = certification.getMasterCustomerId(customerId, merchantId);
- apiAuthenticate.memberCheck(masterCustomerId, card, store, false);
- masterRecordId = masterCustomerId;
- } else {
- masterCustomerId = customerId;
- }
- // 修改賬戶交易值
- totalMoney = RequestUtil.toSafeDigit(transMoney);
- creditService.consumeAccount(masterCustomerId, merchantId, storeId, totalMoney);
- // 增加交易記錄
- transId = StringUtils.generateTransId();
- operateRecord.insertTransRecord(customerId, masterRecordId, merchantId, storeId, transId,
- cardId, posId, TransConstants.TRANS_TYPE_CREDIT_CONSUME, null,
- GlobalConstants.TRANS_WAY_MANUAL, bizId, batchId,null,null);
- MemberCreditAccount account = creditService.findMemberCreditAccount(masterCustomerId, merchantId, storeId);
- operateRecord.addTransCreditRecord(transId, totalMoney, null, merchantMember.getStoreId(), storeId,
- merchantId, customerId, masterCustomerId, TransConstants.TRANS_TYPE_CREDIT_CONSUME,
- posId, cardId, null, "api-pos", GlobalConstants.TRANS_WAY_MANUAL, bizId, account.getBalance(), null);
- // 掛帳資訊
- MemberCreditAccount creditAccount = creditService.findMemberCreditAccount(masterCustomerId, merchantId, storeId);
- if(null != creditAccount) {
- creditLimit = creditAccount.getCreditLimit();
- creditBalance = creditAccount.getBalance();
- }
- } catch (Exception e1) {
- // TODO: handle exception
- e1.printStackTrace();
- if(con != null){
- try {
- con.rollback();
- con.close();
- } catch (SQLException e) {
- // TODO Auto-generated catch block
- e.printStackTrace();
- }
- }
- if(statement != null){
- try {
- statement.close();
- } catch (SQLException e) {
- // TODO Auto-generated catch block
- e.printStackTrace();
- }
- }
- }finally{ //假如判斷到中間某些地方有異常,則回滾當前對資料庫的操作。
- // 解鎖
- try {
- if(con != null) {
- con.commit();
- con.close();
- }
- if(statement != null) {
- statement.close();
- }
- } catch (SQLException e) {
- e.printStackTrace();
- }
- }
- //返回結果
- Map<String, Object> result = new HashMap<String, Object>();
- result.put(ApiConstants.RETURN_STATUS, PosErrors.SUCCESS);
- result.put(ApiConstants.RETURN_CARD_ID, cardId);
- result.put(ApiConstants.RETURN_TRANS_ID, transId);
- result.put(ApiConstants.RETURN_TRANS_MONEY, totalMoney);
- result.put(ApiConstants.RETURN_CREDIT_LIMIT, creditLimit);
- result.put(ApiConstants.RETURN_CREDIT_BALANCE, creditBalance);
- // apiOperationLog.addLog(ApiConstants.CREDITCONSUME, "卡號"+cardId, ApiConstants.API, posId, storeId, merchantId);
- return result;
- }