session、token和线程安全

问题场景

有时会发生用户重复提交的问题,引起重复提交的原因有很多,比如用户多次点击、刷新、网络问题、浏览器问题等。

不论什么原因,重复提交会引起严重的逻辑错误,比如账户多次扣款、多次充值等,所以要通过技术手段避免造成破坏。

简单分析

  • 对于用户多次点击的情况,可以用js代码,在ajax请求时将按钮置灰,避免重复点击。在App中也是类似的操作。
  • 对于网络问题、浏览器问题、黑客故意操作问题,需要通过token解决。

token解决方案

在客户端Get时,服务端生成一个随机码token,将token以hidden字段的形式携带在返回的form中,同时在session中记录这个token。

在客户端Post时,token将随着form一起提交,在服务端获取session并检查token状态,如果token存在,说明未被使用,则执行form提交的操作,然后删除token。

当重复的Post到达服务端,由于token已经使用并删除了,就会token无效,返回错误信息。

更好的用户体验

重复Post时,服务端向客户端返回错误信息,用户就会看到。但是这个错误往往是因为网络、浏览器或其他问题引起的,用户会感觉莫名其妙。

可以在第一次Post到达时,执行完正常的逻辑后,返回一个重定向,这样让用户在另一个页面读取Post的结果。当第二个Post到达时,就不需要返回错误了。这样用户体验会更好,实际上很多页面也正是这样做的。

潜在的线程安全问题

在高并发情况下,还是上面的token解决方案,如果同一个用户的多个后续非法Post(SessionID相同而)到来,而这时第一个合法的Post处理刚刚读取了token还没来得及删除,第二个Post也读取了token,就会发生多次form被执行的情况。

但是实际上,上面说的这种线程安全问题很少发生,为什么呢? 因为文件操作充当了Mutex锁。

例如在ThinkPHP中默认的session都是存储于文件。当一个请求到来时,会以读写方式(如果不存在则先创建)访问一个文件(文件名一般就是sessionID),并采用排它的方式占用文件。这时新的同sessionID的请求会由于文件权限而被阻塞,所以上面说的线程安全问题并不会发生。

但是,如果将session存储于Redis等其他位置时(读Beego源码也确认了这一点),这个自动的Mutex锁就未必存在了,这时就要注意一下。如果并发访问量较大,为了逻辑不出问题,最好加上一个Mutex。

这个锁要加在对应的sessionID绑定的对象上,这样避免不同用户之间存在锁冲突。而同一个用户由于重复Post发生毕竟是少数情况,所以这个锁很少起作用,性能就不会下降多少。

衍生问题:ajax性能问题

我们在使用ajax时,ajax请求中携带着用户的sessionID,那么多个ajax请求之间、用户的操作之间就会发生锁竞争。虽然ajax从其名字看来是异步操作,可以多个ajax同时执行,但是从上面的分析可以看出,实际上是顺序执行的,在开发中如果遇到速度瓶颈,可以考虑一下这个问题。

可能的一个解决方案:建立一个token系统,token的读取和写入要原子化操作,避免后边到来的相同token的请求被执行。这样即防止了重复提交,又可以多个独立ajax请求并行执行。