List应用场景
Redis之List
- List类型是一个双端链表的结构,容量是2的32次方减1个元素,即40多亿个;
- 其主要功能有push、pop、获取元素等;一般应用在栈、队列、消息队列等场景。
一、 Redis list命令实战
- [LR]PUSH key value1 [value2 …]
以头插或尾插方式插入指定key队列中一个或多个元素 - LRANGE key start stop
获取列表指定范围内的元素、通常用于分页查询
127.0.0.1:6379> lpush products 1 2 3
(integer) 3
127.0.0.1:6379> lpush products 4 5 6
(integer) 6
127.0.0.1:6379> lrange products 0 -1
1) "6"
2) "5"
3) "4"
4) "3"
5) "2"
6) "1"
- LINSERT key BEFORE|AFTER pivot value
在列表的元素前或者后插入元素
127.0.0.1:6379> lrange products 0 -1
1) "6"
2) "5"
3) "4"
4) "3"
5) "2"
6) "1"
127.0.0.1:6379> linsert products before 4 a
(integer) 7
127.0.0.1:6379> lrange products 0 -1
1) "6"
2) "5"
3) "a"
4) "4"
5) "3"
6) "2"
7) "1"
127.0.0.1:6379> linsert products after 4 b
(integer) 8
127.0.0.1:6379> lrange products 0 -1
1) "6"
2) "5"
3) "a"
4) "4"
5) "b"
6) "3"
7) "2"
8) "1"
- LLEN key
获取列表长度、分页查询中获取数据的总数
127.0.0.1:6379> lrange products 0 -1
1) "6"
2) "5"
3) "a"
4) "4"
5) "b"
6) "3"
7) "2"
8) "1"
127.0.0.1:6379> llen products
(integer) 8
- LINDEX key index
通过索引获取列表中的元素
127.0.0.1:6379> lrange products 0 -1
1) "6"
2) "5"
3) "a"
4) "4"
5) "b"
6) "3"
7) "2"
8) "1"
127.0.0.1:6379> lindex products 2
"a"
- LSET key index value
通过索引设置列表元素的值
127.0.0.1:6379> lrange products 0 -1
1) "6"
2) "5"
3) "a"
4) "4"
5) "b"
6) "3"
7) "2"
8) "1"
127.0.0.1:6379> lset products 2 A
OK
127.0.0.1:6379> lrange products 0 -1
1) "6"
2) "5"
3) "A"
4) "4"
5) "b"
6) "3"
7) "2"
8) "1"
- LTRIM key start end
截取队列指定区间的元素,其余元素都删除,用来限制list的长度
127.0.0.1:6379> lrange products 0 -1
1) "6"
2) "5"
3) "A"
4) "4"
5) "b"
6) "3"
7) "2"
8) "1"
127.0.0.1:6379> ltrim products 0 3
OK
127.0.0.1:6379> lrange products 0 -1
1) "6"
2) "5"
3) "A"
4) "4"
- LREM key count value
移除列表元素
127.0.0.1:6379> lpush test a 1 a 2 a 3 a 4 5 6
(integer) 10
127.0.0.1:6379> lrange test 0 -1
1) "6"
2) "5"
3) "4"
4) "a"
5) "3"
6) "a"
7) "2"
8) "a"
9) "1"
10) "a"
127.0.0.1:6379> lrem test 4 a
(integer) 4
127.0.0.1:6379> lrange test 0 -1
1) "6"
2) "5"
3) "4"
4) "3"
5) "2"
6) "1"
- [LR]POP key
从队列的头或未弹出节点元素(返回该元素并从队列中删除)
127.0.0.1:6379> lrange test 0 -1
1) "6"
2) "5"
3) "4"
4) "3"
5) "2"
6) "1"
127.0.0.1:6379> lpop test
"6"
127.0.0.1:6379> lrange test 0 -1
1) "5"
2) "4"
3) "3"
4) "2"
5) "1"
127.0.0.1:6379> lpop test
"5"
127.0.0.1:6379> lrange test 0 -1
1) "4"
2) "3"
3) "2"
4) "1"
- RPOPLPUSH source destination
移除列表的最后一个元素,并将该元素添加到另一个列表并返回
127.0.0.1:6379> lrange src 0 -1
1) "3"
2) "2"
3) "1"
127.0.0.1:6379> lrange dst 0 -1
1) "c"
2) "b"
3) "a"
127.0.0.1:6379> rpoplpush src dst
"1"
127.0.0.1:6379> lrange src 0 -1
1) "3"
2) "2"
127.0.0.1:6379> lrange dst 0 -1
1) "1"
2) "c"
3) "b"
4) "a"
- B[LR]POP key1 [key2 …] timeout
移出并获取列表的第一个或最后一个元素, 如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止。
127.0.0.1:6379> lpush list1 1 2
(integer) 2
127.0.0.1:6379> lpush list2 a b
(integer) 2
127.0.0.1:6379> lrange list1 0 -1
1) "2"
2) "1"
127.0.0.1:6379> lrange list2 0 -1
1) "b"
2) "a"
127.0.0.1:6379> blpop list1 list2 10
1) "list1" #弹出元素所属的列表
2) "2" #弹出元素所属的值
127.0.0.1:6379> blpop list1 list2 10
1) "list1"
2) "1"
127.0.0.1:6379> blpop list1 list2 10
1) "list2"
2) "b"
127.0.0.1:6379> blpop list1 list2 10
1) "list2"
2) "a"
127.0.0.1:6379> blpop list1 list2 10
(nil)
(10.08s) # 列表为空的时候,就等待超时
二、商品列表
https://ju.taobao.com/
这张页面的特点:
1.数据量少,才13页
2.高并发,请求量大。
高并发的淘宝聚划算实现技术方案
像聚划算这种高并发的功能,绝对不可能用数据库的!
一般的做法是先把数据库中的数据抽取到redis里面。采用定时器,来定时缓存。
这张页面的特点,数据量不多,才13页。最大的特点就要支持分页。redis的 list数据结构天然支持这种高并发的分页查询功能。
具体的技术方案采用list 的lpush 和 lrange来实现。
## 先用定时器把数据刷新到list中
127.0.0.1:6379> lpush jhs p1 p2 p3 p4 p5 p6 p7 p8 p9 p10
(integer) 10
## 用lrange来实现分页
127.0.0.1:6379> lrange jhs 0 5
1) "p10"
2) "p9"
3) "p8"
4) "p7"
5) "p6"
6) "p5"
127.0.0.1:6379> lrange jhs 6 10
1) "p4"
2) "p3"
3) "p2"
4) "p1"
SpringBoot+Redis实现商品列表功能
- 步骤一:配置redis、pom、properties、bean
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!--swagger-->
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>2.9.2</version>
</dependency>
<!--swagger-ui-->
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
<version>2.9.2</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>2.4.4</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<dependency>
<groupId>commons-lang</groupId>
<artifactId>commons-lang</artifactId>
<version>2.6</version>
</dependency>
spring.swagger2.enabled=true
spring.redis.database=0
spring.redis.host=127.0.0.1
spring.redis.port=6379
spring.redis.password=
spring.redis.lettuce.pool.max-active=8
spring.redis.lettuce.pool.max-idle=8
spring.redis.lettuce.pool.max-wait=-1ms
spring.redis.lettuce.pool.min-idle=0
spring.redis.timeout=100s
配置Bean
@Configuration
public class RedisConfiguration {
/**
* 重写Redis序列化方式,使用Json方式:
* 当我们的数据存储到Redis的时候,我们的键(key)和值(value)都是通过Spring提供的Serializer序列化到Redis的。
* RedisTemplate默认使用的是JdkSerializationRedisSerializer,
* StringRedisTemplate默认使用的是StringRedisSerializer。
*
* Spring Data JPA为我们提供了下面的Serializer:
* GenericToStringSerializer、Jackson2JsonRedisSerializer、
* JacksonJsonRedisSerializer、JdkSerializationRedisSerializer、
* OxmSerializer、StringRedisSerializer。
* 在此我们将自己配置RedisTemplate并定义Serializer。
*/
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
//创建一个json的序列化对象
GenericJackson2JsonRedisSerializer jackson2JsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
//设置value的序列化方式json
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
//设置key序列化方式string
redisTemplate.setKeySerializer(new StringRedisSerializer());
//设置hash key序列化方式string
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
//设置hash value的序列化方式json
redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
}
- 步骤二:采用定时器把特价商品都刷入redis缓存中
@Service
@Slf4j
public class TaskService {
@Autowired
private RedisTemplate redisTemplate;
@PostConstruct
public void initJHS(){
log.info("启动定时器..........");
new Thread(()->runJhs()).start();
}
/**
* 模拟定时器,定时把数据库的特价商品,刷新到redis中
*/
public void runJhs() {
while (true){
//模拟从数据库读取100件特价商品,用于加载到聚划算的页面中
List<Product> list=this.products();
//采用redis list数据结构的lpush来实现存储
this.redisTemplate.delete(Constants.JHS_KEY);
//lpush命令
this.redisTemplate.opsForList().leftPushAll(Constants.JHS_KEY,list);
try {
//间隔一分钟 执行一遍
Thread.sleep(1000*60);
} catch (InterruptedException e) {
e.printStackTrace();
}
log.info("runJhs定时刷新..............");
}
}
/**
* 模拟从数据库读取100件特价商品,用于加载到聚划算的页面中
*/
public List<Product> products() {
List<Product> list=new ArrayList<>();
for (int i = 0; i < 100; i++) {
Random rand = new Random();
int id= rand.nextInt(10000);
Product obj=new Product((long) id,"product"+i,i,"detail");
list.add(obj);
}
return list;
}
}
- 步骤三:redis分页查询
/**
* 分页查询:在高并发的情况下,只能走redis查询,走db的话必定会把db打垮
*/
@GetMapping(value = "/find")
public List<Product> find(int page, int size) {
List<Product> list=null;
long start = (page - 1) * size;
long end = start + size - 1;
try {
//采用redis list数据结构的lrange命令实现分页查询
list = this.redisTemplate.opsForList().range(Constants.JHS_KEY, start, end);
if (CollectionUtils.isEmpty(list)) {
//TODO 走DB查询
}
log.info("查询结果:{}", list);
} catch (Exception ex) {
//这里的异常,一般是redis瘫痪 ,或 redis网络timeout
log.error("exception:", ex);
//TODO 走DB查询
}
return list;
}
二、缓存击穿
什么是缓存击穿
- 在高并发的系统中,大量的请求同时查询一个key时,如果这个key正好失效或删除,就会导致大量的请求都打到数据库上面去。这种现象我们称为缓存击穿;
- 当查询QPS=1000的时候,这时定时任务更新redis,先删除再添加就会出现缓存击穿,就会导致大量的请求都打到数据库上面去,数据库直接垮掉;
解决缓存击穿问题
方法1 : 针对这种定时更新缓存的特定场景,解决缓存击穿一般是采用主从轮询的原理。
-
定时器更新原理
开辟2块缓存,A 和 B,定时器在更新缓存的时候,先更新B缓存,然后再更新A缓存,记得要按这个顺序。
-
查询原理
用户先查询缓存A,如果缓存A查询不到(例如,更新缓存的时候删除了),再查下缓存B
以上2个步骤,由原来的一块缓存,开辟出2块缓存,最终解决了
缓存击穿的问题
如果缓存A失效了,说明key被定时任务删除、而定时任务先更新B,再更新A,A失效的时候,B已经更新完毕。所以,当缓存A失效后,去查B时,可以查到;
缓存击穿实现
@PostConstruct
public void initJHSAB(){
log.info("启动AB定时器..........");
new Thread(()->runJhsAB()).start();
}
public void runJhsAB() {
while (true){
//模拟从数据库读取100件 特价商品,用于加载到聚划算页面
List<Product> list=this.products();
//先更新B
this.redisTemplate.delete(Constants.JHS_KEY_B);
this.redisTemplate.opsForList().leftPushAll(Constants.JHS_KEY_B,list);
//再更新A
this.redisTemplate.delete(Constants.JHS_KEY_A);
this.redisTemplate.opsForList().leftPushAll(Constants.JHS_KEY_A,list);
try {
Thread.sleep(1000*60);
} catch (InterruptedException e) {
e.printStackTrace();
}
log.info("重新刷新..............");
}
}
@GetMapping(value = "/findAB")
public List<Product> findAB(int page, int size) {
List<Product> list=null;
long start = (page - 1) * size;
long end = start + size - 1;
try {
//采用redis,list数据结构的lrange命令实现分页查询。
list = this.redisTemplate.opsForList().range(Constants.JHS_KEY_A, start, end);
//用户先查询缓存A,如果缓存A查询不到(例如,更新缓存的时候删除了),再查下缓存B
if (CollectionUtils.isEmpty(list)) {
this.redisTemplate.opsForList().range(Constants.JHS_KEY_B, start, end);
}
log.info("{}", list);
} catch (Exception ex) {
//这里的异常,一般是redis瘫痪 ,或 redis网络timeout
log.error("exception:", ex);
//TODO 走DB查询
}
return list;
}
方法二: 使用分布式锁,查询为空时,就锁一下,等待第一个线程将数据查出来,放入缓存中后,释放锁。其他线程就不用再去查数据库了,直接从Redis里面拿就可以了。
三、抢红包
关于微信抢红包,每个人应该都用过,我们今天就来聊聊这个抢红包的技术实现。
像微信抢红包的高峰期一般是在年底公司开年会和春节2个时间段,高峰的并发量是在几千万以上。
高峰的抢红包有3大特点:
- 包红包的人多:也就是创建红包的任务比较多,即红包系统是以单个红包的任务来区分,特点就是在高峰期红包任务多。
- 抢红包的人更多:当你发红包出去后,是几十甚至几百人来抢你的红包,即单红包的请求并发量大。
- 抢红包体验:当你发现红包时,要越快抢到越开心,所以要求抢红包的响应速度要快,一般1秒响应。
微信抢红包的技术实现原理
-
包红包
1.先把金额拆解为小金额的红包,例如 总金额100元,发20个,用户在点保存的时候,就自动拆解为20个随机小红包。
2.这里的存储就是个难题,多个金额(例如20个小金额的红包)如何存储?采用set? list? hash?
set不能存储相同的值,也就无法用在金额相同的红包分发中; -
抢红包
高并发的抢红包时核心的关键技术,就是控制各个小红包的原子性。
例如 20个红包在500人的群里被抢,20个红包被抢走一个的同时要不红包的库存减1,即剩下19个。
在整个过程中抢走一个 和 红包库存减1个 是一个原子操作。
那数据类型符合 “抢走一个 和 红包库存减1个 是一个原子操作” 采用set? list? hash?
list比较适合?????
list的pop操作弹出一个元素的同时会自动从队列里面剔除该元素,它是一个原子性操作。
SpringBoot+Redis实现抢红包
/**
* 包红包的接口
*/
@GetMapping(value = "/set")
public long setRedpacket(int total, int count) {
//拆解红包
Integer[] packet= this.splitRedPacket(total,count);
//为红包生成全局唯一id
long n=idGenerator .incrementId();
//采用list存储红包
String key=RED_PACKET_KEY+n;
this.redisTemplate.opsForList().leftPushAll(key,packet);
//设置3天过期
this.redisTemplate.expire(key,3, TimeUnit.DAYS);
log.info("拆解红包{}={}",key,packet);
return n;
}
/**
* 抢红包接口
*/
@GetMapping(value = "/rob")
public int rob(long redid,long userid) {
//第一步:验证该用户是否抢过
Object packet=this.redisTemplate.opsForHash().get(RED_PACKET_CONSUME_KEY+redid,String.valueOf(userid));
if(packet==null){
//第二步:从list队列,弹出一个红包
Object obj=this.redisTemplate.opsForList().leftPop(RED_PACKET_KEY+redid);
if(obj!=null){
//第三步:抢到红包存起来
this.redisTemplate.opsForHash().put(RED_PACKET_CONSUME_KEY+redid,String.valueOf(userid),obj);
log.info("用户={}抢到{}",userid,obj);
//TODO 异步把数据落地到数据库上
return (Integer) obj;
}
//-1 代表抢完
return -1;
}
//-2 该用户代表已抢
return -2;
}
@Service
public class IdGenerator {
@Autowired
RedisTemplate redisTemplate;
public final String id_key = "unique::key";
public Long incrementId() {
Long key = redisTemplate.opsForValue().increment(id_key);
if (key==null){
try {
Thread.sleep(60*1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
key = generateKey();
}
return key;
}
}
PV阅读量
并发量低的情况:通常情况我们使用redisTemplate.opsForValue().increment(postId,num)就可以实现阅读量功能了。
并发量高的情况:假如每天有10万篇文章,每篇文章有10万次点击阅读;那么就需要10亿次自增操作,一天12小时高峰的话,平摊下来,Redis的QPS需要达到50多万,这就导致Redis服务器CPU必然达到了100%。
二级缓存的高并发微信文章的阅读量PV技术方案
- 一级缓存定时器:
JVM的缓存Map: Map<Long, Map<Long,Object>> pvMap=new ConcurrentHashMap<>();
pvMap中的key存储的是时间块的值, Map<Long,Object>是在时间块内增加的阅读量。存储时间块内所有文章的点击量;
一级缓存定时器:将定时5分钟,将5分钟阅读量map放入Redis的List;
List的value值为每隔时间块的阅读量map; - 二级缓存定时器
定时将List的值拿出来,遍历每个map,先将数据插入数据库,再来修改文章阅读量计数器的值,使用incr;
一级缓存的目的:是避免直接与Redis的阅读量计数器进行交互,分摊redis的并发量到本地的JVM,给JVM降压;
二级缓存的目的:就是实现数据同步。
这种方式利用了队列的特点;
SpringBoot+Redis实现高并发PV阅读量
一、模仿点击阅读量操作;
ublic class InitPVTask {
@Autowired
private RedisTemplate redisTemplate;
@PostConstruct
public void initPV(){
log.info("启动模拟大量PV请求 定时器..........");
new Thread(()->runArticlePV()).start();
}
/**
* 模拟大量PV请求
*/
public void runArticlePV() {
while (true){
this.batchAddArticle();
try {
//5秒执行一次
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
/**
* 对1000篇文章,进行模拟请求PV
*/
public void batchAddArticle() {
for (int i = 0; i < 1000; i++) {
this.addPV(new Integer(i));
}
}
/**
*那如何切割时间块呢? 如何把当前的时间切入时间块中?
* 例如,我们要计算“小时块”,先把当前的时间转换为为毫秒的时间戳,然后除以一个小时,
* 即当前时间T/1000*60*60=小时key,然后用这个小时序号作为key。
* 例如:
* 2020-01-12 15:30:00=1578814200000毫秒 转换小时key=1578814200000/1000*60*60=438560
* 2020-01-12 15:59:00=1578815940000毫秒 转换小时key=1578815940000/1000*60*60=438560
* 2020-01-12 16:30:00=1578817800000毫秒 转换小时key=1578817800000/1000*60*60=438561
* 剩下的以此类推
*
* 每一次PV操作时,先计算当前时间是那个时间块,然后存储Map中。
*/
public void addPV(Integer id) {
//生成环境:时间块为5分钟
//long m5=System.currentTimeMillis()/(1000*60*5);
//为了方便测试 改为1分钟 时间块
long m1=System.currentTimeMillis()/(1000*60*1);
Map<Integer,Integer> mMap=Constants.PV_MAP.get(m1);
if (CollectionUtils.isEmpty(mMap)){
mMap=new ConcurrentHashMap();
mMap.put(id,new Integer(1));
//<1分钟的时间块,Map<文章Id,访问量>>
Constants.PV_MAP.put(m1, mMap);
}else {
//通过文章id 取出浏览量
Integer value=mMap.get(id);
if (value==null){
mMap.put(id,new Integer(1));
}else{
mMap.put(id,value+1);
}
}
}
}
二、一级缓存定时器
public class OneCacheTask {
@Autowired
private RedisTemplate redisTemplate;
@PostConstruct
public void cacheTask(){
log.info("启动定时器:一级缓存消费..........");
new Thread(()->runCache()).start();
}
/**
* 一级缓存定时器消费
* 定时器,定时(5分钟)从jvm的map把时间块的阅读pv取出来,
* 然后push到reids的list数据结构中,list的存储的书为Map<文章id,访问量PV>即每个时间块的pv数据
*/
public void runCache() {
while (true){
this.consumePV();
try {
//间隔1.5分钟 执行一遍
Thread.sleep(90000);
} catch (InterruptedException e) {
e.printStackTrace();
}
log.info("消费一级缓存,定时刷新..............");
}
}
public void consumePV(){
//为了方便测试 改为1分钟 时间块
long m1=System.currentTimeMillis()/(1000*60*1);
Iterator<Long> iterator= Constants.PV_MAP.keySet().iterator();
while (iterator.hasNext()){
//取出map的时间块
Long key=iterator.next();
//小于当前的分钟时间块key ,就消费
if (key<m1){
//先push
Map<Integer,Integer> map=Constants.PV_MAP.get(key);
//push到reids的list数据结构中,list的存储的书为Map<文章id,访问量PV>即每个时间块的pv数据
this.redisTemplate.opsForList().leftPush(Constants.CACHE_PV_LIST,map);
//后remove
Constants.PV_MAP.remove(key);
log.info("push进{}",map);
}
}
}
}
三、二级缓存定时器消费
public class TwoCacheTask {
@Autowired
private RedisTemplate redisTemplate;
@PostConstruct
public void cacheTask(){
log.info("启动定时器:二级缓存消费..........");
new Thread(()->runCache()).start();
}
/**
* 二级缓存定时器消费
* 定时器,定时(6分钟),从redis的list数据结构pop弹出Map<文章id,访问量PV>,弹出来做了2件事:
* 第一件事:先把Map<文章id,访问量PV>,保存到数据库
* 第二件事:再把Map<文章id,访问量PV>,同步到redis缓存的计数器incr。
*/
public void runCache() {
while (true){
while (this.pop()){
}
try {
//间隔2分钟 执行一遍
Thread.sleep(1000*60*2);
} catch (InterruptedException e) {
e.printStackTrace();
}
log.info("消费二级缓存,定时刷新..............");
}
}
public boolean pop(){
//从redis的list数据结构pop弹出Map<文章id,访问量PV>
ListOperations<String, Map<Integer,Integer>> operations= this.redisTemplate.opsForList();
Map<Integer,Integer> map= operations.rightPop(Constants.CACHE_PV_LIST);
log.info("弹出pop={}",map);
if (CollectionUtils.isEmpty(map)){
return false;
}
// 第一步:先存入数据库
// TODO: 插入数据库
//第二步:同步redis缓存
for (Map.Entry<Integer,Integer> entry:map.entrySet()){
// log.info("key={},value={}",entry.getKey(),entry.getValue());
String key=Constants.CACHE_ARTICLE+entry.getKey();
//调用redis的increment命令
long n=this.redisTemplate.opsForValue().increment(key,entry.getValue());
// log.info("key={},pv={}",key, n);
}
return true;
}
}
四、查看浏览量
@GetMapping(value = "/view")
public String view(Integer id) {
String key= Constants.CACHE_ARTICLE+id;
//调用redis的get命令
String n=this.stringRedisTemplate.opsForValue().get(key);
log.info("key={},阅读量为{}",key, n);
return n;
}
四、推送帖子
前置条件:
发微博、帖子、文章push消息
用户发微博,帖子时,先将数据插入DB,和Redis,再推送到个人主页List,和粉丝List;
当用户访问个人主页的时候,显示的是自己发过的微博,帖子或者文章等;使用List存储自己发过的微博,一页10页微博,并且可以进行分页查询;
当用户查看关注列表的时候,显示的是自己关注的人发的微博,文章等;
这就意味着:当用户发微博,首先推送到自己的个人主页List,再推送到粉丝的关注列表List;
当大明星发微博时,就会有大量粉丝来查询明星的个人主页;只能查Redis,不能查DB;不然直接夸了。
基于push技术,实现微博个人列表
/**
* push到个人主页
*/
public void pushHomeList(Integer userId,Integer postId){
String key= Constants.CACHE_MY_POST_BOX_LIST_KEY+userId;
this.redisTemplate.opsForList().leftPush(key,postId);
}
/**
* 获取个人主页列表
*/
public PageResult<Content> homeList(Integer userId,int page, int size){
PageResult<Content> pageResult=new PageResult();
List<Integer> list=null;
long start = (page - 1) * size;
long end = start + size - 1;
try {
String key= Constants.CACHE_MY_POST_BOX_LIST_KEY+userId;
//1.查询用户的总数
int total=this.redisTemplate.opsForList().size(key).intValue();
pageResult.setTotal(total);
//2.采用redis list数据结构的lrange命令实现分页查询。
list = this.redisTemplate.opsForList().range(key, start, end);
//3.去拿明细
List<Content> contents=this.getContents(list);
pageResult.setRows(contents);
}catch (Exception e){
log.error("异常",e);
}
return pageResult;
}
protected List<Content> getContents(List<Integer> list){
List<Content> contents=new ArrayList<>();
//发布内容的key
List<String> hashKeys=new ArrayList<>();
hashKeys.add("id");
hashKeys.add("content");
hashKeys.add("userId");
HashOperations<String, String ,Object> opsForHash=redisTemplate.opsForHash();
for (Integer id:list){
String hkey= Constants.CACHE_CONTENT_KEY+id;
List<Object> clist=opsForHash.multiGet(hkey,hashKeys);
//redis没有去db找
if (clist.get(0)==null && clist.get(1)==null){
Content obj=this.contentMapper.selectByPrimaryKey(id);
contents.add(obj);
}else{
Content content=new Content();
content.setId(clist.get(0)==null?0:Integer.valueOf(clist.get(0).toString()));
content.setContent(clist.get(1)==null?"":clist.get(1).toString());
content.setUserId(clist.get(2)==null?0:Integer.valueOf(clist.get(2).toString()));
contents.add(content);
}
}
return contents;
}
基于push技术,实现微博关注列表
发一条微博,批量推送给所有粉丝
/**
* 发一条微博,批量推送给所有粉丝
*/
private void pushFollower(int userId,int postId){
SetOperations<String, Integer> opsForSet = redisTemplate.opsForSet();
//读取粉丝集合
String followerkey=Constants.CACHE_KEY_FOLLOWER+userId;
//千万不能取set集合的所有数据,如果数据量大的话,会卡死
// Set<Integer> sets= opsForSet.members(followerkey);
Cursor<Integer> cursor = opsForSet.scan(followerkey, ScanOptions.NONE);
try{
while (cursor.hasNext()){
//拿出粉丝的userid
Integer object = cursor.next();
String key= Constants.CACHE_MY_ATTENTION_BOX_LIST_KEY+object;
this.redisTemplate.opsForList().leftPush(key,postId);
}
}catch (Exception ex){
log.error("",ex);
}finally {
try {
cursor.close();
} catch (IOException e) {
log.error("",e);
}
}
}
查看关注列表
/**
* 获取关注列表
*/
public PageResult<Content> attentionList(Integer userId,int page, int size){
PageResult<Content> pageResult=new PageResult();
List<Integer> list=null;
long start = (page - 1) * size;
long end = start + size - 1;
try {
String key= Constants.CACHE_MY_ATTENTION_BOX_LIST_KEY+userId;
//1.设置总数
int total=this.redisTemplate.opsForList().size(key).intValue();
pageResult.setTotal(total);
//2.采用redis,list数据结构的lrange命令实现分页查询。
list = this.redisTemplate.opsForList().range(key, start, end);
//3.去拿明细数据
List<Content> contents=this.getContents(list);
pageResult.setRows(contents);
}catch (Exception e){
log.error("异常",e);
}
return pageResult;
}
优化
优化方案采用:限定个人和关注list的长度为1000,即,
发微博的时候,往个人和关注list push完成后,把list的长度剪切为1000,
具体的技术方案采用list 的ltrim命令来实现。
LTRIM key start end
截取队列指定区间的元素,其余元素都删除
微博个人和关注列表的性能优化
//性能优化,截取前1000条
if(this.redisTemplate.opsForList().size(key)>1000){
this.redisTemplate.opsForList().trim(key,0,1000);
}
六、 普通分布式锁
锁,顾名思义,就是一份数据在同一时间内只能被一个人使用,不能2个人同时使用。
对于锁,一般有2种使用场景:
-
单机系统:单机系统在多用户多线程并发操作同一份资源(数据)的时候,采用线程加锁的机制,
即当某个线程获取到该资源(数据)后,立即加锁,当使用完后,再解锁,其它线程就可以接着使用了。
例如,在JAVA的锁机制synchronize/Lock等。 -
分布式系统:在分布式系统环境中,单机的线程锁机制是不起作用的,因为系统采用了集群部署在不同的机器上;
如果是多用户同时访问同一份资源(数据)时,JAVA处理锁机制的synchronize/Lock是起不到作用的(只在本地的JVM内有效),因为资源(数据)在不同的服务器之间共享,即多个JVM之间的同步关系。
因此,针对分布式环境,我们必须采用分布式锁。
分布式锁解决2个问题:
- 解决多用户操作的幂等性问题
典型案例:用户下订单操作,为了避免用户重复提交导致出现多条相同订单,一般采用分布式锁来解决订单的幂等性问题。 - 把多用户的并行操作转化为串行操作
典型案例:在秒杀系统的高并发减库存操作,例如 库存只剩下3台手机,A用户秒杀2台,B用户也秒杀2台,如果A、B用户同时下单就会出现库存等于-1的情况。
分布式锁必须具备5个特性
- 互斥性:指不可能同时2个人(线程)以上的人拿到锁。
- 可用性:redis集群环境下,不能因为某个节点瘫痪,导致客户端不能获取和释放锁。
- 终止性:为了避免死锁,必须有自动的终止或撤销锁操作,一般是采用超时处理机制。
- 抢占性:别人(其他线程)已经占了锁,不能私下解锁别人(其他线程)的锁,必须等等锁的释放
- 可重入性: 同一个节点上的同一个线程如果获取了锁之后那么也可以再次获取这个锁。
分布式锁的实现方案
- 基于数据库的分布式锁;
- 基于Zookeeper的分布式锁(其临时顺序结点)
- 基于Redis的分布式锁(依赖redis自身的原子来实现)
基于Redis的SETNX实现分布式锁
- 步骤1:采用SETNX加锁
采用SETNX 来实现,NX 是not exist 的意思。
例如:
127.0.0.1:6379> setnx lock01 true
(integer) 1
127.0.0.1:6379> setnx lock01 true
(integer) 0
如果 SETNX 返回1,说明拿到锁了。
如果 SETNX 返回0,说明拿锁失败,被其他线程占用。
- 步骤2:为了避免死锁,增加超时机制
为该锁增加10分钟超时,防止死锁
127.0.0.1:6379> expire lock01 600
(integer) 1
以上操作有一个问题?
无法保证锁的原子性操作,因为要操作2个步骤 setnx expire
用以下来代替:
SET key value [NX] [XX] [EX ] [PX [millseconds]] 设置一对key value
必选参数说明
SET:命令
key:待设置的key
value: 设置的key的value
可选参数说明
NX:表示key不存在才设置,如果存在则返回NULL
XX:表示key存在时才设置,如果不存在则返回NULL
EX seconds:设置过期时间,过期时间精确为秒
PX millsecond:设置过期时间,过期时间精确为毫秒
以上set 代替了 setnx +expire 需要分2次执行命令操作的方式,保证了原子性
127.0.0.1:6379> set lock true NX px 600000
OK
127.0.0.1:6379> set lock true NX px 600000
(nil)
如果setnx 返回ok 说明拿到了锁
如果setnx 返回 nil,说明拿锁失败,被其他线程占用。
基于redis的分布式锁实现下订单防止重复提交
用户提交订单的时候,如果客户端APP或JS没做校验的话,用户对订单的提交按钮重复点击,就会造成数据库出现多笔订单。
为了避免用户重复提交导致数据库出现多条相同订单,一般采用分布式锁来解决订单的幂等性问题。
@RequestMapping(value = "/redis")
public class RedisController {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@PostMapping(value = "/createOrder", produces = APPLICATION_JSON_UTF8_VALUE, consumes = APPLICATION_JSON_UTF8_VALUE)
public String createOrder(@RequestBody OrderDTO obj) {
//步骤1:先转换为唯一MD5,尽量减小锁的粒度,针对一个商品一个锁;
String json=JsonUtil.object2Json(obj);
String md5 = DigestUtils.md5DigestAsHex(json.getBytes()).toUpperCase();
//步骤2:把md5设置为分布式锁的key
/**
* setIfAbsent 的作用就相当于 SET key value [NX] [XX] [EX <seconds>] [PX [millseconds]]
* 设置 5分钟过期
*/
Boolean bo=stringRedisTemplate.opsForValue().setIfAbsent(md5,"1",60*5, TimeUnit.SECONDS);
if(bo){
// 加锁成功
log.debug("{}拿锁成功,开始处理业务",md5);
try {
//模拟10秒 业务处理
Thread.sleep(1000*10);
} catch (InterruptedException e) {
e.printStackTrace();
}
//步骤3:解锁
stringRedisTemplate.delete(md5);
log.debug("{}拿锁成功,结束处理业务",md5);
return "ok";
}else{
log.debug("{}拿锁失败",md5);
//拿不锁,直接退出
return "请不要重复点击!";
}
}
}
什么是不可重入锁?
所谓不可重入锁,即若当前线程执行某个方法已经获取了该锁,那么在方法中尝试再次获取锁时,就会获取不到被阻塞。同一个人拿一个锁 ,只能拿一次不能同时拿2次。
设计一个不可重入锁
public class Lock{
//锁的状态:true=锁住,false=解锁
private boolean isLocked = false;
/**
* 获取锁
*/
public synchronized void lock() {
while(isLocked){
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
isLocked = true;
}
/**
* 解锁
*/
public synchronized void unlock(){
isLocked = false;
notify();
}
}
@Slf4j
public class OrderDemo {
Lock lock = new Lock();
public void operation() {
//加锁
lock.lock();
log.info("第一层锁:先减库存");
//无法重入,进入锁等待,即死锁
doAdd();
lock.unlock();
}
public void doAdd() {
//加锁
lock.lock();
log.info("第二层锁:插入订单");
lock.unlock();
}
public static void main(String[] args){
OrderDemo orderDemo=new OrderDemo();
orderDemo.operation();
}
}
可重入锁
可重入锁,也叫做递归锁,指的是在同一线程内,外层函数获得锁之后,内层递归函数仍然可以获取到该锁。
说白了就是同一个线程再次进入同样代码时,可以再次拿到该锁。
它的作用是:防止在同一线程中多次获取锁而导致死锁发生。
在java的编程中synchronized 和 ReentrantLock都是可重入锁。
基于synchronized下订单的可重入锁
模仿下订单操作,先减库存,再插入订单表。
public class SynchronizedDemo {
//模拟库存100
int count=100;
public synchronized void operation(){
log.info("第一层锁:减库存");
//模拟减库存
count--;
add();
log.info("下订单结束库存剩余:{}",count);
}
private synchronized void add(){
log.info("第二层锁:插入订单");
try {
Thread.sleep(1000*10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
基于ReentrantLock的递归锁
ReentrantLock,是一个可重入且独占式的锁,是一种递归无阻塞的同步锁。
和synchronized关键字相比,它更灵活、更强大,增加了轮询、超时、中断等高级功能
- ReentrantLock的递归实现
public class ReentrantLockDemo {
private Lock lock = new ReentrantLock();
public void doSomething(int n){
try{
//进入递归第一件事:加锁
lock.lock();
log.info("--------递归{}次--------",n);
if(n<=2){
try {
Thread.sleep(1000*2);
} catch (InterruptedException e) {
e.printStackTrace();
}
this.doSomething(++n);
}else{
return;
}
}finally {
lock.unlock();
}
}
}
@GetMapping(value = "/lock2")
public void lock2(String key) {
log.info("-------请求{}--------",key);
this.reentrantLockDemo.doSomething(1);
log.info("--------请求{}结束--------",key);
}
redis如何实现分布式重入锁
我们已经知道SETNX是不支持重入锁的,但我们需要重入锁,怎么办呢?
目前对于redis的重入锁业界还是有很多解决方案的,最流行的就是采用Redisson
什么是 Redisson
Redisson是Redis官方推荐的Java版的Redis客户端。
它基于Java实用工具包中常用接口,为使用者提供了一系列具有分布式特性的常用工具类。
它在网络通信上是基于NIO的Netty框架,保证网络通信的高性能。
在分布式锁的功能上,它提供了一系列的分布式锁;如:可重入锁(Reentrant Lock)、公平锁(Fair Lock、联锁(MultiLock)、红锁(RedLock)、 读写锁(ReadWriteLock)等等。
体验redis分布式重入锁
步骤1:加入pom依赖包
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.9.0</version>
</dependency>
步骤2:配置文件
#redi开关配置 见:封装类 CacheConfiguration
#设置模式 sentinel/cluster/single
spring.redis.mode=single
#redis基础配置 见:封装类 RedisProperties
spring.redis.database=0
spring.redis.password=
spring.redis.timeout=3000
#redis线程池配置 见:封装类 RedisPoolProperties
spring.redis.pool.conn-timeout=3000
spring.redis.pool.so-timeout=3000
spring.redis.pool.size=10
#单机模式 见:封装类 RedisSingleProperties
spring.redis.single.address=192.168.1.138:6379
#集群模式 见:封装类 RedisClusterProperties
spring.redis.cluster.scan-interval=1000
spring.redis.cluster.nodes=192.168.2.58:7000,192.168.2.58:7001,192.168.2.58:7002,192.168.2.58:7003,192.168.2.58:7004,192.168.2.58:7005,192.168.2.58:7006
spring.redis.cluster.read-mode=SLAVE
spring.redis.cluster.retry-attempts=3
spring.redis.cluster.failed-attempts=3
spring.redis.cluster.slave-connection-pool-size=64
spring.redis.cluster.master-connection-pool-size=64
spring.redis.cluster.retry-interval=1500
#哨兵模式 见:RedisSentinelProperties
spring.redis.sentinel.master=business-master
spring.redis.sentinel.nodes=
spring.redis.sentinel.master-onlyWrite=true
spring.redis.sentinel.fail-max=3
步骤3:Redisson重入锁代码
public class RedisController {
@Autowired
RedissonClient redissonClient;
@GetMapping(value = "/lock")
public void get(String key) throws InterruptedException {
this.getLock(key, 1);
}
private void getLock(String key, int n) throws InterruptedException {
//模拟递归,3次递归后退出
if (n > 3) {
return;
}
//步骤1:获取一个分布式可重入锁RLock
//分布式可重入锁RLock :实现了java.util.concurrent.locks.Lock接口,同时还支持自动过期解锁。
RLock lock = redissonClient.getLock(key);
//步骤2:尝试拿锁
// 1. 默认的拿锁
//lock.tryLock();
// 2. 支持过期解锁功能,10秒钟以后过期自动解锁, 无需调用unlock方法手动解锁
//lock.tryLock(10, TimeUnit.SECONDS);
// 3. 尝试加锁,最多等待3秒,上锁以后10秒后过期自动解锁
// lock.tryLock(3, 10, TimeUnit.SECONDS);
boolean bs = lock.tryLock(3, 10, TimeUnit.SECONDS);
//lock.lock();
if (bs) {
try {
// 业务代码
log.info("线程{}业务逻辑处理: {},递归{}" ,Thread.currentThread().getName(), key,n);
//模拟处理业务
Thread.sleep(1000 * 5);
//模拟进入递归
this.getLock(key, ++n);
} catch (Exception e) {
log.error(e.getLocalizedMessage());
} finally {
//步骤3:解锁
lock.unlock();
log.info("线程{}解锁退出",Thread.currentThread().getName());
}
} else {
log.info("线程{}未取得锁",Thread.currentThread().getName());
}
}
}
红锁
上面的分布式锁还是存在问题,如果加锁后,不管是主从,还是哨兵,还是集群redis,当主Redis的锁还没同步到其他结点时就挂了,从节点升级为主节点,再次可以获得锁,这样必将导致同一把锁被多人使用。
redlock算法的设计原理
redlock为了解决CAP的cp,数据一致性,采用有n个redis节点,n为奇数,上图我们有3个master,这3个master完全独立,不是主从复制或集群。(这里和zookeeper类似,熟悉zookeeper的同学或听过我课程的同学都能懂)
为什么N必须为奇数呢?
在回答这个问题前,先给大家讲一下什么是容错?
即在集群环境中,失败实例多少个我还是可以容忍,即系统的CP还是数据一致的。
例如
在集群环境中,redis失败1台,我还是可以容忍, 2n+1=2+1=3, 故部署奇数为3台redis 实例, 所以在3台的集群中,死掉1台,剩下2台集群正常工作。
在集群环境中,redis失败2台,我还是可以容忍, 2n+1=2*2+1=5,故部署奇数为5台redis 实例, 所以在5台的集群中,死掉2台,剩下3台集群正常工作。
那为什么是奇数,不是偶数?
因为有一个原则:使用资源最少,产生最大的容错。
例如
在集群环境中,redis失败1台,我还是可以容忍;奇数= 2n+1=2+1=3,偶数=2n+2=4
在集群环境中,redis失败2台,我还是可以容忍;奇数= 2n+1=2*2+1=5,偶数=2n+2=6
以上容忍度一样,但是部署的实例偶数比奇数多了一台。
SpirngBoot+Redis实现红锁
public class RedLockController {
public static final String CACHE_KEY_REDLOCK = "MY_REDLOCK";
@Autowired
RedissonClient redissonClient1;
@Autowired
RedissonClient redissonClient2;
@Autowired
RedissonClient redissonClient3;
@GetMapping(value = "/redlock")
public void getlock() {
//CACHE_KEY_REDLOCK为redis 分布式锁的key
RLock lock1 = redissonClient1.getLock(CACHE_KEY_REDLOCK);
RLock lock2 = redissonClient2.getLock(CACHE_KEY_REDLOCK);
RLock lock3 = redissonClient3.getLock(CACHE_KEY_REDLOCK);
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
boolean isLock;
try {
//waitTime 锁的等待时间处理,正常情况下 等5s
//leaseTime的租约时间,就是redis key的过期时间,正常情况下等5分钟。
isLock = redLock.tryLock(1000*60*30, 1000*60*30, TimeUnit.MILLISECONDS);
log.info("线程{},是否拿到锁:{} ",Thread.currentThread().getName(),isLock);
if (isLock) {
//TODO if get lock success, do something;
Thread.sleep(1000*60*30);
}
} catch (Exception e) {
log.error("redlock exception ",e);
} finally {
// 无论如何, 最后都要解锁
redLock.unlock();
}
}
}