
专注即时通讯技术的学习、交流与传播,希望不再零碎和封闭。
本文由陶文分享,InfoQ编辑发布,有修订和改动。1、前言本系列的前几篇主要是从各个角度讲解Protobuf的基本概念、技术原理这些内容,但回过头来看,对比JSON这种事实上的数据协议工业标准,Protobuf到底性能到底高多少?本篇将以Protobuf为基准,对比市面上的一些主流的JSON解析库,通过全方位测试来证明给你看看Protobuf到底比JSON快几倍。学习交流:- 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》- 开源IM框架源码:https://github.com/JackJiang2011/MobileIMSDK(备用地址点此)(本文已同步发布于:http://www.52im.net/thread-4095-1-1.html)2、系列文章本文是系列文章中的第 5 篇,本系列总目录如下:《IM通讯协议专题学习(一):Protobuf从入门到精通,一篇就够!》《IM通讯协议专题学习(二):快速理解Protobuf的背景、原理、使用、优缺点》《IM通讯协议专题学习(三):由浅入深,从根上理解Protobuf的编解码原理》《IM通讯协议专题学习(四):从Base64到Protobuf,详解Protobuf的数据编码原理》《IM通讯协议专题学习(五):Protobuf到底比JSON快几倍?全方位实测!》(* 本文)《IM通讯协议专题学习(六):手把手教你如何在Android上从零使用Protobuf》(稍后发布..)《IM通讯协议专题学习(七):手把手教你如何在NodeJS中从零使用Protobuf》(稍后发布..)《IM通讯协议专题学习(八):金蝶随手记团队的Protobuf应用实践(原理篇) 》(稍后发布..)《IM通讯协议专题学习(九):金蝶随手记团队的Protobuf应用实践(实战篇) 》(稍后发布..)3、写在前面拿 JSON 衬托 Protobuf 的文章真的太多了,经常可以看到文章中写道:“快来用 Protobuf 吧,JSON 太慢啦”。但是 Protobuf 真的有吹的那么牛么?我觉得从 JSON 切换到 Protobuf 怎么也得快一倍吧,要不然对不起付出的切换成本。然而,DSL-JSON 的家伙们居然说在Java语言里 JSON 和那些二进制的编解码格式有得一拼,这太让人惊讶了!虽然你可能会说,咱们能不用苹果和梨来做比较了么?两个东西根本用途完全不一样好么。咱们用 Protobuf 是冲着跨语言无歧义的 IDL 的去的,才不仅仅是因为性能呢。好吧,这个我同意。但是仍然有那么多人盲目相信,Protobuf 一定会快很多,我觉得还是有必要彻底终结一下这个关于速度的传说。DSL-JSON 的博客里只给了他们的测试结论,但是没有给出任何原因,以及优化的细节,这很难让人信服数据是真实的。你要说 JSON 比二进制格式更快,真的是很反直觉的事情。稍微琢磨一下这个问题,就可以列出好几个 Protobuf 应该更快的理由。比如:1)更容容易绑定值到对象的字段上。JSON 的字段是用字符串指定的,相比之下字符串比对应该比基于数字的字段tag更耗时;2)JSON 是文本的格式,整数和浮点数应该更占空间而且更费时;3)Protobuf 在正文前有一个大小或者长度的标记,而 JSON 必须全文扫描无法跳过不需要的字段。但是仅凭这几点是不是就可以盖棺定论了呢?未必。也有相反的观点:1)如果字段大部分是字符串,占到决定性因素的因素可能是字符串拷贝的速度,而不是解析的速度。在这个评测中,我们看到不少库的性能是非常接近的。这是因为测试数据中大部分是由字符串构成的;2)影响解析速度的决定性因素是分支的数量。因为分支的存在,解析仍然是一个本质上串行的过程。虽然Protobuf里没有[] 或者 {},但是仍然有类似的分支代码的存在。如果没有这些分支的存在,解析不过就是一个 memcpy 的操作而已。只有 Parabix 这样的技术才有革命性的意义,而 Protobuf 相比 JSON 只是改良而非革命;3)也许 Protobuf 是一个理论上更快的格式,但是实现它的库并不一定就更快。这取决于优化做得好不好,如果有不必要的内存分配或者重复读取,实际的速度未必就快。有多个 benchmark 都把 DSL-JSON列到前三名里,有时甚至比其他的二进制编码更快。经过我仔细分析,原因出在了这些 benchmark 对于测试数据的构成选择上。因为构造测试数据很麻烦,所以一般评测只会对相同的测试数据,去测不同的库的实现。这样就使得结果是严重倾向于某种类型输入的。比如 https://github.com/eishay/jvm-serializers/wiki 选择的测试数据的结构是这样的:message Image { required string uri = 1; //url to the thumbnail optional string title = 2; //used in the html ALT required int32 width = 3; // of the image required int32 height = 4; // of the image enum Size { SMALL = 0; LARGE = 1; } required Size size= 5; // of the image (in relative terms, provided by cnbc for example)}message Media { required string uri = 1; //uri to the video, may not be an actual URL optional string title = 2; //used in the html ALT required int32 width = 3; // of the video required int32 height = 4; // of the video required string format = 5; //avi, jpg, youtube, cnbc, audio/mpeg formats ... required int64 duration = 6; //time in miliseconds required int64 size= 7; //file size optional int32 bitrate = 8; //video repeated string person = 9; //name of a person featured in the video enum Player { JAVA = 0; FLASH = 1; } required Player player = 10; //in case of a player specific media optional string copyright = 11;//media copyright}message MediaContent { repeated Image image = 1; required Media media = 2;}无论怎么去构造 small/medium/large 的输入,benchmark 仍然是存在特定倾向性的。而且这种倾向性是不明确的。比如 medium 的输入,到底说明了什么?medium 对于不同的人来说,可能意味着完全不同的东西。所以,在这里我想改变一下游戏的规则。不去选择一个所谓的最现实的配比,而是构造一些极端的情况。这样,我们可以一目了然的知道,JSON的强项和弱点都是什么。通过把这些缺陷放大出来,我们也就可以对最坏的情况有一个清晰的预期。具体在你的场景下性能差距是怎样的一个区间内,也可以大概预估出来。4、本次评测对象好了,废话不多说了,JMH 撸起来。benchmark 的对象有以下几个:1)Jackson:Java 程序里用的最多的 JSON 解析器。benchmark 中开启了 AfterBurner 的加速特性;2)DSL-JSON:世界上最快的 Java JSON 实现;3)Jsoniter:抄袭 DSL-JSON 写的实现;4)Fastjson:在中国很流行的 JSON 解析器;5)Protobuf:在 RPC (远程方法调用)里非常流行的二进制编解码格式;6)Thrift:另外一个很流行的 RPC 编解码格式。这里 benchmark 的是 TCompactProtocol。5、整数解码性能测试(Decode Integer)先从一个简单的场景入手。毫无疑问,Protobuf 非常擅长于处理整数:message PbTestObject { int32 field1 = 1;}https://github.com/json-iterator/java-benchmark/tree/master/src/main/java/com/jsoniter/benchmark/with_int从结果上看,似乎优势非常明显。但是因为只有 1 个整数字段,所以可能整数解析的成本没有占到大头。所以,我们把测试调整对象调整为 10 个整数字段。再比比看:syntax = "proto3";option optimize_for = SPEED;message PbTestObject { int32 field1 = 1; int32 field2 = 2; int32 field3 = 3; int32 field4 = 4; int32 field5 = 5; int32 field6 = 6; int32 field7 = 7; int32 field8 = 8; int32 field9 = 9; int32 field10 = 10;}https://github.com/json-iterator/java-benchmark/tree/master/src/main/java/com/jsoniter/benchmark/with_10_int_fields这下优势就非常明显了。毫无疑问,Protobuf 解析整数的速度是非常快的,能够达到 Jackson 的 8 倍。DSL-JSON 比 Jackson 快很多,它的优化代码在这里:private static int parsePositiveInt(final byte[] buf, final JsonReader reader, final int start, final int end, int i) throwsIOException { int value = 0; for(; i < end; i++) { final int ind = buf[i ] - 48; if(ind < 0|| ind > 9) {... // abbreviated } value = (value << 3) + (value << 1) + ind; if(value < 0) { throw new IOException("Integer overflow detected at position: "+ reader.positionInStream(end - start)); } } return value;}整数是直接从输入的字节里计算出来的,公式是 value = (value << 3) + (value << 1) + ind; 相比读出字符串,然后调用 Integer.valueOf ,这个实现只遍历了一遍输入,同时也避免了内存分配。Jsoniter 在这个基础上做了循环展开:... // abbreviatedint i = iter.head;int ind2 = intDigits[iter.buf[i ]];if(ind2 == INVALID_CHAR_FOR_NUMBER) { iter.head = i; return ind;}int ind3 = intDigits[iter.buf[++i]];if(ind3 == INVALID_CHAR_FOR_NUMBER) { iter.head = i; return ind * 10+ ind2;}int ind4 = intDigits[iter.buf[++i]];if(ind4 == INVALID_CHAR_FOR_NUMBER) { iter.head = i; return ind * 100+ ind2 * 10+ ind3;}... // abbreviated6、整数编码性能测试(Encode Integer)编码方面情况如何呢?和编码一样的测试数据,测试结果如下:不知道为啥,Thrift 的序列化特别慢。而且别的 benchmark 里 Thrift 的序列化都是算慢的。我猜测应该是实现里有不够优化的地方吧,格式应该没问题。整数编码方面,Protobuf 是 Jackson 的 3 倍。但是和 DSL-JSON 比起来,好像没有快很多。这是因为 DSL-JSON 使用了自己的优化方式,和 JDK 的官方实现不一样(代码点此查看):private static int serialize(final byte[] buf, int pos, final int value) { int i; if(value < 0) { if(value == Integer.MIN_VALUE) { for(intx = 0; x < MIN_INT.length; x++) { buf[pos + x] = MIN_INT[x]; } return pos + MIN_INT.length; } i = -value; buf[pos++] = MINUS; } else{ i = value; } final int q1 = i / 1000; if(q1 == 0) { pos += writeFirstBuf(buf, DIGITS[i ], pos); return pos; } final int r1 = i - q1 * 1000; final int q2 = q1 / 1000; if(q2 == 0) { final int v1 = DIGITS[r1]; final int v2 = DIGITS[q1]; int off = writeFirstBuf(buf, v2, pos); writeBuf(buf, v1, pos + off); return pos + 3+ off; } final int r2 = q1 - q2 * 1000; final long q3 = q2 / 1000; final int v1 = DIGITS[r1]; final int v2 = DIGITS[r2]; if(q3 == 0) { pos += writeFirstBuf(buf, DIGITS[q2], pos); } else{ final int r3 = (int) (q2 - q3 * 1000); buf[pos++] = (byte) (q3 + '0'); writeBuf(buf, DIGITS[r3], pos); pos += 3; } writeBuf(buf, v2, pos); writeBuf(buf, v1, pos + 3); return pos + 6;}这段代码的意思是比较令人费解的。不知道哪里就做了数字到字符串的转换了。过程是这样的,假设输入了19823,会被分解为 19 和 823 两部分。然后有一个 `DIGITS` 的查找表,根据这个表把 19 翻译为 "19",把 823 翻译为 "823"。其中 "823" 并不是三个byte分开来存的,而是把bit放到了一个integer里,然后在 writeBuf 的时候通过位移把对应的三个byte解开的。private static void writeBuf(final byte[] buf, final int v, int pos) { buf[pos] = (byte) (v >> 16); buf[pos + 1] = (byte) (v >> 8); buf[pos + 2] = (byte) v;}这个实现比 JDK 自带的 Integer.toString 更快。因为查找表预先计算好了,节省了运行时的计算成本。7、双精度浮点数解码性能测试(Decode Double)解析 JSON 的 Double 就更慢了。message PbTestObject { double field1 = 1; double field2 = 2; double field3 = 3; double field4 = 4; double field5 = 5; double field6 = 6; double field7 = 7; double field8 = 8; double field9 = 9; double field10 = 10;}https://github.com/json-iterator/java-benchmark/tree/master/src/main/java/com/jsoniter/benchmark/with_10_double_fieldsProtobuf 解析 double 是 Jackson 的 13 倍。毫无疑问,JSON真的不适合存浮点数。DSL-Json 中对 Double 也是做了特别优化的(详见源码):private static double parsePositiveDouble(final byte[] buf, final JsonReader reader, final int start, final int end, int i) throws IOException { long value = 0; byte ch = ' '; for(; i < end; i++) { ch = buf[i ]; if(ch == '.') break; final int ind = buf[i ] - 48; value = (value << 3) + (value << 1) + ind; if(ind < 0|| ind > 9) { return parseDoubleGeneric(reader.prepareBuffer(start), end - start, reader); } } if(i == end) return value; else if(ch == '.') { i++; long div = 1; for(; i < end; i++) { final int ind = buf[i ] - 48; div = (div << 3) + (div << 1); value = (value << 3) + (value << 1) + ind; if(ind < 0|| ind > 9) { return parseDoubleGeneric(reader.prepareBuffer(start), end - start, reader); } } return value / (double) div; } return value;}浮点数被去掉了点,存成了 long 类型,然后再除以对应的10的倍数。如果输入是3.1415,则会变成 31415/10000。8、双精度浮点数编码性能测试(Encode Double)把 double 编码为文本格式就更困难了。解码 double 的时候,Protobuf 是 Jackson 的13 倍。如果你愿意牺牲精度的话,Jsoniter 可以选择只保留6位小数。在这个取舍下,可以好一些,但是 Protobuf 仍然是Jsoniter 的两倍。保留6位小数的代码是这样写的,把 double 的处理变成了长整数的处理:if(val < 0) { val = -val; stream.write('-');}if(val > 0x4ffffff) { stream.writeRaw(Double.toString(val)); return;}int precision = 6;int exp = 1000000; // 6long lval = (long)(val * exp + 0.5);stream.writeVal(lval / exp);long fval = lval % exp;if(fval == 0) { return;}stream.write('.');if(stream.buf.length - stream.count < 10) { stream.flushBuffer();}for(int p = precision - 1; p > 0&& fval < POW10[p]; p--) { stream.buf[stream.count++] = '0';}stream.writeVal(fval);while(stream.buf[stream.count-1] == '0') { stream.count--;}到目前来看,我们可以说 JSON 不是为数字设计的。如果你使用的是 Jackson,切换到 Protobuf 的话可以把数字的处理速度提高 10 倍。然而 DSL-Json 做的优化可以把这个性能差距大幅缩小,解码在 3x ~ 4x 之间,编码在 1.3x ~ 2x 之间(前提是牺牲 double 的编码精度)。因为 JSON 处理 double 非常慢。所以 Jsoniter 提供了一种把 double 的 IEEE 754 的二进制表示(64个bit)用 base64 编码之后保存的方案。如果希望提高速度,但是又要保持精度,可以使用 Base64FloatSupport.enableEncodersAndDecoders();。long bits = Double.doubleToRawLongBits(number.doubleValue());Base64.encodeLongBits(bits, stream);static void encodeLongBits(long bits, JsonStream stream) throws IOException { int i = (int) bits; byte b1 = BA[(i >>> 18) & 0x3f]; byte b2 = BA[(i >>> 12) & 0x3f]; byte b3 = BA[(i >>> 6) & 0x3f]; byte b4 = BA[i & 0x3f]; stream.write((byte)'"', b1, b2, b3, b4); bits = bits >>> 24; i = (int) bits; b1 = BA[(i >>> 18) & 0x3f]; b2 = BA[(i >>> 12) & 0x3f]; b3 = BA[(i >>> 6) & 0x3f]; b4 = BA[i & 0x3f]; stream.write(b1, b2, b3, b4); bits = (bits >>> 24) << 2; i = (int) bits; b1 = BA[i >> 12]; b2 = BA[(i >>> 6) & 0x3f]; b3 = BA[i & 0x3f]; stream.write(b1, b2, b3, (byte)'"');}对于 0.123456789 就变成了 "OWNfmt03P78".9、对象解码性能测试(Decode Object)我们已经看到了 JSON 在处理数字方面的笨拙丑态了。在处理对象绑定方面,是不是也一样不堪?前面的 benchmark 结果那么差和按字段做绑定是不是有关系?毕竟我们有 10 个字段要处理那。这就来看看在处理字段方面的效率问题。为了让比较起来公平一些,我们使用很短的 ascii 编码的字符串作为字段的值。这样字符串拷贝的成本大家都差不到哪里去。所以性能上要有差距,必然是和按字段绑定值有关系。message PbTestObject { string field1 = 1;}java-benchmark/src/main/java/com/jsoniter/benchmark/with_1_string_field at master · json-iterator/java-benchmark · GitHub如果只有一个字段,Protobuf 是 Jackson 的 2.5 倍。但是比 DSL-JSON 要慢。我们再把同样的实验重复几次,分别对应 5 个字段,10个字段的情况。message PbTestObject { string field1 = 1; string field2 = 2; string field3 = 3; string field4 = 4; string field5 = 5;}https://github.com/json-iterator/java-benchmark/tree/master/src/main/java/com/jsoniter/benchmark/with_5_string_fields在有 5 个字段的情况下,Protobuf 仅仅是 Jackson 的 1.3x 倍。如果你认为 JSON 对象绑定很慢,而且会决定 JSON 解析的整体性能。对不起,你错了。message PbTestObject { string field1 = 1; string field2 = 2; string field3 = 3; string field4 = 4; string field5 = 5; string field6 = 6; string field7 = 7; string field8 = 8; string field9 = 9; string field10 = 10;}https://github.com/json-iterator/java-benchmark/tree/master/src/main/java/com/jsoniter/benchmark/with_10_string_fields把字段数量加到了 10 个之后,Protobuf 仅仅是 Jackson 的 1.22 倍了。看到这里,你应该懂了吧。Protobuf 在处理字段绑定的时候,用的是 switch case:boolean done = false;while(!done) { int tag = input.readTag(); switch(tag) { case 0: done = true; break; default: { if(!input.skipField(tag)) { done = true; } break; } case 10: { java.lang.String s = input.readStringRequireUtf8(); field1_ = s; break; } case 18: { java.lang.String s = input.readStringRequireUtf8(); field2_ = s; break; } case 26: { java.lang.String s = input.readStringRequireUtf8(); field3_ = s; break; } case 34: { java.lang.String s = input.readStringRequireUtf8(); field4_ = s; break; } case 42: { java.lang.String s = input.readStringRequireUtf8(); field5_ = s; break; } }}这个实现比 Hashmap 来说,仅仅是稍微略快而已。DSL-JSON 的实现是先 hash,然后也是类似的分发的方式:switch(nameHash) {case 1212206434: _field1_ = com.dslplatform.json.StringConverter.deserialize(reader);nextToken = reader.getNextToken(); break;case 1178651196: _field3_ = com.dslplatform.json.StringConverter.deserialize(reader);nextToken = reader.getNextToken(); break;case 1195428815: _field2_ = com.dslplatform.json.StringConverter.deserialize(reader);nextToken = reader.getNextToken(); break;case 1145095958: _field5_ = com.dslplatform.json.StringConverter.deserialize(reader);nextToken = reader.getNextToken(); break;case 1161873577: _field4_ = com.dslplatform.json.StringConverter.deserialize(reader);nextToken = reader.getNextToken(); break;default: nextToken = reader.skip(); break;}使用的 hash 算法是 FNV-1a:long hash = 0x811c9dc5;while(ci < buffer.length) { final byte b = buffer[ci++]; if(b == '"') break; hash ^= b; hash *= 0x1000193;}是 hash 就会碰撞,所以用起来需要小心。如果输入很有可能包含未知的字段,则需要放弃速度选择匹配之后再查一下字段是不是严格相等的。Jsoniter 有一个解码模式 DYNAMIC_MODE_AND_MATCH_FIELD_STRICTLY,它可以产生下面这样的严格匹配的代码:switch(field.len()) {case 6: if(field.at(0) == 102&& field.at(1) == 105&& field.at(2) == 101&& field.at(3) == 108&& field.at(4) == 100) { if(field.at(5) == 49) { obj.field1 = (java.lang.String) iter.readString(); continue; } if(field.at(5) == 50) { obj.field2 = (java.lang.String) iter.readString(); continue; } if(field.at(5) == 51) { obj.field3 = (java.lang.String) iter.readString(); continue; } if(field.at(5) == 52) { obj.field4 = (java.lang.String) iter.readString(); continue; } if(field.at(5) == 53) { obj.field5 = (java.lang.String) iter.readString(); continue; } } break;}iter.skip();即便是严格匹配,速度上也是有保证的。DSL-JSON 也有选项,可以在 hash 匹配之后额外加一次字符串 equals 检查。关于对象绑定来说,只要字段名不长,基于数字的 tag 分发并不会比 JSON 具有明显优势,即便是相比最慢的 Jackson 来说也是如此。10、对象编码性能测试(Encode Object)废话不多说了,直接比较一下三种字段数量情况下,编码的速度。只有 1 个字段:有 5 个字段:有 10 个字段:对象编码方面,Protobuf 是 Jackson 的 1.7 倍。但是速度其实比 DSL-Json 还要慢。优化对象编码的方式是,一次性尽可能多的把控制类的字节写出去。public void encode(Object obj, com.jsoniter.output.JsonStream stream) throws java.io.IOException { if(obj == null) { stream.writeNull(); return; } stream.write((byte)'{'); encode_((com.jsoniter.benchmark.with_1_string_field.TestObject)obj, stream); stream.write((byte)'}');}public static void encode_(com.jsoniter.benchmark.with_1_string_field.TestObject obj, com.jsoniter.output.JsonStream stream) throws java.io.IOException { boolean notFirst = false; if(obj.field1 != null) { if(notFirst) { stream.write(','); } else{ notFirst = true; } stream.writeRaw("\"field1\":", 9); stream.writeVal((java.lang.String)obj.field1); }}可以看到我们把 "field1": 作为一个整体写出去了。如果我们知道字段是非空的,则可以进一步的把字符串的双引号也一起合并写出去。public void encode(Object obj, com.jsoniter.output.JsonStream stream) throws java.io.IOException { if(obj == null) { stream.writeNull(); return; } stream.writeRaw("{\"field1\":\"", 11); encode_((com.jsoniter.benchmark.with_1_string_field.TestObject)obj, stream); stream.write((byte)'\"', (byte)'}');}public static void encode_(com.jsoniter.benchmark.with_1_string_field.TestObject obj, com.jsoniter.output.JsonStream stream) throws java.io.IOException { com.jsoniter.output.CodegenAccess.writeStringWithoutQuote((java.lang.String)obj.field1, stream);}从对象的编解码的 benchmark 结果可以看出,Protobuf 在这个方面仅仅比 Jackson 略微强一些,而比 DSL-Json 要慢。11、整形列表解码性能测试(Decode Integer List)Protobuf 对于整数列表有特别的支持,可以打包存储:22// tag (field number 4, wire type 2)06// payload size (6 bytes)03// first element (varint 3)8E 02// second element (varint 270)9E A7 05// third element (varint 86942)设置 [packed=true]message PbTestObject { repeated int32 field1 = 1[packed=true];}https://github.com/json-iterator/java-benchmark/tree/master/src/main/java/com/jsoniter/benchmark/with_int_list对于整数列表的解码,Protobuf 是 Jackson 的 3 倍。然而比 DSL-Json 的优势并不明显。在 Jsoniter 里,解码的循环被展开了:public static java.lang.Object decode_(com.jsoniter.JsonIterator iter) throws java.io.IOException { java.util.ArrayList col = (java.util.ArrayList)com.jsoniter.CodegenAccess.resetExistingObject(iter); if(iter.readNull()) { com.jsoniter.CodegenAccess.resetExistingObject(iter); returnnull; } if(!com.jsoniter.CodegenAccess.readArrayStart(iter)) { returncol == null? newjava.util.ArrayList(0): (java.util.ArrayList)com.jsoniter.CodegenAccess.reuseCollection(col); } Object a1 = java.lang.Integer.valueOf(iter.readInt()); if(com.jsoniter.CodegenAccess.nextToken(iter) != ',') { java.util.ArrayList obj = col == null? newjava.util.ArrayList(1): (java.util.ArrayList)com.jsoniter.CodegenAccess.reuseCollection(col); obj.add(a1); return obj; } Object a2 = java.lang.Integer.valueOf(iter.readInt()); if(com.jsoniter.CodegenAccess.nextToken(iter) != ',') { java.util.ArrayList obj = col == null? newjava.util.ArrayList(2): (java.util.ArrayList)com.jsoniter.CodegenAccess.reuseCollection(col); obj.add(a1); obj.add(a2); return obj; } Object a3 = java.lang.Integer.valueOf(iter.readInt()); if(com.jsoniter.CodegenAccess.nextToken(iter) != ',') { java.util.ArrayList obj = col == null? newjava.util.ArrayList(3): (java.util.ArrayList)com.jsoniter.CodegenAccess.reuseCollection(col); obj.add(a1); obj.add(a2); obj.add(a3); return obj; } Object a4 = java.lang.Integer.valueOf(iter.readInt()); java.util.ArrayList obj = col == null? newjava.util.ArrayList(8): (java.util.ArrayList)com.jsoniter.CodegenAccess.reuseCollection(col); obj.add(a1); obj.add(a2); obj.add(a3); obj.add(a4); while(com.jsoniter.CodegenAccess.nextToken(iter) == ',') { obj.add(java.lang.Integer.valueOf(iter.readInt())); } return obj;}对于成员比较少的情况,这样搞可以避免数组的扩容带来的内存拷贝。12、整形列表编码性能测试(Encode Integer List)Protobuf 在编码数组的时候应该有优势,不用写那么多逗号出来嘛。Protobuf 在编码整数列表的时候,仅仅是 Jackson 的 1.35 倍。虽然 Protobuf 在处理对象的整数字段的时候优势明显,但是在处理整数的列表时却不是如此。在这个方面,DSL-Json 没有特殊的优化,性能的提高纯粹只是因为单个数字的编码速度提高了。13、对象列表解码性能测试(Decode Object List)列表经常用做对象的容器。测试这种两种容器组合嵌套的场景,也很有代表意义。message PbTestObject { message ElementObject { string field1 = 1; } repeated ElementObject field1 = 1;}java-benchmark/src/main/java/com/jsoniter/benchmark/with_object_list at master · json-iterator/java-benchmark · GitHubProtobuf 处理对象列表是 Jackson 的 1.3 倍。但是不及 DSL-JSON。14、对象列表编码性能测试(Encode Object List)Protobuf 处理对象列表的编码速度是 Jackson 的 2 倍。但是 DSL-JSON 仍然比 Protobuf 更快。似乎 Protobuf 在处理列表的编码解码方面优势不明显。15、双精度浮点数数组解码性能测试(Decode Double Array)Java 的数组有点特殊,double[] 是比 List<Double> 更高效的。使用 double 数组来代表时间点上的值或者坐标是非常常见的做法。然而,Protobuf 的 Java 库没有提供double[] 的支持,repeated 总是使用 List<Double>。我们可以预期 JSON 库在这里有一定的优势。message PbTestObject { repeated doublefield1 = 1[packed=true];}https://github.com/json-iterator/java-benchmark/tree/master/src/main/java/com/jsoniter/benchmark/with_double_arrayProtobuf 在处理 double 数组方面,Jackson 与之的差距被缩小为 5 倍。Protobuf 与 DSL-JSON 相比,优势已经不明显了。所以如果你有很多的 double 数值需要处理,这些数值必须是在对象的字段上,才会引起性能的巨大差别,对于数组里的 double,优势差距被缩小。在 Jsoniter 里,处理数组的循环也是被展开的。public static java.lang.Object decode_(com.jsoniter.JsonIterator iter) throws java.io.IOException {... // abbreviated nextToken = com.jsoniter.CodegenAccess.nextToken(iter); if(nextToken == ']') { return new double[0]; } com.jsoniter.CodegenAccess.unreadByte(iter); double a1 = iter.readDouble(); if(!com.jsoniter.CodegenAccess.nextTokenIsComma(iter)) { return new double[]{ a1 }; } double a2 = iter.readDouble(); if(!com.jsoniter.CodegenAccess.nextTokenIsComma(iter)) { return new double[]{ a1, a2 }; } double a3 = iter.readDouble(); if(!com.jsoniter.CodegenAccess.nextTokenIsComma(iter)) { return new double[]{ a1, a2, a3 }; } double a4 = (double) iter.readDouble(); if(!com.jsoniter.CodegenAccess.nextTokenIsComma(iter)) { return new double[]{ a1, a2, a3, a4 }; } double a5 = (double) iter.readDouble(); double[] arr = new double[10]; arr[0] = a1; arr[1] = a2; arr[2] = a3; arr[3] = a4; arr[4] = a5; inti = 5; while(com.jsoniter.CodegenAccess.nextTokenIsComma(iter)) { if(i == arr.length) { double[] newArr = new double[arr.length * 2]; System.arraycopy(arr, 0, newArr, 0, arr.length); arr = newArr; } arr[i++] = iter.readDouble(); } double[] result = newdouble[i ]; System.arraycopy(arr, 0, result, 0, i); return result;}这避免了数组扩容的开销。16、双精度浮点数数组编码性能测试(Encode Double Array)再来看看 double 数组的编码:Protobuf 可以飞快地对 double 数组进行编码,是 Jackson 的 15 倍。在牺牲精度的情况下,Protobuf 只是Jsoniter 的 2.3 倍。所以,再次证明了,JSON 处理 double 非常慢。如果用 base64 编码 double,则可以保持精度,速度和牺牲精度时一样。17、字符串解码性能测试(Decode String)JSON 字符串包含了转义字符的支持。Protobuf 解码字符串仅仅是一个内存拷贝。理应更快才对。被测试的字符串长度是 160 个字节的 ascii。syntax = "proto3";option optimize_for = SPEED;message PbTestObject { string field1 = 1;}https://github.com/json-iterator/java-benchmark/tree/master/src/main/java/com/jsoniter/benchmark/with_long_stringProtobuf 解码长字符串是 Jackson 的 1.85 倍。然而,DSL-Json 比 Protobuf 更快。这就有点奇怪了,JSON 的处理负担更重,为什么会更快呢?先尝试捷径:DSL-JSON 给 ascii 实现了一个捷径(源码点此):for(int i = 0; i < chars.length; i++) { bb = buffer[ci++]; if(bb == '"') { currentIndex = ci; return i; } // If we encounter a backslash, which is a beginning of an escape sequence // or a high bit was set - indicating an UTF-8 encoded multibyte character, // there is no chance that we can decode the string without instantiating // a temporary buffer, so quit this loop if((bb ^ '\\') < 1) break; chars[i ] = (char) bb;}这个捷径里规避了处理转义字符和utf8字符串的成本。JVM 的动态编译做了特殊优化:在 JDK9 之前,java.lang.String 都是基于 `char[]` 的。而输入都是 byte[] 并且是 utf-8 编码的。所以这使得,我们不能直接用 memcpy 的方式来处理字符串的解码问题。但是在 JDK9 里,java.lang.String 已经改成了基于`byte[]`的了。从 JDK9 的源代码里可以看出:@Deprecated(since="1.1")public String(byte ascii[], int hibyte, int offset, int count) { checkBoundsOffCount(offset, count, ascii.length); if(count == 0) { this.value = "".value; this.coder = "".coder; return; } if(COMPACT_STRINGS && (byte)hibyte == 0) { this.value = Arrays.copyOfRange(ascii, offset, offset + count); this.coder = LATIN1; } else{ hibyte <<= 8; byte[] val = StringUTF16.newBytesFor(count); for(inti = 0; i < count; i++) { StringUTF16.putChar(val, i, hibyte | (ascii[offset++] & 0xff)); } this.value = val; this.coder = UTF16; }}使用这个虽然被废弃,但是还没有被删除的构造函数,我们可以使用 Arrays.copyOfRange 来直接构造 java.lang.String 了。然而,在测试之后,发现这个实现方式并没有比 DSL-JSON 的实现更快。似乎 JVM 的 Hotspot 动态编译时对这段循环的代码做了模式匹配,识别出了更高效的实现方式。即便是在 JDK9 使用 +UseCompactStrings 的前提下,理论上来说本应该更慢的 byte[] => char[] => byte[] 并没有使得这段代码变慢,DSL-JSON 的实现还是最快的。如果输入大部分是字符串,这个优化就变得至关重要了。Java 里的解析艺术,还不如说是字节拷贝的艺术。JVM 的 java.lang.String 设计实在是太愚蠢了。在现代一点的语言中,比如 Go,字符串都是基于 utf-8 byte[] 的。18、字符串编码性能测试(Encode String)类似的问题,因为需要把 char[] 转换为 byte[],所以没法直接内存拷贝。Protobuf 在编码长字符串时,比 Jackson 略微快一点点,一切都归咎于 char[]。19、本文总结最后,我们把所有的战果汇总到一起。编解码数字的时候,JSON仍然是非常慢的。Jsoniter 把这个差距从 10 倍缩小到了 3 倍多一些。JSON 最差的情况是下面几种:1)跳过非常长的字符串:和字符串长度线性相关;2)解码 double 字段:Protobuf 优势明显,是 Jsoniter的 3.27 倍,是 Jackson 的 13.75 倍;3)编码 double 字段:如果不能接受只保留 6 位小数,Protobuf 是 Jackson 的 12.71 倍(如果接受精度损失,Protobuf 是 Jsoniter 的 1.96 倍);4)解码整数:Protobuf 是 Jsoniter 的 2.64 倍,是 Jackson 的 8.51 倍。如果你的生产环境中的JSON没有那么多的double字段,都是字符串占大头,那么基本上来说替换成 Protobuf 也就是仅仅比 Jsoniter 提高一点点,肯定在2倍之内。如果不幸的话,没准 Protobuf 还要更慢一点。20、参考资料[1] Protobuf官方编码资料[2] Protobuf官方手册[3] Why do we use Base64?[4] The Base16, Base32, and Base64 Data Encodings[5] Protobuf从入门到精通,一篇就够![5] 如何选择即时通讯应用的数据传输格式[7] 强列建议将Protobuf作为你的即时通讯应用数据传输格式[8] APP与后台通信数据格式的演进:从文本协议到二进制协议[9] 面试必考,史上最通俗大小端字节序详解[10] 移动端IM开发需要面对的技术问题(含通信协议选择)[11] 简述移动端IM开发的那些坑:架构设计、通信协议和客户端[12] 理论联系实际:一套典型的IM通信协议设计详解[13] 58到家实时消息系统的协议设计等技术实践分享(本文已同步发布于:http://www.52im.net/thread-4095-1-1.html)
1、引言在《IM消息ID技术专题》系列文章的前几篇中,我们已经深切体会到消息ID在分布式IM聊天系统中的重要性以及技术实现难度,各种消息ID生成算法及实现虽然各有优势,但受制于具体的应用场景,也并不能一招吃遍天下,所以真正在IM系统中该如何落地消息ID算法和实现逻辑,还是要因地致宜,根据自已系统的设计逻辑和产品定义取其精华,综合应用之。本文将基于网易严选的订单ID使用现状,分享我们是如何结合业内常用的分布式ID解决方案,从而在此基础之上进行ID特性丰富,并不断提升系统可用性和稳定性保障。同时,也对ID生成算法的落地实践过程中遇到坑进行了深入剖析。本篇中的订单ID虽然不同于IM系统中的消息ID,但其技术实践仍然相通,希望能给你的IM系统消息ID技术选型也来更多的启发。学习交流:- 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》- 开源IM框架源码:https://github.com/JackJiang2011/MobileIMSDK(备用地址点此)(本文已同步发布于:http://www.52im.net/thread-4069-1-1.html)2、关于作者西狂:服务端研发工程师, 早期参与严选采购、严选财务、严选合伙人以及报警平台等系统后端建设,目前主要致力于严选交易域技术演进以及业务研发工作。3、系列文章本文是系列文章中的第7篇,本系列总目录如下:《IM消息ID技术专题(一):微信的海量IM聊天消息序列号生成实践(算法原理篇)》《IM消息ID技术专题(二):微信的海量IM聊天消息序列号生成实践(容灾方案篇)》《IM消息ID技术专题(三):解密融云IM产品的聊天消息ID生成策略》《IM消息ID技术专题(四):深度解密美团的分布式ID生成算法》《IM消息ID技术专题(五):开源分布式ID生成器UidGenerator的技术实现》《IM消息ID技术专题(六):深度解密滴滴的高性能ID生成器(Tinyid)》《IM消息ID技术专题(七):网易严选分布式ID的技术选型、优化、落地实践》(* 本文)4、为什么需要分布式ID?4.1 业务背景如上图所示,对于网易严选的主站、分销和tob都会生成各自的订单ID,在同步订单数据到订单中心的时候,订单中心会生成一个订单中心内部的一个订单号,只是推送给到下游仓配时使用的订单ID略有不同。4.2 带来的问题 因为订单ID使用的混乱,导致了一系列问题的产生,例如: 沟通壁垒 、管控困难以及代码腐化等等。4.3 技术目标 我们希望通过分布式ID来帮助生成订单ID,在业务规则上必须全局唯一、安全性高,在性能上要高可用、低延迟。5、我们的分布式ID架构原理5.1 技术选型下表是业内常见的分布式ID解决方案:综合考虑是否支持水平扩展以及能够显示指定ID长度,最终选择的是Leaf的Segment模式(详见《深度解密美团的分布式ID生成算法》)。5.2 架构简介Leaf采用了预分发的方式来生成ID(如下图所示),在DB之上搭载若干个Server,每个Server在启动的时候,都会去DB中拿固定长度的ID列表,存放于内存中,因为ID是基于内存分发的,所以可以做到很高效。在数据持久化方面,每次去DB拿固定长度的ID列表,只是把最大的ID持久化。整体架构实现比较简单,主要是为了尽快解决业务层DB压力的问题,但是在生产环境中也暴露出一些问题。比如:1)TP999数据波动大,当号段使用完之后还是会hang在更新数据库的I/O上,tp999数据会出现偶尔的尖刺;2)当更新DB号段的时候,如果DB宕机或者发生主从切换,会导致一段时间的服务不可用。5.3 可用性优化为了解决上面提到这个两个问题,引入双Buffer机制和异步更新策略,当一个Buffer消耗到某个临界点时,就会异步的触发任务,把下一个号段加载到内存中。保证无论何时DB出现问题,都能有一个Buffer的号段可以正常对外提供服务,只要DB在一个Buffer的下发的周期内恢复,就不会影响整个Leaf的可用性。5.4 步长动态调整号段长度在固定不变的前提下,流量的突增和锐减都会使得正常流量下维持原有号段正常工作的时间缩短和提升。可以尝试使用以下关系表达式来描述:Q * T = L(Q:服务qps L:号段长度 T:号段更新周期)但是Leaf的本质是希望T固定,如果Q和L可以正相关,T就可以趋于一个定值。所以在Leaf每次更新号段的时候,会根据上一次号段更新的周期T和号段长度step,来决定下一次号段长度nextStep。如下所示:T < 15min,nextStep = step * 215min < T < 30min,nextStep = stepT > 30min,nextStep = step / 2(初始指定step <= nextStep <= 最大值(自定义:100W))6、我们做了什么改进?6.1 特性丰富通过结合严选的实际业务场景,进行了特性化支持,例如支持批量ID获取、大促提前扩容以及提前跳段处理。6.2 可用性保障1)针对DB: DB(MySql)采用主从模式(读写分离、降低主库压力),一主两从的配置方式,Master和Slave之间采用的是半同步复制(数据一致性要求,后期可考虑使用MySql Group Replication)。同时还添加了双1配置,保证不丢数据。2)引入SDK:通过引入SDK可以降低各个业务方的接入成本、降低Leaf服务端压力以及在Leaf服务不可用时,客户端起到短暂降级的效果。SDK的实现原理和Leaf类似,在项目启动之初会加载业务关心参数配置信息,在应用构建本地缓存,同样采用了双Buffer存储模式。6.3 稳定性保障1)运维方面:主要分为3个方面:1)日志监控:可以帮助发现预期之外的异常情况;2)流量监控:流有助于号段长度的评估范围,预防号段被快速消费的极端场景;3)线上巡检:可以时刻对服务进行存活校验。2)SLA高可用方面:除了运维之外还做了SLA的接入,通常用SLA来衡量系统的稳定性,除此之外我们还按照接口维度设定了SLO目标规则,目前的指标项比较单一只有请求延迟和错误率这两项。7、我们遇到的坑7.1 问题发现如下图所示,我们发现每次服务启动上线接口的rt(响应时间)都要比平时高的多,但是过了一段时间之后却又恢复成正常水平。7.2 问题探究在分析之前,我们可以先简单的回顾下java虚拟机是如何运行Java字节码的。虚拟机视角下Java字节码如何被虚拟机运行:Java虚拟机将class文件加载到虚拟机中,然后将字节码翻译成机器码给底层硬件执行,而这里的翻译有两种形式,解释执行和编译执行。前者的优势在于无需等待编译,后者的优势在于实际运行速度更快。HotSpot默认采用混合模式,它会先解释执行字节码,然后将其中反复执行的热点代码,以方法为单位进行即时编译,JVM是依据方法的调用次数以及循环回边的执行次数来触发JIT编译的。在Java7之前我们可以根据程序的特性选择对应的即时编译器。Java7开始引入分层编译机制(-XX:+TieredCompilation):综合了C1的启动性能优势和C2的峰值性能优势。分层编译将JVM的执行状态分为了5个层次:L0:解释执行(也会profiling);L1:执行不带profiling的C1代码;L2:执行仅带方法调用次数和循环回边执行次数profiling的C1代码;L3:执行带所有profiling的C1代码;L4:执行C2代码。对于C1编译的三个层次,按执行效率从高至低:L1 > L2 > L3, 这是因为profiling越多,额外的性能开销越大。通常情况下,C2代码的执行效率比C1代码高出30%以上。(这里需要注意的是Java8默认开启了分层编译)这张图列出了常见的分层编译的编译路径:1)通常情况下,热点方法会被第三层的C1编译器编译,再被C2编译器编译(0-> 3-> 4);2)如果方法的字节数目比较少并且第三层的profilling没有可收集的数据,jvm会判定该方法对于C1和C2的执行效率相同,在经过3层的C1编译过后,直接回到1层的C1(0-> 3-> 1);3)在C1忙碌的情况下,JVM在解释执行过程中对程序进行profiling,而后直接由4层的C2编译(0-> 4);4)在C2忙碌的情况下,方法会被2层的C1编译,然后再被3层的C1编译,以减少方法在3层的执行时间(0-> 2-> 3-> 4)。上图是项目启动时的分层编译日志以及整个过程接口响应RT。从图中可以看到先是执行了C1编译,再执行C2编译(日志文件中的3和4分别打标L3和L4),满足 0->3->4 编译顺序。发现从C1编译到C2编译耗时过程比较长,这符合我们一开始提出的疑问,为什么项目启动需要经过一段时间接口RT才能趋于稳定。7.3 解决方案为了能在项目启动之初,快速达到接口RT峰值,因此只要尽最大程度缩短解释执行这个中间过程即可。相应的解决方案:方案 1:关闭分层编译,降低编译阈值;方案 2:Mock接口数据, 快速触发JIT编译以及C2编译;方案 3:Java9 AOT提前编译。针对方案3:Java9中支持新特性AOT提前编译,相比较于JIT即时编译而言,AOT在运行前就已经编译好了,避免 JIT 编译器的运行时性能消耗,同时避免解释程序的早期性能开销,可以极大提高java代码性能。8、落地使用概况Leaf已经在线上环境投入使用,各个业务方(包括主站、渠道、tob)也相应接入进行统一整改,自此严选订单ID生成得到统一收拢。在整个严选的落地情况,按照业务维度,目前累计接入3个业务,分别是订单ID、订单快照ID、订单商品快照ID,都经受住了双十一和双十二考验。9、参考资料[1] 微信的海量IM聊天消息序列号生成实践(算法原理篇)[2] 解密融云IM产品的聊天消息ID生成策略[3] 深度解密美团的分布式ID生成算法[4] 深度解密滴滴的高性能ID生成器(Tinyid)(本文已同步发布于:http://www.52im.net/thread-4069-1-1.html)
本文由蘑菇街前端技术团队分享,原题“Electron 从零到一”,有修订和改动。1、引言本系列文章的前面几篇主要是从Electron技术本身进行了讨论(包括:第1篇初步了解Electron、第2篇进行了快速开始和技术体验、第3篇基于实际开发考虑的技术栈选型等),各位读者也应该对Electron的开发有了较为深入的了解。本篇将回到IM即时通讯技术本身,根据蘑菇街的实际技术实践,总结和分享基于Electron开发跨平台IM客户端的过程中,需要考虑的典型技术问题以及我们的解决方案。希望能给你带来帮助。学习交流:- 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》- 开源IM框架源码:https://github.com/JackJiang2011/MobileIMSDK(备用地址点此)(本文已同步发布于:http://www.52im.net/thread-4051-1-1.html)2、系列文章本文是系列文章中的第4篇,本系列总目录如下:《IM跨平台技术学习(一):快速了解新一代跨平台桌面技术——Electron》《IM跨平台技术学习(二):Electron初体验(快速开始、跨进程通信、打包、踩坑等)》《IM跨平台技术学习(三):vivo的Electron技术栈选型、全方位实践总结》《IM跨平台技术学习(四):蘑菇街基于Electron开发IM客户端的技术实践》(* 本文)《IM跨平台技术学习(五):融云基于Electron的IM跨平台SDK改造实践总结》(稍后发布.. )《IM跨平台技术学习(六):网易云信基于Electron的IM消息全文检索技术实践》(稍后发布.. )3、IM消息的加密和解密3.1需求背景对IM聊天软件而言,聊天消息的保密性就比较重要了,谁也不希望自己的聊天内容泄露甚至暴露在众人的前面。所以在收发IM信息的时候,我们需要对信息做一些加密解密操作,保证信息在网络中传输的时候是加密的状态。3.2简单的实现方法可能大家会说:这还不简单?项目里写个加密解密的方法——收到消息时候先解密,发送消息时候先加密,服务端收到加密消息直接存储起来。这样写理论上也没有问题,不过客户端直接写加解密方法有一些不好的地方。比如:1)容易逆向:前端代码比较容易被逆向;2)性能较差:用户可能加了很多群组,各群组中都会收到很多消息,前端处理起来比较慢;3)多端实现:如果都在客户端实现加解密算法,那么 ios, android 等不同客户端,因为使用的开发语言不同,都要分别实现相同的算法,增加维护成本。3.3我们的方案我们使用 C++ Addons 提供的能力,在 c++ sdk 中实现加解密算法,让 js 可以像调用 Node 模块一样去调用 c++ sdk 模块。这样就一次性解决了上面提到的所有问题。技术原理如下图:开发完 addon,使用 node-gyp 来构建 C++ Addons。node-gyp 会根据 binding.gyp 配置文件调用各平台上的编译工具集来进行编译。如果要实现跨平台,需要按不同平台编译 nodejs addon,在 binding.gyp 中按平台配置加解密的静态链接库。就像下面这样:{ "targets": [{ "conditions": [ ["OS=='mac'", { "libraries": [ "<(module_root_dir)/lib/mac/security.a" ] }], ["OS=='win'", { "libraries": [ "<(module_root_dir)/lib/win/security.lib"] }], ... ] ... }]当然也可以根据需要添加更多平台的支持,如 linux、unix。对 c++ 代码进程封装 addon 的时候,可以使用 node-addon-api。node-addon-api 包对 N-API 做了封装,并抹平了 nodejs 版本间的兼容问题。封装大大降低了非职业 c++ 开发编写 node addon 的成本(关于 node-addon-api、N-API、NAN 等概念可以参考死月同学的文章《从暴力到 NAN 再到 NAPI——Node.js 原生模块开发方式变迁》)。打包出 .node 文件后,可以在 electron 应用运行时,调用 process.platform 判断运行的平台,分别加载对应平台的 addon。if(process.platform === 'win32') { addon = require('../lib/security_win.node');} else{ addon = require('../lib/security_mac.node');}3.4进一步学习限于篇幅,本篇里没办法对IM的安全进行更深入的总结和分享,感兴趣的读者可以详读:《IM聊天系统安全手段之通信连接层加密技术》、《IM聊天系统安全手段之传输内容端到端加密技术》。4、IM消息的序列化与反序列化4.1需求背景IM聊天消息直接通过 JSON 编解码和传输效率是比较低的,我们可以使用高效的消息序列化与反序列化方案。4.2我们的方案这里我们引入谷歌的 Protocol Buffer 提升效率。PS:关于 Protocol Buffer 更多的介绍,可以查看《Protobuf通信协议详解:代码演示、详细原理介绍等》。node 环境中使用 Protocol Buffer 可以用 protobufjs 包。npm i protobuff -S然后通过 pbjs 命令将 proto 文件转换成 pbJson.jspbjs -t json-module --sparse --force-long -w commonjs -o src/im/data/pbJson.js proto/*.proto要在 js 中支持后端 int64 格式数据,需要使用 long 包配置下 protobuf。var Long = require("long");$protobuf.util.Long = Long;$protobuf.configure();$protobuf.util.LongBits.prototype.toLong = functiontoLong (unsigned) { returnnew $protobuf.util.Long(this.lo | 0, this.hi | 0, Boolean(unsigned)).toString();};后面就是消息的压缩转换了,将 js 字符串转成 pb 格式。import PbJson from './path/to/src/im/data/pbJson.js'; // 封装数据let encodedMsg = PbJson.lookupType('pb-api').ctor.encode(data).finish(); // 解封数据let decodedMsg = PbJson.lookupType('pb-api').ctor.decode(buff);5、网络传输协议的选择开发IM时可供选择的网络传输层协议有 UDP、TCP 等。UDP 实时性好,但是可靠性不好。这里我们选用 的是 TCP 协议。 PS:关于TCP和UDP的区别,以及该如何选择,可以详细阅读这几篇:《快速理解TCP和UDP的差异》《一泡尿的时间,快速搞懂TCP和UDP的区别》《简述传输层协议TCP和UDP的区别》《为什么QQ用的是UDP协议而不是TCP协议?》《移动端即时通讯协议选择:UDP还是TCP?》应用层分别使用 WebSocket 协议保持长连接保证实时传输消息,HTTPS 协议传输消息外的其他状态数据。这里给个例子实现一个简单的 WebSocket 管理类:import { EventEmitter } from 'events';const webSocketConfig = 'wss://xxxx';class SocketServer extends EventEmitter { connect () { if(this.socket){ this.removeEvent(this.socket); this.socket.close(); } this.socket = newWebSocket(webSocketConfig); this.bindEvents(this.socket); returnthis; } close () {} async getSocket () { } bindEvents() {} removeEvent() {} onMessage (e) { // 消息解包 let decodedMSg = 'xxx; this.emit(decodedMSg); } async send(sendData) { const socket = await this.getSocket() socket.send(sendData); } ...}如果你对WebSocket协议还不了解,可以从这两篇入门文章入手学习:《新手快速入门:WebSocket简明教程》、《WebSocket从入门到精通,半小时就够!》对于HTTPS 协议的话就不多介绍了,大家天天用。如果你还不是太了解,可以读读这两篇:《如果这样来理解HTTPS原理,一篇就够了》、《一分钟理解 HTTPS 到底解决了什么问题》。6、IM的私有数据通信协议上几节我们实现了把IM聊天消息序列化和反序列化,也实现了通过 WebSocket 发送和接收消息,但还不能直接这样发送聊天消息。因为我们还需要一个数据通信协议(什么是数据通信协议?可以读读这篇《理论联系实际:一套典型的IM通信协议设计详解》)。也就是给通信层的原始“消息“增加一些属性,比如:id 用来关联收发的消息、type 标记消息类型、version 标记、接口的版本,api 标记调用的接口等。然后据此定义一个编码格式,用 ArrayBuffer 将消息包装起来,放到 WebSocket 中发送,以二进制流的方式传输。协议设计需要保证足够的扩展性,不然修改的时候需要同时修改前后端,比较麻烦。下面是个简化的例子:class PocketManager extends EventEmitter { encode (id, type, version, api, payload) { let headerBuffer = Buffer.alloc(8); let payloadBuffer = Buffer.alloc(0); let offset = 0; let keyLength = Buffer.from(id).length; headerBuffer.writeUInt16BE(keyLength, offset); offset += 2; headerBuffer.write(id, offset, offset + keyLength, 'utf8'); ... payloadBuffer = Buffer.from(payload); returnBuffer.concat([headerBuffer, payloadBuffer], 8 + payloadBuffer.length); } decode () {}}关于IM私有数据通信协议/格式的设计,可以参考《一套海量在线用户的移动端IM架构设计实践分享(含详细图文)》一文中的“3、协议设计”这一节。另外,如果你自认为对于IM的理论知识很匮乏或不成体系,可以从《新手入门一篇就够:从零开发移动端IM》入手,系统地进行学习。7、IM模块多进程优化IM 界面有很多模块:聊天模块,群管理模块,历史消息模块等。另外:消息通信逻辑不应该和界面逻辑放一个进程里,避免界面卡顿时候影响消息的收发。这里有个简单的实现方法,把不同的模块放到 electorn 不同的窗口中,因为不同的窗口由不同的进程管理,我们就不需要自己管理进程了。下面实现一个窗口管理类:import { EventEmitter } from 'events';class BaseWindow extends EventEmitter { open () {} close () {} isExist () {} destroy() {} createWindow() { this.win = newBrowserWindow({ ...this.browserConfig, }); } ...}其中 browserConfig 可以在子类中设置,不同窗口可以继承这个基类设置自己窗口属性。通信模块用作后台收发数据,不需要显示窗口,可以设置窗口 width = 0,height = 0 :class ImWindow extends BaseWindow { browserConfig = { width: 0, height: 0, show: false, } ...}8、IM数据的本地存储 8.1背景IM 软件中可能会有几千个联系人信息,无数的聊天记录。如果每次都通过网络请求访问,比较浪费带宽,影响性能。那么是否有什么优化手段呢?8.2讨论在Electorn 中可以使用 localstorage, 但是 localstorage 有大小限制,实际大多只能存 5M 信息,超过存入大小会报错。有些同学可能还会想到 websql, 但这个技术标准已经被废弃了。浏览器内置的 indexedDB 也是一个可选项。不过这个也有限制,也没有 sqlite 一样丰富的生态工具可以用。8.3方案这里我们选用 sqlite,在 node 中使用 sqlite 可以直接用 sqlite3 包。可以先写个 DAO 类:import sqlite3 from 'sqlite3';class DAO { constructor(dbFilePath) { this.db = newsqlite3.Database(dbFilePath, (err) => { // }); } run(sql, params = []) { returnnewPromise((resolve, reject) => { this.db.run(sql, params, function(err) { if(err) { reject(err); } else{ resolve({ id: this.lastID }); } }); }); } ...}再写个 base Model:class BaseModel { constructor(dao, tableName) { this.dao = dao; this.tableName = tableName; } delete(id) { returnthis.dao.run(`DELETE FROM ${this.tableName} WHERE id = ?`, [id]); } ...}其他 Model 比如消息、联系人等 Model 可以直接继承这个类,复用 delete/getById/getAll 之类的通用方法。如果不喜欢手动编写 SQLite 语句,可以引入 knex 语法封装器。当然也可以直接时髦点用上 orm ,比如 typeorm 什么的。使用如下:const dao = newAppDAO('path/to/database-file.sqlite3');const messageModel = newMessageModel(dao);9、IM新消息托盘图标闪烁在Electron 中没有提供专用的 tray 闪烁的接口,我们可以简单的使用切换 tray 图标来实现这个功能。import { Tray, nativeImage } from 'electron'; class TrayManager { ... setState() { // 设置默认状态 } startBlink(){ if(!this.tray){ return; } let emptyImg = nativeImage.createFromPath(path.join(__dirname, './empty.ico')); let noticeImg = nativeImage.createFromPath(path.join(__dirname, './newMsg.png')); let visible; clearInterval(this.trayTimer); this.trayTimer = setInterval(()=>{ visible = !visible; if(visible){ this.tray.setImage(noticeImg); }else{ this.tray.setImage(emptyImg); } },500); } //停止闪烁 stopBlink(){ clearInterval(this.trayTimer); this.setState(); }}10、IM客户端版本更新一般有几种不同的更新策略,可以一种或几种结合使用,提升体验。第一种:是整个软件更新。这种方式比较暴力,体验不好,打开应用检查到版本变更,直接重新下载整个应用替换老版本。改一行代码,让用户冲下百来兆的文件。第二种:是检测文件变更,下载替换老文件进行升级。第三种:是直接将 view 层文件放在线上,electron 壳加载线上页面访问。有变更发布线上页面就可以。关于版本更新,在本系列的上篇《vivo的Electron技术栈选型、全方位实践总结》也有提及,可以回顾一下。11、进程间通信上一篇文章中,有同学问怎么处理进程间通信。electron 进程间通信主要用到 ipcMain 和 ipcRenderer。可以先写个发消息的方法:import { remote, ipcRenderer, ipcMain } from 'electron'; function sendIPCEvent(event, ...data) { if(require('./is-electron-renderer')) { constcurrentWindow = remote.getCurrentWindow(); if(currentWindow) { currentWindow.webContents.send(event, ...data); } ipcRenderer.send(event, ...data); return; } ipcMain.emit(event, null, ...data);}export defaultsendIPCEvent;这样不管在主进程还是渲染进程,直接调用这个方法就可以发消息。对于某些特定功能的消息,还可以做一些封装,比如所有推送消息可以封装一个方法,通过方法中的参数判断具体推送的消息类型。main 进程中根据消息类型,处理相关逻辑,或者对消息进行转发。class ipcMainManager extends EventEmitter { constructor() { ipcMain.on('imPush', (name, data) => { this.emit(name, data); }) this.listern(); } listern() { this.on('imPush', (name, data) => { // }); }}class ipcRendererManager extends EventEmitter { push (name, data) { ipcRenderer.send('imPush', name, data); }}12、其他杂项还有同学提到日志处理功能。这个和 Electron 关系不大,是 node 项目通用的功能。可以选用 winston 之类第三方包。本地日志的话注意一下存储的路径,定期清理等功能点,远程日志提交到接口就可以了。获取路径可以写些通用的方法,如:import electron from 'electron';functiongetUserDataPath() { if(require('./is-electron-renderer')) { returnelectron.remote.app.getPath('userData'); } returnelectron.app.getPath('userData');}export defaultgetUserDataPath;13、参考资料[1] Protobuf通信协议详解:代码演示、详细原理介绍等[2] IM聊天系统安全手段之通信连接层加密技术[3] IM聊天系统安全手段之传输内容端到端加密技术[4] TCP/IP详解 - 第11章·UDP:用户数据报协议[5] TCP/IP详解 - 第17章·TCP:传输控制协议[6] 移动端即时通讯协议选择:UDP还是TCP?[7] WebSocket从入门到精通,半小时就够![8] 如果这样来理解HTTPS原理,一篇就够了[9] 一套海量在线用户的移动端IM架构设计实践分享(含详细图文)[10] 理论联系实际:一套典型的IM通信协议设计详解(本文已同步发布于:http://www.52im.net/thread-4051-1-1.html)
本文由融云技术团队分享,原题“互联网通信安全之端到端加密技术”,内容有较多修订和改动。1、引言在上篇《IM聊天系统安全手段之通信连接层加密技术》中,分享了关于通信连接层加密的相关技术和实践,包括在传输即时通信消息时启用 TLS 链路加密(保证消息在到达服务器前无法被窃听和篡改)、使用 CA 认证机制(杜绝中间人攻击)等。本篇将围绕IM传输内容的安全问题,以实践为基础,为你分享即时通讯应用中的“端到端”加密技术。学习交流:- 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》- 开源IM框架源码:https://github.com/JackJiang2011/MobileIMSDK(备用地址点此)(本文已同步发布于:http://www.52im.net/thread-4026-1-1.html)2、系列文章本文是IM通讯安全知识系列文章中的第11篇,此系列总目录如下:《即时通讯安全篇(一):正确地理解和使用Android端加密算法》《即时通讯安全篇(二):探讨组合加密算法在IM中的应用》《即时通讯安全篇(三):常用加解密算法与通讯安全讲解》《即时通讯安全篇(四):实例分析Android中密钥硬编码的风险》《即时通讯安全篇(五):对称加密技术在Android平台上的应用实践》《即时通讯安全篇(六):非对称加密技术的原理与应用实践》《即时通讯安全篇(七):如果这样来理解HTTPS原理,一篇就够了》《即时通讯安全篇(八):你知道,HTTPS用的是对称加密还是非对称加密?》《即时通讯安全篇(九):为什么要用HTTPS?深入浅出,探密短连接的安全性》《即时通讯安全篇(十):IM聊天系统安全手段之通信连接层加密技术》《即时通讯安全篇(十一):IM聊天系统安全手段之传输内容端到端加密技术》(* 本文)3、为什么需要端到端加密?上篇中提到的连接层加密技术,这是提升IM客户端到服务器之间数据传输的安全性手段,但是这并不能解决用户间的通信隐私性以及安全性风险。因为在将数据传输到服务器之后,所有有权访问此服务器的人,包括员工、供应商及其他有关人员(甚至黑客),都有可能读取到用户的数据。有鉴于此,端到端加密技术在即时通讯IM领域被广泛应用,包括WhatsApp、Signal、Telegram 等国外即时通信软件中都有使用。PS:有关端到端加密的基础知识,可以从这两篇里得到,建议详读:《移动端安全通信的利器——端到端加密(E2EE)技术详解》《简述实时音视频聊天中端到端加密(E2EE)的工作原理》4、端到端加密的技术设计思路4.1 简化版思路说到端到端加密,我们首先想到的解决方案是:在发送端发送消息前对整个消息进行加密,接收端接收到消息后进行解密。如上这样:消息中转服务器就无法获取我们的消息内容了。事实上:这确实是端到端加密中消息收发的简化版解决方案,只是我们在实际应用中要更加复杂,效果也更加安全。4.2 如何安全地传递用于消息加解密的密钥对于端到端加密,我们需要先解决的前置安全问题是:如何安全地传递用于消息加解密的密钥。答案是:用非对称加密的方式传输密钥(与 SSL / TLS 中安全交换密钥的方式类似)。非对称加密传输对称加密密钥的算法,一般归结两种方式:1)一种是以 RSA、ECC 等为主(公钥加密私钥解密的方式,本质是加解密的算法);2)另一种是以 DH、ECDH 为主的生成共享密钥的方式(本质是通过计算协商一个共同的密钥而不是加解密算法)。实际上:大部分即时通信软件中的端到端加密都采用生成共享密钥的方式来传输会话密钥。这是为什么呢?这就涉及到 DH 算法(即 Diffie-Hellman 密钥交换算法),关于DH算法的资料,有兴趣可以详读《Diffie-Hellman密钥协商算法》,限于篇幅,这里不专门讨论。Diffie-Hellman 密钥交换算法的安全性依赖于这样一个事实:虽然计算以一个素数为模的指数相对容易,但计算离散对数却很困难。对于大的素数,计算出离散对数几乎是不可能的。这里简要描述一下 DH 共享密钥的过程如下:(其中“密钥 S”即为最终的共享密钥)4.3 采用共享密钥的原因端到端加密采用共享密钥的方式来传输会话密钥有如下几个原因:1)如果采用 RSA、ECC 等公钥加密私钥解密的方式传输密钥,需要在创建会话时生成临时密钥,并通过对方公钥加密后传输到接收端。这就需要完全保证消息的可靠性,如果该消息在任何一个环节中丢失或损坏,则后续通信都无法进行。或者,需要采用更为可靠的传输方案,通常做法为需要接收端在线,通过各种确认来保证这个可靠性。而采用共享密钥的方式则只需要知道对方的公钥,就可以完成生成共享密钥,并不一定需要对方在线。2)如果已经生成的临时对称密钥丢失,则需要重新协商密钥。而采用共享密钥的方式则只需要知道对方的公钥,就可以完成生成共享密钥,不需要重新协商。3)采用公钥加密私钥解密的方式至少会比生成共享密钥方式多一次交换对称密钥的通信过程。4)密钥协商方式,不仅仅可以完成两个点之间的密钥协商,还可以延展到多人之间的共同协商出相同的密钥,这样能满足多人群体沟通的需求。5、端到端加密的初步实践方案我们结合对于 DH 算法(即 Diffie-Hellman 密钥交换算法)这种共享密钥方式的认知(即公钥可随意公开),先设计一个简单的端到端消息加密的过程。这个过程的逻辑流程如下:1)在客户端 APP 首次安装时,基于服务器公开的两个全局的参数,生成自己的 DH 公钥和私钥;2)将自己的公钥上传证书服务器,证书服务器上保存用户标识与其公钥的关系。私钥则保存在客户端上;3)首次给对方发送消息或首次接收到对方消息时,便到证书服务器查询对方的公钥;4)根据对方公钥和自己的私钥计算出共享密钥;5)后续与对方所有的消息都基于这个密钥和相同的对称加解密算法进行加密解密操作。端到端消息加密过程示意:至此:我们完成了一个简单的端到端消息加密方案,在这个方案中我们引入了一个第三方的用于存储用户公钥的角色,这个角色的存在可以让任何一方都不用关心对方的在线状态,随时给对方发送加密过消息,而消息转发服务器无法解密消息。接下来,我们针对这个简单方案存在的各种安全隐患问题,进行逐步分析和优化。6、端到端加密实践方案的进一步优化和演进6.1 使用HMAC作为消息完整性认证算法在消息传输过程中,双方需要确认彼此消息的完整性,简单的做法就是将消息进行 Hash,得到的 Hash 值附加到消息后,随消息一起发送;对端接收后,同样进行 Hash,来验证消息是否被篡改。关键点在于不同数据得到的 Hash 值一定不同,其中带密钥的 Hash 值就是 MAC算法。另外,为了避免使用同样的 Hash 函数对相同数据进行操作总是得出同样的值,额外加入一个密钥,这样使用不同密钥就可以得出不同的 MAC。当然,这个密钥是两个对端都知道的。这样,我们就得到了基于加密 Hash 的消息完整性认证的算法——Hash-based MAC(简称HMAC)。基础知识1:什么是MAC算法?全称Message Authentication Code,即消息认证码(带密钥的Hash函数)。在密码学中,MAC是通信实体双方使用的一种验证机制,是保证消息数据完整性的一种工具。MAC算法的安全性依赖于Hash函数,故也称带密钥的Hash函数。消息认证码是基于密钥和消息摘要“hash”所获得的一个值,可用于数据源发认证和完整性校验。使用 MAC 验证消息完整性的具体过程是:1)假设通信双方 A 和 B 共享密钥 K,A用消息认证码算法将 K 和消息 M 计算出消息验证码 Mac,然后将 Mac 和 M 一起发送给 B;2)B 接收到 Mac 和 M 后,利用 M 和 K 计算出新的验证码 Mac*,若 Mac*和Mac 相等则验证成功,证明消息未被篡改。由于攻击者没有密钥 K,攻击者修改了消息内容后无法计算出相应的消息验证码,因此 B 就能够发现消息完整性遭到破坏。简而言之就是:1)发送者通过MAC算法计算出消息的MAC值,并和消息一起发给收信者;2)收信者用同样的MAC算法计算收到的消息的MAC值,并对比两者。下图是原理示意:基础知识2:什么是HMAC算法?HMAC是MAC算法中的一种,其基于加密HASH算法实现。任何加密HASH, 比如MD5、SHA256等,都可以用来实现HMAC算法,其相应的算法称为HMAC-MD5、HMAC-SHA256等。6.2 使用ECDH算法替换DH算法DH 算法是以离散对数的数学难题为基础的,随着计算机计算能力逐步增强,我们要不停地使用更大的数以增加破解难度,目前业界普遍认为至少需要使用 2048 位 DH 算法才具备更好的安全性。在此我们引入 ECDH 算法替换 DH 算法。ECDH 密钥协商算法是 ECC 算法和 DH 密钥交换原理结合使用。ECC 是建立在基于椭圆曲线的离散对数问题上的密码体制。在相同破解难度下,ECC 具有更小长度的密钥和更快的正向计算速度优势。我们系统上的 ECDH 可以直接采用目前公开的 sepc256kl 和 Curve25519 曲线,而无需服务再提供公开大数参数。6.3 提升前向安全性在消息传输过程中,如果协商好的密钥泄露了,就意味着所有信息都将暴露于风险之下。为了防止这种情况发生,我们需要每次加密使用的密钥都与上一次不同,且不可以反向推导得出之前的密钥。此处引入一个 Hash 算法:这个 Hash 算法可以通过输入一个密钥导出另外一个离散性更大的密钥,每次发送消息时都是用上次的消息密钥进行 Hash 运算得出本次密钥,由于 Hash 算法具有单向不可逆的特性,因此就无法通过本次的密钥推导之前的密钥。从感观上,这就像一个棘轮,棘轮就是一种特殊的齿轮,他只能往一个方向转下去,而不能往回转。我们先来感性认识一下棘轮:在技术上,做到"只能往一个方向转下去,而不能往回转",是达到前向安全的关键。这就保证了,如果某一轮的密钥被破解出来,但前面的密钥是无法计算出来的,也就是前面的消息无法被解密。6.4 同时保证前向安全和后向安全性出于极致的安全性要求,我们会同时考虑前向安全和后向安全。如何保证在某次通信中,被破解出来的密钥,不能破解出之前的消息,而且在一定周期内,这个破解出来的密钥将不会再起作用。介于此我们再引入另外一个棘轮来保证其向后的安全性。这就是大名鼎鼎的 Signal protocol 中的双棘轮算法。Signal protocol 是真正的端到端的通讯加密协议,号称是世界上最安全的通讯协议,任何第三方包括服务器都无法查看通讯内容。双棘轮算法包含一个 KDF 棘轮和一个 DH 棘轮。KDF 全称(Key derivation function) 密钥导出函数,用于从一个原始的密钥导出一个或多个密钥。本质上就是 Hash 函数,通常用来将短密码变成长密码。另外 KDF 需要加“盐”(salt),用于防彩虹表,出于 Hash 的特性,这个“盐”的长度至少要大于 Hash 结果长度。KDF (原密钥,盐) = 导出密钥KDF 棘轮就是运用 KDF 算法,设计出一种密钥不断变化的效果,流程如下:首先:将初始密钥使用 KDF 算法导出新的密钥,新密钥被切成两部分,前半部分作为下一次 KDF 计算的输入,后半部分作为消息密钥。每迭代一次(也可以说棘轮步进一次),就会生成新的消息密钥。由于 KDF 算法的单向性,通过这条消息的密钥无法倒推出上一条消息密钥,这就保证了密钥的前向安全。但是如果 KDF 中的盐被掌握,那么它就可以按照这种算法计算出以后所有的消息密钥。为了保证后向安全,就要设计一种方法,使每次迭代时引入的盐是随机的,从而保证每次的消息密钥是不可以向后推算的。由前面介绍的 DH 算法得知:两对密钥对可以通过 DH 协议生成一个安全的协商密钥,如果更换其中一个密钥对,新的协商密钥也会变化。根据这个方法:我们可以设计出一个安全更新盐的方法。我们在证书服务器增加一个临时公钥证书,这个临时证书是按照接收双方标识构建的临时公钥对,即每个人的每个单人会话都具备一个临时公钥。每进行一个消息轮回,就更新一次己方的临时公钥,同时根据另外一方的临时公钥和己方的私钥进行协商,并将协商出的密钥作为盐,使得 KDF 棘轮算法生成的消息密钥具有后向安全性。在初始时我们无法预测出每个人所有的新二人会话:那么我们就可以规定创建新的二人会话时,发起方首先生成一个新的临时 DH 公私钥对,并向服务器上传自己的临时 DH 公钥;其次发送方用接收方公布的长期公钥与自己的临时私钥协商出密钥作为消息加密的密钥,对消息进行加密;最后接收方首次接收到消息后用自己的长期公钥和发送方的临时私钥计算得出消息密钥,并在首次回复消息时生成临时公私钥,同时上传临时公钥。问题是:如果接收端不在线,而发送端每条消息都去更新己方的临时公钥证书,就会导致发出去的这些消息,在接收端上线并收取后无法被正常解密。为了解决这个问题,我们需要规定:只有在发出消息并得到对方回复后才更新临时证书,若对方不回复消息则不去更新临时证书。接收端能回复消息就表示其已经上线并接收完消息,这样就可以保证离线消息或者消息乱序也可以被对方正常解析。这种方法就是双棘轮算法中的另外一个 DH 棘轮。6.5 更安全的密钥交换协议—— X3DH对比最初的方案,为了满足消息的前向安全和后向安全,我们增加了双棘轮算法,在原基础方案上为每个人增加了一组会话级别临时 DH 密钥,每个人都拥有一个长期密钥和一组临时密钥。但是:由于长期密钥无法被更换,所以方案依然存在着安全隐患。因此:Signal protocol 设计了一种更为复杂和安全的 DH 密钥交换过程,称之为 X3DH(即 DH 协议的 3 倍扩展版)。在 X3DH 协议里,每个人都要创建 3 种密钥对,分别如下:1)身份密钥对(Identity Key Pair):一个长期的符合 DH 协议的密钥对,用户注册时创建,与用户身份绑定;2)已签名的预共享密钥(Signed Pre Key):一个中期的符合 DH 协议的密钥对,用户注册时创建,由身份密钥签名,并定期进行轮换,此密钥可能是为了保护身份密钥不被泄露;3)一次性预共享密钥(One-Time Pre Keys):一次性使用的 Curve25519 密钥对队列,安装时生成,不足时补充。所有人都要将这 3 种密钥对的公钥上传到服务器上,以便其他人发起会话时使用。假如 Alice 要给 Bob 发送消息,首先要和 Bob 确定消息密钥,流程大致如下:1)Alice 要创建一个临时密钥对(ephemeral key),我们设成 EPK-A,此密钥对是为了后面棘轮算法准备,在此处作用不大;2)Alice 从服务器获取 Bob 的三种密钥对的公钥:身份密钥对IPK-B、已签名的预共享密钥 SPK-B、一次性预共享密钥 OPK-B;3)Alice 开始使用 DH 协议计算协商密钥,要引入参数包括:自己创建的两个密钥对的私钥,以及 Bob 的三个公钥。然后用类似排列组合的方式,将自己的私钥与对方的公钥分别带入 DH 算法计算。DH1 = DH(IPK-A, SPK-B)DH2 = DH(EPK-A, IPK-B)DH3 = DH(EPK-A, SPK-B)DH4 = DH(IPK-A, OPK-B)如图所示:然后将计算得到的四个值,前后连接起来,就得到了初始密钥,如下:DH = DH1 || DH2 || DH3 || DH4注:“||”代表连接符,比如 456 || 123 = 456123但是 DH 这个密钥太长,不适合作为消息密钥,所以对这个初始密钥进行一次 KDF 计算,以衍生出固定长度的消息密钥 S:S = KDF(DH1 || DH2 || DH3 || DH4)这一步,Alice 终于计算出了消息密钥 S。于是:1)Alice 使用消息密钥 S 对消息进行加密,连同自己的身份公钥 IPK-A 和临时公钥 EPK-A 一同发给 Bob;2)Bob 收到 Alice 的信息后,取出 Alice 的 2 个公钥,连同自己的密钥,使用与 Alice 相同的算法计算消息密钥 S;3)Bob 和 Alice 使用消息密钥进行加密通讯。由上可知:X3DH 实际是复杂版的 DH 协议。至此:我们简单介绍了 Signal Protocol 中最为核心的 X3DH 协议与双棘轮算法,基本上可以满足前向安全和后向安全。当然,真实的处理过程会更为复杂和安全。7、IM群聊的端到端加密方案在即时通讯场景中,除了二人之间的聊天以外,还有一个重要的场景就是群聊,那么群聊时的多人消息如何做端到端加密呢?我们再次回到 DH 密钥协商算法上的推导过程:显然,多方情况下依然可以继续使用 DH 密钥协商算法,这就是群聊中端到端加密的基础。而 Signal Protocol 在群组聊天中的设计与二人聊天又有所不同,由于群聊的保密性要求相对低一些,只采用了 KDF 链棘轮+公钥签名来进行加密通讯以保障加密的前向安全。群组聊天的加解密通讯流程如下:1)每个群组成员都要首先生成随机 32 字节的 KDF 链密钥(Chain Key),用于生成消息密钥,以保障消息密钥的前向安全性,同时还要生成一个随机 Curve25519 签名密钥对,用于消息签名;2)每个群组成员用向其它成员单独加密发送链密钥(Chain Key)和签名公钥。此时每一个成员都拥有群内所有成员的链密钥和签名公钥;3)当一名成员发送消息时,首先用 KDF 链棘轮算法生成的消息密钥加密消息,然后使用私钥签名,再将消息发给服务器,由服务器发送给其它成员;4)其它成员收到加密消息后,首先使用发送人的签名公钥验证,验证成功后,使用相应的链密钥生成消息密钥,并用消息密钥解密;5)当群组成员离开时,所有的群组成员都清除自己链密钥和签名公钥并重新生成,再次单独发给每一位成员。这样操作,离开的成员就无法查看群组内的消息了。由上可知:一个人在不同的群组里,会生成不同的链密钥和签名密钥对,以保障群组之间的隔离。在每个群组中,每个成员还要存储其它成员的 KDF 链和签名公钥,如果群组成员过多,加解密运算量非常大,会影响发送和接收速度,同时密钥管理数据库也会非常大,读取效率也会降低。所以:群组聊天使用 Signal Protocol 协议,群人数不宜太多。8、端到端加密方案的补充说明上面我们介绍了即时通信中二人聊天和群组聊天的端到端加密全部过程。但是正常情况下端到端消息加密只是加密消息的实际负载部分(即只加密消息“体”部分),而消息的控制层则不会被加密,因为消息转发服务器需要根据控制信息进行消息转发或路由(否则肯定大大影响IM底层的路由和通信效率,因为需要反复加密解密)。为了防止消息被定向分析(分析用户什么时间向谁发送了消息,或接收了谁的消息),我们依然需要对整体即时通信的长连接链路进行加密保护(这说的就是上篇文章里的通信连接层加密技术了),防止信息被中间网络设备截获并分析。而且为了防止密钥服务器被中间人攻击,也需要开启链路加密保护。9、参考资料[1] 移动端安全通信的利器——端到端加密(E2EE)技术详解[2] 简述实时音视频聊天中端到端加密(E2EE)的工作原理[3] HASH、MAC、HMAC学习[4] 一文了解加解密、哈希函数、MAC、数字签名、证书、CA等[5] 双棘轮算法:端对端加密安全协议,原理以及流程详解[6] Signal protocol 开源协议理解[7] X25519(Curve25519)椭圆曲线参考资料(本文已同步发布于:http://www.52im.net/thread-4026-1-1.html)
本文由融云技术团队分享,原题“互联网通信安全之端到端加密技术”,内容有修订和改动。1、引言随着移动互联网的普及,IM即时通讯类应用几乎替代了传统运营商的电话、短信等功能。得益于即时通讯技术的实时性优势,使得人与人之间的沟通和交流突破了空间、时间等等限制,让信息的传递变的无处不在。但互联网为我们的生活带来极大便利的同时,用户的隐私和通信安全问题也随之而来。对于IM应用开发者来说,信息沟通的开放性也意味着风险性,用户与网络和移动设备的高度依赖,也为不法之徒提供了可乘之机。因此,提升即时通讯应用的安全性尤其重要。本篇文章将围绕IM通信连接层的安全问题及实现方案,聚焦IM网络“链路安全”,希望能带给你启发。学习交流:- 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》- 开源IM框架源码:https://github.com/JackJiang2011/MobileIMSDK(备用地址点此)(本文已同步发布于:http://www.52im.net/thread-4015-1-1.html)2、系列文章本文是IM通讯安全知识系列文章中的第10篇,此系列总目录如下:《即时通讯安全篇(一):正确地理解和使用Android端加密算法》《即时通讯安全篇(二):探讨组合加密算法在IM中的应用》《即时通讯安全篇(三):常用加解密算法与通讯安全讲解》《即时通讯安全篇(四):实例分析Android中密钥硬编码的风险》《即时通讯安全篇(五):对称加密技术在Android平台上的应用实践》《即时通讯安全篇(六):非对称加密技术的原理与应用实践》《即时通讯安全篇(七):如果这样来理解HTTPS原理,一篇就够了》《即时通讯安全篇(八):你知道,HTTPS用的是对称加密还是非对称加密?》《即时通讯安全篇(九):为什么要用HTTPS?深入浅出,探密短连接的安全性》《即时通讯安全篇(十):IM聊天系统安全手段之通信连接层加密技术》(* 本文)《即时通讯安全篇(十一):IM聊天系统安全手段之传输内容端到端加密技术》(稍后发布...)3、即时通讯面临的安全问题1)窃取内容:如果在整个即时通讯的通信过程中,其数据内容是未加密或弱加密的,那么其信息被截获后就可以直接被读取出来。那么,这就会导致用户数据(包括个人隐私数据)的泄露,甚至可能危害用户的财产安全(比如微信这类IM中,红包、钱包都会涉及财产安全)。如果在办公场景下,被窃取的还可能是公司商业机密,那势必将会造成更大的经济损失。2)篡改内容:如果通信内容被截获后,对其进行修改再发送,会破坏信息的正确性和完整性(此消息已非彼消息)。3)伪造内容:如果用户通信凭证(比如token)被窃取或在通信过程中穿插其他信息,就可能为冒用用户身份骗取与之通信者的信任创造可能,埋下更大的安全隐患。4)传播不法内容:基于即时通讯系统的消息推送能力,不法分子除了可能传播涉黄、涉赌、暴恐或危害国家安全的信息外,还可能传播计算机木马病毒等,可能带来的危害范围将进一步扩大。4、常用的互联网攻击手段网络通信过程中常见的攻击手段:1)移植木马:过在终端移植木马,截获或篡改信息。2)伪造应用:通过伪造 APP 或在 APP 中添加后门等方式,使终端用户误以为是正常应用进行使用,从而达到其不法目的。3)网络抓包:通过在网络设备上进行抓包,获取用户通信内容。4)中间人攻击:通过劫持 DNS 等手段,使用户通信连接经过攻击者的设备,从而达到窃取、篡改等目的。5)漏洞挖掘:服务端或终端除了自有的程序以外还包含了各种三方组件或中间件,通过挖掘其上的漏洞,达到不法目的。从上图和手段可知,聊天信息从应用经过网络到达服务端,这期间的任何一个环节都有可能被人利用。所以,在“危机四伏”的互联网络通信中,“安全”必须重视。5、密码学在即时通讯系统中的应用5.1 基本常识针对前述的安全问题和攻击手段,将密码学应用在即时通讯系统连接上,对通信数据进行加密就变得尤为重要。密码学解决信息安全的三要素(CIA)即:1)机密性(Confidentiality):保证信息不泄露给未经授权的用户;2)完整性(Integrity):保证信息从真实的发信者传送到真实的收信者手中,传送过程中没有被非法用户添加、删除、替换等;3)可用性(Availability):保证授权用户能对数据进行及时可靠的访问。以上表述,好像有点绕口,我们换个通俗一点的表述。。。密码学在网络通信中的三大作用就是:1)加密:防止坏人获取你的数据;2)认证:防止坏人修改了你的数据而你却并没有发现;3)鉴权:防止坏人假冒你的身份。除 CIA 外,还有一些属性也是要求达到的,如可控性(Controllability)和不可否认性(Non-Repudiation)。5.2 在即时通讯中的应用作为即时通讯中的关键组成,IM即时通讯系统为了实现消息的快速、实时送达,一般需要客户端与服务器端建立一条socket长连接,用以快速地将消息送达到客户端。通常即时通讯客户端会以 TCP 或 UDP 的方式与服务器建立连接,同时某些场景下也会使用 HTTP 的方式从服务器获取或提交一些信息。整个过程中所有的数据都需进行加密处理,简单的数据加密和解密过程可以归纳为:发送方输入明文 -> 加密 -> 生成密文 -> 传输密文 -> 接收方解密 -> 得到明文。这其中,会涉及:1)对称加密算法(详见《对称加密技术在Android平台上的应用实践》)2)非对称加密算法(详见《非对称加密技术的原理与应用实践》);3)信息摘要算法(详见《常用加解密算法与通讯安全讲解》)。这其中,我国也有一套自有的密码算法(国密算法):国密算法,即国家商用密码算法,是由国家密码管理局认定和公布的密码算法标准及其应用规范,其中部分密码算法已经成为国际标准。如 SM 商用系列密码:对称加密算法 SM4、非对称加密算法 SM2、信息摘要算法 SM3。6、通信连接层的会话加密对于连接层面(链路层面)面的加密,应最先考虑的是基于 SSL/TLS 协议进行链路加密(比如微信的作法:《微信新一代通信安全解决方案:基于TLS1.3的MMTLS详解》),这是现代网络通信安全的基石。很多人认为 SSL/TLS 协议是附加在 HTTP 协议上的,是 HTTPS 的一部分(详见《为什么要用HTTPS?深入浅出,探密短连接的安全性》)。其实这种理解不完全正确,SSL/TLS 是独立于应用层协议的,高层协议可以透明地分布在 SSL/TLS 协议上面。因此基于socket长连接的IM即时消息通讯协议也可以构建在 SSL/TLS 协议上面。SSL/TLS 是独立于应用层协议:SSL/TLS 可以被简单地归纳为:利用基于公私钥体系的非对称加密算法,传输对称加解密算法的密钥,并将后续通讯的数据包基于双方相同的对称加解密算法和密钥进行加密并传输,从而达到保证数据安全通讯的目的。非对称加密算法里面的公钥和私钥在数学上是相关的,这样才能用一个加密、用另一个解密。不过,尽管是相关的,但以现有的数学算法,是没有办法从一个密钥算出另一个密钥。另外需要着重强调的是:在系统中不要使用自签证书,而要使用具备 CA 认证的证书,这样可以有效的防止中间人攻击。7、基于SSL/TLS的通信连接层如何实现会话的快速恢复7.1 概述客户端和服务器端建立 SSL/TLS 握手时,需要完成很多步骤:密钥协商出会话密钥、数字签名身份验证、消息验证码 MAC 等。整个握手阶段比较耗时的是密钥协商,需要密集的 CPU 处理。当客户端和服务器断开了本次会话连接,那么它们之前连接时协商好的会话密钥就消失了。在下一次客户端连接服务器时,便要进行一次新的完整的握手阶段。这似乎没什么问题,但是当系统中某一时间段里有大量的连接请求提交时,就会占用大量服务器资源,导致网络延迟增加。为了解决上面的问题,TLS/SSL 协议中提供了会话恢复的方式,允许客户端和服务器端在某次关闭连接后,下一次客户端访问时恢复上一次的会话连接。会话恢复有两种:1)一种是基于 Session ID 的恢复;2)一种是使用 Session Ticket TLS 扩展。下面来看看两种方式的优劣。7.2 基于Session ID的SSL/TLS长连接会话恢复一次完整的握手阶段结束后,客户端和服务器端都保存有这个 Session ID。在本次会话关闭,下一次再次连接时:客户端在 Client Hello 子消息中附带这个 Session ID 值,服务器端接收到请求后,将 Session ID 与自己在 Server Cache 中保存的 Session ID 进行匹配。如果匹配成功:服务器端就会恢复上一次的 TLS 连接,使用之前协商过的密钥,不重新进行密钥协商,服务器收到带 Session ID 的 Client Hello 且匹配成功后,直接发送 ChangeCipherSpec 子协议,告诉 TLS 记录层将连接状态切换成可读和可写,就完成会话的恢复。基于Session ID 会话恢复原理:虽然使用 Session ID 进行会话恢复可以减少耗时的步骤,但由于 Session ID 主要保存在服务器 Server Cache 中,若再次连接请求时由于负载均衡设定将请求重定位到了其他服务器上,此时新的服务器 Server Cache 中没有缓存与客户端匹配的 Session ID,会导致会话无法恢复无法进行,因此不建议选用 Session ID 方式进行会话恢复。7.3 基于SessionTicket的SSL/TLS长连接会话恢复一次完整的握手过程后,服务器端将本次的会话数据(会话标识符、证书、密码套件和主密钥等)进行加密,加密后生成一个 ticket ,并将 ticket 通过 NewSessionTicket 子消息发送给客户端,由客户端来保存,下一次连接时客户端就将 ticket 一起发送给服务器端,待服务器端解密校验无误后,就可以恢复上一次会话。基于SessionTicket 会话恢复原理:由于加解密都是在服务端闭环进行,多服务只需要共享密钥就可以完成此过程,相较于 Session ID 的方式,可以不依赖 Server Cache,因此 SessionTicket 会话恢复方式更有利于大型分布式系统使用。8、本文小结本文分享了IM即时通讯的通信连接层安全知识和加密技术等。并着重强调了两方面内容。首先,在IM即时通讯系统中使用具备 CA 认证的 SSL/TLS 证书可以保证传输安全,防止传输过程被监听、防止数据被窃取,确认连接的真实性。其次,利用 SessionTicket 快速地进行会话恢复可以提高整体系统性能,降低连接延时。本文的下篇《即时通讯安全篇(十一):IM聊天系统安全手段之传输内容端到端加密技术》,将继续分享基于IM传输内容的端到端加密技术,敬请关注。9、参考资料[1] TCP/IP详解 - 第11章·UDP:用户数据报协议[2] TCP/IP详解 - 第17章·TCP:传输控制协议[3] 网络编程懒人入门(三):快速理解TCP协议一篇就够[4] 网络编程懒人入门(四):快速理解TCP和UDP的差异[5] 零基础IM开发入门(二):什么是IM系统的实时性?[6] 对称加密技术在Android平台上的应用实践[7] 非对称加密技术的原理与应用实践[8] 常用加解密算法与通讯安全讲解[9]微信新一代通信安全解决方案:基于TLS1.3的MMTLS详解[10] 为什么要用HTTPS?深入浅出,探密短连接的安全性[11] 探讨组合加密算法在IM中的应用(本文已同步发布于:http://www.52im.net/thread-4015-1-1.html)
本文引用自InfoQ社区“5亿用户如何高效沟通?钉钉首次对外揭秘即时消息服务DTIM”一文,作者陈万红等、策划褚杏娟,有修订和改动。一、引言本文是国内企业IM的事实王者钉钉首次对外深度解密其即时消息服务(即DingTalk IM,简称DTIM)的技术设计实践。本篇文章内容将从模型设计原理到具体的技术架构、最底层的存储模型到跨地域的单元化等,全方位展现了 DTIM 在实际生产应用中所遇到的各种技术挑战及相应的解决方案,希望借本文内容的分享能为国内企业级IM的开发带来思考和启发。学习交流:- 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》- 开源IM框架源码:https://github.com/JackJiang2011/MobileIMSDK(备用地址点此)(本文已同步发布于:http://www.52im.net/thread-4012-1-1.html)二、系列文章本文是系列文章的第8篇,总目录如下:《阿里IM技术分享(一):企业级IM王者——钉钉在后端架构上的过人之处》《阿里IM技术分享(二):闲鱼IM基于Flutter的移动端跨端改造实践》《阿里IM技术分享(三):闲鱼亿级IM消息系统的架构演进之路》《阿里IM技术分享(四):闲鱼亿级IM消息系统的可靠投递优化实践》《阿里IM技术分享(五):闲鱼亿级IM消息系统的及时性优化实践》《阿里IM技术分享(六):闲鱼亿级IM消息系统的离线推送到达率优化》《阿里IM技术分享(七):闲鱼IM的在线、离线聊天数据同步机制优化实践》《阿里IM技术分享(八):深度解密钉钉即时消息服务DTIM的技术设计》(* 本文)相关文章:现代IM系统中聊天消息的同步和存储方案探讨钉钉——基于IM技术的新一代企业OA平台的技术挑战(视频+PPT)企业微信的IM架构设计揭秘:消息模型、万人群、已读回执、消息撤回等三、钉钉的技术挑战钉钉已经有 2100 万 + 组织、5 亿 + 注册用户在使用。DTIM 为钉钉用户提供即时消息服务,用于组织内外的沟通,这些组织包括公司、政府、学校等,规模从几人到百万人不等。DTIM 有着丰富的功能,单聊、各种类型的群聊、消息已读、文字表情、多端同步、动态卡片、专属安全和存储等等。同时:钉钉内部很多业务模块,比如文档、钉闪会、Teambition、音视频、考勤、审批等,每个业务都在使用 DTIM,用于实现业务流程通知、运营消息推送、业务信令下发等。每个业务模块对于 DTIM 调用的流量峰值模型各有差别,对可用性要求也不尽相同。DTIM 需要能够面对这些复杂的场景,保持良好的可用性和体验,同时兼顾性能与成本。通用的即时消息系统对消息发送的成功率、时延、到达率有很高的要求,企业 IM 由于 ToB 的特性,在数据安全可靠、系统可用性、多终端体验、开放定制等多个方面有着极致的要求。构建稳定高效的企业 IM 服务,DTIM 主要面临的挑战是:1)企业 IM 极致的体验要求对于系统架构设计的挑战:比如数据长期保存可漫游、多端数据同步、动态消息等带来的数据存储效率和成本压力,多端数据同步带来的一致性问题等;2)极限场景冲击、依赖系统错误带来的可用性问题:比如超大群消息,突发疫情带来的线上办公和线上教学高并发流量,系统需要能够应对流量的冲击,保障高可用;同时在中间件普遍可用性不到 99.99% 的时候,DTIM 服务需要保障核心功能的 99.995% 的可用性;3)不断扩大的业务规模,对于系统部署架构的挑战:比如持续增长的用户规模,突发事件如席卷全球的疫情,单地域架构已经无法满足业务发展的要求。DTIM 在系统设计上:1)为了实现消息收发体验、性能和成本的平衡,设计了高效的读写扩散模型和同步服务,以及定制化的 NoSQL 存储;2)通过对 DTIM 服务流量的分析,对于大群消息、单账号大量的消息热点以及消息更新热点的场景进行了合并、削峰填谷等处理;3)核心链路的应用中间件的依赖做容灾处理,实现了单一中间件失败不影响核心消息收发,保障基础的用户体验。在消息存储过程中,一旦出现存储系统写入异常,系统会回旋缓冲重做,并且在服务恢复时,数据能主动向端上同步。随着用户数不断增长,单一地域已无法承载 DTIM 的容量和容灾需求,DTIM 实现了异地多单元的云原生的弹性架构。在分层上遵从的原则为重云轻端:业务数据计算、存储、同步等复杂操作尽量后移到云端处理,客户端只做终态数据的接收、展示,通过降低客户端业务实现的复杂度,最大化地提升客户端迭代速度,让端上开发可以专注于提升用户的交互体验,所有的功能需求和系统架构都围绕着该原则做设计和扩展。以下章节我们将对 DTIM 做更加详细的介绍。四、模型设计4.1、DTIM系统架构低延迟、高触达、高可用一直是 DTIM 设计的第一原则,依据这个原则在架构上 DTIM 将系统拆分为三个服务做能力的承载。三个服务分别是:1)消息服务:负责 IM 核心消息模型和开放 API,IM 基础能力包括消息发送、单聊关系维护、群组元信息管理、历史消息拉取、已读状态通知、IM 数据存储以及跨地域的流量转发;2)同步服务:负责用户消息数据以及状态数据的端到端同步,通过客户端到服务端长连接通道做实时的数据交互,当钉钉各类设备在线时 IM 及上游各业务通过同步服务做多端的数据同步,保障各端数据和体验一致;3)通知服务:负责用户第三方通道维护以及通知功能,当钉钉的自建通道无法将数据同步到端上时,通过三方提供的通知和透传能力做消息推送,保障钉钉消息的及时性和有效性。同步服务和通知服务除了服务于消息服务,也面向其他钉钉业务比如音视频、直播、Ding、文档等多端 (多设备) 数据同步。图1:DTIM架构图 ▼上图展示了 DTIM 系统架构,接下来详细介绍消息收发链路。4.2、消息收发链路图2:DTIM消息处理架构 ▼1)消息发送:消息发送接口由 Receiver 提供,钉钉统一接入层将用户从客户端发送的消息请求转发到 Receiver 模块,Receiver 校验消息的合法性(文字图片等安全审核、群禁言功能是否开启或者是否触发会话消息限流规则等)以及成员关系的有效性(单聊校验二者聊天、群聊校验发送者在群聊成员列表中),校验通过后为该消息生成一个全局唯一的 MessageId 随消息体以及接收者列表打包成消息数据包投递给异步队列,由下游 Processor 处理。消息投递成功之后,Receiver 返回消息发送成功的回执给客户端。2)消息处理 :Processor 消费到 IM 发送事件首先做按接收者的地域分布(DTIM 支持跨域部署, Geography,Geo)做消息事件分流,将本域用户的消息做本地存储入库(消息体、接收者维度、已读状态、个人会话列表红点更新),最后将消息体以及本域接收者列表打包为 IM 同步事件通过异步队列转发给同步服务。3)消息接收 :同步服务按接收者维度写入各自的同步队列,同时查取当前用户设备在线状态,当用户在线时捞取队列中未同步的消息,通过接入层长连接推送到各端。当用户离线时,打包消息数据以及离线用户状态列表为 IM 通知事件,转发给通知服务的 PNS 模块,PNS 查询离线设备做三方厂商通道推送,至此一条消息的推送流程结束。4.3、存储模型设计了解 IM 服务最快的途径就是掌握它的存储模型。业界主流 IM 服务对于消息、会话、会话与消息的组织关系虽然不尽相同,但是归纳起来主要是两种形式:写扩散读聚合、读扩散写聚合。所谓读写扩散其实是定义消息在群组会话中的存储形式。如下图所示。图3:读模式和写模式 ▼如上图所示:1)读扩散的场景:消息归属于会话,对应到存储中相当于有张 conversation_message 的表存储着该会话产生的所有消息 (cid->msgid->message,cid 会话 ID、msgid 消息 ID、message 消息),这样实现的好处是消息入库效率高,只存储会话与消息的绑定关系即可;2)写扩散的场景:会话产生的消息投递到类似于个人邮件的收件箱,即 message_inbox 表,存储个人的所有消息(uid->msgid->message, uid 用户 ID、msgid 消息 ID、message 消息),基于这种实现,会话中的每条消息面向不同的接收者可以呈现出不同状态。DTIM 对 IM 消息的及时性、前后端存储状态一致性要求异常严格,特别对于历史消息漫游的诉求十分强烈,当前业界 IM 产品对于消息长时间存储和客户端历史消息多端漫游都做得不尽如人意,主要是存储成本过高。因此在产品体验与投入成本之间需要找到一个平衡点。采用读扩散:在个性化的消息扩展及实现层面有很大的约束。采用写扩散带来的问题也很明显:一个群成员为 N 的会话一旦产生消息就会扩散 N 条消息记录,如果在消息发送和扩散量较少的场景,这样的实现相比于读扩散落地更为简单,存储成本也不是问题。但是 DTIM 会话活跃度超高,一条消息的平均扩散比可以达到 1:30,超大群又是企业 IM 最核心的沟通场景,如果采用完全写扩散所带来存储成本问题势必制约钉钉业务发展。所以,在 DTIM 的存储实现上,钉钉采取了混合的方案,将读扩散和写扩散针对不同场景做了适配,最终在用户视角系统会做统一合并,如下图所示。图4:DTIM 读写混合模式 ▼作为读扩散、写扩散方案的混合形式存在,用户维度的消息分别从 conversation_message 和 message_inbox 表中获取,在应用侧按消息发送时间做排序合并,conversation_message 表中记录了该会话面向所有群成员接收的普通消息 N(Normal),而 message_inbox 表在以下两种场景下被写入:1)第一种是:定向消息 P(Private,私有消息),群会话中发送的消息指定了接收者范围,那么会直接写入到接收者的 message_inbox 表中,比如红包的领取状态消息只能被红包发送者可见,那么这种消息即被定义为定向消息。2)第二种是:归属于会话消息的状态转换 NP(Normal to Private,普通消息转私有消息),当会话消息通过某种行为使得某些消息接收者的消息状态发生转换时,该状态会写入到 message_inbox 表中,比如用户在接收侧删除了消息,那么消息的删除状态会写入到 message_inbox 中,在用户拉取时会通过 message_inbox 的删除状态将 conversation_message 中的消息做覆盖,最终用户拉取不到自己已删除的消息。当用户在客户端发起某个会话的历史消息漫游请求时,服务端根据用户上传的消息截止时间(message_create_time)分别从 conversation_message 表和 message_inbox 表拉取消息列表,在应用层做状态的合并,最终返回给用户合并之后的数据,N、P、NP 三种类型消息在消息个性化处理和存储成本之间取得了很好的平衡。4.4、同步模型设计4.4.1 推送模型用户在会话中发出的消息和消息状态变更等事件是如何同步到端上呢?业界关于消息的同步模型的实现方案大致有三种:1)客户端拉取方案;2)服务端推送方案;3)服务端推送位点之后客户端拉取的推拉结合方案。三种方案各有优劣,在此简短总结:1)首先:客户端拉取方案的优点是该方案实施简单、研发成本低,是传统的 B/S 架构。劣势是效率低下,拉取间隔控制权在客户端,对于 IM 这种实时的场景,很难设置一个有效的拉取间隔,间隔太短对服务端压力大,间隔太长时效性差;2)其次:服务端主动推送方案的优点是低延迟、能做到实时,最重要的主动权在服务端。劣势相对拉取方案,如何协调服务端和客户端的处理能力存在问题;3)最后:推拉结合这个方案整合了拉和推的优点,但是方案更复杂,同时会比推的方案多一次 RTT,特别是在移动网络的场景下,不得不面临功耗和推送成功率的问题。DTIM 相对传统 toC 的场景,有较明显的区别:1)第一是对实时性的要求:在企业服务中,比如员工聊天消息、各种系统报警,又比如音视频中的共享画板,无不要求实时事件同步,因此需要一种低延时的同步方案。2)第二是弱网接入的能力:在 DTIM 服务的对象中,上千万的企业组织涉及各行各业,从大城市 5G 的高速到偏远的山区弱网,都需要 DTIM 的消息能发送、能触达。对于复杂的网络环境,需要服务端能判断接入环境,并依据不同的环境条件调整同步数据的策略。3)第三是功耗可控成本可控:在 DTIM 的企业场景中,消息收发频率比传统的 IM 多出一个数量级,在这么大的消息收发场景怎么保障 DTIM 的功耗可控,特别是移动端的功耗可控,是 DTIM 必须面对的问题。在这种要求下,就需要 DTIM 尽量降低 IO 次数,并基于不同的消息优先级进行合并同步,既能要保障实时性不被破坏,又要做到低功耗。从以上三点可知,服务端主动推送的模型更适合 DTIM 场景:1)首先可以做到极低的延时,保障推送耗时在毫秒级别;2)其次是服务端能通过用户接入信息判断用户接入环境好坏,进行对应的分包优化,保障弱网链路下的成功率;3)最后是主动推送相对于推拉结合来说,可以降低一次 IO,对 DTIM 这种每分钟过亿消息服务来说,能极大的降低设备功耗,同时配合消息优先级合并包的优化,进一步降低端的功耗。虽说主动推送有诸多优势,但是客户端会离线,甚至客户端处理速度无法跟上服务端的速度,必然导致消息堆积。DTIM 为了协调服务端和客户端处理能力不一致的问题,支持 Rebase 的能力,当服务端消息堆积的条数达到一定阈值时触发 Rebase,客户端会从 DTIM 拉取最新的消息,同时服务端跳过这部分消息从最新的位点开始推送消息。DTIM 称这个同步模型为推优先模型(Preferentially-Push Model,PPM)。在基于 PPM 的推送方案下,为了保障消息的可靠达到,DTIM 还做一系列优化。这些优化具体是:1)支持消息可重入:服务端可能会针对某条消息做重复推送,客户端需要根据 msgId 做去重处理,避免端上消息重复展示。2)支持消息排序:服务端推送消息特别是群比较活跃的场景,某条消息由于推送链路或者端侧网络抖动,推送失败,而新的消息正常推送到端侧,如果端上不做消息排序的话,消息列表就会发生乱序,所以服务端会为每条消息分配一个时间戳,客户端每次进入消息列表就是根据时间戳做排序再投递给 UI 层做消息展示。3)支持缺失数据回补:在某个极端情况下客户端群消息事件比群创建事件更早到达端上,此时端上没有群的基本信息消息也就无法展现,所以需要客户端主动向服务端拉取群信息同步到本地,再做消息的透出。4.4.2 多端数据一致性多端数据一致性问题一直是多端同步最核心的问题,单个用户可以同时在 PC、Pad 以及 Mobile 登录,消息、会话红点等状态需要在多端保持一致,并且用户更换设备情况下消息可以做全量的回溯。基于上面的业务诉求以及系统层面面临的诸多挑战,钉钉自研了同步服务来解决一致性问题。钉钉的同步服务的设计理念和原则如下:1)统一消息模型抽象,对于 DTIM 服务产生的新消息以及已读事件、会话增删改、多端红点清除等事件统一抽象为同步服务的事件;2)同步服务不感知事件的类型以及数据序列化方式。同步服务为每个用户的事件分配一个自增的 ID(注:这里非连续递增),确保消息可以根据 ID 做遍历的有序查询;3)统一同步队列,同步服务为每个用户分配了一个 FIFO 的队列存储,自增 id 和事件串行写入队列;当有事件推送时,服务端根据用户当前各端在线设备状态做增量变更,将增量事件推送到各端;4)根据设备和网络质量的不同可以做多种分包推送策略,确保消息可以有序、可靠、高效的发送给客户端。上面介绍了 DTIM 的存储模型以及同步模型的设计与思考:1)在存储优化中,存储会基于 DTIM 消息特点进行深度优化,并会对其中原理以及实现细节做深入分析与介绍;2)在同步机制中,会进一步介绍多端同步机制是如何保障消息必达以及各端消息一致性。五、消息存储设计DTIM 底层使用了表格存储作为消息系统的核心存储系统,表格存储是一个典型 LSM 存储架构,读写放大是此类系统的典型问题。LSM 系统通过 Major Compaction 来降低数据的 Level 高度,降低读取数据放大的影响。在 DTIM 的场景中,实时消息写入超过百万 TPS,系统需要划归非常多的计算资源用于 Compaction 处理,但是在线消息读取延迟毛刺依旧严重。在存储的性能分析中,我们发现如下几个特点:1)6% 的用户贡献了 50% 左右的消息量,20% 的用户贡献了 74% 的消息量。高频用户产生的消息远多于低频用户,在 Flush MemTable 时,高频用户消息占据了文件的绝大部分;2)对于高频的用户,由于其“高频”的原因,当消息进入 DTIM,系统发现用户设备在线(高概率在线),会立即推送消息,因此需要推送的消息大部分在内存的 MemTable 中;3)高频用户产生大量的消息,Compaction 耗费了系统大量的计算和 IO 资源;4)低频的用户消息通常散落在多个文件当中。从上面的四个表现来看,我们能得出如下结论:1)绝大部分 Compaction 是无效的计算和 IO,由于大量消息写入产生大量的文件,但是高频的用户消息其实已经下推给用户的设备,Compaction 对读加速效果大打折扣。反而会抢占计算资源,同时引起 IO 抖动;2)低频用户由于入库消息频率低,在 Flush 之后的文件中占比低;同时用户上线频率低,期间会累计较多的待接收的消息,那么当用户上线时,连续的历史消息高概率散落在多个文件当中,导致遍历读取消息毛刺,严重的有可能读取超时。为了解决此类问题,我们采用分而治之方法,将高频用户和低频用户的消息区别对待。我们借鉴了 WiscKey KV 分离技术的思想,就是将到达一定阈值的 Value 分离出来,降低这类消息在文件中的占比进而有效的降低写放大的问题。附:WiscKey KV原始论文附件下载: WiscKey:Separating Keys from Values in SSD-conscious Storage(52im.net).pdf (2.24 MB)但是 WiscKey KV 分离仅考虑单 Key 的情况,在 DITM 的场景下,Key 之间的大小差距不大,直接采用这种 KV 分离技术并不能解决以上问题。因此我们在原有 KV 分离的基础上,改进了 KV 分离,将相同前缀的多个 Key 聚合判断,如果多个 Key 的 Value 超过阈值,那么将这些 Key 的 Value 打包了 value-block 分离出去,写入到 value 文件。数据显示:上述方法能够在 Minor Compaction 阶段将 MemTable 中 70% 的消息放入 value 文件,大幅降低 key 文件大小,带来更低的写放大;同时,Major Compaction 可以更快降低 key 文件数,使读放大更低。高频用户发送消息更多,因此其数据行更容易被分离到 value 文件。读取时,高频用户一般把最近消息全部读出来,考虑到 DTIM 消息 ID 是递增生成,消息读取的 key 是连续的,同一个 value-block 内部的数据会被顺序读取,基于此,通过 IO 预取技术提前读取 value-block,可以进一步提高性能。对于低频用户,其发送消息较少,K-V 分离不生效,从而减少读取时候 value 文件带来的额外 IO。图5:KV分离和预读取 ▼六、多端同步机制设计6.1、概述DTIM 面向办公场景,和面向普通用户的产品在服务端到客户端的数据同步上最大的区别是消息量巨大、变更事件复杂、对多端同步有着强烈的诉求。DTIM 基于同步服务构建了一套完整同步流程。同步服务是一个服务端到客户端的数据同步服务,是一套统一的数据下行平台,支撑钉钉多个应用服务。图6:微服务架构 ▼同步服务是一套多端的数据同步服务,由两部分组成:1)部署于服务端的同步服务;2)由客户端 APP 集成的同步服务 SDK。工作原理类似于MQ消息队列:1)用户 ID 近似消息队列中的 Topic;2)用户设备近似消息队列中的 Consumer Group。每个用户设备作为一个消息队列消费者能够按需获得这个用户一份数据拷贝,从而实现了多端同步诉求。当业务需要同步一个变更数据到指定的用户或设备时:业务调用数据同步接口,服务端会将业务需要同步的数据持久化到存储系统中,然后当客户端在线的时候把数据推送给客户端。每一条数据入库时都会原子的生成一个按用户维度单调递增的位点,服务端会按照位点从小到大的顺序把每一条数据都推送至客户端。客户端应答接收成功后:更新推送数据最大的位点到位点管理存储中,下次推送从这个新的位点开始推送。同步服务 SDK 内部负责接收同步服务数据,接收后写入本地数据库,然后再把数据异步分发到客户端业务模块,业务模块处理成功后删除本地存储对应的内容。在上文章节中,已经初步介绍同步服务推送模型和多端一致性的考虑,本章主要是介绍 DTIM 是如何做存储设计、在多端同步如何实现数据一致性、最后再介绍服务端消息数据堆积技术方案 Rebase。* 推荐阅读:如果您对消息同步机制没有概念,可以先行阅读《浅谈移动端IM的多点登陆和消息漫游原理》。6.2、全量消息存储逻辑在同步服务中,采用以用户为中心,将所有要推送给此用户的消息汇聚在一起,并为每个消息分配唯一且递增的 PTS(即位点,英文术语Point To Sequence),服务端保存每个设备推送的位点。通过两个用户 Bob 和 Alice,来实际展示消息在存储系统中存储的逻辑形态。例如:Bob 给 Alice 发送了一个消息”Hi! Alice“,Alice 回复了 Bob 消息”Hi! Bob“。当 Bob 发送第一条消息给 Alice 时,接收方分别是 Bob 和 Alice,系统会在 Bob 和 Alice 的存储区域末尾分别添加一条消息,存储系统在入库成功时,会分别为这两行分配一个唯一且递增的位点(Bob 的位点是 10005,Alice 的位点是 23001);入库成功之后,触发推送。比如 Bob 的 PC 端上一次下推的位点是 10000,Alice 移动端的推送位点是 23000,在推送流程发起之后,会有两个推送任务,第一是 Bob 的推送任务,推送任务从上一次位点(10000) + 1 开始查询数据,将获取到 10005 位置的”Hi“消息,将此消息推送给 Bob 的设备,推送成功之后,存储推送位点(10005)。Alice 推送流程也是同理。Alice 收到 Bob 消息之后,Alice 回复 Bob,类似上面的流程,入库成功并分配位点(Bob 的位点是 10009,Alice 的位点是 23003)。图7:同步服务的存储设计 ▼6.3、消息多端同步逻辑多端同步是 DTIM 的典型特点,如何保持多端的数据及时触达和解决一致性是 DTIM 同步服务最大的挑战。上文中已经介绍了同步服务的事件存储模型,将需要推送的消息按照用户聚合。当用户有多个设备时,将设备的位点保存在位点管理系统之中,Key 是用户 + 设备 ID,Value 是上一次推送的位点。如果是设备第一次登录,位点默认为 0。由此可知:每个设备都有单独的位点,数据在服务端只有一份按照用户维度的数据,推送到客户端的消息是服务端对应位点下的快照,从而保障了每个端的数据都是一致的。比如:此时 Bob 登录了手机(该设备之前登录过钉钉),同步服务会获取到设备登录的事件,事件中有此设备上次接收数据的位点(比如 10000),同步服务会从 10000 + 1(位点)开始查询数据,获取到五条消息(10005~10017),将消息推送给此台手机并更新服务端位点。此时,Bob 手机和 PC 上的消息一致,当 Alice 再次发送消息时,同步服务会给 Bob 的两台设备推送消息,始终保持 Bob 两个设备之间消息数据的一致性。6.4、大量需同步离线消息的优化逻辑正如上文所述:我们采用了推优先的模型下推数据以保障事件的实时性,采用位点管理实现多端同步,但是实际情况却远比上面的情况复杂。最常见的问题:就是设备离线重新登录,期间该用户可能会累计大量未接收的消息数据,比如几万条。如果按照上面的方案,服务端在短时间会给客户端推送大量的消息,客户端 CPU 资源极有可能耗尽导致整个设备假死。其实对于 IM 这种场景来说:几天甚至几小时之前的数据,再推送给用户已经丧失即时消息的意义,反而会消耗客户移动设备的电量,得不偿失。又或者节假日大群中各种活动,都会有大量的消息产生。对于以上情况:同步服务提供 Rebase 的方案,当要推送的消息累计到一定阈值时,同步服务会向客户端发送 Rebase 事件,客户端收到事件之后,会从消息服务中获取到最新的消息(Lastmsg)。这样可以跳过中间大量的消息,当用户需要查看历史消息,可以基于 Lastmsg 向上回溯,即省电也能提升用户体验。还是以 Bob 为例:Bob 登录了 Pad 设备(一台全新的设备),同步服务收到 Pad 登录的事件,发现登录的位点为 0,查询从 0 开始到当前,已经累计 1 万条消息,累计量大于同步服务的阈值,同步服务发送 Rebase 事件给客户端,客户端从消息服务中获取到最新的一条消息“Tks !!!”,同时客户端从同步服务中获取最新的位点为 10017,并告诉同步服务后续从 10017 这个位置之后开始推送。当 Bob 进入到和 Alice 的会话之后,客户端只要从 Lastmsg 向上回溯几条历史消息填满聊天框即可。七、高可用设计7.1、概述DTIM 对外提供 99.995% 的可用性 SLA,有上百万的组织将钉钉作为自身数字化办公的基础设施,由于其极广的覆盖面,DTIM 些许抖动都会影响大量企业、机构、学校等组织,进而可能形成社会性事件。因此,DTIM 面临着极大的稳定性挑战。高可用是 DTIM 的核心能力。对于高可用,需要分两个维度来看,首先是服务自我防护,在遇到流量洪峰、黑客攻击、业务异常时,要有流量管控、容错等能力,保障服务在极端流量场景下还有基本服务的能力。其次是服务扩展能力,比如常见的计算资源的扩展、存储资源的扩展等,资源伴随流量增长和缩减,提供优质的服务能力并与成本取得较好的平衡。7.2、自我防护7.2.1 背景DTIM 经常会面临各种突发大流量,比如万人大群红包大战、早高峰打卡提醒、春节除夕红包等等都会在短时间内产生大量的聊天消息,给系统带来很大的冲击,基于此我们采用了多种措施。首先是流量管控,限流是保护系统最简单有效的方式。DTIM 服务通过各种维度的限流来保护自身以及下游,最重要的是保护下游的存储。在分布式系统中存储都是分片的,最容易出现的是单个分片的热点问题,DTIM 里面有两个维度的数据:用户、会话 (消息属于会话), 分片也是这两个维度,所以限流采用了会话、用户维度的限流,这样既可以保护下游存储单个分区,又可以一定程度上限制整体的流量。要控制系统的整体流量, 前面两个维度还不够,DTIM 还采用了客户端类型、应用 (服务端 IM 上游业务) 两个维度的限流,来避免整体的流量上涨对系统带来的冲击。其次是削峰平谷。限流简单有效,但是对用户的影响比较大。在 DTIM 服务中有很多消息对于实时性要求不高,比如运营推送等。对于这些场景中的消息可以充分利用消息系统异步性的特点,使用异步消息队列进行削峰平谷,这样一方面减少了对用户的影响,另一方面也减轻对下游系统的瞬时压力。DTIM 可以根据业务类型 (比如运营推送)、消息类型 (比如点赞消息) 等多种维度对消息进行分级,对于低优先级的消息保证在一定时间 (比如 1 个小时) 内处理完成。最后是热点优化。DTIM 服务中面临着各种热点问题,对于这些热点问题仅仅靠限流是不够的。比如通过钉钉小秘书给大量用户推送升级提醒,由于是一个账号与大量账号建立会话,因此会存在 conversation_inbox 的热点问题,如果通过限速来解决,会导致推送速度过慢、影响业务。对于这类问题需要从架构上来解决。总的来说,主要是两类问题:大账号和大群导致的热点问题。对于大账户问题,由于 conversation_inbox 采用用户维度做分区,会导致系统账号的请求都落到某个分区,从而导致热点。解决方案为做热点拆分,既将 conversation_inbox 数据合并到 conversation_member 中 (按照会话做分区),将用户维度的操作转换为会话维度的操作,这样就可以将系统账号的请求打散到所有分区上, 从而实现消除热点。对于大群问题,压力来自大量发消息、消息已读和贴表情互动,大量的接收者带来极大的扩散量。所以我们针对以上三个场景,分而治之。7.2.2 计算延迟与按需拉取对于消息发送,一般的消息对于群里面所有人都是一样的,所以可以采用读扩散的方式,即不管多大的群,发一条消息就存储一份。另一方面,由于每个人在每个会话上都有红点数和 Lastmsg, 一般情况下每次发消息都需要更新红点和 Lastmsg,但是在大群场景下会存在大量扩散,对系统带来巨大的压力。我们的解决方案为,对于大群的红点和 Lastmsg,在发消息时不更新,在拉首屏时实时算,由于拉首屏是低频操作且每个人只有一到两个大群,实时计算压力很小,这样高峰期可以减少 99.99 % 的存储操作, 从而解决了大群发消息对 DTIM 带来的冲击。7.2.3 请求合并在大群发消息的场景中,如果用户都在线,瞬时就会有大量已读请求,如果每个已读请求都处理,则会产生 M*N(M 消息条数,N 群成员数) 的扩散,这个扩散是十分恐怖的。DTIM 的解决方案是客户端将一个会话中的多次已读进行合并,一次性发送给服务端,服务端对于每条消息的已读请求进行合并处理,比如 1 分钟的所有请求合并为 1 次请求。在大群中,进行消息点赞时,短时间会对消息产生大量更新,再加上需要扩散到群里面的所有人,系统整体的扩散量十分巨大。我们发现,对于消息多次更新的场景,可以将一段时间里面多次更新合并,可以大大减少扩散量,从实际优化之后的数据来看,高峰期系统的扩散量同比减少 96%。即使完全做到以上几点,也很难提供当前承诺的 SLA,除了防止自身服务出现问题以外,还必须实现对依赖组件的容灾。我们整体采用了冗余异构存储和异步队列与 RPC 相结合的方案,当任意一类 DTIM 依赖的产品出现问题时, DTIM 都能正常工作,由于篇幅问题,此处不再展开。7.3、水平扩展能力对于服务的弹性扩展能力,也需要分两个维度来看:1)首先:服务内部的弹性扩展,比如计算资源的扩展、存储资源的扩展等,是我们通常构建弹性扩展能力关注的重点方向;2)其次:跨地域维度的扩展,服务能根据自身需要,在其他区域扩展一个服务集群,新的服务集群承接部分流量,在跨地域层面形成一个逻辑统一的分布式服务,这种分布式服务我们称之为单元化。7.3.1 弹性应用架构对于 DTIM 的扩展性,因为构建和生长于云上,在弹性扩展能力建设拥有了更多云的特点和选择。对于计算节点:应用具备横向扩展的能力,系统能在短时间之内感知流量突增进而进行快速扩容,对于上文提到的各种活动引起的流量上涨,能做到轻松应对。同时,系统支持定时扩容和缩容,在系统弹性能力和成本之间取得较好的平衡。对于存储:DTIM 底层选择了可以水平扩展的 Serverless 存储服务,存储服务内部基于读写流量的大小进行动态调度,应用上层完全无感知。对于服务自身的扩展性,我们还实施了不可变基础设施、应用无状态、去单点、松耦合、负载均衡等设计,使 DTIM 构建出了一套高效的弹性应用架构。7.3.2 弹性地域级扩展(单元化)在应用内部实现了高效弹性之后,伴随着业务流量的增长,单个地域已经无法满足 DTIM 亿级别 DUA 的弹性扩展的需求。由于 DTIM 特点,所有用户都可以在添加好友之后进行聊天,这就意味着不能简单换个地域搭建一套孤岛式的 DTIM。为了解决这种规模下的弹性能力,我们基于云上的地域架构,在一个 Geo 地域内,构建了一套异地多活、逻辑上是一体的弹性架构,我们称之为单元化。下图是 DTIM 的单元化架构。图8:DTIM单元化架构 ▼:对于单元化的弹性扩展架构,其中最核心的内容是流量动态调度、数据单地域的自封闭性和单元整体降级。7.3.3 弹性动态调度流量路由决定了数据流向,我们可以依托这个能力,将大群流量调度到新的单元来承接急速增长的业务流量,同时实现流量按照企业维度汇聚,提供就近部署能力,进而提供优质的 RT 服务。业界现在主流的单元化调度方案主要是基于用户维度的静态路由分段,这种方案算法简单可靠,但是很难实现动态路由调度,一旦用户路由固定,无法调整服务单元。比如:在 DTIM 的场景中,企业(用户)规模是随着时间增长、用户业务规模增长之后,单地域无法有效支撑多个大型企业用户时,传统静态方案很难将企业弹性扩展到其他单元,强行迁移会付出极高的运维代价。因此传统的路由方案不具备弹性调度能力。DTIM 提供一套全局一致性的高可用路由服务系统 (RoutingService)。服务中存储了用户会话所在单元,消息服务基于路由服务,将流量路由到不同的单元。应用更新路由数据之后,随之路由信息也发生变化。与此同时,路由服务发起数据订正事件,将会话的历史消息数据进行迁移,迁移完成之后正式切换路由。路由服务底层依赖存储的 GlobalTable 能力,路由信息更新完成之后,保障跨地域的一致性。7.3.4 弹性单元自封闭数据的单元自封闭是将 DTIM 最重要且规模最大的数据:“消息数据”的接收、处理、持久化、推送等过程封闭在当前单元中,解除了对其他单元依赖,进而能高效地扩充单元,实现跨地域级别高效弹性能力。要做到业务数据在单元内自封闭,最关键是要识别清楚要解决哪种数据的弹性扩展能力。在 DTIM 的场景下,用户 Profile、会话数据、消息数据都是 DTIM 最核心的资产,其中消息数据的规模远超其他数据,弹性扩展能力也是围绕消息数据的处理在建设。怎么将消息按照单元数据合理的划分成为单元自封闭的关键维度。在 IM 的场景中,消息数据来自于人与人之间的聊天,可以按照人去划分数据,但是如果聊天的两个人在不同的单元之间,消息数据必然要在两个单元拷贝或者冗余,因此按照人划分数据并不是很好的维度。DTIM 采用了会话维度划分:因为人和会话都是元数据,数据规模有限,消息数据近乎无限,消息归属于会话,会话与会话之间并无交集,消息处理时并没有跨单元的调用。因此,将不同的会话拆分到不同的单元,保障了消息数据仅在一个单元处理和持久化,不会产生跨单元的请求调用,进而实现了单元自封闭。7.3.5 弹性单元降级在单元化的架构中,为了支持服务级别的横向扩展能力,多单元是基本形态。单元的异常流量亦或者是服务版本维护的影响都会放大影响面,进而影响 DTIM 整体服务。因此:DTIM 重点打造了单元降级的能力,单一单元失去服务能力之后,DTIM 会将业务流量切换到新的单元,新消息会从正常的单元下推,钉钉客户端在数据渲染时也不会受到故障单元的影响,做到了单元故障切换用户无感知。八、本文小结本文通过模型设计、存储优化、同步机制以及高可用等维度,本文全方位地展示了当代企业级 IM 设计的核心。本文也是对 DTIM 过去一段时间的技术总结,随着用户数的持续增长,DTIM 也在与时俱进、持续迭代和优化,比如支持条件索引进而实现索引加速和成本可控、实现消息位点的连续累加、实现消息按需拉取和高效的完整性校验、提供多种上下行通道,进一步提升弱网下的成功率和体验等。九、相关文章[1] 企业级IM王者——钉钉在后端架构上的过人之处[2] 现代IM系统中聊天消息的同步和存储方案探讨[3] 钉钉——基于IM技术的新一代企业OA平台的技术挑战(视频+PPT)》[5] 一套亿级用户的IM架构技术干货(上篇):整体架构、服务拆分等[6] 一套亿级用户的IM架构技术干货(下篇):可靠性、有序性、弱网优化等[7] 从新手到专家:如何设计一套亿级消息量的分布式IM系统[8] 企业微信的IM架构设计揭秘:消息模型、万人群、已读回执、消息撤回等[9] 全面揭秘亿级IM消息的可靠投递机制[10] 一套高可用、易伸缩、高并发的IM群聊、单聊架构方案设计实践[11] 一套海量在线用户的移动端IM架构设计实践分享(含详细图文)[12] 一套原创分布式即时通讯(IM)系统理论架构方案(本文已同步发布于:http://www.52im.net/thread-4012-1-1.html)
本文由vivo互联网服务器团队李青鑫分享,有较多修订和改动。1、引言本文内容来自vivo互联网服务器团队李青鑫在“2021 vivo开发者大会”现场的演讲内容整理而成(现场演讲稿可从本文末附件中下载)。本文将要分享的是手机厂商vivo的系统级推送平台在架构设计上的技术实践和总结。这也是目前为止首次由手机厂商分享的自建系统级推送平台的技术细节,我们也得以借此机会一窥厂商ROOM级推送通道的技术水准。学习交流:- 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》- 开源IM框架源码:https://github.com/JackJiang2011/MobileIMSDK(备用地址点此)(本文已同步发布于:http://www.52im.net/thread-4008-1-1.html)2、关于作者李青鑫,vivo互联网服务器团队架构师。3、为什么需要消息推送消息推送对于移动端APP来说,是很常见的业务特征,比如新闻APP中的最新资讯、社交应用中的系统通知、IM即时通讯应用的离线聊天消息等等。可以说,没有消息推送能力,APP就失去了实时触达的能力,对于一个应用来说,它对用户的“粘性”将大大下降。而对于用户来说,信息实时获取的能力也将大大降低,用户体验也将大幅下降。4、消息推送的技术障碍以我们日常最常见的IM应用来说,离线消息的推送是必备能力。但随着Android系统的不断升级,离线推送已经不单单是一个后台服务加长连接那么理所当然了。对于早期的Android系统来说,想要实现IM的离线消息推送并不困难,搞个后台服务再加上socket长连接就算是齐活了。但随着Android系统的升级,针对后台进程和网络服务限制不断加码,为了继续实现离线消息的推送,开发者们不得不跟系统斗志斗勇,搞出了各种保活黑科技,比如:Android4.0之后的双进程守护、Android6.0及之后的防杀复活术、以及发展到后来的腾讯TIM进程永生技术,一时间群魔乱舞、无比风骚(有兴趣的同学,可以读读《Android进程永生技术终极揭秘:进程被杀底层原理、APP应对被杀技巧》这篇针对所有保活黑科技的总结性文章)。随着Andriod 9.0的到来,基本从系统上堵死了各种保活黑科技的活路(详见《Android P正式版即将到来:后台应用保活、消息推送的真正噩梦》),各Android厂商的ROOM系统级推送通道也应运而生——华为推送、小米推送、魅族推送、OPPO推送、vivo推送,一时间从用户的噩梦(保活黑科技对用户困扰很大)变成了开发者的恶梦并持续至今(想要做好IM离线推送,如今的IM开发者们不得不一家家对接各手机厂商的离线推送,你说烦不烦)。也别跟我说为什么不用Android官方的FCM服务(在国内这链接你能打开算我输,至于为什么,你懂的。。。),也别我跟提那个统一推送联盟(4、5年过去了,看样子还要继续等下去)。于是,为了继续搞定离线消息推送,IM的开发者们目前只有两条路可选:1)举白旗向系统投降,放弃保活黑科技,直接引导用户手动加白名单(详见《Android保活从入门到放弃:乖乖引导用户加白名单吧》);2)一家一家对接各厂商的系统级推送通道(华为、小米、魅族、OPPO、vivo,悲剧的是,有些小众厂商并没自建推送的能力)。随着Android系统对于开发者保活黑科技的“堵”,手机厂商们搞出自家的系统级推送通道来“疏”,也算是理所当然。而这些厂商之中,vivo的系统级推送通道出现的算是比较晚的。本篇文章的余下技术内容,算是目前为止首次由手机厂商分享的自建系统级推送平台的技术细节,我们一起来学习。5、从技术角度了解推送平台推送平台是做什么的?从技术的角度上来看,推送平台就是一个通过TCP长连接,将消息发送给用户的平台。所以推送平台的本质其实就是借助网络通道,将消息发送到用户设备上。大家日常都收到过快递通知吧!当快递员将快递放到快递柜中,快递后台就会自动推送一条消息,通知你有快递。我相信,如果你是一位运营人员,你也会喜欢这种自动下发消息高效的方式。大家感兴趣的,可以通过vivo开放平台入口,选择消息推送来更进一步了解更多技术细节,这里就不做展开了。6、短连接与长连接消息推送平台的本质,就是通过长连接将内容、服务、用户连在一起,将内容分发给用户,为终端设备提供实时、双向通信能力。这里有个概念长连接,那么什么是长连接?所谓的长连接就是:客户端与服务端维持的一条、在相对较长的时间里、都能够进行网络通信的网络连接(比如:基于TCP的长连接)。为什么我们要采用长连接而不是短连接作为平台底层的网络通信?先来看看短连接下消息下发的场景:使用短连接的方式就是轮询,即客户端定时的去询问后台有没有设备A的消息,当有设备A的消息时后台返回对应的消息,可能很多情况下都是无功而返,浪费流量。当后台有消息需要发送给设备A时,因为设备A没有过来取导致消息无法下发。而使用长连接:当有设备A的消息时后台直接发送给设备A而不用等设备A自己过拉取,所以长连接让数据交互更加自然、高效。7、业务需求驱动架构升级对于系统的技术架构来说,它是动态的,不同阶段都可能会发生变化。而推动架构进行演进的推力,主要来自于业务需求,一起来回顾,平台的业务发展历程。自2015年立项以来,随着业务量增长,不断为系统添加功能特性,丰富整个系统的能力使其满足不同的业务场景需求。比如支持内容完全审核、支持IM、支持IoT、支持WebSocket 通信等。从图上可以看到,业务量几乎每年都有几十亿的增长,不断攀高,给系统带来了挑战,原有的系统架构存在的问题,也逐渐浮出水面,比如延迟、性能瓶颈。架构服务于业务,2018年之前我们平台所有服务都放在云上,但是依赖的其他内部业务部署在自建机房。随着业务量增长与自建机房的数据传输,已经出现了延迟的问题,并且在逐渐恶化,不利于我们平台功能的拓展。所以在2018年下半年,我们对部署架构进行调整:将所有核心逻辑模块都迁移到自建机房,架构优化之后,数据延迟问题得到彻底解决,同时也为架构进一步演进奠定了基础。从上面的图中可以看到我们接入网关也进行优化三地部署。为什么要进行三地部署而不是更多区域部署呢?主要基于以下三点考虑:1)第一是基于用户分布及成本的考虑;2)第二是能为用户提供就近接入;3)第三是能够让接入网关具备一定容灾能力。大家可以设想下,如果没有三地部署,接入网关机房故障时,那么平台就瘫痪了。随着平台业务规模的进一步扩大,日吞吐量达到10亿的量级,用户对于时效性、并发要求越来越高。而2018年的逻辑服务的系统架构已经无法业务高并发的需求或者需要更高的服务器成本才能满足高并发需求。所以从平台功能、成本优化出发,在2019年对系统进行了重构,为用户提供更加丰富的产品功能及更稳定、更高性能的平台。8、利用长连接能力给更多业务赋能作为公司较大规模的长连接服务平台,团队积累了非常丰富的长连接经验。我们也一直在思考,如何让长连接能力为更多业务赋能。我们平台服务端各个模块之间通过RPC调用,这是一种非常高效的开发模式,不用每个开发人员都去关心底层网络层数据包的。我们设想一下,如果客户端也能通过RPC调用后台,这一定是非常棒的开发体验。未来我们将会提供VRPC通信框架,用于解决客户端与后台通信及开发效率问题,为客户端与后台提供一致的开发体验,让更多的开发人员不再关心网络通信问题,专心开发业务逻辑。作为一个吞吐量超过百亿的推送平台其稳定性、高性能、安全都非常重要,接下来和大家分享,我们在系统稳定性、高性能、安全方面的实践经验。9、vivo推送平台的领域模型从上图的领域模型可以看出,推送平台以通信服务作为核心能力,在核心能力的基础上我们又提供了,大数据服务以及运营系统,通过不同接口对外提供不同的功能、服务。以通信服务为核心的推送平台,其稳定性和性能都会影响消息的时效性。消息的时效性是指,消息从业务方发起用设备收到的耗时。那么如何衡量消息的时效性呢?我们继续往下看。10、如何实现消息时效性的监控与质量度量?传统的消息时效性测量方法如上图左所示:发送端和接收端在两个设备上,在发送的时候取时间t1、在接收到消息的时候取时间t2,这两个时间相减得到消息的耗时。但是这种方法并不严谨,为什么呢?因为这两个设备的时间基准,很有可能是不一致的。我们采用的解决方案如上图右图所示:将发送端和接收端放在同一个设备上,这样就可以解决时间基准的问题。我们就是基于该方案,搭建了一套拨测系统,来主动监控消息送达耗时分布。11、如何实现高性能、稳定的长连接网关?过去10年讨论单机长连接性能时面对的是单机一万连接的问题(C10K问题),而作为一个上亿级设备同时在线的平台,我们要面对的是单机100万连接的问题。作为长连接网关,主要职责是维护与设备端的TCP连接及数据包转发。对于长连接网关:我们应该尽可能使其轻量化。我们从以下几方面进行了自上而下的重构优化:1)架构设计;2)编码;3)操作系统配置;4)硬件特性配置。具体的实施方法,比如:1)调整系统最大文件句柄数、单个进程最大的文件句柄数;2)调整系统网卡软中断负载均衡或者开启网卡多队列、RPS/RFS;3)调整TCP相关参数比如keepalive(需要根据宿主机的session时间进行调整)、关闭timewait recycles;4)硬件上使用AES-NI指令加速数据的加解密。经过我们优化之后,线上8C32GB 的服务器可以稳定支持170万的长连接。另外一大难点在于连接保活:一条端到端的 TCP连接,中间经过层层路由器、网关,而每个硬件的资源都是有限的,不可能将所有TCP连接状态都长期保存。所以为了避免TCP资源,被中间路由器回收导致连接断开,我们需要定时发送心跳请求,来保持连接的活跃状态(为什么TCP有这样的问题?有兴趣可以读这两篇:《为什么说基于TCP的移动端IM仍然需要心跳保活?》、《彻底搞懂TCP协议层的KeepAlive保活机制》)。心跳的发送频率多高才合适?发送太快了会引起功耗、流量问题,太慢了又起不到效果,所以为了减少不必要的心跳及提升连接稳定性,我们采用智能心跳,为不同网络环境采用差异性的频率。有关长连接心跳机制的更详细资料,可以参阅:《手把手教你用Netty实现网络通信程序的心跳机制、断线重连机制》《一文读懂即时通讯应用中的网络心跳包机制:作用、原理、实现思路等》《移动端IM实践:实现Android版微信的智能心跳机制》《移动端IM实践:WhatsApp、Line、微信的心跳策略分析》《一种Android端IM智能心跳算法的设计与实现探讨(含样例代码)》《正确理解IM长连接、心跳及重连机制,并动手实现》《万字长文:手把手教你实现一套高效的IM长连接自适应心跳保活机制》《Web端即时通讯实践干货:如何让你的WebSocket断网重连更快速?》12、如何实现亿级设备的负载均衡?我们平台超过亿级设备同时在线,各个设备连接长连接网关时是通过流量调度系统进行负载均衡的。当客户端请求获取IP时,流量调度系统会下发多个就近接入网关IP:那么调度系统是如何确保下发的ip是可用的呢?大家可以简单思考下。对于我来来说,我们采用四种策略:1)就近接入 ;2)公网探测 ;3)机器负载;4)接口成功率。到底采用这几种策略呢?大家可以想下,这两个问题:1)内网正常,公网就一定能联通吗?2)连接数少服务器,就一定是可用的吗?答案是否定的,因为长连接网关与流量调度系统是通过内网进行心跳保活的,所以在流量调度系统上看到的长连接网关是正常的,但是很有可能长连接网关公网连接是异常的比如没有开通公网权限等。所以我们需要结合多种策略,来评估节点的可用性,保障系统的负载均衡、为系统稳定性提供保障。13、如何满足高并发需求?有这么一个场景:以每秒1000的推送速度,将一条新闻发送给几亿用户,那么有的用户可能是几天后才收到这条消息,这就非常影响用户体验,所以高并发对消息的时效性来说是非常重要的。从上图的推送流程来看:会不会觉得TiDB会成为推送的性能瓶颈?其实不会:初步看可能会觉得它们作为中心存储,但因为我们采用分布式缓存,将中心存储的数据,根据一定的策略缓存到各个业务节点,充分利用服务器资源,提升系统性能、吞吐量。我们线上的分布式缓存命中率99.9% 为中心存储挡住了绝大部分请求,即使TiDB短时间故障,对我们影响也比较小。14、如何保障系统稳定性?14.1 概述作为推送平台,平台的流量主要分为外部调用及内部上下游之间的调用。它们大幅波动都会影响系统的稳定性,所以需要进行限流、控速,保障系统稳定运行。14.2 推送网关限流推送网关作为流量入口,其稳定性非常重要。要让推送网关稳定运行,我们首先要解决流量均衡的问题即避免流量倾斜的问题。因为流量倾斜之后,很有可能会引起雪崩的情况。我们是采用轮询的机制,进行流量的负载均衡,来避免流量倾斜问题。但是这里有两个前提条件:1)所有推送网关节点,服务器配置要保持一致,否则很有可能会因为某个处理能力不足导致过载问题;2)应控制流入我们系统的并发量,避免流量洪峰穿透推送网关导致后端服务过载。我们采用的是令牌桶算法,控制每个推送网关投放速度,进而能够对下游节点起到保护作用。那么令牌数量设置多少才合适呢?设置低了,下游节点资源不能充分利用;设置太高了,下游节点有可能扛不住。我们可以采用主动+被动的动态调整的策略:1)当流量超过下游集群处理能力时,通知上游进行限速;2)当调用下游接口超时,达到一定比例是进行限流。14.3 系统内部限速:标签推送平滑下发既然推送网关已经限流了,为什么内部节点之间还要限速?这个是由于我们平台的业务特点决定的,平台支持全量、标签推送,要避免性能较好的模块,把下游节点资源耗尽的情况。标签推送模块(提供全量、标签推送)就是一个性能较高的服务,为了避免它对下游造成影响。我们基于Redis和令牌桶算法实现了平滑推送的功能,控制每个标签任务的推送速度,来保护下游节点。另外:平台支持应用创建多个标签推送,它们的推送速度会叠加,所以仅控制单个标签任务的平滑推送是不够的。需要在推送下发模块对应用粒度进行限速,避免推送过快对业务后台造成压力。14.4 系统内部限速:消息下发时限速发送为了实现应用级别的限速,我们采用Redis实现分布式漏桶限流的方案,具体方案如上图所示。这里我们为什么采用的是clientId(设备唯一标识),而不是使用应用ID来做一致性hash?主要是为了负载均衡。自从实现了这个功能之后,业务方再也不用担心推送太快,造成自己服务器压力大的问题。那么被限速的消息会被丢掉吗?当然不会,我们会将这些消息存储到本地缓存、并且打散存储到Redis,之所以需要打散存储主要是为了避免后续出现存储热点问题。14.5 熔断降级推送平台,一些突发事件、热点新闻会给系统带来较大的突发流量。我们应该如何应对突发流量呢?如上图左所示:传统的架构上为了避免突发流量对系统的冲击,冗余部署大量机器,成本高、资源浪费严重。在面临突发流量时,无法及时扩容,导致推送成功率降低。我们是怎么做的呢?我们采用增加缓冲通道,使用消息队列和容器的解决方案(这种方案系统改动小)。当无突发流量时以较小量机器部署,当遇到突发流量时我们也不需要人工介入,它会根据系统负载自动扩缩容。15、基于Karate的自动化测试系统在日常开发中大家为了快速开发需求,往往忽视了接口的边界测试,这将会给线上服务造成很大的质量风险。另外,不知道大家有没有注意到,团队中不同角色沟通时使用的不同媒介比如使用word、excel、xmind等,会导致沟通的信息出现不同程度折损。所以为了改善以上问题,我们开发了一个自动化测试平台,用于提升测试效率与接口用例覆盖率,我们采用领域统一的语言减少团队中不同角色沟通信息折损。另外还可以对测试用例统一集中管理,方便迭代维护。16、内容安全作为推送平台,当然要为内容安全把好关,我们提供了内容审计的能力。审计方法采用自动审核为主、人工审核为辅机制来提升审核效率。同时结合基于影响面及应用分级的策略进行内容审计。从下图中可以看到业务请求经过接入网关转发给内容审系统进行第一层本地规则的内容审计,如果没有命中本地规则则调用我们谛听系统进行内容反垃圾审计。17、未来规划前面我们主要介绍了推送平台这几年的架构演进及演进过程中的系统稳定性、高性能、安全等方面的技术实践,接下来介绍未来的重点工作。为了提供更易用、更稳定、更安全的推送,未来将在以下方面持续投入建设:1)在单模块数据一致性的基础上,实现全系统数据一致性;2)将继续完善各系统的熔断降级能力;3)平台的易用性方面持续优化,提供更加便捷的平台服务;4)建设异常流量识别的能力。18、演讲稿附件下载本文内容对应的演讲原稿附件下载: vivo推送平台架构演进(52im.net).pdf (1.93 MB )演讲原稿内容概览:19、参考资料[1] Android6.0以下的双进程守护保活实践[2] Android6.0及以上的保活实践(进程防杀篇)》[3] 为何基于TCP协议的移动端IM仍然需要心跳保活机制?[4] Android版微信后台保活实战分享(进程保活篇)[5] 实现Android版微信的智能心跳机制[6] Android P正式版即将到来:后台应用保活、消息推送的真正噩梦[7] 融云安卓端IM产品的网络链路保活技术实践[8] 正确理解IM长连接的心跳及重连机制,并动手实现[9] 史上最强Android保活思路:深入剖析腾讯TIM的进程永生技术[10] Android进程永生技术终极揭秘:进程被杀底层原理、APP对抗被杀技巧[11] Web端即时通讯实践干货:如何让你的WebSocket断网重连更快速?(本文已同步发布于:http://www.52im.net/thread-4008-1-1.html)
本文收作者“大白菜”分享,有改动。注意:本系列是给IM初学者的文章,IM老油条们还望海涵,勿喷!1、引言这又是一篇基于Netty的IM编码实践文章,因为合成一篇内容太长,读起来太累,所以也就顺着作者的思路分开成4篇,读起来心理压力也就没那么大了。这个系列的几篇文章分享的是:假设在没有任何成型的第3方IM库或SDK的情况下,以网络编程的基础技术视野,思考和实践如何基于Netty网络库从零写一个可以聊天的IM系统的过程,没有眼花缭乱的架构设计、也没有高端大气的模式设计方法论,有的只是从IM入门者的角度的思路和实战,适合IM初学者阅读。本篇主要是徒手撸IM系列的开篇,主要讲解的是的IM设计思路,不涉及实践编码,希望给你带来帮助。学习交流:- 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》- 开源IM框架源码:https://github.com/JackJiang2011/MobileIMSDK(备用地址点此)(本文已同步发布于:http://www.52im.net/thread-3963-1-1.html)2、知识准备* 重要提示:本系列文章主要是代码实战分享,如果你对即时通讯(IM)技术理论了解的不多,建议先详细阅读:《零基础IM开发入门:什么是IM系统?》、《新手入门一篇就够:从零开发移动端IM》。不知道 Netty 是什么?这里简单介绍下:Netty 是一个 Java 开源框架。Netty 提供异步的、事件驱动的网络应用程序框架和工具,用以快速开发高性能、高可靠性的网络服务器和客户端程序。也就是说,Netty 是一个基于 NIO 的客户、服务器端编程框架,使用Netty 可以确保你快速和简单的开发出一个网络应用,例如实现了某种协议的客户,服务端应用。Netty 相当简化和流线化了网络应用的编程开发过程,例如,TCP 和 UDP 的 Socket 服务开发。Netty的基础入门好文章:1)新手入门:目前为止最透彻的的Netty高性能原理和框架架构解析2)写给初学者:Java高性能NIO框架Netty的学习方法和进阶策略3)史上最通俗Netty框架入门长文:基本介绍、环境搭建、动手实战如果你连Java的NIO都不知道是什么,下面的文章建议优先读:1)少啰嗦!一分钟带你读懂Java的NIO和经典IO的区别2)史上最强Java NIO入门:担心从入门到放弃的,请读这篇!3)Java的BIO和NIO很难懂?用代码实践给你看,再不懂我转行!Netty源码和API的在线查阅地址:1)Netty-4.1.x 完整源码(在线阅读版)(* 推荐)2)Netty-4.1.x API文档(在线版)3、系列文章本文是系列文章的第1篇,以下是系列目录:《基于Netty,徒手撸IM(一):IM系统设计篇》(* 本文)《基于Netty,徒手撸IM(二):编码实践篇(单聊功能)》《基于Netty,徒手撸IM(三):编码实践篇(群聊功能)》《基于Netty,徒手撸IM(一):编码实践篇(系统优化)》4、需求分析业务场景: 本次实战就是模拟微信的IM聊天,每个客户端和服务端建立连接,并且可以实现点对点通信(单聊),点对多点通信(群聊)。设计思路: 我们要实现的是点(客户端)对点(客户端)的通讯,但是我们大部分情况下接触的业务都是客户端和服务端之间的通讯(所谓的C/S模式?),客户端只需要知道服务端的 IP 地址和端口号即可发起通讯了。那么客户端和客户端应该怎么去设计呢?技术思考:难道是手机和手机之间建立通讯连接(所谓的P2P),互相发送消息吗?这种方案显然不是很好的方案:1)首先: 客户端和客户端之间通讯,首先需要确定对方的 IP 地址和端口号,显然不是很现实;2)其次: 即使有办法拿到对方的 IP 地址和端口号,那么每个点(客户端)既作为服务端还得作为客户端,无形之中增加了客户端的压力。其实:我们可以使用服务端作为IM聊天消息的中转站,由服务端主动往指定客户端推送消息。如果是这种模式的话,那么 Http 协议是无法支持的(因为Http 是无状态的,只能一请求一响应的模式),于是就只能使用 TCP 协议去实现了。Jack Jiang注:此处作者表述不太准确,因为虽然HTTP是无状态的,但一样可以实现即时通讯能力,有兴趣的读者可以阅读以下几篇文章,了解一下这些曾经利用HTTP实现即时通讯聊天的技术方法:《新手入门贴:史上最全Web端即时通讯技术原理详解》《Web端即时通讯技术盘点:短轮询、Comet、Websocket、SSE》《网页端IM通信技术快速入门:短轮询、长轮询、SSE、WebSocket》5、IM单聊思路设计5.1 通讯架构原理以下是通讯架构原理图:如上图所示,通讯流程解析如下:1)实现客户端和客户端之间通讯,那么需要使用服务端作为通讯的中转站,每个客户端都必须和服务端建立连接;2)每个客户端和服务端建立连接之后,服务端保存用户 ID 和通道的映射关系,其中用户 ID 作为客户端的唯一标识;3)客户端 A 往客户端 B 发送消息时,先把消息发送到服务端,再有服务端往客户端 B 进行推送。针对上述第“3)”点,服务端如何找到客户端 B 呢?客户端 A 往服务端发送消息时,消息携带的信息有:“客户端 A 用户 ID”、“客户端 B 用户 ID”、“消息内容”。这样服务端就能顺利找到服务端 B 的通道并且进行推送消息了。5.2 消息推送流程每个客户端和服务端建立连接的时候,必须把个人用户信息上传到服务端,由服务端统一保存映射关系。如果某个客户端下线了,则服务端监听到连接断开,删除对应的映射关系。其次:发起群聊的时候,需要传递 touser 字段,服务端根据该字段在映射表里面查找到对应的连接通道并发起消息推送。上述逻辑原理如下图所示:5.3 更多的细节其实在真正要做IM之前,要考虑的技术细节还是很多的,以下这几篇文章就步及到了典型的几个IM热门技术点,有兴趣的一定要读一读:《移动端IM开发需要面对的技术问题》《谈谈移动端 IM 开发中登录请求的优化》《IM消息送达保证机制实现(一):保证在线实时消息的可靠投递》《IM消息送达保证机制实现(二):保证离线消息的可靠投递》《如何保证IM实时消息的“时序性”与“一致性”?》6、IM群聊思路设计群聊指的是一个组内多个用户之间的聊天,一个用户发到群组的消息会被组内任何一个成员接收 。具体架构思路如下所示:如上图所示,群聊通讯流程解析如下。1)群聊其实和单聊整体上思路都是一致的,都是需要保存每个用户和通道的对应关系,方便后期通过用户 ID 去查找到对应的通道,再跟进通道推送消息。2)如何把消息发送给多个组内的成员呢?其实很简单,服务端再保存另外一份映射关系,那就是聊天室和成员的映射关系。发送消息时,首先根据聊天室 ID 找到对应的所有成员,然后再跟进各个成员的 ID 去查找到对应的通道,最后由每个通道进行消息的发送。3)成员加入某个群聊组的时候,往映射表新增一条记录,如果成员退群的时候则删除对应的映射记录。通过上面的架构图可以发现,群聊和单聊相比,其实就是多了一份映射关系而已。其实群聊是IM里相对来说技术难度较高的功能,有兴趣的读者可以阅读下面这几篇:《IM单聊和群聊中的在线状态同步应该用“推”还是“拉”?》《IM群聊消息如此复杂,如何保证不丢不重?》《移动端IM中大规模群消息的推送如何保证效率、实时性?》《现代IM系统中聊天消息的同步和存储方案探讨》《关于IM即时通讯群聊消息的乱序问题讨论》《IM群聊消息的已读回执功能该怎么实现?》《IM群聊消息究竟是存1份(即扩散读)还是存多份(即扩散写)?》《一套高可用、易伸缩、高并发的IM群聊、单聊架构方案设计实践》另外,对于超大规模群聊,技术难度更是指数上升:《网易云信技术分享:IM中的万人群聊技术方案实践总结》《阿里钉钉技术分享:企业级IM王者——钉钉在后端架构上的过人之处》《IM群聊消息的已读未读功能在存储空间方面的实现思路探讨》《企业微信的IM架构设计揭秘:消息模型、万人群、已读回执、消息撤回等》《融云IM技术分享:万人群聊消息投递方案的思考和实践》《微信直播聊天室单房间1500万在线的消息架构演进之路》7、本文小结本篇主要是帮助读者掌握单聊和群聊的核心设计思路。单聊: 主要是服务器保存了一份用户和通道之间的映射关系,发送消息的时候,根据接收人 ID 找到其对应的通道 Channel,Channel 的 write () 可以给客户端发送消息。群聊: 保存两份关系,分别是用户 ID 和 Channel 之间的关系、群组 ID 和用户 ID 的关系。推送消息的时候,首先根据聊天组 ID 找到其对应的成员,遍历每个成员再进行找出其对应的通道即可。整体来说,思路还是很简单的,掌握了该设计思路以后,你会发现设计一款 IM 聊天软件其实也不是很复杂。8、相关文章如果你觉得对本系列文章还不够详细,可以系统学习以下系列文章:《跟着源码学IM(一):手把手教你用Netty实现心跳机制、断线重连机制》《跟着源码学IM(二):自已开发IM很难?手把手教你撸一个Andriod版IM》《跟着源码学IM(三):基于Netty,从零开发一个IM服务端》《跟着源码学IM(四):拿起键盘就是干,教你徒手开发一套分布式IM系统》《跟着源码学IM(五):正确理解IM长连接、心跳及重连机制,并动手实现》《跟着源码学IM(六):手把手教你用Go快速搭建高性能、可扩展的IM系统》《跟着源码学IM(七):手把手教你用WebSocket打造Web端IM聊天》《跟着源码学IM(八):万字长文,手把手教你用Netty打造IM聊天》《跟着源码学IM(九):基于Netty实现一套分布式IM系统》《跟着源码学IM(十):基于Netty,搭建高性能IM集群(含技术思路+源码)》《SpringBoot集成开源IM框架MobileIMSDK,实现即时通讯IM聊天功能》9、参考资料[1] 新手入门:目前为止最透彻的的Netty高性能原理和框架架构解析[2] 理论联系实际:一套典型的IM通信协议设计详解[3] 浅谈IM系统的架构设计[4] 简述移动端IM开发的那些坑:架构设计、通信协议和客户端[5] 一套海量在线用户的移动端IM架构设计实践分享(含详细图文)[6] 一套原创分布式即时通讯(IM)系统理论架构方案[7] 一套高可用、易伸缩、高并发的IM群聊、单聊架构方案设计实践[8] 一套亿级用户的IM架构技术干货(上篇):整体架构、服务拆分等[9] 一套亿级用户的IM架构技术干货(下篇):可靠性、有序性、弱网优化等[10] 从新手到专家:如何设计一套亿级消息量的分布式IM系统[11] 基于实践:一套百万消息量小规模IM系统技术要点总结[12] 探探的IM长连接技术实践:技术选型、架构设计、性能优化(本文已同步发布于:http://www.52im.net/thread-3963-1-1.html)
本文由作者jhon_11分享,有大量修订和改动。1、引言如何设计一款高性能、高并发、高可用的im综合消息平台是很多公司发展过程中会碰到且必须要解决的问题。比如一家公司内部的通讯系统、各个互联网平台的客服咨询系统,都是离不开一款好用且维护的方便im综合消息系统。那么,我们应该怎么样来设计一款三高特性的im系统,并能同时支持各个业务线的接入(比如:内部OA通讯、客服咨询、消息推送等等功能)有呢?下面就由我来介绍一下我所负责的公司IM综合消息系统所经历的架构设计历程,以及架构设计过程中的一些思路和总结,希望能给你带来启发。学习交流:- 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》- 开源IM框架源码:https://github.com/JackJiang2011/MobileIMSDK(备用地址点此)(本文已同步发布于:http://www.52im.net/thread-3954-1-1.html)2、初版IM架构2.1 概述im第一版设计的初衷是公司需要一款im消息中间件用于支撑客服咨询业务。但是,考虑到为了方便日后其他业务线也能接入消息沟通平台,所以一开始就将整个消息中心的能力需求给到中间件团队进行开发,以便除客服外的各业务线接入综合消息中心,从而实现多元的消息实时触达能力。2.2 初版架构介绍初版架构图如下图所示:针对上面的架构图,我们逐个解释一下各模块的作用。1)存储端:在初版的架构下,存储端我们使用tidb、redis作为主要存储:[1] redis用于存储消息已读未读,缓存连接信息等功能;[2] tidb作为开源的分布式数据库,选择它是为了方便消息的存储。2)mq消息总线:我们使用rocketmq来实现消息总线(PS:即分布式情况下,不同im实例间通过MQ进行消息交互)。消息总线是整个im的核心,使用rocketmq能支持十万级别的tps。基本所有服务都要从消息总线中消费消息进行业务处理。3)zookeeper注册中心:各个服务会注册到zk中,方便服务之间内部进行调用,同样也可以暴露服务给外部进行调用。4)link服务:link服务主要用于接收客户端的ws(WebSocket协议)、tcp、udp等协议的连接。同时调用用户服务进行认证,并投递连接成功的消息给位置服务进行消费,存储连接信息。ws(WebSocket协议)过来的消息先到link再投递到消息总线。5)消息分发服务:消息分发服务主要用于接收消息总线推过来的消息进行处理,按照im内部消息协议构造好消息体后,又推送到消息总线中(比如会推给会话服务、消息盒子、link服务)。6)位置服务:存储link的(WebSocket协议)连接、tcp连接等信息,并使用redis进行缓存(key为userId),方便根据UserId查询到该用户所登录的客户端连接在哪个link上。一个用户在相同设备只能登录一个,但可以支持多端登录。7)用户服务:用于存储所有用户,提供认证查询接口。8)消息盒子:存储所有消息,提供消息查询、消息已读未读、消息未读数、消息检索等功能。9)会话服务:管理会话、群聊会话、单聊会话等功能。2.3 整体时序图整体架构的时序图如下:3、初版IM架构存在的问题及思考在上节的架构设计介绍中,我们详细分享了初版IM系统架构的设计思路以及具体流程。那么在初版IM架构设计中还存在什么样的问题,又该如何优化呢?我们一条条来看看。3.1 使用MQ消息总线的问题正如上节所分享的那样,我们初版IM架构中,link服务到消息分发服务的消息使用的MQ消息总线。初版架构设计中,link服务将消息下推给消息分发服务进行处理时,使用的是mq消息总线(通俗了说,IM集群内不同IM实例间的通信是依赖于MQ进行的消息传递),而mq消息总线必然做对有一定的时延(而且时延受制于MQ本身的系统实现和技术策略)。举个例子:当两个处于不同IM实例的客户端A和B聊天时,A用户发送消息到link --> 消息总线 --> 消息分发服务 --> 消息总线 --> link --> B用户。正如上面这个例子,im消息投递流程太长了,并且这样也会大大降低系统的吞吐量。3.2 消息落库为写扩散的问题其实现阶段我们使用的是跟微信一样的写扩散策略(详见《企业微信的IM架构设计揭秘:消息模型、万人群、已读回执、消息撤回等》)。那么为啥微信使用写扩散不是缺陷,而对于我们的IM架构来说确是缺陷呢?微信的技术特性:1)微信号称没有存储用户的聊天记录,全是实时推送;2)微信聊天记录全部会在我们手机端存储一份,两台手机终端上的聊天记录并不互通,并且互不可见。我们的IM综合消息中心技术特性:1)综合消息中心是会有拉取历史聊天记录(服务端拉取)的功能,存储了全量消息;2)综合消息中心的客户端,需要支持网页版本。综上所述:1)写扩散对微信这样有移动端的富客户端版本的即时通讯产品十分友好,每个消息在消息分发的时候给处于这个会话(单聊,群聊)下的所有用户所在客户端先推送消息,没找到连接就针对这个用户写一个离线缓存消息,那么下次该用户登录进来,可以从缓存中拉取到该消息,并且清掉缓存;2)写扩散对于我们这类通用综合消息平台并不友好,由于接入方大部分是网页版的客户端,所以没有缓存消息的能力,浏览器刷新就没有了任何消息,所以需要实时去服务端拉取历史消息。假设我是写扩散,在一个群聊中有五百个用户,针对这五百个用户在这个会话,我需要去写五百条消息,大大的增加了写io,并且还不能写缓存(得写数据库)。3.3 tidb存在不稳定性和事务并发的问题tidb是目前主流的开源分布式数据库,查询效率高、无需分库分表。但同样的,tidb存在一些隐藏的问题:1)tidb在高并发情况下,并发事务会导致事务失败,具体原因不知;2)tidb排错成本高,公司很少有tidb专业运维,经常遇到不走索引的情况。3.4 群聊、单聊冗余在同一个服务的问题在我们初版的IM架构设计中,单聊和群聊是冗余在会话服务中的,并且冗余在同一张表的。其实单聊、群聊从数据角度来说,还是会有些不同(比如业务属性)虽然都是会话,我们还是需要将这两个服务拆分开,细粒度的服务拆分能更好的把控整体的逻辑。4、升级版IM架构4.1 初始架构问题正如前面两节分享的那样,渐渐的我们发现初版im架构有很大的不足之处。在生产上暴露出了以下问题:1)tps没达到预期,吞吐量不能满足公司业务的发展;2)使用的存储中间件难以维护(主要是tidb),试错成本高,经常在生产暴露问题,并且速度越来越慢;3)消息写扩散没有太大必要,并大大增加了系统io次数(原因见上一节);4)一些特性无法支持,比如消息图文检索,消息已读未读。4.2 升级版im架构介绍本次升级后的im架构如下图所示:如上图所示,改版后的各模块情况如下:1)存储端:存储端我们改用了mysql,针对消息服务单独使用了主从mysql集群(主节点用于写消息、从节点用于消息检索)——;2)mq消息总线:与第一版相比没有改动;3)link服务:与第一版相比,改动了link服务到消息分发服务的消息推送方式(由MQ总线方式变更为tcp实时推送);4)消息分发服务:集成了消息处理能力、路由能力,每台消息分发服务拥有所有link服务的tcp连接;5)单聊服务:负责单聊会话的管理能力;6)群聊服务:负责群聊会话的管理能力;7)用户服务:提供用户认证,登录\注册能力。5、详细对比针对初版IM架构的改动升级版的IM架构,对比初始初始,具体主要是下面这些改动。5.1 改进了不同im实例间的消息分发方式针对初版MQ消息总结的问题,升级版架构中,我们将link到消息分发服务改为tcp实时连接,百万客户端连接同一台link机器,消息实时触达能力tps达到16万。link到消息分发服务的改版是本次设计的亮点之一,完全消除了mq推送的时延性,并且路由简单,几乎实时触达。举个例子:(当两个处于不同IM实例的客户端A和B聊天时)1)初版架构中是:A用户发送消息到link --> 消息总线 --> 消息分发服务 --> 消息总线 --> link --> B用户;2)升级版架构是:用户A --> link --> 消息分发 --> link --> 用户B。而且:link服务到消息分发服务集群的消息推送使用轮询负载均衡的方式,保证公平,不会导致个别机器负载过高。5.2 取消了位置服务取消了位置服务(这里的位置不是指的IM消息里的地理位置消息哦),消息分发服务集成位置服务的能力。消息分发服务本身业务简单,不需要再单独划分位置服务,因为会增加网络io,并且消息分发服务直连link,而让它负责路由则更加方便。5.3 存储由tidb改成了mysql存储端由tidb改成了mysql,增强了可维护性,消息服务使用mysql主从读写分离方式,提高了消息落库速度与检索速度的同时,也减轻数据库压力。前面有提到过使用tidb这样维护成本高,排查问题难的分布式数据库是一件很痛苦的事情。而我们使用mysql更加稳定,大家对mysql的学习成本相对较低。针对消息服务使用读写分离的方式,能大大提高消息的吞吐量。5.4 实现了初版无法实现的特性功能升级版架构中,我们实现了初版无法实现的特性功能,比如消息已读未读、红包推送、商品链接推送等功能。新版综合消息中心加入了消息已读未读、发送红包、链接推送等功能,但这些功能带有一定的业务特性,毕竟不是所有Im都需要,可通过配置取消这些功能。5.5 消息由写扩散改为读扩散升级版IM架构中,消息存储由写扩散改为了读扩散。前面我们有提到写扩散和读扩散的利弊,对于网页端IM我们更适合使用读扩散,只需要落一条消息,大大提高消息服务的吞吐量.5.6 增加了门面服务升级版IM架构中,我们增加门面服务 im-logic,用于暴露给第三方业务线接口调用。初版架构中,都是im的各个服务各自暴露接口给到外部进行调用, 而升级版架中我们统一使用logic服务暴露给外部调用。在logic服务针对调用可以做一些处理,这样不会影响到整体im的通用,不会增加im底层代码的复杂度,从而将业务逻辑与底层进行解耦。6、优化后的效果对比针对升级版和初版IM架构,我们也做了一些对比测试,具体的测试过程就是详细展开了。以下是测试结果:7、业务线接入im综合消息系统的业务划分思考7.1 到底该如何设计高性能通用im综合消息系统关于业务线接入im综合消息系统的业务划分,我也做了一些总结和思考,为了更形象和易于理解,我这里以客服系统以及企业微信为例来进行分析。假如我开发了一款通用的im综合消息系统,现在有很多业务方需要接入我们,我们该如何进行业务域的清晰划分就显得尤为重要,需要在妥协与不妥协中进行平衡。就像当前市面上开源的im消息平台来说,存在的问题主要是:要么是集成了很多的业务逻辑,要么就只是一款单纯的客服系统,再或者就是一款IM好友聊天系统,中间的业务划分并不明确。当然,这也有好处,拿来就能用,并不需要进行二次业务封装。那么,到底如何将im设计为一款真正的高性能通用im综合消息系统呢?通用的综合消息消息平台只需要有通用的底层能力:以下案例假设在我已经按照上述架构设计了一版im综合消息中心。7.2 以客服系统为例客服系统:客服系统不光需要实现自身业务,还需要整合im的消息能力(消费im的消息),来进行场景分析,实现会话变更、信令消息推送等逻辑。客服系统内部需要根据im的底层支持能力进行相应的业务封装以及客服系统的客服用户池,c端用户池如何初始化到im的用户中心这些问题都是需要考虑进去的。7.3 内部OA通信为例内部OA通信:员工内部OA通信系统需要集成IM好友功能,需要根据im的用户中心封装组织架构,用户权限等功能。同时,内部通信系统需要根据im实现消息已读未读,群聊列表,会话列表拉取等功能。8、本文小结im的综合消息平台是一款需要高度结合业务的中间件系统,它直接与业务打交道,跟普通的中间件有根本的区别。一款好用的im综合消息平台,直接取决于你的通用性,可扩展性以及系统吞吐能力。希望这篇文章所分享的内容,能对大家开发im时候的思路有所启迪。9、参考资料[1] 从零到卓越:京东客服即时通讯系统的技术架构演进历程[2] 从游击队到正规军(一):马蜂窝旅游网的IM系统架构演进之路[3] 瓜子IM智能客服系统的数据架构设计(整理自现场演讲,有配套PPT)[4] 阿里钉钉技术分享:企业级IM王者——钉钉在后端架构上的过人之处[5] 新手入门一篇就够:从零开发移动端IM[6] 零基础IM开发入门(一):什么是IM系统?[7] 基于实践:一套百万消息量小规模IM系统技术要点总结[8] 一套亿级用户的IM架构技术干货(上篇):整体架构、服务拆分等[9] 一套亿级用户的IM架构技术干货(下篇):可靠性、有序性、弱网优化等[10] 从新手到专家:如何设计一套亿级消息量的分布式IM系统[11] 企业微信的IM架构设计揭秘:消息模型、万人群、已读回执、消息撤回等[12] 阿里IM技术分享(三):闲鱼亿级IM消息系统的架构演进之路[13] 一套高可用、易伸缩、高并发的IM群聊、单聊架构方案设计实践(本文已同步发布于:http://www.52im.net/thread-3954-1-1.html)
关于MobileIMSDKMobileIMSDK 是一套专门为移动端开发的开源IM即时通讯框架,超轻量级、高度提炼,一套API优雅支持UDP 、TCP 、WebSocket 三种协议,支持iOS、Android、H5、标准Java平台,服务端基于Netty编写。工程开源地址是:1)Gitee码云地址:https://gitee.com/jackjiang/MobileIMSDK2)Github托管地址:https://github.com/JackJiang2011/MobileIMSDK关于RainbowChat► 详细产品介绍:http://www.52im.net/thread-19-1-1.html► 版本更新记录:http://www.52im.net/thread-1217-1-1.html► 全部运行截图:Android端、iOS端► 在线体验下载:专业版(TCP协议)、专业版(UDP协议) (关于 iOS 端,请:点此查看)RainbowChat是一套基于开源IM聊天框架 MobileIMSDK 的产品级移动端IM系统。RainbowChat源于真实运营的产品,解决了大量的屏幕适配、细节优化、机器兼容问题(可自行下载体验:专业版下载安装)。* RainbowChat可能是市面上提供im即时通讯聊天源码的,唯一一款同时支持TCP、UDP两种通信协议的IM产品(通信层基于开源IM聊天框架 MobileIMSDK 实现)。v8.2 版更新内容此版更新内容(更多历史更新日志):(1)Android端主要更新内容【新增“扫一扫”等功能及优化!】:1)[bug]解决了客户端被踢掉后,再次登陆时提示socket错误的问题;2)[优化]优化了扫码加群界面中,群头像加载失败时的默认显示样式;3)[优化]优化了切换账号和被踢时跳转到登陆界面的切换性能;4)[优化]重构了主要类代码,更方便集成;5)[新增]搜索功能(支持好友、群聊、聊天记录搜索(与微信逻辑一样));6)[新增]“聊信信息”界面中新增“查找聊天记录”功能;7)[新增]“群聊信息”界面中新增“查找聊天记录”、“清空聊天记录”功能。(2)服务端主要更新内容:1)[优化][服务端]升级了MobileIMSDK至v6.2beta(改动了onUserLoginout方法参数);2)[优化][服务端]解决了log4j2的两个jar包冲突导致在linux下不能正常输出log的问题.此版主要新增功能运行截图(更多截图点此查看):
本文由字节跳动技术团队开发工程师王浩分享,即时通讯网收录时有较多修订。1、引言对于移动互联网时代的用户来说,短视频应用再也不是看看视频就完事,尤其抖音这种头部应用,已经是除了传统IM即时通讯软件以外的新型社交产品了。对于中国人一年一度最重的节日——春节来说,红包是必不可少的节日特定社交元素,而抖音自然不会被错过。在2022年的春节活动期间,抖音将视频和春节红包相结合,用户可以通过拍视频发红包的方式来给粉丝和好友送祝福。本文将要分享的是春节期间海量红包社交活动为抖音所带来的各种技术挑战,以及抖音技术团队是如何在实践中一一解决这些问题的。 学习交流:- 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》- 开源IM框架源码:https://github.com/JackJiang2011/MobileIMSDK(备用地址点此)(本文已同步发布于:http://www.52im.net/thread-3945-1-1.html)2、系列文章《社交软件红包技术解密(一):全面解密QQ红包技术方案——架构、技术实现等》《社交软件红包技术解密(二):解密微信摇一摇红包从0到1的技术演进》《社交软件红包技术解密(三):微信摇一摇红包雨背后的技术细节》《社交软件红包技术解密(四):微信红包系统是如何应对高并发的》《社交软件红包技术解密(五):微信红包系统是如何实现高可用性的》《社交软件红包技术解密(六):微信红包系统的存储层架构演进实践》《社交软件红包技术解密(七):支付宝红包的海量高并发技术实践》《社交软件红包技术解密(八):全面解密微博红包技术方案》《社交软件红包技术解密(九):谈谈手Q春节红包的设计、容灾、运维、架构等》《社交软件红包技术解密(十):手Q客户端针对2020年春节红包的技术实践》《社交软件红包技术解密(十一):最全解密微信红包随机算法(含演示代码)》《社交软件红包技术解密(十二):解密抖音春节红包背后的技术设计与实践》(* 本文)3、先看看红包业务玩法抖音的整个红包活动玩法分为 B2C 和 C2C 两种玩法,下面对这两个玩法的流程简单介绍下,也方便读者理解后续的技术问题和解决思路。3.1 B2C 红包在 B2C 红包玩法中,用户需要先来抖音或者抖 Lite 参加春节红包雨活动,有一定概率在春节红包雨活动中获得红包补贴。用户可以在获得补贴后直接跳转到相机页面,或者在之后拍摄视频跳转到相机页面,在相机页面用户拍摄完视频后会看到一个红包挂件,在挂件中可以看到已发放补贴。用户选择补贴后点击下一步完成投稿后即可完成视频红包的发放。如上图所示,从左至左:“图1”是春节红包雨活动、“图 2”是红包补贴 、“图 3”是红包挂件、“图4”是B2C的红包发送 tab 页。3.2 C2C 红包在 C2C 红包玩法中,用户拍摄视频点击挂件,填写红包的金额和个数信息,选择红包的领取范围后,点击发送红包会拉起收银台,用户支付完成后点击下一步发布视频,即可完成 C2C 红包的发放。如上图所示,从左至左:“图1”是C2C 红包发送 Tab 页 、“图2”是支付界面、“图3”是红包支付后挂件展示。3.3 红包领取B2C 和 C2C 红包的领取流程都是一样的。用户在抖音刷视频遇到有视频红包的视频时,视频下方有个领取红包按钮,用户点击红包领取,会弹出到红包封面。用户点击红包封面的开红包后即可领取红包,领取成功后会显示领取结果弹窗,在领取结果中用户可以看领取金额,以及跳转到领取详情页,在领取详情页中可以看到这个红包其他用户的领取手气。如上图所示,从左至左:“图1”是红包视频、“图2”是红包封面、“图3”是红包领取结果、“图4”是红包领取详情。最后,C2C视频红包有个推广视频,大家可以更形象地了解下整体操作流程:(点击下方视频可以播放 ▼)4、技术挑战4.1 通用红包系统的设计问题在上节中有提到,本次春节活动(指的是2022年春节)需要同时支持 B2C 和 C2C 两种类型的红包。这两个类型的红包既有一些相似的业务,也有很多不同的业务。在相同点上:他们都包括红包的发放和领取这两个操作。在不同点上:比如 B2C 的红包发放需要通过使用补贴来发送,而 C2C 的红包发放需要用户去完成支付。B2C 的红包用户领取后需要去提现,而 C2C 的红包用户领取后直接到零钱。因此需要设计一个通用的红包系统来支持多种红包类型。另外:对于红包系统本身而言,除了发领红包外,还涉及到一些红包信息的查询,以及各种状态机的推进,这些功能模块之间如何划分也是需要考虑的一个点。4.2 大流量补贴的发放处理问题前面提到过:B2C 红包玩法会先进行补贴的发放。在春节活动期间,每场红包雨都会有大量的用户进入参与,如果将这些流量直接打到数据库,将需要大量的数据库资源。而春节期间数据库的资源是非常稀缺的,如何减少这部分的资源消耗也是一个需要考虑的问题。4.3 红包领取方案的选型问题在红包业务中,领取是一个高频的操作。在领取方式的设计中,需要业务场景考虑一个红包是否会被多个用户同时领取。多个用户同时去领取同一个红包可能会导致热点账户问题,成为系统性能的瓶颈。解决热点账户问题也有多个方案,我们需要结合视频红包的业务场景特点来选取合适的方案。4.4 稳定性容灾问题在本次春节活动中,包括 B2C 和 C2C 两种业务流程,其中每个业务流量链路都依赖很多的下游服务和基础服务。在这种大型活动中,如果出现黑天鹅事件时,如何快速止损,减少对系统的整体影响,是一个必须要考虑的问题。4.5 资金安全保证问题在春节活动期间,B2C 会发放大量的红包补贴,如果补贴发生超发,或者补贴的核销出现问题,一个补贴被多次核销,将会造成大量的资损。另外 C2C 也涉及到用户的资金流入流出,如果用户领取红包后如果发现钱变少了,也可能会造成大量的客诉和资损。因此资金安全这块需要做好充足的准备。4.6 红包系统的压测问题在传统的压测方式中,我们一般会对某个大流量接口进行压测从而得到系统的瓶颈。然而在红包系统中,用户的发领和查都是同时进行的,并且这几个接口之间也是相互依赖的,比如需要先发红包,有了红包后才能触发多个人的领取,领取完成后才可以查看领取详情。如果使用传统的单接口的压测方式,首先 mock 数据会非常困难。和支付相应的压测数据因为涉及实名还需要特殊生成,而且单个接口单个接口的压测很难得到系统的真实瓶颈。因此如何对系统进行全链路的压测从而得到系统准确的瓶颈也是我们需要解决的一个问题。认清了我们要面临的技术挑战和技术难题之后,接下来将分享我们是如何在实践中一一解决这些问题的。5、通用红包系统的设计实践对于红包系统,核心包括三个操作:1)红包发送;2)红包的领取;3)未领取红包的退款。另外:我们还会需要去查一些红包的信息和领取的信息等。对于发送、领取和退款这三个核心操作,我们需要对它们的状态进行一个维护。同时在我们的业务场景中,还存在 B2C 特有的补贴的发放,我们也需要维护补贴的状态。在上面初步介绍红包系统后,可以看到红包的几个功能模块:1)发放;2)领取;3)退款;4)补贴发放;5)各种信息查询;6)状态机的维护等。对红包的功能进行梳理后,我们开始对红包的模块进行划分。模块划分原则:1)功能内聚,每个系统只处理一个任务(方便之后系统的开发和迭代,以及问题的排查);2)API 网关层只进行简单的 proxy 处理;3)异步任务拆解;4)读写分离,将红包的核心操作和红包的查询分成两个服务。模块划分结果:1)红包网关服务:HTTP API 网关,对外对接客户端和 h5,对内封装各个系统 rpc 接口,限流,权限控制、降级等功能;2)红包核心服务:主要承载红包核心功能,包括红包的发放、领取、退款,以及红包补贴的发放,维护红包状态机,红包的状态推进;3)红包查询服务:主要承载红包查询功能,包括红包详情、红包发送状态、红包领取状态、红包领取详情、红包补贴信息;4)红包异步服务:主要承载红包异步任务,保证状态机的流转,包括红包的转账,红包的退款,以及红包补贴的状态推进;5)红包基础服务:主要承载红包各个系统的公共调用,例如对 DB,redis、tcc 的操作,公共常量和工具类,相当于红包的基础工具包;6)红包对账服务:主要承载红包和财经的对账逻辑,按天和财经对账。最后整个视频红包的系统架构如图所示:6、大流量补贴的发放处理实践6.1 同步奖励发放在红包补贴发放链路流程中,为了应对春节的大流量,整个链路流程也经历过几次方案的迭代。在最初的方案设计中,我们是按照同步的补贴发放流程来处理的,上游链路调用红包系统接口发券,发券成功后用户感知到券发放成功,可以使用该券来发放红包。最初方案的整体流程如下图:上面方案的一个问题是在春节活动期间,整个链路都需要能扛住活动期间的总流量,而且最终流量都会打到数据库,而数据库的资源在春节期间也是比较紧缺的。6.2 异步奖励发放为了解决同步奖励发放的问题,我们将整体流程改为通过 MQ 进行削峰,从而降低下游的流量压力。相当于是从同步改为异步,用户参与活动后会先下发一个加密 Token 给客户端,用于客户端的展示以及和服务端的交互处理。活动异步发券方案如下图:这样算是解决了大流量的问题,但是相应地引入了其他的问题。。。在最初方案中:用户的红包补贴都会先在红包系统中落库,后续用户对补贴的查询和核销我们都能在红包数据库中找到对应的记录。但是在异步的方式中:整个补贴的入账预估需要 10min,而用户在 APP 界面感知到发券后可能马上就会开始使用用补贴来发放视频红包,或者会去红包挂件查看自己已经领取的红包补贴,而此时补贴还未在红包系统中入账。6.3 最终方案为了解决上面问题,我们对红包补贴的视频红包发放和红包补贴查询的整个逻辑进行了修改。即在用户使用红包补贴进行视频红包发放时,我们会先对该补贴进行一个入库操作,入库成功后才可以用这个补贴进行红包发放。另外对于查询接口:我们无法感知到所有补贴是否完全入账,因此每次查询时我们都需要去奖励发放端查询全量的 Token 列表。同时我们还需要查询出数据库中用户的补贴,对这两部分数据进行一次 merge 操作,才能得到全量的补贴列表。在上面的流程中:为了解决 MQ 异步会有延迟的问题,我们在用户进行请求时主动地进行入账,而用户主动的操作包括使用补贴发放红包和查询补贴。我们为什么只在补贴发放红包时入账而在查询补贴时不入账呢?因为用户的查询行为是一个高频行为,同时涉及到批量的操作,在操作 DB 前我们无法感知该补贴是否入账,所以会涉及到 DB 的批量处理,甚至用户每次来查询时我们都需要重复这个操作,会导致大量的 DB 资源浪费。而补贴的发放时入账则是一个低频的,单个补贴的操作,我们只需要在用户核销时入账即可,这样可以大量减轻数据库的压力,节省数据库资源。7、红包领取方案的选型实践在视频红包领取的技术方案中,我们也有一些方案的选择和思考,这里和大家分享下。7.1 悲观锁方案如上图所示:也是最常见的思路(我们称为方案一),在用户领取时对数据库的红包进行加锁,然后扣减金额,然后释放锁完成整个红包领取。这个方案的优点是清晰明了,但是这种方案的问题会导致多个用户同时来领取红包时,会造成数据库行锁的冲突,需要排队等待。当排队请求过多时会造成数据库链接的浪费,影响整体系统的性能。同时在上游长时间未收到反馈导致超时,用户侧可能会不停重试,导致整体数据库链接被耗尽,从而导致系统崩溃。7.2 红包预拆分方案方案一的问题是多个用同时领取会造成锁冲突,不过解锁锁冲突可以通过拆分的方式,来将锁化成更细的粒度,从而提高单个红包的领取并发量。具体方案如下(我们称为方案一):在方案二中,对发红包的流程进行了一个改动,即在发红包时会对红包进行一个预拆分的处理,将红包拆成多个红包,这样就完成了锁粒度的细化,在用户领取红包时从之前的争抢单个红包锁变为现在多个红包锁分配。从而在领取红包时问题就变为如何给用户分配红包。一种常用的思路是当用户请求领取红包时,通过 redis 的自增方法来生成序列号,该序列号即对应该领取那一个红包。但是这种方式强依赖 redis,在 redis 网络抖动或者 redis 服务异常时,需要降级到去查询 DB 还未领取的红包来获取序列号,整体实现比较复杂。7.3 最终方案在视频红包的场景中,整个业务流程是用户拍摄视频发红包,然后在视频推荐 feed 流中刷到视频时,才会触发领取。相对于微信和飞书这种典型IM即时通讯的群聊场景,视频红包中同一个红包的领取并发数并不会很高,因为用户刷视频的操作以及 feed 流本身就完成了流量的打散。所以对于视频红包来说,领取的并发数并不会很高。从业务的角度来看:在需求实现上,我们在用户领取完成后需要能获取到未领取红包的个数信息下发给用户展示。方案一获取红包库存很方便,而方案二获取库存比较麻烦。另外从系统开发复杂度和容灾情况看:方案一相对来说是一个更合适的选择。但是方案一中的风险我们需要处理下。我们需要有其他的方式来保护 DB 资源,尽量减少锁的冲突。具体方案如下:1)红包 redis 限流:为尽可能少的减少 DB 锁冲突,首先会按照红包单号进行限流,每次允许剩余红包个数*1.5 的请求量通过。被限流返回特殊错误码,前端最多轮训 10 次,在请求量过多的情况下通过这种方式来慢慢处理。2)内存排队:除了 redis 限流外,为了减少 DB 锁,我们在领取流程中加个一个红包内存锁。对于单个红包,只有获取到内存锁的请求才能继续去请求 DB,从而将 DB 锁的冲突迁移到内存中提前处理,而内存资源相对于 DB 资源来说是非常廉价的,在请求量过大时,我们可以水平扩容。为了实现内存锁,我们进行了几个改动。首先:需要保证同一个红包请求能打到同一个 tce 实例上,这里我们对网关层路由进行了调整,在网关层调用下游服务时,会按照红包单号进行路由策略,保证同一单号的请求打到同一个实例上。另外:我们在红包系统的 core 服务中基于 channel 实现了一套内存锁,在领取完成后会释放该红包对应的内存锁。最后:为了防止锁的内存占用过大或者未及时释放,我们起了一个定时任务去定期地处理。3)转账异步化:从接口耗时来看:转账是一个耗时较长的操作,本身涉及和第三方支付机构交互,会有跨机房请求,响应延时较长。将转账异步化可以降低领取红包接口的时延,提高服务性能和用户体验。从用户感知来看:用户更关注的是领取红包的点击开后是否领取成功,至于余额是否同步到账用户其实感知没那么强烈。另外:转账本身也是有一个转账中到转账成功的过程,将转账异步化对于用户的感知基本没有影响。8、稳定性容灾实践8.1 概述整个红包系统的容灾,我们主要从以下3个方式来进行的:1)接口限流;2)业务降级;3)多重机制保证状态机的推进。如下图所示:下面对这几个方式分别介绍下。8.2 接口限流接口限流是一种常见的容灾方式,用于保护系统只处理承受范围内的请求,防止外部请求过大将系统打崩。在进行接口限流前,我们首先需要和上下游以及产品沟通得到一个预估的红包发放和领取量,然后根据发放和领取量进行分模块地全链路的大盘流量梳理。下面是当时我们梳理的一个 b2c 全链路的请求量:有个各个模块的请求量后,汇总之后就可以得到各个接口,红包系统各个服务以及下游依赖的各个服务的流量请求,这个时候再做限流就比较方便了。8.3 业务降级8.3.1)核心依赖降级:在春节活动期间,红包系统整个链路依赖的服务有很多,这些下游的链路依赖可以分为核心依赖和非核心依赖。当下游核心服务异常时,可能某一个链路就不可用,此时可以在 API 层直接降级返回一个比较友好的文案提示,等下游服务恢复后再放开。比如在 C2C 的红包发送流程中,用户需要完成支付才可以发红包,如果财经的支付流程异常或者支付成功状态长时间未完成,会造成用户支付后红包发送不成功,也会导致前端来不停的轮训查询红包状态,导致请求量陡增,造成服务压力,甚至影响 B2C 的红包发放和查询。此时可以通过接口降级的方式,将 C2C 的红包发放降级返回,减少服务压力,同时降低对其他业务逻辑的影响。8.3.2)非核心依赖降级:除核心依赖外,红包系统还有一些非核心的下游依赖,对于这些依赖,如果服务出现异常,我们可以降低用户部分体验的方式来保证服务的可用。比如在前面我们提到的,用户在发 B2C 红包前需要先获取所有可用的红包补贴,我们会去奖励发放端查询到所有的 Token 列表,然后查询我们自己的 DB,然后进行 merge 返回。如果获取 Token 列表的接口异常时,我们可以降级只返回我们自己 DB 中的补贴数据,这样可以保证用户在这种情况下还可以进行红包的发放,只影响部分补贴的展示,而不是影响整个红包发送链路。8.4 多重机制保证状态机的推进在红包系统中,如果某个订单长时间未到终态,比如用户领取红包后长时间未到账,或者用户 C2C 红包未领取长时间未给用户退款都有可能造成用户的客诉。因此需要及时准确地保证系统中各个订单的状态能推到终态。这里我们有几种方式去保证。首先是回调,在依赖方系统订单处理完后会及时地通知给红包系统,这种方式也是最及时的一种方式。但是只依赖回调可能会出现依赖方异常或者网络抖动导致回调丢失,此时我们在红包的各个阶段都会给红包系统发一个 mq,间隔一定的时间去消费 mq 主动查询依赖方的订单状态进行更新。最后,我们对每个状态机都会有一个定时任务用于兜底,在定时任务多次执行仍未到终态的会 lark 通知,及时人工介入发现问题。9、资金安全保证实践9.1 交易幂等在编程中,幂等指任意多次执行一个请求所产生的影响与一次执行的影响相同。在资金安全中,通过订单号来进行相应的幂等逻辑处理可以防止资损的发生。具体来说:在红包系统中,在红包的发放、领取和退款中,我们都通过订单号唯一键来保证接口幂等。另外:红包系统的补贴发放接口是幂等的,外部同一个单号多次请求发放补贴,我们需要保证只会发一张券。实现幂等的方案很多:包括有通过数据库或者 redis 来实现幂等的。最可靠的就是通过数据库的唯一键冲突来实现,但是这种方式在数据库存在分片实例时会引入一些额外的问题。这里:我们就补贴的发放来简单介绍下,在业务系统的设计中,我们是按照 uid 分片的方式来建立业务的数据库表,这就导致补贴的分片键是 uid,虽然我们也设置了红包的补贴单号作为唯一键。但是其中存在一个风险就是如果上游的系统调用补贴发放时,同一个外部单号更换了 uid,就可能会导致两个请求分别打到不同的数据库实例上,导致唯一索引失效,造成资损。为了解决这个问题,我们又额外的引入一个以补贴发放外部单号作为分片键的数据库来解决这个风险。9.2 B2C 红包核对除了在开发过程的系统设计上进行相应的资金安全考虑,我们还需要通过对账的方式来校验我们的系统是否有资金安全问题。在 B2C 链路中,整个链路主要是从补贴发放到红包领取,我们对这几个链路的上下游的数据都进行相应的小时计 hive 对账。9.3 C2C 红包核对在 C2C 链路中,整个主要从用户发起支付,到用户领取转账以及最后红包过期退款。在支付、转账、退款这三个流程都需要进行相应的核对。同时:还需要保证用户的红包发放金额大于等于红包转账金额+红包退款金额,这里大于等于是因为红包从发放成功到退款成功整个周期会在 24h 以上。另外:可能存在转账在途的这种订导致会有多笔退款单,如果要求严格等于的话具体对账时机没法控制。10、红包系统的压测实践10.1 概述前面提到过,红包系统的链路包含有多个接口,发领查等,需要模拟用户的真实行为来进行压测才能得到系统的真实性能。这里我们使用了压测平台的脚本压测方式来进行压测。首先:需要对整个压测链路整个改造,和上下游沟通是否可以压测,不能压测的需要进行相应的 mock 处理。另外:对于存储服务,数据库,redis 和 mq 都要确保压测标的正确传递,否则可能会影响到线上。改造完压测链路后,需要构造相应的压测脚本,对于 B2C 和 C2C 分为两个脚本。10.2 B2C 红包链路压测 上面是 B2C 压测的整个链路,首先是补贴的发放,然后通过查询补贴,通过补贴来发放红包,为了模拟多人来领取的情况,我们起了多个 goroutinue 来并发的领取红包。10.3 C2C 红包链路压测C2C 红包因为涉及到支付相关的操作,整个链路又是另外一套流程,因此对于 C2C 也需要有一个单独的脚本。在压测流程中,因为涉及到外部系统的依赖,如果等待全链路 OK 时再一起压测可能会导致一些未知的问题出现。因此我们需要自己压测没问题后再开始全链路一起压测,在图中和支付相关的蓝色模块我们都添加了相应的 mock 开关,来控制压测的结果。在 mock 开关打开时,会直接构造一个结果返回,在 mock 开关关闭时,会正常地去请求财经获取结果。11、后续规划(服务 Set 化)在前面提到的系统容灾中,如果红包核心服务改掉,或者数据库 DB 主机房挂掉,将影响所有的用户。此时只能降级返回,整个系统无法快速切换和恢复。后续考虑将服务改为 set 化的架构。即将服务 Server 和对应的存储划分为一个单独的 Set,每个 Set 只处理对应划分单元内的流量,同时多个单元之间实现流量拆分和故障隔离,以及 Set 之间数据备份。这样后续在某个单元异常时,可以及时将对应单元的流量切到备份单元中。12、更多资料[1] 一套亿级用户的IM架构技术干货(上篇):整体架构、服务拆分等[2] 一套亿级用户的IM架构技术干货(下篇):可靠性、有序性、弱网优化等[3] 从新手到专家:如何设计一套亿级消息量的分布式IM系统[4] 阿里技术分享:电商IM消息平台,在群聊、直播场景下的技术实践[5] 一套高可用、易伸缩、高并发的IM群聊、单聊架构方案设计实践[6] 一套海量在线用户的移动端IM架构设计实践分享(含详细图文)[7] 一套原创分布式即时通讯(IM)系统理论架构方案[8] 新手入门一篇就够:从零开发移动端IM(本文已同步发布于:http://www.52im.net/thread-3945-1-1.html)
本文由B站微服务技术团队资深开发工程师周佳辉原创分享。1、引言如果你在 2015 年就使用 B 站,那么你一定不会忘记那一年 B 站工作日选择性崩溃,周末必然性崩溃的一段时间。也是那一年 B 站投稿量激增,访问量随之成倍上升,而过去的 PHP 全家桶也开始逐渐展露出颓势,运维难、监控难、排查故障难、调用路径深不见底。也就是在这一年,B 站开始正式用 Go 重构 B 站,从此B站的API网关技术子开始了从0到1的持续演进。。。* 补充说明:本次 API 网关演进也以开源形式进行了开发,源码详见本文“12、本文源码”。PS:本文分享的API网关涉及到的主要是HTTP短连接,虽然跟长连接技术有些差异,但从架构设计思路和实践上是一脉相承的,所以也就收录到了本《长连接网关技术专题》系列文章中。学习交流:- 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》- 开源IM框架源码:https://github.com/JackJiang2011/MobileIMSDK(备用地址点此)(本文已同步发布于:http://www.52im.net/thread-3941-1-1.html)2、关于作者周佳辉:哔哩哔哩资深开发工程师。始终以简单为核心的技术设计理念,追求极致简单有效的后端架构。2017 年加入 B 站,先后从事账号、网关、基础库等开发工作。编码 C/V 技能传授者,技术文档背诵者。开源社区爱好者,安全技术爱好者,云计算行业活跃用户,网络工程熟练工。史诗级 bug 生产者,熟练掌握 bug 产生的各类场景。3、专题目录本文是专题系列文章的第8篇,总目录如下:《长连接网关技术专题(一):京东京麦的生产级TCP网关技术实践总结》《长连接网关技术专题(二):知乎千万级并发的高性能长连接网关技术实践》《长连接网关技术专题(三):手淘亿级移动端接入层网关的技术演进之路》《长连接网关技术专题(四):爱奇艺WebSocket实时推送网关技术实践》《长连接网关技术专题(五):喜马拉雅自研亿级API网关技术实践》《长连接网关技术专题(六):石墨文档单机50万WebSocket长连接架构实践》《长连接网关技术专题(七):小米小爱单机120万长连接接入层的架构演进》《长连接网关技术专题(八):B站基于微服务的API网关从0到1的演进之路》(* 本文)4、正式用Go重构B站鉴于引言中所列举的各种技术问题,也是在2015年,财队开始正式用 Go 重构 B 站。B站第一个 Go 项目——bilizone,由冠冠老师(郝冠伟)花了一个周末时间编码完成。commit 4ccb1497ca6d94cec0ea1b2555dd1859e6f4f223Author: felixhao <g******[url=mailto:1@gmail.com]1@gmail.com[/url]>Date: Wed Jul 1 18:55:00 2015 +0800 project initcommit 6e338bc0ee638621e01918adb183747cf2a9e567Author: 郝冠伟 <h*******@bilibili.com>Date: Wed Jul 1 11:21:18 2015 +0800 readme▲ 郝冠伟:哔哩哔哩主站技术中心架构师bilizone 其实还是一个大而全的应用,bilizone 在当时重构的主要意义是将谁也理不清的 PHP 逻辑梳理成了一个比较标准的 Go 应用。bilizone 在当时最大的意义就是为用户终端提供了基本稳定的数据结构、相对可靠的接口和比较有效的监控。但因 bilizone 依旧是一个单体应用,所以它依旧继承了单体应用所具有的缺点:1)代码复杂度高:方法被滥用、超时设置混乱、牵一发而动全身;2)一挂全挂:最常见的比如,超时设置不合理、goroutine 大量堆积、雪崩;3)测试及维护成本高:小改动都需要测试所有 case,运维发布胆战心惊。所以此时B站的崩溃频率虽然已经有所降低,但一炸全炸的问题依旧是一个心腹大患。5、基于微服务的B站架构初具雏形鉴于bilizone所面临的单体应用技术缺点,接下来的一次重构,让B站基于微服务的全局架构面貌就将初具雏形。为了实现微服务模式下的 bilibili,我们将一个 bilizone 应用拆分成多个独立业务应用,如账号、稿件、广告等等,这些业务通过 SLB 直接对外提供 API。当时的调用模式如下图所示:但是随着功能拆分后,我们对外暴露了一批微服务,但是因为缺乏统一的出口而面临了不少困难。这些困难主要是:1)客户端与微服务直接通信,强耦合;2)需要多次请求,客户端聚合数据,工作量巨大,延迟高;3)协议不利于统一,各个部门间有差异,反而需要通过客户端来兼容;4)面向“端”的 API 适配,耦合到了内部服务;5)多终端兼容逻辑复杂,每个服务都需要处理;6)统一逻辑无法收敛,比如安全认证、限流。6、基于BFF模式的微服务架构基于上节的初阶微服务架构带来的技术问题,以及我们想要将对端的处理进行内聚的想法,我们自然的而然的就想到在客户端与后端服务之间加一个 app-interface 的组件,这就是接下来的 BFF(Backend for Frontend)模式。app-interface 的工作模式如下图所示:有了这个 BFF 之后,我们可以在该服务内进行大量的数据聚合,按照业务场景来设计粗粒度的 API。这样,后续服务的演进也带来了很多优势:1)轻量交互:协议精简、聚合;2)差异服务:数据裁剪以及聚合、针对终端定制化 API;3)动态升级:原有系统兼容升级,更新服务而非协议;4)沟通效率提升:协作模式演进为移动业务和网关小组。BFF 可以认为是一种适配服务,将后端的微服务为客户端的需要进行适配(主要包括聚合裁剪和格式适配等逻辑),向终端设备暴露友好和统一的 API,方便无线设备接入访问后端服务,在其中可能还伴随有埋点、日志、统计等需求。然而,这个时期的 BFF 还有一个致命的一个问题是——整个 app-interface 属于 single point of failure,严重代码缺陷或者流量洪峰可能引发集群宕机所有接口不可用。7、基于多套BFF模式的微服务架构针对上节中BFF模式下架构的技术问题,于是我们在上述基础上进一步迭代,将 app-interface 进行业务拆分。进而多套 BFF 的模式横空出世:由此模式开始,基本确定了 B 站微服务接口的对接模式,这套模式也随之在全公司内推广开来。8、垂直BFF模式时代(2016年至2019年)接上节,当 B 站网关的架构发展为多套垂直 BFF 之后,开发团队围绕该模式平稳迭代了相当长的一段时间。而后随着B站业务的发展,团队人员的扩充和几次组织架构调整,此时开始出现直播、电商等独立业务,这些业务的发展我们之后再细说。而在这些调整之后,有一个团队的职责越来越清晰:主站网关组。主站网关组的主要职责就是维护上述各类功能的 BFF 网关,此时 bilibili 的主要流量入口为粉板 App。这里可以简单细说一下粉板 App 上的所有业务组成。主站业务:1)网关组维护的 BFF,如推荐、稿件播放页等;2)业务层自行维护的 BFF,如评论、弹幕、账号等。独立业务:1)电商服务;2)直播服务;3)动态服务。主站业务的 BFF 其实被分为两类:1)一类是由网关组负责的 BFF;2)另一类是业务自行维护的 BFF。而这两类 BFF 的技术栈其实基本一致,基本功能职责也相差不多。如此划分的原因是让网关组可以更专注于迭代客户端特性功能,免去理解部分独立业务场景的接口,如登陆页应该让对安全更专业账号的同学自行维护。在这里我们也可以简述一下,一个新需求应该如何决定参与的 BFF :1)如果这个功能能由业务层的业务 BFF 独立完成,则网关组不需介入;2)如果该功能是一个客户端特性需求,如推荐流等复合型业务,需要对接公司大量部门时,则由网关同学参与开发 BFF。当时主站技术部的后端同学遵循以上两个规则,基本能够满足业务的快速开发和迭代。我把这段时间称为垂直 BFF 时代,因为基本主站每个业务或多或少都有各种形式的网关存在,大家通过这个网关向外提供接口,该网关和 SLB 进行直接交互。9、基于业务的统一API网关架构接上节,我们再来谈一谈几项重要的业务:电商、直播和动态。电商和直播其实并不是同一时期衍生的,直播在主站 PHP 时期就诞生了,而电商相对更晚一些。当时直播的技术栈组成有 C++、PHP、Go,其中早期大部分业务逻辑由 PHP 和 C++ 实现,稍晚一些也开始逐步试用主站的 Go 实现部分业务逻辑。其中 PHP 负责对终端提供接口,C++ 主要实现核心业务功能。因此我们可以简单理解为直播使用由 PHP 编写的 BFF 网关。动态团队其实派生自直播团队,因此技术栈和直播当时基本一致,这里可以简单省略。而众所周知,大部分电商团队的技术栈都是 Java 和 Spring 或 Dubbo。因这几个业务实现上几乎没有相似的地方,且大家对 gRPC 协议逐渐地认同,因此技术栈上大家基本没有大一统的想法,互相能调通即可。而随着 B 站团队进一步的壮大、流量持续的增长,进而经历了诸多线上故障、事故分析之后,大家慢慢发现了这套架构下的各种问题。这些问题主要是:1)单个复杂模块也会导致后续业务集成的高难度,根据康威法则,复杂聚合型 BFF 和多团队之间就出现不匹配问题,团队之间沟通协调成本高,交付效率低下;2)很多跨横切面逻辑,比如安全认证,日志监控,限流熔断等。随着时间的推移,功能的迭代,代码变得越来越复杂,技术债越堆越多。此时:我们可能还需要一个能协调横跨切面的组件,将路由、认证、限流、安全等组件全部上提,能够统一更新发布,把业务集成度高的 BFF 层和通用功能服务层进行分层,进而大家开始引入基于业务的“统一API网关”架构(如下图所示)。在新的架构中:统一网关承担了重要的角色,它是解耦拆分和后续升级迁移的利器。在统一网关的配合下:单块 BFF 实现了解耦拆分,各业务线团队可以独立开发和交付各自的微服务,研发效率大大提升。另外:把跨横切面逻辑从 BFF 剥离到网关上去以后,BFF 的开发人员可以更加专注业务逻辑交付,实现了架构上的关注分离(Separation of Concerns)。10、从基于业务的多网关到全局统一网关(2022年至今)在这两三年的时间里,各个业务团队或多或少都有自己业务网关组建独立的维护团队,也为网关的功能作出过相当多的投入。但随着 B 站业务的发展,公司级中间件功能的不断更替演进,如果将对接各个中间件的工作在每个网关上都实现一次的话带来的人力投入和沟通成本会相当巨大,且实现标准不统一、运营方式不统一无法起到 API 网关所带来的最佳收益。因此微服务团队开发了一款 B 站内部意义上的标准 API 网关(全局统一API网关),该 API 网关汇集以往各型网关中流量治理的优秀经验,对相关功能做出完善设计改进。该 API 网关的目前的主要功能除了常规的限流、熔断、降级、染色外,还会基于这些基础功能和公司各类中间件的基础上,提供各种额外能力。这些额外进阶型AP 质量治理的相关功能主要是:1)全链路灰度;2)流量采样分析、回放;3)流量安全控制;...业务团队在接入 API 网关后都可以一并获得这些功能,为业务的迅速迭代做出力所能及的保障。11、不仅仅是 API 网关在开发 API 网关的同时,我们也会更进一步关注业务团队开发、对接 API 时的体验,我们将以网关作为统一标准 API 规范的起点,为业务团队提供更有效的 API 开发生态。这些API 开发生态可能是:1)规划 API 业务域,简化 SRE 运维;2)标准 API 元信息平台;3)精确的 API 文档和调试工具;4)类型安全的 API 集成 SDK;5)API 兼容性保障服务。API 网关是我们 API 治理生态中的一个标志性里程碑,我们希望在 API 网关的开发中能够多多倾听大家的意见,希望能有更多的声音来帮助我们理清思路。本次 API 网关演进也以开源形式进行了开发,在这里欢迎大家指导(本次源码详见本文“12、本文源码”)。12、本文源码主地址:https://github.com/go-kratos/gateway备地址:https://github.com/52im/gateway或从原文链接中下载附件:http://www.52im.net/thread-3941-1-1.html13、参考资料[1] 喜马拉雅自研亿级API网关技术实践[2] 手淘亿级移动端接入层网关的技术演进之路[3] 从100到1000万高并发的架构演进之路[4] 一文读懂大型分布式系统设计的方方面面[5] 零基础理解大型分布式架构的演进历史、技术原理、最佳实践(本文已同步发布于:http://www.52im.net/thread-3941-1-1.html)
1、HTTP/3终于标准化2022年6月6日,IETF QUIC和HTTP工作组成员Robin Mark在推特上宣布,历时5年,HTTP/3终于被标准化为 RFC 9114,这是HTTP超文本传输协议的第三个主要版本。同时, HTTP/2也被更新为新的 RFC 9113。Robin写道,新发布的HTTP/3标准将与RFC 9204(QPACK header压缩) 和 RFC 9218(可扩展的优先级)一起为Web打开重要的新篇章。2、什么是QUIC协议?QUIC是一种通用、安全、多路复用的传输层新型网络协议。它的目的是替代TCP(目前是互联网上用于数据传输的主流协议)。2012年,QUIC协议由当时还在谷歌任职的Jim Roskind开发。2013年,QUIC正式对外公布。2015年,QUIC被提交给IETF进行标准化,但是直到六年以后,也就是2021年5月,IETF才发布了第一版标准化的QUIC,被命名为RFC 9000。同时,IETF还发布使用了QUIC的HTTP/3标准化版本。QUIC吸纳了很多与TCP类似的属性,还有TLS加密,将它们置于UDP传输之上的应用层中。有关QUIC协议的文章可详细阅读下面几篇,这里不再赘述:[1] 一泡尿的时间,快速读懂QUIC协议:http://www.52im.net/thread-2816-1-1.html[2] 技术扫盲:新一代基于UDP的低延时网络传输层协议——QUIC详解:http://www.52im.net/thread-1309-1-1.html[3] 让互联网更快:新一代QUIC协议在腾讯的技术实践分享:http://www.52im.net/thread-1407-1-1.html[4] 七牛云技术分享:使用QUIC协议实现实时视频直播0卡顿!:http://www.52im.net/thread-1406-1-1.html
本文引用了文章“月活 12.8 亿的微信是如何防止崩溃的?”和论文“Overload Control for Scaling WeChat Microservices”的内容,有大量改动、优化和修订。1、引言微信是一款国民级的即时通讯IM应用,月活用户早就超过10亿,而且经常过年过节会遇到聊天消息量暴增的情况,服务是很容易出现过载的,但事实是微信的后台服务一直比较稳定,那么他们是怎么做到的呢?本文以微信发表的论文《Overload Control for Scaling Wechat Microservices》 为基础(论文PDF原文下载见文末附件),分享了微信基于大规模微服务架构的后台过载管控和保护策略,以及微信根据IM业务特点的一些独特的架构设计做法,其中很多方法很有借鉴意义,值得一读。 (本文已同步发布于:http://www.52im.net/thread-3930-1-1.html)2、微信所面临的并发压力截止论文《Overload Control for Scaling Wechat Microservices》发表前,微信后端有超过3000多个服务(包括即时聊天、社交关系、移动支付和第三方授权等),占用20000多台机器(随着微信的广泛普及,这些数字仍在不断增加)。面向前端请求的入口服务每天需要处理10亿到100亿级别的请求,而每个这样的请求还会触发更多内部的关联服务,从整体来看,微信后端需要每秒处理数亿个请求。随着微信的不断发展,这些服务子系统一直在快速进行更新迭代。以2018 年的3月到5月为例,在短短的两个月时间里,微信的各服务子系统平均每天发生近千次的变更,运维压力可想而之。另外:微信每天请求量的分布很不平均,高峰期请求量能达到平时的3倍。而在特殊日子里(比如过年的时候),高峰期的流量能飙升到平时的10倍。有时朋友圈里有什么刷屏的活动,流量肯定也会突增。由此可见,微信后端系统的并发压力相当之大。而且:微信后端的这些服务所处的环境也是不断变化的,包括硬件故障、代码bug、系统变更等,都会导致服务可承受的容量动态变化。3、微信的后端服务架构微信后端采用的也是微服务架构。说是微服务,其实我理解就是采用统一的 RPC 框架搭建的一个个独立的服务,服务之间互相调用,实现各种各样的功能,这也是现代服务的基本架构。毕竟谁也不希望看到我朋友圈崩了,导致跟我聊天也不行了,这也是微信的典型好处。微信后端的微服务架构一般分为3层:如上图所示,这3层服务分别是:1)“入口跳板”服务(接收外部请求的前端服务);2)“共享跳板”服务(中间层协调服务);3)“基础服务”(不再向其他服务发出请求的服务,也就是充当请求的接收器)。微信后端的大多数服务属于“共享跳板”服务,“入口跳板”服务比如登录、发送聊天消息、支付服务等。“基础服务”也就是日常最好理解的这些信息数据接口类,比如账户数据、个人信息、好友/联系人信息等。按照微信后端服务的请求量(每日在十亿到百亿之间),入口协议触发对“共享跳板”服务和“基础服务”更多的请求,核心服务每秒要处理上亿次的请求,也就是显而易见的了。4、什么是过载保护1)什么是服务过载?服务过载就是服务的请求量超过服务所能承受的最大值,从而导致服务器负载过高,响应延迟加大。用户侧表现就是无法加载或者加载缓慢,这会引起用户进一步的重试,服务一直在处理过去的无效请求,导致有效请求跌 0,甚至导致整个系统产生雪崩。2)为什么会发生服务过载?互联网天生就会有突发流量、秒杀、抢购、突发大事件、节日甚至恶意攻击等,都会造成服务承受平时数倍的压力,比如微博经常出现某明星官宣结婚或者离婚导致服务器崩溃的场景,这就是服务过载。3)过载保护的好处过载保护主要是为了提升用户体验,保障服务质量,在发生突发流量时仍然能够提供一部分服务能力,而不是整个系统瘫痪。系统瘫痪就意味着用户流失、口碑变差、夫妻吵架,甚至威胁生命安全(假如腾讯文档崩溃,这个文档正好用于救灾)。而微信团队在面对这种量级的高并发请求挑战,做法是精细化的服务过载控制。我们继续往下学习。5、微信面临的过载控制技术挑战过载控制对于大规模在线应用程序来说至关重要,这些应用程序需要在不可预测的负载激增的情况下实现 24×7 服务可用性。传统的过载控制机制是为具有少量服务组件、相对狭窄的“前门”和普通依赖关系的系统而设计的。而微信这种现代即时通讯im应用的全时在线服务特性,在架构和依赖性方面正变得越来越复杂,远远超出了传统过载控制的设计目标。这些技术痛点包括:1)由于发送到微信后端的服务请求没有单一的入口点,因此传统的全局入口点(网关)集中负载监控方法并不适用;2)特定请求的服务调用图可能依赖于特定于请求的数据和服务参数,即使对于相同类型的请求也是如此(因此,当特定服务出现过载时,很难确定应该限制哪些类型的请求以缓解这种情况);3)过多的请求中止浪费了计算资源,并由于高延迟而影响了用户体验;4)由于服务的调用链极其复杂,而且在不断演化,导致有效的跨服务协调的维护成本和系统开销过高。由于一个服务可能会向它所依赖的服务发出多个请求,并且还可能向多个后端服务发出请求,因此我们必须特别注意过载控制。我们使用一个专门的术语,叫作“后续过载”,用于描述调用多个过载服务或多次调用单个过载服务的情况。“后续过载”给有效的过载控制带来了挑战。当服务过载时随机执行减载可以让系统维持饱和的吞吐量,但后续过载可能会超预期大大降低系统吞吐量 …即:在大规模微服务场景下,过载会变得比较复杂,如果是单体服务,一个事件只用一个请求,但微服务下,一个事件可能要请求很多的服务,任何一个服务过载失败,就会造成其他的请求都是无效的。如下图所示。 比如:在一个转账服务下,需要查询分别两者的卡号, 再查询 A 时成功了,但查询 B 失败,对于查卡号这个事件就算失败了。比如查询成功率只有 50%, 那对于查询两者卡号这个成功率只有 50% * 50% = 25% 了, 一个事件调用的服务次数越多,那成功率就会越低。6、微信的过载控制机制微信的微服务过载控制机制叫“DAGOR”(因为微信把它的服务间关系模型叫“directed acyclic graph ”,简称DAG)。显然这种微服务底层的机制必须是和具体的业务实现无关的。DAGOR还必须是去中心化的,否则的话在微信这么大且分布不均的流量下,过载控制很难做到实时和准确。同时也无法适应微服务快速的功能迭代发布(平均每天要发生近1000次的微服务上下线)。此外,DAGOR还需要解决一个问题:服务调用链很长,如果底层服务因为过载保护丢弃了请求,上层服务耗费的资源全浪费了,而且很影响用户体验(想想进度条走到99%告诉你失败了)。所以过载控制机制在各服务之间必须有协同作用,有时候需要考虑整个调用链的情况。首先我们来看怎么检测到服务过载。7、微信如何判断过载通常判断过载可以使用吞吐量、延迟、CPU 使用率、丢包率、待处理请求数、请求处理事件等等。微信使用在请求在队列中的平均等待时间作为判断标准。平均等待时间就是从请求到达,到开始处理的时间。为啥不使用响应时间?因为响应时间是跟服务相关的,很多微服务是链式调用,响应时间是不可控的,也是无法标准化的,很难作为一个统一的判断依据。那为什么也不使用 CPU 负载作为判断标准呢? 因为 CPU 负载高不代表服务过载,因为一个服务请求处理及时,CPU 处于高位反而是比较良好的表现。实际上 CPU 负载高,监控服务是会告警出来,但是并不会直接进入过载处理流程。腾讯微服务默认的超时时间是 500ms,通过计算每秒或每 2000 个请求的平均等待时间是否超过 20ms,判断是否过载,这个 20ms 是根据微信后台 5 年摸索出来的门槛值。采用平均等待时间还有一个好处是:独立于服务,可以应用于任何场景,而不用关联于业务,可以直接在框架上进行改造。当平均等待时间大于 20ms 时,以一定的降速因子过滤调部分请求,如果判断平均等待时间小于 20ms,则以一定的速率提升通过率,一般采用快降慢升的策略,防止大的服务波动,整个策略相当于一个负反馈电路。8、微信的过载控制策略微信后台一旦检测到服务过载,就需要按照一定的过载保户策略对请求进行过滤控制,来决定哪些请求能被过载服务处理,哪些是需要丢弃的。前面我们分析过,对于链式调用的微服务场景,随机丢弃请求会导致整体服务的成功率很低。所以请求是按照优先级进行控制的,优先级低的请求会优先丢弃。那么从哪些维度来进行优化级的分级呢?8.1 基于业务的优先级控制对于微信来说,不同的业务场景优先级是不同的, 比如:1)登录场景是最重要的业务(不能登录一切都白瞎);2)支付消息比普通im聊天消息优先级高(因为用户对金钱是更敏感的);3)普通消息又比朋友圈消息优先级高(必竟微信的本质还是im聊天)。所以在微信内是天然存在业务优先级的。微信的做法是,预先定义好所有业务的优先级并保存在一个Hash Table里:没有定义的业务,默认是最低优先级。业务优先级在各个业务的入口服务(Entry Services)中找到请求元信息里。由于一个请求成功与否依赖其下游服务所有的后续请求,所以下游服务的所有后续请求也会带上相同的业务优先级。当服务过载时,会处理优先级更高的请求,丢弃优先级低的请求。然而,只用业务优先级决定是否丢弃请求,容易造成系统颠簸,比如:1)支付请求突然上涨导致过载,消息请求被丢弃;2)丢弃消息请求后,系统负载降低了,又开始处理消息请求;3)然而,处理消息请求又导致服务过载,又会在下一个窗口抛弃消息请求。这样反复调整服务请求管制,整体体验非常不好。所以微信需要更精细化的服务请求管制。PS:微信尝试过提供API让服务提供方自己修改业务优先级,后来在实践中发现这种做法在不同的团队中极难管理,且对于过载控制容易出错,最终放弃了。8.2 基于用户的优先级控制很明显,正如上节内容所述,只基于业务优先级的控制是不够的:1)首先不可能因为负载高,丢弃或允许通过一整个业务的请求,因为每个业务的请求量很大,那一定会造成负载的大幅波动;2)另外如果在业务中随机丢弃请求,在过载情况下还是会导致整体成功率很低。为了解决这个问题,微信引入用户优先级。微信在每个业务优先级内按用户ID计算出的128个优先级:首先用户优先级也不应该相同,对于普通人来说通过 hash 用户唯一 ID计算用户优先级(这个hash函数每小时变一次,让所有用户都有机会在相对较长的时间内享受到高优先级,保证“公平”)。跟业务优先级一样,单个用户的访问链条上的优先级总是一致的。这里有个疑问:为啥不采用会话 ID 计算优先级呢?从理论上来说采用会话 ID 和用户 ID 效果是一样的,但是采用会话 ID 在用户重新登录时刷新,这个时候可能用户的优先级可能变了。在过载的情况下,他可能因为提高了优先级就恢复了。这样用户会养成坏习惯,在服务有问题时就会重新登录,这样无疑进一步加剧了服务的过载情况。于是,因为引入了用户优先级,那就和业务优先级组成了一个二维控制平面。根据负载情况,决定这台服务器的准入优先级(B,U),当过来的请求业务优先级大于 B,或者业务优先级等于 B,但用户优先级高于 U 时,则通过,否则决绝。下图就是这个“优先级(B,U)”控制逻辑(我们会在后面再具体讨论):8.3 自适应优先级调整在大规模微服务场景下,服务器的负载变化是非常频繁的。所以服务器的准入优先级是需要动态变化的,微信分了几十个业务优先级,每个业务优先级下有 128 个用户优先级,所以总的优先级是几千个。如何根据负载情况调整优先级呢?最简单的方式是从右到左遍历:每调整一次判断下负载情况。这个时间复杂度是 O(n), 就算使用二分法,时间复杂度也为 O(logn),在数千个优先级下,可能需要数十次调整才能确定一个合适的优先级,每次调整好再统计优先级,可能几十秒都过去了,这个方法无疑是非常低效的。微信提出了一种基于直方图统计的方法快速调整准入优先级:服务器上维护者目前准入优先级下,过去一个周期的(1s 或 2000 次请求)每个优先级的请求量。当过载时,通过消减下一个周期的请求量来减轻负载。假设上一个周期所有优先级的通过的请求总和是 N,下一个周期的请求量要减少 N*a,怎么去减少呢?每提升一个优先级就减少一定的请求量,一直提升到 减少的数目大于目标量,恢复负载使用相反的方法,只不是系数为 b ,比 a 小,也是为了快降慢升。根据经验值 a 为 5%,b 为 1%。为了进一步减轻过载机器的压力,能不能在下游过载的情况下不把请求发到下游呢?否则下游还是要接受请求、解包、丢弃请求,白白的浪费带宽,也加重了下游的负载。为了实现这个能力:在每次请求下游服务时,下游把当前服务的准入优先级返回给上游,上游维护下游服务的准入优先级,如果发现请求优先级达不到下游服务的准入门槛,直接丢弃,而不再请求下游,进一步减轻下游的压力。9、实验数据微信的这套服务过载控制策略(即DAGOR)在微信的生产环境已经运作多年,这是对它的设计可行性的最好证明。但并没有为学术论文提供必要的图表,所以微信同时进行了一组模拟实验。下面的图表突出显示了基于排队时间而非响应时间的过载控制的好处。在发生后续过载的情况下,这些好处最为明显(图右)。10、小结一下微信的整个过载控制逻辑流程如下图所示:针对上面这张图,我们来解读一下:1)当用户从微信发起请求,请求被路由到接入层服务,分配统一的业务和用户优先级,所有到下游的字请求都继承相同的优先级;2)根据业务逻辑调用 1 个或多个下游服务,当服务收到请求,首先根据自身服务准入优先级判断请求是接受还是丢弃(服务本身根据负载情况周期性的调整准入优先级);3)当服务需要再向下游发起请求时,判断本地记录的下游服务准入优先级(如果小于则丢弃,如果没有记录或优先级大于记录则向下游发起请求);4)下游服务返回上游服务需要的信息,并且在信息中携带自身准入优先级;5)上游接受到返回后解析信息,并更新本地记录的下游服务准入优先级。微信的整个过载控制策略有以下三个特点:1)业务无关的:使用请求等待时间而不是响应时间,制定用户和业务优先级,这些都与业务本身无关;2)高效且公平: 请求链条的优先级是一致的,并且会定时改变 hash 函数调整用户优先级,过载情况下,不会总是影响固定的用户;3)独立控制和联合控制结合:准入优先级取决于独立的服务,但又可以联合下游服务的情况,优化服务过载时的表现。11、写在最后微信团队的分享只提到过载控制,但我相信服务调用方应该还有一些其他机制,能够解决不是因为下游服务过载,而是因为网络抖动导致的请求超时问题。微信的这套微服务过载控制机制(即DAGOR)提供的服务无关、去中心化、高效和公平等特性很好地在微信后端跑了很多年。最后,微信团队还分享了他们设计和运维DAGOR宝贵经验:1)大规模微服务架构中的过载控制必须在每个服务中实现分散和自治;2)过载控制应该要考虑到各种反馈机制(例如 DAGOR 的协作准入控制),而不是仅仅依赖于开环启发式;3)应该通过分析实际工作负载来了解过载控制设计。12、参考资料[1] Overload Control for Scaling WeChat Microservices[2] 罗神解读“Overload Control for Scaling WeChat Microservices”[3] 2W台服务器、每秒数亿请求,微信如何不“失控”?[4] DAGOR:微信微服务过载控制系统[5] 月活 12.8 亿的微信是如何防止崩溃的?[6] 微信朋友圈千亿访问量背后的技术挑战和实践总结[7] QQ 18年:解密8亿月活的QQ后台服务接口隔离技术[8] 微信后台基于时间序的海量数据冷热分级架构设计实践[9] 架构之道:3个程序员成就微信朋友圈日均10亿发布量[有视频]》[10] 快速裂变:见证微信强大后台架构从0到1的演进历程(一)[11] 一份微信后台技术架构的总结性笔记》13、论文原文论文PDF请下载此附件:(因无法上传附件,请从此链接:http://www.52im.net/thread-3930-1-1.html文末的“参考资料”附件中下载)论文PDF全部内容概览:(本文已同步发布于:http://www.52im.net/thread-3930-1-1.html)
本文由蘑菇街前端开发工程师“三体”分享,原题“蘑菇街云端直播探索——启航篇”,有修订。1、引言随着移动网络网速的提升与资费的降低,视频直播作为一个新的娱乐方式已经被越来越多的用户逐渐接受。特别是最近这几年,视频直播已经不仅仅被运用在传统的秀场、游戏类板块,更是作为电商的一种新模式得到迅速成长。本文将通过介绍实时视频直播技术体系,包括常用的推拉流架构、传输协议等,让你对现今主流的视频直播技术有一个基本的认知。学习交流:- 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》- 开源IM框架源码:https://github.com/JackJiang2011/MobileIMSDK(备用地址点此)(本文已同步发布于:http://www.52im.net/thread-3922-1-1.html)2、蘑菇街的直播架构概览目前蘑菇街直播推拉流主流程依赖于某云直播的服务。云直播提供的推流方式有两种:1)一是通过集成SDK的方式进行推流(用于手机端开播);2)另一种是通过RTMP协议向远端服务器进行推流(用于PC开播端或专业控台设备开播)。除去推拉流,该云平台也提供了云通信(IM即时通讯能力)和直播录制等云服务,组成了一套直播所需要的基础服务。3、推拉流架构1:厂商SDK推拉流如上题所示,这一种推拉流架构方式需要依赖腾讯这类厂商提供的手机互动直播SDK,通过在主播端APP和用户端APP都集成SDK,使得主播端和用户端都拥有推拉流的功能。这种推拉流架构的逻辑原理是这样的:1)主播端和用户端分别与云直播的互动直播后台建立长连接;2)主播端通过UDT私有协议向互动直播后台推送音视频流;3)互动直播后台接收到音视频流后做转发,直接下发给与之建立连接的用户端。这种推拉流方式有几点优势:1)只需要在客户端中集成SDK:通过手机就可以开播,对于主播开播的要求比较低,适合直播业务快速铺开;2)互动直播后台仅做转发:没有转码,上传CDN等额外操作,整体延迟比较低;3)主播端和用户端都可以作为音视频上传的发起方:适合连麦、视频会话等场景。4、推拉流架构2:旁路推流之前介绍了通过手机SDK推拉流的直播方式,看起来在手机客户端中观看直播的场景已经解决了。那么问题来了:如果我想要在H5、小程序等其他场景下观看直播,没有办法接入SDK,需要怎么处理呢?这个时候需要引入一个新的概念——旁路推流。旁路推流指的是:通过协议转换将音视频流对接到标准的直播 CDN 系统上。目前云直播开启旁路推流后,会通过互动直播后台将音视频流推送到云直播后台,云直播后台负责将收到音视频流转码成通用的协议格式并且推送到CDN,这样H5、小程序等端就可以通过CDN拉取到通用格式的音视频流进行播放了。目前蘑菇街直播旁路开启的协议类型有HLS、FLV、RTMP三种,已经可以覆盖到所有的播放场景,在后续章节会对这几种协议做详细的介绍。5、推拉流架构3:RTMP推流随着直播业务发展,一些主播逐渐不满足于手机开播的效果,并且电商直播需要高保真地将商品展示在屏幕上,需要通过更加高清专业的设备进行直播,RTMP推流技术应运而生。我们通过使用OBS等流媒体录影程序,对专业设备录制的多路流进行合并,并且将音视频流上传到指定的推流地址。由于OBS推流使用了RTMP协议,因此我们称这一种推流类型为RTMP推流。我们首先在云直播后台申请到推流地址和秘钥,将推流地址和秘钥配置到OBS软件当中,调整推流各项参数,点击推流以后,OBS就会通过RTMP协议向对应的推流地址推送音视频流。这一种推流方式和SDK推流的不同之处在于音视频流是直接被推送到了云直播后台进行转码和上传CDN的,没有直接将直播流转推到用户端的下行方式,因此相比SDK推流延迟会长一些。总结下来RTMP推流的优势和劣势比较明显。优势主要是:1)可以接入专业的直播摄像头、麦克风,直播的整体效果明显优于手机开播;2)OBS已经有比较多成熟的插件,比如目前蘑菇街主播常用YY助手做一些美颜的处理,并且OBS本身已经支持滤镜、绿幕、多路视频合成等功能,功能比手机端强大。劣势主要是:1)OBS本身配置比较复杂,需要专业设备支持,对主播的要求明显更高,通常需要一个固定的场地进行直播;2)RTMP需要云端转码,并且本地上传时也会在OBS中配置GOP和缓冲,延时相对较长。6、高可用架构方案:云互备业务发展到一定阶段后,我们对于业务的稳定性也会有更高的要求,比如当云服务商服务出现问题时,我们没有备用方案就会出现业务一直等待服务商修复进度的问题。因此云互备方案就出现了:云互备指的是直播业务同时对接多家云服务商,当一家云服务商出现问题时,快速切换到其他服务商的服务节点,保证业务不受影响。直播业务中经常遇到服务商的CDN节点下行速度较慢,或者是CDN节点存储的直播流有问题,此类问题有地域性,很难排查,因此目前做的互备云方案,主要是备份CDN节点。目前蘑菇街整体的推流流程已经依赖了原有云平台的服务,因此我们通过在云直播后台中转推一路流到备份云平台上,备份云在接收到了直播流后会对流转码并且上传到备份云自身的CDN系统当中。一旦主平台CDN节点出现问题,我们可以将下发的拉流地址替换成备份云拉流地址,这样就可以保证业务快速修复并且观众无感知。7、视频直播数据流解封装原理介绍流协议之前,先要介绍我们从云端拿到一份数据,要经过几个步骤才能解析出最终需要的音视频数据。如上图所示,总体来说,从获取到数据到最终将音视频播放出来要经历四个步骤。第一步:解协议。协议封装的时候通常会携带一些头部描述信息或者信令数据,这一部分数据对我们音视频播放没有作用,因此我们需要从中提取出具体的音视频封装格式数据,我们在直播中常用的协议有HTTP和RTMP两种。第二步:解封装。获取到封装格式数据以后需要进行解封装操作,从中分别提取音频压缩流数据和视频压缩流数据,封装格式数据我们平时经常见到的如MP4、AVI,在直播中我们接触比较多的封装格式有TS、FLV。第三步:解码音视频。到这里我们已经获取了音视频的压缩编码数据。我们日常经常听到的视频压缩编码数据有H.26X系列和MPEG系列等,音频编码格式有我们熟悉的MP3、ACC等。之所以我们能见到如此多的编码格式,是因为各种组织都提出了自己的编码标准,并且会相继推出一些新的议案,但是由于推广和收费问题,目前主流的编码格式也并不多。获取压缩数据以后接下来需要将音视频压缩数据解码,获取非压缩的颜色数据和非压缩的音频抽样数据。颜色数据有我们平时熟知的RGB,不过在视频的中常用的颜色数据格式是YUV,指的是通过明亮度、色调、饱和度确定一个像素点的色值。音频抽样数据通常使用的有PCM。第四步:音视频同步播放。最后我们需要比对音视频的时间轴,将音视频解码后的数据交给显卡声卡同步播放。PS:如果你对上述流程还不太理解,建议进一步阅读以下系列文章:《移动端实时音视频直播技术详解(一):开篇》《移动端实时音视频直播技术详解(二):采集》《移动端实时音视频直播技术详解(三):处理》《移动端实时音视频直播技术详解(四):编码和封装》《移动端实时音视频直播技术详解(五):推流和传输》《移动端实时音视频直播技术详解(六):延迟优化》另外:有关音视频编解码技术的文章,也可以详细学习以下文章:视频编解码之:《理论概述》、《数字视频介绍》、《编码基础》、《预测技术介绍》《认识主流视频编码技术H.264》《如何开始音频编解码技术的学习》《音频基础及编码原理入门》《常见的实时语音通讯编码标准》《实时视频编码H.264的特点与优势》、《视频编码H.264、VP8的前世今生》《详解音频编解码的原理、演进和应用选型》、《零基础,史上最通俗视频编码技术入门》8、视频直播传输协议1:HLS首先介绍一下HLS协议。HLS是HTTP Live Streaming的简写,是由苹果公司提出的流媒体网络传输协议。从名字可以明显看出:这一套协议是基于HTTP协议传输的。说到HLS协议:首先需要了解这一种协议是以视频切片的形式分段播放的,协议中使用的切片视频格式是TS,也就是我们前文提到的封装格式。在我们获取TS文件之前:协议首先要求请求一个M3U8格式的文件,M3U8是一个描述索引文件,它以一定的格式描述了TS地址的指向,我们根据M3U8文件中描述的内容,就可以获取每一段TS文件的CDN地址,通过加载TS地址分段播放就可以组合出一整段完整的视频。使用HLS协议播放视频时:首先会请求一个M3U8文件,如果是点播只需要在初始化时获取一次就可以拿到所有的TS切片指向,但如果是直播的话就需要不停地轮询M3U8文件,获取新的TS切片。获取到M3U8后:我们可以看一下里面的内容。首先开头是一些通用描述信息,比如第一个分片序列号、片段最大时长和总时长等,接下来就是具体TS对应的地址列表。如果是直播,那么每次请求M3U8文件里面的TS列表都会随着最新的直播切片更新,从而达到直播流播放的效果。HLS这种切片播放的格式在点播播放时是比较适用的,一些大的视频网站也都有用这一种协议作为播放方案。首先:切片播放的特性特别适用于点播播放中视频清晰度、多语种的热切换。比如我们播放一个视频,起初选择的是标清视频播放,当我们看了一半觉得不够清晰,需要换成超清的,这时候只需要将标清的M3U8文件替换成超清的M3U8文件,当我们播放到下一个TS节点时,视频就会自动替换成超清的TS文件,不需要对视频做重新初始化。其次:切片播放的形式也可以比较容易地在视频中插入广告等内容。在直播场景下,HLS也是一个比较常用的协议,他最大的优势是苹果大佬的加持,对这一套协议推广的比较好,特别是移动端。将M3U8文件地址喂给video就可以直接播放,PC端用MSE解码后大部分浏览器也都能够支持。但是由于其分片加载的特性,直播的延迟相对较长。比如我们一个M3U8有5个TS文件,每个TS文件播放时长是2秒,那么一个M3U8文件的播放时长就是10秒,也就是说这个M3U8播放的直播进度至少是10秒之前的,这对于直播场景来说是一个比较大的弊端。HLS中用到的TS封装格式,视频编码格式是通常是H.264或MPEG-4,音频编码格式为AAC或MP3。一个ts由多个定长的packtet组成,通常是188个字节,每个packtet有head和payload组成,head中包含一些标识符、错误信息、包位置等基础信息。payload可以简单理解为音视频信息,但实际上下层还有还有两层封装,将封装解码后可以获取到音视频流的编码数据。9、视频直播传输协议2:HTTP-FLVHTTP-FLV协议,从名字上就可以明显看出是通过HTTP协议来传输FLV封装格式的一种协议。FLV是Flash Video的简写,是一种文件体积小,适合在网络上传输的封包方式。FlV的视频编码格式通常是H.264,音频编码是ACC或MP3。HTTP-FLV在直播中是通过走HTTP长连接的方式,通过分块传输向请求端传递FLV封包数据。在直播中,我们通过HTTP-FLV协议的拉流地址可以拉取到一段chunked数据。打开文件后可以读取到16进制的文件流,通过和FLV包结构对比,可以发现这些数据就是我们需要的FLV数据。首先开头是头部信息:464C56转换ASCII码后是FLV三个字符,01指的是版本号,05转换为2进制后第6位和第8位分别代表是否存在音频和视频,09代表头部长度占了几个字节。后续就是正式的音视频数据:是通过一个个的FLV TAG进行封装,每一个TAG也有头部信息,标注这个TAG是音频信息、视频信息还是脚本信息。我们通过解析TAG就可以分别提取音视频的压缩编码信息。FLV这一种格式在video中并不是原生支持的,我们要播放这一种格式的封包格式需要通过MSE对影视片的压缩编码信息进行解码,因此需要浏览器能够支持MSE这一API。由于HTTP-FLV的传输是通过长连接传输文件流的形式,需要浏览器支持Stream IO或者fetch,对于浏览器的兼容性要求会比较高。FLV在延迟问题上相比切片播放的HLS会好很多,目前看来FLV的延迟主要是受编码时设置的GOP长度的影响。这边简单介绍一下GOP:在H.264视频编码的过程中,会生成三种帧类型:I帧、B帧和P帧。I帧就是我们通常说的关键帧,关键帧内包括了完整的帧内信息,可以直接作为其他帧的参考帧。B帧和P帧为了将数据压缩得更小,需要由其他帧推断出帧内的信息。因此两个I帧之间的时长也可以被视作最小的视频播放片段时长。从视频推送的稳定性考虑,我们也要求主播将关键帧间隔设置为定长,通常是1-3秒,因此除去其他因素,我们的直播在播放时也会产生1-3秒的延时。10、视频直播传输协议3:RTMPRTMP协议实际可以与HTTP-FLV协议归做同一种类型。他们的封包格式都是FlV,但HTTP-FLV使用的传输协议是HTTP,RTMP拉流使用RTMP作为传输协议。RTMP是Adobe公司基于TCP做的一套实时消息传输协议,经常与Flash播放器匹配使用。RTMP协议的优缺点非常明显。RTMP协议的优点主要是:1)首先和HTTP-FLV一样,延迟比较低;2)其次它的稳定性非常好,适合长时间播放(由于播放时借用了Flash player强大的功能,即使开多路流同时播放也能保证页面不出现卡顿,很适合监控等场景)。但是Flash player目前在web端属于墙倒众人推的境地,主流浏览器渐渐都表示不再支持Flash player插件,在MAC上使用能够立刻将电脑变成烧烤用的铁板,资源消耗很大。在移动端H5基本属于完全不支持的状态,兼容性是它最大的问题。11、视频直播传输协议4:MPEG-DASHMPEG-DASH这一协议属于新兴势力,和HLS一样,都是通过切片视频的方式进行播放。他产生的背景是早期各大公司都自己搞自己的一套协议。比如苹果搞了HLS、微软搞了 MSS、Adobe还搞了HDS,这样使用者需要在多套协议封装的兼容问题上痛苦不堪。于是大佬们凑到一起,将之前各个公司的流媒体协议方案做了一个整合,搞了一个新的协议。由于同为切片视频播放的协议,DASH优劣势和HLS类似,可以支持切片之间多视频码率、多音轨的切换,比较适合点播业务,在直播中还是会有延时较长的问题。12、如何选择最优的视频直播传输协议视频直播协议选择非常关键的两点,在前文都已经有提到了,即低延时和更优的兼容性。首先从延时角度考虑:不考虑云端转码以及上下行的消耗,HLS和MPEG-DASH通过将切片时长减短,延时在10秒左右;RTMP和FLV理论上延时相当,在2-3秒。因此在延时方面HLS ≈ DASH > RTMP ≈ FLV。从兼容性角度考虑:HLS > FLV > RTMP,DASH由于一些项目历史原因,并且定位和HLS重复了,暂时没有对其兼容性做一个详尽的测试,被推出了选择的考虑范围。综上所述:我们可以通过动态判断环境的方式,选择当前环境下可用的最低延迟的协议。大致的策略就是优先使用HTTP-FLV,使用HLS作为兜底,在一些特殊需求场景下通过手动配置的方式切换为RTMP。对于HLS和HTTP-FLV:我们可以直接使用 hls.js 和 flv.js 做做解码播放,这两个库内部都是通过MSE做的解码。首先根据视频封装格式提取出对应的音视频chunk数据,在MediaSource中分别对音频和视频创建SourceBuffer,将音视频的编码数据喂给SourceBuffer后SourceBuffer内部会处理完剩下的解码和音视频对齐工作,最后MediaSource将Video标签中的src替换成MediaSource 对象进行播放。在判断播放环境时我们可以参照flv.js内部的判断方式,通过调用MSE判断方法和模拟请求的方式判断MSE和StreamIO是否可用:// 判断MediaSource是否被浏览器支持,H.264视频编码和Acc音频编码是否能够被支持解码window.MediaSource && window.MediaSource.isTypeSupported('video/mp4; codecs="avc1.42E01E,mp4a.40.2"');如果FLV播放不被支持的情况下:需要降级到HLS,这时候需要判断浏览器环境是否在移动端,移动端通常不需要 hls.js 通过MSE解码的方式进行播放,直接将M3U8的地址交给video的src即可。如果是PC端则判断MSE是否可用,如果可用就使用hls.js解码播放。这些判读可以在自己的逻辑里提前判断后去拉取对应解码库的CDN,而不是等待三方库加载完成后使用三方库内部的方法判断,这样在选择解码库时就可以不把所有的库都拉下来,提高加载速度。13、同层播放如何解决电商直播需要观众操作和互动的部分比起传统的直播更加多,因此产品设计的时候很多的功能模块会悬浮在直播视频上方减少占用的空间。这个时候就会遇到一个移动端播放器的老大难问题——同层播放。同层播放问题:是指在移动端H5页面中,一些浏览器内核为了提升用户体验,将video标签被劫持替换为native播放器,导致其他元素无法覆盖于播放器之上。比如我们想要在直播间播放器上方增加聊天窗口,将聊天窗口通过绝对定位提升z-index置于播放器上方,在PC中测试完全正常。但在移动端的一些浏览器中,video被替换成了native播放器,native的元素层级高于我们的普通元素,导致聊天窗口实际显示的时候在播放器下方。要解决这个问题,首先要分多个场景。首先在iOS系统中:正常情况下video标签会自动被全屏播放,但iOS10以上已经原生提供了video的同层属性,我们在video标签上增加playsinline/webkit-playsinline可以解决iOS系统中大部分浏览器的同层问题,剩下的低系统版本的浏览器以及一些APP内的webview容器(譬如微博),用上面提的属性并不管用,调用三方库iphone-inline-video可以解决大部分剩余问题。在Android端:大部分腾讯系的APP内置的webview容器用的都是X5内核,X5内核会将video替换成原生定制的播放器已便于增强一些功能。X5也提供了一套同层的方案(该方案官方文档链接已无法打开),给video标签写入X5同层属性也可以在X5内核中实现内联播放。不过X5的同层属性在各个X5版本中表现都不太一样(比如低版本X5中需要使用X5全屏播放模式才能保证MSE播放的视频同层生效),需要注意区分版本。在蘑菇街App中,目前集成的X5内核版本比较老,在使用MSE的情况下会导致X5同层参数不生效。但如果集成新版本的X5内核,需要对大量的线上页面做回归测试,成本比较高,因此提供了一套折中的解决方案。通过在页面URL中增加一个开关参数,容器读取到参数以后会将X5内核降级为系统原生的浏览器内核,这样可以在解决浏览器视频同层问题的同时也将内核变动的影响范围控制在单个页面当中。14、相关文章[1] 移动端实时音视频直播技术详解(四):编码和封装[2] 移动端实时音视频直播技术详解(五):推流和传输[3] 实现延迟低于500毫秒的1080P实时音视频直播的实践分享[4] 浅谈开发实时视频直播平台的技术要点[5] 直播系统聊天技术(七):直播间海量聊天消息的架构设计难点实践[6] 从0到1:万人在线的实时音视频直播技术实践分享(视频+PPT) [附件下载][7] 实时视频编码H.264的特点与优势[8] 视频编码H.264、VP8的前世今生[9] 零基础,史上最通俗视频编码技术入门[10] 视频编解码之编码基础[11] 零基础入门:实时音视频技术基础知识全面盘点[12] 实时音视频面视必备:快速掌握11个视频技术相关的基础概念[13] 写给小白的实时音视频技术入门提纲(本文已同步发布于:http://www.52im.net/thread-3922-1-1.html)
本文作者张彦飞,原题“聊聊TCP连接耗时的那些事儿”,有少许改动。1、引言对于基于互联网的通信应用(比如IM聊天、推送系统),数据传递时使用TCP协议相对较多。这是因为在TCP/IP协议簇的传输层协议中,TCP协议具备可靠的连接、错误重传、拥塞控制等优点,所以目前在应用场景上比UDP更广泛一些。相信你也一定听闻过TCP也存在一些缺点,能常都是老生常谈的开销要略大。但是各路技术博客里都在单单说开销大、或者开销小,而少见不给出具体的量化分析。不客气的讲,类似论述都是没什么营养的废话。经过日常工作的思考之后,我更想弄明白的是,TCP的开销到底有多大,能否进行量化。一条TCP连接的建立需要耗时延迟多少,是多少毫秒,还是多少微秒?能不能有一个哪怕是粗略的量化估计?当然影响TCP耗时的因素有很多,比如网络丢包等等。我今天只分享我在工作实践中遇到的比较高发的各种情况。写在前面:得益于Linux内核的开源,本文中所提及的底层以及具体的内核级代码例子,都是以Linux系统为例。学习交流:- 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》- 开源IM框架源码:https://github.com/JackJiang2011/MobileIMSDK(备用地址点此)(本文已同步发布于:http://www.52im.net/thread-3265-1-1.html)2、系列文章本文是系列文章中的第11篇,本系列文章的大纲如下:《不为人知的网络编程(一):浅析TCP协议中的疑难杂症(上篇)》《不为人知的网络编程(二):浅析TCP协议中的疑难杂症(下篇)》《不为人知的网络编程(三):关闭TCP连接时为什么会TIME_WAIT、CLOSE_WAIT》《不为人知的网络编程(四):深入研究分析TCP的异常关闭》《不为人知的网络编程(五):UDP的连接性和负载均衡》《不为人知的网络编程(六):深入地理解UDP协议并用好它》《不为人知的网络编程(七):如何让不可靠的UDP变的可靠?》《不为人知的网络编程(八):从数据传输层深度解密HTTP》《不为人知的网络编程(九):理论联系实际,全方位深入理解DNS》《不为人知的网络编程(十):深入操作系统,从内核理解网络包的接收过程(Linux篇)》《不为人知的网络编程(十一):从底层入手,深度分析TCP连接耗时的秘密》(本文)《不为人知的网络编程(十二):彻底搞懂TCP协议层的KeepAlive保活机制》《不为人知的网络编程(十三):深入操作系统,彻底搞懂127.0.0.1本机网络通信》《不为人知的网络编程(十四):拔掉网线再插上,TCP连接还在吗?一文即懂!》3、理想情况下的TCP连接耗时分析要想搞清楚TCP连接的耗时,我们需要详细了解连接的建立过程。在前文《深入操作系统,从内核理解网络包的接收过程(Linux篇)》中我们介绍了数据包在接收端是怎么被接收的:数据包从发送方出来,经过网络到达接收方的网卡;在接收方网卡将数据包DMA到RingBuffer后,内核经过硬中断、软中断等机制来处理(如果发送的是用户数据的话,最后会发送到socket的接收队列中,并唤醒用户进程)。在软中断中,当一个包被内核从RingBuffer中摘下来的时候,在内核中是用struct sk_buff结构体来表示的(参见内核代码include/linux/skbuff.h)。其中的data成员是接收到的数据,在协议栈逐层被处理的时候,通过修改指针指向data的不同位置,来找到每一层协议关心的数据。对于TCP协议包来说,它的Header中有一个重要的字段-flags。如下图:通过设置不同的标记位,将TCP包分成SYNC、FIN、ACK、RST等类型:1)客户端通过connect系统调用命令内核发出SYNC、ACK等包来实现和服务器TCP连接的建立;2)在服务器端,可能会接收许许多多的连接请求,内核还需要借助一些辅助数据结构-半连接队列和全连接队列。我们来看一下整个连接过程:在这个连接过程中,我们来简单分析一下每一步的耗时:1)客户端发出SYNC包:客户端一般是通过connect系统调用来发出SYN的,这里牵涉到本机的系统调用和软中断的CPU耗时开销;2)SYN传到服务器:SYN从客户端网卡被发出,开始“跨过山和大海,也穿过人山人海......”,这是一次长途远距离的网络传输;3)服务器处理SYN包:内核通过软中断来收包,然后放到半连接队列中,然后再发出SYN/ACK响应。又是CPU耗时开销;4)SYC/ACK传到客户端:SYC/ACK从服务器端被发出后,同样跨过很多山、可能很多大海来到客户端。又一次长途网络跋涉;5)客户端处理SYN/ACK:客户端内核收包并处理SYN后,经过几us的CPU处理,接着发出ACK。同样是软中断处理开销;6)ACK传到服务器:和SYN包,一样,再经过几乎同样远的路,传输一遍。 又一次长途网络跋涉;7)服务端收到ACK:服务器端内核收到并处理ACK,然后把对应的连接从半连接队列中取出来,然后放到全连接队列中。一次软中断CPU开销;8)服务器端用户进程唤醒:正在被accpet系统调用阻塞的用户进程被唤醒,然后从全连接队列中取出来已经建立好的连接。一次上下文切换的CPU开销。以上几步操作,可以简单划分为两类:第一类:是内核消耗CPU进行接收、发送或者是处理,包括系统调用、软中断和上下文切换。它们的耗时基本都是几个us左右;第二类:是网络传输,当包被从一台机器上发出以后,中间要经过各式各样的网线、各种交换机路由器。所以网络传输的耗时相比本机的CPU处理,就要高的多了。根据网络远近一般在几ms~到几百ms不等。1ms就等于1000us,因此网络传输耗时比双端的CPU开销要高1000倍左右,甚至更高可能还到100000倍。所以:在正常的TCP连接的建立过程中,一般考虑网络延时即可。PS:一个RTT指的是包从一台服务器到另外一台服务器的一个来回的延迟时间。所以从全局来看:TCP连接建立的网络耗时大约需要三次传输,再加上少许的双方CPU开销,总共大约比1.5倍RTT大一点点。不过,从客户端视角来看:只要ACK包发出了,内核就认为连接是建立成功了。所以如果在客户端打点统计TCP连接建立耗时的话,只需要两次传输耗时-既1个RTT多一点的时间。(对于服务器端视角来看同理,从SYN包收到开始算,到收到ACK,中间也是一次RTT耗时)。4、极端情况下的TCP连接耗时分析上一节可以看到:在客户端视角,正常情况下一次TCP连接总的耗时也就就大约是一次网络RTT的耗时。如果所有的事情都这么简单,我想我的这次分享也就没有必要了。事情不一定总是这么美好,意外的发生在所难免。在某些情况下,可能会导致TCP连接时的网络传输耗时上涨、CPU处理开销增加、甚至是连接失败。本节将就我在线上遇到过的各种切身体会的沟沟坎坎,来分析一下极端情况下的TCP连接耗时情况。4.1 客户端connect调用耗时失控案例正常一个系统调用的耗时也就是几个us(微秒)左右。但是在我的《追踪将服务器CPU耗光的凶手!》一文中,笔者的一台服务器当时遇到一个状况:某次运维同学转达过来说该服务CPU不够用了,需要扩容。当时的服务器监控如下图:该服务之前一直每秒抗2000左右的qps,CPU的idel一直有70%+,怎么突然就CPU一下就不够用了呢。而且更奇怪的是CPU被打到谷底的那一段时间,负载却并不高(服务器为4核机器,负载3-4是比较正常的)。后来经过排查以后发现当TCP客户端TIME_WAIT有30000左右,导致可用端口不是特别充足的时候,connect系统调用的CPU开销直接上涨了100多倍,每次耗时达到了2500us(微秒),达到了毫秒级别。当遇到这种问题的时候,虽然TCP连接建立耗时只增加了2ms左右,整体TCP连接耗时看起来还可接受。但这里的问题在于这2ms多都是在消耗CPU的周期,所以问题不小。解决起来也非常简单,办法很多:修改内核参数net.ipv4.ip_local_port_range多预留一些端口号、改用长连接都可以。4.2 TCP半/全连接队列满的案例如果连接建立的过程中,任意一个队列满了,那么客户端发送过来的syn或者ack就会被丢弃。客户端等待很长一段时间无果后,然后会发出TCP Retransmission重传。拿半连接队列举例:要知道的是上面TCP握手超时重传的时间是秒级别的。也就是说一旦server端的连接队列导致连接建立不成功,那么光建立连接就至少需要秒级以上。而正常的在同机房的情况下只是不到1毫秒的事情,整整高了1000倍左右。尤其是对于给用户提供实时服务的程序来说,用户体验将会受到较大影响。如果连重传也没有握手成功的话,很可能等不及二次重试,这个用户访问直接就超时了。还有另外一个更坏的情况是:它还有可能会影响其它的用户。假如你使用的是进程/线程池这种模型提供服务,比如:php-fpm。我们知道fpm进程是阻塞的,当它响应一个用户请求的时候,该进程是没有办法再响应其它请求的。假如你开了100个进程/线程,而某一段时间内有50个进程/线程卡在和redis或者mysql服务器的握手连接上了(注意:这个时候你的服务器是TCP连接的客户端一方)。这一段时间内相当于你可以用的正常工作的进程/线程只有50个了。而这个50个worker可能根本处理不过来,这时候你的服务可能就会产生拥堵。再持续稍微时间长一点的话,可能就产生雪崩了,整个服务都有可能会受影响。既然后果有可能这么严重,那么我们如何查看我们手头的服务是否有因为半/全连接队列满的情况发生呢?在客户端:可以抓包查看是否有SYN的TCP Retransmission。如果有偶发的TCP Retransmission,那就说明对应的服务端连接队列可能有问题了。在服务端的话:查看起来就更方便一些了。netstat -s 可查看到当前系统半连接队列满导致的丢包统计,但该数字记录的是总丢包数。你需要再借助 watch 命令动态监控。如果下面的数字在你监控的过程中变了,那说明当前服务器有因为半连接队列满而产生的丢包。你可能需要加大你的半连接队列的长度了。$ watch'netstat -s | grep LISTEN' 8 SYNs to LISTEN sockets ignored对于全连接队列来说呢,查看方法也类似:$ watch'netstat -s | grep overflowed' 160 timesthe listen queue of a socket overflowed如果你的服务因为队列满产生丢包,其中一个做法就是加大半/全连接队列的长度。 半连接队列长度Linux内核中,主要受tcp_max_syn_backlog影响 加大它到一个合适的值就可以。# cat /proc/sys/net/ipv4/tcp_max_syn_backlog1024# echo "2048" > /proc/sys/net/ipv4/tcp_max_syn_backlog全连接队列长度是应用程序调用listen时传入的backlog以及内核参数net.core.somaxconn二者之中较小的那个。你可能需要同时调整你的应用程序和该内核参数。# cat /proc/sys/net/core/somaxconn128# echo "256" > /proc/sys/net/core/somaxconn改完之后我们可以通过ss命令输出的Send-Q确认最终生效长度:$ ss -nltRecv-Q Send-Q Local Address:Port Address:Port0 128 *:80 *:*Recv-Q告诉了我们当前该进程的全连接队列使用长度情况。如果Recv-Q已经逼近了Send-Q,那么可能不需要等到丢包也应该准备加大你的全连接队列了。如果加大队列后仍然有非常偶发的队列溢出的话,我们可以暂且容忍。如果仍然有较长时间处理不过来怎么办?另外一个做法就是直接报错,不要让客户端超时等待。例如将Redis、Mysql等后端接口的内核参数tcp_abort_on_overflow为1。如果队列满了,直接发reset给client。告诉后端进程/线程不要痴情地傻等。这时候client会收到错误“connection reset by peer”。牺牲一个用户的访问请求,要比把整个站都搞崩了还是要强的。5、TCP连接耗时实测分析5.1 测试前的准备我写了一段非常简单的代码,用来在客户端统计每创建一个TCP连接需要消耗多长时间。<?php$ip= {服务器ip};$port= {服务器端口};$count= 50000;function buildConnect($ip,$port,$num){ for($i=0;$i<$num;$i++){ $socket= socket_create(AF_INET,SOCK_STREAM,SOL_TCP); if($socket==false) { echo"$ip $port socket_create() 失败的原因是:".socket_strerror(socket_last_error($socket))."\n"; sleep(5); continue; } if(false == socket_connect($socket, $ip, $port)){ echo"$ip $port socket_connect() 失败的原因是:".socket_strerror(socket_last_error($socket))."\n"; sleep(5); continue; } socket_close($socket); }} $t1= microtime(true);buildConnect($ip, $port, $count);echo(($t2-$t1)*1000).'ms';在测试之前,我们需要本机linux可用的端口数充足,如果不够50000个,最好调整充足。# echo "5000 65000" /proc/sys/net/ipv4/ip_local_port_range5.2 正常情况下的测试注意:无论是客户端还是服务器端都不要选择有线上服务在跑的机器,否则你的测试可能会影响正常用户访问首先:我的客户端位于河北怀来的IDC机房内,服务器选择的是公司广东机房的某台机器。执行ping命令得到的延迟大约是37ms,使用上述脚本建立50000次连接后,得到的连接平均耗时也是37ms。这是因为前面我们说过的,对于客户端来看,第三次的握手只要包发送出去,就认为是握手成功了,所以只需要一次RTT、两次传输耗时。虽然这中间还会有客户端和服务端的系统调用开销、软中断开销,但由于它们的开销正常情况下只有几个us(微秒),所以对总的连接建立延时影响不大。接下来:我换了一台目标服务器,该服务器所在机房位于北京。离怀来有一些距离,但是和广东比起来可要近多了。这一次ping出来的RTT是1.6~1.7ms左右,在客户端统计建立50000次连接后算出每条连接耗时是1.64ms。再做一次实验:这次选中实验的服务器和客户端直接位于同一个机房内,ping延迟在0.2ms~0.3ms左右。跑了以上脚本以后,实验结果是50000 TCP连接总共消耗了11605ms,平均每次需要0.23ms。线上架构提示:这里看到同机房延迟只有零点几ms,但是跨个距离不远的机房,光TCP握手耗时就涨了4倍。如果再要是跨地区到广东,那就是百倍的耗时差距了。线上部署时,理想的方案是将自己服务依赖的各种mysql、redis等服务和自己部署在同一个地区、同一个机房(再变态一点,甚至可以是甚至是同一个机架)。因为这样包括TCP链接建立啥的各种网络包传输都要快很多。要尽可能避免长途跨地区机房的调用情况出现。5.3 TCP连接队列溢出情况下的测试测试完了跨地区、跨机房和跨机器。这次为了快,直接和本机建立连接结果会咋样呢?Ping本机ip或127.0.0.1的延迟大概是0.02ms,本机ip比其它机器RTT肯定要短。我觉得肯定连接会非常快,嗯实验一下。连续建立5W TCP连接:总时间消耗27154ms,平均每次需要0.54ms左右。嗯!?怎么比跨机器还长很多?有了前面的理论基础,我们应该想到了:由于本机RTT太短,所以瞬间连接建立请求量很大,就会导致全连接队列或者半连接队列被打满的情况。一旦发生队列满,当时撞上的那个连接请求就得需要3秒+的连接建立延时。所以上面的实验结果中,平均耗时看起来比RTT高很多。在实验的过程中,我使用tcpdump抓包看到了下面的一幕。原来有少部分握手耗时3s+,原因是半连接队列满了导致客户端等待超时后进行了SYN的重传。我们又重新改成每500个连接,sleep 1秒。嗯好,终于没有卡的了(或者也可以加大连接队列长度)。结论是:本机50000次TCP连接在客户端统计总耗时102399 ms,减去sleep的100秒后,平均每个TCP连接消耗0.048ms。比ping延迟略高一些。这是因为当RTT变的足够小的时候,内核CPU耗时开销就会显现出来了,另外TCP连接要比ping的icmp协议更复杂一些,所以比ping延迟略高0.02ms左右比较正常。6、本文小结TCP连接在建立异常的情况下,可能需要好几秒,一个坏处就是会影响用户体验,甚至导致当前用户访问超时都有可能。另外一个坏处是可能会诱发雪崩。所以当你的服务器使用短连接的方式访问数据的时候:一定要学会要监控你的服务器的连接建立是否有异常状态发生。如果有,学会优化掉它。当然你也可以采用本机内存缓存,或者使用连接池来保持长连接,通过这两种方式直接避免掉TCP握手挥手的各种开销也可以。再说正常情况下:TCP建立的延时大约就是两台机器之间的一个RTT耗时,这是避免不了的。但是你可以控制两台机器之间的物理距离来降低这个RTT,比如把你要访问的redis尽可能地部署的离后端接口机器近一点,这样RTT也能从几十ms削减到最低可能零点几ms。最后我们再思考一下:如果我们把服务器部署在北京,给纽约的用户访问可行吗?前面的我们同机房也好,跨机房也好,电信号传输的耗时基本可以忽略(因为物理距离很近),网络延迟基本上是转发设备占用的耗时。但是如果是跨越了半个地球的话,电信号的传输耗时我们可得算一算了。 北京到纽约的球面距离大概是15000公里,那么抛开设备转发延迟,仅仅光速传播一个来回(RTT是Rround trip time,要跑两次),需要时间 = 15,000,000 *2 / 光速 = 100ms。实际的延迟可能比这个还要大一些,一般都得200ms以上。建立在这个延迟上,要想提供用户能访问的秒级服务就很困难了。所以对于海外用户,最好都要在当地建机房或者购买海外的服务器。附录:更多网络编程精华资料[1] 网络编程(基础)资料:《TCP/IP详解 - 第17章·TCP:传输控制协议》《技术往事:改变世界的TCP/IP协议(珍贵多图、手机慎点)》《通俗易懂-深入理解TCP协议(上):理论基础》《理论经典:TCP协议的3次握手与4次挥手过程详解》《P2P技术详解(一):NAT详解——详细原理、P2P简介》《网络编程懒人入门(一):快速理解网络通信协议(上篇)》《网络编程懒人入门(二):快速理解网络通信协议(下篇)》《网络编程懒人入门(三):快速理解TCP协议一篇就够》《网络编程懒人入门(四):快速理解TCP和UDP的差异》《网络编程懒人入门(五):快速理解为什么说UDP有时比TCP更有优势》《网络编程懒人入门(六):史上最通俗的集线器、交换机、路由器功能原理入门》《网络编程懒人入门(七):深入浅出,全面理解HTTP协议》《网络编程懒人入门(八):手把手教你写基于TCP的Socket长连接》《网络编程懒人入门(九):通俗讲解,有了IP地址,为何还要用MAC地址?》《网络编程懒人入门(十):一泡尿的时间,快速读懂QUIC协议》《网络编程懒人入门(十一):一文读懂什么是IPv6》《网络编程懒人入门(十二):快速读懂Http/3协议,一篇就够!》《网络编程懒人入门(十三):一泡尿的时间,快速搞懂TCP和UDP的区别》《网络编程懒人入门(十四):到底什么是Socket?一文即懂!》《技术扫盲:新一代基于UDP的低延时网络传输层协议——QUIC详解》《让互联网更快:新一代QUIC协议在腾讯的技术实践分享》《聊聊iOS中网络编程长连接的那些事》《IPv6技术详解:基本概念、应用现状、技术实践(上篇)》《IPv6技术详解:基本概念、应用现状、技术实践(下篇)》《Java对IPv6的支持详解:支持情况、相关API、演示代码》《从HTTP/0.9到HTTP/2:一文读懂HTTP协议的历史演变和设计思路》《脑残式网络编程入门(一):跟着动画来学TCP三次握手和四次挥手》《脑残式网络编程入门(二):我们在读写Socket时,究竟在读写什么?》《脑残式网络编程入门(三):HTTP协议必知必会的一些知识》《脑残式网络编程入门(四):快速理解HTTP/2的服务器推送(Server Push)》《脑残式网络编程入门(五):每天都在用的Ping命令,它到底是什么?》《脑残式网络编程入门(六):什么是公网IP和内网IP?NAT转换又是什么鬼?》《脑残式网络编程入门(七):面视必备,史上最通俗计算机网络分层详解》《脑残式网络编程入门(八):你真的了解127.0.0.1和0.0.0.0的区别?》《脑残式网络编程入门(九):面试必考,史上最通俗大小端字节序详解》《迈向高阶:优秀Android程序员必知必会的网络基础》《Android程序员必知必会的网络通信传输层协议——UDP和TCP》《技术大牛陈硕的分享:由浅入深,网络编程学习经验干货总结》《可能会搞砸你的面试:你知道一个TCP连接上能发起多少个HTTP请求吗?》《5G时代已经到来,TCP/IP老矣,尚能饭否?》>> 更多同类文章 ……[2] 网络编程(高阶)资料:《高性能网络编程(一):单台服务器并发TCP连接数到底可以有多少》《高性能网络编程(二):上一个10年,著名的C10K并发连接问题》《高性能网络编程(三):下一个10年,是时候考虑C10M并发问题了》《高性能网络编程(四):从C10K到C10M高性能网络应用的理论探索》《高性能网络编程(五):一文读懂高性能网络编程中的I/O模型》《高性能网络编程(六):一文读懂高性能网络编程中的线程模型》《高性能网络编程(七):到底什么是高并发?一文即懂!》《IM开发者的零基础通信技术入门(十):零基础,史上最强5G技术扫盲》《IM开发者的零基础通信技术入门(十一):为什么WiFi信号差?一文即懂!》《IM开发者的零基础通信技术入门(十二):上网卡顿?网络掉线?一文即懂!》《IM开发者的零基础通信技术入门(十三):为什么手机信号差?一文即懂!》《IM开发者的零基础通信技术入门(十四):高铁上无线上网有多难?一文即懂!》《IM开发者的零基础通信技术入门(十五):理解定位技术,一篇就够》《以网游服务端的网络接入层设计为例,理解实时通信的技术挑战》《知乎技术分享:知乎千万级并发的高性能长连接网关技术实践》《淘宝技术分享:手淘亿级移动端接入层网关的技术演进之路》>> 更多同类文章 ……(本文已同步发布于:http://www.52im.net/thread-3265-1-1.html)
本文作者“Carson”,现就职于腾讯公司,原题“高效保活长连接:手把手教你实现自适应的心跳保活机制”,有较多修订和改动。1、引言当要实现IM即时通讯聊天、消息推送等高实时性需求时,我们一般会选择长连接的通信方式。而真正当实现长连接方式时,会遇到很多技术问题,比如最常见的长连接保活问题。今天,我将通过本篇文章,手把手教大家实现一套可自适应的心跳保活机制,从而能高效稳定地维持诸如IM聊天这类需求的长连接。 学习交流:- 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》- 开源IM框架源码:https://github.com/JackJiang2011/MobileIMSDK (本文同步发布于:http://www.52im.net/thread-3908-1-1.html)2、相关文章《为何基于TCP协议的移动端IM仍然需要心跳保活机制?》《一文读懂即时通讯应用中的网络心跳包机制:作用、原理、实现思路等》《一种Android端IM智能心跳算法的设计与实现探讨(含样例代码)》《自已开发IM有那么难吗?手把手教你自撸一个Andriod版简易IM (有源码)》《跟着源码学IM(一):手把手教你用Netty实现心跳机制、断线重连机制》《跟着源码学IM(五):正确理解IM长连接、心跳及重连机制,并动手实现》3、什么是长连接认识长连接:长连接的主要作是通过长时间保持双方连接,从而:1)提高通信速度;2)确保实时性;3)避免短时间内重复连接所造成的信道资源和网络资源的浪费。长连接与短连接的区别:PS:对于IM这类的开发者而言,通常大家都把HTTP协议称“短连接”、把直接基于TCP、UDP或WebSocket的socket称为“长连接”。4、导致长连接断开的原因4.1 基本概念从上节可知,在使用长连接的情况下,双方的所有通信都建立在1条长连接上(比如1次TCP连接)。所以,长连接需要持续保持双方连接才可使得双方持续通信。然而,实际情况是,长连接会存在断开的情况。这些断开原因主要是:1)长连接所在进程被杀死(这主要说的是移动端);2)NAT超时;3)网络状态发生变化;4)其他不可抗因素(网络状态差、DHCP的租期等等 )。下面,我将对每种原因进行分析。4.2 具体分析1)原因1:进程被杀死当进程被杀死后,长连接也会随之断开。进程被杀在Andriod端是最常见的问题,限于篇幅就不在此展开这个话题,有兴趣可以阅读这篇:《Android P正式版即将到来:后台应用保活、消息推送的真正噩梦》。2)原因2:NAT 超时(重点关注)NAT超时现象如下: 各运营商和地区的NAT超时时间如下:PS:上述数据来源于微信团队的《移动端IM实践:实现Android版微信的智能心跳机制》一文,随着4G、5G的普及,这些数据有可能已发生变化,请以实际测试结果为准。特别注意:排除其他外因(网络切换、NAT超时、人为原因),TCP长连接在双方都不断开连接的情况上,本质上是不会自动中断的(也就是不需要心跳包来维持,可以验证一下:让2台电脑连上同1个Wifi,其中1台做服务器, 另1台做客户端连接服务器(无设置KeepAlive)。只要电脑、路由器不断网断电,那么,2台电脑的长连接是不会自动中断的)。Jack Jiang注:上述论述可能不太准确,有新兴趣的读者可以详读《拔掉网线再插上,TCP连接还在吗?一文即懂!》。3)原因3:网络状态发生变化当移动客户端网络状态发生变化时(如移动网络 & Wifi切换、断开、重连),也会使长连接断开。4)原因4:其他不可抗因素如网络状态差、DHCP的租期到期等等,都会使得长连接发生 偶然的断开。DHCP的租期到期:对于 Android系统, DHCP到了租期后不会主动续约(继续使用过期IP),从而导致长连接断开。5、高效维持长连接的解决方案5.1 基本介绍在了解长连接断开原因后,针对这些原因,此处给出我的高效维持长连接的解决方案(如下图所示)。为此,若需有效维持长连接,则需要做到:说得简单点,高效维持长连接的关键在于:1)保活:处于连接状态时要做到尽量不要断;2)重连:连接断了之后要能继续重连回来。5.2 具体措施1)措施1:进程保活整体概括如下:PS:关于Android的进程保活,这个话题就很热门了,感兴趣可以顺着下面的文章详细读一读:《应用保活终极总结(一):Android6.0以下的双进程守护保活实践》《应用保活终极总结(二):Android6.0及以上的保活实践(进程防杀篇)》《应用保活终极总结(三):Android6.0及以上的保活实践(被杀复活篇)》《Android进程保活详解:一篇文章解决你的所有疑问》《微信团队原创分享:Android版微信后台保活实战分享(进程保活篇)》《Android P正式版即将到来:后台应用保活、消息推送的真正噩梦》《全面盘点当前Android后台保活方案的真实运行效果(截止2019年前)》《2020年了,Android后台保活还有戏吗?看我如何优雅的实现!》《史上最强Android保活思路:深入剖析腾讯TIM的进程永生技术》《Android进程永生技术终极揭密:进程被杀底层原理、APP应对被杀技巧》《Android保活从入门到放弃:乖乖引导用户加白名单吧(附7大机型加白示例)》2)措施2:心跳保活机制这是本文的重点,下节开始会详细解析3)措施3:断线重连机制原理就是:检测网络状态变化并及时判断连接的有效性。具体实现:这个其实跟心跳保活机制是一套完整的逻辑,所以下面会在心跳保活机制中一起讲解。6、心跳保活机制简介心跳保活机制的整体介绍如下:不过,很多人容易混淆把心跳机制和传统的HTTP轮询机制搞混。下面给出二者区别:7、主流IM的心跳机制分析和对比对国、内外主流的移动IM产品(WhatsApp、Line、微信)进行了心跳机制的简单分析和对比。具体请看下图:PS:以上数据来自于微信团队分享的《移动端IM实践:WhatsApp、Line、微信的心跳策略分析》一文。8、心跳保活机制方案总体设计下面,我将根据市面上主流的心跳机制,设计了一套心跳机制方案。心跳机制方案的基本流程: 对于心跳机制方案设计的主要考虑因素是:1)要保证消息的实时性;2)要考虑耗费设备的资源(网络流量、电量、CPU等等)。从上图可以看出,对于心跳机制方案设计的要点在于:1)心跳包的规格(内容 & 大小);2)心跳发送的间隔时间;3)断线重连机制 (核心 = 如何 判断长连接的有效性)。在下面的方案设计中,将针对这3个问题给出详细的解决方案。9、心跳机制方案的详细设计9.1 心跳包的规格为了减少流量并提高发送效率,需要精简心跳包的设计。主要从心跳包的内容和大小入手,设计原则具体如下:设计方案:心跳包 = 1个携带少量信息 & 大小在10字节内的信息包9.2 心跳发送的间隔时间为了 防止NAT超时并减少设备资源的消耗(网络流量、电量、CPU等等),心跳发送的间隔时间是整个心跳机制方案设计的重点。心跳发送间隔时间的设计原则如下: 9.3 最常用的心跳间隔方案一般,最直接且常用的心跳发送间隔时间设置方案多采用:“每隔估计 x 分钟发送心跳包1次”。其中,x <5分钟即可(综合主流移动IM产品,此处建议 x= 4分钟)。但是,这种方案存在一些问题:PS:关于固定心跳间隔的方案具体实现,可以详细参考:《跟着源码学IM(一):手把手教你用Netty实现心跳机制、断线重连机制》;《跟着源码学IM(五):正确理解IM长连接、心跳及重连机制,并动手实现》;《自已开发IM有那么难吗?手把手教你自撸一个Andriod版简易IM (有源码)》。9.4 自适应心跳间隔方案下面,我将详细讲解自适应心跳间隔时间的设计方案。基本逻辑: 该方案需要解决的有2个核心问题。1)如何自适应计算心跳间隔 从而使得心跳间隔 接近 当前NAT 超时时间?答:不断增加心跳间隔时间进行心跳应答测试,直到心跳失败5次后,即可找出最接近 当前NAT 超时时间的心跳间隔时间。具体请看下图:注:只有当心跳间隔 接近 NAT 超时时间 时,才能最大化平衡 长连接不中断 & 设备资源消耗最低的问题。2)如何检测 当前网络环境的NAT 超时时间 发生了变化 ?答:当前发送心跳包成功 的最大间隔时间(即最接近NAT超时时间的心跳间隔) 发送失败5次后,则判断当前网络环境的NAT 超时时间 发生了变化。具体请看下图:注:在检测到 NAT 超时时间 发生变化后,重新自适应计算心跳间隔 从而使得心跳间隔 接近 NAT 超时时间总结一下:统筹以上2个核心问题,总结出自适应心跳间隔时间设计方案为下图:PS:关于自适应心跳机制的设计和实现,可以详细参考:《移动端IM实践:实现Android版微信的智能心跳机制》;《一种Android端IM智能心跳算法的设计与实现探讨(含样例代码)》。10、断线重连机制的实现技术上来说:长连接的心跳保活依赖于心跳机制,在心跳机制起作用的情况下,适时启动断线重连机制,在心跳机制和断线重连机制的共同作用下才能实现真正的心跳保活。但为了让逻辑更清晰,我把断线重连机制跟心跳机制单独各作为一节来讲解。本节讲的是断片线重连机制。该机制的核心在于:如何判断长连接的有效性。即:什么情况下视为长连接断线?1)设计原则:基本逻辑就是:判断长连接是否有效的准则 = 服务器是否返回心跳应答。此处需要分清长连接的“存活 & 有效“状态的区别: 2)具体方案:实现思路:通过计数计算,若连续5次发送心跳后,服务器都无心跳应答,则视为长连接无效。判断流程:3)网上流传的方案:在网上流传着一些用于判断长连接是否有效的方案,具体介绍如下: 至此,关于心跳保活机制已经讲解完毕。11、方案小结有必要总结一下我在上两节分享的心跳机制和断线重连机制,这两个机制组成了本文的长连接心跳保活完整逻辑。设计方案: 流程设计:注:标识 “灰色” 的判断流程参考上文描述。12、进一步优化和完善心跳保活方案12.1 基本情况上两节中的方案依然会存在技术缺陷,从而导致长连接断开(比如:长连接本身不可用(此时重连多少次也没用))。下面将优化和完善上述方案,从而保证 客户端与服务器依然保持着通信状态。优化点主要是:1)确保当前网络的有效性和稳定性再开始长连接;2)自适应计算心跳包间隔时间的时机。12.2 确保网络的有效性和稳定性后再开始长连接问题描述: 解决方案:加入到原有的心跳保活机制主流程:12.3 自适应计算心跳包间隔时间的时机问题描述: 方案设计: 加入到到原有的心跳保活机制主流程: 12.4 小结一下13、额外思考:TCP协议自带的KeepAlive机制能否替代心跳机制?很多人认为,TCP 协议自身就有KeepAlive机制,为何基于它的通讯链接,仍需在应用层实现额外的心跳保活机制?结论是:无法替代;原因是:TCP KeepAlive机制的作用是检测连接的有无(死活),但无法检测连接是否有效。注:“连接有效”的定义 = 双方具备发送 & 接收消息的能力。先来看看KeepAlive 机制是什么: KeepAlive 的机制不可替代心跳机制的具体原因如下: 特别注意:1)KeepAlive 机制只是操作系统底层的一个被动机制,不应该被上层应用层使用;2)当系统关闭一个由KeepAlive 机制检查出来的死连接时,是不会主动通知上层应用的,只能通过调用相应IO操作的返回值中发现。小结一下就是:KeepAlive机制无法代替心跳机制,需要在应用层 自己实现心跳机制以检测长连接的有效性,从而高效维持长连接。Jack Jiang注:关于TCP本身的KeepAlive机制,可能详读:《为何基于TCP协议的移动端IM仍然需要心跳保活机制?》《彻底搞懂TCP协议层的KeepAlive保活机制》14、本文总结看完本文后,相信在高效维持长连接的需求下,你可以完美地解决了!本文方案的主体设计就是:方案的优化和完善内容就是:15、参考资料[1] TCP/IP详解 卷1:协议[2] 为何基于TCP协议的移动端IM仍然需要心跳保活机制?[3] 彻底搞懂TCP协议层的KeepAlive保活机制[4] 万字长文,一篇吃透WebSocket:概念、原理、易错常识、动手实践[5] 移动端IM实践:实现Android版微信的智能心跳机制[6] 移动端IM实践:WhatsApp、Line、微信的心跳策略分析[7] 微信团队原创分享:Android版微信后台保活实战分享(网络保活篇)[8] 融云技术分享:融云安卓端IM产品的网络链路保活技术实践[9] 阿里IM技术分享(五):闲鱼亿级IM消息系统的及时性优化实践[10] 2020年了,Android后台保活还有戏吗?看我如何优雅的实现!(本文同步发布于:http://www.52im.net/thread-3908-1-1.html)
关于RainbowChat► 详细产品介绍:http://www.52im.net/thread-19-1-1.html► 版本更新记录:http://www.52im.net/thread-1217-1-1.html► 全部运行截图:Android端、iOS端► 在线体验下载:专业版(TCP协议)、专业版(UDP协议) (关于 iOS 端,请:点此查看) RainbowChat是一套基于开源IM聊天框架 MobileIMSDK 的产品级移动端IM系统。RainbowChat源于真实运营的产品,解决了大量的屏幕适配、细节优化、机器兼容问题(可自行下载体验:专业版下载安装)。* RainbowChat可能是市面上提供im即时通讯聊天源码的,唯一一款同时支持TCP、UDP两种通信协议的IM产品(通信层基于开源IM聊天框架 MobileIMSDK 实现)。v8.1 版更新内容此版更新内容:(1)Android端主要更新内容【新增“扫一扫”等功能及优化!】:1)[新增]“扫一扫”界面及完整功能(支持扫码加好友、加群);2)[新增]“我的二维码”界面及完整功能;3)[新增]“群聊二维码”界面及完整功能;4)[升级]升级okhttp库至4.9.3;5)[优化]其它小优化。(2)服务端主要更新内容:1)[优化]针对扫码加群等功能的相关修改。此版主要新增功能运行截图(更多截图点此查看):
一、前言MobileIMSDK 是什么?MobileIMSDK 是一套专门为移动端开发的开源IM即时通讯框架,超轻量级、高度提炼,一套API优雅支持UDP 、TCP 、WebSocket 三种协议,支持iOS、Android、H5、标准Java平台,服务端基于Netty编写。工程地址是:1)Gitee码云地址:https://www.oschina.net/p/mobileimsdk2)Github托管地址:https://github.com/JackJiang2011/MobileIMSDK 本文将实现:1)基于springboot 集成 MobileIMSDK;2)开发IM服务端;3)开发客户端;4)实现Java客户端与客户端之间的通信。* 补充说明:本文所示Demo源码,请从文末“本文小结”的最后链接中下载!二、SpringBoot 集成 MobileIMSDK 准备2.1 MobileIMSDK下载MobileIMSDK下载地址:1)国外地址:MobileIMSDK的Github地址(最新版打包下载)2)国内地址:MobileIMSDK的码云gitee地址(访问速度快!,最新版打包下载)需要用到的lib包:1)服务端所需jar包: sdk_binary/Server/2)客服端所需jar包: sdk_binary/Client_TCP/java/如下图所示:2.2 pom.xml中引入相关依赖由于这里是maven项目,其中一部分jar包可通过maven仓库直接引入,而其余的则通过外部jar包引入方式使用即可~如下4个需作为外部jar包在pom.xml中引入 : <!-- [url=https://mvnrepository.com/artifact/com.google.code.gson/gson]https://mvnrepository.com/artifact/com.google.code.gson/gson[/url] --><dependency> <groupId>com.google.code.gson</groupId> <artifactId>gson</artifactId> <version>2.8.5</version></dependency> <!-- MobileIMSDK所需jar包依赖[注:这里是在本地lib中引入,maven中央仓库中暂无此jar包],要与<includeSystemScope>true</includeSystemScope>配合使用--><dependency> <groupId>com.zhengqing</groupId> <artifactId>MobileIMSDK4j</artifactId> <scope>system</scope> <systemPath>${project.basedir}/src/main/resources/lib/MobileIMSDK4j.jar</systemPath></dependency><dependency> <groupId>com.zhengqing</groupId> <artifactId>MobileIMSDKServerX_meta</artifactId> <scope>system</scope> <systemPath>${project.basedir}/src/main/resources/lib/MobileIMSDKServerX_meta.jar</systemPath></dependency><dependency> <groupId>com.zhengqing</groupId> <artifactId>swing-worker-1.2(1.6-)</artifactId> <scope>system</scope> <systemPath>${project.basedir}/src/main/resources/lib/swing-worker-1.2(1.6-).jar</systemPath></dependency><dependency> <groupId>com.zhengqing</groupId> <artifactId>MobileIMSDKServerX_netty</artifactId> <scope>system</scope> <systemPath>${project.basedir}/src/main/resources/lib/MobileIMSDKServerX_netty.jar</systemPath></dependency><plugins> <!-- maven打包插件 -> 将整个工程打成一个 fatjar --> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <!-- 作用:项目打成jar,同时把本地jar包也引入进去 --> <configuration> <includeSystemScope>true</includeSystemScope> </configuration> </plugin></plugins>三、开发服务端 3.1 与客服端的所有数据交互事件(实现ServerEventListener类)public class ServerEventListenerImpl implements ServerEventListener { private static Logger logger = LoggerFactory.getLogger(ServerEventListenerImpl.class); /** * 用户身份验证回调方法定义. * <p> * 服务端的应用层可在本方法中实现用户登陆验证。 * <br> * 注意:本回调在一种特殊情况下——即用户实际未退出登陆但再次发起来登陆包时,本回调是不会被调用的! * <p> * 根据MobileIMSDK的算法实现,本方法中用户验证通过(即方法返回值=0时)后 * ,将立即调用回调方法 {@link #onUserLoginAction_CallBack(int, String, IoSession)}。 * 否则会将验证结果(本方法返回值错误码通过客户端的 ChatBaseEvent.onLoginMessage(int dwUserId, int dwErrorCode) * 方法进行回调)通知客户端)。 * * @param userId 传递过来的准一id,保证唯一就可以通信,可能是登陆用户名、也可能是任意不重复的id等,具体意义由业务层决定 * @param token 用于身份鉴别和合法性检查的token,它可能是登陆密码,也可能是通过前置单点登陆接口拿到的token等,具体意义由业务层决定 * @param extra 额外信息字符串。本字段目前为保留字段,供上层应用自行放置需要的内容 * @param session 此客户端连接对应的 netty “会话” * @return 0 表示登陆验证通过,否则可以返回用户自已定义的错误码,错误码值应为:>=1025的整数 */ @Override public int onVerifyUserCallBack(String userId, String token, String extra, Channel session) { logger.debug("【DEBUG_回调通知】正在调用回调方法:OnVerifyUserCallBack...(extra="+ extra + ")"); return 0; } /** * 用户登录验证成功后的回调方法定义(可理解为上线通知回调). * <p> * 服务端的应用层通常可在本方法中实现用户上线通知等。 * <br> * 注意:本回调在一种特殊情况下——即用户实际未退出登陆但再次发起来登陆包时,回调也是一定会被调用。 * * @param userId 传递过来的准一id,保证唯一就可以通信,可能是登陆用户名、也可能是任意不重复的id等,具体意义由业务层决定 * @param extra 额外信息字符串。本字段目前为保留字段,供上层应用自行放置需要的内容。为了丰富应用层处理的手段,在本回调中也把此字段传进来了 * @param session 此客户端连接对应的 netty “会话” */ @Override public void onUserLoginAction_CallBack(String userId, String extra, Channel session) { logger.debug("【IM_回调通知OnUserLoginAction_CallBack】用户:"+ userId + " 上线了!"); } /** * 用户退出登录回调方法定义(可理解为下线通知回调)。 * <p> * 服务端的应用层通常可在本方法中实现用户下线通知等。 * * @param userId 下线的用户user_id * @param obj * @param session 此客户端连接对应的 netty “会话” */ @Override public void onUserLogoutAction_CallBack(String userId, Object obj, Channel session) { logger.debug("【DEBUG_回调通知OnUserLogoutAction_CallBack】用户:"+ userId + " 离线了!"); } /** * 通用数据回调方法定义(客户端发给服务端的(即接收user_id="0")). * <p> * MobileIMSDK在收到客户端向user_id=0(即接收目标是服务器)的情况下通过 * 本方法的回调通知上层。上层通常可在本方法中实现如:添加好友请求等业务实现。 * * <p style="background:#fbf5ee;border-radius:4px;"> * <b><font color="#ff0000">【版本兼容性说明】</font></b>本方法用于替代v3.x中的以下方法:<br> * <code>public boolean onTransBuffer_CallBack(String userId, String from_user_id * , String dataContent, String fingerPrint, int typeu, Channel session); * </code> * * @param userId 接收方的user_id(本方法接收的是发给服务端的消息,所以此参数的值肯定==0) * @param from_user_id 发送方的user_id * @param dataContent 数据内容(文本形式) * @param session 此客户端连接对应的 netty “会话” * @return true表示本方法已成功处理完成,否则表示未处理成功。此返回值目前框架中并没有特殊意义,仅作保留吧 * @since 4.0 */ @Override public boolean onTransBuffer_C2S_CallBack(Protocal p, Channel session) { // 接收者uid String userId = p.getTo(); // 发送者uid String from_user_id = p.getFrom(); // 消息或指令内容 String dataContent = p.getDataContent(); // 消息或指令指纹码(即唯一ID) String fingerPrint = p.getFp(); // 【重要】用户定义的消息或指令协议类型(开发者可据此类型来区分具体的消息或指令) inttypeu = p.getTypeu(); logger.debug("【DEBUG_回调通知】[typeu="+ typeu + "]收到了客户端"+ from_user_id + "发给服务端的消息:str="+ dataContent); returntrue; } /** * 通道数据回调函数定义(客户端发给客户端的(即接收方user_id不为“0”的情况)). * <p> * <b>注意:</b>本方法当且仅当在数据被服务端成功在线发送出去后被回调调用. * <p> * 上层通常可在本方法中实现用户聊天信息的收集,以便后期监控分析用户的行为等^_^。 * <p> * 提示:如果开启消息QoS保证,因重传机制,本回调中的消息理论上有重复的可能,请以参数 #fingerPrint * 作为消息的唯一标识ID进行去重处理。 * * <p style="background:#fbf5ee;border-radius:4px;"> * <b><font color="#ff0000">【版本兼容性说明】</font></b>本方法用于替代v3.x中的以下方法:<br> * <code>public void onTransBuffer_C2C_CallBack(String userId, String from_user_id * , String dataContent, String fingerPrint, int typeu); * * @param userId 接收方的user_id(本方法接收的是客户端发给客户端的,所以此参数的值肯定>0) * @param from_user_id 发送方的user_id * @param dataContent * @since 4.0 */ @Override public void onTransBuffer_C2C_CallBack(Protocal p) { // 接收者uid String userId = p.getTo(); // 发送者uid String from_user_id = p.getFrom(); // 消息或指令内容 String dataContent = p.getDataContent(); // 消息或指令指纹码(即唯一ID) String fingerPrint = p.getFp(); // 【重要】用户定义的消息或指令协议类型(开发者可据此类型来区分具体的消息或指令) inttypeu = p.getTypeu(); logger.debug("【DEBUG_回调通知】[typeu="+ typeu + "]收到了客户端"+ from_user_id + "发给客户端"+ userId + "的消息:str="+ dataContent); } /** * 通用数据实时发送失败后的回调函数定义(客户端发给客户端的(即接收方user_id不为“0”的情况)). * <p> * 注意:本方法当且仅当在数据被服务端<u>在线发送</u>失败后被回调调用. * <p> * <b>此方法存的意义何在?</b><br> * 发生此种情况的场景可能是:对方确实不在线(那么此方法里就可以作为离线消息处理了)、 * 或者在发送时判断对方是在线的但服务端在发送时却没有成功(这种情况就可能是通信错误 * 或对方非正常通出但尚未到达会话超时时限)。<br><u>应用层在此方法里实现离线消息的处理即可!</u> * * <p style="background:#fbf5ee;border-radius:4px;"> * <b><font color="#ff0000">【版本兼容性说明】</font></b>本方法用于替代v3.x中的以下方法:<br> * <code>public boolean onTransBuffer_C2C_RealTimeSendFaild_CallBack(String userId * , String from_user_id, String dataContent, String fingerPrint, int typeu); * </code> * * @param userId 接收方的user_id(本方法接收的是客户端发给客户端的,所以此参数的值肯定>0),此id在本方法中不一定保证有意义 * @param from_user_id 发送方的user_id * @param dataContent 消息内容 * @param fingerPrint 该消息对应的指纹(如果该消息有QoS保证机制的话),用于在QoS重要机制下服务端离线存储时防止重复存储哦 * @return true表示应用层已经处理了离线消息(如果该消息有QoS机制,则服务端将代为发送一条伪应答包 * (伪应答仅意味着不是接收方的实时应答,而只是存储到离线DB中,但在发送方看来也算是被对方收到,只是延 * 迟收到而已(离线消息嘛))),否则表示应用层没有处理(如果此消息有QoS机制,则发送方在QoS重传机制超时 * 后报出消息发送失败的提示) * @see #onTransBuffer_C2C_CallBack(Protocal) * @since 4.0 */ @Override public boolean onTransBuffer_C2C_RealTimeSendFaild_CallBack(Protocal p) { // 接收者uid String userId = p.getTo(); // 发送者uid String from_user_id = p.getFrom(); // 消息或指令内容 String dataContent = p.getDataContent(); // 消息或指令指纹码(即唯一ID) String fingerPrint = p.getFp(); // 【重要】用户定义的消息或指令协议类型(开发者可据此类型来区分具体的消息或指令) inttypeu = p.getTypeu(); logger.debug("【DEBUG_回调通知】[typeu="+ typeu + "]客户端"+ from_user_id + "发给客户端"+ userId + "的消息:str="+ dataContent + ",因实时发送没有成功,需要上层应用作离线处理哦,否则此消息将被丢弃."); returnfalse; }}3.2 服务端主动发起消息的QoS回调通知(实现MessageQoSEventListenerS2C类)public class MessageQoSEventS2CListnerImpl implements MessageQoSEventListenerS2C { private static Logger logger = LoggerFactory.getLogger(MessageQoSEventS2CListnerImpl.class); @Override public void messagesLost(ArrayList<Protocal> lostMessages) { logger.debug("【DEBUG_QoS_S2C事件】收到系统的未实时送达事件通知,当前共有" + lostMessages.size() + "个包QoS保证机制结束,判定为【无法实时送达】!"); } @Override public void messagesBeReceived(String theFingerPrint) { if(theFingerPrint != null) { logger.debug("【DEBUG_QoS_S2C事件】收到对方已收到消息事件的通知,fp="+ theFingerPrint); } }}3.3 服务端配置public class ServerLauncherImpl extends ServerLauncher { // 静态类方法:进行一些全局配置设置 static{ // 设置MobileIMSDK服务端的网络监听端口 ServerLauncherImpl.PORT = 7901; // 开/关Demog日志的输出 QoS4SendDaemonS2C.getInstance().setDebugable(true); QoS4ReciveDaemonC2S.getInstance().setDebugable(true); ServerLauncher.debug = true; // TODO 与客户端协商一致的心跳敏感模式设置// ServerToolKits.setSenseMode(SenseMode.MODE_10S); // 关闭与Web端的消息互通桥接器(其实SDK中默认就是false) ServerLauncher.bridgeEnabled = false; // TODO 跨服桥接器MQ的URI(本参数只在ServerLauncher.bridgeEnabled为true时有意义)// BridgeProcessor.IMMQ_URI = "amqp://js:19844713@192.168.31.190"; } // 实例构造方法 public ServerLauncherImpl() throws IOException { super(); } /** * 初始化消息处理事件监听者. */ @Override protected void initListeners() { // ** 设置各种回调事件处理实现类 this.setServerEventListener(newServerEventListenerImpl()); this.setServerMessageQoSEventListener(newMessageQoSEventS2CListnerImpl()); }}3.4 服务端启动类温馨小提示:这里由于小编将服务端和客户端集成在同一个项目中,因此如下配置:SpringBoot的CommandLineRunner接口主要用于实现在服务初始化后,去执行一段代码块逻辑(run方法),这段初始化代码在整个应用生命周期内只会执行一次!@Order(value = 1) :按照一定的顺序去执行,value值越小越先执行@Slf4j@Component@Order(value = 1)public class ChatServerRunner implements CommandLineRunner { @Override public void run(String... strings) throws Exception { log.info("================= ↓↓↓↓↓↓ 启动MobileIMSDK服务端 ↓↓↓↓↓↓ ================="); // 实例化后记得startup哦,单独startup()的目的是让调用者可以延迟决定何时真正启动IM服务 final ServerLauncherImpl sli = new ServerLauncherImpl(); // 启动MobileIMSDK服务端的Demo sli.startup(); // 加一个钩子,确保在JVM退出时释放netty的资源 Runtime.getRuntime().addShutdownHook(newThread(sli::shutdown)); }}如果服务端与客户端不在同一个项目 ,服务端可直接通过如下方式启动即可~ 四、开发客户端 4.1 客户端与IM服务端连接事件@Slf4jpublic class ChatBaseEventImpl implements ChatBaseEvent { @Override public void onLoginMessage(int dwErrorCode) { if(dwErrorCode == 0) { log.debug("IM服务器登录/连接成功!"); } else{ log.error("IM服务器登录/连接失败,错误代码:"+ dwErrorCode); } } @Override public void onLinkCloseMessage(int dwErrorCode) { log.error("与IM服务器的网络连接出错关闭了,error:"+ dwErrorCode); }}4.2 接收消息事件@Slf4jpublic class ChatTransDataEventImpl implements ChatTransDataEvent { @Override public void onTransBuffer(String fingerPrintOfProtocal, String userid, String dataContent, inttypeu) { log.debug("[typeu="+ typeu + "]收到来自用户"+ userid + "的消息:"+ dataContent); } @Override public void onErrorResponse(int errorCode, String errorMsg) { log.debug("收到服务端错误消息,errorCode="+ errorCode + ", errorMsg="+ errorMsg); }}4.3 消息是否送达事件@Slf4jpublic class MessageQoSEventImpl implements MessageQoSEvent { @Override// 对方未成功接收消息的回调事件 lostMessages:存放消息内容 public void messagesLost(ArrayList<Protocal> lostMessages) { log.debug("收到系统的未实时送达事件通知,当前共有"+ lostMessages.size() + "个包QoS保证机制结束,判定为【无法实时送达】!"); } @Override// 对方成功接收到消息的回调事件 public void messagesBeReceived(String theFingerPrint) { if(theFingerPrint != null) { log.debug("收到对方已收到消息事件的通知,fp="+ theFingerPrint); } }}4.4 MobileIMSDK初始化配置public class IMClientManager { private static IMClientManager instance = null; /** * MobileIMSDK是否已被初始化. true表示已初化完成,否则未初始化. */ privatebooleaninit = false; public static IMClientManager getInstance() { if(instance == null) { instance = new IMClientManager(); } return instance; } private IMClientManager() { initMobileIMSDK(); } public void initMobileIMSDK() { if(!init) { // 设置服务器ip和服务器端口 ConfigEntity.serverIP = "127.0.0.1"; ConfigEntity.serverPort = 8901; // MobileIMSDK核心IM框架的敏感度模式设置// ConfigEntity.setSenseMode(SenseMode.MODE_10S); // 开启/关闭DEBUG信息输出 ClientCoreSDK.DEBUG = false; // 设置事件回调 ClientCoreSDK.getInstance().setChatBaseEvent(newChatBaseEventImpl()); ClientCoreSDK.getInstance().setChatTransDataEvent(newChatTransDataEventImpl()); ClientCoreSDK.getInstance().setMessageQoSEvent(newMessageQoSEventImpl()); init = true; } }}4.5 连接IM服务端,发送消息服务类:public interface IChatService { /** * 登录连接IM服务器请求 * * @param username: 用户名 * @param password: 密码 * @return: void */ void loginConnect(String username, String password); /** * 发送消息 * * @param friendId: 接收消息者id * @param msg: 消息内容 * @return: void */ void sendMsg(String friendId, String msg);}服务实现类:@Slf4j@Service@Transactional(rollbackFor = Exception.class)public class ChatServiceImpl implements IChatService { @Override public void loginConnect(String username, String password) { // 确保MobileIMSDK被初始化哦(整个APP生生命周期中只需调用一次哦) // 提示:在不退出APP的情况下退出登陆后再重新登陆时,请确保调用本方法一次,不然会报code=203错误哦! IMClientManager.getInstance().initMobileIMSDK(); // * 异步提交登陆名和密码 new LocalUDPDataSender.SendLoginDataAsync(username, password) { /** * 登陆信息发送完成后将调用本方法(注意:此处仅是登陆信息发送完成,真正的登陆结果要在异步回调中处理哦)。 * @param code 数据发送返回码,0 表示数据成功发出,否则是错误码 */ protected void fireAfterSendLogin(int code) { if(code == 0) { log.debug("数据发送成功!"); } else{ log.error("数据发送失败。错误码是:"+ code); } } }.execute(); } @Override public void sendMsg(String friendId, String msg) { // 发送消息(异步提升体验,你也可直接调用LocalUDPDataSender.send(..)方法发送) new LocalUDPDataSender.SendCommonDataAsync(msg, friendId) { @Override protected void onPostExecute(Integer code) { if(code == 0) { log.debug("数据已成功发出!"); } else{ log.error("数据发送失败。错误码是:"+ code + "!"); } } }.execute(); }}五、编写Controller进行测试@RestController@RequestMapping("/api")@Api(tags = "聊天测试-接口")public class ChatController { @Autowired private IChatService chatService; @PostMapping(value = "/loginConnect", produces = Constants.CONTENT_TYPE) @ApiOperation(value = "登陆请求", httpMethod = "POST", response = ApiResult.class) public ApiResult loginConnect(@RequestParamString username, @RequestParamString password) { chatService.loginConnect(username, password); return ApiResult.ok(); } @PostMapping(value = "/sendMsg", produces = Constants.CONTENT_TYPE) @ApiOperation(value = "发送消息", httpMethod = "POST", response = ApiResult.class) public ApiResult sendMsg(@RequestParam String friendId, @RequestParam String msg) { chatService.sendMsg(friendId, msg); return ApiResult.ok(); }}启动项目,访问:http://127.0.0.1:8080/swagger-ui.html 1) loginConnect接口:任意输入一个账号密码登录连接IM服务端:控制台日志如下: 2)sendMsg接口:给指定用户发送消息:这里由于只有一个客户端,上一步登录了一个admin账号,因此小编给admin账号(也就是自己) 发送消息控制台日志如下:六、本文小结关于集成可参考MobileIMSDK给出的文档一步一步实现。该开源工程对应的官方文档比较齐全,需要哪个端,就去看对应端的手册就好了。1)Demo安装和使用客户端Demo安装和使用帮助(Android) [1]客户端Demo安装和使用帮助(iOS) [2]客户端Demo安装和使用帮助(Java) [3]客户端Demo演示和说明(H5) [4]服务端Demo安装和使用帮助 [5] new2)开发者指南客户端开发指南(Android)客户端开发指南(iOS)客户端开发指南(Java)客户端开发指南(H5)服务端开发指南3)API文档客户端SDK API文档(Android):TCP版、UDP版客户端SDK API文档(iOS):TCP版、UDP版客户端SDK API文档(Java):TCP版、UDP版客户端SDK API文档(H5):点此进入服务端SDK API文档另外:作者给出了通过Java GUI编程实现的一个小demo,我们可以先将其运行起来,先体验一下功能,代码量也不是太多,我们可以通过debug方式查看执行流程。清楚执行流程之后我们就可以将demo中的代码移植到我们自己的项目中加以修改运用于自己的业务中,切勿拿起就跑,否则一旦运气不好,将浪费更多的时间去集成,这样很不好! 最后:案例demo中相关代码注释都有,这里就简单说下整个流程吧:1)首先启动IM服务端2)用户在客户端登录一个用户与服务端建立连接保持通信( 客户端ChatServiceImpl中loginConnect方法为登录连接服务端事件;服务端ServerEventListenerImpl中onUserLoginVerify方法为服务端接收的上线通知事件);3)客户端通过 ChatServiceImpl中sendMsg方法发送一条消息,如果对方在线能接收消息则走服务端ServerEventListenerImpl中onTransferMessage4C2C方法,否则走onTransferMessage_RealTimeSendFaild方法;如果对方成功接收到消息,客户端将走MessageQoSEventImpl中messagesBeReceived事件,否则走messagesLost事件;4)客户端通过ChatMessageEvent中onRecieveMessage回调事件接收消息。附:本文案例demo源码下载:1)主地址:https://gitee.com/zhengqingya/java-workspace2)备地址:https://gitee.com/instant_messaging_network/java-workspace附录:更多IM聊天新手实践代码《跟着源码学IM(一):手把手教你用Netty实现心跳机制、断线重连机制》《跟着源码学IM(二):自已开发IM很难?手把手教你撸一个Andriod版IM》《跟着源码学IM(三):基于Netty,从零开发一个IM服务端》《跟着源码学IM(四):拿起键盘就是干,教你徒手开发一套分布式IM系统》《跟着源码学IM(五):正确理解IM长连接、心跳及重连机制,并动手实现》《跟着源码学IM(六):手把手教你用Go快速搭建高性能、可扩展的IM系统》《跟着源码学IM(七):手把手教你用WebSocket打造Web端IM聊天》《跟着源码学IM(八):万字长文,手把手教你用Netty打造IM聊天》《跟着源码学IM(九):基于Netty实现一套分布式IM系统》《跟着源码学IM(十):基于Netty,搭建高性能IM集群(含技术思路+源码)》
本文由融云技术团队原创分享,原题“IM 消息数据存储结构设计”,内容有修订。1、引言在如今的移动互联网时代,IM类产品已是我们生活中不可或缺的组成部分。像微信、钉钉、QQ等是典型的以 IM 为核心功能的社交产品。另外也有一些应用虽然IM功能不是核心,但IM能力也是其整个应用极其重要的组成部分,比如在线游戏、电商直播等应用。在IM技术应用场景越来越广泛的前提下,对即时通讯IM技术的学习和掌握就显的越来越有必要。在IM庞大的技术体系中,消息系统无疑是最核心的,而消息系统中,最关键的部分是消息的分发和存储,而离线消息和历史消息又是这个关键环节中不可回避的技术要点。本文将基于IM消息系统的技术实践,分享关于离线消息和历史消息的正确理解,以及具体的技术配合和实践,希望能为你的离线消息和历史消息技术设计带来最佳实践灵感。学习交流:- 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》- 开源IM框架源码:https://github.com/JackJiang2011/MobileIMSDK (本文同步发布于:http://www.52im.net/thread-3887-1-1.html)2、相关文章技术相关文章:《什么是IM系统的可靠性?》《闲鱼IM的在线、离线聊天数据同步机制优化实践》《闲鱼亿级IM消息系统的可靠投递优化实践》《一套亿级用户的IM架构技术干货(下篇):可靠性、有序性、弱网优化等》《IM消息送达保证机制实现(二):保证离线消息的可靠投递》《我是如何解决大量离线消息导致客户端卡顿的》融云技术团队分享的其它文章:《融云安卓端IM产品的网络链路保活技术实践》《全面揭秘亿级IM消息的可靠投递机制》《解密融云IM产品的聊天消息ID生成策略》《万人群聊消息投递方案的思考和实践》《基于WebRTC的实时音视频首帧显示时间优化实践》《融云IM技术分享:万人群聊消息投递方案的思考和实践》3、IM消息投递的一般做法在通常的IM消息系统中,对于实时消息、离线消息、历史消息大概都是下面这样的技术思路。对于在线用户:消息会直接实时发送到在线的接收方,消息发送完成后,服务器端并不会对消息进行落地存储。而对于离线的用户:服务器端会将消息存入到离线库,当用户登录后,从离线库中将离线消息拉走,然后服务器端将离线消息删除。这样实现的缺点就是消息不持久化,导致消息无法支持消息漫游,降低了消息的可靠性。(PS:实际上,这其实也不能算是缺点,因为一些场景下存储历史消息并不是必须的,所谓的消息漫游能力也不是必备的,比如微信。)而在我们设计的消息系统中,服务器只要接收到了发送方发上来的消息,在转发给接收方的同时也会在离线数据库及历史消息库中进行消息的落地存储,而历史消息的落地也就能支持消息漫游等相关功能了。4、什么是离线消息和历史消息?关于离线消息和历史消息,在技术上,我们是这样定义。1)离线消息:离线消息就是用户(即接收方)在离线过程中收到的消息,这些消息大多是用户比较关心的消息,具有一定的时效性。以我们的系统经验来说,我们的离线消息默认只保存最近七天的消息。用户(即接收方)在下次登录后会全量获取这些离线消息,然后在客户端根据聊天会话进行离线消息的UI展示(比如显示一个未读消息气泡等)。(PS:用户离线的可能性在技术上其实是由很多种情况组成的,比如对方不在线、对方网络断掉了、对方手机崩溃了、服务器发送时出错了等等,严格来讲——只要无法实时发送成的消息,都算“离线消息”。)2)历史消息:历史消息存储了用户所有的聊天消息,这些消息包括发出的消息以及接收到的消息。在客户端获取历史消息时,通常是按照会话进行分页获取的。以我们的系统经验来说,历史消息的存储时间我们设计默认为半年,当然这个时间可以按实际的产品运营规则来定,没有硬性规定。5、IM消息的发送及存储流程以下是我们系统整体的消息发送及存储流程: 如上图所示:当用户发送聊天消息到服务器端后,首先会进入到消息系统中,消息系统会对消息进行分发以及存储。这个过程中:对于在线的接收方,会选择直接推送消息。但是遇到接收方不在线或者是消息推送失败的情况下,也会有另外的消息获取方式,比如接收方会主动向服务器拉取未收到的消息。但是接收方何时来服务器拉取消息以及从哪里拉取是未知的,所以消息存入到离线库的意义也就在这里。消息系统存储离线的过程中,为了不影响整个系统的更为平稳,我们使用了MQ消息队列进行IO解偶,所以聊天消息实际上是异步存入到离线库中的(通过MQ进行慢IO解偶,这其实也是惯常做法)。在分发完消息后:消息服务会同步一份消息数据到历史消息服务中,历史消息服务同样会对消息进行落地存储。对于新的客户端设备:会有同步消息的需求(所谓的消息漫游能力),而这也正是历史消息的主要作用。在历史消息库中,客户端是可以拉取任意会话的全量历史消息的。6、IM离线消息、历史消息在存储逻辑上的区别6.1 概述通过上面的图中能清晰的看到:1)离线消息我们存储介质选用的是 Redis;2)历史消息我们选用的是 HBase。对于为什么选用不同的存储介质,其实我们考虑的是离线消息和历史消息不同的业务场景和读写模式。下面我们重点介绍一下离线消息和历史消息存储的区别。6.2 离线消息存储模式——“扩散写”离线消息的存储模式我们用的是扩散写。如上图所示:每个用户都有自己单独的收件箱和发件箱:1)收件箱存放的是需要向这个接收端同步的所有消息;2)发件箱里存放的是发送端发出的所有消息。以单聊为例:聊天中的两人会话中,消息会产生两次写,即发送者的发件箱和接收端的收件箱。而在群的场景下:写入会被更加的放大(扩散),如果群里有 N 个人,那一条群消息就会被扩散写 N 次。小结一下:1)扩散写的优点是:接收端的逻辑会非常清晰简单,只需要从收件箱里读取一次即可,大大降低了同步消息所需的读的压力;2)扩散写的缺点是:写入会被成指数地放大,特别是针对群这种场景。6.3 历史消息存储模式——“扩散读”历史消息的存储模式我们用的是扩散读。因为历史消息中,每个会话都保存了整个会话的全量消息。在扩散读这种模式下,每个会话的消息只保存一次。对比扩散写模式,扩散读的优点和缺点如下:1)优点是:写入次数大大降低,特别是针对群消息,只需要存一次即可;2)缺点是:接收端接收消息非常的复杂和低效,因为这种模式客户端想拉取到所有消息就只能每个会话同步一次,读就会被放大,而且可能会产生很多次无效的读,因为有些会话可能根本没有新消息。6.4 小结在 IM 这种应用场景下,通常会用到扩散写这种消息同步模型,一条消息产生一条,但是可能会被读多次,是典型的读多写少的场景。一个优化好的IM系统,必须从设计上平衡读写压力,避免读或者写任意一个维度达到天花板。当然扩散写这种模式也有其弊端,比如万人群,会导致一条消息,写入了一万次。综合来讲:我们需要根据自己的业务场景做相应设计选择,以我们的IM系统为例,就是是根据了离线和历史消息的不同场景选择了写扩散和读扩散的组合模式。适合的才是最好的,没有必要死搬硬套理论。7、IM客户端的拉取消息逻辑7.1 离线消息拉取逻辑对于IM客户端而言,离线消息的获取针对的是自己的整个离线消息,包括所有的会话(直白了说,就是上线时拉取此次离线过程中的所有未收取的离线消息)。离线消息的获取是自上而下的方式(按时间序),我们的经验是一次获取 200 条(PS:如果离线消息过多,会分页多次拉取,拉取1“次”可以理解为拉取1“页”)。在客户端拉取离线消息的信令中,需要带上当前客户端缓存的消息的最大时间戳。通过上节的图我们应该知道,离线消息我们存储的是一个线性结构(指的是按时间顺序),Server 会根据这个时间戳向下查找离线消息。当重装或者新安装 App 时,客户端的“当前客户端缓存的消息的最大时间戳”可以传 0 上来。Server 也会缓存客户端拉取到的最后一条消息的时间戳,然后根据业务场景,客户端类型等因素来决定从哪里开始拉取,如果没有拉取完 Server 会在拉取消息的应答中带相应的标记位,告诉客户端继续拉取,客户端循环拉取,直到所有离线消息拉完。7.2 历史消息拉取逻辑历史消息的获取通常针对的是单一会话。在拉取过程中,需要向服务端提交两个参数:1)对方的 ID(如果是单聊的话就是对方的 UserID,如果是群则是群组ID);2)当前会话的最前面消息的时间戳(即当前会话最老一条消息的时间戳)。Server据这两个参数,可以定位到这个客户端的此会话,然后一次获取 20 条历史消息。消息的拉取时序上采用的是自下而上的方式(也就是时间序逆序),即从最后面往前翻。只要有消息,客户端可以一直向前翻,手动触发获取会话的历史消息。上面的拉取逻辑,在IM界面功能上通常对应的是下拉或点击“加载更多”,比如这样:8、本文小结本文主要分享了IM中有关离线消息和历史消息的正确,主要包括离线消息和历史消息的区别,以及二者在存储、分发、拉取逻辑方面的最佳践等。如对文中内容有异议,欢迎留言讨论。9、参考资料[1] 一套海量在线用户的移动端IM架构设计实践分享(含详细图文)[2] 一套原创分布式即时通讯(IM)系统理论架构方案[3] 从零到卓越:京东客服即时通讯系统的技术架构演进历程[4] 一套亿级用户的IM架构技术干货(上篇):整体架构、服务拆分等[5] 闲鱼亿级IM消息系统的架构演进之路[6] 闲鱼亿级IM消息系统的可靠投递优化实践[7] 闲鱼亿级IM消息系统的及时性优化实践[8] 基于实践:一套百万消息量小规模IM系统技术要点总结[9] IM消息送达保证机制实现(一):保证在线实时消息的可靠投递[10] 理解IM消息“可靠性”和“一致性”问题,以及解决方案探讨[11] 零基础IM开发入门(一):什么是IM系统?(本文同步发布于:http://www.52im.net/thread-3887-1-1.html)
本文由小米技术团队分享,原题“小爱接入层单机百万长连接演进”,有修订。1、引言小爱接入层是小爱云端负责设备接入的第一个服务,也是最重要的服务之一,本篇文章介绍了小米技术团队2020至2021年在这个服务上所做的一些优化和尝试,最终将单机可承载长连接数从30w提升至120w+,节省了机器30+台。提示:什么是“小爱”?小爱(全名“小爱同学”)是小米旗下的人工智能语音交互引擎,搭载在小米手机、小米AI音箱、小米电视等设备中,在个人移动、智能家庭、智能穿戴、智能办公、儿童娱乐、智能出行、智慧酒店、智慧学习共八大类场景中使用。(本文同步发布于:http://www.52im.net/thread-3860-1-1.html)2、专题目录本文是专题系列文章的第7篇,总目录如下:《长连接网关技术专题(一):京东京麦的生产级TCP网关技术实践总结》《长连接网关技术专题(二):知乎千万级并发的高性能长连接网关技术实践》《长连接网关技术专题(三):手淘亿级移动端接入层网关的技术演进之路》《长连接网关技术专题(四):爱奇艺WebSocket实时推送网关技术实践》《长连接网关技术专题(五):喜马拉雅自研亿级API网关技术实践》《长连接网关技术专题(六):石墨文档单机50万WebSocket长连接架构实践》《长连接网关技术专题(七):小米小爱单机120万长连接接入层的架构演进》(* 本文)3、什么是小爱接入层整个小爱的架构分层如下:接入层主要的工作在鉴权授权层和传输层,它是所有小爱设备和小爱大脑交互的第一个服务。由上图我们知道小爱接入层的重要功能有如下几个:1)安全传输和鉴权:维护设备和大脑的安全通道,保障身份认证有效和传输数据安全;2)维护长连接:维持设备和大脑的长连接(Websocket等),做好连接状态存储,心跳维护等工作;3)请求转发:针对每一次小爱设备的请求做好转发,保障每一次请求的稳定。4、早期接入层的技术实现小爱接入层最早的实现是基于Akka和Play,我们使用它们搭建了第一个版本,该版本特点如下:1)基于Akka我们基本做到了初步的异步化,保障核心线程不被阻塞,性能尚可。2)Play框架天然支持Websocket,因此我们在有限的人力下能够快速搭建和实现,且能够保障协议实现的标准性。5、早期接入层的技术问题随着小爱长连接的数量突破千万大关,针对早期的接入层方案,我们发现了一些问题。主要的问题如下:1)长连接数量上来后,需要维护的内存数据越来越多,JVM的GC成为不可忽略的性能瓶颈,且一旦代码写的不好有GC风险。经过之前事故分析,Akka+Play版的接入层其单实例长连接数量的上限在28w左右。2)老版本的接入层实现比较随意,其Akka Actor之间存在非常多的状态依赖而不是基于不可变的消息传递这样使得Actor之间的通信变成了函数调用,导致代码可读性差且维护很困难,没有发挥出Akka Actor在构建并发程序的优势。3)作为接入层服务,老版本对协议的解析是有很强的依赖的,这导致它要随着版本变动而频繁上线,其上线会引起长连接重连,随时有雪崩的风险。4)由于依赖Play框架,我们发现其长连接打点有不准确的问题(因为拿不到底层TCP连接的数据),这个会影响我们每日巡检对服务容量的评估,且依赖其他框架在长连接数量上来后我们没有办法做更细致的优化。6、新版接入层的设计目标基于早期接入层技术方案的种种问题,我们打算重构接入层。对于新版接入层我们制定的目标是:1)足够稳定:上线尽可能不断连接且服务稳定;2)极致性能:目标单机至少100w长连接,最好不要受GC影响;3)最大限度可控:除了底层网络I/O的系统调用,其他所有代码都要是自己实现/或者内部实现的组件,这样我们有足够的自主权。于是,我们开始了单机百万长连接的漫漫实践之路。。。7、新版接入层的优化思路7.1 接入层的依赖关系接入层与外部服务的关系理清如下:7.2 接入层的功能划分接入层的主要功能划分如下:1)WebSocket解析:收到的客户端字节流,要按照WebSocket协议要求解析出数据;2)Socket状态保持:存储连接的基本状态信息;3)加密解密:与客户端通讯的所有数据都是加密过的,而与后端模块之间传输是json明文的;4)顺序化:同一个物理连接上,先后两个请求A、B到达服务器,后端服务中B可能先于A得到了应答,但是我们收到B不能立刻发送给客户端,必须等待A完成后,再按照A,B的顺序发给客户端;5)后端消息分发:接入层后面不止对接单个服务,可能根据不同的消息转发给不同的服务;6)鉴权:安全相关验证,身份验证等。7.3 接入层的拆分思路把之前的单一模块按照是否有状态,拆分为两个子模块。具体如下:1)前端:有状态,功能最小化,尽量少上线;2)后端:无状态,功能最大化,上线可做到用户无感知。所以,按照上面的原则,理论上我们会做出这样的功能划分,即前端很小、后端很大。示意图如下图所示。8、新版接入层的技术实现8.1 总览模块拆分为前后端:1)前端有状态,后端无状态;2)前后端是独立进程,同机部署。补充:前端负责建立与维护设备长连接的状态,为有状态服务;后端负责具体业务请求,为无状态服务。后端服务上线不会导致设备连接断开重连及鉴权调用,避免了长连接状态因版本升级或逻辑调整而引起的不必要抖动;前端使用CPP实现:1)Websocket协议完全自己解析:可以从Socket层面获取所有信息,任何Bug都可以处理;2)更高的CPU利用率:没有任何额外JVM代价,无GC拖累性能;3)更高的内存利用率:连接数量变大后与连接相关的内存开销变大,自己管理可以极端优化。后端暂时使用Scala实现:1)已实现的功能直接迁移,比重写代价要低得多;2)依赖的部分外部服务(比如鉴权)有可直接利用的Scala(Java)SDK库,而没有C++版本,若用C++重写代价非常大;3)全部功能无状态化改造,可以做到随时重启而用户无感知。通讯使用ZeroMQ:进程间通讯最高效的方式是共享内存,ZeroMQ基于共享内存实现,速度没问题。8.2 前端实现整体架构: 如上图所示,由四个子模块组成:1)传输层:Websocket协议解析,XMD协议解析;2)分发层:屏蔽传输层的差异,不管传输层使用的什么接口,在分发层转化成统一的事件投递到状态机;3)状态机层:为了实现纯异步服务,使用自研的基于Actor模型的类Akka状态机框架XMFSM,这里面实现了单线程的Actor抽象;4)ZeroMQ通讯层:由于ZeroMQ接口是阻塞实现,这一层通过两个线程分别负责发送和接收。8.2.1)传输层:WebSocket 部分使用 C++ 和 ASIO 实现 websocket-lib。小爱长连接基于WebSocket协议,因此我们自己实现了一个WebSocket长连接库。这个长连接库的特点是:a. 无锁化设计,保障性能优异;b. 基于BOOST ASIO 开发,保障底层网络性能。压测显示该库的性能十分优异的:这一层同时也承担了除原始WebSocket外,其他两种通道的的收发任务。目前传输层一共支持以下3种不同的客户端接口:a. websocket(tcp):简称ws;b. 基于ssl的加密websocket(tcp):简称wss;c. xmd(udp):简称xmd。8.2.2)分发层:把不同的传输层事件转化成统一事件投递到状态机,这一层起到适配器的作用,确保无论前面的传输层使用哪种类型,到达分发层变都变成一致的事件向状态机投递。8.2.3)状态机处理层:主要的处理逻辑都位于这一层中,这里非常重要的一个部分是对于发送通道的封装。对于小爱应用层协议,不同的通道处理逻辑是完全一致的,但是在处理和安全相关逻辑上每个通道又有细节差异。比如:a. wss 收发不需要加解密,加解密由更前端的Nginx做了,而ws需要使用AES加密发送;b. wss 在鉴权成功后不需要向客户端下发challenge文本,因为wss不需要做加解密;c. xmd 发送的内容与其他两个不同,是基于protobuf封装的私有协议,且xmd需要处理发送失败后的逻辑,而ws/wss不用考虑发送失败的问题,由底层Tcp协议保证。针对这种情况:我们使用C++的多态特性来处理,专门抽象了一个Channel接口,这个接口中提供的方法包含了一个请求处理的一些关键差异步骤,比如如何发送消息到客户端,如何stop连接,如何处理发送失败等等。对于3种(ws/wss/xmd)不同的发送通道,每个通道有自己的Channel实现。客户端连接对象一创建,对应类型的具体Channel对象就立刻被实例化。这样状态机主逻辑中只实现业务层的公共逻辑即可,当在有差异逻辑调用时,直接调用Channel接口完成,这样一个简单的多态特性帮助我们分割了差异,确保代码整洁。8.2.4)ZeroMQ 通讯层:通过两个线程将ZeroMQ的读写操作异步化,同时负责若干私有指令的封装和解析。8.3 后端实现8.3.1)无状态化改造:后端做的最重要改造之一就是将所有与连接状态相关的信息进行剔除。整个服务以 Request(一次连接上可以传输N个Request)为核心进行各种转发和处理,每次请求与上一次请求没有任何关联。一个连接上的多次请求在后端模块被当做独立请求处理。8.3.2)架构:Scala 服务采用 Akka-Actor 架构实现了业务逻辑。服务从 ZeroMQ 收到消息后,直接投递到 Dispatcher 中进行数据解析与请求处理,在 Dispatcher 中不同的请求会发送给对应的 RequestActor进行 Event 协议解析并分发给该 event 对应的业务 Actor 进行处理。最后将处理后的请求数据通过XmqActor 发送给后端 AIMS&XMQ 服务。一个请求在后端多个 Actor 中的处理流程:8.3.3)Dispatcher 请求分发:前端与后端之间通过 Protobuf 进行交互,避免了Json 解析的性能消耗,同时使得协议更加规范化。后端服务从 ZeroMQ 收到消息后,会在 DispatcherActor 中进行PB协议解析并根据不同的分类(简称CMD)进行数据处理,分类包括如下几种。* BIND 命令:鉴权功能,由于鉴权功能逻辑复杂,使用C++语言实现起来较为困难,目前依然放在 scala 业务层进行鉴权。该部分对设备端请求的 HTTP Headers 进行解析,提取其中的 token 进行鉴权,并将结果返回前端。* LOGIN 命令:设备登入,设备鉴权通过后当前连接已成功建立,此时会进行 Login 命令的执行,用于将该长连接信息发送至AIMS并记录于Varys服务中,方便后续的主动下推等功能。在 Login 过程中,服务首先将请求 Account 服务获取长连接的 uuid(用于连接过程中的路由寻址),然后将设备信息+uuid 发送至AIMS进行设备登入操作。* LOGOUT 命令:设备登出,设备在与服务端断开连接时需要进行 Logout 操作,用于从 Varys 服务中删除该长连接记录。* UPDATE 与 PING 命令:a. Update 命令,设备状态信息更新,用于更新该设备在数据库中保存的相关信息;b. Ping 命令,连接保活,用于确认该设备处于在线连接状态。* TEXT_MESSAGE 与 BINARY_MESSAGE:文本消息与二进制消息,在收到文本消息或二进制消息时将根据 requestid 发送给该请求对应的RequestActor进行处理。8.3.4)Request 请求解析:针对收到的文本和二进制消息,DispatcherActor 会根据 requestId 将其发送给对应的RequestActor进行处理。其中:文本消息将会被解析为Event请求,并根据其中的 namespace 和 name 将其分发给指定的业务Actor。二进制消息则会根据当前请求的业务场景被分发给对应的业务Actor。8.4 其他优化在完成新架构 1.0 调整过程中,我们也在不断压测长连接容量,总结几点对容量影响较大的点。8.4.1)协议优化:a. JSON替换为Protobuf: 早期的前后端通信使用的是 json 文本协议,后来发现 json 序列化、反序列化这部分对CPU的占用较大,改为了 protobuf 协议后,CPU占用率明显下降。b. JSON支持部分解析:业务层的协议是基于json的,没有办法直接替换,我们通过"部分解析json"的方式,只解析很小的 header 部分拿到 namespace 和 name,然后将大部分直接转发的消息转发出去,只将少量 json 消息进行完整反序列化成对象。此种优化后CPU占用下降10%。8.4.2)延长心跳时间:在第一次测试20w连接时,我们发现在前后端收发的消息中,一种用来保持用户在线状态的心跳PING消息占了总消息量的75%,收发这个消息耗费了大量CPU。因此我们延长心跳时间也起到了降低CPU消耗的目的。8.4.3)自研内网通讯库:为了提高与后端服务通信的性能,我们使用自研的TCP通讯库,该库是基于Boost ASIO开发的一个纯异步的多线程TCP网络库,其卓越的性能帮助我们将连接数提升到120w+。9、未来规划经过新版架构1.0版的优化,验证了我们的拆分方向是正确的,因为预设的目标已经达到:1)单机承载的连接数 28w => 120w+(普通服务端机器 16G内存 40核 峰值请求QPS过万),接入层下线节省了50%+的机器成本;2)后端可以做到无损上线。再重新审视下我们的理想目标,以这个为方向,我们就有了2.0版的雏形:具体就是:1)后端模块使用C++重写,进一步提高性能和稳定性。同时将后端模块中无法使用C++重写的部分,作为独立服务模块运维,后端模块通过网络库调用;2)前端模块中非必要功能尝试迁移到后端,让前端功能更少,更稳定;3)如果改造后,前端与后端处理能力差异较大,考虑到ZeroMQ实际是性能过剩的,可以考虑使用网络库替换掉ZeroMQ,这样前后端可以从1:1单机部署变为1:N多机部署,更好的利用机器资源。2.0版目标是:经过以上改造后,期望单前端模块可以达到200w+的连接处理能力。10、参考资料[1] 上一个10年,著名的C10K并发连接问题[2] 下一个10年,是时候考虑C10M并发问题了[3] 一文读懂高性能网络编程中的线程模型[4] 深入操作系统,一文读懂进程、线程、协程[5] Protobuf通信协议详解:代码演示、详细原理介绍等[6] WebSocket从入门到精通,半小时就够![7] 如何让你的WebSocket断网重连更快速?[8] 从100到1000万高并发的架构演进之路学习交流:- 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》- 开源IM框架源码:https://github.com/JackJiang2011/MobileIMSDK (本文同步发布于:http://www.52im.net/thread-3860-1-1.html)
本文由阿里闲鱼技术团队书闲分享,原题“如何有效缩短闲鱼消息处理时长”,有修订和改动,感谢作者的分享。1、引言闲鱼技术团队围绕IM这个技术范畴,已经分享了好几篇实践性总结文章,本篇将要分享的是闲鱼IM系统中在线和离线聊天消息数据的同步机制上所遇到的一些问题,以及实践性的解决方案。学习交流:- 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》- 开源IM框架源码:https://github.com/JackJiang2011/MobileIMSDK (本文已同步发布于:http://www.52im.net/thread-3856-1-1.html)2、系列文章本文是系列文章的第7篇,总目录如下:《阿里IM技术分享(一):企业级IM王者——钉钉在后端架构上的过人之处》《阿里IM技术分享(二):闲鱼IM基于Flutter的移动端跨端改造实践》《阿里IM技术分享(三):闲鱼亿级IM消息系统的架构演进之路》《阿里IM技术分享(四):闲鱼亿级IM消息系统的可靠投递优化实践》《阿里IM技术分享(五):闲鱼亿级IM消息系统的及时性优化实践》《阿里IM技术分享(六):闲鱼亿级IM消息系统的离线推送到达率优化》《阿里IM技术分享(七):闲鱼IM的在线、离线聊天数据同步机制优化实践》(* 本文)3、问题背景随着用户数的快速增长,闲鱼IM系统也迎来了前所未有的挑战。历经多年的业务迭代,客户端侧IM的代码已经因为多年的迭代层次结构不足够清晰,之前一些隐藏起来的聊天数据同步问题,也随着用户数的增大而被放大。这里面的具体流程在于:后台需要同步到用户端侧的数据包,后台会根据数据包的业务类型划分成不同的数据域,数据包在对应域里面存在唯一且连续的编号,每一个数据包发送到端侧并且被成功消费后,端侧会记录当前每一个数据域已经同步过的版本编号,下一次数据同步就以本地数据域的编号开始,不断的同步到客户端。当然用户不会一直在线等待消息,所以之前端侧采用了推拉结合的方式保证数据的同步。具体就是:1)客户端在线时:使用ACCS实时的将最新的数据内容推送到客户端(ACCS是淘宝无线向开发者提供全双工、低延时、高安全的通道服务);2)客户端从离线状态启动后:根据本地的数据域编号,拉取不在线时候的数据差;3)当数据获取出现黑洞时:触发数据同步拉取(“黑洞”即指数据包Version不连续的状态)。4、问题分析当前的聊天数据同步策略确实是可以基本保障IM的数据同步的,但是也伴随着一些隐含的问题。这些隐含的问题主要有:1)短时间密集数据推送时,会快速的触发多次数据域同步。域同步回来的数据如果存在问题,又会触发新一轮的同步,造成网络资源的浪费。冗余数据包/无效的数据内容会占用有效内容的处理资源,又对CPU和内存资源造成浪费;2)数据域中的数据包客户端是否正常消费,服务端侧无感知,只能被动地根据当前数据域信息返回数据;3)数据收取/消息数据体解析/存储落库逻辑拆分不够清晰,无法针对性的对某一层的代码拆分替换进行ABTest。针对上述问题,我们对闲鱼IM进行了分层改造——即抽离数据同步层。这样优化,除了希望以后这个数据的同步内容可以用在IM之外,也希望随着稳定性的增加,赋能其他的业务场景。接下来的内容,我们重点来看下解决客户端侧闲鱼IM聊天数据同步问题的一些实践思路。5、优化思路5.1 分层拆分对于服务端来说:业务侧产出数据包后,会拼接上当前的数据域信息,然后通过数据同步层将数据推送到端侧。对于客户端来说:接收到数据包后,会根据当前的数据域信息,来确定需要消费数据包的业务方,确保数据包在数据域内完整连续后,将数据体脱壳后交于业务侧消费,并且应答消费的状况。数据同步层的抽取:把数据同步中的加壳、脱壳、校验、重试流程封装到一起,可以让上层业务只需要关心自己需要监听的数据域信息,然后当这些数据域更新数据的时候,可以获取到这些数据进行消费,而不再需要关心数据包是否完整。这样做的话:1)业务侧只需要关心业务侧对接的协议;2)数据侧只需要关心数据侧包装的协议;3)网络层负责真实的数据传输。整体的架构原理如下:总结一下就是:1)对齐数据层数据传输协议、描述当前数据包体数据域信息;2)将消息的处理/合并/落库抽离成数据消费者;3)上下楼依赖抽象化,去除对于具体实现的依赖。5.2 数据层结构模型基于对于数据模型剥离和对当下遇见问题的解决方案规整,将数据同步层拆分为下图这样的架构。具体的实施思路就是:1)App启动时建立ACCS长链接服务,保证推推送信道链接,并且根据当前本地数据域信息触发一次数据拉取;2)数据消费者注册消费者信息和需要监听的数据域信息,这里是一对多的关系;3)新的数据抵达端侧后,将数据包放到指定的数据域的缓冲池,批量数据归纳结束后,重新出发数据的读取;4)根据当前数据域优先级弹出最高优的数据包,判断数据域版本是否符合消费者要求,符合则将数据包脱壳后丢给消费者消费,不符合则根据上一次正确的数据包的域信息触发增量的数据域同步拉取;5)触发数据域同步拉取时,block数据读取,此时通过ACCS触达的数据依旧会在继续归纳到指定的数据域队列中,等待数据域同步拉取结果,将数据包进行排序、去重,合并到对应的数据域队列中。然后重新激活数据读取;6)数据包体被消费者正确消费后,更新域信息并且通过上行信道告知服务端已经正确处理的数据域信息。* 数据域同步协议:Region中携带的数据不必过多,但需将数据包的内容描述清楚,具体是:1)目标用户的ID,用以确定目标数据包是否正确;2)数据域ID和优先级信息;3)当前数据包的域优先级版本。* 排序策略:针对于域数据归纳,无论是在写入数据的时候进行排序还是在读取的时候进行查找都需要进行一次排序的操作,时间复杂度最优也是O(logn)级别的。在实际coding中发现由于在一个数据域里面,数据包的Version信息是连续唯一并且不存在断层的,上一个稳定消费的数据体的Version信息自增就是下一个数据包的Version,所以这里采用了以Versio为主键的Map存储,既降低了时间复杂度,也使得唯一标识的数据包后抵达端侧的包内容可以覆盖之前的包内容。6、新的问题及解决策略6.1 多数据来源和唯一数据消费的平衡每当产生一条针对于当前用户的数据包:1)如果当前ACCS长链接存在,就会通过ACCS将数据包推送到客户端;2)如果App切换到后台一段时间,或者直接被杀死,ACCS链接断开,那么只能通过离线推送到用户的通知面板。所以:每当App切换到活跃状态,都需要根据当前本地存储的数据域信息从后台触发一次数据同步。数据包触达到客户端侧的来源主要是ACCS长链接的推送和域同步时的拉取,但是数据包的消费是根据数据域的监听划分的唯一消费者,也就是同一时间内只能消费一个数据包。在压力测试中:当后台短时间内密集的将数据包通过ACCS推送到端侧时,端侧接收到的数据包并不有序,不连续的数据包域版本又会触发新的数据域同步,导致同样的一份数据包会通过两个不同的渠道多次的触达到端侧,浪费了不必要的流量。当数据域同步时:这个时间节点产生的新数据包也会推送到端侧,数据体有效,并且需要被正确的消费。针对上述这些问题的解决策略:即在数据消费和数据获取中间装载一个数据中间层,当触发数据域同步的时候block数据的读取并且ACCS推送下来的数据包会被存放在一个数据的中转站里面,当数据域同步拉取的数据回来后,对数据进行合并后再重启数据读取流程。6.2 数据域优先级需要推送到客户端侧的数据包,根据业务的不同优先级也有不同的划分。用户和用户的聊天产生的数据包会比运营类的消息的数据包优先级要高一些,所以要当多优先级的数据包快速的抵达端侧时,高优先级数据域的数据包需要被优先消费,而数据域的优先级也是需要动态调整,需要不断变换的优先级策略。针对这个问题的解决策略:不同的数据域,产生不同的数据队列,高优队列里面的数据包会被优先读取消费。每一个数据包体中带回的数据域信息,都可以标注当前的数据域优先级,当数据域优先级发生变化的时候,调整数据包消费优先级策略。7、优化后的效果除去结构上分层梳理,使得数据同步层和依赖的服务内容可便捷解耦/每一个环节可插拔之外,数据同步中对于消息消费时长/流量节省,压力测试场景下优化效果更加明显。在“500ms内100条全乱序数据包推送”压力测试场景下:1)消息处理时长(接收-上屏)缩短 31%;2)流量损耗(最终拉取到端侧数据包累积大小)降低35%。8、后续的优化计划8.1 数据同步层能力提升数据同步侧的目标,既要保证数据包完整的到达端侧,又要在保证稳定性的前提下尽可能的减少数据的拉取,使得每一次数据的获取都有效。后续数据同步层会着手于有效数据率和到达率进行更进一步的优化。针对不同的场景,动态智能调整数据同步的优先级策略。阻塞式长链接推送,保证同一时间只存在推模式或者拉模式,进一步减少冗余数据包的推送。8.2 IM端侧整体架构升级升级数据同步层策略主要还是要提升IM的能力,将数据同步分层后,接下来就是将消息的处理流程化,对每一个流程都可监控可回溯,提升IM数据包的正确解析存储和落库率。细化一下就是:1)在数据来源侧剥离开后,后续对IM的整改也会逐步的将消息的处理分层剥离;2)消息处理关键节点的流程式上报、建立完整的监控体系,让问题发现先于用户舆情;3)消息完整性的动态自检,最小化数据补偿补全。9、参考资料[1] IM单聊和群聊中的在线状态同步应该用“推”还是“拉”?[2] IM群聊消息如此复杂,如何保证不丢不重?[3] 一套高可用、易伸缩、高并发的IM群聊、单聊架构方案设计实践[4] 一套亿级用户的IM架构技术干货(下篇):可靠性、有序性、弱网优化等[5] 从新手到专家:如何设计一套亿级消息量的分布式IM系统[6] 融云技术分享:全面揭秘亿级IM消息的可靠投递机制[7] 移动端IM中大规模群消息的推送如何保证效率、实时性?[8] 现代IM系统中聊天消息的同步和存储方案探讨[9] 新手入门一篇就够:从零开发移动端IM[10] IM消息送达保证机制实现(一):保证在线实时消息的可靠投递[11] IM消息送达保证机制实现(二):保证离线消息的可靠投递[12] 零基础IM开发入门(四):什么是IM系统的消息时序一致性?[13] IM开发干货分享:我是如何解决大量离线消息导致客户端卡顿的(本文已同步发布于:http://www.52im.net/thread-3856-1-1.html)
本文由作者小林coding分享,来自公号“小林coding”,有修订和改动。1、引言说到TCP协议,对于从事即时通讯/IM这方面应用的开发者们来说,再熟悉不过了。随着对TCP理解的越来越深入,很多曾今碰到过但没时间深入探究的TCP技术概念或疑问,现在是时候回头来恶补一下了。本篇文章,我们就从系统层面深入地探讨一个有趣的TCP技术问题:拔掉网线后,再插上,原本的这条TCP连接还在吗?或者说它还“好”吗?可能有的人会说:网线都被拔掉了,那说明物理层(也叫实体层)被断开了(关于网络协议分层模型请见《快速理解网络通信协议(上篇)》),那在物理层之上的传输层理应也会断开,所以原本的 TCP 连接就不会存在的了。就好像我们拨打有线电话的时候,如果某一方的电话线被拔了,那么本次通话就彻底断了。答案真的是这样吗?可能并非你理解的这样哦,一起跟随笔者来深入探讨一下。2、系列文章本文是系列文章中的第14篇,本系列文章的大纲如下:《不为人知的网络编程(一):浅析TCP协议中的疑难杂症(上篇)》《不为人知的网络编程(二):浅析TCP协议中的疑难杂症(下篇)》《不为人知的网络编程(三):关闭TCP连接时为什么会TIME_WAIT、CLOSE_WAIT》《不为人知的网络编程(四):深入研究分析TCP的异常关闭》《不为人知的网络编程(五):UDP的连接性和负载均衡》《不为人知的网络编程(六):深入地理解UDP协议并用好它》《不为人知的网络编程(七):如何让不可靠的UDP变的可靠?》《不为人知的网络编程(八):从数据传输层深度解密HTTP》《不为人知的网络编程(九):理论联系实际,全方位深入理解DNS》《不为人知的网络编程(十):深入操作系统,从内核理解网络包的接收过程(Linux篇)》《不为人知的网络编程(十一):从底层入手,深度分析TCP连接耗时的秘密》《不为人知的网络编程(十二):彻底搞懂TCP协议层的KeepAlive保活机制》《不为人知的网络编程(十三):深入操作系统,彻底搞懂127.0.0.1本机网络通信》《不为人知的网络编程(十四):拔掉网线再插上,TCP连接还在吗?一文即懂!》(* 本文)3、比较笼统的答案3.1 答案引言里我们说到:有人认为,网线都被拔掉了,那说明物理层被断开,那么物理层之上的传输层肯定也会断开,所以原来的 TCP 连接自然也就不存在了。(PS:计算机网络分层详解请见《史上最通俗计算机网络分层详解》)上面这个逻辑是有问题的。问题在于:错误的认为拔掉网线这个动作会影响传输层,事实上并不会影响!实际上:TCP 连接在 Linux 内核中是一个名为 struct socket 的结构体,该结构体的内容包含 TCP 连接的状态等信息。所以:当拔掉网线的时候,操作系统并不会变更该结构体的任何内容,所以 TCP 连接的状态也不会发生改变。3.2 实验验证一下我做了个小实验:我用 ssh 终端连接了我的云服务器,然后我通过断开 wifi 的方式来模拟拔掉网线的场景,此时查看 TCP 连接的状态没有发生变化,还是处于 ESTABLISHED 状态(如下图所示)。通过上面实验结果可以验证我的结论:拔掉网线这个动作并不会影响 TCP 连接的状态。不过,这个答案还是有点笼统。实际上,我们应该在更具体的场景中来看待这个问题,答案才更准确一些。这个具体场景就是:1)当拔掉网线后,有数据传输时;2)当拔掉网线后,没有数据传输时。针对上面这两种具体的场景,我来更具体地来分析一下。我们继续往下阅读。4、具体场景1:拔掉网线后,有数据传输时4.1 数据传输过程中,恰好又把网线插回去了如果是客户端被拔掉网线后,服务端向客户端发送的数据报文会得不到任何的响应,在等待一定时长后,服务端就会触发TCP协议的超时重传机制(详见:《TCP/IP详解 - 第21章·TCP的超时与重传》),然而此时重传并不能得到响应的数据报文。如果在服务端重传报文的过程中,客户端恰好把网线插回去了,由于拔掉网线并不会改变客户端的 TCP 连接状态,并且还是处于 ESTABLISHED 状态,所以这时客户端是可以正常接收服务端发来的数据报文的,然后客户端就会回 ACK 响应报文。此时:客户端和服务端的 TCP 连接将依然存在且工作状态不会受到影响,给应用层的感觉就像什么事情都没有发生。。。4.2 数据传输过程中,网线一直没有插回去上面这种情况下,如果在服务端TCP协议重传报文的过程中,客户端一直没有将网线插回去,那么服务端超时重传报文的次数达到一定阈值后,内核就会判定出该 TCP 有问题。然后就会通过 Socket 接口告诉应用程序该 TCP 连接出问题了,于是服务端的 TCP 连接就会断开。接下来,如果客户端再插回网线,如果客户端向服务端发送了数据,由于服务端已经没有与客户端匹配的 TCP 连接信息了,因此服务端内核就会回复 RST 报文,客户端收到后就会释放该 TCP 连接。此时:客户端和服务端的 TCP 连接已经明确被断开,原本的这个连接也就不存在了。4.3 刨根问底:TCP数据报文到底重传几次?本着知其然更应知其所以然的精神,我们来刨根问底一下:TCP 的数据报文到底有重传几次呢?在 Linux 系统中,提供了一个叫 tcp_retries2 配置项,默认值是 15(如下图所示)。如上图所示:这个内核参数是控制 TCP 连接建立的情况下,超时重传的最大次数。不过 tcp_retries2 设置了 15 次,并不代表 TCP 超时重传了 15 次才会通知应用程序终止该 TCP 连接,内核还会基于“最大超时时间”来判定。每一轮的超时时间都是倍数增长的,比如第一次触发超时重传是在 2s 后,第二次则是在 4s 后,第三次则是 8s 后,以此类推。内核会根据 tcp_retries2 设置的值,计算出一个最大超时时间。在重传报文且一直没有收到对方响应的情况时,先达到“最大重传次数”或者“最大超时时间”这两个的其中一个条件后,就会停止重传,然后就会断开 TCP 连接。PS:有关TCP超时重传机制的详细情况,可以阅读《浅析TCP协议中的疑难杂症(下篇)》。5、具体场景2:拔掉网线后,有数据传输时5.1 场景分析针对拔掉网线后,没有数据传输的场景,还得具体看看是否开启了 TCP KeepAlive 机制 (详见《彻底搞懂TCP协议层的KeepAlive保活机制》)。1)如果没有开启 TCP KeepAlive 机制:在客户端拔掉网线后,并且双方都没有进行数据传输,那么客户端和服务端的 TCP 连接将会一直保持存在。2)如果开启了 TCP KeepAlive 机制:在客户端拔掉网线后,即使双方都没有进行数据传输,在持续一段时间后,TCP 就会发送KeepAlive探测报文。根据KeepAlive探测报文响应情况,会有以下两种可能:1)如果对端正常工作:当探测报文被对端收到并正常响应, TCP 保活时间将被重置,等待下一个 TCP 保活时间的到来;2)如果对端主机崩溃或对端由于其他原因导致报文不可达:当探测报文发送给对端后,石沉大海、没有响应,连续几次,达到保活探测次数后,TCP 会报告该连接已经死亡。所以:TCP 保活机制可以在双方没有数据交互的情况,通过TCP KeepAlive 机制的探测报文,来确定对方的 TCP 连接是否存活。5.2 刨根问底:TCP KeepAlive 机制具体是什么样的?TCP KeepAlive 机制的原理是这样的:定义一个时间段,在这个时间段内,如果没有任何连接相关的活动,TCP 保活机制会开始作用,每隔一个时间间隔,发送一个探测报文。该探测报文包含的数据非常少,如果连续几个探测报文都没有得到响应,则认为当前的 TCP 连接已经死亡,系统内核将错误信息通知给上层应用程序。在 Linux 内核可以有对应的参数可以设置保活时间、保活探测的次数、保活探测的时间间隔。以下是 Linux 中的默认值:net.ipv4.tcp_keepalive_time=7200net.ipv4.tcp_keepalive_intvl=75 net.ipv4.tcp_keepalive_probes=9解释一下:1)tcp_keepalive_time=7200:表示保活时间是 7200 秒(2小时),也就 2 小时内如果没有任何连接相关的活动,则会启动保活机制;2)tcp_keepalive_intvl=75:表示每次检测间隔 75 秒;3)tcp_keepalive_probes=9:表示检测 9 次无响应,认为对方是不可达的,从而中断本次的连接。也就是说在 Linux 系统中,最少需要经过 2 小时 11 分 15 秒才可以发现一个“死亡”连接。计算公式是:注意:应用程序若想使用 TCP 保活机制需要通过 socket 接口设置 SO_KEEPALIVE 选项才能够生效,如果没有设置,那么就无法使用 TCP 保活机制。PS:关于TCP协议的KeepAlive 机制详见《彻底搞懂TCP协议层的KeepAlive保活机制》、《一文读懂即时通讯应用中的网络心跳包机制:作用、原理、实现思路等》。5.3 刨根问底:TCP KeepAlive 机制的探测时间也太长了吧?没错,确实有点长。TCP KeepAlive 机制是 TCP 层(内核态) 实现的,它是给所有基于 TCP 传输协议的程序一个兜底的方案。实际上:我们通常在应用层自己实现一套探测机制,可以在较短的时间内,探测到对方是否存活。比如:一般Web 服务器都会提供 keepalive_timeout 参数,用来指定 HTTP 长连接的超时时间。如果设置了 HTTP 长连接的超时时间是 60 秒,Web 服务软件就会启动一个定时器,如果客户端在完后一个 HTTP 请求后,在 60 秒内都没有再发起新的请求,定时器的时间一到,就会触发回调函数来释放该连接。再比如:IM、消息推送系统里的心跳机制,通过应用层的心跳机制(由客户端发出,服务端回复响应包),来灵活控制和探测长连接的健康度。《为何基于TCP协议的移动端IM仍然需要心跳保活机制?》这篇文章解释了IM这类应用中应用层心跳保活的必要性,有兴趣可以读一读。如果对应用层心跳的具体应用没什么概念,可以看看微信的这两篇文章:《微信团队原创分享:Android版微信后台保活实战分享(网络保活篇)》《移动端IM实践:实现Android版微信的智能心跳机制》下面有几个针对im这类应用的心跳实现代码,可以具体感受学习一下:《正确理解IM长连接的心跳及重连机制,并动手实现(有完整IM源码)》《一种Android端IM智能心跳算法的设计与实现探讨(含样例代码)》《自已开发IM有那么难吗?手把手教你自撸一个Andriod版简易IM (有源码)》《手把手教你用Netty实现网络通信程序的心跳机制、断线重连机制》6、本文小结下面简单总结一下文中的内容,本文开头的问题并不是简单一句话能够准确说清楚的,需要分情况对待。也就是:客户端拔掉网线后,并不会直接影响 TCP 的连接状态。所以拔掉网线后,TCP 连接是否还会存在,关键要看拔掉网线之后,有没有进行数据传输。1)有数据传输的情况:在客户端拔掉网线后:如果服务端发送了数据报文,那么在服务端重传次数没有达到最大值之前,客户端恰好插回网线的话,那么双方原本的 TCP 连接还是能存在并正常工作,就好像什么事情都没有发生。在客户端拔掉网线后:如果服务端发送了数据报文,在客户端插回网线之前,服务端重传次数达到了最大值时,服务端就会断开 TCP 连接。等到客户端插回网线后,向服务端发送了数据,因为服务端已经断开了与客户端相同四元组的 TCP 连接,所以就会回 RST 报文,客户端收到后就会断开 TCP 连接。至此, 双方的 TCP 连接都断开了。2)没有数据传输的情况:a. 如果双方都没有开启 TCP keepalive 机制,那么在客户端拔掉网线后,如果客户端一直不插回网线,那么客户端和服务端的 TCP 连接状态将会一直保持存在;b. 如果双方都开启了 TCP keepalive 机制,那么在客户端拔掉网线后,如果客户端一直不插回网线,TCP keepalive 机制会探测到对方的 TCP 连接没有存活,于是就会断开 TCP 连接。而如果在 TCP 探测期间,客户端插回了网线,那么双方原本的 TCP 连接还是能正常存在。除了客户端拔掉网线的场景,还有客户端“宕机和杀死进程”的两种场景。第一个场景:客户端宕机这件事跟拔掉网线是一样无法被服务端的感知的,所以如果在没有数据传输,并且没有开启 TCP keepalive 机制时,,服务端的 TCP 连接将会一直处于 ESTABLISHED 连接状态,直到服务端重启进程。所以:我们可以得知一个点——在没有使用 TCP 保活机制,且双方不传输数据的情况下,一方的 TCP 连接处在 ESTABLISHED 状态时,并不代表另一方的 TCP 连接还一定是正常的。第二个场景:杀死客户端的进程后,客户端的内核就会向服务端发送 FIN 报文,与客户端进行四次挥手(见《跟着动画来学TCP三次握手和四次挥手》)。所以:即使没有开启 TCP KeepAlive,且双方也没有数据交互的情况下,如果其中一方的进程发生了崩溃,这个过程操作系统是可以感知的到的,于是就会发送 FIN 报文给对方,然后与对方进行 TCP 四次挥手。7、参考资料[1] TCP/IP详解 - 第21章·TCP的超时与重传[2] 通俗易懂-深入理解TCP协议(上):理论基础[3] 网络编程懒人入门(三):快速理解TCP协议一篇就够[4] 脑残式网络编程入门(一):跟着动画来学TCP三次握手和四次挥手[5] 脑残式网络编程入门(七):面视必备,史上最通俗计算机网络分层详解[6] 技术大牛陈硕的分享:由浅入深,网络编程学习经验干货总结[7] 网络编程入门从未如此简单(二):假如你来设计TCP协议,会怎么做?[8] 不为人知的网络编程(十):深入操作系统,从内核理解网络包的接收过程(Linux篇)[9] 为何基于TCP协议的移动端IM仍然需要心跳保活机制?[10] 一文读懂即时通讯应用中的网络心跳包机制:作用、原理、实现思路等[11] Web端即时通讯实践干货:如何让你的WebSocket断网重连更快速?学习交流:- 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》- 开源IM框架源码:https://github.com/JackJiang2011/MobileIMSDK (本文同步发布于:http://www.52im.net/thread-3846-1-1.html)
本文由微信开发团队工程师“ qiuwenchen”分享,有修订。1、引言全文搜索是使用倒排索引进行搜索的一种搜索方式。倒排索引也称为反向索引,是指对输入的内容中的每个Token建立一个索引,索引中保存了这个Token在内容中的具体位置。全文搜索技术主要应用在对大量文本内容进行搜索的场景。微信终端涉及到大量文本搜索的业务场景主要包括:im联系人、im聊天记录、收藏的搜索。这些功能从2014年上线至今,底层技术已多年没有更新:1)聊天记录使用的全文搜索引擎还是SQLite FTS3,而现在已经有SQLite FTS5;2)收藏首页的搜索还是使用简单的Like语句去匹配文本;3)联系人搜索甚至用的是内存搜索(在内存中遍历所有联系人的所有属性进行匹配)。随着用户在微信上积累的im聊天数据越来越多,提升微信底层搜索技术的需求也越来越迫切。于是,在2021年我们对微信iOS端的全文搜索技术进行了一次全面升级,本文主要记录了本次技术升级过程中的技术实践。2、系列文章本文是专题系列文章中的第4篇:《IM全文检索技术专题(一):微信移动端的全文检索优化之路》《IM全文检索技术专题(二):微信移动端的全文检索多音字问题解决方案》《IM全文检索技术专题(三):网易云信Web端IM的聊天消息全文检索技术实践》《IM全文检索技术专题(四):微信iOS端的最新全文检索技术优化实践》(* 本文)3、全文检索引擎的选型iOS客户端可以使用的全文搜索引擎并不多,主要有:1)SQLite三个版本的FTS组件(FTS3和4、FTS5);2)Lucene的C++实现版本CLucene;3) Lucene的C语言桥接版本Lucy。这里给出了这些引擎在事务能力、技术风险、搜索能力、读写性能等方面的比较(见下图)。1)在事务能力方面:Lucene没有提供完整的事务能力,因为Lucene使用了多文件的存储结构,它没有保证事务的原子性。而SQLite的FTS组件因为底层还是使用普通的表来实现的,可以完美继承SQLite的事务能力。2)在技术风险方面:Lucene主要应用于服务端,在客户端没有大规模应用的案例,而且CLucene和Lucy自2013年后官方都停止维护了,技术风险较高。SQLite的FTS3和FTS4组件则是属于SQLite的旧版本引擎,官方维护不多了,而且这两个版本都是将一个词的索引存到一条记录中,极端情况下有超出SQLite单条记录最大长度限制的风险。SQLite的FTS5组件作为最新版本引擎也已经推出超过六年了,在安卓微信上也已经全量应用,所以技术风险是最低的。3)在搜索能力方面:Lucene的发展历史比SQLite的FTS组件长很多,搜索能力相比也是最丰富的。特别是Lucene有丰富的搜索结果评分排序机制,但这个在微信客户端没有应用场景。因为我们的搜索结果要么是按照时间排序,要么是按照一些简单的自定义规则排序。在SQLite几个版本的引擎中,FTS5的搜索语法更加完备严谨,提供了很多接口给用户自定义搜索函数,所以搜索能力也相对强一点。4)在读写性能方面:下面3个图是用不同引擎对100万条长度为10的随机生成中文语句生成Optimize状态的索引的性能数据,其中每个语句的汉字出现频率按照实际的汉字使用频率。从上面的3张图可以看到:Lucene读取命中数量的性能比SQLite好很多,说明Lucene索引的文件格式很有优势,但是微信没有只读取命中数量的应用场景。Lucene的其他性能数据跟SQLite的差距不明显。SQLite FTS3和FTS5的大部分性能很接近,FTS5索引的生成耗时比FTS3高一截,这个有优化方法。综合考虑这些因素:我们选择SQLite FTS5作为iOS微信全文搜索的搜索引擎。4、引擎层优化1:实现FTS5的Segment自动Merge机制SQLite FTS5会把每个事务写入的内容保存成一个独立的b树,称为一个segment,segment中保存了本次写入内容中的每个词在本次内容中行号(rowid)、列号和字段中的每次出现的位置偏移,所以这个segment就是该内容的倒排索引。多次写入就会形成多个segment,查询时就需要分别查询这些segment再汇总结果,从而segment数量越多,查询速度越慢。为了减少segment的数量,SQLite FTS5引入了merge机制。新写入的segment的level为0,merge操作可以把level为i的现有segment合并成一个level为i+1的新的segment。merge的示例如下:FTS5默认的merge操作有两种:1)automerge:某一个level的segment达到4时就开始在写入内容时自动执行一部分merge操作,称为一次automerge。每次automerge的写入量跟本次更新的写入量成正比,需要多次automerge才能完整合并成一个新segment。Automerge在完整生成一个新的segment前,需要多次裁剪旧的segment的已合并内容,引入多余的写入量;2)crisismerge:本次写入后某一个level的segment数量达到16时,一次性合并这个level的segment,称为crisismerge。FTS5的默认merge操作都是在写入时同步执行的,会对业务逻辑造成性能影响,特别是crisismerge会偶然导致某一次写入操作特别久,这会让业务性能不可控(之前的测试中FTS5的建索引耗时较久,也主要因为FTS5的merge操作比其他两种引擎更加耗时)。我们在WCDB中实现FTS5的segment自动merge机制,将这些merge操作集中到一个单独子线程执行,并且优化执行参数。具体做法如下:1)监听有FTS5索引的数据库每个事务变更到的FTS5索引表,抛通知到子线程触发WCDB的自动merge操作;2)Merge线程检查所有FTS5索引表中segment数超过 1 的level执行一次merge;3)Merge时每写入16页数据检查一次有没有其他线程的写入操作因为merge操作阻塞,如果有就立即commit,尽量减小merge对业务性能的影响。自动merge逻辑执行的流程图如下:限制每个level的segment数量为1,可以让FTS5的查询性能最接近optimize(所有segment合并成一个)之后的性能,而且引入的写入量是可接受的。假设业务每次写入量为M,写入了N次,那么在merge执行完整之后,数据库实际写入量为MN(log2(N)+1)。业务批量写入,提高M也可以减小总写入量。性能方面,对一个包含100w条中文内容,每条长度100汉字的fts5的表查询三个词,optimize状态下耗时2.9ms,分别限制每个level的segment数量为2、3、4时的查询耗时分别为4.7ms、8.9ms、15ms。100w条内容每次写入100条的情况下,按照WCDB的方案执行merge的耗时在10s内。使用自动Merge机制,可以在不影响索引更新性能的情况下,将FTS5索引保持在最接近Optimize的状态,提高了搜索速度。5、引擎层优化2:分词器优化5.1 分词器性能优化分词器是全文搜索的关键模块,它实现将输入内容拆分成多个Token并提供这些Token的位置,搜索引擎再对这些Token建立索引。SQLite的FTS组件支持自定义分词器,可以按照业务需求实现自己的分词器。分词器的分词方法可以分为按字分词和按词分词。前者只是简单对输入内容逐字建立索引,后者则需要理解输入内容的语义,对有具体含义的词组建立索引。相比于按字分词,按词分词的优势是既可以减少建索引的Token数量,也可以减少搜索时匹配的Token数量,劣势是需要理解语义,而且用户输入的词不完整时也会有搜不到的问题。为了简化客户端逻辑和避免用户漏输内容时搜不到的问题,iOS微信之前的FTS3分词器OneOrBinaryTokenizer是采用了一种巧妙的按字分词算法,除了对输入内容逐字建索引,还会对内容中每两个连续的字建索引,对于搜索内容则是按照每两个字进行分词。下面是用“北京欢迎你”去搜索相同内容的分词例子:相比于简单的按字分词,这种分词方式的优势是可以将搜索时匹配的Token数量接近降低一半,提高搜索速度,而且在一定程度上可以提升搜索精度(比如搜索“欢迎你北京”就匹配不到“北京欢迎你”)。这种分词方式的劣势就是保存的索引内容很多,基本输入内容的每个字都在索引中保存了三次,是一种用空间换时间的做法。因为OneOrBinaryTokenizer用接近三倍的索引内容增长才换取不到两倍的搜索性能提升,不是很划算,所以我们在FTS5上重新开发了一种新的分词器VerbatimTokenizer,这个分词器只采用基本的按字分词,不保存冗余索引内容。同时在搜索时,每两个字用引号引起来组成一个Phrase,按照FTS5的搜索语法,搜索时Phrase中的字要按顺序相邻出现的内容才会命中,实现了跟OneOrBinaryTokenizer一样的搜索精度。VerbatimTokenizer的分词规则示意图如下:5.2 分词器能力扩展VerbatimTokenizer还根据微信实际的业务需求实现了五种扩展能力来提高搜索的容错能力:1)支持在分词时将繁体字转换成简体字:这样用户可以用繁体字搜到简体字内容,用简体字也能搜到繁体字内容,避免了因为汉字的简体和繁体字形相近导致用户输错的问题。2)支持Unicode归一化:Unicode支持相同字形的字符用不同的编码来表示,比如编码为\ue9的é和编码为\u65\u301的é有相同的字形,这会导致用户用看上去一样的内容去搜索结果搜不到的问题。Unicode归一化就是把字形相同的字符用同一个编码表示。3)支持过滤符号:大部分情况下,我们不需要支持对符号建索引,符号的重复量大而且用户一般也不会用符号去搜索内容,但是联系人搜索这个业务场景需要支持符号搜索,因为用户的昵称里面经常出现颜文字,符号的使用量不低。4)支持用Porter Stemming算法对英文单词取词干:取词干的好处是允许用户搜索内容的单复数和时态跟命中内容不一致,让用户更容易搜到内容。但是取词干也有弊端,比如用户要搜索的内容是“happyday”,输入“happy”作为前缀去搜索却会搜不到,因为“happyday”取词干变成“happydai”,“happy”取词干变成“happi”,后者就不能成为前者的前缀。这种badcase在内容为多个英文单词拼接一起时容易出现,联系人昵称的拼接英文很常见,所以在联系人的索引中没有取词干,在其他业务场景中都用上了。5)支持将字母全部转成小写:这样用户可以用小写搜到大写,反之亦然。这些扩展能力都是对建索引内容和搜索内容中的每个字做变换,这个变换其实也可以在业务层做,其中的Unicode归一化和简繁转换以前就是在业务层实现的。但是这样做有两个弊端:1)一个是业务层每做一个转换都需要对内容做一次遍历,引入冗余计算量;2)一个是写入到索引中的内容是转变后的内容,那么搜索出来的结果也是转变后的,会和原文不一致,业务层做内容判断的时候容易出错。鉴于这两个原因,VerbatimTokenizer将这些转变能力都集中到了分词器中实现。6、引擎层优化3:索引内容支持多级分隔符SQLite的FTS索引表不支持在建表后再添加新列,但是随着业务的发展,业务数据支持搜索的属性会变多,如何解决新属性的搜索问题呢?特别是在联系人搜索这个业务场景,一个联系人支持搜索的字段非常多。一个直接的想法是:将新属性和旧属性用分隔符拼接到一起建索引。但这样会引入新的问题:FTS5是以整个字段的内容作为整体去匹配的,如果用户搜索匹配的Token在不同的属性,那这条数据也会命中,这个结果显然不是用户想要的,搜索结果的精确度就降低了。我们需要搜索匹配的Token中间不存在分隔符,那这样可以确保匹配的Token都在一个属性内。同时,为了支持业务灵活扩展,还需要支持多级分隔符,而且搜索结果中还要支持获取匹配结果的层级、位置以及该段内容的原文和匹配词。这个能力FTS5还没有,而FTS5的自定义辅助函数支持在搜索时获取到所有命中结果中每个命中Token的位置,利用这个信息可以推断出这些Token中间有没有分隔符,以及这些Token所在的层级,所以我们开发了SubstringMatchInfo这个新的FTS5搜索辅助函数来实现这个能力。这个函数的大致执行流程如下:7、应用层优化1:数据库表格式优化7.1 非文本搜索内容的保存方式在实际应用中,我们除了要在数据库中保存需要搜索的文本的FTS索引,还需要额外保存这个文本对应的业务数据的id、用于结果排序的的属性(常见的是业务数据的创建时间)以及其他需要直接跟随搜索结果读出的内容,这些都是不参与文本搜索的内容。根据非文本搜索内容的不同存储位置,我们可以将FTS索引表的表格式分成两种:1)第一种方式:是将非文本搜索内容存储在额外的普通表中,这个表保存FTS索引的Rowid和非文本搜索内容的映射关系,而FTS索引表的每一行只保存可搜索的文本内容。这个表格式类似于这样:这种表格式的优势和劣势是很明显,分别是:a)优势是:FTS索引表的内容很简单,不熟悉FTS索引表配置的同学不容易出错,而且普通表的可扩展性好,支持添加新列;b)劣势是:搜索时需要先用FTS索引的Rowid读取到普通表的Rowid,这样才能读取到普通表的其他内容,搜索速度慢一点,而且搜索时需要联表查询,搜索SQL语句稍微复杂一点。2)第二种方式:是将非文本搜索内容直接和可搜索文本内容一起存储在FTS索引表中。表格式类似于这样:这种方式的优劣势跟前一种方式恰好相反:a)优势是:搜索速度快而且搜索方式简单;b)劣势是:扩展性差且需要更细致的配置。因为iOS微信以前是使用第二种表格式,而且微信的搜索业务已经稳定不会有大变化,我们现在更加追求搜索速度,所以我们还是继续使用第二种表格式来存储全文搜索的数据。7.2 避免冗余索引内容FTS索引表默认对表中的每一列的内容都建倒排索引,即便是数字内容也会按照文本来处理,这样会导致我们保存在FTS索引表中的非文本搜索内容也建了索引,进而增大索引文件的大小、索引更新的耗时和搜索的耗时,这显然不是我们想要的。FTS5支持给索引表中的列添加UNINDEXED约束,这样FTS5就不会对这个列建索引了,所以给可搜索文本内容之外的所有列添加这个约束就可以避免冗余索引。7.3 降低索引内容的大小前面提到,倒排索引主要保存文本中每个Token对应的行号(rowid)、列号和字段中的每次出现的位置偏移,其中的行号是SQLite自动分配的,位置偏移是根据业务的实际内容,这两个我们都决定不了,但是列号是可以调整的。在FTS5索引中,一个Token在一行中的索引内容的格式是这样的:从中可以看出,如果我们把可搜索文本内容设置在第一列的话(多个可搜索文本列的话,把内容多的列放到第一列),就可以少保存列分割符0x01和列号,这样可以明显降低索引文件大小。所以我们最终的表格式是这样:7.4 优化前后的效果对比下面是iOS微信优化前后的平均每个用户的索引文件大小对比:8、应用层优化2:索引更新逻辑优化8.1 概述为了将全文搜索逻辑和业务逻辑解耦,iOS微信的FTS索引是不保存在各个业务的数据库中的,而是集中保存到一个专用的全文搜索数据库,各个业务的数据有更新之后再异步通知全文搜索模块更新索引。整体流程如下:这样做既可以避免索引更新拖慢业务数据更新的速度,也能避免索引数据更新出错甚至索引数据损坏对业务造成影响,让全文搜索功能模块能够充分独立。8.2 保证索引和数据的一致业务数据和索引数据分离且异步同步的好处很多,但实现起来也很难。最难的问题是如何保证业务数据和索引数据的一致,也即要保证业务数据和索引数据要逐条对应,不多不少。曾经iOS微信在这里踩了很多坑,打了很多补丁都不能完整解决这个问题,我们需要一个更加体系化的方法来解决这个问题。为了简化问题,我们可以把一致性问题可以拆成两个方面分别处理:1)一是保证所有业务数据都有索引,这个用户的搜索结果就不会有缺漏;2)二是保证所有索引都对应一个有效的业务数据,这样用户就不会搜到无效的结果。要保证所有业务数据都有索引,首先要找到或者构造一种一直增长的数据来描述业务数据更新的进度,这个进度数据的更新和业务数据的更新能保证原子性。而且根据这个进度的区间能拿出业务数据更新的内容,这样我们就可以依赖这个进度来更新索引。在微信的业务中,不同业务的进度数据不同:1)聊天记录是使用消息的rowid;2)收藏是使用收藏跟后台同步的updateSequence;3)联系人找不到这种一直增长的进度数据(我们是通过在联系人数据库中标记有新增或有更新的联系人的微信号来作为索引更新进度)。针对上述第3)点,进度数据的使用方法如下:无论业务数据是否保存成功、更新通知是否到达全文搜索模块、索引数据是否保存成功,这套索引更新逻辑都能保证保存成功的业务数据都能成功建到索引。这其中的一个关键点是数据和进度要在同个事务中一起更新,而且要保存在同个数据库中,这样才能保证数据和进度的更新的原子性(WCDB创建的数据库因为使用WAL模式而无法保证不同数据库的事务的原子性)。还有一个操作图中没有画出,具体是微信启动时如果检查到业务进度小于索引进度,这种一般意味着业务数据损坏后被重置了,这种情况下要删掉索引并重置索引进度。对于每个索引都对应有效的业务数据,这就要求业务数据删除之后索引也要必须删掉。现在业务数据的删除和索引的删除是异步的,会出现业务数据删掉之后索引没删除的情况。这种情况会导致两个问题:1)一是冗余索引会导致搜索速度变慢,但这个问题出现概率很小,这个影响可以忽略不计;2)二是会导致用户搜到无效数据,这个是要避免的。针对上述第2)点:因为要完全删掉所有无效索引成本比较高,所以我们采用了惰性检查的方法来解决这个问题,具体做法是搜索结果要显示给用户时,才检查这个数据是否有效,无效的话不显示这个搜索结果并异步删除对应的索引。因为用户一屏能看到的数据很少,所以检查逻辑带来的性能消耗也可以忽略不计。而且这个检查操作实际上也不算是额外加的逻辑,为了搜索结果展示内容的灵活性,我们也要在展示搜索结果时读出业务数据,这样也就顺带做了数据有效性的检查。8.3 建索引速度优化索引只有在搜索的时候才会用到,它的更新优先级并没有业务数据那么高,可以尽量攒更多的业务数据才去批量建索引。批量建索引有以下三个好处:1)减少磁盘的写入次数,提高平均建索引速度;2)在一个事务中,建索引SQL语句的解析结果可以反复使用,可以减少SQL语句的解析次数,进而提高平均建索引速度;3)减少生成Segment的数量,从而减少Merge Segment带来的读写消耗。当然:也不能保留太多业务数据不建索引,这样用户要搜索时会来不及建索引,从而导致搜索结果不完整。有了前面的Segment自动Merge机制,索引的写入速度非常可控,只要控制好量,就不用担心批量建索引带来的高耗时问题。我们综合考虑了低端机器的建索引速度和搜索页面的拉起时间,确定了最大批量建索引数据条数为100条。同时:我们会在内存中cache本次微信运行期间产生的未建索引业务数据,在极端情况下给没有来得及建索引的业务数据提供相对内存搜索,保证搜索结果的完整性。因为cache上一次微信运行期间产生的未建索引数据需要引入额外的磁盘IO,所以微信启动后会触发一次建索引逻辑,对现有的未建索引业务数据建一次索引。总结一下触发建索引的时机有三个:1)未建索引业务数据达到100条;2)进入搜索界面;3)微信启动。8.4 删除索引速度优化索引的删除速度经常是设计索引更新机制时比较容易忽视的因素,因为被删除的业务数据量容易被低估,会被误以为是低概率场景。但实际被用户删除的业务数据可能会达到50%,是个不可忽视的主场景。而且SQLite是不支持并行写入的,删除索引的性能也会间接影响到索引的写入速度,会为索引更新引入不可控因素。因为删除索引的时候是拿着业务数据的id去删除的。所以提高删除索引速度的方式有两种:1)建一个业务数据id到FTS索引的rowid的普通索引;2)在FTS索引表中去掉业务数据Id那一列的UNINDEXED约束,给业务数据Id添加倒排索引。这里倒排索引其实没有普通索引那么高效,有两个原因:1)倒排索引相比普通索引还带了很多额外信息,搜索效率低一些;2)如果需要多个业务字段才能确定一条倒排索引时,倒排索引是建不了联合索引的,只能匹配其中一个业务字段,其他字段就是遍历匹配,这种情况搜索效率会很低。8.5 优化前后的效果对比聊天记录的优化前后索引性能数据如下:收藏的优化前后索引性能数据如下:9、应用层优化3:搜索逻辑优化9.1 问题用户在iOS微信的首页搜索内容时,交互逻辑如下:如上图所示:当用户变更搜索框的内容之后,会并行发起所有业务的搜索任务,各个搜索任务执行完之后才再将搜索结果返回到主线程给页面展示。这个逻辑会随着用户变更搜索内容而继续重复。9.2 单个搜索任务应支持并行执行虽然现在不同搜索任务已经支持并行执行,但是不同业务的数据量和搜索逻辑差别很大,数据量大或者搜索逻辑复杂的任务耗时会很久,这样还不能充分发挥手机的并行处理能力。我们还可以将并行处理能力引入单个搜索任务内,这里有两种处理方式:1)对于搜索数据量大的业务(比如聊天记录搜索):可以将索引数据均分存储到多个FTS索引表(注意这里不均分的话还是会存在短板效应),这样搜索时可以并行搜索各个索引表,然后汇总各个表的搜索结果,再进行统一排序。这里拆分的索引表数量既不能太多也不能太少,太多会超出手机实际的并行处理能力,也会影响其他搜索任务的性能,太少又不能充分利用并行处理能力。以前微信用了十个FTS表存储聊天记录索引,现在改为使用四个FTS表。2)对于搜索逻辑复杂的业务(比如联系人搜索):可以将可独立执行的搜索逻辑并行执行(比如:在联系人搜索任务中,我们将联系人的普通文本搜索、拼音搜索、标签和地区的搜索、多群成员的搜索并行执行,搜完之后再合并结果进行排序)。这里为什么不也用拆表的方式呢?因为这种搜索结果数量少的场景,搜索的耗时主要是集中在搜索索引的环节,索引可以看做一颗B树,将一颗B树拆分成多个,搜索耗时并不会成比例下降。9.3 搜索任务应支持中断用户在搜索框持续输入内容的过程中可能会自动多次发起搜索任务,如果在前一次发起的搜索任务还没执行完时,就再次发起搜索任务,那前后两次搜索任务就会互相影响对方性能。这种情况在用户输入内容从短到长的过程中还挺容易出现的,因为搜索文本短的时候命中结果就很多,搜索任务也就更加耗时,从而更有机会撞上后面的搜索任务。太多任务同时执行还会容易引起手机发烫、爆内存的问题。所以我们需要让搜索任务支持随时中断,这样就可以在后一次搜索任务发起的时候,能够中断前一次的搜索任务,避免任务量过多的问题。搜索任务支持中断的实现方式是给每个搜索任务设置一个CancelFlag,在搜索逻辑执行时每搜到一个结果就判断一下CancelFlag是否置位,如果置位了就立即退出任务。外部逻辑可以通过置位CancelFlag来中断搜索任务。逻辑流程如下图所示:为了让搜索任务能够及时中断,我们需要让检查CancelFlag的时间间隔尽量相等,要实现这个目标就要在搜索时避免使用OrderBy子句对结果进行排序。因为FTS5不支持建立联合索引,所以在使用OrderBy子句时,SQLite在输出第一个结果前会遍历所有匹配结果进行排序,这就让输出第一个结果的耗时几乎等于输出全部结果的耗时,中断逻辑就失去了意义。不使用OrderBy子句就对搜索逻辑添加了两个限制:1)从数据库读取所有结果之后再排序:我们可以在读取结果时将用于排序的字段一并读出,然后在读完所有结果之后再对所有结果执行排序。因为排序的耗时占总搜索耗时的比例很低,加上排序算法的性能大同小异,这种做法对搜索速度的影响可以忽略。2)不能使用分段查询:在全文搜索这个场景中,分段查询其实是没有什么作用的。因为分段查询就要对结果排序,对结果排序就要遍历所有结果,所以分段查询并不能降低搜索耗时(除非按照FTS索引的Rowid分段查询,但是Rowid不包含实际的业务信息)。9.4 搜索读取内容应最少化搜索时读取内容的量也是决定搜索耗时的一个关键因素。FTS索引表实际是有多个SQLite普通表组成的,这其中一些表格存储实际的倒排索引内容,还有一个表格存储用户保存到FTS索引表的全部原文。当搜索时读取Rowid以外的内容时,就需要用Rowid到保存原文的表的读取内容。索引表输出结果的内部执行过程如下:所以读取内容越少输出结果的速度越快,而且读取内容过多也会有消耗内存的隐患。我们采用的方式是:搜索时只读取业务数据id和用于排序的业务属性,排好序之后,在需要给用户展示结果时,才用业务数据id按需读取业务数据具体内容出来展示。这样做的扩展性也会很好,可以在不更改存储内容的情况下,根据各个业务的需求不断调整搜索结果展示的内容。还有个地方要特别提一下:就是搜索时尽量不要读取高亮信息(SQLite的highlight函数有这个能力)。因为要获取高亮字段不仅要将文本的原文读取出来,还要对文本原文再次分词,才能定位命中位置的原文内容,搜索结果多的情况下分词带来的消耗非常明显。那展示搜索结果时如何获取高亮匹配内容呢?我们采用的方式是将用户的搜索文本进行分词,然后在展示结果时查找每个Token在展示文本中的位置,然后将那个位置高亮显示(同样因为用户一屏看到的结果数量是很少的,这里的高亮逻辑带来的性能消耗可以忽略)。当然在搜索规则很复杂的情况下,直接读取高亮信息是比较方便(比如:联系人搜索就使用前面提到的SubstringMatchInfo函数来读取高亮内容)。这里主要还是因为要读取匹配内容所在的层级和位置用于排序,所以逐个结果重新分词的操作在所难免。9.5 优化前后的效果对比下面是微信各搜索业务优化前后的搜索耗时对比:10、本文小结目前iOS微信已经将这套新全文搜索技术方案全量应用到聊天记录、联系人和收藏的搜索业务中。使用新方案之后:全文搜索的索引文件占用空间更小、索引更新耗时更少、搜索速度也更快了,可以说全文搜索的性能得到了全方位提升。附录:QQ、微信团队技术文章汇总《微信朋友圈千亿访问量背后的技术挑战和实践总结》《腾讯技术分享:Android版手机QQ的缓存监控与优化实践》《微信团队分享:iOS版微信的高性能通用key-value组件技术实践》《微信团队分享:iOS版微信是如何防止特殊字符导致的炸群、APP崩溃的?》《腾讯技术分享:Android手Q的线程死锁监控系统技术实践》《iOS后台唤醒实战:微信收款到账语音提醒技术总结》《微信团队分享:微信每日亿次实时音视频聊天背后的技术解密》《腾讯团队分享 :一次手Q聊天界面中图片显示bug的追踪过程分享》《微信团队分享:微信Android版小视频编码填过的那些坑》《企业微信客户端中组织架构数据的同步更新方案优化实战》《微信团队披露:微信界面卡死超级bug“15。。。。”的来龙去脉》《QQ 18年:解密8亿月活的QQ后台服务接口隔离技术》《月活8.89亿的超级IM微信是如何进行Android端兼容测试的》《微信后台基于时间序的海量数据冷热分级架构设计实践》《微信团队原创分享:Android版微信的臃肿之困与模块化实践之路》《微信后台团队:微信后台异步消息队列的优化升级实践分享》《微信团队原创分享:微信客户端SQLite数据库损坏修复实践》《腾讯原创分享(一):如何大幅提升移动网络下手机QQ的图片传输速度和成功率》《微信新一代通信安全解决方案:基于TLS1.3的MMTLS详解》《微信团队原创分享:Android版微信后台保活实战分享(网络保活篇)》《微信技术总监谈架构:微信之道——大道至简(演讲全文)》《微信海量用户背后的后台系统存储架构(视频+PPT) [附件下载]》《微信异步化改造实践:8亿月活、单机千万连接背后的后台解决方案》《微信朋友圈海量技术之道PPT [附件下载]》《微信对网络影响的技术试验及分析(论文全文)》《架构之道:3个程序员成就微信朋友圈日均10亿发布量[有视频]》《快速裂变:见证微信强大后台架构从0到1的演进历程(一)》《微信团队原创分享:Android内存泄漏监控和优化技巧总结》《Android版微信安装包“减肥”实战记录》《iOS版微信安装包“减肥”实战记录》《移动端IM实践:iOS版微信界面卡顿监测方案》《微信“红包照片”背后的技术难题》《移动端IM实践:iOS版微信小视频功能技术方案实录》《移动端IM实践:Android版微信如何大幅提升交互性能(一)》《移动端IM实践:实现Android版微信的智能心跳机制》《移动端IM实践:谷歌消息推送服务(GCM)研究(来自微信)》《移动端IM实践:iOS版微信的多设备字体适配方案探讨》《IPv6技术详解:基本概念、应用现状、技术实践(上篇)》《手把手教你读取Android版微信和手Q的聊天记录(仅作技术研究学习)》《微信技术分享:微信的海量IM聊天消息序列号生成实践(算法原理篇)》《社交软件红包技术解密(一):全面解密QQ红包技术方案——架构、技术实现等》《社交软件红包技术解密(二):解密微信摇一摇红包从0到1的技术演进》《社交软件红包技术解密(三):微信摇一摇红包雨背后的技术细节》《社交软件红包技术解密(四):微信红包系统是如何应对高并发的》《社交软件红包技术解密(五):微信红包系统是如何实现高可用性的》《社交软件红包技术解密(六):微信红包系统的存储层架构演进实践》《社交软件红包技术解密(十一):解密微信红包随机算法(含代码实现)》《IM开发宝典:史上最全,微信各种功能参数和逻辑规则资料汇总》《微信团队分享:微信直播聊天室单房间1500万在线的消息架构演进之路》《企业微信的IM架构设计揭秘:消息模型、万人群、已读回执、消息撤回等》学习交流:- 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》- 开源IM框架源码:https://github.com/JackJiang2011/MobileIMSDK(本文同步发布于:http://www.52im.net/thread-3839-1-1.html)
本文由融云技术团队原创分享,有修订和改动。1、引言在视频直播场景中,弹幕交互、与主播的聊天、各种业务指令等等,组成了普通用户与主播之间的互动方式。从技术的角度来看,这些实时互动手段,底层逻辑都是实时聊天消息或指令的分发,技术架构类比于IM应用的话,那就相当于IM聊天室功能。本系列文章的上篇《百万人在线的直播间实时聊天消息分发技术实践》主要分享的是消息分发和丢弃策略。本文将主要从高可用、弹性扩缩容、用户管理、消息分发、客户端优化等角度,分享直播间海量聊天消息的架构设计技术难点的实践经验。2、系列文章本文是系列文章中的第7篇:《直播系统聊天技术(一):百万在线的美拍直播弹幕系统的实时推送技术实践之路》《直播系统聊天技术(二):阿里电商IM消息平台,在群聊、直播场景下的技术实践》《直播系统聊天技术(三):微信直播聊天室单房间1500万在线的消息架构演进之路》《直播系统聊天技术(四):百度直播的海量用户实时消息系统架构演进实践》《直播系统聊天技术(五):微信小游戏直播在Android端的跨进程渲染推流实践》《直播系统聊天技术(六):百万人在线的直播间实时聊天消息分发技术实践》《直播系统聊天技术(七):直播间海量聊天消息的架构设计难点实践》(* 本文)3、直播间的主要功能和技术特征如今的视频直播间早已不单纯是视频流媒体技术问题,它还包含了用户可感知的多类型消息发送和管理、用户管理等任务。在万物皆可直播的当下,超大型直播场景屡见不鲜,甚至出现了人数无上限的场景,面对如此海量实时消息和指令的并发挑战,带来的技术难度已非常规手段所能解决。我们先来归纳一下如今的典型视频直播间,相较于传统直播间所包含的主要功能特征、技术特征等。丰富的消息类型和进阶功能:1)可发送文字、语音、图片等传统聊天功能;2)可实现点赞、礼物等非传统聊天功能的消息类型;3)可管理内容安全,包括敏感词设置,聊天内容反垃圾处理等。聊天管理功能:1)用户管理:包括创建、加入、销毁、禁言、查询、封禁(踢人)等;2)用户白名单:白名单用户处于被保护状态不会被自动踢出,且发送消息优先级别最高;3)消息管理:包括消息优先级、消息分发控制等;4)实时统计及消息路由等能力。人数上限和行为特征:1)人数没有上限:一些大型直播场景,如春晚、国庆大阅兵等,直播间累计观看动辄上千万人次,同时观看人数也可达数百万;2)用户进退行为:用户进出直播间非常频繁,高热度直播间的人员进出秒并发可能上万,这对服务支撑用户上下线以及用户管理的能力提出了非常大的挑战。海量消息并发:1)消息并发量大:直播聊天室人数没有明显上限,带来了海量并发消息的问题(一个百万人数的聊天室,消息的上行已是巨量,消息分发量更是几何级上升);2)消息实时性高:如果服务器只做消息的消峰处理,峰值消息的堆积会造成整体消息延时增大。针对上述第 2) 点,延时的累积效应会导致消息与直播视频流在时间线上产生偏差,进而影响用户观看直播时互动的实时性。所以,服务器的海量消息快速分发能力十分重要。4、直播间聊天室的架构设计高可用系统需要支持服务故障自动转移、服务精准熔断降级、服务治理、服务限流、服务可回滚、服务自动扩容 / 缩容等能力。以服务高可用为目标的直播间聊天室系统架构如下:如上图所示,系统架构主要分三层:1)连接层:主要管理服务跟客户端的长链接;2)存储层:当前使用的是 Redis,作为二级缓存,主要存储聊天室的信息(比如人员列表、黑白名单、封禁列表等,服务更新或重启时,可以从 Redis 中加载出聊天室的备份信息);3)业务层:这是整个聊天室的核心,为了实现跨机房容灾,将服务部署在多个可用区,并根据能力和职责,将其分为聊天室服务和消息服务。聊天室服务和消息服务的具体职责:1)聊天室服务:主要负责处理管理类请求,比如聊天室人员的进出、封禁 / 禁言、上行消息处理审核等;2)消息服务:主要缓存本节点需要处理的用户信息以及消息队列信息,并负责聊天室消息的分发。在海量用户高并发场景下,消息分发能力将决定着系统的性能。以一个百万级用户量的直播间聊天室为例,一条上行消息对应的是百万倍的分发。这种情况下,海量消息的分发,依靠单台服务器是无法实现的。我们的优化思路是:将一个聊天室的人员分拆到不同的消息服务上,在聊天室服务收到消息后向消息服务扩散,再由消息服务分发给用户。以百万在线的直播间聊天室为例:假设聊天室消息服务共 200 台,那平均每台消息服务管理 5000 人左右,每台消息服务在分发消息时只需要给落在本台服务器上的用户分发即可。服务落点的选择逻辑:1)在聊天室服务中:聊天室的上行信令是依据聊天室 ID 使用一致性哈希算法来选择节点的;2)在消息服务中:依据用户 ID 使用一致性哈希算法来决定用户具体落在哪个消息服务。一致性哈希选择的落点相对固定,可以将聊天室的行为汇聚到一个节点上,极大提升服务的缓存命中率。聊天室人员进出、黑 / 白名单设置以及消息发送时的判断等处理直接访问内存即可,无须每次都访问第三方缓存,从而提高了聊天室的响应速度和分发速度。最后:Zookeeper 在架构中主要用来做服务发现,各服务实例均注册到 Zookeeper。5、直播间聊天室的扩缩容能力5.1 概述随着直播这种形式被越来越多人接受,直播间聊天室面对人数激增致使服务器压力逐步增大的情况越来越多。所以,在服务压力逐步增大 / 减少的过程中能否进行平滑的扩 / 缩容非常重要。在服务的自动扩缩容方面,业内提供的方案大体一致:即通过压力测试了解单台服务器的瓶颈点 → 通过对业务数据的监控来判断是否需要进行扩缩 → 触发设定的条件后报警并自动进行扩缩容。鉴于直播间聊天室的强业务性,具体执行中应该保证在扩缩容中整体聊天室业务不受影响。5.2 聊天室服务扩缩容聊天室服务在进行扩缩容时,我们通过 Redis 来加载成员列表、封禁 / 黑白名单等信息。需要注意的是:在聊天室进行自动销毁时,需先判断当前聊天室是否应该是本节点的。如果不是,跳过销毁逻辑,避免 Redis 中的数据因为销毁逻辑而丢失。聊天室服务扩缩容方案细节如下图所示:5.3 消息服务扩缩容消息服务在进行扩缩容时,大部分成员需要按照一致性哈希的原则路由到新的消息服务节点上。这个过程会打破当前的人员平衡,并做一次整体的人员转移。1)在扩容时:我们根据聊天室的活跃程度逐步转移人员。2)在有消息时:[消息服务会遍历缓存在本节点上的所有用户进行消息的通知拉取,在此过程中判断此用户是否属于这台节点(如果不是,将此用户同步加入到属于他的节点)。3)在拉消息时:用户在拉取消息时,如果本机缓存列表中没有该用户,消息服务会向聊天室服务发送请求确认此用户是否在聊天室中(如果在则同步加入到消息服务,不在则直接丢掉)。4)在缩容时:消息服务会从公共 Redis 获得全部成员,并根据落点计算将本节点用户筛选出来并放入用户管理列表中。6、海量用户的上下线和管理聊天室服务:管理了所有人员的进出,人员的列表变动也会异步存入 Redis 中。消息服务:则维护属于自己的聊天室人员,用户在主动加入和退出房间时,需要根据一致性哈希算出落点后同步给对应的消息服务。聊天室获得消息后:聊天室服务广播给所有聊天室消息服务,由消息服务进行消息的通知拉取。消息服务会检测用户的消息拉取情况,在聊天室活跃的情况下,30s 内人员没有进行拉取或者累计 30 条消息没有拉取,消息服务会判断当前用户已经离线,然后踢出此人,并且同步给聊天室服务对此成员做下线处理。7、海量聊天消息的分发策略直播间聊天室服务的消息分发及拉取方案如下图:7.1 消息通知的拉取在上图中:用户 A 在聊天室中发送一条消息,首先由聊天室服务处理,聊天室服务将消息同步到各消息服务节点,消息服务向本节点缓存的所有成员下发通知拉取(图中服务器向用户 B 和用户 Z 下发了通知)。在消息分发过程中,server 做了通知合并。通知拉取的详细流程为:1)客户端成功加入聊天,将所有成员加入到待通知队列中(如已存在则更新通知消息时间);2)下发线程,轮训获取待通知队列;3)向队列中用户下发通知拉取。通过这个流程可保障下发线程一轮只会向同一用户发送一个通知拉取(即多个消息会合并为一个通知拉取),有效提升了服务端性能且降低了客户端与服务端的网络消耗。7.2 消息的拉取用户的消息拉取流程如下图: 如上图所示,用户 B 收到通知后向服务端发送拉取消息请求,该请求最终将由消息节点 1 进行处理,消息节点 1 将根据客户端传递的最后一条消息时间戳,从消息队列中返回消息列表(参考下图 )。客户端拉取消息示例:用户端本地最大时间为 1585224100000,从 server 端可以拉取到比这个数大的两条消息。7.3 消息控速服务器应对海量消息时,需要做消息的控速处理。这是因为:在直播间聊天室中,大量用户在同一时段发送的海量消息,一般情况下内容基本相同。如果将所有消息全部分发给客户端,客户端很可能出现卡顿、消息延迟等问题,严重影响用户体验。所以服务器对消息的上下行都做了限速处理。消息控速原理:具体的限速控制策略如下:1)服务器上行限速控制(丢弃)策略:针对单个聊天室的消息上行的限速控制,我们默认为 200 条 / 秒,可根据业务需要调整。达到限速后发送的消息将在聊天室服务丢弃,不再向各消息服务节点同步;2)服务器下行限速(丢弃)策略:服务端的下行限速控制,主要是根据消息环形队列的长度进行控制,达到最大值后最“老”的消息将被淘汰丢弃。每次下发通知拉取后服务端将该用户标记为拉取中,用户实际拉取消息后移除该标记。如果产生新消息时用户有拉取中标记:1)距设置标记时间在 2 秒内,则不会下发通知(降低客户端压力,丢弃通知未丢弃消息);2)超过 2 秒则继续下发通知(连续多次通知未拉取则触发用户踢出策略,不在此赘述)。因此:消息是否被丢弃取决于客户端拉取速度(受客户端性能、网络影响),客户端及时拉取消息则没有被丢弃的消息。8、直播间聊天室的消息优先级消息控速的核心是对消息的取舍,这就需要对消息做优先级划分。划分逻辑大致如下:1)白名单消息:这类消息最为重要,级别最高,一般系统类通知或者管理类信息会设置为白名单消息;2)高优先级消息:仅次于白名单消息,没有特殊设置过的消息都为高优先级;3)低优先级消息:最低优先级的消息,这类消息大多是一些文字类消息。具体如何划分,应该是可以开放出方便的接口进行设置的。服务器对三种消息执行不同的限速策略,在高并发时,低优先级消息被丢弃的概率最大。服务器将三种消息分别存储在三个消息桶中:客户端在拉取消息时按照白名单消息 > 高优先级消息 > 低优先级消息的顺序拉取。9、客户端针对大量消息的接收和渲染优化9.1 消息的接收优化在消息同步机制方面,如果直播间聊天室每收到一条消息都直接下发到客户端,无疑会给客户端带来极大性能挑战。特别是在每秒几千或上万条消息的并发场景下,持续的消息处理会占用客户端有限的资源,影响用户其它方面的互动。考虑到以上问题,为聊天室单独设计了通知拉取机制,由服务端进行一系列分频限速聚合等控制后,再通知客户端拉取。具体分为以下几步:1)客户端成功加入聊天室;2)服务端下发通知拉取信令;3)客户端根据本地存储的消息最大时间戳,去服务端拉取消息。这里需要注意的是:首次加入直播间聊天室时,本地并没有有效时间戳,此时会传 0 给服务拉取最近 50 条消息并存库。后续再次拉取时才会传递数据库里存储的消息的最大时间戳,进行差量拉取。客户端拉取到消息后:会进行排重处理,然后将排重后的数据上抛业务层,以避免上层重复显示。另外:直播间聊天室中的消息即时性较强,直播结束或用户退出聊天室后,之前拉取的消息大部分不需要再次查看,因此在用户退出聊天室时,会清除数据库中该聊天室的所有消息,以节约存储空间。9.2 消息的渲染优化在消息渲染方面,客户端也通过一系列优化保证在直播间聊天室大量消息刷屏的场景下仍有不俗的表现。以Andriod端为例,具体的措施有:1)采用 MVVM 机制:将业务处理和 UI 刷新严格区分。每收到一条消息,都在 ViewModel 的子线程将所有业务处理好,并将页面刷新需要的数据准备完毕后,才通知页面刷新;2)降低主线程负担:精确使用 LiveData 的 setValue() 和 postValue() 方法:已经在主线程的事件通过 setValue() 方式通知 View 刷新,以避免过多的 postValue() 造成主线程负担过重;3)减少非必要刷新:比如在消息列表滑动时,并不需要将接收到的新消息刷新出来,仅进行提示即可;4)识别数据的更新:通过谷歌的数据对比工具 DiffUtil 识别数据是否有更新,仅更新有变更的部分数据;5)控制全局刷新次数:尽量通过局部刷新进行 UI 更新。通过以上机制:从压测结果看,在中端手机上,直播间聊天室中每秒 400 条消息时,消息列表仍然表现流畅,没有卡顿。10、针对传统聊天消息外的自定义属性优化10.1 概述在直播间聊天室场景中,除了传统的聊天消息收发以外,业务层经常需要有自己的一些业务属性,如在语音直播聊天室场景中的主播麦位信息、角色管理等,还有狼人杀等卡牌类游戏场景中记录用户的角色和牌局状态等。相对于传统聊天消息,自定义属性有必达和时效的要求,比如麦位、角色等信息需要实时同步给聊天室的所有成员,然后客户端再根据自定义属性刷新本地的业务。10.2 自定义属性的存储自定义属性是以 key 和 value 的形式进行传递和存储的。自定义属性的操作行为主要有两种:即设置和删除。服务器存储自定义属性也分两部分:1)全量的自定义属性集合;2)自定义属性集合变更记录。自定义属性存储结构如下图所示:针对这两份数据,应该提供两种查询接口,分别是查询全量数据和查询增量数据。这两种接口的组合应用可以极大提升聊天室服务的属性查询响应和自定义分发能力。10.3 自定义属性的拉取内存中的全量数据,主要给从未拉取过自定义属性的成员使用。刚进入聊天室的成员,直接拉取全量自定义属性数据然后展示即可。对于已经拉取过全量数据的成员来说,若每次都拉取全量数据,客户端想获得本次的修改内容,就需要比对客户端的全量自定义属性与服务器端的全量自定义属性,无论比对行为放在哪一端,都会增加一定的计算压力。所以:为了实现增量数据的同步,构建一份属性变更记录集合十分必要。这样:大部分成员在收到自定义属性有变更来拉取时,都可以获得增量数据。属性变更记录采用的是一个有序的 map 集合:key 为变更时间戳,value 里存着变更的类型以及自定义属性内容,这个有序的 map 提供了这段时间内所有的自定义属性的动作。自定义属性的分发逻辑与消息一致:均为通知拉取。即客户端在收到自定义属性变更拉取的通知后,带着自己本地最大自定义属性的时间戳来拉取。比如:如果客户端传的时间戳为 4,则会拉取到时间戳为 5 和时间戳为 6 的两条记录。客户端拉取到增量内容后在本地进行回放,然后对自己本地的自定义属性进行修改和渲染。11、多人群聊参考资料[1] IM单聊和群聊中的在线状态同步应该用“推”还是“拉”?[2] IM群聊消息如此复杂,如何保证不丢不重?[3] 移动端IM中大规模群消息的推送如何保证效率、实时性?[4] 现代IM系统中聊天消息的同步和存储方案探讨[5] 关于IM即时通讯群聊消息的乱序问题讨论[6] IM群聊消息的已读回执功能该怎么实现?[7] IM群聊消息究竟是存1份(即扩散读)还是存多份(即扩散写)?[8] 一套高可用、易伸缩、高并发的IM群聊、单聊架构方案设计实践[9] IM群聊机制,除了循环去发消息还有什么方式?如何优化?[10] 网易云信技术分享:IM中的万人群聊技术方案实践总结[11] 阿里钉钉技术分享:企业级IM王者——钉钉在后端架构上的过人之处[12] IM群聊消息的已读未读功能在存储空间方面的实现思路探讨[13] 企业微信的IM架构设计揭秘:消息模型、万人群、已读回执、消息撤回等[14] 融云IM技术分享:万人群聊消息投递方案的思考和实践学习交流:- 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》- 开源IM框架源码:https://github.com/JackJiang2011/MobileIMSDK(本文已同步发布于:http://www.52im.net/thread-3835-1-1.html)
本文由cxuan分享,原题“原来这才是 Socket”,有修订。1、引言本系列文章前面那些主要讲解的是计算机网络的理论基础,但对于即时通讯IM这方面的应用层开发者来说,跟计算机网络打道的其实是各种API接口。本篇文章就来聊一下网络应用程序员最熟悉的Socket这个东西,抛开生涩的计算机网络理论,从应用层的角度来理解到底什么是Socket。对于 Socket 的认识,本文将从以下几个方面着手介绍:1)Socket 是什么;2)Socket 是如何创建的;3)Socket 是如何连接的;4)Socket 是如何收发数据的;5)Socket 是如何断开连接的;6)Socket 套接字的删除等。特别说明:本文中提到的“Socket”、“网络套接字”、“套接字”,如无特殊指明,指的都是同一个东西哦。2、Socket 是什么一个数据包经由应用程序产生,进入到协议栈中进行各种报文头的包装,然后操作系统调用网卡驱动程序指挥硬件,把数据发送到对端主机。整个过程的大体的图示如下:我们大家知道,协议栈其实是位于操作系统中的一些协议的堆叠,这些协议包括 TCP、UDP、ARP、ICMP、IP等。通常某个协议的设计都是为了解决特定问题的,比如:1)TCP 的设计就负责安全可靠的传输数据;2)UDP 设计就是报文小,传输效率高;3)ARP 的设计是能够通过 IP 地址查询物理(Mac)地址;4)ICMP 的设计目的是返回错误报文给主机;5)IP 设计的目的是为了实现大规模主机的互联互通。应用程序比如浏览器、电子邮件、文件传输服务器等产生的数据,会通过传输层协议进行传输。而应用程序是不会和传输层直接建立联系的,而是有一个能够连接应用层和传输层之间的套件,这个套件就是 Socket。在上面这幅图中,应用程序包含 Socket 和解析器,解析器的作用就是向 DNS 服务器发起查询,查询目标 IP 地址(关于DNS请见《理论联系实际,全方位深入理解DNS》)。应用程序的下面:就是操作系统内部,操作系统内部包括协议栈,协议栈是一系列协议的堆叠。操作系统下面:就是网卡驱动程序,网卡驱动程序负责控制网卡硬件,驱动程序驱动网卡硬件完成收发工作。在操作系统内部有一块用于存放控制信息的存储空间,这块存储空间记录了用于控制通信的控制信息。其实这些控制信息就是 Socket 的实体,或者说存放控制信息的内存空间就是Socket的实体。这里大家有可能不太清楚所以然,所以我用了一下 netstat 命令来给大伙看一下Socket是啥玩意。我们在 Windows 的命令提示符中输入:netstat-ano# netstat 用于显示Socket内容 , -ano 是可选选项# a 不仅显示正在通信的Socket,还显示包括尚未开始通信等状态的所有Socket# n 显示 IP 地址和端口号# o 显示Socket的程序 PID我的计算机会出现下面结果:如上图所示:1)每一行都相当于一个Socket;2)每一列也被称为一个元组。所以,一个Socket就是五元组:1)协议;2)本地地址;3)外部地址;4)状态;5)PID。PS:有的时候也被叫做四元组,四元组不包括协议。我们来解读一下上图中的数据,比如图中的第一行:1)它的协议就是 TCP,本地地址和远程地址都是 0.0.0.0(这表示通信还没有开始,IP 地址暂时还未确定)。2)而本地端口已知是 135,但是远程端口还未知,此时的状态是 LISTENING(LISTENING 表示应用程序已经打开,正在等待与远程主机建立连接。关于各种状态之间的转换,大家可以阅读《通俗易懂-深入理解TCP协议(上):理论基础》)。3)最后一个元组是 PID,即进程标识符,PID 就像我们的身份证号码,能够精确定位唯一的进程。3、Socket 是如何创建的通过上节的讲解,现在你可能对 Socket 有了一个基本的认识,先喝口水,休息一下,让我们继续探究 Socket。现在我有个问题,Socket 是如何创建的呢?Socket 是和应用程序一起创建的。应用程序中有一个 socket 组件,在应用程序启动时,会调用 socket 申请创建Socket,协议栈会根据应用程序的申请创建Socket:首先分配一个Socket所需的内存空间,这一步相当于是为控制信息准备一个容器,但只有容器并没有实际作用,所以你还需要向容器中放入控制信息;如果你不申请创建Socket所需要的内存空间,你创建的控制信息也没有地方存放,所以分配内存空间,放入控制信息缺一不可。至此Socket的创建就已经完成了。Socket创建完成后,会返回一个Socket描述符给应用程序,这个描述符相当于是区分不同Socket的号码牌。根据这个描述符,应用程序在委托协议栈收发数据时就需要提供这个描述符。4、Socket 是如何连接的Socket创建完成后,最终还是为数据收发服务的。但是,在数据收发之前,还需要进行一步“连接”(术语就是 connect),建立连接有一整套过程。这个“连接”并不是真实的连接(用一根水管插在两个电脑之间?不是你想的这样。。。)。实际上这个“连接”是应用程序通过 TCP/IP 协议标准从一个主机通过网络介质传输到另一个主机的过程。Socket刚刚创建完成后,还没有数据,也不知道通信对象。在这种状态下:即使你让客户端应用程序委托协议栈发送数据,它也不知道发送到哪里。所以浏览器需要根据网址来查询服务器的 IP 地址(做这项工作的协议是 DNS),查询到目标主机后,再把目标主机的 IP 告诉协议栈。至此,客户端这边就准备好了。在服务器上:与客户端一样也需要创建Socket,但是同样的它也不知道通信对象是谁,所以我们需要让客户端向服务器告知客户端的必要信息:IP 地址和端口号。现在通信双方建立连接的必要信息已经具备,可以开始“连接”过程了。首先:客户端应用程序需要调用 Socket 库中的 connect 方法,提供 socket 描述符和服务器 IP 地址、端口号。以下是connect的伪码调用:connect(<描述符>、<服务器IP地址和端口号>)这些信息会传递给协议栈中的 TCP 模块,TCP 模块会对请求报文进行封装,再传递给 IP 模块,进行 IP 报文头的封装,然后传递给物理层,进行帧头封装。之后通过网络介质传递给服务器,服务器上会对帧头、IP 模块、TCP 模块的报文头进行解析,从而找到对应的Socket。Socket收到请求后,会写入相应的信息,并且把状态改为正在连接。请求过程完成后:服务器的 TCP 模块会返回响应,这个过程和客户端是一样的(如果大家不太清楚报文头的封装过程,可以阅读《快速理解TCP协议一篇就够》)。在一个完整的请求和响应过程中,控制信息起到非常关键的作用:1)SYN 就是同步的缩写,客户端会首先发送 SYN 数据包,请求服务端建立连接;2)ACK 就是相应的意思,它是对发送 SYN 数据包的响应;3)FIN 是终止的意思,它表示客户端/服务器想要终止连接。由于网络环境的复杂多变,经常会存在数据包丢失的情况,所以双方通信时需要相互确认对方的数据包是否已经到达,而判断的标准就是 ACK 的值。上面的文字不够生动,动画可以更好的说明这个过程:▲ 上图引用自《跟着动画来学TCP三次握手和四次挥手》(PS:这个“连接”的详细理论知识,可以阅读《理论经典:TCP协议的3次握手与4次挥手过程详解》、《跟着动画来学TCP三次握手和四次挥手》,这里不再赘述。)当所有建立连接的报文都能够正常收发之后,此时套接字就已经进入可收发状态了,此时可以认为用一根管理把两个套接字连接了起来。当然,实际上并不存在这个管子。建立连接之后,协议栈的连接操作就结束了,也就是说 connect 已经执行完毕,控制流程被交回给应用程序。另外:如果你对Socket代码更熟悉的话,可以先读读这篇《手把手教你写基于TCP的Socket长连接》。5、Socket 是如何收发数据的当控制流程上节中的连接过程回到应用程序之后,接下来就会直接进入数据收发阶段。数据收发操作是从应用程序调用 write 将要发送的数据交给协议栈开始的,协议栈收到数据之后执行发送操作。协议栈不会关心应用程序传输过来的是什么数据,因为这些数据最终都会转换为二进制序列,协议栈在收到数据之后并不会马上把数据发送出去,而是会将数据放在发送缓冲区,再等待应用程序发送下一条数据。为什么收到数据包不会直接发送出去,而是放在缓冲区中呢?因为只要一旦收到数据就会发送,就有可能发送大量的小数据包,导致网络效率下降(所以协议栈需要将数据积攒到一定数量才能将其发送出去)。至于协议栈会向缓冲区放多少数据,这个不同版本和种类的操作系统有不同的说法。不过,所有的操作系统都会遵循下面这几个标准:1)第一个判断要素:是每个网络包能够容纳的数据长度,判断的标准是 MTU,它表示的是一个网络包的最大长度。最大长度包含头部,所以如果单论数据区的话,就会用 MTU - 包头长度,由此的出来的最大数据长度被称为 MSS。2)另一个判断标准:是时间,当应用程序产生的数据比较少,协议栈向缓冲区放置数据效率不高时,如果每次都等到 MSS 再发送的话,可能因为等待时间太长造成延迟。在这种情况下,即使数据长度没有到达 MSS,也应该把数据发送出去。但协议栈并没有告诉我们怎样平衡这两个因素,如果数据长度优先,那么效率有可能比较低;如果时间优先,那又会降低网络的效率。经过了一段时间。。。。。。假设我们使用的是长度有限法则:此时缓冲区已满,协议栈要发送数据了,协议栈刚要把数据发送出去,却发现无法一次性传输这么大数据量(相对的)的数据,那怎么办呢?在这种情况下,发送缓冲区中的数据就会超过 MSS 的长度,发送缓冲区中的数据会以 MSS 大小为一个数据包进行拆分,拆分出来的每块数据都会加上 TCP,IP,以太网头部,然后被放进单独的网络包中。到现在,网络包已经准备好发往服务器了,但是数据发送操作还没有结束,因为服务器还未确认是否已经收到网络包。因此在客户端发送数据包之后,还需要服务器进行确认。TCP 模块在拆分数据时,会计算出网络包偏移量,这个偏移量就是相对于数据从头开始计算的第几个字节,并将算好的字节数写在 TCP 头部,TCP 模块还会生成一个网络包的序号(SYN),这个序号是唯一的,这个序号就是用来让服务器进行确认的。服务器会对客户端发送过来的数据包进行确认,确认无误之后,服务器会生成一个序号和确认号(ACK)并一起发送给客户端,客户端确认之后再发送确认号给服务器。我们来看一下实际的工作过程:首先:客户端在连接时需要计算出序号初始值,并将这个值发送给服务器。接下来:服务器通过这个初始值计算出确认号并返回给客户端(初始值在通信过程中有可能会丢弃,因此当服务器收到初始值后需要返回确认号用于确认)。同时:服务器也需要计算出从服务器到客户端方向的序号初始值,并将这个值发送给客户端。然后,客户端也需要根据服务器发来的初始值计算出确认号发送给服务器。至此:连接建立完成,接下来就可以进入数据收发阶段了。数据收发阶段中,通信双方可以同时发送请求和响应,双方也可以同时对请求进行确认。请求 - 确认机制非常强大:通过这一机制,我们可以确认接收方有没有收到某个包,如果没有收到则重新发送,这样一来,但凡网络中出现的任何错误,我们都可以即使发现并补救。上面的文字不够生动,动画可以更好的理解请求 - 确认机制:▲ 上图引用自《跟着动画来学TCP三次握手和四次挥手》网卡、集线器、路由器(见《史上最通俗的集线器、交换机、路由器功能原理入门》)都没有错误补救机制,一旦检测到错误就会直接丢弃数据包,应用程序也没有这种机制,起作用的只是 TCP/IP 模块。由于网络环境复杂多变,所以数据包会存在丢失情况,因此发送序号和确认号也存在一定规则,TCP 会通过窗口管理确认号,我们这篇文章不再赘述,大家可以阅读《通俗易懂-深入理解TCP协议(下):RTT、滑动窗口、拥塞处理》来寻找答案。PS:另一篇《我们在读写Socket时,究竟在读写什么?》中用动画详细说明了这个过程,有兴趣可以读一读。6、Socket 是如何断开连接的当通信双方不再需要收发数据时,需要断开连接。不同的应用程序断开连接的时机不同。以 Web 为例:浏览器向 Web 服务器发送请求消息,Web 服务器再返回响应消息,这时收发数据就全部结束了,服务器可能会首先发起断开响应,当然客户端也有可能会首先发起(谁先断开连接是应用程序做出的判断),与协议栈无关。无论哪一方发起断开连接的请求,都会调用 Socket 库的 close 程序。我们以服务器断开连接为例:服务器发起断开连接请求,协议栈会生成断开连接的 TCP 头部,其实就是设置 FIN 位,然后委托 IP 模块向客户端发送数据,与此同时,服务器的Socket会记录下断开连接的相关信息。收到服务器发来 FIN 请求后:客户端协议栈会将Socket标记为断开连接状态,然后,客户端会向服务器返回一个确认号,这是断开连接的第一步,在这一步之后,应用程序还会调用 read 来读取数据。等到服务器数据发送完成后,协议栈会通知客户端应用程序数据已经接收完毕。只要收到服务器返回的所有数据,客户端就会调用 close 程序来结束收发操作,这时客户端会生成一个 FIN 发送给服务器,一段时间后服务器返回 ACK 号。至此,客户端和服务器的通信就结束了。上面的文字不够生动,动画可以更好的说明这个过程:▲ 上图引用自《跟着动画来学TCP三次握手和四次挥手》PS:断开连接的详细理论知识,可以阅读《理论经典:TCP协议的3次握手与4次挥手过程详解》、《跟着动画来学TCP三次握手和四次挥手》,这里不再赘述。7、Socket的删除上述通信过程完成后,用来通信的Socket就不再会使用了,此时我们就可以删除这个Socket了。不过,这时候Socket不会马上删除,而是等过一段时间再删除。等待这段时间是为了防止误操作,最常见的误操作就是客户端返回的确认号丢失,至于等待多长时间,和数据包重传的方式有关,这里我们就深入展开讨论了。关于Socket操作的全过程,如果从系统的角度来看,可能会更深入一些,建议可以深入阅读张彦飞的《深入操作系统,从内核理解网络包的接收过程(Linux篇)》一文。9、参考资料[1] TCP/IP详解 - 第17章·TCP:传输控制协议[2] TCP/IP详解 - 第18章·TCP连接的建立与终止[3] TCP/IP详解 - 第21章·TCP的超时与重传[4] 快速理解网络通信协议(上篇)[5] 快速理解网络通信协议(下篇)[6] 面视必备,史上最通俗计算机网络分层详解[7] 假如你来设计网络,会怎么做?[8] 假如你来设计TCP协议,会怎么做?[10] 浅析TCP协议中的疑难杂症(下篇)[11] 关闭TCP连接时为什么会TIME_WAIT、CLOSE_WAIT[12] 从底层入手,深度分析TCP连接耗时的秘密- 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》- 开源IM框架源码:https://github.com/JackJiang2011/MobileIMSDK(本文已同步发布于:http://www.52im.net/thread-3821-1-1.html)
本文由爱奇艺技术团队原创分享,原题《爱奇艺Android客户端启动优化与分析》。 1、引言 互联网领域里有个八秒定律,如果网页打开时间超过8秒,便会有超过70%的用户放弃等待,对Android APP而言,要求更加严格,如果系统无响应时间超过5秒,便会出现ANR,APP可能会被强制关闭,因此,启动时间作为一个重要的性能指标,关系着用户的第一体验。 爱奇艺安卓APP非常重视启动速度的优化,本文将从启动过程、启动时间测量、启动优化、以及后续监控等方面分享我们在启动优化方面积累的经验。 相关文章: 《移动端IM实践:Android版微信如何大幅提升交互性能(一)》 《移动端IM实践:Android版微信如何大幅提升交互性能(二)》 《移动端IM实践:iOS版微信界面卡顿监测方案》 《微信团队原创分享:Android内存泄漏监控和优化技巧总结》 《美图App的移动端DNS优化实践:HTTPS请求耗时减小近半》 (本文同步发布于:http://www.52im.net/thread-2221-1-1.html) 2、启动模式 要准确的测量APP的启动时间,首先我们要了解APP整个启动过程。 启动过程,一般可以分为以下三类: 从上图可以看出,启动过程中,Cold的模式下,生命周期中做的事情最多,启动的时间最长。因此,我们以冷启动来衡量APP启动时间。 那么启动过程中,如何判断哪些生命周期影响启动速度呢?请继续往下读。 3、启动过程 我们知道,APP的启动和运行,就是Linux系统创建进程和组件对象,并在UI线程中处理组件消息的过程。 启动过程图: App的启动过程,可以划分为三个阶段,下面就各个阶段进行详细讲解。 3.1 创建进程 当APP启动时,如果当前app的进程不存在,便会创建新的进程;App主进程启动后,如果启动某个组件,并且该组件设置了android:process属性,组件所运行的进程不存在,也会创建新的进程。 需要注意的是,如果在启动阶段,初始化的组件中,包含了多个进程,便会创建多次进程,BindApplication操作也会重复执行多次 3.2 创建UI线程及Handler 进程创建后,会通过反射,执行ActivityThread入口函数,创建Handler,并在当前线程中prepareMainLooper,并在Handler中接收组件的消息。 我们来看一下Handler中处理的消息: 1)LAUNCH_ACTIVITY:启动,执行Activity; 2)RESUME_ACTIVITY:恢复Activity; 3)BIND_APPLICATION,启动app; 4)BIND_SERVICE:Service创建, onBind; 5)LOW_MEMORY:内存不足,回收后台程序。 sMainThreadHandler中,处理的消息很多,这里只罗列了,可能在启动阶段可能会执行的操作, 这些操作都是运行在Main Thread中,对启动而言,属于阻塞性的。 Activity生命周期,自然需要在启动阶段执行,但,对于Service的创建,Trim_memory回调,广播接收等操作,就需要重点考虑,其操作耗时性。 3.3 Activity运行及绘制 前两个过程,创建进程和UI线程及Handler,都是由系统决定的,对APP开发者而言,并不能控制其执行时间,在本阶段,执行BindApplication,和Acitivity生命周期,都是可以由开发者自定义。 Activity执行到onResume之后,会执行至ViewRootImpl,执行两次performTraversals,第二次traversal操作中,会执行performDraw操作,同时通知RenderThread线程执行绘制。 从启动的三个阶段,我们可以看出,启动启动时间的长短的决定因素在于:主线程中所做事情消耗的时间的多少。 所以:我们的优化工作主要集中在,排查主线程中耗时性的工作,并进行合理的优化。 Android手机,系统的资源是有限的,过多的异步线程,会抢占CPU,导致主线程执行时间片间隔增大。同样的,内存消耗状态,GC频率,也会影响启动的时间。 4、分析及测量 通过上述的源码的解读,我们已经了解了启动过程,以及可能引起启动过慢的原因。接下来介绍一些常用的分析手段及时间测量方法。 我们的启动分析工具主要使用SysTrace,具体的使用方法请参考官网文档:https://developer.android.com/studio/command-line/systrace。 ▲ Andriod上的各种各样分析工具,请自行选用 4.1 SysTrace分析技巧 【4.1.1、UI Thread 颜色显示】 绿色:Running 白色:Sleeping 棕色:Uninterruptible Sleep 橙色:Uninterruptible Sleep - Block I/O 其中10ms以内的,较短时间的Sleeping状态,不用关注,可能是由于CPU调度的时间片分配间隔引起的;较长时间的Block I/O和Sleep状态,可以确定有阻塞启动的逻辑在这个阶段运行,需要进一步对代码进行分析定位。 【4.1.2、查看CPU状态及线程运行时长】 查看CPU占用状态: 线程执行: 通过该阶段密集程度,反映出CPU占用率,也能在一定程度上反映出该阶段执行时间被阻塞情况;线程执行情况统计,可以查看线程执行时间排名,对执行时间较长的子线程进行优化。 4.2 SysTrace启动时间 在SysTrace图中,UI Thread中包含了bindApplication,activityStart,traversal等操作,RenderThread中包含DrawFrame等操作。这些TAG节点是源码已经添加的,可参考#3.2中介绍。 Trace上启动时间: 从bindApplication至第二次traversal完成,可认为UI第一次绘制完成,启动完成。选中开始点和结束点,可以查看过程消耗的时间。 4.3 adb shell am start -W 在统计APP启动时间时,系统为我们提供了adb命令,可以输出启动时间 TotalTime: 表示新应用启动的耗时,包括新进程的启动和 Activity 的启动,但不包括前一个应用 Activity pause 的耗时。 系统在绘制完成后,ActivityManagerService会回调该方法,统计时间不如SysTrace准确,但是能够方便我们通过脚本多次启动测量TotalTime,对比版本间启动时间差异。 4.4 埋点 通过APP启动生命周期中,关键位置加入时间点记录,达到测量目的。 4.5 录屏 录屏方式收集到的时间,更接近于用户的真实体感。 5、优化总结 为了让用户在进入APP之后,更快更流畅的使用服务,所以会在启动过程中,提前对一些基础库和组建进行初始化操作,这就意味着系统有限的资源会被抢占,影响启动时间。启动时间的优化,是一个平衡性能和体验的过程。 通过Systrace工具分析,我们发现爱奇艺爱奇艺安卓APP启动过程中一些问题,接下来,我们就结合具体的业务实践,进行启动问题进行优化。 5.1 区分进程初始化Application 由第3章我们了解到,对于一个app而言,App内组件可以运行在不同的进程之中。 举个例子: 一个APP拥有主进程,插件进程,下载进程三个进程,会在启动阶段创建相应的组件,但只有一个QYApplication继承自系统Application,创建三次进程,QYApplication中attach(),onCreate()方法都会被执行三次。 每个进程说需要初始化的内容肯定是不一样的,所以,为了防止资源的浪费,我们需要区分进程,初始化Appcation. 成果:对多进程应用而言,通过对初始化内容进行梳理,合理区分初始化,会大幅减少内存和CPU占用。 5.2 异步处理耗时任务 子线程处理耗时任务,主线程做的事情越少,越早进入Acitivity绘制阶段,界面越早展现。 注意: 1)不在主线程做耗时任务,如文件,网络等; 2)启动阶段初始化任务,尽量在异步线程处理; 3)主线程,不用等待或者依赖于子线程任务。 进一步优化:可以自建线程池,维持一定线程个数,管理任务队列。 5.3 防止多线程抢占CPU Android系统资源有限,特别是CPU资源,理论上来说,UI线程执行的任务,也无法保证一直被调度状态,当并发的线程数过多,UI线程时间片会更短,从而导致启动时间被变慢。 下面罗列一些常见,容易造成CPU被抢占的场景: 成果:通过对执行时间较久,执行频率的业务进行优化,将CPU占有率维持在合理的程度,会大幅减少启动时间,减少300ms以上。 5.4 系统API使用 部分系统的API使用是阻塞性的,文件很小可能无法感知,当文件过大,或者使用频繁时,可能造成阻塞。 例如: 1)SharedPreference.Editor提交操作: - commit方法属于属于阻塞性质API,建议使用apply; - 此外,我们知道,SP文件的存储是一个XML文件,以key-value形式存储,当业务过多时,需要拆分为多个文件存储,防止文件过大,出现读取耗时及ANR; - 进一步优化,可对启动阶段,频繁的SP操作在内存中,统一提交。 2)AssetManager.open操作: Android开发中,我们有时会将资源文件放在assets目录中,然后使用open操作读取文件,如果文件过大,需要在异步线程中执行。 成果:随着业务量日积月累,正常的系统API的使用,也可能出现问题,通过排除,可减少50-100ms。 5.5 精简布局 布局的复杂程度,直接影响绘制的时间。 举个例子: 在启动过程中,会有需要大的背景图,只有第一次安装时使用,后续属性设置为android:visibility="gone",但是,虽然设置了gone属性,不会显示,但依旧会被解析。 建议: 1)减少布局层次; 2)无用资源使用ViewStub,使用时加载。 成果:启动阶段的布局较简单,通过优化背景图片的加载,减少50-100ms。 5.6 Service延后初始化 App启动中过程中,经常进行Service初始化操作,由于Service使用一般不涉及界面,可能会认为初始化生命周期不在主线程中,其实不然,在3.2的启动过程源码介绍中讲到,Service的生命周期,也属于主线程Handler接收的Message之一。 建议:Service生命周期中,注意逻辑执行时间性能优化,初始化尽量延后。 成果:取决于初始化Service的生命周期执行时间,可减少200ms以上。 5.7 将任务delay至首页绘制完成后 对于APP首页展示不需要的初始化逻辑,可延后至首页绘制完成后初始化。 注意:需要post两次才能保证在第一次绘制之后显示,因为,系统绘制会执行两次Performtraversal。 进一步优化:可将业务逻辑的初始化划分为,首页绘制后,5s,10s,20s三个阶段分别初始化,防止首页绘制执行任务过多造成掉帧。 成果:释放绘制阶段的CPU,可将复杂的绘制提前200ms以上。 6、性能监控 稳定的用户体验依赖于持续的监控,爱奇艺为监控启动性能建立了一套监控体系、测试、工具、开发等几个团队从不同的纬度搭建不同的监控方案。 监控方案如下: 1)测试:录屏,从用户的真实体验角度,获取最准确的启动时间; 2)实时监控:通过埋点,大数据采样投递获取真实线上环境数据,从地域,时间,机型,app版本,系统版本等各个纬度对启动时间进行监控; 3)脚本测试:通过对脚本,对同一收集多次启动数据进行收集,通过不同版本间的对比,监控启动时间的变化情况。 7、SysTrace扩展 SysTrace通过TAG节点可以清晰展现,启动过程以及方法执行时间,但是,从发现问题,然后通过节点去定位问题,是一件很繁琐的工作,如果你们工程编译又比较慢,简直让人崩溃。 比如:自动化TAG注入。 在Android工程编译的过程中,指定class,在方法前后,自动化插入Trace节点,统计方法执行时间。 流程: 1)在编译的过程中,插入自定义Task任务; 2)读取配置文件,文件中包含了需要注入java文件名和路径名和method; 3)找到需要注入的class文件,然后通过ASM改变字节码,方法前后,插入自定义自定义方法。 通过工具的操作,能够做到不用修改原有工程文件,自动在打包时注入TAG节点和逻辑代码,配置文件可以循环利用,提高分析效率,节能环保。 8、优化成果 启动时间,由于不同的机型性能同,Android系统版本不同,同一APP版本启动时间,相差很大,所以统计一般以同一手机,不同版本做比较,尽量保证手机状态一致。 SysTrace手机优化时间对比: 脚本多次启动时间收集对比: 经过多个版本的持续优化,有无广告两种不同的场景下,启动时间分别减少40%和35%,启动速度得到了较大的提升。 9、本文小结 启动时间的优化和监控,是一项长期的任务,需要对异常的情况进行分析,对可能造成阻塞的代码逻辑进行合理的优化,非常感谢各个业务团队支持和配合。 以上就是全部启动时间优化相关的内容,谢谢大家能够阅读到这里,如果有更好的建议,欢迎交流! 附录:更多移动端高级开发技术分享 《全面了解移动端DNS域名劫持等杂症:技术原理、问题根源、解决方案等》 《金蝶随手记团队分享:还在用JSON? Protobuf让数据传输更省更快(原理篇)》 《金蝶随手记团队分享:还在用JSON? Protobuf让数据传输更省更快(实战篇)》 《腾讯技术分享:社交网络图片的带宽压缩技术演进之路》 《通俗易懂:基于集群的移动端IM接入层负载均衡方案分享》 《QQ音乐团队分享:Android中的图片压缩技术详解(上篇)》 《QQ音乐团队分享:Android中的图片压缩技术详解(下篇)》 《腾讯原创分享(一):如何大幅提升移动网络下手机QQ的图片传输速度和成功率》 《腾讯原创分享(二):如何大幅压缩移动网络下APP的流量消耗(上篇)》 《腾讯原创分享(三):如何大幅压缩移动网络下APP的流量消耗(下篇)》 《基于社交网络的Yelp是如何实现海量用户图片的无损压缩的?》 《腾讯技术分享:腾讯是如何大幅降低带宽和网络流量的(图片压缩篇)》 《腾讯技术分享:腾讯是如何大幅降低带宽和网络流量的(音视频技术篇)》 《最火移动端跨平台方案盘点:React Native、weex、Flutter》 《iPhone X 的 UI界面适配官方指南!》 《新浪微博技术分享:微博短视频服务的优化实践之路》 《全面掌握移动端主流图片格式的特点、性能、调优等》 《迈向高阶:优秀Android程序员必知必会的网络基础》 《HTTPS时代已来,打算更新你的HTTP服务了吗?》 《移动端APP的日志上报机制的优化实践》 《移动端网络优化之HTTP请求的DNS优化》 《伪即时通讯:分享滴滴出行iOS客户端的演进过程》 《Android版微信从300KB到30MB的技术演进(PPT讲稿) [附件下载]》 《微信团队原创分享:Android版微信从300KB到30MB的技术演进》 《Android程序员的痛你永远不懂(一):Bitmap到底占用多大内存?》 《Android程序员的痛你永远不懂(二):如何减少Bitmap内存占用?》 《Android反编译利器APKDB:没有美工的日子里继续坚强的撸》 《全面总结iOS版微信升级iOS9遇到的各种“坑”》 《微信团队原创资源混淆工具:让你的APK立减1M》 《微信团队原创Android资源混淆工具:AndResGuard [有源码]》 《Android版微信安装包“减肥”实战记录》 《iOS版微信安装包“减肥”实战记录》 《iOS端移动网络调优的8条建议》 《微信“红包照片”背后的技术难题》 《移动端IM实践:iOS版微信小视频功能技术方案实录》 《移动端IM实践:iOS版微信的多设备字体适配方案探讨》 《爱奇艺技术分享:爱奇艺Android客户端启动速度优化实践总结》 >> 更多同类文章 …… (本文同步发布于:http://www.52im.net/thread-2221-1-1.html)
本文原文由“狼和哈士奇”原创分享,本次内容有改动。 1、引言 张小龙说:微信消息不做“已读”和“未读”的功能,是因为要给人撒谎的机会,这才符合人性。 真的对吗? 关于这个问题……对,也不对。 ▲ 市面上有很多IM提供了已读功能,上图从左至右分别为:钉钉、易信、旺旺(千牛) (上图引用自文章《IM群聊消息的已读回执功能该怎么实现?》) 学习交流: - 即时通讯/推送技术开发交流4群:101279154 [推荐] - 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》 (本文同步发布于:http://www.52im.net/thread-2184-1-1.html) 2、张小龙赋予了微信所谓“人性”的定义 撒谎的确是人性,但是难道想知道对方是否已经查看了消息不是人性吗? 而查看消息后的举动更是引发人的好奇:是否回复,是否及时回复,为什么这个时候回复……这些都能反应出对方的态度,这种好奇同样是人性。 所以我认为张小龙只说了上半句,下半句应该是:不求有功,但求无过。 即:不满足想知道对方是否已经查看了消息的人性,也不得罪撒谎的人性,用户才会留存,才是最符合微信的利益。 这两种人性奇妙就奇妙在:他们是普遍的,且往往是同一个人都具有的——你有时候既想撒谎,有时候又想要知道对方是否查看了消息;它们相互转换,就像我们是发送者也是接收者。 这个功能改善了发送者的体验,但是可能会给接收者强大的回复压力。 相比较于戳破撒谎对于社交关系的伤害,改善体验的那部分并没有很好的弥补这部分伤害,微信是照顾整个社交关系,权衡利弊,这个功能也不能做。 这也很符合张小龙对于“上帝视角”的著名观点——产品经理要站在上帝视角上,制定出最基本的规则,最基本的才是最有包容性的、最有生命力的。 ▲ 微信的“朋友圈”抓住了国人虚荣的“人性”特点 3、为何其它IM里会有这个功能? 为什么淘宝就有这个功能呢? ▲ 阿里旺旺的PC端消息“已读”功能 换句话说:聊天消息的“已读”和“未读”状态在什么情况下该做呢? 这是一个典型的功能分析,遇到这种分析,我们应该如何用产品思维入手呢? 3.1 第一步:结构性思维 很多人遇到这种问题,不自觉地就从定位、场景、产品理念、用户体验等很多个角度来分析了,其实这就是结构性思维。 结构性思维就是:需要从不同角度,全面、透彻的看待一个问题。 但是结构性思维只是第一步,第二步是全面分析后,知道是哪些因素应该占据主导地位。 比如说上文说的微信这个功能,没有与它的商业目标发生矛盾,那么最核心点就是体验了,最主要就是从体验的角度出发。 但是,淘宝就不一样了。 淘宝是电子商务,其核心目标是促成交易;所有的功能都是为了这个最重要的目的服务。 聊天是发生在买家与卖家之间的,他们虽然是有社交属性,但是社交的目的主要也是为了买卖,所以买卖大于社交。 凡是能够促成交易的,都需要考虑。 这个功能实际上最主要就是提升了沟通效率:买家知道消息状态,不干等,继续逛,有利降低了买家干等引发的焦虑;这种焦虑有可能会降低买家继续了解下去或者购买的欲望,不利于促成交易。 这本质是什么? 本质就是服务——平台协助卖家服务好买家。 这里就用到了本源思维,本源思维就是透过现象看本质。 为什么运用本源思维呢? 因为往往没有所谓好功能和坏功能,只有合适的功能;功能总是有好处也有坏处,帮助我们做出选择的,就是本源思维。 本源思维往往涉及到两个核心点:定位+场景。 ▲ “马总” 从未断过在IM社交上跟某厂对垒的念头。。。 3.2 第二步:本源思维:定位+场景 我们先来看看两个网友,对于微信消息为什么没有“已读”和“未读”功能的优质回答。 回答1:首先需要明确的是对于社交产品的IM功能,是有接收者和发送者2种人群,每个社交产品的倾向性是不一样的,我记得陌陌是有“已读/未读”区分的,意在前期促进信息的产出,因此,会更偏向于发送者的体验。 而微信,在满足双方基本通信需求的基础上,是更倾向于接收者的体验的,而非发送者。 因此,微信对于接收者,有了”对方正在输入…..“这样的状态提示,告诉接收者:请不要着急,对方正在回复你,以此增强接收者的期望值。 而对于“已读/未读”这样的功能,显然是倾向于改善发送者的体验的,让发送者更直观感觉到我的信息是否得到反馈。 假如增加这样的功能,一定会降低接收者的体验。 同时,微信作为熟人间社交,“已读/未读”这样的功能不是没有用;而是对于大部分用户,这样的反馈是毫无价值的。 对于熟人而言,对方回复我了,肯定就是已读;对方没有回复,可能就是没看到或就是不想回。 而至于深层原因,作为熟人,我没必要知道的那么明白。 回答2:微信做的是熟人社交,里面的好友大多数都是熟悉的,试想想你上司给你发信息,你看了你又不回,会不会引起麻烦? 张小龙说过:如果我们针对需求一个人去满足,你可能获取了这部分用户,但是得罪了另外一部分用户,最后可能迫于社交的压力,流失掉相当一部分用户。 我们先暂时不用理会观点是否全部正确,实际上他们两个都用到了最基本的定位+场景分析,即这个产品是在什么场景下,通过什么方式,解决什么用户的什么需求。 4、回归到微信“熟人社交”的产品本质,就能想通为何没有这个功能了 无论微信发展的多大,它的核心功能仍然是基于熟人社交的即时通讯工具。 微信的聊天功能,解决的是熟人社交的即时通讯。即时通讯满足了,关注点就是熟人社交了。 明确了这个场景和定位,将相关方找出来,这里的相关方就是发送者和接收者两个。 分析这个功能对于发送者和接收者的体验,这个时候我们会发现:这个功能会改善发送者体验,但是降低回复者体验,如何抉择呢? 这个时候就从平台的产品目标出发,它的产品目标决定了它鼓励什么。 微信要优先照顾的是它的熟人社交关系: 1)这个功能如果只是单纯改善了发送者体验,那么可以做; 2)但是在改善发送者体验的同时,它有可能降低回复者的体验,这是可能会破坏微信的社交关系的,所以干脆不做。 实质上,越是高级的产品经理做决策最重要的依据往往是本源思维,就像张小龙在阐述为什么不做这个功能时只说了要给人撒谎、符合人性这个原因,实际上用的就是本源思维。 要记住:重点可能有很多,核心往往只有一个。 ▲ “抢红包”的牛X之处在于:居然能让面对面的两个人使用IM却不觉乏味 5、补充 本文是从产品角度讨论微信中的消息“已读”功能,如果您对消息“已读”功能的理论和技术实现有兴趣,可以进一步阅读《IM群聊消息的已读回执功能该怎么实现?》一文。 附录:更多讨论、思考、感悟的文章汇总 [1] 即时通讯/社交产品的实践总结、感悟分享: 《技术往事:微信估值已超5千亿,雷军曾有机会收编张小龙及其Foxmail》 《QQ和微信凶猛成长的背后:腾讯网络基础架构的这些年》 《闲话即时通讯:腾讯的成长史本质就是一部QQ成长史》 《腾讯开发微信花了多少钱?技术难度真这么大?难在哪?》 《技术往事:史上最全QQ图标变迁过程,追寻IM巨人的演进历史》 《开发往事:深度讲述2010到2015,微信一路风雨的背后》 《开发往事:记录微信3.0版背后的故事(距微信1.0发布9个月时)》 《微信七年回顾:历经多少质疑和差评,才配拥有今天的强大》 《前创始团队成员分享:盘点微信的前世今生——微信成功的必然和偶然》 《QQ的成功,远没有你想象的那么顺利和轻松》 《[技术脑洞] 如果把14亿中国人拉到一个微信群里技术上能实现吗?》 《QQ和微信止步不前,意味着即时通讯社交应用创业的第2春已来?》 《那些年微信开发过的鸡肋功能,及其带给我们的思考》 《为什么说即时通讯社交APP创业就是一个坑?》 《即时通讯创业必读:解密微信的产品定位、创新思维、设计法则等》 《老罗最新发布了“子弹短信”这款IM,主打熟人社交能否对标微信?》 《盘点和反思在微信的阴影下艰难求生的移动端IM应用》 《QQ现状深度剖析:你还认为QQ已经被微信打败了吗? 《那些年微信开发过的鸡肋功能,及其带给我们的思考》 《渐行渐远的人人网:十年亲历者的互联网社交产品复盘和反思》 《中国互联网社交二十年:全民见证的互联网创业演义》 《IM热门功能讨论:为什么微信里没有消息“已读”功能?》 >> 更多同类文章 …… [2] 程序员的百味人生: 《一个微信实习生自述:我眼中的微信开发团队》 《微信程序员创业总结:如何提高Android开发效率》 《如何做一个合格的 iOS Team Leader》 《程序员中年危机:拿什么拯救你,我的三十五岁》 《一个魔都程序员的3年:从程序员到CTO的历练》 《为什么说即时通讯社交APP创业就是一个坑?》 《致我们再也回不去的 Github ...》 《一名90后二流大学程序员的自述:我是如何从“菜鸟”到“辣鸡”的》 《一个魔都程序员的3年:从程序员到CTO的历练》 《选择比努力更重要:我是如何从流水线工人到程序员的?》 《程序员的抉择:必须离开帝都——因为除了工作机会,还有什么值得留恋?》 《干了这碗鸡汤:从理发店小弟到阿里P10技术大牛》 《程序员神级跳槽攻略:什么时候该跳?做什么准备?到哪里找工作?》 《感悟分享:在腾讯的八年,我的成长之路和职业思考》 《调皮的程序员:Linux之父雕刻在Linux内核中的故事》 《迷茫中前行:一个专科渣渣菜鸟的编程入门感悟》 《机会不给无准备的人:一个Android程序员屡战屡败的悲惨校招经历》 《笑中带泪的码农往事:入职三天被开,公司给100块叫我走人,有我惨?》 《阿里技术分享:阿里自研金融级数据库OceanBase的艰辛成长之路》 《干货分享:十年大厂资深程序员的开发经验总结》 >> 更多同类文章 …… (本文同步发布于:http://www.52im.net/thread-2184-1-1.html)
1、引言 MySQL作为开源技术的代表作之一,是互联网得以广泛流行的重要基础技术之一。 国外 GitHub、Airbnb、Yelp、Coursera 均在使用 MySQL 数据库,国内阿里巴巴、去哪儿网、腾讯、魅族、京东等等的部分关键业务同样使用了 MySQL 数据库。同时,MySQL 也是众多数据库排行榜单的第一名,丛多国内一线互联网企业都在用的开源数据库。 MySQL在互联网项目中如此流行,众多的开发者们在各种应用场景下总结了许多MySQL的最佳实践。本文将总结和分享当MySQL单表记录数过大时,增删改查性能急剧下降问题的优化思路,这也是资深后端架构师、程序员所必备的知识内容之一,希望本文对你有用。 (本文同步发布于:http://www.52im.net/thread-2157-1-1.html) 2、关于MySQL 2.1 MySQL之父 ▲ MySQL之父:Ulf Michael “Monty” Widenius Michael “Monty” Widenius, 1962年3月3日出生于芬兰赫尔辛基。开源 MySQL数据库的创始成员、MySQL AB公司的首席技术官、MySQL数据库第一行代码的作者、MySQL数据库命名人、MariaDB创始人兼首席技术官;独自完成撰写MySQL数据库服务器端95%的代码。 Monty是MySQL第一行代码的作者,后来与两位好友一起成立了MySQL AB,开始正式商业化运作MySQL,出任CTO,一直到MySQL AB被卖给Sun。之后Monty没有加入Sun,而是离职创立了Monty Program AB,接过MySQL的代码继续开发新的分支——MariaDB,自己担任CEO。 在2014年,Monty Program AB与SkySQL AB合并,成立了MariaDB Corporation,开始商业化运作MariaDB,Monty继续担任新公司的CTO。同时他还兼任MariaDB基金会的CTO。 关于MariaDB、MySQL、MaxDB名字的由来: Monty有一个女儿,名叫My,因此他将自己开发的数据库命名为MySQL。Monty还有一个儿子,名为Max,因此在2003年,SAP公司与MySQL公司建立合作伙伴关系后,Monty又将与SAP合作开发的数据库命名为MaxDB。而现在的MariaDB中的Maria是Monty小孙女的名字。 2.2 MySQL历史 MySQL的海豚标志的名字叫“sakila”,它是由MySQL AB的创始人从用户在“海豚命名”的竞赛中建议的大量的名字表中选出的。获胜的名字是由来自非洲斯威士兰的开源软件开发者Ambrose Twebaze提供。根据Ambrose所说,Sakila来自一种叫SiSwati的斯威士兰方言,也是在Ambrose的家乡乌干达附近的坦桑尼亚的Arusha的一个小镇的名字。 MySQL的历史可以追溯到1979年。当时Allan Larsson和Michael Widenius(Monty)开了一家自己的咨询公司,取名TcX,名字的由来已无从考证。有道是"前世尽付真情,今生亦现福缘积厚"。那年一个夜黑风高的晚上,Michael基于BASIC语言写出了他的第一款数据库报表工具UNIREG。 有当年的天气记录为证,Michael写完该工具时极光异常明亮,炫彩无比。大凡重大事情的发生,后来的著述人都会记录有一些类似的怪现象。比如刮风、下雨、冒仙气什么的,还有天上星星异常闪烁等等,反正就是说明这种事情很不简单。 最初的UNIREG是运行在瑞典人制造的ABC800计算机上的。ABC800的内存只有32KB,CPU是频率只有4MHz的Z80。在1983年Monty遇到了David Axmark,两人相见恨晚,开始合作运营TcX,Monty负责技术,David搞管理。后来TcX将UNIREG移植到其他更加强大的硬件平台,主要是Sun的平台。 ▲ ABC800计算机 1995年5月23日,MySQL的第一个内部版本发行了,并在第二年对外公布了MySQL官方正式发行版(3.11.1)。有趣的是,第一个MySQL正式版恰巧只能运行在Sun Solaris上,仿佛昭示了它日后被Sun收购的命运。 在接下来的两年中,MySQL被移植到不同的平台,同时加入了不少新的特性。到1998时,MySQL能够运行在10多种操作系统之上,其中包括应用非常广泛的 FreeBSD、Linux、Windows 95和Windows NT等。很快MySQL 3.22也发布了,但它仍然存在很多问题--如不支持事务操作、子查询、外键、存储过程和视图等功能。正因为这些缺陷,当时许多Oracle和SQL Server的用户对MySQL根本不屑一顾。 大概在1999的冬天,下了很大一场雪。然后独立的商业公司MySQL AB就在瑞典的中部城市Uppsala成立了。并于同年发布了包含事务型存储引擎BDB的MySQL 3.23。在集成BDB存储引擎的过程中,MySQL开发团队得到了很好的锻炼,为后来能将InnoDB整合以及开发开放插件式的存储引擎架构打下了坚实的基础。 MySQL从诞生之初就提供了双重的授权标准:个人使用是免费的,如果用于商业网站搭建或者Windows平台下就必须购买商业许可证。在2000年的时候MySQL做了一个重大的决定,改换成了GPL许可模式,也就是说商业用户也无需再购买许可证,但必须把他们的源码公开。虽然MySQL AB因此在收入上遭受了巨大的打击,损失了将近80%的收入,但他们依然坚持了GPL许可模式。 与此同时,芬兰公司Heikki开始接触MySQL AB,讨论将Heikki的存储引擎InnoDB整合到MySQL数据库中的可行性。双方的合作非常顺利,并于2001年推出MySQL 4.0 Alpha版本。经过两年的公开测试和应用,到了2003年,包含InnoDB的MySQL已经变得非常稳定了。随即在同一年,MySQL推出4.1版,第一次使得MySQL支持子查询,支持Unicode和预编译SQL等功能。 MySQL 4.1还在Alpha版时,公司已决定并行开发5.0版。因为他们打算加快MySQL的开发速度以适应日益苛刻的市场需求。这个新版本是有史以来MySQL最大的变化,添加了存储过程、服务端游标、触发器、查询优化以及分布式事务等在大家看来一个"正常数据库管理系统"应当拥有的一整套功能。 2008年2月,当时的业界开源老大Sun Microsystems动用10亿美元收购了MySQL,造就了开源软件的收购最高价。这次交易给开源交易设立了一个新的基准。在此之前的交易金额(JBoss、Zimbra、XenSource、Gluecode)从没接近过10亿美元,全部加起来才差不多与Sun Microsystems购买MySQL的花费持平。MySQL被收购之后,MySQL图标停止使用,取而代之的是Sun/MySQL图标。 MySQL和Sun合并之后,推出了MySQL 5.1GA版和MySQL 5.4 Beta版。5.4的推出照搬了4.1和5.0当时的开发模式,让5.4和6.0并行处于Beta开发阶段。 螳螂捕蝉,黄雀在后。2009年,数据库老大Oracle大笔一挥,开出74亿美元的支票,将Sun Microsystems和MySQL通盘收于旗下。 ▲ SUN被Oracle收购了 2.3 MySQL大事记 1999年,MySQL AB在瑞典正式宣布成立。 2000年,ISAM华丽转身MyISAM存储引擎。同年MySQL开放了自己的源代码,并且基于GPL许可协议。同年9月innoDB推出。 2003年,MySQL4.0发布,正式集成innodb 2005年,MySQL 5.0发布。同年Oracle把InnoDB引擎的开发公司innobase收购完成。MySQL明确地表现出迈向高性能数据库的发展步伐。 2006年,sun公司收购了MySQL公司,出价10亿美元。 2009年,Oracle公司收购sun,将MySQL纳入囊中。 2010年,MySQL 5.5正式版发布,Oracle完成了大量改进,并将innodb改成默认引擎。 2013年,MySQL 5.6 GA版本发布。 近期 - MySQL 5.7 GA版本横空出世,其性能、新特性、性能分析带来了质的改变。 2.4 MySQL现状及应用 ▲ 全球数据库排行(截止2017年) ▲ 全球最大网站Top20的数据库使用情况 以下是全球最大网站Top20列表: Facebook.com Google.com YouTube.com Yahoo.com WIKipedia.org - 维基百科 Live.com – 微软新的电子邮件服务 qq.com – 腾讯 Microsoft.com – 微软产品/更新/下载 Baidu.com – 百度 Msn.com – 微软自有互联网信息 Blogger.com – 博客平台 ASK.com - 搜索引擎 Taobao.com 淘宝 Twiter.com – 实时通讯平台 Bing.com – 必应 Sohu.com – 搜狐 Apple.com – 苹果 WrodPress.com – 成行经历 Sina.com – 新浪 Amazon.com-亚马逊 ▲ 国内MySQL行业应用 3、MySQL的单表优化干货总结 除非单表数据未来会一直不断上涨,否则不要一开始就考虑拆分,拆分会带来逻辑、部署、运维的各种复杂度,一般以整型值为主的表在千万级以下,字符串为主的表在五百万以下是没有太大问题的。而事实上很多时候MySQL单表的性能依然有不少优化空间,甚至能正常支撑千万级以上的数据量。 3.1 “字段”优化总结 1)尽量使用TINYINT、SMALLINT、MEDIUM_INT作为整数类型而非INT,如果非负则加上UNSIGNED; 2)VARCHAR的长度只分配真正需要的空间; 3)使用枚举或整数代替字符串类型; 4)尽量使用TIMESTAMP而非DATETIME; 5)单表不要有太多字段,建议在20以内; 6)避免使用NULL字段,很难查询优化且占用额外索引空间; 7)用整型来存IP。 3.2 “索引”优化总结 1)索引并不是越多越好,要根据查询有针对性的创建,考虑在WHERE和ORDER BY命令上涉及的列建立索引,可根据EXPLAIN来查看是否用了索引还是全表扫描; 2)应尽量避免在WHERE子句中对字段进行NULL值判断,否则将导致引擎放弃使用索引而进行全表扫描。 3)值分布很稀少的字段不适合建索引,例如"性别"这种只有两三个值的字段。 4)字符字段只建前缀索引。 5)字符字段最好不要做主键。 6)不用外键,由程序保证约束。 7)尽量不用UNIQUE,由程序保证约束。 8)使用多列索引时主意顺序和查询条件保持一致,同时删除不必要的单列索引。 3.3 “查询SQL”优化总结 1)可通过开启慢查询日志来找出较慢的SQL; 2)不做列运算:SELECT id WHERE age + 1 = 10,任何对列的操作都将导致表扫描,它包括数据库教程函数、计算表达式等等,查询时要尽可能将操作移至等号右边; 3)sql语句尽可能简单:一条sql只能在一个cpu运算;大语句拆小语句,减少锁时间;一条大sql可以堵死整个库; 4)不用SELECT *; 5)OR改写成IN:OR的效率是n级别,IN的效率是log(n)级别,in的个数建议控制在200以内; 6)不用函数和触发器,在应用程序实现; 7)避免%xxx式查询; 8)少用JOIN; 9)使用同类型进行比较,比如用'123'和'123'比,123和123比; 10)尽量避免在WHERE子句中使用!=或<>操作符,否则将引擎放弃使用索引而进行全表扫描; 11)对于连续数值,使用BETWEEN不用IN:SELECT id FROM t WHERE num BETWEEN 1 AND 5; 12)列表数据不要拿全表,要使用LIMIT来分页,每页数量也不要太大。 3.4 “引擎”的选择 目前广泛使用的是MyISAM和InnoDB两种引擎。 【MyISAM】: MyISAM引擎是MySQL 5.1及之前版本的默认引擎,它的特点是: 1)不支持行锁,读取时对需要读到的所有表加锁,写入时则对表加排它锁; 2)不支持事务; 3)不支持外键; 4)不支持崩溃后的安全恢复; 5)在表有读取查询的同时,支持往表中插入新纪录; 6)支持BLOB和TEXT的前500个字符索引,支持全文索引; 7)支持延迟更新索引,极大提升写入性能; 8)对于不会进行修改的表,支持压缩表,极大减少磁盘空间占用。 【InnoDB】: InnoDB在MySQL 5.5后成为默认索引,它的特点是: 1)支持行锁,采用MVCC来支持高并发; 2)支持事务; 3)支持外键; 4)支持崩溃后的安全恢复; 5)不支持全文索引。 总体来讲,MyISAM适合SELECT密集型的表,而InnoDB适合INSERT和UPDATE密集型的表。 3.5 系统调优参数 可以使用下面几个工具来做基准测试: sysbench:一个模块化,跨平台以及多线程的性能测试工具; iibench-mysql:基于 Java 的 MySQL/Percona/MariaDB 索引进行插入性能测试工具; tpcc-mysql:Percona开发的TPC-C测试工具。 具体的调优参数内容较多,具体可参考官方文档,这里介绍一些比较重要的参数: 1)back_log:back_log值指出在MySQL暂时停止回答新请求之前的短时间内多少个请求可以被存在堆栈中。也就是说,如果MySql的连接数据达到max_connections时,新来的请求将会被存在堆栈中,以等待某一连接释放资源,该堆栈的数量即back_log,如果等待连接的数量超过back_log,将不被授予连接资源。可以从默认的50升至500; 2)wait_timeout:数据库连接闲置时间,闲置连接会占用内存资源。可以从默认的8小时减到半小时; 3)max_user_connection: 最大连接数,默认为0无上限,最好设一个合理上限; 4)thread_concurrency:并发线程数,设为CPU核数的两倍; 5)skip_name_resolve:禁止对外部连接进行DNS解析,消除DNS解析时间,但需要所有远程主机用IP访问; 6)key_buffer_size:索引块的缓存大小,增加会提升索引处理速度,对MyISAM表性能影响最大。对于内存4G左右,可设为256M或384M,通过查询show status like 'key_read%',保证key_reads / key_read_requests在0.1%以下最好; 7)innodb_buffer_pool_size:缓存数据块和索引块,对InnoDB表性能影响最大。通过查询show status like 'Innodb_buffer_pool_read%',保证 (Innodb_buffer_pool_read_requests – Innodb_buffer_pool_reads) / Innodb_buffer_pool_read_requests越高越好; 8)innodb_additional_mem_pool_size:InnoDB存储引擎用来存放数据字典信息以及一些内部数据结构的内存空间大小,当数据库对象非常多的时候,适当调整该参数的大小以确保所有数据都能存放在内存中提高访问效率,当过小的时候,MySQL会记录Warning信息到数据库的错误日志中,这时就需要该调整这个参数大小; 9)innodb_log_buffer_size:InnoDB存储引擎的事务日志所使用的缓冲区,一般来说不建议超过32MB; 10)query_cache_size:缓存MySQL中的ResultSet,也就是一条SQL语句执行的结果集,所以仅仅只能针对select语句。当某个表的数据有任何任何变化,都会导致所有引用了该表的select语句在Query Cache中的缓存数据失效。所以,当我们的数据变化非常频繁的情况下,使用Query Cache可能会得不偿失。根据命中率(Qcache_hits/(Qcache_hits+Qcache_inserts)*100))进行调整,一般不建议太大,256MB可能已经差不多了,大型的配置型静态数据可适当调大; 11)可以通过命令show status like 'Qcache_%'查看目前系统Query catch使用大小; 12)read_buffer_size:MySql读入缓冲区大小。对表进行顺序扫描的请求将分配一个读入缓冲区,MySql会为它分配一段内存缓冲区。如果对表的顺序扫描请求非常频繁,可以通过增加该变量值以及内存缓冲区大小提高其性能; 13)sort_buffer_size:MySql执行排序使用的缓冲大小。如果想要增加ORDER BY的速度,首先看是否可以让MySQL使用索引而不是额外的排序阶段。如果不能,可以尝试增加sort_buffer_size变量的大小; 14)read_rnd_buffer_size:MySql的随机读缓冲区大小。当按任意顺序读取行时(例如,按照排序顺序),将分配一个随机读缓存区。进行排序查询时,MySql会首先扫描一遍该缓冲,以避免磁盘搜索,提高查询速度,如果需要排序大量数据,可适当调高该值。但MySql会为每个客户连接发放该缓冲空间,所以应尽量适当设置该值,以避免内存开销过大; 15)record_buffer:每个进行一个顺序扫描的线程为其扫描的每张表分配这个大小的一个缓冲区。如果你做很多顺序扫描,可能想要增加该值; 16)thread_cache_size:保存当前没有与连接关联但是准备为后面新的连接服务的线程,可以快速响应连接的线程请求而无需创建新的; 17)table_cache:类似于thread_cache_size,但用来缓存表文件,对InnoDB效果不大,主要用于MyISAM。 3.6 升级硬件 Scale up,这个不多说了,根据MySQL是CPU密集型还是I/O密集型,通过提升CPU和内存、使用SSD,都能显著提升MySQL性能。 4、读写分离 也是目前常用的优化,从库读主库写,一般不要采用双主或多主引入很多复杂性,尽量采用文中的其他方案来提高性能。同时目前很多拆分的解决方案同时也兼顾考虑了读写分离 5、缓存 缓存可以发生在这些层次: 1)MySQL内部:在系统调优参数介绍了相关设置; 2)数据访问层:比如MyBatis针对SQL语句做缓存,而Hibernate可以精确到单个记录,这里缓存的对象主要是持久化对象Persistence Object; 3)应用服务层:这里可以通过编程手段对缓存做到更精准的控制和更多的实现策略,这里缓存的对象是数据传输对象Data Transfer Object; 4)Web层:针对web页面做缓存; 5)浏览器客户端:用户端的缓存。 可以根据实际情况在一个层次或多个层次结合加入缓存。 这里重点介绍下服务层的缓存实现,目前主要有两种方式: 1)直写式(Write Through):在数据写入数据库后,同时更新缓存,维持数据库与缓存的一致性。这也是当前大多数应用缓存框架如Spring Cache的工作方式。这种实现非常简单,同步好,但效率一般; 2)回写式(Write Back):当有数据要写入数据库时,只会更新缓存,然后异步批量的将缓存数据同步到数据库上。这种实现比较复杂,需要较多的应用逻辑,同时可能会产生数据库与缓存的不同步,但效率非常高。 6、表分区 MySQL在5.1版引入的分区是一种简单的水平拆分,用户需要在建表的时候加上分区参数,对应用是透明的无需修改代码。 对用户来说,分区表是一个独立的逻辑表,但是底层由多个物理子表组成,实现分区的代码实际上是通过对一组底层表的对象封装,但对SQL层来说是一个完全封装底层的黑盒子。MySQL实现分区的方式也意味着索引也是按照分区的子表定义,没有全局索引 用户的SQL语句是需要针对分区表做优化,SQL条件中要带上分区条件的列,从而使查询定位到少量的分区上,否则就会扫描全部分区,可以通过EXPLAIN PARTITIONS来查看某条SQL语句会落在那些分区上,从而进行SQL优化,如下图5条记录落在两个分区上: 分区的好处是: 1)可以让单表存储更多的数据; 2)分区表的数据更容易维护,可以通过清楚整个分区批量删除大量数据,也可以增加新的分区来支持新插入的数据。另外,还可以对一个独立分区进行优化、检查、修复等操作; 3)部分查询能够从查询条件确定只落在少数分区上,速度会很快; 4)分区表的数据还可以分布在不同的物理设备上,从而搞笑利用多个硬件设备; 5)可以使用分区表赖避免某些特殊瓶颈,例如InnoDB单个索引的互斥访问、ext3文件系统的inode锁竞争; 6)可以备份和恢复单个分区。 分区的限制和缺点: 1)一个表最多只能有1024个分区; 2)如果分区字段中有主键或者唯一索引的列,那么所有主键列和唯一索引列都必须包含进来; 3)分区表无法使用外键约束; 4)NULL值会使分区过滤无效; 5)所有分区必须使用相同的存储引擎。 分区的类型: 1)RANGE分区:基于属于一个给定连续区间的列值,把多行分配给分区; 2)LIST分区:类似于按RANGE分区,区别在于LIST分区是基于列值匹配一个离散值集合中的某个值来进行选择; 3)HASH分区:基于用户定义的表达式的返回值来进行选择的分区,该表达式使用将要插入到表中的这些行的列值进行计算。这个函数可以包含MySQL中有效的、产生非负整数值的任何表达式; 4)KEY分区:类似于按HASH分区,区别在于KEY分区只支持计算一列或多列,且MySQL服务器提供其自身的哈希函数。必须有一列或多列包含整数值。 分区最适合的场景数据的时间序列性比较强,则可以按时间来分区,如下所示: 查询时加上时间范围条件效率会非常高,同时对于不需要的历史数据能很容的批量删除。 如果数据有明显的热点,而且除了这部分数据,其他数据很少被访问到,那么可以将热点数据单独放在一个分区,让这个分区的数据能够有机会都缓存在内存中,查询时只访问一个很小的分区表,能够有效使用索引和缓存。 另外MySQL有一种早期的简单的分区实现 - 合并表(merge table),限制较多且缺乏优化,不建议使用,应该用新的分区机制来替代。 7、垂直拆分 垂直分库是根据数据库里面的数据表的相关性进行拆分,比如:一个数据库里面既存在用户数据,又存在订单数据,那么垂直拆分可以把用户数据放到用户库、把订单数据放到订单库。垂直分表是对数据表进行垂直拆分的一种方式,常见的是把一个多字段的大表按常用字段和非常用字段进行拆分,每个表里面的数据记录数一般情况下是相同的,只是字段不一样,使用主键关联。 比如原始的用户表是: 垂直拆分后是: 垂直拆分的优点是: 1)可以使得行数据变小,一个数据块(Block)就能存放更多的数据,在查询时就会减少I/O次数(每次查询时读取的Block 就少); 2)可以达到最大化利用Cache的目的,具体在垂直拆分的时候可以将不常变的字段放一起,将经常改变的放一起; 3)数据维护简单。 缺点是: 1)主键出现冗余,需要管理冗余列; 2)会引起表连接JOIN操作(增加CPU开销)可以通过在业务服务器上进行join来减少数据库压力; 3)依然存在单表数据量过大的问题(需要水平拆分); 4)事务处理复杂。 8、水平拆分 8.1 概述 水平拆分是通过某种策略将数据分片来存储,分库内分表和分库两部分,每片数据会分散到不同的MySQL表或库,达到分布式的效果,能够支持非常大的数据量。前面的表分区本质上也是一种特殊的库内分表。 库内分表,仅仅是单纯的解决了单一表数据过大的问题,由于没有把表的数据分布到不同的机器上,因此对于减轻MySQL服务器的压力来说,并没有太大的作用,大家还是竞争同一个物理机上的IO、CPU、网络,这个就要通过分库来解决。 前面垂直拆分的用户表如果进行水平拆分,结果是: 实际情况中往往会是垂直拆分和水平拆分的结合,即将Users_A_M和Users_N_Z再拆成Users和UserExtras,这样一共四张表。 水平拆分的优点是: 1)不存在单库大数据和高并发的性能瓶颈; 2)应用端改造较少; 3)提高了系统的稳定性和负载能力。 缺点是: 1)分片事务一致性难以解决; 2)跨节点Join性能差,逻辑复杂; 3)数据多次扩展难度跟维护量极大。 8.2 分片原则 1)能不分就不分,参考“单表优化”; 2)分片数量尽量少,分片尽量均匀分布在多个数据结点上,因为一个查询SQL跨分片越多,则总体性能越差,虽然要好于所有数据在一个分片的结果,只在必要的时候进行扩容,增加分片数量; 3)分片规则需要慎重选择做好提前规划,分片规则的选择,需要考虑数据的增长模式,数据的访问模式,分片关联性问题,以及分片扩容问题,最近的分片策略为范围分片,枚举分片,一致性Hash分片,这几种分片都有利于扩容; 4)尽量不要在一个事务中的SQL跨越多个分片,分布式事务一直是个不好处理的问题; 5)查询条件尽量优化,尽量避免Select * 的方式,大量数据结果集下,会消耗大量带宽和CPU资源,查询尽量避免返回大量结果集,并且尽量为频繁使用的查询语句建立索引; 6)通过数据冗余和表分区赖降低跨库Join的可能。 这里特别强调一下分片规则的选择问题,如果某个表的数据有明显的时间特征,比如订单、交易记录等,则他们通常比较合适用时间范围分片,因为具有时效性的数据,我们往往关注其近期的数据,查询条件中往往带有时间字段进行过滤,比较好的方案是,当前活跃的数据,采用跨度比较短的时间段进行分片,而历史性的数据,则采用比较长的跨度存储。 总体上来说,分片的选择是取决于最频繁的查询SQL的条件,因为不带任何Where语句的查询SQL,会遍历所有的分片,性能相对最差,因此这种SQL越多,对系统的影响越大,所以我们要尽量避免这种SQL的产生。 8.3 解决方案 由于水平拆分牵涉的逻辑比较复杂,当前也有了不少比较成熟的解决方案。这些方案分为两大类:客户端架构和代理架构。 【客户端架构】: 通过修改数据访问层,如JDBC、Data Source、MyBatis,通过配置来管理多个数据源,直连数据库,并在模块内完成数据的分片整合,一般以Jar包的方式呈现。 这是一个客户端架构的例子: 可以看到分片的实现是和应用服务器在一起的,通过修改Spring JDBC层来实现 客户端架构的优点是: 1)应用直连数据库,降低外围系统依赖所带来的宕机风险; 2)集成成本低,无需额外运维的组件。 缺点是: 1)限于只能在数据库访问层上做文章,扩展性一般,对于比较复杂的系统可能会力不从心; 2)将分片逻辑的压力放在应用服务器上,造成额外风险。 【代理架构】: 通过独立的中间件来统一管理所有数据源和数据分片整合,后端数据库集群对前端应用程序透明,需要独立部署和运维代理组件。 这是一个代理架构的例子: 代理组件为了分流和防止单点,一般以集群形式存在,同时可能需要Zookeeper之类的服务组件来管理。 代理架构的优点是: 能够处理非常复杂的需求,不受数据库访问层原来实现的限制,扩展性强; 对于应用服务器透明且没有增加任何额外负载。 缺点是: 需部署和运维独立的代理中间件,成本高; 应用需经过代理来连接数据库,网络上多了一跳,性能有损失且有额外风险。 8.4 各方案比较 如此多的方案,如何进行选择?可以按以下思路来考虑: 1)确定是使用代理架构还是客户端架构。中小型规模或是比较简单的场景倾向于选择客户端架构,复杂场景或大规模系统倾向选择代理架构; 2)具体功能是否满足,比如需要跨节点ORDER BY,那么支持该功能的优先考虑; 3)不考虑一年内没有更新的产品,说明开发停滞,甚至无人维护和技术支持; 4)最好按大公司->社区->小公司->个人这样的出品方顺序来选择; 5)选择口碑较好的,比如github星数、使用者数量质量和使用者反馈; 6)开源的优先,往往项目有特殊需求可能需要改动源代码。 按照上述思路,推荐以下选择: 1)客户端架构:ShardingJDBC; 2)代理架构:MyCat或者Atlas。 9、兼容MySQL且可水平扩展的数据库 目前也有一些开源数据库兼容MySQL协议,如: 1)TiDB; 2)Cubrid。 但其工业品质和MySQL尚有差距,且需要较大的运维投入。 如果想将原始的MySQL迁移到可水平扩展的新数据库中,可以考虑一些云数据库: 1)阿里云PetaData; 2)阿里云OceanBase; 3)腾讯云DCDB。 10、NoSQL 在MySQL上做Sharding是一种戴着镣铐的跳舞,事实上很多大表本身对MySQL这种RDBMS的需求并不大,并不要求ACID,可以考虑将这些表迁移到NoSQL,彻底解决水平扩展问题。 例如: 1)日志类、监控类、统计类数据; 2)非结构化或弱结构化数据; 3)对事务要求不强,且无太多关联操作的数据。 附录:更多架构设计方面的文章汇总 [1] 有关IM架构设计的文章: 《浅谈IM系统的架构设计》 《简述移动端IM开发的那些坑:架构设计、通信协议和客户端》 《一套海量在线用户的移动端IM架构设计实践分享(含详细图文)》 《一套原创分布式即时通讯(IM)系统理论架构方案》 《从零到卓越:京东客服即时通讯系统的技术架构演进历程》 《蘑菇街即时通讯/IM服务器开发之架构选择》 《腾讯QQ1.4亿在线用户的技术挑战和架构演进之路PPT》 《微信后台基于时间序的海量数据冷热分级架构设计实践》 《微信技术总监谈架构:微信之道——大道至简(演讲全文)》 《如何解读《微信技术总监谈架构:微信之道——大道至简》》 《快速裂变:见证微信强大后台架构从0到1的演进历程(一)》 《17年的实践:腾讯海量产品的技术方法论》 《移动端IM中大规模群消息的推送如何保证效率、实时性?》 《现代IM系统中聊天消息的同步和存储方案探讨》 《IM开发基础知识补课(二):如何设计大量图片文件的服务端存储架构?》 《IM开发基础知识补课(三):快速理解服务端数据库读写分离原理及实践建议》 《IM开发基础知识补课(四):正确理解HTTP短连接中的Cookie、Session和Token》 《WhatsApp技术实践分享:32人工程团队创造的技术神话》 《微信朋友圈千亿访问量背后的技术挑战和实践总结》 《王者荣耀2亿用户量的背后:产品定位、技术架构、网络方案等》 《IM系统的MQ消息中间件选型:Kafka还是RabbitMQ?》 《腾讯资深架构师干货总结:一文读懂大型分布式系统设计的方方面面》 《以微博类应用场景为例,总结海量社交系统的架构设计步骤》 《快速理解高性能HTTP服务端的负载均衡技术原理》 《子弹短信光鲜的背后:网易云信首席架构师分享亿级IM平台的技术实践》 《知乎技术分享:从单机到2000万QPS并发的Redis高性能缓存实践之路》 《IM开发基础知识补课(五):通俗易懂,正确理解并用好MQ消息队列》 《微信技术分享:微信的海量IM聊天消息序列号生成实践(算法原理篇)》 《微信技术分享:微信的海量IM聊天消息序列号生成实践(容灾方案篇)》 《新手入门:零基础理解大型分布式架构的演进历史、技术原理、最佳实践》 《一套高可用、易伸缩、高并发的IM群聊架构方案设计实践》 《阿里技术分享:深度揭秘阿里数据库技术方案的10年变迁史》 《阿里技术分享:阿里自研金融级数据库OceanBase的艰辛成长之路》 >> 更多同类文章 …… [2] 更多其它架构设计相关文章: 《腾讯资深架构师干货总结:一文读懂大型分布式系统设计的方方面面》 《快速理解高性能HTTP服务端的负载均衡技术原理》 《子弹短信光鲜的背后:网易云信首席架构师分享亿级IM平台的技术实践》 《知乎技术分享:从单机到2000万QPS并发的Redis高性能缓存实践之路》 《新手入门:零基础理解大型分布式架构的演进历史、技术原理、最佳实践》 《阿里技术分享:深度揭秘阿里数据库技术方案的10年变迁史》 《阿里技术分享:阿里自研金融级数据库OceanBase的艰辛成长之路》 《达达O2O后台架构演进实践:从0到4000高并发请求背后的努力》 《优秀后端架构师必会知识:史上最全MySQL大表优化方案总结》 >> 更多同类文章 …… (本文同步发布于:http://www.52im.net/thread-2157-1-1.html)
1、引言 达达创立于2014年5月,业务覆盖全国37个城市,拥有130万注册众包配送员,日均配送百万单,是全国领先的最后三公里物流配送平台。 达达的业务模式与滴滴以及Uber很相似,以众包的方式利用社会闲散人力资源,解决O2O最后三公里即时性配送难题(2016年4月,达达已经与京东到家合并)。 达达的业务组成简单直接——商家下单、配送员接单和配送,也正因为理解起来简单,使得达达的业务量在短时间能实现爆发式增长。而支撑业务快速增长的背后,正是达达技术团队持续不断的快速技术迭代的结果,本文正好借此机会,总结并分享了这一系列技术演进的第一手实践资料,希望能给同样奋斗在互联网创业一线的你带来启发。 (本文同步发布于:http://www.52im.net/thread-2141-1-1.html) 2、相关文章 《新手入门:零基础理解大型分布式架构的演进历史、技术原理、最佳实践》 《腾讯资深架构师干货总结:一文读懂大型分布式系统设计的方方面面》 《快速理解高性能HTTP服务端的负载均衡技术原理》 《知乎技术分享:从单机到2000万QPS并发的Redis高性能缓存实践之路》 《阿里技术分享:深度揭秘阿里数据库技术方案的10年变迁史》 《阿里技术分享:阿里自研金融级数据库OceanBase的艰辛成长之路》 3、技术背景 达达业务主要包含两部分: 1)商家发单; 2)配送员接单配送。 达达的业务逻辑看起来非常简单直接,如下图所示: 达达的业务规模增长极大,在1年左右的时间从零增长到每天近百万单,给后端带来极大的访问压力。压力主要分为两类:读压力、写压力。读压力来源于配送员在APP中抢单,高频刷新查询周围的订单,每天访问量几亿次,高峰期QPS高达数千次/秒。写压力来源于商家发单、达达接单、取货、完成等操作。达达业务读的压力远大于写压力,读请求量约是写请求量的30倍以上。 下图是达达在成长初期,每天的访问量变化趋图,可见增长极快: 下图是达达在成长初期,高峰期请求QPS的变化趋势图,可见增长极快: 极速增长的业务,对技术的要求越来越高,我们必须在架构上做好充分的准备,才能迎接业务的挑战。接下来,我们一起看看达达的后台架构是如何演化的。 小知识:什么是QPS、TPS? QPS:Queries Per Second意思是“每秒查询率”,是一台服务器每秒能够相应的查询次数,是对一个特定的查询服务器在规定时间内所处理流量多少的衡量标准。 TPS:是TransactionsPerSecond的缩写,也就是事务数/秒。它是软件测试结果的测量单位。一个事务是指一个客户机向服务器发送请求然后服务器做出反应的过程。客户机在发送请时开始计时,收到服务器响应后结束计时,以此来计算使用的时间和完成的事务个数。 4、最初的技术架构:简单直接 作为创业公司,最重要的一点是敏捷,快速实现产品,对外提供服务,于是我们选择了公有云服务,保证快速实施和可扩展性,节省了自建机房等时间。在技术选型上,为快速的响应业务需求,业务系统使用Python做为开发语言,数据库使用MySQL。 如下图所示,应用层的几大系统都访问一个数据库: 5、中期架构优化:读写分离 5.1 数据库瓶颈越来越严重 随着业务的发展,访问量的极速增长,上述的方案很快不能满足性能需求:每次请求的响应时间越来越长,比如配送员在app中刷新周围订单,响应时间从最初的500毫秒增加到了2秒以上。业务高峰期,系统甚至出现过宕机,一些商家和配送员甚至因此而怀疑我们的服务质量。在这生死存亡的关键时刻,通过监控,我们发现高期峰MySQL CPU使用率已接近80%,磁盘IO使用率接近90%,Slow Query从每天1百条上升到1万条,而且一天比一天严重。数据库俨然已成为瓶颈,我们必须得快速做架构升级。 如下是数据库一周的qps变化图,可见数据库压力的增长极快: 5.2 我们的读写分离方案 当Web应用服务出现性能瓶颈的时候,由于服务本身无状态(stateless),我们可以通过加机器的水平扩展方式来解决。 而数据库显然无法通过简单的添加机器来实现扩展,因此我们采取了MySQL主从同步和应用服务端读写分离的方案。 MySQL支持主从同步,实时将主库的数据增量复制到从库,而且一个主库可以连接多个从库同步。 利用MySQL的此特性,我们在应用服务端对每次请求做读写判断: 1)若是写请求,则把这次请求内的所有DB操作发向主库; 2)若是读请求,则把这次请求内的所有DB操作发向从库。 如下图所示: 实现读写分离后,数据库的压力减少了许多,CPU使用率和IO使用率都降到了5%内,Slow Query也趋近于0。 主从同步、读写分离给我们主要带来如下两个好处: 1)减轻了主库(写)压力:达达的业务主要来源于读操作,做读写分离后,读压力转移到了从库,主库的压力减小了数十倍; 2)从库(读)可水平扩展(加从库机器):因系统压力主要是读请求,而从库又可水平扩展,当从库压力太时,可直接添加从库机器,缓解读请求压力。 如下是优化后数据库QPS的变化图: ▲ 读写分离前主库的select QPS ▲ 读写分离后主库的select QPS 5.3 新状况出现:主从延迟问题 当然,没有一个方案是万能的。 读写分离,暂时解决了MySQL压力问题,同时也带来了新的挑战: 1)业务高峰期,商家发完订单,在我的订单列表中却看不到当发的订单(典型的read after write); 2)系统内部偶尔也会出现一些查询不到数据的异常。 通过监控,我们发现,业务高峰期MySQL可能会出现主从延迟,极端情况,主从延迟高达10秒。 那如何监控主从同步状态?在从库机器上,执行show slave status,查看Seconds_Behind_Master值,代表主从同步从库落后主库的时间,单位为秒,若同从同步无延迟,这个值为0。MySQL主从延迟一个重要的原因之一是主从复制是单线程串行执行。 那如何为避免或解决主从延迟?我们做了如下一些优化: 1)优化MySQL参数,比如增大innodb_buffer_pool_size,让更多操作在MySQL内存中完成,减少磁盘操作; 2)使用高性能CPU主机; 3)数据库使用物理主机,避免使用虚拟云主机,提升IO性能; 4)使用SSD磁盘,提升IO性能。SSD的随机IO性能约是SATA硬盘的10倍; 5)业务代码优化,将实时性要求高的某些操作,使用主库做读操作。 5.4 主库的写操作变的越来越慢 读写分离很好的解决读压力问题,每次读压力增加,可以通过加从库的方式水平扩展。但是写操作的压力随着业务爆发式的增长没有很有效的缓解办法,比如商家发单起来越慢,严重影响了商家的使用体验。我们监控发现,数据库写操作越来越慢,一次普通的insert操作,甚至可能会执行1秒以上。 下图是数据库主库的压力: ▲ 可见磁盘IO使用率已经非常高,高峰期IO响应时间最大达到636毫秒,IO使用率最高达到100% 同时,业务越来越复杂,多个应用系统使用同一个数据库,其中一个很小的非核心功能出现Slow query,常常影响主库上的其它核心业务功能。 我们有一个应用系统在MySQL中记录日志,日志量非常大,近1亿行记录,而这张表的ID是UUID,某一天高峰期,整个系统突然变慢,进而引发了宕机。监控发现,这张表insert极慢,拖慢了整个MySQL Master,进而拖跨了整个系统。(当然在MySQL中记日志不是一种好的设计,因此我们开发了大数据日志系统。另一方面,UUID做主键是个糟糕的选择,在下文的水平分库中,针对ID的生成,有更深入的讲述)。 5.5 进一步对主库进行拆分,优化主库写操作慢的问题 这时,主库成为了性能瓶颈,我们意识到,必需得再一次做架构升级,将主库做拆分: 1)一方面以提升性能; 2)另一方面减少系统间的相互影响,以提升系统稳定性。 这一次,我们将系统按业务进行了垂直拆分。 如下图所示,将最初庞大的数据库按业务拆分成不同的业务数据库,每个系统仅访问对应业务的数据库,避免或减少跨库访问: 下图是垂直拆分后,数据库主库的压力,可见磁盘IO使用率已降低了许多,高峰期IO响应时间在2.33毫秒内,IO使用率最高只到22.8%: 未来是美好的,道路是曲折的。 垂直分库过程,也遇到不少挑战,最大的挑战是:不能跨库join,同时需要对现有代码重构。单库时,可以简单的使用join关联表查询;拆库后,拆分后的数据库在不同的实例上,就不能跨库使用join了。 比如在CRM系统中,需要通过商家名查询某个商家的所有订单,在垂直分库前,可以join商家和订单表做查询,如下如示: 分库后,则要重构代码,先通过商家名查询商家id,再通过商家Id查询订单表,如下所示: 垂直分库过程中的经验教训,使我们制定了SQL最佳实践,其中一条便是程序中禁用或少用join,而应该在程序中组装数据,让SQL更简单。一方面为以后进一步垂直拆分业务做准备,另一方面也避免了MySQL中join的性能较低的问题。 经过一个星期紧锣密鼓的底层架构调整,以及业务代码重构,终于完成了数据库的垂直拆分。拆分之后,每个应用程序只访问对应的数据库,一方面将单点数据库拆分成了多个,分摊了主库写压力;另一方面,拆分后的数据库各自独立,实现了业务隔离,不再互相影响。 6、为未来做准备,进一步升级架构:水平分库(sharding) 通过上一节的分享,我们知道: 1)读写分离,通过从库水平扩展,解决了读压力; 2)垂直分库通过按业务拆分主库,缓存了写压力。 但技术团队是否就此高枕无忧?答案是:NO。 上述架构依然存在以下隐患: 1)单表数据量越来越大:如订单表,单表记录数很快将过亿,超出MySQL的极限,影响读写性能; 2)核心业务库的写压力越来越大:已不能再进一次垂直拆分,MySQL 主库不具备水平扩展的能力。 以前,系统压力逼迫我们架构升级,这一次,我们需提前做好架构升级,实现数据库的水平扩展(sharding)。我们的业务类似于Uber,而Uber在公司成立的5年后(2014)年才实施了水平分库,但我们的业务发展要求我们在成立18月就要开始实施水平分库。 本次架构升级的逻辑架构图如下图所示: 水平分库面临的第一个问题是,按什么逻辑进行拆分: 1)一种方案是按城市拆分,一个城市的所有数据在一个数据库中; 2)另一种方案是按订单ID平均拆分数据。 按城市拆分的优点是数据聚合度比较高,做聚合查询比较简单,实现也相对简单,缺点是数据分布不均匀,某些城市的数据量极大,产生热点,而这些热点以后可能还要被迫再次拆分。 按订单ID拆分则正相反,优点是数据分布均匀,不会出现一个数据库数据极大或极小的情况,缺点是数据太分散,不利于做聚合查询。比如,按订单ID拆分后,一个商家的订单可能分布在不同的数据库中,查询一个商家的所有订单,可能需要查询多个数据库。针对这种情况,一种解决方案是将需要聚合查询的数据做冗余表,冗余的表不做拆分,同时在业务开发过程中,减少聚合查询。 反复权衡利弊,并参考了Uber等公司的分库方案后,我们最后决定按订单ID做水平分库。 从架构上,我们将系统分为三层: 1)应用层:即各类业务应用系统; 2)数据访问层:统一的数据访问接口,对上层应用层屏蔽读写分库、分库、缓存等技术细节; 3)数据层:对DB数据进行分片,并可动态的添加shard分片。 水平分库的技术关键点在于数据访问层的设计。 数据访问层主要包含三部分: 1)ID生成器:生成每张表的主键; 2)数据源路由:将每次DB操作路由到不同的shard数据源上; 3)缓存: 采用Redis实现数据的缓存,提升性能。 ID生成器是整个水平分库的核心,它决定了如何拆分数据,以及查询存储-检索数据: 1)ID需要跨库全局唯一,否则会引发业务层的冲突; 2)此外,ID必须是数字且升序,这主要是考虑到升序的ID能保证MySQL的性能; 3)同时,ID生成器必须非常稳定,因为任何故障都会影响所有的数据库操作。 我们的ID的生成策略借鉴了Instagram的ID生成算法。 我们具体的ID生成算法方案如下: 如上图所示,方案说明如下: 1)整个ID的二进制长度为64位; 2)前36位使用时间戳,以保证ID是升序增加; 3)中间13位是分库标识,用来标识当前这个ID对应的记录在哪个数据库中; 4)后15位为MySQL自增序列,以保证在同一秒内并发时,ID不会重复。每个shard库都有一个自增序列表,生成自增序列时,从自增序列表中获取当前自增序列值,并加1,做为当前ID的后15位。 7、写在最后 创业是与时间赛跑的过程,前期为了快速满足业务需求,我们采用简单高效的方案,如使用云服务、应用服务直接访问单点DB。 后期随着系统压力增大,性能和稳定性逐渐纳入考虑范围,而DB最容易出现性能瓶颈,我们采用读写分离、垂直分库、水平分库等方案。 面对高性能和高稳定性,架构升级需要尽可能超前完成,否则,系统随时可能出现系统响应变慢甚至宕机的情况。 附录:架构设计相关文章汇总 [1] 有关IM架构设计的文章: 《浅谈IM系统的架构设计》 《简述移动端IM开发的那些坑:架构设计、通信协议和客户端》 《一套海量在线用户的移动端IM架构设计实践分享(含详细图文)》 《一套原创分布式即时通讯(IM)系统理论架构方案》 《从零到卓越:京东客服即时通讯系统的技术架构演进历程》 《蘑菇街即时通讯/IM服务器开发之架构选择》 《腾讯QQ1.4亿在线用户的技术挑战和架构演进之路PPT》 《微信后台基于时间序的海量数据冷热分级架构设计实践》 《微信技术总监谈架构:微信之道——大道至简(演讲全文)》 《如何解读《微信技术总监谈架构:微信之道——大道至简》》 《快速裂变:见证微信强大后台架构从0到1的演进历程(一)》 《17年的实践:腾讯海量产品的技术方法论》 《移动端IM中大规模群消息的推送如何保证效率、实时性?》 《现代IM系统中聊天消息的同步和存储方案探讨》 《IM开发基础知识补课(二):如何设计大量图片文件的服务端存储架构?》 《IM开发基础知识补课(三):快速理解服务端数据库读写分离原理及实践建议》 《IM开发基础知识补课(四):正确理解HTTP短连接中的Cookie、Session和Token》 《WhatsApp技术实践分享:32人工程团队创造的技术神话》 《微信朋友圈千亿访问量背后的技术挑战和实践总结》 《王者荣耀2亿用户量的背后:产品定位、技术架构、网络方案等》 《IM系统的MQ消息中间件选型:Kafka还是RabbitMQ?》 《腾讯资深架构师干货总结:一文读懂大型分布式系统设计的方方面面》 《以微博类应用场景为例,总结海量社交系统的架构设计步骤》 《快速理解高性能HTTP服务端的负载均衡技术原理》 《子弹短信光鲜的背后:网易云信首席架构师分享亿级IM平台的技术实践》 《知乎技术分享:从单机到2000万QPS并发的Redis高性能缓存实践之路》 《IM开发基础知识补课(五):通俗易懂,正确理解并用好MQ消息队列》 《微信技术分享:微信的海量IM聊天消息序列号生成实践(算法原理篇)》 《微信技术分享:微信的海量IM聊天消息序列号生成实践(容灾方案篇)》 《新手入门:零基础理解大型分布式架构的演进历史、技术原理、最佳实践》 《一套高可用、易伸缩、高并发的IM群聊架构方案设计实践》 《阿里技术分享:深度揭秘阿里数据库技术方案的10年变迁史》 《阿里技术分享:阿里自研金融级数据库OceanBase的艰辛成长之路》 >> 更多同类文章 …… [2] 更多其它架构设计相关文章: 《腾讯资深架构师干货总结:一文读懂大型分布式系统设计的方方面面》 《快速理解高性能HTTP服务端的负载均衡技术原理》 《子弹短信光鲜的背后:网易云信首席架构师分享亿级IM平台的技术实践》 《知乎技术分享:从单机到2000万QPS并发的Redis高性能缓存实践之路》 《新手入门:零基础理解大型分布式架构的演进历史、技术原理、最佳实践》 《阿里技术分享:深度揭秘阿里数据库技术方案的10年变迁史》 《阿里技术分享:阿里自研金融级数据库OceanBase的艰辛成长之路》 《达达O2O后台架构演进实践:从0到4000高并发请求背后的努力》 >> 更多同类文章 …… (本文同步发布于:http://www.52im.net/thread-2141-1-1.html)
1、引言 随着瓜子二手车相关业务的发展,公司有多个业务线都接入了IM系统,IM系统中的Socket长连接的安全问题变得越来越重要。本次分享正是基于此次解决Socket长连接身份安全认证的实践总结而来,方案可能并不完美,但愿能起到抛砖引玉的作用,希望能给您的IM系统开发带来启发。 学习交流: - 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》 (本文同步发布于:http://www.52im.net/thread-2106-1-1.html) 2、原作者 封宇:瓜子二手车技术专家,中国计算机学会专业会员。主要负责瓜子即时消息解决方案及相关系统研发工作。曾供职于58同城、华北计算技术研究所,参与到家消息系统、58爬虫系统以及多个国家级军工科研项目的架构及研发工作。 封宇同时还分享了其它IM方面的技术实践和总结,您可能也会感兴趣: 《从零开始搭建瓜子二手车IM系统(PPT) [附件下载]》 《一套海量在线用户的移动端IM架构设计实践分享(含详细图文)》 《一个低成本确保IM消息时序的方法探讨》 《移动端IM中大规模群消息的推送如何保证效率、实时性?》 3、系列文章 本文是IM通讯安全知识系列文章中的第7篇,总目录如下: 《即时通讯安全篇(一):正确地理解和使用Android端加密算法》 《即时通讯安全篇(二):探讨组合加密算法在IM中的应用》 《即时通讯安全篇(三):常用加解密算法与通讯安全讲解》 《即时通讯安全篇(四):实例分析Android中密钥硬编码的风险》 《即时通讯安全篇(五):对称加密技术在Android上的应用实践》 《即时通讯安全篇(六):非对称加密技术的原理与应用实践》 《即时通讯安全篇(七):用JWT技术解决IM系统Socket长连接的身份认证痛点》(本文) 4、我们面临的技术痛点 针对我们IM系统中的Socket长连接的身份认证安全问题,瓜子有统一登录认证系统SSO(即单点登陆系统,原理详见《IM开发基础知识补课(一):正确理解前置HTTP SSO单点登陆接口的原理》)。 我们的IM长连接通道也利用这个系统做安全认证,结构如下图: 如上图所示,整个认证步骤如下: 1)用户登录App,App从业务后台拿到单点登陆系统SSO颁发的token; 2)当App需要使用IM功能时,将token传给IM客服端SDK; 3)客服端SDK跟IM Server建立长连接的时候用token进行认证; 4)IM Server请求SSO单点登陆系统,确认token合法性。 * 补充:如您对SSO单点登陆系统的了解知之甚少,请务必先阅读《IM开发基础知识补课(一):正确理解前置HTTP SSO单点登陆接口的原理》。 咋一看,这个过程没有什么问题,但是IM(尤其是移动端IM)业务的特殊性,这个流程结构并不好。 为什么说上面的流程结构对于移动端的IM来说并不好呢?原因如下: 1)网络不稳定:手机(移动端)的网络很不稳定,进出地铁可能断网,挪动位置也可能换基站; 2)长连接频繁建立和释放:正因为1)中的原因,在一个聊天会话过程中,会经常重新建立长连接,从而导致上图里的第3步会被频繁执行,进而第4步也会频繁执行; 3)系统压力会增大:鉴于2)中的表现,将大大增加了SSO单点登陆系统的压力(因为IM实例需要频繁的调用SSO系统,从而完全客户端长连接的身份合法性检查); 4)用户体验也不好:长连接建立过程中,因SSO单点登陆系统并不属于IM服务端实例范围之内,IM服务端实例与SSO系统的通信等,带来的额外通信链路延迟对于用户的体验也是一种伤害(而且SSO系统也可能短暂开小差)。 如果不通过上图中的第4步就能完成IM长连接的身份合法性验证,那这个痛点会得到极大缓解。于是,我们便想到了JWT技术。 * 题外话:如果您对移动端弱网络的物理特性还不了解,那么下面的文章有助于您建立起这方面的认知: 《现代移动端网络短连接的优化手段总结:请求速度、弱网适应、安全保障》 《移动端IM开发者必读(一):通俗易懂,理解移动网络的“弱”和“慢”》 《移动端IM开发者必读(二):史上最全移动弱网络优化方法总结》 5、完全搞懂什么是JWT技术 5.1 基础知识 JSON Web Token(简称JWT),是一个开放安全的行业标准(详见RFC7519),可以用于多个系统之间传递安全可靠的信息(也包括本文中将要用到的传递身份认证信息的场景)。 一个完整的JWT的token字符串是什么样子的结构? ▲ JWT说到底也是一个token字符串,它由三部分组成:头部、载荷与签名 正如上图中所示,一个JWT的token字符串组成如下: 1)红色的为Header:指定token类型与签名类型; 2)紫色的为载荷(playload):存储用户id等关键信息; 3)蓝色的为签名:保证整个信息的完整性、可靠性(这个签名字符串,相当于是一段被加密了的密文文本,安全性就是由它来决定的)。 5.2解密JWT的头部(Header) JWT的头部用于描述关于该JWT的最基本的信息,例如其类型以及签名所用的算法等。 这可以被表示成一个JSON对象: { "typ": "JWT", "alg": "HS256" } ▲ 在这个头信息里,标明了这是一个JWT字符串,并且所用的签名算法是HS256算法 对它进行Base64编码,之后的字符串就成了JWT的Header(头部),也就是你在5.1节中看到的红色部分: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9 (你可以自已试着进行Base64的加密和解决,比如用这个在线工具:http://tool.oschina.net/encrypt?type=3) 5.3 解密JWT的载荷(playload) 在载荷(playload)中可以定义以下属性: 1)iss: 该JWT的签发者; 2)sub: 该JWT所面向的用户; 3)aud: 接收该JWT的一方; 4)exp(expires): 什么时候过期,这里是一个Unix时间戳; 5)iat(issued at): 在什么时候签发的。 上面的信息也可以用一个JSON对象来描述,将上面的JSON对象进行base64编码,可以得到下面的字符串。 这个字符串我们将它称作JWT的Payload(载荷),以下字串样例就是你在5.1节中看到的紫色部分: eyJpc3MiOiIyOWZmMDE5OGJlOGM0YzNlYTZlZTA4YjE1MGRhNTU0NC1XRUIiLCJleHAiOjE1MjI0OTE5MTV9 (你可以自已试着进行Base64的加密和解决,比如用这个在线工具:http://tool.oschina.net/encrypt?type=3) 5.4 解决JWT的签名(Signature) JWT的签名部分,在官方文档中是如下描述的: HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret) 上述伪码的意义,即如下操作: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiIyOWZmMDE5OGJlOGM0YzNlYTZlZTA4YjE1MGRhNTU0NC1XRUIiLCJleHAiOjE1MjI0OTE5MTV9 ▲ 将上面的两个base64编码后的字符串都用句号‘.’连接在一起(头部在前),就形成了如下字符串 最后,我们将上面拼接完的字符串用HS256算法进行加密。在加密的时候,我们还需要提供一个密钥(secret)。 那么,按照RFC7519上描述的方法,就可以得到我们加密后的内容: P-k-vIzxElzyzFbzR4tUxAAET8xT9EP49b7hpcPazd0 ▲ 这个就是我们需要的JWT的签名部分了 5.5 签名的目的 生成JWT的token字符串的最后一步签名过程,实际上是对头部以及载荷内容进行加密。 一般而言:加密算法对于不同的输入产生的输出总是不一样的。所以,如果有人对头部以及载荷的内容解码之后进行修改,再进行编码的话,那么新的头部和载荷的签名和之前的签名就将是不一样的。而且,如果不知道服务器加密的时候用的密钥的话,得出来的签名也一定会是不一样的。 换句话说:你的JWT字符串的安全强度,基本上就是由这个签名部分来决定的。 使用时:服务器端在接受到JWT的token字符串后,会首先用开发者指明的secret(可以理解为密码)对头部和载荷的内容用同一算法再次签名。那么服务器应用是怎么知道我们用的是哪一种算法呢?别忘了,我们在JWT的头部中已经用alg字段指明了我们的加密算法了。 如果服务器端对头部和载荷再次以同样方法签名之后发现,自己计算出来的签名和接受到的签名不一样,那么就说明这个Token的内容被别人动过的,我们应该拒绝这个JWT Token,返回一个HTTP 401 Unauthorized响应。 5.6 一个典型的JWT应用流程 JWT是一个怎样的流程? 先上个官方文档的图: 如上图所示,整个应用流程描述如下: 1)客户端使用账户密码请求登录接口; 2)登录成功后服务器使用签名密钥生成JWT ,然后返回JWT给客户端; 3)客户端再次向服务端请求其他接口时带上JWT; 4)服务端接收到JWT后验证签名的有效性.对客户端做出相应的响应。 5.7 总而言之 JWT的整个技术原理,就是一个很典型的对称加密应用过程,通俗的说也就是用开发者在服务端保存的密码,对用户的id等信息进行加密并按照JWT的规则(见5.1节)组成字符串返回给用户。用户在使用时将这个字符串提交给对应的服务端,服务端取出JWT字串的头信息、载荷,用开发者指明的密码试着进行加密并得到一个字符串(即合法的JWT token),两相比较,相同则认为用户提交上来的JWT合法,否则不合法。这就是JWT的全部原理,相当简单易懂。 JWT技术的价值不在于具体的技术实现,而在于它的思想本身,尤其在异构系统、分布式系统方面,可以极大的简化安全认证的成本,包括简化架构复杂性、降低使用门槛等,因为JWT的技术原理决定了认证的过程不需要其它系统的参与,由当前实例自已就可以完成,而成认证代码极小(就是一个加密字符串的比较而已)。 它的技术思路在当前的各种开发系统中应用广泛,比如下图中微信公众号的服务接口配置里,也用到了类似的思想: 另外,苹果著名的APNs推送服务,也支持JWT技术,详见《基于APNs最新HTTP/2接口实现iOS的高性能消息推送(服务端篇)》第6.2节: ▲ 上述截图内容摘录自苹果官方开发者文档 6、我们是怎样使用JWT技术的? 上一章节,我们详细理解了JWT技术的原理,那么回到本文的初衷:我们该如何使用JWT技术来解决上面所提到的通点呢? 我们采用JWT验证IM的Socket长连接流程如下: 如上图所示,整个验证过程描述如下: 1)用户登录App(使用IM客服端SDK),App从业务后台拿到SSO单点登陆系统颁发的token(注意:此token还不是JWT的token,它将在第3)步中被使用并生成真正的JWT token); 2)当App需要使用IM功能时,将token传给IM客服端SDK(这是在客户端完成的,即当App的功能调用IM客服端SDK时传入); 3)IM客服端SDK将用户名及第2步中得到的token发给后台的JWT Server(签发JWT token的模块),请求JWT token; 4)收到第3)步中提交过来的token后,JWT Server会通过RPC等技术向SSO系统提交验证此token的合法性,如果合法,将用跟IM Server约定的Secret(你可以理解为这就是一个固定的密码而已),根据业务需要签发JWT token,并最终返回给IM客服端SDK(即完成第3步中的请求)。 5)后绪,IM客服端SDK将使用得到的JWT token请求IM Server验证长连接,IM Server根据约定的算法(不依赖其他系统直接用JWT的规则,加上第4)步中与JWT Server 约定的Secret)即可完成jwttoken合法性验证。 通过上述努力,移动端在弱网情况下的频繁建立长连接的身份验证痛点得到了解决。 7、JWT技术的缺点 当然,我们之所以选择JWT技术,主要看重的还是它简单易用,但或许正因为如此,某种程度上来说这也恰是居致它的缺点的原因所在。 JWT技术的缺点及建议的解决方法主要有: 1)JWT的最大缺点是服务器不保存会话状态,所以在使用期间不可能取消token或更改token的权限。也就是说,一旦JWT签发,在有效期内将会一直有效; 2)JWT本身包含认证信息(即你在第5.1节中看到的头信息、负载信息),因此一旦信息泄露,任何人都可以获得token的所有权限。为了减少盗用,JWT的有效期不宜设置太长。对于某些重要操作,用户在使用时应该每次都进行进行身份验证; 3)为了减少盗用和窃取,JWT不建议使用HTTP协议来传输代码,而是使用加密的HTTPS(SSL)协议进行传输。 以下这篇文章列了一些适用JWT的应用场景,仅供参考: https://www.jianshu.com/p/af8360b83a9f 8、点评 JWT其实是一项比较有争议的技术,夸它的人会说它简单易用、成本低,极度贬低它的人会说它的安全性就像一层窗户纸——捅一下就破了。 不可否认,跟当前流行的非对称加密技术(大家最熟悉的HTTPS协议就是一个典型的非对称加密应用场景)相比,JWT技术的安全系数确实相对要低一些,因为JWT技术的本质就是对称加密技术的应用,而非对称加密技术出现的原因也就是为了提升对称加密技术所不具有的一些安全性。 但非对称加密技术这么好,也并不意味着对称加密技术就一无是处,因为并不是所有场景都需要用性能、架构的复杂性、运维成本来换取高安全性,还是那句话:“安全这东西,够用就行”,而这也正是JWT这种技术仍然有其价值的原因所在。 非对称加密技术虽然安全,但也并非理论上的无懈可击,这世上还没有绝对安全的算法,总之,不苛责级极致安全的情况下,够用便好,你说呢? 如果您对对称加密和非对称加密技术的还不是太了解,可以阅读以下文章: 《即时通讯安全篇(三):常用加解密算法与通讯安全讲解》 《即时通讯安全篇(六):非对称加密技术的原理与应用实践》 附录:更多即时通讯方面的文章 如果您是IM开发初学者,强烈建议首先阅读: 《新手入门一篇就够:从零开发移动端IM》 即时通讯安全方面的文章汇总如下: 《即时通讯安全篇(一):正确地理解和使用Android端加密算法》 《即时通讯安全篇(二):探讨组合加密算法在IM中的应用》 《即时通讯安全篇(三):常用加解密算法与通讯安全讲解》 《即时通讯安全篇(四):实例分析Android中密钥硬编码的风险》 《即时通讯安全篇(五):对称加密技术在Android平台上的应用实践》 《即时通讯安全篇(六):非对称加密技术的原理与应用实践》 《即时通讯安全篇(七):用JWT技术解决IM系统Socket长连接的身份认证痛点》 《传输层安全协议SSL/TLS的Java平台实现简介和Demo演示》 《理论联系实际:一套典型的IM通信协议设计详解(含安全层设计)》 《微信新一代通信安全解决方案:基于TLS1.3的MMTLS详解》 《来自阿里OpenIM:打造安全可靠即时通讯服务的技术实践分享》 《简述实时音视频聊天中端到端加密(E2EE)的工作原理》 《移动端安全通信的利器——端到端加密(E2EE)技术详解》 《Web端即时通讯安全:跨站点WebSocket劫持漏洞详解(含示例代码)》 《通俗易懂:一篇掌握即时通讯的消息传输安全原理》 《IM开发基础知识补课(四):正确理解HTTP短连接中的Cookie、Session和Token》 《快速读懂量子通信、量子加密技术》 《即时通讯安全篇(七):如果这样来理解HTTPS原理,一篇就够了》 《一分钟理解 HTTPS 到底解决了什么问题》 >> 更多同类文章 …… (本文同步发布于:http://www.52im.net/thread-2106-1-1.html)
1、前言 标题虽然是为了解释有了 IP 地址,为什么还要用 MAC 地址,但是本文的重点在于理解为什么要有 IP 这样的东西。本文对读者的定位是知道 MAC 地址是什么,IP 地址是什么。 (本文同步发布于:http://www.52im.net/thread-2067-1-1.html) 2、关于作者 翟志军,个人博客地址:https://showme.codes/,Github:https://github.com/zacker330。感谢作者的原创分享。 作者的另一篇《即时通讯安全篇(七):如果这样来理解HTTPS,一篇就够了》也写的非常好,有兴趣的读者可以深读之。 3、系列文章 本文是系列文章中的第9篇,本系列文章的大纲如下: 《网络编程懒人入门(一):快速理解网络通信协议(上篇)》 《网络编程懒人入门(二):快速理解网络通信协议(下篇)》 《网络编程懒人入门(三):快速理解TCP协议一篇就够》 《网络编程懒人入门(四):快速理解TCP和UDP的差异》 《网络编程懒人入门(五):快速理解为什么说UDP有时比TCP更有优势》 《网络编程懒人入门(六):史上最通俗的集线器、交换机、路由器功能原理入门》 《网络编程懒人入门(七):深入浅出,全面理解HTTP协议》 《网络编程懒人入门(八):手把手教你写基于TCP的Socket长连接》 《网络编程懒人入门(九):通俗讲解,有了IP地址,为何还要用MAC地址?》(本文) 本站的《脑残式网络编程入门》也适合入门学习,本系列大纲如下: 《脑残式网络编程入门(一):跟着动画来学TCP三次握手和四次挥手》 《脑残式网络编程入门(二):我们在读写Socket时,究竟在读写什么?》 《脑残式网络编程入门(三):HTTP协议必知必会的一些知识》 《脑残式网络编程入门(四):快速理解HTTP/2的服务器推送(Server Push)》 如果您觉得本系列文章过于基础,您可直接阅读《不为人知的网络编程》系列文章,该系列目录如下: 《不为人知的网络编程(一):浅析TCP协议中的疑难杂症(上篇)》 《不为人知的网络编程(二):浅析TCP协议中的疑难杂症(下篇)》 《不为人知的网络编程(三):关闭TCP连接时为什么会TIME_WAIT、CLOSE_WAIT》 《不为人知的网络编程(四):深入研究分析TCP的异常关闭》 《不为人知的网络编程(五):UDP的连接性和负载均衡》 《不为人知的网络编程(六):深入地理解UDP协议并用好它》 关于移动端网络特性及优化手段的总结性文章请见: 《现代移动端网络短连接的优化手段总结:请求速度、弱网适应、安全保障》 《移动端IM开发者必读(一):通俗易懂,理解移动网络的“弱”和“慢”》 《移动端IM开发者必读(二):史上最全移动弱网络优化方法总结》 4、书上说的 基本概念: 如今的网络是分层来实现的,就像是搭积木一样,先设计某个特定功能的模块,然后把模块拼起来组成整个网络。局域网也不例外,一般来说,在组网上我们使用的是IEEE802参考模型,从下至上分为:物理层、媒体接入控制层(MAC),逻辑链路控制层(LLC)。 标识网络中的一台计算机,一般至少有三种方法,最常用的是域名地址、IP地址和MAC地址,分别对应应用层、网络层、物理层。网络管理一般就是在网络层针对IP地址进行管理,但由于一台计算机的IP地址可以由用户自行设定,管理起来相对困难,MAC地址一般不可更改,所以把IP地址同MAC地址组合到一起管理就成为常见的管理方式。 什么是MAC地址? MAC地址就是在媒体接入层上使用的地址,也叫物理地址、硬件地址或链路地址,由网络设备制造商生产时写在硬件内部。MAC地址与网络无关,也即无论将带有这个地址的硬件(如网卡、集线器、路由器等)接入到网络的何处,都有相同的MAC地址,它由厂商写在网卡的BIOS里。MAC地址可采用6字节(48比特)或2字节(16比特)这两种中的任意一种。但随着局域网规模越来越大,一般都采用6字节的MAC地址。这个48比特都有其规定的意义,前24位是由生产网卡的厂商向IEEE申请的厂商地址,目前的价格是1000美元买一个地址块,后24位由厂商自行分配,这样的分配使得世界上任意一个拥有48位MAC 地址的网卡都有唯一的标识。另外,2字节的MAC地址不用网卡厂商申请。 MAC地址通常表示为12个16进制数,每2个16进制数之间用冒号隔开,如:08:00:20:0A:8C:6D就是一个MAC地址,其中前6位16进制数08:00:20代表网络硬件制造商的编号,它由IEEE分配,而后6位16进制数0A:8C:6D代表该制造商所制造的某个网络产品(如网卡)的系列号。每个网络制造商必须确保它所制造的每个以太网设备都具有相同的前三字节以及不同的后三个字节。这样就可保证世界上每个以太网设备都具有唯一的MAC 地址。 什么是IP地址? IP地址是指互联网协议地址(英语:Internet Protocol Address,又译为网际协议地址),是IP Address的缩写。IP地址是IP协议提供的一种统一的地址格式,它为互联网上的每一个网络和每一台主机分配一个逻辑地址,以此来屏蔽物理地址的差异。 为什么要用到MAC地址? 这是由组网方式决定的,如今比较流行的接入Internet的方式(也是未来发展的方向)是把主机通过局域网组织在一起,然后再通过交换机和 Internet相连接。这样一来就出现了如何区分具体用户,防止盗用的问题。由于IP只是逻辑上标识,任何人都随意修改,因此不能用来标识用户;而 MAC地址则不然,它是固化在网卡里面的。从理论上讲,除非盗来硬件(网卡),否则是没有办法冒名顶替的(注意:其实也可以盗用,后面将介绍)。 基于MAC地址的这种特点,局域网采用了用MAC地址来标识具体用户的方法。注意:具体实现:在交换机内部通过“表”的方式把MAC地址和IP地址一一对应,也就是所说的IP、MAC绑定。 具体的通信方式:接收过程,当有发给本地局域网内一台主机的数据包时,交换机接收下来,然后把数据包中的IP地址按照“表”中的对应关系映射成MAC地址,转发到对应的MAC地址的主机上,这样一来,即使某台主机盗用了这个IP地址,但由于他没有这个MAC地址,因此也不会收到数据包。发送过程和接收过程类似,限于篇幅不叙述。 综上可知,只有IP而没有对应的MAC地址在这种局域网内是不能上网的,于是解决了IP盗用问题。 IP地址与MAC地址的区别是什么? IP地址基于逻辑,比较灵活,不受硬件限制,也容易记忆。MAC地址在一定程度上与硬件一致,基于物理,能够标识具体。这两种地址各有好处,使用时也因条件而采取不同的地址。 MAC地址涉及到的安全问题: 从上面的介绍可以知道,这种标识方式只是MAC地址基于的,如果有人能够更改MAC地址,就可以盗用IP免费上网了,目前网上针对小区宽带的盗用MAC地址免费上网方式就是基于此这种思路。如果想盗用别人的IP地址,除了IP地址还要知道对应的MAC地址。举个例子,获得局域网内某台主机的MAC地址,比如想得到局域网内名为TARGET主机的MAC地址,先用PING命令:PING TARGET,这样在我们主机上面的ARP表的缓存中就会留下目标地址和MAC映射的记录,然后通过ARP A命令来查询ARP表,这样就得到了指定主机的MAC地址。最后用ARP -s IP 网卡MAC地址,命令把网关的IP地址和它的MAC地址映射起来就可以了。 如果要得到其它网段内的MAC地址,那么可以用工具软件来实现,我觉得Windows优化大 师中自带的工具不错,点击“系统性能优化”→“系统安全优化”→“附加工具”→“集群Ping”,可以成批的扫出MAC地址并可以保存到文件。 小知识:ARP(Address Resolution Protocol)是地址解析协议,ARP是一种将IP地址转化成物理地址的协议。从IP地址到物理地址的映射有两种方式:表格方式和非表格方式。ARP 具体说来就是将网络层(IP层,也就是相当于OSI的第三层)地址解析为数据连接层(MAC层,也就是相当于OSI的第二层)的MAC地址。ARP协议是通过IP地址来获得MAC地址的。 ARP原理:郴鰽要向主机B发送报文,会查询本地的ARP缓存表,找到B的IP地址对应的MAC地址后就会进行数据传输。如果未找到,则广播A一个 ARP请求报文(携带主机A的IP地址Ia——物理地址Pa),请求IP地址为Ib的主机B回答物理地址Pb。网上所有主机包括B都收到ARP请求,但只有主机B识别自己的IP地址,于是向A主机发回一个ARP响应报文。其中就包含有B的MAC地址,A接收到B的应答后,就会更新本地的ARP缓存。接着使用这个MAC地址发送数据(由网卡附加MAC地址)。因此,本地高速缓存的这个ARP表是本地网络流通的基础,而且这个缓存是动态的。ARP表:为了回忆通信的速度,最近常用的MAC地址与IP的转换不用依靠交换机来进行,而是在本机上建立一个用来记录常用主机IP-MAC映射表,即ARP表。 5、最通俗的解释 看完上一节中各种书籍里对IP地址、MAC地址的理解介绍和说明,还是很蒙逼,那么请继续看完本节吧。 5.1 网络洪荒时代 一开始时,网络中的机器并不多。大家都连到同一个集线器就可以了,就可以实现互通。这时,机器 A 发消息到机器 B ,消息头里附上机器 B 的MAC,集线器收到消息后就广播给所有连到集线器的机器。 机器 C 收到消息,发现消息里的 MAC 地址和自己的不一样,就丢弃。机器B发现消息里的 MAC 地址和自己一样,就收到下并解析。 这样机制带来问题很明显:首先每次广播,给所在网络带来不必要的浪费。所以,就出现了交换机。它能识别消息里的目标 MAC 地址后,直接就消息丢到机器 B 所连接的端口中。另一个角度,交换机必须记住所有的 MAC 地址和端口之间的关系。 这样的机制在网络规规模小的时候是高效的。但是当网络规模扩大到全球的时候,不可能让一台交换机记录下全球这么多的网络设备,也不可能让全球的机器连接到一台交换机上。 5.2 如果是多台交换机呢? 想像一下,你是斯坦福的学生,你的电脑 x 的网络直连的是学校的交换机,而学校的交换机又连美国国家网络交换机。而美国国家网络交换机又直接的是中国国家网络交换机,中国服务器 y 直连的是中国国家交换机。 你想访问中国的服务器 y 中的资源。你了解到服务器 y 的 MAC 地址是00:0C:29:01:00:12,所以你在消息里附上这个 MAC 地址。 学校交换机收到消息后,拿到 MAC 地址后就愣了,这是要发给谁啊?因为中国服务器 y 并不是直连学校交换机的。这时,学校交换机有一个选择,就是收到不明的 MAC 地址时,一律转发给默认端口。斯坦福交换机就将消息转给美国国家交换机。 美国国家交换机同样发愣了,因为没有这条 MAC 地址对应的端口。它又直接向默认端口:中国国家网络交换机。 中国国家网络交换机收到消息,发现自己记录了 MAC 地址 对应的是服务器 y。就直接将你这位斯坦福学生的消息转发到服务器 y 所连接的端口。 最终,我们的服务器 y 终于收到来自美国斯坦福学生的资源访问请求。 那么,我们的服务器 y 如何将相应的资源返回给学生呢?将消息中的源MAC 地址作为响应消息的目标 MAC 地址发送给中国国家交换机不就可以了?同样的机制,只不过是把源地址和目标地址反一下。 这下,我们是不是完美实现使用交换机组建美国网络和中国网络的互通? 但是美国和中国并不能代表全世界。其他国家也需要加入这个大网络。当日本国家交换机也接入美国国家交换机后,斯坦福学生的消息从学校到达美国国家交换机后就需要进行广播所有直连自己的端口了,因为这时,它没有对外的所谓默认端口了。这里有点烧脑,容各位同学一点时间思考。 5.3 小结 也就是说,当两个网络互接时,MAC 地址 + 交换机还能解决问题广播问题,但是两个以上的网络互连时,MAC 地址 + 交换机就没有办法解决广播问题了。 这时,我们面临的问题就是无法使用现有的技术—— MAC 地址 + 交换机——解决多网络互连的问题了。所以,需要发明一种新的技术。 而 IP 协议就是就是解决此问题的一项技术。 事实上,IP协议的产生并不只是为解决上述的“广播问题”。还解决了很多其他网络传输过程会遇到的问题,比如一次传输的消息过大时,如何对消息进行分组等问题。 好了,如果以上内容你还是没有完全理解,那么以下3篇文章你必须好好读读(再不懂的话,真没救了..): 《网络编程懒人入门(一):快速理解网络通信协议(上篇)》 《网络编程懒人入门(二):快速理解网络通信协议(下篇)》 《网络编程懒人入门(六):史上最通俗的集线器、交换机、路由器功能原理入门》 6、写在最后 由于历史原因,MAC 地址及相关技术先出现,但是后来发现它并不能解决所有(已知)的问题,所以,先驱们发明了 IP 地址及相关技术来解决。 另一个角度,个人认为,由于 MAC 地址没有办法表达网络中的子网的概念,而 IP 地址可以。如果网络互换设备(比如路由器)能从目标 MAC 地址中分析出目标网络,而不是只是目标主机,IP 地址还会出现吗? 有另一个有趣的问题:如果历史反过来,一开始就使用的是 IP 地址,而不是 MAC 地址,我们现在的网络世界会怎么样? 附录:更多网络编程方面的文章 [1] 网络编程基础资料: 《TCP/IP详解 - 第11章·UDP:用户数据报协议》 《TCP/IP详解 - 第17章·TCP:传输控制协议》 《TCP/IP详解 - 第18章·TCP连接的建立与终止》 《TCP/IP详解 - 第21章·TCP的超时与重传》 《技术往事:改变世界的TCP/IP协议(珍贵多图、手机慎点)》 《通俗易懂-深入理解TCP协议(上):理论基础》 《通俗易懂-深入理解TCP协议(下):RTT、滑动窗口、拥塞处理》 《理论经典:TCP协议的3次握手与4次挥手过程详解》 《理论联系实际:Wireshark抓包分析TCP 3次握手、4次挥手过程》 《计算机网络通讯协议关系图(中文珍藏版)》 《UDP中一个包的大小最大能多大?》 《P2P技术详解(一):NAT详解——详细原理、P2P简介》 《P2P技术详解(二):P2P中的NAT穿越(打洞)方案详解》 《P2P技术详解(三):P2P技术之STUN、TURN、ICE详解》 《通俗易懂:快速理解P2P技术中的NAT穿透原理》 《技术扫盲:新一代基于UDP的低延时网络传输层协议——QUIC详解》 《让互联网更快:新一代QUIC协议在腾讯的技术实践分享》 《现代移动端网络短连接的优化手段总结:请求速度、弱网适应、安全保障》 《聊聊iOS中网络编程长连接的那些事》 《移动端IM开发者必读(一):通俗易懂,理解移动网络的“弱”和“慢”》 《移动端IM开发者必读(二):史上最全移动弱网络优化方法总结》 《IPv6技术详解:基本概念、应用现状、技术实践(上篇)》 《IPv6技术详解:基本概念、应用现状、技术实践(下篇)》 《从HTTP/0.9到HTTP/2:一文读懂HTTP协议的历史演变和设计思路》 《以网游服务端的网络接入层设计为例,理解实时通信的技术挑战》 《迈向高阶:优秀Android程序员必知必会的网络基础》 >> 更多同类文章 …… [2] NIO异步网络编程资料: 《Java新一代网络编程模型AIO原理及Linux系统AIO介绍》 《有关“为何选择Netty”的11个疑问及解答》 《开源NIO框架八卦——到底是先有MINA还是先有Netty?》 《选Netty还是Mina:深入研究与对比(一)》 《选Netty还是Mina:深入研究与对比(二)》 《NIO框架入门(一):服务端基于Netty4的UDP双向通信Demo演示》 《NIO框架入门(二):服务端基于MINA2的UDP双向通信Demo演示》 《NIO框架入门(三):iOS与MINA2、Netty4的跨平台UDP双向通信实战》 《NIO框架入门(四):Android与MINA2、Netty4的跨平台UDP双向通信实战》 《Netty 4.x学习(一):ByteBuf详解》 《Netty 4.x学习(二):Channel和Pipeline详解》 《Netty 4.x学习(三):线程模型详解》 《Apache Mina框架高级篇(一):IoFilter详解》 《Apache Mina框架高级篇(二):IoHandler详解》 《MINA2 线程原理总结(含简单测试实例)》 《Apache MINA2.0 开发指南(中文版)[附件下载]》 《MINA、Netty的源代码(在线阅读版)已整理发布》 《解决MINA数据传输中TCP的粘包、缺包问题(有源码)》 《解决Mina中多个同类型Filter实例共存的问题》 《实践总结:Netty3.x升级Netty4.x遇到的那些坑(线程篇)》 《实践总结:Netty3.x VS Netty4.x的线程模型》 《详解Netty的安全性:原理介绍、代码演示(上篇)》 《详解Netty的安全性:原理介绍、代码演示(下篇)》 《详解Netty的优雅退出机制和原理》 《NIO框架详解:Netty的高性能之道》 《Twitter:如何使用Netty 4来减少JVM的GC开销(译文)》 《绝对干货:基于Netty实现海量接入的推送服务技术要点》 《Netty干货分享:京东京麦的生产级TCP网关技术实践总结》 《新手入门:目前为止最透彻的的Netty高性能原理和框架架构解析》 >> 更多同类文章 …… (本文同步发布于:http://www.52im.net/thread-2067-1-1.html)
本文由微信开发团队工程是由“oneliang”原创发表于WeMobileDev公众号,内容稍有改动。 1、引言 Kotlin 是一个用于现代多平台应用的静态编程语言,由 JetBrains 开发(也就是开发了号称Java界最智能的集成开发工具IntelliJ IDEA的公司)。Kotlin可以编译成Java字节码(就像Groovy和Scala一样),也可以编译成JavaScript,方便在没有JVM的设备上运行。Kotlin已于2017年的Google I/O开发者大会上正式被宣布为Android官方支持开发语言(见《[资讯] Kotlin成为Android官方开发语言!》)。 有人说Kolin对于Android的作用,是不是Swift对于iOS的作用一样(主要用于降低Objective-C开发门槛等)。实际上,Kotlin对于Android的意义和重要性要远大于Swift对于iOS,因为不管是Objective-C还是Swift,它们至少都是苹果自已的东西,而悲剧的是Java并不属于Google。鉴于Google和Oracle(Java的创造者SUN公司早就被Oracle收购了)的官司(见《[资讯] Java侵权案逆转:Google需赔88亿!》),如何解决掉Java这个如鲠在喉的历史遗留,是Android决策者早就在考虑的问题,只是恰好选中了Kotlin而已。 Google官方已在各种场合直接或间接地表明了对于Kotlin和Java的态度——那就是Kotlin是 “Over” Java的(即可以理解为Kotlin在ANdroid中的定位是高于Java的)。所以,不管Android开发者有没有做好准备,或者还在纠结要不要学习Kotlin时,都不影响Kotlin在Android中的定位和越来越明确的地位。但无论如何,对于Android开发者来说,多学一门技术确实很痛苦,但提前做好准备是更明智之选,至少到了Kotlin真的取代Java的那一天,而不至于后懂准备地太晚。 作为移动端即时通讯IM应用的王者——微信,为了始终保持技术的领先性,无论日后Kotlin在微信客户中的重要性几何,技术团队做好技术储备和预研实践是肯定有必要的,于是便有了本文的整理和分享,希望业界共同学习、互相交流。 (本文同步发布于:http://www.52im.net/thread-2066-1-1.html) 2、概述 微信订阅号助手的Android App项目首次尝试使用Kotlin进行大规模的业务开发(483个Kt文件,3.8W行不包含空行的Kt代码),一开始接触Kotlin的时候难免会有点不适应,但经过几天的强制使用后,慢慢有些感觉,项目落地后回顾了一下,发现Kotlin确实是有它独特的风味。 什么是微信订阅号助手? 微信公众平台“订阅号助手”APP已正式上架App Store,通过这款订阅号助手APP,公众号运营者可以快捷地编辑和发表内容、方便地处理留言和回复粉丝消息。 订阅号助手app能将你的iPhone变成一个随身的公众号“工作室”,无论身处何地,你都可以发表内容、与读者互动。订阅号助手app简洁的编辑工具让每个人轻松变身为作者,留住即刻的灵感,尽享内容创作的乐趣。订阅号助手app让每个有才华的个体都有机会被关注,都有自己的品牌。 3、“烹饪”准备 食材: 1)Android,主要食材(指Framework、Api等); 2)Kotlin,食用安全、味鲜(扩展函数)、香(重载)、甜(富含糖份Lambda),第二主要食材,切好块状; 3)Java,少量,Kotlin这种食材需要它来做引子。 锅: AndroidStudio、Eclipse这两个牌子的锅质量都不错。 调味料: Kotlin Android Extension、Android KTX、AndroidX、Anko等。 如果没有上述这些材料请移步到如下网址"购买": https://developers.google.com/android https://kotlinlang.org/docs/reference https://www.oracle.com/java 4、“烹饪”过程 1)开火,放少量食用油; 2)先把Android倒进去,伴两下; 3)倒少量Java,主要是"字节码"和"工具部分",再伴两下; 4)把切好块的Kotlin一块块慢慢平铺在Android上面,把Android盖住; 5)慢火煮3-5分钟,观察一下这个过程: Kotlin把Android的味道慢慢释放出来,比Android + Java更香; Kotlin与Java融为一体 (前提是少量Java,如果Java放得太多,香味会受影响,粘合不够好,容易松散(NPE)); 6)关火,焖一会。 5、开锅,上菜 色香味倶全,敬请尽情享受这番独特的风味。 5.1 特色风味一:食用安全 食用安全,Nullable or NotNul从源头抓起。 Kotlin代码安全性更强: varoutput: String output = null// Compilation error val name: String? = null// Nullable type println(name.length()) // Compilation error 食用安全从从源头上抓起,只要跟定义不符就编译不通过,这是Kotlin小而精的一个优点,一下子能把整碟"菜"的安全系数提高,此Code来自官方文档。 5.2 特色风味二:鲜 扩展函数,味道鲜美,百吃不厌。 项目工具类的另一种写法: fun String.toIntSafely(defaultValue: Int = 0): Int { returntry{ this.toInt() } catch(e: Exception) { defaultValue } } fun main(args: Array<String>) { println("1".toIntSafely()) } String 转 Int,这种需求几乎很多项目都是需要,像上述Kotlin如果是在Java里面描述的话,估计会写成这样: public final class StringUtil{ private StringUtil() {} public static int stringToInt(String string, int defaultValue) { //省略 } } 使用时: StringUtil.stringToInt("1", 0); 大家看到这里可能会觉得没什么,大家都是工具类,用的时候有些小差别而已。 但正因为这些小差别,优点就体现出来了,确实是鲜美: 1)不需要记住工具类的名字和方法的名字:假如你是一个刚接手项目的新人,正准备做一个需求开发,突然需要这种String to Int的工具,但是不知道工具在哪,这就好比你去到一个陌生人的家里,想找个螺丝刀拧个松掉的螺丝一样,这“螺丝刀”在哪?除了问“主人”之外,要么就是“翻柜子”,这不就显得效率低么?使用Kotlin的扩展函数就能有效避免前面所说的问题,接手项目的新人只需要轻轻的“.”一下,滚两下鼠标,"toIntSafely"的方法就会看到。这就为什么你看Kotlin的Java扩展库很多都是通过扩展函数来封装; 2)方法的类归属更好理解:以上述的"toIntSafely"为例,String.toIntSafely,使得开发者更容易直观感受到这个函数是用于String,不像StringUtil.stringToInt没有归属可言,纯粹就是一个工具函数,不如Kotlin的写法容易理解; 3)对定义函数者的要求高了:正因体现了函数的类归属,也就使得开发者在定义函数的时候需要考虑归属给哪个类还是顶层函数这些问题,归属的范围少了,会导致不好用,范围广了又怕暴露导致滥用或者误用。 5.3 特色风味三:香 重载(Overload),回味无穷。 虽然这个概念在面向对象领域用得很多,但Kotlin这个重载的味道真是令我们吃上瘾。 重载在工具类的场景用得非常多,一个项目下来没工具类也是不可能。 例如我们在项目中会封装一些对话框(Dialog)工具类供开发的同学一句调用: 1)开发的同学需要在界面显示一个Dialog,只想改变Dialog的内容,那么Java里面就有showDialog(String message)的写法; 2)开发的同学需要在界面显示一个Dialog,即想改变Dialog的标题,又想改变Dialog的内容,那么Java里面就有showDialog(String title, String message)的写法; 3)开发的同学想改变Dialog里面Icon的.... 4)开发的同学想...... 这些场景估计做Android开发的同学都会碰到,其实不限于Android,Java开发的同学也经常遇到。 我们看看Kotlin是怎样把这些需求收拢: fun showDialog(title: String = "标题", message: String = "内容") { //TODO } 这个写法一下子满足 2的2次方(4) 种重载方法: showDialog() showDialog("新标题") showDialog(message = "新内容") showDialog("新标题", "新内容") 这种重载方式有效地减少我们项目中的重载方法数量,使得我们项目开发更简洁和更有效率 ,自然就回味无穷。 5.4 特色风味四:甜而不腻 带了糖,甜而不腻。 Kotlin里面Function与Lambda既可相互理解,又有其味道(写法)上的一些差异。 味道 (结果) 一样,但味道消去的过程 (用法) 有差别。 Function(函数)常用写法: fun f(x: Int): Any { returnAny() } 用法: val y = f(1) Function(函数)的一种Lambda写法: fun f() = { x: Int -> Any() } 等价于 fun f(): (Int) -> Any = { x: Int -> Any() } 用法: val y = f()(1) 或 val y = f().invoke(1) Lambda写法: val f = { x: Int -> Any() } 等价于 val f: (Int) -> Any = { x: Int -> Any } 用法: val y = f(1) 或 val y = f.invoke(1) 细节点:Function时,有"="跟没有"="意义不一样,有"="的时候可以理解右边( { x: Int -> Any() } )是 左边函数返回类型((Int) -> Any) 的实现。 函数不用置疑,项目里面必备。 Lambda: Lambda,语法糖,这是怎样的一种成份? Lambda是长这样的: val block: () -> Unit = {} val sum: (Int, Int) -> Int = { p1, p2 -> p1 + p2 } Lambda令我们的项目减少了很多接口类,尤其是回调接口,我们项目几乎没有。一般的业务场景里面回调接口都会用得不少,Lambda能有效减少这种Callback接口的定义,少写不少接口类,事半功倍。 另lambda里面不能写return,最后一行的值就是返回值。 从数学函数角度抽象理解: 函数: y = f(x) 〉假设x与y都是Int类型 可以理解为 Kotlin 函数: fun f(x: Int): Int { return1 // 这里的返回值就是对应y } 也可以理解为 Lambda: val f = { x: Int -> 1 } 等价于 val f: (Int) -> Int = { x: Int -> 1 } 使用时f(1),但是如果像上述那种f(x)的kotlin函数与f(x)的lambda同时同名同方法签名存在,使用上要f(1)与f.invoke(1)来区分是函数调用还是lambda调用。 〉假设x与y都是Lambda类型 x是Lambda类型 (Int) -> Int ,y是Lambda类型 (Int) -> Int,可以换算成: fun f(x: (Int) -> Int): (Int) -> Int { return{ it -> x(it) } } 或这样: fun f(x: (Int) -> Int): (Int) -> Int = { it -> x(it) } 使用时: f { it -> it + 10 }(1) or f { it -> it + 10 }.invoke(1) 或 Lambda: val f: ((Int) -> Int) -> ((Int) -> Int) = { x -> { it -> x(it) } } // val时要inline 使用时: f.invoke { it -> it + 10 }.invoke(1) 通过上述的 替换 能更好地理解和使用Lambda。 6、如何更好地了解Kotlin这种食材的味道 Kotlin用于Java领域,中间产物毫无疑问还是字节码,因此本质还是Java的基础知识,反编译Kotlin生成的字节码是学习Kotlin一种较好的方式,可利用AndroidStudio的Tools来反编译kt,能帮助快速理解Kotlin。 谢谢品尝这份美味,希望Kotlin这款食材能带给各位读者不少Android上的特色的风味。 附录:QQ、微信团队原创技术文章 《微信朋友圈千亿访问量背后的技术挑战和实践总结》 《腾讯技术分享:腾讯是如何大幅降低带宽和网络流量的(图片压缩篇)》 《腾讯技术分享:腾讯是如何大幅降低带宽和网络流量的(音视频技术篇)》 《微信团队分享:微信移动端的全文检索多音字问题解决方案》 《腾讯技术分享:Android版手机QQ的缓存监控与优化实践》 《微信团队分享:iOS版微信的高性能通用key-value组件技术实践》 《微信团队分享:iOS版微信是如何防止特殊字符导致的炸群、APP崩溃的?》 《腾讯技术分享:Android手Q的线程死锁监控系统技术实践》 《微信团队原创分享:iOS版微信的内存监控系统技术实践》 《让互联网更快:新一代QUIC协议在腾讯的技术实践分享》 《iOS后台唤醒实战:微信收款到账语音提醒技术总结》 《腾讯技术分享:社交网络图片的带宽压缩技术演进之路》 《微信团队分享:视频图像的超分辨率技术原理和应用场景》 《微信团队分享:微信每日亿次实时音视频聊天背后的技术解密》 《QQ音乐团队分享:Android中的图片压缩技术详解(上篇)》 《QQ音乐团队分享:Android中的图片压缩技术详解(下篇)》 《腾讯团队分享:手机QQ中的人脸识别酷炫动画效果实现详解》 《腾讯团队分享 :一次手Q聊天界面中图片显示bug的追踪过程分享》 《微信团队分享:微信Android版小视频编码填过的那些坑》 《微信手机端的本地数据全文检索优化之路》 《企业微信客户端中组织架构数据的同步更新方案优化实战》 《微信团队披露:微信界面卡死超级bug“15。。。。”的来龙去脉》 《QQ 18年:解密8亿月活的QQ后台服务接口隔离技术》 《月活8.89亿的超级IM微信是如何进行Android端兼容测试的》 《以手机QQ为例探讨移动端IM中的“轻应用”》 《一篇文章get微信开源移动端数据库组件WCDB的一切!》 《微信客户端团队负责人技术访谈:如何着手客户端性能监控和优化》 《微信后台基于时间序的海量数据冷热分级架构设计实践》 《微信团队原创分享:Android版微信的臃肿之困与模块化实践之路》 《微信后台团队:微信后台异步消息队列的优化升级实践分享》 《微信团队原创分享:微信客户端SQLite数据库损坏修复实践》 《腾讯原创分享(一):如何大幅提升移动网络下手机QQ的图片传输速度和成功率》 《腾讯原创分享(二):如何大幅压缩移动网络下APP的流量消耗(下篇)》 《腾讯原创分享(三):如何大幅压缩移动网络下APP的流量消耗(上篇)》 《微信Mars:微信内部正在使用的网络层封装库,即将开源》 《如约而至:微信自用的移动端IM网络层跨平台组件库Mars已正式开源》 《开源libco库:单机千万连接、支撑微信8亿用户的后台框架基石 [源码下载]》 《微信新一代通信安全解决方案:基于TLS1.3的MMTLS详解》 《微信团队原创分享:Android版微信后台保活实战分享(进程保活篇)》 《微信团队原创分享:Android版微信后台保活实战分享(网络保活篇)》 《Android版微信从300KB到30MB的技术演进(PPT讲稿) [附件下载]》 《微信团队原创分享:Android版微信从300KB到30MB的技术演进》 《微信技术总监谈架构:微信之道——大道至简(演讲全文)》 《微信技术总监谈架构:微信之道——大道至简(PPT讲稿) [附件下载]》 《如何解读《微信技术总监谈架构:微信之道——大道至简》》 《微信海量用户背后的后台系统存储架构(视频+PPT) [附件下载]》 《微信异步化改造实践:8亿月活、单机千万连接背后的后台解决方案》 《微信朋友圈海量技术之道PPT [附件下载]》 《微信对网络影响的技术试验及分析(论文全文)》 《一份微信后台技术架构的总结性笔记》 《架构之道:3个程序员成就微信朋友圈日均10亿发布量[有视频]》 《快速裂变:见证微信强大后台架构从0到1的演进历程(一)》 《快速裂变:见证微信强大后台架构从0到1的演进历程(二)》 《微信团队原创分享:Android内存泄漏监控和优化技巧总结》 《全面总结iOS版微信升级iOS9遇到的各种“坑”》 《微信团队原创资源混淆工具:让你的APK立减1M》 《微信团队原创Android资源混淆工具:AndResGuard [有源码]》 《Android版微信安装包“减肥”实战记录》 《iOS版微信安装包“减肥”实战记录》 《移动端IM实践:iOS版微信界面卡顿监测方案》 《微信“红包照片”背后的技术难题》 《移动端IM实践:iOS版微信小视频功能技术方案实录》 《移动端IM实践:Android版微信如何大幅提升交互性能(一)》 《移动端IM实践:Android版微信如何大幅提升交互性能(二)》 《移动端IM实践:实现Android版微信的智能心跳机制》 《移动端IM实践:WhatsApp、Line、微信的心跳策略分析》 《移动端IM实践:谷歌消息推送服务(GCM)研究(来自微信)》 《移动端IM实践:iOS版微信的多设备字体适配方案探讨》 《信鸽团队原创:一起走过 iOS10 上消息推送(APNS)的坑》 《腾讯信鸽技术分享:百亿级实时消息推送的实战经验》 《IPv6技术详解:基本概念、应用现状、技术实践(上篇)》 《IPv6技术详解:基本概念、应用现状、技术实践(下篇)》 《腾讯TEG团队原创:基于MySQL的分布式数据库TDSQL十年锻造经验分享》 《微信多媒体团队访谈:音视频开发的学习、微信的音视频技术和挑战等》 《了解iOS消息推送一文就够:史上最全iOS Push技术详解》 《腾讯技术分享:微信小程序音视频技术背后的故事》 《腾讯资深架构师干货总结:一文读懂大型分布式系统设计的方方面面》 《微信多媒体团队梁俊斌访谈:聊一聊我所了解的音视频技术》 《腾讯音视频实验室:使用AI黑科技实现超低码率的高清实时视频聊天》 《腾讯技术分享:微信小程序音视频与WebRTC互通的技术思路和实践》 《手把手教你读取Android版微信和手Q的聊天记录(仅作技术研究学习)》 《微信技术分享:微信的海量IM聊天消息序列号生成实践(算法原理篇)》 《微信技术分享:微信的海量IM聊天消息序列号生成实践(容灾方案篇)》 《腾讯技术分享:GIF动图技术详解及手机QQ动态表情压缩技术实践》 《微信团队分享:Kotlin渐被认可,Android版微信的技术尝鲜之旅》 >> 更多同类文章 …… (本文同步发布于:http://www.52im.net/thread-2066-1-1.html)
1、引言 Netty 是一个广受欢迎的异步事件驱动的Java开源网络应用程序框架,用于快速开发可维护的高性能协议服务器和客户端。 本文基于 Netty 4.1 展开介绍相关理论模型,使用场景,基本组件、整体架构,知其然且知其所以然,希望给大家在实际开发实践、学习开源项目方面提供参考。 本文作者的另两篇《高性能网络编程(五):一文读懂高性能网络编程中的I/O模型》、《高性能网络编程(六):一文读懂高性能网络编程中的线程模型》也写的很好,有兴趣的读者可以一并看看。 关于作者: 陈彩华(caison),从事服务端开发,善于系统设计、优化重构、线上问题排查工作,主要开发语言是 Java,微信号:hua1881375。 (本文同步发布于:http://www.52im.net/thread-2043-1-1.html) 2、相关资料 Netty源码在线阅读: Netty-4.1.x地址是:http://docs.52im.net/extend/docs/src/netty4_1/ Netty-4.0.x地址是:http://docs.52im.net/extend/docs/src/netty4/ Netty-3.x地址是:http://docs.52im.net/extend/docs/src/netty3/ Netty在线API文档: Netty-4.1.x API文档(在线版):http://docs.52im.net/extend/docs/api/netty4_1/ Netty-4.0.x API文档(在线版):http://docs.52im.net/extend/docs/api/netty4/ Netty-3.x API文档(在线版):http://docs.52im.net/extend/docs/api/netty3/ 有关Netty的其它精华文章: 《有关“为何选择Netty”的11个疑问及解答》 《开源NIO框架八卦——到底是先有MINA还是先有Netty?》 《选Netty还是Mina:深入研究与对比(一)》 《选Netty还是Mina:深入研究与对比(二)》 《Netty 4.x学习(一):ByteBuf详解》 《Netty 4.x学习(二):Channel和Pipeline详解》 《Netty 4.x学习(三):线程模型详解》 《实践总结:Netty3.x升级Netty4.x遇到的那些坑(线程篇)》 《实践总结:Netty3.x VS Netty4.x的线程模型》 《详解Netty的安全性:原理介绍、代码演示(上篇)》 《详解Netty的安全性:原理介绍、代码演示(下篇)》 《详解Netty的优雅退出机制和原理》 《NIO框架详解:Netty的高性能之道》 《Twitter:如何使用Netty 4来减少JVM的GC开销(译文)》 《绝对干货:基于Netty实现海量接入的推送服务技术要点》 《Netty干货分享:京东京麦的生产级TCP网关技术实践总结》 3、JDK 原生 NIO 程序的问题 JDK 原生也有一套网络应用程序 API,但是存在一系列问题,主要如下: 1)NIO 的类库和 API 繁杂,使用麻烦:你需要熟练掌握 Selector、ServerSocketChannel、SocketChannel、ByteBuffer 等。 2)需要具备其他的额外技能做铺垫:例如熟悉 Java 多线程编程,因为 NIO 编程涉及到 Reactor 模式,你必须对多线程和网路编程非常熟悉,才能编写出高质量的 NIO 程序。 3)可靠性能力补齐,开发工作量和难度都非常大:例如客户端面临断连重连、网络闪断、半包读写、失败缓存、网络拥塞和异常码流的处理等等。NIO 编程的特点是功能开发相对容易,但是可靠性能力补齐工作量和难度都非常大。 4)JDK NIO 的 Bug:例如臭名昭著的 Epoll Bug,它会导致 Selector 空轮询,最终导致 CPU 100%。官方声称在 JDK 1.6 版本的 update 18 修复了该问题,但是直到 JDK 1.7 版本该问题仍旧存在,只不过该 Bug 发生概率降低了一些而已,它并没有被根本解决。 4、Netty 的特点 Netty 对 JDK 自带的 NIO 的 API 进行了封装,解决了上述问题。 Netty的主要特点有: 1)设计优雅:适用于各种传输类型的统一 API 阻塞和非阻塞 Socket;基于灵活且可扩展的事件模型,可以清晰地分离关注点;高度可定制的线程模型 - 单线程,一个或多个线程池;真正的无连接数据报套接字支持(自 3.1 起)。 2)使用方便:详细记录的 Javadoc,用户指南和示例;没有其他依赖项,JDK 5(Netty 3.x)或 6(Netty 4.x)就足够了。 3)高性能、吞吐量更高:延迟更低;减少资源消耗;最小化不必要的内存复制。 4)安全:完整的 SSL/TLS 和 StartTLS 支持。 5)社区活跃、不断更新:社区活跃,版本迭代周期短,发现的 Bug 可以被及时修复,同时,更多的新功能会被加入。 5、Netty 常见使用场景 Netty 常见的使用场景如下: 1)互联网行业:在分布式系统中,各个节点之间需要远程服务调用,高性能的 RPC 框架必不可少,Netty 作为异步高性能的通信框架,往往作为基础通信组件被这些 RPC 框架使用。典型的应用有:阿里分布式服务框架 Dubbo 的 RPC 框架使用 Dubbo 协议进行节点间通信,Dubbo 协议默认使用 Netty 作为基础通信组件,用于实现各进程节点之间的内部通信。 2)游戏行业:无论是手游服务端还是大型的网络游戏,Java 语言得到了越来越广泛的应用。Netty 作为高性能的基础通信组件,它本身提供了 TCP/UDP 和 HTTP 协议栈。 非常方便定制和开发私有协议栈,账号登录服务器,地图服务器之间可以方便的通过 Netty 进行高性能的通信。 3)大数据领域:经典的 Hadoop 的高性能通信和序列化组件 Avro 的 RPC 框架,默认采用 Netty 进行跨界点通信,它的 Netty Service 基于 Netty 框架二次封装实现。 有兴趣的读者可以了解一下目前有哪些开源项目使用了 Netty的Related Projects。 6、Netty 高性能设计 Netty 作为异步事件驱动的网络,高性能之处主要来自于其 I/O 模型和线程处理模型,前者决定如何收发数据,后者决定如何处理数据。 6.1 I/O 模型 用什么样的通道将数据发送给对方,BIO、NIO 或者 AIO,I/O 模型在很大程度上决定了框架的性能。 【阻塞 I/O】: 传统阻塞型 I/O(BIO)可以用下图表示: 特点如下: 每个请求都需要独立的线程完成数据 Read,业务处理,数据 Write 的完整操作问题。 当并发数较大时,需要创建大量线程来处理连接,系统资源占用较大。 连接建立后,如果当前线程暂时没有数据可读,则线程就阻塞在 Read 操作上,造成线程资源浪费。 【I/O 复用模型】: 在 I/O 复用模型中,会用到 Select,这个函数也会使进程阻塞,但是和阻塞 I/O 所不同的是这两个函数可以同时阻塞多个 I/O 操作。 而且可以同时对多个读操作,多个写操作的 I/O 函数进行检测,直到有数据可读或可写时,才真正调用 I/O 操作函数。 Netty 的非阻塞 I/O 的实现关键是基于 I/O 复用模型,这里用 Selector 对象表示: Netty 的 IO 线程 NioEventLoop 由于聚合了多路复用器 Selector,可以同时并发处理成百上千个客户端连接。 当线程从某客户端 Socket 通道进行读写数据时,若没有数据可用时,该线程可以进行其他任务。 线程通常将非阻塞 IO 的空闲时间用于在其他通道上执行 IO 操作,所以单独的线程可以管理多个输入和输出通道。 由于读写操作都是非阻塞的,这就可以充分提升 IO 线程的运行效率,避免由于频繁 I/O 阻塞导致的线程挂起。 一个 I/O 线程可以并发处理 N 个客户端连接和读写操作,这从根本上解决了传统同步阻塞 I/O 一连接一线程模型,架构的性能、弹性伸缩能力和可靠性都得到了极大的提升。 【基于 Buffer】: 传统的 I/O 是面向字节流或字符流的,以流式的方式顺序地从一个 Stream 中读取一个或多个字节, 因此也就不能随意改变读取指针的位置。 在 NIO 中,抛弃了传统的 I/O 流,而是引入了 Channel 和 Buffer 的概念。在 NIO 中,只能从 Channel 中读取数据到 Buffer 中或将数据从 Buffer 中写入到 Channel。 基于 Buffer 操作不像传统 IO 的顺序操作,NIO 中可以随意地读取任意位置的数据。 6.2 线程模型 数据报如何读取?读取之后的编解码在哪个线程进行,编解码后的消息如何派发,线程模型的不同,对性能的影响也非常大。 【事件驱动模型】: 通常,我们设计一个事件处理模型的程序有两种思路: 1)轮询方式:线程不断轮询访问相关事件发生源有没有发生事件,有发生事件就调用事件处理逻辑; 2)事件驱动方式:发生事件,主线程把事件放入事件队列,在另外线程不断循环消费事件列表中的事件,调用事件对应的处理逻辑处理事件。事件驱动方式也被称为消息通知方式,其实是设计模式中观察者模式的思路。 以 GUI 的逻辑处理为例,说明两种逻辑的不同: 1)轮询方式:线程不断轮询是否发生按钮点击事件,如果发生,调用处理逻辑。 2)事件驱动方式:发生点击事件把事件放入事件队列,在另外线程消费的事件列表中的事件,根据事件类型调用相关事件处理逻辑。 这里借用 O'Reilly 大神关于事件驱动模型解释图: 主要包括 4 个基本组件: 1)事件队列(event queue):接收事件的入口,存储待处理事件; 2)分发器(event mediator):将不同的事件分发到不同的业务逻辑单元; 3)事件通道(event channel):分发器与处理器之间的联系渠道; 4)事件处理器(event processor):实现业务逻辑,处理完成后会发出事件,触发下一步操作。 可以看出,相对传统轮询模式,事件驱动有如下优点: 1)可扩展性好:分布式的异步架构,事件处理器之间高度解耦,可以方便扩展事件处理逻辑; 2)高性能:基于队列暂存事件,能方便并行异步处理事件。 【Reactor 线程模型】: Reactor 是反应堆的意思,Reactor 模型是指通过一个或多个输入同时传递给服务处理器的服务请求的事件驱动处理模式。 服务端程序处理传入多路请求,并将它们同步分派给请求对应的处理线程,Reactor 模式也叫 Dispatcher 模式,即 I/O 多了复用统一监听事件,收到事件后分发(Dispatch 给某进程),是编写高性能网络服务器的必备技术之一。 Reactor 模型中有 2 个关键组成: 1)Reactor:Reactor 在一个单独的线程中运行,负责监听和分发事件,分发给适当的处理程序来对 IO 事件做出反应。它就像公司的电话接线员,它接听来自客户的电话并将线路转移到适当的联系人; 2)Handlers:处理程序执行 I/O 事件要完成的实际事件,类似于客户想要与之交谈的公司中的实际官员。Reactor 通过调度适当的处理程序来响应 I/O 事件,处理程序执行非阻塞操作。 取决于 Reactor 的数量和 Hanndler 线程数量的不同,Reactor 模型有 3 个变种: 1)单 Reactor 单线程; 2)单 Reactor 多线程; 3)主从 Reactor 多线程。 可以这样理解,Reactor 就是一个执行 while (true) { selector.select(); …} 循环的线程,会源源不断的产生新的事件,称作反应堆很贴切。 篇幅关系,这里不再具体展开 Reactor 特性、优缺点比较,有兴趣的读者可以参考我之前另外一篇文章:《高性能网络编程(五):一文读懂高性能网络编程中的I/O模型》、《高性能网络编程(六):一文读懂高性能网络编程中的线程模型》。 【Netty 线程模型】: Netty 主要基于主从 Reactors 多线程模型(如下图)做了一定的修改,其中主从 Reactor 多线程模型有多个 Reactor: 1)MainReactor 负责客户端的连接请求,并将请求转交给 SubReactor; 2)SubReactor 负责相应通道的 IO 读写请求; 3)非 IO 请求(具体逻辑处理)的任务则会直接写入队列,等待 worker threads 进行处理。 这里引用 Doug Lee 大神的 Reactor 介绍——Scalable IO in Java 里面关于主从 Reactor 多线程模型的图: 特别说明的是:虽然 Netty 的线程模型基于主从 Reactor 多线程,借用了 MainReactor 和 SubReactor 的结构。但是实际实现上 SubReactor 和 Worker 线程在同一个线程池中: EventLoopGroup bossGroup = newNioEventLoopGroup(); EventLoopGroup workerGroup = newNioEventLoopGroup(); ServerBootstrap server = newServerBootstrap(); server.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) 上面代码中的 bossGroup 和 workerGroup 是 Bootstrap 构造方法中传入的两个对象,这两个 group 均是线程池: 1)bossGroup 线程池则只是在 Bind 某个端口后,获得其中一个线程作为 MainReactor,专门处理端口的 Accept 事件,每个端口对应一个 Boss 线程; 2)workerGroup 线程池会被各个 SubReactor 和 Worker 线程充分利用。 【异步处理】: 异步的概念和同步相对。当一个异步过程调用发出后,调用者不能立刻得到结果。实际处理这个调用的部件在完成后,通过状态、通知和回调来通知调用者。 Netty 中的 I/O 操作是异步的,包括 Bind、Write、Connect 等操作会简单的返回一个 ChannelFuture。 调用者并不能立刻获得结果,而是通过 Future-Listener 机制,用户可以方便的主动获取或者通过通知机制获得 IO 操作结果。 当 Future 对象刚刚创建时,处于非完成状态,调用者可以通过返回的 ChannelFuture 来获取操作执行的状态,注册监听函数来执行完成后的操作。 常见有如下操作: 1)通过 isDone 方法来判断当前操作是否完成; 2)通过 isSuccess 方法来判断已完成的当前操作是否成功; 3)通过 getCause 方法来获取已完成的当前操作失败的原因; 4)通过 isCancelled 方法来判断已完成的当前操作是否被取消; 5)通过 addListener 方法来注册监听器,当操作已完成(isDone 方法返回完成),将会通知指定的监听器;如果 Future 对象已完成,则理解通知指定的监听器。 例如下面的代码中绑定端口是异步操作,当绑定操作处理完,将会调用相应的监听器处理逻辑: serverBootstrap.bind(port).addListener(future -> { if(future.isSuccess()) { System.out.println(newDate() + ": 端口["+ port + "]绑定成功!"); } else{ System.err.println("端口["+ port + "]绑定失败!"); } }); 相比传统阻塞 I/O,执行 I/O 操作后线程会被阻塞住, 直到操作完成;异步处理的好处是不会造成线程阻塞,线程在 I/O 操作期间可以执行别的程序,在高并发情形下会更稳定和更高的吞吐量。 7、Netty框架的架构设计 前面介绍完 Netty 相关一些理论,下面从功能特性、模块组件、运作过程来介绍 Netty 的架构设计。 7.1 功能特性 Netty 功能特性如下: 1)传输服务:支持 BIO 和 NIO; 2)容器集成:支持 OSGI、JBossMC、Spring、Guice 容器; 3)协议支持:HTTP、Protobuf、二进制、文本、WebSocket 等一系列常见协议都支持。还支持通过实行编码解码逻辑来实现自定义协议; 4)Core 核心:可扩展事件模型、通用通信 API、支持零拷贝的 ByteBuf 缓冲对象。 7.2 模块组件 【Bootstrap、ServerBootstrap】: Bootstrap 意思是引导,一个 Netty 应用通常由一个 Bootstrap 开始,主要作用是配置整个 Netty 程序,串联各个组件,Netty 中 Bootstrap 类是客户端程序的启动引导类,ServerBootstrap 是服务端启动引导类。 【Future、ChannelFuture】: 正如前面介绍,在 Netty 中所有的 IO 操作都是异步的,不能立刻得知消息是否被正确处理。 但是可以过一会等它执行完成或者直接注册一个监听,具体的实现就是通过 Future 和 ChannelFutures,他们可以注册一个监听,当操作执行成功或失败时监听会自动触发注册的监听事件。 【Channel】: Netty 网络通信的组件,能够用于执行网络 I/O 操作。Channel 为用户提供: 1)当前网络连接的通道的状态(例如是否打开?是否已连接?) 2)网络连接的配置参数 (例如接收缓冲区大小) 3)提供异步的网络 I/O 操作(如建立连接,读写,绑定端口),异步调用意味着任何 I/O 调用都将立即返回,并且不保证在调用结束时所请求的 I/O 操作已完成。 4)调用立即返回一个 ChannelFuture 实例,通过注册监听器到 ChannelFuture 上,可以 I/O 操作成功、失败或取消时回调通知调用方。 5)支持关联 I/O 操作与对应的处理程序。 不同协议、不同的阻塞类型的连接都有不同的 Channel 类型与之对应。 下面是一些常用的 Channel 类型: NioSocketChannel,异步的客户端 TCP Socket 连接。 NioServerSocketChannel,异步的服务器端 TCP Socket 连接。 NioDatagramChannel,异步的 UDP 连接。 NioSctpChannel,异步的客户端 Sctp 连接。 NioSctpServerChannel,异步的 Sctp 服务器端连接,这些通道涵盖了 UDP 和 TCP 网络 IO 以及文件 IO。 【Selector】: Netty 基于 Selector 对象实现 I/O 多路复用,通过 Selector 一个线程可以监听多个连接的 Channel 事件。 当向一个 Selector 中注册 Channel 后,Selector 内部的机制就可以自动不断地查询(Select) 这些注册的 Channel 是否有已就绪的 I/O 事件(例如可读,可写,网络连接完成等),这样程序就可以很简单地使用一个线程高效地管理多个 Channel 。 【NioEventLoop】: NioEventLoop 中维护了一个线程和任务队列,支持异步提交执行任务,线程启动时会调用 NioEventLoop 的 run 方法,执行 I/O 任务和非 I/O 任务: I/O 任务,即 selectionKey 中 ready 的事件,如 accept、connect、read、write 等,由 processSelectedKeys 方法触发。 非 IO 任务,添加到 taskQueue 中的任务,如 register0、bind0 等任务,由 runAllTasks 方法触发。 两种任务的执行时间比由变量 ioRatio 控制,默认为 50,则表示允许非 IO 任务执行的时间与 IO 任务的执行时间相等。 【NioEventLoopGroup】: NioEventLoopGroup,主要管理 eventLoop 的生命周期,可以理解为一个线程池,内部维护了一组线程,每个线程(NioEventLoop)负责处理多个 Channel 上的事件,而一个 Channel 只对应于一个线程。 【ChannelHandler】: ChannelHandler 是一个接口,处理 I/O 事件或拦截 I/O 操作,并将其转发到其 ChannelPipeline(业务处理链)中的下一个处理程序。 ChannelHandler 本身并没有提供很多方法,因为这个接口有许多的方法需要实现,方便使用期间,可以继承它的子类: ChannelInboundHandler 用于处理入站 I/O 事件。 ChannelOutboundHandler 用于处理出站 I/O 操作。 或者使用以下适配器类: ChannelInboundHandlerAdapter 用于处理入站 I/O 事件。 ChannelOutboundHandlerAdapter 用于处理出站 I/O 操作。 ChannelDuplexHandler 用于处理入站和出站事件。 【ChannelHandlerContext】: 保存 Channel 相关的所有上下文信息,同时关联一个 ChannelHandler 对象。 【ChannelPipline】: 保存 ChannelHandler 的 List,用于处理或拦截 Channel 的入站事件和出站操作。 ChannelPipeline 实现了一种高级形式的拦截过滤器模式,使用户可以完全控制事件的处理方式,以及 Channel 中各个的 ChannelHandler 如何相互交互。 下图引用 Netty 的 Javadoc 4.1 中 ChannelPipeline 的说明,描述了 ChannelPipeline 中 ChannelHandler 通常如何处理 I/O 事件。 I/O 事件由 ChannelInboundHandler 或 ChannelOutboundHandler 处理,并通过调用 ChannelHandlerContext 中定义的事件传播方法。 例如:ChannelHandlerContext.fireChannelRead(Object)和 ChannelOutboundInvoker.write(Object)转发到其最近的处理程序。 入站事件由自下而上方向的入站处理程序处理,如图左侧所示。入站 Handler 处理程序通常处理由图底部的 I/O 线程生成的入站数据。 通常通过实际输入操作(例如 SocketChannel.read(ByteBuffer))从远程读取入站数据。 出站事件由上下方向处理,如图右侧所示。出站 Handler 处理程序通常会生成或转换出站传输,例如 write 请求。 I/O 线程通常执行实际的输出操作,例如 SocketChannel.write(ByteBuffer)。 在 Netty 中每个 Channel 都有且仅有一个 ChannelPipeline 与之对应,它们的组成关系如下: 一个 Channel 包含了一个 ChannelPipeline,而 ChannelPipeline 中又维护了一个由 ChannelHandlerContext 组成的双向链表,并且每个 ChannelHandlerContext 中又关联着一个 ChannelHandler。 入站事件和出站事件在一个双向链表中,入站事件会从链表 head 往后传递到最后一个入站的 handler,出站事件会从链表 tail 往前传递到最前一个出站的 handler,两种类型的 handler 互不干扰。 8、Netty框架的工作原理 典型的初始化并启动 Netty 服务端的过程代码如下: publicstaticvoidmain(String[] args) { // 创建mainReactor NioEventLoopGroup boosGroup = newNioEventLoopGroup(); // 创建工作线程组 NioEventLoopGroup workerGroup = newNioEventLoopGroup(); finalServerBootstrap serverBootstrap = newServerBootstrap(); serverBootstrap // 组装NioEventLoopGroup .group(boosGroup, workerGroup) // 设置channel类型为NIO类型 .channel(NioServerSocketChannel.class) // 设置连接配置参数 .option(ChannelOption.SO_BACKLOG, 1024) .childOption(ChannelOption.SO_KEEPALIVE, true) .childOption(ChannelOption.TCP_NODELAY, true) // 配置入站、出站事件handler .childHandler(newChannelInitializer<NioSocketChannel>() { @Override protectedvoidinitChannel(NioSocketChannel ch) { // 配置入站、出站事件channel ch.pipeline().addLast(...); ch.pipeline().addLast(...); } }); // 绑定端口 intport = 8080; serverBootstrap.bind(port).addListener(future -> { if(future.isSuccess()) { System.out.println(newDate() + ": 端口["+ port + "]绑定成功!"); } else{ System.err.println("端口["+ port + "]绑定失败!"); } }); } 基本过程描述如下: 1)初始化创建 2 个 NioEventLoopGroup:其中 boosGroup 用于 Accetpt 连接建立事件并分发请求,workerGroup 用于处理 I/O 读写事件和业务逻辑。 2)基于 ServerBootstrap(服务端启动引导类):配置 EventLoopGroup、Channel 类型,连接参数、配置入站、出站事件 handler。 3)绑定端口:开始工作。 结合上面介绍的 Netty Reactor 模型,介绍服务端 Netty 的工作架构图: Server 端包含 1 个 Boss NioEventLoopGroup 和 1 个 Worker NioEventLoopGroup。 NioEventLoopGroup 相当于 1 个事件循环组,这个组里包含多个事件循环 NioEventLoop,每个 NioEventLoop 包含 1 个 Selector 和 1 个事件循环线程。 每个 Boss NioEventLoop 循环执行的任务包含 3 步: 1)轮询 Accept 事件; 2)处理 Accept I/O 事件,与 Client 建立连接,生成 NioSocketChannel,并将 NioSocketChannel 注册到某个 Worker NioEventLoop 的 Selector 上; 3)处理任务队列中的任务,runAllTasks。任务队列中的任务包括用户调用 eventloop.execute 或 schedule 执行的任务,或者其他线程提交到该 eventloop 的任务。 每个 Worker NioEventLoop 循环执行的任务包含 3 步: 1)轮询 Read、Write 事件; 2)处理 I/O 事件,即 Read、Write 事件,在 NioSocketChannel 可读、可写事件发生时进行处理; 3)处理任务队列中的任务,runAllTasks。 其中任务队列中的 Task 有 3 种典型使用场景: ① 用户程序自定义的普通任务: ctx.channel().eventLoop().execute(newRunnable() { @Override publicvoidrun() { //... } }); ② 非当前 Reactor 线程调用 Channel 的各种方法: 例如在推送系统的业务线程里面,根据用户的标识,找到对应的 Channel 引用,然后调用 Write 类方法向该用户推送消息,就会进入到这种场景。最终的 Write 会提交到任务队列中后被异步消费。 ③ 用户自定义定时任务: ctx.channel().eventLoop().schedule(newRunnable() { @Override publicvoidrun() { } }, 60, TimeUnit.SECONDS); 9、本文小结 现在推荐使用的主流稳定版本还是 Netty4,Netty5 中使用了 ForkJoinPool,增加了代码的复杂度,但是对性能的改善却不明显,所以这个版本不推荐使用,官网也没有提供下载链接。 Netty 入门门槛相对较高,是因为这方面的资料较少,并不是因为它有多难,大家其实都可以像搞透 Spring 一样搞透 Netty。 在学习之前,建议先理解透整个框架原理结构,运行过程,可以少走很多弯路。 附录:更多网络通信方面的文章 [1] 网络编程基础资料: 《TCP/IP详解 - 第11章·UDP:用户数据报协议》 《TCP/IP详解 - 第17章·TCP:传输控制协议》 《TCP/IP详解 - 第18章·TCP连接的建立与终止》 《TCP/IP详解 - 第21章·TCP的超时与重传》 《技术往事:改变世界的TCP/IP协议(珍贵多图、手机慎点)》 《通俗易懂-深入理解TCP协议(上):理论基础》 《通俗易懂-深入理解TCP协议(下):RTT、滑动窗口、拥塞处理》 《理论经典:TCP协议的3次握手与4次挥手过程详解》 《理论联系实际:Wireshark抓包分析TCP 3次握手、4次挥手过程》 《计算机网络通讯协议关系图(中文珍藏版)》 《UDP中一个包的大小最大能多大?》 《P2P技术详解(一):NAT详解——详细原理、P2P简介》 《P2P技术详解(二):P2P中的NAT穿越(打洞)方案详解》 《P2P技术详解(三):P2P技术之STUN、TURN、ICE详解》 《通俗易懂:快速理解P2P技术中的NAT穿透原理》 《高性能网络编程(一):单台服务器并发TCP连接数到底可以有多少》 《高性能网络编程(二):上一个10年,著名的C10K并发连接问题》 《高性能网络编程(三):下一个10年,是时候考虑C10M并发问题了》 《高性能网络编程(四):从C10K到C10M高性能网络应用的理论探索》 《高性能网络编程(五):一文读懂高性能网络编程中的I/O模型》 《高性能网络编程(六):一文读懂高性能网络编程中的线程模型》 《不为人知的网络编程(一):浅析TCP协议中的疑难杂症(上篇)》 《不为人知的网络编程(二):浅析TCP协议中的疑难杂症(下篇)》 《不为人知的网络编程(三):关闭TCP连接时为什么会TIME_WAIT、CLOSE_WAIT》 《不为人知的网络编程(四):深入研究分析TCP的异常关闭》 《不为人知的网络编程(五):UDP的连接性和负载均衡》 《不为人知的网络编程(六):深入地理解UDP协议并用好它》 《不为人知的网络编程(七):如何让不可靠的UDP变的可靠?》 《网络编程懒人入门(一):快速理解网络通信协议(上篇)》 《网络编程懒人入门(二):快速理解网络通信协议(下篇)》 《网络编程懒人入门(三):快速理解TCP协议一篇就够》 《网络编程懒人入门(四):快速理解TCP和UDP的差异》 《网络编程懒人入门(五):快速理解为什么说UDP有时比TCP更有优势》 《网络编程懒人入门(六):史上最通俗的集线器、交换机、路由器功能原理入门》 《网络编程懒人入门(七):深入浅出,全面理解HTTP协议》 《网络编程懒人入门(八):手把手教你写基于TCP的Socket长连接》 《技术扫盲:新一代基于UDP的低延时网络传输层协议——QUIC详解》 《让互联网更快:新一代QUIC协议在腾讯的技术实践分享》 《现代移动端网络短连接的优化手段总结:请求速度、弱网适应、安全保障》 《聊聊iOS中网络编程长连接的那些事》 《移动端IM开发者必读(一):通俗易懂,理解移动网络的“弱”和“慢”》 《移动端IM开发者必读(二):史上最全移动弱网络优化方法总结》 《IPv6技术详解:基本概念、应用现状、技术实践(上篇)》 《IPv6技术详解:基本概念、应用现状、技术实践(下篇)》 《从HTTP/0.9到HTTP/2:一文读懂HTTP协议的历史演变和设计思路》 《脑残式网络编程入门(一):跟着动画来学TCP三次握手和四次挥手》 《脑残式网络编程入门(二):我们在读写Socket时,究竟在读写什么?》 《脑残式网络编程入门(三):HTTP协议必知必会的一些知识》 《脑残式网络编程入门(四):快速理解HTTP/2的服务器推送(Server Push)》 《脑残式网络编程入门(五):每天都在用的Ping命令,它到底是什么?》 《以网游服务端的网络接入层设计为例,理解实时通信的技术挑战》 《迈向高阶:优秀Android程序员必知必会的网络基础》 >> 更多同类文章 …… [2] NIO异步网络编程资料: 《Java新一代网络编程模型AIO原理及Linux系统AIO介绍》 《NIO框架入门(一):服务端基于Netty4的UDP双向通信Demo演示》 《NIO框架入门(二):服务端基于MINA2的UDP双向通信Demo演示》 《NIO框架入门(三):iOS与MINA2、Netty4的跨平台UDP双向通信实战》 《NIO框架入门(四):Android与MINA2、Netty4的跨平台UDP双向通信实战》 《Apache Mina框架高级篇(一):IoFilter详解》 《Apache Mina框架高级篇(二):IoHandler详解》 《MINA2 线程原理总结(含简单测试实例)》 《Apache MINA2.0 开发指南(中文版)[附件下载]》 《MINA、Netty的源代码(在线阅读版)已整理发布》 《解决MINA数据传输中TCP的粘包、缺包问题(有源码)》 《解决Mina中多个同类型Filter实例共存的问题》 >> 更多同类文章 …… (本文同步发布于:http://www.52im.net/thread-2043-1-1.html)
本文来自腾讯前端开发工程师“ wendygogogo”的技术分享,作者自评:“在Web前端摸爬滚打的码农一枚,对技术充满热情的菜鸟,致力为手Q的建设添砖加瓦。” 1、GIF格式的历史 GIF ( Graphics Interchange Format )原义是“图像互换格式”,是 CompuServe 公司在1987年开发出的图像文件格式,可以说是互联网界的老古董了。 GIF 格式可以存储多幅彩色图像,如果将这些图像((https://www.qcloud.com/document/ ... w.59167.59167.59167)连续播放出来,就能够组成最简单的动画。所以常被用来存储“动态图片”,通常时间短,体积小,内容简单,成像相对清晰,适于在早起的慢速互联网上传播。 本来,随着网络带宽的拓展和视频技术的进步,这种图像已经渐渐失去了市场。可是,近年来流行的表情包文化,让老古董 GIF 图有了新的用武之地。 表情包通常来源于手绘图像,或是视频截取,目前有很多方便制作表情包的小工具。 这类图片通常具有文件体积小,内容简单,兼容性好(无需解码工具即可在各类平台上查看),对画质要求不高的特点,刚好符合 GIF 图的特性。 所以,老古董 GIF 图有了新的应用场景。 学习交流: - 即时通讯开发交流3群:185926912[推荐] - 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》 (本文同步发布于:http://www.52im.net/thread-2032-1-1.html) 2、相关文章 《腾讯技术分享:社交网络图片的带宽压缩技术演进之路》 《QQ音乐团队分享:Android中的图片压缩技术详解(上篇)》 《QQ音乐团队分享:Android中的图片压缩技术详解(下篇)》 《腾讯原创分享(一):如何大幅提升移动网络下手机QQ的图片传输速度和成功率》 《腾讯技术分享:腾讯是如何大幅降低带宽和网络流量的(图片压缩篇)》 《全面掌握移动端主流图片格式的特点、性能、调优等》 《基于社交网络的Yelp是如何实现海量用户图片的无损压缩的?》 3、技术需求场景 新的应用场景带来新的需求,本文所要探究的技术和要解决的问题来源于某个真实的业务场景下——即为用户批量推送GIF表情包的功能需求。 一批图像大约有200-500张,以缩略图列表的形式展示在客户端。 根据我们使用测试数据进行的统计 GIF 图表情包的尺寸大部分在200k-500k之间,批量推送的一个重要问题就是数据量太大,因此,我们希望能够在列表里展示体积较小的缩略图,用户点击后,再单独拉取原图。 传统的 GIF 缩略图是静态的,通常是提取第一帧,但在表情包的情形下,这种方式不足以表达出图片中信息。 比如下面的例子: (左为原始GIF动态图,右为GIF的第一帧) 第一帧完全看不出重点啊! 所以,我们希望缩略图也是动态的,并尽可能和原图相似。 对于传统图片来说,文件大小一般和图片分辨率(尺寸)正相关,所以,生成缩略图最直观的思路就是缩小尺寸,resize大法。 但是在 GIF 图的场合,这个方式不再高效,因为 GIF 图的文件大小还受到一个重要的因素制约——帧数 以这张柴犬表情为例,原图宽度200,尺寸1.44M,等比缩放到150之后,尺寸还是1.37M,等比缩放到100,相当于尺寸变为原来的四分之一,体积还是749K。 可见,resize大法的压缩率并不理想,收效甚微。 而且,我们所得到的大部分表情图素材,分辨率已经很小了,为了保证客户端展示效果,不能够过度减少尺寸,不然图片会变得模糊。 所以,想要对GIF图进行压缩,只能从别的方向入手。 4、GIF技术详解:拆解GIF格式 4.1 基本 想要压缩一个文件,首先要了解它是如何存储的。毕竟,编程的事,万变不离其宗嘛。 作为一种古老的格式,GIF的存储规则也相对简单,容易理解。 一个GIF文件主要由以下几部分组成: 1)文件头; 2)图像帧信息; 3)注释。 下面我们来分别探究每个部分。 4.2 文件头 GIF格式文件头和一般文件头差别不大,也包含有: 1)格式声明; 2)逻辑屏幕描述块; 3)全局调色盘; 格式声明: Signature 为“GIF”3 个字符;Version 为“87a”或“89a”3 个字符。 逻辑屏幕描述块: 前两字节为像素单位的宽、高,用以标识图片的视觉尺寸。 Packet里是调色盘信息,分别来看: 1)Global Color Table Flag为全局颜色表标志,即为1时表明全局颜色表有定义; 2)Color Resolution 代表颜色表中每种基色位长(需要+1),为111时,每个颜色用8bit表示,即我们熟悉的RGB表示法,一个颜色三字节; 3)Sort Flag 表示是否对颜色表里的颜色进行优先度排序,把常用的排在前面,这个主要是为了适应一些颜色解析度低的早期渲染器,现在已经很少使用了; 4)Global Color Table 表示颜色表的长度,计算规则是值+1作为2的幂,得到的数字就是颜色表的项数,取最大值111时,项数=256,也就是说GIF格式最多支持256色的位图,再乘以Color Resolution算出的字节数,就是调色盘的总长度。 这四个字段一起定义了调色盘的信息。 Background color Index 定义了图像透明区域的背景色在调色盘里的索引。 Pixel Aspect Ratio 定义了像素宽高比,一般为0。 什么是调色盘?我们先考虑最直观的图像存储方式,一张分辨率M×N的图像,本质是一张点阵,如果采用Web最常见的RGB三色方式存储,每个颜色用8bit表示,那么一个点就可以由三个字节(3BYTE = 24bit)表达,比如0xFFFFFF可以表示一个白色像素点,0x000000表示一个黑色像素点。 如果我们采用最原始的存储方式,把每个点的颜色值写进文件,那么我们的图像信息就要占据就是3×M×N字节,这是静态图的情况,如果一张GIF图里有K帧,点阵信息就是3×M×N×K。 下面这张兔子snowball的表情有18帧,分辨率是200×196,如果用上述方式计算,文件尺寸至少要689K。 但实际文件尺寸只有192K,它一定经历过什么…… 我们可以使用命令行图片处理工具gifsicle来看看它的信息: gifsicle -I snowball.gif > snowball.txt 我们得到下面的文本: 5.gif 19 images logical screen 200x196 global color table (128) background 93 loop forever extensions 1 + image #0 200x196 transparent 93 disposal asis delay 0.04s + image #1 200x188 transparent 93 disposal asis delay 0.04s ........ 可以看到,global color table 128就是它的调色盘,长度128。 为了确认,我们再用二进制查看器查看一下它的文件头: 可以看到Packet里的字段的确符合我们的描述。 在实际情况中,GIF图具有下面的特征: 1)一张图像最多只会包含256个RGB值; 2)在一张连续动态GIF里,每一帧之间信息差异不大,颜色是被大量重复使用的。 在存储时,我们用一个公共的索引表,把图片中用到的颜色提取出来,组成一个调色盘,这样,在存储真正的图片点阵时,只需要存储每个点在调色盘里的索引值。 如果调色盘放在文件头,作为所有帧公用的信息,就是公共(全局)调色盘,如果放在每一帧的帧信息中,就是局部调色盘。GIF格式允许两种调色盘同时存在,在没有局部调色盘的情况下,使用公共调色盘来渲染。 这样,我们可以用调色盘里的索引来代表实际的颜色值。 一个256色的调色盘,24bit的颜色只需要用9bit就可以表达了。 调色盘还可以进一步减少,128色,64色,etc,相应的压缩率就会越来越大…… 还是以兔子为例,我们还可以尝试指定它的调色盘大小,对它进行重压缩: gifsicle --colors=64 5.gif > 5-64.gif gifsicle --colors=32 5.gif > 5-32.gif gifsicle --colors=16 5.gif > 5-16.gif gifsicle --colors=2 5.gif > 5-2.gif ...... 依然使用gifsicle工具,colors参数就是调色盘的长度,得到的结果: 注意到了2的时候,图像已经变成了黑白二值图。 居然还能看出是个兔子…… 所以我们得出结论——如果可以接受牺牲图像的部分视觉效果,就可以通过减色来对图像做进一步压缩。 文件头所包含的对我们有用的信息就是这些了,我们继续往后看。 4.3 帧信息描述 帧信息描述就是每一帧的图像信息和相关标志位,在逐项了解它之前,我们首先探究一下帧的存储方式。 我们已经知道调色盘相关的定义,除了全局调色盘,每一帧可以拥有自己的局部调色盘,渲染顺序更优先,它的定义方式和全局调色盘一致,只是作用范围不同。 直观地说,帧信息应该由一系列的点阵数据组成,点阵中存储着一系列的颜色值。点阵数据本身的存储也是可以进行压缩的,GIF图所采用的是LZW压缩算法。 这样的压缩和图像本身性质无关,是字节层面的,文本信息也可以采用(比如常见的gzip,就是LZW和哈夫曼树的一个实现)。 基于表查询的无损压缩是如何进行的?基本思路是,对于原始数据,将每个第一次出现的串放在一个串表中,用索引来表示串,后续遇到同样的串,简化为索引来存储(串表压缩法)。 举一个简单的例子来说明LZW算法的核心思路。 有原始数据:ABCCAABCDDAACCDB 可以看出,原始数据里只包括4个字符A,B,C,D,四个字符可以用2bit的索引来表示,0-A,1-B,2-C,3-D。 原始字符串存在重复字符,比如AB,CC,都重复出现过。用4代表AB,5代表CC,上面的字符串可以替代表示为45A4CDDAA5DB 这样就完成了压缩,串长度从16缩减到12。对原始信息来说,LZW压缩是无损的。 除了采用LZW之外,帧信息存储过程中还采取了一些和图像相关的优化手段,以减小文件的体积,直观表述就是——公共区域排除、透明区域叠加 这是ImageMagick官方范例里的一张GIF图: 根据直观感受,这张图片的每一帧应该是这样的: 但实际上,进行过压缩优化的图片,每一帧是这样的: 首先,对于各帧之间没有变化的区域进行了排除,避免存储重复的信息。 其次,对于需要存储的区域做了透明化处理,只存储有变化的像素,没变化的像素只存储一个透明值。 这样的优化在表情包中也是很常见的,举个栗子: 上面这个表情的文件大小是278KB,帧数是14 我们试着用工具将它逐帧拆开,这里使用另一个命令行图像处理工具ImageMagick: gm convert source.gif target_%d.gif 可以看出,除了第一帧之外,后面的帧都做了不同程度的处理,文件体积也比第一帧小。 这样的压缩处理也是无损的,带来的压缩比和原始图像的具体情况有关,重复区域越多,压缩效果越好,但相应地,也需要存储一些额外的信息,来告诉引擎如何渲染。 具体包括: 帧数据长宽分辨率,相对整图的偏移位置; 透明彩色索引——填充透明点所用的颜色; Disposal Method——定义该帧对于上一帧的叠加方式; Delay Time——定义该帧播放时的停留时间。 其中值得额外说明的是Disposal Method,它定义的是帧之间的叠加关系,给定一个帧序列,我们用怎样的方式把它们渲染成起来。 详细参数定义,可以参考该网站的范例:http://www.theimage.com/animation/pages/disposal.html Disposal Method和透明颜色一起,定义了帧之间的叠加关系。在实际使用中,我们通常把第一帧当做基帧(background),其余帧向前一帧对齐的方式来渲染,这里不再赘述。 理解了上面的内容,我们再来看帧信息的具体定义,主要包括: 1)帧分隔符; 2)帧数据说明; 3)点阵数据(它存储的不是颜色值,而是颜色索引); 4)帧数据扩展(只有89a标准支持)。 1和3比较直观,第二部分和第四部分则是一系列的标志位,定义了对于“帧”需要说明的内容。 帧数据说明: 除了上面说过的字段之外,还多了一个Interlace Flag,表示帧点阵的存储方式,有两种,顺序和隔行交错,为 1 时表示图像数据是以隔行方式存放的。最初 GIF 标准设置此标志的目的是考虑到通信设备间传输速度不理想情况下,用这种方式存放和显示图像,就可以在图像显示完成之前看到这幅图像的概貌,慢慢的变清晰,而不觉得显示时间过长。 帧数据扩展是89a标准增加的,主要包括四个部分。 1)程序扩展结构(Application Extension):主要定义了生成该gif的程序相关信息 2)注释扩展结构(Comment Extension):一般用来储存图片作者的签名信息 3)图形控制扩展结构(Graphic Control Extension):这部分对图片的渲染比较重要 除了前面说过的Dispose Method、Delay、Background Color之外,User Input用来定义是否接受用户输入后再播放下一帧,需要图像解码器对应api的配合,可以用来实现一些特殊的交互效果。 4)平滑文本扩展结构(Plain Text Control Extension): 89a标准允许我们将图片上的文字信息额外储存在扩展区域里,但实际渲染时依赖解码器的字体环境,所以实际情况中很少使用。 以上扩展块都是可选的,只有Label置位的情况下,解码器才会去渲染。 5、将技术理论付诸应用——给表情包减负 说完了基本原理,用刚才了解到的技术细节来分析一下我们的实际问题。 给大量表情包生成缩略图,在不损耗原画质的前提下,尽可能减少图片体积,节省用户流量。 之前说过,单纯依靠resize大法不能满足我们的要求,没办法,只能损耗画质了。 主要有两个思路:减少颜色和减少帧数: 1)减少颜色——图片情况各异,标准难以控制,而且会造成缩略图和原图视觉差异比较明显。 2)减少帧数——通过提取一些间隔帧,比如对于一张10帧的动画,提取其中的提取1,3,5,7,9帧。来减少图片的整体体积,似乎更可行。 先看一个成果,就拿文章开头的图做栗子吧: 看上去连贯性不如以前,但是差别不大,作为缩略图的视觉效果可以接受,由于帧数减小,体积也可以得到明显的优化。体积从428K缩到了140K。 但是,在开发初期,我们尝试暴力间隔提取帧,把帧重新连接压成新的GIF图,这时,会得到这样的图片: 主要有两个问题: 1)帧数过快; 2)能看到明显的残留噪点。 分析我们上面的原理,不难找到原因,正是因为大部分GIF存储时采用了公共区域排除和透明区域叠加的优化,如果我们直接间隔抽帧,再拼起来,就破坏了原来的叠加规则,不该露出来的帧露出来了,所以才会产生噪点。 所以,我们首先要把原始信息恢复出来。 两个命令行工具,gifsicle和ImageMagick都提供这样的命令: gm convert -coalesce source.gif target_%d.gif gifsicle --unoptimize source.gif > target.gif 还原之后抽帧,重建新的GIF,就可以解决问题2了。 注意重建的时候,可以应用工具再进行对透明度和公共区域的优化压缩。 至于问题1,也是因为我们没有对帧延迟参数Delay Time做处理,直接取原帧的参数,帧数减少了,速度一定会加快。 所以,我们需要把抽去的连续帧的总延时加起来,作为新的延迟数据,这样可以保持缩略图和原图频率一致,看起来不会太过鬼畜,也不会太过迟缓。 提取出每一帧的delay信息,也可以通过工具提供的命令来提取: gm identify -verbose source.gif gifsicle -I source.gif 在实际应用中,抽帧的间隔gap是根据总帧数frame求出的: frame<8 gap=1 frame>40 gap=5 delay值的计算还做了归一化处理,如果新生成缩略图的帧间隔平均值大于200ms,则统一加速到均值200ms,同时保持原有节奏,这样可以避免极端情况下,缩略图过于迟缓。 6、具体的代码实践 本文介绍的算法已经应用于手Q热图功能的后台管理系统等,使用Nodejs编写。ImageMagick是一个较为常用的图像处理工具,除了gif还可以处理各类图像文件,有node封装的版本可以使用。gifsicle只有可执行版本,在服务器上重新编译源码后,采用spawn调起子进程的方式实现。 ImageMagick对于图片信息的解析较为方便,可以直接得到结构化信息。gifsicle支持命令管道级联,处理图片速度较快。实际生产过程中,同时采用了两个工具。 const {spawn} = require('child_process'); const image = gm("src2/"+file) image.identify((err, val) => { if(!val.Scene){ console.log(file+" has err:"+err) return } let frames_count = val.Scene[0].replace(/\d* of /, '') * 1 let gap = countGap(frames_count) let delayList = []; let totaldelay = 0 if(val.Delay!=undefined){ let iii for(iii = 0; iii < val.Delay.length; iii ++) { delayList[iii] = val.Delay[iii].replace(/x\d*/, '') * 1 totaldelay+=delayList[iii] } for(; iii < val.Scene.length; iii ++) { delayList[iii] = 8 totaldelay+=delayList[iii] } }else{ for(let iii = 0; iii < val.Scene.length; iii ++) { delayList[iii] = 8 totaldelay+=delayList[iii] } } let totalFrame = parseInt(frames_count/gap) //判断是否速度过慢,需要进行归一加速处理 if(totaldelay/totalFrame>20){ let scale =(totalFrame*1.0*20)/totaldelay for(let iii = 0; iii < delayList.length; iii ++) { delayList[iii] = parseInt(delayList[iii] * scale) } } let params=[] params.push("--colors=255") params.push("--unoptimize") params.push("src2/"+file) let tempdelay = delayList[0] for(let iii = 1; iii < frames_count; iii ++) { if(i%gap==0){ params.push("-d"+tempdelay) params.push("#"+(iii-gap)) tempdelay=0 } tempdelay += delayList[iii] } params.push("--optimize=3") params.push("-o") params.push("src2/"+file+"gap-keepdelay.gif") spawn("gifsicle", params, { stdio: 'inherit'}) }) 测试时,采用该算法随机选择50张gif图进行压缩,原尺寸15.5M被压缩到6.0M,压缩比38%,不过由于该算法的压缩比率和具体图片质量、帧数、图像特征有关,测试数据仅供参考。 本文到这里就结束了,原来看似简单的表情包,也有不少文章可做。 谢谢观看,希望文中介绍的知识和研究方法对你有所启发。 附录:来自即时通讯大厂的分享 [1] QQ、微信团队原创技术文章: 《微信朋友圈千亿访问量背后的技术挑战和实践总结》 《腾讯技术分享:腾讯是如何大幅降低带宽和网络流量的(图片压缩篇)》 《腾讯技术分享:腾讯是如何大幅降低带宽和网络流量的(音视频技术篇)》 《微信团队分享:微信移动端的全文检索多音字问题解决方案》 《腾讯技术分享:Android版手机QQ的缓存监控与优化实践》 《微信团队分享:iOS版微信的高性能通用key-value组件技术实践》 《微信团队分享:iOS版微信是如何防止特殊字符导致的炸群、APP崩溃的?》 《腾讯技术分享:Android手Q的线程死锁监控系统技术实践》 《微信团队原创分享:iOS版微信的内存监控系统技术实践》 《让互联网更快:新一代QUIC协议在腾讯的技术实践分享》 《iOS后台唤醒实战:微信收款到账语音提醒技术总结》 《腾讯技术分享:社交网络图片的带宽压缩技术演进之路》 《微信团队分享:视频图像的超分辨率技术原理和应用场景》 《微信团队分享:微信每日亿次实时音视频聊天背后的技术解密》 《QQ音乐团队分享:Android中的图片压缩技术详解(上篇)》 《QQ音乐团队分享:Android中的图片压缩技术详解(下篇)》 《腾讯团队分享:手机QQ中的人脸识别酷炫动画效果实现详解》 《腾讯团队分享 :一次手Q聊天界面中图片显示bug的追踪过程分享》 《微信团队分享:微信Android版小视频编码填过的那些坑》 《微信手机端的本地数据全文检索优化之路》 《企业微信客户端中组织架构数据的同步更新方案优化实战》 《微信团队披露:微信界面卡死超级bug“15。。。。”的来龙去脉》 《QQ 18年:解密8亿月活的QQ后台服务接口隔离技术》 《月活8.89亿的超级IM微信是如何进行Android端兼容测试的》 《以手机QQ为例探讨移动端IM中的“轻应用”》 《一篇文章get微信开源移动端数据库组件WCDB的一切!》 《微信客户端团队负责人技术访谈:如何着手客户端性能监控和优化》 《微信后台基于时间序的海量数据冷热分级架构设计实践》 《微信团队原创分享:Android版微信的臃肿之困与模块化实践之路》 《微信后台团队:微信后台异步消息队列的优化升级实践分享》 《微信团队原创分享:微信客户端SQLite数据库损坏修复实践》 《腾讯原创分享(一):如何大幅提升移动网络下手机QQ的图片传输速度和成功率》 《腾讯原创分享(二):如何大幅压缩移动网络下APP的流量消耗(下篇)》 《腾讯原创分享(三):如何大幅压缩移动网络下APP的流量消耗(上篇)》 《微信Mars:微信内部正在使用的网络层封装库,即将开源》 《如约而至:微信自用的移动端IM网络层跨平台组件库Mars已正式开源》 《开源libco库:单机千万连接、支撑微信8亿用户的后台框架基石 [源码下载]》 《微信新一代通信安全解决方案:基于TLS1.3的MMTLS详解》 《微信团队原创分享:Android版微信后台保活实战分享(进程保活篇)》 《微信团队原创分享:Android版微信后台保活实战分享(网络保活篇)》 《Android版微信从300KB到30MB的技术演进(PPT讲稿) [附件下载]》 《微信团队原创分享:Android版微信从300KB到30MB的技术演进》 《微信技术总监谈架构:微信之道——大道至简(演讲全文)》 《微信技术总监谈架构:微信之道——大道至简(PPT讲稿) [附件下载]》 《如何解读《微信技术总监谈架构:微信之道——大道至简》》 《微信海量用户背后的后台系统存储架构(视频+PPT) [附件下载]》 《微信异步化改造实践:8亿月活、单机千万连接背后的后台解决方案》 《微信朋友圈海量技术之道PPT [附件下载]》 《微信对网络影响的技术试验及分析(论文全文)》 《一份微信后台技术架构的总结性笔记》 《架构之道:3个程序员成就微信朋友圈日均10亿发布量[有视频]》 《快速裂变:见证微信强大后台架构从0到1的演进历程(一)》 《快速裂变:见证微信强大后台架构从0到1的演进历程(二)》 《微信团队原创分享:Android内存泄漏监控和优化技巧总结》 《全面总结iOS版微信升级iOS9遇到的各种“坑”》 《微信团队原创资源混淆工具:让你的APK立减1M》 《微信团队原创Android资源混淆工具:AndResGuard [有源码]》 《Android版微信安装包“减肥”实战记录》 《iOS版微信安装包“减肥”实战记录》 《移动端IM实践:iOS版微信界面卡顿监测方案》 《微信“红包照片”背后的技术难题》 《移动端IM实践:iOS版微信小视频功能技术方案实录》 《移动端IM实践:Android版微信如何大幅提升交互性能(一)》 《移动端IM实践:Android版微信如何大幅提升交互性能(二)》 《移动端IM实践:实现Android版微信的智能心跳机制》 《移动端IM实践:WhatsApp、Line、微信的心跳策略分析》 《移动端IM实践:谷歌消息推送服务(GCM)研究(来自微信)》 《移动端IM实践:iOS版微信的多设备字体适配方案探讨》 《信鸽团队原创:一起走过 iOS10 上消息推送(APNS)的坑》 《腾讯信鸽技术分享:百亿级实时消息推送的实战经验》 《IPv6技术详解:基本概念、应用现状、技术实践(上篇)》 《IPv6技术详解:基本概念、应用现状、技术实践(下篇)》 《腾讯TEG团队原创:基于MySQL的分布式数据库TDSQL十年锻造经验分享》 《微信多媒体团队访谈:音视频开发的学习、微信的音视频技术和挑战等》 《了解iOS消息推送一文就够:史上最全iOS Push技术详解》 《腾讯技术分享:微信小程序音视频技术背后的故事》 《腾讯资深架构师干货总结:一文读懂大型分布式系统设计的方方面面》 《微信多媒体团队梁俊斌访谈:聊一聊我所了解的音视频技术》 《腾讯音视频实验室:使用AI黑科技实现超低码率的高清实时视频聊天》 《腾讯技术分享:微信小程序音视频与WebRTC互通的技术思路和实践》 《手把手教你读取Android版微信和手Q的聊天记录(仅作技术研究学习)》 《微信技术分享:微信的海量IM聊天消息序列号生成实践(算法原理篇)》 《微信技术分享:微信的海量IM聊天消息序列号生成实践(容灾方案篇)》 《腾讯技术分享:GIF动图技术详解及手机QQ动态表情压缩技术实践》 >> 更多同类文章 …… [2] 有关QQ、微信的技术故事: 《技术往事:微信估值已超5千亿,雷军曾有机会收编张小龙及其Foxmail》 《QQ和微信凶猛成长的背后:腾讯网络基础架构的这些年》 《闲话即时通讯:腾讯的成长史本质就是一部QQ成长史》 《2017微信数据报告:日活跃用户达9亿、日发消息380亿条》 《腾讯开发微信花了多少钱?技术难度真这么大?难在哪?》 《技术往事:创业初期的腾讯——16年前的冬天,谁动了马化腾的代码》 《技术往事:史上最全QQ图标变迁过程,追寻IM巨人的演进历史》 《技术往事:“QQ群”和“微信红包”是怎么来的?》 《开发往事:深度讲述2010到2015,微信一路风雨的背后》 《开发往事:微信千年不变的那张闪屏图片的由来》 《开发往事:记录微信3.0版背后的故事(距微信1.0发布9个月时)》 《一个微信实习生自述:我眼中的微信开发团队》 《首次揭秘:QQ实时视频聊天背后的神秘组织》 《为什么说即时通讯社交APP创业就是一个坑?》 《微信七年回顾:历经多少质疑和差评,才配拥有今天的强大》 《前创始团队成员分享:盘点微信的前世今生——微信成功的必然和偶然》 《即时通讯创业必读:解密微信的产品定位、创新思维、设计法则等》 《QQ的成功,远没有你想象的那么顺利和轻松》 《QQ现状深度剖析:你还认为QQ已经被微信打败了吗?》 《[技术脑洞] 如果把14亿中国人拉到一个微信群里技术上能实现吗?》 >> 更多同类文章 …… (本文同步发布于:http://www.52im.net/thread-2032-1-1.html)
本文原作者“虞大胆的叽叽喳喳”,原文链接:jianshu.com/p/8861da5734ba,感谢原作者。 1、引言 很多人一提到 HTTPS,第一反应就是安全,对于普通用户来说这就足够了; 但对于程序员,很有必要了解下 HTTP 到底有什么问题?以及HTTPS 是如何解决这些问题的?其背后的解决思路和方法是什么? 本文只做简单的描述,力求简单明了的阐明主要内容,因为HTTPS 体系非常复杂,这么短的文字是无法做到很详细和精准的分析。想要详细了解HTTPS的方方面面,可以阅读此前即时通讯网整理的《即时通讯安全篇(七):如果这样来理解HTTPS,一篇就够了》一文。 (本文同步发布于:http://www.52im.net/thread-2027-1-1.html) 2、HTTPS相关文章 《即时通讯安全篇(七):如果这样来理解HTTPS,一篇就够了》 《一文读懂Https的安全性原理、数字证书、单项认证、双项认证等》 《HTTPS时代已来,打算更新你的HTTP服务了吗?》 《苹果即将强制实施 ATS,你的APP准备好切换到HTTPS了吗?》 3、对HTTPS性能的理解 HTTP 有典型的几个问题,第一就是性能,HTTP 是基于 TCP 的,所以网络层就不说了(快慢不是 HTTP 的问题)。 比较严重的问题在于 HTTP 头是不能压缩的,每次要传递很大的数据包。另外 HTTP 的请求模型是每个连接只能支持一个请求,所以会显得很慢。 那么 HTTPS 是解决这些问题的吗? 不是,实际上 HTTPS 是在 HTTP 协议上又加了一层,会更慢,相信未来会逐步解决的。同时 HTTPS 用到了很多加密算法,这些算法的执行也是会影响速度的。 为什么说 HTTPS 提升了性能呢?因为只有支持了 HTTPS,才能部署 HTTP/2,而 HTTP/2 协议会提升速度,能够有效减轻客户端和服务器端的压力,让响应更快速。有关HTTP/2详细文章可以看看《从HTTP/0.9到HTTP/2:一文读懂HTTP协议的历史演变和设计思路》、《脑残式网络编程入门(四):快速理解HTTP/2的服务器推送(Server Push)》,这里只要知道一点:HTTP/2 能够加快速度的主要原因在于多路复用,同一个连接能够并行发送和接收多个请求。 4、传统HTTP的安全性问题 当用户在浏览器输入一个网址的时候,在地址栏上看到小锁图标,就会安心,潜意识的认为自己的上网行为是安全的,当然对于小白用户来说可能还不明白,但是未来会慢慢改善的(万事开头难嘛)。 那么 HTTP 到底有什么安全问题呢,看几个例子: 1)由于互联网传输是能够被拦截的,所以假如你的上网方式被别人控制了(没有绝对的安全),那么你的任何行为和信息攻击者都会知道,比如我们连上一个匿名的 WIFI,当你上网的时候,输入的网站密码可能就已经泄漏了; 2)当我们在上一个网站的时候,莫名其妙跳出一个广告(这个广告并不是这个网站的),那是因为访问的页面可能被运营商强制修改了(加入了他自己的内容,比如广告)。 HTTP 最大的问题就在于数据没有加密,以及通信双方没有办法进行身份验证( confidentiality and authentication),由于数据没有加密,那么只要数据包被攻击者劫持,信息就泄漏了。 身份验证的意思就是服务器并不知道连接它的客户端到底是谁,而客户端也不确定他连接的服务器就是他想连接的服务器,双方之间没有办法进行身份确认。 有关HTTP比较好的文章,可以看看: 《网络编程懒人入门(七):深入浅出,全面理解HTTP协议》 《从HTTP/0.9到HTTP/2:一文读懂HTTP协议的历史演变和设计思路》 《脑残式网络编程入门(三):HTTP协议必知必会的一些知识》 5、HTTPS 背后的密码学 为了解决 HTTP 的两个核心问题,HTTPS 出现了,HTTPS 包含了核心的几个部分:TLS 协议、OpenSSL,证书。 什么是 OpenSSL 呢,它实现了世界上非常重要和多的密码算法,而密码学是解决问题最重要的一个环节。 TLS 最重要的是握手的处理方式。证书的体系也很大,但是他们背后都是基于同样的密码学。 1)既然 HTTP 没有数据加密,那么我们就加密下,对称加密算法上场了,这种算法加密和解密要使用同一个密钥,通信双方需要知道这个密钥(或者每次协商一个),实际上这种方法不太可能,这涉及到密钥保密和配送的问题,一旦被攻击者知道了密钥,那么传输的数据等同没有加密。 2)这个时候非对称加密算法上场了,公钥和私钥是分开的,客户端保存公钥,服务器保存私钥(不会公开),这时候好像能够完美解决问题了。 但实际上会存在两个问题,第一就是非对称加密算法运算很慢,第二就是会遇到中间人攻击问题。 先说说中间人攻击的问题,假如使用非对称加密算法,对于客户端来说它拿到的公钥可能并不是真正服务器的公钥,因为客户端上网的时候可能不会仔细分辨某个公钥是和某个公司绑定的,假如错误的拿到攻击者的公钥,那么他发送出去的数据包被劫持后,攻击者用自己的私钥就能反解了。 3)接下来如何解决公钥认证的问题呢?证书出现了,证书是由 CA 机构认证的,客户端都充分信任它,它能够证明你拿到的公钥是特定机构的,然后就能使用非对称加密算法加密了。 证书是怎么加密的呢?实际上也是通过非对称加密算法,但是区别在于证书是用私钥加密,公钥解密。 CA 机构会用自己的私钥加密服务器用户的公钥,而客户端则用 CA 机构的公钥解出服务器的公钥。听上去有点晕,仔细体会下。这方面的知识,可以详细阅读:《即时通讯安全篇(七):如果这样来理解HTTPS,一篇就够了》。 4)上面说了非对称加密算法加密解密非常耗时,对于 HTTP 这样的大数据包,速度就更慢了,这时候可以使用对称加密算法,这个密钥是由客户端和服务器端协商出来,并由服务器的公钥进行加密传递,所以不存在安全问题。 5)另外客户端拿到证书后会验证证书是否正确,它验证的手段就是通过 Hash 摘要算法,CA 机构会将证书信息通过 Hash 算法运算后再用私钥加密,客户端用 CA 的公钥解出后,再计算证书的 Hash 摘要值,两者一致就说明验证身份通过。 6)HTTPS 解决的第三个问题是完整性问题,就是信息有没有被篡改(信息能够被反解),用的是 HMAC 算法,这个算法和 Hash 方法差不多,但是需要传递一个密钥,这个密钥就是客户端和服务器端上面协商出来的。 附录:更多安全方面的文章 《即时通讯安全篇(一):正确地理解和使用Android端加密算法》 《即时通讯安全篇(二):探讨组合加密算法在IM中的应用》 《即时通讯安全篇(三):常用加解密算法与通讯安全讲解》 《即时通讯安全篇(四):实例分析Android中密钥硬编码的风险》 《即时通讯安全篇(五):对称加密技术在Android平台上的应用实践》 《即时通讯安全篇(六):非对称加密技术的原理与应用实践》 《传输层安全协议SSL/TLS的Java平台实现简介和Demo演示》 《理论联系实际:一套典型的IM通信协议设计详解(含安全层设计)》 《微信新一代通信安全解决方案:基于TLS1.3的MMTLS详解》 《来自阿里OpenIM:打造安全可靠即时通讯服务的技术实践分享》 《简述实时音视频聊天中端到端加密(E2EE)的工作原理》 《移动端安全通信的利器——端到端加密(E2EE)技术详解》 《Web端即时通讯安全:跨站点WebSocket劫持漏洞详解(含示例代码)》 《通俗易懂:一篇掌握即时通讯的消息传输安全原理》 《IM开发基础知识补课(四):正确理解HTTP短连接中的Cookie、Session和Token》 《快速读懂量子通信、量子加密技术》 《即时通讯安全篇(七):如果这样来理解HTTPS原理,一篇就够了》 《一分钟理解 HTTPS 到底解决了什么问题》 >> 更多同类文章 …… (本文同步发布于:http://www.52im.net/thread-2027-1-1.html)
本文由“声网Agora”的RTC开发者社区整理。 1、概述 本文将分享新浪微博系统开发工程师陈浩在 RTC 2018 实时互联网大会上的演讲。他分享了新浪微博直播互动答题架构设计的实战经验。其背后的百万高并发实时架构,值得借鉴并用于未来更多场景中。本文正文是对演讲内容的整理,请继续往下阅读。 另外,即时通讯网整理的直播答题相关文章有: 《近期大热的实时直播答题系统的实现思路与技术难点分享》 《2018新“风口”——直播答题应用原理解析》 新浪微博团队分享的:《新浪微博技术分享:微博短视频服务的优化实践之路》一文,您也可能感兴趣。 (本文同步发布于:http://www.52im.net/thread-2022-1-1.html) 2、什么是直播答题 首先,如下图所示,这是一个传统的直播页面。它的主页面是直播的音视频流,下面显示的是消息互动,包括评论、点赞和分享。什么是直播答题呢? 直播答题其实本质上还是一个直播的场景,只是引入了答题的互动方式。主持人可以通过口令控制客户端的行为,比如控制发题。同时,直播答题通过奖金激励,带动更多用户参与进来。在每一次答题之后,会将数据实时展示出来。下图展示的是直播答题的流程,中间的部分是会重复进行的环节。 3、直播答题的技术挑战 直播答题的核心需求用一句话就可以概括:海量用户同时在线的场景下,确保用户的答题画面能展现,在直播过程中流畅地参与答题,最终分到奖金。 这一句话中有三个关键点,分别对应了不同的技术要求: 第一个:就是“海量用户同时在线”。海量用户带来的就是海量的数据。在这个场景下第一个用户高峰出现在活动开始前,海量的用户会在几分钟内加入房间。在直播进行中,答题的十秒倒计时内,海量用户会同时提交答案,会产生海量的答题消息,包括我们互动与题目结果的消息,所以下发、上传双向都会出现高并发。这就考验我们对海量数据高并发的处理能力。 第二个:关键点是“答题画面能够展示”,这是非常基础且首要的需求,因为用户参与答题,如果画面都展示不出来,那么这场游戏就没法进行下去了。每一轮答题都可能淘汰一批用户,淘汰答错题的用户是正常的,但如果是因为未能展示出答题画面而淘汰,那就是技术问题。其技术难点在于海量题目的下发成功率要有保证,给技术提出的对应要求就是服务的可靠性。 最后一个:是“流畅地参与答题”,这与用户体验相关,每一轮答题时间间隔很短,一旦错过一道题的答题时间,用户就没法完成这个游戏,所以要保证消息下发后,10秒内让用户收到并且正常展示。同时,每一轮答题后,主持人需要立刻看到答题数据,包括有多少用户答对,有多少用户使用了复活卡等。这给我们带来的是对海量数据进行实时下发、实时统计的挑战。 4、答题直播技术方案 我们基于微博直播的技术现状,设计了一个方案。如下图所示,这是我们微博直播互动的架构图。左侧是微博的基础设施服务,基本上都是自研的,比如最核心的短连使用的是自研的 wesync 协议,支持SSL,是支撑百万互动消息下发的核心服务之一。长连维护消息通道可动态扩容,海量用户同时涌入后会进行容量的计算,对我们的资源进行扩缩容。 在直播答题方案设计(下图)中,最核心的就是解决答题信令通道的选择问题。我们想到了三个方案来解决。 方案一:轮询 客户端不断进行请求,由服务端控制时间窗口,到时间我们开放请求,结果返回。优点是实现简单。缺点在于大量无用请求会消耗大量带宽资源,给服务端带来持续性的压力。而且,题目到达时间与音视频流到达时间难以保持一致。 方案二:复用音视频通道 我们可以在音视频流里面直接加入题目的信息。在主持人口令位置插入题目消息。客户端播放音视频流,收到题号数据的时候,直接把题目给展示出来。这个方案的特点就是题目展示的时间能和主持人口令一致,也就是说用户是感知不到时间差的,体验非常好。缺点是太依赖于音视频流,一旦出现网络抖动,或者直播流中断,用户可能会收不到题目,这个游戏就没法继续下去了。 方案三:复用互动通道 直播有音视频流通道和互动通道,题目使用互动通道独立下发。它的特点是题目下发不依赖于音视频流,它的通道是独立的,不受直播流的影响,即使直播中断了,哪怕是黑屏,我们也可以把题目的画面展示给用户。缺点也是一样,因为它并不是跟音视频在一个通道,所以它们两者时间难以保持一致。 我们从接入难度、扩展性和音视频同步三方面,对三个方案进行了对比。针对以上三个方案,我们最终使用方案三。首先要保证答题不受直播流信号的影响。我们现在微博直播现有的架构上能够支持千万级消息的下发,我们把答题信息放到互动通道下发,这是我们有能力支持的。答题和互动的上行消息由短连服务支撑,在发题以及结果展示信息的时候,我们直接通过主动推送,经过广播消息,通过长连最终发给用户。也就是说整个答题就直接采用了互动的通道,与音视频流完全隔离开来。 5、如何解决实时性、可靠性与高并发? 针对实时性、可靠性和高并发,三个典型的问题,我们也有不同的解决方法。 实时性问题主要体现在两方面,一个是答题画面的实时展现,另一个是海量数据的实时统计。 5.1 答题画面的实时展现 直播流经过采编设备发给用户客户端是有延时的,中间经过编解码,到达客户端的时间和主持人发出口令时间,有一个时间间隔。我们采用互动通道的时候,这两个时间我们是不容易做同步的。客户端收到题目和视频流最终到达的时间会出现不一致的情况。 我们看下图,当主持人 T0 时间发题,用户在 T2 时间有可能才收到这个视频流。如果我们 T0 的时间进行发题,在 T1 的时间题目就到用户客户端了。问题在于我们如何抹去 T2-T1 的时间差。对于用户体验来说,我们在 T1 把题目画面展示出来,在下一秒才能听到主持人说“请听题”,这体验肯定不好。 我们的处理方式是在音视频每隔一帧,或者一定帧数内,插入服务器的时间戳。同时,我们在下发的消息体内也埋入服务器的时间戳。客户端播放音视频流的时候,到达相应的时间戳时,把跟这个时间戳相匹配的消息在页面上渲染出来,也就是展示出了答题的画面。通过使用统一时间戳进行对标,就抹平了视频与题目的时间差。 5.2 海量用户数据实时统计 我们每一轮答题结束的时候,都要统计用户的答题状态,比如用户答案是否正确,用户是否复活,以及他是否有复活卡。当这些逻辑都放在一起需要处理,并且又是在一个海量数据场景下时,就变得非常复杂了。 另一方面,每一轮的答题只有发题和展示答案两个指令。主持人在发题时会说题目是什么,最终说出结果是什么。没有单独指令触发告诉服务器端什么时候进行数据处理。而且,海量数据需要得到快速的计算。 把海量用户产生的海量数据一次性的获取出来,这是不现实的,耗费资源相当巨大,所以我们的思路就是化整为零,做并行处理。 首先,当发题指令到达服务端的时候,我们按照一定的规则对用户进行细粒度的拆分。同时根据倒计时和流延时等等时间综合考虑,能够计算出我们什么时候才能开始进行数据处理。然后将刚才做好的用户分片,封装成任务分片,放在延时队列当中。到达这个执行时间的时候,由我们处理机的机群拉取这个任务,只有在执行时间才会去处理这个任务,不会出现用户答案没有提交上来,我们就开始计算了。所以不会有将一部分用户漏掉的状况。 处理机拉到用户的任务分片时,根据用户选择、状态,以及长连的地址,我们对用户的消息整合。因为有海量的用户,所以体量巨大,但是答案选择往往只有 A、B、C、D 四种,针对答案我们可以做一个分组,比如选 A 用户有多少,选 B 用户有多少。我们把单独消息进行合并,选A的用户做为一个集合。 也就是说这一个消息体其实包含了很多用户的消息,从消息体量上,我们进行降量,把小的消息合成成一个消息体,把一个消息体发给我们长连接的服务,长连接收到这个消息体的时候再进行拆分。它类似于消息的一个包,我们把它按照用户的维度进行拆分,比如用户选择了什么答案,它是否使用过复活卡,以及它的状态,进行拆分后,最终下发给用户。这样在前面进行拆,在后面进行合,合完之后再拆一遍,这是我们解决海量数据实时计算的思路。 5.3 海量题目下发的可靠性 刚才我们提到,用户如果在弱网情况下发生丢包,我们推送的消息有可能他没法收到,他一旦收不到消息,整个答题没有办法进行,有可能导致他在这一轮就被淘汰了。我们的解决方案是实现更稳定更快速的自动重连。虽然用户的网络环境是我们没有办法去保证的,但我们可以更快速发现他和我们长连服务器断连,并进行更快速的重连。 同时,在答题倒计时内我们无条件对题目消息进行重传。例如我们 T0 的时候发现用户断连,他在 T1 的时候,下发的题目收不到,然后我们在 T2 进行重连,在 T3 进行无条件重传的时候保证他收到这个题目。我们在消息体埋了一个最迟的展现时间,到这个时间后客户端一定会把题目展示出来,保证他就算直播流断了,我们也可以正常答题。面对黑屏的场景我们也可以完成答题的游戏。 5.4 高并发提交答案 每道题目下发后有一个10秒倒计时。假设有百万用户在线,在10秒之内都可以提交完答案,用户提交答案大概集中在第3至第6秒之间,QPS 峰值预估会有30万。其次,我们保证用户答案在短时间都能提交,因为它是有时间限制的,如果我们做了削峰限流,他就会错过答题的时间窗口。所以我们不能对请求做削峰限流。 我们的解决方案就是用户请求处理快速返回时,把重逻辑往后延,前面上行请求只是保证轻逻辑,让它可以迅速返回。 同时,在资源层,我们对数据进行处理时,把用户提交的请求做一个合并,交给独立的资源池进行批量提交。我们的设计方案有一个阈值,当遇到不可控,比如负荷达到我们设计的阈值时,我们有自动随机重试的机制,保证用户把答案都可以提交上来。对于重试请求我们做针对性的时间补偿,这样保证流量达到我们负载的时候,答题请求也可以提交上来。 5.5 海量消息下发 一条题目消息,会被复制N份后下发给用户。百万用户产生的答案消息是海量的,对于千万级消息实时下发的系统来说,订阅端的网络带宽压力也是巨大的。如下图所示,消息出口的带宽消耗非常大,因为我们是针对海量用户的连接。 我们的解决方法有两方面。第一就是针对海量消息下发,对消息进行体积上的压缩,减少消息传输的冗余。压缩消息的时候我们采用了一个私有协议,我们尽量压缩里面无用的东西,减小传输冗余,减小带宽的消耗。 第二个是消息降量,我们根据用户的答案进行分组,按照分组把这些消息进行合并,由原来的一条消息都要推送一次,转变成下发一个消息集合。同时,我们提升消息的吞吐量,采用中间件的集群,进行多端口并行的下发。 5.6 上线前的保障 直播答题场景有一个特别明显的特征,它不像我们上线其它功能或者接口,我们可以进行灰度放量。直播答题一上线,就是全量,没有能通过灰度放量发现问题的过程。所以我们对系统服务承载能力需要有一个量化的评估。 我们的处理方式就是进行多轮压测和持续的性能优化。 首先我们做开发的时候已经开始同步压测。我们进行一些功能问题修复的时候,压测的同事可以进行做一些单接口的压测,找出接口性能的临界点。开发的同事做优化的同时,压测组模拟海量用户在线的场景,搭建压测的环境。 总体来讲,有四轮压测: 1)单机单接口压测:掌握单机性能数据; 2)单机综合压测:定位性能损耗点,优化业务处理逻辑; 3)负载均衡压测:评估负载均衡数量; 4)集群全链路压测: - a. 搭建起压机测试集群,保证能模拟百万量级用户产生的数据量; - b. 按照预估百万量级用户消耗的公网带宽配置起压机出口带宽,真实模拟线上业务场景; - c. 按照预估用户量和资源消耗量对线上服务及资源集群进行扩容,对线上服务真实压测。 6、本文小结 简单总结一下,针对音画与题目同步的实时性问题,我们将直播流和互动通道进行对标,解决题目与音视频之间的同步问题。 针对海量消息的实时下发问题,我们通过将用户分组,把大体量的消息任务化整为零,做分布式的分批次处理。 针对可靠性的问题,我们通过完善快速自动断连重试机制,以及题目消息无条件重传,来保证弱网下的用户也能正常参与答题活动。 另外,对于高并发问题,我们将消息按照用户选项进行分组,化零为整,降低信息的推送量。同时,我们对消息结构进行了优化,从这两方面解决高并发问题。 最后,还有一个关键的核心,就是压测,通过压测我们可以快速了解上述解决方案是否有效,让我们可以持续优化解决方案。 附录1:更多直播技术文章参考 《浅谈开发实时视频直播平台的技术要点》 《实现延迟低于500毫秒的1080P实时音视频直播的实践分享》 《移动端实时视频直播技术实践:如何做到实时秒开、流畅不卡》 《技术揭秘:支持百万级粉丝互动的Facebook实时视频直播》 《移动端实时音视频直播技术详解(一):开篇》 《移动端实时音视频直播技术详解(二):采集》 《移动端实时音视频直播技术详解(三):处理》 《移动端实时音视频直播技术详解(四):编码和封装》 《移动端实时音视频直播技术详解(五):推流和传输》 《移动端实时音视频直播技术详解(六):延迟优化》 《理论联系实际:实现一个简单地基于HTML5的实时视频直播》 《浅谈实时音视频直播中直接影响用户体验的几项关键技术指标》 《如何优化传输机制来实现实时音视频的超低延迟?》 《首次披露:快手是如何做到百万观众同场看直播仍能秒开且不卡顿的?》 《Android直播入门实践:动手搭建一套简单的直播系统》 《网易云信实时视频直播在TCP数据传输层的一些优化思路》 《P2P技术如何将实时视频直播带宽降低75%?》 《近期大热的实时直播答题系统的实现思路与技术难点分享》 《七牛云技术分享:使用QUIC协议实现实时视频直播0卡顿!》 《实时视频直播客户端技术盘点:Native、HTML5、WebRTC、微信小程序》 《实时音频的混音在视频直播应用中的技术原理和实践总结》 附录2:更多音视频技术文章参考 [1] 开源实时音视频技术WebRTC的文章: 《开源实时音视频技术WebRTC的现状》 《简述开源实时音视频技术WebRTC的优缺点》 《访谈WebRTC标准之父:WebRTC的过去、现在和未来》 《良心分享:WebRTC 零基础开发者教程(中文)[附件下载]》 《WebRTC实时音视频技术的整体架构介绍》 《新手入门:到底什么是WebRTC服务器,以及它是如何联接通话的?》 《WebRTC实时音视频技术基础:基本架构和协议栈》 《浅谈开发实时视频直播平台的技术要点》 《[观点] WebRTC应该选择H.264视频编码的四大理由》 《基于开源WebRTC开发实时音视频靠谱吗?第3方SDK有哪些?》 《开源实时音视频技术WebRTC中RTP/RTCP数据传输协议的应用》 《简述实时音视频聊天中端到端加密(E2EE)的工作原理》 《实时通信RTC技术栈之:视频编解码》 《开源实时音视频技术WebRTC在Windows下的简明编译教程》 《网页端实时音视频技术WebRTC:看起来很美,但离生产应用还有多少坑要填?》 《了不起的WebRTC:生态日趋完善,或将实时音视频技术白菜化》 《腾讯技术分享:微信小程序音视频与WebRTC互通的技术思路和实践》 >> 更多同类文章 …… [2] 实时音视频开发的其它精华资料: 《即时通讯音视频开发(一):视频编解码之理论概述》 《即时通讯音视频开发(二):视频编解码之数字视频介绍》 《即时通讯音视频开发(三):视频编解码之编码基础》 《即时通讯音视频开发(四):视频编解码之预测技术介绍》 《即时通讯音视频开发(五):认识主流视频编码技术H.264》 《即时通讯音视频开发(六):如何开始音频编解码技术的学习》 《即时通讯音视频开发(七):音频基础及编码原理入门》 《即时通讯音视频开发(八):常见的实时语音通讯编码标准》 《即时通讯音视频开发(九):实时语音通讯的回音及回音消除概述》 《即时通讯音视频开发(十):实时语音通讯的回音消除技术详解》 《即时通讯音视频开发(十一):实时语音通讯丢包补偿技术详解》 《即时通讯音视频开发(十二):多人实时音视频聊天架构探讨》 《即时通讯音视频开发(十三):实时视频编码H.264的特点与优势》 《即时通讯音视频开发(十四):实时音视频数据传输协议介绍》 《即时通讯音视频开发(十五):聊聊P2P与实时音视频的应用情况》 《即时通讯音视频开发(十六):移动端实时音视频开发的几个建议》 《即时通讯音视频开发(十七):视频编码H.264、VP8的前世今生》 《实时语音聊天中的音频处理与编码压缩技术简述》 《网易视频云技术分享:音频处理与压缩技术快速入门》 《学习RFC3550:RTP/RTCP实时传输协议基础知识》 《基于RTMP数据传输协议的实时流媒体技术研究(论文全文)》 《声网架构师谈实时音视频云的实现难点(视频采访)》 《还在靠“喂喂喂”测试实时语音通话质量?本文教你科学的评测方法!》 《如何用最简单的方法测试你的实时音视频方案》 《简述实时音视频聊天中端到端加密(E2EE)的工作原理》 《IM实时音视频聊天时的回声消除技术详解》 《如何优化传输机制来实现实时音视频的超低延迟?》 《实时音视频聊天技术分享:面向不可靠网络的抗丢包编解码器》 《专访微信视频技术负责人:微信实时视频聊天技术的演进》 《腾讯音视频实验室:使用AI黑科技实现超低码率的高清实时视频聊天》 《微信团队分享:微信每日亿次实时音视频聊天背后的技术解密》 《福利贴:最全实时音视频开发要用到的开源工程汇总》 《实时音视频聊天中超低延迟架构的思考与技术实践》 《理解实时音视频聊天中的延时问题一篇就够》 《写给小白的实时音视频技术入门提纲》 《微信多媒体团队访谈:音视频开发的学习、微信的音视频技术和挑战等》 《腾讯技术分享:微信小程序音视频技术背后的故事》 《微信多媒体团队梁俊斌访谈:聊一聊我所了解的音视频技术》 《新浪微博技术分享:微博短视频服务的优化实践之路》 《以网游服务端的网络接入层设计为例,理解实时通信的技术挑战》 《腾讯技术分享:微信小程序音视频与WebRTC互通的技术思路和实践》 >> 更多同类文章 …… (本文同步发布于:http://www.52im.net/thread-2022-1-1.html)
本文由腾讯官方知乎账号发布和分享,原文知乎标题:“把 14 亿中国人民都拉到一个微信群里在技术上能实现吗?”。 1、引言 知乎上有一个非常热门的问题:“把 13 亿中国人民都拉到一个微信群里在技术上能实现吗?”(见下图) 听到这个问题,全厂的人都炸了。要知道一个微信群最多只能有500人啊,QQ群也只有2000而已。当你有机会加入一个2000人QQ群的时候,你就已经感受到“信息爆炸”的可怕…… 13亿人的微信群?Are you sure? 然鹅,鹅厂的工程师居然有人跳出来认认真真地做了回答。喏,就是下面这位开发小哥哥,他给出了一个知乎万赞的回答,请好好欣赏他的灵魂作画! 先说结论:也许可以实现,但你会什么都看不见。 学习交流: - 即时通讯开发交流3群:185926912[推荐] - 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》 (本文同步发布于:http://www.52im.net/thread-2017-1-1.html) 2、作为理科男,我们来认真的分析一下 根据2017年《微信数据报告》的公开数据:2017年9月,微信日均登陆9.02亿人,日均发送消息380亿次。 ► 这意味着平均每人每天发送信息42条,如果全国人民(对了,现在全国人口已经接近14亿)在同一个群里说话,这个群每天出现的信息就高达: ► 这么多信息仅仅是匀速发送的话,考虑到大家的睡眠,睡觉的8小时不算,那么手机里每秒要接收的信息就是: 哇塞,每秒超过100万条啊!目前主频最高的手机CPU之一,高通骁龙845有2.8GHz的处理能力,一共是8核。 ► 如不计算安卓系统、显示刷新、网络IO等CPU操作的话,每条信息能分配到的计算能力是: 这是什么概念?全球第一款微处理器是1971年英特尔推出的Intel 4004,这个老古董的主频也有108KHz啊。所以21.9KHz就是啥也干不了。 幸好IT界有个摩尔定律:每18个月CPU性能就能翻倍(或者价钱是一半)。虽然现有科技已经很难让主频提升(某牙膏厂拼命挤也只有5Ghz)。 ► 但假设我们使用了黑科技提升主频。等到了2025摩尔定律失效时,我们的手机CPU主频应该达到: ► 看起来不错嘛,不过每条消息能得到的计算能力将达到: 呵呵,依然没有达到Intel 4004的水平,所以结果就是你等了7年,还是进不了这个全国群抢一个红包。 好吧,咱们让手机接入一个给力点的电脑, 比如说曾经全球超算第一名的太湖之光[参考5],用它的1千万个CPU核心来帮忙处理这个宇宙第一大微信群。算力的问题算是有了着落。 我们假设平均每条消息有10个汉字,这大概相当于30 byte,算上应用层会加上一定的控制字符,再加上TCP/IP网络层的数据消耗大概是74 byte,取个整,平均每条消息有100 byte。 ► 而每个byte 相当于8个bit,所以这时每秒需要的网络带宽大约是: 这时千万不要有人发红包,否则需要的带宽就更大了。 理论上,4G网络能支持1000Mbps,但别忘了,是全国人民在同一个群里,而你周围的人也需要同样的带宽,这使得你附近的基站不堪重负,陷入瘫痪。 为了避免网络瘫痪导致你抢不到红包或者看群消息,你需要搬到一个周围没有人的基站,比如放暑假了全校只有你还没回家的时候。 ► 不过运营商的日子就不好过了,因为这一秒全国上下的流量就达到了惊人的: 这相当于2017 4月份的全国移动数据总流量的65.7%,同时意味着每18秒就能用完全国一年的流量 。运营商瑟瑟发抖.gif 如果把1.146Ebit数据用2TByte 3.5英寸硬盘(20mm高)装起来,然后叠起来,有1433.25m,相比之下,全球最高楼——迪拜的哈利法塔只有区区828m。 当然,如果确实有需要,我相信电信运营商们肯定砸下重金为你建设全世界最大的宽带网络。 不过,接下来该花钱的就不是运营商——而是腾讯了。 为了处理这1.146Ebps 的流量, 腾讯需要准备11466万套交换机和服务器。 ► 目前一台大厂4口万兆交换机售价大约是4000元,一台便宜带万兆口的服务器则大概需要10000元,这两项加起来的费用是: 呃,仅仅这两项就相当于深圳2014年全年的GDP。这里还不包括网线、电线、服务器机架、机房托管、电费、运行支出…… ► 况且,这么多设备的存放也是个问题。一台带万兆(10Gbps)口的2U服务器有88.9mm高,这样叠起来就有: 这差不多是中国到美国的飞机航线距离啊,用来修铁路也是够够的了。 好了,有了这么多设备加持,这下你终于可以愉快地进了群。 但你惊讶地发现,屏幕上除了白色,什么都没有——这是因为你的眼睛没办法接收这么快的数据! 人眼的视觉暂留时间是100-400毫秒,而我们这个群每秒钟就要显示102万条信息,每条消息停留的时间只有大概0.0001毫秒。相比之下,电影、电视都有41毫秒。 因此你还没来得及看清消息,它就已经消失了,最后只留下一团白色的色块在屏幕的正中央。 我的手机着火了,能不能来一下…… 附录:有关QQ、微信的技术故事 《技术往事:微信估值已超5千亿,雷军曾有机会收编张小龙及其Foxmail》 《QQ和微信凶猛成长的背后:腾讯网络基础架构的这些年》 《闲话即时通讯:腾讯的成长史本质就是一部QQ成长史》 《2017微信数据报告:日活跃用户达9亿、日发消息380亿条》 《腾讯开发微信花了多少钱?技术难度真这么大?难在哪?》 《技术往事:创业初期的腾讯——16年前的冬天,谁动了马化腾的代码》 《技术往事:史上最全QQ图标变迁过程,追寻IM巨人的演进历史》 《技术往事:“QQ群”和“微信红包”是怎么来的?》 《开发往事:深度讲述2010到2015,微信一路风雨的背后》 《开发往事:微信千年不变的那张闪屏图片的由来》 《开发往事:记录微信3.0版背后的故事(距微信1.0发布9个月时)》 《一个微信实习生自述:我眼中的微信开发团队》 《首次揭秘:QQ实时视频聊天背后的神秘组织》 《为什么说即时通讯社交APP创业就是一个坑?》 《微信七年回顾:历经多少质疑和差评,才配拥有今天的强大》 《前创始团队成员分享:盘点微信的前世今生——微信成功的必然和偶然》 《即时通讯创业必读:解密微信的产品定位、创新思维、设计法则等》 《QQ的成功,远没有你想象的那么顺利和轻松》 《QQ现状深度剖析:你还认为QQ已经被微信打败了吗?》 《[技术脑洞] 如果把14亿中国人拉到一个微信群里技术上能实现吗?》 >> 更多同类文章 …… (本文同步发布于:http://www.52im.net/thread-2017-1-1.html)
编辑文章 本文原题为“一套高可用群聊消息系统实现”,由作者“于雨氏”授权整理和发布,内容有些许改动,作者博客地址:alexstocks.github.io。应作者要求,如需转载,请联系作者获得授权。 一、引言 要实现一整套能用于大用户量、高并发场景下的IM群聊,技术难度远超IM系统中的其它功能,原因在于:IM群聊消息的实时写扩散特性带来了一系列技术难题。 举个例子:如一个2000人群里,一条普通消息的发出问题,将瞬间写扩散为2000条消息的接收问题,如何保证这些消息的及时、有序、高效地送达,涉及到的技术问题点实在太多,更别说个别场景下万人大群里的炸群消息难题了更别说个别场景下万人大群里的炸群消息难题了。 这也是为什么一般中大型IM系统中,都会将群聊单独拎出来考虑架构的设计,单独有针对性地进行架构优化,从而降低整个系统的设计难度。 本文将分享的是一套生产环境下的IM群聊消息系统的高可用、易伸缩、高并发架构设计实践,属于原创第一手资料,内容较专业,适合有一定IM架构经验的后端程序员阅读。 推荐:如有兴趣,本文作者的另一篇《一套原创分布式即时通讯(IM)系统理论架构方案》,也适合正在进行IM系统架构设计研究的同学阅读。 学习交流: - 即时通讯开发交流3群:185926912[推荐] - 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》 (本文同步发布于:http://www.52im.net/thread-2015-1-1.html) 二、群聊技术文章 《IM群聊消息究竟是存1份(即扩散读)还是存多份(即扩散写)?》 《IM群聊消息的已读回执功能该怎么实现?》 《关于IM即时通讯群聊消息的乱序问题讨论》 《现代IM系统中聊天消息的同步和存储方案探讨》 《移动端IM中大规模群消息的推送如何保证效率、实时性?》 《微信后台团队:微信后台异步消息队列的优化升级实践分享》 《IM群聊消息如此复杂,如何保证不丢不重?》 《IM单聊和群聊中的在线状态同步应该用“推”还是“拉”?》 《如何保证IM实时消息的“时序性”与“一致性”?》 《快速裂变:见证微信强大后台架构从0到1的演进历程(一)》 三、万事开头难:初始的极简实现 所谓的群聊消息系统,就是一种多对多群体聊天方式,譬如直播房间内的聊天室对应的服务器端就是一个群聊消息系统。 2017年9月初,我们初步实现了一套极简的群聊消息系统,其大致架构如下: 系统名词解释: 1)Client : 消息发布者【或者叫做服务端群聊消息系统调用者】,publisher; 2)Proxy : 系统代理,对外统一接口,收集Client发来的消息转发给Broker; 3)Broker :系统消息转发Server,Broker 会根据 Gateway Message 组织一个 RoomGatewayList【key为RoomID,value为 Gateway IP:Port 地址列表】,然后把 Proxy 发来的消息转发到 Room 中所有成员登录的所有 Gateway; 4)Router :用户登录消息转发者,把Gateway转发来的用户登入登出消息转发给所有的Broker; 5)Gateway :所有服务端的入口,接收合法客户端的连接,并把客户端的登录登出消息通过Router转发给所有的Broker; 6)Room Message : Room聊天消息; 7)Gateway Message : Room内某成员 登录 或者 登出 某Gateway消息,包含用户UIN/RoomID/Gateway地址{IP:Port}等消息。 当一个 Room 中多个 Client 连接一个 Gateway 的时候,Broker只会根据 RoomID 把房间内的消息转发一次给这个Gateway,由Gateway再把消息复制多份分别发送给连接这个 Gateway 的 Room 中的所有用户的客户端。 这套系统有如下特点: 1)系统只转发房间内的聊天消息,每个节点收到后立即转发出去,不存储任何房间内的聊天消息,不考虑消息丢失以及消息重复的问题; 2)系统固定地由一个Proxy、三个Broker和一个Router构成; 3)Proxy接收后端发送来的房间消息,然后按照一定的负载均衡算法把消息发往某个Broker,Broker则把消息发送到所有与Room有关系的接口机Gateway; 4)Router接收Gateway转发来的某个Room内某成员在这个Gateway的登出或者登录消息,然后把消息发送到所有Broker; 5)Broker收到Router转发来的Gateway消息后,更新(添加或者删除)与某Room相关的Gateway集合记录; 6)整个系统的通信链路采用UDP通信方式。 从以上特点,整个消息系统足够简单,没有考虑扩缩容问题,当系统负载到达极限的时候,就重新再部署一套系统以应对后端client的消息压力。 这种处理方式本质是把系统的扩容能力甩锅给了后端Client以及前端Gateway:每次扩容一个系统,所有Client需要在本地配置文件中添加一个Proxy地址然后全部重启,所有Gateway则需要再本地配置文件添加一个Router地址然后全部重启。 这种“幸福我一人,辛苦千万家”的扩容应对方式,必然导致公司内部这套系统的使用者怨声载道,下一阶段的升级就是必然的了。 四、进一步重点设计:“可扩展性” 4.1、基本思路 大道之行也,天下为公,不同的系统有不同的构架,相同的系统总有类似的实现。类似于数据库的分库分表【关于分库分表,目前看到的最好的文章是《一种支持自由规划无须数据迁移和修改路由代码的Replicaing扩容方案》】,其扩展实现核心思想是分Partition分Replica,但各Replica之间还区分leader(leader-follower,只有leader可接受写请求)和non-leader(所有replica均可接收写请求)两种机制。 从数据角度来看,这套系统接收两种消息:Room Message(房间聊天消息)和Gateway Message(用户登录消息)。两种消息的交汇之地就是Broker,所以应对扩展的紧要地方就是Broker,Broker的每个Partition采用non-leader机制,各replica均可接收Gateway Message消息写请求和Room Message转发请求。 首先,当Room Message量加大时可以对Proxy进行水平扩展,多部署Proxy即可因应Room Message的流量。 其次,当Gateway Message量加大时可以对Router进行水平扩展,多部署Router即可因应Gateway Message的流量。 最后,两种消息的交汇之地Broker如何扩展呢?可以把若干Broker Replica组成一个Partition,因为Gateway Message是在一个Partition内广播的,所有Broker Replica都会有相同的RoomGatewayList 数据,因此当Gateway Message增加时扩容Partition即可。当Room Message量增加时,水平扩容Partition内的Broker Replica即可,因为Room Message只会发送到Partition内某个Replica上。 从个人经验来看,Room ID的增长以及Room内成员的增加量在一段时间内可以认为是直线增加,而Room Message可能会以指数级增长,所以若设计得当则Partition扩容的概率很小,而Partition内Replica水平增长的概率几乎是100%。 不管是Partition级别的水平扩容还是Partition Replica级别的水平扩容,不可能像系统极简版本那样每次扩容后都需要Client或者Gateway去更新配置文件然后重启,因应之道就是可用zookeeper充当角色的Registriy。通过这个zookeeper注册中心,相关角色扩容的时候在Registry注册后,与之相关的其他模块得到通知即可获取其地址等信息。采用zookeeper作为Registry的时候,所以程序实现的时候采用实时watch和定时轮询的策略保证数据可靠性,因为一旦网络有任何的抖动,zk就会认为客户端已经宕机把链接关闭。 分析完毕,与之相对的架构图如下: 以下各分章节将描述各个模块详细流程。 4.2、Client Client详细流程如下: 1)从配置文件加载Registry地址; 2)从Registy上Proxy注册路径/pubsub/proxy下获取所有的Proxy,依据各个Proxy ID大小顺序递增组成一个ProxyArray; 3)启动一个线程实时关注Registry路径/pubsub/proxy,以获取Proxy的动态变化,及时更新ProxyArray; 4)启动一个线程定时轮询获取Registry路径/pubsub/proxy下各个Proxy实例,作为关注策略的补充,以期本地ProxyArray内各个Proxy成员与Registry上的各个Proxy保持一致;定时给各个Proxy发送心跳,异步获取心跳回包;定时清除ProxyArray中心跳超时的Proxy成员; 5)发送消息的时候采用snowflake算法给每个消息分配一个MessageID,然后采用相关负载均衡算法把消息转发给某个Proxy。 4.3、Proxy Proxy详细流程如下: 1)读取配置文件,获取Registry地址; 2)把自身信息注册到Registry路径/pubsub/proxy下,把Registry返回的ReplicaID作为自身ID; 3)从Registry路径/pubsub/broker/partition(x)下获取每个Broker Partition的各个replica; 4)从Registry路径/pubsub/broker/partition_num获取当前有效的Broker Partition Number; 5)启动一个线程关注Registry上的Broker路径/pubsub/broker,以实时获取以下信息: {Broker Partition Number} - 新的Broker Partition(此时发生了扩容); - Broker Partition内新的broker replica(Partition内发生了replica扩容); - Broker Parition内某replica挂掉的信息; 6)定时向各个Broker Partition replica发送心跳,异步等待Broker返回的心跳响应包,以探测其活性,以保证不向超时的replica转发Room Message; 7)启动一个线程定时读取Registry上的Broker路径/pubsub/broker下各个子节点的值,以定时轮询的策略观察Broker Partition Number变动,以及各Partition的变动情况,作为实时策略的补充;同时定时检查心跳包超时的Broker,从有效的BrokerList中删除; 8)依据规则【BrokerPartitionID = RoomID % BrokerPartitionNum, BrokerReplicaID = RoomID % BrokerPartitionReplicaNum】向某个Partition的replica转发Room Message,收到Client的Heatbeat包时要及时给予响应。 之所以把Room Message和Heartbeat Message放在一个线程处理,是为了防止进程假死这种情况。 当/pubsub/broker/partition_num的值发生改变的时候(譬如值改为4),意味着Router Partition进行了扩展,Proxy要及时获取新Partition路径(如/pubsub/broker/Partition2和/pubsub/broker/Partition3)下的实例,并关注这些路径,获取新Partition下的实例。 之所以Proxy在获取Registry下所有当前的Broker实例信息后再注册自身信息,是因为此时它才具有转发消息的资格。 Proxy转发某个Room消息时候,只发送给处于Running状态的Broker。为Broker Partition内所有replica依据Registry给其分配的replicaID进行递增排序,组成一个Broker Partition Replica Array,规则中BrokerPartitionReplicaNum为Array的size,而BrokerReplicaID为replica在Array中的下标。 4.4、Pipeline 收到的 Room Message 需要做三部工作:收取 Room Message、消息协议转换和向 Broker 发送消息。 初始系统这三步流程如果均放在一个线程内处理,proxy 的整体吞吐率只有 50 000 Msg/s。 最后的实现方式是按照消息处理的三个步骤以 pipeline 方式做如下流程处理: 1)启动 1 个消息接收线程和 N【N == Broker Parition 数目】个多写一读形式的无锁队列【称之为消息协议转换队列】,消息接收线程分别启动一个 epoll 循环流程收取消息,然后把消息以相应的 hash 算法【队列ID = UIN % N】写入对应的消息协议转换队列; 2)启动 N 个线程 和 N * 3 个一写一读的无锁队列【称之为消息发送队列】,每个消息协议专家线程从消息协议转换队列接收到消息并进行协议转换后,根据相应的 hash 算法【队列ID = UIN % 3N】写入消息发送队列; 3)启动 3N 个消息发送线程,分别创建与之对应的 Broker 的连接,每个线程单独从对应的某个消息发送队列接收消息然后发送出去。 经过以上流水线改造后,Proxy 的整体吞吐率可达 200 000 Msg/s。 关于 pipeline 自身的解释,本文不做详述,可以参考下图: 4.5、大房间消息处理 每个 Room 的人数不均,最简便的解决方法就是给不同人数量级的 Room 各搭建一套消息系统,不用修改任何代码。 然所谓需求推动架构改进,在系统迭代升级过程中遇到了这样一个需求:业务方有一个全国 Room,用于给所有在线用户进行消息推送。针对这个需求,不可能为了一个这样的 Room 单独搭建一套系统,况且这个 Room 的消息量很少。 如果把这个 Room 的消息直接发送给现有系统,它有可能影响其他 Room 的消息发送:消息系统是一个写放大的系统,全国 Room 内有系统所有的在线用户,每次发送都会卡顿其他 Room 的消息发送。 最终的解决方案是:使用类似于分区的方法,把这样的大 Room 映射为 64 个虚拟 Room【称之为 VRoom】。在 Room 号段分配业务线的配合下,给消息系统专门保留了一个号段,用于这种大 Room 的切分,在 Proxy 层依据一个 hash 方法 【 VRoomID = UserID % 64】 把每个 User 分配到相应的 VRoom,其他模块代码不用修改即完成了大 Room 消息的路由。 4.6、Broker Broker详细流程如下: 1)Broker加载配置,获取自身所在Partition的ID(假设为3); 2)向Registry路径/pubsub/broker/partition3注册,设置其状态为Init,注册中心返回的ID作为自身的ID(replicaID); 3)接收Router转发来的Gateway Message,放入GatewayMessageQueue; 4)从Database加载数据,把自身所在的Broker Partition所应该负责的 RoomGatewayList 数据加载进来; 5)异步处理GatewayMessageQueue内的Gateway Message,只处理满足规则【PartitionID == RoomID % PartitionNum】的消息,把数据存入本地路由信息缓存; 6)修改Registry路径/pubsub/broker/partition3下自身节点的状态为Running; 7)启动线程实时关注Registry路径/pubsub/broker/partition_num的值; 8)启动线程定时查询Registry路径/pubsub/broker/partition_num的值; 9)当Registry路径/pubsub/broker/partition_num的值发生改变的时候,依据规则【PartitionID == RoomID % PartitionNum】清洗本地路由信息缓存中每条数据; 10)接收Proxy发来的Room Message,依据RoomID从路由信息缓存中查找Room有成员登陆的所有Gateway,把消息转发给这些Gateway。 注意Broker之所以先注册然后再加载Database中的数据,是为了在加载数据的时候同时接收Router转发来的Gateway Message,但是在数据加载完前这些受到的数据先被缓存起来,待所有 RoomGatewayList 数据加载完后就把这些数据重放一遍; Broker之所以区分状态,是为了在加载完毕 RoomGatewayList 数据前不对Proxy提供转发消息的服务,同时也方便Broker Partition应对的消息量增大时进行水平扩展。 当Broker发生Partition扩展的时候,新的Partition个数必须是2的幂,只有新Partition内所有Broker Replica都加载实例完毕,再更改/pubsub/broker/partition_num的值。 老的Broker也要watch路径/pubsub/broker/partition_num的值,当这个值增加的时候,它也需要清洗本地的路由信息缓存。 Broker的扩容过程犹如细胞分裂,形成中的两个细胞有着完全相同的数据,分裂完成后【Registry路径/pubsub/broker/partition_num的值翻倍】则需要清洗垃圾信息。这种方法称为翻倍法。 4.7、Router Router详细流程如下: 1)Router加载配置,Registry地址; 2)把自身信息注册到Registry路径/pubsub/router下,把Registry返回的ReplicaID作为自身ID; 3)从Registry路径/pubsub/broker/partition(x)下获取每个Broker Partition的各个replica; 4)从Registry路径/pubsub/broker/partition_num获取当前有效的Broker Partition Number; 5)启动一个线程关注Registry上的Broker路径/pubsub/broker,以实时获取以下信息: {Broker Partition Number} - 新的Broker Partition(此时发生了扩容); - Broker Partition内新的broker replica(Partition内发生了replica扩容); - Broker Parition内某replica挂掉的信息; 6)定时向各个Broker Partition replica发送心跳,异步等待Broker返回的心跳响应包,以探测其活性,以保证不向超时的replica转发Gateway Message; 7)启动一个线程定时读取Registry上的Broker路径/pubsub/broker下各个子节点的值,以定时轮询的策略观察Broker Partition Number变动,以及各Partition的变动情况,作为实时策略的补充;同时定时检查心跳包超时的Broker,从有效的BrokerList中删除; 8)从Database全量加载路由 RoomGatewayList 数据放入本地缓存; 9)收取Gateway发来的心跳消息,及时返回ack包; 10)收取Gateway转发来的Gateway Message,按照一定规则【BrokerPartitionID % BrokerPartitionNum = RoomID % BrokerPartitionNum】转发给某个Broker Partition下所有Broker Replica,保证Partition下所有replica拥有同样的路由 RoomGatewayList 数据,再把Message内数据存入本地缓存,当检测到数据不重复的时候把数据异步写入Database。 4.8、Gateway Gateway详细流程如下: 1)读取配置文件,加载Registry地址; 2)从Registry路径/pubsub/router/下获取所有router replica,依据各Replica的ID递增排序组成replica数组RouterArray; 3)启动一个线程实时关注Registry路径/pubsub/router,以获取Router的动态变化,及时更新RouterArray; 4)启动一个线程定时轮询获取Registry路径/pubsub/router下各个Router实例,作为关注策略的补充,以期本地RouterArray及时更新;定时给各个Router发送心跳,异步获取心跳回包;定时清除RouterArray中心跳超时的Router成员; 5)当有Room内某成员客户端连接上来或者Room内所有成员都不连接当前Gateway节点时,依据规则【RouterArrayIndex = RoomID % RouterNum】向某个Router发送Gateway Message; 6)收到Broker转发来的Room Message时,根据MessageID进行去重,如果不重复则把消息发送到连接到当前Gateway的Room内所有客户端,同时把MessageID缓存起来以用于去重判断。 Gateway本地有一个基于共享内存的LRU Cache,存储最近一段时间发送的消息的MessageID。 五、接下来迫切要解决的:系统稳定性 系统具有了可扩展性仅仅是系统可用的初步,整个系统要保证最低粒度的SLA(0.99),就必须在两个维度对系统的可靠性就行感知:消息延迟和系统内部组件的高可用。 5.1、消息延迟 准确的消息延迟的统计,通用的做法可以基于日志系统对系统所有消息或者以一定概率抽样后进行统计,但限于人力目前没有这样做。 目前使用了一个方法:通过一种构造一组伪用户ID,定时地把消息发送给proxy,每条消息经过一层就把在这层的进入时间和发出时间以及组件自身的一些信息填入消息,这组伪用户的消息最终会被发送到一个伪Gateway端,伪Gateway对这些消息的信息进行归并统计后,即可计算出当前系统的平均消息延迟时间。 通过所有消息的平均延迟可以评估系统的整体性能。同时,因为系统消息路由的哈希方式已知,当固定时间内伪Gateway没有收到消息时,就把消息当做发送失败,当某条链路失败一定次数后就可以产生告警了。 5.2、高可用 上面的方法同时能够检测某个链路是否出问题,但是链路具体出问题的点无法判断,且实时性无法保证。 为了保证各个组件的高可用,系统引入了另一种评估方法:每个层次都给后端组件发送心跳包,通过心跳包的延迟和成功率判断其下一级组件的当前的可用状态。 譬如proxy定时给每个Partition内每个broker发送心跳,可以依据心跳的成功率来快速判断broker是否处于“假死”状态(最近业务就遇到过broker进程还活着,但是对任何收到的消息都不处理的情况)。 同时依靠心跳包的延迟还可以判断broker的处理能力,基于此延迟值可在同一Partition内多broker端进行负载均衡。 六、进一步优化:消息可靠性 公司内部内部原有一个走tcp通道的群聊消息系统,但是经过元旦一次大事故(几乎全线崩溃)后,相关业务的一些重要消息改走这套基于UDP的群聊消息系统了。这些消息如服务端下达给客户端的游戏动作指令,是不允许丢失的,但其特点是相对于聊天消息来说量非常小(单人1秒最多一个),所以需要在目前UDP链路传递消息的基础之上再构建一个可靠消息链路。 国内某IM大厂的消息系统也是以UDP链路为基础的(见《为什么QQ用的是UDP协议而不是TCP协议?》),他们的做法是消息重试加ack构建了可靠消息稳定传输链路。但是这种做法会降低系统的吞吐率,所以需要独辟蹊径。 UDP通信的本质就是伪装的IP通信,TCP自身的稳定性无非是重传、去重和ack,所以不考虑消息顺序性的情况下可以通过重传与去重来保证消息的可靠性。 基于目前系统的可靠消息传输流程如下: 1)Client给每个命令消息依据snowflake算法配置一个ID,复制三份,立即发送给不同的Proxy; 2)Proxy收到命令消息以后随机发送给一个Broker; 3)Broker收到后传输给Gateway; 4)Gateway接收到命令消息后根据消息ID进行重复判断,如果重复则丢弃,否则就发送给APP,并缓存之。 正常的消息在群聊消息系统中传输时,Proxy会根据消息的Room ID传递给固定的Broker,以保证消息的有序性。 七、Router需要进一步强化 7.1、简述 当线上需要部署多套群聊消息系统的时候,Gateway需要把同样的Room Message复制多份转发给多套群聊消息系统,会增大Gateway压力,可以把Router单独独立部署,然后把Room Message向所有的群聊消息系统转发。 Router系统原有流程是:Gateway按照Room ID把消息转发给某个Router,然后Router把消息转发给下游Broker实例。新部署一套群聊消息系统的时候,新系统Broker的schema需要通过一套约定机制通知Router,使得Router自身逻辑过于复杂。 重构后的Router架构参照上图,也采用分Partition分Replica设计,Partition内部各Replica之间采用non-leader机制;各Router Replica不会主动把Gateway Message内容push给各Broker,而是各Broker主动通过心跳包形式向Router Partition内某个Replica注册,而后此Replica才会把消息转发到这个Broker上。 类似于Broker,Router Partition也以2倍扩容方式进行Partition水平扩展,并通过一定机制保证扩容或者Partition内部各个实例停止运行或者新启动时,尽力保证数据的一致性。 Router Replica收到Gateway Message后,replica先把Gateway Message转发给Partition内各个peer replica,然后再转发给各个订阅者。Router转发消息的同时异步把消息数据写入Database。 独立Router架构下,下面小节将分别详述Gateway、Router和Broker三个相关模块的详细流程。 7.2、Gateway Gateway详细流程如下: 1)从Registry路径/pubsub/router/partition(x)下获取每个Partition的各个replica; 2)从Registry路径/pubsub/router/partition_num获取当前有效的Router Partition Number; 3)启动一个线程关注Registry上的Router路径/pubsub/router,以实时获取以下信息:{Router Partition Number} -> 新的Router Partition(此时发生了扩容); Partition内新的replica(Partition内发生了replica扩容); Parition内某replica挂掉的信息; 4)定时向各个Partition replica发送心跳,异步等待Router返回的心跳响应包,以探测其活性,以保证不向超时的replica转发Gateway Message; 5)启动一个线程定时读取Registry上的Router路径/pubsub/router下各个子节点的值,以定时轮询的策略观察Router Partition Number变动,以及各Partition的变动情况,作为实时策略的补充;同时定时检查心跳包超时的Router,从有效的BrokerList中删除; 6 依据规则向某个Partition的replica转发Gateway Message。 第六步的规则决定了Gateway Message的目的Partition和replica,规则内容有: 如果某Router Partition ID满足condition(RoomID % RouterPartitionNumber == RouterPartitionID % RouterPartitionNumber),则把消息转发到此Partition; 这里之所以不采用直接hash方式(RouterPartitionID = RoomID % RouterPartitionNumber)获取Router Partition,是考虑到当Router进行2倍扩容的时候当所有新的Partition的所有Replica都启动完毕且数据一致时才会修改Registry路径/pubsub/router/partitionnum的值,按照规则的计算公式才能保证新Partition的各个Replica在启动过程中就可以得到Gateway Message,也即此时每个Gateway Message会被发送到两个Router Partition。 当Router扩容完毕,修改Registry路径/pubsub/router/partitionnum的值后,此时新集群进入稳定期,每个Gateway Message只会被发送固定的一个Partition,condition(RoomID % RouterPartitionNumber == RouterPartitionID % RouterPartitionNumber)等效于condition(RouterPartitionID = RoomID % RouterPartitionNumber)。 如果Router Partition内某replia满足condition(replicaPartitionID = RoomID % RouterPartitionReplicaNumber),则把消息转发到此replica。 replica向Registry注册的时候得到的ID称之为replicaID,Router Parition内所有replica按照replicaID递增排序组成replica数组RouterPartitionReplicaArray,replicaPartitionID即为replica在数组中的下标。 Gateway Message数据一致性: Gateway向Router发送的Router Message内容有两种:某user在当前Gateway上进入某Room 和 某user在当前Gateway上退出某Room,数据项分别是UIN(用户ID)、Room ID、Gateway Addr和User Action(Login or Logout。 由于所有消息都是走UDP链路进行转发,则这些消息的顺序就有可能乱序。Gateway可以统一给其发出的所有消息分配一个全局递增的ID【下文称为GatewayMsgID,Gateway Message ID】以保证消息的唯一性和全局有序性。 Gateway向Registry注册临时有序节点时,Registry会给Gateway分配一个ID,Gateway可以用这个ID作为自身的Instance ID【假设这个ID上限是65535】。 GatewayMsgID字长是64bit,其格式如下: //63 -------------------------- 48 47 -------------- 38 37 ------------ 0 //| 16bit Gateway Instance ID | 10bit Reserve | 38bit自增码 | 7.3、Router Router系统部署之前,先设置Registry路径/pubsub/router/partition_num的值为1。 Router详细流程如下: 1)Router加载配置,获取自身所在Partition的ID(假设为3); 2)向Registry路径/pubsub/router/partition3注册,设置其状态为Init,注册中心返回的ID作为自身的ID(replicaID); 3)注册完毕会收到Gateway发来的Gateway Message以及Broker发来的心跳消息(HeartBeat Message),先缓存到消息队列MessageQueue; 4)从Registry路径/pubsub/router/partition3下获取自身所在的Partition内的各个replica; 5)从Registry路径/pubsub/router/partition_num获取当前有效的Router Partition Number; 6)启动一个线程关注Registry路径/pubsub/router,以实时获取以下信息:{Router Partition Number} -> Partition内新的replica(Partition内发生了replica扩容); Parition内某replica挂掉的信息; 7)从Database加载数据; 8)启动一个线程异步处理MessageQueue内的Gateway Message,把Gateway Message转发给同Partition内其他peer replica,然后依据规则【RoomID % BrokerPartitionNumber == BrokerReplicaPartitionID % BrokerPartitionNumber】转发给BrokerList内每个Broker;处理Broker发来的心跳包,把Broker的信息存入本地BrokerList,然后给Broker发送回包; 9)修改Registry路径/pubsub/router/partition3下节点的状态为Running; 10)启动一个线程定时读取Registry路径/pubsub/router下各个子路径的值,以定时轮询的策略观察Router各Partition的变动情况,作为实时策略的补充;检查超时的Broker,把其从BrokerList中剔除; 11)当RouterPartitionNum倍增时,Router依据规则【RoomID % BrokerPartitionNumber == BrokerReplicaPartitionID % BrokerPartitionNumber】清洗自身路由信息缓存中数据; 12)Router本地存储每个Gateway的最大GatewayMsgID,收到小于GatewayMsgID的Gateway Message可以丢弃不处理,否则就更新GatewayMsgID并根据上面逻辑进行处理。 之所以把Gateway Message和Heartbeat Message放在一个线程处理,是为了防止进程假死这种情况。 Broker也采用了分Partition分Replica机制,所以向Broker转发Gateway Message时候路由规则,与Gateway向Router转发消息的路由规则相同。 另外启动一个工具,当水平扩展后新启动的Partition内所有Replica的状态都是Running的时候,修改Registry路径/pubsub/router/partition_num的值为所有Partition的数目。 7.4、Broker Broker详细流程如下: 1)Broker加载配置,获取自身所在Partition的ID(假设为3); 2)向Registry路径/pubsub/broker/partition3注册,设置其状态为Init,注册中心返回的ID作为自身的ID(replicaID); 3)从Registry路径/pubsub/router/partition_num获取当前有效的Router Partition Number; 4)从Registry路径/pubsub/router/partition(x)下获取每个Router Partition的各个replica; 5)启动一个线程关注Registry路径/pubsub/router,以实时获取以下信息:{Router Partition Number} -> 新的Router Partition(此时发生了扩容); Partition内新的replica(Partition内发生了replica扩容); Parition内某replica挂掉的信息; 6)依据规则【RouterPartitionID % BrokerPartitionNum == BrokerPartitionID % BrokerPartitionNum,RouterReplicaID = BrokerReplicaID % BrokerPartitionNum】选定目标Router Partition下某个Router replica,向其发送心跳消息,包含BrokerPartitionNum、BrokerPartitionID、BrokerHostAddr和精确到秒级的Timestamp,并异步等待所有Router replica的回复,所有Router转发来的Gateway Message放入GatewayMessageQueue; 7)依据规则【BrokerPartitionID == RoomID % BrokerParitionNum】从Database加载数据; 8)依据规则【BrokerPartitionID % BrokerParitionNum == RoomID % BrokerParitionNum】异步处理GatewayMessageQueue内的Gateway Message,只留下合乎规则的消息的数据; 9)修改Registry路径/pubsub/broker/partition3下自身节点的状态为Running; 10)启动一个线程定时读取Registry路径/pubsub/router下各个子路径的值,以定时轮询的策略观察Router各Partition的变动情况,作为实时策略的补充;定时检查超时的Router,某Router超时后更换其所在的Partition内其他Router替换之,定时发送心跳包; 11)当Registry路径/pubsub/broker/partition_num的值BrokerPartitionNum发生改变的时候,依据规则【PartitionID == RoomID % PartitionNum】清洗本地路由信息缓存中每条数据; 12)接收Proxy发来的Room Message,依据RoomID从路由信息缓存中查找Room有成员登陆的所有Gateway,把消息转发给这些Gateway; 13)Broker本地存储每个Gateway的最大GatewayMsgID,收到小于GatewayMsgID的Gateway Message可以丢弃不处理,否则更新GatewayMsgID并根据上面逻辑进行处理。 BrokerPartitionNumber可以小于或者等于或者大于RouterPartitionNumber,两个数应该均是2的幂,两个集群可以分别进行扩展,互不影响。譬如BrokerPartitionNumber=4而RouterPartitionNumber=2,则Broker Partition 3只需要向Router Partition 1的某个follower发送心跳消息即可;若BrokerPartitionNumber=4而RouterPartitionNumber=8,则Broker Partition 3需要向Router Partition 3的某个follower发送心跳消息的同时,还需要向Router Partition 7的某个follower发送心跳,以获取全量的Gateway Message。 Broker需要关注/pubsub/router/partitionnum和/pubsub/broker/partitionnum的值的变化,当router或者broker进行parition水平扩展的时候,Broker需要及时重新构建与Router之间的对应关系,及时变动发送心跳的Router Replica对象【RouterPartitionID = BrokerReplicaID % RouterPartitionNum,RouterPartitionID为Router Replica在PartitionRouterReplicaArray数组的下标】。 当Router Partition内replica死掉或者发送心跳包的replica对象死掉(无论是注册中心通知还是心跳包超时),broker要及时变动发送心跳的Router replica对象。 另外,Gateway使用UDP通信方式向Router发送Gateway Message,如若这个Message丢失则此Gateway上该Room内所有成员一段时间内(当有新的成员在当前Gateway上加入Room 时会产生新的Gateway Message)都无法再接收消息,为了保证消息的可靠性,可以使用这样一个约束解决问题:在此Gateway上登录的某Room内的人数少于3时,Gateway会把Gateway Message复制两份非连续(如以10ms为时间间隔)重复发送给某个Partition leader。因Gateway Message消息处理的幂等性,重复Gateway Message并不会导致Room Message发送错误,只在极少概率的情况下会导致Gateway收到消息的时候Room内已经没有成员在此Gateway登录,此时Gateway会把消息丢弃不作处理。 传递实时消息群聊消息系统的Broker向特定Gateway转发Room Message的时候,会带上Room内在此Gateway上登录的用户列表,Gateway根据这个用户列表下发消息时如果检测到此用户已经下线,在放弃向此用户转发消息的同时,还应该把此用户已经下线的消息发送给Router,当Router把这个消息转发给Broker后,Broker把此用户从用户列表中剔除。通过这种负反馈机制保证用户状态更新的及时性。 八、离线消息的处理 8.1、简述 前期的系统只考虑了用户在线情况下实时消息的传递,当用户离线时其消息便无法获取。 若系统考虑用户离线消息传递,需要考虑如下因素: 1)消息固化:保证用户上线时收到其离线期间的消息; 2)消息有序:离线消息和在线消息都在一个消息系统传递,给每个消息分配一个ID以区分消息先后顺序,消息顺序越靠后则ID愈大。 离线消息的存储和传输,需要考虑用户的状态以及每条消息的发送状态,整个消息核心链路流程会有大的重构。 新消息架构如下图: 系统名词解释: 1)Pi : 消息ID存储模块,存储每个人未发送的消息ID有序递增集合; 2)Xiu : 消息存储KV模块,存储每个人的消息,给每个消息分配ID,以ID为key,以消息内为value; 3)Gateway Message(HB) : 用户登录登出消息,包括APP保活定时心跳(Hearbeat)消息。 系统内部代号貔貅(貔貅者,雄貔雌貅),源自上面两个新模块。 这个版本架构流程的核心思想为“消息ID与消息内容分离,消息与用户状态分离”。消息发送流程涉及到模块 Client/Proxy/Pi/Xiu,消息推送流程则涉及到模块 Pi/Xiu/Broker/Router/Gateway。 下面小节先细述Pi和Xiu的接口,然后再详述发送和推送流程。 8.2、Xiu Xiu模块功能名称是Message Storage,用户缓存和固化消息,并给消息分配ID。Xiu 集群采用分 Partition 分 Replica 机制,Partition 初始数目须是2的倍数,集群扩容时采用翻倍法。 8.2.1 存储消息 存储消息请求的参数列表为{SnowflakeID,UIN, Message},其流程如下: 1)接收客户端发来的消息,获取消息接收人ID(UIN)和客户端给消息分配的 SnowflakeID; 2)检查 UIN % Xiu_Partition_Num == Xiu_Partition_ID % Xiu_Partition_Num 添加是否成立【即接收人的消息是否应当由当前Xiu负责】,不成立则返回错误并退出; 3)检查 SnowflakeID 对应的消息是否已经被存储过,若已经存储过则返回其对应的消息ID然后退出; 4)给消息分配一个 MsgID: 每个Xiu有自己唯一的 Xiu_Partition_ID,以及一个初始值为 0 的 Partition_Msg_ID。MsgID = 1B[ Xiu_Partition_ID ] + 1B[ Message Type ] + 6B[ ++ Partition_Msg_ID ]。每次分配的时候 Partition_Msg_ID 都自增加一。 5)以 MsgID 为 key 把消息存入基于共享内存的 Hashtable,并存入消息的 CRC32 hash值和插入时间,把 MsgID 存入一个 LRU list 中: LRU List 自身并不存入共享内存中,当进程重启时,可以根据Hashtable中的数据重构出这个List。把消息存入 Hashtable 中时,如果 Hashtable full,则依据 LRU List 对Hashtable 中的消息进行淘汰。 6)把MsgID返回给客户端; 7)把MsgID异步通知给消息固化线程,消息固化线程根据MsgID从Hashtable中读取消息并根据CRC32 hash值判断消息内容是否完整,完整则把消息存入本地RocksDB中。 8.2.2读取消息 读取消息请求的参数列表为{UIN, MsgIDList},其流程为: 1)获取请求的 MsgIDList,判断每个MsgID MsgID{Xiu_Partition_ID} == Xiu_Partition_ID 条件是否成立,不成立则返回错误并退出; 2)从 Hashtable 中获取每个 MsgID 对应的消息; 3)如果 Hashtable 中不存在,则从 RocksDB 中读取 MsgID 对应的消息; 4)读取完毕则把所有获取的消息返回给客户端。 8.2.3主从数据同步 目前从简,暂定Xiu的副本只有一个。 Xiu节点启动的时候根据自身配置文件中分配的 Xiu_Partition_ID 到Registry路径 /pubsub/xiu/partition_id 下进行注册一个临时有序节点,注册成功则Registry会返回Xiu的节点 ID。 Xiu节点获取 /pubsub/xiu/partition_id 下的所有节点的ID和地址信息,依据 节点ID最小者为leader 的原则,即可判定自己的角色。只有leader可接受读写数据请求。 数据同步流程如下: 1)follower定时向leader发送心跳信息,心跳信息包含本地最新消息的ID; 2)leader启动一个数据同步线程处理follower的心跳信息,leader的数据同步线程从LRU list中查找 follower_latest_msg_id 之后的N条消息的ID,若获取到则读取消息并同步给follower,获取不到则回复其与leader之间消息差距太大; 3)follower从leader获取到最新一批消息,则存储之; 4)follower若获取leader的消息差距太大响应,则请求leader的agent把RocksDB的固化数据全量同步过来,整理完毕后再次启动与leader之间的数据同步流程。 follower会关注Registry路径 /pubsub/xiu/partition_id 下所有所有节点的变化情况,如果leader挂掉则及时转换身份并接受客户端请求。如果follower 与 leader 之间的心跳超时,则follower删掉 leader 的 Registry 路径节点,及时进行身份转换处理客户端请求。 当leader重启或者follower转换为leader的时候,需要把 Partition_Msg_ID 进行一个大数值增值(譬如增加1000)以防止可能的消息ID乱序情况。 8.2.4集群扩容 Xiu 集群扩容采用翻倍法,扩容时新 Partition 的节点启动后工作流程如下: 1)向Registry的路径 /pubsub/xiu/partition_id 下自己的 node 的 state 为 running,同时注册自己的对外服务地址信息; 2)另外启动一个工具,当水平扩展后所有新启动的 Partition 内所有 Replica 的状态都是 Running 的时候,修改 Registry 路径 /pubsub/xiu/partition_num 的值为扩容后 Partition 的数目。按照开头的例子,即由2升级为4。 之所以 Xiu 不用像 Broker 和 Router 那样启动的时候向老的 Partition 同步数据,是因为每个 Xiu 分配的 MsgID 中已经带有 Xiu 的 PartitionID 信息,即使集群扩容这个 ID 也不变,根据这个ID也可以定位到其所在的Partition,而不是借助 hash 方法。 8.3、Pi Pi 模块功能名称是 Message ID Storage,存储每个用户的 MsgID List。Xiu 集群也采用分 Partition 分 Replica 机制,Partition 初始数目须是2的倍数,集群扩容时采用翻倍法。 8.3.1存储消息ID MsgID 存储的请求参数列表为{UIN,MsgID},Pi 工作流程如下: 1)判断条件 UIN % Pi_Partition_Num == Pi_Partition_ID % Pi_Partition_Num 是否成立,若不成立则返回error退出; 2)把 MsgID 插入UIN的 MsgIDList 中,保持 MsgIDList 中所有 MsgID 不重复有序递增,把请求内容写入本地log,给请求者返回成功响应。 Pi有专门的日志记录线程,给每个日志操作分配一个 LogID,每个 Log 文件记录一定量的写操作,当文件 size 超过配置的上限后删除之。 8.3.2读取消息ID列表 读取请求参数列表为{UIN, StartMsgID, MsgIDNum, ExpireFlag},其意义为获取用户 UIN 自起始ID为 StartMsgID 起(不包括 StartMsgID )的数目为 MsgIDNum 的消息ID列表,ExpireFlag意思是 所有小于等于 StartMsgID 的消息ID是否删除。 流程如下: 1)判断条件 UIN % Pi_Partition_Num == Pi_Partition_ID % Pi_Partition_Num 是否成立,若不成立则返回error退出; 2)获取 (StartID, StartMsgID + MsgIDNum] 范围内的所有 MsgID,把结果返回给客户端; 3)如果 ExpireFlag 有效,则删除MsgIDList内所有在 [0, StartMsgID] 范围内的MsgID,把请求内容写入本地log。 8.3.3主从数据同步 同 Xiu 模块,暂定 Pi 的同 Parition 副本只有一个。 Pi 节点启动的时候根据自身配置文件中分配的 Pi_Partition_ID 到Registry路径 /pubsub/pi/partition_id 下进行注册一个临时有序节点,注册成功则 Registry 会返回 Pi 的节点 ID。 Pi 节点获取 /pubsub/pi/partition_id 下的所有节点的ID和地址信息,依据 节点ID最小者为leader 的原则,即可判定自己的角色。只有 leader 可接受读写数据请求。 数据同步流程如下: 1)follower 定时向 leader 发送心跳信息,心跳信息包含本地最新 LogID; 2)leader 启动一个数据同步线程处理 follower 的心跳信息,根据 follower 汇报的 logID 把此 LogID; 3)follower 从 leader 获取到最新一批 Log,先存储然后重放。 follower 会关注Registry路径 /pubsub/pi/partition_id 下所有节点的变化情况,如果 leader 挂掉则及时转换身份并接受客户端请求。如果follower 与 leader 之间的心跳超时,则follower删掉 leader 的 Registry 路径节点,及时进行身份转换处理客户端请求。 8.3.4集群扩容 Pi 集群扩容采用翻倍法。则节点启动后工作流程如下: 1)向 Registry 注册,获取 Registry 路径 /pubsub/xiu/partition_num 的值 PartitionNumber; 2)如果发现自己 PartitionID 满足条件 PartitionID >= PartitionNumber 时,则意味着当前 Partition 是扩容后的新集群,更新 Registry 中自己状态为start; 3)读取 Registry 路径 /pubsub/xiu 下所有 Parition 的 leader,根据条件 自身PartitionID % PartitionNumber == PartitionID % PartitionNumber 寻找对应的老 Partition 的 leader,称之为 parent_leader; 4)缓存收到 Proxy 转发来的用户请求; 5)向 parent_leader 获取log; 6)向 parent_leader 同步内存数据; 7)重放 parent_leader 的log; 8)更新 Registry 中自己的状态为 Running; 9)重放用户请求; 10)当 Registry 路径 /pubsub/xiu/partition_num 的值 PartitionNumber 满足条件 PartitionID >= PartitionNumber 时,意味着扩容完成,处理用户请求时要给用户返回响应。 Proxy 会把读写请求参照条件 UIN % Pi\_Partition\_Num == Pi\_Partition\_ID % Pi\_Partition\_Num 向相关 partition 的 leader 转发用户请求。假设原来 PartitionNumber 值为2,扩容后值为4,则原来转发给 partition0 的写请求现在需同时转发给 partition0 和 partition2,原来转发给 partition1 的写请求现在需同时转发给 partition1 和 partition3。 另外启动一个工具,当水平扩展后所有新启动的 Partition 内所有 Replica 的状态都是 Running 的时候,修改Registry路径/pubsub/xiu/partition_num的值为扩容后 Partition 的数目。 8.4、数据发送流程 消息自 PiXiu 的外部客户端(Client,服务端所有使用 PiXiu 提供的服务者统称为客户端)按照一定负载均衡规则发送到 Proxy,然后存入 Xiu 中,把 MsgID 存入 Pi 中。 其详细流程如下: 1)Client 依据 snowflake 算法给消息分配 SnowflakeID,依据 ProxyID = UIN % ProxyNum 规则把消息发往某个 Proxy; 2)Proxy 收到消息后转发到 Xiu; 3)Proxy 收到 Xiu 返回的响应后,把响应转发给 Client; 4)如果 Proxy 收到 Xiu 返回的响应带有 MsgID,则发起 Pi 写流程,把 MsgID 同步到 Pi 中; 5)如果 Proxy 收到 Xiu 返回的响应带有 MsgID,则给 Broker 发送一个 Notify,告知其某 UIN 的最新 MsgID。 8.5、数据转发流程 转发消息的主体是Broker,原来的在线消息转发流程是它收到 Proxy 转发来的 Message,然后根据用户是否在线然后转发给 Gateway。 PiXiu架构下 Broker 会收到以下类型消息: 1)用户登录消息; 2)用户心跳消息; 3)用户登出消息; 4)Notify 消息; 5)Ack 消息。 Broker流程受这五种消息驱动,下面分别详述其收到这五种消息时的处理流程。 用户登录消息流程如下: 1)检查用户的当前状态,若为 OffLine 则把其状态值为在线 OnLine; 2)检查用户的待发送消息队列是否为空,不为空则退出; 3)向 Pi 模块发送获取 N 条消息 ID 的请求 {UIN: uin, StartMsgID: 0, MsgIDNum: N, ExpireFlag: false},设置用户状态为 GettingMsgIDList 并等待回应; 4)根据 Pi 返回的消息 ID 队列,向 Xiu 发起获取消息请求 {UIN: uin, MsgIDList: msg ID List},设置用户状态为 GettingMsgList 并等待回应; 5)Xiu 返回消息列表后,设置状态为 SendingMsg,并向 Gateway 转发消息。 可以把用户心跳消息当做用户登录消息处理。 Gateway的用户登出消息产生有三种情况: 1)用户主动退出; 2)用户心跳超时; 3)给用户转发消息时发生网络错误。 用户登出消息处理流程如下: 1)检查用户状态,如果为 OffLine,则退出; 2)用户状态不为 OffLine 且检查用户已经发送出去的消息列表的最后一条消息的 ID(LastMsgID),向 Pi 发送获取 MsgID 请求{UIN: uin, StartMsgID: LastMsgID, MsgIDNum: 0, ExpireFlag: True},待 Pi 返回响应后退出。 处理 Proxy 发来的 Notify 消息处理流程如下: 1)如果用户状态为 OffLine,则退出; 2)更新用户的最新消息 ID(LatestMsgID),如果用户发送消息队列不为空则退出; 3)向 Pi 模块发送获取 N 条消息 ID 的请求 {UIN: uin, StartMsgID: 0, MsgIDNum: N, ExpireFlag: false},设置用户状态为 GettingMsgIDList 并等待回应; 4)根据 Pi 返回的消息 ID 队列,向 Xiu 发起获取消息请求 {UIN: uin, MsgIDList: msg ID List},设置用户状态为 GettingMsgList 并等待回应; 5)Xiu 返回消息列表后,设置状态为 SendingMsg,并向 Gateway 转发消息。 所谓 Ack 消息,就是 Broker 经 Gateway 把消息转发给 App 后,App 给Broker的消息回复,告知Broker其最近成功收到消息的 MsgID。 Ack 消息处理流程如下: 1)如果用户状态为 OffLine,则退出; 2)更新 LatestAckMsgID 的值; 3)如果用户发送消息队列不为空,则发送下一个消息后退出; 4)如果 LatestAckMsgID >= LatestMsgID,则退出; 5)向 Pi 模块发送获取 N 条消息 ID 的请求 {UIN: uin, StartMsgID: 0, MsgIDNum: N, ExpireFlag: false},设置用户状态为 GettingMsgIDList 并等待回应; 6)根据 Pi 返回的消息 ID 队列,向 Xiu 发起获取消息请求 {UIN: uin, MsgIDList: msg ID List},设置用户状态为 GettingMsgList 并等待回应; 7)Xiu 返回消息列表后,设置状态为 SendingMsg,并向 Gateway 转发消息。 总体上,PiXiu 转发消息流程采用拉取(pull)转发模型,以上面五种消息为驱动进行状态转换,并作出相应的动作行为。 九、本文总结 这套群聊消息系统尚有以下task list需完善: 1)消息以UDP链路传递,不可靠【2018/01/29解决之】; 2)目前的负载均衡算法采用了极简的RoundRobin算法,可以根据成功率和延迟添加基于权重的负载均衡算法实现; 3)只考虑传递,没有考虑消息的去重,可以根据消息ID实现这个功能【2018/01/29解决之】; 4)各个模块之间没有考虑心跳方案,整个系统的稳定性依赖于Registry【2018/01/17解决之】; 5)离线消息处理【2018/03/03解决之】; 6)区分消息优先级。 此记。 参考文档:《一种支持自由规划无须数据迁移和修改路由代码的Replicaing扩容方案》 十、本文成文历程 于雨氏,2017/12/31,初作此文于丰台金箱堂。 于雨氏,2018/01/16,于海淀添加“系统稳定性”一节。 于雨氏,2018/01/29,于海淀添加“消息可靠性”一节。 于雨氏,2018/02/11,于海淀添加“Router”一节,并重新格式化全文。 于雨氏,2018/03/05,于海淀添加“PiXiu”一节。 于雨氏,2018/03/14,于海淀添加负反馈机制、根据Gateway Message ID保证Gateway Message数据一致性 和 Gateway用户退出消息产生机制 等三个细节。 于雨氏,2018/08/05,于海淀添加 “pipeline” 一节。 于雨氏,2018/08/28,于海淀添加 “大房间消息处理” 一节。 附录:更多IM架构设计文章 《浅谈IM系统的架构设计》 《简述移动端IM开发的那些坑:架构设计、通信协议和客户端》 《一套海量在线用户的移动端IM架构设计实践分享(含详细图文)》 《一套原创分布式即时通讯(IM)系统理论架构方案》 《从零到卓越:京东客服即时通讯系统的技术架构演进历程》 《蘑菇街即时通讯/IM服务器开发之架构选择》 《腾讯QQ1.4亿在线用户的技术挑战和架构演进之路PPT》 《微信后台基于时间序的海量数据冷热分级架构设计实践》 《微信技术总监谈架构:微信之道——大道至简(演讲全文)》 《如何解读《微信技术总监谈架构:微信之道——大道至简》》 《快速裂变:见证微信强大后台架构从0到1的演进历程(一)》 《17年的实践:腾讯海量产品的技术方法论》 《移动端IM中大规模群消息的推送如何保证效率、实时性?》 《现代IM系统中聊天消息的同步和存储方案探讨》 《IM开发基础知识补课(二):如何设计大量图片文件的服务端存储架构?》 《IM开发基础知识补课(三):快速理解服务端数据库读写分离原理及实践建议》 《IM开发基础知识补课(四):正确理解HTTP短连接中的Cookie、Session和Token》 《WhatsApp技术实践分享:32人工程团队创造的技术神话》 《微信朋友圈千亿访问量背后的技术挑战和实践总结》 《王者荣耀2亿用户量的背后:产品定位、技术架构、网络方案等》 《IM系统的MQ消息中间件选型:Kafka还是RabbitMQ?》 《腾讯资深架构师干货总结:一文读懂大型分布式系统设计的方方面面》 《以微博类应用场景为例,总结海量社交系统的架构设计步骤》 《快速理解高性能HTTP服务端的负载均衡技术原理》 《子弹短信光鲜的背后:网易云信首席架构师分享亿级IM平台的技术实践》 《知乎技术分享:从单机到2000万QPS并发的Redis高性能缓存实践之路》 《IM开发基础知识补课(五):通俗易懂,正确理解并用好MQ消息队列》 《微信技术分享:微信的海量IM聊天消息序列号生成实践(算法原理篇)》 《微信技术分享:微信的海量IM聊天消息序列号生成实践(容灾方案篇)》 《新手入门:零基础理解大型分布式架构的演进历史、技术原理、最佳实践》 《一套高可用、易伸缩、高并发的IM群聊架构方案设计实践》 >> 更多同类文章 …… (本文同步发布于:http://www.52im.net/thread-2015-1-1.html)
1、点评 对于IM系统来说,如何做到IM聊天消息离线差异拉取(差异拉取是为了节省流量)、消息多端同步、消息顺序保证等,是典型的IM技术难点。 就像即时通讯网整理的以下IM开发干货系列一样: 《IM消息送达保证机制实现(一):保证在线实时消息的可靠投递》 《IM消息送达保证机制实现(二):保证离线消息的可靠投递》 《如何保证IM实时消息的“时序性”与“一致性”?》 《IM单聊和群聊中的在线状态同步应该用“推”还是“拉”?》 《IM群聊消息如此复杂,如何保证不丢不重?》 《浅谈移动端IM的多点登陆和消息漫游原理》 《IM群聊消息究竟是存1份(即扩散读)还是存多份(即扩散写)?》 上面这些文章所涉及的IM聊天消息的省流量、可靠投递、离线拉取、时序性、一致性、多端同步等等问题,总结下来其实就是要解决好一个问题:即如何保证聊天消息的唯一性判定和顺序判定。 很多群友在讨论这个问题的时候,普遍考虑的是使用整型自增序列号作为消息ID(即MsgId):这样既能保证消息的唯一性又方便保证顺序性,但问题是在分布式情况下是很难保证消息id的唯一性且顺序递增的,维护id生成的一致性难度太大了(网络延迟、调试出错等等都可能导致不同的机器取到的消息id存在碰撞的可能)。 不过,通过本文中微信团队分享的微信消息序列号生成思路,实际上要解决消息的唯一性、顺序性问题,可以将一个技术点分解成两个:即将原先每条消息一个自增且唯一的消息ID分拆成两个关键属性——消息ID(msgId)、消息序列号(seqId),即消息ID只要保证唯一性而不需要兼顾顺序性(比如直接用UUID)、消息序列号只要保证顺序性而不需要兼顾唯一性(就像本文中微信的思路一样),这样的技术分解就能很好的解决原本一个消息ID既要保证唯一性又要保证顺序性的难题。 那么,如何优雅地解决“消息序列号只要保证顺序性而不需要兼顾唯一性”的问题呢?这就是本文所要分享的内容,强烈建议深入理解和阅读。 本文因篇幅较长,分为上下两篇,敬请点击阅读: 上篇:《微信技术分享:微信的海量IM聊天消息序列号生成实践(算法原理篇)》(本文) 下篇:《微信技术分享:微信的海量IM聊天消息序列号生成实践(容灾方案篇)》 学习交流: - 即时通讯开发交流3群:185926912[推荐] - 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》 2、正文引言 微信在立项之初,就已确立了利用数据版本号(注:具体的实现也就是本文要分享的消息序列号)实现终端与后台的数据增量同步机制,确保发消息时消息可靠送达对方手机,避免了大量潜在的家庭纠纷。时至今日,微信已经走过第五个年头,这套同步机制仍然在消息收发、朋友圈通知、好友数据更新等需要数据同步的地方发挥着核心的作用。 而在这同步机制的背后,需要一个高可用、高可靠的消息序列号生成器来产生同步数据用的版本号(注:因为序列号天生的递增特性,完全可以当版本号来使用,但又不仅限于版本号的用途)。这个消息序列号生成器我们微信内部称之为 seqsvr ,目前已经发展为一个每天万亿级调用的重量级系统,其中每次申请序列号平时调用耗时1ms,99.9%的调用耗时小于3ms,服务部署于数百台4核 CPU 服务器上。 本篇将重点介绍微信的消息序列号生成器 seqsvr 的算法原理、架构核心思想,以及 seqsvr 随着业务量快速上涨所做的架构演变(下篇《微信技术分享:微信的海量IM聊天消息序列号生成实践(容灾方案篇)》会着重讨论分布式容灾方案,敬请关注)。 3、关于作者 曾钦松:微信高级工程师,负责过微信基础架构、微信翻译引擎、微信围棋PhoenixGo,致力于高可用高性能后台系统的设计与研发。2011年毕业于西安电子科技大学,早先曾在腾讯搜搜从事检索架构、分布式数据库方面的工作。 4、技术思路 微信服务器端为每一份需要与客户端同步的数据(例如聊天消息)都会赋予一个唯一的、递增的序列号(后文称为 sequence ),作为这份数据的版本号(这是利用了序列号递增的特性)。在客户端与服务器端同步的时候,客户端会带上已经同步下去数据的最大版本号,后台会根据客户端最大版本号与服务器端的最大版本号,计算出需要同步的增量数据,返回给客户端。这样不仅保证了客户端与服务器端的数据同步的可靠性,同时也大幅减少了同步时的冗余数据(就像这篇文章中讨论的一样:《如何保证IM实时消息的“时序性”与“一致性”?》)。 这里不用乐观锁机制来生成版本号,而是使用了一个独立的 seqsvr 来处理序列号操作: 1)一方面因为业务有大量的 sequence 查询需求——查询已经分配出去的最后一个 sequence ,而基于 seqsvr 的查询操作可以做到非常轻量级,避免对存储层的大量 IO 查询操作; 2)另一方面微信用户的不同种类的数据存在不同的 Key-Value 系统中,使用统一的序列号有助于避免重复开发,同时业务逻辑可以很方便地判断一个用户的各类数据是否有更新。 从 seqsvr 申请的、用作数据版本号的 sequence ,具有两种基本的性质: 1)递增的64位整型变量; 2)每个用户都有自己独立的64位 sequence 空间。 举个例子,小明当前申请的 sequence 为100,那么他下一次申请的 sequence ,可能为101,也可能是110,总之一定大于之前申请的100。而小红呢,她的 sequence 与小明的 sequence 是独立开的,假如她当前申请到的 sequence 为50,然后期间不管小明申请多少次 sequence 怎么折腾,都不会影响到她下一次申请到的值(很可能是51)。 这里用了每个用户独立的64位 sequence 的体系,而不是用一个全局的64位(或更高位) sequence ,很大原因是全局唯一的 sequence 会有非常严重的申请互斥问题,不容易去实现一个高性能高可靠的架构。对微信业务来说,每个用户独立的64位 sequence 空间已经满足业务要求。 目前 sequence 用在终端与后台的数据同步外,同时也广泛用于微信后台逻辑层的基础数据一致性cache中,大幅减少逻辑层对存储层的访问。虽然一个用于终端——后台数据同步,一个用于后台cache的一致性保证,场景大不相同。 但我们仔细分析就会发现,两个场景都是利用 sequence 可靠递增的性质来实现数据的一致性保证,这就要求我们的 seqsvr 保证分配出去的 sequence 是稳定递增的,一旦出现回退必然导致各种数据错乱、消息消失;另外,这两个场景都非常普遍,我们在使用微信的时候会不知不觉地对应到这两个场景:小明给小红发消息、小红拉黑小明、小明发一条失恋状态的朋友圈,一次简单的分手背后可能申请了无数次 sequence。 微信目前拥有数亿的活跃用户,每时每刻都会有海量 sequence 申请,这对 seqsvr 的设计也是个极大的挑战。那么,既要 sequence 可靠递增,又要能顶住海量的访问,要如何设计 seqsvr 的架构?我们先从 seqsvr 的架构原型说起。 5、具体的技术架构原型 不考虑 seqsvr 的具体架构的话,它应该是一个巨大的64位数组,而我们每一个微信用户,都在这个大数组里独占一格8 bytes 的空间,这个格子就放着用户已经分配出去的最后一个 sequence:cur_seq。每个用户来申请sequence的时候,只需要将用户的cur_seq+=1,保存回数组,并返回给用户。 ▲ 图1:小明申请了一个sequence,返回101 5.1 预分配中间层 任何一件看起来很简单的事,在海量的访问量下都会变得不简单。前文提到,seqsvr 需要保证分配出去的sequence 递增(数据可靠),还需要满足海量的访问量(每天接近万亿级别的访问)。满足数据可靠的话,我们很容易想到把数据持久化到硬盘,但是按照目前每秒千万级的访问量(~10^7 QPS),基本没有任何硬盘系统能扛住。 后台架构设计很多时候是一门关于权衡的哲学,针对不同的场景去考虑能不能降低某方面的要求,以换取其它方面的提升。仔细考虑我们的需求,我们只要求递增,并没有要求连续,也就是说出现一大段跳跃是允许的(例如分配出的sequence序列:1,2,3,10,100,101)。 于是我们实现了一个简单优雅的策略: 1)内存中储存最近一个分配出去的sequence:cur_seq,以及分配上限:max_seq; 2)分配sequence时,将cur_seq++,同时与分配上限max_seq比较:如果cur_seq > max_seq,将分配上限提升一个步长max_seq += step,并持久化max_seq; 3)重启时,读出持久化的max_seq,赋值给cur_seq。 ▲ 图2:小明、小红、小白都各自申请了一个sequence,但只有小白的max_seq增加了步长100 这样通过增加一个预分配 sequence 的中间层,在保证 sequence 不回退的前提下,大幅地提升了分配 sequence 的性能。实际应用中每次提升的步长为10000,那么持久化的硬盘IO次数从之前~10^7 QPS降低到~10^3 QPS,处于可接受范围。在正常运作时分配出去的sequence是顺序递增的,只有在机器重启后,第一次分配的 sequence 会产生一个比较大的跳跃,跳跃大小取决于步长大小。 5.2 分号段共享存储 请求带来的硬盘IO问题解决了,可以支持服务平稳运行,但该模型还是存在一个问题:重启时要读取大量的max_seq数据加载到内存中。 我们可以简单计算下,以目前 uid(用户唯一ID)上限2^32个、一个 max_seq 8bytes 的空间,数据大小一共为32GB,从硬盘加载需要不少时间。另一方面,出于数据可靠性的考虑,必然需要一个可靠存储系统来保存max_seq数据,重启时通过网络从该可靠存储系统加载数据。如果max_seq数据过大的话,会导致重启时在数据传输花费大量时间,造成一段时间不可服务。 为了解决这个问题,我们引入号段 Section 的概念,uid 相邻的一段用户属于一个号段,而同个号段内的用户共享一个 max_seq,这样大幅减少了max_seq 数据的大小,同时也降低了IO次数。 ▲ 图3:小明、小红、小白属于同个Section,他们共用一个max_seq。在每个人都申请一个sequence的时候,只有小白突破了max_seq上限,需要更新max_seq并持久化 目前 seqsvr 一个 Section 包含10万个 uid,max_seq 数据只有300+KB,为我们实现从可靠存储系统读取max_seq 数据重启打下基础。 5.3 工程实现 工程实现在上面两个策略上做了一些调整,主要是出于数据可靠性及灾难隔离考虑: 1)把存储层和缓存中间层分成两个模块 StoreSvr 及 AllocSvr 。StoreSvr 为存储层,利用了多机 NRW 策略来保证数据持久化后不丢失; AllocSvr 则是缓存中间层,部署于多台机器,每台 AllocSvr 负责若干号段的 sequence 分配,分摊海量的 sequence 申请请求。 2)整个系统又按 uid 范围进行分 Set,每个 Set 都是一个完整的、独立的 StoreSvr+AllocSvr 子系统。分 Set 设计目的是为了做灾难隔离,一个 Set 出现故障只会影响该 Set 内的用户,而不会影响到其它用户。 ▲ 图4:原型架构图 6、本篇小结 写到这里把 seqsvr 基本原型讲完了,正是如此简单优雅的模型,可靠、稳定地支撑着微信五年来的高速发展。五年里访问量一倍又一倍地上涨,seqsvr 本身也做过大大小小的重构,但 seqsvr 的分层架构一直没有改变过,并且在可预见的未来里也会一直保持不变。 原型跟生产环境的版本存在一定差距,最主要的差距在于容灾上。像微信的 IM 类应用,对系统可用性非常敏感,而 seqsvr 又处于收发消息、朋友圈等功能的关键路径上,对可用性要求非常高,出现长时间不可服务是分分钟写故障报告的节奏。 本文的下篇《微信技术分享:微信的海量IM聊天消息序列号生成实践(容灾方案篇)会讲讲 seqsvr 的容灾方案演变。 附录:更多QQ、微信团队原创技术文章 《微信朋友圈千亿访问量背后的技术挑战和实践总结》 《腾讯技术分享:腾讯是如何大幅降低带宽和网络流量的(图片压缩篇)》 《腾讯技术分享:腾讯是如何大幅降低带宽和网络流量的(音视频技术篇)》 《微信团队分享:微信移动端的全文检索多音字问题解决方案》 《腾讯技术分享:Android版手机QQ的缓存监控与优化实践》 《微信团队分享:iOS版微信的高性能通用key-value组件技术实践》 《微信团队分享:iOS版微信是如何防止特殊字符导致的炸群、APP崩溃的?》 《腾讯技术分享:Android手Q的线程死锁监控系统技术实践》 《微信团队原创分享:iOS版微信的内存监控系统技术实践》 《让互联网更快:新一代QUIC协议在腾讯的技术实践分享》 《iOS后台唤醒实战:微信收款到账语音提醒技术总结》 《腾讯技术分享:社交网络图片的带宽压缩技术演进之路》 《微信团队分享:视频图像的超分辨率技术原理和应用场景》 《微信团队分享:微信每日亿次实时音视频聊天背后的技术解密》 《QQ音乐团队分享:Android中的图片压缩技术详解(上篇)》 《QQ音乐团队分享:Android中的图片压缩技术详解(下篇)》 《腾讯团队分享:手机QQ中的人脸识别酷炫动画效果实现详解》 《腾讯团队分享 :一次手Q聊天界面中图片显示bug的追踪过程分享》 《微信团队分享:微信Android版小视频编码填过的那些坑》 《微信手机端的本地数据全文检索优化之路》 《企业微信客户端中组织架构数据的同步更新方案优化实战》 《微信团队披露:微信界面卡死超级bug“15。。。。”的来龙去脉》 《QQ 18年:解密8亿月活的QQ后台服务接口隔离技术》 《月活8.89亿的超级IM微信是如何进行Android端兼容测试的》 《以手机QQ为例探讨移动端IM中的“轻应用”》 《一篇文章get微信开源移动端数据库组件WCDB的一切!》 《微信客户端团队负责人技术访谈:如何着手客户端性能监控和优化》 《微信后台基于时间序的海量数据冷热分级架构设计实践》 《微信团队原创分享:Android版微信的臃肿之困与模块化实践之路》 《微信后台团队:微信后台异步消息队列的优化升级实践分享》 《微信团队原创分享:微信客户端SQLite数据库损坏修复实践》 《腾讯原创分享(一):如何大幅提升移动网络下手机QQ的图片传输速度和成功率》 《腾讯原创分享(二):如何大幅压缩移动网络下APP的流量消耗(下篇)》 《腾讯原创分享(三):如何大幅压缩移动网络下APP的流量消耗(上篇)》 《微信Mars:微信内部正在使用的网络层封装库,即将开源》 《如约而至:微信自用的移动端IM网络层跨平台组件库Mars已正式开源》 《开源libco库:单机千万连接、支撑微信8亿用户的后台框架基石 [源码下载]》 《微信新一代通信安全解决方案:基于TLS1.3的MMTLS详解》 《微信团队原创分享:Android版微信后台保活实战分享(进程保活篇)》 《微信团队原创分享:Android版微信后台保活实战分享(网络保活篇)》 《Android版微信从300KB到30MB的技术演进(PPT讲稿) [附件下载]》 《微信团队原创分享:Android版微信从300KB到30MB的技术演进》 《微信技术总监谈架构:微信之道——大道至简(演讲全文)》 《微信技术总监谈架构:微信之道——大道至简(PPT讲稿) [附件下载]》 《如何解读《微信技术总监谈架构:微信之道——大道至简》》 《微信海量用户背后的后台系统存储架构(视频+PPT) [附件下载]》 《微信异步化改造实践:8亿月活、单机千万连接背后的后台解决方案》 《微信朋友圈海量技术之道PPT [附件下载]》 《微信对网络影响的技术试验及分析(论文全文)》 《一份微信后台技术架构的总结性笔记》 《架构之道:3个程序员成就微信朋友圈日均10亿发布量[有视频]》 《快速裂变:见证微信强大后台架构从0到1的演进历程(一)》 《快速裂变:见证微信强大后台架构从0到1的演进历程(二)》 《微信团队原创分享:Android内存泄漏监控和优化技巧总结》 《全面总结iOS版微信升级iOS9遇到的各种“坑”》 《微信团队原创资源混淆工具:让你的APK立减1M》 《微信团队原创Android资源混淆工具:AndResGuard [有源码]》 《Android版微信安装包“减肥”实战记录》 《iOS版微信安装包“减肥”实战记录》 《移动端IM实践:iOS版微信界面卡顿监测方案》 《微信“红包照片”背后的技术难题》 《移动端IM实践:iOS版微信小视频功能技术方案实录》 《移动端IM实践:Android版微信如何大幅提升交互性能(一)》 《移动端IM实践:Android版微信如何大幅提升交互性能(二)》 《移动端IM实践:实现Android版微信的智能心跳机制》 《移动端IM实践:WhatsApp、Line、微信的心跳策略分析》 《移动端IM实践:谷歌消息推送服务(GCM)研究(来自微信)》 《移动端IM实践:iOS版微信的多设备字体适配方案探讨》 《信鸽团队原创:一起走过 iOS10 上消息推送(APNS)的坑》 《腾讯信鸽技术分享:百亿级实时消息推送的实战经验》 《IPv6技术详解:基本概念、应用现状、技术实践(上篇)》 《IPv6技术详解:基本概念、应用现状、技术实践(下篇)》 《腾讯TEG团队原创:基于MySQL的分布式数据库TDSQL十年锻造经验分享》 《微信多媒体团队访谈:音视频开发的学习、微信的音视频技术和挑战等》 《了解iOS消息推送一文就够:史上最全iOS Push技术详解》 《腾讯技术分享:微信小程序音视频技术背后的故事》 《腾讯资深架构师干货总结:一文读懂大型分布式系统设计的方方面面》 《微信多媒体团队梁俊斌访谈:聊一聊我所了解的音视频技术》 《腾讯音视频实验室:使用AI黑科技实现超低码率的高清实时视频聊天》 《腾讯技术分享:微信小程序音视频与WebRTC互通的技术思路和实践》 《手把手教你读取Android版微信和手Q的聊天记录(仅作技术研究学习)》 《微信技术分享:微信的海量IM聊天消息序列号生成实践(算法原理篇)》 >> 更多同类文章 …… (本文同步发布于:http://www.52im.net/thread-1998-1-1.html)
1、引言 特别说明:本文内容仅用于即时通讯技术研究和学习之用,请勿用于非法用途。如本文内容有不妥之处,请联系JackJiang进行处理! 我司有关部门为了获取黑产群的动态,有同事潜伏在大量的黑产群(QQ群、微信群)中,干起了无间道的工作。随着黑产群数量的激增,同事希望能自动获取黑产群的聊天信息,并交付风控引擎进行风险评估。于是,这个工作就交给我了,是时候表现一波了…… 针对同事的需求,分析了一通,总结一下: 1)能够自动获取微信和 QQ群的聊天记录; 2)只要文字记录,图片和表情包,语音之类的不要; 3)后台自动运行,非实时获取记录。 (注:本文读取聊天记录的方法只适用于监控自己拥有的微信或者QQ ,无法监控或者盗取其他人的聊天记录。本文只写了如何获取聊天记录,服务器落地程序并不复杂,不做赘述。写的仓促,有错别字还请见谅。) 学习交流: - 即时通讯开发交流3群:185926912[推荐] - 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》 (本文同步发布于:http://www.52im.net/thread-1992-1-1.html) 2、相关文章 即时通讯网之前整理过微信本地数据库的读取和样本,如有兴趣可请往阅读: 《微信本地数据库破解版(含iOS、Android),仅供学习研究 [附件下载]》 3、准备工作 参阅很多相关的文章之后,对这个需求有了大致的想法,开始着手准备: 1)需要一个有root权限的Android手机,我用的是红米5(强调必须已被ROOT); 2)android的开发环境(就是Android Studio那一套啦); 3)android相关的开发经验(我是个PHP,第一次写Android程序,踩了不少坑)。 4、获取微信聊天记录过程分享 4.1 着手准备 微信的聊天记录保存在Android系统的:"/data/data/com.tencent.mm/MicroMsg/c5fb89d4729f72c345711cb*/EnMicroMsg.db" 目录和文件下。 该文件是加密的数据库文件,需要用到sqlcipher来打开。密码为:MD5(手机的IMEI+微信UIN)的前七位。文件所在的那个乱码文件夹的名称也是一段加密MD5值:MD5('mm'+微信UIN)。微信的UIN存放在微信文件夹“/data/data/com.tencent.mmshared_prefs/system_config_prefs.xml”中。(这个减号一定要带着!) 另外:即时通讯网之前整理过微信本地数据库的样本,如有兴趣可请往下载:《微信本地数据库破解版(含iOS、Android),仅供学习研究 [附件下载]》。 注意:如果手机是双卡双待,那么会有两个IMEI号,默认选择 IMEI1,如果不行,可以尝试一下字符串‘1234567890ABCDEF’。早期的微信会去判定你的IMEI,如果为空 默认选择这个字符串。 拿到密码,就可以打开EnMicroMsg.db了。微信聊天记录,包括个人、群组的所有记录全部存在message这张表里(如下图所示),就像下面这两张截图里展示的一样。 (为了方便截图,此图截自《微信本地数据库破解版(含iOS、Android),仅供学习研究 [附件下载]》中的样本) (为了方便截图,此图截自《微信本地数据库破解版(含iOS、Android),仅供学习研究 [附件下载]》中的样本) 4.2 代码实现 第一步,不可能直接去访问EnMicroMsg.db。因为没有权限,还要避免和微信本身产生冲突,所以选择把这个文件拷贝到自己的项目下: oldPath ="/data/data/com.tencent.mm/MicroMsg/c5fb89d4729f72c345711cb**\***/EnMicroMsg.db"; newPath ="/data/data/com.你的项目/EnMicroMsg.db"; copyFile(oldPath,newPath);//代码见 部分源码 第二步,拿到文件的密码: String password = (MD5Until.md5("IMEI+微信UIN").substring(0, 7).toLowerCase()); 第三步,打开文件,执行SQL: SQLiteDatabase.loadLibs(context); SQLiteDatabaseHook hook = newSQLiteDatabaseHook() { publicvoidpreKey(SQLiteDatabase database) { } publicvoidpostKey(SQLiteDatabase database) { database.rawExecSQL("PRAGMA cipher_migrate;");//很重要 } }; SQLiteDatabase db = openDatabase(newPath, password, null, NO_LOCALIZED_COLLATORS, hook); longnow = System.currentTimeMillis(); Log.e("readWxDatabases", "读取微信数据库:"+ now); intcount = 0; if(msgId != "0") { String sql = "select * from message"; Log.e("sql", sql); Cursor c = db.rawQuery(sql, null); while(c.moveToNext()) { long_id = c.getLong(c.getColumnIndex("msgId")); String content = c.getString(c.getColumnIndex("content")); inttype = c.getInt(c.getColumnIndex("type")); String talker = c.getString(c.getColumnIndex("talker")); longtime = c.getLong(c.getColumnIndex("createTime")); JSONObject tmpJson = handleJson(_id, content, type, talker, time); returnJson.put("data"+ count, tmpJson); count++; } c.close(); db.close(); Log.e("readWxDatanases", "读取结束:"+ System.currentTimeMillis() + ",count:"+ count); } 到此,我们就可以通过自已写的代码拿到微信的聊天记录了,之后可以直接将整理好的JSON通过POST请求发到服务器就可以了。(忍不住吐槽:写服务器落地程序用了30分钟,写上面这一坨花了三四天,还不包括搭建开发环境、下载SDK、折腾ADB什么的)。 5、获取QQ聊天记录过程分享 5.1 说明 QQ的聊天记录有点麻烦,他的文件保存在:“/data/data/com.tencent.mobileqq/databases/你的QQ号码.db”。 这个文件是不加密的,可以直接打开。QQ中群组的聊天记录是单独建表存放的,所有的QQ群信息存放在TroopInfoV2表里,需要对字段troopuin求MD5,然后找到他的聊天记录表:mr_troop_" + troopuinMD5 +"_New。 但是!(看到“但是”就没好事。。。) 问题来了,它的内容是加密的,而且加密方法还很复杂:根据手机IMEI循环逐位异或。具体的我不举例子了,太麻烦,直接看文章最后的解密方法。 5.2 代码实现 第一步,还是拷贝数据库文件: final String QQ_old_path = "/data/data/com.tencent.mobileqq/databases/QQ号.db"; final String QQ_new_path = "/data/data/com.android.saurfang/QQ号.db"; DataHelp.copyFile(QQ_old_path,QQ_new_path); 第二步,打开并读取内容: SQLiteDatabase.loadLibs(context); String password = ""; SQLiteDatabaseHook hook = newSQLiteDatabaseHook() { publicvoidpreKey(SQLiteDatabase database) {} publicvoidpostKey(SQLiteDatabase database) { database.rawExecSQL("PRAGMA cipher_migrate;"); } }; MessageDecode mDecode = newMessageDecode(imid); HashMap<String, String> troopInfo = newHashMap<String, String>(); try{ SQLiteDatabase db = openDatabase(newPath,password,null, NO_LOCALIZED_COLLATORS,hook); longnow = System.currentTimeMillis(); Log.e("readQQDatabases","读取QQ数据库:"+now); //读取所有的群信息 String sql = "select troopuin,troopname from TroopInfoV2 where _id"; Log.e("sql",sql); Cursor c = db.rawQuery(sql,null); while(c.moveToNext()){ String troopuin = c.getString(c.getColumnIndex("troopuin")); String troopname = c.getString(c.getColumnIndex("troopname")); String name = mDecode.nameDecode(troopname); String uin = mDecode.uinDecode(troopuin); Log.e("readQQDatanases","读取结束:"+name); troopInfo.put(uin, name); } c.close(); inttroopCount = troopInfo.size(); Iterator<String> it = troopInfo.keySet().iterator(); JSONObject json = newJSONObject(); //遍历所有的表 while(troopCount > 0) { try{ while(it.hasNext()) { String troopuin = (String)it.next(); String troopname = troopInfo.get(troopuin); if(troopuin.length() < 8) continue; String troopuinMD5 = getMD5(troopuin); String troopMsgSql = "select _id,msgData, senderuin, time from mr_troop_"+ troopuinMD5 +"_New"; Log.e("sql",troopMsgSql); Cursor cc = db.rawQuery(troopMsgSql,null); JSONObject tmp = newJSONObject(); while(cc.moveToNext()) { long_id = cc.getLong(cc.getColumnIndex("_id")); byte[] msgByte = cc.getBlob(cc.getColumnIndex("msgData")); String ss = mDecode.msgDecode(msgByte); //图片不保留 if(ss.indexOf("jpg") != -1|| ss.indexOf("gif") != -1 || ss.indexOf("png") != -1) continue; String time = cc.getString(cc.getColumnIndex("time")); String senderuin = cc.getString(cc.getColumnIndex("senderuin")); senderuin = mDecode.uinDecode(senderuin); JSONObject tmpJson = handleQQJson(_id,ss,senderuin,time); tmp.put(String.valueOf(_id),tmpJson); } troopCount--; cc.close(); } } catch(Exception e) { Log.e("e","readWxDatabases"+e.toString()); } } db.close(); }catch(Exception e){ Log.e("e","readWxDatabases"+e.toString()); } 然后你就可以把信息发到服务器落地了(同样跟微信的记录上传一样,通过你自已写的代码发送到你的服务端就可以了)。 6、题外话:一些注意点 这里还有几个需要注意的地方。 1)最新安卓系统很难写个死循环直接跑了,所以我们需要使用Intent,来开始Service,再通过Service调用AlarmManager,就像下面的代码这样: publicclassMainActivity extendsAppCompatActivity { privateIntent intent; @Override protectedvoidonCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity\_main); intent = newIntent(this, LongRunningService.class); startService(intent); } @Override protectedvoidonDestroy() { super.onDestroy(); stopService(intent); } } 然后再创建一个LongRunningService,在其中调用AlarmManager: AlarmManager manager = (AlarmManager) getSystemService(ALARM_SERVICE); intMinutes = 60*1000; //此处规定执行的间隔时间 longtriggerAtTime = SystemClock.elapsedRealtime() + Minutes; Intent intent1 = newIntent(this, AlarmReceiver.class);//注入要执行的类 PendingIntent pendingIntent = PendingIntent.getBroadcast(this, 0, intent1, 0); manager.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, triggerAtTime, pendingIntent); returnsuper.onStartCommand(intent, flags, startId); 在AlarmReceiver中调用我们的方法: //微信部分 postWXMsg.readWXDatabase(); //QQ部分 postQQMsg.readQQDatabase(); //再次开启LongRunningService这个服务,即可实现定时循环。 Intent intentNext = newIntent(context, LongRunningService.class); context.startService(intentNext); 2)安卓不允许在主线程里进行网络连接,可以直接用 retrofit2 来发送数据(或者最简单的方法就是用AsyncTask了)。 3)项目需要授权网络连接(就是在AndroidManifast.xml里加上网络权限申请就是了); 4)项目需要引入的包: implementation files('libs/sqlcipher.jar') implementation files('libs/sqlcipher-javadoc.jar') implementation 'com.squareup.retrofit2:retrofit:2.0.0' implementation 'com.squareup.retrofit2:converter-gson:2.0.0' 5)如果复制文件时失败,校验文件路径不存在,多半是因为授权问题。需要对数据库文件授权 全用户rwx权限; 6)如果服务端使用MySql数据库的话,数据库编码请用utf8mb4编码,用来支持Emoji表情。。 7、我的部分源码 (因为种种原因,我不太好直接把源码贴上来,现把几个实用方法分享出来,可以直接使用。) 复制文件的方法: /** * 复制单个文件 * * @param oldPath String 原文件路径 如:c:/fqf.txt * @param newPath String 复制后路径 如:f:/fqf.txt * @return boolean */ publicstaticbooleancopyFile(String oldPath, String newPath) { deleteFolderFile(newPath, true); Log.e("copyFile", "time_1:"+ System.currentTimeMillis()); InputStream inStream = null; FileOutputStream fs = null; try{ intbytesum = 0; intbyteread = 0; File oldfile = newFile(oldPath); Boolean flag = oldfile.exists(); Log.e("copyFile", "flag:"+flag ); if(oldfile.exists()) { //文件存在时 inStream = newFileInputStream(oldPath); //读入原文件 fs = newFileOutputStream(newPath); byte[] buffer = newbyte[2048]; while((byteread = inStream.read(buffer)) != -1) { bytesum += byteread; //字节数 文件大小 fs.write(buffer, 0, byteread); } Log.e("copyFile", "time_2:"+ System.currentTimeMillis()); } } catch(Exception e) { System.out.println("复制单个文件操作出错"); e.printStackTrace(); } finally{ try{ if(inStream != null) { inStream.close(); } if(fs != null) { fs.close(); } } catch(IOException e) { e.printStackTrace(); } } returntrue; } /** * 删除单个文件 * * @param filepath * @param deleteThisPath */ publicstaticvoiddeleteFolderFile(String filepath, booleandeleteThisPath) { if(!TextUtils.isEmpty(filepath)) { try{ File file = newFile(filepath); if(file.isDirectory()) { //处理目录 File files[] = file.listFiles(); for(inti = 0; i < file.length(); i++) { deleteFolderFile(files[i].getAbsolutePath(), true); } } if(deleteThisPath) { if(!file.isDirectory()) { //删除文件 file.delete(); } else{ //删除目录 if(file.listFiles().length == 0) { file.delete(); } } } } catch(Exception e) { e.printStackTrace(); } } } MD5方法: publicclassMD5Until { publicstaticcharHEX_DIGITS[] = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'}; //将字符串转化为位 publicstaticString toHexString(byte[] b){ StringBuilder stringBuilder = newStringBuilder(b.length * 2); for(inti = 0; i < b.length; i++) { stringBuilder.append(HEX_DIGITS[(b[i] & 0xf0) >>> 4]); stringBuilder.append(HEX_DIGITS[b[i] & 0x0f]); } returnstringBuilder.toString(); } publicstaticString md5(String string){ try{ MessageDigest digest = java.security.MessageDigest.getInstance("MD5"); digest.update(string.getBytes()); bytemessageDigest[] = digest.digest(); returntoHexString(messageDigest); }catch(NoSuchAlgorithmException e){ e.printStackTrace(); } return""; } } QQ信息解密方法: public class MessageDecode { public String imeiID; public intimeiLen; public MessageDecode(String imeiID) { this.imeiID = imeiID; this.imeiLen = imeiID.length(); } public boolean isChinese(bytech) { intres = ch & 0x80; if(res != 0) returntrue; returnfalse; } public String timeDecode(String time) { String datetime = "1970-01-01 08:00:00"; SimpleDateFormat sdFormat = newSimpleDateFormat("yyyy-MM-dd HH:mm:ss"); try{ longsecond = Long.parseLong(time); Date dt = newDate(second * 1000); datetime = sdFormat.format(dt); } catch(NumberFormatException e) { e.printStackTrace(); } returndatetime; } public String nameDecode(String name) { bytenbyte[] = name.getBytes(); byteibyte[] = imeiID.getBytes(); bytexorName[] = newbyte[nbyte.length]; intindex = 0; for(inti = 0; i < nbyte.length; i++) { if(isChinese(nbyte[i])){ xorName[i] = nbyte[i]; i++; xorName[i] = nbyte[i]; i++; xorName[i] = (byte)(nbyte[i] ^ ibyte[index % imeiLen]); index++; } else{ xorName[i] = (byte)(nbyte[i] ^ ibyte[index % imeiLen]); index++; } } return new String(xorName); } public String uinDecode(String uin) { byteubyte[] = uin.getBytes(); byteibyte[] = imeiID.getBytes(); bytexorMsg[] = newbyte[ubyte.length]; intindex = 0; for(inti = 0; i < ubyte.length; i++) { xorMsg[i] = (byte)(ubyte[i] ^ ibyte[index % imeiLen]); index++; } returnnewString(xorMsg); } public String msgDecode(byte[] msg) { byteibyte[] = imeiID.getBytes(); bytexorMsg[] = newbyte[msg.length]; intindex = 0; for(int i = 0; i < msg.length; i++) { xorMsg[i] = (byte)(msg[i] ^ ibyte[index % imeiLen]); index++; } return new String(xorMsg); } } 附录:有关微信、QQ的技术文章汇总 《微信朋友圈千亿访问量背后的技术挑战和实践总结》 《腾讯技术分享:腾讯是如何大幅降低带宽和网络流量的(图片压缩篇)》 《腾讯技术分享:腾讯是如何大幅降低带宽和网络流量的(音视频技术篇)》 《微信团队分享:微信移动端的全文检索多音字问题解决方案》 《腾讯技术分享:Android版手机QQ的缓存监控与优化实践》 《微信团队分享:iOS版微信的高性能通用key-value组件技术实践》 《微信团队分享:iOS版微信是如何防止特殊字符导致的炸群、APP崩溃的?》 《腾讯技术分享:Android手Q的线程死锁监控系统技术实践》 《微信团队原创分享:iOS版微信的内存监控系统技术实践》 《让互联网更快:新一代QUIC协议在腾讯的技术实践分享》 《iOS后台唤醒实战:微信收款到账语音提醒技术总结》 《腾讯技术分享:社交网络图片的带宽压缩技术演进之路》 《微信团队分享:视频图像的超分辨率技术原理和应用场景》 《微信团队分享:微信每日亿次实时音视频聊天背后的技术解密》 《QQ音乐团队分享:Android中的图片压缩技术详解(上篇)》 《QQ音乐团队分享:Android中的图片压缩技术详解(下篇)》 《腾讯团队分享:手机QQ中的人脸识别酷炫动画效果实现详解》 《腾讯团队分享 :一次手Q聊天界面中图片显示bug的追踪过程分享》 《微信团队分享:微信Android版小视频编码填过的那些坑》 《微信手机端的本地数据全文检索优化之路》 《企业微信客户端中组织架构数据的同步更新方案优化实战》 《微信团队披露:微信界面卡死超级bug“15。。。。”的来龙去脉》 《QQ 18年:解密8亿月活的QQ后台服务接口隔离技术》 《月活8.89亿的超级IM微信是如何进行Android端兼容测试的》 《以手机QQ为例探讨移动端IM中的“轻应用”》 《一篇文章get微信开源移动端数据库组件WCDB的一切!》 《微信客户端团队负责人技术访谈:如何着手客户端性能监控和优化》 《微信后台基于时间序的海量数据冷热分级架构设计实践》 《微信团队原创分享:Android版微信的臃肿之困与模块化实践之路》 《微信后台团队:微信后台异步消息队列的优化升级实践分享》 《微信团队原创分享:微信客户端SQLite数据库损坏修复实践》 《腾讯原创分享(一):如何大幅提升移动网络下手机QQ的图片传输速度和成功率》 《腾讯原创分享(二):如何大幅压缩移动网络下APP的流量消耗(下篇)》 《腾讯原创分享(三):如何大幅压缩移动网络下APP的流量消耗(上篇)》 《微信Mars:微信内部正在使用的网络层封装库,即将开源》 《如约而至:微信自用的移动端IM网络层跨平台组件库Mars已正式开源》 《开源libco库:单机千万连接、支撑微信8亿用户的后台框架基石 [源码下载]》 《微信新一代通信安全解决方案:基于TLS1.3的MMTLS详解》 《微信团队原创分享:Android版微信后台保活实战分享(进程保活篇)》 《微信团队原创分享:Android版微信后台保活实战分享(网络保活篇)》 《Android版微信从300KB到30MB的技术演进(PPT讲稿) [附件下载]》 《微信团队原创分享:Android版微信从300KB到30MB的技术演进》 《微信技术总监谈架构:微信之道——大道至简(演讲全文)》 《微信技术总监谈架构:微信之道——大道至简(PPT讲稿) [附件下载]》 《如何解读《微信技术总监谈架构:微信之道——大道至简》》 《微信海量用户背后的后台系统存储架构(视频+PPT) [附件下载]》 《微信异步化改造实践:8亿月活、单机千万连接背后的后台解决方案》 《微信朋友圈海量技术之道PPT [附件下载]》 《微信对网络影响的技术试验及分析(论文全文)》 《一份微信后台技术架构的总结性笔记》 《架构之道:3个程序员成就微信朋友圈日均10亿发布量[有视频]》 《快速裂变:见证微信强大后台架构从0到1的演进历程(一)》 《快速裂变:见证微信强大后台架构从0到1的演进历程(二)》 《微信团队原创分享:Android内存泄漏监控和优化技巧总结》 《全面总结iOS版微信升级iOS9遇到的各种“坑”》 《微信团队原创资源混淆工具:让你的APK立减1M》 《微信团队原创Android资源混淆工具:AndResGuard [有源码]》 《Android版微信安装包“减肥”实战记录》 《iOS版微信安装包“减肥”实战记录》 《移动端IM实践:iOS版微信界面卡顿监测方案》 《微信“红包照片”背后的技术难题》 《移动端IM实践:iOS版微信小视频功能技术方案实录》 《移动端IM实践:Android版微信如何大幅提升交互性能(一)》 《移动端IM实践:Android版微信如何大幅提升交互性能(二)》 《移动端IM实践:实现Android版微信的智能心跳机制》 《移动端IM实践:WhatsApp、Line、微信的心跳策略分析》 《移动端IM实践:谷歌消息推送服务(GCM)研究(来自微信)》 《移动端IM实践:iOS版微信的多设备字体适配方案探讨》 《信鸽团队原创:一起走过 iOS10 上消息推送(APNS)的坑》 《腾讯信鸽技术分享:百亿级实时消息推送的实战经验》 《IPv6技术详解:基本概念、应用现状、技术实践(上篇)》 《IPv6技术详解:基本概念、应用现状、技术实践(下篇)》 《腾讯TEG团队原创:基于MySQL的分布式数据库TDSQL十年锻造经验分享》 《微信多媒体团队访谈:音视频开发的学习、微信的音视频技术和挑战等》 《了解iOS消息推送一文就够:史上最全iOS Push技术详解》 《腾讯技术分享:微信小程序音视频技术背后的故事》 《腾讯资深架构师干货总结:一文读懂大型分布式系统设计的方方面面》 《微信多媒体团队梁俊斌访谈:聊一聊我所了解的音视频技术》 《腾讯音视频实验室:使用AI黑科技实现超低码率的高清实时视频聊天》 《腾讯技术分享:微信小程序音视频与WebRTC互通的技术思路和实践》 《手把手教你读取Android版微信和手Q的聊天记录(仅作技术研究学习)》 >> 更多同类文章 …… (本文同步发布于:http://www.52im.net/thread-1992-1-1.html)
1、引言 消息是互联网信息的一种表现形式,是人利用计算机进行信息传递的有效载体,比如即时通讯网坛友最熟悉的即时通讯消息就是其具体的表现形式之一。 消息从发送者到接收者的典型传递方式有两种: 1)一种我们可以称为即时消息:即消息从一端发出后(消息发送者)立即就可以达到另一端(消息接收者),这种方式的具体实现就是平时最常见的IM聊天消息; 2)另一种称为延迟消息:即消息从某端发出后,首先进入一个容器进行临时存储,当达到某种条件后,再由这个容器发送给另一端。 在上述“消息传递方式2)”中所指的这个容器的一种具体实现就是MQ消息队列服务。 MQ消息队列中间件是中大型分布式系统中重要的组件,它主要用来解决:应用解耦、异步消息、流量削锋等问题,用以实现高性能、高可用、可伸缩和最终一致性架构。目前使用较多的消息队列有ActiveMQ、RabbitMQ、ZeroMQ、Kafka、MetaMQ、RocketMQ等。MQ消息队列中间件已被广泛用于电商、即时通讯、社交等各种中大型分布式应用系统。 ▲ 各种MQ消息队列,玲琅满目 在一个典型的IM即时通讯应用中,MQ消息队列可以用于: 1)用户的聊天消息离线存储环节:因为IM消息的发送属于高吞吐场景,直接操纵DB很容易就把DB搞挂了,所以离线消息在落地入库前,可以先扔到MQ消息队列中,再由单独部署的消费者来有节奏地存储到DB中; 2)用户的行为数据收集环节:因为用户的聊天消息和指令等,可以用于大数据分析,而且基于国家监管要求也是必须要存储一段时间的,所以此类数据的收集同样可以用于MQ消息队列,再由单独部署的消费者存储到DB中; 3)用户的操作日志收集环节:log这种数据价值不高,但关键时刻又非常有用,而且数据量又很大,要想存储起来难度很高,这时就轮到Linkedin公司开源的Kafka出场了; .... 因此,对于即时通讯开发者来说,正确地理解MQ消息队列,对于IM或消息推送系统的架构设计、方案选型等都大有裨益。 ▲ 一个典型的消息队列原理图(生产者将消息通过队列传递给消费者) 学习交流: - 即时通讯开发交流3群:185926912 [推荐] - 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》 (本文同步发布于:http://www.52im.net/thread-1979-1-1.html) 2、系列文章 ▼ IM开发干货系列文章(本文是其第16篇): 《IM消息送达保证机制实现(一):保证在线实时消息的可靠投递》 《IM消息送达保证机制实现(二):保证离线消息的可靠投递》 《如何保证IM实时消息的“时序性”与“一致性”?》 《IM单聊和群聊中的在线状态同步应该用“推”还是“拉”?》 《IM群聊消息如此复杂,如何保证不丢不重?》 《一种Android端IM智能心跳算法的设计与实现探讨(含样例代码)》 《移动端IM登录时拉取数据如何作到省流量?》 《通俗易懂:基于集群的移动端IM接入层负载均衡方案分享》 《浅谈移动端IM的多点登陆和消息漫游原理》 《IM开发基础知识补课(一):正确理解前置HTTP SSO单点登陆接口的原理》 《IM开发基础知识补课(二):如何设计大量图片文件的服务端存储架构?》 《IM开发基础知识补课(三):快速理解服务端数据库读写分离原理及实践建议》 《IM开发基础知识补课(四):正确理解HTTP短连接中的Cookie、Session和Token》 《IM群聊消息的已读回执功能该怎么实现?》 《IM群聊消息究竟是存1份(即扩散读)还是存多份(即扩散写)?》 《IM开发基础知识补课(五):通俗易懂,正确理解并用好MQ消息队列》(本文) 如果您是IM开发初学者,强烈建议首先阅读《新手入门一篇就够:从零开发移动端IM》。 3、MQ消息队列的典型应用场景 MQ消息队列目前在中大型分布式系统实际应用中常用的使用场景主要有:异步处理、应用解耦、流量削锋和消息通讯四个场景。 3.1 应用场景1:异步处理 场景说明:一个典型的IM即时通讯系统中,用户注册成功后可能需要发送注册邮件和注册通知短信。 传统的做法有两种: 1)串行的方式:即将注册信息写入数据库成功后、发送注册邮件、再发送注册短信。以上三个任务全部完成后,返回给客户端; 2)并行方式:即将注册信息写入数据库成功后,发送注册邮件的同时,发送注册短信。以上三个任务完成后,返回给客户端。与串行的差别是,并行的方式可以提高处理的时间。 假设三个业务节点每个使用50毫秒,不考虑网络等其他开销,则串行方式的时间是150毫秒,并行的时间可能是100毫秒。 因为CPU在单位时间内处理的请求数是一定的,假设CPU1秒内吞吐量是100次。则串行方式1秒内CPU可处理的请求量是7次(1000/150)。并行方式处理的请求量是10次(1000/100)。 小结:如以上案例描述,传统的方式系统的性能(并发量,吞吐量,响应时间)会有瓶颈。 如何解决这个问题呢?答案是:引入消息队列,将不是必须的业务逻辑,异步处理。 改造后的架构如下: 按照以上约定,用户的响应时间相当于是注册信息写入数据库的时间,也就是50毫秒。注册邮件,发送短信写入消息队列后,直接返回,因此写入消息队列的速度很快,基本可以忽略,因此用户的响应时间可能是50毫秒。因此架构改变后,系统的吞吐量提高到每秒20 QPS。比串行提高了3倍,比并行提高了两倍。 3.2 应用场景2:应用解耦 场景说明:一个典型的电商购物系统中,用户下订单后,订单系统需要通知库存系统。 传统的做法是:订单系统调用库存系统的接口。如下图所示: 传统模式的缺点:假如库存系统无法访问,则订单减库存将失败,从而导致订单失败,订单系统与库存系统耦合。 如何解决以上问题呢?答案是:引入应用消息队列后的方案。如下图: 如上图所示,大致的原理是: 订单系统:用户下单后,订单系统完成持久化处理,将消息写入消息队列,返回用户订单下单成功; 库存系统:订阅下单的消息,采用拉/推的方式,获取下单信息,库存系统根据下单信息,进行库存操作。 好处就是:假如在下单时库存系统不能正常使用,也不影响正常下单,因为下单后,订单系统写入消息队列就不再关心其他的后续操作了。实现订单系统与库存系统的应用解耦。 3.3 应用场景3:流量削锋 流量削锋也是消息队列中的常用场景,一般在电商秒杀等大型活动(比如双11)、团购抢单活动中使用广泛。 应用场景:秒杀活动,一般会因为流量过大,导致流量暴增,应用挂掉。为解决这个问题,一般需要在应用前端加入消息队列。 在这种场景下加入消息队列服务的好处: 1)可以控制活动的人数; 2)可以缓解短时间内高流量压垮应用。 ▲ 原理图如上图所示 用户的请求,服务器接收后,首先写入消息队列。假如消息队列长度超过最大数量,则直接抛弃用户请求或跳转到错误页面。秒杀业务根据消息队列中的请求信息,再做后续处理。 3.4 应用场景4:日志处理 日志处理是指将消息队列用在日志处理中,比如Linkedin这种大型职业社交应用架构中Kafka的应用(Kafka就是Linkedin开发并开源的),解决大量日志传输的问题。 使用Kafka后的架构简化如下: 上图所示的架构原理就是: 日志采集客户端:负责日志数据采集,定时写入Kafka队列; Kafka消息队列:负责日志数据的接收,存储和转发; 日志处理应用:订阅并消费kafka队列中的日志数据。 3.5 应用场景5:即时消息通讯 即时消息通讯是指,消息队列一般都内置了高效的通信机制,因此也可以用在纯的即时消息通讯场景。比如实现点对点消息队列或者IM聊天室等(但Jack Jiang认为,在中大型IM系统中,MQ并不适合这么用,具体的讨论请见:《请教可以使用MQ消息队列中间件做即时通讯系统吗?》)。 点对点通讯:客户端A和客户端B使用同一队列,进行消息通讯; 聊天室通讯:客户端A,客户端B,客户端N订阅同一主题,进行消息发布和接收。实现类似聊天室效果。 以上实际是消息队列的两种消息模式,点对点或发布订阅模式。模型为示意图,供参考。 4、MQ消息队列的常见消息模式 常见的MQ消息队列消息模式有: 1)P2P模式; 2)Pub/sub模式(也就是常说的“发布/订阅”模式); 3)推(Push)模式和拉(Pull)模式。 下面将逐个介绍这几种常消息模式。 4.1 P2P模式 ▲ 典型的P2P消息模式原理图 P2P模式包含三个角色: 1)消息队列(Queue); 2)发送者(Sender); 3)接收者(Receiver)。 每个消息都被发送到一个特定的队列,接收者从队列中获取消息。队列保留着消息,直到他们被消费或超时。 P2P消息模式的特点: 每个消息只有一个消费者(Consumer)(即一旦被消费,消息就不再在消息队列中)发送者和接收者之间在时间上没有依赖性,也就是说当发送者发送了消息之后,不管接收者有没有正在运行,它不会影响到消息被发送到队列接收者在成功接收消息之后需向队列应答成功 如果希望发送的每个消息都会被成功处理的话,那么需要P2P模式。 4.2 Pub/sub模式 ▲ 典型的Pub/sub消息模式原理图 如上图所示,此消息模式包含三个角色: 1)主题(Topic); 2)发布者(Publisher); 3)订阅者(Subscriber)。 多个发布者将消息发送到Topic,系统将这些消息传递给多个订阅者。 Pub/Sub的特点: 每个消息可以有多个消费者发布者和订阅者之间有时间上的依赖性。针对某个主题(Topic)的订阅者,它必须创建一个订阅者之后,才能消费发布者的消息。为了消费消息,订阅者必须保持运行的状态。为了缓和这样严格的时间相关性,有些MQ消息队列(比如RabbitMQ)允许订阅者创建一个可持久化的订阅。这样,即使订阅者没有被激活(运行),它也能接收到发布者的消息。如果希望发送的消息可以不被做任何处理、或者只被一个消息者处理、或者可以被多个消费者处理的话,那么可以采用Pub/Sub模型。 4.3 推模式和拉模式 ▲ 一个典型的推模式和拉模式原理图 推(push)模式是一种基于C/S机制、由服务器主动将信息送到客户器的技术。 在Push模式应用中,服务器把信息送给客户器之前,并没有明显的客户请求。push事务由服务器发起。push模式可以让信息主动、快速地寻找用户/客户器,信息的主动性和实时性比较好。但精确性较差,可能推送的信息并不一定满足客户的需求。 Push模式不能保证能把信息送到客户器,因为推模式采用了广播机制,如果客户器正好联网并且和服务器在同一个频道上,推送模式才是有效的。 Push模式无法跟踪状态,采用了开环控制模式,没有用户反馈信息。在实际应用中,由客户器向服务器发送一个申请,并把自己的地址(如IP、port)告知服务器,然后服务器就源源不断地把信息推送到指定地址。在多媒体信息广播中也采用了推模式。 拉(Pull)模式与推(Push)模式相反,是由客户器主动发起的事务。服务器把自己所拥有的信息放在指定地址(如IP、port),客户器向指定地址发送请求,把自己需要的资源“拉”回来。不仅可以准确获取自己需要的资源,还可以及时把客户端的状态反馈给服务器。 5、主流的MQ消息队列技术选型对比 一份主流MQ技术对比清单: 另外,即时通讯网整理的另一篇《IM系统的MQ消息中间件选型:Kafka还是RabbitMQ?》,可以详细了解一下Kafka和RabbitMQ的对比。 5.1 Kafka Kafka是Linkedin开源的MQ系统(现已是Apache下的一个子项目),它是一个高性能跨语言分布式发布/订阅消息队列系统,主要特点是基于Pull的模式来处理消息消费,追求高吞吐量,一开始的目的就是用于日志收集和传输,0.8开始支持复制,不支持事务,适合产生大量数据的互联网服务的数据收集业务。 Kafka还具有以下特性: 1)快速持久化,可以在O(1)的系统开销下进行消息持久化; 2)高吞吐,在一台普通的服务器上既可以达到10W/s的吞吐速率; 3)完全的分布式系统,Broker、Producer、Consumer都原生自动支持分布式,自动实现负载均衡; 4)支持Hadoop数据并行加载,对于像Hadoop的一样的日志数据和离线分析系统,但又要求实时处理的限制,这是一个可行的解决方案。Kafka通过Hadoop的并行加载机制统一了在线和离线的消息处理。 Apache Kafka相对于ActiveMQ是一个非常轻量级的消息系统,除了性能非常好之外,还是一个工作良好的分布式系统。 5.2 RabbitMQ RabbitMQ是使用Erlang语言开发的开源消息队列系统,基于AMQP协议来实现。AMQP的主要特征是面向消息、队列、路由(包括点对点和发布/订阅)、可靠性、安全。AMQP协议更多用在企业系统内,对数据一致性、稳定性和可靠性要求很高的场景,对性能和吞吐量的要求还在其次。 RabbitMQ本身支持很多的协议:AMQP,XMPP, SMTP, STOMP,也正因如此,它非常重量级,更适合于企业级的开发。同时实现了Broker构架,这意味着消息在发送给客户端时先在中心队列排队。对路由,负载均衡或者数据持久化都有很好的支持。 5.3 RocketMQ RocketMQ是阿里开源的消息中间件,它是纯Java开发,具有高吞吐量、高可用性、适合大规模分布式系统应用的特点。RocketMQ思路起源于Kafka,但并不是Kafka的一个Copy,它对消息的可靠传输及事务性做了优化,目前在阿里集团被广泛应用于交易、充值、流计算、消息推送、日志流式处理、binglog分发等场景。 5.4 ZeroMQ ZeroMQ只是一个网络编程的Pattern库,将常见的网络请求形式(分组管理,链接管理,发布订阅等)模式化、组件化,简而言之socket之上、MQ之下。对于MQ来说,网络传输只是它的一部分,更多需要处理的是消息存储、路由、Broker服务发现和查找、事务、消费模式(ack、重投等)、集群服务等。 ZeroMQ是号称最快的消息队列系统,尤其针对大吞吐量的需求场景。ZeroMQ能够实现RabbitMQ不擅长的高级/复杂的队列,但是开发人员需要自己组合多种技术框架,技术上的复杂度是对这MQ能够应用成功的挑战。ZeroMQ具有一个独特的非中间件的模式,你不需要安装和运行一个消息服务器或中间件,因为你的应用程序将扮演这个服务器角色。你只需要简单的引用ZeroMQ程序库,可以使用NuGet安装,然后你就可以愉快的在应用程序之间发送消息了。但是ZeroMQ仅提供非持久性的队列,也就是说如果宕机,数据将会丢失。其中,Twitter的Storm 0.9.0以前的版本中默认使用ZeroMQ作为数据流的传输(Storm从0.9版本开始同时支持ZeroMQ和Netty作为传输模块)。 5.5 小结 RabbitMQ/Kafka/ZeroMQ 都能提供消息队列服务,但有很大的区别。 在面向服务架构中通过消息代理(比如 RabbitMQ / Kafka等),使用生产者-消费者模式在服务间进行异步通信是一种比较好的思想。 因为服务间依赖由强耦合变成了松耦合。消息代理都会提供持久化机制,在消费者负载高或者掉线的情况下会把消息保存起来,不会丢失。就是说生产者和消费者不需要同时在线,这是传统的请求-应答模式比较难做到的,需要一个中间件来专门做这件事。其次消息代理可以根据消息本身做简单的路由策略,消费者可以根据这个来做负载均衡,业务分离等。 缺点也有,就是需要额外搭建消息代理集群(但优点是大于缺点的 ) 。 ZeroMQ 和 RabbitMQ/Kafka 不同,它只是一个异步消息库,在套接字的基础上提供了类似于消息代理的机制。使用 ZeroMQ 的话,需要对自己的业务代码进行改造,不利于服务解耦。 RabbitMQ 支持 AMQP(二进制),STOMP(文本),MQTT(二进制),HTTP(里面包装其他协议)等协议。Kafka 使用自己的协议。 Kafka 自身服务和消费者都需要依赖 Zookeeper。 RabbitMQ 在有大量消息堆积的情况下性能会下降,Kafka不会。毕竟AMQP设计的初衷不是用来持久化海量消息的,而Kafka一开始是用来处理海量日志的。 总的来说,RabbitMQ 和 Kafka 都是十分优秀的分布式的消息代理服务,只要合理部署,基本上可以满足生产条件下的任何需求。 关于这两种MQ的比较,网上的资料并不多,最权威的的是kafka的提交者写一篇文章:http://www.quora.com/What-are-the-differences-between-Apache-Kafka-and-RabbitMQ 这篇文间里面提到的要点: 1) RabbitMq比kafka成熟,在可用性上,稳定性上,可靠性上,RabbitMq超过kafka; 2) Kafka设计的初衷就是处理日志的,可以看做是一个日志系统,针对性很强,所以它并没有具备一个成熟MQ应该具备的特性; 3) Kafka的性能(吞吐量、tps)比RabbitMq要强,这篇文章的作者认为,两者在这方面没有可比性; 4)总的来说,目前RocketMq、Kafka、RabbitMq在各家公司都有使用,具体看技术团队的熟悉程度及使用场景了。 附录1:有关IM即时通讯架构设计的文章 《浅谈IM系统的架构设计》 《简述移动端IM开发的那些坑:架构设计、通信协议和客户端》 《一套海量在线用户的移动端IM架构设计实践分享(含详细图文)》 《一套原创分布式即时通讯(IM)系统理论架构方案》 《从零到卓越:京东客服即时通讯系统的技术架构演进历程》 《蘑菇街即时通讯/IM服务器开发之架构选择》 《腾讯QQ1.4亿在线用户的技术挑战和架构演进之路PPT》 《微信后台基于时间序的海量数据冷热分级架构设计实践》 《微信技术总监谈架构:微信之道——大道至简(演讲全文)》 《如何解读《微信技术总监谈架构:微信之道——大道至简》》 《快速裂变:见证微信强大后台架构从0到1的演进历程(一)》 《17年的实践:腾讯海量产品的技术方法论》 《移动端IM中大规模群消息的推送如何保证效率、实时性?》 《现代IM系统中聊天消息的同步和存储方案探讨》 《IM开发基础知识补课(二):如何设计大量图片文件的服务端存储架构?》 《IM开发基础知识补课(三):快速理解服务端数据库读写分离原理及实践建议》 《IM开发基础知识补课(四):正确理解HTTP短连接中的Cookie、Session和Token》 《WhatsApp技术实践分享:32人工程团队创造的技术神话》 《微信朋友圈千亿访问量背后的技术挑战和实践总结》 《王者荣耀2亿用户量的背后:产品定位、技术架构、网络方案等》 《IM系统的MQ消息中间件选型:Kafka还是RabbitMQ?》 《腾讯资深架构师干货总结:一文读懂大型分布式系统设计的方方面面》 《以微博类应用场景为例,总结海量社交系统的架构设计步骤》 《快速理解高性能HTTP服务端的负载均衡技术原理》 《子弹短信光鲜的背后:网易云信首席架构师分享亿级IM平台的技术实践》 《知乎技术分享:从单机到2000万QPS并发的Redis高性能缓存实践之路》 《IM开发基础知识补课(五):通俗易懂,正确理解并用好MQ消息队列》 >> 更多同类文章 …… 附录2:有关IM即时通讯的更多热点问题的文章 《移动端IM开发者必读(一):通俗易懂,理解移动网络的“弱”和“慢”》 《移动端IM开发者必读(二):史上最全移动弱网络优化方法总结》 《从客户端的角度来谈谈移动端IM的消息可靠性和送达机制》 《现代移动端网络短连接的优化手段总结:请求速度、弱网适应、安全保障》 《腾讯技术分享:社交网络图片的带宽压缩技术演进之路》 《小白必读:闲话HTTP短连接中的Session和Token》 《IM开发基础知识补课:正确理解前置HTTP SSO单点登陆接口的原理》 《移动端IM中大规模群消息的推送如何保证效率、实时性?》 《移动端IM开发需要面对的技术问题》 《开发IM是自己设计协议用字节流好还是字符流好?》 《请问有人知道语音留言聊天的主流实现方式吗?》 《IM消息送达保证机制实现(一):保证在线实时消息的可靠投递》 《IM消息送达保证机制实现(二):保证离线消息的可靠投递》 《如何保证IM实时消息的“时序性”与“一致性”?》 《一个低成本确保IM消息时序的方法探讨》 《IM单聊和群聊中的在线状态同步应该用“推”还是“拉”?》 《IM群聊消息如此复杂,如何保证不丢不重?》 《谈谈移动端 IM 开发中登录请求的优化》 《移动端IM登录时拉取数据如何作到省流量?》 《浅谈移动端IM的多点登陆和消息漫游原理》 《完全自已开发的IM该如何设计“失败重试”机制?》 《通俗易懂:基于集群的移动端IM接入层负载均衡方案分享》 《微信对网络影响的技术试验及分析(论文全文)》 《即时通讯系统的原理、技术和应用(技术论文)》 《开源IM工程“蘑菇街TeamTalk”的现状:一场有始无终的开源秀》 《QQ音乐团队分享:Android中的图片压缩技术详解(上篇)》 《QQ音乐团队分享:Android中的图片压缩技术详解(下篇)》 《腾讯原创分享(一):如何大幅提升移动网络下手机QQ的图片传输速度和成功率》 《腾讯原创分享(二):如何大幅压缩移动网络下APP的流量消耗(上篇)》 《腾讯原创分享(三):如何大幅压缩移动网络下APP的流量消耗(下篇)》 《如约而至:微信自用的移动端IM网络层跨平台组件库Mars已正式开源》 《基于社交网络的Yelp是如何实现海量用户图片的无损压缩的?》 《腾讯技术分享:腾讯是如何大幅降低带宽和网络流量的(图片压缩篇)》 《腾讯技术分享:腾讯是如何大幅降低带宽和网络流量的(音视频技术篇)》 《为什么说即时通讯社交APP创业就是一个坑?》 《字符编码那点事:快速理解ASCII、Unicode、GBK和UTF-8》 《全面掌握移动端主流图片格式的特点、性能、调优等》 《最火移动端跨平台方案盘点:React Native、weex、Flutter》 《子弹短信光鲜的背后:网易云信首席架构师分享亿级IM平台的技术实践》 《IM开发基础知识补课(五):通俗易懂,正确理解并用好MQ消息队列》 >> 更多同类文章 …… (本文同步发布于:http://www.52im.net/thread-1979-1-1.html)
1、MMKV简介 腾讯微信团队于2018年9月底宣布开源 MMKV ,这是基于 mmap 内存映射的 key-value 组件,底层序列化/反序列化使用 protobuf 实现,主打高性能和稳定性。近期也已移植到 Android 平台,一并对外开源。 MMKV 是基于 mmap 内存映射的 key-value 组件,底层序列化/反序列化使用 protobuf 实现,性能高,稳定性强。从 2015 年中至今,在 iOS 微信上使用已有近 3 年,其性能和稳定性经过了时间的验证。近期也已移植到 Android 平台,一并开源。 MMKV最新源码托管地址:https://github.com/Tencent/MMKV 2、MMKV 源起 在微信客户端的日常运营中,时不时就会爆发特殊文字引起系统的 crash(请参见文章:《微信团队分享:iOS版微信是如何防止特殊字符导致的炸群、APP崩溃的?》、《微信团队分享:iOS版微信的高性能通用key-value组件技术实践》),文章里面设计的技术方案是在关键代码前后进行计数器的加减,通过检查计数器的异常,来发现引起闪退的异常文字。在会话列表、会话界面等有大量 cell 的地方,希望新加的计时器不会影响滑动性能;另外这些计数器还要永久存储下来——因为闪退随时可能发生。 这就需要一个性能非常高的通用 key-value 存储组件,我们考察了 SharedPreferences、NSUserDefaults、SQLite 等常见组件,发现都没能满足如此苛刻的性能要求。考虑到这个防 crash 方案最主要的诉求还是实时写入,而 mmap 内存映射文件刚好满足这种需求,我们尝试通过它来实现一套 key-value 组件。 3、MMKV 原理 内存准备: 通过 mmap 内存映射文件,提供一段可供随时写入的内存块,App 只管往里面写数据,由操作系统负责将内存回写到文件,不必担心 crash 导致数据丢失。 数据组织: 数据序列化方面我们选用 protobuf 协议,pb 在性能和空间占用上都有不错的表现。 写入优化: 考虑到主要使用场景是频繁地进行写入更新,我们需要有增量更新的能力。我们考虑将增量 kv 对象序列化后,append 到内存末尾。 空间增长: 使用 append 实现增量更新带来了一个新的问题,就是不断 append 的话,文件大小会增长得不可控。我们需要在性能和空间上做个折中。 更详细的设计原理参考MMKV 原理。 4、iOS 指南 安装引入(推荐使用 CocoaPods): 安装CocoaPods; 打开命令行,cd到你的项目工程目录, 输入pod repo update让 CocoaPods 感知最新的 MMKV 版本; 打开 Podfile, 添加pod 'MMKV'到你的 app target 里面; 在命令行输入pod install; 用 Xcode 打开由 CocoaPods 自动生成的.xcworkspace文件; 添加头文件#import <MMKV/MMKV.h>,就可以愉快地开始你的 MMKV 之旅了。 更多安装指引参考iOS Setup。 快速上手: MMKV 的使用非常简单,无需任何配置,所有变更立马生效,无需调用synchronize: MMKV *mmkv = [MMKV defaultMMKV]; [mmkvsetBool:YESforKey:@"bool"];BOOL bValue = [mmkvgetBoolForKey:@"bool"]; [mmkvsetInt32:-1024forKey:@"int32"];int32_t iValue = [mmkvgetInt32ForKey:@"int32"]; [mmkvsetObject:@"hello, mmkv"forKey:@"string"];NSString *str = [mmkvgetObjectOfClass:NSString.classforKey:@"string"]; 更详细的使用教程参考iOS Tutorial。 性能对比: 循环写入随机的int1w 次,我们有如下性能对比: 更详细的性能对比参考iOS Benchmark。 5、Android 指南 安装引入: 推荐使用 Maven: dependencies{implementation'com.tencent:mmkv:1.0.10'// replace"1.0.10"with any available version} 更多安装指引参考Android Setup。 快速上手: MMKV 的使用非常简单,所有变更立马生效,无需调用sync、apply。 在 App 启动时初始化 MMKV,设定 MMKV 的根目录(files/mmkv/),例如在 MainActivity 里: protectedvoidonCreate(Bundle savedInstanceState){super.onCreate(savedInstanceState); String rootDir = MMKV.initialize(this); System.out.println("mmkv root: "+ rootDir);//……} MMKV 提供一个全局的实例,可以直接使用: importcom.tencent.mmkv.MMKV;//……MMKV kv = MMKV.defaultMMKV();kv.encode("bool",true);booleanbValue = kv.decodeBool("bool");kv.encode("int", Integer.MIN_VALUE);intiValue = kv.decodeInt("int");kv.encode("string","Hello from mmkv");String str = kv.decodeString("string"); MMKV 支持多进程访问,更详细的用法参考Android Tutorial。 性能对比: 循环写入随机的int1k 次,我们有如下性能对比: 更详细的性能对比参考Android Benchmark。
本文引用了公众号纯洁的微笑作者奎哥的技术文章,感谢原作者的分享。 1、前言 老于网络编程熟手来说,在测试和部署网络通信应用(比如IM聊天、实时音视频等)时,如果发现网络连接超时,第一时间想到的就是使用Ping命令Ping一下服务器看看通不通。甚至在有些情况下通过图形化的Ping命令工具对目标网络进行长测(比如:《两款增强型Ping工具:持续统计、图形化展式网络状况 [附件下载]》、《网络测试:Android版多路ping命令工具EnterprisePing[附件下载]》),可以得出当前网络通信的网络延迟、网络丢包率、网络抖动等等有价值信息。 Ping命令很简单,但作为为数不多的网络检测工具,却非常有用,是开发网络应用时最常用到的命令。虽然“Ping”这个动作这么简单,但你知道Ping命令背后后的逻辑吗?这就是本文要告诉你! 学习交流: - 即时通讯开发交流3群:185926912 [推荐] - 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》 (本文同步发布于:http://www.52im.net/thread-1973-1-1.html) 2、系列文章 本文是系列文章中的第5篇,本系列大纲如下: 《脑残式网络编程入门(一):跟着动画来学TCP三次握手和四次挥手》 《脑残式网络编程入门(二):我们在读写Socket时,究竟在读写什么?》 《脑残式网络编程入门(三):HTTP协议必知必会的一些知识》 《脑残式网络编程入门(四):快速理解HTTP/2的服务器推送(Server Push)》 《脑残式网络编程入门(五):每天都在用的Ping命令,它到底是什么?》(本文) 3、Ping命令的作用和原理 简单来说,「ping」是用来探测本机与网络中另一主机之间是否可达的命令,如果两台主机之间ping不通,则表明这两台主机不能建立起连接。ping是定位网络通不通的一个重要手段。 ping 命令是基于 ICMP 协议来工作的,「 ICMP 」全称为 Internet 控制报文协议(Internet Control Message Protocol)。ping 命令会发送一份ICMP回显请求报文给目标主机,并等待目标主机返回ICMP回显应答。因为ICMP协议会要求目标主机在收到消息之后,必须返回ICMP应答消息给源主机,如果源主机在一定时间内收到了目标主机的应答,则表明两台主机之间网络是可达的。 举一个例子来描述「ping」命令的工作过程: 1)假设有两个主机,主机A(192.168.0.1)和主机B(192.168.0.2),现在我们要监测主机A和主机B之间网络是否可达,那么我们在主机A上输入命令:ping 192.168.0.2; 2)此时,ping命令会在主机A上构建一个 ICMP的请求数据包(数据包里的内容后面再详述),然后 ICMP协议会将这个数据包以及目标IP(192.168.0.2)等信息一同交给IP层协议; 3)IP层协议得到这些信息后,将源地址(即本机IP)、目标地址(即目标IP:192.168.0.2)、再加上一些其它的控制信息,构建成一个IP数据包; 4)IP数据包构建完成后,还不够,还需要加上MAC地址,因此,还需要通过ARP映射表找出目标IP所对应的MAC地址。当拿到了目标主机的MAC地址和本机MAC后,一并交给数据链路层,组装成一个数据帧,依据以太网的介质访问规则,将它们传送出出去; 5)当主机B收到这个数据帧之后,会首先检查它的目标MAC地址是不是本机,如果是就接收下来处理,接收之后会检查这个数据帧,将数据帧中的IP数据包取出来,交给本机的IP层协议,然后IP层协议检查完之后,再将ICMP数据包取出来交给ICMP协议处理,当这一步也处理完成之后,就会构建一个ICMP应答数据包,回发给主机A; 6)在一定的时间内,如果主机A收到了应答包,则说明它与主机B之间网络可达,如果没有收到,则说明网络不可达。除了监测是否可达以外,还可以利用应答时间和发起时间之间的差值,计算出数据包的延迟耗时。 通过ping的流程可以发现,ICMP协议是这个过程的基础,是非常重要的,下面的章节会把ICMP协议再详细解释一下,请继续往下读。 4、正确理解ICMP协议 Ping命令所基于的ICMP协议所处的网络模型层级: (▲ 上图来自《计算机网络通讯协议关系图(中文珍藏版)[附件下载]》,您可下载此图的完整清晰版) Ping命令这么简单,在任何系统上上手就能使用,很多人可能想当然的认为Ping命令使用的ICMP协议应该是基于传输层的TCP或UDP协议的吧。 正如上图所示,ICMP协议既不是基于TCP,也不是基于UDP,而是直接基于网络层的IP协议,在整个网络协议栈中属于相当底层的协议了。这也从侧面证明了它的重要性,因为根据ICMP的RFC手册规定:ICMP协议是任何支持IP协议的系统必须实现的,没有余地。而IP协议是整个互联网的基石,ICMP协议虽简单,但重要性不言而喻。 所以,以后面视的时候,如果碰到“ICMP协议是基于什么实现的?”这样的问题,请一定要记往此节所讲的内容。 5、深入ICMP协议 我们知道,ping命令是基于ICMP协议来实现的。那么我们再来看下图,就明白了ICMP协议又是通过IP协议来发送的,即ICMP报文是封装在IP包中(如下图所示)。 IP协议是一种无连接的,不可靠的数据包协议,它并不能保证数据一定被送达,那么我们要保证数据送到就需要通过其它模块来协助实现,这里就引入的是ICMP协议。 当传送的IP数据包发送异常的时候,ICMP就会将异常信息封装在包内,然后回传给源主机。 将上图再细拆一下可见: 继续将ICMP协议模块细拆: 由图可知,ICMP数据包由8bit的类型字段和8bit的代码字段以及16bit的校验字段再加上选项数据组成。 ICMP协议大致可分为两类: 1)查询报文类型; 2)差错报文类型。 【关于查询报文类型】: 查询报文主要应用于:ping查询、子网掩码查询、时间戳查询等等。 上面讲到的ping命令的流程其实就对应ICMP协议查询报文类型的一种使用。在主机A构建ICMP请求数据包的时候,其ICMP的类型字段中使用的是 8 (回送请求),当主机B构建ICMP应答包的时候,其ICMP类型字段就使用的是 0 (回送应答),更多类型值参考上表。 对 查询报文类型 的理解可参考一下文章最开始讲的ping流程,这里就不做赘述。 【关于差错报文类型】: 差错报文主要产生于当数据传送发送错误的时候。 它包括:目标不可达(网络不可达、主机不可达、协议不可达、端口不可达、禁止分片等)、超时、参数问题、重定向(网络重定向、主机重定向等)等等。 差错报文通常包含了引起错误的IP数据包的第一个分片的IP首部,加上该分片数据部分的前8个字节。 当传送IP数据包发生错误的时候(例如 主机不可达),ICMP协议就会把错误信息封包,然后传送回源主机,那么源主机就知道该怎么处理了。 6、ICMP差错报文的妙用 正如上一节所介绍的那样,ICMP协议主要有:查询报文类型和差错报文类型两种。对于差错报文来说,是不是只有遇到错误的时候才能使用呢?不是! 基于这个特性,Linux下的Traceroute指令(Windows下的对等指令是tracert)利于ICMP的差错报文可以实现遍历到数据包传输路径上的所有路由器!这真是个有用的命令! 百度百科上关于traceroute命令的用途: traceroute (Windows 系统下是tracert) 命令利用ICMP 协议定位您的计算机和目标计算机之间的所有路由器。TTL 值可以反映数据包经过的路由器或网关的数量,通过操纵独立ICMP 呼叫报文的TTL 值和观察该报文被抛弃的返回信息,traceroute命令能够遍历到数据包传输路径上的所有路由器。 ICMP的差错报文的使用,使得Traceroute成为用来侦测源主机到目标主机之间所经过路由情况的常用工具。Traceroute 的原理就是利用ICMP的规则,制造一些错误的事件出来,然后根据错误的事件来评估网络路由情况。 traceroute的基本原理如下图所示: 具体做法就是: 1)Traceroute会设置特殊的TTL值,来追踪源主机和目标主机之间的路由数。首先它给目标主机发送一个 TTL=1 的UDP数据包,那么这个数据包一旦在路上遇到一个路由器,TTL就变成了0(TTL规则是每经过一个路由器都会减1),因为TTL=0了,所以路由器就会把这个数据包丢掉,然后产生一个错误类型(超时)的ICMP数据包回发给源主机,也就是差错包。这个时候源主机就拿到了第一个路由节点的IP和相关信息了; 2)接着,源主机再给目标主机发一个 TTL=2 的UDP数据包,依旧上述流程走一遍,就知道第二个路由节点的IP和耗时情况等信息了; 3)如此反复进行,Traceroute就可以拿到从主机A到主机B之间所有路由器的信息了。 但是有个问题是,如果数据包到达了目标主机的话,即使目标主机接收到TTL值为1的IP数据包,它也是不会丢弃该数据包的,也不会产生一份超时的ICMP回发数据包的,因为数据包已经达到了目的地嘛。那我们应该怎么认定数据包是否达到了目标主机呢? Traceroute的方法是在源主机发送UDP数据包给目标主机的时候,会设置一个不可能达到的目标端口号(例如大于30000的端口号),那么当这个数据包真的到达目标主机的时候,目标主机发现没有对应的端口号,因此会产生一份“端口不可达”的错误ICMP报文返回给源主机。 可见Traceroute的原理确实很取巧,很有趣。如您对Traceroute感兴趣,可以深入读一读《从Traceroute看网络问题》一文。 附录:更多网络编程精华文章 《TCP/IP详解 - 第11章·UDP:用户数据报协议》 《TCP/IP详解 - 第17章·TCP:传输控制协议》 《TCP/IP详解 - 第18章·TCP连接的建立与终止》 《TCP/IP详解 - 第21章·TCP的超时与重传》 《技术往事:改变世界的TCP/IP协议(珍贵多图、手机慎点)》 《通俗易懂-深入理解TCP协议(上):理论基础》 《通俗易懂-深入理解TCP协议(下):RTT、滑动窗口、拥塞处理》 《理论经典:TCP协议的3次握手与4次挥手过程详解》 《理论联系实际:Wireshark抓包分析TCP 3次握手、4次挥手过程》 《计算机网络通讯协议关系图(中文珍藏版)》 《UDP中一个包的大小最大能多大?》 《P2P技术详解(一):NAT详解——详细原理、P2P简介》 《P2P技术详解(二):P2P中的NAT穿越(打洞)方案详解》 《P2P技术详解(三):P2P技术之STUN、TURN、ICE详解》 《通俗易懂:快速理解P2P技术中的NAT穿透原理》 《高性能网络编程(一):单台服务器并发TCP连接数到底可以有多少》 《高性能网络编程(二):上一个10年,著名的C10K并发连接问题》 《高性能网络编程(三):下一个10年,是时候考虑C10M并发问题了》 《高性能网络编程(四):从C10K到C10M高性能网络应用的理论探索》 《高性能网络编程(五):一文读懂高性能网络编程中的I/O模型》 《高性能网络编程(六):一文读懂高性能网络编程中的线程模型》 《不为人知的网络编程(一):浅析TCP协议中的疑难杂症(上篇)》 《不为人知的网络编程(二):浅析TCP协议中的疑难杂症(下篇)》 《不为人知的网络编程(三):关闭TCP连接时为什么会TIME_WAIT、CLOSE_WAIT》 《不为人知的网络编程(四):深入研究分析TCP的异常关闭》 《不为人知的网络编程(五):UDP的连接性和负载均衡》 《不为人知的网络编程(六):深入地理解UDP协议并用好它》 《不为人知的网络编程(七):如何让不可靠的UDP变的可靠?》 《网络编程懒人入门(一):快速理解网络通信协议(上篇)》 《网络编程懒人入门(二):快速理解网络通信协议(下篇)》 《网络编程懒人入门(三):快速理解TCP协议一篇就够》 《网络编程懒人入门(四):快速理解TCP和UDP的差异》 《网络编程懒人入门(五):快速理解为什么说UDP有时比TCP更有优势》 《网络编程懒人入门(六):史上最通俗的集线器、交换机、路由器功能原理入门》 《网络编程懒人入门(七):深入浅出,全面理解HTTP协议》 《网络编程懒人入门(八):手把手教你写基于TCP的Socket长连接》 《技术扫盲:新一代基于UDP的低延时网络传输层协议——QUIC详解》 《让互联网更快:新一代QUIC协议在腾讯的技术实践分享》 《现代移动端网络短连接的优化手段总结:请求速度、弱网适应、安全保障》 《聊聊iOS中网络编程长连接的那些事》 《移动端IM开发者必读(一):通俗易懂,理解移动网络的“弱”和“慢”》 《移动端IM开发者必读(二):史上最全移动弱网络优化方法总结》 《IPv6技术详解:基本概念、应用现状、技术实践(上篇)》 《IPv6技术详解:基本概念、应用现状、技术实践(下篇)》 《从HTTP/0.9到HTTP/2:一文读懂HTTP协议的历史演变和设计思路》 《以网游服务端的网络接入层设计为例,理解实时通信的技术挑战》 《迈向高阶:优秀Android程序员必知必会的网络基础》 >> 更多同类文章 …… (本文同步发布于:http://www.52im.net/thread-1973-1-1.html)
本文来自知乎官方技术团队的“知乎技术专栏”,感谢原作者陈鹏的无私分享。 1、引言 知乎存储平台团队基于开源Redis 组件打造的知乎 Redis 平台,经过不断的研发迭代,目前已经形成了一整套完整自动化运维服务体系,提供很多强大的功能。本文作者陈鹏是该系统的负责人,本次文章深入介绍了该系统的方方面面,值得互联网后端程序员仔细研究。 (本文同步发布于:http://www.52im.net/thread-1968-1-1.html) 2、关于作者 陈鹏:现任知乎存储平台组 Redis 平台技术负责人,2014 年加入知乎技术平台组从事基础架构相关系统的开发与运维,从无到有建立了知乎 Redis 平台,承载了知乎高速增长的业务流量。 3、技术背景 知乎作为知名中文知识内容平台,每日处理的访问量巨大 ,如何更好的承载这样巨大的访问量,同时提供稳定低时延的服务保证,是知乎技术平台同学需要面对的一大挑战。 知乎存储平台团队基于开源 Redis 组件打造的 Redis 平台管理系统,经过不断的研发迭代,目前已经形成了一整套完整自动化运维服务体系,提供一键部署集群,一键自动扩缩容, Redis 超细粒度监控,旁路流量分析等辅助功能。 目前,Redis 在知乎的应用规模如下: 1)机器内存总量约 70TB,实际使用内存约 40TB; 2)平均每秒处理约 1500 万次请求,峰值每秒约 2000 万次请求; 3)每天处理约 1 万亿余次请求; 4)单集群每秒处理最高每秒约 400 万次请求; 5)集群实例与单机实例总共约 800 个; 6)实际运行约 16000 个 Redis 实例; 7)Redis 使用官方 3.0.7 版本,少部分实例采用 4.0.11 版本。 4、知乎的Redis应用类型 根据业务的需求,我们将Redis实例区分为单机(Standalone)和集群(Cluster)两种类型,单机实例通常用于容量与性能要求不高的小型存储,而集群则用来应对对性能和容量要求较高的场景。 而在集群(Cluster)实例类型中,当实例需要的容量超过 20G 或要求的吞吐量超过 20万请求每秒时,我们会使用集群(Cluster)实例来承担流量。集群是通过中间件(客户端或中间代理等)将流量分散到多个 Redis 实例上的解决方案。知乎的 Redis 集群方案经历了两个阶段:客户端分片(2015年前使用的方案)与 Twemproxy 代理(2015年至今使用的方案)。 下面将分别来介绍这两个类型的Redis实例在知乎的应用实践情况。 5、知乎的Redis实例应用类型1:单机(Standalone) 对于单机实例,我们采用原生主从(Master-Slave)模式实现高可用,常规模式下对外仅暴露 Master 节点。由于使用原生 Redis,所以单机实例支持所有 Redis 指令。 对于单机实例,我们使用 Redis 自带的哨兵(Sentinel)集群对实例进行状态监控与 Failover。Sentinel 是 Redis 自带的高可用组件,将 Redis 注册到由多个 Sentinel 组成的 Sentinel 集群后,Sentinel 会对 Redis 实例进行健康检查,当 Redis 发生故障后,Sentinel 会通过 Gossip 协议进行故障检测,确认宕机后会通过一个简化的 Raft 协议来提升 Slave 成为新的 Master。 通常情况我们仅使用 1 个 Slave 节点进行冷备,如果有读写分离请求,可以建立多个 Read only slave 来进行读写分离。 如上图所示,通过向 Sentinel 集群注册 Master 节点实现实例的高可用,当提交 Master 实例的连接信息后,Sentinel 会主动探测所有的 Slave 实例并建立连接,定期检查健康状态。客户端通过多种资源发现策略如简单的 DNS 发现 Master 节点,将来有计划迁移到如 Consul 或 etcd 等资源发现组件 。 当 Master 节点发生宕机时,Sentinel 集群会提升 Slave 节点为新的 Master,同时在自身的 pubsub channel +switch-master 广播切换的消息,具体消息格式为: switch-master <master name> <oldip> <oldport> <newip> <newport> watcher 监听到消息后,会去主动更新资源发现策略,将客户端连接指向新的 Master 节点,完成 Failover,具体 Failover 切换过程详见 Redis 官方文档(Redis Sentinel Documentation - Redis)。 实际使用中需要注意以下几点: 1)只读 Slave 节点可以按照需求设置 slave-priority 参数为 0,防止故障切换时选择了只读节点而不是热备 Slave 节点; 2)Sentinel 进行故障切换后会执行 CONFIG REWRITE 命令将 SLAVEOF 配置落地,如果 Redis 配置中禁用了 CONFIG 命令,切换时会发生错误,可以通过修改 Sentinel 代码来替换 CONFIG 命令; 3)Sentinel Group 监控的节点不宜过多,实测超过 500 个切换过程偶尔会进入 TILT 模式,导致 Sentinel 工作不正常,推荐部署多个 Sentinel 集群并保证每个集群监控的实例数量小于 300 个; 4)Master 节点应与 Slave 节点跨机器部署,有能力的使用方可以跨机架部署,不推荐跨机房部署 Redis 主从实例; 5)Sentinel 切换功能主要依赖 down-after-milliseconds 和 failover-timeout 两个参数,down-after-milliseconds 决定了 Sentinel 判断 Redis 节点宕机的超时,知乎使用 30000 作为阈值。而 failover-timeout 则决定了两次切换之间的最短等待时间,如果对于切换成功率要求较高,可以适当缩短 failover-timeout 到秒级保证切换成功,具体详见 Redis 官方文档; 6)单机网络故障等同于机器宕机,但如果机房全网发生大规模故障会造成主从多次切换,此时资源发现服务可能更新不够及时,需要人工介入。 6、知乎的Redis实例应用类型2:集群之客户端分片方案(2015以前使用) 早期知乎使用 redis-shard 进行客户端分片,redis-shard 库内部实现了 CRC32、MD5、SHA1 三种哈希算法 ,支持绝大部分 Redis 命令。使用者只需把 redis-shard 当成原生客户端使用即可,无需关注底层分片。 基于客户端的分片模式具有如下优点: 1)基于客户端分片的方案是集群方案中最快的,没有中间件,仅需要客户端进行一次哈希计算,不需要经过代理,没有官方集群方案的 MOVED/ASK 转向; 2)不需要多余的 Proxy 机器,不用考虑 Proxy 部署与维护; 3)可以自定义更适合生产环境的哈希算法。 但是也存在如下问题: 1)需要每种语言都实现一遍客户端逻辑,早期知乎全站使用 Python 进行开发,但是后来业务线增多,使用的语言增加至 Python,Golang,Lua,C/C++,JVM 系(Java,Scala,Kotlin)等,维护成本过高; 2)无法正常使用 MSET、MGET 等多种同时操作多个 Key 的命令,需要使用 Hash tag 来保证多个 Key 在同一个分片上; 3)升级麻烦,升级客户端需要所有业务升级更新重启,业务规模变大后无法推动; 4)扩容困难,存储需要停机使用脚本 Scan 所有的 Key 进行迁移,缓存只能通过传统的翻倍取模方式进行扩容; 5)由于每个客户端都要与所有的分片建立池化连接,客户端基数过大时会造成 Redis 端连接数过多,Redis 分片过多时会造成 Python 客户端负载升高。 具体特点详见:https://github.com/zhihu/redis-shard 早期知乎大部分业务由 Python 构建,Redis 使用的容量波动较小, redis-shard 很好地应对了这个时期的业务需求,在当时是一个较为不错解决方案。 7、知乎的Redis实例应用类型2:集群之Twemproxy 集群方案(2015之今在用) 2015 年开始,业务上涨迅猛,Redis 需求暴增,原有的 redis-shard 模式已经无法满足日益增长的扩容需求,我们开始调研多种集群方案,最终选择了简单高效的 Twemproxy 作为我们的集群方案。 由 Twitter 开源的 Twemproxy 具有如下优点: 1)性能很好且足够稳定,自建内存池实现 Buffer 复用,代码质量很高; 2)支持 fnv1a_64、murmur、md5 等多种哈希算法; 3)支持一致性哈希(ketama),取模哈希(modula)和随机(random)三种分布式算法。 具体特点详见:https://github.com/twitter/twemproxy 但是缺点也很明显: 1)单核模型造成性能瓶颈; 2)传统扩容模式仅支持停机扩容。 对此,我们将集群实例分成两种模式,即缓存(Cache)和存储(Storage): 如果使用方可以接收通过损失一部分少量数据来保证可用性,或使用方可以从其余存储恢复实例中的数据,这种实例即为缓存,其余情况均为存储。 我们对缓存和存储采用了不同的策略,请继续往下读。 7.1 存储 对于存储我们使用 fnv1a_64 算法结合 modula 模式即取模哈希对 Key 进行分片,底层 Redis 使用单机模式结合 Sentinel 集群实现高可用,默认使用 1 个 Master 节点和 1 个 Slave 节点提供服务,如果业务有更高的可用性要求,可以拓展 Slave 节点。 当集群中 Master 节点宕机,按照单机模式下的高可用流程进行切换,Twemproxy 在连接断开后会进行重连,对于存储模式下的集群,我们不会设置 auto_eject_hosts, 不会剔除节点。 同时,对于存储实例,我们默认使用 noeviction 策略,在内存使用超过规定的额度时直接返回 OOM 错误,不会主动进行 Key 的删除,保证数据的完整性。 由于 Twemproxy 仅进行高性能的命令转发,不进行读写分离,所以默认没有读写分离功能,而在实际使用过程中,我们也没有遇到集群读写分离的需求,如果要进行读写分离,可以使用资源发现策略在 Slave 节点上架设 Twemproxy 集群,由客户端进行读写分离的路由。 7.2 缓存 考虑到对于后端(MySQL/HBase/RPC 等)的压力,知乎绝大部分业务都没有针对缓存进行降级,这种情况下对缓存的可用性要求较数据的一致性要求更高,但是如果按照存储的主从模式实现高可用,1 个 Slave 节点的部署策略在线上环境只能容忍 1 台物理节点宕机,N 台物理节点宕机高可用就需要至少 N 个 Slave 节点,这无疑是种资源的浪费。 所以我们采用了 Twemproxy 一致性哈希(Consistent Hashing)策略来配合 auto_eject_hosts 自动弹出策略组建 Redis 缓存集群。 对于缓存我们仍然使用使用 fnv1a_64 算法进行哈希计算,但是分布算法我们使用了 ketama 即一致性哈希进行 Key 分布。缓存节点没有主从,每个分片仅有 1 个 Master 节点承载流量。 Twemproxy 配置 auto_eject_hosts 会在实例连接失败超过 server_failure_limit 次的情况下剔除节点,并在 server_retry_timeout 超时之后进行重试,剔除后配合 ketama 一致性哈希算法重新计算哈希环,恢复正常使用,这样即使一次宕机多个物理节点仍然能保持服务。 在实际的生产环境中需要注意以下几点: 1)剔除节点后,会造成短时间的命中率下降,后端存储如 MySQL、HBase 等需要做好流量监测; 2)线上环境缓存后端分片不宜过大,建议维持在 20G 以内,同时分片调度应尽可能分散,这样即使宕机一部分节点,对后端造成的额外的压力也不会太多; 3)机器宕机重启后,缓存实例需要清空数据之后启动,否则原有的缓存数据和新建立的缓存数据会冲突导致脏缓存。直接不启动缓存也是一种方法,但是在分片宕机期间会导致周期性 server_failure_limit 次数的连接失败; 4)server_retry_timeout 和 server_failure_limit 需要仔细敲定确认,知乎使用 10min 和 3 次作为配置,即连接失败 3 次后剔除节点,10 分钟后重新进行连接。 7.3 Twemproxy 部署 在方案早期我们使用数量固定的物理机部署 Twemproxy,通过物理机上的 Agent 启动实例,Agent 在运行期间会对 Twemproxy 进行健康检查与故障恢复,由于 Twemproxy 仅提供全量的使用计数,所以 Agent 运行时还会进行定时的差值计算来计算 Twemproxy 的 requests_per_second 等指标。 后来为了更好地故障检测和资源调度,我们引入了 Kubernetes,将 Twemproxy 和 Agent 放入同一个 Pod 的两个容器内,底层 Docker 网段的配置使每个 Pod 都能获得独立的 IP,方便管理。 最开始,本着简单易用的原则,我们使用 DNS A Record 来进行客户端的资源发现,每个 Twemproxy 采用相同的端口号,一个 DNS A Record 后面挂接多个 IP 地址对应多个 Twemproxy 实例。 初期,这种方案简单易用,但是到了后期流量日益上涨,单集群 Twemproxy 实例个数很快就超过了 20 个。由于 DNS 采用的 UDP 协议有 512 字节的包大小限制,单个 A Record 只能挂接 20 个左右的 IP 地址,超过这个数字就会转换为 TCP 协议,客户端不做处理就会报错,导致客户端启动失败。 当时由于情况紧急,只能建立多个 Twemproxy Group,提供多个 DNS A Record 给客户端,客户端进行轮询或者随机选择,该方案可用,但是不够优雅。 7.4 如何解决 Twemproxy 单 CPU 计算能力的限制 之后我们修改了 Twemproxy 源码, 加入 SO_REUSEPORT 支持。 Twemproxy with SO_REUSEPORT on Kubernetes: 同一个容器内由 Starter 启动多个 Twemproxy 实例并绑定到同一个端口,由操作系统进行负载均衡,对外仍然暴露一个端口,但是内部已经由系统均摊到了多个 Twemproxy 上。 同时 Starter 会定时去每个 Twemproxy 的 stats 端口获取 Twemproxy 运行状态进行聚合,此外 Starter 还承载了信号转发的职责。 原有的 Agent 不需要用来启动 Twemproxy 实例,所以 Monitor 调用 Starter 获取聚合后的 stats 信息进行差值计算,最终对外界暴露出实时的运行状态信息。 7.5 为什么没有使用官方 Redis 集群方案 我们在 2015 年调研过多种集群方案,综合评估多种方案后,最终选择了看起来较为陈旧的 Twemproxy 而不是官方 Redis 集群方案与 Codis,具体原因如下: 1)MIGRATE 造成的阻塞问题: Redis 官方集群方案使用 CRC16 算法计算哈希值并将 Key 分散到 16384 个 Slot 中,由使用方自行分配 Slot 对应到每个分片中,扩容时由使用方自行选择 Slot 并对其进行遍历,对 Slot 中每一个 Key 执行 MIGRATE 命令进行迁移。 调研后发现,MIGRATE 命令实现分为三个阶段: a)DUMP 阶段:由源实例遍历对应 Key 的内存空间,将 Key 对应的 Redis Object 序列化,序列化协议跟 Redis RDB 过程一致; b)RESTORE 阶段:由源实例建立 TCP 连接到对端实例,并将 DUMP 出来的内容使用 RESTORE 命令到对端进行重建,新版本的 Redis 会缓存对端实例的连接; c)DEL 阶段(可选):如果发生迁移失败,可能会造成同名的 Key 同时存在于两个节点,此时 MIGRATE 的 REPLACE 参数决定是是否覆盖对端的同名 Key,如果覆盖,对端的 Key 会进行一次删除操作,4.0 版本之后删除可以异步进行,不会阻塞主进程。 经过调研,我们认为这种模式并不适合知乎的生产环境。Redis 为了保证迁移的一致性, MIGRATE 所有操作都是同步操作,执行 MIGRATE 时,两端的 Redis 均会进入时长不等的 BLOCK 状态。 对于小 Key,该时间可以忽略不计,但如果一旦 Key 的内存使用过大,一个 MIGRATE 命令轻则导致 P95 尖刺,重则直接触发集群内的 Failover,造成不必要的切换 同时,迁移过程中访问到处于迁移中间状态的 Slot 的 Key 时,根据进度可能会产生 ASK 转向,此时需要客户端发送 ASKING 命令到 Slot 所在的另一个分片重新请求,请求时延则会变为原来的两倍。 同样,方案初期时的 Codis 采用的是相同的 MIGRATE 方案,但是使用 Proxy 控制 Redis 进行迁移操作而非第三方脚本(如 redis-trib.rb),基于同步的类似 MIGRATE 的命令,实际跟 Redis 官方集群方案存在同样的问题。 对于这种 Huge Key 问题决定权完全在于业务方,有时业务需要不得不产生 Huge Key 时会十分尴尬,如关注列表。一旦业务使用不当出现超过 1MB 以上的大 Key 便会导致数十毫秒的延迟,远高于平时 Redis 亚毫秒级的延迟。有时,在 slot 迁移过程中业务不慎同时写入了多个巨大的 Key 到 slot 迁移的源节点和目标节点,除非写脚本删除这些 Key ,否则迁移会进入进退两难的地步。 对此,Redis 作者在 Redis 4.2 的 roadmap 中提到了 Non blocking MIGRATE 但是截至目前,Redis 5.0 即将正式发布,仍未看到有关改动,社区中已经有相关的 Pull Request ,该功能可能会在 5.2 或者 6.0 之后并入 master 分支,对此我们将持续观望。 2)缓存模式下高可用方案不够灵活: 还有,官方集群方案的高可用策略仅有主从一种,高可用级别跟 Slave 的数量成正相关,如果只有一个 Slave,则只能允许一台物理机器宕机, Redis 4.2 roadmap 提到了 cache-only mode,提供类似于 Twemproxy 的自动剔除后重分片策略,但是截至目前仍未实现。 3)内置 Sentinel 造成额外流量负载: 另外,官方 Redis 集群方案将 Sentinel 功能内置到 Redis 内,这导致在节点数较多(大于 100)时在 Gossip 阶段会产生大量的 PING/INFO/CLUSTER INFO 流量,根据 issue 中提到的情况,200 个使用 3.2.8 版本节点搭建的 Redis 集群,在没有任何客户端请求的情况下,每个节点仍然会产生 40Mb/s 的流量,虽然到后期 Redis 官方尝试对其进行压缩修复,但按照 Redis 集群机制,节点较多的情况下无论如何都会产生这部分流量,对于使用大内存机器但是使用千兆网卡的用户这是一个值得注意的地方。 4)slot 存储开销: 最后,每个 Key 对应的 Slot 的存储开销,在规模较大的时候会占用较多内存,4.x 版本以前甚至会达到实际使用内存的数倍,虽然 4.x 版本使用 rax 结构进行存储,但是仍然占据了大量内存,从非官方集群方案迁移到官方集群方案时,需要注意这部分多出来的内存。 总之,官方 Redis 集群方案与 Codis 方案对于绝大多数场景来说都是非常优秀的解决方案,但是我们仔细调研发现并不是很适合集群数量较多且使用方式多样化的我们,场景不同侧重点也会不一样,但在此仍然要感谢开发这些组件的开发者们,感谢你们对 Redis 社区的贡献。 8、知乎Redis实例的扩容实践 8.1 静态扩容 对于单机实例,如果通过调度器观察到对应的机器仍然有空闲的内存,我们仅需直接调整实例的 maxmemory 配置与报警即可。同样,对于集群实例,我们通过调度器观察每个节点所在的机器,如果所有节点所在机器均有空闲内存,我们会像扩容单机实例一样直接更新 maxmemory 与报警。 8.2 动态扩容 但是当机器空闲内存不够,或单机实例与集群的后端实例过大时,无法直接扩容,需要进行动态扩容: 1)对于单机实例,如果单实例超过 30GB 且没有如 sinterstore 之类的多 Key 操作我们会将其扩容为集群实例; 2)对于集群实例,我们会进行横向的重分片,我们称之为 Resharding 过程。 Resharding 过程: 原生 Twemproxy 集群方案并不支持扩容,我们开发了数据迁移工具来进行 Twemproxy 的扩容,迁移工具本质上是一个上下游之间的代理,将数据从上游按照新的分片方式搬运到下游。 原生 Redis 主从同步使用 SYNC/PSYNC 命令建立主从连接,收到 SYNC 命令的 Master 会 fork 出一个进程遍历内存空间生成 RDB 文件并发送给 Slave,期间所有发送至 Master 的写命令在执行的同时都会被缓存到内存的缓冲区内,当 RDB 发送完成后,Master 会将缓冲区内的命令及之后的写命令转发给 Slave 节点。 我们开发的迁移代理会向上游发送 SYNC 命令模拟上游实例的 Slave,代理收到 RDB 后进行解析,由于 RDB 中每个 Key 的格式与 RESTORE 命令的格式相同,所以我们使用生成 RESTORE 命令按照下游的 Key 重新计算哈希并使用 Pipeline 批量发送给下游。 等待 RDB 转发完成后,我们按照新的后端生成新的 Twemproxy 配置,并按照新的 Twemproxy 配置建立 Canary 实例,从上游的 Redis 后端中取 Key 进行测试,测试 Resharding 过程是否正确,测试过程中的 Key 按照大小,类型,TTL 进行比较。 测试通过后,对于集群实例,我们使用生成好的配置替代原有 Twemproxy 配置并 restart/reload Twemproxy 代理,我们修改了 Twemproxy 代码,加入了 config reload 功能,但是实际使用中发现直接重启实例更加可控。而对于单机实例,由于单机实例和集群实例对于命令的支持不同,通常需要和业务方确定后手动重启切换。 由于 Twemproxy 部署于 Kubernetes ,我们可以实现细粒度的灰度,如果客户端接入了读写分离,我们可以先将读流量接入新集群,最终接入全部流量。 这样相对于 Redis 官方集群方案,除在上游进行 BGSAVE 时的 fork 复制页表时造成的尖刺以及重启时造成的连接闪断,其余对于 Redis 上游造成的影响微乎其微。 这样扩容存在的问题: 1)对上游发送 SYNC 后,上游 fork 时会造成尖刺: - 对于存储实例,我们使用 Slave 进行数据同步,不会影响到接收请求的 Master 节点; - 对于缓存实例,由于没有 Slave 实例,该尖刺无法避免,如果对于尖刺过于敏感,我们可以跳过 RDB 阶段,直接通过 PSYNC 使用最新的 SET 消息建立下游的缓存。 2)切换过程中有可能写到下游,而读在上游: - 对于接入了读写分离的客户端,我们会先切换读流量到下游实例,再切换写流量。 3)一致性问题,两条具有先后顺序的写同一个 Key 命令在切换代理后端时会通过 1)写上游同步到下游 2)直接写到下游两种方式写到下游,此时,可能存在应先执行的命令却通过 1)执行落后于通过 2)执行,导致命令先后顺序倒置: - 这个问题在切换过程中无法避免,好在绝大部分应用没有这种问题,如果无法接受,只能通过上游停写排空 Resharding 代理保证先后顺序; - 官方 Redis 集群方案和 Codis 会通过 blocking 的 migrate 命令来保证一致性,不存在这种问题。 实际使用过程中,如果上游分片安排合理,可实现数千万次每秒的迁移速度,1TB 的实例 Resharding 只需要半小时左右。另外,对于实际生产环境来说,提前做好预期规划比遇到问题紧急扩容要快且安全得多。 9、旁路分析实践 由于生产环境调试需要,有时会需要监控线上 Redis 实例的访问情况,Redis 提供了多种监控手段,如 MONITOR 命令。 但由于 Redis 单线程的限制,导致自带的 MONITOR 命令在负载过高的情况下会再次跑高 CPU,对于生产环境来说过于危险,而其余方式如 Keyspace Notify 只有写事件,没有读事件,无法做到细致的观察。 对此我们开发了基于 libpcap 的旁路分析工具,系统层面复制流量,对应用层流量进行协议分析,实现旁路 MONITOR,实测对于运行中的实例影响微乎其微。 同时对于没有 MONITOR 命令的 Twemproxy,旁路分析工具仍能进行分析,由于生产环境中绝大部分业务都使用 Kubernetes 部署于 Docker 内 ,每个容器都有对应的独立 IP,所以可以使用旁路分析工具反向解析找出客户端所在的应用,分析业务方的使用模式,防止不正常的使用。 10、将来的工作 由于 Redis 5.0 发布在即,4.0 版本趋于稳定,我们将逐步升级实例到 4.0 版本,由此带来的如 MEMORY 命令、Redis Module 、新的 LFU 算法等特性无论对运维方还是业务方都有极大的帮助。 11、写在最后 知乎架构平台团队是支撑整个知乎业务的基础技术团队,开发和维护着知乎几乎全量的核心基础组件,包括容器、Redis、MySQL、Kafka、LB、HBase 等核心基础设施,团队小而精,每个同学都独当一面负责上面提到的某个核心系统。 随着知乎业务规模的快速增长,以及业务复杂度的持续增加,我们团队面临的技术挑战也越来越大,欢迎对技术感兴趣、渴望技术挑战的小伙伴加入我们,一起建设稳定高效的知乎云平台。 12、参考资料 [1] Redis Official site [2] Twemproxy Github Page twitter/twemproxy [3] Codis Github Page CodisLabs/codis [4] SO_REUSEPORT Man Page socket(7) - Linux manual page [5] Kubernetes Production-Grade Container Orchestration 附录:有关架构设计方面的文章汇总 《浅谈IM系统的架构设计》 《简述移动端IM开发的那些坑:架构设计、通信协议和客户端》 《一套海量在线用户的移动端IM架构设计实践分享(含详细图文)》 《一套原创分布式即时通讯(IM)系统理论架构方案》 《从零到卓越:京东客服即时通讯系统的技术架构演进历程》 《蘑菇街即时通讯/IM服务器开发之架构选择》 《腾讯QQ1.4亿在线用户的技术挑战和架构演进之路PPT》 《微信后台基于时间序的海量数据冷热分级架构设计实践》 《微信技术总监谈架构:微信之道——大道至简(演讲全文)》 《如何解读《微信技术总监谈架构:微信之道——大道至简》》 《快速裂变:见证微信强大后台架构从0到1的演进历程(一)》 《17年的实践:腾讯海量产品的技术方法论》 《移动端IM中大规模群消息的推送如何保证效率、实时性?》 《现代IM系统中聊天消息的同步和存储方案探讨》 《IM开发基础知识补课(二):如何设计大量图片文件的服务端存储架构?》 《IM开发基础知识补课(三):快速理解服务端数据库读写分离原理及实践建议》 《IM开发基础知识补课(四):正确理解HTTP短连接中的Cookie、Session和Token》 《WhatsApp技术实践分享:32人工程团队创造的技术神话》 《微信朋友圈千亿访问量背后的技术挑战和实践总结》 《王者荣耀2亿用户量的背后:产品定位、技术架构、网络方案等》 《IM系统的MQ消息中间件选型:Kafka还是RabbitMQ?》 《腾讯资深架构师干货总结:一文读懂大型分布式系统设计的方方面面》 《以微博类应用场景为例,总结海量社交系统的架构设计步骤》 《快速理解高性能HTTP服务端的负载均衡技术原理》 《子弹短信光鲜的背后:网易云信首席架构师分享亿级IM平台的技术实践》 《知乎技术分享:从单机到2000万QPS并发的Redis高性能缓存实践之路》 >> 更多同类文章 …… (本文同步发布于:http://www.52im.net/thread-1968-1-1.html)
1、前言 网络通信一直是Android项目里比较重要的一个模块,Android开源项目上出现过很多优秀的网络框架,从一开始只是一些对HttpClient和HttpUrlConnection简易封装使用的工具类,到后来Google开源的比较完善丰富的Volley,再到如今比较流行的Okhttp、Retrofit。 要想理解他们之间存在的异同(或者具体点说,要想更深入地掌握Android开发中的网络通信技术),必须对网络基础知识、Android网络框架的基本原理等做到心中有数、信手拈来,关键时刻才能找到适合您APP的最佳网络通信技术实践。 事实证明在Android的日常开发和源码阅读中也会经常碰到相关知识,掌握这些网络基础知识,也是Android程序员真正迈向高阶的过程中必备的一些基本技术素质之一。 有鉴于此,本文将主要介绍计算机网络的一些基础,以及在Android开发中的一些使用及遇到的问题和解决。 本篇主要分为以下几部分: 1)计算机网络体系结构; 2)Http相关; 3)Tcp相关; 4)Socket。 学习交流: - 即时通讯开发交流3群:185926912[推荐] - 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》 (本文同步发布于:http://www.52im.net/thread-1963-1-1.html) 2、关于作者 舒大飞:携程网Android开发工程师,作者博客:https://www.jianshu.com/u/ae379336190b。 注:在收录本文时,为了更易于理解,对内容做了更为细致的修订。 3、计算机网络体系结构 计算机网络体系结构,即经常看到的计算机网络体系的分层结构,理清这个还是有必要的,防止对Http和Tcp两个根本不在同一层的协议纠缠不清。 根据不同的参考模型,分层结构有几个不同的版本,如OSI模型以及TCP/IP模型。 下面就以比较经常看到的的5层结构为例: (更为清晰完整的图,请见《计算机网络通讯协议关系图(中文珍藏版)[附件下载] 》) 如上图所示,五层的体系结构至上往下,最终可以实现端对端之间的数据传输与通信,他们各自负责一些什么,最终如何实现端对端之间的通信? 1)应用层:如http协议,它实际上是定义了如何包装和解析数据,应用层是http协议的话,则会按照协议规定包装数据,如按照请求行、请求头、请求体包装,包装好数据后将数据传至运输层。 2)运输层:运输层有TCP和UDP两种协议,分别对应可靠的运输和不可靠的运输,如TCP因为要提供可靠的传输,所以内部要解决如何建立连接、如何保证传输是可靠的不丢数据、如何调节流量控制和拥塞控制。关于这一层,我们平常一般都是和Socket打交道,Socket是一组封装的编程调用接口,通过它,我们就能操作TCP、UDP进行连接的建立等。我们平常使用Socket进行连接建立的时候,一般都要指定端口号,所以这一层指定了把数据送到对应的端口号。 3)网络层:这一层IP协议,以及一些路由选择协议等等,所以这一层的指定了数据要传输到哪个IP地址。中间涉及到一些最优线路,路由选择算法等等。 4)数据链路层:印象比较深的就是ARP协议,负责把IP地址解析为MAC地址,即硬件地址,这样就找到了对应的唯一的机器。 5)物理层:这一层就是最底层了,提供二进制流传输服务,也就是也就是真正开始通过传输介质(有线、无线)开始进行数据的传输了。 所以通过上面五层的各司其职,实现物理传输介质--MAC地址--IP地址--端口号--获取到数据根据应用层协议解析数据最终实现了网络通信和数据传输。 下面会着重讲一下HTTP和TCP相关的东西,关于其他层,毕业了这么久也忘的很多,如果想更加细致具体的了解像下面三层的如路由选择算法、ARP寻址以及物理层等等还是要重新去看一下《TCP/IP详解 卷1:协议》~ 4、HTTP相关 本节主要讲一些关于Http的基础知识,以及在Android中的一些实际应用和碰到的问题和解决。 限于篇幅原因,本文在一些知识点上只做简要性的概述,如果想要全面深入地掌握HTTP协议,请阅读以下文章: 《网络编程懒人入门(七):深入浅出,全面理解HTTP协议》 《从HTTP/0.9到HTTP/2:一文读懂HTTP协议的历史演变和设计思路》 《脑残式网络编程入门(三):HTTP协议必知必会的一些知识》 4.1 正确理解HTTP的“无连接”“与无状态” Http是无连接无状态的。 无连接并不是说不需要连接,Http协议只是一个应用层协议,最终还是要靠运输层的如TCP协议向上提供的服务进行连接。 无连接的含义是http约定了每次连接只处理一个请求,一次请求完成后就断开连接,这样主要是为了缓解服务器的压力,减小连接对服务器资源的占用。我的理解是,建立连接实际上是运输层的事,面向应用层的http来说的话,它就是无连接的,因为上层对下层无感知。 无状态的指每个请求之间都是独立的,对于之前的请求事务没有记忆的能力。所以就出现了像Cookie这种,用来保存一些状态的东西。 4.2 请求报文与响应报文 这里主要简单说一下HTTP请求报文和响应报文的格式方面的基础知识。 请求报文: 响应报文: 关于Get和Post,我们都熟知的关于Get和Post的区别大致有以下几点: 1)Get会把请求参数都拼接在url后面,最终显示在地址栏,而Post则会把请求参数数据放进请求体中,不会再地址栏显示出来; 2)传递参数的长度限制。 问题: 对于第1)点,如果是在浏览器里把隐私数据暴露在地址栏上确实不妥,但是如果是在App开发中呢,没有地址栏的概念,那么这一点是不是还会成为选择post还是get的制约条件; 对于第2)点,长度的限制应该是浏览器的限制,跟get本身无关,如果是在App开发中,这一点是否也可以忽略。 4.3 HTTP的缓存机制 之所以想介绍以下Http的缓存机制,是因为Okhttp中对于网络请求缓存这一块就是利用了Http的的缓存机制,而不是像Volley等框架那样客户端完全自己写一套缓存策略自己玩。 Http的缓存主要利用header里的两个字段来控制:即Cache-control和ETag,下面将分别来介绍。 1)Cache-control主要包含以及几个字段: private:则只有客户端可以缓存; public:客户端和代理服务器都可以缓存; max-age:缓存的过期时间; no-cache:需要使用对比缓存来验证缓存数据; no-store:所有内存都不会进行缓存。 实际上就是在这里面设置了一个缓存策略,由服务端第一次通过header下发给客户端,可以看到: max-age:即缓存过期的时间,则之后再次请求,如果没有超过缓存失效的时间则可以直接使用缓存; no-cache:表示需要使用对比缓存来验证缓存数据,如果这个字段是打开的,则就算max-age缓存没有失效,则还是需要发起一次请求向服务端确认一下资源是否有更新,是否需要重新请求数据,至于怎么做对比缓存,就是下面要说的Etag的作用。如果服务端确认资源没有更新,则返回304,取本地缓存即可,如果有更新,则返回最新的资源; no-store:这个字段打开,则不会进行缓存,也不会取缓存。 2)ETag:即用来进行对比缓存,Etag是服务端资源的一个标识码 当客户端发送第一次请求时服务端会下发当前请求资源的标识码Etag,下次再请求时,客户端则会通过header里的If-None-Match将这个标识码Etag带上,服务端将客户端传来的Etag与最新的资源Etag做对比,如果一样,则表示资源没有更新,返回304。 3)小结: 通过Cache-control和Etag的配合来实现Http的缓存机制。更多有关HTTP缓存方面的的知识,在文章《脑残式网络编程入门(三):HTTP协议必知必会的一些知识》的相关章节做了详细的读解,可以参阅之。 4.4 HTTP的Cookie 上面说了Http协议是无状态的,而Cookie就是用来在本地缓存记住一些状态的,一个Cookie一般都包含domin(所属域)、path、Expires(过期时间)等几个属性。服务端可以通过在响应头里的set-cookies来将状态写入客户端的Cookie里。下次客户端发起请求时可以将Cookie带上。 Android开发中遇到的问题及解决: 说起Cookie,一般如果平常只是做App开发,比较不经常遇到,但是如果是涉及到WebView的需求,则有可能会遇到。 下面就说一下我在项目里遇到过的一个关于WebView Cookie的揪心往事:需求是这样的,加载的WebView中的H5页面需要是已登录状态的,所以我们需要在原生页面登录后,手动将ticket写入WebView的Cookie,之后WebView里加载的H5页面带着Cookie里的ticket给服务端验证通过就好了。 但是遇到一个问题:通过Chrome inspect调试WebView,手动写的Cookie确实是已经写进去了,但是发起请求的时候,Cookie就是没有带上,导致请求验证失败,之后通过排查,是WebView的属性默认关闭引起,通过下面的代码设置打开即可: CookieManager cookieManager = CookieManager.getInstance(); if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { cookieManager.setAcceptThirdPartyCookies(mWebView, true); } else{ cookieManager.setAcceptCookie(true); } 4.5 Https 我们都知道Https保证了我们数据传输的安全,Https=Http+Ssl,之所以能保证安全主要的原理就是利用了非对称加密算法,平常用的对称加密算法之所以不安全,是因为双方是用统一的密匙进行加密解密的,只要双方任意一方泄漏了密匙,那么其他人就可以利用密匙解密数据。 而非对称加密算法之所以能实现安全传输的核心精华就是:公钥加密的信息只能用私钥解开,私钥加密的信息只能被公钥解开。 1)简述非对称加密算法为什么安全: 服务端申请CA机构颁发的证书,则获取到了证书的公钥和私钥,私钥只有服务器端自己知道,而公钥可以告知其他人,如可以把公钥传给客户端,这样客户端通过服务端传来的公钥来加密自己传输的数据,而服务端利用私钥就可以解密这个数据了。由于客户端这个用公钥加密的数据只有私钥能解密,而这个私钥只有服务端有,所以数据传输就安全了。 上面只是简单说了一下非对称加密算法是如何保证数据安全的,实际上Https的工作过程远比这要复杂(篇幅限制这里就不细说了,网上有很多相关文章): 一个是客户端还需要验证服务端传来的CA证书的合法性、有效性,因为存在传输过程CA证书被人调包的风险,涉及到客户端如何验证服务器证书的合法性的问题,保证通信双方的身份合法; 另一个是非对称算法虽然保证了数据的安全,但是效率相对于对称算法来说比较差,如何来优化,实现既保证了数据的安全,又提高了效率。 2)客户端如何验证证书的合法性: 首先CA证书一般包括以下内容: 证书的颁发机构以及版本; 证书的使用者; 证书的公钥; 证书有效时间; 证书的数字签名Hash值以及签名Hash算法(这个数字签名Hash值是用证书的私钥加密过的值); 等等。 客户端验证服务端传过来的证书的合法性是通过:先利用获取到的公钥来解密证书中的数字签名Hash值1(因为它是利用私钥加密的嘛),然后在利用证书里的签名Hash算法生成一个Hash值2,如果两个值相等,则表示证书合法,服务器端可以被信任。 3)Android开发中遇到的问题及解决: 顺便说一个在项目开发中使用Android WebView加载公司测试服务器上网页证书过期导致网页加载不出来白屏的问题。 解决方案就是测试环境下暂时忽略SSL的报错,这样就可以把网页加载出来,当然在生产上不要这么做,一个是会有安全问题,一个是google play应该审核也不会通过。 最佳办法是重写WebViewClient的onReceivedSslError(): @Override publicvoidonReceivedSslError(WebView view, SslErrorHandler handler, SslError error) { if(ContextHolder.sDebug) { handler.proceed(); return; } super.onReceivedSslError(view, handler, error); } 最后:有关HTTPS更为详细全面的知识,请深入阅读《即时通讯安全篇(七):如果这样来理解HTTPS原理,一篇就够了》。 4.6 Http 2.0 Okhttp支持配置使用Http 2.0协议,Http2.0相对于Http1.x来说提升是巨大的,主要有以下几点。 1)二进制格式:http1.x是文本协议,而http2.0是二进制以帧为基本单位,是一个二进制协议,一帧中除了包含数据外同时还包含该帧的标识:Stream Identifier,即标识了该帧属于哪个request,使得网络传输变得十分灵活; 2)多路复用:一个很大的改进,原先http1.x一个连接一个请求的情况有比较大的局限性,也引发了很多问题,如建立多个连接的消耗以及效率问题。 http1.x为了解决效率问题,可能会尽量多的发起并发的请求去加载资源,然而浏览器对于同一域名下的并发请求有限制,而优化的手段一般是将请求的资源放到不同的域名下来突破这种限制。 而http2.0支持的多路复用可以很好的解决这个问题,多个请求共用一个TCP连接,多个请求可以同时在这个TCP连接上并发,一个是解决了建立多个TCP连接的消耗问题,一个也解决了效率的问题。 那么是什么原理支撑多个请求可以在一个TCP连接上并发呢?基本原理就是上面的二进制分帧,因为每一帧都有一个身份标识,所以多个请求的不同帧可以并发的无序发送出去,在服务端会根据每一帧的身份标识,将其整理到对应的request中。 3)header头部压缩:主要是通过压缩header来减少请求的大小,减少流量消耗,提高效率。因为之前存在一个问题是,每次请求都要带上header,而这个header中的数据通常是一层不变的。 4)支持服务端推送。 有关HTTP2的更多知识,请阅读《从HTTP/0.9到HTTP/2:一文读懂HTTP协议的历史演变和设计思路》。 5、TCP相关 TCP面向连接,提供可靠的数据传输。在这一层,我们通常都是通过Socket Api来操作TCP,建立连接等等。 5.1 三次握手建立连接 第一次:发送SNY=1表示此次握手是请求建立连接的,然后seq生成一个客户端的随机数X 第二次:发送SNY=1,ACK=1表示是回复请求建立连接的,然后ack=客户端的seq+1(这样客户端收到后就能确认是之前想要连接的那个服务端),然后把服务端也生成一个代表自己的随机数seq=Y发给客户端。 第三次:ACK=1。 seq=客户端随机数+1,ack=服务端随机数+1(这样服务端就知道是刚刚那个客户端了) 为什么建立连接需要三次握手? 首先非常明确的是两次握手是最基本的,第一次握手,C端发了个连接请求消息到S端,S端收到后S端就知道自己与C端是可以连接成功的,但是C端此时并不知道S端是否接收到这个消息,所以S端接收到消息后得应答,C端得到S端的回复后,才能确定自己与S端是可以连接上的,这就是第二次握手。 C端只有确定了自己能与S端连接上才能开始发数据。所以两次握手肯定是最基本的。 那么为什么需要第三次握手呢?假设一下如果没有第三次握手,而是两次握手后我们就认为连接建立,那么会发生什么? 第三次握手是为了防止已经失效的连接请求报文段突然又传到服务端,因而产生错误。 具体情况就是: C端发出去的第一个网络连接请求由于某些原因在网络节点中滞留了,导致延迟,直到连接释放的某个时间点才到达S端,这是一个早已失效的报文,但是此时S端仍然认为这是C端的建立连接请求第一次握手,于是S端回应了C端,第二次握手。 如果只有两次握手,那么到这里,连接就建立了,但是此时C端并没有任何数据要发送,而S端就会傻傻的等待着,造成很大的资源浪费。所以需要第三次握手,只有C端再次回应一下,就可以避免这种情况。 要想深刻理解TCP三次握手,请不要错过以下文章: 《TCP/IP详解 - 第18章·TCP连接的建立与终止》 《通俗易懂-深入理解TCP协议(上):理论基础》 《理论经典:TCP协议的3次握手与4次挥手过程详解》 《理论联系实际:Wireshark抓包分析TCP 3次握手、4次挥手过程》 《脑残式网络编程入门(一):跟着动画来学TCP三次握手和四次挥手》 5.2 四次挥手断开连接 经过上面的建立连接图的解析,这个图应该不难看懂。 这里主要有一个问题:为什么比建立连接时多了一次挥手? 可以看到这里服务端的ACK(回复客户端)和FIN(终止)消息并不是同时发出的,而是先ACK,然后再FIN,这也很好理解,当客户端要求断开连接时,此时服务端可能还有未发送完的数据,所以先ACK,然后等数据发送完再FIN。这样就变成了四次握手了。 上面讲了TCP建立连接和断开连接的过程,TCP最主要的特点就是提供可靠的传输,那么他是如何保证数据传输是可靠的呢,这就是下面要讲的滑动窗口协议。 相关知识请深入阅读: 《理论经典:TCP协议的3次握手与4次挥手过程详解》 《理论联系实际:Wireshark抓包分析TCP 3次握手、4次挥手过程》 《脑残式网络编程入门(一):跟着动画来学TCP三次握手和四次挥手》 5.3 滑动窗口协议 滑动窗口协议是保证TCP的可靠传输的根本,因为发送窗口只有收到确认帧才会向后移动窗口继续发送其他帧。 下面举个例子:假如发送窗口是3帧 一开始发送窗口在前3帧[1,2,3],则前3帧是可以发送的,后面的则暂时不可以发送,比如[1]帧发送出去后,收到了来自接收方的确认消息,则此时发送窗口才可以往后移1帧,发送窗口来到[2,3,4],同样只有发送窗口内的帧才可以被发送,一次类推。 而接收窗口接收到帧后将其放入对应的位置,然后移动接收窗口,接口窗口与发送窗口一样也有一个大小,如接收窗口是5帧,则落在接收窗口之外的帧会被丢弃。 发送窗口和接收窗口大小的不同设定就延伸出了不同的协议: 停止-等待协议:每发一帧都要等到确认消息才能发送下一帧,缺点:效率较差。 后退N帧协议:采取累计确认的方式,接收方正确的接受到N帧后发一个累计确认消息给发送窗口,确认N帧已正确收到,如果发送方规定时间内未收到确认消息则认为超时或数据丢失,则会重新发送确认帧之后的所有帧。缺点:出错序号后面的PDU已经发送过了,但是还是要重新发送,比较浪费。 选择重传协议:若出现差错,只重新传输出现差错涉及需要的PDU,提高了传输效率,减少不必要的重传。 到这里还剩下最后一个问题:由于发送窗口与接收窗口之间会存在发送效率和接收效率不匹配的问题,就会导致拥塞,解决这个问题TCP有一套流量控制和拥塞控制的机制。 5.4 流量控制和拥塞控制 1)流量控制: 流量控制是对一条通信路径上的流量进行控制,就是发送方通过获取接收方的回馈来动态调整发送的速率,来达到控制流量的效果,其目的是保证发送者的发送速度不超过接收者的接收速度。 2)拥塞控制: 拥塞控制是对整个通信子网的流量进行控制,属于全局控制。 ① 慢开始+拥塞避免 先来看一张经典的图: 一开始使用慢启动,即拥塞窗口设为1,然后拥塞窗口指数增长到慢开始的门限值(ssthresh=16),则切换为拥塞避免,即加法增长,这样增长到一定程度,导致网络拥塞,则此时会把拥塞窗口重新降为1,即重新慢开始,同时调整新的慢开始门限值为12,之后以此类推。 ② 快重传+快恢复 快重传:上面我们说的重传机制都是等到超时还未收到接收方的回复,才开始进行重传。而快重传的设计思路是:如果发送方收到3个重复的接收方的ACK,就可以判断有报文段丢失,此时就可以立即重传丢失的报文段,而不用等到设置的超时时间到了才开始重传,提高了重传的效率。 快恢复:上面的拥塞控制会在网络拥塞时将拥塞窗口降为1,重新慢开始,这样存在的一个问题就是网络无法很快恢复到正常状态。快恢复就是来优化这个问题的,使用快恢复,则出现拥塞时,拥塞窗口只会降低到新的慢开始门阀值(即12),而不会降为1,然后直接开始进入拥塞避免加法增长,如下图所示: 快重传和快恢复是对拥塞控制的进一步改进。 要更深入地理解本小节问题,请详读:《TCP/IP详解 - 第21章·TCP的超时与重传》、《通俗易懂-深入理解TCP协议(下):RTT、滑动窗口、拥塞处理》。 6、有关Socket Socket是一组操作TCP/UDP的API,像HttpURLConnection和Okhttp这种涉及到比较底层的网络请求发送的,最终当然也都是通过Socket来进行网络请求连接发送,而像Volley、Retrofit则是更上层的封装,最后是依靠HttpURLConnection或者Okhttp来进行最终的连接建立和请求发送。 Socket的简单使用的话应该都会,两个端各建立一个Socket,服务端的叫ServerSocket,然后建立连接即可。 相关资料,请阅读: 《网络编程懒人入门(八):手把手教你写基于TCP的Socket长连接》 《脑残式网络编程入门(二):我们在读写Socket时,究竟在读写什么?》 7、本文小结 当然,以上这些内容只是我自己知道的并且认为挺重要的计算机网络基础,还有非常多的网络基础知识需要去深入了解去探索。写了很多,算是对自己网络基础的一个整理,可能也会有纰漏,权当抛砖引玉,还请各位大牛不吝赐教。 附录:更多网络基础知识文章 《TCP/IP详解 - 第11章·UDP:用户数据报协议》 《TCP/IP详解 - 第17章·TCP:传输控制协议》 《TCP/IP详解 - 第18章·TCP连接的建立与终止》 《TCP/IP详解 - 第21章·TCP的超时与重传》 《技术往事:改变世界的TCP/IP协议(珍贵多图、手机慎点)》 《通俗易懂-深入理解TCP协议(上):理论基础》 《通俗易懂-深入理解TCP协议(下):RTT、滑动窗口、拥塞处理》 《理论经典:TCP协议的3次握手与4次挥手过程详解》 《理论联系实际:Wireshark抓包分析TCP 3次握手、4次挥手过程》 《计算机网络通讯协议关系图(中文珍藏版)》 《UDP中一个包的大小最大能多大?》 《P2P技术详解(一):NAT详解——详细原理、P2P简介》 《P2P技术详解(二):P2P中的NAT穿越(打洞)方案详解》 《P2P技术详解(三):P2P技术之STUN、TURN、ICE详解》 《通俗易懂:快速理解P2P技术中的NAT穿透原理》 《高性能网络编程(一):单台服务器并发TCP连接数到底可以有多少》 《高性能网络编程(二):上一个10年,著名的C10K并发连接问题》 《高性能网络编程(三):下一个10年,是时候考虑C10M并发问题了》 《高性能网络编程(四):从C10K到C10M高性能网络应用的理论探索》 《高性能网络编程(五):一文读懂高性能网络编程中的I/O模型》 《高性能网络编程(六):一文读懂高性能网络编程中的线程模型》 《不为人知的网络编程(一):浅析TCP协议中的疑难杂症(上篇)》 《不为人知的网络编程(二):浅析TCP协议中的疑难杂症(下篇)》 《不为人知的网络编程(三):关闭TCP连接时为什么会TIME_WAIT、CLOSE_WAIT》 《不为人知的网络编程(四):深入研究分析TCP的异常关闭》 《不为人知的网络编程(五):UDP的连接性和负载均衡》 《不为人知的网络编程(六):深入地理解UDP协议并用好它》 《不为人知的网络编程(七):如何让不可靠的UDP变的可靠?》 《网络编程懒人入门(一):快速理解网络通信协议(上篇)》 《网络编程懒人入门(二):快速理解网络通信协议(下篇)》 《网络编程懒人入门(三):快速理解TCP协议一篇就够》 《网络编程懒人入门(四):快速理解TCP和UDP的差异》 《网络编程懒人入门(五):快速理解为什么说UDP有时比TCP更有优势》 《网络编程懒人入门(六):史上最通俗的集线器、交换机、路由器功能原理入门》 《网络编程懒人入门(七):深入浅出,全面理解HTTP协议》 《网络编程懒人入门(八):手把手教你写基于TCP的Socket长连接》 《技术扫盲:新一代基于UDP的低延时网络传输层协议——QUIC详解》 《让互联网更快:新一代QUIC协议在腾讯的技术实践分享》 《现代移动端网络短连接的优化手段总结:请求速度、弱网适应、安全保障》 《聊聊iOS中网络编程长连接的那些事》 《移动端IM开发者必读(一):通俗易懂,理解移动网络的“弱”和“慢”》 《移动端IM开发者必读(二):史上最全移动弱网络优化方法总结》 《IPv6技术详解:基本概念、应用现状、技术实践(上篇)》 《IPv6技术详解:基本概念、应用现状、技术实践(下篇)》 《从HTTP/0.9到HTTP/2:一文读懂HTTP协议的历史演变和设计思路》 《脑残式网络编程入门(一):跟着动画来学TCP三次握手和四次挥手》 《脑残式网络编程入门(二):我们在读写Socket时,究竟在读写什么?》 《脑残式网络编程入门(三):HTTP协议必知必会的一些知识》 《脑残式网络编程入门(四):快速理解HTTP/2的服务器推送(Server Push)》 《以网游服务端的网络接入层设计为例,理解实时通信的技术挑战》 《迈向高阶:优秀Android程序员必知必会的网络基础》 >> 更多同类文章 …… (本文同步发布于:http://www.52im.net/thread-1963-1-1.html)
1、前言 在一个典型的高并发、大用户量的Web互联网系统的架构设计中,对HTTP集群的负载均衡设计是作为高性能系统优化环节中必不可少的方案。HTTP负载均衡的本质上是将Web用户流量进行均衡减压,因此在互联网的大流量项目中,其重要性不言而喻。 本文将以简洁通俗的文字,为你讲解主流的HTTP服务端实现负载均衡的常见方案,以及具体到方案中的负载均衡算法的实现原理。理解和掌握这些方案、算法原理,有助于您今后的互联网项的技术选型和架构设计,因为没有哪一种方案和算法能解决所有问题,只有针对特定的场景使用合适的方案和算法才是最明智的选择。 即时通讯网注:本文中所提及的HTTP负载均衡方案和算法,并不完全适用IM即时通讯Socket长连接的负载均衡,因为IM长连接、有状态的特性,跟HTTP这种短连接、无状态的特征是矛盾的,所以请勿盲目套用。但,一个完整的IM系统是由HTTP短连接+IM长连接组成,因而本文内容虽不能套用于IM长连接的负载均衡方案,但可以用于您IM的高并发、大用户量的HTTP短连接的方案设计。 (本文同步发布于:http://www.52im.net/thread-1950-1-1.html) 2、相关文章 深入阅读以下文章,有助于您更好地理解本篇内容: 《腾讯资深架构师干货总结:一文读懂大型分布式系统设计的方方面面》 《以微博类应用场景为例,总结海量社交系统的架构设计步骤》 《IM开发基础知识补课(四):正确理解HTTP短连接中的Cookie、Session和Token》 3、什么是负载均衡? 早期的互联网应用,由于用户流量比较小,业务逻辑也比较简单,往往一个单服务器就能满足负载需求。随着现在互联网的流量越来越大,稍微好一点的系统,访问量就非常大了,并且系统功能也越来越复杂,那么单台服务器就算将性能优化得再好,也不能支撑这么大用户量的访问压力了,这个时候就需要使用多台机器,设计高性