-
Redis实际应用场景
https://www.cnblogs.com/mrhgw/p/6278619.html
Redis在很多方面与其他数据库解决方案不同:
它使用内存提供主存储支持,而仅使用硬盘做持久性的存储;它的数据模型非常独特,用的是单线程。
另外在一些需要大容量数据集的应用,Redis也并不适合,因为它的数据集不会超过系统可用的内存。
所以如果你有大数据应用,而且主要是读取访问模式,那么Redis并不是正确的选择。
比如那些你现有的数据库处理起来感到缓慢的任务。这些你就可以通过Redis来进行优化,或者为应用创建些新的功能。
1、热点数据的缓存
由于redis访问速度块、支持的数据类型比较丰富,所以redis很适合用来存储热点数据,另外结合expire,我们可以设置过期时间然后再进行缓存更新操作,这个功能最为常见,我们几乎所有的项目都有所运用。
2、限时业务的运用
redis中可以使用expire命令设置一个键的生存时间,到时间后redis会删除它。利用这一特性可以运用在限时的优惠活动信息、手机验证码等业务场景。
3、我们的一个Web应用想要列出用户贴出的最新20条评论。
我们假设数据库中的每条评论都有一个唯一的递增的ID字段。
我们可以使用分页来制作主页和评论页,使用Redis的模板,每次新评论发表时,我们会将它的ID添加到一个Redis列表:
LPUSH latest.comments <ID>
我们将列表裁剪为指定长度,因此Redis只需要保存最新的5000条评论:
LTRIM latest.comments 0 5000
每次我们需要获取最新评论的项目范围时,我们调用一个函数来完成(使用伪代码):
FUNCTION get_latest_comments(start, num_items):
id_list = redis.lrange("latest.comments",start,start+num_items - 1)
IF id_list.length < num_items
id_list = SQL_DB("SELECT ... ORDER BY time LIMIT ...")
END
RETURN id_list
END
4、排行榜相关
关系型数据库在排行榜方面查询速度普遍偏慢,所以可以借助redis的SortedSet进行热点数据的排序。
在奶茶活动中,我们需要展示各个部门的点赞排行榜, 所以我针对每个部门做了一个SortedSet,然后以用户的openid作为上面的username,以用户的点赞数作为上面的score, 然后针对每个用户做一个hash,通过zrange by score就可以按照点赞数获取排行榜,然后再根据username获取用户的hash信息,这个当时在实际运用中性能体验也蛮不错的。
另一个很普遍的需求是各种数据库的数据并非存储在内存中,因此在按得分排序以及实时更新这些几乎每秒钟都需要更新的功能上数据库的性能不够理想。
典型的比如那些在线游戏的排行榜,比如一个Facebook的游戏,根据得分你通常想要:
- 列出前100名高分选手
- 列出某用户当前的全球排名
这些操作对于Redis来说小菜一碟,即使你有几百万个用户,每分钟都会有几百万个新的得分。
模式是这样的,每次获得新得分时,我们用这样的代码:
ZADD leaderboard <score> <username>
你可能用userID来取代username,这取决于你是怎么设计的。
得到前100名高分用户很简单:ZREVRANGE leaderboard 0 99。
用户的全球排名也相似,只需要:ZRANK leaderboard <username>。
5、按照用户投票和时间排序
排行榜的一种常见变体模式就像Reddit或Hacker News用的那样,新闻按照类似下面的公式根据得分来排序:
score = points / time^alpha
因此用户的投票会相应的把新闻挖出来,但时间会按照一定的指数将新闻埋下去。下面是我们的模式,当然算法由你决定。
模式是这样的,开始时先观察那些可能是最新的项目,例如首页上的1000条新闻都是候选者,因此我们先忽视掉其他的,这实现起来很简单。
每次新的新闻贴上来后,我们将ID添加到列表中,使用LPUSH + LTRIM,确保只取出最新的1000条项目。
有一项后台任务获取这个列表,并且持续的计算这1000条新闻中每条新闻的最终得分。计算结果由ZADD命令按照新的顺序填充生成列表,老新闻则被清除。这里的关键思路是排序工作是由后台任务来完成的。
6、计数
Redis是一个很好的计数器,这要感谢INCRBY和其他相似命令。
我相信你曾许多次想要给数据库加上新的计数器,用来获取统计或显示新信息,但是最后却由于写入敏感而不得不放弃它们。
好了,现在使用Redis就不需要再担心了。有了原子递增(atomic increment),你可以放心的加上各种计数,用GETSET重置,或者是让它们过期。
例如这样操作:
INCR user:<id> EXPIRE
user:<id> 60
你可以计算出最近用户在页面间停顿不超过60秒的页面浏览量,当计数达到比如20时,就可以显示出某些条幅提示,或是其它你想显示的东西。
redis由于incrby命令可以实现原子性的递增,所以可以运用于高并发的秒杀活动、分布式序列号的生成、具体业务还体现在比如限制一个手机号发多少条短信、一个接口一分钟限制多少请求、一个接口一天限制调用多少次等等。
7、实时分析正在发生的情况,用于数据统计与防止垃圾邮件等,黑名单列表
我们只做了几个例子,但如果你研究Redis的命令集,并且组合一下,就能获得大量的实时分析方法,有效而且非常省力。使用Redis原语命令,更容易实施垃圾邮件过滤系统或其他实时跟踪系统。
8、队列
你应该已经注意到像list push和list pop这样的Redis命令能够很方便的执行队列操作了,但能做的可不止这些:比如Redis还有list pop的变体命令,能够在列表为空时阻塞队列。
现代的互联网应用大量地使用了消息队列(Messaging)。消息队列不仅被用于系统内部组件之间的通信,同时也被用于系统跟其它服务之间的交互。消息队列的使用可以增加系统的可扩展性、灵活性和用户体验。非基于消息队列的系统,其运行速度取决于系统中最慢的组件的速度(注:短板效应)。而基于消息队列可以将系统中各组件解除耦合,这样系统就不再受最慢组件的束缚,各组件可以异步运行从而得以更快的速度完成各自的工作。
此外,当服务器处在高并发操作的时候,比如频繁地写入日志文件。可以利用消息队列实现异步处理。从而实现高性能的并发操作。
典型应用场景
- 缓存系统
- 计数器
- 消息队列系统 发布订阅
- 排行榜
- 社交网络
- 实时系统
一、Redis分布式锁解决方案
1.分布式锁是什么?
分布式锁是控制分布式系统或不同系统之间共同访问共享资源的一种锁实现。如果不同的系统或同一个系统的不同主机之间共享了某个资源时,往往通过互斥来防止彼此之间的干扰。Nginx 分布式部署
分布式锁一般有三种实现方式:1. 数据库乐观锁;2. 基于Redis的分布式锁;3. 基于ZooKeeper的分布式锁。本篇博客将介绍第二种方式,基于Redis实现分布式锁。
实现分布式锁的方式有很多,可以通过各种中间件来进行分布式锁的设计,包括Redis、Zookeeper等,这里我们主要介绍Redis如何实现分布式锁以及在整个过程中出现的问题和优化解决方案。
2.分布式锁设计目的
可以保证在分布式部署的应用集群中,同一个方法的同一操作只能被一台机器上的一个线程执行。、
分布式锁至少包含以下几点可靠性
1.互斥性。任意时刻只有一个服务持有锁。在任意时刻,只有一个客户端能持有锁。
2.不会死锁。即使持有锁的服务异常崩溃,没有主动解锁,后续也能够保证其他服务可以拿到锁。
3.解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。
4.具有容错性。只要大部分的Redis节点正常运行,客户端就可以加锁和解锁。
发散思维,解决可能出现的问题:
在执行业务时,多个并发的请求,导致同个资源被访问被多次执行(加锁)
加锁后,业务并发访问这个线程都返回了失败,只有少部分获取到锁的线程才能处理,这种情况下是非常不通情理的(使用redison已经内部实现的自旋锁来进行锁的等待,获取不到锁就while循环一直尝试加锁,知道锁的获取成功才返回结果,但是这是悲观锁)
加完锁后,每次获取完锁时就对一个特定值+1,执行完后对特定值进行释放,但是线程拿到锁后,抛出异常时,无法执行最后释放锁操作(加finally块执行释放锁操作)
加了finally块后,这个块中的业务失败了,或者程序挂了,redis连接失败了,无法释放锁(对锁加超时时间)
加了超时时间后,在持有锁这个业务中的执行时间比超时时间长,在业务执行的时候,锁超时释放了,这时,其他的请求的线程就能获取到这个锁了,出现了线程的重复进入
(对redis锁的key值用一个UUID来设计,同个线程内获取这个锁都需要生成一个唯一的id,释放时只能让同个线程内存放的id匹配释放)
,第二个线程执行了业务,而且这个业务执行后,比第一个线程快,并且锁超时解锁解了第一个锁
(获取到这个锁的时候,另外开辟一个线程,比如超时时间是10秒,这个线程就每5秒就查询一下这个锁是否失效,如果没有失效就增加5秒中,保持这个锁的有效性)
/**
* 尝试获取分布式锁
* 加锁代码
* 正确姿势
*
* @param jedis Redis客户端
* @param lockKey 锁
* @param requestId 请求标识
* @param expireTime 超期时间
* @return 是否获取成功
*/
public static boolean getDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
// 从2.6.12版本开始,redis为SET命令增加了一系列选项(SET key value [EX seconds] [PX milliseconds] [NX|XX]):
// EX seconds – 设置键key的过期时间,单位时秒
// PX milliseconds – 设置键key的过期时间,单位时毫秒
// NX 只有键key不存在的时候才会设置key的值
// XX 只有键key存在的时候才会设置key的值
String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
if (LOCK_SUCCESS.equals(result)) {
return true;
}
return false;
// 执行上面的set()方法就只会导致两种结果:
// 1. 当前没有锁(key不存在),那么就进行加锁操作,并对锁设置个有效期,同时value表示加锁的客户端。
// 2. 已有锁存在,不做任何操作。
}
/**
* 释放分布式锁
* 解锁代码
* 正确姿势
*
* @param jedis Redis客户端
* @param lockKey 锁
* @param requestId 请求标识
* @return 是否释放成功
*/
public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
if (RELEASE_SUCCESS.equals(result)) {
return true;
}
return false;
}
// 错误示例1
//比较常见的错误示例就是使用jedis.setnx()和jedis.expire()组合实现加锁,代码如下
/*:
1. 通过setnx()方法尝试加锁,如果当前锁不存在,返回加锁成功。
2. 如果锁已经存在则获取锁的过期时间,和当前时间比较,如果锁已经过期,则设置新的过期时间,返回加锁成功。代码如下:
*
* */
public static boolean lock(Jedis jedis, String lockKey, int expireTime) {
long expires = System.currentTimeMillis() + expireTime;
String expiresStr = String.valueOf(expires);
// 如果当前锁不存在,返回加锁成功
if (jedis.setnx(lockKey, expiresStr) == 1) {
return true;
}
String currentValueStr = jedis.get(lockKey); // 如果锁存在,获取锁的过期时间
if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {
// 锁已过期,获取上一个锁的过期时间,并设置现在锁的过期时间
String oldValueStr = jedis.getSet(lockKey, expiresStr); //GETSET key value 设置键的字符串值,并返回旧值
if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
// 考虑多线程并发的情况,只有一个线程的设置值和当前值相同,它才有权利加锁
return true;
}
}
return false; // 其他情况,一律返回加锁失败
}
//解锁
public void unlock(String key, String value) {
try {
if (redisTemplate.opsForValue().get(key).toString().equals(value)) {
redisTemplate.opsForValue().getOperations().delete(key);
}
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 高并发没问题,效率还行
*/
public String order2(String product_id) {
/**
* redis加锁
*/
String value = System.currentTimeMillis() + 10000 + "";
if (!redisLock.lock1(product_id, value)) {
//系统繁忙,请稍后再试
// throw new CongestionException();
}
//##############################业务逻辑#################################//
if (stock.get(product_id) == 0) {
return "活动已经结束了";
//已近买完了
} else { //还没有卖完
try {
//模拟操作数据库
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
/**
* redis解锁
*/
redisLock.unlock(product_id, value);
}
// orders.put(MyStringUtils.getuuid(), product_id);
stock.put(product_id, stock.get(product_id) - 1);
}
//##############################业务逻辑#################################//
return select_info(product_id);
}
public String order_new(String product_id) {
RLock lock = redisson.getLock(KEY);
try {
lock.lock(10, TimeUnit.MINUTES);
//##############################业务逻辑#################################//
//doSomething
//##############################业务逻辑#################################//
} finally {
lock.unlock();
}
return select_info(product_id);
}
1、传统的单实例redis分布式锁实现(关键步骤)
获取锁(含自动释放锁):
SET resource_name my_random_value NX PX 30000
手动删除锁(Lua脚本):
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
2、分布式环境的redis(多master节点)的分布式锁实现
为了保证在尽可能短的时间内获取到(N/2)+1个节点的锁,可以并行去获取各个节点的锁(当然,并行可能需要消耗更多的资源,因为串行只需要count到足够数量的锁就可以停止获取了);
另外,怎么动态实时统一获取redis master nodes需要更进一步去思考了。
QA,补充一下说明
1、在关键问题2.1中,删除就删除了,会造成什么问题?
线程A超时,准备删除锁;但此时的锁属于线程B;线程B还没执行完,线程A把锁删除了,这时线程C获取到锁,同时执行程序;所以不能乱删。
2、在关键问题2.2中,只要在key生成时,跟线程相关就不用考虑这个问题了吗?
不同的线程执行程序,线程之间肯虽然有差异呀,然后在redis锁的value设置有线程信息,比如线程id或线程名称,是分布式环境的话加个机器id前缀咯(类似于twitter的snowflake算法!),但是在del命令只会涉及到key,不会再次检查value,所以还是需要lua脚本控制if(condition){xxx}的原子性。
3、那要不要考虑锁的重入性?
不需要重入;try…finally 没得重入的场景;对于单个线程来说,执行是串行的,获取锁之后必定会释放,因为finally的代码必定会执行啊(只要进入了try块,finally必定会执行)。
4、为什么两个线程都会去删除锁?(貌似重复的问题。不管怎样,还是耐心解答吧)
每个线程只能管理自己的锁,不能管理别人线程的锁啊。这里可以联想一下ThreadLocal。
5、如果加锁的线程挂了怎么办?只能等待自动超时?
看你怎么写程序的了,一种是问题3的回答;另外,那就自动超时咯。这种情况也适用于网络over了。
6、时间太长,程序异常就会蛋疼,时间太短,就会出现程序还没有处理完就超时了,这岂不是很尴尬?
实践部分,公平锁(Fair Lock)代码
基于Redis的Redisson分布式可重入公平锁也是实现了java.util.concurrent.locks.Lock
接口的一种RLock
对象。同时还提供了异步(Async)、反射式(Reactive)和RxJava2标准的接口。它保证了当多个Redisson客户端线程同时请求加锁时,优先分配给先发出请求的线程。所有请求线程会在一个队列中排队,当某个线程出现宕机时,Redisson会等待5秒后继续下一个线程,也就是说如果前面有5个线程都处于等待状态,那么后面的线程会等待至少25秒。
RLock fairLock = redisson.getFairLock("anyLock");
// 最常见的使用方法
fairLock.lock();
大家都知道,如果负责储存这个分布式锁的Redis节点宕机以后,而且这个锁正好处于锁住的状态时,这个锁会出现锁死的状态。为了避免这种情况的发生,Redisson内部提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断的延长锁的有效期。默认情况下,看门狗的检查锁的超时时间是30秒钟,也可以通过修改Config.lockWatchdogTimeout来另行指定。
另外Redisson还通过加锁的方法提供了leaseTime
的参数来指定加锁的时间。超过这个时间后锁便自动解开了。
// 10秒钟以后自动解锁
// 无需调用unlock方法手动解锁
fairLock.lock(10, TimeUnit.SECONDS);
// 尝试加锁,最多等待100秒,上锁以后10秒自动解锁
boolean res = fairLock.tryLock(100, 10, TimeUnit.SECONDS);
...
fairLock.unlock();
Redisson同时还为分布式可重入公平锁提供了异步执行的相关方法:
RLock fairLock = redisson.getFairLock("anyLock");
fairLock.lockAsync();
fairLock.lockAsync(10, TimeUnit.SECONDS);
Future<Boolean> res = fairLock.tryLockAsync(100, 10, TimeUnit.SECONDS);
实践部分,红锁(RedLock)代码
基于Redis的Redisson红锁RedissonRedLock
对象实现了Redlock介绍的加锁算法。该对象也可以用来将多个RLock
对象关联为一个红锁,每个RLock
对象实例可以来自于不同的Redisson实例。
RLock lock1 = redissonInstance1.getLock("lock1");
RLock lock2 = redissonInstance2.getLock("lock2");
RLock lock3 = redissonInstance3.getLock("lock3");
RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);
// 同时加锁:lock1 lock2 lock3
// 红锁在大部分节点上加锁成功就算成功。
lock.lock();
...
lock.unlock();
大家都知道,如果负责储存某些分布式锁的某些Redis节点宕机以后,而且这些锁正好处于锁住的状态时,这些锁会出现锁死的状态。为了避免这种情况的发生,Redisson内部提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断的延长锁的有效期。默认情况下,看门狗的检查锁的超时时间是30秒钟,也可以通过修改Config.lockWatchdogTimeout来另行指定。
另外Redisson还通过加锁的方法提供了leaseTime
的参数来指定加锁的时间。超过这个时间后锁便自动解开了。
RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);
// 给lock1,lock2,lock3加锁,如果没有手动解开的话,10秒钟后将会自动解开
lock.lock(10, TimeUnit.SECONDS);
// 为加锁等待100秒时间,并在加锁成功10秒钟后自动解开
boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS);
...
lock.unlock();
记一次由Redis分布式锁造成的重大事故,避免以后踩坑!
来源:juejin.im/post/5f159cd8f265da22e425f71d
前言
基于Redis使用分布式锁在当今已经不是什么新鲜事了。本篇文章主要是基于我们实际项目中因为redis分布式锁造成的事故分析及解决方案。
背景:我们项目中的抢购订单采用的是分布式锁来解决的。
有一次,运营做了一个飞天茅台的抢购活动,库存100瓶,但是却超卖了!要知道,这个地球上飞天茅台的稀缺性啊!!!事故定为P0级重大事故...只能坦然接受。整个项目组被扣绩效了~~
事故现场
经过一番了解后,得知这个抢购活动接口以前从来没有出现过这种情况,但是这次为什么会超卖呢?
原因在于:之前的抢购商品都不是什么稀缺性商品,而这次活动居然是飞天茅台,通过埋点数据分析,各项数据基本都是成倍增长,活动热烈程度可想而知!话不多说,直接上核心代码,机密部分做了伪代码处理。。。
public SeckillActivityRequestVO seckillHandle(SeckillActivityRequestVO request) {
SeckillActivityRequestVO response;
String key = "key:" + request.getSeckillId;
try {
Boolean lockFlag = redisTemplate.opsForValue().setIfAbsent(key, "val", 10, TimeUnit.SECONDS);
if (lockFlag) {
// HTTP请求用户服务进行用户相关的校验
// 用户活动校验
// 库存校验
Object stock = redisTemplate.opsForHash().get(key+":info", "stock");
assert stock != null;
if (Integer.parseInt(stock.toString()) <= 0) {
// 业务异常
} else {
redisTemplate.opsForHash().increment(key+":info", "stock", -1);
// 生成订单
// 发布订单创建成功事件
// 构建响应VO
}
}
} finally {
// 释放锁
stringRedisTemplate.delete("key");
// 构建响应VO
}
return response;
}
以上代码,通过分布式锁过期时间有效期10s来保障业务逻辑有足够的执行时间;采用try-finally语句块保证锁一定会及时释放。业务代码内部也对库存进行了校验。
事故原因
飞天茅台抢购活动吸引了大量新用户下载注册我们的APP,其中,不乏很多羊毛党,采用专业的手段来注册新用户来薅羊毛和刷单。当然我们的用户系统提前做好了防备,接入阿里云人机验证、三要素认证以及自研的风控系统等校验接口,挡住了大量的非法用户。此处不禁点个赞~
但也正因如此,让用户服务一直处于较高的运行负载中。
抢购活动开始的一瞬间,大量的用户校验请求打到了用户服务。导致用户服务网关出现了短暂的响应延迟,有些请求的响应时长超过了10s,但由于HTTP请求的响应超时我们设置的是30s,这就导致接口一直阻塞在用户校验那里,10s后,分布式锁已经失效了,此时有新的请求进来是可以拿到锁的,也就是说锁被覆盖了。这些阻塞的接口执行完之后,又会执行释放锁的逻辑,这就把其他线程的锁释放了,导致新的请求也可以竞争到锁~这真是一个极其恶劣的循环。
这个时候只能依赖库存校验,但是偏偏库存校验不是非原子性的,采用的是get and compare 的方式,超卖的悲剧就这样发生了~~~
事故分析
仔细分析下来,可以发现,这个抢购接口在高并发场景下,是有严重的安全隐患的,主要集中在三个地方:
1、没有其他系统风险容错处理
由于用户服务吃紧,网关响应延迟,但没有任何应对方式,这是超卖的导火索。
2、看似安全的分布式锁其实一点都不安全
虽然采用了set key value [EX seconds] [PX milliseconds] [NX|XX]
的方式,但是如果线程A执行的时间较长没有来得及释放,锁就过期了,此时线程B是可以获取到锁的。当线程A执行完成之后,释放锁,实际上就把线程B的锁释放掉了。这个时候,线程C又是可以获取到锁的,而此时如果线程B执行完释放锁实际上就是释放的线程C设置的锁。这是超卖的直接原因。
3、非原子性的库存校验
非原子性的库存校验导致在并发场景下,库存校验的结果不准确。这是超卖的根本原因。
通过以上分析,问题的根本原因在于库存校验严重依赖了分布式锁。因为在分布式锁正常set、del的情况下,库存校验是没有问题的。但是,当分布式锁不安全可靠的时候,库存校验就没有用了。
解决方案
实现相对安全的分布式锁
相对安全的定义:set、del是一一映射的,不会出现把其他现成的锁del的情况。从实际情况的角度来看,即使能做到set、del一一映射,也无法保障业务的绝对安全。
因为锁的过期时间始终是有界的,除非不设置过期时间或者把过期时间设置的很长,但这样做也会带来其他问题。故没有意义。
要想实现相对安全的分布式锁,必须依赖key的value值。在释放锁的时候,通过value值的唯一性来保证不会勿删。我们基于LUA脚本实现原子性的get and compare,如下:
public void safedUnLock(String key, String val) {
String luaScript = "local in = ARGV[1] local curr=redis.call('get', KEYS[1]) if in==curr then redis.call('del', KEYS[1]) end return 'OK'"";
RedisScript<String> redisScript = RedisScript.of(luaScript);
redisTemplate.execute(redisScript, Collections.singletonList(key), Collections.singleton(val));
}
我们通过LUA脚本来实现安全地解锁。
实现安全的库存校验
如果我们对于并发有比较深入的了解的话,会发现想 get and compare/ read and save
等操作,都是非原子性的。如果要实现原子性,我们也可以借助LUA脚本来实现。
但就我们这个例子中,由于抢购活动一单只能下1瓶,因此可以不用基于LUA脚本实现而是基于redis本身的原子性。原因在于:
// redis会返回操作之后的结果,这个过程是原子性的
Long currStock = redisTemplate.opsForHash().increment("key", "stock", -1);
发现没有,代码中的库存校验完全是“画蛇添足”。
改进之后的代码
经过以上的分析之后,我们决定新建一个DistributedLocker类专门用于处理分布式锁。
public SeckillActivityRequestVO seckillHandle(SeckillActivityRequestVO request) {
SeckillActivityRequestVO response;
String key = "key:" + request.getSeckillId();
String val = UUID.randomUUID().toString();
try {
Boolean lockFlag = distributedLocker.lock(key, val, 10, TimeUnit.SECONDS);
if (!lockFlag) {
// 业务异常
}
// 用户活动校验
// 库存校验,基于redis本身的原子性来保证
Long currStock = stringRedisTemplate.opsForHash().increment(key + ":info", "stock", -1);
if (currStock < 0) { // 说明库存已经扣减完了。
// 业务异常。
log.error("[抢购下单] 无库存");
} else {
// 生成订单
// 发布订单创建成功事件
// 构建响应
}
} finally {
distributedLocker.safedUnLock(key, val);
// 构建响应
}
return response;
}
深度思考
分布式锁有必要么
改进之后,其实可以发现,我们借助于redis本身的原子性扣减库存,也是可以保证不会超卖的。对的。但是如果没有这一层锁的话,那么所有请求进来都会走一遍业务逻辑,由于依赖了其他系统,此时就会造成对其他系统的压力增大。这会增加的性能损耗和服务不稳定性,得不偿失。基于分布式锁可以在一定程度上拦截一些流量。
分布式锁的选型
有人提出用RedLock来实现分布式锁。RedLock的可靠性更高,但其代价是牺牲一定的性能。在本场景,这点可靠性的提升远不如性能的提升带来的性价比高。如果对于可靠性极高要求的场景,则可以采用RedLock来实现。
再次思考分布式锁有必要么
由于bug需要紧急修复上线,因此我们将其优化并在测试环境进行了压测之后,就立马热部署上线了。实际证明,这个优化是成功的,性能方面略微提升了一些,并在分布式锁失效的情况下,没有出现超卖的情况。
然而,还有没有优化空间呢?有的!
由于服务是集群部署,我们可以将库存均摊到集群中的每个服务器上,通过广播通知到集群的各个服务器。网关层基于用户ID做hash算法来决定请求到哪一台服务器。这样就可以基于应用缓存来实现库存的扣减和判断。性能又进一步提升了!
// 通过消息提前初始化好,借助ConcurrentHashMap实现高效线程安全
private static ConcurrentHashMap<Long, Boolean> SECKILL_FLAG_MAP = new ConcurrentHashMap<>();
// 通过消息提前设置好。由于AtomicInteger本身具备原子性,因此这里可以直接使用HashMap
private static Map<Long, AtomicInteger> SECKILL_STOCK_MAP = new HashMap<>();
...
public SeckillActivityRequestVO seckillHandle(SeckillActivityRequestVO request) {
SeckillActivityRequestVO response;
Long seckillId = request.getSeckillId();
if(!SECKILL_FLAG_MAP.get(requestseckillId)) {
// 业务异常
}
// 用户活动校验
// 库存校验
if(SECKILL_STOCK_MAP.get(seckillId).decrementAndGet() < 0) {
SECKILL_FLAG_MAP.put(seckillId, false);
// 业务异常
}
// 生成订单
// 发布订单创建成功事件
// 构建响应
return response;
}
通过以上的改造,我们就完全不需要依赖redis了。性能和安全性两方面都能进一步得到提升!
当然,此方案没有考虑到机器的动态扩容、缩容等复杂场景,如果还要考虑这些话,则不如直接考虑分布式锁的解决方案。
总结
稀缺商品超卖绝对是重大事故。如果超卖数量多的话,甚至会给平台带来非常严重的经营影响和社会影响。经过本次事故,让我意识到对于项目中的任何一行代码都不能掉以轻心,否则在某些场景下,这些正常工作的代码就会变成致命杀手!
对于一个开发者而言,则设计开发方案时,一定要将方案考虑周全。怎样才能将方案考虑周全?唯有持续不断地学习!
二、利用redis进行去重过滤
// 去重思路;利用filter先对RDD进行过滤
val filteredDstream: DStream[Startuplog] = startuplogStream.transform { rdd =>
println("过滤前:" + rdd.count())
//driver //周期性执行DataFrame
val curdate: String = new SimpleDateFormat("yyyy-MM-dd").format(new Date)
val jedis: Jedis = RedisUtil.getJedisClient
val key = "dau:" + curdate
val dauSet: util.Set[String] = jedis.smembers(key) //SMEMBERS key 获取集合里面的所有key
val dauBC: Broadcast[util.Set[String]] = ssc.sparkContext.broadcast(dauSet)
val filteredRDD: RDD[Startuplog] = rdd.filter { startuplog =>
//executor
val dauSet: util.Set[String] = dauBC.value
!dauSet.contains(startuplog.mid)
}
println("过滤后:" + filteredRDD.count())
filteredRDD
}
//去重思路;把相同的mid的数据分成一组 ,每组取第一个
val groupbyMidDstream: DStream[(String, Iterable[Startuplog])] =
filteredDstream.map(startuplog => (startuplog.mid, startuplog)).groupByKey()
val distinctDstream: DStream[Startuplog] =
groupbyMidDstream.flatMap { case (mid, startulogItr) =>startulogItr.take(1) }
// 保存到redis中
distinctDstream.foreachRDD { rdd =>
//driver
// redis type set
// key dau:2019-06-03 value : mids
rdd.foreachPartition { startuplogItr =>
//executor
val jedis: Jedis = RedisUtil.getJedisClient
val list: List[Startuplog] = startuplogItr.toList
for (startuplog <- list) {
val key = "dau:" + startuplog.logDate
val value = startuplog.mid
jedis.sadd(key, value)
println(startuplog) //往es中保存
}
MyEsUtil.indexBulk(GmallConstant.ES_INDEX_DAU, list)
jedis.close()
}
三、Redis命令拾遗(集合类型Set)--- 搜索筛选商品设计实例。
Redis数据类型之集合(Set)。
单个集合中最多允许存储2的三十二次方减1个元素。内部使用hash table散列表实现。
SADD Key members.....,向集合中增加多个元素,返回成功个数。另外由于集合中不允许有重复元素,所以当添加重复元素时,会忽略不计,当然也不计影响个数。
SMEMBERS Key 获取目标集合Key下的所有元素。
SREM Key members 从目标集合中移除多个元素。
SADD brand::huawei P30 P20 meta30 meta20
SADD screenSize::5.6-6.0 P30 meta30 xiaomi9
SADD brand::xiaomi xiaomi9 xiaomi8 xiaomi7 xiaomi6
SADD os::android P30 P20 meta30 meta20 xiaomi xiaomi9 xiaomi8 xiaomi7 xiaomi6
SINTER brand::huawei screenSize::5.6-6.0 #交集
SDIFF brand::huawei screenSize::5.6-6.0
SDIFF screenSize::5.6-6.0 brand::huawei
SUNION brand::huawei screenSize::5.6-6.0 #并集