目录
一、短信验证登录
1.基于session实现
2.基于session实现登陆的问题
3.基于redis实现短信登陆
二、Redis缓存
1.选择缓存更新策略
1.业务逻辑
3.缓存存在的问题
3.1 缓存穿透
3.2 缓存雪崩
3.3 缓存击穿
三、优惠券秒杀
1.秒杀下单功能
2.超卖问题
3.一人一单功能
4.一人一单的并发安全问题
5.基于Redis的分布式锁
1、实现分布式锁需要实现的两个方法:
2、实现思路
3、代码实现
4、测试
5、Redis分布式锁的误删问题
6、Redis分布式锁的原子性问题
7、秒杀优化
四、达人探店
1、发布探店笔记
2、点赞功能
3、点赞排行榜
五、好友关注
1、关注和取关
2、共同关注
3、关注推送
六、附近商户
1、GEO数据结构
2、搜索附近商户
七、用户签到
1、签到功能
2、签到统计
项目学习来源:
黑马程序员Redis入门到实战教程,深度透析redis底层原理+redis分布式锁+企业解决方案+黑马点评实战项目_哔哩哔哩_bilibili
单体应用时用户的会话信息保存在session中,session存在于服务器端的内存中,由于前前后后用户只针对一个web服务器,所以没啥问题。但是一到了web服务器集群的环境下(我们一般都是用Nginx做负载均衡,若是使用了轮询等这种请求分配策略),就会导致用户在A服务器登录了,session存在于A服务器中,但是第二次请求被分配到了B服务器,由于B服务器中没有该用户的session会话,导致该用户还要再登陆一次,以此类推。这样用户体验很不好。当然解决办法也有很多种,比如同一个用户分配到同一个服务处理、使用cookie保持用户会话信息等。
因此,要解决这样的问题必须满足以下条件:
因此,我们可以利用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().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)
//这里因为stringRedisTemple的key,value都为String类型,所以在存储时,保证Map集合的filed,value也为String类型
HashMap 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信息,原因是:
3、自定义拦截器:
首先,自定义两个拦截器:刷新token拦截器、登录拦截器。
对于每个请求,我们首先会根据获取到的token判断用户是否存在,并将获取到的用户保存到ThreadLocal中,刷新token有效期,然后登录拦截器会判断ThreadLocal中的用户是否存在,不存在则拦截,否则放行。
之后来到登陆拦截器,如果ThreadLocal没有用户,说明没有登陆则进行拦截,否则放行。
/**
* ThreadLocal类:用于保存和设置当前登录用户的信息
*/
public class UserHolder {
private static final ThreadLocal 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的拦截器
* 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
/**
* 登录功能拦截器
*/
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的key是代表当前线程,即弱引用,所以在gc时,key会被回收掉,但是value是强引用没有被回收,所以在我们拦截器的方法里必须手动remove(),即根据当前线程删除value。
本项目选择了主动更新策略,相对较好,主动更新又有以下三种方式:
选择在更新数据库的同时更新缓存。操作缓存和数据库时有三个问题需要考虑:
/**
* 根据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 shopTypes = JSONUtil.toList(shoptypeJSON, ShopType.class);
return Result.ok(shopTypes);
}
//不存在,则查询数据库中是否存在
List 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);
}
问题:缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
常见的解决方案有两种:
解决方案:
/**
* 解决缓存穿透问题:缓存空值
* @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;
}
问题:缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案:
问题:缓存击穿问题也叫热点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));
}
/**
* 优惠券秒杀的下单功能
* @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);
}
请求A查询库存,发现库存为1,请求B这时也来查询库存,库存也为1,然后请求A让数据库减1,这时候B查询到的仍然是1,也继续让库存减1,就会导致超卖。
1、解决方案:
乐观锁:认为线程安全问题不一定会发生,因此不加锁,只是在更新数据时去判断有没有其它线程对数据做了修改。
如果没有修改则认为是安全的,自己才更新数据。如果已经被其它线程修改说明发生了安全问题,此时可以重试或异常。
悲观锁:认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行。例如Synchronized、Lock都属于悲观锁。
2、实现乐观锁主要有以下两种方法:
每次更新数据库的时候按照版本查询,并且要更新版本。
CAS是英文单词Compare And Swap的缩写,翻译过来就是比较并替换。该方法是版本号法的简化
CAS的缺点:
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("库存不足");
}
要求同一个优惠券,一个用户只能下单一次
这样的方式会产生线程安全问题,问题和超卖情况类似,所以这里需要用到悲观锁,逻辑如下:
/**
* 优惠券秒杀的下单功能
* @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);
}
通过加锁的方式可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了(每个jvm都有自己的锁监视器,集群模式下各个服务器的锁不共享)。
因此,我们的解决方案就是实现一个共享的锁监视器,即:
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。 分布式锁满足:多进程可见、互斥、高可用、高性能、安全性等。
因此,下面通过基于Redis的分布式锁来解决一人一单的并发安全问题。
/**
* 基于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();
}
}
通过模拟集群模式并进行测试,发现能够解决在集群模式下一人一单带来的并发安全问题,保证了多进程之间的可见并且互斥。
可以看到,数据库优惠券库存数减1,订单量加1:
因此,通过Redis的setnx命令能够实现互斥,进而能够实现分布式锁,因为不同服务之间共享同一个Redis服务器,而Redis服务器中的锁key是唯一的,实现了不同服务共享同一个锁监视器。
1、误删问题
当前我们基于Redis实现的分布式锁还存在一个锁误删的问题,简单来说,当线程1获取到锁后发生了业务阻塞导致锁超时释放了,这时线程2获取到了锁并执行业务,恰好此时线程1业务执行完成了并释放锁,导致线程2的锁被误删释放,就造成了锁误删问题。因此,要针对误删问题改进分布式锁。
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:";
//线程标示的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);
}
}
}
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 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());
}
}
详情见:
2.3基于阻塞队列实现异步秒杀
2.4 Redis消息队列实现异步秒杀
/**
* 发布探店笔记
* @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);
}
}
1、需求:
2、实现步骤:
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 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();
}
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 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
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()
.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);
}
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 idStr = stringRedisTemplate.opsForSet().intersect(key1, key2);
if (idStr == null || idStr.isEmpty()){
//无交集
return Result.ok(Collections.emptyList());
}
//解析用户ID
List ids = idStr.stream().map(Long::valueOf).collect(Collectors.toList());
//根据ID查询用户
List userDtos = userService.listByIds(ids)
.stream().map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());
//返回
return Result.ok(userDtos);
}
1、Feed流
2、实现思路
基于推模式实现关注推送功能:
分页问题:这里选择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> tuples = stringRedisTemplate
.opsForZSet()
.reverseRangeByScoreWithScores(key, 0, max, offset, 2);
if (tuples == null || tuples.isEmpty()){
return Result.ok();
}
//解析收件箱中的数据:blogId、minTime(时间戳)、offset
List ids = new ArrayList<>(tuples.size());
long minTime = 0; //最小时间戳
int os = 1; //偏移量
for (ZSetOperations.TypedTuple 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 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);
}
GEO就是Geolocation的简写形式,代表地理坐标。Redis在3.2版本中加入了对GEO的支持,允许存储地理坐标信息,帮助我们根据经纬度来检索数据。常见的命令有:
1、实现思路:
2、代码实现
void saveGeoOfShop(){
//查询商户信息
List list = shopService.list();
//根据typeId对商户进行分组,相同类型的商户分为一组
Map> map = list.stream().collect(Collectors.groupingBy(Shop::getTypeId));
//将商户信息(id,经度,纬度)保存到Redis的GEO集合中
for (Map.Entry> entry : map.entrySet()) {
//获取typeId
Long typeId = entry.getKey();
//商户类型地理信息key
String key = "geo:shop:"+typeId;
//获取同一类型的商户信息
List shops = entry.getValue();
//将商户id,坐标封装到locations中
List> locations = new ArrayList<>();
for (Shop shop : shops) {
locations.add(new RedisGeoCommands.GeoLocation(
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文件
org.springframework.boot
spring-boot-starter-data-redis
lettuce-core
io.lettuce
spring-data-redis
org.springframework.data
org.springframework.data
spring-data-redis
2.6.2
io.lettuce
lettuce-core
6.1.6.RELEASE
/**
* 根据商铺类型滚动分页查询商铺信息(查询附近商户)
* @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 page = query()
.eq("type_id", typeId)
.page(new Page(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> 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>> content = results.getContent();
//截取from~end的数据,滚动分页显示
if (content.size() ids = new ArrayList<>(content.size()); //封装shopId
Map 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 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);
}
提示:因为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();
}
从最后一次签到开始向前统计,直到遇到第一次未签到为止,计算总的签到次数,就是连续签到天数。
/**
* 用户连续签到统计
* @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 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);
}