默认做好了其它的前提,只专注于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,查询相关用户文章来源:https://www.toymoban.com/news/detail-620350.html
1、实现: Redis的命令: ZRANGE z1 0 4
,意思是sorted_set 查询一个索引范围内的值,因为存储时就是按照时间存储的,所以在redis中是升序排序,若想要获取最早的几个用户,就要用到ZRANGE
指令,若想获取最新的用户则使用ZREVRANGE
文章来源地址https://www.toymoban.com/news/detail-620350.html
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);
}
到了这里,关于Redis学习路线(9)—— Redis的场景使用的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!