秒杀系统的设计

198 阅读7分钟

最近离职,总结下公司做过的秒杀系统。秒杀系统分为两类:1、预约性秒杀系统(茅台);2、非预约性秒杀(春节火车票抢购或商城的日常秒杀)。
先说下秒杀系统的特点:1、短时间大流程,2、限时、限量、限价。
秒杀系统的常用架构:

秒杀系统常需要考虑的问题,我们从下面几个角度考虑

1、稳定性,无论在什么场景下,都需要保证系统的稳定。

  • 隔离:首先针对秒杀的业务进行资源隔离,包括服务器,域名,LBS,Redis进行隔离或者数据库的隔离,保证不会因为秒杀引起其它系统的稳定性问题;
  • 监控和链路追踪:监控报警包括服务主机的监控预警、服务日志的监控预警、中间件的监控预警。让开发能实时了解机器和服务的状态。

2、高可用性

缓存:包括前端缓存,本地缓存,Redis缓存(降低IO);

异步化:主要体现在用户获取缓存之后,先写MQ,再通过MQ写数据库,降低直接对数据库的操作,提高并发量和稳定性(降低IO和CPU);

分布式:主要是把服务微服务化,可以通过docker快速扩缩容,当然微服务之后,需要进行服务治理保证服务的稳定可靠;

3、高并发

高并发就是对流量的控制;

前端流量控制

页面缓存

在前端,页面显示分为静态文件+部分后台数据,为了加快页面的反应和避免白屏现象,建议采用前端页面的缓存,针对前端页面,我们采用方式:1、从缓存中获取页面信息,如果数据存在,则页面进行渲染;2、异步请求后台数据,更新后台数据,并设置缓存5分钟左右;

后端处理

在秒杀的过程中,会先读取商品信息,然后再进行库存处理;首先为了提高并发度,商品数据和库存数据会进行存入Redis中;

商品缓存

商品缓存时会存在下面几个问题

1. 缓存穿透

2. 缓存击穿

3. 缓存雪崩

解决上面缓存问题代码

package com.newretail.account.service;

import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;

@Service
public class CacheService {

    @Autowired
    private RedisTemplate redisTemplate;

    private Cache<String, Object> dataCache = CacheBuilder.newBuilder()      // 使用 CacheBuilder 创建
            .expireAfterAccess(Duration.ofSeconds(30))  // 设置过期时间,在每次访问的半小时后过期,(再次访问则重新等待半小时)
            // .expireAfterWrite(Duration.ofHour(2))     在每次写入缓存2小时后过期
            .maximumSize(10240)     // 缓存最大数量
            .concurrencyLevel(4)    // 指定并发修改的数量,默认4
            .initialCapacity(2048)      // 设置初始化大小,避免经常扩容
            .build();       // 无参的 build 方法创建 Cache


    private Map<String, Object> locks = new ConcurrentHashMap<>();

    private AtomicBoolean casLock = new AtomicBoolean(false);

    public <T> List<T> getCache(String key, DBData<T> dbUtils, int expire) {
        List<T> data = getCacheData(key);
        if (data == null) {
            // 先获取指定key的锁,在获取前,先得加锁,保证每个key只能获取一个Key
            Object lock = null;
            //此处用cas保证只有一个线程进来,主要原因是获取key锁都是内存操作,时间短
            if (casLock.compareAndSet(false, true)) {
                lock = locks.get(key);
                if (lock == null) {
                    lock = new Object();
                    locks.put(key, lock);
                }
                casLock.set(false);
            }
            synchronized (lock) {
                //DCL检查,保证获取锁的线程直接读取Redis的数据,不要执行DB操作
                data = getCacheData(key);
                if (data == null) {
                    data = dbUtils.getDBData();
                    setCacheData(key, expire, data);
                }
            }
            // 清除内存,防止内存溢出,释放的时候,可能会有使一个key有多个锁,但是缓存中已经有了数据,不会有大面积请求请求到数据库
            locks.remove(key);
        }
        return data;
    }


    private <T> List<T> getCacheData(String key) {
        List<T> localCache = (List<T>) dataCache.getIfPresent(key);
        if (localCache != null) {
            return localCache;
        }
        List<T> redisCache = (List<T>) redisTemplate.opsForValue().get(key);
        if (redisCache != null) {
            dataCache.put(key, redisCache);
            return redisCache;
        }
        return null;
    }

    private void setCacheData(String key, int expire, List cacheData) {
        redisTemplate.opsForValue().set(key, cacheData, expire, TimeUnit.SECONDS);
        dataCache.put(key, cacheData);
    }
}


interface DBData<T> {
    List<T> getDBData();
}
4. 数据一致性

具体使用可以参考:zhuanlan.zhihu.com/p/450576104

扣减库存方式

扣减库存方式:预订单扣减和支付扣减。

预订单扣减:用户下单之后立马扣减掉库存,一般会设置支付时间,针对秒杀系统,支付时间设置的较短,建议5分钟,5分钟之后,用户未付款,一般库存会换回到商品库存中;

支付扣减:是用户在支付的时候进行扣减库存,这种方式不会涉及到库存退还的逻辑,但是用户体验不太好,在用户秒杀完之后,进行付款时,会提示用户库存不足,同时也会产生大量的待付款的订单,形成一堆无效的数据;

公司采用预订单扣减库存。

怎么保证秒杀不超卖,不少卖

主要采用Redis TCC 和 异步消息的方式,具体步骤如下


1、设置活动库存的时候,基于商品ID和活动ID+固定前缀,组成库存的Redis key。
2、在用户下单时,先生成订单id,通过Lua脚本执行下面命令 DECR 商品库存数,Redis添加一条订单+商品的锁库存数,同时把锁的时间加到value中;
3、把预订单的所有信息发送到MQ中,如果MQ返回失败,则基于锁的数量INCR库存数,并清理掉锁记录数;
4、订单服务接受MQ的消息,写入数据库,然后清理Redis中锁的数据;
5、定时任务定时扫描Redis中锁的数据,如果锁的时间超过5分钟,再判断锁的记录在订单服务中是否有记录,如果有,则清理redis锁记录,如果在订单服务不存在,则把锁的数量还回Redis中;

针对扣减库存还有下面几点可以优化:

1、增加队列模式;

2、本地缓存模式,本地缓存数据加上一定Buff,先检查本地库存是否满足,再扣减Redis中的库存数;

分布式锁

为了方式重复请求,主要有下面几步

1、前端做按钮重复点击过滤,如:1秒钟最多点击1次;

2、秒杀系统通过分布式锁做请求过滤,锁的主键为:用户ID,商品ID,活动ID,下单数量,1s钟只有一个请求可以到下游,具体做法可以使用Redission实现;

3、订单服务消费消息的Exactly-Once

数据库方案:主要是通过事务保证消息只被消费一次,help.aliyun.com/zh/apsaramq…

Redis方案:

具体实现可以参考:jaskey.github.io/blog/2020/0…

公司商城秒杀系统的实现

针对茅台秒杀的相关说明

  1. 茅台现在采用的是预约制;
  2. 茅台是有利可图;
  3. 茅台的请求很多都是爬虫脚本;

所有茅台系统的实现大致可以分为下面几个方向:

  1. 加强黑名单机制,目前最严厉的是,如果每秒收到用户10个请求,则把用户的账号拉入黑名单;
  2. 控制虚拟手机号的注册,增加创造用户的成本;
  3. 因为是预约制,即可以知道可以抢到用户数,把可中签的用户加入内存中;
  4. 数据请求加密,不定期调整加密规则;
  5. 通过限流和waf拦截重复无效的用户,

目前公司用4台4核8G的机器支持茅台业务,茅台最高QPS 20万;

怎么发现热点

1、通过Redis命令查找热点key
2、实现Redis代理,进行统计热点key。热key可参考zhuanlan.zhihu.com/p/65790148

秒杀处理方法

  • 限流:抛掉过多的无用流量,获取部分有效流量。限流分为下面几种方式:
  1. 前端限流,控制每秒客户端发送的请求数(一般控制每秒只能发送1-2个请求即可);
  2. Nginx限流、LBS限流或网关服务限流,降低到服务端的流量;
  3. 服务端限流,主要是用Sentinel(漏斗和令牌)或者其他的限流工具;
  4. Waf第三方服务;
  5. 黑名单机制;
  • 流量分发(路由):流量根据机器的负载把流量近似公平的分派到各服务;
  • 动态扩缩容:通过更多机器负载流量(需要考虑成本,最好设置最大扩容机器数)
  • 服务降级:针对所有服务设置最大超时时间,针对核心业务制定好降级方案;