文章首先介绍乐观锁的概念,然后介绍乐观锁的实现原理,最后用一个springboot项目演示乐观锁的实现方式。
目录
什么是乐观锁
乐观锁实现原理
实战
在进行数据库操作的时候,乐观锁总是假设查询不会修改数据,因此不会对查询到的数据上锁,只有在真正更新数据的时候再去检测是否有冲突,如果有冲突则更新失败。
有的小伙伴会问:为什么要使用乐观锁?因为在处理并发时,我们经常需要面对竞态条件,即某一方法的返回值取决于运行在线程中操作的交替执行方式(下一节会举栗),这是线程不安全的。乐观锁就是为了保证线程安全性,且提高并发访问的效率。(ps:所谓线程安全性,指的是当多个线程访问某个类时,不管运行环境采用何种调度方式或者线程如何交替执行,这个类始终都能表现出正确的行为)(pps:何为正确的行为:所见即所知we know it when we see it)。
乐观锁的实现原理是,在表中新增一个version字段,每次更新数据库的时候,都去检查version字段是否符合预期值,如果符合则更新,否则不更新。
举栗:
有一张用户的存款表account,里面有一条小明同学的存款记录,显示账户里有1000块。表结构非常简单:
id | user_name | account_num | update_time |
1 | 小明 | 1000 | null |
现在小明要从自己的账户里取50块钱,如果不使用锁,后台的逻辑会是这样:
a1、先查出小明的存款记录select * from account where user_name="小明",查询出余额为account_num1
a2、存款余额减50后试图更新表update account set account_num=account_num1-50 where user_name="小明"
看起来这样似乎没什么问题,但其实不然。
就在小明操作自己账户的同时,小华也正在给小明还钱,数额100:
b1、先查出小明的存款记录select * from account where user_name="小明",查询出余额为account_num2
b2、存款余额加100后试图更新表update account set account_num=account_num2+100 where user_name="小明"
小明取50,小华还100,理论上小明账户里应该还有1050。
但是因为没有加锁,且以上的a1,a2,b1,b2执行顺序存在随机性,导致结果可能出错。
我们假设执行的顺序是a1,b1,a2,b2,小明和小华查到的余额都是1000,小明成功取了钱,余额设置成了950,但是由于b2最后更新,小明账户的余额会是1100(小明高兴了,银行不乐意);如果执行的顺序是a1,b1,b2,a2,由于a2最后更新,小明的账户余额会是950(小华不高兴了,钱白还了)。
乐观锁正是用来解决上面的并发问题,我们来看看如何解决。
在表中增加一个字段version(名称无所谓):
id | user_name | account_num | update_time | version |
1 | 小明 | 1000 | null | 1 |
小明仍然取50块钱:
a1、先查出小明的存款记录select * from account where user_name="小明",查出余额为account_num1,version为version1
a2、存款余额减50后试图更新表update account set account_num=account_num1-50, version = version+1 where user_name="小明" and version=version1
小华存100:
b1、先查出小明的存款记录select * from account where user_name="小明",查出余额为account_num2,version为version2
b2、存款余额加100后试图更新表update account set account_num=account_num2+100, version=version+1 where user_name="小明" and version=version2
注意在更新记录的时候加了一个where条件version,并同时更新version+1。
1、假如执行顺序还是a1,b1,a2,b2,由于a2更新成功后,version+1变为2,那么b2在试图更新的时候,由于where条件中version=1不符合,则该条更新语句不执行,小明的余额变为950,小华还钱失败;
2、同理,假如执行顺序是a1,b1,b2,a2,小明取钱失败,小华还钱成功,余额变为1100;
3、或者执行顺序是a1,a2,b1,b2,那么小明取钱后余额变为950,version变为2,此时小华还钱,更新仍旧成功,余额变为1050,version变为3,两个人都更新成功。
有人可能会问,情况1和情况2中,都有人未更新成功啊,这怎么办。需要声明的是乐观锁的作用是防止并发时产生数据更新不一致的问题,这里其实已经实现了。至于更新失败后怎么处理,那就需要后台去实现一个重试机制(下一节会展示),这就不在乐观锁的功能范围内了。
下面以一个springboot项目为例,看看乐观锁具体是怎么实现的,其中也会提供一种重试机制。
建一张account表:
CREATE TABLE `account_wallet` (
`id` int(11) NOT NULL COMMENT '用户钱包主键',
`user_open_id` varchar(64) DEFAULT NULL COMMENT '用户中心的用户唯一编号',
`user_amount` decimal(10,5) DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`pay_password` varchar(64) DEFAULT NULL,
`is_open` int(11) DEFAULT NULL COMMENT '0:代表未开启支付密码,1:代表开发支付密码',
`check_key` varchar(64) DEFAULT NULL COMMENT '平台进行用户余额更改时,首先效验key值,否则无法进行用户余额更改操作',
`version` int(11) DEFAULT NULL COMMENT '基于mysql乐观锁,解决并发访问',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
表中插入一条记录:
INSERT INTO `account_wallet` (`id`, `user_open_id`, `user_amount`, `create_time`, `update_time`, `pay_password`, `is_open`, `check_key`, `version`)
VALUES
(1, '1', 1000.00000, NULL, NULL, NULL, NULL, 'haha', 1);
项目结构如下:
配置信息如下:注意修改数据库连接信息。
# 应用名称
spring.application.name=optimiclock
# 应用服务 WEB 访问端口
server.port=8087
spring.datasource.url=jdbc:mysql://IP:port/demo?characterEncoding=utf8&useUnicode=true&useSSL=false&serverTimezone=GMT%2B8
spring.datasource.username=username
spring.datasource.password=password
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
#实体类别名
mybatis.type-aliases-package=com.example.demo.model
#映射文件的位置
mybatis.mapper-locations=classpath:mapper/*.xml
mybatis.configuration.map-underscore-to-camel-case=true
dao层:
我这里使用的mybatis-generator插件直接生成数据库表的mapper,具体使用方法请自行google。
User实体类如下。
@Data
public class User {
String openId; //账户
String userName; //用户
String amount; //存取的数额
Boolean openType; //true存 false取
}
service层:
public interface TestService {
AccountWallet selectByOpenId(String openId);
int updateAccountWallet(AccountWallet record);
List initUsers();
void process(User user) throws InterruptedException;
}
其中selectByOpenId方法用于查询存款记录:
updateAccountWallet用于更新存款记录:
initUsers用于初始化用户。我这里为了演示,初始化了10个用户,并随机指定了用户是存或取,金额也随机指定。
public List initUsers() {
List res = new ArrayList<>();
Random random = new Random();
for (int i = 0; i < 10; i++) {
User user = new User();
user.setUserName(i + "");
user.setAmount((String.valueOf(random.nextInt(10) * 5)));//随机指定用户存取的金额
user.setOpenId("1");
res.add(user);
user.setOpenType(random.nextBoolean());//随机指定用户是存还是取
}
return res;
}
process用于模拟存取款操作。
这里介绍下重试机制。首先给用户设定一个重试时长,我这里设定的是35秒,用户在这个时间段内会重复尝试更新数据直到成功或者超时结束。
public void process(User user) throws InterruptedException {
//用户开抢时间
long startTime = System.currentTimeMillis();
Boolean success = false;
String message = "";
//while时间内会不断尝试更新直到成功
while ((startTime + 35000L) >= System.currentTimeMillis()) {
AccountWallet accountWallet = selectByOpenId("1");
//cash为用户要存入或取出的金额
BigDecimal cash = BigDecimal.valueOf(Double.parseDouble(user.getAmount()));
cash.doubleValue();
cash.floatValue();
String add = "+";//+表示存入,-表示取出
BigDecimal original = accountWallet.getUserAmount();
if (user.getOpenType()) {
accountWallet.setUserAmount(accountWallet.getUserAmount().add(cash));
} else {
add = "-";
accountWallet.setUserAmount(accountWallet.getUserAmount().subtract(cash));
}
//尝试更新数据库
int res = updateAccountWallet(accountWallet);
if (res == 1) {
success = true;
message = "成功" + " 基数: " + original + add + cash + " 更新后:" + accountWallet.getUserAmount();
break;
}
//休息后再次尝试更新
Thread.sleep(10L);
}
if (success) {
System.out.println(message);
} else {
System.out.println("失败!");
}
}
controller层:这里使用了parallelStream的方式模拟并发。
@RestController
@Slf4j
public class TestController {
@Autowired
TestService accountWalletService;
@PostMapping(value="/test")
@ResponseBody
public void test() {
List users = accountWalletService.initUsers();
//模拟并发
users.parallelStream().forEach(b -> {
try {
accountWalletService.process(b);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}
}
程序将模拟10个用户并发操作数据库中的同一条记录,运行程序并调用test接口:
IDE中打印的消息如下:
从打印的消息可以看出,10个用户的并发访问都成功了,并且都正确的更新了存款余额。
查看数据库中的记录:
能够看到存款余额正确更新,并且version成功更新了10次。
好的,关于乐观锁的介绍就到这里,源码在此lisz112/optimicLock