1. 概述
1.1 ElasticStack特点
- 使用门槛低,开发周期短,上线快
- 性能好,查询快,实时展示结果
- 扩容方便,快速支撑增长迅猛的数据
这个礼拜似乎是写了一礼拜的业务代码,没遇到什么太恶心的坑,就是理解业务逻辑费了点功夫。下个礼拜似乎又要开始撸组件撸网页了,现在想想感觉还是写写业务比较舒服,没事可以怼怼产品,给前端找找bug,写完还可以慢慢测;撸组件就比较无聊了,容易出大锅,而且还得求着人家用,用出问题还会被怼。。。不过好处大概就是以后跳槽面试的时候不容易被问死吧。。。
写代码的时候经常会遇到一些需要枚举的类型,比如“活动类型”、“数据来源”这类的。但是一般来讲我们不会直接用enum类型,因为这些数据一般都是以整数的形式存储在数据表里面,因此用整型去存储、转移会更加方便。所以一般我们都是直接声明成一个整型,然后用另外一个类去存储这个类型的值,比如:
1 | //需要引用的地方 |
1 | //该类型的值 |
这样搞没啥问题,但是比较讨厌的就是,当另外一个人看到xxxType这个字段时,他可不知道这玩意的值是XxxType去记录的,他甚至有可能去用YyyType的类型去给xxxType赋值。这就说不清楚了。
因此一个比较简单的方法就是把注释写清楚了,而相比普通的注释,java的文档注释就比较好用了。这里面有一个@see的注释,非常适合这种场景:
1 | /** |
也可以使用{@link}注释,达到类似的效果:
1 | /** |
这样无论谁在给这个字段赋值时,都非常清楚的知道该赋什么值了。这一点jdk做的就非常好,要向他学习。。。
SQL里的limit语句分页的性能不高这个应该是个常识,因为limit语句其实只是对前面查询的结果进行了一个简单的过滤,而没有做任何额外的优化。比如说我们希望从一个表中把所有的数据批量同步出来,但是考虑到量比较大,所以我们会希望通过分页慢慢查而不是一次性查出来。一个比较弱智的做法就是这样:
1 | select * from someTable limit 0,1000; |
比如有1000w的数据,那我们只要查1w次就好了,似乎效果不错。但是实际上我们是每一次都把1000w个数据查出来然后进行的过滤,也就是执行了1w次的全表扫描。可能刚开始的时候比较快,因为不需要查询多少数据就攒满了1000个数据,直接返回了,但是越查到后面就一定会越慢。
这时候正确的做法应该是尽量去使用索引来限制每一次查找的范围,使得每一次扫描不再是全表查找而是索引查找。具体的做法大概是这样:
1 | select * from someTable where id > 0 limit 1000; |
当然,前提还是在id字段上加了索引。至于为什么说第二种方法会优于第一种方法,其实很简单:
Talk is cheap , explain your SQL.
我们知道每一条SQL语句都会有一个最少执行时间,无论这条语句有多简单,况且每一次网络传输也需要时间。因此如果有大批量的DML语句需要执行,写一个for循环挨个去执行显然是一个很蠢的方法。所以我们更加倾向于将多条查询语句拼凑成一条,一次性去请求数据库。
对于批量查询,我们知道有 where in 语句,可以很方便的一次性查询多条记录,比如:
1 | select * from someTable where id in ( 1, 22, 333 ); |
对于批量插入,我们知道 insert 语句本身就可以支持同时插入多个values:
1 | insert into someTable (id ,key) values (1,'aa'),(22,'bbb'),(333,'cccc'); |
对于批量更新,我们可以利用when case 语句结合where in语句,一次性更新:
1 | update someTable set key = case when id=1 then 'aa' when id=22 then 'bbb' when id=333 then 'ccc' when id in (1, 22, 333); |
对于批量更新,如果使用mybatis,mapper大概是这样:
1 | <update id="updateKey" parameterType="java.util.List"> |
一个之前一直没有注意到的git命令,主要用于把当前未保存的状态直接推到一个栈里暂存,并且将工作区环境清空,回头有空的时候也可以再把工作区恢复。当开发到一半突然要切分支的时候特别有用。。。
1 | $ git stash -h |
主要利用gradle的maven插件。
1 | apply plugin: 'maven' |
执行gradle uploadArchives
命令即可。
需要注意的是,gradle默认的缓存目录并不是maven的~/.m2/repository/
下,而是类似~/.gradle/caches/modules-2/files-2.1
的目录下。
在将一些对象转化为json的时候要格外注意,尤其是在数据中有map类型的数据,而且key是普通对象的时候。看下下面的例子:
1 | import com.alibaba.fastjson.JSONObject; |
这个例子中,fastjson会将这个map对象转成下面这个:
1 | {{"field1":2,"field2":3}:2,{"field1":1,"field2":2}:1} |
fastjson将map的key也展开成了json,导致这个结果不满足标准的json格式,需要格外注意,不要因为无法进行json格式化而怀疑人生。
gson会将这个map对象转换成下面这个:
1 | {"Foo(field1\u003d2, field2\u003d3)":2,"Foo(field1\u003d1, field2\u003d2)":1} |
他也将key展开成了json,但是仍然是一个字符串,这样就仍然符合json的格式,可以格式化成这样:
1 | { |
二者其实各有利弊,一个方便我们去理解,另一个则满足了约定俗成的标准。
不得不说,愚蠢的我一直以为replace函数是只替换一次,而replaceAll函数是替换全部。其实压根不是这样,replace是用普通字符串进行匹配,而replaceAll是用正则表达式去匹配。事实上他们的本质都是用的正则表达式,看一下函数实现就知道了:
1 | public String replace(CharSequence target, CharSequence replacement) { |
误用这两个函数会有很多明显的bug,比如下面的代码:
1 | String s = "(1)(11)(111)"; |
输出就会是这样:
1 | (2)(11)(111) |
因为括号会被正则理解为捕获,而不被当成括号,类似的错误还会有很多很多,要格外注意。
当然,即使是知道差别,有时候想当然的用了也会出问题。比如下面代码:
1 | String s = ",1,1,1,11,111,"; |
我的意图是将字符串按逗号分隔,将值为1的全部变为2,(不替换11,111)。但是这段代码实际跑起来缺变成了这样:
1 | ,2,1,2,11,111, |
这样就少替换了一个。这是由于正则匹配的本质是自动机,匹配过的字符串是不会拿回来重新匹配的。。。
好快啊,又是一周的轮回,本来打算把本周遇到的问题展开一个一个总结的,但是奈何踩的坑是在有点多,展开来根本没时间搞,索性就搞了这个类似周报的东西。希望从这一周开始,每一周都能坚持下来喽。
由于跟给老大看的周报不同,这个是给俺自己看的,所以成果啥的就不表了,主要表一表自己写的bug跟领悟。就记一些大实话吧,”写者有罪,闻者足戒“。
这句话是同事跟我讲的,领会了下他的意思,大概是,下面的写法二要比写法一好:
1 | //Method1 |
方法一看似简单清晰,一行代码就完成了控制逻辑,但是这样其实有弊端:
当然,复杂逻辑适合方法二这么搞,但是辩证的看,如果逻辑比较简单,用方法一也无可厚非嘛。
我总结我们内部的业务数据表都是通过增加is_deleted字段来代表改项是否被删除(逻辑删除),而不会直接删除这条记录。分析了下这样做有下面几个好处:
当然,如果一张表采用的是逻辑删除,那么业务代码在查询的时候就要小心一点了,别查到了脏数据。
一般来说,一张数据表我们会加上这样三个字段:
1 | `gmt_create` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间(毫秒)', |
gmt_create
很显然保存了记录创建的时间,方便数据追查和校验,这个可以理解。但是gmt_modified
跟updated_at
字段似乎有些重复,都保存了记录的更新时间,区别只在于一个是手动更新,一个是自动更新。可是这到底有没有必要呢?
在某些场景下,是有必要的,因为这两个字段的逻辑含义是不同的。gmt_modified
表示的是字段的最后的修改时间,而updated_at
表示的是程序最后一次试图修改的时间(逻辑上类似Unix系统的touch
)。比如某个场景下,数据库A希望从数据库B中同步一些数据,但是同步过来的数据只是数据库A的一部分,这时候我们可能希望知道数据库A中到底有哪些字段是从数据库B中同步过的(即使值没有变化),哪些字段是没有同步过的。此时,仅凭gmt_modified
,我们无法获知哪些字段是从数据库B中获得的,这时我们就在执行同步操作的代码里手动去更新updated_at
即可。
当然,如果业务比较简单,能保证不牵涉到类似的场景,也是可以把updated_at
拿掉的。。。
guava包里有很多便利的工具,mark下,以后慢慢用。比如在做数据分片时用Lists.partition
方法,或者初始化集合类的Lists.newArrayListWithExpectedSize
方法等。不过需要小心的是这种工具为了效率,容易返回一些Immutable的集合,在下游使用的时候要格外小心。比如这样的操作就不被允许:
1 | List<Integer> list = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10); |
因为partition返回的是不可变集合,而shuffle方法需要进行set操作,因此就会报UnsupportedOperationException
。
测试用的数据库一定要和线上数据库的定义保持一致,否则很多线上bug在线下根本测不出来。当线下数据库变更之后,不要忘记在线上加字段,即使当时不影响业务,也容易留坑给后人。
踩了一个前人的坑,线上的数据表跟测试环境的不一样,导致我新加的一个功能始终跑不动,非常诡异。当时查表的操作是放在一个线程里的,异常信息也没打出来,导致排查了很久。。。
一般情况下,一个比较复杂的web服务会将task模块与api模块分别进行部署。这是因为通常情况下,task模块的任务量比较固定,因此我们会用固定数量的机器去跑,很少去变动;而api服务的任务量不固定,经常需要根据负载去弹性伸缩,经常回去扩容和缩容。因此对于一个定时任务来说,如果这个任务执行多次的结果不是幂等的,那么就要注意不能把它部署到api服务的机器上,否则就容易造成同样的任务被不同的机器执行多次的结果。
当然,在分布式环境下,定时任务还是最好接入分布式任务调度系统比较好,由调度系统统一配置和管理,这样就没这么多幺蛾子了。
在看着leader扩容机器的时候突然想到的,于是我赶紧把放在api模块里的定时任务偷偷移到了task模块里。。。
这一点需要留意,很多情况下我们是在一个服务里不断添加子功能,那么在写代码时就要注意不能因为一个子功能出错而导致整个服务起不起来甚至报错,尤其是在服务初始化的时候。因此我们一定要做好
try catch finally
,保证子功能的异常不会抛到外面。
之前写了一个接入Rmq的服务,这个服务在接入时出了一点问题导致这个服务没起来,但是由于异常抛出去了,导致整个应用都没起来。后来把异常全都捕获之后才定位了问题并解决掉的。一般来说在一些很可能抛异常的地方一定要把异常捕获全了,可以这么写:
1 | try{ |
注意一定要通过捕获Throwable
将所有的异常、错误捕获全了,不能仅仅捕获IDE提醒的那些checked exception
。
在设计表结构的时候,字段的默认值最好不要有业务含义,如果非得有,那一定要和业务逻辑相适配,不能就这么随便设个0或者是空啥的(况且一般来说字段类型都得是Not Null的才好)。
我的一个锅,在变更表结构的时候,有一个需要新加的字段含义是某个排名的前百分比。我当时没有细想,默认就设成了0,后来在表结构变更完之后忽然想起来,默认是0的话岂不是表示默认的这些条目排名最靠前么(前0%),这显然太不科学了,于是又重新变更了表结构,然后还要再刷一波数据。还好我的数据都是从别人那同步来的,再同步一次就好了,否则就得被迫跑路了。。
当然,设置正确的默认值的前提是要了解字段的逻辑含义,如果字段是从别人那边同步过来的一定要问清楚,有效数据的范围是什么,数据的逻辑含义是什么。
当然,这是常识,一开始写的时候也会记得,但是当这个bean组合了其他的bean的时候,那些bean也是要做序列化的。。。这个很容易被忘掉。
曾经写的一个的bug,在测试的时候就觉得奇怪,服务调用方传进来的参数始终不对,于是重头检查了这一块的代码,果然是序列化的问题,于是赶紧在发布前打了一个新包给服务调用方(其实也是我自己了)。
比如Double.MIN_VALUE,遇到这种常量一定要点进去看下到底是啥,不能再把这个当成最小负数来用了。
同样的坑不能踩第二遍。
其实不仅是es,很多与配置有关的问题都会牵涉到一点,叫
默认配置
。有默认值固然方便,但是默认不代表可以无视,一定要做到心中有数,否则就可能会因为默认配置与当前业务逻辑不符而造成意外的、难以排查的bug
曾经在前人维护的es配置中有这样的一个analyzer:
1 | "analysis": { |
他定义了一个comma_analyzer,试图以逗号分隔进行分词,一切似乎都很简单干净,人畜无害。但是真正用的时候却发现很多本应当搜索到的词却搜索不到,代码改了半天也没啥进展,直到我翻到es的文档,才发现有一个坑爹的默认配置:
1 | Pattern Analyzer |
好嘛,默认还加了一个大写转小写的过滤器,难怪用大写字母去搜大写字母根本搜不到,必须用小写字母才能搜到对应的大写字母。
而且这个filter是在建索引的时候添加的,因此搜索结果本身是看不出被转为小写了,这个问题排查起来难度还是很大的。
这就是一开始建索引考虑不周导致的遗留问题,现在发现bug之后修改起来也很不方便,必须要删除索引重建。在目前业务数据非常庞大的情况下,这样的代价是非常大的,因此也只能将这个缺陷告知所有的业务接入方了,在业务代码中适配了。
前一段时间在做代码性能比较的时候用到了jmh这个工具,原本以为拥有了这个方便的工具就能hold住java微基准测试这个命题。但是事实上,用着用着就发现自己的理解还非常不深入,有很多在测试的时候难以解释的现象。于是查阅了相关资料,才发现这里面的水比我想象要深,趁着记忆还热乎,赶紧记录一下。
java作为一种动态编译语言与c/c++这种静态编译语言有本质的不同。静态编译语言是在编译时就已经对代码做好了编译优化(比如C/C++在编译时指定-O1 -O2 -O3参数),得到的程序能够直接被计算机忠实地执行。而java这种动态编译语言在编译时几乎不会做什么优化,而是等到运行在虚拟机中时,动态的进行优化。
动态优化有好处有坏处,好处就在于他可以根据程序实时的运行状况,忽略掉那些事实上没有被执行的代码的影响,最大化的优化那些被多次执行的代码(这也是jvm有“预热”这一说法的原因);但是,缺点也在于,随着程序的运行,程序运行的环境会发生变化,如果继续保留之前动态优化的代码则会无法起作用甚至可能会出现错误,此时就需要卸载之前做的优化,这就是“动态反优化(Dynamic Deoptimization)”。
显然,我们并不希望jvm经常进行动态反优化,但是其实正常情况下,相比于程序逻辑的执行时间,这点反优化造成影响还是微不足道的。相比于他带来的问题,我们往往更加享受他带来的便利,因此大部分情况我们也无需太在意这些细节。但是当我们分析问题的粒度逐渐变小时、尤其是在做微基准测试时,就需要做到对这类问题心中有数了,否则就可能贻笑大方。
说的可能有点玄乎,举一个简单的例子,比如下面这样一段微基准测试代码:
1 | package com.pinduoduo.tusenpo.test; |
这段代码中我们定义了一个父类Operator和他的两个子类Method1、Method2,这两个类的实现完全相同,相当于一个自增操作。同时测试了五个方法:
考虑到在jmh中,以@Benchmark注解的方法是按照方法名的字典序顺序依次执行的,而且我采用的是@Fork(0)注解,因此上述函数的排序就是该函数的执行顺序,且执行的环境是同一个。
理论上讲,上面四个函数的执行速度应该是1≈2≈4≈5<<3
,但是这段代码跑起来的结果乍一看却让人大吃一惊:
1 | Benchmark Mode Cnt Score Error Units |
最终的执行结果竟然是1≈3≈5>>2≈4
,乍一看是相当令人不可思议。幸亏这里比较的方法比较简单,从而可以很容易让人归谬。如果这几个不一样的方法,那么人们就很容易做出一些自以为是的愚蠢的判断了。
事实上,由于待测试的函数运行时间相对比较短,因此动态编译对函数的影响就非常的大。而随着JVM的日趋强大,动态编译本身就是一个十分复杂的系统,各种策略与运行状态会互相影响,因此想要真正完全掌握代码的执行状态其实是很困难的事。想要真正了解代码的运行状态,原则上是要在运行时加入-XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining"
这些编译参数。这些参数表示打印出编译信息、解锁隐藏参数以及打印内联信息。事实上,很多编译优化基本都是通过内联来实现的,因此打印内联信息能让我们很好的研究编译优化的情况。
当然,学会读这些结果还是比较有难度的,我也只是大概理解他的含义。不过其实如果了解java编译优化的基本原理,这个问题还是不难解释的。
回归上面的问题,仔细分析一下,上面的这段诡异的代码其实有如下几个疑点:
不过仔细分析一下,其实可以根据上面的情况分析出三点:
那么显然,test_1,test_5在运行的时候一定是受到了JVM的动态优化,而test_2,test_4在执行的时候则受到了动态反优化,回归了正常而无用的计算。这里主要用到了两种优化逻辑。
我们知道,jvm支持多态,这个性质可以很方便的帮我们对复杂并具有关联的事物进行抽象建模。但是代价就是,当用父类的引用去调用子类的方法时,会多一次查虚拟表的操作,同时也不利于进行代码的内联(Inline)优化。而内联优化基本可以说是jvm优化的最重要的形式。因此在这种情况下,很多优化措施都无法生效。但是,jvm非常聪明,当他发现最近的代码块中某一个父类只有一个子类的实例时,他就很机智的将这个父类的方法与这个子类的方法进行绑定,使得调用子类的方法变得更快;同时当子类的方法比较简单时,甚至会将子类的方法进行内联。这就是JVM动态优化的一种,叫单形调用变换(monomorphic)。
无用代码移除的优化相比上面的优化更好理解,也就是JVM会判断,某些值在进行运算时如果没有对环境造成除其本身外的任何影响,那么JVM在执行时就有可能将这个值的运算直接移除。
有了这两个知识,基本就可以解释上面这个问题了。
首先,在test_5中,这样的简单循环计算出来的d其实没有任何用处,因此JVM就直接优化掉了,这个test_5也就直接被优化成了test_3。
然后,在test_1中,由于存在单形调用变换,operate方法被直接内联成了d+1
,因此test_1也就直接被优化成了test_5,从而最终被优化成了test_3。
最后,在test_2和test_4中,由于环境中存在着Operator类的不同实例,因此单形调用变换失效,内联代码被重新动态反优化成了函数调用。而在函数调用中,当读到d=op.operate(d)
这个方法无法时,jvm无法直接判断出d是否对op这个对象造成影响,因此也就无法将d直接优化掉,从而导致了程序完完整整的跑完了一百万次循环。
这个例子告诉我们,对java进行微基准测试与对c/c++进行微基准测试是不一样的。JVM会有很多复杂的逻辑,我们要对代码心存敬畏。
那么,有什么方法能够让我们尽量避免编译优化与编译反优化对我们的基准测试的影响呢?其实我们能做的也很有限,我们不能指定JVM去优化某段代码或者让他不去优化某段代码,我们只能尽量保证让JVM以同样的优化逻辑去优化我们希望比较的那些函数,从而尽量避免动态优化对我们结论的影响。
同样是上面的那段代码,如果我们将预热和执行的运行时间充分扩大(比如扩大十倍),那么我们就会得到完全不一样的运行结果:
调整参数:
1 | @Warmup(iterations = 5, time = 2000, timeUnit = TimeUnit.MILLISECONDS) |
运行结果:
1 | Benchmark Mode Cnt Score Error Units |
你会发现,经过充分的预热,所有的代码都得到了优化。
同样是上面的那段代码,如果我们将不同测试时的环境(profiler)进行隔离,我们也会得到完全优化后的结果:
调整参数
这里使用给@Fork注解赋一个非零值来给新的函数创造新的执行环境。
1 | @Warmup(iterations = 5, time = 200, timeUnit = TimeUnit.MILLISECONDS) |
运行结果
1 | Benchmark Mode Cnt Score Error Units |
其实根据观察者效应或者是广义的测不准原理,对代码进行微基准测试本身就会对代码造成影响,正如下面这句话所说:
Once you measure a system’s performance ,you change the system.
总而言之,学会对代码保持敬畏,技术和技术带来的现象永远只是表象,要学会脱离技术思考本质,提高自己的思维。毕竟,在IT界还有这样一句话:
A fool with a tool is still a fool.
共勉。
Java 理论与实践-动态编译与性能测量
An introduction to JVM performance
JVM Mechanics: When Does the JVM JIT & Deoptimize?
What is the purpose of JMH @Fork?
How do I write a correct micro-benchmark in Java?
前几天在面向 stackoverflow 编程时,遇到了一串有点诡异的代码:
1 | private String method1(byte[] bytes) { |
对java还不是很熟,乍一看还是有点懵逼的,于是就抽了个时间研究了下。
这段代码其实只做了一件简单的事,就是将一个字节数组转换成一个十六进制字符串,比如说传入{1,2,126,127,-1,-2,-127,-128}
,就会输出01027e7ffffe8180
。这种类似的代码在很多需要进行编码的场景下还是很常见的。
其实正常人在写这种功能的时候是这样写的:
1 | private String method2(byte[] bytes) { |
这种代码还是比较好理解的,将一个byte转换成两个字节的十六进制字符串,通俗易懂。那method1是如何实现相同的功能的呢,这里有两个难点,理解了就简单了。
& 0xff
+ 0x100
并且要substring(1)
第一点,是因为java中的byte是有符号的,为了使用Integer.toString()
转换成16进制,必须要有一个从byte到int的转换。而默认的转换方法会保留符号,比如对于一个负数的byte,转换成int后符号位就提到最前面了,而我们期望的是无符号的转换。因此这里使用了& 0xff
的方式,隐式的进行了无符号的转换。
第二点,是因为在byte转换为int后,在末8位的部分有可能是以0开头,这样转换成16进制后,生成的字符串长度就会小于2,开头的0就被舍弃了。因此我们通过+ 0x100
的方式强制生成一个长度为3的字符串,再用substring(1)
将开头的1舍弃,这样就保证了输出的字符串长度一定是2。
原理很简单,我感兴趣的是在 stackoverflow 上搜索的时候看到了高票答案有这样一句话:
1 | It's debatable whether all that has better performance (it certainly isn't clearer) than: |
很有趣,method1是否比method2更快竟然是有争议的,那我为啥要写这种奇怪的代码呢,速度没优势还可读性更差。从哲学上讲如果method2在任何方面都吊打method1,那么method1就没有任何存在的道理了。于是我就闲着蛋疼跑了一波微基准测试(记得在一位大佬的书里看到过这样一句话:任何在做微基准测试之前就对函数执行效率进行评论的行为都是耍流氓)。
实验很简单,照着微基准测试的模板敲了(顺便mark下,以后接着用):
gradle依赖配置:
1 | compile 'org.openjdk.jmh:jmh-core:1.21' |
如果不加第二个依赖有可能会报错:
1 | Unable to find the resource: /META-INF/BenchmarkList |
测试代码:
1 | package com.pinduoduo.tusenpo.test; |
我这里测量的是函数单线程下的执行效率,比较了经过1秒钟预热以后在5秒钟内填充长度为1024的字节数组的执行次数(由于函数比较简单,这里执行时间短一点没问题)。
执行结果:
1 | # JMH version: 1.21 |
很明显,”难读”的方法比”简单”的方法还是快了几十倍的。当然,如果这一块不是性能瓶颈的话,执行一次相差零点几毫秒这点区别还是没啥意义的。