seckill秒杀系统设计

seckill相关知识

Posted by ZBX on August 14, 2020

流程&思考

秒杀地址隐藏

数据库的乐观锁-> 上缓存-> 解决缓存雪崩—> 先记录订单再减库存会减少行级锁的等待时间

hot key出现造成集群访问量倾斜

即热点key,导致大部分的访问流量在经过proxy分片之后,都集中访问到某一个redis实例上。hot key 通常在不同业务中,存储着不同的热点信息。比如

  1. 新闻应用中的热点新闻内容;
  2. 活动系统中某个用户疯狂参与的活动的活动配置;
  3. 商城秒杀系统中,最吸引用户眼球,性价比最高的商品信息

解决方案

  1. 使用本地缓存

    • 在client端使用本地缓存,从而降低了redis集群对hotkey的访问量。但是同时带来两个问题:1、如果对可能成为 hot key 的 key 都进行本地缓存,那么本地缓存是否会过大,从而影响应用程序本身所需的缓存开销。2、如何保证本地缓存和redis集群数据的有效期的一致性。
  2. 利用分片算法的特性,对key进行打散处理

    • 给hotkey加上前缀或者后缀,把一个hotkey 的数量变成 redis 实例个数N的倍数M,从而由访问一个 redis key 变成访问 N * M 个redis key。

    • //redis 实例数
      const M = 16
           
      //redis 实例数倍数(按需设计,2^n倍,n一般为1到4的整数)
      const N = 2
           
      func main() {
      //获取 redis 实例 
          c, err := redis.Dial("tcp", "127.0.0.1:6379")
          if err != nil {
              fmt.Println("Connect to redis error", err)
              return
          }
          defer c.Close()
           
          hotKey := "hotKey:abc"
          //随机数
          randNum := GenerateRangeNum(1, N*M)
          //得到对 hot key 进行打散的 key
          tmpHotKey := hotKey + "_" + strconv.Itoa(randNum)
           
          //hot key 过期时间
          expireTime := 50
           
          //过期时间平缓化的一个时间随机值
          randExpireTime := GenerateRangeNum(0, 5)
           
          data, err := redis.String(c.Do("GET", tmpHotKey))
          if err != nil {
              data, err = redis.String(c.Do("GET", hotKey))
              if err != nil {
                  data = GetDataFromDb()
                  c.Do("SET", "hotKey", data, expireTime)
                  c.Do("SET", tmpHotKey, data, expireTime + randExpireTime)
              } else {
                  c.Do("SET", tmpHotKey, data, expireTime + randExpireTime)
              }
          }
      }
      

      在这个代码中,通过一个大于等于 1 小于 M * N 的随机数,得到一个 tmp key,程序会优先访问tmp key,在得不到数据的情况下,再访问原来的hot key,并将hot key的内容写回tmp key。tmp key的过期时间是在hot key的过期时间上加上一个随机正整数。

big key 造成集群数据量倾斜

big key即数据量大的key,由于其数据大小远大于其他key,导致经过分片之后,某个具体存储这个big key的实例内存使用量远大于其他实例,造成内存不足,拖累整个集群的使用。big key在不同业务上,通常体现为不同的数据,比如:

  1. 论坛中的大型持久盖楼活动;
  2. 聊天室系统中热门聊天室的消息列表;

解决方案

对 big key 进行拆分

对 big key 存储的数据 (big value)进行拆分,变成value1,value2… valueN,

  1. 如果big value 是个大json 通过 mset 的方式,将这个 key 的内容打散到各个实例中,减小big key 对数据量倾斜造成的影响。
  2. 如果big value 是个大list,可以拆成将list拆成。= list_1, list_2, list3, listN
  3. 其他数据类型同理。

如何发现hot key, big key

  1. 事前-预判

    • 在业务开发阶段,就要对可能变成 hot key ,big key 的数据进行判断,提前处理,这需要的是对产品业务的理解,对运营节奏的把握,对数据设计的经验。
  2. 事中-监控和自动处理

    监控

    1. 在应用程序端,对每次请求 redis 的操作进行收集上报;不推荐,但是在运维资源缺少的场景下可以考虑。开发可以绕过运维搞定);
    2. 在proxy层,对每一个 redis 请求进行收集上报;(推荐,运维来做自然是最好的方案);
    3. 对 redis 实例使用monitor命令统计热点key(不推荐,高并发条件下会有造成redis 内存爆掉的隐患);
    4. 机器层面,Redis客户端使用TCP协议与服务端进行交互,通信协议采用的是RESP。如果站在机器的角度,可以通过对机器上所有Redis端口的TCP数据包进行抓取完成热点key的统计(不推荐,公司每台机器上的基本组件已经很多了,别再添乱了);

    自动处理

    通过监控之后,程序可以获取 big key 和 hot key,再报警的同时,程序对 big key 和 hot key 进行自动处理。或者通知程序猿利用一定的工具进行定制化处理(在程序中对特定的key 执行前面提到的解决方案)

分流

静态文件服务器

在Java开发过程以及生产环境中,最常用的web应用服务器当属Tomcat,尽管这只猫也能够处理一些静态请求,例如图片、html、样式文件等,但是效率并不是那么尽人意。所以在生产环境中,我们一般使用Nginx代理服务器来处理静态文件,来提升网站性能。用作CDN

server {
        listen  80;
        server_name  file.52itstyle.com;
        charset utf-8;
        #root 指令用来指定文件在服务器上的基路径
        root /data/statics;
        #location指令用来映射请求到本地文件系统
        location / {
           autoindex on; # 索引
           autoindex_exact_size on; # 显示文件大小
           autoindex_localtime on; # 显示文件时间
        }
   }

负载均衡

Nginx 提供轮询(round robin)、IP 哈希(client IP)和加权轮询 3 种方式,默认情况下,Nginx 采用的是轮询。

轮询(默认)

每个请求按时间顺序逐一分配到不同的后端服务器,如果后端服务器down掉,能自动剔除。

upstream backserver { 
    server 192.168.1.14; 
    server 192.168.1.15; 
} 

加权轮询

指定轮询几率,weight和访问比率成正比,用于后端服务器性能不均的情况。

upstream backserver { 
    server 192.168.1.14 weight=1; 
    server 192.168.1.15 weight=2; 
} 

ip_hash

每个请求按访问ip的hash结果分配,这样每个访客固定访问一个后端服务器,可以解决session的问题。

upstream backserver { 
    ip_hash; 
    server 192.168.0.14; 
    server 192.168.0.15; 
} 

重试策略

可以为每个 backserver 指定最大的重试次数,和重试时间间隔,所使用的关键字是 max_fails 和 fail_timeout。

upstream backserver { 
	server 192.168.1.14  weight=1  max_fails=2 fail_timeout=30s; 
	server 192.168.1.15  weight=2  max_fails=2 fail_timeout=30s;
} 

失败重试次数为3,且超时时间为30秒。

热机策略

upstream backserver { 
    server 192.168.1.14  weight=1  max_fails=2 fail_timeout=30s; 
    server 192.168.1.15  weight=2  max_fails=2 fail_timeout=30s;

    server 192.168.1.16 backup;
}

fair

按后端服务器的响应时间来分配请求,响应时间短的优先分配。

url_hash

按访问的url的hash结果来分配请求,使每个url定向到同一个。

分布式锁解决Redis的并发竞争

  • 基于缓存(Redis等)实现分布式锁;
    • Redisson
    • watchdog
  • 基于Zookeeper实现分布式锁
    • 创建一个锁目录;
    • 线程A获取锁会在锁目录下创建临时顺序节点,首先先获取目录下的所有子节点,然后得到比自己小的兄弟节点,如果不存在,则说明当前序号最小,获得锁;否者监听Watcher比自己次小的节点(防止羊群效应)
    • 线程A处理完后,删除自己的节点,另外一个线程监听到变更事件,判断自己是最小的节点,获得锁。

链接加盐

URL动态化,就连写代码的人都不知道,你就通过MD5之类的加密算法加密随机的字符串去做url,然后通过前端代码获取url后台校验才能通过。

//创建path
public String createMiaoshaPath(MiaoshaUser user, long goodsId) {
		if(user == null || goodsId <=0) {
			return null;
		}
		String str = MD5Utils.md5(UUIDUtil.uuid()+"123456");
		redisService.set(MiaoshaKey.getMiaoshaPath, ""+user.getNickname() + "_"+ goodsId, str);
		return str;
	}

//验证path
boolean check = miaoshaService.checkPath(user, goodsId, path);

public boolean checkPath(MiaoshaUser user, long goodsId, String path) {
		if(user == null || path == null) {
			return false;
		}
		String pathOld = redisService.get(MiaoshaKey.getMiaoshaPath, ""+user.getNickname() + "_"+ goodsId, String.class);
		return path.equals(pathOld);
	}

分布式唯一ID

雪花算法生成唯一ID(订单编号的生成)

0 41bit-时间戳 工作机器id (10bit) 序列号 (12bit)
0 当前时间 - 开始时间 可以容纳1024个节点 在同一毫秒同一节点上从0开始不断累加

同一毫秒的ID数量 = 1024 X 4096 = 4194304,400多万个ID。

异步订单超时处理

对于用户提交订单后,没有进行支付,我们需要设置一个超时处理方法,比如:超过30分钟,订单自动取消。

@Service
public class SchedulerService {
    private static final Logger log= LoggerFactory.getLogger(SchedulerService.class);

    @Autowired
    private ItemKillSuccessMapper itemKillSuccessMapper;

    @Autowired
    private Environment env;

    //定时获取status=0的订单并判断是否超过TTL,然后进行失效
    @Scheduled(cron = "0 0/30 * * * ?")
    public void schedulerExpireOrders(){
        try {
            List<ItemKillSuccess> list=itemKillSuccessMapper.selectExpireOrders();
            if (list!=null && !list.isEmpty()){
                list.stream().forEach(i -> {
                    if (i!=null && i.getDiffTime() > env.getProperty("scheduler.expire.orders.time",Integer.class)){
                        itemKillSuccessMapper.expireOrder(i.getCode());
                    }
                });
            }
        }catch (Exception e){
            log.error("定时获取status=0的订单并判断是否超过TTL,然后进行失效-发生异常:",e.fillInStackTrace());
        }
    }
}

/**
 * 定时任务多线程处理的通用化配置
 * @Author:debug (SteadyJack)
 * @Date: 2019/6/29 21:45
 **/
@Configuration
public class SchedulerConfig implements SchedulingConfigurer{
	//针对定时任务调度-配置多线程(线程池)
    @Override
    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
        taskRegistrar.setScheduler(Executors.newScheduledThreadPool(10));
    }
}

主从同步

数据库的主从

  1. schemas.xml – Mycat对应的物理数据库和数据库表的配置

    schema 是第一个,与server.xml配置的名称相对应
    table 里表明student 表,主键是id,有两个dataNode节点,这两个节点是用来水平分片的,规则就是rule定义的ruleById.
    dataNode节点标明物理数据库和物理主机
    dataHost 设置具体的数据库
        balance=”0”, 不开启读写分离机制,所有读操作都发送到当前可用的 writeHost 上
        balance=”1”,全部的 readHost 与 stand by writeHost 参与 select 语句的负载均衡
        balance=”2”,所有读操作都随机的在 writeHost、 readhost 上分发。
        balance=”3”, 所有读请求随机的分发到 wiriterHost 对应的 readhost 执行,writerHost 不负担读压力
    writeType 设置 写入方式的,负载均衡方式可以设置
    

缓存预热

缓存、尽量不要让大量请求穿透到DB层,活动开始前商品信息可以推送至分布式缓存。实现InitializingBean接口

@Override
public void afterPropertiesSet() throws Exception {
    List<GoodsVo> goodsList = goodsService.listGoodsVo();
    if (goodsList == null) {
    	return;
    }
    for (GoodsVo goods : goodsList) {
    	redisService.set(GoodsKey.getMiaoshaGoodsStock, "" + goods.getId(), goods.getStockCount());
        localOverMap.put(goods.getId(), false);
    }
}

服务单一职责

MQ异步消费消息

发送消息

RabbitMQ监听消息,监听秒杀队列:

@RabbitListener(queues=MQConfig.MIAOSHA_QUEUE)
public void receive(String message) {
	log.info("receive message:"+message);
	MiaoshaMessage mm  = RedisService.stringToBean(message, MiaoshaMessage.class);
	MiaoshaUser user = mm.getUser();
	long goodsId = mm.getGoodsId();
	//判断库存  查数据库
	GoodsVo goods = goodsService.getGoodsVoByGoodsId(goodsId);
	int stock = goods.getStockCount();
	if(stock <= 0) {return;}
	
    //判断是否已经秒杀到了  查Redis缓存
    MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(Long.valueOf(user.getNickname()), goodsId);
	if(order != null) {return;}
	
    //减库存 下订单 写入秒杀订单
	miaoshaService.miaosha(user, goods);
}

声明式事务,开启一个事务,把减库存和下订单放在一个事务中。

@Transactional
public OrderInfo miaosha(MiaoshaUser user, GoodsVo goods) {
	//下订单  减库存  写入秒杀订单
    // 下面的逻辑有点问题
	boolean success = goodsService.reduceStock(goods);
	if(success){
		return orderService.createOrder(user,goods) ;
	}else {
		//如果库存不存在则内存标记为true
		setGoodsOver(goods.getId());
		return null;
	}
}

缓存一致性

数据库与缓存数据一致性问题

  1. 缓存必须要有过期时间
  2. 保证数据库跟缓存的最终一致性即可,不必追求强一致性

1、Cache Aside Pattern

(1)读的时候,先读缓存,缓存没有的话,那么就读数据库,然后取出数据后放入缓存,同时返回响应

(2)更新的时候,先删除缓存,然后再更新数据库

为什么?

  • 先修改数据库,再删除缓存,如果删除缓存失败了,那么会导致数据库中是新数据,缓存中是旧数据,数据出现不一致
  • 比如可能更新了某个表的一个字段,然后其对应的缓存,是需要查询另外两个表的数据,并进行运算,才能计算出缓存最新的值的。更新缓存的代价是很高的。这个缓存到底会不会被频繁访问到呢?
  • 其实删除缓存,而不是更新缓存,就是一个lazy计算的思想,不要每次都重新做复杂的计算,不管它会不会用到,而是让它到需要被使用的时候再重新计算

数据库与缓存更新与读取操作进行异步串行化

  • 更新数据的时候,根据数据的唯一标识,将操作路由之后,发送到一个jvm内部的队列中

  • 读取数据的时候,如果发现数据不在缓存中,那么将重新读取数据+更新缓存的操作,根据唯一标识路由之后,也发送同一个jvm内部的队列中,一个队列对应一个工作线程

  • 每个工作线程串行拿到对应的操作,然后一条一条的执行

  • 这样的话,一个数据变更的操作,先执行,删除缓存,然后再去更新数据库,但是还没完成更新

  • 此时如果一个读请求过来,读到了空的缓存,那么可以先将缓存更新的请求发送到队列中,此时会在队列中积压,然后同步等待缓存更新完成

高并发

防止负库存(判断库存和原子性操作)

加库存用Lua,

秒杀主流程

public ResultGeekQ<Integer> miaosha(Model model, MiaoshaUser user, @PathVariable("path") 									String path,@RequestParam("goodsId") long goodsId) {
 
    //验证path
    boolean check = miaoshaService.checkPath(user, goodsId, path);
    if (!check) {
    	result.withError(REQUEST_ILLEGAL.getCode(), REQUEST_ILLEGAL.getMessage());
        return result;
    }
    //是否已经秒杀到 , 查缓存
    MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(Long.valueOf(user.getNickname()), goodsId);
    if (order != null) {
        result.withError(REPEATE_MIAOSHA.getCode(), REPEATE_MIAOSHA.getMessage());
        return result;
    }
    
    //内存标记,减少redis访问,
    boolean over = localOverMap.get(goodsId);
    if (over) {
        result.withError(MIAO_SHA_OVER.getCode(), MIAO_SHA_OVER.getMessage());
        return result;
    }
    //预减库存
    Long stock = redisService.decr(GoodsKey.getMiaoshaGoodsStock, "" + goodsId);
    if (stock < 0) {
        localOverMap.put(goodsId, true);
        result.withError(MIAO_SHA_OVER.getCode(), MIAO_SHA_OVER.getMessage());
        return result;
    }
    // 往消息队列中发送消息
    MiaoshaMessage mm = new MiaoshaMessage();
    mm.setGoodsId(goodsId);
    mm.setUser(user);
    mqSender.sendMiaoshaMessage(mm);
    return result;
}