一致性哈希

本文谈谈一致性哈希,一致性哈希作为「负载均衡」中比较常见的一种实现,经常会有意无意的被大家使用到。我希望通过这篇文章可以使得你完全明白:

  • 一致性哈希要解决的问题
  • 一致性哈希的原理
  • 一致性哈希的优点
  • 一致性哈希的不适用场景
  • 如何手动实现一致性哈希
  • 常见开源代码中的一致性哈希实现

一致性哈希要优化的问题

一致性哈希要解决的问题,或者说目标,其实用一句话概括就是:在hash value区间有限并且可能会发生变化的情况下,相同的hash key尽可能得到同一个hash value

上面短短的一句话,我们可以得到一些重要信息:

一致性哈希的原理

上面短短的

一致性哈希负载均衡需要保证的是“相同的请求尽可能落到同一个服务器上”,注意这短短的一句描述,却包含了相当大的信息量。“相同的请求” — 什么是相同的请求?一般在使用一致性哈希负载均衡时,需要指定一个 key 用于 hash 计算

一致性哈希的优点

相比于传统的「取模」哈希,一致性哈希减少了因为服务节点变更导致的key的映射关系失效的数量

一致性哈希的不适用场景

nginx开启gzip

前几天看到一个nginx的文章,突然想到我还没有为我的博客的nginx开启gzip压缩呢。所以今天就弄了一下。

下面是我在nginx配置中增加的开启gzip相关的配置:

nginx增加gzip配置

1
2
3
4
5
6
7
8
#gzip
gzip on;
gzip_min_length 1k;
gzip_buffers 4 32k;
gzip_comp_level 4;
gzip_types text/plain application/javascript application/x-javascript text/javascript text/xml text/css;
gzip_vary on;
gzip_disable "MSIE [1-6]\.";

然后重新使得nginx加载配置:

1
/usr/sbin/nginx -s reload

gzip指令描述

  • gzip on 这个指令用来控制开启或者关闭gzip模块,默认值为gzip off代表默认不开启gzip压缩
  • gzip_min_length 1k 置允许压缩的页面最小字节数,页面字节数从header头中的Content-Length中进行获取。默认值: 0 ,不管页面多大都压缩
  • gzip_buffers 4 32k 设置系统获取几个单位的缓存用于存储gzip的压缩结果数据流。 例如 4 4k 代表以4k为单位,按照原始数据大小以4k为单位的4倍申请内存。 4 8k 代表以8k为单位,按照原始数据大小以8k为单位的4倍申请内存默认值: gzip_buffers 4 4k/8k 如果没有设置,默认值是申请跟原始数据相同大小的内存空间去存储gzip压缩结果。
  • gzip_comp_level 4 gzip压缩级别,压缩级别 1-9,级别越高压缩率越大,当然压缩时间也就越长(传输快但比较消耗cpu)。默认值:1
  • gzip_types text/plain application/javascript application/x-javascript text/javascript text/xml text/css 压缩类型,匹配MIME类型进行压缩.默认值: gzip_types text/html 也就是默认不对js/css文件进行压缩。需要注意的是此处不能使用通配符,比如:text/*,无论是否指定text/html, 这种类型的都会被压缩。
  • gzip_vary on 和http头有关系,加个vary头,给代理服务器用的,有的浏览器支持压缩,有的不支持,所以避免浪费不支持的也压缩,所以根据客户端的HTTP头来判断,是否需要压缩
  • gzip_disable "MSIE [1-6]\." 禁用IE6的gzip压缩,为了确保其它的IE6版本不出问题,所以建议加上gzip_disable的设置
  • gzip_proxied off | expired | no-cache | no-store | private | no_last_modified | no_etag | auth | any ... 我没有配置这个选项。这个指令一般是Nginx作为反向代理的时候启用,根据某些请求和应答来决定是否在对代理请求的应答启用gzip压缩,是否压缩取决于请求头中的Via字段,指令中可以同时指定多个不同的参数,意义如下:
    • expired - 启用压缩,如果header头中包含 “Expires” 头信息
    • no-cache - 启用压缩,如果header头中包含 “Cache-Control:no-cache” 头信息
    • no-store - 启用压缩,如果header头中包含 “Cache-Control:no-store” 头信息
    • private - 启用压缩,如果header头中包含 “Cache-Control:private” 头信息
    • no_last_modified - 启用压缩,如果header头中不包含 “Last-Modified” 头信息
    • no_etag - 启用压缩 ,如果header头中不包含 “ETag” 头信息
    • auth - 启用压缩 , 如果header头中包含 “Authorization” 头信息
    • any - 无条件启用压缩

检测是否gzip生效

可以使用chrome的开发者试图中的network窗口看具体资源的请求,检查其中的response中的Content-Encoding

或者使用curl命令来检测,一个样例:

1
2
3
4
5
6
7
8
9
10
11
12
~ » curl -I -H "Accept-Encoding: gzip, deflate" https://wenchao.ren
HTTP/1.1 200 Connection established

HTTP/1.1 200 OK
Server: nginx/1.12.2
Date: Wed, 27 Feb 2019 02:59:36 GMT
Content-Type: text/html
Last-Modified: Fri, 22 Feb 2019 12:58:41 GMT
Connection: keep-alive
Vary: Accept-Encoding
ETag: W/"5c6ff201-1cf51"
Content-Encoding: gzip

参考资料

几个有意思的java小题目

null + String

写出下面代码执行结果:

1
2
3
4
5
6
7
// 1. 打印 null String
String s = null;
System.out.println(s);

String str = null;
str = str + "!";
System.out.println(str);

这个片段程序不会出现NPE,正常输出:

1
2
null
null!

我一开始以为第二个输出会抛出NPE。google了一下,看到了这篇文章Java String 对 null 对象的容错处理, 里面有解释:

对于代码片段:

1
2
3
String s = null;
s = s + "!";
System.out.print(s);

编译器生成的字节码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
L0
LINENUMBER 27 L0
ACONST_NULL
ASTORE 1
L1
LINENUMBER 28 L1
NEW java/lang/StringBuilder
DUP
INVOKESPECIAL java/lang/StringBuilder.<init> ()V
ALOAD 1
INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
LDC "!"
INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String;
ASTORE 1
L2
LINENUMBER 29 L2
GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
ALOAD 1
INVOKEVIRTUAL java/io/PrintStream.print (Ljava/lang/String;)V

这其中涉及到+字符串拼接的原理了:编译器对字符串相加会进行优化,首先实例化一个StringBuilder,然后把相加的字符串按顺序append,最后调用toString返回一个String对象。不信你们看看上面的字节码是不是出现了StringBuilder。因此:

1
2
3
4
5
6
 String s = "a" + "b";
//等价于
StringBuilder sb = new StringBuilder();
sb.append("a");
sb.append("b");
String s = sb.toString();

再回到我们的问题,现在我们知道秘密在StringBuilder.append函数的源码中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//针对 String 对象
public AbstractStringBuilder append(String str) {
if (str == null)
return appendNull();
int len = str.length();
ensureCapacityInternal(count + len);
str.getChars(0, len, value, count);
count += len;
return this;
}
//针对非 String 对象
public AbstractStringBuilder append(Object obj) {
return append(String.valueOf(obj));
}

private AbstractStringBuilder appendNull() {
int c = count;
ensureCapacityInternal(c + 4);
final char[] value = this.value;
value[c++] = 'n';
value[c++] = 'u';
value[c++] = 'l';
value[c++] = 'l';
count = c;
return this;
}

null cast

下面程序的输出结果为:Hello

1
2
3
4
5
6
7
8
9
public class Example {
private static void sayHello() {
System.out.println("Hello");
}

public static void main(String[] args) {
((Example)null).sayHello();
}
}

null作为非基本类型,可以做类型转换,转换后调用静态方法输出字符串。
基本类型,比如int,类型转换时会报告空指针异常,比如 int a = (Integer)null; 原因就是转换过程中会调用intValue(),因此会报告异常。

String 常量池

String s3 = new String(“Cat”) 这句代码会创建几个 String 对象

如果在执行语句之前String常量池中没有Cat字符串,那么会创建2个String;反之只创建1个 String对象。这里涉及到String的常量池。

深入java字符串常量池

参考文章:

个人认为的工作多年的开发者的3个最重要技能

不知道你觉的工作多年的开发者最重要的技能是什么?

随着工作经历的增长,我个人越来越觉得对于开发者来说,很多时候个人的专业技能反而并不是最重要的,甚至在很多的时候是最廉价,最容易被替代的。反而是一些非专业技能确显得更加的重要。我觉的按照优先级从高到低依次为:

  • 业务洞察力
  • 技术视野
  • 非常高执行力

业务洞察力

业务洞察力是值:在当下能够做出合理的判断,清楚Team或者公司做什么事情收益最大,很多时候是 战略层面 的问题

我们经常会面临有无穷无尽的事情要做,有无穷无尽的事情可以做,但是

  • 我们应该先做哪个呢?
  • 做哪些事情的收益是最大的呢?
  • 做哪些事情做了和没做对公司区别没那么大呢?
  • 甚至哪些做哪些事情是吃力不讨好的呢?

这里不是说大家要做老油条,而是要有超前的眼光,跳出仅仅作为一个代码编写员的视角,站在更高的层次来思索事情的优先级。

那平时应该怎么做呢?

我觉的首先要有这方面的意识,其次多了解公司各个team正在做的事情,通过新闻、自己的观察、茶前饭后的闲聊、帮其他组同事排查问题时听到的等等的手段,尽可能多的获取一些公司大的层面的一些信息,然后了解公司的一些计划,公司业务的重点发展方向,业务团队经常的痛点是什么等等的。然后基于这些信息,来辅助我们做优先级判断。

技术视野

技术视野即技术选型能力,是 战术层面 的问题,在清楚做什么事情后,需要进一步解决怎么做的问题,也就是能够给出合理的技术选型方案:是完全基于开源的方案,还是基于开源二次开发的方案,还是完全自研的方案,同时要有一定的前瞻性,保持自己对业界技术风向的敏感度。但是一切都要结合公司实际发展情况,要务实不能务虚。不能盲目的追求高新技术,一定要把握好技术风险。

非常高执行力

执行力是技术落地执行层面的问题,一旦技术设计方案确定后,需要能够快速完成。一般工作多年的同事的执行力往往都是比较高的,毕竟手熟。所以要注意培养自己的前两点能力。

这3点层层递进,最重要的是先把技术战略问题思考清楚,然后再进一步解决技术战术问题,最后是快速落地执行的问题。

谈谈缓存

本文谈谈缓存相关的问题,主要从下面几个点来出发,但是主要谈分布式缓存。

  • 为什么需要用缓存?
  • 缓存的原理是什么?
  • 有哪几种类的缓存?
  • 分布式缓存一般存在哪些问题?
    • 缓存一致性问题
    • 先操作数据库,还是先操作缓存
    • 缓存应该淘汰还是应该修改
    • 缓存不一致应该怎么解?
    • 缓存并发更新问题
    • 缓存穿透问题
    • 缓存雪崩
    • 缓存的「无底洞」现象

为什么需要用缓存

一般我们使用缓存,其实都是为了加速数据访问。

缓存的原理是什么

因为缓存是为了加速数据访问。而为了达到这个目的,往往是采用了下面的思路:

  • 将数据缓存在更高速的读取设备上。比如cpu的寄存器
  • 将数据提前运算并加载,可以减少同样数据的获取步骤,进而提升效率,大多数应用级的cache都是基于这种思路。
  • 将数据放置在距离用户更近的地点,可以加快数据获取速度。比如CDN就是这种思路。

有哪几类缓存

常见的缓存有下面几类:

  • CDN(Content Delivery Network 内容分发网络)。

    • CND的基本原理是广泛采用各种缓存服务器,将这些缓存服务器分布更接近用户的网络中,在用户访问网站时,利用全局负载技术将用户的访问指向距离最近的工作正常的缓存服务器上,由缓存服务器直接响应用户请求。一般会缓存图片,css js甚至视频。
  • 反向代理缓存

    • 反向代理位于应用服务器机房,处理所有对WEB服务器的请求。 如果用户请求的页面在代理服务器上有缓冲的话,代理服务器直接将缓冲内容发送给用户。如果没有缓冲则先向WEB服务器发出请求,取回数据,本地缓存后再发送给用户。通过降低向WEB服务器的请求数,从而降低了WEB服务器的负载。这种缓存一般都是缓存一些静态资源,比如css js和图片。比如常见的nginx就可以用来干这个事情。
  • 应用本地缓存

    • 指的是在应用中的缓存组件,其最大的优点是应用和cache是在同一个进程内部,请求缓存非常快速,没有过多的网络开销。比如常见的guava cache。
  • 分布式缓存

    • 指的是与应用分离的缓存组件或服务,其最大的优点是自身就是一个独立的应用,与本地应用隔离,多个应用可直接的共享缓存。

分布式缓存一般存在哪些问题?

缓存一致性问题

一个最简单的场景,应用架构为Spring MVC + redis缓存 + mysql存储。如果保证redis缓存和mysql数据库的数据一致性呢?

假设先不考虑缓存并发的问题,假设在单个请求下,会有下面4种情况

  • 先更新数据库成功,再更新缓存失败,产生数据不一致
  • 先更新缓存成功,在更新数据库失败, 产生数据不一致
  • 先更新数据库成功,在删除缓存失败,产生数据不一致
  • 先删除缓存成功,在更新数据库失败,产生数据不一致

现在我们考虑一下真实的场景,很可能同时会有多个请求读写相同的key,这样就可能出现「写后立即读」这个场景,如果还有主从同步的从场景,会更加复杂。

先操作数据库,还是先操作缓存?

关于这个问题,行业有两种不同的实践:

Cache Aside Pattern

什么是Cache Aside Pattern呢?简单的说Cache Aside Pattern指的是:

  • 对于读请求

    • 先读缓存
    • 如果缓存miss,则读db,并把结果放置在缓存中
    • 如果缓存hit,那么直接返回缓存中的结果
  • 对于写请求

    • 先写数据库,然后在淘汰缓存(Cache Aside Pattern建议是淘汰缓存,这个在下节有讨论)

Cache Aside Pattern中提出的观点是应该先操作数据库,再淘汰缓存 。但是Cache Aside Pattern也是存在问题的:

如果先操作数据库,再淘汰缓存,在原子性被破坏时:

  • 修改数据库成功了
  • 淘汰缓存失败了

导致,数据库与缓存的数据不一致。

如果先淘汰缓存,在操作数据库呢?
  • 对于读请求,和Cache Aside Pattern中的读请求是一样的

    • 先读缓存
    • 如果缓存miss,则读db,并把结果放置在缓存中
    • 如果缓存hit,那么直接返回缓存中的结果
  • 对于写请求

    • 先操作缓存,然后在写数据库。

但是对于写请求当原子性被坏以后呢,会发生什么呢?

这里又分了两种情况:

  • 操作缓存使用set
    • 第一步成功,第二步失败,会导致,缓存里是set后的数据,数据库里是之前的数据,数据不一致,业务无法接受。并且,一般来说,数据最终以数据库为准,写缓存成功,其实并不算成功。
  • 操作缓存使用delete
    • 第一步成功,第二步失败,会导致,缓存里没有数据,数据库里是之前的数据,数据没有不一致,对业务无影响。只是下一次读取,会多一次cache miss。但是在高并发情况下,也可能出现缓存和db数据不一致的问题,比如2个请求,一个更新,一个读。可能出现:
      • 更新请求删除缓存
      • 读请求cache miss
      • 读请求查询db并回写cache
      • 更新请求更新db成功

从上面的两种分析,我们可以看到,无论是先操作缓存,还是先操作数据库,都会存在数据一致性问题。

因此我们在做任何技术方案的选型时,一定要具体到特定业务。只有适合的方案,未必有最优的方案。技术人,不是被动接受,而要主动思考。

缓存应该淘汰还是应该修改

我们看看修改缓存和淘汰缓存的区别:

  • 淘汰某个key,操作简单,直接将key置为无效,但下一次该key的访问会cache miss
  • 修改某个key的内容,逻辑相对复杂,但下一次该key的访问仍会cache hit

我们可以看到,两者的区别仅仅在于一次cache miss。

然后考虑一下我们平时缓存在缓存中的对象,基本类型的话,直接set,但是如果是序列化以后的呢,我们会先读出来,反序列化,修改,在序列化,在set….
我们可以看到对于对象类型,或者文本类型,修改缓存value的成本较高。

同时考虑下面的场景,在1和2两个并发写发生时,由于无法保证时序,此时不管先操作缓存还是先操作数据库,都可能出现:

  • 请求1先操作数据库,请求2后操作数据库
  • 请求2先set了缓存,请求1后set了缓存

因此就会导致数据库与缓存之间的数据不一致。因此我们可以得到结论:

  • 「修改缓存」比「淘汰缓存」少因此cache miss
  • 对于复杂对象,「修改缓存」成本较高,而「淘汰缓存」成本很低,而且大部分场景下,「修改缓存」成本会高于“增加一次cache miss”

所以 我们应该使用「淘汰缓存」,而不是「修改缓存」。而Cache Aside Pattern也是建议delete缓存,而不是set缓存。

数据不一致应该怎么解?

此处讨论两种数据不一致的情况,下面就这两种数据不一致的情况分别说一下

  • 缓存和数据库数据不一致
  • 主从同步数据不一致

缓存和数据库数据不一致

通过上面的说明,我们可以看到,读操作一般都没什么问题,但是一涉及到写请求,在高并发的情况下,无论是先操作数据库,还是先操作缓存,其实都会出现缓存和数据库数据不一致的问题。一般针对缓存和数据不一致的问题,也有下面几种常见的解决办法:

  • 采用延时双删策略
  • 异步更新缓存(基于订阅binlog的同步机制)
延时双删策略 + 缓存过期

延时双删策略简单的说就是,在写操作数据库前后,都删除缓存中的数据。伪代码如下:

1
2
3
4
5
6
public void writeRequest(object key, Object value){
redis.delKey(key);
db.writeValue(value);
Thread.sleep(someTime);
redis.delKey(key);
}

如伪代码所示,在写操作数据库的前后,都删除缓存中的数据。但是需要注意的是,在写操作数据成功以后,等待一段时间(这个时间需要自己评估项目的读数据业务逻辑的耗时)在删除一次缓存。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。

当然这种策略还要考虑redis和数据库主从同步的耗时。所以一般休眠时间的考量为:

休眠时间 = 读数据业务逻辑的耗时 + 主从数据同步时间 + 一定的随机时间

同时依赖缓存过期策略,就可以保证缓存和数据库的最终一致性。但是这种方法会增加写请求的耗时,在一些场景下其实并不合适。

基于订阅binlog的同步机制

这种机制类似于mysql的master slave同步。在初始情况下,数据库和缓存数据是一致的,此时,通过订阅数据库的binlog(比如基于canal(阿里的一款开源框架)),然后基于binlog来在缓存上面replay,那么就可以保证最终数据一致性

主从数据同步不一致

常见的数据库集群架构一般为一主多从,主从同步,读写分离

  • 一个主库提供写服务
  • 多个从库提供读服务,可以增加从库提升读性能
  • 主从之间同步数据

但是因为主从同步有延迟,在这个延迟期间,读请求打到从库的时候,可能相关数据还没有同步完成,此时就会读不到数据,或者读取到一个不一致的数据。

一般如何应对主从数据同步不一致的问题呢?一般有下面几种方法:

  • 如果业务可以容忍,那么就可以忽略主从同步不一致
  • 强制读主库
  • 选择性读主库

下面分别说说这几种情况(下面的解决方法的前提是主从同步延迟没那么巨大)

忽略主从同步不一致

一些业务场景比如搜索,比如帖子等的,是可以忍受主从同步延迟的。此时就可以忽略主从同步数据不一致的问题。

强制读主库

这种方案的思路就是:

  • 使用一个高可用主库提供数据库服务
  • 读和写都落到主库上
  • 采用缓存来提升系统读性能

这种方案中,从库仅仅相当于主库的backup,当主库出现问题时,在使得某个从库成为新的主库。但是如果实现不好的话,在切换过程中可能会丢失数据。

选择性读主

选择性读主其实是强制读主库的优化版。这种方案增加一层cache,用来记录刚刚修改过的数据:

  • 当写请求发生时,先写主库
  • 然后将记录的唯一key写入一个cache中,同时为这个key设置一个过期时间,一般这个过期时间比主从同步的延迟时间大一点点就行。

然后当读请求过来的时候:

  • 根据记录的唯一key,先在cache中查询
    • 如果在cache中查询到,说明这个记录刚刚在主库修改了不久,此时就去主库读
    • 如果在cache中没有查询到,说明这个记录要么很久没有修改了,要么主库刚刚写完还没来得及写入cache,此时就去读从库
      • 注意:这种方案读从库依旧可能读取到不一致的数据

根据上面的分析,我们可以看到「选择性读主」,比起写请求无脑写操作主库,读请求无脑读从库的方案,在一定的程度上减少了读取到不一致数据的概率,并没有根本解决问题

缓存并发问题

缓存过期后将尝试从后端数据库获取数据,这是一个看似合理的流程。但是,在高并发场景下,有可能多个请求并发的去从数据库获取数据,对后端数据库造成极大的冲击,甚至导致 “雪崩”现象。此外,当某个缓存key在被更新时,同时也可能被大量请求在获取,这也会导致一致性的问题。那如何避免类似问题呢?

一般会采用锁机制,在缓存更新或者过期的情况下,先尝试获取到锁,当更新或者从数据库获取完成后再释放锁,其他的请求只需要牺牲一定的等待时间,即可直接从缓存中继续获取数据。

一个经典的缓存并发导致的问题

考虑如下业务业务场景:

  • 调用第三方服务,例如微信,一般会分配一个token,每次访问接口需要带上这个token;
  • 这个token是有有效期的,当token过期时,需要去重新认证申请;
  • 可以在token过期前重新申请,但此时旧token会失效。

假如实现方式为:

  • 把token放在缓存中,每次带上token去调用接口;
  • 如果token过期,需要去申请新token;
  • 申请完新token,需要把新token更新到缓存里。

在高并发情况下,就可能出现缓存并发问题,而导致token一直失效:

  • 请求1用旧token,访问接口,发现token过期;
  • 请求2用旧token,访问接口,也发现token过期;
  • 请求1去申请新token1;
  • 请求2去申请新token2 (此时token1会过期);
  • 请求1把token1放入缓存,同时使用token1访问接口(此时token1已经过期),发现token1过期,可能会递归申请新token3(此时token2过期);
  • 请求2把token2放入缓存,同时使用token2访问接口(此时token2已经过期),发现token2过期,可能会递归申请新token4(此时token3过期);

这就是非常经典的:高并发请求导致相互失效。

一般这种问题的解决办法其实很简单:

  • 将申请token异步化,也就是有专门的线程定时来申请新token并放置在cache中(这一步要避免并发更新,比如采用MVCC)
  • 同时为cache中的token设置一个更新时间戳,如果这个token刚刚更新过,那么就先不更新,等待一定时间在去申请新token并更新

缓存穿透问题

缓存穿透一般有两种情况:

  • 部分高频率读取的key在DB中没有值
  • 缓存系统刚刚重启,缓存还没有加载

部分高频率读取的key在DB中没有值

在高并发场景下,如果某一个key被高并发访问,没有被命中,出于对容错性考虑,会尝试去从后端数据库中获取,从而导致了大量请求达到数据库,而当该key对应的数据本身就是空的情况下,这就导致数据库中并发的去执行了很多不必要的查询操作,从而导致巨大冲击和压力。

一般都会通过缓存空对象来解决部分高频率读取的key在DB中没有值的问题。对查询结果为空的对象也进行缓存,如果是集合,可以缓存一个空的集合(非null),如果是缓存单个对象,可以通过字段标识来区分(需要区分空值和数据为空值的区别)。这样避免请求穿透到后端数据库。同时,也需要保证缓存数据的时效性。这种方式实现起来成本较低,比较适合命中不高,但可能被频繁更新的数据。

缓存系统刚刚重启,缓存还没有加载

有些情况下,缓存系统因为一些原因刚刚重启,缓存还没来得及加载初始化,而此时如果大量流量进来的话,就会导致大量流量直接打到DB,从而导致缓存雪崩。

一般这种情况的解决办法就是避免短时间大规模重启缓存集群,重启以后增加缓存预热功能

缓存雪崩

缓存雪崩其实发生在缓存穿透场景下,导致大量请求到达后端数据库,从而导致数据库崩溃,整个系统崩溃,发生灾难。

此处特别提一下,平时在设计缓存过期的时候,一般不要产生某一时刻大批量缓存过期的场景,可以加随机时间。将缓存过期的时间分散。避免出现缓存雪崩

缓存的「无底洞」现象

该问题由 facebook 的工作人员提出的, facebook 在 2010 年左右,memcached 节点就已经达3000 个,缓存数千 G 内容。他们发现了一个问题—memcached 连接频率,效率下降了,于是加 memcached 节点,添加了后,发现因为连接频率导致的问题,仍然存在,并没有好转,称之为”无底洞现象”。

目前主流的数据库、缓存、Nosql、搜索中间件等技术栈中,都支持“分片”技术,来满足“高性能、高并发、高可用、可扩展”等要求。有些是在client端通过Hash取模(或一致性Hash)将值映射到不同的实例上,有些是在client端通过范围取值的方式映射的。当然,也有些是在服务端进行的。但是,每一次操作都可能需要和不同节点进行网络通信来完成,实例节点越多,则开销会越大,对性能影响就越大。

一般这种情况,可以从下面几个方面来考虑:

  • 数据分布方式,比如尽可能把相关数据按照业务常见组织结构,放在一个节点,避免访问多个缓存节点后在组装数据
  • IO优化, 可以充分利用连接池,NIO等技术来尽可能降低连接开销,增强并发连接能力。
  • 数据访问方式, 一次性获取大的数据集,会比分多次去获取小数据集的网络IO开销更小

如何设计密码重置功能

密码重置功能或者说密码找回功能是互联网行业的一项基本功能,本篇文章主要总结一下完成这个功能需要注意的一些点,主要遵循以下几条原则:

  • 密码要「安全的」存储,我个人一般推荐bcrypt加密算法,当然PBKDF2scrypt也很不错。
  • 密码找回功能,不能告诉用户原来密码,而是应该让用户重置密码。
    • 尽量不要采用预先分配一个密码,然后告诉用户这个初始密码再让用户去修改的办法。
    • 而是应该发给用户一个有时效性的链接,让用户在规定的时间内,通过这个连接来重置密码。
    • 避免前后端明文传递密码
  • 密码找回的时候,因为需要知道找回谁的密码,所以需要一个身份标识,一般建议采用邮箱或者手机号,需要注意的是,无论这个身份是否存在,都不能页面提示
    这个身份是否存在,避免被扫描。而是应该无论用户输入邮箱还是电话,都正常发验证码。如果用户瞎填,他自然收不到验证码。建议在邮件内容中提示是正在重置xxx网站的密码。
  • 同时为了避免机器人,在重置密码提交form时需要增加验证码环节
  • 尽可能让用户在正确填写验证码以后,在验证一下一些问题。比如一些用户提前设置的问题,如果没有的话,可以根据具体的业务,比如登录地点,上次登录时间,xxx是你的朋友么等等的问题。

zoopeeper Unexpected Exception: java.nio.channels.CancelledKeyException

在运维过程中zookeeper(版本:3.4.9)出现下面的异常:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
2019-02-18 11:12:04,043 [myid:] - ERROR [NIOServerCxn.Factory:0.0.0.0/0.0.0.0:2181:NIOServerCnxn@178] - Unexpected Exception:
java.nio.channels.CancelledKeyException
at sun.nio.ch.SelectionKeyImpl.ensureValid(SelectionKeyImpl.java:73)
at sun.nio.ch.SelectionKeyImpl.interestOps(SelectionKeyImpl.java:77)
at org.apache.zookeeper.server.NIOServerCnxn.sendBuffer(NIOServerCnxn.java:151)
at org.apache.zookeeper.server.ZooKeeperServer.finishSessionInit(ZooKeeperServer.java:663)
at org.apache.zookeeper.server.ZooKeeperServer.revalidateSession(ZooKeeperServer.java:625)
at org.apache.zookeeper.server.ZooKeeperServer.reopenSession(ZooKeeperServer.java:633)
at org.apache.zookeeper.server.ZooKeeperServer.processConnectRequest(ZooKeeperServer.java:926)
at org.apache.zookeeper.server.NIOServerCnxn.readConnectRequest(NIOServerCnxn.java:418)
at org.apache.zookeeper.server.NIOServerCnxn.readPayload(NIOServerCnxn.java:198)
at org.apache.zookeeper.server.NIOServerCnxn.doIO(NIOServerCnxn.java:244)
at org.apache.zookeeper.server.NIOServerCnxnFactory.run(NIOServerCnxnFactory.java:203)
at java.lang.Thread.run(Thread.java:745)
2019-02-18 11:12:04,100 [myid:] - ERROR [NIOServerCxn.Factory:0.0.0.0/0.0.0.0:2181:NIOServerCnxn@178] - Unexpected Exception:
java.nio.channels.CancelledKeyException
at sun.nio.ch.SelectionKeyImpl.ensureValid(SelectionKeyImpl.java:73)
at sun.nio.ch.SelectionKeyImpl.interestOps(SelectionKeyImpl.java:77)
at org.apache.zookeeper.server.NIOServerCnxn.sendBuffer(NIOServerCnxn.java:151)
at org.apache.zookeeper.server.ZooKeeperServer.finishSessionInit(ZooKeeperServer.java:663)
at org.apache.zookeeper.server.ZooKeeperServer.revalidateSession(ZooKeeperServer.java:625)
at org.apache.zookeeper.server.ZooKeeperServer.reopenSession(ZooKeeperServer.java:633)
at org.apache.zookeeper.server.ZooKeeperServer.processConnectRequest(ZooKeeperServer.java:926)
at org.apache.zookeeper.server.NIOServerCnxn.readConnectRequest(NIOServerCnxn.java:418)
at org.apache.zookeeper.server.NIOServerCnxn.readPayload(NIOServerCnxn.java:198)
at org.apache.zookeeper.server.NIOServerCnxn.doIO(NIOServerCnxn.java:244)
at org.apache.zookeeper.server.NIOServerCnxnFactory.run(NIOServerCnxnFactory.java:203)
at java.lang.Thread.run(Thread.java:745)

这个是这个版本的zookeeper的一个bug,具体参见:https://issues.apache.org/jira/browse/ZOOKEEPER-1237

官方也给出了fix的patch:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
diff -uwp zookeeper-3.4.5/src/java/main/org/apache/zookeeper/server/NIOServerCnxn.java.ZK1237 zookeeper-3.4.5/src/java/main/org/apache/zookeeper/server/NIOServerCnxn.java
--- zookeeper-3.4.5/src/java/main/org/apache/zookeeper/server/NIOServerCnxn.java.ZK1237 2012-09-30 10:53:32.000000000 -0700
+++ zookeeper-3.4.5/src/java/main/org/apache/zookeeper/server/NIOServerCnxn.java 2013-08-07 13:20:19.227152865 -0700
@@ -150,7 +150,8 @@ public class NIOServerCnxn extends Serve
// We check if write interest here because if it is NOT set,
// nothing is queued, so we can try to send the buffer right
// away without waking up the selector
- if ((sk.interestOps() & SelectionKey.OP_WRITE) == 0) {
+ if (sk.isValid() &&
+ (sk.interestOps() & SelectionKey.OP_WRITE) == 0) {
try {
sock.write(bb);
} catch (IOException e) {
@@ -214,14 +215,18 @@ public class NIOServerCnxn extends Serve

return;
}
- if (k.isReadable()) {
+ if (k.isValid() && k.isReadable()) {
int rc = sock.read(incomingBuffer);
if (rc < 0) {
- throw new EndOfStreamException(
+ if (LOG.isDebugEnabled()) {
+ LOG.debug(
"Unable to read additional data from client sessionid 0x"
+ Long.toHexString(sessionId)
+ ", likely client has closed socket");
}
+ close();
+ return;
+ }
if (incomingBuffer.remaining() == 0) {
boolean isPayload;
if (incomingBuffer == lenBuffer) { // start of next request
@@ -242,7 +247,7 @@ public class NIOServerCnxn extends Serve
}
}
}
- if (k.isWritable()) {
+ if (k.isValid() && k.isWritable()) {
// ZooLog.logTraceMessage(LOG,
// ZooLog.CLIENT_DATA_PACKET_TRACE_MASK
// "outgoingBuffers.size() = " +

这个bug在zookeeper的3.4.10中已经解决。所以建议大家使用最新版的zookeeper

Java中的堆和栈

堆和栈都是Java用来在RAM中存放数据的地方。

  • Java的堆是一个运行时数据区,类的对象从堆中分配空间。这些对象通过new等指令建立,通过垃圾回收器来销毁。

  • 堆的优势是可以动态地分配内存空间,需要多少内存空间不必事先告诉编译器,因为它是在运行时动态分配的。但缺点是,由于需要在运行时动态分配内存,所以存取速度较慢。

  • 堆内存满的时候抛出java.lang.OutOfMemoryError: Java Heap Space错误
  • 可以使用-Xms-Xmx JVM选项定义开始的大小和堆内存的最大值
  • 存储在堆中的对象是全局可以被其他线程访问的

  • 栈中主要存放一些基本数据类型的变量(byte,short,int,long,float,double,boolean,char)和对象的引用,但对象本身不存放在栈中,而是存放在堆(new 出来的对象)或者常量池中(对象可能在常量池里)(字符串常量对象存放在常量池中。)。
  • 栈的优势是,存取速度比堆快,栈数据可以共享。但缺点是,存放在栈中的数据占用多少内存空间需要在编译时确定下来,缺乏灵活性。
  • 当栈内存满的时候,Java抛出java.lang.StackOverFlowError
  • 和堆内存比,栈内存要小的多
  • 明确使用了内存分配规则(LIFO)
  • 可以使用-Xss定义栈的大小
  • 栈内存不能被其他线程所访问。

静态域

存放静态成员(static定义的)

常量池

存放字符串常量和基本类型常量(public static final

举例说明栈数据可以共享

String 可以用以下两种方式来创建:

1
2
String str1 = newString("abc");
String str2 = "abc";

第一种使用new来创建的对象,它存放在堆中。每调用一次就创建一个新的对象。

第二种是先在栈中创建对象的引用str2,然后查找栈中有没有存放“abc”,如果没有,则将“abc”存放进栈,并将str2指向“abc”,如果已经有“abc”, 则直接将str2指向“abc”。

1
2
3
4
5
public static void main(String[] args) {
String str1 = newString("abc");
String str2 = newString("abc");
System.out.println(str1 == str2);
}

输出结果为:false

1
2
3
4
5
public static void main(String[] args) {
String str1 = "abc";
String str2 = "abc";
System.out.println(str1 == str2);
}

输出结果为:true

java中创建Completed future

在Java中如何创建Completed future呢?

Java8中可以Future future = CompletableFuture.completedFuture(value);
Guava中可以Futures.immediateFuture(value)
Apache commons Lang中可以Future<T> future = ConcurrentUtils.constantFuture(T myValue);

为Spring boot项目增加Servlet

为Spring boot项目增加Servlet有好多种方式

方式1

Just add a bean for the servlet. It’ll get mapped to /{beanName}/.

1
2
3
4
@Bean
public Servlet foo() {
return new FooServlet();
}

Note that if you actually want it mapped to /something/* rather than /something/ you will need to use ServletRegistrationBean

方式2

使用ServletRegistrationBean

1
2
3
4
@Bean
public ServletRegistrationBean servletRegistrationBean(){
return new ServletRegistrationBean(new FooServlet(),"/someOtherUrl/*");
}

如果想增加多个的话,就类似下面的方式

1
2
3
4
5
6
7
8
9
10
11
@Bean
public ServletRegistrationBean axisServletRegistrationBean() {
ServletRegistrationBean registration = new ServletRegistrationBean(new AxisServlet(), "/services/*");
registration.addUrlMappings("*.jws");
return registration;
}

@Bean
public ServletRegistrationBean adminServletRegistrationBean() {
return new ServletRegistrationBean(new AdminServlet(), "/servlet/AdminServlet");
}

方式3

通过实现WebApplicationInitializer或者ServletContextInitializer或者ServletContainerInitializer接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Configuration
public class ConfigureWeb implements ServletContextInitializer, EmbeddedServletContainerCustomizer {

@Override
public void onStartup(ServletContext servletContext) throws ServletException {
registerServlet(servletContext);
}

private void registerServlet(ServletContext servletContext) {
log.debug("register Servlet");
ServletRegistration.Dynamic serviceServlet = servletContext.addServlet("ServiceConnect", new ServiceServlet());

serviceServlet.addMapping("/api/ServiceConnect/*");
serviceServlet.setAsyncSupported(true);
serviceServlet.setLoadOnStartup(2);
}
}

方式4

如果使用内嵌的server的话,那么还可以使用@WebServlet WebServlet

Annotation used to declare a servlet.

This annotation is processed by the container at deployment time, and the corresponding servlet made available at the specified URL patterns.

1
2
@WebServlet(urlPatterns = "/example")
public class ExampleServlet extends HttpServlet

然后增加注解@ServletComponentScan:

1
2
3
4
@ServletComponentScan
@EntityScan(basePackageClasses = { ExampleApp.class, Jsr310JpaConverters.class })
@SpringBootApplication
public class ExampleApp

Please note that @ServletComponentScan will work only with embedded server:

Enables scanning for Servlet components (filters, servlets, and listeners). Scanning is only performed when using an embedded web server.

参考资料

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×