最近回想蜘蛛网,我负责抢购秒杀系统,那时已经遇到瓶颈,即使rabbitmq改成kafka的的消息队列,也无法突破10倍以上的秒杀速度。
原因在于单机数据库,以下是水平拆分订单表的设计。
那么一般设计水平拆分表要注意什么呢?
1、id编号的处理,保证各个DB都不重复,通过ID可以快速定位到具体在哪一个数据库里
2、id某个很小的时间段里也能在各个DB里分布均匀
3、根据另一个维度查列表,分页的处理,分页包括有序和无序等情况
4、扩容处理,就是增加一个节点DB数据库
现在有一个服务,提供订单增加,查询等功能,
订单表有:订单id,产品名称,用户id,创建时间,订单状态等属性,
查询根据订单id,用户id,创建时间等纬度,也是同样要求每秒10万条记录
一、订单号生成规则依旧是时间戳+机器码+序列号
ExecutorService pool=Executors.newFixedThreadPool(8);
final AtomicInteger seq=new AtomicInteger(Integer.MIN_VALUE);//3位 0-999
Map<String, String> uidMap=new ConcurrentHashMap<String, String>();
@Test
public void testSort() throws InterruptedException{
long s=System.currentTimeMillis();
//时间戳+机器码+seq (保证同一台机器一毫秒内“seq ”不重复)
for (int i = 1; i <= 200; i++) {
pool.execute(new Runnable() {
@Override
public void run() {
String ss=getSeq();
if(uidMap.containsKey(ss)){
System.out.println(ss+"异常重复"+uidMap.get(ss)+"-"+Thread.currentThread().getName());
return;
}else{
uidMap.put(ss, Thread.currentThread().getName());
}
}
});
}
pool.awaitTermination(3, TimeUnit.MILLISECONDS);
System.out.println("耗时"+(System.currentTimeMillis()-s)+",size="+uidMap.size());
for (Entry e : uidMap.entrySet()) {
System.out.println(e.getKey());
}
}
private String getSeq(){
String strSeq=String.valueOf(seq.addAndGet(1)%1000);//999
if(strSeq.length()<3){
strSeq="000".substring(0,3-strSeq.length())+strSeq;
}else if(strSeq.length()>3){
strSeq=strSeq.substring(1,4);
}
return new Date().getTime()+"-"+"3"+"-"+strSeq;
}
二、如何存
我们把数据切分成虚拟的1000份
机器0包含1-500
机器1包含501-1000
例如订单号是14731681192103623
机器ID=Integer.parseInt("14731681192103623".substring(11))%1000=623,那么属于机器1的。
三、如何扩容
如果现在增加1台机器,编号是2的,不过最好建议2的倍数的,数据份数好分
可以设置
机器0包含1-350
机器1包含501-850
机器2包含351-500,851-1000
配置好后,机器2开始剪切机器0的351-500和机器1的851-1000的数据。
在机器2没有完全复制机器0,1的数据时,如果存在一条取余是360的数据,那么存放到新的机器2中,读是同时判断机器0和机器2是否包含,直到机器2完全复制结束。
四、查询
查询根据订单id,用户id,创建时间等纬度,支持分页
根据订单号这个不说了,直接hash找到
用户id和创建时间
这个需求解决方法有2种,
第一种是我推荐参考阿里巴巴的一个技术总监写的书描述的,查询每一个库的数据然后拼凑列表。
这里再区分 有排序 和 没排序 情况
例如需求:查询用户id=18的,创建时间在今日的订单列表的第二页数据,每一页是10个。那么怎么做?
(1)无需排序情况
横向取分页好处是大部分无需分别连接多个DB,见下图
@Test
public void testDistPage() throws Exception {
int[] distDataSize={4,8,9,1,7};//每个数据库记录个数
int pageNum=5;//取第几页
int pageSize=4;//每页个数
//需要计算的,每个db的开始位置和取的数量
DistPage[] dp=new DistPage[distDataSize.length];
//如果直到取pageNum时,每个DB能独立给出页,那么不用计算
int minCanPageNum=Integer.MAX_VALUE;
for (int dbsize : distDataSize) {
int tmp=dbsize/pageSize;
if(tmp<minCanPageNum){
minCanPageNum=tmp;
}
}
//即每个DB能提供minCanPageNum个页
if(minCanPageNum*distDataSize.length>=pageNum){
int dbi=(pageNum-1)%distDataSize.length;//选中第几个DB
int pcount=(pageNum-1)/distDataSize.length;//跳过的页
System.out.println("快速db-"+dbi+"查询第"+(pcount*pageSize+1)+"-"+(pageSize+(pcount*pageSize+1)-1));
return;
}else{
if(minCanPageNum>0){
//初始化dp
for (int j = 0; j < dp.length; j++) {
dp[j]=new DistPage();
dp[j].distDBID=j;
dp[j].startIndex=pageSize*(minCanPageNum-1);
dp[j].size=pageSize;
dp[j].remain=distDataSize[j]-pageSize*minCanPageNum;
dp[j].isNeed=false;
}
}else{
//初始化dp
for (int j = 0; j < dp.length; j++) {
dp[j]=new DistPage();
dp[j].distDBID=j;
dp[j].startIndex=0;
dp[j].size=0;
dp[j].remain=distDataSize[j];
dp[j].isNeed=false;
}
}
}
//开始计算部分DB带有提供不足,其他DB补的情况下
for (int i = minCanPageNum*distDataSize.length+1; i <=pageNum; i++) {
for (int j = 0; j < dp.length; j++) {
if(dp[j]!=null){
dp[j].isNeed=false;
}
}
//当前的头
int head=(i-1)%distDataSize.length;
//如果当前取整页不足,则下一个去补,直到一个循环
if(dp[head].remain<pageSize){
dp[head].startIndex+=dp[head].size;
dp[head].size=dp[head].remain;
dp[head].remain=0;
dp[head].isNeed=true;
//库存没就不需要此节点提供数据
if(dp[head].size<=0){
dp[head].isNeed=false;
}
int findNext=head;
int needGet=pageSize-dp[head].size;
do{
findNext=(findNext+1)%distDataSize.length;
if(dp[findNext].remain<=0)continue;//无法提供数据
if(dp[findNext].remain<needGet){
dp[findNext].startIndex+=dp[findNext].size;
dp[findNext].size=dp[findNext].remain;
dp[findNext].remain=0;
dp[findNext].isNeed=true;
needGet-=dp[findNext].size;
}else{
dp[findNext].startIndex+=dp[findNext].size;
dp[findNext].size=needGet;
dp[findNext].remain-=needGet;
dp[findNext].isNeed=true;
break;
}
}while(head!=findNext);
}else{
dp[head].startIndex+=dp[head].size;//加上一个的size得出当前start
dp[head].size=pageSize;
dp[head].remain-=pageSize;
dp[head].isNeed=true;
}
for (DistPage distPage : dp) {
if(distPage!=null&&distPage.isNeed)
System.out.println("查询第 "+i+"页的db-"+distPage.distDBID+"查询第"+distPage.startIndex+"-"+(distPage.size+distPage.startIndex-1));
}
System.out.println();
}
for (DistPage distPage : dp) {
if(distPage!=null&&distPage.isNeed)
System.out.println("db-"+distPage.distDBID+"查询第"+distPage.startIndex+"-"+(distPage.size+distPage.startIndex-1));
}
}
class DistPage{
public int distDBID;
public int startIndex;
public int size;
public int remain;
public boolean isNeed;
}
(2)有排序情况
例如每页10个,取第11-20条记录,那么每个DB取1-20条,然后组合一起排序,取11-20条记录即可
这里排序可能有多个, 比如按这个排序 id,name desc,orderTime
下边是排序简单处理,逻辑是id相同,判断name,name相同判断orderTime
/**
* @param list
* @param ors
* added by cruze(penkee@163.com) at 2014-9-24
*/
private void sort(List<Map<String, Object>> list, String[] ors) {
// TODO Auto-generated method stub
for (int i = 0; i < list.size() - 1; i++) {
for (int j = i + 1; j < list.size(); j++) {
Map<String, Object> a = list.get(i);
Map<String, Object> b = list.get(j);
if (isChange(a, b, ors, 0)) {
list.set(i, b);
list.set(j, a);
}
}
}
}
/**
* 指示是否换,递归
* @param a
* @param b
* @param ors
* @return
* added by cruze(penkee@163.com) at 2014-9-24
* @throws SecurityException
* @throws NoSuchMethodException
* @throws InvocationTargetException
* @throws IllegalArgumentException
* @throws IllegalAccessException
*/
boolean isChange(Map<String, Object> a, Map<String, Object> b,
String[] ors, int i) {
if (i > ors.length - 1)
return false;
String o1 = ors[i];
boolean isDesc = false;
if (o1.toLowerCase().contains(" desc")) {
isDesc = true;
}
String key = o1.replace(" desc", "").trim().toUpperCase();
Object aV = a.get(key);
Object bV = b.get(key);
int res=0;
try {
Method method=aV.getClass().getMethod("compareTo", aV.getClass());
res = (Integer)method.invoke(aV, bV);
} catch (Exception e) {
return false;
}
if (res < 0) {
return isDesc;
} else if (res == 0) {
return isChange(a, b, ors, ++i);
} else {
return !isDesc;
}
}
第二种再创建一个纬度,如果频繁操作的话,但要注意数据同步。
2016-9-20
实践操作下发现问题了,因为订单号最后是seq,所以分布非常不均匀,导致一段时候压力都在同一个数据库上
由于订单号是对1000取余的,所以设计倒数第三位是随机数,然后插在时间中间,把strSeq减少一位。保证订单号长度不变
private String getSeq(String machineId){
String strSeq=String.valueOf(Math.abs(seq.addAndGet(1))%100);//0-99
if(strSeq.length()<2){
strSeq="00".substring(0,2-strSeq.length())+strSeq;
}
String time=String.valueOf(System.currentTimeMillis());
return machineId+"-"+strSeq+"-"+time.substring(0,time.length()-2)+"-"+new Random().nextInt(10)+"-"+time.substring(time.length()-2);
}
结果如下:
可以看出来分布比较均匀了。
https://github.com/penkee/fruitstall