这道题其实问的是,多轮对话在工程落地时,怎么平衡状态管理的灵活性和高并发下的性能跟一致性。我会从状态分层、存储选型、缓存策略和一致性保障几个角度来讲,重点说一下落地时的取舍。
先说状态分层。我一般分成三层:会话级、用户级和全局级。会话级就是当前这一轮对话的临时状态,像槽位、上下文,TTL设个5到30分钟,用Redis存,过期自动清理。用户级是长期偏好、历史意图,需要持久化,我会用TiDB或MySQL,读的时候加一层本地缓存,写走异步。全局级是热点配置或A/B实验参数,量小但所有节点都要读,所以本地缓存加广播更新,确保一致性。三层分开的好处是,不同生命周期的数据用不同的存储策略,避免把高频会话数据和低频用户画像混在一起。
存储选型上,活跃会话我首选Redis Cluster,用Hash结构,field就是session_id,这样单会话内的操作都是O(1)。历史消息用MongoDB或ES,按user_id分片,方便回溯。用户画像用TiDB,支持强一致性,但写操作我会异步化,因为画像更新不要求实时,读的时候本地缓存兜底。
高并发场景下,缓存是重中之重。我会做两级缓存:L1用Caffeine本地缓存,命中率能做到90%以上,过期1分钟;L2是Redis。如果L1没命中,再去查Redis,同时做穿透防护,布隆过滤器拦截掉不存在的session_id,避免缓存击穿。会话数据序列化用Protobuf,压缩率高,网络开销小。
一致性保障是难点。我的原则是:同一session的所有请求由单线程处理,通过Kafka按session_id分区,保证顺序。但分布式环境下,并发写还是可能冲突,所以我会加分布式锁,用Redisson可重入锁,锁粒度就是session_id,超时5秒自动释放。另外,CAS乐观锁加版本号,写操作时比较版本号,冲突就重试,这样不用一直持锁。
举个例子,客服退款场景。用户说“我要退单”,系统记录意图和订单号,然后用户又说“改成退运费”,这时候就要更新槽位。如果两个请求同时进来,版本号机制能保证只有一个生效,另一个重试后拿到最新状态,避免状态错乱。
这里有个坑:对话历史不能全量存,否则Redis内存会炸。我会用[[滑动窗口]],保留最近10轮,再加一个关键槽位摘要,比如用户确认过的意图、必填槽位。会话结束或空闲30秒后,异步持久化到DB。
落地的前提是,业务能接受会话状态最终一致,而不是强一致。如果要求强一致,比如金融交易场景,那这套方案就不够,得用分布式事务,但那样性能会下降。常见失败场景是,缓存和数据库不一致,比如会话还没持久化就挂了,导致用户重连后对话上下文丢失。所以上线我会特别关注持久化的延迟和失败重试,监控会话丢失率。
另外,异步持久化有个取舍:频率高了影响性能,低了丢失风险大。我会根据业务容忍度调整,比如客服场景容忍几分钟丢失,但交易场景可能不行。这个平衡点怎么找,是一个值得深入的点。
所以总的来说,我更倾向把会话状态管理看成分层缓存加异步持久化的权衡,核心是保证高并发下的低延迟和最终一致性,同时通过锁和版本号避免脏数据。