庄周梦蝶

生活、程序、未来

Clojure Under a Microscope(1): Clojure 如何理解代码(上)

| Comments

开篇

最近在读《Ruby Under a Microscope》(已经有中文版《Ruby 原理剖析》)。我很喜欢这本书介绍 Ruby 语言实现的方式,图文并茂,娓娓道来,不是特别深入,但是给你一个可以开始学习 Ruby 源码的框架和概念。

我大概在 2-3 年前开始阅读 Clojure Java 实现的源码,陆陆续续也有一些心得,想着可以在读 Ruby 这本书的时候,按照这本书的思路梳理一遍。因此就有了这第一篇: Clojure 如何理解代码。

IO Reader

我们抛开 leiningen 等构建工具,Clojure 唯一需要的是 JVM 和它的 jar 包,运行一段简单的 clojure 代码,可以这样:

1
2
$ java -cp clojure.jar  clojure.main -e "(println (+ 2 2))"
4

clojure.main 是所有 clojure 程序的启动入口,关于启动过程,后续会有单独一篇博客来介绍。-e 用来直接执行一段传入的 clojure 代码。

当 clojure 读到 (println (+ 2 2)) 这么一行代码的时候,它看到的是一个字符串。接下来它会将这段字符串拆成一个一个字符来读入,也就是

1
( p r i n t l n   ( +   2   2 ) )

这么一个字符列表。这是通过 java.io.PushBackReader 来完成。 Clojure 内部封装了一个 LineNumberingPushbackReader 的类继承了 PushbackReader ,并且内部封装了 Java 标准库的 LineNumberReader 来支持记录代码行列号(为了调试、报错、记录元信息等目的),并且最重要的是支持字符的回退(unread),它可以将读出来的字符『吐』回去,留待下次再读。内部其实就是一个回退字符缓冲区。

我们来试试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
(def r
  (-> "(println (+ 2 2))"
      (java.io.StringReader.)
      (clojure.lang.LineNumberingPushbackReader.)))

(.read r)            ; => 40  '('
(.read r)            ; => 112 'p'
(.read r)            ; => 114 'r'
(.unread r 114)      ; 『吐』回 'r' 
(.read r)            ; => 114 'r'   
(.read r)            ; => 105 'i' 
(.getLineNumber r)   ;  获取行号,从 1 开始
(.getColumnNumber r) ;  获取列号,从 0 开始
......

read 返回的的是字符串的整数编码(0 – 65535),Clojure 默认使用的是 UTF-8 编码。查看一个字符的整数编码可以 int 强制转换:

1
2
(int \()  ; => 40
(int \你) ; => 20320

上面的例子中我们 unread 了 114(也就是字符 ‘r’),然后下次调用 read,返回的仍然是 114。Clojure 的词法解析需要依赖这个回退功能。

此外还可以通过 getLineNumbergetColumnNumber 获取代码的行号和列号。这个行列信息最终会在 Clojure 对象的 metadata 里,比如我们看下 + 这个函数的行列信息:

1
2
user=> (select-keys (meta #'clojure.core/+) [:column :line :file])
{:column 1, :line 965, :file "clojure/core.clj"}

LispReader

单个字符是没有意义,接下来,Clojure 需要理解这些字符组成的字符串是个什么东西,理解了之后才能去执行求值。

这个『东西』,在 Clojure 里定义为 form。form 其实不是 clojure 特有的概念,而应该说是 lisp 系的语言都有一个概念。form 该怎么理解呢? 粗糙地理解,它是 Clojure 的对象,对应了一种 clojure 数据类型。更精确地说,form 是一个可以被正常求值的『程序单元』。

form 可以是:

  • Literals 字面量,比如字符、字符串、数字、nil、true/false 布尔值等等。
  • Symbol 符号,可以先简单地理解成类似 Java 的变量名称 identifier。
  • Lists 括号括起来的列表,如 (a b c)
  • Vectors 这是 clojure 有别于其他 lisp 方言的地方,中括号括起来的列表 [1 2 3]
  • Maps 散列表 {:a 1 :b 2}
  • Sets/Map namespace(1.9 新增)、deftype、defrecord 等其他类型。

那么 Clojure 是怎么将上面 reader 读到的字符流理解成 form 的呢?这是通过 LispReader 来完成,他负责将字符流解析成 form。我们尝试调用它的 read 方法来读取下 "(println (+ 2 2))",看看结果是什么:

1
2
3
4
5
 (def r
  (-> "(println (+ 2 2))"
      (java.io.StringReader.)
      (clojure.lang.LineNumberingPushbackReader.)))
 (def form (clojure.lang.LispReader/read r nil))

查看下 form:

1
2
3
4
user=> form
(println (+ 2 2))
user=> (class form)
clojure.lang.PersistentList

这个 form 的『样子』和它的文本字符串是一模一样的 (println (+ 2 2)),可是它不是字符串了,而是一个 List —— Clojure 的数据结构也是最重要的数据结构。这个一模一样就是所谓的同像性,也就是 Homoiconicity。因为 form 其实就是 AST,(println (+ 2 2)) 是一个层次的嵌套结构,转换成树形如下:

image

对应的刚好也是语法树,那么同像性就赋予我们操作这棵语法树的能力,因为它本质上就是一个普通的 Clojure 『对象』,也就是 form。我们可以随心所欲的操作这个 form,这也是 Clojure 强大的元编程能力的基础。

如果对应到编译原理, LispReader 不仅是 Lexer,同时也是 Parser。除了读取解析出词法单元之外,还会检查读取的结果是否是一个合法的可以被求值的 form,比如我们故意少一个括号:

1
2
user=> (read-string  "(+ 1 2")
RuntimeException EOF while reading  clojure.lang.Util.runtimeException (Util.java:221)

read-string 和另一个函数 read 最终调用的还是 LispReader,因为少了个括号,它会报错,这不是一个合法的 form。

Clojure 的编译器是 one-pass 还是 two-pass?

编译器可以多遍扫描源码,做分词、解析、优化等等工作。那么 Clojure 编译器是几遍?

严格来讲, Clojure 的编译器是 two-pass 的,但是很多情况下都是 one-pass 的。

但是 pass 这个概念在 clojure 里不是特别合适,按照 Rich Hickey 的答复,Clojure 的编译器更多是按照一个一个编译单元来描述更合适。每个单元是一个顶层(toplevel) form。

比如你有一个 clojure 代码文件:

1
2
3
(def a 1)
(def b 2)
(println (+ 1 2))

clojure 编译器会认为这里有三个顶层编译单元,分别是 (def a 1)(def b 2)(println (+ 1 2)),这三个编译单元都是最顶层的 form,它们会按照在文件中的出现顺序一一编译。

正因为编译单元要按照这个顺序,因此其实 clojure 不支持循环引用,或者前向查找(但是特别提供了 declare):

1
2
(def b 2)
(println (+ a b))

第二个 form 将报错,因为找不到 a:

1
 Unable to resolve symbol: a in this context

请注意,前向查找跟多少遍扫描没有关系,一遍扫描也可以实现前向查找。Clojure 这里的选择是基于两个理由:编译性能和名称冲突考虑。参见这个 YC 上的回复

LispReader 实现

LispReader 的实现是一个典型的递归下推机,往前读一个字符,根据这个字符的类型通过一系列 if 语句判断要执行哪一段解析,完整代码在 github,核心的循环代码精简如下,并加上注释:

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
for(; ;){
           //读取到一个 List,返回。
          if(pendingForms instanceof List && !((List)pendingForms).isEmpty())
              return ((List)pendingForms).remove(0);

          //读一个字符
          int ch = read1(r);

          //跳过空白,注意,逗号也被认为是空白
          while(isWhitespace(ch))
              ch = read1(r);

          //读到末尾
          if(ch == -1)
              {
              if(eofIsError)
                  throw Util.runtimeException("EOF while reading");
              return eofValue;
              }

          //读到设定的返回字符,提前返回。
          if(returnOn != null && (returnOn.charValue() == ch)) {
              return returnOnValue;
          }

          //可能是数字
          if(Character.isDigit(ch))
              {
              Object n = readNumber(r, (char) ch);
              return n;
              }

          //根据字符,查找 reader 表,走入更具体的解析
          IFn macroFn = getMacro(ch);
          if(macroFn != null)
              {
              Object ret = macroFn.invoke(r, (char) ch, opts, pendingForms);
              //no op macros return the reader
              if(ret == r)
                  continue;
              return ret;
              }
          //如果是正负符号,进一步判断可能是数字
          if(ch == '+' || ch == '-')
              {
              //再读一个字符
              int ch2 = read1(r);
              //如果是数字
              if(Character.isDigit(ch2))
                  {
                  //先回退 ch2 ,继续调用 readNumber 读出数字。
                  unread(r, ch2);
                  Object n = readNumber(r, (char) ch);
                  return n;
                  }
              //不是数字,回退 ch2
              unread(r, ch2);
              }
          //读取 token,并解析
          String token = readToken(r, (char) ch);
          return interpretToken(token);
          }
}

LispReader 维护了一个字符到 reader 的映射,专门用于读取特定的 form,也就是上面 getMacro 用到的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
   static IFn[] macros = new IFn[256]; //特殊宏字符到 Reader 函数的映射
  macros['"'] = new StringReader();  // 双引号开头的使用字符串Reader
  macros[';'] = new CommentReader();  // 注释
  macros['\''] = new WrappingReader(QUOTE); // quote 
  macros['@'] = new WrappingReader(DEREF);// deref符号 @
  macros['^'] = new MetaReader();   //元数据
  macros['`'] = new SyntaxQuoteReader(); // syntax quote
  macros['~'] = new UnquoteReader();   // unquote
  macros['('] = new ListReader();      //list 
  macros[')'] = new UnmatchedDelimiterReader();  //括号不匹配
  macros['['] = new VectorReader();   //vector
  macros[']'] = new UnmatchedDelimiterReader();  // 中括号不匹配
  macros['{'] = new MapReader();     // map
  macros['}'] = new UnmatchedDelimiterReader();  // 大括号不匹配
  macros['\\'] = new CharacterReader();   //字符,如\a
  macros['%'] = new ArgReader();   // 匿名函数便捷记法里的参数,如%, %1
  macros['#'] = new DispatchReader();  // #下面将提到的 dispatch macro
  
  static private IFn getMacro(int ch){
    if(ch < macros.length)
        return macros[ch];
    return null;
   }

ListReader 实现解析

我们先看下 ListReader,它是一个普通的 Clojure 函数,继承 AFn,并实现了 invoke 调用方法,关于 Clojure 的对象或者说运行时模型,我们后文再谈,ListReader 核心的代码如下:

1
2
3
List list = readDelimitedList(')', r, true);
IObj s = (IObj) PersistentList.create(list);
return s;

调用了 readDelimitedList 获取了一个 List 列表,然后转换成 Clojure 的 PersistentList 返回。readDelimitedList 的处理也很容易理解:

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
44
45
46
//收集结果
ArrayList a = new ArrayList();

for(; ;)
  {
  int ch = read1(r);
  //忽略空白
  while(isWhitespace(ch))
      ch = read1(r);
  //非法终止
  if(ch == -1)
      {
      if(firstline < 0)
          throw Util.runtimeException("EOF while reading");
      else
          throw Util.runtimeException("EOF while reading, starting at line " + firstline);
      }
  //读到终止符号,也就是右括号),停止
  if(ch == delim)
      break;
  //可能是macro fn
  IFn macroFn = getMacro(ch);
  if(macroFn != null)
      {
      Object mret = macroFn.invoke(r, (char) ch);
      //no op macros return the reader
      
      //macro fn 如果是no op,返回reader本身
      if(mret != r)
          //非no op,加入结果集合
          a.add(mret);
      }
  else
      {
      //非macro,回退ch
      unread(r, ch);
      //读取object并加入结果集合
      Object o = read(r, true, null, isRecursive);
      //同样,根据约定,如果返回是r,表示null
      if(o != r)
          a.add(o);
      }
  }
//返回收集的结果集合

return a;

再举一个例子,MetaReader,用于读取 form 的元信息。

MetaReader 解析

Clojure 可以为每个 form 附加上元信息,例如:

1
2
user=> (meta (read-string "^:private (+ 2 2)"))
{:private true}

通过 ^:private,我们给 (+ 2 2) 这个 form 设置了元信息 private=true。当 LispReader 读到 ^ 字符的时候,它从 macros 表找到 MetaReader,然后使用它来继续读取元信息:

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
   //meta对象,可能是map,可能是symbol,也可能是字符串,例如(defn t [^"[B" bs] (String. bs))
   Object meta = read(r, true, null, true);
  //symbol 或者 字符串,就是简单的type hint tag
  if(meta instanceof Symbol || meta instanceof String)
      meta = RT.map(RT.TAG_KEY, meta);
  //如果是keyword,证明是布尔值的开关变量,如 ^:dynamic ^:private
  else if (meta instanceof Keyword)
      meta = RT.map(meta, RT.T);
  //如果连 map 都不是,那很抱歉,非法的meta数据
  else if(!(meta instanceof IPersistentMap))
      throw new IllegalArgumentException("Metadata must be Symbol,Keyword,String or Map");

  //读取要附加元数据的目标对象
  Object o = read(r, true, null, true);
  if(o instanceof IMeta)
      //如果可以附加,那么继续走下去
      {
      if(line != -1 && o instanceof ISeq)
          {
          //如果是ISeq,加入行号,列号
          meta = ((IPersistentMap) meta).assoc(RT.LINE_KEY, line).assoc(RT.COLUMN_KEY, column);
          }
      if(o instanceof IReference)
          {
          //如果是 ref,重设 meta
          ((IReference)o).resetMeta((IPersistentMap) meta);
          return o;
          }
      //增加 meta 到原有的 ometa
      Object ometa = RT.meta(o);
      for(ISeq s = RT.seq(meta); s != null; s = s.next()) {
      IMapEntry kv = (IMapEntry) s.first();
      ometa = RT.assoc(ometa, kv.getKey(), kv.getValue());
      }
      //关联到o
      return ((IObj) o).withMeta((IPersistentMap) ometa);
      }
  else
      //不可附加元素,抱歉,直接抛出异常
      throw new IllegalArgumentException("Metadata can only be applied to IMetas");

从代码里可以看到,不是所有 form 都可以添加元信息的,只有实现 IMeta 接口的 IObj 才可以,否则将抛出异常:

1
2
user=> ^:private 3
IllegalArgumentException Metadata can only be applied to IMetas  clojure.lang.LispReader$MetaReader.invoke (LispReader.java:820)

Dispatch Macros

Clojure 同时还支持 # 字符开始的所谓 dispatch macros,比如正则表达式 #"abc" 或者忽略解析的 #_(form)。这部分的解析也是查表法:

1
2
3
4
5
6
7
8
9
dispatchMacros['^'] = new MetaReader();  //元数据,老的形式 #^
dispatchMacros['\''] = new VarReader();   //读取var,#'a,所谓var-quote
dispatchMacros['"'] = new RegexReader();  //正则,#"[a-b]"
dispatchMacros['('] = new FnReader();    //匿名函数快速记法 #(println 3)
dispatchMacros['{'] = new SetReader();   // #{1} 集合
dispatchMacros['='] = new EvalReader();  // eval reader,支持 var 和 list的eval
dispatchMacros['!'] = new CommentReader();  //注释宏, #!开头的行将被忽略
dispatchMacros['<'] = new UnreadableReader();   // #< 不可读
dispatchMacros['_'] = new DiscardReader();   //#_ 丢弃

LispReader 读到 # 字符的时候,会从 macros 表找到 DispatchReader,然后在 DispatchReader 内部继续读取一个字符,去 dispatchMacros 找到相应的 reader 进行下一步解析。

更多 Reader 源码解析,可以参考我的注解,或者自行研读。

本篇总结

一张图来总结本篇所介绍的内容:

reader

Clojure 在从文件或者其他地方读取到代码文本后,交给 IO Reader 拆分成字符,然后 LispReader 将字符流解析成可以被求值的 form。

我们前面提到 LispReader 同时是 Lexer 和 Parser,但是它并不是完整意义上的 Parser,比如它不会去检查 if 的使用是否合法:

1
2
3
4
user=> (read-string "(read-string "(if 1 2 3 4)")")
(if 1 2 3 4)
user=> (if 1 2 3 4)
CompilerException java.lang.RuntimeException: Too many arguments to if, compiling:(NO_SOURCE_PATH:93:1)

LispReader 只会检查它是否是一个合法的 form,而不会去检查它的语义是否正确,更进一步的检查需要 clojure.lang.Compiler 介入了,它会执行一个 analyze 解析过程来检查,这是下一篇要讲的内容。

Clojure 并发实践:使用 pmap 加速程序

| Comments

LeanCloud 的控制台会展示一个应用列表,应用列表会展示该用户的所有应用,以及每个应用的基本信息,例如总用户数、昨天请求量和本月请求量等。我们最多允许每个用户创建 50 个应用。伪代码大概是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
(defn add-app-info
  "添加应用统计信息。"
  [app]
  (assoc app
         :yesterday_reqs (count-reqs app 7)
         :monthly_reqs (count-reqs app 30)
         :total_users (count-users app)))

(defn get-client-apps
  "获取用户的应用列表"
  [client_id]
  (->> client_id
       (db/find-apps-by-client-id)
       (map add-app-info)))

显然,这里每个应用为了获取这些请求信息,都至少要请求三次。虽然这些统计请求本身已经有了缓存,但是假设有 50 个应用(实际中,部分开发者的应用数量包括协作应用在内会更多),那就需要发起 150 个请求,这个过程如果完全串行处理的话,假设 add-app-info 的开销至少是 1~3 毫秒,串行处理下来也需要 50~150 毫秒,加上传输的时间,那么用户的体验的就相当差了。

这时候,我们可以用并发处理来加速了,你只需要替换一个函数,将 get-client-appsmap 替换为 pmap 即可:

1
2
3
4
5
6
(defn get-client-apps
   "获取用户的应用列表"
  [client_id]
  (->> client_id
       (db/find-apps-by-client-id)
       (pmap add-app-info)))

关于 pmap 的讨论参见 并发函数pmap、pvalues和pcalls。因为 pmap 对于 chunked sequnce 的处理是批量处理,因此最多同时使用 32 个并发任务在处理,这个线程数量在这个场景下是可以接受的。加速后的性能也可以估算出来 (Math/round (/ n 32.0)) x (1~3) 毫秒。 在实际线上环境中,大概加速了 3~4 倍左右。

在测量性能的时候,注意使用 doall,因为 clojure 的 LazySeq 特性会干扰你的测试。

一个函数替换就能获得并发加速,抽象的力量在这里体现的淋漓尽致。

Xmemcached 死锁分析和 Aviator 可变参数方法实现

| Comments

首先是 xmemcached 发了 2.2.0 版本,最重要解决的问题就是请求超时。详细的情况可以参考这个 issue 。推荐所有还在用 xmc 的朋友升级到这个版本,性能和稳定性都有所改进。

这个 bug 的原因可能更值得说道说道。

xmemcached 本身会对发出去的请求维护一个队列,在 onMessageSent 也就是消息写到 socket 后将请求放入队列,然后在收到 memcached 返回应答的时候,找出当前的请求来 decode 应答内容。伪代码是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
//Handler 里加入队列。
public void onMessageSent(Command msg, Session session){
    session.getQueue().offer(msg);
}

//Decoder 里做解码
public Command decode(ByteBuffer buf, Session session){
    Command cmd = session.getQueue().take().decode(buf);
    if(cmd!=null)
        return cmd;
    else
        return null;
}

这个 Bug 的关键就在于加入队列的时候和 take 的使用。 take 会阻塞当前操作,直到队列中有可用的元素或者被中断。而我们放入队列的时候是在命令被完全写入 socket 之后(有没有发出去,你无法确认的,因为有 socket 缓冲区、网卡缓冲区的存在)。其次是这两段逻辑是发生在同一个处理线程上。

那么当用户写入一个超过 1M 的数据的时候,假设是 2M。因为 memcached 最多只允许保存 1M 大小的数据,当 xmemcached 将超过 1M 但是还没有达到 2M 的数据发送到 memcached 后, memcached 立即应答返回错误。但是此时,数据还没有完全写出去,导致命令没有被加入队列,同时 take 也取不到数据,我们遇到了死锁: take 在等待命令加入,而写入命令数据的线程被 take 阻塞了没有机会继续写

找到问题解决就很简单了: 将放入队列的时机从完全写入后,放到开始写第一个字节之前的时候;或者将 take 改成 poll,不阻塞当前线程,当没有可用命令的时候会去重试。我选择了前者的方案。因为 memcached 的典型场景是读多写少,更希望尽快地 decode 出结果来响应。

Aviator 的自定义函数是继承 AbstractFunction,然后复写特定参数 arity 的方法实现即可。但是后来有用户发现无法处理超过 20 个参数的可变参数。回顾下代码,确实是没有处理这个逻辑。当超过 20 个参数的时候,应该将多余的参数封装成数组来调用。

Java 里的可变参数就是数组,一个简单例子:

1
2
3
4
5
6
7
8
public class Test{
    public static void method(int a, int ...b){
    }

    public static void main(String []args){
        method(1, 2, 3, 4);
    }
}

编译后 javap -v Test 看看字节码,关键是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
     0: iconst_1
     1: iconst_3
     2: newarray       #int,创建数组
     4: dup
     5: iconst_0
     6: iconst_2
     7: iastore        #将 2 放入数组
     8: dup
     9: iconst_1
    10: iconst_3
    11: iastore        #将 3 放入数组
    12: dup
    13: iconst_2
    14: iconst_4
    15: iastore        #将4放入数组
    16: invokestatic  #2                  // Method method:(I[I)V

最终调用的方法签名是 (I[I)V,也就是 void (int a, int [] b)

Aviator 的 Parser 是 one pass 的,解析的同时生成字节码,就是一个典型的递归下降解释器。当遇到方法调用,因为是一遍扫描,在完全解析完整个方法调用之前,我是不知道有多少个参数的,因此就不知道应该创建多大的额外参数数组。

遇到这种不知道多少元素将加入的集合的问题,那肯定不能用数组了,直接用 List 吧。基本的实现逻辑也很简单,当解析到第 20 个参数,就创建一个 List 实例,后面再解析到的参数就加入这个 List。到解析方法调用完成后,将前面的 20 个参数,加上 list 里面的元素组成的数组,一起做个调用,伪代码类似:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public void onMessageParam(Token token){
    if(getEnv().getParamsNumber()) > 20){
        List list = getEnv().getOrCreateParamList();
        list.add(token);
    }
    onTernary(token);
}

public void onMessageInvoke(Token token){
    List list = getEnv().getOrCreateParamList();
    AviatorObject [] extraParams = new AviatorObject[list.size()];
    extraParams = list.toArray(extraParams);

    ......
    function.call(param1, param2, ......., extraParams);
}

当然实际的代码是用 ASM 直接写成字节码的。

Clojure 并发实践: future 和 promise 处理异步返回值

| Comments

Clojure 的并发方面的详细介绍可以参考我过去总结的 wiki —— Clojure 并发。 这次又想写个系列,介绍下实际编程中对这些并发机制的应用。

不过,很可能不会涉及 STM。 LeanCloud 本质上是一个 web 型的应用,基础的并发模型已经由 web server 和后端存储决定了,STM 的适应场景没有出现过。

这一篇先从 future 和 promise 开始。

最近处理这么一个任务,有一段业务代码要调用一个第三方接口来查询域名备案号,但是呢,这个第三方接口非常不稳定,经常查询出错或者超时,导致这个业务经常不可用。

1
2
3
(defn query-icp [domain]
     ;; HTTP 调用第三方接口 API 。
     (query-icp-from-thirdparty1 domain))

为了提高这个接口的稳定性,我们引入另一个查询服务,想让两个服务来竞争,谁能返回正常结果,就用谁的。假设这个服务封装成了函数 query-icp-from-thirdparty2

ok,我们先加个 or 上去

1
2
3
4
(defn query-icp [domain]
  (or
    (query-icp-from-thirdparty1 domain)
    (query-icp-from-thirdparty2 domain)))

先尝试从一个服务查询,如果没有返回就尝试第二个服务。

但是这样有个问题,第三方服务的调用我们是一定要设置一个超时的。这个 or 改动我们改变了 query-icp 的超时承诺,原来最多等待 query-icp-from-thirdparty1 超时,现在可能遇到最高两倍的超时时间(假设两个服务都遇到超时),因为两个是顺序调用的,这肯定是不能接受的。

第一时间想到,我们将查询并发,启个线程去同时去查询两个服务,这时候就可以用 future。其次,任何一个服务如果有结果返回,我们就使用它,不等另一个服务的结果。在 Java 里我们可以用 CountDownLatch 或者 CompletionService。 在 Clojure 里我们可以用 promise + deliver。

1
2
3
4
5
6
7
8
9
10
(defn- do-query-icp [p f domain]
  (future
    (when-let [ret (f domain)]
      (deliver p ret))))

(defn query-icp [domain]
  (let [p (promise)]
    (do-query-icp p query-icp-from-thirdparty1 domain)
    (do-query-icp p query-icp-from-thirdparty2 domain)
    (deref p :5000 nil)))

do-query-icp 里我们利用 future 来异步调用接口,当接口有返回的时候,使用 deliver 将结果 ret 喂给 promise

而在 query-icp 里,我们先创建一个 promise,然后接连发起两次 do-query-icp 异步请求分别调用两个服务,然后利用 (deref p timeout-ms timeout-val) 等待结果,同时设置超时 5 秒和超时后的返回值 nil

当然,实质上, clojure 的 promise 也是基于 CountDownLatch 实现。

编程小记: bug、clojure 状态和 paxos

| Comments

一个 Bug

前段时间观察我们 API 系统的 hystrix 监控,一直发现一个函数 cache/add 的调用特别的高,在整个集群范围内高峰的时候接近 3 到 4 万的 QPS,跟其他指标比起来非常的碍眼,极不正常。

抽了点时间专门调查了下,原来是不小心掉进去了 hystrix request cache 的坑里。

Hystrix Request Cache 的原理很简单,在同一个 RequestContext 里,对某个 command 调用同样的参数,第一次调用的结果将被缓存,后续的对同样参数的请求将直接返回第一次的结果,通过内存换效率,类似 clojure 的 memoize

简单例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
(require '[com.netflix.hystrix.core :refer [defcommand with-request-context]]))

(def call-times (atom 0))

(defcommand myinc
  {:hystrix/cache-key-fn (fn [i] (str i))}
  [i]
  (swap! call-times inc) ;;统计调用次数
  (+ 1 i))

(with-request-context
  ;;调用了两次 myinc
  (myinc 1)
  (myinc 1))

(println @call-times) ;; call-times 只统计了一次调用。

业务代码里有一段逻辑大概是这样:

1
2
3
4
5
6
(def get-or-create [k nv]
  (if-let [v (get-value k)]
    v
    (if-not (add k nv)
      (recur k nv)
      nv)))

其中 get-value 是一个 hystrix command 设置了 cache-fn 启用了请求缓存。这段代码是尝试先从缓存里加载 k 对应的值,如果没有,就将 nv 存储到 k 键上,如果 add 存储成功,返回 nv,如果 add 失败,循环重试(表示有其他人 add 成功,我们可以重试 get-value)。

问题就出在 recur 循环上,因为 get-value 启用了请求缓存,那么循环调用 get-or-create 的时候因为仍然在同一个 RequestContext 里,导致 (get-value k) 一直为空,但是接下来的 add 也继续失败,不停地 recur 循环。后果就是 get-valueadd 都被无限调用,并且耗费了大量 CPU。

解决起来也简单,在 recur 之前清空请求缓存即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
(defn invalidate-get-cache [k]
    (..
     HystrixRequestCache
     (getInstance (get-command-key)
                  (HystrixConcurrencyStrategyDefault/getInstance))
     (clear k)))

(def get-or-create [k nv]
  (if-let [v (get-value k)]
    v
    (if-not (add k nv)
      (do
        ;;清空 get-value 请求缓存
        (invalidate-get-cache k)
        (recur k nv))
      nv)))

volatile! 和 local.var

在 Clojure 1.7 之前,为了保存一个可变的状态,你的大部分选择是 atom,除非为了 STM 协作事务才使用 ref。但是 atom 严格的原子性导致它的效率在简单的场景里就不是特别合适,比如我只是保存一个局部的可变状态,它只是在局部范围内可变,收集或者统计一些状态,不会发布到外面,完全没有必要保证严格的原子性。还有配置型的全局状态,接近只读。

因此 Clojure 1.7 为了改善 transducer 的实现效率引入了新的可变状态保存器—— volatile,它的语义与 Java 里的 volatile 完全一样,仅保证可见性,不保证原子性

1
2
3
4
5
6
7
8
9
10
11
12
13
(def val (volatile! 0))

@val
;;=> 0

(vswap! val inc)
;;=> 1

(vreset! val "nothing")
;;=> "nothing"

@val
;;=> "nothing"

不保证原子性的意思就是 (vswap! val inc) 这个递增调用在多线程环境下会产生不同步的结果。

在一些不需要原子操作的场合就非常适合替代 atom 了,比如全局状态、局部状态等。

但是,其实呢,这还不够, volatile 本身仍然有可见性的严格要求,每次读取都强制从 main memory 读取最新的值,如果我只是局部变量在用,或者完全不需要同步的场合里,一个更轻量级的状态保存器是有必要的。因此我写了个 local.var。它就更简单了,只是一个 Object 里保存了一个 value 值,没有任何同步的语义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
(require '[local.var :refer [transient-var! ltv-reset! ltv-swap! transient-var?]])

(let [sent (transient-var! false)]
    ;;send emails to client
    ;;......
    (ltv-reset! sent true)
    (if @sent
        (println "Sent email successfully!")
        (println "Sent email failed.")))

(def x (transient-var! 1))
@x         ;; => 1
(deref x)  ;; => 1

(ltv-reset! x 99) ;; => 99
@x                ;; => 99

(ltv-swap! x inc)   ;; => 100
(transient-var? x)  ;; => true

@(future (ltv-reset! x 100))   ;; =>  IllegalAccessError Local transient var used by 
                               ;;     non-owner thread  local.var/ltv-reset!

并且类似 transient 集合那样,加了 Thread Owner 的保护,避免被多线程修改。

Paxos

最近连读了几篇一致性算法的论文。 Paxos 琢磨的最多,毕竟它没有像 RAFT 那样有清晰明确的算法步骤,围绕它的解释也有一大堆论文,made simple, made live, made pratical,乃至于要 made crazy 了。

这里稍微总结下我的理解。

第一, Paxos 解决什么问题? 简单地说就是在多个参与者的情况下确定一个值,并且这个值是唯一的。少数服从多数,超过一半的参与者确定的值,就可以代表整个群体的的确认值。中央决定了,就你来当领导。

第二,为了达到这个目标应该怎么做?我们分解下步骤:

  1. 每个人提出一些提议,以供大家选择,这称为 proposal 阶段。
  2. 每个人收到他人的提议,决定要不要接受,产生一个选择集。
  3. 在选择集合中确定唯一的一个,并让所有人知道。

对应到 paxos 过程就是其中的 proposal、accept 和 learn 三阶段。Proposal 阶段产生提议,结合 accept 阶段来确定唯一的值,最终 learn 阶段通知这个确定结果给所有参与者。

为了让选择收敛唯一,又引入了一个 MaxVote 机制,每一轮投票选择的值都是上一轮确定的最高提议编号的值,如果没有,则任意选择一个新值。哪怕有冲突导致多轮投票,确定后的值却不会变。

Paxos instance 是确定一个值,那么 multi paxos 就是确定多个值的过程。为了避免冲突频繁提升提议编号,加速达成一致的效率, multi paxos 自然而然地要求产生一个 leader proposer ,它来产生一系列提议并赋予编号,还可以缩减 prepare 阶段。

更进一步,我们在谈论值,那么值到底是什么? 结合实际的工程项目,需要跟 Replication State Machine 结合起来,简单来讲,值就是日志,paxos 的过程就是要决定一系列日志的顺序在所有参与机器之间保持一致,那么一致顺序的日志回放加上状态机变迁,我们可以让所有参与者里的状态机状态保持一致,也就是达到了在机器之间复制状态机的目的,这就是我们工程上想要的一致性。

Leiningen 代理设置

| Comments

墙已经成为平常工作效率最大的敌人。由于我们内部的 maven 仓库也部署在海外,导致 Leiningen 下载依赖经常超时。你不得不走代理才能解决。这里记录下大部分要点。

首先修改 lein 脚本本身,默认不超时,建议加入超时设置,找到下面类似的代码:

1
2
3
4
5
6
7
8
9
10
 "$LEIN_JAVA_CMD" \
            "${BOOTCLASSPATH[@]}" \
            -Dfile.encoding=UTF-8 \
            -Dmaven.wagon.http.ssl.easy=false \
            -Dmaven.wagon.rto=600000 \
            $LEIN_JVM_OPTS \
            -Dleiningen.original.pwd="$ORIGINAL_PWD" \
            -Dleiningen.script="$SCRIPT" \
            -classpath "$CLASSPATH" \
            clojure.main -m leiningen.core.main "$@"

新增的配置选项是 -Dmaven.wagon.rto=600000,也就是 10 分钟超时。

其次,如果你有一个 HTTP 代理, lein 尊重 http_proxyhttps_proxy 环境变量,可以将下面代码加入 ~/.profile,也可以使用的时候 export 下:

1
2

http_proxy=http://username:password@proxy:port
https_proxy=http://username:password@proxy:port

设置代理后,所有 lein 发起的 http 请求都将走代理,你可以可以设置一个白名单避免代理:

1
http_no_proxy="*example1.com|*example2.com|*example3.com"

最后,如果你用的是 socks5 代理,比如 shadowsocks 搭建的代理服务器,那么可以安装 privoxy 将 socks5 转为 HTTP 代理:

1
$ brew install privoxy

安装后,默认配置在 /usr/local/etc/privoxy/config 文件,找到下面类似这行代码,修改成你的 socks5 代理配置:

1
    forward-socks5t   /               127.0.0.1:1080 .

我这里是 127.0.0.1:1080

默认 privoxy 监听在 8118 端口,因此设置 http_proxy 为该端口即可:

1
2
export https_proxy=http://127.0.0.1:8118
export http_proxy=http://127.0.0.1:8118

深夜杂感

| Comments

可能是今晚上 8 点就上床睡觉,11 点醒过来就再也不睡着了。胡思乱想,趁着还清醒,可以稍微总结下。

人们总是说过了 30 岁,是个坎。我原来不觉的,上周刚过了 32 周岁的生日,慢慢有那么点感觉。这感觉是什么?具体地来说,是更谨小慎微,并且缺乏用于尝试和勤奋自律的劲头。

坦率地说,有家有室后,你的生活的很大一部分精力是投入在家庭生活里。如果你 20 出头,看这种日子会觉的索然无趣,但是人到中年,你会感觉,家可能是才是最重要的部分,你会乐在其中。

所有时间管理的核心其实就是分配你的精力。这边投入多了,那边必然少了。加上身体机能的下降,生活的磨砺,你的心性会有渐进的变化。变化有好有坏,哪个方向,在于一心。

但是呢,我不甘心如此下去了。

还没有去过几个国家,还没有做出一点值得称道的东西,还没有写过一门编程语言,还没有做过超大规模的系统……

过去别人说程序员当不了一辈子, 很庆幸我还在写代码,并且仍然留有热情,并且预计这个热情可以持续下去。

那么,趁还有半辈子,可以立个 flag,继续前行,莫忘初衷,是以为记。

Redis 高可用(1)——Sentinel 篇

| Comments

最近在学习 Redis 的高可用方案,就从 sentinel 开始。本篇文档基本只是 redis sentinel 官方文档的摘要和总结,感兴趣的直接阅读官方文档是更好的选择。

基本原理

Sentinel 的原理并不复杂:

  • 启动 n 个 sentinel 实例,这些 sentinel 实例会去监控你指定的 redis master/slaves
  • 当 redis master 节点挂掉后, Sentinel 实例通过 ping 检测失败发现这种情况就认为该节点进入 SDOWN 状态,也就是检测的 sentinel 实例主观地(Subjectively)认为该 redis master 节点挂掉。
  • 当一定数目(Quorum 参数设定)的 Sentinel 实例都认为该 master 挂掉的情况下,该节点将转换进入 ODOWN 状态,也就是客观地(Objectively)挂掉的状态。
  • 接下来 sentinel 实例之间发起选举,选择其中一个 sentinel 实例发起 failover 过程:从 slave 中选择一台作为新的 master,让其他 slave 从新的 master 复制数据,并通过 Pub/Sub 发布事件。
  • 使用者客户端从任意 Sentinel 实例获取 redis 配置信息,并监听(可选) Sentinel 发出的事件: SDOWN, ODOWN 以及 failover 等,并做相应主从切换,Sentinel 还扮演了服务发现的角色。
  • Sentinel 的 Leader 选举采用的是 Raft 协议

一张示意图,正常情况下:

Sentinel 原理

当 M1 挂掉后:

Sentinel 原理

节点 2 被提升为 master,Sentinel 通知客户端和 slaves 去使用新的 Master。

搭建实验环境

  • 两个 redis,一个主一个从,分别监听在 6379 和 6380 端口
1
2
$ redis-server
$ redis-server --port 6380
  • redis-cli -p 6380 连上 6380 端口的 redis,执行 slaveof 127.0.0.1 6379 将它设置为 6379 的 slave。
  • 启动三个 sentinel 实例,分别监听在 5000 – 5002 端口,并且监控 6379 的 redis master,首先是配置文件

s1.conf:

1
2
3
4
port 5000
sentinel monitor mymaster 127.0.0.1 6370 2
sentinel down-after-milliseconds mymaster 1000
sentinel failover-timeout mymaster 60000

其他两个配置文件是 s2.conf 和 s3.conf 只是将 port 5000 修改为 5001 和 5002,就不再重复。需要确保配置文件是可写的,因为 Sentinel 会往配置文件里添加很多信息作为状态持久化,这是为了重启等情况下可以正确地恢复 sentinel 的状态。

启动:

1
2
3
$ redis-sentinel s1.conf
$ redis-sentinel s2.conf
$ redis-sentinel s3.conf

配置说明:

  • port ,指定 sentinel 启动后监听的端口,sentinel 实例之间需要通过此端口通讯。
  • sentinel monitor [name] [ip] [port] [quorum],最重要的配置,指定要监控的 redis master 的 IP 和端口,给这个监控命名 name。Quorum 指定至少多少个 sentinel 实例对 redis master 挂掉的情况达成一致,只有达到这个数字后,Sentinel 才会去开始一次 failover 过程。
  • down-after-milliseconds,设定 Sentinel 发现一个 redis 没有响应 ping 到 Sentinel 认为该 redis 实例不可访问的时间。
  • failover-timeout,Sentinel 实例投票对于同一个 master 发起 failover 过程的间隔时间,防止同时开始多次 failover。

Sentinel 启动后会输出类似的日志:

1
2
17326:X 13 Oct 12:00:55.143 # +monitor master mymaster 127.0.0.1 6379 quorum 2
17326:X 13 Oct 12:00:55.143 * +slave slave 127.0.0.1:6380 127.0.0.1 6380 @ mymaster 127.0.0.1 6379

表示开始监控 mymaster 集群,并输出集群的基本信息。

以及 Sentinel 之间的感知日志,比如 s3 节点的输出:

1
2
18441:X 13 Oct 12:01:39.985 * +sentinel sentinel eab05ac9fc34d8af6d59155caa195e0df5e80d73 127.0.0.1 5000 @ mymaster 127.0.0.1 6379
18441:X 13 Oct 12:01:52.918 * +sentinel sentinel 4bf24767144aea7b4d44a7253621cdd64cea6634 127.0.0.1 5002 @ mymaster 127.0.0.1 6379

查看信息

可以用 redis-cli 连上 sentinel 实例,查看信息:

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
$ redis-cli -p 5000
127.0.0.1:5000> sentinel master mymaster
 1) "name"
 2) "mymaster"
 3) "ip"
 4) "127.0.0.1"
 5) "port"
 6) "6379"
 7) "runid"
 8) "4b97e168125b735e034d49c7b1f45925f43aded9"
 9) "flags"
10) "master"
11) "link-pending-commands"
12) "0"
13) "link-refcount"
14) "1"
15) "last-ping-sent"
16) "0"
17) "last-ok-ping-reply"
18) "729"
19) "last-ping-reply"
20) "729"
21) "down-after-milliseconds"
22) "1000"
23) "info-refresh"
24) "6258"
25) "role-reported"
26) "master"
27) "role-reported-time"
28) "11853370"
29) "config-epoch"
30) "0"
31) "num-slaves"
32) "1"
33) "num-other-sentinels"
34) "2"
35) "quorum"
36) "2"
37) "failover-timeout"
38) "60000"
39) "parallel-syncs"
40) "1"

sentinel master [name] 用于查看监控的某个 redis master 信息,包括配置和状态等,其他命令还包括:

  • sentinel masters 查看所有监控的 master 信息。
  • sentinel slaves [name] 查看监控的某个 redis 集群的所有 slave 节点信息。
  • sentinel sentinels [name] 查看所有 sentinel 实例信息。

更重要的一个命令是根据名称来查询 redis 信息,客户端会用到:

1
2
3
127.0.0.1:5000> SENTINEL get-master-addr-by-name mymaster
1) "127.0.0.1"
2) "6379"

测试 Failover

我们让 6379 的 master 主动休眠 30 秒来观察 failover 过程:

1
$ redis-cli -p 6379 DEBUG sleep 30

我们可以看到每个 sentinel 进程都监控到 master 挂掉,从 sdown 状态进入 odown,然后选举了一个 leader 来进行 failover,最终 6380 成为新的 master, sentinel 的日志输出:

1
2
3
4
5
6
7
8
9
10
18441:X 13 Oct 15:26:51.735 # +sdown master mymaster 127.0.0.1 6379
18441:X 13 Oct 15:26:51.899 # +new-epoch 1
18441:X 13 Oct 15:26:51.900 # +vote-for-leader eab05ac9fc34d8af6d59155caa195e0df5e80d73 1
18441:X 13 Oct 15:26:52.854 # +odown master mymaster 127.0.0.1 6379 #quorum 3/2
18441:X 13 Oct 15:26:52.854 # Next failover delay: I will not start a failover before Thu Oct 13 15:28:52 2016
18441:X 13 Oct 15:26:53.034 # +config-update-from sentinel eab05ac9fc34d8af6d59155caa195e0df5e80d73 127.0.0.1 5000 @ mymaster 127.0.0.1 6379
18441:X 13 Oct 15:26:53.034 # +switch-master mymaster 127.0.0.1 6379 127.0.0.1 6380
18441:X 13 Oct 15:26:53.034 * +slave slave 127.0.0.1:6379 127.0.0.1 6379 @ mymaster 127.0.0.1 6380
18441:X 13 Oct 15:26:54.045 # +sdown slave 127.0.0.1:6379 127.0.0.1 6379 @ mymaster 127.0.0.1 6380
18441:X 13 Oct 15:27:20.383 # -sdown slave 127.0.0.1:6379 127.0.0.1 6379 @ mymaster 127.0.0.1 6380

日志的几个主要事件:

  • +sdown master mymaster 127.0.0.1 6379,发现 master 检测失败,主观认为该节点挂掉,进入 sdown 状态。
  • +odown master mymaster 127.0.0.1 6379 #quorum 3/2,有两个 sentinel 节点认为 master 6379 挂掉,达到配置的 quorum 值 2,因此认为 master 已经客观挂掉,进入 odown 状态。
  • +vote-for-leader eab05ac9fc34d8af6d59155caa195e0df5e80d73 准备选举一个 sentinel leader 来开始 failover。
  • +switch-master mymaster 127.0.0.1 6379 127.0.0.1 6380 切换 master 节点, failover 完成。
  • +config-update-from sentinel eab05ac9fc34d8af6d59155caa195e0df5e80d73 127.0.0.1 5000 @ mymaster 127.0.0.1 6379 更新 sentinel 配置。
  • 6379 休眠回来,作为 slave 挂载到 6380 后面,可见 sentinel 确实同时在监控 slave 状态,并且挂掉的节点不会自动移除,而是继续监控。

此时查看 sentinel 配置文件,会发现增加了一些内容:

1
2
3
4
5
6
7
8
9
# Generated by CONFIG REWRITE
dir "/Users/dennis/opensources/redis-sentinel"
sentinel failover-timeout mymaster 60000
sentinel config-epoch mymaster 1
sentinel leader-epoch mymaster 1
sentinel known-slave mymaster 127.0.0.1 6379
sentinel known-sentinel mymaster 127.0.0.1 5001 8ba1e75cbf4c268be4a2950ee7389df746c6b0b4
sentinel known-sentinel mymaster 127.0.0.1 5002 4bf24767144aea7b4d44a7253621cdd64cea6634
sentinel current-epoch 1

可以看到 sentinel 将最新的集群状态写入了配置文件。

运维

命令

除了上面提到的一些查看信息的命令之外, sentinel 还支持下列命令来管理和检测 sentinel 配置:

  • SENTINEL reset <pattern> 强制重设所有监控的 master 状态,清除已知的 slave 和 sentinel 实例信息,重新获取并生成配置文件。
  • SENTINEL failover <master name> 强制发起一次某个 master 的 failover,如果该 master 不可访问的话。
  • SENTINEL ckquorum <master name> 检测 sentinel 配置是否合理, failover 的条件是否可能满足,主要用来检测你的 sentinel 配置是否正常。
  • SENTINEL flushconfig 强制 sentinel 重写所有配置信息到配置文件。

增加和移除监控以及修改配置参数:

  • SENTINEL MONITOR <name> <ip> <port> <quorum>
  • SENTINEL REMOVE <name>
  • SENTINEL SET <name> <option> <value>

增加和移除 Sentinel

增加新的 Sentinel 实例非常简单,修改好配置文件,启动即可,其他 Sentinel 会自动发现该实例并加入集群。如果要批量启动一批 Sentinel 节点,最好以 30 秒的间隔一个一个启动为好,这样能确保整个 Sentinel 集群的大多数能够及时感知到新节点,满足当时可能发生的选举条件。

移除一个 sentinel 实例会相对麻烦一些,因为 sentinel 不会忘记已经感知到的 sentinel 实例,所以最好按照下列步骤来处理:

  • 停止将要移除的 sentinel 进程。
  • 给其余的 sentinel 进程发送 SENTINEL RESET * 命令来重置状态,忘记将要移除的 sentinel,每个进程之间间隔 30 秒。
  • 确保所有 sentinel 对于当前存货的 sentinel 数量达成一致,可以通过 SENTINEL MASTER [mastername] 命令来观察,或者查看配置文件。

客户端实现

客户端从过去直接连接 redis ,变成:

  1. 先连接一个 sentinel 实例
  2. 使用 SENTINEL get-master-addr-by-name master-name 获取 redis 地址信息。
  3. 连接返回的 redis 地址信息,通过 ROLE 命令查询是否是 master。如果是,连接进入正常的服务环节。否则应该断开重新查询。
  4. (可选)客户端可以通过 SENTINEL sentinels [name] 来更新自己的 sentinel 实例列表。

当 Sentinel 发起 failover 后,切换了新的 master,sentinel 会发送 CLIENT KILL TYPE normal 命令给客户端,客户端需要主动断开对老的master 的链接,然后重新查询新的 master 地址,再重复走上面的流程。这样的方式仍然相对不够实时,可以通过 sentinel 提供的 Pub/Sub 来更快地监听到 failover 事件,加快重连。

如果需要实现读写分离,读走 slave,那可以走 SENTINEL slaves [name] 来查询 slave 列表并连接。

生产环境推荐

对于一个最小集群,Redis 应该是一个 master 带上两个 slave,并且开启下列选项:

1
2
min-slaves-to-write 1
min-slaves-max-lag 10

这样能保证写入 master 的同时至少写入一个 slave,如果出现网络分区阻隔并发生 failover 的时候,可以保证写入的数据最终一致而不是丢失,写入老的 master 会直接失败,参考 Consistency under partitions

Slave 可以适当设置优先级,除了 0 之外(0 表示永远不提升为 master),越小的优先级,越有可能被提示为 master。如果 slave 分布在多个机房,可以考虑将和 master 同一个机房的 slave 的优先级设置的更低以提升他被选为新的 master 的可能性。

考虑到可用性和选举的需要,Sentinel 进程至少为 3 个,推荐为 5 个,如果有网络分区,应当适当分布(比如 2 个在 A 机房, 2 个在 B 机房,一个在 C 机房)等。

其他

由于 Redis 是异步复制,所以 sentinel 其实无法达到强一致性,它承诺的是最终一致性:最后一次 failover 的 redis master 赢者通吃,其他slave 的数据将被丢弃,重新从新的 master 复制数据。此外还有前面提到的分区带来的一致性问题。

其次,Sentinel 的选举算法依赖时间,因此要确保所有机器的时间同步,如果发现时间不一致,Sentinel 实现了一个 TITL 模式来保护系统的可用性。

更好,还是更坏?

| Comments

变化时时刻刻,缓慢的,或剧烈的,无可避免,就像崔健唱的那样:不是我不明白,这世界变化快。不要说世界,就说自个儿,变化也太快,更重要的是你也不明白这变化是好的,还是坏的。当然,更佛家一点的说法,色即是空,空即是色,世界就是我,我就是世界,所以没有特别必要强调『我』或者『世界』,因为两者是『一体』的。

明显坏的变化,肚子大了,三高来了,今年还犯了一次痛风,要和啤酒海鲜菠菜说再见了。身体在告警:您的余额要不足了,请及时充值。跑步不少,但是欠费更多。

还有个坏的变化,工作上的冲劲似乎没了,叹气的时候多了,旁观的时候多了,憋着话的时候也多了。妥协的多了,抗争的少了。眼看着一头牛要滑向深渊,你得用力拽住、劝慰,再慢慢拉回来。

另一个可能是不好不坏的变化,从无产者变成可疑的有产者(当老毛还挂在城楼上的,我们可能是有产者吗?),心态没那么愤世嫉俗了,关注民生新闻少了,愤怒的次数少了,有『小粉红』的倾向,从全盘西化转向中国人的事情还是要自己解决。某些观点越来越中立,越来越中庸。同样,也可能越来越宽容,大家要和谐,不要搞大新闻。

好的变化也有一些,恢复写博客算是一个,陆续在更新开源库也算一个,读书相比去年也读的多了一点,闲书少了,技术的多了一点。其他的,和儿子关系相处更好了一点,也算是个好变化。

无论是好的,还是坏的,只能接受,因为这就是世界,也就是我。正面的或者负面的,你只能拥抱、亲吻、吵架、小心地劝导、耐心地包容,它们将伴随一生,如影随形,越早承认并坦然接受这一点,生活会更好点。

Xmemcached 2.0.1 is out!

| Comments

陆续准备更新一些 Java 开源库,先从 xmemcached 开始。我在 LeanCloud 也用了自己这个库,用来和 memcached 和 kestrel 交互,总体还是稳定靠谱的。

昨天晚上更新了 2.0.1 版本,主要变更如下:

  • 将心跳和连接修复线程设置为 daemon 线程。
  • 默认关闭客户端的 shutdown hook,如果需要开启,可以通过启动传参 -Dxmemcached.shutdown.hook.enable=true
  • 改进了内部日志,更符合习惯。
  • 修复二进制协议的 Auth 授权实现。
  • 新增 setSelectorPoolSize 可用于单独设置每个客户端实例的 NIO Reactor 线程池大小。
  • 特别感谢 bmahe,做了很多代码清理和重构的工作。
  • 一些小的内部 Bug 修复,感谢所有贡献 PR 的朋友。
  • 搬迁了文档和设计了新首页 http://fnil.net/xmemcached/

Maven 只要修改下引用即可:

1
2
3
4
5
 <dependency>
       <groupId>com.googlecode.xmemcached</groupId>
       <artifactId>xmemcached</artifactId>
       <version>2.0.1</version>
  </dependency>