在12.09的时候,生产环境出了个问题,具体原因呢,是因为线上有两个异步任务同时执行,这两个异步任务都是生成几万条数据,然后写表,而表的主键id是用雪花算法生成的,具体是使用公共库里面的SnowFlake.php
文件的nextId
方法生成的雪花算法ID
大概逻辑如下:
foreach ($checkData as $v) {
$data = [
'client_id' => $v['client_id'],
'activity_tag' => $v['activity_tag'],
'id_type' => $v['id_type'],
'activity_type' => $v['activity_type'],
'update_time' => date("Y-m-d H:i:s")
];
$data['id'] = $snowFlakeModel->nextId();
$data['activity_tag'] = $v['activity_tag'];
$data['create_time'] = date("Y-m-d H:i:s");
$bool = $relationModel->create($data);
if (!$bool) {
Yii::warning('crowd_relation_info operate failed');
}
}
这个库的雪花算法在单机下单进程是没问题的,但在单机多进程下是有问题的,有概率出现id重复的问题
为了验证是否确实有问题,我这边写了个PHP脚本来测试,脚本如下:
YIi项目\console\controllers\TestController.php
<?php
namespace app\commands;
use app\helper\SnowFlake;
use yii\console\Controller;
use Yii;
/**
* 测试多进程下全局ID生成算法是否有重复
* 默认生成的全局ID放到redis的set集合中
*
* 原理:
* 1. 使用雪花算法生成全局ID
* 2. 使用redis的set集合来存放生成的全局ID,由于set集合里面的元素是唯一的,所以插入的时候失败那么可以认为已经生成过相同的全局ID
*
* 使用方法:
* 1. 进到console目录下
* 2. 执行命令:php yii test/main {进程数} {每个进程生成的全局ID数量} 主进程开多个子进程去生成全局ID进行测试
* * 进程数:不传的话默认是2
* * 每个进程生成的全局ID数量:不传的话默认是20000
*
* 注意:要去日志里面查看进程的执行结果,如果有重复的话日志里面会输出,但终端下不会输出
*
* 3. 使用命令:php yii test/get {数量} 可以查看保存的随机的固定数量的全局ID
* 4. 使用命令:php yii test/del 删除redis的set集合 【测试完成必须进行删除】
*/
class TestController extends Controller
{
const KEY = 'FIN_PHP:BOLI_TEST_SET';
protected static $redisObj = null;
private static function getRedisObj()
{
if (self::$redisObj) {
return self::$redisObj;
}
return Yii::$app->redis;
}
/**
* 主进程开多个子进程去跑
* @param $n int 进程数
* @param $num int 每个进程生成的全局ID的数量
*
* 注意:默认起两个进程,每个进程生成20000条全局ID
*/
public function actionMain(int $n = 2, int $num = 20000)
{
var_dump('这是主进程');
// 开多个子进程异步去生成全局id
for ($i = 1; $i <= $n; $i++) {
exec('php ' . __DIR__ . '/../yii test/run ' . $i . ' ' . $num . ' >/dev/null &', $output, $return);
}
var_dump('主进程成功');
}
/**
* 批量生成全局id
* 生成$num个全局id,保存到redis的set集合中
*/
public function actionRun($pid = 0, $num = 20000): int
{
Yii::info("进程:{$pid} 开始");
$snowFlakeModel = new SnowFlake();
$redis = self::getRedisObj();
$i = 1;
while (true) {
if ($i > $num) break;
$id = $snowFlakeModel->nextId(); // 老的雪花算法生成
// 如果set集合已经存在该id,则重复生成了,此时该进程会退出
if ($redis->exists(self::KEY) && $redis->sismember(self::KEY, $id)) {
Yii::info("进程: {$pid} 失败,有重复了,ID为:{$id},这是第 {$i} 次插入");
return 0;
}
// 往set集合里面插入元素
$redis->sadd(self::KEY, $id);
$i++;
}
Yii::info('redis的set集合的数量有:' . $redis->scard(self::KEY));
Yii::info("进程: {$pid} 成功");
return 1;
}
/**
* 随机取出保存好的全局ID
* @param $num int 全局ID的数量 默认取十个
* @return void
*/
public function actionGet(int $num = 10)
{
$redis = self::getRedisObj();
var_dump('redis的set集合的数量有:' . $redis->scard(self::KEY));
var_dump("随机取出{$num}个:");
var_dump($redis->spop(self::KEY, $num));
}
/**
* 删除redis中的set集合
*/
public function actionDel()
{
var_dump('开始删除');
$redis = self::getRedisObj();
if (empty($redis->exists(self::KEY))) {
var_dump('不存在该set集合');
}
// 如果存在set集合则进行删除set集合
if ($redis->del(self::KEY)) {
var_dump('删除成功');
}
var_dump('删除失败');
}
}
原理是:
PHP脚本开多个进程同时跑(可以指定进程数,不指定默认开两个进程),每个进程循环遍历生成20000个雪花算法ID,然后生成好了之后插入到Redis的set集合中,由于set集合里面的元素是唯一的,所以可以通过插入是否成功来判断是否已经有重复
注意:我这里为了方便是使用Yii的console脚本来实现的,也可以使用PHP原生脚本来实现
测试结果如下:
# php yii test/main
string(15) "这是主进程"
string(15) "主进程成功"
日志结果如下:
2021-12-18 21:22:45 [-][-][-][info][application] 进程:1 开始
2021-12-18 21:22:45 [-][-][-][info][application] 进程: 1 失败,有重复了,ID为:524698059632803840,这是第 6 次插入
2021-12-18 21:22:45 [-][-][-][info][application] 进程:2 开始
2021-12-18 21:22:49 [-][-][-][info][application] redis的set集合的数量有:20002
2021-12-18 21:22:49 [-][-][-][info][application] 进程: 2 成功
进程1在插入第6个的时候就报错退出了,因为有重复的ID生成了
也就是说,我们现在使用的雪花算法的库,是有问题的,在多进程并发下可能会生成重复的雪花算法ID。
为了解决上面的重复ID问题,我这边copy了Github上的一些雪花算法,生成了新的雪花算法类,并且进行测试
参考:https://github.com/tengzbiao/php-snowflake
主要是这个类文件IDWorker.php:
<?php
namespace app\helper;
/**
* 雪花算法
* 每毫秒最多可生成4096个ID
* 参考:https://github.com/tengzbiao/php-snowflake
*
* 注意:该雪花算法在多进程并发下是可以稳定生成全局ID的,并不会重复,建议使用
* 注意:SnowFlake类生成的雪花算法在多进程并发下会有概率重复
*/
class IDWorker
{
const WORKER_BITS = 6;
const DATA_CENTER_BITS = 2;
const EXTENSION_BITS = 2;
const SEQUENCE_BITS = 12;
private $timestampShift = self::SEQUENCE_BITS + self::EXTENSION_BITS + self::WORKER_BITS + self::DATA_CENTER_BITS;
private $dataCenterShift = self::SEQUENCE_BITS + self::EXTENSION_BITS + self::WORKER_BITS;
private $workerShift = self::SEQUENCE_BITS + self::EXTENSION_BITS;
private $extensionShirt = self::SEQUENCE_BITS;
private $workerMax = -1 ^ (-1 << self::WORKER_BITS);
private $dataCenterMax = -1 ^ (-1 << self::DATA_CENTER_BITS);
private $sequenceMax = -1 ^ (-1 << self::SEQUENCE_BITS);
private $extensionMax = -1 ^ (-1 << self::EXTENSION_BITS);
private static $ins;
private $workerID; // 节点ID
private $dataCenterID; // 数据中心ID
private $timestamp; // 上一次时间
private $epoch = 1514736000000; // 2018-01-01 00:00:00,这个一旦定义且开始生成ID后千万不要改了,不然可能会生成相同的ID
private $extension = 0;
private function __construct($dataCenterID, $workerID, int $epoch)
{
if ($dataCenterID > $this->dataCenterMax) {
throw new \Exception("data center id should between 0 and " . $this->dataCenterMax);
}
if ($workerID > $this->workerMax) {
throw new \Exception("worker id should between 0 and " . $this->workerID);
}
$this->dataCenterID = $dataCenterID;
$this->workerID = $workerID;
if ($epoch > 0) {
$this->epoch = $epoch;
}
// $epochMax = $this->getUnixTimestamp();
// $epochMin = $epochMax - strtotime("1 year");
// if ($this->epoch > $epochMax || $this->epoch < $epochMin) {
// throw new \Exception(sprintf("epoch should between %s and %s", $epochMin, $epochMax));
// }
}
/**
* 生成实例
* @param int $dataCenterID 数据中心id 如果是多个数据中心的话,最好使用不同的id,支持最多4个数据中心 0-3之间
* @param int $workerID 机器id 如果是多个机器的话,最好使用不同的id,支持最多64台机器 0-63之间
* @param int $epoch
*/
public static function getInstance($dataCenterID = 1, $workerID = 1, int $epoch = 0)
{
if (is_null(self::$ins)) {
self::$ins = new self($dataCenterID, $workerID, $epoch);
}
return self::$ins;
}
public function id()
{
$timestamp = $this->getUnixTimestamp();
// 允许时钟回拨
if ($timestamp < $this->timestamp) {
$diff = $this->timestamp - $timestamp;
if ($diff < 2) {
sleep($diff);
$timestamp = $this->getUnixTimestamp();
if ($timestamp < $this->timestamp) {
$this->extension += 1;
if ($this->extension > $this->extensionMax) {
throw new \Exception("clock moved backwards");
}
}
} else {
$this->extension += 1;
if ($this->extension > $this->extensionMax) {
throw new \Exception("clock moved backwards");
}
}
}
$sequenceID = $this->getSequenceID();
if ($sequenceID > $this->sequenceMax) {
$timestamp = $this->getUnixTimestamp();
while ($timestamp <= $this->timestamp) {
$timestamp = $this->getUnixTimestamp();
}
$sequenceID = $this->getSequenceID();
}
$this->timestamp = $timestamp;
$id = (int)($timestamp - $this->epoch) << $this->timestampShift
| $this->dataCenterID << $this->dataCenterShift
| $this->workerID << $this->workerShift
| $this->extension << $this->extensionShirt
| $sequenceID;
return (string)$id;
}
private function getUnixTimestamp()
{
return floor(microtime(true) * 1000);
}
private function getSequenceID($max = 4096, $min = 0)
{
$key = ftok(__FILE__, 'd');
$var_key = 100;
$sem_id = sem_get($key);
$shm_id = shm_attach($key, 4096);
$cycle_id = 0;
if (sem_acquire($sem_id)) {
$cycle_id = intval(@shm_get_var($shm_id, $var_key) ?: 0);
$cycle_id++;
if ($cycle_id > $max) {
$cycle_id = $min;
}
shm_put_var($shm_id, $var_key, $cycle_id);
shm_detach($shm_id);
sem_release($sem_id);
}
return $cycle_id;
}
private function __clone()
{
}
}
使用上面的脚本进行测试:(注意:测试之前需要使用php yii test/del
来删掉Redis的set集合,避免出现测试数据问题)
修改脚本的actionRun
方法:
/**
* 批量生成全局id
* 生成$num个全局id,保存到redis的set集合中
*/
public function actionRun($pid = 0, $num = 20000): int
{
Yii::info("进程:{$pid} 开始");
$idWorker = IDWorker::getInstance();
$redis = self::getRedisObj();
$i = 1;
while (true) {
if ($i > $num) break;
$id = $idWorker->id(); // 新的雪花算法生成
// 如果set集合已经存在该id,则重复生成了,此时该进程会退出
if ($redis->exists(self::KEY) && $redis->sismember(self::KEY, $id)) {
Yii::info("进程: {$pid} 失败,有重复了,ID为:{$id},这是第 {$i} 次插入");
return 0;
}
// 往set集合里面插入元素
$redis->sadd(self::KEY, $id);
$i++;
}
Yii::info('redis的set集合的数量有:' . $redis->scard(self::KEY));
Yii::info("进程: {$pid} 成功");
return 1;
}
开5个进程,每个进程跑40000的ID进行测试:
# php yii test/main 5 40000
string(15) "这是主进程"
string(15) "主进程成功"
看日志:
2021-12-18 23:29:21 [-][-][-][info][application] 进程:1 开始
2021-12-18 23:30:07 [-][-][-][info][application] redis的set集合的数量有:199966
2021-12-18 23:30:07 [-][-][-][info][application] 进程: 1 成功
2021-12-18 23:29:21 [-][-][-][info][application] 进程:2 开始
2021-12-18 23:30:07 [-][-][-][info][application] redis的set集合的数量有:199974
2021-12-18 23:30:07 [-][-][-][info][application] 进程: 2 成功
2021-12-18 23:29:22 [-][-][-][info][application] 进程:3 开始
2021-12-18 23:30:07 [-][-][-][info][application] redis的set集合的数量有:199983
2021-12-18 23:30:07 [-][-][-][info][application] 进程: 3 成功
2021-12-18 23:29:22 [-][-][-][info][application] 进程:4 开始
2021-12-18 23:30:07 [-][-][-][info][application] redis的set集合的数量有:199997
2021-12-18 23:30:07 [-][-][-][info][application] 进程: 4 成功
2021-12-18 23:29:22 [-][-][-][info][application] 进程:5 开始
2021-12-18 23:30:07 [-][-][-][info][application] redis的set集合的数量有:200000
2021-12-18 23:30:07 [-][-][-][info][application] 进程: 5 成功
结果是没问题的,也就是说这个雪花算法类在多进程并发场景下也是可以稳定生成唯一的全局ID的。
推荐使用IDWorker来生成雪花算法ID
这个新的雪花算法(IDWorker)使用方式很简单:
use app\helper\IDWorker;
$idWorker = IDWorker::getInstance();
$id = $idWorker->id();
如果是有多台机器的话,可以使用参数:
$dataCenterID = 1;
$workerID = 1; // 不同的机器使用不同的ID
$idWorker = IDWorker::getInstance($dataCenterID, $workerID);
具体可以看代码:
/**
* 生成实例
* @param int $dataCenterID 数据中心id 如果是多个数据中心的话,最好使用不同的id,支持最多4个数据中心 0-3之间
* @param int $workerID 机器id 如果是多个机器的话,最好使用不同的id,支持最多64台机器 0-63之间
* @param int $epoch
*/
public static function getInstance($dataCenterID = 1, $workerID = 1, int $epoch = 0)
{
if (is_null(self::$ins)) {
self::$ins = new self($dataCenterID, $workerID, $epoch);
}
return self::$ins;
}