秒杀系统项目总结

本文最后更新于:2022年1月30日 晚上

高并发场景下的一些注意事项。

文中数据均使用 jmeter 和 Linux 中的 top 命令测试获得。

性能瓶颈

性能瓶颈主要有两个,一个便是单体 Application Server Tomcat 的性能瓶颈,一个便是数据库的性能瓶颈。

对于应用服务器的性能瓶颈,目前我所了解的解决方法便有以下几点:

  1. 横向拓展,增加 Application Server 数量,本质上就是增加服务器,让诸如 Nginx 一类的 HTTP Server 负载均衡来分摊请求
  2. 根据服务器的性能,对 Application Server 进行调参
  3. 升级单个服务器

对于数据库的性能瓶颈,我所了解的解决方法有:

  1. 搭建 MySQL 集群,使用诸如 mycat 的数据库读写分离 + 负载均衡管理中间件来降低单体数据库的负载
  2. MySQL 调参优化
  3. SQL 优化
  4. 加缓存,加消息队列等中间件

添加缓存

处理高并发,本质就是两种策略,一种是加服务器,另一种便是加缓存。

对于静态资源,有很多处理方式,比如 CDN 缓存服务器、浏览器缓存、页面缓存(不常用)、URL 缓存(不常用),此外,还有在业务代码层面的缓存添加,代码层面能添加的缓存方式则非常多,比如对象缓存、缓存预修改等,业务代码添加的缓存是减少数据库服务器负载的重要手段,以下介绍本人用到过的缓存实例。

对象缓存

对于经常被访问,却又变化不大的数据库字段,比如商品列表、商品详情,直接缓存到 Redis,下次读取直接读缓存,如果缓存中没有才读数据库,这种缓存策略一般被称为对象缓存。

注意,当这些缓存数据被更新时,Redis 中的缓存也应当被更新,此处涉及到缓存数据与数据库数据的双写一致性,详情可看下文或进行搜索。

对象缓存,有效地解决了频繁调取数据库的情况,获取数据都是现在缓存中获取,十分有效地减少了 MySQL 的 读请求

这种策略的不足之处也很明显,对于读请求,缓存能有效地降低数据库的读请求,但对于写请求,对象缓存则无能为力了。

优化实例

设备:2核4G云服务器,Tomcat 9.0 默认配置,Redis 默认配置,1核1G MySQL 服务器

接口:查询商品列表

并发策略:5000个线程并发轮询10次接口

添加缓存前:预热后,jmeter 测得 QPS 约为1306

添加缓存后:预热后,jmeter 测得 QPS 约为2884

页面静态化

通俗地讲,将资源进行标记,让浏览器能够将这些资源缓存到本地,下次访问时,缓存到本地的资源则不需要再次请求获取。

具体的方法就是在响应头上加缓存相关的字段,比如Cache-Control,详情可浏览 HTTP 协议和 Nginx 相关内容。

拓展:对于静态资源,我们可以通过配置 HTTP 服务器,将多个静态资源请求合并为一个请求,避免占用过多的空闲连接数(但一般都会先交给 CDN 服务器进行静态资源处理,除此之外,一些 CDN 还会将动态请求自动分发到离用户最近的服务站点上,提升传输速度)

秒杀流程优化

数据库

对于秒杀活动,由于是高并发场景,我们在进行数据库修改操作时,如果是生产平时的订单表,如果流程设计有缺陷、代码质量不足,或产生了由于并发量过大而导致的一些问题,比如库存超卖、同一个用户秒杀多次等问题,因此,需要健壮的数据库设计来进行兜底。

独立建表

对于秒杀活动生成的订单、参与秒杀的商品等秒杀业务涉及到的表,均可以独立建表,这样不仅能够在数据库层面将秒杀业务和普通订单的下单业务分离出来,减少对单个表的访问次数,减少创建销毁锁的消耗等,还能针对秒杀场景对秒杀订单表进行优化。

建立唯一索引(秒杀业务场景特解)

秒杀的业务场景,通常只允许一个用户对一件商品成功秒杀一次,因此可以对用户id + 商品id 建立唯一索引,保证生成订单的唯一性。

SQL

在 update 时,添加健壮的 where 条件,比如当秒杀商品的库存大于0时才允许进行秒杀商品的减库存操作。

事务

设计好秒杀流程的事务,保证在秒杀流程某一环节出错时,对一些关键操作进行回滚。

订单

对于已经生成成功的秒杀订单,我们可以先不用在订单表生成普通订单,而是拿缓存中订单商品的相关信息,先生成一个临时的普通订单缓存到 Redis,随后等待秒杀活动结束后,才将生成普通订单,并写入数据库,减少对秒杀商品订单、普通订单、秒杀商品的表的操作。

Redis

加 Redis 的本质,便是尽可能地减少访问数据库,因此,对于秒杀这一快速更改数据库的某一个字段的场景,我们需要进行对应的预处理,来减少进入秒杀时数据库的负载。

秒杀商品库存预处理

系统初始化时,预先将商品库存数量加载到 Redis,收到请求时,优先减少 Redis 中的库存数量,如果 Redis 库存不足,直接返回,如果库存足够,在 Redis 中预减库存,并处理请求(放进消息队列中进行等待,返回给前端排队中的状态,后端根据数据库处理能力,依次处理数据,将订单、库存等更新信息更新到数据库中,于此同时,前端轮询访问是否秒杀成功,详情见下文)

分布式锁

在商品预处理时,可能会因为大量并发而导致 Redis 修改出现问题,虽然本项目不会出现问题,但对其它并发场景有着一些作用。

RabbitMQ

RabbitMQ 是用 Erlang 语言开发的消息队列中间件,本文章使用 RabbitMQ 版本为 3.9.13

Docker

docker run -d --hostname my-rabbit --name rabbit_self -p 15672:15672 -p 5672:5672 rabbitmq:3.9.13-management

相关知识

官方文档

四种交换机模式

在秒杀流程中,使用 RabbitMQ 对数据库访问请求频繁的接口进行削峰,减少对数据库的负载。

优化后的秒杀流程

系统初始化,把商品库存存入 Redis (InitializingBean)

收到请求,Redis 预减库存,库存不足,直接返回失败(Redis)

减少库存后,将请求入队,返回排队中(MQ)

请求出队,收到请求后生成订单,减少库存,并将订单写入缓存(MySQL Redis)

客户端轮询,查看是否生成订单来判断是否秒杀成功(Redis)

简易代码逻辑:

/**
 * 处理秒杀的核心方法简略代码
 * TODO 如果 Redis 预减库存小于0,把对应的 goodId 写入 Guava 缓存中,先读缓存,存在则直接返回,减少访问 Redis 次数
 */
public Response handleFlashRequest(String userId, String goodId) {
  // 预减库存,使用分布式锁(此处直接将部分代码移到此处,其实这里也不用加分布式锁,只是为了熟悉加锁操作罢了)
	// GoodKey 是定义生成对应 Redis Key 格式的类
  long timeMills = System.currentTimeMills();
  long limitTime = 1000L;
  while (defaultRedisService.setNx(LockKey.getFlashLockKey(goodId), 
                                   userId, 
                                   limitTime, 
                                   TimeUnit.MILLISECONDS)) {
    if (System.currentTimeMills() - timeMills >= limitTime) {
      break;
    }
  }
	try {
  	if (defaultRedisService.decr(GoodKey.getStockKey(goodId)) < 0) {
    	// TODO 在 Response 添加对 ErrorCode 的统一转译处理
    	return Response.buildFailure(GoodErrorCode.MAX_NUM.getCode(), GoodErrorCode.MAX_NUM.getDescription());
  	}
	} catch (Exception e) {
  	// 防止超出 Long 范围而抛出异常
  	// GoodsErrorCode 实现了一个带有 getCode 和 getDescription 方法的 MessageCode 接口
  	return Response.buildFailure(GoodErrorCode.MAX_NUM.getCode(), GoodErrorCode.MAX_NUM.getDescription());
	} finally {
    defaultRedisService.getDel(LockKey.getFlashLockKey(goodId));
  }
	// 生成入队对象,此处忽略值填充
	FlashDO flashDO = new FlashDO();
	// 入队
	mqSender.sendFlashRequestMessage(flashDO);
	// 返回成功状态
	return Response.buildSuccess();
}

/**
 * 处理出队后的简略代码(异步)
 */
@RabbitListener(queues=MQMessage.FLASH_QUEUE)
public void handleReceivedFlashRequest(String message) {
  // 还原为 DO
  FlashDO flashDO = new Gson().fromJson(message, FlashDO.class);
  // 由于经过消息队列的削峰,在此处进行数据库操作就比较友好,此处详细的数据库操作
  
  // 秒杀订单重复校验、秒杀商品库存校验,没有过校验,直接返回
  
  // 秒杀订单对象生成,忽略值填充
  flashOrderEntity = new FlashGoodsEntity();
  
  // 减库存成功后,写入秒杀订单表,写入成功后,将秒杀订单写入 Redis
  if (flashGoodsService.insert(flashOrderEntity)) {
    String redisKey = FlashOrderKey.getOrderKey(flashDO.getUserId, flashDO.getGoodId);
    defaultRedisService.set(redisKey, flashOrderEntity);
  }
  
}

/**
 * 间隔一定时间轮询获取秒杀结果
 * TODO 轮询获取一定次数或时间后,如果仍然拿不到订单,则调用另一个查秒杀订单数据库的方法
 */
public SingleResponse<flashResultCO> getFlashResult(String userId, String goodId) {
  // 从 Redis 中获取订单信息
  FlashOrderEntity flashOrderEntity = 
    defaultRedisService.get(FlashOrderKey.getOrderKey(userId, goodId), FlashOrderEntity.class);
  
  // 创建生成结果对象,忽略值填充
  FlashResultCO flashResultCO = new FlashResultCO();
  
  // 定义订单状态
  String orderStatusCode = ObjectUtil.isNull(flashOrderEntity) ? 
    FlashOrderStatus.NULL.getCode() : FlashOrderStatus.SUCCEED.getCode();
  flashResultCO.setOrderStatusCode(orderStatusCode);
  // 返回订单结果
  return SingleResponse.of(flashResultCO);
  
}

优化实例

设备:2核4G云服务器,Tomcat 9.0 默认配置,Redis 默认配置,RabitMQ 默认配置,1核1G MySQL 服务器

接口:库存为1000的商品秒杀

并发策略:5000个线程并发轮询10次接口

添加缓存前:预热后,jmeter 测得 QPS 约为1217

添加缓存后:预热后,jmeter 测得 QPS 约为2114

此处由于服务器性能限制,QPS 始终无法上升,因为 Tomcat、Redis、RabbitMQ 都在一台服务器内,同时占用了大量 CPU 和内存。

高并发的其它优化方法

七层负载均衡

利用 Nginx 等 HTTP Server,通过反向代理多个分布式微服务,实现负载均衡,详情可见博客中的 Nginx 笔记文章。

其次,如果一台 HTTP Server 也已经无法负载这么多的请求了,可以使用 Linux Vitural Server,来横向拓展 HTTP Server,通常这个负载均衡策略在处理千万级别以上的请求才会使用到。

拓展:高效的负载均衡策略:LVS

接口地址隐藏

以秒杀系统为例:在点击秒杀按钮的时候,不直接调用秒杀接口,而是调用获取秒杀地址的接口获取一个验证码,再调用秒杀接口时再传入这个验证码作为随机路径参数,验证通过后才进行秒杀操作。

示例代码:

/* 下列方法可以使用其它方式实现,这里仅仅提供一个简单的思路和解法 */

/**
 * 获取秒杀路径的方法
 * 省略参数校验
 */
public SingleResponse<String> getFlashVerifyCode(String goodId, String userId) {
  // 生成验证码,存入 Redis
  String verifyCode = UUIDUtil.getRandomUUID();
  defaultRedisService.setNx(VerifyCodeKey.getVerifyKey(userId, goodId), 
                            verifyCode, 
                            60L, 
                            TimeUnit.SECONDS);
  // 组装路径,最后返回
  String flashPath = createFlashPath(userId, goodId, verifyCode);
  return SingleResponse.of(flashPath);
}

/**
 * 修改秒杀服务,省略其它业务代码
 */
public Response handleFlashRequest(String userId, String goodId, String verifyCode) {
  // 判断是否存在该验证码
  String code = defaultRedisService.get(VerifyCodeKey.getVerifyKey(userId, goodId));
  if (StrUtil.isEmpty(code) || !code.equal(verifyCode)) {
    return Response.buildFailure(FlashErrorCode.VERIFY_FAIL.getCode(), 
                                 FlashErrorCode.VERIFY_FAIL.getDescription());
  }
  
  // 省略其它业务代码...
}

接口限流防刷

以秒杀系统为例:隐藏了接口地址,能过滤一般的恶意请求,如果别人恶意刷参数校验的接口,也会造成一些影响,因此,增加限流防刷能有效减少恶意请求数量。

主要实现就是 Redis 的 setex 和 exists。

主要实现代码:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface AccessLimit {
  long seconds();
	long maxCount();
	boolean needLogin() default true;
}

@Component
public class AccessLimitInterceptor implements HandlerInterceptor {
  
  @Resource
  private DefaultRedisService defaultRedisService;
  
  @Resource
  private UserService userService;
  
  @Override
  public boolean preHandle(@Nonnull HttpServletRequest request,
                           @Nonnull HttpServletResponse response,
                           @Nonnull Object handler) {
    if (!(handler instanceof HandlerMethod)) {
      return true;
    }
    AccessLimit accessLimit = ((HandlerMethod) handler).getMethodAnnotation(AccessLimit.class);
    if (accessLimit == null) {
      return true;
    }
    long seconds = accessLimit.seconds();
    long maxCount = accessLimit.maxCount();
    if (accessLimit.needLogin()) {
      
    }
  }
  
}