Redis学习路线(9)—— Redis的场景使用

默认做好了其它的前提,只专注于Redis使用

一、短信登录

在没有Redis数据库时,我们会基于Session实现登录(利用令牌进行授权),是怎么实现的呢?

(一)基于Session的短信登录功能

1、发送短信验证码

(1)流程: 客户端提交手机号 》 校验手机号 》 生成验证码 》 保存验证码到session 》 发送验证码

说明
请求方式POST
请求路径/usr/code
请求参数phone
返回值void
// Result 为结果类,成员变量有success、errorMsg、data、total,方法有成功结果(携带数据【包含了List以及统计总数的数据】和不携带数据)和错误结果(返回错误信息)
@Override
public Result sendCode(String phone, HttpSession session) {
	//1、校验手机号,校验不通过,则返回错误信息;校验通过,则查询用户信息。
	if(RegexUtils.isPhoneIncalid(phone)) //static boolean RegexUtils.isPhoneIncalid(String phone),自定义格式校验工具
		return Result.error("手机号格式错误");
	
	//2、生成验证码(6位数字),使用自定义随机数生成工具
	String code = RandomUtils.randomNumbers(6);
	
	//3、保存验证码到session
	session.setAttribute("verify", code);
	
	//4、发送验证码,模拟发送成功,业务上可以使用阿里云的短信服务进行处理
	log.debug("发送短信验证码成功,验证码:{}", code);
	
	return Result.ok();
}

2、短信验证码登录、注册

(1)流程: 客户端提交手机号和验证码 》 校验验证码 》 根据手机号查询用户 》 查询用户是否存在(若不存在则创建新用户存储到数据库) 》保存用户到session

说明
请求方式POST
请求路径/usr/login
请求参数phone, verify
返回值void
private static final String USER_NICK_NAME_PREFIX = "Coder_";

@Autowired
private LoginMapper loginMapper;

@Override
public Result login(String phone, String verify, HttpSession session) {
	//1、校验验证码,若校验通过,则根据手机号查询用户;若不成功则添加到数据库
	String code = (String)session.getAttribute("verify");
	if(code == null || !verify.equals(code)) {	//检验验证码的存在状态,可能发生过期或未获取验证码的情况
		return Result.error("验证码错误");
	}
	
	private User user;
	if((user = loginMapper.queryUserByPhone(phone)) == null){
		// 新增用户,需要初始化用户的信息有 手机号,随机昵称,因为用户是根据手机号进行登录的,昵称可以采用随机字符的方式进行注册,即使重复也没关系。
		user = new User(phone, USER_NICK_NAME_PREFIX + RandomUtils.randomString(10));
		loginMapper.addUserByPhone(user);
	}

	//2、保存用户信息到session
	session.setAttribute("user", user);
	return Result.ok();
}

3、校验登录状态

(1)流程: 客户端请求并携带cookie 》 从session中获取用户 》 判断用户是否存在(若不存在则拦截) 》 保存到ThreadLocal 》放行处理

拦截器:LoginInterceptor.java

public class LoginInterceptor implements HandlerInterceptor {
	@Override
	public boolean preHandle(HttpServletRequest request, HttpServeltResponse response, Object handler){
		//1、获取session
		HttpSession session = request.getSession();
		//2、获取session中的用户
		User user = (User)session.getAttribute("user");
		//3、判断用户是否存在
		if(user == null){
			// 拦截,返回401状态码
			response.setStatus(401);
			return false;
		}
		//4、保存用户信息到ThreadLocal
		// UserHolder 中 定义了一个 ThreadLocal 实例对象,使用 set(User) 即可保存信息,使用 get() 即可获取信息,使用 remove() 即可删除信息
		UserHolder.saveUser(user);
		//5、放行
		return true;
	}
}

	@Override
	public void afterCompletion(HttpServletRequest request, HttpServeltResponse response,  Object handler, Exception ex){
		//移除用户
		UserHolder.removeUser();
	}

配置类:MVCConfig.java

@Configuration
public class MVCConfig implements WebMvcConfigurer {
	
	@Resource
	private SpringRedisTemplate springRedisTemplate;
	
	@Override
	public void addInterceptors(InterceptorRegistry registry) {
		List<String> excludeList = new ArrayList();
		list.add("/usr/sendCode");
		list.add("/usr/login");
		list.add("/usr/logout");
		list.add("/usr/currentUser");
		list.add("/shop/**");
		list.add("/shop-type/**");
		list.add("/blog/hot");
		.....
		registry.addInterceptor(new LoginInterceptor()).excludePathPatterns(excludeList).order(1);
	}
}

此时项目出现的问题: 用户信息没有脱敏。

解决方案: 返回部分信息即可,可以创建响应数据类xxDTO进行用户信息存储。

集群的session共享问题: 多态Tomcat并不共享session空间,当请求切换到不同的tomcat服务时导致数据丢失的问题。

解决方案: Redis代替session。

(二)基于Redis的短信登录功能

1、发送短信验证码

(1)流程: 客户端提交手机号 》 校验手机号 》 生成验证码 》 保存验证码到redis 》 发送验证码

@Override
public Result sendCode(String phone) {
	if(RegexUtils.isPhoneIncalid(phone))
		return Result.error("手机号格式错误");
		
	String code = RandomUtils.randomNumbers(6);
	
	//使用 phone-code 结构保存,保存验证码5分钟,5分钟后失效
	stringRedisTemplate.opsForValue().set("phone:"+phone, code, 5, TimeUnit.MINUTES);
	
	log.debug("发送短信验证码成功,验证码:{}", code);
	
	return Result.ok();
}

2、短信验证码登录、注册

(1)流程: 客户端提交手机号和验证码 》 校验验证码 》 根据手机号查询用户 》 查询用户是否存在(若不存在则创建新用户存储到数据库) 》保存用户到Redis

说明
请求方式POST
请求路径/usr/login
请求参数phone, verify
返回值void
private static final String USER_NICK_NAME_PREFIX = "Coder_";

@Autowired
private LoginMapper loginMapper;

@Override
public Result login(String phone, String verify) {
	String code = stringRedisTemplate.opsForValue().get("phone:"+phone);
	if(code == null || !verify.equals(code)) {	//检验验证码的存在状态,可能发生过期或未获取验证码的情况
		return Result.error("验证码错误");
	}
	
	User user;
	if((user = loginMapper.queryUserByPhone(phone)) == null){
		user = new User(phone, USER_NICK_NAME_PREFIX + RandomUtils.randomString(10));
		loginMapper.addUserByPhone(user);
	}
	
	//获取UUID唯一标识
    String token = "token:"+ UUID.randomUUID().toString(true).replaceAll("-","");
	
	//以token-HashMap结构存储对象信息。
	Map<String, Object> map = JSONObject.parseObject(JSONObject.toJSONString(user), Map.class);
    map.replaceAll((k,v) -> v.toString());
    stringRedisTemplate.opsForHash().putAll(token, map);	
	
	//设置有效期
	RedisUtils.REDIS.expire(tokenKey, 30, TimeUnit.MINUTES);
	return Result.ok();
}

3、校验登录状态

(1)流程: 客户端请求并携带token 》 从redis中获取用户 》 判断用户是否存在(若不存在则拦截) 》 保存到ThreadLocal,更新有效期 》放行处理

拦截器:LoginInterceptor.java

public class LoginInterceptor implements HandlerInterceptor {
	@Override
	public boolean preHandle(HttpServletRequest request, HttpServeltResponse response, Object handler){
		//1、获取token
		String token = request.getHeader("authorization");
		if(token == null){
			response.setStatus(401);
			return false;
		}
		//2、获取redis中的用户
		Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries("login:token:"+token);
		//3、判断用户是否存在
		if(userMap.isEmpty()){
			// 拦截,返回401状态码
			response.setStatus(401);
			return false;
		}
		
		//4、将Map转为实体类
		User user = JSON.parseObject(JSON.toJSONString(userMap), User.class);
		
		//5、将用户信息存储到ThreadLocal
		UserHolder.saveUser(user);
		
		//6、刷新token活跃状态
		stringRedisTemplate.expire("login:token:"+token, 30, TimeUnit.MINUTES);
		
		//7、放行
		return true;
	}
}

	@Override
	public void afterCompletion(HttpServletRequest request, HttpServeltResponse response,  Object handler, Exception ex){
		//移除用户
		UserHolder.removeUser();
	}

解决登录状态刷新问题: 拦截器只有在访问需要校验的网页才会刷新用户活跃状态。

解决方案: 拦截器执行链。

设置两个拦截器。

  • 拦截器1: 拦截所有路径,并检查用户token的存在并刷新,一律放行。
  • 拦截器2: 拦截需要登录的路径,不存在则拦截,存在则继续。
public class LoginInterceptor implements HandlerInterceptor {
	@Override
	public boolean preHandle(HttpServletRequest request, HttpServeltResponse response, Object handler){
		if(UserHolder.getUser() == null){
			response.setStatus(401);
			return false;
		}
		//7、放行
		return true;
	}
}

public class RefreshTokenInterceptor implements HandlerInterceptor {
	@Override
	public boolean preHandle(HttpServletRequest request, HttpServeltResponse response, Object handler){
		//1、获取token
		String token = request.getHeader("authorization");
		if(token == null){
			return true;
		}
		
		//2、获取redis中的用户
		Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries("login:token:"+token);
		//3、判断用户是否存在
		if(userMap.isEmpty()){
			return true;
		}
		
		//4、将Map转为实体类
		User user = JSON.parseObject(JSON.toJSONString(userMap), User.class);
		
		//5、将用户信息存储到ThreadLocal
		UserHolder.saveUser(user);
		
		//6、刷新token活跃状态
		stringRedisTemplate.expire("login:token:"+token, 30, TimeUnit.MINUTES);
		
		//7、放行
		return true;
	}
}

	@Override
	public void afterCompletion(HttpServletRequest request, HttpServeltResponse response,  Object handler, Exception ex){
		//移除用户
		UserHolder.removeUser();
	}

MVC配置文件新增拦截器

registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).order(0);

二、商户查询缓存

(一)添加Redis缓存

1、缓存作用模型: 客户端 》 Redis(命中返回) 》 数据库(查询返回客户端并写入缓存)

2、根据id查询商铺缓存流程: 客户端提交商铺 id 》 从缓存里读取(命中则返回商铺信息) 》 根据id查询数据库(不存在则返回错误信息) 》 数据库写入Redis 》 返回商铺信息。

3、逻辑过期的实现

(1)封装逻辑过期类

@Data
@NoArgsConfiguration
@AllArgsConfiguration
public class RedisData {
	private Object data;
	private LocalDateTime dateTime;
}

(2)保存逻辑过期方法

public void saveShop2Redis(Long id, Long expireSeconds){
	//1、查询店铺数据
	Shop shop = shopMapper.queryShopById(id);
	//2、封装逻辑过期时间
	RedisData redisData = new RedisData(shop, LocalDateTime.now().plusSeconds(expireSeconds));
	//3、写入Redis
	stringRedisTemplate.opsForValue().set("lock:shop:"+id, JSON.toJSONString(redisData));
}

3、项目实现

//可以存储在一个专门的类中进行引用
private static final String SHOP_KEY = "program:shop:";

@Autowired
private ShopMapper shopMapper;

@Override
public Result queryShopById(Long id) {
	try{
		//1、查询缓存是否存在商铺信息
		String shopJSON = stringRedisTemplate.opsForValue().get(SHOP_KEY + id);
		
		//2、从缓存里读取(命中则返回商铺信息)
		if(!shopJSON.isEmpty()){
			//存在,则直接返回
			Shop shop = JSONObject.parseObject(shopJSON, Shop.class);
			// 设置 3h 的缓存时长,若无相关访问该数据,则删除缓存,可自行定义。
			stringRedisTemplate.expire(HOP_KEY + id, 3, TimeUnits.HOURS);
			return Result.ok(shop);
		}
		
		//3、实现缓存重建
		String lockKey = "lock:shop:"+id;
		boolean isLock = tryLock(lockKey);
		//判断是否获取成功
		if(!isLock){
			//失败则休眠后重试
			Thread.sleep(50);
			return queryWithMutex(id);
		}	
	
		//4、根据id查询数据库(不存在则返回错误信息) 
		Shop shop = shopMapper.queryShopById(id);
		if(shop == null){
			stringRedisTemplate.opsForValue().set(SHOP_KEY + id, null);
			stringRedisTemplate.expire(SHOP_KEY + id, 2, TimeUnits.MINUTES);
			return Result.error("未查询到该商铺");
		}
		
		//5、数据库写入Redis
		Map<String, Object> map = JSONObject.parseObject(JSONObject.toJSONString(shop), Map.class);
	    map.replaceAll((k,v) -> v.toString());
	    stringRedisTemplate.opsForHash().putAll(SHOP_KEY + id, map);	
		stringRedisTemplate.expire(HOP_KEY + id, 3, TimeUnits.HOURS);
		
	}catch(InteruptedException e) {
		throw new RuntimeException(e);
	}finally {
		unlock(lockKey);
	}
	//6、返回信息
	return Result.ok(shop);
}

private boolean tryLock(String key) {
	Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "", 10, TimeUnit.SECONDS);
	return flag != null? flag : false;
}

private void unlock(String key) {
	stringRedisTemplate.delete(key);
}

5、基于逻辑过期方式解决缓存击穿问题(仅改动代码)


public Result queryWithLogicalExpire(Long id){
	try{
		// 判断缓存中是否有对象过程....
		
		RedisData redisData = JSONObject.parseObject(shop, RedisData.class);
		Object data = redisData.getData();
		Shop shop = (Shop)data;
		//1、命中缓存
		//判断过期
		if(redisData.getDateTime < LocalDateTime.now()){
			//未过期返回店铺信息
			return Result.ok(shop);
		}
		
		//已过期则需要缓存重建
		//获取互斥锁
		String lockKey = "lock:shop:"+id;
		boolean isLock = tryLock(lockKey);
		//判断是否获取成功
		if(!isLock){
			//失败则休眠后重试
			Thread.sleep(50);
			return queryWithMutex(id);
		}
		//开启独立线程
		Executors.newFixedThreadPool(10).submit(() -> {
			this.saveShop2Redis(id, 30);
		});
		//返回店铺信息
		return shop;
	}catch(InteruptedException e) {
		throw new RuntimeException(e);
	}finally {
		unlock(lockKey);
	}
	return Result.ok(shop);
}

三、优惠券秒杀

(一)抢购秒杀优惠券初级版

说明
请求方式POST
请求路径/voucher-order/seckill/{id}
请求参数id,优惠券id
返回值订单id

1、需要注意的两个要点:

  • 秒杀活动只有在规定时间段内可以下单
  • 库存不足无法下单

2、流程: 客户端提交优惠券id 》 查询优惠券信息 》 判断秒杀是否开始(若未开始则返回错误) 》 判断库存是否充足(不足则返回错误) 》 扣除库存 》 创建订单 》 返回订单id

3、实现功能

@Resource
private SeckillVoucherMapper seckillVoucherMapper;

@Resource
private RedisIdBuilder redisIdBuilder;

@Override	
@Transactional	//添加事务
public Result seckillVoucher(Long voucherId) {
	//1、查询优惠券
	SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
	//2、查询活动时间
	Long begin = voucher.getBeginTime();
	Long end= voucher.getEndTime();
	Long now = LocalDateTime.now();
	if(voucher.getBeginTime().isAfter(now)) 
		return Result.error("秒杀活动还未开始");
	else if(voucher.getEndTime().isBefore(now)) 
		return Result.error("秒杀活动已结束");
	//3、判断库存状态
	if( voucher.getStock() < 1 )
		return Result.error("优惠券已告罄");
	//4、扣减库存, update seckillvoucher set stock = stock - 1 where voucher_id = #{voucherId}
	boolean flag = seckillVoucherMapper.removeOneStock(voucherId);

	if(flag){
		return Result.error("库存不足");
	}
		
	//5、创建订单
	Long orderId = redisIdBuilder.nextId("order");
	orderMapper.createOrder(orderId, UserHolder.getUser().getId(), voucherId);
	//6、返回订单Id
	return Result.ok(orderId);
}

(二)超卖问题

当多用户同时进行秒杀活动,就会有超卖问题。

乐观锁解决超卖实现(改动代码)

// 要改变的地方有: 查询库存的信息保存,扣除库存时的判断

//查询库存
int stock = voucher.getStock();

if( stock < 1 )
	return Result.error("优惠券已告罄");
	
//4、扣减库存, update seckillvoucher set stock = stock - 1 where voucher_id = #{voucherId} and stock = #{stock}
if(seckillVoucherMapper.removeOneStock(voucherId, stock)){
	return Result.error("库存不足");
}

乐观锁的一个缺点: 成功率太低,当一个第一个线程先查询到了库存,并且执行了减库存操作,但后续的线程在第一次查询是也查询到了库存,现在由于第一个线程完成了操作,库存不一致了,那么这次请求就失败了,在这段期间内的所有线程都会失败。

如何解决这个问题? 只要把条件放开,条件无需符合相等的原则,只需要完成正常库存判断即可。

if( voucher.getStock() < 1 )
	return Result.error("优惠券已告罄");
	
//4、扣减库存, update seckillvoucher set stock = stock - 1 where voucher_id = #{voucherId} and stock > 0
if(seckillVoucherMapper.removeOneStock(voucherId)){
	return Result.error("库存不足");
}

(三)一人一单

1、需求: 修改秒杀业务,要求同一个优惠券,一个用户只能下一单。

2、增加的流程: 优惠券id和用户id查询订单,若不存在则允许减库存,若存在,则返回错误

3、实现

	//1、查询订单(通过优惠券id,用户id)
	Order order = orderMapper.queryByVoucherAndUser(UserHolder.getUser().getId(), voucherId);
	
	//2、判断是否存在
	if(Objects.nunNull(order){
		//存在则返回错误
		return Result.error("对不起您已经领过该优惠券");
	}
	
	//3、不存在,则减库存
	seckillVoucherMapper.removeOneStock(voucherId);
	
	//4、创建订单
	Long orderId = redisIdBuilder.nextId("order");
	orderMapper.createOrder(orderId, UserHolder.getUser().getId(), voucherId);

4、出现的一人超卖问题: 由于用户可以多次发起请求,每次发起请求都会被响应,多次请求又都查询出没有订单信息,所以都会往下继续执行减库存,创订单的操作。

解决方案: 加锁,这次加悲观锁,因为要锁住一个线程只执行一次。

public Result seckillVoucher(Long voucherId) {
	
	//前期工作...

	sychronized(UserHolder.getUser().getId().toString().intern()) {
		//获取事务代理对象,需要添加一个依赖aspectjweaver,启动类开启依赖@EnableAspectJAutoProxy(exposeProxy = true)
		SeckillVoucherService proxy = (SeckillVoucherService) AopContext.currentProxy();
		//在释放锁时,数据可以确保已经完成提交
		returen proxy.createVoucherOrder(voucherId);
	}
}
@Transactional
public Result createVoucherOrder(Long voucherId) {
	Order order = orderMapper.queryByVoucherAndUser(UserHolder.getUser().getId(), voucherId);
	
	if(Objects.nunNull(order){
		return Result.error("对不起您已经领过该优惠券");
	}
	
	seckillVoucherMapper.removeOneStock(voucherId);
	
	Long orderId = redisIdBuilder.nextId("order");
	orderMapper.createOrder(new Order(orderId, UserHolder.getUser().getId(), voucherId));
	return Result.ok(voucherId);
}

5、集群情况下的并发问题: 同一个用户在集群环境下多次请求,同时抢购一个优惠券,会出现同步锁失效的情况。

(1)出现这种并发安全问题的原因: JVM内部维护了一个锁监视器,在同一个userid下,认为这个线程是同一个线程,但是当有两个或更多的JVM集群出来,而锁监视器并没有锁定同一个线程,所以才会有并发安全问题。

(2)解决方案: 分布式锁

(3)项目中替换同步锁

声明StringRedisTemplate

@Resource
private StringRedisTemplate redisTemplate;

使用Redis分布式锁

//1、创建锁对象
SimpleRedisLock simpleRedisLock = new SimpleRedisLock("order:" + UserHolder.getUser().getId(), redisTemplate);

//2、获取锁
boolean isLock = lock.tryLock(1200);

//3、判断是否获取成功
if(!isLock){
	//获取失败,返回错误或重试
	return Result.error("不允许重复下单");
}

try{
	SeckillVoucherService proxy = (SeckillVoucherService) AopContext.currentProxy();
	//在释放锁时,数据可以确保已经完成提交
	returen proxy.createVoucherOrder(voucherId);
}finally{
	lock.unlock();
}

6、业务阻塞导致锁超时释放的问题

有两个线程,线程a 和 线程b ,线程a 获得锁开始执行自己的业务,但由于某种原因导致业务阻塞,线程a一直在等待业务的完成。

由于Redis锁设置了释放时间,线程a的锁在阻塞中已经被释放,当业务完成后,线程a 依旧执行释放锁操作,导致 线程b 获取的锁被释放,从而导致线程安全问题。

(1)原因: 线程被阻塞,分布式锁超时被释放,导致线程运行混乱。

(2)解决方法: 在业务完成后,先检查锁的标识是否一致,再判断是否释放锁。

(3)改进Redis分布式锁

获取锁时,存入线程表示

	private static final String UID = UUID.randomUUID().toString(true).replaceAll("-","");

    @Override
    public boolean tryLock(long timeoutSec) {
    	//1、获取线程表示
    	String threadId = UID + Thread.currentThread().getId();
        //2、获取锁+存储线程标识
        Boolean absent = redisTemplate.opsForValue().setIfAbsent(LOCK + threadName, threadId, timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(absent);
    }

    @Override
    public void unlock() {
    	//1、判断所表示是否一致
    	if(redisTemplate.opsForValue().get(LOCK + threadName).equals(UID + Thread.currentThread().getId())){
	    	redisTemplate.delete(LOCK + threadName);
		}
    }

7、由于JVM的垃圾回收机制,线程在释放锁之前可能会遭遇阻塞,造成超时释放锁

(1)解决方法: 将判断表示与释放锁形成原子性。

(2)实现方法: 使用Lua脚本,编写多条Redis,保证Redis命令的原子性。

Lua脚本的使用方法: Redis提供了一个回调函数,可以调用脚本。

EVAL "return redis.call('set', KEYS[1], ARGV[1])" 1 name Rose

(3)释放锁的业务流程

  • 获取锁中的线程标识
  • 判断是否与指定标识(当前线程标识)一致
  • 如果一致则释放锁(删除)
  • 如果不一致则什么都不做

(4)Lua脚本实现

-- 判断是否一致
if(redis.call('get', KEYS[1]) == ARGV[1]) then
	-- 释放锁
	return redis.call('del', KEYS[1])
end
return 0

(5)Java执行Lua脚本

RedisTemplate调用Lua的API源码

@Override
public <T> T execute(RedisScript<T> script, List<K> keys, Object... args) {
	return scriptExecutor.execute(script, keys, args);
}

调用Lua脚本实现原子性操作

	private static final DefaultRedisScript UNLOCK_SCRIPT;
    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
        UNLOCK_SCRIPT.setResultType(Long.class);
    }   
	
	@Override
    public void unlock() {
        redisTemplate.execute(UNLOCK_SCRIPT, Collections.singletonList(LOCK + threadName), UID + Thread.currentThread().getId());
    }

(六)Redis优化秒杀

Redis结构1:key=店铺id:优惠券id,value=用户id,存储某个用户抢购了某个店铺的某个优惠券的信息,并设置TTL
Redis结构2:key=店铺id:优惠券id,value=数量,存储某个店铺的某个优惠券的库存信息,并设置TTL

1、优化流程: 某个用户抢购某个店铺的某个优惠券,提交店铺id 与 优惠券id 》 查询Redis关于shopid:voulerid 对应的 field(查询到用户信息,直接返回错误信息) 》保存抢购信息到redis同时在相应的库存信息自减,再开一个异步线程生成订单写入数据库,并返回结果。

2、实现(改动部分)

优化点:

  • (1)只关注于Redis缓存的操作,主线程不掺杂数据库操作
  • (2)使用线程池操作数据库生成订单,减少了主程序对数据库的操作事件,提升工作性能
  • (3)使用Redis这样的快速响应数据库,提升工作性能,可以制作集群提高请求载荷
//1、Redis查询库存
String stock = redisTemplate.opsForValue().get(key);
if (Objects.isNull(stock)){
	return Result.error("活动未开始或以结束");
}
if (Integer.parseInt(stock) < 1){
    return Result.error("库存不足");
}

//2、查询缓存中是否有该用户
Boolean isMember = redisTemplate.opsForSet().isMember(key, uid);
if (Boolean.TRUE.equals(isMember)){
	//若存在
	return Result.error("您已经买过了");
}

//若不存在则存储抢购信息,减库存并返回正确信息,并设置过期时间(即活动结束时长)
redisTemplate.opsForSet().add(key, uid);        
redisTemplate.expire(key, realseTime, TimeUnit.HOURS);
redisTemplate.opsForValue().increment("stock:"+key, -1);

//3、开启线程执行常见订单操作,采用的是线程池
POOL.submit(() -> {
	mapper.createOrderByVouler(redisIdBuilder.nextId("order"), shopId, voulerId, uid);
});

return Result.ok(voulerId);

3、优化项目,实现异步秒杀

(1)通过命令行模式实现创建队列(也可以在脚本中 先判断指定消费者组和队列是否存在,再决定是否进行创建)

#创建消费者组同时创建队列
XGROUP CREATE stream.oreders g1 0 MKSTREAM

(2)改写lua脚本(改写部分)

local orderId = ARGV[3];

#发送消息到队列
redis.call("XADD", "stream.orders", "*" , "userId", userid, "voucherId", voucherId, :"id", orderId);

(3)服务实现改写:

//抽取订单创建的功能代码(因为消息队列为该功能分了功能角色,消费者负责检验数据与发送消息,生产者读取消息进行后续工作)
pool.submit(() -> {
	String queueName = "stream.orders";
	@Override
	public void run(){
		try{
			while(true){
				//1、获取消息队列中的订单消息
				List<MapRecord<String, Object, Object>> list = redisTemplate.opsForStream().read(
					Consumer.form("g1","c1"),
					StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
					StreamOffset.create(queueName, ReadOffset.lostConsumer())
				);
				//2、判断是否获取成功
				if(Objects.isNull(list) || list.isEmpty()){
					//若失败,则继续下一次循环
					continue;
				}
				
				//若成功,则解析订单信息
				MapRecord<String, Object, Object> record= list.get(0);
				Map<Object, Object> values= map.getValue();
				Order order = JSONUtils.toJsonObject(JSONUtils.toJsonStr(values), Order.class);
				//2、创建订单
				mapper.createOrderByVouler(order);
				//3、ACK确认,队列名 + 消费者组ID + 消息ID
				redisTemplate.opsForStream().acknowledge(queueName, "g1", record.getId());
			}
		} catch (Exception e){
			log.error("处理订单异常:{}", e);
			handlePenddingList();
		}
	}
});

//队列异常处理的方法
private void handlePenddingList() {
	try {
		while(true){
			//1、获取消息队列中的订单消息
			List<MapRecord<String, Object, Object>> list = redisTemplate.opsForStream().read(
				Consumer.form("g1","c1"),
				StreamReadOptions.empty().count(1)),
				StreamOffset.create(queueName, ReadOffset.from("0"))
			);
			//2、判断是否获取成功
			if(Objects.isNull(list) || list.isEmpty()){
				//若失败,则结束
				break;
			}
					
			//若成功,则解析订单信息
			MapRecord<String, Object, Object> record= list.get(0);
			Map<Object, Object> values= map.getValue();
			Order order = JSONUtils.toJsonObject(JSONUtils.toJsonStr(values), Order.class);
			//2、创建订单
			mapper.createOrderByVouler(order);
			//3、ACK确认,队列名 + 消费者组ID + 消息ID
			redisTemplate.opsForStream().acknowledge(queueName, "g1", record.getId());
		}
	} catch(Exception e){
			log.error("pedding-list异常:{}", e);
			Thread.sleep(20);
	}
}


四、附近的商户

(一)需求:

  • 1、通过用户授权获取用户位置
  • 2、通过Redis的GEO结构,计算出直线距离并排序
  • 3、通过商铺ID获取商铺列表

(二)实现:

private void loadShopData(){
	//1、查询所有店铺
	List<Shop> list = shopMapper.getAllShop();
	//2、按照typeId分组
	Map<Long. List<Shop>> map = list.stream().collect(Collectors.groupingBy(Shop:getTypeId));
	//3、分批写入Redis
	for (Map.Entry<Long, List<Shop>> entry : map.entrySet()){
		Long typeID = entry.getKey();
		String key = "shop:geo:" + typeID;
		List<Shop> value = entry.getValue();
		List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(value.size());
		for(Shop shop){
			//redisTemplate.opsForGeo().add(key, new Point(shop.getX(), shop.getY), shop.getId().toString());
			location.add(new RedisGeoCommands.GeoLocation<>(shop.getId().toString(), new Point(shop.getX(), shop.getY())));
		}
		redisTemplate.opsForGeo().add(key, locations);
	}
}

查询附近的商铺

private Result queryShopByType(Integer typeId, Integer current, Double x, Double y){
	//1、判断是否需要根据坐标查询
	if(x == null || y == null){
		return Result.ok(shopMapper.getAllShopByType(typeId));
	}
	//2、查询redis,limit是查询数量
	GeoResults<RedisGeoCommands.GeoLocation<String>> results = GredisTemplate.opsForGeo().search( 
		"shop:geo:" + typeId, 
		GeoReference.fromCoordinate(x, y), 
		new Distance(5000), RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().includeDistance().limit(15));
	
	
	//3、解析id
	if(results == null){
		return Result.ok(Collections.emptyList());
	}
	
	List<GeoResults<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();
	List<Long> ids = new ArrayList<>(list.size());
	Map<String, Distance) distanceMap = new HashMap<>(list.size());
	list.stream().forEach(result -> {
		String shopIdStr = result.getContent().getName();
		ids.add(Long.valueOf(shopIdStr));
		Distance distance = result.getDistance();
		distanceMap.put(shopIdStr, distance);
	})

	//4、根据ID 查询 Shop
	String[] idsStr= ids.toArray(new String[0]);
	List<Shop> shops = shopMapper.getAllShopByIds(idsStr);
	for (Shop shop: shops){
		shop.setDistance(distanceMap.get(shop.getId().toString()).getValue());
	}
	return shops;
}

五、UV统计

UV统计,主要是通过使用Redis的HyperLogLog来记录用户访问数。

private Result getTotalClickCount(){
	String[] values = new String[1000];
	for(int i = 0; i < 1000000; i++) {
		i = i % 1000;
		values[i] = "user_" + i;
		if(i === 999){
			redisTemplate.opsForHyperLogLog().add("h12",values);
		}
	}
	long count = redisTemplate.opsForHyperLogLog().size("h12");
	
}

六、用户签到

(一)签到实现

private Result sign(){
	//获取当前登录用户
	Long userId = UserHolder.getUser().getId();
	//获取日期
	LocalDateTime now = LocalDateTime.now();
	//拼接key
	String keySuffix = non.format(DateTimeFormatter.ofPattern(":yyyyMM"));
	String key = "sign:" + userId + keySuffix;
	//获取今天是本月的第几天
	int dayOfMounth = now.getDayOfMounth();
	//写入Redis
	redisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);
	return Result.ok();
}

(二)连续签到

private Result countOfSign(){
	Long userId = UserHolder.getUser().getId();
	LocalDateTime now = LocalDateTime.now();
	String keySuffix = non.format(DateTimeFormatter.ofPattern(":yyyyMM"));
	String key = "sign:" + userId + keySuffix;
	int dayOfMounth = now.getDayOfMounth();
	//1、获取本月到今日为止的记录
	redisTemplate.opsForValue().bitField(
		key,
		BitFieldSubCommands.create().get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0)
	);
	if(result == null | result.isEmpty())
		return Result.ok(0);
	
	long num = result.get(0);
	if(num == null | num == 0)
		return Result.ok();
	int count = 0;
	while(true){
		if(num%1)==0){
			break;
		}else{
			count++;
		}
		num >>>= 1;
	}
}
return Result.ok(count);

七、好友关注

(一)关注和取关

1、查询是否关注用户

private Result isFollow(Long followUserId) {
	//	1、获取登录用户
	Long currentUser= UserHolder.getUser().getId();
	String prefix = "follow:auth:";
	if(Objects.isNull(currentUser))
		return Result.error("用户未登录");
	// 2、查询关注列表, select count(*) from follow_list where follow_user = #{followUserId} and user_id = #{userId}
	Boolean flag = redisTemplate.opsForSet().isMember(prefix + followUserId, currentUser);
	
	if(Objects.isNull(flag))
		return Result.ok(true);
		
	return Result.ok(false);
}

2、关注和取关接口

private Result follow(Long followUserId, Boolean isFollow) {
	//	获取登录用户
	Long currentUser= UserHolder.getUser().getId();
	String prefix = "follow:auth:";
	//1、判断关注状态
	switch(isFollow){
		case true:
			//2、关注,新建数据
			Follow follow = new Follow();
			follow.setUserId(currentUser);
			follow.setFollowUserId(followUserId);
			//insert into follow_list (xx,xx,xx, follow_user_id, user_id) value (#{xx}, #{xx}, #{xx}, #{followUserId}, #{userId})
			followMapper.createFollow(follow);
			//存储到Redis
		    redisTemplate.opsForSet().add(prefix + followUserId, currentUser);
			break;
		case false:
			//3、取关,删除,delete follow_list where user_id = #{userId} and follow_user_id = #{followUserId}
			redisTemplate.opsForSet().remove(prefix + followUserId, currentUser);
			break:
		default:
			return Result.error("请求错误");
	}
	//4、返回
	return Result.ok();
}

(二)共同关注

private Result CommonFollow (long queryUser){
	//1、获取当前用户
	long currentUser = UserHolder.getUser().getId();
	//2、获取两个用户的关注列表,并存储在Redis中
	String prefix = "follow:auth:";
	
	Set<String> unionSet = redisTemplate.opsForSet().intersect(prefix + queryUser, prefix + currentUser);
	if(unionSet == null || unionSet.isEmpty())
		return Result.ok(Collections.emptyList());
		
	List<Long> ids = unionSet.stream().map(Long::valueOf).collect(Colletors.toList());
	
	List<User> users = userService.listById(ids).stream().map(
		user -> JSONObject.parseObject(
			JSONObject.toJSONString(user), 
			UserDTO.class
		).collect(Collectors.toList())
	);
	return users;
}

(三)关注推送Feed

1、概念: 通过无限拉取刷新获取新的信息。

2、模式:

  • Timeline: 不做内容筛序,简单的按内容发布时间排序,常用于好友或关注。
    • 优点: 信息全面,不会缺失,并且实现简单。
    • 缺点: 信息噪声较多,用户不一定感兴趣,内容获取效率低。
  • 智能排序: 利用智能算法屏蔽掉违规的、用户不感兴趣的内容,推送用户感兴趣信息来吸引用户。
    • 优点: 推送用户感兴趣信息,用户粘度性高,容易沉迷。
    • 缺点: 如果算法不精准,可能会起反作用。

3、Feed流的实现方案

(1)拉模式: 也叫做读扩散。

流程: 每一位博主都有一个内容队列,每发一次博文,内容队列增加一条BlogID,每个用户都有一个收件队列,当用户关注一个博主时,会拉取当前时间之后的所有博文,而不会获取全部博文。

(2)推模式: 也叫做写扩散(常用)。

流程: 每一位博主拉取自己的关注列表,每次发送博文,会推送到关注者收件队列,用户订阅到推送信息,则开始读取。

(3)推拉结合模式: 也叫做读写混合,兼具推和拉模式的优点。

流程: 每一位博主都有自己的活跃关注用户和普通关注用户,使用推模式发送给活跃用户,使用拉模式发送给普通用户。

4、实现推模式实现关注推送

(1)需求:

  • 修改新增探店笔记的业务,在保存blog到数据库的同时,推送到粉丝的收件箱
  • 收件箱满足可以根据时间戳排序,必须用Redis的数据结构实现
  • 查询收件箱数据时,可以实现分页查询

(2)修改新增探店笔记的业务:

private Result saveBlog(Blog blog){
	//1、获取用户
	long currentUser = UserHolder.getUser().getId();
	blog.setUserId(currentUser);
	
	//2、保存探店博文
	boolean isSuccess = blogService.save(blog);
	if(!isSuccess)
		return Result.error("新增失败");
	
	//3、查询笔记作者的所有粉丝, select * from follow_list where follow_user_id = #{userId}
	List<Follow> follows = followMapper.getAllFollows(currentUser);
	
	//4、推送笔记id给所有粉丝
	for (Follow follow: follows) {
		// 获取粉丝id
		Long userId = follow.getUserId();
		// 推送
		String key = "feed:" + currentUser;
		redisTemplate.opsForZSet().add(key, blog.getId().toString(), System.currentTimeMillis());
	}
	
	//3、返回博文id
	return Result.ok(blog.getId());
}

(3)获取关注者新发的博文

private Result getFollowMessage(long currentTime, long bloggerID, int offset, int max){
	//1、获取当前用户
	Long userId = UserHolder.getUser().getId();
	//2、查询收件箱
	String key = "feed:" + userId;
	Set<ZSetOperations.TypedTuple<String>> typedTuples = redisTemplate.opsForZSet().reverseRangeByScoreWithScores(key, 0, max, offset, 2);
	if(typedTuples == null || typedTuples.isEmpty()){
		return Result.ok();
	}
	//3、解析数据:blogid,minTime,offset
	List<Long> ids = new ArrayList<>(typedTuples.size());
	long minTime = 0;
	int ofs = 1;
	for(ZSetOperations.TypedTuple<String> tuple: typedTuples){
		// 获取id
		ids.add(Long.alueOf(tuple.getValue()));
		//	获取分数
		long time = tuple.getScore().longValue();
		if(time == minTime){
			ofs++;
		}else{
			minTime = time;
			ofs = 1;
		}
	}
	//4、根据id查询blog
	StringBuilder builder = new StringBuilder();
	for(Long id: ids){
		builder.append(id);
	}
	List<Blog> blogs = blogMapper.queryBlogsById(ids, builder.toString());

	//查询blog是否被点赞过
	for (Blog blog : blogs){
		queryBlogUser(blog);
		isBlogLiked(blog);
	}

	ScrollResult r = new ScrollResult();
	r.setList(blogs);
	r.setOffset(ofs);
	r.setMinTime(minTime);

	return Result.ok();
}

八、达人探店

(一)发布探店笔记

1、数据的准备: 日志,也就是log,一个日志发布功能需要有 图片、以及分享文字。

2、数据的处理:

(1)图片的处理: 从前台获取的图片信息,经过原始文件名 》 生成存储文件名 》 保存图片 的流程就可以保存图片,并传回存储的文件名进行回显。

public Result uploadImage(MultipartFile image) {
	try{
		//	1、获取原始文件名
		String originalFilename = image.getOriginalFilename();
		//	2、生成新文件名
		String fileName = createNewFileName(originalFilename);
		//	3、保存文件,保存地址是本机地址,一般会存在服务器上
		image.transferTo(new File(SystemConstants.IMAGE_UPLOAD_DIR, fileName));
		//	4、返回结果
		log.debug("文件上传成功,{}", fileName);
		return Result.ok(fileName);
	} catch (IOException e) {
		throw new RuntimeException("文件上传失败", e);
	}
}

(2)保存博文: 通过前台获取的图片的filename, 博文内容conent,以及用户id组合起来的Blog类,只需要存储这个博文,并返回博文ID进行回显。

public Result saveBlog(Blog blog) {
	//1、获取用户
	UserDTO user = UserHolder.getUser();
	blog.setUserId(user.getId);
	//2、保存探店博文
	blogService.save(blog);
	//3、返回博文id
	return Result.ok(blog.getId());
}

(3)查看博文: 通过前台获取blogid,进行查询,返回笔记信息以及发布的用户信息

public Result queryBlogById(Long id) {
	//1、Blog信息
	Blog blog = blogMapper.getById(id);
	if(Objects.isNull(blog)){
		return.error("博文不存在");
	}
	//2、查询blog相关用户
	Long userId = blog.getUserId();
	User user = userMapper.getById(userId);
	blog.setName(user.getNickName());
	blog.setIcon(user.getIcon());
	//3、返回博文信息
	return Result.ok(blog);
}

(二)点赞: 通过前台获取blogid,userid,查询相关blog,并自增点赞数

1、需求:

  • 同一个用户只能点赞一次Blog,再次点击则取消点赞。
  • 若当前用户点赞,则高亮。

2、解决方案: 使用Redis的ZSET结构,该结构唯一且有序,将该BLOG点赞过的用户进行一次查询并保存到Redis中,当一个用户点赞过,就加入到缓存,再一次点赞,就删除相应缓存。

3、实现:

public Result likeBlog(Long id){
	//1、获取当前用户
	Long userID = UserHolder.getUser().getId;
	//2、判断当前用户是否已经点赞
	String key = "blog:liked:" + id;
	Boolean isMember = Objets.isNull(redis.Template.opsForZSet().score(key, userId.toString()));
	//3、若未点赞则点赞
	if(Boolean.isFalse(isMember)){
		//数据库点赞+1,update from blog set like = like + 1 where id = #{id}
		boolean isSuccess = blogMapper.plusBlogLike(id);
		//保存用户到Redis的Set集合
		if(isSuccess){
			redisTemplate.opsForZSet().add(key, userId.toString(), LocalDateTime.now());
		}
	} else {
		//4、若已点赞则取消点赞
		//数据库点赞-1,update from blog set like = like - 1 where id = #{id}
		boolean isSuccess = blogMapper.minusBlogLike(id);
		//Redis集合删除
		if(isSuccess){
			redisTemplate.del(key);
		}
	}
	return Result.ok();
}

(三)点赞排行榜: 通过前端获取blogid,查询相关用户

1、实现: Redis的命令: ZRANGE z1 0 4,意思是sorted_set 查询一个索引范围内的值,因为存储时就是按照时间存储的,所以在redis中是升序排序,若想要获取最早的几个用户,就要用到ZRANGE指令,若想获取最新的用户则使用ZREVRANGE

private Result getLikeUserTOP(int count, long blogId){
	String key = "blog:liked:";
	//1、从redis获取前n个用户
    Set<String> userList = redisTemplate.opsForZSet().range(key + id, 0, count);
    if(Objects.isNull(userList) || userList.isEmpty())
    	return Result.error(Collections.emptyList());
	//2、解析用户id
	List<Long> ids = userList.stream().map(Long::valueOf).collect(Colletors.toList());
	//3、查询用户
	List<User> users = userService.listById(ids).stream().map(
		user -> JSONObject.parseObject(
			JSONObject.toJSONString(user), 
			UserDTO.class
		).collect(Collectors.toList())
	);
	//4、返回
	return Result.ok(users);
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Zain_horse

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值