Redis事务和锁
Redis 事务
redis事务就是一个命令执行的队列,将一系列预定义命令包装成一个整体(一个队列)。当执行时,一次性 按照添加顺序依次执行,中间不会被打断或者干扰。
redisTemplate.multi(); //开启事务
redisTemplate.discard(); //取消事务,需要放在开启和执行中间才能取消。
redisTemplate.exec(); //执行事务
事务工作流:
若当前不是事务状态,则识别该指令是普通指令(执行)还是Mutil指令(进入事务状态);
若当前是事务状态,则识别该指令是普通指令(加入队列),还是exec指令(执行事务),还是discard指令(取消当前事务,放弃之前的指令)。
(图源自水印)
-
redis事务执行前先检查一遍命令并放到队列里。命令入队阶段出错(命令格式错误等),命令还没执行,数据没改变,不影响。
-
在事务中加入命令时,出现discard,之前的命令取消执行,事务终止。
-
使用exec()后执行事务出错时,已经执行完毕的命令对应的数据不会自动回滚,需要程序员自己在代码中实现回滚。未执行的命令也放弃。
redis如何实现回滚?
执行事务中出现错误,已经执行完毕的命令对应的数据不会自动回滚,需要程序员自己在代码中实现回滚。未执行的命令也放弃。
为什么redis是单线程的已经保证了原子性,但是还是需要加锁?
- redis是单线程,本身的指令不会产生并发问题。指令都是one by one 执行的。
- 但是具体到业务上,我们希望比如 **获取余额,判断,扣款,写回 **整个过程就必须是原子性的,否则就可能出错。这就需要分布式锁,来保证整个过程的原子性。
为什么分布式应用需要分布式锁而synchronized不起作用?
- 在传统单体应用单机部署的情况下,可以使用Java并发相关的锁,如ReentrantLcok或synchronized进行互斥控制。因为单体状态下,多线程运行在同一个JVM里,多线程由于可以共享堆内存,因此可以简单的采取内存作为标记存储位置。
- 而分布式环境下,系统被部署在多机器多JVM上同时提供服务,这使得原单机部署情况下的并发控制锁策略失效了,需要一种跨JVM的互斥机制来控制共享资源的访问,于是出现了分布式锁。因为进程之间甚至可能都不在同一台物理机上,因此需要将标记存储在一个所有进程都能看到的地方,可以用redis实现分布式锁。
什么时候用synchronized?什么时候用分布式锁?
- 如果应用是前后端分离的单体应用,也就是说应用在同一台机器上运行,那么一般来说使用 synchronized 就可以满足同步的需求了。因为后端只有一个进程,只运行在一个JVM上。
- 分布式锁通常用于协调在多个机器上运行的进程/线程之间的访问共享资源。当你的后端部署在多个JVM上,就需要用到分布式锁。
redis锁
1、Watch
对 key 添加监视锁,在执行exec前如果key发生了变化,终止事务执行。
若设置一下重试次数就相当于乐观锁。
redisTemplate.watch(ARTICLE_VIEWS_COUNT); //添加对key的监视
redisTemplate.unwatch(); //取消对所有 key 的监视
redisTemplate.multi(); //开启事务
...... //业务操作
redisTemplate.exec(); //执行事务
应用场景:
- 多个客户端有可能同时操作同一组数据,并且该数据一旦被操作修改后,将不适用于继续操作。即不能重复操作同一个数据的场景。
- 在操作之前锁定要操作的数据,一旦发生变化,终止当前操作
- 比如货物上架,一个货物上架了,再次上架就失败。
2、分布式锁setnx
加锁
使用 setnx 设置一个公共锁。发展历史:
- setnx key value
这种方式的缺点是容易产生死锁,因为有可能忘记解锁,或者解锁失败。 - setnx key value;expire key seconds
给锁增加了过期时间,避免出现死锁。但这两个命令不是原子的,第二步可能会失败,依然无法避免死锁问题。 - set key value nx ex seconds
将加锁、过期命令编排到一起,它们是原子操作了,可以避免死锁。
Redis 2.8 版本中作者加入了 set 指令的扩展参数,使得 setnx 和 expire 指令可以一起执行,彻底解决了分布式锁的乱象。
但是仍存在问题,就是A锁到期后自动解锁,但线程在A锁到期后才执行完任务并再次解锁,这个时候可能解锁的是别的线程的锁。
- 所以只需要把到期时间设置得长一点,保证让线程能执行完任务,一般不会出现问题。
- 另一种办法是:在加锁时就要给锁设置一个额外标识,进程获取锁后才能访问这个标识(即通过key获取锁,通过value标识)。当进程解锁的时候,要进行判断,当前锁的value是不是对应自己线程的value,对应才能释放。
- 解锁时要先判断、再释放。这两部不是原子操作,就可能导致重复释放(一个在判断,一个在释放)。这就需要采用Lua 脚本,通过 Lua 脚本将两个命令编排在一起,而整个Lua脚本的执行过程是原子的。
解锁
解锁就是删除代表锁的那份数据。del key
获取分布式锁失败后怎么处理?
-
自旋直到成功获取
while(!redisLockUtils.getLock(COMMENT_USER_LIKE+COMMENT_LIKE_COUNT+LOCK, value) ); //直到获取了锁,反回了true,才退出循环
-
线程休眠一定时间,重新递归调用获取。
Boolean getLock = redisLockUtils.getLock(COMMENT_USER_LIKE+COMMENT_LIKE_COUNT+LOCK, value); if (getLock) { ...... //业务代码 } else { // 休眠重试获取锁 Thread.sleep(RedisLockUtils.LOCK_REDIS_WAIT); this.saveCommentLike(commentId); }
Redis与Lua脚本
redis与lua实现分布式锁
-
lua脚本,通过redis的eval/evalsha命令,而这个命令是原子性的,保证了lua脚本原子性执行。
-
lua脚本原子性删除锁:
-- KEYS和ARGV分别是以集合方式传入的参数,对应上文的key和value。 -- 如果对应的value等于传入的value。 if redis.call('get', KEYS[1]) == ARGV[1] then -- 执行删除操作 return redis.call('del', KEYS[1]) else -- 不成功,返回0 return 0 end
redia与lua实现限流
Redis作为缓存层
redis怎么作为缓存使用?
- 在读取数据时,先从Redis中尝试获取,如果没有,则从数据库中读取,并将其存储到Redis中以备下次使用。
- 在写入数据时,先将数据存储到Redis中,然后再将其写入到数据库中,以确保数据的一致性。
- 数据更新时同步更新缓存:当数据库中的数据发生变化时,要及时更新Redis中的缓存数据,以保证缓存数据的一致性。
- 限制缓存容量:为了防止Redis缓存中数据量过大而导致内存溢出,可以设置一个最大容量,并在达到容量限制时,将最近最少使用的数据删除。
把redis作为缓存层的基本操作流程?
public void handleRequest(HttpServletRequest request, HttpServletResponse response) {
// 1. 接收请求
String param1 = request.getParameter("param1");
String param2 = request.getParameter("param2");
// 2. 验证请求
if (param1 == null || param1.isEmpty() || param2 == null || param2.isEmpty()) {
// 返回错误响应
response.getWriter().write("Missing parameters");
return;
}
// 3. 读取缓存
String data = cache.get(param1, param2);
// 4、判断缓存是否存在,不存在从数据库读取
if(data == null){
data = dao.get(param1, param2);
}
// 5. 处理数据
String result = processRequest(data);
// ...
// 6. 写入缓存
cache.put(param1, param2, result);
// 7. 写入数据库
dao.save(param1, param2, result);
// 8. 返回响应
response.getWriter().write(result);
}
}
如果出现写入redis成功,而写入数据库失败导致数据不一致怎么办?
- 可以使用Spring框架的声明式事务管理来实现,也就是在handleRequest()方法上添加@Transactional注解
- 写入Redis成功,但写入数据库失败,事务管理器会回滚事务,撤销Redis中的写入操作,保证Redis和数据库中的数据一致性。