Redis点评项目重点
用户登录模块
1.设置登录拦截
设置拦截器,略
2.使用session存储验证码以及用户信息
单体应用时用户的会话信息保存在session中,session存在于服务器端的内存中,由于前前后后用户只针对一个web服务器,所以没啥问题。但是一到了web服务器集群的环境下(我们一般都是用Nginx做负载均衡,若是使用了轮询等这种请求分配策略),就会导致用户小a在A服务器登录了,session存在于A服务器中,但是第二次请求被分配到了B服务器,由于B服务器中没有用户小a的session会话,导致用户小a还要再登陆一次,以此类推。这样用户体验很不好。当然解决办法也有很多种,比如同一个用户分配到同一个服务处理、使用cookie保持用户会话信息等。
因此,要解决这样的问题必须满足以下条件:
- 数据共享
- 内存存储
- key、value结构
3.使用redis替代session(hash)
-
生成随机token作为登陆令牌
使用uuid生成随机字符串,可升级为JWT
-
用户信息使用redis的hash结构存储
对象转hashmap,使用hutool的api
final Map<String, Object> map = BeanUtil.beanToMap(userDTO, new HashMap<>(), CopyOptions.create().setIgnoreNullValue(true).setFieldValueEditor((fieldName, fieldValue)->{ return fieldValue.toString(); }) );
存入redis并设置过期时间
//存入redis redisTemplate.opsForHash().putAll(LOGIN_USER_KEY + token, map); //设置过期时间 redisTemplate.expire(LOGIN_USER_KEY + token, 3000, TimeUnit.MINUTES);
-
token有效期的刷新
- 首先,对于每个请求,我们首先根据token判断用户是否已经登陆(是否已经保存到ThreadLocal中),如果没有登陆,放行交给登陆拦截器去做,如果已经登陆,刷新token的有效期,然后放行。
-
之后来到登陆拦截器,如果ThreadLocal没有用户,说明没有登陆,拦截,否则放行。
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { //获取token String header = request.getHeader("authorization"); //请求头为null,放行 if (header == null) { response.setStatus(401); return false; } //header即为token,根据token从redis中查询用户信息 final Map<Object, Object> userMap = redisTemplate.opsForHash().entries(LOGIN_USER_KEY + header); if (userMap.isEmpty()) { response.setStatus(401); return false; } UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false); //通过ThreadLocale保存用户信息 UserHolder.saveUser(userDTO); //刷新token有效期 redisTemplate.expire(LOGIN_USER_KEY + header, LOGIN_USER_TTL, TimeUnit.MINUTES); return true; }
-
对于第三点中token的刷新存在问题,如果用户一直都访问不需要被拦截的网页,就不会出发拦截器,进而不会刷新token有效期,因此我们需要再加一个拦截器,并将大部分操作放进新的拦截器中
商户缓存模块
1.设置商户信息,商户分类缓存
从redis中查找->有则返回没有则从mysql获取并存入redis
2.缓存更新策略
选择在更新数据库的同时更新缓存。
操作缓存和数据库时有三个问题需要考虑:
- 删除缓存还是更新缓存?
更新缓存:每次更新数据库都更新缓存,无效写操作较多
删除缓存:更新数据库时让缓存失效,查询时再更新缓存
-
如何保证缓存与数据库的操作的同时成功或失败?
单体系统,将缓存与数据库操作放在一个事务
分布式系统,利用TCC等分布式事务方案 -
先操作缓存还是先操作数据库?
先操作db,再删缓存出现问题概率较小
3.更新商铺信息
采用先更新数据库,后删除缓存,更新操作应加上spring的事务,但需要注意,spring事务可以回滚数据库操作,但无法回滚redis操作,不过redis数据在此业务中无关紧要,因此无需额外处理
//增加事务,但spring事务无法回滚redis操作
@Override
@Transactional
public Result updateShopInfo(Shop shop) {
Long id = shop.getId();
if (id == null){
return Result.fail("商户id不存在");
}
//1.更新数据库
updateById(shop);
//2.删除缓存
redisTemplate.delete(CACHE_SHOP_KEY+id);
return Result.ok("更新成功");
}
4.缓存穿透解决
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
常见的解决方案有两种:
-
缓存空对象
优点:实现简单,维护方便
缺点:额外的内存消耗,可能造成短期的不一致
适合命中不高,但可能被频繁更新的数据 -
布隆过滤
优点:内存占用较少,没有多余key
缺点:实现复杂,存在误判可能
适合命中不高,但是更新不频繁的数据
4.缓存雪崩解决
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案:
- 给不同的Key的TTL添加随机值
- 利用Redis集群提高服务的可用性
- 给缓存业务添加降级限流策略
- 给业务添加多级缓存
5.缓存击穿解决
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
常见的解决方案有两种:
- 互斥锁
- 逻辑过期
优惠券秒杀模块
1.redis仿雪花算法全局id
@Component
public class RedisIdWorker {
/**
* 开始时间戳
*/
//2022年1月1日
private static final long BEGIN_TIMESTAMP = 1640995200L;
/**
* 序列号的位数
*/
private static final int COUNT_BITS = 32;
@Autowired
private StringRedisTemplate stringRedisTemplate;
public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
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("yyyy:MM:dd"));
// 2.2.自增长
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
// 3.拼接并返回
return timestamp << COUNT_BITS | count;
}
}
2.库存超卖
多线程同时访问库存,就会多减库存,将库存减到负数
- 悲观锁,让程序串行执行,性能太差,不考虑
- 乐观锁,在更新时判断是否有其他线程在修改
3.库存新需求:一人一单
解决方案:查询当前用户是否下过单
注意问题:
- 事务和锁的关系,应在事务提交后再释放锁
- 事务失效
- 集群模式下,单机锁会失效
4.分布式锁
概念:满足分布式系统或者集群模式下多进程可见并互斥的锁
实现:使用redis的setnx替代单机锁
锁设计:一个用户一把锁,使用用户id作为key,uuid作为value
加锁
@Override
public boolean tryLock(long timeoutSec) {
long id = Thread.currentThread().getId();
Boolean success = redisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, id + "", timeoutSec, TimeUnit.SECONDS);
//避免拆箱空指针
return BooleanUtil.isTrue(success);
}
释放锁
@Override
public void unlock() {
Boolean delete = redisTemplate.delete(KEY_PREFIX + name);
log.info("锁删除:{}", delete);
}
5.分布式锁误删问题
问题
由于可能发生的线程1业务阻塞,ttl到了自动释放锁,线程2再次获取相同的锁后,此时线程1执行完业务并删除锁,导致线程2的锁被删除
解决方案:
为锁添加uuid标识,删除锁应先判断是否是自己的锁
改进代码
public class SimpleRedisLock implements ILock {
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
private String name;
private StringRedisTemplate redisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate redisTemplate) {
this.name = name;
this.redisTemplate = redisTemplate;
}
@Override
public boolean tryLock(long timeoutSec) {
long id = Thread.currentThread().getId();
Boolean success = redisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, ID_PREFIX + id, timeoutSec, TimeUnit.SECONDS);
//避免拆箱空指针
return BooleanUtil.isTrue(success);
}
@Override
public void unlock() {
//当前线程锁
String threadId = ID_PREFIX + Thread.currentThread().getId();
//redis中的锁
String s = redisTemplate.opsForValue().get(KEY_PREFIX + name);
//锁一致才能删除锁
if (threadId.equals(s)){
Boolean delete = redisTemplate.delete(KEY_PREFIX + name);
log.info("锁删除:{}", delete);
}
}
}
6.分布式锁的原子性问题
即解锁过程无法保证原子性,使用lua脚本执行解锁,即可保证原子性
使用lua脚本解决
--获取锁
local id = redis.call('get',KEYS[1])
--对比
if( id == ARGV[1]) then
return redis.call('del',KEYS[1])
end
return 0
java调用lua脚本
使用静态变量初始化lua脚本,避免多次io
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("lua/redis-unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
调用
//当前线程锁
String threadId = ID_PREFIX + Thread.currentThread().getId();
redisTemplate.execute(UNLOCK_SCRIPT, Collections.singletonList(KEY_PREFIX + name), threadId);
7.Redission
基于stenx的分布式锁存在的问题
配置
引入依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.17.7</version>
</dependency>
配置redisson客户端
@Configuration
public class RedisConfig {
@Bean
public RedissonClient redissonClient(){
Config config = new Config();
config.useSingleServer().setAddress("39.99.141.194:6379").setPassword("mqq05*09*");
return Redisson.create(config);
}
}
使用
替换掉自定义的锁即可,使用方式大致一样,但他的trylock
有三个参数,如tryLock(5,10, TimeUnit.SECONDS);其含义是10秒钟后释放锁,如果获取锁失败,在5s内会不断重试
@Autowired
private RedissonClient redissonClient;
RLock lock = redissonClient.getLock("lock:order:" + id);
boolean b = lock.tryLock(1,10, TimeUnit.SECONDS);
if (!b) {
return Result.fail("禁止重复下单");
}
try {
return createOrder(voucherId);
} finally {
lock.unlock();
}
可重入锁原理
8.Redis优化秒杀
秒杀逻辑优化
- 使用redis判断库存以及库存预减
- 使用异步队列进行库存扣减
需求
lua脚本实现秒杀资格判断
-- 1.参数列表
-- 1.1.优惠券id
local voucherId = ARGV[1]
-- 1.2.用户id
local userId = ARGV[2]
-- 1.3.订单id
local orderId = ARGV[3]
-- 2.数据key
-- 2.1.库存key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2.订单key
local orderKey = 'seckill:order:' .. voucherId
-- 3.脚本业务
-- 3.1.判断库存是否充足 get stockKey,tonumber()强转数字
if(tonumber(redis.call('get', stockKey)) <= 0) then
-- 3.2.库存不足,返回1
return 1
end
-- 3.2.判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember', orderKey, userId) == 1) then
-- 3.3.存在,说明是重复下单,返回2
return 2
end
-- 3.4.扣库存 incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 3.5.下单(保存用户)sadd orderKey userId
redis.call('sadd', orderKey, userId)
-- 3.6.发送消息到队列中, XADD stream.orders * k1 v1 k2 v2 ...
redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)
return 0
基于阻塞队列实现异步秒杀
//创建阻塞队列
private BlockingQueue<VoucherOrder> orders = new ArrayBlockingQueue<>(1024 * 1024);
//创建线程池
private ExecutorService executorService = Executors.newCachedThreadPool();
//将订单放入阻塞队列
orders.add(voucherOrder);
//当前类初始化完毕后执行
@PostConstruct
private void init() {
executorService.submit(new VoucherOrderHandler());
}
private class VoucherOrderHandler implements Runnable {
@Override
public void run() {
while (true) {
//从阻塞队列获取订单信息
try {
//取出订单信息
VoucherOrder voucherOrder = orders.take();
//创建订单
createOrder(voucherOrder);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}
//数据库减库存
@Transactional
public void createOrder(VoucherOrder voucherOrder) {
boolean result = seckillVoucherService.update().setSql("stock=stock-1").eq("voucher_id", voucherOrder.getVoucherId()).gt("stock", 0).update();
save(voucherOrder);
}
问题:
- 内存问题,阻塞队列运行在jvm中,有内存溢出的风险
- 安全问题,如果服务意外宕机,阻塞队列的数据将丢失
基于redis消息队列实现异步秒杀(Stream)
创建队列
xgroup create stream.orders g1 0 mkstream
达人探店
完善点赞功能
需求:
- 同一个用户只能点赞一次,再次点击则取消点赞
- 如果当前用户已经点赞,则点赞按钮高亮显示(前端已实现,判断字段Blog类的isLike属性)
实现步骤:
- 给Blog类中添加一个isLike字段,标示是否被当前用户点赞
- 修改点赞功能,利用Redis的set集合判断是否点赞过,未点赞过则点赞数+1,已点赞过则点赞数-1
- 修改根据id查询Blog的业务,判断当前登录用户是否点赞过,赋值给isLike字段
- 修改分页查询Blog业务,判断当前登录用户是否点赞过,赋值给isLike字段
public Result likeBlog(Long id) {
// 1.获取登录用户
Long userId = UserHolder.getUser().getId();
// 2.判断当前登录用户是否已经点赞
String key = "blog:liked:" + id;
Double score = redisTemplate.opsForZSet().score(key, userId.toString());
if (score == null) {
// 3.如果未点赞,可以点赞
// 3.1.数据库点赞数 + 1
boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
// 3.2.保存用户到Redis的set集合 zadd key value score
if (isSuccess) {
redisTemplate.opsForZSet().add(key, userId.toString(), System.currentTimeMillis());
}
} else {
// 4.如果已点赞,取消点赞
// 4.1.数据库点赞数 -1
boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
// 4.2.把用户从Redis的set集合移除
if (isSuccess) {
redisTemplate.opsForZSet().remove(key, userId.toString());
}
}
return Result.ok();
}
点赞排行榜(SortedSet)
需求:
按照点赞时间先后排序,返回Top5的用户
set和SortedSet优劣
使用SortedSet:
- 通过 ZSCORE 命令获取 SortedSet 中存储的元素的相关的 SCORE 值。
- 通过 ZRANGE 命令获取指定范围内的元素。
public Result queryBlogLikes(Long id) {
String key = "blog:liked:" + id;
// 1.查询top5的点赞用户 zrange key 0 4
Set<String> top5 = redisTemplate.opsForZSet().range(key, 0, 4);
if (top5 == null || top5.isEmpty()) {
return Result.ok(Collections.emptyList());
}
// 2.解析出其中的用户id
List<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList());
String idStr = StrUtil.join(",", ids);
// 3.根据用户id查询用户 WHERE id IN ( 5 , 1 ) ORDER BY FIELD(id, 5, 1)
List<UserDTO> userDTOS = userService.query()
.in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list()
.stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());
// 4.返回
return Result.ok(userDTOS);
}
好友关注
共同关注(set)
主要使用了redis的set集合的求交集功能
Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key, key2);
@Override
public Result followCommons(Long id) {
// 1.获取当前用户
Long userId = UserHolder.getUser().getId();
String key = "follows:" + userId;
// 2.求交集
String key2 = "follows:" + id;
Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key, key2);
if (intersect == null || intersect.isEmpty()) {
// 无交集
return Result.ok(Collections.emptyList());
}
// 3.解析id集合
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);
}
关注推送(feed流)
feed流分类
实现关注推送(SortedSet)
@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("新增笔记失败!");
}
// 3.查询笔记作者的所有粉丝 select * from tb_follow where follow_user_id = ?
List<Follow> follows = followService.query().eq("follow_user_id", user.getId()).list();
// 4.推送笔记id给所有粉丝
for (Follow follow : follows) {
// 4.1.获取粉丝id
Long userId = follow.getUserId();
// 4.2.推送
String key = FEED_KEY + userId;
redisTemplate.opsForZSet().add(key, blog.getId().toString(), System.currentTimeMillis());
}
// 5.返回id
return Result.ok(blog.getId());
}
附近商铺(geo)
附近商铺搜索
需求:
- 前端传入商铺id,经纬度,后端计算范围内的商铺
- 采用分页返回
- 距离从近到远
实现:
-
使用redis的geo数据类型,存储商铺的经纬度,key设计,商铺类型作为key,商铺id作为value,商铺经纬度作为score存入
-
查询并返回,实现代码如下:
@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 = SHOP_GEO_KEY + typeId; GeoResults<RedisGeoCommands.GeoLocation<String>> results = redisTemplate.opsForGeo() // GEOSEARCH key BYLONLAT x y BYRADIUS 10 WITHDISTANCE .search( key, GeoReference.fromCoordinate(x, y), new Distance(5000), RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().includeDistance().limit(end) ); // 4.解析出id if (results == null) { return Result.ok(Collections.emptyList()); } List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent(); if (list.size() <= from) { // 没有下一页了,结束 return Result.ok(Collections.emptyList()); } // 4.1.截取 from ~ end的部分 List<Long> ids = new ArrayList<>(list.size()); Map<String, Distance> distanceMap = new HashMap<>(list.size()); list.stream().skip(from).forEach(result -> { // 4.2.获取店铺id String shopIdStr = result.getContent().getName(); ids.add(Long.valueOf(shopIdStr)); // 4.3.获取距离 Distance distance = result.getDistance(); distanceMap.put(shopIdStr, distance); }); // 5.根据id查询Shop String idStr = StrUtil.join(",", ids); List<Shop> shops = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list(); for (Shop shop : shops) { shop.setDistance(distanceMap.get(shop.getId().toString()).getValue()); } // 6.返回 return Result.ok(shops); }
关键查询代码:
GeoResults<RedisGeoCommands.GeoLocation<String>> results = redisTemplate.opsForGeo() // GEOSEARCH key BYLONLAT x y BYRADIUS 10 WITHDISTANCE .search( key, GeoReference.fromCoordinate(x, y), new Distance(5000), RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs() //由近到远排序 .sortAscending() //结果包含距离 .includeDistance() //查询end条 .limit(end) );
难点:逻辑分页
由于redis返回的是0-end条的数据,因此需要手动处理,截取数据
// 4.1.截取 from ~ end的部分
List<Long> ids = new ArrayList<>(list.size());
Map<String, Distance> distanceMap = new HashMap<>(list.size());
list.stream().skip(from).forEach(result -> {
// 4.2.获取店铺id
String shopIdStr = result.getContent().getName();
ids.add(Long.valueOf(shopIdStr));
// 4.3.获取距离
Distance distance = result.getDistance();
distanceMap.put(shopIdStr, distance);
});
用户签到(bitmap)
签到
分析
使用mysql,硬盘占用过大
采用redis的bitmap结构
实现
@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 = USER_SIGN_KEY + userId + keySuffix;
// 4.获取今天是本月的第几天
int dayOfMonth = now.getDayOfMonth();
// 5.写入Redis SETBIT key offset 1
redisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);
return Result.ok();
}