使用 Redis 实现秒杀系统

一、简介

1 秒杀系统

秒杀系统是指在一个非常短的时间内(通常是几十秒钟),将某种商品或服务以极低的价格进行销售。这种销售方式需要保证高并发和高可用性,同时防止超卖和恶意攻击等问题。秒杀系统的特点是大量的用户在同一时间瞬间涌入服务器,该类型的高并发读写操作对系统性能提出了较高的要求。

2 常见问题

在秒杀场景下,会遇到以下常见问题:

  • 高并发(每秒新建的TCP连接数非常高)
  • 超卖(由于网页刷新频率过快,导致用户可购买数量超出实际剩余数量)
  • 恶意攻击(攻击者通过机器人、脚本等手段进行抢购,从而瘫痪系统)

二、Redis 简介

Redis(Remote Dictionary Server)是一个开源、支持网络、基于内存、键值对存储数据库。它支持多种数据结构,如字符串(String)、哈希(Hash)、列表(List)、集合(Set)和有序集合(Sorted Set)。Redis 的访问速度非常快,在存储海量数据时,丝毫不会影响系统性能,所以 Redis 被广泛应用于高并发的互联网项目中。

1 Redis基本概念

  • 单线程:Redis 采用单线程模型进行工作,避免了线程切换带来的上下文切换开销,因此速度非常快。
  • 持久化存储:Redis 中支持 RDB 持久化和 AOF 持久化,可以将内存中的数据保留到磁盘上,防止服务器崩溃时数据的丢失。
  • 丰富的数据类型:Redis 支持多种数据类型,如字符串、列表、集合、哈希表等,方便用户根据不同的业务需求选择合适的数据类型。
  • 高性能:Redis 是一个基于内存的数据库,它的读写速度都非常快,同时也因为是基于内存,所以 Redis 的存储容量受限,不适用于存储大量的数据。
  • 分布式:Redis 支持分布式集群,可以将数据进行分片存储,提高了系统的并发处理能力,同时增加了系统的可扩展性,保证了高可用性。

2 Redis 作为秒杀系统的优点

  • 高效读写:Redis 的读写性能非常快,能够满足秒杀系统的高并发读写需求,保证了系统的高效运作。
  • 数据持久化:Redis 支持数据的持久化存储,可以将内存中的数据保留到磁盘上,防止服务器崩溃时数据的丢失,减小对系统的影响。
  • 分布式特性:Redis 支持分布式集群,可以将缓存分片存储,避免单节点压力过大,保证了系统的可扩展性和高可用性。
  • 原子操作:Redis 支持多个操作的原子性,如事务处理、CAS 等,保证了数据的一致性和安全性,有效地防止了超卖等问题。

三、Redis 在秒杀系统中的应用

1 数据存储中的应用

Redis 的快速读写操作使得它成为二级缓存的首选,常用于缓存不经常变更或者不经常使用的数据。在秒杀系统中,Redis 可以用来缓存商品名称、库存数量、是否售罄等信息,减少数据库的访问量,提高数据读写效率和系统的响应速度。

// jedis 是 Redis 的 Java 客户端

// 设置 key-value 对
jedis.set("product:001:name", "iPhone 12");
jedis.set("product:001:stock", "1000");

// 获取 key-value 对
String name = jedis.get("product:001:name");
String stock = jedis.get("product:001:stock");

2 在分布式锁中的应用

在秒杀场景中为了防止商品超卖,通常需要引入分布式锁机制。Redis 提供了一种简单有效的分布式锁实现方式,通过抢占 key 来实现锁,避免了多个系统同时修改数据的情况。


// 尝试获取锁
boolean lockResult = jedis.setnx("lock:product:001", "value");
if(lockResult) {
    
    
    // 获取锁成功,执行业务逻辑...
    // 释放锁
    jedis.del("lock:product:001");
} else {
    
    
    // 获取锁失败,等待重试...
}

3 在消息队列中的应用

在秒杀场景中系统需要处理大量并发请求,为了避免请求在瞬间涌入服务器导致系统崩溃,可以使用消息队列来对用户的请求进行排队,这样可以有效地缓解系统压力。


// 将秒杀请求加入消息队列
jedis.lpush("seckill:requests", "request001");

// 从消息队列中获取请求
String request = jedis.brpop("seckill:requests", 10).get(1);

四、Redis秒杀系统设计

1 数据库表设计

秒杀系统一般需要两个表:商品表和订单表。商品表用于存储商品信息,订单表用于存储订单信息。

商品表设计

在商品表中需要包含以下字段:

字段名 类型 描述
id int 商品id
name varchar 商品名称
description text 商品描述
price decimal 商品单价
stock int 商品库存

订单表设计

在订单表中需要包含以下字段:

扫描二维码关注公众号,回复: 15557044 查看本文章
字段名 类型 描述
id int 订单id
user_id int 用户id
goods_id int 商品id
create_time datetime 创建时间
status int 订单状态,0表示未支付,1表示已支付

2 接口设计

秒杀系统需要以下几个接口:

  • 商品列表接口:用于获取商品列表。
  • 商品详情接口:用于获取指定商品的详细信息。
  • 下单接口:用于下单操作。
  • 订单列表接口:用于获取对应用户的订单列表。

3 队列设计

秒杀系统需要一个队列用于处理订单的下单请求,可以选用Redis作为队列。在Redis中使用list数据结构作为队列,在多个服务器下运行多个相同的消费者程序,以实现分布式处理订单请求。

4 Redis 优化策略

为了保证秒杀系统的高并发和性能,需要对Redis进行优化。优化策略包括:

  • 增加Redis的内存大小,以缓存更多的商品和订单信息。
  • 合理设置Redis的过期时间,避免Redis中的数据一直占用内存。
  • 使用Redis集群模式或主从复制模式,以提高Redis的可用性和性能。

五、秒杀系统的实现流程

1 商品初始化

在秒杀系统中首先需要进行商品初始化。具体实现流程如下:

// 定义商品实体类
public class Goods {
    
    
  private int id;
  private String name;
  private int stock;
  private double price;
  // 省略 getter 和 setter 方法
}

// 在系统启动时,从数据库中读取所有秒杀商品信息
List<Goods> goodsList = goodsDAO.queryAllSeckillGoods();
for (Goods goods : goodsList) {
    
    
  // 将商品信息存入到 Redis 中,以便后续操作使用
  redisService.set("seckill:good:" + goods.getId(), JSON.toJSONString(goods));
  // 将商品库存数量存入到 Redis 中,以便进行库存的修改操作
  redisService.set("seckill:stock:" + goods.getId(), goods.getStock());
}

5.2 前端页面限流

在秒杀系统中,为了避免瞬间大量用户访问导致系统崩溃,需要对前端页面进行限流。具体实现流程如下:

// 在前端页面中加入验证码或者滑动验证等机制
public class SeckillController {
    
    
  @PostMapping("/seckill")
  public String seckill(@RequestParam("goodsId") int goodsId,
                        @RequestParam("userId") int userId,
                        @RequestParam("verifyCode") String verifyCode) {
    
    
    // 验证码通过之后再执行秒杀操作
    if (verifyCodeIsValid(userId, verifyCode)) {
    
    
      // 秒杀操作
      seckillService.seckill(goodsId, userId);
    }
  }
}

5.3 后端请求接口限流

在秒杀系统中,同样需要对后端请求接口进行限流,以避免恶意攻击。具体实现流程如下:

// 使用限流工具对后端接口进行限流
public class SeckillController {
    
    
  @PostMapping("/seckill")
  public String seckill(@RequestParam("goodsId") int goodsId,
                        @RequestParam("userId") int userId) {
    
    
    if (rateLimiter.tryAcquire()) {
    
     // 使用 Guava RateLimiter 进行限流
      // 秒杀操作
      seckillService.seckill(goodsId, userId);
    } else {
    
    
      return "请求过于频繁,请稍后再试!";
    }
  }
}

5.4 分布式锁控制全局唯一性

在秒杀系统中由于多个用户同时访问同一个商品,需要对商品进行加锁,保证全局唯一性。具体实现流程如下:

// 使用 Redis 的分布式锁实现秒杀商品的唯一性
public class SeckillServiceImpl implements SeckillService {
    
    

  @Override
  public void seckill(int goodsId, int userId) {
    
    
    // 加锁操作
    String lockKey = "seckill:lock:" + goodsId;
    String requestId = UUID.randomUUID().toString();
    long expireTime = 3000; // 锁过期时间设置为 3 秒钟
    boolean isSuccess = redisService.tryLock(lockKey, requestId, expireTime);
    if (isSuccess) {
    
    
      try {
    
    
        // 秒杀操作
        int stock = redisService.get("seckill:stock:" + goodsId, Integer.class);
        if (stock > 0) {
    
    
          redisService.decr("seckill:stock:" + goodsId); // 减库存
          seckillDAO.insertOrder(goodsId, userId); // 写入订单记录
          notificationService.sendSeckillSuccessMsg(userId, goodsId); // 发送通知消息
        }
      } finally {
    
    
        // 释放锁操作
        redisService.releaseLock(lockKey, requestId);
      }
    }
  }

}

5.5 Redis 减库存

在秒杀系统中对商品的操作都是基于 Redis 获取和修改的,包括商品库存数量。具体实现流程如下:

// Redis 减库存操作
public class SeckillServiceImpl implements SeckillService {
    
    

  @Override
  public void seckill(int goodsId, int userId) {
    
    
    // 加锁和减库存操作
    int stock = redisService.get("seckill:stock:" + goodsId, Integer.class);
    if (stock > 0) {
    
    
      redisService.decr("seckill:stock:" + goodsId);
      // 省略其他业务逻辑操作
    }
  }
  
}

5.6 MySQL 写入订单记录

在秒杀系统中,需要将成功秒杀的订单信息记录到 MySQL 数据库中。具体实现流程如下:

// MySQL 写入订单记录操作
public class SeckillDAOImpl implements SeckillDAO {
    
    

  @Override
  public void insertOrder(int goodsId, int userId) {
    
    
    String sql = "INSERT INTO seckill_order (goods_id, user_id, create_time) VALUES (?, ?, ?)";
    jdbcTemplate.update(sql, goodsId, userId, new Date());
  }
  
}

5.7 消息通知用户秒杀成功

在秒杀系统中可以通过消息队列等方式,对用户进行秒杀成功的通知。具体实现流程如下:

// 消息通知用户秒杀成功
public class NotificationServiceImpl implements NotificationService {
    
    

  private static final Logger logger = LoggerFactory.getLogger(NotificationServiceImpl.class);

  @Override
  public void sendSeckillSuccessMsg(int userId, int goodsId) {
    
    
    // 使用消息队列对用户进行通知
    Message message = new Message();
    message.setUserId(userId);
    message.setGoodsId(goodsId);
    rocketMQTemplate.convertAndSend("seckill-success-topic", message);
    logger.info("通知消息已发送:{}", message);
  }
  
}

六、安全策略

秒杀系统是一个高并发业务,为了保证系统的安全性和稳定性,在使用Redis做缓存的同时,需要针对以下两个方面进行安全策略的设计:

1 防止超卖

在秒杀活动中,一件商品仅有有限的数量,当超过了这个数量之后就不能再销售,此时需要采取防止超卖的措施。

实现方式

  • 基于Redis的单线程机制,把减库存操作原子化执行,并且需要锁住对应的商品id。
  • 针对锁定商品的情况,使用 Redis 的分布式锁机制。以此来保证一次只有一个请求能够成功地请求到库存锁,并且持有锁的时间应尽量短。

下面是Java代码实现:

public boolean decrementStock(String key) {
    
    
    String lockKey = "LOCK_" + key;
    try (Jedis jedis = jedisPool.getResource()) {
    
    
        //加锁
        String lockValue = UUID.randomUUID().toString();
        String result;
        while (true) {
    
    
            result = jedis.set(lockKey, lockValue, "NX", "PX", 3000);
            if ("OK".equals(result)) {
    
    
                break;
            }
            Thread.sleep(100);
        }
        //判断是否加锁成功
        if (!lockValue.equals(jedis.get(lockKey))) {
    
    
            return false;
        }

        try {
    
    
            //操作库存
            int stock = Integer.parseInt(jedis.get(key));
            if (stock > 0) {
    
    
                jedis.decr(key);
                return true;
            }
            return false;
        } finally {
    
    
            //释放锁
            jedis.del(lockKey);
        }
    } catch (Exception e) {
    
    
        log.error("decrementStock failed, key:{}", key, e);
        return false;
    }
}

2 防止恶意刷单

恶意用户通过程序模拟大量请求,从而导致服务器无法响应正常用户的请求。为了解决这个问题,需要加入防止恶意刷单的策略。

实现方式

  • 对每个用户IP进行限流,设置每分钟能够请求的次数。
  • 设置人机验证,如图形验证码或者短信验证等机制,让恶意用户成本太高从而放弃攻击。

下面是Java代码实现:

public boolean checkUserRequest(String ip) {
    
    
    // 检查ip对应的请求数是否超过最大允许请求次数
    String requestCountKey = "REQUEST_COUNT_" + ip;
    try (Jedis jedis = jedisPool.getResource()) {
    
    
        long currentCount = jedis.incr(requestCountKey);
        if (currentCount == 1) {
    
    
            // 第一次计数,设置过期时间为60s
            jedis.expire(requestCountKey, 60);
        }

        if (currentCount > maxRequestPerMinute) {
    
    
            // 超过最大允许请求次数,返回false
            return false;
        }
        return true;
    }
}

七、部署方案

1 安全性优化

  • 部署到专门的CDN缓存服务器,减小服务器带宽压力,保护服务器和数据库。
  • 设置服务器防火墙,禁止外部访问 Redis 和 数据库等敏感资源。
  • 开启Redis的持久化,以防止内存数据丢失或者意外宕机等情况。

2 性能优化

  • 提高Redis性能,采用集群方式,增加机器和配置Redis相关参数等。
  • 使用高效的缓存查询方式,避免频繁查询数据库,如使用Redis自带的哈希表来存储秒杀商品信息。

猜你喜欢

转载自blog.csdn.net/u010349629/article/details/130904369
今日推荐