今天想测试一下疼讯云的垃圾服务器,用python脚本测试接口,共计5000次,本来只是想看看服务器的处理速度,但是在脚本中的测试数据是全部写死了的,这5000次请求理论上应该只有1次是成功的,但是结果却是有13次成功,因为我在数据库中的表是把每个数据都设置成了主键,所以最终只增加了1条数据。但是为什么响应成功的次数不是1次呢?嗯,肯定是多个线程同时操作导致的,经查阅,加锁可以解决此类问题,于是决定研究一下线程锁这个东西。
部分代码,只展示实现类
请求体:
/**
* 控制器中添加用户请求数据.
* </p>
*
* @author xuLiang
* @since 0.0.1
*/
public class VoAddUserRequest {
private Long id;
private String password;
private String name;
private String email;
getters and setters..
}
DAO层:
/**
*
* 添加一条用户信息记录.
*
* @param Users
* 待添加的用户信息
* @return 添加完成的用户信息对象
* @see param.position.dao.UsersMapper#addUser(param.position.pojo.po.mysql.tables.pojos.Users).
* @author xuLiang
* @since 0.0.1
*/
@Override
public Users postUser(Users users) {
UsersRecord userRecord = new UsersRecord();
userRecord = dsl.newRecord(USERS);
userRecord.from(users);
int addResult = userRecord.store();
if (addResult == 1) {
return userRecord.into(Users.class);
}
return null;
}
Controller接口
/**
*
* 添加一个用户信息记录.
*
* @param voAddUserRequest
* 需要添加的用户信息记录
* @return 封装用户信息的统一响应对象
*
* @author xuLiang
* @since 0.0.1
*/
@PostMapping("/users")
@ResponseStatus(HttpStatus.CREATED)
@Override
@CustomAnnotation
public @ResponseBody GetUserPersonResp postUser(@RequestBody VoAddUserRequest voAddUserRequest) {
GetUserPersonResp result = new GetUserPersonResp();
result.setCode(1);
result.setMessage("添加成功!");
result.setVoGetUserResponse(VoUserPersonMapper.INSTANCE.fromBoToVoGetUserResponseMap(
userPersonService.postUser(VoUserPersonMapper.INSTANCE.fromVoToBoAddUserRequestMap(voAddUserRequest))));
return result;
}
先说一说为什么会产生这种现象,原因很简单,当用户每发送一次请求,服务器便会新开一个线程来处理这个用户的请求,当服务器检测到用户发送的请求体与数据库中的主键不冲突的时候,便会将用户发送的数据写入数据库,但是当多个用户同时发送相同的数据的时候(脚本运行速度极快,可以近似认为是同一时间发出请求),此时多个线程同时对数据库进行检测(此时还没有线程对数据库进行操作),主键并未冲突,所以都返回了成功的结果,但事实上只要有一个线程完成了数据库的操作之后,其他线程的操作统统都是失败的。为了解决这个问题,就要引入锁(synchronized)的机制。
public synchronized Users postUser(Users users)
锁的作用,简单点说,就是将数据库的表锁住,当一个线程完成了整个操作流程并且销毁之后,才会允许下一个线程进行操作,这样在理论上可以保证只有第一个请求可以给用户返回成功的信息,但是后果就是其他用户会等待很久才会收到返回结果,因为这些线程都要排队一个一个来操作,这就会导致大量线程处于等待状态,也就是线程阻塞,就是我们平时所说的网站崩了。
这时候就需要使用到线程池,作用就是有请求过来了就到线程池里面取出一条线程去处理它,处理完成就把它收回到线程池里面,Java通过Executors接口提供四种线程池,分别为:
newCachedThreadPool创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程;
newFixedThreadPool 创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待;
newScheduledThreadPool 创建一个定长线程池,支持定时及周期性任务执行;
newSingleThreadExecutor 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行;
线程以及线程池的具体使用方法可直接百度,此处不再赘述。
但是在Java中,synchronized的思想是悲观锁。悲观锁总是假设最坏的情况,每次取数据时都认为其他线程会修改,所以都会加锁(读锁、写锁、行锁等),当其他线程想要访问数据时,都需要阻塞挂起。可以依靠数据库实现,如行锁、读锁和写锁等,都是在操作之前加锁。
这对于处理大量请求来说显然不是最佳方法,这时候就引入了乐观锁:
总是认为不会产生并发问题,每次去取数据的时候总认为不会有其他线程对数据进行修改,因此不会上锁,但是在更新时会判断其他线程在这之前有没有对数据进行修改,一般会使用版本号机制或CAS操作实现。
version方式:一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加一。当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。
核心SQL代码:
update table set x=x+1, version=version+1 where id=#{id} and version=#{version};
CAS操作方式:即compare and swap 或者 compare and set,涉及到三个操作数,数据所在的内存值,预期值,新值。当需要更新时,判断当前内存值与之前取到的值是否相等,若相等,则用新值更新,若失败则重试,一般情况下是一个自旋操作,即不断的重试。
当然还有一种最直接的方法,就是将数据库换为Redis。与MySQL和Oracle等数据库不同,由于Redis进行操作的数据都是缓存在内存中,处理速度远远高于SSD硬盘,机械硬盘就更不用说了,但是一旦开发人员操作不当就会导致数据丢失,而且无法复原,所以建议使用单独的数据库服务器,并且对程序员的水平要有一定要求。
使用乐观锁+Redis基本上就能应付绝大多数情况了,当然遇到双十一这种情况还是有可能崩的(反正阿里有钱,大不了再修个机房就完了)。。。