业务层之本地缓存运用分享

作者:bo11120022025.02.25 11:17浏览量:13

简介:作者在文章中分享了他在处理一个类似“双11”活动规模的分布式高并发项目中的经验与见解。他指出,传统的

开篇引入
早年,我负责一个分布式高并发项目,类似于“双11”活动的规模,需要在极短的时间内处理海量的高并发数据。当时,我们主要采用的技术栈是PHP + MySQL,通过MVC架构在后端(服务器)生成前端HTML代码,然后提交给浏览器展示。ASP.NET和JSP的开发也大多采用这种模式。许多开发者认为这种做法是无可厚非的,但实际上,它对服务器资源的消耗极大,资源利用率非常低。
在这个过程中,大量的计算资源被消耗在不断重复的对象创建(内存分配)和销毁上,而这些分配和销毁操作本身就非常耗时。同时,频繁的数据库操作也是资源消耗的一个大头。如今,开发者似乎忽略了new操作带来的开销,随意地使用new,认为现代机器内存大、多核CPU处理能力强,加上固态硬盘读写速度快,便不再有太多担忧。但真的是这样吗?今天,我想针对这一现象,分享我的观点和解决策略,探讨如何在低开销的前提下最大化服务器性能。
理解业务对象的生命周期

在项目刚开始的时候,程序员们主要就是忙着完成任务和功能,这样在项目初期或者成长期的时候,性能问题不会太明显。但是,一旦项目进入成熟期,客户数量猛增,大量的业务操作会让服务器压力山大,系统可能会瘫痪。这时候,就得花钱增加硬件,比如多加几台服务器。但是,因为业务模式的限制,你可能会陷入一个无休止的循环,不停地打补丁修bug。即便这样,服务器的压力还是不会减轻(特别是在数据服务器上)。可能有人会说,那就加个缓存机制吧,比如加强数据库的缓存,或者增加对象和查询缓存(比如用Redis)。这个主意听起来不错,但实际操作起来会很头疼,经历过这个过程的程序员肯定深有体会。

用缓存当然是个好办法,但关键是要在开发阶段就开始考虑这个问题。如果到了开发后期才想起来加缓存机制,那就得要么打补丁,要么重构代码,不管哪种方式都很痛苦。

理念

咱们先聊聊我的小秘诀,想要让系统跑得飞快,就得用空间来换时间。咱们得用对象缓存来减少对数据库的依赖,这样就能少跑数据库,少创建对象,还能减少对象间的复杂关系。要做到这些,就得设计出合适对象类型。
顺便说一句,如果咱们有台超级电脑,它有无限的处理能力,内存多到用不完,永远都不会断电,保证永远不会宕机,数据也不会丢失,简直就是完美的服务器。那我得问一句,咱们还需要数据库吗?数据库还重要吗?其实数据库就是个存储东西的地方,需要的时候能找回来就行。说到底,处理数据只需要CPU和内存就够了。当然了,这种完美的服务器是不存在的,但我想说的是,别太依赖数据库(存储介质),真正构建虚拟(镜像)世界的核心是对象。
来,看这张图:

这是三层结构——最基本的系统分层结构,其中表示层和数据层均配备了缓存机制。在此,我将不深入探讨这两层。在系统中,最为核心的部分是业务层,它也是相对难以处理的。接下来,我将重点阐述业务层设计缓存机制的基本理念。

业务层的设计开发时的注意事
1.编程语言的选择
建议优先选择混合式强类型面向对象的编程语言,如Java、C#、C++,而非脚本类解释型过程化语言,例如PHP、Python、Ruby、JavaScript。若因特定需求选择了后者,最好能与一种强类型语言搭配使用,以作补充。
跨进程缓存操作涉及序列化与反序列化过程(例如文本结构与JSON对象之间的转换),这会带来性能开销。我不排斥序列化和反序列化的应用,但使用进程缓存(本地缓存 )会优先考虑。
2.关于数据表的结构设计
在数据表设计的七大原则中,结构单一原则,亦可理解为功能单一原则或原子原则。我们通常会将一行数据直接转换为一个对象进行处理,目的是为了降低对象结构的复杂性。避免设计包含多个功能的大型数据表,因为这虽然在初期看似方便,但一旦需求变更,问题就会接踵而至。在查询记录集时,应尽量采用单表查询,避免使用联表查询来加载对象。这是因为当考虑缓存时,字段数据的冗余和对象变更的唯一性问题会变得尤为重要(如果查询仅用于生成报表,直接将记录集提供给前端,中间不经过业务层转换为对象,则联表查询是可行的)。原因将在后文解释。
3.对象结构设计
在实际开发中,我们常常采用一种通用的架构模式,如MVC或Spring等。无论我们从数据库中检索出何种记录集,最终都需要将其转换为相应的对象,并构建成一个对象集合,以便提供给前端展示。面对一个简单的查询,为何不直接将记录集呈现给前端呢?这个问题是否曾引起过你的思考?可能许多程序员对对象的理解还不够深入。我的看法是:当我们需要修改数据,或者需要对数据进行可靠性验证时,构建一个对象来处理是必要的。如果仅仅是为了展示数据,实际上并不需要构建对象。构建对象的真正目的是为了更好地服务于数据本身(例如,在变更、验证或动态组合数据时)。数据库无法完成的任务,正是对象所要承担的。
当一个联表记录被转换成一个冗余对象时,例如,表a包含字段[a1,a2],表b包含字段[b1,b2],联表查询结果被加载为对象A-B[a1,a2,b1,b2]。如果需要对A-B对象或其中任一表进行验证和变更,那么缓存中的A或B该如何处理呢?我明白这可以通过某些方法来处理,但这种处理方式很可能会破坏业务功能的完整性和唯一性(如果你还不太明白,随着实践的深入和体会的积累,你会逐渐理解的)。
4.同样是维护单一性原则,还有两个方面的考量

1: 将表a转换为A对象的过程被称为创建原子对象,它体现了功能的单一性和唯一性。在全局范围内,任何对A对象数据的变更都必须由A对象本身来处理。如果B对象或其他对象需要使用或操作A对象的数据,它们必须通过引用A对象来进行,不允许B对象绕过A对象直接对a表进行操作。虽然代码可以这样编写,但这样做可能会带来一些不良后果。然而,在实际应用中,我们可能需要实现B(B1,B2,A1)这样的效果。对此,有两种处理方法:组合方式B(B1,B2, A, get_A1-> A.A1)和继承方式B:A(B1,B2, B.A1),通常优先考虑组合方式。

2: 此外,对象的单一性也是构建抽象层的基础条件。尽管大家可能都听说过抽象层这个术语,但很少有书籍具体讲解如何构建抽象层,通常只有理论上的描述。在继承方式中,A和B之间的原始逻辑关系就是一种抽象的实现。当需求发生变化时,通过继承方式重写或扩展功能可以改变对象的行为。抽象层的作用是在保持对象自身及对象间业务逻辑不变的前提下,改变特定对象的功能。其效果是在不修改源代码的情况下,通过扩展方式修改了某个功能的代码,而不是直接修改原有功能代码。同样,当数据表结构发生变化时,也应只进行新增操作,不修改现有结构,然后通过继承方式新增一个类。在业务中,对象之间的关联、引用、传递(方法中对象参数)以及使用,都应基于原子类型(抽象类型),除非主业务逻辑需求发生变更,否则你的修改通常不会影响整个业务逻辑(这种效果仅限于业务层,表示层及数据层在大多数情况下也需要做相应的调整)。其他层的代码变更处理将在后续讨论。

5.概括一下这一原则的核心
单一原则确保了对象的唯一性,避免了对象及其属性和功能的冗余。在任何时刻,这个对象都是独一无二的。一条记录对应一个对象,同时也对应一个唯一的缓存项。这个对象要么不存在(即为0),要么存在且唯一(即为1)。当其他对象引用这个对象时,它们都指向同一个唯一的对象。因此,当该对象发生变化时,所有引用它的对象也会同步更新,无需编写或考虑同步数据的代码。同样,对象内部的改变不会影响到其他对象。(例如:若我想改变自己,只有我自己愿意改变才行;别人无法强迫我改变。然而,若我要“消失”(即销毁或删除),我无法自行完成,需要他人介入。创建一个新对象也是如此,如果对象不存在,就无法自行产生。至于分布式部署,我们稍后再讨论其解释和实现方法。)另一个需要维护的观点是:销毁或创建对象需要外部“操作”,但这些功能仍然是对象自身的特性。因此,销毁或创建的实现代码必须编写在对象类型内部,但由外部调用执行(我拥有生存和消亡的特性,这些特性我自己不能控制,但别人可以让我的”消失”)。

6.层之间的数据交互的方式选择
无论是三层架构还是N层架构,核心在于确保各层的独立性和隔离性。分层的主要目的之一是为了促进分工协作,具体可以划分为:
物理层面的分工,例如UI设计师、服务端开发人员、SQL开发人员等,这部分分工相对直观。
思想层面的分工,例如在实现业务逻辑层时,开发人员无需关注其他层的实现细节;同样,在开发表示层时,也不会担忧业务逻辑层的实现是否会对上下文层造成影响。这就像同时用左手画方形和右手画圆形的感受一样;只有各自独立,互不干扰,才能专心完成工作;分层目的是为了保持业务逻辑的完整性,确保即使表示层和数据层发生变化,例如将webUI更改为windowsUI,数据库从mysql变更为mssql,业务层的代码也无需修改或仅需进行少量修改。
层间交互的基本原则:
在设计每一层时,应将其视为一个独立的系统。层与层之间通过预先定义的协议进行数据交互,仅依赖这些协议来实现各自的代码。这就像螺丝和螺母的关系,我们不关心它们的材质或外观,只关注孔径、直径和螺距是否匹配,以确保它们能够正确组合。层间的实现也遵循同样的逻辑,你无需了解我的内部实现细节,只要实现了我的接口协议,反之亦然,这样我们就可以无缝协作。
这种做法带来的好处是,一旦制定出产品功能说明书,各层的开发人员就可以并行开展工作。在完成各自的任务后,通过对接协议接口,整个系统便可以进行测试和验收。虽然这在实际开发中可能难以完全实现,但相较于传统的流水线作业,这种方法在效率、沟通成本和系统稳定性方面具有明显优势。

7.缓存策略的选择
在讨论缓存时,人们常常提及Redis和Memcached这两种服务端缓存组件,尤其是在Web服务开发中,似乎对服务器端缓存组件有着明显的偏好。然而,服务端缓存涉及跨进程的TCP连接,缓存对象需要进行序列化和反序列化操作(对于JSON或XML格式的存储对象,这一过程尤其耗时),频繁的访问可能会对服务器造成压力。相比之下,本地缓存或进程内缓存(如使用map结构)则显得更为迅速,因为它直接存储对象,无需任何中间转换,但其局限性在于无法跨进程共享对象。像PHP和Python这样的脚本语言,其指令执行速度快,并且通常采用多进程方式处理请求,但数据库往往无法跟上它的速度。因此,在这类语言的Web开发中,本地缓存并不常见,因为请求处理完成后,所有资源都会被销毁,下一次请求时又需要重新构建。在这种情况下,只能依赖跨进程的服务端缓存组件,这实属无奈之举。因此,需要一种支持本地缓存功能的语言介入;(每种语言都有其独特的优势和不足,关键在于如何合理利用)

上述讨论仅提供了结论而未深入解释原因。实际上,每个要点都足够撰写一篇详细的文章。对此感兴趣的读者可以查阅相关资料。

实例

我们先俯视看一下整体上的一个过程图,下面我们说一个实例并使用伪代码来实现
业务说明:
参与活动的用户,在通过审核后,将获得一些小礼物,例如入场券——尽管这并非关键。
表结构设计如下:
user: [id, name(用户名)]
user_address: [id, user_id(用户ID), address(地址), linkman(联系人)]
activity: [id, content_desc(活动内容), begin_time(开始时间), end_time(结束时间)]
user_to_active: {id, active_id(活动ID), user_id(用户ID), to_time(参与时间), is_pass(是否通过审核), address_id(地址ID)}
表结构设计已经涵盖了1:1、1:N以及N:N的所有关系。在实际开发过程中,我通常会先构建对象模型,然后根据模型来设计数据结构,接下来就可以实现对象的类结构(伪代码)。
业务模型
User->
{
实体属性
ID->{get;set;}
Name->{ get;set; }

重写内置的对象比较
override bool Equals(object obj) { return this.ID == obj.ID }

实现把一个数据集转换为对象(很多组件实现了自动转换,由于可能表结构或业务需求的变更,代码也需要变动,如:把两个表合成一个对象或字段类型的特殊转换.. )
static User To_Object(dataReader)
{ this.ID = dataReader[‘id’] ; this.Name = dataReader[‘name’] ; }

重要,依据ID获取某个对象,id不存在则创建,否则从本地缓存中获取
public static User GetByID(int id)
{
string key = string.Format(“U_{0}”, id); //生成一个唯一无二的缓存Key
User theInfo = MyCach.Get(key) as User; //从全局缓存返回对象
if (theInfo == null){
表示没有缓存项,查询数据库获取dataReader,调用User.To_Object方法返回实例对象
dataRader = AccessDB.getDataReader(sql);
theInfo = User.To_Object(dataRader);
一旦对象被加载,它将被存放到缓存中。
请注意:此操作意味着所有对象都将被缓存,或者说,所有对象都将成为缓存的一部分。Mycac中包含一个消息接收器,其参数为key,用于从缓存中移除指定的缓存对象。当有代码尝试获取该对象时,若返回null,则会从数据库中重新获取一个新的对象。这一机制仅在进程消息通知时被激活使用。
MyCach.Set(key, theInfo , DateTime.Now.AddHours(24));
缓存时间为24小时,如果不设置,则永久保持,如果是常变化的数据我都会加一个缓存有效时间,主要防止过度占用缓存空间
}
return theInfo
}

用户名变更的功能方法, Result->{errText,isOK,…, returnData=null }
public Result Update_Name(name)
{
name是从外部的请求处理层输入,常理上name参数已进行了验证,但仍然对它进行验证
if(name==””|| name.len > 10 ..) return resutl ;
dataParames[id]=this.ID;
dataParames[name]=name ;
Result rs = AccessDB.Update( sql , dataParames );
if(rs.IsOK == true) this.Name = name ;
在数据更新后,进程内的对象属性无需手动刷新。由于当前对象是缓存中唯一的引用,因此当其他代码(例如多线程环境)访问该对象时,它们获取的也将是更新后的对象。这里需要注意的是多线程环境中的脏写问题。尽管没有实现lock(线程锁定)机制,但在调用AccessDB.Update方法时,会有lock处理(该方法会被锁定,直至返回结果)。这意味着在任何给定时刻,只有一个线程能够处理update操作(通过队列处理,确保只有一个写入连接)。
进一步阐述:在分布式部署业务进程时,理论上所有API都可以被调用。然而,在实际操作中,调用是根据上层请求(HTTP)处理的情况进行分片的,即每个进程负责不同的功能区域。
Event_DelKey(this.id);
在多进程环境下,当一个对象发生变化时,我们需要通知其他进程(这些进程可能位于不同的服务器上)。在更新数据表记录的同时,相关记录会被锁定。这里需要特别说明的是,在更新多个表记录时,并未采用事务处理(是否使用事务,我曾深思熟虑过):
原因1:每个表都只负责特定的功能,大多数情况下,操作都集中在单一表内,跨表更新的情况较为罕见。如果频繁出现跨表更新,这可能意味着数据设计存在重大问题。
原因2:如果需要更新的表分布在不同的数据库或服务器上,那么事务处理的成本将会非常高。基于这些原因,我决定不使用事务。如果确实需要进行跨表更新,我会手动进行事务处理,逐个更新对象。如果在更新过程中遇到失败,则会执行回滚操作(使用当前对象的值重新更新)。
}

查询指定数目的记录集
public User[] Get_AllUser_top(int top)
{
userList[] = new User[top];
ps = [top];
reader = AccessDB_SelectReader(sql , ps ) ;
for(reader)
{
if (reader.Read())
{
id = reader[i++][id];
User theInfo = User.GetByID(id) ;
if(theInfo != null) UserList.Add(theInfo);
初次见到这段代码,您可能会感到困惑,心想频繁地查询数据库岂不是效率低下?起初,我也有同样的顾虑。但请放心,User.GetByID 方法中已经实现了缓存机制(如上文所述),SQL查询仅限于获取id(例如,通过执行类似 “select id from user where top=?” 的查询)。理论上,每条记录只会被查询一次。因此,尽管该方法每次调用时可能都会触发一次数据表查询,但之后再次调用时,数据将直接从缓存中获取,而无需再次访问数据库。经过压力测试,访问速度非常快,虽然没有问题,但在生产环境中尚未经过验证,心里难免有些忐忑。然而,在多个项目中实践后,我逐渐放心了。有一次,服务器遭受了长达两个多小时的DOS攻击,即便是知名的数据中心也束手无策,无法识别哪些请求是恶意的。所有的攻击请求都渗透到了服务器上,导致请求处理端(PHP)和数据库都无法正常工作。但令人欣慰的是,业务层的进程仍然能够正常处理请求,未受到任何影响。
如前所述,如果只是进行简单的查询操作(例如报表生成),则无需采用这种处理方式。直接查询并返回结果集通常由上层的请求处理层完成,无需经过业务层。这种对象转换操作仅适用于业务逻辑的变更,以及对整个业务状态的维护和控制。如果仅用于纯查询,其效果并不显著,反而可能显得过于繁琐,就像用牛刀杀鸡,多此一举。
}
}
return userList ;
}

用户的地址信息(1:N)
UserAddres->{

实体属性
ID->{get;set;}
user_id->{ get;set; }
Address->{ get;set; }
Linkman->{ get;set; }

私有构造,防止其它类去实例化当前对象
private UserAddres(){ }
同User对象类似的处理
override bool Equals(object obj) { … }
static UserAddress To_Object(dataReader){ … }
public static UserAddress GetByID(int id) { … }

新增地址记录, 和上面Use->Update_Name一样的原则,注意这个是静态全局方法
public static Rresult AddInfo( User theUser , address , linkman )
{
rs = AssccDB.Insert(sql , ps[theUser.ID ,address, linkname])
if(rs.IsOK == true)
{
id = (int)rs.Data ;
UserAddress theInfo = UserAddress .GetByID(id) ;
}
return rs ;
}
更新当前对象的address和linkman,注意这个是实例方法, 先存在这个对象才能修改
public Rresult Update_Info( address , linkman ) { … }
修改流程大致相似,这些更新操作的实现方式是根据业务需求定制的。例如,针对用户界面需求,如果地址(address)和联系人(linkman)需要独立更新,那么我们就需要设计两个独立的方法来进行相应的修改。

获取当前地址所关联的用户, 这样子就可能可以把对象连接起来了
public User GetUser( ){
return User.GetByID( this.User_ID ) ;
}
//获取某个用户的所有地址信息
public UserAddress[] GetListByUser( User theUser )
{
ps = [theUser.ID ] ;
AccessDB.SelectReader( sql , ps ) ;
….
和User.Get_AllUser_top 方法类似处理
}

}

//活动信息类,主要是描述活动的内容及规则
Activity->{
//实体属性
ID->{get;set;}
ContentDesc->{ get;set; }
BeginTime->{ get;set; }
EndTime->{ get;set; }

private Activity(){ } 防止其它类去实例化当前对象
override bool Equals(object obj) { … }
static Activity To_Object(dataReader){ … }
public static Activity GetByID(int id) { … }
上面几个方法实现基本上一样,把上面的代码copy过来修改一下就可以了

获取某个用户参加的活动列表
public static Activity[] GetListByUser(User theUser) {

sql = “select id from activity where id in ( select id from user_to_active where user_id={theuser.ID} ) ” ;
activity 和 user_to_active 两张表是一个逻辑整体,部署的时候一定要放在同一个数据库下,所以这两个表是可以联表查询的,但任何时候,返回的结果集都是单表单行来加载对象的
Activity theInfo = Activity .GetByID( reader[i++][‘id’] ) ;
Activity[].Add( theInfo)
return Activity[] ;

如果后期因某原因,需要独立部署两个表,只需要在UserToActive类中增加一个方法: UserToActive[] UserToActive.GetByUser( User theUser ) ,当前类就新增一个重载方法的样子:Activity[] GetListByUser(User theUser ,UserToActive[] ), 然后把对象集合中的active_id , 排列成 where id in ( 1,4,56,6… )

}

用户参与活动的关系表(N:N)
UserToActive->{
实体属性
ID->{get;set;}
ActiveID->{ get;set; }
UserID->{ get;set; }
AddressID->{ get;set; }
ToTime->{ get;set; }
IsPass->{ get;set; }

获取关联的用户
public User GetUser( ){
return User.GetByID( this.User_ID ) ;
}
获取关联的活动
public Active GetActive( ){
return Active.GetByID( this.ActiveID ) ;
}

获取关联的地址
public Address GetAddress( ){
return Address .GetByID( this.AddressID ) ;
}

private UserToActive(){ } 防止其它类去实例化当前对象
override bool Equals(object obj) { … }
static UserToActiveTo_Object(dataReader){ … }
public static UserToActiveGetByID(int id) { … }
上面几个方法实现基本上一样,把上面的代码copy过来修改一下就可以了

获取某个用户的所有活动,参考:Activity.GetListByUser方法
public static UserToActive[] GetByUser(User theUser ) { … }

}

业务api中调用模型中的对象:

获取某用户的活动列表为例子,User与Activity 是N:N的关系,中间表:UserToActive

uid = request[user_id]; 获取参数:某用户ID
User theUser = User.GetByID(udi) ;
if( theUser == null ) return new Result( Isok=false, Description=‘用户不存’) ;
Activity[] list = Activity.GetListByUser( theUser) ;

Result rs = new Result( Isok=true , Description=‘200’ , list ) ;

strJson = Json.ToJson( rs ) ;
response.wirte(strJson) ;
response.end() ;
基本返回json给上层(请求处理层)
返回json格式: {isOK:true , Description:200 , Data:[ {ID:xx,ContentDesc:xx,… }… ] }

扩展:组装对象
先做个公用的继承键值对的类做为包装类
PackMap:Map ( key ,Value ) ->{
Object Data->{get;set}
}

uid = request[user_id]; 获取参数:某用户ID
User theUser = User.GetByID(udi) ;
if( theUser == null ) return new Result( Isok=false, Description=‘用户不存’ ) ;
Activity[] list = Activity.GetListByUser( theUser) ;

PackMap pack = new PackMap(“MyUser” , theUser ) ;
pack.Data = list ;

Result rs = new Result( Isok=true , Description=‘200’ , pack ) ;

strJson = Json.ToJson( rs) ;
response.wirte(strJson) ;
response.end() ;

返回json格式: {
isOK:true , Description:200 ,
Data:
{
MyUser: {ID:xx,Name:xx,… } ,
Data:[ {ID:xx,ContentDesc:xx,… },{…} ]
}

组装的方式很多,就不一一枚举了,建议用树形结构,尽量不要用扁平的数据结构
扁平的数据结构:
[
(user_id , user_name , activity_id , activity_conent_text , …. ),
(user_id , user_name , activity_id , activity_conent_text , …. ), …
]

树形结构:
{
user_id , user_name , [{ activity_id , activity_conent_text , …. }, ….]
}

总体而言,该业务模型具备高度的灵活性,允许将多个业务层部署在多个服务器上。数据表同样可以分布在不同的服务器和数据库中。由于业务层依赖于缓存(作为数据消费端,它无需了解数据来源,类似于购车者只需会使用车辆,而无需了解车辆制造过程及零件来源),因此它并不依赖于数据库。对于业务层的上层,即请求层,也不需要了解使用的具体编程语言或架构,只要遵循协议约定(即契约),明确输入与输出的对应关系。
根据业务需求,明确处理边界,实现良好的隔离,可以降低业务代码的复杂性和耦合度,同时提升部署的灵活性。这些原则构成了整个业务层的核心思想。

相关文章推荐

发表评论