Skip to content

Commit

Permalink
feat(Snowflake): 增加 Snowflake 服务,用于生成全局唯一 ID
Browse files Browse the repository at this point in the history
  • Loading branch information
twinh committed Jan 31, 2023
1 parent b13a1a2 commit 4cc7fed
Show file tree
Hide file tree
Showing 2 changed files with 313 additions and 0 deletions.
192 changes: 192 additions & 0 deletions lib/Snowflake.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
<?php

namespace Wei;

use InvalidArgumentException;

/**
* @mixin \CacheMixin
*/
class Snowflake extends Base
{
/**
* The start timestamp in milliseconds, the default value is "2021-09-01" in milliseconds
*
* @var int
*/
protected $startTimestamp = 1630425600000;

/**
* 机器位数,10 位共 1024 台
*
* @var int
*/
protected $workerBits = 10;

/**
* 每毫秒最多能生成的序列位数,12 位共 4096 个
*
* @var int
*/
protected $sequenceBits = 12;

/**
* 当前的机器(服务器,进程)编号
*
* @var int|null
*/
protected $workerId;

/**
* 是否随机生成开始的序列数,如果为 false 则从 0 开始
*
* 优点
* 1. 减少相同编号 worker 生成的序列数冲突
* 2. 避免序列数总是从 0 开始,取模不均匀
* 3. 提高数据量估算难度
*
* 缺点
* 1. 序列数可用长度减少一半
* 2. 增加等待下一毫秒的几率
*
* @var bool
*/
protected $randomStartSequence = true;

/**
* @return int
* @svc
*/
protected function getWorkerId(): int
{
if (null === $this->workerId) {
$this->workerId = mt_rand(0, $this->getMaxNumber($this->workerBits));
}
return $this->workerId;
}

/**
* Set the worker id
*
* @param int $workerId
* @return $this
* @svc
*/
protected function setWorkerId(int $workerId): self
{
if ($workerId < 0) {
throw new InvalidArgumentException('Worker ID must be greater than 0');
}
if ($workerId > $this->getMaxNumber($this->workerBits)) {
throw new InvalidArgumentException(
'Worker ID must be less than or equal to ' . $this->getMaxNumber($this->workerBits)
);
}
$this->workerId = $workerId;
return $this;
}

/**
* Return the start timestamp
*
* @return int
* @svc
*/
protected function getStartTimestamp(): int
{
return $this->startTimestamp;
}

/**
* Set the start timestamp
*
* @param int $startTimestamp
* @return $this
* @svc
*/
protected function setStartTimestamp(int $startTimestamp): self
{
if ($startTimestamp < 0) {
throw new InvalidArgumentException('Start timestamp must be greater than 0');
}
if ($startTimestamp > time() * 1000) {
throw new InvalidArgumentException('Start timestamp must be less than or equal to the current time');
}
$this->startTimestamp = $startTimestamp;
return $this;
}

/**
* Generate an id
*
* @return string
* @svc
*/
protected function next(): string
{
$timestamp = $this->getTimestamp();
$sequence = $this->getSequence($timestamp);
while ($sequence > $this->getMaxNumber($this->sequenceBits)) {
usleep(1);
$timestamp = $this->getTimestamp();
$sequence = $this->getSequence($timestamp);
}

return (string) (($timestamp - $this->getStartTimestamp() << ($this->workerBits + $this->sequenceBits))
| ($this->getWorkerId() << $this->sequenceBits)
| $sequence);
}

/**
* Parse the given id, return timestamp, worker ID and sequence
*
* @param string|int $id
* @return array{timestamp: int, workerId: int, sequence: int}
* @svc
*/
protected function parse($id): array
{
$bin = decbin($id);
return [
'timestamp' => bindec(substr($bin, 0, -$this->workerBits - $this->sequenceBits)) + $this->startTimestamp,
'workerId' => bindec(substr($bin, -$this->workerBits - $this->sequenceBits, $this->workerBits)),
'sequence' => bindec(substr($bin, -$this->sequenceBits)),
];
}

/**
* Return the current timestamp
*
* @return int
*/
protected function getTimestamp(): int
{
return (int) (microtime(true) * 1000);
}

/**
* Return the sequence of current timestamp
*
* @param int $timestamp
* @return int
*/
protected function getSequence(int $timestamp): int
{
$key = 'snowflake:' . $timestamp;
// TODO 考虑增加比例,减少跨毫秒的情况
$startSequence = $this->randomStartSequence ? mt_rand(0, $this->getMaxNumber($this->sequenceBits)) : 0;
if ($this->cache->add($key, $startSequence, 1)) {
return $startSequence;
}
return $this->cache->incr($key);
}

/**
* @param int $bits
* @return int
*/
protected function getMaxNumber(int $bits): int
{
return 2 ** $bits - 1;
}
}
121 changes: 121 additions & 0 deletions tests/unit/SnowflakeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<?php

namespace WeiTest;

use Wei\Snowflake;

class SnowflakeTest extends TestCase
{
public function testReturnType()
{
$id = Snowflake::next();
$this->assertIsString($id);
$this->assertIsNumeric($id);
}

public function testOrder()
{
$id = Snowflake::next();
$id2 = Snowflake::next();
$id3 = Snowflake::next();

$this->assertGreaterThan($id, $id2);
$this->assertGreaterThan($id2, $id3);
}

public function testParse()
{
$snowflake = new Snowflake([
'workerId' => 1,
'randomStartSequence' => false,
]);

$id = $snowflake->next();
$id2 = $snowflake->next();
$result = $snowflake->parse($id);
$result2 = $snowflake->parse($id2);

$timestamp = (int) (microtime(true) * 1000);
$this->assertLessThanOrEqual($timestamp, $result['timestamp']);
$this->assertLessThanOrEqual($timestamp, $result2['timestamp']);

$this->assertLessThan(100, $timestamp - $result['timestamp']);
$this->assertLessThan(100, $timestamp - $result2['timestamp']);

$this->assertSame(1, $result['workerId']);
$this->assertSame(1, $result2['workerId']);

$this->assertSame(0, $result['sequence']);
$this->assertTrue(in_array($result2['sequence'], [0, 1], true));
}

public function testParseWorkerId()
{
$workerId = mt_rand(0, 1023);
$snowflake = new Snowflake([
'workerId' => $workerId,
]);

$id = $snowflake->next();

$this->assertSame($workerId, $snowflake->getWorkerId());
$this->assertSame($snowflake->getWorkerId(), $snowflake->parse($id)['workerId']);
}

public function testParseShorterId()
{
$startTimestamp = time() * 1000;
$snowflake = new Snowflake([
'startTimestamp' => $startTimestamp,
'workerId' => 1,
'randomStartSequence' => false,
]);

$id = $snowflake->next();
$this->assertLessThanOrEqual(10, strlen($id));

$result = $snowflake->parse($id);
$this->assertGreaterThanOrEqual($startTimestamp, $result['timestamp']);
$this->assertSame(1, $result['workerId']);
$this->assertSame(0, $result['sequence']);
}

public function testWorkerId()
{
$snowflake = new Snowflake([
'workerId' => 1,
]);

$this->expectExceptionObject(new \InvalidArgumentException('Worker ID must be greater than 0'));
$snowflake->setWorkerId(-1);

$this->expectExceptionObject(new \InvalidArgumentException('Worker ID must be less than or equal to 1023'));
$snowflake->setWorkerId(1024);

$this->assertSame(1, $snowflake->getWorkerId());

$snowflake->setWorkerId(1023);
$this->assertSame(1023, $snowflake->getWorkerId());
}

public function testStartTimestamp()
{
$snowflake = new Snowflake([
'startTimestamp' => 0,
]);

$this->expectExceptionObject(new \InvalidArgumentException('Start timestamp must be greater than 0'));
$snowflake->setStartTimestamp(-1);

$this->expectExceptionObject(new \InvalidArgumentException(
'Start timestamp must be less than or equal to to the current time'
));
$snowflake->setStartTimestamp(time() * 1000 + 1);

$this->assertSame(0, $snowflake->getStartTimestamp());

$now = time() * 1000;
$snowflake->setStartTimestamp($now);
$this->assertSame($now, $snowflake->getStartTimestamp());
}
}

0 comments on commit 4cc7fed

Please sign in to comment.