29

HBase基础面试题总结

 3 years ago
source link: http://mp.weixin.qq.com/s?__biz=MzA4NzA5NzE5Ng%3D%3D&%3Bmid=2650229838&%3Bidx=1&%3Bsn=22f3c5333cf7117b7e10e8f58735a78e
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

点击关注上方“ 知了小巷 ”,

设为“置顶或星标”,第一时间送达干货。

1. 为什么用HBase存储?

HBase(Hadoop DataBase)是一个高可靠性、高性能、可伸缩、面向列的分布式数据库(分布式存储系统)。

HBase与Hadoop的关系非常紧密,Hadoop的HDFS提供了高可靠的底层存储支持,Hadoop MapReduce为HBase提供了高性能的计算能力,Zookeeper为HBase提供了稳定性及Failover机制的保障。同时其他周边产品诸如Hive可以与HBase相结合,使得对HBase中的数据进行数据统计变得简单,Sqoop为HBase提供了方便的RDBMS数据导入功能,使传统数据库的数据向HBase中迁移变得容易,Spark等高性能的分布式内存计算引擎也可以帮助我们更加快速的对HBase中的数据进行处理分析。

2. Rowkey怎么设计,有什么好处?

  1. 长度原则

Rowkey是一个二进制码流,可以是任意字符串,最大长度为64KB,实际应用中一般为10~100bytes,以byte[]形式保存,一般设计成定长。建议越短越好,不要超过16个字节,原因如下:

  • 数据持久化到HFile文件中时按照Key-Value存储的,如果Rowkey过长,例如超过100bytes,那么1000w行的记录,仅Rowkey就需占用近1GB的空间。这样会极大影响HFile的存储效率。

  • MemStore会缓存部分数据到内存中,若Rowkey字段过长,内存的有效利用率就会降低,就不能缓存更多的数据,从而降低检索效率。

  • 目前操作系统都是64位系统,内存8字节对齐,控制在16字节,8字节的整数倍利用了操作系统的最佳特性。

  1. 唯一原则

必须在设计上保证Rowkey的唯一性。由于在HBase中数据存储是Key-Value形式,若向HBase中同一张表插入相同Rowkey的数据,则原先存在的数据会被新的数据覆盖(新的数据版本)。

  1. 排序原则

HBase的Rowkey是按照ASCII有序排序的,因此我们在设计Rowkey的时候要充分利用这一点。

  1. 散列原则

设计的Rowkey应均匀的分布在各个HBase节点上。

3. HBase的优化?

  1. 表设计

  • (1)建表时就分区(预分区),Rowkey设置定长(64字节),CF(Column Family)2到3个。

  • (2)Max Version,Time to live,Compact&Split。

  1. 写表

  • (1)多HTable并发写,提高吞吐量

  • (2)HTable参数设置,手动flush,降低IO

  • (3)WriteBuffer

  • (4)批量写,减少网络IO开销

  • (5)多线程并发写,结合定时flush和写buffer(writeBufferSize),可以既保证在数据量小的时候,数据可以在较短时间内被flush(如1秒内),同时又保证在数据量大的时候,写buffer一满就及时进行flush。

  1. 读表

  • (1)多HTable并发读,提高吞吐量

  • (2)HTable参数设置

  • (3)批量读

  • (4)释放资源

  • (5)缓存查询结果

4. HBase的读写流程?

  1. 元数据存储

HBase中有一个系统表 hbase:meta ,存储元数据信息,可以在HBase Web UI查看到相关信息。

该表记录保存了每个表的Region地址,还有一些其他信息,例如Region的名字,对应表的名字,开始Rowkey(STARTKEY)、结束Rowkey(ENDKEY)、服务器的信息。hbase:meta表中每一行对应一个单一的Region。

hbase:meta表单个数据含义:

  • RowKey(行键)- TableName(表名),table produce epoch(表创建的时间戳)。

  • ColumnFamily(列族) - info (信息)

  • ColumnQualifier(列)- regioninfo;server;serverstartcode;seqnumDuringOpen

  • value - NAME:表名 STARTKEY:表行键开始值 ENDKEY:表行键结束值 ENCODED:Region编码后的名字;服务器地址:端口;服务开始的时间戳;Region在线时长的一个二进制串

Zookeeper中存储了hbase:meta表的位置 ,客户端可以通过Zookeeper查找到hbase:meta表的位置,hbase:meta是HBase当中的一张表,肯定由一个HRegionServer来管理,其实主要就是要通过Zookeeper的"/hbase/meta-region-server"获取存储"hbase:meta"表的HRegionServer的地址。

附:ZK的作用

  • 作用一:HMaster、RegionServer容错

    当HBase集群启动成功后,会在ZK注册如下znode:

  1. /hbase/master ,其中包含当前活动(Active)的HMaster信息;

  2. /hbase/backup-masters/[host-name] ,每个子znode包含当前作为热备的HMaster信息;

  3. /hbase/rs/[host-name] ,每个子znode包含各个RegionServer的信息。

所有znode都是临时(ephemeral)节点,HMaster和RegionServer通过心跳维护这些znode。

  • 作用二:Log Split管理

    /hbase/splitlog临时节点

    ,其中存放有存活RegionServer与其应该处理的Region HLog的映射关系。各个RegionServer从该节点得到分配的Region,重放HLog,并将结果写回该节点,以通知HMaster进行后续操作。

  • 作用三:.META.表位置维护

    永久(persistent)节点/hbase/meta-region-server

    来记录.META.表保存在哪个RegionServer上。

  • 作用四:Replication管理

    /hbase/replication

    这个znode下。

多个HBase集群是可以共用一个ZK集群的。只需要修改HBase的zookeeper.znode.parent参数,对不同集群指定不同的ZK根路径即可,例如/hbase-cluster1、/hbase-cluster2。

  1. 读数据流程

  1. HBaseClient先访问Zookeeper,从meta表读取Region的位置,然后读取meta表中的数据。meta中又存储了用户表的Region信息;

  1. 根据Rowkey在meta表中找到对应的Region信息;

  1. 找到这个Region对应的RegionServer;

  1. 查找对应的Region;

  1. 先从MetaStore找数据,如果没有,再到BlockCache里面读;

  1. BlockCache还没有,再到StoreFile上读(为了读取的效率);

  1. 如果是从StoreFile里面读取的数据,不是直接返回给客户端,而是先写入BlockCache,再返回给客户端。

  1. 写数据流程

  1. HBaseClient访问Zookeeper,获取meta表所处位置(IP地址);

  1. 访问meta表,然后读取meta表中的数据;

  1. 根据namespace(类似于关系数据库中的数据库)、表名和Rowkey在meta表中找到该Rowkey应该写入到哪个Region;

  1. 找到这个Region对应的RegionServer,并发送写数据请求;

  1. HRegionServer将数据先写到HLog(Write Ahead Log)。为了数据的持久化和恢复;

  1. HRegionServer将数据写到内存(MemStore);

  1. 反馈给HBaseClient写入成功。

HBase将数据写入内存中后,就返回给客户端写入成功, 响应非常快 。这也是为什么HBase写数据速度快的原因。

  1. 数据flush过程

HBase写数据是写入到MemStore内存就会返回客户端了,并没有直接落磁盘。磁盘IO非常小。那么什么时候数据会落到磁盘呢?其实MemStore空间是有限的,当MemStore数据达到阈值(默认是128MB),RegionServer将数据刷到HDFS上,生成HFile,然后将内存中的数据删除,同时删除HLog中的历史数据。该操作是由RegionServer自己完成。

5. Rowkey如何设计可以避免数据热点问题?

通常有3种方法来解决Region热点问题:

  1. Reverse反转

针对固定长度的Rowkey反转后存储,这样可以使得Rowkey中经常改变的部分放在前面,可以 有效的随机Rowkey 。反转Rowkey的例子通常以手机举例,可以将手机号反转后的字符串作为Rowkey,这样就避免了以手机号那样比较固定开头(139x、18x等)导致热点问题,这样做的 缺点就是牺牲了Rowkey的有序性

  1. Salt加盐

Salt是将 每一个Rowkey加一个前缀 ,前缀使用一些随机字符,使得数据分散在多个不同的Region,达到Region负载均衡的目的。

比如在一个有4个Region(以[,a)、[a,b)、[b,c)、[c,)为Region起点和结束点)的HBase表中,加Salt前的Rowkey:abc001、abc002、abc003。

分布加上a、b、c前缀,加Salt知乎Rowkey为:a-abc001、b-abc002、c-abc003。可以看到,加盐前的Rowkey默认会在第2个Region中,加盐后的Rowkey数据会分布在3个Region中,理论上处理后的吞吐量是之前的3倍。 由于前缀是随机的,读这些数据时需要耗费更多的时间,所以Salt增加了写操作的吞吐量,不过缺点是同时增加了读操作的开销

  1. Hash散列或者Mod

用Hash散列来替代随机Salt前缀的好处是能让一个给定的行有相同的前缀,这在分散了Region负载的同时,使读操作也能够推断。确定性Hash(比如md5后取前4位做前缀)能让客户端重建完整的Rowkey,可以使用get操作直接get想要的行。

比如,对abc001、abc002、abc003采用md5散列算法取前4位做前缀,

9bf0-abc001

7006-abc002

95e6-abc003

若以 前4位 字符作为不同分区的起止,上面几个Rowkey数据会分布在3个Region中。实际应用场景是当数据量越来越大的时候,这种设计会使得分区之间更加均衡。

如果Rowkey是数字类型的,也可以考虑mod方法。

6. HBase的最小存储单位

HRegion是HBase中分布式存储和负载均衡的最小单元。最小单元就表示不同的HRegion可以分布在不同的HRegionServer上。

HRegion由一个或者多个Store组成,每个Store保存一个Column Family。每个Store又由一个MemStore和0至多个StoreFile组成,每个StoreFile以HFile格式保存在HDFS上,HFile是Hadoop的二进制格式文件,实际上StoreFile就是对HFile做了轻量级包装,即StoreFile底层就是HFile。

7.HBase是怎么做预分区的,预分区的作用是什么

HBase默认建表时只有一个Region,这个Region的Rowkey是没有边界的,即没有StartKey和EndKey,在数据写入时,所有数据都会写入这个默认的Region,随着数据量的不断增加,此Region已经不能承受不断增长的数据量,会进行split,分成两个Region。在此过程中,会产生两个问题:

  1. 数据往一个Region上写会有写热点问题

  2. Region split会消耗宝贵的集群IO资源

    基于此,我们可以控制 在创建表的时候,创建多个空Region,并确定每个Region的起始和结束Rowkey,这样只要我们的Rowkey设计能够均匀的命中各个Region,就不会存在写热点问题 。自然split的几率也会大大降低。当随着数据量的不断增长,该split的还是要进行split。像这样 预先创建HBase表分区的方式,称之为预分区

    创建预分区可以通过hbase shell或者Java代码实现。

    hbase shell:

    指明分割点

create 't1', 'f1', splits=>['10', '20', '30', '40']

HexStringSplit是指明分割策略,-c 10是指明要分割的区域数量,-f指明表中的列族,用“:”分割。

hbase org.apache.hadoop.hbase.util.RegionSplitter test_table HexStringSplit -c 10 -f f1

根据文件创建分区并压缩

create 'split_table_test', {NAME => 'cf', COMPRESSION => 'SNAPPY'}, {SPLITS_FILE => 'region_split_info.txt'}

8. HBase中的HFile什么时候要合并成大文件,什么时候拆分成小文件

  1. HFile合并

  • (1)为什么要有HFile的合并?

HBase是一个可以随机读写的存储系统,而它所基于的持久化层HDFS却是要么新增,要么整个删除,不能修改的系统。HBase是怎么实现增删改查的?

HBase是一种Log-Structured Merge Tree架构模式(LSM树),HBase几乎总是在做新增操作。当新增一个单元格时,HBase在HDFS上新增一条数据。当修改一个单元格的时候,HBase在HDFS上又新增一条数据,只是版本号比之前那个大(或者自定义)。当删除一个单元格的时候,HBase还是新增一条数据!只是这条数据没有value,类型为DELETE,这条数据叫做墓碑标记(Tombstone)。由于HBase在使用过程中积累了很多增删改操作,数据的连续性和顺序性必然会被破坏,为了提升性能,HBase每隔一段时间会进行一次合并-Compaction,合并的对象就是HFile文件。

另外,随着数据写入不断增长,flush次数也会不断增多,进而HFile数据文件就会越来越多。太多数据文件会导致数据查询IO次数增多,因此HBase尝试着不断对这些文件进行合并。

合并分为两种:minor compaction和major compaction。

  • minor compaction:将Store中多个HFile合并为一个HFile。在这个过程中达到TTL的数据会被移除,但是被手动删除的数据不会被移除。这种合并触发频率较高。

  • major compaction:合并Store中的所有HFile为一个HFile。在这个过程中被手动删除的数据会被真正地移除。同时被删除的还有单元格内超过MaxVersion的版本数据。这种合并触发频率较低,默认为7天一次。不过由于major compaction消耗的性能比较大,建议手动控制major compaction的时机,避免发生在业务高峰期。

  1. compaction执行时间

触发compaction的时机:

  • 通过CompactionChecker线程定时检查是否需要执行compaction(RegionServer启动时在initializeThread()中初始化),每隔10000秒(可配置)检查一次。

  • 每当RegionServer发生一次MemStore flush操作之后也会进行检查是否需要进行compaction操作。

  • 手动触发,执行命令

major_compact
compact

源码HRegionServer#CompactionChecker:

public abstract class ScheduledChore implements Runnable {...}

内部类CompactionChecker:

/*
* Inner class that runs on a long period checking if regions need compaction.
*/

private static class CompactionChecker extends ScheduledChore {
private final HRegionServer instance;
private final int majorCompactPriority;
private final static int DEFAULT_PRIORITY = Integer.MAX_VALUE;
//Iteration is 1-based rather than 0-based so we don't check for compaction
// immediately upon region server startup
private long iteration = 1;

CompactionChecker(final HRegionServer h, final int sleepTime,
final Stoppable stopper) {
super("CompactionChecker", stopper, sleepTime);
this.instance = h;
LOG.info(this.getName() + " runs every " + StringUtils.formatTime(sleepTime));

/* MajorCompactPriority is configurable.
* If not set, the compaction will use default priority.
*/

this.majorCompactPriority = this.instance.conf.
getInt("hbase.regionserver.compactionChecker.majorCompactPriority",
DEFAULT_PRIORITY);
}

@Override
protected void chore() {
for (Region r : this.instance.onlineRegions.values()) {
if (r == null) {
continue;
}
for (Store s : r.getStores()) {
try {
long multiplier = s.getCompactionCheckMultiplier();
assert multiplier > 0;
if (iteration % multiplier != 0) {
continue;
}
if (s.needsCompaction()) {
// Queue a compaction. Will recognize if major is needed.
this.instance.compactSplitThread.requestSystemCompaction(r, s, getName()
+ " requests compaction");
} else if (s.isMajorCompaction()) {
s.triggerMajorCompaction();
if (majorCompactPriority == DEFAULT_PRIORITY
|| majorCompactPriority > ((HRegion) r).getCompactPriority()) {
this.instance.compactSplitThread.requestCompaction(r, s, getName()
+ " requests major compaction; use default priority", null);
} else {
this.instance.compactSplitThread.requestCompaction(r, s, getName()
+ " requests major compaction; use configured priority",
this.majorCompactPriority, null, null);
}
}
} catch (IOException e) {
LOG.warn("Failed major compaction check on " + r, e);
}
}
}
iteration = (iteration == Long.MAX_VALUE) ? 0 : (iteration + 1);
}
}
  1. compaction相关控制参数

2.0版本以上的:

(1)minor compaction

# 默认值10;表示一次minor compaction中最多选取10个StoreFile
hbase.hstore.compaction.max
# 默认值3;表示至少需要三个满足条件的StoreFile时,minor compaction才会启动
hbase.hstore.compaction.min
# 文件大小小于该值的StoreFile 一定会加入到minor compaction的StoreFile中
hbase.hstore.compaction.min.size
# 文件大小大于该值的StoreFile,一定会被minor compaction排除掉
hbase.hstore.compaction.max.size
# 默认值1.2 将StoreFile按照文件年龄顺序(older to younger),minor compaction总是从older store file开始选择
hbase.hstore.compaction.ratio

根据StoreFile文件年龄顺序,minor compaction总是从 older store file 开始选择,计算公式:* 该文件<(所有文件大小总和 - 该文件大小) 比例因子 。如果文件的size小于后面hbase.hstore.compaction.max个store file size之和乘以ratio的值,那么该StoreFile将加入到minor compaction中。如果满足minor compaction条件的文件数量大于hbasehstore.compaction.min,才会启动minor compaction。hbase.hstore.compaction.min.size和hbase.hstore.compaction.max.size参数用于控制特殊大小的文件直接判断是否加入minor compaction。如果该文件大小小于最小合并大小(minCompactSize),则连上面那个公式都不需要套用,直接进入待合并列表。最小合并大小的配置项∶hbase.hstore.compacton.min.size。如果没设定该配置项,则使用hhbase.hregion.memstore.flush.size。被挑选的文件必须能通过以上提到的筛选条件,并且组合内含有的文件数必须大于hbase.hstore.compaction.min,小于hbase.hstore.compaction.max。文件太少了没必要合并,还浪费资源;文件太多了太消耗资源,怕机器受不了。上面的选择方式,会形成多个满足条件的StoreFle组合,然后再比较哪个文件组合包含的文件更多,就合并哪个组合。如果出现平局,就挑选那个文件尺寸总和更小的组合。

(2)major compaction

# major compaction发生的周期,单位是毫秒,默认值是7天
hbase.hregion.majorcompaction

major compaction对系统压力比较大,建议关闭自动major compaction

hbase.hregion.majorcompaction=0

采用手动触发的方式,定期进行major compaction。

# all regions in a table
hbase> major_compact 't1'
hbase> major_compact 'ns1:t1'
# an entire region
hbase> major_compact 'r1'
# a single column family within a region
hbase> major_compact 'r1', 'c1'
# a single column family within a table
hbase> major_compact 't1', 'c1'
  1. Region拆分

  • (1)ConstantSizeRegionSplitPolicy(了解内存)

# Region的最大大小,默认值是10GB
hbase.hregion.max.filesize

当单个Region大小超过10GB,就会被HBase拆分成2个Region。这种策略使得集群中的Region大小很平均。

  • (2)IncreasingToUpperBoundRegionSplitPolicy(0.94版本以后默认)

    0.94版本之后,有了IncreasingToUpperBoundRegionSplitPolicy策略。这种策略从名字上可以看出是 限制不断增长的文件尺寸的策略

    依赖以下公式来计算∶

  • Math.min(tableRegionsCounts^3*initialSize,defaultRegionMaxFileSize)

  • tableRegionCount∶表在所有RegionServer上所拥有的Region数量总和。

  • talSize∶如果定义了hbase.increasing.policy.initalsize,则使用这个数值。如果没有定义,就用memstore的刷写大小的2倍,hbase.hregion.memstore.flush.size*2。

  • defaultRegionMaxFileSize∶ ConstantSizeRegionsplitPolicy 所用到的hbase.hregion.max.filesize,即Region最大大小。

假如hbase.hregion.memstore.flush.size定义为128MB.那么文件尺寸的上限增长将是这样∶

  • ①刚开始只有一个region的时候,上限是256MB.因为1^3 * 128 * 2=256MB。
  • ②当有2个region的时候,上限是2GB,因为2^3 * 128 * 2=2048MB。
  • ③当有3个文件的时候,上限是6.75GB,因为3^3 * 128 * 2=6912MB。
  • ④以此类推,直到计算出来的上限达到hbase,hrecion.maxfilesize region所定义的10GB。

Region大小上限

当Region个数达到4个的时候由于计算出来的上限已经达到了16GB,已经大于10GB,所以后面当Region数量再增加的时候文件大小上限已经不会增加了。在最新的版本用

IncreasingToUpperBoundRegionSplitPoliciy

是默认的配置。

  • (3)KeyPrefixRegionsplitPlicy

# Rowkey的前缀长度
KeyPrefixRegionSplitPolicy.prefix_length

该策略会根据上面参数所定义的长度来截取Rowkey作为分组的依据, 同一个组的数据不会被划分到不同的Region上 。比如Rowkey都是16位的,指定前5位相同的Rowkey在进行region split的时候会分到相同的Region中。

如果所有数据都只有一两个前缀,KeyPrefixRegionSplitPolicy就无效了,此时采用默认的策略较好。如果前缀划分的比较细,查询就比较容易发生跨Region查询的情况,此时采用KeyPrefixRegionSplitPolicy较好。

KeyPrefixRegionSplitPolicy策略的使用场景

:数据有多重前缀。查询多是针对前缀,比较少跨越多个前缀来查询。

  • (4) DelimitedKeyPrefixRegionSplitPolicy

# 前缀分隔符
DelimitedKeyPrefixRegionSplitPolicy.delimiter

例如定义了前缀分隔符为_,host1_001和host12_999的前缀就分别是host1和host12。

  • (5)BusyRegionSplitPolicy

# 请求阻塞率,请求被阻塞的严重程度
hbase.busy.policy.blockedRequests

取值范围是0.0~1.0,默认是0.2,即20%的请求被阻塞的意思。

# 拆分最小年龄
hbase.busy.policy.minAge

当Region的年龄比这个小的时候不拆分,这是为了防止在判断是否要拆分的时候出现了短时间的访问频率波峰,结果没必要拆分的Region被拆分了,因为短时间的波峰会很快地降回到正常水平。单位为毫秒,默认是60 0000,10分钟。

# 计算是否繁忙的时间窗口,单位毫秒,默认值是30 000,5分钟
hbase.busy.policy.aggWindow

用以控制计算的频率,计算该Region是否繁忙的计算方法:

  • 如果”当前时间-上次检测时间>=hbase.busy.policyaggWindow“,则进行如下计算:这段时间被阻塞的请求/这段时间的总请求=请求的被阻塞率(aggBlockedRate)。

  • 如果”aggBlockedRate>hbase.busy.policy.blockedRequests“,则判断该Region为繁忙。

  • 如果系统常常会出现热点Region,对性能要求比较高的话,可以采用此策略。它通过拆分热点Region来缓解热点Region的压力,但是根据热点来拆分Region也会带来很多不确定因素,就是不知道下一个被拆分的Region是哪一个。

  • (6)DisabledRegionSplitPolicy

    此策略只有一个方法:shouldSplit,且永远返回false。意思就是Region用不自动拆分。依然可以使用手动进行拆分。防止大量数据涌入时,可能出现的一边拆分一边写入大量数据的情况。由于Region拆分需要占用大量的IO,有可能对HRegionServer造成一定压力。如果事先就知道这个Table应该按怎样的策略来拆分Region的话,可以事先定义拆分点-SplitPoint。所谓拆分点就是拆分位置的Rowkey,可以按照26个字母定义25个拆分点,这样数据一到HBase就会被分配到各自所属的Region里面。这时候就可以把自动拆分关掉,只用手动拆分。手动拆分:预拆分和强制拆分。

9. 为什么HBase查询比较快

HBase查询快,主要原因是由其 架构和底层的数据结构 决定的,即 LSM(Log-Structured Merge Tree)+HTable(region分区)+Cache

客户端可以直接定位到要查数据所在的HRegion Server服务器,然后直接在服务器的一个Region上查找要匹配的数据,并且这些数据部分是经过Cache缓存的。

HBase会将数据保存到内存中,在内存中的数据是有序的,如果内存空间满了,会刷写到HFile中,而在HFile中保存的内容也是有序的。 当数据写入HFile后,内存中的数据会被丢弃 。HFile文件为磁盘顺序读取做了优化。

HBase的写入速度快是因为它其实并不是真的立即写入到文件中,而是先写入内存,随后异步刷入HFile。所以在客户端看来,写入速度很快。另外, 写入时候将随机写入转换成顺序写,数据写入速度也很稳定

。读取速度快是因为它使用了LSM树形结构,而不是B或B+树。磁盘的顺序读取速度很快,但是相比而言,寻找磁道的速度就要慢很多。HBase的存储结构导致它需要磁盘寻道时间在可预测范围内,并且读取与所要查询的Rowkey连续的任意数据的记录都不会引发额外的寻道开销。比如有5个存储文件,那么最多需要5次磁盘寻道就可以。而关系数据库,即时使用索引,也无法确定磁盘寻道次数。而且,

HBase读取首先会在缓存(BlockCache)中查找,它采用了LRU(最近最少使用算法),如果缓存中没找到,会从内存中的MemStore中查找,只有这两个地方都找不到时,才会加载HFile中的内容

猜你喜欢:

Hive基础面试题总结

MapReduce和YARN基础面试题总结

HDFS基础面试题总结

数据中台从哪⾥来,要到哪⾥去?

nIzeUzu.png!mobile


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK