1. Redis事务
1.1 Redis的事务定义
Redis事务是一个单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。
Redis事务的主要作用就是串联多个命令防止别的命令插队。
三个阶段:
1.2 Multi、Exec、discard
输入Multi命令开始,输入的命令都会依次进入命令队列中,但不会执行,直到输入Exec后,Redis会将之前的命令队列中的命令依次执行。
组队的过程中可以通过discard来放弃组队
案例:
组队成功,提交成功
组队阶段报错,提交失败
组队成功,提交有失败的情况
1.3 事务的错误处理
组队中某个命令出现了报告错误,执行时整个的所有队列都会被取消。
如果执行阶段某个命令报出了错误,则只有报错的命令不会被执行,而其他的命令都会执行,不会回滚。
1.4 WATCH
WATCH命令是一个乐观锁,它可以在EXEC命令执行之前,监视任意数量的数据库键,并在EXEC命令执行时,检查被监视的键是否至少有一个已经被更改过,如果是的化,服务器拒绝执行事务,并向客户端返回代表事务执行失败的空回复。
1.4.1 悲观锁和乐观锁
-
悲观锁 当要对数据库中的一条数据进行修改的时候,为了避免同时被其他人修改,最好的办法就是直接对该数据进行加锁以防止并发。这种借助数据库锁机制,在修改数据之前先锁定,再修改的方式被称之为悲观并发控制【Pessimistic Concurrency Control,缩写“PCC”,又名“悲观锁”】。 悲观锁,具有强烈的独占和排他特性。它指的是对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)修改持保守态度。因此,在整个数据处理过程中,将数据处于锁定状态。 悲观锁的实现,往往依靠数据库提供的锁机制(也只有数据库层提供的锁机制才能真正保证数据访问的排他性,否则,即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据) -
乐观锁 乐观锁是相对悲观锁而言的,乐观锁假设数据一般情况不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果冲突,则返回给用户异常信息,让用户决定如何去做。乐观锁适用于读多写少的场景,这样可以提高程序的吞吐量。 乐观锁采取了更加宽松的加锁机制。也是为了避免数据库幻读、业务处理时间过长等原因引起数据处理错误的一种机制,但乐观锁不会刻意使用数据库本身的锁机制,而是依据数据本身来保证数据的正确性。乐观锁的实现:
- CAS 实现:Java 中java.util.concurrent.atomic包下面的原子变量使用了乐观锁的一种 CAS 实现方式。
- 版本号控制:一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会 +1。当线程 A 要更新数据时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值与当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。
1.4.2 具体过程
客户端A开始watch name,并开启事务
客户端B更改name的值
发现客户端A的事务执行失败
注意点:在一个客户端中watch name,在本客户端开启事务执行更改name的值,watch会失效。必须在多个客户端中更改才会生效。
1.4.3 unwatch
取消 WATCH 命令对所有 key 的监视。
如果在执行 WATCH 命令之后,EXEC 命令或DISCARD 命令先被执行了的话,那么就不需要再执行UNWATCH 了。
1.5 Redis事务三特性
-
单独的隔离操作 事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。 -
没有隔离级别的概念 队列中的命令没有提交之前都不会实际被执行,因为事务提交前任何指令都不会被实际执行 -
不保证原子性 事务中如果有一条命令执行失败,其后的命令仍然会被执行,没有回滚
2. Redis事务—秒杀实例
2.1 单机模拟
在商品秒杀的场景中,我们需要两个映射来发反应秒杀的状况
mapper1:商品id->库存个数
mapper2:商品id->抢到者id的List
秒杀开始后,每当有一个人抢到商品,mapper1中商品的库存数量-1 mapper2中商品对应的抢到者List增添该用户id
public static boolean doSecKill(String uid, String goodsId) {
if (uid == null || goodsId == null) return false;
Jedis jedis = new Jedis("192.168.219.128", 6379);
String kcKey = "sk:"+ goodsId + ":qt";
String userKey = "sk:"+ goodsId + ":user";
String kc = jedis.get(kcKey);
if (kc == null) {
System.out.println("秒杀未开始");
jedis.close();
return false;
}
if(jedis.sismember(userKey, uid)) {
System.out.println("秒杀成功过l");
jedis.close();
return false;
}
if (Integer.parseInt(kc) <= 0) {
System.out.println("秒杀结束");
jedis.close();
return false;
}
jedis.decr(kcKey);
jedis.sadd(userKey,uid);
System.out.println("秒杀成功");
jedis.close();
return true;
}
上面的例子只是秒杀的具体思路,而现实生活中秒杀肯定是多用户高并发,必须考虑并发下的可用性和一致性
2.2 考虑并发
考虑三个人同一个账号购买商品,不加锁没有事务,秒杀结束时会出现负数库存和超出限定个数的秒杀成功者的情况,而且还需要考虑连接超时等问题…
2.2.1 解决连接超时问题
每次的请求都要创建一个Jedis对象将请求打到redis服务器,由于redis是单线程的,后续请求需要排队。
长时间未处理时,本次连接超时,用户秒杀失败,且多次创建redis对象对对服务器而言是很大的浪费
解决连接超时问题,可以采用连接池,类似于Mysql的连接池。
public class JedisPoolUtil {
private static JedisPool jedisPool = null;
private JedisPoolUtil() {}
public static JedisPool getJedisPoolInstance() {
if (null == jedisPool) {
synchronized (JedisPoolUtil.class) {
if (null == jedisPool) {
JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
jedisPoolConfig.setMaxIdle(32);
jedisPoolConfig.setMaxTotal(200);
jedisPoolConfig.setMaxWaitMillis(100*1000);
jedisPoolConfig.setBlockWhenExhausted(true);
jedisPoolConfig.setTestOnBorrow(true);
jedisPool = new JedisPool(jedisPoolConfig, "192.168.219.128", 6379, 60000);
}
}
}
return jedisPool;
}
}
有了连接池,就可以在代码中使用以替代直接连接的方式
Jedis jedis = new Jedis("127.0.0.1", 6379);
JedisPoolUtil jedisPool = JedisPool.getJedisPoolInstance();
Jedis jedis = jedisPoolInstance.getResource();
3.2.2 超卖问题
Redis中没有使用事务时,多请求操作同一个K对应的数据,极易导致数据混乱
采用乐观锁watch监控库存的value,将秒杀过程放入multi队列处理
public static boolean doSecKill(String uid, String goodsId) {
if (uid == null || goodsId == null) return false;
JedisPool jedisPoolInstance = JedisPoolUtil.getJedisPoolInstance();
Jedis jedis = jedisPoolInstance.getResource();
String kcKey = "sk:"+ goodsId + ":qt";
String userKey = "sk:"+ goodsId + ":user";
jedis.watch(kcKey);
String kc = jedis.get(kcKey);
if (kc == null) {
System.out.println("秒杀未开始");
jedis.close();
return false;
}
if(jedis.sismember(userKey, uid)) {
System.out.println("秒杀成功过l");
jedis.close();
return false;
}
if (Integer.parseInt(kc) <= 0) {
System.out.println("秒杀结束");
jedis.close();
return false;
}
Transaction multi = jedis.multi();
multi.decr(kcKey);
multi.sadd(userKey, uid);
List<Object> exec = multi.exec();
if (exec == null || exec.size() <= 0) {
System.out.println("秒杀失败");
jedis.close();
return false;
}
System.out.println("秒杀成功");
jedis.close();
return true;
}
3.2.3 库存遗留问题
秒杀还可能出现这样的问题,库存设置为500当整个秒杀快结束时,后到的用户发出请求时发现失败。但此时的库存却还未到0,这就是库存遗留问题,以为卖完了,其实没卖完,出现这样的状况是由于乐观锁导致的,并发来临之际每个请求都能拿到初始版本的数据。当一个请求完成抢购并且修改数据版本号时候,存在其他用户就不能使用该数据。
开始时使用乐观锁watch了库存数值时,此时的库存数据版本是1.0 当秒杀快结束时,有10个人读取到了当前库存值10 版本5.0 假设第一个人的秒杀请求先处理,库存变为9,版本号变为5.1 其他9个人发秒杀请求想改库存数据时,却发现版本号对不上,无法修改库存数 此时秒杀时间结束,就出现了库存仍有遗留的问题 这样的问题很容易想到死锁解决,但redis中并不支持死锁
对此的解决方案可以采用Lua脚本,实质上是Redis利用其单线程的特性,用任务队列的方式解决多任务并发问题
|