1、投票要求
(1)投票系统规则
每个ip每天限投10票,10票可同一选项,也可不同选项
(2)访问量要求日均1000万
按照80/20法则,系统的每秒要达到并发量:1000万*80%/(24*3600*20%)≈463。
(3)计数要准确,支持高并发,每个用户的投票记录也要保存
2、实现
投票数据先放在redis队列中,再通过定时任务把投票日志保存到mysql,投票计数器保存在txt文件中。
2.1、用户点击投票操作
在用户进行投票点击时,投票传输到服务器端,服务器端进行3个步骤处理:
(1)用户答案按以ip为key保存到redis中,避免用户重复提交
(2)用户答案保存到redis的lPush的队列中,等待后台程序读取入库保存
(3)同时标记当前ip的用户投票未入库,前台通过这个标记判断,让当前ip用户实时看到投票数据更新
<?php
include_once "VoteUtils.php";
session_start();
$vote = new VoteUtils();
$vote->openRedis();
// 1、获取客户端IP
$ip = $vote->getClientIp();
// 2、获取用户投票选项,
$ids = $vote->getUserOptions();
// 3、判断当前ip是否已有投票记录
$vote_options = $vote->getVoteOption($ip);
$vote_count = count($vote_options);
// 4.1如果已有投票记录、提示重复投票
if($vote_count > 0){ // 重复投票
$result = ['status'=>2, 'msg'=>'success','vote_count'=>$vote_count,'vote_options'=>$ids, 'vote_options2'=>$vote_options];
}
else{
// 4.2如果没有记录,保存数据
if(count($ids) == 10){
// 投票选项保存到redis,按key='ip_vote_city_data_' . $ip保存,避免用户重复投票
$vote->setVoteOption($ip, $ids);
// 添加redis投票队列,按lPush队列key=vote_topic保存
$vote->addQueues($ip, $ids);
// 标明当前ip的投票选项未入库标志,让当前ip用户实时看到投票数据更新
$vote->setVoteCompute($ip);
$result = ['status'=>0, 'msg'=>'success','vote_count'=>count($ids),'vote_options'=>$ids];
}
else{ // 当前投票选项未足10票
$result = ['status'=>1, 'msg'=>'error','vote_count'=>count($ids),'vote_options'=>$ids];
}
}
$vote->closeRedis();
echo json_encode($result);
?>
2.2、服务器端后台任务处理
后台通过守护进程一直读取队列中的投票数据,并把数据持久化保存:
(1)通过redis的rPop读取队列中的投票记录,把投票日志批量保存到mysql。
(2)投票总数保存到文件中。
(3)清除用户投票操作(3)中的未入库状态
class VoteController extends Controller
{
private function getRedis(){
$redis = new \Redis();
$redis->connect('127.0.0.1', 6379);
return $redis;
}
public function actionRedisVote(){
$redis = $this->getRedis();
$data_count = 0;
$datas = array();
$updateStatus = 0;
while(true){
$data = $redis->rPop('vote_topic');
$updateStatus++;
if($updateStatus % 500 == 0){
$updateStatus = 0;
}
// 如果队列中没有新数据,先把变量中已有数据保存
if(empty($data) == true ){
// 保存数据
if( count($datas) > 0){
$this->saveLog($datas, $redis);
$data_count = 0;
unset($datas);
$datas = array();
}
sleep(10);
}
else{
$data_count++;
$datas[] = $data;
// 当达到500条投票记录,批量保存
if($data_count >= 500){
// 保存数据
$this->saveLog($datas, $redis);
$data_count = 0;
unset($datas);
$datas = array();
}
}
}
}
private function getIp($ip){
$ipinfo = IpTool::getInfo($ip);
$region = empty($ipinfo['region']) == false ? $ipinfo['region'] : '';
$country = empty($region) == false ? explode('|', $region)[0] : '';
return $country;
}
private function saveLog($datas, $redis){
$rows = [];
$dataCount = [];
foreach($datas as $data){
$data = json_decode($data, true);
if(empty($data) == false){
$client_ip = $data[0];
$country = $this->getIp($client_ip);
$city_ids = $data[1];
$create_time = $data[2];
$user_agent = $data[3];
$create_date = date('Y-m-d H:i:s', $create_time);
$create_day = date('md', $create_time);
$create_hour = date('mdH', $create_time);
$create_minute = date('mdHi', $create_time);
foreach($city_ids as $city_id){
$dataCount[$city_id] = isset($dataCount[$city_id]) == true ? $dataCount[$city_id] + 1 : 1;
$client_browser = mb_strlen($user_agent, 'UTF-8') < 200 ? $user_agent : mb_substr($user_agent, 0, 200, 'UTF-8');
$rows[] = [
$city_id,
$client_ip,
$country,
$client_browser,
$create_date,
$create_day,
$create_hour,
$create_minute,
];
}
$vote_compute_key = 'ip_vote_compute_key_'.$client_ip;
// 删除当前ip的投票选项未入库标记
$redis->del($vote_compute_key);
}
}
VoteLog::getDb()->createCommand()->batchInsert(
'vote_log',
['city_id', 'client_ip', 'country', 'client_browser','create_date','create_day','create_hour', 'create_minute'],
$rows
)->execute();
$this->updateDbCount($dataCount);
}
/**
* 更新本地记录数,把每个选项的记录数保存到文件中
*/
public function updateDbCount($dataCount){
$data = array();
$filePath = Yii::$app->basePath.'/count/';
$filenames = $this->getPathFiles($filePath);
if(count($filenames) == 0){
for($i = 1; $i <= 15; $i++){
$file = $filePath . $i;
if(is_file($file) == false){
file_put_contents($file, 0);
}
$filenames[] = strval($i);
}
}
if(count($filenames) > 0){
$total = 0;
foreach($filenames as $name){
$id = $name;
$idInt = intval($id);
if($idInt > 0 && $idInt < 20){
$file = $filePath . $name;
$count = intval(file_get_contents($file));
// 新增加的投票
if(isset($dataCount[$id]) == true){
$count = $count + $dataCount[$id];
file_put_contents($file, $count);
}
$total = $total + $count;
$data[$id] = $count;
$is_success = VoteCity::getDb()->createCommand(" update vote_city set vote_count = {$count} where id = {$idInt} ")->execute();
}
}
$data['100'] = $total;
$data['time'] = time();
file_put_contents($filePath.'100', json_encode($data));
chmod($filePath.'100', 0755);
}
}
}
2.3、用户访问投票页面
投票页面内容纯html化,放在通过cdn降低服务器压力,另外像当前票数这些数据是实时变动的,需要通过动态接口获取:
(1)读取投票总数文件,如果用户存在答案未入库状态的,把当前用户投票记录也要加进总数中,这样用户投票记录虽然还在队列中,但是也能让用户实时看到投票数量变化。
<?php
include_once 'VoteUtils.php';
session_start();
$vote = new VoteUtils();
$vote->openRedis();
$ip = $vote->getClientIp();
$voteOptions = $vote->getVoteOption($ip);
// 基于文件缓存
$dataCount = $vote->getFileVoteCount();
// 检查数据是否已经被统计
$isVoteCompute = $vote->isVoteCompute($ip);
if(empty($voteOptions) == false && $isVoteCompute == false){
foreach($voteOptions as $option){
if(isset($dataCount[$option]) == true){
$dataCount[$option] = $dataCount[$option] + 1;
}
}
}
$result = [
'vote_options'=>$voteOptions,
'data_count'=>$dataCount,
'ip'=>$ip,
];
$vote->closeRedis();
echo json_encode($result);
VoteUtils.php
<?php
class VoteUtils
{
public $redis;
public $expire_time = 86400; // 24小时
function __construct() {
}
public function openRedis(){
$this->redis = new Redis();
$this->redis->connect('127.0.0.1',6379);
}
public function closeRedis(){
$this->redis->close();
}
/**
* 基于文件缓存获取所有当前城市投票总数
* @return array
*/
public function getFileVoteCount(){
$data = array();
$filePath = __DIR__.'/../runtime/count/';
$totalFile = $filePath . '100';
if(file_exists($totalFile) == true){
$totalData = file_get_contents($totalFile);
}
else{
$totalData='["1":0,"2":0,"3":0,"4":0,"5":0,"6":0,"7":0,"8":0,"9":0,"10":0,"11":0,"12":0,"13":0,"14":0,"15":0]';
}
$totalData = json_decode($totalData, true);
if(empty($totalData) == false && (time() - $totalData['time']) < 10){
return $totalData;
}
else{
$filenames = $this->getPathFiles($filePath);
if(count($filenames) > 0){
$total = 0;
foreach($filenames as $name){
$id = $name;
$file = $filePath . $name;
$count = intval(file_get_contents($file));
$total = $total + $count;
$data[$id] = $count;
}
$data['100'] = $total;
$data['time'] = time();
file_put_contents($totalFile,json_encode($data));
chmod($totalFile, 0755);
}
return $data;
}
}
private function getPathFiles($logdir){
$filenames = scandir($logdir);
$files = array();
foreach($filenames as $name){
$file = $logdir.'/'.$name;
if(is_file($file) == true){
$files[] = $name;
}
}
return $files;
}
/**
* 获取客户端IP
* @return string 返回ip地址,如127.0.0.1
*/
public function getClientIp()
{
$onlineip = $_SERVER['REMOTE_ADDR'];
return $onlineip;
}
/**
* 获取用户投票选项
* 一次投5票
*/
public function getUserOptions(){
$ids = $_GET['ids'];
if(empty($ids) == true || is_array($ids) == false){
echo json_encode(['status'=>0, 'msg'=>'Data can not be null!', "idstemp"=>$ids]); // 数据不能为空!
exit();
}
else{
$idstemp = [];
foreach($ids as $idtemp){
$id = intval($idtemp);
if($id > 0 && $id <=15){
$idstemp[] = $idtemp;
}
}
return $idstemp;
}
}
/**
* 是否为已投票选项
*/
public function isVoteOption($ip, $id){
$vote_option_key = 'ip_vote_city_data_' . $ip;
$vote_option = $this->getVoteOption($ip);
if(empty($vote_option) == true){
$this->redis->setex($vote_option_key, $this->expire_time, json_encode([$id]));
}
else if(in_array($ip, $vote_option) == false){
$vote_option[] = $id;
$this->redis->setex($vote_option_key, $this->expire_time, json_encode($vote_option));
}
}
public function setVoteOption($ip, $vote_options){
$vote_option_key = 'ip_vote_city_data_' . $ip;
$this->redis->setex($vote_option_key, $this->expire_time, json_encode($vote_options));
}
/**
* @param $ip
* @return array|mixed
*/
public function getVoteOption($ip){
$vote_option_key = 'ip_vote_city_data_' . $ip;
$vote_option = $this->redis->get($vote_option_key);
$vote_option = json_decode($vote_option, true);
if(empty($vote_option) == true){
$vote_option = [];
}
return $vote_option;
}
/**
* 添加到队列
* 一次投5票
*/
public function addQueues($ip,$ids){
$user_agent = $_SERVER['HTTP_USER_AGENT'];
$this->redis->lPush('vote_topic', json_encode([$ip, $ids, time(),$user_agent]));
}
/**
* 当前ip的投票记录是否已经入库
* @param $ip
*/
public function isVoteCompute($ip){
$vote_compute_key = 'ip_vote_compute_key_'.$ip;
$result = $this->redis->get($vote_compute_key);
if(empty($result) == false){ // 如果缓存中存在数据,则为未入库
return false;
}
else{
return true;
}
}
/**
* 当前ip的投票记录是否已经入库
* @param $ip
*/
public function setVoteCompute($ip){
$vote_compute_key = 'ip_vote_compute_key_'.$ip;
$this->redis->setex($vote_compute_key, $this->expire_time, 'y');
}
}
jmeter简单本机压测一下,tps最大值可达到1000以上了