网上都说黑马的redis的课好,我也来,简单记录一下重要的知识点和解决问题的思路,因为之前了解过一些redis的基本用法,这里就直接进入实战了
源码:https://gitee.com/lzy612/hmdp
首先搞一个lower版本的,后面再用redis进行优化
这个逻辑我也见到很多次了,希望下次可以不假思索,直接说出来,
这里的发送验证码,和登录就不记录了,具体写一写校验登录的方法
拦截器代码
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1、获取session
HttpSession session = request.getSession();
// 2、获取session中的用户
UserDTO userDto = (UserDTO)session.getAttribute("user");
// 3、判断用户是否存在
if (userDto == null) {
// 4、不存在就拦截
response.setStatus(401);
return false;
}
// 5、存在就将信息放到线程中去,并放行
UserHolder.saveUser(userDto);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户,防止内存泄露
UserHolder.removeUser();
}
}
注册代码
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor()).excludePathPatterns(
// 放行
"/user/code", // 发送验证码
"/user/login", // 登录
"/shop/**", // 商店所有的
"blog/hot", // 热点评论的
"/shop-type/**", // 商店类型的
"/voucher/**" // 优惠卷的
);
}
}
知识点
造轮子的嫌疑了
,所以这里使用了将信息放到线程中去,有人要用就从这里面取出来,没人用就算了还有一个问题在使用完这个线程中的数据的时候,可以用拦截器中的after那个方法将线程中的数据删除掉,虽然我现在可能写不写这个东西无所谓,好习惯有头有尾
多台Tomcat并不共享Session,如果一通过nginx访问一个tomcat的集群,你刚在tomcat1机器里面输入了密码登录信息,然后当你下一个请求进入的tomcat2中,但是这个tomcat2并没有session,你还得重新登录,非常的不合理
这里使用的解决方案就是使用redis来取代session
满足的特点:
修改发送验证码
将code存储到session中变成存储到redis中去,将手机号作为唯一值作为key,验证码code作为redis,并且同时设置好过期时间
修改登录部分
这里有点东西
登录过程验证完校验码后,要将得到的用户信息存储到redis中,这里用什么格式去存储,又是一个讲究,
这个应该不算是一个逻辑问题,算是一个用法方面的问题
Map<String, Object> map = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create().setIgnoreNullValue(true).setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
自定义key-value的类型,能设置好多的规则
修改登录拦截器部分
String token = request.getHeader(“authorization”);
然后这里应该也算是技巧,将key-value又转换成实体类,
果然解铃还须系铃人
这里又会发现一个问题,说实话我以为写到这个程度就算是很完美了,想不到啊,太细了!!!!之前不是说逻辑方面,要进入到一个页面就要刷新token的有效期,但是有一些页面不会被拦截,也就是不会刷新token有效期,这里采用了一个拦截器链来解决这个问题
之前不知道这个步骤就叫做缓存命中了
, 缓存命中就是请求到redis中的数据成功返回就是缓存命中
这里感觉还可以没啥说的
这里提到删除缓存和操作数据库谁前谁后的线程安全问题,记录一下
第一组删除缓存,再操作数据库
异常情况
也就是在你删除缓存和更新数据库之间,被趁虚而入了,感觉就是这两个操作不是原子操作
第二组操作数据库,再删除缓存
正常情况
比较两种异常情况的发生概率
这里目的就是查询到正确的数据
其实我觉得这件可以这样理解,我们把查询缓存未命中,查询数据库可以看做是一体的,中间被钻空的几率是很小的,然后删除缓存和更新数据库的执行顺序可以这样看,如果先执行删除缓存的话,再执行更新数据库中间的时间较长,也就是被侵入的几率打;如果先执行更新数据库的话,然后紧接着删除缓存,中间的时间较短,被侵入的几率有,但是很小。也就是从这样来看的话,先操作数据,再删除缓存较为优
我这里的理解可能还不到位,慢慢来吧
新知识点,缓存穿透和布隆过滤
之前是这样的,从开始走到结束的过程中,看起来都挺好的,但是如果有人恶意多次查询redis中没有的数据和数据库中没有的数据,这样就会导致数据库的压力增大,甚至崩坏掉,所以要解决这个问题
视频中是使用缓存空对象来解决
这个逻辑过期就和逻辑删除感觉一样,通过在value中设置一个逻辑的ttl过期时间来代替这个数据的ttl,然后你还可以获取到这个数据但是还是有点小问题,就是数据可能不一致,这里通过新开一个线程去处理新的重建任务,此时其他的线程就会访问一个旧的数据
// 利用互斥锁解决缓存击穿
public Shop queryWithMutex(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1、从redis中查缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2、如果命中直接返回即可
if(StrUtil.isNotBlank(shopJson)){
// 存在直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
// 如果判断命中的是空值的话就直接结束
return shop;
}
// 未命中
if(shopJson != null){
return null;
}
// *实现缓存重建*
// 1.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
Shop shopInfo = null;
try {
boolean isLock = tryLock(lockKey);
// 2.判断是否获取成功
if(!isLock){
// 3.失败,则休眠并重试
Thread.sleep(50);
return queryWithMutex(id);
}
// 如果获取成功的话就再检查一下缓存是否存在,存在就可以直接返回了,不用再重建缓存了
if(StrUtil.isNotBlank(shopJson)){
// 存在直接返回
Shop shopAgain = JSONUtil.toBean(shopJson, Shop.class);
// 如果判断命中的是空值的话就直接结束
return shopAgain;
}
// 如果我们在等待了许久之后,redis还是空的话,我们就根据id查数据库,去重建redis缓存
shopInfo = this.getById(id);
// 模拟网络延迟
Thread.sleep(200);
// 4、判断商铺是否存在
if(shopInfo == null){
// 如果不存在的话会将空值写入reids
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 5、如果存在就将数据写到redis中返回即可
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shopInfo), CACHE_SHOP_TTL, TimeUnit.MINUTES );
} catch (Exception e) {
throw new RuntimeException(e);
}finally {
// 释放锁
unlock(id.toString());
}
return shopInfo;
}
// 尝试获取锁
private boolean tryLock(String key){
Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.MINUTES);
return BooleanUtil.isTrue(aBoolean);
}
// 释放锁
private void unlock(String key){
stringRedisTemplate.delete(key);
}
通过jmeter测试
就查询一次数据库,太美了
// 存储热点数据
public void saveShop2Redis(Long id, Long expireSeconds) throws InterruptedException {
// 1、查询店铺数据
Shop shop = getById(id);
Thread.sleep(200);
// 2、逻辑疯转逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
// 3、写入Redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
public Shop queryWithLogicalExpire(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1、从redis中查缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2、未命中后直接返回
if(StrUtil.isBlank(shopJson)){
return null;
}
// 命中后,先把json反序列出来
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
JSONObject data = (JSONObject)redisData.getData();
Shop shop = JSONUtil.toBean(data, Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 判断是否过期
if(expireTime.isAfter(LocalDateTime.now())){
// 未过期就返回客户信息
return shop;
}
String lockKey = LOCK_SHOP_KEY + id;
// 已经过期,需要缓存重建
// 获取互斥锁
boolean isLock = tryLock(lockKey);
// 判断时候获取锁成功
if(isLock){
// 成功,开启独立线程,去完成缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
// 重建缓存
try {
this.saveShop2Redis(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
}finally {
// 释放锁
unlock(lockKey);
}
});
}
return shop;
}
@Component
public class RedisIdWorker {
// 开始的时间戳
private static final long BEGIN_TIMESTAMP = 1640995200L;
// 序列号的位数
private static final long COUNT_BITS = 32;
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("yyyyMMdd"));
// 2.2、自增长
Long count = stringRedisTemplate.opsForValue().increment("icr" + keyPrefix + ":" + date);
// 3、拼接并返回
return timestamp << COUNT_BITS | count;
}
}
视频中展示的是通过redis自增,说实话不太懂,目前就知道能搞一堆不重复的id,就像那个加密策略一样
下单功能实现
这里一看就是存在一个并发问题,也就是下面的超卖问题
乐观锁版本号法实现:通过多添加一个version,在进行库存减一的时候where中的version就是就是查询的version,如果和数据库中的version一样的就正常,如果不一样的话就不会发生数据库的库存减一,也就是相当于是阻止了非法操作,但是我再考虑这个是不是要在数据库中直接新加一个字段,并且如果没有扣减成功是不是该线程就会直接废了
使用了这个CAS,测试的时候,成功率太低了,就是我上面我猜的,如果库存扣减发生异常的时候,就直接就失败了,都没有机会,直接就消失了
然后这里又修改了一下,把判断库存的数量弄成了大于0,之前那样是太小心了,只要是不按套路来就直接失败了,现在这样就会提高成功率,当即将发生超卖的时候就给拦截住,让他失败
这里通过实现这个逻辑,还是会出现并发的问题,在查询订单的时候涌入一大堆的线程,还是会发生一人多单的情况,这里应该使用悲观锁
去解决这个问题,之前那个是更新优惠卷的操作,所以适合用乐观锁,这里这个是要查询一个数据,不太适合,所以使用悲观锁去解决
@Transactional
public Result createVoucherorder(Long voucherId){
Long userId = UserHolder.getUser().getId();
// 根据优惠券id和用户id查询订单
Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 判断订单是否存在
if (count > 0) {
// 用户已经购买了
return Result.fail("用户已经购买了");
}
// 4、充足就可以扣减库存
boolean success = seckillVoucherService.update().setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
// 多设施一个条件
.gt("stock", 0)
.update();
if (!success) {
// 扣减失败
return Result.fail("库存不足!");
}
// 5、然后就创建订单并返回订单id
VoucherOrder voucherOrder = new VoucherOrder();
// 订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 用户id
voucherOrder.setUserId(userId);
// 代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
return Result.ok(voucherId);
}
这里我有个小疑问,就是虽然是一人一单的实现了,但是我看还是查询了数据库100次,大量的访问数据库的话,会不会数据库会直接崩掉,正常来说不会有一个人闲着没事干,成百上千的访问,就怕那些恶意的,所以我觉得将这种优惠卷的东西,还是通过redis来做一下缓冲,要不就直接把这东西放到redis中去
都要改,要不然的,nginx就不会负载均衡
然后通过debug后就可以复现,一人两单的问题
又发现自己的知识欠缺的地方了,jvm虚拟机要提上日程了
,在集群和分布式的情况下,之前解决一人一单的解决方案就不太行了
这样的话,死锁的风险会很高,因为这三个操作不具有原子性
这个把上述的问题基本解决了一下,就算是获取锁和释放锁直接出了差错,但是还是有过期时间,以免死锁,但是又引入了新的问题,你怎么知道一个合适的过期时间呢,太短了可能没执行完就没了,太长了又影响性能,这就很烦人了
public class SimpleRedisLock implements ILock{
private String name;
private StringRedisTemplate stringRedisTemplate;
private static final String KEY_PREFIX = "lock:";
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean tryLock(long timeoutSec) {
// 1、获取线程标识
long threadId = Thread.currentThread().getId();
// 2、获取锁
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
// 做拆箱的时候注意空指针的问题
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
这里的锁的误删是因为业务阻塞问题,通过在释放锁之前加一个判断锁是否是自己的来解决误删锁的问题
然后下面这种情况是,当你判断完锁是否是自己的,然后在判断锁和释放锁之间发生了阻塞的话,就又回到了之前那种情况,又会去删除别人的锁,也就是目前的判断锁和释放锁的操作不是一个原子操作,很容易被趁虚而入
这个已经是比较成熟了,但是还有有进步的空间,我靠!太麻烦了,太细致了!
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient(){
// 配置
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.84.132:6379");
// 创建ReissonClient对象
return Redisson.create(config);
}
}
这里直接换成Redisson的锁,直接就能用,好像是之前都是在造轮子,但是一步一步过来的,原理也清楚了些
通过使用Hash来实现可重入锁
这里好难啊,,,,先放一放
-- 1、参数列表
-- 1.1、优惠卷id
local voucherId = ARGV[1]
-- 1.2、用户id
local userId = ARGV[2]
-- 2、数据key
-- 2.1、库存key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2、订单key
local orderKey = 'seckill:order:' .. voucherId
-- 3、脚本业务
-- 3.1、判断库存是否充足get stockKey
if(tonumber(redis.call('get', stockKey)) <= 0) then
-- 3.2、库存不足,返回1
return 1
end
-- 3.2、判断用户是否下单
if(redis.call('sismember', orderKey, userId) == 1) then
-- 3.3、存在,说明是重复下单
return 2
end
-- 3.4、扣库存
redis.call('incrby', stockKey, -1)
-- 3.5、下单(保存用户)
redis.call('sadd', orderKey, userId)
return 0
// 秒杀部分优化
@Override
public Result seckillVoucher(Long voucherId) {
// 获取用户
Long userId = UserHolder.getUser().getId();
// 1、执行lua脚本
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(),
userId.toString()
);
// 2、判断结果是否是0
int r = result.intValue();
if(r != 0){
// 2.1、不为0,代表没有购买资格
return Result.fail(r == 1? "库存不足" : "不能重复下单");
}
// 2.2、为0,有购买资格,把下单信息保存到阻塞队列中
long order = redisIdWorker.nextId("order");
return Result.ok(order);
}
这里用到了阻塞队列还有线程来进行扣库存
// 队列
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 class VoucherOrderHandler implements Runnable{
@Override
public void run() {
while(true){
try {
// 1、获取队列中的订单信息
VoucherOrder take = orderTasks.take();
// 2、创建订单
handleVoucherOrder(take);
} catch (InterruptedException e) {
log.error("处理订单异常", e);
}
}
}
}
private void handleVoucherOrder(VoucherOrder take) {
// 获取用户
Long userId = take.getUserId();
// 创建锁对象(这个是自己定义的锁)
RLock lock = redissonClient.getLock("lock:order:" + userId);
boolean isLock = lock.tryLock();
// 判断是否获取锁成功
if(!isLock){
// 获取锁失败,返回错误或重试
log.error("不允许重复下单");
return ;
}
try {
proxy.createVoucherorder(take);
} finally {
lock.unlock();
}
}
@Transactional
public void createVoucherorder(VoucherOrder voucherOrder){
Long userId = voucherOrder.getUserId();
// 根据优惠券id和用户id查询订单
Integer count = query().eq("user_id", userId).eq("voucher_id", voucherOrder.getVoucherId()).count();
// 判断订单是否存在
if (count > 0) {
// 用户已经购买了
log.error("用户已经购买过一次");
return;
}
// 4、充足就可以扣减库存
boolean success = seckillVoucherService.update().setSql("stock = stock - 1")
.eq("voucher_id", voucherOrder.getVoucherId())
// 多设施一个条件
.gt("stock", 0)
.update();
if (!success) {
// 扣减失败
log.error("库存不足!");
return;
}
save(voucherOrder);
}
跟着视频做到这里,还有有些小疑问,在此之前我们使用的是一个判断库存的乐观锁,一个是判断一人一单的Redisson的非重入锁,那时候已经基本完成了我们所需要的任务,但是为了提高性能,我们将在redis优化中,将判断库存和一人一单提取到了redis中去做判断,然后将合法的订单生成出来放入阻塞队列中,然后另开一个线程来异步的进行数据库的操作,这时候,我发现我们一共运用了4个锁了,除了之前那两个锁以外,我们又在redis中加了一个库存的锁,一个判断一人一单的锁,功能重复实现了,我觉得把后面的那两个删除了也可以,视频中最后给那些判断的返回值都弄成了return;应该就是这个原因了吧
最后呢,使用了redis中的stream来进行对项目进行了优化,其实这里提供了三种方式,最优的就是Stream了,所以我觉得我其他两种就当是了解了,学一下stream,速度快,安全
应该熟悉的命令
key的名称
消费者组的名称
起始id标识
MKSTREAM示例:
XGROUP CREATE stream.orders g1 0 MKSTREAM
key的名称
* k1 v1 k2 v2…示例(lua脚本):
redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)
消费者组的名称
消费者名称
COUNT 读取几个
BLOCK 等待时间
STREAMS key的名称
从未消费的开始(>)/从pending-list中读取第一个(0)
示例(java的api):
stringRedisTemplate.opsForStream().read( Consumer.from("g1", "c1"), StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)), StreamOffset.create("stream.orders", ReadOffset.lastConsumed())
感觉还有一个重要的点,就是读取消息队列中的消息,正常的读取后就要进行ACK确认,出异常的话就要再去读取pending-list中的消息,进行处理读取pending-list的命令也有些不同,下面就贴点代码
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
if(tonumber(redis.call('get', stockKey)) <= 0) then
-- 3.2、库存不足,返回1
return 1
end
-- 3.2、判断用户是否下单
if(redis.call('sismember', orderKey, userId) == 1) then
-- 3.3、存在,说明是重复下单
return 2
end
-- 3.4、扣库存
redis.call('incrby', stockKey, -1)
-- 3.5、下单(保存用户)
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
java代码
private class VoucherOrderHandler implements Runnable{
String queueName = "stream.orders";
@Override
public void run() {
while(true) {
try {
// 1、获取消息队列中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS streams.order >
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
StreamOffset.create(queueName, ReadOffset.lastConsumed())
);
// 2、判断消息获取是否成功
// 2.1、如果获取失败,说明没有消息,继续下一次循环
if (list == null || list.isEmpty()){
// 如果获取失败,说明没有消息,继续下一次循环
continue;
}
// 解析消息中的订单信息
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> value = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
// 3、如果获取成功就可以下单
handleVoucherOrder(voucherOrder);
// 4、ACK确认
stringRedisTemplate.opsForStream().acknowledge(queueName, "g1", record.getId());
} catch (Exception e) {
log.error("处理订单异常", e);
handlePendingList();
}
}
}
private void handlePendingList(){
while(true) {
try {
// 1、获取pending-list中的订单信息 XREADGROUP GROUP g1 c1 STREAMS streams.order 0
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1),
StreamOffset.create(queueName, ReadOffset.from("0"))
);
// 2、判断消息获取是否成功
// 2.1、如果获取失败,说明没有消息,继续下一次循环
if (list == null || list.isEmpty()){
// 如果获取失败,说明pending-list中没有消息,结束循环
break;
}
// 解析消息中的订单信息
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> value = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
// 3、如果获取成功就可以下单
handleVoucherOrder(voucherOrder);
// 4、ACK确认
stringRedisTemplate.opsForStream().acknowledge(queueName, "g1", record.getId());
} catch (Exception e) {
log.error("处理pengding-list订单异常", e);
try {
Thread.sleep(20);
} catch (InterruptedException ex) {
throw new RuntimeException(ex);
}
}
}
}
}
他给我实现了
@Override
public Result queryBlogById(Long id) {
// 1、查询blog
Blog blog = getById(id);
if(blog == null){
return Result.fail("笔记不存在");
}
// 2、查询blog有关的用户
queryBlogUser(blog);
return Result.ok(blog);
}
private void queryBlogUser(Blog blog){
Long userId = blog.getUserId();
User user = userService.getById(userId);
blog.setName(user.getNickName());
blog.setIcon(user.getIcon());
}
根据应用场景去选择合适的数据类型去实现,这里就使用set集合,这里面本来就不允许重复,判断集合中是否点赞过,没点赞就+1,点过赞就-1
@Override
public Result likeBlog(Long id) {
// 1、获取登录用户
Long userId = UserHolder.getUser().getId();
// 2、判断当前登录用户是否已经点赞
String key = "blog:liked:" + id;
Boolean member = stringRedisTemplate.opsForSet().isMember(key, userId.toString());
if(BooleanUtil.isFalse(member)){
// 3、如果未点赞。可以点赞
// 3.1、数据库点赞数 + 1
boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
// 3.2、保存用户到Redis的set集合
if(isSuccess){
stringRedisTemplate.opsForSet().add(key, userId.toString());
}
}else{
// 4、如果已点赞,取消点赞
// 4.1、数据库点赞数 -1
boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
// 4.2、把用户从Redis的set集合中移除
if(isSuccess){
stringRedisTemplate.opsForSet().remove(key, userId.toString());
}
}
return Result.ok();
}
这里要把刚才点赞功能的那个给修改一下,换成sortedSet
小tips:这里发现点赞排行榜的点赞顺序有问题,视频中解释的数据库的问题,也就是使用in的那个地方的问题,使用了in和要查询的顺序就反过来了,然后使用了order by field来解决这个问题
// 2、解析出其中的用户id
List<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList());
// 3、解析出用户id的用户
String idStr = StrUtil.join(",", ids);
List<UserDTO> collect = userService.query().
in("id", ids).last("order by field (id," + idStr + ")").list()
.stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class)).collect(Collectors.toList());
这里在查询id的用户的时候,就使用in和last配置实现了在数据库中那个正确查询顺序的操作,很神奇,又学到了一点东西
首先这里的按钮只有关注和取消关系,当点击关注的时候,会往后台发送一条请求,将关注的用户id和是否关注发到后台,通过判断是取关还是关注,来进行对数据库中关注表的增删,然后返回,并且还有一个请求在判断页面上是否是关注还是取消关注
实现思路
另一个请求
实现思路
这里多次使用到了stream,map映射什么的东西,不太懂,得学
)需要去临时拉取信息,延迟高
已经拉好了,直接读取就可以了
通过对大V和粉丝的区分,进行活跃用户推消息,普通用户拉消息,这样就稍微均衡一些
实现思路
这个实现思路,是通过来倒序根据分数来查询数据,并进行分页展示的,其中不仅要记录上一次查询的最小时间戳,也要查询上一次结果中,与最小值一样的元素的个数作为偏移量进行查询
@Override
public Result queryBlogOfFollow(Long max, Integer offset) {
// 1、获取当前用户
Long userId = UserHolder.getUser().getId();
// 2、查询收件箱
String key = FEED_KEY + userId;
Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
.reverseRangeByScoreWithScores(key, 0, max, offset, 2);
// 3、解析数据:blogId, 时间戳, offset
if(typedTuples == null || typedTuples.isEmpty()){
return Result.ok(Collections.emptyList());
}
ArrayList<Long> ids = new ArrayList<>(typedTuples.size());
long minTime = 0;
int os = 1;
for (ZSetOperations.TypedTuple<String> typedTuple : typedTuples) {
// 4.1、获取blogId
ids.add(Long.valueOf(typedTuple.getValue()));
// 4.2、获取分数(时间戳)
// 4.3、获取offset
long time = typedTuple.getScore().longValue();
// 相当于是当前的和上一次的做对比
if(time == minTime){
os++;
}else{
minTime = time;
// 恢复现场
os = 1;
}
}
// 4、查询blog
String idStr = StrUtil.join(",", ids);
List<Blog> blogs = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();
for (Blog blog : blogs) {
// 查询blog有关的用户
queryBlogUser(blog);
// 查询blog是否被点赞了
isBlogLiked(blog);
}
// 5、封装并返回
ScrollResult r = new ScrollResult();
r.setList(blogs);
r.setOffset(os);
r.setMinTime(minTime);
return Result.ok(r);
}
实现步骤
List
blogs = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();
@Test
public void loadShopData(){
// 1、查询店铺信息
List<Shop> list = shopService.list();
// 2、把店铺分组,把typeId分组,id一致的放到一个集合
// stream流好牛啊
Map<Long, List<Shop>> map = list
.stream()
.collect(Collectors.groupingBy(Shop::getTypeId));
// 3、分批写入redis
for (Map.Entry<Long, List<Shop>> longListEntry : map.entrySet()) {
// 1、获取类型id
Long typeId = longListEntry.getKey();
String key = "shop:geo:" + typeId;
// 2、获取同类型的店铺
List<Shop> value = longListEntry.getValue();
List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>();
// 3、写入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);
}
}
@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>> radius = stringRedisTemplate.opsForGeo()
.radius(key, new Circle(new Point(x, y), new Distance(5000, Metrics.MILES)), RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs().
//包含距离,包含经纬度,升序前五个
includeDistance().includeCoordinates().sortAscending().limit(end));
// 4、解析出id
if(radius == null){
return Result.ok(Collections.emptyList());
}
List<GeoResult<RedisGeoCommands.GeoLocation<String>>> content = radius.getContent();
if(content.size() <= from){
// 没有下一页了,结束
return Result.ok(Collections.emptyList());
}
// 截取from-end的部分
List<Long> ids = new ArrayList<>(content.size());
Map<String, Distance> distanceMap = new HashMap<>(content.size());
content.stream().skip(from).forEach(res -> {
// 店铺id
String shopIdStr = res.getContent().getName();
ids.add(Long.valueOf(shopIdStr));
// 获取距离
Distance distance = res.getDistance();
distanceMap.put(shopIdStr, distance);
});
// 5、根据id查询Shop
String idStr = StrUtil.join(",", ids);
List<Shop> list = query().in("id", ids).last("ORDER BY FIELD (id," + idStr + ")").list();
for (Shop shop : list) {
shop.setDistance(distanceMap.get(shop.getId().toString()).getValue());
}
// 6、返回
return Result.ok(list);
}
逻辑有点复杂,一个是使用了滚动分页,说实话我看这后端不知道是怎么实现了,应该是前端设计成这样子的,而且这个计算分页参数也是一些小小的经验,还有那个redis的附近商店查询,我的redis有点旧,我就使用了那个低版本的去实现了,还有那个用stream的跳过啥的然后在forEach中去收集一些数据,之前还有在stream中去构造,修改一些数据,这东西真方便,这个redis实战看完就学那个,最后还是in ("id", ids).last("order by field(id, + idsStr + ")",已经见过好多次了
@Override
public Result sign() {
// 1、获取当前登录用户
Long userId = UserHolder.getUser().getId();
// 2、获取日期
LocalDateTime now = LocalDateTime.now();
// 3、拼接key
// 获取年月的时间
String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
// 拼接好了 前缀+用户id+年月
String key = USER_SIGN_KEY + userId + keySuffix;
// 4、获取今天是本月的第几天
int dayOfMonth = now.getDayOfMonth();
// 5、写入redis
stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);
return Result.ok();
}
@Override
public Result signCount() {
// 1、获取当前登录用户
Long userId = UserHolder.getUser().getId();
// 2、获取日期
LocalDateTime now = LocalDateTime.now();
// 3、拼接key
// 获取年月的时间
String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
// 拼接好了 前缀+用户id+年月
String key = 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)
);
if(result == null || result.isEmpty()){
// 没有任何签到结果
return Result.ok();
}
Long aLong = result.get(0);
if(aLong == null || aLong == 0){
return Result.ok(0);
}
// 6、循环遍历
int count = 0;
while(true){
// 7、让这个数字与1做与运算,得到数字的最后一个bit位 8、判断这个bit位是否为0
if((aLong & 1) == 0){
// 9、如果是0,说明未签到,结束
break;
}else{
// 10、如果不为0.说明已签到,计数器加1
count++;
}
// 11、把数字右移一位,抛弃最后一个bit位,继续判断下一个bit位
// 右移一位
aLong >>>= 1;
}
return Result.ok(count);
}
这里用了一个双重判断, 还用了进制和位运算,这些都不太熟悉,很难受
@Test
void testHyperLogLog(){
String[] values = new String[1000];
int j = 0;
for(int i = 0; i < 1000000 ; i++){
j = i % 1000;
values[j] = "user_" + i;
if(j == 999){
stringRedisTemplate.opsForHyperLogLog().add("hl2", values);
}
}
// 统计数量
Long count = stringRedisTemplate.opsForHyperLogLog().size("hl2");
System.out.println("count = " + count);
}