redis
0. noSQL 非结构化数据库 基础知识
- 无结构
- 没有关联,有重复数据
- 没有固定格式的查询语句
- 不满足ACID,不支持事务
1 redis(Remote Dictionary Server)
1.1 特点
前台启动转为后台启动(修改配置文件):【也可配置为开机自启,参考黑马】
然后执行:redis-server redis.conf
1.2 数据类型
1.3 常用指令
关于有效期:
关于hash:
2. 使用spring集成的redis
@SpringBootTest
class RedisDemoApplicationTests {
@Autowired
private RedisTemplate redisTemplate;
@Test
void testString() {
redisTemplate.opsForValue().set("name","google");
Object name = redisTemplate.opsForValue().get("name");
System.out.println(name);
}
}
执行上述代码通过,但是redis中并不能查到name字段为Google的记录:
原因:我们加入的name,被spring默认当成对象,序列化后塞进redis。解决方案:对key进行String序列化,对value进行json序列化(自定义序列化)。通过配置类实现:
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String,Object> redisTemplate( RedisConnectionFactory factory){
RedisTemplate<String,Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
// 设置key的序列化方式
template.setKeySerializer(RedisSerializer.string());
// 设置value的序列化方式
template.setValueSerializer(RedisSerializer.json());
// 设置hash的key的序列化方式
template.setHashKeySerializer(RedisSerializer.string());
// 设置hash的value的序列化方式
template.setHashValueSerializer(RedisSerializer.json());
template.afterPropertiesSet();
return template;
}
}
但是这个过程,value是类的时候,把类的字节码写进去了,是业务无关的,且占空间。解决方案:
这个String序列化器Spring提供了,直接用:
private static final ObjectMapper mapper = new ObjectMapper();//字符串与对象互转
@Test
void test2() throws JsonProcessingException {
User user = new User("google", 21);
String s = mapper.writeValueAsString(user);//转为String
stringRedisTemplate.opsForValue().set("user:200",s);
String jsonUser = stringRedisTemplate.opsForValue().get("user:200");
User user1 = mapper.readValue(jsonUser, User.class);//获取String手动转回对象
System.out.println(user1);
}
变得纯粹了
3. redis优化查询
3.1 在常规查询中添加redis缓存(查)
在访问数据库前加一层,命中cache直接拿,没命中乖乖从数据库拿,并写到缓存中
3.2 修改数据库内容时,缓存怎么办(改)
讨论后的最终策略:先改数据库内容,然后删缓存(毫秒级别)。这两步用事务包裹起来
(1)为什么是删不是改:频繁改redis代价大,而且数据库变了不代表就会立即有人来查询,等人来查了再更新redis就行
(2)为啥先操作数据库后操作redis?能不能颠倒?
这种更安全,出现数据不一致问题的条件更苛刻
反序的问题:
3.3 缓存穿透
问题描述:查询一个数据库中不存在的对象时,问redis要没有,问数据库要没有,这个用户可能回接着一直问下去
3.4 缓存雪崩
无数key同时过期,大量的请求直接打在服务器上
3.5 缓存击穿
redis中一个被高频访问的key失效了(比如活动商品),在redis中重构他又要花费一定的时间,导致新来的大量请求打在数据库上
两种解决方案:互斥锁、逻辑过期(拿旧数据也行,凑合过)
查询商铺信息的新业务流程图:
(1)互斥锁法
通过redis中的setnx指令来模拟锁:(为什么不用synchronized:获取不到锁就会干呆着啥也不干,然而我们在获取不到锁时需要干一些额外的操作)
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
private void unLock(String key) {
Boolean flag = stringRedisTemplate.delete(key);
}
4. 优惠券秒杀
4.1 多线程超卖问题
场景:100个优惠券200个线程抢,理想状态是有100个线程抢到了,100个没抢到,库存变成0
但是运行发现库存变负数了,原因:
解决方案:乐观锁悲观锁
4.2 一人只能买一次优惠券
场景:消费记录表中,一个用户只能购买一次某个大额优惠券。
发现还是有一个用户超买的问题,因为同一个用户多线程访问,都是还没购买的状态
解决方案:乐观锁悲观锁
此处选用悲观锁,原因:
业务逻辑变化:
业务实现两个关键点:释放锁的时机+代理对象实现事务
synchronized (userId.toString().intern()) {//锁扣在事务外面,String intern保证同一个用户id值获取同一个锁
IVoucherOrderService proxy = (IVoucherOrderService)AopContext.currentProxy();
//spring中事务的进行是让当前对象的代理对象做的,所以要获取当前对象的代理对象
return proxy.createVoucherOrder(voucherId, userId);//否则toString还是底层new了新对象
//基于接口进行调用
//pom及启动类中添加aspect/proxy相关
}
//上面是主方法
//下面是事务
@Transactional
public Result createVoucherOrder(Long voucherId, Long userId) {
//一人一单
Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count > 0) {
return Result.fail("用户已经购买过一次");
}
// 5、秒杀券合法,则秒杀券抢购成功,秒杀券库存数量减一
boolean flag = seckillVoucherService.update()
.setSql("stock = stock-1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
if (!flag) {
return Result.fail("秒杀券扣减失败");
}
// 6、秒杀成功,创建对应的订单,并保存到数据库
VoucherOrder voucherOrder = new VoucherOrder();
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherOrder.getId());
flag = this.save(voucherOrder);
if (!flag) {
return Result.fail("创建秒杀券订单失败");
}
return Result.ok(orderId);
}
4.3 分布式锁
多个JVM的锁监视器如何同步
获取与释放锁:
public class SimpleRedisLock implements ILock{
private String name;
private StringRedisTemplate stringRedisTemplate;
private static final String KEY_PREFIX = "lock";
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean tryLock(long timeoutSec) {
long threadId = Thread.currentThread().getId();
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX+name, threadId+"", timeoutSec, TimeUnit.SECONDS);
// return success; 自动拆箱Boolean若为null则拆箱时会出现空指针问题
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
stringRedisTemplate.delete(KEY_PREFIX+name);
}
}
应用此锁:
//UserHolder从threadlocal中获取当前线程用户
Long userId = UserHolder.getUser().getId();
//创建分布式锁
//key拼接了用户id,锁定范围精确到了用户。若不拼接用户id,每来一个订单都会被锁定
SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
//获取锁
boolean isLock = lock.tryLock(1200);
if(!isLock){//获取锁失败
return Result.fail("不能重复下单");
}
try {
//spring中事务的进行是让当前对象的代理对象做的,所以要获取当前对象的代理对象
IVoucherOrderService proxy = (IVoucherOrderService)AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId, userId);
} finally {
lock.unlock();
}
4.4 redis分布式锁的误删问题
进阶,考虑新的场景:
线程1特别长,长到锁ttl了任务还没结束,此时1失去锁
线程2重新获取锁,开始干活
线程1终于干完了,释放了之前自己持有的锁(此时正被线程2用着)
线程2:?你个老六
解决:新增红圈两处判断,让一个线程只能获取/释放自己一开始的锁:
再进阶:在上述过程中,先判断再释放,如果这俩操作间突然被阻塞,则还是会出现线程1释放了线程2的锁的问题
解决:将判断与释放两个redis操作设为原子性操作,借助Lua编写redis脚本:
@Override
public void unlock() {
stringRedisTemplate.execute(UNLOCK_SCRIPT,//脚本
Collections.singletonList(KEY_PREFIX + name),//key集合
ID_PREFIX + Thread.currentThread().getId()//value集合
);
}
总结:
4.5 Redisson
追加可重入等功能,用redisson这个第三方工具
4.5.1 可重入
加锁:每次锁个数+1并重置有效期
释放锁:每次锁个数-1并重置有效期
Redisson底层通过利用Lua脚本确保 判断锁是否存在、添加锁的有效期、添加线程标识这些的操作全部封装到了一个Lua脚本(确保了锁的原子性和可重入性)
加锁:
解锁:
pexpire用于重置有效期
4.5.2 可重试锁&超时续约
重试:没立即获取,那我等一会
如何解决可重试问题:利用信号量和PubSub功能(发布/订阅)实现等待、唤醒,获取锁失败的重试机制。
如何解决超时续约问题(锁超时释放了):利用watchDog,获取锁成功后,每隔一段时间(releaseTime / 3),重置超时时间。
4.5.3 主从一致性问题 mutiLock
如何解决主从一致性问题:利用Redisson的multiLock,多个独立的Redis节点,必须在所有节点都获取重入锁,才算获取锁成功
缺陷:运维成本高、实现复杂
开启三个redis服务
5. 秒杀业务
现在用户购买优惠券的相关查询都是打在mysql数据库上的(redis目前只是起到分布式锁的作用),响应慢
因此考虑使用redis优化秒杀业务:
5.1 阻塞队列
private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024*1024);
队列为空则一直等着(阻塞),直到有人塞入才读取
5.2 异步下单
开启额外一个线程实现下单
业务类初始化的时候创建线程池
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
@PostConstruct
private void init(){//此类一旦初始化完事,就调用这个线程的run方法
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
private class VoucherOrderHandler implements Runnable{
@Override
public void run() {//处理阻塞队列中的任务
while (true){
try {
//获取订单信息
VoucherOrder voucherOrder = orderTasks.take();
//创建订单
handleVoucherOrder(voucherOrder)
} catch (InterruptedException e) {
log.error("处理订单异常",e);
}
}
}
}
5.3 主要业务逻辑
业务入口(主代码)
@Override
public Result seckillVoucher(Long voucherId) {
//执行lua,判断用户和券是否合法
Long userId = UserHolder.getUser().getId();
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString()
);
//判断结果是否为0
int r = result.intValue();
if (r != 0) { //!=0,没有购买资格
return Result.fail(r==1?"库存不足":"不能重复下单");
}
//0,有购买资格,把下单信息保存到阻塞队列
long orderId = redisIdWorker.nextId("order");
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherOrder.getId());
// 阻塞队列add
orderTasks.add(voucherOrder);//主线程
//获取(主线程的)代理对象
proxy = (IVoucherOrderService)AopContext.currentProxy();
//返回订单id
return Result.ok(orderId);
}
调用阻塞队列新增下订单业务,使用线程池完成阻塞队列中的任务。参考5.1&5.2,线程池中的线程调用如下代码完成创建订单
private void handleVoucherOrder(VoucherOrder voucherOrder) {
Long userId = voucherOrder.getUserId();
RLock lock = redissonClient.getLock("lock:order" + userId);//获取redis中的锁
boolean isLock = lock.tryLock();
if (!isLock) {
log.error("一人只能下一单");
return;
}
try {// 创建订单(使用代理对象调用,是为了确保事务生效)
proxy.createVoucherOrder(voucherOrder);
} finally {
lock.unlock();
}
}
@Transactional
public void createVoucherOrder(VoucherOrder voucherOrder) { //数据库层面创建订单
//一人一单
Long userId = voucherOrder.getUserId();
Integer count = query().eq("user_id", userId).eq("voucher_id", voucherOrder.getVoucherId()).count();
if (count > 0) {
log.error("用户已经购买过一次");
return ;
}
// 秒杀券合法,则秒杀券抢购成功,秒杀券库存数量减一
boolean flag = seckillVoucherService.update()
.setSql("stock = stock-1")
.eq("voucher_id", voucherOrder.getVoucherId())
.gt("stock", 0)
.update();
if (!flag) {
log.error("秒杀券扣减失败");
return ;
}
save(voucherOrder);
}
6. 消息队列
6.1 基于List的
消息丢失:自己移除了个任务开始处理,然后自己挂了,别人也没办法再处理
6.2 基于PubSub的
消息丢失:消息发了可能没人接
这两种消息队列中,消息读完都会消失,非持久化
6.3 基于Stream
存储的所谓“消息”,就是一组键值对
漏读:处理得到的消息中,来了五条新消息,此时若读取最新的消息,则只会读到第五条消息,漏掉中间四条
6.3.1 Stream消息队列
6.3.2 消费者组
单消费方式,容易发生消息堆积导致消息丢失,因此改用消费者组的模式:
6.3.3 Stream+消费者组
最终的 异步处理 Redis消息队列【下单】中的任务:
(1)从消息队列中阻塞读
(2)有消息则解析、处理(下单)、ACK
(3)在(2)的处理过程中出现异常,没有ACK,则消息进入pending list(异常消息队列)
(4)从pending list中直接读消息,有消息则解析、处理(下单)、ACK
(5)从pending list中没读到消息,直接退出
(6)读pending list又出现异常,跳转到(4),重新尝试从pending list中读消息,直到pending list中没异常了【异常订单消息一定能得到处理】
private class VoucherOrderHandler implements Runnable{
String queueName="stream.orders";
@Override
public void run() {//处理阻塞队列中的任务
while (true){
try {
//获取消息队列中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000STREAMS streams.order >
// >表示最近一条未消费
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
StreamOffset.create(queueName, ReadOffset.lastConsumed())
);
//判断消息是否获取成功
if(list==null||list.isEmpty()){
continue;
}
//获取成功
//解析消息中的订单
MapRecord<String, Object, Object> record = list.get(0);
//其中,String是消息id 后面两个value是因为我们发消息的时候就是key-value的格式
Map<Object, Object> value = record.getValue(); //获取消息
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
//创建订单
handleVoucherOrder(voucherOrder);
//ACK确认 SACK streams.order g1 id(消息id)
stringRedisTemplate.opsForStream().acknowledge(queueName,"g1",record.getId());
} catch (Exception e) {//出现异常,进入pending list
log.error("处理订单异常",e);
handlePendingList();
}
}
}
private void handlePendingList(){
while (true){
try {
//获取pending list中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 streams.order 0
// >表示最近一条未消费
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1),
StreamOffset.create(queueName, ReadOffset.from("0"))
);
//判断消息是否获取成功
if(list==null||list.isEmpty()){
//获取失败,说明pending list没有消息,结束循环
break;
}
//获取成功
//解析消息中的订单
MapRecord<String, Object, Object> record = list.get(0);
//其中,String是消息id 后面两个value是因为我们发消息的时候就是key-value的格式
Map<Object, Object> value = record.getValue(); //获取消息
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
//创建订单
handleVoucherOrder(voucherOrder);
//ACK确认 SACK streams.order g1 id(消息id)
stringRedisTemplate.opsForStream().acknowledge(queueName,"g1",record.getId());
} catch (Exception e) {//出现异常,进入pending list
log.error("处理pending list订单异常",e);
try {
Thread.sleep(20);
}catch (InterruptedException interruptedException){
interruptedException.printStackTrace();
}
}
}
}
}
7. 关注推送的分页查询
下拉刷新,注意消息顺序
核心功能实现:redis的sortedSet,时间戳作为优先级
为了防止新来的消息导致滚动消息队列变成乱序,每次从上一次读取的消息末尾继续读取,先不管新的消息
ZREVRANGEBYSCORE key Max Min LIMIT offset count 实现按照消息从新到旧排序
【参数意义】
key:获取某个用户被推送的(关注的博主发的)消息队列
min:时间戳最小为0
max:上一次读取的最早消息(最小时间戳),作为本次读取的最大值【计划接着往前读更早的消息】
offset:符合范围的结果集中,需要去除掉几个
count:读几条消息(一页默认显示多少消息)
@Override
public Result queryBlogOfFollow(Long max, Integer offset) {
UserDTO userId = UserHolder.getUser();
String key = FEED_KEY + userId;
//获取用户的收件箱 ZREVRANGEBYSCORE key Max Min LIMIT offset count
Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
.reverseRangeByScoreWithScores(key, 0, max, offset, 2);
if (typedTuples == null || typedTuples.isEmpty()) {
return Result.ok();
}
//解析数据blogId minTime(时间戳) offset(跟我上次查询的最小值一样的个数)
List<Long> ids = new ArrayList<>(typedTuples.size());
long minTime = 0;
int os = 1;
for (ZSetOperations.TypedTuple<String> tuple : typedTuples) {
ids.add(Long.valueOf(tuple.getValue()));
long time = tuple.getScore().longValue();
if (time == minTime) {
os++;
} else {
minTime = time;
os = 1;
}
}
//根据id查询blog
String idStr = StrUtil.join(",", ids);
List<Blog> blogs = query()
.in("id", ids)
.last("ORDER BY FIELD(id," + idStr + ")").list();
for (Blog blog : blogs) {
queryBlogUser(blog);//查询blog有关用户
isBlogLiked(blog);//查询blog是否被当前用户点赞
}
ScrollResult r = new ScrollResult();
r.setList(blogs);
r.setOffset(os);
r.setMinTime(minTime);
return Result.ok(r);
}
8. 附近商铺(地理)
(1)按照离当前用户地理位置距离升序展示商铺,借助redis的GEO
(2)分页
8.1 商铺信息(地理)存进redis
将商铺的地理位置按照类别分组存储
@Test
void loadShopData() {
//查询店铺
List<Shop> list = shopService.list();
//使用stream流实现按类别分组,key-long表示类别
Map<Long, List<Shop>> map = list.stream().collect(Collectors.groupingBy(Shop::getTypeId));
for (Map.Entry<Long, List<Shop>> entry : map.entrySet()) {
Long typeId = entry.getKey();
String key = SHOP_GEO_KEY + typeId;
List<Shop> value = entry.getValue();
List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(value.size());
//写入redis: GEOADD key 经度 纬度 member
for (Shop shop : value) {
// stringRedisTemplate.opsForGeo().add(key, new Point(shop.getX(), shop.getY()), shop.getId().toString());
locations.add(new RedisGeoCommands.GeoLocation<>(
shop.getId().toString(),
new Point(shop.getX(), shop.getY())
));
}
stringRedisTemplate.opsForGeo().add(key, locations);
}
}
8.2 分页
@Override
public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) {
//不需要根据坐标查询(排序)
if (x == null || y == null) {
Page<Shop> page = query()
.eq("type_id", typeId)
.page(new Page<>(current, SystemConstants.DEFAULT_PAGE_SIZE));
return Result.ok(page.getRecords());
}
//按地理坐标查询
int from = (current - 1) * SystemConstants.DEFAULT_PAGE_SIZE;//定位分页要查询的数据范围
int end = current * SystemConstants.DEFAULT_PAGE_SIZE;
String key = SHOP_GEO_KEY + typeId;//确定类别
//查询语句
GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo()
.search(key,
GeoReference.fromCoordinate(x, y),
new Distance(5000),
RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().includeDistance().limit(end)
);
if(results==null){
return Result.ok();
}
//截取from-to部分
List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();
if(list.size()<=from){
return Result.ok(Collections.emptyList());
}
//查询结束,转换数据为指定格式
ArrayList<Long> ids = new ArrayList<>(list.size());
Map<String, Distance> distanceMap = new HashMap<>(list.size());
list.stream().skip(from).forEach(result ->{
String shopIdStr = result.getContent().getName();
ids.add(Long.valueOf(shopIdStr));
Distance distance = result.getDistance();
distanceMap.put(shopIdStr,distance);
});
//根据ids查找shop
String idStr = StrUtil.join(",", ids);
List<Shop> shops = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();
for (Shop shop : shops) {
shop.setDistance(distanceMap.get(shop.getId().toString()).getValue());
}
return Result.ok(shops);
}
9. BitMap签到
签到:
以今天是14号为例:
其中,0是偏移量
10. UV统计
hyper log log:唯一性统计,有多少用户登录过次网站(去重)
redis实现:
插入100万,统计有这些
误差小,内存占用小