Spring Boot 整合 MyBatis 實現樂觀鎖和悲觀鎖

Spring Boot 整合 MyBatis 實現樂觀鎖和悲觀鎖

本文以轉賬操作為例,實現並測試樂觀鎖和悲觀鎖。

死鎖問題

當 A, B 兩個賬戶同時向對方轉賬時,會出現如下情況:

時刻事務 1 (A 向 B 轉賬)事務 2 (B 向 A 轉賬)T1Lock ALock BT2Lock B (由於事務 2 已經 Lock A,等待)Lock A (由於事務 1 已經 Lock B,等待)

由於兩個事務都在等待對方釋放鎖,於是死鎖產生了,解決方案:按照主鍵的大小來加鎖,總是先鎖主鍵較小或較大的那行數據。

建立數據表並插入數據(MySQL)

create table account
(
id int auto_increment
primary key,
deposit decimal(10, 2) default 0.00 not null,
version int default 0 not null
);
INSERT INTO vault.account (id, deposit, version) VALUES (1, 1000, 0);
INSERT INTO vault.account (id, deposit, version) VALUES (2, 1000, 0);
INSERT INTO vault.account (id, deposit, version) VALUES (3, 1000, 0);
INSERT INTO vault.account (id, deposit, version) VALUES (4, 1000, 0);
INSERT INTO vault.account (id, deposit, version) VALUES (5, 1000, 0);
INSERT INTO vault.account (id, deposit, version) VALUES (6, 1000, 0);
INSERT INTO vault.account (id, deposit, version) VALUES (7, 1000, 0);
INSERT INTO vault.account (id, deposit, version) VALUES (8, 1000, 0);
INSERT INTO vault.account (id, deposit, version) VALUES (9, 1000, 0);

INSERT INTO vault.account (id, deposit, version) VALUES (10, 1000, 0);

Mapper 文件

悲觀鎖使用 select ... for update,樂觀鎖使用 version 字段。


br> "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper>
<select>
select *
from account
where id = #{id}
/<select>
<update>
update account
set deposit=#{deposit},
version = version + 1
where id = #{id}
and version = #{version}
/<update>
<select>
select *
from account
where id = #{id} for
update
/<select>
<update>
update account
set deposit=#{deposit}
where id = #{id}
/<update>
<select>
select sum(deposit) from account;
/<select>
/<mapper>

Mapper 接口

@Component
public interface AccountMapper {
Account selectById(int id);

Account selectByIdForUpdate(int id);
int updateDepositWithVersion(Account account);
void updateDeposit(Account account);
BigDecimal getTotalDeposit();
}

Account POJO

@Data
public class Account {
private int id;
private BigDecimal deposit;
private int version;
}

AccountService

在 transferOptimistic 方法上有個自定義註解 @Retry,這個用來實現樂觀鎖失敗後重試。

@Slf4j
@Service
public class AccountService {
public enum Result{
SUCCESS,
DEPOSIT_NOT_ENOUGH,
FAILED,
}
@Resource
private AccountMapper accountMapper;
private BiPredicate<bigdecimal> isDepositEnough = (deposit, value) -> deposit.compareTo(value) > 0;
/**
* 轉賬操作,悲觀鎖
*
* @param fromId 扣款賬戶
* @param toId 收款賬戶
* @param value 金額
*/
@Transactional(isolation = Isolation.READ_COMMITTED)
public Result transferPessimistic(int fromId, int toId, BigDecimal value) {
Account from, to;

try {
// 先鎖 id 較大的那行,避免死鎖
if (fromId > toId) {
from = accountMapper.selectByIdForUpdate(fromId);
to = accountMapper.selectByIdForUpdate(toId);
} else {
to = accountMapper.selectByIdForUpdate(toId);
from = accountMapper.selectByIdForUpdate(fromId);
}
} catch (Exception e) {
log.error(e.getMessage());
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
return Result.FAILED;
}
if (!isDepositEnough.test(from.getDeposit(), value)) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
log.info(String.format("Account %d is not enough.", fromId));
return Result.DEPOSIT_NOT_ENOUGH;
}
from.setDeposit(from.getDeposit().subtract(value));
to.setDeposit(to.getDeposit().add(value));
accountMapper.updateDeposit(from);
accountMapper.updateDeposit(to);
return Result.SUCCESS;
}
/**
* 轉賬操作,樂觀鎖
* @param fromId 扣款賬戶
* @param toId 收款賬戶
* @param value 金額
*/
@Retry
@Transactional(isolation = Isolation.REPEATABLE_READ)
public Result transferOptimistic(int fromId, int toId, BigDecimal value) {
Account from = accountMapper.selectById(fromId),
to = accountMapper.selectById(toId);
if (!isDepositEnough.test(from.getDeposit(), value)) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
return Result.DEPOSIT_NOT_ENOUGH;
}
from.setDeposit(from.getDeposit().subtract(value));
to.setDeposit(to.getDeposit().add(value));
int r1, r2;
// 先鎖 id 較大的那行,避免死鎖
if (from.getId() > to.getId()) {
r1 = accountMapper.updateDepositWithVersion(from);

r2 = accountMapper.updateDepositWithVersion(to);
} else {
r2 = accountMapper.updateDepositWithVersion(to);
r1 = accountMapper.updateDepositWithVersion(from);
}
if (r1 < 1 || r2 < 1) {
// 失敗,拋出重試異常,執行重試
throw new RetryException("Transfer failed, retry.");
} else {
return Result.SUCCESS;
}
}
}
/<bigdecimal>

使用 Spring AOP 實現樂觀鎖失敗後重試

自定義註解 Retry

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Retry {
int value() default 3; // 重試次數
}

重試異常 RetryException

public class RetryException extends RuntimeException {
public RetryException(String message) {
super(message);
}
}

重試的切面類

tryAgain 方法使用了 @Around 註解(表示環繞通知),可以決定目標方法在何時執行,或者不執行,以及自定義返回結果。這裡首先通過 ProceedingJoinPoint.proceed() 方法執行目標方法,如果拋出了重試異常,那麼重新執行直到滿三次,三次都不成功則回滾並返回 FAILED。

@Slf4j
@Aspect
@Component
public class RetryAspect {
@Pointcut("@annotation(com.cloud.demo.annotation.Retry)")
public void retryPointcut() {
}
@Around("retryPointcut() && @annotation(retry)")
@Transactional(isolation = Isolation.READ_COMMITTED)
public Object tryAgain(ProceedingJoinPoint joinPoint, Retry retry) throws Throwable {
int count = 0;
do {
count++;
try {
return joinPoint.proceed();
} catch (RetryException e) {
if (count > retry.value()) {
log.error("Retry failed!");
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
return AccountService.Result.FAILED;
}
}
} while (true);
}
}

單元測試

用多個線程模擬併發轉賬,經過測試,悲觀鎖除了賬戶餘額不足,或者數據庫連接不夠以及等待超時,全部成功;樂觀鎖即使加了重試,成功的線程也很少,500 個平均也就十幾個成功。

所以對於寫多讀少的操作,使用悲觀鎖,對於讀多寫少的操作,可以使用樂觀鎖。

完整代碼請見 Github:https://github.com/imcloudfloating/Lock_Demo。

@Slf4j
@SpringBootTest
@RunWith(SpringRunner.class)
class AccountServiceTest {
// 併發數
private static final int COUNT = 500;
@Resource
AccountMapper accountMapper;
@Resource
AccountService accountService;
private CountDownLatch latch = new CountDownLatch(COUNT);
private List<thread> transferThreads = new ArrayList<>();
private List<pair>> transferAccounts = new ArrayList<>();
@BeforeEach
void setUp() {
Random random = new Random(currentTimeMillis());
transferThreads.clear();
transferAccounts.clear();
for (int i = 0; i < COUNT; i++) {
int from = random.nextInt(10) + 1;
int to;
do{
to = random.nextInt(10) + 1;
} while (from == to);
transferAccounts.add(new Pair<>(from, to));
}
}
/**
* 測試悲觀鎖
*/
@Test
void transferByPessimisticLock() throws Throwable {
for (int i = 0; i < COUNT; i++) {
transferThreads.add(new Transfer(i, true));
}
for (Thread t : transferThreads) {
t.start();
}
latch.await();
Assertions.assertEquals(accountMapper.getTotalDeposit(),
BigDecimal.valueOf(10000).setScale(2, RoundingMode.HALF_UP));
}
/**
* 測試樂觀鎖
*/
@Test
void transferByOptimisticLock() throws Throwable {
for (int i = 0; i < COUNT; i++) {
transferThreads.add(new Transfer(i, false));

}
for (Thread t : transferThreads) {
t.start();
}
latch.await();
Assertions.assertEquals(accountMapper.getTotalDeposit(),
BigDecimal.valueOf(10000).setScale(2, RoundingMode.HALF_UP));
}
/**
* 轉賬線程
*/
class Transfer extends Thread {
int index;
boolean isPessimistic;
Transfer(int i, boolean b) {
index = i;
isPessimistic = b;
}
@Override
public void run() {
BigDecimal value = BigDecimal.valueOf(
new Random(currentTimeMillis()).nextFloat() * 100
).setScale(2, RoundingMode.HALF_UP);
AccountService.Result result = AccountService.Result.FAILED;
int fromId = transferAccounts.get(index).getKey(),
toId = transferAccounts.get(index).getValue();
try {
if (isPessimistic) {
result = accountService.transferPessimistic(fromId, toId, value);
} else {
result = accountService.transferOptimistic(fromId, toId, value);
}
} catch (Exception e) {
log.error(e.getMessage());
} finally {
if (result == AccountService.Result.SUCCESS) {
log.info(String.format("Transfer %f from %d to %d success", value, fromId, toId));
}
latch.countDown();
}
}
}
}
/<pair>/<thread>

MySQL 配置

innodb_rollback_on_timeout='ON' 

max_connections=1000
innodb_lock_wait_timeout=500

關注我,後續更多幹貨奉上!


分享到:


相關文章: