设置拦截器,略
单体应用时用户的会话信息保存在session中,session存在于服务器端的内存中,由于前前后后用户只针对一个web服务器,所以没啥问题。但是一到了web服务器集群的环境下(我们一般都是用Nginx做负载均衡,若是使用了轮询等这种请求分配策略),就会导致用户小a在A服务器登录了,session存在于A服务器中,但是第二次请求被分配到了B服务器,由于B服务器中没有用户小a的session会话,导致用户小a还要再登陆一次,以此类推。这样用户体验很不好。当然解决办法也有很多种,比如同一个用户分配到同一个服务处理、使用cookie保持用户会话信息等。
因此,要解决这样的问题必须满足以下条件:
生成随机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有效期的刷新
之后来到登陆拦截器,如果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有效期,因此我们需要再加一个拦截器,并将大部分操作放进新的拦截器中
从redis中查找->有则返回没有则从mysql获取并存入redis
选择在更新数据库的同时更新缓存。
操作缓存和数据库时有三个问题需要考虑:
更新缓存:每次更新数据库都更新缓存,无效写操作较多
删除缓存:更新数据库时让缓存失效,查询时再更新缓存
如何保证缓存与数据库的操作的同时成功或失败?
单体系统,将缓存与数据库操作放在一个事务
分布式系统,利用TCC等分布式事务方案
先操作缓存还是先操作数据库?
先操作db,再删缓存出现问题概率较小
采用先更新数据库,后删除缓存,更新操作应加上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("更新成功");
}
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
常见的解决方案有两种:
缓存空对象
优点:实现简单,维护方便
缺点:额外的内存消耗,可能造成短期的不一致
适合命中不高,但可能被频繁更新的数据
布隆过滤
优点:内存占用较少,没有多余key
缺点:实现复杂,存在误判可能
适合命中不高,但是更新不频繁的数据
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案:
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
常见的解决方案有两种:
@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;
}
}
多线程同时访问库存,就会多减库存,将库存减到负数
解决方案:查询当前用户是否下过单
注意问题:
概念:满足分布式系统或者集群模式下多进程可见并互斥的锁
实现:使用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);
}
由于可能发生的线程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);
}
}
}
即解锁过程无法保证原子性,使用lua脚本执行解锁,即可保证原子性
--获取锁
local id = redis.call('get',KEYS[1])
--对比
if( id == ARGV[1]) then
return redis.call('del',KEYS[1])
end
return 0
使用静态变量初始化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);
引入依赖
<dependency>
<groupId>org.redissongroupId>
<artifactId>redissonartifactId>
<version>3.17.7version>
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();
}
-- 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);
}
问题:
xgroup create stream.orders g1 0 mkstream
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();
}
按照点赞时间先后排序,返回Top5的用户
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);
}
主要使用了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);
}
@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());
}
使用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);
});
使用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();
}