背景

最近线上某个服务出现了 JVM Crash 现象,并且每次都发生在比较相近的时间段里,下面是某一次的日志

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
#
# A fatal error has been detected by the Java Runtime Environment:
#
# SIGSEGV (0xb) at pc=0x00007ff3ed5c1811, pid=192, tid=140655176587008
#
# JRE version: Java(TM) SE Runtime Environment (8.0_45-b14) (build 1.8.0_45-b14)
# Java VM: Java HotSpot(TM) 64-Bit Server VM (25.45-b02 mixed mode linux-amd64 compressed oops)
# Problematic frame:
# J 9931 C2 java.util.ArrayList.contains(Ljava/lang/Object;)Z (14 bytes) @ 0x00007ff3ed5c1811 [0x00007ff3ed5c17c0+0x51]
# Failed to write core dump. Core dumps have been disabled. To enable core dumping, try "ulimit -c unlimited" before starting Java again
--------------- T H R E A D ---------------
Current thread (0x00007ff36c20f000): JavaThread "ForkJoinPool-55-worker-0" daemon [_thread_in_Java, id=84902, stack(0x00007fecd5c61000,0x00007fecd5d62000)]
siginfo: si_signo: 11 (SIGSEGV), si_code: 1 (SEGV_MAPERR), si_addr: 0x0000000000000009
Registers:
RAX=0x00000007c001b6c0, RBX=0x0000000000000002, RCX=0x00000000ebfedc3e, RDX=0x0000000000000001
RSP=0x00007fecd5d5fb10, RBP=0x0000000000000000, RSI=0x000000075ff6e108, RDI=0x0000000000000002
R8 =0x0000000000000002, R9 =0x00000000ebfedc32, R10=0x00000007c001b6c0, R11=0x0000000000000000
R12=0x0000000000000000, R13=0x0000000000000002, R14=0x000000075ff6e1d8, R15=0x00007ff36c20f000
RIP=0x00007ff3ed5c1811, EFLAGS=0x0000000000010202, CSGSFS=0x0000000000000033, ERR=0x0000000000000004
TRAPNO=0x000000000000000e
Top of Stack: (sp=0x00007fecd5d5fb10)
0x00007fecd5d5fb10: 000000000000003f 0000000000000000
0x00007fecd5d5fb20: 00000005c5a0b488 00000007c0412700
0x00007fecd5d5fb30: 00000000ebfe7804 000000075fecbe70
0x00007fecd5d5fb40: 00000000b8ca514c 00007ff3f2b5e2ec
0x00007fecd5d5fb50: 00007fecd5d5fb90 00007fecd5d5fb80
0x00007fecd5d5fb60: 00007ff248698ec0 000000001e61f9cd
...
Instructions: (pc=0x00007ff3ed5c1811)
0x00007ff3ed5c17f1: c4 e1 f9 6e ce 8b 4e 14 44 8b 46 10 33 ed 48 85
0x00007ff3ed5c1801: d2 0f 84 95 04 00 00 45 85 c0 0f 8e 65 03 00 00
0x00007ff3ed5c1811: 44 8b 52 08 41 81 fa da 02 00 f8 0f 85 a8 05 00
0x00007ff3ed5c1821: 00 45 8b 6c cc 0c 49 8d 2c cc 45 85 ed 0f 86 48
Register to memory mapping:
RAX=0x00000007c001b6c0 is pointing into metadata
RBX=0x0000000000000002 is an unknown value
RCX=0x00000000ebfedc3e is an unknown value
RDX=0x0000000000000001 is an unknown value
RSP=0x00007fecd5d5fb10 is pointing into the stack for thread: 0x00007ff36c20f000
RBP=0x0000000000000000 is an unknown value
RSI=
[error occurred during error reporting (printing register info), id 0xb]
Stack: [0x00007fecd5c61000,0x00007fecd5d62000], sp=0x00007fecd5d5fb10, free space=1018k
Native frames: (J=compiled Java code, j=interpreted, Vv=VM code, C=native code)
J 9931 C2 java.util.ArrayList.contains(Ljava/lang/Object;)Z (14 bytes) @ 0x00007ff3ed5c1811 [0x00007ff3ed5c17c0+0x51]
--------------- P R O C E S S ---------------
Java Threads: ( => current thread )
0x00007fee48009800 JavaThread "hystrix-fuiou_default-10" daemon [_thread_in_native, id=84913, stack(0x00007fecd6a6f000,0x00007fecd6b70000)]
=>0x00007ff36c20f000 JavaThread "ForkJoinPool-55-worker-0" daemon [_thread_in_Java, id=84902, stack(0x00007fecd5c61000,0x00007fecd5d62000)]
0x00007fee4400a000 JavaThread "ForkJoinPool-55-worker-3" daemon [_thread_blocked, id=84901, stack(0x00007fecd5d62000,0x00007fecd5e63000)]
...
Internal exceptions (10 events):
Event: 42112.006 Thread 0x00007fee6c014800 Exception <a 'java/lang/ClassNotFoundException': com/xueqiu/fundx/market/domain/FundDataStockDetailPercentCustomizer> (0x000000077aad98c0) thrown at [/RE-WORK/workspace/8-2-build-linux-amd64/jdk8u45/3457/hotspot/src/share/vm/classfile/systemDictionary.cpp
Event: 42112.007 Thread 0x00007fee6c014800 Exception <a 'java/lang/ClassNotFoundException': com/xueqiu/fundx/market/domain/FundDataStockDetailPercentBeanInfo> (0x000000077ab42100) thrown at [/RE-WORK/workspace/8-2-build-linux-amd64/jdk8u45/3457/hotspot/src/share/vm/classfile/systemDictionary.cpp,
Event: 42112.008 Thread 0x00007fee6c014800 Exception <a 'java/lang/ClassNotFoundException': com/xueqiu/fundx/market/domain/FundDataStockDetailPercentCustomizer> (0x000000077aba3a70) thrown at [/RE-WORK/workspace/8-2-build-linux-amd64/jdk8u45/3457/hotspot/src/share/vm/classfile/systemDictionary.cpp
Event: 42112.009 Thread 0x00007fee6c014800 Exception <a 'java/lang/ClassNotFoundException': com/xueqiu/fundx/market/domain/FundDataStockDetailPercentBeanInfo> (0x000000077a40c330) thrown at [/RE-WORK/workspace/8-2-build-linux-amd64/jdk8u45/3457/hotspot/src/share/vm/classfile/systemDictionary.cpp,
Event: 42112.009 Thread 0x00007fee6c014800 Exception <a 'java/lang/ClassNotFoundException': com/xueqiu/fundx/market/domain/FundDataStockDetailPercentCustomizer> (0x000000077a46dca0) thrown at [/RE-WORK/workspace/8-2-build-linux-amd64/jdk8u45/3457/hotspot/src/share/vm/classfile/systemDictionary.cpp
Event: 42112.192 Thread 0x00007ff154083000 Exception <a 'java/net/SocketTimeoutException': Read timed out> (0x0000000779e96098) thrown at [/RE-WORK/workspace/8-2-build-linux-amd64/jdk8u45/3457/hotspot/src/share/vm/prims/jni.cpp, line 735]
Event: 42112.817 Thread 0x00007ff154265800 Exception <a 'java/net/SocketTimeoutException': Read timed out> (0x000000077743dc38) thrown at [/RE-WORK/workspace/8-2-build-linux-amd64/jdk8u45/3457/hotspot/src/share/vm/prims/jni.cpp, line 735]
Event: 42113.412 Thread 0x00007ff15427e000 Exception <a 'java/net/SocketTimeoutException': Read timed out> (0x00000007747e4f80) thrown at [/RE-WORK/workspace/8-2-build-linux-amd64/jdk8u45/3457/hotspot/src/share/vm/prims/jni.cpp, line 735]
Event: 42114.344 Thread 0x00007ff154351000 Exception <a 'java/net/SocketTimeoutException': Read timed out> (0x000000076d9d3658) thrown at [/RE-WORK/workspace/8-2-build-linux-amd64/jdk8u45/3457/hotspot/src/share/vm/prims/jni.cpp, line 735]
Event: 42115.538 Thread 0x00007ff36c20f000 Implicit null exception at 0x00007ff3ed5c1811 to 0x0000000000000000
Events (10 events):
Event: 42112.009 loading class com/xueqiu/fundx/market/domain/FundDataStockDetailPercentCustomizer
Event: 42112.009 loading class com/xueqiu/fundx/market/domain/FundDataStockDetailPercentCustomizer done
Event: 42112.009 loading class com/xueqiu/fundx/market/domain/FundDataStockDetailPercentCustomizer
Event: 42112.009 loading class com/xueqiu/fundx/market/domain/FundDataStockDetailPercentCustomizer done
Event: 42112.009 loading class com/xueqiu/fundx/market/domain/FundDataStockDetailPercentCustomizer
Event: 42112.009 loading class com/xueqiu/fundx/market/domain/FundDataStockDetailPercentCustomizer done
Event: 42112.284 Thread 0x00007ff3fce22000 flushing nmethod 0x00007ff3f5a26250
Event: 42112.284 Thread 0x00007ff3fce22000 flushing nmethod 0x00007ff3f5a43390
Event: 42112.288 Thread 0x00007ff3fce22000 flushing nmethod 0x00007ff3f63395d0
Event: 42114.505 Thread 0x00007ff0cc017800 Thread added: 0x00007ff0cc017800

分析错误日志

从日志中能获取到的重要信息

1
2
3
4
5
6
7
8
9
10
11
12
13
siginfo: si_signo: 11 (SIGSEGV), si_code: 1 (SEGV_MAPERR), si_addr: 0x0000000000000009
SIGSEGV: A segmentation fault occurs when a program attempts to access a memory location that it is not allowed to access, or attempts to access a memory location in a way that is not allowed (for example, attempting to write to a read-only location, or to overwrite part of the operating system).
访问了一个无效的内存引用(可能是不允许访问的内存地址,或者是禁止的访问方式)
SEGV_MAPERR :地址没有映射到对象
siaddr: 0x0000000000000009
这个就是访问的地址,0x00000000-0x0000FFFF 是进程NULL区的保留地址,禁止访问
Event: Thread 0x00007ff36c20f000 Implicit null exception at 0x00007ff3ed5c1811 to 0x0000000000000000
提示了这是一个隐式空指针问题

从线程名中找到对应的代码逻辑块,并没有发现对 ArrayList.contains 的调用,随后通过使用 udcli 转译指令成汇编,貌似也没有找到匹配的代码

1
2
3
4
➜ ~ echo '44 8b 52 08 41 81 fa da 02 00 f8 0f 85 a8 05 00' |udcli -intel -x -64 -o 0x00007ff3ed5c1811
00007ff3ed5c1811 448b5208 mov r10d, [rdx+0x8] //转移 rdx+8 (也就是出问题的地址)
00007ff3ed5c1815 4181fada0200f8 cmp r10d, 0xf80002da //比较俩个值(相减),应该是个比大小的操作
00007ff3ed5c181c 0f85a80500 invalid

暂时没有什么头绪,想借助 core dump 来进行更深入的分析,但因为我们应用跑在 Docker 里,core dump 默认没有打开。

首先找 SRE 团队配合改一下 Docker 的配置,另外在应用内加上了更多的定位日志,希望能定位到具体行。

1
2
3
1. docker run 参数里的加上 --ulimit core=-1
2. 在 /etc/sysctl.conf 里可以配置文件生成路径
kernel.core_pattern = /persist/core_%e_%p

尝试定位修复

等待下一次 crash 期间也做了几件事

  • 猜测是否是使用了 forkjoin 框架本身的问题,随后替换成标准的线程池,无用

  • 根据日志定位到了大概的代码块,是一个普通是 foreach 中,仔细分析后似乎没有发现问题

  • 再次仔细的分析了下crash 日志,发现每次都会有相应的 jni 调用及某个类的异常,如下

    1
    2
    Event: 42112.009 Thread 0x00007fee6c014800 Exception <a 'java/lang/ClassNotFoundException': com/xueqiu/fundx/market/domain/FundDataStockDetailPercentCustomizer> (0x000000077a46dca0) thrown at [/RE-WORK/workspace/8-2-build-linux-amd64/jdk8u45/3457/hotspot/src/share/vm/classfile/systemDictionary.cpp
    Event: 42112.192 Thread 0x00007ff154083000 Exception <a 'java/net/SocketTimeoutException': Read timed out> (0x0000000779e96098) thrown at [/RE-WORK/workspace/8-2-build-linux-amd64/jdk8u45/3457/hotspot/src/share/vm/prims/jni.cpp, line 735]

    根据线程分析,这是一个MQ的触发事件,但如果是JNI导致的错误,为什么是另外一个线程的异常 Crash,这里没有想通

分析 core dump

拿到 core dump 后,首先想通过 jstack 获取到具体的异常栈信息,栈信息已丢失

1
Thread 84902: (state = IN_JAVA) //线程正在执行编译后的Java代码

尝试使用 jmap 导出内存 dump,失败

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
app@bjxg-vd-9-201:~$ jmap -dump:format=b,file=heap.hprof /usr/bin/java core_java_192
Picked up _JAVA_OPTIONS: -Djava.net.preferIPv4Stack=true -Dfile.encoding=UTF-8
Attaching to core core_java_192 from executable /usr/bin/java, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.45-b02
Dumping heap to heap2.hprof ...
Exception in thread "main" java.lang.reflect.InvocationTargetException
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:497)
at sun.tools.jmap.JMap.runTool(JMap.java:201)
at sun.tools.jmap.JMap.main(JMap.java:130)
Caused by: sun.jvm.hotspot.utilities.AssertionFailure: can not get class data for java/util/stream/FindOps$$Lambda$210x00000007c019bc28
at sun.jvm.hotspot.utilities.Assert.that(Assert.java:32)
at sun.jvm.hotspot.utilities.HeapHprofBinWriter.writeInstance(HeapHprofBinWriter.java:803)
at sun.jvm.hotspot.utilities.AbstractHeapGraphWriter$1.doObj(AbstractHeapGraphWriter.java:95)
at sun.jvm.hotspot.oops.ObjectHeap.iterateLiveRegions(ObjectHeap.java:353)
at sun.jvm.hotspot.oops.ObjectHeap.iterate(ObjectHeap.java:171)
at sun.jvm.hotspot.utilities.AbstractHeapGraphWriter.write(AbstractHeapGraphWriter.java:51)
at sun.jvm.hotspot.utilities.HeapHprofBinWriter.write(HeapHprofBinWriter.java:433)
at sun.jvm.hotspot.tools.HeapDumper.run(HeapDumper.java:62)
at sun.jvm.hotspot.tools.Tool.startInternal(Tool.java:260)
at sun.jvm.hotspot.tools.Tool.start(Tool.java:223)
at sun.jvm.hotspot.tools.Tool.execute(Tool.java:118)
at sun.jvm.hotspot.tools.HeapDumper.main(HeapDumper.java:83)
... 6 more

随后尝试使用 gdb 加载分析

1
2
3
4
5
6
7
8
9
10
11
(gdb) bt
#0 0x00007ff40130ecc9 in _quicksort (pbase=0x0, total_elems=<optimized out>, size=32, cmp=0x7fecd5d5d420, arg=0x7ff401269640 <OnError>) at qsort.c:128
#1 0x0000000000010206 in ?? ()
#2 0x0000000000000033 in ?? ()
#3 0x0000000000000004 in ?? ()
#4 0x000000000000000e in ?? ()
#5 0xfffffffe7ffbfaff in ?? ()
#6 0x0000000000000030 in ?? ()
#7 0x00007fecd5d5d880 in ?? ()
#8 0x00007ff4009ee156 in jni_GetObjectField () from /data/jdk1.8.0_45/jre/lib/amd64/server/libjvm.so
Backtrace stopped: previous frame inner to this frame (corrupt stack?)

Backtrace 最后一帧是一个qsort 函数,这里和之前汇编 cmp 操作能对应上

至此大概猜测是某种原因破坏了堆内存,例如 JNI 调用使用了错误的参数触发了空指针等

最后尝试一下更新 JDK 版本,1.8.0_45 -> 1.8.0_201,尝试之后至今还未再次发生Crash 事件,难道真的是一个JDK 的bug 吗?

雪球内部很早就开始尝试使用 Spring Boot,我们团队是第一批吃螃蟹的人(使用的版本是 v1.2.4,发布于 2015 年中)。到目前 Spring Boot 已经成为公司内普及的基础底层框架,但各个团队之间使用上并不统一,1.2、1.3、1.5 版本都有在使用,也引发了一些中间件的版本依赖问题。

我们的版本是其中最古老的,已经偏离了四个大版本。其实很早就有升级的规划,在 1.5 版本期间有尝试过一次,因为涉及的改动很多加上当时业务繁忙所以停滞了。Spring Boot 2.0 release 版本在三月份发布,四月正好空余出较大块的时间,趁这个天时地利机会把整个后端模块全部升级到了 2.0 基准。

这次会分享一些升级过程中的一些经验及遇到问题、踩过的坑,旨在帮助大家能更顺畅的升级,顺便推动大家在合适的机会把版本升级到统一基准下。

简单介绍下新特性

Spinrg Boot 2.0 带来最大的新特性是 Reactive Spring & WebFlux,准确的说是因为引入了 Spring 5.0。

Reactive Programing(响应式编程,以下简称 RP)核心思想是利用异步和消息驱动构建一个流畅的高弹性的系统。 RP 不是什么新鲜的技术,这里就不展开介绍了。WebFlux 是基于 Reactive Streams 构建的一整套新的响应式编程模型,并提供像 WebClient 响应式的客户端,对标的是 Spring MVC 模块。

在 RP 模型下,务必要保证整个流程的异步性,如果一个 Callback 堵塞会直接影响到整系统,但目前大多数的 JAVA 库都是阻塞模型的,虽然 Spring 这次带来了一整套的响应式技术栈,但 JDBC 这种阻塞模型还没有得到支持(阿里内部通过 JVM 的 Wisp 协程技术解决此类问题,并基于 RX Java 也实现了一整套的 RP 模型框架),希望 Spring Data Reactive 也可以早日解决。

还有其他一些特性例如 HTTP/2 的支持、Actuator 模块的增强、Quartz 模块的支持等更多特性大家可以查阅官方文档。

除了新特性还有其他一些有意思的改动,或许算是技术方向的指引?

  • Lettuce 替代 Jedis
  • Caffeine 替代 Guava
  • HikariCP 替代 Tomcat Pool

市面上常用的 Redis Client 除了 Jedis 还有 Lettuce 和 Redisson,两者都是基于 Netty 实现,并也都支持了响应式的 API,Spring 选择 Lettuce 的原因我猜测或许是因为它相对 Redisson 来说足够简单~

Guava Cache 是非常常用的组件,Caffeine 采用了更高效的 LRU 算法,并优化了内存使用。官方性能评测优于 Guava 10 倍左右,这应该是 Spring 放弃 Guava 的原因。Caffeine 也提供了 Guava Adapter,可以让用户无缝迁移过来,目前我们团队也逐步替换中。

HikariCP 一直号称是最快的连接池组件,这次变为 Spring Boot 默认组件算是官方对它的认可了。雪球团队一直使用 Druid 连接池,主要是因为它在监控方面做的很全面,插件很多,还经过了国内诸多大厂的全面“测试”,使用起来较顺手。

升级过程

对于过于老旧的版本,官方建议的是先升级到 1.5 作为跳板再升级到 2.0。但我们的做法比较激进, 也导致涉及依赖包的升级,配置的变动,及代码层面的改动非常之多,在提交上线的 PR 时改动文件有 250+,看到还挺惊讶。

这里推荐几个组件,利用好 starters 可以有效减少依赖包的管理难度

  • spring-boot-starter-json
  • spring-boot-starter-test
  • spring-boot-starter-cache

这几个官方组件支持了一些常用的模块,例如 jackson,测试的 JUnit、mockito 及常用的 cache manager 支持等。

配置层面也有许多变化,重构了很多 key 结构,也提供了 spring-boot-properties-migrator 模块来协助迁移。 并提供了更松散的绑定规则(破折号,驼峰,下划线),但建议在 .properties 和 .yml 文件中使用小写字母+破折号规范(个人建议所有配置都使用这个标准)

1
2
3
4
#老配置
multipart.maxFileSize=1M
#新规范
spring.servlet.multipart.max-file-size=1M

以上这些改动虽然非常多但较容易发现,或是编译失败或无法启动。整个升级过程中最可怕的是一些隐性的风险,非常容易忽略。

Path Matching 默认关闭后缀匹配

在以前你可以任意修改路径后缀名都能得到正确的解析,例如 /fund/000961.xyz 也能正确解析到 /fund/000961。但是 2.0 默认禁止了后缀匹配模式,这也是 Spring 的官方建议。

其实在做升级的过程中已经知道有这个改动,但我们团队一直使用的 REST 风格不带后缀的形式,所以没太在意。但万万没想到,其他一个团队的使用了我们的开放接口,但却采用了他们自己的路径规则,加上了 .json 后缀,在以前一直稳定运行,但现在这部分接口全部挂掉。这也是整个升级过程造成的唯一线上事故,幸好及时发现修复,没有产生大的影响。

解决方案就是把后缀匹配打开:

1
spring.mvc.pathmatch.use-suffix-pattern=true
新增 HTTP PUT Method Filter

在升级后的测试过程中发现部分接口明明参数正确却一直提示失败,经排查发现 PUT 类型的参数出现了数据重叠

1
/invest/501021?pwd=abc -> pwd=abc,abc(后端拿到的值)

随后跟踪源码 debug 发现新增了几个 Filter,其中 HttpPutFormContentFilter 用来处理 PUT 及 PATCH 的 body 传参问题(这个补丁在 1.5 版本后就已经引入并默认开启)。

因为浏览器 form 表单只支持 GET 与 POST 请求,所以早期的 Spring 不支持 PUT 请求使用 body 传参,正确的解决方案应该是在后端做 Method 的隐式转换。但之前并没有使用此方案,而是客户端同学把参数改成以 param 传递,这本身没有太大问题,但却忘记了把 body 里参数去掉,在新版本就变成了重名参数,以字符数组接收(数值类的不受影响)

解决方案是暂时关闭这个 Filter,待客户端修复后,在未来不再支持这些有问题的老版本客户端时再打开:

1
spring.mvc.formcontent.putfilter.enabled=false
Swagger 官方不支持 Spring Boot 2.0

在做完了所有的升级后发现 Swagger 无法使用,去官方 repo 寻求方案,发现有人已经问过了,官方回应暂不支持 2.0 版本😱。幸好在其他帖子内发现有人说使用成功,但没有给出清晰的解决方案,只好自己一点点摸索了,好在最终还是解决了。

方案是去除老的 security 配置以及添加部分资源的映射

1
2
3
4
5
6
7
@Override
protected void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("swagger-ui.html")
.addResourceLocations("classpath:/META-INF/resources/");
registry.addResourceHandler("/webjars/**")
.addResourceLocations("classpath:/META-INF/resources/webjars/");
}
总结

本次升级涉及十多个子系统模块,从开发->测试->灰度->上线大概 3 周多时间,除了上面引发的小事故外整个升级过程比预期要顺利很多,总结下经验有以下几点

  • 对系统代码及方案足够的熟悉
  • 不要只看升级指南,要详读官方文档
  • 有较大块的时间及足够细心

有些人因为担心风险而不愿意升级,觉得够用就行。但在我看来合理的升级是必要的,收益也是显而易见的,只要使用了正确的升级方法,大部分风险还是可控的。对于升级这件事来说,时间越久,版本差异越大意味着风险也越高,制定合适的升级周期也能有效的降低风险。

小李是一枚资深产品汪,工作五年多,过着平凡充实的生活,偶尔也反思一下,什么时候能财务自由,迎娶白富美,走向人生巅峰呢?

有一天领导找来了小李…

领导:你看某某公司这个功能不错,吸了很多新用户,我们可以考虑做一下

小李:恩,是的,不过我们这个版本需求满了,可以加在下月的版本迭代中

经过半个多月的深度学(chao)习(xi),脑海中反复的迭代构思,小李终于想出一个绝佳的方案,一定能让老板满意。

3-30 周五 10:00

小李:来来来,我们开下个月的需求评审会,确定一下这个版本的需求

(会上大家非常踊跃,天马行空的提出了各自的想法和意见。)

B:这个需求看起来简单,为了保证通用性和稳定性,改动并不小,需要仔细的考虑下模块的设计。

D:这个功能确实不错,但是不太容易加入到我们目前视觉设计中,可能要想想其他办法

F:我建议引入 H5,这样更加的容易扩展和维护,这样APP的代码也不会太臃肿。

C:引入需谨慎啊,这样会影响测试的便利性

O:其实我觉得这个功能应该换一个方式可能更好…(此处省略500字),这样用户更容易理解,运营也更容易出效果

F:我觉得 O 的提议不错,这样前端目前的模块已经可以支持了

B:都可以,反正我们后端设计上都兼容,至于效果好坏可能还得调研下,可以做个 A/B Test

D:对了,这个输入的地方情况很多,如何给用户提示要仔细设计,不然体验非常不好

小李:是的,应该是弹框提醒吧

F:这不行,到处弹框太恶心了,给个 Toest 得了

D:重要信息还得弹啊,不然不够明显

A:不要在输入的时候主动发请求,让用户自主点击后再触发验证弹框,减少服务端压力

大家还在紧张的讨论中,小李一看手表,不知不觉快12点了,公司食堂要开饭了。

小李:好了,这次只是先让大家了解下需求,具体细节做的时候再讨论,吃饭吧。

13:10

饭后准备睡一会儿整理下混乱的大脑…

14:00

哇啦哇啦哦哇啦… 叫醒闹钟准时响起。下午大家各自进入到紧张的工作中,小李准备把下个迭代内容细化一下…

C: 李哥,这个需求需要你确定一下,功能上和需求文档不太一致

小李:好的,这不对吧,这俩处的文案需要不同,效果不一样

C:工程师说这样不行,结构上不统一,不好处理

B:你不能这么处理,一样的数据各处展现不一致,这没法处理,不能做成一致吗?

小李:em…我找设计同学来问问吧

D:这不行,外面这个宽度不适合这么长,太影响美观了

F:不能因为美观去改数据结构啊,这模块都不能抽象了!

小李:对,这里没必要放那么长

O:一个数据不同地方名称不一样,显得也太不专业了吧

B:那不然把里面的数据改成短的吧,这样就一致了

D:这样也行,短的大家应该也能理解吧?

F:可以的可以的,这样好

小李:……那就都改短吧。

说完手机传来嗡嗡声「15:06 luckin 送您一张2折优惠券,仅限今日哟」,去喝杯 coffee 冷静一下…

F/C/D/B:帮我带一杯,谢谢!

——

O:李哥,这产品的文案赶紧出一个,四点就上线推广了,还有十分钟!

小李:噢,好的,就写「内有红包,点击领取」吧。

O:这样不行,有合规问题,不能出现红包字眼

小李:「点我观看有奖励!」这个如何

O:不太专业,我觉得「学习小知识,领取奖学金」不错,还有内涵。

小李:恩,那这个吧

O:推广效果还不错,才半个小时有一百多个用户点击了呢!

小李:购买用户呢?

O:不太理想,只有三个

小李:持续观察吧,我先去听个内部分享

17:50

领导:小李,来我们开个会,对下当前进度和下个版本需求

小李:好…

19:00

会后室友来短信「今天我加班,记得早点回去遛狗,今天它有点拉肚子,多在外面待会儿…」

小李:同学们,明天上午我们进行这版本效果演示,都准备一下,家里有事先撤了…