讲讲Mongodb的24位ObjectId的无损压缩


一、问题背景

        工作中,需要开发一个第三方接口用来同步数据,接口要求数据 ID 为字符串,且最长为16个字符,但是我公司保存数据使用的是Mongodb,其ID是ObjectId类型的,将其实例化为String时是24个字符,显然这个问题需要解决。

        解决方法想到了几个:

1.比如再增加一个ID字段专门用于接口,而原有业务仍使用ObjectId;

2.或者另外再增加一个ID映射表。

3.将24位OjbectId截取某一段。

        无疑前两种解决方案都需要在原有代码上进行修改,不管是额外维护一个字段还是一张表,都是非常麻烦的。而第三种方案会遇到一个问题就是两种ID无法互相转换。完美的解决方案是将24位的ObjectId压缩为一个16位或更少的ID,并且可以转换回来。

        要压缩OjbectId,就要先了解OjbectId是什么以及是怎么生成的:(参考:http://www.cnblogs.com/xjk15082/archive/2011/09/18/2180792.html )


====================我是引用开始分割线========================

举个例子:OjbectId(“4e7020cb7cac81af7136236b”)这个24位的字符串,实际上它是由一组十六进制的字符构成,每个字节两位的十六进制数字,总共用了12字节的存储空间。官网中对ObjectId的规范,如图所示。

1)     Time

时间戳。将objectid的前4位进行提取“4e7020cb”,然后按照十六进制转为十进制,变为“1315971275”,这个数字就是一个时间戳。通过时间戳的转换,就成了易看清的时间格式,如图3所示。

图3 时间戳的转换

2)    Machine

机器。接下来的三个字节就是“7cac81”,这三个字节是所在主机的唯一标识符,一般是机器主机名的散列值,这样就确保了不同主机生成不同的机器hash值,确保在分布式中不造成冲突,这也就是在同一台机器生成的objectId中间的字符串都是一模一样的原因。

3)    PID

进程ID。上面的Machine是为了确保在不同机器产生的objectId不冲突,而pid就是为了在同一台机器不同的mongodb进程产生了objectId不冲突,接下来的“af71”两位就是产生objectId的进程标识符。

4)    INC

自增计数器。前面的九个字节是保证了一秒内不同机器不同进程生成objectId不冲突,这后面的三个字节“36236b”是一个自动增加的计数器,用来确保在同一秒内产生的objectId也不会发现冲突,允许256的3次方等于16777216条记录的唯一性。

总的来看,objectId的前4个字节时间戳,记录了文档创建的时间;接下来3个字节代表了所在主机的唯一标识符,确定了不同主机间产生不同的objectId;后2个字节的进程id,决定了在同一台机器下,不同mongodb进程产生不同的objectId;最后通过3个字节的自增计数器,确保同一秒内产生objectId的唯一性。ObjectId的这个主键生成策略,很好地解决了在分布式环境下高并发情况主键唯一性问题,值得学习借鉴。


====================我是引用结束分割线========================


        上面引用了不少,其中有一个非常重要的信息是它是由一组十六进制的字符构成,24个字符,换句话说就是总共占用12字节,也就是96bit,每一个字符占用4bit。

        由此,反正总共是96bit,假如用32进制来表示,也就是每5个bit用一个字符来表示,不就可以缩短字符串长度了么,可以缩短到 96 / 5 = 19.2 个字符,悲剧,还是不够。正面算得算到什么时候啊,还是反过来吧,要求是用16个字符来表示这96bit信息,那么一个字符就要能表示 96 / 16 = 6bit 的内容,仔细看看,一个字符表示6bit内容这不就是 2 ^ 6bit = 64 进制么,就像 2  ^ 1bit = 2 进制是用一个字符(‘0’或者‘1’)来表示1bit内容,2 ^ 4bit = 16 进制用一个字符来表示4bit内容么。

        16进制需要(0~9 a~f)16个符号来表示,64进制就需要64个符号,(0~9 a~z A~Z)加起来才62个符号,还少两个,可以用标点符号么,这个就要参考Base64编码(Wiki:http://zh.wikipedia.org/wiki/Base64 )了,一句话解释就是“Base64是一种基于64个可打印字符来表示二进制数据的表示方法。”。

        还没完,我们的接口中id是放在URL中传输的,标准的Base64并不适合直接放在URL里传输,因为URL编码器会把标准Base64中的“/”和“+”字符变为形如“%XX”的形式,而这些“%”号在存入数据库时还需要再进行转换,因为ANSI SQL中已将“%”号用作通配符。
因此,可采用一种用于URL的改进Base64编码,将标准Base64中的“+”和“/”分别改成了“-”和“_”,这样就免去了在URL编解码和数据库存储时所要作的转换,避免了编码信息长度在此过程中的增加,并统一了数据库、表单等处对象标识符的格式。


二、具体实现

        还是以上面的例子来看,ObjectId为“4e7020cb7cac81af7136236b”,由于是将24个字符压缩成16个字符,也就是每3个字符压缩成2个字符,因此我们取前3个字符作为示例:

1、16进制:'4' 'e' '7'

2、其中e表示的是14,所以三个字符转换为2进制形式就是:0100,1110,0111

3、上面每4bit便表示了一个字符,现在要转换成6bit表示一个字符,也就是 (0100<<2)+(1110>>2)=010011,(1110&0x3)<<4+0111=100111

4、通过移位和相加操作,就将3个4bit的信息保存到2个6bit中了,现在将这两个6bit用10进制来表示就是19,39

5、将19,39转换为64进制的字符表示:‘j',’D‘

        通过以上步骤就将“4e7”转换为“jD”了,同样的道理,将以上步骤反过来运行就可以将“jD”转回“4e7”了。

        重复以上步骤,可以将“4e7020cb7cac81af7136236b”转换为“jD0wOTOIwqZNdydH”。


        我相信程序君们更喜欢直接看代码,下面就贴上上述算法的java实现:

/**
 * @author xumeng
 *
 */
public class ObjectIdConverter {
	
	/**
	 * 将 64进制编码长度为16位的ID 转换为 16进制编码的长度为24位的ID
	 * @param qunarHotelId
	 * @return
	 */
	public static String unCompressObjectId(String shortId){
		if(shortId == null || shortId.length() != 16){
			throw new IllegalArgumentException();
		}
		
		StringBuilder res = new StringBuilder(24);
		char[] str = shortId.toCharArray();
		for(int i = 0; i < str.length; i += 2){
			int pre = char2Int(str[i]),end = char2Int(str[i+1]);
			res.append(int2Char( (pre >> 2) ));
			res.append(int2Char( ((pre & 3) << 2) + (end >> 4) ));
			res.append(int2Char( end & 15 ));
		}
		return res.toString();
	}
	
	/**
	 * 将 16进制编码的长度为24位的ID 转换为 64进制编码长度为16位的ID
	 * @param tdxHotelId
	 * @return
	 */
	public static String compressObjectId(String objectId){
		if(objectId == null || objectId.length() != 24){
			throw new IllegalArgumentException();
		}
		
		StringBuilder res = new StringBuilder(16);
		char[] str = objectId.toCharArray();
		for(int i = 0; i < str.length; i += 3){
			int pre = char2Int(str[i]),mid = char2Int(str[i+1]),end = char2Int(str[i+2]);
			res.append(int2Char( (pre << 2) + (mid >> 2) ));
			res.append(int2Char( ((mid & 3) << 4) + end ));
		}
		return res.toString();
	}
	
	/**
	 * 支持64进制bit转字符
	 * 0~9,a~z,A~Z,-,_
	 * @param i
	 */
	private static char int2Char(int i){
		if(i >= 0 && i <= 9){
			return (char) ('0'+i);
		}else if(i >= 10 && i <= 35){
			return (char) ('a'+i-10);
		}else if(i >= 36 && i <= 61){
			return (char) ('A'+i-36);
		}else if(i == 62){
			return '-';
		}else if(i == 63){
			return '_';
		}else{
			throw new IllegalArgumentException();
		}
	}
	
	/**
	 * 支持64进制字符转bit
	 * 0~9,a~z,A~Z,-,_
	 * @param c
	 */
	private static int char2Int(char c){
		if(c >= '0' && c <= '9'){
			return c-'0';
		}else if(c >= 'a' && c <= 'z'){
			return 10+c-'a';
		}else if(c >= 'A' && c <= 'Z'){
			return 36+c-'A';
		}else if(c=='-'){
			return 62;
		}else if(c=='_'){
			return 63;
		}else{
			throw new IllegalArgumentException();
		}
	}
}



三、附:位操作符


或操作符:| ,示例:5 | 3 = 0101b | 0011b = 0111b = 7

非操作符:~ ,示例: ~5 = ~0000...0101b = 1111...1010b = -6

异或操作符: ^ ,示例: 5 ^ 3 = 0101b ^ 0011b = 0110b = 6

与操作符: & ,示例: 5 & 3 = 0101b & 0011b = 0001b = 1

左移操作符:<< ,示例:5 << 35 = 0101b << ( 35%32) = 0101b << 3 = 0010 1000 = 40

算数右移:>> ,示例1: 5 >> 2 = 0101b >> 2 = 0001b = 1 ;示例2:-5 >> 2 = 1111...1011b >> 2 = 1111...1110b  = -2

逻辑右移:>>> ,示例:-5 >>> 2 = 1111...1011b >>> 2 = 0011...1110b = 1073741822




你可能感兴趣的:(mongodb)