文章目錄
- 1. 前言
- 樂觀鎖 vs. 悲觀鎖:基本概念對比
- 使用場景及優勢簡述
- 2. 基于版本號的樂觀鎖實現
- 代碼示例
- 注意事項
- 3. 基于CAS機制的樂觀鎖實現
- 核心思想
- 代碼示例
- 關鍵點說明
- 4. 框架中的樂觀鎖實踐
- MyBatis中基于版本號的樂觀鎖實現
- 示例代碼
- JPA(Hibernate)中的樂觀鎖
- @Version 注解關鍵點與底層原理
- 示例代碼
- 5. 樂觀鎖使用中的注意細節
- 并發沖突后的重試機制與失敗處理
- 事務管理中的注意事項
- 數據持久化時的并發一致性保障
- 適用場景的取舍:當沖突過于頻繁時的風險分析
- 實際應用中的問題
- 是否可以用狀態機(比如:待支付 支付中 支付完成 支付異常)作為版本號
- mysql樂觀鎖是否一定需要和事務同時出現才有效果
- 沒有事務支持使用版本號邏輯 mysql樂觀鎖會出現什么問題
1. 前言
在現代并發編程中,如何有效處理數據爭搶和共享資源的訪問是一項很關鍵的問題。常用的兩種策略是樂觀鎖和悲觀鎖,它們雖然目標相同——確保數據的一致性和完整性——但采用的方法卻截然不同。
樂觀鎖 vs. 悲觀鎖:基本概念對比
-
悲觀鎖
- 思想:對每次操作前都假設可能發生數據沖突,因此在操作時先加鎖,確保資源不會被其他線程/進程同時修改。
- 特點:
- 適用于寫操作較多、數據沖突較頻繁的場景。
- 實現方式通常依賴數據庫鎖(如行鎖、表鎖)或使用Java中的
synchronized
關鍵字。
- 類比:就像在辦公室會議室中安排發言,每個人在發言前都得先預定會議室,確保不會有人同時使用。
-
樂觀鎖
- 思想:假設數據在大部分情況下不會發生沖突,因此直接進行操作,在提交更新時再進行版本驗證(如基于版本號或CAS判斷)。
- 特點:
- 適用于讀取操作遠多于寫入操作,因為數據修改的沖突幾率較低。
- 不需要在每次操作前對數據進行加鎖,性能一般更好,但在沖突時需要重試操作。
- 類比:就像大家在共享一個白板上標注信息,盡管同時編輯的情況可能存在,但大多數人不會同時做出修改,當檢測到沖突時大家回頭進行調整。
使用場景及優勢簡述
-
使用場景:
- 數據庫場景:
- 悲觀鎖更適用于那些寫操作頻繁、業務邏輯要求嚴格的場景,如銀行轉賬系統。
- 樂觀鎖適合于讀操作較多、寫操作相對較少的場景,如商品庫存查看、博客評論系統。
- 內存并發場景:
- 悲觀鎖用在要求線程安全且數據操作沖突率高的情況,可能采用
ReentrantLock
或synchronized
。 - 樂觀鎖通過CAS等機制保證數據修改的正確性,適合于低沖突或容錯性較好的系統設計。
- 悲觀鎖用在要求線程安全且數據操作沖突率高的情況,可能采用
- 數據庫場景:
-
優勢比較:
- 樂觀鎖:
- 提升性能:避免頻繁加鎖和解鎖帶來的性能損耗。
- 高度并發:在多數操作不沖突的情況下,多線程能高效執行。
- 悲觀鎖:
- 數據安全:即使高并發情況下,也能確保數據絕對不會出現臟讀或不一致的情況。
- 實現簡單:邏輯上比較直觀,容易理解。
- 樂觀鎖:
2. 基于版本號的樂觀鎖實現
在開發中,我們通常會在數據表中增加一個版本號字段(version)或者在內存中的共享對象設置一個版本屬性。工作流程大致如下:
- 讀取數據時,同時獲取當前版本號。
- 在更新數據時,帶上之前讀取到的版本號作為條件,執行類似下面的更新操作:
- SQL 更新語句示例(假設數據表中有一個version字段):
UPDATE product SET stock = ?, version = version + 1 WHERE id = ? AND version = ?
- 如果更新成功,說明在讀取到更新之間數據未被修改;如果沒有更新數據(影響行數為0),說明中途數據被別人修改過,通常程序會捕獲這種情況,再進行相應的重試或異常處理。
- SQL 更新語句示例(假設數據表中有一個version字段):
這種方式保證了在更新操作時,如果其他事務已經修改了數據(導致版本號發生變化),當前事務將無法繼續更新,避免數據沖突。
代碼示例
設計一個簡單的倉儲類,通過版本號檢查更新庫存信息:
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import javax.sql.DataSource;public class ProductRepository {private DataSource dataSource;// 構造函數注入數據源public ProductRepository(DataSource dataSource) {this.dataSource = dataSource;}/*** 獲取指定產品的當前版本號*/public int getCurrentVersion(int productId) throws SQLException {String selectSQL = "SELECT version FROM product WHERE id = ?";try (Connection conn = dataSource.getConnection();PreparedStatement ps = conn.prepareStatement(selectSQL)) {ps.setInt(1, productId);try (ResultSet rs = ps.executeQuery()) {if (rs.next()) {return rs.getInt("version");} else {throw new SQLException("Product with id " + productId + " not found");}}}}/*** 更新產品的庫存,并利用樂觀鎖實現數據一致性控制*/public boolean updateProductStock(int productId, int newStock, int currentVersion) throws SQLException {String updateSQL = "UPDATE product SET stock = ?, version = version + 1 WHERE id = ? AND version = ?";try (Connection conn = dataSource.getConnection();PreparedStatement ps = conn.prepareStatement(updateSQL)) {ps.setInt(1, newStock);ps.setInt(2, productId);ps.setInt(3, currentVersion);int affectedRows = ps.executeUpdate();return affectedRows > 0;}}
}
示例中,我們首先通過 getCurrentVersion 方法獲取當前產品的版本號,然后在更新庫存時帶上該版本號作為條件。如果有其它事務在更新時修改了該記錄(導致版本號已發生改變),則 updateProductStock 執行時不會更新任何記錄,返回的 affectedRows 為 0,從而達到樂觀鎖控制數據并發沖突的目的。
下面是一個使用上述方法的簡單示例:
public class Application {public static void main(String[] args) {// 注意:此處數據源的初始化邏輯需要根據具體環境配置DataSource dataSource = DataSourceFactory.getDataSource(); // 假設有一個工廠類提供 DataSourceProductRepository repository = new ProductRepository(dataSource);int productId = 1; // 假設操作id為1的產品try {// 讀取產品當前版本號int currentVersion = repository.getCurrentVersion(productId);// 嘗試更新庫存boolean success = repository.updateProductStock(productId, 100, currentVersion);if (success) {System.out.println("更新成功,庫存已調整,新版本號已同步。");} else {System.out.println("更新失敗,數據可能已被其他操作修改,請準備重試操作。");}} catch (SQLException e) {e.printStackTrace();}}
}
注意事項
-
數據版本一致性問題
要確保所有對該數據的更新操作都使用版本號作為條件,否則可能破壞數據的一致性。如果遺漏了版本號條件,則可能出現部分操作修改成功、部分操作失敗的情況,最終導致數據狀態不符合預期。 -
高并發下可能出現的沖突處理
在高并發環境下,多線程或多事務可能同時讀取相同的版本號并嘗試更新,只有一個線程能夠更新成功。一般需要在業務層面對失敗的更新進行重試、記錄日志或者返回給用戶友好的消息。重試機制需要注意避免死循環或長時間等待,確保系統對并發沖突有有效的容錯處理策略。
3. 基于CAS機制的樂觀鎖實現
核心思想
CAS是一種硬件級別的原子操作,在Java中常通過java.util.concurrent.atomic包來實現。CAS操作的基本步驟是:
- 讀取內存中的某個變量的當前值。
- 與預期值(舊值)進行比較。
- 如果內存中的值與預期值相等,則將該值更新為新值;否則,說明在比較期間該變量已被其他線程修改,更新操作失敗。
這種原子性操作通常被封裝在循環內,不斷重試直至更新成功。在Java中,示例如下:
import java.util.concurrent.atomic.AtomicInteger;public class OptimisticLockExample {// 初始化版本號為0private AtomicInteger version = new AtomicInteger(0);public boolean updateVersion() {int currentVersion = version.get();// 假設新版本號為舊值+1int newVersion = currentVersion + 1;// 嘗試CAS操作return version.compareAndSet(currentVersion, newVersion);}public static void main(String[] args) {OptimisticLockExample example = new OptimisticLockExample();if (example.updateVersion()) {System.out.println("更新成功,當前版本為:" + example.version.get());} else {System.out.println("更新失敗,需采取重試機制。");}}
}
在這個例子中:
- 我們使用了
AtomicInteger
來存儲版本號,實現原子性更新。 - 調用
compareAndSet
方法:它會比較當前的值與預期的值,若相同則更新為新值,返回true
;否則返回false
,表示其他線程在此期間已做了修改。
通過CAS算法,我們可以在無需加鎖的情況下實現對共享數據的安全更新,這正是樂觀鎖在高并發場景下能提高性能的關鍵點。
代碼示例
下面給出一個簡單的示例,演示如何使用AtomicInteger實現CAS機制來對一個共享變量做安全更新:
import java.util.concurrent.atomic.AtomicInteger;public class CASExample {// 使用AtomicInteger來管理共享變量private AtomicInteger counter = new AtomicInteger(0);/*** 利用CAS循環機制更新counter的值* @param newValue 期望設置的新值* @return true表示更新成功,false表示更新過程中遇到問題(在本示例中,永遠會重試直到成功)*/public boolean updateCounter(int newValue) {while (true) {// 1. 獲取當前值int currentValue = counter.get();// 2. 執行比較并嘗試更新if (counter.compareAndSet(currentValue, newValue)) {// 如果當前值與期望值一致,則更新成功并退出System.out.println("更新成功:" + currentValue + " -> " + newValue);return true;} else {// 輸出調試信息,說明競爭導致更新失敗、正在重試System.out.println("CAS更新失敗,當前值已變為:" + counter.get() + ",準備重試。");}// 可選:可以在此添加退避策略,避免長時間自旋帶來的CPU飆高問題}}public static void main(String[] args) {CASExample example = new CASExample();// 模擬多線程下的并發更新情況Thread t1 = new Thread(() -> {example.updateCounter(1);});Thread t2 = new Thread(() -> {example.updateCounter(2);});t1.start();t2.start();}
}
關鍵點說明
-
依賴包
- 本示例依賴于
java.util.concurrent.atomic.AtomicInteger
,無需額外的第三方包。
- 本示例依賴于
-
CAS循環機制
- 在 updateCounter 方法中,通過一個無限循環不斷嘗試調用
compareAndSet
。當操作失敗(說明其他線程已經修改了該變量)時,會再次獲取最新值并重試。
- 在 updateCounter 方法中,通過一個無限循環不斷嘗試調用
-
自旋鎖與性能問題
- 如果多個線程競爭激烈,CAS循環可能會導致大量的自旋重試,進而消耗較多CPU資源。在實際生產環境中,可以加入退避策略(例如在每次失敗后休眠一段動態調整的時間),以減少自旋帶來的性能損耗。
4. 框架中的樂觀鎖實踐
在實際開發中,很多持久化框架都內置了樂觀鎖實現的支持,下面介紹 MyBatis 和 JPA(Hibernate)中的實踐方式。
MyBatis中基于版本號的樂觀鎖實現
MyBatis 通常通過在 Mapper XML 中編寫帶有版本號條件的更新語句來實現樂觀鎖。在使用時,需要注意以下配置點:
- 數據庫表需要定義一個版本號字段(如 version)。
- 實體類對應數據庫記錄時,需要映射該 version 字段。
- 編寫更新 SQL 時,應在 WHERE 條件中加入 version 字段的判斷,即:
- 更新時要求數據庫記錄的版本號與傳入的版本一致,更新成功后版本號加一。
- 如果更新返回受影響行數為 0,則說明記錄可能被其他事務修改,需要做相應的處理,比如重試或告警。
示例代碼
假設有一個 Product 實體,其中包含 id、stock、version 等字段。下面是 MyBatis 的 Mapper XML 配置示例:
– ProductMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapperPUBLIC "-//mybatis.org//DTD Mapper 3.0//EN""http://mybatis.org/dtd/mybatis-3-mapper.dtd"><mapper namespace="com.example.mapper.ProductMapper"><!-- 根據ID查詢產品(包含版本號) --><select id="selectProductById" resultType="com.example.entity.Product">SELECT id, stock, versionFROM productWHERE id = #{id}</select><!-- 基于版本號的樂觀鎖更新 --><update id="updateProductStock" parameterType="com.example.entity.Product">UPDATE productSET stock = #{stock},version = version + 1WHERE id = #{id} AND version = #{version}</update></mapper>
在 Java 中調用時,可以寫一個 Service 層方法:
package com.example.service;import com.example.entity.Product;
import com.example.mapper.ProductMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;@Service
public class ProductService {@Autowiredprivate ProductMapper productMapper;@Transactionalpublic boolean updateStock(int productId, int newStock) {// 查詢當前記錄Product product = productMapper.selectProductById(productId);if (product == null) {throw new RuntimeException("產品不存在");}// 設置新的庫存值product.setStock(newStock);// 執行更新;更新返回值為受影響行數int affectedRows = productMapper.updateProductStock(product);return affectedRows > 0;}
}
這樣在更新時,如果其它事務已經修改了記錄(導致版本號不一致),則本次更新不會影響到任何記錄,服務層可據此判斷是否需要重試或返回錯誤信息。
JPA(Hibernate)中的樂觀鎖
JPA 提供了內置的樂觀鎖支持,開發人員只需在實體中通過注解 @Version 標記一個版本字段即可。Hibernate 在底層自動通過 SQL’s UPDATE 的 WHERE 子句檢查版本號,從而實現樂觀鎖機制。
@Version 注解關鍵點與底層原理
-
定義版本號字段
- 在實體類中添加一個字段,比如 Integer、Long 或 Timestamp 類型,并加上 @Version 注解。
- 當實體被更新時,Hibernate 會在更新語句中加入類似 “WHERE id = ? AND version = ?” 的判斷,如果版本不匹配,則更新返回 0 行數據,進而拋出 OptimisticLockException 異常。
-
底層原理
- 每次更新操作前,Hibernate 讀取實體當前的版本號。
- 執行更新時,將版本號作為條件,如果數據庫記錄中的版本與傳入一致,則更新成功;同時 Hibernate 會自動將版本號遞增。
- 如果更新失敗,說明數據版本與預期不符,通常會拋出異常,提示樂觀鎖沖突。
示例代碼
下面是一個簡單的 JPA 實體示例:
package com.example.entity;import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Version;
import javax.persistence.Table;@Entity
@Table(name = "product")
public class Product {@Idprivate Integer id;private Integer stock;// 使用@Version注解標記版本字段@Versionprivate Integer version;// getter和setter省略public Integer getId() {return id;}public void setId(Integer id) {this.id = id;}public Integer getStock() {return stock;}public void setStock(Integer stock) {this.stock = stock;}public Integer getVersion() {return version;}public void setVersion(Integer version) {this.version = version;}
}
在 Spring Data JPA 或純 JPA 中,只需正常調用 save 方法即可,底層會自動管理版本號更新:
package com.example.service;import com.example.entity.Product;
import com.example.repository.ProductRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;@Service
public class ProductService {@Autowiredprivate ProductRepository productRepository;@Transactionalpublic void updateProductStock(Integer productId, Integer newStock) {Product product = productRepository.findById(productId).orElseThrow(() -> new RuntimeException("產品不存在"));product.setStock(newStock);// 調用save方法時,Hibernate會生成:// UPDATE product SET stock = ?, version = version + 1 WHERE id = ? AND version = ?productRepository.save(product);}
}
如果在并發環境下存在版本沖突,Hibernate 會拋出 OptimisticLockException,這時可以捕獲異常,并進行相應的重試或錯誤提示處理。
5. 樂觀鎖使用中的注意細節
在使用樂觀鎖時,開發者需要注意多個細節以保障系統的數據一致性、性能和用戶體驗。下面對幾個關鍵點進行詳細說明:
并發沖突后的重試機制與失敗處理
-
重試機制設計:
- 當樂觀鎖檢測到版本不匹配時,通常需要在業務層實現重試邏輯。重試機制可以是簡單的固定次數重試,也可以采用指數退避(Exponential Back-off)機制,來避免頻繁并發寫操作時的性能瓶頸。
- 重試次數不應過多,否則可能對系統性能和響應時間產生負面影響;一般會定義一定的最大重試次數,當超過這個次數,應該認為當前操作失敗并反饋給用戶或上層調用者。
-
失敗處理:
- 如果重試多次后仍不能成功更新數據,則應捕捉到樂觀鎖異常,并根據業務邏輯決定是否需要回滾、通知用戶或記錄日志用于后續分析。
- 在一些業務場景中,可能允許部分更新失敗以確保系統能繼續運轉,此時就需要對失敗結果進行合理的補償或告警機制。
事務管理中的注意事項
-
事務邊界:
- 樂觀鎖通常依托于事務機制來確保數據在一次完整操作中的一致性,因此務必保證更新操作在同一事務內完成。
- 應確保查詢、業務邏輯判斷和更新操作處在同一事務內,避免在事務提交前數據已經被其他線程修改。
-
隔離級別:
- 雖然樂觀鎖利用版本號機制避免并發沖突,但事務的隔離級別依然起到關鍵作用。例如,在讀已提交(Read Committed)或可重復讀(Repeatable Read)的級別下,能較好地配合樂觀鎖,而在更低的隔離級別下,可能會導致不可預期的問題。
- 設計事務時要充分考慮并發沖突與數據臟讀之間的平衡,必要時調優隔離級別或明確設置事務傳播行為,確保數據正確更新。
數據持久化時的并發一致性保障
-
數據一致性策略:
- 樂觀鎖依靠版本號確保操作前后數據的一致性。當版本號不匹配時,更新操作被拒絕,從而避免數據覆蓋、丟失等問題。
- 在持久化層面,要確保版本字段正確映射到數據庫,并在數據更新時遞增版本號,這樣才能有效地防止并發更新沖突。
-
并發一致性保障:
- 應該考慮到分布式環境中數據復制、緩存失效等可能影響一致性的問題,可能需要結合分布式事務或緩存失效策略一起使用。
- 對于讀取操作,如果需要更高的一致性保證,可能需要配合臟檢查操作或在適當的場景中禁用緩存。
適用場景的取舍:當沖突過于頻繁時的風險分析
-
樂觀鎖的適用場景:
- 適合讀操作多、寫操作少的場景,因為樂觀鎖并不像悲觀鎖那樣在數據訪問前就加鎖,而是在更新時檢查版本號。
- 在用戶操作較分散、沖突概率較低的環境(例如電商系統中的庫存更新)尤為適用。
-
沖突過于頻繁的風險:
- 如果并發寫操作非常頻繁,樂觀鎖會經常檢測到版本沖突,從而觸發大量重試或失敗處理,這會導致系統響應變慢、資源耗損(如CPU占用增高)以及用戶體驗下降。
- 此時,需要評估是否采用悲觀鎖機制、數據分片或引入其他協調機制來降低沖突概率,或者通過調優重試策略和失敗處理方式來適應高并發場景。
-
風險取舍:
- 當沖突頻繁發生時,系統應根據業務特點在一致性、可用性和響應速度之間做出權衡。一方面,嚴格的數據一致性是保證業務正確運行的前提;另一方面,過高的并發沖突率會導致系統瓶頸。
- 此外,還可以考慮將部分寫操作改為冪等設計,利用消息隊列或事件驅動架構來緩解直接高并發寫入數據庫造成的沖突。
實際應用中的問題
是否可以用狀態機(比如:待支付 支付中 支付完成 支付異常)作為版本號
不建議使用狀態機中的業務狀態(如“待支付”、“支付中”、“支付完成”、“支付異常”)來充當樂觀鎖的版本號。原因主要如下:
-
分離關注點原則
- 狀態機中的狀態主要描述業務流程和狀態轉變,而版本號主要用于并發控制和數據一致性驗證。混用兩者容易導致職責不清,降低代碼的可維護性和理解度。
-
單調性與可靠性
- 樂觀鎖依賴版本號具有單調遞增或連續變化的特性,以便在每次更新時能夠準確檢測到數據是否在一段時間內被其他事務修改。業務狀態通常是離散且有限的,不具備嚴格的單調性,有可能出現多次轉換到同一狀態的情況,從而無法作為有效的版本控制標識。
-
易引入風險
- 使用業務狀態作為版本號,在進行業務流程設計時可能會存在狀態合理性校驗和版本沖突檢測混在一起的問題。如果業務狀態發生錯誤(例如業務流程狀態轉換本身設計不當或出現異常),就可能誤判沖突,甚至引發數據更新錯誤。
-
異常處理和可擴展性
- 樂觀鎖中的版本號通常是由數據庫或框架自動管理的,不容易人為干預。而業務狀態需要根據業務邏輯進行轉換,如果重試或錯誤處理邏輯和版本號機制混淆后,會使得系統異常處理更加復雜,并增加未來擴展或變更時出錯的風險。
mysql樂觀鎖是否一定需要和事務同時出現才有效果
樂觀鎖本質上是一種數據版本控制的機制,用于檢測并發更新時數據是否發生變化。它通常依賴于數據庫的原子更新操作,而事務正是保證這類更新操作原子性的一種手段。
-
如果沒有事務支持,每個更新操作可能無法在單一的、原子性的環境中完成,導致在多線程或多進程并發場景下數據狀態難以準確檢測和更新。一旦版本檢測和更新操作不是原子執行,其他并發操作可能會介入修改,從而破壞樂觀鎖的檢測機制。
-
在實際的 ORM(如JPA/Hibernate)框架中,樂觀鎖往往依賴于事務作為基本單元,只有在事務邊界內才能確保版本號檢查和更新操作的原子性,從而有效避免數據沖突。
沒有事務支持使用版本號邏輯 mysql樂觀鎖會出現什么問題
下面給出一個簡單的示例,說明如果沒有事務支持時如何導致樂觀鎖版本檢查失效,從而引發并發更新數據不一致的問題。假設有個訂單實體 Order,屬性中包含 version 字段用以進行樂觀鎖檢查,下面代碼模擬兩個線程并發對同一個訂單進行修改的情況。
【問題場景說明】
-
初始訂單數據:
? id = 1
? status = “待支付”
? version = 1 -
同時有兩個線程(線程A、線程B)同時查詢到該訂單(均獲得 version=1)。
-
兩個線程開始各自的業務處理:
? 線程A處理完業務后,檢查訂單版本為1,準備更新訂單狀態為"支付中",并將版本號更新為2。
? 線程B處理完業務后,同樣檢查訂單版本為1,準備更新訂單狀態為"支付完成",并將版本號更新為2。 -
如果沒有事務的支持,這兩個操作往往不在原子操作中完成,可能出現如下的 race condition:
? 線程A更新增加 version 后,線程B由于仍檢測到 version 為1(或由于兩個線程并發,互相干擾),導致兩次更新并發寫入,最壞情況,線程B覆蓋了線程A的更新,導致數據“臟寫”。