likes
comments
collection
share

博客网站首页性能优化:基于分页查询实现懒加载,并集成Redis做缓存与分布式锁

作者站长头像
站长
· 阅读数 33

前言

最近为了学习完整的微服务项目开发,打算自己做一个分布式的博客系统,在做首页的博客列表查询接口时,自己考虑了一些场景,针对接口进行了一些优化。包括基于分页查询实现了懒加载,并简单应用了redis,例如 做了数据缓存,以及分布式锁避免缓存击穿。本文就来分享一下

1、基础数据查询

第一版就是一个简单的数据查询接口,将博客表中所有数据查出来,包括博客的id、简介、点击数、点赞数、收藏数等,并关联用户表拿到了作者的昵称。这里因为是首页的展示,所以没有查询select * ,因为那样会把博客的正文字段也查出来,首页列表展示只需要标题、简介等信息,点击文章预览时,再单独去查文章的正文。

@GetMapping("/list")
public Result list() {
    List<BlogListVo> vos = blogService.listBlog(page);
    return Result.success(vos);
}
<select id="listBlog" resultType="com.xb.blog.web.vo.BlogListVo">
    SELECT
        b.uid,
        b.title,
        b.summary,
        b.pic_uid picUid,
        IFNULL(b.click_count, 0) clickCount,
        IFNULL(b.like_count, 0) likeCount,
        IFNULL(b.collect_count, 0) collectCount,
        u.uid authorId,
        u.nick_name authorName
    FROM
        t_blog b
            LEFT JOIN t_user u ON b.author = u.uid
    WHERE
        b.status = 1
    ORDER BY
        b.create_time DESC
</select>

2、基于分页查询实现懒加载

上面代码的问题很明显:如果以后系统的文章数量变多了,比如达到了几千几万,那也一次性查出来吗?显然不可能。我们应该在用户需要看到文章的时候,再把他们查出来。懒加载就是一个很好的解决方案。

我的前端项目基于vue3 并整合了Element Plus,Element Plus中有一个无限滚动组件(Infinite Scroll),它可以监听一个滚动列表,当它滚动到底部时,触发自定义方法,来查询新数据,这里就基于它来完成懒加载。

至于后端接口怎么提供数据,我考虑将其作为一个分页查询接口,每次查询时,传入一个页码,然后固定每页数据为10条,这样,再查询新数据时,可以将页码自增,就可以查到后边的新数据。前端获取的新数据后,将他们追加到文章列表数据的尾部,就可以实现懒加载的效果。

实现代码如下:

前端

前端的代码中,调用接口查询数据时使用了一个变量page,初始值为1,这样第一次查询接口时返回的就是第一页数据。然后在文章列表容器上使用了v-infinite-scroll这个属性,在滚动到容器底部的时候,触发load方法,在load方法中,会根据将page这个变量的值+1,然后调用getList方法查询新的数据,并追加到文章列表数组list中。

注意滚动容器必须要设置heightoverflow属性。

<template>
    <div class="blogList" v-infinite-scroll="load" infinite-scroll-distance="10" infinite-scroll-immediate="false">
        <div v-for="item in list" :key="item.uid">
            {{ item }}
        </div>
    </div>
</template>

<script setup>
import {onMounted, ref} from "vue";
import request from '@/utils/request.js'

let list = ref([]);

const getList = () => {
    request.get('/web/home/list/' + page.value).then(result => {
        list.value.push(...result.data)
    })
}

let page = ref(1)

const load = () => {
    page.value++
    getList()
}

onMounted(getList)
</script>

<style scoped>
.blogList {
    overflow: auto;
    height: calc(100vh - 100px);
}
</style>

后端

后端代码就是在接口中新增了一个page入参,在执行sql查询文章数据时,会使用LIMIT 和 OFFSET关键字来做分页查询。需要注意的是,使用OFFSET进行分页 初始下标为0,所以在查询之前对page做了处理。

@GetMapping("/list/{page}")
public Result list(@PathVariable("page") Long page) {
    List<BlogListVo> vos = blogService.listBlog(page);
    return Result.success(vos);
}
@Override
public List<BlogListVo> listBlog(Long page) {
    //换算分页参数(使用OFFSET关键字进行分页,故此处起始页码应为0)
    page = page != null ? (page - 1) * 10 : 0L;
    return baseMapper.listBlog(page);
}
<select id="listBlog" resultType="com.xb.blog.web.vo.BlogListVo">
    SELECT
        * <!--省略查询字段-->
    FROM
        t_blog b
            LEFT JOIN t_user u ON b.author = u.uid
    WHERE
        b.status = 1
    ORDER BY
        b.create_time DESC
    <if test="page != null">
        limit 10 OFFSET #{page}
    </if>
</select>

3、使用redis作为数据缓存

上面的代码已经实现了一个文章列表查询接口的基本功能,考虑到在博客系统中,访问量最大的应该就是首页,而且在业务上,不管是否登录,首页的文章列表都是可以查看的。所以还需要给这个接口做一定的缓存处理,这里就引入了redis来做缓存。

首先在SpringBoot中,要使用redis,需要引入依赖以及配置地址

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
spring:
  redis:
    host: localhost
    port: 6379

代码中,首先定义了一个key:HOME_BLOG_LIST_DATA_SIZE_10_PAGE_页码,这样设计一是比较清楚明白,具体这是哪个功能的缓存一眼就能看出来,二来考虑到以后有可能会将每页的数据条数进行修改,提前预留好每页条数的位置,也方便后续拓展。

然后代码中遵循了常规的缓存方式:先根据key查询缓存,如果缓存中有,直接返回,如果缓存中没有,查数据库,在返回前将其设置到缓存中。在这里还做了一定的防止缓存穿透处理:如果查出的数据为空或者为null,也将其设置到缓存中,并设置一个较短的缓存时间。比如代码中,就设置了10秒,正常查出数据的缓存设置了10分钟,这个数值可以根据自己的实际情况来修改。

@Autowired  
private StringRedisTemplate redisTemplate;

public List<BlogListVo> listBlog(Long page) {
    //处理特殊情况
    if (page == null) page = 1L;
    
    //定义缓存Keu格式(每页数据单独缓存,且固定每页条数为10条)
    String dataKey = "HOME_BLOG_LIST_DATA_SIZE_10_PAGE_" + page;
    
    //换算分页参数(使用OFFSET关键字进行分页,故此处起始页码应为0)
    page = (page - 1L) * 10L;

    //从缓存中获取数据
    String cache = redisTemplate.opsForValue().get(dataKey);
    if (StrUtil.isNotBlank(cache)) {
        //缓存中有数据 直接返回
        return JSONUtil.toList(cache, BlogListVo.class);
    }
    
    List<BlogListVo> list = baseMapper.listBlog(page);
    if (CollUtil.isEmpty(list)) {
        //设置空值 避免缓存穿透
        redisTemplate.opsForValue().set(dataKey, JSONUtil.toJsonStr(list), 10, TimeUnit.SECONDS);
    } else {
        redisTemplate.opsForValue().set(dataKey, JSONUtil.toJsonStr(list), 10, TimeUnit.MINUTES);
    }
    return list;
}

4、使用redis作为分布式锁

最后考虑一个场景:如果在高并发的情况下,很多个线程同时查询数据,发现缓存中没有,都去查询数据库,如果并发量足够大,是有可能把数据库打挂的。

所以这里需要实现一个分布式锁,保证在同一时刻只有一个请求能打到数据库,查询数据后设置缓存,别的请求直接拿到缓存并返回。当然了,并非所有的接口都需要做这样的处理,主要是考虑到首页的接口应该是整个系统中并发量最大的。

具体的做法是:首先定义一个key,用来作为锁的key。然后在查询数据库前,需要先去获取锁,如果能获取到锁,正常执行查询数据库、设置缓存的逻辑,并且要在finally块中释放锁(可以保证无论正常还是异常,锁都可以被释放),加锁时value设置了一个uuid,这是为了保证在解锁时,解的是自己加的锁。解锁时,需要比对这个uuid,是自己加的锁,自己才能解。这里使用了lua脚本,来保证这个操作的原子性。

如果未获取到锁,我这里采取的措施是:进行一定次数的重试,查询缓存,如果能查到数据,就返回结果。如果重试后依然没有数据,直接返回空集合。还有一个方案是可以在这里递归调用listBlog方法,进行一个自旋的重试。具体的实现方��因业务而异,选择最合适业务的即可。

具体代码如下:

@Autowired  
private StringRedisTemplate redisTemplate;

public List<BlogListVo> listBlog(Long page) {
    //处理特殊情况
    if (page == null) page = 1L;

    //定义缓存Keu格式(每页数据单独缓存,且固定每页条数为10条)
    String lockKey = "HOME_BLOG_LIST_LOCK_SIZE_10_PAGE_" + page;
    String dataKey = "HOME_BLOG_LIST_DATA_SIZE_10_PAGE_" + page;

    //换算分页参数(使用OFFSET关键字进行分页,故此处起始页码应为0)
    page = (page - 1L) * 10L;

    //从缓存中获取数据
    String cache = redisTemplate.opsForValue().get(dataKey);
    if (StrUtil.isNotBlank(cache)) {
        //缓存中有数据 直接返回
        return JSONUtil.toList(cache, BlogListVo.class);
    }

    //获取分布式锁
    String uuid = UUID.randomUUID().toString();
    Boolean lock = redisTemplate.opsForValue().setIfAbsent(lockKey, uuid);

    if (lock) {
        //拿到分布式锁,查询数据库
        try {
            List<BlogListVo> list = baseMapper.listBlog(page);
            if (CollUtil.isEmpty(list)) {
                //设置空值 避免缓存穿透
                redisTemplate.opsForValue().set(dataKey, JSONUtil.toJsonStr(list), 10, TimeUnit.SECONDS);
            } else {
                redisTemplate.opsForValue().set(dataKey, JSONUtil.toJsonStr(list), 10, TimeUnit.MINUTES);
            }
            return list;
        } finally {
            //使用lua脚本 保证释放分布式锁的原子性
            String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
            redisTemplate.execute(new DefaultRedisScript<>(script, Long.class)
                    , Arrays.asList(lockKey), uuid);
        }
    }

    //未拿到分布式锁,进行一定次数的重试
    for (int i = 0; i < 3; i++) {
        try {
            //适当休眠 避免cpu空转
            Thread.sleep(200);

            //再次查询缓存
            cache = redisTemplate.opsForValue().get(dataKey);
            if (StrUtil.isNotBlank(cache)) {
                //缓存中有数据 直接返回
                return JSONUtil.toList(cache, BlogListVo.class);
            }

        } catch (InterruptedException exception) {
            Thread.currentThread().interrupt();
        }
    }

    //重试获取数据失败 返回空集合
    return Collections.emptyList();
}

总结

以上,就完成了一个博客网站的文章列表加载接口,支持懒加载,并具备一定的缓存和并发能力。感谢阅读,希望可以帮到你。

转载自:https://juejin.cn/post/7387672843387191350
评论
请登录