InfoQ

文章

Web Farm中异步、高效的用户登录解决方案

作者 Udi Dahan译者 罗小平 发布于 2008年1月22日 下午11时34分

社区
Architecture
主题
设计,
性能和可伸缩性,
Web服务

在我的咨询工作中,常常会碰到一些持如下观点的人:“有些东西并不适合使用异步模式”——尽管他们自己也认可异步通讯 模式与生俱来的稳定性。一个常常被引用的例子就是用户验证——将用户名和密码对提交给后端系统验证。为了讨论方便,我假设后端系统使用了用户数据库。

问题假设

为保证基本的安全性,我们假设密码在被存储前以某种散列算法编码。同时假设网络结构设计合理,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

3 条回复

回复

很好的经验之谈。 发表人 deshi xiao 发表于 2008年1月25日 上午12时58分
好吗? 发表人 max ma 发表于 2008年1月25日 上午3时52分
Re: 好吗? 发表人 wenjun lee 发表于 2008年1月26日 上午2时51分
  1. 返回顶部

    很好的经验之谈。

    2008年1月25日 上午12时58分 发表人 deshi xiao

    使用消息对列这种技术在内存中快速存储。确实是一个不错的办法。现在就差有实践的参考项目了,看看性能如何。

  2. 返回顶部

    好吗?

    2008年1月25日 上午3时52分 发表人 max ma

    在Web访问中,不只做用户验证吧。 比登录验证负载大的动作有的是,全都缓存?

  3. 返回顶部

    Re: 好吗?

    2008年1月26日 上午2时51分 发表人 wenjun lee

    同感,你说的是关键!

独家内容

Hadoop中的集群配置和使用技巧

本文介绍了Hadoop如何配置分布式框架运行环境,同时特别讲解了其中的一些细节。Hadoop可以单机跑,也可以配置集群跑,这里主要重点说一下集群配置运行的过程。本文是Hadoop入门实践三部曲的第二部。

JavaScript多线程编程简介

虽然有越来越多的网站在采用AJAX技术,但是开发复杂的AJAX应用仍然是个难题。本文探索了如何应用多线程缓解其中一些问题。

Ruby的开放类──或者:怎样避免动态打补丁

Ruby的开放类(Open Classes)功能强大,但很容易被误用。这篇文章关注于怎样减少使用开放类的风险,介绍了一些其他可替代的类似方法,并分析了其他语言如何实现类似的功能。

REST反模式

在本文中,Stefan Tilkov讲解了一些经常出现在自称“符合REST式设计”的应用中的反模式(比如:全部采用GET或POST,忽视缓存及响应代码,误用cookies,忘记超媒体与MIME类型,以及破坏自描述性等),并给出了避免这些反模式的对策。

分布式计算开源框架Hadoop介绍

Hadoop是Apache开源组织的一个分布式计算开源框架,在很多大型网站上都已经得到了应用,如亚马逊、Facebook和Yahoo等等。本文是Hadoop入门实践三部曲的第一部,主要讲述了What和Why的问题。

37 Signals的实用最小主义实践

本文结合37 Signals公司在开发Basecamp等产品时的实践,介绍了实用最小主义开发方法。实践证明,尤其是在开发Web应用时,这一方法非常有效。根据作者的观察,Google现在之所以那么成功,其所遵循的软件开发哲学和最小实用主义非常类似。

与林昊一起探讨OSGi

在今年5月份的网侠大会上,InfoQ中文站有幸与国内OSGi的先锋林昊(BlueDavy)在一起探讨了OSGi的相关话题,包括它的优势、复杂度以及Java下的实现等等。

超越F#基础——异步工作流

Robert Pickering在F#的第三篇文章中,他继续着上次的话题,不过这次他要关注的是异步工作流(Asynchronous Workflows),以及在使用这个特性后获得的性能改善。虽然这篇文章是关于F#的,但是这样的知识对于所有的.NET语言都是适用的。