简介
最近做一个小网站,需要用户登录,发现用户登录后经常会莫名其妙随机地自动退出登录。本文简单记录一下这个bug排查的过程,希望以后尽量避免此类错误。
最初代码
SESSION数据是存放在数据库中,利用PDO处理读写过程,实现参考了SO和php手册中关于session的章节,最终的数据表结构和php代码如下:
数据表结构:
CREATE TABLE `sessions` (
`session_id` varchar(40) NOT NULL DEFAULT '',
`session_data` text NOT NULL,
`expire_at` int(11) unsigned NOT NULL,
UNIQUE KEY `session_id` (`session_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
用PDO实现的session处理:
/*
* session db handler, based on pdo
*/
class SessionDBHandler
{
/*
* session table name
*/
const SESSION_TABLE = 'sessions';
/*
* session data structure
*/
const FIELD_SESSION_ID = 'session_id';
const FIELD_SESSION_DATA = 'session_data';
const FIELD_EXPIRE_AT = 'expire_at';
/*
* session db config
*/
private $config = null;
/*
* session pdo
*/
private $sess_pdo = null;
/*
* session lifetime
*/
private $sess_lifetime = 0;
/*
* default session max lifetime in seconds
* max_lifetime 31536000=3600*24*365
*/
const SESSION_MAX_LIFETIME = 31536000;
/*
* init db_conf and session maxlifetime
* @db_conf array
* @max_lifetime int default 31536000=3600*24*365
*/
public function __construct($db_conf = array(), $max_lifetime = self::SESSION_MAX_LIFETIME)
{
$this->config = $db_conf;
$this->sess_lifetime = $max_lifetime;
session_set_save_handler(
array($this, 'sess_open'),
array($this, 'sess_close'),
array($this, 'sess_read'),
array($this, 'sess_write'),
array($this, 'sess_destroy'),
array($this, 'sess_gc')
);
register_shutdown_function('session_write_close');
}
/*
* the first callback function executed when the session is started
* automatically or manually with session_start()
* return TURE for success, FALSE for failure
*/
public function sess_open($save_path, $session_name)
{
if($this->sess_pdo == null)
{
if($this->config['unix_socket'])
{
$dsn = "mysql:dbname={$this->config['database']};" .
"unix_socket={$this->config['unix_socket']}";
}
else
{
$dsn = "mysql:host={$this->config['host']};port={$this->config['port']};" .
"dbname={$this->config['database']}";
}
$username = $this->config['username'];
$password = $this->config['password'];
$options = array_merge(array(PDO::ATTR_PERSISTENT => $this->config['persistent']), $this->config['options']);
try
{
$this->sess_pdo = new PDO($dsn, $username, $password, $options);
$this->sess_pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->sess_pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
$this->sess_pdo->exec("SET NAMES {$this->config['charset']}");
$this->sess_pdo->exec("SET character_set_client=binary");
}
catch(PDOException $e)
{
$this->sess_pdo = null;
die('error in ' . __METHOD__ . ' : ' . $e->getMessage());
}
}
return true;
}
/*
* executed after the session write callback has been called,
* also invoked when session_write_close() is called
* TRUE for success, FALSE for failure
*/
public function sess_close()
{
$this->sess_pdo = null;
return true;
}
/*
* must always return a session encoded string, or an empty string if no data to read
* called internally by PHP where session start, before this callback is invoked PHP
* will invoke the open callback
*/
public function sess_read($session_id)
{
try
{
$query = 'SELECT %s FROM %s WHERE %s=? AND %s>=?';
$query = sprintf($query, self::FIELD_SESSION_DATA, self::SESSION_TABLE,
self::FIELD_SESSION_ID, self::FIELD_EXPIRE_AT);
$stmt = $this->sess_pdo->prepare($query);
$stmt->bindValue(1, $session_id);
$stmt->bindValue(2, time());
$stmt->execute();
if($stmt->rowCount() == 1)
{
list($sess_data) = $stmt->fetch();
return $sess_data;
}
return '';
}
catch(PDOException $e)
{
$this->sess_pdo = null;
die('error in ' . __METHOD__ . ' : ' . $e->getMessage());
}
}
/*
* called when the session needs to be saved and closed
* @session_id session ID
* @data serialized version of the $_SESSION
* invoked when php shuts down or explicitly session_write_close is called
*/
public function sess_write($session_id, $data)
{
try
{
/*
* the session_id field must be defined as UNIQUE
*/
$query = 'INSERT INTO %s (%s, %s, %s) VALUES (?, ?, ?)'.
" ON DUPLICATE KEY UPDATE %s=VALUES(%s), %s=VALUES(%s)";
$query = sprintf($query, self::SESSION_TABLE, self::FIELD_SESSION_ID,
self::FIELD_SESSION_DATA, self::FIELD_EXPIRE_AT,
self::FIELD_SESSION_DATA, self::FIELD_SESSION_DATA,
self::FIELD_EXPIRE_AT, self::FIELD_EXPIRE_AT);
$stmt = $this->sess_pdo->prepare($query);
$stmt->bindValue(1, $session_id);
$stmt->bindValue(2, $data);
$stmt->bindValue(3, time() + $this->sess_lifetime);
$stmt->execute();
return (bool)$stmt->rowCount();
}
catch(PDOException $e)
{
$this->sess_pdo = null;
die('error in ' . __METHOD__ . ' : ' . $e->getMessage());
}
}
/*
* executed when a session is destroyed with session_destroy or session_regenerate_id
* return TRUE for success, FALSE for failure
*/
public function sess_destroy($session_id)
{
try
{
$query = 'DELETE FROM %s WHERE %s=? LIMIT 1';
$query = sprintf($query, self::SESSION_TABLE, self::FIELD_SESSION_ID);
$stmt = $this->sess_pdo->prepare($query);
$stmt->bindValue(1, $session_id);
$stmt->execute();
return (bool)$stmt->rowCount();
}
catch(PDOException $e)
{
$this->sess_pdo = null;
die('error in ' . __METHOD__ . ': ' . $e->getMessage());
}
}
/*
* invoked internally by PHP periodically in order to purge old session data.
* frequency session.gc_probability/session.gc_divisor, the value of lifetime is passed to
* this call back be set in session.gc_maxlifetime
* return TRUE for success, FALSE for failure
*/
public function sess_gc($lifetime)
{
try
{
$query = "DELETE FROM %s WHERE %s <= NOW()";
$query = sprintf($query, self::SESSION_TABLE, self::FIELD_EXPIRE_AT);
$count = $this->sess_pdo->exec($query);
return true;
}
catch(PDOException $e)
{
$this->sess_pdo = null;
die('error in ' . __METHOD__ . ' : ' . $e->getMessage());
}
}
}
其中,数据库配置参数数组为:
$DB_CONF = array(
'host' => '127.0.0.1',
'port' => '3306',
'username' => 'test',
'password' => 'test',
'database' => 'test',
'charset' => 'utf8',
'persistent' => true,
'unix_socket' => '',
'options' => array()
);
这里 在sess_write中利用 INSERT INTO ... ON DUPLICATE KEY UPDATE ... 语句更新session数据,需要将session_id字段声明为UNIQUE。
第一次排查, 误入歧途
最初出现自动退出并没有引起注意,在和前端的同学联调的过程中,登录后退出的问题就充分暴露出来了。在排查的过程中,将连接属性persistent=false,发现退出后就无法登录系统了。这引入了一个新问题,偏离了原bug,但也定位出程序中的一个隐藏bug。为session函数添加了执行日志,并查看mysql中实际执行的语句序列。以sess_write和sess_destroy为例,代码片段为如下:
/*
* called when the session needs to be saved and closed
* @session_id session ID
* @data serialized version of the $_SESSION
* invoked when php shuts down or explicitly session_write_close is called
*/
public function sess_write($session_id, $data)
{
try
{
/*
* the session_id field must be defined as UNIQUE
*/
$query = 'INSERT INTO %s (%s, %s, %s) VALUES (?, ?, ?)'.
" ON DUPLICATE KEY UPDATE %s=VALUES(%s), %s=VALUES(%s)";
$query = sprintf($query, self::SESSION_TABLE, self::FIELD_SESSION_ID,
self::FIELD_SESSION_DATA, self::FIELD_EXPIRE_AT,
self::FIELD_SESSION_DATA, self::FIELD_SESSION_DATA,
self::FIELD_EXPIRE_AT, self::FIELD_EXPIRE_AT);
$stmt = $this->sess_pdo->prepare($query);
$stmt->bindValue(1, $session_id);
$stmt->bindValue(2, $data);
$stmt->bindValue(3, time() + $this->sess_lifetime);
$stmt->execute();
$write_count = $stmt->rowCount();
error_log("sess_write:(records num) write_count=" . $write_count, 3, "/tmp/logout.log");
//read data immediately after write
$query = 'SELECT %s FROM %s WHERE %s=? AND %s>=?';
$query = sprintf($query, self::FIELD_SESSION_DATA, self::SESSION_TABLE,
self::FIELD_SESSION_ID, self::FIELD_EXPIRE_AT);
$stmt = $this->sess_pdo->prepare($query);
$stmt->bindValue(1, $session_id);
$stmt->bindValue(2, time());
$stmt->execute();
list($sess_data) = $stmt->fetch();
error_log("sess_write:(read after write) sess_data=" . print_r($sess_data, true), 3, "/tmp/logout.log");
return (bool)$write_count;
}
catch(PDOException $e)
{
$this->sess_pdo = null;
die('error in ' . __METHOD__ . ' : ' . $e->getMessage());
}
}
/*
* executed when a session is destroyed with session_destroy or session_regenerate_id
* return TRUE for success, FALSE for failure
*/
public function sess_destroy($session_id)
{
try
{
$trace_arr = debug_backtrace();
$trace_str = '';
foreach($trace_arr as $index => $trace)
{
$trace_str .= '#' . $index . '|file=' . $trace['file'] . '|line=' . $trace['line'] .
'|class=' . $trace['class'] . '|function=' . $trace['function'];
}
$trace_str .= "\n";
error_log($trace_str, 3, '/tmp/logout.log');
$query = 'DELETE FROM %s WHERE %s=? LIMIT 1';
$query = sprintf($query, self::SESSION_TABLE, self::FIELD_SESSION_ID);
$stmt = $this->sess_pdo->prepare($query);
$stmt->bindValue(1, $session_id);
$stmt->execute();
return (bool)$stmt->rowCount();
}
catch(PDOException $e)
{
$this->sess_pdo = null;
die('error in ' . __METHOD__ . ': ' . $e->getMessage());
}
}
打开数据库执行语句序列的方法,参考SO上的问答:
mysql> show variables like 'general_log%';
mysql> SET GLOBAL general_log = 'ON';
--查看输出形式和输出文件存放位置
mysql> show variables like '%log_out%';
mysql> show variables like 'general_log_file';
用户登录成功后ssession_start()函数调用的sess_open对应执行的SQL序列为:
Query SET AUTOCOMMIT=0
Query SET NAMES utf8
Query SET character_set_client=binary
Prepare SELECT session_data FROM sessions WHERE session_id=? AND expire_at>=?
Execute SELECT session_data FROM sessions WHERE session_id='fd99bece38392b9e7d5fe49cf6de32c8' AND expire_at>='1375685562'
Close stmt
从SQL语句的执行序列中可以发现,一开始就执行了一个
SET AUTOCOMMIT=0
的语句。另一方面,从session处理函数的log中可以看到,每次sess_write之后,可以读到刚写的数据。然而断开连接后就无法读到session数据了。这正好与没有SQL语句序列中不自动提交语句相符和。在sess_open函数中设置每次都自动提交:
$this->sess_pdo->setAttribute(PDO::ATTR_AUTOCOMMIT, true);
此时就发现比较有意思的SQL执行序列:
Query SET AUTOCOMMIT=0
Query SET AUTOCOMMIT=1
Query SET NAMES utf8
Query SET character_set_client=binary
在InnoDB中, 默认是自动提交的, 可以通过下面的方法查看:
mysql> show variables like '%autocommit%';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit | ON |
+---------------+-------+
1 row in set (0.00 sec)
近一步检查代码,就可发现,原来在sess_open初始化PDO的时候,为了设置长连接参数,使用了一个array_merge操作,问题就出在这里, php手册中说过:
Values in the input array with numeric keys will be renumbered with incrementing keys starting from zero in the result array.
array_merge会对数值型下标重排,这样
array_merge(array(PDO::ATTR_PERSISTENT => $this->config['persistent']), $this->config['options']);
的结果并不是设置了长连接属性值,而是设置了PDO::ATTR_AUTOCOMMIT的值为false,从而导致无法登录的问题。至此,随机退出的问题没解决,反而发现了程序中一个隐藏的bug,长连接参数不起作用。这个bug的修正很直接: 只需要用+替换array_merge就可以了。
偶然发现玄机
可是随机退出的问题还是没有解决,直到今天,查看退出的日志才偶然发现是gc函数的缘故。其实早就该怀疑是gc异常导致的问题,只是受到上面一个bug的影响,一直也没有往这方面多想,再加上sess_gc中记录的日志太粗糙,没有记录删除语句影响的行数,每次都无法直观地看出问题来,记录日志的效果很不好。
sess_gc函数中执行的语句是:
$query = "DELETE FROM %s WHERE %s <= NOW()";
$query = sprintf($query, self::SESSION_TABLE, self::FIELD_EXPIRE_AT);
而数据库表中的数据类型是int。结果就很明显了,gc以php.ini中配置的概率执行,而每次执行的时候都会删除当前登录用户数据,从而造成用户的随机登出的现象。bug的修正也很直接,略去不记了。
小结
通过这次bug追踪,如果对常用函数如array_merge等,理解的不够细致,往往会引入隐藏的bug。日志是为了帮助解决问题的,而在gc函数中只记录了执行的sql语句,而没有记录语句执行所影响的行数,不但不利于排查,反而会让人误以为这里没有问题。此外,导致这次bug最主要的原因,也是容易让人陷入"这里没问题"的思维惯性从而导致很难发现bug的原因:数据结构的定义和操作函数一定要一致。