一、登录案例
1、项目架构
2、业务流程
3、项目依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
<version>5.1.47</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.3</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.2.8</version>
</dependency>
4、配置参数
server:
port: 8081
spring:
application:
name: hmdp
datasource:
druid:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://localhost:3306/redis?useSSL=false&serverTimezone=UTC
username: root
password: 123456
redis:
host: localhost
port: 6379
lettuce:
pool:
max-active: 10
max-idle: 10
min-idle: 1
time-between-eviction-runs: 10s
jackson:
default-property-inclusion: non_null # JSON处理时忽略非空字段
mybatis-plus:
type-aliases-package: erer.redis.project.mapper # 别名扫描包
logging:
level:
erer.redis: debug
5、POJO
1. Entity
package erer.redis.project.entity;
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("tb_user")
public class User implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 主键
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 手机号码
*/
private String phone;
/**
* 密码,加密存储
*/
private String password;
/**
* 昵称,默认是随机字符
*/
private String nickName;
/**
* 用户头像
*/
private String icon = "";
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
}
2. DTO
- UserDTO
package erer.redis.project.dto;
@Data
public class UserDTO {
private Long id;
private String nickName;
private String icon;
}
- LoginFormDTO
package erer.redis.project.dto;
@Data
public class LoginFormDTO {
private String phone;
private String code;
private String password;
}
- Result
package erer.redis.project.dto;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result {
private Boolean success;
private String errorMsg;
private Object data;
private Long total;
public static Result ok(){
return new Result(true, null, null, null);
}
public static Result ok(Object data){
return new Result(true, null, data, null);
}
public static Result ok(List<?> data, Long total){
return new Result(true, null, data, total);
}
public static Result fail(String errorMsg){
return new Result(false, errorMsg, null, null);
}
}
6、Utils
1. UserHolder
package erer.redis.project.utils;
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. RedisConstants
package erer.redis.project.utils;
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 = 30L;
}
7、Mapper
package erer.redis.project.mapper;
@Mapper
public interface UserMapper extends BaseMapper<User> {
}
8、Service
1. 接口
package erer.redis.project.service;
public interface IUserService extends IService<User> {
// 发送验证码
Result sendCode(String phone, HttpSession session);
// 实现登录功能
Result login(LoginFormDTO loginForm, HttpSession session);
// 根据电话号码创建用户
User createUserWithPhone(String phone);
}
2. 实现类
package erer.redis.project.service.impl;
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result sendCode(String phone, HttpSession session) {
// 1.校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
// 如果不符合,返回错误信息
log.debug("手机号格式错误!");
return Result.fail("手机号格式错误!");
}
// 2.符合生成验证码
String code = RandomUtil.randomNumbers(6);
// 3.保存验证码到 redis
stringRedisTemplate.opsForValue().set(RedisConstants.LOGIN_CODE_KEY + phone, code, RedisConstants.LOGIN_CODE_TTL, TimeUnit.MINUTES);
// 4.发送验证码
log.debug("发送验证码成功,验证码:{" + code + "}");
return Result.ok();
}
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1.校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
// 如果不符合,返回错误信息
log.debug("手机号格式错误!");
return Result.fail("手机号格式错误!");
}
// 3.校验验证码,从 redis 获取
String cacheCode = stringRedisTemplate.opsForValue().get(RedisConstants.LOGIN_CODE_KEY + phone);
if (cacheCode == null || !cacheCode.equals(loginForm.getCode())) {
// 验证码错误,报错
return Result.fail("验证码错误!!!");
}
// 4.根据手机号查询用户
User user = query().eq("phone", phone).one();
// 5.判断用户是否存在
if (user == null) {
// 不存在,创建用户
user = createUserWithPhone(phone);
}
// 6. 保存用户信息(基本信息)到 redis 中
// 6.1 随机生成 token 值,作为登录令牌
String token = UUID.randomUUID().toString();
// 6.2 将 user 对象转换为 UserDTO
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
// 6.3 将 UserDTO 对象转换为 HashMap(setIgnoreNullValue(true):忽略空值;id 为 int 型,转换为 String 型)
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((fieldName, fieldValue)-> fieldValue.toString()));
// 6.4 储存
String tokenKey = RedisConstants.LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
// 6.5 设置有效期
stringRedisTemplate.expire(tokenKey, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
// 7 返回 token
return Result.ok(token);
}
public User createUserWithPhone(String phone) {
// 1.创建用户
User user = new User();
user.setPhone(phone);
user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
// 2.保存用户
save(user);
return user;
}
}
9、Controller
package erer.redis.project.controller;
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {
@Resource
private IUserService userService;
/**
* 发送手机验证码
*/
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
// 发送短信验证码并保存验证码
return userService.sendCode(phone, session);
}
/**
* 登录功能
*
* @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
*/
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session) {
// 实现登录功能
return userService.login(loginForm, session);
}
/**
* 登出功能
*
* @return 无
*/
@PostMapping("/logout")
public Result logout() {
// TODO 实现登出功能
return Result.fail("功能未完成");
}
@GetMapping("/me")
public Result me() {
// 获取当前登录的用户并返回
UserDTO user = UserHolder.getUser();
return Result.ok(user);
}
@GetMapping("/info/{id}")
public Result info(@PathVariable("id") Long userId) {
// 查询详情
UserInfo info = userInfoService.getById(userId);
if (info == null) {
// 没有详情,应该是第一次查看详情
return Result.ok();
}
info.setCreateTime(null);
info.setUpdateTime(null);
// 返回
return Result.ok(info);
}
}
10、Interceptor
1. token 刷新拦截器
package erer.redis.project.interceptor;
public class RefreshTokenInterceptor implements HandlerInterceptor {
private final StringRedisTemplate stringRedisTemplate;
public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
// 前置拦截器:token 刷新拦截器
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.获取 请求头 中的 token,并判断是否存在
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) {
return true;
}
// 2.基于 token 获取 redis 中的用户
String tokenKey = RedisConstants.LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(tokenKey);
// 3. 判断用户是否存在
if (userMap.isEmpty()) {
return true;
}
// 4.将查询到的 Hash 数据 转换为 UserDTO 对象 (false:不忽略异常)
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
// 5.用户存在,保存用户信息到 threadLocal 放行
UserHolder.saveUser(userDTO);
// 6.刷新 token 有效期
stringRedisTemplate.expire(tokenKey, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
// 7.放行
return true;
}
@Override
// 校验完毕,销毁信息,避免内存泄露。
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户
UserHolder.removeUser();
}
}
2. 登录校验
package erer.redis.project.interceptor;
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);
return false;
}
// 2.用户存在,放行
return true;
}
}
11、核心配置类
package erer.redis.project.config;
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
// 加载拦截器
public void addInterceptors (InterceptorRegistry registry) {
// token 刷新拦截器
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate))
.addPathPatterns("/**")
.order(0);
// 登录 拦截器
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/user/code",
"/user/login",
"/blog/hot",
"/shop/**",
"/shop-type/**",
"/upload/**",
"/voucher/**"
)
.order(1);
}
}
二、商户查询缓存
1、什么是缓存
- 缓存就是数据交换的缓冲区(称作Cache [ kæʃ ] ),是存贮数据的临时地方,一般读写性能较高。
2、缓存作用模型
package erer.redis.project.service.impl;
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
// 1.从 Redis 查询 商铺缓存
String shopKey = RedisConstants.CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(shopKey);
// 2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 存在直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 3.不存在根据 id 查询数据库
Shop shop = getById(id);
if (shop == null) {
// 不存在,返回错误
return Result.fail("商铺信息不存在!");
}
// 4.存在,写入 Redis,并设置超时时间
stringRedisTemplate.opsForValue().set(shopKey, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}
}
3、缓存更新策略
项目 | 内存淘汰 | 超时剔除 | 主动更新 |
---|---|---|---|
说明 | 内存不足时自动淘汰部分数据 | 缓存数据添加TTL时间,到期自动删除 | 修改数据库同时更新缓存。 |
一致性 | 差 | 一般 | 好 |
维护成本 | 无 | 低 | 高 |
- 业务场景:
- 低一致性需求:使用内存淘汰机制。例如店铺类型的查询缓存
- 高一致性需求:主动更新,并以超时剔除作为兜底方案。例如店铺详情查询的缓存
4、主动更新策略
- Cache Aside Pattern(最优解)
- 由缓存的调用者,在更新数据库的同时更新缓存
- Read/Write Through Pattern
- 缓存与数据库整合为一个服务,由服务来维护一致性。调用者调用该服务,无需关心缓存一致性问题。
- Write Behind Caching Pattern
- 调用者只操作缓存,由其它线程异步的将缓存数据持久化到数据库,保证最终一致。
1. Cache Aside Pattern
- 删除缓存还是更新缓存?
- 更新缓存:每次更新数据库都更新缓存,无效写操作较多
- 删除缓存:更新数据库时让缓存失效,查询时再更新缓存(√)
- 如何保证缓存与数据库的操作的同时成功或失败?
- 单体系统,将缓存与数据库操作放在一个事务
- 分布式系统,利用TCC等分布式事务方案
- 先操作缓存还是先操作数据库?
- 先删除缓存,再操作数据库
- 先操作数据库,再删除缓存(√)
2. 最佳实践方案
- 低一致性需求:使用Redis自带的内存淘汰机制
- 高一致性需求:主动更新,并以超时剔除作为兜底方案
- 读操作:
- 缓存命中则直接返回
- 缓存未命中则查询数据库,并写入缓存,设定超时时间
- 写操作:
- 先写数据库,然后再删除缓存
- 要确保数据库与缓存操作的原子性
- 读操作:
3. 主动更新策略
package erer.redis.project.service.impl;
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
// 开启事务
@Transactional
public Result update(Shop shop) {
// 1.判断 id 是否存在
Long id = shop.getId();
if (id == null) {
return Result.fail("店铺 id 不能为空。");
}
// 2.更新数据库
updateById(shop);
// 3.删除缓存
stringRedisTemplate.delete(RedisConstants.CACHE_SHOP_KEY + id);
return Result.ok();
}
}
5、缓存穿透
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
1. 缓存空对象
1.1 解决方案
1.2 优缺点
- 优点:实现简单,维护方便
- 缺点:
- 额外的内存消耗
- 可能造成短期的不一致
1.3 业务流程
1.4 代码实现
package erer.redis.project.service.impl;
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
// 1.从 Redis 查询 商铺缓存
String shopKey = RedisConstants.CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(shopKey);
// 2.判断店铺信息是否命中
if (StrUtil.isNotBlank(shopJson)) {
// 2.1 店铺信息命中直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
} else if (shopJson != null) {
// 2.2 店铺信息是空字符串
return Result.fail("店铺信息不存在!");
}
// 3.未命中根据 id 查询数据库
Shop shop = getById(id);
if (shop == null) {
// 3.1 将空值写入 Redis(缓存空对象解决缓存穿透)
stringRedisTemplate.opsForValue().set(shopKey, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
// 3.2 不存在,返回错误
return Result.fail("商铺信息不存在!!!");
} else {
// 3.3 存在,写入 Redis,并设置超时时间
stringRedisTemplate.opsForValue().set(shopKey, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}
}
}
2. 布隆过滤
2.1 解决方案
2.2 优缺点
- 优点:内存占用较少,没有多余key
- 缺点:
- 实现复杂
- 存在误判可能
3. 其他解决方案
- 增强id的复杂度,避免被猜测id规律
- 做好数据的基础格式校验
- 加强用户权限校验
- 做好热点参数的限流
6、缓存雪崩
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案:
- 给不同的 Key 的 TTL 添加随机值
- 利用 Redis 集群提高服务的可用性
- 给缓存业务添加降级限流策略
- 给业务添加多级缓存
7、缓存击穿
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
解决方案 | 优点 | 缺点 |
---|---|---|
互斥锁 | 没有额外的内存消耗;保证一致性;实现简单 | 线程需要等待;性能受影响;可能有死锁风险 |
逻辑过期 | 线程无需等待,性能较好 | 不保证一致性;有额外内存消耗;实现复杂 |
1. 互斥锁
1.1 解决方案
1.2 业务流程
1.3 代码实现
package erer.redis.project.service.impl;
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
return queryWithMutex(id);
}
// 获取锁
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
// 释放锁
private void unLock(String key) {
stringRedisTemplate.delete(key);
}
// 互斥锁解决缓存击穿
public Result queryWithMutex(Long id) {
// 1.从 Redis 查询 商铺缓存
String shopKey = RedisConstants.CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(shopKey);
// 2.判断店铺信息是否命中
if (StrUtil.isNotBlank(shopJson)) {
// 2.1 店铺信息命中直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
} else if (shopJson != null) {
// 2.2 店铺信息是空字符串 ""
return Result.fail("店铺信息不存在!");
}
// 3.实现缓存重建
// 3.1 获取互斥锁
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
try {
boolean isLock = tryLock(lockKey);
// 3.2 判断释放获取成功
if (!isLock) {
// 3.3 失败,休眠后重试
Thread.sleep(50);
return queryWithMutex(id);
}
// 4.未命中根据 id 查询数据库
Shop shop = getById(id);
// 模拟重建的延时
// Thread.sleep(5000);
if (shop == null) {
// 4.1 将空值写入 Redis
stringRedisTemplate.opsForValue().set(shopKey, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
// 4.2 不存在,返回错误
return Result.fail("商铺信息不存在!!!");
} else {
// 4.3 存在,写入 Redis,并设置超时时间
stringRedisTemplate.opsForValue().set(shopKey, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}
} catch (InterruptedException e) {
e.printStackTrace();
return Result.fail("商铺信息不存在!");
} finally {
// 5.释放互斥锁
unLock(lockKey);
}
}
}
2. 逻辑过期
2.1 解决方案
2.2 业务流程
2.3 创建RedisData
package erer.redis.project.utils;
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
2.4 代码实现
package erer.redis.project.service.impl;
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
// 缓存重建线程池
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
@Override
public Result queryById(Long id) {
// 逻辑过期解决缓存击穿问题
return queryWithLogicalExpire(id);
}
// 获取锁
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
// 释放锁
private void unLock(String key) {
stringRedisTemplate.delete(key);
}
// 逻辑过期解决缓存击穿
public Result queryWithLogicalExpire(Long id) {
// 1.从 Redis 查询 商铺缓存
String shopKey = RedisConstants.CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(shopKey);
// 2.判断店铺信息是否命中
if (StrUtil.isBlank(shopJson)) {
// 店铺信息未命中直接返回
return null;
}
// 3.命中,获取 json 发序列化对象(Shop对象及过期时间)
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
JSONObject data = (JSONObject) redisData.getData();
Shop shop = JSONUtil.toBean(data, Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 4.判断是否过期
if (!expireTime.isAfter(LocalDateTime.now())) {
// 过期,重建缓存
// 5.获取互斥锁
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 6.判断是否获取成功
if (isLock) {
// 6.2 获取成功,开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 查询店铺数据
Shop newShop = getById(id);
// 模拟重建的延时
Thread.sleep(5000);
// 封装逻辑过期时间
RedisData newRedisData = new RedisData();
newRedisData.setData(newShop);
newRedisData.setExpireTime(LocalDateTime.now().plusSeconds(20));
// 写入 Redis
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + shop.getId(), JSONUtil.toJsonStr(newRedisData));
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
unLock(lockKey);
}
});
}
}
return Result.ok(shop);
}
}
8、缓存工具封装
1. RedisData
package erer.redis.project.utils;
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
2. RedisCacheUtils
package erer.redis.project.utils;
@Slf4j
@Component
public class RedisCacheUtils {
private final StringRedisTemplate redisTemplate;
// 缓存重建线程池
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public RedisCacheUtils(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
/** 将任意Java对象序列化为json并存储在string类型的key中,并且可以设置TTL过期时间
* @param key:Redis 缓存 key
* @param value:Redis 缓存 value
* @param time:过期时间
* @param unit:时间单位
*/
public void set(String key, Object value, Long time, TimeUnit unit) {
redisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
}
/** 将任意Java对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题
* @param key:Redis 缓存 key
* @param value:Redis 缓存 value
* @param time:逻辑过期时间
* @param unit:时间单位
*/
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
redisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
/**
* 根据指定的 key 查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
* @param keyPrefix:RedisKey 前缀
* @param id:RedisKey id
* @param type:反序列化 json 的 对象类型
* @param dbFallBack:数据库查询语句
* @param time:Redis 缓存过期时间
* @param unit:时间单位
* @param <R>:返回值类型
* @param <ID>:id 类型
*/
public <R, ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallBack, Long time, TimeUnit unit) {
// 1.从 Redis 查询缓存
String key = keyPrefix + id;
String json = redisTemplate.opsForValue().get(key);
// 2.判断缓存信息是否命中
if (StrUtil.isNotBlank(json)) {
// 2.1 缓存信息命中直接返回
return JSONUtil.toBean(json, type);
} else if (json != null) {
// 2.2 缓存信息是空字符串(防止缓存穿透)
return null;
} else {
// 2.3 未命中根据 id 查询数据库
R r = dbFallBack.apply(id);
if (r == null) {
// 3 不存在,利用缓存空值的方式解决缓存穿透问题
redisTemplate.opsForValue().set(key, "", time, unit);
return null;
} else {
// 4 存在,写入 Redis,并设置超时时间
this.set(key, r, time, unit);
return r;
}
}
}
// 获取锁
private boolean tryLock(String key) {
Boolean flag = redisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
// 释放锁
private void unLock(String key) {
redisTemplate.delete(key);
}
/**
* 根据指定的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题
* @param keyPrefix:RedisKey 前缀
* @param id:RedisKey id
* @param type:反序列化 json 的 对象类型
* @param dbFallBack:数据库查询语句
* @param time:逻辑过期时间
* @param unit:时间单位
* @param <R>:返回值类型
* @param <ID>:id 类型
*/
public <R, ID> R queryWithLogicalExpire(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallBack, Long time, TimeUnit unit) {
// 1.从 Redis 查询 缓存
String key = keyPrefix + id;
String json = redisTemplate.opsForValue().get(key);
// 2.判断缓存信息是否命中
if (StrUtil.isBlank(json)) {
// 店铺信息未命中直接返回
return null;
}
// 3.命中,获取 json 发序列化对象(对象及过期时间)
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
JSONObject data = (JSONObject) redisData.getData();
R r = JSONUtil.toBean(data, type);
LocalDateTime expireTime = redisData.getExpireTime();
// 4.判断是否过期
if (expireTime.isBefore(LocalDateTime.now())) {
// 过期,重建缓存
// 5.获取互斥锁
String lockKey = "redis:cache:lock:" + id;
boolean isLock = tryLock(lockKey);
// 6.判断是否获取成功
if (isLock) {
// 获取成功,开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 查询数据库,获取最新数据
R newR = dbFallBack.apply(id);
// 写入 Redis
this.setWithLogicalExpire(key, newR, time, unit);
} catch (Exception e) {
e.printStackTrace();
} finally {
unLock(lockKey);
}
});
}
}
return r;
}
}
3. 使用工具
package erer.redis.project.service.impl;
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private RedisCacheUtils redisCacheUtils;
@Override
public Result queryById(Long id) {
// 缓存空对象解决缓存穿透
// Shop shop = redisCacheUtils.queryWithPassThrough(RedisConstants.CACHE_SHOP_KEY, id, Shop.class, this::getById, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 逻辑过期解决缓存击穿问题
Shop shop = redisCacheUtils.queryWithLogicalExpire(RedisConstants.CACHE_SHOP_KEY, id, Shop.class, this::getById, RedisConstants.LOCK_SHOP_TTL, TimeUnit.SECONDS);
return shop == null ? Result.fail("店铺不存在!") : Result.ok(shop);
}
}
三、优惠券秒杀
1、全局唯一ID
1. 面临问题
当用户抢购时,就会生成订单并保存到 数据库,而订单表如果使用数据库自增ID就存在一些问题:
- id的规律性太明显
- 受单表数据量的限制
2. ID生成器特性
- 唯一性
- 高可用
- 高性能
- 递增性
- 安全性
3. ID生成器组成
ID的组成部分:
- 符号位:1bit,永远为0
- 时间戳:31bit,以秒为单位,可以使用69年
- 序列号:32bit,秒内的计数器,支持每秒产生2^32个不同 ID
4. 代码实现
package erer.redis.project.utils;
@Component
public class RedisIdWorker {
/**
* 开始时间戳
*/
private static final long BEGIN_TIMESTAMP;
/**
* 序列号位数
*/
private static final int COUNT_BITS = 32;
@Resource
private StringRedisTemplate stringRedisTemplate;
static {
LocalDateTime time = LocalDateTime.of(2020, 1, 1, 0, 0, 0);
BEGIN_TIMESTAMP = time.toEpochSecond(ZoneOffset.UTC);
}
public long nextId(String keyPrefix) {
// 1.生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long timestamp = nowSecond - BEGIN_TIMESTAMP;
// 2.生成序列号
// 2.1 获取当前日期
String date = now.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
// 2.2 自增长
Long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
// 3.拼接返回 519767261305644692
return timestamp << COUNT_BITS | count;
}
}
5. 生成策略
- UUID
- Redis自增
- snowflake算法
- 数据库自增
2、优惠券秒杀下单
1. 业务流程
2. 代码实现
package erer.redis.project.service.impl;
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Override
// 开启事务
@Transactional
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("秒杀尚未开始!");
}
// 3.判断秒杀是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("秒杀已经结束!");
}
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
return Result.fail("库存不足!");
}
// 5.扣减库存
boolean flag = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.update();
if (!flag) {
return Result.fail("库存不足!");
}
// 6.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 订单 id
voucherOrder.setId(redisIdWorker.nextId("order"));
// 用户 id
voucherOrder.setUserId(UserHolder.getUser().getId());
// 代金券 id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7.返回订单id
return Result.ok(voucherOrder.getId());
}
}
3、超卖问题
1. 超卖原因
2. 问题解决
3. 乐观锁
3.1 版本号法
3.2 CAS法
- 代码实现
//扣减库存(gt("stock", 0):CAS法解决超卖问题)
boolean flag = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherId) // where id = voucherId
.gt("stock", 0) // where stock > 0
.update();
4、一人一单
1. 业务流程
2. 代码实现
package erer.redis.project.service.impl;
/**
* 乐观锁
*/
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Override
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("秒杀尚未开始!");
}
// 3.判断秒杀是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("秒杀已经结束!");
}
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
return Result.fail("库存不足!");
}
return creatVoucherOrder(voucherId);
}
// 开启事务
@Transactional
public Result creatVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
// 针对每个用户加锁
synchronized (userId.toString().intern()) {
// 5.一人一单
Integer count = query().eq("user_id", userId)
.eq("voucher_id", voucherId)
.count();
if (count > 0) {
return Result.fail("用户已购买一次。");
}
// 6.扣减库存(gt("stock", 0):CAS法解决超卖问题)
boolean flag = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherId) // where id = voucherId
.gt("stock", 0) // where stock > 0
.update();
if (!flag) {
return Result.fail("库存不足!");
}
// 7.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 订单 id
voucherOrder.setId(redisIdWorker.nextId("order"));
// 用户 id
voucherOrder.setUserId(userId);
// 代金券 id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7.返回订单id
return Result.ok(voucherOrder.getId());
}
}
}
3. 并发安全问题
集群模式下,synchronized 锁失效:
5、分布式锁
1. 分布式锁原理
2. 分布式锁特点
**分布式锁:**满足分布式系统或集群模式下多进程可见并且互斥的锁。
- 多进程可见
- 互斥
- 高可用
- 高性能(高并发)
- 安全性(死锁问题)
- 可重入性
- 公平锁
- …
3. 分布式锁实现
项目 | MySQL | Redis | Zookeeper |
---|---|---|---|
互斥 | 利用mysql本身的互斥锁机制 | 利用setnx这样的互斥命令 | 利用节点的唯一性和有序性实现互斥 |
高可用 | 好 | 好 | 好 |
高性能 | 一般 | 好 | 一般 |
安全性 | 断开连接,自动释放锁 | 利用锁超时时间,到期释放 | 临时节点,断开连接自动释放 |
6、Redis分布式锁
1. 实现方法
实现分布式锁时需要实现的两个基本方法:
- 获取锁
- 互斥:确保只能有一个线程获取锁
- 非阻塞:尝试一次,成功返回true,失败返回false
# 添加锁,NX是互斥、EX是设置超时时间
SET lock thread1 NX EX 10
- 释放锁
- 手动释放
- 超时释放:获取锁时添加一个超时时间
# 释放锁,删除即可
DEL key
2. 业务流程
3. 代码实现
3.1 创建锁对象
package erer.redis.project.lock.impl;
public class SimpleRedisLock implements ILock {
private static final String KEY_PREFIX = "simple:redis:lock:";
private StringRedisTemplate stringRedisTemplate;
private String name;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
this.name = name;
}
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标识
long threadId = Thread.currentThread().getId();
// 获取锁
Boolean flag = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(flag);
}
@Override
public void unlock() {
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
3.2 使用锁对象
package erer.redis.project.service.impl;
/**
* 解决问题:
* 分布式系统或集群模式下多进程导致 synchronized 失效。
*/
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private StringRedisTemplate stringRedisTemplate;
// 开启事务
@Transactional
public Result creatVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
// 创建锁对象
SimpleRedisLock redisLock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
// 尝试获取锁
boolean isLock = redisLock.tryLock(12000000);
// 判断是否获取成功
if (!isLock) {
// 获取锁失败,直接返回
return Result.fail("不允许重复下单!");
}
try {
// 5.一人一单
Integer count = query().eq("user_id", userId)
.eq("voucher_id", voucherId)
.count();
if (count > 0) {
return Result.fail("用户已购买一次。");
}
// 6.扣减库存(gt("stock", 0):CAS法解决超卖问题)
boolean flag = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherId) // where id = voucherId
.gt("stock", 0) // where stock > 0
.update();
if (!flag) {
return Result.fail("库存不足!");
}
// 7.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 订单 id
voucherOrder.setId(redisIdWorker.nextId("order"));
// 用户 id
voucherOrder.setUserId(userId);
// 代金券 id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7.返回订单id
return Result.ok(voucherOrder.getId());
} finally {
redisLock.unlock();
}
}
}
7、改进Redis分布式锁
1. 阻塞情况一
1.1 产生原因
- 线程1 获取锁,由于某种原因导致业务阻塞
- 业务阻塞时间过长,导致线程1超时释放锁
- 此时线程2 获取锁,执行业务
- 线程2 执行任务过程中,线程1 继续,完成业务
- 线程1 完成业务后 释放锁,此时释放的锁为 线程2 的锁
1.2 解决方案
- 在获取锁时存入线程标识(可以用UUID表示)
- 在释放锁时先获取锁中的线程标识,判断是否与当前线程标识一致
1.3 业务流程
1.4 代码实现
package erer.redis.project.lock.impl;
/**
* 改进Redis分布式锁:
* 1、业务阻塞时间过长,导致线程超时释放锁 问题
* 在获取锁时存入线程标识(可以用UUID表示),在释放锁时先获取锁中的线程标识,判断是否与当前线程标识一致
*/
public class SimpleRedisLock implements ILock {
private final StringRedisTemplate stringRedisTemplate;
private final String name;
private static final String KEY_PREFIX = "simple:redis:lock:";
private static final String ID_PREFIX = UUID.randomUUID() + ":";
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
this.name = name;
}
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标识
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁
Boolean flag = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(flag);
}
@Override
public void unlock() {
// 获取线程标识
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁中的标识
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
// 释放锁
if (threadId.equals(id)) {
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
}
2. 阻塞情况二
2.1 产生原因
- 线程1 完成 锁标识后,释放锁之前,发生阻塞,导致超时释放锁
- 此时 线程2 获取锁,执行业务
- 线程2 执行任务过程中,线程1 继续并释放锁
- 此时释放的锁为 线程2 的锁
2.2 解决方案
基于Lua脚本实现分布式锁的释放锁逻辑,确保多条命令执行时的原子性。
- 执行redis命令
# 执行redis命令
redis.call('命令名称', 'key', '其它参数', ...)
# 先执行 set name jack
redis.call('set', 'name', 'jack')
# 再执行 get name
local name = redis.call('get', 'name')
# 返回
return name
- 调用脚本
# 我们要执行 redis.call('set', 'name', 'jack') 这个脚本
# 调用脚本:""-脚本内容; 0-脚本需要的key类型的参数个数
EVAL "return redis.call('set', 'name', 'jack')" 0
# 如果脚本中的key、value不想写死,可以作为参数传递
# key类型参数会放入KEYS数组,其它参数会放入ARGV数组,角标从1开始
# 调用脚本:""-脚本内容; 1-脚本需要的key类型的参数个数
EVAL "return redis.call('set',KEYS[1],ARGV[1])" 1 name Rose
2.3 业务流程
- 获取锁中的线程标识
- 判断是否与指定的标识(当前线程标识)一致
- 如果一致则释放锁(删除)
- 如果不一致则什么都不做
-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
-- 一致,则删除锁
return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0
2.4 代码实现
- unlock.lua
-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
-- 一致,则删除锁
return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0
- 释放锁
package erer.redis.project.lock.impl;
/**
* 改进Redis分布式锁:
* 2、线程 完成 锁标识后,释放锁之前,发生阻塞,导致超时释放锁
* 基于Lua脚本实现分布式锁的释放锁逻辑,确保多条命令执行时的原子性。
*/
public class SimpleRedisLock implements ILock {
private final StringRedisTemplate stringRedisTemplate;
private final String name;
private static final String KEY_PREFIX = "simple:redis:lock:";
private static final String ID_PREFIX = UUID.randomUUID() + ":";
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 void unlock() {
// 调用 lua 脚本
stringRedisTemplate.execute(
UNLOCK_SCRIPT, // 脚本
Collections.singletonList(KEY_PREFIX + name), // key 数组
ID_PREFIX + Thread.currentThread().getId() // ARGV 数组
);
}
}
3. 实现思路
- 利用 set nx ex 获取锁,并设置过期时间,保存线程标示
- 释放锁时先判断线程标示是否与自己一致,一致则删除锁
- 基于Lua脚本实现分布式锁的释放锁逻辑,确保原子性
4. 特性
- 利用 set nx 满足互斥性
- 利用 set ex 保证故障时锁依然能释放,避免死锁,提高安全性
- 利用 Redis 集群保证高可用和高并发特性
8、Redisson分布式锁
1. 分布式锁存在问题
- 不可重入:同一个线程无法多次获取同一把锁
- 不可重试:获取锁只尝试一次就返回false,没有重试机制
- 超时释放:锁超时释放虽然可以避免死锁,但如果是业务执行耗时较长,也会导致锁释放,存在安全隐患
- 主从一致性:如果Redis提供了主从集群,主从同步存在延迟,当主宕机时,如果从并同步主中的锁数据,则会出现锁实现
2. Redisson 介绍
Redisson 是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。
- 分布式锁(Lock)和同步器(Synchronizer)
- 可重入锁(Reentrant Lock)
- 公平锁(Fair Lock)
- 联锁(MultiLock)
- 红锁(RedLock)
- 读写锁(ReadWriteLock)
- 信号量(Semaphore)
- 可过期性信号量(PermitExpirableSemaphore)
- 闭锁(CountDownLatch)
3. Redisson入门
3.1 引入依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
3.2 配置客户端
package erer.redis.project.config;
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
// 配置类
Config config = new Config();
// 添加redis地址,这里添加了单点的地址,也可以使用config.useClusterServers()添加集群地址
config.useSingleServer().setAddress("redis://localhost:6379");
// 创建客户端
return Redisson.create(config);
}
}
3.3 使用分布式锁
package erer.redis.project;
@SpringBootTest()
class ProjectApplicationTests {
@Resource
private RedissonClient redissonClient;
@Test
void testRedisson() throws InterruptedException {
// 获取锁(可重入),指定锁的名称
RLock lock = redissonClient.getLock("anyLock");
// 尝试获取锁,参数分别是:获取锁最大等待时间(期间会重试),锁自动释放时间,时间单位
boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS);
// 判断释放获取成功
if (isLock) {
try {
System.out.println("执行业务");
} finally {
// 释放锁
lock.unlock();
}
}
}
}
package erer.redis.project.service.impl;
/**
* 解决以下问题:
* 不可重入、不可重试、超时释放、主从一致性 等
*/
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Resource
private RedissonClient redissonClient;
// Redisson分布式锁
// 开启事务
@Transactional
public Result creatVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
// 创建锁对象
RLock redissonLock = redissonClient.getLock("order:" + userId);
// 尝试获取锁:参数分别是:获取锁最大等待时间(期间会重试),锁自动释放时间,时间单位
// 空参不等待,失败直接结束
boolean isLock = redissonLock.tryLock();
// 判断是否获取成功
if (!isLock) {
// 获取锁失败,直接返回
return Result.fail("不允许重复下单!");
}
try {
// 5.一人一单:查询订单
Integer count = query().eq("user_id", userId)
.eq("voucher_id", voucherId)
.count();
if (count > 0) {
return Result.fail("用户已购买一次。");
}
// 6.扣减库存(gt("stock", 0):CAS法解决超卖问题)
boolean flag = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherId) // where id = voucherId
.gt("stock", 0) // where stock > 0
.update();
if (!flag) {
return Result.fail("库存不足!");
}
// 7.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 订单 id
voucherOrder.setId(redisIdWorker.nextId("order"));
// 用户 id
voucherOrder.setUserId(userId);
// 代金券 id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7.返回订单id
return Result.ok(voucherOrder.getId());
} finally {
redissonLock.unlock();
}
}
}
4. 可重入锁原理
4.1 业务流程
4.2 获取锁
-- 锁的key
local key = KEYS[1];
-- 线程唯一标识
local threadId = ARGV[1];
-- 锁的自动释放时间
local releaseTime = ARGV[2];
-- 判断是否存在
if(redis.call('exists', key) == 0) then
-- 不存在, 获取锁,设置 value值(记录获取次数)
redis.call('hset', key, threadId, '1');
-- 设置有效期
redis.call('expire', key, releaseTime);
-- 返回结果
return 1;
end;
-- 锁已经存在,判断 threadId 是否是自己
if(redis.call('hexists', key, threadId) == 1) then
-- 锁是自己,自增:重入次数+1
redis.call('hincrby', key, threadId, '1');
-- 重置有效期
redis.call('expire', key, releaseTime);
-- 返回结果
return 1;
end;
-- 代码走到这里,说明获取锁的不是自己,获取锁失败
return 0;
4.3 释放锁
-- 锁的key
local key = KEYS[1];
-- 线程唯一标识
local threadId = ARGV[1];
-- 锁的自动释放时间
local releaseTime = ARGV[2];
-- 判断当前锁是否还是被自己持有
if (redis.call('HEXISTS', key, threadId) == 0) then
-- 如果已经不是自己,则直接返回
return nil;
end ;
-- 是自己的锁,则重入次数-1
local count = redis.call('HINCRBY', key, threadId, -1);
-- 判断是否重入次数是否已经为 0
if (count > 0) then
-- 大于0说明不能释放锁,重置有效期然后返回
redis.call('EXPIRE', key, releaseTime);
return nil;
else
-- 等于0说明可以释放锁,直接删除
redis.call('DEL', key);
return nil;
end ;
4.4 代码实现
@Slf4j
@SpringBootTest
class RedissonTest {
@Resource
private RedissonClient redissonClient;
private RLock lock;
@BeforeEach
void setUp() {
lock = redissonClient.getLock("order");
}
@Test
void method1() throws InterruptedException {
// 尝试获取锁
boolean isLock = lock.tryLock(1L, TimeUnit.SECONDS);
if (!isLock) {
log.error("获取锁失败 .... 1");
return;
}
try {
log.info("获取锁成功 .... 1");
method2();
log.info("开始执行业务 ... 1");
} finally {
log.warn("准备释放锁 .... 1");
lock.unlock();
}
}
void method2() {
// 尝试获取锁
boolean isLock = lock.tryLock();
if (!isLock) {
log.error("获取锁失败 .... 2");
return;
}
try {
log.info("获取锁成功 .... 2");
log.info("开始执行业务 ... 2");
} finally {
log.warn("准备释放锁 .... 2");
lock.unlock();
}
}
}
5. 分布式锁原理
- 可重入:利用hash结构记录线程id和重入次数
- 可重试:利用信号量和PubSub功能实现等待、唤醒,获取锁失败的重试机制
- 超时续约:利用watchDog,每隔一段时间(releaseTime / 3),重置超时时间
6. 主从一致性问题
- java 应用 获取锁之后,主节点进行主从同步之前发生宕机
- java程序获取的锁失效
- 解决办法:联合锁—只有所有节点都拿到锁才成功
9、分布式锁总结
1. 不可重入Redis分布式锁
- 原理:利用setnx的互斥性;利用ex避免死锁;释放锁时判断线程标示
- 缺陷:不可重入、无法重试、锁超时失效
2. 可重入的Redis分布式锁
- 原理:利用hash结构,记录线程标示和重入次数;利用watchDog延续锁时间;利用信号量控制锁重试等待
- 缺陷:redis宕机引起锁失效问题
3. Redisson的multiLock
- 原理:多个独立的Redis节点,必须在所有节点都获取重入锁,才算获取锁成功
- 缺陷:运维成本高、实现复杂
四、Redis优化秒杀业务
1、业务流程
1. 原始业务流程
2. 优化业务流程
3. 秒杀业务流程
2、改进业务
1. 新增优惠券并保存Redis
package erer.redis.project.service.impl;
@Service
public class VoucherServiceImpl extends ServiceImpl<VoucherMapper, Voucher> implements IVoucherService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryVoucherOfShop(Long shopId) {
// 查询优惠券信息
List<Voucher> vouchers = getBaseMapper().queryVoucherOfShop(shopId);
stringRedisTemplate.opsForValue().set("order" + shopId, JSONUtil.toJsonStr(vouchers));
// 返回结果
return Result.ok(vouchers);
}
@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);
// 保存优惠券信息(seckill:stock: + 优惠券 id = 库存)到 Redis
stringRedisTemplate.opsForValue().set(RedisConstants.SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString());
}
}
2. Lua脚本判断是否抢购成功
-- 1、参数列表
-- 1.优惠券 id
local voucherId = ARGV[1]
-- 2.用户 id
local userId = ARGV[2]
-- 2、数据 key
-- 1.库存 key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.订单 key
local stockKey = 'seckill:order:' .. voucherId
-- 3、脚本业务
-- 1.判断库存是否充足
if(tonumber(redis.call('get', stockKey)) <= 0) then
-- 库存不足 返回 1
return 1
end
-- 2.判断用户是否已经下过单
if(redis.call('sismember', orderKey, userId) == 1) then
-- 存在,表示重复下单 返回 2
return 2
end
-- 3.扣库存
redis.call('incrby', stockKey, -1)
-- 4.下单(保存用户)
redis.call('sadd', orderKey, userId)
3. 执行Lua脚本判断抢购结果
4. 开启线程任务
package erer.redis.project.service.impl;
/**
* 优化业务流程,减少处理时间
*/
@Slf4j
@Service
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 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 static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
// 加载 lua 脚本
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, // lua 脚本
Collections.emptyList(), // key 数组
voucherId.toString(), userId.toString() // ARGV 数组
);
// 2.判断执行结果
if (result.intValue() != 0) {
// 下单失败!
return Result.fail(result.intValue() == 1 ? "库存不足!" : "不能重复下单!");
}
// 3.抢购成功,将优惠券id和用户id封装后存入阻塞队列
// 3.1 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 封装订单 id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 封装用户 id
voucherOrder.setUserId(userId);
// 封装代金券 id
voucherOrder.setVoucherId(voucherId);
// 3.2 放入阻塞队列
orderTasks.add(voucherOrder);
return Result.ok(orderId);
}
// 4.开启线程任务
private class VoucherOrderHandler implements Runnable {
@Override
public void run() {
while (true) {
try {
// 1. 获取队列中的 订单信息
VoucherOrder voucherOrder = orderTasks.take();
// 2. 创建订单
creatVoucherOrder(voucherOrder);
} catch (InterruptedException e) {
log.error("处理订单异常", e);
}
}
}
}
private void creatVoucherOrder(VoucherOrder voucherOrder) {
Long userId = voucherOrder.getUserId();
Long voucherId = voucherOrder.getVoucherId();
// 1.创建锁对象
RLock redissonLock = redissonClient.getLock("redissonLock:order:" + userId);
// 2.尝试获取锁:参数分别是:获取锁最大等待时间(期间会重试),锁自动释放时间,时间单位
// 空参不等待,失败直接结束
boolean isLock = redissonLock.tryLock();
// 3.判断是否获取成功
if (!isLock) {
// 获取锁失败,直接返回
log.error("不允许重复下单!");
return;
}
try {
// 4.一人一单:查询订单
Integer count = query().eq("user_id", userId)
.eq("voucher_id", voucherId)
.count();
if (count > 0) {
log.error("用户已购买一次。");
return;
}
// 5.扣减库存(gt("stock", 0):CAS法解决超卖问题)
boolean flag = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherId) // where id = voucherId
.gt("stock", 0) // where stock > 0
.update();
if (!flag) {
log.error("库存不足!");
return;
}
// 6.保存订单
save(voucherOrder);
} finally {
redissonLock.unlock();
}
}
}
五、Redis消息队列
消息队列(Message Queue),字面意思就是存放消息的队列。最简单的消息队列模型包括3个角色:
- 消息队列:存储和管理消息,也被称为消息代理(Message Broker)
- 生产者:发送消息到消息队列
- 消费者:从消息队列获取消息并处理消息
Redis提供了三种不同的方式来实现消息队列:
- list结构:基于List结构模拟消息队列
- PubSub:基本的点对点消息模型
- Stream:比较完善的消息队列模型
1、基于List模拟消息队列
- 队列是入口和出口不在一边,因此我们可以利用:LPUSH 结合 RPOP、或者 RPUSH 结合 LPOP来实现。
- 不过要注意的是,当队列中没有消息时 RPOP 或 LPOP 操作会返回 null,并不像 JVM 的阻塞队列那样会阻塞并等待消息。因此这里应该使用 BRPOP 或者 BLPOP 来实现阻塞效果。
1. 优点
- 利用Redis存储,不受限于JVM内存上限
- 基于Redis的持久化机制,数据安全性有保证
- 可以满足消息有序性
2. 缺点
- 无法避免消息丢失
- 只支持单消费者
2、基于PubSub的消息队列
**PubSub(发布订阅)**是Redis2.0版本引入的消息传递模型。顾名思义,消费者可以订阅一个或多个channel,生产者向对应channel发送消息后,所有订阅者都能收到相关消息。
- SUBSCRIBE channel [channel] :订阅一个或多个频道
- PUBLISH channel msg :向一个频道发送消息
- PSUBSCRIBE pattern[pattern] :订阅与pattern格式匹配的所有频道
1. 优点
- 采用发布订阅模型,支持多生产、多消费
2. 缺点
- 不支持数据持久化
- 无法避免消息丢失
- 消息堆积有上限,超出时数据丢失
3、基于Stream的消息队列
1. 发送消息
# 创建名为 users 的队列,并向其中发送一个消息,内容是:{name=jack,age=21},并且使用Redis自动生成ID
127.0.0.1:6379> XADD users * name jack age 21
"1644805700523-0"
2. 读取第一个消息
# 消息永久存在,可以被多次读取
127.0.0.1:6379> xread count 1 streams user 0
1) 1) "user"
2) 1) 1) "1649635916845-0"
2) 1) "name"
2) "jack"
3) "age"
4) "21"
3. 读取最新消息
# block:阻塞方式读取信息;0:阻塞毫秒时间(0表示永久阻塞)
127.0.0.1:6379> xread count 1 block 0 streams user $
1) 1) "user"
2) 1) 1) "1649636518683-0"
2) 1) "name"
2) "tom"
3) "age"
4) "20"
(4.48s)
注意
当我们指定起始 ID 为 $ 时,代表读取最新的消息,如果我们处理一条消息的过程中,又有超过1条以上的消息到达队列,则下次获取时也只能获取到最新的一条,会出现漏读消息的问题。
4. xread命令特点
- 消息可回溯
- 一个消息可以被多个消费者读取
- 可以阻塞读取
- 有消息漏读的风险
4、Stream:消费者组
消费者组(Consumer Group):将多个消费者划分到一个组中,监听同一个队列。具备下列特点:
- 消息分流:队列中的消息会分流给组内的不同消费者,而不是重复消费,从而加快消息处理的速度
- 消息标示:消费者组会维护一个标示,记录最后一个被处理的消息,哪怕消费者宕机重启,还会从标示之后读取消息,确保每一个消息都会被消费
- 消息确认:消费者获取消息后,消息处于pending状态,并存入一个pending-list。当处理完成后需要通过XACK来确认消息,标记消息为已处理,才会从pending-list移除
1. 创建消费者组
xgroup create key groupName ID [MKSTREAM]
- key:队列名称
- groupName:消费者组名称
- ID:起始ID标示,$代表队列中最后一个消息,0则代表队列中第一个消息
- MKSTREAM:队列不存在时自动创建队列
127.0.0.1:6379> xadd s1 * k1 v1
"1649638753923-0"
127.0.0.1:6379> xadd s1 * k2 v2
"1649638762059-0"
127.0.0.1:6379> xadd s1 * k3 v3
"1649638768680-0"
127.0.0.1:6379> xadd s1 * k4 v4
"1649638775981-0"
127.0.0.1:6379> xadd s1 * k5 v5
"1649638782658-0"
# 队列中已经有消息但不想消费用 $;队列中没有消息或想消费已有消息采用 0
127.0.0.1:6379> xgroup create s1 g1 0
OK
2. 其他常见命令
# 删除指定的消费者组
xgroup destory key groupName
# 给指定的消费者组添加消费者
xgroup createConsumer key groupname consumername
# 删除消费者组中的指定消费者
xgroup delConsumer key groupname consumername
3. 从消费者组读取消息
xreadgroup GROUP group consumer [COUNT count] [BLOCK milliseconds] [NOACK] STREAMS key [key ...] ID [ID ...]
- group:消费组名称
- consumer:消费者名称,如果消费者不存在,会自动创建一个消费者
- count:本次查询的最大数量
- BLOCK milliseconds:当没有消息时最长等待时间
- NOACK:无需手动ACK,获取到消息后自动确认
- STREAMS key:指定队列名称
- ID:获取消息的起始ID:
- “>”:从下一个未消费的消息开始
- 其它:根据指定id从pending-list中获取已消费但未确认的消息,例如0,是从pending-list中的第一个消息开始
127.0.0.1:6379> xreadgroup GROUP g1 c1 count 1 block 2000 streams s1 >
1) 1) "s1"
2) 1) 1) "1649638753923-0"
2) 1) "k1"
2) "v1"
127.0.0.1:6379> xreadgroup GROUP g1 c1 count 1 block 2000 streams s1 >
1) 1) "s1"
2) 1) 1) "1649638762059-0"
2) 1) "k2"
2) "v2"
127.0.0.1:6379> xreadgroup GROUP g1 c1 count 1 block 2000 streams s1 >
1) 1) "s1"
2) 1) 1) "1649638768680-0"
2) 1) "k3"
2) "v3"
# 确认处理消息(pending-list)
127.0.0.1:6379> xack s1 g1 1649638753923-0 1649638762059-0 1649638768680-0
(integer) 3
# 获取未确认消息
127.0.0.1:6379> xpending s1 g1 - + 10
(empty list or set)
# 读取消息
127.0.0.1:6379> xreadgroup GROUP g1 c1 count 1 block 2000 streams s1 >
1) 1) "s1"
2) 1) 1) "1649638775981-0"
2) 1) "k4"
2) "v4"
# 获取未确认消息(pending-list)
127.0.0.1:6379> xpending s1 g1 - + 10
1) 1) "1649638775981-0"
2) "c1"
3) (integer) 3634
4) (integer) 1
# 读取未处理消息(pending-list)中的第一条消息
127.0.0.1:6379> xreadgroup GROUP g1 c1 count 1 block 2000 streams s1 0
1) 1) "s1"
2) 1) 1) "1649638775981-0"
2) 1) "k4"
2) "v4"
# 确认处理消息
127.0.0.1:6379> xack s1 g1 1649638775981-0
(integer) 1
# 读取未处理消息(pending-list)中的第一条消息
127.0.0.1:6379> xreadgroup GROUP g1 c1 count 1 block 2000 streams s1 0
1) 1) "s1"
2) (empty list or set)
4. 消费者监听消息
while (true) {
// 尝试监听队列,使用组赛模式,最长等待2000 毫秒
Object msg = redis.call("XREADGROUP GROUP gI CI COUNT 1 BLOCK 2000 STREAMS SI >");
// null说明没有消息,继续下一次
if (msg == null){
continue;
}
try {
// 处理消息,完成后一定要 ACK
handleMessage(msg);
} catch (Exception e) {
while (true) {
Object msg = redis.call("XREADGROUP GROUP gI CI COUNT 1 STREAMS S1 @");
// null说明没有异常消息,所有消息都已确认,结束据环
if (msg == null) {
break;
}
try {
//说明有异常消息,再次处理
handleMessage(msg);
} catch (Exception e) {
//再次出现异常,记灵日志,继续婚环
continue;
}
}
}
}
5. xreadgroup命令特点
- 消息可回溯
- 可以多消费者争抢消息,加快消费速度
- 可以阻塞读取
- 没有消息漏读的风险
- 有消息确认机制,保证消息至少被消费一次
5、Redis消息队列
项目 | List | PubSub | Stream |
---|---|---|---|
消息持久化 | 支持 | 不支持 | 支持 |
阻塞读取 | 支持 | 支持 | 支持 |
消息堆积处理 | 受限于内存空间 可以利用多消费者加快处理 | 受限于消费者缓冲区 | 受限于队列长度 可以利用消费者组提高消费速度,减少堆积 |
消息确认机制 | 不支持 | 不支持 | 支持 |
消息回溯 | 不支持 | 不支持 | 支持 |
6、实现异步秒杀下单
1. 创建消息队列
127.0.0.1:6379> xgroup create stream.orders g1 0 mkstream
OK
2. 编写Lua脚本
- 在认定有抢购资格后,直接向 stream.orders 中添加消息,内容包含 voucherId、userId、orderId
-- 1、参数列表
-- 1.优惠券 id
local voucherId = ARGV[1]
-- 2.用户 id
local userId = ARGV[2]
-- 3.订单 id
local orderId = ARGV[3]
-- 2、数据 key
-- 1.库存 key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.订单 key
local orderKey = 'seckill:order:' .. voucherId
-- 3、脚本业务
-- 1.判断库存是否充足
if(tonumber(redis.call('get', stockKey)) <= 0) then
-- 库存不足 返回 1
return 1
end
-- 2.判断用户是否已经下过单
if(redis.call('sismember', orderKey, userId) == 1) then
-- 存在,表示重复下单 返回 2
return 2
end
-- 3.扣库存
redis.call('incrby', stockKey, -1)
-- 4.下单(保存用户)
redis.call('sadd', orderKey, userId)
-- 5.发送消息到队列中 xadd stream.orders * k1 v1 k2 v2... (*代表由Redis自动生成消息的唯一id)
redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', order)
return 0
3. 执行Lua脚本
4. 开启一个线程任务
5. 尝试获取消息
6. 完成下单
package erer.redis.project.service.impl;
/**
* 基于Redis的Stream结构作为消息队列,实现异步秒杀下单
*/
@Slf4j
@Service
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 {
// 加载 lua 脚本
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();
// 线程任务
private class VoucherOrderHandler implements Runnable {
@Override
public void run() {
String queueName = "stream.orders";
while (true) {
try {
// 1. 获取 消息队列中的 订单信息 xreadgroup GROUP g1 c1 count 1 block 2000 streams s1 >
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"), // g1:消费者组的名称、c1:消费者名称
StreamReadOptions.empty()
.count(1).block(Duration.ofSeconds(2)), // 读取一条信息,等待时间为2s
// 读取 stream.order 中下一个未消费的消息
StreamOffset.create(queueName, ReadOffset.lastConsumed())
);
// 2. 判断订单信息是否为空
if (list == null || list.isEmpty()) {
// 为空,说明没有消息,继续下一次循环
continue;
}
// 3. 解析消息,创建订单
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> value = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
creatVoucherOrder(voucherOrder);
log.info("处理订单,完成!");
// 4. 确认消息
stringRedisTemplate.opsForStream().acknowledge(queueName, "g1", record.getId());
} catch (Exception e) {
log.error("处理订单异常", e);
handlePendingList();
}
}
}
}
// 类初始化完成后 执行线程任务
@PostConstruct
private void init() {
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
// 处理 异常消息
private void handlePendingList() {
String queueName = "stream.order";
while (true) {
try {
// 1. 获取 PendingList 队列中异常消息 xreadgroup GROUP g1 c1 count 1 block 2000 streams s1 0
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"), // g1:消费者组的名称、c1:消费者名称
StreamReadOptions.empty()
.count(1).block(Duration.ofSeconds(2)), // 读取一条信息,等待时间为2s
// 读取 stream.order 中下一个未消费的消息
StreamOffset.create(queueName, ReadOffset.from("0"))
);
// 2. 判断订单信息是否为空
if (list == null || list.isEmpty()) {
// 为空,说明 PendingList 没有 异常消息,结束循环
break;
}
// 3. 解析消息,创建订单
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> value = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
creatVoucherOrder(voucherOrder);
log.info("异常订单处理完毕");
// 4. 确认消息
stringRedisTemplate.opsForStream().acknowledge(queueName, "g1", record.getId());
} catch (Exception e) {
log.error("处理 Pending 订单异常", e);
try {
Thread.sleep(100);
} catch (InterruptedException interruptedException) {
interruptedException.printStackTrace();
}
}
}
}
@Override
public Result seckillVoucher(Long voucherId) {
Long userId = UserHolder.getUser().getId();
Long orderId = redisIdWorker.nextId("order");
// 1.执行Lua脚本
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT, // lua 脚本
Collections.emptyList(), // key 数组
String.valueOf(voucherId), String.valueOf(userId), String.valueOf(orderId) // ARGV 数组
);
// 2.判断执行结果
int flag = Integer.parseInt(String.valueOf(result));
if (flag != 0) {
// 下单失败!
return Result.fail(flag == 1 ? "库存不足!" : "不能重复下单!");
}
return Result.ok(orderId);
}
private void creatVoucherOrder(VoucherOrder voucherOrder) {
Long userId = voucherOrder.getUserId();
Long voucherId = voucherOrder.getVoucherId();
// 1.创建锁对象
RLock redissonLock = redissonClient.getLock("redissonLock:order:" + userId);
// 2.尝试获取锁:参数分别是:获取锁最大等待时间(期间会重试),锁自动释放时间,时间单位
// 空参不等待,失败直接结束
boolean isLock = redissonLock.tryLock();
// 3.判断是否获取成功
if (!isLock) {
// 获取锁失败,直接返回
log.error("不允许重复下单!");
return;
}
try {
// 4.一人一单:查询订单
Integer count = query().eq("user_id", userId)
.eq("voucher_id", voucherId)
.count();
if (count > 0) {
log.error("用户已购买一次。");
return;
}
// 5.扣减库存(gt("stock", 0):CAS法解决超卖问题)
boolean flag = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherId) // where id = voucherId
.gt("stock", 0) // where stock > 0
.update();
if (!flag) {
log.error("库存不足!");
return;
}
// 6.保存订单
save(voucherOrder);
} finally {
redissonLock.unlock();
}
}
}
六、探店笔记
1、发布探店笔记
1. 前端接口
2. POJO
- tb_blog:探店笔记表,包含笔记中的标题、文字、图片等
package erer.redis.project.entity;
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("tb_blog")
public class Blog implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 主键
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 商户id
*/
private Long shopId;
/**
* 用户id
*/
private Long userId;
/**
* 用户图标
* @TableField :该字段不属于 tb_blog 表
*/
@TableField(exist = false)
private String icon;
/**
* 用户姓名
*/
@TableField(exist = false)
private String name;
/**
* 是否点赞过了
* @TableField :该字段不属于 tb_blog 表
*/
@TableField(exist = false)
private Boolean isLike;
/**
* 标题
*/
private String title;
/**
* 探店的照片,最多9张,多张以","隔开
*/
private String images;
/**
* 探店的文字描述
*/
private String content;
/**
* 点赞数量
*/
private Integer liked;
/**
* 评论数量
*/
private Integer comments;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
}
- tb_blog_comments:其他用户对探店笔记的评价
package erer.redis.project.entity;
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("tb_blog_comments")
public class BlogComments implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 主键
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 用户id
*/
private Long userId;
/**
* 探店id
*/
private Long blogId;
/**
* 关联的1级评论id,如果是一级评论,则值为0
*/
private Long parentId;
/**
* 回复的评论id
*/
private Long answerId;
/**
* 回复的内容
*/
private String content;
/**
* 点赞数
*/
private Integer liked;
/**
* 状态,0:正常,1:被举报,2:禁止查看
*/
private Boolean status;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
}
3. 保存图片
package erer.redis.project.controller;
@Slf4j
@RestController
@RequestMapping("upload")
public class UploadController {
@PostMapping("blog")
public Result uploadImage(@RequestParam("file") MultipartFile image) {
try {
// 获取原始文件名称
String originalFilename = image.getOriginalFilename();
// 生成新文件名
String fileName = createNewFileName(originalFilename);
// 保存文件 SystemConstants.IMAGE_UPLOAD_DIR = 文件保存地址
image.transferTo(new File(SystemConstants.IMAGE_UPLOAD_DIR, fileName));
// 返回结果
log.debug("文件上传成功,{}", fileName);
return Result.ok(fileName);
} catch (IOException e) {
throw new RuntimeException("文件上传失败", e);
}
}
@GetMapping("/blog/delete")
public Result deleteBlogImg(@RequestParam("name") String filename) {
File file = new File(SystemConstants.IMAGE_UPLOAD_DIR, filename);
if (file.isDirectory()) {
return Result.fail("错误的文件名称");
}
FileUtil.del(file);
return Result.ok();
}
private String createNewFileName(String originalFilename) {
// 获取后缀
String suffix = StrUtil.subAfter(originalFilename, ".", true);
// 生成目录
String name = UUID.randomUUID().toString();
int hash = name.hashCode();
int d1 = hash & 0xF;
int d2 = (hash >> 4) & 0xF;
// 判断目录是否存在
File dir = new File(SystemConstants.IMAGE_UPLOAD_DIR, StrUtil.format("/blogs/{}/{}", d1, d2));
if (!dir.exists()) {
dir.mkdirs();
}
// 生成文件名
return StrUtil.format("/blogs/{}/{}/{}.{}", d1, d2, name, suffix);
}
}
4. 保存笔记
package erer.redis.project.controller;
@RestController
@RequestMapping("/blog")
public class BlogController {
@Resource
private IBlogService blogService;
// 保存探店博文
@PostMapping
public Result saveBlog(@RequestBody Blog blog) {
// 获取登录用户
UserDTO user = UserHolder.getUser();
blog.setUserId(user.getId());
// 保存探店笔记
blogService.save(blog);
// 返回id
return Result.ok(blog.getId());
}
// 通过 id 获取探店笔记
@GetMapping("/{id}")
public Result queryById(@PathVariable("id") Long id) {
return blogService.queryById(id);
}
// 获取高赞笔记
@GetMapping("/hot")
public Result queryHotBlog(@RequestParam(value = "current", defaultValue = "1") Integer current) {
return blogService.queryHotBlog(current);
}
}
5. 获取探店笔记
package erer.redis.project.service.impl;
@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {
@Resource
private IUserService userService;
@Override
// 通过 id 获取探店笔记
public Result queryById(Long id) {
// 查询blog
Blog blog = getById(id);
if (blog == null) {
return Result.fail("笔记不存在!");
} else {
// 补全 blog 用户信息
queryBlogUser(blog);
}
return Result.ok(blog);
}
@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();
// 补全 blog 作者信息
records.forEach(this::queryBlogUser);
return Result.ok(records);
}
// 补全 blog 作者信息
private void queryBlogUser(Blog blog) {
// 获取编写 blog 的 用户id
Long userId = blog.getUserId();
// 根据 用户id 获取用户信息
User user = userService.getById(userId);
// blog 插入 用户昵称
blog.setName(user.getNickName());
// blog 插入 用户头像
blog.setIcon(user.getIcon());
}
}
2、实现点赞功能
- 同一个用户只能点赞一次,再次点击则取消点赞
- 如果当前用户已经点赞,则点赞按钮高亮显示(前端已实现,判断字段Blog类的isLike属性)
1. 添加标识
- 给 Blog 类中添加一个isLike字段,标识是否被当前用户点赞
package erer.redis.project.entity;
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("tb_blog")
public class Blog implements Serializable {
private static final long serialVersionUID = 1L;
...
/**
* 是否点赞过了
* @TableField :该字段不属于 tb_blog 表
*/
@TableField(exist = false)
private Boolean isLike;
...
}
2. 完成点赞功能
- 利用Redis的set集合判断是否点赞过,未点赞过则点赞数+1,已点赞过则点赞数-1
- 修改根据id查询Blog的业务,判断当前登录用户是否点赞过,赋值给isLike字段
- 修改分页查询Blog业务,判断当前登录用户是否点赞过,赋值给isLike字段
package erer.redis.project.controller;
@RestController
@RequestMapping("/blog")
public class BlogController {
@Resource
private IBlogService blogService;
@PutMapping("/like/{id}")
public Result likeBlog(@PathVariable("id") Long id) {
// // 修改点赞数量
// blogService.update()
// .setSql("liked = liked + 1").eq("id", id).update();
return blogService.likeBlog(id);
}
}
package erer.redis.project.service.impl;
@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {
@Resource
private IUserService userService;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
// 通过 id 获取探店笔记
public Result queryById(Long id) {
// 查询blog
Blog blog = getById(id);
if (blog == null) {
return Result.fail("笔记不存在!");
} else {
// 补全 blog 用户信息
queryBlogUser(blog);
// 查询 blog 是否被点赞
isBlogLiked(blog);
}
return Result.ok(blog);
}
@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();
// 补全 blog 用户信息
records.forEach(blog -> {
this.queryBlogUser(blog);
this.isBlogLiked(blog);
});
return Result.ok(records);
}
@Override
// 笔记点赞功能
public Result likeBlog(Long id) {
// 1.获取登录用户
Long userId = UserHolder.getUser().getId();
// 2.判断当前用户是否点赞 BLOG_LIKED_KEY = "blog:liked:"
String key = RedisConstants.BLOG_LIKED_KEY + id;
Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString());
if (BooleanUtil.isFalse(isMember) ) {
// 2.1当前用户没有点赞,完成点赞
boolean isSuccess = update()
.setSql("liked = liked + 1")
.eq("id", id)
.update();
if (isSuccess) {
stringRedisTemplate.opsForSet().add(key, userId.toString());
}
} else {
// 2.2当前用户已经点赞,取消点赞
boolean isSuccess = update()
.setSql("liked = liked - 1")
.eq("id", id)
.update();
if (isSuccess) {
stringRedisTemplate.opsForSet().remove(key, userId.toString());
}
}
return Result.ok();
}
// 补全 blog 作者信息
private void queryBlogUser(Blog blog) {
// 获取编写 blog 的 用户id
Long userId = blog.getUserId();
// 根据 用户id 获取用户信息
User user = userService.getById(userId);
// blog 插入 用户昵称
blog.setName(user.getNickName());
// blog 插入 用户头像
blog.setIcon(user.getIcon());
}
// 判断用户是否点赞笔记
private void isBlogLiked(Blog blog) {
// 1.获取登录用户
UserDTO user = UserHolder.getUser();
if (user == null) {
// 用户未登录,无需查询是否点赞
return;
}
// 2.获取用户id
Long userId = user.getId();
// 3.判断当前用户是否点赞 BLOG_LIKED_KEY = "blog:liked:"
String key = RedisConstants.BLOG_LIKED_KEY + blog.getId();
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
// 4.如果点过赞 结果为 true
blog.setIsLike(score != null);
}
}
3、点赞排行榜
- 按照点赞时间先后排序,返回Top5的用户
List | Set | SortedSet | |
---|---|---|---|
排序方式 | 按添加顺序排序 | 无法排序 | 根据score值排序 |
唯一性 | 不唯一 | 唯一 | 唯一 |
查找方式 | 按索引查找或首尾查找 | 根据元素查找 | 根据元素查找 |
1. 改造点赞功能
2. 获取点赞用户
package erer.redis.project.controller;
@RestController
@RequestMapping("/blog")
public class BlogController {
@Resource
private IBlogService blogService;
...
@GetMapping("/likes/{id}")
public Result queryLikesBlog(@PathVariable("id") Long id) {
return blogService.queryLikesBlog(id);
}
...
}
3. 排序打印结果
package erer.redis.project.service.impl;
@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {
@Resource
private IUserService userService;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
// 通过 id 获取探店笔记
public Result queryById(Long id) {
// 查询blog
Blog blog = getById(id);
if (blog == null) {
return Result.fail("笔记不存在!");
} else {
// 补全 blog 用户信息
queryBlogUser(blog);
// 查询 blog 是否被点赞
isBlogLiked(blog);
}
return Result.ok(blog);
}
@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();
// 补全 blog 用户信息
records.forEach(blog -> {
this.queryBlogUser(blog);
this.isBlogLiked(blog);
});
return Result.ok(records);
}
@Override
// 笔记点赞功能
public Result likeBlog(Long id) {
// 1.获取登录用户
Long userId = UserHolder.getUser().getId();
// 2.判断当前用户是否点赞 BLOG_LIKED_KEY = "blog:liked:"
String key = RedisConstants.BLOG_LIKED_KEY + id;
// 获取时间戳
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
if (score == null) {
// 2.1当前用户没有点赞,完成点赞
boolean isSuccess = update()
.setSql("liked = liked + 1")
.eq("id", id)
.update();
if (isSuccess) {
// 将用户id 保存到 Redis 的 Zset 集合,同时保存时间戳
stringRedisTemplate.opsForZSet().add(key, userId.toString(), System.currentTimeMillis());
}
} else {
// 2.2当前用户已经点赞,取消点赞
boolean isSuccess = update()
.setSql("liked = liked - 1")
.eq("id", id)
.update();
if (isSuccess) {
// 将用户id 在 Redis 的 Zset 集合中删除
stringRedisTemplate.opsForZSet().remove(key, userId.toString());
}
}
return Result.ok();
}
@Override
// 获取笔记点赞用户
public Result queryLikesBlog(Long id) {
// 1.查询top5的点赞用户 zrange key 0 4
// BLOG_LIKED_KEY = "blog:liked:"
String key = RedisConstants.BLOG_LIKED_KEY + id;
Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4);
if (top5 == null || top5.isEmpty()) {
return Result.ok(Collections.emptyList());
} else {
// 2.解析出其中的用户id
List<Long> ids = top5
.stream()
.map(Long::valueOf)
.collect(Collectors.toList());
String idsStr = StrUtil.join(",", ids);
// 3.根据用户id获取用户 select * from tb_user where id in (...) 会打乱排序
// select * from tb_user where id in (...) order by field (id, ...)
List<UserDTO> users = userService
.query() // 查询
.in("id", ids) // 条件:in
.last("order by field(id,"+ idsStr + ")") // 拼接 order语句
.list() // 查询结果封装为 list
.stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());
// 4.返回
return Result.ok(users);
}
}
// 补全 blog 作者信息
private void queryBlogUser(Blog blog) {
// 获取编写 blog 的 用户id
Long userId = blog.getUserId();
// 根据 用户id 获取用户信息
User user = userService.getById(userId);
// blog 插入 用户昵称
blog.setName(user.getNickName());
// blog 插入 用户头像
blog.setIcon(user.getIcon());
}
// 判断用户是否点赞笔记
private void isBlogLiked(Blog blog) {
// 1.获取登录用户
UserDTO user = UserHolder.getUser();
if (user == null) {
// 用户未登录,无需查询是否点赞
return;
}
// 2.获取用户id
Long userId = user.getId();
// 3.判断当前用户是否点赞 BLOG_LIKED_KEY = "blog:liked:"
String key = RedisConstants.BLOG_LIKED_KEY + blog.getId();
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
// 4.如果点过赞 结果为 true
blog.setIsLike(score != null);
}
}
七、好友关注
1、关注和取关
1. 前端接口
2. 数据表
CREATE TABLE `tb_follow` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` bigint unsigned NOT NULL COMMENT '用户id',
`follow_user_id` bigint unsigned NOT NULL COMMENT '关联的用户id',
`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=COMPACT;
3. POJO
package erer.redis.project.entity;
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("tb_follow")
public class Follow implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 主键
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 用户id
*/
private Long userId;
/**
* 关联的用户id
*/
private Long followUserId;
/**
* 创建时间
*/
private LocalDateTime createTime;
}
4. Controller
package erer.redis.project.controller;
@RestController
@RequestMapping("/follow")
public class FollowController {
@Resource
private IFollowService followService;
@PutMapping("/{id}/{isFollow}")
public Result follow(@PathVariable("id") Long followUserId, @PathVariable("isFollow") Boolean isFollow) {
return followService.follow(followUserId, isFollow);
}
@GetMapping("/or/not/{id}")
public Result isFollow(@PathVariable("id") Long followUserId) {
return followService.isFollow(followUserId);
}
}
5. Service
package erer.redis.project.service.impl;
@Service
public class FollowServiceImpl extends ServiceImpl<FollowMapper, Follow> implements IFollowService {
@Override
// 关注和取关
public Result follow(Long followUserId, Boolean isFollow) {
// 1.获取登录用户
UserDTO user = UserHolder.getUser();
// 2.判断是关注还是取关
if (isFollow) {
// 关注
Follow follow = new Follow();
follow.setUserId(user.getId());
follow.setFollowUserId(followUserId);
save(follow);
} else {
// 取关:删除 delete from tb_follow where user_id = ? and follow_user_id = ?
remove(new QueryWrapper<Follow>()
.eq("user_id", user.getId())
.eq("follow_user_id", followUserId)
);
}
return Result.ok();
}
@Override
// 查询是否关注
public Result isFollow(Long followUserId) {
// 1.获取登录用户
UserDTO user = UserHolder.getUser();
// 2.查询是否关注 select count(*) from tb_follow where user_id = ? and follow_user_id = ?
Integer count = query()
.eq("user_id", user.getId())
.eq("follow_user_id", followUserId)
.count();
// 3.返回查询结果
return Result.ok(count > 0);
}
}
2、共同关注
1. 改造关注接口
- 利用 Redis 中恰当的数据结构,实现共同关注功能。在博主个人页面展示出当前用户与博主的共同好友。
2. 完成共同关注
- Controller
package erer.redis.project.controller;
@RestController
@RequestMapping("/follow")
public class FollowController {
@Resource
private IFollowService followService;
@PutMapping("/{id}/{isFollow}")
public Result follow(@PathVariable("id") Long followUserId, @PathVariable("isFollow") Boolean isFollow) {
return followService.follow(followUserId, isFollow);
}
@GetMapping("/or/not/{id}")
public Result isFollow(@PathVariable("id") Long followUserId) {
return followService.isFollow(followUserId);
}
@GetMapping("/common/{id}")
public Result followCommons(@PathVariable("id") Long id) {
return followService.followCommons(id);
}
}
- Service
package erer.redis.project.service.impl;
@Service
public class FollowServiceImpl extends ServiceImpl<FollowMapper, Follow> implements IFollowService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private IUserService userService;
@Override
// 关注和取关
public Result follow(Long followUserId, Boolean isFollow) {
// 1.获取登录用户
UserDTO user = UserHolder.getUser();
// 2.判断是关注还是取关
String key = RedisConstants.FOLLOW_KEY + user.getId();
if (isFollow) {
// 关注
Follow follow = new Follow();
follow.setUserId(user.getId());
follow.setFollowUserId(followUserId);
boolean isSuccess = save(follow);
if (isSuccess) {
// 把 关注用户的 id ,放入 redis 的 set 集合
stringRedisTemplate.opsForSet().add(key, followUserId.toString());
}
} else {
// 取关:删除 delete from tb_follow where user_id = ? and follow_user_id = ?
boolean isSuccess = remove(new QueryWrapper<Follow>()
.eq("user_id", user.getId())
.eq("follow_user_id", followUserId)
);
// 把 关注用户的 id ,从 redis 的 set 集合移除
if (isSuccess) {
stringRedisTemplate.opsForSet().remove(key, followUserId.toString());
}
}
return Result.ok();
}
@Override
// 查询是否关注
public Result isFollow(Long followUserId) {
// 1.获取登录用户
UserDTO user = UserHolder.getUser();
// 2.查询是否关注 select count(*) from tb_follow where user_id = ? and follow_user_id = ?
Integer count = query()
.eq("user_id", user.getId())
.eq("follow_user_id", followUserId)
.count();
// 3.返回查询结果
return Result.ok(count > 0);
}
@Override
// 获取共同关注
public Result followCommons(Long id) {
// 1.获取登录用户
Long userId = UserHolder.getUser().getId();
String key = RedisConstants.FOLLOW_KEY + userId;
// 2.求交集
String followKey = RedisConstants.FOLLOW_KEY + id;
Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key, followKey);
// 3.解析集合
if (intersect == null || intersect.isEmpty()) {
// 无共同关注
return Result.ok(Collections.emptyList());
} else {
List<Long> ids = intersect.stream().map(Long::valueOf).collect(Collectors.toList());
// 4.查询用户
List<UserDTO> users = userService.listByIds(ids)
.stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());
return Result.ok(users);
}
}
}
3、关注推送
1. Feed流
Feed流产品有两种常见模式:
- Timeline:不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注。例如朋友圈
- 优点:信息全面,不会有缺失。并且实现也相对简单
- 缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低
- 智能排序:利用智能算法屏蔽掉违规的、用户不感兴趣的内容。推送用户感兴趣信息来吸引用户
- 优点:投喂用户感兴趣信息,用户粘度很高,容易沉迷
- 缺点:如果算法不精准,可能起到反作用
2. Timeline模式
该模式的实现方案有三种:
- 拉模式:也叫做读扩散
- 推模式:也叫做写扩散
- 推拉结合:也叫做读写混合,兼具推和拉两种模式的优点
3. 方案对比
项目 | 拉模式 | 推模式 | 推拉结合 |
---|---|---|---|
写比例 | 低 | 高 | 中 |
读比例 | 高 | 低 | 中 |
用户读取延迟 | 高 | 低 | 低 |
实现难度 | 复杂 | 简单 | 很复杂 |
使用场景 | 很少使用 | 用户量少、没有大V | 过千万的用户量,有大V |
4. 关注推送功能
- 修改新增探店笔记的业务,在保存blog到数据库的同时,推送到粉丝的收件箱
- 收件箱满足可以根据时间戳排序,必须用Redis的数据结构实现
- 查询收件箱数据时,可以实现分页查询
4.1 Controller
package erer.redis.project.controller;
@RestController
@RequestMapping("/blog")
public class BlogController {
@Resource
private IBlogService blogService;
@PostMapping
public Result saveBlog(@RequestBody Blog blog) {
// // 获取登录用户
// UserDTO user = UserHolder.getUser();
// blog.setUserId(user.getId());
// // 保存探店博文
// blogService.save(blog);
// // 返回id
// return Result.ok(blog.getId());
return blogService.saveBlog(blog);
}
...
}
4.2 Service
package erer.redis.project.service.impl;
@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {
@Resource
private IUserService userService;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private IFollowService followService;
@Override
public Result saveBlog(Blog blog) {
// 1.获取登录用户
UserDTO user = UserHolder.getUser();
blog.setUserId(user.getId());
// 2.保存探店笔记
boolean isSuccess = save(blog);
if (!isSuccess) {
return Result.fail("新增笔记失败!");
} else {
// 3.查询笔记作者所有粉丝 select * from tb_follow where follow_user_id = ?
List<Follow> follows = followService
.query()
.eq("follow_user_id", user.getId())
.list();
// 4.推送笔记id给所有粉丝
long time = System.currentTimeMillis();
for (Follow follow : follows) {
// 获取粉丝 id
Long userId = follow.getUserId();
// 推送
String key = RedisConstants.FEED_KEY + userId;
stringRedisTemplate.opsForZSet().add(key, blog.getId().toString(), time);
}
// 5.返回 id
return Result.ok(blog.getId());
}
}
}
5. Feed流的分页问题
5.1 问题描述
- Feed流中的数据会不断更新,所以数据的角标也在变化,因此不能采用传统的分页模式。
5.2 滚动分页
6. 滚动分页查询
- 参数:
- max:当前时间戳(上一次查询的最小时间戳)
- min:0
- offset:0(上一次查询结果中,与最小值一样的元素个数)
- count:3(单次查询条数)
6.1 POJO
package erer.redis.project.dto;
@Data
public class ScrollResult {
private List<?> list;
private Long minTime;
private Integer offset;
}
6.2 Controller
package erer.redis.project.controller;
@RestController
@RequestMapping("/blog")
public class BlogController {
@Resource
private IBlogService blogService;
// URL: http://localhost:8080/api/blog/of/follow?&lastId=1650055922613
@GetMapping("/of/follow")
public Result queryBlogOfFollow(
@RequestParam("lastId") Long max,
@RequestParam(value = "offset", defaultValue = "0") Integer offset) {
return blogService.queryBlogOfFollow(max, offset);
}
}
6.3 Service
package erer.redis.project.service.impl;
@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {
@Resource
private IUserService userService;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private IFollowService followService;
@Override
public Result queryBlogOfFollow(Long max, Integer offset) {
// 1.获取登录用户
UserDTO user = UserHolder.getUser();
String key = RedisConstants.FEED_KEY + user.getId();
// 2.查询收件箱 zRevRangeByScore key max min withScores limit offset count
Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
.reverseRangeByScoreWithScores(key, 0, max, offset, 2);
if (typedTuples == null || typedTuples.isEmpty()) {
return Result.ok();
} else {
// 3.解析结果:blogId、minTime(时间戳)、offset
List<Long> ids = new ArrayList<>(typedTuples.size());
long minTime = 0;
int minTimNum = 1;
for (ZSetOperations.TypedTuple<String> tuple : typedTuples) {
// 获取id
ids.add(Long.valueOf(tuple.getValue()));
// 获取时间戳
long time = tuple.getScore().longValue();
if (time == minTime) {
minTimNum++;
} else {
minTime = time;
minTimNum = 1;
}
}
// 4.根据id查询blog
String idsStr = StrUtil.join(",", ids);
List<Blog> blogs = query() // 查询
.in("id", ids) // 条件:in
.last("order by field(id," + idsStr + ")") // 拼接 order语句
.list();// 查询结果封装为 list
for (Blog blog : blogs) {
// 补全 blog 用户信息
queryBlogUser(blog);
// 查询 blog 是否被点赞
isBlogLiked(blog);
}
// 5.封装返回结果
ScrollResult result = new ScrollResult();
result.setList(blogs);
result.setOffset(minTimNum);
result.setMinTime(minTime);
return Result.ok(result);
}
}
private void queryBlogUser(Blog blog) {
// 获取编写 blog 的 用户id
Long userId = blog.getUserId();
// 根据 用户id 获取用户信息
User user = userService.getById(userId);
// blog 插入 用户昵称
blog.setName(user.getNickName());
// blog 插入 用户头像
blog.setIcon(user.getIcon());
}
private void isBlogLiked(Blog blog) {
// 1.获取登录用户
UserDTO user = UserHolder.getUser();
if (user == null) {
// 用户未登录,无需查询是否点赞
return;
}
// 2.获取用户id
Long userId = user.getId();
// 3.判断当前用户是否点赞 BLOG_LIKED_KEY = "blog:liked:"
String key = RedisConstants.BLOG_LIKED_KEY + blog.getId();
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
// 4.如果点过赞 结果为 true
blog.setIsLike(score != null);
}
}
八、附近商户搜索
1、获取信息
- 在首页中点击某个频道,即可看到频道下的商户:
2、信息分类
@SpringBootTest()
class ProjectApplicationTests {
@Resource
private ShopServiceImpl shopService;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Test
void loadShopData() {
// 1.查询店铺信息
List<Shop> list = shopService.list();
// 2.根据 typeId 分组,id一致的放到一个集合
Map<Long, List<Shop>> map = list.stream().collect(Collectors.groupingBy(Shop::getTypeId));
// 3.分批完成写入 Redis
for (Map.Entry<Long, List<Shop>> entry : map.entrySet()) {
// 获取类型 id
Long typeId = entry.getKey();
String key = "shop:geo:" + typeId;
// 获取同类型的店铺的集合
List<Shop> value = entry.getValue();
List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(value.size());
// 写入 redis
for (Shop shop : value) {
//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);
}
}
}
3、距离排序
package erer.redis.project.service.impl;
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
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 = RedisConstants.SHOP_GEO_KEY + typeId;
RedisGeoCommands.GeoRadiusCommandArgs args = RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs()
.includeDistance()
.limit(end);
GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo().search(
key,
GeoReference.fromCoordinate(x, y),
new Distance(5000),
args
);
// 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());
}
// 截取 from ~ end 的部分
List<Long> ids = new ArrayList<>(list.size());
Map<String, Distance> distanceMap = new HashMap<>(list.size());
list.stream().skip(from).forEach(result -> {
// 获取店铺 id
String name = result.getContent().getName();
ids.add(Long.valueOf(name));
Distance distance = result.getDistance();
distanceMap.put(name, 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);
}
}
九、用户签到
1、BitMap
我们按月来统计用户签到信息,签到记录为1,未签到则记录为0。
- 把每一个bit位对应当月的每一天,形成了映射关系。用0和1标示业务状态,这种思路就称为 位图(BitMap)。
- **Redis **中是利用 string 类型数据结构实现 BitMap,因此最大上限是512M,转换为bit则是232个bit位。
2、操作命令
- [SETBIT]:向指定位置(offset)存入一 个 0 或 1
- [GETBIT]:获取指定位置(offset)的bit值
- [BITCOUNT]:统计BitMap中值为1的bit位的数量
- [BITFIELD]:操作(查询、修改、自增)BitMap中bit数组中的指定位置(offset)的值
- [BITFIELD_RO]:获取BitMap中bit数组,并以十进制形式返回
- [BITOP]:将多个BitMap的结果做位运算(与 、或、异或)
- [BITPOS]:查找 bit 数组中指定范围内第一个0或1出现的位置
127.0.0.1:6379> setbit bm1 0 1
(integer) 0
127.0.0.1:6379> setbit bm1 1 1
(integer) 0
127.0.0.1:6379> setbit bm1 2 1
(integer) 0
127.0.0.1:6379> setbit bm1 5 1
(integer) 0
127.0.0.1:6379> setbit bm1 6 1
(integer) 0
127.0.0.1:6379> setbit bm1 7 1
(integer) 0
127.0.0.1:6379> getbit bm1 2
(integer) 1
127.0.0.1:6379> getbit bm1 3
(integer) 0
127.0.0.1:6379> bitcount bm1
(integer) 6
-- 范围查询 u:有符号,i:无符号, 2:获取2个bit位,0:从0号位开始获取
127.0.0.1:6379> bitfield bm1 get u2 0
1) (integer) 3
127.0.0.1:6379> bitfield bm1 get u3 0
1) (integer) 7
127.0.0.1:6379> bitfield bm1 get u3 1
1) (integer) 6
-- 查找0、1第一次出现的位置
127.0.0.1:6379> bitpos bm1 0
(integer) 3
127.0.0.1:6379> bitpos bm1 1
(integer) 0
3、签到功能
package erer.redis.project.service.impl;
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result sign() {
// 1.获取当前登录的用户
Long userId = UserHolder.getUser().getId();
// 2.获取日期
LocalDateTime now = LocalDateTime.now();
// 3.拼接 key
String keySuffix = now.format(DateTimeFormatter.ofPattern("yyyyMM"));
String key = RedisConstants.USER_SIGN_KEY + userId + ":" + keySuffix;
// 4.获取今天是本月第几天
int dayOfMonth = now.getDayOfMonth();
// 5.写入 Redis
stringRedisTemplate.opsForValue().setBit(key, dayOfMonth-1, true);
return Result.ok();
}
}
4、签到测试
- 发送请求
- 测试结果
5、签到统计
package erer.redis.project.service.impl;
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result signCount() {
// 1.获取当前登录的用户
Long userId = UserHolder.getUser().getId();
// 2.获取日期
LocalDateTime now = LocalDateTime.now();
// 3.拼接 key
String keySuffix = now.format(DateTimeFormatter.ofPattern("yyyyMM"));
String key = RedisConstants.USER_SIGN_KEY + userId + ":" + keySuffix;
// 4.获取今天是本月第几天
int dayOfMonth = now.getDayOfMonth();
// 5.获取本月截至今天所有的签到记录,返回的是一个十进制的数字
List<Long> result = stringRedisTemplate.opsForValue().bitField(
key,
BitFieldSubCommands
.create()
.get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0)
);
// 6.健壮性判断
if (result == null || result.isEmpty()) {
return Result.ok(0);
}
Long num = result.get(0);
if (num == null || num == 0) {
return Result.ok(0);
}
// 7.循环遍历
int count = 0;
while (true) {
// 与 1 做与运算,获取数字最后亿个 bit 位
if ((num & 1) == 0) {
// 如果为 0,说明未签到,结束循环
break;
} else {
// 如果不为0,说明签到,计数器 +1
count++;
}
// 把数字右移一位,抛弃掉最后一位 bit
num >>>= 1;
}
return Result.ok(count);
}
}
6、统计测试
十、UV统计
1、HyperLogLog
- UV:全称Unique Visitor,也叫独立访客量,是指通过互联网访问、浏览这个网页的自然人。1天内同一个用户多次访问该网站,只记录1次。
- PV:全称Page View,也叫页面访问量或点击量,用户每访问网站的一个页面,记录1次PV,用户多次打开页面,则记录多次PV。往往用来衡量网站的流量。
UV统计在服务端做会比较麻烦,因为要判断该用户是否已经统计过了,需要将统计过的用户信息保存。但是如果每个访问的用户都保存到Redis中,数据量会非常恐怖。
Hyperloglog(HLL)是从Loglog算法派生的概率算法,用于确定非常大的集合的基数,而不需要存储其所有值。相关算法原理大家可以参考:https://juejin.cn/post/6844903785744056333#heading-0
Redis中的HLL是基于string结构实现的,单个HLL的内存永远小于16kb,内存占用低的令人发指!作为代价,其测量结果是概率性的,有小于0.81%的误差。不过对于UV统计来说,这完全可以忽略。
2、使用方法
127.0.0.1:6379> pfadd hl e1 e2 e3 e4 e5
(integer) 1
127.0.0.1:6379> pfcount hl
(integer) 5
127.0.0.1:6379> pfadd hl e1 e2 e3 e4 e5
(integer) 0
127.0.0.1:6379> pfcount hl
(integer) 5
3、统计测试
@SpringBootTest()
class ProjectApplicationTests {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Test
void testHyperLogLog() {
// 准备数组,装用户数据
String[] users = new String[1000];
// 数组角标
int index = 0;
for (int i = 1; i <= 1000000; i++) {
// 赋值
users[index++] = "user_" + i;
// 每1000条发送一次
if (i % 1000 == 0) {
index = 0;
stringRedisTemplate.opsForHyperLogLog().add("hll1", users);
}
}
// 统计数量
Long size = stringRedisTemplate.opsForHyperLogLog().size("hll1");
System.out.println("size = " + size);
}
}
sTemplate stringRedisTemplate;
@Override
public Result sign() {
// 1.获取当前登录的用户
Long userId = UserHolder.getUser().getId();
// 2.获取日期
LocalDateTime now = LocalDateTime.now();
// 3.拼接 key
String keySuffix = now.format(DateTimeFormatter.ofPattern("yyyyMM"));
String key = RedisConstants.USER_SIGN_KEY + userId + ":" + keySuffix;
// 4.获取今天是本月第几天
int dayOfMonth = now.getDayOfMonth();
// 5.写入 Redis
stringRedisTemplate.opsForValue().setBit(key, dayOfMonth-1, true);
return Result.ok();
}
}