6月份的时候开发了一个类似消费攒积分的活动。用户在当月消费达到门槛,在下月可以领取相应门槛的礼物。 当时采取的方案有两个关键点。1. 用户只有打开小程序时才会领取活动。假如用户一直不打开小程序而一直使用现金支付,就无法参与活动了。 2. 监听订单的变更消息统计总金额存在数据库中。这种方式用户查询活动参与信息时很快,但是如果统计出现问题就无法处理了。 这个项目在没有上线前就交接给其他团队负责了。最近线上出现了一个问题,一个大姐反馈上个月消费满足了门槛(200元)但是给她计算的是一百九十多块。原因是新团队接受项目后,想要将用户领取任务的触发时间从打开小程序变成第一次下单。我觉得这个改动是对的,除了能够解决一直现金支付无法参与活动的问题,还能大大减少数据库中维护的任务数。只有真正下单过的用户才会创建任务。 新团队监听了订单的mq消息,接受到订单消息时判断用户是否有任务,如果用户此时没有任务,就为这个用户创建一个任务。问题出在了创建任务时复用的历史的创建任务方法(我写的那一版),历史版本在用户首次进入小程序时才会创建任务,所以用户可能在进入小程序前有过消费记录,代码里通过时间查询当月用户所有的订单信息然后累加在一起初始化用户任务的初始金额。当监听订单mq消息后创建任务时,立即调用时间范围查询,因为主从延迟所以反查不到订单的消息导致没有统计到第一笔订单。 因为使用的是根据时间范围查找所有订单的接口,这个接口查询不到订单信息在业务上是正常的。如果指定订单id查询没有查询到可以直接报错通过mq反复重试。 我检查了一下我任务创建后监听订单mq消息后计算金额的实现,发现我在监听下单的mq时如果查询不到订单信息是会直接抛出异常,但是退款的订单信息没有。所以实现上还是有问题的。 吸取到的经验 指定id查询信息,如果技术上这个id应该查到(这个id本身就是对方服务提供的),那么查询不到不应该忽略而应该抛出异常 因为主从延迟的存在,即使是调用其他服务的接口也可能有时查询不到数据,需要有重试机制 补充: 新团队还在做实时计算用户的累积金额的工作,这是最准确的,即使有主从延迟,用户重试也能统计到正确数据。但是我们的场景,查询到订单数据后需要解析所有的商品,并且过滤掉这些商品中的特殊商品,过滤条件有商品的属性,所以需要调用商品的接口查询所有商品的信息,同时还需要统计退款金额。这样在一次请求里操作耗时会很长。目前的实现只有一个库查询(直接获取金额)。 我觉得实时计算的开发可以写一个定时任务,巡检程序再加一个修复接口。每晚在店停止下单后巡检任务计算,如果发生不一致可以修复。
Qtp Acceptor Block问题排查
线上发现每一台机器都始终有一个线程block, 并且每一个block的线程名都一样。qtpxxxx-acceptor-1@xxx-ServerConnector@xxx{HTTP/1.1,(http/1.1)}{0.0.0.0:8080}。 xxxx指代各种数字编码。 https://github.com/jetty/jetty.project/issues/760 https://github.com/jetty/jetty.project/issues/3266 在jetty里,这种现象是正常的,当没有请求的时候,一个acceptor会监听,其他的acceptor会被阻塞。但是我们服务之前是没有这个block线程的,查看其他类似的服务也没有发现有block线程。一定是有什么发生了变化导致acceptor变多了。 https://github.com/jetty/jetty.project/blob/a9729c7e7f33a459d2616a8f9e9ba8a90f432e95/jetty-server/src/main/java/org/eclipse/jetty/server/AbstractConnector.java#L222-L227 结合代码和网上的资料,jetty在12.0.7版本之前的计算公式是 int cores = ProcessorUtils.availableProcessors(); if (acceptors < 0) acceptors = Math.max(1, Math.min(4, cores / 8)); if (acceptors > cores) LOG.warn("Acceptors should be <= availableProcessors: " + this); _acceptors = new Thread[acceptors]; 我们机器最近扩配,核数从8升级为16,所以acceptor数量从1变成了2,当变成2后就出现1个acceptor被阻塞。 You have 2 ServerConnectors, so you have 1 RUNNABLE thread each, 1 lock each, and N-1 BLOCKED threads each (where N is the number of acceptors you have configured).
读书笔记 纳瓦尔宝典 01
阅读原文 抄录或复述标记片段的核心观点 专长无法被教授,但可以被学习。 解释内化 用自己的话+生活案例解读 这里教授和学习处于对立面,教授应该特指不需要额外的付出和思考,只要接受到消息就可以做的事情。比如扫地是可以教授的,简单两句话就可以让一个小孩子拿起扫把扫地。但是将卧室清理的井井有条,使用不同的工具,对于不同的污渍使用不同的方式处理,这种本领是需要下投入时间去学习的,也有人天生有规划好打扫卫生的本领。那这种就是专长,比方说《断舍离》这本书的作者,就是一个收纳大师,所以能写出一个专门教别人收纳的书籍。 关联过去 回忆自己的相关经历 我有哪些专长呢? • 我的文笔很好。从小的作文就常拿高分, 也总是被老师单独拿出来点评。我的同学也很喜欢读我写的作文,有同学说我的文笔风格和一位大师很像。我的朋友作为一个新时代老师,拿着我的高中作文做教学案例。这应该是天赋和从小到大的积累,因为小时候家里条件不好,加上臀肌挛缩导致外八。所以一直自卑,敏感,对身边事物和人的观察比常人更加细致。而讲故事的能力倒是来的不同寻常,小时候小朋友们到学校总是会分享自己周末看的动画片,而我因为家境贫穷没有电视机,但是又不想被别人知道,所以就会假装自己看过相同的动画片而也激情的参与到讨论中。虽然我一部动画片都没有看过,但是故事情节却了如指掌,成了讲故事的好手。 • 我的点子很多。我小时候一直是孩子王,一直能相处各种各样好玩的游戏,让一帮孩子不无聊。到了大学社团,我也是做了话剧社社长,也出谋划策了一些想法。我的想法比较多,身边的人很多是没有办法有新的想法的。这应该也是专长,我之所以想法会比其他人多很多,也是小时候积累的,我从小因为没有电视看,就只能看书,什么书都看,各种奇闻怪谈,科幻科普,心理学成功学,导致知识面比较广。另外,小时候家里比较穷,衣服和房子都很差,所以每天晚上睡觉前都在幻想自己有一个好房子,穿好看的衣服,导致想象力比较丰富。这些遭遇不是正常人能遇到的,所以是我的专长。 规划未来 写一个具体可执行的动作 我有不少想法比较零碎, 一个零碎的想法可能无法产生收益,但足够多的零碎的想法可能就是一笔财富。我的专长是能够有大量的想法,并且文笔也足以清晰的描述。我的缺点是我没有落地这些想法,也没有把零碎的想法扩展成庞大版图。我可能可以把我的缺点改掉,但是眼下更好的是,立即记录我所有零碎的想法。
奥卡姆剃刀剃不干净不如不剃
如无必要,勿增实体。这个原则在软件开发领域奉为圭臬。 我所在的项目组是从另一个项目中孵化并独立出来的,原先有若干子业务线同属于一个项目组,代码中有一个公用的字段区分不同的业务线,也叫做租户id。 项目组独立出来后,只有一个业务线,所以租户id都固定传入1。这使得租户id的传入成为了一个样板代码。于是在大约两年前,开始了一波去租户id的革命风潮。 租户id的设计是从数据存储阶段就设计好的,所以整个项目组的服务中都有各种各样的强制校验。自然,去租户id的运动最终失败了,部分链路不依赖租户id或者都默认填充1, 部分链路仍然强制校验租户id。而数据库中一直存储这租户id。现在导致租户id不单单是一个模版代码,还是一个无法正常使用的模板代码,因为没有人会知道他们传入的租户id会在那一层被悄悄的篡改为1 尽管租户id已经成为一个无法正常使用但又必须存在的系统寄生虫, 但是整个业务还是可以正常工作的, 偶尔看到的租户id,大家以及熟悉视而不见。 这个时候,N项目登场了。 出于商业上的考量,我们需要新创建一条业务线,复用目前的能力,但是实现细节更简单,未来的发展也明显不同。(一个是线上业务,一个是线下实体业务)。所以,租户是最理想的实现方式。 最早设计的租户id已经无法正常使用了,所以我们需要再给所有的系统,存量数据新增一个崭新的租户id字段,他的目的和最早的租户id一样。然而旧的租户id我们仍然不能把他下线,所以在n项目之后,我们的系统里就会出现两个租户id,有两套截然不同的租户判断和使用方式。
Concurrenthashmap写入加锁阻塞
我们有一个服务会往数据库中写入一系列的规则,并且查询请求是否命中了这些规则。 规则的数据结构非常复杂, 拓展性很强, 劣势直接使用这种数据结构进行查询性能很差。 为此,我们设计了一个定时任务, 定期的将写入时的mysql数据转化为能更加快速读取的redis缓存数据结构。(我们的规则生效允许有一定的延时, 所以定时任务可以满足要求) 每个请求会读取该请求所需要的规则,同时也会读取全局规则。 规则都存储在redis中,所以大量请求读取全局规则会导致热点key问题。 为了解决热点key问题,我们为这些全局规则做了服务上的本地缓存。(全局规则的数量很少,一共只会占用不到1mb的内存)。 进行压力测试时,发现大量的读请求阻塞在concurrenthashmap的compute方法上, 导致80%的请求超时失败,服务无法正常提供功能。 原因是使用的本地缓存,在读取redis上值为空时写入空,这会导致每个本地缓存读取请求都触发一次缓存更新操作(从redis读取)并写入底层的concurrenthashmap中,而concurrenthashmap的写入需要加锁并且性能很差,导致请求被阻塞。业务原因是,全局规则有可能不存在,所以redis上也不存在相应的值。 最终,通过读取不到规则时设置一个对应的空规则而不是null,解决了该问题。
内存对齐
/// Align downwards. Returns the greatest x with alignment `align` /// so that x <= addr. The alignment must be a power of 2. pub fn align_down(addr: usize, align: usize) -> usize { if align.is_power_of_two() { addr & !(align - 1) } else if align == 0 { addr } else { panic!("`align` must be a power of 2"); } } /// Align upwards. Returns the smallest x with alignment `align` /// so that x >= addr....
GC与TP999
最近我开发了一个接口,这个接口需要从 Redis 上读取一系列规则,判断请求是否满足这些规则。服务使用 Java 开发,垃圾回收器选择了 G1。由于请求量很大,且用户对性能要求较高,查询延迟(TP999)必须尽可能低。 每次请求最多会读取 5 条规则,也有可能读取不到规则。每条规则的形式是一个 Map,且比较大,每个 Map 大约 1 到 2 KB。规则数量非常庞大,并且会有更新操作,因此将所有规则全部做成本地缓存是不现实的。我们估算,大部分请求会读取 2 到 3 条规则,只有极少部分请求会读取 5 条规则(我们通过布隆过滤器来快速判断并拦截那些会读取 5 条规则的请求,从而减少查询时间)。 为了尽量减少查询延迟,我们考虑过一种方案,即提前将请求可能用到的所有规则(最多 5 条)通过 mget 一次性加载到内存中。这样,无论请求最终读取多少条规则,都能以较低的延迟完成查询,虽然加载 5 条规则的时间略长一点。但这种做法的缺点是,无论请求最终需要多少条规则,都会预先加载 5 条规则,这些规则的生命周期仅限于一次请求。如果请求量很大,频繁的内存分配可能会导致 GC 增加,甚至触发 G1 的 MixedGC,从而引发 STW(Stop-the-World)。 如果不提前加载规则,每次请求最多需要读取 5 条规则时,就会调用 5 次 Redis 请求,导致查询延迟增大,TP999 的表现就不好了。 这个问题困扰了我一段时间,不过现在我已经想通了。我的结论是,“提前加载所有需要的规则”。因为 GC 问题可以通过水平扩容来解决,而 TP999 的问题,只有通过提前加载所需的规则才能根本解决。 使用 Java 这种有垃圾回收机制的语言,追求单机的极致性能其实是一个伪命题。虚拟机和垃圾回收器的存在意味着,不管怎么优化,也不可能达到顶级稳定的性能。Java 的优势在于减少了开发的复杂度,避免了过多的性能调优工作。对于单机性能,我们只需要确保性能不至于太差,而在流量压力大的情况下,可以通过水平扩容来解决。如果为了 GC 优化而不提前加载规则,那么每个请求的 TP999 延迟很难通过外部手段来解决,也无法通过水平扩容来解决。 因此,我认为,业务开发中应该进行性能优化,但优化要适度。因为业务代码可能会在半年内被重构或删除,而根据用户体验法则,响应时间超过 150ms 会让用户明显感到不适。如果真的需要追求极致性能,应该优先考虑选择高性能的语言和框架,而不是在现有的技术栈上做过度的调优。
DcVm的对象模型
我在用Rust写一个JVM,整个项目从三个月前开始准备,查阅了大量的资料,也写了不少代码做试验,直到最近才走上正规,进入了稳定的开发阶段。 其中最困扰我的是如何设计DcVm的内存模型来表达Java的对象,我在之前没有用C++、Rust这种无GC的语言系统性的写过大项目,也没有过编写虚拟机或者语言解释器的经验,这部分着实花了我很久才找到一点头绪。 一开始的时候,我打算照搬KiVM的设计,KiVM是用C++编写的JVM,使用了一种简化的oop-klass模型。然而我在使用Rust编写时,发现照搬oop-klass模型实在是太复杂了,非常难以实现。oop-klass模型使用了大量的继承关系,并且oop-klass之前有引用的关系。官方也提到这种模型的实现里面有历史原因,比如markOop不是一个oop。我希望DcVM是一个简单的、优先考虑可读性而不是性能的教学用的JVM(未来打算进行可视化),所以我不想在新实现的时候还要背负hotspot这种成熟的、性能高的的JVM的复杂实现。 然后我又去研究了rjvm的设计,rjvm使用Rust编写的Jvm,它的实现是类似与解释性语言的虚拟机实现,在底层通过复杂的指针操作,将类型信息直接写入内存,这在写简单的解释型语言的虚拟机时是个场景的做法,但是Java的字节码里是有类型信息的,底层这种大量的指针操作使得代码很不直观,作者也对这部分实现不满意。 百思不得其解时,我读到了Writing Interpreters in Rust: a Guide这边教程,这个教程并不是写JVM的,而是教如何写一个解释型语言的虚拟机实现的。代码很好,让我学到了很多rust的使用方式,我尝试使用其中TaggedPtr, FatPtr, ScopterPtr, TaggedScopterPtr, RawPtr各种Ptr来表示我的底层实现。我发现这太复杂了,我大部分实现都在思考用什么Ptr,这些ptr在教程里的好处是在虚拟机实现的时候写代码在编译期能拿到类型,但这只使用与解释型语言,因为他们的类型是固定的几种,而Java是可以自定义类型的。 最终,我决定简化oop-klass模型,将oop始作数据的存储区,将klass始作算法的存储区,DcVM的类型分为三类:原始类型、instance和Array, 原始类型是int、long这些,而Instance是自定义的类型,每个instanceOop会指向一个instanceKlass(虚拟机运行时同一个类可以很多个oop, 但只能有1个klass), Array是个递归的结构(里面可以存Array, Instance或原始类型)。整体的实现目前没有使用裸指针,先用typed_arena做了简单的内存分配(后面要自己写内存分配器和垃圾回收), 所以代码里有大量的引用的生命周期参数。目前我简单的将所有的生命周期都写成一样的,后续对生命周期理解深入了之后需要做一下优化。
虚伪与憨厚
陈丹青说他那个年代的人都很憨,现在人不够憨,一开口就知道你这个人想要骗我。 的确如此,虚伪的人越来越多。虚伪的定义太哲学,以致于说道虚伪,并没有什么特别的感受,只是有一个这个人不是很好的印象。 把虚伪具体点讲,一个人一张口就知道他是想要骗我。虚伪的人的赞美,就像他抽烟后吐出的烟圈,虚无缥缈,毫无穿透力,你能感觉的到他虚伪的赞美是无力的,只能在屋子里转悠,进入不了任何人的内心。 这个资本的世界,虚伪的人越来越多,总是想着去欺骗,去忽悠别人,把真相隐藏起来,不真诚。和别人对话目的性太强,就是想要获得利益,没有利益的事不做,只想着去收割其他人。 希望在这种世界里,我仍能够保持纯真,不愿意做虚伪的人,虚伪的人活不长久也不会过的开心,每天勾心斗角,也不会有真正的朋友。
Ld脚本的简单入门
SECTIONS { . = 0x80000; /* Kernel load address for AArch64 */ .text : { KEEP(*(.text.boot)) *(.text .text.* .gnu.linkonce.t*) } .rodata : { *(.rodata .rodata.* .gnu.linkonce.r*) } PROVIDE(_data = .); .data : { *(.data .data.* .gnu.linkonce.d*) } .bss (NOLOAD) : { . = ALIGN(16); __bss_start = .; *(.bss .bss.*) *(COMMON) __bss_end = .; } _end = .; /DISCARD/ : { *(.comment) *(.gnu*) *(.note*) *(.eh_frame*) } } __bss_size = (__bss_end - __bss_start)>>3; 每个链接脚本都需要有SECTIONS,每个SECTIONS里面可以定义多个secname,比如示例里的....