MySQL Memory Allocation

MySQL Memory Allocation

Table of Contents

Allocating RAM for MySQL – The Short Answer
What is the key_buffer?
What is the buffer_pool?
Another algorithm
Mutex bottleneck
HyperThreading and Multiple cores (CPUs)
32-bit OS and MySQL
64-bit OS with 32-bit MySQL
64-bit OS and MySQL
max_connections, thread_stack
table_cache (table_open_cache)
Query Cache
thread_cache_size
Binary Logs
swappiness
NUMA
huge pages
ENGINE=MEMORY
How to Set VARIABLEs
Web server
Tools
Postlog
Brought to you by Rick James

Allocating RAM for MySQL – The Short Answer

If using just MyISAM, set key_buffer_size to 20% of _available_ RAM. (Plus innodb_buffer_pool_size=0)

If using just InnoDB, set innodb_buffer_pool_size to 70% of _available_ RAM. (Plus key_buffer_size = 10M, small, but not zero.)

Rule of thumb for tuning mysql:
⚈ Start with released copy of my.cnf / my.ini.
⚈ Change key_buffer_size and innodb_buffer_pool_size according to engine usage and RAM.
⚈ Slow queries can usually be ‘fixed’ via indexes, schema changes, or SELECT changes, not by tuning.
⚈ Don’t get carried away with the Query cache until you understand what it can and cannot do.
⚈ Don’t change anything else unless you run into trouble (eg, max connections).
⚈ Be sure the changes are under the [mysqld] section, not some other section.

Now for the gory details. (NDB Cluster is not discussed here.)in

What is the key_buffer?

MyISAM does two different things for caching.
⚈ Index blocks (1KB each, BTree structured, from .MYI file) live in the “key buffer”.
⚈ Data block caching (from .MYD file) is left to the OS, so be sure to leave a bunch of free space for this.
Caveat: Some flavors of OS always claim to be using over 90%, even when there is really lots of free space.

SHOW GLOBAL STATUS LIKE ‘Key%’; then calculate Key_read_requests / Key_reads If it is high (say, over 10), then the key_buffer is big enough.

What is the buffer_pool?

InnoDB does all its caching in a the “buffer pool”, whose size is controlled by innodb_buffer_pool_size. It contains 16KB data and index blocks from the open tables, plus some maintenance overhead.

MySQL 5.5 (and 5.1 with the “Plugin”) lets you declare the block size to be 8KB or 4KB. MySQL 5.5 allows multiple buffer pools; this can help because there is one mutex per pool, thereby relieving some of the Mutex bottleneck.

More on InnoDB Tuning

Another algorithm

This will set the main cache settings to the minimum; it could be important to systems with lots of other processes and/or RAM is 2GB or smaller.

Do SHOW TABLE STATUS for all the tables in all the databases.

Add up Index_length for all the MyISAM tables. Set key_buffer_size no larger than that size.

Add up Data_length + Index_length for all the InnoDB tables. Set innodb_buffer_pool_size to no more than 110% of that total.

If that leads to swapping, cut both settings back. Suggest cutting them down proportionately.

Run this to see the values for you system. (If you have a lot of tables, it can take minute(s).)
SELECT ENGINE,
ROUND(SUM(data_length) /1024/1024, 1) AS “Data MB”,
ROUND(SUM(index_length)/1024/1024, 1) AS “Index MB”,
ROUND(SUM(data_length + index_length)/1024/1024, 1) AS “Total MB”,
COUNT(*) “Num Tables”
FROM INFORMATION_SCHEMA.TABLES
WHERE table_schema not in (“information_schema”, “performance_schema”)
GROUP BY ENGINE;

Mutex bottleneck

MySQL was designed in the days of single-CPU machines, and designed to be easily ported to many different architectures. Unfortunately, that lead to some sloppiness in how to interlock actions. There are small number (too small) of “mutexes” to gain access to several critical processes. Of note:
⚈ MyISAM’s key_buffer
⚈ The Query Cache
⚈ InnoDB’s buffer_pool
With multi-core boxes, the mutex problem is causing performance problems. In general, past 4-8 cores, MySQL gets slower, not faster. MySQL 5.5 and Percona’s XtraDB are making that somewhat better in InnoDB; the practical limit for cores is more like 32, and performance tends plateaus after that rather than declining. 5.6 claims to scale up to about 48 cores.

HyperThreading and Multiple cores (CPUs)

Short answers:
⚈ Turn off HyperThreading
⚈ Turn off any cores beyond 8
⚈ HyperThreading is mostly a thing of the past, so this section may not apply.

HyperThreading is great for marketing, lousy for performance. It involves having two processing units sharing a single hardware cache. If both units are doing the same thing, the cache will be reasonably useful. If the units are doing different things, they will be clobbering each other’s cache entries.

Furthermore MySQL is not great on using multiple cores. So, if you turn off HT, the remaining cores run a little faster.

32-bit OS and MySQL

First, the OS (and the hardware?) may conspire to not let you use all 4GB, if that is what you have. If you have more than 4GB of RAM, the excess beyond 4GB is _totally_ inaccessable and unusable on a 32-bit OS.

Secondly, the OS probably has a limit on how much RAM it will allow any process to use.

Example: FreeBSD’s maxdsiz, which defaults to 512MB.

Example:
$ ulimit -a

max memory size (kbytes, -m) 524288

So, once you have determined how much RAM is available to mysqld, then apply the 20%/70%, but round down some.

If you get an error like [ERROR] /usr/libexec/mysqld: Out of memory (Needed xxx bytes), it probably means that MySQL exceeded what the OS is willing to give it. Decrease the cache settings.

64-bit OS with 32-bit MySQL

The OS is not limited by 4GB, but MySQL is.

If you have at least 4GB of RAM, then maybe these would be good:
⚈ key_buffer_size = 20% of _all_ of RAM, but not more than 3G
⚈ buffer_pool = 3G

You should probably upgrade MySQL to 64-bit.

64-bit OS and MySQL

MyISAM only: key_buffer_size (before 5.0.52 / 5.1.23) had a hard limit of 4G. See also 5.1 restrictions
Otherwise, use about 20% of RAM. Set (in my.cnf / my.ini) innodb_buffer_pool_size = 0.

InnoDB only: innodb_buffer_pool_size = 70% of RAM. If you have lots of RAM and are using 5.5 (or later), then consider having multiple pools. Recommend 1-16 innodb_buffer_pool_instances, such that each one is no smaller than 1GB. (Sorry, no metric on how much this will help; probably not a lot.)

Meanwhile, set key_buffer_size = 20M (tiny, but non-zero)

If you have a mixture of engines, lower both numbers.

max_connections, thread_stack

Each “thread” takes some amount of RAM. This used to be about 200KB; 100 threads would be 20MB, not a signifcant size. If you have max_connections = 1000, then you are talking about 200MB, maybe more. Having that many connections probably implies other issues that should be addressed.

In 5.6 (or MariaDB 5.5), optional thread pooling interacts with max_connections. This is a more advanced topic.

Thread stack overrun rarely happens. If it does, do something like thread_stack=256K

More on max_connections, wait_timeout, connection pooling, etc

table_cache (table_open_cache)

(The name changed in some version.)

The OS has some limit on the number of open files it will let a process have. Each table needs 1 to 3 open files. Each PARTITION is effectively a table. Most operations on a partitioned table open _all_ partitions.

In *nix, ulimit tells you what the file limit is. The maximum value is in the tens of thousands, but sometimes it is set to only 1024. This limits you to about 300 tables. More discussion on ulimit

(This paragraph is in disputed.) On the other side, the table cache is (was) inefficiently implemented — lookups were done with a linear scan. Hence, setting table_cache in the thousands could actually slow down mysql. (Benchmarks have shown this.)

You can see how well your system is performing via SHOW GLOBAL STATUS; and computing the opens/second via Opened_files / Uptime If this is more than, say, 5, table_cache should be increased. If it is less than, say, 1, you might get improvement by decreasing table_cache.

Query Cache

Short answer: query_cache_type = OFF and query_cache_size = 0

The QC is effectively a hash mapping SELECT statements to resultsets.

Long answer… There are many aspects of the “Query cache”; many are negative.
⚈ Novice Alert! The QC is totally unrelated to the key_buffer and buffer_pool.
⚈ When it is useful, the QC is blazingly fast. It would not be hard to create a benchmark that runs 1000x faster.
⚈ There is a single mutex controlling the QC.
⚈ The QC, unless it is OFF & 0, is consulted for _every_ SELECT.
⚈ Yes, the mutex is hit even if query_cache_type = DEMAND (2).
⚈ Yes, the mutex is hit even for SQL_NO_CACHE.
⚈ Any change to a query (even adding a space) leads (potentially) to a different entry in the QC.

“Pruning” is costly and frequent:
⚈ When _any_ write happens on a table, _all_ entries in the QC for _that_ table are removed.
⚈ It happens even on a readonly Slave.
⚈ Purges are performed with a linear algorithm, so a large QC (even 200MB) can be noticeably slow.

To see how well your QC is performing, SHOW GLOBAL STATUS LIKE ‘Qc%’; then compute the read hit rate: Qcache_hits / Qcache_inserts If it is over, say, 5, the QC might be worth keeping.

If you decide the QC is right for you, then I recommend
⚈ query_cache_size = no more than 50M
⚈ query_cache_type = DEMAND
⚈ SQL_CACHE or SQL_NO_CACHE in all SELECTs, based on which queries are likely to benefit from caching.

QC in depth

thread_cache_size

This is a minor tunable. Zero will slow down thread (connection) creation. A small (say, 10), non-zero number is good. The setting has essentially no impact on RAM usage.

It is the number of extra processes to hang onto. It does not restrict the number of threads; max_connections does.

Binary Logs

If you have turned on binarly loging (via log_bin) for replication and/or point-in-time recovery, The system will create binary logs forever. That is, they can slowly fill up disk. Suggest setting expire_logs_days = 14 to keep only 14 days’ worth of logs.

swappiness

RHEL, in its infinite wisdom, decided to let you control how aggressively the OS will preemptively swap RAM. This is good in general, but lousy for MySQL.

MySQL would love for RAM allocations to be reasonably stable — the caches are (mostly) pre-allocated; the threads, etc, are (mostly) of limited scope. ANY swapping is likely to severly hurt performance of MySQL.

With a high value for swappiness, you lose some RAM because the OS is trying to keep a lot of space free for future allocations (that MySQL is not likely to need).

With swappiness = 0, the OS will probably crash rather than swap. I would rather have MySQL limping than die.

Somewhere in between (say, 5?) might be a good value for a MySQL-only server.

NUMA

OK, it’s time to complicate the architecture of how a CPU talks to RAM. NUMA (Non-Uniform Memory Access) enters the picture. Each CPU (or maybe socket with several cores) has a part of the RAM hanging off each. This leads to memory access being faster for local RAM, but slower (tens of cycles slower) for RAM hanging off other CPUs.

Then the OS enters the picture. In at least one case (RHEL?), two things seem to be done:
⚈ OS allocations are pinned to the ‘first’ CPU’s RAM.]
⚈ Other allocations go by default to the first CPU until it is full.

Now for the problem.
⚈ The OS and MySQL have allocated all the ‘first’ RAM.
⚈ MySQL has allocated some of the second RAM.
⚈ The OS needs to allocate something.
Ouch — it is out of room in the one CPU where it is willing to allocate its stuff, so it swaps out some of MySQL. Bad.

Possible solution: Configure the BIOS to “interleave” the RAM allocations. This should prevent the premature swapping, at the cost of off-CPU RAM accesses half the time. Well, you have the costly accesses anyway, since you really want to use all of RAM.

Overall performance loss/gain: A few percent.

huge pages

This is another hardware performance gimmick.

For a CPU to access RAM, especially mapping a 64-bit address to somewhere in, say, 128GB or ‘real’ RAM, the TLB is used. (TLB = Translation Lookup Buffer.) Think of the TLB as a hardware associative memory lookup table; given a 64-bit virtual address, what is the real address.

Because it is an associative memory of finite size, sometimes there will be “misses” that require reaching into real RAM to resolve the lookup. This is costly, so should be avoided.

Normally, RAM is ‘paged’ in 4KB pieces; the TLB actually maps the top (64-12) bits into a specific page. Then the bottom 12 bits of the virtual address are carried over intact.

For example, 128GB of RAM broken 4KB pages means 32M page-table entries. This is a lot, and probably far exceeds the capacity of the TLB. So, enter the “Huge page” trick.

With the help of both the hardware and the OS, it is possible to have some of RAM in huge pages, of say 4MB (instead of 4KB). This leads to far fewer TLB entries, but it means the unit of paging is 4MB for such parts of RAM. Hence, huge pages tend to be non-pagable.

Now RAM is broken into pagable and non pagable parts; what parts can reasonably be non pagable? In MySQL, the innodb_buffer_pool is a perfect candidate. So, by correctly configuring these, InnoDB can run a little faster:
Huge pages enabled
⚈ Tell the OS to allocate the right amount (namely to match the buffer_pool)
⚈ Tell MySQL to use huge pages

innodb memory usage vs swap
That thread has more details on what to look for and what to set.

Overall performance gain: A few percent. Yawn.

ENGINE=MEMORY

This is a little-used alternative to MyISAM and InnoDB. The data is not persistent, so it has limited uses. The size of a MEMORY table is limited to max_heap_table_size, which defaults to 16MB. I mention it in case you have changed the value to something huge; this would stealing from other possible uses of RAM.

How to Set VARIABLEs

In the text file my.cnf (my.ini on Windows), add more modify a line to say something like
innodb_buffer_pool_size = 5G
That is, VARIABLE name, “=”, and a value. Some abbreviations are allowed, such as M for million (1048576), G for billion.

For the server to see it, the settings must be in the “[mysqld]” section of the file.

The settings in my.cnf or my.ini will not take effect until you restart the server.

Most settings can be changed on the live system by connecting as user root (or other user with SUPER privilege) and doing something like
SET @@global.key_buffer_size = 77000000;
Note: No M or G suffix is allowed here.

To see the setting a global VARIABLE do something like
mysql> SHOW GLOBAL VARIABLES LIKE “key_buffer_size”;
+—————–+———-+
| Variable_name | Value |
+—————–+———-+
| key_buffer_size | 76996608 |
+—————–+———-+
Note that this particular setting was rounded down to some multiple that MySQL liked.

You may want to do both (SET, and modify my.cnf) in order to make the change immediately and have it so that the next restart (for whatever reason) will again get the value.

Web server

A web server like Apache runs multiple threads. If each threads opens a connection to MySQL, you could run out of connections. Make sure MaxClients (or equivalent) is set to some civilized number (under 50).

Tools

MySQLTuner
⚈ TUNING-PRIMER

There are several tools that advise on memory. One misleading entry they come up with
Maximum possible memory usage: 31.3G (266% of installed RAM)
Don’t let it scare you — the formulas used are excessively conservative. They assume all of max_connections are in use and active, and doing something memory-intensive.

Total fragmented tables: 23 This implies that OPTIMIZE TABLE _might_ help. I suggest it for tables with either a high percentage of “free space” (see SHOW TABLE STATUS) or where you know you do a lot of DELETEs and/or UPDATEs. Still, don’t bother to OPTIMIZE too often. Once a month might suffice.

Postlog

Created 2010; Refreshed Oct, 2012, Jan, 2014

More in-depth: Tocker’s tuning for 5.6
Irfan’s InnoDB performance optimization basics (redux)
10 MySQL settings to tune after installation

Contact me by posting a question at MySQL Forums :: Performance
– Rick James

MySQL Documents by Rick James

Tips, Debugging, HowTos, Optimizations, etc…

Rick’s RoTs (Rules of Thumb — lots of tips)
Memory Allocation (caching, etc)
Character Set and Collation problem solver
Converting from MyISAM to InnoDB — includes differences between them
Big DELETEs – how to optimize
Compound INDEXes plus other insights into the mysteries of INDEXing
Partition Maintenance (DROP+REORG) for time series
Entity-Attribute-Value — a common, poorly performing, design patter; plus an alternative
Find the nearest 10 pizza parlors (efficient searching on Latitude + Longitude)
Alter of a Huge table
Latest 10 news articles — how to optimize the schema and code for such
Pagination, not with OFFSET, LIMIT
Data Warehouse techniques (esp., Summary Tables)
Techniques on efficiently finding a random row (On beyond ORDER BY RAND())
GUID/UUID Performance (type 1 only)
IP Range Table Performance
MySQL Limits
Galera Limitations (with Percona XtraDB Cluster / MariaDB)
Rollup Unique User Counts
Best of MySQL Forum

原文地址:
mysql.rjweb.org/doc.php/memory

Posted in MySQL | Tagged | Leave a comment

最近的生活 2014-07-25

最近总觉得时间过的很快,一眨眼一个周就过去了,有很多事情来不急去做。每天起床、吃饭、上班、下班、睡觉,要看书只能够在上下班的地铁上了,不过就靠着这挤出来的时间,倒也看完了几本书了,我是标准的不求甚解者,看完的东西隔个几天就记忆不清了,真想拥有过目不忘的本事啊。
最近换了手机,老的G2手机太慢了,好多的软件都运行不起来,现在用手机订阅了一些博客,在手机多看上也买了几本书,现在在吃饭的间隙、等车的时候…都翻翻看看,人呀,总要找些事情做。
说起学习,刚看完了《深入理解jvm虚拟机》《java虚拟机原理》,《mysql技术内幕.innodb存储引擎》。看得时候觉得嗯嗯嗯,然后过几天就忘记了,回过头来看又觉得是啊,是要这么实现,然后过几天又记不清了。唉,纸上得来终是浅啊,得亲自搞搞才行,前些天自己编译了jdk,打算参考着代码看看,还有innodb的代码,不过c,c++不熟悉啊,希望在看的同时再学习下她。毕竟还是必要的。要学的东西太多了…年纪大了…
朋友也少联系了…

–EOF-

Posted in 猿の生活 | Leave a comment

工程师的生活

工程师的生活
http://www.raychase.net/1543

    我忽然很好奇,想知道其他软件工程师的生活是什么样的?人永远都没有活在别人心中的形象那么绚烂,生活中总有无数烂事烦事需要处理,但是每个人都有自己享受生活的方式。逛了逛了各式技术博客和论坛,我发现大家似乎都太严肃了,太谦逊了,太学术了。做软件本来是一件很有意思的事情,但是这些帖子和文章无非就包括这么几种:
    1、技术文章,不解释,这部分当然是大头,虽然技术文章普遍不受欢迎;
    2、牢骚,喵了个咪的薪水低啊,呜了个汪的加班苦啊;
    3、心灵鸡汤,要励志、要发奋、要改变世界;
长者语气教育后辈,“给刚入职的程序员们的警示”;
    4、无聊的纷争,Linux就是比Windows牛逼,Java就是一门屎尿屁的语言……
    做软件的人只是如此吗?就只有上面这几条单调的事情可以聊?工程师就不能记录更丰富的生活吗?在大多数人都在谈论生活品质的时候,工程师也应该跟上脚步。我相信Geek的生活有人羡慕也只能算少数,码农的生活虽司空见惯但他们才是最大的群体,才是软件行业未来的希望。既然没有任何人提及软件工程师的生活品质,那我愿意做第一个吃螃蟹的人:
成为工程师,而不是码农。如果你连这样的愿望都没有,我们的不同点太多,就算我白啰嗦了。
    寻找不同的享乐方式。为什么把享受放在那么靠前的位置?不是说要先奋斗后享乐吗?这样想的话,说不定你已经被洗过脑了。你的成功不会和享乐冲突的,每个人都可以选择自己的生活方式,谁都有自己的衡量标准,但是在我看来,只有在苦中坚持而不会作乐的生活才是百分百失败的。
     为你和你自己的梦想而工作。不要单纯为公司而工作,也不要只是为父母而工作。知道得少不可怕,可怕的是知道的都是被洗脑了的。容许其他人说那些大道理,容许那些心灵鸡汤天天试图灌你喝,自己千万要清醒,要对自己负责。那些为了公司而拼了命的人,并不是你的榜样。前两天看到一条评论根深蒂固加班文化的奴才机制,过度工作导致又有某某人猝死,却有大部分回复是在说“请注意锻炼一下”,这让我感到无比悲哀和寒心,这里的问题是“锻炼”不够的问题么?
    尊重、容忍和改变。我在《致那些自嘲码农的苦逼程序员》里面已经说过了,我们都理解那些迫不得已的事情,隐忍是在等待时机,蛰伏是有明确目的的,是为了冲破现状,追寻更接近理想和价值观的生活。最怕的事情是,在这样自己都不愿认可的生活中,磨平了棱角。
    积极争取想要的一切。我不想泡心灵鸡汤,因为我只是想说那些小事。就像你想要“一台大屏幕的显示器”这样的事情一样,如果它当然可以大大地提高你的工作品质,没有什么太明显不过的迹象阻挠你,为什么不争取一下?会失败还是成功?至少争取过了,不会有一点遗憾。我有一位欧洲的同事,它给公司的后勤部门提了不少意见建议,于是我们有了咖啡机、饮料有了更多的选品,灯管坏了能得到及时修理。还有一件小事,我的同事在飞机上被冷气吹得不舒服,提出来,没费多少口舌,得到了五千个里程的补偿。如果不屑去做、无所谓、有顾虑、懒得动弹,那就什么都不会有的。
    过酷一点的生活,还有自由的生活。你会有你自己的理解,比如西乔所说的“我在过着很奢侈的生活”,这绝不仅仅是只物质上(事实上她认为程序员还算是“收入能和付出成正比的群体”)。我可以以我自己为例,生活在北京但是我和我老婆远没有足够的钱,去买北京令我们方便和舒适的房子,那么我们就先不买,她每周都去练瑜伽,我每周都会打球,周末可以看电影、享受美食、学自己感兴趣的东西。我们还收养(主要是她在照顾)了两只无家可归的小狗(刚来的时候大概眼睛刚睁开,只能吃奶粉,现在已经会疯跑和到处乱啃了),等它们再大一点的时候可以把它们送到好一点的人家里去当宠物(如果你也在北京且感兴趣的话请联系我,邮件地址在右上角“关于四火”里有),我觉得这很酷。

Posted in 猿の生活 | Leave a comment

Netty系列之Netty可靠性分析

1. 背景

1.1. 宕机的代价

1.1.1. 电信行业

毕马威国际(KPMG International)在对46个国家的74家运营商进行调查后发现,全球通信行业每年的收益流失约为400亿美元,占总收入的1%-3%。导致收益流失的因素有多种,主要原因就是计费BUG。

1.1.2. 互联网行业

美国太平洋时间8月16日下午3点50分到3点55分(北京时间8月17日6点50分到6点55分),谷歌遭遇了宕机。根据事后统计,短短的5分钟,谷歌损失了54.5万美元。也就是服务每中断一分钟,损失就达10.8万美元。

2013年,从美国东部时间8月19日下午2点45分开始,有用户率先发现了亚马逊网站出现宕机,大约在20多分钟后又恢复正常。此次宕机让亚马逊每分钟损失近6.7万美元,在宕机期间,消费者无法通过Amazon.com、亚马逊移动端以及Amazon.ca等网站进行购物。

1.2. 软件可靠性

软件可靠性是指在给定时间内,特定环境下软件无错运行的概率。软件可靠性包含了以下三个要素:

1) 规定的时间:软件可靠性只是体现在其运行阶段,所以将运行时间作为规定的时间的度量。运行时间包括软件系统运行后工作与挂起(开启但空闲)的累计时间。由于软件运行的环境与程序路径选取的随机性,软件的失效为随机事件,所以运行时间属于随机变量;

2) 规定的环境条件:环境条件指软件的运行环境。它涉及软件系统运行时所需的各种支持要素,如支持硬件、操作系统、其它支持软件、输入数据格式和范围以及操作规程等。不同的环境条件下软件的可靠性是不同的。具体地说,规定的环境条件主要是描述软件系统运行时计算机的配置情况以及对输入数据的要求,并假定其它一切因素都是理想的。有了明确规定的环境条件,还可以有效判断软件失效的责任在用户方还是提供方;

3) 规定的功能:软件可靠性还与规定的任务和功能有关。由于要完成的任务不同,软件的运行剖面会有所区别,则调用的子模块就不同(即程序路径选择不同),其可靠性也就可能不同。所以要准确度量软件系统的可靠性必须首先明确它的任务和功能。

1.3. Netty的可靠性

首先,我们要从Netty的主要用途来分析它的可靠性,Netty目前的主流用法有三种:

1) 构建RPC调用的基础通信组件,提供跨节点的远程服务调用能力;

2) NIO通信框架,用于跨节点的数据交换;

3) 其它应用协议栈的基础通信组件,例如HTTP协议以及其它基于Netty开发的应用层协议栈。

以阿里的分布式服务框架Dubbo为例,Netty是Dubbo RPC框架的核心。它的服务调用示例图如下:

图1-1 Dubbo的节点角色说明图

其中,服务提供者和服务调用者之间可以通过Dubbo协议进行RPC调用,消息的收发默认通过Netty完成。

通过对Netty主流应用场景的分析,我们发现Netty面临的可靠性问题大致分为三类:

1) 传统的网络I/O故障,例如网络闪断、防火墙Hang住连接、网络超时等;

2) NIO特有的故障,例如NIO类库特有的BUG、读写半包处理异常、Reactor线程跑飞等等;

3) 编解码相关的异常。

在大多数的业务应用场景中,一旦因为某些故障导致Netty不能正常工作,业务往往会陷入瘫痪。所以,从业务诉求来看,对Netty框架的可靠性要求是非常的高。作为当前业界最流行的一款NIO框架,Netty在不同行业和领域都得到了广泛的应用,它的高可靠性已经得到了成百上千的生产系统检验。

Netty是如何支持系统高可靠性的?下面,我们就从几个不同维度出发一探究竟。

2. Netty高可靠性之道

2.1. 网络通信类故障

2.1.1. 客户端连接超时

在传统的同步阻塞编程模式下,客户端Socket发起网络连接,往往需要指定连接超时时间,这样做的目的主要有两个:

1) 在同步阻塞I/O模型中,连接操作是同步阻塞的,如果不设置超时时间,客户端I/O线程可能会被长时间阻塞,这会导致系统可用I/O线程数的减少;

2) 业务层需要:大多数系统都会对业务流程执行时间有限制,例如WEB交互类的响应时间要小于3S。客户端设置连接超时时间是为了实现业务层的超时。

JDK原生的Socket连接接口定义如下:

图2-1 JDK Socket连接超时接口

对于NIO的SocketChannel,在非阻塞模式下,它会直接返回连接结果,如果没有连接成功,也没有发生IO异常,则需要将SocketChannel注册到Selector上监听连接结果。所以,异步连接的超时无法在API层面直接设置,而是需要通过定时器来主动监测。

下面我们首先看下JDK NIO类库的SocketChannel连接接口定义:

图2-2 JDK NIO 类库SocketChannel连接接口

从上面的接口定义可以看出,NIO类库并没有现成的连接超时接口供用户直接使用,如果要在NIO编程中支持连接超时,往往需要NIO框架或者用户自己封装实现。

下面我们看下Netty是如何支持连接超时的,首先,在创建NIO客户端的时候,可以配置连接超时参数:

图2-3 Netty客户端创建支持设置连接超时参数

设置完连接超时之后,Netty在发起连接的时候,会根据超时时间创建ScheduledFuture挂载在Reactor线程上,用于定时监测是否发生连接超时,相关代码如下:

图2-4 根据连接超时创建超时监测定时任务

创建连接超时定时任务之后,会由NioEventLoop负责执行。如果已经连接超时,但是服务端仍然没有返回TCP握手应答,则关闭连接,代码如上图所示。

如果在超时期限内处理完成连接操作,则取消连接超时定时任务,相关代码如下:

图2-5 取消连接超时定时任务

Netty的客户端连接超时参数与其它常用的TCP参数一起配置,使用起来非常方便,上层用户不用关心底层的超时实现机制。这既满足了用户的个性化需求,又实现了故障的分层隔离。

2.1.2. 通信对端强制关闭连接

在客户端和服务端正常通信过程中,如果发生网络闪断、对方进程突然宕机或者其它非正常关闭链路事件时,TCP链路就会发生异常。由于TCP是全双工的,通信双方都需要关闭和释放Socket句柄才不会发生句柄的泄漏。

在实际的NIO编程过程中,我们经常会发现由于句柄没有被及时关闭导致的功能和可靠性问题。究其原因总结如下:

1) IO的读写等操作并非仅仅集中在Reactor线程内部,用户上层的一些定制行为可能会导致IO操作的外逸,例如业务自定义心跳机制。这些定制行为加大了统一异常处理的难度,IO操作越发散,故障发生的概率就越大;

2) 一些异常分支没有考虑到,由于外部环境诱因导致程序进入这些分支,就会引起故障。

下面我们通过故障模拟,看Netty是如何处理对端链路强制关闭异常的。首先启动Netty服务端和客户端,TCP链路建立成功之后,双方维持该链路,查看链路状态,结果如下:

图2-6 Netty服务端和客户端TCP链路状态正常

强制关闭客户端,模拟客户端宕机,服务端控制台打印如下异常:

图2-7 模拟TCP链路故障

从堆栈信息可以判断,服务端已经监控到客户端强制关闭了连接,下面我们看下服务端是否已经释放了连接句柄,再次执行netstat命令,执行结果如下:

图2-8 查看故障链路状态

从执行结果可以看出,服务端已经关闭了和客户端的TCP连接,句柄资源正常释放。由此可以得出结论,Netty底层已经自动对该故障进行了处理。

下面我们一起看下Netty是如何感知到链路关闭异常并进行正确处理的,查看AbstractByteBuf的writeBytes方法,它负责将指定Channel的缓冲区数据写入到ByteBuf中,详细代码如下:

图2-9 AbstractByteBuf的writeBytes方法

在调用SocketChannel的read方法时发生了IOException,代码如下:

图2-10 读取缓冲区数据发生IO异常

为了保证IO异常被统一处理,该异常向上抛,由AbstractNioByteChannel进行统一异常处理,代码如下:

图2-11 链路异常退出异常处理

为了能够对异常策略进行统一,也为了方便维护,防止处理不当导致的句柄泄漏等问题,句柄的关闭,统一调用AbstractChannel的close方法,代码如下:

图2-12 统一的Socket句柄关闭接口

2.1.3. 正常的连接关闭

对于短连接协议,例如HTTP协议,通信双方数据交互完成之后,通常按照双方的约定由服务端关闭连接,客户端获得TCP连接关闭请求之后,关闭自身的Socket连接,双方正式断开连接。

在实际的NIO编程过程中,经常存在一种误区:认为只要是对方关闭连接,就会发生IO异常,捕获IO异常之后再关闭连接即可。实际上,连接的合法关闭不会发生IO异常,它是一种正常场景,如果遗漏了该场景的判断和处理就会导致连接句柄泄漏。

下面我们一起模拟故障,看Netty是如何处理的。测试场景设计如下:改造下Netty客户端,双发链路建立成功之后,等待120S,客户端正常关闭链路。看服务端是否能够感知并释放句柄资源。

首先启动Netty客户端和服务端,双方TCP链路连接正常:

图2-13 TCP连接状态正常

120S之后,客户端关闭连接,进程退出,为了能够看到整个处理过程,我们在服务端的Reactor线程处设置断点,先不做处理,此时链路状态如下:

图2-14 TCP连接句柄等待释放

从上图可以看出,此时服务端并没有关闭Socket连接,链路处于CLOSE_WAIT状态,放开代码让服务端执行完,结果如下:

图2-15 TCP连接句柄正常释放

下面我们一起看下服务端是如何判断出客户端关闭连接的,当连接被对方合法关闭后,被关闭的SocketChannel会处于就绪状态,SocketChannel的read操作返回值为-1,说明连接已经被关闭,代码如下:

图2-16 需要对读取的字节数进行判断

如果SocketChannel被设置为非阻塞,则它的read操作可能返回三个值:

1) 大于0,表示读取到了字节数;

2) 等于0,没有读取到消息,可能TCP处于Keep-Alive状态,接收到的是TCP握手消息;

3) -1,连接已经被对方合法关闭。

通过调试,我们发现,NIO类库的返回值确实为-1:

图2-17 链路正常关闭,返回值为-1

得知连接关闭之后,Netty将关闭操作位设置为true,关闭句柄,代码如下:

图2-18 连接正常关闭,释放资源

2.1.4. 故障定制

在大多数场景下,当底层网络发生故障的时候,应该由底层的NIO框架负责释放资源,处理异常等。上层的业务应用不需要关心底层的处理细节。但是,在一些特殊的场景下,用户可能需要感知这些异常,并针对这些异常进行定制处理,例如:

1) 客户端的断连重连机制;

2) 消息的缓存重发;

3) 接口日志中详细记录故障细节;

4) 运维相关功能,例如告警、触发邮件/短信等

Netty的处理策略是发生IO异常,底层的资源由它负责释放,同时将异常堆栈信息以事件的形式通知给上层用户,由用户对异常进行定制。这种处理机制既保证了异常处理的安全性,也向上层提供了灵活的定制能力。

具体接口定义以及默认实现如下:

图2-19 故障定制接口

用户可以覆盖该接口,进行个性化的异常定制。例如发起重连等。

2.2. 链路的有效性检测

当网络发生单通、连接被防火墙Hang住、长时间GC或者通信线程发生非预期异常时,会导致链路不可用且不易被及时发现。特别是异常发生在凌晨业务低谷期间,当早晨业务高峰期到来时,由于链路不可用会导致瞬间的大批量业务失败或者超时,这将对系统的可靠性产生重大的威胁。

从技术层面看,要解决链路的可靠性问题,必须周期性的对链路进行有效性检测。目前最流行和通用的做法就是心跳检测。

心跳检测机制分为三个层面:

1) TCP层面的心跳检测,即TCP的Keep-Alive机制,它的作用域是整个TCP协议栈;

2) 协议层的心跳检测,主要存在于长连接协议中。例如SMPP协议;

3) 应用层的心跳检测,它主要由各业务产品通过约定方式定时给对方发送心跳消息实现。

心跳检测的目的就是确认当前链路可用,对方活着并且能够正常接收和发送消息。

做为高可靠的NIO框架,Netty也提供了心跳检测机制,下面我们一起熟悉下心跳的检测原理。

图2-20 心跳检测机制

不同的协议,心跳检测机制也存在差异,归纳起来主要分为两类:

1) Ping-Pong型心跳:由通信一方定时发送Ping消息,对方接收到Ping消息之后,立即返回Pong应答消息给对方,属于请求-响应型心跳;

2) Ping-Ping型心跳:不区分心跳请求和应答,由通信双方按照约定定时向对方发送心跳Ping消息,它属于双向心跳。

心跳检测策略如下:

1) 连续N次心跳检测都没有收到对方的Pong应答消息或者Ping请求消息,则认为链路已经发生逻辑失效,这被称作心跳超时;

2) 读取和发送心跳消息的时候如何直接发生了IO异常,说明链路已经失效,这被称为心跳失败。

无论发生心跳超时还是心跳失败,都需要关闭链路,由客户端发起重连操作,保证链路能够恢复正常。

Netty的心跳检测实际上是利用了链路空闲检测机制实现的,相关代码如下:

图2-21 心跳检测的代码包路径

Netty提供的空闲检测机制分为三种:

1) 读空闲,链路持续时间t没有读取到任何消息;

2) 写空闲,链路持续时间t没有发送任何消息;

3) 读写空闲,链路持续时间t没有接收或者发送任何消息。

Netty的默认读写空闲机制是发生超时异常,关闭连接,但是,我们可以定制它的超时实现机制,以便支持不同的用户场景。

WriteTimeoutHandler的超时接口如下:

图2-22 写超时

ReadTimeoutHandler的超时接口如下:

图2-23 读超时

读写空闲的接口如下:

图2-24 读写空闲

利用Netty提供的链路空闲检测机制,可以非常灵活的实现协议层的心跳检测。在《Netty权威指南》中的私有协议栈设计和开发章节,我利用Netty提供的自定义Task接口实现了另一种心跳检测机制,感兴趣的朋友可以参阅该书。

2.3. Reactor线程的保护

Reactor线程是IO操作的核心,NIO框架的发动机,一旦出现故障,将会导致挂载在其上面的多路用复用器和多个链路无法正常工作。因此它的可靠性要求非常高。

笔者就曾经遇到过因为异常处理不当导致Reactor线程跑飞,大量业务请求处理失败的故障。下面我们一起看下Netty是如何有效提升Reactor线程的可靠性的。

2.3.1. 异常处理要当心

尽管Reactor线程主要处理IO操作,发生的异常通常是IO异常,但是,实际上在一些特殊场景下会发生非IO异常,如果仅仅捕获IO异常可能就会导致Reactor线程跑飞。为了防止发生这种意外,在循环体内一定要捕获Throwable,而不是IO异常或者Exception。

Netty的相关代码如下:

图2-25 Reactor线程异常保护

捕获Throwable之后,即便发生了意外未知对异常,线程也不会跑飞,它休眠1S,防止死循环导致的异常绕接,然后继续恢复执行。这样处理的核心理念就是:

1) 某个消息的异常不应该导致整条链路不可用;

2) 某条链路不可用不应该导致其它链路不可用;

3) 某个进程不可用不应该导致其它集群节点不可用。

2.3.2. 死循环保护

通常情况下,死循环是可检测、可预防但是无法完全避免的。Reactor线程通常处理的都是IO相关的操作,因此我们重点关注IO层面的死循环。

JDK NIO类库最著名的就是 epoll bug了,它会导致Selector空轮询,IO线程CPU 100%,严重影响系统的安全性和可靠性。

SUN在JKD1.6 update18版本声称解决了该BUG,但是根据业界的测试和大家的反馈,直到JDK1.7的早期版本,该BUG依然存在,并没有完全被修复。发生该BUG的主机资源占用图如下:

图2-26 epoll bug CPU空轮询

SUN在解决该BUG的问题上不给力,只能从NIO框架层面进行问题规避,下面我们看下Netty是如何解决该问题的。

Netty的解决策略:

1) 根据该BUG的特征,首先侦测该BUG是否发生;

2) 将问题Selector上注册的Channel转移到新建的Selector上;

3) 老的问题Selector关闭,使用新建的Selector替换。

下面具体看下代码,首先检测是否发生了该BUG:

图2-27 epoll bug 检测

一旦检测发生该BUG,则重建Selector,代码如下:

图2-28 重建Selector

重建完成之后,替换老的Selector,代码如下:

图2-29 替换Selector

大量生产系统的运行表明,Netty的规避策略可以解决epoll bug 导致的IO线程CPU死循环问题。

2.4. 优雅退出

Java的优雅停机通常通过注册JDK的ShutdownHook来实现,当系统接收到退出指令后,首先标记系统处于退出状态,不再接收新的消息,然后将积压的消息处理完,最后调用资源回收接口将资源销毁,最后各线程退出执行。

通常优雅退出有个时间限制,例如30S,如果到达执行时间仍然没有完成退出前的操作,则由监控脚本直接kill -9 pid,强制退出。

Netty的优雅退出功能随着版本的优化和演进也在不断的增强,下面我们一起看下Netty5的优雅退出。

首先看下Reactor线程和线程组,它们提供了优雅退出接口。EventExecutorGroup的接口定义如下:

图2-30 EventExecutorGroup优雅退出

NioEventLoop的资源释放接口实现:

图2-31 NioEventLoop资源释放

ChannelPipeline的关闭接口:

图2-32 ChannelPipeline关闭接口

目前Netty向用户提供的主要接口和类库都提供了资源销毁和优雅退出的接口,用户的自定义实现类可以继承这些接口,完成用户资源的释放和优雅退出。

2.5. 内存保护

2.5.1. 缓冲区的内存泄漏保护

为了提升内存的利用率,Netty提供了内存池和对象池。但是,基于缓存池实现以后需要对内存的申请和释放进行严格的管理,否则很容易导致内存泄漏。

如果不采用内存池技术实现,每次对象都是以方法的局部变量形式被创建,使用完成之后,只要不再继续引用它,JVM会自动释放。但是,一旦引入内存池机制,对象的生命周期将由内存池负责管理,这通常是个全局引用,如果不显式释放JVM是不会回收这部分内存的。

对于Netty的用户而言,使用者的技术水平差异很大,一些对JVM内存模型和内存泄漏机制不了解的用户,可能只记得申请内存,忘记主动释放内存,特别是JAVA程序员。

为了防止因为用户遗漏导致内存泄漏,Netty在Pipe line的尾Handler中自动对内存进行释放,相关代码如下:

图2-33 TailHandler的内存回收操作

对于内存池,实际就是将缓冲区重新放到内存池中循环使用,代码如下:

图2-34 PooledByteBuf的内存回收操作

2.5.2. 缓冲区内存溢出保护

做过协议栈的读者都知道,当我们对消息进行解码的时候,需要创建缓冲区。缓冲区的创建方式通常有两种:

1) 容量预分配,在实际读写过程中如果不够再扩展;

2) 根据协议消息长度创建缓冲区。

在实际的商用环境中,如果遇到畸形码流攻击、协议消息编码异常、消息丢包等问题时,可能会解析到一个超长的长度字段。笔者曾经遇到过类似问题,报文长度字段值竟然是2G多,由于代码的一个分支没有对长度上限做有效保护,结果导致内存溢出。系统重启后几秒内再次内存溢出,幸好及时定位出问题根因,险些酿成严重的事故。

Netty提供了编解码框架,因此对于解码缓冲区的上限保护就显得非常重要。下面,我们看下Netty是如何对缓冲区进行上限保护的:

首先,在内存分配的时候指定缓冲区长度上限:

图2-35 缓冲区分配器可以指定缓冲区最大长度

其次,在对缓冲区进行写入操作的时候,如果缓冲区容量不足需要扩展,首先对最大容量进行判断,如果扩展后的容量超过上限,则拒绝扩展:

图2-35 缓冲区扩展上限保护

最后,在解码的时候,对消息长度进行判断,如果超过最大容量上限,则抛出解码异常,拒绝分配内存:

图2-36 超出容量上限的半包解码,失败

图2-37 抛出TooLongFrameException异常

2.6. 流量整形

大多数的商用系统都有多个网元或者部件组成,例如参与短信互动,会涉及到手机、基站、短信中心、短信网关、SP/CP等网元。不同网元或者部件的处理性能不同。为了防止因为浪涌业务或者下游网元性能低导致下游网元被压垮,有时候需要系统提供流量整形功能。

下面我们一起看下流量整形(traffic shaping)的定义:流量整形(Traffic Shaping)是一种主动调整流量输出速率的措施。一个典型应用是基于下游网络结点的TP指标来控制本地流量的输出。流量整形与流量监管的主要区别在于,流量整形对流量监管中需要丢弃的报文进行缓存——通常是将它们放入缓冲区或队列内,也称流量整形(Traffic Shaping,简称TS)。当令牌桶有足够的令牌时,再均匀的向外发送这些被缓存的报文。流量整形与流量监管的另一区别是,整形可能会增加延迟,而监管几乎不引入额外的延迟。

流量整形的原理示意图如下:

图2-38 流量整形原理图

作为高性能的NIO框架,Netty的流量整形有两个作用:

1) 防止由于上下游网元性能不均衡导致下游网元被压垮,业务流程中断;

2) 防止由于通信模块接收消息过快,后端业务线程处理不及时导致的“撑死”问题。

下面我们就具体学习下Netty的流量整形功能。

2.6.1. 全局流量整形

全局流量整形的作用范围是进程级的,无论你创建了多少个Channel,它的作用域针对所有的Channel。

用户可以通过参数设置:报文的接收速率、报文的发送速率、整形周期。相关的接口如下所示:

图2-39 全局流量整形参数设置

Netty流量整形的原理是:对每次读取到的ByteBuf可写字节数进行计算,获取当前的报文流量,然后与流量整形阈值对比。如果已经达到或者超过了阈值。则计算等待时间delay,将当前的ByteBuf放到定时任务Task中缓存,由定时任务线程池在延迟delay之后继续处理该ByteBuf。相关代码如下:

图2-40 动态计算当前流量

如果达到整形阈值,则对新接收的ByteBuf进行缓存,放入线程池的消息队列中,稍后处理,代码如下:

图2-41 缓存当前的ByteBuf

定时任务的延时时间根据检测周期T和流量整形阈值计算得来,代码如下:

图2-42 计算缓存等待周期

需要指出的是,流量整形的阈值limit越大,流量整形的精度越高,流量整形功能是可靠性的一种保障,它无法做到100%的精确。这个跟后端的编解码以及缓冲区的处理策略相关,此处不再赘述。感兴趣的朋友可以思考下,Netty为什么不做到 100%的精确。

流量整形与流控的最大区别在于流控会拒绝消息,流量整形不拒绝和丢弃消息,无论接收量多大,它总能以近似恒定的速度下发消息,跟变压器的原理和功能类似。

2.6.2. 单条链路流量整形

除了全局流量整形,Netty也支持但链路的流量整形,相关的接口定义如下:

图2-43 单链路流量整形

单链路流量整形与全局流量整形的最大区别就是它以单个链路为作用域,可以对不同的链路设置不同的整形策略。

它的实现原理与全局流量整形类似,我们不再赘述。值得说明的是,Netty支持用户自定义流量整形策略,通过继承AbstractTrafficShapingHandler的doAccounting方法可以定制整形策略。相关接口定义如下:

图2-44 定制流量整形策略

3. 总结

尽管Netty在架构可靠性上面已经做了很多精细化的设计,以及基于防御式编程对系统进行了大量可靠性保护。但是,系统的可靠性是个持续投入和改进的过程,不可能在一个版本中一蹴而就,可靠性工作任重而道远。

从业务的角度看,不同的行业、应用场景对可靠性的要求也是不同的,例如电信行业的可靠性要求是5个9,对于铁路等特殊行业,可靠性要求更高,达到6个9。对于企业的一些边缘IT系统,可靠性要求会低些。

可靠性是一种投资,对于企业而言,追求极端可靠性对研发成本是个沉重的包袱,但是相反,如果不重视系统的可靠性,一旦不幸遭遇网上事故,损失往往也是惊人的。

对于架构师和设计师,如何权衡架构的可靠性和其它特性的关系,是一个很大的挑战。通过研究和学习Netty的可靠性设计,也许能够给大家带来一些启示。

4. Netty学习推荐书籍

目前市面上介绍netty的文章很多,如果读者希望系统性的学习Netty,推荐两本书:

1) 《Netty in Action》

2) 《Netty权威指南》

5.作者简介

李林锋,2007年毕业于东北大学,2008年进入华为公司从事高性能通信软件的设计和开发工作,有6年NIO设计和开发经验,精通Netty、Mina等NIO框架。Netty中国社区创始人,《Netty权威指南》作者。

原文链接:http://www.infoq.com/cn/articles/netty-reliability

Posted in 搜索与分布式 | Leave a comment

分布式存储系统的雪崩效应

一 分布式存储系统背景

副本是分布式存储系统中的常见概念:将一定大小的数据按照一定的冗余策略存储,以保障系统在局部故障情况下的可用性。

副本间的冗余复制方式有多种,比较常用有两类:

  • Pipeline:像个管道,a->b->c,通过管道的方式进行数据的复制。该方式吞吐较高,但有慢节点问题,某一节点出现拥塞,整个过程都会受影响
  • 分发:client -> a  client ->b client ->c。系统整体吞吐较低,但无慢节点问题

对于冗余副本数目,本文选择常见的三副本方案。

二 雪崩效应的产生

在一段时间内数目较多的宕机事件有较大可能性诱发系统的大规模副本补全策略。目前的分布式存储系统的两个特点导致这个大规模副本补全策略容易让系统产生雪崩效应:

a. 集群整体的free空间较小:通常整体<=30%, 局部机器小于<=20% 甚至10%

   b. 应用混布:不同的应用部署在同一台物理/虚拟机器上以最大化利用硬件资源

今年火起来的各种网盘、云盘类服务就是a的典型情况。在各大公司拼个人存储容量到1T的背后,其实也在拼运营成本、运维成本。现有的云存储大多只增不减、或者根据数据冷热程度做数据分级(类似Facebook的数据分级项目)。云存储总量大,但增量相对小,为了减少存储资源和带宽资源浪费,新创建的文件若原有的存储数据中已有相同的md5或者sha1签名则当做已有文件做内部链接,不再进行新文件的创建。但即使这样,整体的数据量还是很大。

目前云存储相关业务未有明显的收入来源,每年却有数万每台的服务器成本,为运营成本的考虑,后端分布式存储系统的空闲率很低。而瞬间的批量宕机会带来大量的副本修复,大量的副本修复很有可能继而打满原本就接近存储quota的其他存活机器,继而让该机器处于宕机或者只读状态。如此继续,整个集群可能雪崩,系统残废。

三 预防雪崩

本节主要讨论如何在系统内部的逻辑处理上防止系统整体雪崩的发生。预防的重要性大于事故之后的处理,预测集群状态、提前进行优化也成为预防雪崩的一个方向。

下面选取曾经发生过的几个实际场景与大家分享。

1. 跨机架副本选择算法和机器资源、用户逻辑隔离

现场还原:

某天运维同学发现某集群几十台机器瞬间失联,负责触发修复副本的主控节点开始进行疯狂的副本修复。大量用户开始反馈集群变慢,读写夯住。

现场应对:

优先解决——副本修复量过大造成的集群整体受影响。

a. 处理的工程师当机立断,gdb到进程更改修复副本的条件为副本<2,而非原本的3(replicas_num),让主控节点这个时候仅修复副本数小于2个的文件,即保证未丢失的文件有至少一个冗余副本,防止只有一个副本的数据因可能再次发生的挂机造成文件丢失。

b. 紧急解决这批机器失联问题,发现是交换机问题,a.b.c.d ip网段的c网段机器批量故障。催促网络组尽快修复。

c. 副本修复到>=2之后,Gdb更改检测副本不足周期,将几十秒的检测时间推迟到1天。等待网络组解决交换机问题。

d. 网络恢复,原有的机器重新加入集群。大量2副本文件重新变为3副本,部分3副本全丢失文件找回。

e. 恢复主控节点到正常参数设置状态,系统开始正常修复。

改进措施:

在改进措施前,先分析下这次事件暴露的系统不足:

1) Master参数不支持热修正,Gdb线上进程风险过大。

2) 一定数量但局域性的机器故障影响了整体集群(几十台相对一个大集群仍属于局域性故障)。如上所述,月千分之几的故障率总有机会让你的存储系统经历一次交换机故障带来的集群影响。

案例分析后的改进措施出炉:

1)  Master支持热修正功能排期提前,尽早支持核心参数的热修改。

热修改在上线后的效果可观,后续规避过数次线上问题。

2) 在选择数据副本存储宿主机器的pickup算法中加入跨交换机(机架位)策略,强制——或者尽量保证——副本选择时跨机架位。这种算法底下的副本,至少有1个副本与其他两个副本处于不同的交换机下(IP a.b.c.d的c段)。该措施同时作用于新的存储数据副本选择和副本缺失后的副本补全策略,能在副本宿主选择上保证系统不会因为交换机的宕机而出现数据丢失,进而避免一直处于副本补全队列/列表的大量的丢失副本节点加重主控节点负载。

3) 机器按region划分隔离功能提上日程;用户存储位置按照region进行逻辑划分功能提上日程;Pickup算法加入跨region提上日程。

a) 机器按照物理位置划分region、用户按照region进行逻辑存储位置划分,能让集群在局部故障的情况下仅影响被逻辑划分进使用这部分机器的用户。

这样一来,最坏情况无非是这个region不可用,导致拥有这个region读写权限的用户受影响。Pickup算法跨region的设计进一步保证被划分region的用户不会因为一个region不可用而出现数据丢失,因为其他副本存到其他region上了。于是,核心交换机故障导致一个region数百台机器的宕机也不会对集群造成范围过大的影响了。

b) 增加region可信度概念,将机器的稳定性因素加入到副本冗余算法中。

当集群规模达到一定量后,会出现机器稳定性不同的问题(一般来说,同一批上线的机器稳定性一致)。通过标记region的稳定性,能强制在选择数据副本的时候将至少一个副本至于稳定副本中,减少全部副本丢失的概率。

c) Region划分需要综合考虑用户操作响应时间SLA、物理机器稳定情况、地理位置等信息。

合理的region划分对提升系统稳定性、提升操作相应时间、预防系统崩溃都有益处。精巧的划分规则会带来整体的稳定性提升,但也增加了系统的复杂度。这块如何取舍,留给读者朋友深入思考了。

2. 让集群流控起来

流控方面有个通用且符合分布式存储系统特点的原则:任何操作都不应占用过多的处理时间。这里的“任何操作”包含了在系统出现流量激增、局部达到一定数量的机器宕机时进行的操作。只有平滑且成功的处理这些操作,才能保证系统不因为异常而出现整体受影响,甚至雪崩。

现场还原:

1) 场景1 某天运维同学发现,集群写操作在某段时间大增。通过观察某个存储节点,发现不仅是写、而且是随机写!某些产品线的整体吞吐下降了。

2) 场景2 某集群存储大户需要进行业务调整,原有的数据做变更,大量数据需要删除。

运维同学发现,a. 整个集群整体上处于疯狂gc垃圾回收阶段 b. 集群响应速度明显变慢,特别是涉及到meta元信息更新的操作。

3) 场景3 某天运维同学突然发现集群并发量激增,单一用户xyz进行了大量的并发操作,按照原有的用户调研,该用户不应该拥有如此规模的使用场景。

此类集群某些操作预期外的激增还有很多,不再累述。

现场应对:

1) 立刻电联相关用户,了解操作激增原因,不合理的激增需要立刻处理。

我们发现过如下不合理的激增:

a. 场景1类:通过Review代码发现,大量的操作进行了随机读写更改。建议用户将随机读写转换为读取后更改+写新文件+删除旧文件,转换随机读写为顺序读写。

b. 场景3类:某产品线在线上进行了性能测试。运维同学立刻通知该产品线停止了相关操作。所有公有集群再次发通过邮件强调,不可用于性能测试。如有需要,联系相关人员在独占集群进行性能场景测试。

2) 推动设计和实现集群各个环节的流控机制功能并上线。

改进措施:

1) 用户操作流控

a. 对用户操作进行流控限制

可通过系统内部设计实现,也可通过外部的网络限流等方式实现,对单用户做一定的流控限制,防止单个用户占用过多整个集群的资源。

b. 存储节点操作流控

可按照对集群的资源消耗高低分为High – Medium – Low三层,每层实现类似于抢token的设计,每层token数目在集群实践后调整为比较适合的值。这样能防止某类操作过多消耗集群负载。若某类操作过多消耗负载,其他操作类的请求有较大delay可能,继而引发timeout后的重试、小范围的崩溃,有一定几率蔓延到整个集群并产生整体崩溃。

c. 垃圾回收gc单独做流控处理。删除操作在分布式存储系统里面常用设计是:接收到用户删除操作时,标记删除内容的meta信息,直接回返,后续进行策略控制,限流的删除,防止大量的gc操作消耗过多单机存储节点的磁盘处理能力。具体的限流策略和token值设置需要根据集群特点进行实践并得出较优设置。

2) 流控黑名单

用户因为对线上做测试类的场景可以通过人为制度约束,但无法避免线上用户bug导致效果等同于线上测试规模的场景。这类的场景一般在短时间内操作数严重超过限流上限。

对此类场景可进行流控黑名单设置,当某用户短时间内(e.g. 1小时)严重超过设置的上限时,将该用户加入黑名单,暂时阻塞操作。外围的监控会通知运维组同学紧急处理。

3) 存储节点并发修复、创建副本流控

大量的数据副本修复操作或者副本创建操作如果不加以速度限制,将占用存储节点的带宽和CPU、内存等资源,影响正常的读写服务,出现大量的延迟。而大量的延迟可能引发重试,加重集群的繁忙程度。

同一个数据宿主进程需要限制并发副本修复、副本创建的个数,这样对入口带宽的占用不会过大,进程也不会因为过量进行这类操作而增加大量其他操作的延迟时间。这对于采用分发的副本复制协议的系统尤其重要。分发协议一般都有慢节点检查机制,副本流控不会进一步加重系统延迟而增大成为慢节点的可能。如果慢节点可能性增大,新创建的文件可能在创建时就因为慢节点检查机制而缺少副本,这会让集群状况更加恶化。

3. 提前预测、提前行动

1) 预测磁盘故障,容错单磁盘错误。

场景复现:

某厂商的SSD盘某批次存在问题,集群上线运行一段时间后,局部集中出现数量较多的坏盘,但并非所有的盘都损坏。当时并未有单磁盘容错机制,一块磁盘坏掉,整个机器就被置成不可用状态,这样导致拥有这批坏盘的机器都不可用,集群在一段时间内都处于副本修复状态,吞吐受到较大影响。

改进措施:

a) 对硬盘进行健康性预测,自动迁移大概率即将成为坏盘的数据副本

近年来,对磁盘健康状态进行提前预测的技术越来越成熟,技术上已可以预判磁盘健康程度并在磁盘拥有大概率坏掉前,自动迁移数据到其他磁盘,减少磁盘坏掉对系统稳定性的影响。

b) 对单硬盘错误进行容错处理

存储节点支持对坏盘的异常处理。单盘挂掉时,自动迁移/修复单盘的原有数据到其他盘,而不是进程整体宕掉,因为一旦整体宕掉,其他盘的数据也会被分布式存储系统当做缺失副本,存储资源紧张的集群经历一次这样的宕机事件会造成长时间的副本修复过程。在现有的分布式存储系统中, 也有类似淘宝TFS那样,每个磁盘启动一个进程进行管理,整机挂载多少个盘就启动多少个进程。

2) 根据现有存储分布,预测均衡性发展,提前进行负载均衡操作。

这类的策略设计越来越常见。由于分布式存储集群挂机后的修复策略使得集群某些机器总有几率成为热点机器,我们可以对此类的机器进行热点预测,提前迁移部分数据到相对负载低的机器。

负载均衡策略和副本选择策略一样,需要取舍复杂度和优化程度问题。复杂的均衡策略带来好的集群负载,但也因此引入高复杂度、高bug率问题。如何取舍,仍旧是个困扰分布式存储系统设计者的难题。

四 安全模式

安全模式是项目实践过程中产生的防分布式存储系统雪崩大杀器,因此我特别将其单独列为一节介绍。其基本思路是在一定时间内宕机数目超过预期上限则让集群进入安全模式,按照策略配置、情况严重程度,停止修复副本、停止读写,直到停止一切操作(一般策略)。

在没有机器region概念的系统中,安全模式可以起到很好的保护作用。我过去参与的一个项目经历的某次大规模宕机,由于没有安全模式,系统进行正常的处理副本修复,生生将原本健康的存储节点也打到残废,进而雪崩,整个集群都陷入疯狂副本修复状态。这种状态之后的集群修复过程会因为已发生的副本修复导致的元信息/实际数据的更改而变的困难重重。 该事件最后结局是数据从冷备数据中恢复了一份,丢失了冷备到故障发生时间的数据。

当然,安全模式并非完美无缺。“一段时间”、“上限”该如何设置、什么时候停副本修复、什么时候停读、什么时候停写、是自己恢复还是人工干预恢复到正常状态、安全模式力度是否要到region级别,这些问题都需要安全模式考虑,而此类的设计一般都和集群设计的目标用户息息相关。举例,如果是低延迟且业务敏感用户,可能会选择小规模故障不能影响读写,而高延迟、高吞吐集群就可以接受停读写。

五 思考

由于分布式存储系统的复杂性和篇幅所限,本文仅选择有限个典型场景进行了分析和讨论, 真实的分布式存储系统远比这数个案例复杂的多、细节的多。如何平衡集群异常自动化处理和引入的复杂度,如何较好的实现流控和避免影响低延迟用户的响应时间,如何引导集群进行负载均衡和避免因负载均衡带来的过量集群资源开销,这类问题在真实的分布式存储系统设计中层出不穷。如果设计者是你,你会如何取舍呢?


感谢丁雪丰对本文的审校。

原文地址:分布式存储系统的雪崩效应

Posted in 搜索与分布式 | Leave a comment

Apache Kafka:下一代分布式消息系统

本文kafka的介绍和实现原理是基于0.7.2版本或以下版本的,最新版本的kafka在实在上做了比较大的调整,以至于与0.7的版本不兼容,但其官方上还是提供了个程序方便其用户把现有基于0.7老版本的数据导入到新版本。

简介

Apache Kafka是分布式发布-订阅消息系统。它最初由LinkedIn公司开发,之后成为Apache项目的一部分。Kafka是一种快速、可扩展的、设计内在就是分布式的,分区的和可复制的提交日志服务。

Apache Kafka与传统消息系统相比,有以下不同:

  • 它被设计为一个分布式系统,易于向外扩展;
  • 它同时为发布和订阅提供高吞吐量;
  • 它支持多订阅者,当失败时能自动平衡消费者;
  • 它将消息持久化到磁盘,因此可用于批量消费,例如ETL,以及实时应用程序。

本文我将重点介绍Apache Kafka的架构、特性和特点,帮助我们理解Kafka为何比传统消息服务更好。

我将比较Kafak和传统消息服务RabbitMQ、Apache ActiveMQ的特点,讨论一些Kafka优于传统消息服务的场景。在最后一节,我们将探讨一个进行中的示例应用,展示Kafka作为消息服务器的用途。这个示例应用的完整源代码在GitHub。关于它的详细讨论在本文的最后一节。

架构

首先,我介绍一下Kafka的基本概念。它的架构包括以下组件:

  • 话题(Topic)是特定类型的消息流。消息是字节的有效负载(Payload),话题是消息的分类名或种子(Feed)名。
  • 生产者(Producer)是能够发布消息到话题的任何对象。
  • 已发布的消息保存在一组服务器中,它们被称为代理(Broker)或Kafka集群
  • 消费者可以订阅一个或多个话题,并从Broker拉数据,从而消费这些已发布的消息。

图1:Kafka生产者、消费者和代理环境

生产者可以选择自己喜欢的序列化方法对消息内容编码。为了提高效率,生产者可以在一个发布请求中发送一组消息。下面的代码演示了如何创建生产者并发送消息。

生产者示例代码:

producer = new Producer(…); 
message = new Message(“test message str”.getBytes()); 
set = new MessageSet(message); 
producer.send(“topic1”, set);

为了订阅话题,消费者首先为话题创建一个或多个消息流。发布到该话题的消息将被均衡地分发到这些流。每个消息流为不断产生的消息提供了迭代接口。然后消费者迭代流中的每一条消息,处理消息的有效负载。与传统迭代器不同,消息流迭代器永不停止。如果当前没有消息,迭代器将阻塞,直到有新的消息发布到该话题。Kafka同时支持点到点分发模型(Point-to-point delivery model),即多个消费者共同消费队列中某个消息的单个副本,以及发布-订阅模型(Publish-subscribe model),即多个消费者接收自己的消息副本。下面的代码演示了消费者如何使用消息。

消费者示例代码:

streams[] = Consumer.createMessageStreams(“topic1”, 1) 
for (message : streams[0]) { 
bytes = message.payload(); 
// do something with the bytes 
}

Kafka的整体架构如图2所示。因为Kafka内在就是分布式的,一个Kafka集群通常包括多个代理。为了均衡负载,将话题分成多个分区,每个代理存储一或多个分区。多个生产者和消费者能够同时生产和获取消息。

图2:Kafka架构

Kafka存储

Kafka的存储布局非常简单。话题的每个分区对应一个逻辑日志。物理上,一个日志为相同大小的一组分段文件。每次生产者发布消息到一个分区,代理就将消息追加到最后一个段文件中。当发布的消息数量达到设定值或者经过一定的时间后,段文件真正写入磁盘中。写入完成后,消息公开给消费者。

与传统的消息系统不同,Kafka系统中存储的消息没有明确的消息Id。

消息通过日志中的逻辑偏移量来公开。这样就避免了维护配套密集寻址,用于映射消息ID到实际消息地址的随机存取索引结构的开销。消息ID是增量的,但不连续。要计算下一消息的ID,可以在其逻辑偏移的基础上加上当前消息的长度。

消费者始终从特定分区顺序地获取消息,如果消费者知道特定消息的偏移量,也就说明消费者已经消费了之前的所有消息。消费者向代理发出异步拉请求,准备字节缓冲区用于消费。每个异步拉请求都包含要消费的消息偏移量。Kafka利用sendfile API高效地从代理的日志段文件中分发字节给消费者。注:这个地方写的不是太好懂,消费者自己维护自己的消费状态(已消费消息的偏移量),下次取消息时会从这个偏移量开始顺序的消费消息。

图3:Kafka存储架构

Kafka代理

与其它消息系统不同,Kafka代理是无状态的。这意味着消费者必须维护已消费的状态信息。这些信息由消费者自己维护,代理完全不管。这种设计非常微妙,它本身包含了创新。

  • 从代理删除消息很棘手,因为代理并不知道消费者是否已经使用了该消息。Kafka创新性地解决了这个问题,它将一个简单的基于时间的SLA应用于保留策略。当消息在代理中超过一定时间后,将会被自动删除。注:kafka的broker在配置文件中可以配置最多保存多少小时的数据和分区最大的空间占用,过期的和超量的数据会被broker自动清除掉。
  • 这种创新设计有很大的好处,消费者可以故意倒回到老的偏移量再次消费数据。这违反了队列的常见约定,但被证明是许多消费者的基本特征。注:消息存放在磁盘中,所以其可以保存大量的消息,消息获取依据分区offset值,所以给一个老的偏移量就能够从broker中取下相应偏移量后的消息。这个特征对需要重算的消费者是方便的。

ZooKeeper与Kafka

考虑一下有多个服务器的分布式系统,每台服务器都负责保存数据,在数据上执行操作。这样的潜在例子包括分布式搜索引擎、分布式构建系统或者已知的系统如Apache Hadoop。所有这些分布式系统的一个常见问题是,你如何在任一时间点确定哪些服务器活着并且在工作中。最重要的是,当面对这些分布式计算的难题,例如网络失败、带宽限制、可变延迟连接、安全问题以及任何网络环境,甚至跨多个数据中心时可能发生的错误时,你如何可靠地做这些事。这些正是Apache ZooKeeper所关注的问题,它是一个快速、高可用、容错、分布式的协调服务。你可以使用ZooKeeper构建可靠的、分布式的数据结构,用于群组成员、领导人选举、协同工作流和配置服务,以及广义的分布式数据结构如锁、队列、屏障(Barrier)和锁存器(Latch)。许多知名且成功的项目依赖于ZooKeeper,其中包括HBase、Hadoop 2.0、Solr Cloud、Neo4J、Apache Blur(Incubating)和Accumulo。

ZooKeeper是一个分布式的、分层级的文件系统,能促进客户端间的松耦合,并提供最终一致的,类似于传统文件系统中文件和目录的Znode视图。它提供了基本的操作,例如创建、删除和检查Znode是否存在。它提供了事件驱动模型,客户端能观察特定Znode的变化,例如现有Znode增加了一个新的子节点。ZooKeeper运行多个ZooKeeper服务器,称为Ensemble,以获得高可用性。每个服务器都持有分布式文件系统的内存复本,为客户端的读取请求提供服务。

图4:ZooKeeper Ensemble架构

上图4展示了典型的ZooKeeper ensemble,一台服务器作为Leader,其它作为Follower。当Ensemble启动时,先选出Leader,然后所有Follower复制Leader的状态。所有写请求都通过Leader路由,变更会广播给所有Follower。变更广播被称为原子广播

Kafka中ZooKeeper的用途:正如ZooKeeper用于分布式系统的协调和促进,Kafka使用ZooKeeper也是基于相同的原因。ZooKeeper用于管理、协调Kafka代理。每个Kafka代理都通过ZooKeeper协调其它Kafka代理。当Kafka系统中新增了代理或者某个代理故障失效时,ZooKeeper服务将通知生产者和消费者。生产者和消费者据此开始与其它代理协调工作。Kafka整体系统架构如图5所示。注:broker和生产者、消费者各自都是集群,集群中的各个实例他们之间是对等的,集群扩充节点很方便。

图5:Kafka分布式系统的总体架构

Apache Kafka对比其它消息服务

让我们了解一下使用Apache Kafka的两个项目,以对比其它消息服务。这两个项目分别是LinkedIn和我的项目:

LinkedIn的研究

LinkedIn团队做了个实验研究,对比Kafka与Apache ActiveMQ V5.4和RabbitMQ V2.4的性能。他们使用ActiveMQ默认的消息持久化库Kahadb。LinkedIn在两台Linux机器上运行他们的实验,每台机器的配置为8核2GHz、16GB内存,6个磁盘使用RAID10。两台机器通过1GB网络连接。一台机器作为代理,另一台作为生产者或者消费者。

生产者测试

LinkedIn团队在所有系统中配置代理,异步将消息刷入其持久化库。对每个系统,运行一个生产者,总共发布1000万条消息,每条消息200字节。Kafka生产者以1和50批量方式发送消息。ActiveMQ和RabbitMQ似乎没有简单的办法来批量发送消息,LinkedIn假定它的批量值为1。结果如下面的图6所示:

图6:LinkedIn的生产者性能实验结果

Kafka性能要好很多的主要原因包括:

  • Kafka不等待代理的确认,以代理能处理的最快速度发送消息。
  • Kafka有更高效的存储格式。平均而言,Kafka每条消息有9字节的开销,而ActiveMQ有144字节。其原因是JMS所需的沉重消息头,以及维护各种索引结构的开销。LinkedIn注意到ActiveMQ一个最忙的线程大部分时间都在存取B-Tree以维护消息元数据和状态。
  • 注:kafka使用sendfile、多条消息可打包压缩和传递,磁盘中顺序直接存储不需要维护复杂的存储结构。

消费者测试

为了做消费者测试,LinkedIn使用一个消费者获取总共1000万条消息。LinkedIn让所有系统每次拉请求都预获取大约相同数量的数据,最多1000条消息或者200KB。对ActiveMQ和RabbitMQ,LinkedIn设置消费者确认模型为自动。结果如图7所示。

图7:LinkedIn的消费者性能实验结果

Kafka性能要好很多的主要原因包括:

  • Kafka有更高效的存储格式;在Kafka中,从代理传输到消费者的字节更少。注:消息在生产者处打包压缩成消息集,发送到代理上,代理不加修改直接顺序存储收到的消息集合,当消费者消费消息时,通过sendfile系统调用把消息集发给消费者,消费者自己根据消息块中的标识解压消息集得到一条条消息。所以传输层传递的消息字节更少。
  • ActiveMQ和RabbitMQ两个容器中的代理必须维护每个消息的传输状态。注:其实就是消息是否消费成功,代理是否可以在代理上清除消息的ack,也就是需要维护消费状态  LinkedIn团队注意到其中一个ActiveMQ线程在测试过程中,一直在将KahaDB页写入磁盘。与此相反,Kafka代理没有磁盘写入动作。最后,Kafka通过使用sendfile API降低了传输开销。

目前,我正在工作的一个项目提供实时服务,从消息中快速并准确地提取场外交易市场(OTC)定价内容。这是一个非常重要的项目,处理近25种资产类别的财务信息,包括债券、贷款和ABS(资产担保证券)。项目的原始信息来源涵盖了欧洲、北美、加拿大和拉丁美洲的主要金融市场领域。下面是这个项目的一些统计,说明了解决方案中包括高效的分布式消息服务是多么重要:

  • 每天处理的消息数量超过1,300,000
  • 每天解析的OTC价格数量超过12,000,000
  • 支持超过25种资产类别;
  • 每天解析的独立票据超过70,000

消息包含PDF、Word文档、Excel及其它格式。OTC定价也可能要从附件中提取。

由于传统消息服务器的性能限制,当处理大附件时,消息队列变得非常大,我们的项目面临严重的问题,JMSqueue一天需要启动2-3次。重启JMS队列可能丢失队列中的全部消息。项目需要一个框架,不论解析器(消费者)的行为如何,都能够保住消息。Kafka的特性非常适用于我们项目的需求。

当前项目具备的特性:

  1. 使用Fetchmail获取远程邮件消息,然后由Procmail过滤并处理,例如单独分发基于附件的消息。
  2. 每条消息从单独的文件获取,该文件被处理(读取和删除)为一条消息插入到消息服务器中。
  3. 消息内容从消息服务队列中获取,用于解析和提取信息。

示例应用

这个示例应用是基于我在项目中使用的原始应用修改后的版本。我已经删除日志的使用和多线程特性,使示例应用的工件尽量简单。示例应用的目的是展示如何使用Kafka生产者和消费者的API。应用包括一个生产者示例(简单的生产者代码,演示Kafka生产者API用法并发布特定话题的消息),消费者示例(简单的消费者代码,用于演示Kafka消费者API的用法)以及消息内容生成API(在特定路径下生成消息内容到文件的API)。下图展示了各组件以及它们与系统中其它组件间的关系。

图8:示例应用组件架构

示例应用的结构与Kafka源代码中的例子程序相似。应用的源代码包含Java源程序文件夹‘src’和’config’文件夹,后者包括几个配置文件和一些Shell脚本,用于执行示例应用。要运行示例应用,请参照ReadMe.md文件或GitHub网站Wiki页面的说明。

程序构建可以使用Apache Maven,定制也很容易。如果有人想修改或定制示例应用的代码,有几个Kafka构建脚本已经过修改,可用于重新构建示例应用代码。关于如何定制示例应用的详细描述已经放在项目GitHub的Wiki页面

现在,让我们看看示例应用的核心工件。

Kafka生产者代码示例

/** 
 * Instantiates a new Kafka producer. 
 * 
 * @param topic the topic 
 * @param directoryPath the directory path 
 */ 
public KafkaMailProducer(String topic, String directoryPath) { 
       props.put("serializer.class", "kafka.serializer.StringEncoder"); 
       props.put("metadata.broker.list", "localhost:9092"); 
       producer = new kafka.javaapi.producer.Producer<Integer, String>(new ProducerConfig(props)); 
       this.topic = topic; 
       this.directoryPath = directoryPath; 
} 

public void run() { 
      Path dir = Paths.get(directoryPath); 
      try { 
           new WatchDir(dir).start(); 
           new ReadDir(dir).start(); 
      } catch (IOException e) { 
           e.printStackTrace(); 
      } 
}

上面的代码片断展示了Kafka生产者API的基本用法,例如设置生产者的属性,包括发布哪个话题的消息,可以使用哪个序列化类以及代理的相关信息。这个类的基本功能是从邮件目录读取邮件消息文件,然后作为消息发布到Kafka代理。目录通过java.nio.WatchService类监视,一旦新的邮件消息Dump到该目录,就会被立即读取并作为消息发布到Kafka代理。

Kafka消费者代码示例

public KafkaMailConsumer(String topic) { 
       consumer = 
Kafka.consumer.Consumer.createJavaConsumerConnector(createConsumerConfig()); 
       this.topic = topic; 
} 

/** 
 * Creates the consumer config. 
 * 
 * @return the consumer config 
 */ 
private static ConsumerConfig createConsumerConfig() { 
      Properties props = new Properties(); 
      props.put("zookeeper.connect", KafkaMailProperties.zkConnect); 
      props.put("group.id", KafkaMailProperties.groupId); 
      props.put("zookeeper.session.timeout.ms", "400"); 
      props.put("zookeeper.sync.time.ms", "200"); 
      props.put("auto.commit.interval.ms", "1000"); 
      return new ConsumerConfig(props); 
} 

public void run() { 
      Map<String, Integer> topicCountMap = new HashMap<String, Integer>(); 
      topicCountMap.put(topic, new Integer(1)); 
      Map<String, List<KafkaStream<byte[], byte[]>>> consumerMap = consumer.createMessageStreams(topicCountMap); 
      KafkaStream<byte[], byte[]> stream = consumerMap.get(topic).get(0); 
      ConsumerIterator<byte[], byte[]> it = stream.iterator();
      while (it.hasNext()) 
      System.out.println(new String(it.next().message())); 
}

上面的代码演示了基本的消费者API。正如我们前面提到的,消费者需要设置消费的消息流。在Run方法中,我们进行了设置,并在控制台打印收到的消息。在我的项目中,我们将其输入到解析系统以提取OTC定价。

在当前的质量保证系统中,我们使用Kafka作为消息服务器用于概念验证(Proof of Concept,POC)项目,它的整体性能优于JMS消息服务。其中一个我们感到非常兴奋的特性是消息的再消费(re-consumption),这让我们的解析系统可以按照业务需求重新解析某些消息。基于Kafka这些很好的效果,我们正计划使用它,而不是用Nagios系统,去做日志聚合与分析。

总结

Kafka是一种处理大量数据的新型系统。Kafka基于拉的消费模型让消费者以自己的速度处理消息。如果处理消息时出现了异常,消费者始终可以选择再消费该消息。

英文原文:Apache Kafka: Next Generation Distributed Messaging System

Posted in kafka | Leave a comment

Case Study GFS:Evolution on Fast-forward

作者:Kirk McKusick & Sean Quinlan  2009-8

原文:http://queue.acm.org/detail.cfm?id=1594206

译者:phylips@bmy 2011-8

译文:  http://duanple.blog.163.com/blog/static/7097176720117611327190/

[一个Kirk McKusick 与Sean Quinlan之间关于GFS的起源和演化的讨论]

在Google的早期开发阶段,最初的想法并没有包含一个构建新的文件系统的计划。工作依然是通过公司最早版本的爬虫和索引系统来完成,但是,事情对于核心工程师们很快变得明朗起来,除了构建一个新的系统外他们别无选择,于是GFS(Google File System)就诞生了。

首先,由于Google的目标是要通过使用很多廉价的商品化硬件来构建一个大规模存储网络。因此它必须要假设组件失败是一种常态—这就意味着常规性的监控,错误检测,容错,自动恢复必须作为系统内完整的一部分。而且,即使是Google最早期的一些估算,该文件系统的吞吐率需求在任何人看来都是非常可观的—处理multi-gigabyte级别的文件,数据集可能包含数TB的数据及数百万个对象。很明显,这意味着必须要重新审视关于IO操作及块大小的传统假设。同时还需要关注可扩展性。这肯定是一个需要全然不同的扩展性的文件系统。当然,在早期的那些日子里,没有人能说出到底需要什么样的扩展性。但是很快他们就会明白。

虽然已过了近十年,但是Google那令人叹为观止的数据存储以及不断增长的应用仍然是建立在GFS之上。在这个过程中,该文件系统进行过很多变更—同时那些使用GFS的应用程序也不断进行着各种改变—这使得一切成为可能。

为了探索某些关键的初始设计决定以及从那时起所进行的一些改进的缘由,ACM邀请了Sean Quinlan来揭开那些处于不断变更中的文件系统需求以及那些不断演化的思想的神秘面纱。在很多年中,Sean Quinlan一直是GFS的技术领导人,目前已是Google的首席工程师,因此他是一个引导我们透视GFS的不二人选。为避免局限于Google的视野,ACM又邀请了Kirk McKusick来推动这个讨论。他因为在BSD Unix上的工作,包括Berkeley FFS(Fast File System)的原始设计而为人熟知。

这个讨论以初始GFS实现中的单master设计决定作为开端。乍看,单个的中央Master会成为带宽瓶颈—或者,更糟的是可能产生单点失败—但是实际上呢,对于这个决定,Google的工程师们有他们自己的设计考虑。

MCKUSICK 原始的GFS架构中有一个很有趣也很重要的设计决定就是基于一个单master。能否解释一下是什么原因导致了这个决定?

QUINLAN 采用单master的决定实际上是最初的几个决定中的一个,最主要的是为了简化总体的设计。也就是说,直接建立一个分布式的master当时看来很困难而且会花太多时间。而且,通过这种单master策略,工程师也可以简化很多问题。这就有一个中央位置可以用来控制relication,垃圾回收和很多其他行为,这比在一个分布式的环境上更简单。因此,最后决定将这些集中到一台机器上。

MCKUSICK 那是不是主要是因为这样就可以在一个尽量短的时间内解决很多问题。

QUINLAN 是的。曾经参与GFS的一些工程师后来继续去实现BigTable,一个分布式的存储系统,这个系统就花了好多年。这种将最初的GFS建立在单master上的决定大大加快了其实现进度。

而且,考察了面临的使用场景之后,当时看起来单master的设计也不会引起大的问题。当时所考虑的规模大概是在数百TB数据以及数百万文件数。事实上,这个系统一开始工作的很好。

MCKUSICK 但是,之后呢?

QUINLAN 随着底层存储大小的增长,一些问题开始暴露出来。当从数百TB上升到PB级别,再到数10PB……这会导致master需要维护的元数据数量产生了相应规模的增长。而且,那些扫描元数据的操作也是随数据量线性增长的。这种需要master所做的工作大幅增长,所需要存储的数据量也随之增长。

另外,这对于客户端也会是一个瓶颈,即使客户端本身只产生很少的元数据操作—比如,客户端进行一个open操作时会与master通信。当有成千上万个客户端同时与master通信时,假设master每秒只能处理几千个操作,那么客户端就不能在一秒内产生这么多请求。而且需要注意的是有很多像MapReduce这样的应用程序,可能有上千个task,每个都可能打开一些文件。很明显,master需要花很长时间去处理这些请求,会承受很大的压力。

MCKUSICK 现在,在当前的GFS模式里,一个GFS cell有一个master,对吗?

QUINLAN是的。

MCKUSICK 历史上,你们曾经是一个数据中心一个GFS cell,是吗?

QUINLAN 那是最初的目标,但是那样无法进行更大的扩展—一部分是由于受单master设计的限制,一部分原因是因为在一个cell里很难做到隔离性。结果,最终每个数据中心都不止一个cell。而且后来我们也采用了一个称为”multi-cell”的策略,主要是可以把多个GFS master建立在一堆的chunkserver机器池上。通过这种方式,chunkserver机器集可以配置成多个master,比如说安排给它们8个GFS master。这样应用程序负责在不同的cell间划分数据。

MCKUSICK 这样看来,每个程序就可以有自己的master来管理它自己的小文件系统。是这样的吗?

QUINLAN 好吧,有对也有错。应用程序倾向于使用一个master或者一小集master。我们也提供称为名字空间的东西,它是一种静态划分名字空间的方式,人们可以用它将这些事情与实际应用程序透明。以日志处理系统为例:一旦日志超过了一个cell的存储能力,它们就会移到多个GFS cell;一个名字空间文件描述了日志如何在这些不同的cell间划分,主要让这些具体的划分与应用程序透明。但是,这都是完全静态的。

MCKUSICK 整体来看,性能如何?

QUINLAN 我们没有投入很多精力在优化master的性能上。在Google,很少将精力投入到对某个特殊的二进制执行程序的优化上。通常来说,我们只是让一切可以工作后,然后关注可扩展性—只要通过扩展某些东西就可以让性能得到提高。因为在这个问题中,我们有一个中央瓶颈可能会影响到很多操作,但是我们认为通过增加一些额外的努力让master变得更轻量级是更值得的事情。在将规模从数千操作扩展到数万的级别,单master还未成为瓶颈。当然在二进制程序上进行更多的优化肯定比不去做优化,更能让GFS走的更长久一些。

可以说让GFS在很短的时间内投入到产品级的应用中,为它的成功做出了贡献,同时也加速了Google走向市场的速度,最终可能导致了公司的成功。一个三人的团队负责所有的事情, 包括GFS核心,使系统可以在一年内为部署做好准备。

但是所有成功的系统都会碰到一个问题—规模和应用总是以超过任何人可以想象的速度进行扩展。在Google,这种情况则更加明显。

尽管各大公司组织没有就文件系统统计信息进行过交换共享,可以说GFS是世界上运行中的最大的文件系统。因此,即使原始的GFS架构已经提供了数倍的扩展能力,但是Google还是很快就超过了它。

另外,GFS所需要支持的应用程序数据也在急剧增长。在对原始GFS的架构者Howard Gobioff的一次采访中,他提到”我们最早的GFS版本用户主要是为爬虫和索引系统。当质量保证团队和研究团队开始大量使用GFS的时候我们迎来了第二个高峰—而且他们都是用GFS来存储大的数据,很快我们就有了50个用户,他们随时都需要技术支持”。

令人吃惊的是Google不仅构建了这样一个文件系统,而且所有的应用程序都运行在它之上。需要对GFS进行持续调整以更好的适应新的使用场景,同时应用程序本身的也在伴随着GFS的优势和缺点不断演进。”因为一切都是我们构建的,因此我们可以做任何我们想做的”,Gobioff一针见血的指出。”我们可以将问题在应用程序与文件系统之间来回考量,最后决定在哪块进行调整”。

关于规模的问题,需要一些更实质性的调整。一种上层的解决策略是使用多个跨网络的cells,它们在功能上相关但是是不同的文件系统。除了有助于解决这种扩展性问题,这对于很多来自分散的数据中心的操作也是一种有效的安排。

快速的增长也会对初始的GFS设计的另一个关键参数造成压力:选择64MB作为标准chunk大小。当然,这比普通的文件系统块大小要大很多,但是这是因为由Google的爬虫和索引系统生成的文件本身很大。但是伴随着应用程序的多样性,必须找到一种方式让系统可以高效地处理那些大量远小于64MB的文件(比如Gmail中的文件)。文件数本身不是太大的问题,但是所有的文件在中央的master节点上都有一个内存需求,因此这也将原始GFS设计的固有风险暴露出来。

MCKUSICK 从最初的GFS论文上,可以看出文件数一直是一个关键的问题。能否深入讲一下?

QUINLAN 文件数问题很早就碰到了。举一个具体的例子,在我在Google的早期,设计过一个日志处理系统。最初设计的模型里,前端服务器会写log,我们之后为处理和归档的需要,简单地将它拷贝到GFS上。一开始工作的很好,但是随着前端服务器的增加,每个每天都在产生日志。同时,日志的类型也在不断增加,还有前端服务器可能会陷入crash循环,这会产生更多的日志。这使得我们面对的日志规模远远超出了我们最初的估计。这成为我们必须要关注的问题。最后,我们不得不承认没有办法去应对这样的文件数增长规模。

MCKUSICK 不知道我理解的是否正确:你们关于文件数增长带来的问题是因为你们需要在master端为每个文件保存一些元数据,而这些元数据必须能够放入master的内存。

QUINLAN 对,是这样的。

MCKUSICK 那么这就只能容纳不能让master内存耗尽的有限数目的文件了?

QUINLAN 对。有两种元数据。一个用于标识文件,另一个用于组成文件的那些chunks。如果你有一个只有1MB的chunk,虽然它只占了1MB的磁盘空间,但是它仍然需要这两类放在master上的元数据。如果你的平均文件大小小于64MB,那么你能存储下的对象数就会降低。这就成了问题。

回到前面的日志系统的例子,情况很快就很清楚了,我们考虑的这种自然性的映射根本是行不通的。我们需要找到一种方式来绕过这个问题,通过将一些底层对象合并成大文件的方式。在日志系统的这个例子里,虽然它不像造一个火箭那样复杂,但是还是需要付出一些努力。

MCKUSICK 这听起来有些像在旧时代里,IBM因为具有磁盘分配上的限制,因此就提供给用户一个工具,可以将一系列文件放在一块,然后为它生成一个内容表格。

QUINLAN 是的。对于我们来说,每个应用程序都需要或多或少的这样去做。在某些应用程序里这可能很简单。对于其他的一些应用程序,文件数问题可能更急切。大多数情况下,对于这种应用程序最简单的解决方案就是不要使用GFS—即使最初看来文件数规模是可以接受的,但是它很快会成为一个问题。在我们开始使用更多的共享cells的时候,我们开始在文件数和存储空间上设置quota。目前为止,人们遇到的大部分限制都是因为文件数quota。与之相比,底层的存储quota很少成为一个问题。

MCKUSICK 为了解决文件数问题,你们采取什么样的长期策略?很明显,如果master仍然需要把所有的元数据存放在内存,即使是采用分布式的master也无法解决这个问题。

QUINLAN 分布式的master肯定会允许增加文件数,增加的数量与你想投入的机器数一致。这肯定是有帮助的。分布式多master模型有一个问题,如果你将所有的东西扩展两个数量级,然后文件平均大小降低到1-MB,这与64-MB的情况仍然是有很大的不同。在1MB的情况下,你会遇到很多需要关注的其他问题。比如如果你要读取10000个10-KB文件,比仅仅读100个1MB文件你需要进行更多的seek操作。

我的直觉是如果你的设计是面向平均1MB大小的文件,那么肯定需要提供比面向平均64MB大小的设计多得多的东西。

MCKUSICK 那么你们做了哪些事情使得GFS可以在1MB文件的情况下工作?

QUINLAN 我们并没有修改现有的GFS设计。我们的计划为1MB文件提供服务的分布式master系统将会是一个全新的设计。我们的目标是每个master可以处理100million级别的文件,可以有数百个master。

MCKUSICK那么,每个master上面肯定不会有所有的数据吧?

QUINLAN 正是如此。

随着Goolge Bigtable最近的出现,一个用于管理结构化数据的分布式存储系统,针对文件数问题的一个潜在解决方案—尽管可能不是最好的一个,但起码是可用的一个。

然而Bigtable的意义远远超过了文件数问题。尤其是,它是设计用于PB级别,跨越成千上万台机器,简化系统机器的添加以及不需要重配置就可以自动化利用这些资源。对于公司来说,使用集中电力,潜在的冗余,大规模部署商品化硬件带来的成本节省,这些都是非常明显的优势。

目前Bigtable已经用于很多Google应用程序。尽管它代表与过去的一种完全不同的系统,但是需要说明的是,Bigtable建立在、运行在GFS之上。而且很多方面的设计与大多数的GFS设计思想是一致的。这样看来,它也是使得GFS在快速和广泛的变化中继续保持活力的一种大的改进。

MCKUSICK 你们现在已经有了Bigtable。在你的角度看是否会将它看做是一个应用程序呢?

QUINLAN 从GFS的角度看,它的确是一个应用程序。但是很明显,它更是一种基础架构。

MCKUSICK 不知这样的理解是否正确:Bigtable其实是一种轻量级的关系型数据库。

QUINLAN 它并不是一个真正的关系数据库。我是说,我们没有提供SQL,实际上它也不支持像join这样的操作。Bigtable实际上是一个允许你维护大量key-value对及其schema的一个结构化的存储系统。

MCKUSICK 实际中Bigtable的客户端都有哪些呢?

QUINLAN Bigtable的使用还在增长中,目前它已用于爬虫和索引系统,同时还被很多client-facing的应用程序所使用。事实上,现在已经有成堆的Bigtable client。基本上,具有任何大量小数据项的app都倾向于使用Bigtable,尤其是在具有相当结构化的数据时。

MCKUSICK 我想我这里真正想提的问题是:Bigtable是否单纯为了给应用程序提供一种处理小文件问题的尝试而提出的呢,主要方法就是通过将一堆小的东西聚合在一块?

QUINLAN 这肯定可以算作Bigtable的一个应用场景,但是它实际上是为了解决更通用的一类问题。如果你以那种方式使用Bigtable—即,作为一种解决文件数问题的方式—那么你肯定不会充分利用Bigtable提供的功能。Bigtable实际上并不是解决这种问题的一个理想方案,因为它可能给你的操作引入很多额外资源开销。而且,它的垃圾回收策略也不是侵入性的,这就无法最有效的利用你的空间。我想说的是,那些使用Bigtable单纯来解决文件数问题的人们可能并不会感到高兴,但是毫无疑问这是解决这个问题的一个方式。

MCKUSICK 根据我的了解,看起来这个想法只有两种基本的数据结构:logs 和SSTables(sorted string tables)。因此,我猜SSTables肯定是用来处理key-value对及排序的,这与Bigtable有何区别?

QUINLAN 主要的区别是SSTables是不可变的,而Bigtable提供了可变的key value存储。Bigtable本身实际上是建立在logs 和SSTables之上。初始时,它会将输入数据存入一个事务日志文件,之后它会被compacted成一系列的SSTables,随着时间的进行SSTables也会被compacted。这让我想起了log-structure文件系统。不管怎样,如你所看到的,logs 和SSTables确实是我们对我们大部分数据进行结构化的底层数据结构。我们使用log文件记录变更操作。一旦到达一定数量,就可以对它们排序,把它们放入一个具有索引的结构里。

尽管GFS并没有提供一个Posix接口,它仍然有一个漂亮的通用文件系统接口,因此人们可以自由的存储他们喜欢的任意数据。只是,随着时间的推移,我们的大多数用户最后只需要使用这样两种数据结构。我们也有一个称为protocol buffer的东西,是我们的数据描述语言。在两种数据结构中的大部分的数据都会是protocol buffer的格式。

同时它也提供了压缩和checksums。即使有些人可能想重新实现这些东西,大部分的人只是直接使用这两个基本构建块有可以了。

因为GFS最初是为爬虫和索引系统设计的,吞吐率意味着一切。实际上,原始论文也明确指出了这一点。

但是Google也开发了很多面向用户的服务,对于它们来说,大部分都不符合上述情况。这使得GFS的单master设计的缺点更加迫切。一个单点失败对于面向批处理的应用来说可能不是一个灾难,但是对于延时敏感的应用来说肯定是不可忍受的,比如视频服务。即使后来增加了自动故障恢复的能力,但是服务仍可能长达1分钟的时间不可用。

当然这个对于GFS的挑战已经通过将延时敏感的应用程序建立在另一个设计在完全不同的优先级集合的文件系统上得到了解决。

n

n

MCKUSICK 文档里说的很明确,GFS设计的最初的重点在批处理效率而不是低延迟。但是现在它已经引起了一些问题,尤其是在处理视频服务这样的东西时。你们是怎么解决这个问题的呢?

QUINLAN GFS设计模型起初只是关注吞吐率,并没有关注应该达到怎样的延迟。举一个具体的例子,比如你准备写文件,它将会被写成一式三份的形式—意味着你实际上需要写到三个chunkserver上。如果其中一个死了或者很长时间内一直不稳定,GFS master会发现这个问题,并引发一个称为pullchunk的过程,它会再复制一份chunk出来。使得你能达到三个copy,之后系统才会将控制权交给客户端,然后继续写。

当我们执行一个pullchunk时,可能会限制在5-10MB/s的速度上。这样对于64MB,就可能需要花费10秒钟。还有很多其他的情况会花掉10秒钟到一分钟,这对于批处理类型没有问题。比如你正在进行一个MapReduce 任务,你会觉得对于一个几小时的任务来说,几分钟没什么影响。但是,如果你正在使用Gmail,然后你尝试写入一个代表用户行为的变更,然后被卡住了一分钟,这的确会很糟糕。

起初,GFS没有提供自动的故障恢复。都是一个手动的过程。尽管这不经常发生,但是一旦发生,这个GFS cell就可能down掉一个小时。尽管我们最初的master故障恢复可能需要分钟级的时间,经过这些年后,现在大概需要数十秒的级别。

MCKUSICK 然而,对于面向用户的应用来说,这仍然是不可接受的。

QUINLAN 是的。当我们提供了故障恢复和错误恢复后,对于批处理的情况可能可接受的了,但是站在面向用户的应用程序的角度看,这些仍然是不行的。这里存在的另一个问题是,我们为提高吞吐率所进行的优化,将数千个操作放到队列中一块处理。这提高了吞吐率,但是却不利于延迟。光在等待队列中的等待时间就可能达到数秒钟。

现在,我们的使用场景已经从基于MapReduce的世界更多的转移到依赖于诸如Bigtable这样的交互式世界中。Gmail是一个很明显的例子。视频的情况可能并不是那么糟糕,因为当你对数据进行流式传输的时候,意味着你可以进行缓冲。但是将一个交互式数据库建立在一个起初用于面向批处理操作的文件系统上,本身是一件很痛苦的事情。

MCKUSICK 你们又是怎样处理这些情况的呢?

QUINLAN 在GFS内部,我们会进行一定程度的改进,但主要是通过设计应用程序来处理遇到的问题。以Bigtable为例,Bigtable的事务日志实际上是将事务日志化的最大瓶颈,我们通过打开两个文件进行日志写入,之后再归并它们来解决这个问题。我们倾向于设计具有类似功能的应用程序—让它们自己来隐藏延迟问题,因为系统底层并不擅长处理这种问题。

Gmail的开发者们使用了一个多宿主模型,当你的Gamil账号所做的其中一个实例挂掉了,可以简单地通过转到另一个数据中心解决这个问题。实际上,这主要是用来保证可用性,其中一部分的原因也是为了隐藏GFS的问题。

MCKUSICK 我觉得,通过使用一个分布式master文件系统,一定可以解决其中某些延迟问题。

QUINLAN 这的确是我们的一个设计目标。而且,Bigtable本身是一个失败感知迅速的系统,能够对失败迅速作出反应。使用它作为我们的元数据存储系统可以帮助解决一些延时问题。

GFS做出了很多与原始文件系统不同的设计选择。对于一致性所采用的策略是其中非常明显的一个。GFS的工程师团队选择了一个与传统文件系统相比,相对松的一致性保证。因为GFS主要是作为一个append-only系统使用,而不是一个overwriting系统。

MCKUSICK 我们来讨论下一致性吧。问题看起来好像是,要让所有的东西全部写入到所有副本想必要花大量的时间。我想在继续之前,你先说一下关于GFS必须要确保所有的都已经全部被写入后才能继续进行,这一点所带来的影响吧。

QUINLAN 是的。

MCKUSICK 如果这样的话,你们是怎么处理不一致的情况呢?

QUINLAN 客户端失败可能会把事情搞地很糟糕。基本上,GFS的模型里,客户端仅仅是不断的推送写操作直到它成功为止。如果客户端在操作中crash掉,就会留下一些不确定的状态。

最初,这被认为是没有问题的,但是随着时间的推移,我们不断的压缩不一致性可以被容忍的窗口,然后不断的去降低这种不一致。只要数据处于不一致的状态,你都可能得到同一个文件的不同大小。这会导致一些问题。我们不得不在这些情况下提供一些检查文件数据一致性的接口。我们还有一个称为RecordAppend的东西,是设计用来允许多个writer并发向一个文件append的。在这里,一致性设计的很松。现在看来,这带来了比我们想象的多的痛苦。

MCKUSICK 为什么是loose的呢?如果主副本为每次写操作选择好在哪个offset开始写,然后保证这一定会发生,不太清楚不一致性是如何产生的。

QUINLAN 当主副本重试时就会产生。它会选择一个offset,也会执行写操作,但是其中的某个写操作可能不会被实际写入。这样这个主副本就可能已经变化了,此时它可能会选出一个不同的offset。RecordAppend也不提供任何replay保护。在一个文件里你可能得到某个数据多次。

甚至还有些情况,你可能会以不同的顺序得到数据。比如数据可能在某个chunk副本中重复出现了多次,但是其他的副本里可能没有。如果你正在读取文件,那么读取多次就可能以多种方式得到数据。在记录级别上,你读到的记录顺序依赖于你刚好读取到哪个chunk 副本。

MCKUSICK 这是by design的吗?

QUINLAN 当时看起来这是个好主意。但是现在看来,我认为这种一致性带来的痛苦也比它带来的好处要多。这与人们关于文件系统的期望不同,通常会让人感到很吃惊。

MCKUSICK 现在看的话,怎么处理这种不同?

QUINLAN 我认为让一个文件只有一个写者会更有意义。

MCKUSICK 恩,但是当有多个人想append某个日志时该怎么办呢?

QUINLAN 可以用一个进程将这些写操作进行串行化,来保证各副本的一致性。

MCKUSICK 而且当你想对一个chunk进行snapshot的时候也会有这个问题。另外,还有一些情况比如你有时想替换一个副本,或者当chunkserver down掉的时候,需要替换它的文件。

QUINLAN 事实上,这里有两种情况。第一个,如你所说,是恢复机制,肯定会引入关于文件副本的拷贝。在GFS里使用的方式是,我们撤销它上面的锁这样客户端就不能再去写它,当然这会引起一些延迟方面的问题。

还有另一个问题,是为了支持GFS的snapshot。GFS具有你所能想象的最通用的snapshot能力。你可以对任何目录进行snapshot,同时与生成的拷贝是完全等价的。它们会共享未更新的数据。因此与大多数人关于snapshot的想法相比,它更多的是clone的概念。它很有趣,但是也带来了一些困难—尤其是当你试图创建更多的分布式系统以及文件树的更大的chunks进行snapshot时。

我觉得有趣的是snapshot也很少被用到尽管它是一个很强大的feature。从文件系统的角度看,它实际上提供了一种很漂亮的功能,但是将snapshot引入文件系统,我想你也知道,实际上是很痛苦的。

MCKUSICK 我知道,我也这样做过。的确是让人难以忍受的—尤其是在一个overwriting系统中。

QUINLAN 是啊。无法否认的是,以实现的角度看,很难去创建真正的snapshot。然而,看起来在这种情况下,尽力而为是一个正确的决定。同样地,它也是一个与我们早期所做的其他决定有趣的对比。

不管怎样,即使是在近十年后,这个关于GFS的报告看起来依然是很有意义的。虽然存在一些问题和缺点,但是毫无疑问的是GFS在Google的成功中起到了重要作用。但是,GFS目前也面临着很多挑战。比如,在一个初始设计于批处理系统吞吐率的系统基础之上,去支持日益增长的面向用户的延时敏感的应用,其中的尴尬和问题也是很明显的。Bigtable的出现对此提供了一些帮助。然而,实际上Bigtable也并没有解决DFS的所有问题。它只是减轻了系统的单master设计的瓶颈限制。因为这样那样的原因,Google的工程师在过去的两年中一直在为实现一个新的分布式文件系统而努力,通过它来充分利用Bigtable的优势,去解决那些对于GFS来说很难解决的问题。

现在看来,为了保证GFS持续提供服务,在未来的时间里它将会依旧持续地演化。

Posted in 搜索与分布式 | Leave a comment

五个改善你服务器日志的技术

duke_log

最近我们看到各种各样新的工具,能够帮助你搞定日志。开源的项目如Scribe和LogStash,在线的工具如Splunk,托管的服务如Sumologic和PaperTrail。这些工具可以帮你减少大量日志数据。

但是有一个东西它们都无法帮到你,它们都依赖你实际放入日志中的数据。获得更多、更高质量数据的任务就落在你身上了。所以,在关键时刻你需要调试部分代码和丢失的日志数据,你可能要取消晚饭了。

为了减少以上情况发生的次数,我要给你分享5件事情,当你在生产环境使用日志的时候你必须紧记在心:

1. 你好,我(线程)的名字是

和Ringo一样,线程的名称是java中最被低估的方法之一。原因是它主要是描述性的属性。那又怎么样呢?我们让它们的名称变得有意义。

线程的名称在多线程日志记录中扮演重要角色。许多日志框架会记录当前调用日志记录的线程名称。可悲的是,它们大部分类似 “http-nio-8080-exec-3″,这个是线程池或者容器给它们取的。

出于某种原因,我不止一次听到对线程名称是不可变的误解。它们是可变的。线程名称是你日志中主要标记,你必须确保正确地使用它。这就意味着给线程取的名称必须结合上下文,例如servlet的名称或者任务此刻的意义,还有一些动态的上下文环境,例如一个用户或消息ID。

因此,代码的入口处应该像下面这样:

Thread.currentThread().setName(ProcessTask.class.getName() + “: “+ message.getID);

一个更高级的写法是加载一个线程本地变量到当前线程中,并且配置一个自定义日志(appender),自动把这个变量加入到每一条日志记录中。

当多线程写服务器日志时,并且你需要关注某一个线程的时候,这是非常有用的。如果你的程序是分布式/面向服务的环境,你等会看到它的另一个好处。

2. 分布式标示

在面向服务或者消息驱动的架构中,一个任务的执行很可能要跨越多个机器。当这样的任务执行失败的时候,机器之间的连接点和它们的状态是搞明白到底发生了什么的关键。很多日志分析工具可以帮你进行日志归类,前提是你日志中带有它们可以用来做分类的唯一ID。(校对注:当A应用调用B应用接口时,而B应用的接口实现又需要调用应用C的接口时,一旦报错很难定位这个请求到底是在调用哪个应用时报错的?所以就使用一个唯一ID把这个请求链路串起来。)

从设计的角度来看,这意味着每一个操作进入到你的系统中都应该有一个唯一的ID,用这个ID直到它执行完成。注意,那些持久化标示符,例如用户的ID在这里可能不是很好的选择,因为一个用户可能有多个操作发生在同一个日志中,这会使得隔离出一个特定的操作流变得更难。UUID在这边是一个很好的选择,这个值可以被作为线程的名称或者作为一个TLS-线程本地存储。

3. 不要在循环中记录日志

你经常会看到一小段代码运行在一个紧凑的循环中,并且执行一个日志操作。潜在的假设是这段代码运行的次数是有限的。

当一切执行顺利的话这样写是可以的。但是当这段代码获得一个意外的输入,循环可能无法跳出,在那种情况下你处理的不仅是一个无限循环(那已经够糟了),你正在处理的代码是无限地写入大量数据到磁盘中或者网络上。

任由其一直写日志,这会使得服务器宕机,在分布式环境中,一整个集群都会挂了。所以可能的话,不要在循环中记录日志。尤其是在捕获错误的时候。让我们来看一个例子,我们在一个while循环中记录异常日志:

void read() {
    while (hasNext()) {
        try {
            readData();
        } catch {Exception e) {
            // this isn’t recommend
            logger.error(“error reading data“, e);
        }
    }
}

如果readData抛异常,并且hasNext方法返回true,最终我们无限记录日志。解决它的一个方法是确保我们不记录所有东西:

void read() {
    int exceptionsThrown = 0;
    while (hasNext()) {
        try {
            readData();
        } catch {Exception e) {
            if (exceptionsThrown < THRESHOLD) {
                logger.error(“error reading data", e);
                exceptionsThrown++;
            } else {
                // Now the error won’t choke the system.
            }
        }
    }
}

另一个方式是把日志从循环中完全移除,并且保存第一个或最后一个异常对象到其他地方记录。

4. 未捕获异常处理者

维斯特洛有绝境长城作为最后防线,你有 Thread.uncaughtExceptionHandler。所以,一定要确保你有使用它们。如果你没有设置这些处理器,你有可能会把异常抛到“野外”,如果发生这样的情况,很难控制日志记录下它们来。

找出你代码中曾经出现过大量错误,并且未被记录的,或者有关它们的记录是少量非状态日志,这是一个极大的错误。

注意,即使有未捕获异常处理器,从表面上看,你不能获得任何抛出异常线程(线程已经终止)中的变量,即便你可以获得线程对象的引用。如果你坚持第一步(给线程命名),你仍然可以通过调用 thread.getName() 方法记录一个有意义的值。

5. 捕获外部调用

无论什么时候你调用一个JDK之外的API,产生异常的几率都会大大增加。包括web service,HTTP,DB,文件系统和其他JNI调用。对待每一个调用都应该认为它会产生异常(很有可能在某一个点会抛异常)。

很多情况下,外部API调用失败的原因是提供的入参是未预知的。把那些输入参数现成的记录在日志中是你修复代码的关键。

这个点上你可能会选择不记录错误日志,但是必须记录抛出的异常,这是对的。在这种情况下,只需要尽可能多的收集传递给调用的相关参数,并且把他们格式化到异常错误信息中。

只需要却表异常被捕获,并且和调用栈一起被高日志级别记录。

try {
    return s3client.generatePresignedUrl(request);
} catch (Exception e) {
    String err = String.format(“Error generating request: %s bucket: %s key: %s. method: %s", request, bucket, path, method);
    log.error(err, e); //you can also throw a nested exception here with err instead.
}

(全文完)

原文链接译文链接,译者:梁海舰,校对:方腾飞

Posted in 大数据 | Leave a comment

Java Memory Model Under The Hood

There are many sources where you can get an idea of what JMM is about, but most of them still leave you with lots of unanswered questions. How does that happens-before thing work? Does using volatile result in caches being dropped? Why do we even need a memory model in the first place?

This article is intended to give the readers a level of understanding which allows them to answer all of these questions. It will consist of two large parts; the first of them being a hardware-level outline of what’s happening, and the second is indulging in some digging around OpenJDK sources and experimenting. Thus, even if you’re not exactly into Java, the first part might still be of interest to you.

The Hardware-Related Stuff

The engineers that create hardware are working hard on optimizing their products ever further, enabling you to get more and more performance units out of your code. However, it does come at a price of counter-intuitive execution scenarios that your code may display when it is run. There are countless hardware details obscured from our view by abstractions. And abstractions tend to get leaky.

Processor Caches

A request to the main memory is an expensive operation, which can take hundreds of nanoseconds to execute, even on modern hardware. The execution time of other operations, however, has grown considerably smaller over the years, unlike the main memory access. This problem is commonly named as the Memory Wall, and the obvious workaround for this is introducing caches. To put it simply, the processor has local copies of the contents of the main memory that it frequently uses. You can read further on different cache structures here, while we shall move on to the problem of keeping the cached values up to date.

Although there is apparently no problem when you have only one execution unit (referred to as processor from now on), things get complicated if you have more than one of those.

How does processor A know that processor B has modified some value, if A has it cached?
Or, more generally, how do you ensure cache coherency?

To maintain a consistent view on the state of the world, processors have to communicate with each other. The rules of such communication are called a cache coherency protocol.

Cache Coherency Protocols

There are numerous different protocols, which vary not only from one hardware manufacturer to another, but also constantly develop within a single vendor’s product line. In spite of all this variety, most of the protocols have lots of common aspects. Which is why we will take a closer look at MESI. It does not give the reader a full overview of all the protocols out there, however. There are some (e.g. Directory Based) protocols that are absolutely different. We are not going to look into them.

In MESI, every cache entry can be in one of the following states:

  • Invalid: the cache does not have such entry
  • Exclusive: the entry resides in this cache only, and has not been modified
  • Modified: the processor has modified a value, but has not yet written it back to the main memory or sent to any other processor
  • Shared: more that one processor has the entry in its cache

Transitions between states occur via sending certain messages that are also a part of the protocol. The exact message types are not quite relevant, so they are omitted in this article. There are many other sources which you can use to gain insight into them. Memory Barriers: a Hardware View for Software Hackers is the one that I would recommend.

It is ironical that deep down, messaging is used to change states concurrently. Problem, Actor Model haters?

MESI Optimizations And The Problems They Introduce

Without going into details, we will say that it takes time for messages to be delivered, which introduces more latency into state switching. It is also important to understand that some state transitions require some special handling, which might stall the processor. These things lead to all sorts of scalability and performance problems.

Store Buffers

If you need to write something to a variable that is Shared in the cache, you have to send an Invalidate message to all its other holders, and wait for them to acknowledge it. The processor is going to be stalled for that duration Which is a sad thing, seeing as the time required for that is typically several orders of magnitude higher than executing a simple instruction needs.

In real life, cache entries do not contain just a single variable. The established unit is a cache line, which usually contains more than one variable, and is typically 64 bytes in size.

It can lead to interesting side effects, e.g. cache contention.

To avoid such a waste of time, Store Buffers are used. The processor places the values which it wants to write to its buffer, and goes on executing things. When all the Invalidate Acknowledge messages are received, the data is finally committed.

One can expect a number of hidden dangers here. The easy one is that a processor may try to read some value that it has placed in the store buffer, but which has not yet been committed. The workaround is called Store Forwarding, which causes the loads to return the value in the store buffer, if it is present.

The second pitfall is that there is no guarantee on the order in which the stores will leave the buffer. Consider the following piece of code:

void executedOnCpu0() {
    value = 10;
    finished = true;
}
void executedOnCpu1() {
    while(!finished);
    assert value == 10;
}

Suppose that when the execution starts, CPU 0 has finished in the Exclusive state, while value is not installed in its cache at all (i.e. is Invalid). In such scenario, value will leave the store buffer considerably later than finished will. It is entirely possible that CPU 1 will then load finished as true, while value will not be equal to 10.

Such changes in the observable behavior are called reorderings. Note that it does not necessarily mean that your instructions’ places have been changed by some malicious (or well-meaning) party.

It just means that some other CPU has observed their results in a different order than what’s written in the program.

Invalidate Queues

Executing an invalidation is not a cheap operation as well, and it costs for the processor applying it. Moreover, it is no surprise that Store Buffers are not infinite, so the processors sometimes have to wait for Invalidate Acknowledge to come. These two can make performance degrade considerably. To counter this, Invalidate Queues have been introduced. Their contract is as follows:

  • For all incoming Invalidate requests, Invalidate Acknowledge messages are immediately sent
  • The Invalidate is not in fact applied, but placed to a special queue, to be executed when convenient
  • The processor will not send any messages on the cache entry in question, until it processes the Invalidate

There, too, are cases when such optimization will lead to counter-intuitive results. We return to our code, and assume that CPU 1 has value in the Exclusive state. Here’s a diagram of a possible execution:

Concurrency is simple and easy, is it not? The problem is in steps (4) — (6). When CPU 1 receives an Invalidate in (4), it queues it without processing. Then CPU 1 gets Read Response in (6), while the corresponding Read has been sent earlier in (2). Despite this, we do not invalidate value, ending up with an assertion that fails. If only operation (N) has executed earlier. But alas, the damn optimization has spoiled everything! On the other hand, it grants us some significant performance boost.

The thing is that hardware engineers cannot know in advance when such an optimization is allowed, and when it is not. Which is why they leave the problem in our capable hands. They also give us a little something, with a note attached to it: “It’s dangerous to go alone! Take this!”

Hardware Memory Model

The Magical Sword that software engineers who are setting out to fight Dragons are given, is not quite a sword. Rather, what the hardware guys have given us are the Rules As Written. They describe which values a processor can observe given the instructions this (or some other) processor has executed. What we could classify as Spells would be the Memory Barriers. For the MESI example of ours, they would be the following:

  • Store Memory Barrier (a.k.a. ST, SMB, smp_wmb) is the instruction that tells the processor to apply all the storesthat are already in the store buffer, before it applies any that come after this instruction
  • Load Memory Barrier (a.k.a. LD, RMB, smp_rmb) is the instruction that tells the processor to apply all theinvalidates that are already in the invalidate queue, before executing any loads

So, these two Spells can prevent the two situations which we have come across earlier. We should use it:

void executedOnCpu0() {
    value = 10;
    storeMemoryBarrier(); // Mighty Spell!
    finished = true;
}
void executedOnCpu1() {
    while(!finished);
    loadMemoryBarrier(); // I am a Wizard!
    assert value == 10;
}

 

 

Yay! We are now safe. Time to write some high-performance and correct concurrent code!

Oh, wait. It doesn’t even compile, says something about missing methods. What a mess.

Write Once @ Run Anywhere

All those cache coherency protocols, memory barriers, dropped caches and whatnot seem to be awfully platform-specific things. Java Developers should not care for those at all. Java Memory Model has no notion of reordering, after all.

If you do not fully understand this last phrase, you should not continue reading this article. A better idea would be to go and learn some JMM instead. A good start would be this FAQ.

But there are reorderings happening on deeper levels of abstractions. Should be interesting to see how JMM maps to the hardware model. Let’s start with a simple class (github):

  1. public class TestSubject {
  2. private volatile boolean finished;
  3. private int value = 0;
  4. void executedOnCpu0() {
  5. value = 10;
  6. finished = true;
  7. }
  8. void executedOnCpu1() {
  9. while(!finished);
  10. assert value == 10;
  11. }
  12. }

There are many venues we could follow to understand what’s going on: the PrintAssembly fun, checking out the interpreter’s doings, asking someone, mysteriously saying that the caches are being dropped, and many more. I have decided to stick with looking at the C1 (a.k.a. the client compiler) of OpenJDK. While the client compiler is barely used in real applications, it is a good choice for educational purposes.

I have used jdk8 at revision 933:4f8fa4724c14. Things may be different in other versions.

If you have never before digged through the sources of OpenJDK (and even if you have, for that matter), it could be hard to find where the things that interest you lie. An easy way to narrow down the search space is getting the name of the bytecode instruction that interests you, and simply look for it. Alright, let’s do that:

$ javac TestSubject.java && javap -c TestSubject
void executedOnCpu0();
  Code:
     0: aload_0          // Push this to the stack
     1: bipush        10 // Push 10 to the stack
     3: putfield      #2 // Assign 10 to the second field(value) of this
     6: aload_0          // Push this to the stack
     7: iconst_1         // Push 1 to the stack
     8: putfield      #3 // Assign 1 to the third field(finished) of this
    11: return

void executedOnCpu1();
  Code:
     0: aload_0          // Push this to the stack
     1: getfield      #3 // Load the third field of this(finished) and push it to the stack
     4: ifne          10 // If the top of the stack is not zero, go to label 10
     7: goto          0  // One more iteration of the loop
    10: getstatic     #4 // Get the static system field $assertionsDisabled:Z
    13: ifne          33 // If the assertions are disabled, go to label 33(the end)
    16: aload_0          // Push this to the stack
    17: getfield      #2 // Load the second field of this(value) and push it to the stack
    20: bipush        10 // Push 10 to the stack
    22: if_icmpeq     33 // If the top two elements of the stack are equal, go to label 33(the end)
    25: new           #5 // Create a new java/lang/AssertionError
    28: dup              // Duplicate the top of the stack
    29: invokespecial #6 // Invoke the constructor (the  method)
    32: athrow           // Throw what we have at the top of the stack (an AssertionError)
    33: return

You should not try to predict the performance (or even low-level behavior) of your program by looking at the bytecode. When the JIT Compiler is through with it, there will not be much similarities left.

We are only doing this because we need to know who the assassins were working for.

There are two things of interest here:

  1. Assertions are disabled by default, as many people tend to forget. Use -ea to enable them.
  2. The names that we were looking for: getfield and putfield.

Ah, the Field brothers. I knew it was them. It is not long before I put them behind bars for good, now.

Down The Rabbit Hole

As we can see, the instructions used for loading and storing are the same for both volatile and plain fields. So, it is a good idea to find where the compiler learns whether a field is volatile or not. Digging around a little, we end up inshare/vm/ci/ciField.hpp. The method of interest is

  1. bool is_volatile () { return flags().is_volatile(); }

So, what we now are tasked with is finding the methods that handle loading and storing of fields and use investigate all the codepaths conditional on the result of invoking the method above. The Client Compiler processes them on the Low-Level Intermediate Representation (LIR) stage, in the file share/vm/c1/c1_LIRGenerator.cpp.

C1 Intermediate Representation

Let’s start with the stores. The method that we are looking into isvoid LIRGenerator::do_StoreField(StoreField* x), and resides at lines 1658:1751. The first remarkable action that we see is

  1. if (is_volatile && os::is_MP()) {
  2. __ membar_release();
  3. }

Cool, a memory barrier! The two underscores are a macro that expand into gen()->lir()->, and the invoked method is defined in share/vm/c1/c1_LIR.hpp:

  1. void membar_release() { append(new LIR_Op0(lir_membar_release)); }

So, what happened is that we have appended one more operation, lir_membar_release, to our representation.

  1. if (is_volatile && !needs_patching) {
  2. volatile_field_store(value.result(), address, info);
  3. }

The invoked method has platform-specific implementations. For x86 (cpu/x86/vm/c1\_LIRGenerator\_x86.cpp), it’s fairly simple: for 64-bit fields, we dabble in some Dark Magics to ensure write atomicity. Because the spec says so. This is a bit outdated, and may be reviewed in Java 9. The last thing that we want to see is one more memory barrier at the very end of the method:

  1. if (is_volatile && os::is_MP()) {
  2. __ membar();
  3. }
  1. void membar() { append(new LIR_Op0(lir_membar)); }

That’s it for the stores.

The loads are just a bit lower in the source code, and do not contain anything principally new. They have the same Dark Magic stuff for the atomicity of long and double fields, and add a lir_membar_acquire after the load is done.

Note that I have deliberately left out some of the things that are going on, e.g. the GC-related instructions.

Memory Barrier Types And Abstraction Levels

By this time, you must be wondering what the release and acquire memory barriers are, for we have not yet introduced them. This is all because the store and load memory barriers which we have seen before are the operations in the MESI model, while we currently reside a couple of abstraction levels above it (or any other Cache Coherency Protocol). At this level, we have different terminology.

Given that we have two kinds of operations, Load and Store, we have four ordered of pairs of them: LoadLoad,LoadStoreStoreLoad and StoreStore. It is therefore very convenient to have four types of memory barriers with the same names.

If we have a XY memory barrier, it means that all X operations that come before the barrier must complete their execution before any Y operation after the barrier starts.

For instance, all Store operations before a StoreStore barrier must complete earlier than any Store operation that comes after the barrier starts. The JSR-133 Cookbook is a good read on the subject.

Some people get confused and think that memory barriers take a variable as an argument, and then prohibit reorderings of the variable stores or loads across threads.

Memory barriers work within one thread only. By combining them in the right way, you can ascertain that when some thread loads the values stored by another thread, it sees a consistent picture. More generally, all the abstractions that JMM goes on about are granted by the correct combination of memory barrers.

Then there are the Acquire and Release semantics. A write operation that has release semantics requires that all the memory operations that come before it are finished before the operation itself starts its execution. The opposite is true for the read-acquire operations.

One can see that a Release Memory Barrier can be implemented as a LoadStore|StoreStore combination, and theAcquire Memory Barrier is a LoadStore|LoadLoad. The StoreLoad is what we have seen above as lir_membar.

Emitting Assembly Code

Now that we have sorted out the IR and its memory barriers, we can get down to the native level. All the emission happens in the share/vm/c1/c1_LIRAssembler.cpp file:

  1. case lir_membar_release:
  2. membar_release();
  3. break;

The memory barriers are platform-specific, so for x86 we are looking into the cpu/x86/vm/c1_LIRAssembler_x86.cppfile. Seeing as x86 is an architecture with a rather strict memory model, most of the memory barriers are no-ops.

  1. void LIR_Assembler::membar_acquire() {
  2. // No x86 machines currently require load fences
  3. // __ load_fence();
  4. }
  5. void LIR_Assembler::membar_release() {
  6. // No x86 machines currently require store fences
  7. // __ store_fence();
  8. }

Not all of them, however:

  1. void LIR_Assembler::membar() {
  2. // QQQ sparc TSO uses this,
  3. __ membar( Assembler::Membar_mask_bits(Assembler::StoreLoad));
  4. }

(which we follow into cpu/x86/vm/assembler_x86.hpp)

  1. // Serializes memory and blows flags
  2. void membar(Membar_mask_bits order_constraint) {
  3. if (os::is_MP()) {
  4. // We only have to handle StoreLoad
  5. if (order_constraint & StoreLoad) {
  6. // All usable chips support “locked” instructions which suffice
  7. // as barriers, and are much faster than the alternative of
  8. // using cpuid instruction. We use here a locked add [esp],0.
  9. // This is conveniently otherwise a no-op except for blowing
  10. // flags.
  11. // Any change to this code may need to revisit other places in
  12. // the code where this idiom is used, in particular the
  13. // orderAccess code.
  14. lock();
  15. addl(Address(rsp, 0), 0);// Assert the lock# signal here
  16. }
  17. }
  18. }

So, for every volatile write we have to use the relatively expensive StoreLoad barrier in the form oflock addl $0x0,(%rsp). It forces us to execute all the pending stores, and effectively ensures that other threads see the fresh values quickly. And for volatile read, we emit no additional barriers. One should not think that volatile reads are as cheap as regular reads are, however.

It should be clear that while the barriers may emit no assembly code, they are still there in the IR. If they were ignored by the components that can modify the code (say, the compiler), there would be bugs like this one.

Sanity Checks

While making up theories by looking at the sources of OpenJDK is all nice and good, all the real scientists go out there and test their theories. Let us not get too out of the loop and try it as well.

Java Concurrency Stress Fun

The first thing we want to check is that things will actually get bad if we remove volatile from our code. The problem with demonstrating such a “reordering” is that the prior probability of it happening is fairly low. And on some architectures, the HMM prohibits it altogether. So, we have to rely on the compiler, and also try it a lot of times.

The good thing is that we have no need to invent the wheel, as there’s the jcstress tool that executes the code lots of times and keeps an aggregated track of the outcomes. It also very conveniently does all the dirty work for us, including the dirty work we did not even suspect we had to do.

Moreover, jcstress already has the very test that we need:

  1. static class State {
  2. int x;
  3. int y; // acq/rel var
  4. }
  5. @Override
  6. public void actor1(State s, IntResult2 r) {
  7. s.x = 1;
  8. s.x = 2;
  9. s.y = 1;
  10. s.x = 3;
  11. }
  12. @Override
  13. public void actor2(State s, IntResult2 r) {
  14. r.r1 = s.y;
  15. r.r2 = s.x;
  16. }

We have one thread executing stores, and another thread doing reads, and then reporting the observed states. The framework aggregates the results for us, and then matches it against some certain rules. We are interested in two possible observations made by the second thread: [1, 0] and [1, 1]. In these two cases, it has loaded y == 1, but has either failed to see any writes to x, or the loaded version was not the most recent one at the time y was written. According to our theory, such events should happen without the volatile modifier. Let’s see:

$ java -jar tests-all/target/jcstress.jar -v -t ".*UnfencedAcquireReleaseTest.*"
...

Observed state  Occurrence      Expectation       Interpretation
----------------------------------------------------------------------------------------------------------------
 [0, 0]          32725135        ACCEPTABLE       Before observing releasing write to, any value is OK for $x.
 [0, 1]             15           ACCEPTABLE       Before observing releasing write to, any value is OK for $x.
 [0, 2]             36           ACCEPTABLE       Before observing releasing write to, any value is OK for $x.
 [0, 3]           10902          ACCEPTABLE       Before observing releasing write to, any value is OK for $x.
 [1, 0]           65960    ACCEPTABLE_INTERESTING Can read the default or old value for $x after $y is observed.
 [1, 3]          50929785        ACCEPTABLE       Can see a released value of $x if $y is observed.
 [1, 2]             7            ACCEPTABLE       Can see a released value of $x if $y is observed.

So, in 65960 cases out 83731840 (≈ 0.07%) the second thread has observed y == 1 && x == 0, which confirms that the reorderings can indeed happen.

PrintAssembly Fun

The second thing we want to check is if we have correctly predicted the generated assembly code. So, we add lots of invocations of the required code, disable inlining for easier result interpretation, enable assertions and run in the client VM:

$ java -client -ea -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:MaxInlineSize=0 TestSubject
...
  # {method} 'executedOnCpu0' '()V' in 'TestSubject'
...
  0x00007f6d1d07405c: movl   $0xa,0xc(%rsi)
  0x00007f6d1d074063: movb   $0x1,0x10(%rsi)
  0x00007f6d1d074067: lock addl $0x0,(%rsp)     ;*putfield finished
                                                ; - TestSubject::executedOnCpu0@8 (line 15)
...
  # {method} 'executedOnCpu1' '()V' in 'TestSubject'
...
  0x00007f6d1d061126: movzbl 0x10(%rbx),%r11d   ;*getfield finished
                                                ; - TestSubject::executedOnCpu1@1 (line 19)
  0x00007f6d1d06112b: test   %r11d,%r11d
...

Yay, just as planned! Means that it’s time to go wrap up.

Let me remind you the questions that you should be able to answer by now:

  • How does that happens-before thing work?
  • Does using volatile result in caches being dropped?
  • Why do we even need a memory model in the first place?

Do you think you can answer these? Welcome to the comments in any case!

P.S. This is the translation of my earlier blog entry in Russian, which has been reviewed by Aleksey Shipilёv, so thank him.
 

Posted in 猿の生活 | Leave a comment

The Google File System中文版

The Google File System中文版
译者:alex

摘要

我们设计并实现了Google GFS文件系统,一个面向大规模数据密集型应用的、可伸缩的分布式文件系统。GFS虽然运行在廉价的普遍硬件设备上,但是它依然了提供灾难冗余的能力,为大量客户机提供了高性能的服务。

虽然GFS的设计目标与许多传统的分布式文件系统有很多相同之处,但是,我们的设计还是以我们对自己的应用的负载情况和技术环境的分析为基础 的,不管现在还是将来,GFS和早期的分布式文件系统的设想都有明显的不同。所以我们重新审视了传统文件系统在设计上的折衷选择,衍生出了完全不同的设计 思路。

GFS完全满足了我们对存储的需求。GFS作为存储平台已经被广泛的部署在Google内部,存储我们的服务产生和处理的数据,同时还用于那些 需要大规模数据集的研究和开发工作。目前为止,最大的一个集群利用数千台机器的数千个硬盘,提供了数百TB的存储空间,同时为数百个客户机服务。

在本论文中,我们展示了能够支持分布式应用的文件系统接口的扩展,讨论我们设计的许多方面,最后列出了小规模性能测试以及真实生产系统中性能相关数据。

分类和主题描述

D [4]: 3—D分布文件系统

常用术语

设计,可靠性,性能,测量

关键词

容错,可伸缩性,数据存储,集群存储

1. 简介

为了满足Google迅速增长的数据处理需求,我们设计并实现了Google文件系统(Google File System – GFS)。GFS与传统的分布式文件系统有着很多相同的设计目标,比如,性能、可伸缩性、可靠性以及可用性。但是,我们的设计还基于我们对我们自己的应用 的负载情况和技术环境的观察的影响,不管现在还是将来,GFS和早期文件系统的假设都有明显的不同。所以我们重新审视了传统文件系统在设计上的折衷选择, 衍生出了完全不同的设计思路。

首先,组件失效被认为是常态事件,而不是意外事件。GFS包括几百甚至几千台普通的廉价设备组装的存储机器,同时被相当数量的客户机访问。 GFS组件的数量和质量导致在事实上,任何给定时间内都有可能发生某些组件无法工作,某些组件无法从它们目前的失效状态中恢复。我们遇到过各种各样的问 题,比如应用程序bug、操作系统的bug、人为失误,甚至还有硬盘、内存、连接器、网络以及电源失效等造成的问题。所以,持续的监控、错误侦测、灾难冗 余以及自动恢复的机制必须集成在GFS中

其次,以通常的标准衡量,我们的文件非常巨大。数GB的文件非常普遍。每个文件通常都包含许多应用程序对象,比如web文档。当我们经常需要处 理快速增长的、并且由数亿个对象构成的、数以TB的数据集时,采用管理数亿个KB大小的小文件的方式是非常不明智的,尽管有些文件系统支持这样的管理方 式。因此,设计的假设条件和参数,比如I/O操作和Block的尺寸都需要重新考虑

第三,绝大部分文件的修改是采用在文件尾部追加数据,而不是覆盖原有数据的方式。对文件的随机写入操作在实际中几乎不存在。一旦写完之后,对文 件的操作就只有读,而且通常是按顺序读。大量的数据符合这些特性,比如:数据分析程序扫描的超大的数据集;正在运行的应用程序生成的连续的数据流;存档的 数据;由一台机器生成、另外一台机器处理的中间数据,这些中间数据的处理可能是同时进行的、也可能是后续才处理的。对于这种针对海量文件的访问模式,客户 端对数据块缓存是没有意义的,数据的追加操作是性能优化和原子性保证的主要考量因素。

第四,应用程序和文件系统API的协同设计提高了整个系统的灵活性。比如,我们放松了对GFS一致性模型的要求,这样就减轻了文件系统对应用程 序的苛刻要求,大大简化了GFS的设计。我们引入了原子性的记录追加操作,从而保证多个客户端能够同时进行追加操作,不需要额外的同步操作来保证数据的一 致性。本文后面还有对这些问题的细节的详细讨论。

Google已经针对不同的应用部署了多套GFS集群。最大的一个集群拥有超过1000个存储节点,超过300TB的硬盘空间,被不同机器上的数百个客户端连续不断的频繁访问。

2.设计概述

2.1设计预期

在设计满足我们需求的文件系统时候,我们的设计目标既有机会、又有挑战。之前我们已经提到了一些需要关注的关键点,这里我们将设计的预期目标的细节展开讨论。
系统由许多廉价的普通组件组成,组件失效是一种常态。系统必须持续监控自身的状态,它必须将组件失效作为一种常态,能够迅速地侦测、冗余并恢复失效的组件。
系统存储一定数量的大文件。我们预期会有几百万文件,文件的大小通常在100MB或者以上。数个GB大小的文件也是普遍存在,并且要能够被有效的管理。系统也必须支持小文件,但是不需要针对小文件做专门的优化。
系统的工作负载主要由两种读操作组成:大规模的流式读取和小规模的随机读取。大规模的流式读取通常一次读取数百KB的数据,更常见的是一次读取 1MB甚至更多的数据。来自同一个客户机的连续操作通常是读取同一个文件中连续的一个区域。小规模的随机读取通常是在文件某个随机的位置读取几个KB数 据。如果应用程序对性能非常关注,通常的做法是把小规模的随机读取操作合并并排序,之后按顺序批量读取,这样就避免了在文件中前后来回的移动读取位置。
系统的工作负载还包括许多大规模的、顺序的、数据追加方式的写操作。一般情况下,每次写入的数据的大小和大规模读类似。数据一旦被写入后,文件就很少会被修改了。系统支持小规模的随机位置写入操作,但是可能效率不彰。
系统必须高效的、行为定义明确的(alex注:well-defined)实现多 客户端并行追加数据到同一个文件里的语意。我们的文件通常被用于”生产者-消费者“队列,或者其它多路文件合并操作。通常会有数百个生产者,每个生产者进 程运行在一台机器上,同时对一个文件进行追加操作。使用最小的同步开销来实现的原子的多路追加数据操作是必不可少的。文件可以在稍后读取,或者是消费者在 追加的操作的同时读取文件。
高性能的稳定网络带宽远比低延迟重要。我们的目标程序绝大部分要求能够高速率的、大批量的处理数据,极少有程序对单一的读写操作有严格的响应时间要求。

2.2 接口

GFS提供了一套类似传统文件系统的API接口函数,虽然并不是严格按照POSIX等标准API的形式实现的。文件以分层目录的形式组织,用路径名来标识。我们支持常用的操作,如创建新文件、删除文件、打开文件、关闭文件、读和写文件。

另外,GFS提供了快照和记录追加操作。快照以很低的成本创建一个文件或者目录树的拷贝。记录追加操作允许多个客户端同时对一个文件进行数据追 加操作,同时保证每个客户端的追加操作都是原子性的。这对于实现多路结果合并,以及”生产者-消费者”队列非常有用,多个客户端可以在不需要额外的同步锁 定的情况下,同时对一个文件追加数据。我们发现这些类型的文件对于构建大型分布应用是非常重要的。快照和记录追加操作将在3.4和3.3节分别讨论。

2.3 架构


一个GFS集群包含一个单独的Master节点(alex注:这里的一个 单独的Master节点的含义是GFS系统中只存在一个逻辑上的Master组件。后面我们还会提到Master节点复制,因此,为了理解方便,我们把 Master节点视为一个逻辑上的概念,一个逻辑的Master节点包括两台物理主机,即两台Master服务器)、多台 Chunk服务器,并且同时被多个客户端访问,如图1所示。所有的这些机器通常都是普通的Linux机器,运行着用户级别(user-level)的服务 进程。我们可以很容易的把Chunk服务器和客户端都放在同一台机器上,前提是机器资源允许,并且我们能够接受不可靠的应用程序代码带来的稳定性降低的风 险。

GFS存储的文件都被分割成固定大小的Chunk。在Chunk创建的时候,Master服务器会给每个Chunk分配一个不变的、全球唯一的 64位的Chunk标识。Chunk服务器把Chunk以linux文件的形式保存在本地硬盘上,并且根据指定的Chunk标识和字节范围来读写块数据。 出于可靠性的考虑,每个块都会复制到多个块服务器上。缺省情况下,我们使用3个存储复制节点,不过用户可以为不同的文件命名空间设定不同的复制级别。

Master节点管理所有的文件系统元数据。这些元数据包括名字空间、访问控制信息、文件和Chunk的映射信息、以及当前Chunk的位置信息。Master节点还管理着系统范围内的活动,比如,Chunk租用管理(alex注:BDB也有关于lease的描述,不知道是否相同)、孤儿Chunk(alex注:orphaned chunks)的回收、以及Chunk在Chunk服务器之间的迁移。Master节点使用心跳信息周期地和每个Chunk服务器通讯,发送指令到各个Chunk服务器并接收Chunk服务器的状态信息。

GFS客户端代码以库的形式被链接到客户程序里。客户端代码实现了GFS文件系统的API接口函数、应用程序与Master节点和Chunk服 务器通讯、以及对数据进行读写操作。客户端和Master节点的通信只获取元数据,所有的数据操作都是由客户端直接和Chunk服务器进行交互的。我们不 提供POSIX标准的API的功能,因此,GFS API调用不需要深入到Linux vnode级别。

无论是客户端还是Chunk服务器都不需要缓存文件数据。户端缓存数据几乎没有什么用处,因为大部分程序要么以流的方式读取一个巨大文件,要 么工作集太大根本无法被缓存。无需考虑缓存相关的问题也简化了客户端和整个系统的设计和实现。(不过,客户端会缓存元数据。)Chunk服务器不需要缓存 文件数据的原因是,Chunk以本地文件的方式保存,Linux操作系统的文件系统缓存会把经常访问的数据缓存在内存中。

2.4 单一Master节点

单一的Master节点的策略大大简化了我们的设计。单一的Master节点可以通过全局的信息精确定位Chunk的位置以及进行复制决策。另 外,我们必须减少对Master节点的读写,避免Master节点成为系统的瓶颈。客户端并不通过Master节点读写文件数据。反之,客户端向 Master节点询问它应该联系的Chunk服务器。客户端将这些元数据信息缓存一段时间,后续的操作将直接和Chunk服务器进行数据读写操作。

我们利用图1解释一下一次简单读取的流程。首先,客户端把文件名和程序指定的字节偏移,根据固定的Chunk大小,转换成文件的Chunk索 引。然后,它把文件名和Chunk索引发送给Master节点。Master节点将相应的Chunk标识和副本的位置信息发还给客户端。客户端用文件名和 Chunk索引作为key缓存这些信息。

之后客户端发送请求到其中的一个副本处,一般会选择最近的。请求信息包含了Chunk的标识和字节范围。在对这个Chunk的后续读取操作中, 客户端不必再和Master节点通讯了,除非缓存的元数据信息过期或者文件被重新打开。实际上,客户端通常会在一次请求中查询多个Chunk信 息,Master节点的回应也可能包含了紧跟着这些被请求的Chunk后面的Chunk的信息。在实际应用中,这些额外的信息在没有任何代价的情况下,避 免了客户端和Master节点未来可能会发生的几次通讯。

2.5 Chunk尺寸

Chunk的大小是关键的设计参数之一。我们选择了64MB,这个尺寸远远大于一般文件系统的Block size。每个Chunk的副本都以普通Linux文件的形式保存在Chunk服务器上,只有在需要的时候才扩大。惰性空间分配策略避免了因内部碎片造成 的空间浪费,内部碎片或许是对选择这么大的Chunk尺寸最具争议一点。

选择较大的Chunk尺寸有几个重要的优点。首先,它减少了客户端和Master节点通讯的需求,因为只需要一次和Mater节点的通信就可以 获取Chunk的位置信息,之后就可以对同一个Chunk进行多次的读写操作。这种方式对降低我们的工作负载来说效果显著,因为我们的应用程序通常是连续 读写大文件。即使是小规模的随机读取,采用较大的Chunk尺寸也带来明显的好处,客户端可以轻松的缓存一个数TB的工作数据集所有的Chunk位置信 息。其次,采用较大的Chunk尺寸,客户端能够对一个块进行多次操作,这样就可以通过与Chunk服务器保持较长时间的TCP连接来减少网络负载。第 三,选用较大的Chunk尺寸减少了Master节点需要保存的元数据的数量。这就允许我们把元数据全部放在内存中,在2.6.1节我们会讨论元数据全部 放在内存中带来的额外的好处。

另一方面,即使配合惰性空间分配,采用较大的Chunk尺寸也有其缺陷。小文件包含较少的Chunk,甚至只有一个Chunk。当有许多的客户 端对同一个小文件进行多次的访问时,存储这些Chunk的Chunk服务器就会变成热点。在实际应用中,由于我们的程序通常是连续的读取包含多个 Chunk的大文件,热点还不是主要的问题。

然而,当我们第一次把GFS用于批处理队列系统的时候,热点的问题还是产生了:一个可执行文件在GFS上保存为single-chunk文件, 之后这个可执行文件在数百台机器上同时启动。存放这个可执行文件的几个Chunk服务器被数百个客户端的并发请求访问导致系统局部过载。我们通过使用更大 的复制参数来保存可执行文件,以及错开批处理队列系统程序的启动时间的方法解决了这个问题。一个可能的长效解决方案是,在这种的情况下,允许客户端从其它 客户端读取数据。

2.6 元数据

Master服务器(alex注:注意逻辑的Master节点和物理的Master服务器的区别。后续我们谈的是每个Master服务器的行为,如存储、内存等等,因此我们将全部使用物理名称)存 储3种主要类型的元数据,包括:文件和Chunk的命名空间、文件和Chunk的对应关系、每个Chunk副本的存放地点。所有的元数据都保存在 Master服务器的内存中。前两种类型的元数据(命名空间、文件和Chunk的对应关系)同时也会以记录变更日志的方式记录在操作系统的系统日志文件 中,日志文件存储在本地磁盘上,同时日志会被复制到其它的远程Master服务器上。采用保存变更日志的方式,我们能够简单可靠的更新Master服务器 的状态,并且不用担心Master服务器崩溃导致数据不一致的风险。Master服务器不会持久保存Chunk位置信息。Master服务器在启动时,或 者有新的Chunk服务器加入时,向各个Chunk服务器轮询它们所存储的Chunk的信息。

2.6.1 内存中的数据结构

因为元数据保存在内存中,所以Master服务器的操作速度非常快。并且,Master服务器可以在后台简单而高效的周期性扫描自己保存的全部 状态信息。这种周期性的状态扫描也用于实现Chunk垃圾收集、在Chunk服务器失效的时重新复制数据、通过Chunk的迁移实现跨Chunk服务器的 负载均衡以及磁盘使用状况统计等功能。4.3和4.4章节将深入讨论这些行为。

将元数据全部保存在内存中的方法有潜在问题:Chunk的数量以及整个系统的承载能力都受限于Master服务器所拥有的内存大小。但是在实际 应用中,这并不是一个严重的问题。Master服务器只需要不到64个字节的元数据就能够管理一个64MB的Chunk。由于大多数文件都包含多个 Chunk,因此绝大多数Chunk都是满的,除了文件的最后一个Chunk是部分填充的。同样的,每个文件的在命名空间中的数据大小通常在64字节以 下,因为保存的文件名是用前缀压缩算法压缩过的。

即便是需要支持更大的文件系统,为Master服务器增加额外内存的费用是很少的,而通过增加有限的费用,我们就能够把元数据全部保存在内存里,增强了系统的简洁性、可靠性、高性能和灵活性。

2.6.2 Chunk位置信息

Master服务器并不保存持久化保存哪个Chunk服务器存有指定Chunk的副本的信息。Master服务器只是在启动的时候轮询Chunk服 务器以获取这些信息。Master服务器能够保证它持有的信息始终是最新的,因为它控制了所有的Chunk位置的分配,而且通过周期性的心跳信息监控 Chunk服务器的状态
最初设计时,我们试图把Chunk的位置信息持久的保存在Master服务器上,但是后来我们发现在启动的时候轮询Chunk服务器,之后定期轮询 更新的方式更简单。这种设计简化了在有Chunk服务器加入集群、离开集群、更名、失效、以及重启的时候,Master服务器和Chunk服务器数据同步 的问题。在一个拥有数百台服务器的集群中,这类事件会频繁的发生。
可以从另外一个角度去理解这个设计决策:只有Chunk服务器才能最终确定一个Chunk是否在它的硬盘上。我们从没有考虑过在Master服务器 上维护一个这些信息的全局视图,因为Chunk服务器的错误可能会导致Chunk自动消失(比如,硬盘损坏了或者无法访问了),亦或者操作人员可能会重命 名一个Chunk服务器。

2.6.3 操作日志

操作日志包含了关键的元数据变更历史记录。这对GFS非常重要。这不仅仅是因为操作日志是元数据唯一的持久化存储记录,它也作为判断同步操作顺序的逻辑时间基线(alex注:也就是通过逻辑日志的序号作为操作发生的逻辑时间,类似于事务系统中的LSN)。文件和Chunk,连同它们的版本(参考4.5节),都由它们创建的逻辑时间唯一的、永久的标识。
操作日志非常重要,我们必须确保日志文件的完整,确保只有在元数据的变化被持久化后,日志才对客户端是可见的。否则,即使Chunk本身没有出现任 何问题,我们仍有可能丢失整个文件系统,或者丢失客户端最近的操作。所以,我们会把日志复制到多台远程机器,并且只有把相应的日志记录写入到本地以及远程 机器的硬盘后,才会响应客户端的操作请求。Master服务器会收集多个日志记录后批量处理,以减少写入磁盘和复制对系统整体性能的影响。
Master服务器在灾难恢复时,通过重演操作日志把文件系统恢复到最近的状态。为了缩短Master启动的时间,我们必须使日志足够小(alex注:即重演系统操作的日志量尽量的少)。Master服务器在日志增长到一定量时对系统状态做一次Checkpoint(alex注:Checkpoint是一种行为,一种对数据库状态作一次快照的行为),将所有的状态数据写入一个Checkpoint文件(alex注:并删除之前的日志文件)。在灾难恢复的时候,Master服务器就通过从磁盘上读取这个Checkpoint文件,以及重演Checkpoint之后的有限个日志文件就能够恢复系统。Checkpoint文件以压缩B-树形势的数据结构存储,可以直接映射到内存,在用于命名空间查询时无需额外的解析。这大大提高了恢复速度,增强了可用性。

由于创建一个Checkpoint文件需要一定的时间,所以Master服务器的内部状态被组织为一种格式,这种格式要确保在Checkpoint 过程中不会阻塞正在进行的修改操作。Master服务器使用独立的线程切换到新的日志文件和创建新的Checkpoint文件。新的Checkpoint 文件包括切换前所有的修改。对于一个包含数百万个文件的集群,创建一个Checkpoint文件需要1分钟左右的时间。创建完成后,Checkpoint 文件会被写入在本地和远程的硬盘里。
Master服务器恢复只需要最新的Checkpoint文件和后续的日志文件。旧的Checkpoint文件和日志文件可以被删除,但是为了应对灾难性的故障(alex注:catastrophes,数据备份相关文档中经常会遇到这个词,表示一种超出预期范围的灾难性事件),我们通常会多保存一些历史文件。Checkpoint失败不会对正确性产生任何影响,因为恢复功能的代码可以检测并跳过没有完成的Checkpoint文件。

2.7 一致性模型

GFS支持一个宽松的一致性模型,这个模型能够很好的支撑我们的高度分布的应用,同时还保持了相对简单且容易实现的优点。本节我们讨论GFS的一致 性的保障机制,以及对应用程序的意义。我们也着重描述了GFS如何管理这些一致性保障机制,但是实现的细节将在本论文的其它部分讨论。

2.7.1 GFS一致性保障机制

文件命名空间的修改(例如,文件创建)是原子性的。它们仅由Master节点的控制:命名空间锁提供了原子性和正确性(4.1章)的保障;Master节点的操作日志定义了这些操作在全局的顺序(2.6.3章)。

数据修改后文件region(alex注:region这个词用中文非常难以表达,我认为应该是修改操作所涉及的文件中的某个范围)的 状态取决于操作的类型、成功与否、以及是否同步修改。

表1总结了各种操作的结果。如果所有客户端,无论从哪个副本读取,读到的数据都一样,那么我们认为文 件region是“一致的”;如果对文件的数据修改之后,region是一致的,并且客户端能够看到写入操作全部的内容,那么这个region是“已定义 的”。当一个数据修改操作成功执行,并且没有受到同时执行的其它写入操作的干扰,那么影响的region就是已定义的(隐含了一致性):所有的客户端都可 以看到写入的内容。并行修改操作成功完成之后,region处于一致的、未定义的状态:所有的客户端看到同样的数据,但是无法读到任何一次写入操作写入的 数据。通常情况下,文件region内包含了来自多个修改操作的、混杂的数据片段。失败的修改操作导致一个region处于不一致状态(同时也是未定义 的):不同的客户在不同的时间会看到不同的数据。后面我们将描述应用如何区分已定义和未定义的region。应用程序没有必要再去细分未定义region 的不同类型。
数据修改操作分为写入或者记录追加两种。写入操作把数据写在应用程序指定的文件偏移位置上。即使有多个修改操作并行执行时,记录追加操作至少可以把数据原子性的追加到文件中一次,但是偏移位置是由GFS选择的(3.3章)(alex注:这句话有点费解,其含义是所有的追加写入都会成功,但是有可能被执行了多次,而且每次追加的文件偏移量由GFS自己计算)。 (相比而言,通常说的追加操作写的偏移位置是文件的尾部。)GFS返回给客户端一个偏移量,表示了包含了写入记录的、已定义的region的起点。另 外,GFS可能会在文件中间插入填充数据或者重复记录。这些数据占据的文件region被认定是不一致的,这些数据通常比用户数据小的多。

经过了一系列的成功的修改操作之后,GFS确保被修改的文件region是已定义的,并且包含最后一次修改操作写入的数据。GFS通过以下措施确保 上述行为:(a) 对Chunk的所有副本的修改操作顺序一致(3.1章),(b)使用Chunk的版本号来检测副本是否因为它所在的Chunk服务器宕机(4.5章)而错 过了修改操作而导致其失效。失效的副本不会再进行任何修改操作,Master服务器也不再返回这个Chunk副本的位置信息给客户端。它们会被垃圾收集系 统尽快回收。
由于Chunk位置信息会被客户端缓存,所以在信息刷新前,客户端有可能从一个失效的副本读取了数据。在缓存的超时时间和文件下一次被打开的时 间之间存在一个时间窗,文件再次被打开后会清除缓存中与该文件有关的所有Chunk位置信息。而且,由于我们的文件大多数都是只进行追加操作的,所以,一 个失效的副本通常返回一个提前结束的Chunk而不是过期的数据。当一个Reader(alex注:本文中将用到两个专有名词,Reader和Writer,分别表示执行GFS读取和写入操作的程序)重新尝试并联络Master服务器时,它就会立刻得到最新的Chunk位置信息。

即使在修改操作成功执行很长时间之后,组件的失效也可能损坏或者删除数据。GFS通过Master服务器和所有Chunk服务器的定期“握手” 来找到失效的Chunk服务器,并且使用Checksum来校验数据是否损坏(5.2章)。一旦发现问题,数据要尽快利用有效的副本进行恢复(4.3 章)。只有当一个Chunk的所有副本在GFS检测到错误并采取应对措施之前全部丢失,这个Chunk才会不可逆转的丢失。在一般情况下GFS的反应时间(alex注:指Master节点检测到错误并采取应对措施)是几分钟。即使在这种情况下,Chunk也只是
可用了,而不是损坏了:应用程序会收到明确的错误信息而不是损坏的数据。

2.7.2 程序的实现

使用GFS的应用程序可以利用一些简单技术实现这个宽松的一致性模型,这些技术也用来实现一些其它的目标功能,包括:尽量采用追加写入而不是覆盖,Checkpoint,自验证的写入操作,自标识的记录。

在实际应用中,我们所有的应用程序对文件的写入操作都是尽量采用数据追加方式,而不是覆盖方式。一种典型的应用,应用程序从头到尾写入数据,生 成了一个文件。写入所有数据之后,应用程序自动将文件改名为一个永久保存的文件名,或者周期性的作Checkpoint,记录成功写入了多少数据。 Checkpoint文件可以包含程序级别的校验和。Readers仅校验并处理上个Checkpoint之后产生的文件region,这些文件 region的状态一定是已定义的。这个方法满足了我们一致性和并发处理的要求。追加写入比随机位置写入更加有效率,对应用程序的失败处理更具有弹性。 Checkpoint可以让Writer以渐进的方式重新开始,并且可以防止Reader处理已经被成功写入,但是从应用程序的角度来看还并未完成的数 据。

我们再来分析另一种典型的应用。许多应用程序并行的追加数据到同一个文件,比如进行结果的合并或者是一个生产者-消费者队列。记录追加方式的 “至少一次追加”的特性保证了Writer的输出。Readers使用下面的方法来处理偶然性的填充数据和重复内容。Writers在每条写入的记录中都 包含了额外的信息,例如Checksum,用来验证它的有效性。Reader可以利用Checksum识别和抛弃额外的填充数据和记录片段。如果应用不能 容忍偶尔的重复内容(比如,如果这些重复数据触发了非幂等操作),可以用记录的唯一标识符来过滤它们,这些唯一标识符通常用于命名程序中处理的实体对象, 例如web文档。这些记录I/O功能(alex注:These functionalities for record I/O)(除了剔除重复数据)都包含在我们的程序共享的库中,并且适用于Google内部的其它的文件接口实现。所以,相同序列的记录,加上一些偶尔出现的重复数据,都被分发到Reader了。

3. 系统交互

我们在设计这个系统时,一个重要的原则是最小化所有操作和Master节点的交互。带着这样的设计理念,我们现在描述一下客户机、Master服务器和Chunk服务器如何进行交互,以实现数据修改操作、原子的记录追加操作以及快照功能。

3.1 租约(lease)和变更顺序

(alex注:lease是数据库中的一个术语)
变更是一个会改变Chunk内容或者元数据的操作,比如写入操作或者记录追加操作。变更操作会在Chunk的所有副本上执行。我们使用租约 (lease)机制来保持多个副本间变更顺序的一致性。Master节点为Chunk的一个副本建立一个租约,我们把这个副本叫做主Chunk。主 Chunk对Chunk的所有更改操作进行序列化。所有的副本都遵从这个序列进行修改操作。因此,修改操作全局的顺序首先由Master节点选择的租约的 顺序决定,然后由租约中主Chunk分配的序列号决定。
设计租约机制的目的是为了最小化Master节点的管理负担。租约的初始超时设置为60秒。不过,只要Chunk被修改了,主Chunk就可以 申请更长的租期,通常会得到Master节点的确认并收到租约延长的时间。这些租约延长请求和批准的信息通常都是附加在Master节点和Chunk服务 器之间的心跳消息中来传递。有时Master节点会试图提前取消租约(例如,Master节点想取消在一个已经被改名的文件上的修改操作)。即使 Master节点和主Chunk失去联系,它仍然可以安全地在旧的租约到期后和另外一个Chunk副本签订新的租约。

在图2中,我们依据步骤编号,展现写入操作的控制流程。

1. 客户机向Master节点询问哪一个Chunk服务器持有当前的租约,以及其它副本的位置。如果没有一个Chunk持有租约,Master节点就选择其中一个副本建立一个租约(这个步骤在图上没有显示)。
2. Master节点将主Chunk的标识符以及其它副本(又称为secondary副本、二级副本)的位置返回给客户机。客户机缓存这些数据以便 后续的操作。只有在主Chunk
3. 可用,或者主Chunk回复信息表明它已不再持有租约的时候,客户机才需要重新跟Master节点联系。
4. 客户机把数据推送到所有的副本上。客户机可以以任意的顺序推送数据。Chunk服务器接收到数据并保存在它的内部LRU缓存中,一直到数据被使 用或者过期交换出去。由于数据流的网络传输负载非常高,通过分离数据流和控制流,我们可以基于网络拓扑情况对数据流进行规划,提高系统性能,而不用去理会 哪个Chunk服务器保存了主Chunk。3.2章节会进一步讨论这点。
5. 当所有的副本都确认接收到了数据,客户机发送写请求到主Chunk服务器。这个请求标识了早前推送到所有副本的数据。主Chunk为接收到的所 有操作分配连续的序列号,这些操作可能来自不同的客户机,序列号保证了操作顺序执行。它以序列号的顺序把操作应用到它自己的本地状态中(alex注:也就是在本地执行这些操作,这句话按字面翻译有点费解,也许应该翻译为“它顺序执行这些操作,并更新自己的状态”)。
6. 主Chunk把写请求传递到所有的二级副本。每个二级副本依照主Chunk分配的序列号以相同的顺序执行这些操作。
7. 所有的二级副本回复主Chunk,它们已经完成了操作。
8. 主Chunk服务器(alex注:即主Chunk所在的Chunk服务器)回 复客户机。任何副本产生的任何错误都会返回给客户机。在出现错误的情况下,写入操作可能在主Chunk和一些二级副本执行成功。(如果操作在主Chunk 上失败了,操作就不会被分配序列号,也不会被传递。)客户端的请求被确认为失败,被修改的region处于不一致的状态。我们的客户机代码通过重复执行失 败的操作来处理这样的错误。在从头开始重复执行之前,客户机会先从步骤(3)到步骤(7)做几次尝试。

如果应用程序一次写入的数据量很大,或者数据跨越了多个Chunk,GFS客户机代码会把它们分成多个写操作。这些操作都遵循前面描述的控制流 程,但是可能会被其它客户机上同时进行的操作打断或者覆盖。因此,共享的文件region的尾部可能包含来自不同客户机的数据片段,尽管如此,由于这些分 解后的写入操作在所有的副本上都以相同的顺序执行完成,Chunk的所有副本都是一致的。这使文件region处于2.7节描述的一致的、但是未定义的状 态。

3.2 数据流

为了提高网络效率,我们采取了把数据流和控制流分开的措施。在控制流从客户机到主Chunk、然后再到所有二级副本的同时,数据以管道的方式, 顺序的沿着一个精心选择的Chunk服务器链推送。我们的目标是充分利用每台机器的带宽,避免网络瓶颈和高延时的连接,最小化推送所有数据的延时。

为了充分利用每台机器的带宽,数据沿着一个Chunk服务器链顺序的推送,而不是以其它拓扑形式分散推送(例如,树型拓扑结构)。线性推送模式下,每台机器所有的出口带宽都用于以最快的速度传输数据,而不是在多个接受者之间分配带宽。

为了尽可能的避免出现网络瓶颈和高延迟的链接(eg,inter-switch最有可能出现类似问题),每台机器都尽量的在网络拓扑中选择一台 还没有接收到数据的、离自己最近的机器作为目标推送数据。假设客户机把数据从Chunk服务器S1推送到S4。它把数据推送到最近的Chunk服务器 S1。S1把数据推送到S2,因为S2和S4中最接近的机器是S2。同样的,S2把数据传递给S3和S4之间更近的机器,依次类推推送下去。我们的网络拓 扑非常简单,通过IP地址就可以计算出节点的“距离”。

最后,我们利用基于TCP连接的、管道式数据推送方式来最小化延迟。Chunk服务器接收到数据后,马上开始向前推送。管道方式的数据推送对我 们帮助很大,因为我们采用全双工的交换网络。接收到数据后立刻向前推送不会降低接收的速度。在没有网络拥塞的情况下,传送B字节的数据到R个副本的理想时 间是 B/T+RL ,T是网络的吞吐量,L是在两台机器数据传输的延迟。通常情况下,我们的网络连接速度是100Mbps(T),L将远小于1ms。因此,1MB的数据在理 想情况下80ms左右就能分发出去。

3.3 原子的记录追加

GFS提供了一种原子的数据追加操作–记录追加。传统方式的写入操作,客户程序会指定数据写入的偏移量。对同一个region的并行写入操作不 是串行的:region尾部可能会包含多个不同客户机写入的数据片段。使用记录追加,客户机只需要指定要写入的数据。GFS保证至少有一次原子的写入操作 成功执行(即写入一个顺序的byte流),写入的数据追加到GFS指定的偏移位置上,之后GFS返回这个偏移量给客户机。这类似于在Unix操作系统编程 环境中,对以O_APPEND模式打开的文件,多个并发写操作在没有竞态条件时的行为。

记录追加在我们的分布应用中非常频繁的使用,在这些分布式应用中,通常有很多的客户机并行地对同一个文件追加写入数据。如果我们采用传统方式的 文件写入操作,客户机需要额外的复杂、昂贵的同步机制,例如使用一个分布式的锁管理器。在我们的工作中,这样的文件通常用于多个生产者/单一消费者的队列 系统,或者是合并了来自多个客户机的数据的结果文件。

记录追加是一种修改操作,它也遵循3.1节描述的控制流程,除了在主Chunk有些额外的控制逻辑。客户机把数据推送给文件最后一个Chunk 的所有副本,之后发送请求给主Chunk。主Chunk会检查这次记录追加操作是否会使Chunk超过最大尺寸(64MB)。如果超过了最大尺寸,主 Chunk首先将当前Chunk填充到最大尺寸,之后通知所有二级副本做同样的操作,然后回复客户机要求其对下一个Chunk重新进行记录追加操作。(记 录追加的数据大小严格控制在Chunk最大尺寸的1/4,这样即使在最坏情况下,数据碎片的数量仍然在可控的范围。)通常情况下追加的记录不超过 Chunk的最大尺寸,主Chunk把数据追加到自己的副本内,然后通知二级副本把数据写在跟主Chunk一样的位置上,最后回复客户机操作成功。

如果记录追加操作在任何一个副本上失败了,客户端就需要重新进行操作。重新进行记录追加的结果是,同一个Chunk的不同副本可能包含不同的数 据–重复包含一个记录全部或者部分的数据。GFS并不保证Chunk的所有副本在字节级别是完全一致的。它只保证数据作为一个整体原子的被至少写入一次。 这个特性可以通过简单观察推导出来:如果操作成功执行,数据一定已经写入到Chunk的所有副本的相同偏移位置上。这之后,所有的副本至少都到了记录尾部 的长度,任何后续的记录都会追加到更大的偏移地址,或者是不同的Chunk上,即使其它的Chunk副本被Master节点选为了主Chunk。就我们的 一致性保障模型而言,记录追加操作成功写入数据的region是已定义的(因此也是一致的),反之则是不一致的(因此也就是未定义的)。正如我们在 2.7.2节讨论的,我们的程序可以处理不一致的区域。

3.4 快照

(alex注:这一节非常难以理解,总的来说依次讲述了什么是快照、快照使用的COW技术、快照如何不干扰当前操作)
快照操作几乎可以瞬间完成对一个文件或者目录树(“源”)做一个拷贝,并且几乎不会对正在进行的其它操作造成任何干扰。我们的用户可以使用快照 迅速的创建一个巨大的数据集的分支拷贝(而且经常是递归的拷贝拷贝),或者是在做实验性的数据操作之前,使用快照操作备份当前状态,这样之后就可以轻松的 提交或者回滚到备份时的状态。

就像AFS(alex注:AFS,即Andrew File System,一种分布式文件系统),我 们用标准的copy-on-write技术实现快照。当Master节点收到一个快照请求,它首先取消作快照的文件的所有Chunk的租约。这个措施保证 了后续对这些Chunk的写操作都必须与Master交互交互以找到租约持有者。这就给Master节点一个率先创建Chunk的新拷贝的机会。

租约取消或者过期之后,Master节点把这个操作以日志的方式记录到硬盘上。然后,Master节点通过复制源文件或者目录的元数据的方式,把这条日志记录的变化反映到保存在内存的状态中。新创建的快照文件和源文件指向完全相同的Chunk地址。

在快照操作之后,当客户机第一次想写入数据到Chunk C,它首先会发送一个请求到Master节点查询当前的租约持有者。Master节点注意到Chunke C的引用计数超过了1(alex注:不太明白为什么会大于1.难道是Snapshot没有释放引用计数?)。 Master节点不会马上回复客户机的请求,而是选择一个新的Chunk句柄C`。之后,Master节点要求每个拥有Chunk C当前副本的Chunk服务器创建一个叫做C`的新Chunk。通过在源Chunk所在Chunk服务器上创建新的Chunk,我们确保数据在本地而不是 通过网络复制(我们的硬盘比我们的100Mb以太网大约快3倍)。从这点来讲,请求的处理方式和任何其它Chunk没什么不同:Master节点确保新 Chunk C`的一个副本拥有租约,之后回复客户机,客户机得到回复后就可以正常的写这个Chunk,而不必理会它是从一个已存在的Chunk克隆出来的。

4. Master节点的操作

Master节点执行所有的名称空间操作。此外,它还管理着整个系统里所有Chunk的副本:它决定Chunk的存储位置,创建新Chunk和 它的副本,协调各种各样的系统活动以保证Chunk被完全复制,在所有的Chunk服务器之间的进行负载均衡,回收不再使用的存储空间。本节我们讨论上述 的主题。

4.1 名称空间管理和锁

Master节点的很多操作会花费很长的时间:比如,快照操作必须取消Chunk服务器上快照所涉及的所有的Chunk的租约。我们不希望在这 些操作的运行时,延缓了其它的Master节点的操作。因此,我们允许多个操作同时进行,使用名称空间的region上的锁来保证执行的正确顺序。

不同于许多传统文件系统,GFS没有针对每个目录实现能够列出目录下所有文件的数据结构。GFS也不支持文件或者目录的链接(即Unix术语中 的硬链接或者符号链接)。在逻辑上,GFS的名称空间就是一个全路径和元数据映射关系的查找表。利用前缀压缩,这个表可以高效的存储在内存中。在存储名称 空间的树型结构上,每个节点(绝对路径的文件名或绝对路径的目录名)都有一个关联的读写锁。

每个Master节点的操作在开始之前都要获得一系列的锁。通常情况下,如果一个操作涉及/d1/d2/…/dn/leaf,那么操作首先要获 得目录/d1,/d1/d2,…,/d1/d2/…/dn的读锁,以及/d1/d2/…/dn/leaf的读写锁。注意,根据操作的不同,leaf可以是 一个文件,也可以是一个目录。

现在,我们演示一下在/home/user被快照到/save/user的时候,锁机制如何防止创建文件/home/user/foo。快照操 作获取/home和/save的读取锁,以及/home/user和/save/user的写入锁。文件创建操作获得/home和/home/user的 读取锁,以及/home/user/foo的写入锁。这两个操作要顺序执行,因为它们试图获取的/home/user的锁是相互冲突。文件创建操作不需要 获取父目录的写入锁,因为这里没有”目录”,或者类似inode等用来禁止修改的数据结构。文件名的读取锁足以防止父目录被删除。

采用这种锁方案的优点是支持对同一目录的并行操作。比如,可以再同一个目录下同时创建多个文件:每一个操作都获取一个目录名的上的读取锁和文件 名上的写入锁。目录名的读取锁足以的防止目录被删除、改名以及被快照。文件名的写入锁序列化文件创建操作,确保不会多次创建同名的文件。

因为名称空间可能有很多节点,读写锁采用惰性分配策略,在不再使用的时候立刻被删除。同样,锁的获取也要依据一个全局一致的顺序来避免死锁:首先按名称空间的层次排序,在同一个层次内按字典顺序排序。

4.2 副本的位置

GFS集群是高度分布的多层布局结构,而不是平面结构。典型的拓扑结构是有数百个Chunk服务器安装在许多机架上。Chunk服务器被来自同 一或者不同机架上的数百个客户机轮流访问。不同机架上的两台机器间的通讯可能跨越一个或多个网络交换机。另外,机架的出入带宽可能比机架内所有机器加和在 一起的带宽要小。多层分布架构对数据的灵活性、可靠性以及可用性方面提出特有的挑战。

Chunk副本位置选择的策略服务两大目标:最大化数据可靠性和可用性,最大化网络带宽利用率。为了实现这两个目的,仅仅是在多台机器上分别存 储这些副本是不够的,这只能预防硬盘损坏或者机器失效带来的影响,以及最大化每台机器的网络带宽利用率。我们必须在多个机架间分布储存Chunk的副本。 这保证Chunk的一些副本在整个机架被破坏或掉线(比如,共享资源,如电源或者网络交换机造成的问题)的情况下依然存在且保持可用状态。这还意味着在网 络流量方面,尤其是针对Chunk的读操作,能够有效利用多个机架的整合带宽。另一方面,写操作必须和多个机架上的设备进行网络通信,但是这个代价是我们 愿意付出的。

4.3 创建,重新复制,重新负载均衡

Chunk的副本有三个用途:Chunk创建,重新复制和重新负载均衡。

当Master节点创建一个Chunk时,它会选择在哪里放置初始的空的副本。Master节点会考虑几个因素。(1)我们希望在低于平均硬盘 使用率的Chunk服务器上存储新的副本。这样的做法最终能够平衡Chunk服务器之间的硬盘使用率。(2)我们希望限制在每个Chunk服务器上”最 近”的Chunk创建操作的次数。虽然创建操作本身是廉价的,但是创建操作也意味着随之会有大量的写入数据的操作,因为Chunk在Writer真正写入 数据的时候才被创建,而在我们的”追加一次,读取多次”的工作模式下,Chunk一旦写入成功之后就会变为只读的了。(3)如上所述,我们希望把 Chunk的副本分布在多个机架之间。

当Chunk的有效副本数量少于用户指定的复制因数的时候,Master节点会重新复制它。这可能是由几个原因引起的:一个Chunk服务器不 可用了,Chunk服务器报告它所存储的一个副本损坏了,Chunk服务器的一个磁盘因为错误
可用了,或者Chunk副本的复制因数提高了。每个需要被 重新复制的Chunk都会根据几个因素进行排序。一个因素是Chunk现有副本数量和复制因数相差多少。例如,丢失两个副本的Chunk比丢失一个副本的 Chunk有更高的优先级。另外,我们优先重新复制活跃(live)文件的Chunk而不是最近刚被删除的文件的Chunk(查看4.4节)。最后,为了 最小化失效的Chunk对正在运行的应用程序的影响,我们提高会阻塞客户机程序处理流程的Chunk的优先级。

Master节点选择优先级最高的Chunk,然后命令某个Chunk服务器直接从可用的副本”克隆”一个副本出来。选择新副本的位置的策略和 创建时类似:平衡硬盘使用率、限制同一台Chunk服务器上的正在进行的克隆操作的数量、在机架间分布副本。为了防止克隆产生的网络流量大大超过客户机的 流量,Master节点对整个集群和每个Chunk服务器上的同时进行的克隆操作的数量都进行了限制。另外,Chunk服务器通过调节它对源Chunk服 务器读请求的频率来限制它用于克隆操作的带宽。

最后,Master服务器周期性地对副本进行重新负载均衡:它检查当前的副本分布情况,然后移动副本以便更好的利用硬盘空间、更有效的进行负载 均衡。而且在这个过程中,Master服务器逐渐的填满一个新的Chunk服务器,而不是在短时间内用新的Chunk填满它,以至于过载。新副本的存储位 置选择策略和上面讨论的相同。另外,Master节点必须选择哪个副本要被移走。通常情况,Master节点移走那些剩余空间低于平均值的Chunk服务 器上的副本,从而平衡系统整体的硬盘使用率。

4.4 垃圾回收

GFS在文件删除后不会立刻回收可用的物理空间。GFS空间回收采用惰性的策略,只在文件和Chunk级的常规垃圾收集时进行。我们发现这个方法使系统更简单、更可靠。

4.4.1 机制

当一个文件被应用程序删除时,Master节点象对待其它修改操作一样,立刻把删除操作以日志的方式记录下来。但是,Master节点并不马上 回收资源,而是把文件名改为一个包含删除时间戳的、隐藏的名字。当Master节点对文件系统命名空间做常规扫描的时候,它会删除所有三天前的隐藏文件 (这个时间间隔是可以设置的)。直到文件被真正删除,它们仍旧可以用新的特殊的名字读取,也可以通过把隐藏文件改名为正常显示的文件名的方式“反删除”。 当隐藏文件被从名称空间中删除,Master服务器内存中保存的这个文件的相关元数据才会被删除。这也有效的切断了文件和它包含的所有Chunk的连接(alex注:原文是This effectively severs its links to all its chunks)。

在对Chunk名字空间做类似的常规扫描时,Master节点找到孤儿Chunk(不被任何文件包含的Chunk)并删除它们的元数据。 Chunk服务器在和Master节点交互的心跳信息中,报告它拥有的Chunk子集的信息,Master节点回复Chunk服务器哪些Chunk在 Master节点保存的元数据中已经不存在了。Chunk服务器可以任意删除这些Chunk的副本。

4.4.2 讨论

虽然分布式垃圾回收在编程语言领域是一个需要复杂的方案才能解决的难题,但是在GFS系统中是非常简单的。我们可以轻易的得到Chunk的所有 引用:它们都只存储在Master服务器上的文件到块的映射表中。我们也可以很轻易的得到所有Chunk的副本:它们都以Linux文件的形式存储在 Chunk服务器的指定目录下。所有Master节点不能识别的副本都是”垃圾”。

垃圾回收在空间回收方面相比直接删除有几个优势。首先,对于组件失效是常态的大规模分布式系统,垃圾回收方式简单可靠。Chunk可能在某些 Chunk服务器创建成功,某些Chunk服务器上创建失败,失败的副本处于无法被Master节点识别的状态。副本删除消息可能丢失,Master节点 必须重新发送失败的删除消息,包括自身的和Chunk服务器的(alex注:自身的指删除metadata的消息)。 垃圾回收提供了一致的、可靠的清除无用副本的方法。第二,垃圾回收把存储空间的回收操作合并到Master节点规律性的后台活动中,比如,例行扫描和与 Chunk服务器握手等。因此,操作被批量的执行,开销会被分散。另外,垃圾回收在Master节点相对空闲的时候完成。这样Master节点就可以给那 些需要快速反应的客户机请求提供更快捷的响应。第三,延缓存储空间回收为意外的、不可逆转的删除操作提供了安全保障。

根据我们的使用经验,延迟回收空间的主要问题是,延迟回收会阻碍用户调优存储空间的使用,特别是当存储空间比较紧缺的时候。当应用程序重复创建 和删除临时文件时,释放的存储空间不能马上重用。我们通过显式的再次删除一个已经被删除的文件的方式加速空间回收的速度。我们允许用户为命名空间的不同部 分设定不同的复制和回收策略。例如,用户可以指定某些目录树下面的文件不做复制,删除的文件被即时的、不可恢复的从文件系统移除。

4.5 过期失效的副本检测

当Chunk服务器失效时,Chunk的副本有可能因错失了一些修改操作而过期失效。Master节点保存了每个Chunk的版本号,用来区分当前的副本和过期副本。

无论何时,只要Master节点和Chunk签订一个新的租约,它就增加Chunk的版本号,然后通知最新的副本。Master节点和这些副本 都把新的版本号记录在它们持久化存储的状态信息中。这个动作发生在任何客户机得到通知以前,因此也是对这个Chunk开始写之前。如果某个副本所在的 Chunk服务器正好处于失效状态,那么副本的版本号就不会被增加。Master节点在这个Chunk服务器重新启动,并且向Master节点报告它拥有 的Chunk的集合以及相应的版本号的时候,就会检测出它包含过期的Chunk。如果Master节点看到一个比它记录的版本号更高的版本 号,Master节点会认为它和Chunk服务器签订租约的操作失败了,因此会选择更高的版本号作为当前的版本号。

Master节点在例行的垃圾回收过程中移除所有的过期失效副本。在此之前,Master节点在回复客户机的Chunk信息请求的时候,简单的 认为那些过期的块根本就不存在。另外一重保障措施是,Master节点在通知客户机哪个Chunk服务器持有租约、或者指示Chunk服务器从哪个 Chunk服务器进行克隆时,消息中都附带了Chunk的版本号。客户机或者Chunk服务器在执行操作时都会验证版本号以确保总是访问当前版本的数据。

5. 容错和诊断

我们在设计GFS时遇到的最大挑战之一是如何处理频繁发生的组件失效。组件的数量和质量让这些问题出现的频率远远超过一般系统意外发生的频率: 我们不能完全依赖机器的稳定性,也不能完全相信硬盘的可靠性。组件的失效可能造成系统
可用,更糟糕的是,还可能产生不完整的数据。我们讨论我们如何面对 这些挑战,以及当组件失效不可避免的发生时,用GFS自带工具诊断系统故障。

5.1 高可用性

在GFS集群的数百个服务器之中,在任何给定的时间必定会有些服务器是
可用的。我们使用两条简单但是有效的策略保证整个系统的高可用性:快速恢复和复制。

5.1.1 快速恢复

不管Master服务器和Chunk服务器是如何关闭的,它们都被设计为可以在数秒钟内恢复它们的状态并重新启动。事实上,我们并不区分正常关闭和异常关闭;通常,我们通过直接kill掉进程来关闭服务器。客户机和其它的服务器会感觉到系统有点颠簸(alex注:a minor hiccup),正在发出的请求会超时,需要重新连接到重启后的服务器,然后重试这个请求。6.6.2章节记录了实测的启动时间。

5.1.2 Chunk复制

正如之前讨论的,每个Chunk都被复制到不同机架上的不同的Chunk服务器上。用户可以为文件命名空间的不同部分设定不同的复制级别。缺省是 3。当有Chunk服务器离线了,或者通过Chksum校验(参考5.2节)发现了已经损坏的数据,Master节点通过克隆已有的副本保证每个 Chunk都被完整复制(alex注:即每个Chunk都有复制因子制定的个数个副本,缺省是3)。虽然Chunk复制策略对我们非常有效,但是我们也在寻找其它形式的跨服务器的冗余解决方案,比如使用奇偶校验、或者Erasure codes(alex注:Erasure codes用来解决链接层中不相关的错误,以及网络拥塞和buffer限制造成的丢包错误)来解决我们日益增长的只读存储需求。我们的系统主要的工作负载是追加方式的写入和读取操作,很少有随机的写入操作,因此,我们认为在我们这个高度解耦合的系统架构下实现这些复杂的冗余方案很有挑战性,但并非不可实现。

5.1.3 Master服务器的复制

为了保证Master服务器的可靠性,Master服务器的状态也要复制。Master服务器所有的操作日志和checkpoint文件都被复 制到多台机器上。对Master服务器状态的修改操作能够提交成功的前提是,操作日志写入到Master服务器的备节点和本机的磁盘。简单说来,一个 Master服务进程负责所有的修改操作,包括后台的服务,比如垃圾回收等改变系统内部状态活动。当它失效的时,几乎可以立刻重新启动。如果Master 进程所在的机器或者磁盘失效了,处于GFS系统外部的监控进程会在其它的存有完整操作日志的机器上启动一个新的Master进程。客户端使用规范的名字访 问Master(比如gfs-test)节点,这个名字类似DNS别名,因此也就可以在Master进程转到别的机器上执行时,通过更改别名的实际指向访 问新的Master节点。

此外,GFS中还有些“影子”Master服务器,这些“影子”服务器在“主”Master服务器宕机的时候提供文件系统的只读访问。它们是影子, 而不是镜像,所以它们的数据可能比“主”Master服务器更新要慢,通常是不到1秒。对于那些不经常改变的文件、或者那些允许获取的数据有少量过期的应 用程序,“影子”Master服务器能够提高读取的效率。事实上,因为文件内容是从Chunk服务器上读取的,因此,应用程序不会发现过期的文件内容。在 这个短暂的时间窗内,过期的可能是文件的元数据,比如目录的内容或者访问控制信息。
“影子”Master服务器为了保持自身状态是最新的,它会读取一份当前正在进行的操作的日志副本,并且依照和主Master服务器完全相同的顺序 来更改内部的数据结构。和主Master服务器一样,“影子”Master服务器在启动的时候也会从Chunk服务器轮询数据(之后定期拉数据),数据中 包括了Chunk副本的位置信息;“影子”Master服务器也会定期和Chunk服务器“握手”来确定它们的状态。在主Master服务器因创建和删除 副本导致副本位置信息更新时,“影子”Master服务器才和主Master服务器通信来更新自身状态。

5.2 数据完整性

每个Chunk服务器都使用Checksum来检查保存的数据是否损坏。考虑到一个GFS集群通常都有好几百台机器、几千块硬盘,磁盘损坏导致数据 在读写过程中损坏或者丢失是非常常见的(第7节讲了一个原因)。我们可以通过别的Chunk副本来解决数据损坏问题,但是跨越Chunk服务器比较副本来 检查数据是否损坏很不实际。另外,GFS允许有歧义的副本存在:GFS修改操作的语义,特别是早先讨论过的原子纪录追加的操作,并不保证副本完全相同(alex注:副本不是byte-wise完全一致的)。因此,每个Chunk服务器必须独立维护Checksum来校验自己的副本的完整性。
我们把每个Chunk都分成64KB大小的块。每个块都对应一个32位的Checksum。和其它元数据一样,Checksum与其它的用户数据是分开的,并且保存在内存和硬盘上,同时也记录操作日志。
对于读操作来说,在把数据返回给客户端或者其它的Chunk服务器之前,Chunk服务器会校验读取操作涉及的范围内的块的Checksum。因此 Chunk服务器不会把错误数据传递到其它的机器上。如果发生某个块的Checksum不正确,Chunk服务器返回给请求者一个错误信息,并且通知 Master服务器这个错误。作为回应,请求者应当从其它副本读取数据,Master服务器也会从其它副本克隆数据进行恢复。当一个新的副本就绪 后,Master服务器通知副本错误的Chunk服务器删掉错误的副本。
Checksum对读操作的性能影响很小,可以基于几个原因来分析一下。因为大部分的读操作都至少要读取几个块,而我们只需要读取一小部分额外的相 关数据进行校验。GFS客户端代码通过每次把读取操作都对齐在Checksum block的边界上,进一步减少了这些额外的读取操作的负面影响。另外,在Chunk服务器上,Chunksum的查找和比较不需要I/O操 作,Checksum的计算可以和I/O操作同时进行。
Checksum的计算针对在Chunk尾部的追加写入操作作了高度优化(与之对应的是覆盖现有数据的写入操作),因为这类操作在我们的工作中占了 很大比例。我们只增量更新最后一个不完整的块的Checksum,并且用所有的追加来的新Checksum块来计算新的Checksum。即使是最后一个 不完整的Checksum块已经损坏了,而且我们不能够马上检查出来,由于新的Checksum和已有数据不吻合,在下次对这个块进行读取操作的时候,会 检查出数据已经损坏了。
相比之下,如果写操作覆盖已经存在的一个范围内的Chunk,我们必须读取和校验被覆盖的第一个和最后一个块,然后再执行写操作;操作完成之后再重 新计算和写入新的Checksum。如果我们
校验第一个和最后一个被写的块,那么新的Checksum可能会隐藏没有被覆盖区域内的数据错误。
在Chunk服务器空闲的时候,它会扫描和校验每个不活动的Chunk的内容。这使得我们能够发现很少被读取的Chunk是否完整。一旦发现有 Chunk的数据损坏,Master可以创建一个新的、正确的副本,然后把损坏的副本删除掉。这个机制也避免了非活动的、已损坏的Chunk欺骗 Master节点,使Master节点认为它们已经有了足够多的副本了。

5.3 诊断工具

详尽的、深入细节的诊断日志,在问题隔离、调试、以及性能分析等方面给我们带来无法估量的帮助,同时也只需要很小的开销。没有日志的帮助,我们很难 理解短暂的、不重复的机器之间的消息交互。GFS的服务器会产生大量的日志,记录了大量关键的事件(比如,Chunk服务器启动和关闭)以及所有的RPC 的请求和回复。这些诊断日志可以随意删除,对系统的正确运行不造成任何影响。然而,我们在存储空间允许的情况下会尽量的保存这些日志。
RPC日志包含了网络上发生的所有请求和响应的详细记录,但是不包括读写的文件数据。通过匹配请求与回应,以及收集不同机器上的RPC日志记录,我们可以重演所有的消息交互来诊断问题。日志还用来跟踪负载测试和性能分析。
日志对性能的影响很小(远小于它带来的好处),因为这些日志的写入方式是顺序的、异步的。最近发生的事件日志保存在内存中,可用于持续不断的在线监控。

6. 度量

本节中,我们将使用一些小规模基准测试来展现GFS系统架构和实现上的一些固有瓶颈,还有些来自Google内部使用的真实的GFS集群的基准数据。

6.1 小规模基准测试

我们在一个包含1台Master服务器,2台Master服务器复制节点,16台Chunk服务器和16个客户机组成的GFS集群上测量性能。注意,采用这样的集群配置方案只是为了易于测试。典型的GFS集群有数百个Chunk服务器和数百个客户机。

所有机器的配置都一样:两个PIII 1.4GHz处理器,2GB内存,两个80G/5400rpm的硬盘,以及100Mbps全双工以太网连接到一个HP2524交换机。GFS集群中所有的 19台服务器都连接在一个交换机,所有16台客户机连接到另一个交换机上。两个交换机之间使用1Gbps的线路连接。

6.1.1 读取

N个客户机从GFS文件系统同步读取数据。每个客户机从320GB的文件集合中随机读取4MB region的内容。读取操作重复执行256次,因此,每个客户机最终都读取1GB的数据。所有的Chunk服务器加起来总共只有32GB的内存,因此, 我们预期只有最多10%的读取请求命中Linux的文件系统缓冲。我们的测试结果应该和一个在没有文件系统缓存的情况下读取测试的结果接近。

图三:合计吞吐量:上边的曲线显示了我们网络拓扑下的合计理论吞吐量上限。下边的曲线显示了观测到的吞吐量。这个曲线有着95%的可靠性,因为有时候测量会不够精确。
图3(a)显示了N个客户机整体的读取速度以及这个速度的理论极限。当连接两个交换机的1Gbps的链路饱和时,整体读取速度达到理论的极限值 是125MB/S,或者说每个客户机配置的100Mbps网卡达到饱和时,每个客户机读取速度的理论极限值是12.5MB/s。实测结果是,当一个客户机 读取的时候,读取的速度是10MB/s,也就是说达到客户机理论读取速度极限值的80%。对于16个客户机,整体的读取速度达到了94MB/s,大约是理 论整体读取速度极限值的75%,也就是说每个客户机的读取速度是6MB/s。读取效率从80%降低到了75%,主要的原因是当读取的客户机增加时,多个客 户机同时读取一个Chunk服务器的几率也增加了,导致整体的读取效率下降。

6.1.2 写入

N个客户机同时向N个不同的文件中写入数据。每个客户机以每次1MB的速度连续写入1GB的数据。图3(b)显示了整体的写入速度和它们理论上 的极限值。理论上的极限值是67MB/s,因为我们需要把每一byte写入到16个Chunk服务器中的3个上,而每个Chunk服务器的输入连接速度是 12.5MB/s。

一个客户机的写入速度是6.3MB,大概是理论极限值的一半。导致这个结果的主要原因是我们的网络协议栈。它与我们推送数据到Chunk服务器时采用的管道模式不相适应。从一个副本到另一个副本的数据传输延迟降低了整个的写入速度。

16个客户机整体的写入速度达到了35MB/s(即每个客户机2.2MB/s),大约只是理论极限值的一半。和多个客户机读取的情形很类型,随 着客户机数量的增加,多个客户机同时写入同一个Chunk服务器的几率也增加了。而且,16个客户机并行写入可能引起的冲突比16个客户机并行读取要大得 多,因为每个写入都会涉及三个不同的副本。

写入的速度比我们想象的要慢。在实际应用中,这没有成为我们的主要问题,因为即使在单个客户机上能够感受到延时,它也不会在有大量客户机的时候对整体的写入带宽造成显著的影响。

6.1.3 记录追加

图3(c)显示了记录追加操作的性能。N个客户机同时追加数据到一个文件。记录追加操作的性能受限于保存文件最后一个Chunk的Chunk服务器 的带宽,而与客户机的数量无关。记录追加的速度由一个客户机的6.0MB/s开始,下降到16个客户机的4.8MB/s为止,速度的下降主要是由于不同客 户端的网络拥塞以及网络传输速度的不同而导致的。
我们的程序倾向于同时处理多个这样的文件。换句话说,即N个客户机同时追加数据到M个共享文件中,这里N和M都是数十或者数百以上。所以,在我们的 实际应用中,Chunk服务器的网络拥塞并没有成为一个严重问题,如果Chunk服务器的某个文件正在写入,客户机会去写另外一个文件。

6.2 实际应用中的集群


我们现在来仔细评估一下Google内部正在使用的两个集群,它们具有一定的代表性。集群A通常被上百个工程师用于研究和开发。典型的任务是被 人工初始化后连续运行数个小时。它通常读取数MB到数TB的数据,之后进行转化或者分析,最后把结果写回到集群中。集群B主要用于处理当前的生产数据。集 群B的任务持续的时间更长,在很少人工干预的情况下,持续的生成和处理数TB的数据集。在这两个案例中,一个单独的”任务”都是指运行在多个机器上的多个 进程,它们同时读取和写入多个文件。

6.2.1 存储

如上表前五行所描述的,两个集群都由上百台Chunk服务器组成,支持数TB的硬盘空间;两个集群虽然都存储了大量的数据,但是还有剩余的空间。 “已用空间”包含了所有的Chunk副本。实际上所有的文件都复制了三份。因此,集群实际上各存储了18TB和52TB的文件数据。
两个集群存储的文件数量都差不多,但是集群B上有大量的死文件。所谓“死文件”是指文件被删除了或者是被新版本的文件替换了,但是存储空间还没有来得及被回收。由于集群B存储的文件较大,因此它的Chunk数量也比较多。

6.2.2 元数据

Chunk服务器总共保存了十几GB的元数据,大多数是来自用户数据的、64KB大小的块的Checksum。保存在Chunk服务器上其它的元数据是Chunk的版本号信息,我们在4.5节描述过。
在Master服务器上保存的元数据就小的多了,大约只有数十MB,或者说平均每个文件100字节的元数据。这和我们设想的是一样的,Master 服务器的内存大小在实际应用中并不会成为GFS系统容量的瓶颈。大多数文件的元数据都是以前缀压缩模式存放的文件名。Master服务器上存放的其它元数 据包括了文件的所有者和权限、文件到Chunk的映射关系,以及每一个Chunk的当前版本号。此外,针对每一个Chunk,我们都保存了当前的副本位置 以及对它的引用计数,这个引用计数用于实现写时拷贝(alex注:即COW,copy-on-write)。
对于每一个单独的服务器,无论是Chunk服务器还是Master服务器,都只保存了50MB到100MB的元数据。因此,恢复服务器是非常快速 的:在服务器响应客户请求之前,只需要花几秒钟时间从磁盘上读取这些数据就可以了。不过,Master服务器会持续颠簸一段时间–通常是30到60秒–直 到它完成轮询所有的Chunk服务器,并获取到所有Chunk的位置信息。
6.2.3 读写速率


表三显示了不同时段的读写速率。在测试的时候,这两个集群都运行了一周左右的时间。(这两个集群最近都因为升级新版本的GFS重新启动过了)。

集群重新启动后,平均写入速率小于30MB/s。当我们提取性能数据的时候,集群B正进行大量的写入操作,写入速度达到了100MB/s,并且因为每个Chunk都有三个副本的原因,网络负载达到了300MB/s。
读取速率要比写入速率高的多。正如我们设想的那样,总的工作负载中,读取的比例远远高于写入的比例。两个集群都进行着繁重的读取操作。特别是,集群A在一 周时间内都维持了580MB/s的读取速度。集群A的网络配置可以支持750MB/s的速度,显然,它有效的利用了资源。集群B支持的峰值读取速度是 1300MB/s,但是它的应用只用到了380MB/s。

6.2.4 Master服务器的负载

表3的数据显示了发送到Master服务器的操作请求大概是每秒钟200到500个。Master服务器可以轻松的应付这个请求速度,所以Master服务器的处理能力不是系统的瓶颈。

在早期版本的GFS中,Master服务器偶尔会成为瓶颈。它大多数时间里都在顺序扫描某个很大的目录(包含数万个文件)去查找某个特定的文 件。因此我们修改了Master服务器的数据结构,通过对名字空间进行二分查找来提高效率。现在Master服务器可以轻松的每秒钟进行数千次文件访问。 如果有需要的话,我们可以通过在名称空间数据结构之前设置名称查询缓冲的方式进一步提高速度。

6.2.5 恢复时间

当某个Chunk服务器失效了,一些Chunk副本的数量可能会低于复制因子指定的数量,我们必须通过克隆副本使Chunk副本数量达到复制因 子指定的数量。恢复所有Chunk副本所花费的时间取决于资源的数量。在我们的试验中,我们把集群B上的一个Chunk服务器Kill掉。这个Chunk 服务器上大约有15000个Chunk,共计600GB的数据。为了减小克隆操作对正在运行的应用程序的影响,以及为GFS调度决策提供修正空间,我们缺 省的把集群中并发克隆操作的数量设置为91个(Chunk服务器的数量的40%),每个克隆操作最多允许使用的带宽是6.25MB/s(50mbps)。 所有的Chunk在23.2分钟内恢复了,复制的速度高达440MB/s。

在另外一个测试中,我们Kill掉了两个Chunk服务器,每个Chunk服务器大约有16000个Chunk,共计660GB的数据。这两个 故障导致了266个Chunk只有单个副本。这266个Chunk被GFS优先调度进行复制,在2分钟内恢复到至少有两个副本;现在集群被带入到另外一个 状态,在这个状态下,系统可以容忍另外一个Chunk服务器失效而不丢失数据。

6.3 工作负荷分析(Workload Breakdown)

本节中,我们展示了对两个GFS集群工作负载情况的详细分析,这两个集群和6.2节中的类似,但是不完全相同。集群X用于研究和开发,集群Y用于生产数据处理。

6.3.1 方法论和注意事项

本章节列出的这些结果数据只包括客户机发起的原始请求,因此,这些结果能够反映我们的应用程序对GFS文件系统产生的全部工作负载。它们不包含 那些为了实现客户端请求而在服务器间交互的请求,也不包含GFS内部的后台活动相关的请求,比如前向转发的写操作,或者重新负载均衡等操作。

我们从GFS服务器记录的真实的RPC请求日志中推导重建出关于IO操作的统计信息。例如,GFS客户程序可能会把一个读操作分成几个RPC请 求来提高并行度,我们可以通过这些RPC请求推导出原始的读操作。因为我们的访问模式是高度程式化,所以我们认为任何不符合的数据都是误差(alex注:Since our access patterns are highly stylized, we expect any error to be in the noise)。应用程序如果能够记录更详尽的日志,就有可能提供更准确的诊断数据;但是为了这个目的去重新编译和重新启动数千个正在运行的客户机是不现实的,而且从那么多客户机上收集结果也是个繁重的工作。

应该避免从我们的工作负荷数据中过度的归纳出普遍的结论(alex注:即不要把本节的数据作为基础的指导性数据)。因为Google完全控制着GFS和使用GFS的应用程序,所以,应用程序都针对GFS做了优化,同时,GFS也是为了这些应用程序而设计的。这样的相互作用也可能存在于一般程序和文件系统中,但是在我们的案例中这样的作用影响可能更显著。

6.3.2 Chunk服务器工作负荷


表4显示了操作按涉及的数据量大小的分布情况。读取操作按操作涉及的数据量大小呈现了双峰分布。小的读取操作(小于64KB)一般是由查找操作的客户端发起的,目的在于从巨大的文件中查找小块的数据。大的读取操作(大于512KB)一般是从头到尾顺序的读取整个文件。
在集群Y上,有相当数量的读操作没有返回任何的数据。在我们的应用中,尤其是在生产系统中,经常使用文件作为生产者-消费者队列。生产者并行的向文 件中追加数据,同时,消费者从文件的尾部读取数据。某些情况下,消费者读取的速度超过了生产者写入的速度,这就会导致没有读到任何数据的情况。集群X通常 用于短暂的数据分析任务,而不是长时间运行的分布式应用,因此,集群X很少出现这种情况。
写操作按数据量大小也同样呈现为双峰分布。大的写操作(超过256KB)通常是由于Writer使用了缓存机制导致的。Writer缓存较小的数据,通过频繁的Checkpoint或者同步操作,或者只是简单的统计小的写入(小于64KB)的数据量(alex注:即汇集多次小的写入操作,当数据量达到一个阈值,一次写入),之后批量写入。
再来观察一下记录追加操作。我们可以看到集群Y中大的记录追加操作所占比例比集群X多的多,这是因为集群Y用于我们的生产系统,针对GFS做了更全面的调优。


表5显示了按操作涉及的数据量的大小统计出来的总数据传输量。在所有的操作中,大的操作(超过256KB)占据了主要的传输量。小的读取(小于64KB)虽然传输的数据量比较少,但是在读取的数据量中仍占了相当的比例,这是因为在文件中随机Seek的工作负荷而导致的。

6.3.3 记录追加 vs. 写操作

记录追加操作在我们的生产系统中大量使用。对于集群X,记录追加操作和普通写操作的比例按照字节比是108:1,按照操作次数比是8:1。对于 作为我们的生产系统的集群Y来说,这两个比例分别是3.7:1和2.5:1。更进一步,这一组数据说明在我们的两个集群上,记录追加操作所占比例都要比写 操作要大。对于集群X,在整个测量过程中,记录追加操作所占比率都比较低,因此结果会受到一两个使用某些特定大小的buffer的应用程序的影响。

如同我们所预期的,我们的数据修改操作主要是记录追加操作而不是覆盖方式的写操作。我们测量了第一个副本的数据覆盖写的情况。这近似于一个客户机故 意覆盖刚刚写入的数据,而不是增加新的数据。对于集群X,覆盖写操作在写操作所占据字节上的比例小于0.0001%,在所占据操作数量上的比例小于 0.0003%。对于集群Y,这两个比率都是0.05%。虽然这只是某一片断的情况,但是仍然高于我们的预期。这是由于这些覆盖写的操作,大部分是由于客 户端在发生错误或者超时以后重试的情况。这在本质上应该不算作工作负荷的一部分,而是重试机制产生的结果。

6.3.4 Master的工作负荷


表6显示了Master服务器上的请求按类型区分的明细表。大部分的请求都是读取操作查询Chunk位置信息(FindLocation)、以及修改操作查询lease持有者的信息(FindLease-Locker)。

集群X和Y在删除请求的数量上有着明显的不同,因为集群Y存储了生产数据,一般会重新生成数据以及用新版本的数据替换旧有的数据。数量上的差异 也被隐藏在了Open请求中,因为旧版本的文件可能在以重新写入的模式打开时,隐式的被删除了(类似UNIX的open函数中的“w”模式)。

FindMatchingFiles是一个模式匹配请求,支持“ls”以及其它类似的文件系统操作。不同于Master服务器的其它请求,它可 能会检索namespace的大部分内容,因此是非常昂贵的操作。集群Y的这类请求要多一些,因为自动化数据处理的任务进程需要检查文件系统的各个部分, 以便从全局上了解应用程序的状态。与之不同的是,集群X的应用程序更加倾向于由单独的用户控制,通常预先知道自己所需要使用的全部文件的名称。

7. 经验

在建造和部署GFS的过程中,我们经历了各种各样的问题,有些是操作上的,有些是技术上的。

起初,GFS被设想为我们的生产系统的后端文件系统。随着时间推移,在GFS的使用中逐步的增加了对研究和开发任务的支持。我们开始增加一些小 的功能,比如权限和配额,到了现在,GFS已经初步支持了这些功能。虽然我们生产系统是严格受控的,但是用户层却
总是这样的。需要更多的基础架构来防止 用户间的相互干扰。

我们最大的问题是磁盘以及和Linux相关的问题。很多磁盘都声称它们支持某个范围内的Linux IDE硬盘驱动程序,但是实际应用中反映出来的情况却不是这样,它们只支持最新的驱动。因为协议版本很接近,所以大部分磁盘都可以用,但是偶尔也会有由于 协议不匹配,导致驱动和内核对于驱动器的状态判断失误。这会导致数据因为内核中的问题意外的被破坏了。这个问题促使我们使用Checksum来校验数据, 同时我们也修改内核来处理这些因为协议不匹配带来的问题。

较早的时候,我们在使用Linux 2.2内核时遇到了些问题,主要是fsync()的效率问题。它的效率与文件的大小而不是文件修改部分的大小有关。这在我们的操作日志文件过大时给出了难 题,尤其是在我们尚未实现Checkpoint的时候。我们费了很大的力气用同步写来解决这个问题,但是最后还是移植到了Linux2.4内核上。

另一个和Linux相关的问题是单个读写锁的问题,也就是说,在某一个地址空间的任意一个线程都必须在从磁盘page in(读锁)的时候先hold住,或者在mmap()调用(写锁)的时候改写地址空间。我们发现即使我们的系统负载很轻的情况下也会有偶尔的超时,我们花 费了很多的精力去查找资源的瓶颈或者硬件的问题。最后我们终于发现这个单个锁在磁盘线程交换以前映射的数据到磁盘的时候,锁住了当前的网络线程,阻止它把 新数据映射到内存。由于我们的性能主要受限于网络接口,而不是内存copy的带宽,因此,我们用pread()替代mmap(),用了一个额外的copy 动作来解决这个问题。

尽管偶尔还是有其它的问题,Linux的开放源代码还是使我们能够快速探究和理解系统的行为。在适当的时候,我们会改进内核并且和公开源码组织共享这些改动。

8. 相关工作

和其它的大型分布式文件系统,比如AFS[5]类似,GFS提供了一个与位置无关的名字空间,这使得数据可以为了负载均衡或者灾难冗余等目的在 不同位置透明的迁移。不同于AFS的是,GFS把文件分布存储到不同的服务器上,这种方式更类似Xfs[1]和Swift[3],这是为了提高整体性能以 及灾难冗余的能力。

由于磁盘相对来说比较便宜,并且复制的方式比RAID[9]方法简单的多,GFS目前只使用复制的方式来进行冗余,因此要比xFS或者Swift占用更多的裸存储空间(alex注:Raw storage,裸盘的空间)。

与AFS、xFS、Frangipani[12]以及Intermezzo[6]等文件系统不同的是,GFS并没有在文件系统层面提供任何 Cache机制。我们主要的工作在单个应用程序执行的时候几乎不会重复读取数据,因为它们的工作方式要么是流式的读取一个大型的数据集,要么是在大型的数 据集中随机Seek到某个位置,之后每次读取少量的数据。

某些分布式文件系统,比如Frangipani、xFS、Minnesota’s GFS[11]、GPFS[10],去掉了中心服务器,只依赖于分布式算法来保证一致性和可管理性。我们选择了中心服务器的方法,目的是为了简化设计,增 加可靠性,能够灵活扩展。特别值得一提的是,由于处于中心位置的Master服务器保存有几乎所有的Chunk相关信息,并且控制着Chunk的所有变 更,因此,它极大地简化了原本非常复杂的Chunk分配和复制策略的实现方法。我们通过减少Master服务器保存的状态信息的数量,以及将Master 服务器的状态复制到其它节点来保证系统的灾难冗余能力。扩展能力和高可用性(对于读取)目前是通过我们的影子Master服务器机制来保证的。对 Master服务器状态更改是通过预写日志的方式实现持久化。为此,我们可以调整为使用类似Harp[7]中的primary-copy方案,从而提供比 我们现在的方案更严格的一致性保证。

我们解决了一个难题,这个难题类似Lustre[8]在如何在有大量客户端时保障系统整体性能遇到的问题。不过,我们通过只关注我们的应用程序 的需求,而不是提供一个兼容POSIX的文件系统,从而达到了简化问题的目的。此外,GFS设计预期是使用大量的不可靠节点组建集群,因此,灾难冗余方案 是我们设计的核心。

GFS很类似NASD架构[4]。NASD架构是基于网络磁盘的,而GFS使用的是普通计算机作为Chunk服务器,就像NASD原形中方案一 样。所不同的是,我们的Chunk服务器采用惰性分配固定大小的Chunk的方式,而不是分配变长的对象存储空间。此外,GFS实现了诸如重新负载均衡、 复制、恢复机制等等在生产环境中需要的特性。

不同于与Minnesota’s GFS和NASD,我们并不改变存储设备的Model(alex注:对这两个文件系统不了解,因为不太明白改变存储设备的Model用来做什么,这不明白这个model是模型、还是型号)。我们只关注用普通的设备来解决非常复杂的分布式系统日常的数据处理。

我们通过原子的记录追加操作实现了生产者-消费者队列,这个问题类似River[2]中的分布式队列。River使用的是跨主机的、基于内存的 分布式队列,为了实现这个队列,必须仔细控制数据流;而GFS采用可以被生产者并发追加记录的持久化的文件的方式实现。River模式支持m-到-n的分 布式队列,但是缺少由持久化存储提供的容错机制,GFS只支持m-到-1的队列。多个消费者可以同时读取一个文件,但是它们输入流的区间必须是对齐的。

9. 结束语

Google文件系统展示了一个使用普通硬件支持大规模数据处理的系统的特质。虽然一些设计要点都是针对我们的特殊的需要定制的,但是还是有很多特性适用于类似规模的和成本的数据处理任务。

首先,我们根据我们当前的和可预期的将来的应用规模和技术环境来评估传统 的文件系统的特性。我们的评估结果将我们引导到一个使用完全不同于传统的设计思路上。根据我们的设计思路,我们认为组件失效是常态而不是异常,针对采用追 加方式(有可能是并发追加)写入、然后再读取(通常序列化读取)的大文件进行优化,以及扩展标准文件系统接口、放松接口限制来改进整个系统。

我们系统通过持续监控,复制关键数据,快速和自动恢复提供灾难冗余。 Chunk复制使得我们可以对Chunk服务器的失效进行容错。高频率的组件失效要求系统具备在线修复机制,能够周期性的、透明的修复损坏的数据,也能够 第一时间重新建立丢失的副本。此外,我们使用Checksum在磁盘或者IDE子系统级别检测数据损坏,在这样磁盘数量惊人的大系统中,损坏率是相当高 的。

我们的设计保证了在有大量的并发读写操作时能够提供很高的合计吞吐量。我们通过分离控制流和数据流来实现这个目标,控制流在Master服务器处理,而数据流在Chunk服务器和客户端处理。当一般的操作涉及到Master服务器时,由于GFS选择的Chunk尺寸较大(alex注:从而减小了元数据的大小),以及通过Chunk Lease将控制权限移交给主副本,这些措施将Master服务器的负担降到最低。这使得一个简单、中心的Master不会成为成为瓶颈。我们相信我们对网络协议栈的优化可以提升当前对于每客户端的写入吞吐量限制。

GFS成功的实现了我们对存储的需求,在Google内部,无论是作为研究和开发的存储平台,还是作为生产系统的数据处理平台,都得到了广泛的应用。它是我们持续创新和处理整个WEB范围内的难题的一个重要工具。

致谢

We wish to thankt he following people for their contributions to the system or the paper. Brain Bershad (our shepherd) and the anonymous reviewers gave us valuable comments and suggestions. Anurag Acharya, Jeff Dean, and David des-Jardins contributed to the early design. Fay Chang worked on comparison of replicas across chunkservers. Guy Edjlali worked on storage quota. Markus Gutschke worked on a testing frameworkan d security enhancements. David
Kramer worked on performance enhancements. Fay Chang, Urs Hoelzle, Max Ibel, Sharon Perl, Rob Pike, and Debby Wallach commented on earlier drafts of the paper. Many of our colleagues at Google bravely trusted their data to a new file system and gave us useful feedback. Yoshka helped with early testing.

参考

[1] Thomas Anderson, Michael Dahlin, Jeanna Neefe, David Patterson, Drew Roselli, and Randolph Wang. Serverless networkfil e systems. In Proceedings of the 15th ACM Symposium on Operating System Principles, pages 109–126, Copper Mountain Resort, Colorado, December 1995.
[2] Remzi H. Arpaci-Dusseau, Eric Anderson, Noah Treuhaft, David E. Culler, Joseph M. Hellerstein, David Patterson, and Kathy Yelick. Cluster I/O with River: Making the fast case common. In Proceedings of the Sixth Workshop on Input/Output in Parallel and Distributed Systems (IOPADS ’99), pages 10–22, Atlanta, Georgia, May 1999.
[3] Luis-Felipe Cabrera and Darrell D. E. Long. Swift: Using distributed disks triping to provide high I/O data rates. Computer Systems, 4(4):405–436, 1991.

[4] Garth A. Gibson, David F. Nagle, Khalil Amiri, Jeff Butler, Fay W. Chang, Howard Gobioff, Charles Hardin, ErikR iedel, David Rochberg, and Jim Zelenka. A cost-effective, high-bandwidth storage architecture. In Proceedings of the 8th Architectural Support for Programming Languages and Operating Systems, pages 92–103, San Jose, California, October 1998.

[5] John Howard, Michael Kazar, Sherri Menees, David Nichols, Mahadev Satyanarayanan, Robert Sidebotham, and Michael West. Scale and performance in a distributed file system. ACM Transactions on Computer Systems, 6(1):51–81, February 1988.
[6] InterMezzo. http://www.inter-mezzo.org, 2003.

[7] Barbara Liskov, Sanjay Ghemawat, Robert Gruber, Paul Johnson, Liuba Shrira, and Michael Williams. Replication in the Harp file system. In 13th Symposium on Operating System Principles, pages 226–238, Pacific Grove, CA, October 1991.
[8] Lustre. http://www.lustreorg, 2003.

[9] David A. Patterson, Garth A. Gibson, and Randy H. Katz. A case for redundant arrays of inexpensive disks (RAID). In Proceedings of the 1988 ACM SIGMOD International Conference on Management of Data, pages 109–116, Chicago, Illinois, September 1988.
[10] FrankS chmuck and Roger Haskin. GPFS: A shared-diskfi le system for large computing clusters. In Proceedings of the First USENIX Conference on File and Storage Technologies, pages 231–244, Monterey, California, January 2002.

[11] Steven R. Soltis, Thomas M. Ruwart, and Matthew T.O’Keefe. The Gobal File System. In Proceedings of the Fifth NASA Goddard Space Flight Center Conference on Mass Storage Systems and Technologies, College Park, Maryland, September 1996.
[12] Chandramohan A. Thekkath, Timothy Mann, and Edward K. Lee. Frangipani: A scalable distributed file system. In Proceedings of the 16th ACM Symposium on Operating System Principles, pages 224–237, Saint-Malo, France, October 1997

Posted in 大数据, 系统架构 | Leave a comment