简介
我供职的在上家是一家物流公司,我主要从事架构方面的工作,但偶尔也会写一些跟算法相关代码。项目组曾一个查找全路径的需求,系统现在的方法是循环查找数据库,迭代出所有数据。但数据量过多,且方法调用过于频繁,给数据库造成了严重的负担。于是我便用简单的数据结构加算法重新实现了。
以下是系统中的数据表:
station (站点表)
id | name |
---|---|
1 | 上海 |
2 | 昆山 |
3 | 苏州 |
4 | 常州 |
5 | 镇江 |
6 | 南京 |
7 | 扬州 |
8 | 西安 |
9 | 郑州 |
站点表用于记录所有分拔与网点的信息
route (路径表)
station | destination_station | next_station | status |
---|---|---|---|
1 | 6 | 2 | 1 |
2 | 6 | 3 | 1 |
3 | 6 | 4 | 1 |
4 | 6 | 5 | 1 |
5 | 6 | 6 | 1 |
7 | 6 | 6 | 1 |
1 | 8 | 2 | 1 |
2 | 8 | 3 | 1 |
3 | 8 | 4 | 1 |
4 | 8 | 5 | 1 |
5 | 8 | 6 | 1 |
7 | 8 | 6 | 1 |
6 | 8 | 9 | 1 |
9 | 8 | 8 | 1 |
路由信息的基础数据表,用于记录从当前网点(station)前往目前网点(destination_station)的下一网点(next_station)。以及这个路由的启动状态(status ,1,启动,2关闭)
要生成一条完整的路由。为了缓解数据库压力,加快查询速度。决定将原数据库查询更换成纯算法实现。
解决思跑路
定义数据结构,及其算法。利用Map进行快速查找。
数据结构
public class Station<T> implements Serializable {
private Serializable id;
private String name;
private T extra;
private final Map<Serializable,Path> pathMap = new HashMap<>();
....getter and setter...
/**
* 连接两个station
* @param destination
* @param next
* @param extra
* @param <E>
*/
public <E> void linkTo(Station destination, Station next, E extra){
Path p = pathMap.get(destination.getId());
if(p==null){
p = new Path<>(this);
}else{
detach(destination);
}
p.setExtra(extra);
p.setDestination(destination);
p.setNext(next);
pathMap.put(destination.getId(),p);
Path nextPath = next.getPath(destination);
if(nextPath==null){
nextPath = new Path(next);
nextPath.setDestination(destination);
next.pathMap.put(destination.getId(),nextPath);
}
nextPath.addPrevious(this);
}
/**
* 获聚首当前路是径
* @param des
* @return
*/
public Path getPath(Station des){
return pathMap.get(des.getId());
}
public void detach(Station des){
Path path = pathMap.get(des.getId());
if(path!=null){
path.removePrevious(this);
}
if(path.getPrevious().size()==0) {
pathMap.remove(des.getId());
}else{
path.setNext(null);
}
}
}
@Data
public class Path<T> {
private T extra;
private Station destination;
private Station cur;
private Station next;
private List<Station> previous = new ArrayList<>();
public Path(Station cur) {
this.cur = cur;
}
public void addPrevious(Station station){
if(!previous.contains(station)){
previous.add(station);
}
}
public void removePrevious(Station station){
previous.remove(station);
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("[");
for (Station station : previous) {
sb.append(",");
sb.append(station.getId());
}
sb.append("]");
return "Path{" +
" cur=" + cur.getId() +
", destination=" + (destination!=null?destination.getId():"") +
", next=" + (next!=null ? next.getId():"") +
", previous=" + sb.toString() +
'}';
}
}
解决并发问题加锁方案
PathFinder类主要是用来存储所有站点的集合,以及提供所有路径想着的操作(可能我要重新命名),因为业务中要根据网点id查询网点(或路由信息),所以用map来存储网点。路径信息可能随时会出变动,而每段路径的变动,都会影响到整个路由。所以我在此处用到了读写锁,每段路径的变动,只会影响到与其相同终点的路径,我让每个Station都持有一个读锁和写锁。在做读取或更改操作时,我获取目的节点上的锁,并对其进行加锁操作(这种不锁全部,只锁部门的思想在LongAdapter和ConcurrentMap中都有应用)。
@Service
public class PathFinder<T,E> {
ConcurrentSkipListMap<Serializable,Station<T>> stationMap = new ConcurrentSkipListMap();
public void link(Station curt, Station des, Station next, E extra){
if(curt == next){
throw new IllegalStateException("当前网点与下一网点不可以相同");
}
ReentrantReadWriteLock.WriteLock lock = des.writeLock();
lock.lock();
try {
/**
* 验证是否存在死循环
*/
List<Path<E>> route = findRoute(next, des);
for (Path<E> p : route) {
if(p.getNext()==curt||p.getCur() == curt){
throw new IllegalStateException("网点存在死循环。");
}
}
curt.linkTo(des, next, extra);
}finally {
lock.unlock();
}
}
public List<Path<E>> findRoute(Station begin,Station des){
int len = 0;
List<Path<E>> list = new ArrayList<>();
ReentrantReadWriteLock.ReadLock lock = des.readLock();
lock.lock();
try{
Path p;
Station station =begin;
while((p=station.getPath(des))!=null && (station=p.getNext())!=null){
list.add(p);
if((len++)==100){
throw new IllegalStateException("链路太长,可能出现死路:"+list);
}
}
}finally {
lock.unlock();
}
return list;
}
}
数据全加载时遇到的问题
原计划是在程序启动时,加载整张表数据。但测试之后,2000万条数据,加载速度很慢,之少要20分钟之久。
延迟加载及并行加载
数据全加载保证了用户访问时的查询速度,以及线程安全等问题。但增长了系统启动时间。
懒加载不影响系统启动时间。但可能会带来两个问题:
- 多线程下数据重复加载问题,比如有几个请求同时请求上海南京的节点,可能造成两个线程同时加载数据。
- 可能会降低查询速度,用户第一次请求查询某路径时,因为要从数据库加载数据,可能会出现延迟。
-
状态控制及加锁
为了避免数据重复加载,可以对每个节点添加一个状态,表示以此节点为目的节点的数据是否已加载,如果未加裁,可以使用tryLock试获此节点的锁,如果获取成功,则加载数据据,否则进行自旋,不断获取加载状态。一旦数据另载成功,便返回。
-
并行加载
懒加载是指在在需要数据时再加载数据(从数据库中读取所有目的网点的数据),在访问峰值时,会造成类似于缓存的雪崩现像。所以采用预加载与懒加载并行的方式,在程序启动时,另开一线程加载数据,这样不会影响到项目的启动时间。同时由于上面的状判断及懒加载的方案,也能在数据未能加载完成时,对外提供服务。
后继方案
目前2000个网点,生成的节点数所约500万条,得用jmap查看内存发现,总共占用内存不到1G, 如果以后再增加网点数,可以考虑LRU策略进行淘汰,目前我存储网点用的的是ConcurrentSkipListMap,如果要实现LRU策略,可能要换成HashLinkedMap实现。当然我也可以用数据分片,不管是LRU或数据分片,都可以利用目的网点对数据进行分割。这要比传统的那种寻找最小路径算法简单多了(因为如果是传统的图,我想不到什么好的办法,对数据进行分割)。因为不想增加复杂性,我只对该服务进行单节点部署(多节点部署要考虑数据一致性问题,而且当前拥有有的服务器资源也不适做多节点部署),以后如果要做节点部署,可以考虑应用数据一致性算法,加乐观锁机制。
总结
算法很简单,应用的技术也不算太复杂,关键是选择合适的方案来解决当前的问题。