在我的咨询工作中,常常会碰到一些持如下观点的人:“有些东西并不适合使用异步模式”——尽管他们自己也认可异步通讯 模式与生俱来的稳定性。一个常常被引用的例子就是用户验证——将用户名和密码对提交给后端系统验证。为了讨论方便,我假设后端系统使用了用户数据库。
问题假设
为保证基本的安全性,我们假设密码在被存储前以某种散列算法编码。同时假设网络结构设计合理,Web服务器在DMZ区域中得到隔离,它与应用服务器交互,应用服务器再与数据库服务器通讯。当然,再在Web服务器间(尤其是对用户登录等功能)应用轮循式(round- robin)负载均衡,也是一个很好的想法。
在深入讨论这个问题前,先来段开场白。我发现人们不感冒异步模式,大多是因为没有考虑应用的实际发布环境,或者解决方案不需要以多服务器、Web Farm或多数据中心的分布式模式部署。
同步解决方案
在同步解决方案中,每个Web服务器对于每个用户的登录请求,都必须与应用服务器通讯。换句话说,应用服务器上的负载(数据库服务器也是类似的)将随用户登录数正比上升。
我不想在这里对同步解决方案多加纠缠,因为以前已经分析得太多了。在这种系统中,数据库最终都会成为瓶颈。为解决这个问题,一般会采用数据库分割方法 。很多大型站点配备有多个只读数据库——主数据库负责数据更新,并将这些数据复制到只读数据库。如果在LAMP架构下使用廉价的MySQL,这是一个很好的解决方案;但如果运行Oracle或MS SQL Server,就不是那么回事了。
无论你在数据层动什么手脚,都回避不了这个问题。将数据访问操作限制在Web服务器内部不是很好吗?即便使用廉价的Apache,系统也会运行得更为流畅。异步解决方案的本质,就是以较小的内存代价,换取对其他资源很大的节省。
异步解决方案
在异步方案中,我们可将用户名/散列密码对缓存在Web服务器的内存中,并用缓存中的数据实现对用户的验证。首先,我们分析一下这种方法对内存的消耗量。
用户名一般不超过12个字符,我们在这里大方些,假设平均为32个字符。使用Unicode编码后,每用户名占用64字节。散列加密的密码因算法而异,会占用256到512位,即最长64字节。因此总的是128字节。也就是说,使用Web服务器上1GB内存,我们可以安全缓存8百万用户名/密码对。如果你有1百万用户(也不错了,行啊你),则只需要消耗128MB内存——这对于不需要花多少钱就可以配备2GB内存的服务器来说,小意思了。
新用户注册时,我们可以在Web服务器缓存中检查是否存在该用户名。当然,若考虑到并发问题,还需要通过数据库再次检查,但毫无疑问,数据库的负载已经大大降低。另外值得一提的是,在这种方案中,已经不存在只读数据库副本和数据复制操作。换个角度看,其实是我们的Web服务器充当了“数据库副本”。
验证服务
整个系统的核心模块是应用服务器上的“验证服务”,用于处理来自Web服务器的所有登录请求,当然包括新用户及其信息的注册。以前我们总是使用同步模式,新方案的不同之处在于新用户注册时,它会发出一个消息。此服务保证所有Web服务器都能收到各自订阅的全部用户名/散列密码对信息。
在这里,我使用开源通讯框架nServiceBus说明此方案的实现过程,当然你也可以使用其他任何消息处理或ESB方案。在nServiceBus中,一条物理消息可包括多条逻辑消息,这样我们可以模拟单个更新消息发布,用相同类型的逻辑消息返回整个结果清单。我们定义如下消息:
[Serializable]
public class UsernameInUseMessage : IMessage
{
private string username;
public string Username
{
get { return username; }
set { username = value; }
}
private byte[] hashedPassword;
public byte[] HashedPassword
{
get { return hashedPassword; }
set { hashedPassword = value; }
}
}
定义需要整个清单时,Web服务器发出的消息:
[Serializable]
public class GetAllUsernamesMessage : IMessage
{
}
Web服务器启动时执行的代码大致如下(可在构造函数中注入依赖对象):
public class UserAuthenticationServiceAgent
{
public UserAuthenticationServiceAgent(IBus bus)
{
this.bus = bus;
bus.Subscribe(typeof(UsernameInUseMessage)); // 订阅更新类消息
bus.Send(new GetAllUsernamesMessages()); // 请求整个清单的信息
}
}
当验证服务收到消息GetAllUsernamesMessage时,它的消息处理器将首先访问用户名/散列密码缓存,然后构造一个新的消息,并返回给请求者,代码如下:
public class GetAllUsernamesMessageHandler : BaseMessageHandler
{
public override void Handle(GetAllUsernamesMessage message)
{
this.Bus.Reply(Cache.GetAll ());
}
}
消息UsernameInUseMessage到达Web服务器时,负责处理的类定义如下:
public class UsernameInUseMessageHandler : BaseMessageHandler
{
public override void Handle(UsernameInUseMessage message)
{
WebCache.SaveOrUpdate(message.Username, message.HashedPassword);
}
}
应用服务器向Web服务器发送整个清单时,UsernameInUseMessage类的多个实例会被包含在单独一条物理消息中。而Web服务器上的bus对象则每次只会向如上的消息处理器发出一条逻辑消息。
这样,实际验证一个用户时,Web页(如果你使用MVC,也可叫做控制器)将调用:
public class UserAuthenticationServiceAgent
{
public bool Authenticate(string username, string password)
{
byte[]existingHashedPassword = WebCache[username];
if (existingHashedPassword != null)
return existingHashedPassword == this.Hash(password);
return false;
}
}
注册新用户时,Web服务器当然会首先检查缓存,然后发出一条包含了用户名和散列密码的消息RegisterUserMessage:
[Serializable]
[StartsWorkflow]
public class RegisterUserMessage : IMessage
{
private string username;
public string Username
{
get { return username; }
set { username = value; }
}
private string email;
public string Email
{
get { return email; }
set { email = value; }
}
private byte[] hashedPassword;
public byte[] HashedPassword
{
get { return hashedPassword; }
set { hashedPassword = value; }
}
}
消息RegisterUserMessage到达应用服务器时,如下流程的新实例负责处理:
public class RegisterUserWorkflow :
BaseWorkflow ,IMessageHandler
{
public void Handle(RegisterUserMessage message)
{
//通过message.Email发出包含了this.Id(一个guid号,是URL的组成部分)的确认请求
}
///
/// 用户点击email中的确认链接后,Web服务器发出包含了流程Id的消息UserualidatedMessage
///
public void Handle(UserValidatedMessage message)
{
// 将用户存入数据库
this.Bus.Publish(new UsernameInUseMessage(
message.Username, message.HashedPassword));
}
}
消息UsernameInUseMessage最终将到达所有订阅了它的Web服务器。
性能/安全性的权衡
更深入考察整个流程,我们发现实际可实现为两个独立的消息处理器,并可用email地址代替流程Id。不过,在这个改进的替代方案中必须考虑安全问题。删除对流程Id的依赖,即表示我们可在未收到消息RegisterUserMessage前收到UserValidatedMessage。
因为UserValidatedMessage的处理过程消耗的资源相对较多——写入数据库,并向所有Web服务器发布消息,恶意用户用不多的消息就可以发起拒绝服务攻击(DOS),同时也能躲开多数探测系统的眼睛。而要想依靠GUID欺骗则困难得多。不过,因为注册流程的处理实例可以缓存于内存中,这将大大降低相关数据搜索带来的资源消耗——甚至可以小到在探测系统察觉前,DOS攻击不能发生作用。
对带宽和服务器资源的要求降低
这个解决方案,使我们可以通过扩展Web层,规避对数据层的巨大压力和扩展需求。同时,它也能大大节省带宽消耗。对于用户名和密码而言,这看起来不是大问题,但在其他一些情况下,需处理的数据量可能大很多。当然,在此方案中处理用户信息所需的时间也会大大缩短,因为我们不需要在Web服务器(位于DMZ)、应用服务器和数据库服务器间来回奔走。
在这个解决方案中,我们应该谨记的部分是消息发布/订阅。nServiceBus提供的在消息发布/订阅基础上设计系统的API十分简单。消息发布,是实现系统扩展性的核心部分。随着用户的增长,你只需要增添Web服务器,而不是数据库服务器。在整个解决方案,每请求所消耗的平均要小很多,因为所有的工作都是在接收请求的服务器本地完成。
锦上添花:ETags
为方便高级用户,我们还将此解决方案封装成了ETags。Web服务器停止运行时,缓存会被清空,我们能做的就是将缓存内容记录到磁盘上去(可用后台线程),并用服务器随UsernameInUseMessage消息一起传给我们的某种数据作为标记。这样,Web服务器重新启动后,它请求GetAllUsernamesMessage时可同时发出ETag,应用服务器就只需要发送有变动的数据。使用“If-Modified-Since”头HTTP GET的REST(译者注:可参看 深入浅出REST),也能很好解决这个问题。所有这些措施,都可以依靠Web服务器上磁盘空间的较小消耗,大大降低对网络带宽的需求。
结束语
即便你只有一台机子,同时充当Web和数据库服务器,在这个解决方案基础上构建的系统的运行效率也会很高。如果服务器更多,性能自然会更好。不仅如此,此方案还极具可扩展性——即使你得到了8百万Facebook用户,也不会因为遭受重大冲击而必须修改整个系统架构。
更多信息
http://www.nservicebus.com/
nServiceBus是一个用于构建企业级.NET系统的开源通讯框架。它在消息发布/订阅支持、工作流集成和高度可扩展性等方面表现优异,因此是很多分布式系统基础平台的理想选择。
Podcast on Autonomous Servers and Publish/Subscribe
我们在这里主要研究服务自治、消息发布/订阅、异常、数据复制、系统复用和监管等领域的问题。
作者简介
Udi Dahan:以主张简化软件闻名,Microsoft Solutions Architect MVP,公认的.NET专家,Microsoft Architects和Technologists Councils会员。
Udi为遍布世界各地的客户提供培训、指导和高端架构咨询服务,特别是想在SOA、.NET架构扩展和安全性设计和Web Service领域。
他也是INETA(International Speakers Bureau of the International .NET Association)的会员、IASA(International Association of Software Architects)准会员,经常出席各种技术会议;Dr. Dobb's期刊Web Service、SOA和XML专栏作家。他的网址是http://www.UdiDahan.com。
查看英文原文:Asynchronous, High-Performance Login for Web Farms