⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 juejin.cn/post/7179791593287876664 「撸猫的代码」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注**微信公众号:【芋道源码】**有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言将得到认真回复。甚至不知道如何读源码也可以请教噢
  4. 新的源码解析文章实时收到通知。每周更新一篇左右
  5. 认真的源码交流微信群。

🔖Part One

我们假想一个场景,在并发场景下,假如要对账户余额(tb_balance.balance)进行增加和减少余额,要怎么设计才能保证数据不出错呢?

🔅下面是我的一些设想(假设余额更新20或减少20):

🔢直接sql更新

直接balance自增20,怎么并发都余额都不会错了是不是~,但这里会有问题,1.假如是减余额的话,要注意余额不能小于0 2.假如后续需要用这个余额结果再进行一些业务操作的话,是取不到这个余额的sql

update tb_balance set balance=balance+20 where balance=#{old_bal} and user_id=#{user_id}

🔢CAS

经典的compare and swap,就是入库时去对比旧值是否与现数据库中的值相等,若相等则更新否则不更新,但是会有风险,就是经典的aba问题。假如更新返回的影响数为0的话,说明余额已经发生了变化,所以需要抛出异常并进行重试。(这里需要写一些补偿的业务逻辑去处理余额更新失败的问题)

/**
* 用户余额增加20
* newBal 为 新值
* oldBal 为 库中查出的值
**/
oldBal = balService.getBal(userId);
newBal = oldBal + 20;

int count = balService.updateBal(newBal,oldBal,userId);
Assert.businessInvalid(count==0,"update error");

// todo some buisness

update tb_balance set balance=#{new_bal} where balance=#{old_bal} and user_id=#{user_id}

🔢乐观锁

乐观锁其实就是使用version去进行版本控制,在更新时判断是否更新数据的版本与现库内版本是一致的,若一致则更改并上升版本号,否则认为在此期间有其它线程进行数据更新。假如更新失败的话,同样进行重试处理。这样的好处就是可以避免aba问题,同时也可以使用增加后的余额进行后续的操作。目前已知有些公司就是这么操作的

try{
//dosome buisness
int count = balService.updateBal(newBal,old_version,new_version,userId);
Assert.businessInvalid(count==0,"update error");
//dosome buisness
} catch(){
//if fail todo something try again
}

update tb_balance set balance = #{balance} and balance = #{new_version} where version = #{old_version} and user_id=#{user_id}

-- if affect count > 1 success
-- if affect count = 0 retry

🔢redission分布式锁

像集群的项目,我们经常会使用分布式锁去保证幂等性,如果只有单一接口会去操作账户余额那使用分布式锁没有问题,只要在该接口加上锁,保证同一时间只有单一线程进行该业务操作即可;但往往实际业务场景并不会那么简单,比如一个商城,可能会有几十上百个入口可以对余额进行变更,比如定时扣费、订单支付、替他人代付等等;那其实可以通过面向对象的思想,将余额的操作进行抽象,抽象出一个余额类,将该类的方法加上锁,然后所有的业务都强制通过该类进行余额变更的操作,即可保证操作的可靠性。

🤔所以也就是说,订单系统、服务管理中心等等服务都不应该能直接操作到余额数据,还是得抽出一个类型财务中台的东东去统一处理余额,然后财务中台中又维护这么一个余额类去保证更新的可靠性? 这其实也就是软件工程中的单一入口思想吧

🔖Part Two

业务背景:现在实际开发过程中会有这样一种场景,订单表a作为订单业务核心表,而会有a1,a2,a3...an个业务会去更新订单表a的状态,而同时a.status(订单状态)也是作为b,c,d...业务操作的核心判断依据(也就是在不同的订单状态下可能做的操作是截然相反的)

在代码层面上,我们一般的写法是直接用注解开个事务,以保证操作的原子性。这种写法目前让我们的业务还是平稳运行的~

// @apiLock是redis锁
@ApiLock
@Transactional
public void dosomething(){

}

那么假设用户量激增,并发量暴涨,那么会出现什么情况呢?

🤔搞事情的时间来了,事务只能保证单个操作的事务性,假如并发量高的时候,可能就会出现(假设b业务的前提是a订单再待接单[wait_acceipt]的状态):

b业务查询时,a.status=wait_acceipt,然后b业务开始处理;而此时,a1业务对a订单进行操作,并且先于b业务处理完,这时a订单走到了待服务(wait_service),而b还在进行业务处理;然后b业务处理完成,并进行提交。

☢️那么就会产生很多异常的数据了,而且影响范围会很大

解决方案&一些思考

🤔我们可以关注到,这个场景的问题点其实并不在数据的插入与更新,而是在读取,在业务处理过程怎么在确定订单状态没有发生改变

🔢使用redis锁,在读取订单时候对订单进行上锁,业务结束之后再释放

单一入口:将订单类的操作抽象成一个order类,然后在这个order类中去进行查询操作,或者在不同服务中用一个redis-key也是OK的

public class OrderService{

lock()

select()

unlock()

}

public void dosomeB(){
try{
orderService.lock(serviceOrderId);
// todo
}finally(){
orderService.unlock(serviceOrderId);
}
}

🔢使用mysql共享锁(读锁),在数据库层面进行阻塞,更加精准,并且不使用单一入口也是可以的,但是可能存在索引失效锁表的风险(核心表被锁就炸了)

public class OrderService{
/**
* 读取并且上锁
*/
selectAndLock()

/**
* 读取
*/
select()
}

-- 读锁会阻塞写(X),但是不会堵塞读(S),在事务提交后,读锁会自动释放
select xxx from order where service_order_id = xxx lock in share mode;

总体来说,思想就是对正在操作的数据进行加读锁,阻塞其它的线程。当然这种方案也是双刃剑,毕竟会减少吞吐量,还是应该进行业务梳理,确定加锁的必要性,避免过分设计~像现在的系统,我就没去动它,hh~

文章目录
  1. 1. 🔖Part One
  2. 2. 🔖Part Two