文章目录
- 一、Mybatis实现
- 二、Redis实现
乐观锁介绍:
乐观锁( Optimistic Locking ) 相对悲观锁而言,乐观锁假设认为数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则让返回用户错误的信息,让用户决定如何去做。那么我们如何实现乐观锁呢,一般来说有以下2种方式:
一、Mybatis实现
1、使用数据版本(Version)记录机制实现,这是乐观锁最常用的一种实现方式。何谓数据版本?即为数据增加一个版本标识,一般是通过为数据库表增加一个数字类型的 “version” 字段来实现。当读取数据时,将version字段的值一同读出,数据每更新一次,对此version值加一。当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的version值进行比对,如果数据库表当前版本号与第一次取出来的version值相等,则予以更新,否则认为是过期数据。用下面的一张图来说明:
如上图所示,如果更新操作顺序执行,则数据的版本(version)依次递增,不会产生冲突。但是如果发生有不同的业务操作对同一版本的数据进行修改,那么,先提交的操作(图中B)会把数据version更新为2,当A在B之后提交更新时发现数据的version已经被修改了,那么A的更新操作会失败。
2、乐观锁定的第二种实现方式和第一种差不多,同样是在需要乐观锁控制的table中增加一个字段,名称无所谓,字段类型使用时间戳(timestamp), 和上面的version类似,也是在更新提交的时候检查当前数据库中数据的时间戳和自己更新前取到的时间戳进行对比,如果一致则OK,否则就是版本冲突。
这里以Version数据版本为例,以简单商品下单操作为例:商品goods表中有一个字段status,status为1代表商品未被下单,status为2代表商品已经被下单,那么我们对某个商品下单时必须确保该商品status为1。
下单操作包括3步骤:
1.查询出商品信息
2.根据商品信息生成订单
3.修改商品status为2
那么为了使用乐观锁,我们首先修改t_goods表,增加一个version字段,数据默认version值为1。
t_goods表初始数据如下:
mysql> select * from t_goods;
+----+--------+------+---------+
| id | status | name | version |
+----+--------+------+---------+
| 1 | 1 | 道具 | 1 |
| 2 | 2 | 装备 | 2 |
+----+--------+------+---------+
2 rows in set
mysql>
商品POJO代码:
/**
* ClassName: Goods
* Function: 商品实体类
* 使用lombok注解省略getter、setter等方法
*/
@NoArgsConstructor //无参构造
@Alias("goods")
@Data //setter、getter、toString
@AllArgsConstructor //有参构造
public class Goods implements Serializable {
private static final long serialVersionUID = 3969438177161438988L;//序列化ID
//serialVersionUID主要是为了解决对象反序列化的兼容性问题。
private int id; //主键id
private int status; //商品状态:1未下单、2已下单
private String name; //商品名称
private int version; //商品数据版本号
@Override
public String toString(){
return "good id:"+id+",goods status:"+status+",goods name:"+name+",goods version:"+version;
}
}
序列化操作的时候系统会把当前类的serialVersionUID写入到序列化文件中,当反序列化时系统会去检测文件中的serialVersionUID,判断它是否与当前类的serialVersionUID一致,如果一致就说明序列化类的版本与当前类版本是一样的,可以反序列化成功,否则失败。
简单说一下idea如何生成serialVersionUID。
上面设置好了以后我们光标定位到实现了Serializable接口的类名下面Goods为例,按ctrl+enter弹出选项框,选择Add ‘serialVersionUID’ field即可生成serialVersionUID。
Mapper代码:
/**
* updateGoodsUseCAS:使用CAS(Compare and set)更新商品信息
* @param goods 商品对象
* @return 影响的行数
*/
int updateGoodsUseCAS(Goods goods);
对应的xml代码:
<update id="updateGoodsUseCAS" parameterType="goods">
<![CDATA[
update t_goods
set status=#{status},name=#{name},version=version+1
where id=#{id} and version=#{version}
]]>
</update>
mybatis中<![CDATA[]]>
的作用:在使用mybatis 时我们sql是写在xml 映射文件中,如果写的sql中有一些特殊的字符的话,在解析xml文件的时候会被转义,但我们不希望他被转义,所以我们要使用<![CDATA[ ]]>
来解决。如果文本包含了很多的"<"字符 <=和"&"
字符——就象程序代码一样,那么最好把他们都放到CDATA部件中。但是有个问题那就是 等这些标签都不会被解析,所以我们只把有特殊字符的语句放在 <![CDATA[ ]]>
中,尽量缩小其作用范围。
上面xml中的sql语句其实不要这个<![CDATA[]]>
也可以,这里只是多涨一点姿势。
根据商品id获取商品信息xml代码:
<select id="getGoodsById" resultType="goods">
select status,version from t_goods where id=#{id}
</select>
测试类代码:
@Autowired
private GoodsDao goodsDao;//将mapper注入进来
@Test
public void goodsDaoTest(){
int goodsId = 1;
//根据相同的id查询出商品信息,赋给2个对象
Goods goods1 = goodsDao.getGoodsById(goodsId);
Goods goods2 = goodsDao.getGoodsById(goodsId);
//打印当前商品信息
System.out.println(goods1);
System.out.println(goods2);
//更新商品信息1
goods1.setStatus(2);//修改status为2
int updateResult1 = goodsDao.updateGoodsUseCAS(goods1);
System.out.println("修改商品信息1"+(updateResult1==1?"成功":"失败"));
//更新商品信息2
goods1.setStatus(2);//修改status为2
int updateResult2 = goodsDao.updateGoodsUseCAS(goods1);
System.out.println("修改商品信息2"+(updateResult2==1?"成功":"失败"));
}
执行结果:
good id:1,goods status:1,goods name:道具,goods version:1
good id:1,goods status:1,goods name:道具,goods version:1
修改商品信息1成功
修改商品信息2失败
说明:在GoodsDaoTest测试方法中,我们首先同时查出同一个版本的数据,赋给不同的goods对象,然后good1对象先修改然后执行更新操作,执行成功。然后我们修改goods2,执行更新操作时提示操作失败。这是因为goods2中我们的version字段已经被先前的goods1对象修改了,此时Sql语句中的where version=#{version}
自然就不匹配了,因此操作失败。
二、Redis实现
先简单看一下redis事务,Redis中的事务(transaction)是一组命令的集合。事务同命令一样都是Redis最小的执行单位,一个事务中的命令要么都执行,要么都不执行。Redis事务的实现需要用到 MULTI 和 EXEC 两个命令,事务开始的时候先向Redis服务器发送 MULTI 命令,然后依次发送需要在本次事务中处理的命令,最后再发送 EXEC 命令表示事务命令结束。redis事务是没有隔离级别的概念!所有的命令在事务中,并没有直接被执行!只有发起执行命令(exec)的时候才会执行!redis单条命令保证原子性的,但是redis事务不保证原子性!
编译型异常(代码有问题,命令有错!),事务中所有的命令都不会被执行!
运行时异常(1/0),如果事务队列中存在运行时异常,那么执行命令的时候,其他命令是可以正常执行的。
redis事务本质:一组命令的集合!一个事务中的所有命令都会被序列化,在执行事务过程中,会按照顺序执行!具有一次性(一次执行)、顺序性、排他性(执行过程中不能被其他打断)特征!
Redis的事务是下面4个命令来实现 :
1)multi,开启Redis的事务,置客户端为事务态。
2)exec,提交事务,执行从multi到此命令前的命令队列,置客户端为非事务态。
3)discard,取消事务,置客户端为非事务态。
4)watch,监视键值对,作用时如果事务提交exec时发现监视的监视对发生变化,事务将被取消。
最简单的redis实现乐观锁是利用watch。
线程1:
watch money
decrby money 20
incrby out 20
此时线程2:
get money
set money 1000
然后再线程1执行exec
此时线程1执行exec提交事务就会发现出错了,因为watch监视的money已经不是最新的了(最新的是被线程2修改了的1000),那怎么解决线程1的操作呢?此时线程1先unwatch(解锁之前的监视)、 再watch(监视最新值)然后又执行其余操作(和自旋锁差不多)直到成功为止。
Jedis:是Redis推荐的Java连接开发工具!使用Java操作Redis中间件!在springboot2.x之后,原来使用的jedis被替换为了lettuce。
jedis:采用直连方式,多线程下是不安全的,如果想要避免不安全情况,使用jedis pool连接池!更像BIO
lettuce:采用netty,实例可以在多线程中共享,不存在线程不安全情况,可以减少线程数据了,更像NIO
秒杀小案例
在限量秒杀抢购的场景,一定会遇到抢购成功数超过限量的问题和高并发的情况影响系统性能。
1、虽然能用数据库的锁避免超过限量的问题。但是在大并发的情况下,大大影响数据库性能
2、为了避免并发操作数据库,我们可以使用队列来限制,但是并发量会让队列内存瞬间升高
3、我们又可以用悲观锁来实现,但是这样会造成用户等待,响应慢体验不好
/**
*开启20个线程模拟10000个人并发来抢购
**/
public static void main(String[] arg){
String redisKey = "redisTest";
ExecutorService executorService = Executors.newFixedThreadPool(20);
try {
Jedis jedis = new Jedis("127.0.0.1",6379);
jedis.set(redisKey,"0");
jedis.close();
}catch (Exception e){
e.printStackTrace();
}
for (int i=0;i<10000;i++){
executorService.execute(()->{
Jedis jedis1 = new Jedis("127.0.0.1",6379);
try {
jedis1.watch(redisKey); //监视
String redisValue = jedis1.get(redisKey);
int valInteger = Integer.valueOf(redisValue);
String userInfo = UUID.randomUUID().toString();
if (valInteger<20){
Transaction transaction = jedis1.multi();
transaction.incr(redisKey);
List list = tx.exec();
if (list!=null){
System.out.println("用户:"+userInfo+",秒杀成功!当前成功人数:"+(valInteger+1));
}else {
System.out.println("用户:"+userInfo+",秒杀失败");
}
}else {
System.out.println("已经有20人秒杀成功,秒杀结束");
}
}catch (Exception e){
e.printStackTrace();
}finally {
jedis1.close();
}
});
}
executorService.shutdown();
}
jedis实现乐观锁步骤为:
1、利用redis的watch功能,监控这个redisKey的状态值
2、获取redisKey的值
3、创建redis事务
4、给这个key的值+1
5、然后去执行这个事务,如果key的值被修改过则回滚,key不+1
再给出另一位大佬redis乐观锁实现秒杀案例代码:
import java.util.List;
import java.util.Set;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import javax.transaction.Transaction;
import redis.clients.jedis.Jedis;
/**
* 乐观锁实现秒杀系统
*
*/
public class OptimisticLockTest {
public static void main(String[] args) {
long startTime = System.currentTimeMillis();
initProduct();
initClient();
printResult();
long endTime = System.currentTimeMillis();
long time = endTime - startTime;
System.out.println("程序运行时间 : " + (int)time + "ms");
}
/**
* 初始化商品
* @date 2017-10-17
*/
public static void initProduct() {
int prdNum = 100;//商品个数
String key = "prdNum";
String clientList = "clientList";//抢购到商品的顾客列表
Jedis jedis = RedisUtil.getInstance().getJedis();
if (jedis.exists(key)) {
jedis.del(key);
}
if (jedis.exists(clientList)) {
jedis.del(clientList);
}
jedis.set(key, String.valueOf(prdNum));//初始化商品
RedisUtil.returnResource(jedis);
}
/**
* 顾客抢购商品(秒杀操作)
* @date 2017-10-17
*/
public static void initClient() {
ExecutorService cachedThreadPool = Executors.newCachedThreadPool();
int clientNum = 10000;//模拟顾客数目
for (int i = 0; i < clientNum; i++) {
cachedThreadPool.execute(new ClientThread(i));//启动与顾客数目相等的消费者线程
}
cachedThreadPool.shutdown();//关闭线程池
while (true) {
if (cachedThreadPool.isTerminated()) {
System.out.println("所有的消费者线程均结束了");
break;
}
try {
Thread.sleep(100);
} catch (Exception e) {
// TODO: handle exception
}
}
}
/**
* 打印抢购结果
* @date 2017-10-17
*/
public static void printResult() {
Jedis jedis = RedisUtil.getInstance().getJedis();
Set<String> set = jedis.smembers("clientList");
int i = 1;
for (String value : set) {
System.out.println("第" + i++ + "个抢到商品,"+value + " ");
}
RedisUtil.returnResource(jedis);
}
/**
* 内部类:模拟消费者线程
*/
static class ClientThread implements Runnable{
Jedis jedis = null;
String key = "prdNum";//商品主键
String clientList = "clientList";//抢购到商品的顾客列表主键
String clientName;
public ClientThread(int num){
clientName = "编号=" + num;
}
// 1.multi,开启Redis的事务,置客户端为事务态。
// 2.exec,提交事务,执行从multi到此命令前的命令队列,置客户端为非事务态。
// 3.discard,取消事务,置客户端为非事务态。
// 4.watch,监视键值对,作用是如果事务提交exec时发现监视的键值对发生变化,事务将被取消。
@Override
public void run() {
try {
Thread.sleep((int)Math.random()*5000);//随机睡眠一下
} catch (InterruptedException e) {
e.printStackTrace();
}
while(true){
System.out.println("顾客:" + clientName + "开始抢购商品");
jedis = RedisUtil.getInstance().getJedis();
try {
jedis.watch(key);//监视商品键值对,作用时如果事务提交exec时发现监视的键值对发生变化,事务将被取消
int prdNum = Integer.parseInt(jedis.get(key));//当前商品个数
if (prdNum > 0) {
Transaction transaction = (Transaction) jedis.multi();//开启redis事务
((Jedis) transaction).set(key,String.valueOf(prdNum - 1));//商品数量减一
List<Object> result = ((redis.clients.jedis.Transaction) transaction).exec();//提交事务(乐观锁:提交事务的时候才会去检查key有没有被修改)
if (result == null || result.isEmpty()) {
System.out.println("很抱歉,顾客:" + clientName + "没有抢到商品");// 可能是watch-key被外部修改,或者是数据操作被驳回
}else {
jedis.sadd(clientList, clientName);//抢到商品的话记录一下
System.out.println("恭喜,顾客:" + clientName + "抢到商品");
break;
}
}else {
System.out.println("很抱歉,库存为0,顾客:" + clientName + "没有抢到商品");
break;
}
} catch (Exception e) {
// TODO: handle exception
}finally{
jedis.unwatch();
RedisUtil.returnResource(jedis);
}
}
}
}
}
redis悲观锁实现秒杀案例代码:
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import redis.clients.jedis.Jedis;
import test.OptimisticLockTest.ClientThread;
public class PessimisticLockTest {
public static void main(String[] args) {
long startTime = System.currentTimeMillis();
initProduct();
initClient();
printResult();
long endTime = System.currentTimeMillis();
long time = endTime - startTime;
System.out.println("程序运行时间 : " + (int)time + "ms");
}
/**
* 初始化商品
* @date 2017-10-17
*/
public static void initProduct() {
int prdNum = 100;//商品个数
String key = "prdNum";
String clientList = "clientList";//抢购到商品的顾客列表
Jedis jedis = RedisUtil.getInstance().getJedis();
if (jedis.exists(key)) {
jedis.del(key);
}
if (jedis.exists(clientList)) {
jedis.del(clientList);
}
jedis.set(key, String.valueOf(prdNum));//初始化商品
RedisUtil.returnResource(jedis);
}
/**
* 顾客抢购商品(秒杀操作)
* @date 2017-10-17
*/
public static void initClient() {
ExecutorService cachedThreadPool = Executors.newCachedThreadPool();
int clientNum = 10000;//模拟顾客数目
for (int i = 0; i < clientNum; i++) {
cachedThreadPool.execute(new ClientThread(i));//启动与顾客数目相等的消费者线程
}
cachedThreadPool.shutdown();//关闭线程池
while (true) {
if (cachedThreadPool.isTerminated()) {
System.out.println("所有的消费者线程均结束了");
break;
}
try {
Thread.sleep(100);
} catch (Exception e) {
// TODO: handle exception
}
}
}
/**
* 打印抢购结果
* @date 2017-10-17
*/
public static void printResult() {
Jedis jedis = RedisUtil.getInstance().getJedis();
Set<String> set = jedis.smembers("clientList");
int i = 1;
for (String value : set) {
System.out.println("第" + i++ + "个抢到商品,"+value + " ");
}
RedisUtil.returnResource(jedis);
}
/**
* 消费者线程
*/
static class PessClientThread implements Runnable{
String key = "prdNum";//商品主键
String clientList = "clientList";//抢购到商品的顾客列表
String clientName;
Jedis jedis = null;
RedisBasedDistributedLock redisBasedDistributedLock;
public PessClientThread(int num){
clientName = "编号=" + num;
init();
}
public void init() {
jedis = RedisUtil.getInstance().getJedis();
redisBasedDistributedLock = new RedisBasedDistributedLock(jedis, "lock.lock", 5 * 1000);
}
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
while (true) {
if (Integer.parseInt(jedis.get(key)) <= 0) {
break;//缓存中没有商品,跳出循环,消费者线程执行完毕
}
//缓存中还有商品,取锁,商品数目减一
System.out.println("顾客:" + clientName + "开始抢商品");
if (redisBasedDistributedLock.tryLock(3,TimeUnit.SECONDS)) {//等待3秒获取锁,否则返回false(悲观锁:每次拿数据都上锁)
int prdNum = Integer.parseInt(jedis.get(key));//再次取得商品缓存数目
if (prdNum > 0) {
jedis.decr(key);//商品数减一
jedis.sadd(clientList, clientName);//将抢购到商品的顾客记录一下
System.out.println("恭喜,顾客:" + clientName + "抢到商品");
} else {
System.out.println("抱歉,库存为0,顾客:" + clientName + "没有抢到商品");
}
redisBasedDistributedLock.unlock0();//操作完成释放锁
break;
}
}
//释放资源
redisBasedDistributedLock = null;
RedisUtil.returnResource(jedis);
}
}
}