由商品页的设计与实现说起

这是对我1年前设计与开发的商品详情页的总结,主要从性能、可用性和可维护性三个方面进行。

一、性能优化

    从整体架构、程序内部和运行环境三个层面进行性能优化。

1.   架构保障

由商品页的设计与实现说起

   由于现阶段商品数量处于千万级,并且结合商品的增长速度分析,以静态化方案为主即可满足未来一定时间的发展,具体静态化方案如下:

提前生成商品页的HTML文件存储到NGINX所在的服务器,当CDN缓存过期或者用户请求穿透CDN缓存时,直接从NGINX服务器上把提前准备好的HTML返回即可,无需进行数据库读取等操作,其中删除的商品和新增但还未静态化的商品通过动态服务程序查询数据库动态生成商品HTML返回,该方案会涉及到SSI技术、服务器间文件同步和全量静态化等问题,这儿就不一一详述。

2.   程序优化

2.1 数据库访问优化

    数据库相关优化内容实际包含数据库自身的优化(比如分库分表,索引的建立等以及数据库访问的优化)和数据库访问的优化:

  • 避免数据库进行类型转换;
  • 尽量不使用自定义函数; 
  • 尽量不查多余的数据;
  • 配置合适的数据库连接池,比如:系统要支持100并发,集群由4个节点组成,则单节点配置的最大的连接数需大于25,当然连接池的配置不仅仅只关注并发因素;
  • 查询时使用nolock关键字(当初使用的是sqlserver数据库),避免加锁。
    以优化点1:避免数据库进行类型转换为例说明商品页改进前后的性能差别:

  由于历史原因,商品表的id是String类型的,而其它使用商品id表的商品id是int类型,在优化前没有注意这个区别,在查询商品时是按照int类型进行查询,结果导致数据库对字段进行类型转换,且不再使用索引,这样每次查询均会全表扫描,且每扫描一条记录就进行一次类型转换,极其影响数据库性能。

l  改进前:tps:100,响应时间:600ms,数据库cpu消耗:90%;

l  改进后:tps:400,响应时间:40ms,数据库cpu消耗:30%。

注:测试时商品表有500万数据

2.2 CPU优化

程序的执行效率大多受制CPU的资源占用情况,因此在本章节将从多个方面探讨如何缩减对CPU的占用,响应1次请求占用的CPU时间越少,则可剩余越多的CPU时间处理更多的请求。

2.2.1  减少框架和第三方类库的使用

    框架和类库在带来方便等好处的同时,会增加程序执行的复杂度,占用更多的CPU时间,在商品页实现中:

  • 直接使用SERVLET,抛弃STRUTS2;
  • 直接解析JSON字符串,抛弃JACKSON。

    以抛弃JACKSON直接解析JSON字符串为例说明改进前后对CPU的占用情况:

  • 改进前:采用JACKSON解析JSON:耗时100ms。
  • 改进后:直接解析JSON字符串:耗时30ms。

注:以上数据是在服务器CPU消耗为80%时通过JPROFILER工具测得。

    如果对性能没有极致要求,且是有一定规模的系统,基于框架等开发,对团队的协作与效率以及代码的稳定性等方面或许具有更加明显的优势,具体在运用该方法时应根据具体的项目核心诉求进行权衡决策。

2.2.2   使用多线程并合理使用

    当单线程无法充分利用CPU时,可以使用多线程技术,但是对多线程的使用需把握一定的度,因为线程的创建、调度等比较耗资源,在使用多线程时具体开启线程数的多少视具体的场景而定,如果是计算密集性的应用则通常是开启与CPU核数相同或者+-1的数量,如果有IO等操作的应用则可以适当增加,以商品页的静态化为例讲述:

    1、商品页静态化的程序,经过性能测试发现,线程池大小设置为100时每小时生成的商品HTML个数最多,如果设置为200,每小时生成的商品HTML个数会下降,并且CPU的占用会增长1倍多,在商品页静态化场景中,瓶颈是磁盘IO,开启的更多线程大多在等待IO处理,但是由于开启了过多的线程,增加了线程管理的资源消耗,因此反而静态化效率更低。

    2、由于商品页比较复杂,涉及十几张表,若干SOA服务,因此在开发动态服务时,曾采用多线程并行处理数据库和SOA服务,由于测试环境的数据库性能和SOA服务的性能较差,因此如此做的确可以提高2~3倍的性能,但是在配置接近线上环境的测试环境进行性能测试时,发现性能只提高大概7%,但是当CPU占用到70%时,会导致REDIS经常性访问失败,系统不稳定。

2.2.3   提前准备

    提前准备好业务执行期需要使用的数据,简化程序执行逻辑,比如:

    在商品页静态化应用中,每次执行静态化的任务前均会先查询出商品分类、品牌等5个表的数据,并且解析成静态化单个商品时可以方便使用的数据,避免静态化每个商品均访问数据库和进行相同的数据处理,比如从数据库查询出分类信息后解析成如下三种数据结构:

由商品页的设计与实现说起
 在生成单个商品HTML时,通过三级分类id在psId3MapPs中获取到分类对象,通过分类对象获取到分类信息显示到商品页的分类导航区域等:

由商品页的设计与实现说起
 通过二级分类id在psId2MapPsId3中获取到所有的相关三级分类:
由商品页的设计与实现说起
 2.3 使用缓存

通常缓存的内容是存储在内存中,而文件和数据库的数据通常是存储在磁盘上,在硬件层面内存的访问数据通常比磁盘的访问快很多,因此在允许使用缓存的条件下可以尽量的使用缓存以提高数据访问速度。

商品页静态化应用和动态化服务均会缓存数据库的5张表(这5张表数据量小,更新不频繁,共大概50M),但是缓存策略不同,静态化应用是单机部署,只需要每次执行静态化前缓存一次,但是动态服务是集群部署,需要解决集群一致性和网络开销等问题,为解决这些问题,动态服务提供了双缓存设计:REDIS缓存+本地缓存(通过JAVA对象的类属性缓存数据,SERVICE层的商品SERVICE类是单例的,因此可以通过SERVICE类的实例对象属性缓存数据),REDIS只作为集群数据的唯一源,本地缓存从REDIS获取到数据后为程序执行提供数据。
由商品页的设计与实现说起由商品页的设计与实现说起
 
 双缓存设计解决如下问题:

  1. 集群中每个节点均会发起RELOAD CACHE的操作,采用REDIS的SETNX命令可以确保REDIS只会缓存最先到达的数据,从而确保REDIS的数据只来自集群的一个节点;
  2. 良好的CHECK和TRY机制确保在非极端情况下集群中各节点本地缓存的一致性;
  3. 每个节点均执行相同的操作,无须得知其它节点的缓存状态,避免了集群的状态同步,提高了集群的水平扩展性;
  4. REDIS和缓存的网络开销以及数据库的访问与TPS无关,只与集群的节点数和RELOAD缓存频率有关,避免了TPS越高,请求REDIS、数据库的次数越多,网络和数据库开销越大的问题。

双缓存在带来如上好处的同时也存在一定的问题:

  1. 程序设计的复杂度增加,不利于程序实现与维护;
  2. 在RELOAD期间,存在最长几十秒的数据不一致问题。

双缓存实现的代码示例:
由商品页的设计与实现说起
 
由商品页的设计与实现说起
 
由商品页的设计与实现说起
 
由商品页的设计与实现说起
 
由商品页的设计与实现说起
 
由商品页的设计与实现说起
 2.4 网络IO优化

在商品页实现中网络IO的优化主要体现在双缓存设计中,本地缓存的存在极大的减少了请求REDIS的次数和从REDIS加载的数据量,从而极大的优化了网络IO,优化网络IO即可以为商品页应用带来好处,也可以减少对所处网络环境的影响,进而避免通过网络影响到其它应用。
由商品页的设计与实现说起
 注:桥上右边的交通流量就如双缓存设计后的网络IO流量

3.   运行环境优化

运行环境的优化也至关重要,往往可以极大的提高应用性能,比如可以从以下几点考虑对环境的优化:

  • JVM,比如: -Xmx –Xms -Xmn –Xss -XX:PermSize -XX:MaxPermSize;
  • TOMCAT等WEB容器,比如:增加TOMCAT连接池的大小;
  • NGINX等WEB SERVER,比如:配置适合的KEEPALIVE时间和GZIP;
  • 操作系统,比如: 增大TCP并发数net.ipv4.tcp_max_syn_backlog;

三、可用性

1.   架构与运行环境保障

系统的稳健与可用首先需要整体架构和运行环境的保障,在商品页项目中,主要从以下5点提供保障:

  • 成熟的静态化方案,无需经历架构完善过程;
  • 集群部署,避免单点问题;
  • 采用高可靠的WEB SERVER:NGINX,在比较极端的情况下NGINX仍可提供服务;
  • 采用磁盘阵列RAID技术,减少磁盘导致问题的可能;
  • 集群的服务器位于不同交换机下,避免一个交换机问题导致整个商品页不可访问的问题。

2.   程序设计

有了整体架构的保障并不意味着应用就很稳健,如果程序的设计与实现很糟糕一样会导致系统的不可用,就如:千里大堤溃于蝼蚁。

2.1 失败后重试

在访问外部资源,比如:数据库、SOA服务等时,如果发生错误,最好有重试策略,避免偶发的问题导致整个处理失败,常言道:再给他一次机会、事不过三,套用该思路,程序亦然。

2.2 降级处理

当某个功能或者某种资源不可用时,如果允许降级则应降级处理,常言道:退而求其次、有总比没有好,比如:在调用销量排行服务获取商品页左边同类别排行榜数据时,如果调用失败则会从数据库中随机取6个同类商品显示到页面,该6个商品可能不是热销商品,但是至少在页面有商品信息,而不是空白。

2.3 设置超时时间

在调用外部资源,比如:访问REDIS、SOA服务时需要设置合理的超时时间,避免出现过多的无用TCP长连接,影响网络服务能力。

2.4 异常处理

异常总是无处不在,应正确的看待异常,期望出现无任何异常的程序几乎不现实,在程序可用性方面,如果方法中局部的逻辑异常不影响整个方法的执行,则局部代码必须进行异常处理,避免异常的扩散。

2.5 监控

如果公司有监控系统,则应尽可能的接入并充分利用(如果没有,也可以自己开发简单的监控程序),通过监控系统(程序)可以及时的发现已经产生的问题,也可通过监控的趋势图等预先发现问题,进行提前处理。

2.6 补救措施

当问题无法避免时,则只能在事后尽快补救,那么如何才能尽快补救是由具体面对的场景决定的,比如:数据库宕机问题,可以通过提前做双机备份,一旦一台宕机,马上启用另外一台备用服务器,再如:在商品页的实现中,提供了若干后台服务实现不用修改程序文件动态补救的措施(如果修改程序文件还需要走公司的上线流程,这是一个比较耗时的事),商品页提供了超过10个的后台控制服务,其中一个是:根据商品id生成商品页,当发现某个商品的页面有问题时,可以通过该服务重新生成该商品的HTML。

当然补救措施是否有以及是否完善是需要对项目面临的问题有比较深刻的认识,否则只能从一些公共的问题方面进行补救预案,比如:双机备份。

四、可维护性

    系统的可维护性并不会带来直观的收益,因此可能很多同学并不重视,但是实际上可维护性的好坏会在每天不经意间消耗我们的时间和精力,这些时间单看可能并不多,但是这些细碎的时间一经统计可能会很多,而且作为开发者的你是不是经常会因为面对一堆rubbish的代码或者缺少关键文档的代码而头疼,冒出若干XXX的话?

1.   关键文档

按照CMMI等过程改进模型规定在软件项目的整个过程应该有很多文档,但是通常如果一个项目要比较扎实的按照CMMI等模型的定义开展工作可能投入的成本会很高,这在国内的大多公司可能并不现实,但是一些关键的文档或者文档里的关键内容是不可或缺的,比如:用户需求、整体架构设计、数据库设计等文档,这些文档涉及的内容如果不经固化,会导致后续开发,维护等众多问题,而且很多工作的开展只能依赖人的传递,实际上最不靠谱的就是人,不是这样吗?

2.   注释

注释很重要,通过注释可以把程序的各种需要反馈的信息都反馈出来,以便后期维护,就算这些代码永远都只是原作者自己维护,但是也需要注释,常言道:好记性不如烂笔头。

2.1 类注释

在类中可以注释出该类的作用、关键方案、注意事项、使用举例等,通过这些信息应能很容易的整体了解该类,类注释在多人维护以及公共类库中发挥的作用体现尤其明显,以下是商品页的核心SERVICE类的类注释:
由商品页的设计与实现说起
 2.2 属性注释

需导出DOC的注释必须使用,对于私有方法,确定不会导出DOC的可以使用//节省源代码空间,方便阅读:
由商品页的设计与实现说起
 说明key和value,map的功能一目了然 :
由商品页的设计与实现说起
 说明变量存在的意义:

由商品页的设计与实现说起
 2.3方法注释

说明方法的作用、关键技术方案、核心功能、参数、返回值、异常等 :
由商品页的设计与实现说起
  接口中说明的内容可以通过inheritDoc注解引用,相同的内容只存在一个方法,避免存在差异:
由商品页的设计与实现说起
 3. 公共方法

2.1 项目级

项目中多处使用的代码可以提炼到公共方法中,一处实现,处处实现,一处修改,处处变化,例如:

//提供url的工具类

public class URLUtils {

    //商品详情页url

    public static String getProductURL(String skuId)

    public static String getProductURL(Integer skuId)

    public static String getProductURL(int skuId)//实现主体

   

    //图片url

    public static String getImageURL(String skuId, int size, String relativePath)

    public static String getImageURL(int skuId, int size, String relativePath)//实现主体

   

    //价格url

    public static String getPriceURL(int skuId, int size)

    public static String getPriceURL(String skuId, int size)

   

    //商品分类url

    public static String getProductSort1URL(int ps1Id)

    public static String getProductSort2URL(int ps1Id, int ps2Id)

    public static String getProductSortURL(int ps1Id, int ps2Id, int ps3Id)//实现主体

    …

}

 

2.2 类级

在类中的相同代码也尽量集中,比如:利用方法的重载,实现功能的扩展,核心实现只在一处:

public static String getProductSort1URL(int ps1Id) {

    return getProductSortURL(ps1Id, 0, 0);

}

public static String getProductSort2URL(int ps1Id, int ps2Id) {

    return getProductSortURL(ps1Id, ps2Id, 0);

}

public static String getProductSortURL(int ps1Id, int ps2Id, int ps3Id) {

    String ps2Url = ps2IdURLMap.get(ps2Id);

if ( ps1Id <= 0 ) {

        throw new IllegalArgumentException("一级分类id值异常,参数ps1id=" + ps1Id);

}

if ( ps2Id <= 0 ) {

       return ps1IdURLMap.get(ps1Id);

}

if ( ps3Id <= 0 ) {

      return (ps2Url == null ? "http://xxx.xxx.com/" +ps1Id+ "-" +ps2Id+".html" : ps2Url);

}

return "http://xxx.xxx.com/" +ps1Id+ "-" + ps2Id + "-" +ps3Id+ ".html";

}

4.   去掉大段的if-else

当有较多分支时,可以使用Map数据结构改进if-else,例如:
由商品页的设计与实现说起
 
由商品页的设计与实现说起

这段代码实现的功能如果是通过if else实现,则已经超过上百行了。

5.   可维护性还有很多,比如:业务的规划,子系统的拆分,模块的依赖,命名的规范等。

总之:软件的设计与实现不应只考虑到功能的实现,还需考虑到一些非功能性需求,比如:性能、可用性、可维护性、可扩展性以及安全等因素。

相关推荐