Redis缓存组件开发规范

图片来自pixabay.com的katja会员

1. 简介

Redis是业界流行的缓存组件,为了规范Redis缓存的使用,避免落入各种问题陷阱,特此编写了此开发规范。本规范结合实际情况,描述了需要遵守的Redis最佳使用规约,及其供参考的最佳实践,供研发团队在项目开发中使用。

本文将出现如下规约术语,其中根据约束力强弱,规约分别有如下三级,
【强制】必须遵守的规约
【推荐】推荐遵守的规约,若无特殊情况,需要遵守该规约
【参考】参考遵守的规约,团队根据实际情况,可以选择性的遵守

每一个规约,根据情况,将有如下附加说明,

  • 说明:对规约进一步的引申和解释
  • 正例:提供一个正面遵守规约的样例
  • 反例:提供一个规约的反面样例,以及真实的错误案例,提醒误区

2. 规约

2.1 键值设计

  1. 【强制】键的命名使用英文小写和冒号、下划线、数字,其中冒号和下划线不能作为键名的开始和结束,而冒号为命名空间分隔符。不要使用中文和特殊字符作为健名。
    • 正例:"pphh:account:user1"
    • 反例1:"pphh.账号-USER1",该反例使用了点、中文、中划线、英文大写。
    • 反例2:":pphh:account:user1_", 该反例使用了冒号和下划线作为开始和结束
  2. 【推荐】键的命名尽量简单、清晰、易懂,长度尽量控制在32个字符以内,不要超过64个字符。
    • 说明:长的键名不仅消耗内存空间,而且会影响键的搜索查询速度。为了保证键的可读性,也不推荐太短的键名,比如"u1000flw",可以使用"user:1000:followers"来表达。
    • 反例1:"pphh:account:loooooooooooooooooooooooooooooooooooooooooog:name"
  3. 【推荐】键的命名空间分为三级,格式为 {系统简称}:{应用名}:{业务健名},命名空间通过冒号区分,公共键值以common表示。
    • 正例1:"web:home_page:click"
    • 正例2:"common:user_login:verification_code"
    • 反例3:"web_home_page_click"
  4. 【推荐】键的值存储空间大小尽量在10KB以内,不推荐存储大于1MB的值。

  5. 【推荐】对于列表键(Hash、List、Set、Zset),尽量控制元素个数不超过千万数量级,若大于这个数量级,建议通过键值切分列表。

  6. 【推荐】建议List当做队列来使用。

2.2 应用规约

  1. 【推荐】业务应用尽量不在Redis存储持久状态,并且需要考虑和实现键值随时失效时的数据更新方法。

  2. 【推荐】对于键值,建议设置随机失效时间,避免同一时间大量键值失效,从而导致的缓存雪崩问题。

    • 说明:设置合适的随机失效时间,以避免在同一时间大量key失效。
  3. 【推荐】在使用缓存时,关注访问不存在键时导致的缓存穿透问题,关注热key导致的缓存击穿问题。
    • 说明:可以通过监控机制获取当前热key的访问情况,以便改进键值分布,详情见下文的监控告警。
  4. 【强制】禁止使用全表搜索命令,比如keys等命令。
    • 说明:请使用scan实现游标式的遍历,建议通过rename-command将keys命令禁用。
  5. 【推荐】使用scan实现游标式的遍历,实现全表搜索。

  6. 【推荐】对于O(N)时间复杂度的Redis命令,在使用时要预估N的大小,建议N不大于1000。

    • 说明:对于O(N)的Redis命令,当N的数量级不可预知时,则应避免使用。对一个Hash数据执行HGETALL/HKEYS/HVALS命令,通常来说这些命令执行的很快,但如果这个Hash中的元素数量级增大后,耗时就会成倍增长。
  7. 【推荐】键的排序、并集、交集等操作时间复杂度都在O(N),建议这些排序、并集、交集操作放在应用代码里执行。
    • 说明:使用SUNION对两个Set执行并集操作,或使用SORT对List/Set执行排序操作等时,当列表元素很多时,容易导致Redis操作时间很长,形成阻塞。
  8. 【强制】禁止如下命令的使用:flushall,flushdb
    • 说明:键值的删除建议通过scan和del命令相配合的方式删除,建议通过rename-command将flushall和flushdb命令禁用。
  9. 【推荐】推荐使用Redis的批量操作命令,比如MSET/MGET/HMSET/HMGET等等,以取代多次单键操作。
    • 说明:一个MSET批量操作命令可以替代多次SET操作,这可以大大减少维护网络连接和传输数据所消耗的资源和时间。

2.3 服务端配置

  1. 【推荐】搭建Redis集群,保障高可用服务,对热键可以考虑读写分离的架构实现。

  2. 【推荐】选择RDB的数据持久化方式。

    • 说明:Redis支持RDB和AOF两种持久化方式,相比AOF,RDB方式性能影响最小,其保存快照时是通过fork的子线程进行,几乎不影响Redis处理客户端请求,生成的快照文件大小也比AOF方式小,从快照中恢复数据的速度也要更快。RDB的缺点是其快照生成是定期的,在Redis出现问题时会出现数据丢失,这需要业务代码能够进行一定的容错处理(即:Redis缓存不保证数据的一致性)。
    save 900 1     # 每900秒检查一次数据变更情况,如果发生了1次或以上的数据变更,则进行RDB快照保存
    save 300 10    # 每300秒检查一次数据变更情况,如果发生了10次或以上的数据变更,则进行RDB快照保存
    save 60 10000  # 每60秒检查一次数据变更情况,如果发生了100次或以上的数据变更,则进行RDB快照保存
    
  3. 【建议】合理使用Redis database功能
    • 说明:Redis database主要是实现命名空间的功能,实际工作中通过冒号区分命名空间已经足够。Redis的多数据库功能比较简单,不同的数据库支持空间查询、键值清空等操作,并且多个数据库还是单线程处理,相互之间有影响,不同数据库是通过数字区分。
  4. 【推荐】关注网络带宽和延时
    • 说明:相比CPU和内存,网络带宽和延时对Redis服务带来的性能影响更加明显、更加直接。一个4KB的字符串,在10000 q/s的高并发请求量下需要的带宽达312.5 Mbit/s,这个需要千兆带宽的网卡。
  5. 【推荐】设置最大内存,不超过物理内存空间的大小,避免使用swap空间。
    • 说明:默认情况下,在32位OS中,Redis最大使用3GB的内存,在64位OS中则没有限制。当应用使用的虚拟内存空间暂满了物理内存空间,则会开始使用swap空间,可以通过maxmemory来设置Redis的可用最大内存。注意设置时预留一定的主机物理可用内存,以便其它应用运行。
    maxmemory 4000mb
    
  6. 【推荐】设置数据淘汰策略,当Redis的内存空间达到最大可用内存后,则可以根据有效的淘汰策略尝试淘汰数据,释放空间。
    • 说明:默认情况下,Redis不进行数据淘汰。如果当Redis的内存空间达到设置的maxmemory后,若没有配置数据淘汰策略,或者没有数据可以淘汰,那么Redis会对所有写请求返回错误。推荐使用volatile-lru,使用LRU算法进行数据淘汰(淘汰上次使用时间最早的,且使用次数最少的key),其只淘汰设定了有效期的key。
    maxmemory-policy volatile-lru   # 默认是noeviction,即不进行数据淘汰
    
  7. 【强制】开启慢查询记录。
    slowlog-log-slower-than 10000  # 执行时间慢于10毫秒的命令计入慢查询日志
    slowlog-max-len 1024  # 慢查询日志记录长度
    

2.4 客户端配置

  1. 【强制】使用连接池
    • 说明:Redis连接的创建非常耗时,Redis服务的键值查询一般在微妙级别,但是网络连接和传输的耗时在毫秒级别,因此通过连接池来保持连接,可以有效地保证Redis缓存的请求速度,避免频繁连接带来的性能损耗。
  2. 【强制】合理配置连接池
    • 说明:Redis服务端可支持的连接数是有限的,合理配置连接池,可以有效地支持高并发需求场景,也可以在空闲时节约连接数。这些配置包括:最小和最大空闲连接数、空闲连接淘汰策略等。

2.5 安全

  1. 【强制】Redis在设计时要求运行在一个安全的内网,因此禁止暴露Redis服务到外网空间。
    • Redis在设计之初是为了最大的性能考虑,在安全上考虑比较弱,因此不应该暴露Redis服务到外网空间,也就是不应该有外网的客户端访问Redis TCP端口服务。
  2. 【强制】设置用户登录密码

2.6 监控告警

  1. 【强制】定时监控慢查询,对于慢查询操作进行分析并解决,避免再次发生。

  2. 【推荐】定时分析Redis big keys,避免占用空间大的Redis健值,合理拆分。

    • 说明:可以通过redis-cli --bigkeys -i 0.1命令查询大的Redis健值。
  3. 【推荐】定时分析Redis热键,设置合理的过期失效时间和更新时间,设计合理的键值分布。
    • 说明:在Redis 4.0版本以上,可以通过redis-cli --hotkeys命令查询大的Redis热键。
  4. 【推荐】定时分析Redis内存空间使用情况,通过RDB快照获取内存空间,查出占用空间大的键值,并进行拆分修复。

3. 参考资料

  1. Redis官方文档
  2. Redis官网:数据类型简介
  3. Redis官网:性能简介
  4. Redis官网:安全简介
  5. 阿里云Redis开发规范
  6. Redis基础、高级特性与性能调优
  7. 阿里云Redis最佳实践:内存分析方法
  8. 阿里云Redis最佳实践:Redis 4.0 热点Key查询方法

中小企业web应用的架构演化

图片来自pixabay.com的hansbenn会员

本文从简单的单体应用架构说起,逐一介绍企业web应用在其演化进程之路上的各种典型架构,并对于各个架构阶段所面临的挑战进行讨论,最后讨论中小企业应用的终极架构目标:同城主备、异地双活。

1. 单体应用架构

在企业的创世阶段,应用开发是一个从0到1的阶段,选择一个简单架构,不仅可以快速搭建起业务,验证业务模式的正确性,还可以方便业务的快速频繁迭代。因此,很多创业企业都是从单体应用架构开始,这个应用架构的典型特点是:单应用+单数据库。

其架构示意图如下,

虽然是简单架构,麻雀虽小但五脏俱全,在这个阶段,选择合适的前后端技术栈和web框架非常重要,尽量为未来发展创造条件。

1.1 面临的挑战

业务的快速变化,应用迭代频繁,这是初期企业应用面临最大的挑战,这个时期,数据模型的设计甚至比技术栈的选择更加困难。很多时候,一旦业务发展起来了,再想对历史数据推倒重来,会是非常困难的一件事情。

为了能够面对业务的快速迭代,数据模型和应用尽量小范围、内聚、低耦合,在应用内尽量实现无状态的数据流转(比如:数据的事务操作),为未来的调整扩展留出空间、做好打算。

2. 高可用、高可靠、高性能的集群架构

当公司的业务发展起来后,对应用高可用、高可靠、高性能的要求随之而来,于是每一个应用服务部署多个实例,将其挂载在SLB下,通过SLB实现流量的均衡负载,这种集群部署方式其架构简单、技术成熟,在中小企业应用中非常流行。

为了保证数据的安全,一般会搭建起数据库的主从架构,主库用于读写,从库用于读。

2.1 架构示意图

一个集群架构的示意图如下,

图中显示了数据库的主从架构,更多的数据库架构设计可以选择,比如主主、一主多从、双主多从等。

2.2 面临的挑战

集群架构简单,部署起来也不复杂,刚开始很容易形成怎么快怎么做的问题,随着部署的应用数增多,机器环境的管理、应用的灰度部署和快速回滚、问题的定位等等,都会让部署问题显得特别突出。

这个时候,Jenkins等持续集成和持续交付工具的使用,配与一套行之有效的上线规范,将有利于上述问题的解决。

3. 微服务化架构

当公司的业务规模发展壮大到一定程度,应用数达到成百上千时,整个系统的复杂度将成倍增长,如何管控系统的复杂度是需要解决的问题。

为了解决这个问题,各种业界基础架构组件涌现登场,这是其发挥神通广大的时候,这些基础技术组件若按其实现目的有如下三种归类,

  1. 组件抽象(即各种中间件):缓存、消息、数据库访问、网关、隔离熔断、日志
  2. 系统的复杂度管理:配置、灰度发布、日志、监控、告警
  3. 架构解耦:RPC服务组件(RPC框架、服务注册和发现等)

从技术角度,各种中间件的使用相对比较独立,一般在企业应用架构早期便可引入;而对于配置、日志、监控、告警等组件,则会按需引入,这些组件的尽早使用将大大加速系统的运维效率。

而对于RPC服务组件,这是微服务化架构的核心组件,其主要解决服务和服务之间调用的问题。在引入RPC之前,应用和应用之间的调用通过HTTP + 域名 + 网关访问,这种方式的调用有三个主要弊端,

  • HTTP通过网关跳转,通信效率低
  • 网关成为流量瓶颈
  • 应用的上线和下线需要频繁通知网关

RPC服务组件解决了上述的弊端,服务和服务之间可以通过IP直连调用,大大提高了服务和服务之间的访问效率,各个服务的上下线可以通过注册和发现机制自动感知,这是微服务化架构所带来的最大的改变。为了对接内外服务调用,在流量层也需要添加一层内网网关,不仅可以实现服务的内外服务调用转换,还可以实现微服务集群的按应用、按环境隔离。

3.1 架构示意图

一个微服务架构的简要示意图如下,

注:上图勾勒了微服务架构所需的逻辑组件,图中并未画出内外服务的网络调用路径,但这也是搭建微服务架构特别需要关注的方面。

3.2 面临的挑战

为了解决系统的复杂度,引入大量中间件和RPC组件,引入的同时本身就加大了对系统的技术投入,用好、管理好这些基础组件需要较深的技术储备。

从另一个方面,各种基础组件的组合解决方案也层出不穷,花样繁多,这也考验着微服务架构的搭建好与坏。选择合适、成熟的解决方案,谨慎引入一个技术组件,对一个技术组件的考察,不仅考虑其开发使用、和整体架构的融合度,还要考虑其未来的升级,充分的考察和评审有助于搭建良好的微服务架构。

在数据库这一层,一旦某业务表的存储记录达到上亿级别,则需要考虑其分库分表的需求。

4. 多机房架构

当公司的业务规模持续发展,若经营盈利能力较好,业务模式上也有需求,就可以考虑多机房的投资建设。

从架构上有如下几种选择,

  • 同城主备:在同一个城市,搭建主副双机房,主机房正常接流量,副机房则冷备;容灾时主副流量切换。
  • 异地双活:在两个较远距离的城市,搭建双机房,两个机房都同时接流量;容灾时流量切换到可用机房。
  • 两地三中心:在两个较远距离的城市,搭建三机房,其中异地两个机房同时接流量,另外一个机房冷备;容灾时随时启用备用机房,流量切换到可用机房。金融行业为了保证数据和业务安全,有两地三中心的架构需求。

多机房的建设一方面满足业务的需求,实现用户就近访问,提高访问效率;另一个方面实现容灾建设,保障了数据不丢失、服务不中断的容灾目标。

对于大多数中小企业应用来说,同城主备、异地双活为终极架构目标。

4.1 架构示意图 - 同城主备

一个同城主备的示意图如下,

4.2 架构示意图 - 异地双活

异地实现双活,在应用层、数据层可以有两种实现方案,

  • 方案1:两地提供等同的应用服务;数据层根据机房位置,实现异地的数据库冷备。
  • 方案2:应用层根据业务归类,实现异地的应用服务冷备;数据库表根据业务应用归类,实现异地互为冷备。

两种架构模式的示意图如下,

  • 实现方案1

  • 实现方案2

4.3 面临的挑战

多机房架构的挑战主要来自网络架构方面,即使多机房之间拉取了双专线,网络的长距离传输导致的延时(大于100毫秒),对于实现容灾时的网络流量及时切换,会是挑战不小的工作。

因此,在多机房建设的前期,需要对如下方面进行充分的调研,

  1. 网络架构设计:机房双专线搭建、VPC网络搭建、运行环境搭建
  2. 容灾的自动化切换:监控告警、容灾预案、自动化切换(接入层、应用层、数据层)
  3. 面临数据时延性传输的挑战:数据库的DTS同步、应用对数据一致性的容错、避免跨机房的访问调用

5. 架构演化的简单对比

架构演化 企业发展阶段 基础设施投入 人员投入 技术架构
单体 初期 1-2人 简单
集群 中期 一般 2-5人 中等
微服务 中长期 5-10人 复杂
多机房 根据业务需求和经济能力 非常高 10+人 复杂

6. 小结

任何一个成长型企业,软件架构的演化都将经历一个从简单到复杂的进化过程,在不同的阶段有不同的功能职责要求,需要搭建起相应合适的应用架构,承载起企业的发展。

对于大多数中小企业应用来说,基本可以做到集群架构,但是在微服务架构上的搭建好坏,取决于团队的技术储备。受限于经济投入和应用场景,多机房的建设是需要仔细考量后再做定夺。

7. 参考资料

  1. 阿里云:《数据库异地多活解决方案》

RestController方法返回Boolean对象时SpringFramework报告HTTP Status 406 – Not Acceptable

图片来自pixabay.com的mrgajowy3会员

1. 问题描述

一个简单的spring mvc样例,spring-webmvc框架版本为5.1.5.RELEASE,里面定义了两个RestController接口方法,

@RestController
@RequestMapping(value = "/api", produces = "application/json; charset=utf-8")
public class SimpleController {

    @RequestMapping(value = "test", method = RequestMethod.GET)
    public String test() {
        return "hello,world";
    }

      @RequestMapping(value = "test2", method = RequestMethod.GET)
    public Boolean test2() {
        return Boolean.TRUE;
    }

}

通过http客户端工具调用/api/test接口能够正常返回信息,但是调用/api/test2时,报如下错误信息,

<body><h1>HTTP Status 406 – Not Acceptable</h1><hr class="line" /><p><b>Type</b> Status Report</p><p><b>Description</b> The target resource does not have a current representation that would be acceptable to the user agent, according to the proactive negotiation header fields received in the request, and the server is unwilling to supply a default representation.</p><hr class="line" /><h3>Apache Tomcat/8.5.37</h3></body>

后端日志报如下HttpMediaTypeNotAcceptableException异常信息,

org.springframework.web.HttpMediaTypeNotAcceptableException: Could not find acceptable representation

2. 问题调查

问题奇怪在于,接口api/test能正常返回信息,但接口api/test2报错。比较下两个接口,都差不多,区别在于:前者返回String,后者返回Boolean对象。

调试跟踪api/test2的接口,发现异常HttpMediaTypeNotAcceptableException在如下位置被抛出,

/** 调用栈
 * DispatcherServlet.doDispatch()
 * AbstractHandlerMethodAdapter.handle()
 * RequestMappingHandlerAdapter.invokeHandlerMethod()
 * ServletInvocableHandlerMethod.invokeAndHandle(webRequest, mavContainer, providedArgs)
 * this.returnValueHandlers.handleReturnValue(returnValue, this.getReturnValueType(returnValue), mavContainer, webRequest);
 * AbstractMessageConverterMethodProcessor.handleReturnValue()
 * this.writeWithMessageConverters(returnValue, returnType, inputMessage, outputMessage);
 *
 * 到达类AbstractMessageConverterMethodProcessor中的如下方法,
 * 注:如下为反编译代码,非源码,仅供问题调查
 */

protected <T> void writeWithMessageConverters(...){
    ...
    HttpMessageConverter converter;
    GenericHttpMessageConverter genericConverter;
    label138: {
        if(selectedMediaType != null) {
            selectedMediaType = selectedMediaType.removeQualityValue();
            var21 = this.messageConverters.iterator();

            while(var21.hasNext()) {
                converter = (HttpMessageConverter)var21.next();
                genericConverter = converter instanceof GenericHttpMessageConverter?(GenericHttpMessageConverter)converter:null;
                if(genericConverter != null) {
                    if(((GenericHttpMessageConverter)converter).canWrite((Type)declaredType, valueType, selectedMediaType)) {
                        break label138;
                    }
                } else if(converter.canWrite(valueType, selectedMediaType)) {
                    break label138;
                }
            }
        }

        if(outputValue != null) {
            // 异常抛出点
            throw new HttpMediaTypeNotAcceptableException(this.allSupportedMediaTypes);
        }

        return;
   }
   ...
}

查看上传代码,可以了解异常抛出的原因,是因为没有找到合适HttpMessageConverter,无法转换返回的消息。

调试查看this.messageConverters对象,发现有converters列表如下,

this.messageConverters ( ArrayList size = 7)
[0] ByteArrayHttpMessageConverter
[1] StringHttpMessageConverter
[2] ResourceHttpMessageConverter
[3] ResourceRegionHttpMessageConverter
[4] SourceHttpMessageConverter
[5] AllEncompassingFormHttpMessageConverter
[6] Jaxb2CollectionHttpMessageConverter

这个列表没有发现我们熟悉的Json Converter,于是接下来开始追查messageConverters的列表初始化,为什么没有Json converter?

查看spring mvc源码可以看到如下的convert初始化过程,
1. 在类RequestMappingHandlerAdapter中创建this.messageConverters列表对象,并加载各个缺省convert。
2. 其中AllEncompassingFormHttpMessageConverter被创建,在这个类中,其会检查当前JVM中是否加载了Jackson/Gson相关类,若有,则加载json converter。

public class RequestMappingHandlerAdapter {

  public RequestMappingHandlerAdapter() {
      StringHttpMessageConverter stringHttpMessageConverter = new StringHttpMessageConverter();
      stringHttpMessageConverter.setWriteAcceptCharset(false);
      this.messageConverters = new ArrayList(4);
      this.messageConverters.add(new ByteArrayHttpMessageConverter());
      this.messageConverters.add(stringHttpMessageConverter);
      this.messageConverters.add(new SourceHttpMessageConverter());
      this.messageConverters.add(new AllEncompassingFormHttpMessageConverter());
  }

}

public class AllEncompassingFormHttpMessageConverter {

    private static final boolean jackson2Present = ClassUtils.isPresent("com.fasterxml.jackson.databind.ObjectMapper", ...);
    private static final boolean gsonPresent = ClassUtils.isPresent("com.google.gson.Gson", ...);

    public AllEncompassingFormHttpMessageConverter() {
      ...
      if(jackson2Present) {
        this.addPartConverter(new MappingJackson2HttpMessageConverter());
      } else if(gsonPresent) {
        this.addPartConverter(new GsonHttpMessageConverter());
      } else if(jsonbPresent) {
        this.addPartConverter(new JsonbHttpMessageConverter());
      }
      ...
    }
}

由此可以看到,缺少json converter的原因就是缺少相应的Jackson/Gson类库,而默认的converter无法对Boolean对象转换,从而产生HttpMediaTypeNotAcceptableException异常,接而导致HTTP 406的返回消息。

3. 问题解决

在项目中添加如下依赖,加载MappingJackson2HttpMessageConverter到this.messageConverters列表中,问题得到解决。

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>${fasterxml.version}</version>
</dependency>