133 lines
4.0 KiB
PHP
133 lines
4.0 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace app\common\service;
|
||
|
||
use support\Redis;
|
||
use Throwable;
|
||
|
||
/**
|
||
* 热点实体互斥锁(Redis SET NX EX),按资源串行化写入,避免多管理员/多进程同时改同一缓存键对应的数据。
|
||
*/
|
||
final class GameHotDataLock
|
||
{
|
||
public const TYPE_USER = 'user';
|
||
|
||
public const TYPE_GAME_CONFIG = 'gc';
|
||
|
||
public const TYPE_GAME_RECORD = 'gr';
|
||
|
||
private const KEY_PREFIX = 'dfw:v1:lock:mut:';
|
||
|
||
/**
|
||
* @return array{acquired: bool, token: ?string, redis_lock: bool}
|
||
*/
|
||
public static function tryAcquire(string $type, string $resourceKey): array
|
||
{
|
||
if ($resourceKey === '') {
|
||
return ['acquired' => false, 'token' => null, 'redis_lock' => false];
|
||
}
|
||
$lockKey = self::lockKey($type, $resourceKey);
|
||
$ttl = self::lockTtl();
|
||
$token = bin2hex(random_bytes(16));
|
||
try {
|
||
$client = Redis::connection()->client();
|
||
if (!is_object($client) || !method_exists($client, 'set')) {
|
||
return ['acquired' => true, 'token' => null, 'redis_lock' => false];
|
||
}
|
||
$ok = $client->set($lockKey, $token, ['nx', 'ex' => $ttl]);
|
||
if ($ok === true) {
|
||
return ['acquired' => true, 'token' => $token, 'redis_lock' => true];
|
||
}
|
||
} catch (Throwable) {
|
||
return ['acquired' => true, 'token' => null, 'redis_lock' => false];
|
||
}
|
||
|
||
return ['acquired' => false, 'token' => null, 'redis_lock' => true];
|
||
}
|
||
|
||
/**
|
||
* 带短等待的重试(毫秒),用于对局写入与 ensureAiLocked 等可能交叉的路径。
|
||
*
|
||
* @return array{acquired: bool, token: ?string, redis_lock: bool}
|
||
*/
|
||
public static function tryAcquireWithWait(string $type, string $resourceKey, int $maxWaitMs = 800): array
|
||
{
|
||
$deadline = (int) (microtime(true) * 1000) + max(0, $maxWaitMs);
|
||
while (true) {
|
||
$r = self::tryAcquire($type, $resourceKey);
|
||
if ($r['acquired']) {
|
||
return $r;
|
||
}
|
||
$now = (int) (microtime(true) * 1000);
|
||
if ($now >= $deadline) {
|
||
return $r;
|
||
}
|
||
usleep(25_000);
|
||
}
|
||
}
|
||
|
||
public static function release(string $type, string $resourceKey, ?string $token, bool $redisLock): void
|
||
{
|
||
if ($resourceKey === '' || !$redisLock || $token === null || $token === '') {
|
||
return;
|
||
}
|
||
$key = self::lockKey($type, $resourceKey);
|
||
$script = <<<'LUA'
|
||
if redis.call('get', KEYS[1]) == ARGV[1] then
|
||
return redis.call('del', KEYS[1])
|
||
end
|
||
return 0
|
||
LUA;
|
||
try {
|
||
$client = Redis::connection()->client();
|
||
if (is_object($client) && method_exists($client, 'eval')) {
|
||
$client->eval($script, [$key, $token], 1);
|
||
}
|
||
} catch (Throwable) {
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @template T
|
||
* @param callable(): T $fn
|
||
* @return T|null
|
||
*/
|
||
public static function runExclusive(string $type, string $resourceKey, callable $fn): mixed
|
||
{
|
||
$lock = self::tryAcquire($type, $resourceKey);
|
||
if (!$lock['acquired']) {
|
||
return null;
|
||
}
|
||
try {
|
||
return $fn();
|
||
} finally {
|
||
self::release($type, $resourceKey, $lock['token'], $lock['redis_lock']);
|
||
}
|
||
}
|
||
|
||
public static function safeResourceKeyForConfig(string $configKey): string
|
||
{
|
||
return rtrim(strtr(base64_encode($configKey), '+/', '-_'), '=');
|
||
}
|
||
|
||
public static function lockKeyFromConfigKey(string $configKey): string
|
||
{
|
||
return self::lockKey(self::TYPE_GAME_CONFIG, self::safeResourceKeyForConfig($configKey));
|
||
}
|
||
|
||
private static function lockKey(string $type, string $resourceKey): string
|
||
{
|
||
return self::KEY_PREFIX . $type . ':' . $resourceKey;
|
||
}
|
||
|
||
private static function lockTtl(): int
|
||
{
|
||
$v = config('game_hot_cache.admin_user_mutation_lock_ttl', 30);
|
||
$n = filter_var($v, FILTER_VALIDATE_INT);
|
||
|
||
return ($n === false || $n < 5) ? 30 : $n;
|
||
}
|
||
}
|