基于Redis实现共享Session登陆

- 对于将用户的信息保存到Redis中的操作 String结构不方便,如果需要修改单个字段需要改整个json序列
- 这里使用Hash结构

保存手机信息
// session.setAttribute("code",code);
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY+phone,code,LOGIN_CODE_TTL, TimeUnit.MINUTES);保存用户信息
// //保存到session
// session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));
//保存用户信息到Redis中
//生成随机token作为登陆令牌,将user对象转换为Hash存储
String token = UUID.randomUUID().toString(true);
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO);
stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY+token,userMap);
stringRedisTemplate.expire(LOGIN_USER_KEY,LOGIN_USER_TTL,TimeUnit.MINUTES);
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
//校验手机号并看用户是否存在
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机格式错误");
}
// //从session中获取验证码
// Object cacheCode = session.getAttribute("code");
//改为redis
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
String code = loginForm.getCode();
if(cacheCode==null || !cacheCode.equals(code)){
return Result.fail("验证码错误");
}
User user = query().eq("phone",phone).one();
if(user==null){
user = createUserByPhone(phone);
}
// //保存到session
// session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));
//保存用户信息到Redis中
//生成随机token作为登陆令牌,将user对象转换为Hash存储
String token = UUID.randomUUID().toString(true);
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO);
// 确保所有值都是String类型,避免StringRedisTemplate序列化时出错
Map<String, String> stringUserMap = new HashMap<>();
for (Map.Entry<String, Object> entry : userMap.entrySet()) {
stringUserMap.put(entry.getKey(), entry.getValue().toString());
}
stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY + token, stringUserMap);
stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL, TimeUnit.MINUTES);
return Result.ok(token);拦截器
LoginInterceptor.java中注入private StringRedisTemplate stringRedisTemplate;时,因为没有@Compoment等注解,需要手动创建构造函数,通过MvcConfig.java中的入口传入 stringRedisTemplate
于此同时,需要在拦截器的prehandle中获取redis中的tokne并与网络请求中的token做比较来决定是否放行
并将用户信息存入线程,刷新token的有效期
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// //获取session判断是否拦截,如果存在,保存用户信息到treadLocal
// HttpSession session = request.getSession();
// Object user = session.getAttribute("user");
String token = request.getHeader("authorization");
if(StrUtil.isBlank(token)){
response.setStatus(401);
return false;
}
//基于token获取Redis数据
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(RedisConstants.LOGIN_USER_KEY + token);
if(userMap.isEmpty()){
//拦截
response.setStatus(401);
return false;
}
//重新转换为DTO
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
//保存用户到线程
UserHolder.saveUser(userDTO);
// UserHolder.saveUser((UserDTO) user);
//刷新token有效期
stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY+token,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
return true;
}拦截器的优化
因为token的刷新可能会失效,所以在外嵌套一个拦截器,保证刷新token有效期

在多个拦截器的情况下,默认是按添加顺序生效,也可以设置order字段来手动确定
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/user/code",
"/blog/hot",
"/shop?**",
"/shop-type/**",
"/voucher/**",
"/user/login",
"/upload/**"
).order(1);
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);//默认拦截所有路径
}
}商品缓存

添加缓存

缓存更新

这里主要难点是第三种:主动更新
主动更新策略

线程安全问题(CacheAsidePattern模式选择)

- 方案2的概率会比1低非常多,因为缓存的写是微秒级别.
三种经典问题
缓存穿透
- 缓存空对象
- 布隆过滤
- 增强id的复杂度,避免被猜测id规律
- 做好数据的基础格式校验
- 加强用户权限校验
- 做好热点参数的限流

缓存雪崩

- 添加随机TTL
- Redis集群
- 给缓存业务加降级限流策略
- 给业务添加多级缓存
(SpringCloud微服务知识)
缓存击穿(热点key问题)
- 不要有无数个请求过来!

- 互斥锁
- 逻辑过期

互斥锁模式
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 Shop queryWithMutex(Long id){
//根据redis查询是否存在商铺缓存,来决定是否访问数据库和回写操作
String shopJson = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
if(StrUtil.isNotBlank(shopJson)){
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
//判断命中的是否为空字符串(isNotBlank只要非数据就会true)
if(shopJson!=null){
return null;
}
//开始实现缓存重建
//1.获取互斥锁
String lockKey = "lock:shop:"+id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
//2.判断是否成功
//3.失败则休眠并重试
if(!isLock){
Thread.sleep(50);
return queryWithMutex(id);
}
//4.成功,根据id查询数据库
shop = getById(id);
//5.不存在,返回失败
if(shop==null){
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY+id,"",RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;
}
//6.存在,存入redis
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(shop),RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
unlock(lockKey);
}
//7.释放互斥锁
return shop;
}逻辑过期方式

-
先封装一个存储shop信息与过期时间到redis的函数(RedisData为自定义的@Data对象)

-
难点1:从redisData(自定义Data)中取出时间和对象,对象是需要先从String转为RedisData
-
再从RedisData在转出时间和对象,其中对象需要从object转为需要的对象(通过JSONUtil.toBean)

- 创建线程池


缓存工具封装

待完成
优惠券秒杀
全局ID生成器

@Component
public class RedisIdWorker {
private static final long BEGIN_TIMESTAMP = 1735689600L;
private final StringRedisTemplate stringRedisTemplate;
public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
public Long nextId(String keyPrefix){
//生成时间戳
LocalDateTime nowTime = LocalDateTime.now();
long nowSecond = nowTime.toEpochSecond(ZoneOffset.UTC);
long timeStamp = nowSecond-BEGIN_TIMESTAMP;
String data = nowTime.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
long incrementCount = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + data);
return timeStamp<<32| incrementCount;
}
public static void main(String[] agrs){
LocalDateTime time = LocalDateTime.of(2025,1,1,0,0,0);
long second = time.toEpochSecond(ZoneOffset.UTC);
System.out.println("second = " + second);
}
}

业务逻辑

- 单纯逻辑(无解决并发问题)
@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) {
//查询优惠券,判断秒杀是否开始
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
if(voucher.getBeginTime().isAfter(LocalDateTime.now())){
return Result.fail("秒杀尚未开始");
}
if(voucher.getEndTime().isBefore(LocalDateTime.now())){
return Result.fail("秒杀已经结束");
}
//是,判断库存是否充足
if( voucher.getStock()<1){
return Result.fail("库存不足!");
}
boolean success = seckillVoucherService.update()
.setSql("stock = stock -1")
.eq("voucher_id",voucherId).update();
if(!success){
return Result.fail("库存不足1");
}
//创建订单
VoucherOrder voucherOrder = new VoucherOrder();
Long id = redisIdWorker.nextId("order");
voucherOrder.setId(id);
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
return Result.ok(id);
}
}超卖问题

弊端:
- 性能太低
乐观锁
这里的id也就是有人说的版本号

弊端
- 失败率高
boolean success = seckillVoucherService.update()
.setSql("stock = stock -1")//set
.eq("voucher_id",voucherId).gt("stock",0)//where
.update();
if(!success){
return Result.fail("库存不足1");
}一人一单
事务可能失效的情况
- 拿到代理对象
- 需要配置pom和注解
synchronized (userId1.toString().intern()) {
IVoucherOrderService proxy = (IVoucherOrderService)AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);集群模式下的并发问题


分布式锁

满足分布式系统或集群模式下多进程可见并且互斥的锁
- 多进程可见
- 互斥
- 高可用
- 高性能
- 安全性

基于Redis的分布式锁

优化: 在获取锁的时候添加标识,在释放🔒的时候确认是不是自己的锁
Lua脚本
- 保证redis命令的原子性


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(){
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX+name),
ID_PREFIX+Thread.currentThread().getId()
);
}
//
// @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);
// }
//
// }Redisson


快速入门

可重入锁原理
- 类似读者写者问题,最后一个人签到完后释放锁
- 使用Lua脚本实现
- 已实现,只需了解原理


源码阅读(是怎么解决开头的四个问题的),未完成
主从一致性问题

总结

Redis秒杀优化
多加一层Redis负责前置判断,再开线程见库存

思路实现
- 将库存信息保存到Redis(string),将用户信息保存到Reids (set)中

---参数列表
local voucherId = ARGV[1]
local userId = ARGV[2]
---数据key
local stockKey = 'seckill:stock:'..voucherId
local orderKey = 'seckill:order:'..voucherId
---脚本业务
local stock = redis.call('get', stockKey)
if(not stock or tonumber(stock) == nil or tonumber(stock) <= 0) then
return 1
end
if(redis.call('sismember',orderKey,userId)==1) then
return 2
end
---扣库存,保存用户
redis.call('incrby',stockKey,-1)
redis.call('sadd',orderKey,userId)
return 0- 异步下单(写到数据库的操作)未完成后
- 线程池,阻塞队列
阻塞队列
private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024*1024);
orderTasks.add(VoucherOrder);线程池
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 {
VoucherOrder voucherOrder = orderTasks.take();
handleVocherOrder(voucherOrder);
} catch (Exception e) {
log.error("订单处理异常",e);
}
}
}
private void handleVocherOrder(VoucherOrder voucherOrder) {
//使用分布式锁
// SimpleRedisLock lock = new SimpleRedisLock("order:" + userId1, stringRedisTemplate);
Long userId = voucherOrder.getVoucherId();
RLock lock = redissonClient.getLock("lock:order:" + userId);
boolean isLock = lock.tryLock();
if(!isLock){
//获取锁失败🔒
log.error("不许重复下单");
return ;
}
try {
proxy.createVoucherOrder(voucherOrder);
} catch (IllegalStateException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
}
private IVoucherOrderService proxy;
@Override
public Result seckillVoucher(Long voucherId) {
//1.执行lua脚本,根据返回值判断是否有购买资格并保存订单
Long userId = UserHolder.getUser().getId();
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(),userId.toString()
);
int r = result.intValue();
if(r!=0){
return Result.fail(r==1? "库存不足":"不能重复下单");
}
//将下单信息保存在阻塞队列中
long orderId = redisIdWorker.nextId("orderId");
//创建订单
VoucherOrder voucherOrder = new VoucherOrder();
Long id = redisIdWorker.nextId("order");
voucherOrder.setId(id);
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
orderTasks.add(VoucherOrder);
proxy = (IVoucherOrderService)AopContext.currentProxy();
return Result.ok(orderId);
}
Redis消息队列异步秒杀

基于List结构模拟

BRPOP🆚BLPOP
优点:
- 基于Redis存储,不受限于JVM内存上限
- 基于Redis的持久化机制,数据安全有保障
- 满足消息有序性 缺点:
- 无法避免消息丢失(Redis服务挂掉)
- 只支持单消费者
基于PubSub

优点:
- 多生产,多消费
缺点:
- 不支持数据持久化
- 无法避免消息丢失
- 消息堆积有上限,超出时数据丢失
- (你比jvm还拉啊)
基于String
写

读


单消费者模式下 (XREAD)特点:
- 消息可回溯
- 一个消息可被多个消费者读取
- 可以阻塞读取
- 但有消息漏读风险
消费者组




消费者组模式下 (XREADGROUP)特点:
- 消息可回溯
- 一个消息可被多个消费者读取
- 多消费者可以争抢消息,加快消费速度(纯乞丐)
- 可以阻塞读取
- 无消息漏读风险
- 消息确认机制,保证至少被消费一次
- 生产者出问题就玩大单了,只能解决消费者问题
总结


达人探店
发布探店笔记

基于Redis点赞功能

点赞排行榜
-
将原本的string变为zset(ShortedSet)
-
解决id不一致的问题
-
类型转换问题
-
将opsForZSet中的返回的Set<String>提取用户ids
-
自定义的MyBatisPuls语句
String key = "blog:user:"+id;
Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4);
if(top5==null||top5.isEmpty()){
return Result.ok(Collections.emptyList());
}
//解析其中的用户id
// 将 top5 集合中的每个元素通过 Long.valueOf 转换为 Long 类型。
// 将转换后的 Long 类型元素收集到一个新的 List 中,并将这个 List 赋值给变量 ids。
List<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList());
String idStr = StrUtil.join(",",ids);
List<UserDTO> userDTOS = userService.query()
.in("id",ids).last("Order by field(id,"+idStr+")").list()
// List<UserDTO> userDTOS = userService.listByIds(ids)
.stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());好友关注功能

实现共同关注
- 使用set集合来实现交集

关注推送(Feed流)

拉模式(读扩散)

推模式(写扩散)

推拉混合



因为List结构的Redis不支持游动角标,这里使用Sorted-set


- 滑动窗口算法?
redis中的分页查询
@Override
public Result queryBlogOfFollow(Long max, Integer offset) {
Long userId = UserHolder.getUser().getId();
String key = "feed:"+userId;
Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
.reverseRangeByScoreWithScores(key, 0, max, offset, 2);
if(typedTuples==null||typedTuples.isEmpty()){
return Result.ok();
}
List<Long> ids = new ArrayList<>(typedTuples.size());
long minTime = 0;
int os =1;
for (ZSetOperations.TypedTuple<String> typedTuple : typedTuples) {
String value = typedTuple.getValue();
long score = typedTuple.getScore().longValue();
ids.add(Long.valueOf(value));
if(score==minTime){
os++;
}else {
minTime=score;
os=1;
}
}
String idsStr = StrUtil.join(",", ids);
List<Blog> blogs = query().in("id", ids).last("order by field(id," + idsStr + ")").list();
for (Blog blog : blogs) {
User user = userService.getById(userId);
blog.setName(user.getNickName());
blog.setIcon(user.getIcon());
//查询bolg是否被点赞
Double score = stringRedisTemplate.opsForZSet().score("blog:user:" + blog.getId(), UserHolder.getUser().getId().toString());
blog.setIsLike(score!=null);
}
ScrollResult r = new ScrollResult();
r.setList(blogs);
r.setOffset(os);
r.setMinTime(minTime);
return Result.ok(r);
}附近商户(GEO)
GEO数据结构

导入GEO数据
@Test
void loadShopData(){
List<Shop> shopList = shopService.list();
Map<Long, List<Shop>> map = shopList.stream().collect(Collectors.groupingBy(Shop::getTypeId));
for (Map.Entry<Long, List<Shop>> entry : map.entrySet()) {
Long typeId = entry.getKey();
String key = "shop:geo:"+typeId;
List<Shop> value = entry.getValue();
List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>();
for (Shop shop : value) {
locations.add(
new RedisGeoCommands.GeoLocation<>(
shop.getId().toString(),
new Point(shop.getX(),shop.getY())
)
);
}
stringRedisTemplate.opsForGeo().add(key,locations);
}
}查询GEO数据
@Override
public Result queryByType(Integer typeId, Integer current, Double x, Double y) {
//根据是否带有xy坐标参数来决定在数据库中查还是redis中查
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());
}
//计算分页参数,查处redis中的shopId,返回shop
int from = (current-1)*SystemConstants.DEFAULT_PAGE_SIZE;
int end = current*SystemConstants.DEFAULT_PAGE_SIZE;
String key = "shop:geo:"+typeId;
GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo()
.search(
key,
GeoReference.fromCoordinate(x, y),
new Distance(5000),
RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs().includeDistance().limit(end)
);
if(results == null){
return Result.ok(Collections.emptyList());
}
List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();
if (list == null || list.isEmpty()) {
return Result.ok(Collections.emptyList());
}
List<Long> ids = new ArrayList<>(list.size());
Map<String,Distance> distanceMap = new HashMap<>(list.size());
list.stream().skip(from).forEach(result->{
//从list里获取店铺id和距离
String shopIdStr = result.getContent().getName();
ids.add(Long.valueOf(shopIdStr));
distanceMap.put(shopIdStr,result.getDistance());
});
if (ids.isEmpty()) {
return Result.ok(Collections.emptyList());
}
//根据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());
}
return Result.ok(shops);签到

BitMap用法



统计签到
@Override
public Result signcount() {
Long userId = UserHolder.getUser().getId();
LocalDateTime now = LocalDateTime.now();
String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = "sign:"+userId+keySuffix;
int dayOfMonth = now.getDayOfMonth();
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==null||num==0){
return Result.ok(0);
}
int count = 0;
while (true){
if((num&1)==0){
break;
}else {
count++;
}
num >>>=1;
}
return Result.ok(count);
}UV统计


