分布式API接口用户请求次数同步技术方案,实现过程简单,小白也可以看懂
背景
易客云天气api提供接口服务, 项目初期, 天气API在分布式API服务架构中,每条接口线路对应不同的服务器,我们并没有做中间redis服务器,而是每台服务器独立统计,例如我们每个用户限制当日最多可以请求10万次,用户在线路A请求了接口10万次,超额报错之后再请求线路B,或者两条线路随机访问,当日一共可以请求20万次,为了防止用户薅羊毛,我们必须得把次数统一,降低服务器带宽。
目标
实现跨服务器节点的用户请求计数实时同步,确保每日请求限额的准确性和一致性,同时兼顾系统性能和可扩展性,方便后期扩展新的线路服务器。
实现思路流程图

代码思路
1. redis次数同步中间服务器
- 设置每台服务器标识, key的规则为:uid+日期+服务器标识
- 设置总的请求次数的key, 就是所有服务器加起来的次数
- 同步线路服务器发来的次数, 更新总次数值, 并返回线路服务器需要增加的次数
- 可以查询接口可用剩余次数
2. 线路服务器
- 创建一个同步任务表, 配合crontab 一小时执行一次, 插入所有用户当前的请求次数, 并标记为待处理, 表的关键字段(处理状态, 用户ID, 当前请求次数)
- 执行shell脚本, 1s一次请求任务表, 执行同步代码, 处理当前用户的同步次数
- 线路服务器在请求接口的时候, 验证同步后的可用次数
实现示例
redis中间服务器、线路次数同步代码、线上接口限制代码
1. redis中间服务器
php
<?php
/**
* 1. 同步接口
* 接收单台线路服务器的本地计数并同步到全局, 返回可用次数等
*/
private $dailyLimit = 100000; // 单用户每日总限额
private $redisKeyPrefix = 'user_total_'; // 用户全局计数Key前缀
function syncLocalCount($userId, $serverId, $localCount, $date)
{
if ($localCount <= 0) {
return [
'code' => 400,
'msg' => '同步计数需大于0',
'data' => []
];
}
$redisKey = $this->redisKeyPrefix . $userId . '_' . $date;
$serverLocalKey = "server_local_count_{$userId}_{$date}_{$serverId}"; //记录该服务器已同步的数,避免重复同步
$limitkey = 'limit_' . $userId;//每个用户的请求次数可能也不一样
$this->dailyLimit = $this->redis->get($limitkey);
$currentGlobalCount = $this->redis->get($redisKey) ?: 0;
// 步骤1:检查该服务器本次同步的数是否已处理(防重复同步)
$syncedCount = $this->redis->get($serverLocalKey) ?: 0;
$needSyncCount = $localCount - $syncedCount; //仅同步新增的计数
if ($needSyncCount <= 0) {
//无需同步也返回一下当日可用次数
return [
'code' => 200,
'msg' => '无新增计数需同步',
'data' => [
'server_id' => $serverId,
'synced_count' => (int)$syncedCount,
'local_count' => $localCount,
'need_sync_count' => 0,
'current_global_count' => (int)$currentGlobalCount,
'dailyLimit' => (int)$this->dailyLimit,
'available_count' => $this->dailyLimit - (int)$currentGlobalCount
]
];
}
// 步骤2:原子累加全局计数(核心,保证多服务器同步不冲突)
$this->redis->incrBy($redisKey, $needSyncCount);
// 步骤3:更新该服务器已同步的计数(标记已处理)
$this->redis->set($serverLocalKey, $localCount);
// 步骤4:设置全局Key过期时间(25小时,次日自动重置)
if (!$this->redis->ttl($redisKey) || $this->redis->ttl($redisKey) < 0) {
$this->redis->expire($redisKey, 86400 + 3600);
$this->redis->expire($serverLocalKey, 86400 + 3600); // 同步Key同过期
}
// 步骤5:返回同步结果
$currentGlobalCount = $this->redis->get($redisKey) ?: 0;
return [
'code' => 0,
'msg' => '计数同步成功',
'data' => [
'user_id' => $userId,
'server_id' => $serverId,
'local_count' => $localCount,
'synced_count' => (int)$syncedCount,
'need_sync_count' => $needSyncCount,
'current_global_count' => (int)$currentGlobalCount,
'dailyLimit' => (int)$this->dailyLimit,
'available_count' => $this->dailyLimit - (int)$currentGlobalCount
]
];
}
线路同步代码
php
// 更新所有本地用户, 写入同步任务表
public function sync()
{
$serverId = strtoupper(substr(hash('md5', gethostname() . file_get_contents('/sys/class/net/eth0/address')), 0, 8));
$datefix = date('Ymd');
// 场景:更新所有本地用户(遍历本地计数Key)
$localCountPattern = $datefix . '_*';
$keys = $this->redis->keys($localCountPattern);
$uptime = time();
$params = array();
foreach ($keys as $key) {
$appid = $key;
$params[] = array(
'zt' => 0,
'uptime' => $uptime,
'server' => $serverId,
'appid' => $appid,
'total' => (int)$this->redis->get($key)//本地总请求次数,
);
}
$this->db->insert('account_sync', $params);
echo $this->db->last();
}
// 执行同步任务
public function synctask()
{
$serverId = strtoupper(substr(hash('md5', gethostname() . file_get_contents('/sys/class/net/eth0/address')), 0, 8));
$row = $this->db->get('account_sync', '*', ['zt' => 0, 'server' => $serverId]);
if (empty($row)) {
exit('empty');
}
$this->db->update('account_sync', ['zt' => 1], ['id' => $row['id']]);
$datefix = date('Ymd', $row['uptime']);
$local_count = (int)$row['total'];
$api_url = 'http://1.1.1.1:9001/?action=sync_local&date=' . $datefix . '&appid=' . $row['appid'] . '&server_id=' . $serverId . '&local_count=' . $local_count;
$data = $this->getget($api_url);
$json = json_decode($data, true);
print_r($json);//输出请求结果
if (intval($json['data']['dailyLimit'])) {
echo 'server_limit_' . $datefix . '_' . $row['appid'] . ' > ' . intval($json['data']['dailyLimit']);
$this->redis->set('server_limit_' . $datefix . '_' . $row['appid'], intval($json['data']['dailyLimit']));//总可以请求次数
$this->redis->set('server_remain_' . $datefix . '_' . $row['appid'], intval($json['data']['available_count']));//服务器总的剩余可用请求次数,同步自Redis中间
echo $this->redis->get('server_limit_' . $datefix . '_' . $row['appid']) . ' - ';
echo $this->redis->get('server_remain_' . $datefix . '_' . $row['appid']) . '<br>';
exit('已更新' . json_encode($json, JSON_UNESCAPED_UNICODE));
}
}
接口验证代码
php
$dailyLimit = (int)$this->redis->get('server_limit_' . $datefix . $this->appid);//同步后可以请求次数
$num = (int)$this->redis->get($datefix . $this->appid);//本地统计次数
if ($num < 1) {
$this->redis->setex($datefix . $this->appid, 259200, 1);//3days
}
if ($dailyLimit > 0) {
//服务器总的剩余可用请求次数,同步自Redis中间人
$remainCount = (int)$this->redis->get('server_remain_' . $datefix . $this->appid);
// 本地校验:剩余次数≤0 或 本地已用≥剩余 → 拒绝请求
if ($remainCount < 1) {
Help::print_json(100, '调用次数过高[' . $dailyLimit . ', ' . $remainCount . ', ' . $num . ']');
}
}
方案总结
主要难点就是redis中间服务器的次数处理, 线路服务器能同步到次数, 然后增加可用验证就可以了
扩展考虑
- 新增线路服务器部署一下同步代码, 配置下crontab和shell就可以了
- 针对不同的接口进行限制
- 增加每个用户的可请求次数不统一限制