分布式缓存实践示例

博客主页:https://tomcat.blog.csdn.net
博主昵称:农民工老王
主要领域:Java、Linux、K8S
期待大家的关注💖点赞👍收藏⭐留言💬
家乡

《浅谈缓存的理论与实践》这篇文章中,我们以 Guava 的 LoadingCache 为例,介绍了堆内缓存的特点以及一些注意事项。同时,还了解了缓存使用的场景,这对分布式缓存来说,同样适用。

那什么叫分布式缓存呢?它其实是一种集中管理的思想。如果我们的服务有多个节点,堆内缓存在每个节点上都会有一份;而分布式缓存,所有的节点,共用一份缓存,既节约了空间,又减少了管理成本。

在分布式缓存领域,使用最多的就是 Redis。Redis 支持非常丰富的数据类型,包括字符串(string)、列表(list)、集合(set)、有序集合(zset)、哈希表(hash)等常用的数据结构。当然,它也支持一些其他的比如位图(bitmap)一类的数据结构。

说到 Redis,就不得不提一下另外一个分布式缓存 Memcached(以下简称 MC)。MC 现在已经很少用了,但面试的时候经常会问到它们之间的区别,这里简单罗列一下:
在这里插入图片描述

SpringBoot 如何使用 Redis

Redis 在互联网中,几乎是标配。我们接下来,先简单看一下 Redis 在 Spring 中是如何使用的,然后,再介绍一下在秒杀业务中,Redis是如何帮助我们承接瞬时流量的。

使用 SpringBoot 可以很容易地对 Redis 进行操作(完整代码见仓库)。Java 的 Redis的客户端,常用的有三个:jedis、redisson 和 lettuce,Spring 默认使用的是 lettuce。

lettuce 是使用 netty 开发的,操作是异步的,性能比常用的 jedis 要高;redisson 也是异步的,但它对常用的业务操作进行了封装,适合书写有业务含义的代码。

通过加入下面的 jar 包即可方便地使用 Redis。

<dependency> 
    <groupId>org.springframework.boot</groupId> 
    <artifactId>spring-boot-starter-data-redis</artifactId> 
</dependency>

上面这种方式,我们主要是使用 RedisTemplate 这个类。它针对不同的数据类型,抽象了相应的方法组。
在这里插入图片描述
另外一种方式,就是使用 Spring 抽象的缓存包 spring-cache。它使用注解,采用 AOP的方式,对 Cache 层进行了抽象,可以在各种堆内缓存框架和分布式框架之间进行切换。这是它的 maven 坐标:

<dependency> 
    <groupId>org.springframework.boot</groupId> 
    <artifactId>spring-boot-starter-cache</artifactId> 
</dependency>

与 spring-cache 类似的,还有阿里的 jetcache,都是比较好用的。

使用 spring-cache 有三个步骤:

  • 在启动类上加入 @EnableCaching 注解;

  • 使用 CacheManager 初始化要使用的缓存框架,使用 @CacheConfig 注解注入要使用的资源;

  • 使用 @Cacheable 等注解对资源进行缓存。

我们这里使用的是 RedisCacheManager,由于现在只有这一个初始化实例,第二个步骤是可以省略的。

针对缓存操作的注解,有三个:

  • @Cacheable 表示如果缓存系统里没有这个数值,就将方法的返回值缓存起来;

  • @CachePut 表示每次执行该方法,都把返回值缓存起来;

  • @CacheEvict 表示执行方法的时候,清除某些缓存值。

Redis使用示例:秒杀

场景介绍

对于秒杀系统来说,仅仅使用这三个注解是有局限性的,需要使用更加底层的 API,比如 RedisTemplate,来完成逻辑开发,下面就来介绍一些比较重要的功能。

秒杀,是对正常业务流程的考验。因为它会产生突发流量,平常一天的请求,可能就集中在几秒内就要完成。比如,京东的某些抢购,可能库存就几百个,但是瞬时进入的流量可能是几十上百万。
在这里插入图片描述
如果参与秒杀的人,等待很长时间,体验就非常差,想象一下拥堵的高速公路收费站,就能理解秒杀者的心情。同时,被秒杀的资源会成为热点,发生并发争抢的后果。比如 12306 的抢票,如果单纯使用数据库来接受这些请求,就会产生严重的锁冲突,这也是秒杀业务难的地方。

大家可以回忆一下浅谈缓存的理论与实践这篇文章的内容,此时,秒杀前端需求与数据库之间的速度是严重不匹配的,而且秒杀的资源是热点资源。这种场景下,采用缓存是非常合适的。

处理秒杀业务有三个绝招:

  • 第一,选择速度最快的内存作为数据写入;

  • 第二,使用异步处理代替同步请求;

  • 第三,使用分布式横向扩展。

下面,我们就来看一下 Redis 是如何助力秒杀的。

实现

一个秒杀系统是非常复杂的,一般来说,秒杀可以分为一下三个阶段:

  • 准备阶段,会提前载入一些必需的数据到缓存中,并提前预热业务数据,用户会不断刷新页面,来查看秒杀是否开始;

  • 抢购阶段,就是我们通常说的秒杀,会产生瞬时的高并发流量,对资源进行集中操作;

  • 结束清算,主要完成数据的一致性,处理一些异常情况和回仓操作。
    在这里插入图片描述
    下面,我将介绍一下最重要的秒杀阶段。

我们可以设计一个 Hash 数据结构,来支持库存的扣减。

seckill:goods:${goodsId}{ 
    total: 100, 
    start: 0, 
    alloc:0 
}

在这个 Hash 数据结构中,有以下三个重要部分:

  • total 是一个静态值,表示要秒杀商品的数量,在秒杀开始前,会将这个数值载入到缓存中。

  • start 是一个布尔值。秒杀开始前的值为 0;通过后台或者定时,将这个值改为 1,则表示秒杀开始。

  • 此时,alloc 将会记录已经被秒杀的商品数量,直到它的值达到 total 的上限。

static final String goodsId = "seckill:goods:%s";

String getKey(String id) {
    return String.format(goodsId, id);
}

public void prepare(String id, int total) {
    String key = getKey(id);
    if (redisTemplate.hasKey(key)) {
        return;
    }
    Map<String, String> goods = new HashMap<>();
    goods.put("total", String.valueOf(total));
    goods.put("start", "0");
    goods.put("alloc", "0");
    redisTemplate.opsForHash().putAll(key, goods);
}

秒杀的时候,首先需要判断库存,才能够对库存进行锁定。这两步动作并不是原子的,在分布式环境下,多台机器同时对 Redis 进行操作,就会发生同步问题。

为了解决同步问题,一种方式就是使用 Lua 脚本,把这些操作封装起来,这样就能保证原子性;另外一种方式就是使用分布式锁,分布式锁我们将在后续的文章中介绍。

下面是一个调试好的 Lua 脚本,可以看到一些关键的比较动作,和 HINCRBY 命令,能够成为一个原子操作。

local falseRet = "0"
local n = tonumber(ARGV[1])
local key = KEYS[1]
local goodsInfo = redis.call("HMGET",key,"total","alloc")
local total = tonumber(goodsInfo[1])
local alloc = tonumber(goodsInfo[2])
if not total then
    return falseRet
end
if total >= alloc + n  then
    local ret = redis.call("HINCRBY",key,"alloc",n)
    return tostring(ret)
end
return falseRet

对应的秒杀代码如下,由于我们使用的是 String 的序列化方式,所以会把库存的扣减数量先转化为字符串,然后再调用 Lua 脚本。

public int secKill(String id, int number) {
    String key = getKey(id);
    Object alloc =  redisTemplate.execute(script, Arrays.asList(key), String.valueOf(number));
    return Integer.valueOf(alloc.toString());
}

执行仓库里的 testSeckill 方法。启动 1000 个线程对 100 个资源进行模拟秒杀,可以看到生成了 100 条记录,同时其他的线程返回的是 0,表示没有秒杀到。
在这里插入图片描述

完整的java代码如下:

package cn.wja.cache;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.test.context.junit4.SpringRunner;

import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

@RunWith(SpringRunner.class)
@SpringBootTest
public class SeckillRedisTest {

    DefaultRedisScript script;

    @Before
    public void init() {
        script = new DefaultRedisScript();
        script.setScriptSource(new ResourceScriptSource(
                new ClassPathResource("seckill.lua")
        ));
        script.setResultType(Integer.class);
    }

    @Autowired
    StringRedisTemplate redisTemplate;

    static final String goodsId = "seckill:goods:%s";

    String getKey(String id) {
        return String.format(goodsId, id);
    }

    public void prepare(String id, int total) {
        String key = getKey(id);
        if (redisTemplate.hasKey(key)) {
            return;
        }
        Map<String, String> goods = new HashMap<>();
        goods.put("total", String.valueOf(total));
        goods.put("start", "0");
        goods.put("alloc", "0");
        redisTemplate.opsForHash().putAll(key, goods);
    }

    public int secKill(String id, int number) {
        String key = getKey(id);
        Object alloc =  redisTemplate.execute(script, Arrays.asList(key), String.valueOf(number));
        return Integer.parseInt(alloc.toString());
    }

    @Test
    public void testSeckill() {
        String id = "114";
        prepare(id, 100);
        ExecutorService executor = Executors.newCachedThreadPool();

        for (int i = 0; i < 1000; i++) {
            executor.submit(() -> {
                int alloc = secKill(id, 1);
                System.out.println("count==================" + alloc);
            });
        }

        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            throw new IllegalStateException();
        }

        executor.shutdown();
    }
}

小结

本文和浅谈缓存的理论与实践这篇文章,都是围绕着缓存展开的,它们之间有很多知识点也比较相似。对于分布式缓存来说,Redis 是现在使用最广泛的。我们先简单介绍了一下它和 Memcached 的一些区别,介绍了 SpringBoot 项目中 Redis 的使用方式,然后以秒杀场景为主,学习了库存扣减这一个核心功能的 Lua 代码。这段代码主要是把条件判断和扣减命令做成了原子性操作。

Redis 的 API 使用非常简单,速度也很快,但同时它也引入了很多问题。如果不能解决这些异常场景,那么 Redis 的价值就大打折扣,这也是我下一篇博客要谈的内容。


如需转载,请注明本文的出处:农民工老王的CSDN博客https://blog.csdn.net/monarch91 。

评论 18
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

农民工老王

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值