开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

面试必考:秒杀系统要如何设计?

发表于 2021-07-29

前言

高并发下如何设计秒杀系统?这是一个高频面试题。这个问题看似简单,但是里面的水很深,它考查的是高并发场景下,从前端到后端多方面的知识。

秒杀一般出现在商城的促销活动中,指定了一定数量(比如:10个)的商品(比如:手机),以极低的价格(比如:0.1元),让大量用户参与活动,但只有极少数用户能够购买成功。这类活动商家绝大部分是不赚钱的,说白了是找个噱头宣传自己。

虽说秒杀只是一个促销活动,但对技术要求不低。下面给大家总结一下设计秒杀系统需要注意的9个细节。

图片

1 瞬时高并发

一般在秒杀时间点(比如:12点)前几分钟,用户并发量才真正突增,达到秒杀时间点时,并发量会达到顶峰。

但由于这类活动是大量用户抢少量商品的场景,必定会出现狼多肉少的情况,所以其实绝大部分用户秒杀会失败,只有极少部分用户能够成功。

正常情况下,大部分用户会收到商品已经抢完的提醒,收到该提醒后,他们大概率不会在那个活动页面停留了,如此一来,用户并发量又会急剧下降。所以这个峰值持续的时间其实是非常短的,这样就会出现瞬时高并发的情况,下面用一张图直观的感受一下流量的变化:

图片

像这种瞬时高并发的场景,传统的系统很难应对,我们需要设计一套全新的系统。可以从以下几个方面入手:

  1. 页面静态化
  2. CDN加速
  3. 缓存
  4. mq异步处理
  5. 限流
  6. 分布式锁
  1. 页面静态化

活动页面是用户流量的第一入口,所以是并发量最大的地方。

如果这些流量都能直接访问服务端,恐怕服务端会因为承受不住这么大的压力,而直接挂掉。

图片活动页面绝大多数内容是固定的,比如:商品名称、商品描述、图片等。为了减少不必要的服务端请求,通常情况下,会对活动页面做静态化处理。用户浏览商品等常规操作,并不会请求到服务端。只有到了秒杀时间点,并且用户主动点了秒杀按钮才允许访问服务端。

图片这样能过滤大部分无效请求。

但只做页面静态化还不够,因为用户分布在全国各地,有些人在北京,有些人在成都,有些人在深圳,地域相差很远,网速各不相同。

如何才能让用户最快访问到活动页面呢?

这就需要使用CDN,它的全称是Content Delivery Network,即内容分发网络。

图片使用户就近获取所需内容,降低网络拥塞,提高用户访问响应速度和命中率。

3 秒杀按钮

大部分用户怕错过秒杀时间点,一般会提前进入活动页面。此时看到的秒杀按钮是置灰,不可点击的。只有到了秒杀时间点那一时刻,秒杀按钮才会自动点亮,变成可点击的。

但此时很多用户已经迫不及待了,通过不停刷新页面,争取在第一时间看到秒杀按钮的点亮。

从前面得知,该活动页面是静态的。那么我们在静态页面中如何控制秒杀按钮,只在秒杀时间点时才点亮呢?

没错,使用js文件控制。

为了性能考虑,一般会将css、js和图片等静态资源文件提前缓存到CDN上,让用户能够就近访问秒杀页面。

看到这里,有些聪明的小伙伴,可能会问:CDN上的js文件是如何更新的?

秒杀开始之前,js标志为false,还有另外一个随机参数。图片)当秒杀开始的时候系统会生成一个新的js文件,此时标志为true,并且随机参数生成一个新值,然后同步给CDN。由于有了这个随机参数,CDN不会缓存数据,每次都能从CDN中获取最新的js代码。图片此外,前端还可以加一个定时器,控制比如:10秒之内,只允许发起一次请求。如果用户点击了一次秒杀按钮,则在10秒之内置灰,不允许再次点击,等到过了时间限制,又允许重新点击该按钮。

4 读多写少

在秒杀的过程中,系统一般会先查一下库存是否足够,如果足够才允许下单,写数据库。如果不够,则直接返回该商品已经抢完。

由于大量用户抢少量商品,只有极少部分用户能够抢成功,所以绝大部分用户在秒杀时,库存其实是不足的,系统会直接返回该商品已经抢完。

这是非常典型的:读多写少 的场景。

图片如果有数十万的请求过来,同时通过数据库查缓存是否足够,此时数据库可能会挂掉。因为数据库的连接资源非常有限,比如:mysql,无法同时支持这么多的连接。

而应该改用缓存,比如:redis。

即便用了redis,也需要部署多个节点。图片

最近我建了新的技术交流群,打算将它打造成高质量的活跃群,欢迎小伙伴们加入。

我以往的技术群里技术氛围非常不错,大佬很多。

image.png

加微信:su_san_java,备注:加群,即可加入该群。

5 缓存问题

通常情况下,我们需要在redis中保存商品信息,里面包含:商品id、商品名称、规格属性、库存等信息,同时数据库中也要有相关信息,毕竟缓存并不完全可靠。

用户在点击秒杀按钮,请求秒杀接口的过程中,需要传入的商品id参数,然后服务端需要校验该商品是否合法。

大致流程如下图所示:

图片

根据商品id,先从缓存中查询商品,如果商品存在,则参与秒杀。如果不存在,则需要从数据库中查询商品,如果存在,则将商品信息放入缓存,然后参与秒杀。如果商品不存在,则直接提示失败。

这个过程表面上看起来是OK的,但是如果深入分析一下会发现一些问题。

5.1 缓存击穿

比如商品A第一次秒杀时,缓存中是没有数据的,但数据库中有。虽说上面有如果从数据库中查到数据,则放入缓存的逻辑。

然而,在高并发下,同一时刻会有大量的请求,都在秒杀同一件商品,这些请求同时去查缓存中没有数据,然后又同时访问数据库。结果悲剧了,数据库可能扛不住压力,直接挂掉。

如何解决这个问题呢?

这就需要加锁,最好使用分布式锁。

图片当然,针对这种情况,最好在项目启动之前,先把缓存进行预热。即事先把所有的商品,同步到缓存中,这样商品基本都能直接从缓存中获取到,就不会出现缓存击穿的问题了。

是不是上面加锁这一步可以不需要了?

表面上看起来,确实可以不需要。但如果缓存中设置的过期时间不对,缓存提前过期了,或者缓存被不小心删除了,如果不加速同样可能出现缓存击穿。

其实这里加锁,相当于买了一份保险。

5.2 缓存穿透

如果有大量的请求传入的商品id,在缓存中和数据库中都不存在,这些请求不就每次都会穿透过缓存,而直接访问数据库了。

由于前面已经加了锁,所以即使这里的并发量很大,也不会导致数据库直接挂掉。

但很显然这些请求的处理性能并不好,有没有更好的解决方案?

这时可以想到布隆过滤器。

图片系统根据商品id,先从布隆过滤器中查询该id是否存在,如果存在则允许从缓存中查询数据,如果不存在,则直接返回失败。

虽说该方案可以解决缓存穿透问题,但是又会引出另外一个问题:布隆过滤器中的数据如何更缓存中的数据保持一致?

这就要求,如果缓存中数据有更新,则要及时同步到布隆过滤器中。如果数据同步失败了,还需要增加重试机制,而且跨数据源,能保证数据的实时一致性吗?

显然是不行的。

所以布隆过滤器绝大部分使用在缓存数据更新很少的场景中。

如果缓存数据更新非常频繁,又该如何处理呢?

这时,就需要把不存在的商品id也缓存起来。

图片下次,再有该商品id的请求过来,则也能从缓存中查到数据,只不过该数据比较特殊,表示商品不存在。需要特别注意的是,这种特殊缓存设置的超时时间应该尽量短一点。

6 库存问题

对于库存问题看似简单,实则里面还是有些东西。

真正的秒杀商品的场景,不是说扣完库存,就完事了,如果用户在一段时间内,还没完成支付,扣减的库存是要加回去的。

所以,在这里引出了一个预扣库存的概念,预扣库存的主要流程如下:

图片

扣减库存中除了上面说到的预扣库存和回退库存之外,还需要特别注意的是库存不足和库存超卖问题。

6.1 数据库扣减库存

使用数据库扣减库存,是最简单的实现方案了,假设扣减库存的sql如下:

1
bash复制代码update product set stock=stock-1 where id=123;

这种写法对于扣减库存是没有问题的,但如何控制库存不足的情况下,不让用户操作呢?

这就需要在update之前,先查一下库存是否足够了。

伪代码如下:

1
2
3
4
5
6
7
scss复制代码int stock = mapper.getStockById(123);
if(stock > 0) {
  int count = mapper.updateStock(123);
  if(count > 0) {
    addOrder(123);
  }
}

大家有没有发现这段代码的问题?

没错,查询操作和更新操作不是原子性的,会导致在并发的场景下,出现库存超卖的情况。

有人可能会说,这样好办,加把锁,不就搞定了,比如使用synchronized关键字。

确实,可以,但是性能不够好。

还有更优雅的处理方案,即基于数据库的乐观锁,这样会少一次数据库查询,而且能够天然的保证数据操作的原子性。

只需将上面的sql稍微调整一下:

1
bash复制代码update product set stock=stock-1 where id=product and stock > 0;

在sql最后加上:stock > 0,就能保证不会出现超卖的情况。

但需要频繁访问数据库,我们都知道数据库连接是非常昂贵的资源。在高并发的场景下,可能会造成系统雪崩。而且,容易出现多个请求,同时竞争行锁的情况,造成相互等待,从而出现死锁的问题。

6.2 redis扣减库存

redis的incr方法是原子性的,可以用该方法扣减库存。伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
ini复制代码 boolean exist = redisClient.query(productId,userId);
  if(exist) {
    return -1;
  }
  int stock = redisClient.queryStock(productId);
  if(stock <=0) {
    return 0;
  }
  redisClient.incrby(productId, -1);
  redisClient.add(productId,userId);
return 1;

代码流程如下:

  1. 先判断该用户有没有秒杀过该商品,如果已经秒杀过,则直接返回-1。
  2. 查询库存,如果库存小于等于0,则直接返回0,表示库存不足。
  3. 如果库存充足,则扣减库存,然后将本次秒杀记录保存起来。然后返回1,表示成功。

估计很多小伙伴,一开始都会按这样的思路写代码。但如果仔细想想会发现,这段代码有问题。

有什么问题呢?

如果在高并发下,有多个请求同时查询库存,当时都大于0。由于查询库存和更新库存非原则操作,则会出现库存为负数的情况,即库存超卖。

当然有人可能会说,加个synchronized不就解决问题?

调整后代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ini复制代码   boolean exist = redisClient.query(productId,userId);
   if(exist) {
    return -1;
   }
   synchronized(this) {
       int stock = redisClient.queryStock(productId);
       if(stock <=0) {
         return 0;
       }
       redisClient.incrby(productId, -1);
       redisClient.add(productId,userId);
   }

return 1;

加synchronized确实能解决库存为负数问题,但是这样会导致接口性能急剧下降,每次查询都需要竞争同一把锁,显然不太合理。

为了解决上面的问题,代码优化如下:

1
2
3
4
5
6
7
8
9
ini复制代码boolean exist = redisClient.query(productId,userId);
if(exist) {
  return -1;
}
if(redisClient.incrby(productId, -1)<0) {
  return 0;
}
redisClient.add(productId,userId);
return 1;

该代码主要流程如下:

  1. 先判断该用户有没有秒杀过该商品,如果已经秒杀过,则直接返回-1。
  2. 扣减库存,判断返回值是否小于0,如果小于0,则直接返回0,表示库存不足。
  3. 如果扣减库存后,返回值大于或等于0,则将本次秒杀记录保存起来。然后返回1,表示成功。

该方案咋一看,好像没问题。

但如果在高并发场景中,有多个请求同时扣减库存,大多数请求的incrby操作之后,结果都会小于0。

虽说,库存出现负数,不会出现超卖的问题。但由于这里是预减库存,如果负数值负的太多的话,后面万一要回退库存时,就会导致库存不准。

那么,有没有更好的方案呢?

6.3 lua脚本扣减库存

我们都知道lua脚本,是能够保证原子性的,它跟redis一起配合使用,能够完美解决上面的问题。

lua脚本有段非常经典的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
ini复制代码  StringBuilder lua = new StringBuilder();
  lua.append("if (redis.call('exists', KEYS[1]) == 1) then");
  lua.append("    local stock = tonumber(redis.call('get', KEYS[1]));");
  lua.append("    if (stock == -1) then");
  lua.append("        return 1;");
  lua.append("    end;");
  lua.append("    if (stock > 0) then");
  lua.append("        redis.call('incrby', KEYS[1], -1);");
  lua.append("        return stock;");
  lua.append("    end;");
  lua.append("    return 0;");
  lua.append("end;");
  lua.append("return -1;");

该代码的主要流程如下:

  1. 先判断商品id是否存在,如果不存在则直接返回。
  2. 获取该商品id的库存,判断库存如果是-1,则直接返回,表示不限制库存。
  3. 如果库存大于0,则扣减库存。
  4. 如果库存等于0,是直接返回,表示库存不足。

最近我建了新的技术交流群,打算将它打造成高质量的活跃群,欢迎小伙伴们加入。

我以往的技术群里技术氛围非常不错,大佬很多。

image.png

加微信:su_san_java,备注:加群,即可加入该群。

7 分布式锁

之前我提到过,在秒杀的时候,需要先从缓存中查商品是否存在,如果不存在,则会从数据库中查商品。如果数据库中,则将该商品放入缓存中,然后返回。如果数据库中没有,则直接返回失败。

大家试想一下,如果在高并发下,有大量的请求都去查一个缓存中不存在的商品,这些请求都会直接打到数据库。数据库由于承受不住压力,而直接挂掉。

那么如何解决这个问题呢?

这就需要用redis分布式锁了。

7.1 setNx加锁

使用redis的分布式锁,首先想到的是setNx命令。

1
2
3
scss复制代码if (jedis.setnx(lockKey, val) == 1) {
   jedis.expire(lockKey, timeout);
}

用该命令其实可以加锁,但和后面的设置超时时间是分开的,并非原子操作。

假如加锁成功了,但是设置超时时间失败了,该lockKey就变成永不失效的了。在高并发场景中,该问题会导致非常严重的后果。

那么,有没有保证原子性的加锁命令呢?

7.2 set加锁

使用redis的set命令,它可以指定多个参数。

1
2
3
4
5
kotlin复制代码String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
    return true;
}
return false;

其中:

  • lockKey:锁的标识
  • requestId:请求id
  • NX:只在键不存在时,才对键进行设置操作。
  • PX:设置键的过期时间为 millisecond 毫秒。
  • expireTime:过期时间

由于该命令只有一步,所以它是原子操作。

7.3 释放锁

接下来,有些朋友可能会问:在加锁时,既然已经有了lockKey锁标识,为什么要需要记录requestId呢?

答:requestId是在释放锁的时候用的。

1
2
3
4
5
kotlin复制代码if (jedis.get(lockKey).equals(requestId)) {
    jedis.del(lockKey);
    return true;
}
return false;

在释放锁的时候,只能释放自己加的锁,不允许释放别人加的锁。

这里为什么要用requestId,用userId不行吗?

答:如果用userId的话,假设本次请求流程走完了,准备删除锁。此时,巧合锁到了过期时间失效了。而另外一个请求,巧合使用的相同userId加锁,会成功。而本次请求删除锁的时候,删除的其实是别人的锁了。

当然使用lua脚本也能避免该问题:

1
2
3
4
5
vbnet复制代码if redis.call('get', KEYS[1]) == ARGV[1] then 
 return redis.call('del', KEYS[1]) 
else 
  return 0 
end

它能保证查询锁是否存在和删除锁是原子操作。

7.4 自旋锁

上面的加锁方法看起来好像没有问题,但如果你仔细想想,如果有1万的请求同时去竞争那把锁,可能只有一个请求是成功的,其余的9999个请求都会失败。

在秒杀场景下,会有什么问题?

答:每1万个请求,有1个成功。再1万个请求,有1个成功。如此下去,直到库存不足。这就变成均匀分布的秒杀了,跟我们想象中的不一样。

如何解决这个问题呢?

答:使用自旋锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
kotlin复制代码try {
  Long start = System.currentTimeMillis();
  while(true) {
      String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
     if ("OK".equals(result)) {
        return true;
     }
     
     long time = System.currentTimeMillis() - start;
      if (time>=timeout) {
          return false;
      }
      try {
          Thread.sleep(50);
      } catch (InterruptedException e) {
          e.printStackTrace();
      }
  }
 
} finally{
    unlock(lockKey,requestId);
}  
return false;

在规定的时间,比如500毫秒内,自旋不断尝试加锁,如果成功则直接返回。如果失败,则休眠50毫秒,再发起新一轮的尝试。如果到了超时时间,还未加锁成功,则直接返回失败。

7.5 redisson

除了上面的问题之外,使用redis分布式锁,还有锁竞争问题、续期问题、锁重入问题、多个redis实例加锁问题等。

这些问题使用redisson可以解决,由于篇幅的原因,在这里先保留一点悬念,有疑问的私聊给我。后面会出一个专题介绍分布式锁,敬请期待。

8 mq异步处理

我们都知道在真实的秒杀场景中,有三个核心流程:图片而这三个核心流程中,真正并发量大的是秒杀功能,下单和支付功能实际并发量很小。所以,我们在设计秒杀系统时,有必要把下单和支付功能从秒杀的主流程中拆分出来,特别是下单功能要做成mq异步处理的。而支付功能,比如支付宝支付,是业务场景本身保证的异步。

于是,秒杀后下单的流程变成如下:图片如果使用mq,需要关注以下几个问题:

8.1 消息丢失问题

秒杀成功了,往mq发送下单消息的时候,有可能会失败。原因有很多,比如:网络问题、broker挂了、mq服务端磁盘问题等。这些情况,都可能会造成消息丢失。

那么,如何防止消息丢失呢?

答:加一张消息发送表。

图片在生产者发送mq消息之前,先把该条消息写入消息发送表,初始状态是待处理,然后再发送mq消息。消费者消费消息时,处理完业务逻辑之后,再回调生产者的一个接口,修改消息状态为已处理。

如果生产者把消息写入消息发送表之后,再发送mq消息到mq服务端的过程中失败了,造成了消息丢失。

这时候,要如何处理呢?

答:使用job,增加重试机制。

图片用job每隔一段时间去查询消息发送表中状态为待处理的数据,然后重新发送mq消息。

8.2 重复消费问题

本来消费者消费消息时,在ack应答的时候,如果网络超时,本身就可能会消费重复的消息。但由于消息发送者增加了重试机制,会导致消费者重复消息的概率增大。

那么,如何解决重复消息问题呢?

答:加一张消息处理表。图片

消费者读到消息之后,先判断一下消息处理表,是否存在该消息,如果存在,表示是重复消费,则直接返回。如果不存在,则进行下单操作,接着将该消息写入消息处理表中,再返回。

有个比较关键的点是:下单和写消息处理表,要放在同一个事务中,保证原子操作。

8.3 垃圾消息问题

这套方案表面上看起来没有问题,但如果出现了消息消费失败的情况。比如:由于某些原因,消息消费者下单一直失败,一直不能回调状态变更接口,这样job会不停的重试发消息。最后,会产生大量的垃圾消息。

那么,如何解决这个问题呢?图片每次在job重试时,需要先判断一下消息发送表中该消息的发送次数是否达到最大限制,如果达到了,则直接返回。如果没有达到,则将次数加1,然后发送消息。

这样如果出现异常,只会产生少量的垃圾消息,不会影响到正常的业务。

8.4 延迟消费问题

通常情况下,如果用户秒杀成功了,下单之后,在15分钟之内还未完成支付的话,该订单会被自动取消,回退库存。

那么,在15分钟内未完成支付,订单被自动取消的功能,要如何实现呢?

我们首先想到的可能是job,因为它比较简单。

但job有个问题,需要每隔一段时间处理一次,实时性不太好。

还有更好的方案?

答:使用延迟队列。

我们都知道rocketmq,自带了延迟队列的功能。

图片下单时消息生产者会先生成订单,此时状态为待支付,然后会向延迟队列中发一条消息。达到了延迟时间,消息消费者读取消息之后,会查询该订单的状态是否为待支付。如果是待支付状态,则会更新订单状态为取消状态。如果不是待支付状态,说明该订单已经支付过了,则直接返回。

还有个关键点,用户完成支付之后,会修改订单状态为已支付。

图片

最近我建了新的技术交流群,打算将它打造成高质量的活跃群,欢迎小伙伴们加入。

我以往的技术群里技术氛围非常不错,大佬很多。

image.png

加微信:su_san_java,备注:加群,即可加入该群。

9 如何限流?

通过秒杀活动,如果我们运气爆棚,可能会用非常低的价格买到不错的商品(这种概率堪比买福利彩票中大奖)。

但有些高手,并不会像我们一样老老实实,通过秒杀页面点击秒杀按钮,抢购商品。他们可能在自己的服务器上,模拟正常用户登录系统,跳过秒杀页面,直接调用秒杀接口。

如果是我们手动操作,一般情况下,一秒钟只能点击一次秒杀按钮。图片)但是如果是服务器,一秒钟可以请求成上千接口。图片这种差距实在太明显了,如果不做任何限制,绝大部分商品可能是被机器抢到,而非正常的用户,有点不太公平。

所以,我们有必要识别这些非法请求,做一些限制。那么,我们该如何现在这些非法请求呢?

目前有两种常用的限流方式:

  1. 基于nginx限流
  2. 基于redis限流

9.1 对同一用户限流

为了防止某个用户,请求接口次数过于频繁,可以只针对该用户做限制。图片限制同一个用户id,比如每分钟只能请求5次接口。

9.2 对同一ip限流

有时候只对某个用户限流是不够的,有些高手可以模拟多个用户请求,这种nginx就没法识别了。

这时需要加同一ip限流功能。图片限制同一个ip,比如每分钟只能请求5次接口。

但这种限流方式可能会有误杀的情况,比如同一个公司或网吧的出口ip是相同的,如果里面有多个正常用户同时发起请求,有些用户可能会被限制住。

9.3 对接口限流

别以为限制了用户和ip就万事大吉,有些高手甚至可以使用代理,每次都请求都换一个ip。

这时可以限制请求的接口总次数。图片在高并发场景下,这种限制对于系统的稳定性是非常有必要的。但可能由于有些非法请求次数太多,达到了该接口的请求上限,而影响其他的正常用户访问该接口。看起来有点得不偿失。

9.4 加验证码

相对于上面三种方式,加验证码的方式可能更精准一些,同样能限制用户的访问频次,但好处是不会存在误杀的情况。

图片通常情况下,用户在请求之前,需要先输入验证码。用户发起请求之后,服务端会去校验该验证码是否正确。只有正确才允许进行下一步操作,否则直接返回,并且提示验证码错误。

此外,验证码一般是一次性的,同一个验证码只允许使用一次,不允许重复使用。

普通验证码,由于生成的数字或者图案比较简单,可能会被破解。优点是生成速度比较快,缺点是有安全隐患。

还有一个验证码叫做:移动滑块,它生成速度比较慢,但比较安全,是目前各大互联网公司的首选。

9.5 提高业务门槛

上面说的加验证码虽然可以限制非法用户请求,但是有些影响用户体验。用户点击秒杀按钮前,还要先输入验证码,流程显得有点繁琐,秒杀功能的流程不是应该越简单越好吗?

其实,有时候达到某个目的,不一定非要通过技术手段,通过业务手段也一样。

12306刚开始的时候,全国人民都在同一时刻抢火车票,由于并发量太大,系统经常挂。后来,重构优化之后,将购买周期放长了,可以提前20天购买火车票,并且可以在9点、10、11点、12点等整点购买火车票。调整业务之后(当然技术也有很多调整),将之前集中的请求,分散开了,一下子降低了用户并发量。

回到这里,我们通过提高业务门槛,比如只有会员才能参与秒杀活动,普通注册用户没有权限。或者,只有等级到达3级以上的普通用户,才有资格参加该活动。

这样简单的提高一点门槛,即使是黄牛党也束手无策,他们总不可能为了参加一次秒杀活动,还另外花钱充值会员吧?

最后说一句(求关注,别白嫖我)

如果这篇文章对您有所帮助,或者有所启发的话,帮忙扫描下发二维码关注一下,您的支持是我坚持写作最大的动力。

求一键三连:点赞、转发、在看。

关注公众号:【苏三说技术】,在公众号中回复:面试、代码神器、开发手册、时间管理有超赞的粉丝福利,另外回复:加群,可以跟很多BAT大厂的前辈交流和学习。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Nginx的X-Frame-Options头设置

发表于 2021-07-29

今天突然发现自己的iMydl和Ymanz.com两个站点都无法Frame内调用,特别是在iMydl里因为使用的是鸟哥的 Begin 主题,主题自带的下载模块里的“演示链接”竟然因此无法使用了。同样的Ymanz.com也不能使用百度统计的“页面点击图”,很明显这不科学。经过排查后发现是Nginx里X-Frame-Options头设置都懒省事儿设定为“DENY”造成的,于是就简单了解了一下这个X-Frame-Options头,特作笔记如下:

[6f24162ec93644626f45dddea8a2998f.jpg

X-Frame-Options HTTP 响应头是用来给浏览器 指示允许一个页面 可否在 <frame>, <iframe>, <embed>或者 <object>中展现的标记。站点可以通过确保网站没有被嵌入到别人的站点里面,从而避免 ClickJacking 攻击。

X-Frame-Options. Content-Security-Policy HTTP 头中的 frame-ancestors 指令会替代这个非标准的 header。CSP 的 frame-ancestors 会在 Gecko 4.0 中支持,但是并不会被所有浏览器支持。然而 X-Frame-Options 是个已广泛支持的非官方标准,可以和 CSP 结合使用。

[36032399-812e200c-0d73-11e8-9981-d036a9378d6d.png

X-Frame-Options 有三个可能的值:

  • X-Frame-Options: deny
  • X-Frame-Options: sameorigin
  • X-Frame-Options "Allow-From domain.com"

换一句话说,如果设置为 deny,不光在别人的网站 frame 嵌入时会无法加载,在同域名页面中同样会无法加载。另一方面,如果设置为sameorigin,那么页面就可以在同域名页面的 frame 中嵌套。

  1. deny

表示该页面不允许在 frame 中展示,即便是在相同域名的页面中嵌套也不允许。

  1. sameorigin

表示该页面可以在相同域名页面的 frame 中展示。

  1. allow-from uri

表示该页面可以在指定来源的 frame 中展示。

配置 Nginx 发送 X-Frame-Options 响应头,把下面这行添加到 ‘http’, ‘server’ 或者 ‘location’ 的配置中:

add_header X-Frame-Options sameorigin;

或者:

add_header X-Frame-Options deny;

又或者像明月的配置一样,指定允许某个域名或者多个域名:

add_header X-Frame-Options "Allow-From domain1.com";
add_header X-Frame-Options "Allow-From domain2.com";
add_header X-Frame-Options "Allow-From domain3.com";

更多写法可参考如下:

1
2
3
4
5
6
sql复制代码  add_header X-Frame-Options "Allow-From domain.com"; 
add_header X-Frame-Options "ALLOW-FROM domain.com";
add_header X-Frame-Options "ALLOW-FROM: domain.com";
add_header X-Frame-Options "Allow-From: domain.com";
add_header X-Frame-Options ALLOW-FROM "domain.com";
add_header X-Frame-Options ALLOW-FROM domain.com;

另外CSP写法可参考:

add_header Content-Security-Policy "frame-ancestors https://www.domain.com/";

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

OA系统经验总结 前言 目的 规范流程 提高效率 致谢

发表于 2021-07-29

前言

很早就接触过 OA 系统, 但真正做 OA 系统, 是从2019年12月开始, 在这里, 记录一下个人对 OA 系统的总结和经验, 也欢迎大家交流, 提出宝贵的意见.

OA 系统是公司不可或缺的一种系统, OA 系统主要目的是为了规范流程, 提高效率, 减少不必要的线下作业. 严格来讲, OA 应叫 BPM, 管控企业流程.

目的

  • 规范流程
  • 提高效率, 包括提高签核效率; 衔接三方系统( 如ERP), 减少人工重复作业;

流程的规范是通过系统来推动既定的流程, 通过系统保障流程的规范, 可以避免流程因人主观的因素而遭到破坏

效率一部分是通过签核单无纸化来实现, 提单的人不再需要拿着纸质单挨个去找签核人来进行签字. 因为已经有了线上的系统, 可以不再需要纸本化的签核, 当有需要签核的单, 系统可以通过邮件等方式通知签核人; 在出差等场景时, 则可以通过手机来签核. 效率另一部分是通过与三方系统的无缝衔接来实现.当在 bpm 系统上将资料导入后, 签核完毕后自动将资料导入到相应的系统, 如 ERP 等, 不需要两个系统一起操作.

规范流程

签核

最常见的签核方式: 串签, 会签, 知会, 认领, 指派, 作业

串签

简单理解就是签核人有顺序 比如请假单, 上级主管站点签核人包括 上级主管(1级), 上级主管(2级), 上级主管(3级)

会签

简单理解就是签核人没有顺序, 谁先签谁后签无所谓, 不重要

会签可以细分, 多少人签了后这个站点就算签完了, 常用的是只要一个人签了, 或者所有人都签了, 这个站点就算签完了

知会

简单点理解就是告诉签核人有这个事情, 被知会的人不能参与流程, 包括不能退签不能修改什么都不能做

如果表单内容很简单的话, 知会可以直接通过发邮件等方式通知即可; 当表单内容比较多时, 发邮件等通知方式可能不能很好地表达需要知会的内容, 这种情况下可以通过让知会人员进入签核页面, 点击一个 [已知会] 的按钮

作业

当表单流程签核完毕后, 通常会进入作业的阶段, 特别是财务有关的表单, 比如报销单, 需要财务作业打款

作业之前, 需要明确由谁来作业.通常会有三种方式:

  1. 系统指派: 制定流程规则, 基于一定的规则来指派作业人员 (注意, 作业人员也可能多个)
  2. 特定主管指派: 制定流程规则, 但规则只指定某个人来指派作业, 通常是作业主管, 由作业主管来指定作业的人
  3. 认领: 制定流程规则, 规则指定哪些人可以认领, 只能由一个人认领

更多的是使用系统指派和特定主管指派这两种结合的方式, 有明确的规则时使用系统指派, 规则复杂或者不明确的使用 特定主管指派

作业的人员理论上只有 [作业完毕] 的按钮选项, 不会有退单的操作.但是, 实践中发现并没有那么理想, 一方面是签核过程中主管不会在意那么多细节, 签完了到了作业层面才发现有问题; 另一方面, 有些资料在提单的时候没有拿到, 希望在作业前补资料.所以很多作业的时候, 会希望能够给提单人(甚至不是提单人)补齐或者修改资料的机会, 当然哪些资料可以修改需要, 哪些资料不可以修改, 流程制定的时候明确下来(以及技术层面上需要记录是谁在什么时候修改过资料的日志, 以便查验). 作业人员委派给补资料的人员, 告知他资料的问题, 补资料的人员完成以后, 又回到作业人员手上, 继续作业.

委派: A 委派给 B, B 完成以后又回到 A 身上

小结一下: 作业指派 ==> 作业 ==> 委派 ==> 继续作业

退签

退签的难点在于应该退到哪里

  • 由用户选择退到哪里: 用户没办法判断退到哪里
  • 流程制定的时候, 指定每个站点的退签规则: 可以有不同的退签理由退到不同的站点等规则
  • 直接退回给提单人

转单

转单是签核人发现该单的签核人不是自己, 应该是其他人, 把表单主动给他签

转单需要被转单人的同意, 被转单人有权利拒绝被转单

抽单

抽单是指提单人提单以后, 已经在签核流程中, 提单人后悔了, 把表单撤销的操作

有些人认为提单人可以反悔 可以抽单, 那签核人也应可以反悔, 抽单. 个人不建议给签核人反悔的机制, 否则会变得混乱

跳签

同一个流程中, 一个人可能同时出现在不同站点中, 这会导致同一个人签核多次, 为了避免这种情况, 使他只签一次即可, 于是第一次签了以后, 后面就无需再次签核

有种观点认为, 假设签核过程中, 有修改到比较重要的资料, 做了跳签就不合适. 个人的观点是这样子: 如果是签核中可以修改重要的资料, 流程的设计需要更严谨, 再做好记录好谁在什么时候改过什么资料的日志, 而且大多数的员工都不会主动做不利于公司的事, 所以跳签是可以接受的

代理

当某个主管请长假时, 希望将涉及到的表单的签核权限都给(或者部分) 给不同的人来代替签核, 这就是代理

代理建议和请假单一起, 请假的时候指定代理人. 当然除了请假以外, 也可以自己手动设置代理人及代理时间

再严谨一点的代理会指定哪些模块代理给哪些人, 但从实践上看, 指定模块几乎不会有人去用

人员组织架构

签核人员和组织架构密切相关, 通常都是指定某个部门的主管来签核, 而不会指定特定的人来签核, 当组织变动(比如主管换人了), 流程可以保持不变

人员离职

人员离职后需要处理把挂在离职人员身上的表单转单

签核过程中变更表单内容

签核过程中可以对表单内容进行变更

甚至有些会要求, 明细项只能相应的负责人才可以变更(或者相应的负责人才能看到, 这其实是数据权限的问题, 不止 BPM 系统遇到过这种问题)

通知

通知包括 签核通知, 退签通知, 表单完结通知, 转单通知, 抽单通知

通知需要做站外通知, 比如邮件, 钉钉通知, 企业微信通知, 公众号通知等等, 站内通知反而可以不用做

实践表明, 不管站内通知做的多好多及时, 依然没有多少人会在意站内通知, 可以干脆不做

站外通知需要做到点链接, 能直达需要签核的表单页面(题外话, jwt 就很适合这种链接)

站外通知不必表明来源于 BPM 系统, 没有用户在意到底是来自 BPM 还是 ERP, 甚至用户都分不清 BPM 和 ERP

提高效率

移动端

有些主管会经常出差, 这个时候就很需要便捷的签核渠道, 移动端就是便捷的渠道 APP 成本高, 还需要做 IOS / 安卓端, 成本高昂, 可以直接使用钉钉/企业微信/微信公众号/小程序客户端等 站外通知发送到客户端, 由客户端唤起移动化的 BPM 网页, 进行签核

衔接第三方系统

将签核后的数据通过 API 等各种方式抛到 ERP 等, 省去重复作业, 也避免重复作业出错

跳签

跳签可以加快签核速度

批量签核

不建议做这个功能, 否则就会有人看都不看表单的内容, 直接全部签过

长时间不签核

技术上可以有一些手段, 比如 催签, 加急, 超过3天还没签核的每天发站外通知等等, 但并不能根本解决问题, 拖着不签的人, BPM 是没有办法的, 只能从制度上去解决

页面重点突出关注的内容

流程中每个站点重点关注的内容其实是不一样的, 而表单上一个页面上会汇集所有表单资料, 内容非常多, 签核人员签核的时候会有一种茫然的感觉, 会去努力回想自己关注的内容, 然后再去页面上找这些散落着的内容, 影响签核人签核的效率.可以考虑每个站点都有自己单独的页面, 突出重点关注的内容(当然也都会有显示出所有资料的页面)

致谢

刚开始接触 BPM 需求和开发都是比较蒙圈的状态, 非常感谢杨胜富先生带领我入门, 指导我谈需求和开发, 并给我非常细致的照顾, 再次表示感谢!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

我去!爬虫遇到JS逆向AES加密反爬,哭了|Python 主

发表于 2021-07-29

本文正在参加「Python主题月」,详情查看 活动链接

今天准备爬取网页时,遇到『JS逆向AES加密』反爬。比如这样的:

在发送请求获取数据时,需要用到参数params和encSecKey,但是这两个参数经过JS逆向AES加密而来。

既然遇到了这个情况,那么辰哥就教大家如何去解决这类反爬(JS逆向AES加密)

01 网页分析

在开始分析JS逆向AES加密之前,先简单介绍一下要爬取的内容:下载某抑云音乐。其中获取歌曲的真实播放地址m4a的过程涉及到JS逆向AES加密。

点击播放,在浏览器中查看抓取到的数据包,如下图所示:

查看响应数据:

可以看到在url字段中存储着真实播放地址,放到浏览器中打开:

可以看到正常播放,说明歌曲的真实播放地址获取正确。

唯一变的就是data,data里面包含两个参数(params和encSecKey),根据辰哥的经验,这八九不离十是经过JS加密而来,并且肯定跟歌曲的地址有关(浏览器页面地址,非真实播放地址)

02 JS逆向过程

既然知道这两个参数是js逆向加密而来,那直接搜索这两个参数存在于哪个js文件中。

搜索到了5个js,那么就查看这两个参数都同时存在于哪个js中,刚好在第一个js中就看到了。

可以看到params对应的是encText,encSecKey对应的是encSecKey。encText和encSecKey来自于bUE3x,而bUE3x来自于window.asrsea。

1
less复制代码var bUE3x = window.asrsea(JSON.stringify(i3x), bsf6Z(["流泪", "强"]), bsf6Z(WS0x.md), bsf6Z(["爱心", "女孩", "惊恐",并以某抑云"大笑"]));

继续搜索window.asrsea

可以看到window.asrsea来源于d,d是一个函数,该函数中返回的h赋值给window.asrsea。这里我们给函数d打断点。

点击刷新网页,重新播放

可以看到函数d需要传入四个参数,通过分析多首歌曲,分析参数e、f、g没变化,唯一变是参数d中的id。

这个id刚好是歌曲的id

music.163.com/#/song?id=4…

函数d接收到四个参数后,创建一个字典h(用于存放变量),接着调用函数a,我们继续给函数a打断点。

刷新网页

函数a的作用就是生成一个16为的随机数,下面是函数a运行后最终的参数值,其中c是返回值,因此我们可以认为c是一个固定的值(反正也是随机生成的)

1
2
3
vbnet复制代码a: 16
b: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
c: "z2Ggtvz5ZIsiKO5F"

函数a解析完了,继续分析函数d。

1
2
3
4
5
6
7
8
ini复制代码function d(d, e, f, g) {
var h = {}
, i = a(16);
return h.encText = b(d, g),
h.encText = b(h.encText, i),
h.encSecKey = c(i, e, f),
h
}

接着经过两次AES加密(执行了两次函数b)

1
2
3
4
5
6
7
8
9
10
ini复制代码function b(a, b) {
var c = CryptoJS.enc.Utf8.parse(b)
, d = CryptoJS.enc.Utf8.parse("0102030405060708")
, e = CryptoJS.enc.Utf8.parse(a)
, f = CryptoJS.AES.encrypt(e, c, {
iv: d,
mode: CryptoJS.mode.CBC
});
return f.toString()
}

需要传入参数a和b,实际上就是函数d中的参数d和g,参数g是固定的,参数d我们刚刚已经分析过了。

一开始分析的两个js逆向参数(params和encSecKey)的parmas我们已经清楚了加密过程(encText就是params)。

接着函数d继续看

1
ini复制代码h.encSecKey = c(i, e, f),

encSecKey是通过函数c得到,函数c的代码如下:

1
2
3
4
5
6
scss复制代码function c(a, b, c) {
var d, e;
return setMaxDigits(131), //131 => n的十六进制位数/2+3
d = new RSAKeyPair(b,"",c),
e = encryptedString(d, a)
}

函数c:通过RSA加密生成encSecKey值。

OK,JS逆向加密分析的过程就完成了。

03 小结

辰哥在本文中主要讲解了『JS逆向AES加密』反爬,并以某抑云获取歌曲真实播放地址为例去实战演示分析。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Python实用案例,Python脚本,Python实现帮你

发表于 2021-07-29

往期回顾

Python实现自动监测Github项目并打开网页

Python实现文件自动归类

前言:

今天我们就利用python脚本实现帮你选择双色球号码。直接开整~

开发工具:

python版本: 3.9.6

函数: random

效果展示

效果展示

原理简介

双色球,顾名思义,就是两种颜色的球,红色和蓝色。

红球从1-33中取出6个,篮球从1-16取出1个。注意,红球为不放回采样,也就是不能有重复的。

那么,带大家用python来选择双色球号码。

其实很简单,只用到一个随机数模块。
先说红球共6个,每次从1-33个数中随机选择一个,且不重复的情况下,添加到一个列表中;蓝球从1-16个球中随机选择一个即可。

源码展示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
python复制代码import random

red_ball = []
while True:
# 生成一位随机数
a = random.randint(1, 34)

# 避免重复
if a not in red_ball:
# 把不重复的数字,添加到列表
red_ball.append(a)

# 返回6个不重复的红球
if len(red_ball) == 6:
print("红球:", red_ball)
break

# 生成蓝球
blue_ball = random.randint(1, 17)
print("蓝球:", blue_ball)

# 运行结果:
红球: [17, 28, 24, 19, 29, 23]
蓝球: 9

运行上面程序,就会随机生成一组数据。

不过你说,这样选出来的数据有啥用,真的也只能在平淡的生活多了那么一丝期待。

文章到这里就结束了,感谢你的观看,Python实用脚本系列,下篇文章分享更换“必应图片”为“桌面壁纸”

为了感谢读者们,我想把我最近收藏的一些编程干货分享给大家,回馈每一个读者,希望能帮到你们。

干货主要有:

① 2000多本Python电子书(主流和经典的书籍应该都有了)

② Python标准库资料(最全中文版)

③ 项目源码(四五十个有趣且经典的练手项目及源码)

④ Python基础入门、爬虫、web开发、大数据分析方面的视频(适合小白学习)

⑤ Python学习路线图(告别不入流的学习)

⑥ 两天的Python爬虫训练营直播权限

All done~详见个人简介或者私信获取完整源代码。。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

高并发教程一:多线程基础(缓存行、volatile、Java

发表于 2021-07-29

本文主要介绍计算机基础、CPU与内存、缓存行、线程与进程、JMM(Java内存模型),并发三大特性、指令重排、重排约束、volatile等基础知识,帮助大家再做多线程与并发时有夯实的基础知识。

计算机基础

计算机发展史

第一阶段(1946~1957):电子管计算机
image.png
第二次世界大战是电子管计算机产生的催化剂(英国为了解密德国海军的密文),在第二次世界大战中,战争使用了飞机和火箭,打得准需要计算设计参数,设计参数需要几千次运算才能计算出来,在没有计算机之前 ,这些都需要人工手动去计算,埃尼阿克的计算速度大约是手工计算的20万倍。

第二阶段(1957~1964):晶体管计算机

image.png
贝尔实验室的三个科学家发明了晶体管,第一台晶体管计算机产生于麻省理工大学的林肯实验室。

第三阶段(1964~1980):集成电路计算机

image.png
德州仪器的工程师发明了集成电路(IC),后面就有了集成电路计算机,集成电路计算机让计算机具备进入千家万户的条件。因为集成电路使计算机变得更小,功耗变得更低,计算速度变得更快

第四阶段(1980~至今):超大规模集成电路计算机
image.png
超大规模集成电路,一个芯片上集成了上百万的晶体管,这样使超大规模集成电路计算机速度更快,价格更低,体积更小,更能被大众所接受,而且用途丰富,可以用作文本处理,表格处理,高交互的由于与应用等

image.png

冯·诺依曼

冯·诺依曼,著名匈牙利裔美籍数学家 ,说过计算机应该有这部分组成:

  • 运算器:包含算术逻辑单元(Arithmetic Logic Unit,ALU)和处理器寄存器(Processor Register)的处理器单元(Processing Unit),用来完成各种算术和逻辑运算,因为它能够完成各种数据的处理或者计算工作,因此也有人把这个叫作数据通路(Datapath)或者运算器。
  • 控制器:包含指令寄存器(Instruction Register)和程序计数器(Program Counter)的控制器单元(Control Unit/CU),用来控制程序的流程,通常就是不同条件下的分支和跳转
  • 存储器:用来存储数据(Data)和指令(Instruction)的内存。以及更大容量的外部存储,在过去,可能是磁带、磁鼓这样的设备,现在通常就是硬盘
  • 输入和输出:各种输入和输出设备,以及对应的输入和输出机制,如个人电脑的鼠标键盘是输入设备,显示器是输出设备

image.png

计算机与软件结构

image.png

CPU与内存

CPU 结构

image.png

控制单元:

  • 指令计数器 IC:指令计数器又称程序计数器,是中央处理器内的一个寄存器,其作用是存放当前正在进行的指令的地址。当指令计算器中指令被取出后,计数器内的地址加一或者指针下移一位,此时计数器内的地址即为下一条指令的地址。
  • 指令寄存器 IR:指令寄存器的主要功能是存储将要执行的程序指令。由指令计数器中的指令地址找到内存中相对应的指令地址后,处理器将此条指令存储进指令寄存器中,以供后续使用。
  • 指令译码器 ID:程序指令存储进处理器的指令寄存器后,由指令译码器将由高级语言表示的指令翻译成计算机能够识别的机器语言
  • 操作控制器 OC:操作控制器的功能就是根据指令操作码和时序信号,产生各种操作控制信号,发送给运算单元,完成取指令和执行操作的控制

运算单元(ALU):

  • 运算单元是运算器的核心。可以执行算术运算(包括加减乘数等基本运算及其附加运算)和逻辑运算(包括移位、逻辑测试或两个值比较)

存储单元:

  • 存储单元包括 CPU 片内缓存和寄存器组,是 CPU 中暂时存放数据的地方,里面保存着那些等待处理的数据,或已经处理过的数据,CPU 访问寄存器所用的时间要比访问内存的时间短

内存

image.png

当CPU执行运算的时候,它先去L1查找所需的数据、再去L2、然后是L3,如果最后这些缓存中都没有,所需的数据就要去主内存拿。走得越远,运算耗费的时间就越长。所以要尽量确保数据在L1缓存中

image.png
MESI协议:是一个基于失效的缓存一致性协议,是支持回写(write-back)缓存的最常用协议。也称作伊利诺伊协议

image.png

MESI 是指4个状态的首字母。每个Cache line有4个状态,可用2个bit表示,它们分别是:

  • M:modified–与主存的内容相比,有改动就标记为M。我改过,那么对于别人来说就是i了。
  • E:exclusive–内容为我锁独占,标记为E。
  • S:shared–与此同时别人也在读,标记为S。
  • I:invalid–读时被其他CPU改过,说明我读到的数据是无效的,必须再读一次。

缓存行

内存比CPU慢很多、为了提高访问内存(RAM)的效率就诞生了CPU缓存(CPU Cache),缓存行 (Cache Line) 便是 CPU Cache 中的最小单位,CPU Cache 由若干缓存行组成,一个缓存行的大小通常是 64 字节(这取决于 CPU),并且它有效地引用主内存中的一块地址

缓存行实验

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
ini复制代码/**
* @author lm
**/
public class CacheLinePaddingBefore {
public volatile long x = 1L;
public volatile long y = 1L;
public static void main(String[] args) throws Exception {
CacheLinePaddingBefore cacheLinePaddingBefore =
new CacheLinePaddingBefore();
Thread threadA = new Thread(() -> {
for (long i = 0; i < 1000_0000; i++)
{ cacheLinePaddingBefore.x = i; }
}, "ThreadA");
Thread threadB = new Thread(() -> {
for (long i = 0; i < 1000_0000; i++)
{ cacheLinePaddingBefore.y = i; }
}, "ThreadB");
final long start = System.nanoTime();
threadA.start();
threadB.start();
threadA.join();
threadB.join();
final long end = System.nanoTime();
System.out.println("耗时:" + (end - start) / 100_0000);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
ini复制代码/**
* @author lm
**/
public class CacheLinePaddingAfter {
public volatile long p1, p2, p3, p4, p5, p6, p7;
public volatile long x = 1L;
public volatile long p11, p12, p13, p14, p51, p16, p17;
public volatile long y = 1L;
public static void main(String[] args) throws Exception {
CacheLinePaddingAfter cacheLinePaddingAfter
= new CacheLinePaddingAfter();
Thread threadA = new Thread(() -> {
for (long i = 0; i < 1000_0000; i++)
{ cacheLinePaddingAfter.x = i; }
}, "ThreadA");
Thread threadB = new Thread(() -> {
for (long i = 0; i < 1000_0000; i++)
{ cacheLinePaddingAfter.y = i; }
}, "ThreadB");
final long start = System.nanoTime();
threadA.start();
threadB.start();
threadA.join();
threadB.join();
final long end = System.nanoTime();
System.out.println("耗时:" + (end - start) / 100_0000);
}
}

image.png
基本一样的代码执行的耗时为何相差几倍?

结果分析

缓存行两大特性

  • 空间局部性:当 CPU 把内存的数据载入 cache 时,会把临近的共 64 Byte 的数据一同放入同一个Cache line

64 字节背景:
1、缓存行越大,局部性空间效率越高,但读取时间慢
2、反之缓存行越小,局部性空间效率越低,但读取时间快,所以就取了一个折中值:64字节

  • 时间局部性:如果一个信息项正在被访问,那么在近期它很可能还会被再次访问。比如循环、递归等方法反复调用

伪共享

  • 伪共享指的是多个线程同时读写同一个缓存行的不同变量时导致的 CPU 缓存失效。CPU更新缓存行中数据,并将其写回缓存,同时其他处理器会使该缓存行失效,如需使用,还需从内存中重新加载

过程分析
伪共享.gif

Java 的 long 类型是 8 字节、因此在一个缓存行中可以存 8 个 long 类型的变量

  • 假如变量x和变量y共处在同一缓存行中,core1需要操作变量x,core2需要操作变量y。
  • core1修改缓存行内的变量x后,按照缓存一致性协议,core2需将缓存行置为失效,core1将最新缓存行数据写回内存。
  • core2需重新从内存中加载包含变量y的缓存行数据,并放置缓存。如果core2修改变量y,需要core1将缓存行置为失效,core2将最新缓存写回内存。
  • core1或其他处理器如需操作同一缓存行内的其他数据,同上述步骤

解决方案

1、缓存行对齐(Java7)-空间换时间

  • 业界经典案例:Disruptor
    2、@Contended 注解 (Java8)

线程与进程与超线程

image.png
进程:

  • 系统资源分配和调度的独立单位
  • 至少包含一个线程、拥有自己的资源、执行开销大
    线程:
  • CPU调度和分派的基本单位
  • 同一进程内的线程共享本进程的资源,但是进程之间的资源是独立的、执行开销小
    超线程:
  • 超线程(超线程就是一个ALU(运算单元)对应了两组寄存器,这里同时活着的线程可以有两个,这时候线程切换的时候就不需要将数据更换,只需要ALU挪一下地方去计算就可以了)

JMM(Java内存模型)

为避免Java 线程每次读取与写入都直接操作主内存、对性能影响很大、所以通过Java 线程之间的通信由 Java 内存模型(Java Memory Model,简称为 JMM)控制,JMM 决定一个线程对共享变量的写入何时对另一个线程可见
,所以每条线程拥有各自的工作内存,工作内存中的变量是主内存中的一份拷贝,线程对变量的读取和写入,直接在工作内存中操作,而不能直接去操作主内存中的变量

image.png

  • lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
  • unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
  • write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。

并发三大特性

原子性

原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
ini复制代码/**
* 创建2个线程,然后分别执行1000次i++操作。目的是程序输出结果2000
* 但是,多次执行的结果都小于2000
* @author lm
*/
public class AtomicTest {
public int inc = 0;

public void increase() { inc++; }

public static void main(String[] args) throws InterruptedException {
final AtomicTest test = new AtomicTest();
Thread threadA = new Thread(() -> {
for (long i = 0; i < 1000; i++)
{ test.increase(); }
}, "ThreadA");
Thread threadB = new Thread(() -> {
for (long i = 0; i < 1000; i++)
{ test.increase(); }
}, "ThreadB");
threadA.start();
threadB.start();
threadA.join();
threadB.join();
System.out.println(test.inc);
}

}
// 结果:
1700

分析:
原子性.gif

Inc++需要执行三条CPU指令

  1. 将count的值从内存中读取到寄存器中。
  2. 在寄存器中进行+1操作。
  3. 将结果写入内存中

如何保证原子性

  • 通过 synchronized 关键字定义同步代码块或者同步方法保障原子性。
  • 通过 Lock 接口保障原子性。
  • 通过 Atomic 类型保障原子性。

可见性

可见性:指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
java复制代码/**
* 结果:明明将flag设置为true了 为什么没有看见我退出了。
* @author lm
**/
public class VisibilityTest {
public static boolean a = true;
public static void main(String[] args) throws Exception {
System.out.println("我开始了");
ExecutorService executorService =
Executors.newCachedThreadPool();
//线程开始
executorService.execute(() -> {
while(a){
}
System.out.println("我退出了");
});
Thread.sleep(100);
a = false;
}
}

// 结果:
我开始了

分析:
可见性.gif

有序性

有序性:变量在并发的情况下,执行的结果和单线程的执行结果是一样的,不因为重排序问题导致结果不可预知

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
ini复制代码/**
* @author lm
* 预期结果:(0,1)(1,1)(1,0)
**/
public class ReorderingTest {
private static int x = 0, y = 0;
private static int a = 0, b =0;
public static void main(String[] args) throws Exception {
int i = 0;
while(true) {
i++;
x = 0; y = 0;
a = 0; b = 0;
Thread one = new Thread(() -> {
a = 1;
x = b;
});
Thread two = new Thread(() -> {
b = 1;
y = a;
});
one.start();two.start();
one.join();two.join();
String result = "第" + i + "次 (" + x + "," + y + ")";
if(x == 0 && y == 0) {
System.err.println(result);
break;
} else {
System.out.println(result);
}
}
}
}

结果:
image.png

分析:

有序性.gif

总结

image.png

指令重排

jvm 为了提高性能、提高并行度、在部分场景会对指令做了重排序,从而提高性能

image.png

重排约束

as-if-serial(约束重排)

as-if-serial(约束重排):不管怎么重排序,单线程程序的执行结果不被改变。编译器、runtime和处理器都须遵守

image.png

happens- before(先行发生)

happens- before(先行发生):要求编译器优化后一定遵守 Happens-Before 规则

image.png

  1. 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
  2. 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
  3. volatile变量规则:就是如果一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作的结果一定对读的这个线程可见。
  4. 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
  5. start()规则:主线程A启动线程B,线程B中可以看到主线程启动B之前的操作。也就是start() happens before 线程B中的操作。
  6. join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
  7. 程序中断规则:主线程A等待子线程B完成,当子线程B执行完毕后,主线程A可以看到线程B的所有操作。也就是说,子线程B中的任意操作,happens-before join()的返回。
  8. 对象finalize规则:一个对象的初始化完成(构造函数执行结束)先行于发生它的finalize()方法的开始

volatile

volatile: volatile 是一个类型修饰符,volatile 的作用是作为指令关键字,确保本条指令不会因编译器的优化而省略,它是具备可见性、非原子性、有序性的

可见性

可见性: Volatile保证了数据在多线程之间的可见性,每个线程在获取volatile修饰的变量时候都会去主内存获取

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
java复制代码/**
* @author lm
* 根据之前的不可见的case加上关键字volatile之后可见了
**/
public class VisibilityTest2 {
public volatile static boolean a = true;
public static void main(String[] args) throws Exception {
System.out.println("我开始了");
ExecutorService executorService =
Executors.newCachedThreadPool();
//线程开始
executorService.execute(() -> {
while(a){

}
System.out.println("我退出了");
});
Thread.sleep(100);
a = false;
}
}

//结果:
我开始了
我退出了

分析
v可见性.gif

  • 写a时,JMM会把该线程对应的本地内存中的共享变量 a 刷新到主内存
  • 读a时,JMM会把该线程对应的本地内存置为无效,将从主内存中读取a

非原子性

非原子性: volatile 只能保证对单次读/写的原子性,i++ 这种操作不能保证原子性,自增操作是不具备原子性的,它包括三步:

  • 1、读取变量的原始值
  • 2、进行加1操作
  • 3、写入工作内存
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
ini复制代码public class NoAtomicTest {
public volatile int inc = 0;

public void increase() { inc++; }

public static void main(String[] args) throws Exception {
final NoAtomicTest test = new NoAtomicTest();
Thread threadA = new Thread(() -> {
for (long i = 0; i < 1000; i++)
{ test.increase(); }
}, "ThreadA");
Thread threadB = new Thread(() -> {
for (long i = 0; i < 1000; i++)
{ test.increase(); }
}, "ThreadB");
threadA.start();
threadB.start();
threadA.join();
threadB.join();
System.out.println(test.inc);
}
}

结果:小于2000

分析
v原子性.gif

有序性

有序性: volatile变量读写前后插入内存屏障以避免重排序,保证了有序性

内存屏障:通过阻止屏障两边的指令重排序来避免编译器和硬件的不正确优化而提出的一种解决办法。

x86架构的内存屏障

  • Store Barrier(sfence):能让当前线程写入高速缓存中的最新数据更新写入主内存,让其他线程可见
  • Load Barrier(lfence):可以让高速缓存中的数据失效,强制当前线程从主内存里面加载数据
  • Full Barrier(mfence):在该指令前的读写操作必须在该指令后的读写操作前完成

JVM 中内存屏障

  • LoadLoad屏障(对应读屏障):对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕
  • StoreStore屏障(对应写屏障):对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见
  • LoadStore屏障(对应读屏障):对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕
  • StoreLoad屏障(万能屏障):对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见

Volatile内存屏障

Normal Load, Normal Store 对应的是对普通引用的修改。好比有 int a = 1;就是 Normal Store。如果变量带着 volatile 修饰,那对应的读取和写入操作就是 Volatile Load 或者 Volatile Store。JVM 在将字节码编译为汇编时,如果碰见比如 getfield, putfield 这些字节码,并发现带着 volatile 标记的成员变量,就会根据 JMM 要求插入对应的 Barrier

image.png

总结:

  • 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
  • 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
  • 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

NumPy之 多维数组中的线性代数|Python 主题月 简

发表于 2021-07-29

本文正在参加「Python主题月」,详情查看 活动链接

[toc]

简介

本文将会以图表的形式为大家讲解怎么在NumPy中进行多维数据的线性代数运算。

多维数据的线性代数通常被用在图像处理的图形变换中,本文将会使用一个图像的例子进行说明。

图形加载和说明

熟悉颜色的朋友应该都知道,一个颜色可以用R,G,B来表示,如果更高级一点,那么还有一个A表示透明度。通常我们用一个四个属性的数组来表示。

对于一个二维的图像来说,其分辨率可以看做是一个X*Y的矩阵,矩阵中的每个点的颜色都可以用(R,G,B)来表示。

有了上面的知识,我们就可以对图像的颜色进行分解了。

首先需要加载一个图像,我们使用imageio.imread方法来加载一个本地图像,如下所示:

1
2
3
go复制代码import imageio
img=imageio.imread('img.png')
print(type(img))

上面的代码从本地读取图片到img对象中,使用type可以查看img的类型,从运行结果,我们可以看到img的类型是一个数组。

1
arduino复制代码class 'imageio.core.util.Array'

通过img.shape可以得到img是一个(80, 170, 4)的三维数组,也就是说这个图像的分辨率是80*170,每个像素是一个(R,B,G,A)的数组。

最后将图像画出来如下所示:

1
2
javascript复制代码import matplotlib.pyplot as plt
plt.imshow(img)

图形的灰度

对于三维数组来说,我们可以分别得到三种颜色的数组如下所示:

1
2
3
ini复制代码red_array = img_array[:, :, 0]
green_array = img_array[:, :, 1]
blue_array = img_array[:, :, 2]

有了三个颜色之后我们可以使用下面的公式对其进行灰度变换:

1
ini复制代码Y=0.2126R + 0.7152G + 0.0722B

上图中Y表示的是灰度。

怎么使用矩阵的乘法呢?使用 @ 就可以了:

1
ini复制代码 img_gray = img_array @ [0.2126, 0.7152, 0.0722]

现在img是一个80 * 170的矩阵。

现在使用cmap=”gray”作图:

1
ini复制代码plt.imshow(img_gray, cmap="gray")

可以得到下面的灰度图像:

灰度图像的压缩

灰度图像是对图像的颜色进行变换,如果要对图像进行压缩该怎么处理呢?

矩阵运算中有一个概念叫做奇异值和特征值。

设A为n阶矩阵,若存在常数λ及n维非零向量x,使得Ax=λx,则称λ是矩阵A的特征值,x是A属于特征值λ的特征向量。

一个矩阵的一组特征向量是一组正交向量。

即特征向量被施以线性变换 A 只会使向量伸长或缩短而其方向不被改变。

特征分解(Eigendecomposition),又称谱分解(Spectral decomposition)是将矩阵分解为由其特征值和特征向量表示的矩阵之积的方法。

假如A是m * n阶矩阵,q=min(m,n),A*A的q个非负特征值的算术平方根叫作A的奇异值。

特征值分解可以方便的提取矩阵的特征,但是前提是这个矩阵是一个方阵。如果是非方阵的情况下,就需要用到奇异值分解了。先看下奇异值分解的定义:

A=UΣVTA=UΣV^TA=UΣVT

其中A是目标要分解的m * n的矩阵,U是一个 m * m的方阵,Σ 是一个m * n 的矩阵,其非对角线上的元素都是0。VTV^TVT是V的转置,也是一个n * n的矩阵。

奇异值跟特征值类似,在矩阵Σ中也是从大到小排列,而且奇异值的减少特别的快,在很多情况下,前10%甚至1%的奇异值的和就占了全部的奇异值之和的99%以上了。也就是说,我们也可以用前r大的奇异值来近似描述矩阵。r是一个远小于m、n的数,这样就可以进行压缩矩阵。

通过奇异值分解,我们可以通过更加少量的数据来近似替代原矩阵。

要想使用奇异值分解svd可以直接调用linalg.svd 如下所示:

1
ini复制代码U, s, Vt = linalg.svd(img_gray)

其中U是一个m * m矩阵,Vt是一个n * n矩阵。

在上述的图像中,U是一个(80, 80)的矩阵,而Vt是一个(170, 170) 的矩阵。而s是一个80的数组,s包含了img中的奇异值。

如果将s用图像来表示,我们可以看到大部分的奇异值都集中在前的部分:

这也就意味着,我们可以取s中前面的部分值来进行图像的重构。

使用s对图像进行重构,需要将s还原成80 * 170 的矩阵:

1
2
3
4
5
ini复制代码# 重建
import numpy as np
Sigma = np.zeros((80, 170))
for i in range(80):
Sigma[i, i] = s[i]

使用 U @ Sigma @ Vt 即可重建原来的矩阵,可以通过计算linalg.norm来比较一下原矩阵和重建的矩阵之间的差异。

1
scss复制代码linalg.norm(img_gray - U @ Sigma @ Vt)

或者使用np.allclose来比较两个矩阵的不同:

1
scss复制代码np.allclose(img_gray, U @ Sigma @ Vt)

或者只取s数组的前10个元素,进行重新绘图,比较一下和原图的区别:

1
2
3
ini复制代码k = 10
approx = U @ Sigma[:, :k] @ Vt[:k, :]
plt.imshow(approx, cmap="gray")

可以看到,差异并不是很大:

原始图像的压缩

上一节我们讲到了如何进行灰度图像的压缩,那么如何对原始图像进行压缩呢?

同样可以使用linalg.svd对矩阵进行分解。

但是在使用前需要进行一些处理,因为原始图像的img_array 是一个(80, 170, 3)的矩阵–这里我们将透明度去掉了,只保留了R,B,G三个属性。

在进行转换之前,我们需要把不需要变换的轴放到最前面,也就是说将index=2,换到index=0的位置,然后进行svd操作:

1
2
3
4
5
scss复制代码img_array_transposed = np.transpose(img_array, (2, 0, 1))
print(img_array_transposed.shape)

U, s, Vt = linalg.svd(img_array_transposed)
print(U.shape, s.shape, Vt.shape)

同样的,现在s是一个(3, 80)的矩阵,还是少了一维,如果重建图像,需要将其进行填充和处理,最后将重建的图像输出:

1
2
3
4
5
6
7
8
9
scss复制代码Sigma = np.zeros((3, 80, 170))

for j in range(3):
np.fill_diagonal(Sigma[j, :, :], s[j, :])

reconstructed = U @ Sigma @ Vt
print(reconstructed.shape)

plt.imshow(np.transpose(reconstructed, (1, 2, 0)))

当然,也可以选择前面的K个特征值对图像进行压缩:

1
2
3
less复制代码approx_img = U @ Sigma[..., :k] @ Vt[..., :k, :]
print(approx_img.shape)
plt.imshow(np.transpose(approx_img, (1, 2, 0)))

重新构建的图像如下:

对比可以发现,虽然损失了部分精度,但是图像还是可以分辨的。

总结

图像的变化会涉及到很多线性运算,大家可以以此文为例,仔细研究。

本文已收录于 www.flydean.com/08-python-n…

最通俗的解读,最深刻的干货,最简洁的教程,众多你不知道的小技巧等你来发现!

欢迎关注我的公众号:「程序那些事」,懂技术,更懂你!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

【玩转掘金】掘金专栏文章正生成pdf文档 |Python 主

发表于 2021-07-29

本文正在参加「Python主题月」,详情查看 活动链接

前言

掘金有在看各位的很多优秀的专栏,我一直在思考,如何把这些专栏的文章内容整合在一起呢?

对,生成PDF文件,数据聚合,方便你我她他它。

Don’t worry! 实现的方法方式都很简单,一共不到200行代码。

源码:JueJinColToPdf

在线预览:
我的掘金前端周栏.pdf

效果演示

代码执行

cmd.gif

PDF文档效果

pdf.gif

基本思路

  1. 收集API信息和关联
  2. 下载专栏数据
  3. 生成html代码 (markdown转html)
  4. 生成pdf文件

收集API信息和关联

数据是一切的根源,所以先分析如何获取掘金专栏数据。

其实三个API就满足需求:

  1. 专栏摘要信息

作用: 获取专栏的摘要信息,主要是专栏ID和专栏名称

API地址: api.juejin.cn/content_api…
2. 专栏文章列表

作用: 获取专栏文章列表,主要是文章ID,一个中间商罢了。

API地址: api.juejin.cn/content_api…
3. 文章详情

作用: 获取文章详情,主要是文章标题和MarkDown格式的文章内容

API地址: api.juejin.cn/content_api…

整体流程如下:

image.png

获取专栏信息

API地址: api.juejin.cn/content_api…]

请求参数

QueryString

  1. column_id 专栏ID

数据结构

有用的就是 title字段,生成html和pdf时需要。

1
2
3
4
5
6
7
8
json复制代码{
column_id:""6979380367216082957"
data: {
"column_version": {
"title": "前端基础进阶“ // 专栏标题
}
}
}

获取专栏文章列表

API 地址
https://api.juejin.cn/content_api/v1/column/articles_cursor

请求参数

1
2
3
4
5
6
json复制代码{
"column_id":"6979380367216082957", // 专栏ID
"cursor":"0", // 当前下标
"limit":20, // 一页大小
"sort":0 // 0 发布事件从近到远,反之从远到近
}

数据结构
有用的其实就 artcile_id

1
2
3
4
5
6
7
8
9
10
11
12
13
14
json复制代码{
"err_no": 0,
"err_msg": "success",
"cursor": "2",
"count": 5,
"has_more": false,
"data": [
{
"article_id": "6989391487200919566",
.....................

}
]
}

获取文章详情

API地址:

https://api.juejin.cn/content_api/v1/article/detail

请求参数:

1
2
3
json复制代码{
"article_id": "6989391487200919566" // 文章ID
}

返回结果:

data属性中的mark_content字段就是markdown语法的原始内容。

1
2
3
4
5
6
7
8
9
10
json复制代码{
"data": {
"article_id": "6989391487200919566",
"article_info": {
"mark_content": "---\ntheme: channing-cyan\nhighlight: a11y-dark\n---\n\n\n## 前言\n\n本文收录在 **[前端基础进阶](https://juejin.cn/column/6979380367216082957)** 专栏"
}
},
"err_no": 0,
"err_msg": "success",
}

下载专栏数据

我们选择Python里面比较有名的requests库,虽然不支持异步,不过问题不大。

基本顺序:

  1. 下载专栏信息
  2. 下载专栏文章列表信息
  3. 下载单篇文章信息

注意点:

  1. 我们会按照专栏ID创建文件夹,把下载下来的JSON格式的文件都放到下面

至于为什么? 21世纪什么最重要, 数据!!

有了数据,你可以自己生成网页,做成word, pdf,亦或其他,无线的选择。

本着本文为Python类别,还是贴一段主流程代码:
更多细节查看源码 JueJinColToPdf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
python复制代码def downloadColumn(cid):

column = getColumn(cid);
print("获取专栏信息成功,专栏名称:", column["data"]["column_version"]["title"]);
saveColumn(cid, json.dumps(column["data"]))

articleList = getArticleList(cid)
saveArticleList(cid, json.dumps(articleList["data"]))

articleIds = list(map(lambda item: item["article_id"], articleList["data"]))
print("获取专栏文章列表成功:", articleIds)

for artId in articleIds:
artContent = getArticleContent(artId)
saveArticleContent(cid, artId, json.dumps(artContent["data"]))
print("文章ID为 %s 的文章下载完毕" % artId)

生成Html代码

直接说一些问题

如何把markdown格式转为html代码

巨人的肩上markdown库

1
2
python复制代码 html += markdown.markdown(
removeScheme(article["article_info"]["mark_content"]), extensions=extensions)

如何高亮代码

Pygments库,

1
js复制代码pygmentize -S default -f html -a .codehilite > code.css

然后设置一个markdown库的extensions

1
2
3
4
5
6
7
8
9
10
python复制代码extensions = [  # 根据不同教程加上的扩展
'markdown.extensions.extra',
'markdown.extensions.codehilite', # 代码高亮扩展
'markdown.extensions.toc',
'markdown.extensions.tables',
'markdown.extensions.fenced_code',
]

html += markdown.markdown(
removeScheme(article["article_info"]["mark_content"]), extensions=extensions)

去掉掘金主题和代码风格

如果你选择了主题或者代码高亮,markdown头部会多出如下的内容。

1
2
3
4
markdown复制代码---
theme: github
highlight: a11y-light
---

方案:正则替换或者字符串截取

如何自定义样式

新建一个 extend.css

1
2
3
4
5
6
7
8
css复制代码* {
font-size: 30px; // 默认字体
}

img{
display: block;
min-width: 50%; // 图片至少50%
}

如何生成一个大的html文件

  1. 创建一个html文件,
  2. core.css和extend.css文件写入html的style标签里面
  3. 写入头部的html,
  4. 写入markdown转换后的html,
  5. 最后写入尾部的html。

如果文章太多,怎么办

以一定文章数量分割,比如20篇

以一定文字数量分割,比如5万字

在笔者的代码中是未实现的,不过问题不大,我之前有用html生成400多页的pdf也妥妥的。

转为pdf文件

借助 wkhtmltopdf.exe, 这款神奇可以把网络地址或者本地html文件直接转换成为pdf文件。

最重要的是其完美的支持目录。

从官方下载安装,然后配置一下环境变量就可以,代码嘛就下面这点。

1
2
3
4
5
6
7
8
9
python复制代码def genPdf(title):
# TODO:: 需要配置全局路径
exePath = "wkhtmltopdf.exe"
sourcePath = "./htmls/%s.html" % title
targetPath = "./pdfs/%s.pdf" % title
cmd = '"%s" --outline-depth 2 --footer-center [page] "%s" "%s"' % (
exePath, sourcePath, targetPath)
print(cmd)
subprocess.call(cmd)

写在最后

写作不易,你的一赞一评,就是我最大的动力。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

项目管理的点滴心得

发表于 2021-07-28

最近有幸被指派作为主R负责两个横跨两个月、涉及多个人力的项目,对于之前没有太多项目管理经验的我而言是一个挑战和学习的机会。在项目推进过程中,所得到的学习和感悟颇多,故决定将或由上级教授,或自己领悟提炼的经验,一一记录下来,以供大家学习。可能有些看起来颇为幼稚,但是谁没有个从不会到会的过程呢。

一、缓冲时间/预备队

项目正常应该留有一定的缓冲时间,因为总是有意想不到的事务,乃至紧急需求插入,占据原本的开发时间。按照上级从百度带来的时间预估习惯,一个跨月项目(1.5-2月+)一般预留1周工作日的缓冲时间。

二、把工作交出去

带新人、带团队新成员时,要在脑里和培养计划里有一个目标,就是能把大部分工作交接出去,交由新成员完成。这样既保证研发互备,又使主干研发有精力身兼多个项目而不用事事兼顾,避免被太多具体事务牵扯精力而丧失对整体的把握,甚至主干研发可以以先驱者角色,去考虑更多长远的问题。

三、保守的排期

项目对外承诺完成时间时,给一个保守稳重的排期,宁可被人认为开发效率不高,也不要带有一点乐观情绪去给一个有风险的排期。承诺了排期却没法按时交付,既影响印象又影响绩效。

如果项目完成时间由外部给出,且需要高优满足,通过倒排的方式来规划排期。

需要和PM确认外部/上级对项目完成时间的要求,避免出现:研发给出乐观排期 -> PM以此排期和上级承诺 -> 上级关注此事,变成需要高优响应的需求 -> 研发需要强力保证按时完成,成为悬在头上的达克摩利斯之剑。

四、过程中对进度和人力的把握

项目实施过程中,作为主R要每天都对进度和人力的投入和产出有所把握。

第一是保证进度是符合预期的,每天跟进项目进度并比照计划,能保证项目如期完成。如果项目开始一段时间后,发现实际进展和预期不符,可能造成延期后,要基于实际开发进度重新规划项目排期,并同步到上级和相关部门。同步排期变更的时间最好早于需要延期的时,比如:项目需要延期2周,则在项目开始后两周内重新规划排期并给出包含延期时间的新排期。

第二是方便灵活调度人力以弥补项目计划的不足。比如某个子任务比预期要的时间少,已经被组员提前完成,掌握进度的话就可以分配后续的子任务。如果某个子任务比预期的复杂,则可以根据各任务的人力情况,再重复组织人力去填任务。

推荐使用甘特图作为规划项目排期的工具(可以直接使用Excel画甘特图,或者有适当学习成本的OmniPlan),横轴为时间,粒度到每天,纵轴标记任务,按粒度从大到小分单元格,每个子任务一个最小单元格,并署名相应的负责人员,任务所排时间则用柱状表示,如果任务完成则变换柱状的颜色。例子:

甘特图示例.png

五、对核心工作/重难点的评估和把控

做方案设计时,要把核心工作/重难点的复杂性考虑好,需要对重难点有足够深度的思考,思考其可行性、成本和收益,这些工作都需要以书面形式拿到方案评审上被人质询的。最好有相应的经验能推断大概的开发成本。对于核心工作的开发有中什么不能确认有效的(技术是否满足要求、满足性能等),最好在项目开始前能尽量以小规模开发和测试、借鉴他人项目的方式论证。只要核心工作的设计可行、工期安排合理,整个项目就不会失控太多。

六、明确的目标和可量化的预期值

接上回,在设计方案时要一直对照需求,以确保方案没有偏离目标,对于是否能达到目标,方案最好能给出可量化的指标,以让上级对你的方案有信心。比如新方案经测试能支持5000的QPS,新方案将能把计算时间从2小时降至30分钟,等等。最好不要使用比率,比如比原来快2-10倍,降低50%存储空间等等,因为如果base值就很低,那么你投入的开发成本在上级看来可能仍是不划算的,可有可无的。例子:

成本估算.png
推荐的方案设计方法:将一件大项目的若干个目标和为此所做的工作内容拆解到小块,列出每个任务(甚至可以是每个任务的每个可选小方案)分别对应的目标和可量化的成本,验证和预测该方案是否能达到目标,且付出上述成本下的收益是否可观,是否值得去做。

关于资源:一个系统需要维护的人效,也即人力维护成本是很重要的,如果系统为了追求效率,在有效提高性能的基础上提高了人力维护成本,即把系统做得更复杂了,那么除非性能是生死线,不然大概率会被上级否决方案。(计算/存储)资源使用的原则是,只要资源没有被浪费,哪怕使用量很大,也是可以被接受的。

七、保持文档与开发进度同步

项目开发经常发生文档滞后于开发的情况,且因开发工作繁忙,业务细节又繁琐,开发后一般研发也没有太多的主观能动性去更新业务文档。但是落后的文档可能导致和外界对接时的沟通成本增加,和人交接或培养新人的成本上升,所以保持文档最新的收益也是值得研发花费时间的。

可以以开发前的方案文档为骨架,在开发中不断填充上内容,将需要了解该业务所需的所有业务知识和技术内容都按照章程去填充至各目录,随时更新开发中对方案的变更、待办事项和已知问题等。比如如果你的公司是使用wiki作为文档工具,可以为项目新建一个目录页,再附上各种子页面,最开始的子页面即是项目方案页。

八、测试用例

这个应该大部分研发都知道的,先写测试用例再写代码。如果先写代码再写测试用例,可能会按代码逻辑去考虑异常case,而不是按业务逻辑去考虑,容易测不出实际问题。测试用例也可以一直同步到文档,这样后面追责或重构时,也方便从测试用例获得有效信息。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

基于Redis实现简单的商品秒杀服务端

发表于 2021-07-28

简介

这是一个简单的商品秒杀服务端,通过redis实现快速读写库存,实现以商品粒度的分布式锁来防止并发操作库存的问题。秒杀成功的订单以消息的模式发送到mq上,然后可以在商品服务中消费这个mq来创建订单落库,消费者这一块这个项目中没有实现。

秒杀流程

miaosha.png

  1. 通过拦截器每秒放一部分请求进来进行抢购,过滤的请求直接返回抢购失败,
  2. 进行抢购数量,活动,商品是否售罄等基本的校验,
  3. 尝试扣库存:
    1. 尝试请求获取该商品的锁,
    2. 获取该商品的库存,如果库存为0,则添加到售罄队列,返回抢购失败,
    3. 扣减库存,
    4. 生成订单发送mq,
    5. 抢购成功,返回订单号。

主要逻辑

利用分布式锁对同一个抢购商品加锁,进而扣减库存

  1. 由于可能存在多个商品的描述活动,所以需要按商品获取锁。先定义一个自定义注解表示加锁:
1
2
3
4
5
6
7
less复制代码@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface OrderLock {
// 商品编码
String value() default "";
}
  1. 扣减库存的方法加上OrderLock注解,并通过el表达式设置value值,也就是后面加锁的对象:
1
2
3
4
5
less复制代码@Override
@OrderLock("#activity.itemCode")
public long stockReduce(String userId, int quantity, SeckillActivityInfo activity) {
...
}
  1. 通过环绕切面尝试对抢购的商品库存加锁,处理成功后释放锁:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
java复制代码@Around("@annotation(orderLock)")
public Object invoke(ProceedingJoinPoint pjp, OrderLock orderLock) throws Throwable {
Object lockObject = getLockObject(pjp, orderLock.value());
if (lockObject == null) {
throw new LockFailException("无法获取加锁对象");
}

RLock lock = redissonClient.getLock(lockObject.toString());
boolean isLock = false;
try {
// 尝试在1000ms内获取锁,5000ms锁失效时间
isLock = lock.tryLock(1000, 5000, TimeUnit.MILLISECONDS);
if (isLock) {
return pjp.proceed();
}

throw new LockFailException("对象"" + lockObject + ""加锁失败");
} finally {
if (isLock) {
lock.unlock();
}
}
}

使用RLocalCachedMap来缓存活动信息数据

实际上,在每个抢购请求处理之前,都需要查询抢购的活动信息,来获取活动商品信息,活动价格等数据。这是一个高度频繁的读取操作,而且活动信息数据在这期间不会更新,所以不需要查询远端数据,网络通信将会是瓶颈。Redisson提供了一个带有本地缓存功能的分布式缓存映射RLocalCachedMap刚好适用我们这个场景。

RLocalCachedMap继承自RMap,实现了java.util.concurrent.ConcurrentMap和java.util.Map两个接口,本地缓存功能充分的利用了JVM的自身内存空间,对部分常用的元素实行就地缓存,按照官方的说法是读取操作的性能比分布式映射提高最多45倍。还等什么,拿来用吧。

扣减库存具体步骤

  1. 获取抢购的商品库存,
1
2
ini复制代码RBucket<ItemStockBase> bucket = redissonClient.getBucket(key);
ItemStockBase stock = bucket.get();
  1. 判断库存是否等于0,如果库存等于0,将该商品添加到售罄集合里,返回抢购失败,
1
2
3
4
5
6
ini复制代码if (stockQuantity == 0) {
// 售罄
RSet<String> itemSaleOut = redissonClient.getSet(ITEM_SALE_OUT);
itemSaleOut.add(itemCode);
return -1;
}
  1. 抢购数量是否大于库存,库存不足,抢购失败,
  2. 更新库存,
1
2
ini复制代码stock.setQuantity(stockQuantity - quantity);
bucket.set(stock);
  1. 生成订单发送mq,
1
2
3
4
5
6
7
8
9
ini复制代码// 生成订单发送mq
SeckillOrder order = createSeckillOrder(userId, activity, quantity);
SendResult sr = orderProducerBean.send(order, seckillOrderTopic);

if (sr.isSuccess()) {
return order.getOrderNo();
} else {
return -1;
}

这里需要注意下,发送mq可能失败,所以还需要根据send的结果来判断是否抢购成功,这一步应该在扣减redis库存后面,确保不会超卖,但是有可能会少卖。

测试

可以通过jmeter并发测试秒杀接口,同时参与2个商品的抢购活动。

  • /seckill/init 初始化秒杀活动信息。预设活动信息,商品库存信息。建议每次压测前都删除redis数据,重新调用这个接口初始化数据,
  • /seckill/order 秒杀接口。

总结

秒杀系统是非常复杂的,这里只是模拟了服务端的工作流程,并没有涉及到前端、负载均衡等的部分。高并发的思想就是避免频繁的读写数据库IO操作,利用mq来异步生成订单,并且要保证不能超卖。

  • 以商品维度生成分布式锁,可以支持多个不同商品的抢购活动,
  • 请求过滤(接口限流)。抢购本来就是一个概率事件,1000个商品,100万人抢购,千分之一的成功率,总不能把这100万个请求都去redis库存查一遍吧,那样redis也扛不住,所以随机丢弃部分请求是巧妙且合理的。这里就是为秒杀接口配置一个限流的拦截器来过滤请求,
  • 预防超卖。关于扣库存和发送mq,应该是先扣库存,后发送mq,因为发送mq有可能会失败,mq里面的订单一定是扣减过库存的,不会超卖。有人说这里通过mq的结果来判断扣减redis库存,或者发送mq失败再把库存加回去,其实没必要,少买总比超卖好。其实mq的可靠性还是很高的,我测试了很多次百万级别的并发请求,还没有出现少买的情况。
    完整的源码已经上传:github.com/Phantom0103… 请多提意见帮我改进,🙏 !

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…588589590…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%