UserDTO userDTO = BeanUtil.fillBeanWithMap(map, new UserDTO(), false);
Shop shop = JSONUtil.toBean(cacheShop, Shop.class);
String shopCache = JSONUtil.toJsonStr(shop);
String data = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
long time = LocalDateTime.of(2022, 10, 12, 16, 43).toEpochSecond(ZoneOffset.UTC);
long nowSecond = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC);
知识点:
如上图所示,需要实现三个功能:
(1)验证码生成
(2)短信验证码登录
(3)校验登陆状态(判断是否登录)
验证码生成
通过/user/code接口实现验证码生成。
思路:
a、验证手机号格式
b、生成验证码
c、保存验证码到session
d、发送验证码,涉及到短信服务,这里就不实现
controller接口
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
// TODO 发送短信验证码并保存验证码
return userService.sendCode(phone, session);
}
serviceImpl
public Result sendCode(String phone, HttpSession session) {
// 1、验证手机号格式
if(RegexUtils.isPhoneInvalid(phone)){
return Result.fail("手机号格式错误");
}
// 2、生成验证码
String code = RandomUtil.randomString(6);
// 3、保存验证码到session
session.setAttribute("code",code);
// 4、发送验证码,涉及到短信服务,这里就不实现
log.info("发送验证码成功,验证码:{}",code);
return Result.ok();
}
短信验证码登录
user/login接口
前面已经生成了验证码,我们在后台复制验证码,然后前端输入手机号和验证码,就可以登录。
流程:
a、校验手机号
b、校验验证码
c、验证码不一致,报错
d、验证码一致,根据手机号查询用户信息
e、用户不存在,创建用户并保存进数据库
f、保存用户信息到session,只存储非私密信息
serviceImpl
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1、校验手机号
String phone = loginForm.getPhone();
if(RegexUtils.isPhoneInvalid(phone))
return Result.fail("手机号格式错误");
// 2、校验验证码
Object cacheCode = session.getAttribute("code");
String code = loginForm.getCode();
// 3、验证码不一致,报错
if(cacheCode == null || !cacheCode.toString().equals(code))
return Result.fail("验证码错误");
// 4、验证码一致,根据手机号查询用户信息
QueryWrapper<User> userQueryWrapper = new QueryWrapper<>();
userQueryWrapper.eq("phone",phone);
User user = this.baseMapper.selectOne(userQueryWrapper);
// 5、用户不存在,创建用户并保存
if(user == null){
user = createUserWithPhone(phone);
}
// 6、用户存在,保存用户信息到session,只存储非私密信息
session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));
return Result.ok();
}
校验登陆状态
有许多页面只有用户登录才可以访问,所以我们需要使用拦截器检查用户是否登录。
1)创建拦截器,将用户信息存入ThreadLocal中
a、获取session
b、获取session中的用户
c、判断用户是否存在
d、不存在直接拦截
e、存在,保存用户信息到ThreadLocal
f、放行
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1、获取session
HttpSession session = request.getSession();
// 2、获取session中的用户
Object user = session.getAttribute("user");
// 3、判断用户是否存在
// 4、不存在直接拦截
if(user == null)
return false;
// 5、存在,保存用户信息到ThreadLocal
UserHolder.saveUser((UserDTO) user);
// 6、放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 清除用户信息
UserHolder.removeUser();
}
}
上面使用了自己手写的工具类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();
}
}
2)配置拦截器
创建配置类,并实现WebMvcConfigurer
有一些页面不登录也可以访问,所以要设置不需要拦截的路径
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
// 不需要拦截的路径
.excludePathPatterns(
"/user/login",
"/user/code",
"/blog/hot",
"/shop/**",
"/shop-type/**",
"/voucher/**"
);
}
}
3)前面已经将用户信息存入了ThreadLocal
所以对于获取用户信息接口,可以直接返回ThreadLocal中的用户信息
/user/me接口
@GetMapping("/me")
public Result me(){
// TODO 获取当前登录的用户并返回
// 从userHolder中获取用户信息
UserDTO user = UserHolder.getUser();
return Result.ok(user);
}
ThreadLocal相关知识
cookie概念
:当用户首次使用浏览器访问一个支持Cookie的网站的时候,用户会提供包括用户名在内的个人信息并且提交至服务器;接着,服务器在向客户端回传相应的超文本的同时也会发回这些个人信息,当然这些信息并不是存放在HTTP响应体(Response Body)中的,而是存放于HTTP响应头(Response Header);当客户端浏览器接收到来自服务器的响应之后,浏览器会将这些信息存放在一个统一的位置。
session概念
:Session一般叫做回话,Session技术是http状态保持在服务端
的解决方案,它是通过服务器来保持状态的。我们可以把客户端浏览器与服务器之间一系列交互的动作称为一个 Session。是服务器端为客户端所开辟的存储空间,在其中保存的信息就是用于保持状态。因此,session是解决http协议无状态问题的服务端解决方案,它能让客户端和服务端一系列交互动作变成一个完整的事务。
问题描述:项目部署时,提供同一服务的项目会被部署到多台tomact中,用于负载均衡。当用户第一次登陆时,被映射到第一台tomcat,则session信息存储在了第一台tomcat,第二次登陆时,被映射到了第二胎tomcat,此时就会发现该tomcat没有用户的session信息,就会出现登陆失败情况。
知识点:
对于验证码的操作:
将手机号作为key,验证码作为value存入redis中
对于登陆时用户信息的操作:
使用token作为key,用户信息作为value。
对于用户信息,可以使用string存储,也可以使用hash存储 但是hash相较于string有以下优点,所以采用hash存储用户信息
。
登陆成功后,会将后端返回的token存放在sessionStorage中,每次请求时将token加入到请求头中的authorization字段。后端验证时只需要获取请求头中的authorization字段。
因为token要保存到前端浏览器,所以不可以使用手机号作为用户信息的key,这样会泄露隐私。
只要是存入到redis中的信息,都需要设置存活时间,因为redis在内存中,一直存在会浪费内存。
将session替换为redis,只需要将第一章节的三个功能中的session替换为redis。
此时默认项目已经引入了redis相关依赖并配置好了redis参数
(1)验证码生成
(2)短信验证码登录
(3)校验登陆状态(判断是否登录)
(4)redis中的key一般会设置一个有效期
验证码生成
将生成的验证码存储在redis中。
a:注入StrignRedisTemplate(基础篇知识)
b:存入redis,并设置存活时间
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result sendCode(String phone, HttpSession session) {
// 1、验证手机号格式
if(RegexUtils.isPhoneInvalid(phone)){
return Result.fail("手机号格式错误");
}
// 2、生成验证码
String code = RandomUtil.randomString(6);
// 3、保存验证码到redis
stringRedisTemplate.opsForValue().set(RedisConstants.LOGIN_CODE_KEY + phone, code, RedisConstants.LOGIN_CODE_TTL, TimeUnit.MINUTES);
// 4、发送验证码,涉及到短信服务,这里就不实现
log.info("发送验证码成功,验证码:{}",code);
return Result.ok();
}
一般对于一些关键字,会定义特定的常量类,例如这里的验证码的key和存活时间都定义在常量类1RedisConstants1中。
public class RedisConstants {
public static final String LOGIN_CODE_KEY = "login:code:";
public static final Long LOGIN_CODE_TTL = 2L;
public static final String LOGIN_USER_KEY = "login:token:";
public static final Long LOGIN_USER_TTL = 36000L;
public static final Long CACHE_NULL_TTL = 2L;
public static final Long CACHE_SHOP_TTL = 30L;
public static final String CACHE_SHOP_KEY = "cache:shop:";
public static final String LOCK_SHOP_KEY = "lock:shop:";
public static final Long LOCK_SHOP_TTL = 10L;
public static final String SECKILL_STOCK_KEY = "seckill:stock:";
public static final String BLOG_LIKED_KEY = "blog:liked:";
public static final String FEED_KEY = "feed:";
public static final String SHOP_GEO_KEY = "shop:geo:";
public static final String USER_SIGN_KEY = "sign:";
}
测试:
短信验证码登录
a、根据手机号取出redis中的验证码,判断验证码是否正确
b、使用hash保存用户信息,需要将user对象转为hash存储
c、使用token作为用户信息的key(这里为了简单使用uuid,不使用jwt)
d、保存到redis中,并设置存活时间
e、将token返回给前端
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1、校验手机号
String phone = loginForm.getPhone();
if(RegexUtils.isPhoneInvalid(phone))
return Result.fail("手机号格式错误");
// 2、redis取出并校验验证码
String cacheCode = stringRedisTemplate.opsForValue().get(RedisConstants.LOGIN_CODE_KEY);
String code = loginForm.getCode();
// 3、验证码不一致,报错
if(cacheCode == null || !cacheCode.equals(code))
return Result.fail("验证码错误");
// 4、验证码一致,根据手机号查询用户信息
QueryWrapper<User> userQueryWrapper = new QueryWrapper<>();
userQueryWrapper.eq("phone",phone);
User user = this.baseMapper.selectOne(userQueryWrapper);
// 5、用户不存在,创建用户并保存
if(user == null){
user = createUserWithPhone(phone);
}
// 6、用户存在,保存用户信息到redis,只存储非私密信息
// 6.1 使用uuid作为token
String token = UUID.randomUUID().toString(true);
// 6.2 user转为userdto
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
// 6.3 使用hutool将userdto转为map
Map<String, Object> map = BeanUtil.beanToMap(userDTO);
// 6.4 存入redis
String tokenKey = RedisConstants.LOGIN_USER_KEY+token;
stringRedisTemplate.opsForHash().putAll(tokenKey, map);
// 6.5 设置存活时间
stringRedisTemplate.expire(tokenKey, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
return Result.ok(token);
}
问题1:BeanUtil.beanToMap将userDTO转为map时,由于id字段是long型,所以map中的id也是long。stringRedisTemplate要求所有的key value都是string类型,并且不会自动序列化,如果输入不是string,就会报错。所以需要我们手动序列化。
所以,我们转map时,要将所有值转为string
解决方法:
1、手动将long转string,并手动创建map
2、还是使用beanToMap自动转换,beanToMap是允许对map中的key value自定义类型。
将上面Map
替换为如下代码
Map<String, Object> map = BeanUtil.beanToMap(userDTO,
new HashMap<>(),
CopyOptions.create().setIgnoreNullValue(true)
.setFieldValueEditor(
(filedName, fielValue)-> fielValue.toString()));
问题2:redis中设置存活时间是在创立后的存活时间,对于token,如果创建后一直访问,这个存活时间应该一直更新为初始设置值。可以在拦截器中实现,每次访问,取出token并重置存活时间。
校验登陆状态
拦截器代码
a、将获取的用户信息hash值转为userDto,存储到ThreadLocal
b、刷新token的有效期
由于我们的拦截器是手动创建
的,即new之后加入到的MVC配置类中,并没有使用component等注解,所以拦截器中无法注入容器中的备案,即无法注入stringRedisTemplate。只有spring创建的才可以注入,加了componment,mapper等注解的类是spring创建的。
所以我们只能构造函数注入stringRedisTemplate。
思路:拦截器是在配置类中new的,配置类是spring创建的,可以注入stringRedisTemplate,所以在配置类中注入stringRedisTemplate,然后将其作为拦截器的参数参入。即构造函数注入bean。
配置类代码
拦截器获取bean
构造函数注入bean的思路很重要。
登录拦截器完整代码
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");
// 1.1 token为空拦截
if(StringUtils.isBlank(token))
return false;
// 2、获取redis中的用户,entries获取key的整个hash值
String tokenKey = RedisConstants.LOGIN_USER_KEY + token;
Map<Object, Object> map = stringRedisTemplate.opsForHash().entries(tokenKey);
// 2.1、缓存用户信息不存在则拦截
if(map.isEmpty())
return false;
// 2.2 存在 hash转为userdto
UserDTO userDTO = BeanUtil.fillBeanWithMap(map, new UserDTO(), false);
// 3 userDTO存储到ThreadLocal
UserHolder.saveUser(userDTO);
// 4 刷新token
stringRedisTemplate.expire(tokenKey, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
// 5、放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
}
登陆成功,token已经存入redis
前面在登录拦截器部分对token存活时间进行了刷新,但是登录拦截器并没有设置拦截全部请求,所以有些请求就不会刷新token。
优化:在登录拦截前面加一个拦截一切请求的拦截器,该拦截器会对所有请求的token刷新。
思路:
1)新建一个token刷新拦截器,过滤所有请求,token存在并刷新token,然后将用户信息存入threadlocal。只做刷新和存储用户信息,不做拦截。所以即使没有登陆也应该放行
2)原始的登录拦截器只需要判断threadlocal中有无用户信息,有则放行,没有则拦截。
3)将两个拦截器加入到配置文件,并通过order属性设置拦截器的执行顺序,token刷新拦截器应在前,所以order最小。
代码:
1)token刷新拦截器
注意:只做刷新和存储用户信息,不做拦截。所以即使没有登陆也应该放行,因为登录拦截在登录拦截器中实现。所以token和用户信息为空时,也要放行。
public class TokenInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
public TokenInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1、获取token
String token = request.getHeader("authorization");
// 1.1 token为空放行,因为有的页面可以不登陆也可以访问,本拦截器只做token刷新
if(StringUtils.isBlank(token))
return true;
// 2、获取redis中的用户,entries获取key的整个hash值
String tokenKey = RedisConstants.LOGIN_USER_KEY + token;
Map<Object, Object> map = stringRedisTemplate.opsForHash().entries(tokenKey);
// 2.1、不存在也放行,因为有的页面可以不登陆也可以访问,本拦截器只做token刷新
if(map.isEmpty())
return true;
// 2.2 存在 hash转为userdto
UserDTO userDTO = BeanUtil.fillBeanWithMap(map, new UserDTO(), false);
// 3 userDTO存储到ThreadLocal
UserHolder.saveUser(userDTO);
// 4 刷新token
stringRedisTemplate.expire(tokenKey, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
// 5、放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
}
2)登录拦截器改造
只根据threadlocal中有无用户信息做拦截,所以不需要redis操作。并且不需要移除threadlocal中的用户信息,该操作在前面拦截器实现。
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// token拦截器已经将用户信息存储了threadlocal中
// 所以本拦截器,只需要判断thradloacl中是否有用户信息,有放行,没有直接拦截
UserDTO user = UserHolder.getUser();
// 没有用户信息,证明没登录,直接拦截
if(user == null){
response.setStatus(401);
return false;
}
// 由用户则放行
return true;
}
}
3)配置拦截器
通过order属性保证token刷新拦截器在前
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// order属性值越小,拦截器先触发
// 对所有请求进行拦截,刷新token,并存储用户信息到threadlocal
registry.addInterceptor(new TokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
// 对部分需要登陆的请求页面做拦截,如果TokenInterceptor中存储了用户信息,则证明登陆了放行,没有则拦截
registry.addInterceptor(new LoginInterceptor())
// 不需要拦截的路径
.excludePathPatterns(
"/user/login",
"/user/code",
"/blog/hot",
"/shop/**",
"/shop-type/**",
"/voucher/**"
).order(1);
}
}
知识点:
该章节通过redis缓存实现请求的高速响应。
不添加缓存时,客户端的每次请求都是直接到数据库,然后数据库返回数据给客户端。
添加缓存后:
商户信息查询思路:
1)从redis中查询商铺信息缓存,key为前缀+id;
2)redis中存在则返回;
3)不存在则查询数据库;
4)数据库中不存在,返回错误;
5)存在,返回数据,并写入reids缓存;
该接口对应shopController中的queryShopById
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
return shopService.queryById(id);
}
业务类实现:
public Result queryById(Long id) {
// key类型cache:shop:id
String shopKey = RedisConstants.CACHE_SHOP_KEY + id;
// 因为存储用户信息时,使用了hash,为了学习所有数据类型。所以这里使用string存储
// 1、查询redis
String cacheShop = stringRedisTemplate.opsForValue().get(shopKey);
// 2、存在则返回
if(StringUtils.isNotBlank(cacheShop)){
// 将json字符串反序列化为类对象
Shop shop = JSONUtil.toBean(cacheShop, Shop.class);
return Result.ok(shop);
}
// 3、不存在则查数据库
Shop shop = baseMapper.selectById(id);
// 4、数据库中不存在商户信息,返回错误
if(shop == null)
return Result.fail("不存在该店铺");
// 5、存在则返回数据并写入redis
String shopCache = JSONUtil.toJsonStr(shop);
stringRedisTemplate.opsForValue().set(shopKey,shopCache);
return Result.ok(shop);
}
前面已经实现了商铺信息的缓存,接下来实现商铺类型的缓存。
红色区域为商铺类型,所以返回值是一个列表,所以在redis中使用list存储
过程:
1)查询redis中的商铺列表。 shopTypeList = stringRedisTemplate.opsForList().range(shopTypeKey, 0, -1);
2)shopTypeList
不为空,则遍历列表,使用JSONUtil.toBean
将其string转为shoptype类对象。然后返回给前端。
3)shopTypeList
为空,则查数据库,查不到返回错误信息。
4)数据库查到列表shopTypes
,遍历列表元素,使用JSONUtil.toJsonStr
将类对象转为json字符串,并加入到json列表。将json字符串列表存入redis,设置存活时间。
5)将查询结果返回给前端。
controller接口
@GetMapping("list")
public Result queryTypeList() {
return typeService.queryTypeList();
}
服务类
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryTypeList() {
String shopTypeKey = "shop:type:list";
// 1、查询redis
List<String> shopTypeList = new ArrayList<String>();
shopTypeList = stringRedisTemplate.opsForList().range(shopTypeKey, 0, -1);
// 2、存在则返回
if(!shopTypeList.isEmpty()){
ArrayList<ShopType> typeList = new ArrayList<>();
for(String type: shopTypeList){
ShopType shopType = JSONUtil.toBean(type, ShopType.class);
typeList.add(shopType);
}
return Result.ok(typeList);
}
// 3、不存在则查数据库
QueryWrapper<ShopType> wrapper = new QueryWrapper<>();
wrapper.orderByAsc("sort");
List<ShopType> shopTypes = baseMapper.selectList(wrapper);
// 4、数据库中不存在商户信息,返回错误
if(shopTypes.isEmpty())
return Result.fail("商品类型列表不存在");
// 5、存在则返回数据并写入redis
for(ShopType type : shopTypes){
String s = JSONUtil.toJsonStr(type);
shopTypeList.add(s);
}
stringRedisTemplate.opsForList().rightPushAll(shopTypeKey,shopTypeList);
stringRedisTemplate.expire(shopTypeKey,RedisConstants.CACHE_SHOP_TTL,TimeUnit.MINUTES);
return Result.ok(shopTypes);
}
总结:上面无论是取还是存redis中的list类型时,都是遍历列表中的元素,对每个元素做序列化或反序列化。但是hutoll JSONUtil
提供了其它的函数用于批量处理list的序列化和反序列化,自己在编写代码时进行了尝试,但是出错了,索性就采用了遍历每个元素的方法。
前面使用了缓存,会存在一个问题:当缓存中的数据还存活时,数据库相应数据发生了变化,下次查询时会走缓存,并且得到的是旧数据。所以就引入了缓存更新。
主动更新策略
第三种为计组中的写回法
。
上面两种方式都有可能出现错误情况,但是先操作数据库再删除缓存方案犯错的概率相对小一些,因为缓存的操作时间大多数情况比数据库操作时间短
,所以基本不会出现上图的错误情况。所以一般先操作数据库再删除缓存。
下图中第一条在2.1中已经实现了,即设置存活时间,第二条是根据我们上一小节得到结论,主动更新采用先操作数据库再删除缓存。
所以为了实现缓存和数据库一致性,只需要在商铺数据库信息改变时,删除商铺信息缓存,然后再重新写入缓存即可。逻辑比较简单。
所以,我们只需要修改商铺更新接口代码。
为了保证操作数据库和redis的一致性,需要加入事务
a、开启事务
b、判断有无商铺id,没有返回错误
c、有则更新
d、更新成功则查缓存
e、缓存查到则删除
f、重写缓存
@PutMapping
public Result updateShop(@RequestBody Shop shop) {
// 写入数据库
return shopService.updateShopById(shop);
}
业务类代码:
@Override
@Transactional
public Result updateShopById(Shop shop) {
// 0、判断有无商铺id
Long id = shop.getId();
if(id == null)
return Result.fail("用户id不能为空");
// 1、更新数据库
int i = baseMapper.updateById(shop);
// 1.1、更新失败,直接返回错误
if(i == 0)
return Result.fail("商铺信息更新失败");
// 1.2、更新成功,操作redis缓存
String shopKey = RedisConstants.CACHE_SHOP_KEY+id;
// 2、查询缓存
String cacheShop = stringRedisTemplate.opsForValue().get(shopKey);
// 3、存在则删除
if(StringUtils.isNotBlank(cacheShop))
stringRedisTemplate.delete(shopKey);
// 4、重写缓存,并设置存活时间
cacheShop = JSONUtil.toJsonStr(shop);
stringRedisTemplate.opsForValue().set(shopKey,cacheShop,RedisConstants.CACHE_SHOP_TTL,TimeUnit.MINUTES);
return Result.ok();
}
解决方案:
1)缓存空对象
数据库未命中时,返回给一个null,下次redis可以命中,只是数值为null
这里使用缓存空对象解决商铺信息获取可能带来的缓存穿透。
左侧为原始请求逻辑,右侧为加入了缓存空对象请求逻辑。
代码改变点:
1)数据库查询不到时,向redis中写入空字符串,并且存活时间较短
2)请求查询redis时,要判断获取的信息是否为空字符串,如果是空,直接返回错误信息,店铺不存在。
3)经过上面两步,如果前端n个请求查询数据库中不存在的值,只会查一次数据库,其余请求全部返回错误信息,防止缓存穿透。
所以根据id查询商铺信息服务改为:
当Str的length>0时,isNotBlank返回true,所以对于空缓存对象返回false,空缓存对象只是length=0,但是!=null
@Override
public Result queryById(Long id) {
// key类型cache:shop:id
String shopKey = RedisConstants.CACHE_SHOP_KEY + id;
// 因为存储用户信息时,使用了hash,为了学习所有数据类型。所以这里使用string存储
// 1、查询redis
String cacheShop = stringRedisTemplate.opsForValue().get(shopKey);
// 2、存在则返回
if(StringUtils.isNotBlank(cacheShop)){
// 将json字符串反序列化为类对象
Shop shop = JSONUtil.toBean(cacheShop, Shop.class);
return Result.ok(shop);
}
// 查到的是防止缓存穿透的空缓存对象
if(cacheShop != null){
return Result.fail("店铺不存在");
}
// 3、不存在则查数据库
Shop shop = baseMapper.selectById(id);
// 4、数据库中不存在商户信息,返回错误
if(shop == null){
// 缓存空对象防止缓存穿透问题
stringRedisTemplate.opsForValue().set(shopKey,"",RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
return Result.fail("不存在该店铺");
}
// 5、存在则返回数据并写入redis
String shopCache = JSONUtil.toJsonStr(shop);
stringRedisTemplate.opsForValue().set(shopKey,shopCache,RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}
解决方案1:不同key设置不同存活时间,防止所有key同一时间到期。
解决方案2:高级篇章节
方案3 、4:黑马springcloud课程
两种解决方案:互斥锁,逻辑过期
互斥锁:缓存未命中时,先获取互斥锁,然后再查数据库。
逻辑过期:不再设置存活时间TTL,而是在存储的数据字段加一个逻辑过期时间,这样处理的key逻辑上就永远不会失效。当逻辑过期时间为0时,代表该数据逻辑过期,但是不会删除,逻辑过期后的数据成为过期数据。
使用setnx实现互斥锁
setnx key value:当key不存在时才会创建。
stringRedisTemplate中通过setIfAbsent实现setnx命令。
获取锁和释放锁代码
// 互斥锁解决缓存击穿--》获取锁
private boolean tryLock(String key){
//
Boolean aBoolean = stringRedisTemplate.opsForValue()
.setIfAbsent(key, "1", RedisConstants.LOCK_SHOP_TTL, TimeUnit.SECONDS);
// 不能直接返回aBoolean,因为他是Boolean类型,会做拆箱返回boolean,这个过程可能会出错
// 使用工具类hutool
return BooleanUtil.isTrue(aBoolean);
}
// 互斥锁解决缓存击穿--》释放锁
private void unLock(String key){
stringRedisTemplate.delete(key);
}
为了提高代码的可读性,将基于互斥锁的缓存击穿预防,包含基于缓存空对象的缓存穿透的预防获取商户信息代码封装为一个函数queryWithMutex
.
参数为商户id,返回值商铺实体类。
流程:
public Shop queryWithMutex(Long id) {
// 商铺信息key.key类型cache:shop:id
String shopKey = RedisConstants.CACHE_SHOP_KEY + id;
// 互斥锁key
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
// 因为存储用户信息时,使用了hash,为了学习所有数据类型。所以这里使用string存储
// 1、查询redis缓存
String cacheShop = stringRedisTemplate.opsForValue().get(shopKey);
Shop shop = null;
// 2、存在则返回
// 2.1、真实存在
if(StringUtils.isNotBlank(cacheShop)){
// 将json字符串反序列化为类对象
shop = JSONUtil.toBean(cacheShop, Shop.class);
return shop;
}
// 2.2、查到的是防止缓存穿透的空缓存对象,返回空
if(cacheShop != null){
return null;
}
// 缓存中不存在该=》获取锁,再次判断缓存,不存在查数据库,写入缓存
try {
// 3、不存在,先获取互斥锁
boolean b = tryLock(lockKey);
// 3.1、没有获取到锁,休眠并重试
if(!b){
// 休眠
Thread.sleep(50);
// 重试,递归实现
return queryWithMutex(id);
}
// 3.2.获取到锁,再次判断redis缓存是否存在,因为商铺信息更新或者创建后会将数据写入缓存
String reCacheShop = stringRedisTemplate.opsForValue().get(shopKey);
// 3.3、第二次查缓存,存在则返回
// 查到的是真实值
if(StringUtils.isNotBlank(reCacheShop)){
// 将json字符串反序列化为类对象
shop = JSONUtil.toBean(reCacheShop, Shop.class);
return shop;
}
// 查到的是防止缓存穿透的空缓存对象,返回空
if(cacheShop != null){
return null;
}
// 3.4、第二次查缓存不存在,则查数据库
shop = baseMapper.selectById(id);
// 4、数据库中不存在商户信息,采用基于缓存空对象的缓存穿透的预防,即存入空字符串
if(shop == null){
// 缓存空对象防止缓存穿透问题
stringRedisTemplate.opsForValue().set(shopKey,"",RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 5、数据库中存在则返回数据并写入redis
String shopCache = JSONUtil.toJsonStr(shop);
stringRedisTemplate.opsForValue().set(shopKey,shopCache,RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
e.printStackTrace();
throw new RuntimeException();
} finally {
// 6、释放锁
unLock(lockKey);
}
// 7、返回数据
return shop;
}
获取商铺信息
@Override
public Result queryById(Long id) {
// 基于互斥锁的缓存击穿预防,包含基于缓存空对象的缓存穿透的预防
Shop shop = queryWithMutex(id);
if(shop == null)
return Result.fail("商铺信息不存在");
return Result.ok(shop);
}
流程图:代码的核心逻辑
1)还是以根据id获取商铺信息为例。
因为多了一个字段逻辑过期时间,所以我们新建一个类RedisData
data:这里只商铺信息类。
expireTime:逻辑过期时间。
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
2)为了实现逻辑过期,我们要先提前向redis中存入含有逻辑过期时间的对象,并且不设置存活时间。
在IShopService中定义函数saveShop2Redis,然后在shopserviceImpl中重写。
向缓存中添加带有逻辑过期时间的商铺信息函数。
publicvoid saveShop2Redis(Long id, Long expireSeconds){
// 1.查询数据
Shop shop = baseMapper.selectById(id);
// 2、封装逻辑过期时间对象
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
// 3、写入redis
String key = RedisConstants.CACHE_SHOP_KEY + id;
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
利用的单元测试,实现提前向redis中存入含有逻辑过期时间的对象。
@Resource
private IShopService shopService;
@Test
public void saveShopRedis(){
// 实现提前向redis中存入含有逻辑过期时间的对象
shopService.saveShop2Redis(1L,10L);
}
基于逻辑存活时间的缓存击穿的预防
流程:
saveShop2Redis
用于查数据库并写入缓存。// 线程池
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
// 基于逻辑过期时间的预防缓存击穿
public Shop queryWithLogicalExpire(Long id) {
// 因为已经提前存入了不设置存活时间的商铺信息,所以不需要预防缓存穿透
// key类型cache:shop:id
String shopKey = RedisConstants.CACHE_SHOP_KEY + id;
// 因为存储用户信息时,使用了hash,为了学习所有数据类型。所以这里使用string存储
// 1、查询redis
String cacheShop = stringRedisTemplate.opsForValue().get(shopKey);
// 2、未命中返回null
if(StringUtils.isBlank(cacheShop))
return null;
// 3、命中判断是否过期
// 3.1、json反序列化
RedisData redisData = JSONUtil.toBean(cacheShop, RedisData.class);
// 3.2、因为RedisData中的data使用的时Object,所以反序列化得到的时JSONObject
JSONObject data = (JSONObject)redisData.getData();
// 3.3、JSONObject转为java实体类
Shop shop = JSONUtil.toBean(data, Shop.class);
// 3.4、拿到逻辑过期时间
LocalDateTime expireTime = redisData.getExpireTime();
// 4、是否过期
if(expireTime.isAfter(LocalDateTime.now())){
// 4.1、未过期,直接返回
return shop;
}
// 4.2、过期则需要缓存重建
// 5、缓存重建
// 5.1、获取互斥锁
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
// 5.2、互斥锁获取成功
if(tryLock(lockKey)){
// 5.3、开启一个新线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(()->{
// 5.3.1调用前面写好的向redis中写入包含逻辑过期时间的商铺信息
this.saveShop2Redis(id,30L);
});
// 5.4、释放锁
unLock(lockKey);
}
// 无论获取锁是否成功过,返回过期的信息
// 6、返回过期的信息
return shop;
}
以前一小节的代码为例
1)创建线程池
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
2)从线程池中获取一个线程并执行某个操作
// 5.3、开启一个新线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(()->{
// 5.3.1调用前面写好的向redis中写入包含逻辑过期时间的商铺信息
this.saveShop2Redis(id,30L);
});
前面对于redis的操作包括:
1)存储在string类型的key,例如商铺信息
2)设置逻辑存活时间
3)互斥锁解决缓存击穿
4)基于逻辑存活时间解决缓存击穿
前面实现的这些操作都是针对于商铺信息的,如果我们想要对其他信息例如商品信息做缓存,则还需要再次重写上面的函数,所以将上面四个功能封装,使用时,直接调用。封装为CacheClient,并交给spring管理
@Component
@Slf4j
public class CacheClient {
private final StringRedisTemplate stringRedisTemplate;
// 线程池
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public CacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
// 1、将任意java对象序列化为json,并存储在string类型的key中,并且设置ttl
public void set(String key, Object data, Long time, TimeUnit unit){
stringRedisTemplate.opsForValue()
.set(key, JSONUtil.toJsonStr(data), time, unit);
}
// 2、再1的基础上加入逻辑过期时间,用于缓存击穿处理
public void setWithLogicalExpire(String key, Object data, Long time, TimeUnit unit){
// 调用之前定义的包含过期时间和数据的封装类
RedisData redisData = new RedisData();
redisData.setData(data);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
// 3、根据指定key查缓存,并反序列化为指定类型(泛型),并解决缓存穿透问题
public <R,ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
// 1、查询redis
String cacheShop = stringRedisTemplate.opsForValue().get(key);
// 2、存在则返回
if(StringUtils.isNotBlank(cacheShop))
return JSONUtil.toBean(cacheShop, type);
// 3、查到的是防止缓存穿透的空缓存对象
if(cacheShop != null){
return null;
}
// 4、不存在则查数据库
// 不同类对应不同数据库和表,所以查询数据库的方法应该作为函数参数传入进来
R dbRes = dbFallback.apply(id);
// 5、数据库不为空、写入缓存
if(dbRes != null){
String shopCache = JSONUtil.toJsonStr(dbRes);
this.set(key,shopCache,time, unit);
}
// 6、数据库为空,写入空缓存对象
else{
// 缓存空对象防止缓存穿透问题
this.set(key,"",time, unit);
return null;
}
// 7、返回数据
return dbRes;
}
// 4、根据指定key查缓存,并反序列化为指定泛型,并使用逻辑过期时间解决缓存击穿为题
public <R,ID> R queryWithLogicalExpire(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
// 1、查询redis
String cacheShop = stringRedisTemplate.opsForValue().get(key);
// 2、未命中返回null
if(StringUtils.isBlank(cacheShop))
return null;
// 3、命中判断是否过期
// 3.1、json反序列化RedisData
RedisData redisData = JSONUtil.toBean(cacheShop, RedisData.class);
// 3.2、因为RedisData中的data使用的时Object,所以反序列化得到的是JSONObject
JSONObject data = (JSONObject)redisData.getData();
// 3.3、JSONObject转为type类型java实体类
R resCache = JSONUtil.toBean(data, type);
// 3.4、拿到逻辑过期时间
LocalDateTime expireTime = redisData.getExpireTime();
// 4、是否过期
if(expireTime.isAfter(LocalDateTime.now())){
// 4.1、未过期,直接返回
return resCache;
}
// 4.2、过期则需要缓存重建
// 5、缓存重建
// 5.1、获取互斥锁
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
// 5.2、互斥锁获取成功
if(tryLock(lockKey)){
// 5.3、开启一个新线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(()->{
// 5.3.1查数据库并写入redis
R r1 = dbFallback.apply(id);
// 调用前面定义的加入逻辑过期时间方法
this.setWithLogicalExpire(key, r1, time, unit);
});
// 5.4、释放锁
unLock(lockKey);
}
// 6、返回过期的信息
return resCache;
}
// 互斥锁解决缓存击穿--》获取锁
private boolean tryLock(String key){
//
Boolean aBoolean = stringRedisTemplate.opsForValue()
.setIfAbsent(key, "1", RedisConstants.LOCK_SHOP_TTL, TimeUnit.SECONDS);
// 不能直接返回aBoolean,因为他是Boolean类型,会做拆箱返回boolean,这个过程可能会出错
// 使用工具类hutool
return BooleanUtil.isTrue(aBoolean);
}
// 互斥锁解决缓存击穿--》释放锁
private void unLock(String key){
stringRedisTemplate.delete(key);
}
}
1,2函数比较简单。3,4中使用了泛型,通过泛型可以返回任意类型的对象。并且还使用了函数作为参数。
封装好之后,前面的商铺信息获取,可以直接调用,以预防缓存击穿为例:
此时service中的queryById根据id获取商铺信息变得十分简洁。
@Override
public Result queryById(Long id) {
// 基于互斥锁的缓存击穿预防,包含基于缓存空对象的缓存穿透的预防
//Shop shop = cacheClient.queryWithPassThrough(RedisConstants.CACHE_SHOP_KEY, id, Shop.class,
// id2 -> baseMapper.selectById(id2), 30L, TimeUnit.MINUTES);
// 基于逻辑存储时间的缓存击穿预防,包含基于缓存空对象的缓存穿透的预防
Shop shop = cacheClient.queryWithLogicalExpire(RedisConstants.CACHE_SHOP_KEY, id, Shop.class,
id2 -> baseMapper.selectById(id2), 30L, TimeUnit.MINUTES);
if(shop == null)
return Result.fail("商铺信息不存在");
return Result.ok(shop);
}
至此有关商铺信息缓存的功能全部实现,本章节实现的功能也可以概括为缓存工具中的四个函数。
2.调用泛型方法
传参时,使用的反射,下面的通过类名反射获取Class对象也可以改为User,class
使用:
格式:Function<参数, 返回类型> func
func.apply:调用该函数
public static void funcPlus(int value, Function<Integer, Integer> func) {
System.out.println(func.apply(value));
}
传入,通过lambda表达式传入
public static void main(String... args) {
Function<Integer, Integer> increase = e -> e + 7; // lambda表达式
System.out.println(increase.getClass());
funcPlus(3, increase);
}
上面代码可以简化为
funcPlus(3, e -> e + 7);
1)redis中increment自增
2)时间戳
3)时间的字符串表示
每个店铺都可以发布优惠券:
当用户抢购时,就会生成订单并保存到 tb_voucher_order 这张表中,而订单表如果使用数据库自增 ID 就存在一些问题:
id 的规律性太明显
受单表数据量的限制
场景分析:如果我们的 id 具有太明显的规则,用户或者说商业对手很容易猜测出来我们的一些敏感信息,比如商城在一天时间内,卖出了多少单,这明显不合适。
场景分析二:随着我们商城规模越来越大,mysql 的单表的容量不宜超过 500W,数据量过大之后,我们要进行拆库拆表,但拆分表了之后,他们从逻辑上讲他们是同一张表,所以他们的 id 是不能一样的, 于是乎我们需要保证 id 的唯一性。
为了增加 ID 的安全性,我们可以不直接使用 Redis 自增的数值,而是拼接一些其它信息:
思路:使用64为long存储全局唯一ID
1)第一位为0,保证id为正。
2)中间31为为当前时间与某个起始时间的差值(时间戳实现)
3)后32用redis中的自增increment策略实现。
核心:每天生成一个用于自增的Key,每天通过对Key自增实现每天全局唯一ID
生成全局唯一ID的工具类
@Component
public class RedisIdWorker {
// LocalDateTime time = LocalDateTime.of(2022, 10, 12, 16, 43);
// long l1 = time.toEpochSecond(ZoneOffset.UTC);
// l1表示2022-10-12的时间戳
private static final long BEGIN_TIMESTAMP = 1665592980L;
private static final long COUNT_BITS = 32;
@Resource
private StringRedisTemplate stringRedisTemplate;
public long getNextId(String keyPrefix){
// 1、生成时间戳
// 1.1、获取当前时间戳
long nowSecond = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC);
// 1.2、生成ID中的时间戳部分
long timeStamp = nowSecond - BEGIN_TIMESTAMP;
// 2、生成序列号
// 2.1、获得当前日期的字符串表示
String data = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
// 2.2、实现自增长
Long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + data);
// 3、将时间戳部分和自增长部分拼接为64为long
return timeStamp << COUNT_BITS | count;
}
public static void main(String[] args) {
// 获取某一天的时间戳
LocalDateTime time = LocalDateTime.of(2022, 10, 12, 16, 43);
long l1 = time.toEpochSecond(ZoneOffset.UTC);
System.out.println(l1);
// 获取当前时间的字符串表示
String data = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
System.out.println(data);
}
}
由上面代码可知每天会对应一个不同的key:"icr:" + keyPrefix + ":" + 某一天的字符串表示
,用于自增生成当天的全局唯一ID。
例如key=icr:test:2022:10:12
,即2022.10.12当天所有订单的全局唯一ID通过对该key执行increment生成。我们也可以通过这个key得知今天的交易额。因为每交易一次,这个key对应的值就会+1;
注意:redis中increment函数:当key不存在时,会创建并设置为1。
测试:生成100个id
@Test
public void getUniqueId(){
for (int i = 0; i < 100; i++) {
long test = redisIdWorker.getNextId("test");
System.out.println(test);
}
}
根据结果显示,由于今天是2022.10.12所以redis中key后半部分为当前日期,由于生成了100个id,所以该键对应的值自增了100次,结果为100。
数据库中有两张表:优惠券,秒杀券
可以将秒杀券表视为普通券表的额外字段,即秒杀券也属于普通券,通过两张表共享主键ID,秒杀券可以获得普通券的属性,并且秒杀券还具备普通券没有的属性,存储在秒杀券表中。
平价卷由于优惠力度并不是很大,所以是可以任意领取。而代金券由于优惠力度大,所以像第二种卷,就得限制数量,从表结构上也能看出,特价卷除了具有优惠卷的基本信息以外,还具有库存,抢购时间,结束时间等等字段
根据两个券的实体类就可以发现,秒杀券也属于普通券,之所以为秒杀券新建一张表,是因为秒杀券不仅具备普通券信息,还包含其他信息,比如库存(普通券就没有库存限制)。所以对于秒杀券,在普通券类内存储他的普通券信息,在秒杀券类内存储他的秒杀券信息,所以秒杀券在两个类中的主键ID相同
普通券类
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("tb_voucher")
public class Voucher implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 主键
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private Long shopId;
private String title;
private String subTitle;
private String rules;
private Long payValue;
private Long actualValue;
private Integer type;
private Integer status;
@TableField(exist = false)
private Integer stock;
@TableField(exist = false)
private LocalDateTime beginTime;
@TableField(exist = false)
private LocalDateTime endTime;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}
优惠券类
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("tb_seckill_voucher")
public class SeckillVoucher implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 关联的优惠券的id
*/
@TableId(value = "voucher_id", type = IdType.INPUT)
private Long voucherId;
private Integer stock;
private LocalDateTime createTime;
private LocalDateTime beginTime;
private LocalDateTime endTime;
private LocalDateTime updateTime;
}
普通券表
秒杀券表
新增普通优惠券接口
@PostMapping
public Result addVoucher(@RequestBody Voucher voucher) {
voucherService.save(voucher);
return Result.ok(voucher.getId());
}
新增秒杀优惠券接口
@PostMapping("seckill")
public Result addSeckillVoucher(@RequestBody Voucher voucher) {
voucherService.addSeckillVoucher(voucher);
return Result.ok(voucher.getId());
}
新增秒杀优惠券service方法:addSeckillVoucher
新增秒杀优惠券时,先向普通优惠卷表中添加普通券通用信息,然后再向秒杀券表中添加秒杀券信息。可以认为秒杀券表是普通券表的数据扩充,两个表的主键id相同。
@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);
}
1)秒杀券是需要用户去下单抢购的,流程如下
2)判断秒杀是否开始或结束(秒杀券的开始结束时间段)
3)判断秒杀券库存是否充足
4)生成秒杀券下单订单,库存减一,并返回订单号
5)操作了秒杀券表和秒杀券订单表,所以要加事务
秒杀券下单代码
@Transactional // 该接口涉及两张表,需要事务
public Result seckillVoucher(Long voucherId) {
// 1、判断秒杀券ID是否存在
if(voucherId == null)
return Result.fail("不存在该优惠券");
// 2、查询秒杀券信息
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
if(voucher != null){
// 2.1、秒杀还没开始
if(voucher.getBeginTime().isAfter(LocalDateTime.now()))
return Result.fail("秒杀尚未开始");
// 2.2、秒杀已经结束
if(voucher.getEndTime().isBefore(LocalDateTime.now()))
return Result.fail("秒杀已经结束");
// 3、判断库存是否充足(库存存储在秒杀券表中)
if(voucher.getStock() < 1)
return Result.fail("库存不足");
}else{
return Result.fail("不存在该秒杀券");
}
// 4、库存充足,扣减库存
boolean update = seckillVoucherService.update()
.setSql("stock = stock - 1").eq("voucher_id", voucherId).update();
if(!update)
return Result.fail("库存不足");
// 5、生成订单
VoucherOrder voucherOrder = new VoucherOrder();
// 5.1、redisIdWorker工具类生成唯一ID
voucherOrder.setId(redisIdWorker.getNextId("order"));
voucherOrder.setVoucherId(voucherId);
// 5.2、threadlocal获取用户id
voucherOrder.setUserId(UserHolder.getUser().getId());
save(voucherOrder);
// 6、返回订单编号
return Result.ok(voucherOrder.getId());
}
问题描述:多线程加锁
假设线程 1 过来查询库存,判断出来库存大于 1,正准备去扣减库存,但是还没有来得及去扣减,此时线程 2 过来,线程 2 也去查询库存,发现这个数量一定也大于 1,那么这两个线程都会去扣减库存,最终多个线程相当于一起去扣减库存,此时就会出现库存的超卖问题。
乐观锁实现方法:
版本号方法:
第二种方法:CAS法
上面版本号的方法,分为两步查询和修改,查询和修改都涉及到了库存和版本号字段,所以我们可以使用库存字段代替版本号字段。
基于CAS法实现乐观锁,解决秒杀全超卖问题,只需要在秒杀券下单代码中的库存扣减前添加一个对库存值是否变化的判断。
不加乐观锁代码
boolean update = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.update();
if(!update)
return Result.fail("库存不足");
加乐观锁代码
boolean update = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.eq("stock", voucher.getStock())
.update();
if(!update)
return Result.fail("库存不足");
通过乐观锁虽然解决了不同线程买同一个秒杀券的,但是又引发了新问题:
eg:
总结:使用乐观锁不会出现超卖问题,但是会增加线程的失败率。
解决:将上面的.eq("stock", voucher.getStock())
条件改为.gt("stock", 0)
即stock > 0;
代码变为
boolean update = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
if(!update)
return Result.fail("库存不足");
总结
问题描述:多线程加锁
在上面秒杀券下单的基础上添加:同一用户只能下一单
业务流程改为
// 4、库存充足,
// 4.1、一人一单,检查用户是否已经下过单
UserDTO user = UserHolder.getUser();
int count = query()
.eq("user_id", user.getId())
.eq("voucher_id", voucherId)
.count();
if(count > 0)
return Result.fail("每位用户只能购买一次同一秒杀券");
// 4.2、用户第一次下单则扣减库存
boolean update = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
if(!update)
return Result.fail("库存不足");
存在问题:
eg: 同一用户同时发200个请求,按照上面代码的逻辑,这两百个请求应该只能下一单。但是由于是同时发出,所有请求都判断count小于零都会下单,还会导致一个用户多单问题。
所以现在的问题还是和之前一样,并发过来,查询数据库,都不存在订单,所以我们还是需要加锁,但是乐观锁比较适合更新数据,而现在是插入数据,所以我们需要使用悲观锁操作
解决方案
注意: 在这里提到了非常多的问题,我们需要慢慢的来思考,首先我们的初始方案是封装了一个 createVoucherOrder
方法,同时为了确保他线程安全,在方法上添加了一把 synchronized
锁
@Transactional
public synchronized Result createVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
// 5.1.查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.2.判断是否存在
if (count > 0) {
// 用户已经购买过了
return Result.fail("用户已经购买过一次!");
}
// 6.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
.update();
if (!success) {
// 扣减失败
return Result.fail("库存不足!");
}
// 7.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 7.1.订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 7.2.用户id
voucherOrder.setUserId(userId);
// 7.3.代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7.返回订单id
return Result.ok(orderId);
}
我们只是对同一用户的下单加锁,这样在函数上加锁,相当于对所有用户加锁。即这样添加锁,锁的粒度太粗了,在使用锁过程中,控制锁粒度 是一个非常重要的事情,因为如果锁的粒度太大,会导致每个线程进来都会锁住,所以我们需要去控制锁的粒度,以上这段代码需要修改为将锁加在同一用户身上。
intern ()
这个方法是从常量池中拿到数据,如果我们直接使用 userId.toString () 他拿到的对象实际上是不同的对象,new 出来的对象。我们必须保证同一用户每次访问的是同一锁,所以我们需要使用 intern () 方法。
@Transactional
public Result createVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
synchronized(userId.toString().intern()){
// 5.1.查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.2.判断是否存在
if (count > 0) {
// 用户已经购买过了
return Result.fail("用户已经购买过一次!");
}
// 6.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
.update();
if (!success) {
// 扣减失败
return Result.fail("库存不足!");
}
// 7.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 7.1.订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 7.2.用户id
voucherOrder.setUserId(userId);
// 7.3.代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7.返回订单id
return Result.ok(orderId);
}
}
但是以上代码还是存在问题,问题的原因在于当前方法加了事务注解,被 spring 的事务控制。
上面代码的执行流程如下:
1)上锁,下单,库存减一,释放锁
2)提交事务
上面的流程会存在一个问题,当第一步结束时,当前方法事务还没提交,但是锁已经释放。此时其他线程可以访问锁,还是会出现并发问题。
所以我们选择将当前方法整体包裹起来,确保事务不会出现问题:如下:
这样就实现了事务提交后才释放锁。
但是以上做法依然有问题,因为你调用的方法,其实是 this. 的方式调用的,this拿到的是VoucherOrderServiceImpl
对象,不是代理对象,事务想要生效,需要利用代理来生效,所以这个地方,我们需要获得原始的事务对象, 来操作事务
public Result seckillVoucher(Long voucherId) {
// 1、判断秒杀券ID是否存在
if(voucherId == null)
return Result.fail("不存在该优惠券");
// 2、查询秒杀券信息
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
if(voucher != null){
// 2.1、秒杀还没开始
if(voucher.getBeginTime().isAfter(LocalDateTime.now()))
return Result.fail("秒杀尚未开始");
// 2.2、秒杀已经结束
if(voucher.getEndTime().isBefore(LocalDateTime.now()))
return Result.fail("秒杀已经结束");
// 3、判断库存是否充足(库存存储在秒杀券表中)
if(voucher.getStock() < 1)
return Result.fail("库存不足");
}else{
return Result.fail("不存在该秒杀券");
}
// 一人一单,解决库存超卖问题
Long userId = UserHolder.getUser().getId();
synchronized(userId.toString().intern()){
// 拿到当前对象的代理的对
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
}
实现一人一单,解决库存超卖问题方法
@Transactional // 该接口涉及两张表,需要事务
public Result createVoucherOrder(Long voucherId){
// 4、库存充足,
// 4.1、一人一单,检查用户是否已经下过单
Long userId = UserHolder.getUser().getId();
// 每位用户只能购买一次同一秒杀券
int count = query()
.eq("user_id", userId)
.eq("voucher_id", voucherId)
.count();
if(count > 0)
return Result.fail("每位用户只能购买一次同一秒杀券");
// 4.2、用户第一次下单则扣减库存
boolean update = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
if(!update)
return Result.fail("库存不足");
// 5、生成订单
VoucherOrder voucherOrder = new VoucherOrder();
// 5.1、redisIdWorker工具类生成唯一ID
voucherOrder.setId(redisIdWorker.getNextId("order"));
voucherOrder.setVoucherId(voucherId);
// 5.2、threadlocal获取用户id
voucherOrder.setUserId(userId);
save(voucherOrder);
// 6、返回订单编号
return Result.ok(voucherOrder.getId());
}
使用了获取代理对象方法,需要添加依赖
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
<version>1.8.13</version>
</dependency>
并在启动类上添加开启注解
@EnableAspectJAutoProxy(exposeProxy = true)
事务和代理对象知识:
1)this指代目标对象,是非代理对象
2)事务必须有代理对象才能生效
3)代理对象只能是接口类,不能是实现类。
4)AopContext.currentProxy()
获取对象代理
通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了。
1)我们将服务启动两份,端口分别为 8081 和 8082:
具体操作如下
最终结果
2)然后修改 nginx 的 conf 目录下的 nginx.conf 文件,配置反向代理和负载均衡:
原始nginx文件
修改为
使用postman发送两个相同的优惠券秒杀请求,会出现问题:我们前面设置的锁不起作用了,两个线程都能访问。
原因:
由于现在我们部署了多个 tomcat,每个 tomcat 都有一个属于自己的 jvm,那么假设在服务器 A 的 tomcat 内部,有两个线程,这两个线程由于使用的是同一份代码,那么他们的锁对象是同一个,是可以实现互斥的,但是如果现在是服务器 B 的 tomcat 内部,又有两个线程,但是他们的锁对象写的虽然和服务器 A 一样,但是锁对象却不是同一个,所以线程 3 和线程 4 可以实现互斥,但是却无法和线程 1 和线程 2 实现互斥,这就是 集群环境下,syn 锁失效的原因,在这种情况下,我们就需要使用分布式锁来解决这个问题。
分布式锁特点
注意:此处锁的应用是:防止同一用户的不同线程
的同时下单操作(一人只能下一单)
1)redis锁名字的组成,例如秒杀全下单key=order:userId
2)try catch的使用
该方法在前面基于互斥锁的预防缓存击穿问题中已经讲过。
key:自定义前缀+参数name,name为模块名+用户ID,可以实现对同一用户加锁
value: 当前线程ID
public interface ILock {
boolean tryLock(long timeoutSec);
void unLock();
}
创建一个简单的reids分布式锁类SimpleRedisLock
key:自定义前缀+参数name,name为模块名+用户ID,可以实现对同一用户加锁
value: 当前线程ID
public class SimpleRedisLock implements ILock{
private String name;
private StringRedisTemplate stringRedisTemplate;
private static final String KEY_PREFIX = "lock:";
// 构造函数注入锁的名字和redisTemplate
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
// 获取锁
@Override
public boolean tryLock(long timeoutSec) {
long threadId = Thread.currentThread().getId();
Boolean aBoolean = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
// Boolean到boolean的自动拆箱可能返回空指针,我们期待返回true或false
return BooleanUtil.isTrue(aBoolean);
}
// 释放锁
@Override
public void unLock() {
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
改进秒杀券下单函数seckillVoucher
,使用自定义的redis锁代替synchronized
。
原始代码
Long userId = UserHolder.getUser().getId();
synchronized(userId.toString().intern()){
// 拿到当前对象的代理的对
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
使用自定义锁代码
细节
: 把实现一人一单,解决库存超卖问题的函数createVoucherOrder
放在try中执行,把释放锁放在finally中执行。即使try中出现异常,锁也可以正常释放
// 创建自定义锁对象
SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
// 获取锁失败
if(!lock.tryLock(5))
return Result.fail("不允许重复下单");
// 使用finally是防止try中出现异常,导致锁无法释放
try{
// 拿到当前对象的代理的对
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}finally {
// 释放锁
lock.unLock();
}
问题描述: 持有锁的线程在锁的内部出现了阻塞,导致他的锁自动释放,这时其他线程,线程 2 来尝试获得锁,就拿到了这把锁,然后线程 2 在持有锁执行过程中,线程 1 阻塞结束,继续执行,而线程 1 执行过程中,走到了删除锁逻辑,此时就会把本应该属于线程 2 的锁进行删除,这就是误删别人锁的情况说明
解决方案: 解决方案就是在每个线程释放锁的时候,去判断一下当前这把锁是否属于自己,如果不属于自己,则不进行锁的删除,假设还是上边的情况,线程 1 卡顿,锁自动释放,线程 2 进入到锁的内部执行逻辑,此时线程 1 反应过来,然后删除锁,但是线程 1,一看当前这把锁不是属于自己,于是不进行删除锁逻辑,当线程 2 走到删除锁逻辑时,如果没有卡过自动释放锁的时间点,则判断当前这把锁是属于自己的,于是删除这把锁。
我们在前面创建锁时,key是自定义前缀+业务名称,value是线程的ID,只需要判断锁在redis中对应的值是否等于本线程ID即可。这也是为什么锁的value要设置为线程ID的原因。
上一小节说到使用线程ID作为锁在redis中的value来防止锁误删,这种方法只适用于单机服务器。
原因: 线程的ID是由JVM递增的分配的,当有多台服务器时,就会涉及到多个JVM,就可能导致不同服务器中的线程ID相同,所以这里不能使用线程ID,推荐使用UUID。
可能不太理解,如果使用UUID那还怎么记住每个线程对应的UUID呢。
redis锁的实现类:
类里面定义了一个静态final常量ID_PREFIX :值为UUID的字符串表示
锁的key:KEY_PREFIX + 参数name,name位操作场景+用户id
锁的value:ID_PREFIX + 线程ID,ID_PREFIX 是uuid的字符串表示
public class SimpleRedisLock implements ILock{
private String name;
private StringRedisTemplate stringRedisTemplate;
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
// 构造函数注入锁的名字和redisTemplate
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
// 获取锁
@Override
public boolean tryLock(long timeoutSec) {
long threadId = Thread.currentThread().getId();
Boolean aBoolean = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, ID_PREFIX + threadId, timeoutSec, TimeUnit.SECONDS);
// Boolean到boolean的自动拆箱可能返回空指针,我们期待返回true或false
return BooleanUtil.isTrue(aBoolean);
}
// 释放锁
@Override
public void unLock() {
long threadId = Thread.currentThread().getId();
// 获取锁的值
String value = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
// 判断锁的值和当前线程的ID_PREFIX + threadId是否相同,相同这是当前线程的锁
// 这里的每个线程有不同的UUID,可以区分不同线程,也可以区分不同服务的相同ID的线程
if(value.equals(ID_PREFIX + threadId))
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
我们在使用该锁时,会创建一个SimpleRedisLock的对象,此时会产生成一个静态变量UUID,并且和当前线程id拼接作为锁的值,当其他线程想要删除不属于自己的锁时,他们会判断该锁的值和自己锁的值(创建SimpleRedisLock对象时就固定了)是否相同。由于锁的值是uuid+线程id,所以即使两个来自于不同服务且线程id相同的线程,也不会出现锁误删的问题。
总结:该小节是为了解决(来自不同服务但线程id相同)线程出现的锁误删问题。
虽然上面代码实现了防误删的操作,但是由于释放锁的函数不是原子性操作,所以可能会出现一种更为极端的误删情况:
// 释放锁
@Override
public void unLock() {
long threadId = Thread.currentThread().getId();
// 获取锁的值
String value = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
// 判断锁的值和当前线程的ID_PREFIX + threadId是否相同,相同这是当前线程的锁
// 这里的每个线程有不同的UUID,可以区分不同线程,也可以区分不同服务的相同ID的线程
if(value.equals(ID_PREFIX + threadId))
stringRedisTemplate.delete(KEY_PREFIX + name);
}
对于同一用户的不同线程12,由于用户相同,所以线程12共享一把锁,即key是由用户id确定的。
线程 1 现在持有锁之后,在执行业务逻辑过程中,他正准备删除锁,而且已经走到了条件判断的过程中(即上面代码的if判断已经成功
),所以此时他已经拿到了当前这把锁确实是属于他自己的,正准备删除锁(if内的代码
),但是由于系统发生了阻塞,而且阻塞的时间超过了锁的存活时间,锁被reids自动删除,但是此时线程1还没有执行删除操作。
此时线程 2 进来,获取锁成功(因为之前的锁到期删除了),执行代码。此时线程 1 的阻塞结束了,他会接着往后执行(即执行删除锁操作),就把线程2的锁删除了,相当于条件判断并没有起到作用,这就是删锁时的原子性问题,之所以有这个问题,是因为线程 1 的拿锁,判断锁,删锁,实际上并不是原子性的,我们要防止刚才的情况发生。
经过上图的1-10序列操作,就会出现同一用户的线程2,3同时工作的问题,即不满足一人一单的要求。
前面小节出现了redis操作的原子性问题,所以本章节使用Lua脚本解决该问题。
执行Lua脚本示例
:EVAL执行,Lua脚本脚本可以分为两类,有参和无参,且Lua脚本中数组下标从1开始。
1)执行无参数脚本示例
// 设置name为key,value为jack,0表示没有参数
eval "return redis.call('set', 'name', 'jack')" 0
测试
2)执行有参数脚本示例
key的参数存放在KEYS数组中,value的参数存在ARGV数组中,并且数组的下标从1开始。
// 1表示需要key类型的参数的个数
eval "return redis.call('set', KEYS[1], ARGV[1])" 1 name tom
测试
Lua脚本代码示例:
“–”表示注解,local表示变量,if和linux命令类似。
上面的脚本是无参释放锁的脚本,我们需要的是有参脚本,在上面代码的基础上改进。
java中使用execute方法实行lua脚本,
1)第一个参数为脚本,类型为RedisScript< T>,T为返回类型,RedisScript是一个接口,一般都是用它的实现类DefaultRedisScript< T >
2)第二个参数是key的参数,格式为List
3)第三个参数是value的参数,格式为Object
在resource
中创建释放锁的Lua脚本文件unlock.lua
--- 从参数中获取锁中的key
local key = KEYS[1]
--- 获取锁中的value
local id = redis.call('get', key)
--- 如果锁的值和参数传递的值相同,则释放锁
if(id == ARGV[1]) then
return redis.call('del', key)
end
return 0
java中执行lua脚本
1)读取脚本文件
将lua文件转为DefaultRedisScript
类型,并作为execute的第一个参数
// 静态变量可以保证只读取一次lua文件,减少IO流
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
// 静态变量必须初始化
static {
// 初始化
UNLOCK_SCRIPT = new DefaultRedisScript<>();
// 使用配置文件读取lua脚本
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
// 设置返回类型
UNLOCK_SCRIPT.setResultType(Long.class);
}
2)执行脚本文件
此处为执行释放锁脚本文件,所以改变释放锁函数代码
这里使用Collections.singletonList
将单个值转为列表,因为execute对第二个参数的要求是List
public void unLock() {
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId()
);
}
总结:从3.7到本章节我们已经实现了一个简单的分布式锁。
基于 Redis 的分布式锁实现思路:
利用 set nx 获取锁,并设置过期时间,保存线程表示
释放锁时先判断线程标示是否与自己一致,一致则删除锁
特性:
利用 set nx 满足互斥性
利用 过期时间 保证故障时锁依然能释放,避免死锁,提高安全性
利用 Redis 集群保证高可用和高并发特性
个人总结:我们一路走来,利用添加过期时间,防止死锁问题的发生,但是有了过期时间之后,可能出现误删别人锁的问题,这个问题我们开始是利用删之前 通过拿锁,比锁,删锁这个逻辑来解决的,也就是删之前判断一下当前这把锁是否是属于自己的,但是现在还有原子性问题,也就是我们没法保证拿锁比锁删锁是一个原子性的动作,最后通过 lua 表达式来解决这个问题
但是目前还剩下一个问题锁不住,什么是锁不住呢,你想一想,如果当过期时间到了之后,我们可以给他续期一下,比如续个 30s,就好像是网吧上网, 网费到了之后,然后说,来,网管,再给我来 10 块的,是不是后边的问题都不会发生了,那么续期问题怎么解决呢,可以依赖于我们接下来要学习 redission 啦
测试逻辑:
第一个线程进来,得到了锁,手动删除锁,模拟锁超时了,其他线程会执行 lua 来抢锁,当第一天线程利用 lua 删除锁时,lua 能保证他不能删除其它的锁,第二个线程删除锁时,利用 lua 同样可以保证不会删除别人的锁,同时还能保证原子性。
分布式锁完整代码SimpleRedisLock
public class SimpleRedisLock implements ILock{
private String name;
private StringRedisTemplate stringRedisTemplate;
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
// 静态变量可以保证只读取一次lua文件,减少IO流
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
// 静态变量必须初始化
static {
// 初始化
UNLOCK_SCRIPT = new DefaultRedisScript<>();
// 使用配置文件读取lua脚本
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
// 设置返回类型
UNLOCK_SCRIPT.setResultType(Long.class);
}
// 构造函数注入锁的名字和redisTemplate
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
// 获取锁
@Override
public boolean tryLock(long timeoutSec) {
long threadId = Thread.currentThread().getId();
Boolean aBoolean = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, ID_PREFIX + threadId, timeoutSec, TimeUnit.SECONDS);
// Boolean到boolean的自动拆箱可能返回空指针,我们期待返回true或false
return BooleanUtil.isTrue(aBoolean);
}
// lua脚本释放锁
@Override
public void unLock() {
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId()
);
}
}
基于setnx的分布式锁存在以下问题
所以今后在使用锁时,直接使用开源框架Redisson,前面的自定义锁是加深我们对锁的理解。
Redisson入门
1)引入依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
2)配置Redission客户端(配置文件)
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient(){
// 配置类
Config config = new Config();
// 添加redis地址,这里使用的是单点的地址,也可以使用集群地址
config.useSingleServer().setAddress("redis://127.0.0.1:6379").setPassword("747699");
// 创建客户端
return Redisson.create(config);
}
}
3)使用分布式锁
尝试获取锁 参数:获得锁的最大等待时间、锁自动释放时间、时间单位
boolean tryLock = anyLock.tryLock(1, 10, TimeUnit.SECONDS);
最大等待时间表示,如果当前没有获得锁,在等待时间内还回去获取锁,实现了获取锁的可重试。前面自定义的锁,当获取失败时流程就直接结束了。
public void RedissonLock() throws InterruptedException {
// 获取锁对象(可重入锁),参数为锁名
RLock anyLock = redissonClient.getLock("anyLock");
// 尝试获取锁 参数:获得锁的最大等待时间、锁自动释放时间、时间单位
boolean tryLock = anyLock.tryLock(1, 10, TimeUnit.SECONDS);
// 判断锁是否获得成功
if(tryLock){
try{
System.out.println("获取锁成功");
}finally {
// 释放锁
anyLock.unlock();
}
}
}
Redisson代替自定义锁
修改一人一单部分代码
// 创建Redisson锁对象
RLock lock = redissonClient.getLock("order:" + userId);
// 获取锁失败
if(!lock.tryLock(5, 10, TimeUnit.SECONDS))
return Result.fail("不允许重复下单");
// 使用finally是防止try中出现异常,导致锁无法释放
try{
// 拿到当前对象的代理的对
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}finally {
// 释放锁
lock.unlock();
}
什么是 “可重入”,可重入就是说某个线程已经获得某个锁,可以再次获取锁而不会出现死锁。及可重入是针对于同一个线程。
下图对value的操作类似于OS中的读写者问题,value用于记录当前线程第几次重入锁。为了实现下面的可重入锁,使用lua脚本,实现原子性。
根据上图可知
锁的key:和之前一样string类型
锁的value:使用hash存储。线程ID,value(实现可重入)
获取锁流程:
exists判断某个key是否存在
hexists判断hash数据类型中的某个key是否存在
释放锁流程
上面是使用redis实现可重入锁的获取和释放脚本文件,如果想要使用可重入锁,可以根据3.14中讲的java使用lua脚本实现。其实Redisson提供了可重入锁RLock,实现原理就是我们上面使用lua实现的原理。
1)定义静态变量:RedisScript
的实现类DefaultRedisScript
对象
2)静态代码块初始化DefaultRedisScript
对象,使用resource
的方式初始化,设置返回类型等
3)调用StringRedisTemplate
的execute
方法获取锁
4)调用StringRedisTemplate
的execute
方法释放锁
哔哩哔哩视频讲解
为了提高 redis 的可用性,我们会搭建集群或者主从,现在以主从为例。
此时我们去写命令,写在主机上, 主机会将数据同步给从机,但是假设在主机还没有来得及把数据写入到从机去的时候,此时主机宕机,哨兵会发现主机宕机,并且选举一个 slave 变成 master,而此时新的 master 中实际上并没有锁信息,此时锁信息就已经丢掉了。
为了解决这个问题,redission 提出来了 MutiLock 锁,使用这把锁咱们就不使用主从了,每个节点的地位都是一样的, 这把锁加锁的逻辑需要写入到每一个主丛节点上,只有所有的服务器都写入成功,此时才是加锁成功,假设现在某个节点挂了,那么他去获得锁的时候,只要有一个节点拿不到,都不能算是加锁成功,就保证了加锁的可靠性。
那么 MutiLock 加锁原理是什么呢?笔者画了一幅图来说明
当我们去设置了多个锁时,redission 会将多个锁添加到一个集合中,然后用 while 循环去不停去尝试拿锁,但是会有一个总共的加锁时间,这个时间是用需要加锁的个数 * 1500ms ,假设有 3 个锁,那么时间就是 4500ms,假设在这 4500ms 内,所有的锁都加锁成功, 那么此时才算是加锁成功,如果在 4500ms 有线程加锁失败,则会再次去进行重试.
原始秒杀流程是串行的,并且涉及了多次数据库操作,耗时比较长。
上面的流程可以分为两部分:查数据库、秒杀资格判断(库存,一人一单)。
优化方案:我们将耗时比较短的逻辑判断放入到 redis
中,比如是否库存足够,比如是否一人一单,这样的操作,只要这种逻辑可以完成,就意味着我们是一定可以下单完成的,我们只需要进行快速的逻辑判断,根本就不用等下单逻辑走完,我们直接给用户返回成功, 再在后台开一个线程,后台线程慢慢的去执行 queue 里边的消息,这样程序不就超级快了吗?而且也不用担心线程池消耗殆尽的问题,因为这里我们的程序中并没有手动使用任何线程池,当然这里边有两个难点
第一个难点是我们怎么在 redis 中去快速校验一人一单,还有库存判断
第二个难点是由于我们校验和 tomcat 下单是两个线程,那么我们如何知道到底哪个订单他最后是否成功,或者是下单完成,为了完成这件事我们在 redis 操作完之后,我们会将一些信息返回给前端,同时也会把这些信息丢到异步 queue 中去,后续操作中,可以通过这个 id 来查询我们 tomcat 中的下单逻辑是否完成了。
第一个难点解决:redis采用string存储库存,采用set判断一人一单
整体思路:当用户下单之后,判断库存是否充足只需要去 redis 中根据 key 找对应的 value 是否大于 0 即可,如果不充足,则直接结束,如果充足,继续在 redis 中判断用户是否已经下过单,如果 set 集合中没有这条数据,说明他可以下单,则将 userId 和优惠卷存入到 redis 中,并且返回 0,否则返回异常信息。整个过程需要保证是原子性的,我们可以使用 lua 来操作
所以通过实现下面需求来改进秒杀业务:
1)为了判断库存是否充足,使用string类型存储库存:
key: "seckill:stock:"+优惠券ID
value:优惠券库存
2)为了判断是否已下过单,使用set记录每类秒杀券的下单用户
key: "seckill:order:"+优惠券ID
value:用户id
将秒杀券库存保存到redis中
修改新增优惠券函数,实现新增秒杀优惠券的同时,将优惠券库存保存到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(
RedisConstants.SECKILL_STOCK_KEY + voucher.getId().toString(),
voucher.getStock().toString());
}
使用postman向数据库中添加一类新的秒杀券,因为我们的后续代码默认条件是redis中有库存缓存的,所以要执行上面的代码,向redis中写入秒杀券的库存信息,否则后面代码去redis获取秒杀券库存会报错。
向商铺1添加一种新优惠券,返回优惠券id为6,接下来使用这个优惠券ID测试
通过lua脚本,判断秒杀库存、一人一单决定是否抢购成功。
目前lua脚本不仅判断用户是否有资格下单,即判断库存和一人一单,还会将库存和下单信息存入redis中。
参数:订单id,用户id
返回值:0表示有资格,1表示库存不足,2表示已经下过单
--- 1.参数列表
--- 1.1 优惠券id
local voucherId = ARGV[1]
--- 1.2 用户id
local userId = ARGV[2]
--- 2.redis key
--- 2.1 库存key
--- ..表示字符串拼接
local stockKey = 'seckill:stock:' .. voucherId
--- 2.2 订单key,用于判断一人一单
local orderKey = 'seckill:order:' .. voucherId
--- 3.判断库存是否充足
if(tonumber(redis.call('get', stockKey)) <= 0) then
--- 3.1 库存不充足返回1
return 1
end
--- 3.2 库存充足,判断是否已经下过单
if(redis.call('sismember', orderKey, userId) == 1) then
--- 用户已下过单,返回2
return 2
end
--- 3.4 库存充足,且用户没有下过单,减库存
redis.call('incrby', stockKey, -1)
--- 3.5 下单,保存用户信息
redis.call('sadd', orderKey, userId)
return 0
修改秒杀券下单函数
使用java执行lua文件,3.14已经介绍了java如何执行lua脚本
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
SECKILL_SCRIPT = new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}
@Override
//秒杀券下单(异步执行)
public Result seckillVoucher(Long voucherId) throws InterruptedException {
// 获取用户ID
Long userId = UserHolder.getUser().getId();
// 1、通过执行lua脚本判断是否由资格购买(库存和一人一单)
Long res = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString());
// 2、判断脚本结果res是否为0
// 2.1 res = 0 表示有资格,具体见lua脚本
if(res != 0){
return Result.fail(res == 1 ? "库存不足" : "不能重复下单");
}
// 2.2 res = 0,有资格下单,生成唯一订单id
long orderId = redisIdWorker.getNextId("order");
// TODO 保存到阻塞队列
return Result.ok(orderId);
}
测试
对id为6的优惠券进行测试(前面已经将其添加到redis和数据库中)
连续带点击两次下两次单,返回信息不可重复下单。
由于已经下单,所以redis会有该优惠券的库存和用户下点集合set
lua中操作的库存信息
lua中操作的set(记录每类秒杀券有哪些用户下单,实现一人一单)
这里通过阻塞队列的方式实现啊异步
4.2已经实现了秒杀优化的两个需求:
1)将秒杀全库存保存在redis中
2)使用lua脚本判断用户下单资格
秒杀优化全过程:现在我们去下单时,是通过 lua 表达式去原子执行判断逻辑,如果判断我出来不为 0 ,则要么是库存不足,要么是重复下单,返回错误信息,如果是 0,则把下单的逻辑保存到队列中去,然后异步执行(本节实现部分)
用户有资格下单后,生成订单信息,并将订单信息加入到阻塞队列。
创建阻塞队列
// 创建阻塞队列
private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024*1024);
用户有资格下单后,生成订单信息,并将订单信息加入到阻塞队列。
//秒杀券下单(异步执行)
public Result seckillVoucher(Long voucherId){
// 获取用户ID
Long userId = UserHolder.getUser().getId();
// 1、通过执行lua脚本判断是否由资格购买(库存和一人一单)
Long res = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString()
);
// 2、判断脚本结果res是否为0
// 2.1 res = 0 表示有资格,具体见lua脚本
if(res != 0){
return Result.fail(res == 1 ? "库存不足" : "不能重复下单");
}
// 2.2 res = 0,有资格下单,生成订单,把订单信息保存到阻塞队列
// 2.3 生成订单
VoucherOrder voucherOrder = new VoucherOrder();
// 2.3.1 设置订单id--redisIdWorker工具类生成唯一ID
long orderId = redisIdWorker.getNextId("order");
voucherOrder.setId(orderId);
// 2.3.2 设置代金券id
voucherOrder.setVoucherId(voucherId);
// 2.3.3 设置用户id
voucherOrder.setUserId(userId);
// 2.4 放入阻塞队列
orderTasks.add(voucherOrder);
// 3 返回订单id
return Result.ok(orderId);
}
这样我们的主线程功能就实现了:再次贴一下异步秒杀思路图。
到目前为止,我们实现了,用户下单资格判断和订单信息加入到阻塞队列的功能,只有一部下单任务还没有实现(操作数据库)。
创建线程池
// 创建线程池,用于异步下单操作数据库
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
通过内部类创建线程任务,使用其他线程执行库存减和订单加操作
由于系统启动了就有可能会有用户下单秒杀券,所以下在类初始化之后就要执行线程任务
代码中设计了一些线程的知识:
1)创建线程池
2)定义线程任务(内部类方式),用于执行库存减和订单加操作(异步下单核心)
3)提交线程
//在类初始化之后执行线程任务
// 因为当这个类初始化好了之后,随时都是有可能要执行的
@PostConstruct
private void init() {
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
// 线程任务(内部类实现)
private class VoucherOrderHandler implements Runnable{
// 用于执行我们异步下单需要执行的操作(操作数据库)
@Override
public void run() {
while(true){
try{
// 1.获取队列中的订单信息
VoucherOrder voucherOrder = orderTasks.take();
// 2.创建订单
handleVoucherOrder(voucherOrder);
}catch (Exception e){
log.error("处理订单异常", e);
}
}
}
// 创建订单
private void handleVoucherOrder(VoucherOrder voucherOrder){
// 操作数据库,库存减,订单加
//注意:由于是spring的事务是放在threadLocal中,此时的是多线程,事务会失效
// 即在多线程内不能获取代理,所以我们在主线程内获取代理对象,并将其作为类变量使用
proxy.createVoucherOrder(voucherOrder);
}
}
// 一人一单:减库存和写入订单信息
@Transactional // 该接口涉及两张表,需要事务
public Result createVoucherOrder(VoucherOrder voucherOrder){
// 该函数是在多线程中调用,所以不能在本地线程中获取用户id
// 所以从订单信息中获取用户id
// 1、获取用户id
Long userId = voucherOrder.getId();
// 2、获取优惠券id
Long voucherId = voucherOrder.getVoucherId();
// 3、用户第一次下单则扣减库存
seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
// 4、订单信息写入数据库
save(voucherOrder);
// 6、返回订单编号
return Result.ok(voucherOrder.getId());
}
异步下单:
key: "seckill:stock:"+优惠券ID
value:优惠券库存
key: "seckill:order:"+优惠券ID
value:用户id
以下为异步线程
createVoucherOrder
,并添加事务createVoucherOrder
实现数据库操作异步下单完整代码
Service
@Slf4j
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private RedissonClient redissonClient;
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
SECKILL_SCRIPT = new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}
// 创建阻塞队列
private final 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 VoucherOrderHandler());
}
// 线程任务(内部类实现)
private class VoucherOrderHandler implements Runnable{
// 用于执行我们异步下单需要执行的操作(操作数据库)
@Override
public void run() {
while(true){
try{
// 1.获取队列中的订单信息
VoucherOrder voucherOrder = orderTasks.take();
// 2.创建订单
handleVoucherOrder(voucherOrder);
}catch (Exception e){
log.error("处理订单异常", e);
}
}
}
// 创建订单
private void handleVoucherOrder(VoucherOrder voucherOrder){
// 操作数据库,库存减,订单加
//注意:由于是spring的事务是放在threadLocal中,此时的是多线程,事务会失效
// 即在多线程内不能获取代理,所以我们在主线程内获取代理对象,并将其作为类变量使用
proxy.createVoucherOrder(voucherOrder);
}
}
private IVoucherOrderService proxy;
@Override
//秒杀券下单(异步执行)
public Result seckillVoucher(Long voucherId){
// 获取用户ID
Long userId = UserHolder.getUser().getId();
// 1、通过执行lua脚本判断是否由资格购买(库存和一人一单)
Long res = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString()
);
// 2、判断脚本结果res是否为0
// 2.1 res = 0 表示有资格,具体见lua脚本
if(res != 0){
return Result.fail(res == 1 ? "库存不足" : "不能重复下单");
}
// 2.2 res = 0,有资格下单,生成订单,把订单信息保存到阻塞队列
// 2.3 生成订单
VoucherOrder voucherOrder = new VoucherOrder();
// 2.3.1 设置订单id--redisIdWorker工具类生成唯一ID
long orderId = redisIdWorker.getNextId("order");
voucherOrder.setId(orderId);
// 2.3.2 设置代金券id
voucherOrder.setVoucherId(voucherId);
// 2.3.3 设置用户id
voucherOrder.setUserId(userId);
// 2.4 放入阻塞队列
orderTasks.add(voucherOrder);
// 3 获取代理对象
proxy = (IVoucherOrderService)AopContext.currentProxy();
// 3 返回订单id
return Result.ok(orderId);
}
// 一人一单:减库存和写入订单信息
@Transactional // 该接口涉及两张表,需要事务
public Result createVoucherOrder(VoucherOrder voucherOrder){
// 该函数是在多线程中调用,所以不能在本地线程中获取用户id
// 所以从订单信息中获取用户id
// 1、获取用户id
Long userId = voucherOrder.getId();
// 2、获取优惠券id
Long voucherId = voucherOrder.getVoucherId();
// 3、用户第一次下单则扣减库存
seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
// 4、订单信息写入数据库
save(voucherOrder);
// 6、返回订单编号
return Result.ok(voucherOrder.getId());
}
}
将前面的阻塞队列替换为消息队列。
消息队列不受jvm内存限制,还可以确保数据的安全。
RPOP:RPOP key
从右侧移除一个元素并返回,没有返回null
BRPOP:BRPOP key [key ...] timeout
从右侧移除一个元素并返回,如果没有元素会阻塞timeout时间,等待有元素(阻塞队列)
所以我们可以使用redis
中的list
和RPOP
、LPUSH
命令实现消息队列。
只能实现但消费者,因为RPOP
取出元素后还会将该元素删除。
PubSub:public subscript(发布订阅)
根据上图的订阅和发布可以发现,这里channel
使用的是.
分割,而不是像key
使用:
分割
PSUBSCRIPT pattern[pattern]
表示订阅与pattern格式匹配的所有频道,即pattern是通配符,下图是通配符规则:
示例:开启三个redis控制台,一个作为生产者,两个为消费者
消费者1、2使用两种方式订阅了channel,由于此时生产者还没有生产数据,所以1,2会被阻塞。
生产者向order.q1
通道发送了消息hello
,1,2消费者都收到了消息hello
生产者向order.q2
通道发送了消息hello2
,只有消费者2都收到了消息hello2
,因为消费者1没有订阅该通道。
总结:
不支持数据持久化:当发布者发布消失后,如果没有人订阅,则会自动消失,类似于广播。
stream 写消息:XADD
大写字母为关键字,必须写不然会报错
XADD message * name mfx age 12
stream读消息: XREAD
// 大写字母为关键字,必须写不然会报错
XREAD COUNT 1 BLOCK 200 STREAMS message $
steams的消息读取后不会删除。
通过block实现阻塞读取消息
示例:
1)两个消费者读取最新消息,此时还没有写入消息,所以等待
2)写入两条消息,此时两个消费者读到两条最新消息
注意:如果在阻塞时间内没有读到消息,就会返回空。
存在问题:漏读消息
确认消息
消费者组的特点是消息读取后会存放在pending-list队列中,我们需要对其确认才完成了消息的处理。如果确认时出现异常,则消息还会存放在pending-list中,再次确认即可处理异常。
读取pending-list中的未确认消息
如果程序正常执行,pending-list是没有消息的,如果出现异常,pending-list中的消息就是执行异常的消息。
读取pending-list的消息时,就不需要阻塞Block字段了
消费者组消息队列处理消息操作流程:
1)先使用>
表示ID来获取未读取的消息,读取后,对其确认。
由于消费组的第二条特性消息标识,当确认过程中如果出现异常(redis宕机),会记录最后一个处理的消息。
如果程序正常执行,pending-list最终是没有消息的(都被正常确认),如果出现异常,pending-list中的消息(未被正常确认)就是执行异常的消息。
2)使用0
表示ID来获取pending-list中未确认的第一条消息(出现异常时的消息),对其进行确认。该步骤可以理解为处理异常。
3)通过上图的2,3特点可以实现消息的成功处理。
所以在实际代码中,1)是写在try中,2)是写在catch中,这样就可以保证所有消息都能处理成功。
java中操作基于消费者组的消息队列(伪代码)
下图代码中的1,2表示正常情况,3,4,5表示异常处理情况。
回溯是指Consumer已经消费成功的消息,或者之前消费业务逻辑有问题,现在需要重新消费。在这里对应strema中消息确认时错误,可以回溯到出错的消息再次确认。
命令行创建消息队列和消费者组
g1为消费者组名;
stream.orders为消息队列名;
id=0表示从消息队列第一个未读消息开始处理;
MKSTREAM表示如果消息队列不存在则自动创建
XGROUP CREATE stream.orders g1 0 MKSTREAM
修改先前秒杀lua脚本
lua脚本在判断用户有资格下单时,直接向消息队列stream.orders中添加信息,内容包括:秒杀券id,订单id,用户id.
下面代码只添加两处:
1)传入订单id
2)将秒杀券id,订单id,用户id.写入消息队列,且订单id的key使用‘id’,方便l数据库存储
-- 1.参数列表
-- 1.1.优惠券id
local voucherId = ARGV[1]
-- 1.2.用户id
local userId = ARGV[2]
-- 1.3 订单id
local orderId = ARGV[3]
-- 2.数据key
-- 2.1.库存key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2.订单key
local orderKey = 'seckill:order:' .. voucherId
-- 3.脚本业务
-- 3.1.判断库存是否充足 get stockKey
if(tonumber(redis.call('get', stockKey)) <= 0) then
-- 3.2.库存不足,返回1
return 1
end
-- 3.2.判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember', orderKey, userId) == 1) then
-- 3.3.存在,说明是重复下单,返回2
return 2
end
-- 3.4.扣库存 incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 3.5.下单(保存用户)sadd orderKey userId
redis.call('sadd', orderKey, userId)
-- 4、将voucherId、orderId、userId发送到stream消息队列中
-- XADD 队列名 id 若干 k,v
-- 存储订单id时,key采用id,方便后续转换为订单类存入数据库
redis.call('XADD', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)
return 0
修改主线程判断下单资格代码
判断下单资格的代码更加简介了。
@Override
//秒杀券下单,判断是否具有下单资格(数据库操作使用stream消息队列,异步执行)
public Result seckillVoucher(Long voucherId){
// 获取用户ID
Long userId = UserHolder.getUser().getId();
// 订单id--redisIdWorker工具类生成唯一ID
long orderId = redisIdWorker.getNextId("order");
// 1、通过执行lua脚本判断是否由资格购买(库存和一人一单,并加入到消息队列)
Long res = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString(), String.valueOf(orderId)
);
// 2、判断脚本结果res是否为0
// 2.1 res = 0 表示有资格,具体见lua脚本
if(res != 0){
return Result.fail(res == 1 ? "库存不足" : "不能重复下单");
}
// 2.2 res = 0,有资格下单,接下来由消息队列生成订单和减库存此操作
// lua脚本已经将用户id订单id优惠券id存入到消息队列
// 在项目启动时,创建一个线程任务,获取消息队列中的任务,执行
// 3 获取代理对象
proxy = (IVoucherOrderService)AopContext.currentProxy();
// 4 返回订单id
return Result.ok(orderId);
}
异步下单(库存减,订单加)代码
修改线程任务代码:
这里的业务逻辑就是5.4小节的总结部分
// 线程任务(内部类实现)
private class VoucherOrderHandler implements Runnable{
String queueName = "stream.orders";
// 用于执行我们异步下单需要执行的操作(操作数据库)
@Override
public void run() {
while(true){
try{
// 1.获取消息队列中的订单信息
// XREADGROUP GROUP 组名 消费者名 COUNT 1 BLOCK 2000 STREAMS stream.orders >
// 下面的Consumer、StreamReadOptions、StreamOffset都是springframe包提供
// map中string为消息ID,后面两项为k-v键值对
// stream.orders队列和g1消息组已经提前创建好,
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
StreamOffset.create(queueName,ReadOffset.lastConsumed())
);
// 2. 判断消息是否获取成功
// 2.1 获取失败则说明没有消息,继续下一次循环
if(list == null || list.isEmpty())
continue;
// 2.2 获取成功,解析消息-->取一条消息
MapRecord<String, Object, Object> record = list.get(0);
// 2.3 map中包含userId、voucherId、订单id
Map<Object, Object> value = record.getValue();
// 3 创建订单
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
// 4 下单,操作数据库,减库存,写订单
handleVoucherOrder(voucherOrder);
// 5. ACK确认 SACK stream.orders g1 消息id
stringRedisTemplate.opsForStream().acknowledge(queueName, "g1", record.getId());
}catch (Exception e){
// 再次处理pending-list中异常的消息
handlePendingList();
}
}
}
private void handlePendingList(){
while (true){
try{
// 1 读取pending-list中的消息
// XREADGROUP GROUP 组名 消费者名 COUNT 1 STREAMS 队列名 0
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1),
StreamOffset.create(queueName, ReadOffset.from("0"))
);
// 2 若pending-list中的消息为空,说明没有异常,直接break结束循环
if(list == null || list.isEmpty())
// 如果为null,说明没有异常消息,结束循环
break;
// 3 获取队列中的一条异常消息
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> value = record.getValue();
// 4 生成订单
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
// 5 操作数据库,库存减,订单加
handleVoucherOrder(voucherOrder);
// 6 确认该消息
stringRedisTemplate.opsForStream().acknowledge(queueName, "g1", record.getId());
}catch (Exception e){
log.error("处理pending-list中的消息异常");
try{
// 休眠20ms继续循环处理异常消息
Thread.sleep(20);
} catch (InterruptedException interruptedException) {
interruptedException.printStackTrace();
}
}
}
}
// 创建订单
private void handleVoucherOrder(VoucherOrder voucherOrder){
// 操作数据库,库存减,订单加
//注意:由于是spring的事务是放在threadLocal中,此时的是多线程,事务会失效
// 即在多线程内不能获取代理,所以我们在主线程内获取代理对象,并将其作为类变量使用
proxy.createVoucherOrder(voucherOrder);
}
}
测试
1)清空数据订单表信息
2)redis已经提前设置了id为6的优惠券库存和stream消息队列。
id为6的订单当前库存为99
3)postman对id为6的优惠券下单
查看数据库和redis是否已经有订单信息
stream消息队列中存储了订单信息
该信息已经确认
再次下单失败
总结
秒杀下单学习过程
1)超卖问题,使用库存作为乐观锁解决
2)一人一单,使用synchronized关键字对用户id上锁解决
3)使用redis自定义锁解决一人一单问题
4)使用Redisson提供的可重入锁解决一人一单问题
5)使用jvm提供的阻塞队列实现异步秒杀下单
6)使用stream消息队列实现异步秒杀下单
基于stream消息队列的异步秒杀流程图
完整代码
@Service
@Slf4j
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private RedissonClient redissonClient;
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
SECKILL_SCRIPT = new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}
// 创建线程池,用于异步下单操作数据库
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
// 在类初始化之后执行线程任务
// 因为当这个类初始化好了之后,随时都是有可能要执行的
@PostConstruct
private void init() {
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
// 线程任务(内部类实现)
private class VoucherOrderHandler implements Runnable{
String queueName = "stream.orders";
// 用于执行我们异步下单需要执行的操作(操作数据库)
@Override
public void run() {
while(true){
try{
// 1.获取消息队列中的订单信息
// XREADGROUP GROUP 组名 消费者名 COUNT 1 BLOCK 2000 STREAMS stream.orders >
// 下面的Consumer、StreamReadOptions、StreamOffset都是springframe包提供
// map中string为消息ID,后面两项为k-v键值对
// stream.orders队列和g1消息组已经提前创建好,
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
StreamOffset.create(queueName,ReadOffset.lastConsumed())
);
// 2. 判断消息是否获取成功
// 2.1 获取失败则说明没有消息,继续下一次循环
if(list == null || list.isEmpty())
continue;
// 2.2 获取成功,解析消息-->取一条消息
MapRecord<String, Object, Object> record = list.get(0);
// 2.3 map中包含userId、voucherId、订单id
Map<Object, Object> value = record.getValue();
// 3 创建订单
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
// 4 下单,操作数据库,减库存,写订单
handleVoucherOrder(voucherOrder);
// 5. ACK确认 SACK stream.orders g1 消息id
stringRedisTemplate.opsForStream().acknowledge(queueName, "g1", record.getId());
}catch (Exception e){
// 再次处理pending-list中异常的消息
handlePendingList();
}
}
}
private void handlePendingList(){
while (true){
try{
// 1 读取pending-list中的消息
// XREADGROUP GROUP 组名 消费者名 COUNT 1 STREAMS 队列名 0
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1),
StreamOffset.create(queueName, ReadOffset.from("0"))
);
// 2 若pending-list中的消息为空,说明没有异常,直接break结束循环
if(list == null || list.isEmpty())
// 如果为null,说明没有异常消息,结束循环
break;
// 3 获取队列中的一条异常消息
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> value = record.getValue();
// 4 生成订单
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
// 5 操作数据库,库存减,订单加
handleVoucherOrder(voucherOrder);
// 6 确认该消息
stringRedisTemplate.opsForStream().acknowledge(queueName, "g1", record.getId());
}catch (Exception e){
log.error("处理pending-list中的消息异常");
try{
// 休眠20ms继续循环处理异常消息
Thread.sleep(20);
} catch (InterruptedException interruptedException) {
interruptedException.printStackTrace();
}
}
}
}
// 创建订单
private void handleVoucherOrder(VoucherOrder voucherOrder){
// 操作数据库,库存减,订单加
//注意:由于是spring的事务是放在threadLocal中,此时的是多线程,事务会失效
// 即在多线程内不能获取代理,所以我们在主线程内获取代理对象,并将其作为类变量使用
proxy.createVoucherOrder(voucherOrder);
}
}
private IVoucherOrderService proxy;
@Override
//秒杀券下单,判断是否具有下单资格(数据库操作使用stream消息队列,异步执行)
public Result seckillVoucher(Long voucherId){
// 获取用户ID
Long userId = UserHolder.getUser().getId();
// 订单id--redisIdWorker工具类生成唯一ID
long orderId = redisIdWorker.getNextId("order");
// 1、通过执行lua脚本判断是否由资格购买(库存和一人一单,并加入到消息队列)
Long res = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString(), String.valueOf(orderId)
);
// 2、判断脚本结果res是否为0
// 2.1 res = 0 表示有资格,具体见lua脚本
if(res != 0){
return Result.fail(res == 1 ? "库存不足" : "不能重复下单");
}
// 2.2 res = 0,有资格下单,接下来由消息队列生成订单和减库存此操作
// lua脚本已经将用户id订单id优惠券id存入到消息队列
// 在项目启动时,创建一个线程任务,获取消息队列中的任务,执行
// 3 获取代理对象
proxy = (IVoucherOrderService)AopContext.currentProxy();
// 4 返回订单id
return Result.ok(orderId);
}
// 一人一单:减库存和写入订单信息
@Transactional // 该接口涉及两张表,需要事务
public Result createVoucherOrder(VoucherOrder voucherOrder){
// 该函数是在多线程中调用,所以不能在本地线程中获取用户id
// 所以从订单信息中获取用户id
// 1、获取用户id
Long userId = voucherOrder.getId();
// 2、获取优惠券id
Long voucherId = voucherOrder.getVoucherId();
// 3、用户第一次下单则扣减库存
seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
// 4、订单信息写入数据库
save(voucherOrder);
// 6、返回订单编号
return Result.ok(voucherOrder.getId());
}
}
用户可以对别人发表的探店笔记点赞,并且探店笔记下方会显示前五名点赞的用户。
方法:sortedSet实现点赞排行,它的集合特性又可以实现一人只能点一次赞的要求,当用户再次点赞时会取消点赞。通过sortedset的zscore可以判断用户是在集合中(sortedset没有ismember函数)
key: 关键字+blog id
value:用户id
score: 时间戳
点赞接口
@PutMapping("/like/{id}")
public Result likeBlog(@PathVariable("id") Long id) {
// 修改点赞数量: 点赞数量+1
return blogService.likeBlog(id);
}
业务方法
// 点赞功能
@Override
public Result likeBlog(Long id) {
String key = RedisConstants.BLOG_LIKED_KEY + id;
Long userId = UserHolder.getUser().getId();
// 1、set判断当前用户是否已经点赞
// 通过判断用户id在集合中是否有值确定该用户是否点过赞
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
if(score == null || score.isNaN()){
// 第一次点赞则点赞数+1
boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
// 存入redis
if(isSuccess)
stringRedisTemplate.opsForZSet().add(key, userId.toString(), System.currentTimeMillis());
}else{
// 已经点过赞,再次点赞则点赞数-1
boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
if(isSuccess)
// 取消点赞则清除缓存中的信息项
stringRedisTemplate.opsForZSet().remove(key, userId.toString());
}
return Result.ok();
}
当点击成功后,redis中已经有了信息,后面的点赞排行榜使用score进行排序获得。
接口类
// 获取点赞人数(点赞排行榜)
@GetMapping("/likes/{id}")
public Result queryBlogLikes(@PathVariable("id") Long id){
return blogService.queryBlogLikes(id);
}
业务代码
// 点赞排行榜
@Override
public Result queryBlogLikes(Long id) {
String key = RedisConstants.BLOG_LIKED_KEY + id;
// 1、redis中sortedset查询前五名点赞的用户
Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 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());
String idStr = StrUtil.join(",", ids);
// 3、查询用户
userService.query()
.in("id", ids)
.last("ORDER BY FIELD(id,"+idStr+")").list();
List<User> users = userService.listByIds(ids);
List<UserDTO> userDTOS = BeanUtil.copyToList(users, UserDTO.class);
return Result.ok(userDTOS);
}
细节:比如我们按照时间在redis中查到的用户id是5,1即5先点赞,1后点赞,那mybatis提供的selectlist能按照顺序查出来吗。
eg:先查5再查1
select * from tb_user where id in (5,1)
发现1在前,5在后,这就不符合我们按照时间排序的点赞排行榜要求。
需要修改sql
使用order by field
select * from tb_user where id in (5,1) ORDER BY FIELD(id,5,1)
对应mybatis代码
// 2、解析出用户id
List<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList());
String idStr = StrUtil.join(",", ids);
userService.query()
.in("id", ids)
.last("ORDER BY FIELD(id,"+idStr+")").list();
为了实现共同关注,将用户关注的用户id存储在redis中的set
key:用户id。value:被关注的用过户id
用户在关注的同时将信息存储到redis中
关注取关接口
@Resource
IFollowService followService;
@PutMapping("/{id}/{isFollow}")
public Result follow(@PathVariable("id") Long followUserId, @PathVariable("isFollow") boolean isFollow){
return followService.follow(followUserId, isFollow);
}
// 关注
@Override
public Result follow(Long followUserId, boolean isFollow) {
// 1.获取当前用户id
UserDTO user = UserHolder.getUser();
if(user == null)
return Result.fail("请先登录");
Long id = user.getId();
if(followUserId == null)
return Result.ok();
String key = "follows:" + id;
// 2.1 isFollow为true表示关注
if(isFollow){
Follow follow = new Follow();
follow.setFollowUserId(followUserId);
follow.setUserId(id);
int insert = baseMapper.insert(follow);
if(insert <= 0)
return Result.fail("关注失败");
}else{
// 2.2 isFollow为false表示取消关注
QueryWrapper<Follow> wrapper = new QueryWrapper<>();
wrapper.eq("user_id", id);
wrapper.eq("follow_user_id", followUserId);
int delete = baseMapper.delete(wrapper);
if(delete <= 0)
return Result.fail("取消关注失败");
// 取消关注则删除redis中相应id
stringRedisTemplate.opsForSet().remove(key, followUserId.toString());
}
// 关注成功,存入set
stringRedisTemplate.opsForSet().add(key, followUserId.toString());
return Result.ok();
}
用户1010关注了用户2
点击用户头像,可以进入用户主页,然后查看共同关注的好友。
通过set的交集实现共同关注功能
// 共同关注好友
@GetMapping("/common/{id}")
public Result common(@PathVariable("id") Long id){
return followService.common(id);
}
@Override
public Result common(Long id) {
Long userId = UserHolder.getUser().getId();
// 当前用户对应set key
String key1 = "follows:" + userId;
// 查看的用户对应set key
String key2 = "follows:" + id;
// 查看二者的共同关注
Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key1, key2);
// 没有共同关注
if(intersect == null || intersect.isEmpty())
return Result.fail("没有共同关注");
ArrayList<Long> ids = new ArrayList<>();
for(String userIds : intersect)
ids.add(Long.valueOf(userIds));
List<User> userList = userService.listByIds(ids);
List<UserDTO> userDTOS = BeanUtil.copyToList(userList, UserDTO.class);
return Result.ok(userDTOS);
}
在这里用户1010,1011都关注了用户2,且用户1011还关注了1010,如果1011访问1010主页,就可以看到他们的共同关注用户2。
当我们关注了用户后,这个用户发了动态,那么我们应该把这些数据推送给用户,这个需求,其实我们又把他叫做 Feed 流,关注推送也叫做 Feed 流,直译为投喂。为用户持续的提供 “沉浸式” 的体验,通过无限下拉刷新获取新的信息。
对于传统的模式的内容解锁:我们是需要用户去通过搜索引擎或者是其他的方式去解锁想要看的内容
对于新型的 Feed 流的的效果:不需要我们用户再去推送信息,而是系统分析用户到底想要什么,然后直接把内容推送给用户,从而使用户能够更加的节约时间,不用主动去寻找。
Feed流模式
我们本次针对好友的操作,采用的就是 Timeline 的方式,只需要拿到我们关注用户的信息,然后按照时间排序即可,因此采用 Timeline 的模式。该模式的实现方案有三种:
拉模式:也叫做读扩散
该模式的核心含义就是:当张三和李四和王五发了消息后,都会保存在自己的邮箱中,假设赵六要读取信息,那么他会从读取他自己的收件箱,此时系统会从他关注的人群中,把他关注人的信息全部都进行拉取,然后在进行排序
优点:比较节约空间,因为赵六在读信息时,并没有重复读取,而且读取完之后可以把他的收件箱进行清理。
缺点:比较延迟,当用户读取数据时才去关注的人里边去读取数据,假设用户关注了大量的用户,那么此时就会拉取海量的内容,对服务器压力巨大
推模式是没有写邮箱的,当张三写了一个内容,此时会主动的把张三写的内容发送到他的粉丝收件箱中去,假设此时李四再来读取,就不用再去临时拉取了
优点:时效快,不用临时拉取
缺点:内存压力大,假设一个大 V 写信息,很多人关注他, 就会写很多分数据到粉丝那边去
推拉结合模式:也叫做读写混合,兼具推和拉两种模式的优点。
推拉模式是一个折中的方案,站在发件人这一段,如果是个普通的人
,那么我们采用写扩散
的方式,直接把数据写入到他的粉丝中去,因为普通的人他的粉丝关注量比较小,所以这样做没有压力,如果是大 V
,那么他是直接将数据先写入到一份到发件箱
里边去,然后再直接写一份到活跃粉丝收件箱里边去
。现在站在收件人这端来看,如果是活跃粉丝
,那么大 V 和普通的人发的都会直接写入到自己收件箱里边来,而如果是普通的粉丝
,由于他们上线不是很频繁,所以等他们上线时,再从发件箱里边去拉信息。
传统了分页在 feed 流是不适用的,因为我们的数据会随时发生变化。
假设在 t1 时刻,我们去读取第一页,此时 page = 1 ,size = 5 ,那么我们拿到的就是 10-6 这几条记录,假设现在 t2 时候又发布了一条记录,此时 t3 时刻,我们来读取第二页,读取第二页传入的参数是 page=2 ,size=5 ,那么此时读取到的第二页实际上是从 6 开始,然后是 6 ,那么我们就读取到了重复的数据,所以 feed 流的分页,不能采用原始方案来做。
我们需要记录每次操作的最后一条,然后从这个位置开始去读取数据
举个例子:我们从 t1 时刻开始,拿第一页数据,拿到了 10~6,然后记录下当前最后一次拿取的记录,就是 6,t2 时刻发布了新的记录,此时这个 11 放到最顶上,但是不会影响我们之前记录的 6,此时 t3 时刻来拿第二页,第二页这个时候拿数据,还是从 6 后一点的 5 去拿,就拿到了 5-1 的记录。我们这个地方可以采用 sortedSet
来做,可以进行范围查询,并且还可以记录当前获取数据时间戳最小值,就可以实现滚动分页了
所以实现feed的核心的意思就是我们在保存完探店笔记后,获得到当前用户的粉丝,然后把数据推送到粉丝的 redis 中去。
推送消息分两步:
1、数据库查询该用户的粉丝。
2、redis每个用户建立一个sortedset收件箱,发布笔记的用户像粉丝收件箱中推送消息。key为关键字+用户id,value为笔记id,score为当前时间。
// 用于获取当前系统时间,以毫秒为单位
System.currentTimeMillis()
发布笔记接口
@PostMapping
public Result saveBlog(@RequestBody Blog blog) {
// 返回blog id
return blogService.saveBlog(blog);
}
// 发布笔记的同时将笔记id推送给粉丝
@Override
public Result saveBlog(Blog blog) {
// 1、获取当前用户id
Long id = UserHolder.getUser().getId();
// 2、保存笔记
boolean save = save(blog);
if(!save)
return Result.fail("保存笔记失败");
// 3、获取当前用户的粉丝id列表
List<Follow> list = followService.query().eq("follow_user_id", id).list();
// 4、将blog推送给粉丝
Long blogId = blog.getId();
for(Follow follow : list){
// 4.1 粉丝id
Long userId = follow.getUserId();
String key = RedisConstants.FEED_KEY + userId;
stringRedisTemplate.opsForZSet().add(key, blogId.toString(), System.currentTimeMillis());
}
return Result.ok(blogId);
}
测试:
1011用户关注了1010,那么1010发布笔记后,会推送到1011的收件箱。
需求:在个人主页的 “关注” 卡片中,查询并展示推送的 Blog 信息:
具体操作如下:
1、每次查询完成后,我们要分析出查询出数据的最小时间戳,这个值会作为下一次查询的条件
2、我们需要找到与上一次查询相同的查询个数作为偏移量,下次查询时,跳过这些查询过的数据,拿到我们需要的数据
综上:我们的请求参数中就需要携带 lastId:上一次查询的最小时间戳 和偏移量这两个参数。
这两个参数第一次会由前端来指定,以后的查询就根据后台结果作为条件,再次传递到后台。
定义出来具体的返回值实体类
@Data
public class ScrollResult {
private List<?> list;
private Long minTime;
private Integer offset;
}
分页查询
sortedset分页查询,每次查两条数据
ZREVRANGEBYSCORE key Max Min LIMIT offset count
// 接收关注博主发布的笔记
public Result queryBlogOfFollow(Long max, Integer offset) {
// 1 获取用户id
Long id = UserHolder.getUser().getId();
String key = RedisConstants.FEED_KEY + id;
// 2.查询收件箱 ZREVRANGEBYSCORE key Max Min LIMIT offset count
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
List<Long> ids = new ArrayList<>(typedTuples.size());
long minTime = 0; // 新时间戳
int os = 1; // 新offset
for(ZSetOperations.TypedTuple<String> tuple : typedTuples){
// 4.1.获取id
ids.add(Long.valueOf(tuple.getValue()));
// 4.2.获取分数(时间戳)
long time = tuple.getScore().longValue();
// 4.3.1 score(时间戳)相同, 则os(偏移量)加1
if(time == minTime){
os++;
}else{
// 4.3.2 score不同
minTime = time;
os = 1;
}
}
os = minTime == max ? os : os + offset;
// 5 查询笔记
String idStr = StrUtil.join(",", ids);
List<Blog> blogs = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();
ScrollResult scrollResult = new ScrollResult();
scrollResult.setList(blogs);
scrollResult.setOffset(os);
scrollResult.setMinTime(minTime);
return Result.ok(scrollResult);
}
Redis GEO 主要用于存储地理位置信息,并对存储的信息进行操作,
Redis GEO 操作方法有:
geoadd:添加地理位置的坐标。
geopos:获取地理位置的坐标。
geodist:计算两个位置之间的距离。
georadius:根据用户给定的经纬度坐标来获取指定范围内的地理位置集合。
georadiusbymember:根据储存在位置集合里面的某个地点获取指定范围内的地理位置集合。
geohash:返回一个或多个位置对象的 geohash 值。
注意:视频中由redissearch命令,但是本人使用的是5.0,没有该命令。6.0有redissearch命令,建议使用georadius或georadiusbymember代替
添加三个坐标点
GEOADD添加命令如下
GEOADD key longitude latitude member [longitude latitude member ...]
geoadd g1 116.378 39.865 beijingn 116.428 39.903 beijingz 116.322 39.893 beijingx
查询添加结果发现GEO的底层是ZSET,也就是sortedset。
计算两个坐标的距离
GEODIST key member1 member2 [unit]
,unit是返回值的格式:m、km、ft、mi。默认单位是m。
搜索天安门10km内的火车站(附近的人)
georadius
当我们点击美食之后,会出现一系列的商家,商家中可以按照多种排序方式,我们此时关注的是距离,这个地方就需要使用到我们的 GEO,向后台传入当前 app 收集的地址 (我们此处是写死的) ,以当前坐标作为圆心,同时绑定相同的店家类型 type,以及分页信息,把这几个条件传入后台,后台查询出对应的数据再返回。
数据库表中店铺信息
我们要做的事情是:将数据库表中的数据导入到 redis 中去,redis 中的 GEO,GEO 在 redis 中就一个 member 和一个经纬度,我们把 x 和 y 轴传入到 redis 做的经纬度位置去,但我们不能把所有的数据都放入到 menber 中去,毕竟作为 redis 是一个内存级数据库,如果存海量数据,redis 还是力不从心,所以我们在这个地方存储他的 id 即可。
但是这个时候还有一个问题,就是在 redis 中并没有存储店铺类型 type,所以我们无法根据 type 来对数据进行筛选,所以我们可以按照商户类型做分组,类型相同的商户作为同一组,以 typeId 为 key 存入同一个 GEO 集合中即可
所以我们要提前将数据库中的店铺地理位置信息导入redis
使用单元测试完成
GEOADD key longitude latitude member [longitude latitude member ...]
key: 前缀+typeId
member:shopId
经纬度:shop经纬度
java使用GeoLocation来封装一个点和点对应的member
@Test
public void loadShopData(){
// 1. key前缀:shop:geo:
String keyPrefix = RedisConstants.SHOP_GEO_KEY;
// 2. 查询所有店铺信息
List<Shop> list = shopService.list();
// 3. 把店铺分组,按照typeId分组,typeId一致的放到一个集合
Map<Long, List<Shop>> map = new HashMap<>();
map = list.stream().collect(Collectors.groupingBy(shop -> shop.getTypeId()));
// 4. 分批存入redis
for(Map.Entry<Long, List<Shop>> entry : map.entrySet()){
// 4.1 获取typeId
Long typeId = entry.getKey();
List<Shop> shops = entry.getValue();
String key = keyPrefix + typeId;
// 4.2 将成员存经纬度,id封装为一个集合
List<RedisGeoCommands.GeoLocation<String>> locations= new ArrayList<>();
for(Shop shop : shops){
Point point = new Point(shop.getX(), shop.getY());
RedisGeoCommands.GeoLocation<String> location =
new RedisGeoCommands.GeoLocation<>(shop.getId().toString(), point);
locations.add(location);
}
// 4.3 存入redis
stringRedisTemplate.opsForGeo().add(key, locations);
}
}
测试用例结果
@GetMapping("/of/type")
public Result queryShopByType(
@RequestParam("typeId") Integer typeId,
@RequestParam(value = "current", defaultValue = "1") Integer current,
@RequestParam(value = "x", required = false) Double x,
@RequestParam(value = "y", required = false) Double y
) {
return shopService.queryShopByType(typeId, current, x, y);
}
流程:
判断有无经纬度坐标,没有直接普通分页查询数据库结束
计算分页参数,即开始和结束页码:from-end
根据typeId店铺类型查询redis
使用georadius函数查询以某点为圆心5000m为半径的圆内的点
limit函数表示查询到第几条数据(从1开始),这里为end,即查询1-end数据
如果查询结果为空,借宿
获取查询结果的list表示。如果list.size()<=from,则表示已经到最后一页了,直接返回一个空集合。
上面查询出的数据是1-end,不是from-end,所以要去截取from-end,使用stream.skip()。
遍历from-end数据
(1)解析出shopId
(2)解析出距离distance
通过解析出的shopid列表查询数据库,使用order by field自定义排序方式
为shop类的distance字段赋值
返回shop列表
@Override
public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) {
// 1. 如果不是按照范围查找,则直接分页查
if(x == null || y == null){
Page<Shop> shops = query().eq("type_id", typeId)
.page(new Page<Shop>(current, SystemConstants.DEFAULT_PAGE_SIZE));
return Result.ok(shops.getRecords());
}
// 2. 计算分页参数
int from = (current - 1) * SystemConstants.DEFAULT_PAGE_SIZE;
// end = from + page_size
int end = current * SystemConstants.DEFAULT_PAGE_SIZE;
// 3. 根据typeId查询redis,获取shopId
String key = RedisConstants.SHOP_GEO_KEY + typeId;
Point point = new Point(x, y);
// 查询key内在以point为圆心5000m为半径的园内的点,
// 第三个参数includeDistance表示返回距离, limit表示查询到第几条条数据(从1开始)
// 查询出的数据是0-end,不是from-end
GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo().radius(key,
new Circle(point, 5000),
RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs().includeDistance().limit(end));
// 4. 解析出shopid
if(results == null)
return Result.ok();
// 4.1. 获取结果集合
List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();
// 上面 查询出的数据是0-end,不是from-end,所以要去截取from-end
if(list.size() <= from)
return Result.ok(Collections.emptyList());
List<Long> ids = new ArrayList<>(list.size());
Map<String, Distance> distanceMap = new HashMap<>(list.size());
// 4.2 截取from-end,.skip(from)跳过前from条数据
list.stream().skip(from).forEach(result -> {
// 读取id
String shopId = result.getContent().getName();
ids.add(Long.valueOf(shopId));
// 读取店铺对应的距离
Distance distance = result.getDistance();
distanceMap.put(shopId, distance);
});
// 5.根据id查询Shop
String idStr = StrUtil.join(",", ids);
List<Shop> shops = query().in("id", ids).last("ORDER BY FIELD ( id," + idStr + ")").list();
// 6. 给shop的distance属性赋值
for(Shop shop : shops){
shop.setDistance(distanceMap.get(shop.getId().toString()).getValue());
}
return Result.ok(shops);
}
bitmap是一种数据结构,它是基于string数据类型实现的
Bitmap
向位图中存入值1(1表示某一天签到)
eg: 一周内只有周四、五没有签到
setbit bm1 0 1
setbit bm1 1 1
setbit bm1 2 1
setbit bm1 5 1
setbit bm1 6 1
查询某天是否签到
getbit bm1 0
通过位图中1的个数
bitcount bm1
对位图中指定位置元素进行增改查
bitfield,返回结果位十进制。
一般只用上面的GET做查询,添加在直接用前面的setbit。
type:表示读取几位,(这也是与getbit的区别,getbit只能读一位,bitfield可以读多位)。
type有两种类型:
(1)不带符号u。type=u2表示获取两位比特
(2)带符号i,type=i3表示获取三位比特
无论是否带符号,都要将读取的二进制转为十进制,u,i只是表示二进制第一位是否表示符号位。
offset: 表示从第几位开始读(偏移量)
eg: bm1 = 11100111(二进制)
// 从第0位开始获取2位,结果位带符号的十进制
bitfield bm1 get i2 0
结果为-1,对应有符号二进制11
// 从第2位开始获取2位,结果位带符号的十进制
bitfield bm1 get u2 2
结果为2,对应无符号二进制10
查找1,0出现的第一个位置
查找1出现的第一个位置
bitpos bm1 1
查找0出现的第一个位置
bitpos bm1 0
实现签到只需要使用到getbit,setbit命令
bitmap:
(1)key: 前缀+用户id+日期
(2)偏移量:本月第几天-1
(2)value: true表示签到
位图的下标是从0开始的,后天set时要对第几天-1
/ 签到
@PostMapping("/sign")
public Result sign(){
return userService.sign();
}
}
@Override
public Result sign() {
// 1. 获取用户id
Long id = UserHolder.getUser().getId();
// 2. 获取当前日期
LocalDateTime now = LocalDateTime.now();
// 3. 获取日期的年月格式
String format = now.format(DateTimeFormatter.ofPattern(":yyyy/MM"));
// 4. 获取当前是本月的第几天,
int dayOfMonth = now.getDayOfMonth();
// 5. 拼接redis key
String key = RedisConstants.USER_SIGN_KEY + id + format;
// 6. 存入redis,setbit的偏移量为dayOfMonth-1
stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);
return Result.ok();
}
postman测试
redis结果
(1)获取本月到今天为止的所有签到记录(bitfield),结果为十进制数
(2)对十进制数遍历,与1并右移。
今天是23号,所以命令如下:
bitfield key get u23 0
对应的java代码
List<Long> result = stringRedisTemplate.opsForValue().bitField(
key,
BitFieldSubCommands.create()
.get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0)
);
全部代码
// 获取连续签到天数
@Override
public Result signCount() {
// 1. 获取用户id
Long id = UserHolder.getUser().getId();
// 2. 获取当前日期
LocalDateTime now = LocalDateTime.now();
// 3. 获取日期的年月格式
String format = now.format(DateTimeFormatter.ofPattern(":yyyy/MM"));
// 4. 获取当前是本月的第几天,
int dayOfMonth = now.getDayOfMonth();
// 5. 拼接redis key
String key = RedisConstants.USER_SIGN_KEY + id + format;
// 6. 获取本月到目前为止所有签到记录
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 || num == null)
return Result.ok(0);
// 7.循环遍历
int count = 0;
while(true){
if((num & 1) == 0){
// 未签到结束
break;
}else{
count ++;
}
num = num >> 1;
}
return Result.ok(count);
}
先查看当前用户的签到记录
末尾为0是因为redis对二进制是按照字节操作的,所以不够八位时会补0。所以最后一个1代表今天的签到情况。根据下图可知,到目前为止,以连续签到两天。
PFADD: 添加元素(可同时添加多个)
PFCOUNT: 统计元素个数(有误差)
PFMERGE:合并多个key
HyperLogLog不会存储重复的数据,所以可以所UV统计
执行上面的测试用例之后的内存使用
程序输出,100万条数据成功了997593条误差率0.2%
总结:hyperloglog占用内存不会高于16kb,误差率也可以实现,可以满足对uv统计的需求。