实现 go 的 goroutine 本地存储又一种方式

go 本身没有对外提供 goroutine 本地存储,而现实中,又经常需要在上下文中传递一些数据。使用 context 也是一种方式,但是要求在所有需要的地方都要传递,还是非常麻烦,而且有侵入性。

偶然发现 go 已经提供了一个用于 profile 的 pprof label,可以在 goroutine 中携带一些数据。不过这个东西既然是用于 pprof 的,随意往里塞太多东西显然也不适合,还会对 pprof 产生干扰。所以,想办法只用其中一个 label,用一些黑科技把一个 map 放了进去,将影响降到最小。同时,pprof 包中已经有一些基于 context 访问处理 label 的逻辑,所以还要做一些兼容处理,避免被其覆盖。

写了个简单的库:https://github.com/xiezhenye/gls

优化 Java 反射字段访问

最近负责的一个 Java 服务经常报 CPU 占用高的告警。于是乘着一次 CPU 高的时候,去用 JFR profile 了一下。(使用 JFR 对 Java 应用 profile 可以参考这篇文章:Java 应用在线性能分析和火焰图)。把数据拖回来用 JMC 打开一看,发现热点都在反射上:

reflect_jfr

图里排在最前面的都是反射相关的函数,而且实际都是同一个地方引入的。那里为了读取一个私有字段,使用了类似下面的代码:

public class SomeSingleton {
    private Field field;
    public SomeSingleton() {
        field = someObject.getDeclaredField("fieldName");
        field.setAccessible(true);
    }

    private void someMethod() {
        Object value = field.get(someObject);
    }
}

这里已经 SomeSingleton 是个单例对象,field 只会反射一次,而且 setAccessible(true) 后已经避免了额外的访问权限检查。profile 出来的性能问题集中在 field.get(someObject) 上。

于是去看了下这块 Java 反射的源代码,发现一个有意思的地方。

Field.get 会创建并缓存一个 FieldAccessor 对象,通过这个对象访问实际的字段。而这个 FieldAccessor 对象会由于对象的修饰符不同而创建不同的实例(UnsafeFieldAccessorFactory.java)代码大致如下:

class UnsafeFieldAccessorFactory {
    static FieldAccessor newFieldAccessor(Field field, boolean override) {
        Class type = field.getType();
        boolean isStatic = Modifier.isStatic(field.getModifiers());
        boolean isFinal = Modifier.isFinal(field.getModifiers());
        boolean isVolatile = Modifier.isVolatile(field.getModifiers());
        boolean isQualified = isFinal || isVolatile;
        boolean isReadOnly = isFinal && (isStatic || !override);
        if (isStatic) {
            ...
            if (!isQualified) {
                ...
                } else {
                    return new UnsafeStaticObjectFieldAccessorImpl(field);
                }
            } else {
                ...
                } else {
                    return new UnsafeQualifiedStaticObjectFieldAccessorImpl(field, isReadOnly);
                }
            }
        } else {
            if (!isQualified) {
                ...
                } else {
                    return new UnsafeObjectFieldAccessorImpl(field);
                }
            } else {
                ...
                } else {
                    return new UnsafeQualifiedObjectFieldAccessorImpl(field, isReadOnly);
                }
            }
        }
    }
}

这里有四种情况的组合,有/没有 Static,有/没有 Qualified。

有 Qualified 的实现,对应 volatile 或 final 的字段,会忽略线程本地 cache。没有 Static 的那组,即访问的是实例对象,会有一次额外的检查对象实例是否匹配。即前面截图中的 ensureObject。(参见:UnsafeObjectFieldAccessorImpl.java)

我们访问的是一个实例对象,所以每次访问字段都经过了这一次实际并不需要的检查。为了优化这个操作,不得不用上了 unsafe 大法,写了个加速反射字段访问的工具类。

...
import sun.misc.Unsafe;
import java.lang.reflect.Field;

public class UnsafeFieldAccessor {
    private static final Unsafe unsafe;

    static {
        try {
            Field field = Unsafe.class.getDeclaredField("theUnsafe");
            field.setAccessible(true);
            unsafe = (Unsafe) field.get(null);
        } catch (Exception e) {
            throw new IllegalStateException(e);
        }
    }

    private final long fieldOffset;

    public UnsafeFieldAccessor(Field field) {
        fieldOffset = unsafe.objectFieldOffset(field);
    }

    public Object getObject(Object object) {
        return unsafe.getObject(object, fieldOffset);
    }

    public void setObject(Object object, Object value) {
        unsafe.putObject(object, fieldOffset, value);
    }

    public int getInt(Object object) {
        return unsafe.getInt(object, fieldOffset);
    }

    public void setInt(Object object, int value) {
        unsafe.putInt(object, fieldOffset, value);
    }
}

这里是 Java8 的写法。Java9 及以上版本 Unsafe 的使用需要做一定调整。这里有 Object 和 int 类型的字段访问,其他基本类型,如 long, double ,或者需要触发清掉 cache 也可以依样画葫芦。

下面对比一下性能。在我自己的机器上,各自执行 10000000 次,通过反射和 UnsafeFieldAccessor 的时间。

测试代码:

public class UnsafeFieldAccessorTest {
    static class T {
        private Object obj;
    }

    public void benchmark() throws Exception {
        Field field = T.class.getDeclaredField("obj");
        field.setAccessible(true);
        UnsafeFieldAccessor accessor = new UnsafeFieldAccessor(field);
        T t = new T();
        Object v = new Object();
        int n = 10000000;
        long start;
        start = System.nanoTime();
        for (int i = 0; i < n; i++) {
            accessor.getObject(t);
        }
        System.out.printf("unsafe get: %d\n", System.nanoTime() - start);
        start = System.nanoTime();
        for (int i = 0; i < n; i++) {
            field.get(t);
        }
        System.out.printf("reflect get: %d\n", System.nanoTime() - start);
        start = System.nanoTime();
        for (int i = 0; i < n; i++) {
            accessor.setObject(t, v);
        }
        System.out.printf("unsafe set: %d\n", System.nanoTime() - start);
        start = System.nanoTime();
        for (int i = 0; i < n; i++) {
            field.set(t, v);
        }
        System.out.printf("reflect set: %d\n", System.nanoTime() - start);
    }
}

结果如下,单位是纳秒:

unsafe get: 6859493
reflect get: 22821121
unsafe set: 10714617
reflect set: 53694089

在能确保赋值的类型安全的前提下,使用 unsafe 绕过一些检查,可以大幅减少反射访问字段消耗的时间。

tcpdump 抓包显示 toa 源地址

LVS 是个很流行的高性能负载均衡程序。不过因为工作在 3-4 层,对被代理的后端来说,就只能看到 LVS 的源地址,看不到实际的用户来源 IP 地址了。所以 LVS 提供了一个 toa 内核模块,在后端安装后,可以从 TCP 头的扩展段里获取源 IP 地址。不过这只限于应用层面。系统层面的工具比如 netstat,tcpdump 等仍然只能看到 LVS 的源地址,想通过 tcpdump 抓包过滤或者分析实际来源地址就不行了。不过既然在 TCP 头里有这数据我们就还是可以利用。

tcpdump 输出的内容里,如果有 toa 扩展,会多出一截 Unknown Option。其中后面的 32bit 就是实际的源 IP。如

... ack 1562404315, win 29, options [Unknown Option 20030390a0a0101], length 0

按 TOA 的结构:

struct toa_data {
	__u8 opcode;
	__u8 opsize;
	__u16 port;
	__u32 ip;
};

这里的 0a0a0101 就是 10.10.1.1,3039就是源端口 12345。

因为 TCP 头里多了扩展字段,所以 TCP 头中的 data_offset 就会大于 5(words)。用 ((tcp[12] & 0xf0)>>4) > 5 这个条件过滤可以只处理带 toa 头的包。TCP 扩展字段从 20 字节开始。所以可以直接用比如 tcp[24:4]==0x0a0a0101 过滤指定源 IP 的包。

下面这段脚本就会输出发往 1234 端口的实际源 IP 的地址。

sudo tcpdump -nn -l "tcp dst port 1234 and ((tcp[12] & 0xf0)>>4) > 5" 2>/dev/null | \
    awk 'match($0, "Unknown Option ([0-9a-f]+)",a) {print strtonum("0x" substr(a[1],8,2)) "." strtonum("0x" substr(a[1],10,2)) "." strtonum("0x" substr(a[1],12,2)) "." strtonum("0x" substr(a[1],14,2)); fflush()}'

升级 centos 内核到 4.x

centos 发行版是跟随 redhat 发行版的。在内核版本上比较保守。比如 centos 6.x 分支最高是 2.6.32 内核。虽然也会 backport 一些高版本的功能,但是总会需要的功能没有的情况。

不过 centos 的仓库里其实藏着一个 4.x 的内核。

在 centos 仓库里,有一个 centos-release-xen 的包,是 由 Centos、CitrixXen、Godaddy、Rackspace 共同维护的 Xen4CentOS 项目的一部分。

Xen4CentOS
The project, while hosted at centos.org, is a collaboration between the Xen Project, the Citrix Xen open source teams, the CentOS developers, GoDaddy Cloud Operations team, Rackspace Hosting and members of the CentOS QA Team.

这个包中其实并没有 xen 的工具,只是一个内核而已。安装这个包就能将 linux 内核升级到高版本。

yum install centos-release-xen
yum update

重启,然后 uname -r 检查一下就会发现内核版本已经到了比如 4.9.58-29.el6.x86_64

如何做一个靠谱的发号器

本文曾载于 有赞技术团队博客 https://tech.youzan.com/id_generator/

为什么需要一个发号器

在使用数据库时,表的主键经常会使用数据库的自增(auto_increment)来产生。这当然很方便也很高效。但是使用自增也会带来一些麻烦。如果从一个数据库以外的地方,也就是发号器来产生全局唯一 ID,这些问题就可以得到解决,生活就可以更美好。

  • 难以适应分片场景

    在采用数据库分片时,如果使用数据库自增 ID,不同分片上会产生相同的 ID。单靠 ID 无法唯一标示一个对象,还需要额外加上分片字段才行。如果需要将 ID 用于其他对象的关联时,会麻烦很多。而采用发号器生成的是全局唯一的 ID,单靠 ID 就能实现关联。同时,这也使得采用 ID 作为分片字段成为可能。

  • 主备切换时数据冲突

    在 MySQL 集群发生主备切换时,异步复制无法确保主从完全同步。在备库开放写入后,备库上产生的自增 ID 会和尚未同步的主库上的数据冲突。这样一来,即使原来的主库恢复了,也无法重新加入集群。数据修复也变成了一件非常困难的事情。引入发号器以后,备库上插入的 ID 和原来主库上的 ID 是不会重复的。因此,未复制的新增数据和对这些新增数据的修改就不会在备库发生冲突。

  • 网络异常时无法判断插入是否成功

    当插入记录时,如果使用数据库自增 ID,在完成插入后,才能得到产生的 ID。如果在执行语句时发生网络中断,客户端无法知道事务是否成功,即使成功,也无法再获得产生的 ID。如果使用发号器,就可以在插入之前预先产生 ID。如果碰到网络中断,可以用已经获得的 ID 去尝试查询来判断之前的插入是否成功。

此外,一些业务 ID 会需要一个全局唯一的整数作为它的组成部分。其他的分布式系统可以用全局单调的唯一 ID 作为事务号。有一个现成的服务就不用各自实现了。

发号器的必要特性

既然叫发号器,首先就得保证 ID 的全局唯一。就是说保证无论什么情况下都不会发出重复的 ID。这看起来很简单,但是事实上,很多实现却上并没有做到这点。要真正做到全局唯一,发号器必须要实现 crash safe,并不受外部环境变化影响。

  • crash safe

    首先是 crash safe。即得保证在服务崩溃重新恢复后,不会产生已经发过的 ID。在服务彻底完蛋时,也要能够在其他地方恢复出一个一定能用的。有的实现定期保存或者异步保存已经发过的 ID。如果发生崩溃,如果直接用保存过的 ID 继续发,就会发出已经发过的号。有的实现采用 MySQL 或 Redis 来产生 ID。由于 MySQL 和 Redis 的复制本身难以保证强一致,在发生主备切换时,备机尚未完全同步的话,还是会发出重复的 ID 来。有的实现没有使用副本,单纯靠分片来实现负载均衡和高可用,这时如果某个实例完蛋了,想要重新恢复一个就没法了。

  • 不受外部环境变化影响

    很多发号器实现是基于时间戳的。但是有些实现直接采用了机器上的时间戳作为 ID 的一部分。如果机器时间发生回跳(不要认为这不可能),就会造成 ID 重复。使用时间戳同时也对机器时间的精度有了依赖。

要让发号器能真正有用,还得实现高可用,并能支撑足够大的吞吐量。不然发号器本身也会成为一个单点或瓶颈。

如何设计发号器

有赞同样有对发号器的需求。经过对现有实现的考察后,我们还是打算实现一个自己的发号器,我给它起了个名字:March。我们的发号器同样要解决这些问题。

持久化

要满足真正的全局唯一,持久化是必须的。而且持久化还必须是不会丢失的,强一致的。

如果发号器实现是分散在各个应用服务器上的,由于应用服务器的持久化能力是难以保证的,可靠性就会受影响。而且这样一来,每个应用服务器也要有一个终身及死后也全局唯一的 ID 作为产生的 ID 的一部分,来满足全局唯一,这就大大提高了部署和运维的门槛。所以,我们认为发号器最好还是集中式的。

在采用集中式的前提下,持久化的副本也是不可少的。要自己实现这样的一个持久化系统是很难的。所以,在持久化方案上,我们选择了现成的 etcd。etcd 能满足不会丢失的,多副本,强一致的全部需求。持久化就可以全部放到 etcd 中,发号器本身就可以是无状态的,这样一来,高可用的实现也会容易一些。

是否全局单调

是否全局单调其实是个权衡。在确定要高可用的前提下,全局单调和负载均衡是不可兼得的(可以想想为什么)。我们最终还是选择实现全局单调。全局单调的 ID 有额外的好处。作为主键时,可以直接代替时间字段排序。由于 MySQL 二级索引是指向主键的,使用主键排序通常可以避免排序操作,直接利用索引就能完成。另外,如果要实现一些分布式一致性系统,一个全局单调的 ID 生成器也是一个必备的组件。

高可用

由于采用了全局单调,高可用方案就只能是主备的。一个集群内,同时只能有一个实例对外提供服务。这时候就要考虑怎么实现选主和故障切换。既然我们用了 etcd,实现高可用的时候也正好可以用上它的 TTL、Watch 这些特性。然后也要能让客户端知道哪个实例才是主实例,可以自动切换访问路径。

ID 的形式

发号器产生的 ID 一般都是 64 位整数,这样对数据库比较友好,容量也能满足业务需求,不会哪天爆了。通常产生的 ID 可以分成两大类。一类是单纯的 Sequence,即一个不断递增的整数。另一类是基于 Timestamp 的,由于机器时间的精度限制,通常都会额外再加一段 Sequence。为了分布式,还经常会加上各种不同的标示实例的位。不同的实现无非就是这些东西的组合以及各段的长短的变化。有赞之前已经有了几个实现。新的发号器要落地,也得兼容现有的。所以不同的 ID 的形式还是都得支持。但是具体实现细节上,可以比原有的更进一步。

认证和权限控制

使用发号器的业务方会有很多。为了信息安全,和避免相互干扰,认证和权限控制功能也有了需求。March 可以设置多个用户,为每个用户分配访问不同的发号器的权限,以及其他的创建,管理类权限。用户信息同样不能丢,所以也持久化在 etcd 中。

通信协议

作为一个服务,就会有和客户端交互。有交互,就要有一个协议。我们希望尽量能采用一个现成的协议。这样对实现不同语言的客户端会方便很多。同时这个协议要足够轻量高效,也能具备扩展性。我们最后选择了 Redis 协议。Redis 协议很简单,协议本身的负担小。由于是个广泛使用的东西,各种语言都有它的库。这样在实现客户端 SDK 的时候,就有了个很好的起点。现成的一些命令,如 INCR,INCRBY,GET 等本身也很适合用于发号器。在需要一些特殊的功能时,也可以自己添加新命令。高可用方面,Redis Cluster 的协议也可以用上。这样客户端的自动切换就不用自己实现了。对于服务端,好几个语言也都有现成的库。

发号器的实现

有赞的发号器 March 是用 Go 语言实现的。语言选择上其实没太大讲究。不过对于这类项目,Go 在开发效率,部署简便,和倾向低延迟的 gc 优化还是有一些优势。

ID生成

前面说过,发号器产生的 ID 可以分成两大类。一类是 Sequence,一类是基于 Timestamp 的。这两类有各自的实现。

  • Sequence

    March 在启动时会从 etcd 中载入之前持久化的已经发过的 id 作为起点。然后执行一次持久化,将起始 id + batch 保存下来。 [ id, id + batch ) 的区间就是缓存。客户端请求时,下发的 id 都是从这个缓存中取的。同时启动一个 goroutine 来做持久化。在这个缓存的容量低于水位线(默认是 50%)时,会异步通知这个持久化 goroutine 进行持久化,将 id + batch * 2 保存下来。此时,缓存的上界就扩容到了 id + batch * 2,以此类推。由于持久化是异步的,所以一般情况下,并不会阻塞请求,造成请求延迟增大。但是有突发的并发时,在持久化没进行完,缓存就已经耗尽的情况下,为了保证正确性,才会发生阻塞,等待持久化完成。所以,对于高并发的应用,配置一个大的缓存区间可以获取更高的性能。比如将 batch 设为 10000,平均发出 10000 个号才需要持久化一次。备机平时是不提供服务的,在发生主备切换时,备机才会从持久化中重新载入配置。所以备机提升为主机以后,也可以保证不会发重,只是从客户端看来,会跳空一段 id。不过这也算不上什么问题。

  • Timestamp

    Timestamp 类型的 ID 分成 3 段:node,timestamp,sequence。通过配置各个段的长度和偏移,以及时间戳的精度,就可以兼容各种已有的基于时间戳的发号器实现。多个请求到来时,如果 timestamp 相同,会增长 sequence。timestamp 改变时,就清零 sequence。有一点特别的地方是,我们允许 sequence 段溢出。 溢出的部分会加到 timestamp 段上去。这样即使在时间戳精度范围内 sequence 耗尽了,也不用阻塞请求。Timestamp 类型持久化的是时间,保存的是当前的 timestamp + 提前量。这里的 timestamp 是包含 sequence 溢出的部分。Timestamp 类型的持久化是定时进行的。由于已持久化的时间戳总是大于当前时间的,因此等待持久化而造成的阻塞基本上是不会发生的。March 启动时,如果获取的当前时间大于保存的时间,就使用当前时间作为起点,否则就使用已保存的时间作为起点。每次请求获取时间时也是类似。如果发现获取的时间小于已经发过的 timestamp,就继续使用当前 timestamp。这样就确保了即使机器时间跳变时,发出的 id 也是单调增长的,绝对不会重复。同时由于允许溢出,也不会因为时间回跳而阻塞。当然这种方式带来的一个影响是,如果从获取的 id 里解析出时间,可能并不是准确的时间。由于切换或溢出,看到的时间可能会提前。不过本来也不应该依赖这些细节不是么。

高可用

March 的高可用是利用 etcd 的 ttl 和 watch 实现的。启动时,先尝试创建一个新的带 ttl 的 Node。如果成功,就成为了主节点;如果由于已存在而失败,就成为了备节点。

  • 主节点

    定时用前一次请求返回的 index 刷新 Node 的 ttl,保持自己的主节点角色。发现刷新失败时,说明主节点角色已经被抢走,从抢主节点过程重新开始。与此同时,还会等待 demote 请求。收到 demote 请求时,会等待新的主节点信息,然后将自己置为备节点。

  • 备节点

    先查询主节点的信息。在备节点收到发号请求时,会按 Redis Cluster 协议重定向到主节点。之后就开始 Watch Node 的变化。检测到变化后,也开始抢主节点过程。

这样,可以做到在主节点发生故障时,最多等待一个 ttl 就能检测到,并完成切换。而在主动切换时,结合客户端,可以做到完全无损,只有毫秒级的阻塞。

此外,每个节点都会存保存各自的带 ttl 的节点信息,同时定时刷新,用于返回给客户端集群信息。每个发号器在每次持久化时,也会携带上上一次持久化获得的 index。一旦不匹配出错,也会将自身重置为备节点。这可以避免网络堵塞或进程僵死造成原主失效而自身却不知道。在发生非预期错误时,HA goroutine 会等待 2 * ttl,以免不断出错造成死循环。此外,备节点也需要能够完成用户认证。但因为认证是不能重定向的,所以还需要检测 etcd 上的用户信息变化,重新同步用户数据。

小结

发号器看起来简单,但是要实现一个靠谱的,易用的,要考虑到的地方还是很多的。其实很多东西都是这样。我们还做了更多。为了更容易接入落地,我们在数据库中间件中也做了集成。配置后,执行 insert 时,会自动代入配置的自增字段和 id 值,让业务方完全无痛。

排查 etcd 响应慢的问题

有一台 etcd,突然出现了响应缓慢的情况。排查中发现,系统 CPU,带宽,IO,内存,rlimit 都还有很大余量。但是 ss 发现,端口的backlog 堆积了大量连接。

$ ss -lnt
State      Recv-Q Send-Q            Local Address:Port              Peer Address:Port
...
LISTEN     745    65535               10.20.30.40:2379                         *:*
...

但是看并没有 SYN_RECV 状态的连接存在。所以不像由于并发连接太多造成的来不及 accept,而是 etcd 本身处理 accept
的过程慢。统计 ESTABLISHED 连接数,发现数量总是大致在 10000 个左右。这个连接数看起来很整,怀疑是不是有什么特殊的限制。

于是去看 etcd 的代码,发现 etcdmain/etcd.go 中,启动 etcd 的代码里有

l = transport.LimitListener(l, int(fdLimit-reservedInternalFDNum))

这个 LimitListener 限制了最多同时服务的客户端连接数,超过时就会等待已有连接退出。传入的 fdLimit 是获取的 open files rlimit 限制。reservedInternalFDNum 是保留的 fd 数,是个等于 150 的常量。看起来意图是在 fd 不够用的时候,保证自身的 IO 和集群通信不受影响。

后来了解到,ulimit 原本是 10240,后来调整到了 102400,当时为了不重启进程,动态修改了运行中的 etcd 的 rlimit。没想到 etcd 在启动的时候还使用这个值做了自己的限制。虽然 rlimit 改了,但是由于进程没重启,etcd 还是使用的原来的值做的限制。最后重启 etcd 就恢复了正常。

一个屏蔽 IP 的脚本

在测试可用性的时候,经常需要模拟断网。这时候用 iptables 是比较方便的。但是如果想更方便一点,不用去敲那么长的命令会更好些。于是就写了个 ban_ip 脚本。

#!/bin/bash

if [[ "$EUID" != 0 ]]; then
  echo "should run as root"
  exit 1
fi

action="DROP"
comment="ban_ip"
cmd="$1"
case "$cmd" in
list)
  iptables -L -n | awk -v "cmt=$comment" '$0~cmt{print $4}'
  ;;
add)
  ip="$2"
  if [[ -z "$ip" ]]; then
    echo "missing arg ip"
    exit 1
  fi
  iptables -A INPUT -s "$ip" -j "$action" -m comment --comment "$comment"
  ;;
del)
  ip="$2"
  if [[ -z "$ip" ]]; then
    echo "missing arg ip"
    exit 1
  fi
  iptables -D INPUT -s "$ip" -j "$action" -m comment --comment "$comment"
  ;;
*)
  echo "bad command: should be list, add <ip>, del <ip>"
  exit 1
  ;;
esac

用的时候就方便了不少,也便于查看当前已经 ban 掉的 ip。

$ sudo ./ban_ip.sh list
10.10.10.1

$ sudo ./ban_ip.sh add 10.10.10.2

$ sudo ./ban_ip.sh list
10.10.10.1
10.10.10.2

$ sudo ./ban_ip.sh del 10.10.10.1

$ sudo ./ban_ip.sh list
10.10.10.2

linux capabilities

在 linux 系统中,很多操作是需要 root 用户权限才能操作的。比如 chown,改变进程 uid,使用 raw socket 等等。要不就得用 sudo 提升权限,如果想让每个用户都能用特权来执行一个程序,配置管理和权限控制就很麻烦了。还有一个办法是使用粘滞位,通过 chmod +s,可以让一个 owner 为 root 的可执行文件再运行时具有 root 权限。在一些发型版中,ping 命令就是这么干的。给 ping 命令加上粘滞位以后,普通用户也可以使用这个命令通过 raw socket 发送 icmp 包了。不过这样一来,这个程序也就无所不能了。万一程序有啥漏洞,就容易造成严重后果。有没有办法只给这个程序开所需要的权限呢?其实是可以的。linux 有一套 capabilities 机制就是用来实现这个。

事实上,linux 本身对权限的检查就是基于 capabilities 的,root 用户有全部的 capabilities,所以啥都能干。如果想给一个可执行文件加上某个 capability,可以用 setcap 命令,如

setcap cap_net_raw=+ep ping

就可以给 ping 命令加上使用 raw socket 的权限。cap_net_raw 是 capability 的名字,后面是 mode,可以有

  • e:表示是否激活该 capability
  • p:是否允许进程设置该 capability
  • i:子进程是否能继承 capabilities

+ 表示启用,- 表示禁用。
这样执行以后,普通用户执行这个 ping 命令,也可以正常玩耍了。而且这个 ping 命令只获得了 raw socket 的权限。
通过 getcap ping 可以查看这个程序所拥有的 capabilities。

实现上,是通过 setxattr 系统调用,为文件加上 security.capability 的扩展属性。

man 7 capabilities 中可以看到所有可用的 capabilities。

man 3 cap_from_text 中可以看到关于 capability mode 的表达式说明。

为 bash 提示符加上 git 状态

在有一次手误合错分支以后,就决定为 bash 提示符加上显示当前分支,以及提交状态,这样就可以更清楚地知道当前在哪个分支,以及是不是 commit 了,是不是 push 了。代码如下:

function __git_prompt() {
  local s=$(git status -b --porcelain 2>/dev/null)
  if [[ -z "$s" ]]; then
    PS1="\h:\W \u$ "
  else
    local l=$(echo "$s"|wc -l)
    local br=$(echo "$s"|head)
    if [[ "${br%[ahead*}" != "$br" ]]; then
      local np=1
    fi
    br="${br#\#\# }"
    br="${br%...*}"
    if [[ "$l" -gt 1 ]]; then
      local g="(git:\[$(tput setaf 9)\]$br\[$(tput sgr0)\])" # dirty: red
    elif [[ -z "$np" ]]; then
      local g="(git:\[$(tput setaf 10)\]$br\[$(tput sgr0)\])" # clean: green
    else
      local g="(git:\[$(tput setaf 11)\]$br\[$(tput sgr0)\])" # not pushed: yellow
    fi
    PS1="\h:\W \u $g$ "
  fi
}


PROMPT_COMMAND='__git_prompt'

加入到 .bash_profile 即可。这里是按 MacOS 默认的 PS1 基础上改的,实际使用的时候可以根据需要调整。

page cache 造成 java 长时间 gc

最近在升级一个 java 应用时,在刚启动不久的时候发生了长时间的 gc,时间到了数秒,业务访问纷纷超时。

看了下 gc log:

2016-12-06T22:50:44.256+0800: 13.632: [GC pause (G1 Evacuation Pause) (young)
Desired survivor size 13631488 bytes, new threshold 1 (max 15)
- age   1:   27212320 bytes,   27212320 total
 13.632: [G1Ergonomics (CSet Construction) start choosing CSet, _pending_cards: 4890, predicted base time: 28.66 ms, remaining time: 71.34 ms, target pause time: 100.00 ms]
 13.632: [G1Ergonomics (CSet Construction) add young regions to CSet, eden: 89 regions, survivors: 13 regions, predicted young region time: 6880.88 ms]
 13.632: [G1Ergonomics (CSet Construction) finish choosing CSet, eden: 89 regions, survivors: 13 regions, old: 0 regions, predicted pause time: 6909.54 ms, target pause time: 100.00 ms]
 22.969: [G1Ergonomics (Heap Sizing) attempt heap expansion, reason: recent GC overhead higher than threshold after GC, recent GC overhead: 65.85 %, threshold: 10.00 %, uncommitted: 0 bytes, calculated expansion amount: 0 bytes (20.00 %)]
, 9.3374979 secs]
...
      [Object Copy (ms):  8821.5  6481.7  8743.0  6477.4  8173.8  6477.4  6477.5  6477.3  6792.5  6477.2  6477.0  6476.7  9331.9  9250.5  6476.0  6476.0  6471.3  7438.9
       Min: 6471.3, Avg: 7211.0, Max: 9331.9, Diff: 2860.6, Sum: 129797.8]
...

这还是一次 young gc,说明 java 自身的堆是够用的,并没有升级为 mix。但是为什么用了这么长时间呢?注意到大部分时间都是花在了 Object Copy 上。内存 copy 哪怕是几个 G,都不可能花那么久。这里必有蹊跷。

搜索到的结果里,有提到内存不足,使用 swap 造成的情况。但是我们的内存是足够的,而且已经禁用了 swap。但是这倒是给了个提示。看了下当时的内存情况,有大量内存在 cache 中。于是想到了一种可能:有大量文件写入,使 page cache 填满了内存。而我们的 java 应用使用了 4G 的堆大小,刚启动的时候,大部分虚拟内存都还没有分配实际的物理内存。而在 java 在 gc 的过程中需要映射物理内存时,需要等待系统将这部分内存释放出来。如果这时候脏页的数量大,就需要等待脏页刷写到磁盘。一涉及 IO,时间就完全没谱了。

然后在测试环境试着重现一下,先用 dd 写几个大文件,直到剩余的空闲内存很少,大部分都在 cache 中,然后再次启动应用,果然重现了。临时处理办法就是在启动应用的时候,如果发现空闲内存不够,就先用 vmtouch 刷一下占用 cache 的文件缓存(通常是日志)。为了更好的避免类似情况发生,就要控制 page cache 里脏页数量,这样就算是需要释放 cache 也不会花多少时间。可以通过 vm.dirty_background_ratiovm.dirty_ratio 内核参数来控制。