1.增加互斥锁:保证缓存和数据库数据一致性

2.增加消费队列,保证mysql数据的正常保存
This commit is contained in:
2026-04-20 14:13:48 +08:00
parent 614fb00ec4
commit 1eed3cf0f7
23 changed files with 836 additions and 255 deletions

View File

@@ -28,6 +28,14 @@ GAME_HOT_CACHE_ENABLED = true
GAME_HOT_CACHE_TTL_GAME_CONFIG = 86400
GAME_HOT_CACHE_TTL_GAME_RECORD = 60
GAME_HOT_CACHE_TTL_USER = 90
# 后台对同一用户钱包等互斥锁持有时间(秒),需小于业务事务最长耗时
GAME_HOT_CACHE_ADMIN_USER_LOCK_TTL = 30
# 是否启用热点缓存回源队列webman 进程 gameHotDataQueueConsumer
GAME_HOT_CACHE_ENABLE_WRITE_QUEUE = true
GAME_HOT_CACHE_QUEUE_LIST_KEY = dfw:q:hot_data_write
GAME_HOT_CACHE_QUEUE_MAX_LENGTH = 50000
GAME_HOT_CACHE_QUEUE_CONSUMER_TICK = 0.1
GAME_HOT_CACHE_QUEUE_CONSUMER_BATCH = 80
# 移动端接口鉴权(/api/v1/authToken
AUTH_TOKEN_SECRET = 564d14asdasd113e46542asd6das1a2a

View File

@@ -4,7 +4,8 @@ namespace app\admin\controller\config;
use app\common\controller\Backend;
use app\common\library\game\DepositTier as DepositTierLib;
use app\common\service\GameHotDataRedis;
use app\common\service\GameHotDataCoordinator;
use app\common\service\GameHotDataLock;
use InvalidArgumentException;
use support\think\Db;
use support\Response;
@@ -104,30 +105,39 @@ class DepositTier extends Backend
}
$now = time();
try {
$exists = Db::name('game_config')->where('config_key', DepositTierLib::CONFIG_KEY)->find();
if ($exists) {
Db::name('game_config')->where('config_key', DepositTierLib::CONFIG_KEY)->update([
'config_value' => $json,
'value_type' => 'json',
'update_time' => $now,
]);
} else {
Db::name('game_config')->insert([
'config_key' => DepositTierLib::CONFIG_KEY,
'config_value' => $json,
'value_type' => 'json',
'remark' => '充值档位 JSON 数组(独立表单维护)',
'create_time' => $now,
'update_time' => $now,
]);
}
} catch (Throwable $e) {
return $this->error($e->getMessage());
$resourceKey = GameHotDataLock::safeResourceKeyForConfig(DepositTierLib::CONFIG_KEY);
$lock = GameHotDataLock::tryAcquire(GameHotDataLock::TYPE_GAME_CONFIG, $resourceKey);
if (!$lock['acquired']) {
return $this->error('该配置正在被其他操作占用,请稍后再试');
}
try {
try {
$exists = Db::name('game_config')->where('config_key', DepositTierLib::CONFIG_KEY)->find();
if ($exists) {
Db::name('game_config')->where('config_key', DepositTierLib::CONFIG_KEY)->update([
'config_value' => $json,
'value_type' => 'json',
'update_time' => $now,
]);
} else {
Db::name('game_config')->insert([
'config_key' => DepositTierLib::CONFIG_KEY,
'config_value' => $json,
'value_type' => 'json',
'remark' => '充值档位 JSON 数组(独立表单维护)',
'create_time' => $now,
'update_time' => $now,
]);
}
} catch (Throwable $e) {
return $this->error($e->getMessage());
}
GameHotDataRedis::gameConfigForget(DepositTierLib::CONFIG_KEY);
GameHotDataCoordinator::afterGameConfigKeyCommitted(DepositTierLib::CONFIG_KEY);
return $this->success(__('Saved successfully'));
return $this->success(__('Saved successfully'));
} finally {
GameHotDataLock::release(GameHotDataLock::TYPE_GAME_CONFIG, $resourceKey, $lock['token'], $lock['redis_lock']);
}
}
}

View File

@@ -6,7 +6,8 @@ namespace app\admin\controller\config;
use app\common\controller\Backend;
use app\common\library\game\StreakWinReward as StreakWinRewardLib;
use app\common\service\GameHotDataRedis;
use app\common\service\GameHotDataCoordinator;
use app\common\service\GameHotDataLock;
use support\think\Db;
use support\Response;
use Throwable;
@@ -91,33 +92,42 @@ class StreakWinReward extends Backend
}
$encoded = StreakWinRewardLib::encodeForDb($payload);
$now = time();
Db::startTrans();
try {
$exists = Db::name('game_config')->where('config_key', StreakWinRewardLib::CONFIG_KEY)->find();
if ($exists) {
Db::name('game_config')->where('config_key', StreakWinRewardLib::CONFIG_KEY)->update([
'config_value' => $encoded,
'update_time' => $now,
]);
} else {
Db::name('game_config')->insert([
'config_key' => StreakWinRewardLib::CONFIG_KEY,
'config_value' => $encoded,
'value_type' => 'json',
'remark' => '连胜奖励',
'create_time' => $now,
'update_time' => $now,
]);
}
Db::commit();
} catch (Throwable $e) {
Db::rollback();
return $this->error($e->getMessage());
$resourceKey = GameHotDataLock::safeResourceKeyForConfig(StreakWinRewardLib::CONFIG_KEY);
$lock = GameHotDataLock::tryAcquire(GameHotDataLock::TYPE_GAME_CONFIG, $resourceKey);
if (!$lock['acquired']) {
return $this->error('该配置正在被其他操作占用,请稍后再试');
}
StreakWinRewardLib::clearCache();
GameHotDataRedis::gameConfigForget(StreakWinRewardLib::CONFIG_KEY);
try {
Db::startTrans();
try {
$exists = Db::name('game_config')->where('config_key', StreakWinRewardLib::CONFIG_KEY)->find();
if ($exists) {
Db::name('game_config')->where('config_key', StreakWinRewardLib::CONFIG_KEY)->update([
'config_value' => $encoded,
'update_time' => $now,
]);
} else {
Db::name('game_config')->insert([
'config_key' => StreakWinRewardLib::CONFIG_KEY,
'config_value' => $encoded,
'value_type' => 'json',
'remark' => '连胜奖励',
'create_time' => $now,
'update_time' => $now,
]);
}
Db::commit();
} catch (Throwable $e) {
Db::rollback();
return $this->success('保存成功');
return $this->error($e->getMessage());
}
StreakWinRewardLib::clearCache();
GameHotDataCoordinator::afterGameConfigKeyCommitted(StreakWinRewardLib::CONFIG_KEY);
return $this->success('保存成功');
} finally {
GameHotDataLock::release(GameHotDataLock::TYPE_GAME_CONFIG, $resourceKey, $lock['token'], $lock['redis_lock']);
}
}
}

View File

@@ -3,7 +3,8 @@
namespace app\admin\controller\config;
use app\common\controller\Backend;
use app\common\service\GameHotDataRedis;
use app\common\service\GameHotDataCoordinator;
use app\common\service\GameHotDataLock;
use app\common\library\game\ZiHuaDictionary as ZiHuaDictionaryLib;
use InvalidArgumentException;
use support\think\Db;
@@ -104,30 +105,39 @@ class ZiHuaDictionary extends Backend
}
$now = time();
try {
$exists = Db::name('game_config')->where('config_key', ZiHuaDictionaryLib::CONFIG_KEY)->find();
if ($exists) {
Db::name('game_config')->where('config_key', ZiHuaDictionaryLib::CONFIG_KEY)->update([
'config_value' => $json,
'value_type' => 'json',
'update_time' => $now,
]);
} else {
Db::name('game_config')->insert([
'config_key' => ZiHuaDictionaryLib::CONFIG_KEY,
'config_value' => $json,
'value_type' => 'json',
'remark' => '36字花字典 JSON 数组(独立表单维护)',
'create_time' => $now,
'update_time' => $now,
]);
}
} catch (Throwable $e) {
return $this->error($e->getMessage());
$resourceKey = GameHotDataLock::safeResourceKeyForConfig(ZiHuaDictionaryLib::CONFIG_KEY);
$lock = GameHotDataLock::tryAcquire(GameHotDataLock::TYPE_GAME_CONFIG, $resourceKey);
if (!$lock['acquired']) {
return $this->error('该配置正在被其他操作占用,请稍后再试');
}
try {
try {
$exists = Db::name('game_config')->where('config_key', ZiHuaDictionaryLib::CONFIG_KEY)->find();
if ($exists) {
Db::name('game_config')->where('config_key', ZiHuaDictionaryLib::CONFIG_KEY)->update([
'config_value' => $json,
'value_type' => 'json',
'update_time' => $now,
]);
} else {
Db::name('game_config')->insert([
'config_key' => ZiHuaDictionaryLib::CONFIG_KEY,
'config_value' => $json,
'value_type' => 'json',
'remark' => '36字花字典 JSON 数组(独立表单维护)',
'create_time' => $now,
'update_time' => $now,
]);
}
} catch (Throwable $e) {
return $this->error($e->getMessage());
}
GameHotDataRedis::gameConfigForget(ZiHuaDictionaryLib::CONFIG_KEY);
GameHotDataCoordinator::afterGameConfigKeyCommitted(ZiHuaDictionaryLib::CONFIG_KEY);
return $this->success(__('Saved successfully'));
return $this->success(__('Saved successfully'));
} finally {
GameHotDataLock::release(GameHotDataLock::TYPE_GAME_CONFIG, $resourceKey, $lock['token'], $lock['redis_lock']);
}
}
}

View File

@@ -4,7 +4,8 @@ namespace app\admin\controller\game;
use app\common\controller\Backend;
use app\common\library\game\ZiHuaDictionary as ZiHuaDictionaryLib;
use app\common\service\GameHotDataRedis;
use app\common\service\GameHotDataCoordinator;
use app\common\service\GameHotDataLock;
use InvalidArgumentException;
use support\think\Db;
use support\Response;
@@ -90,30 +91,39 @@ class ZiHuaDictionary extends Backend
}
$now = time();
try {
$exists = Db::name('game_config')->where('config_key', ZiHuaDictionaryLib::CONFIG_KEY)->find();
if ($exists) {
Db::name('game_config')->where('config_key', ZiHuaDictionaryLib::CONFIG_KEY)->update([
'config_value' => $json,
'value_type' => 'json',
'update_time' => $now,
]);
} else {
Db::name('game_config')->insert([
'config_key' => ZiHuaDictionaryLib::CONFIG_KEY,
'config_value' => $json,
'value_type' => 'json',
'remark' => '36字花字典 JSON 数组(独立表单维护)',
'create_time' => $now,
'update_time' => $now,
]);
}
} catch (Throwable $e) {
return $this->error($e->getMessage());
$resourceKey = GameHotDataLock::safeResourceKeyForConfig(ZiHuaDictionaryLib::CONFIG_KEY);
$lock = GameHotDataLock::tryAcquire(GameHotDataLock::TYPE_GAME_CONFIG, $resourceKey);
if (!$lock['acquired']) {
return $this->error('该配置正在被其他操作占用,请稍后再试');
}
try {
try {
$exists = Db::name('game_config')->where('config_key', ZiHuaDictionaryLib::CONFIG_KEY)->find();
if ($exists) {
Db::name('game_config')->where('config_key', ZiHuaDictionaryLib::CONFIG_KEY)->update([
'config_value' => $json,
'value_type' => 'json',
'update_time' => $now,
]);
} else {
Db::name('game_config')->insert([
'config_key' => ZiHuaDictionaryLib::CONFIG_KEY,
'config_value' => $json,
'value_type' => 'json',
'remark' => '36字花字典 JSON 数组(独立表单维护)',
'create_time' => $now,
'update_time' => $now,
]);
}
} catch (Throwable $e) {
return $this->error($e->getMessage());
}
GameHotDataRedis::gameConfigForget(ZiHuaDictionaryLib::CONFIG_KEY);
GameHotDataCoordinator::afterGameConfigKeyCommitted(ZiHuaDictionaryLib::CONFIG_KEY);
return $this->success(__('Saved successfully'));
return $this->success(__('Saved successfully'));
} finally {
GameHotDataLock::release(GameHotDataLock::TYPE_GAME_CONFIG, $resourceKey, $lock['token'], $lock['redis_lock']);
}
}
}

View File

@@ -4,6 +4,8 @@ namespace app\admin\controller\user;
use Throwable;
use app\common\controller\Backend;
use app\common\service\GameHotDataCoordinator;
use app\common\service\GameHotDataRedis;
use support\think\Db;
use support\Response;
use Webman\Http\Request as WebmanRequest;
@@ -214,8 +216,8 @@ class User extends Backend
}
$userIdRaw = $request->post('user_id');
$userId = is_numeric(strval($userIdRaw)) ? intval(strval($userIdRaw)) : 0;
if ($userId <= 0) {
$userId = filter_var($userIdRaw, FILTER_VALIDATE_INT);
if ($userId === false || $userId <= 0) {
return $this->error(__('Parameter error'));
}
@@ -243,69 +245,93 @@ class User extends Backend
$remark = '后台管理员(' . $adminName . '' . $actionText . $amountForRemark . '(值)';
}
$user = $this->model->where('id', $userId)->find();
if (!$user) {
return $this->error(__('Record not found'));
}
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds && !in_array($user[$this->dataLimitField], $dataLimitAdminIds)) {
return $this->error(__('You have no permission'));
$lock = GameHotDataRedis::userAdminMutationLockTry($userId);
if (!$lock['acquired']) {
return $this->error('该用户正在被其他管理员操作(钱包/并发保存),请稍后再试');
}
$channelIdRaw = $user['channel_id'] ?? null;
$channelId = is_numeric(strval($channelIdRaw)) ? intval(strval($channelIdRaw)) : null;
$before = strval($user['coin'] ?? '0');
$delta = self::normalizeAmountScale($amountText, 4);
if ($op === 'credit') {
$after = bcadd($before, $delta, 4);
$bizType = 'admin_credit';
$direction = 1;
} else {
if (bccomp($before, $delta, 4) < 0) {
return $this->error('余额不足,扣点失败');
}
$after = bcsub($before, $delta, 4);
$bizType = 'admin_deduct';
$direction = 2;
}
$now = time();
$idem = 'admin_adjust_' . $userId . '_' . $this->auth->id . '_' . $now . '_' . random_int(1000, 9999);
Db::startTrans();
try {
Db::name('user')->where('id', $userId)->update([
'coin' => $after,
'update_time' => $now,
]);
$user = $this->model->where('id', $userId)->find();
if (!$user) {
return $this->error(__('Record not found'));
}
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds && !in_array($user[$this->dataLimitField], $dataLimitAdminIds)) {
return $this->error(__('You have no permission'));
}
Db::name('user_wallet_record')->insert([
$channelIdRaw = $user['channel_id'] ?? null;
$channelId = filter_var($channelIdRaw, FILTER_VALIDATE_INT);
if ($channelId === false) {
$channelId = null;
}
$before = strval($user['coin'] ?? '0');
$delta = self::normalizeAmountScale($amountText, 4);
if ($op === 'credit') {
$after = bcadd($before, $delta, 4);
$bizType = 'admin_credit';
$direction = 1;
} else {
if (bccomp($before, $delta, 4) < 0) {
return $this->error('余额不足,扣点失败');
}
$after = bcsub($before, $delta, 4);
$bizType = 'admin_deduct';
$direction = 2;
}
$now = time();
$idem = 'admin_adjust_' . $userId . '_' . $this->auth->id . '_' . $now . '_' . random_int(1000, 9999);
$operatorId = filter_var($this->auth->id, FILTER_VALIDATE_INT);
if ($operatorId === false) {
$operatorId = 0;
}
Db::startTrans();
try {
$affected = Db::name('user')->where('id', $userId)->where('coin', $before)->update([
'coin' => $after,
'update_time' => $now,
]);
if ($affected !== 1) {
Db::rollback();
return $this->error('保存失败:该用户余额已被其他请求修改(如下注、派彩或其他管理员已保存),请刷新后重试');
}
Db::name('user_wallet_record')->insert([
'user_id' => $userId,
'channel_id' => $channelId,
'biz_type' => $bizType,
'direction' => $direction,
'amount' => $delta,
'balance_before' => $before,
'balance_after' => $after,
'ref_type' => 'admin_user_wallet_adjust',
'ref_id' => null,
'idempotency_key' => $idem,
'operator_admin_id' => $operatorId,
'remark' => substr($remark, 0, 500),
'create_time' => $now,
]);
Db::commit();
} catch (Throwable $e) {
Db::rollback();
return $this->error($e->getMessage());
}
GameHotDataCoordinator::afterUserCommitted($userId);
return $this->success('钱包调整成功', [
'user_id' => $userId,
'channel_id' => $channelId,
'biz_type' => $bizType,
'direction' => $direction,
'amount' => $delta,
'balance_before' => $before,
'balance_after' => $after,
'ref_type' => 'admin_user_wallet_adjust',
'ref_id' => null,
'idempotency_key' => $idem,
'operator_admin_id' => intval(strval($this->auth->id)),
'remark' => substr($remark, 0, 500),
'create_time' => $now,
'coin_before' => self::formatAmountForDisplay($before),
'coin_after' => self::formatAmountForDisplay($after),
'amount' => self::formatAmountForDisplay($delta),
'op' => $op,
]);
Db::commit();
} catch (Throwable $e) {
Db::rollback();
return $this->error($e->getMessage());
} finally {
GameHotDataRedis::userAdminMutationLockRelease($userId, $lock['token'], $lock['redis_lock']);
}
return $this->success('钱包调整成功', [
'user_id' => $userId,
'coin_before' => self::formatAmountForDisplay($before),
'coin_after' => self::formatAmountForDisplay($after),
'amount' => self::formatAmountForDisplay($delta),
'op' => $op,
]);
}
private static function normalizeAmountScale(string $amount, int $scale): string

View File

@@ -8,6 +8,7 @@ use app\common\library\game\ZiHuaDictionary;
use app\common\model\BetOrder;
use app\common\model\GameRecord;
use app\common\model\UserWalletRecord;
use app\common\service\GameHotDataCoordinator;
use app\common\service\GameHotDataRedis;
use app\common\service\UserPushService;
use support\think\Db;
@@ -175,6 +176,12 @@ class Game extends MobileBase
return $this->mobileError(3002, 'Betting is closed');
}
$userIdRaw = $this->auth->id ?? null;
$userId = filter_var($userIdRaw, FILTER_VALIDATE_INT);
if ($userId === false || $userId <= 0) {
return $this->mobileError(1001, 'Missing parameters');
}
$user = $this->auth->getUser();
if (bccomp((string) $user->coin, $totalAmount, 4) < 0) {
return $this->mobileError(2001, 'Insufficient balance');
@@ -185,64 +192,101 @@ class Game extends MobileBase
return $this->mobileError(3003, 'Duplicate request');
}
Db::startTrans();
$lock = GameHotDataRedis::userAdminMutationLockTry($userId);
if (!$lock['acquired']) {
return $this->mobileError(5000, '该用户正在被其他管理员操作(钱包/并发保存),请稍后再试');
}
try {
$before = (string) $user->coin;
$after = bcsub($before, $totalAmount, 4);
UserWalletRecord::create([
'user_id' => $user->id,
'channel_id' => $user->channel_id,
'biz_type' => 'bet',
'direction' => 2,
'amount' => $totalAmount,
'balance_before' => $before,
'balance_after' => $after,
'ref_type' => 'bet_order',
'remark' => '移动端下注',
'create_time' => time(),
]);
Db::name('user')->where('id', $user->id)->update(['coin' => $after, 'update_time' => time()]);
$orderNo = 'BO' . date('YmdHis') . substr(str_replace('.', '', uniqid('', true)), -6);
BetOrder::create([
'period_id' => $period->id,
'period_no' => $period->period_no,
'user_id' => $user->id,
'channel_id' => $user->channel_id,
'pick_numbers' => $numbers,
'total_amount' => $totalAmount,
'streak_at_bet' => $user->current_streak ?? 0,
'is_auto' => 0,
'status' => 1,
'idempotency_key' => $idempotencyKey,
'create_time' => time(),
'update_time' => time(),
]);
Db::commit();
$uid = filter_var($user->id, FILTER_VALIDATE_INT);
if ($uid !== false) {
GameHotDataRedis::userForget($uid);
$coinRow = Db::name('user')->where('id', $userId)->field(['coin', 'channel_id', 'current_streak'])->find();
if (!$coinRow) {
return $this->mobileError(5000, 'System is busy, please try again later');
}
UserPushService::publish((int) $user->id, UserPushService::EVT_BET_ACCEPTED, [
$before = (string) ($coinRow['coin'] ?? '0');
if (bccomp($before, $totalAmount, 4) < 0) {
return $this->mobileError(2001, 'Insufficient balance');
}
$existsLocked = BetOrder::where('idempotency_key', $idempotencyKey)->find();
if ($existsLocked) {
return $this->mobileError(3003, 'Duplicate request');
}
$channelIdRaw = $coinRow['channel_id'] ?? null;
$channelId = filter_var($channelIdRaw, FILTER_VALIDATE_INT);
if ($channelId === false) {
$channelId = null;
}
$now = time();
$after = bcsub($before, $totalAmount, 4);
$orderNo = 'BO' . date('YmdHis') . substr(str_replace('.', '', uniqid('', true)), -6);
$streakAtBet = (int) ($coinRow['current_streak'] ?? 0);
Db::startTrans();
try {
$affected = Db::name('user')->where('id', $userId)->where('coin', $before)->update([
'coin' => $after,
'update_time' => $now,
]);
if ($affected !== 1) {
Db::rollback();
return $this->mobileError(5000, '扣款失败:该用户余额已被其他请求修改(如下注、派彩或其他管理员已保存),请刷新后重试');
}
UserWalletRecord::create([
'user_id' => $userId,
'channel_id' => $channelId,
'biz_type' => 'bet',
'direction' => 2,
'amount' => $totalAmount,
'balance_before' => $before,
'balance_after' => $after,
'ref_type' => 'bet_order',
'remark' => '移动端下注',
'create_time' => $now,
]);
BetOrder::create([
'period_id' => $period->id,
'period_no' => $period->period_no,
'user_id' => $userId,
'channel_id' => $channelId,
'pick_numbers' => $numbers,
'total_amount' => $totalAmount,
'streak_at_bet' => $streakAtBet,
'is_auto' => 0,
'status' => 1,
'idempotency_key' => $idempotencyKey,
'create_time' => $now,
'update_time' => $now,
]);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return $this->mobileError(5000, 'System is busy, please try again later', ['detail' => $e->getMessage()]);
}
GameHotDataCoordinator::afterUserCommitted($userId);
UserPushService::publish($userId, UserPushService::EVT_BET_ACCEPTED, [
'order_no' => $orderNo,
'period_no' => (string) $period->period_no,
'status' => 'accepted',
'balance_after' => $after,
'total_amount' => $totalAmount,
'current_streak' => (int) ($user->current_streak ?? 0),
'current_streak' => $streakAtBet,
]);
} catch (\Throwable $e) {
Db::rollback();
return $this->mobileError(5000, 'System is busy, please try again later', ['detail' => $e->getMessage()]);
}
return $this->mobileSuccess([
'order_no' => $orderNo,
'period_no' => $period->period_no,
'status' => 'accepted',
'locked_balance' => '0.0000',
'balance_after' => $after,
'current_streak' => $user->current_streak ?? 0,
]);
return $this->mobileSuccess([
'order_no' => $orderNo,
'period_no' => $period->period_no,
'status' => 'accepted',
'locked_balance' => '0.0000',
'balance_after' => $after,
'current_streak' => $streakAtBet,
]);
} finally {
GameHotDataRedis::userAdminMutationLockRelease($userId, $lock['token'], $lock['redis_lock']);
}
}
public function betMyOrders(Request $request): Response

View File

@@ -3,7 +3,7 @@
namespace app\common\model;
use app\common\library\game\StreakWinReward;
use app\common\service\GameHotDataRedis;
use app\common\service\GameHotDataCoordinator;
use support\think\Model;
/**
@@ -24,7 +24,7 @@ class GameConfig extends Model
{
$key = trim((string) ($model->getAttr('config_key') ?? ''));
if ($key !== '') {
GameHotDataRedis::gameConfigForget($key);
GameHotDataCoordinator::afterGameConfigKeyCommitted($key);
}
if ($key === StreakWinReward::CONFIG_KEY) {
StreakWinReward::clearCache();
@@ -35,7 +35,7 @@ class GameConfig extends Model
{
$key = trim((string) ($model->getAttr('config_key') ?? ''));
if ($key !== '') {
GameHotDataRedis::gameConfigForget($key);
GameHotDataCoordinator::afterGameConfigKeyCommitted($key);
}
if ($key === StreakWinReward::CONFIG_KEY) {
StreakWinReward::clearCache();

View File

@@ -2,7 +2,7 @@
namespace app\common\model;
use app\common\service\GameHotDataRedis;
use app\common\service\GameHotDataCoordinator;
use support\think\Model;
class GameRecord extends Model
@@ -46,12 +46,12 @@ class GameRecord extends Model
public static function onAfterWrite(GameRecord $model): void
{
$id = filter_var($model->getAttr('id'), FILTER_VALIDATE_INT);
GameHotDataRedis::gameRecordForget($id === false ? null : $id);
GameHotDataCoordinator::afterGameRecordCommitted($id === false ? null : $id);
}
public static function onAfterDelete(GameRecord $model): void
{
$id = filter_var($model->getAttr('id'), FILTER_VALIDATE_INT);
GameHotDataRedis::gameRecordForget($id === false ? null : $id);
GameHotDataCoordinator::afterGameRecordCommitted($id === false ? null : $id);
}
}

View File

@@ -2,7 +2,7 @@
namespace app\common\model;
use app\common\service\GameHotDataRedis;
use app\common\service\GameHotDataCoordinator;
use support\think\Model;
/**
@@ -68,7 +68,7 @@ class User extends Model
{
$id = filter_var($model->getAttr('id'), FILTER_VALIDATE_INT);
if ($id !== false && $id > 0) {
GameHotDataRedis::userForget($id);
GameHotDataCoordinator::afterUserCommitted($id);
}
}
@@ -76,7 +76,7 @@ class User extends Model
{
$id = filter_var($model->getAttr('id'), FILTER_VALIDATE_INT);
if ($id !== false && $id > 0) {
GameHotDataRedis::userForget($id);
GameHotDataCoordinator::afterUserCommitted($id);
}
}
}

View File

@@ -131,7 +131,7 @@ final class GameBetSettleService
'current_streak' => $next,
'update_time' => $now,
]);
GameHotDataRedis::userForget($userId);
GameHotDataCoordinator::afterUserCommitted($userId);
}
foreach ($aggregateByUser as $userId => $agg) {
@@ -273,7 +273,7 @@ final class GameBetSettleService
'bet_flow_coin' => Db::raw('bet_flow_coin + ' . $flow),
'update_time' => $now,
]);
GameHotDataRedis::userForget($userId);
GameHotDataCoordinator::afterUserCommitted($userId);
}
/**
@@ -321,7 +321,7 @@ final class GameBetSettleService
'coin' => $after,
'update_time' => $now,
]);
GameHotDataRedis::userForget($userId);
GameHotDataCoordinator::afterUserCommitted($userId);
return $after;
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace app\common\service;
/**
* 缓存与队列统一入口:落库后先同步回源 Redis再入队幂等任务削峰。
*/
final class GameHotDataCoordinator
{
public static function afterUserCommitted(int $userId): void
{
if ($userId <= 0) {
return;
}
GameHotDataRedis::userReplaceCacheFromDb($userId);
GameHotDataWriteQueue::enqueue([
'op' => GameHotDataWriteQueue::OP_USER_REFRESH,
'id' => $userId,
]);
}
public static function afterGameConfigKeyCommitted(string $configKey): void
{
if ($configKey === '') {
return;
}
GameHotDataRedis::gameConfigReplaceFromDb($configKey);
GameHotDataWriteQueue::enqueue([
'op' => GameHotDataWriteQueue::OP_GC_REFRESH,
'key' => $configKey,
]);
}
/**
* @param int|null $recordId 有 id 时刷新该行并清除活跃/最新聚合键null 时仅清除聚合键
*/
public static function afterGameRecordCommitted(?int $recordId): void
{
GameHotDataRedis::gameRecordSyncCachesAfterDbWrite($recordId);
GameHotDataWriteQueue::enqueue([
'op' => GameHotDataWriteQueue::OP_GR_SYNC,
'id' => $recordId,
]);
}
}

View File

@@ -0,0 +1,132 @@
<?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;
}
}

View File

@@ -66,6 +66,45 @@ final class GameHotDataRedis
self::redisDel(self::KEY_GC . $configKey);
}
/**
* 按库中当前行覆盖 game_config 缓存(无行则删缓存)
*/
public static function gameConfigReplaceFromDb(string $configKey): void
{
if ($configKey === '' || !self::enabled()) {
return;
}
$row = Db::name('game_config')->where('config_key', $configKey)->find();
if (!$row) {
self::gameConfigForget($configKey);
return;
}
$ttl = self::intConfig('ttl_game_config', 86400);
self::redisSetEx(self::KEY_GC . $configKey, $ttl, json_encode($row, JSON_UNESCAPED_UNICODE));
}
/**
* 对局写入后:刷新指定 id 的行缓存,并删除「活跃局 / 最新局」聚合键以免脏读
*
* @param int|null $id 可为 null仅清聚合键
*/
public static function gameRecordSyncCachesAfterDbWrite(?int $id): void
{
if (!self::enabled()) {
return;
}
if ($id !== null && $id > 0) {
$row = Db::name('game_record')->where('id', $id)->find();
if ($row) {
$ttl = self::intConfig('ttl_game_record', 60);
self::redisSetEx(self::KEY_GR_ID . $id, $ttl, json_encode($row, JSON_UNESCAPED_UNICODE));
} else {
self::redisDel(self::KEY_GR_ID . $id);
}
}
self::redisDel(self::KEY_GR_ACTIVE, self::KEY_GR_LATEST);
}
/**
* @return array<string, mixed>|null
*/
@@ -212,6 +251,49 @@ final class GameHotDataRedis
self::redisDel(self::KEY_USER . $userId);
}
/**
* 从数据库读取最新 user 行并覆盖写入 Redis与 DB 同事务后调用,保持缓存与库一致)
*/
public static function userReplaceCacheFromDb(int $userId): void
{
if ($userId <= 0 || !self::enabled()) {
return;
}
$row = Db::name('user')->where('id', $userId)->find();
if (!$row) {
self::userForget($userId);
return;
}
$ttl = self::intConfig('ttl_user', 90);
self::redisSetEx(self::KEY_USER . $userId, $ttl, json_encode($row, JSON_UNESCAPED_UNICODE));
}
/**
* 尝试获取「后台修改该用户」互斥锁Redis SET NX EX
* 与热点缓存开关无关:只要 Redis 可用即加锁连接失败时降级为仅依赖数据库乐观锁WHERE coin=)。
*
* @return array{acquired: bool, token: ?string, redis_lock: bool}
*/
public static function userAdminMutationLockTry(int $userId): array
{
if ($userId <= 0) {
return ['acquired' => false, 'token' => null, 'redis_lock' => false];
}
return GameHotDataLock::tryAcquire(GameHotDataLock::TYPE_USER, (string) $userId);
}
/**
* 释放 userAdminMutationLockTry 取得的锁(仅当 redis_lock 且 token 非空)
*/
public static function userAdminMutationLockRelease(int $userId, ?string $token, bool $redisLock): void
{
if ($userId <= 0) {
return;
}
GameHotDataLock::release(GameHotDataLock::TYPE_USER, (string) $userId, $token, $redisLock);
}
/**
* 用缓存行构造已存在库的 User供 Auth 等高频读)
*/

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use support\Redis;
use Throwable;
/**
* 热点缓存回源刷新队列LPUSH / RPOP由独立进程消费削峰 Redis 与 DB 回读。
* 业务线程在落库后仍会同步刷新缓存;队列任务为幂等补偿,避免瞬时高峰打满连接。
*/
final class GameHotDataWriteQueue
{
public const OP_USER_REFRESH = 'user.refresh';
public const OP_GC_REFRESH = 'gc.refresh';
public const OP_GR_SYNC = 'gr.sync';
public static function queueListKey(): string
{
$k = config('game_hot_cache.queue_list_key', 'dfw:q:hot_data_write');
return is_string($k) && $k !== '' ? $k : 'dfw:q:hot_data_write';
}
public static function enabled(): bool
{
return filter_var(config('game_hot_cache.enable_cache_write_queue', true), FILTER_VALIDATE_BOOLEAN);
}
/**
* @param array<string, mixed> $job 须含 op 字段
*/
public static function enqueue(array $job): void
{
if (!self::enabled()) {
return;
}
if (!isset($job['op']) || !is_string($job['op']) || $job['op'] === '') {
return;
}
$job['v'] = 1;
$job['ts'] = time();
$max = self::maxQueueLength();
try {
if ($max > 0) {
$len = Redis::lLen(self::queueListKey());
if (is_int($len) && $len >= $max) {
Redis::rPop(self::queueListKey());
}
}
Redis::lPush(self::queueListKey(), json_encode($job, JSON_UNESCAPED_UNICODE));
} catch (Throwable) {
}
}
public static function maxQueueLength(): int
{
$v = config('game_hot_cache.queue_max_length', 50000);
$n = filter_var($v, FILTER_VALIDATE_INT);
return ($n === false || $n < 0) ? 50000 : $n;
}
}

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace app\common\service;
use app\common\library\game\StreakWinReward;
use app\common\service\GameHotDataCoordinator;
use app\common\service\GameHotDataLock;
use support\think\Db;
use Throwable;
use Webman\Push\Api;
@@ -253,13 +255,22 @@ final class GameLiveService
return ['ok' => false, 'msg' => __('This period has ended; please refresh the page')];
}
self::ensureAiLocked((int) $record['id']);
Db::name('game_record')->where('id', (int) $record['id'])->update([
'pending_draw_number' => $manualNumber,
'update_time' => time(),
]);
GameHotDataRedis::gameRecordForget((int) $record['id']);
self::publishSnapshot(null);
$rid = (int) $record['id'];
$lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, 1200);
if (!$lock['acquired']) {
return ['ok' => false, 'msg' => __('Another operation is in progress for this period; please try again later')];
}
try {
self::ensureAiLocked($rid);
Db::name('game_record')->where('id', $rid)->update([
'pending_draw_number' => $manualNumber,
'update_time' => time(),
]);
GameHotDataCoordinator::afterGameRecordCommitted($rid);
self::publishSnapshot(null);
} finally {
GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, $lock['token'], $lock['redis_lock']);
}
return [
'ok' => true,
@@ -289,8 +300,14 @@ final class GameLiveService
return ['ok' => false, 'msg' => __('Period countdown has not ended; cannot draw yet')];
}
self::ensureAiLocked((int) $record['id']);
$record = self::reloadRecord((int) $record['id']);
$rid = (int) $record['id'];
$lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, 2000);
if (!$lock['acquired']) {
return ['ok' => false, 'msg' => __('Another operation is in progress for this period; please try again later')];
}
try {
self::ensureAiLocked($rid);
$record = self::reloadRecord($rid);
if (!$record) {
return ['ok' => false, 'msg' => __('No active game in progress')];
}
@@ -346,10 +363,10 @@ final class GameLiveService
return ['ok' => false, 'msg' => __('Game live: settlement error') . ': ' . $e->getMessage()];
}
GameHotDataRedis::gameRecordForget((int) $record['id']);
GameHotDataCoordinator::afterGameRecordCommitted($rid);
try {
GameRecordStatService::refreshForRecordId((int) $record['id']);
GameRecordStatService::refreshForRecordId($rid);
} catch (Throwable) {
}
JackpotPushService::publishHits($settleOut['jackpot_hits'] ?? []);
@@ -365,6 +382,9 @@ final class GameLiveService
'estimated_loss' => $finalLoss,
'payout_until' => $payoutUntil,
];
} finally {
GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, $lock['token'], $lock['redis_lock']);
}
}
/**
@@ -382,22 +402,30 @@ final class GameLiveService
return;
}
$id = (int) $row['id'];
Db::startTrans();
try {
Db::name('game_record')->where('id', $id)->update([
'status' => 4,
'payout_until' => null,
'update_time' => time(),
]);
GameRecordService::createNextRecordAfterDraw();
Db::commit();
} catch (Throwable) {
Db::rollback();
$lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $id, 2000);
if (!$lock['acquired']) {
return;
}
GameHotDataRedis::gameRecordForget($id);
GameRecordStatService::refreshForRecordId($id);
self::publishSnapshot(null);
try {
Db::startTrans();
try {
Db::name('game_record')->where('id', $id)->update([
'status' => 4,
'payout_until' => null,
'update_time' => time(),
]);
GameRecordService::createNextRecordAfterDraw();
Db::commit();
} catch (Throwable) {
Db::rollback();
return;
}
GameHotDataCoordinator::afterGameRecordCommitted($id);
GameRecordStatService::refreshForRecordId($id);
self::publishSnapshot(null);
} finally {
GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $id, $lock['token'], $lock['redis_lock']);
}
}
public static function tickAutoDraw(): void
@@ -609,7 +637,7 @@ final class GameLiveService
'status' => 1,
'update_time' => time(),
]);
GameHotDataRedis::gameRecordForget($recordId);
GameHotDataCoordinator::afterGameRecordCommitted($recordId);
$record['status'] = 1;
self::publishPublicPeriodLocked($record);
}
@@ -629,7 +657,7 @@ final class GameLiveService
$update['status'] = 1;
}
Db::name('game_record')->where('id', $recordId)->update($update);
GameHotDataRedis::gameRecordForget($recordId);
GameHotDataCoordinator::afterGameRecordCommitted($recordId);
$record = array_merge($record, $update);
if ($st === 0) {
self::publishPublicPeriodLocked($record);

View File

@@ -99,7 +99,7 @@ final class GameRecordService
'create_time' => $now,
'update_time' => $now,
]);
GameHotDataRedis::gameRecordForget();
GameHotDataCoordinator::afterGameRecordCommitted(null);
return $periodNo;
}
@@ -123,7 +123,7 @@ final class GameRecordService
'remark' => $remark,
'update_time' => $now,
]);
GameHotDataRedis::gameConfigForget($key);
GameHotDataCoordinator::afterGameConfigKeyCommitted($key);
return;
}
Db::name('game_config')->insert([
@@ -134,6 +134,6 @@ final class GameRecordService
'create_time' => $now,
'update_time' => $now,
]);
GameHotDataRedis::gameConfigForget($key);
GameHotDataCoordinator::afterGameConfigKeyCommitted($key);
}
}

View File

@@ -33,7 +33,7 @@ final class GameRecordStatService
'winner_user_count' => 0,
'update_time' => $now,
]);
GameHotDataRedis::gameRecordForget($recordId);
GameHotDataCoordinator::afterGameRecordCommitted($recordId);
return;
}
@@ -79,7 +79,7 @@ final class GameRecordStatService
'winner_user_count' => count($winnerUserIds),
'update_time' => $now,
]);
GameHotDataRedis::gameRecordForget($recordId);
GameHotDataCoordinator::afterGameRecordCommitted($recordId);
}
/**

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace app\process;
use app\common\service\GameHotDataRedis;
use app\common\service\GameHotDataWriteQueue;
use support\Redis;
use Throwable;
use Workerman\Timer;
/**
* 消费热点缓存回源队列RPOP 批量),与 HTTP 进程分离以削峰。
*/
class GameHotDataQueueConsumer
{
public function onWorkerStart(): void
{
$interval = config('game_hot_cache.queue_consumer_tick_seconds', 0.1);
if (!is_float($interval) && !is_int($interval)) {
$interval = 0.1;
}
$interval = (float) $interval;
if ($interval < 0.02) {
$interval = 0.02;
}
Timer::add($interval, static function (): void {
if (!GameHotDataWriteQueue::enabled()) {
return;
}
$key = GameHotDataWriteQueue::queueListKey();
$batch = config('game_hot_cache.queue_consumer_batch', 80);
$n = filter_var($batch, FILTER_VALIDATE_INT);
if ($n === false || $n < 1) {
$n = 80;
}
for ($i = 0; $i < $n; $i++) {
try {
$raw = Redis::rPop($key);
} catch (Throwable) {
break;
}
if ($raw === false || $raw === null || $raw === '') {
break;
}
if (!is_string($raw)) {
continue;
}
$job = json_decode($raw, true);
if (!is_array($job)) {
continue;
}
$op = $job['op'] ?? '';
if (!is_string($op) || $op === '') {
continue;
}
try {
if ($op === GameHotDataWriteQueue::OP_USER_REFRESH) {
$uid = filter_var($job['id'] ?? null, FILTER_VALIDATE_INT);
if ($uid !== false && $uid > 0) {
GameHotDataRedis::userReplaceCacheFromDb($uid);
}
continue;
}
if ($op === GameHotDataWriteQueue::OP_GC_REFRESH) {
$k = $job['key'] ?? '';
if (is_string($k) && $k !== '') {
GameHotDataRedis::gameConfigReplaceFromDb($k);
}
continue;
}
if ($op === GameHotDataWriteQueue::OP_GR_SYNC) {
$rid = filter_var($job['id'] ?? null, FILTER_VALIDATE_INT);
GameHotDataRedis::gameRecordSyncCachesAfterDbWrite($rid === false ? null : $rid);
}
} catch (Throwable) {
}
}
});
}
}

View File

@@ -25,4 +25,16 @@ return [
'ttl_game_record' => $envInt('GAME_HOT_CACHE_TTL_GAME_RECORD', 60),
/** user 行缓存(秒),余额/连胜变更会主动删除 */
'ttl_user' => $envInt('GAME_HOT_CACHE_TTL_USER', 90),
/** 后台对同一用户互斥操作(如钱包加减点)的 Redis 锁 TTL */
'admin_user_mutation_lock_ttl' => $envInt('GAME_HOT_CACHE_ADMIN_USER_LOCK_TTL', 30),
/** 是否启用缓存回源队列(独立进程消费) */
'enable_cache_write_queue' => filter_var(env('GAME_HOT_CACHE_ENABLE_WRITE_QUEUE', true), FILTER_VALIDATE_BOOLEAN),
/** 队列 Redis List 键名 */
'queue_list_key' => env('GAME_HOT_CACHE_QUEUE_LIST_KEY', 'dfw:q:hot_data_write'),
/** 队列最大长度,超出则丢弃最旧一条再入队 */
'queue_max_length' => $envInt('GAME_HOT_CACHE_QUEUE_MAX_LENGTH', 50000),
/** 消费者 Timer 间隔(秒) */
'queue_consumer_tick_seconds' => (float) (env('GAME_HOT_CACHE_QUEUE_CONSUMER_TICK', '0.1')),
/** 每轮最多处理任务数 */
'queue_consumer_batch' => $envInt('GAME_HOT_CACHE_QUEUE_CONSUMER_BATCH', 80),
];

View File

@@ -46,6 +46,12 @@ return [
'count' => 1,
'reloadable' => false,
],
/** 热点缓存写队列消费者(与 GAME_HOT_CACHE_ENABLE_WRITE_QUEUE 配合) */
'gameHotDataQueueConsumer' => [
'handler' => app\process\GameHotDataQueueConsumer::class,
'count' => 1,
'reloadable' => false,
],
// File update detection and automatic reload
'monitor' => [

View File

@@ -279,7 +279,7 @@
### 8.4 非功能
- **Redis已落地**
- **热点数据缓存**`app/common/service/GameHotDataRedis.php`,依赖 `webman/redis``config/redis.php`。缓存对象:`user` 行(鉴权与余额类读路径)、`game_config` 行(按 `config_key`)、`game_record`(活跃局、按 id、最新一条。写库后通过模型事件或服务内 **`gameRecordForget` / `gameConfigForget` / `userForget`** 失效;环境变量 `GAME_HOT_CACHE_*` 控制开关TTL`.env-example`)。
- **热点数据缓存**`app/common/service/GameHotDataRedis.php`,依赖 `webman/redis``config/redis.php`。缓存对象:`user` 行(鉴权与余额类读路径)、`game_config` 行(按 `config_key`)、`game_record`(活跃局、按 id、最新一条**写库后一致性**:统一经 **`GameHotDataCoordinator`**`afterUserCommitted` / `afterGameConfigKeyCommitted` / `afterGameRecordCommitted`)先 **同步回源**`userReplaceCacheFromDb``gameConfigReplaceFromDb``gameRecordSyncCachesAfterDbWrite`),再视配置将幂等任务写入 **`GameHotDataWriteQueue`**,由进程 **`GameHotDataQueueConsumer`** 批量消化以削峰。互斥:**`GameHotDataLock`** 用于同一用户、同一 `game_config` 键、同一对局记录等资源的并发写串行化(后台钱包加减点、独立配置表单、开奖相关服务、**移动端 `betPlace` 扣款**等与后台共用用户锁)。环境变量 `GAME_HOT_CACHE_*` 控制开关TTL、队列开关与背压(见 `.env-example``config/game_hot_cache.php`)。
- **规划/增强**(未替代上述):当前期注单池、近 30 期开奖列表缓存、纯 Redis 状态机等,可按压测需求迭代。
- **与 `CACHE_DRIVER`**`config/cache.php` 默认 `file` 仅影响 **系统配置表 `config`**(如 `get_sys_config`**不**控制 `GameHotDataRedis`
- MQ派彩异步消费**幂等**(如 `period_id` + 用户 + 业务单号)。
@@ -349,6 +349,7 @@
| V1.11 | 2026-04-16 | 落地运营公告:`operation_notice``user_notice_read` 表与菜单目录 `operation`(运营公告、用户阅读记录) |
| V1.12 | 2026-04-18 | 下注口径:`bet_order` 仅保留 `total_amount`(整笔压注),删除 `unit_amount``pick_count`DDL 与 Phinx 迁移 `20260418270000_bet_order_drop_unit_amount_pick_count` 对齐 |
| V1.14 | 2026-04-20 | 服务端 Redis 热点缓存:`GameHotDataRedis``user` / `game_config` / `game_record``config/game_hot_cache.php``.env-example``GAME_HOT_CACHE_*`;更新 §1.1、§2.1、§8.4、第九章风险与依赖、附录 `user`;与 `CACHE_DRIVER`(系统 `config` 表文件缓存)区分说明 |
| V1.15 | 2026-04-20 | 热点写路径收口:`GameHotDataCoordinator` + `GameHotDataLock` + `GameHotDataWriteQueue` / `GameHotDataQueueConsumer`;文档与实现对齐(替代仅 `*Forget` 描述);移动端 `betPlace` 与后台钱包共用用户互斥锁及 `coin` 乐观更新 |
---
@@ -381,7 +382,7 @@
| `total_deposit_coin` / `total_withdraw_coin` | 累计充值入账、累计提现出账(提现受理时累加);与「净充值」口径配合校验提现门槛 |
| `bet_flow_coin` | 打码量/流水(历史名 `total_valid_bet_coin`2026-04-18 迁移重命名);开奖结算成功时按注单 `total_amount` 1:1 累加,提现门槛 `bet_flow_coin >= (total_deposit_coin - total_withdraw_coin) × withdraw_bet_flow_ratio` |
| `risk_flags` | 风控位(如禁止登录/下注/提现,按位定义) |
| `current_streak` / `last_bet_period_no` | 连胜与期号兜底;**读路径**可通过 **`GameHotDataRedis` 缓存 `user` 行** 降低压力,**写路径**以下注/结算后落库为准并主动失效缓存 |
| `current_streak` / `last_bet_period_no` | 连胜与期号兜底;**读路径**可通过 **`GameHotDataRedis` 缓存 `user` 行** 降低压力,**写路径**以下注/结算后落库为准,并经 **`GameHotDataCoordinator::afterUserCommitted`** 同步刷新 Redis 与写队列入队 |
| `admin_id` | 归属子代理管理员 |
| 其余 | 账号、头像、状态、时间戳等见 DDL |

View File

@@ -105,19 +105,21 @@
**服务端缓存覆盖(与移动端直接相关的读路径)**
- **用户**:会员鉴权 `Auth::init` 优先读 Redis 中的 `user` 行快照,未命中再查库并回填**余额、连胜、打码量等变更**后服务端会删除该用户缓存,下次请求重新加载
- **游戏配置**`game_config``config_key` 缓存(如字典、`pick_max_number_count``withdraw_bet_flow_ratio`、连胜配置等);后台或脚本直连 `Db` 更新配置时,保存逻辑需调用 **`GameHotDataRedis::gameConfigForget($key)`**(已实现于模型事件及部分后台控制器),否则最长延迟为 TTL。
- **对局**:当前活跃局、按 `id` 加载的局、以及「最新一条 `game_record`」(与 `order id desc limit 1` 一致)缓存;**开奖封盘、新建下一期**等写库后会 **`gameRecordForget`**,保证关键状态尽快一致
- **用户**:会员鉴权优先读 Redis 中的 `user` 行快照,未命中再查库并回填**余额、连胜、打码量等变更**落库后,统一经 **`GameHotDataCoordinator::afterUserCommitted($userId)`**:先 **`GameHotDataRedis::userReplaceCacheFromDb`** 与 DB 对齐,再向 Redis 写队列投递幂等刷新任务(见 `GameHotDataWriteQueue` / `GameHotDataQueueConsumer`),用于削峰而非替代同步回源
- **游戏配置**`game_config``config_key` 缓存。后台直连 `Db` 更新时须 **`GameHotDataCoordinator::afterGameConfigKeyCommitted($key)`**(模型 `GameConfig` 事件与独立表单控制器已接入);独立保存接口在写入前对同一 `config_key` 使用 **`GameHotDataLock``TYPE_GAME_CONFIG`** 互斥。勿仅删除缓存键而不回源,否则最长不一致窗口为 TTL。
- **对局**:当前活跃局、按 `id` 的局、最新一条 `game_record` 等;写库后经 **`GameHotDataCoordinator::afterGameRecordCommitted`** 同步刷新相关 Redis 键并入队。开奖/封盘等路径另可按记录 id 使用 **`GameHotDataLock``TYPE_GAME_RECORD`** 串行化
**环境变量(示例见仓库根目录 `.env-example`**
- `GAME_HOT_CACHE_ENABLED`:是否启用上述 Redis 热点缓存(`false` 时全程回退数据库)。
- `GAME_HOT_CACHE_TTL_GAME_CONFIG` / `GAME_HOT_CACHE_TTL_GAME_RECORD` / `GAME_HOT_CACHE_TTL_USER`:各类缓存 TTL仍应以**写后失效**为主TTL 仅作兜底。
- `GAME_HOT_CACHE_TTL_GAME_CONFIG` / `GAME_HOT_CACHE_TTL_GAME_RECORD` / `GAME_HOT_CACHE_TTL_USER`:各类缓存 TTL**写后同步回源为主**TTL 仅作兜底。
- `GAME_HOT_CACHE_ENABLE_WRITE_QUEUE` 及队列长度、消费进程间隔等:控制写库后的**幂等刷新任务**是否入队及背压策略(见 `config/game_hot_cache.php`)。
**一致性提示(联调/测试)**
- 在 TTL 窗口内,若存在**绕过模型事件、且未调用 forget** 的手工 SQL 改库,可能出现短时不一致;生产环境应避免。
- 客户端仍可按 **§3.2 `dictionaryList``version`** 做本地缓存;服务端字典数据另有 Redis 加速,二者可同时存在
- 任何绕过协调入口、只改 DB 不调用 **`GameHotDataCoordinator`** 的手工脚本,都可能与 Redis 短期不一致;生产环境应避免。
- **`POST /api/game/betPlace`** 扣款路径使用与后台钱包加减点相同的 **用户维度 Redis 锁**`GameHotDataRedis::userAdminMutationLockTry`)及 **`WHERE coin = ?` 条件更新**,与并发派彩/后台调账互斥;失败时返回 **§4.2** 所列中文说明
- 客户端仍可按 **§3.2 `dictionaryList``version`** 做本地缓存;服务端字典另有 Redis 加速,二者可同时存在。
---
@@ -289,6 +291,10 @@
- `balance_after`string含义下单后余额
- `current_streak`int含义下单后连胜快照
**可能错误码(补充)**(其余见文档头部错误码分段;扣款与缓存一致性强相关):
- `5000`:系统繁忙;或 **用户 Redis 互斥锁**未获取(与后台钱包/并发写同一用户串行,文案与后台一致:「该用户正在被其他管理员操作(钱包/并发保存),请稍后再试」);或 **`coin` 条件更新**未命中(并发下注/派彩/后台已改余额:「扣款失败:该用户余额已被其他请求修改(如下注、派彩或其他管理员已保存),请刷新后重试」)。
> 说明:一键重复上一注、自动托管开启/停止均由前端控制,客户端在相应时机调用 `/api/game/betPlace` 即可完成,不再提供独立接口。
### 4.3 查询我的下注记录最近1个月