基于Redis位图实现系统用户登录统计

项目需求,试着写了一个简单登录统计,基本功能都实现了,日志数据量小。具体性能没有进行测试~ 记录下开发过程与代码,留着以后改进!

1. 需求 

         实现记录用户哪天进行了登录,每天只记录是否登录过,重复登录状态算已登录。不需要记录用户的操作行为,不需要记录用户上次登录时间和IP地址(这部分以后需要可以单独拿出来存储) 区分用户类型 查询数据需要精确到天

2. 分析

  考虑到只是简单的记录用户是否登录,记录数据比较单一,查询需要精确到天。以百万用户量为前提,前期考虑了几个方案

2.1 使用文件

  使用单文件存储:文件占用空间增长速度快,海量数据检索不方便,Map/Reduce操作也麻烦

  使用多文件存储:按日期对文件进行分割。每天记录当天日志,文件量过大

2.2 使用数据库

不太认同直接使用数据库写入/读取

  • 频繁请求数据库做一些日志记录浪费服务器开销。 
  • 随着时间推移数据急剧增大 
  • 海量数据检索效率也不高,同时使用索引,易产生碎片,每次插入数据还要维护索引,影响性能

  所以只考虑使用数据库做数据备份。

2.3 使用Redis位图(BitMap)

  这也是在网上看到的方法,比较实用。也是我最终考虑使用的方法,

  首先优点:

  数据量小:一个bit位来表示某个元素对应的值或者状态,其中的key就是对应元素本身。我们知道8个bit可以组成一个Byte,所以bitmap本身会极大的节省储存空间。1亿人每天的登陆情况,用1亿bit,约1200WByte,约10M 的字符就能表示。

  计算方便:实用Redis bit 相关命令可以极大的简化一些统计操作。常用命令 SETBITGETBITBITCOUNTBITOP

  再说弊端:

  存储单一:这也算不上什么缺点,位图上存储只是0/1,所以需要存储其他信息就要别的地方单独记录,对于需要存储信息多的记录就需要使用别的方法了

3. 设计3.1 Redis BitMap

  Key结构:前缀_年Y-月m_用户类型_用户ID

标准Key: KEYS loginLog_2017-10_client_1001
检索全部: KEYS loginLog_*
检索某年某月全部: KEYS loginLog_2017-10_*
检索单个用户全部: KEYS loginLog_*_client_1001
检索单个类型全部: KEYS loginLog_*_office_*
…  

  每条BitMap记录单个用户一个月的登录情况,一个bit位表示一天登录情况。

设置用户1001,217-10-25登录: SETBIT loginLog_2017-10_client_1001 25 1
获取用户1001,217-10-25是否登录:GETBIT loginLog_2017-10_client_1001 25
获取用户1001,217-10月是否登录: GETCOUNT loginLog_2017-10_client_1001
获取用户1001,217-10/9/7月是否登录:BITOP OR stat loginLog_2017-10_client_1001 loginLog_2017-09_client_1001 loginLog_2017-07_client_1001

  关于获取登录信息,就得获取BitMap然后拆开,循环进行判断。特别涉及时间范围,需要注意时间边界的问题,不要查询出多余的数据

  获取数据Redis优先级高于数据库,Redis有的记录不要去数据库获取

  Redis数据过期:在数据同步中进行判断,过期时间自己定义(我定义的过期时间单位为“天”,必须大于31)。

  在不能保证同步与过期一致性的问题,不要给Key设置过期时间,会造成数据丢失。

上一次更新时间: 2107-10-02
下一次更新时间: 2017-10-09
Redis BitMap 过期时间: 2017-10-05

这样会造成:2017-10-09同步的时候,3/4/5/6/7/8/9 数据丢失 

 所以我把Redis过期数据放到同步时进行判断  

  我自己想的同步策略(定时每周一凌晨同步):

一、验证是否需要进行同步:

1. 当前日期 >= 8号,对本月所有记录进行同步,不对本月之前的记录进行同步

2. 当前日期 < 8号,对本月所有记录进行同步,对本月前一个月的记录进行同步,对本月前一个月之前的所有记录不进行同步

二、验证过期,如果过期,记录日志后删除[/code]3.2 数据库,表结构

  每周同步一次数据到数据库,表中一条数据对应一个BitMap,记录一个月数据。每次更新已存在的、插入没有的

3.3 暂定接口 

  •  设置用户登录
  •  查询单个用户某天是否登录过
  • 查询单个用户某月是否登录过
  •  查询单个用户某个时间段是否登录过
  •  查询单个用户某个时间段登录信息
  •  指定用户类型:获取某个时间段内有效登录的用户
  •  全部用户:获取某个时间段内有效登录的用户

4. Code

  TP3中实现的代码,在接口服务器内部库中,Application\Lib\

  ├─LoginLog

  │├─Logs 日志目录,Redis中过期的记录删除写入日志进行备份

  │├─LoginLog.class.php 对外接口

  │├─LoginLogCommon.class.php 公共工具类

  │├─LoginLogDBHandle.class.php 数据库操作类

  │├─LoginLogRedisHandle.class.php Redis操作类

4.1 LoginLog.class.php

<?php

namespace Lib\LoginLog;
use Lib\CLogFileHandler;
use Lib\HObject;
use Lib\Log;
use Lib\Tools;

/**
* 登录日志操作类
* User: dbn
* Date: 2017/10/11
* Time: 12:01
* ------------------------
* 日志最小粒度为:天
*/

class LoginLog extends HObject
{
private $_redisHandle; // Redis登录日志处理
private $_dbHandle;  // 数据库登录日志处理

public function __construct()
{
$this->_redisHandle = new LoginLogRedisHandle($this);
$this->_dbHandle  = new LoginLogDBHandle($this);

// 初始化日志
$logHandler = new CLogFileHandler(__DIR__ . '/Logs/del.log');
Log::Init($logHandler, 15);
}

/**
* 记录登录:每天只记录一次登录,只允许设置当月内登录记录
* @param string $type 用户类型
* @param int  $uid 唯一标识(用户ID)
* @param int  $time 时间戳
* @return boolean
*/
public function setLogging($type, $uid, $time)
{
$key = $this->_redisHandle->getLoginLogKey($type, $uid, $time);
if ($this->_redisHandle->checkLoginLogKey($key)) {
return $this->_redisHandle->setLogging($key, $time);
}
return false;
}

/**
* 查询用户某一天是否登录过
* @param string $type 用户类型
* @param int  $uid 唯一标识(用户ID)
* @param int  $time 时间戳
* @return boolean 参数错误或未登录过返回false,登录过返回true
*/
public function getDateWhetherLogin($type, $uid, $time)
{
$key = $this->_redisHandle->getLoginLogKey($type, $uid, $time);
if ($this->_redisHandle->checkLoginLogKey($key)) {

// 判断Redis中是否存在记录
$isRedisExists = $this->_redisHandle->checkRedisLogExists($key);
if ($isRedisExists) {

// 从Redis中进行判断
return $this->_redisHandle->dateWhetherLogin($key, $time);
} else {

// 从数据库中进行判断
return $this->_dbHandle->dateWhetherLogin($type, $uid, $time);
}
}
return false;
}

/**
* 查询用户某月是否登录过
* @param string $type 用户类型
* @param int  $uid 唯一标识(用户ID)
* @param int  $time 时间戳
* @return boolean 参数错误或未登录过返回false,登录过返回true
*/
public function getDateMonthWhetherLogin($type, $uid, $time)
{
$key = $this->_redisHandle->getLoginLogKey($type, $uid, $time);
if ($this->_redisHandle->checkLoginLogKey($key)) {

// 判断Redis中是否存在记录
$isRedisExists = $this->_redisHandle->checkRedisLogExists($key);
if ($isRedisExists) {

// 从Redis中进行判断
return $this->_redisHandle->dateMonthWhetherLogin($key);
} else {

// 从数据库中进行判断
return $this->_dbHandle->dateMonthWhetherLogin($type, $uid, $time);
}
}
return false;
}

/**
* 查询用户在某个时间段是否登录过
* @param string $type 用户类型
* @param int  $uid 唯一标识(用户ID)
* @param int  $startTime 开始时间戳
* @param int  $endTime  结束时间戳
* @return boolean 参数错误或未登录过返回false,登录过返回true
*/
public function getTimeRangeWhetherLogin($type, $uid, $startTime, $endTime){
$result = $this->getUserTimeRangeLogin($type, $uid, $startTime, $endTime);
if ($result['hasLog']['count'] > 0) {
return true;
}
return false;
}

/**
* 获取用户某时间段内登录信息
* @param string $type   用户类型
* @param int  $uid    唯一标识(用户ID)
* @param int  $startTime 开始时间戳
* @param int  $endTime  结束时间戳
* @return array 参数错误或未查询到返回array()
* -------------------------------------------------
* 查询到结果:
* array(
*   'hasLog' => array(
*     'count' => n,                 // 有效登录次数,每天重复登录算一次
*     'list' => array('2017-10-1', '2017-10-15' ...) // 有效登录日期
*   ),
*   'notLog' => array(
*     'count' => n,                 // 未登录次数
*     'list' => array('2017-10-1', '2017-10-15' ...) // 未登录日期
*   )
* )
*/
public function getUserTimeRangeLogin($type, $uid, $startTime, $endTime)
{
$hasCount  = 0;    // 有效登录次数
$notCount  = 0;    // 未登录次数
$hasList  = array(); // 有效登录日期
$notList  = array(); // 未登录日期
$successFlg = false;  // 查询到数据标识

if ($this->checkTimeRange($startTime, $endTime)) {

// 获取需要查询的Key
$keyList = $this->_redisHandle->getTimeRangeRedisKey($type, $uid, $startTime, $endTime);

if (!empty($keyList)) {
foreach ($keyList as $key => $val) {

// 判断Redis中是否存在记录
$isRedisExists = $this->_redisHandle->checkRedisLogExists($val['key']);
if ($isRedisExists) {

// 存在,直接从Redis中获取
$logInfo = $this->_redisHandle->getUserTimeRangeLogin($val['key'], $startTime, $endTime);
} else {

// 不存在,尝试从数据库中读取
$logInfo = $this->_dbHandle->getUserTimeRangeLogin($type, $uid, $val['time'], $startTime, $endTime);
}

if (is_array($logInfo)) {
$hasCount += $logInfo['hasLog']['count'];
$hasList = array_merge($hasList, $logInfo['hasLog']['list']);
$notCount += $logInfo['notLog']['count'];
$notList = array_merge($notList, $logInfo['notLog']['list']);
$successFlg = true;
}
}
}
}

if ($successFlg) {
return array(
'hasLog' => array(
'count' => $hasCount,
'list' => $hasList
),
'notLog' => array(
'count' => $notCount,
'list' => $notList
)
);
}

return array();
}

/**
* 获取某段时间内有效登录过的用户 统一接口
* @param int  $startTime 开始时间戳
* @param int  $endTime  结束时间戳
* @param array $typeArr  用户类型,为空时获取全部类型
* @return array 参数错误或未查询到返回array()
* -------------------------------------------------
* 查询到结果:指定用户类型
* array(
*   'type1' => array(
*     'count' => n,           // type1 有效登录总用户数
*     'list' => array('111', '222' ...) // type1 有效登录用户
*   ),
*   'type2' => array(
*     'count' => n,           // type2 有效登录总用户数
*     'list' => array('333', '444' ...) // type2 有效登录用户
*   )
* )
* -------------------------------------------------
* 查询到结果:未指定用户类型,全部用户,固定键 'all'
* array(
*   'all' => array(
*     'count' => n,           // 有效登录总用户数
*     'list' => array('111', '222' ...) // 有效登录用户
*   )
* )
*/
public function getOrientedTimeRangeLogin($startTime, $endTime, $typeArr = array())
{
if ($this->checkTimeRange($startTime, $endTime)) {

// 判断是否指定类型
if (is_array($typeArr) && !empty($typeArr)) {

// 指定类型,验证类型合法性
if ($this->checkTypeArr($typeArr)) {

// 依据类型获取
return $this->getSpecifyTypeTimeRangeLogin($startTime, $endTime, $typeArr);
}
} else {

// 未指定类型,统一获取
return $this->getSpecifyAllTimeRangeLogin($startTime, $endTime);
}
}
return array();
}

/**
* 指定类型:获取某段时间内登录过的用户
* @param int  $startTime 开始时间戳
* @param int  $endTime  结束时间戳
* @param array $typeArr  用户类型
* @return array
*/
private function getSpecifyTypeTimeRangeLogin($startTime, $endTime, $typeArr)
{
$data = array();
$successFlg = false; // 查询到数据标识

// 指定类型,根据类型单独获取,进行整合
foreach ($typeArr as $typeArrVal) {

// 获取需要查询的Key
$keyList = $this->_redisHandle->getSpecifyTypeTimeRangeRedisKey($typeArrVal, $startTime, $endTime);
if (!empty($keyList)) {

$data[$typeArrVal]['count'] = 0;    // 该类型下有效登录用户数
$data[$typeArrVal]['list'] = array(); // 该类型下有效登录用户

foreach ($keyList as $keyListVal) {

// 查询Kye,验证Redis中是否存在:此处为单个类型,所以直接看Redis中是否存在该类型Key即可判断是否存在
// 存在的数据不需要去数据库中去查看
$standardKeyList = $this->_redisHandle->getKeys($keyListVal['key']);
if (is_array($standardKeyList) && count($standardKeyList) > 0) {

// Redis存在
foreach ($standardKeyList as $standardKeyListVal) {

// 验证该用户在此时间段是否登录过
$redisCheckLogin = $this->_redisHandle->getUserTimeRangeLogin($standardKeyListVal, $startTime, $endTime);
if ($redisCheckLogin['hasLog']['count'] > 0) {

// 同一个用户只需记录一次
$uid = $this->_redisHandle->getLoginLogKeyInfo($standardKeyListVal, 'uid');
if (!in_array($uid, $data[$typeArrVal]['list'])) {
$data[$typeArrVal]['count']++;
$data[$typeArrVal]['list'][] = $uid;
}
$successFlg = true;
}
}

} else {

// 不存在,尝试从数据库中获取
$dbResult = $this->_dbHandle->getTimeRangeLoginSuccessUser($keyListVal['time'], $startTime, $endTime, $typeArrVal);
if (!empty($dbResult)) {
foreach ($dbResult as $dbResultVal) {
if (!in_array($dbResultVal, $data[$typeArrVal]['list'])) {
$data[$typeArrVal]['count']++;
$data[$typeArrVal]['list'][] = $dbResultVal;
}
}
$successFlg = true;
}
}
}
}
}

if ($successFlg) { return $data; }
return array();
}

/**
* 全部类型:获取某段时间内登录过的用户
* @param int  $startTime 开始时间戳
* @param int  $endTime  结束时间戳
* @return array
*/
private function getSpecifyAllTimeRangeLogin($startTime, $endTime)
{
$count   = 0;    // 有效登录用户数
$list    = array(); // 有效登录用户
$successFlg = false;  // 查询到数据标识

// 未指定类型,直接对所有数据进行检索
// 获取需要查询的Key
$keyList = $this->_redisHandle->getSpecifyAllTimeRangeRedisKey($startTime, $endTime);

if (!empty($keyList)) {
foreach ($keyList as $keyListVal) {

// 查询Kye
$standardKeyList = $this->_redisHandle->getKeys($keyListVal['key']);

if (is_array($standardKeyList) && count($standardKeyList) > 0) {

// 查询到Key,直接读取数据,记录类型
foreach ($standardKeyList as $standardKeyListVal) {

// 验证该用户在此时间段是否登录过
$redisCheckLogin = $this->_redisHandle->getUserTimeRangeLogin($standardKeyListVal, $startTime, $endTime);
if ($redisCheckLogin['hasLog']['count'] > 0) {

// 同一个用户只需记录一次
$uid = $this->_redisHandle->getLoginLogKeyInfo($standardKeyListVal, 'uid');
if (!in_array($uid, $list)) {
$count++;
$list[] = $uid;
}
$successFlg = true;
}
}
}

// 无论Redis中存在不存在都要尝试从数据库中获取一遍数据,来补充Redis获取的数据,保证检索数据完整(Redis类型缺失可能导致)
$dbResult = $this->_dbHandle->getTimeRangeLoginSuccessUser($keyListVal['time'], $startTime, $endTime);
if (!empty($dbResult)) {
foreach ($dbResult as $dbResultVal) {
if (!in_array($dbResultVal, $list)) {
$count++;
$list[] = $dbResultVal;
}
}
$successFlg = true;
}
}
}

if ($successFlg) {
return array(
'all' => array(
'count' => $count,
'list' => $list
)
);
}
return array();
}

/**
* 验证开始结束时间
* @param string $startTime 开始时间
* @param string $endTime  结束时间
* @return boolean
*/
private function checkTimeRange($startTime, $endTime)
{
return $this->_redisHandle->checkTimeRange($startTime, $endTime);
}

/**
* 批量验证用户类型
* @param array $typeArr 用户类型数组
* @return boolean
*/
private function checkTypeArr($typeArr)
{
$flg = false;
if (is_array($typeArr) && !empty($typeArr)) {
foreach ($typeArr as $val) {
if ($this->_redisHandle->checkType($val)) {
$flg = true;
} else {
$flg = false; break;
}
}
}
return $flg;
}

/**
* 定时任务每周调用一次:从Redis同步登录日志到数据库
* @param int  $existsDay 一条记录在Redis中过期时间,单位:天,必须大于31
* @return string
* 'null':  Redis中无数据
* 'fail':  同步失败
* 'success':同步成功
*/
public function cronWeeklySync($existsDay)
{

// 验证生存时间
if ($this->_redisHandle->checkExistsDay($existsDay)) {
$likeKey = 'loginLog_*';
$keyList = $this->_redisHandle->getKeys($likeKey);

if (!empty($keyList)) {
foreach ($keyList as $keyVal) {

if ($this->_redisHandle->checkLoginLogKey($keyVal)) {
$keyTime     = $this->_redisHandle->getLoginLogKeyInfo($keyVal, 'time');
$thisMonth    = date('Y-m');
$beforeMonth   = date('Y-m', strtotime('-1 month'));

// 验证是否需要进行同步:
// 1. 当前日期 >= 8号,对本月所有记录进行同步,不对本月之前的记录进行同步
// 2. 当前日期 < 8号,对本月所有记录进行同步,对本月前一个月的记录进行同步,对本月前一个月之前的所有记录不进行同步
if (date('j') >= 8) {

// 只同步本月数据
if ($thisMonth == $keyTime) {
$this->redis2db($keyVal);
}
} else {

// 同步本月或本月前一个月数据
if ($thisMonth == $keyTime || $beforeMonth == $keyTime) {
$this->redis2db($keyVal);
}
}

// 验证是否过期
$existsSecond = $existsDay * 24 * 60 * 60;
if (strtotime($keyTime) + $existsSecond < time()) {

// 过期删除
$bitMap = $this->_redisHandle->getLoginLogBitMap($keyVal);
Log::INFO('删除过期数据[' . $keyVal . ']:' . $bitMap);
$this->_redisHandle->delLoginLog($keyVal);
}
}
}
return 'success';
}
return 'null';
}
return 'fail';
}

/**
* 将记录同步到数据库
* @param string $key 记录Key
* @return boolean
*/
private function redis2db($key)
{
if ($this->_redisHandle->checkLoginLogKey($key) && $this->_redisHandle->checkRedisLogExists($key)) {
$time = $this->_redisHandle->getLoginLogKeyInfo($key, 'time');
$data['id']   = Tools::generateId();
$data['user_id'] = $this->_redisHandle->getLoginLogKeyInfo($key, 'uid');
$data['type']  = $this->_redisHandle->getLoginLogKeyInfo($key, 'type');
$data['year']  = date('Y', strtotime($time));
$data['month']  = date('n', strtotime($time));
$data['bit_log'] = $this->_redisHandle->getLoginLogBitMap($key);
return $this->_dbHandle->redis2db($data);
}
return false;
}
}

4.2 LoginLogCommon.class.php

<?php

namespace Lib\LoginLog;

use Lib\RedisData;
use Lib\Status;

/**
* 公共方法
* User: dbn
* Date: 2017/10/11
* Time: 13:11
*/
class LoginLogCommon
{
protected $_loginLog;
protected $_redis;

public function __construct(LoginLog $loginLog)
{
$this->_loginLog = $loginLog;
$this->_redis  = RedisData::getRedis();
}

/**
* 验证用户类型
* @param string $type 用户类型
* @return boolean
*/
protected function checkType($type)
{
if (in_array($type, array(
Status::LOGIN_LOG_TYPE_ADMIN,
Status::LOGIN_LOG_TYPE_CARRIER,
Status::LOGIN_LOG_TYPE_DRIVER,
Status::LOGIN_LOG_TYPE_OFFICE,
Status::LOGIN_LOG_TYPE_CLIENT,
))) {
return true;
}
$this->_loginLog->setError('未定义的日志类型:' . $type);
return false;
}

/**
* 验证唯一标识
* @param string $uid
* @return boolean
*/
protected function checkUid($uid)
{
if (is_numeric($uid) && $uid > 0) {
return true;
}
$this->_loginLog->setError('唯一标识非法:' . $uid);
return false;
}

/**
* 验证时间戳
* @param string $time
* @return boolean
*/
protected function checkTime($time)
{
if (is_numeric($time) && $time > 0) {
return true;
}
$this->_loginLog->setError('时间戳非法:' . $time);
return false;
}

/**
* 验证时间是否在当月中
* @param string $time
* @return boolean
*/
protected function checkTimeWhetherThisMonth($time)
{
if ($this->checkTime($time) && $time > strtotime(date('Y-m')) && $time < strtotime(date('Y-m') . '-' . date('t'))) {
return true;
}
$this->_loginLog->setError('时间未在当前月份中:' . $time);
return false;
}

/**
* 验证时间是否超过当前时间
* @param string $time
* @return boolean
*/
protected function checkTimeWhetherFutureTime($time)
{
if ($this->checkTime($time) && $time <= time()) {
return true;
}
return false;
}

/**
* 验证开始/结束时间
* @param string $startTime 开始时间
* @param string $endTime  结束时间
* @return boolean
*/
protected function checkTimeRange($startTime, $endTime)
{
if ($this->checkTime($startTime) &&
$this->checkTime($endTime) &&
$startTime < $endTime &&
$startTime < time()
) {
return true;
}
$this->_loginLog->setError('时间范围非法:' . $startTime . '-' . $endTime);
return false;
}

/**
* 验证时间是否在指定范围内
* @param string $time   需要检查的时间
* @param string $startTime 开始时间
* @param string $endTime  结束时间
* @return boolean
*/
protected function checkTimeWithinTimeRange($time, $startTime, $endTime)
{
if ($this->checkTime($time) &&
$this->checkTimeRange($startTime, $endTime) &&
$startTime <= $time &&
$time <= $endTime
) {
return true;
}
$this->_loginLog->setError('请求时间未在时间范围内:' . $time . '-' . $startTime . '-' . $endTime);
return false;
}

/**
* 验证Redis日志记录标准Key
* @param string $key
* @return boolean
*/
protected function checkLoginLogKey($key)
{
$pattern = '/^loginLog_\d{4}-\d{1,2}_\S+_\d+$/';
$result = preg_match($pattern, $key, $match);
if ($result > 0) {
return true;
}
$this->_loginLog->setError('RedisKey非法:' . $key);
return false;
}

/**
* 获取月份中有多少天
* @param int $time 时间戳
* @return int
*/
protected function getDaysInMonth($time)
{
return date('t', $time);
}

/**
* 对没有前导零的月份或日设置前导零
* @param int $num 月份或日
* @return string
*/
protected function setDateLeadingZero($num)
{
if (is_numeric($num) && strlen($num) <= 2) {
$num = (strlen($num) > 1 ? $num : '0' . $num);
}
return $num;
}

/**
* 验证过期时间
* @param int   $existsDay 一条记录在Redis中过期时间,单位:天,必须大于31
* @return boolean
*/
protected function checkExistsDay($existsDay)
{
if (is_numeric($existsDay) && ctype_digit(strval($existsDay)) && $existsDay > 31) {
return true;
}
$this->_loginLog->setError('过期时间非法:' . $existsDay);
return false;
}

/**
* 获取开始日期边界
* @param int $time   需要判断的时间戳
* @param int $startTime 起始时间
* @return int
*/
protected function getStartTimeBorder($time, $startTime)
{
$initDay = 1;
if ($this->checkTime($time) && $this->checkTime($startTime) &&
date('Y-m', $time) === date('Y-m', $startTime) && false !== date('Y-m', $time)) {
$initDay = date('j', $startTime);
}
return $initDay;
}

/**
* 获取结束日期边界
* @param int $time   需要判断的时间戳
* @param int $endTime  结束时间
* @return int
*/
protected function getEndTimeBorder($time, $endTime)
{
$border = $this->getDaysInMonth($time);
if ($this->checkTime($time) && $this->checkTime($endTime) &&
date('Y-m', $time) === date('Y-m', $endTime) && false !== date('Y-m', $time)) {
$border = date('j', $endTime);
}
return $border;
}
}

4.3 LoginLogDBHandle.class.php

<?php

namespace Lib\LoginLog;
use Think\Model;

/**
* 数据库登录日志处理类
* User: dbn
* Date: 2017/10/11
* Time: 13:12
*/
class LoginLogDBHandle extends LoginLogCommon
{

/**
* 从数据库中获取用户某月记录在指定时间范围内的用户信息
* @param string $type   用户类型
* @param int   $uid    唯一标识(用户ID)
* @param int   $time   需要查询月份时间戳
* @param int   $startTime 开始时间戳
* @param int   $endTime  结束时间戳
* @return array
* array(
*   'hasLog' => array(
*     'count' => n,                 // 有效登录次数,每天重复登录算一次
*     'list' => array('2017-10-1', '2017-10-15' ...) // 有效登录日期
*   ),
*   'notLog' => array(
*     'count' => n,                 // 未登录次数
*     'list' => array('2017-10-1', '2017-10-15' ...) // 未登录日期
*   )
* )
*/
public function getUserTimeRangeLogin($type, $uid, $time, $startTime, $endTime)
{
$hasCount = 0;    // 有效登录次数
$notCount = 0;    // 未登录次数
$hasList = array(); // 有效登录日期
$notList = array(); // 未登录日期

if ($this->checkType($type) && $this->checkUid($uid) && $this->checkTimeWithinTimeRange($time, $startTime, $endTime)) {

$timeYM = date('Y-m', $time);

// 设置开始时间
$initDay = $this->getStartTimeBorder($time, $startTime);

// 设置结束时间
$border = $this->getEndTimeBorder($time, $endTime);

$bitMap = $this->getBitMapFind($type, $uid, date('Y', $time), date('n', $time));
for ($i = $initDay; $i <= $border; $i++) {

if (!empty($bitMap)) {
if ($bitMap[$i-1] == '1') {
$hasCount++;
$hasList[] = $timeYM . '-' . $this->setDateLeadingZero($i);
} else {
$notCount++;
$notList[] = $timeYM . '-' . $this->setDateLeadingZero($i);
}
} else {
$notCount++;
$notList[] = $timeYM . '-' . $this->setDateLeadingZero($i);
}
}
}

return array(
'hasLog' => array(
'count' => $hasCount,
'list' => $hasList
),
'notLog' => array(
'count' => $notCount,
'list' => $notList
)
);
}

/**
* 从数据库获取用户某月日志位图
* @param string $type 用户类型
* @param int   $uid  唯一标识(用户ID)
* @param int   $year 年Y
* @param int   $month 月n
* @return string
*/
private function getBitMapFind($type, $uid, $year, $month)
{
$model = D('Home/StatLoginLog');
$map['type']  = array('EQ', $type);
$map['user_id'] = array('EQ', $uid);
$map['year']  = array('EQ', $year);
$map['month']  = array('EQ', $month);

$result = $model->field('bit_log')->where($map)->find();
if (false !== $result && isset($result['bit_log']) && !empty($result['bit_log'])) {
return $result['bit_log'];
}
return '';
}

/**
* 从数据库中判断用户在某一天是否登录过
* @param string $type 用户类型
* @param int   $uid  唯一标识(用户ID)
* @param int   $time 时间戳
* @return boolean 参数错误或未登录过返回false,登录过返回true
*/
public function dateWhetherLogin($type, $uid, $time)
{
if ($this->checkType($type) && $this->checkUid($uid) && $this->checkTime($time)) {

$timeInfo = getdate($time);
$bitMap = $this->getBitMapFind($type, $uid, $timeInfo['year'], $timeInfo['mon']);
if (!empty($bitMap)) {
if ($bitMap[$timeInfo['mday']-1] == '1') {
return true;
}
}
}
return false;
}

/**
* 从数据库中判断用户在某月是否登录过
* @param string $type 用户类型
* @param int   $uid  唯一标识(用户ID)
* @param int   $time 时间戳
* @return boolean 参数错误或未登录过返回false,登录过返回true
*/
public function dateMonthWhetherLogin($type, $uid, $time)
{
if ($this->checkType($type) && $this->checkUid($uid) && $this->checkTime($time)) {

$timeInfo = getdate($time);
$userArr = $this->getMonthLoginSuccessUser($timeInfo['year'], $timeInfo['mon'], $type);
if (!empty($userArr)) {
if (in_array($uid, $userArr)) {
return true;
}
}
}
return false;
}

/**
* 获取某月所有有效登录过的用户ID
* @param int   $year 年Y
* @param int   $month 月n
* @param string $type 用户类型,为空时获取全部类型
* @return array
*/
public function getMonthLoginSuccessUser($year, $month, $type = '')
{
$data = array();
if (is_numeric($year) && is_numeric($month)) {
$model = D('Home/StatLoginLog');
$map['year']  = array('EQ', $year);
$map['month']  = array('EQ', $month);
$map['bit_log'] = array('LIKE', '%1%');
if ($type != '' && $this->checkType($type)) {
$map['type']  = array('EQ', $type);
}
$result = $model->field('user_id')->where($map)->select();
if (false !== $result && count($result) > 0) {
foreach ($result as $val) {
if (isset($val['user_id'])) {
$data[] = $val['user_id'];
}
}
}
}
return $data;
}

/**
* 从数据库中获取某月所有记录在指定时间范围内的用户ID
* @param int   $time   查询的时间戳
* @param int   $startTime 开始时间戳
* @param int   $endTime  结束时间戳
* @param string $type 用户类型,为空时获取全部类型
* @return array
*/
public function getTimeRangeLoginSuccessUser($time, $startTime, $endTime, $type = '')
{
$data = array();
if ($this->checkTimeWithinTimeRange($time, $startTime, $endTime)) {

$timeInfo = getdate($time);

// 获取满足时间条件的记录
$model = D('Home/StatLoginLog');
$map['year']  = array('EQ', $timeInfo['year']);
$map['month']  = array('EQ', $timeInfo['mon']);
if ($type != '' && $this->checkType($type)) {
$map['type']  = array('EQ', $type);
}

$result = $model->where($map)->select();
if (false !== $result && count($result) > 0) {

// 设置开始时间
$initDay = $this->getStartTimeBorder($time, $startTime);

// 设置结束时间
$border = $this->getEndTimeBorder($time, $endTime);

foreach ($result as $val) {

$bitMap = $val['bit_log'];
for ($i = $initDay; $i <= $border; $i++) {

if ($bitMap[$i-1] == '1' && !in_array($val['user_id'], $data)) {
$data[] = $val['user_id'];
}
}
}
}
}
return $data;
}

/**
* 将数据更新到数据库
* @param array $data 单条记录的数据
* @return boolean
*/
public function redis2db($data)
{
$model = D('Home/StatLoginLog');

// 验证记录是否存在
$map['user_id'] = array('EQ', $data['user_id']);
$map['type']  = array('EQ', $data['type']);
$map['year']  = array('EQ', $data['year']);
$map['month']  = array('EQ', $data['month']);

$count = $model->where($map)->count();
if (false !== $count && $count > 0) {

// 存在记录进行更新
$saveData['bit_log'] = $data['bit_log'];

if (!$model->create($saveData, Model::MODEL_UPDATE)) {

$this->_loginLog->setError('同步登录日志-更新记录,创建数据对象失败:' . $model->getError());
logger()->error('同步登录日志-更新记录,创建数据对象失败:' . $model->getError());
return false;
} else {

$result = $model->where($map)->save();

if (false !== $result) {
return true;
} else {
$this->_loginLog->setError('同步登录日志-更新记录,更新数据失败:' . json_encode($data));
logger()->error('同步登录日志-更新记录,更新数据失败:' . json_encode($data));
return false;
}
}
} else {

// 不存在记录插入一条新的记录
if (!$model->create($data, Model::MODEL_INSERT)) {

$this->_loginLog->setError('同步登录日志-插入记录,创建数据对象失败:' . $model->getError());
logger()->error('同步登录日志-插入记录,创建数据对象失败:' . $model->getError());
return false;
} else {

$result = $model->add();

if (false !== $result) {
return true;
} else {
$this->_loginLog->setError('同步登录日志-插入记录,插入数据失败:' . json_encode($data));
logger()->error('同步登录日志-插入记录,插入数据失败:' . json_encode($data));
return false;
}
}
}
}
}

4.4 LoginLogRedisHandle.class.php

<?php

namespace Lib\LoginLog;

/**
* Redis登录日志处理类
* User: dbn
* Date: 2017/10/11
* Time: 15:53
*/
class LoginLogRedisHandle extends LoginLogCommon
{
/**
* 记录登录:每天只记录一次登录,只允许设置当月内登录记录
* @param string $key 日志记录Key
* @param int  $time 时间戳
* @return boolean
*/
public function setLogging($key, $time)
{
if ($this->checkLoginLogKey($key) && $this->checkTimeWhetherThisMonth($time)) {

// 判断用户当天是否已经登录过
$whetherLoginResult = $this->dateWhetherLogin($key, $time);
if (!$whetherLoginResult) {

// 当天未登录,记录登录
$this->_redis->setBit($key, date('d', $time), 1);
}
return true;
}
return false;
}

/**
* 从Redis中判断用户在某一天是否登录过
* @param string $key 日志记录Key
* @param int  $time 时间戳
* @return boolean 参数错误或未登录过返回false,登录过返回true
*/
public function dateWhetherLogin($key, $time)
{
if ($this->checkLoginLogKey($key) && $this->checkTime($time)) {
$result = $this->_redis->getBit($key, date('d', $time));
if ($result === 1) {
return true;
}
}
return false;
}

/**
* 从Redis中判断用户在某月是否登录过
* @param string $key 日志记录Key
* @return boolean 参数错误或未登录过返回false,登录过返回true
*/
public function dateMonthWhetherLogin($key)
{
if ($this->checkLoginLogKey($key)) {
$result = $this->_redis->bitCount($key);
if ($result > 0) {
return true;
}
}
return false;
}

/**
* 判断某月登录记录在Redis中是否存在
* @param string $key 日志记录Key
* @return boolean
*/
public function checkRedisLogExists($key)
{
if ($this->checkLoginLogKey($key)) {
if ($this->_redis->exists($key)) {
return true;
}
}
return false;
}

/**
* 从Redis中获取用户某月记录在指定时间范围内的用户信息
* @param string $key    日志记录Key
* @param int   $startTime 开始时间戳
* @param int   $endTime  结束时间戳
* @return array
* array(
*   'hasLog' => array(
*     'count' => n,                 // 有效登录次数,每天重复登录算一次
*     'list' => array('2017-10-1', '2017-10-15' ...) // 有效登录日期
*   ),
*   'notLog' => array(
*     'count' => n,                 // 未登录次数
*     'list' => array('2017-10-1', '2017-10-15' ...) // 未登录日期
*   )
* )
*/
public function getUserTimeRangeLogin($key, $startTime, $endTime)
{
$hasCount = 0;    // 有效登录次数
$notCount = 0;    // 未登录次数
$hasList = array(); // 有效登录日期
$notList = array(); // 未登录日期

if ($this->checkLoginLogKey($key) && $this->checkTimeRange($startTime, $endTime) && $this->checkRedisLogExists($key)) {

$keyTime = $this->getLoginLogKeyInfo($key, 'time');
$keyTime = strtotime($keyTime);
$timeYM = date('Y-m', $keyTime);

// 设置开始时间
$initDay = $this->getStartTimeBorder($keyTime, $startTime);

// 设置结束时间
$border = $this->getEndTimeBorder($keyTime, $endTime);

for ($i = $initDay; $i <= $border; $i++) {
$result = $this->_redis->getBit($key, $i);
if ($result === 1) {
$hasCount++;
$hasList[] = $timeYM . '-' . $this->setDateLeadingZero($i);
} else {
$notCount++;
$notList[] = $timeYM . '-' . $this->setDateLeadingZero($i);
}
}
}

return array(
'hasLog' => array(
'count' => $hasCount,
'list' => $hasList
),
'notLog' => array(
'count' => $notCount,
'list' => $notList
)
);
}

/**
* 面向用户:获取时间范围内可能需要的Key
* @param string $type   用户类型
* @param int  $uid    唯一标识(用户ID)
* @param string $startTime 开始时间
* @param string $endTime  结束时间
* @return array
*/
public function getTimeRangeRedisKey($type, $uid, $startTime, $endTime)
{
$list = array();

if ($this->checkType($type) && $this->checkUid($uid) && $this->checkTimeRange($startTime, $endTime)) {

$data = $this->getSpecifyUserKeyHandle($type, $uid, $startTime);
if (!empty($data)) { $list[] = $data; }

$temYM = strtotime('+1 month', strtotime(date('Y-m', $startTime)));

while ($temYM <= $endTime) {
$data = $this->getSpecifyUserKeyHandle($type, $uid, $temYM);
if (!empty($data)) { $list[] = $data; }

$temYM = strtotime('+1 month', $temYM);
}
}
return $list;
}
private function getSpecifyUserKeyHandle($type, $uid, $time)
{
$data = array();
$key = $this->getLoginLogKey($type, $uid, $time);
if ($this->checkLoginLogKey($key)) {
$data = array(
'key' => $key,
'time' => $time
);
}
return $data;
}

/**
* 面向类型:获取时间范围内可能需要的Key
* @param string $type   用户类型
* @param string $startTime 开始时间
* @param string $endTime  结束时间
* @return array
*/
public function getSpecifyTypeTimeRangeRedisKey($type, $startTime, $endTime)
{
$list = array();

if ($this->checkType($type) && $this->checkTimeRange($startTime, $endTime)) {

$data = $this->getSpecifyTypeKeyHandle($type, $startTime);
if (!empty($data)) { $list[] = $data; }

$temYM = strtotime('+1 month', strtotime(date('Y-m', $startTime)));

while ($temYM <= $endTime) {
$data = $this->getSpecifyTypeKeyHandle($type, $temYM);
if (!empty($data)) { $list[] = $data; }

$temYM = strtotime('+1 month', $temYM);
}
}
return $list;
}
private function getSpecifyTypeKeyHandle($type, $time)
{
$data = array();
$temUid = '11111111';

$key = $this->getLoginLogKey($type, $temUid, $time);
if ($this->checkLoginLogKey($key)) {
$arr = explode('_', $key);
$arr[count($arr)-1] = '*';
$key = implode('_', $arr);
$data = array(
'key' => $key,
'time' => $time
);
}
return $data;
}

/**
* 面向全部:获取时间范围内可能需要的Key
* @param string $startTime 开始时间
* @param string $endTime  结束时间
* @return array
*/
public function getSpecifyAllTimeRangeRedisKey($startTime, $endTime)
{
$list = array();

if ($this->checkTimeRange($startTime, $endTime)) {

$data = $this->getSpecifyAllKeyHandle($startTime);
if (!empty($data)) { $list[] = $data; }

$temYM = strtotime('+1 month', strtotime(date('Y-m', $startTime)));

while ($temYM <= $endTime) {
$data = $this->getSpecifyAllKeyHandle($temYM);
if (!empty($data)) { $list[] = $data; }

$temYM = strtotime('+1 month', $temYM);
}
}
return $list;
}
private function getSpecifyAllKeyHandle($time)
{
$data = array();
$temUid = '11111111';
$temType = 'office';

$key = $this->getLoginLogKey($temType, $temUid, $time);
if ($this->checkLoginLogKey($key)) {
$arr = explode('_', $key);
array_pop($arr);
$arr[count($arr)-1] = '*';
$key = implode('_', $arr);
$data = array(
'key' => $key,
'time' => $time
);
}
return $data;
}

/**
* 从Redis中查询满足条件的Key
* @param string $key 查询的Key
* @return array
*/
public function getKeys($key)
{
return $this->_redis->keys($key);
}

/**
* 从Redis中删除记录
* @param string $key 记录的Key
* @return boolean
*/
public function delLoginLog($key)
{
return $this->_redis->del($key);
}

/**
* 获取日志标准Key:前缀_年-月_用户类型_唯一标识
* @param string $type 用户类型
* @param int  $uid 唯一标识(用户ID)
* @param int  $time 时间戳
* @return string
*/
public function getLoginLogKey($type, $uid, $time)
{
if ($this->checkType($type) && $this->checkUid($uid) && $this->checkTime($time)) {
return 'loginLog_' . date('Y-m', $time) . '_' . $type . '_' . $uid;
}
return '';
}

/**
* 获取日志标准Key上信息
* @param string $key  key
* @param string $field 需要的参数 time,type,uid
* @return mixed 返回对应的值,没有返回null
*/
public function getLoginLogKeyInfo($key, $field)
{
$param = array();
if ($this->checkLoginLogKey($key)) {
$arr = explode('_', $key);
$param['time'] = $arr[1];
$param['type'] = $arr[2];
$param['uid'] = $arr[3];
}
return $param[$field];
}

/**
* 获取Key记录的登录位图
* @param string $key key
* @return string
*/
public function getLoginLogBitMap($key)
{
$bitMap = '';
if ($this->checkLoginLogKey($key)) {
$time = $this->getLoginLogKeyInfo($key, 'time');
$maxDay = $this->getDaysInMonth(strtotime($time));
for ($i = 1; $i <= $maxDay; $i++) {
$bitMap .= $this->_redis->getBit($key, $i);
}
}
return $bitMap;
}

/**
* 验证日志标准Key
* @param string $key
* @return boolean
*/
public function checkLoginLogKey($key)
{
return parent::checkLoginLogKey($key);
}

/**
* 验证开始/结束时间
* @param string $startTime 开始时间
* @param string $endTime  结束时间
* @return boolean
*/
public function checkTimeRange($startTime, $endTime)
{
return parent::checkTimeRange($startTime, $endTime);
}

/**
* 验证用户类型
* @param string $type
* @return boolean
*/
public function checkType($type)
{
return parent::checkType($type);
}

/**
* 验证过期时间
* @param int $existsDay 一条记录在Redis中过期时间,单位:天,必须大于31
* @return boolean
*/
public function checkExistsDay($existsDay)
{
return parent::checkExistsDay($existsDay);
}
}

5. 参考资料

  https://segmentfault.com/a/1190000008188655

  http://blog.csdn.net/rdhj5566/article/details/54313840

  http://www.redis.net.cn/tutorial/3508.html

本篇文章到此结束,如果您有相关技术方面疑问可以联系我们技术人员远程解决,感谢大家支持本站!


数据运维技术 » 基于Redis位图实现系统用户登录统计