动态选择4种库存更新策略+缓存预热+多级存储结构——应对高并发秒杀情景和其他多种情景的商城购物架构

商城整体功能架构图

图片

文字分析:

  1. 活动入口进行风控检测,这个使用独立的服务来实现,风控使用批量提交的形式来实现,直接运用Java线程池来实现,可以提交一个列表来实现批量,可以参考以下的代码

    创建批量请求并且进行提交

    import java.util.ArrayList;
    import java.util.List;
    
    public class Main {
        public static void main(String[] args) {
            ExecutorService executorService= ThreadPoolManager.getExecutorService();
    
            // 创建批量请求
            List<List<String>> batches = new ArrayList<>();
            for (int i = 0; i < 5; i++) {
                List<String> batch = new ArrayList<>();
                for (int j = 1; j <= 4; j++) {
                    batch.add("Request" + (i * 4 + j));
                }
                batches.add(batch);
            }
    
            // 提交批量任务
            for (List<String> batch : batches) {
                executorService.submit(new BatchTask(batch));
            }
    
            // 关闭线程池(等待所有任务完成)
            ThreadPoolManager.shutDown();
        }
    }
    
    

    实现批量请求的处理

    import java.util.List;
    
    public class BatchTask implements Runnable {
        private List<String> requests;
    
        public BatchTask(List<String> requests) {
            this.requests = requests;
        }
    
        @Override
        public void run() {
            // 批量处理请求
            System.out.println("Processing batch: " + requests);
            try {
                // 模拟批量网络请求或I/O操作
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            System.out.println("Completed batch: " + requests);
        }
    }
    
    
  2. 活动规则的校验,这个校验实现原理是使用一个权限列表来实现,即使用一个权限表来实现,根据用户的权限情况判定当前权益是否可以获得,同样这个也是使用多线程批量处理,需要调用很多个业务领域的数据来进行判定

    [!WARNING]

    但是要清楚一点,规则鉴定是决定性的步骤,如果不符合规则还能进行获得效益的话,这就会让公司亏钱,所以这一步的数据一致性很重要,那么就不可以使用异步处理,而是要用多线程的方法来实现,虽然多线程是MQ的低级版,但是不算是异步,这个时候既可以解决巨额并发量,又可以保证数据的一致性

  3. 多线程校验规则之后的结果要作为判定权益是否入账的决定性影响因素,这个时候如果权益入账,那么就进行写库操作,同步库存量信息、用户个人权益信息(优惠券的使用等)、用户的个人数据(商品已购买)

使用导入配置文件属性来实现动态选择4种读写库存的策略

商城最热点的信息正是库存量信息,需要频繁的查询和更新,其他商品信息属于静态信息,所以有必要提高其读写性能,兼考虑一致性问题

4种读写库存的策略

传统方式(日常领券链路)读写Redis(系统复杂度低)

读链路使用get命令,写链路使用lua实现库存控制,理论可支持(读+写)共4w/s。流量小一致性要求高的场景推荐使用

优点:简单、性能有保障、强一致性。

缺点:单节点并发瓶颈

图片

具体实现策略:

直接使用StringRedisTemplate进行操作就好

package com.coding.demo.utils.SeckillUtilsPackage.StockManagementPackage.DualRedis2Stock;

import com.coding.demo.utils.SeckillUtilsPackage.StockManagementPackage.ReadWriteStockStrategy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

/**
 * @author juicychenzepeng
 * @since 2024-06-09
 * @apiNote 日常简单实现策略,性能有保障,一致性很强
 * */
@Component("dualRedis")
@Slf4j
public class DualRedis2ReadWrite implements ReadWriteStockStrategy {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Integer readStock(int skuId){
        String redis_keyname_stock = "skuInfo:seckill:sku:" + skuId + ":stock";
        String redisStock = stringRedisTemplate.boundValueOps(redis_keyname_stock).get();
        if (redisStock != null){
            log.info("日常双Redis链路:Redis库存查询成功");
            return Integer.parseInt(redisStock);
        }
        log.error("日常双Redis链路:Redis库存查询失败");
        return null;
    }

    @Override
    public boolean writeStock(int skuId){
        String redis_keyname_stock = "skuInfo:seckill:sku:" + skuId + ":stock";
        Integer oldRedisStock = readStock(skuId);
        if(oldRedisStock != null){
            Long surplus_expiration = stringRedisTemplate.boundValueOps(redis_keyname_stock).getExpire(); // 剩余过期时间
            Integer newRedisStock = oldRedisStock-1;
            stringRedisTemplate.boundValueOps(redis_keyname_stock).set(String.valueOf(newRedisStock),surplus_expiration, TimeUnit.SECONDS);
            log.info("日常双Redis链路:Redis库存更新成功");
            return true;
        }
        log.error("日常双Redis链路:Redis库存更新失败");
        return false;
    }
}

本地库存方式(收银台链路)读Cache+写Redis(一致性要求高,读性能高)

读链路预取库存在本地进行库存扣减,写链路使用lua实现库存控制,写最高2w,读理论无上限。一致性要求强库存量级大读多写少场景使用

优点:读链路可实现横向扩展、强一致性。

缺点:库存较少时可能会产生单节点瓶颈

图片

具体实现逻辑:

  1. 在系统启动或者初始化的时候,预先把一定量库存key(因为有很多个板块的商品信息,所以自然会有很多个库存key)放入本地缓存,这里结合缓存预热,直接采用定时任务在活动开始之前添加数据

    package com.coding.demo.configuration.Quartz;
    
    import com.coding.demo.service.SeckillAllService;
    import org.quartz.Job;
    import org.quartz.JobExecutionContext;
    import org.quartz.JobExecutionException;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.stereotype.Component;
    
    @Component
    public class SeckillInitialJob implements Job {
    
        @Autowired
        private SeckillAllService seckillAllService;
    
        @Override
        public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException {
            // 执行任务的具体逻辑
            System.out.println("********************************Author:czp From Guangzhou,执行秒杀缓存定时载入多级缓存任务...**********************************");
            // 这里可以放置初始化秒杀相关的业务逻辑
            seckillAllService.redisPreExecute();
        }
    }
    
    package com.coding.demo.configuration.Quartz;
    
    import org.quartz.*;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    
    @Configuration
    public class QuartzConfig {
    
        @Bean
        public JobDetail initJobDetail() {
            return JobBuilder.newJob(SeckillInitialJob.class)
                    .withIdentity("initSeckill")
                    .storeDurably()
                    .build();
        }
    
        @Bean
        public Trigger initJobTrigger() {
            CronScheduleBuilder cron = CronScheduleBuilder.cronSchedule("0/18 * * * * ?");   // 修改为每3分钟执行一遍任务
            return TriggerBuilder.newTrigger()
                    .forJob(initJobDetail())
                    .withIdentity("initTrigger")
                    .withSchedule(cron).build();
        }
    
        @Bean
        public JobDetail LuaDeleteHotSearchJobDetail() {
            return JobBuilder.newJob(LuaDeleteHotSearchJob.class)
                    .withIdentity("luaDeleteHotSearchJob")
                    .storeDurably()
                    .build();
        }
    
        @Bean
        public Trigger LuaDeleteHotSearchJobTrigger() {
            CronScheduleBuilder cron = CronScheduleBuilder.cronSchedule("0/9 * * * * ?"); // 每秒通知一次
            return TriggerBuilder.newTrigger()
                    .forJob(LuaDeleteHotSearchJobDetail())
                    .withIdentity("luaDeleteHotSearchTrigger")
                    .withSchedule(cron)
                    .build();
        }
    }
    
    

    [!CAUTION]

    1. 请读者自行实现redisPreExecute()方法来完成获取数据
    2. QuartzConfig里面的任务执行周期请自行修改cron表达式,这里本人为了测试才设置为18秒一次,正常而言应该设置为活动开始的时间
  2. 使用监听Redis事件实现更新(不使用定时任务和缓存失效策略,因为这个方案追求高一致性,定时任务会出现在任务执行之前就已经被访问的问题,缓存失效策略也一样,如果数据过期之前已经变更数据,但是还没来得及刷新缓存,那么就会出现读取旧数据的问题Redisson实现

    • 配置Redisson客户端

      package com.coding.demo.configuration;
      
      import org.redisson.Redisson;
      import org.redisson.api.RedissonClient;
      import org.redisson.codec.JsonJacksonCodec;
      import org.redisson.config.Config;
      import org.springframework.context.annotation.Bean;
      import org.springframework.context.annotation.Configuration;
      
      @Configuration
      public class RedissonConfig {
           @Bean
       public RedissonClient getRedisson() {
           Config config = new Config();
           config.useSingleServer().
                   setAddress("redis://" + "127.0.0.1" + ":" + "6379");
           config.setCodec(new JsonJacksonCodec());
           return Redisson.create(config);
       }
      }
      
  • 实现订阅功能

    package com.coding.demo.utils.SeckillUtilsPackage.StockManagementPackage.RedisPubSub2Stock;
    
    import com.github.xiaolyuh.cache.Cache;
    import com.github.xiaolyuh.manager.CacheManager;
    import org.redisson.api.RTopic;
    import org.redisson.api.RedissonClient;
    import org.redisson.api.listener.MessageListener;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.stereotype.Component;
    
    @Component
    public class SubStock {
    
        private final RedissonClient redissonClient;
        @Autowired
        private CacheManager cacheManager;
    
        public SubStock(RedissonClient redissonClient){
            this.redissonClient = redissonClient;
        }
    
        private void subscribe() {
            RTopic topic = redissonClient.getTopic("stock_management");
            topic.addListener(String.class, new MessageListener<String>() {
                @Override
                public void onMessage(CharSequence channel, String message) {
                    String[] parts = message.split(":");
                    String skuId = parts[0];
                    String local_keyname_stock = "seckill:sku:" + skuId + ":stock";
                    int newStockValue = Integer.parseInt(parts[1]);
                    for (Cache cache:cacheManager.getCache("skuInfo")){
                        cache.evict(local_keyname_stock);
                        cache.put(local_keyname_stock,newStockValue);
                        break;
                    }
                }
            });
        }
    }
    

    [!NOTE]

    1. 这里指定了订阅的主题为stock_management,同时预先定制好消息的形式skuId:newStockValue
    2. 由于本系统使用了Github上面的Layering-Cache多级缓存框架,所以使用的CacheManager、Cache都是这个框架重写的版本,请读者多加留意
    3. CacheManager的getCache方法返回的是多个同样的Cache,所以直接使用break取一条就够了,然后更新操作使用先删除再添加的方法,而且cache只有两个插入方法:put()putIfAbsent(),需要更新所以采用前者
  • 实现发布功能

    lua脚本实现原子性更新Redis

    ---
    --- Generated by EmmyLua(https://github.com/EmmyLua)
    --- Created by juicychenzepeng.
    --- DateTime: 2024/6/9 11:11
    --- System: MacBook Air M2 MacOS Sonoma 14.2
    ---
    -- Lua 脚本用于库存管理
    local skuId = KEYS[1]
    local keyName = 'seckill:sku:'..skuId..':stock'
    local currentInventory = tonumber(redis.call('GET', keyName))
    
    -- 库存数据不存在返回-1
    if currentInventory == nil then
        return -1
    end
    
    -- 库存不够返回0
    if currentInventory < 1 then
        return 0
    else
        redis.call('DECRBY', keyName, 1)
        redis.call('PUBLISH', 'stock_management', keyName..':'..redis.call('GET', keyName))
        return 1
    end
    
    

    使用Jedis执行lua脚本

    package com.coding.demo.utils;
    
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.core.io.ClassPathResource;
    import org.springframework.stereotype.Component;
    import redis.clients.jedis.Jedis;
    import redis.clients.jedis.JedisPool;
    
    import java.io.BufferedReader;
    import java.io.InputStreamReader;
    import java.util.List;
    // 集成lua脚本的操作的工具类
    @Component
    @Slf4j
    public class LuaScriptUtils {
        @Autowired
        private final JedisPool jedisPool;
    
        public LuaScriptUtils(JedisPool jedisPool) {
            this.jedisPool = jedisPool;
        }
        // 执行lua脚本
        public Object executeLuaScript2Redis(String scriptPath, List<String> keys, List<String> args) {
            try (Jedis jedis = jedisPool.getResource()) {
                String luaScript = readLuaScript(scriptPath);
                // 执行 Lua 脚本
                Object lua_value = jedis.eval(luaScript, keys, args);
                log.info("执行Lua脚本文件完成");
                return lua_value;// 如果脚本执行有返回值就会返回,如果没有就会返回一个默认值
            }
        }
        // 读取lua脚本文件的里面的每一行语句
        private String readLuaScript(String scriptPath) {
            StringBuilder luaScript = new StringBuilder();
            try (BufferedReader reader = new BufferedReader(new InputStreamReader(
                    new ClassPathResource(scriptPath).getInputStream()))) {
                String line;
                while ((line = reader.readLine()) != null) {
                    luaScript.append(line).append("\n");
                }
                log.info("Lua脚本文件已读取");
            } catch (Exception e) {
                throw new RuntimeException("读取Lua脚本文件失败:" + scriptPath, e);
            }
            return luaScript.toString();
        }
    }
    
    package com.coding.demo.utils.SeckillUtilsPackage.StockManagementPackage.RedisPubSub2Stock;
    
    import com.coding.demo.utils.LuaScriptUtils;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.stereotype.Component;
    
    import java.util.ArrayList;
    import java.util.List;
    
    @Component
    @Slf4j
    public class PubStock {
    
        @Autowired
        private LuaScriptUtils luaScriptUtils;
    
        public boolean executeDecreaseStock(int skuId){
            List<String> keys_param = new ArrayList<>();
            List<String> argv_param = new ArrayList<>();
            keys_param.add(String.valueOf(skuId));
            Object executeResult = luaScriptUtils.executeLuaScript2Redis("UpdateStock.lua",keys_param,argv_param);
            switch (Integer.parseInt((String) executeResult)){
                case -1:
                    log.info("库存数据不存在");
                    return false;
                case 0:
                    log.info("库存数量不足");
                    return false;
                case 1:
                    log.info("库存数据Redis更新完成");
            }
            return true;
        }
    }
    
    
  1. 聚合查询库存操作和扣减库存操作

    • 查询操作:先查询本地缓存,如果不存在就查Redis数据,而且需要更新本地缓存

      public Integer readStock(int skuId){
          String localCacheStock = null;
          for (Cache cache:cacheManager.getCache("skuInfo")){
              localCacheStock = cache.get("seckill:sku:" + skuId + ":stock",String.class);
              if(localCacheStock == null){
                  localCacheStock = stringRedisTemplate.boundValueOps("skuInfo:seckill:sku:" + skuId + ":stock").get();
                  cache.put("seckill:sku:" + skuId + ":stock",localCacheStock);
              }
              break;
          }
          if (localCacheStock != null) {
              log.info("正常获取库存数据");
              return Integer.parseInt(localCacheStock);
          }
          log.error("库存数据获取异常");
          return null;
      }
      
    • 更新操作:直接调用发布类的发布方法

      public boolean writeStock(int skuId){
          return pubStock.executeDecreaseStock(skuId);
      }
      
    package com.coding.demo.utils.SeckillUtilsPackage.StockManagementPackage.RedisPubSub2Stock;
    
    import com.coding.demo.utils.SeckillUtilsPackage.StockManagementPackage.ReadWriteStockStrategy;
    import com.github.xiaolyuh.cache.Cache;
    import com.github.xiaolyuh.manager.CacheManager;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.data.redis.core.StringRedisTemplate;
    import org.springframework.stereotype.Component;
    
    /**
     * @author juicychenzepeng
     * @apiNote 查询剩余库存的操作直接使用本地缓存+Redis,如果没有数据的话就从Redis里面拿数据,然后把数据加载进Cache里面 && 扣减库存的功能直接使用PubStock来操作
     * */
    @Component("localRedis")
    @Slf4j
    public class LocalRedis2ReadWrite implements ReadWriteStockStrategy {
    
        @Autowired
        private CacheManager cacheManager;
        @Autowired
        private StringRedisTemplate stringRedisTemplate;
        @Autowired
        private LocalRedisPubStock localRedisPubStock;
    
        @Override
        public Integer readStock(int skuId){
            String localCacheStock = null;
            for (Cache cache:cacheManager.getCache("skuInfo")){
                localCacheStock = cache.get("seckill:sku:" + skuId + ":stock",String.class);
                if(localCacheStock == null){
                    localCacheStock = stringRedisTemplate.boundValueOps("skuInfo:seckill:sku:" + skuId + ":stock").get();
                    cache.put("seckill:sku:" + skuId + ":stock",localCacheStock);
                }
                break;
            }
            if (localCacheStock != null) {
                log.info("本地+Redis链路:正常获取库存数据");
                return Integer.parseInt(localCacheStock);
            }
            log.error("本地+Redis链路:库存数据获取异常");
            return null;
        }
    
        @Override
        public boolean writeStock(int skuId){
            return localRedisPubStock.executeDecreaseStock(skuId);
        }
    }
    

分片库存(高并发读写性能高+一致性要求低)

读链路通过使用一个特定的本地缓存来存储库存不足商品的标识,用以识别库存不足所以不做取操作。如果存在标识那么就代表库存不足,管理员如果有需要就追加库存(属于管理员接口),然后添加(更新)库存数据于Redis和本地缓存再删除原来的标识,如果不存在标识就代表库存充足,直接到特定的本地库存条目进行数据的取用。

写链路按照商品的pin(个人使用商品id)对所有库存信息进行Redis存储分片(也就是不同的商品的库存信息可能存储在不同的Redis节点里面),然后利用读操作进行判断是否够库存(因为本地缓存有标识所以不用专门到Redis里面判断,这是比较慢的),不够的话直接向本地缓存添加该商品库存不足的标识,停止其他操作,如果库存充足的,通过hashtag(每一个商品的库存信息只存在于一个节点里面)访问对应Redis节点的库存信息然后直接进行更新,再利用消息队列实现异步更新本地缓存的对应库存弱一致性库存量级大读多写多场景使用

优点:读写高并发,可以减小单一节点的压力,提升系统可用性

缺点:弱一致性,少卖,库存和流量的量级要求要达标

图片

具体实现逻辑:

  1. 实现读数据操作

    • 本地缓存里面维护一个特定的缓存Cache,里面专门put所有过期商品的库存标识内容,也是使用一个定时任务来实现,在活动开始之前先添加所有商品的库存信息(这个在缓存预热工作里已经实现)

      package com.coding.demo.utils.SeckillUtilsPackage.StockManagementPackage.SliceByPin2Stock;
      
      import com.github.xiaolyuh.annotation.Cacheable;
      import com.github.xiaolyuh.annotation.FirstCache;
      import com.github.xiaolyuh.support.CacheMode;
      import lombok.extern.slf4j.Slf4j;
      import org.springframework.stereotype.Component;
      
      import java.util.concurrent.TimeUnit;
      
      /**
       * @author juicychenzepeng
       * @apiNote 实现分片的辅助工作
       * @since 2024-06-06
       * @version 1.0.0
       * */
      @Component
      @Slf4j
      public class SliceUtils2Stock {
      
          @Cacheable(value = "NoneStockSignal",
                  key = "'stock:'+#skuId+':signal'",
                  cacheMode = CacheMode.FIRST,
                  firstCache = @FirstCache(expireTime = 600,timeUnit = TimeUnit.SECONDS))
          public String insertNoneStockSignal(int skuId){
              return "out";
          }
      }
      
      
    • 扫描存储库存标识的缓存,然后如果存在该商品的标识的话,返回-1,如果不存在就直接用Cache拿数据,返回拿到的数据

      public Integer readStock(int skuId){
          String stockSignalKey = "stock:"+skuId+":signal";
          String local_keyname_stock = "seckill:sku:" + skuId + ":stock";
      
          for (Cache cache:cacheManager.getCache("NoneStockSignal")){
              String signal = cache.get(stockSignalKey, String.class);
              if(signal!=null){
                  return -1;
              }
              String stock = cache.get(local_keyname_stock,String.class);
              if(stock!=null){
                  log.info("分片库存链路:正常获取库存数据");
                  return Integer.parseInt(stock);
              }
              break;
          }
          log.error("分片库存链路:获取库存数据异常");
          return null;
      }
      
  2. 实现写数据操作

    • 配置多节点信息

      package com.coding.demo.configuration;
      
      import org.redisson.Redisson;
      import org.redisson.api.RedissonClient;
      import org.redisson.codec.JsonJacksonCodec;
      import org.redisson.config.Config;
      import org.springframework.beans.factory.annotation.Value;
      import org.springframework.context.annotation.Bean;
      import org.springframework.context.annotation.Configuration;
      
      @Configuration
      public class RedissonConfig {
      
          @Value("${redis.cluster.nodes}")
          private String[] nodeAddresses;
      
          @Bean
          public RedissonClient redissonClient() {
              Config config = new Config();
              config.useClusterServers()
                      .addNodeAddress(nodeAddresses);
              return Redisson.create(config);
          }
      }
      
      
    • 调用读操作,然后根据返回结果进行三种操作:如果为-1代表已经不足了,那么就不操作,如果为0代表刚好不足,向本地缓存添加标识然后不做更新操作,如果为整数,那么进行数据的更新(使用Redisson的RMap基于一致性哈希槽的自动进行数据分片机制和路由机制来实现把库存信息均匀分散到不同的节点上

      private final RedissonClient redissonClient;
      public SliceUtils2Stock(RedissonClient redissonClient){
          this.redissonClient = redissonClient;
      }
      public void updateRedisStock(int skuId) {
          String shardKey = getShardKey(skuId);
          RMap<String, Integer> stockMap = redissonClient.getMap(shardKey);
      
          // 获取分布式锁
          redissonClient.getLock(String.valueOf(skuId)).lock();
          try {
              // 获取当前库存
              Integer currentStock = stockMap.get(String.valueOf(skuId));
              if (currentStock == null || currentStock < 1) {
                  throw new IllegalStateException("库存量不足");
              }
              stockMap.put(String.valueOf(skuId), currentStock - 1);
          } finally {
              redissonClient.getLock(String.valueOf(skuId)).unlock();
          }
      }
      private String getShardKey(Integer skuId) {
          // 使用哈希函数生成分片键
          int shardId = skuId.hashCode() % 9; // 假设有9个Redis实例
          return "RedisNode:" + shardId;
      }
      

      [!IMPORTANT]

      1. 这里面使用哈希函数实现分片键,然后可以保证同一个skuId会被分配到同一个Redis节点上面
      2. 使用Redisson自身的分布式锁来保证更新操作的并发安全
    • 使用Kafka消息队列发布消息来异步更新本地缓存

      package com.coding.demo.utils.SeckillUtilsPackage.StockManagementPackage.SliceByPin2Stock;
      
      import com.github.xiaolyuh.cache.Cache;
      import com.github.xiaolyuh.manager.CacheManager;
      import lombok.extern.slf4j.Slf4j;
      import org.apache.kafka.clients.consumer.ConsumerRecord;
      import org.springframework.beans.factory.annotation.Autowired;
      import org.springframework.kafka.annotation.KafkaListener;
      import org.springframework.stereotype.Component;
      
      @Component
      @Slf4j
      public class SliceMQConsumer {
      
          @Autowired
          private CacheManager cacheManager;
      
          @KafkaListener(topics = "slice-local")
          public void updateLocalStock(ConsumerRecord<String,String> consumerRecord){
              int skuId = Integer.parseInt(consumerRecord.value());
              String cacheName = "skuInfo";
              String localCacheStock = "seckill:sku:" + skuId + ":stock";
              for(Cache cache:cacheManager.getCache(cacheName)){
                  String localStockValue = cache.get(localCacheStock, String.class);
                  if(localStockValue != null){
                      Integer newLocalStockValue = Integer.parseInt(localStockValue)-1;
                      cache.evict(localCacheStock);
                      cache.put(localCacheStock,newLocalStockValue);
                      log.info("分片库存链路:本地库存更新完成");
                      return;
                  }
                  log.error("分片库存链路:找不到本地库存");
                  return;
              }
          }
      }
      
  3. 实现运营添加库存(管理员后台接口)这个自行实现一个接口来实现

  4. 聚合查询操作和更新操作:

    package com.coding.demo.utils.SeckillUtilsPackage.StockManagementPackage.SliceByPin2Stock;
    
    import com.coding.demo.utils.SeckillUtilsPackage.StockManagementPackage.ReadWriteStockStrategy;
    import com.github.xiaolyuh.cache.Cache;
    import com.github.xiaolyuh.manager.CacheManager;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.kafka.core.KafkaTemplate;
    import org.springframework.stereotype.Component;
    
    /**
     * @author juicychenzepeng
     * @since 2024-06-06
     * @version 1.0.0
     * @apiNote 这个写操作需要使用独立的缓存预热功能,也就是需要需要实现把商品的库存信息插入到hashtag指向的Redis节点进行存储
     * */
    @Component("slice")
    @Slf4j
    public class Slice2ReadWrite implements ReadWriteStockStrategy {
    
        @Autowired
        private CacheManager cacheManager;
        @Autowired
        private SliceUtils2Stock sliceUtils2Stock;
        @Autowired
        private KafkaTemplate stringKafkaTemplate;
    
        @Override
        public Integer readStock(int skuId){
            String stockSignalKey = "stock:"+skuId+":signal";
            String local_keyname_stock = "seckill:sku:" + skuId + ":stock";
    
            for (Cache cache:cacheManager.getCache("NoneStockSignal")){
                String signal = cache.get(stockSignalKey, String.class);
                if(signal!=null){
                    return -1;
                }
                String stock = cache.get(local_keyname_stock,String.class);
                if(stock!=null){
                    log.info("分片库存链路:正常获取库存数据");
                    return Integer.parseInt(stock);
                }
                break;
            }
            log.error("分片库存链路:获取库存数据异常");
            return null;
        }
    
        @Override
        public boolean writeStock(int skuId){
            Integer readResult = readStock(skuId);
            switch (readResult){
                case -1:
                    return false;
                case 0:
                    sliceUtils2Stock.insertNoneStockSignal(skuId);
                    return false;
                default:
                    sliceUtils2Stock.updateRedisStock(skuId);
                    stringKafkaTemplate.send("slice-local", String.valueOf(skuId));
                    return true;
            }
        }
    }
    
    

三级缓存结合消息队列(读功能通用,Kafka)

这个方法考虑到数据能够最大稳定地拿出来,所以采用了一个顺序获得数据的方案,本地缓存没有数据就在Redis里面拿,Redis里面没有数据就到数据库里面拿,本地缓存优先,所以理论上读数据无上限,写操作由于是异步更新多级存储,所以高并发情境下一致性不是特别优秀。并发不高库存量级多读多写少一般并发量下一致性要求很高的的场景适用

优点:一般并发量情景数据很稳定不丢失数据,由于是优先更新本地缓存而且读的时候也是优先本地缓存,所以一致性很高

缺点:受制于消息队列的性质,高并发情境下容易数据不一致问题会比较严重

具体实现逻辑:

  1. 实现获得数据操作:

    /**
     * @apiNote 实现从三级存储结构里面__获得所有数据包含数据__(字符串类型),按照顺序:一级本地缓存->二级Redis缓存->三级数据库,不作为接口专门获得数据的方法,因为一次只可以获得一个数据
     *
     */
    public String getDataFrom_3Storage(String cache_name, String local_keyname, String redis_keyname,String databaseOP, int skuId, boolean isFramework){
        Collection<Cache> cacheCollection = cacheManager.getCache(cache_name);      // 直接getCache的话返回的是一个集合Collection
        String data_asString = null;
        for(Cache cache:cacheCollection){
            // 只需要拿一次就够了,因为Cache里面可以有同名的,但是我们为了避免混乱就避免缓存名重复
            data_asString = cache.get(local_keyname, String.class);
            break;
        }
    
        if(data_asString!=null){
            log.info("______从本地缓存中获得{}数据:{}______",databaseOP,data_asString);
            return data_asString;     // 使用了Layering-cache框架的注解存储Redis缓存的时候会多一个双引号
        }
        else {
            // 从Redis里面获得数据
            data_asString = stringRedisTemplate.boundValueOps(redis_keyname).get();
            if(isFramework){
                data_asString = data_asString.substring(1,data_asString.length()-1);
            }
            if(data_asString !=null){
                log.info("______从Redis缓存中获得{}数据:{}______",databaseOP,data_asString);
                return data_asString;
            }
            // 从数据库里面获得数据,这里如果出现问题的话就要改为获得所有字段的数据
            else {
                if(Objects.equals(databaseOP, "STOCK")){
                    data_asString = String.valueOf(seckillSkuMapper.selectStockBySkuId(skuId));
                }
                else if(Objects.equals(databaseOP, "LIMIT")){
                    data_asString = String.valueOf(seckillSkuMapper.selectLimitBySkuId(skuId));
                }
                log.info("______从数据库中获得{}数据:{}______",databaseOP,data_asString);
                return data_asString;
            }
        }
    }
    
  2. 实现Kafka消息队列的发布和监听功能:

    • 配置KafkaConfig:

      package com.coding.demo.configuration.Kafka;
      
      import com.coding.demo.domain.Event.OrderInfo;
      import org.apache.kafka.clients.consumer.ConsumerConfig;
      import org.apache.kafka.common.serialization.StringDeserializer;
      import org.springframework.beans.factory.annotation.Autowired;
      import org.springframework.boot.context.properties.EnableConfigurationProperties;
      import org.springframework.kafka.annotation.EnableKafka;
      import org.springframework.kafka.support.serializer.JsonDeserializer;
      import org.springframework.kafka.support.serializer.JsonSerializer;
      import org.apache.kafka.common.serialization.StringSerializer;
      import org.springframework.context.annotation.Bean;
      import org.springframework.context.annotation.Configuration;
      import org.springframework.kafka.core.DefaultKafkaProducerFactory;
      import org.springframework.kafka.core.KafkaTemplate;
      import org.springframework.kafka.core.ProducerFactory;
      
      import java.util.HashMap;
      import java.util.Map;
      
      // 配合CustomizePartitioner.java进行自定义分区策略
      @Configuration
      
      public class KafkaConfig {
      
          @Bean
          public ProducerFactory<String, Object> producerFactory() {
              Map<String, Object> configs = new HashMap<>();
              configs.put(org.apache.kafka.clients.producer.ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
              // 设置键的序列化器和值的序列化器
              configs.put(org.apache.kafka.clients.producer.ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
              configs.put(org.apache.kafka.clients.producer.ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
              configs.put(org.apache.kafka.clients.consumer.ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
              configs.put(org.apache.kafka.clients.consumer.ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
              configs.put(org.apache.kafka.clients.producer.ProducerConfig.TRANSACTIONAL_ID_CONFIG, "order-info-producer"); // 设置事务ID前缀
              // 其他配置项...
      
              // 设置自定义的分区器
              configs.put(org.apache.kafka.clients.producer.ProducerConfig.PARTITIONER_CLASS_CONFIG, CustomizePartitioner.class);
      
              return new DefaultKafkaProducerFactory<>(configs);
          }
      
          @Bean
          public KafkaTemplate<String, Object> kafkaTemplate() {
              return new KafkaTemplate<>(producerFactory());
          }
      }
      
    • 实现发布功能:

      package com.coding.demo.utils.SeckillUtilsPackage.StockManagementPackage.KafkaPubSub2Stock;
      
      import com.alibaba.fastjson.JSONObject;
      import com.coding.demo.domain.SyncInfo;
      import com.github.xiaolyuh.cache.Cache;
      import com.github.xiaolyuh.manager.CacheManager;
      import jakarta.annotation.Resource;
      import lombok.extern.slf4j.Slf4j;
      import org.springframework.beans.factory.annotation.Autowired;
      import org.springframework.kafka.core.KafkaTemplate;
      import org.springframework.stereotype.Component;
      import org.springframework.transaction.annotation.Transactional;
      
      import java.util.Collection;
      
      /**
       * @apiNote 实现向三级存储结构进行数据的同步更新操作,当前系统只有库存量需要同步,kafka版本
       * */
      @Component
      @Slf4j
      public class KafkaPubStock {
      
          @Autowired
          private CacheManager cacheManager;
          @Resource
          private KafkaTemplate<String,String> kafkaTemplate;
      
          @Transactional
          public boolean syncUpdateData_3Storage(String cache_name,String local_keyname,String redis_keyname,String databaseOP,int change,int skuId,boolean isFramework){
              // 本地缓存的同步更新
              Collection<Cache> cacheCollection = cacheManager.getCache(cache_name);
              for(Cache cache:cacheCollection){
                  String old_LocalValue = cache.get(local_keyname,String.class);
                  if(old_LocalValue == null){
                      log.error("^^^^^^本地缓存同步更新失败^^^^^^");
                      return false;
                  }
                  String new_LocalValue = String.valueOf(Integer.parseInt(old_LocalValue)+change);
                  cache.evict(local_keyname);
                  cache.put(local_keyname,new_LocalValue);
                  break;
              }   // 执行一遍就完成
      
              SyncInfo syncInfo = new SyncInfo();
              syncInfo.setCacheName(cache_name);
              syncInfo.setLocalKeyName(local_keyname);
              syncInfo.setRedisKeyName(redis_keyname);
              syncInfo.setDatabaseOP(databaseOP);
              syncInfo.setChange(change);
              syncInfo.setSkuId(skuId);
              syncInfo.setFramework(isFramework);
              log.info("开始同步Redis缓存与数据库");
              kafkaTemplate.executeInTransaction(kafkaOperations -> {
                  kafkaOperations.send("sync-Redis-DB", JSONObject.toJSONString(syncInfo));
                  return true;
              });
              return true;
          }
      }
      

      [!CAUTION]

    由于本方法获取数据的顺序是先本地再Redis再数据库,所以为了最大可能地保证数据一致,先更新本地缓存,Redis采用异步更新

    • 实现监听功能:

       package com.coding.demo.utils.SeckillUtilsPackage.StockManagementPackage.KafkaPubSub2Stock;
      
      import com.alibaba.fastjson.JSONObject;
           import com.coding.demo.domain.SyncInfo;
      import com.coding.demo.mapper.SeckillSkuMapper;
      import com.coding.demo.utils.SeckillUtilsPackage.SeckillRedisCaffeineUtils;
      import lombok.extern.slf4j.Slf4j;
      import org.apache.kafka.clients.consumer.ConsumerRecord;
      import org.springframework.beans.factory.annotation.Autowired;
      import org.springframework.data.redis.core.StringRedisTemplate;
      import org.springframework.kafka.annotation.KafkaListener;
      import org.springframework.stereotype.Component;
      
      import java.util.Objects;
           import java.util.concurrent.TimeUnit;
      
      @Component
           @Slf4j
      public class KafkaSub2Stock {
          @Autowired
          private StringRedisTemplate stringRedisTemplate;
          @Autowired
          private SeckillRedisCaffeineUtils seckillRedisCaffeineUtils;
          @Autowired
          private SeckillSkuMapper seckillSkuMapper;
      
          @KafkaListener(topics = "sync-Redis-DB")
               public void sync_execute(ConsumerRecord<String,String> record){
              SyncInfo syncInfo = JSONObject.parseObject(record.value(), SyncInfo.class);
              Long surplus_expiration = stringRedisTemplate.boundValueOps(syncInfo.getRedisKeyName()).getExpire(); // 剩余过期时间
              String old_RedisValue = seckillRedisCaffeineUtils.getDataFrom_3Storage(syncInfo.getCacheName()
                      , syncInfo.getLocalKeyName()
                      , syncInfo.getRedisKeyName()
                      , syncInfo.getDatabaseOP()
                      , syncInfo.getSkuId()
                      , syncInfo.isFramework()); // 也从三级存储空间拿数据
              if(old_RedisValue == null || surplus_expiration == null){
                  log.error("^^^^^^Redis缓存同步更新失败^^^^^^");
                  return;
              }
              String new_RedisValue = String.valueOf(Integer.parseInt(old_RedisValue)+ syncInfo.getChange());
              stringRedisTemplate.boundValueOps(syncInfo.getRedisKeyName() ).set(new_RedisValue,surplus_expiration, TimeUnit.SECONDS);
      
              if(Objects.equals(syncInfo.getDatabaseOP(), "STOCK")){
                       seckillSkuMapper.updateStock(syncInfo.getSkuId());
              }
          }
      }
      
  3. 聚合查询库存操作和扣减库存操作:

    • 查询操作:直接调用三级存储的取数据方法getDataFrom_3Storage()

       public Integer readStock(int skuId){
          String cache_name = "skuInfo";
          String local_keyname_stock = "seckill:sku:" + skuId + ":stock";
          String redis_keyname_stock = "skuInfo:seckill:sku:" + skuId + ":stock";
          String stock = seckillRedisCaffeineUtils.getDataFrom_3Storage(cache_name,local_keyname_stock,redis_keyname_stock,"STOCK",skuId,true);
          if (stock != null){
              log.info("多级存储链路:正常获取库存数据");
              return Integer.parseInt(stock);
          }
          log.error("多级存储链路:库存数据获取异常");
          return null;
      }
      
    • 更新操作:直接调用发布消息的方法syncUpdateData_3Storage()

       public boolean writeStock(int skuId){
          String cache_name = "skuInfo";
          String local_keyname_stock = "seckill:sku:" + skuId + ":stock";
          String redis_keyname_stock = "skuInfo:seckill:sku:" + skuId + ":stock";
          return kafkaPubStock.syncUpdateData_3Storage(cache_name,local_keyname_stock,redis_keyname_stock,"STOCK",-1,skuId,true);
      }
      
    package com.coding.demo.utils.SeckillUtilsPackage.StockManagementPackage.KafkaPubSub2Stock;
    
    import com.coding.demo.utils.SeckillUtilsPackage.SeckillRedisCaffeineUtils;
    import com.coding.demo.utils.SeckillUtilsPackage.StockManagementPackage.ReadWriteStockStrategy;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.stereotype.Component;
    
    /**
     * @author juicychenzepeng
     * @apiNote 多级存储架构的读写库存操作
     * @since 2024-06-09
     * */
    @Component("multiStore")
    @Slf4j
    public class MultiStore2ReadWrite implements ReadWriteStockStrategy {
    
        @Autowired
        private SeckillRedisCaffeineUtils seckillRedisCaffeineUtils;
        @Autowired
        private KafkaPubStock kafkaPubStock;
    
        @Override
        public Integer readStock(int skuId){
            String cache_name = "skuInfo";
            String local_keyname_stock = "seckill:sku:" + skuId + ":stock";
            String redis_keyname_stock = "skuInfo:seckill:sku:" + skuId + ":stock";
            String stock = seckillRedisCaffeineUtils.getDataFrom_3Storage(cache_name,local_keyname_stock,redis_keyname_stock,"STOCK",skuId,true);
            if (stock != null){
                log.info("多级存储链路:正常获取库存数据");
                return Integer.parseInt(stock);
            }
            log.error("多级存储链路:库存数据获取异常");
            return null;
        }
    
        @Override
        public boolean writeStock(int skuId){
            String cache_name = "skuInfo";
            String local_keyname_stock = "seckill:sku:" + skuId + ":stock";
            String redis_keyname_stock = "skuInfo:seckill:sku:" + skuId + ":stock";
            return kafkaPubStock.syncUpdateData_3Storage(cache_name,local_keyname_stock,redis_keyname_stock,"STOCK",-1,skuId,true);
        }
    }
    
    

动态自定义查询更新方法

向application.yml配置文件添加新配置

read-write-stock:
  strategy: localRedis     # 可选值: slice, localRedis, multiStore, dualRedis

创建统一查询更新方法的接口类

package com.coding.demo.utils.SeckillUtilsPackage.StockManagementPackage;

public interface ReadWriteStockStrategy {
    Integer readStock(int skuId);
    boolean writeStock(int skuId);
}

创建对应的服务类注入配置

package com.coding.demo.utils.SeckillUtilsPackage.StockManagementPackage;

import com.coding.demo.utils.SeckillUtilsPackage.StockManagementPackage.DualRedis2Stock.DualRedis2ReadWrite;
import com.coding.demo.utils.SeckillUtilsPackage.StockManagementPackage.KafkaPubSub2Stock.MultiStore2ReadWrite;
import com.coding.demo.utils.SeckillUtilsPackage.StockManagementPackage.RedisPubSub2Stock.LocalRedis2ReadWrite;
import com.coding.demo.utils.SeckillUtilsPackage.StockManagementPackage.SliceByPin2Stock.Slice2ReadWrite;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Service;

@Service
public class ReadWriteStockService {

    private final ReadWriteStockStrategy readWriteStockStrategy;

    @Autowired
    private KafkaTemplate stringKafkaTemplate;

    @Autowired
    public ReadWriteStockService(@Value("${read-write-stock.strategy}") String strategyName,
                                 Slice2ReadWrite slice2ReadWrite,
                                 LocalRedis2ReadWrite localRedis2ReadWrite,
                                 MultiStore2ReadWrite multiStore2ReadWrite,
                                 DualRedis2ReadWrite dualRedis2ReadWrite) {
        switch (strategyName) {
            case "slice":
                this.readWriteStockStrategy = slice2ReadWrite;
                break;
            case "localRedis":
                this.readWriteStockStrategy = localRedis2ReadWrite;
                break;
            case "multiStore":
                this.readWriteStockStrategy = multiStore2ReadWrite;
                break;
            case "dualRedis":
                this.readWriteStockStrategy = dualRedis2ReadWrite;
                break;
            default:
                throw new IllegalArgumentException("Invalid stock strategy: " + strategyName);
        }
    }

    public Integer readStock(int skuId) {
        return readWriteStockStrategy.readStock(skuId);
    }

    public boolean writeStock(int skuId) {
        stringKafkaTemplate.send("update-DB-stock", String.valueOf(skuId));
        return readWriteStockStrategy.writeStock(skuId);
    }
}

这里面在writeStock方法里面添加发布消息的语句,实现更新数据库的操作,之后如果要查询库存或者更细你库存信息的话只需要调用这个服务类

消费功能的实现

由于秒杀活动里面要求用户只能购买限定数量的商品,所以添加一个计数器,这个时候Redis就很适合完成这个任务

创建Redis计数器

前端页面一般在活动开始的时候就可以调用这个方法来实现创建

/**
 * @apiNote 创建限购量key,专门记录登录的每一个用户的对于所有商品的当前购买量
 * 一次秒杀活动只执行一次这个方法
 * 使用setIfAbsent防止限购key刷新
 */
public void initLimitKey(String token){
    LocalDateTime time = LocalDateTime.now().plusMinutes(1);        
    // 提前一分钟开始给进入商城页面的用户创建计数器
    List<SeckillSku> seckillSkuList = new ArrayList<>();            
    // 当前所有要进行秒杀的商品
    for(Integer spuId: seckillSpuMapper.findSeckillSpuIdsByTime(time)){
        seckillSkuList.addAll(seckillSkuMapper.findSeckillSkuBySpuId(spuId));
    }
    for(SeckillSku seckillSku:seckillSkuList){
        Integer skuId = seckillSku.getSkuId();
        Duration duration = Duration.between(seckillSpuMapper.selectStartTime(seckillSkuMapper.selectSpuIdBySkuId(skuId)), seckillSpuMapper.selectEndTime(seckillSkuMapper.selectSpuIdBySkuId(skuId)));         // 每件商品的秒杀时间可能不一样
        long expirationTime = duration.getSeconds();        
        // 获得秒杀时长(时间单位以秒为单位)
        stringRedisTemplate.boundValueOps("Limit-personal-"+StpUtil.getLoginIdByToken(token)+"-product-"+skuId).setIfAbsent("0",expirationTime,TimeUnit.MILLISECONDS);  
        // 不存在才可以创建,否则用户一刷新页面重新进入自动调用这个方法的时候就会重新变为0
    }
}

[!CAUTION]

  1. 这个方法的思想是可以在活动开放的时候调用这个接口然后给所有准备秒杀的商品(提前一点创建可以防止出现还没创建完就有购物行为以及减小活动开始的时候Redis的压力
  2. 这个方法可以采用前端页面开放(定时任务实现定时开放)给用户的同时进行创建,这里面使用setIfAbsent()方法保证不存在这个计数器才可以创建,当然还可以使用后端直接用定时任务设定这个计数器在活动开始前自动调用创建

订单的创建和处理工作

创建订单操作:

  1. 判断库存是否足够创建订单以及创建订单的操作、调用微信支付、支付宝支付等第三方api判断是否已经支付

    package com.coding.demo.utils.SeckillUtilsPackage;
    
    import com.coding.demo.domain.Event.OrderInfo;
    import com.coding.demo.mapper.OrderMapper;
    import com.coding.demo.utils.SeckillUtilsPackage.SeckillRedisCaffeineUtils;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.data.redis.core.StringRedisTemplate;
    import org.springframework.stereotype.Component;
    
    import java.time.LocalDateTime;
    
    // 实现订单一些协助操作:判断是否支付成功
    @Component
    @Slf4j
    public class OrderUtils {
    
        @Autowired
        private SeckillRedisCaffeineUtils seckillRedisCaffeineUtils;
        @Autowired
        private OrderMapper orderMapper;
        @Autowired
        private StringRedisTemplate stringRedisTemplate;
    
        // 提供商品的id来三级存储里面查找对应的库存量,如果有库存量就可以创建订单
        public boolean isAbleCreateOrder(Integer skuId){
            String cache_name = "skuInfo";
            String local_keyname_stock = "seckill:sku:" + skuId + ":stock";
            String redis_keyname_stock = "skuInfo:seckill:sku:" + skuId + ":stock";
            Integer stock_target = Integer.parseInt(seckillRedisCaffeineUtils.getDataFrom_3Storage(cache_name,local_keyname_stock,redis_keyname_stock,"STOCK",skuId,true));
            return stock_target > 0;
    
        }
    
        // 创建订单然后存储进数据库和Redis缓存里面,最后返还订单号
        public Integer createOrder(Integer consumerId,Integer productId,Integer buyAmount){
            OrderInfo orderInfo = new OrderInfo();
            LocalDateTime TIME_NOW = LocalDateTime.now();
            orderInfo.setOrderStatus(1);
            orderInfo.setCreateTime(TIME_NOW);
            orderInfo.setConsumerId(consumerId);
            orderInfo.setProductId(productId);
            orderInfo.setBuyAmount(buyAmount);
            if(!isAbleCreateOrder(productId)){
                orderInfo.setOrderStatus(0);
                log.error("订单创建失败");
                return null;
            }
    
            orderMapper.insertOrderInfo(orderInfo);
            // 订单信息的持久操作
            stringRedisTemplate.
                opsForList().
                leftPush("orderId",
                         String.valueOf(orderInfo.getOrderId()));   
            // 不需要使用Redis的队列因为实现起来效果差不多但是会比Kafka更加复杂,所以这个操作只是用来方便查看订单情况
            log.info("订单创建成功,订单号为:{}",orderInfo.getOrderId());
            return orderInfo.getOrderId();
        }
    
        // 自行调用支付框架来用API实现支付成功与否的返回
        public boolean isHavePaid(){
            return true;
        }
    
    }
    
  2. 发布请求订单创建消息和请求支付后更新操作消息

    package com.coding.demo.event.producer;
    import com.alibaba.fastjson.JSONObject;
    import com.coding.demo.domain.Event.OrderInfo;
    import com.coding.demo.event.topic.OrderTopic;
    import com.coding.demo.mapper.OrderMapper;
    import com.coding.demo.utils.SeckillUtilsPackage.OrderUtils;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.kafka.core.KafkaTemplate;
    import org.springframework.stereotype.Component;
    import org.springframework.transaction.annotation.Transactional;
    @Component
    @Slf4j
    public class OrderProducer implements OrderTopic {
        @Autowired
        private KafkaTemplate kafkaTemplate;
        @Autowired
        private OrderUtils orderUtils;
        @Autowired
        private OrderMapper orderMapper;
        @Transactional     // 第一级削峰,把购买请求发出来
        public void sendOrderInfo(Integer consumerId,Integer productId,Integer buyAmount) {
    
            Integer orderId = orderUtils.createOrder(consumerId,productId,buyAmount);
            if(orderId==null){
                return;
            }
            OrderInfo orderInfo = orderMapper.selectOrderInfoByOrderId(orderId);
            kafkaTemplate.executeInTransaction(kafkaOperations -> {
                kafkaOperations.send(TOPIC_ORDER, orderId.toString(), JSONObject.toJSONString(orderInfo));
                return true;
            });
            log.info("订单已提交");
        }                   
        // 使用Kafka的事务功能保证操作的原子性以及同一线程串行执行从而保证数据的一致
    
        @Transactional     // 第二级削峰,把支付成功的信息发出来
        public void sendPayInfo(String payId,Integer productId){
            // 暂时默认支付成功,后面对接支付功能的时候就要添加api来返回支付的情况,具体验证内容还是不需要用Redis的List
            if(orderUtils.isHavePaid()){
                kafkaTemplate.executeInTransaction(kafkaOperations -> {
                    kafkaOperations.send(TOPIC_PAY, payId, productId.toString());
                    return true;
                });
                log.info("订单完成支付");
                return;
            }
            log.error("订单未支付");
        }
    }
    
  3. 订单信息的创建和支付后更新操作

    package com.coding.demo.event.consumer;
    
    import cn.dev33.satoken.stp.StpUtil;
    import com.alibaba.fastjson.JSONException;
    import com.alibaba.fastjson.JSONObject;
    import com.coding.demo.domain.Event.OrderInfo;
    import com.coding.demo.event.producer.OrderProducer;
    import com.coding.demo.event.topic.OrderTopic;
    import com.coding.demo.service.SeckillAllService;
    import lombok.extern.slf4j.Slf4j;
    import org.apache.kafka.clients.consumer.ConsumerRecord;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.kafka.annotation.KafkaListener;
    import org.springframework.stereotype.Component;
    
    @Component
    @Slf4j
    public class OrderConsumer implements OrderTopic {
    
        @Autowired
        private OrderProducer orderProducer;
        @Autowired
        private SeckillAllService seckillAllService;
    
        @KafkaListener(topics = TOPIC_ORDER)
        public void processOrderInfo(ConsumerRecord record) {
            try {
                OrderInfo orderInfo = JSONObject.parseObject(record.value().toString(), OrderInfo.class);
                Integer orderId = orderInfo.getOrderId();
                Integer consumerId = orderInfo.getConsumerId();
                Integer productId = orderInfo.getProductId();
                Integer buyAmount = orderInfo.getBuyAmount();
                String payId = String.valueOf(orderId) +"-"+ String.valueOf(buyAmount) +"-"+ String.valueOf(consumerId);
    
                orderProducer.sendPayInfo(payId,productId);
    
            } catch (JSONException e) {
                // 处理 Fastjson 解析异常
                // 可以记录日志或进行其他逻辑处理
                e.printStackTrace();
            }
    
        }
    
        @KafkaListener(topics = TOPIC_PAY)
        public void processPayInfo(ConsumerRecord record) {
            if(record==null){
                log.error("支付失败,订单取消");
            }
            else {
                String payId = record.key().toString();
                String orderId = payId.split("-")[0];
                String productId = record.value().toString();   
                // 使用toString()保证数据类型一定为String类型
                Integer buyAmount = Integer.parseInt(payId.split("-")[1]);
                String consumerId = payId.split("-")[2];
    
                // 执行消费数据库里面商品的操作,根据数量来执行购买的操作
                for(int i=0;i<buyAmount;++i){
                    seckillAllService.consumeGood(
                        Integer.parseInt(productId), 
                        StpUtil.getTokenValueByLoginId(consumerId));
                }
                log.info(
                    "订单完成,支付凭证:{},订单号:{},商品id:{},购买量:{}",
                    payId,
                    orderId,
                    productId,
                    buyAmount);
            }
        }
    }
    
  4. 更新商品的方法consumerGood()

    public boolean consumeGood(int skuId,String token){
        RLock rLock_stock = redissonClient.getLock("stock_lock");
        RLock rLock_limit = redissonClient.getLock("limit_lock");
        String cache_name = "skuInfo";
        String local_keyname_limit = "seckill:sku:" + skuId + ":seckillLimit";
        String redis_keyname_limit = "skuInfo:seckill:sku:" + skuId + ":seckillLimit";
        String local_keyname_stock = "seckill:sku:" + skuId + ":stock";
        String redis_keyname_stock = "skuInfo:seckill:sku:" + skuId + ":stock";
        String redis_keyname_limit_lock = "Limit-personal-"+StpUtil.getLoginIdByToken(token)+"-product-"+skuId;
        Integer limit = Integer.parseInt(seckillRedisCaffeineUtils.getDataFrom_3Storage(cache_name,local_keyname_limit,redis_keyname_limit,"LIMIT",skuId,true));
    
        try{
            rLock_stock.lock();
            rLock_limit.lock();
    
            Integer stock_now = readWriteStockService.readStock(skuId);
            Integer limit_now = Integer.parseInt(stringRedisTemplate.boundValueOps(redis_keyname_limit_lock).get());
            if(stock_now > 0 || limit_now < limit){
                if(readWriteStockService.writeStock(skuId)){
                    log.info("^^^^^^库存量三级存储数据同步成功^^^^^^");
                }else{
                    log.error("^^^^^^库存量三级存储数据同步失败^^^^^^");
                    return false;
                }
                if(seckillRedisCaffeineUtils.syncUpdateData_OnlyRedis(redis_keyname_limit_lock)){
                    log.info("^^^^^^限购量计数器同步成功^^^^^^");
                }else{
                    log.error("^^^^^^限购量计数器同步失败^^^^^^");
                    return false;
                }
            }
        } catch(Exception e){
            e.printStackTrace();
        } finally {
            rLock_stock.unlock();
            rLock_limit.unlock();
        }
        return true;
    }
    
  5. 用户进行购买操作的接口

    package com.coding.demo.controller.EventControllerPackage;
    
    import cn.dev33.satoken.stp.StpUtil;
    import com.coding.demo.domain.Result;
    import com.coding.demo.event.producer.OrderProducer;
    import jakarta.servlet.http.HttpServletRequest;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.web.bind.annotation.*;
    import org.springframework.web.context.request.RequestContextHolder;
    import org.springframework.web.context.request.ServletRequestAttributes;
    
    @RestController
    @CrossOrigin
    @RequestMapping("/order")
    public class OrderController {
    
        @Autowired
        private OrderProducer orderProducer;
    
        @GetMapping("/buy-pay")
        public Result buyAndPay(Integer productId, Integer buyAmount, @RequestHeader("satoken")String satoken){
            Integer consumerId = Integer.parseInt((String)StpUtil.getLoginIdByToken(satoken));
            orderProducer.sendOrderInfo(consumerId, productId, buyAmount);
            return Result.success("商品购买成功");
        }
    }
    
    

热点信息的缓存预热

使用Layering-Cache多级缓存框架实现插入数据于本地缓存+Redis缓存

@Cacheable(value = "skuInfo",
        key = "'seckill:sku:' + #sku.getSkuId() + ':stock'",
        depict = "库存量",
        cacheMode = CacheMode.ALL,
        firstCache =  @FirstCache(expireTime = EXPIRETIME_FIRST+1, timeUnit = TimeUnit.SECONDS),
        secondaryCache = @SecondaryCache(expireTime = EXPIRETIME_SECOND+2, preloadTime = PRELOADTIME, forceRefresh = true, timeUnit = TimeUnit.SECONDS))
public String insert2LocalSku_Stock(SeckillSku sku){
    return String.valueOf(sku.getSeckillStock());
}

实现预热功能

/**
 * @apiNote 缓存的预热功能
 * */
public void redisPreExecute(){

    LocalDateTime time = LocalDateTime.now().plusMinutes(5);
    List<SeckillSpu> seckillSpuList = seckillSpuMapper.findSeckillSpusByTime(time);
    // 把要秒杀的商品根据秒杀时间在当前时间的前五分钟开始获得出来(放在数据库里面)
    for (SeckillSpu spu:seckillSpuList){
        List<SeckillSku> seckillSkuList = seckillSkuMapper.findSeckillSkuBySpuId(spu.getSpuId());
        for(SeckillSku sku:seckillSkuList){
            log.info("开始将{}号sku商品的存货量预热到本地缓存和Redis缓存",sku.getSkuId());
            // 存储到Redis的数据类型为String-String类型(无小键),键名代表skuId,值对应库存数量的字符串内容
            // 本双层循环的作用在于根据是否有库存信息来判断是否已经存储当前秒杀商品于缓存
            // 现在把重复插入的判断改为判断Redis就好,因为使用了多级缓存框架,本地缓存存在这个数据的话那么Redis缓存也存在
            boolean skuRedisMainKey = redisCacheUtils.getMainKeyBySkuId(sku.getSkuId());
            if(skuRedisMainKey){
                log.info("{}号sku商品的信息已进入缓存",sku.getSkuId());
            }

            seckillRedisCaffeineUtils.insert2LocalSku_Stock(sku);
            log.info("{}号sku商品库存数进入本地缓存和Redis缓存",sku.getSkuId());

            seckillRedisCaffeineUtils.insert2LocalSku_Id(sku);
            seckillRedisCaffeineUtils.insert2LocalSku_SkuId(sku);
            seckillRedisCaffeineUtils.insert2LocalSku_SpuId(sku);
            seckillRedisCaffeineUtils.insert2LocalSku_SeckillPrice(sku);
            seckillRedisCaffeineUtils.insert2LocalSku_GmtCreate(sku);
            seckillRedisCaffeineUtils.insert2LocalSku_GmtModified(sku);
            seckillRedisCaffeineUtils.insert2LocalSku_SeckiilLimit(sku);
            seckillRedisCaffeineUtils.insert2LocalSku_skuName(sku);
            seckillRedisCaffeineUtils.insert2LocalSku_skuPhoto(sku);
            log.info("{}号sku商品的基本信息进入本地缓存和Redis缓存",sku.getSkuId());
            log.info("<----------------------------------------------------------------------->");


        }
        log.info("");
        boolean spuRedisMainKey = redisCacheUtils.getMainKeyBySpuId(spu.getSpuId());
		//String randomCode = redisCacheUtils.getRandomCodeKey(spu.getSpuId());
        int randomCodeNew = RandomUtils.nextInt(0, 900000) + 100000;            
        // 随机码需要放在这里(循环体里面)否则每一个商品种类都会一样的spuId这样,然后这里也可以实现先随机定义好,然后如果已经进入缓存的话,就要对这个随机码的值改为查询到的值,保证不会每次都新建多一个全新的缓存数据
        log.info("{}号商品种类随机码生成值为:{}", spu.getSpuId(),randomCodeNew);

        if(spuRedisMainKey){
		//log.info("{}号商品种类的基本信息已进入缓存,使用原随机码:{}", spu.getSpuId(), randomCode);
            log.info("{}号商品种类的基本信息已进入缓存,使用原随机码", spu.getSpuId());
		//seckillRedisCaffeineUtils.insert2LocalSpu_RandomCode(spu, Integer.parseInt(randomCode));
        }
        else {
            log.info("{}号商品种类的基本信息未进入缓存,使用新随机码:{}", spu.getSpuId(), randomCodeNew);
            seckillRedisCaffeineUtils.insert2LocalSpu_RandomCode(spu,randomCodeNew);
        }
        seckillRedisCaffeineUtils.insert2LocalSpu_Id(spu);
        seckillRedisCaffeineUtils.insert2LocalSpu_SpuId(spu);
        seckillRedisCaffeineUtils.insert2LocalSpu_ListId(spu);
        seckillRedisCaffeineUtils.insert2LocalSpu_StartTime(spu);
        seckillRedisCaffeineUtils.insert2LocalSpu_EndTime(spu);
        seckillRedisCaffeineUtils.insert2LocalSpu_GmtCreate(spu);
        seckillRedisCaffeineUtils.insert2LocalSpu_GmtModified(spu);
        seckillRedisCaffeineUtils.insert2LocalSpu_spuName(spu);
        log.info("{}号商品种类的基本信息进入本地缓存和Redis缓存",spu.getSpuId());
        log.info("<----------------------------------------------------------------------->");
    }
}

[!NOTE]

预热功能采用当前时间添加几分钟来实现,结合数据库存储的秒杀活动时间来实现

从三级存储里面稳定获得数据

/**
 * @apiNote 实现从三级存储结构里面__获得所有数据包含数据__(字符串类型),按照顺序:一级本地缓存->二级Redis缓存->三级数据库,不作为接口专门获得数据的方法,因为一次只可以获得一个数据
 *
 */
public String getDataFrom_3Storage(String cache_name, String local_keyname, String redis_keyname,String databaseOP, int skuId, boolean isFramework){
    Collection<Cache> cacheCollection = cacheManager.getCache(cache_name);      // 直接getCache的话返回的是一个集合Collection
    String data_asString = null;
    for(Cache cache:cacheCollection){
        // 只需要拿一次就够了,因为Cache里面可以有同名的,但是我们为了避免混乱就避免缓存名重复
        data_asString = cache.get(local_keyname, String.class);
        break;
    }

    if(data_asString!=null){
        log.info("______从本地缓存中获得{}数据:{}______",databaseOP,data_asString);
        return data_asString;     
        // 使用了Layering-cache框架的注解存储Redis缓存的时候会多一个双引号
    }
    else {
        // 从Redis里面获得数据
        data_asString = stringRedisTemplate.boundValueOps(redis_keyname).get();
        if(isFramework){
            data_asString = data_asString.substring(1,data_asString.length()-1);
        }
        if(data_asString !=null){
            log.info("______从Redis缓存中获得{}数据:{}______",databaseOP,data_asString);
            return data_asString;
        }
        // 从数据库里面获得数据,这里如果出现问题的话就要改为获得所有字段的数据
        else {
            if(Objects.equals(databaseOP, "STOCK")){
                data_asString = String.valueOf(seckillSkuMapper.selectStockBySkuId(skuId));
            }
            else if(Objects.equals(databaseOP, "LIMIT")){
                data_asString = String.valueOf(seckillSkuMapper.selectLimitBySkuId(skuId));
            }
            log.info("______从数据库中获得{}数据:{}______",databaseOP,data_asString);
            return data_asString;
        }
    }
}

[!CAUTION]

这里面的策略就是如果当前本地缓存拿的数据为空就在Redis里面拿,Redis的数据为空就在数据库里面拿数据


本文立足于自己的项目,结合京东技术的思路进行融合,如有问题欢迎读者评论区予以指出

  • 50
    点赞
  • 56
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值