RateLimiter 浅析

本文基于 Guava-18.0.jar 。

0. 概述

RateLimiter 是令牌桶思想的一个实现,可实现流量整形、资源访问速率控制。

与信号量对比:

  • 一旦从 RateLimiter 获得许可,不需要释放。信号量分配后必须释放。
  • RateLimiter 控制的是速率,以配置的速率分发许可,速率不变时单位时间内分发的许可量是恒定的。信号量控制的是并发访问的数量,单位时间内分配的次数跟使用许可的时长有关,每次申请使用的时间越短,则单位时间内能分配的次数就越多。

RateLimiter 请求许可的数量不会对请求本身产生抑制影响,但会对下一个请求产生抑制。例如一个请求很大数量许可的请求到达空闲 RateLimiter 时,它将马上获得许可,但下一个请求会被抑制,从而为前面昂贵的请求付出代价。

RateLimiter 申请许可时,可能会阻塞、也可能不会,是否阻塞取决于上一次分配许可的时间和当前请求的许可数量。

RateLimiter 可以配置一个 warnup 热身周期,在这个周期内每秒发出的许可数量稳步增长直至达到稳定的速率。简单说是慢启动吧。

1. 使用示例:

// 每秒2个的速率提交任务
final RateLimiter rateLimiter = RateLimiter.create(2.0);
void submitTasks(List tasks, Executor executor) {
    for (Runnable task : tasks) {
        rateLimiter.acquire(); // may wait
        executor.execute(task);
    }
}

继续阅读

一个与 Ehcache 相关的死锁案例

最近朋友分享一个与 Ehcache 相关的死锁案例,记录下。

0. 一个思考题

缓存击穿是指高并发的请求访问同一个 key,当这个 key 失效后,所有的请求都会传递到数据库,数据库因而崩溃。

为了防止数据库崩溃,我们需要在应用层面拦截这些请求,比如控制一个应用加载一个 key 时只有一个请求到数据库。

如果用加锁的方法,每个 key 一个锁,那么 100 万个 key 就会有 100 万个锁实例在 JVM 里。如果 key 过期了,锁实例可能还得过期,特麻烦。

如果所有的 key 共享同一个锁,那么这个锁就会成为瓶颈。

该如何控制是好?

1. 背景说明

一个面向海量用户的 api 服务,需要提供用户信息查询的功能。

缓存有两层,第一层是 JVM 内基于 Ehcache 的,第二层是 memcached ,查找缓存时首先从本机 Ehcache 查询,找不到再去 memcached 查找,还找不到则从 Loader 接口加载数据并存入相应的缓存。

用户信息由 会员信息、个人信息、家庭信息 等组成,这些信息位于不同的模块,存储在不同的表或库里。

在 Ehcache 里,用户信息有一层缓存,会员信息、个人信息、家庭信息 也有对应的缓存。

以 ID 为 “007” 的用户举例,用户信息缓存对应的 key 为 “User:007″,会员信息对应的缓存 key 为 “member:007″,个人信息对应的缓存 key 为 “personal:007″,家庭信息对应的缓存 key 为 “family:007″。
其中用户信息对应的缓存的过期时间是比较短的。

当用户信息在缓存里都查找不到时,它的 Loader 实现会调用 MemerbService 加载会员信息、调用 PersonalService 加载个人信息,FamilyService 加载家庭信息,然后合并成用户信息保存在缓存里。MemerbService/PersonalService/FamilyService加载信息时也是先从缓存查找,两级缓存都查找不到时也调用对应的 Loader 从表里加载数据。

使用的 Ehcache 的版本是 ehcache-core-2.6.9 。

继续阅读

LMAX 架构–笔记

原文 The LMAX Architecture

原文介绍了 LMAX 支持高性能、低延迟的架构,还介绍了 Disruptor 这个框架的设计缘由。

LMAX 用 3Ghz 的 CPU 单线程处理达到 600w TPS,意味着要在 500 个时钟周期内处理完一个事务。(dual-socket quad-core,32GB RAM)

LMAX 整体架构:

Business Logic Processor,BLP

只是简单的 Java 代码,不依赖于任何框架。单线程执行,全内存操作, 顺序地获取输入的消息。

要操作的数据全在内存里。好处有两点:快 和 简化了编程(没有对象/关系映射)。

用 Event Sourcing 事件溯源机制来保证 BLP 的状态是可以重建的,输入事件由 input disruptor 来进行持久化。事件溯源机制可以采用快照的方式来缩短重建需要的时间。

LMAX 采用多个 BLP 同时处理同样的事件(两个在同一个数据中心,第三个在灾备中心),但只有一个 BLP 的输出是有效的。当存活的 processor 失败时,系统切换到另一个。

事件溯源的另一个好处是诊断方便,可以把事件拷贝到开发环境进行重放。

继续阅读

HikariCP 连接池–高性能数据结构

HikariCP,日语的含义是“光”,号称目前最快的数据库连接池。

它的高性能来自两个方面:

  1. 利用 Javassist 在字节码层面优化代理对象的创建,提升代理对象的调用性能。
  2. 在数据结构上采用定制的 FastListConcurrentBag 来提升性能。

本文主要关注这两种数据结构的实现。

1. FastList

我们用 JDBC 编程的时候,首先是获取 Connection、创建 Statement、执行查询得到 ResultSet,执行完成后依次关闭:ResultSetStatementConnection,特别是一个逻辑里创建了多个 PreparedStatement 时,一般用完就关闭。

为了防止用户忘了关闭 StatementResultSet,连接池需要跟踪创建的 StatementResultSet,在连接返回到连接池时关闭这两类资源。

public abstract class ProxyConnection implements Connection
{
    // 跟踪本连接创建的语句
    private final FastList<Statement> openStatements;

   private synchronized <T extends Statement> T trackStatement(final T statement) {
      openStatements.add(statement);
      return statement;
   }

   // Statement 关闭时回调此方法
   final synchronized void untrackStatement(final Statement statement) {
      openStatements.remove(statement);
   }

   // 创建语句时加入跟踪列表
   public Statement createStatement() throws SQLException {
      return ProxyFactory.getProxyStatement(this, trackStatement(delegate.createStatement()));
   }

    // Connection.close 方法回调此方法关闭打开的语句
    private synchronized void closeStatements() {
      final int size = openStatements.size();
      if (size > 0) {
         for (int i = 0; i < size && delegate != ClosedConnection.CLOSED_CONNECTION; i++) {
            // 利用 try-with-resources 机制进行关闭
            try (Statement ignored = openStatements.get(i)) {
               // automatic resource cleanup
            } catch (SQLException e) {
               LOGGER.warn("{} - Connection {} marked as broken because of an exception closing open statements during Connection.close()",
                           poolEntry.getPoolName(), delegate);
               leakTask.cancel();
               poolEntry.evict("(exception closing Statements during Connection.close())");
               // 包装的代理对象不再持有底层的连接
               delegate = ClosedConnection.CLOSED_CONNECTION;
            }
         }

         openStatements.clear();
      }
}

关闭的顺序跟创建的顺序是相反的,要关闭并移除的对象一般在列表的末尾。而 ArrayList 的移除对象是从列表头部开始的,在这种场景下不高效。FastList 的实现是逆序查找要删除对象;对于根据下标进行的操作,移除了对下标合法性的检查,由连接池来保证。

继续阅读

《系统之美》-摘记

一、系统

几乎每一个系统都有一个重要的目标,那就是确保自我永存。

调节回路:使系统逐渐恢复原来的目标,是保持平衡或达到特定目标的结构,也是稳定性和抵制变革的根源。

增强回路:是自我强化的,随着时间的变化,增强回路会导致指数级增长或加速崩溃。

时间延迟:调节回路上的时间延迟很可能导致系统的振荡。

二、系统之美:系统的三大特征

1. 适应力

适应力是一个系统的基础,使系统能正常发挥和维持功能。

有适应力的系统是动态变化的、非静止或恒定。

适应力使系统具有自我修复或复位能力,在遭遇干扰时能恢复原来状态。

2. 自组织

自组织特性会产生异质性和不可预测性:系统可能演变出全新的结构,发展出全新的行为模式。它需要自由和实验,也需要一定的混乱。

是指系统具有塑造自身、生成新结构、学习、多样化和复杂化的能力。

即使是非常复杂的自组织形式,也有可能产生于相对简单的组织规则。

3. 层次性

层次性是系统的伟大发明,能让系统更加稳定和有适应力,而且因为它们减少了信息量,使得系统各部分更容易记录和跟进。

层次性原本的目的是帮助各个子系统更好地做好其工作,不幸的是,系统的层次越高或越低,越容易忘记这一目的。很多系统因为层次的功能失调,而不能实现预定的目标。

层次结构既要有足够的中央控制,以有效第协调整体系统目标的实现,又要让各个子系统有足够的自主权,以维持子系统的活力、功能和自组织。

继续阅读

检查清单(20200510)

1. 逻辑代码

1.1 基础开发

1.1.1 参数校验

  • 入参基本校验:金额不能小于0、不能大于余额、不能大于应还总额等。
  • 字符串非空。

  • 请求参数用 POJO 接收,POJO 的属性要添加基本校验注解,Controller 方法要用 @Valid 注解开启校验。

  • 正则表达式:用正则表达式进行参数校验时,不能写太复杂的,因为 Java 的正则引擎采用的是贪婪匹配模式。

1.1.2 跨系统调用

  • 超时限定:为了防止外部服务出现响应缓慢而拖累调用方,调用方必须设置连接超时、读超时等。

  • 幂等性,防止重复调用:调用方传递唯一的请求编号、服务方根据请求编号进行幂等性控制;

  • 重试:对于重要业务,调用方应该有重试机制,重试一定次数后仍然失败的,有告警出来,人工介入处理。

  • 调用链跟踪:

1.2 SQL

  • 检查SQL语句的执行计划,关注是否有全表扫描、索引全扫描。

  • 尽量把 1+N 次查询改写成 1或2 次查询。

  • 不能在字段上应用函数。

  • 字段应该单独在比较运算符的左侧。

  • 检查是否有隐式类型转换

    • 隐式类型转换会对扫描到的每条记录的字段进行转换,然后再进行比较,执行性能较慢,还会导致无法使用被转换列上的索引。
    • 比如 varchar(10) 类型的字段用 abc=40 则会进行隐式类型转换, abc='40' 则不会。
    • MyBatis在处理数据库 Date 类型时只能传 TIMESTAMP。 类型,与Oracle的Date类型进行比较会导致隐式转换。
    • MySQL 里如果比较的两个字符串列的字符集不同也会发生隐式类型转换。
  • join 的连接条件不要放到 where 子句,连接条件与过滤条件的执行顺序不同。

继续阅读

CMS GC

1. 概述

CMS,Most concurrently,是一款 采用 并发-标记-清除(Concurrent-Mark-Sweep)算法、针对老年代的 GC,其目的是最小化暂停时间。

CMS GC 在 JDK 9 被标记为 deprecated。

2. 执行过程

CMS 的标记过程采用指针可达性分析方法,对于存活的对象,在对象头上进行标记。

2.1 初始标记

从 GC Roots、新生代(Eden、From、To)出发标记直接关联的对象。

需要 STW 。

2.2 并发标记

该阶段进行 GC Root Tracing,从初始标记阶段 标记过的对象出发,标记所有可达的对象。

与应用线程并发执行,可以采用多个标记线程进行并发标记。

2.3 预清理

此阶段从新生代晋升的对象、新分配到老年代的对象以及在并发阶段修改了的对象 出发进行标记。
在并发标记阶段,如果老年代中有对象内部引用发生变化,会把所在的 card 标记为 dirty,通过扫描这些 card,重新标记那些在并发阶段引用被更新的对象。

通过 -XX:-CMSPrecleaningEnabled 参数选择关闭该阶段,默认开启。

目的是减少重新标记阶段的暂停时间。

CMS 提供了参数 CMSScavengeBeforeRemark 在 重新标记之前强制进行一次 minor GC,这样做的好处是缩短了重新标记阶段的暂停时间,坏处是 Minor GC 后紧跟着一个 重新标记的 暂停,这样使停顿时间也比较久。

继续阅读

《垃圾回收算法手册》–第2章 标记-清扫回收

1、垃圾回收基础

垃圾回收器的安全性是指:在任何时候都不能回收存活对象。

浮动垃圾(floating garbage):如果某个对象在回收过程启动之后才变成垃圾,那么该对象只能在下一个回收周期内得到回收。

回收器在标记对象时可以使用对象中的域,也可以使用额外的数据结构如位图 bitmap;对于并发回收器或其他需要将堆分为数个独立区域的回收器,其需要额外的记忆集(remembered set)来保存赋值器所修改的指针值或者跨区域指针的位置。

赋值器:应用线程。

在类型安全的语言中,一旦某个对象在堆中不可达,赋值器在没有外部系统协助的情况下是无法访问到不可达对象的。

赋值器在工作过程中会执行三种与回收器相关的操作:创建(New)、读(Read)、写(Write)。某些特殊的内存管理器会为基本操作增加一些额外功能,即屏障(barrier),屏障操作会同步或异步地与回收器产生交互。

在新分配的对象可被赋值器操作之前,回收器都需要先对其元数据进行初始化。

任何自动内存管理系统都面临三个任务:
1、为新对象分配空间。
2、确定存活对象。
3、回收死亡对象所占用的空间。

回收空间的方法影响这分配新空间的方法。

基于指针可达性分析得到的存活对象集合中可能包含一些永远不会再被赋值器访问的对象,但是死亡对象集合中的对象却必定是死亡的。

继续阅读

CAS/volatile 原理

1. 指令的 lock 前缀

intel手册对 lock 前缀的说明如下:

  1. 确保被修饰指令执行的原子性。
  2. 禁止该指令与前面和后面的读写指令重排序。
  3. 指令执行完后把写缓冲区的所有数据刷新到内存中。(这样这个指令之前的其他修改对所有处理器可见。)

在 Pentium 及之前的处理器中,带有 lock 前缀的指令在执行期间会声言 LOCK# 信号以锁住总线,使得其它处理器暂时无法通过总线访问内存,很显然,这个开销很大。在新的处理器中,Intel 使用缓存锁定来保证指令执行的原子性,缓存锁定将大大降低 lock 前缀指令的执行开销。如果访问的内存区域已经缓存在处理器内部,则不会声言 LOCK# 信号。相反地,它会锁定这块内存区域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域数据

继续阅读

MySQL 事务隔离及实现

1. 事务的 ACID 特性

  • Actomic,原子性:一个事务中的所有操作,要么全部完成,要么全部失败。

  • Consistency:一致性:事务执行前和执行后数据库都处于一致的状态。

  • Isolation:隔离性:同时进行中的事务不会看到其他事务的中间状态。

  • Durability,持久性:事务提交成功后,对数据库所做的变更都是持久的。

2. SQL 标准的事务隔离级别

  • 读未提交, read uncommitted:一个事务还没提交,它做的变更就能被别的事务看到。问题:会产生”脏读”,读到的数据可能是不一致的。

  • 读已提交, read committed:一个事务提交后,它做的变更才会被别的事务看到。问题:不可重复读,在一个事务里,前后两次执行同一个 SQL 看到的数据是不一致的,产生的原因有:1. 数据被修改或删除(称为”不可重复读”),2. 有新插入的数据(称为”幻读”)。

  • 可重复读, repeatable read:一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。解决了”不可重复读”的问题,但没有解决”幻读”问题。

  • 串行化, serializable:对于同一行记录,“写”会加写锁,“读”会加读锁。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。解决了”幻读”的问题,但效率低。

继续阅读