@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {
@Resource
private IUserService userService;
@Resource
private IUserInfoService userInfoService;
/**
* 发送手机验证码
*/
@PostMapping("/code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
//发送短信验证码并保存验证码
return userService.sendCode(phone,session);;
}
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
@Override
public Result sendCode(String phone, HttpSession session) {
//1.校验手机号
//2.如果不符合,则返回错误dto
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号格式错误!");
}
//3.不满足第2点,说明符合,生成验证码
String code = RandomUtil.randomNumbers(6);
//4.保存验证码到session
session.setAttribute("code",code);
//5.发送验证码:用日志模拟发送验证码
log.debug("验证码发送成功,验证码:"+code);
return Result.ok();
}
}
/**
* 登录功能
* @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
*/
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
//实现登录功能
return userService.login(loginForm,session);
}
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
//1.校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号格式错误!");
}
//2.校验验证码
String code = loginForm.getCode();
Object cacheCode = session.getAttribute("code");
//3.如果验证码不一致,返回错误信息
if (cacheCode == null || !cacheCode.toString().equals(code)) {
return Result.fail("验证码错误!");
}
//4.如果一致,查询数据库有没有这样的用户:select * from user where phone = #{phone} //list or one
User user = query().eq("phone", phone).one();
//5.查不到,创建新用户,保存到数据库
if (user == null) {
user = createAndSaveUser(phone);//新建的用户
}
//6.查得到直接把用户保存到session中,查不到就把新建的用户保存到session中
session.setAttribute("user", user);
return Result.ok();
}
private User createAndSaveUser(String phone) {
User user = new User();
user.setPhone(phone);//用户输入的手机号
user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(8));
save(user);
return user;
}
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1.获取session
HttpSession session = request.getSession();
//2.获取用户
Object user = session.getAttribute("user");
//3.判断用户是否存在
if (user==null){
//4.用户不存在,拦截
response.setStatus(401);
return false;
}
//5.如果存在,保存到ThreadLocal
UserHolder.saveUser((User) user);
//6.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//注销用户
UserHolder.removeUser();
}
}
放行发送验证和登录验证的请求:
@Configuration
public class WebMVCConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/user/code",
"/user/login"
);
}
}
@GetMapping("/me")
public Result me(){
// 获取当前登录的用户并返回
User user = UserHolder.getUser();
return Result.ok(user);
}
UserDTO只封装了常用的且不暴露用户信息的属性:
@Data
public class UserDTO {
private Long id;
private String nickName;
private String icon;
}
因此我们在把user保存到session中的时候,不需要保存所有的字段:
//6.查得到直接把用户保存到session中,查不到就把新建的用户保存到session中
session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));
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();
}
}
返回给me请求的时候,只需要返回UserDTO对象了。
@GetMapping("/me")
public Result me(){
// 获取当前登录的用户并返回
UserDTO user = UserHolder.getUser();
return Result.ok(user);
}
由于客户端访问nginx之后,如果有多台tomcat做并发集群,当nginx作负载均衡,在多个tomcat之间做轮循,每一个tomcat都会有自己的session,如果同一个用户负载均衡进入的tomcat不一样,那么他们的session保存的数据就不一致,所以为了解决session数据同步性,共享性,存储性,我们需要用redis来代替session。
校验手机号,生成验证码,把校验成功的手机号作为key,验证码作为value存入到Redis中去。那么当用户点击登录/注册按钮的时候,进行校验验证码的时候,就可以从redis中去获取value,那么当我们验证码校验成功后,用户存在我们需要把用户存入到redis中,则需要手动的生成一个随机的token作为key,用户的信息作为value存入到redis中。
校验登录状态时,请求并携带token,通过随机token作为key获取到用户数据。用户存在就保存到ThreadLocal中并放行当前请求。拦截用户不存在的请求。
@Resource
StringRedisTemplate stringRedisTemplate;
@Override
public Result sendCode(String phone, HttpSession session) {
//1.校验手机号
//2.如果不符合,则返回错误信息
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号格式错误!");
}
//3.生成验证码
String code = RandomUtil.randomNumbers(6);
//4.以手机号作为key保存验证码到redis
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
//5.发送验证码:用日志模拟发送验证码
log.debug("验证码发送成功,验证码:" + code);
return Result.ok();
}
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
//1.校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号格式错误!");
}
//2.以手机号作为key读取验证码,再进行校验验证码
String code = loginForm.getCode();
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
//3.如果验证码不一致,返回错误信息
if (cacheCode == null || !cacheCode.equals(code)) {
return Result.fail("验证码错误!");
}
//4.如果一致,查询数据库有没有这样的用户
User user = query().eq("phone", phone).one();
//5.判断用户是否存在
if (user == null) {
//6.不存在,新建用户
user = createAndSaveUser(phone);
}
//7.把用户信息保存到redis中
//7.1 随机生成token 作为登录令牌
String token = UUID.randomUUID().toString(true);//不带下划线的UUID
String tokenKey = LOGIN_USER_KEY + token;
//7.2 将user对象转成Hash存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create().setIgnoreNullValue(true).
setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
//7.3 存储
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
//7.4设置有效期
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
//8.返回token到前端
return Result.ok(token);
}
private User createAndSaveUser(String phone) {
User user = new User();
user.setPhone(phone);//用户输入的手机号
user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(8));
save(user);
return user;
}
public class LoginInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1.获取请求头中的token
String token = request.getHeader("authorization");
//2.基于token获取redis中的用户
String tokenKey = RedisConstants.LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(tokenKey);
//3.判断用户是否存在
if (userMap.isEmpty()){
//4.用户不存在,拦截
response.setStatus(401);
return false;
}
//5.将查询到的Hash数据转为UserDTO对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
//6.如果存在,保存到ThreadLocal
UserHolder.saveUser(userDTO);
//7.更新token有效期
stringRedisTemplate.expire(tokenKey,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
//8.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//注销用户
UserHolder.removeUser();
}
}
前置拦截方法内:
这里说一下为什么要在拦截器里刷新token的有效期:
如果不刷新token的有效期,用户访问登录接口,过了这个有效期,token就过期失效了。而我们希望的是,只要用户不断的去访问,就应该不断的更新redis中的token。问题是我怎么知道用户什么时候访问,而用户每访问一次登录请求之后,都会经过一次拦截器,所以我们就可以在拦截此时进行token的更新,从而实现用户不断的访问,token不断的更新。
为了实现用户访问所有的请求都可以刷新token而不只是登录请求,我们需要在登录拦截器基础上再加一层拦截器RefreshTokenInterceptor:
RefreshTokenInterceptor:用来刷新token,并且让他拦截一切的请求。
LoginInterceptor :只判断ThreadLocal中是否存在用户信息实现拦截和放行。
public class RefreshTokenInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1.获取请求头中的token
String token = request.getHeader("authorization");
//2.基于token获取redis中的用户
String tokenKey = RedisConstants.LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(tokenKey);
//3.判断用户是否存在
if (userMap.isEmpty()){
//4.用户不存在,先放行,交给登录拦截器拦截
return true;
}
//5.将查询到的Hash数据转为UserDTO对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
//6.如果存在,保存到ThreadLocal
UserHolder.saveUser(userDTO);
//7.更新token有效期
stringRedisTemplate.expire(tokenKey,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
//8.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//注销用户
UserHolder.removeUser();
}
}
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1.查看ThreadLocal中是否存在用户信息
if (UserHolder.getUser()==null){
response.setStatus(401);
//2.没有则拦截
return false;
}
//3.有则放行
return true;
}
}
@Override
public Result queryShopById(Long id) {
//1.从redis查询缓存
String shopKey = RedisConstants.CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(shopKey);
//2.判断缓存中是否存在
if (StrUtil.isNotBlank(shopJson)) {
//3.存在,则返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//4.不存在,查询数据库
Shop shop = getById(id);
//5.数据库查不到,返回错误信息
if (shop == null) {
Result.fail("商铺信息不存在!");
}
//6.数据库查的到,存入redis缓存,返回信息
stringRedisTemplate.opsForValue().set(shopKey, JSONUtil.toJsonStr(shop));
return Result.ok(shop);
}
@Override
public Result queryTypeList() {
//1.从redis查询缓存的list
String shopTypeKey = RedisConstants.CACHE_SHOPTYPE_KEY;
List<String> shopTypeJsonList = stringRedisTemplate.opsForList().range(shopTypeKey, 0, -1);
//2.判断缓存中list是否存在
if (!shopTypeJsonList.isEmpty()) {
//3.存在,则返回
ArrayList<ShopType> shopTypeList = new ArrayList<>();
for (String shopTypeJson : shopTypeJsonList) {
ShopType shopType = JSONUtil.toBean(shopTypeJson, ShopType.class);
shopTypeList.add(shopType);
}
return Result.ok(shopTypeList);
}
//4.判断数据库中是否存在
List<ShopType> typeList = query().orderByAsc("sort").list();
//5.不存在,则返回错误
if (typeList.isEmpty()) {
return Result.fail("分类错误!");
}
//6.存在,缓存到redis中去
//6.1 把泛型为shopType的list转成泛型为json String的list
ArrayList<String> jsonList = new ArrayList<>();
for (ShopType shopType : typeList) {
String json = JSONUtil.toJsonStr(shopType);
jsonList.add(json);
}
//6.2 存入redis中去
stringRedisTemplate.opsForList().rightPushAll(shopTypeKey,jsonList);
//7.返回数据
return Result.ok(typeList);
}
业务场景:
低一致性需求:使用内存淘汰机制。例如店铺类型的查询缓存
高一致性需求:主动更新,并以超时剔除作为兜底方案。例如店铺详情查询的缓存
其中企业中最常用的策略正是Cache Aside Pattern:
先删除缓存,再更新数据库:如果有一个线程1做删除缓存,随后更新数据库的操作,在线程1执行过程中,突然穿插了线程2做查询缓存,由于缓存已经被删了,所以未命中,就去查询数据库,最后写入缓存。最后线程1的数据库的更新操作才执行,但是刚刚线程2写入的缓存是数据库更新前的旧数据,这样数据库和缓存的数据就不一致。而且线程2的执行时间远小于线程1的,这种情况发生的概率比较高。
先更新数据库,再删除缓存:如果有一个线程1做查询缓存,假如缓存过期了,未命中,就去查数据库,查到的数据是旧数据。在线程1执行写入缓存中操作之前,假如此时有线程2更新数据库,数据库的值被更新了,然后删除了缓存,注意缓存本身就没有数据,所以删了相当于没删。然后线程1 的写入缓存的操作才开始执行,此时写的数据是之前查的旧数据,因此缓存里的数据是旧数据,而数据库里的数据是新数据,就不一致了。但是线程2的执行时间是远大于线程1的,所以这种情况发生的概率比较小。
因此我们先更新数据库,再删除缓存的执行顺序更能保护我们的线程安全。
那么有了缓存更新策略的理论支持:我们来实现商铺缓存与数据库的双写一致:
修改ShopController中的业务逻辑,满足下面的需求:
修改queryShopById方法:增加expire参数,实现缓存超时剔除。
//6.数据库查的到,存入redis缓存,返回信息
stringRedisTemplate.opsForValue().set(shopKey, JSONUtil.toJsonStr(shop),
RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
更新商户信息:缓存与数据库的双写一致
@Override
@Transactional
public Result update(Shop shop) {
//1.更新数据库
Long id = shop.getId();
if (id == null) {
return Result.fail("商户id不能为空!");
}
updateById(shop);
//2.删除缓存
stringRedisTemplate.delete(RedisConstants.CACHE_SHOP_KEY + id);
return Result.ok();
}
缓存穿透是指用户请求的数据在缓存和数据库中都不存在的情况,缓存永远不会生效,这些请求会打到数据库中查到一个null的数据。
解决方案:
需要在原来基础上修改两个地方:
其一是:在查询数据库时如果未命中,就把空值写入redis中去。
其二是:为了防止请求命中这个空值的缓存,我们还需要在缓存命中后判断命中的是否为空值。
@Override
public Result queryShopById(Long id) {
//1.从redis查询缓存
String shopKey = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(shopKey);
//2.判断缓存中是否存在
if (StrUtil.isNotBlank(shopJson)) {
//3.存在,则返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//缓存命中后,需要判断命中的是否为空值
if (shopJson!=null){//缓存里没有值,又不为空,只能是""
return Result.fail("店铺不存在!");
}
//4.不存在,查询数据库
Shop shop = getById(id);
//5.数据库查不到,返回错误信息
if (shop == null) {
//5.1为了解决缓存穿透,把未命中的空值写入到redis中去
stringRedisTemplate.opsForValue().set(shopKey, "",CACHE_NULL_TTL, TimeUnit.MINUTES);
return Result.fail("店铺不存在!");
}
//6.数据库查的到,存入redis缓存,返回信息
stringRedisTemplate.opsForValue().set(shopKey, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}
缓存穿透产生的原因是什么?
缓存穿透的解决方案有哪些?
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案:
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会访问数据库给数据库造成巨大压力。
利用redis里的setNX命令类似于互斥锁的机制,定义获取锁和释放锁两个方法:
private boolean tryLock(String key) {
//如果不存在这样的key才set value,并设置ttl作为兜底,防止锁没释放的意外
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
解决缓存击穿的方法queryWithPassMutex:
public Shop queryWithPassMutex(Long id) {
//1.从redis查询缓存
String shopKey = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(shopKey);
//2.判断缓存中是否命中
if (isCacheExist(shopKey)) {
//3.缓存命中,则返回shop
return JSONUtil.toBean(shopJson, Shop.class);
}
//3.缓存命中后,需要判断命中的是否为空值
if (shopJson != null) {//缓存里没有值,又不为空,只能是""
return null;
}
//4.缓存未命中,实现缓存重建,解决缓存击穿
// 4.1 获取互斥锁
Shop shop = null;
String lockKey = LOCK_SHOP_KEY + id;
try {
boolean isLock = tryLock(lockKey);
// 4.2 判断是否获取成功
if (!isLock) {
// 4.3 获取锁失败,休眠,重试
Thread.sleep(50);
queryWithPassMutex(id);
}
//4.4 获取锁成功,再次检查redis缓存中是否命中,如果命中,则直接返回
if (isCacheExist(shopKey)) {
return JSONUtil.toBean(stringRedisTemplate.opsForValue().get(shopKey), Shop.class);
}
//4.5 未命中,则查询数据库
shop = getById(id);
//模拟重建的延时
Thread.sleep(200);
//5.数据库查不到,返回错误信息
if (shop == null) {
//5.1为了解决缓存穿透,把未命中的空值写入到redis中去
stringRedisTemplate.opsForValue().set(shopKey, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//6.数据库查的到,存入redis缓存,返回信息
stringRedisTemplate.opsForValue().set(shopKey, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
//7.释放互斥锁
unlock(lockKey);
}
return shop;
}
/**
* 判断缓存中是否命中
* @param key
* @return boolean
*/
public Boolean isCacheExist(String key){
String Json = stringRedisTemplate.opsForValue().get(key);
//2.判断缓存中是否存在
if (StrUtil.isNotBlank(Json)) {
//3.存在,则返回
return true;
}
return false;
}
主调用方法:
@Override
public Result queryShopById(Long id) {
Shop shop = queryWithPassMutex(id);
if (shop == null) {
return Result.fail("店铺不存在!");
}
return Result.ok(shop);
}
接下来我们用jmeter压力测试工具进行高并发测试(首先要确保redis中缓存中没有这个key):
每秒查询效率(QPS=200)每秒查询200个线程。
运行完发现,数据库只访问了一次,却完成了1000个线程的并发。
其原理就是:
第1个线程,缓存未命中,就去获取锁,获取锁成功后,再次二次检查缓存是否未命中,未命中的情况下,就访问数据库,就把数据存入redis缓存中了,最后释放锁,返回数据。而第2个线程,是和第一个线程并行,但是获取锁失败,于是就休眠,直到等待第一个锁释放,此时缓存中已经有数据了,因此就直接返回了,就不会访问数据库。此后的所有线程与第2给线程一样。都直接从缓存中取,因此,数据库只走了一次。
逻辑过期要求我们的热点key的有效期是永久的。因此我们第一次要做缓存重建。在第一个线程判断逻辑过期时间时,若不提前做缓存重建,就会报空指针异常:
@SpringBootTest
class HmDianPingApplicationTests {
@Resource
ShopServiceImpl shopService;
@Test
void testSaveShop() throws InterruptedException {
shopService.saveShopToRedis(1L,10L);
}
}
public Shop queryWithLogicExpire(Long id) {
//1.从redis查询缓存
String shopKey = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(shopKey);
//2.判断缓存中是否命中
if (!isCacheExist(shopKey)) {
//3.1 缓存未命中,则返回空
return null;
}
//4.将json反序列化为RedisData对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
//3.2 缓存命中后,需要判断缓存是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
//4.1 未过期,返回旧数据
return shop;
}
//4.2 过期,缓存重建
//5.尝试获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
//6.判断是否获取到锁
boolean isLock = tryLock(lockKey);
if (isLock) {
//6.1 获取成功,则开启一个独立线程做缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
saveShopToRedis(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
unlock(lockKey);
}
});
}
//6.2 获取失败
//7.统一返回
return shop;
}
/**
* 判断缓存中是否命中
*
* @param key
* @return boolean
*/
public Boolean isCacheExist(String key) {
String Json = stringRedisTemplate.opsForValue().get(key);
//2.判断缓存中是否存在
if (StrUtil.isNotBlank(Json)) {
//3.存在,则返回
return true;
}
return false;
}
private void saveShopToRedis(Long id, Long expireSeconds) throws InterruptedException {
//1.查询数据库数据
Shop shop = getById(id);
//模拟重建延时
Thread.sleep(200);
//2.封装逻辑过期时间
RedisData redisData = new RedisData(LocalDateTime.now().plusSeconds(expireSeconds), shop);
System.out.println(LocalDateTime.now().plusSeconds(expireSeconds));
//3.写入Redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
@Slf4j
@Configuration
public class CacheClient {
@Resource
private StringRedisTemplate stringRedisTemplate;
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
//写入redis中
public void set(String key, Object value, Long time, TimeUnit unit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
}
//设置逻辑过期 & 写入Redis
public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
// 设置逻辑过期
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
// 写入Redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
/**
* 缓存穿透
*
* @param keyPrefix
* @param id
* @param type
* @param dbFallback
* @param time
* @param timeUnit
* @param
* @param
* @return
*/
public <R, ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit timeUnit) {
//1.从redis查询缓存
String key = keyPrefix + id;
String json = stringRedisTemplate.opsForValue().get(key);
//2.判断缓存中是否存在
if (isCacheExist(key)) {
//3.存在,则返回
return JSONUtil.toBean(json, type);
}
//缓存命中后,需要判断命中的是否为空值
if (json != null) {//缓存里没有值,又不为空,只能是""
return null;
}
//4.不存在,查询数据库
R r = dbFallback.apply(id);
//5.数据库查不到,返回错误信息
if (r == null) {
//5.1为了解决缓存穿透,把未命中的空值写入到redis中去
set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;
}
//6.数据库查的到,存入redis缓存,返回信息
set(key, r, time, timeUnit);
return r;
}
/**
* 互斥锁解决缓存击穿
* @param keyPrefix
* @param id
* @param type
* @param dbFallback
* @param time
* @param timeUnit
* @param
* @param
* @return
*/
public <R, ID> R queryWithMutex(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit timeUnit) {
//1.从redis查询缓存
String key = keyPrefix + id;
String json = stringRedisTemplate.opsForValue().get(key);
//2.判断缓存中是否命中
if (isCacheExist(key)) {
//3.缓存命中,则返回shop
return JSONUtil.toBean(json, type);
}
//3.缓存命中后,需要判断命中的是否为空值
if (json != null) {//缓存里没有值,又不为空,只能是""
return null;
}
//4.缓存未命中,实现缓存重建,解决缓存击穿
// 4.1 获取互斥锁
R r = null;
String lockKey = LOCK_SHOP_KEY + id;
try {
boolean isLock = tryLock(lockKey);
// 4.2 判断是否获取成功
if (!isLock) {
// 4.3 获取锁失败,休眠,重试
Thread.sleep(50);
return queryWithMutex(keyPrefix, id, type, dbFallback, time, timeUnit);
}
//4.4 获取锁成功,再次检查redis缓存中是否命中,如果命中,则直接返回
if (isCacheExist(key)) {
return JSONUtil.toBean(stringRedisTemplate.opsForValue().get(key), type);
}
//4.5 未命中,则查询数据库
r = dbFallback.apply(id);
//模拟重建的延时
Thread.sleep(200);
//5.数据库查不到,返回错误信息
if (r == null) {
//5.1为了解决缓存穿透,把未命中的空值写入到redis中去
set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;
}
//6.数据库查的到,存入redis缓存,返回信息
set(key,r,time,timeUnit);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
//7.释放互斥锁
unlock(lockKey);
}
return r;
}
/**
* 逻辑过期解决缓存击穿
* @param keyPrefix
* @param id
* @param type
* @param dbFallback
* @param time
* @param timeUnit
* @param
* @param
* @return
*/
public <R, ID> R queryWithLogicExpire(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit timeUnit) {
//1.从redis查询缓存
String key = keyPrefix + id;
String json = stringRedisTemplate.opsForValue().get(key);
//2.判断缓存中是否命中
if (!isCacheExist(key)) {
//3.1 缓存未命中,则返回空
return null;
}
//4.将json反序列化为RedisData对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
LocalDateTime expireTime = redisData.getExpireTime();
//3.2 缓存命中后,需要判断缓存是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
//4.1 未过期,返回旧数据
return r;
}
//4.2 过期,缓存重建
//5.尝试获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
//6.判断是否获取到锁
boolean isLock = tryLock(lockKey);
if (isLock) {
//6.1 获取成功,则开启一个独立线程做缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
R newR = dbFallback.apply(id);
//缓存重建
setWithLogicalExpire(key, newR, time, timeUnit);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
unlock(lockKey);
}
});
}
//6.2 获取失败
//7.统一返回
return r;
}
/**
* 判断缓存中是否存在
*
* @param key
* @return
*/
public Boolean isCacheExist(String key) {
String Json = stringRedisTemplate.opsForValue().get(key);
//2.判断缓存中是否存在
if (StrUtil.isNotBlank(Json)) {
//3.存在,则返回
return true;
}
return false;
}
private boolean tryLock(String key) {
//如果不存在这样的key才set value,并设置ttl作为兜底,防止锁没释放的意外
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
}
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
StringRedisTemplate stringRedisTemplate;
@Resource
CacheClient cacheClient;
@Override
public Result queryShopById(Long id) {
// Shop shop = cacheClient.queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
//互斥锁解决缓存击穿
Shop shop = cacheClient.queryWithMutex(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.SECONDS);
//逻辑过期解决缓存击穿
// Shop shop = cacheClient.queryWithLogicExpire(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.SECONDS);
if (shop == null) {
return Result.fail("店铺不存在!");
}
return Result.ok(shop);
}
全局唯一ID生成策略:
Redis自增ID策略:
获取某个时刻的时间戳:
public static void main(String[] args) {
LocalDateTime time = LocalDateTime.of(2022, 10, 10, 0, 0, 0);
long second = time.toEpochSecond(ZoneOffset.UTC);
System.out.println(second);
}
把获取到的时间戳定义成常量
private static final long BEGIN_TIMESTAMP =1665360000L;
自增ID的实现:
@Component
public class RedisIdWorker {
@Resource
private StringRedisTemplate stringRedisTemplate;
private static final long BEGIN_TIMESTAMP =1665360000L;
private static final int COUNT_BITS = 32;
public long nextId(String keyPrefix){
//1.生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long timeStamp = nowSecond - BEGIN_TIMESTAMP;
//2.生成序列号(默认32bit)
//2.1 获取当前日期
String date = now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
//2.2 自增长
Long count = stringRedisTemplate.opsForValue().increment("incr" + keyPrefix + ":" + date);
//3.拼接时间戳和序列号并返回:将时间戳的最高位向左移动32(序列号占32)位,并把自增长结果补给余下位
return timeStamp << COUNT_BITS | count;
}
}
测试:
@Resource
private RedisIdWorker redisIdWorker;
// 线程池
private ExecutorService executorService = Executors.newFixedThreadPool(500);
@Test
void testIdWorker() throws InterruptedException {
//为了让begin和end标志位和所有线程一起执行,使用CountDownLatch
CountDownLatch latch = new CountDownLatch(300);
Runnable task = () -> {
for (int i = 0; i < 100; i++) {
long id = redisIdWorker.nextId("order");
System.out.println("id=" + id);
}
//任务执行完之前countDown,记录begin
latch.countDown();
};
long begin = System.currentTimeMillis();
for (int i = 0; i < 300; i++) {
executorService.submit(task);
}
//任务提交后等待,记录end
latch.await();
long end = System.currentTimeMillis();
System.out.println("executeTime=" + (end - begin));
}
拦截器开放voucher请求
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/user/code",
"/user/login",
"/shop-type/**",
"/shop/**",
"/voucher/**"
).order(1);
@RestController
@RequestMapping("/voucher-order")
public class VoucherOrderController {
@Resource
private IVoucherOrderService voucherOrderService;
@PostMapping("seckill/{id}")
public Result seckillVoucher(@PathVariable("id") Long voucherId) {
return voucherOrderService.seckillVoucher(voucherId);
}
}
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
ISeckillVoucherService seckillVoucherService;
@Resource
RedisIdWorker redisIdWorker;
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
//1. 查询优惠券信息
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
//2. 判断秒杀是否开始
if (voucher.getCreateTime().isAfter(LocalDateTime.now())) {
//3. 尚未开始 返回异常
return Result.fail("秒杀未开始!");
}
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
//3.1 结束,返回异常
return Result.fail("秒杀已结束!");
}
//4. 开始,先判断库存是否充足
if (voucher.getStock() < 1) {
//5. 不充足,返回异常
return Result.fail("库存不足!");
}
//6. 充足,扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId).update();
if (!success) {
return Result.fail("库存不足!");
}
//7. 创建订单:订单id,用户id,代金券id
VoucherOrder voucherOrder = new VoucherOrder();
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
//8. 返回
return Result.ok(orderId);
}
}
乐观锁:版本号法和CAS法。
版本号法(增加一个版本号用于判断是否被修改过):
CAS法(根据数据本身是否被修改作为条件):
对于当前线程:判断库存值是否修改过,如果库存值与原来查询的库存值一致,说明没有被修改过,则放心大胆的去扣减库存。如果不一致,说明已经有别的线程修改过了,就不进行扣减库存。
但是这样做的弊端就是,对于库存只剩最后一件的情况这么做,才能实现库存不被超卖,但是对于库存很充足的情况下,如果用乐观锁,则会导致其他线程以为上一个线程修改过了而不去扣减库存的情况,因此这里的where条件不应该是stock = 1而是stock > 0;这样就只针对只剩最后一件库存的去情况去保证库存超卖的问题:
于是seckillVoucher方法里进行修改这一行代码:
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId)
.gt("stock",0)
.update();
总结:
在原来基础上,判断库存充足之后,如果充足,则根据优惠券id和用户id查询是否有唯一的订单存在,如果不存在,说明该用户之前没有下过单,此时就可以扣减库存和创建订单。如果存在,说明用户之前下过单,则返回异常信息即可。
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
//1. 查询优惠券信息
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
//2. 判断秒杀是否开始
if (voucher.getCreateTime().isAfter(LocalDateTime.now())) {
//3. 尚未开始 返回异常
return Result.fail("秒杀未开始!");
}
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
//3.1 结束,返回异常
return Result.fail("秒杀已结束!");
}
//4. 开始,先判断库存是否充足
if (voucher.getStock() < 1) {
//5. 不充足,返回异常
return Result.fail("库存不足!");
}
//6.充足,一人一单:根据优惠券id和用户id查询唯一订单
Long userId = UserHolder.getUser().getId();
Long count = query().eq("voucher_id", voucherId).eq("user_id", userId).count();
//6.1判断订单是否存在
if (count >0) {
return Result.fail("不可以重复下单!");
}
//7. 充足,扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId)
.gt("stock",0)
.update();
if (!success) {
return Result.fail("库存不足!");
}
//8. 创建订单:订单id,用户id,代金券id
VoucherOrder voucherOrder = new VoucherOrder();
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
//9. 返回
return Result.ok(orderId);
}
在高并发的情况下,根据优惠券id和用户id查询的count值可能都为0.就会出现一个用户重复下单的情况。但是我们此时的业务场景是查询,无法根据是否被修改来加乐观锁,因此我们只能加悲观锁,这里用户是唯一的,因此以userId作为关键字加锁是最理想的:
@Transactional
public Result createVoucherOrder(Long voucherId) {
//6.充足,一人一单:根据优惠券id和用户id查询唯一订单
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
Long count = query().eq("voucher_id", voucherId).eq("user_id", userId).count();
//6.1判断订单是否存在
if (count > 0) {
return Result.fail("不可以重复下单!");
}
//7. 充足,扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
if (!success) {
return Result.fail("库存不足!");
}
//8. 创建订单:订单id,用户id,代金券id
VoucherOrder voucherOrder = new VoucherOrder();
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
//9. 返回
return Result.ok(orderId);
}
}
但是@Transactional注解,事务是在这个方法执行完之后(也就是最后一个花括号)才提交的,而synchronized锁是在倒数第二个花括号执行完后释放的。此时其他线程就可以进来了,而事务尚未提交,就会造成线程不安全问题。所以我们需要让锁的范围扩大至整个方法,以保证事务提交之后再释放锁:
@Override
public Result seckillVoucher(Long voucherId) {
//1. 查询优惠券信息
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
//2. 判断秒杀是否开始
if (voucher.getCreateTime().isAfter(LocalDateTime.now())) {
//3. 尚未开始 返回异常
return Result.fail("秒杀未开始!");
}
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
//3.1 结束,返回异常
return Result.fail("秒杀已结束!");
}
//4. 开始,先判断库存是否充足
if (voucher.getStock() < 1) {
//5. 不充足,返回异常
return Result.fail("库存不足!");
}
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
return createVoucherOrder(voucherId);//让锁的范围扩大至整个方法,
//以保证事务提交之后再释放锁:
}
}
但是由于我们@Transactional事务加在了createVoucherOrder方法上,seckillVoucher方法却没有(因为这一块不需要事务)。那么我们调用createVoucherOrder方法实质上是通过this调用,this就是实现类VoucherOrderServiceImpl。而不是代理对象,而事务的本质是动态代理,this是目标对象,而非代理对象,所以这里的事务就不能生效。
解决方案:
synchronized (userId.toString().intern()) {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
拿到代理对象,并用接口去生成代理对象。这个代理对象是接口的对象,所以接口里要重写createVoucherOrder方法。
并且导入织入依赖aspectjweaver。
以及暴露代理
@EnableAspectJAutoProxy(exposeProxy = true)
@MapperScan("com.hmdp.mapper")
@SpringBootApplication
public class HmDianPingApplication {
当nginx负载均衡多台服务器做集群的情况下,每一个JVM都会有锁监视器,每一个jvm只能确保锁自己线程池中的线程,这就导致其他的JVM也会并发的执行,而不会受到别的JVM锁的影响,从而导致并发线程安全问题。
解决方案:分布式锁。
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。
public class SimpleRedisLock implements ILock {
private String name;
private StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
private static final String KEY_PREFIX = "lock:";
@Override
public boolean tryLock(long timeoutSec) {
//1.获取当前线程id
String threadName = Thread.currentThread().getName();
//2.获取key
String key = KEY_PREFIX + name;
//3.设置锁
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(key, threadName, timeoutSec, TimeUnit.SECONDS);
return BooleanUtil.isTrue(success);
}
@Override
public void unlock() {
stringRedisTemplate.delete(KEY_PREFIX + name);
}
@Override
public Result seckillVoucher(Long voucherId) {
//1. 查询优惠券信息
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
//2. 判断秒杀是否开始
if (voucher.getCreateTime().isAfter(LocalDateTime.now())) {
//3. 尚未开始 返回异常
return Result.fail("秒杀未开始!");
}
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
//3.1 结束,返回异常
return Result.fail("秒杀已结束!");
}
//4. 开始,先判断库存是否充足
if (voucher.getStock() < 1) {
//5. 不充足,返回异常
return Result.fail("库存不足!");
}
Long userId = UserHolder.getUser().getId();
//创建锁对象
SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
boolean isLock = lock.tryLock(1200);
//判断锁是否获取成功
if (!isLock) {
return Result.fail("不允许重复下单!");
}
try {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
//释放锁
lock.unlock();
}
}
解决方案:在每次释放锁之前判断一下锁的标识是否与当前锁一致,如果是则释放,否则什么都不做。另外要在每次获取锁的时候存入线程标识。
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
@Override
public boolean tryLock(long timeoutSec) {
//1.获取当前线程标识
String theadId = ID_PREFIX + Thread.currentThread().getId();
//2.获取key
String key = KEY_PREFIX + name;
//3.设置锁
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(key, theadId, timeoutSec, TimeUnit.SECONDS);
return BooleanUtil.isTrue(success);
}
@Override
public void unlock() {
//获取线程标识
String theadId = ID_PREFIX + Thread.currentThread().getId();
//获取锁的标识
String lockId = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
//判断是否一致
if (theadId.equals(lockId)) {
//释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
}
基于上一种解决方案下,如果gc回收的时候导致判断完后释放锁阻塞了:
解决方案:让判断标识是否一致和释放锁的动作具有原子性。
那么怎么保证这两个动作原子性呢?
--获取锁的标识
local lockId = redis.call("get", KEYS[1]);
--获取线程的标识
local threadId = ARGS[1];
--判断是否一致
if threadId==lockId then
return redis.call("del",KEYS[1])
end
return 0;
简化后
if redis.call("get", KEYS[1])==ARGS[1] then
return redis.call("del",KEYS[1])
end
return 0;
释放锁的方法里就一行代码用于调用lua脚本,但是在此之前DefaultRedisScript类需要在类加载之前初始化:
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
@Override
public boolean tryLock(long timeoutSec) {
//1.获取当前线程标识
String theadId = ID_PREFIX + Thread.currentThread().getId();
//2.获取key
String key = KEY_PREFIX + name;
//3.设置锁
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(key, theadId, timeoutSec, TimeUnit.SECONDS);
return BooleanUtil.isTrue(success);
}
@Override
public void unlock() {
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId());
}
配置redisson:
<dependency>
<groupId>org.redissongroupId>
<artifactId>redissonartifactId>
<version>3.18.0version>
dependency>
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient(){
//配置
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.239.130:6379").setPassword("123321");
//创建客户端
return Redisson.create(config);
}
}
注入redissonClient到实现类,通过getLock方法获取锁:
//创建锁对象
//SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
RLock lock = redissonClient.getLock("order:" + userId);
boolean isLock = lock.tryLock();
//判断锁是否获取成功
if (!isLock) {
return Result.fail("不允许重复下单!");
}
try {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
//释放锁
lock.unlock();
}
}
获取锁时,除了保存线程标识,还要保存一个锁的计数器,用来表示锁被重入的次数。所以采用hash结构进行存储。
第一次tryLock,初始化锁的计数器为1,当同一个线程里,再次被重入时,计数+1。当unlock时,锁的计数-1。当所有的锁被释放后,同一个线程的锁的计数一定为0。
具体流程:
tryLock:判断锁是否存在,
(这里的线程标识用于下一次被重入时判断是否为同一把锁)
如果锁不存在,就获取锁并添加线程标识,设置锁的有效期
如果锁存在,就通过线程标识判断是否是同一个线程下的锁,
如果不是,则获取失败。
如果是,说明可重入,则锁计数+1,然后设置有效期。
unLock:通过线程标识来判断锁是否是自己的锁,
如果不是,则说明锁已经被释放了,就不用再释放。
如果是,则锁计数-1,然后在执行锁释放前,需要先判断锁计数是不是0,
如果不是0,则需要重置锁的有效期,再回到之前判断步骤。
如果是0,就可以放心释放锁。
为了确保原子性,这些都会被编写成lua脚本在Redisson的tryLock方法和unLock方法的源码里。
锁超时释放虽然可以避免死锁,但如果是业务执行耗时较长,也会导致锁释放,存在安全隐患
可重试源码解析:
tryLock:
参数有等待时间,释放时间,单位元
进来之后首先,把等待时间转换成毫秒单位参与后面的运算,然后获取当前时间,以及线程Id
调用tryAcquire方法,返回一个ttl有效期(剩余时间)
进入tryAcquire方法里:如果没有传释放时间,也就是小于0,则释放时间会给一个默认值,看门狗超时释放值为30秒,
然后调用执行获取锁的lua脚本,这段脚本的包含了可重入的功能:
首先判断锁是否存在,如果不存在,就可重试计数加1,设置有效期;如果存在,就判断锁是不是自己的,如果是就重试计数加1,设置有效期。
获取锁成功都返回nil,失败则返回一个ttl,也就是剩余有效期。
如果有效期等于null,则返回true,代表锁获取成功
否则就是失败的情况:当前时间减去尝试获取锁之前的时间,得到尝试获取锁消耗的时间。然后再用等待时间减去消耗时间,得到剩余等待时间。
如果剩余等待时间小于等于0,说明消耗时间太长了以至于把等待时间都消耗完了,因此返回一个false,获取失败
如果剩余等待时间大于0,记录当前时间,然后订阅当前线程上一次释放锁的信号,返回一个future结果。
然后通过future来判断剩余等待时间内有没有得到释放,如果剩余等待时间内还没有收到释放的通知,也就是超时了,就取消这个订阅,然后返回false
如果没有超时,就再次根据当前时间计算剩余等待时间,便开始重试。再重试的时候,不是立马就重试,依然要通过futrue结果通过信号量的方式去、
类似的,如果有效期ttl小于剩余等待时间,说明等待的时候就已经释放了,那就没有必要再等了,所以执行tryAcquire的等待时间参数就是有效期ttl,等ttl时间释放即可。
如果有效期ttl大于剩余等待时间,说明剩余等待时间到期了还没释放,执行tryAcquire的等待时间参数就是等待时间,这里面获取锁失败交给它来做。
最后再计算一下剩余等待时间,如果没有了,就返回false,否则继续重试。逻辑同上。
总结:
在Redis里,尝试获取锁的时候,假如java客户端有执行set lock thead1 Nx命令,向redis主节点发起,就在主节点存入了这个锁,为了保证安全性,redis通常还有一个从节点去同步主节点。但是在还没同步之前,一旦主节点宕机了,我们的redis就会把从节点当成主节点,但是此时的主节点,是没有锁的,这样下一个线程就能获取到了,就会造成线程不安全。
那么Redisson是怎么解决的呢?
Redisson没有主节点也没有从节点,如果一个线程要想成功获取到锁,必须拿到所有的节点的锁,一旦有一个不成功,就会获取失败。那么如果有一个节点宕机了,那么这个节点是没有锁的,这个时候如果有其他线程来的时候,它能获取到这个节点的锁,但是不能获取到其他节点的锁,因此线程是安全的。这样就解决了主从一致性问题。
不可重入Redis分布式锁:
原理:利用setnx的互斥性;利用ex避免死锁;释放锁时判断线程标示
缺陷:不可重入、无法重试、锁超时失效
可重入的Redis分布式锁:
原理:利用hash结构,记录线程标示和重入次数;利用watchDog延续锁时间;利用信号量控制锁重试等待
缺陷:redis宕机引起锁失效问题
Redisson的multiLock:
原理:多个独立的Redis节点,必须在所有节点都获取重入锁,才算获取锁成功
缺陷:运维成本高、实现复杂
在原来的秒杀业务里,基于redis的读写操作和数据库的读写操作都是串行执行的。而对数据库读写操作本身比较耗时,在并发量较大的情况下,单位时间内执行的线程数会少,这会降低并发能力。
因此我们把是否有秒杀资格的业务交给Redis去做:
判断库存是否充足,不充足返回1,充足的情况下,根据set集合里是否有这样的userId,如果有说明重复,则返回1,不允许重复下单。如果没有则把userId添加到set集合里,并且返回0。
为了确保原子性,我们把这一块业务封装到lua脚本,根据该脚本的返回值,来决定是否拥有下单的资格。
添加优惠券到数据库的同时保存到redis中去。
@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {
// 保存优惠券
save(voucher);
// 保存秒杀信息
SeckillVoucher seckillVoucher = new SeckillVoucher();
seckillVoucher.setVoucherId(voucher.getId());
seckillVoucher.setStock(voucher.getStock());
seckillVoucher.setBeginTime(voucher.getBeginTime());
seckillVoucher.setEndTime(voucher.getEndTime());
seckillVoucherService.save(seckillVoucher);
// 保存到redis中
stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString());
}
local voucherId = ARGV[1]
local userId = ARGV[2]
local stockKey = 'seckill:stock' .. voucherId --库存key
local orderKey = 'seckill:order' .. voucherId --订单key:存放userId的set
if (tonumber(redis.call('get', stockKey)) <= 0) then
return 1
end
-- orderKey的订单集合中是否有userId的成员:下单重复
if (redis.call('sismember',orderKey,userId)==1) then
return 2
end
-- 以上情况不满足说明,可以下单了
-- 扣减库存incrby key -1
redis.call('incrby',stockKey,-1)
-- 将userId存入到set集合中 sadd orderKey userId
redis.call('sadd',orderKey,userId)
return 0
static {
SECKILL_SCRIPT = new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}
@Override
public Result seckillVoucher(Long voucherId) {
Long userId = UserHolder.getUser().getId();
//1.执行lua脚本
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(), voucherId.toString(), userId.toString()
);
int r = result.intValue();
//2.判断lua脚本的返回值是否为0,为0则代表有下单资格
if (r != 0) {//没有下单资格
return r == 1 ? Result.fail("库存不足!") : Result.fail("不允许重复下单");
}
//TODO 3.把用户id,优惠券id,订单id放入阻塞队列
long orderId = redisIdWorker.nextId("order:");
//4.返回订单id
return Result.ok(orderId);
}
private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
@PostConstruct//当前类初始化完毕后执行
private void init() {
//提交线程任务
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandle());
}
private class VoucherOrderHandle implements Runnable {
@Override
public void run() {
while (true) {
try {
//获取阻塞队列中的订单信息
VoucherOrder voucherOrder = orderTasks.take();
//创建订单
handleVoucherOrder(voucherOrder);
} catch (InterruptedException e) {
log.error("处理订单异常", e);
}
}
}
}
private void handleVoucherOrder(VoucherOrder voucherOrder) {
//1.获取用户
Long userId = voucherOrder.getUserId();
//2.创建锁对象
RLock lock = redissonClient.getLock("lock:order:" + userId);
//3.获取锁
boolean isLock = lock.tryLock();
//4.判断锁是否获取成功
if (!isLock) {
log.error("不允许重复下单!");
return;
}
try {
proxy.createVoucherOrder(voucherOrder);
} finally {
//释放锁
lock.unlock();
}
}
private IVoucherOrderService proxy;
@Override
public Result seckillVoucher(Long voucherId) {
Long userId = UserHolder.getUser().getId();
//1.执行lua脚本
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(), voucherId.toString(), userId.toString()
);
int r = result.intValue();
//2.判断lua脚本的返回值是否为0,为0则代表有下单资格
if (r != 0) {//没有下单资格
return r == 1 ? Result.fail("库存不足!") : Result.fail("不允许重复下单");
}
//TODO 3.把用户id,优惠券id,订单id放入阻塞队列
VoucherOrder voucherOrder = new VoucherOrder();
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
//3.1放入阻塞队列
orderTasks.add(voucherOrder);
//3.2获取代理对象
proxy = (IVoucherOrderService) AopContext.currentProxy();
//4.返回订单id
return Result.ok(orderId);
}
@Transactional
public void createVoucherOrder(VoucherOrder voucherOrder) {
//6.充足,一人一单:根据优惠券id和用户id查询唯一订单
Long userId = voucherOrder.getUserId();
Long count = query().eq("voucher_id", voucherOrder.getVoucherId()).eq("user_id", userId).count();
//6.1判断订单是否存在
if (count > 0) {
log.error("不可以重复下单!");
return;
}
//7. 充足,扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherOrder.getVoucherId())
.gt("stock", 0)
.update();
if (!success) {
log.error("库存不足!");
return;
}
//8. 创建订单:订单id,用户id,代金券id
save(voucherOrder);
}
}
秒杀业务的优化思路是什么?
先利用redis完成库存余量、一人一单判断,完成抢单业务
再将下单业务放入阻塞队列,利用独立线程异步下单
基于阻塞队列的异步秒杀存在哪些问题?
内存限制问题
数据安全问题
前面说了,使用JDK提供的阻塞队列存在两大问题:一是会占用JVM内存,二是不能保证数据安全问题:因为没有持久化,一旦JVM宕机了,数据就丢失了。
因此我们需要使用消息队列,这里我们介绍基于Redis模拟消息队列、消息中间件:RabbitMQ、kfk等
基于List的消息队列有哪些优缺点?
STREAM类型消息队列的XREAD命令特点:
消费者组((Consumer Group):将多个消费者划分到一个组中,监听同一个队列。具备下列特点:
消息分流
队列中的消息会分流给组内的不同消费者,而不是重复消费,从而加快消息处理的速度
消息标示
消费者组会维护一个标示,记录最后一个被处理的消息,哪怕消费者宕机重启,还会从标示之后读取消息。确保每一个消息都会被消费
消息确认
消费者获取消息后,消息处于pending状态,并存入一个pending-list。当处理完成后需要通过XACK来确认消息,标记消息为已处理,才会从pending-list移除。
创建消费者组:
XGROUP CREATE key groupName ID [MKSTREAM]
#删除指定的消费者组
XGROUP DESTORY key groupName
#给指定的消费者组添加消费者
XGROUP CREATECONSUMER key groupname consumername
#删除消费者组中的指定消费者
XGROUP DELCONSUMER key groupname consumername
从消费者组读取消息:
XREADGROUP GROUP group consumer[COUNT count][BLOCK milliseconds][NOACK] STREAMNSkey [key ...]ID [ID ...]
STREAM类型消息队列的XREADGROUP命令特点:
@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {
@Resource
private IUserService userService;
@Override
public Result queryHotBlog(Integer current) {
// 根据用户查询
Page<Blog> page = query()
.orderByDesc("liked")
.page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
// 获取当前页数据
List<Blog> records = page.getRecords();
// 查询用户
records.forEach(this::queryBlog);
return Result.ok(records);
}
@Override
public Result queryBlogById(Integer id) {
//1.获取笔记
Blog blog = getById(id);
//2.判断笔记是否为空
if (blog == null){
return Result.fail("笔记为空!");
}
//3.查询返回Blog
queryBlog(blog);
return Result.ok(blog);
}
public void queryBlog(Blog blog){
Long userId = blog.getUserId();
User user = userService.getById(userId);
blog.setName(user.getNickName());
blog.setIcon(user.getIcon());
}
}
需求:
实现步骤:
@Override
public Result likeBlog(Long id) {
//1.获取用户id
Long userId = UserHolder.getUser().getId();
//2.获取redis中的set集合中是否有重复的元素:是否已经被点赞过(isLike)
Boolean isMember = stringRedisTemplate.opsForSet().isMember(BLOG_LIKED_KEY + id, userId.toString());
if(BooleanUtil.isTrue(isMember)){
//2.1如果已经被点赞了
//2.2数据库的liked字段-1:update blog set liked = liked-1 where id = #{id}
boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
//2.3将redis中的set集合中的用户id移除
if(isSuccess){
stringRedisTemplate.opsForSet().remove(BLOG_LIKED_KEY + id,userId.toString());
}
}else {
//3.1如果没有点赞 数据库的liked字段+1
boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
//3.3将点赞的用户添加到redis中的set集合中
if(isSuccess){
stringRedisTemplate.opsForSet().add(BLOG_LIKED_KEY + id,userId.toString());
}
}
return Result.ok();
}
写一个setBlogLiked方法用于设置是否点赞属性到实体类Blog里
private void setBlogLiked(Blog blog) {
UserDTO user = UserHolder.getUser();
//用户未登录状态,不获取userId,防止空指针异常
if (user == null){
return;
}
//1.获取用户id
Long userId = blog.getUserId();
//2.获取redis中的set集合中是否有重复的元素:是否已经被点赞过(isLike)
Boolean isMember = stringRedisTemplate.opsForSet().isMember(BLOG_LIKED_KEY +blog.getId(), userId.toString());
blog.setIsLike(BooleanUtil.isTrue(isMember));
}
由于相比较之前博客,对于当前用户而已,多了是否已经点赞的信息,因此除了博主的用户信息要展示在博客上,还要把是否已经点赞信息展示。
@Override
public Result queryHotBlog(Integer current) {
// 根据用户查询
Page<Blog> page = query()
.orderByDesc("liked")
.page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
// 获取当前页数据
List<Blog> records = page.getRecords();
// 查询用户、是否被点赞
records.forEach(blog -> {
this.setBlogUser(blog);
this.setBlogLiked(blog);
});
return Result.ok(records);
}
@Override
public Result queryBlogById(Long id) {
//1.获取笔记
Blog blog = getById(id);
//2.判断笔记是否为空
if (blog == null) {
return Result.fail("笔记为空!");
}
//3.Blog、以及是否被点赞
setBlogUser(blog);
setBlogLiked(blog);
return Result.ok(blog);
}
public void setBlogUser(Blog blog) {
Long userId = blog.getUserId();
User user = userService.getById(userId);
blog.setName(user.getNickName());
blog.setIcon(user.getIcon());
}
由于需要排序,原来的set是不可重复但无序的,因此这里我们需要把点赞业务中进行修改:没有点赞过的,点赞了就把用户id作为value,和当前时间戳作为score一起存入redis中去。后续我们通过score的值来确定排行榜的顺序,score值越前,用户排行越前。
@Override
public Result likeBlog(Long id) {
//1.获取用户id
Long userId = UserHolder.getUser().getId();
//2.获取redis中的set集合中是否有重复的元素:是否已经被点赞过(isLike)
Double score = stringRedisTemplate.opsForZSet().score(BLOG_LIKED_KEY + id, userId.toString());
if(score != null){
//2.1如果已经被点赞了
//2.2数据库的liked字段-1:update blog set liked = liked-1 where id = #{id}
boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
//2.3将redis中的sortedset集合中的键值对移除
if(isSuccess){
stringRedisTemplate.opsForZSet().remove(BLOG_LIKED_KEY + id,userId.toString());
}
}else {
//3.1如果没有点赞 数据库的liked字段+1
boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
//3.3将点赞的用户id以及当前时间戳添加到redis中的sortedset集合中
if(isSuccess){
stringRedisTemplate.opsForZSet().add(BLOG_LIKED_KEY + id,userId.toString(), System.currentTimeMillis());
}
}
return Result.ok();
}
同样的用于判断是否点赞的方法也要相应的修改:
private void setBlogLiked(Blog blog) {
UserDTO user = UserHolder.getUser();
//用户未登录状态,不获取userId,防止空指针异常
if (user == null){
return;
}
//1.获取用户id
Long userId = blog.getUserId();
//2.获取redis中的set集合中是否有重复的元素:是否已经被点赞过(isLike)
Double score = stringRedisTemplate.opsForZSet().score(BLOG_LIKED_KEY + blog.getId(), userId.toString());
blog.setIsLike(BooleanUtil.isTrue(score != null));
}
接下来就是排行榜的业务实现:
@Override
public Result likesBlog(Long id) {
//1.从sortedset查询top5的点赞用户
Set<String> top5 = stringRedisTemplate.opsForZSet().range(BLOG_LIKED_KEY + id, 0, 4);
if (top5 == null||top5.isEmpty()){
return Result.ok(Collections.emptyList());
}
//2.解析出其中的用户id
List<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList());
//3.根据用户id查询用户 select * from table_user where id1 = #{id1} or id2 = #{id2}
String idStr = StrUtil.join(",",ids);
//ORDER BY FIELD做顺序处理
List<User> users = userService.query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();
//将user转成UserDTO
List<UserDTO> userDTOS = BeanUtil.copyToList(users, UserDTO.class);
//4.返回
return Result.ok(userDTOS);
}
页面加载进来时先判断是否被关注的布尔值,通过这个布尔值,来决定关注业务里面是否关注的标识。页面一加载就会走判断是否被关注的接口,而点击关注按钮,才会走关注接口。
@Override
public Result follow(Long followUserId,Boolean isFollow) {
//1.获取当前用户id
Long userId = UserHolder.getUser().getId();
//2.1 判断是否被关注:isFollow为true
if (isFollow) {
//3.如果isFollow = true说明,就关注,保存到数据库
Follow follow = new Follow();
follow.setUserId(userId);
follow.setFollowUserId(followUserId);
save(follow);
}else{
//2.如果isFollow = false,从关注表中移除用户
remove(new QueryWrapper<Follow>().eq("user_id",userId).eq("follow_user_id",followUserId));
}
return Result.ok();
}
@Override
public Result isFollow(Long followUserId) {
//1.获取用户id
Long userId = UserHolder.getUser().getId();
//2.查询数据库有没有关注的用户
Follow follow = query().eq("user_id", userId).eq("follow_user_id", followUserId).one();
return Result.ok(follow!=null);//不为空就返回true 的结果
}
@GetMapping("/{id}")
public Result queryUserById(@PathVariable("id") Long userId){
// 查询详情
User user = userService.getById(userId);
if (user == null) {
return Result.ok();
}
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
// 返回
return Result.ok(userDTO);
}
显示笔记,分页查询:
@GetMapping("/of/user")
public Result queryBlogByUserId(
@RequestParam(value = "current", defaultValue = "1") Integer current,
@RequestParam("id") Long id) {
// 根据用户查询
Page<Blog> page = blogService.query()
.eq("user_id", id).page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
// 获取当前页数据
List<Blog> records = page.getRecords();
return Result.ok(records);
}
思路:通过redis中的set集合的SINTER key1 key2 命令求出两个集合的交集,然后通过求出的这些满足的用户id,去查询数据库,返回一个个完整的用户,并用List集合封装起来,最后转成List
@Override
public Result follow(Long followUserId,Boolean isFollow) {
//1.获取当前用户id
Long userId = UserHolder.getUser().getId();
//2.1 判断是否被关注:isFollow为true
if (isFollow) {
//3.如果isFollow = true说明,就关注,保存到数据库
Follow follow = new Follow();
follow.setUserId(userId);
follow.setFollowUserId(followUserId);
boolean isSuccess = save(follow);
if (isSuccess){
//4.将被关注的用户保存到redis中的set集合中去:为求交集实现共同关注功能
stringRedisTemplate.opsForSet().add(FOLLOW_COMMONS_KEY+userId,followUserId.toString());
}
}else{
//2.2如果isFollow = false,从关注表中移除用户
boolean isSuccess = remove(new QueryWrapper<Follow>().eq("user_id", userId).eq("follow_user_id", followUserId));
if (isSuccess){
//2.3将set中被关注的用户移除
stringRedisTemplate.opsForSet().remove(FOLLOW_COMMONS_KEY+userId,followUserId.toString());
}
}
return Result.ok();
}
@Override
public Result commons(Long id) {//此id是点进主页的id
//1.获取自己的id
Long userId = UserHolder.getUser().getId();
//2.根据用户id和被关注者的id,通过redis中set集合求交集
Set<String> commonSet = stringRedisTemplate.opsForSet().intersect(FOLLOW_COMMONS_KEY + userId, FOLLOW_COMMONS_KEY + id);
if (commonSet == null||commonSet.isEmpty()){
return Result.ok();
}
//3.解析集合
List<Long> ids = commonSet.stream().map(Long::valueOf).collect(Collectors.toList());
//这个ids集合里只存放共同用户的id,我们最终需要展示的是整个用户,所以我们需要通过这个id查询数据库,然后返回
//4,从数据库查询共同关注用户,并且封装成list集合
List<User> userList = userService.listByIds(ids);
//5.最后要返回一个UserDTO的List集合封装共同关注用户
List<UserDTO> userDTOS = BeanUtil.copyToList(userList, UserDTO.class);
return Result.ok(userDTOS);
}
首先博主每发一篇笔记,保存到数据库的同时,我们还要保存到粉丝的收件箱里:
@Override
public Result saveBlog(Blog blog) {
// 1.获取登录用户
UserDTO user = UserHolder.getUser();
blog.setUserId(user.getId());
// 2.保存探店博文
boolean isSuccess = blogService.save(blog);
if (!isSuccess){
return Result.fail("笔记保存失败!");
}
//3.查询博主所有粉丝
List<Follow> follows = followService.query().eq("follow_user_id", user.getId()).list();
//4.推模式:将博客id保存到粉丝收件箱中
for (Follow follow : follows) {
//4.1获取每一个粉丝id
Long userId = follow.getUserId();
//4.2推送:将博客id和当前时间戳作为score保存到粉丝的收件箱:
String key = "feed:"+userId;
stringRedisTemplate.opsForZSet().add(key,blog.getId().toString(),System.currentTimeMillis());
}
//5. 返回id
return Result.ok(blog.getId());
}
推送业务:
@Override
public Result followPush(Long max, Integer offset) {
//1.获取当前用户
Long userId = UserHolder.getUser().getId();
//2.通过userId查询收件箱:zReverangeByScore
String key =FEED_KEY+userId;
//key:收件箱,min:每页查询的最小分数写死成0、max:查询的最大分数,offset:偏移量,第一次为0,往后如果有相同的score值的value,偏移量为相同的score值的value的个数,count:2,每页查询2条,写死。
Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet().reverseRangeByScoreWithScores(key, 0, max, offset, 2);
if (typedTuples==null||typedTuples.isEmpty()){
return Result.ok();
}
//3.解析数据:blogId、minTime、offset
long minTime = 0;
int os = 1;
ArrayList<Long> ids = new ArrayList<>(typedTuples.size());
for (ZSetOperations.TypedTuple<String> typedTuple : typedTuples) {
//3.1获取blogId:每层循环就是一个页:value就是每一个blogId,一页2个
String idStr = typedTuple.getValue();
ids.add(Long.valueOf(idStr));
//3.2获取分数score
long time = typedTuple.getScore().longValue();
if (time == minTime){
os++;
}else {
minTime=time;
os = 1;
}
}
//4.根据blogId查询blog
String idStr = StrUtil.join(",", ids);
List<Blog> blogs = blogService.query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();
for (Blog blog : blogs) {
setBlogUser(blog);
setBlogLiked(blog);
}
ResultScroll r = new ResultScroll();
r.setBlogs(blogs);
r.setOffset(os);//偏移量
r.setMinTime(minTime);//最后一个元素的时间戳
//5.封装成ResultScroll返回
return Result.ok(r);
}
但是这里会有bug,也就是取关博主后,还能在关注列表受到已经取关的博主的推送,所以我们在取关业务里,除了移除follows的键值对,还要移除feed的键值对。也就是移除粉丝的收件箱。对应的,当我们关注一个博主的时候,添加粉丝的收件箱。
@Override
public Result follow(Long followUserId, Boolean isFollow) {
//1.获取当前用户id
Long userId = UserHolder.getUser().getId();
//2.1 判断是否被关注:isFollow为true
if (isFollow) {
//3.如果isFollow = true说明,就关注,保存到数据库
Follow follow = new Follow();
follow.setUserId(userId);
follow.setFollowUserId(followUserId);
boolean isSuccess = save(follow);
if (isSuccess) {
//4.将被关注的用户保存到redis中的set集合中去:为求交集实现共同关注功能
stringRedisTemplate.opsForSet().add(FOLLOW_COMMONS_KEY + userId, followUserId.toString());
//4.1关注了后,将博主笔记添加到粉丝的收件箱
List<Blog> blogs = blogService.query().eq("user_id", followUserId).list();
for (Blog blog : blogs) {
stringRedisTemplate.opsForZSet().add(FEED_KEY + userId,blog.getId().toString(),System.currentTimeMillis());
}
}
} else {
//2.2如果isFollow = false,从关注表中移除用户
boolean isSuccess = remove(new QueryWrapper<Follow>().eq("user_id", userId).eq("follow_user_id", followUserId));
if (isSuccess) {
//2.3将set中被关注的用户移除
stringRedisTemplate.opsForSet().remove(FOLLOW_COMMONS_KEY + userId, followUserId.toString());
//2.4移除粉丝的收件箱
//2.4.1获取被关注用户的blogs
List<Blog> blogs = blogService.query().eq("user_id", followUserId).list();
for (Blog blog : blogs) {
//2.4.2将每一个当前的blogId移除
stringRedisTemplate.opsForZSet().remove(FEED_KEY + userId,blog.getId().toString());
}
}
}
return Result.ok();
}
GEO就是Geolocation的简写形式,代表地理坐标。Redis在3.2版本中加入了对GEO的支持,允许存储地理坐标信息,帮助我们根据经纬度来检索数据。常见的命令有:
@Test
void loadShopData(){
//1.查询店铺信息 select * from shop
List<Shop> list = shopService.list();
//2.店铺信息按typeId分组,typeId一致的放到一个集合里
Map<Long,List<Shop>> map = list.stream().collect(Collectors.groupingBy(Shop::getTypeId));
//3.分批写入Redis
for (Map.Entry<Long, List<Shop>> entry : map.entrySet()) {
//3.1每一个entry就是一个根据shopId分好的组
Long typeId = entry.getKey();
//3.2获取同类型的店铺集合
List<Shop> shopList = entry.getValue();
String key ="shop:geo:"+typeId;
List<RedisGeoCommands.GeoLocation<String>> locations =new ArrayList<>(shopList.size());
//3.3写入redis GEOADD key x y member , shopId作为member
for (Shop shop : shopList) {
//这样写,重复写入redis操作开销太大,最好使用批量写入:GeoLocation的可迭代对象
//stringRedisTemplate.opsForGeo().add(key,new Point(shop.getX(), shop.getY()),shop.getId().toString())
locations.add(new RedisGeoCommands.GeoLocation<>(shop.getId().toString(),new Point(shop.getX(), shop.getY())));
}
stringRedisTemplate.opsForGeo().add(key,locations);
}
}
这两个依赖必须使用稳定版的,否则会报错
<dependency>
<groupId>org.springframework.datagroupId>
<artifactId>spring-data-redisartifactId>
<version>2.6.2version>
dependency>
<dependency>
<groupId>io.lettucegroupId>
<artifactId>lettuce-coreartifactId>
<version>6.1.6.RELEASEversion>
dependency>
public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) {
// 1.判断是否需要根据坐标查询
if (x == null || y == null) {
// 不需要坐标查询,按数据库查询
Page<Shop> page = query()
.eq("type_id", typeId)
.page(new Page<>(current, SystemConstants.DEFAULT_PAGE_SIZE));
// 返回数据
return Result.ok(page.getRecords());
}
// 2.计算分页参数
int from = (current - 1) * SystemConstants.DEFAULT_PAGE_SIZE;
int end = current * SystemConstants.DEFAULT_PAGE_SIZE;
// 3.查询redis、按照距离排序、分页。结果:shopId、distance
String key = SHOP_GEO_KEY + typeId;
GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo() // GEOSEARCH key BYLONLAT x y BYRADIUS 10 WITHDISTANCE
.search(
key,
GeoReference.fromCoordinate(x, y),
new Distance(5000),
RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().includeDistance().limit(end)
);
// 4.解析出id
if (results == null) {
return Result.ok(Collections.emptyList());
}
List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();
if (list.size() <= from) {
// 没有下一页了,结束
return Result.ok(Collections.emptyList());
}
// 4.1.截取 from ~ end的部分
List<Long> ids = new ArrayList<>(list.size());
Map<String, Distance> distanceMap = new HashMap<>(list.size());
list.stream().skip(from).forEach(result -> {
// 4.2.获取店铺id
String shopIdStr = result.getContent().getName();
ids.add(Long.valueOf(shopIdStr));
// 4.3.获取距离
Distance distance = result.getDistance();
distanceMap.put(shopIdStr, distance);
});
// 5.根据id查询Shop
String idStr = StrUtil.join(",", ids);
List<Shop> shops = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();
for (Shop shop : shops) {
shop.setDistance(distanceMap.get(shop.getId().toString()).getValue());
}
// 6.返回
return Result.ok(shops);
}
Redis中是利用string类型数据结构实现BitMap,因此最大上限是512M,转换为bit则是2^32个bit位。BitMap的操作命令有:
@Override
public Result sign() {
//1.获取用户信息
Long userId = UserHolder.getUser().getId();
//2.获取当天日期
LocalDateTime now = LocalDateTime.now();
//3.拼接key
String prefix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = USER_SIGN_KEY + userId + prefix;
//4.判断当天是本月的第几天
int dayOfMonth = now.getDayOfMonth();
//5.存入redis中 setBit key offset 1
stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);
return Result.ok();
}
@Override
public Result signCount() {
//1.获取用户信息
Long userId = UserHolder.getUser().getId();
//2.获取当天日期
LocalDateTime now = LocalDateTime.now();
//3.拼接key
String prefix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = USER_SIGN_KEY + userId + prefix;
//4.判断当天是本月的第几天
int dayOfMonth = now.getDayOfMonth();
//5.
List<Long> result = stringRedisTemplate.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 == 0) {
return Result.ok(0);
}
int count = 0;
while (true) {
//判断 最后一位与1进行与运算的值 就是当前这个位,也就是第几天 是否为0
if ((num & 1) == 0) {
//如果为0,说明未签到
break;
} else {
//如果为1,说明签到,计数器加1
count++;
}
//将位,向右移一位
num=num>>1;
}
return Result.ok(count);
}