Mythsman


乐极生悲,苦尽甘来。


工作中遇到的一些坑(1)

前言

好快啊,又是一周的轮回,本来打算把本周遇到的问题展开一个一个总结的,但是奈何踩的坑是在有点多,展开来根本没时间搞,索性就搞了这个类似周报的东西。希望从这一周开始,每一周都能坚持下来喽。

由于跟给老大看的周报不同,这个是给俺自己看的,所以成果啥的就不表了,主要表一表自己写的bug跟领悟。就记一些大实话吧,"写者有罪,闻者足戒"。

知识&技巧

while循环的控制逻辑写在循环体内

这句话是同事跟我讲的,领会了下他的意思,大概是,下面的写法二要比写法一好:

    //Method1
    while (!shouldBreak1() && !shouldBreak2()) {
        //Do sth
    }
    
    //Method1
    while (true) {
        if (shouldBreak1()) {
            break;
        }
        if (shouldBreak2()) {
            break;
        }
        //Do sth
    }

方法一看似简单清晰,一行代码就完成了控制逻辑,但是这样其实有弊端:

  1. 调试麻烦,如果条件越来越多,容易混淆出错的到底是哪一行
  2. 打日志不方便,这很显然
  3. 逻辑一致性较差,有的控制逻辑可以写在开头,可有的控制逻辑就无法写在开头了,这时候不如都直接写在代码体里,保证一致性。

当然,复杂逻辑适合方法二这么搞,但是辩证的看,如果逻辑比较简单,用方法一也无可厚非嘛。

不要对数据表的记录进行物理删除

我总结我们内部的业务数据表都是通过增加is_deleted字段来代表改项是否被删除(逻辑删除),而不会直接删除这条记录。分析了下这样做有下面几个好处:

  1. 防止数据丢失。这很显然。
  2. 方便追查数据变更。可以看出这行记录是何时删除的等等。
  3. 方便进行数据同步。比如有的场景下,数据库A希望定时全量同步数据库B的数据。在不使用DTS的情况下,A只有扫描B的全表来更新自己。当B表把某条记录删除时,A表却无法获知B表的变更,就会造成数据不一致,这样需要进行一些额外的补偿操作,比较麻烦。
  4. 简化并发环境下的操作。当多个服务同时对一条记录进行操作时,如果某一个服务进行了物理删除操作,则很容易造成一些奇怪的问题。

当然,如果一张表采用的是逻辑删除,那么业务代码在查询的时候就要小心一点了,别查到了脏数据。

数据表默认加上gmt_modified,gmt_create,updated_at字段

一般来说,一张数据表我们会加上这样三个字段:

   `gmt_create` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间(毫秒)',
   `gmt_modified` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '修改时间(毫秒)',
   `updated_at` int(20) NOT NULL DEFAULT '0' COMMENT '记录更新时间',

gmt_create 很显然保存了记录创建的时间,方便数据追查和校验,这个可以理解。但是gmt_modifiedupdated_at字段似乎有些重复,都保存了记录的更新时间,区别只在于一个是手动更新,一个是自动更新。可是这到底有没有必要呢?

在某些场景下,是有必要的,因为这两个字段的逻辑含义是不同的。gmt_modified表示的是字段的最后的修改时间,而updated_at表示的是程序最后一次试图修改的时间(逻辑上类似Unix系统的touch)。比如某个场景下,数据库A希望从数据库B中同步一些数据,但是同步过来的数据只是数据库A的一部分,这时候我们可能希望知道数据库A中到底有哪些字段是从数据库B中同步过的(即使值没有变化),哪些字段是没有同步过的。此时,仅凭gmt_modified,我们无法获知哪些字段是从数据库B中获得的,这时我们就在执行同步操作的代码里手动去更新updated_at即可。

当然,如果业务比较简单,能保证不牵涉到类似的场景,也是可以把updated_at拿掉的。。。

Guava大法好

guava包里有很多便利的工具,mark下,以后慢慢用。比如在做数据分片时用Lists.partition方法,或者初始化集合类的Lists.newArrayListWithExpectedSize方法等。不过需要小心的是这种工具为了效率,容易返回一些Immutable的集合,在下游使用的时候要格外小心。比如这样的操作就不被允许:

    List<Integer> list = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
    List<List<Integer>> partitions = Lists.partition(list, 3);
    Collections.shuffle(partitions);

因为partition返回的是不可变集合,而shuffle方法需要进行set操作,因此就会报UnsupportedOperationException

问题&反省

保证线上线下数据库一致

测试用的数据库一定要和线上数据库的定义保持一致,否则很多线上bug在线下根本测不出来。当线下数据库变更之后,不要忘记在线上加字段,即使当时不影响业务,也容易留坑给后人。

踩了一个前人的坑,线上的数据表跟测试环境的不一样,导致我新加的一个功能始终跑不动,非常诡异。当时查表的操作是放在一个线程里的,异常信息也没打出来,导致排查了很久。。。

定时任务在发布时注意机器数量

一般情况下,一个比较复杂的web服务会将task模块与api模块分别进行部署。这是因为通常情况下,task模块的任务量比较固定,因此我们会用固定数量的机器去跑,很少去变动;而api服务的任务量不固定,经常需要根据负载去弹性伸缩,经常回去扩容和缩容。因此对于一个定时任务来说,如果这个任务执行多次的结果不是幂等的,那么就要注意不能把它部署到api服务的机器上,否则就容易造成同样的任务被不同的机器执行多次的结果。
当然,在分布式环境下,定时任务还是最好接入分布式任务调度系统比较好,由调度系统统一配置和管理,这样就没这么多幺蛾子了。

在看着leader扩容机器的时候突然想到的,于是我赶紧把放在api模块里的定时任务偷偷移到了task模块里。。。

一个服务中互相无关的组件在启动时不能互相影响

这一点需要留意,很多情况下我们是在一个服务里不断添加子功能,那么在写代码时就要注意不能因为一个子功能出错而导致整个服务起不起来甚至报错,尤其是在服务初始化的时候。因此我们一定要做好try catch finally,保证子功能的异常不会抛到外面。

之前写了一个接入Rmq的服务,这个服务在接入时出了一点问题导致这个服务没起来,但是由于异常抛出去了,导致整个应用都没起来。后来把异常全都捕获之后才定位了问题并解决掉的。一般来说在一些很可能抛异常的地方一定要把异常捕获全了,可以这么写:

    try{
        //TODO something that may raise an exception
    }catch (Exception1 e){
        //TODO do sth to handle or print this exception
    }catch (Exception2 e){
        //TODO do sth to handle or print this exception
    }catch (Throwable e){
        //TODO do sth to handle or print all exceptions that might be raised.
    }finally {
        //TODO do sth that must be done.
    }

注意一定要通过捕获Throwable将所有的异常、错误捕获全了,不能仅仅捕获IDE提醒的那些checked exception

数据库字段默认值设置要小心

在设计表结构的时候,字段的默认值最好不要有业务含义,如果非得有,那一定要和业务逻辑相适配,不能就这么随便设个0或者是空啥的(况且一般来说字段类型都得是Not Null的才好)。

我的一个锅,在变更表结构的时候,有一个需要新加的字段含义是某个排名的前百分比。我当时没有细想,默认就设成了0,后来在表结构变更完之后忽然想起来,默认是0的话岂不是表示默认的这些条目排名最靠前么(前0%),这显然太不科学了,于是又重新变更了表结构,然后还要再刷一波数据。还好我的数据都是从别人那同步来的,再同步一次就好了,否则就得被迫跑路了。。

当然,设置正确的默认值的前提是要了解字段的逻辑含义,如果字段是从别人那边同步过来的一定要问清楚,有效数据的范围是什么,数据的逻辑含义是什么。

将bean用来进行rpc传输要记得序列化

当然,这是常识,一开始写的时候也会记得,但是当这个bean组合了其他的bean的时候,那些bean也是要做序列化的。。。这个很容易被忘掉。

曾经写的一个的bug,在测试的时候就觉得奇怪,服务调用方传进来的参数始终不对,于是重头检查了这一块的代码,果然是序列化的问题,于是赶紧在发布前打了一个新包给服务调用方(其实也是我自己了)。

遇到预定义的常量要确认他的值到底是什么

比如Double.MIN_VALUE,遇到这种常量一定要点进去看下到底是啥,不能再把这个当成最小负数来用了。

同样的坑不能踩第二遍。

ES中自定义Analyzer时要小心默认的配置

其实不仅是es,很多与配置有关的问题都会牵涉到一点,叫默认配置。有默认值固然方便,但是默认不代表可以无视,一定要做到心中有数,否则就可能会因为默认配置与当前业务逻辑不符而造成意外的、难以排查的bug

曾经在前人维护的es配置中有这样的一个analyzer:

"analysis": {
  "analyzer": {
    "comma_analyzer": {
      "pattern": ",",
      "type": "pattern"
    }
  }
}

他定义了一个comma_analyzer,试图以逗号分隔进行分词,一切似乎都很简单干净,人畜无害。但是真正用的时候却发现很多本应当搜索到的词却搜索不到,代码改了半天也没啥进展,直到我翻到es的文档,才发现有一个坑爹的默认配置:

Pattern Analyzer

Definition
It consists of:

Tokenizer
  Pattern Tokenizer
  
Token Filters
  Lower Case Token Filter
  Stop Token Filter (disabled by default)

好嘛,默认还加了一个大写转小写的过滤器,难怪用大写字母去搜大写字母根本搜不到,必须用小写字母才能搜到对应的大写字母。
而且这个filter是在建索引的时候添加的,因此搜索结果本身是看不出被转为小写了,这个问题排查起来难度还是很大的。

这就是一开始建索引考虑不周导致的遗留问题,现在发现bug之后修改起来也很不方便,必须要删除索引重建。在目前业务数据非常庞大的情况下,这样的代价是非常大的,因此也只能将这个缺陷告知所有的业务接入方了,在业务代码中适配了。