目录
项目学习来源:
黑马程序员Redis入门到实战教程,深度透析redis底层原理+redis分布式锁+企业解决方案+黑马点评实战项目_哔哩哔哩_bilibili
一、短信验证登录
1.基于session实现
2.基于session实现登陆的问题
单体应用时用户的会话信息保存在session中,session存在于服务器端的内存中,由于前前后后用户只针对一个web服务器,所以没啥问题。但是一到了web服务器集群的环境下(我们一般都是用Nginx做负载均衡,若是使用了轮询等这种请求分配策略),就会导致用户在A服务器登录了,session存在于A服务器中,但是第二次请求被分配到了B服务器,由于B服务器中没有该用户的session会话,导致该用户还要再登陆一次,以此类推。这样用户体验很不好。当然解决办法也有很多种,比如同一个用户分配到同一个服务处理、使用cookie保持用户会话信息等。
因此,要解决这样的问题必须满足以下条件:
- 数据共享
- 内存存储
- key、value结构
因此,我们可以利用Redis来实现登录的功能。
3.基于redis实现短信登陆
1、发送验证码功能:
/**
* 发送手机验证码
*/
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
return userService.sendCode(phone,session);
}
@Override
public Result sendCode(String phone, HttpSession session) {
//判断手机号格式是否有效
if (RegexUtils.isPhoneInvalid(phone)) {
//无效则返回失败信息
return Result.fail("手机号格式有误!");
}
//手机号有效,则生成6位验证码
String code = RandomUtil.randomNumbers(6);
//将验证码保存在Redis中,有效时间2分钟
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY+phone,code,LOGIN_CODE_TTL, TimeUnit.MINUTES);
//将手机号保存在Redis中,有效时间2分钟
stringRedisTemplate.opsForValue().set(LOGIN_PHONE_KEY+phone,phone,LOGIN_PHONE_TTL,TimeUnit.MINUTES);
//控制台显示验证码
log.info("验证码发送成功,验证码为:{}",code);
return Result.ok();
}
2、登录功能:
该 login方法会把生成的token返回给前端,前端会将token保存在请求头中,并且添加前端拦截器,每次发送请求时拦截器会将token保存到请求头中,这样后端就能够接收到token并进行验证。
/**
* 登录功能
* @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
*/
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
//实现登录功能
return userService.login(loginForm,session);
}
/**
* 登录功能
* @param loginForm
* @param session
* @return
*/
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
//判断手机号是否为当前发送验证码的手机号
String redisPhone = stringRedisTemplate.opsForValue().get(LOGIN_PHONE_KEY + loginForm.getPhone());
if (loginForm.getPhone() == null || !(redisPhone.equals(loginForm.getPhone()))) {
return Result.fail("手机号有误");
}
//判断验证码是否有效
if (RegexUtils.isCodeInvalid(loginForm.getCode())) {
return Result.fail("验证码格式有误");
}
//判断验证码是否一致
String redisCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + redisPhone);
if (redisCode==null || !(redisCode.equals(loginForm.getCode()))){
return Result.fail("验证码不正确");
}
//判断用户是否在数据库中已存在
User user = getOne(new LambdaQueryWrapper<User>().eq(User::getPhone, loginForm.getPhone()));
if(user == null){
//不存在,则创建用户
user = createUserWithPhone(loginForm.getPhone());
}
//已存在,则将用户信息保存在Redis中
//属性拷贝:将数据封装到UserDTO中,用于隐藏用户的隐私信息
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
//随机生成token,作为登录令牌
String token = UUID.randomUUID().toString(true);
//将userDTO转为Map类型,并保存在Hash结构中(key,Map<Field,value>)
//这里因为stringRedisTemple的key,value都为String类型,所以在存储时,保证Map集合的filed,value也为String类型
HashMap<String, String> userMap = new HashMap<>();
userMap.put("id",userDTO.getId().toString());
userMap.put("nickName",userDTO.getNickName());
userMap.put("icon",userDTO.getIcon());
//用户信息作为value,token作为key保存在Redis中
String key = LOGIN_USER_KEY+token;
stringRedisTemplate.opsForHash().putAll(key,userMap);
//设置token的有效时间30分钟
stringRedisTemplate.expire(key,LOGIN_USER_TTL,TimeUnit.MINUTES);
//将token返回给前端,前端会将token保存在请求头中,每次请求都会携带toekn,后端接收到token进行判断
return Result.ok(token);
}
/**
* 创建用户
* @param phone
* @return
*/
private User createUserWithPhone(String phone) {
User user = new User();
user.setPhone(phone);
user.setNickName("user_"+RandomUtil.randomString(8));
save(user);
return user;
}
*这里使用redis的hash结构存储user信息,原因是:
- 若使用String结构,以JSON字符串来保存,比较直观
- Hash结构可以将对象中的每个字段独立存储,可以针对单个字段做CRUD,并且内存占用更少
3、自定义拦截器:
首先,自定义两个拦截器:刷新token拦截器、登录拦截器。
对于每个请求,我们首先会根据获取到的token判断用户是否存在,并将获取到的用户保存到ThreadLocal中,刷新token有效期,然后登录拦截器会判断ThreadLocal中的用户是否存在,不存在则拦截,否则放行。
之后来到登陆拦截器,如果ThreadLocal没有用户,说明没有登陆则进行拦截,否则放行。
- ThreadLocal工具类(UserHolder):用于保存并设置当前登录用户信息。
/**
* ThreadLocal类:用于保存和设置当前登录用户的信息
*/
public class UserHolder {
private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();
public static void saveUser(UserDTO user){
tl.set(user);
}
public static UserDTO getUser(){
return tl.get();
}
public static void removeUser(){
tl.remove();
}
}
- 刷新token拦截器
/**
* 刷新token的拦截器
* 1、该拦截器是自定义的,所以没有被Spring容器管理
* 2、自定义的拦截器不能通过Spring容器进行依赖注入,因此这里利用构造函数实现依赖注入
* 因为拦截器配置类是由Spring容器管理的,可以依赖注入redisTemple,然后将注入的对象传入到拦截器的构造函数中,就实现了依赖注入
*/
public class RefreshInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
public RefreshInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 刷新token有效时间
* @param request
* @param response
* @param handler
* @return
* @throws Exception
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//获取前端请求头中的token
String token = request.getHeader("authorization");
if (token==null){
return true;
}
//根据token获取Redis中保存的用户信息
String key = LOGIN_USER_KEY+token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
//判断用户是否存在
if (userMap.isEmpty()) {
return true;
}
//存在,则将当前登录用户保存在ThreadLocal中
//将Map类型转换为UserDTO类型
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
UserHolder.saveUser(userDTO);
//刷新token的有效时间
stringRedisTemplate.expire(key,LOGIN_USER_TTL, TimeUnit.MINUTES);
return true;
}
/**
* 执行完后,清除当前用户信息,避免内存泄露
* @param request
* @param response
* @param handler
* @param ex
* @throws Exception
*/
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//ThreadLocal底层是Map存储,以当前线程作为key,用户信息作为value,所以根据当前线程删除value
UserHolder.removeUser();
}
}
- 登录拦截器
/**
* 登录功能拦截器
*/
public class LoginInterceptor implements HandlerInterceptor {
/**
* 登录拦截器:判断用户是否已登录
* @param request
* @param response
* @param handler
* @return
* @throws Exception
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//获取ThreadLocal中的用户信息
UserDTO userDTO = UserHolder.getUser();
//判断当前用户是否为空
if (userDTO == null){
//拦截
response.setStatus(401);
return false;
}
//放行
return true;
}
}
- 拦截器配置类
/**
* WebMvc配置类
* 1、设置两个拦截器的原因:因为如果用户访问一个不需要拦截的首页面,30分钟后导致token会失效,
* 所以配置两个拦截器,一个用于刷新token有效时间的拦截器,该拦截器会拦截所有请求,使得任何请求都会刷新token,该拦截器优先执行
* 一个用于拦截当前用户是否登录的拦截器,该拦截器拦截部分请求,该拦截器作为第二拦截器执行
*/
@Configuration
public class AdminMvcConfig implements WebMvcConfigurer {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
//刷新token的拦截器,优先级高
registry.addInterceptor(new RefreshInterceptor(stringRedisTemplate))
.addPathPatterns("/**").order(0);
//登录功能拦截器,优先级低
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns("/user/code",
"/user/login",
"/blog/hot",
"shop-type/**",
"/shop/**",
"/upload/**",
"/voucher/**"
).order(1);
}
}
- ThreadLocal补充:
- Thread类有一个类型为ThreadLocal,ThreadLocalMap的实例变量threadLocals,也就是说每个线程有一个自己的ThreadLocalMap。
- ThreadLocalMap有自己的独立实现,可以简单地将它的key视作ThreadLocal,value为代码中放入的值(实际上key并不是ThreadLocal本身,而是它的一个弱引用)。
- 每个线程在往ThreadLocal里放值的时候,都会往自己的ThreadLocalMap里存,读也是以ThreadLocal作为引用,在自己的map里找对应的key,从而实现了线程隔离。
- ThreadLocalMap有点类似HashMap的结构,只是HashMap是由数组+链表实现的,而ThreadLocalMap中并没有链表结构。
- 我们还要注意Entry, 它的key是ThreadLocal<?> k ,继承自WeakReference, 也就是我们常说的弱引用类型。
内存泄漏问题: 由于ThreadLocal的key是代表当前线程,即弱引用,所以在gc时,key会被回收掉,但是value是强引用没有被回收,所以在我们拦截器的方法里必须手动remove(),即根据当前线程删除value。
二、Redis缓存
1.选择缓存更新策略
本项目选择了主动更新策略,相对较好,主动更新又有以下三种方式:
选择在更新数据库的同时更新缓存。操作缓存和数据库时有三个问题需要考虑:
- 删除缓存还是更新缓存?
-
- 更新缓存:每次更新数据库都更新缓存,无效写操作较多
- 删除缓存:更新数据库时让缓存失效,查询时再更新缓存
- 如何保证缓存与数据库的操作的同时成功或失败?
-
- 单体系统,将缓存与数据库操作放在一个事务
- 分布式系统,利用TCC等分布式事务方案
- 先操作缓存还是先操作数据库?
-
- 先操作数据库
1.业务逻辑
- 根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间
- 根据id修改店铺时,先修改数据库,再删除缓存
/**
* 根据ID查询商铺信息--基于Redis查询
* @param id
* @return
*/
@Override
public Result queryShopById(Long id) {
//根据ID查询Redis中是否存在
String key = CACHE_SHOP_KEY+id;
String shopJSON = stringRedisTemplate.opsForValue().get(key);
//Redis有,则直接返回
if (StringUtils.isNotBlank(shopJSON)){
//将json数据转为java对象
Shop shop = JSONUtil.toBean(shopJSON, Shop.class);
return Result.ok(shop);
}
//没有,则查询数据库
Shop shop = getById(id);
if (shop == null){
//数据库中没有,则返回fail
return Result.fail("该商铺不存在");
}
//数据库有,则将查询到的数据保存到Redis中
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
//返回给前端
return Result.ok(shop);
}
/**
* 根据ID修改商铺信息,并清空缓存
* @param shop
* @return
*/
@Override
public Result update(Shop shop) {
//判断商铺是否存在
if (shop.getId()==null){
return Result.fail("该商铺不存在");
}
//根据ID修改商铺信息
updateById(shop);
//清空缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY+shop.getId());
return Result.ok("商铺更新成功");
}
/**
* 查询商铺类型列表--基于Redis查询
* @return
*/
@Override
public Result queryShopTypeList() {
//查询Redis中是否存在
String shoptypeJSON = stringRedisTemplate.opsForValue().get(CACHE_SHOPTYPE_KEY);
if (StringUtils.isNotBlank(shoptypeJSON)){
//存在,则直接返回
List<ShopType> shopTypes = JSONUtil.toList(shoptypeJSON, ShopType.class);
return Result.ok(shopTypes);
}
//不存在,则查询数据库中是否存在
List<ShopType> list = query().orderByAsc("sort").list();
if (list == null){
//不存在,则返回空
return Result.fail("商铺分类不存在");
}
//存在,则将数据保存到Redis中
String jsonStr = JSONUtil.toJsonStr(list);
stringRedisTemplate.opsForValue().set(CACHE_SHOPTYPE_KEY,jsonStr,CACHE_SHOPTYPE_TTL, TimeUnit.MINUTES);
//返回给前端
return Result.ok(list);
}
3.缓存存在的问题
3.1 缓存穿透
问题:缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
常见的解决方案有两种:
- 缓存空对象
优点:实现简单,维护方便
缺点:额外的内存消耗,可能造成短期的不一致
适合命中不高,但可能被频繁更新的数据 - 布隆过滤
优点:内存占用较少,没有多余key
缺点:实现复杂,存在误判可能
适合命中不高,但是更新不频繁的数据
解决方案:
/**
* 解决缓存穿透问题:缓存空值
* @param id
* @return
*/
public Shop queryWithPassThrough(Long id){
//根据ID查询Redis中是否存在
String key = CACHE_SHOP_KEY+id;
String shopJSON = stringRedisTemplate.opsForValue().get(key);
//Redis有,则直接返回
//isNotBlank:不为null、不为""、length!=0
if (StringUtils.isNotBlank(shopJSON)){
//将json数据转为java对象
return JSONUtil.toBean(shopJSON, Shop.class);
}
//判断查询到的是否为空值""
if ("".equals(shopJSON)){
return null;
}
//不存在,则查询数据库
Shop shop = getById(id);
if (shop == null){
//数据库中没有,则缓存空值到Redis中
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;
}
//数据库有,则将查询到的数据保存到Redis中
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
//返回给前端
return shop;
}
3.2 缓存雪崩
问题:缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案:
- 给不同的Key的TTL添加随机值
- 利用Redis集群提高服务的可用性
- 给缓存业务添加降级限流策略
- 给业务添加多级缓存
3.3 缓存击穿
问题:缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
常见的解决方案有两种:
- 互斥锁
- 逻辑过期
两种方案对比:
3.3.1 互斥锁解决缓存击穿问题
/**
* 利用互斥锁解决缓存击穿问题
* @param id
* @return
*/
public Shop queryWithMutex(Long id){
//根据ID查询Redis中是否存在
String key = CACHE_SHOP_KEY+id;
String shopJSON = stringRedisTemplate.opsForValue().get(key);
//Redis有,则直接返回
//isNotBlank:不为null、不为""、length!=0
if (StringUtils.isNotBlank(shopJSON)){
//将json数据转为java对象
return JSONUtil.toBean(shopJSON, Shop.class);
}
//判断查询到的是否为空值""
if ("".equals(shopJSON)){
return null;
}
//不存在,则查询数据库实现缓存重建
String lockKey = LOCK_SHOP_KEY+id;
Shop shop = null;
try {
//1、获取互斥锁
boolean isLock = tryLock(lockKey);
//2、判断是否获取到锁
if (!isLock){
//3、失败,则休眠一段时间并重试 这里用到了递归,return结束递归
Thread.sleep(50);
return queryWithMutex(id);
}
//4、成功,则查询数据库,将数据写入缓存
shop = getById(id);
//模拟重建的延时
Thread.sleep(200);
if (shop == null){
//数据库中没有,则缓存空值到Redis中
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;
}
//数据库有,则将查询到的数据保存到Redis中
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
//释放锁
unLock(lockKey);
}
//返回给前端
return shop;
}
/**
* 获取互斥锁:利用Redis中的setnx方法实现互斥锁的功能,当且仅当key不存在时设置其value值
* @param key
* @return
*/
public boolean tryLock(String key){
//设置互斥锁
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "lock", LOCK_SHOP_TTL, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
/**
* 释放互斥锁
* @param key
*/
public void unLock(String key){
stringRedisTemplate.delete(key);
}
3.3.2 设置逻辑过期解决缓存击穿问题
[点击并拖拽以移动]
//创建线程池
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
/**
* 设置逻辑过期时间解决缓存击穿问题
* @param id
* @return
*/
public Shop queryWithLogicalExpire(Long id){
//从Redis中查询商铺信息
String shopJSON = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//判断是否存在
if (StrUtil.isBlank(shopJSON)){
//不存在
return null;
}
//存在,则需要先将json反序列化为java对象
RedisData redisData = JSONUtil.toBean(shopJSON, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
//判断缓存是否过期
LocalDateTime expireTime = redisData.getExpireTime();
if (expireTime.isAfter(LocalDateTime.now())){
//未过期,则直接返回信息
return shop;
}
//已过期,则重建缓存
//获取互斥锁
String lockKey = CACHE_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
//判断是否获取成功
if (isLock){
//成功,则开启独立线程,进行缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
//重建缓存
saveShop2Redis(id,20L);
//释放锁
unLock(lockKey);
});
}
//失败,则返回过期的店铺信息
return shop;
}
/**
* 重建缓存:将数据库查询到的数据写入缓存
* @param id
*/
public void saveShop2Redis(Long id,Long expireTime){
//查询店铺信息
Shop shop = getById(id);
//封装查询到的数据和逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireTime));
//写入Redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(redisData));
}
三、优惠券秒杀
1.秒杀下单功能
/**
* 优惠券秒杀的下单功能
* @param voucherId
* @return
*/
@Override
public Result seckillVoucher(Long voucherId) {
//查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
//判断优惠券秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())){
//尚未开始
return Result.fail("秒杀尚未开始");
}
//判断优惠券秒杀是否已经结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())){
//已经结束
return Result.fail("秒杀已经结束");
}
//判断库存是否充足
if (voucher.getStock() < 1){
//库存不足
return Result.fail("库存不足");
}
//扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock -1")
.eq("voucher_id", voucherId).update();
//判断扣减是否成功
if (!success){
return Result.fail("库存不足");
}
//创建订单
VoucherOrder voucherOrder = new VoucherOrder();
//订单id
long orderId = redisIDWorker.nextId("order");
voucherOrder.setId(orderId);
//用户id
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
//优惠券id
voucherOrder.setVoucherId(voucherId);
//新增订单
save(voucherOrder);
//返回订单ID
return Result.ok(orderId);
}
2.超卖问题
请求A查询库存,发现库存为1,请求B这时也来查询库存,库存也为1,然后请求A让数据库减1,这时候B查询到的仍然是1,也继续让库存减1,就会导致超卖。
1、解决方案:
乐观锁:认为线程安全问题不一定会发生,因此不加锁,只是在更新数据时去判断有没有其它线程对数据做了修改。
如果没有修改则认为是安全的,自己才更新数据。如果已经被其它线程修改说明发生了安全问题,此时可以重试或异常。
悲观锁:认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行。例如Synchronized、Lock都属于悲观锁。
2、实现乐观锁主要有以下两种方法:
- 版本号法:
每次更新数据库的时候按照版本查询,并且要更新版本。
- CAS
CAS是英文单词Compare And Swap的缩写,翻译过来就是比较并替换。该方法是版本号法的简化
CAS的缺点:
- CPU开销较大:在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给CPU带来很大的压力。
- 不能保证代码块的原子性:CAS机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证3个变量共同进行原子性的更新,就不得不使用Synchronized了。
3、业务逻辑
//判断库存是否充足
if (voucher.getStock() < 1){
//库存不足
return Result.fail("库存不足");
}
//扣减库存 update tb_seckill_voucher set stock=stock-1 where voucher_id=? and stock>0
boolean success = seckillVoucherService.update()
.setSql("stock = stock -1")
.eq("voucher_id", voucherId)
.gt("stock",0)
.update();
//判断扣减是否成功
if (!success){
return Result.fail("库存不足");
}
3.一人一单功能
要求同一个优惠券,一个用户只能下单一次
这样的方式会产生线程安全问题,问题和超卖情况类似,所以这里需要用到悲观锁,逻辑如下:
/**
* 优惠券秒杀的下单功能
* @param voucherId
* @return
*/
@Override
public Result seckillVoucher(Long voucherId) {
//查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
//判断优惠券秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())){
//尚未开始
return Result.fail("秒杀尚未开始");
}
//判断优惠券秒杀是否已经结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())){
//已经结束
return Result.fail("秒杀已经结束");
}
//判断库存是否充足
if (voucher.getStock() < 1){
//库存不足
return Result.fail("库存不足");
}
//保证事务提交之后再释放锁
//同步监视器:userId,保证一个用户用的是同一把锁
//创建代理对象,实现事务管理功能,避免使用被代理类而导致事务失效,
// 并且需要在启动类加注解@EnableAspectJAutoProxy(exposeProxy = true),暴露该代理对象才能获取
Long userId = UserHolder.getUser().getId();
synchronized(userId.toString().intern()){
//获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
}
/**
* 优惠券订单生成
* @param voucherId
* @return
*/
@Transactional
public Result createVoucherOrder(Long voucherId) {
//实现一人一单
Long userId = UserHolder.getUser().getId();
//查询优惠券订单
int count = query().eq("voucher_id", voucherId).eq("user_id", userId).count();
//判断订单是否存在
if (count>0){
//该用户已经购买过了
return Result.fail("用户已经购买一次!");
}
//扣减库存 update tb_seckill_voucher set stock=stock-1 where voucher_id=? and stock>0
boolean success = seckillVoucherService.update()
.setSql("stock = stock -1")
.eq("voucher_id", voucherId)
.gt("stock",0)
.update();
//判断扣减是否成功
if (!success){
return Result.fail("库存不足");
}
//创建订单
VoucherOrder voucherOrder = new VoucherOrder();
//订单id
long orderId = redisIDWorker.nextId("order");
voucherOrder.setId(orderId);
//用户id
voucherOrder.setUserId(userId);
//优惠券id
voucherOrder.setVoucherId(voucherId);
//新增订单
save(voucherOrder);
//返回订单ID
return Result.ok(orderId);
}
4.一人一单的并发安全问题
通过加锁的方式可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了(每个jvm都有自己的锁监视器,集群模式下各个服务器的锁不共享)。
因此,我们的解决方案就是实现一个共享的锁监视器,即:
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。 分布式锁满足:多进程可见、互斥、高可用、高性能、安全性等。
因此,下面通过基于Redis的分布式锁来解决一人一单的并发安全问题。
5.基于Redis的分布式锁
1、实现分布式锁需要实现的两个方法:
- 获取锁
- 互斥:确保只能有一个线程获取锁
- 非阻塞:尝试一次,成功返回true,失败返回false
- 释放锁
- 手动释放
- 超时释放:获取锁时设置一个超时时间
2、实现思路
3、代码实现
/**
* 基于Redis实现分布式锁
*/
public class SimpleRedisLock implements ILock{
//业务名称,即不同业务有不同的锁key
private String name;
private StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
//互斥锁key前缀
private static final String KEY_PREFIX = "lock:";
/**
* 尝试获取锁
* @param timeoutSec 锁的过期时间,过期后自动释放
* @return
*/
@Override
public boolean tryLock(long timeoutSec) {
//获取当前线程的ID
long threadId = Thread.currentThread().getId();
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId+"", timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
/**
* 释放锁
*/
@Override
public void unLock() {
stringRedisTemplate.delete(KEY_PREFIX+name);
}
}
/**
* 优惠券秒杀的下单功能
* @param voucherId
* @return
*/
@Override
public Result seckillVoucher(Long voucherId) {
//查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
//判断优惠券秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())){
//尚未开始
return Result.fail("秒杀尚未开始");
}
//判断优惠券秒杀是否已经结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())){
//已经结束
return Result.fail("秒杀已经结束");
}
//判断库存是否充足
if (voucher.getStock() < 1){
//库存不足
return Result.fail("库存不足");
}
//这里锁key的设计,是针对当前业务的同一个用户进行互斥锁,来保证同一个用户只能下一单。并且减小锁的范围,不同用户不会受到互斥的限制
Long userId = UserHolder.getUser().getId();
//创建锁对象
SimpleRedisLock redisLock = new SimpleRedisLock("order"+userId.toString(),stringRedisTemplate);
//尝试获取锁
boolean isLock = redisLock.tryLock(1);
//判断是否获取到
if (!isLock){
//获取锁失败,返回错误
return Result.fail("不允许重复下单");
}
try {
//获取代理对象(事务) 保证事务提交之后再释放锁
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
//释放锁
redisLock.unLock();
}
}
4、测试
通过模拟集群模式并进行测试,发现能够解决在集群模式下一人一单带来的并发安全问题,保证了多进程之间的可见并且互斥。
可以看到,数据库优惠券库存数减1,订单量加1:
因此,通过Redis的setnx命令能够实现互斥,进而能够实现分布式锁,因为不同服务之间共享同一个Redis服务器,而Redis服务器中的锁key是唯一的,实现了不同服务共享同一个锁监视器。
5、Redis分布式锁的误删问题
1、误删问题
当前我们基于Redis实现的分布式锁还存在一个锁误删的问题,简单来说,当线程1获取到锁后发生了业务阻塞导致锁超时释放了,这时线程2获取到了锁并执行业务,恰好此时线程1业务执行完成了并释放锁,导致线程2的锁被误删释放,就造成了锁误删问题。因此,要针对误删问题改进分布式锁。
2、解决误删问题的实现思路:
- 在获取锁时存入线程标示(可以用UUID表示),这里考虑使用UUID是因为多个服务之间的线程ID可能相同,采用的都是ID递增策略。所以用UUID+线程ID来保证多个服务之间的线程ID唯一且各不相同。
- 在释放锁时先获取锁中的线程标示,判断是否与当前线程标示一致
- 如果一致则释放锁
- 如果不一致则不释放锁
3、代码实现
/**
* 基于Redis实现分布式锁
*/
public class SimpleRedisLock implements ILock{
//业务名称,即不同业务有不同的锁key
private String name;
private StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
//互斥锁key前缀
private static final String KEY_PREFIX = "lock:";
//线程标示的UUID
private static final String ID_PREFIX = UUID.randomUUID().toString(true)+"-";
/**
* 尝试获取锁
* @param timeoutSec 锁的过期时间,过期后自动释放
* @return
*/
@Override
public boolean tryLock(long timeoutSec) {
//生成当前线程的标示:UUID+线程ID
String threadId = ID_PREFIX+Thread.currentThread().getId();
//锁的value为线程的标示
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
/**
* 释放锁
*/
@Override
public void unLock() {
//获取当前线程标示
String threadId = ID_PREFIX+Thread.currentThread().getId();
//获取锁的线程标示
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
//判断线程标示是否一致
if (threadId.equals(id)) {
//一致,则释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
}
6、Redis分布式锁的原子性问题
1、原子性问题:
当前我们基于Redis实现的分布式锁还存在原子性问题,简单来说,当线程1执行完业务进行释放锁时,会先判断当前锁的标示是否是自己的,条件成立后此时,线程1可能因为GC发生了阻塞,导致锁超时释放了,那么线程2获取到锁后执行业务,这个时候线程1由阻塞恢复为运行状态,接着就会释放锁(因为线程1之前已经进行过释放锁的判断了),导致线程2的锁被释放,因此出现了释放锁的原子性问题。
2、解决方案:
通过Lua脚本解决多条命令的原子性问题。Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。
Lua是一种编程语言,它的基本语法大家可以参考网站:Lua 教程 | 菜鸟教程
如果脚本中的key、value不想写死,可以作为参数传递。key类型参数会放入KEYS数组,其它参数会放入ARGV数组,在脚本中可以从KEYS和ARGV数组获取这些参数:
3、实现思路
首先基于Lua脚本编写分布式锁的释放锁逻辑,然后在Java中通过RedisTemplate提供的API来调用Lua脚本。
4、代码实现
--判断锁的标示和当前线程标示是否一致
if (redis.call('get', KEYS[1]) == ARGV[1]) then
--释放锁 del key
return redis.call('del',KEYS[1])
end
return 0
/**
* 基于Redis实现分布式锁
*/
public class SimpleRedisLock implements ILock{
//业务名称,即不同业务有不同的锁key
private String name;
private StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
//互斥锁key前缀
private static final String KEY_PREFIX = "lock:";
//线程标示的UUID
private static final String ID_PREFIX = UUID.randomUUID().toString(true)+"-";
//初始化脚本对象,用于加载Lua脚本 这里定义为static是为了在类加载时就初始化该脚本对象,并且只会初始化一次,提高IO性能
private static final DefaultRedisScript<Long> redisScript;
static {
redisScript = new DefaultRedisScript<>();
redisScript.setLocation(new ClassPathResource("unlock.lua"));
redisScript.setResultType(Long.class);
}
/**
* 尝试获取锁
* @param timeoutSec 锁的过期时间,过期后自动释放
* @return
*/
@Override
public boolean tryLock(long timeoutSec) {
//生成当前线程的标示:UUID+线程ID
String threadId = ID_PREFIX+Thread.currentThread().getId();
//锁的value为线程的标示
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
/**
* 释放锁
*/
@Override
public void unLock() {
//调用Lua脚本
stringRedisTemplate.execute(redisScript,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX+Thread.currentThread().getId());
}
}
7、秒杀优化
详情见:
四、达人探店
1、发布探店笔记
/**
* 发布探店笔记
* @param blog
* @return
*/
@PostMapping
public Result saveBlog(@RequestBody Blog blog) {
// 获取登录用户
UserDTO user = UserHolder.getUser();
blog.setUserId(user.getId());
// 保存探店博文
blogService.save(blog);
// 返回id
return Result.ok(blog.getId());
}
/**
* 探店笔记图片上传功能
* @param image
* @return
*/
@PostMapping("blog")
public Result uploadImage(@RequestParam("file") MultipartFile image) {
try {
// 获取原始文件名称
String originalFilename = image.getOriginalFilename();
// 生成新文件名
String fileName = createNewFileName(originalFilename);
// 保存文件
image.transferTo(new File(SystemConstants.IMAGE_UPLOAD_DIR, fileName));
// 返回结果
log.debug("文件上传成功,{}", fileName);
return Result.ok(fileName);
} catch (IOException e) {
throw new RuntimeException("文件上传失败", e);
}
}
2、点赞功能
1、需求:
- 同一个用户只能点赞一次,再次点击则取消点赞
- 如果当前用户已经点赞,则点赞按钮高亮显示(前端已实现,判断字段Blog类的isLike属性)
2、实现步骤:
- 给Blog类中添加一个isLike字段,标示是否被当前用户点赞过
- 修改点赞功能,利用Redis的zset集合判断是否点赞过,未点赞过则点赞数+1,已点赞过则点赞数-1
- 修改根据id查询Blog的业务,判断当前登录用户是否点赞过,赋值给isLike字段
- 修改分页查询Blog业务,判断当前登录用户是否点赞过,赋值给isLike字段
3、代码实现
/**
* 点赞功能:使用sortedSet集合
* @param id
* @return
*/
@Override
public Result likeBlog(Long id) {
//获取当前用户信息
Long userId = UserHolder.getUser().getId();
//判断当前用户是否已点赞过
String key = BLOG_LIKED_KEY + id;
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
if (score==null){
//未点赞,可以点赞
//数据库点赞数+1
boolean success = update().setSql("liked = liked + 1").eq("id", id).update();
//Redis的zset集合添加该用户
if (success){
stringRedisTemplate.opsForZSet().add(key,userId.toString(),System.currentTimeMillis());
}
}else {
//已点赞,取消点赞
//数据库点赞数-1
UpdateWrapper<Blog> wrapper = new UpdateWrapper<>();
wrapper.setSql("liked = liked - 1");
wrapper.eq("id",id);
boolean success = update(wrapper);
//删除Redis的set集合中的该用户
if (success){
stringRedisTemplate.opsForZSet().remove(key,userId.toString());
}
}
return Result.ok();
}
3、点赞排行榜
1、需求
按照点赞时间的先后顺序,将给指定笔记点赞的前TOP5用户显示出来,形成排行榜。
2、实现思路
实现点赞排行榜的功能,需要使用sortedSet集合,利用score命令判断用户是否点过赞,并且利用时间戳作为权值对点赞时间先后的用户进行排序。
3、代码实现
/**
* 查看指定笔记的点赞排行榜
* @param id
* @return
*/
@Override
public Result queryBlogLikes(Long id) {
String key = BLOG_LIKED_KEY + id;
//查询top5的点赞用户 zrange key 0 4
Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4);
if (top5 == null || top5.isEmpty()){
return Result.ok(Collections.emptyList());
}
/*
//根据用户ID查询用户信息 WHERE id IN(5,1) ORDER BY FIELD(id,5,1)
String idStr = StrUtil.join(",", ids);
List<Object> userDtos = userService.query()
.in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list()
.stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());*/
//根据用户ID查询用户信息
List<UserDTO> userDtos = top5.stream().map(idstr -> {
//解析查询到的用户ID
Long userid = Long.valueOf(idstr);
User user = userService.getById(userid);
//将User复制给UserDTO,用于隐藏用户隐私信息
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
return userDTO;
}).collect(Collectors.toList());
//返回
return Result.ok(userDtos);
}
五、好友关注
1、关注和取关
1、实现思路
关注是博主与粉丝的关系,数据库中有一张tb_follow表来标示,当前用户在关注某个博主前,会发起一个请求先判断是否已关注该博主,如果未关注,则新增数据,则可以进行关注d;否则,可以取关,删除数据。
2、代码实现
/**
* 关注取关
* @param followUserId
* @param isFollow
* @return
*/
@Override
public Result follow(Long followUserId, Boolean isFollow) {
//获取当前登录用户
Long userId = UserHolder.getUser().getId();
//当前用户的关注集合key
String key = "follow:"+userId;
//判断要关注还是要取关
if (isFollow) {
//要关注,新增数据
Follow follow = new Follow();
follow.setUserId(userId);
follow.setFollowUserId(followUserId);
boolean success = save(follow);
if (success)
stringRedisTemplate.opsForSet().add(key,followUserId.toString());
}else {
//要取关,删除 delete from tb_follow where user_id = ? and follow_user_id = ?
boolean success = remove(new QueryWrapper<Follow>()
.eq("user_id", userId)
.eq("follow_user_id", followUserId));
if (success)
stringRedisTemplate.opsForSet().remove(key,followUserId.toString());
}
return Result.ok();
}
/**
* 判断用户是否被关注
* @param id
* @return
*/
@Override
public Result isFollow(Long id) {
//获取当前登录用户
Long userId = UserHolder.getUser().getId();
//查询用户是否被关注
Integer count = query().eq("user_id", userId)
.eq("follow_user_id", id)
.count();
//判断
return Result.ok(count > 0);
}
2、共同关注
1、实现思路
点击某个博主的个人主页,可以看到当前用户和博主的共同关注,这里可以选择Redis中的Set集合,当前用户ID作为key,关注的用户ID作为value,利用Set集合提供的intersect命令求两个集合的交集,实现共同关注。
2、代码实现
/**
* 查询共同关注
* @param id
* @return
*/
@Override
public Result followCommon(Long id) {
//获取当前登录用户ID
Long userId = UserHolder.getUser().getId();
//当前用户的关注集合key
String key1 = "follow:"+userId;
//目标用户的关注集合key
String key2 = "follow:"+id;
//求交集,获取共同关注用户的ID
Set<String> idStr = stringRedisTemplate.opsForSet().intersect(key1, key2);
if (idStr == null || idStr.isEmpty()){
//无交集
return Result.ok(Collections.emptyList());
}
//解析用户ID
List<Long> ids = idStr.stream().map(Long::valueOf).collect(Collectors.toList());
//根据ID查询用户
List<UserDTO> userDtos = userService.listByIds(ids)
.stream().map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());
//返回
return Result.ok(userDtos);
}
3、关注推送
1、Feed流
- 关注推送也叫做Feed流,直译为投喂。为用户推送感兴趣的内容,用户可以通过无限下拉刷新获取新的信息。
- Feed流有两种模式:
- Timeline:不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注。例如朋友圈
-
- 优点:信息全面,不会有缺失。并且实现也相对简单
- 缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低
- 智能排序:利用智能算法屏蔽掉违规的、用户不感兴趣的内容。推送用户感兴趣信息来吸引用户
-
- 优点:投喂用户感兴趣信息,用户粘度很高,容易沉迷
- 缺点:如果算法不精准,可能起到反作用
- 本项目是基于关注的好友来做Feed流,因此采用Timeline的模式。该模式的实现方案有三种:拉模式、推模式、推拉结合
2、实现思路
基于推模式实现关注推送功能:
- 修改新增探店笔记的业务,在保存blog到数据库的同时,推送到粉丝的收件箱
- 收件箱满足可以根据时间戳排序,必须用Redis的数据结构实现
- 查询收件箱数据时,可以实现分页查询
分页问题:这里选择sortedSet,因为sortedSet集合可以根据score值(使用时间戳)进行排序,而list集合会按照角标排序,但Feed流中的数据会不断更新,所以数据的角标也在变化,因此不能采用传统的分页模式,这里采用滚动分页,每次查询时会记录最小的时间戳,那么下一次分页查询的时候会查询比该时间戳小的下一个数据。
整体思路说明:分页查询收件箱时,采用的是滚动分页,即记录每次查询结果的最小时间戳,并在下一次查询时会查询小于等于该最小时间戳的数据,对于特殊情况如果上个查询中最小时间戳相同的元素有n个,那么下次查询时就会偏移掉n个。总而言之,第一次查询收件箱时,lastId即时间戳为当前的时间戳,偏移量默认为0,所以第一次查询时会查询小于等于当前时间戳的第一个数据(可指定count即每次查询几条),之后查询时会查询小于等于上次查询结果的最小时间戳并且偏移量为1的数据,如果上次查询结果有n个最小时间戳相同的元素,那么偏移量offset就为n。
3、代码实现
/**
* 关注推送:查看已关注用户的推送笔记
* @param max
* @param offset
* @return
*/
@Override
public Result queryBlogOfFollow(Long max, Integer offset) {
//获取当前登录用户
Long userId = UserHolder.getUser().getId();
//粉丝的收件箱key
String key = FEED_KEY+ userId;
//查看收件箱 ZREVRANGEBYSCORE key max min LIMIT offset count
Set<ZSetOperations.TypedTuple<String>> tuples = stringRedisTemplate
.opsForZSet()
.reverseRangeByScoreWithScores(key, 0, max, offset, 2);
if (tuples == null || tuples.isEmpty()){
return Result.ok();
}
//解析收件箱中的数据:blogId、minTime(时间戳)、offset
List<Long> ids = new ArrayList<>(tuples.size());
long minTime = 0; //最小时间戳
int os = 1; //偏移量
for (ZSetOperations.TypedTuple<String> tuple : tuples){
//获取blogId
ids.add(Long.valueOf(tuple.getValue()));
//获取score(时间戳)
long time = tuple.getScore().longValue();
if (minTime == time){
os++;
}else{
//不等于,说明当前time比minTime小,因为该sortedSet集合是按时间戳从大到小排序获取的
minTime = time;
os = 1;
}
}
//根据blogId查询笔记
String idStr = StrUtil.join(",", ids);
List<Blog> blogs = blogService.query()
.in("id", ids)
.last("ORDER BY FIELD(id," + idStr + ")")
.list();
//blog封装其他数据
for (Blog blog : blogs){
//查询笔记对应的作者
queryBlogUser(blog);
//查询笔记是否被当前用户点赞
isBlogLiked(blog);
}
//封装数据并返回
ScrollResult scrollResult = new ScrollResult();
scrollResult.setList(blogs);
scrollResult.setMinTime(minTime);
scrollResult.setOffset(os);
return Result.ok(scrollResult);
}
六、附近商户
1、GEO数据结构
GEO就是Geolocation的简写形式,代表地理坐标。Redis在3.2版本中加入了对GEO的支持,允许存储地理坐标信息,帮助我们根据经纬度来检索数据。常见的命令有:
- GEOADD:添加一个地理空间信息,包含:经度(longitude)、纬度(latitude)、值(member)
- GEODIST:计算指定的两个点之间的距离并返回
- GEOHASH:将指定member的坐标转为hash字符串形式并返回
- GEOPOS:返回指定member的坐标
- GEORADIUS:指定圆心、半径,找到该圆内包含的所有member,并按照与圆心之间的距离排序后返回。6.2以后已废弃
- GEOSEARCH:在指定范围内搜索member,并按照与指定点之间的距离排序后返回。范围可以是圆形或矩形。6.2.新功能
- GEOSEARCHSTORE:与GEOSEARCH功能一致,不过可以把结果存储到一个指定的key。 6.2.新功能
2、搜索附近商户
1、实现思路:
- Redis的Geo集合导入商户信息:按照商户类型做分组,类型相同的商户作为同一组,以typeId为key存入同一个GEO集合中,Geo集合是使用sortedSet集合存储的,typeId作为key,商户id作为value,商户坐标作为score。
- 查询附近商户:根据前端传入的参数判断是否需要根据距离查询商户信息,如果经纬度有值,则按距离查询附近商户信息。首先根据命令GEOSEARCH key FROMLONLAT x y BYRADIUS 5 WITHDISTANCE,查询Redis中的Geo集合中的商户信息(shopId,distance),结果按照距离升序排序,并实现滚动分页。结果首先会获得GeoResults集合,调用getContent()获取到GeoResult<GeoLocation>类型的List集合,GeoResult中封装了GeoLocation类型的数据和距离信息。GeoLocation中封装了商户ID和商户坐标。所以可以根据GeoLocation获取到商户ID,根据GeoResult获取到距离信息,然后根据商户ID查询到商户对象,把距离信息封装到商户对象中,然后将查询到的商户对象集合返回即可。
2、代码实现
- 向Redis的Geo集合导入商户信息(key:商户类型id,value:商户id,score:商户坐标)
void saveGeoOfShop(){
//查询商户信息
List<Shop> list = shopService.list();
//根据typeId对商户进行分组,相同类型的商户分为一组
Map<Long,List<Shop>> map = list.stream().collect(Collectors.groupingBy(Shop::getTypeId));
//将商户信息(id,经度,纬度)保存到Redis的GEO集合中
for (Map.Entry<Long, List<Shop>> entry : map.entrySet()) {
//获取typeId
Long typeId = entry.getKey();
//商户类型地理信息key
String key = "geo:shop:"+typeId;
//获取同一类型的商户信息
List<Shop> shops = entry.getValue();
//将商户id,坐标封装到locations中
List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>();
for (Shop shop : shops) {
locations.add(new RedisGeoCommands.GeoLocation<String>(
shop.getId().toString(),
new Point(shop.getX(),shop.getY())
));
}
//添加到Redis的Geo集合中,typeId作为key,locations作为value
stringRedisTemplate.opsForGeo().add(key,locations);
}
}
2.查询附近商户
SpringDataRedis的2.3.9版本并不支持Redis 6.2提供的GEOSEARCH命令,因此我们需要提示其版本,修改自己的POM文件
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<artifactId>lettuce-core</artifactId>
<groupId>io.lettuce</groupId>
</exclusion>
<exclusion>
<artifactId>spring-data-redis</artifactId>
<groupId>org.springframework.data</groupId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>
<version>2.6.2</version>
</dependency>
<dependency>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
<version>6.1.6.RELEASE</version>
</dependency>
/**
* 根据商铺类型滚动分页查询商铺信息(查询附近商户)
* @param typeId 商铺类型
* @param current 页码
* @param x 经度
* @param y 纬度
* @return 商铺列表
*/
@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<Shop>(current, DEFAULT_PAGE_SIZE));
return Result.ok(page.getRecords());
}
//计算分页参数
int from = (current-1)*DEFAULT_PAGE_SIZE;
int end = current*DEFAULT_PAGE_SIZE;
//查询Redis,按照距离排序,分页,结果(shopId,distance) GEOSEARCH key FROMLONLAT x y BYRADIUS 5 WITHDISTANCE
String key = SHOP_GEO_KEY+typeId;
GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo().search(
key,
GeoReference.fromCoordinate(x, y), //根据经纬度查询
new Distance(5000), //查询距离范围5km
RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs()
.includeDistance() //结果携带距离
.limit(end)); //限制查询的数量
if (results == null){
return Result.ok(Collections.emptyList());
}
//获取GeoResult类型集合,其封装了GeoLocation和Distance,GeoLocation中封装了商户ID、商户坐标
List<GeoResult<RedisGeoCommands.GeoLocation<String>>> content = results.getContent();
//截取from~end的数据,滚动分页显示
if (content.size()<from){
//查询出的数据数量小于本页查询要求的起始条数时,例如第三页是从第10条开始查,总数据只有9条,所以第三页查询结果为空
return Result.ok(Collections.emptyList());
}
List<String> ids = new ArrayList<>(content.size()); //封装shopId
Map<String,Distance> map = new HashMap<>(content.size()); //封装shopId,distance,使其一一对应
content.stream().skip(from).forEach(result -> {
//获取商户ID
String shopIdStr = result.getContent().getName();
ids.add(shopIdStr);
//获取距离
Distance distance = result.getDistance();
map.put(shopIdStr,distance);
});
//根据ID查询商户信息,并按指定顺序将结果排序
String idStr = StrUtil.join(",", ids);
List<Shop> shops = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();
//将距离信息封装到shop中
for (Shop shop : shops) {
shop.setDistance(map.get(shop.getId().toString()).getValue());
}
return Result.ok(shops);
}
七、用户签到
1、签到功能
提示:因为BitMap底层是基于String数据结构,因此其操作也都封装在字符串相关操作中了。
/**
* 用户签到
* @return
*/
@Override
public Result sign() {
//获取当前登录用户
Long userId = UserHolder.getUser().getId();
//获取当前时间
LocalDateTime now = LocalDateTime.now();
//拼接key
String date = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = USER_SIGN_KEY + userId + date;
//获取当天是这个月的哪一天
int day = now.getDayOfMonth();
//签到
stringRedisTemplate.opsForValue().setBit(key,day-1,true);
return Result.ok();
}
2、签到统计
从最后一次签到开始向前统计,直到遇到第一次未签到为止,计算总的签到次数,就是连续签到天数。
/**
* 用户连续签到统计
* @return
*/
@Override
public Result signCount() {
//获取当前登录用户
Long userId = UserHolder.getUser().getId();
//获取当前时间
LocalDateTime now = LocalDateTime.now();
//拼接key
String date = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = USER_SIGN_KEY + userId + date;
//获取当天是这个月的哪一天
int day = now.getDayOfMonth();
//获取本月截止今天为止的所有签到记录,返回的是一个十进制数 BITFIELD key GET u天数 0
List<Long> result = stringRedisTemplate.opsForValue().bitField(
key,
BitFieldSubCommands.create().
get(BitFieldSubCommands.BitFieldType.unsigned(day)).valueAt(0));
if (result == null || result.isEmpty()){
//没有签到记录,返回0
return Result.ok(0);
}
Long num = result.get(0);
if (num == null || num == 0){
return Result.ok(0);
}
//循环遍历
int count = 0;
while (true){
//将该记录result与1做与运算,判断结果bit是否为0
if ((num & 1) == 0){
//结果bit为0,则未签到
break;
}else {
//结果bit为1,则已签到
count++;
}
//将num无符号右移一位,继续和1做与运算
num >>>= 1;
}
//返回签到次数
return Result.ok(count);
}