目前Sphinx广泛应用在Linux平台上,尽管官方所发布的产品中也有window版本,并且支持mssql数据库,但在使用过程中才发现,其只在发布的windows平台下的版本里才支持mssql数据库,而linux平台下只有MySql,PostgreSQL这两种数据库支持。尽管后来在网上查找资料时发现可以使用UNIXODBC方式在LINUX下链接MsSql数据库,但在unixodbc的官方网站下载的源码包中却发现其并不包含makefile文件,从而导致下载解压的源码包无法编译(看来unixodbc开发者也疏忽了),当然即使ODBC能链接成功,但效率上还是可能存在问题。
而在window下尽管能实现链接mssql和创建索引以及查询,但在进行压力测试时发现即使50并发也会让sphinx守护进程疲于奔命到停止响应(后来在Linux下布署,发现同样数据索引量同一台机器,可以支持至少200-300并发),而sphinx的宿主环境是一台单核1.5G+1.5g内存的普通台式机(现在主流笔记本的配置都比它高得多)。所以就并发性而言,目前主流平台还是建立在linux上.
正因为如此,我才在解决方案中引入了一个mysql数据库,来实现下面三个目标:
1.为LINUX平台下SPHINX提供可访问的数据源。
2.充当快照功能(Slave database)解决当主数据库(Master database)宕机后,不会影响SPHINX创建索引了,这样看就相当于备份功能。
3.解决当创建SPHINX索引时,对主数据库的访问压力。
下面这张图简单说明了Sphinx(Linux) + MySql + Discuz!NT的关系:
如上面所说的,我们在产品中引入了Sphinx客户端API,而服务端则通过LINUX+SPHINX守护进程来实现,所以这篇文章要被分为两个部分,今天要说明的只是Discuz!NT方面的改动。下一篇将会介绍如何在LINUX下配置SPHINX及增量索引等相关工作。好了开始正文吧。
首先为了简化安装,我写了一个文件类用于配置SPHINX客户端信息,如下(Discuz.Config\EntLibConfigInfo.cs):
/// Sphinx企业级查询配置信息
/// </summary>
public class SphinxConfig
{
/// <summary>
/// Mysql增量数据库链接地址
/// </summary>
public string MySqlConn;
/// <summary>
/// 是否启用Sphinx搜索
/// </summary>
public bool Enable;
/// <summary>
/// Sphinx服务地址
/// </summary>
public string SphinxServiceHost;
/// <summary>
/// Sphinx服务端口
/// </summary>
public int SphinxServicePort = 3312;
......
}
该配置类中提供了是否开启SPHINX查询(Enable),以及MYSQL数据库链接,以及SPHINX服务端的地址端口信息等。
下面介绍一下Sphinx的客户端代码(C#)实现,该代码被放到了Discuz.EntLib这个项目中(位于SphinxClient\),而该项目是一个基于GNU的开源项目,其类"SphinxClient"实现了构造方法和相应访问SPHINX服务端守护进程的方法,如下:
这些方法的名称和参数信息与SPHINX开放的API是对应的,而相应的SPHINX文档可以从官方下载,这里只提供一个中文文档的下载地址,也就是CORESEEK的文档链接, 如下:
http://www.coreseek.cn/uploads/pdf/sphinx_doc_zhcn_0.9.pdf
这份手册中介绍了大部分API方法的使用和示例,是目前为止网上找到最全的中文文档了。
有了客户端,我们还要在已有的搜索代码中植入SPHINX查询逻辑代码。
在原来的产品中,搜索功能是使用SQLSERVER全文检索的方法提供的,其原理是:
使用SQLSERVER全文检索方法查询帖子分表(dnt_posts,表结构如下图所示)的MESSAGE字段:
而该字段是Text类型,所以在一次性查询出所有记录的pid字段后,以distinct方法过滤其中记录重复的tid信息,最终会返回tid字段并将其放入到数据库中,相应SQL语句构造方法参照如下(Discuz.Data.SqlServer/GlobalManage.cs):
{
StringBuilder sqlBuilder = new StringBuilder();
string orderfield = "lastpost";
switch (resultOrder)
{
case 1:
orderfield = "tid";
break;
case 2:
orderfield = "replies";
break;
case 3:
orderfield = "views";
break;
default:
orderfield = "lastpost";
break;
}
sqlBuilder.AppendFormat(
"SELECT DISTINCT [{0}posts{1}].[tid],[{0}topics].[{2}] FROM [{0}posts{1}] LEFT JOIN [{0}topics] ON [{0}topics].[tid]=[{0}posts{1}].[tid] WHERE [{0}topics].[displayorder]>=0 ",BaseConfigs.GetTablePrefix,
postTableId,
orderfield);
if (searchForumId != "")
sqlBuilder.AppendFormat(" AND [{0}posts{1}].[fid] IN ({2})", BaseConfigs.GetTablePrefix, postTableId, searchForumId);
if (posterId != -1)
sqlBuilder.AppendFormat(" AND [{0}posts{1}].[posterid]={2}", BaseConfigs.GetTablePrefix, postTableId, posterId);
if (searchTime != 0)
sqlBuilder.AppendFormat(" AND [{0}posts{1}].[postdatetime] {2} '{3}'",
BaseConfigs.GetTablePrefix,
postTableId,
searchTimeType == 1 ? "<" : ">",
DateTime.Now.AddDays(searchTime).ToString("yyyy-MM-dd 00:00:00"));
string[] keywordlist = Utils.SplitString(strKeyWord.ToString(), " ");
strKeyWord = new StringBuilder();
for (int i = 0; i < keywordlist.Length; i++)
{
if (strKeyWord.Length > 0)
strKeyWord.Append(" OR ");
if (GeneralConfigs.GetConfig().Fulltextsearch == 1)
{
strKeyWord.AppendFormat("CONTAINS(message, '\"*", BaseConfigs.GetTablePrefix, postTableId);
strKeyWord.Append(keywordlist[i]);
strKeyWord.Append("*\"') ");
}
else
strKeyWord.AppendFormat("[{0}posts{1}].[message] LIKE '%{2}%' ",
BaseConfigs.GetTablePrefix,
postTableId,
RegEsc(keywordlist[i]));
}
if (keywordlist.Length > 0)
sqlBuilder.Append(" AND " + strKeyWord.ToString());
sqlBuilder.AppendFormat(
"ORDER BY [{0}topics].", BaseConfigs.GetTablePrefix);switch (resultOrder)
{
case 1:
sqlBuilder.Append("[tid]");
break;
case 2:
sqlBuilder.Append("[replies]");
break;
case 3:
sqlBuilder.Append("[views]");
break;
default:
sqlBuilder.Append("[lastpost]");
break;
}
return sqlBuilder.Append(resultOrderType == 1 ? " ASC" : " DESC").ToString();
}
如果上述的构造方法所拼接出的SQL语句被顺利执行后,就会在相应的dnt_searchcaches表中生成一条记录,形如:
注:<ForumTopics>表示其是论坛搜索的结果(因为产品中同时也提供了空间相册搜索功能,所以这样加以标识).
而dnt_searchcacheds数据字典如下(上面的ForumTopics对应表中的tids字段:text类型 ):
然后根据这些tid记录,按分页的大小一次获取其中一段数据(比如头10条:1,5,6,10,11,12,13,2,26,25),然后再用这段tid集合作为where条件 放到类似下面的查询语句中运行,就会获取相应的主题列表了(查询结果以主题列表而不是帖子列表方式呈现,这也是为什么要在GetSearchPostContentSQL中进行distinct的原因,因为一个主题可以有多个帖子,即1:n):
select * from dnt_topics where tid in (tid集合)
原理清楚之后,下面就是加入SPHINX查询逻辑了。因为SPHINX对全文索引进行查询时,会返回相应的documemntId,相应对帖子分表中的pid字段,所以只要将逻辑代码放到GetSearchPostContentSQL中就可以了,这里使用了配置文件开关的方式来标识是否执行SPHINX查询,如下:
{
//如果开启sphinx全文搜索时
if (!string.IsNullOrEmpty(strKeyWord.ToString()) && EntLibConfigs.GetConfig() != null && EntLibConfigs.GetConfig().Sphinxconfig.Enable)
{
return GetSphinxSqlService().GetSearchPostContentSQL(posterId, searchForumId, resultOrder, resultOrderType, searchTime, searchTimeType, postTableId, strKeyWord);
}
StringBuilder sqlBuilder
= new StringBuilder();string orderfield = "lastpost";
switch (resultOrder)
{
case 1:
orderfield = "tid";
break;
......
通过上述代码,可以看出GetSphinxSqlService()这个方法就是提供SPHINX查询和数据服务的接口,该接口定义如下:
/// 查询服务数据操作接口
/// </summary>
public interface ISqlService
{
/// <summary>
/// 创建Sphinx的数据表(目前只支持mysql数据库类型)
/// </summary>
/// <param name="tableName">当前分表名称</param>
/// <returns></returns>
bool CreatePostTable(string tableName);
/// <summary>
/// 创建Sphinx数据表帖子
/// </summary>
/// <param name="tableName">当前分表名称</param>
/// <param name="pid">帖子ID</param>
/// <param name="tid">主题ID</param>
/// <param name="fid">所属版块ID</param>
/// <param name="posterid">发帖人</param>
/// <param name="postdatetime">发帖日期</param>
/// <param name="title">标题</param>
/// <param name="message">内容</param>
/// <returns></returns>
int CreatePost(string tableName, int pid, int tid, int fid, int posterid, string postdatetime, string title, string message);
/// <summary>
/// 更新Sphinx数据表帖子
/// </summary>
/// <param name="tableName">当前分表名称</param>
/// <param name="pid">帖子ID</param>
/// <param name="tid">主题ID</param>
/// <param name="fid">所属版块ID</param>
/// <param name="posterid">发帖人</param>
/// <param name="postdatetime">发帖日期</param>
/// <param name="title">标题</param>
/// <param name="message">内容</param>
/// <returns></returns>
int UpdatePost(string tableName, int pid, int tid, int fid, int posterid, string postdatetime, string title, string message);
/// <summary>
/// 获取要搜索的主题ID(Tid)信息
/// </summary>
/// <param name="posterId">发帖者id</param>
/// <param name="searchForumId">搜索版块id</param>
/// <param name="resultOrder">结果排序方式</param>
/// <param name="resultOrderType">结果类型类型</param>
/// <param name="searchTime">搜索时间</param>
/// <param name="searchTimeType">搜索时间类型</param>
/// <param name="postTableId">当前分表ID</param>
/// <param name="strKeyWord">关键字</param>
/// <returns></returns>
string GetSearchPostContentSQL(int posterId, string searchForumId, int resultOrder, int resultOrderType, int searchTime, int searchTimeType, int postTableId, System.Text.StringBuilder strKeyWord);
}
设计这个接口的目的首先是解除Discuz.EntLib.dll与其它DLL文件的互相依赖。第二就是为了当本机用户发表或更新帖子信息时,会调用这个接口的中的相应方法来创建(CreatePost)或更新(UpdatePost)mysql数据库中的相应帖子记录,以确保sphinx获取索引数据的有效性。
可以通过反射的方法实例化该接口对象以便访问其中的方法,如下(GlobalManage.cs):
private static SphinxConfig.ISqlService sphinxSqlService;
private static SphinxConfig.ISqlService GetSphinxSqlService()
{
if (sphinxSqlService == null)
{
try
{
sphinxSqlService = (SphinxConfig.ISqlService)Activator.CreateInstance(Type.GetType("Discuz.EntLib.SphinxClient.SphinxSqlService, Discuz.EntLib", false, true));
}
catch
{
throw new Exception("请检查BIN目录下有无Discuz.EntLib.dll文件");
}
}
return sphinxSqlService;
}
#endregion
而在Discuz.EntLib中提供了该接口的MYSQL类型数据库实现方法(Discuz.EntLib\SphinxClient\SphinxSqlService.cs),如下:
{
private SphinxConfig sphinxConfig = EntLibConfigs.GetConfig().Sphinxconfig;
public int CreatePost(string tableName, int pid, int tid, int fid, int posterid, string postdatetime, string title, string message)
{
DbParameter[] prams = {
MakeParam("?pid", (DbType)MySqlDbType.Int32, 4, ParameterDirection.Input, pid),
MakeParam("?tid", (DbType)MySqlDbType.Int32, 4, ParameterDirection.Input, tid),
MakeParam("?fid", (DbType)MySqlDbType.Int16, 2, ParameterDirection.Input, fid),
MakeParam("?posterid", (DbType)MySqlDbType.Int32, 4, ParameterDirection.Input, posterid),
MakeParam("?postdatetime", (DbType)MySqlDbType.VarString, 20, ParameterDirection.Input, postdatetime),
MakeParam("?title", (DbType)MySqlDbType.VarString, 60, ParameterDirection.Input, title),
MakeParam("?message", (DbType)MySqlDbType.Text, 0, ParameterDirection.Input, message)
};
string commandText = "SET NAMES utf8;INSERT INTO `" + tableName + "` (`pid`,`tid`,`fid`,`posterid`,`postdatetime`,`title`,`message`) VALUES(?pid,?tid,?fid,?posterid,?postdatetime,?title,?message)";
try
{
return TypeConverter.ObjectToInt(ExecuteScalar(commandText, prams));
}
catch
{
CreatePostTable(tableName);
return TypeConverter.ObjectToInt(ExecuteScalar(commandText, prams));
}
}
public bool CreatePostTable(string tableName)
{
EntLibConfigInfo entLibConfigInfo = EntLibConfigs.GetConfig();
//数据库MY.ini文件中也要指定或在安装实例是即指定为utf-8
//SET NAMES utf8;CREATE DATABASE IF NOT EXISTS test;
string commandText = string.Format("SET NAMES utf8;" +
"CREATE TABLE IF NOT EXISTS `{0}` (" +
"`pid` INT(11) NOT NULL ," +
"`tid` INT(11) NOT NULL ," +
"`fid` INT(11) NOT NULL ," +
"`posterid` INT(11) NOT NULL ," +
"`postdatetime` DATETIME NOT NULL," +
"`title` varchar(60) NOT NULL," +
"`message` TEXT NOT NULL," +
"PRIMARY KEY (`pid`)" +
") DEFAULT CHARSET=utf8;",
tableName);
ExecuteScalar(commandText,
null);return true;
}
public int UpdatePost(string tableName, int pid, int tid, int fid, int posterid, string postdatetime, string title, string message)
{
DbParameter[] prams = {
MakeParam("?pid", (DbType)MySqlDbType.Int32, 4, ParameterDirection.Input, pid),
MakeParam("?tid", (DbType)MySqlDbType.Int32, 4, ParameterDirection.Input, tid),
MakeParam("?fid", (DbType)MySqlDbType.Int16, 2, ParameterDirection.Input, fid),
MakeParam("?posterid", (DbType)MySqlDbType.Int32, 4, ParameterDirection.Input, posterid),
MakeParam("?postdatetime", (DbType)MySqlDbType.VarString, 20, ParameterDirection.Input, postdatetime),
MakeParam("?title", (DbType)MySqlDbType.VarString, 60, ParameterDirection.Input, title),
MakeParam("?message", (DbType)MySqlDbType.Text, 0, ParameterDirection.Input, message)
};
string commandText = "SET NAMES utf8;UPDATE `" + tableName + "` SET `tid` = ?tid, `fid` = ?fid, `posterid`= ?posterid,`postdatetime` = ?postdatetime,`title` = ?title, `message` = ?message WHERE `pid` = ?pid";
try
{
return ExecuteNonQuery(commandText, prams);
}
catch
{
return 0;
}
}
public string GetSearchPostContentSQL(int posterId, string searchForumId, int resultOrder, int