前言:最近上线时遇到一个很诡异的问题,这个问题在测试环境和pr环境都没问题,但是在生产环境必现,排查配置、代码是否一致等原因后,最终通过cat生成的traceId和研读代码,花费了两个小时,定位到问题是由于SecureRandom产生随机数系统熵池中数量不足,阻塞了当前线程。记录一下这个踩坑经验,希望大家规避掉类似的使用~
情境描述以及随机算法介绍
系统中经常有获取随机数的场景,我们通常new一个Random对象,通过random.nextInt(size)来获取一个随机数。通过jdk8中对Random类的解释,我们知道:
- Random 类使用线性同余法 linear congruential formula 来生成伪随机数。
- 两个 Random 实例,如果使用相同的种子 seed,那他们产生的随机数序列也是一样的。
- Random 是线程安全的,你的程序如果对性能要求比较高的话,推荐使用 ThreadLocalRandom。
- Random 不是密码学安全的,加密相关的推荐使用 SecureRandom。
从下面的源码中可以看到,Random 的默认使用当前系统时钟来生成种子 seed。
private static final AtomicLong seedUniquifier = new AtomicLong(8682522807148012L);
// 默认使用当前系统时钟来生成种子seed
public Random() {
this(seedUniquifier() ^ System.nanoTime());
}
// 指定初始化种子seed
public Random(long seed) {
if (getClass() == Random.class)
this.seed = new AtomicLong(initialScramble(seed));
else {
// 子类可能重写了setSeed方法
this.seed = new AtomicLong();
setSeed(seed);
}
}
// 线性同余算法
private static long seedUniquifier() {
for (;;) {
long current = seedUniquifier.get();
long next = current * 181783497276652981L;
if (seedUniquifier.compareAndSet(current, next))
return next;
}
}
在上面介绍Random类时,该类的解释加密相关的推荐使用 SecureRandom类,所以我们看下Random的子类SecureRandom类的解释,主要有以下几点:
- 该类提供了能满足加密要求的强随机数生成器。
- 许多SecureRandom实现都是伪随机的,数字发生器(PRNG),这意味着他们使用确定性算法,从一个真正的随机种子产生一个伪随机序列。
- 传递给 SecureRandom 种子必须是不可预测的,seed 使用不当引发的安全漏洞,比如: 比特币电子钱包漏洞。
// 超类构造函数的调用将导致调用到SecureRandom类重写的setSeed方法
public SecureRandom() {
super(0);
getDefaultPRNG(false, null);
}
//
private void getDefaultPRNG(boolean setSeed, byte[] seed) {
String prng = getPrngAlgorithm();
if (prng == null) {
// bummer, get the SUN implementation
// SUN提供程序
prng = "SHA1PRNG";
this.secureRandomSpi = new sun.security.provider.SecureRandom();
this.provider = Providers.getSunProvider();
if (setSeed) {
this.secureRandomSpi.engineSetSeed(seed);
}
} else {
// NativePRNG算法,使用/dev/random或/dev/urandom获取种子,启动应用程序时可以通过参数 -Djava.security.egd=file:/dev/urandom 来指定seed源。使用/dev/random会阻塞线程直到足够的熵可用
try {
SecureRandom random = SecureRandom.getInstance(prng);
this.secureRandomSpi = random.getSecureRandomSpi();
this.provider = random.getProvider();
if (setSeed) {
this.secureRandomSpi.engineSetSeed(seed);
}
} catch (NoSuchAlgorithmException nsae) {
throw new RuntimeException(nsae);
}
}
if (getClass() == SecureRandom.class) {
this.algorithm = prng;
}
}
由上面的分析,Random产生的随机数不够随机,并且为了提升性能和随机性,Sonar建议定义一个 Random 单例来统一产生随机数, 建议使用 SecureRandom.getInstanceStrong() 。我们根据Sonar建议改掉之后,忽略了一个问题:使用/dev/ random会阻塞线程直到足够的熵可用。
所以上线当天的现象是:同一份代码配置相同其他环境都ok,但是生产环境总是接口执行超时,场景无法重现。
问题定位
由于该接口调用了很多三方接口,通过traceId分析日志,发现该接口调用日志和部分三方日志时间为00:32分,并且接口响应rt也无异常,但是另一些三方日志竟然在01:16分才打印出来,很显然出现了线程阻塞问题,通过日志给出的线索,发现执行到 SecureRandom.getInstanceStrong() 方法后就阻塞了。
private Random rand = SecureRandom.getInstanceStrong();
this.rand.nextInt(hasNoRuleSalerId.size())
通过查询资料发现SecureRandom.getInstanceStrong() 方法在 linux 环境下使用 /dev/random 生成种子,其实现原理是:操作系统收集了一些随机事件,比如鼠标点击、键盘点击、磁盘活动等,SecureRandom 使用这些随机事件作为种子。当服务器缺乏”活动”时,就会等待种子,从而阻塞线程。
- /dev/random 设备会返回小于熵池噪声总数的随机字节。/dev/random 可生成高随机性的公钥或一次性密码本。若熵池空了,对/dev/random的读操作将会被阻塞
- /dev/random 的一个副本是 /dev/urandom (“unlocked”,非阻塞的随机数发生器),它会重复使用熵池中的数据以产生伪随机数据。这表示对/dev/urandom的读取操作不会产生阻塞,但其输出的熵可能小于 /dev/random 的。它可以作为生成较低强度密码的伪随机数生成器,不建议用于生成高强度长期密码。
问题解决
- 在不要求强随机性和安全性的业务场景下,推荐使用new Random()来获取随机数。
- 如果需要强随机性的业务场景,只需使用去空参数构造函数new SecureRandom(),让系统选择最好的随机数生成器,但是一些要求非常高速的操作情况下,SecureRandom中的随机算法稍差些。
- 取其精华去其糟粕,我们也可以使用Random时,手动写工具类,在获取随机数方法中增加随机性因子来达到我们的要求。
public synchronized long nextId() {
Long id = Instant.now().toEpochMilli();
int asInt = new Random().ints(0, (999 + 1)).findFirst().getAsInt();
return id + asInt;
}
参考文档:https://www.cnblogs.com/xiekun/p/11938196.html