Files
webman-buildadmin/app/common/service/GameHotDataLock.php

150 lines
4.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
}
}
/**
* 管理员强制操作前释放可能残留的互斥锁(仅删 key不校验 token
*/
public static function forceRelease(string $type, string $resourceKey): void
{
if ($resourceKey === '') {
return;
}
try {
$client = Redis::connection()->client();
if (is_object($client) && method_exists($client, 'del')) {
$client->del(self::lockKey($type, $resourceKey));
}
} catch (Throwable) {
}
}
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;
}
}